From b657205103d788ef50e96f57047fe2e16008ef85 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 03:41:49 -0700 Subject: [PATCH] Align order lifecycle with rqalpha close semantics --- crates/fidc-core/src/broker.rs | 83 ++++++- crates/fidc-core/src/engine.rs | 17 +- crates/fidc-core/src/events.rs | 2 + crates/fidc-core/tests/engine_hooks.rs | 202 +++++++++++++++++- crates/fidc-core/tests/explicit_order_flow.rs | 37 ++-- 5 files changed, 314 insertions(+), 27 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 5560c05..506aad8 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -43,6 +43,8 @@ struct OpenOrder { order_id: u64, symbol: String, side: OrderSide, + requested_quantity: u32, + filled_quantity: u32, remaining_quantity: u32, limit_price: f64, reason: String, @@ -947,7 +949,7 @@ where } }; if let Some(order) = canceled { - self.emit_canceled_open_order(date, order, reason, report); + self.emit_user_canceled_open_order(date, order, reason, report); } } @@ -973,7 +975,7 @@ where canceled }; for order in canceled { - self.emit_canceled_open_order(date, order, reason, report); + self.emit_user_canceled_open_order(date, order, reason, report); } } @@ -988,38 +990,87 @@ where std::mem::take(&mut *open_orders) }; for order in canceled { - self.emit_canceled_open_order(date, order, reason, report); + self.emit_user_canceled_open_order(date, order, reason, report); } } - fn emit_canceled_open_order( + fn emit_user_canceled_open_order( &self, date: NaiveDate, order: OpenOrder, reason: &str, report: &mut BrokerExecutionReport, ) { + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderPendingCancel, + order.order_id, + &order.symbol, + order.side, + format!("reason={reason}"), + ); 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, + requested_quantity: order.requested_quantity, + filled_quantity: order.filled_quantity, status: OrderStatus::Canceled, - reason: format!("{reason}: canceled open order"), + reason: format!("{reason}: canceled by user"), }); Self::emit_order_process_event( report, date, - ProcessEventKind::OrderUnsolicitedUpdate, + ProcessEventKind::OrderCancellationPass, order.order_id, &order.symbol, order.side, - "status=Canceled reason=canceled open order", + format!( + "status=Canceled requested_quantity={} filled_quantity={}", + order.requested_quantity, order.filled_quantity + ), ); } + pub fn after_trading(&self, date: NaiveDate) -> BrokerExecutionReport { + let mut report = BrokerExecutionReport::default(); + let pending = { + let mut open_orders = self.open_orders.borrow_mut(); + std::mem::take(&mut *open_orders) + }; + for order in pending { + let market_close_reason = format!( + "Order Rejected: {} can not match. Market close.", + order.symbol + ); + report.order_events.push(OrderEvent { + date, + order_id: Some(order.order_id), + symbol: order.symbol.clone(), + side: order.side, + requested_quantity: order.requested_quantity, + filled_quantity: order.filled_quantity, + status: OrderStatus::Rejected, + reason: market_close_reason.clone(), + }); + Self::emit_order_process_event( + &mut report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order.order_id, + &order.symbol, + order.side, + format!( + "status=Rejected requested_quantity={} filled_quantity={} reason={market_close_reason}", + order.requested_quantity, order.filled_quantity + ), + ); + } + report + } + fn emit_order_process_event( report: &mut BrokerExecutionReport, date: NaiveDate, @@ -1583,6 +1634,8 @@ where order_id, symbol: symbol.to_string(), side: OrderSide::Sell, + requested_quantity: requested_qty, + filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit sell"), reason: reason.to_string(), @@ -1642,6 +1695,8 @@ where order_id, symbol: symbol.to_string(), side: OrderSide::Sell, + requested_quantity: requested_qty, + filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit sell"), reason: reason.to_string(), @@ -1748,6 +1803,8 @@ where order_id, symbol: symbol.to_string(), side: OrderSide::Sell, + requested_quantity: requested_qty, + filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit sell"), reason: reason.to_string(), @@ -1883,6 +1940,8 @@ where order_id, symbol: symbol.to_string(), side: OrderSide::Sell, + requested_quantity: requested_qty, + filled_quantity: filled_qty, remaining_quantity: remaining_qty, limit_price: limit_price.expect("limit price for pending limit sell"), reason: reason.to_string(), @@ -2639,6 +2698,8 @@ where order_id, symbol: symbol.to_string(), side: OrderSide::Buy, + requested_quantity: requested_qty, + filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit buy"), reason: reason.to_string(), @@ -2770,6 +2831,8 @@ where order_id, symbol: symbol.to_string(), side: OrderSide::Buy, + requested_quantity: requested_qty, + filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit buy"), reason: reason.to_string(), @@ -2902,6 +2965,8 @@ where order_id, symbol: symbol.to_string(), side: OrderSide::Buy, + requested_quantity: requested_qty, + filled_quantity: filled_qty, remaining_quantity: remaining_qty, limit_price: limit_price.expect("limit price for pending limit buy"), reason: reason.to_string(), diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 7983579..c4dc457 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -330,11 +330,6 @@ where ProcessEventKind::PostOnDay, "on_day:post", ); - let daily_fill_count = report.fill_events.len(); - let day_orders = report.order_events.clone(); - let day_fills = report.fill_events.clone(); - let broker_diagnostics = report.diagnostics.clone(); - self.extend_result(&mut result, report); portfolio.update_prices(execution_date, &self.data, PriceField::Close)?; @@ -358,6 +353,13 @@ where ProcessEventKind::AfterTrading, "after_trading", ); + let mut close_report = self.broker.after_trading(execution_date); + process_events.append(&mut close_report.process_events); + report.order_events.extend(close_report.order_events); + report.fill_events.extend(close_report.fill_events); + report.position_events.extend(close_report.position_events); + report.account_events.extend(close_report.account_events); + report.diagnostics.extend(close_report.diagnostics); push_phase_event( &mut process_events, execution_date, @@ -383,6 +385,11 @@ where ProcessEventKind::PostSettlement, "settlement:post", ); + let daily_fill_count = report.fill_events.len(); + let day_orders = report.order_events.clone(); + let day_fills = report.fill_events.clone(); + let broker_diagnostics = report.diagnostics.clone(); + self.extend_result(&mut result, report); let benchmark = self.data diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 9abdf23..a8c9e4d 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -110,6 +110,8 @@ pub enum ProcessEventKind { PostSettlement, OrderPendingNew, OrderCreationPass, + OrderPendingCancel, + OrderCancellationPass, OrderUnsolicitedUpdate, Trade, } diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index aed8282..d011f2c 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -6,7 +6,7 @@ use chrono::NaiveDate; use fidc_core::{ BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, - Instrument, PriceField, ProcessEventKind, ScheduleRule, ScheduleStage, Strategy, + Instrument, OrderIntent, PriceField, ProcessEventKind, ScheduleRule, ScheduleStage, Strategy, StrategyContext, StrategyDecision, }; @@ -120,6 +120,10 @@ struct ScheduledProbeStrategy { log: Rc>>, } +struct LimitCarryStrategy { + issued: bool, +} + impl Strategy for ScheduledProbeStrategy { fn name(&self) -> &str { "scheduled-probe" @@ -152,6 +156,35 @@ impl Strategy for ScheduledProbeStrategy { } } +impl Strategy for LimitCarryStrategy { + fn name(&self) -> &str { + "limit-carry" + } + + fn on_day( + &mut self, + _ctx: &StrategyContext<'_>, + ) -> Result { + if self.issued { + return Ok(StrategyDecision::default()); + } + self.issued = true; + Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::LimitShares { + symbol: "000001.SZ".to_string(), + quantity: 200, + limit_price: 9.8, + reason: "carry_limit".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }) + } +} + #[test] fn engine_runs_strategy_hooks_in_daily_order() { let date1 = d(2025, 1, 2); @@ -443,6 +476,173 @@ fn engine_executes_open_auction_decisions_before_on_day() { assert_eq!(result.fills[0].quantity, 100); } +#[test] +fn engine_rejects_pending_limit_orders_at_market_close() { + let date1 = d(2025, 1, 2); + let date2 = d(2025, 1, 3); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Anchor".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![ + DailyMarketSnapshot { + date: date1, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-01-02 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: 10.0, + ask1: 10.0, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date: date2, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-01-03 10:18:00".to_string()), + day_open: 9.7, + open: 9.7, + high: 9.8, + low: 9.6, + close: 9.7, + last_price: 9.7, + bid1: 9.7, + ask1: 9.7, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 10.67, + lower_limit: 9.0, + price_tick: 0.01, + }, + ], + vec![ + DailyFactorSnapshot { + date: date1, + symbol: "000001.SZ".to_string(), + market_cap_bn: 20.0, + free_float_cap_bn: 18.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date: date2, + symbol: "000001.SZ".to_string(), + market_cap_bn: 21.0, + free_float_cap_bn: 19.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + ], + vec![ + CandidateEligibility { + date: date1, + symbol: "000001.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: date2, + symbol: "000001.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: date1, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: date2, + benchmark: "000300.SH".to_string(), + open: 101.0, + close: 101.0, + prev_close: 100.0, + volume: 1_100_000, + }, + ], + ) + .expect("dataset"); + + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let strategy = LimitCarryStrategy { issued: false }; + let mut engine = BacktestEngine::new( + data, + strategy, + broker, + BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date1), + end_date: Some(date2), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ); + + let result = engine.run().expect("backtest run"); + assert!(result.fills.is_empty()); + assert!(result.holdings_summary.is_empty()); + assert!( + result.order_events.iter().any(|event| { + event.date == date1 && event.status == fidc_core::OrderStatus::Pending + }) + ); + assert!(result.order_events.iter().any(|event| { + event.date == date1 + && event.status == fidc_core::OrderStatus::Rejected + && event.reason.contains("Market close") + })); + assert!(result.process_events.iter().any(|event| { + event.date == date1 && event.kind == ProcessEventKind::OrderUnsolicitedUpdate + })); +} + #[test] fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() { let date1 = d(2025, 1, 30); diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index da96a0d..eea96f4 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -2740,7 +2740,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet { } #[test] -fn broker_keeps_limit_buy_open_until_price_becomes_marketable() { +fn broker_rejects_open_limit_buy_at_market_close() { 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); @@ -2776,6 +2776,20 @@ fn broker_keeps_limit_buy_open_until_price_becomes_marketable() { assert_eq!(day1_report.order_events[0].status, OrderStatus::Pending); let order_id = day1_report.order_events[0].order_id.expect("order id"); + let close_report = broker.after_trading(day1); + assert!(close_report.fill_events.is_empty()); + assert_eq!(close_report.order_events.len(), 1); + assert_eq!(close_report.order_events[0].order_id, Some(order_id)); + assert_eq!(close_report.order_events[0].status, OrderStatus::Rejected); + assert!( + close_report.order_events[0] + .reason + .contains("Order Rejected: 000002.SZ can not match. Market close.") + ); + assert!(close_report.process_events.iter().any(|event| { + event.kind == ProcessEventKind::OrderUnsolicitedUpdate && event.order_id == Some(order_id) + })); + let day2_report = broker .execute( day2, @@ -2791,15 +2805,9 @@ fn broker_keeps_limit_buy_open_until_price_becomes_marketable() { }, ) .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 - ); + assert!(day2_report.fill_events.is_empty()); + assert!(day2_report.order_events.is_empty()); + assert!(portfolio.position("000002.SZ").is_none()); } #[test] @@ -2901,7 +2909,6 @@ fn broker_executes_limit_value_and_limit_percent_intents() { #[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(), @@ -2934,7 +2941,7 @@ fn broker_cancels_open_order_by_order_id() { let day2_report = broker .execute( - day2, + day1, &mut portfolio, &data, &StrategyDecision { @@ -2958,6 +2965,12 @@ fn broker_cancels_open_order_by_order_id() { .iter() .any(|event| event.order_id == Some(order_id) && event.status == OrderStatus::Canceled) ); + assert!(day2_report.process_events.iter().any(|event| { + event.kind == ProcessEventKind::OrderPendingCancel && event.order_id == Some(order_id) + })); + assert!(day2_report.process_events.iter().any(|event| { + event.kind == ProcessEventKind::OrderCancellationPass && event.order_id == Some(order_id) + })); assert!(portfolio.position("000002.SZ").is_none()); }