fix(backtest): advance resting limit orders on subsequent quote events

This commit is contained in:
boris
2026-09-12 05:05:39 +08:00
parent 9804851133
commit 0c81ecb317
2 changed files with 95 additions and 8 deletions
+1 -1
View File
@@ -7961,7 +7961,7 @@ where
quote.volume_delta > 0 && quote.bid1_volume == 0 && quote.ask1_volume == 0
}
fn matching_type_uses_intraday_quotes(&self) -> bool {
pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool {
matches!(
self.matching_type,
MatchingType::MinuteLast
+94 -7
View File
@@ -2849,9 +2849,16 @@ where
"bar:post",
)?;
if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions) {
if self.execution_quote_loader.is_some() && !self.subscriptions.is_empty() {
let mut minute_symbols = self.subscriptions.clone();
if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions)
|| (self.broker.has_open_orders() && self.broker.matching_type_uses_intraday_quotes())
{
let unfiltered_minute_stream = self.subscriptions.is_empty();
let mut full_minute_symbols = self.subscriptions.clone();
if self.broker.matching_type_uses_intraday_quotes() {
full_minute_symbols.extend(self.broker.open_order_views().into_iter().map(|order| order.symbol));
}
if self.execution_quote_loader.is_some() && !full_minute_symbols.is_empty() {
let mut minute_symbols = full_minute_symbols.clone();
self.load_missing_execution_quotes(
execution_date,
None,
@@ -2862,11 +2869,11 @@ where
// Keep the iterator attached to an O(1) DataSet clone. This
// preserves the immutable quote snapshot for the day while
// allowing lazy quote loads and broker state updates on self.
let quote_data = self.data.clone();
let mut quote_data = self.data.clone();
let mut minute_quotes = quote_data
.execution_quotes_iter_on_date_for_symbols(
execution_date,
(!self.subscriptions.is_empty()).then_some(&self.subscriptions),
(!unfiltered_minute_stream).then_some(&full_minute_symbols),
)
.peekable();
let requires_minute_callbacks = self.strategy.requires_minute_callbacks();
@@ -2913,7 +2920,8 @@ where
minute_group.push(
minute_quotes
.next()
.expect("peeked minute quote must be available"),
.expect("peeked minute quote must be available")
.clone(),
);
}
let has_specific_schedule = next_schedule_timestamp == Some(minute_timestamp);
@@ -2985,7 +2993,10 @@ where
crate::strategy::StrategyDecision::default()
};
if requires_minute_callbacks {
for &quote in &minute_group {
for quote in &minute_group {
if !self.subscriptions.is_empty() && !self.subscriptions.contains(&quote.symbol) {
continue;
}
minute_decision.merge_from(self.strategy.on_minute(
&StrategyContext {
execution_date,
@@ -3098,6 +3109,28 @@ where
ProcessEventKind::PostMinute,
format!("minute:{minute_timestamp}:post"),
)?;
// A scheduled strategy need not subscribe to every
// minute to keep a DAY/GTC limit order alive. Fetch the
// resting symbols once, then resume the actual quote
// clock strictly after the event already processed.
let mut newly_pending = self.broker.open_order_views().into_iter()
.map(|order| order.symbol)
.filter(|symbol| !full_minute_symbols.contains(symbol))
.collect::<BTreeSet<_>>();
if !newly_pending.is_empty() && self.broker.matching_type_uses_intraday_quotes() {
full_minute_symbols.extend(newly_pending.iter().cloned());
if self.execution_quote_loader.is_some() {
self.load_missing_execution_quotes(execution_date, None, None, &mut newly_pending)?;
}
drop(minute_quotes);
quote_data = self.data.clone();
minute_quotes = quote_data.execution_quotes_iter_on_date_for_symbols(
execution_date, (!unfiltered_minute_stream).then_some(&full_minute_symbols),
).peekable();
while minute_quotes.peek().is_some_and(|quote| quote.timestamp <= minute_timestamp) {
minute_quotes.next();
}
}
}
drop(minute_group);
drop(minute_quotes);
@@ -5847,6 +5880,60 @@ mod tests {
);
}
#[test]
fn scheduled_day_limit_order_loads_later_quotes_without_strategy_minute_subscription() {
struct RestingLimit { quantity: i32 }
impl Strategy for RestingLimit {
fn name(&self) -> &str { "resting-limit" }
fn requires_minute_callbacks(&self) -> bool { false }
fn schedule_rules(&self) -> Vec<ScheduleRule> {
vec![ScheduleRule::daily("open", ScheduleStage::OnDay)
.with_time_rule(ScheduleTimeRule::physical_time(9, 30))]
}
fn on_scheduled(&mut self, _: &StrategyContext<'_>, _: &ScheduleRule) -> Result<StrategyDecision, crate::BacktestError> {
Ok(StrategyDecision { order_intents: vec![OrderIntent::LimitTargetShares {
symbol: SYMBOL.into(), target_quantity: self.quantity, limit_price: 10.0, reason: "resting-entry".into(),
}], ..StrategyDecision::default() })
}
}
for partial in [false, true] {
let date = d(2026, 6, 1);
let quote = |hour, minute, price| IntradayExecutionQuote {
date, symbol: SYMBOL.into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
last_price: price, bid1: price, ask1: price, bid1_volume: 10_000, ask1_volume: 10_000,
volume_delta: 10_000, amount_delta: price * 10_000.0, trading_phase: None,
};
let first = quote(9, 30, if partial { 9.8 } else { 10.2 });
let earlier = quote(9, 29, 9.0);
let later = quote(10, 0, 9.8);
let last = quote(10, 1, 9.8);
let mut data = dataset_from_market_and_candidates(vec![market(date, 10.2, 9.8)], vec![candidate(date)]);
data.add_execution_quotes(vec![first.clone()]);
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose)
.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(partial).with_volume_percent(0.01).with_liquidity_limit(false).with_inactive_limit(false);
let requests = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&requests);
let mut engine = BacktestEngine::new(data, RestingLimit { quantity: if partial { 300 } else { 100 } }, broker, BacktestConfig {
initial_cash: 100_000.0, benchmark_code: "000852.SH".into(), start_date: Some(date), end_date: Some(date),
decision_lag_trading_days: 0, execution_price_field: PriceField::Close,
}).with_execution_quote_loader(move |request| {
captured.lock().unwrap().push((request.start_time, request.end_time));
Ok(vec![earlier.clone(), first.clone(), later.clone(), last.clone()])
});
let result = engine.run().unwrap();
assert_eq!(result.fills.len(), if partial { 3 } else { 1 }, "resting DAY order must match later actual quotes: {:?}", result.order_events);
assert_eq!(result.fills[0].execution_timestamp, if partial { date.and_hms_opt(9, 30, 0) } else { date.and_hms_opt(10, 0, 0) });
assert_eq!(result.fills[0].price, 9.8);
assert_eq!(result.fills[0].quantity, 100);
assert_eq!(result.fills.iter().map(|fill| fill.quantity).sum::<u32>(), if partial { 300 } else { 100 });
assert!(result.fills.iter().all(|fill| fill.execution_timestamp >= date.and_hms_opt(9, 30, 0)));
assert_eq!(requests.lock().unwrap().as_slice(), &[(None, None)]);
assert!(!result.order_events.iter().any(|order| order.status == crate::OrderStatus::Expired));
}
}
#[test]
fn scheduled_event_detail_records_actual_time_only_for_timed_rules() {
let timed = ScheduleRule::daily("timed", ScheduleStage::OnDay)