diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 72b4fc2..4beaa87 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -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"); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index ba11257..ae748b1 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -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; }