use chrono::NaiveDate; use fidc_core::{ BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, CorporateAction, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision, }; use std::collections::{BTreeMap, BTreeSet}; fn d(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } struct BuyThenHoldStrategy; impl Strategy for BuyThenHoldStrategy { fn name(&self) -> &str { "buy-then-hold" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() { return Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), order_intents: vec![OrderIntent::Value { symbol: "000001.SZ".to_string(), value: 10_000.0, reason: "seed_position".to_string(), }], notes: Vec::new(), diagnostics: Vec::new(), }); } Ok(StrategyDecision::default()) } } #[test] fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() { 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: "Delisted".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(d(2020, 1, 1)), delisted_at: Some(date1), status: "delisted".to_string(), }, Instrument { symbol: "000002.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: date1, symbol: "000002.SZ".to_string(), timestamp: Some("2025-01-02 10:18:00".to_string()), day_open: 5.0, open: 5.0, high: 5.1, low: 4.9, close: 5.0, last_price: 5.0, bid1: 4.99, ask1: 5.01, prev_close: 5.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: 5.5, lower_limit: 4.5, price_tick: 0.01, }, DailyMarketSnapshot { date: date2, symbol: "000002.SZ".to_string(), timestamp: Some("2025-01-03 10:18:00".to_string()), day_open: 5.1, open: 5.1, high: 5.2, low: 5.0, close: 5.1, last_price: 5.1, bid1: 5.09, ask1: 5.11, prev_close: 5.0, volume: 120_000, tick_volume: 120_000, bid1_volume: 120_000, ask1_volume: 120_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 5.5, lower_limit: 4.5, 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: date1, symbol: "000002.SZ".to_string(), market_cap_bn: 30.0, free_float_cap_bn: 28.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }, DailyFactorSnapshot { date: date2, symbol: "000002.SZ".to_string(), market_cap_bn: 31.0, free_float_cap_bn: 29.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: date1, symbol: "000002.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: "000002.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 mut engine = BacktestEngine::new( data, BuyThenHoldStrategy, 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!(result.fills.len(), 2); assert!( result .fills .iter() .any(|fill| fill.reason.contains("delisted_cash_settlement") && fill.symbol == "000001.SZ") ); assert!( result .holdings_summary .iter() .all(|holding| holding.symbol != "000001.SZ") ); } #[test] fn engine_applies_successor_conversion_before_delisted_cash_settlement() { let date1 = d(2025, 1, 2); let date2 = d(2025, 1, 3); let data = DataSet::from_components_with_actions( vec![ Instrument { symbol: "000001.SZ".to_string(), name: "Legacy".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(d(2020, 1, 1)), delisted_at: Some(date2), status: "delisted".to_string(), }, Instrument { symbol: "000002.SZ".to_string(), name: "Successor".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: date1, symbol: "000002.SZ".to_string(), timestamp: Some("2025-01-02 10:18:00".to_string()), day_open: 20.0, open: 20.0, high: 20.0, low: 20.0, close: 20.0, last_price: 20.0, bid1: 20.0, ask1: 20.0, prev_close: 20.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: 22.0, lower_limit: 18.0, price_tick: 0.01, }, DailyMarketSnapshot { date: date2, symbol: "000002.SZ".to_string(), timestamp: Some("2025-01-03 10:18:00".to_string()), day_open: 21.0, open: 21.0, high: 21.0, low: 21.0, close: 21.0, last_price: 21.0, bid1: 21.0, ask1: 21.0, prev_close: 20.0, volume: 120_000, tick_volume: 120_000, bid1_volume: 120_000, ask1_volume: 120_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 22.0, lower_limit: 18.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: date1, symbol: "000002.SZ".to_string(), market_cap_bn: 30.0, free_float_cap_bn: 28.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }, DailyFactorSnapshot { date: date2, symbol: "000002.SZ".to_string(), market_cap_bn: 31.0, free_float_cap_bn: 29.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: date1, symbol: "000002.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: "000002.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, }, ], vec![CorporateAction { date: date2, symbol: "000001.SZ".to_string(), payable_date: None, share_cash: 0.0, share_bonus: 0.0, share_gift: 0.0, issue_quantity: 0.0, issue_price: 0.0, reform: false, adjust_factor: None, successor_symbol: Some("000002.SZ".to_string()), successor_ratio: Some(0.5), successor_cash: Some(1.0), }], ) .expect("dataset"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let mut engine = BacktestEngine::new( data, BuyThenHoldStrategy, 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!(result.equity_curve.iter().any(|point| { point .notes .contains("successor_conversion 000001.SZ->000002.SZ") })); assert!(result.fills.iter().all( |fill| !fill.reason.contains("delisted_cash_settlement") || fill.symbol != "000001.SZ" )); let successor_holding = result .holdings_summary .iter() .find(|holding| holding.symbol == "000002.SZ") .expect("successor holding exists"); assert_eq!(successor_holding.quantity, 450); assert!( result .holdings_summary .iter() .all(|holding| holding.symbol != "000001.SZ") ); assert!(result.account_events.iter().any(|event| { event .note .contains("successor_conversion 000001.SZ->000002.SZ") && event.note.contains("cash=900.00") })); }