diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index bac5043..dba6e7f 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -549,6 +549,7 @@ where sellable.min(current_qty), round_lot, 0, + sellable >= current_qty, ) { Ok(quantity) => quantity.min(sellable).min(current_qty), Err(_) => 0, @@ -577,11 +578,17 @@ where if !rule.allowed { return current_qty; } - let additional_limit = - match self.market_fillable_quantity(snapshot, OrderSide::Buy, u32::MAX, round_lot, 0) { - Ok(quantity) => quantity, - Err(_) => 0, - }; + let additional_limit = match self.market_fillable_quantity( + snapshot, + OrderSide::Buy, + u32::MAX, + round_lot, + 0, + false, + ) { + Ok(quantity) => quantity, + Err(_) => 0, + }; current_qty.saturating_add(additional_limit) } @@ -655,6 +662,7 @@ where requested_qty.min(sellable), self.round_lot(data, symbol), *intraday_turnover.get(symbol).unwrap_or(&0), + requested_qty >= position.quantity && sellable >= position.quantity, ); let filled_qty = match market_limited_qty { Ok(quantity) => quantity.min(sellable), @@ -693,6 +701,7 @@ where data, filled_qty, self.round_lot(data, symbol), + filled_qty >= position.quantity, execution_cursors, None, None, @@ -979,6 +988,7 @@ where requested_qty, self.round_lot(data, symbol), *intraday_turnover.get(symbol).unwrap_or(&0), + false, ); let constrained_qty = match market_limited_qty { Ok(quantity) => quantity, @@ -1004,6 +1014,7 @@ where data, constrained_qty, self.round_lot(data, symbol), + false, execution_cursors, None, Some(portfolio.cash()), @@ -1176,6 +1187,7 @@ where requested_qty: u32, round_lot: u32, consumed_turnover: u32, + allow_odd_lot_sell: bool, ) -> Result { if requested_qty == 0 { return Ok(0); @@ -1197,7 +1209,12 @@ where if top_level_liquidity == 0 { return Err("no quote liquidity".to_string()); } - max_fill = max_fill.min(self.round_buy_quantity(top_level_liquidity, lot)); + let top_level_limit = if side == OrderSide::Sell && allow_odd_lot_sell { + top_level_liquidity + } else { + self.round_buy_quantity(top_level_liquidity, lot) + }; + max_fill = max_fill.min(top_level_limit); } if self.volume_limit { @@ -1206,7 +1223,11 @@ where if raw_limit <= 0 { return Err("tick volume limit".to_string()); } - let volume_limited = self.round_buy_quantity(raw_limit as u32, lot); + let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell { + raw_limit as u32 + } else { + self.round_buy_quantity(raw_limit as u32, lot) + }; if volume_limited == 0 { return Err("tick volume limit".to_string()); } @@ -1225,6 +1246,7 @@ where data: &DataSet, requested_qty: u32, round_lot: u32, + allow_odd_lot_sell: bool, _execution_cursors: &mut BTreeMap, _global_execution_cursor: Option, cash_limit: Option, @@ -1273,6 +1295,7 @@ where start_cursor, requested_qty, round_lot, + allow_odd_lot_sell, cash_limit, gross_limit, ) { @@ -1290,6 +1313,7 @@ where start_cursor: Option, requested_qty: u32, round_lot: u32, + allow_odd_lot_sell: bool, cash_limit: Option, gross_limit: Option, ) -> Option { @@ -1332,7 +1356,9 @@ where break; } let mut take_qty = remaining_qty.min(available_qty); - take_qty = self.round_buy_quantity(take_qty, lot); + if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) { + take_qty = self.round_buy_quantity(take_qty, lot); + } if take_qty == 0 { continue; } diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index fec92af..1aab77d 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -575,6 +575,110 @@ fn broker_uses_instrument_round_lot_for_buy_sizing() { assert_eq!(report.fill_events[0].quantity, 1000); } +#[test] +fn broker_allows_full_odd_lot_sell_when_liquidating_position() { + let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap(); + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let symbol = "000001.SZ"; + let data = DataSet::from_components( + 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("2024-01-10 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.1, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 10_000, + tick_volume: 1_400, + bid1_volume: 350, + ask1_volume: 10_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: 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_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); + portfolio.position_mut(symbol).buy(prev_date, 350, 10.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::TargetValue { + symbol: symbol.to_string(), + target_value: 0.0, + reason: "odd_lot_liquidation".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].side, fidc_core::OrderSide::Sell); + assert_eq!(report.fill_events[0].quantity, 350); + assert!(portfolio.position(symbol).is_none()); +} + #[test] fn same_day_sell_then_rebuy_reinserts_position_at_end() { let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();