From 0793473210b5581106cb14e503e07544b13bfa23 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 18:28:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=9B=9E=E6=B5=8B=E9=83=A8?= =?UTF-8?q?=E5=88=86=E6=88=90=E4=BA=A4=E7=BB=88=E6=80=81=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 49 +++-------- crates/fidc-core/src/events.rs | 84 +++++++++++++++++++ crates/fidc-core/tests/explicit_order_flow.rs | 7 +- 3 files changed, 103 insertions(+), 37 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index e443483..ae15040 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -29,6 +29,15 @@ pub struct BrokerExecutionReport { pub diagnostics: Vec, } +impl BrokerExecutionReport { + fn validate(&self) -> Result<(), BacktestError> { + for event in &self.order_events { + event.validate().map_err(BacktestError::Execution)?; + } + Ok(()) + } +} + #[derive(Debug, Clone, Copy)] struct ExecutionLeg { price: f64, @@ -1038,6 +1047,7 @@ where self.runtime_target_position_limit .set(previous_target_position_limit); portfolio.prune_flat_positions(); + report.validate()?; return Ok(report); } @@ -1153,6 +1163,7 @@ where } portfolio.prune_flat_positions(); + report.validate()?; Ok(report) } @@ -3850,7 +3861,7 @@ where let status = if keep_open { OrderStatus::PartiallyFilled } else if filled_qty < requested_qty { - final_partial_fill_status(partial_fill_reason.as_deref()) + OrderStatus::Canceled } else { OrderStatus::Filled }; @@ -3862,14 +3873,6 @@ where "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"); - report.diagnostics.push(format!( - "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() @@ -5506,7 +5509,7 @@ where let status = if keep_open { OrderStatus::PartiallyFilled } else if filled_qty < requested_qty { - final_partial_fill_status(partial_fill_reason.as_deref()) + OrderStatus::Canceled } else { OrderStatus::Filled }; @@ -5518,14 +5521,6 @@ where "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"); - report.diagnostics.push(format!( - "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() @@ -6589,24 +6584,6 @@ fn zero_fill_status_for_reason(reason: &str) -> OrderStatus { } } -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 at or before start") - || reason.contains("no execution quotes after start") - || reason.contains("upper_limit") - || reason.contains("lower_limit") - || reason.contains("open at or above upper limit") - || reason.contains("open at or below lower limit") => - { - OrderStatus::Canceled - } - _ => OrderStatus::PartiallyFilled, - } -} - fn price_field_name(field: PriceField) -> &'static str { match field { PriceField::DayOpen => "day_open", diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 6aafe4a..0db981e 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -106,6 +106,49 @@ pub struct OrderEvent { pub reason: String, } +impl OrderEvent { + pub fn validate(&self) -> Result<(), String> { + if self.symbol.trim().is_empty() || self.requested_quantity == 0 { + return Err(format!( + "invalid order identity/quantity order_id={:?} symbol={} requested={}", + self.order_id, self.symbol, self.requested_quantity + )); + } + if self.filled_quantity > self.requested_quantity { + return Err(format!( + "order overfill order_id={:?} requested={} filled={}", + self.order_id, self.requested_quantity, self.filled_quantity + )); + } + let quantity_valid = match self.status { + OrderStatus::Pending => self.filled_quantity < self.requested_quantity, + OrderStatus::Filled => self.filled_quantity == self.requested_quantity, + OrderStatus::PartiallyFilled => { + self.filled_quantity > 0 && self.filled_quantity < self.requested_quantity + } + OrderStatus::Canceled => self.filled_quantity < self.requested_quantity, + OrderStatus::Rejected => self.filled_quantity == 0, + }; + if !quantity_valid { + return Err(format!( + "order status/quantity mismatch order_id={:?} status={} requested={} filled={}", + self.order_id, + self.status.as_str(), + self.requested_quantity, + self.filled_quantity + )); + } + if self.reason.trim().is_empty() { + return Err(format!( + "order reason is empty order_id={:?} status={}", + self.order_id, + self.status.as_str() + )); + } + Ok(()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FillEvent { #[serde(with = "date_format")] @@ -250,3 +293,44 @@ pub struct ProcessEvent { pub side: Option, pub detail: String, } + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + + use super::{OrderEvent, OrderSide, OrderStatus}; + + fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent { + OrderEvent { + date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), + decision_date: None, + order_created_date: None, + execution_date: None, + order_id: Some(1), + symbol: "600000.SH".to_string(), + side: OrderSide::Buy, + requested_quantity: 100, + filled_quantity, + status, + reason: "test".to_string(), + } + } + + #[test] + fn order_event_status_quantity_contract_is_explicit() { + assert!(order_event(OrderStatus::Pending, 0).validate().is_ok()); + assert!( + order_event(OrderStatus::PartiallyFilled, 40) + .validate() + .is_ok() + ); + assert!(order_event(OrderStatus::Filled, 100).validate().is_ok()); + assert!(order_event(OrderStatus::Canceled, 40).validate().is_ok()); + assert!(order_event(OrderStatus::Rejected, 0).validate().is_ok()); + + assert!(order_event(OrderStatus::PartiallyFilled, 0).validate().is_err()); + assert!(order_event(OrderStatus::Filled, 99).validate().is_err()); + assert!(order_event(OrderStatus::Canceled, 100).validate().is_err()); + assert!(order_event(OrderStatus::Rejected, 1).validate().is_err()); + } +} diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 7336ba8..e84fef4 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -5150,8 +5150,13 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() { 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].status, OrderStatus::Canceled); assert_eq!(report.order_events[1].filled_quantity, 100); + assert!(report.order_events[1].reason.contains("remaining quantity canceled")); + let open_orders = broker.open_order_views(); + assert_eq!(open_orders.len(), 1); + assert_eq!(open_orders[0].reason, "reserve_sell"); + assert_eq!(open_orders[0].remaining_quantity, 200); assert_eq!( portfolio.position("000002.SZ").expect("position").quantity, 200