diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index b1f507b..7983579 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -94,6 +94,7 @@ pub struct BacktestEngine { strategy: S, broker: BrokerSimulator, config: BacktestConfig, + dividend_reinvestment: bool, } impl BacktestEngine { @@ -108,8 +109,14 @@ impl BacktestEngine { strategy, broker, config, + dividend_reinvestment: false, } } + + pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self { + self.dividend_reinvestment = enabled; + self + } } impl BacktestEngine @@ -633,14 +640,106 @@ where let mut report = BrokerExecutionReport::default(); let settled = portfolio.settle_cash_receivables(date); for receivable in settled { - let note = format!( + let mut note = format!( "cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}", receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount ); + let cash_before = portfolio.cash() - receivable.amount; + + if self.dividend_reinvestment + && receivable.reason.starts_with("cash_dividend") + && receivable.amount > 0.0 + { + let reinvest_price = portfolio + .position(&receivable.symbol) + .map(|position| position.last_price) + .filter(|price| price.is_finite() && *price > 0.0) + .or_else(|| { + self.data + .calendar() + .previous_day(date) + .and_then(|prev_date| { + self.data.price_on_or_before( + prev_date, + &receivable.symbol, + PriceField::Close, + ) + }) + }); + let round_lot = self + .data + .instrument(&receivable.symbol) + .map(|instrument| instrument.round_lot.max(1)) + .unwrap_or(100); + if let Some(price) = reinvest_price { + let raw_quantity = (receivable.amount / price).floor() as u32; + let reinvest_quantity = (raw_quantity / round_lot) * round_lot; + if reinvest_quantity > 0 { + let reinvest_cash = reinvest_quantity as f64 * price; + let residual_cash = receivable.amount - reinvest_cash; + portfolio.apply_cash_delta(-reinvest_cash); + portfolio.position_mut(&receivable.symbol).buy( + date, + reinvest_quantity, + price, + ); + + note = format!( + "cash_receivable_reinvested {} ex_date={} payable_date={} cash={:.2} reinvest_qty={} reinvest_price={:.4} residual_cash={:.2}", + receivable.symbol, + receivable.ex_date, + receivable.payable_date, + receivable.amount, + reinvest_quantity, + price, + residual_cash + ); + report.fill_events.push(FillEvent { + date, + order_id: None, + symbol: receivable.symbol.clone(), + side: OrderSide::Buy, + quantity: reinvest_quantity, + price, + gross_amount: reinvest_cash, + commission: 0.0, + stamp_tax: 0.0, + net_cash_flow: -reinvest_cash, + reason: "dividend_reinvestment".to_string(), + }); + report.position_events.push(PositionEvent { + date, + symbol: receivable.symbol.clone(), + delta_quantity: reinvest_quantity as i32, + quantity_after: portfolio + .position(&receivable.symbol) + .map(|position| position.quantity) + .unwrap_or(0), + average_cost: portfolio + .position(&receivable.symbol) + .map(|position| position.average_cost) + .unwrap_or(0.0), + realized_pnl_delta: 0.0, + reason: "dividend_reinvestment".to_string(), + }); + report.process_events.push(ProcessEvent { + date, + kind: ProcessEventKind::Trade, + order_id: None, + symbol: Some(receivable.symbol.clone()), + side: Some(OrderSide::Buy), + detail: format!( + "dividend_reinvestment quantity={} price={}", + reinvest_quantity, price + ), + }); + } + } + } notes.push(note.clone()); report.account_events.push(AccountEvent { date, - cash_before: portfolio.cash() - receivable.amount, + cash_before, cash_after: portfolio.cash(), total_equity: portfolio.total_equity(), note, diff --git a/crates/fidc-core/tests/corporate_actions.rs b/crates/fidc-core/tests/corporate_actions.rs index 575c70e..66ef293 100644 --- a/crates/fidc-core/tests/corporate_actions.rs +++ b/crates/fidc-core/tests/corporate_actions.rs @@ -1,5 +1,12 @@ use chrono::NaiveDate; -use fidc_core::{CashReceivable, PortfolioState, Position}; +use std::collections::{BTreeMap, BTreeSet}; + +use fidc_core::{ + BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, + CashReceivable, ChinaAShareCostModel, ChinaEquityRuleHooks, CorporateAction, + DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, PortfolioState, Position, + PriceField, Strategy, StrategyContext, StrategyDecision, +}; fn d(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") @@ -52,3 +59,271 @@ fn portfolio_settles_cash_receivable_on_payable_date() { assert!((portfolio.cash() - 1_000_500.0).abs() < 1e-9); assert!(portfolio.cash_receivables().is_empty()); } + +struct BuyAndHoldStrategy { + first_date: NaiveDate, +} + +impl Strategy for BuyAndHoldStrategy { + fn name(&self) -> &str { + "buy-and-hold" + } + + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: if ctx.execution_date == self.first_date { + vec![fidc_core::OrderIntent::Shares { + symbol: "000001.SZ".to_string(), + quantity: 1_000, + reason: "initial_buy".to_string(), + }] + } else { + Vec::new() + }, + notes: Vec::new(), + diagnostics: Vec::new(), + }) + } +} + +#[test] +fn engine_reinvests_dividend_receivable_in_round_lots() { + let buy_date = d(2025, 1, 1); + let ex_date = d(2025, 1, 2); + let payable_date = d(2025, 1, 3); + let data = DataSet::from_components_with_actions( + 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: buy_date, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-01-01 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: ex_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: 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: payable_date, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-01-03 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, + }, + ], + vec![ + DailyFactorSnapshot { + date: buy_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(), + }, + DailyFactorSnapshot { + date: ex_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(), + }, + DailyFactorSnapshot { + date: payable_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: buy_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: ex_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: payable_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: buy_date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: ex_date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: payable_date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 100.0, + volume: 1_000_000, + }, + ], + vec![CorporateAction { + date: ex_date, + symbol: "000001.SZ".to_string(), + payable_date: Some(payable_date), + share_cash: 1.05, + share_bonus: 0.0, + share_gift: 0.0, + issue_quantity: 0.0, + issue_price: 0.0, + reform: false, + adjust_factor: None, + successor_symbol: None, + successor_ratio: None, + successor_cash: None, + }], + ) + .expect("dataset"); + + let mut engine = BacktestEngine::new( + data, + BuyAndHoldStrategy { + first_date: buy_date, + }, + BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ), + BacktestConfig { + initial_cash: 11_005.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(buy_date), + end_date: Some(payable_date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ) + .with_dividend_reinvestment(true); + + let result = engine.run().expect("backtest run"); + let final_holding = result + .holdings_summary + .iter() + .find(|row| row.symbol == "000001.SZ") + .expect("holding"); + assert_eq!(final_holding.quantity, 1_100); + assert!((result.equity_curve.last().expect("equity").cash - 1_050.0).abs() < 1e-9); + let reinvest_fill = result + .fills + .iter() + .find(|fill| fill.reason == "dividend_reinvestment") + .expect("reinvestment fill"); + assert_eq!(reinvest_fill.quantity, 100); + assert_eq!(reinvest_fill.commission, 0.0); + assert_eq!(reinvest_fill.stamp_tax, 0.0); +}