use chrono::{NaiveDate, NaiveTime}; use fidc_core::{ BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } fn t(hour: u32, minute: u32, second: u32) -> NaiveTime { NaiveTime::from_hms_opt(hour, minute, second).expect("valid time") } #[derive(Default)] struct DecisionQuoteReader { day_count: usize, } impl Strategy for DecisionQuoteReader { fn name(&self) -> &str { "decision_quote_reader" } fn decision_quote_times(&self) -> Vec { vec![t(10, 40, 0)] } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { self.day_count += 1; if self.day_count == 1 { return Ok(StrategyDecision { order_intents: vec![OrderIntent::Value { symbol: "000001.SZ".to_string(), value: 5_000.0, reason: "seed_position".to_string(), }], ..StrategyDecision::default() }); } assert!( ctx.portfolio.position("000001.SZ").is_some(), "second day should carry the first day position" ); let quote_loaded_before_decision = ctx .data .execution_quotes_on(ctx.execution_date, "000001.SZ") .iter() .any(|quote| quote.timestamp.time() == t(10, 39, 59) && quote.last_price == 11.0); assert!( quote_loaded_before_decision, "engine must load declared decision quote before strategy.on_day" ); Ok(StrategyDecision::default()) } } #[test] fn engine_preloads_declared_decision_quotes_for_current_positions() { let first = d(2026, 1, 5); let second = d(2026, 1, 6); let data = DataSet::from_components( Vec::new(), vec![ DailyMarketSnapshot { date: first, symbol: "000001.SZ".to_string(), timestamp: Some("2026-01-05 15:00:00".to_string()), day_open: 10.0, open: 10.0, high: 10.2, low: 9.9, close: 10.0, last_price: 10.0, bid1: 10.0, ask1: 10.0, prev_close: 9.8, volume: 10_000, tick_volume: 1_000, bid1_volume: 10_000, ask1_volume: 10_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 10.78, lower_limit: 8.82, price_tick: 0.01, }, DailyMarketSnapshot { date: second, symbol: "000001.SZ".to_string(), timestamp: Some("2026-01-06 15:00:00".to_string()), day_open: 10.5, open: 10.5, high: 11.2, low: 10.4, close: 10.6, last_price: 10.6, bid1: 10.6, ask1: 10.6, prev_close: 10.0, volume: 10_000, tick_volume: 1_000, bid1_volume: 10_000, ask1_volume: 10_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, }, ], vec![ DailyFactorSnapshot { date: first, symbol: "000001.SZ".to_string(), market_cap_bn: 10.0, free_float_cap_bn: 10.0, pe_ttm: 10.0, turnover_ratio: None, effective_turnover_ratio: None, extra_factors: Default::default(), }, DailyFactorSnapshot { date: second, symbol: "000001.SZ".to_string(), market_cap_bn: 10.0, free_float_cap_bn: 10.0, pe_ttm: 10.0, turnover_ratio: None, effective_turnover_ratio: None, extra_factors: Default::default(), }, ], vec![ CandidateEligibility { date: first, symbol: "000001.SZ".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, }, CandidateEligibility { date: second, symbol: "000001.SZ".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, }, ], vec![ BenchmarkSnapshot { date: first, benchmark: "000852.SH".to_string(), open: 1000.0, close: 1000.0, prev_close: 990.0, volume: 1_000_000, }, BenchmarkSnapshot { date: second, benchmark: "000852.SH".to_string(), open: 1000.0, close: 1001.0, prev_close: 1000.0, volume: 1_000_000, }, ], ) .expect("dataset"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_matching_type(MatchingType::NextTickLast) .with_intraday_execution_start_time(t(10, 40, 0)); let config = BacktestConfig { initial_cash: 10_000.0, benchmark_code: "000852.SH".to_string(), start_date: Some(first), end_date: Some(second), decision_lag_trading_days: 0, execution_price_field: PriceField::Last, }; let mut engine = BacktestEngine::new(data, DecisionQuoteReader::default(), broker, config) .with_execution_quote_loader(move |request| { assert_eq!( request.end_time, None, "decision quote preload must request latest quote at or before start_time" ); Ok(request .symbols .into_iter() .map(|symbol| IntradayExecutionQuote { date: request.date, symbol, timestamp: request .date .and_time(t(10, 39, 59)), last_price: if request.date == second { 11.0 } else { 10.0 }, bid1: if request.date == second { 11.0 } else { 10.0 }, ask1: if request.date == second { 11.0 } else { 10.0 }, bid1_volume: 10_000, ask1_volume: 10_000, volume_delta: 10_000, amount_delta: 100_000.0, trading_phase: Some("continuous".to_string()), }) .collect()) }); engine.run().expect("backtest should run"); }