diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index d70e115..285c170 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1726,8 +1726,93 @@ where let decision_slot = execution_idx .checked_sub(self.config.decision_lag_trading_days) .map(|decision_idx| (decision_idx, execution_dates[decision_idx])); - let (decision_index, decision_date) = - decision_slot.unwrap_or((execution_idx, execution_date)); + let Some((decision_index, decision_date)) = decision_slot else { + let mut process_events = Vec::new(); + let mut report = BrokerExecutionReport::default(); + portfolio.update_prices_with_options( + execution_date, + &self.data, + PriceField::Close, + self.broker.same_day_buy_close_mark_at_fill(), + )?; + let close_report = self.broker.after_trading(execution_date); + merge_broker_report(&mut report, close_report); + let futures_daily_settlement_report = self.settle_futures_daily(execution_date); + merge_broker_report(&mut report, futures_daily_settlement_report); + let futures_expiration_report = self.settle_futures_expirations(execution_date); + merge_broker_report(&mut report, futures_expiration_report); + + let daily_fill_count = report.fill_events.len(); + let day_orders = report.order_events.clone(); + let day_fills = report.fill_events.clone(); + let broker_diagnostics = report.diagnostics.clone(); + self.extend_result(&mut result, report); + + let benchmark = + self.data + .benchmark(execution_date) + .ok_or(BacktestError::MissingBenchmark { + date: execution_date, + })?; + let notes = corporate_action_notes.join(" | "); + let diagnostics = std::iter::once(format!( + "decision_lag_warmup lag_days={} execution_index={}", + self.config.decision_lag_trading_days, execution_idx + )) + .chain(broker_diagnostics.into_iter()) + .collect::>() + .join(" | "); + let holdings_for_day = portfolio.holdings_summary(execution_date); + let day_process_events = process_events.clone(); + let aggregate_initial_cash = self.aggregate_initial_cash(); + let aggregate_cash = self.aggregate_cash(&portfolio); + let aggregate_market_value = self.aggregate_market_value(&portfolio); + let aggregate_total_equity = self.aggregate_total_equity(&portfolio); + + result.equity_curve.push(DailyEquityPoint { + date: execution_date, + cash: aggregate_cash, + market_value: aggregate_market_value, + total_equity: aggregate_total_equity, + benchmark_close: benchmark.close, + benchmark_prev_close: benchmark.prev_close, + notes, + diagnostics, + }); + result.daily_holdings.extend(holdings_for_day.clone()); + let latest = result + .equity_curve + .last() + .expect("equity point pushed for progress event"); + on_progress(&BacktestDayProgress { + date: execution_date, + cash: latest.cash, + market_value: latest.market_value, + total_equity: latest.total_equity, + unit_nav: if aggregate_initial_cash.abs() < f64::EPSILON { + 0.0 + } else { + latest.total_equity / aggregate_initial_cash + }, + total_return: if aggregate_initial_cash.abs() < f64::EPSILON { + 0.0 + } else { + (latest.total_equity / aggregate_initial_cash) - 1.0 + }, + benchmark_close: latest.benchmark_close, + daily_fill_count, + cumulative_trade_count: result.fills.len(), + holding_count: holdings_for_day.len(), + notes: latest.notes.clone(), + diagnostics: latest.diagnostics.clone(), + orders: day_orders, + fills: day_fills, + holdings: holdings_for_day, + process_events: day_process_events, + }); + result.process_events.append(&mut process_events); + continue; + }; let mut process_events = Vec::new(); let mut directive_report = BrokerExecutionReport::default(); let pre_open_orders = self.open_order_views(); @@ -3852,3 +3937,202 @@ mod date_format { serializer.serialize_str(&date.format(FORMAT).to_string()) } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use chrono::NaiveDate; + + use super::{BacktestConfig, BacktestEngine}; + use crate::broker::{BrokerSimulator, MatchingType}; + use crate::cost::ChinaAShareCostModel; + use crate::data::{ + BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, + PriceField, + }; + use crate::instrument::Instrument; + use crate::rules::ChinaEquityRuleHooks; + use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; + + const SYMBOL: &str = "000001.SZ"; + + #[derive(Debug)] + struct BuyWhenDecisionDateStrategy { + decision_date: NaiveDate, + } + + impl Strategy for BuyWhenDecisionDateStrategy { + fn name(&self) -> &str { + "buy_when_decision_date" + } + + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + if ctx.decision_date == self.decision_date && ctx.portfolio.position(SYMBOL).is_none() { + return Ok(StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: SYMBOL.to_string(), + quantity: 100, + reason: "test_buy".to_string(), + }], + ..StrategyDecision::default() + }); + } + Ok(StrategyDecision::default()) + } + } + + fn d(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).expect("valid date") + } + + fn market(date: NaiveDate, open: f64, close: f64) -> DailyMarketSnapshot { + DailyMarketSnapshot { + date, + symbol: SYMBOL.to_string(), + timestamp: Some(format!("{date} 15:00:00")), + day_open: open, + open, + high: close.max(open) + 1.0, + low: close.min(open) - 1.0, + close, + last_price: close, + bid1: close - 0.01, + ask1: close + 0.01, + prev_close: 10.0, + volume: 1_000_000, + minute_volume: 10_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 200.0, + lower_limit: 1.0, + price_tick: 0.01, + } + } + + fn factor(date: NaiveDate) -> DailyFactorSnapshot { + DailyFactorSnapshot { + date, + symbol: SYMBOL.to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 8.0, + pe_ttm: 12.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + } + } + + fn candidate(date: NaiveDate) -> CandidateEligibility { + CandidateEligibility { + date, + symbol: SYMBOL.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + } + } + + fn benchmark(date: NaiveDate) -> BenchmarkSnapshot { + BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1000.0, + prev_close: 1000.0, + volume: 1_000_000, + } + } + + fn dataset() -> DataSet { + let first = d(2025, 1, 2); + let second = d(2025, 1, 3); + DataSet::from_components( + vec![Instrument { + symbol: SYMBOL.to_string(), + name: "Test Stock".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![market(first, 10.0, 11.5), market(second, 12.0, 99.0)], + vec![factor(first), factor(second)], + vec![candidate(first), candidate(second)], + vec![benchmark(first), benchmark(second)], + ) + .expect("dataset") + } + + fn run_with_matching( + matching_type: MatchingType, + execution_price_field: PriceField, + decision_lag_trading_days: usize, + ) -> super::BacktestResult { + let first = d(2025, 1, 2); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + execution_price_field, + ) + .with_matching_type(matching_type) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let config = BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000852.SH".to_string(), + start_date: Some(first), + end_date: Some(d(2025, 1, 3)), + decision_lag_trading_days, + execution_price_field, + }; + + BacktestEngine::new( + dataset(), + BuyWhenDecisionDateStrategy { + decision_date: first, + }, + broker, + config, + ) + .run() + .expect("backtest run") + } + + #[test] + fn current_bar_close_uses_decision_day_close_for_fill() { + let result = run_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0); + + assert_eq!(result.fills.len(), 1); + assert_eq!(result.fills[0].date, d(2025, 1, 2)); + assert_eq!(result.fills[0].price, 11.5); + } + + #[test] + fn next_bar_open_skips_unavailable_lag_day_and_fills_next_open() { + let result = run_with_matching(MatchingType::NextBarOpen, PriceField::Open, 1); + + assert_eq!(result.fills.len(), 1); + assert_eq!(result.fills[0].date, d(2025, 1, 3)); + assert_eq!(result.fills[0].price, 12.0); + assert!( + result.equity_curve[0] + .diagnostics + .contains("decision_lag_warmup"), + "{}", + result.equity_curve[0].diagnostics + ); + } +}