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::cost::CostModel;
|
||||||
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
|
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
|
||||||
use crate::engine::BacktestError;
|
use crate::engine::BacktestError;
|
||||||
use crate::execution_capacity::{CapacityError, ParticipationRate};
|
use crate::execution_capacity::{CapacityError, ParticipationRate, VolumeObservation, VolumeObservationKind};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||||
ProcessEventKind,
|
ProcessEventKind,
|
||||||
@@ -7696,9 +7696,11 @@ where
|
|||||||
let mut liquidity_consumption = Vec::new();
|
let mut liquidity_consumption = Vec::new();
|
||||||
|
|
||||||
for (quote_index, quote) in eligible_quotes.iter().enumerate() {
|
for (quote_index, quote) in eligible_quotes.iter().enumerate() {
|
||||||
// Approximate platform-native market-order fills with the evolving L1 book after
|
let execution_at = if use_decision_time_quote {
|
||||||
// the decision time instead of trade VWAP. This keeps quantities/prices
|
start_cursor.expect("as-of orders have an execution clock").max(quote.timestamp)
|
||||||
// closer to the observed 10:18 execution logs.
|
} else {
|
||||||
|
quote.timestamp
|
||||||
|
};
|
||||||
let Some(raw_quote_price) =
|
let Some(raw_quote_price) =
|
||||||
self.select_quote_reference_price(snapshot, quote, side, matching_type)
|
self.select_quote_reference_price(snapshot, quote, side, matching_type)
|
||||||
else {
|
else {
|
||||||
@@ -7706,7 +7708,7 @@ where
|
|||||||
};
|
};
|
||||||
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price) {
|
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price) {
|
||||||
execution_block_reason.get_or_insert(reason);
|
execution_block_reason.get_or_insert(reason);
|
||||||
execution_block_timestamp = Some(quote.timestamp);
|
execution_block_timestamp = Some(execution_at);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mark_price = self.quote_mark_price(quote, raw_quote_price);
|
let mark_price = self.quote_mark_price(quote, raw_quote_price);
|
||||||
@@ -7760,8 +7762,18 @@ where
|
|||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
);
|
);
|
||||||
let raw_limit = self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?
|
let observation = VolumeObservation {
|
||||||
.remaining(quote.volume_delta, u64::from(consumed), remaining_qty);
|
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 {
|
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
|
||||||
raw_limit
|
raw_limit
|
||||||
} else {
|
} else {
|
||||||
@@ -7794,7 +7806,7 @@ where
|
|||||||
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
||||||
{
|
{
|
||||||
execution_block_reason.get_or_insert(reason);
|
execution_block_reason.get_or_insert(reason);
|
||||||
execution_block_timestamp = Some(quote.timestamp);
|
execution_block_timestamp = Some(execution_at);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
saw_non_blocked_execution_price = true;
|
saw_non_blocked_execution_price = true;
|
||||||
@@ -7822,7 +7834,7 @@ where
|
|||||||
self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
||||||
{
|
{
|
||||||
execution_block_reason.get_or_insert(reason);
|
execution_block_reason.get_or_insert(reason);
|
||||||
execution_block_timestamp = Some(quote.timestamp);
|
execution_block_timestamp = Some(execution_at);
|
||||||
take_qty = 0;
|
take_qty = 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -7867,21 +7879,21 @@ where
|
|||||||
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
|
||||||
{
|
{
|
||||||
execution_block_reason.get_or_insert(reason);
|
execution_block_reason.get_or_insert(reason);
|
||||||
execution_block_timestamp = Some(quote.timestamp);
|
execution_block_timestamp = Some(execution_at);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
gross_amount += quote_price * take_qty as f64;
|
gross_amount += quote_price * take_qty as f64;
|
||||||
mark_amount += mark_price * take_qty as f64;
|
mark_amount += mark_price * take_qty as f64;
|
||||||
filled_qty += take_qty;
|
filled_qty += take_qty;
|
||||||
first_timestamp.get_or_insert(quote.timestamp);
|
first_timestamp.get_or_insert(execution_at);
|
||||||
last_timestamp = Some(quote.timestamp);
|
last_timestamp = Some(execution_at);
|
||||||
legs.push(ExecutionLeg {
|
legs.push(ExecutionLeg {
|
||||||
price: quote_price,
|
price: quote_price,
|
||||||
mark_price,
|
mark_price,
|
||||||
quantity: take_qty,
|
quantity: take_qty,
|
||||||
execution_start_timestamp: Some(quote.timestamp),
|
execution_start_timestamp: Some(execution_at),
|
||||||
execution_timestamp: Some(quote.timestamp),
|
execution_timestamp: Some(execution_at),
|
||||||
});
|
});
|
||||||
if consume_depth {
|
if consume_depth {
|
||||||
let state = depth_state
|
let state = depth_state
|
||||||
@@ -11592,14 +11604,51 @@ mod tests {
|
|||||||
assert_eq!(fill.quantity, 200);
|
assert_eq!(fill.quantity, 200);
|
||||||
assert_eq!(fill.legs.len(), 1);
|
assert_eq!(fill.legs.len(), 1);
|
||||||
assert_eq!(fill.legs[0].price, 10.8);
|
assert_eq!(fill.legs[0].price, 10.8);
|
||||||
assert_eq!(fill.legs[0].execution_timestamp, Some(quote_timestamp));
|
assert!(quote_timestamp < decision_time);
|
||||||
assert!(fill.legs[0].execution_timestamp.unwrap() <= decision_time);
|
assert_eq!(fill.legs[0].execution_timestamp, Some(decision_time));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fill.next_cursor,
|
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]
|
#[test]
|
||||||
fn value_buy_process_uses_latest_quote_before_decision_time() {
|
fn value_buy_process_uses_latest_quote_before_decision_time() {
|
||||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
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;
|
gross_amount += quote_price * take_qty as f64;
|
||||||
filled_qty += take_qty;
|
filled_qty += take_qty;
|
||||||
last_timestamp = Some(quote.timestamp);
|
last_timestamp = Some(start_cursor.max(quote.timestamp));
|
||||||
if filled_qty >= requested_qty {
|
if filled_qty >= requested_qty {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user