Files
fidc-backtest-engine/crates/fidc-core/src/events.rs
T
2026-08-27 09:04:48 +08:00

528 lines
18 KiB
Rust

use chrono::{NaiveDate, NaiveDateTime};
use serde::{Deserialize, Serialize};
mod date_format {
use chrono::NaiveDate;
use serde::{self, Deserialize, Deserializer, Serializer};
const FORMAT: &str = "%Y-%m-%d";
pub fn serialize<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&date.format(FORMAT).to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
where
D: Deserializer<'de>,
{
let text = String::deserialize(deserializer)?;
NaiveDate::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom)
}
}
mod optional_date_format {
use chrono::NaiveDate;
use serde::{self, Deserialize, Deserializer, Serializer};
const FORMAT: &str = "%Y-%m-%d";
pub fn serialize<S>(date: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match date {
Some(date) => serializer.serialize_some(&date.format(FORMAT).to_string()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
value
.map(|text| NaiveDate::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom))
.transpose()
}
}
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<S>(datetime: &Option<NaiveDateTime>, serializer: S) -> Result<S::Ok, S::Error>
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<Option<NaiveDateTime>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::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,
Sell,
}
impl OrderSide {
pub fn as_str(&self) -> &'static str {
match self {
Self::Buy => "buy",
Self::Sell => "sell",
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum OrderStatus {
Pending,
Filled,
PartiallyFilled,
Canceled,
Rejected,
Expired,
}
impl OrderStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Filled => "filled",
Self::PartiallyFilled => "partially_filled",
Self::Canceled => "canceled",
Self::Rejected => "rejected",
Self::Expired => "expired",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderEvent {
#[serde(with = "date_format")]
pub date: NaiveDate,
#[serde(default, with = "optional_date_format")]
pub decision_date: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub order_created_date: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub execution_date: Option<NaiveDate>,
#[serde(default)]
pub order_id: Option<u64>,
pub symbol: String,
pub side: OrderSide,
pub requested_quantity: u32,
pub filled_quantity: u32,
pub status: OrderStatus,
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,
OrderStatus::Expired => self.filled_quantity < self.requested_quantity,
};
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")]
pub date: NaiveDate,
#[serde(default, with = "optional_date_format")]
pub decision_date: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub order_created_date: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub execution_date: Option<NaiveDate>,
#[serde(
default,
with = "optional_datetime_format",
skip_serializing_if = "Option::is_none"
)]
pub execution_start_timestamp: Option<NaiveDateTime>,
#[serde(
default,
with = "optional_datetime_format",
skip_serializing_if = "Option::is_none"
)]
pub execution_timestamp: Option<NaiveDateTime>,
#[serde(default)]
pub order_id: Option<u64>,
pub symbol: String,
pub side: OrderSide,
pub quantity: u32,
pub price: f64,
pub gross_amount: f64,
pub commission: f64,
pub stamp_tax: f64,
pub transfer_fee: f64,
pub net_cash_flow: f64,
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")]
pub date: NaiveDate,
pub symbol: String,
pub delta_quantity: i32,
pub quantity_after: u32,
pub average_cost: f64,
pub realized_pnl_delta: f64,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountEvent {
#[serde(with = "date_format")]
pub date: NaiveDate,
pub cash_before: f64,
pub cash_after: f64,
pub total_equity: f64,
pub note: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum ProcessEventKind {
PreBeforeTrading,
BeforeTrading,
PostBeforeTrading,
PreOpenAuction,
OpenAuction,
PostOpenAuction,
PreBar,
Bar,
PostBar,
PreMinute,
Minute,
PostMinute,
PreScheduled,
PostScheduled,
PreOnDay,
OnDay,
PostOnDay,
PreAfterTrading,
AfterTrading,
PostAfterTrading,
PreSettlement,
Settlement,
PostSettlement,
OrderPendingNew,
OrderCreationPass,
OrderCreationReject,
OrderPendingCancel,
OrderCancellationPass,
OrderCancellationReject,
OrderPendingUpdate,
OrderUpdatePass,
OrderUpdateReject,
OrderUnsolicitedUpdate,
Trade,
UniverseUpdated,
UniverseSubscribed,
UniverseUnsubscribed,
AccountDepositWithdraw,
AccountFinanceRepay,
AccountManagementFee,
}
impl ProcessEventKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::PreBeforeTrading => "pre_before_trading",
Self::BeforeTrading => "before_trading",
Self::PostBeforeTrading => "post_before_trading",
Self::PreOpenAuction => "pre_open_auction",
Self::OpenAuction => "open_auction",
Self::PostOpenAuction => "post_open_auction",
Self::PreBar => "pre_bar",
Self::Bar => "bar",
Self::PostBar => "post_bar",
Self::PreMinute => "pre_minute",
Self::Minute => "minute",
Self::PostMinute => "post_minute",
Self::PreScheduled => "pre_scheduled",
Self::PostScheduled => "post_scheduled",
Self::PreOnDay => "pre_on_day",
Self::OnDay => "on_day",
Self::PostOnDay => "post_on_day",
Self::PreAfterTrading => "pre_after_trading",
Self::AfterTrading => "after_trading",
Self::PostAfterTrading => "post_after_trading",
Self::PreSettlement => "pre_settlement",
Self::Settlement => "settlement",
Self::PostSettlement => "post_settlement",
Self::OrderPendingNew => "order_pending_new",
Self::OrderCreationPass => "order_creation_pass",
Self::OrderCreationReject => "order_creation_reject",
Self::OrderPendingCancel => "order_pending_cancel",
Self::OrderCancellationPass => "order_cancellation_pass",
Self::OrderCancellationReject => "order_cancellation_reject",
Self::OrderPendingUpdate => "order_pending_update",
Self::OrderUpdatePass => "order_update_pass",
Self::OrderUpdateReject => "order_update_reject",
Self::OrderUnsolicitedUpdate => "order_unsolicited_update",
Self::Trade => "trade",
Self::UniverseUpdated => "universe_updated",
Self::UniverseSubscribed => "universe_subscribed",
Self::UniverseUnsubscribed => "universe_unsubscribed",
Self::AccountDepositWithdraw => "account_deposit_withdraw",
Self::AccountFinanceRepay => "account_finance_repay",
Self::AccountManagementFee => "account_management_fee",
}
}
/// Returns whether the event is part of the durable business lifecycle
/// audit. Phase boundary events are useful during interactive debugging,
/// but retaining every minute phase marker for a long run is unnecessary.
pub fn is_business_lifecycle(&self) -> bool {
matches!(
*self,
Self::PreScheduled
| Self::PostScheduled
| Self::PreOnDay
| Self::OnDay
| Self::PostOnDay
| Self::OrderPendingNew
| Self::OrderCreationPass
| Self::OrderCreationReject
| Self::OrderPendingCancel
| Self::OrderCancellationPass
| Self::OrderCancellationReject
| Self::OrderPendingUpdate
| Self::OrderUpdatePass
| Self::OrderUpdateReject
| Self::OrderUnsolicitedUpdate
| Self::Trade
| Self::UniverseUpdated
| Self::UniverseSubscribed
| Self::UniverseUnsubscribed
| Self::AccountDepositWithdraw
| Self::AccountFinanceRepay
| Self::AccountManagementFee
| Self::Settlement
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessEvent {
#[serde(with = "date_format")]
pub date: NaiveDate,
pub kind: ProcessEventKind,
#[serde(default)]
pub order_id: Option<u64>,
#[serde(default)]
pub symbol: Option<String>,
#[serde(default)]
pub side: Option<OrderSide>,
pub detail: String,
}
#[cfg(test)]
mod tests {
use chrono::{NaiveDate, NaiveDateTime};
use super::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEventKind};
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::Expired, 40).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());
assert!(order_event(OrderStatus::Expired, 100).validate().is_err());
}
fn fill_event(start: Option<NaiveDateTime>, end: Option<NaiveDateTime>) -> 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"
);
}
#[test]
fn process_event_business_lifecycle_filter_keeps_audit_events_only() {
assert!(ProcessEventKind::OrderUpdateReject.is_business_lifecycle());
assert!(ProcessEventKind::Settlement.is_business_lifecycle());
assert!(!ProcessEventKind::PreMinute.is_business_lifecycle());
assert!(!ProcessEventKind::PostBar.is_business_lifecycle());
}
}