修复迟到成交跨公司行为的经济账本校正
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
cash_dividends_enabled: bool,
|
||||
cash_dividend_adjusts_cost_basis: bool,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
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)
|
||||
}
|
||||
+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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<BTreeSet<NaiveDate>>,
|
||||
closed: RefCell<BTreeSet<NaiveDate>>,
|
||||
cash: RefCell<Vec<CashEffect>>,
|
||||
reconciled_count: Cell<usize>,
|
||||
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<NaiveDateTime>,
|
||||
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<Vec<ManualCorporateActionReference>, 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<bool, String> {
|
||||
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::<BTreeSet<_>>();
|
||||
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<PortfolioState, String> {
|
||||
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<Utc>) -> NaiveDateTime {
|
||||
value
|
||||
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
|
||||
.naive_local()
|
||||
}
|
||||
fn digest(value: &serde_json::Value) -> Result<String, String> {
|
||||
Ok(format!(
|
||||
"{:x}",
|
||||
Sha256::digest(serde_json::to_vec(value).map_err(|error| error.to_string())?)
|
||||
))
|
||||
}
|
||||
@@ -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<ManualCorporateAdjustment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualCorporateAdjustment {
|
||||
pub schema: String,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub cash_dividends_enabled: bool,
|
||||
pub dividend_cost_basis_adjustment: bool,
|
||||
pub actions: Vec<ManualCorporateActionReference>,
|
||||
pub cash_before: String,
|
||||
pub cash_after: String,
|
||||
pub corporate_cash_delta: String,
|
||||
pub positions: BTreeMap<String, ManualCorporatePositionChange>,
|
||||
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<String>,
|
||||
pub share_cash: String,
|
||||
pub split_ratio: String,
|
||||
pub successor_ratio: Option<String>,
|
||||
pub successor_cash: Option<String>,
|
||||
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<ManualExecutionReplay> {
|
||||
self.replay.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn next_observation(&self) -> Option<ManualFillObservation<'_>> {
|
||||
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<F>(
|
||||
&mut self, portfolio: &mut PortfolioState, project: F,
|
||||
) -> Result<Option<ManualReplayApplication>, 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, String> {
|
||||
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<ManualReplayApplication, String> {
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
lots.sort();
|
||||
(symbol.clone(), serde_json::json!({"quantity":position.quantity,"lots":lots,
|
||||
"openedDate":position.opened_date,"lastBuyDate":position.last_buy_date}))
|
||||
}).collect::<std::collections::BTreeMap<_, _>>();
|
||||
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::<Vec<_>>();
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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::<DateTime<Utc>>().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::<Vec<_>>();
|
||||
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<S: Strategy>(
|
||||
data: DataSet,
|
||||
source: ManualExecutionReplay,
|
||||
strategy: S,
|
||||
cash_dividends: bool,
|
||||
adjust_cost: bool,
|
||||
) -> Result<fidc_core::BacktestResult, fidc_core::BacktestError> {
|
||||
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<fidc_core::BacktestResult, fidc_core::BacktestError> {
|
||||
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::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
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::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
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<fidc_core::StrategyDecision, fidc_core::BacktestError> {
|
||||
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<Option<f64>, 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::<f64>(),
|
||||
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::<Vec<_>>()
|
||||
};
|
||||
// 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
|
||||
);
|
||||
}
|
||||
@@ -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完成。
|
||||
Reference in New Issue
Block a user