From cdbd8a67deebce67fc18308e8b7d362c4a8d7982 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 27 Aug 2026 02:46:38 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=88=86=E9=92=9F=E6=88=90?= =?UTF-8?q?=E4=BA=A4=E7=B2=BE=E7=A1=AE=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 21 +++ crates/fidc-core/src/engine.rs | 2 + crates/fidc-core/src/events.rs | 142 +++++++++++++++++- crates/fidc-core/src/futures.rs | 2 + crates/fidc-core/tests/explicit_order_flow.rs | 16 ++ 5 files changed, 180 insertions(+), 3 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 654ae3c..8e1fc59 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -36,6 +36,9 @@ impl BrokerExecutionReport { for event in &self.order_events { event.validate().map_err(BacktestError::Execution)?; } + for event in &self.fill_events { + event.validate().map_err(BacktestError::Execution)?; + } Ok(()) } } @@ -45,6 +48,8 @@ struct ExecutionLeg { price: f64, mark_price: f64, quantity: u32, + execution_start_timestamp: Option, + execution_timestamp: Option, } #[derive(Debug, Clone)] @@ -4084,6 +4089,8 @@ where price: execution_price, mark_price: self.snapshot_mark_price(snapshot, OrderSide::Sell), quantity: fillable_qty, + execution_start_timestamp: None, + execution_timestamp: None, }], None, Vec::new(), @@ -4227,6 +4234,8 @@ where decision_date: None, order_created_date: None, execution_date: None, + execution_start_timestamp: leg.execution_start_timestamp, + execution_timestamp: leg.execution_timestamp, order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Sell, @@ -5784,6 +5793,8 @@ where price: execution_price, mark_price: self.snapshot_mark_price(snapshot, OrderSide::Buy), quantity: filled_qty, + execution_start_timestamp: None, + execution_timestamp: None, }], None, Vec::new(), @@ -5928,6 +5939,8 @@ where decision_date: None, order_created_date: None, execution_date: None, + execution_start_timestamp: leg.execution_start_timestamp, + execution_timestamp: leg.execution_timestamp, order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Buy, @@ -6812,6 +6825,7 @@ where let mut filled_qty = 0_u32; let mut gross_amount = 0.0_f64; let mut mark_amount = 0.0_f64; + let mut first_timestamp = None; let mut last_timestamp = None; let mut legs = Vec::new(); let mut budget_block_reason = None; @@ -7000,11 +7014,14 @@ where gross_amount += quote_price * take_qty as f64; mark_amount += mark_price * take_qty as f64; filled_qty += take_qty; + first_timestamp.get_or_insert(quote.timestamp); last_timestamp = Some(quote.timestamp); legs.push(ExecutionLeg { price: quote_price, mark_price, quantity: take_qty, + execution_start_timestamp: Some(quote.timestamp), + execution_timestamp: Some(quote.timestamp), }); if consume_depth { let state = depth_state @@ -7064,6 +7081,8 @@ where price: gross_amount / filled_qty as f64, mark_price: mark_amount / filled_qty as f64, quantity: filled_qty, + execution_start_timestamp: first_timestamp, + execution_timestamp: last_timestamp, }] } else { legs @@ -9966,6 +9985,8 @@ mod tests { assert_eq!(fill.quantity, 200); assert_eq!(fill.legs.len(), 1); assert_eq!(fill.legs[0].price, 10.8); + assert_eq!(fill.legs[0].execution_timestamp, Some(quote_timestamp)); + assert!(fill.legs[0].execution_timestamp.unwrap() <= decision_time); assert_eq!( fill.next_cursor, quote_timestamp + chrono::Duration::seconds(1) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 5f57246..2de624c 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -3494,6 +3494,8 @@ where decision_date: None, order_created_date: None, execution_date: None, + execution_start_timestamp: None, + execution_timestamp: None, order_id: None, symbol: receivable.symbol.clone(), side: OrderSide::Buy, diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 6eddd15..539b77a 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -1,4 +1,4 @@ -use chrono::NaiveDate; +use chrono::{NaiveDate, NaiveDateTime}; use serde::{Deserialize, Serialize}; mod date_format { @@ -50,6 +50,35 @@ mod optional_date_format { } } +mod optional_datetime_format { + use chrono::NaiveDateTime; + use serde::{self, Deserialize, Deserializer, Serializer}; + + const FORMAT: &str = "%Y-%m-%d %H:%M:%S%.f"; + + pub fn serialize(datetime: &Option, serializer: S) -> Result + where + S: Serializer, + { + match datetime { + Some(datetime) => serializer.serialize_some(&datetime.format(FORMAT).to_string()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let value = Option::::deserialize(deserializer)?; + value + .map(|text| { + NaiveDateTime::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom) + }) + .transpose() + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum OrderSide { Buy, @@ -162,6 +191,18 @@ pub struct FillEvent { pub order_created_date: Option, #[serde(default, with = "optional_date_format")] pub execution_date: Option, + #[serde( + default, + with = "optional_datetime_format", + skip_serializing_if = "Option::is_none" + )] + pub execution_start_timestamp: Option, + #[serde( + default, + with = "optional_datetime_format", + skip_serializing_if = "Option::is_none" + )] + pub execution_timestamp: Option, #[serde(default)] pub order_id: Option, pub symbol: String, @@ -176,6 +217,42 @@ pub struct FillEvent { pub reason: String, } +impl FillEvent { + pub fn validate(&self) -> Result<(), String> { + if self.symbol.trim().is_empty() + || self.quantity == 0 + || !self.price.is_finite() + || self.price <= 0.0 + { + return Err(format!( + "invalid fill identity/quantity/price order_id={:?} symbol={} quantity={} price={}", + self.order_id, self.symbol, self.quantity, self.price + )); + } + if let (Some(start), Some(end)) = (self.execution_start_timestamp, self.execution_timestamp) + { + if start > end { + return Err(format!( + "fill execution timestamp order is invalid order_id={:?} start={} end={}", + self.order_id, start, end + )); + } + if start.date() != self.date || end.date() != self.date { + return Err(format!( + "fill execution timestamp date mismatch order_id={:?} fill_date={} start={} end={}", + self.order_id, self.date, start, end + )); + } + } else if self.execution_start_timestamp.is_some() || self.execution_timestamp.is_some() { + return Err(format!( + "fill execution timestamp range is incomplete order_id={:?}", + self.order_id + )); + } + Ok(()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PositionEvent { #[serde(with = "date_format")] @@ -299,9 +376,9 @@ pub struct ProcessEvent { #[cfg(test)] mod tests { - use chrono::NaiveDate; + use chrono::{NaiveDate, NaiveDateTime}; - use super::{OrderEvent, OrderSide, OrderStatus}; + use super::{FillEvent, OrderEvent, OrderSide, OrderStatus}; fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent { OrderEvent { @@ -342,4 +419,63 @@ mod tests { assert!(order_event(OrderStatus::Rejected, 1).validate().is_err()); assert!(order_event(OrderStatus::Expired, 100).validate().is_err()); } + + fn fill_event(start: Option, end: Option) -> FillEvent { + FillEvent { + date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), + decision_date: None, + order_created_date: None, + execution_date: None, + execution_start_timestamp: start, + execution_timestamp: end, + order_id: Some(1), + symbol: "600000.SH".to_string(), + side: OrderSide::Buy, + quantity: 100, + price: 10.0, + gross_amount: 1_000.0, + commission: 5.0, + stamp_tax: 0.0, + transfer_fee: 0.0, + net_cash_flow: -1_005.0, + reason: "test".to_string(), + } + } + + #[test] + fn fill_execution_timestamp_range_is_explicit_and_backward_compatible() { + let start = NaiveDate::from_ymd_opt(2025, 1, 2) + .unwrap() + .and_hms_opt(10, 18, 0) + .unwrap(); + let end = start + chrono::Duration::seconds(3); + assert!(fill_event(Some(start), Some(end)).validate().is_ok()); + assert!(fill_event(Some(end), Some(start)).validate().is_err()); + assert!(fill_event(Some(start), None).validate().is_err()); + + let next_day = start + chrono::Duration::days(1); + assert!( + fill_event(Some(next_day), Some(next_day)) + .validate() + .is_err() + ); + + let legacy = fill_event(None, None); + let legacy_json = serde_json::to_value(&legacy).unwrap(); + assert!(legacy_json.get("execution_start_timestamp").is_none()); + assert!(legacy_json.get("execution_timestamp").is_none()); + let decoded: FillEvent = serde_json::from_value(legacy_json).unwrap(); + assert_eq!(decoded.execution_start_timestamp, None); + assert_eq!(decoded.execution_timestamp, None); + + let timestamped_json = serde_json::to_value(fill_event(Some(start), Some(end))).unwrap(); + assert_eq!( + timestamped_json["execution_start_timestamp"], + "2025-01-02 10:18:00" + ); + assert_eq!( + timestamped_json["execution_timestamp"], + "2025-01-02 10:18:03" + ); + } } diff --git a/crates/fidc-core/src/futures.rs b/crates/fidc-core/src/futures.rs index 5b412b7..07b4cc6 100644 --- a/crates/fidc-core/src/futures.rs +++ b/crates/fidc-core/src/futures.rs @@ -1052,6 +1052,8 @@ impl FuturesAccountState { decision_date: None, order_created_date: None, execution_date: None, + execution_start_timestamp: None, + execution_timestamp: None, order_id, symbol: intent.symbol.clone(), side, diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 19f4f15..870d9f0 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -2537,6 +2537,14 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() { assert_eq!(report.fill_events[1].quantity, 100); assert!((report.fill_events[0].price - 10.01).abs() < 1e-9); assert!((report.fill_events[1].price - 10.03).abs() < 1e-9); + assert_eq!( + report.fill_events[0].execution_timestamp, + date.and_hms_opt(10, 18, 3) + ); + assert_eq!( + report.fill_events[1].execution_timestamp, + date.and_hms_opt(10, 18, 6) + ); assert!((report.fill_events[0].commission - 5.0).abs() < 1e-9); assert_eq!(report.fill_events[1].commission, 0.0); assert_eq!(report.account_events.len(), 2); @@ -2699,6 +2707,14 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() { assert_eq!(report.fill_events.len(), 1); assert_eq!(report.fill_events[0].quantity, 200); assert!((report.fill_events[0].price - 10.02).abs() < 1e-9); + assert_eq!( + report.fill_events[0].execution_start_timestamp, + date.and_hms_opt(10, 18, 3) + ); + assert_eq!( + report.fill_events[0].execution_timestamp, + date.and_hms_opt(10, 18, 6) + ); assert!((report.fill_events[0].commission - 5.0).abs() < 1e-9); assert_eq!(report.account_events.len(), 1); assert_eq!(