From 984f9d308dbddec8134b8aed249f82b962839505 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 14 Sep 2026 22:30:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=98=8E=E7=A1=AE=E7=BA=A2=E5=88=A9=E8=B4=A6?= =?UTF-8?q?=E5=8A=A1=E5=86=8D=E6=8A=95=E6=9D=A5=E6=BA=90=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=B5=81=E5=BC=8F=E9=81=97=E6=BC=8F=E4=B8=8E=E6=A0=A1?= =?UTF-8?q?=E6=AD=A3=E6=97=B6=E9=92=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 2 + crates/fidc-core/src/corporate_book.rs | 236 ++++++++++++- crates/fidc-core/src/engine.rs | 155 ++------- crates/fidc-core/src/events.rs | 46 ++- crates/fidc-core/src/futures.rs | 1 + crates/fidc-core/src/lib.rs | 2 +- .../fidc-core/src/manual_corporate_replay.rs | 15 +- crates/fidc-core/src/manual_execution.rs | 4 + crates/fidc-core/src/strategy_ai.rs | 2 +- .../tests/dividend_reinvestment_contract.rs | 328 ++++++++++++++++++ docs/reinvestment-origin-20260914.md | 30 ++ 11 files changed, 681 insertions(+), 140 deletions(-) create mode 100644 crates/fidc-core/tests/dividend_reinvestment_contract.rs create mode 100644 docs/reinvestment-origin-20260914.md diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 2830d60..29781e9 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -5335,6 +5335,7 @@ where .map_err(BacktestError::Execution)?; report.fill_events.push(FillEvent { + origin: crate::events::FillOrigin::MarketExecution, date, decision_date: None, order_created_date: None, @@ -7168,6 +7169,7 @@ where } report.fill_events.push(FillEvent { + origin: crate::events::FillOrigin::MarketExecution, date, decision_date: None, order_created_date: None, diff --git a/crates/fidc-core/src/corporate_book.rs b/crates/fidc-core/src/corporate_book.rs index 4e83d31..20ce4a7 100644 --- a/crates/fidc-core/src/corporate_book.rs +++ b/crates/fidc-core/src/corporate_book.rs @@ -1,6 +1,6 @@ use crate::{ - AccountEvent, BacktestError, BrokerExecutionReport, CashReceivable, DataSet, PortfolioState, - PositionEvent, + AccountEvent, BacktestError, BrokerExecutionReport, CashReceivable, DataSet, FillEvent, + OrderSide, PortfolioState, PositionEvent, PriceField, ProcessEvent, ProcessEventKind, }; use chrono::NaiveDate; @@ -159,3 +159,235 @@ pub(crate) fn apply( portfolio.prune_flat_positions(); Ok(report) } +/// Preserve the declared fee-free accounting allocation model; this does not +/// submit a market order or use a later opening quote as an earlier fact. +pub(crate) fn settle_receivables( + date: NaiveDate, + data: &DataSet, + portfolio: &mut PortfolioState, + notes: &mut Vec, + reinvest_enabled: bool, +) -> Result { + if !portfolio + .cash_receivables() + .iter() + .any(|row| row.payable_date <= date) + { + return Ok(BrokerExecutionReport::default()); + } + let mut next = portfolio.clone(); + let mut recorded = Vec::new(); + let report = settle_receivables_inner(date, data, &mut next, &mut recorded, reinvest_enabled)?; + *portfolio = next; + notes.extend(recorded); + Ok(report) +} + +fn settle_receivables_inner( + date: NaiveDate, + data: &DataSet, + portfolio: &mut PortfolioState, + notes: &mut Vec, + reinvest_enabled: bool, +) -> Result { + let mut report = BrokerExecutionReport::default(); + let due = portfolio.take_due_cash_receivables(date); + for receivable in due { + let cash_before = portfolio.cash(); + portfolio + .settle_cash_receivable(&receivable) + .map_err(BacktestError::Execution)?; + let mut note = format!( + "cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}", + receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount + ); + if reinvest_enabled + && receivable.reason.starts_with("cash_dividend") + && receivable.amount > 0.0 + { + let instrument = data.instrument(&receivable.symbol).ok_or_else(|| BacktestError::Execution(format!( + "dividend_reinvestment: instrument metadata missing symbol={} payable_date={date}", receivable.symbol)))?; + if let Some(reason) = instrument.dated_market_absence_reason(date) { + note.push_str(&format!( + " dividend_reinvestment_not_applied reason={reason} cash_retained=true" + )); + } else { + let (reinvest_price, reference_basis) = if let Some(position) = portfolio + .position(&receivable.symbol) + .filter(|position| position.quantity > 0) + { + (Some(position.last_price), "adjusted_carried_mark") + } else { + ( + data.calendar().previous_day(date).and_then(|prev_date| { + data.price_on_or_before( + prev_date, + &receivable.symbol, + PriceField::Close, + ) + }), + "previous_completed_close", + ) + }; + let price = reinvest_price.filter(|price| price.is_finite() && *price > 0.).ok_or_else(|| BacktestError::Execution(format!( + "dividend_reinvestment: accounting reference missing or invalid symbol={} payable_date={date} basis={reference_basis}", receivable.symbol)))?; + let round_lot = instrument.round_lot; + if round_lot == 0 { + return Err(BacktestError::Execution(format!( + "dividend_reinvestment: invalid quantity unit symbol={}", + receivable.symbol + ))); + } + { + let raw = (receivable.amount / price).floor(); + if !raw.is_finite() || raw > i32::MAX as f64 { + return Err(BacktestError::Execution("dividend_reinvestment: accounting allocation quantity exceeds the ledger contract".into())); + } + let raw_quantity = raw 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) + .map_err(BacktestError::Execution)?; + 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 { + origin: crate::events::FillOrigin::DividendReinvestment, + date, + decision_date: None, + order_created_date: None, + execution_date: None, + execution_start_timestamp: date.and_hms_opt(0, 0, 0), + execution_timestamp: date.and_hms_opt(0, 0, 0), + 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, + transfer_fee: 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 model=fee_free_accounting booked_at={} quantity={} price={} reference_basis={} ex_date={} payable_date={} residual_cash={}", + date.and_hms_opt(0,0,0).unwrap(), reinvest_quantity, price, reference_basis, + receivable.ex_date, receivable.payable_date, residual_cash), + }); + } + } + } + } + notes.push(note.clone()); + report.account_events.push(AccountEvent { + date, + cash_before, + cash_after: portfolio.cash(), + total_equity: portfolio.total_equity(), + note, + }); + } + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + fn date() -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 9, 14).unwrap() + } + fn data(delisted: bool) -> DataSet { + DataSet::from_components( + vec![crate::Instrument { + symbol: "000001.SZ".into(), + name: "fixture".into(), + board: "SZ".into(), + round_lot: 100, + listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()), + delisted_at: delisted.then_some(date()), + status: "active".into(), + }], + vec![], + vec![], + vec![], + vec![crate::BenchmarkSnapshot { + date: date(), + benchmark: "000300.SH".into(), + open: 100., + close: 100., + prev_close: 100., + volume: 0, + }], + ) + .unwrap() + } + fn book() -> PortfolioState { + let mut book = PortfolioState::new(10.); + book.add_cash_receivable(CashReceivable { + symbol: "000001.SZ".into(), + ex_date: date().pred_opt().unwrap(), + payable_date: date(), + amount: 100., + reason: "cash_dividend 1".into(), + }); + book + } + #[test] + fn missing_accounting_reference_is_atomic_not_a_silent_cash_only_success() { + let mut book = book(); + let before = book.financial_replay_identity(); + let error = + settle_receivables(date(), &data(false), &mut book, &mut Vec::new(), true).unwrap_err(); + assert!(error.to_string().contains("accounting reference missing")); + assert_eq!(book.financial_replay_identity(), before); + } + #[test] + fn terminated_security_keeps_paid_cash_and_is_not_recreated_by_reinvestment() { + let mut book = book(); + let mut notes = Vec::new(); + let report = settle_receivables(date(), &data(true), &mut book, &mut notes, true).unwrap(); + assert_eq!(book.cash(), 110.); + assert!(book.positions().is_empty()); + assert!(book.cash_receivables().is_empty()); + assert!(report.fill_events.is_empty()); + assert!(notes[0].contains("dividend_reinvestment_not_applied")); + } +} diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index ec1b0dc..0e4bd7b 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -439,9 +439,11 @@ pub struct BacktestDayProgress { pub benchmark_close: f64, pub daily_fill_count: usize, pub daily_manual_fill_count: usize, + pub daily_corporate_allocation_count: usize, pub daily_order_count: usize, pub cumulative_trade_count: usize, pub cumulative_manual_fill_count: usize, + pub cumulative_corporate_allocation_count: usize, pub holding_count: usize, pub notes: String, pub diagnostics: String, @@ -3050,7 +3052,8 @@ where crate::manual_corporate_replay::ManualCorporateReplay::new( first, self.cash_dividends_enabled, - self.cash_dividend_adjusts_cost_basis, + self.cash_dividend_adjusts_cost_basis, + self.dividend_reinvestment, self.broker.matching_type(), self.broker.execution_price_field(), self.broker.same_day_buy_close_mark_at_fill(), @@ -3111,8 +3114,13 @@ where }; let mut stock_equity_by_date = BTreeMap::::new(); let mut previous_external_cash_flow_total = portfolio.external_cash_flow_total(); + let mut cumulative_corporate_allocation_count = 0; for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() { + let day_order_start = result.order_events.len(); + let day_fill_start = result.fills.len(); + let day_manual_start = result.manual_executions.len(); + let progress_process_start = result.process_events.len(); let mut corporate_action_notes = Vec::new(); // Non-trading-day receipts between two sessions must precede the // next session's corporate actions. They do not create market bars. @@ -3178,10 +3186,6 @@ where execution_date, execution_date, ); - let day_order_start = result.order_events.len(); - let day_fill_start = result.fills.len(); - let day_manual_start = result.manual_executions.len(); - let decision_slot = execution_schedule .get(execution_idx) .and_then(|(_, decision_slot)| *decision_slot); @@ -3226,6 +3230,8 @@ where self.extend_result(&mut result, report, execution_date, execution_date); result.risk_decisions.extend(execution_risk_decisions); let daily_fill_count = result.fills.len() - day_fill_start + result.manual_executions.len() - day_manual_start; + let daily_corporate_allocation_count = result.fills[day_fill_start..].iter().filter(|fill| fill.origin == crate::FillOrigin::DividendReinvestment).count(); + cumulative_corporate_allocation_count += daily_corporate_allocation_count; let daily_order_count = result.order_events.len() - day_order_start; let benchmark = @@ -3247,7 +3253,6 @@ where let holding_start = result.daily_holdings.len(); let holding_count = holdings_for_day.len(); result.daily_holdings.extend(holdings_for_day); - let progress_process_start = result.process_events.len(); self.retain_process_events(&mut result.process_events, &mut process_events); let aggregate_cash = self.aggregate_cash(&portfolio); let aggregate_market_value = self.aggregate_market_value(&portfolio); @@ -3285,9 +3290,11 @@ where benchmark_close: latest.benchmark_close, daily_fill_count, daily_manual_fill_count: result.manual_executions.len() - day_manual_start, + daily_corporate_allocation_count, daily_order_count, cumulative_trade_count: result.fills.len() + result.manual_executions.len(), cumulative_manual_fill_count: result.manual_executions.len(), + cumulative_corporate_allocation_count, holding_count, notes: include_progress_diagnostics .then(|| latest.notes.clone()) @@ -4532,7 +4539,6 @@ where let holding_start = result.daily_holdings.len(); let holding_count = holdings_for_day.len(); result.daily_holdings.extend(holdings_for_day); - let progress_process_start = result.process_events.len(); self.retain_process_events(&mut result.process_events, &mut process_events); let aggregate_cash = self.aggregate_cash(&portfolio); let aggregate_market_value = self.aggregate_market_value(&portfolio); @@ -4559,6 +4565,8 @@ where .equity_curve .last() .expect("equity point pushed for progress event"); + let daily_corporate_allocation_count = result.fills[day_fill_start..].iter().filter(|fill| fill.origin == crate::FillOrigin::DividendReinvestment).count(); + cumulative_corporate_allocation_count += daily_corporate_allocation_count; on_progress(&BacktestDayProgress { date: execution_date, cash: latest.cash, @@ -4570,9 +4578,11 @@ where benchmark_close: latest.benchmark_close, daily_fill_count, daily_manual_fill_count: result.manual_executions.len() - day_manual_start, + daily_corporate_allocation_count, daily_order_count, cumulative_trade_count: result.fills.len() + result.manual_executions.len(), cumulative_manual_fill_count: result.manual_executions.len(), + cumulative_corporate_allocation_count, holding_count, notes: include_progress_diagnostics .then(|| latest.notes.clone()) @@ -4677,130 +4687,9 @@ where } fn settle_cash_receivables( - &self, - date: NaiveDate, - portfolio: &mut PortfolioState, - notes: &mut Vec, + &self, date: NaiveDate, portfolio: &mut PortfolioState, notes: &mut Vec, ) -> Result { - let mut report = BrokerExecutionReport::default(); - let due = portfolio.take_due_cash_receivables(date); - for receivable in due { - let cash_before = portfolio.cash(); - portfolio - .settle_cash_receivable(&receivable) - .map_err(BacktestError::Execution)?; - let mut note = format!( - "cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}", - receivable.symbol, receivable.ex_date, receivable.payable_date, 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) - .map_err(BacktestError::Execution)?; - 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, - decision_date: None, - order_created_date: None, - execution_date: None, - execution_start_timestamp: None, - execution_timestamp: None, - 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, - transfer_fee: 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, - cash_after: portfolio.cash(), - total_equity: portfolio.total_equity(), - note, - }); - } - Ok(report) + crate::corporate_book::settle_receivables(date, &self.data, portfolio, notes, self.dividend_reinvestment) } fn settle_pending_cash_flows( @@ -5647,8 +5536,10 @@ fn annotate_broker_report_dates( event.execution_date.get_or_insert(execution_date); } for fill in &mut report.fill_events { - fill.decision_date.get_or_insert(decision_date); - fill.order_created_date.get_or_insert(order_created_date); + if fill.origin.is_market_execution() { + fill.decision_date.get_or_insert(decision_date); + fill.order_created_date.get_or_insert(order_created_date); + } fill.execution_date.get_or_insert(execution_date); } } diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index bfdcfe1..6c1fac8 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -181,8 +181,22 @@ impl OrderEvent { } } +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FillOrigin { + #[default] + MarketExecution, + DividendReinvestment, +} + +impl FillOrigin { + pub fn is_market_execution(&self) -> bool { *self == Self::MarketExecution } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FillEvent { + #[serde(default, skip_serializing_if = "FillOrigin::is_market_execution")] + pub origin: FillOrigin, #[serde(with = "date_format")] pub date: NaiveDate, #[serde(default, with = "optional_date_format")] @@ -219,6 +233,14 @@ pub struct FillEvent { impl FillEvent { pub fn validate(&self) -> Result<(), String> { + if self.origin == FillOrigin::DividendReinvestment && ( + self.order_id.is_some() || self.side != OrderSide::Buy + || self.commission != 0. || self.stamp_tax != 0. || self.transfer_fee != 0. + || self.execution_timestamp != self.date.and_hms_opt(0, 0, 0) + || self.execution_start_timestamp != self.execution_timestamp + ) { + return Err("dividend accounting allocation cannot carry an exchange order, fees, or a market clock".into()); + } if self.symbol.trim().is_empty() || self.quantity == 0 || !self.price.is_finite() @@ -425,7 +447,7 @@ pub struct ProcessEvent { mod tests { use chrono::{NaiveDate, NaiveDateTime}; - use super::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEventKind}; + use super::{FillEvent, FillOrigin, OrderEvent, OrderSide, OrderStatus, ProcessEventKind}; fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent { OrderEvent { @@ -469,6 +491,7 @@ mod tests { fn fill_event(start: Option, end: Option) -> FillEvent { FillEvent { + origin: crate::events::FillOrigin::MarketExecution, date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), decision_date: None, order_created_date: None, @@ -489,6 +512,27 @@ mod tests { } } + #[test] + fn accounting_origin_cannot_disguise_an_exchange_order_or_fee() { + let at = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(0, 0, 0).unwrap(); + let mut fill = fill_event(Some(at), Some(at)); + fill.origin = FillOrigin::DividendReinvestment; + fill.order_id = None; + fill.commission = 0.; + fill.net_cash_flow = -1000.; + assert!(fill.validate().is_ok()); + for kind in 0..3 { + let mut invalid = fill.clone(); + match kind { + 0 => invalid.order_id = Some(1), + 1 => invalid.commission = 1., + _ => { invalid.execution_timestamp = Some(at + chrono::Duration::hours(9)); invalid.execution_start_timestamp = invalid.execution_timestamp; } + } + assert!(invalid.validate().is_err()); + } + assert!(serde_json::to_value(fill_event(None, None)).unwrap().get("origin").is_none()); + } + #[test] fn fill_execution_timestamp_range_is_explicit_and_backward_compatible() { let start = NaiveDate::from_ymd_opt(2025, 1, 2) diff --git a/crates/fidc-core/src/futures.rs b/crates/fidc-core/src/futures.rs index 04f69a2..d1e2cb4 100644 --- a/crates/fidc-core/src/futures.rs +++ b/crates/fidc-core/src/futures.rs @@ -1048,6 +1048,7 @@ impl FuturesAccountState { ) .to_f64(); report.fill_events.push(FillEvent { + origin: crate::events::FillOrigin::MarketExecution, date, decision_date: None, order_created_date: None, diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 42376a3..5dc199b 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -69,7 +69,7 @@ pub use engine::{ }; pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus}; pub use events::{ - AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, + AccountEvent, FillEvent, FillOrigin, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, }; pub use fixed_point::{ diff --git a/crates/fidc-core/src/manual_corporate_replay.rs b/crates/fidc-core/src/manual_corporate_replay.rs index b7d6ee6..33ae651 100644 --- a/crates/fidc-core/src/manual_corporate_replay.rs +++ b/crates/fidc-core/src/manual_corporate_replay.rs @@ -26,6 +26,7 @@ pub(crate) struct ManualCorporateReplay { reconciled_count: Cell, cash_dividends: bool, adjust_cost: bool, + reinvest: bool, matching: MatchingType, daily_price: PriceField, same_day_mark_at_fill: bool, @@ -36,6 +37,7 @@ impl ManualCorporateReplay { first_date: NaiveDate, cash_dividends: bool, adjust_cost: bool, + reinvest: bool, matching: MatchingType, daily_price: PriceField, same_day_mark_at_fill: bool, @@ -48,6 +50,7 @@ impl ManualCorporateReplay { reconciled_count: Cell::new(0), cash_dividends, adjust_cost, + reinvest, matching, daily_price, same_day_mark_at_fill, @@ -246,6 +249,7 @@ impl ManualCorporateReplay { observed_at: observation.fill.observed_at, cash_dividends_enabled: self.cash_dividends, dividend_cost_basis_adjustment: self.adjust_cost, + dividend_reinvestment: self.reinvest, actions, cash_before: before.to_decimal_string(), cash_after: after.to_decimal_string(), @@ -309,6 +313,12 @@ impl ManualCorporateReplay { events.push((clock, 4, fills.len() + index, Event::Manual(*observation))); } for (index, fill) in fills.iter().enumerate() { + fill.validate()?; + if fill.origin == crate::events::FillOrigin::DividendReinvestment { + // The declared accounting model is recalculated from the + // corrected entitlements; this was never a submitted order. + continue; + } let date = fill.execution_date.unwrap_or(fill.date); // This is the frozen daily matching model, not a broker timestamp. // Intraday contracts must supply their actual execution clock. @@ -372,9 +382,8 @@ impl ManualCorporateReplay { .map_err(|error| error.to_string())?; } Event::Settle(date) => { - for receivable in book.take_due_cash_receivables(date) { - book.settle_cash_receivable(&receivable)?; - } + crate::corporate_book::settle_receivables(date, data, &mut book, &mut Vec::new(), self.reinvest) + .map_err(|error| error.to_string())?; } Event::Manual(observation) => { observation.apply(&mut book, data, false)?; diff --git a/crates/fidc-core/src/manual_execution.rs b/crates/fidc-core/src/manual_execution.rs index eb75d2f..2630b91 100644 --- a/crates/fidc-core/src/manual_execution.rs +++ b/crates/fidc-core/src/manual_execution.rs @@ -493,6 +493,8 @@ pub struct ManualCorporateAdjustment { pub observed_at: DateTime, pub cash_dividends_enabled: bool, pub dividend_cost_basis_adjustment: bool, + #[serde(default, skip_serializing_if = "disabled_flag")] + pub dividend_reinvestment: bool, pub actions: Vec, pub cash_before: String, pub cash_after: String, @@ -502,6 +504,8 @@ pub struct ManualCorporateAdjustment { pub replayed_sha256: String, } +fn disabled_flag(value: &bool) -> bool { !value } + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct ManualCorporateActionReference { diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 1fd4e17..3e7970d 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -254,7 +254,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, ManualSection { title: "corporate_actions.dividend_reinvestment".to_string(), - detail: "支持 corporate_actions.dividend_reinvestment(true)。开启后,现金分红到账会优先按 round lot 回补成同一只股票,零头保留为现金。".to_string(), + detail: "历史兼容的回测账务再投模型:corporate_actions.dividend_reinvestment(true) 在分红结算时按调整后的参考价分配整手股数,零头留现金,费用为0;来源标记为 dividend_reinvestment,不是交易所委托或真实自动买入。新策略不应手工处理公司行为。".to_string(), }, ManualSection { title: "execution.matching_type / execution.slippage".to_string(), diff --git a/crates/fidc-core/tests/dividend_reinvestment_contract.rs b/crates/fidc-core/tests/dividend_reinvestment_contract.rs new file mode 100644 index 0000000..7060dc5 --- /dev/null +++ b/crates/fidc-core/tests/dividend_reinvestment_contract.rs @@ -0,0 +1,328 @@ +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() + ); +} diff --git a/docs/reinvestment-origin-20260914.md b/docs/reinvestment-origin-20260914.md new file mode 100644 index 0000000..0c46a11 --- /dev/null +++ b/docs/reinvestment-origin-20260914.md @@ -0,0 +1,30 @@ +# 红利再投来源、入账时钟及流式完整性 + +2026-09-14。候选未发布;完整Goal继续。 + +## 先核对语义 + +读取AiQuant `alv/portfolio/accounts/position_model.py::_handle_dividend_payable`与FIDC原实现后确认:现有开关是历史兼容的账务再投模型,按调整后的参考价分配整手股数、零费用,不是交易所委托。不能把盘前分配直接解释成市场成交提前发生,也不能为了修时钟擅自改为开盘价、收费市价单或调用GT。 + +保留此价格/数量/费用合同,新增明确`FillOrigin::DividendReinvestment`及结算入账时刻。普通市场成交默认来源不序列化,旧普通字段和原历史不改写。账务来源不得携带委托编号、市场时钟或手续费;规则由Core验证。 + +## 实際错误与修复 + +- 红利再投缺来源/时间,被手工公司行为复算当作09:30市场成交,09:15的回报因此报“future financial fact”。现在正常与复算使用同一结算模型,账务分配按结算阶段处理,不伪造或重跑市场订单。 +- 已观察时点的原账务记录保留。迟到成交改变已知权益后,重新计算账务分配并记录校正;它不是不能改变的交易所成交。1000股额外买入的例子,最终2200股,与及时观察对照一致,权益现金调整155。 +- 每日计数/流式切片原来在盘前结算之后才开始,漏掉晨间再投和周末回报。现从会话处理前开始,下一代表交易日完整交付期间观察,原实际观察时间不改,不补造非交易日行情。 +- 活跃证券缺参考价/元信息明确失败,结算批次原子回滚;终止上市等已证明生命周期不允许分配时保留现金并记录原因,不重建旧证券。未改变ST/停牌等市场委托风控:账务模型不代表市场买入获准。 + +## 交付 + +Runner对明确账务来源生成独立记录,不要求或伪造委托。API分开市场成交与账务分配的件数/金额;合入手工成交时也不把账务金额加回市场成交额。兼容总事件数仍保留,新增市场件数和账务件数。账务时钟在传输时显式带上海偏移;不猜测旧普通记录的时区。 + +提交能力增加`accounting_allocations:v1`,旧消费者不能处理本轮完整手工合同。Core能力说明和FIDC策略Agent注册表同步澄清其历史账务性质,新策略仍不得自行编写公司行为处理。 + +## 验证 + +本机Core901、Trading625、Runner463/API129及三项私有PG通过;原ignore单列。前端2278测试、类型/定向lint/主题检查与独立构建通过。共享Rust投影的实际表格在1440/390明暗四组无文档溢出、错误或交易写入;窄屏表格保留横向滚动。初验发现无时区标记及独立挂载缺少工作台令牌导致浅色选中项不清晰,已修正生产投影和共享组件令牌回退,复验通过。 + +旧价8.95、100股、零费用仍相同;无委托来源及进度覆盖、周末观察、迟到权益再投、非法来源拒绝、缺参考原子失败和退市现金保留均有专项。机械字段迁移时重复插入和测试导入问题在编译阶段纠正,不计作业务通过。 + +本次只证明这些边界,不等于真实Source/GT权益到账或全参数完成。仍须做后继证券范围、目标状态、持仓清空后参考与跨模式ETF的完整核对;实际Source/Runner及Arrow候选准入仍受Source冻结约束。Linux和发布证据另列,不能复用旧ZAnvlO等收据。