diff --git a/README.md b/README.md index 0f684cd..d72ff47 100644 --- a/README.md +++ b/README.md @@ -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. + ## 当前能力 - 日频和分钟执行价策略生命周期与确定性回放。 diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index b622eae..98dd89c 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -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, pub portfolio_drawdown_control: Option, 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, + decision_date: NaiveDate, +) -> Option { + schedule + .range(..=decision_date) + .next_back() + .map(|(_, exposure)| exposure.clamp(0.0, 1.0)) +} + #[derive(Default, Clone)] struct ProjectedExecutionState { execution_cursors: BTreeMap, @@ -7588,9 +7600,15 @@ impl PlatformExprStrategy { ctx: &StrategyContext<'_>, day: &DayExpressionState, ) -> Result { - 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![ diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 3917c5c..d46d19c 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -902,6 +902,8 @@ pub struct StrategyExpressionAllocationConfig { pub struct StrategyExpressionRiskConfig { #[serde(default)] pub exposure_expr: Option, + #[serde(default, alias = "position_exposure_schedule")] + pub position_exposure_schedule: Vec, #[serde(default)] pub portfolio_drawdown_control: Option, #[serde(default)] @@ -916,6 +918,15 @@ pub struct StrategyExpressionRiskConfig { pub stop_take_reference_price_mode: Option, } +#[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!({