From e1ff46c97a62710718618cbd28e6013f7ade2cee Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 9 Sep 2026 05:40:04 +0800 Subject: [PATCH] test: verify portfolio loss against finalized engine accounting --- .../fidc-core/src/platform_expr_strategy.rs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index b7f09c9..a200f4f 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1465,6 +1465,7 @@ impl PlatformExprStrategy { } self.portfolio_loss_state = Some(state); Ok(()) + } fn market_cap_storage_to_strategy_unit(value: f64) -> f64 { value @@ -14573,6 +14574,81 @@ mod tests { .expect("single-symbol platform dataset") } + #[test] + fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() { + use std::sync::Mutex; + use crate::{BacktestConfig, BacktestEngine, BrokerSimulator, ChinaEquityRuleHooks}; + + struct Capture { + inner: PlatformExprStrategy, + first: NaiveDate, + rows: Arc>>, + } + impl Strategy for Capture { + fn name(&self) -> &str { "portfolio-loss-lifecycle-test" } + fn requires_minute_callbacks(&self) -> bool { false } + fn before_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), BacktestError> { + self.inner.before_trading(ctx) + } + fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { + let mut decision = self.inner.on_day(ctx)?; + if ctx.execution_date == self.first { + decision.order_intents.push(OrderIntent::SetManagementFeeRate { rate: 0.001, reason: "fee accounting test".to_owned() }); + } + if ctx.execution_date == self.first + Duration::days(5) { + decision.order_intents.push(OrderIntent::DepositWithdraw { amount: 10_000.0, receiving_days: 0, reason: "unit NAV flow test".to_owned() }); + } + Ok(decision) + } + fn on_process_event(&mut self, ctx: &StrategyContext<'_>, event: &ProcessEvent) -> Result<(), BacktestError> { + self.inner.on_process_event(ctx, event)?; + if event.kind == ProcessEventKind::PostSettlement { + let state = self.inner.portfolio_loss_state().unwrap(); + self.rows.lock().unwrap().push((state.last_session().unwrap().clone(), state.last_decision().unwrap().clone())); + } + Ok(()) + } + } + let first = d(2023, 1, 3); + let dates = (0..25).map(|day| first + Duration::days(day)).collect::>(); + let mut parts = single_symbol_platform_data(&dates, "000001.SZ").snapshot_components(); + for (index, row) in parts.market.iter_mut().enumerate() { + let price = (1000.0 * 0.99_f64.powi(index as i32)).round() / 100.0; + row.day_open = price; row.open = price; row.high = price; row.low = price; + row.close = price; row.last_price = price; row.bid1 = price; row.ask1 = price; + row.prev_close = price / 0.99; row.upper_limit = price * 1.1; row.lower_limit = price * 0.9; + } + let data = DataSet::from_components(parts.instruments, parts.market, parts.factors, parts.candidates, parts.benchmarks).unwrap(); + let mut config = PlatformExprStrategyConfig::generic(); + config.universe_include = Some(BTreeSet::from(["000001.SZ".to_owned()])); + config.stock_filter_expr = "true".to_owned(); config.rank_expr = "1.0".to_owned(); + config.selection_limit_expr = "1".to_owned(); config.max_positions = 1; + config.market_cap_lower_expr = "0.0".to_owned(); config.market_cap_upper_expr = "1000.0".to_owned(); + config.exposure_expr = "0.9".to_owned(); config.refresh_rate = 1; config.refresh_rate_expr = "1".to_owned(); + config.portfolio_loss_control = Some(PortfolioLossConfig { lookback: 10, loss_trigger: 0.05, floor_exposure: 0.2, cooldown_trading_days: 3 }); + let rows = Arc::new(Mutex::new(Vec::new())); + let strategy = Capture { inner: PlatformExprStrategy::new(config), first, rows: Arc::clone(&rows) }; + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose); + let mut engine = BacktestEngine::new(data, strategy, broker, BacktestConfig { + initial_cash: 10_000.0, benchmark_code: "000852.SH".to_owned(), start_date: Some(first), + end_date: dates.last().copied(), decision_lag_trading_days: 0, execution_price_field: PriceField::Close, + }); + let result = engine.run().unwrap(); + let records = rows.lock().unwrap(); + assert_eq!(records.len(), dates.len()); + assert!(result.fills.len() > 1); + assert_eq!(result.equity_curve[5].external_cash_flow, 10_000.0); + assert!(records.iter().any(|(_, decision)| decision.newly_triggered)); + for ((session, decision), equity) in records.iter().zip(&result.equity_curve) { + assert_eq!(session.date, equity.date); + assert_eq!(session.end_unit_nav.to_bits(), equity.unit_nav.to_bits()); + assert!(decision.observed_through.is_none_or(|date| date < session.date)); + if decision.observation_count < 10 { assert!(!decision.threshold_breached); } + } + assert!(result.equity_curve[5].unit_nav < result.equity_curve[4].unit_nav); + } + #[test] fn stock_state_cache_resets_before_reusing_compact_keys_on_another_date() { let dates = [d(2025, 1, 2), d(2025, 1, 3)];