use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; use std::rc::Rc; use chrono::NaiveDate; use fidc_core::{ BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, OrderIntent, PriceField, ProcessEventKind, ScheduleRule, ScheduleStage, Strategy, StrategyContext, StrategyDecision, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } struct HookProbeStrategy { log: Rc>>, } impl Strategy for HookProbeStrategy { fn name(&self) -> &str { "hook-probe" } fn before_trading( &mut self, ctx: &StrategyContext<'_>, ) -> Result<(), fidc_core::BacktestError> { self.log .borrow_mut() .push(format!("before:{}", ctx.execution_date)); Ok(()) } fn open_auction( &mut self, ctx: &StrategyContext<'_>, ) -> Result { self.log .borrow_mut() .push(format!("auction:{}", ctx.execution_date)); Ok(StrategyDecision::default()) } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { self.log .borrow_mut() .push(format!("on_day:{}", ctx.execution_date)); Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), order_intents: Vec::new(), notes: Vec::new(), diagnostics: Vec::new(), }) } fn after_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), fidc_core::BacktestError> { self.log .borrow_mut() .push(format!("after:{}", ctx.execution_date)); Ok(()) } fn on_settlement(&mut self, ctx: &StrategyContext<'_>) -> Result<(), fidc_core::BacktestError> { self.log .borrow_mut() .push(format!("settlement:{}", ctx.execution_date)); Ok(()) } } struct AuctionOrderStrategy { saw_quantity_in_on_day: Rc>>, } impl Strategy for AuctionOrderStrategy { fn name(&self) -> &str { "auction-order" } fn open_auction( &mut self, _ctx: &StrategyContext<'_>, ) -> Result { Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), order_intents: vec![fidc_core::OrderIntent::Value { symbol: "000001.SZ".to_string(), value: 1_000.0, reason: "auction_buy".to_string(), }], notes: Vec::new(), diagnostics: Vec::new(), }) } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { let quantity = ctx .portfolio .position("000001.SZ") .map(|position| position.quantity) .unwrap_or(0); *self.saw_quantity_in_on_day.borrow_mut() = Some(quantity); Ok(StrategyDecision::default()) } } struct ScheduledProbeStrategy { log: Rc>>, process_log: Rc>>, } struct LimitCarryStrategy { issued: bool, } impl Strategy for ScheduledProbeStrategy { fn name(&self) -> &str { "scheduled-probe" } fn on_process_event( &mut self, _ctx: &StrategyContext<'_>, event: &fidc_core::ProcessEvent, ) -> Result<(), fidc_core::BacktestError> { self.process_log .borrow_mut() .push(format!("{:?}:{}", event.kind, event.detail)); Ok(()) } fn schedule_rules(&self) -> Vec { vec![ ScheduleRule::daily("daily_auction", ScheduleStage::OpenAuction), ScheduleRule::weekly_by_weekday("friday_on_day", 5, ScheduleStage::OnDay), ScheduleRule::monthly("first_trading_day_on_day", 1, ScheduleStage::OnDay), ] } fn on_scheduled( &mut self, ctx: &StrategyContext<'_>, rule: &ScheduleRule, ) -> Result { self.log .borrow_mut() .push(format!("scheduled:{}:{}", rule.name, ctx.execution_date)); Ok(StrategyDecision::default()) } fn on_day( &mut self, _ctx: &StrategyContext<'_>, ) -> Result { Ok(StrategyDecision::default()) } } impl Strategy for LimitCarryStrategy { fn name(&self) -> &str { "limit-carry" } fn on_day( &mut self, _ctx: &StrategyContext<'_>, ) -> Result { if self.issued { return Ok(StrategyDecision::default()); } self.issued = true; Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), order_intents: vec![OrderIntent::LimitShares { symbol: "000001.SZ".to_string(), quantity: 200, limit_price: 9.8, reason: "carry_limit".to_string(), }], notes: Vec::new(), diagnostics: Vec::new(), }) } } #[test] fn engine_runs_strategy_hooks_in_daily_order() { let date1 = d(2025, 1, 2); let date2 = d(2025, 1, 3); let data = DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "Anchor".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(d(2020, 1, 1)), delisted_at: None, status: "active".to_string(), }], vec![ DailyMarketSnapshot { date: date1, symbol: "000001.SZ".to_string(), timestamp: Some("2025-01-02 10:18:00".to_string()), day_open: 10.0, open: 10.0, high: 10.0, low: 10.0, close: 10.0, last_price: 10.0, bid1: 10.0, ask1: 10.0, prev_close: 10.0, volume: 100_000, tick_volume: 100_000, bid1_volume: 100_000, ask1_volume: 100_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, }, DailyMarketSnapshot { date: date2, symbol: "000001.SZ".to_string(), timestamp: Some("2025-01-03 10:18:00".to_string()), day_open: 10.1, open: 10.1, high: 10.1, low: 10.1, close: 10.1, last_price: 10.1, bid1: 10.1, ask1: 10.1, prev_close: 10.0, volume: 110_000, tick_volume: 110_000, bid1_volume: 110_000, ask1_volume: 110_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, }, ], vec![ DailyFactorSnapshot { date: date1, symbol: "000001.SZ".to_string(), market_cap_bn: 20.0, free_float_cap_bn: 18.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }, DailyFactorSnapshot { date: date2, symbol: "000001.SZ".to_string(), market_cap_bn: 21.0, free_float_cap_bn: 19.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }, ], vec![ CandidateEligibility { date: date1, 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: date2, 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: date1, benchmark: "000300.SH".to_string(), open: 100.0, close: 100.0, prev_close: 99.0, volume: 1_000_000, }, BenchmarkSnapshot { date: date2, benchmark: "000300.SH".to_string(), open: 101.0, close: 101.0, prev_close: 100.0, volume: 1_100_000, }, ], ) .expect("dataset"); let log = Rc::new(RefCell::new(Vec::new())); let strategy = HookProbeStrategy { log: log.clone() }; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let mut engine = BacktestEngine::new( data, strategy, broker, BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000300.SH".to_string(), start_date: Some(date1), end_date: Some(date2), decision_lag_trading_days: 0, execution_price_field: PriceField::Open, }, ); let result = engine.run().expect("backtest succeeds"); assert_eq!( log.borrow().as_slice(), [ "before:2025-01-02", "auction:2025-01-02", "on_day:2025-01-02", "after:2025-01-02", "settlement:2025-01-02", "before:2025-01-03", "auction:2025-01-03", "on_day:2025-01-03", "after:2025-01-03", "settlement:2025-01-03", ] ); assert_eq!(result.process_events.len(), 30); assert_eq!( result.process_events[..15] .iter() .map(|event| &event.kind) .collect::>(), vec![ &ProcessEventKind::PreBeforeTrading, &ProcessEventKind::BeforeTrading, &ProcessEventKind::PostBeforeTrading, &ProcessEventKind::PreOpenAuction, &ProcessEventKind::OpenAuction, &ProcessEventKind::PostOpenAuction, &ProcessEventKind::PreOnDay, &ProcessEventKind::OnDay, &ProcessEventKind::PostOnDay, &ProcessEventKind::PreAfterTrading, &ProcessEventKind::AfterTrading, &ProcessEventKind::PostAfterTrading, &ProcessEventKind::PreSettlement, &ProcessEventKind::Settlement, &ProcessEventKind::PostSettlement, ] ); } #[test] fn engine_executes_open_auction_decisions_before_on_day() { let date = d(2025, 1, 2); let data = DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "Anchor".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(d(2020, 1, 1)), delisted_at: None, status: "active".to_string(), }], vec![DailyMarketSnapshot { date, symbol: "000001.SZ".to_string(), timestamp: Some("2025-01-02 09:25:00".to_string()), day_open: 10.0, open: 10.0, high: 10.0, low: 10.0, close: 10.0, last_price: 10.0, bid1: 10.0, ask1: 10.0, prev_close: 10.0, volume: 100_000, tick_volume: 100_000, bid1_volume: 100_000, ask1_volume: 100_000, trading_phase: Some("open_auction".to_string()), paused: false, upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, }], vec![DailyFactorSnapshot { date, symbol: "000001.SZ".to_string(), market_cap_bn: 20.0, free_float_cap_bn: 18.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }], vec![CandidateEligibility { date, 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, benchmark: "000300.SH".to_string(), open: 100.0, close: 100.0, prev_close: 99.0, volume: 1_000_000, }], ) .expect("dataset"); let observed_quantity = Rc::new(RefCell::new(None)); let strategy = AuctionOrderStrategy { saw_quantity_in_on_day: observed_quantity.clone(), }; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::DayOpen, ); let mut engine = BacktestEngine::new( data, strategy, broker, BacktestConfig { initial_cash: 10_000.0, benchmark_code: "000300.SH".to_string(), start_date: Some(date), end_date: Some(date), decision_lag_trading_days: 0, execution_price_field: PriceField::DayOpen, }, ); let result = engine.run().expect("backtest run"); assert_eq!(*observed_quantity.borrow(), Some(100)); assert_eq!(result.fills.len(), 1); assert_eq!(result.fills[0].reason, "auction_buy"); assert_eq!(result.fills[0].quantity, 100); } #[test] fn engine_rejects_pending_limit_orders_at_market_close() { let date1 = d(2025, 1, 2); let date2 = d(2025, 1, 3); let data = DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "Anchor".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(d(2020, 1, 1)), delisted_at: None, status: "active".to_string(), }], vec![ DailyMarketSnapshot { date: date1, symbol: "000001.SZ".to_string(), timestamp: Some("2025-01-02 10:18:00".to_string()), day_open: 10.0, open: 10.0, high: 10.1, low: 9.9, close: 10.0, last_price: 10.0, bid1: 10.0, ask1: 10.0, prev_close: 10.0, volume: 100_000, tick_volume: 100_000, bid1_volume: 100_000, ask1_volume: 100_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, }, DailyMarketSnapshot { date: date2, symbol: "000001.SZ".to_string(), timestamp: Some("2025-01-03 10:18:00".to_string()), day_open: 9.7, open: 9.7, high: 9.8, low: 9.6, close: 9.7, last_price: 9.7, bid1: 9.7, ask1: 9.7, prev_close: 10.0, volume: 100_000, tick_volume: 100_000, bid1_volume: 100_000, ask1_volume: 100_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 10.67, lower_limit: 9.0, price_tick: 0.01, }, ], vec![ DailyFactorSnapshot { date: date1, symbol: "000001.SZ".to_string(), market_cap_bn: 20.0, free_float_cap_bn: 18.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }, DailyFactorSnapshot { date: date2, symbol: "000001.SZ".to_string(), market_cap_bn: 21.0, free_float_cap_bn: 19.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }, ], vec![ CandidateEligibility { date: date1, 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: date2, 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: date1, benchmark: "000300.SH".to_string(), open: 100.0, close: 100.0, prev_close: 99.0, volume: 1_000_000, }, BenchmarkSnapshot { date: date2, benchmark: "000300.SH".to_string(), open: 101.0, close: 101.0, prev_close: 100.0, volume: 1_100_000, }, ], ) .expect("dataset"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let strategy = LimitCarryStrategy { issued: false }; let mut engine = BacktestEngine::new( data, strategy, broker, BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000300.SH".to_string(), start_date: Some(date1), end_date: Some(date2), decision_lag_trading_days: 0, execution_price_field: PriceField::Open, }, ); let result = engine.run().expect("backtest run"); assert!(result.fills.is_empty()); assert!(result.holdings_summary.is_empty()); assert!( result.order_events.iter().any(|event| { event.date == date1 && event.status == fidc_core::OrderStatus::Pending }) ); assert!(result.order_events.iter().any(|event| { event.date == date1 && event.status == fidc_core::OrderStatus::Rejected && event.reason.contains("Market close") })); assert!(result.process_events.iter().any(|event| { event.date == date1 && event.kind == ProcessEventKind::OrderUnsolicitedUpdate })); } #[test] fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() { let date1 = d(2025, 1, 30); let date2 = d(2025, 1, 31); let date3 = d(2025, 2, 3); let data = DataSet::from_components( vec![Instrument { symbol: "000001.SZ".to_string(), name: "Anchor".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(d(2020, 1, 1)), delisted_at: None, status: "active".to_string(), }], vec![ DailyMarketSnapshot { date: date1, symbol: "000001.SZ".to_string(), timestamp: Some("2025-01-30 09:25:00".to_string()), day_open: 10.0, open: 10.0, high: 10.1, low: 9.9, close: 10.0, last_price: 10.0, bid1: 10.0, ask1: 10.0, prev_close: 9.9, volume: 100_000, tick_volume: 100_000, bid1_volume: 100_000, ask1_volume: 100_000, trading_phase: Some("open_auction".to_string()), paused: false, upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, }, DailyMarketSnapshot { date: date2, symbol: "000001.SZ".to_string(), timestamp: Some("2025-01-31 09:25:00".to_string()), day_open: 10.1, open: 10.1, high: 10.2, low: 10.0, close: 10.1, last_price: 10.1, bid1: 10.1, ask1: 10.1, prev_close: 10.0, volume: 110_000, tick_volume: 110_000, bid1_volume: 110_000, ask1_volume: 110_000, trading_phase: Some("open_auction".to_string()), paused: false, upper_limit: 11.1, lower_limit: 9.1, price_tick: 0.01, }, DailyMarketSnapshot { date: date3, symbol: "000001.SZ".to_string(), timestamp: Some("2025-02-03 09:25:00".to_string()), day_open: 10.2, open: 10.2, high: 10.3, low: 10.1, close: 10.2, last_price: 10.2, bid1: 10.2, ask1: 10.2, prev_close: 10.1, volume: 120_000, tick_volume: 120_000, bid1_volume: 120_000, ask1_volume: 120_000, trading_phase: Some("open_auction".to_string()), paused: false, upper_limit: 11.2, lower_limit: 9.2, price_tick: 0.01, }, ], vec![ DailyFactorSnapshot { date: date1, symbol: "000001.SZ".to_string(), market_cap_bn: 20.0, free_float_cap_bn: 18.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }, DailyFactorSnapshot { date: date2, symbol: "000001.SZ".to_string(), market_cap_bn: 21.0, free_float_cap_bn: 19.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }, DailyFactorSnapshot { date: date3, symbol: "000001.SZ".to_string(), market_cap_bn: 22.0, free_float_cap_bn: 20.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }, ], vec![ CandidateEligibility { date: date1, 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: date2, 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: date3, 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: date1, benchmark: "000300.SH".to_string(), open: 100.0, close: 100.0, prev_close: 99.0, volume: 1_000_000, }, BenchmarkSnapshot { date: date2, benchmark: "000300.SH".to_string(), open: 101.0, close: 101.0, prev_close: 100.0, volume: 1_100_000, }, BenchmarkSnapshot { date: date3, benchmark: "000300.SH".to_string(), open: 102.0, close: 102.0, prev_close: 101.0, volume: 1_200_000, }, ], ) .expect("dataset"); let log = Rc::new(RefCell::new(Vec::new())); let process_log = Rc::new(RefCell::new(Vec::new())); let strategy = ScheduledProbeStrategy { log: log.clone(), process_log: process_log.clone(), }; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::DayOpen, ); let mut engine = BacktestEngine::new( data, strategy, broker, BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000300.SH".to_string(), start_date: Some(date1), end_date: Some(date3), decision_lag_trading_days: 0, execution_price_field: PriceField::DayOpen, }, ); engine.run().expect("backtest run"); assert_eq!( log.borrow().as_slice(), [ "scheduled:daily_auction:2025-01-30", "scheduled:first_trading_day_on_day:2025-01-30", "scheduled:daily_auction:2025-01-31", "scheduled:friday_on_day:2025-01-31", "scheduled:daily_auction:2025-02-03", "scheduled:first_trading_day_on_day:2025-02-03", ] ); let process_log = process_log.borrow(); assert!( process_log .iter() .any(|item| { item == "PreScheduled:scheduled:daily_auction:open_auction:pre" }) ); assert!( process_log .iter() .any(|item| { item == "PostScheduled:scheduled:daily_auction:open_auction:post" }) ); assert!( process_log .iter() .any(|item| { item == "PreScheduled:scheduled:friday_on_day:on_day:pre" }) ); assert!( process_log .iter() .any(|item| { item == "PostScheduled:scheduled:first_trading_day_on_day:on_day:post" }) ); }