test: verify portfolio loss against finalized engine accounting

This commit is contained in:
boris
2026-09-09 05:40:04 +08:00
parent d4bb180841
commit e1ff46c97a
@@ -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<Mutex<Vec<(ClosedPortfolioSession, crate::portfolio_loss::PortfolioLossDecision)>>>,
}
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<StrategyDecision, BacktestError> {
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::<Vec<_>>();
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)];