diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 285c170..8b310dd 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1702,18 +1702,18 @@ where &mut corporate_action_notes, ); self.extend_result(&mut result, pending_cash_flow_report); - let receivable_report = self.settle_cash_receivables( - execution_date, - &mut portfolio, - &mut corporate_action_notes, - )?; - self.extend_result(&mut result, receivable_report); let corporate_action_report = self.apply_corporate_actions( execution_date, &mut portfolio, &mut corporate_action_notes, )?; self.extend_result(&mut result, corporate_action_report); + let receivable_report = self.settle_cash_receivables( + execution_date, + &mut portfolio, + &mut corporate_action_notes, + )?; + self.extend_result(&mut result, receivable_report); let delisting_report = self.settle_delisted_positions( execution_date, &mut portfolio, @@ -2875,30 +2875,21 @@ where }; if cash_delta.abs() > f64::EPSILON { let payable_date = action.payable_date.unwrap_or(date); - let immediate_cash = payable_date <= date; - let note = if immediate_cash { - portfolio.apply_cash_delta(cash_delta); - format!( - "cash_dividend {} share_cash={:.6} quantity={} cash={:.2}", - action.symbol, action.share_cash, quantity_after, cash_delta - ) - } else { - portfolio.add_cash_receivable(CashReceivable { - symbol: action.symbol.clone(), - ex_date: date, - payable_date, - amount: cash_delta, - reason: format!("cash_dividend {:.6}", action.share_cash), - }); - format!( - "cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}", - action.symbol, - action.share_cash, - quantity_after, - payable_date, - cash_delta - ) - }; + portfolio.add_cash_receivable(CashReceivable { + symbol: action.symbol.clone(), + ex_date: date, + payable_date, + amount: cash_delta, + reason: format!("cash_dividend {:.6}", action.share_cash), + }); + let note = format!( + "cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}", + action.symbol, + action.share_cash, + quantity_after, + payable_date, + cash_delta + ); notes.push(note.clone()); report.account_events.push(AccountEvent { date, diff --git a/crates/fidc-core/tests/corporate_actions.rs b/crates/fidc-core/tests/corporate_actions.rs index 8696504..1aa9fe7 100644 --- a/crates/fidc-core/tests/corporate_actions.rs +++ b/crates/fidc-core/tests/corporate_actions.rs @@ -92,6 +92,71 @@ impl Strategy for BuyAndHoldStrategy { } } +fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot { + DailyMarketSnapshot { + 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: 10.0, + ask1: 10.0, + prev_close: 10.0, + volume: 100_000, + minute_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, + } +} + +fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot { + 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(), + } +} + +fn stock_candidate(date: NaiveDate) -> CandidateEligibility { + 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, + risk_level_code: None, + } +} + +fn benchmark_snapshot(date: NaiveDate) -> BenchmarkSnapshot { + BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + } +} + #[test] fn engine_reinvests_dividend_receivable_in_round_lots() { let buy_date = d(2025, 1, 1); @@ -330,3 +395,86 @@ fn engine_reinvests_dividend_receivable_in_round_lots() { assert_eq!(reinvest_fill.commission, 0.0); assert_eq!(reinvest_fill.stamp_tax, 0.0); } + +#[test] +fn engine_settles_same_day_dividend_after_split_for_aiquant_semantics() { + let buy_date = d(2025, 1, 1); + let ex_date = d(2025, 1, 2); + 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![stock_market_snapshot(buy_date), stock_market_snapshot(ex_date)], + vec![stock_factor_snapshot(buy_date), stock_factor_snapshot(ex_date)], + vec![stock_candidate(buy_date), stock_candidate(ex_date)], + vec![benchmark_snapshot(buy_date), benchmark_snapshot(ex_date)], + vec![CorporateAction { + date: ex_date, + symbol: "000001.SZ".to_string(), + payable_date: Some(ex_date), + share_cash: 1.05, + share_bonus: 0.2, + 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_008.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(buy_date), + end_date: Some(ex_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_300); + + let reinvest_fill = result + .fills + .iter() + .find(|fill| fill.reason == "dividend_reinvestment") + .expect("reinvestment fill"); + assert_eq!(reinvest_fill.quantity, 100); + assert!((reinvest_fill.price - ((10.0 - 1.05) / 1.2)).abs() < 1e-9); + + assert!(result + .position_events + .iter() + .any(|event| event.reason == "stock_split 1.200000" && event.quantity_after == 1_200)); + assert!(result + .account_events + .iter() + .any(|event| event.note.contains("cash_receivable_reinvested"))); +}