支持日期化仓位调整回放

This commit is contained in:
boris
2026-09-02 18:15:07 +08:00
parent d014bb2fbd
commit 1215a04b7d
3 changed files with 115 additions and 1 deletions
+10
View File
@@ -2,6 +2,16 @@
面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。
## Runtime position exposure schedule
`strategy_spec.runtimeExpressions.risk.positionExposureSchedule` accepts dated
`effectiveDate` plus `targetExposureBps` points. The platform expression strategy
uses the latest point whose date is not later than the current execution date and
otherwise keeps the strategy's normal `exposureExpr`. This contract is intended for
audited runtime controls replayed by paper/live shadow reconciliation; it is not a
market-data signal and does not change selection, pricing, fees, or execution-day
risk checks.
## 当前能力
- 日频和分钟执行价策略生命周期与确定性回放。
+37 -1
View File
@@ -388,6 +388,7 @@ pub struct PlatformExprStrategyConfig {
pub stock_filter_expr: String,
pub buy_scale_expr: String,
pub exposure_expr: String,
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
pub stop_loss_expr: String,
pub take_profit_expr: String,
@@ -460,6 +461,7 @@ impl PlatformExprStrategyConfig {
stock_filter_expr: String::new(),
buy_scale_expr: "1.0".to_string(),
exposure_expr: "1.0".to_string(),
position_exposure_schedule: BTreeMap::new(),
portfolio_drawdown_control: None,
stop_loss_expr: String::new(),
take_profit_expr: String::new(),
@@ -555,6 +557,16 @@ fn band_low(index_close) {
}
}
fn scheduled_position_exposure(
schedule: &BTreeMap<NaiveDate, f64>,
decision_date: NaiveDate,
) -> Option<f64> {
schedule
.range(..=decision_date)
.next_back()
.map(|(_, exposure)| exposure.clamp(0.0, 1.0))
}
#[derive(Default, Clone)]
struct ProjectedExecutionState {
execution_cursors: BTreeMap<String, NaiveDateTime>,
@@ -7588,9 +7600,15 @@ impl PlatformExprStrategy {
ctx: &StrategyContext<'_>,
day: &DayExpressionState,
) -> Result<f64, BacktestError> {
let risk_on_exposure = self
let strategy_exposure = self
.eval_float(ctx, &self.config.exposure_expr, day, None, None)?
.clamp(0.0, 1.0);
let risk_on_exposure = scheduled_position_exposure(
&self.config.position_exposure_schedule,
ctx.execution_date,
)
.unwrap_or(strategy_exposure)
.clamp(0.0, 1.0);
let Some(controller) = self.portfolio_drawdown_controller.as_mut() else {
return Ok(risk_on_exposure);
};
@@ -12488,6 +12506,7 @@ mod tests {
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode,
PlatformTradeAction, PlatformUniverseActionKind, RuntimeHelperResolution,
SelectionRiskDeferral, StockFilterQuoteUsage, framework_stock_rolling_factor_requirement,
scheduled_position_exposure,
};
use crate::{
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
@@ -12503,6 +12522,23 @@ mod tests {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
#[test]
fn dated_position_exposure_uses_the_latest_effective_point() {
let schedule = BTreeMap::from([
(d(2026, 8, 14), 0.6451),
(d(2026, 8, 20), 0.3225),
]);
assert_eq!(scheduled_position_exposure(&schedule, d(2026, 8, 13)), None);
assert_eq!(
scheduled_position_exposure(&schedule, d(2026, 8, 14)),
Some(0.6451),
);
assert_eq!(
scheduled_position_exposure(&schedule, d(2026, 8, 31)),
Some(0.3225),
);
}
#[test]
fn universe_exclude_matches_explicit_symbols_and_bjse_alias() {
let excludes = vec![
@@ -902,6 +902,8 @@ pub struct StrategyExpressionAllocationConfig {
pub struct StrategyExpressionRiskConfig {
#[serde(default)]
pub exposure_expr: Option<String>,
#[serde(default, alias = "position_exposure_schedule")]
pub position_exposure_schedule: Vec<StrategyPositionExposureSchedulePoint>,
#[serde(default)]
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
#[serde(default)]
@@ -916,6 +918,15 @@ pub struct StrategyExpressionRiskConfig {
pub stop_take_reference_price_mode: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyPositionExposureSchedulePoint {
#[serde(alias = "effective_date")]
pub effective_date: String,
#[serde(alias = "target_exposure_bps", alias = "effective_bps")]
pub target_exposure_bps: i32,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyPortfolioDrawdownControlConfig {
@@ -2035,6 +2046,34 @@ pub fn platform_expr_config_from_spec(
expr.clone()
};
}
for point in &risk.position_exposure_schedule {
let effective_date = NaiveDate::parse_from_str(
point.effective_date.trim(),
"%Y-%m-%d",
)
.map_err(|_| {
"runtimeExpressions.risk.positionExposureSchedule effectiveDate must use YYYY-MM-DD"
.to_string()
})?;
if !(0..=10_000).contains(&point.target_exposure_bps) {
return Err(
"runtimeExpressions.risk.positionExposureSchedule targetExposureBps must be between 0 and 10000"
.to_string(),
);
}
if cfg
.position_exposure_schedule
.insert(
effective_date,
f64::from(point.target_exposure_bps) / 10_000.0,
)
.is_some()
{
return Err(format!(
"runtimeExpressions.risk.positionExposureSchedule contains duplicate date {effective_date}"
));
}
}
if let Some(control) = risk.portfolio_drawdown_control.as_ref()
&& control.enabled.unwrap_or(true)
{
@@ -4003,6 +4042,35 @@ mod tests {
assert_eq!(cfg.stock_long_ma_days, 21);
}
#[test]
fn runtime_expression_parses_dated_position_exposure_schedule() {
let spec = serde_json::json!({
"runtimeExpressions": {
"risk": {
"exposureExpr": "1.0",
"positionExposureSchedule": [
{"effectiveDate": "2026-08-14", "targetExposureBps": 6451},
{"effective_date": "2026-08-20", "effective_bps": 3225}
]
}
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(
cfg.position_exposure_schedule
[&NaiveDate::from_ymd_opt(2026, 8, 14).unwrap()],
0.6451,
);
assert_eq!(cfg.position_exposure_schedule.len(), 2);
let invalid = serde_json::json!({
"runtimeExpressions": {"risk": {"positionExposureSchedule": [
{"effectiveDate": "2026-08-14", "targetExposureBps": 10001}
]}}
});
assert!(platform_expr_config_from_value("", "", &invalid).is_err());
}
#[test]
fn index_throttle_uses_signal_ma_not_performance_benchmark_ma() {
let spec = serde_json::json!({