From 16ae8267247a328f93b34c7e49e2bd7a47e4a663 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 00:52:50 -0700 Subject: [PATCH] Align market order cancellation semantics --- crates/fidc-core/src/broker.rs | 44 ++++++- crates/fidc-core/tests/explicit_order_flow.rs | 117 ++++++++++++++++-- 2 files changed, 149 insertions(+), 12 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 7c99b2b..d4011d4 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -942,7 +942,7 @@ where side: OrderSide::Sell, requested_quantity: requested_qty, filled_quantity: 0, - status: OrderStatus::Rejected, + status: zero_fill_status_for_reason(&limit_reason), reason: format!("{reason}: {limit_reason}"), }); return Ok(()); @@ -1063,7 +1063,7 @@ where *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let status = if filled_qty < requested_qty { - OrderStatus::PartiallyFilled + final_partial_fill_status(partial_fill_reason.as_deref()) } else { OrderStatus::Filled }; @@ -1075,6 +1075,14 @@ where "order_partial_fill symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}" )); format!("{reason}: partial fill due to {detail}") + } else if status == OrderStatus::Canceled && filled_qty < requested_qty { + let detail = partial_fill_reason + .as_deref() + .unwrap_or("remaining quantity could not be filled"); + report.diagnostics.push(format!( + "order_remainder_canceled symbol={symbol} side=sell requested={requested_qty} filled={filled_qty} reason={detail}" + )); + format!("{reason}: partial fill due to {detail}; remaining quantity canceled") } else { reason.to_string() }; @@ -1340,7 +1348,7 @@ where side: OrderSide::Buy, requested_quantity: requested_qty, filled_quantity: 0, - status: OrderStatus::Rejected, + status: zero_fill_status_for_reason(&limit_reason), reason: format!("{reason}: {limit_reason}"), }); return Ok(()); @@ -1488,7 +1496,7 @@ where *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let status = if filled_qty < requested_qty { - OrderStatus::PartiallyFilled + final_partial_fill_status(partial_fill_reason.as_deref()) } else { OrderStatus::Filled }; @@ -1500,6 +1508,14 @@ where "order_partial_fill symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}" )); format!("{reason}: partial fill due to {detail}") + } else if status == OrderStatus::Canceled && filled_qty < requested_qty { + let detail = partial_fill_reason + .as_deref() + .unwrap_or("remaining quantity could not be filled"); + report.diagnostics.push(format!( + "order_remainder_canceled symbol={symbol} side=buy requested={requested_qty} filled={filled_qty} reason={detail}" + )); + format!("{reason}: partial fill due to {detail}; remaining quantity canceled") } else { reason.to_string() }; @@ -2011,6 +2027,26 @@ fn merge_partial_fill_reason(current: Option, next: Option<&str>) -> Opt } } +fn zero_fill_status_for_reason(reason: &str) -> OrderStatus { + match reason { + "tick no volume" | "tick volume limit" => OrderStatus::Canceled, + _ => OrderStatus::Rejected, + } +} + +fn final_partial_fill_status(partial_reason: Option<&str>) -> OrderStatus { + match partial_reason { + Some(reason) + if reason.contains("market liquidity or volume limit") + || reason.contains("intraday quote liquidity exhausted") + || reason.contains("no execution quotes after start") => + { + OrderStatus::Canceled + } + _ => OrderStatus::PartiallyFilled, + } +} + 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 e014d6e..0ec5481 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -511,7 +511,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() { } #[test] -fn broker_emits_partial_fill_reason_when_intraday_quote_liquidity_exhausted() { +fn broker_cancels_market_order_remainder_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 { @@ -623,21 +623,122 @@ fn broker_emits_partial_fill_reason_when_intraday_quote_liquidity_exhausted() { 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") + fidc_core::OrderStatus::Canceled ); + assert!(report.order_events[0].reason.contains( + "partial fill due to intraday quote liquidity exhausted; remaining quantity canceled" + )); assert!( report .diagnostics .iter() - .any(|item| item.contains("order_partial_fill symbol=000002.SZ side=buy")) + .any(|item| item.contains("order_remainder_canceled symbol=000002.SZ side=buy")) ); } +#[test] +fn broker_cancels_market_buy_when_tick_has_no_volume() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components( + 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.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 0, + tick_volume: 0, + bid1_volume: 0, + ask1_volume: 0, + 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, + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + + 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: 10_000.0, + reason: "no_volume_buy".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.fill_events.len(), 0); + assert_eq!(report.order_events.len(), 1); + assert_eq!( + report.order_events[0].status, + fidc_core::OrderStatus::Canceled + ); + assert!(report.order_events[0].reason.contains("tick no volume")); +} + #[test] fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();