增加组合回撤冷却风控状态机

This commit is contained in:
boris
2026-07-15 18:39:50 +08:00
parent e17c5ad3b0
commit 9f85625b83
3 changed files with 305 additions and 9 deletions
+5 -4
View File
@@ -52,8 +52,8 @@ pub use metrics::{BacktestMetrics, compute_backtest_metrics};
pub use platform_expr_strategy::{ pub use platform_expr_strategy::{
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformSelectionQuotePlan, PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
PlatformTradeAction, PlatformUniverseActionKind, PlatformSelectionQuotePlan, PlatformTradeAction, PlatformUniverseActionKind,
}; };
pub use platform_runtime_schema::{ pub use platform_runtime_schema::{
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names, PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
@@ -66,8 +66,9 @@ pub use platform_strategy_spec::{
StrategyExpressionActionConfig, StrategyExpressionAllocationConfig, StrategyExpressionActionConfig, StrategyExpressionAllocationConfig,
StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig, StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig,
StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig, StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig,
StrategyExpressionTradingConfig, StrategyRuntimeEnvironment, StrategyRuntimeExpressions, StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig,
StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value, StrategyRuntimeEnvironment, StrategyRuntimeExpressions, StrategyRuntimeSpec,
platform_expr_config_from_spec, platform_expr_config_from_value,
}; };
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position}; pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
pub use risk_control::{ pub use risk_control::{
+200 -3
View File
@@ -39,6 +39,134 @@ pub struct PlatformRebalanceSchedule {
pub time_rule: Option<ScheduleTimeRule>, pub time_rule: Option<ScheduleTimeRule>,
} }
#[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<f64>,
cooldown_remaining: usize,
rearm_pending: bool,
trigger_count: usize,
last_decision: Option<PlatformPortfolioDrawdownDecision>,
}
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<PlatformPortfolioDrawdownDecision, BacktestError> {
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<String> {
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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SelectionRiskDeferral { enum SelectionRiskDeferral {
None, None,
@@ -214,6 +342,7 @@ pub struct PlatformExprStrategyConfig {
pub stock_filter_expr: String, pub stock_filter_expr: String,
pub buy_scale_expr: String, pub buy_scale_expr: String,
pub exposure_expr: String, pub exposure_expr: String,
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
pub stop_loss_expr: String, pub stop_loss_expr: String,
pub take_profit_expr: String, pub take_profit_expr: String,
pub rank_by: String, pub rank_by: String,
@@ -287,6 +416,7 @@ fn band_low(index_close) {
.to_string(), .to_string(),
buy_scale_expr: "1.0".to_string(), buy_scale_expr: "1.0".to_string(),
exposure_expr: "1.0".to_string(), exposure_expr: "1.0".to_string(),
portfolio_drawdown_control: None,
stop_loss_expr: String::new(), stop_loss_expr: String::new(),
take_profit_expr: String::new(), take_profit_expr: String::new(),
rank_by: "market_cap".to_string(), rank_by: "market_cap".to_string(),
@@ -614,6 +744,7 @@ pub struct PlatformExprStrategy {
rebalance_day_counter: usize, rebalance_day_counter: usize,
last_rebalance_date: Option<NaiveDate>, last_rebalance_date: Option<NaiveDate>,
last_trading_ratio: Option<f64>, last_trading_ratio: Option<f64>,
portfolio_drawdown_controller: Option<PlatformPortfolioDrawdownController>,
pending_highlimit_holdings: BTreeSet<String>, pending_highlimit_holdings: BTreeSet<String>,
pending_full_close_symbols: BTreeSet<String>, pending_full_close_symbols: BTreeSet<String>,
position_entry_dates: BTreeMap<String, NaiveDate>, position_entry_dates: BTreeMap<String, NaiveDate>,
@@ -841,12 +972,17 @@ impl PlatformExprStrategy {
&normalized_stock_filter_expr, &normalized_stock_filter_expr,
&prelude_declared_identifiers, &prelude_declared_identifiers,
); );
let portfolio_drawdown_controller = config
.portfolio_drawdown_control
.clone()
.map(PlatformPortfolioDrawdownController::new);
Self { Self {
config, config,
engine, engine,
rebalance_day_counter: 0, rebalance_day_counter: 0,
last_rebalance_date: None, last_rebalance_date: None,
last_trading_ratio: None, last_trading_ratio: None,
portfolio_drawdown_controller,
pending_highlimit_holdings: BTreeSet::new(), pending_highlimit_holdings: BTreeSet::new(),
pending_full_close_symbols: BTreeSet::new(), pending_full_close_symbols: BTreeSet::new(),
position_entry_dates: BTreeMap::new(), position_entry_dates: BTreeMap::new(),
@@ -5627,12 +5763,19 @@ impl PlatformExprStrategy {
} }
fn trading_ratio( fn trading_ratio(
&self, &mut self,
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
day: &DayExpressionState, day: &DayExpressionState,
) -> Result<f64, BacktestError> { ) -> Result<f64, BacktestError> {
self.eval_float(ctx, &self.config.exposure_expr, day, None, None) let risk_on_exposure = self
.map(|value| value.clamp(0.0, 1.0)) .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( fn market_cap_band(
@@ -9897,6 +10040,13 @@ impl Strategy for PlatformExprStrategy {
diagnostics.extend(explicit_action_diagnostics); diagnostics.extend(explicit_action_diagnostics);
diagnostics.extend(daily_top_up_debug_notes); diagnostics.extend(daily_top_up_debug_notes);
diagnostics.extend(projection_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![ let notes = vec![
format!("stock_list={}", stock_list.len()), format!("stock_list={}", stock_list.len()),
@@ -9962,6 +10112,7 @@ mod tests {
use super::{ use super::{
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
PlatformPortfolioDrawdownControlConfig, PlatformPortfolioDrawdownController,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction, PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
PlatformUniverseActionKind, SelectionRiskDeferral, StockFilterQuoteUsage, PlatformUniverseActionKind, SelectionRiskDeferral, StockFilterQuoteUsage,
precomputed_stock_rolling_mean, precomputed_stock_rolling_mean,
@@ -30006,4 +30157,50 @@ mod tests {
false, 10_000, 10_100 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);
}
} }
+100 -2
View File
@@ -7,8 +7,9 @@ use serde_json::Value;
use crate::{ use crate::{
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage, DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction, PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel, PlatformTradeAction, PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule,
SlippageModel,
}; };
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -661,11 +662,28 @@ pub struct StrategyExpressionRiskConfig {
#[serde(default)] #[serde(default)]
pub exposure_expr: Option<String>, pub exposure_expr: Option<String>,
#[serde(default)] #[serde(default)]
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
#[serde(default)]
pub stop_loss_expr: Option<String>, pub stop_loss_expr: Option<String>,
#[serde(default)] #[serde(default)]
pub take_profit_expr: Option<String>, pub take_profit_expr: Option<String>,
} }
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyPortfolioDrawdownControlConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub drawdown_trigger: Option<f64>,
#[serde(default)]
pub floor_exposure: Option<f64>,
#[serde(default)]
pub cooldown_trading_days: Option<usize>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StrategyExpressionOrderingConfig { pub struct StrategyExpressionOrderingConfig {
@@ -1583,6 +1601,43 @@ pub fn platform_expr_config_from_spec(
expr.clone() 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 if let Some(expr) = risk
.stop_loss_expr .stop_loss_expr
.as_ref() .as_ref()
@@ -3211,4 +3266,47 @@ mod tests {
assert!(!cfg.delayed_limit_open_exit_enabled); assert!(!cfg.delayed_limit_open_exit_enabled);
assert_eq!(cfg.delayed_limit_open_exit_time, None); 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"));
}
} }