From f65267accfb2076f7fa4146dd989bc2ffa594627 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 03:17:09 -0700 Subject: [PATCH] Reserve sellable quantity for open orders --- crates/fidc-core/src/broker.rs | 27 +++- crates/fidc-core/tests/explicit_order_flow.rs | 116 ++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 0a07123..688053e 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -739,6 +739,19 @@ where .retain(|existing| existing.order_id != order_id); } + fn reserved_open_sell_quantity(&self, symbol: &str, exclude_order_id: Option) -> u32 { + self.open_orders + .borrow() + .iter() + .filter(|order| { + order.side == OrderSide::Sell + && order.symbol == symbol + && exclude_order_id.is_none_or(|order_id| order.order_id != order_id) + }) + .map(|order| order.remaining_quantity) + .sum() + } + fn process_open_orders( &self, date: NaiveDate, @@ -1154,7 +1167,9 @@ where if !rule.allowed { return current_qty; } - let sellable = position.sellable_qty(date); + let sellable = position + .sellable_qty(date) + .saturating_sub(self.reserved_open_sell_quantity(symbol, None)); let sell_limit = match self.market_fillable_quantity( snapshot, OrderSide::Sell, @@ -1242,7 +1257,9 @@ where if !rule.allowed { return rule.reason; } - let sellable = position.sellable_qty(date); + let sellable = position + .sellable_qty(date) + .saturating_sub(self.reserved_open_sell_quantity(symbol, None)); match self.market_fillable_quantity( snapshot, OrderSide::Sell, @@ -1397,7 +1414,9 @@ where ); } - let sellable = position.sellable_qty(date); + let sellable = position + .sellable_qty(date) + .saturating_sub(self.reserved_open_sell_quantity(symbol, Some(order_id))); let mut partial_fill_reason = if sellable < requested_qty { Some("sellable quantity limit".to_string()) } else { @@ -1415,7 +1434,7 @@ where let fillable_qty = match market_limited_qty { Ok(quantity) => { let quantity = quantity.min(sellable); - if quantity < requested_qty { + if quantity < requested_qty.min(sellable) { partial_fill_reason = merge_partial_fill_reason( partial_fill_reason, Some("market liquidity or volume limit"), diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 8a07c27..e646fb6 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -2864,3 +2864,119 @@ fn broker_cancels_open_order_by_order_id() { ); assert!(portfolio.position("000002.SZ").is_none()); } + +#[test] +fn broker_reserves_sellable_quantity_for_open_limit_sells() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).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 09:30: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, + }], + ) + .expect("dataset"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut portfolio = PortfolioState::new(1_000_000.0); + portfolio + .position_mut("000002.SZ") + .buy(prev_date, 300, 10.0); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![ + OrderIntent::LimitShares { + symbol: "000002.SZ".to_string(), + quantity: -200, + limit_price: 10.5, + reason: "reserve_sell".to_string(), + }, + OrderIntent::Shares { + symbol: "000002.SZ".to_string(), + quantity: -200, + reason: "second_sell".to_string(), + }, + ], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.order_events.len(), 2); + assert_eq!(report.order_events[0].status, OrderStatus::Pending); + assert_eq!(report.order_events[1].status, OrderStatus::PartiallyFilled); + assert_eq!(report.order_events[1].filled_quantity, 100); + assert_eq!( + portfolio.position("000002.SZ").expect("position").quantity, + 200 + ); +}