use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; use std::rc::Rc; use chrono::{NaiveDate, NaiveDateTime}; use fidc_core::{ BacktestConfig, BacktestEngine, BacktestProcessMod, BacktestProcessModLoader, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesOrderIntent, FuturesTradingParameter, FuturesValidationConfig, Instrument, IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PlatformExprStrategy, PlatformExprStrategyConfig, PortfolioState, PriceField, ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } fn dt(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> NaiveDateTime { d(year, month, day) .and_hms_opt(hour, minute, second) .expect("valid datetime") } fn bool_flags(values: Vec) -> String { values .into_iter() .map(|value| if value { "1" } else { "0" }) .collect::>() .join(",") } fn single_day_anchor_data(date: NaiveDate) -> DataSet { 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 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: 9.9, volume: 1_000_000, tick_volume: 1_000_000, bid1_volume: 1_000_000, ask1_volume: 1_000_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 10.89, lower_limit: 8.91, price_tick: 0.01, }], vec![DailyFactorSnapshot { date, symbol: "000001.SZ".to_string(), market_cap_bn: 100.0, free_float_cap_bn: 80.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") } fn market_row(date: NaiveDate, symbol: &str, open: f64, close: f64) -> DailyMarketSnapshot { DailyMarketSnapshot { date, symbol: symbol.to_string(), timestamp: Some(format!("{date} 10:18:00")), day_open: open, open, high: open.max(close), low: open.min(close), close, last_price: close, bid1: close, ask1: close, prev_close: open, volume: 1_000_000, tick_volume: 1_000_000, bid1_volume: 1_000_000, ask1_volume: 1_000_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: open * 1.1, lower_limit: open * 0.9, price_tick: 0.2, } } fn factor_row( date: NaiveDate, symbol: &str, extra_factors: BTreeMap, ) -> DailyFactorSnapshot { DailyFactorSnapshot { date, symbol: symbol.to_string(), market_cap_bn: 100.0, free_float_cap_bn: 80.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors, } } fn candidate_row(date: NaiveDate, symbol: &str) -> 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, } } fn benchmark_row(date: NaiveDate) -> BenchmarkSnapshot { BenchmarkSnapshot { date, benchmark: "000300.SH".to_string(), open: 100.0, close: 100.0, prev_close: 99.0, volume: 1_000_000, } } fn two_day_futures_data() -> DataSet { let d1 = d(2025, 1, 2); let d2 = d(2025, 1, 3); DataSet::from_components_with_actions_quotes_and_futures( 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(), }, Instrument { symbol: "IF2501".to_string(), name: "IF".to_string(), board: "FUTURE".to_string(), round_lot: 1, listed_at: Some(d(2024, 1, 1)), delisted_at: None, status: "active".to_string(), }, ], vec![ market_row(d1, "000001.SZ", 10.0, 10.0), market_row(d2, "000001.SZ", 10.0, 10.0), market_row(d1, "IF2501", 4000.0, 4000.0), market_row(d2, "IF2501", 3988.0, 3990.0), ], vec![ factor_row( d1, "000001.SZ", BTreeMap::from([ ("custom_alpha".to_string(), 7.0), ("margin_all".to_string(), 1.0), ("yield_curve_1y".to_string(), 0.02), ("total_shares".to_string(), 123.0), ("stock_connect_north_bound".to_string(), 1.0), ("industry_citics_l1".to_string(), 10.0), ("fundamental_net_profit".to_string(), 99.0), ]), ), factor_row( d2, "000001.SZ", BTreeMap::from([ ("custom_alpha".to_string(), 8.0), ("margin_all".to_string(), 1.0), ("yield_curve_1y".to_string(), 0.021), ("total_shares".to_string(), 124.0), ("stock_connect_north_bound".to_string(), 1.0), ("industry_citics_l1".to_string(), 10.0), ("fundamental_net_profit".to_string(), 101.0), ]), ), ], vec![ candidate_row(d1, "000001.SZ"), candidate_row(d2, "000001.SZ"), ], vec![benchmark_row(d1), benchmark_row(d2)], Vec::new(), Vec::new(), vec![FuturesTradingParameter { symbol: "IF2501".to_string(), effective_date: Some(d1), contract_multiplier: 300.0, long_margin_rate: 0.12, short_margin_rate: 0.14, commission_type: FuturesCommissionType::ByVolume, open_commission_ratio: 2.5, close_commission_ratio: 2.0, close_today_commission_ratio: 3.0, price_tick: 0.2, }], ) .expect("futures dataset") } 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 FuturesOrderStrategy; impl Strategy for FuturesOrderStrategy { fn name(&self) -> &str { "futures-order" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { if ctx.execution_date != d(2025, 1, 2) { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::open( "IF2501", FuturesDirection::Long, FuturesContractSpec::new(300.0, 0.12, 0.14), 1, 4000.0, 12.0, "open index future", ), }], notes: Vec::new(), diagnostics: Vec::new(), }) } } struct FuturesLimitOrderStrategy; impl Strategy for FuturesLimitOrderStrategy { fn name(&self) -> &str { "futures-limit-order" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { if ctx.execution_date != d(2025, 1, 2) { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::limit_open( "IF2501", FuturesDirection::Long, FuturesContractSpec::new(1.0, 0.0, 0.0), 2, 3990.0, 0.0, "wait for pullback", ), }], ..StrategyDecision::default() }) } } struct FuturesInvalidTickLimitStrategy; impl Strategy for FuturesInvalidTickLimitStrategy { fn name(&self) -> &str { "futures-invalid-tick-limit" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { if ctx.execution_date != d(2025, 1, 2) { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::limit_open( "IF2501", FuturesDirection::Long, FuturesContractSpec::new(1.0, 0.0, 0.0), 1, 3988.13, 0.0, "bad tick limit", ), }], ..StrategyDecision::default() }) } } struct FuturesClosedPhaseOrderStrategy; impl Strategy for FuturesClosedPhaseOrderStrategy { fn name(&self) -> &str { "futures-closed-phase-order" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { if ctx.execution_date != d(2025, 1, 2) { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::open( "IF2501", FuturesDirection::Long, FuturesContractSpec::new(1.0, 0.0, 0.0), 1, 4000.0, 0.0, "closed phase order", ), }], ..StrategyDecision::default() }) } } struct FuturesAboveUpperLimitStrategy; impl Strategy for FuturesAboveUpperLimitStrategy { fn name(&self) -> &str { "futures-above-upper-limit" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { if ctx.execution_date != d(2025, 1, 2) { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::limit_open( "IF2501", FuturesDirection::Long, FuturesContractSpec::new(1.0, 0.0, 0.0), 1, 5000.0, 0.0, "outside upper limit", ), }], ..StrategyDecision::default() }) } } struct FuturesDepthLimitOrderStrategy; impl Strategy for FuturesDepthLimitOrderStrategy { fn name(&self) -> &str { "futures-depth-limit-order" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { if ctx.execution_date != d(2025, 1, 2) { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::limit_open( "IF2501", FuturesDirection::Long, FuturesContractSpec::new(1.0, 0.0, 0.0), 3, 3990.0, 0.0, "sweep depth until limit", ), }], ..StrategyDecision::default() }) } } struct AdvancedDataApiProbeStrategy { observed: Rc>>, } impl Strategy for AdvancedDataApiProbeStrategy { fn name(&self) -> &str { "data-api-probe" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { let factors = ctx.get_factor( "000001.SZ", ctx.execution_date, ctx.execution_date, "custom_alpha", ); let margin_stocks = ctx.get_margin_stocks("all"); let yield_curve = ctx.get_yield_curve(ctx.execution_date, ctx.execution_date, Some("1y")); let dominant = ctx.get_dominant_future("IF").unwrap_or_default(); let dominant_prices = ctx.get_dominant_future_price("IF", ctx.execution_date, ctx.execution_date, "1d"); let shares = ctx.get_shares("000001.SZ", ctx.execution_date, ctx.execution_date, "total"); let turnover = ctx.get_turnover_rate( "000001.SZ", ctx.execution_date, ctx.execution_date, "turnover", ); let price_change = ctx.get_price_change_rate("000001.SZ", ctx.execution_date, ctx.execution_date); let stock_connect = ctx.get_stock_connect( "000001.SZ", ctx.execution_date, ctx.execution_date, "north_bound", ); let industry = ctx .get_industry("000001.SZ", "citics", 1) .map(|row| row.value) .unwrap_or_default(); let fundamentals = ctx.get_fundamentals( "000001.SZ", ctx.execution_date, ctx.execution_date, "net_profit", ); self.observed.borrow_mut().push(format!( "factor={:.0};margin={};yield={:.3};dominant={};prices={};shares={:.0};turnover={:.1};change={:.3};connect={:.0};industry={:.0};profit={:.0}", factors.first().map(|row| row.value).unwrap_or_default(), margin_stocks.join(","), yield_curve.first().map(|row| row.value).unwrap_or_default(), dominant, dominant_prices.len(), shares.first().map(|row| row.value).unwrap_or_default(), turnover.first().map(|row| row.value).unwrap_or_default(), price_change.first().map(|row| row.value).unwrap_or_default(), stock_connect.first().map(|row| row.value).unwrap_or_default(), industry, fundamentals.first().map(|row| row.value).unwrap_or_default() )); Ok(StrategyDecision::default()) } } struct ScheduledProbeStrategy { log: Rc>>, process_log: Rc>>, } struct ProcessContextProbeStrategy { snapshots: Rc>>, } struct LimitCarryStrategy { issued: bool, } struct UniverseDirectiveStrategy { snapshots: Rc>>, } struct TickProbeStrategy { seen_ticks: Rc>>, ordered: bool, } struct DataApiProbeStrategy { target_date: NaiveDate, snapshots: Rc>>, } struct OrderInspectionStrategy { observed: Rc>>, } struct AccountFlowStrategy; 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_before_trading", ScheduleStage::BeforeTrading) .with_time_rule(ScheduleTimeRule::before_trading()), ScheduleRule::daily("daily_market_open", ScheduleStage::OpenAuction) .with_time_rule(ScheduleTimeRule::market_open(0, 0)), ScheduleRule::weekly_by_weekday("friday_on_day", 5, ScheduleStage::OnDay) .with_time_rule(ScheduleTimeRule::physical_time(10, 18)), ScheduleRule::monthly("first_trading_day_on_day", 1, ScheduleStage::OnDay) .with_time_rule(ScheduleTimeRule::physical_time(10, 18)), ] } 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(), }) } } impl Strategy for ProcessContextProbeStrategy { fn name(&self) -> &str { "process-context-probe" } fn on_process_event( &mut self, ctx: &StrategyContext<'_>, _event: &fidc_core::ProcessEvent, ) -> Result<(), fidc_core::BacktestError> { self.snapshots.borrow_mut().push(format!( "{}:{}:{}", ctx.current_process_event_kind(), ctx.latest_process_event_kind(), ctx.process_event_count() )); Ok(()) } fn on_day( &mut self, _ctx: &StrategyContext<'_>, ) -> Result { Ok(StrategyDecision::default()) } } impl Strategy for UniverseDirectiveStrategy { fn name(&self) -> &str { "universe-directive-probe" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { let eligible = ctx .eligible_universe_on(ctx.execution_date) .into_iter() .map(|row| row.symbol) .collect::>() .join(","); self.snapshots.borrow_mut().push(format!( "{}:{}:{}:{}", ctx.execution_date, ctx.dynamic_universe_count(), ctx.subscription_count(), eligible )); let order_intents = match ctx.execution_date { date if date == d(2025, 1, 2) => vec![ OrderIntent::UpdateUniverse { symbols: BTreeSet::from(["000002.SZ".to_string()]), reason: "focus_single_symbol".to_string(), }, OrderIntent::Subscribe { symbols: BTreeSet::from(["000001.SZ".to_string()]), reason: "subscribe_probe".to_string(), }, ], date if date == d(2025, 1, 3) => vec![OrderIntent::Unsubscribe { symbols: BTreeSet::from(["000001.SZ".to_string()]), reason: "unsubscribe_probe".to_string(), }], _ => Vec::new(), }; Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), order_intents, notes: Vec::new(), diagnostics: Vec::new(), }) } } impl Strategy for TickProbeStrategy { fn name(&self) -> &str { "tick-probe" } fn on_day( &mut self, _ctx: &StrategyContext<'_>, ) -> Result { Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), order_intents: vec![OrderIntent::Subscribe { symbols: BTreeSet::from(["000001.SZ".to_string()]), reason: "subscribe_tick_probe".to_string(), }], notes: Vec::new(), diagnostics: Vec::new(), }) } fn on_tick( &mut self, ctx: &StrategyContext<'_>, quote: &IntradayExecutionQuote, ) -> Result { let visible_last = ctx .history_bars("e.symbol, 9, "tick", "last", true) .iter() .map(|value| format!("{value:.2}")) .collect::>() .join(","); let previous_last = ctx .history_bars("e.symbol, 9, "tick", "last", false) .iter() .map(|value| format!("{value:.2}")) .collect::>() .join(","); self.seen_ticks.borrow_mut().push(format!( "{}:{}:{}:visible={visible_last}:previous={previous_last}", quote.symbol, quote.timestamp.time(), ctx.is_subscribed("e.symbol) )); if self.ordered { return Ok(StrategyDecision::default()); } self.ordered = true; Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), order_intents: vec![OrderIntent::Shares { symbol: quote.symbol.clone(), quantity: 100, reason: "tick_buy".to_string(), }], notes: Vec::new(), diagnostics: Vec::new(), }) } } impl Strategy for DataApiProbeStrategy { fn name(&self) -> &str { "data-api-probe" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { if ctx.execution_date == self.target_date { let daily_close = ctx .history_bars("000001.SZ", 2, "1d", "close", true) .iter() .map(|value| format!("{value:.2}")) .collect::>() .join(","); let previous_close = ctx .history_bars("000001.SZ", 2, "daily", "close", false) .iter() .map(|value| format!("{value:.2}")) .collect::>() .join(","); let tick_last = ctx .history_bars("000001.SZ", 2, "1m", "last", true) .iter() .map(|value| format!("{value:.2}")) .collect::>() .join(","); let previous_tick_last = ctx .history_bars("000001.SZ", 2, "1m", "last", false) .iter() .map(|value| format!("{value:.2}")) .collect::>() .join(","); let current_close = ctx .current_snapshot("000001.SZ") .map(|snapshot| format!("{:.2}", snapshot.close)) .unwrap_or_default(); let instrument_name = ctx .instrument("000001.SZ") .map(|instrument| instrument.name.clone()) .unwrap_or_default(); let prev_date = ctx .get_previous_trading_date(ctx.execution_date, 1) .map(|date| date.to_string()) .unwrap_or_default(); let next_date = ctx .get_next_trading_date(d(2025, 1, 3), 1) .map(|date| date.to_string()) .unwrap_or_default(); let trading_date_count = ctx .get_trading_dates(d(2025, 1, 2), ctx.execution_date) .len(); let suspended = bool_flags(ctx.is_suspended("000001.SZ", 3)); let st_flags = bool_flags(ctx.is_st_stock("000001.SZ", 3)); let daily_price_count = ctx .get_price("000001.SZ", d(2025, 1, 3), ctx.execution_date, "1d") .len(); let tick_price_count = ctx .get_price("000001.SZ", d(2025, 1, 3), ctx.execution_date, "tick") .len(); let instrument_history_count = ctx.instruments_history(&["000001.SZ", "000002.SZ"]).len(); let active_instrument_count = ctx.active_instruments(&["000001.SZ", "000002.SZ"]).len(); self.snapshots.borrow_mut().push(format!( "daily={daily_close};previous={previous_close};tick={tick_last};previous_tick={previous_tick_last};current={current_close};instrument={instrument_name};all={};history={instrument_history_count};active={active_instrument_count};range={trading_date_count};prev={prev_date};next={next_date};suspended={suspended};st={st_flags};price_daily={daily_price_count};price_tick={tick_price_count}", ctx.all_instruments().len() )); } Ok(StrategyDecision::default()) } } impl Strategy for OrderInspectionStrategy { fn name(&self) -> &str { "order-inspection" } fn on_day( &mut self, _ctx: &StrategyContext<'_>, ) -> Result { Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), order_intents: vec![OrderIntent::Shares { symbol: "000001.SZ".to_string(), quantity: 100, reason: "inspect_buy".to_string(), }], notes: Vec::new(), diagnostics: Vec::new(), }) } fn after_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), fidc_core::BacktestError> { let order = ctx.order(1).expect("order 1 visible after trading"); self.observed.borrow_mut().push(format!( "status={};filled={};unfilled={};avg={:.2};cost={:.2};symbol={};side={}", order.status.as_str(), order.filled_quantity, order.unfilled_quantity, order.avg_price, order.transaction_cost, order.symbol, order.side.as_str() )); Ok(()) } } impl Strategy for AccountFlowStrategy { fn name(&self) -> &str { "account-flow" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { if ctx.execution_date != d(2025, 1, 2) { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), order_intents: vec![ OrderIntent::FinanceRepay { amount: 1_000.0, reason: "borrow".to_string(), }, OrderIntent::DepositWithdraw { amount: 500.0, receiving_days: 0, reason: "cash_in".to_string(), }, OrderIntent::DepositWithdraw { amount: 1_000.0, receiving_days: 1, reason: "cash_in_next_day".to_string(), }, OrderIntent::SetManagementFeeRate { rate: 0.01, reason: "enable_fee".to_string(), }, ], notes: Vec::new(), diagnostics: Vec::new(), }) } fn management_fee( &mut self, _ctx: &StrategyContext<'_>, _rate: f64, ) -> Result, fidc_core::BacktestError> { Ok(Some(42.0)) } } #[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(), 36); assert_eq!( result.process_events[..18] .iter() .map(|event| &event.kind) .collect::>(), vec![ &ProcessEventKind::PreBeforeTrading, &ProcessEventKind::BeforeTrading, &ProcessEventKind::PostBeforeTrading, &ProcessEventKind::PreOpenAuction, &ProcessEventKind::OpenAuction, &ProcessEventKind::PostOpenAuction, &ProcessEventKind::PreOnDay, &ProcessEventKind::OnDay, &ProcessEventKind::PreBar, &ProcessEventKind::Bar, &ProcessEventKind::PostOnDay, &ProcessEventKind::PostBar, &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_executes_futures_order_intents_against_future_account() { 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 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: 9.9, volume: 1_000_000, tick_volume: 1_000_000, bid1_volume: 1_000_000, ask1_volume: 1_000_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 10.89, lower_limit: 8.91, price_tick: 0.01, }], vec![DailyFactorSnapshot { date, symbol: "000001.SZ".to_string(), market_cap_bn: 100.0, free_float_cap_bn: 80.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 broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let mut engine = BacktestEngine::new( data, FuturesOrderStrategy, broker, BacktestConfig { initial_cash: 100_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::Open, }, ) .with_futures_initial_cash(500_000.0); let result = engine.run().expect("backtest succeeds"); assert!(result.order_events.iter().any(|event| { event.symbol == "IF2501" && event.status == OrderStatus::Filled && event.filled_quantity == 1 })); assert!(result.fills.iter().any(|fill| { fill.symbol == "IF2501" && fill.quantity == 1 && (fill.commission - 12.0).abs() < 1e-6 })); assert!(result.process_events.iter().any(|event| { event.symbol.as_deref() == Some("IF2501") && event.kind == ProcessEventKind::Trade })); let futures_account = engine.futures_account().expect("future account"); let position = futures_account .position("IF2501", FuturesDirection::Long) .expect("long futures position"); assert_eq!(position.quantity, 1); assert!((futures_account.total_cash() - 499_988.0).abs() < 1e-6); assert!((futures_account.cash() - 355_988.0).abs() < 1e-6); } #[test] fn engine_settles_configured_futures_expiration_at_settlement() { let date = d(2025, 1, 2); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let mut engine = BacktestEngine::new( single_day_anchor_data(date), FuturesOrderStrategy, broker, BacktestConfig { initial_cash: 100_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::Open, }, ) .with_futures_initial_cash(500_000.0) .with_futures_expiration(date, "IF2501", 4010.0); let result = engine.run().expect("backtest succeeds"); assert_eq!( result .order_events .iter() .filter(|event| event.symbol == "IF2501" && event.status == OrderStatus::Filled) .count(), 2 ); let futures_account = engine.futures_account().expect("future account"); assert!( futures_account .position("IF2501", FuturesDirection::Long) .is_none() ); assert!((futures_account.total_cash() - 502_988.0).abs() < 1e-6); assert!(result.process_events.iter().any(|event| { event.symbol.as_deref() == Some("IF2501") && event.kind == ProcessEventKind::Trade && event.detail.contains("4010") })); } #[test] fn engine_aggregates_futures_account_into_nav_and_metrics() { let date = d(2025, 1, 2); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let mut engine = BacktestEngine::new( single_day_anchor_data(date), FuturesOrderStrategy, broker, BacktestConfig { initial_cash: 100_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::Open, }, ) .with_futures_initial_cash(500_000.0); let result = engine.run().expect("backtest succeeds"); assert_eq!(result.metrics.initial_cash, 600_000.0); assert!((result.equity_curve[0].total_equity - 599_988.0).abs() < 1e-6); assert!((result.metrics.total_assets - 599_988.0).abs() < 1e-6); assert_eq!(result.analyzer_report().trades.len(), result.fills.len()); assert_eq!(result.analyzer_report().monthly_returns.len(), 1); assert_eq!( result.analyzer_report().risk_summary.total_return, result.metrics.total_return ); assert!( result .analyzer_report_json() .expect("report json") .contains("\"monthly_returns\"") ); } #[test] fn engine_matches_pending_futures_limit_order_with_data_driven_costs() { let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let mut engine = BacktestEngine::new( two_day_futures_data(), FuturesLimitOrderStrategy, broker, BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000300.SH".to_string(), start_date: Some(d(2025, 1, 2)), end_date: Some(d(2025, 1, 3)), decision_lag_trading_days: 0, execution_price_field: PriceField::Open, }, ) .with_futures_initial_cash(1_000_000.0); let result = engine.run().expect("backtest succeeds"); assert!( result .order_events .iter() .any(|event| { event.symbol == "IF2501" && event.status == OrderStatus::Pending }) ); assert!(result.order_events.iter().any(|event| { event.symbol == "IF2501" && event.status == OrderStatus::Filled && event.filled_quantity == 2 })); let fill = result .fills .iter() .find(|fill| fill.symbol == "IF2501") .expect("futures fill"); assert!((fill.price - 3988.0).abs() < 1e-6); assert!((fill.commission - 5.0).abs() < 1e-6); let futures_account = engine.futures_account().expect("future account"); let position = futures_account .position("IF2501", FuturesDirection::Long) .expect("long futures position"); assert_eq!(position.quantity, 2); assert!((position.contract_multiplier - 300.0).abs() < 1e-6); } #[test] fn engine_rejects_futures_limit_orders_not_aligned_to_tick() { let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let mut engine = BacktestEngine::new( two_day_futures_data(), FuturesInvalidTickLimitStrategy, broker, BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000300.SH".to_string(), start_date: Some(d(2025, 1, 2)), end_date: Some(d(2025, 1, 2)), decision_lag_trading_days: 0, execution_price_field: PriceField::Open, }, ) .with_futures_initial_cash(1_000_000.0); let result = engine.run().expect("backtest succeeds"); assert!(result.order_events.iter().any(|event| { event.symbol == "IF2501" && event.status == OrderStatus::Rejected && event.reason.contains("not aligned to tick") })); } #[test] fn engine_allows_disabling_futures_limit_tick_validation() { let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let mut engine = BacktestEngine::new( two_day_futures_data(), FuturesInvalidTickLimitStrategy, broker, BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000300.SH".to_string(), start_date: Some(d(2025, 1, 2)), end_date: Some(d(2025, 1, 3)), decision_lag_trading_days: 0, execution_price_field: PriceField::Open, }, ) .with_futures_initial_cash(1_000_000.0) .with_futures_validation_config(FuturesValidationConfig { enforce_limit_price_tick: false, ..FuturesValidationConfig::default() }); let result = engine.run().expect("backtest succeeds"); assert!( result .order_events .iter() .any(|event| event.symbol == "IF2501" && event.status == OrderStatus::Pending) ); assert!(result.order_events.iter().any(|event| { event.symbol == "IF2501" && event.status == OrderStatus::Filled && event.filled_quantity == 1 })); let fill = result .fills .iter() .find(|fill| fill.symbol == "IF2501") .expect("futures fill"); assert!((fill.price - 3988.0).abs() < 1e-6); } #[test] fn engine_rejects_futures_limit_orders_outside_price_limits() { let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let mut engine = BacktestEngine::new( two_day_futures_data(), FuturesAboveUpperLimitStrategy, broker, BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000300.SH".to_string(), start_date: Some(d(2025, 1, 2)), end_date: Some(d(2025, 1, 2)), decision_lag_trading_days: 0, execution_price_field: PriceField::Open, }, ) .with_futures_initial_cash(1_000_000.0); let result = engine.run().expect("backtest succeeds"); assert!(result.order_events.iter().any(|event| { event.symbol == "IF2501" && event.status == OrderStatus::Rejected && event.reason.contains("above upper limit") })); } #[test] fn engine_rejects_futures_orders_when_trading_phase_is_closed() { let date = d(2025, 1, 2); let mut future_market = market_row(date, "IF2501", 4000.0, 4000.0); future_market.trading_phase = Some("closed".to_string()); let data = DataSet::from_components_with_actions_quotes_and_futures( 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(), }, Instrument { symbol: "IF2501".to_string(), name: "IF".to_string(), board: "FUTURE".to_string(), round_lot: 1, listed_at: Some(d(2024, 1, 1)), delisted_at: None, status: "active".to_string(), }, ], vec![market_row(date, "000001.SZ", 10.0, 10.0), future_market], vec![factor_row(date, "000001.SZ", BTreeMap::new())], vec![candidate_row(date, "000001.SZ")], vec![benchmark_row(date)], Vec::new(), Vec::new(), vec![FuturesTradingParameter { symbol: "IF2501".to_string(), effective_date: Some(date), contract_multiplier: 300.0, long_margin_rate: 0.12, short_margin_rate: 0.14, commission_type: FuturesCommissionType::ByVolume, open_commission_ratio: 2.5, close_commission_ratio: 2.0, close_today_commission_ratio: 3.0, price_tick: 0.2, }], ) .expect("futures dataset"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let mut engine = BacktestEngine::new( data, FuturesClosedPhaseOrderStrategy, broker, BacktestConfig { initial_cash: 100_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::Open, }, ) .with_futures_initial_cash(1_000_000.0); let result = engine.run().expect("backtest succeeds"); assert!(result.order_events.iter().any(|event| { event.symbol == "IF2501" && event.status == OrderStatus::Rejected && event.reason.contains("trading phase") })); } #[test] fn engine_sweeps_futures_order_book_depth_when_available() { let date = d(2025, 1, 2); let data = DataSet::from_components_with_actions_quotes_futures_and_depth( 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(), }, Instrument { symbol: "IF2501".to_string(), name: "IF".to_string(), board: "FUTURE".to_string(), round_lot: 1, listed_at: Some(d(2024, 1, 1)), delisted_at: None, status: "active".to_string(), }, ], vec![ market_row(date, "000001.SZ", 10.0, 10.0), market_row(date, "IF2501", 4000.0, 4000.0), ], vec![factor_row(date, "000001.SZ", BTreeMap::new())], vec![candidate_row(date, "000001.SZ")], vec![benchmark_row(date)], Vec::new(), Vec::new(), vec![FuturesTradingParameter { symbol: "IF2501".to_string(), effective_date: Some(date), contract_multiplier: 300.0, long_margin_rate: 0.12, short_margin_rate: 0.14, commission_type: FuturesCommissionType::ByVolume, open_commission_ratio: 2.5, close_commission_ratio: 2.0, close_today_commission_ratio: 3.0, price_tick: 0.2, }], vec![ IntradayOrderBookDepthLevel { date, symbol: "IF2501".to_string(), timestamp: date.and_hms_opt(10, 18, 0).unwrap(), level: 1, bid_price: 3987.8, bid_volume: 1, ask_price: 3988.0, ask_volume: 1, }, IntradayOrderBookDepthLevel { date, symbol: "IF2501".to_string(), timestamp: date.and_hms_opt(10, 18, 0).unwrap(), level: 2, bid_price: 3987.6, bid_volume: 1, ask_price: 3990.0, ask_volume: 1, }, IntradayOrderBookDepthLevel { date, symbol: "IF2501".to_string(), timestamp: date.and_hms_opt(10, 18, 0).unwrap(), level: 3, bid_price: 3987.4, bid_volume: 10, ask_price: 3994.0, ask_volume: 10, }, ], ) .expect("depth dataset"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Last, ) .with_matching_type(MatchingType::CounterpartyOffer); let mut engine = BacktestEngine::new( data, FuturesDepthLimitOrderStrategy, broker, BacktestConfig { initial_cash: 100_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::Last, }, ) .with_futures_initial_cash(1_000_000.0); let result = engine.run().expect("backtest succeeds"); let fill = result .fills .iter() .find(|fill| fill.symbol == "IF2501") .expect("depth futures fill"); assert_eq!(fill.quantity, 2); assert!((fill.price - 3989.0).abs() < 1e-6); assert!(result.order_events.iter().any(|event| { event.symbol == "IF2501" && event.status == OrderStatus::PartiallyFilled && event.filled_quantity == 2 })); assert!(result.order_events.iter().any(|event| { event.symbol == "IF2501" && event.status == OrderStatus::Pending && event.requested_quantity == 3 })); } #[test] fn strategy_context_exposes_advanced_data_helpers() { let observed = Rc::new(RefCell::new(Vec::new())); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let mut engine = BacktestEngine::new( two_day_futures_data(), AdvancedDataApiProbeStrategy { observed: observed.clone(), }, broker, BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000300.SH".to_string(), start_date: Some(d(2025, 1, 2)), end_date: Some(d(2025, 1, 2)), decision_lag_trading_days: 0, execution_price_field: PriceField::Open, }, ); let result = engine.run().expect("backtest succeeds"); assert_eq!( observed.borrow().as_slice(), &[ "factor=7;margin=000001.SZ;yield=0.020;dominant=IF2501;prices=1;shares=123;turnover=1.0;change=0.000;connect=1;industry=10;profit=99" ] ); assert!(result.analyzer_report().positions.is_empty()); } #[test] fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() { let date = d(2025, 1, 2); let data = DataSet::from_components_with_actions_and_quotes( 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 10:18:00".to_string()), day_open: 10.0, open: 10.0, high: 10.4, low: 9.9, close: 10.3, last_price: 10.2, bid1: 10.1, ask1: 10.2, 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, }], 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, }], Vec::new(), vec![ IntradayExecutionQuote { date, symbol: "000001.SZ".to_string(), timestamp: dt(2025, 1, 2, 10, 18, 0), last_price: 10.2, bid1: 10.1, ask1: 10.2, bid1_volume: 1_000, ask1_volume: 1_000, volume_delta: 1_000, amount_delta: 10_200.0, trading_phase: Some("continuous".to_string()), }, IntradayExecutionQuote { date, symbol: "000001.SZ".to_string(), timestamp: dt(2025, 1, 2, 10, 19, 0), last_price: 10.3, bid1: 10.2, ask1: 10.3, bid1_volume: 1_000, ask1_volume: 1_000, volume_delta: 1_000, amount_delta: 10_300.0, trading_phase: Some("continuous".to_string()), }, ], ) .expect("dataset"); let seen_ticks = Rc::new(RefCell::new(Vec::new())); let strategy = TickProbeStrategy { seen_ticks: seen_ticks.clone(), ordered: false, }; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Last, ); 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::Last, }, ); let result = engine.run().expect("backtest run"); assert_eq!( seen_ticks.borrow().as_slice(), [ "000001.SZ:10:18:00:true:visible=10.20:previous=", "000001.SZ:10:19:00:true:visible=10.20,10.30:previous=10.20" ] ); assert_eq!(result.fills.len(), 1); assert_eq!(result.fills[0].reason, "tick_buy"); assert_eq!(result.fills[0].quantity, 100); assert!( result .process_events .iter() .any(|event| event.kind == ProcessEventKind::PreTick) ); assert!( result .process_events .iter() .any(|event| event.kind == ProcessEventKind::Tick) ); assert!( result .process_events .iter() .any(|event| event.kind == ProcessEventKind::PostTick) ); } #[test] fn strategy_context_exposes_engine_native_data_helpers() { let date1 = d(2025, 1, 2); let date2 = d(2025, 1, 3); let date3 = d(2025, 1, 6); let instruments = 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(), }, Instrument { symbol: "000002.SZ".to_string(), name: "Historical".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(d(2020, 1, 1)), delisted_at: Some(date2), status: "active".to_string(), }, ]; let market = [ (date1, 10.0, 10.0, 10.0, 100_000), (date2, 10.1, 10.1, 10.0, 110_000), (date3, 10.2, 10.2, 10.1, 120_000), ] .into_iter() .map( |(date, open, close, prev_close, volume)| DailyMarketSnapshot { date, symbol: "000001.SZ".to_string(), timestamp: Some(format!("{date} 10:18:00")), day_open: open, open, high: close + 0.2, low: close - 0.2, close, last_price: close, bid1: close - 0.01, ask1: close + 0.01, prev_close, volume, tick_volume: volume, bid1_volume: volume, ask1_volume: volume, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: prev_close * 1.1, lower_limit: prev_close * 0.9, price_tick: 0.01, }, ) .collect::>(); let factors = [date1, date2, date3] .into_iter() .map(|date| 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(), }) .collect::>(); let candidates = [ (date1, false, false), (date2, true, true), (date3, false, false), ] .into_iter() .map(|(date, is_paused, is_st)| CandidateEligibility { date, symbol: "000001.SZ".to_string(), is_st, is_new_listing: false, is_paused, allow_buy: true, allow_sell: true, is_kcb: false, is_one_yuan: false, }) .collect::>(); let benchmarks = [ (date1, 100.0, 99.0), (date2, 101.0, 100.0), (date3, 102.0, 101.0), ] .into_iter() .map(|(date, close, prev_close)| BenchmarkSnapshot { date, benchmark: "000300.SH".to_string(), open: close, close, prev_close, volume: 1_000_000, }) .collect::>(); let quotes = vec![ IntradayExecutionQuote { date: date2, symbol: "000001.SZ".to_string(), timestamp: dt(2025, 1, 3, 14, 30, 0), last_price: 10.15, bid1: 10.14, ask1: 10.15, bid1_volume: 1000, ask1_volume: 1000, volume_delta: 1000, amount_delta: 10_150.0, trading_phase: Some("continuous".to_string()), }, IntradayExecutionQuote { date: date3, symbol: "000001.SZ".to_string(), timestamp: dt(2025, 1, 6, 10, 18, 0), last_price: 10.25, bid1: 10.24, ask1: 10.25, bid1_volume: 1000, ask1_volume: 1000, volume_delta: 1000, amount_delta: 10_250.0, trading_phase: Some("continuous".to_string()), }, IntradayExecutionQuote { date: date3, symbol: "000001.SZ".to_string(), timestamp: dt(2025, 1, 6, 10, 19, 0), last_price: 10.26, bid1: 10.25, ask1: 10.26, bid1_volume: 1000, ask1_volume: 1000, volume_delta: 1000, amount_delta: 10_260.0, trading_phase: Some("continuous".to_string()), }, ]; let data = DataSet::from_components_with_actions_and_quotes( instruments, market, factors, candidates, benchmarks, Vec::new(), quotes, ) .expect("dataset"); let snapshots = Rc::new(RefCell::new(Vec::new())); let strategy = DataApiProbeStrategy { target_date: date3, snapshots: snapshots.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: 10_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::Open, }, ); engine.run().expect("backtest run"); assert_eq!( snapshots.borrow().as_slice(), [ "daily=10.10,10.20;previous=10.00,10.10;tick=10.15,10.25;previous_tick=10.15;current=10.20;instrument=Anchor;all=2;history=2;active=1;range=3;prev=2025-01-03;next=2025-01-06;suspended=0,1,0;st=0,1,0;price_daily=2;price_tick=3" ] ); } #[test] fn strategy_context_exposes_final_order_runtime_view() { 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 10:18:00".to_string()), day_open: 10.0, open: 10.0, high: 10.4, low: 9.9, close: 10.2, last_price: 10.2, bid1: 10.19, ask1: 10.2, 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, }], 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 = Rc::new(RefCell::new(Vec::new())); let strategy = OrderInspectionStrategy { observed: observed.clone(), }; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Close, ); 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::Close, }, ); engine.run().expect("backtest run"); assert_eq!( observed.borrow().as_slice(), ["status=filled;filled=100;unfilled=0;avg=10.20;cost=5.00;symbol=000001.SZ;side=buy"] ); } #[test] fn strategy_context_exposes_engine_native_account_runtime_view() { let prev_date = d(2025, 1, 2); let date = d(2025, 1, 3); let data = DataSet::from_components( Vec::new(), Vec::new(), Vec::new(), Vec::new(), 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 mut portfolio = PortfolioState::new(10_000.0); portfolio .position_mut("000001.SZ") .buy(prev_date, 100, 10.0); portfolio.begin_trading_day(); portfolio.position_mut("000001.SZ").buy(date, 50, 11.0); portfolio .position_mut("000001.SZ") .sell(40, 12.0) .expect("sell"); portfolio.position_mut("000001.SZ").record_trade_cost(3.0); let open_orders = vec![OpenOrderView { order_id: 7, symbol: "000002.SZ".to_string(), side: OrderSide::Buy, requested_quantity: 100, filled_quantity: 0, remaining_quantity: 50, unfilled_quantity: 50, status: OrderStatus::Pending, avg_price: 0.0, transaction_cost: 0.0, limit_price: 12.0, reason: "pending_buy".to_string(), }]; let subscriptions = BTreeSet::new(); let ctx = StrategyContext { execution_date: date, decision_date: date, decision_index: 0, data: &data, portfolio: &portfolio, futures_account: None, open_orders: &open_orders, dynamic_universe: None, subscriptions: &subscriptions, process_events: &[], active_process_event: None, active_datetime: None, order_events: &[], fills: &[], }; let account = ctx.account(); assert_eq!(account.account_type, "STOCK"); assert!((account.starting_cash - 10_000.0).abs() < 1e-6); assert!((account.frozen_cash - 600.0).abs() < 1e-6); assert!((account.available_cash - 9_400.0).abs() < 1e-6); assert!((account.transaction_cost - 3.0).abs() < 1e-6); assert!((account.daily_pnl - 247.0).abs() < 1e-6); assert!((account.daily_returns - portfolio.daily_returns()).abs() < 1e-6); assert!((account.total_returns - portfolio.total_returns()).abs() < 1e-6); assert!((ctx.available_cash() - account.available_cash).abs() < 1e-6); assert!(ctx.future_account().is_none()); assert!(ctx.account_by_type("FUTURE").is_none()); assert!((ctx.account_by_type("stock").unwrap().cash - account.cash).abs() < 1e-6); assert_eq!( ctx.accounts().keys().cloned().collect::>(), vec!["STOCK".to_string()] ); let spec = FuturesContractSpec::new(300.0, 0.12, 0.14); let mut futures_account = FuturesAccountState::new(500_000.0); futures_account.open("IF2501", FuturesDirection::Long, spec, 2, 4000.0, 12.0); futures_account.mark_price("IF2501", FuturesDirection::Long, 4010.0); let future_ctx = StrategyContext { execution_date: date, decision_date: date, decision_index: 0, data: &data, portfolio: &portfolio, futures_account: Some(&futures_account), open_orders: &open_orders, dynamic_universe: None, subscriptions: &subscriptions, process_events: &[], active_process_event: None, active_datetime: None, order_events: &[], fills: &[], }; let future_account = future_ctx.future_account().expect("future account"); assert_eq!(future_account.account_type, "FUTURE"); assert!((future_account.starting_cash - 500_000.0).abs() < 1e-6); assert!((future_account.cash - 211_268.0).abs() < 1e-6); assert!((future_account.market_value - 2_406_000.0).abs() < 1e-6); assert!((future_account.total_value - 505_988.0).abs() < 1e-6); assert!((future_ctx.account_by_type("future").unwrap().cash - 211_268.0).abs() < 1e-6); assert_eq!( future_ctx.accounts().keys().cloned().collect::>(), vec!["FUTURE".to_string(), "STOCK".to_string()] ); } #[test] fn engine_applies_account_cash_flow_and_financing_intents() { 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: 9.99, ask1: 10.01, prev_close: 9.9, 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.0, open: 10.0, high: 10.0, low: 10.0, close: 10.0, last_price: 10.0, bid1: 9.99, ask1: 10.01, 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, }, ], 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: 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: 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: 100.0, close: 100.0, prev_close: 100.0, volume: 1_000_000, }, ], ) .expect("dataset"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Close, ); let mut engine = BacktestEngine::new( data, AccountFlowStrategy, broker, BacktestConfig { initial_cash: 10_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::Close, }, ); let result = engine.run().expect("backtest run"); assert!((result.equity_curve[0].cash - 11_458.0).abs() < 1e-6); assert!((result.equity_curve[0].total_equity - 10_458.0).abs() < 1e-6); assert!((result.equity_curve[1].cash - 12_416.0).abs() < 1e-6); assert!((result.equity_curve[1].total_equity - 11_416.0).abs() < 1e-6); assert!(result.account_events.iter().any(|event| { event .note .contains("finance_repay amount=1000.00 liabilities_before=0.00") })); assert!(result.account_events.iter().any(|event| { event .note .contains("deposit_withdraw_scheduled amount=1000.00") })); assert!(result.account_events.iter().any(|event| { event .note .contains("deposit_withdraw_settled amount=1000.00") })); assert!(result.account_events.iter().any(|event| { event .note .contains("management_fee rate=0.010000 fee=42.00") })); assert!(result.process_events.iter().any(|event| { event.kind == ProcessEventKind::AccountFinanceRepay && event.detail.contains("liabilities_after=1000.00") })); assert!(result.process_events.iter().any(|event| { event.kind == ProcessEventKind::AccountManagementFee && event.detail.contains("fee=42.00") })); } #[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_before_trading:2025-01-30", "scheduled:daily_market_open:2025-01-30", "scheduled:first_trading_day_on_day:2025-01-30", "scheduled:daily_before_trading:2025-01-31", "scheduled:daily_market_open:2025-01-31", "scheduled:friday_on_day:2025-01-31", "scheduled:daily_before_trading:2025-02-03", "scheduled:daily_market_open: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_before_trading:before_trading:pre" }) ); assert!( process_log .iter() .any(|item| { item == "PostScheduled:scheduled:daily_market_open: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" }) ); } #[test] fn engine_dispatches_process_events_to_external_bus_listeners() { 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, process_log }; 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, }, ); let external_log = Rc::new(RefCell::new(Vec::new())); engine.add_process_listener(ProcessEventKind::PreScheduled, { let external_log = external_log.clone(); move |event| { external_log .borrow_mut() .push(format!("{:?}:{}", event.kind, event.detail)); } }); engine.add_process_listener(ProcessEventKind::PostScheduled, { let external_log = external_log.clone(); move |event| { external_log .borrow_mut() .push(format!("{:?}:{}", event.kind, event.detail)); } }); engine.run().expect("backtest run"); let external_log = external_log.borrow(); assert!( external_log.iter().any(|item| { item == "PreScheduled:scheduled:daily_before_trading:before_trading:pre" }) ); assert!( external_log .iter() .any(|item| { item == "PostScheduled:scheduled:first_trading_day_on_day:on_day:post" }) ); } struct AnyEventCountingMod { sink: Rc>>, } impl BacktestProcessMod for AnyEventCountingMod { fn name(&self) -> &str { "any-event-counter" } fn install(&mut self, bus: &mut ProcessEventBus) { let sink = self.sink.clone(); bus.add_any_listener(move |event: &ProcessEvent| { sink.borrow_mut() .push(format!("{:?}:{}", event.kind, event.detail)); }); } } struct NamedEventCountingMod { name: &'static str, sink: Rc>>, } impl BacktestProcessMod for NamedEventCountingMod { fn name(&self) -> &str { self.name } fn install(&mut self, bus: &mut ProcessEventBus) { let sink = self.sink.clone(); bus.add_any_listener(move |event: &ProcessEvent| { sink.borrow_mut() .push(format!("{:?}:{}", event.kind, event.detail)); }); } } #[test] fn engine_installs_process_mods_on_event_bus() { let date = d(2025, 1, 2); let data = single_day_anchor_data(date); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::DayOpen, ); let mut engine = BacktestEngine::new( data, HookProbeStrategy { log: Rc::new(RefCell::new(Vec::new())), }, broker, BacktestConfig { initial_cash: 100_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 sink = Rc::new(RefCell::new(Vec::new())); let mut module = AnyEventCountingMod { sink: sink.clone() }; engine.install_process_mod(&mut module); engine.run().expect("backtest run"); assert!( sink.borrow() .iter() .any(|item| { item.starts_with("PreBeforeTrading:before_trading:pre") }) ); } #[test] fn engine_installs_enabled_process_mods_from_loader() { let date = d(2025, 1, 2); let data = single_day_anchor_data(date); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::DayOpen, ); let mut engine = BacktestEngine::new( data, HookProbeStrategy { log: Rc::new(RefCell::new(Vec::new())), }, broker, BacktestConfig { initial_cash: 100_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 enabled_sink = Rc::new(RefCell::new(Vec::new())); let disabled_sink = Rc::new(RefCell::new(Vec::new())); let mut loader = BacktestProcessModLoader::new(); loader.register(NamedEventCountingMod { name: "enabled-counter", sink: enabled_sink.clone(), }); loader.register(NamedEventCountingMod { name: "disabled-counter", sink: disabled_sink.clone(), }); assert_eq!( loader.module_names(), vec![ "enabled-counter".to_string(), "disabled-counter".to_string() ] ); let installed = engine.install_enabled_process_mods(&mut loader, &["enabled-counter".to_string()]); engine.run().expect("backtest run"); assert_eq!(installed, vec!["enabled-counter".to_string()]); assert!(!enabled_sink.borrow().is_empty()); assert!(disabled_sink.borrow().is_empty()); } #[test] fn engine_applies_dynamic_universe_and_subscription_directives() { let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)]; let snapshots = Rc::new(RefCell::new(Vec::new())); let strategy = UniverseDirectiveStrategy { snapshots: snapshots.clone(), }; let instruments = vec![ Instrument { symbol: "000001.SZ".to_string(), name: "One".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(d(2020, 1, 1)), delisted_at: None, status: "active".to_string(), }, Instrument { symbol: "000002.SZ".to_string(), name: "Two".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(d(2020, 1, 1)), delisted_at: None, status: "active".to_string(), }, ]; let markets = dates .iter() .flat_map(|date| { [ DailyMarketSnapshot { date: *date, symbol: "000001.SZ".to_string(), timestamp: Some(format!("{date} 10:18:00")), day_open: 10.0, open: 10.0, high: 10.1, low: 9.9, close: 10.0, last_price: 10.0, bid1: 9.99, ask1: 10.01, prev_close: 9.95, volume: 100_000, tick_volume: 5_000, bid1_volume: 2_000, ask1_volume: 2_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 11.0, lower_limit: 9.0, price_tick: 0.01, }, DailyMarketSnapshot { date: *date, symbol: "000002.SZ".to_string(), timestamp: Some(format!("{date} 10:18:00")), day_open: 20.0, open: 20.0, high: 20.1, low: 19.9, close: 20.0, last_price: 20.0, bid1: 19.99, ask1: 20.01, prev_close: 19.95, volume: 100_000, tick_volume: 5_000, bid1_volume: 2_000, ask1_volume: 2_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 22.0, lower_limit: 18.0, price_tick: 0.01, }, ] }) .collect::>(); let factors = dates .iter() .flat_map(|date| { [ DailyFactorSnapshot { date: *date, symbol: "000001.SZ".to_string(), market_cap_bn: 10.0, free_float_cap_bn: 8.0, pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }, DailyFactorSnapshot { date: *date, symbol: "000002.SZ".to_string(), market_cap_bn: 12.0, free_float_cap_bn: 10.0, pe_ttm: 12.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), }, ] }) .collect::>(); let candidates = dates .iter() .flat_map(|date| { [ CandidateEligibility { date: *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, }, CandidateEligibility { date: *date, 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, }, ] }) .collect::>(); let benchmarks = dates .iter() .map(|date| BenchmarkSnapshot { date: *date, benchmark: "000852.SH".to_string(), open: 1000.0, close: 1000.0, prev_close: 999.0, volume: 100_000, }) .collect::>(); let data = DataSet::from_components(instruments, markets, factors, candidates, benchmarks) .expect("dataset"); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let mut engine = BacktestEngine::new( data, strategy, broker, BacktestConfig { initial_cash: 1_000_000.0, benchmark_code: "000852.SH".to_string(), start_date: Some(dates[0]), end_date: Some(dates[2]), decision_lag_trading_days: 0, execution_price_field: PriceField::Open, }, ); let result = engine.run().expect("backtest result"); assert_eq!( snapshots.borrow().as_slice(), &[ "2025-01-02:0:0:000001.SZ,000002.SZ", "2025-01-03:1:1:000002.SZ", "2025-01-06:1:0:000002.SZ", ] ); assert!( result .process_events .iter() .any(|event| event.kind == ProcessEventKind::UniverseUpdated) ); assert!( result .process_events .iter() .any(|event| event.kind == ProcessEventKind::UniverseSubscribed) ); assert!( result .process_events .iter() .any(|event| event.kind == ProcessEventKind::UniverseUnsubscribed) ); } #[test] fn engine_exposes_current_process_context_to_strategies() { 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 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: 9.9, 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, }], 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 snapshots = Rc::new(RefCell::new(Vec::new())); let strategy = ProcessContextProbeStrategy { snapshots: snapshots.clone(), }; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Last, ); let mut engine = BacktestEngine::new( data, strategy, broker, BacktestConfig { initial_cash: 100_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::Last, }, ); engine.run().expect("backtest run"); let snapshots = snapshots.borrow(); assert_eq!( snapshots.first().map(String::as_str), Some("pre_before_trading:pre_before_trading:1") ); assert!(snapshots.iter().any(|item| item == "on_day:on_day:8")); } struct BuyMissingRowThenHoldStrategy; impl Strategy for BuyMissingRowThenHoldStrategy { fn name(&self) -> &str { "buy-missing-row-then-hold" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { if ctx.execution_date == d(2025, 5, 26) { return Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), order_intents: vec![OrderIntent::Value { symbol: "601028.SH".to_string(), value: 1_000.0, reason: "seed_position".to_string(), }], notes: Vec::new(), diagnostics: Vec::new(), }); } Ok(StrategyDecision::default()) } } #[test] fn engine_carries_position_price_when_current_market_row_is_missing() { let date1 = d(2025, 5, 26); let date2 = d(2025, 5, 27); let data = DataSet::from_components( vec![ Instrument { symbol: "601028.SH".to_string(), name: "Missing Row".to_string(), board: "SH".to_string(), round_lot: 100, listed_at: Some(d(2020, 1, 1)), delisted_at: None, status: "active".to_string(), }, 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![ market_row(date1, "601028.SH", 10.0, 10.3), market_row(date2, "000001.SZ", 20.0, 20.2), ], vec![ factor_row(date1, "601028.SH", BTreeMap::new()), factor_row(date2, "000001.SZ", BTreeMap::new()), ], vec![ candidate_row(date1, "601028.SH"), candidate_row(date2, "000001.SZ"), ], vec![ benchmark_row(date1), 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, BuyMissingRowThenHoldStrategy, 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 should not fail on one missing holding row"); assert_eq!(result.equity_curve.len(), 2); assert!( result .daily_holdings .iter() .any(|holding| holding.date == date2 && holding.symbol == "601028.SH") ); } #[test] fn platform_strategy_skips_position_stop_take_when_current_market_row_is_missing() { let date1 = d(2025, 5, 26); let date2 = d(2025, 5, 27); let data = DataSet::from_components( vec![ Instrument { symbol: "601028.SH".to_string(), name: "Missing Row".to_string(), board: "SH".to_string(), round_lot: 100, listed_at: Some(d(2020, 1, 1)), delisted_at: None, status: "active".to_string(), }, Instrument { symbol: "000001.SZ".to_string(), name: "Signal 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![ market_row(date1, "601028.SH", 10.0, 10.3), market_row(date1, "000001.SZ", 20.0, 20.0), market_row(date2, "000001.SZ", 20.0, 20.2), ], vec![ factor_row(date1, "601028.SH", BTreeMap::new()), factor_row(date2, "000001.SZ", BTreeMap::new()), ], vec![ candidate_row(date1, "601028.SH"), candidate_row(date2, "000001.SZ"), ], vec![ benchmark_row(date1), 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 mut config = PlatformExprStrategyConfig::microcap_rotation(); config.strategy_name = "missing-row-platform-risk".to_string(); config.benchmark_symbol = "000300.SH".to_string(); config.signal_symbol = "000001.SZ".to_string(); config.refresh_rate = 1; config.max_positions = 1; config.prelude.clear(); config.universe_exclude.clear(); config.market_cap_field = "market_cap".to_string(); config.market_cap_lower_expr = "0".to_string(); config.market_cap_upper_expr = "200".to_string(); config.selection_limit_expr = "1".to_string(); config.stock_filter_expr = "true".to_string(); config.stop_loss_expr = "0.93".to_string(); config.take_profit_expr = "1.07".to_string(); config.benchmark_short_ma_days = 1; config.benchmark_long_ma_days = 1; config.stock_short_ma_days = 1; config.stock_mid_ma_days = 1; config.stock_long_ma_days = 1; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), PriceField::Open, ); let mut engine = BacktestEngine::new( data, PlatformExprStrategy::new(config), 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("platform strategy should hold through a missing current market row"); assert_eq!(result.equity_curve.len(), 2); assert!( result .daily_holdings .iter() .any(|holding| holding.date == date2 && holding.symbol == "601028.SH") ); }