test: verify portfolio loss against finalized engine accounting
This commit is contained in:
@@ -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)];
|
||||
|
||||
Reference in New Issue
Block a user