From 01d1e5073de5d09f9a93e85c709b68509cd54725 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 27 Aug 2026 00:57:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=B7=A8=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E6=92=AE=E5=90=88=E6=B5=81=E5=8A=A8=E6=80=A7=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E6=B6=88=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 490 ++++++++++++++---- crates/fidc-core/tests/explicit_order_flow.rs | 356 +++++++++++++ 2 files changed, 754 insertions(+), 92 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index a1a9e0c..654ae3c 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -1,5 +1,6 @@ use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, BTreeSet}; +use std::ops::{Deref, DerefMut}; use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime}; @@ -51,9 +52,143 @@ struct ExecutionFill { quantity: u32, next_cursor: NaiveDateTime, legs: Vec, + liquidity_consumption: Vec, unfilled_reason: Option<&'static str>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QuoteBookSide { + Bid, + Ask, +} + +impl QuoteBookSide { + fn for_order_side(side: OrderSide) -> Self { + match side { + OrderSide::Buy => Self::Ask, + OrderSide::Sell => Self::Bid, + } + } +} + +#[derive(Debug, Clone, Copy)] +struct QuoteDepthConsumption { + price_bits: u64, + displayed_quantity: u32, + consumed_quantity: u32, +} + +#[derive(Debug, Clone)] +struct QuoteLiquidityConsumption { + symbol: String, + timestamp: NaiveDateTime, + book_side: QuoteBookSide, + depth_price_bits: u64, + displayed_quantity: u32, + consume_depth: bool, + consume_volume: bool, + quantity: u32, +} + +#[derive(Debug, Default)] +struct IntradayExecutionLedger { + cursors: BTreeMap, + depth_consumption: BTreeMap; 2]>, + volume_consumption: BTreeMap>, +} + +impl IntradayExecutionLedger { + fn depth_slot(side: QuoteBookSide) -> usize { + match side { + QuoteBookSide::Bid => 0, + QuoteBookSide::Ask => 1, + } + } + + fn depth_consumed( + &self, + symbol: &str, + side: QuoteBookSide, + price_bits: u64, + displayed_quantity: u32, + ) -> u32 { + self.depth_consumption + .get(symbol) + .and_then(|sides| sides[Self::depth_slot(side)]) + .filter(|state| { + state.price_bits == price_bits && state.displayed_quantity == displayed_quantity + }) + .map(|state| state.consumed_quantity.min(displayed_quantity)) + .unwrap_or(0) + } + + fn volume_consumed(&self, symbol: &str, timestamp: NaiveDateTime) -> u32 { + self.volume_consumption + .get(symbol) + .and_then(|quotes| quotes.get(×tamp)) + .copied() + .unwrap_or(0) + } + + fn apply_liquidity_consumption(&mut self, consumptions: &[QuoteLiquidityConsumption]) { + for consumption in consumptions { + if consumption.quantity == 0 { + continue; + } + if consumption.consume_depth { + let sides = self + .depth_consumption + .entry(consumption.symbol.clone()) + .or_insert([None, None]); + let slot = &mut sides[Self::depth_slot(consumption.book_side)]; + match slot { + Some(state) + if state.price_bits == consumption.depth_price_bits + && state.displayed_quantity == consumption.displayed_quantity => + { + state.consumed_quantity = state + .consumed_quantity + .saturating_add(consumption.quantity) + .min(state.displayed_quantity); + } + _ => { + *slot = Some(QuoteDepthConsumption { + price_bits: consumption.depth_price_bits, + displayed_quantity: consumption.displayed_quantity, + consumed_quantity: consumption + .quantity + .min(consumption.displayed_quantity), + }); + } + } + } + if consumption.consume_volume { + let consumed = self + .volume_consumption + .entry(consumption.symbol.clone()) + .or_default() + .entry(consumption.timestamp) + .or_default(); + *consumed = consumed.saturating_add(consumption.quantity); + } + } + } +} + +impl Deref for IntradayExecutionLedger { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.cursors + } +} + +impl DerefMut for IntradayExecutionLedger { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cursors + } +} + #[derive(Debug, Clone)] struct OpenOrder { order_id: u64, @@ -71,6 +206,26 @@ struct OpenOrder { reason: String, } +#[derive(Debug, Default)] +struct BrokerExecutionSession { + date: Option, + intraday_turnover: BTreeMap, + execution_cursors: IntradayExecutionLedger, + global_execution_cursor: Option, + commission_state: BTreeMap, +} + +impl BrokerExecutionSession { + fn activate(&mut self, date: NaiveDate) { + if self.date != Some(date) { + *self = Self { + date: Some(date), + ..Self::default() + }; + } + } +} + #[derive(Debug, Clone)] struct TargetConstraint { symbol: String, @@ -220,6 +375,7 @@ pub struct BrokerSimulator { runtime_time_in_force: Cell>, next_order_id: Cell, open_orders: RefCell>, + execution_session: RefCell, } impl BrokerSimulator { @@ -251,6 +407,7 @@ impl BrokerSimulator { runtime_time_in_force: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), + execution_session: RefCell::new(BrokerExecutionSession::default()), } } @@ -286,6 +443,7 @@ impl BrokerSimulator { runtime_time_in_force: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), + execution_session: RefCell::new(BrokerExecutionSession::default()), } } @@ -1033,20 +1191,31 @@ where portfolio: &mut PortfolioState, data: &DataSet, decision: &StrategyDecision, + ) -> Result { + let mut session = std::mem::take(&mut *self.execution_session.borrow_mut()); + session.activate(date); + let result = self.execute_with_daily_session(date, portfolio, data, decision, &mut session); + *self.execution_session.borrow_mut() = session; + result + } + + fn execute_with_daily_session( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + decision: &StrategyDecision, + session: &mut BrokerExecutionSession, ) -> Result { let mut report = BrokerExecutionReport::default(); - let mut intraday_turnover = BTreeMap::::new(); - let mut execution_cursors = BTreeMap::::new(); - let mut global_execution_cursor = None::; - let mut commission_state = BTreeMap::::new(); self.process_open_orders( date, portfolio, data, - &mut intraday_turnover, - &mut execution_cursors, - &mut global_execution_cursor, - &mut commission_state, + &mut session.intraday_turnover, + &mut session.execution_cursors, + &mut session.global_execution_cursor, + &mut session.commission_state, &mut report, )?; if !decision.order_intents.is_empty() { @@ -1076,10 +1245,10 @@ where portfolio, data, intent, - &mut intraday_turnover, - &mut execution_cursors, - &mut global_execution_cursor, - &mut commission_state, + &mut session.intraday_turnover, + &mut session.execution_cursors, + &mut session.global_execution_cursor, + &mut session.commission_state, &mut report, ); if let Err(error) = result { @@ -1136,10 +1305,10 @@ where requested_qty, self.reserve_order_id(), sell_reason(decision, &symbol), - &mut intraday_turnover, - &mut execution_cursors, - &mut global_execution_cursor, - &mut commission_state, + &mut session.intraday_turnover, + &mut session.execution_cursors, + &mut session.global_execution_cursor, + &mut session.commission_state, None, false, true, @@ -1191,10 +1360,10 @@ where requested_qty, self.reserve_order_id(), "rebalance_buy", - &mut intraday_turnover, - &mut execution_cursors, - &mut global_execution_cursor, - &mut commission_state, + &mut session.intraday_turnover, + &mut session.execution_cursors, + &mut session.global_execution_cursor, + &mut session.commission_state, None, None, false, @@ -1287,7 +1456,7 @@ where data: &DataSet, intent: &OrderIntent, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -1781,7 +1950,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -1816,7 +1985,7 @@ where existing_order_id: Option, emit_creation_events: bool, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -1885,7 +2054,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -2059,7 +2228,7 @@ where portfolio: &mut PortfolioState, data: &DataSet, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -2881,7 +3050,7 @@ where valuation_prices: Option<&BTreeMap>, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -3541,7 +3710,7 @@ where order_id: u64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, limit_price: Option, @@ -3872,10 +4041,17 @@ where algo_request, limit_price, ); - let (filled_qty, execution_legs, next_cursor) = if let Some(fill) = fill { + let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) = + fill + { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); - (fill.quantity, fill.legs, Some(fill.next_cursor)) + ( + fill.quantity, + fill.legs, + Some(fill.next_cursor), + fill.liquidity_consumption, + ) } else { let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(fillable_qty)); @@ -3883,7 +4059,7 @@ where self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price) { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } else if !self.price_satisfies_limit( OrderSide::Sell, execution_price, @@ -3894,7 +4070,7 @@ where partial_fill_reason, Some("limit price not marketable yet"), ); - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } else { match self.execution_price_with_limit_slippage_or_rejection( snapshot, @@ -3910,11 +4086,12 @@ where quantity: fillable_qty, }], None, + Vec::new(), ), Err(reason) => { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } } } @@ -4101,6 +4278,7 @@ where }); } portfolio.prune_flat_positions(); + execution_cursors.apply_liquidity_consumption(&liquidity_consumption); *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let remaining_qty = requested_qty.saturating_sub(filled_qty); @@ -4190,7 +4368,7 @@ where target_value: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4290,7 +4468,7 @@ where end_time: Option, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4373,7 +4551,7 @@ where target_quantity: i32, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4456,7 +4634,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4536,7 +4714,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4618,7 +4796,7 @@ where target_percent: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4649,7 +4827,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4680,7 +4858,7 @@ where value: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4813,7 +4991,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4908,7 +5086,7 @@ where percent: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4939,7 +5117,7 @@ where limit_price: f64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -4973,7 +5151,7 @@ where end_time: Option, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -5079,7 +5257,7 @@ where end_time: Option, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -5112,7 +5290,7 @@ where quantity: i32, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, algo_request: Option<&AlgoExecutionRequest>, @@ -5177,7 +5355,7 @@ where lots: i32, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, @@ -5215,7 +5393,7 @@ where _round_lot: u32, _value_budget: f64, _reason: &str, - _execution_cursors: &BTreeMap, + _execution_cursors: &IntradayExecutionLedger, _global_execution_cursor: Option, ) -> u32 { requested_qty @@ -5238,7 +5416,7 @@ where order_id: u64, reason: &str, intraday_turnover: &mut BTreeMap, - execution_cursors: &mut BTreeMap, + execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, value_budget: Option, @@ -5509,10 +5687,17 @@ where algo_request, limit_price, ); - let (filled_qty, execution_legs, next_cursor) = if let Some(fill) = fill { + let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) = + fill + { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); - (fill.quantity, fill.legs, Some(fill.next_cursor)) + ( + fill.quantity, + fill.legs, + Some(fill.next_cursor), + fill.liquidity_consumption, + ) } else { let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(constrained_qty)); @@ -5520,7 +5705,7 @@ where self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price) { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } else if !self.price_satisfies_limit( OrderSide::Buy, execution_price, @@ -5531,7 +5716,7 @@ where partial_fill_reason, Some("limit price not marketable yet"), ); - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } else { match self.execution_price_with_limit_slippage_or_rejection( snapshot, @@ -5542,7 +5727,7 @@ where Err(reason) => { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } Ok(mut execution_price) => { let mut filled_qty = self.affordable_buy_quantity( @@ -5579,7 +5764,7 @@ where } } if blocked_by_final_price { - (0, Vec::new(), None) + (0, Vec::new(), None, Vec::new()) } else { if filled_qty < constrained_qty { partial_fill_reason = merge_partial_fill_reason( @@ -5601,6 +5786,7 @@ where quantity: filled_qty, }], None, + Vec::new(), ) } } @@ -5791,6 +5977,7 @@ where note: format!("buy {symbol} {reason}"), }); } + execution_cursors.apply_liquidity_consumption(&liquidity_consumption); *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let remaining_qty = requested_qty.saturating_sub(filled_qty); @@ -6427,7 +6614,7 @@ where minimum_order_quantity: u32, order_step_size: u32, allow_odd_lot_sell: bool, - _execution_cursors: &mut BTreeMap, + execution_ledger: &mut IntradayExecutionLedger, _global_execution_cursor: Option, cash_limit: Option, gross_limit: Option, @@ -6454,7 +6641,8 @@ where .map(|end_time| date.and_time(end_time)); let quotes = data.execution_quotes_on(date, symbol); - if let Some(fill) = self.select_execution_fill( + if let Some(fill) = self.select_execution_fill_with_ledger( + symbol, snapshot, quotes, side, @@ -6469,13 +6657,19 @@ where cash_limit, gross_limit, limit_price, + execution_ledger, ) { return Some(fill); } - if algo_request.is_some() || self.intraday_execution_start_time.is_some() { + if algo_request.is_some() + || runtime_start_time.is_some() + || runtime_end_time.is_some() + || self.intraday_execution_start_time.is_some() + { let next_cursor = algo_request .and_then(|request| request.start_time) + .or(runtime_start_time) .or(self.intraday_execution_start_time) .map(|start_time| date.and_time(start_time) + Duration::seconds(1)) .unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight")); @@ -6483,6 +6677,7 @@ where quantity: 0, next_cursor, legs: Vec::new(), + liquidity_consumption: Vec::new(), unfilled_reason: Some(self.empty_intraday_quote_reason( quotes, start_cursor, @@ -6521,6 +6716,7 @@ where } } + #[cfg(test)] fn select_execution_fill( &self, snapshot: &crate::data::DailyMarketSnapshot, @@ -6537,6 +6733,46 @@ where cash_limit: Option, gross_limit: Option, limit_price: Option, + ) -> Option { + self.select_execution_fill_with_ledger( + &snapshot.symbol, + snapshot, + quotes, + side, + matching_type, + start_cursor, + end_cursor, + requested_qty, + round_lot, + minimum_order_quantity, + order_step_size, + allow_odd_lot_sell, + cash_limit, + gross_limit, + limit_price, + &IntradayExecutionLedger::default(), + ) + } + + #[allow(clippy::too_many_arguments)] + fn select_execution_fill_with_ledger( + &self, + symbol: &str, + snapshot: &crate::data::DailyMarketSnapshot, + quotes: &[IntradayExecutionQuote], + side: OrderSide, + matching_type: MatchingType, + start_cursor: Option, + end_cursor: Option, + requested_qty: u32, + round_lot: u32, + minimum_order_quantity: u32, + order_step_size: u32, + allow_odd_lot_sell: bool, + cash_limit: Option, + gross_limit: Option, + limit_price: Option, + execution_ledger: &IntradayExecutionLedger, ) -> Option { if requested_qty == 0 { return None; @@ -6583,6 +6819,13 @@ where let mut execution_block_timestamp = None; let mut saw_non_blocked_execution_price = false; let saw_quote_after_cursor = !eligible_quotes.is_empty(); + let book_side = QuoteBookSide::for_order_side(side); + let mut depth_state = execution_ledger + .depth_consumption + .get(symbol) + .and_then(|sides| sides[IntradayExecutionLedger::depth_slot(book_side)]); + let mut pending_volume_consumption = BTreeMap::::new(); + 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 @@ -6599,14 +6842,39 @@ where break; } let missing_level1_depth = Self::quote_lacks_level1_depth(quote); - let mut available_qty = if quote_quantity_limited && !missing_level1_depth { - let top_level_liquidity = match side { - OrderSide::Buy => quote.ask1_volume, - OrderSide::Sell => quote.bid1_volume, - }; - top_level_liquidity - .saturating_mul(lot as u64) - .min(u32::MAX as u64) as u32 + let consume_depth = quote_quantity_limited && !missing_level1_depth; + let top_level_price = match side { + OrderSide::Buy => quote.ask1, + OrderSide::Sell => quote.bid1, + }; + let displayed_quantity = match side { + OrderSide::Buy => quote.ask1_volume, + OrderSide::Sell => quote.bid1_volume, + } + .saturating_mul(lot as u64) + .min(u32::MAX as u64) as u32; + let depth_price_bits = top_level_price.to_bits(); + let mut available_qty = if consume_depth { + let consumed = depth_state + .filter(|state| { + state.price_bits == depth_price_bits + && state.displayed_quantity == displayed_quantity + }) + .map(|state| state.consumed_quantity.min(displayed_quantity)) + .unwrap_or_else(|| { + execution_ledger.depth_consumed( + symbol, + book_side, + depth_price_bits, + displayed_quantity, + ) + }); + depth_state = Some(QuoteDepthConsumption { + price_bits: depth_price_bits, + displayed_quantity, + consumed_quantity: consumed, + }); + displayed_quantity.saturating_sub(consumed) } else { remaining_qty }; @@ -6617,7 +6885,15 @@ where } else { self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size) }; - available_qty = available_qty.min(volume_limited); + let consumed = execution_ledger + .volume_consumed(symbol, quote.timestamp) + .saturating_add( + pending_volume_consumption + .get("e.timestamp) + .copied() + .unwrap_or(0), + ); + available_qty = available_qty.min(volume_limited.saturating_sub(consumed)); } if available_qty == 0 { continue; @@ -6730,6 +7006,33 @@ where mark_price, quantity: take_qty, }); + if consume_depth { + let state = depth_state + .as_mut() + .expect("depth state must exist when depth consumption is enabled"); + state.consumed_quantity = state + .consumed_quantity + .saturating_add(take_qty) + .min(state.displayed_quantity); + } + if self.volume_limit { + let consumed = pending_volume_consumption + .entry(quote.timestamp) + .or_default(); + *consumed = consumed.saturating_add(take_qty); + } + if consume_depth || self.volume_limit { + liquidity_consumption.push(QuoteLiquidityConsumption { + symbol: symbol.to_string(), + timestamp: quote.timestamp, + book_side, + depth_price_bits, + displayed_quantity, + consume_depth, + consume_volume: self.volume_limit, + quantity: take_qty, + }); + } if filled_qty >= requested_qty { break; @@ -6746,6 +7049,7 @@ where .expect("blocked execution quote timestamp") + Duration::seconds(1), legs: Vec::new(), + liquidity_consumption: Vec::new(), unfilled_reason: Some(reason), }); } @@ -6764,6 +7068,7 @@ where } else { legs }, + liquidity_consumption, unfilled_reason: if filled_qty < requested_qty { budget_block_reason.or(if saw_quote_after_cursor { Some("intraday quote liquidity exhausted") @@ -6917,7 +7222,8 @@ mod tests { use std::collections::BTreeMap; use super::{ - BrokerExecutionReport, BrokerSimulator, MatchingType, RebalanceCashMode, SlippageModel, + BrokerExecutionReport, BrokerSimulator, IntradayExecutionLedger, MatchingType, + RebalanceCashMode, SlippageModel, }; use crate::cost::ChinaAShareCostModel; use crate::data::{ @@ -8002,7 +8308,7 @@ mod tests { 5_500.0, "next_open_target_value", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8060,7 +8366,7 @@ mod tests { 20_000.0, "target_value_20_percent", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8118,7 +8424,7 @@ mod tests { 10_000.0, "unchanged_target_value", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8136,7 +8442,7 @@ mod tests { None, "unchanged timed target value", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8151,7 +8457,7 @@ mod tests { 1_000, "unchanged target shares", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8208,7 +8514,7 @@ mod tests { 9_995.0, "sub_lot_target_adjust", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8234,7 +8540,7 @@ mod tests { 995.0, "sub_lot_target_open", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut buy_report, @@ -8295,7 +8601,7 @@ mod tests { 0.2, "target_percent_20_percent", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8344,7 +8650,7 @@ mod tests { None, "date_conditioned_target_weights", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8447,7 +8753,7 @@ mod tests { None, "target_weights_test", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8537,7 +8843,7 @@ mod tests { None, "aiquant_deferred_buy_risk", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -8627,7 +8933,7 @@ mod tests { .buy(prev_date, 1_000, 10.0); let mut report = BrokerExecutionReport::default(); let mut intraday_turnover = BTreeMap::new(); - let mut execution_cursors = BTreeMap::new(); + let mut execution_cursors = IntradayExecutionLedger::default(); let mut global_execution_cursor = None; let mut commission_state = BTreeMap::new(); @@ -8844,7 +9150,7 @@ mod tests { None, "rebalance_cash_mode_test", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut sell_then_buy_report, @@ -8885,7 +9191,7 @@ mod tests { None, "rebalance_cash_mode_test", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut pre_open_cash_report, @@ -9276,7 +9582,7 @@ mod tests { Some(&valuation_prices), "custom_valuation_market_order_test", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9343,7 +9649,7 @@ mod tests { 0.0, "target_rebalance_exit", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9400,7 +9706,7 @@ mod tests { None, "target_portfolio_rebalance", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9477,7 +9783,7 @@ mod tests { Some(date.and_hms_opt(9, 31, 0).unwrap().time()), "risk_forced_exit", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9604,7 +9910,7 @@ mod tests { 9_996_284.62 * 0.5 / 40.0, "daily_position_target_adjust", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9715,7 +10021,7 @@ mod tests { 125_000.0, "periodic_rebalance_buy", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9776,7 +10082,7 @@ mod tests { 125_000.0, "periodic_rebalance_buy", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -9843,7 +10149,7 @@ mod tests { value_budget, "periodic_rebalance_buy", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -10073,7 +10379,7 @@ mod tests { 0.0, "stop_loss_exit", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, @@ -10162,7 +10468,7 @@ mod tests { 0.0, "stop_loss_exit", &mut BTreeMap::new(), - &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), &mut None, &mut BTreeMap::new(), &mut report, diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 3796cb3..19f4f15 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -77,6 +77,117 @@ fn order_value_rounding_data(date: NaiveDate, symbol: &str, price: f64) -> DataS .expect("dataset") } +fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet { + DataSet::from_components_with_actions_and_quotes( + vec![Instrument { + symbol: symbol.to_string(), + name: "Test".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some(format!("{date} 10:19:00")), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.0, + prev_close: 10.0, + volume: 100_000, + minute_volume: 1_000, + bid1_volume: 5, + ask1_volume: 5, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 15.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_star_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + Vec::new(), + vec![ + IntradayExecutionQuote { + date, + symbol: symbol.to_string(), + timestamp: date.and_hms_opt(10, 18, 0).unwrap(), + last_price: 10.0, + bid1: 9.99, + ask1: 10.0, + bid1_volume: 4, + ask1_volume: 4, + volume_delta: 1_000, + amount_delta: 10_000.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date, + symbol: symbol.to_string(), + timestamp: date.and_hms_opt(10, 19, 0).unwrap(), + last_price: 10.0, + bid1: 9.99, + ask1: 10.0, + bid1_volume: 4, + ask1_volume: 4, + volume_delta: 1_000, + amount_delta: 10_000.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date, + symbol: symbol.to_string(), + timestamp: date.and_hms_opt(10, 20, 0).unwrap(), + last_price: 10.0, + bid1: 9.99, + ask1: 10.0, + bid1_volume: 5, + ask1_volume: 5, + volume_delta: 1_000, + amount_delta: 10_000.0, + trading_phase: Some("continuous".to_string()), + }, + ], + ) + .expect("dataset") +} + fn execute_single_value_order( date: NaiveDate, data: &DataSet, @@ -4830,6 +4941,251 @@ fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() { assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 100); } +#[test] +fn broker_persists_daily_volume_consumption_across_execute_calls() { + let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); + let data = two_day_limit_order_data(10.0, 10.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_volume_limit(true) + .with_volume_percent(0.001) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(1_000_000.0); + let decision = || StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: "000002.SZ".to_string(), + quantity: 100, + reason: "daily_volume_session_buy".to_string(), + }], + ..StrategyDecision::default() + }; + + let first = broker + .execute(day1, &mut portfolio, &data, &decision()) + .expect("first same-day execution"); + assert_eq!(first.fill_events.len(), 1); + assert_eq!(first.fill_events[0].quantity, 100); + + let second = broker + .execute(day1, &mut portfolio, &data, &decision()) + .expect("second same-day execution"); + assert!(second.fill_events.is_empty()); + assert_eq!(second.order_events.len(), 1); + assert_eq!(second.order_events[0].status, OrderStatus::Canceled); + assert_eq!(second.order_events[0].filled_quantity, 0); + assert!(second.order_events[0].reason.contains("daily volume limit")); + assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 100); + + let next_day = broker + .execute(day2, &mut portfolio, &data, &decision()) + .expect("next-day execution resets daily liquidity"); + assert_eq!(next_day.fill_events.len(), 1); + assert_eq!(next_day.fill_events[0].quantity, 100); + assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 200); +} + +#[test] +fn broker_persists_quote_depth_until_fresh_level_data_arrives() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let symbol = "000002.SZ"; + let data = intraday_liquidity_data(date, symbol); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Last, + ) + .with_matching_type(MatchingType::MinuteLast) + .with_volume_limit(false) + .with_liquidity_limit(true); + let mut portfolio = PortfolioState::new(1_000_000.0); + let decision = |quantity| StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: symbol.to_string(), + quantity, + reason: "quote_depth_session_buy".to_string(), + }], + ..StrategyDecision::default() + }; + let at_1018 = NaiveTime::from_hms_opt(10, 18, 0).unwrap(); + let at_1019 = NaiveTime::from_hms_opt(10, 19, 0).unwrap(); + let at_1020 = NaiveTime::from_hms_opt(10, 20, 0).unwrap(); + + let atomic_reject = broker + .execute_between( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::Shares { + symbol: symbol.to_string(), + quantity: 500, + reason: "quote_depth_fok_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Fok), + ], + ..StrategyDecision::default() + }, + Some(at_1018), + Some(at_1018), + ) + .expect("FOK rejection must not consume quote depth"); + assert!(atomic_reject.fill_events.is_empty()); + assert_eq!(atomic_reject.order_events[0].status, OrderStatus::Canceled); + assert!(portfolio.position(symbol).is_none()); + + let first = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(300), + Some(at_1018), + Some(at_1018), + ) + .expect("first quote-depth execution"); + assert_eq!(first.fill_events.len(), 1); + assert_eq!(first.fill_events[0].quantity, 300); + + let second = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(200), + Some(at_1018), + Some(at_1018), + ) + .expect("second quote-depth execution"); + assert_eq!(second.fill_events.len(), 1); + assert_eq!(second.fill_events[0].quantity, 100); + assert_eq!(second.order_events[0].status, OrderStatus::Canceled); + assert_eq!(second.order_events[0].filled_quantity, 100); + assert!( + second.order_events[0] + .reason + .contains("intraday quote liquidity exhausted") + ); + + let unchanged_level = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(100), + Some(at_1019), + Some(at_1019), + ) + .expect("unchanged level must remain depleted"); + assert!(unchanged_level.fill_events.is_empty()); + assert_eq!( + unchanged_level.order_events[0].status, + OrderStatus::Canceled + ); + assert!( + unchanged_level.order_events[0] + .reason + .contains("intraday quote liquidity exhausted") + ); + + let fresh_level = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(200), + Some(at_1020), + Some(at_1020), + ) + .expect("fresh quote level resets depth consumption"); + assert_eq!(fresh_level.fill_events.len(), 1); + assert_eq!(fresh_level.fill_events[0].quantity, 200); + assert_eq!(fresh_level.order_events[0].status, OrderStatus::Filled); + assert_eq!(portfolio.position(symbol).unwrap().quantity, 600); +} + +#[test] +fn broker_persists_quote_volume_participation_until_next_quote() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let symbol = "000002.SZ"; + let data = intraday_liquidity_data(date, symbol); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Last, + ) + .with_matching_type(MatchingType::MinuteLast) + .with_volume_limit(true) + .with_volume_percent(0.25) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(1_000_000.0); + let decision = |quantity| StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: symbol.to_string(), + quantity, + reason: "quote_volume_session_buy".to_string(), + }], + ..StrategyDecision::default() + }; + let at_1018 = NaiveTime::from_hms_opt(10, 18, 0).unwrap(); + let at_1019 = NaiveTime::from_hms_opt(10, 19, 0).unwrap(); + + let first = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(100), + Some(at_1018), + Some(at_1018), + ) + .expect("first quote-volume execution"); + assert_eq!(first.fill_events[0].quantity, 100); + + let second = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(200), + Some(at_1018), + Some(at_1018), + ) + .expect("second quote-volume execution"); + assert_eq!(second.fill_events[0].quantity, 100); + assert_eq!(second.order_events[0].status, OrderStatus::Canceled); + + let exhausted = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(100), + Some(at_1018), + Some(at_1018), + ) + .expect("quote volume must remain exhausted"); + assert!(exhausted.fill_events.is_empty()); + assert_eq!(exhausted.order_events[0].status, OrderStatus::Canceled); + + let next_quote = broker + .execute_between( + date, + &mut portfolio, + &data, + &decision(200), + Some(at_1019), + Some(at_1019), + ) + .expect("next quote receives a fresh participation bucket"); + assert_eq!(next_quote.fill_events[0].quantity, 200); + assert_eq!(portfolio.position(symbol).unwrap().quantity, 400); +} + #[test] fn broker_day_market_order_cancels_remainder_without_creating_invalid_open_order() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();