修复迟到成交跨公司行为的经济账本校正
This commit is contained in:
@@ -2766,6 +2766,15 @@ where
|
|||||||
if execution.side == OrderSide::Sell {
|
if execution.side == OrderSide::Sell {
|
||||||
let date = execution.executed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive();
|
let date = execution.executed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive();
|
||||||
self.mark_same_day_sold(date, &execution.symbol);
|
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,
|
FuturesTransactionCostModel,
|
||||||
};
|
};
|
||||||
use crate::metrics::{BacktestMetrics, RiskFreeRateContract, compute_backtest_metrics_with_manual};
|
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::risk_control::{FidcRiskDecisionAudit, RiskCheckScope};
|
||||||
use crate::rules::EquityRuleHooks;
|
use crate::rules::EquityRuleHooks;
|
||||||
use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time};
|
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)>,
|
execution_lifecycle_reported: BTreeSet<(String, String)>,
|
||||||
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
||||||
manual_execution_source: Option<std::sync::Arc<crate::manual_execution::ManualExecutionReplay>>,
|
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>,
|
deferred_session_decisions: Vec<DeferredSessionDecision>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,6 +626,7 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|||||||
execution_lifecycle_reported: BTreeSet::new(),
|
execution_lifecycle_reported: BTreeSet::new(),
|
||||||
risk_free_rate_contract: None,
|
risk_free_rate_contract: None,
|
||||||
manual_execution_source: None,
|
manual_execution_source: None,
|
||||||
|
manual_corporate_replay: None,
|
||||||
deferred_session_decisions: Vec::new(),
|
deferred_session_decisions: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -815,8 +817,52 @@ where
|
|||||||
let cash_before = portfolio.cash();
|
let cash_before = portfolio.cash();
|
||||||
let conflict = self.has_open_orders() || self.broker.has_pending_stock_pool_execution()
|
let conflict = self.has_open_orders() || self.broker.has_pending_stock_pool_execution()
|
||||||
|| self.broker.pending_etf_target_count() > 0;
|
|| self.broker.pending_etf_target_count() > 0;
|
||||||
let application = cursor.advance_next(portfolio, &self.data, conflict)
|
let requires_corporate_replay = self
|
||||||
.map_err(BacktestError::Execution)?.expect("next observation checked");
|
.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.broker.record_observed_manual_execution(&application);
|
||||||
self.strategy.on_observed_manual_execution(&application)?;
|
self.strategy.on_observed_manual_execution(&application)?;
|
||||||
result.account_events.push(AccountEvent { date: observed.date(), cash_before,
|
result.account_events.push(AccountEvent { date: observed.date(), cash_before,
|
||||||
@@ -1629,10 +1675,20 @@ where
|
|||||||
reason,
|
reason,
|
||||||
} => {
|
} => {
|
||||||
let cash_before = portfolio.cash();
|
let cash_before = portfolio.cash();
|
||||||
|
let fixed_before = portfolio.cash_fixed();
|
||||||
if receiving_days == 0 {
|
if receiving_days == 0 {
|
||||||
portfolio
|
portfolio
|
||||||
.deposit_withdraw(amount)
|
.deposit_withdraw(amount)
|
||||||
.map_err(BacktestError::Execution)?;
|
.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 {
|
directive_report.account_events.push(AccountEvent {
|
||||||
date: execution_date,
|
date: execution_date,
|
||||||
cash_before,
|
cash_before,
|
||||||
@@ -1694,10 +1750,16 @@ where
|
|||||||
}
|
}
|
||||||
crate::strategy::OrderIntent::FinanceRepay { amount, reason } => {
|
crate::strategy::OrderIntent::FinanceRepay { amount, reason } => {
|
||||||
let cash_before = portfolio.cash();
|
let cash_before = portfolio.cash();
|
||||||
|
let fixed_before = portfolio.cash_fixed();
|
||||||
let liabilities_before = portfolio.cash_liabilities();
|
let liabilities_before = portfolio.cash_liabilities();
|
||||||
portfolio
|
portfolio
|
||||||
.finance_repay(amount)
|
.finance_repay(amount)
|
||||||
.map_err(BacktestError::Execution)?;
|
.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 {
|
directive_report.account_events.push(AccountEvent {
|
||||||
date: execution_date,
|
date: execution_date,
|
||||||
cash_before,
|
cash_before,
|
||||||
@@ -2979,9 +3041,38 @@ where
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(execution_date, _)| *execution_date)
|
.map(|(execution_date, _)| *execution_date)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if let (Some(first), Some(observed)) = (execution_dates.first(), manual_cursor.as_ref().and_then(|cursor| cursor.next_observation_at())) {
|
self.manual_corporate_replay = if manual_has_fills
|
||||||
if observed.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive() < *first {
|
&& execution_dates
|
||||||
return Err(BacktestError::Execution("manual observations precede the declared initial portfolio period".into()));
|
.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 {
|
let mut result = BacktestResult {
|
||||||
@@ -3060,6 +3151,9 @@ where
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&mut corporate_action_notes,
|
&mut corporate_action_notes,
|
||||||
)?;
|
)?;
|
||||||
|
if let Some(replay) = &self.manual_corporate_replay {
|
||||||
|
replay.record_session(execution_date);
|
||||||
|
}
|
||||||
self.extend_result(
|
self.extend_result(
|
||||||
&mut result,
|
&mut result,
|
||||||
receivable_report,
|
receivable_report,
|
||||||
@@ -3116,6 +3210,9 @@ where
|
|||||||
PriceField::Close,
|
PriceField::Close,
|
||||||
self.broker.same_day_buy_close_mark_at_fill(),
|
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);
|
let close_report = self.broker.after_trading(execution_date);
|
||||||
merge_broker_report(&mut report, close_report);
|
merge_broker_report(&mut report, close_report);
|
||||||
let futures_daily_settlement_report = self.settle_futures_daily(execution_date);
|
let futures_daily_settlement_report = self.settle_futures_daily(execution_date);
|
||||||
@@ -4139,6 +4236,9 @@ where
|
|||||||
PriceField::Close,
|
PriceField::Close,
|
||||||
self.broker.same_day_buy_close_mark_at_fill(),
|
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 post_trade_open_orders = self.open_order_views();
|
||||||
let mut broker_diagnostics = std::mem::take(&mut report.diagnostics);
|
let mut broker_diagnostics = std::mem::take(&mut report.diagnostics);
|
||||||
@@ -4566,150 +4666,14 @@ where
|
|||||||
portfolio: &mut PortfolioState,
|
portfolio: &mut PortfolioState,
|
||||||
notes: &mut Vec<String>,
|
notes: &mut Vec<String>,
|
||||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||||
let mut report = BrokerExecutionReport::default();
|
crate::corporate_book::apply(
|
||||||
for action in self.data.corporate_actions_on(date) {
|
date,
|
||||||
if !action.has_effect() {
|
&self.data,
|
||||||
continue;
|
portfolio,
|
||||||
}
|
notes,
|
||||||
let Some(existing_position) = portfolio.position(&action.symbol) else {
|
self.cash_dividends_enabled,
|
||||||
continue;
|
self.cash_dividend_adjusts_cost_basis,
|
||||||
};
|
)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn settle_cash_receivables(
|
fn settle_cash_receivables(
|
||||||
@@ -4846,10 +4810,20 @@ where
|
|||||||
notes: &mut Vec<String>,
|
notes: &mut Vec<String>,
|
||||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||||
let mut report = BrokerExecutionReport::default();
|
let mut report = BrokerExecutionReport::default();
|
||||||
for flow in portfolio
|
let fixed_before = portfolio.cash_fixed();
|
||||||
|
let flows = portfolio
|
||||||
.settle_pending_cash_flows(date)
|
.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 cash_before = portfolio.cash() - flow.amount;
|
||||||
let note = format!(
|
let note = format!(
|
||||||
"deposit_withdraw_settled amount={:.2} payable_date={} reason={}",
|
"deposit_withdraw_settled amount={:.2} payable_date={} reason={}",
|
||||||
@@ -4989,9 +4963,19 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cash_before = portfolio.cash();
|
let cash_before = portfolio.cash();
|
||||||
|
let fixed_before = portfolio.cash_fixed();
|
||||||
portfolio
|
portfolio
|
||||||
.apply_management_fee(fee)
|
.apply_management_fee(fee)
|
||||||
.map_err(BacktestError::Execution)?;
|
.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();
|
let mut report = BrokerExecutionReport::default();
|
||||||
report.account_events.push(AccountEvent {
|
report.account_events.push(AccountEvent {
|
||||||
date: execution_date,
|
date: execution_date,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod broker;
|
pub mod broker;
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod cost;
|
pub mod cost;
|
||||||
|
mod corporate_book;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
mod numeric_factors;
|
mod numeric_factors;
|
||||||
pub mod daily_patterns;
|
pub mod daily_patterns;
|
||||||
@@ -22,6 +23,7 @@ pub mod futures;
|
|||||||
pub mod instrument;
|
pub mod instrument;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod manual_execution;
|
pub mod manual_execution;
|
||||||
|
mod manual_corporate_replay;
|
||||||
mod numeric_expr_vm;
|
mod numeric_expr_vm;
|
||||||
pub mod platform_expr_strategy;
|
pub mod platform_expr_strategy;
|
||||||
pub mod platform_runtime_schema;
|
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_gross_amount: String,
|
||||||
pub ledger_fees: String,
|
pub ledger_fees: String,
|
||||||
pub cash_delta: 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 {
|
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> {
|
pub fn new(replay: ManualExecutionReplay) -> Result<Self, String> {
|
||||||
Self::from_shared(std::sync::Arc::new(replay))
|
Self::from_shared(std::sync::Arc::new(replay))
|
||||||
}
|
}
|
||||||
@@ -594,6 +665,7 @@ impl ManualReplayCursor {
|
|||||||
ledger_gross_amount: applied.gross.to_decimal_string(),
|
ledger_gross_amount: applied.gross.to_decimal_string(),
|
||||||
ledger_fees: applied.fees.to_decimal_string(),
|
ledger_fees: applied.fees.to_decimal_string(),
|
||||||
cash_delta: applied.cash_delta.to_decimal_string(),
|
cash_delta: applied.cash_delta.to_decimal_string(),
|
||||||
|
corporate_adjustment: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
*portfolio = next;
|
*portfolio = next;
|
||||||
@@ -604,6 +676,21 @@ impl ManualReplayCursor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ManualFillObservation<'_> {
|
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(
|
pub(crate) fn apply(
|
||||||
&self,
|
&self,
|
||||||
portfolio: &mut PortfolioState,
|
portfolio: &mut PortfolioState,
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ fn reseal(input: &mut ManualExecutionReplay) {
|
|||||||
input.content_sha256 = input.content_digest().unwrap();
|
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 {
|
fn delayed_buy_replay() -> ManualExecutionReplay {
|
||||||
let mut input = sample();
|
let mut input = sample();
|
||||||
let template = input.actions[0].clone();
|
let template = input.actions[0].clone();
|
||||||
|
|||||||
@@ -12470,6 +12470,11 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
.entry(execution.symbol.clone())
|
.entry(execution.symbol.clone())
|
||||||
.and_modify(|previous| *previous = (*previous).max(date))
|
.and_modify(|previous| *previous = (*previous).max(date))
|
||||||
.or_insert(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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
|
|||||||
@@ -734,6 +734,10 @@ impl PortfolioState {
|
|||||||
pub fn new(initial_cash: f64) -> Self {
|
pub fn new(initial_cash: f64) -> Self {
|
||||||
let initial_cash = fixed_money(initial_cash, "initial cash")
|
let initial_cash = fixed_money(initial_cash, "initial cash")
|
||||||
.expect("initial cash must be finite fixed-point money");
|
.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 {
|
Self {
|
||||||
initial_cash,
|
initial_cash,
|
||||||
units: initial_cash,
|
units: initial_cash,
|
||||||
@@ -756,6 +760,8 @@ impl PortfolioState {
|
|||||||
self.initial_cash.to_f64()
|
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{
|
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()
|
self.stock_pool_states.get(pool_id).cloned().unwrap_or_default()
|
||||||
}
|
}
|
||||||
@@ -787,6 +793,46 @@ impl PortfolioState {
|
|||||||
self.cash.to_f64()
|
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 {
|
pub fn external_cash_flow_total(&self) -> f64 {
|
||||||
self.external_cash_flow_total.to_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> {
|
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
|
self.cash = self
|
||||||
.cash
|
.cash
|
||||||
.checked_add(fixed_money(delta, "cash delta")?)
|
.checked_add(delta)
|
||||||
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
|
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
|
||||||
Ok(())
|
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