fix: separate as-of quote time from execution clock and retain volume consumption

This commit is contained in:
boris
2026-09-11 15:12:42 +08:00
parent 4acecda79d
commit 2b6d031a55
2 changed files with 67 additions and 18 deletions
+66 -17
View File
@@ -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,&quotes,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,&quotes,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,&quotes,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;
}