From 63c577bd765e61412acd3aa2a76cfec4dd3be9e3 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 9 Sep 2026 05:30:10 +0800 Subject: [PATCH] feat: connect portfolio loss to finalized accounting and daily risk clock --- Cargo.toml | 3 +- crates/fidc-core/Cargo.toml | 1 + crates/fidc-core/src/lib.rs | 1 + .../fidc-core/src/platform_expr_strategy.rs | 117 ++++++++++++++++-- .../fidc-core/src/platform_strategy_spec.rs | 46 +++++++ crates/fidc-core/src/portfolio_loss.rs | 37 +++++- 6 files changed, 194 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7745668..4593a77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ version = "0.1.0" authors = ["OpenAI Codex"] [workspace.dependencies] +sha2 = "=0.10.9" ahash = "=0.8.12" chrono = { version = "=0.4.44", features = ["serde"] } indexmap = { version = "=2.11.4", features = ["serde"] } @@ -18,5 +19,5 @@ reqwest = { version = "=0.12.24", default-features = false, features = ["json", rayon = "=1.12.0" rhai = { version = "=1.23.6", features = ["sync"] } serde = { version = "=1.0.228", features = ["derive"] } -serde_json = "=1.0.145" +serde_json = { version = "=1.0.145", features = ["float_roundtrip"] } thiserror = "=2.0.18" diff --git a/crates/fidc-core/Cargo.toml b/crates/fidc-core/Cargo.toml index 0fcfb41..1097253 100644 --- a/crates/fidc-core/Cargo.toml +++ b/crates/fidc-core/Cargo.toml @@ -13,4 +13,5 @@ rayon.workspace = true rhai.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true thiserror.workspace = true diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 6293c7c..17a835a 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -85,6 +85,7 @@ pub use platform_strategy_spec::{ platform_expr_config_from_value, validate_strategy_risk_policy_fields, }; pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position}; +pub use portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossDecision, PortfolioLossError, PortfolioLossState}; pub use risk_control::{ ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope, StaticRiskRuleConfig, TradingConstraintConfig, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 2b3ff11..9cd0836 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3,7 +3,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use ahash::{AHashMap, AHashSet}; -use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; +use chrono::{Datelike, Duration, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc}; +use sha2::{Digest, Sha256}; use rhai::{AST, Dynamic, Engine, ImmutableString, Map, Scope}; use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel}; @@ -16,7 +17,7 @@ use crate::data::{ NumericFactorMap, decision_market_cap_bn, }; use crate::engine::BacktestError; -use crate::events::OrderSide; +use crate::events::{OrderSide, ProcessEvent, ProcessEventKind}; use crate::fixed_point::FixedMoney; use crate::futures::{ FuturesContractSpec, FuturesDirection, FuturesOrderIntent, FuturesPositionEffect, @@ -26,6 +27,7 @@ use crate::numeric_expr_vm::{ Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType, }; use crate::portfolio::PortfolioState; +use crate::portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossState}; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit}; use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler}; use crate::strategy::{ @@ -179,6 +181,22 @@ impl PlatformPortfolioDrawdownController { } } +fn portfolio_gross_exposure(portfolio: &PortfolioState) -> Result { + let equity = portfolio.total_equity(); + let market_value: f64 = portfolio.positions().values().map(|position| position.market_value().abs()).sum(); + if !equity.is_finite() || equity <= 0.0 || !market_value.is_finite() { + return Err(BacktestError::Execution("portfolio loss requires finite positive accounting equity".to_owned())); + } + Ok(market_value / equity) +} + +fn portfolio_loss_decision_at(ctx: &StrategyContext<'_>) -> chrono::DateTime { + let local = ctx.active_datetime.filter(|value| value.date() == ctx.execution_date) + .unwrap_or_else(|| ctx.execution_date.and_hms_opt(9, 30, 0).unwrap()); + FixedOffset::east_opt(8 * 3600).unwrap().from_local_datetime(&local) + .single().unwrap().with_timezone(&Utc) +} + fn validated_target_scales(scales: &[(String, f64)]) -> Result, BacktestError> { if scales.is_empty() { return Ok(Vec::new()); @@ -610,6 +628,7 @@ pub struct PlatformExprStrategyConfig { pub exposure_expr: String, pub position_exposure_schedule: BTreeMap, pub portfolio_drawdown_control: Option, + pub portfolio_loss_control: Option, pub stop_loss_expr: String, pub take_profit_expr: String, pub position_target_rules: Vec, @@ -690,6 +709,7 @@ impl PlatformExprStrategyConfig { exposure_expr: "1.0".to_string(), position_exposure_schedule: BTreeMap::new(), portfolio_drawdown_control: None, + portfolio_loss_control: None, stop_loss_expr: String::new(), take_profit_expr: String::new(), position_target_rules: Vec::new(), @@ -1335,6 +1355,8 @@ pub struct PlatformExprStrategy { last_target_order: Option>, last_trading_ratio: Option, portfolio_drawdown_controller: Option, + portfolio_loss_state: Option, + portfolio_loss_opening: Option<(NaiveDate, f64, f64)>, pending_highlimit_holdings: BTreeSet, pending_full_close_symbols: BTreeSet, position_entry_dates: BTreeMap, @@ -1433,6 +1455,20 @@ fn completed_session_factor_date( } impl PlatformExprStrategy { + pub fn portfolio_loss_state(&self) -> Option<&PortfolioLossState> { + self.portfolio_loss_state.as_ref() + } + + pub fn restore_portfolio_loss_state(&mut self, state: PortfolioLossState) -> Result<(), BacktestError> { + let expected = self.config.portfolio_loss_control.as_ref().ok_or_else(|| BacktestError::Execution( + "portfolio loss state supplied for a strategy without that control".to_owned()))?; + state.validate(expected).map_err(|error| BacktestError::Execution(error.to_string()))?; + if self.portfolio_loss_opening.is_some() { + return Err(BacktestError::Execution("cannot restore portfolio loss state during an open session".to_owned())); + } + self.portfolio_loss_state = Some(state); + Ok(()) + fn market_cap_storage_to_strategy_unit(value: f64) -> f64 { value } @@ -1729,6 +1765,8 @@ impl PlatformExprStrategy { last_target_order: None, last_trading_ratio: None, portfolio_drawdown_controller, + portfolio_loss_state: None, + portfolio_loss_opening: None, pending_highlimit_holdings: BTreeSet::new(), pending_full_close_symbols: BTreeSet::new(), position_entry_dates: BTreeMap::new(), @@ -8488,12 +8526,18 @@ impl PlatformExprStrategy { ) .unwrap_or(strategy_exposure) .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)) + let mut exposure = risk_on_exposure; + if let Some(controller) = self.portfolio_drawdown_controller.as_mut() { + exposure = controller.update(ctx.decision_date, day.total_value, exposure)?.target_exposure; + } + if self.config.portfolio_loss_control.is_some() { + let state = self.portfolio_loss_state.as_mut().ok_or_else(|| BacktestError::Execution( + "portfolio loss state must be initialized or restored before planning".to_owned()))?; + let previous = ctx.data.previous_trading_date(ctx.execution_date, 1); + exposure = state.decide(ctx.execution_date, previous, portfolio_loss_decision_at(ctx), exposure) + .map_err(|error| BacktestError::Execution(error.to_string()))?.target_exposure; + } + Ok(exposure.clamp(0.0, 1.0)) } fn market_cap_band( @@ -12147,6 +12191,59 @@ impl Strategy for PlatformExprStrategy { self.config.strategy_name.as_str() } + fn before_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), BacktestError> { + let Some(config) = self.config.portfolio_loss_control.clone() else { return Ok(()); }; + if ctx.futures_account.is_some() { + return Err(BacktestError::Execution("portfolio loss control currently requires equity-only accounting".to_owned())); + } + if self.portfolio_loss_state.is_none() { + self.portfolio_loss_state = Some(PortfolioLossState::new(config, ctx.execution_date) + .map_err(|error| BacktestError::Execution(error.to_string()))?); + } + if let Some((date, _, _)) = self.portfolio_loss_opening { + if date == ctx.execution_date { return Ok(()); } + return Err(BacktestError::Execution("portfolio loss previous session was not finalized".to_owned())); + } + let state = self.portfolio_loss_state.as_ref().unwrap(); + let start_nav = state.last_session().map(|row| row.end_unit_nav).unwrap_or_else(|| ctx.portfolio.unit_net_value()); + let gross = portfolio_gross_exposure(ctx.portfolio)?; + self.portfolio_loss_opening = Some((ctx.execution_date, start_nav, gross)); + // Advance the daily risk clock even when the selection schedule is not + // due. The actual current exposure budget is applied during planning. + self.portfolio_loss_state.as_mut().unwrap().decide(ctx.execution_date, + ctx.data.previous_trading_date(ctx.execution_date, 1), portfolio_loss_decision_at(ctx), 1.0) + .map_err(|error| BacktestError::Execution(error.to_string()))?; + Ok(()) + } + + fn on_process_event(&mut self, ctx: &StrategyContext<'_>, event: &ProcessEvent) -> Result<(), BacktestError> { + if self.config.portfolio_loss_control.is_none() || event.kind != ProcessEventKind::PostSettlement { return Ok(()); } + let Some((date, start_nav, start_gross)) = self.portfolio_loss_opening else { + return Err(BacktestError::Execution("portfolio loss settlement has no opening accounting snapshot".to_owned())); + }; + if date != ctx.execution_date { + return Err(BacktestError::Execution("portfolio loss settlement date differs from opening snapshot".to_owned())); + } + let end_nav = ctx.portfolio.unit_net_value(); + let end_gross = portfolio_gross_exposure(ctx.portfolio)?; + let state = self.portfolio_loss_state.as_mut().unwrap(); + let previous = state.last_session().map(|row| row.date); + let mut hash = Sha256::new(); + hash.update(b"fidc.engine-finalized-portfolio-session/v1\0"); + hash.update(self.config.strategy_name.as_bytes()); + hash.update(date.to_string().as_bytes()); + for value in [start_nav, end_nav, start_gross, end_gross] { hash.update(value.to_bits().to_le_bytes()); } + state.observe(ClosedPortfolioSession { + date, previous_session_date: previous, + available_at: date.and_hms_opt(7, 30, 0).unwrap().and_utc(), + start_unit_nav: start_nav, end_unit_nav: end_nav, + start_gross_exposure: start_gross, end_gross_exposure: end_gross, + source_sha256: format!("{:x}", hash.finalize()), + }).map_err(|error| BacktestError::Execution(error.to_string()))?; + self.portfolio_loss_opening = None; + Ok(()) + } + fn initial_subscriptions(&self) -> BTreeSet { self.config.initial_subscriptions.clone() } @@ -14009,6 +14106,10 @@ impl PlatformExprStrategy { { diagnostics.push(diagnostic); } + if let Some(decision) = self.portfolio_loss_state.as_ref().and_then(PortfolioLossState::last_decision) { + diagnostics.push(format!("portfolio_loss_control {}", serde_json::to_string(decision) + .map_err(|error| BacktestError::Execution(error.to_string()))?)); + } let notes = vec![ format!("stock_list={}", stock_list.len()), diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 26970e7..d4df80f 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use chrono::{NaiveDate, NaiveTime}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::portfolio_loss::PortfolioLossConfig; use crate::{ DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage, @@ -915,6 +916,8 @@ pub struct StrategyExpressionRiskConfig { #[serde(default)] pub portfolio_drawdown_control: Option, #[serde(default)] + pub portfolio_loss_control: Option, + #[serde(default)] pub stop_loss_expr: Option, #[serde(default)] pub take_profit_expr: Option, @@ -963,6 +966,16 @@ pub struct StrategyPortfolioDrawdownControlConfig { pub cooldown_trading_days: Option, } +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StrategyPortfolioLossControlConfig { + pub enabled: Option, + pub lookback: Option, + pub loss_trigger: Option, + pub floor_exposure: Option, + pub cooldown_trading_days: Option, +} + #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyExpressionOrderingConfig { @@ -2176,6 +2189,18 @@ pub fn platform_expr_config_from_spec( )); } } + if let Some(control) = risk.portfolio_loss_control.as_ref() + && control.enabled.unwrap_or(true) + { + let parsed = PortfolioLossConfig { + lookback: control.lookback.ok_or("portfolioLossControl.lookback is required")?, + loss_trigger: control.loss_trigger.ok_or("portfolioLossControl.lossTrigger is required")?, + floor_exposure: control.floor_exposure.ok_or("portfolioLossControl.floorExposure is required")?, + cooldown_trading_days: control.cooldown_trading_days.ok_or("portfolioLossControl.cooldownTradingDays is required")?, + }; + parsed.validate().map_err(|error| error.to_string())?; + cfg.portfolio_loss_control = Some(parsed); + } if let Some(control) = risk.portfolio_drawdown_control.as_ref() && control.enabled.unwrap_or(true) { @@ -4564,6 +4589,27 @@ mod tests { assert_eq!(control.cooldown_trading_days, 30); } + #[test] + fn portfolio_loss_contract_is_explicit_and_validated() { + let spec = serde_json::json!({"runtimeExpressions":{"risk":{"portfolioLossControl":{ + "enabled":true,"lookback":20,"lossTrigger":0.05,"floorExposure":0.2,"cooldownTradingDays":10 + }}}}); + let cfg = platform_expr_config_from_value("", "", &spec).unwrap(); + assert_eq!(cfg.portfolio_loss_control.unwrap(), PortfolioLossConfig { + lookback:20, loss_trigger:0.05, floor_exposure:0.2, cooldown_trading_days:10, + }); + for (field, value) in [("lookback", serde_json::json!(0)), + ("lossTrigger", serde_json::json!(0.01)), ("floorExposure", serde_json::json!(1.1)), + ("cooldownTradingDays", serde_json::json!(0))] { + let mut invalid = spec.clone(); + invalid["runtimeExpressions"]["risk"]["portfolioLossControl"][field] = value; + assert!(platform_expr_config_from_value("", "", &invalid).is_err()); + } + let mut missing = spec.clone(); + missing["runtimeExpressions"]["risk"]["portfolioLossControl"].as_object_mut().unwrap().remove("lossTrigger"); + assert!(platform_expr_config_from_value("", "", &missing).is_err()); + } + #[test] fn rejects_invalid_portfolio_drawdown_control() { let spec = serde_json::json!({ diff --git a/crates/fidc-core/src/portfolio_loss.rs b/crates/fidc-core/src/portfolio_loss.rs index 9d9a9ff..4514e2b 100644 --- a/crates/fidc-core/src/portfolio_loss.rs +++ b/crates/fidc-core/src/portfolio_loss.rs @@ -453,8 +453,10 @@ mod tests { state.decide(date(2), Some(date(1)), time(2, 1), 0.9), Err(PortfolioLossError::SessionDiscontinuity) ); - let mut late = state.clone(); - late.last_session.as_mut().unwrap().available_at = time(2, 1); + let mut late = PortfolioLossState::new(config(), date(0)).unwrap(); + let mut delayed = session(0, 1.0, 0.99, 1.0); + delayed.available_at = time(2, 1); + late.observe(delayed).unwrap(); assert_eq!( late.decide(date(1), Some(date(0)), time(1, 1), 0.9), Err(PortfolioLossError::ObservationNotVisible) @@ -481,4 +483,35 @@ mod tests { assert_eq!(decision.target_exposure, 0.9); assert_eq!(state.last_session().unwrap().date, date(19)); } + + #[test] + fn restored_state_rejects_changed_policy_and_forged_cooldown() { + let state = PortfolioLossState::new(config(), date(0)).unwrap(); + let mut changed = config(); + changed.floor_exposure = 0.5; + assert_eq!( + state.validate(&changed), + Err(PortfolioLossError::StateMismatch) + ); + let mut forged = state.clone(); + forged.cooldown_remaining = 1; + assert_eq!( + forged.validate(&config()), + Err(PortfolioLossError::StateMismatch) + ); + } + + #[test] + fn nav_serialization_preserves_float_bits() { + let mut seed = 0xabcddcba12345678_u64; + for _ in 0..2000 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let value = 0.01 + (seed as f64 / u64::MAX as f64) * 9.99; + let serialized = serde_json::to_string(&value).unwrap(); + let restored: f64 = serde_json::from_str(&serialized).unwrap(); + assert_eq!(value.to_bits(), restored.to_bits()); + } + } }