From 14326c0847ac818ebf762083bc97ff8cf28a9f58 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 03:13:26 -0700 Subject: [PATCH] Add persistent limit orders and cancel semantics --- crates/fidc-core/src/broker.rs | 861 +++++++++++++++--- crates/fidc-core/src/events.rs | 1 + crates/fidc-core/src/strategy.rs | 17 + crates/fidc-core/tests/explicit_order_flow.rs | 257 +++++- 4 files changed, 1020 insertions(+), 116 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 9ecc3fc..0a07123 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -1,3 +1,4 @@ +use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, BTreeSet}; use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime}; @@ -37,6 +38,16 @@ struct ExecutionFill { unfilled_reason: Option<&'static str>, } +#[derive(Debug, Clone)] +struct OpenOrder { + order_id: u64, + symbol: String, + side: OrderSide, + remaining_quantity: u32, + limit_price: f64, + reason: String, +} + #[derive(Debug, Clone)] struct TargetConstraint { symbol: String, @@ -81,6 +92,8 @@ pub struct BrokerSimulator { inactive_limit: bool, liquidity_limit: bool, intraday_execution_start_time: Option, + next_order_id: Cell, + open_orders: RefCell>, } impl BrokerSimulator { @@ -97,6 +110,8 @@ impl BrokerSimulator { inactive_limit: true, liquidity_limit: true, intraday_execution_start_time: None, + next_order_id: Cell::new(1), + open_orders: RefCell::new(Vec::new()), } } @@ -117,6 +132,8 @@ impl BrokerSimulator { inactive_limit: true, liquidity_limit: true, intraday_execution_start_time: None, + next_order_id: Cell::new(1), + open_orders: RefCell::new(Vec::new()), } } @@ -340,7 +357,16 @@ where let mut execution_cursors = BTreeMap::::new(); let mut global_execution_cursor = None::; let mut commission_state = BTreeMap::::new(); - let mut next_order_id = 1_u64; + self.process_open_orders( + date, + portfolio, + data, + &mut intraday_turnover, + &mut execution_cursors, + &mut global_execution_cursor, + &mut commission_state, + &mut report, + )?; if !decision.order_intents.is_empty() { for intent in &decision.order_intents { self.process_order_intent( @@ -352,7 +378,6 @@ where &mut execution_cursors, &mut global_execution_cursor, &mut commission_state, - &mut next_order_id, &mut report, )?; } @@ -397,12 +422,15 @@ where data, &symbol, requested_qty, - Self::reserve_order_id(&mut next_order_id), + self.reserve_order_id(), sell_reason(decision, &symbol), &mut intraday_turnover, &mut execution_cursors, &mut global_execution_cursor, &mut commission_state, + None, + false, + true, &mut report, )?; } @@ -422,13 +450,16 @@ where data, &symbol, requested_qty, - Self::reserve_order_id(&mut next_order_id), + self.reserve_order_id(), "rebalance_buy", &mut intraday_turnover, &mut execution_cursors, &mut global_execution_cursor, &mut commission_state, None, + None, + false, + true, &mut report, )?; } @@ -449,7 +480,6 @@ where execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, - next_order_id: &mut u64, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { match intent { @@ -463,7 +493,25 @@ where data, symbol, *quantity, - next_order_id, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), + OrderIntent::LimitShares { + symbol, + quantity, + limit_price, + reason, + } => self.process_limit_shares( + date, + portfolio, + data, + symbol, + *quantity, + *limit_price, reason, intraday_turnover, execution_cursors, @@ -481,7 +529,6 @@ where data, symbol, *lots, - next_order_id, reason, intraday_turnover, execution_cursors, @@ -499,7 +546,6 @@ where data, symbol, *target_value, - next_order_id, reason, intraday_turnover, execution_cursors, @@ -517,7 +563,6 @@ where data, symbol, *value, - next_order_id, reason, intraday_turnover, execution_cursors, @@ -535,7 +580,6 @@ where data, symbol, *percent, - next_order_id, reason, intraday_turnover, execution_cursors, @@ -553,7 +597,6 @@ where data, symbol, *target_percent, - next_order_id, reason, intraday_turnover, execution_cursors, @@ -561,15 +604,274 @@ where commission_state, report, ), + OrderIntent::CancelOrder { order_id, reason } => { + self.cancel_open_order(date, *order_id, reason, report); + Ok(()) + } + OrderIntent::CancelSymbol { symbol, reason } => { + self.cancel_open_orders_for_symbol(date, symbol, reason, report); + Ok(()) + } + OrderIntent::CancelAll { reason } => { + self.cancel_all_open_orders(date, reason, report); + Ok(()) + } } } - fn reserve_order_id(next_order_id: &mut u64) -> u64 { - let order_id = *next_order_id; - *next_order_id = next_order_id.saturating_add(1); + fn process_limit_shares( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + quantity: i32, + limit_price: f64, + reason: &str, + intraday_turnover: &mut BTreeMap, + execution_cursors: &mut BTreeMap, + global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, + report: &mut BrokerExecutionReport, + ) -> Result<(), BacktestError> { + self.process_limit_shares_internal( + date, + portfolio, + data, + symbol, + quantity, + limit_price, + reason, + None, + true, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ) + } + + fn process_limit_shares_internal( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + quantity: i32, + limit_price: f64, + reason: &str, + existing_order_id: Option, + emit_creation_events: bool, + intraday_turnover: &mut BTreeMap, + execution_cursors: &mut BTreeMap, + global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, + report: &mut BrokerExecutionReport, + ) -> Result<(), BacktestError> { + if quantity == 0 { + return Ok(()); + } + let order_id = existing_order_id.unwrap_or_else(|| self.reserve_order_id()); + if quantity > 0 { + let requested_qty = self.round_buy_quantity( + quantity as u32, + self.minimum_order_quantity(data, symbol), + self.order_step_size(data, symbol), + ); + if requested_qty == 0 { + return Ok(()); + } + self.process_buy( + date, + portfolio, + data, + symbol, + requested_qty, + order_id, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + None, + Some(limit_price), + true, + emit_creation_events, + report, + ) + } else { + self.process_sell( + date, + portfolio, + data, + symbol, + quantity.unsigned_abs(), + order_id, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + Some(limit_price), + true, + emit_creation_events, + report, + ) + } + } + + fn reserve_order_id(&self) -> u64 { + let order_id = self.next_order_id.get(); + self.next_order_id.set(order_id.saturating_add(1)); order_id } + fn upsert_open_order(&self, open_order: OpenOrder) { + let mut open_orders = self.open_orders.borrow_mut(); + open_orders.retain(|existing| existing.order_id != open_order.order_id); + open_orders.push(open_order); + } + + fn clear_open_order(&self, order_id: u64) { + self.open_orders + .borrow_mut() + .retain(|existing| existing.order_id != order_id); + } + + fn process_open_orders( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + intraday_turnover: &mut BTreeMap, + execution_cursors: &mut BTreeMap, + global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, + report: &mut BrokerExecutionReport, + ) -> Result<(), BacktestError> { + let pending_orders = { + let mut open_orders = self.open_orders.borrow_mut(); + std::mem::take(&mut *open_orders) + }; + for order in pending_orders { + let signed_quantity = if order.side == OrderSide::Buy { + order.remaining_quantity as i32 + } else { + -(order.remaining_quantity as i32) + }; + self.process_limit_shares_internal( + date, + portfolio, + data, + &order.symbol, + signed_quantity, + order.limit_price, + &order.reason, + Some(order.order_id), + false, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + )?; + } + Ok(()) + } + + fn cancel_open_order( + &self, + date: NaiveDate, + order_id: u64, + reason: &str, + report: &mut BrokerExecutionReport, + ) { + let canceled = { + let mut open_orders = self.open_orders.borrow_mut(); + if let Some(index) = open_orders + .iter() + .position(|order| order.order_id == order_id) + { + Some(open_orders.remove(index)) + } else { + None + } + }; + if let Some(order) = canceled { + self.emit_canceled_open_order(date, order, reason, report); + } + } + + fn cancel_open_orders_for_symbol( + &self, + date: NaiveDate, + symbol: &str, + reason: &str, + report: &mut BrokerExecutionReport, + ) { + let canceled = { + let mut open_orders = self.open_orders.borrow_mut(); + let mut canceled = Vec::new(); + let mut retained = Vec::with_capacity(open_orders.len()); + for order in open_orders.drain(..) { + if order.symbol == symbol { + canceled.push(order); + } else { + retained.push(order); + } + } + *open_orders = retained; + canceled + }; + for order in canceled { + self.emit_canceled_open_order(date, order, reason, report); + } + } + + fn cancel_all_open_orders( + &self, + date: NaiveDate, + reason: &str, + report: &mut BrokerExecutionReport, + ) { + let canceled = { + let mut open_orders = self.open_orders.borrow_mut(); + std::mem::take(&mut *open_orders) + }; + for order in canceled { + self.emit_canceled_open_order(date, order, reason, report); + } + } + + fn emit_canceled_open_order( + &self, + date: NaiveDate, + order: OpenOrder, + reason: &str, + report: &mut BrokerExecutionReport, + ) { + report.order_events.push(OrderEvent { + date, + order_id: Some(order.order_id), + symbol: order.symbol.clone(), + side: order.side, + requested_quantity: order.remaining_quantity, + filled_quantity: 0, + status: OrderStatus::Canceled, + reason: format!("{reason}: canceled open order"), + }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order.order_id, + &order.symbol, + order.side, + "status=Canceled reason=canceled open order", + ); + } + fn emit_order_process_event( report: &mut BrokerExecutionReport, date: NaiveDate, @@ -1022,6 +1324,9 @@ where execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, + limit_price: Option, + allow_pending_limit: bool, + emit_creation_events: bool, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let snapshot = data.require_market(date, symbol)?; @@ -1030,15 +1335,17 @@ where return Ok(()); }; - Self::emit_order_process_event( - report, - date, - ProcessEventKind::OrderPendingNew, - order_id, - symbol, - OrderSide::Sell, - format!("requested_quantity={requested_qty} reason={reason}"), - ); + if emit_creation_events { + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderPendingNew, + order_id, + symbol, + OrderSide::Sell, + format!("requested_quantity={requested_qty} reason={reason}"), + ); + } let rule = self.rules.can_sell( date, @@ -1074,18 +1381,21 @@ where OrderSide::Sell, format!("status={status:?} reason={rule_reason}"), ); + self.clear_open_order(order_id); return Ok(()); } - Self::emit_order_process_event( - report, - date, - ProcessEventKind::OrderCreationPass, - order_id, - symbol, - OrderSide::Sell, - "sell order passed rule checks", - ); + if emit_creation_events { + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderCreationPass, + order_id, + symbol, + OrderSide::Sell, + "sell order passed rule checks", + ); + } let sellable = position.sellable_qty(date); let mut partial_fill_reason = if sellable < requested_qty { @@ -1102,7 +1412,7 @@ where *intraday_turnover.get(symbol).unwrap_or(&0), requested_qty >= position.quantity && sellable >= position.quantity, ); - let filled_qty = match market_limited_qty { + let fillable_qty = match market_limited_qty { Ok(quantity) => { let quantity = quantity.min(sellable); if quantity < requested_qty { @@ -1114,6 +1424,36 @@ where quantity } Err(limit_reason) => { + if allow_pending_limit { + self.upsert_open_order(OpenOrder { + order_id, + symbol: symbol.to_string(), + side: OrderSide::Sell, + remaining_quantity: requested_qty, + limit_price: limit_price.expect("limit price for pending limit sell"), + reason: reason.to_string(), + }); + report.order_events.push(OrderEvent { + date, + order_id: Some(order_id), + symbol: symbol.to_string(), + side: OrderSide::Sell, + requested_quantity: requested_qty, + filled_quantity: 0, + status: OrderStatus::Pending, + reason: format!("{reason}: pending due to {limit_reason}"), + }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Sell, + format!("status=Pending reason={limit_reason}"), + ); + return Ok(()); + } report.order_events.push(OrderEvent { date, order_id: Some(order_id), @@ -1139,7 +1479,40 @@ where return Ok(()); } }; - if filled_qty == 0 { + if fillable_qty == 0 { + if allow_pending_limit { + let detail = partial_fill_reason + .as_deref() + .unwrap_or("no sellable quantity"); + self.upsert_open_order(OpenOrder { + order_id, + symbol: symbol.to_string(), + side: OrderSide::Sell, + remaining_quantity: requested_qty, + limit_price: limit_price.expect("limit price for pending limit sell"), + reason: reason.to_string(), + }); + report.order_events.push(OrderEvent { + date, + order_id: Some(order_id), + symbol: symbol.to_string(), + side: OrderSide::Sell, + requested_quantity: requested_qty, + filled_quantity: 0, + status: OrderStatus::Pending, + reason: format!("{reason}: pending due to {detail}"), + }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Sell, + format!("status=Pending reason={detail}"), + ); + return Ok(()); + } report.order_events.push(OrderEvent { date, order_id: Some(order_id), @@ -1168,15 +1541,16 @@ where OrderSide::Sell, snapshot, data, - filled_qty, + fillable_qty, self.round_lot(data, symbol), self.minimum_order_quantity(data, symbol), self.order_step_size(data, symbol), - filled_qty >= position.quantity, + fillable_qty >= position.quantity, execution_cursors, None, None, None, + limit_price, ); let (filled_qty, execution_legs) = if let Some(fill) = fill { execution_cursors.insert(symbol.to_string(), fill.next_cursor); @@ -1187,14 +1561,87 @@ where merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); (fill.quantity, fill.legs) } else { - ( - filled_qty, - vec![ExecutionLeg { - price: self.sell_price(snapshot), - quantity: filled_qty, - }], - ) + let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Sell); + if !self.price_satisfies_limit( + OrderSide::Sell, + execution_price, + limit_price, + snapshot.effective_price_tick(), + ) { + partial_fill_reason = merge_partial_fill_reason( + partial_fill_reason, + Some("limit price not marketable yet"), + ); + (0, Vec::new()) + } else { + ( + fillable_qty, + vec![ExecutionLeg { + price: execution_price, + quantity: fillable_qty, + }], + ) + } }; + if filled_qty == 0 { + let detail = partial_fill_reason + .as_deref() + .unwrap_or("limit price not marketable yet"); + if allow_pending_limit && Self::limit_order_can_remain_open(Some(detail)) { + self.upsert_open_order(OpenOrder { + order_id, + symbol: symbol.to_string(), + side: OrderSide::Sell, + remaining_quantity: requested_qty, + limit_price: limit_price.expect("limit price for pending limit sell"), + reason: reason.to_string(), + }); + report.order_events.push(OrderEvent { + date, + order_id: Some(order_id), + symbol: symbol.to_string(), + side: OrderSide::Sell, + requested_quantity: requested_qty, + filled_quantity: 0, + status: OrderStatus::Pending, + reason: format!("{reason}: pending due to {detail}"), + }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Sell, + format!("status=Pending reason={detail}"), + ); + return Ok(()); + } + report.order_events.push(OrderEvent { + date, + order_id: Some(order_id), + symbol: symbol.to_string(), + side: OrderSide::Sell, + requested_quantity: requested_qty, + filled_quantity: 0, + status: zero_fill_status_for_reason(detail), + reason: format!("{reason}: {detail}"), + }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Sell, + format!( + "status={:?} reason={detail}", + zero_fill_status_for_reason(detail) + ), + ); + self.clear_open_order(order_id); + return Ok(()); + } if execution_legs.len() > 1 { report.diagnostics.push(format!( "order_split_fill symbol={symbol} side=sell order_id={order_id} fills={}", @@ -1271,12 +1718,39 @@ where portfolio.prune_flat_positions(); *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; - let status = if filled_qty < requested_qty { + let remaining_qty = requested_qty.saturating_sub(filled_qty); + let keep_open = allow_pending_limit + && remaining_qty > 0 + && Self::limit_order_can_remain_open(partial_fill_reason.as_deref()); + if keep_open { + self.upsert_open_order(OpenOrder { + order_id, + symbol: symbol.to_string(), + side: OrderSide::Sell, + remaining_quantity: remaining_qty, + limit_price: limit_price.expect("limit price for pending limit sell"), + reason: reason.to_string(), + }); + } else { + self.clear_open_order(order_id); + } + + let status = if keep_open { + OrderStatus::PartiallyFilled + } else if filled_qty < requested_qty { final_partial_fill_status(partial_fill_reason.as_deref()) } else { OrderStatus::Filled }; - let order_reason = if status == OrderStatus::PartiallyFilled { + let order_reason = if keep_open { + 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}; remaining open" + )); + format!("{reason}: partial fill due to {detail}; remaining quantity pending") + } else if status == OrderStatus::PartiallyFilled { let detail = partial_fill_reason .as_deref() .unwrap_or("remaining quantity could not be filled"); @@ -1327,7 +1801,6 @@ where data: &DataSet, symbol: &str, target_value: f64, - next_order_id: &mut u64, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, @@ -1361,12 +1834,15 @@ where data, symbol, current_qty - target_qty, - Self::reserve_order_id(next_order_id), + self.reserve_order_id(), reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, + None, + false, + true, report, )?; } else if target_qty > current_qty { @@ -1376,13 +1852,16 @@ where data, symbol, target_qty - current_qty, - Self::reserve_order_id(next_order_id), + self.reserve_order_id(), reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, None, + None, + false, + true, report, )?; } else if (current_value - target_value).abs() <= f64::EPSILON { @@ -1412,7 +1891,6 @@ where data: &DataSet, symbol: &str, target_percent: f64, - next_order_id: &mut u64, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, @@ -1427,7 +1905,6 @@ where data, symbol, total_equity * target_percent.max(0.0), - next_order_id, reason, intraday_turnover, execution_cursors, @@ -1444,7 +1921,6 @@ where data: &DataSet, symbol: &str, value: f64, - next_order_id: &mut u64, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, @@ -1490,13 +1966,16 @@ where data, symbol, requested_qty, - Self::reserve_order_id(next_order_id), + self.reserve_order_id(), reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, Some(value.abs()), + None, + false, + true, report, ) } else { @@ -1512,12 +1991,15 @@ where data, symbol, requested_qty, - Self::reserve_order_id(next_order_id), + self.reserve_order_id(), reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, + None, + false, + true, report, ) } @@ -1530,7 +2012,6 @@ where data: &DataSet, symbol: &str, percent: f64, - next_order_id: &mut u64, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, @@ -1545,7 +2026,6 @@ where data, symbol, total_equity * percent, - next_order_id, reason, intraday_turnover, execution_cursors, @@ -1562,7 +2042,6 @@ where data: &DataSet, symbol: &str, quantity: i32, - next_order_id: &mut u64, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, @@ -1585,13 +2064,16 @@ where data, symbol, requested_qty, - Self::reserve_order_id(next_order_id), + self.reserve_order_id(), reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, None, + None, + false, + true, report, ) } else { @@ -1601,12 +2083,15 @@ where data, symbol, quantity.unsigned_abs(), - Self::reserve_order_id(next_order_id), + self.reserve_order_id(), reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, + None, + false, + true, report, ) } @@ -1619,7 +2104,6 @@ where data: &DataSet, symbol: &str, lots: i32, - next_order_id: &mut u64, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, @@ -1640,7 +2124,6 @@ where data, symbol, signed_quantity, - next_order_id, reason, intraday_turnover, execution_cursors, @@ -1680,20 +2163,25 @@ where global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, value_budget: Option, + limit_price: Option, + allow_pending_limit: bool, + emit_creation_events: bool, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let snapshot = data.require_market(date, symbol)?; let candidate = data.require_candidate(date, symbol)?; - Self::emit_order_process_event( - report, - date, - ProcessEventKind::OrderPendingNew, - order_id, - symbol, - OrderSide::Buy, - format!("requested_quantity={requested_qty} reason={reason}"), - ); + if emit_creation_events { + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderPendingNew, + order_id, + symbol, + OrderSide::Buy, + format!("requested_quantity={requested_qty} reason={reason}"), + ); + } let rule = self .rules @@ -1725,18 +2213,21 @@ where OrderSide::Buy, format!("status={status:?} reason={rule_reason}"), ); + self.clear_open_order(order_id); return Ok(()); } - Self::emit_order_process_event( - report, - date, - ProcessEventKind::OrderCreationPass, - order_id, - symbol, - OrderSide::Buy, - "buy order passed rule checks", - ); + if emit_creation_events { + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderCreationPass, + order_id, + symbol, + OrderSide::Buy, + "buy order passed rule checks", + ); + } let mut partial_fill_reason = None; let market_limited_qty = self.market_fillable_quantity( @@ -1756,6 +2247,36 @@ where quantity } Err(limit_reason) => { + if allow_pending_limit { + self.upsert_open_order(OpenOrder { + order_id, + symbol: symbol.to_string(), + side: OrderSide::Buy, + remaining_quantity: requested_qty, + limit_price: limit_price.expect("limit price for pending limit buy"), + reason: reason.to_string(), + }); + report.order_events.push(OrderEvent { + date, + order_id: Some(order_id), + symbol: symbol.to_string(), + side: OrderSide::Buy, + requested_quantity: requested_qty, + filled_quantity: 0, + status: OrderStatus::Pending, + reason: format!("{reason}: pending due to {limit_reason}"), + }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Buy, + format!("status=Pending reason={limit_reason}"), + ); + return Ok(()); + } report.order_events.push(OrderEvent { date, order_id: Some(order_id), @@ -1797,6 +2318,7 @@ where None, Some(portfolio.cash()), value_budget.map(|budget| budget + 400.0), + limit_price, ); let (filled_qty, execution_legs) = if let Some(fill) = fill { execution_cursors.insert(symbol.to_string(), fill.next_cursor); @@ -1808,36 +2330,82 @@ where (fill.quantity, fill.legs) } else { let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy); - let filled_qty = self.affordable_buy_quantity( - date, - portfolio.cash(), - value_budget.map(|budget| budget + 400.0), + if !self.price_satisfies_limit( + OrderSide::Buy, execution_price, - constrained_qty, - self.minimum_order_quantity(data, symbol), - self.order_step_size(data, symbol), - ); - if filled_qty < constrained_qty { + limit_price, + snapshot.effective_price_tick(), + ) { 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, - ), + Some("limit price not marketable yet"), ); + (0, Vec::new()) + } else { + let filled_qty = self.affordable_buy_quantity( + date, + portfolio.cash(), + value_budget.map(|budget| budget + 400.0), + execution_price, + constrained_qty, + 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, + vec![ExecutionLeg { + price: execution_price, + quantity: filled_qty, + }], + ) } - ( - filled_qty, - vec![ExecutionLeg { - price: execution_price, - quantity: filled_qty, - }], - ) }; if filled_qty == 0 { + let detail = partial_fill_reason + .as_deref() + .unwrap_or("insufficient cash after fees"); + if allow_pending_limit && Self::limit_order_can_remain_open(Some(detail)) { + self.upsert_open_order(OpenOrder { + order_id, + symbol: symbol.to_string(), + side: OrderSide::Buy, + remaining_quantity: requested_qty, + limit_price: limit_price.expect("limit price for pending limit buy"), + reason: reason.to_string(), + }); + report.order_events.push(OrderEvent { + date, + order_id: Some(order_id), + symbol: symbol.to_string(), + side: OrderSide::Buy, + requested_quantity: requested_qty, + filled_quantity: 0, + status: OrderStatus::Pending, + reason: format!("{reason}: pending due to {detail}"), + }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + OrderSide::Buy, + format!("status=Pending reason={detail}"), + ); + return Ok(()); + } report.order_events.push(OrderEvent { date, order_id: Some(order_id), @@ -1846,12 +2414,7 @@ where requested_quantity: requested_qty, filled_quantity: 0, status: OrderStatus::Rejected, - reason: format!( - "{reason}: {}", - partial_fill_reason - .as_deref() - .unwrap_or("insufficient cash after fees") - ), + reason: format!("{reason}: {detail}"), }); Self::emit_order_process_event( report, @@ -1860,13 +2423,9 @@ where order_id, symbol, OrderSide::Buy, - format!( - "status=Rejected reason={}", - partial_fill_reason - .as_deref() - .unwrap_or("insufficient cash after fees") - ), + format!("status=Rejected reason={detail}"), ); + self.clear_open_order(order_id); return Ok(()); } @@ -1945,12 +2504,39 @@ where } *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; - let status = if filled_qty < requested_qty { + let remaining_qty = requested_qty.saturating_sub(filled_qty); + let keep_open = allow_pending_limit + && remaining_qty > 0 + && Self::limit_order_can_remain_open(partial_fill_reason.as_deref()); + if keep_open { + self.upsert_open_order(OpenOrder { + order_id, + symbol: symbol.to_string(), + side: OrderSide::Buy, + remaining_quantity: remaining_qty, + limit_price: limit_price.expect("limit price for pending limit buy"), + reason: reason.to_string(), + }); + } else { + self.clear_open_order(order_id); + } + + let status = if keep_open { + OrderStatus::PartiallyFilled + } else if filled_qty < requested_qty { final_partial_fill_status(partial_fill_reason.as_deref()) } else { OrderStatus::Filled }; - let order_reason = if status == OrderStatus::PartiallyFilled { + let order_reason = if keep_open { + 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}; remaining open" + )); + format!("{reason}: partial fill due to {detail}; remaining quantity pending") + } else if status == OrderStatus::PartiallyFilled { let detail = partial_fill_reason .as_deref() .unwrap_or("remaining quantity could not be filled"); @@ -2263,6 +2849,32 @@ where Ok(max_fill) } + fn price_satisfies_limit( + &self, + side: OrderSide, + execution_price: f64, + limit_price: Option, + price_tick: f64, + ) -> bool { + let Some(limit_price) = limit_price else { + return execution_price.is_finite() && execution_price > 0.0; + }; + if !execution_price.is_finite() || execution_price <= 0.0 { + return false; + } + let tolerance = price_tick.abs().max(1e-9); + match side { + OrderSide::Buy => execution_price <= limit_price + tolerance, + OrderSide::Sell => execution_price + tolerance >= limit_price, + } + } + + fn limit_order_can_remain_open(partial_reason: Option<&str>) -> bool { + !partial_reason.is_some_and(|reason| { + reason.contains("insufficient cash") || reason.contains("value budget") + }) + } + fn resolve_execution_fill( &self, date: NaiveDate, @@ -2279,6 +2891,7 @@ where _global_execution_cursor: Option, cash_limit: Option, gross_limit: Option, + limit_price: Option, ) -> Option { if self.execution_price_field != PriceField::Last { return None; @@ -2301,12 +2914,21 @@ where allow_odd_lot_sell, cash_limit, gross_limit, + limit_price, ) { return Some(fill); } if self.intraday_execution_start_time.is_some() { let execution_price = self.snapshot_execution_price(snapshot, side); + if !self.price_satisfies_limit( + side, + execution_price, + limit_price, + snapshot.effective_price_tick(), + ) { + return None; + } let quantity = match side { OrderSide::Buy => self.affordable_buy_quantity( date, @@ -2359,6 +2981,7 @@ where allow_odd_lot_sell: bool, cash_limit: Option, gross_limit: Option, + limit_price: Option, ) -> Option { if requested_qty == 0 { return None; @@ -2387,6 +3010,14 @@ where let Some(quote_price) = self.select_quote_reference_price(snapshot, quote, side) else { continue; }; + if !self.price_satisfies_limit( + side, + quote_price, + limit_price, + snapshot.effective_price_tick(), + ) { + continue; + } let top_level_liquidity = match side { OrderSide::Buy => quote.ask1_volume, OrderSide::Sell => quote.bid1_volume, diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 4c29345..9abdf23 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -31,6 +31,7 @@ pub enum OrderSide { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum OrderStatus { + Pending, Filled, PartiallyFilled, Canceled, diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index c4252b5..b7afbe2 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -89,6 +89,12 @@ pub enum OrderIntent { quantity: i32, reason: String, }, + LimitShares { + symbol: String, + quantity: i32, + limit_price: f64, + reason: String, + }, Lots { symbol: String, lots: i32, @@ -114,6 +120,17 @@ pub enum OrderIntent { target_percent: f64, reason: String, }, + CancelOrder { + order_id: u64, + reason: String, + }, + CancelSymbol { + symbol: String, + reason: String, + }, + CancelAll { + reason: String, + }, } #[derive(Debug, Clone)] diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 5bde8f4..8a07c27 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -2,7 +2,7 @@ use chrono::NaiveDate; use fidc_core::{ BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, - IntradayExecutionQuote, MatchingType, OrderIntent, PortfolioState, PriceField, + IntradayExecutionQuote, MatchingType, OrderIntent, OrderStatus, PortfolioState, PriceField, ProcessEventKind, SlippageModel, StrategyDecision, }; use std::collections::{BTreeMap, BTreeSet}; @@ -2609,3 +2609,258 @@ fn same_day_sell_then_rebuy_reinserts_position_at_end() { ] ); } + +fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet { + let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); + 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: day1, + symbol: "000002.SZ".to_string(), + timestamp: Some("2024-01-10 09:30:00".to_string()), + day_open: day1_open, + open: day1_open, + high: day1_open + 0.2, + low: day1_open - 0.2, + close: day1_open, + last_price: day1_open, + bid1: day1_open - 0.01, + ask1: day1_open + 0.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, + }, + DailyMarketSnapshot { + date: day2, + symbol: "000002.SZ".to_string(), + timestamp: Some("2024-01-11 09:30:00".to_string()), + day_open: day2_open, + open: day2_open, + high: day2_open + 0.2, + low: day2_open - 0.2, + close: day2_open, + last_price: day2_open, + bid1: day2_open - 0.01, + ask1: day2_open + 0.01, + prev_close: day1_open, + 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: day1, + 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(), + }, + DailyFactorSnapshot { + date: day2, + 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: day1, + 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, + }, + CandidateEligibility { + date: day2, + 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: day1, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: day2, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 100.0, + volume: 1_000_000, + }, + ], + ) + .expect("dataset") +} + +#[test] +fn broker_keeps_limit_buy_open_until_price_becomes_marketable() { + 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, 9.7); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let day1_report = broker + .execute( + day1, + &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: 9.8, + reason: "limit_entry".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("day1 execution"); + assert_eq!(day1_report.fill_events.len(), 0); + assert_eq!(day1_report.order_events.len(), 1); + assert_eq!(day1_report.order_events[0].status, OrderStatus::Pending); + let order_id = day1_report.order_events[0].order_id.expect("order id"); + + let day2_report = broker + .execute( + day2, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: Vec::new(), + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("day2 execution"); + assert_eq!(day2_report.fill_events.len(), 1); + assert_eq!(day2_report.fill_events[0].order_id, Some(order_id)); + assert_eq!(day2_report.order_events.len(), 1); + assert_eq!(day2_report.order_events[0].status, OrderStatus::Filled); + assert_eq!(day2_report.order_events[0].order_id, Some(order_id)); + assert_eq!( + portfolio.position("000002.SZ").expect("position").quantity, + 200 + ); +} + +#[test] +fn broker_cancels_open_order_by_order_id() { + 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.1); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let day1_report = broker + .execute( + day1, + &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: 9.8, + reason: "limit_entry".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("day1 execution"); + let order_id = day1_report.order_events[0].order_id.expect("order id"); + + let day2_report = broker + .execute( + day2, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::CancelOrder { + order_id, + reason: "user_cancel".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("day2 execution"); + + assert!(day2_report.fill_events.is_empty()); + assert!( + day2_report + .order_events + .iter() + .any(|event| event.order_id == Some(order_id) && event.status == OrderStatus::Canceled) + ); + assert!(portfolio.position("000002.SZ").is_none()); +}