From 05f1cbbe00ffe75b72de8a24cea6a4e40e8142b0 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 14 Sep 2026 21:14:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=9F=E5=88=B0=E6=88=90?= =?UTF-8?q?=E4=BA=A4=E8=B7=A8=E5=85=AC=E5=8F=B8=E8=A1=8C=E4=B8=BA=E7=9A=84?= =?UTF-8?q?=E7=BB=8F=E6=B5=8E=E8=B4=A6=E6=9C=AC=E6=A0=A1=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 9 + crates/fidc-core/src/corporate_book.rs | 161 ++++++ crates/fidc-core/src/engine.rs | 290 +++++----- crates/fidc-core/src/lib.rs | 2 + .../fidc-core/src/manual_corporate_replay.rs | 431 +++++++++++++++ crates/fidc-core/src/manual_execution.rs | 87 +++ .../fidc-core/src/manual_execution/tests.rs | 15 + .../fidc-core/src/platform_expr_strategy.rs | 5 + crates/fidc-core/src/portfolio.rs | 52 +- .../tests/manual_corporate_observation.rs | 505 ++++++++++++++++++ docs/manual-corporate-replay-20260914.md | 44 ++ 11 files changed, 1447 insertions(+), 154 deletions(-) create mode 100644 crates/fidc-core/src/corporate_book.rs create mode 100644 crates/fidc-core/src/manual_corporate_replay.rs create mode 100644 crates/fidc-core/tests/manual_corporate_observation.rs create mode 100644 docs/manual-corporate-replay-20260914.md diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index bad84e9..2830d60 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -2766,6 +2766,15 @@ where if execution.side == OrderSide::Sell { let date = execution.executed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive(); self.mark_same_day_sold(date, &execution.symbol); + if let Some(adjustment) = &execution.corporate_adjustment { + for successor in adjustment + .actions + .iter() + .filter_map(|action| action.successor_symbol.as_deref()) + { + self.mark_same_day_sold(date, successor); + } + } } } diff --git a/crates/fidc-core/src/corporate_book.rs b/crates/fidc-core/src/corporate_book.rs new file mode 100644 index 0000000..4e83d31 --- /dev/null +++ b/crates/fidc-core/src/corporate_book.rs @@ -0,0 +1,161 @@ +use crate::{ + AccountEvent, BacktestError, BrokerExecutionReport, CashReceivable, DataSet, PortfolioState, + PositionEvent, +}; +use chrono::NaiveDate; + +/// One corporate-action calculation for normal processing and audited replay. +pub(crate) fn apply( + date: NaiveDate, + data: &DataSet, + portfolio: &mut PortfolioState, + notes: &mut Vec, + cash_dividends_enabled: bool, + cash_dividend_adjusts_cost_basis: bool, +) -> Result { + let mut report = BrokerExecutionReport::default(); + for action in data.corporate_actions_on(date) { + if !action.has_effect() { + continue; + } + let Some(existing_position) = portfolio.position(&action.symbol) else { + continue; + }; + if existing_position.quantity == 0 { + continue; + } + + if cash_dividends_enabled && action.share_cash.abs() > f64::EPSILON { + let cash_before = portfolio.cash(); + let (cash_delta, quantity_after, average_cost) = { + let position = portfolio + .position_mut_if_exists(&action.symbol) + .expect("position exists for dividend action"); + let cash_delta = if cash_dividend_adjusts_cost_basis { + position.apply_cash_dividend(action.share_cash) + } else { + position.apply_cash_dividend_preserve_cost_basis(action.share_cash) + }; + (cash_delta, position.quantity, position.average_cost) + }; + if cash_delta.abs() > f64::EPSILON { + let payable_date = action.payable_date.unwrap_or(date); + 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, + cash_before, + cash_after: portfolio.cash(), + total_equity: portfolio.total_equity(), + note, + }); + report.position_events.push(PositionEvent { + date, + symbol: action.symbol.clone(), + delta_quantity: 0, + quantity_after, + average_cost, + realized_pnl_delta: 0.0, + reason: format!("cash_dividend {:.6}", action.share_cash), + }); + } + } + + let split_ratio = action.split_ratio(); + if (split_ratio - 1.0).abs() > f64::EPSILON { + portfolio + .adjust_stock_pool_split(&action.symbol, split_ratio) + .map_err(BacktestError::Execution)?; + let (delta_quantity, quantity_after, average_cost) = { + let position = portfolio + .position_mut_if_exists(&action.symbol) + .expect("position exists for split action"); + let delta_quantity = position.apply_split_ratio(split_ratio); + (delta_quantity, position.quantity, position.average_cost) + }; + if delta_quantity != 0 { + let note = format!( + "stock_split {} ratio={:.6} delta_qty={}", + action.symbol, split_ratio, delta_quantity + ); + notes.push(note); + report.position_events.push(PositionEvent { + date, + symbol: action.symbol.clone(), + delta_quantity, + quantity_after, + average_cost, + realized_pnl_delta: 0.0, + reason: format!("stock_split {:.6}", split_ratio), + }); + } + } + + if action.has_successor_conversion() { + let successor_symbol = action + .successor_symbol + .as_deref() + .expect("successor symbol checked"); + let Some(outcome) = portfolio.apply_successor_conversion( + &action.symbol, + successor_symbol, + action.successor_ratio_value(), + action.successor_cash_value(), + ) else { + continue; + }; + let reason = format!( + "successor_conversion {}->{} ratio={:.6} cash_per_share={:.6}", + outcome.old_symbol, + outcome.new_symbol, + action.successor_ratio_value(), + action.successor_cash_value() + ); + notes.push(reason.clone()); + report.position_events.push(PositionEvent { + date, + symbol: outcome.old_symbol.clone(), + delta_quantity: -(outcome.old_quantity as i32), + quantity_after: 0, + average_cost: 0.0, + realized_pnl_delta: 0.0, + reason: reason.clone(), + }); + report.position_events.push(PositionEvent { + date, + symbol: outcome.new_symbol.clone(), + delta_quantity: outcome.new_quantity_delta, + quantity_after: outcome.new_quantity_after, + average_cost: outcome.new_average_cost_after, + realized_pnl_delta: 0.0, + reason: reason.clone(), + }); + if outcome.cash_delta.abs() > f64::EPSILON { + let cash_before = portfolio.cash(); + portfolio + .apply_cash_delta(outcome.cash_delta) + .map_err(BacktestError::Execution)?; + report.account_events.push(AccountEvent { + date, + cash_before, + cash_after: portfolio.cash(), + total_equity: portfolio.total_equity(), + note: format!("{} cash={:.2}", reason, outcome.cash_delta), + }); + } + } + } + + portfolio.prune_flat_positions(); + Ok(report) +} diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 765ce79..ec1b0dc 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -17,7 +17,7 @@ use crate::futures::{ FuturesTransactionCostModel, }; use crate::metrics::{BacktestMetrics, RiskFreeRateContract, compute_backtest_metrics_with_manual}; -use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; +use crate::portfolio::{HoldingSummary, PortfolioState}; use crate::risk_control::{FidcRiskDecisionAudit, RiskCheckScope}; use crate::rules::EquityRuleHooks; use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time}; @@ -490,6 +490,7 @@ pub struct BacktestEngine { execution_lifecycle_reported: BTreeSet<(String, String)>, risk_free_rate_contract: Option, manual_execution_source: Option>, + manual_corporate_replay: Option, deferred_session_decisions: Vec, } @@ -625,6 +626,7 @@ impl BacktestEngine { execution_lifecycle_reported: BTreeSet::new(), risk_free_rate_contract: None, manual_execution_source: None, + manual_corporate_replay: None, deferred_session_decisions: Vec::new(), } } @@ -815,8 +817,52 @@ where let cash_before = portfolio.cash(); let conflict = self.has_open_orders() || self.broker.has_pending_stock_pool_execution() || self.broker.pending_etf_target_count() > 0; - let application = cursor.advance_next(portfolio, &self.data, conflict) - .map_err(BacktestError::Execution)?.expect("next observation checked"); + let requires_corporate_replay = self + .manual_corporate_replay + .as_ref() + .map(|replay| { + replay.required( + cursor.next_observation().expect("next observation checked"), + &self.data, + ) + }) + .transpose() + .map_err(BacktestError::Execution)? + .unwrap_or(false); + let application = if requires_corporate_replay { + if self.futures_account.is_some() { + return Err(BacktestError::Execution( + "manual corporate replay requires an equity-only financial ledger".into(), + )); + } + let source = cursor.frozen_source(); + let applied_count = cursor.applied_count(); + let replay = self + .manual_corporate_replay + .as_ref() + .expect("corporate replay checked"); + let application = cursor + .advance_next_projected(portfolio, |observation, next| { + replay.project( + &source, + applied_count, + observation, + next, + &self.data, + &result.fills, + conflict, + ) + }) + .map_err(BacktestError::Execution)? + .expect("next observation checked"); + replay.committed(cursor.applied_count()); + application + } else { + cursor + .advance_next(portfolio, &self.data, conflict) + .map_err(BacktestError::Execution)? + .expect("next observation checked") + }; self.broker.record_observed_manual_execution(&application); self.strategy.on_observed_manual_execution(&application)?; result.account_events.push(AccountEvent { date: observed.date(), cash_before, @@ -1629,10 +1675,20 @@ where reason, } => { let cash_before = portfolio.cash(); + let fixed_before = portfolio.cash_fixed(); if receiving_days == 0 { portfolio .deposit_withdraw(amount) .map_err(BacktestError::Execution)?; + if let Some(replay) = &self.manual_corporate_replay { + replay + .record_cash( + callback_datetime, + fixed_before, + portfolio.cash_fixed(), + ) + .map_err(BacktestError::Execution)?; + } directive_report.account_events.push(AccountEvent { date: execution_date, cash_before, @@ -1694,10 +1750,16 @@ where } crate::strategy::OrderIntent::FinanceRepay { amount, reason } => { let cash_before = portfolio.cash(); + let fixed_before = portfolio.cash_fixed(); let liabilities_before = portfolio.cash_liabilities(); portfolio .finance_repay(amount) .map_err(BacktestError::Execution)?; + if let Some(replay) = &self.manual_corporate_replay { + replay + .record_cash(callback_datetime, fixed_before, portfolio.cash_fixed()) + .map_err(BacktestError::Execution)?; + } directive_report.account_events.push(AccountEvent { date: execution_date, cash_before, @@ -2979,9 +3041,38 @@ where .iter() .map(|(execution_date, _)| *execution_date) .collect::>(); - if let (Some(first), Some(observed)) = (execution_dates.first(), manual_cursor.as_ref().and_then(|cursor| cursor.next_observation_at())) { - if observed.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive() < *first { - return Err(BacktestError::Execution("manual observations precede the declared initial portfolio period".into())); + self.manual_corporate_replay = if manual_has_fills + && execution_dates + .iter() + .any(|date| !self.data.corporate_actions_on(*date).is_empty()) + { + execution_dates.first().copied().map(|first| { + crate::manual_corporate_replay::ManualCorporateReplay::new( + first, + self.cash_dividends_enabled, + self.cash_dividend_adjusts_cost_basis, + self.broker.matching_type(), + self.broker.execution_price_field(), + self.broker.same_day_buy_close_mark_at_fill(), + ) + }) + } else { + None + }; + if let (Some(first), Some(observed)) = ( + execution_dates.first(), + manual_cursor + .as_ref() + .and_then(|cursor| cursor.next_observation_at()), + ) { + if observed + .with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()) + .date_naive() + < *first + { + return Err(BacktestError::Execution( + "manual observations precede the declared initial portfolio period".into(), + )); } } let mut result = BacktestResult { @@ -3060,6 +3151,9 @@ where &mut portfolio, &mut corporate_action_notes, )?; + if let Some(replay) = &self.manual_corporate_replay { + replay.record_session(execution_date); + } self.extend_result( &mut result, receivable_report, @@ -3116,6 +3210,9 @@ where PriceField::Close, self.broker.same_day_buy_close_mark_at_fill(), )?; + if let Some(replay) = &self.manual_corporate_replay { + replay.record_close(execution_date); + } let close_report = self.broker.after_trading(execution_date); merge_broker_report(&mut report, close_report); let futures_daily_settlement_report = self.settle_futures_daily(execution_date); @@ -4139,6 +4236,9 @@ where PriceField::Close, self.broker.same_day_buy_close_mark_at_fill(), )?; + if let Some(replay) = &self.manual_corporate_replay { + replay.record_close(execution_date); + } let post_trade_open_orders = self.open_order_views(); let mut broker_diagnostics = std::mem::take(&mut report.diagnostics); @@ -4566,150 +4666,14 @@ where portfolio: &mut PortfolioState, notes: &mut Vec, ) -> Result { - let mut report = BrokerExecutionReport::default(); - for action in self.data.corporate_actions_on(date) { - if !action.has_effect() { - continue; - } - let Some(existing_position) = portfolio.position(&action.symbol) else { - continue; - }; - if existing_position.quantity == 0 { - continue; - } - - if self.cash_dividends_enabled && action.share_cash.abs() > f64::EPSILON { - let cash_before = portfolio.cash(); - let (cash_delta, quantity_after, average_cost) = { - let position = portfolio - .position_mut_if_exists(&action.symbol) - .expect("position exists for dividend action"); - let cash_delta = if self.cash_dividend_adjusts_cost_basis { - position.apply_cash_dividend(action.share_cash) - } else { - position.apply_cash_dividend_preserve_cost_basis(action.share_cash) - }; - (cash_delta, position.quantity, position.average_cost) - }; - if cash_delta.abs() > f64::EPSILON { - let payable_date = action.payable_date.unwrap_or(date); - 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, - cash_before, - cash_after: portfolio.cash(), - total_equity: portfolio.total_equity(), - note, - }); - report.position_events.push(PositionEvent { - date, - symbol: action.symbol.clone(), - delta_quantity: 0, - quantity_after, - average_cost, - realized_pnl_delta: 0.0, - reason: format!("cash_dividend {:.6}", action.share_cash), - }); - } - } - - let split_ratio = action.split_ratio(); - if (split_ratio - 1.0).abs() > f64::EPSILON { - portfolio.adjust_stock_pool_split(&action.symbol, split_ratio) - .map_err(BacktestError::Execution)?; - let (delta_quantity, quantity_after, average_cost) = { - let position = portfolio - .position_mut_if_exists(&action.symbol) - .expect("position exists for split action"); - let delta_quantity = position.apply_split_ratio(split_ratio); - (delta_quantity, position.quantity, position.average_cost) - }; - if delta_quantity != 0 { - let note = format!( - "stock_split {} ratio={:.6} delta_qty={}", - action.symbol, split_ratio, delta_quantity - ); - notes.push(note); - report.position_events.push(PositionEvent { - date, - symbol: action.symbol.clone(), - delta_quantity, - quantity_after, - average_cost, - realized_pnl_delta: 0.0, - reason: format!("stock_split {:.6}", split_ratio), - }); - } - } - - if action.has_successor_conversion() { - let successor_symbol = action - .successor_symbol - .as_deref() - .expect("successor symbol checked"); - let Some(outcome) = portfolio.apply_successor_conversion( - &action.symbol, - successor_symbol, - action.successor_ratio_value(), - action.successor_cash_value(), - ) else { - continue; - }; - let reason = format!( - "successor_conversion {}->{} ratio={:.6} cash_per_share={:.6}", - outcome.old_symbol, - outcome.new_symbol, - action.successor_ratio_value(), - action.successor_cash_value() - ); - notes.push(reason.clone()); - report.position_events.push(PositionEvent { - date, - symbol: outcome.old_symbol.clone(), - delta_quantity: -(outcome.old_quantity as i32), - quantity_after: 0, - average_cost: 0.0, - realized_pnl_delta: 0.0, - reason: reason.clone(), - }); - report.position_events.push(PositionEvent { - date, - symbol: outcome.new_symbol.clone(), - delta_quantity: outcome.new_quantity_delta, - quantity_after: outcome.new_quantity_after, - average_cost: outcome.new_average_cost_after, - realized_pnl_delta: 0.0, - reason: reason.clone(), - }); - if outcome.cash_delta.abs() > f64::EPSILON { - let cash_before = portfolio.cash(); - portfolio - .apply_cash_delta(outcome.cash_delta) - .map_err(BacktestError::Execution)?; - report.account_events.push(AccountEvent { - date, - cash_before, - cash_after: portfolio.cash(), - total_equity: portfolio.total_equity(), - note: format!("{} cash={:.2}", reason, outcome.cash_delta), - }); - } - } - } - - portfolio.prune_flat_positions(); - Ok(report) + crate::corporate_book::apply( + date, + &self.data, + portfolio, + notes, + self.cash_dividends_enabled, + self.cash_dividend_adjusts_cost_basis, + ) } fn settle_cash_receivables( @@ -4846,10 +4810,20 @@ where notes: &mut Vec, ) -> Result { let mut report = BrokerExecutionReport::default(); - for flow in portfolio + let fixed_before = portfolio.cash_fixed(); + let flows = portfolio .settle_pending_cash_flows(date) - .map_err(BacktestError::Execution)? - { + .map_err(BacktestError::Execution)?; + if let Some(replay) = &self.manual_corporate_replay { + replay + .record_cash( + date.and_hms_opt(0, 0, 0), + fixed_before, + portfolio.cash_fixed(), + ) + .map_err(BacktestError::Execution)?; + } + for flow in flows { let cash_before = portfolio.cash() - flow.amount; let note = format!( "deposit_withdraw_settled amount={:.2} payable_date={} reason={}", @@ -4989,9 +4963,19 @@ where } let cash_before = portfolio.cash(); + let fixed_before = portfolio.cash_fixed(); portfolio .apply_management_fee(fee) .map_err(BacktestError::Execution)?; + if let Some(replay) = &self.manual_corporate_replay { + replay + .record_cash( + stage_datetime(execution_date, callback_time), + fixed_before, + portfolio.cash_fixed(), + ) + .map_err(BacktestError::Execution)?; + } let mut report = BrokerExecutionReport::default(); report.account_events.push(AccountEvent { date: execution_date, diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 689d522..42376a3 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -1,6 +1,7 @@ pub mod broker; pub mod calendar; pub mod cost; +mod corporate_book; pub mod data; mod numeric_factors; pub mod daily_patterns; @@ -22,6 +23,7 @@ pub mod futures; pub mod instrument; pub mod metrics; pub mod manual_execution; +mod manual_corporate_replay; mod numeric_expr_vm; pub mod platform_expr_strategy; pub mod platform_runtime_schema; diff --git a/crates/fidc-core/src/manual_corporate_replay.rs b/crates/fidc-core/src/manual_corporate_replay.rs new file mode 100644 index 0000000..b7d6ee6 --- /dev/null +++ b/crates/fidc-core/src/manual_corporate_replay.rs @@ -0,0 +1,431 @@ +use std::cell::{Cell, RefCell}; +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, Utc}; +use sha2::{Digest, Sha256}; + +use crate::manual_execution::{ + AppliedManualFill, ManualCorporateActionReference, ManualCorporateAdjustment, + ManualCorporatePositionChange, ManualExecutionReplay, ManualFillObservation, +}; +use crate::{DataSet, FillEvent, FixedMoney, MatchingType, OrderSide, PortfolioState, PriceField}; + +#[derive(Debug, Clone)] +struct CashEffect { + at: NaiveDateTime, + amount: FixedMoney, +} + +/// Replays issued financial facts, never strategy callbacks or hypothetical orders. +/// The ordinary book remains observable until an actual receipt is delivered. +pub(crate) struct ManualCorporateReplay { + first_date: NaiveDate, + sessions: RefCell>, + closed: RefCell>, + cash: RefCell>, + reconciled_count: Cell, + cash_dividends: bool, + adjust_cost: bool, + matching: MatchingType, + daily_price: PriceField, + same_day_mark_at_fill: bool, +} + +impl ManualCorporateReplay { + pub(crate) fn new( + first_date: NaiveDate, + cash_dividends: bool, + adjust_cost: bool, + matching: MatchingType, + daily_price: PriceField, + same_day_mark_at_fill: bool, + ) -> Self { + Self { + first_date, + sessions: RefCell::new(BTreeSet::new()), + closed: RefCell::new(BTreeSet::new()), + cash: RefCell::new(Vec::new()), + reconciled_count: Cell::new(0), + cash_dividends, + adjust_cost, + matching, + daily_price, + same_day_mark_at_fill, + } + } + + pub(crate) fn record_session(&self, date: NaiveDate) { + self.sessions.borrow_mut().insert(date); + } + pub(crate) fn record_close(&self, date: NaiveDate) { + self.closed.borrow_mut().insert(date); + } + pub(crate) fn committed(&self, count: usize) { + self.reconciled_count.set(count); + } + + pub(crate) fn record_cash( + &self, + at: Option, + before: FixedMoney, + after: FixedMoney, + ) -> Result<(), String> { + let amount = after + .checked_sub(before) + .ok_or("manual corporate cash observation overflow")?; + if amount != FixedMoney::ZERO { + self.cash.borrow_mut().push(CashEffect { + at: at.ok_or("manual corporate cash observation has no execution clock")?, + amount, + }); + } + Ok(()) + } + + fn action_references( + &self, + observation: ManualFillObservation<'_>, + data: &DataSet, + ) -> Result, String> { + let mut symbols = BTreeSet::from([observation.order.symbol.clone()]); + let observed_date = local(observation.fill.observed_at).date(); + let mut actions = Vec::new(); + for date in self.sessions.borrow().range(( + std::ops::Bound::Excluded(observation.fill.trade_date), + std::ops::Bound::Included(observed_date), + )) { + for action in data.corporate_actions_on(*date) { + if !symbols.contains(&action.symbol) { + continue; + } + crate::finite_serialization::validate(action).map_err(|error| error.to_string())?; + let effective = (action.split_ratio() - 1.).abs() > f64::EPSILON + || action.has_successor_conversion() + || (self.cash_dividends && action.share_cash.abs() > f64::EPSILON); + if !effective { + continue; + } + if let Some(successor) = action + .successor_symbol + .as_ref() + .filter(|_| action.has_successor_conversion()) + { + if data.instrument(successor).is_none() { + return Err(format!( + "manual corporate successor is absent from frozen source data: symbol={successor} action_date={date}" + )); + } + symbols.insert(successor.clone()); + } + actions.push(ManualCorporateActionReference { + date: *date, + symbol: action.symbol.clone(), + successor_symbol: action.successor_symbol.clone(), + share_cash: action.share_cash.to_string(), + split_ratio: action.split_ratio().to_string(), + successor_ratio: action.successor_ratio.map(|value| value.to_string()), + successor_cash: action.successor_cash.map(|value| value.to_string()), + sha256: digest( + &serde_json::to_value(action).map_err(|error| error.to_string())?, + )?, + }); + } + } + Ok(actions) + } + + pub(crate) fn required( + &self, + observation: ManualFillObservation<'_>, + data: &DataSet, + ) -> Result { + Ok(!self.action_references(observation, data)?.is_empty()) + } + + pub(crate) fn project( + &self, + source: &ManualExecutionReplay, + applied_count: usize, + observation: ManualFillObservation<'_>, + current: &mut PortfolioState, + data: &DataSet, + fills: &[FillEvent], + has_pending: bool, + ) -> Result<(AppliedManualFill, ManualCorporateAdjustment), String> { + if has_pending { + return Err("manual observation conflicts with pending shadow orders".into()); + } + let all = source.observations()?; + if all.get(applied_count).is_none_or(|next| { + next.fill.observation_event_id != observation.fill.observation_event_id + }) { + return Err( + "manual corporate observation prefix differs from the immutable trace".into(), + ); + } + let actions = self.action_references(observation, data)?; + if actions.is_empty() { + return Err("manual corporate projection has no processed corporate action".into()); + } + let at = local(observation.fill.observed_at); + let reference = self.replay( + current.initial_cash_fixed(), + &all[..applied_count], + self.reconciled_count.get(), + data, + fills, + at, + )?; + let expected = current.financial_replay_identity(); + let reference_identity = reference.financial_replay_identity(); + if reference_identity != expected { + return Err(format!( + "manual corporate ledger coverage mismatch: symbol={} observed_at={} expected={} replayed={}", + observation.order.symbol, + observation.fill.observed_at, + digest(&expected)?, + digest(&reference_identity)? + )); + } + let replayed = self.replay( + current.initial_cash_fixed(), + &all[..=applied_count], + applied_count + 1, + data, + fills, + at, + )?; + let replayed_identity = replayed.financial_replay_identity(); + let gross = FixedMoney::from_decimal_str(&observation.fill.gross_amount()?.to_string())?; + let fees = FixedMoney::from_decimal_str(&observation.fill.total_fees()?.to_string())?; + let cash_delta = match observation.order.side { + OrderSide::Buy => gross.checked_add(fees).and_then(FixedMoney::checked_neg), + OrderSide::Sell => gross.checked_sub(fees), + } + .ok_or("manual corporate trade cash overflow")?; + let before = current.cash_fixed(); + let after = replayed.cash_fixed(); + let corporate_cash = after + .checked_sub(before) + .and_then(|delta| delta.checked_sub(cash_delta)) + .ok_or("manual corporate adjustment overflow")?; + let symbols = current + .positions() + .keys() + .chain(replayed.positions().keys()) + .cloned() + .chain(std::iter::once(observation.order.symbol.clone())) + .collect::>(); + let mut positions = BTreeMap::new(); + for symbol in symbols { + let change = ManualCorporatePositionChange { + quantity_before: current + .position(&symbol) + .map_or(0, |position| position.quantity), + quantity_after: replayed + .position(&symbol) + .map_or(0, |position| position.quantity), + cost_basis_before: current + .financial_position_basis(&symbol) + .to_decimal_string(), + cost_basis_after: replayed + .financial_position_basis(&symbol) + .to_decimal_string(), + }; + if change.quantity_before != change.quantity_after + || change.cost_basis_before != change.cost_basis_after + { + positions.insert(symbol, change); + } + } + let quantity_after = replayed + .position(&observation.order.symbol) + .map_or(0, |position| position.quantity); + let adjustment = ManualCorporateAdjustment { + schema: "fidc.manual-corporate-adjustment/v1".into(), + observed_at: observation.fill.observed_at, + cash_dividends_enabled: self.cash_dividends, + dividend_cost_basis_adjustment: self.adjust_cost, + actions, + cash_before: before.to_decimal_string(), + cash_after: after.to_decimal_string(), + corporate_cash_delta: corporate_cash.to_decimal_string(), + positions, + reference_sha256: digest(&reference_identity)?, + replayed_sha256: digest(&replayed_identity)?, + }; + current.replace_replayed_financial_book(replayed)?; + Ok(( + AppliedManualFill { + gross, + fees, + cash_delta, + quantity_after, + }, + adjustment, + )) + } + + fn replay( + &self, + initial_cash: FixedMoney, + manual: &[ManualFillObservation<'_>], + economic_count: usize, + data: &DataSet, + fills: &[FillEvent], + at: NaiveDateTime, + ) -> Result { + enum Event<'a> { + Session, + Cash(&'a CashEffect), + Corporate(NaiveDate), + Settle(NaiveDate), + Manual(ManualFillObservation<'a>), + Simulated(&'a FillEvent), + Close(NaiveDate), + } + let mut events = Vec::new(); + let sessions = self.sessions.borrow(); + let closed = self.closed.borrow(); + let cash = self.cash.borrow(); + for date in &*sessions { + let clock = date.and_hms_opt(0, 0, 0).unwrap(); + events.push((clock, 0, 0, Event::Session)); + events.push((clock, 2, 0, Event::Corporate(*date))); + events.push((clock, 3, 0, Event::Settle(*date))); + } + for (index, effect) in cash.iter().enumerate() { + events.push((effect.at, 1, index, Event::Cash(effect))); + } + for (index, observation) in manual.iter().enumerate() { + let clock = if index < economic_count { + local(observation.fill.executed_at) + } else { + local(observation.fill.observed_at) + }; + if clock.date() < self.first_date { + return Err("manual corporate execution precedes the represented initial ledger; opening facts are required".into()); + } + events.push((clock, 4, fills.len() + index, Event::Manual(*observation))); + } + for (index, fill) in fills.iter().enumerate() { + 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. + let clock = match fill.execution_timestamp { + Some(clock) => clock, + None if matches!( + self.matching, + MatchingType::OpenAuction | MatchingType::NextBarOpen + ) => + { + date.and_hms_opt(9, 30, 0).unwrap() + } + None if self.matching == MatchingType::CurrentBarClose + && self.daily_price == PriceField::Close => + { + date.and_hms_opt(15, 0, 0).unwrap() + } + None => { + return Err( + "manual corporate replay lacks an intraday fill execution clock".into(), + ); + } + }; + events.push((clock, 4, index, Event::Simulated(fill))); + } + for date in &*closed { + events.push(( + date.and_hms_nano_opt(23, 59, 59, 999_999_999) + .unwrap() + .min(at), + 5, + 0, + Event::Close(*date), + )); + } + events.sort_by_key(|(clock, priority, sequence, _)| (*clock, *priority, *sequence)); + let mut book = PortfolioState::from_fixed_initial_cash(initial_cash); + for (clock, _, _, event) in events { + if clock > at { + return Err("manual corporate replay contains a future financial fact".into()); + } + match event { + Event::Session => book.begin_trading_day(), + Event::Cash(effect) => { + book.apply_cash_delta_fixed(effect.amount)?; + if book.cash_fixed() < FixedMoney::ZERO { + return Err( + "manual corporate replay conflicts with prior cash facts".into() + ); + } + } + Event::Corporate(date) => { + crate::corporate_book::apply( + date, + data, + &mut book, + &mut Vec::new(), + self.cash_dividends, + self.adjust_cost, + ) + .map_err(|error| error.to_string())?; + } + Event::Settle(date) => { + for receivable in book.take_due_cash_receivables(date) { + book.settle_cash_receivable(&receivable)?; + } + } + Event::Manual(observation) => { + observation.apply(&mut book, data, false)?; + } + Event::Simulated(fill) => { + let gross = FixedMoney::from_f64(fill.gross_amount) + .ok_or("invalid simulated gross amount")?; + let fees = FixedMoney::checked_sum_f64([ + fill.commission, + fill.stamp_tax, + fill.transfer_fee, + ]) + .ok_or("invalid simulated fee amount")?; + book.apply_observed_manual_fill( + fill.execution_date.unwrap_or(fill.date), + &fill.symbol, + fill.side, + fill.quantity, + fill.price, + fill.price, + gross, + fees, + )?; + book.prune_flat_positions(); + } + Event::Close(date) => { + book.update_prices_with_options( + date, + data, + PriceField::Close, + self.same_day_mark_at_fill, + ) + .map_err(|error| error.to_string())?; + } + } + if book.cash_fixed() < FixedMoney::ZERO { + return Err("manual corporate replay would borrow unobserved cash".into()); + } + } + Ok(book) + } +} + +fn local(value: DateTime) -> NaiveDateTime { + value + .with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap()) + .naive_local() +} +fn digest(value: &serde_json::Value) -> Result { + Ok(format!( + "{:x}", + Sha256::digest(serde_json::to_vec(value).map_err(|error| error.to_string())?) + )) +} diff --git a/crates/fidc-core/src/manual_execution.rs b/crates/fidc-core/src/manual_execution.rs index e8a101f..eb75d2f 100644 --- a/crates/fidc-core/src/manual_execution.rs +++ b/crates/fidc-core/src/manual_execution.rs @@ -482,9 +482,80 @@ pub struct ManualReplayApplication { pub ledger_gross_amount: String, pub ledger_fees: String, pub cash_delta: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub corporate_adjustment: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManualCorporateAdjustment { + pub schema: String, + pub observed_at: DateTime, + pub cash_dividends_enabled: bool, + pub dividend_cost_basis_adjustment: bool, + pub actions: Vec, + pub cash_before: String, + pub cash_after: String, + pub corporate_cash_delta: String, + pub positions: BTreeMap, + pub reference_sha256: String, + pub replayed_sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManualCorporateActionReference { + pub date: NaiveDate, + pub symbol: String, + pub successor_symbol: Option, + pub share_cash: String, + pub split_ratio: String, + pub successor_ratio: Option, + pub successor_cash: Option, + pub sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManualCorporatePositionChange { + pub quantity_before: u32, + pub quantity_after: u32, + pub cost_basis_before: String, + pub cost_basis_after: String, } impl ManualReplayCursor { + pub(crate) fn frozen_source(&self) -> std::sync::Arc { + self.replay.clone() + } + + pub(crate) fn next_observation(&self) -> Option> { + self.indices.get(self.cursor).map(|&(a, o, f)| ManualFillObservation { + action: &self.replay.actions[a], order: &self.replay.actions[a].orders[o], + fill: &self.replay.actions[a].orders[o].fills[f], + }) + } + + pub(crate) fn advance_next_projected( + &mut self, portfolio: &mut PortfolioState, project: F, + ) -> Result, String> + where F: FnOnce(ManualFillObservation<'_>, &mut PortfolioState) -> Result<(AppliedManualFill, ManualCorporateAdjustment), String> { + let Some(observation) = self.next_observation() else { return Ok(None); }; + let at = observation.fill.observed_at; + if at > self.replay.observation_cutoff || self.clock.is_some_and(|clock| at < clock) { + return Err("manual projected observation clock violates the frozen trace".into()); + } + let mut next = portfolio.clone(); + let (applied, adjustment) = project(observation, &mut next)?; + let mut application = observation.application(applied)?; + application.corporate_adjustment = Some(adjustment); + crate::finite_serialization::validate(&application).map_err(|error| error.to_string())?; + *portfolio = next; + self.cursor += 1; + self.clock = Some(at); + Ok(Some(application)) + } + pub fn new(replay: ManualExecutionReplay) -> Result { Self::from_shared(std::sync::Arc::new(replay)) } @@ -594,6 +665,7 @@ impl ManualReplayCursor { ledger_gross_amount: applied.gross.to_decimal_string(), ledger_fees: applied.fees.to_decimal_string(), cash_delta: applied.cash_delta.to_decimal_string(), + corporate_adjustment: None, }); } *portfolio = next; @@ -604,6 +676,21 @@ impl ManualReplayCursor { } impl ManualFillObservation<'_> { + fn application(&self, applied: AppliedManualFill) -> Result { + Ok(ManualReplayApplication { + action_id: self.action.action_id.clone(), order_id: self.order.order_id.clone(), + trade_id: self.fill.trade_id.clone(), observation_event_id: self.fill.observation_event_id.clone(), + observation_sequence: self.fill.observation_sequence, observed_at: self.fill.observed_at, + fee_observation_event_id: self.fill.fee_observation_event_id.clone(), fee_observed_at: self.fill.fee_observed_at, + executed_at: self.fill.executed_at, symbol: self.order.symbol.clone(), side: self.order.side, + quantity: self.fill.quantity, quantity_after: applied.quantity_after, price: self.fill.price.to_string(), + commission: self.fill.commission.map(|fee| fee.to_string()), stamp_tax: self.fill.stamp_tax.map(|fee| fee.to_string()), + transfer_fee: self.fill.transfer_fee.map(|fee| fee.to_string()), source_total_fee: self.fill.total_fee.to_string(), + source_gross_amount: self.fill.gross_amount()?.to_string(), ledger_gross_amount: applied.gross.to_decimal_string(), + ledger_fees: applied.fees.to_decimal_string(), cash_delta: applied.cash_delta.to_decimal_string(), corporate_adjustment: None, + }) + } + pub(crate) fn apply( &self, portfolio: &mut PortfolioState, diff --git a/crates/fidc-core/src/manual_execution/tests.rs b/crates/fidc-core/src/manual_execution/tests.rs index d4ec410..273823c 100644 --- a/crates/fidc-core/src/manual_execution/tests.rs +++ b/crates/fidc-core/src/manual_execution/tests.rs @@ -24,6 +24,21 @@ fn reseal(input: &mut ManualExecutionReplay) { input.content_sha256 = input.content_digest().unwrap(); } +#[test] +fn a_failed_corporate_projection_does_not_change_the_book_or_receipt_cursor() { + let mut cursor = ManualReplayCursor::new(sample()).unwrap(); + let mut portfolio = PortfolioState::new(10000.); + let before = portfolio.financial_replay_identity(); + let error = cursor.advance_next_projected(&mut portfolio, |_, next| { + next.apply_cash_delta(-50.)?; + next.position_mut("000001.SZ").buy(NaiveDate::from_ymd_opt(2026, 9, 11).unwrap(), 100, 10.); + Err("financial coverage mismatch".into()) + }).unwrap_err(); + assert_eq!(error, "financial coverage mismatch"); + assert_eq!(portfolio.financial_replay_identity(), before); + assert_eq!(cursor.applied_count(), 0); +} + fn delayed_buy_replay() -> ManualExecutionReplay { let mut input = sample(); let template = input.actions[0].clone(); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 7a1b14d..e021c24 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -12470,6 +12470,11 @@ impl Strategy for PlatformExprStrategy { .entry(execution.symbol.clone()) .and_modify(|previous| *previous = (*previous).max(date)) .or_insert(date); + if let Some(adjustment) = &execution.corporate_adjustment { + for successor in adjustment.actions.iter().filter_map(|action| action.successor_symbol.as_ref()) { + history.entry(successor.clone()).and_modify(|previous| *previous = (*previous).max(date)).or_insert(date); + } + } Ok(()) } fn name(&self) -> &str { diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index ba7e784..063d40f 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -734,6 +734,10 @@ impl PortfolioState { pub fn new(initial_cash: f64) -> Self { let initial_cash = fixed_money(initial_cash, "initial cash") .expect("initial cash must be finite fixed-point money"); + Self::from_fixed_initial_cash(initial_cash) + } + + pub(crate) fn from_fixed_initial_cash(initial_cash: FixedMoney) -> Self { Self { initial_cash, units: initial_cash, @@ -756,6 +760,8 @@ impl PortfolioState { self.initial_cash.to_f64() } + pub(crate) fn initial_cash_fixed(&self) -> FixedMoney { self.initial_cash } + pub(crate) fn stock_pool_execution_state(&self,pool_id:&str)->crate::stock_pool_state::StockPoolExecutionState{ self.stock_pool_states.get(pool_id).cloned().unwrap_or_default() } @@ -787,6 +793,46 @@ impl PortfolioState { self.cash.to_f64() } + pub(crate) fn cash_fixed(&self) -> FixedMoney { self.cash } + + pub(crate) fn financial_replay_identity(&self) -> serde_json::Value { + let positions = self.positions.iter().filter(|(_, position)| position.quantity > 0) + .map(|(symbol, position)| { + let mut lots = position.lots.iter().map(|lot| (lot.acquired_date, lot.quantity, + lot.entry_value.to_decimal_string(), lot.cost_basis.to_decimal_string())).collect::>(); + lots.sort(); + (symbol.clone(), serde_json::json!({"quantity":position.quantity,"lots":lots, + "openedDate":position.opened_date,"lastBuyDate":position.last_buy_date})) + }).collect::>(); + let mut receivables = self.cash_receivables.iter().map(|row| (row.symbol.clone(), row.ex_date, + row.payable_date, fixed_money_or_panic(row.amount, "receivable identity").to_decimal_string(), row.reason.clone())).collect::>(); + receivables.sort(); + serde_json::json!({"cash":self.cash.to_decimal_string(),"positions":positions,"receivables":receivables}) + } + + pub(crate) fn financial_position_basis(&self, symbol: &str) -> FixedMoney { + self.positions.get(symbol).map_or(FixedMoney::ZERO, Position::total_cost_basis) + } + + pub(crate) fn replace_replayed_financial_book(&mut self, mut replayed: PortfolioState) -> Result<(), String> { + if replayed.cash < FixedMoney::ZERO || replayed.initial_cash != self.initial_cash { + return Err("manual corporate replay changed initial capital or borrowed cash".into()); + } + for (symbol, position) in &mut replayed.positions { + if let Some(current) = self.positions.get(symbol).filter(|current| current.quantity > 0) { + position.last_price = current.last_price; + position.refresh_day_pnl(); + } + } + self.cash = replayed.cash; + self.positions = replayed.positions; + self.cash_receivables = replayed.cash_receivables; + self.day_sold_symbols = replayed.day_sold_symbols; + // Existing issued units, explicit cash-flow/financing facts, and task + // target state are observed controls, not counterfactual new orders. + Ok(()) + } + pub fn external_cash_flow_total(&self) -> f64 { self.external_cash_flow_total.to_f64() } @@ -822,9 +868,13 @@ impl PortfolioState { } pub fn apply_cash_delta(&mut self, delta: f64) -> Result<(), String> { + self.apply_cash_delta_fixed(fixed_money(delta, "cash delta")?) + } + + pub(crate) fn apply_cash_delta_fixed(&mut self, delta: FixedMoney) -> Result<(), String> { self.cash = self .cash - .checked_add(fixed_money(delta, "cash delta")?) + .checked_add(delta) .ok_or_else(|| "fixed-point cash overflow".to_string())?; Ok(()) } diff --git a/crates/fidc-core/tests/manual_corporate_observation.rs b/crates/fidc-core/tests/manual_corporate_observation.rs new file mode 100644 index 0000000..05a1614 --- /dev/null +++ b/crates/fidc-core/tests/manual_corporate_observation.rs @@ -0,0 +1,505 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use fidc_core::manual_execution::{MANUAL_REPLAY_SCHEMA, ManualExecutionReplay}; +use fidc_core::{ + BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, + ChinaAShareCostModel, ChinaEquityRuleHooks, CorporateAction, DailyFactorSnapshot, + DailyMarketSnapshot, DataSet, Instrument, MatchingType, PriceField, Strategy, +}; + +fn date(day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 9, day).unwrap() +} + +#[derive(Clone, Copy, Debug)] +enum Action { + Split, + Dividend, + Successor, +} + +fn data(action: Action) -> DataSet { + let days = [10, 11, 14, 15].map(date); + let mut market = Vec::new(); + let mut factors = Vec::new(); + let mut candidates = Vec::new(); + for day in days { + for symbol in ["000001.SZ", "000002.SZ"] { + if matches!(action, Action::Successor) && symbol == "000001.SZ" && day >= date(14) { + continue; + } + let price = if day < date(14) + || (symbol == "000002.SZ" && !matches!(action, Action::Successor)) + { + 10. + } else if matches!(action, Action::Dividend) { + 9. + } else { + 5. + }; + market.push(DailyMarketSnapshot { + date: day, + symbol: symbol.into(), + timestamp: Some(format!("{day} 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, + }); + factors.push(DailyFactorSnapshot { + date: day, + 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(), + }); + candidates.push(CandidateEligibility { + date: day, + 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, + }); + } + } + DataSet::from_components_with_actions( + ["000001.SZ", "000002.SZ"] + .into_iter() + .map(|symbol| Instrument { + symbol: symbol.into(), + name: symbol.into(), + board: "SZ".into(), + round_lot: 100, + listed_at: Some(date(1)), + delisted_at: (matches!(action, Action::Successor) && symbol == "000001.SZ") + .then_some(date(14)), + status: "active".into(), + }) + .collect(), + market, + factors, + candidates, + days.map(|day| BenchmarkSnapshot { + date: day, + benchmark: "000300.SH".into(), + open: 100., + close: 100., + prev_close: 100., + volume: 100000, + }) + .into(), + vec![CorporateAction { + date: date(14), + symbol: "000001.SZ".into(), + payable_date: Some(date(14)), + share_cash: if matches!(action, Action::Dividend) { + 1. + } else { + 0. + }, + share_bonus: if matches!(action, Action::Split) { + 1. + } else { + 0. + }, + share_gift: 0., + issue_quantity: 0., + issue_price: 0., + reform: false, + adjust_factor: None, + successor_symbol: matches!(action, Action::Successor).then(|| "000002.SZ".into()), + successor_ratio: matches!(action, Action::Successor).then_some(2.), + successor_cash: matches!(action, Action::Successor).then_some(0.5), + }], + ) + .unwrap() +} + +fn source(delayed: bool, sell: bool) -> ManualExecutionReplay { + let trades = if sell { + vec![ + ("initial-buy", "Buy", 10, 200, false), + ("sale", "Sell", 11, 100, delayed), + ] + } else { + vec![("buy", "Buy", 11, 100, delayed)] + }; + let actions = trades.into_iter().enumerate().map(|(index, (id, side, day, quantity, late))| { + let executed = format!("2026-09-{day:02}T06:00:00Z").parse::>().unwrap(); + let observed = if late { "2026-09-15T05:00:00Z".parse().unwrap() } else { executed + chrono::Duration::seconds(1) }; + let created = executed - chrono::Duration::seconds(1); + serde_json::json!({"actionId":id,"source":"manual_security_trade","auditEventIds":[format!("audit-{id}")], + "confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[{ + "orderId":id,"brokerOrderId":id,"sourceAdapter":"paper","symbol":"000001.SZ","side":side,"quantity":quantity, + "orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{ + "tradeId":id,"observationEventId":id,"observationSequence":index+1,"tradeDate":date(day), + "executedAt":executed,"observedAt":observed,"feeObservationEventId":id,"feeObservationSequence":index+1, + "feeObservedAt":observed,"timestampPrecision":"second","quantity":quantity,"price":"10","totalFee":"1" + }] + }]}) + }).collect::>(); + let mut source: ManualExecutionReplay = serde_json::from_value(serde_json::json!({ + "schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64), + "contentSha256":"","observationCutoff":"2026-09-15T08:00:00Z","actions":actions, + })).unwrap(); + source.content_sha256 = source.content_digest().unwrap(); + source.validate().unwrap(); + source +} + +struct Hold; +impl Strategy for Hold { + fn name(&self) -> &str { + "manual corporate observation" + } + fn requires_minute_callbacks(&self) -> bool { + false + } +} + +fn run_custom( + data: DataSet, + source: ManualExecutionReplay, + strategy: S, + cash_dividends: bool, + adjust_cost: bool, +) -> Result { + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::NextBarOpen) + .with_volume_limit(false) + .with_liquidity_limit(false); + BacktestEngine::new( + data, + strategy, + broker, + BacktestConfig { + initial_cash: 10000., + benchmark_code: "000300.SH".into(), + start_date: Some(date(10)), + end_date: Some(date(15)), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ) + .with_cash_dividends(cash_dividends) + .with_cash_dividend_cost_basis_adjustment(adjust_cost) + .with_observed_manual_executions(source) + .unwrap() + .run() +} + +fn run( + action: Action, + delayed: bool, + sell: bool, +) -> Result { + run_custom(data(action), source(delayed, sell), Hold, true, true) +} + +#[test] +fn delayed_buy_does_not_lose_corporate_entitlements() { + for action in [Action::Split, Action::Dividend, Action::Successor] { + let timely = run(action, false, false).unwrap(); + let delayed = run(action, true, false).unwrap(); + let project = |result: &fidc_core::BacktestResult| { + ( + result.equity_curve.last().unwrap().cash, + result.equity_curve.last().unwrap().total_equity, + result + .holdings_summary + .iter() + .map(|row| (row.symbol.clone(), row.quantity)) + .collect::>(), + ) + }; + assert_eq!(project(&delayed), project(&timely), "{action:?}"); + assert_eq!(delayed.manual_executions.len(), 1); + assert!(delayed.fills.is_empty()); + if matches!(action, Action::Successor) + && let Ok(directory) = std::env::var("FIDC_CORPORATE_QA_OUTPUT") + { + use std::io::Write; + let path = std::path::Path::new(&directory).join("corporate-successor-result.json"); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path).unwrap(); + file.write_all(&serde_json::to_vec(&serde_json::json!({ + "source":delayed.manual_execution_source.as_deref(), "applications":delayed.manual_executions, + })).unwrap()).unwrap(); + } + } +} + +#[test] +fn delayed_sale_does_not_keep_unearned_corporate_entitlements() { + for action in [Action::Split, Action::Dividend, Action::Successor] { + let timely = run(action, false, true).unwrap(); + let delayed = run(action, true, true).unwrap(); + let project = |result: &fidc_core::BacktestResult| { + ( + result.equity_curve.last().unwrap().cash, + result.equity_curve.last().unwrap().total_equity, + result + .holdings_summary + .iter() + .map(|row| (row.symbol.clone(), row.quantity)) + .collect::>(), + ) + }; + assert_eq!(project(&delayed), project(&timely), "{action:?}"); + assert_eq!(delayed.manual_executions.len(), 2); + assert!(delayed.fills.is_empty()); + } +} + +#[test] +fn corporate_replay_preserves_issued_orders_cash_flows_financing_and_charged_fees() { + struct ExistingActivity { + receiving_days: usize, + } + impl Strategy for ExistingActivity { + fn name(&self) -> &str { + "corporate replay with original activity" + } + fn requires_minute_callbacks(&self) -> bool { + false + } + fn on_day( + &mut self, + ctx: &fidc_core::StrategyContext<'_>, + ) -> Result { + use fidc_core::OrderIntent; + let order_intents = if ctx.execution_date == date(10) { + vec![ + OrderIntent::DepositWithdraw { + amount: 500., + receiving_days: self.receiving_days, + reason: "original deposit".into(), + }, + OrderIntent::FinanceRepay { + amount: 200., + reason: "original financing".into(), + }, + OrderIntent::SetManagementFeeRate { + rate: 0.001, + reason: "original fee policy".into(), + }, + ] + } else if ctx.execution_date == date(11) { + vec![OrderIntent::Shares { + symbol: "000002.SZ".into(), + quantity: 100, + reason: "unrelated stock".into(), + }] + } else if ctx.execution_date == date(14) { + vec![OrderIntent::Shares { + symbol: "000001.SZ".into(), + quantity: 100, + reason: "already issued after corporate action".into(), + }] + } else { + vec![] + }; + Ok(fidc_core::StrategyDecision { + order_intents, + ..Default::default() + }) + } + fn management_fee( + &mut self, + _: &fidc_core::StrategyContext<'_>, + _: f64, + ) -> Result, fidc_core::BacktestError> { + Ok(Some(0.25)) + } + } + for receiving_days in [0, 1] { + for sell in [false, true] { + let timely = run_custom( + data(Action::Split), + source(false, sell), + ExistingActivity { receiving_days }, + true, + true, + ) + .unwrap(); + let delayed = run_custom( + data(Action::Split), + source(true, sell), + ExistingActivity { receiving_days }, + true, + true, + ) + .unwrap(); + assert_eq!( + serde_json::to_value(&timely.fills).unwrap(), + serde_json::to_value(&delayed.fills).unwrap() + ); + assert_eq!(delayed.fills.len(), 2); + assert_eq!( + delayed.equity_curve.last().unwrap().cash, + timely.equity_curve.last().unwrap().cash + ); + assert_eq!( + delayed.equity_curve.last().unwrap().total_equity, + timely.equity_curve.last().unwrap().total_equity + ); + assert_eq!( + delayed + .equity_curve + .iter() + .map(|row| row.external_cash_flow) + .sum::(), + 500. + ); + assert_eq!(delayed.manual_executions.len(), if sell { 2 } else { 1 }); + } + } +} + +#[test] +fn pending_and_disabled_dividends_keep_the_configured_cash_and_cost_contract() { + for paid in [false, true] { + for enabled in [false, true] { + for adjust_cost in [false, true] { + let fixture = || { + let mut parts = data(Action::Dividend).snapshot_components(); + parts.corporate_actions[0].payable_date = + Some(date(if paid { 14 } else { 16 })); + DataSet::from_components_with_actions( + parts.instruments, + parts.market, + parts.factors, + parts.candidates, + parts.benchmarks, + parts.corporate_actions, + ) + .unwrap() + }; + let timely = + run_custom(fixture(), source(false, false), Hold, enabled, adjust_cost) + .unwrap(); + let delayed = + run_custom(fixture(), source(true, false), Hold, enabled, adjust_cost).unwrap(); + assert_eq!( + delayed.equity_curve.last().unwrap().cash, + timely.equity_curve.last().unwrap().cash + ); + let financial = |result: &fidc_core::BacktestResult| { + result + .holdings_summary + .iter() + .map(|row| { + ( + row.symbol.clone(), + row.quantity, + row.average_cost, + row.last_price, + row.market_value, + row.unrealized_pnl, + row.realized_pnl, + row.pnl, + row.dividend_receivable, + ) + }) + .collect::>() + }; + // Receipt-day turnover is deliberately different when the + // dividend option is disabled and no adjustment is required. + assert_eq!(financial(&delayed), financial(&timely)); + assert_eq!( + delayed.manual_executions[0].corporate_adjustment.is_some(), + enabled + ); + if enabled && !paid { + assert_eq!(delayed.terminal_audit.cash_receivable_count, 1); + } + } + } + } +} + +#[test] +fn late_fill_replays_aggregate_split_rounding_not_an_independent_rounded_fragment() { + let fixture = || { + let mut parts = data(Action::Split).snapshot_components(); + parts.corporate_actions[0].share_bonus = 0.125; + for row in &mut parts.market { + if row.symbol == "000001.SZ" && row.date >= date(14) { + row.day_open = 8.89; + row.open = 8.89; + row.close = 8.89; + row.last_price = 8.89; + row.high = 8.89; + row.low = 8.89; + row.prev_close = 8.89; + row.bid1 = 8.89; + row.ask1 = 8.89; + row.upper_limit = 9.78; + row.lower_limit = 8.; + } + } + DataSet::from_components_with_actions( + parts.instruments, + parts.market, + parts.factors, + parts.candidates, + parts.benchmarks, + parts.corporate_actions, + ) + .unwrap() + }; + let input = |delayed| { + let mut value = source(delayed, true); + value.actions[0].orders[0].quantity = 100; + value.actions[0].orders[0].fills[0].quantity = 100; + value.actions[1].orders[0].side = fidc_core::OrderSide::Buy; + value.content_sha256 = value.content_digest().unwrap(); + value + }; + let timely = run_custom(fixture(), input(false), Hold, true, true).unwrap(); + let delayed = run_custom(fixture(), input(true), Hold, true, true).unwrap(); + assert_eq!(timely.holdings_summary[0].quantity, 225); + assert_eq!(delayed.holdings_summary[0].quantity, 225); + assert_eq!( + delayed.equity_curve.last().unwrap().total_equity, + timely.equity_curve.last().unwrap().total_equity + ); + assert_eq!( + delayed.manual_executions[1] + .corporate_adjustment + .as_ref() + .unwrap() + .positions["000001.SZ"] + .quantity_before, + 113 + ); +} diff --git a/docs/manual-corporate-replay-20260914.md b/docs/manual-corporate-replay-20260914.md new file mode 100644 index 0000000..8282b72 --- /dev/null +++ b/docs/manual-corporate-replay-20260914.md @@ -0,0 +1,44 @@ +# 迟到成交跨公司行为:校正候选 + +2026-09-14。仅开发验收,未发布生产;完整股票池目标保持进行中。 + +## 已复现 + +相同成交与同一数据/配置,仅推迟回报收到时间,原实现会丢失或多留公司行为权益: + +- 买100股后送转2倍:及时回报为200股、权益9999;迟到回报只有100股、权益9499。 +- 原持有200股、送转前卖100股:及时回报最后200股、权益9998;迟到回报最后300股、权益10498。 +- 派息会漏记或多记应收/现金;换股后的迟到旧代码成交不能直接写回旧证券持仓。 + +这些是隔离的确定性引擎样例,不是实际GT账户金额。 + +## 实现 + +正常公司行为与校正共用 `corporate_book` 的原始计算逻辑。迟到回报跨过已处理的有效公司行为时,先复算当前已观察前缀,核对现金、批次取得日/成本及应收身份;覆盖不完整就失败,不以账户最终快照覆盖结果。 + +随后在私有经济账本按实际成交时间放入已经确认的手工事实,重放已发出的模拟成交、原现金变动与已执行的公司行为。原始策略不重跑,委托不补造,旧成交/历史日终行不重写;不存在的税费或现金对价不推算。普通日线成交的内部顺序使用冻结撮合模型,不把模型时刻写成实际券商时间。 + +现金与持仓在真实回报收到时原子替换;失败不推进游标。已发行单位、融资/资金流控制和任务目标状态保留,不重新发行单位或改用户参数。已收管理费和既有委托价格/费用保持原事实。现金计算直接使用固定金额,避免大额资金再经浮点转换。 + +校正新增独立审计:公司行为日期、冻结参数和SHA;现金前后与权益现金变化;逐代码股数及成本前后;前后账本摘要。原始成交股数、价格、费用和资金变动仍分开保存。换股后的卖后禁买事实沿已证实的后继代码传递,不冒充新增交易。 + +这是冻结回测合同内的经济账本校正,不是对实际GT税后派息或权益到账的独立核验。 + +## 验证范围 + +- 买/卖两方向 × 送转、派息、换股的最终股数/现金/权益对照通过。 +- 期间存在已执行的同股/其他股票买入、即期或延期入金、显式融资及固定已收管理费,对照通过;原模拟成交列表逐字段不变。 +- 派息启用/禁用、已付/待付、成本调整开/关八组合通过。禁用时不改变原观察日成交统计,仅比较最终经济事实,不将其误称同日成交。 +- 两个100股批次、1.125倍送转,合计225股;不是把两段分别取整得到226股。 +- 原子投影失败测试、来源绑定/金额/日期/后继链篡改拒绝测试通过。 +- 本机Core895、交易625、Runner462/API127、前端2277通过;原ignore/skip不计通过。三项私有PG提交/权限/租约用例实际执行通过,最初错误筛选匹配0项的命令不计通过。 + +Runner正常构建保留现有Mac专属dead-code提示;格式化辅助脚本在lib.rs的模块排序比较处主动停止,没有强行覆盖模块顺序。最终源码检查与测试仍单独执行。 + +## 贯通与剩余门禁 + +提交能力增加 `corporate_adjustments: v1`,旧消费者不接受本轮手工输入。共享最终/流式成交投影保留校正结构并核对固定金额;页面保留原始交易,额外证据无效时单独报错而不藏掉原交易。 + +真实Rust样例经共享投影导出后,通过本机HTTP加载实际审计弹窗,1440/390、短屏及明暗主题共六组通过;无横向溢出、页面错误或交易写请求。原生Tab曾短暂离开弹窗,已补循环焦点;Esc回到原按钮。详见OmniQuant同名文档及 `docs/evidence/manual-corporate-replay-20260914/browser.json`。 + +Source d5明确冻结尚未获准解除,实际Source/Runner/生产联合验收仍未完成。新Linux配套验收与生产发布另列,不用旧AEGDYL/UUx5ru/FewUWP收据冒充本轮。继续核对红利再投入场时钟、后继证券数据范围、目标状态及跨模式ETF等剩余矩阵;本轮通过不代表完整Goal完成。