From 9f85625b839ef55cb55db6a917ca8d17fcc47303 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 15 Jul 2026 18:39:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=BB=84=E5=90=88=E5=9B=9E?= =?UTF-8?q?=E6=92=A4=E5=86=B7=E5=8D=B4=E9=A3=8E=E6=8E=A7=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/lib.rs | 9 +- .../fidc-core/src/platform_expr_strategy.rs | 203 +++++++++++++++++- .../fidc-core/src/platform_strategy_spec.rs | 102 ++++++++- 3 files changed, 305 insertions(+), 9 deletions(-) diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 9b55cc0..5f45841 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -52,8 +52,8 @@ pub use metrics::{BacktestMetrics, compute_backtest_metrics}; pub use platform_expr_strategy::{ PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, - PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformSelectionQuotePlan, - PlatformTradeAction, PlatformUniverseActionKind, + PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency, + PlatformSelectionQuotePlan, PlatformTradeAction, PlatformUniverseActionKind, }; pub use platform_runtime_schema::{ PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names, @@ -66,8 +66,9 @@ pub use platform_strategy_spec::{ StrategyExpressionActionConfig, StrategyExpressionAllocationConfig, StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig, StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig, - StrategyExpressionTradingConfig, StrategyRuntimeEnvironment, StrategyRuntimeExpressions, - StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value, + StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig, + StrategyRuntimeEnvironment, StrategyRuntimeExpressions, StrategyRuntimeSpec, + platform_expr_config_from_spec, platform_expr_config_from_value, }; pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position}; pub use risk_control::{ diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 5349898..064e9b6 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -39,6 +39,134 @@ pub struct PlatformRebalanceSchedule { pub time_rule: Option, } +#[derive(Debug, Clone, PartialEq)] +pub struct PlatformPortfolioDrawdownControlConfig { + pub mode: String, + pub drawdown_trigger: f64, + pub floor_exposure: f64, + pub cooldown_trading_days: usize, +} + +#[derive(Debug, Clone, PartialEq)] +struct PlatformPortfolioDrawdownDecision { + decision_date: NaiveDate, + equity: f64, + peak_equity: f64, + realized_drawdown: f64, + threshold_breached: bool, + newly_triggered: bool, + risk_off: bool, + cooldown_before: usize, + cooldown_after: usize, + rearm_pending: bool, + target_exposure: f64, +} + +#[derive(Debug, Clone)] +struct PlatformPortfolioDrawdownController { + config: PlatformPortfolioDrawdownControlConfig, + peak_equity: Option, + cooldown_remaining: usize, + rearm_pending: bool, + trigger_count: usize, + last_decision: Option, +} + +impl PlatformPortfolioDrawdownController { + fn new(config: PlatformPortfolioDrawdownControlConfig) -> Self { + Self { + config, + peak_equity: None, + cooldown_remaining: 0, + rearm_pending: false, + trigger_count: 0, + last_decision: None, + } + } + + fn update( + &mut self, + decision_date: NaiveDate, + equity: f64, + risk_on_exposure: f64, + ) -> Result { + if let Some(cached) = self + .last_decision + .as_ref() + .filter(|decision| decision.decision_date == decision_date) + { + return Ok(cached.clone()); + } + if !equity.is_finite() || equity <= 0.0 { + return Err(BacktestError::Execution(format!( + "portfolio drawdown control requires positive finite signal-visible equity on {decision_date}, got {equity}" + ))); + } + if self.rearm_pending && self.cooldown_remaining == 0 { + self.peak_equity = Some(equity); + self.rearm_pending = false; + } + let peak_equity = self.peak_equity.unwrap_or(equity).max(equity); + self.peak_equity = Some(peak_equity); + let realized_drawdown = equity / peak_equity - 1.0; + let threshold_breached = realized_drawdown <= -self.config.drawdown_trigger; + let cooldown_before = self.cooldown_remaining; + let newly_triggered = cooldown_before == 0 && threshold_breached; + let risk_off = cooldown_before > 0 || newly_triggered; + if cooldown_before > 0 { + self.cooldown_remaining = cooldown_before - 1; + if self.cooldown_remaining == 0 { + self.rearm_pending = true; + } + } else if newly_triggered { + self.trigger_count += 1; + self.cooldown_remaining = self.config.cooldown_trading_days.saturating_sub(1); + self.rearm_pending = self.cooldown_remaining == 0; + } + let target_exposure = if risk_off { + self.config.floor_exposure.min(risk_on_exposure) + } else { + risk_on_exposure + }; + let decision = PlatformPortfolioDrawdownDecision { + decision_date, + equity, + peak_equity, + realized_drawdown, + threshold_breached, + newly_triggered, + risk_off, + cooldown_before, + cooldown_after: self.cooldown_remaining, + rearm_pending: self.rearm_pending, + target_exposure, + }; + self.last_decision = Some(decision.clone()); + Ok(decision) + } + + fn diagnostic(&self) -> Option { + let decision = self.last_decision.as_ref()?; + Some(format!( + "portfolio_drawdown_control mode={} decision_date={} equity={:.8} peak_equity={:.8} drawdown={:.8} trigger={:.8} breached={} newly_triggered={} risk_off={} cooldown_before={} cooldown_after={} rearm_pending={} target_exposure={:.8} trigger_count={}", + self.config.mode, + decision.decision_date, + decision.equity, + decision.peak_equity, + decision.realized_drawdown, + self.config.drawdown_trigger, + decision.threshold_breached, + decision.newly_triggered, + decision.risk_off, + decision.cooldown_before, + decision.cooldown_after, + decision.rearm_pending, + decision.target_exposure, + self.trigger_count, + )) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SelectionRiskDeferral { None, @@ -214,6 +342,7 @@ pub struct PlatformExprStrategyConfig { pub stock_filter_expr: String, pub buy_scale_expr: String, pub exposure_expr: String, + pub portfolio_drawdown_control: Option, pub stop_loss_expr: String, pub take_profit_expr: String, pub rank_by: String, @@ -287,6 +416,7 @@ fn band_low(index_close) { .to_string(), buy_scale_expr: "1.0".to_string(), exposure_expr: "1.0".to_string(), + portfolio_drawdown_control: None, stop_loss_expr: String::new(), take_profit_expr: String::new(), rank_by: "market_cap".to_string(), @@ -614,6 +744,7 @@ pub struct PlatformExprStrategy { rebalance_day_counter: usize, last_rebalance_date: Option, last_trading_ratio: Option, + portfolio_drawdown_controller: Option, pending_highlimit_holdings: BTreeSet, pending_full_close_symbols: BTreeSet, position_entry_dates: BTreeMap, @@ -841,12 +972,17 @@ impl PlatformExprStrategy { &normalized_stock_filter_expr, &prelude_declared_identifiers, ); + let portfolio_drawdown_controller = config + .portfolio_drawdown_control + .clone() + .map(PlatformPortfolioDrawdownController::new); Self { config, engine, rebalance_day_counter: 0, last_rebalance_date: None, last_trading_ratio: None, + portfolio_drawdown_controller, pending_highlimit_holdings: BTreeSet::new(), pending_full_close_symbols: BTreeSet::new(), position_entry_dates: BTreeMap::new(), @@ -5627,12 +5763,19 @@ impl PlatformExprStrategy { } fn trading_ratio( - &self, + &mut self, ctx: &StrategyContext<'_>, day: &DayExpressionState, ) -> Result { - self.eval_float(ctx, &self.config.exposure_expr, day, None, None) - .map(|value| value.clamp(0.0, 1.0)) + let risk_on_exposure = self + .eval_float(ctx, &self.config.exposure_expr, day, None, None)? + .clamp(0.0, 1.0); + let Some(controller) = self.portfolio_drawdown_controller.as_mut() else { + return Ok(risk_on_exposure); + }; + controller + .update(ctx.decision_date, day.total_value, risk_on_exposure) + .map(|decision| decision.target_exposure.clamp(0.0, 1.0)) } fn market_cap_band( @@ -9897,6 +10040,13 @@ impl Strategy for PlatformExprStrategy { diagnostics.extend(explicit_action_diagnostics); diagnostics.extend(daily_top_up_debug_notes); diagnostics.extend(projection_debug_notes); + if let Some(diagnostic) = self + .portfolio_drawdown_controller + .as_ref() + .and_then(PlatformPortfolioDrawdownController::diagnostic) + { + diagnostics.push(diagnostic); + } let notes = vec![ format!("stock_list={}", stock_list.len()), @@ -9962,6 +10112,7 @@ mod tests { use super::{ PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, + PlatformPortfolioDrawdownControlConfig, PlatformPortfolioDrawdownController, PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction, PlatformUniverseActionKind, SelectionRiskDeferral, StockFilterQuoteUsage, precomputed_stock_rolling_mean, @@ -30006,4 +30157,50 @@ mod tests { false, 10_000, 10_100 )); } + + #[test] + fn portfolio_drawdown_control_is_idempotent_and_rearms_after_cooldown() { + let mut controller = + PlatformPortfolioDrawdownController::new(PlatformPortfolioDrawdownControlConfig { + mode: "portfolio_dd3_floor10_cool3".to_string(), + drawdown_trigger: 0.03, + floor_exposure: 0.10, + cooldown_trading_days: 3, + }); + let day1 = NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(); + let day2 = NaiveDate::from_ymd_opt(2026, 1, 6).unwrap(); + let day3 = NaiveDate::from_ymd_opt(2026, 1, 7).unwrap(); + let day4 = NaiveDate::from_ymd_opt(2026, 1, 8).unwrap(); + let day5 = NaiveDate::from_ymd_opt(2026, 1, 9).unwrap(); + + assert_eq!( + controller.update(day1, 100.0, 1.0).unwrap().target_exposure, + 1.0 + ); + assert_eq!( + controller.update(day1, 90.0, 1.0).unwrap().target_exposure, + 1.0 + ); + + let trigger = controller.update(day2, 96.0, 1.0).unwrap(); + assert!(trigger.newly_triggered); + assert_eq!(trigger.cooldown_after, 2); + assert_eq!(trigger.target_exposure, 0.10); + + assert_eq!( + controller.update(day3, 95.0, 1.0).unwrap().cooldown_after, + 1 + ); + let cooldown_end = controller.update(day4, 97.0, 1.0).unwrap(); + assert!(cooldown_end.risk_off); + assert_eq!(cooldown_end.cooldown_after, 0); + assert!(cooldown_end.rearm_pending); + + let rearmed = controller.update(day5, 96.0, 1.0).unwrap(); + assert!(!rearmed.risk_off); + assert_eq!(rearmed.peak_equity, 96.0); + assert_eq!(rearmed.realized_drawdown, 0.0); + assert_eq!(rearmed.target_exposure, 1.0); + assert_eq!(controller.trigger_count, 1); + } } diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index ed6782b..ce96070 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -7,8 +7,9 @@ use serde_json::Value; use crate::{ DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig, - PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction, - PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel, + PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency, + PlatformTradeAction, PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, + SlippageModel, }; #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -661,11 +662,28 @@ pub struct StrategyExpressionRiskConfig { #[serde(default)] pub exposure_expr: Option, #[serde(default)] + pub portfolio_drawdown_control: Option, + #[serde(default)] pub stop_loss_expr: Option, #[serde(default)] pub take_profit_expr: Option, } +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyPortfolioDrawdownControlConfig { + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub mode: Option, + #[serde(default)] + pub drawdown_trigger: Option, + #[serde(default)] + pub floor_exposure: Option, + #[serde(default)] + pub cooldown_trading_days: Option, +} + #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyExpressionOrderingConfig { @@ -1583,6 +1601,43 @@ pub fn platform_expr_config_from_spec( expr.clone() }; } + if let Some(control) = risk.portfolio_drawdown_control.as_ref() + && control.enabled.unwrap_or(true) + { + let drawdown_trigger = control + .drawdown_trigger + .filter(|value| value.is_finite() && *value > 0.0 && *value < 1.0) + .ok_or_else(|| { + "runtimeExpressions.risk.portfolioDrawdownControl.drawdownTrigger must be between 0 and 1" + .to_string() + })?; + let floor_exposure = control + .floor_exposure + .filter(|value| value.is_finite() && (0.0..=1.0).contains(value)) + .ok_or_else(|| { + "runtimeExpressions.risk.portfolioDrawdownControl.floorExposure must be between 0 and 1" + .to_string() + })?; + let cooldown_trading_days = control + .cooldown_trading_days + .filter(|value| *value > 0) + .ok_or_else(|| { + "runtimeExpressions.risk.portfolioDrawdownControl.cooldownTradingDays must be positive" + .to_string() + })?; + cfg.portfolio_drawdown_control = Some(PlatformPortfolioDrawdownControlConfig { + mode: control + .mode + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("portfolio_drawdown") + .to_string(), + drawdown_trigger, + floor_exposure, + cooldown_trading_days, + }); + } if let Some(expr) = risk .stop_loss_expr .as_ref() @@ -3211,4 +3266,47 @@ mod tests { assert!(!cfg.delayed_limit_open_exit_enabled); assert_eq!(cfg.delayed_limit_open_exit_time, None); } + + #[test] + fn parses_portfolio_drawdown_control_into_platform_config() { + let spec = serde_json::json!({ + "runtimeExpressions": { + "risk": { + "exposureExpr": "1.0", + "portfolioDrawdownControl": { + "enabled": true, + "mode": "portfolio_dd3_floor10_cool30", + "drawdownTrigger": 0.03, + "floorExposure": 0.10, + "cooldownTradingDays": 30 + } + } + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + let control = cfg.portfolio_drawdown_control.expect("drawdown control"); + assert_eq!(control.mode, "portfolio_dd3_floor10_cool30"); + assert_eq!(control.drawdown_trigger, 0.03); + assert_eq!(control.floor_exposure, 0.10); + assert_eq!(control.cooldown_trading_days, 30); + } + + #[test] + fn rejects_invalid_portfolio_drawdown_control() { + let spec = serde_json::json!({ + "runtimeExpressions": { + "risk": { + "portfolioDrawdownControl": { + "drawdownTrigger": 0.0, + "floorExposure": 0.10, + "cooldownTradingDays": 30 + } + } + } + }); + + let error = platform_expr_config_from_value("", "", &spec).expect_err("invalid trigger"); + assert!(error.contains("drawdownTrigger")); + } }