test: verify portfolio loss against finalized engine accounting
This commit is contained in:
@@ -1468,6 +1468,7 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
self.portfolio_loss_state = Some(state);
|
self.portfolio_loss_state = Some(state);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn market_cap_storage_to_strategy_unit(value: f64) -> f64 {
|
fn market_cap_storage_to_strategy_unit(value: f64) -> f64 {
|
||||||
value
|
value
|
||||||
@@ -14639,6 +14640,81 @@ mod tests {
|
|||||||
.expect("single-symbol platform dataset")
|
.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]
|
#[test]
|
||||||
fn stock_state_cache_resets_before_reusing_compact_keys_on_another_date() {
|
fn stock_state_cache_resets_before_reusing_compact_keys_on_another_date() {
|
||||||
let dates = [d(2025, 1, 2), d(2025, 1, 3)];
|
let dates = [d(2025, 1, 2), d(2025, 1, 3)];
|
||||||
|
|||||||
Reference in New Issue
Block a user