|
|
|
@@ -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)
|
|
|
|
|