保留模拟器失败调用前的委托与执行状态

This commit is contained in:
boris
2026-09-15 01:28:38 +08:00
parent 4c6147e2ee
commit 695fdee4b8
6 changed files with 776 additions and 30 deletions
+152 -28
View File
@@ -100,7 +100,7 @@ struct QuoteLiquidityConsumption {
quantity: u32,
}
#[derive(Debug, Default)]
#[derive(Debug, Default, Clone)]
struct IntradayExecutionLedger {
cursors: BTreeMap<String, NaiveDateTime>,
depth_consumption: BTreeMap<String, [Option<QuoteDepthConsumption>; 2]>,
@@ -235,7 +235,7 @@ enum BrokerCallbackPhase {
BeforeStrategy,
}
#[derive(Debug, Default)]
#[derive(Debug, Default, Clone)]
struct BrokerExecutionSession {
date: Option<NaiveDate>,
intraday_turnover: BTreeMap<String, u32>,
@@ -441,6 +441,43 @@ impl<T: Copy> Drop for RestoreCell<'_, T> {
fn drop(&mut self) { self.0.set(self.1); }
}
struct RestoreRefCell<'a, T>(&'a RefCell<T>, Option<T>);
impl<T> Drop for RestoreRefCell<'_, T> {
fn drop(&mut self) {
if let Some(value) = self.1.take() { self.0.replace(value); }
}
}
macro_rules! execution_context_checkpoint {
($($field:ident : $kind:ty),* $(,)?) => {
struct BrokerExecutionContext { $($field: $kind),* }
impl BrokerExecutionContext {
fn capture<C, R>(broker: &BrokerSimulator<C, R>) -> Self {
Self { $($field: broker.$field.get()),* }
}
fn restore<C, R>(self, broker: &BrokerSimulator<C, R>) {
$(broker.$field.set(self.$field);)*
}
}
};
}
execution_context_checkpoint! {
runtime_etf_daily_open: bool,
runtime_stock_pool_followup: bool,
runtime_intraday_start_time: Option<NaiveTime>,
runtime_intraday_end_time: Option<NaiveTime>,
runtime_execution_clock: Option<NaiveTime>,
runtime_callback_phase: BrokerCallbackPhase,
runtime_algo_schedule: Option<AlgoExecutionRequest>,
runtime_unprocessed_algorithm_cash: FixedMoney,
runtime_decision_date: Option<NaiveDate>,
runtime_order_created_date: Option<NaiveDate>,
runtime_resting_order_origin: Option<RestingOrderOrigin>,
runtime_decision_total_equity: Option<f64>,
runtime_target_position_limit: Option<usize>,
runtime_time_in_force: Option<OrderTimeInForce>,
}
pub struct BrokerSimulator<C, R> {
historical_etf_open_fallback: bool,
verified_etf_minute_absences: RefCell<BTreeSet<(NaiveDate, String)>>,
@@ -485,9 +522,96 @@ pub struct BrokerSimulator<C, R> {
next_order_id: Cell<u64>,
open_orders: RefCell<Vec<OpenOrder>>,
execution_session: RefCell<BrokerExecutionSession>,
execution_transaction_depth: Cell<usize>,
}
/// Only unpublished simulator state is transactional. Broker observations and
/// results returned successfully by earlier calls are outside this checkpoint.
struct BrokerExecutionCheckpoint {
portfolio: crate::portfolio::PortfolioCheckpoint,
orders: Vec<OpenOrder>,
etf_targets: crate::etf_execution::DeferredEtfTargets,
pool_targets: BTreeMap<String, stock_pool::DeferredStockPoolExecution>,
sold: BTreeMap<NaiveDate, BTreeSet<String>>,
session: BrokerExecutionSession,
next_order_id: u64,
context: BrokerExecutionContext,
}
impl BrokerExecutionCheckpoint {
fn capture<C, R>(broker: &BrokerSimulator<C, R>, portfolio: &PortfolioState, symbols: Option<&BTreeSet<String>>) -> Self {
Self {
portfolio: portfolio.checkpoint(symbols), orders: broker.open_orders.borrow().clone(),
etf_targets: broker.deferred_etf_targets.borrow().clone(),
pool_targets: broker.deferred_stock_pools.borrow().clone(),
sold: broker.same_day_sold_symbols.borrow().clone(),
session: broker.execution_session.borrow().clone(),
next_order_id: broker.next_order_id.get(),
context: BrokerExecutionContext::capture(broker),
}
}
fn restore<C, R>(self, broker: &BrokerSimulator<C, R>, portfolio: &mut PortfolioState) {
self.portfolio.restore(portfolio);
*broker.open_orders.borrow_mut() = self.orders;
*broker.deferred_etf_targets.borrow_mut() = self.etf_targets;
*broker.deferred_stock_pools.borrow_mut() = self.pool_targets;
*broker.same_day_sold_symbols.borrow_mut() = self.sold;
*broker.execution_session.borrow_mut() = self.session;
broker.next_order_id.set(self.next_order_id);
self.context.restore(broker);
}
}
struct BrokerExecutionTransaction<'a, C, R> {
broker: &'a BrokerSimulator<C, R>,
portfolio: &'a mut PortfolioState,
checkpoint: Option<BrokerExecutionCheckpoint>,
}
impl<C, R> Drop for BrokerExecutionTransaction<'_, C, R> {
fn drop(&mut self) {
if let Some(checkpoint) = self.checkpoint.take() {
checkpoint.restore(self.broker, self.portfolio);
}
}
}
impl<C, R> BrokerSimulator<C, R> {
fn execution_transaction<F>(&self, portfolio: &mut PortfolioState, needed: bool, symbols: Option<&BTreeSet<String>>, execute: F)
-> Result<BrokerExecutionReport, BacktestError>
where F: FnOnce(&mut PortfolioState) -> Result<BrokerExecutionReport, BacktestError> {
if !needed || self.execution_transaction_depth.get() > 0 { return execute(portfolio); }
let checkpoint = BrokerExecutionCheckpoint::capture(self, portfolio, symbols);
let mut transaction = BrokerExecutionTransaction { broker: self, portfolio, checkpoint: Some(checkpoint) };
let _depth = RestoreCell(&self.execution_transaction_depth,
self.execution_transaction_depth.replace(1));
let result = execute(transaction.portfolio);
if result.is_ok() { transaction.checkpoint = None; }
result
}
fn checkpoint_symbols(&self, decision: &StrategyDecision) -> Option<BTreeSet<String>> {
if decision.rebalance || !self.deferred_stock_pools.borrow().is_empty() { return None; }
let mut symbols = self.open_orders.borrow().iter().map(|order| order.symbol.clone()).collect::<BTreeSet<_>>();
symbols.extend(decision.exit_symbols.iter().cloned());
for intent in &decision.order_intents {
let symbol = match intent.unwrapped() {
OrderIntent::Shares { symbol, .. } | OrderIntent::LimitShares { symbol, .. }
| OrderIntent::Lots { symbol, .. } | OrderIntent::LimitLots { symbol, .. }
| OrderIntent::TargetShares { symbol, .. } | OrderIntent::LimitTargetShares { symbol, .. }
| OrderIntent::Value { symbol, .. } | OrderIntent::LimitValue { symbol, .. }
| OrderIntent::TargetValue { symbol, .. } | OrderIntent::LimitTargetValue { symbol, .. }
| OrderIntent::TimedTargetValue { symbol, .. } | OrderIntent::AlgoValue { symbol, .. }
| OrderIntent::Percent { symbol, .. } | OrderIntent::LimitPercent { symbol, .. }
| OrderIntent::TargetPercent { symbol, .. } | OrderIntent::LimitTargetPercent { symbol, .. } => symbol,
// Unknown/new/whole-portfolio controls must retain everything.
_ => return None,
};
symbols.insert(symbol.clone());
}
Some(symbols)
}
pub fn new(cost_model: C, rules: R) -> Self {
Self {
historical_etf_open_fallback: false,
@@ -531,6 +655,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_target_position_limit: Cell::new(None),
runtime_time_in_force: Cell::new(None),
next_order_id: Cell::new(1),
execution_transaction_depth: Cell::new(0),
open_orders: RefCell::new(Vec::new()),
execution_session: RefCell::new(BrokerExecutionSession::default()),
}
@@ -583,6 +708,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_target_position_limit: Cell::new(None),
runtime_time_in_force: Cell::new(None),
next_order_id: Cell::new(1),
execution_transaction_depth: Cell::new(0),
open_orders: RefCell::new(Vec::new()),
execution_session: RefCell::new(BrokerExecutionSession::default()),
}
@@ -1602,30 +1728,22 @@ where
data: &DataSet,
decision: &StrategyDecision,
) -> Result<BrokerExecutionReport, BacktestError> {
let previous_decision_date = self.runtime_decision_date.get();
let previous_buy_denials = self.runtime_buy_denials.replace(decision.buy_denials.clone());
let _buy_denials = RestoreRefCell(&self.runtime_buy_denials,
Some(self.runtime_buy_denials.replace(decision.buy_denials.clone())));
let protection_denials = |scope| decision.risk_decisions.iter()
.filter(|row| !row.accepted && row.stage == "automatic_trade_protection" && row.scope == scope)
.map(|row| (row.symbol.clone(), row.reason.clone())).collect();
let previous_auto_buy_denials = self.runtime_auto_buy_denials.replace(protection_denials(crate::risk_control::RiskCheckScope::Buy));
let previous_auto_sell_denials = self.runtime_auto_sell_denials.replace(protection_denials(crate::risk_control::RiskCheckScope::Sell));
let previous_order_created_date = self.runtime_order_created_date.get();
let previous_decision_total_equity = self.runtime_decision_total_equity.get();
self.runtime_decision_date.set(Some(decision_date));
self.runtime_order_created_date
.set(Some(order_created_date));
self.runtime_decision_total_equity
.set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0));
let result = self.execute_with_runtime_dates(date, portfolio, data, decision);
self.runtime_buy_denials.replace(previous_buy_denials);
self.runtime_auto_buy_denials.replace(previous_auto_buy_denials);
self.runtime_auto_sell_denials.replace(previous_auto_sell_denials);
self.runtime_decision_date.set(previous_decision_date);
self.runtime_order_created_date
.set(previous_order_created_date);
self.runtime_decision_total_equity
.set(previous_decision_total_equity);
result
let _auto_buy = RestoreRefCell(&self.runtime_auto_buy_denials, Some(self.runtime_auto_buy_denials
.replace(protection_denials(crate::risk_control::RiskCheckScope::Buy))));
let _auto_sell = RestoreRefCell(&self.runtime_auto_sell_denials, Some(self.runtime_auto_sell_denials
.replace(protection_denials(crate::risk_control::RiskCheckScope::Sell))));
let _decision_date = RestoreCell(&self.runtime_decision_date,
self.runtime_decision_date.replace(Some(decision_date)));
let _created_date = RestoreCell(&self.runtime_order_created_date,
self.runtime_order_created_date.replace(Some(order_created_date)));
let _equity = RestoreCell(&self.runtime_decision_total_equity,
self.runtime_decision_total_equity.replace(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0)));
self.execute_with_runtime_dates(date, portfolio, data, decision)
}
fn execute_with_runtime_dates(
@@ -1638,11 +1756,16 @@ where
if self.volume_limit {
self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?;
}
let mut session = std::mem::take(&mut *self.execution_session.borrow_mut());
session.activate(date);
let result = self.execute_with_daily_session(date, portfolio, data, decision, &mut session);
*self.execution_session.borrow_mut() = session;
result
self.execution_session.borrow_mut().activate(date);
let may_execute = self.has_open_orders() || !self.deferred_stock_pools.borrow().is_empty()
|| decision.rebalance || !decision.order_intents.is_empty() || !decision.exit_symbols.is_empty();
let symbols = may_execute.then(|| self.checkpoint_symbols(decision)).flatten();
self.execution_transaction(portfolio, may_execute, symbols.as_ref(), |portfolio| {
let mut session = std::mem::take(&mut *self.execution_session.borrow_mut());
let result = self.execute_with_daily_session(date, portfolio, data, decision, &mut session);
*self.execution_session.borrow_mut() = session;
result
})
}
fn execute_with_daily_session(
@@ -8749,6 +8872,7 @@ mod tests {
use crate::strategy::{AlgoOrderStyle, OrderIntent, OrderTimeInForce, StrategyDecision};
include!("broker_stock_pool_batch_tests.rs");
include!("broker_order_recovery_tests.rs");
#[test]
fn queued_order_retains_the_real_creation_clock_when_retried() {
@@ -0,0 +1,515 @@
// Kept inside broker::tests to inspect internal accepted-order identity as
// well as the public report. These are simulator states, never GT requests.
fn recovery_test_data(missing_previous: Option<usize>, intraday: bool) -> DataSet {
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut instruments = Vec::new();
let mut rows = Vec::new();
let mut candidates = Vec::new();
let mut quotes = Vec::new();
for index in 1..=2 {
let symbol = format!("{index:06}.SZ");
let mut instrument = limit_test_instrument();
instrument.symbol = symbol.clone();
instruments.push(instrument);
for day in [previous, date] {
if day == previous && missing_previous == Some(index) {
continue;
}
let mut row = dated_limit_test_snapshot(day);
row.symbol = symbol.clone().into();
rows.push(row);
let mut candidate = dated_limit_test_candidate(day, false, false, true, true);
candidate.symbol = symbol.clone().into();
candidates.push(candidate);
}
if intraday {
let mut quote = limit_test_quote(10., 10., 10.);
quote.symbol = symbol;
quote.date = date;
quote.timestamp = date.and_hms_opt(9, 33, 0).unwrap();
quotes.push(quote);
}
}
DataSet::from_components_with_actions_and_quotes(
instruments,
rows,
vec![],
candidates,
vec![
dated_limit_test_benchmark(previous),
dated_limit_test_benchmark(date),
],
vec![],
quotes,
)
.unwrap()
}
fn recovery_test_broker(
intraday: bool,
first_side: OrderSide,
) -> (
BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
PortfolioState,
) {
let mut broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(if intraday {
MatchingType::MinuteLast
} else {
MatchingType::CurrentBarClose
})
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(
super::DynamicSlippageConfig::new(0., 0., 0.1),
));
if intraday {
broker =
broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 33, 0).unwrap());
}
let mut first = test_open_order(1);
first.filled_quantity = 100;
first.remaining_quantity = 100;
first.commission_remaining = Some(0.);
first.side = first_side;
let mut second = test_open_order(2);
second.symbol = "000002.SZ".into();
broker.upsert_open_order(first);
broker.upsert_open_order(second);
broker.next_order_id.set(3);
let mut account = PortfolioState::new(9000.);
account.position_mut("000001.SZ").buy(
chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
if first_side == OrderSide::Buy {
100
} else {
200
},
10.,
);
account.begin_trading_day();
(broker, account)
}
fn recovery_report_value(report: &BrokerExecutionReport) -> serde_json::Value {
serde_json::json!({"orders":report.order_events,"fills":report.fill_events,
"positions":report.position_events,"accounts":report.account_events,
"events":report.process_events,"diagnostics":report.diagnostics})
}
#[test]
fn failed_resting_order_batch_keeps_accepted_orders_and_unpublished_financial_state() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
for intraday in [false, true] {
for first_side in [OrderSide::Buy, OrderSide::Sell] {
for missing in [1, 2] {
let (broker, mut account) = recovery_test_broker(intraday, first_side);
let orders = format!("{:?}", broker.open_orders.borrow());
let ledger = account.financial_replay_identity();
let error = broker
.execute(
date,
&mut account,
&recovery_test_data(Some(missing), intraday),
&StrategyDecision::default(),
)
.unwrap_err();
assert!(
error
.to_string()
.contains("historical_slippage_calibration_missing")
);
assert_eq!(
format!("{:?}", broker.open_orders.borrow()),
orders,
"intraday={intraday} first={first_side:?} missing={missing}"
);
assert_eq!(account.financial_replay_identity(), ledger);
assert!(broker.same_day_sold_symbols.borrow().is_empty());
let recovered = broker
.execute(
date,
&mut account,
&recovery_test_data(None, intraday),
&StrategyDecision::default(),
)
.unwrap();
let (clean, mut clean_account) = recovery_test_broker(intraday, first_side);
let reference = clean
.execute(
date,
&mut clean_account,
&recovery_test_data(None, intraday),
&StrategyDecision::default(),
)
.unwrap();
assert_eq!(
recovery_report_value(&recovered),
recovery_report_value(&reference)
);
assert_eq!(
account.financial_replay_identity(),
clean_account.financial_replay_identity()
);
assert!(broker.open_order_views().is_empty());
assert_eq!(recovered.fill_events.len(), 2);
}
}
}
}
#[test]
fn failed_new_batch_does_not_erase_prior_success_or_double_charge_on_retry() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let (broker, mut account) = recovery_test_broker(true, OrderSide::Buy);
let good = recovery_test_data(None, true);
let prior = broker
.execute(date, &mut account, &good, &StrategyDecision::default())
.unwrap();
assert_eq!(prior.fill_events.len(), 2);
let initial = account.financial_replay_identity();
let id = broker.next_order_id.get();
let decision = StrategyDecision {
order_intents: vec![
OrderIntent::Shares {
symbol: "000001.SZ".into(),
quantity: 100,
reason: "next-batch-a".into(),
},
OrderIntent::Shares {
symbol: "000002.SZ".into(),
quantity: 100,
reason: "next-batch-b".into(),
},
],
..Default::default()
};
// A later quote lets this batch execute independently of the prior fills.
let mut parts = good.snapshot_components();
for quote in &mut parts.execution_quotes {
quote.timestamp += chrono::Duration::minutes(1);
}
broker
.runtime_execution_clock
.set(Some(NaiveTime::from_hms_opt(9, 34, 0).unwrap()));
let restored = DataSet::from_components_with_actions_and_quotes(
parts.instruments.clone(),
parts.market.clone(),
parts.factors.clone(),
parts.candidates.clone(),
parts.benchmarks.clone(),
vec![],
parts.execution_quotes.clone(),
)
.unwrap();
parts
.market
.retain(|row| !(row.symbol.as_str() == "000002.SZ" && row.date < date));
let broken = DataSet::from_components_with_actions_and_quotes(
parts.instruments,
parts.market,
parts.factors,
parts.candidates,
parts.benchmarks,
vec![],
parts.execution_quotes,
)
.unwrap();
assert!(
broker
.execute(date, &mut account, &broken, &decision)
.is_err()
);
assert_eq!(account.financial_replay_identity(), initial);
assert_eq!(broker.next_order_id.get(), id);
assert!(broker.open_orders.borrow().is_empty());
let result = broker
.execute(date, &mut account, &restored, &decision)
.unwrap();
assert_eq!(result.fill_events.len(), 2);
assert_eq!(result.fill_events[0].order_id, Some(id));
assert_eq!(result.fill_events[1].order_id, Some(id + 1));
assert_eq!(account.position("000001.SZ").unwrap().quantity, 300);
assert_eq!(account.position("000002.SZ").unwrap().quantity, 300);
assert_eq!(
prior.fill_events.len(),
2,
"previously returned report remains intact"
);
}
#[test]
fn unwinding_an_unpublished_simulator_transaction_restores_its_state() {
let (broker, mut account) = recovery_test_broker(false, OrderSide::Sell);
let initial = account.financial_replay_identity();
let orders = format!("{:?}", broker.open_orders.borrow());
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = broker.execution_transaction(&mut account, true, None, |account| {
account.apply_cash_delta(500.).unwrap();
broker.open_orders.borrow_mut().clear();
panic!("isolated simulator callback unwind");
});
}));
assert!(result.is_err());
assert_eq!(account.financial_replay_identity(), initial);
assert_eq!(format!("{:?}", broker.open_orders.borrow()), orders);
assert_eq!(broker.execution_transaction_depth.get(), 0);
}
#[test]
fn deferred_etf_batch_failure_keeps_both_targets_and_prior_generation_progress() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut parts = recovery_test_data(None, false).snapshot_components();
for instrument in &mut parts.instruments {
instrument.board = "ETF".into();
}
let good = DataSet::from_components_with_actions_and_quotes(
parts.instruments.clone(),
parts.market.clone(),
parts.factors.clone(),
parts.candidates.clone(),
parts.benchmarks.clone(),
vec![],
vec![],
)
.unwrap();
parts
.market
.retain(|row| !(row.date == date && row.symbol.as_str() == "000002.SZ"));
let bad = DataSet::from_components_with_actions_and_quotes(
parts.instruments,
parts.market,
parts.factors,
parts.candidates,
parts.benchmarks,
vec![],
vec![],
)
.unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_limit(false)
.with_liquidity_limit(false);
let members = std::sync::Arc::new(
(1..=2)
.map(|index| crate::stock_pool_execution::StockPoolMemberSpec {
symbol: format!("{index:06}.SZ"),
requested_order: index,
recommendation_reason: String::new(),
target_weight_bps: None,
stop_loss: None,
take_profit: None,
})
.collect::<Vec<_>>(),
);
broker
.deferred_etf_targets
.borrow_mut()
.replace_generation("pool", "latest");
for index in 1..=2 {
broker
.deferred_etf_targets
.borrow_mut()
.upsert(crate::etf_execution::DeferredEtfTarget {
pool_id: "pool".into(),
generation: "latest".into(),
symbol: format!("{index:06}.SZ"),
signal_date: previous,
signal_at: previous.and_hms_opt(13, 0, 0).unwrap(),
execute_on: Some(date),
target_value: 1000.into(),
target_weight_bps: 5000,
side: crate::stock_pool_execution::OrderSide::Buy,
max_positions: 2,
rule: Default::default(),
members: std::sync::Arc::clone(&members),
reason: "deferred recovery fixture".into(),
});
}
let queue = format!("{:?}", broker.deferred_etf_targets.borrow());
let mut account = PortfolioState::new(10000.);
let state = account.stock_pool_execution_state("pool");
assert!(
broker
.execute_deferred_etf_targets(date, &mut account, &bad)
.is_err()
);
assert_eq!(account.cash(), 10000.);
assert!(account.positions().is_empty());
assert_eq!(account.stock_pool_execution_state("pool"), state);
assert_eq!(format!("{:?}", broker.deferred_etf_targets.borrow()), queue);
assert_eq!(broker.next_order_id.get(), 1);
assert_eq!(broker.execution_transaction_depth.get(), 0);
let result = broker
.execute_deferred_etf_targets(date, &mut account, &good)
.unwrap();
assert_eq!(result.fill_events.len(), 2, "{result:?}");
assert_eq!(broker.pending_etf_target_count(), 0);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
assert_eq!(account.position("000002.SZ").unwrap().quantity, 100);
}
#[test]
fn public_callback_unwind_does_not_leak_order_context_or_authoritative_prior_state() {
struct PanicRules;
impl crate::rules::EquityRuleHooks for PanicRules {
fn can_buy(
&self,
_: chrono::NaiveDate,
_: &DailyMarketSnapshot,
_: &CandidateEligibility,
_: PriceField,
) -> crate::rules::RuleCheck {
panic!("isolated rule callback panic")
}
fn can_sell(
&self,
_: chrono::NaiveDate,
_: &DailyMarketSnapshot,
_: &CandidateEligibility,
_: &crate::portfolio::Position,
_: PriceField,
) -> crate::rules::RuleCheck {
unreachable!()
}
}
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let prior = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), PanicRules)
.with_volume_limit(false)
.with_liquidity_limit(false);
broker.runtime_decision_date.set(Some(prior));
broker
.runtime_buy_denials
.borrow_mut()
.insert("unrelated".into(), "prior".into());
let mut account = PortfolioState::new(10000.);
let decision = StrategyDecision {
buy_denials: BTreeMap::from([("another".into(), "temporary".into())]),
order_intents: vec![
OrderIntent::LimitShares {
symbol: "000001.SZ".into(),
quantity: 100,
limit_price: 10.,
reason: "panic fixture".into(),
}
.with_time_in_force(OrderTimeInForce::Gtc),
],
..Default::default()
};
assert!(
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = broker.execute(
date,
&mut account,
&recovery_test_data(None, false),
&decision,
);
}))
.is_err()
);
assert_eq!(account.cash(), 10000.);
assert!(account.positions().is_empty());
assert!(broker.open_order_views().is_empty());
assert_eq!(broker.runtime_decision_date.get(), Some(prior));
assert_eq!(
*broker.runtime_buy_denials.borrow(),
BTreeMap::from([("unrelated".into(), "prior".into())])
);
assert_eq!(broker.runtime_time_in_force.get(), None);
assert_eq!(broker.runtime_target_position_limit.get(), None);
assert_eq!(broker.execution_transaction_depth.get(), 0);
}
#[test]
fn simulator_transaction_profile_preserves_successful_output() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut instruments = Vec::new();
let mut market = Vec::new();
let mut candidates = Vec::new();
for index in 1..=30 {
let symbol = format!("{index:06}.SZ");
let mut instrument = limit_test_instrument();
instrument.symbol = symbol.clone();
instruments.push(instrument);
for day in [previous, date] {
let mut row = dated_limit_test_snapshot(day);
row.symbol = symbol.clone().into();
market.push(row);
let mut row = dated_limit_test_candidate(day, false, false, true, true);
row.symbol = symbol.clone().into();
candidates.push(row);
}
}
let data = DataSet::from_components(
instruments,
market,
vec![],
candidates,
vec![
dated_limit_test_benchmark(previous),
dated_limit_test_benchmark(date),
],
)
.unwrap();
let mut reference = None;
let mut samples = Vec::new();
for protected in [false, true, true, false] {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose)
.with_volume_limit(false)
.with_liquidity_limit(false);
// Private comparison only: no runtime option can disable protection.
if !protected {
broker.execution_transaction_depth.set(1);
}
let mut account = PortfolioState::new(10_000_000.);
for index in 1..=30 {
for _ in 0..20 {
account
.position_mut(&format!("{index:06}.SZ"))
.buy(previous, 100, 10.);
}
}
account.begin_trading_day();
let mut orders = Vec::new();
let mut fills = Vec::new();
let start = std::time::Instant::now();
for index in 0..500 {
let report = broker
.execute(
date,
&mut account,
&data,
&StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: format!("{:06}.SZ", index % 30 + 1),
quantity: 100,
reason: "transaction profile".into(),
}],
..Default::default()
},
)
.unwrap();
orders.extend(report.order_events);
fills.extend(report.fill_events);
}
samples.push(
serde_json::json!({"protected":protected,"microseconds":start.elapsed().as_micros()}),
);
assert_eq!(fills.len(), 500);
let outcome = serde_json::json!({"orders":orders,"fills":fills,"ledger":account.financial_replay_identity()});
if let Some(reference) = &reference {
assert_eq!(&outcome, reference);
} else {
reference = Some(outcome);
}
}
println!(
"simulator_transaction_profile={}",
serde_json::json!({"securities":30,"initial_lots_per_security":20,"calls":500,"samples":samples,
"scope":"isolated broker only; not Source or full backtest throughput"})
);
}
+6 -1
View File
@@ -5,7 +5,7 @@ use crate::stock_pool_execution as pool;
use rust_decimal::{Decimal, prelude::ToPrimitive};
use chrono::Timelike;
#[derive(Debug)]
#[derive(Debug, Clone)]
pub(super) struct DeferredStockPoolExecution {
date: NaiveDate,
contract: Box<pool::FrozenStockPoolIntent>,
@@ -751,6 +751,11 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
/// Called at the opening clock, after settlement/corporate actions and
/// auction callbacks. It never sends a stock order or replays a strategy.
pub(crate) fn execute_deferred_etf_targets(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result<BrokerExecutionReport, BacktestError> {
self.execution_transaction(portfolio, !self.has_open_orders() && self.pending_etf_target_count() > 0, None,
|portfolio| self.execute_deferred_etf_targets_inner(date, portfolio, data))
}
fn execute_deferred_etf_targets_inner(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
if self.has_open_orders() {
if self.pending_etf_target_count() > 0 {
+1 -1
View File
@@ -61,7 +61,7 @@ pub(crate) struct DeferredEtfTarget {
/// Owned by one broker/run. Replacing a full pool generation supersedes older
/// queued targets; order of the latest candidate list is retained.
#[derive(Debug, Default)]
#[derive(Debug, Default, Clone)]
pub(crate) struct DeferredEtfTargets {
generations: std::collections::BTreeMap<String, String>,
rows: Vec<DeferredEtfTarget>,
+73
View File
@@ -715,6 +715,27 @@ pub struct PortfolioState {
stock_pool_states: std::collections::BTreeMap<String,crate::stock_pool_state::StockPoolExecutionState>,
}
pub(crate) struct PortfolioCheckpoint {
saved: PortfolioState,
position_order: Option<Vec<String>>,
}
impl PortfolioCheckpoint {
pub(crate) fn restore(mut self, current: &mut PortfolioState) {
if let Some(order) = self.position_order.take() {
let mut positions = IndexMap::with_capacity(order.len());
for symbol in order {
let position = self.saved.positions.shift_remove(&symbol)
.or_else(|| current.positions.shift_remove(&symbol))
.expect("unchanged checkpoint position must remain present");
positions.insert(symbol, position);
}
self.saved.positions = positions;
}
*current = self.saved;
}
}
#[derive(Debug, Clone)]
pub struct PendingCashFlow {
pub payable_date: NaiveDate,
@@ -734,6 +755,29 @@ pub(crate) struct SuccessorConversionOutcome {
}
impl PortfolioState {
/// Ordinary single-security orders need not duplicate every other lot.
/// Complex portfolio intents request the complete checkpoint instead.
pub(crate) fn checkpoint(&self, symbols: Option<&BTreeSet<String>>) -> PortfolioCheckpoint {
let Some(symbols) = symbols else {
return PortfolioCheckpoint { saved: self.clone(), position_order: None };
};
PortfolioCheckpoint {
saved: Self {
initial_cash: self.initial_cash, units: self.units, cash: self.cash,
external_cash_flow_total: self.external_cash_flow_total,
cash_liabilities: self.cash_liabilities, management_fee_rate: self.management_fee_rate,
management_fees: self.management_fees,
// prune_flat_positions can remove an unrelated zero row.
positions: self.positions.iter().filter(|(symbol, position)| position.quantity == 0 || symbols.contains(*symbol))
.map(|(symbol, position)| (symbol.clone(), position.clone())).collect(),
cash_receivables: self.cash_receivables.clone(), pending_cash_flows: self.pending_cash_flows.clone(),
day_sold_symbols: self.day_sold_symbols.clone(), corporate_predecessors: self.corporate_predecessors.clone(),
stock_pool_states: self.stock_pool_states.clone(),
},
position_order: Some(self.positions.keys().cloned().collect()),
}
}
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");
@@ -1647,6 +1691,35 @@ mod tests {
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
PriceField,
};
#[test]
fn scoped_checkpoint_restores_order_flat_rows_and_progress_without_copying_untouched_lots() {
let date = NaiveDate::from_ymd_opt(2026,9,15).unwrap();
let mut portfolio = PortfolioState::new(10000.);
portfolio.position_mut("000001.SZ").buy(date,100,10.);
portfolio.position_mut("000002.SZ").buy(date,200,10.);
portfolio.position_mut("000003.SZ").buy(date,100,10.);
portfolio.position_mut("000003.SZ").sell(100,11.).unwrap();
let flat_realized = portfolio.position("000003.SZ").unwrap().realized_pnl;
let untouched_lots = portfolio.position("000002.SZ").unwrap().lots.as_ptr();
let before = portfolio.financial_replay_identity();
let order = portfolio.positions.keys().cloned().collect::<Vec<_>>();
let checkpoint = portfolio.checkpoint(Some(&BTreeSet::from(["000001.SZ".into(), "000004.SZ".into()])));
assert!(!checkpoint.saved.positions.contains_key("000002.SZ"));
portfolio.position_mut("000001.SZ").sell(100,11.).unwrap();
portfolio.prune_flat_positions();
portfolio.position_mut("000004.SZ").buy(date,100,12.);
portfolio.apply_cash_delta(100.).unwrap();
portfolio.stock_pool_states.insert("changed".into(), Default::default());
checkpoint.restore(&mut portfolio);
assert_eq!(portfolio.financial_replay_identity(), before);
assert_eq!(portfolio.positions.keys().cloned().collect::<Vec<_>>(), order);
assert_eq!(portfolio.position("000002.SZ").unwrap().lots.as_ptr(), untouched_lots);
assert_eq!(portfolio.position("000003.SZ").unwrap().realized_pnl, flat_realized);
assert!(portfolio.stock_pool_states.is_empty());
assert!(portfolio.position("000004.SZ").is_none());
}
#[test]
fn cash_ledger_accumulates_micro_yuan_exactly() {
let mut portfolio = PortfolioState::new(1_000_000.0);
+29
View File
@@ -0,0 +1,29 @@
# 模拟器异常恢复与活动委托保留
2026-09-15。开发候选,未发布生产;不是实际GT/QMT撤改单功能。
## 已复现的问题
活动委托恢复先用 `mem::take` 取出整批订单。某只证券历史滑点证据缺失而返回错误后,当前订单及后续未处理订单被一起丢掉。原有两笔GTC模拟委托,一笔已累计成交100股,错误后队列直接为空。若前一笔已经在本次调用内部撮合,资金/持仓/手续费和行情消费也可能改变,但整个调用没有返回成功报告,重试将不一致。
## 事务边界
- 只保护一次模拟器调用中尚未成功返回的内部结果:资金、实际批次持仓、订单及累计成交、股票池/ETF顺延目标、执行游标/成交量消费、手续费状态和内部编号,以及临时运行上下文。
- 本次调用明确失败或回调解栈时恢复检查点,错误继续向调用方返回;不吞错、不自动重跑。调用方仍应修复输入后重试原请求或发出明确新请求,不能把失败请求当成已接受的新目标。
- 已成功返回的旧成交和报告、进入本次调用前已确认的手工事实不回滚。真实券商订单和回报不在该内存事务内,不能撤销或伪造实际GT/QMT结果。
- 正常业务拒绝仍是有效结果:报告成功返回时,其他成功成交与拒绝记录一同保留,不因为有Rejected状态就整批回退。
- 普通执行及ETF顺延消费共用边界,嵌套调用只保留一次检查点;临时日期、委托有效期、风险限制等在回调异常后不会泄漏到下一次调用。硬件断电、OOM以及用户自定义钩子的外部副作用不属于此证明。
## 开销控制
初版完整复制全部持仓。现普通单证券指令只保存可能变动的持仓与可被清理的零股记录,保留原位置顺序;完整组合、新类型指令和股票池阶段使用保守的完整检查点。未触及的持仓批次不复制,失败时才重新组装;无工作、无交易的调用不建检查点。
隔离未优化编译配置下,30只证券、每只20个初始批次、500次调用:初版保护样例约35.7—40.2ms,缩小持仓范围后约24.8—26.8ms;无保护对照11.5—22.1ms,首轮/并发噪声存在。保护有成本,不能称无性能回退,更不能据此宣称生产整段回测提速。四次对照的委托、成交和经济账本完全相同,正式Source/Runner性能准入仍保留。
## 验证
七项新增专项覆盖:日线/分钟、买/卖、第一或第二笔失败,已有部分成交;补齐数据后的原编号恢复与正常一次执行逐字段一致;此前成功调用不受后续失败影响;ETF两个顺延目标和进度完整保留;异常解栈;公开回调的临时上下文;局部检查点的位置顺序、零股和未复制批次;成功路径微基准的结果等价。
本机Core926、Trading625、Runner463/API129全量通过,原9/63/16项ignore不计。测试初次String/CompactString赋值错误及筛选名匹配0项已纠正,0项不当作通过;新增批次对照也改用实际更晚的执行时钟,不把未来报价当可立即成交。
未修改UI、在线账户、交易路由或任务;未做新的私有PG或真实券商验收。Source d5冻结保持,跨公司行为的实际券商委托调整、正式换股数据/范围、在线事实重建与真正Source/Runner联合验收仍未完成。Linux使用本轮新只读快照,不复用旧收据;没有release/tag或生产重启。