修复迟到成交跨公司行为的经济账本校正

This commit is contained in:
boris
2026-09-14 21:14:20 +08:00
parent ef9cc39882
commit 05f1cbbe00
11 changed files with 1447 additions and 154 deletions
+137 -153
View File
@@ -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<S, C, R> {
execution_lifecycle_reported: BTreeSet<(String, String)>,
risk_free_rate_contract: Option<RiskFreeRateContract>,
manual_execution_source: Option<std::sync::Arc<crate::manual_execution::ManualExecutionReplay>>,
manual_corporate_replay: Option<crate::manual_corporate_replay::ManualCorporateReplay>,
deferred_session_decisions: Vec<DeferredSessionDecision>,
}
@@ -625,6 +626,7 @@ impl<S, C, R> BacktestEngine<S, C, R> {
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::<Vec<_>>();
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<String>,
) -> Result<BrokerExecutionReport, BacktestError> {
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<String>,
) -> Result<BrokerExecutionReport, BacktestError> {
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,