Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32a34fadd6 | |||
| 4edc70c4c6 |
+175
-10
@@ -204,6 +204,7 @@ struct OpenOrder {
|
||||
order_id: u64,
|
||||
decision_date: Option<NaiveDate>,
|
||||
order_created_date: Option<NaiveDate>,
|
||||
submission_time: Option<NaiveTime>,
|
||||
symbol: String,
|
||||
side: OrderSide,
|
||||
requested_quantity: u32,
|
||||
@@ -216,6 +217,12 @@ struct OpenOrder {
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct RestingOrderOrigin {
|
||||
created_date: Option<NaiveDate>,
|
||||
submission_time: Option<NaiveTime>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct BrokerExecutionSession {
|
||||
date: Option<NaiveDate>,
|
||||
@@ -439,6 +446,7 @@ pub struct BrokerSimulator<C, R> {
|
||||
runtime_auto_buy_denials: RefCell<BTreeMap<String, String>>,
|
||||
runtime_auto_sell_denials: RefCell<BTreeMap<String, String>>,
|
||||
runtime_order_created_date: Cell<Option<NaiveDate>>,
|
||||
runtime_resting_order_origin: Cell<Option<RestingOrderOrigin>>,
|
||||
runtime_decision_total_equity: Cell<Option<f64>>,
|
||||
runtime_target_position_limit: Cell<Option<usize>>,
|
||||
runtime_time_in_force: Cell<Option<OrderTimeInForce>>,
|
||||
@@ -475,6 +483,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_auto_sell_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_order_created_date: Cell::new(None),
|
||||
runtime_resting_order_origin: Cell::new(None),
|
||||
runtime_decision_total_equity: Cell::new(None),
|
||||
runtime_target_position_limit: Cell::new(None),
|
||||
runtime_time_in_force: Cell::new(None),
|
||||
@@ -515,6 +524,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_auto_sell_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_order_created_date: Cell::new(None),
|
||||
runtime_resting_order_origin: Cell::new(None),
|
||||
runtime_decision_total_equity: Cell::new(None),
|
||||
runtime_target_position_limit: Cell::new(None),
|
||||
runtime_time_in_force: Cell::new(None),
|
||||
@@ -636,6 +646,13 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
.or(self.intraday_execution_start_time)
|
||||
}
|
||||
|
||||
fn order_origin(&self) -> RestingOrderOrigin {
|
||||
self.runtime_resting_order_origin.get().unwrap_or(RestingOrderOrigin {
|
||||
created_date: self.runtime_order_created_date.get(),
|
||||
submission_time: self.submission_time(),
|
||||
})
|
||||
}
|
||||
|
||||
fn execution_phase_for_submission(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -661,10 +678,11 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
}
|
||||
|
||||
fn execution_phase(&self, date: NaiveDate) -> EquityExecutionPhase {
|
||||
let origin = self.order_origin();
|
||||
self.execution_phase_for_submission(
|
||||
date,
|
||||
self.runtime_order_created_date.get(),
|
||||
self.submission_time(),
|
||||
origin.created_date,
|
||||
origin.submission_time,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -684,10 +702,11 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
) -> Option<(NaiveDateTime, NaiveDateTime)> {
|
||||
let origin = self.order_origin();
|
||||
self.post_close_execution_quote_window_for_submission(
|
||||
date,
|
||||
self.runtime_order_created_date.get(),
|
||||
self.submission_time(),
|
||||
origin.created_date,
|
||||
origin.submission_time,
|
||||
)
|
||||
.map(|(start, end)| (date.and_time(start), date.and_time(end)))
|
||||
}
|
||||
@@ -730,7 +749,8 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
if self.is_post_close_fixed_price(date) {
|
||||
return match self.runtime_time_in_force.get() {
|
||||
Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill,
|
||||
_ => RemainderPolicy::Cancel,
|
||||
Some(OrderTimeInForce::Ioc | OrderTimeInForce::Gtc) => RemainderPolicy::Cancel,
|
||||
_ => RemainderPolicy::KeepUntilClose,
|
||||
};
|
||||
}
|
||||
match self.runtime_time_in_force.get() {
|
||||
@@ -781,7 +801,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
filled_quantity: order.filled_quantity,
|
||||
remaining_quantity: order.remaining_quantity,
|
||||
unfilled_quantity: order.remaining_quantity,
|
||||
status: OrderStatus::Pending,
|
||||
status: if order.filled_quantity > 0 { OrderStatus::PartiallyFilled } else { OrderStatus::Pending },
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: order.limit_price,
|
||||
@@ -793,6 +813,17 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
pub fn has_open_orders(&self) -> bool {
|
||||
!self.open_orders.borrow().is_empty()
|
||||
}
|
||||
|
||||
fn resting_order_session_close(&self, date: NaiveDate, order: &OpenOrder) -> NaiveTime {
|
||||
let post_close = self.execution_phase_for_submission(date, order.order_created_date, order.submission_time)
|
||||
== EquityExecutionPhase::PostCloseFixedPrice;
|
||||
NaiveTime::from_hms_opt(15, if post_close { 30 } else { 0 }, 0).expect("session end")
|
||||
}
|
||||
|
||||
pub(crate) fn next_day_order_expiry(&self, date: NaiveDate) -> Option<NaiveTime> {
|
||||
self.open_orders.borrow().iter().filter(|order| order.time_in_force == OrderTimeInForce::Day)
|
||||
.map(|order| self.resting_order_session_close(date, order)).min()
|
||||
}
|
||||
}
|
||||
|
||||
impl<C, R> BrokerSimulator<C, R>
|
||||
@@ -2389,7 +2420,7 @@ where
|
||||
}
|
||||
|
||||
fn current_order_created_date(&self, date: NaiveDate) -> NaiveDate {
|
||||
self.runtime_order_created_date.get().unwrap_or(date)
|
||||
self.order_origin().created_date.unwrap_or(date)
|
||||
}
|
||||
|
||||
fn annotate_report_range(
|
||||
@@ -2541,6 +2572,18 @@ where
|
||||
std::mem::take(&mut *open_orders)
|
||||
};
|
||||
for order in pending_orders {
|
||||
let close = self.resting_order_session_close(date, &order);
|
||||
let clock = self.submission_time();
|
||||
let past_day = order.time_in_force == OrderTimeInForce::Day
|
||||
&& order.order_created_date.is_some_and(|created| created < date);
|
||||
if past_day || clock.is_some_and(|time| time > close) {
|
||||
if order.time_in_force == OrderTimeInForce::Day {
|
||||
Self::emit_resting_day_expiry(report, date, &order, order.filled_quantity);
|
||||
} else {
|
||||
self.open_orders.borrow_mut().push(order);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let order_event_start = report.order_events.len();
|
||||
let fill_event_start = report.fill_events.len();
|
||||
if let Some(commission_remaining) = order.commission_remaining {
|
||||
@@ -2565,6 +2608,11 @@ where
|
||||
let previous_time_in_force = self
|
||||
.runtime_time_in_force
|
||||
.replace(Some(order.time_in_force));
|
||||
let previous_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin {
|
||||
created_date: order.order_created_date,
|
||||
submission_time: order.submission_time,
|
||||
}));
|
||||
let previous_decision_date = self.runtime_decision_date.replace(order.decision_date);
|
||||
let execution_result = self.process_limit_shares_internal(
|
||||
date,
|
||||
portfolio,
|
||||
@@ -2582,6 +2630,8 @@ where
|
||||
report,
|
||||
);
|
||||
self.runtime_time_in_force.set(previous_time_in_force);
|
||||
self.runtime_resting_order_origin.set(previous_origin);
|
||||
self.runtime_decision_date.set(previous_decision_date);
|
||||
execution_result?;
|
||||
let attempt_filled = report.fill_events[fill_event_start..]
|
||||
.iter()
|
||||
@@ -2600,6 +2650,7 @@ where
|
||||
remains_open = remaining_quantity > 0;
|
||||
reopened.decision_date = order.decision_date;
|
||||
reopened.order_created_date = order.order_created_date;
|
||||
reopened.submission_time = order.submission_time;
|
||||
reopened.requested_quantity = order.requested_quantity;
|
||||
reopened.filled_quantity = cumulative_filled;
|
||||
reopened.remaining_quantity = remaining_quantity;
|
||||
@@ -2611,6 +2662,13 @@ where
|
||||
open_orders.retain(|open| open.order_id != order.order_id);
|
||||
}
|
||||
}
|
||||
if remains_open && order.time_in_force == OrderTimeInForce::Day
|
||||
&& clock.is_some_and(|time| time >= close)
|
||||
{
|
||||
self.clear_open_order(order.order_id);
|
||||
Self::emit_resting_day_expiry(report, date, &order, cumulative_filled);
|
||||
remains_open = false;
|
||||
}
|
||||
if report.order_events.len() == order_event_start && !remains_open {
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
@@ -2666,6 +2724,18 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_resting_day_expiry(report: &mut BrokerExecutionReport, date: NaiveDate, order: &OpenOrder, filled: u32) {
|
||||
let detail = format!("DAY order expired at session end: {} remaining_quantity={}", order.symbol, order.requested_quantity.saturating_sub(filled));
|
||||
report.order_events.push(OrderEvent {
|
||||
date, decision_date: order.decision_date, order_created_date: order.order_created_date,
|
||||
execution_date: Some(date), order_id: Some(order.order_id), symbol: order.symbol.clone(),
|
||||
side: order.side, requested_quantity: order.requested_quantity, filled_quantity: filled,
|
||||
status: OrderStatus::Expired, reason: detail.clone(),
|
||||
});
|
||||
Self::emit_order_process_event(report, date, ProcessEventKind::OrderUnsolicitedUpdate,
|
||||
order.order_id, &order.symbol, order.side, format!("status=Expired reason={detail}"));
|
||||
}
|
||||
|
||||
fn cancel_open_order(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -4391,6 +4461,7 @@ where
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let limit_price = limit_price.or_else(|| self.is_post_close_fixed_price(date).then_some(snapshot.close));
|
||||
let Some(candidate) = data.candidate(date, symbol) else {
|
||||
Self::reject_unavailable_order(
|
||||
report,
|
||||
@@ -4582,6 +4653,7 @@ where
|
||||
if Self::keeps_remainder_open(remainder_policy) {
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: self.order_origin().submission_time,
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
symbol: symbol.to_string(),
|
||||
@@ -4595,6 +4667,10 @@ where
|
||||
execution_cursor: execution_cursors.get(symbol).copied(),
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
// Waiting without a fill is not a new order-state transition.
|
||||
if !emit_creation_events {
|
||||
return Ok(());
|
||||
}
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
@@ -4667,6 +4743,7 @@ where
|
||||
.unwrap_or("no sellable quantity");
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: self.order_origin().submission_time,
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
symbol: symbol.to_string(),
|
||||
@@ -4680,6 +4757,10 @@ where
|
||||
execution_cursor: execution_cursors.get(symbol).copied(),
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
// Waiting without a fill is not a new order-state transition.
|
||||
if !emit_creation_events {
|
||||
return Ok(());
|
||||
}
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
@@ -4834,6 +4915,7 @@ where
|
||||
{
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: self.order_origin().submission_time,
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
symbol: symbol.to_string(),
|
||||
@@ -4847,6 +4929,10 @@ where
|
||||
execution_cursor: execution_cursors.get(symbol).copied(),
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
// Waiting without a fill is not a new order-state transition.
|
||||
if !emit_creation_events {
|
||||
return Ok(());
|
||||
}
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
@@ -5000,6 +5086,7 @@ where
|
||||
if keep_open {
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: self.order_origin().submission_time,
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
symbol: symbol.to_string(),
|
||||
@@ -6197,6 +6284,7 @@ where
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let limit_price = limit_price.or_else(|| self.is_post_close_fixed_price(date).then_some(snapshot.close));
|
||||
let Some(candidate) = data.candidate(date, symbol) else {
|
||||
Self::reject_unavailable_order(
|
||||
report,
|
||||
@@ -6387,6 +6475,7 @@ where
|
||||
if Self::keeps_remainder_open(remainder_policy) {
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: self.order_origin().submission_time,
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
symbol: symbol.to_string(),
|
||||
@@ -6400,6 +6489,10 @@ where
|
||||
execution_cursor: execution_cursors.get(symbol).copied(),
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
// Waiting without a fill is not a new order-state transition.
|
||||
if !emit_creation_events {
|
||||
return Ok(());
|
||||
}
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
@@ -6621,6 +6714,7 @@ where
|
||||
{
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: self.order_origin().submission_time,
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
symbol: symbol.to_string(),
|
||||
@@ -6634,6 +6728,10 @@ where
|
||||
execution_cursor: execution_cursors.get(symbol).copied(),
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
// Waiting without a fill is not a new order-state transition.
|
||||
if !emit_creation_events {
|
||||
return Ok(());
|
||||
}
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
decision_date: None,
|
||||
@@ -6789,6 +6887,7 @@ where
|
||||
if keep_open {
|
||||
self.upsert_open_order(OpenOrder {
|
||||
order_id,
|
||||
submission_time: self.order_origin().submission_time,
|
||||
decision_date: Some(self.current_decision_date(date)),
|
||||
order_created_date: Some(self.current_order_created_date(date)),
|
||||
symbol: symbol.to_string(),
|
||||
@@ -7442,14 +7541,24 @@ where
|
||||
|
||||
let runtime_start_time = self.runtime_intraday_start_time.get();
|
||||
let runtime_end_time = self.runtime_intraday_end_time.get();
|
||||
let start_cursor = post_close_window.map(|window| window.0).or_else(|| {
|
||||
let start_cursor = post_close_window.map(|window| {
|
||||
runtime_start_time.map_or(window.0, |start| window.0.max(date.and_time(start)))
|
||||
}).or_else(|| {
|
||||
algo_request
|
||||
.and_then(|request| request.start_time)
|
||||
.or(runtime_start_time)
|
||||
.or(self.intraday_execution_start_time)
|
||||
.map(|start_time| date.and_time(start_time))
|
||||
});
|
||||
let end_cursor = post_close_window.map(|window| window.1).or_else(|| {
|
||||
let start_cursor = if let Some(origin) = self.runtime_resting_order_origin.get()
|
||||
&& origin.created_date == Some(date)
|
||||
&& let Some(submitted) = origin.submission_time
|
||||
{
|
||||
Some(start_cursor.map_or(date.and_time(submitted), |cursor| cursor.max(date.and_time(submitted))))
|
||||
} else { start_cursor };
|
||||
let end_cursor = post_close_window.map(|window| {
|
||||
runtime_end_time.map_or(window.1, |end| window.1.min(date.and_time(end)))
|
||||
}).or_else(|| {
|
||||
algo_request
|
||||
.and_then(|request| request.end_time)
|
||||
.or(runtime_end_time)
|
||||
@@ -7961,7 +8070,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
|
||||
@@ -8104,6 +8213,7 @@ mod tests {
|
||||
order_id,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
submission_time: None,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
side: OrderSide::Buy,
|
||||
requested_quantity: 200,
|
||||
@@ -8634,6 +8744,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gtc_resting_order_keeps_its_session_and_original_dates_across_days() {
|
||||
let first = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).unwrap();
|
||||
let second = first.succ_opt().unwrap();
|
||||
let mut snapshot = dated_limit_test_snapshot(first);
|
||||
snapshot.open = 10.2;
|
||||
snapshot.close = 9.8;
|
||||
snapshot.upper_limit = 20.;
|
||||
snapshot.lower_limit = 1.;
|
||||
let mut next = snapshot.clone(); next.date = second;
|
||||
let mut opening = limit_test_quote(10.2,10.2,10.2);
|
||||
opening.date=first; opening.timestamp=first.and_hms_opt(9,30,0).unwrap();
|
||||
let mut closing = opening.clone(); closing.timestamp=first.and_hms_opt(15,5,0).unwrap();
|
||||
closing.last_price=9.8; closing.bid1=9.8; closing.ask1=9.8;
|
||||
let mut next_open = closing.clone(); next_open.date=second; next_open.timestamp=second.and_hms_opt(9,30,0).unwrap();
|
||||
let data=DataSet::from_components_with_actions_and_quotes(
|
||||
vec![limit_test_instrument()], vec![snapshot,next], Vec::new(),
|
||||
vec![dated_limit_test_candidate(first,false,false,true,true),dated_limit_test_candidate(second,false,false,true,true)],
|
||||
vec![dated_limit_test_benchmark(first),dated_limit_test_benchmark(second)],Vec::new(),vec![opening,closing,next_open],
|
||||
).unwrap();
|
||||
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(false).with_liquidity_limit(false);
|
||||
let decision=StrategyDecision{order_intents:vec![OrderIntent::WithTimeInForce{
|
||||
time_in_force:OrderTimeInForce::Gtc,intent:Box::new(OrderIntent::LimitTargetShares{
|
||||
symbol:"000001.SZ".into(),target_quantity:100,limit_price:10.,reason:"original-entry".into(),
|
||||
})}],..StrategyDecision::default()};
|
||||
let mut portfolio=PortfolioState::new(100000.);
|
||||
let report=broker.execute_between(first,&mut portfolio,&data,&decision,
|
||||
NaiveTime::from_hms_opt(9,30,0),NaiveTime::from_hms_opt(9,30,0)).unwrap();
|
||||
assert!(report.fill_events.is_empty());
|
||||
let report=broker.execute_between(first,&mut portfolio,&data,&StrategyDecision::default(),
|
||||
NaiveTime::from_hms_opt(15,5,0),NaiveTime::from_hms_opt(15,5,0)).unwrap();
|
||||
assert!(report.fill_events.is_empty()); assert!(report.order_events.is_empty());
|
||||
assert!(broker.has_open_orders());
|
||||
portfolio.begin_trading_day();
|
||||
let report=broker.execute_between(second,&mut portfolio,&data,&StrategyDecision::default(),
|
||||
NaiveTime::from_hms_opt(9,30,0),NaiveTime::from_hms_opt(9,30,0)).unwrap();
|
||||
assert_eq!(report.fill_events.len(),1,"{report:?}");
|
||||
assert_eq!(report.fill_events[0].order_created_date,Some(first));
|
||||
assert_eq!(report.fill_events[0].decision_date,Some(first));
|
||||
assert_eq!(report.fill_events[0].execution_date,Some(second));
|
||||
assert!(!broker.has_open_orders());
|
||||
assert!(broker.runtime_resting_order_origin.get().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_close_order_uses_close_without_slippage_and_waits_until_matching_window() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date");
|
||||
@@ -8677,6 +8834,14 @@ mod tests {
|
||||
)
|
||||
.expect("post-close order executes");
|
||||
|
||||
assert!(report.fill_events.is_empty(), "15:00 must not receive a future 15:05 fill");
|
||||
assert!(broker.has_open_orders());
|
||||
let report = broker.execute_between(date, &mut portfolio, &data, &StrategyDecision::default(),
|
||||
NaiveTime::from_hms_opt(15,4,0),NaiveTime::from_hms_opt(15,4,0)).unwrap();
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert!(report.order_events.is_empty());
|
||||
let report = broker.execute_between(date, &mut portfolio, &data, &StrategyDecision::default(),
|
||||
NaiveTime::from_hms_opt(15,5,0),NaiveTime::from_hms_opt(15,5,0)).unwrap();
|
||||
assert_eq!(report.fill_events.len(), 1, "{report:?}");
|
||||
let fill = &report.fill_events[0];
|
||||
assert_eq!(fill.price, 10.0, "fixed-price trading uses official close");
|
||||
|
||||
@@ -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();
|
||||
@@ -2893,18 +2900,26 @@ where
|
||||
.into_iter()
|
||||
.peekable();
|
||||
let mut minute_group = Vec::new();
|
||||
let mut last_minute_timestamp = None;
|
||||
// Merge the immutable quote stream with clock events. Equal
|
||||
// timestamps form one event; scheduled callbacks run before
|
||||
// `on_minute` below.
|
||||
loop {
|
||||
let next_quote_timestamp = minute_quotes.peek().map(|quote| quote.timestamp);
|
||||
let next_schedule_timestamp = minute_schedule_timestamps.peek().copied();
|
||||
let next_expiry_timestamp = self.broker.next_day_order_expiry(execution_date)
|
||||
.map(|time| execution_date.and_time(time))
|
||||
.filter(|time| last_minute_timestamp.is_none_or(|last| last < *time));
|
||||
let Some(minute_timestamp) =
|
||||
next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp)
|
||||
next_minute_event_timestamp(
|
||||
next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp),
|
||||
next_expiry_timestamp,
|
||||
)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let minute_time = minute_timestamp.time();
|
||||
last_minute_timestamp = Some(minute_timestamp);
|
||||
minute_group.clear();
|
||||
while minute_quotes
|
||||
.peek()
|
||||
@@ -2913,7 +2928,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 +3001,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 +3117,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 +5888,72 @@ 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 scenario in 0..4 {
|
||||
let partial = scenario == 1;
|
||||
let closing_only = scenario >= 2;
|
||||
let date = if closing_only { d(2026, 7, 6) } else { 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 unchanged = quote(9, 45, 10.2);
|
||||
let later = quote(10, 0, if closing_only { 10.2 } else { 9.8 });
|
||||
let last = if closing_only { quote(15, 0, if scenario == 2 { 9.8 } else { 10.2 }) } else { quote(10, 1, 9.8) };
|
||||
let mut post_close = quote(15, 5, 9.7);
|
||||
post_close.trading_phase = Some("post_close_fixed_price".into());
|
||||
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(), unchanged.clone(), later.clone(), last.clone(), post_close.clone()])
|
||||
});
|
||||
let result = engine.run().unwrap();
|
||||
if scenario == 3 {
|
||||
assert!(result.fills.is_empty(), "continuous DAY order must not migrate to post-close execution");
|
||||
assert_eq!(result.order_events.len(), 2, "only initial pending and expiry are state changes");
|
||||
assert_eq!(result.order_events.last().unwrap().status, crate::OrderStatus::Expired);
|
||||
continue;
|
||||
}
|
||||
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 if closing_only { date.and_hms_opt(15, 0, 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));
|
||||
assert_eq!(result.order_events.len(), if partial { 3 } else { 2 }, "unchanged pending attempts must not emit state transitions");
|
||||
}
|
||||
}
|
||||
|
||||
#[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