增加组合回撤冷却风控状态机
This commit is contained in:
@@ -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::{
|
||||
|
||||
@@ -39,6 +39,134 @@ pub struct PlatformRebalanceSchedule {
|
||||
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)]
|
||||
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<PlatformPortfolioDrawdownControlConfig>,
|
||||
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<NaiveDate>,
|
||||
last_trading_ratio: Option<f64>,
|
||||
portfolio_drawdown_controller: Option<PlatformPortfolioDrawdownController>,
|
||||
pending_highlimit_holdings: BTreeSet<String>,
|
||||
pending_full_close_symbols: BTreeSet<String>,
|
||||
position_entry_dates: BTreeMap<String, NaiveDate>,
|
||||
@@ -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<f64, BacktestError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
||||
#[serde(default)]
|
||||
pub stop_loss_expr: Option<String>,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user