fix: size TWAP slices from frozen clocks instead of future quotes

This commit is contained in:
boris
2026-09-11 17:04:25 +08:00
parent 1e8d38f2ee
commit 2445dc925a
3 changed files with 107 additions and 7 deletions
+45 -7
View File
@@ -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::<NaiveDateTime, u32>::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::<Vec<_>>();
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<_>>(), 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)
@@ -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<NaiveDateTime>,
end: Option<NaiveDateTime>,
quantity: u32,
) -> Result<Self, BacktestError> {
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);
}
}
+1
View File
@@ -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;