fix(backtest): advance resting limit orders on subsequent quote events
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 "e in &minute_group {
|
||||
for quote in &minute_group {
|
||||
if !self.subscriptions.is_empty() && !self.subscriptions.contains("e.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)
|
||||
|
||||
Reference in New Issue
Block a user