增加组合回撤冷却风控状态机
This commit is contained in:
@@ -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::{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user