From 406cb05146c9cdf6f9d94b531a5eae4f8aae5687 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 00:26:54 -0700 Subject: [PATCH] Expose partial fill denial reasons --- crates/fidc-core/src/broker.rs | 126 ++++++++++++++++- crates/fidc-core/tests/explicit_order_flow.rs | 131 +++++++++++++++++- 2 files changed, 251 insertions(+), 6 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 2d6a09a..f912b54 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -24,6 +24,7 @@ struct ExecutionFill { price: f64, quantity: u32, next_cursor: NaiveDateTime, + unfilled_reason: Option<&'static str>, } #[derive(Debug, Clone)] @@ -914,6 +915,11 @@ where } let sellable = position.sellable_qty(date); + let mut partial_fill_reason = if sellable < requested_qty { + Some("sellable quantity limit".to_string()) + } else { + None + }; let market_limited_qty = self.market_fillable_quantity( snapshot, OrderSide::Sell, @@ -924,7 +930,16 @@ where requested_qty >= position.quantity && sellable >= position.quantity, ); let filled_qty = match market_limited_qty { - Ok(quantity) => quantity.min(sellable), + Ok(quantity) => { + let quantity = quantity.min(sellable); + if quantity < requested_qty { + partial_fill_reason = merge_partial_fill_reason( + partial_fill_reason, + Some("market liquidity or volume limit"), + ); + } + quantity + } Err(limit_reason) => { report.order_events.push(OrderEvent { date, @@ -975,6 +990,8 @@ where if self.uses_serial_execution_cursor(reason) { *global_execution_cursor = Some(fill.next_cursor); } + partial_fill_reason = + merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); (fill.quantity, fill.price) } else { (filled_qty, self.sell_price(snapshot)) @@ -1002,6 +1019,17 @@ where } else { OrderStatus::Filled }; + let order_reason = if status == OrderStatus::PartiallyFilled { + let detail = partial_fill_reason + .as_deref() + .unwrap_or("remaining quantity could not be filled"); + report.diagnostics.push(format!( + "order_partial_fill symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}" + )); + format!("{reason}: partial fill due to {detail}") + } else { + reason.to_string() + }; report.order_events.push(OrderEvent { date, @@ -1011,7 +1039,7 @@ where requested_quantity: requested_qty, filled_quantity: filled_qty, status, - reason: reason.to_string(), + reason: order_reason, }); report.fill_events.push(FillEvent { date, @@ -1279,6 +1307,7 @@ where return Ok(()); } + let mut partial_fill_reason = None; let market_limited_qty = self.market_fillable_quantity( snapshot, OrderSide::Buy, @@ -1289,7 +1318,12 @@ where false, ); let constrained_qty = match market_limited_qty { - Ok(quantity) => quantity, + Ok(quantity) => { + if quantity < requested_qty { + partial_fill_reason = Some("market liquidity or volume limit".to_string()); + } + quantity + } Err(limit_reason) => { report.order_events.push(OrderEvent { date, @@ -1326,6 +1360,8 @@ where if self.uses_serial_execution_cursor(reason) { *global_execution_cursor = Some(fill.next_cursor); } + partial_fill_reason = + merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); (fill.quantity, fill.price) } else { let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy); @@ -1338,6 +1374,18 @@ where self.minimum_order_quantity(data, symbol), self.order_step_size(data, symbol), ); + if filled_qty < constrained_qty { + partial_fill_reason = merge_partial_fill_reason( + partial_fill_reason, + self.buy_reduction_reason( + portfolio.cash(), + value_budget.map(|budget| budget + 400.0), + execution_price, + constrained_qty, + filled_qty, + ), + ); + } (filled_qty, execution_price) }; if filled_qty == 0 { @@ -1349,7 +1397,12 @@ where requested_quantity: requested_qty, filled_quantity: 0, status: OrderStatus::Rejected, - reason: format!("{reason}: insufficient cash after fees"), + reason: format!( + "{reason}: {}", + partial_fill_reason + .as_deref() + .unwrap_or("insufficient cash after fees") + ), }); return Ok(()); } @@ -1376,6 +1429,17 @@ where } else { OrderStatus::Filled }; + let order_reason = if status == OrderStatus::PartiallyFilled { + let detail = partial_fill_reason + .as_deref() + .unwrap_or("remaining quantity could not be filled"); + report.diagnostics.push(format!( + "order_partial_fill symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}" + )); + format!("{reason}: partial fill due to {detail}") + } else { + reason.to_string() + }; report.order_events.push(OrderEvent { date, @@ -1385,7 +1449,7 @@ where requested_quantity: requested_qty, filled_quantity: filled_qty, status, - reason: reason.to_string(), + reason: order_reason, }); report.fill_events.push(FillEvent { date, @@ -1547,6 +1611,26 @@ where 0 } + fn buy_reduction_reason( + &self, + cash_limit: f64, + gross_limit: Option, + price: f64, + requested_qty: u32, + filled_qty: u32, + ) -> Option<&'static str> { + if filled_qty >= requested_qty { + return None; + } + if gross_limit.is_some_and(|limit| price * requested_qty as f64 > limit + 1e-6) { + Some("value budget limit") + } else if cash_limit.is_finite() { + Some("insufficient cash after fees") + } else { + None + } + } + fn market_fillable_quantity( &self, snapshot: &crate::data::DailyMarketSnapshot, @@ -1653,6 +1737,13 @@ where price: execution_price, quantity, next_cursor, + unfilled_reason: self.buy_reduction_reason( + cash_limit.unwrap_or(f64::INFINITY), + gross_limit, + execution_price, + requested_qty, + quantity, + ), }); } @@ -1702,11 +1793,14 @@ where let mut filled_qty = 0_u32; let mut gross_amount = 0.0_f64; let mut last_timestamp = None; + let mut budget_block_reason = None; + let mut saw_quote_after_cursor = false; for quote in quotes { if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) { continue; } + saw_quote_after_cursor = true; // Approximate JoinQuant market-order fills with the evolving L1 book after // the decision time instead of trade VWAP. This keeps quantities/prices @@ -1745,6 +1839,7 @@ where while take_qty > 0 { let candidate_gross = gross_amount + quote_price * take_qty as f64; if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { + budget_block_reason = Some("value budget limit"); take_qty = self.decrement_order_quantity( take_qty, minimum_order_quantity, @@ -1755,6 +1850,7 @@ where if candidate_gross <= cash + 1e-6 { break; } + budget_block_reason = Some("insufficient cash after fees"); take_qty = self.decrement_order_quantity( take_qty, minimum_order_quantity, @@ -1783,6 +1879,15 @@ where price: gross_amount / filled_qty as f64, quantity: filled_qty, next_cursor: last_timestamp.unwrap() + Duration::seconds(1), + unfilled_reason: if filled_qty < requested_qty { + budget_block_reason.or(if saw_quote_after_cursor { + Some("intraday quote liquidity exhausted") + } else { + Some("no execution quotes after start") + }) + } else { + None + }, }) } @@ -1792,6 +1897,17 @@ where } } +fn merge_partial_fill_reason(current: Option, next: Option<&str>) -> Option { + match (current, next) { + (Some(existing), Some(next_reason)) if !existing.contains(next_reason) => { + Some(format!("{existing}; {next_reason}")) + } + (Some(existing), _) => Some(existing), + (None, Some(next_reason)) => Some(next_reason.to_string()), + (None, None) => None, + } +} + fn price_field_name(field: PriceField) -> &'static str { match field { PriceField::DayOpen => "day_open", diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 1d9af67..fbaac40 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -2,7 +2,8 @@ use chrono::NaiveDate; use fidc_core::{ BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, - OrderIntent, PortfolioState, PriceField, SlippageModel, StrategyDecision, + IntradayExecutionQuote, OrderIntent, PortfolioState, PriceField, SlippageModel, + StrategyDecision, }; use std::collections::{BTreeMap, BTreeSet}; @@ -509,6 +510,134 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() { assert!((report.fill_events[0].price - 10.02).abs() < 1e-9); } +#[test] +fn broker_emits_partial_fill_reason_when_intraday_quote_liquidity_exhausted() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components_with_actions_and_quotes( + vec![Instrument { + symbol: "000002.SZ".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: "000002.SZ".to_string(), + timestamp: Some("2024-01-10 10:18:00".to_string()), + 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.01, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 80_000, + ask1_volume: 80_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000002.SZ".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: "000002.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + 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: "000002.SZ".to_string(), + timestamp: date.and_hms_opt(10, 18, 3).unwrap(), + last_price: 10.02, + bid1: 10.01, + ask1: 10.03, + bid1_volume: 2, + ask1_volume: 2, + volume_delta: 1, + amount_delta: 0.0, + trading_phase: Some("continuous".to_string()), + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Last, + ); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Value { + symbol: "000002.SZ".to_string(), + value: 5_100.0, + reason: "intraday_quote_partial".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].quantity, 200); + assert_eq!(report.order_events.len(), 1); + assert_eq!( + report.order_events[0].status, + fidc_core::OrderStatus::PartiallyFilled + ); + assert!( + report.order_events[0] + .reason + .contains("partial fill due to intraday quote liquidity exhausted") + ); + assert!( + report + .diagnostics + .iter() + .any(|item| item.contains("order_partial_fill symbol=000002.SZ side=buy")) + ); +} + #[test] fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() { let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();