use chrono::{NaiveDate, NaiveTime}; use fidc_core::{ BacktestConfig, BacktestEngine, BrokerSimulator, ChinaAShareCostModel, ChinaEquityRuleHooks, DataSet, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision, }; use std::{cell::RefCell, collections::BTreeSet, rc::Rc}; const SYMBOL: &str = "000001.SZ"; fn day(value: u32) -> NaiveDate { NaiveDate::from_ymd_opt(2026, 9, value).unwrap() } fn data() -> DataSet { let days = [11, 14, 15].map(day); DataSet::from_components_with_actions_and_quotes( vec![fidc_core::Instrument { symbol: SYMBOL.into(), name: "fixture".into(), board: "SZ".into(), round_lot: 100, listed_at: Some(day(1)), delisted_at: None, status: "active".into(), }], days.iter() .map(|&date| { let price = if date == day(11) { 10. } else { 8.95 }; fidc_core::DailyMarketSnapshot { date, symbol: SYMBOL.into(), timestamp: Some(format!("{date} 15:00:00")), day_open: price, open: price, high: price, low: price, close: price, last_price: price, bid1: price, ask1: price, prev_close: price, volume: 100000, minute_volume: 100000, bid1_volume: 100000, ask1_volume: 100000, trading_phase: Some("continuous".into()), paused: false, upper_limit: price * 1.1, lower_limit: price * 0.9, price_tick: 0.01, } }) .collect(), days.iter() .map(|&date| fidc_core::DailyFactorSnapshot { date, symbol: SYMBOL.into(), market_cap_bn: 10., free_float_cap_bn: 10., pe_ttm: 10., turnover_ratio: None, effective_turnover_ratio: None, adjustment_factor_backward1: Some(1.), extra_factors: Default::default(), }) .collect(), days.iter() .map(|&date| fidc_core::CandidateEligibility { date, symbol: SYMBOL.into(), is_st: false, is_star_st: false, is_new_listing: false, is_paused: false, allow_buy: true, allow_sell: true, is_kcb: false, is_one_yuan: false, risk_level_code: None, }) .collect(), days.iter() .map(|&date| fidc_core::BenchmarkSnapshot { date, benchmark: "000300.SH".into(), open: 100., close: 100., prev_close: 100., volume: 100000, }) .collect(), vec![fidc_core::CorporateAction { date: day(14), symbol: SYMBOL.into(), payable_date: Some(day(14)), share_cash: 1.05, share_bonus: 0., share_gift: 0., issue_quantity: 0., issue_price: 0., reform: false, adjust_factor: None, successor_symbol: None, successor_ratio: None, successor_cash: None, }], [(9, 15), (9, 31)] .into_iter() .map(|(hour, minute)| fidc_core::IntradayExecutionQuote { observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar, date: day(14), symbol: SYMBOL.into(), timestamp: day(14).and_hms_opt(hour, minute, 0).unwrap(), last_price: 8.95, bid1: 8.95, ask1: 8.95, bid1_volume: 100000, ask1_volume: 100000, volume_delta: 10000, amount_delta: 89500., trading_phase: Some("continuous".into()), }) .collect(), ) .unwrap() } struct Hold { seen: Rc>>, } impl Strategy for Hold { fn name(&self) -> &str { "accounting reinvestment contract" } fn initial_subscriptions(&self) -> BTreeSet { [SYMBOL.into()].into() } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { Ok(if ctx.execution_date == day(11) { StrategyDecision { order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.into(), quantity: 1000, reason: "initial".into(), }], ..Default::default() } } else { StrategyDecision::default() }) } fn on_minute( &mut self, ctx: &StrategyContext<'_>, _: &fidc_core::IntradayExecutionQuote, ) -> Result { self.seen.borrow_mut().push(( ctx.current_time().unwrap(), ctx.portfolio .position(SYMBOL) .map_or(0, |position| position.quantity), )); Ok(Default::default()) } } fn engine() -> BacktestEngine { BacktestEngine::new( data(), Hold { seen: Rc::new(RefCell::new(Vec::new())), }, BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default() .with_commission_rate(0.0008) .with_minimum_commission(0.), ChinaEquityRuleHooks, PriceField::Open, ) .with_matching_type(MatchingType::NextBarOpen) .with_volume_limit(false) .with_liquidity_limit(false), BacktestConfig { initial_cash: 50000., benchmark_code: "000300.SH".into(), start_date: Some(day(11)), end_date: Some(day(15)), decision_lag_trading_days: 0, execution_price_field: PriceField::Open, }, ) .with_dividend_reinvestment(true) } #[test] fn accounting_reinvestment_has_an_explicit_origin_clock_and_progress_delivery() { let mut progress = Vec::new(); let result = engine() .run_with_progress(|event| progress.push(event.clone())) .unwrap(); let reinvest = result .fills .iter() .find(|fill| fill.reason == "dividend_reinvestment") .unwrap(); assert_eq!( ( reinvest.quantity, reinvest.price, reinvest.commission, reinvest.order_id ), (100, 8.95, 0., None) ); assert_eq!( serde_json::to_value(reinvest).unwrap()["origin"], "dividend_reinvestment" ); assert_eq!(reinvest.execution_timestamp, day(14).and_hms_opt(0, 0, 0)); let received = progress.iter().find(|event| event.date == day(14)).unwrap(); assert!( received .fills .iter() .any(|fill| fill.reason == "dividend_reinvestment") ); assert_eq!( progress .iter() .map(|event| event.daily_fill_count) .sum::(), result.fills.len() ); } fn manual_source(delayed: bool) -> fidc_core::manual_execution::ManualExecutionReplay { let observed = if delayed { "2026-09-14T01:15:00Z" } else { "2026-09-11T06:00:01Z" }; let mut source: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({ "schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"", "observationCutoff":"2026-09-15T08:00:00Z","actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"], "confirmedAt":"2026-09-11T05:59:59Z","confirmationObservedAt":"2026-09-11T05:59:59Z","outcome":"orders_terminal","orders":[{ "orderId":"manual-order","sourceAdapter":"paper","symbol":SYMBOL,"side":"Buy","quantity":1000, "orderCreatedAt":"2026-09-11T05:59:59Z","terminalObservedAt":observed,"terminalStatus":"filled","fills":[{ "tradeId":"manual-fill","observationEventId":"receipt","observationSequence":1,"feeObservationEventId":"receipt","feeObservationSequence":1, "feeObservedAt":observed,"tradeDate":"2026-09-11","executedAt":"2026-09-11T06:00:00Z","observedAt":observed, "timestampPrecision":"second","quantity":1000,"price":"10","totalFee":"1" }] }]}] })).unwrap(); source.content_sha256 = source.content_digest().unwrap(); source } #[test] fn delayed_receipt_before_market_open_reconciles_accounting_not_future_market_fills() { let timely = engine() .with_observed_manual_executions(manual_source(false)) .unwrap() .run() .unwrap(); let delayed = engine() .with_observed_manual_executions(manual_source(true)) .unwrap() .run() .unwrap(); assert_eq!(delayed.holdings_summary[0].quantity, 2200); assert_eq!( delayed.holdings_summary[0].quantity, timely.holdings_summary[0].quantity ); assert_eq!( delayed.equity_curve.last().unwrap().cash, timely.equity_curve.last().unwrap().cash ); assert_eq!( delayed.manual_executions[0] .corporate_adjustment .as_ref() .unwrap() .corporate_cash_delta, "155" ); } #[test] fn weekend_receipts_and_morning_allocations_are_in_the_next_progress_batch() { let mut source = manual_source(true); let observed = "2026-09-12T02:00:00Z".parse().unwrap(); let order = &mut source.actions[0].orders[0]; order.terminal_observed_at = observed; order.fills[0].observed_at = observed; order.fills[0].fee_observed_at = observed; source.content_sha256 = source.content_digest().unwrap(); let mut progress = Vec::new(); let result = engine() .with_observed_manual_executions(source) .unwrap() .run_with_progress(|event| progress.push(event.clone())) .unwrap(); let monday = progress.iter().find(|event| event.date == day(14)).unwrap(); assert_eq!(monday.daily_manual_fill_count, 1); assert_eq!(monday.manual_executions[0].observed_at, observed); assert!( monday .fills .iter() .any(|fill| fill.origin == fidc_core::FillOrigin::DividendReinvestment) ); assert!( monday .process_events .iter() .any(|event| event.kind == fidc_core::ProcessEventKind::ManualExecutionObserved) ); assert_eq!( progress .iter() .map(|event| event.daily_fill_count) .sum::(), result.fills.len() + result.manual_executions.len() ); } fn exposure_event(id: &str, sequence: u64, at: &str, action: fidc_core::position_exposure::PositionExposureAction) -> fidc_core::position_exposure::PositionExposureEvent { fidc_core::position_exposure::PositionExposureEvent { event_id: id.into(), sequence, effective_at: at.parse().unwrap(), allocation_weights_bps: None, action, } } fn cleared_reinvestment_case( events: Vec, legacy: std::collections::BTreeMap, extra_buy_delayed: Option, ) -> fidc_core::BacktestResult { let mut parts = data().snapshot_components(); parts.corporate_actions[0].payable_date = Some(day(15)); let data = DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market, parts.factors, parts.candidates, parts.benchmarks, parts.corporate_actions, parts.execution_quotes).unwrap(); let mut source = manual_source(false); let mut sale = source.actions[0].clone(); sale.action_id = "clear".into(); sale.audit_event_ids = vec!["clear-audit".into()]; sale.confirmed_at = "2026-09-14T05:59:59Z".parse().unwrap(); sale.confirmation_observed_at = sale.confirmed_at; let order = &mut sale.orders[0]; order.order_id = "clear-order".into(); order.side = fidc_core::OrderSide::Sell; order.order_created_at = sale.confirmed_at; order.terminal_observed_at = "2026-09-14T06:00:01Z".parse().unwrap(); let fill = &mut order.fills[0]; fill.trade_id = "clear-fill".into(); fill.observation_event_id = "clear-receipt".into(); fill.observation_sequence = 2; fill.fee_observation_event_id = "clear-receipt".into(); fill.fee_observation_sequence = 2; fill.trade_date = day(14); fill.executed_at = "2026-09-14T06:00:00Z".parse().unwrap(); fill.observed_at = order.terminal_observed_at; fill.fee_observed_at = order.terminal_observed_at; fill.price = "8.95".parse().unwrap(); source.actions.push(sale); if let Some(delayed) = extra_buy_delayed { let mut extra = source.actions[0].clone(); extra.action_id = "extra".into(); extra.audit_event_ids = vec!["extra-audit".into()]; extra.confirmed_at = "2026-09-11T06:00:59Z".parse().unwrap(); extra.confirmation_observed_at = extra.confirmed_at; let order = &mut extra.orders[0]; order.order_id = "extra-order".into(); order.order_created_at = extra.confirmed_at; order.terminal_observed_at = if delayed { "2026-09-15T01:15:00Z" } else { "2026-09-11T06:01:01Z" }.parse().unwrap(); let fill = &mut order.fills[0]; fill.trade_id = "extra-fill".into(); fill.observation_event_id = "extra-receipt".into(); fill.observation_sequence = if delayed { 3 } else { 2 }; fill.fee_observation_event_id = "extra-receipt".into(); fill.fee_observation_sequence = fill.observation_sequence; fill.executed_at = "2026-09-11T06:01:00Z".parse().unwrap(); fill.observed_at = order.terminal_observed_at; fill.fee_observed_at = order.terminal_observed_at; if !delayed { source.actions[1].orders[0].fills[0].observation_sequence = 3; source.actions[1].orders[0].fills[0].fee_observation_sequence = 3; } source.actions.push(extra); } source.position_exposure_events = events; source.legacy_position_exposure_bps = legacy; source.content_sha256 = source.content_digest().unwrap(); let mut config = fidc_core::PlatformExprStrategyConfig::generic(); config.signal_symbol = SYMBOL.into(); config.benchmark_symbol = "000300.SH".into(); config.rotation_enabled = false; config.matching_type = MatchingType::CurrentBarClose; BacktestEngine::new(data, fidc_core::PlatformExprStrategy::new(config), BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) .with_matching_type(MatchingType::CurrentBarClose).with_volume_limit(false).with_liquidity_limit(false), BacktestConfig { initial_cash: 50000., benchmark_code: "000300.SH".into(), start_date: Some(day(11)), end_date: Some(day(15)), decision_lag_trading_days: 0, execution_price_field: PriceField::Close }) .with_dividend_reinvestment(true).with_observed_manual_executions(source).unwrap().run().unwrap() } #[test] fn an_effective_manual_zero_must_not_recreate_a_cleared_position_on_dividend_payment() { use fidc_core::position_exposure::PositionExposureAction as Action; let zero = exposure_event("zero", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 0 }); let result = cleared_reinvestment_case(vec![zero], Default::default(), None); assert!(result.fills.is_empty(), "{:?}", result.fills); assert!(result.holdings_summary.is_empty()); assert_eq!(result.equity_curve.last().unwrap().cash, 49998.); assert!(result.equity_curve.last().unwrap().notes.contains("runtime_zero_exposure")); } #[test] fn a_clear_without_a_manual_zero_keeps_the_declared_legacy_reinvestment_model() { let result = cleared_reinvestment_case(vec![], Default::default(), None); assert_eq!(result.fills.len(), 1); assert_eq!((result.fills[0].quantity, result.fills[0].price, result.fills[0].commission), (100, 8.95, 0.)); assert_eq!(result.fills[0].gross_amount, 895.); assert_eq!(result.fills[0].net_cash_flow, -895.); assert!(result.order_events.is_empty()); } #[test] fn reinvestment_respects_zero_restore_same_instant_sequence_and_legacy_granularity() { use fidc_core::position_exposure::PositionExposureAction as Action; let before = "2026-09-14T07:00:00Z"; let settlement = "2026-09-14T16:00:00Z"; let later = "2026-09-15T01:31:00Z"; let zero = || exposure_event("zero", 1, before, Action::Scale { requested_bps: 0 }); let cases = vec![ (vec![zero()], Default::default(), false), (vec![exposure_event("zero-at-settlement", 1, settlement, Action::Set { target_exposure_bps: 0 })], Default::default(), false), (vec![exposure_event("later-zero", 1, later, Action::Set { target_exposure_bps: 0 })], Default::default(), true), (vec![zero(), exposure_event("restore", 2, before, Action::Restore)], Default::default(), true), (vec![exposure_event("restore", 1, before, Action::Restore), exposure_event("last-zero", 2, before, Action::Set { target_exposure_bps: 0 })], Default::default(), false), (vec![exposure_event("restore", 2, before, Action::Restore), zero()], Default::default(), true), (vec![zero(), exposure_event("later-restore", 2, later, Action::Restore)], Default::default(), false), (vec![], std::collections::BTreeMap::from([(day(14), 0)]), false), (vec![exposure_event("restore-legacy", 1, before, Action::Restore)], std::collections::BTreeMap::from([(day(14), 0)]), true), (vec![exposure_event("positive", 1, before, Action::Set { target_exposure_bps: 3000 })], Default::default(), true), ]; for (events, legacy, allowed) in cases { let result = cleared_reinvestment_case(events, legacy, None); assert_eq!(result.fills.len(), usize::from(allowed)); assert_eq!(result.equity_curve.last().unwrap().cash, if allowed { 49103. } else { 49998. }); assert_eq!(result.holdings_summary.iter().map(|holding| holding.quantity).sum::(), if allowed {100} else {0}); assert!(result.order_events.is_empty()); } } #[test] fn an_explicit_zero_member_weight_or_omission_blocks_only_that_reinvestment() { use fidc_core::position_exposure::PositionExposureAction as Action; for included in [false, true] { for weight in [0, 10000] { let mut event = exposure_event("allocation", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 5000 }); let mut weights = std::collections::BTreeMap::from([("000002.SZ".into(), if included {10000-weight} else {10000})]); if included { weights.insert(SYMBOL.into(), weight); } event.allocation_weights_bps = Some(weights); let allowed = included && weight > 0; let result = cleared_reinvestment_case(vec![event], Default::default(), None); assert_eq!(result.fills.len(), usize::from(allowed)); if !allowed { assert!(result.equity_curve.last().unwrap().notes.contains("runtime_zero_allocation")); } } } } #[test] fn late_corporate_replay_uses_the_same_zero_policy_and_retains_actual_manual_shares() { use fidc_core::position_exposure::PositionExposureAction as Action; let event = exposure_event("zero", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 0 }); let timely = cleared_reinvestment_case(vec![event.clone()], Default::default(), Some(false)); let late = cleared_reinvestment_case(vec![event], Default::default(), Some(true)); assert!(timely.fills.is_empty()); assert!(late.fills.is_empty()); assert_eq!(timely.equity_curve.last().unwrap().cash, 41047.); assert_eq!(late.equity_curve.last().unwrap().cash, timely.equity_curve.last().unwrap().cash); assert_eq!(late.holdings_summary[0].quantity, 1000); assert_eq!(late.holdings_summary[0].quantity, timely.holdings_summary[0].quantity); assert_eq!(late.manual_executions.last().unwrap().corporate_adjustment.as_ref().unwrap().corporate_cash_delta, "1050"); }