fix: separate as-of quote time from execution clock and retain volume consumption
This commit is contained in:
@@ -7,7 +7,7 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
||||
use crate::cost::CostModel;
|
||||
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
|
||||
use crate::engine::BacktestError;
|
||||
use crate::execution_capacity::{CapacityError, ParticipationRate};
|
||||
use crate::execution_capacity::{CapacityError, ParticipationRate, VolumeObservation, VolumeObservationKind};
|
||||
use crate::events::{
|
||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||
ProcessEventKind,
|
||||
@@ -7696,9 +7696,11 @@ where
|
||||
let mut liquidity_consumption = Vec::new();
|
||||
|
||||
for (quote_index, quote) in eligible_quotes.iter().enumerate() {
|
||||
// Approximate platform-native market-order fills with the evolving L1 book after
|
||||
// the decision time instead of trade VWAP. This keeps quantities/prices
|
||||
// closer to the observed 10:18 execution logs.
|
||||
let execution_at = if use_decision_time_quote {
|
||||
start_cursor.expect("as-of orders have an execution clock").max(quote.timestamp)
|
||||
} else {
|
||||
quote.timestamp
|
||||
};
|
||||
let Some(raw_quote_price) =
|
||||
self.select_quote_reference_price(snapshot, quote, side, matching_type)
|
||||
else {
|
||||
@@ -7706,7 +7708,7 @@ where
|
||||
};
|
||||
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price) {
|
||||
execution_block_reason.get_or_insert(reason);
|
||||
execution_block_timestamp = Some(quote.timestamp);
|
||||
execution_block_timestamp = Some(execution_at);
|
||||
continue;
|
||||
}
|
||||
let mark_price = self.quote_mark_price(quote, raw_quote_price);
|
||||
@@ -7760,8 +7762,18 @@ where
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
);
|
||||
let raw_limit = self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?
|
||||
.remaining(quote.volume_delta, u64::from(consumed), remaining_qty);
|
||||
let observation = VolumeObservation {
|
||||
kind: VolumeObservationKind::TradeIncrement,
|
||||
start: quote.timestamp,
|
||||
end: quote.timestamp,
|
||||
available_at: quote.timestamp,
|
||||
shares: quote.volume_delta,
|
||||
};
|
||||
let raw_limit = observation.remaining(
|
||||
execution_at,
|
||||
self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?,
|
||||
u64::from(consumed), remaining_qty,
|
||||
).map_err(|error| BacktestError::Execution(error.to_string()))?;
|
||||
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
||||
raw_limit
|
||||
} else {
|
||||
@@ -7794,7 +7806,7 @@ where
|
||||
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
||||
{
|
||||
execution_block_reason.get_or_insert(reason);
|
||||
execution_block_timestamp = Some(quote.timestamp);
|
||||
execution_block_timestamp = Some(execution_at);
|
||||
continue;
|
||||
}
|
||||
saw_non_blocked_execution_price = true;
|
||||
@@ -7822,7 +7834,7 @@ where
|
||||
self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
||||
{
|
||||
execution_block_reason.get_or_insert(reason);
|
||||
execution_block_timestamp = Some(quote.timestamp);
|
||||
execution_block_timestamp = Some(execution_at);
|
||||
take_qty = 0;
|
||||
break;
|
||||
}
|
||||
@@ -7867,21 +7879,21 @@ where
|
||||
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
||||
{
|
||||
execution_block_reason.get_or_insert(reason);
|
||||
execution_block_timestamp = Some(quote.timestamp);
|
||||
execution_block_timestamp = Some(execution_at);
|
||||
continue;
|
||||
}
|
||||
|
||||
gross_amount += quote_price * take_qty as f64;
|
||||
mark_amount += mark_price * take_qty as f64;
|
||||
filled_qty += take_qty;
|
||||
first_timestamp.get_or_insert(quote.timestamp);
|
||||
last_timestamp = Some(quote.timestamp);
|
||||
first_timestamp.get_or_insert(execution_at);
|
||||
last_timestamp = Some(execution_at);
|
||||
legs.push(ExecutionLeg {
|
||||
price: quote_price,
|
||||
mark_price,
|
||||
quantity: take_qty,
|
||||
execution_start_timestamp: Some(quote.timestamp),
|
||||
execution_timestamp: Some(quote.timestamp),
|
||||
execution_start_timestamp: Some(execution_at),
|
||||
execution_timestamp: Some(execution_at),
|
||||
});
|
||||
if consume_depth {
|
||||
let state = depth_state
|
||||
@@ -11592,14 +11604,51 @@ mod tests {
|
||||
assert_eq!(fill.quantity, 200);
|
||||
assert_eq!(fill.legs.len(), 1);
|
||||
assert_eq!(fill.legs[0].price, 10.8);
|
||||
assert_eq!(fill.legs[0].execution_timestamp, Some(quote_timestamp));
|
||||
assert!(fill.legs[0].execution_timestamp.unwrap() <= decision_time);
|
||||
assert!(quote_timestamp < decision_time);
|
||||
assert_eq!(fill.legs[0].execution_timestamp, Some(decision_time));
|
||||
assert_eq!(
|
||||
fill.next_cursor,
|
||||
quote_timestamp + chrono::Duration::seconds(1)
|
||||
decision_time + chrono::Duration::seconds(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_execution_clocks_do_not_replenish_the_same_observed_volume() {
|
||||
let date = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last,
|
||||
).with_volume_limit(true).with_volume_percent(0.25).with_liquidity_limit(false);
|
||||
let snapshot = limit_test_snapshot();
|
||||
let mut quote = limit_test_quote(10.8,10.79,10.81);
|
||||
quote.timestamp = date.and_hms_opt(9,32,58).unwrap();
|
||||
quote.volume_delta = 1000;
|
||||
let quotes = [quote];
|
||||
let mut ledger = super::IntradayExecutionLedger::default();
|
||||
let clock = date.and_hms_opt(9,33,0).unwrap();
|
||||
let first = broker.select_execution_fill_with_ledger(
|
||||
&snapshot.symbol,&snapshot,"es,OrderSide::Buy,MatchingType::MinuteLast,
|
||||
Some(clock),Some(clock),200,100,100,100,false,None,None,None,&ledger,None,
|
||||
).unwrap().unwrap();
|
||||
assert_eq!(first.quantity,200);
|
||||
assert_eq!(first.legs[0].execution_timestamp,Some(clock));
|
||||
assert_eq!(first.liquidity_consumption[0].timestamp,quotes[0].timestamp);
|
||||
ledger.apply_liquidity_consumption(&first.liquidity_consumption);
|
||||
let later = clock + Duration::seconds(1);
|
||||
let second = broker.select_execution_fill_with_ledger(
|
||||
&snapshot.symbol,&snapshot,"es,OrderSide::Sell,MatchingType::MinuteLast,
|
||||
Some(later),Some(later),100,100,100,100,true,None,None,None,&ledger,None,
|
||||
).unwrap().unwrap();
|
||||
assert_eq!(second.quantity,50);
|
||||
assert_eq!(second.legs[0].execution_timestamp,Some(later));
|
||||
ledger.apply_liquidity_consumption(&second.liquidity_consumption);
|
||||
assert_eq!(ledger.volume_consumed(&snapshot.symbol,quotes[0].timestamp),250);
|
||||
let third = broker.select_execution_fill_with_ledger(
|
||||
&snapshot.symbol,&snapshot,"es,OrderSide::Buy,MatchingType::MinuteLast,
|
||||
Some(later),Some(later),100,100,100,100,false,None,None,None,&ledger,None,
|
||||
).unwrap();
|
||||
assert!(third.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_buy_process_uses_latest_quote_before_decision_time() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||
|
||||
@@ -3410,7 +3410,7 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
gross_amount += quote_price * take_qty as f64;
|
||||
filled_qty += take_qty;
|
||||
last_timestamp = Some(quote.timestamp);
|
||||
last_timestamp = Some(start_cursor.max(quote.timestamp));
|
||||
if filled_qty >= requested_qty {
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user