修复迟到成交跨公司行为的经济账本校正
This commit is contained in:
+137
-153
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user