From 32a34fadd6e6d37e9efb7824c601b93c2472c2e8 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 12 Sep 2026 07:00:47 +0800 Subject: [PATCH] fix(backtest): preserve order origin clocks and emit only state transitions --- crates/fidc-core/src/broker.rs | 183 +++++++++++++++++++++++++++++++-- crates/fidc-core/src/engine.rs | 34 ++++-- 2 files changed, 201 insertions(+), 16 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 82ff94b..83743c1 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -204,6 +204,7 @@ struct OpenOrder { order_id: u64, decision_date: Option, order_created_date: Option, + submission_time: Option, symbol: String, side: OrderSide, requested_quantity: u32, @@ -216,6 +217,12 @@ struct OpenOrder { reason: String, } +#[derive(Debug, Clone, Copy)] +struct RestingOrderOrigin { + created_date: Option, + submission_time: Option, +} + #[derive(Debug, Default)] struct BrokerExecutionSession { date: Option, @@ -439,6 +446,7 @@ pub struct BrokerSimulator { runtime_auto_buy_denials: RefCell>, runtime_auto_sell_denials: RefCell>, runtime_order_created_date: Cell>, + runtime_resting_order_origin: Cell>, runtime_decision_total_equity: Cell>, runtime_target_position_limit: Cell>, runtime_time_in_force: Cell>, @@ -475,6 +483,7 @@ impl BrokerSimulator { 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 BrokerSimulator { 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 BrokerSimulator { .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 BrokerSimulator { } 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 BrokerSimulator { &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 BrokerSimulator { 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 BrokerSimulator { 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 BrokerSimulator { 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 { + self.open_orders.borrow().iter().filter(|order| order.time_in_force == OrderTimeInForce::Day) + .map(|order| self.resting_order_session_close(date, order)).min() + } } impl BrokerSimulator @@ -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) @@ -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"); diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 843d0a7..a4a31ce 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -2900,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() @@ -5896,8 +5904,10 @@ mod tests { }], ..StrategyDecision::default() }) } } - for partial in [false, true] { - let date = d(2026, 6, 1); + 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, @@ -5905,8 +5915,11 @@ mod tests { }; 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 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) @@ -5920,17 +5933,24 @@ mod tests { 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()]) + 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 { date.and_hms_opt(10, 0, 0) }); + 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::(), 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"); } }