From 2445dc925a0cf1dfeed870711a5b3be831d1332e Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 11 Sep 2026 17:04:25 +0800 Subject: [PATCH] fix: size TWAP slices from frozen clocks instead of future quotes --- crates/fidc-core/src/broker.rs | 52 +++++++++++++++--- crates/fidc-core/src/execution_schedule.rs | 61 ++++++++++++++++++++++ crates/fidc-core/src/lib.rs | 1 + 3 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 crates/fidc-core/src/execution_schedule.rs diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 529e42b..bff3490 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -8,6 +8,7 @@ use crate::cost::CostModel; use crate::data::{DataSet, IntradayExecutionQuote, PriceField}; use crate::engine::BacktestError; use crate::execution_capacity::{CapacityError, ParticipationRate, VolumeObservation, VolumeObservationKind}; +use crate::execution_schedule::TwapSchedule; use crate::events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, @@ -7585,6 +7586,9 @@ where let quote_quantity_limited = self.quote_quantity_limited_for_window(matching_type, start_cursor, end_cursor); + let twap_schedule = (matching_type == MatchingType::Twap) + .then(|| TwapSchedule::new(start_cursor, end_cursor, requested_qty)) + .transpose()?; let lot = round_lot.max(1); let exact_time_order_quote = matching_type != MatchingType::MinuteLast && start_cursor.is_some() @@ -7634,7 +7638,7 @@ where let mut pending_volume_consumption = BTreeMap::::new(); let mut liquidity_consumption = Vec::new(); - for (quote_index, quote) in eligible_quotes.iter().enumerate() { + for quote in &eligible_quotes { let execution_at = if use_decision_time_quote { start_cursor.expect("as-of orders have an execution clock").max(quote.timestamp) } else { @@ -7724,11 +7728,8 @@ where continue; } - let mut take_qty = if matching_type == MatchingType::Twap { - let remaining_quotes = (eligible_quotes.len() - quote_index) as u32; - let scheduled_qty = - ((remaining_qty as f64) / remaining_quotes.max(1) as f64).ceil() as u32; - remaining_qty.min(available_qty).min(scheduled_qty.max(1)) + let mut take_qty = if let Some(schedule) = &twap_schedule { + remaining_qty.min(available_qty).min(schedule.due_quantity(execution_at, filled_qty)) } else { remaining_qty.min(available_qty) }; @@ -8056,7 +8057,7 @@ mod tests { use super::{ BrokerExecutionReport, BrokerSimulator, EquityExecutionPhase, IntradayExecutionLedger, - MatchingType, OpenOrder, RebalanceCashMode, SlippageModel, + ExecutionFill, MatchingType, OpenOrder, RebalanceCashMode, SlippageModel, }; use crate::cost::ChinaAShareCostModel; use crate::data::{ @@ -11810,6 +11811,43 @@ mod tests { assert!((fill.price - 7.15428).abs() < 1e-6); } + #[test] + fn twap_earlier_fills_do_not_depend_on_later_quote_liquidity_or_count() { + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_limit(true).with_volume_percent(0.25).with_liquidity_limit(false); + let snapshot = limit_test_snapshot(); + let start = snapshot.date.and_hms_opt(10, 0, 0).unwrap(); + let end = snapshot.date.and_hms_opt(10, 10, 0).unwrap(); + let quote_at = |minute| { + let mut quote = limit_test_quote(10.0, 9.99, 10.01); + quote.timestamp = snapshot.date.and_hms_opt(10, minute, 0).unwrap(); + quote.volume_delta = 10_000; + quote.ask1_volume = 0; + quote.bid1_volume = 0; + quote + }; + let original = vec![quote_at(2), quote_at(5), quote_at(10)]; + let mut changed = original.clone(); + changed[2].volume_delta = 0; + let read = |quotes: &[IntradayExecutionQuote]| broker.select_execution_fill( + &snapshot, quotes, OrderSide::Buy, MatchingType::Twap, Some(start), Some(end), + 1_000, 100, 100, 100, false, None, None, None, + ).unwrap(); + let original_fill = read(&original); + let changed_fill = read(&changed); + let fewer_fill = read(&original[..2]); + let prefix = |fill: &ExecutionFill| fill.legs.iter() + .filter(|leg| leg.execution_timestamp.unwrap() < end) + .map(|leg| (leg.execution_timestamp, leg.quantity, leg.price.to_bits())) + .collect::>(); + assert_eq!(prefix(&original_fill), prefix(&changed_fill)); + assert_eq!(prefix(&original_fill), prefix(&fewer_fill)); + assert_eq!(prefix(&original_fill).iter().map(|row| row.1).collect::>(), vec![200, 300]); + assert_eq!(original_fill.quantity, 1_000); + assert_eq!(changed_fill.quantity, 500); + assert_eq!(fewer_fill.quantity, 500); + } + #[test] fn instantaneous_twap_without_limits_does_not_cap_quote_quantity() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) diff --git a/crates/fidc-core/src/execution_schedule.rs b/crates/fidc-core/src/execution_schedule.rs new file mode 100644 index 0000000..e29e91c --- /dev/null +++ b/crates/fidc-core/src/execution_schedule.rs @@ -0,0 +1,61 @@ +use chrono::NaiveDateTime; + +use crate::engine::BacktestError; + +/// Cumulative TWAP entitlement depends on the frozen clock, not future quotes. +pub(crate) struct TwapSchedule { + start: NaiveDateTime, + end: NaiveDateTime, + quantity: u32, +} + +impl TwapSchedule { + pub(crate) fn new( + start: Option, + end: Option, + quantity: u32, + ) -> Result { + let (Some(start), Some(end)) = (start, end) else { + return Err(BacktestError::Execution("TWAP requires an explicit start and end clock".into())); + }; + if end < start || start.date() != end.date() { + return Err(BacktestError::Execution("TWAP requires an ordered same-session clock window".into())); + } + Ok(Self { start, end, quantity }) + } + + pub(crate) fn due_quantity(&self, at: NaiveDateTime, filled: u32) -> u32 { + if at < self.start { + return 0; + } + let entitlement = if at >= self.end { + self.quantity + } else { + let elapsed = (at - self.start).num_microseconds().expect("same-day interval") as u128; + let duration = (self.end - self.start).num_microseconds().expect("same-day interval") as u128; + (u128::from(self.quantity) * elapsed / duration) as u32 + }; + entitlement.saturating_sub(filled) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, NaiveDate}; + + #[test] + fn clock_entitlements_are_exact_and_allow_backlog_without_future_quote_counts() { + let start = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(10, 0, 0).unwrap(); + let end = start + Duration::minutes(10); + let schedule = TwapSchedule::new(Some(start), Some(end), 1_000).unwrap(); + assert_eq!(schedule.due_quantity(start, 0), 0); + assert_eq!(schedule.due_quantity(start + Duration::minutes(2), 0), 200); + assert_eq!(schedule.due_quantity(start + Duration::minutes(5), 100), 400); + assert_eq!(schedule.due_quantity(end, 100), 900); + assert_eq!(schedule.due_quantity(end, 1_000), 0); + assert!(TwapSchedule::new(Some(start), None, 1_000).is_err()); + assert!(TwapSchedule::new(Some(end), Some(start), 1_000).is_err()); + assert_eq!(TwapSchedule::new(Some(start), Some(start), 1_000).unwrap().due_quantity(start, 0), 1_000); + } +} diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index ae21e83..4cad045 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod pattern_context; pub mod session_events; pub mod factor_events; pub mod execution_capacity; +mod execution_schedule; mod factor_event_catalog; pub mod factor_cross_section; pub mod market_event_context;