528 lines
18 KiB
Rust
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());
|
|
}
|
|
}
|