fix: size TWAP slices from frozen clocks instead of future quotes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user