// ------------------------------------------------------------------------------------------------- // Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved. // https://nautechsystems.io // // Licensed under the GNU Lesser General Public License Version 3.0 (the "License"); // You may not use this file except in compliance with the License. // You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ------------------------------------------------------------------------------------------------- //! Order types for the trading domain model. //! //! Each order type wraps an [`OrderCore`] carrying the state common to all of them and adds its //! own type-specific fields. [`OrderAny`] dispatches over the concrete types. //! //! Orders are event sourced. [`OrderCore::apply`] validates the status transition, runs the //! handler for that event, then appends it, so [`OrderAny::from_events`] replays a stream to //! reconstruct its event-derived state. //! //! A fill-void correction rebuilds the derived fill state from the fills that survive it. //! `avg_px` retains the chronological `Decimal` notional and quantity fold between fills and //! keeps the quotient at that precision. Corrections rebuild the fold rather than subtracting //! from saturated or rounded totals. Deserialized orders rebuild the cached fold from stored //! events on the next fill. `slippage` derives from `avg_px` in the same arithmetic. //! A later fill with the same `trade_id` and a new price restates the price that trade //! contributes to `avg_px`. The original fill's `last_px` stays so a void still matches it, //! and quantity and status stay unchanged. Repeating the latest price for that trade ID //! returns [`OrderError::DuplicateFill`]. A return to an earlier price restates the average. pub mod any; pub mod limit; pub mod limit_if_touched; pub mod list; pub mod market; pub mod market_if_touched; pub mod market_to_limit; pub mod stop_limit; pub mod stop_market; pub mod trailing_stop_limit; pub mod trailing_stop_market; #[cfg(any(test, feature = "test-support"))] pub mod builder; #[cfg(any(test, feature = "test-support"))] pub mod stubs; // Re-exports use ahash::{AHashMap, AHashSet}; use enum_dispatch::enum_dispatch; use indexmap::IndexMap; use nautilus_core::{ UUID4, UnixNanos, correctness::{CorrectnessError, check_predicate_false}, }; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use ustr::Ustr; #[cfg(any(test, feature = "test-support"))] pub use crate::orders::builder::OrderTestBuilder; pub use crate::orders::{ any::{LimitOrderAny, OrderAny, OrderReplayError, PassiveOrderAny, StopOrderAny}, limit::LimitOrder, limit_if_touched::LimitIfTouchedOrder, list::{OrderList, OrderListValidationError}, market::MarketOrder, market_if_touched::MarketIfTouchedOrder, market_to_limit::MarketToLimitOrder, stop_limit::StopLimitOrder, stop_market::StopMarketOrder, trailing_stop_limit::TrailingStopLimitOrder, trailing_stop_market::TrailingStopMarketOrder, }; use crate::{ enums::{ ContingencyType, LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSide, TimeInForce, TrailingOffsetType, TriggerType, }, events::{ OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDenied, OrderEmulated, OrderEventAny, OrderExpired, OrderFillVoided, OrderFilled, OrderInitialized, OrderModifyRejected, OrderPendingCancel, OrderPendingUpdate, OrderRejected, OrderReleased, OrderSubmitted, OrderTriggered, OrderUpdated, }, identifiers::{ AccountId, ClientOrderId, ExecAlgorithmId, InstrumentId, OrderListId, PositionId, StrategyId, Symbol, TradeId, TraderId, Venue, VenueOrderId, }, orderbook::OwnBookOrder, reports::OrderStatusReport, types::{Currency, Money, Price, Quantity}, }; /// Order types that have stop/trigger prices. pub const STOP_ORDER_TYPES: &[OrderType] = &[ OrderType::StopMarket, OrderType::StopLimit, OrderType::MarketIfTouched, OrderType::LimitIfTouched, ]; /// Order types that have limit prices. pub const LIMIT_ORDER_TYPES: &[OrderType] = &[ OrderType::Limit, OrderType::StopLimit, OrderType::LimitIfTouched, OrderType::TrailingStopLimit, ]; /// Order types that support the TRIGGERED order status. /// /// Market-style stops (`StopMarket`, `MarketIfTouched`, `TrailingStopMarket`) execute /// immediately on trigger and have no intermediate TRIGGERED state. pub const TRIGGERABLE_ORDER_TYPES: &[OrderType] = &[ OrderType::StopLimit, OrderType::TrailingStopLimit, OrderType::LimitIfTouched, ]; /// Order statuses for locally active orders (pre-submission to venue). pub const LOCAL_ACTIVE_ORDER_STATUSES: &[OrderStatus] = &[ OrderStatus::Initialized, OrderStatus::Emulated, OrderStatus::Released, ]; #[derive(thiserror::Error, Debug)] pub enum OrderError { #[error("Order not found: {0}")] NotFound(ClientOrderId), #[error("Order invariant failed: must have a side for this operation")] NoOrderSide, #[error("Invalid event for order type")] InvalidOrderEvent, #[error("Invalid order state transition")] InvalidStateTransition, #[error("Order was already initialized")] AlreadyInitialized, #[error("Order had no previous state")] NoPreviousState, #[error("Duplicate fill: trade_id {0} already applied to order")] DuplicateFill(TradeId), #[error("Duplicate fill void: trade_id {0} already has this cumulative correction")] DuplicateFillVoid(TradeId), #[error("Fill void for trade_id {0} is older than the applied correction")] StaleFillVoid(TradeId), #[error("Fill void quantity exceeds the original fill for trade_id {0}")] OverVoid(TradeId), #[error("Fill void commission is missing or conflicts with trade_id {0}")] InvalidFillVoidCommission(TradeId), #[error("{0}")] Invariant(#[from] CorrectnessError), } /// Converts an `IndexMap` with `Ustr` keys and values to `String` keys and values. #[must_use] pub fn ustr_indexmap_to_str(h: IndexMap) -> IndexMap { h.into_iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect() } /// Converts an `IndexMap` with `String` keys and values to `Ustr` keys and values. #[must_use] pub fn str_indexmap_to_ustr(h: IndexMap) -> IndexMap { h.into_iter() .map(|(k, v)| (Ustr::from(&k), Ustr::from(&v))) .collect() } #[inline] pub(crate) fn check_display_qty( display_qty: Option, quantity: Quantity, ) -> Result<(), OrderError> { if let Some(q) = display_qty { check_predicate_false(q > quantity, "`display_qty` may not exceed `quantity`")?; } Ok(()) } #[inline] pub(crate) fn check_time_in_force( time_in_force: TimeInForce, expire_time: Option, ) -> Result<(), OrderError> { check_predicate_false( time_in_force == TimeInForce::Gtd && expire_time.unwrap_or_default() == 0, "`expire_time` is required for `GTD` order", )?; Ok(()) } impl OrderStatus { /// Transitions the order state machine based on the given `event`. /// /// # Errors /// /// Returns an error if the state transition is invalid from the current status. #[rustfmt::skip] pub fn transition(&mut self, event: &OrderEventAny) -> Result { let new_state = match (self, event) { (Self::Initialized, OrderEventAny::Denied(_)) => Self::Denied, (Self::Initialized, OrderEventAny::Emulated(_)) => Self::Emulated, // Emulated orders (Self::Initialized, OrderEventAny::Released(_)) => Self::Released, // Emulated orders (Self::Initialized, OrderEventAny::Submitted(_)) => Self::Submitted, (Self::Initialized, OrderEventAny::Rejected(_)) => Self::Rejected, // External orders (Self::Initialized, OrderEventAny::Accepted(_)) => Self::Accepted, // External orders (Self::Initialized, OrderEventAny::Canceled(_)) => Self::Canceled, // External orders (Self::Initialized, OrderEventAny::Expired(_)) => Self::Expired, // External orders (Self::Initialized, OrderEventAny::Triggered(_)) => Self::Triggered, // External orders (Self::Initialized, OrderEventAny::Updated(_)) => Self::Initialized, // In-place modification (Self::Emulated, OrderEventAny::Canceled(_)) => Self::Canceled, // Emulated orders (Self::Emulated, OrderEventAny::Expired(_)) => Self::Expired, // Emulated orders (Self::Emulated, OrderEventAny::Updated(_)) => Self::Emulated, // In-place modification (Self::Emulated, OrderEventAny::Released(_)) => Self::Released, // Emulated orders (Self::Released, OrderEventAny::Submitted(_)) => Self::Submitted, // Emulated orders (Self::Released, OrderEventAny::Denied(_)) => Self::Denied, // Emulated orders (Self::Released, OrderEventAny::Canceled(_)) => Self::Canceled, // Execution algo (Self::Released, OrderEventAny::Updated(_)) => Self::Released, // In-place modification (Self::Submitted, OrderEventAny::PendingUpdate(_)) => Self::PendingUpdate, (Self::Submitted, OrderEventAny::PendingCancel(_)) => Self::PendingCancel, (Self::Submitted, OrderEventAny::ModifyRejected(_)) => Self::Submitted, (Self::Submitted, OrderEventAny::CancelRejected(_)) => Self::Submitted, // Cancel can fail before submission is acknowledged (Self::Submitted, OrderEventAny::Rejected(_)) => Self::Rejected, (Self::Submitted, OrderEventAny::Canceled(_)) => Self::Canceled, // FOK and IOC cases (Self::Submitted, OrderEventAny::Accepted(_)) => Self::Accepted, (Self::Submitted, OrderEventAny::Updated(_)) => Self::Submitted, (Self::Submitted, OrderEventAny::Filled(_)) => Self::Filled, (Self::Accepted, OrderEventAny::Rejected(_)) => Self::Rejected, // StopLimit order (Self::Accepted, OrderEventAny::PendingUpdate(_)) => Self::PendingUpdate, (Self::Accepted, OrderEventAny::PendingCancel(_)) => Self::PendingCancel, (Self::Accepted, OrderEventAny::ModifyRejected(_)) => Self::Accepted, (Self::Accepted, OrderEventAny::CancelRejected(_)) => Self::Accepted, // Acceptance can overtake a cancel rejection (Self::Accepted, OrderEventAny::Canceled(_)) => Self::Canceled, (Self::Accepted, OrderEventAny::Triggered(_)) => Self::Triggered, (Self::Accepted, OrderEventAny::Updated(_)) => Self::Accepted, // Updates should preserve state (Self::Accepted, OrderEventAny::Expired(_)) => Self::Expired, (Self::Accepted, OrderEventAny::Filled(_)) => Self::Filled, (Self::Accepted, OrderEventAny::FillVoided(_)) => Self::Accepted, (Self::Canceled, OrderEventAny::Filled(_)) => Self::Filled, // Real world possibility (Self::Canceled, OrderEventAny::FillVoided(_)) => Self::Canceled, (Self::Canceled, OrderEventAny::Updated(_)) => Self::Canceled, (Self::PendingUpdate, OrderEventAny::Rejected(_)) => Self::Rejected, (Self::PendingUpdate, OrderEventAny::Accepted(_)) => Self::Accepted, (Self::PendingUpdate, OrderEventAny::Canceled(_)) => Self::Canceled, (Self::PendingUpdate, OrderEventAny::Expired(_)) => Self::Expired, (Self::PendingUpdate, OrderEventAny::Triggered(_)) => Self::Triggered, (Self::PendingUpdate, OrderEventAny::Submitted(_)) => Self::PendingUpdate, // Real world possibility (Self::PendingUpdate, OrderEventAny::PendingUpdate(_)) => Self::PendingUpdate, // Allow multiple requests (Self::PendingUpdate, OrderEventAny::PendingCancel(_)) => Self::PendingCancel, (Self::PendingUpdate, OrderEventAny::CancelRejected(_)) => Self::PendingUpdate, // Preserve in-flight modification (Self::PendingUpdate, OrderEventAny::ModifyRejected(_)) => Self::PendingUpdate, // Handled by modify_rejected to restore previous_status (Self::PendingUpdate, OrderEventAny::Updated(_)) => Self::PendingUpdate, // Handled by updated to restore previous_status (Self::PendingUpdate, OrderEventAny::Filled(_)) => Self::Filled, (Self::PendingUpdate, OrderEventAny::FillVoided(_)) => Self::PendingUpdate, (Self::PendingCancel, OrderEventAny::Rejected(_)) => Self::Rejected, (Self::PendingCancel, OrderEventAny::PendingCancel(_)) => Self::PendingCancel, // Allow multiple requests (Self::PendingCancel, OrderEventAny::ModifyRejected(_)) => Self::PendingCancel, // Preserve in-flight cancellation (Self::PendingCancel, OrderEventAny::CancelRejected(_)) => Self::PendingCancel, // Handled by cancel_rejected to restore previous_status (Self::PendingCancel, OrderEventAny::Canceled(_)) => Self::Canceled, (Self::PendingCancel, OrderEventAny::Expired(_)) => Self::Expired, (Self::PendingCancel, OrderEventAny::Accepted(_)) => Self::Accepted, // Allow failed cancel requests (Self::PendingCancel, OrderEventAny::Updated(_)) => Self::PendingCancel, // Handled by updated to restore previous_status (Self::PendingCancel, OrderEventAny::Filled(_)) => Self::Filled, (Self::PendingCancel, OrderEventAny::FillVoided(_)) => Self::PendingCancel, (Self::Triggered, OrderEventAny::Rejected(_)) => Self::Rejected, (Self::Triggered, OrderEventAny::PendingUpdate(_)) => Self::PendingUpdate, (Self::Triggered, OrderEventAny::PendingCancel(_)) => Self::PendingCancel, (Self::Triggered, OrderEventAny::ModifyRejected(_)) => Self::Triggered, (Self::Triggered, OrderEventAny::CancelRejected(_)) => Self::Triggered, (Self::Triggered, OrderEventAny::Canceled(_)) => Self::Canceled, (Self::Triggered, OrderEventAny::Expired(_)) => Self::Expired, (Self::Triggered, OrderEventAny::Filled(_)) => Self::Filled, (Self::Triggered, OrderEventAny::Updated(_)) => Self::Triggered, (Self::Triggered, OrderEventAny::FillVoided(_)) => Self::Triggered, (Self::PartiallyFilled, OrderEventAny::PendingUpdate(_)) => Self::PendingUpdate, (Self::PartiallyFilled, OrderEventAny::PendingCancel(_)) => Self::PendingCancel, (Self::PartiallyFilled, OrderEventAny::ModifyRejected(_)) => Self::PartiallyFilled, (Self::PartiallyFilled, OrderEventAny::CancelRejected(_)) => Self::PartiallyFilled, (Self::PartiallyFilled, OrderEventAny::Canceled(_)) => Self::Canceled, (Self::PartiallyFilled, OrderEventAny::Expired(_)) => Self::Expired, (Self::PartiallyFilled, OrderEventAny::Filled(_)) => Self::Filled, (Self::PartiallyFilled, OrderEventAny::Accepted(_)) => Self::Accepted, (Self::PartiallyFilled, OrderEventAny::Updated(_)) => Self::PartiallyFilled, (Self::PartiallyFilled, OrderEventAny::FillVoided(_)) => Self::PartiallyFilled, (Self::Filled, OrderEventAny::FillVoided(_)) => Self::Voided, (Self::Filled, OrderEventAny::Updated(_)) => Self::Filled, (Self::Expired, OrderEventAny::Filled(_)) => Self::Filled, // Real world possibility (Self::Expired, OrderEventAny::FillVoided(_)) => Self::Expired, (Self::Expired, OrderEventAny::Updated(_)) => Self::Expired, (Self::Voided, OrderEventAny::FillVoided(_)) => Self::Voided, _ => return Err(OrderError::InvalidStateTransition), }; Ok(new_state) } } #[enum_dispatch] pub trait Order: 'static + Send { fn into_any(self) -> OrderAny; fn status(&self) -> OrderStatus; fn trader_id(&self) -> TraderId; fn strategy_id(&self) -> StrategyId; fn instrument_id(&self) -> InstrumentId; fn symbol(&self) -> Symbol; fn venue(&self) -> Venue; fn client_order_id(&self) -> ClientOrderId; fn venue_order_id(&self) -> Option; fn position_id(&self) -> Option; fn account_id(&self) -> Option; fn last_trade_id(&self) -> Option; fn order_side(&self) -> OrderSide; fn order_type(&self) -> OrderType; fn quantity(&self) -> Quantity; fn time_in_force(&self) -> TimeInForce; fn expire_time(&self) -> Option; fn price(&self) -> Option; fn activation_price(&self) -> Option { None } fn trigger_price(&self) -> Option; fn trigger_type(&self) -> Option; fn liquidity_side(&self) -> Option; fn is_post_only(&self) -> bool; fn is_reduce_only(&self) -> bool; fn is_quote_quantity(&self) -> bool; fn display_qty(&self) -> Option; fn limit_offset(&self) -> Option; fn trailing_offset(&self) -> Option; fn trailing_offset_type(&self) -> Option; fn emulation_trigger(&self) -> Option; fn trigger_instrument_id(&self) -> Option; fn contingency_type(&self) -> Option; fn order_list_id(&self) -> Option; fn linked_order_ids(&self) -> Option<&[ClientOrderId]>; fn parent_order_id(&self) -> Option; fn exec_algorithm_id(&self) -> Option; fn exec_algorithm_params(&self) -> Option<&IndexMap>; fn exec_spawn_id(&self) -> Option; fn tags(&self) -> Option<&[Ustr]>; fn filled_qty(&self) -> Quantity; fn voided_qty(&self) -> Quantity; fn non_reopened_voided_qty(&self) -> Quantity; fn leaves_qty(&self) -> Quantity; fn overfill_qty(&self) -> Quantity; /// Calculates potential overfill quantity without mutating order state. fn calculate_overfill(&self, fill_qty: Quantity) -> Quantity { let potential_filled = self.filled_qty() + fill_qty; let quantity = self.quantity(); if potential_filled > quantity { potential_filled - quantity } else { Quantity::zero(fill_qty.precision) } } fn avg_px(&self) -> Option; fn slippage(&self) -> Option; fn init_id(&self) -> UUID4; fn ts_init(&self) -> UnixNanos; fn ts_submitted(&self) -> Option; fn ts_accepted(&self) -> Option; fn ts_closed(&self) -> Option; fn ts_last(&self) -> UnixNanos; fn commissions(&self) -> &IndexMap; /// Applies the `event` to the order. /// /// # Errors /// /// Returns an error if the event is invalid for the current order status. fn apply(&mut self, event: OrderEventAny) -> Result<(), OrderError>; fn update(&mut self, event: &OrderUpdated); fn events(&self) -> Vec<&OrderEventAny>; fn last_event(&self) -> &OrderEventAny { self.events() .last() .expect("Order invariant violated: no events") } fn event_count(&self) -> usize { self.events().len() } fn venue_order_ids(&self) -> Vec<&VenueOrderId>; fn trade_ids(&self) -> Vec<&TradeId>; fn has_price(&self) -> bool; /// Returns `true` if the latest fill for this `trade_id` already has this side, quantity, and price. fn is_duplicate_fill(&self, fill: &OrderFilled) -> bool { let mut latest = None; for event in self.events() { if let OrderEventAny::Filled(existing) = event && existing.trade_id == fill.trade_id && existing.order_side == fill.order_side { latest = Some(existing); } } latest.is_some_and(|existing| { existing.last_qty == fill.last_qty && existing.last_px == fill.last_px }) } fn is_buy(&self) -> bool { self.order_side() == OrderSide::Buy } fn is_sell(&self) -> bool { self.order_side() == OrderSide::Sell } fn is_passive(&self) -> bool { self.order_type() != OrderType::Market } fn is_aggressive(&self) -> bool { self.order_type() == OrderType::Market } fn is_emulated(&self) -> bool { self.status() == OrderStatus::Emulated } fn is_active_local(&self) -> bool { matches!( self.status(), OrderStatus::Initialized | OrderStatus::Emulated | OrderStatus::Released ) } fn is_primary(&self) -> bool { self.exec_algorithm_id().is_some() && self .exec_spawn_id() .is_some_and(|spawn_id| self.client_order_id() == spawn_id) } fn is_spawned(&self) -> bool { self.exec_algorithm_id().is_some() && self .exec_spawn_id() .is_some_and(|spawn_id| self.client_order_id() != spawn_id) } fn is_contingency(&self) -> bool { self.contingency_type().is_some() } fn is_parent_order(&self) -> bool { match self.contingency_type() { Some(c) => c == ContingencyType::Oto, None => false, } } fn is_child_order(&self) -> bool { self.parent_order_id().is_some() } fn is_open(&self) -> bool { if self.emulation_trigger().is_some() { return false; } matches!( self.status(), OrderStatus::Accepted | OrderStatus::Triggered | OrderStatus::PendingCancel | OrderStatus::PendingUpdate | OrderStatus::PartiallyFilled ) } fn is_canceled(&self) -> bool { self.status() == OrderStatus::Canceled } fn is_closed(&self) -> bool { matches!( self.status(), OrderStatus::Denied | OrderStatus::Rejected | OrderStatus::Canceled | OrderStatus::Expired | OrderStatus::Filled | OrderStatus::Voided ) } fn is_inflight(&self) -> bool { if self.emulation_trigger().is_some() { return false; } matches!( self.status(), OrderStatus::Submitted | OrderStatus::PendingCancel | OrderStatus::PendingUpdate ) } fn is_pending_update(&self) -> bool { self.status() == OrderStatus::PendingUpdate } fn is_pending_cancel(&self) -> bool { self.status() == OrderStatus::PendingCancel } fn to_own_book_order(&self) -> OwnBookOrder { OwnBookOrder::new( self.trader_id(), self.client_order_id(), self.venue_order_id(), self.order_side(), self.price().expect("`OwnBookOrder` must have a price"), self.leaves_qty(), self.order_type(), self.time_in_force(), self.status(), self.ts_last(), self.ts_accepted().unwrap_or_default(), self.ts_submitted().unwrap_or_default(), self.ts_init(), ) } /// Builds an [`OrderStatusReport`] snapshot from the order's current state. /// /// Returns `None` if the order has no `venue_order_id` or `account_id`: /// a status report describes a venue-acknowledged order, and both fields /// are set by the time a venue acknowledges the order. fn to_order_status_report(&self, report_id: Option) -> Option { let account_id = self.account_id()?; let venue_order_id = self.venue_order_id()?; let mut report = OrderStatusReport::new( account_id, self.instrument_id(), Some(self.client_order_id()), venue_order_id, self.order_side().into(), self.order_type(), self.time_in_force(), self.status(), self.quantity(), self.filled_qty(), self.ts_accepted().unwrap_or_else(|| self.ts_last()), self.ts_last(), self.ts_init(), report_id, ) .with_post_only(self.is_post_only()) .with_reduce_only(self.is_reduce_only()); if let Some(price) = self.price() { report = report.with_price(price); } if let Some(activation_price) = self.activation_price() { report = report.with_activation_price(activation_price); } if let Some(trigger_price) = self.trigger_price() { report = report.with_trigger_price(trigger_price); } if let Some(trigger_type) = self.trigger_type() { report = report.with_trigger_type(trigger_type); } if let Some(limit_offset) = self.limit_offset() { report = report.with_limit_offset(limit_offset); } if let Some(trailing_offset) = self.trailing_offset() { report = report.with_trailing_offset(trailing_offset); } if let Some(trailing_offset_type) = self.trailing_offset_type() { report = report.with_trailing_offset_type(trailing_offset_type); } if let Some(display_qty) = self.display_qty() { report = report.with_display_qty(display_qty); } if let Some(expire_time) = self.expire_time() { report = report.with_expire_time(expire_time); } if let Some(contingency_type) = self.contingency_type() { report = report.with_contingency_type(contingency_type); } if let Some(order_list_id) = self.order_list_id() { report = report.with_order_list_id(order_list_id); } if let Some(linked_order_ids) = self.linked_order_ids() { report = report.with_linked_order_ids(linked_order_ids.iter().copied()); } if let Some(parent_order_id) = self.parent_order_id() { report = report.with_parent_order_id(parent_order_id); } if let Some(position_id) = self.position_id() { report = report.with_venue_position_id(position_id); } // The trait exposes no trigger or rejection metadata accessors, // recover the last occurrences from the event history. let mut ts_triggered = None; let mut rejected_reason = None; for event in self.events() { match event { OrderEventAny::Triggered(triggered) => ts_triggered = Some(triggered.ts_event), OrderEventAny::Rejected(rejected) => rejected_reason = Some(rejected.reason), _ => {} } } if let Some(ts_triggered) = ts_triggered { report = report.with_ts_triggered(ts_triggered); } // `cancel_reason` is the report's only reason channel, // reconciliation reads it for rejected orders. if let Some(reason) = rejected_reason { report = report.with_cancel_reason(reason.to_string()); } if let Some(avg_px) = self.avg_px() { report = report.with_avg_px(avg_px); } Some(report) } fn is_triggered(&self) -> Option; // TODO: Temporary on trait fn set_position_id(&mut self, position_id: Option); fn set_quantity(&mut self, quantity: Quantity); fn set_leaves_qty(&mut self, leaves_qty: Quantity); fn set_emulation_trigger(&mut self, emulation_trigger: Option); fn set_is_quote_quantity(&mut self, is_quote_quantity: bool); fn set_liquidity_side(&mut self, liquidity_side: LiquiditySide); fn would_reduce_only(&self, side: PositionSide, position_qty: Quantity) -> bool; fn previous_status(&self) -> Option; } impl From<&T> for OrderInitialized where T: Order, { fn from(order: &T) -> Self { Self { trader_id: order.trader_id(), strategy_id: order.strategy_id(), instrument_id: order.instrument_id(), client_order_id: order.client_order_id(), order_side: order.order_side(), order_type: order.order_type(), quantity: order.quantity(), price: order.price(), activation_price: order.activation_price(), trigger_price: order.trigger_price(), trigger_type: order.trigger_type(), time_in_force: order.time_in_force(), expire_time: order.expire_time(), post_only: order.is_post_only(), reduce_only: order.is_reduce_only(), quote_quantity: order.is_quote_quantity(), display_qty: order.display_qty(), limit_offset: order.limit_offset(), trailing_offset: order.trailing_offset(), trailing_offset_type: order.trailing_offset_type(), emulation_trigger: order.emulation_trigger(), trigger_instrument_id: order.trigger_instrument_id(), contingency_type: order.contingency_type(), order_list_id: order.order_list_id(), linked_order_ids: order.linked_order_ids().map(<[ClientOrderId]>::to_vec), parent_order_id: order.parent_order_id(), exec_algorithm_id: order.exec_algorithm_id(), exec_algorithm_params: order.exec_algorithm_params().map(ToOwned::to_owned), exec_spawn_id: order.exec_spawn_id(), tags: order.tags().map(<[Ustr]>::to_vec), event_id: order.init_id(), ts_event: order.ts_init(), ts_init: order.ts_init(), reconciliation: false, causation_id: None, } } } /// Common event-sourced state shared by order types. /// /// Construct with [`Self::new`] and update through [`Self::apply`]. Inspect history with /// [`Self::events`]; use [`Self::prepend_events`] to retain history when transforming an order, /// or [`OrderAny::from_events`] to reconstruct its state from an event stream. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct OrderCore { events: Vec, #[serde(skip)] fill_totals: Option, pub commissions: IndexMap, pub venue_order_ids: Vec, pub trade_ids: Vec, pub previous_status: Option, pub status: OrderStatus, pub trader_id: TraderId, pub strategy_id: StrategyId, pub instrument_id: InstrumentId, pub client_order_id: ClientOrderId, pub venue_order_id: Option, pub position_id: Option, pub account_id: Option, pub last_trade_id: Option, pub side: OrderSide, pub order_type: OrderType, pub quantity: Quantity, pub time_in_force: TimeInForce, pub liquidity_side: Option, pub is_reduce_only: bool, pub is_quote_quantity: bool, #[serde(default, with = "crate::enums::serde_option_trigger_type")] pub emulation_trigger: Option, #[serde(default, with = "crate::enums::serde_option_contingency_type")] pub contingency_type: Option, pub order_list_id: Option, pub linked_order_ids: Option>, pub parent_order_id: Option, pub exec_algorithm_id: Option, pub exec_algorithm_params: Option>, pub exec_spawn_id: Option, pub tags: Option>, pub filled_qty: Quantity, #[serde(default)] pub voided_qty: Quantity, /// Cumulative quantity voided by non-reopened `OrderFillVoided` corrections, already excluded /// from both `filled_qty` and `leaves_qty`. Kept current by `recompute_fill_state`. #[serde(default)] pub non_reopened_voided_qty: Quantity, pub leaves_qty: Quantity, pub overfill_qty: Quantity, pub avg_px: Option, pub slippage: Option, pub init_id: UUID4, pub ts_init: UnixNanos, pub ts_submitted: Option, pub ts_accepted: Option, pub ts_closed: Option, pub ts_last: UnixNanos, } impl OrderCore { /// Creates a new [`OrderCore`] instance. #[must_use] pub fn new(init: OrderInitialized) -> Self { let events: Vec = vec![OrderEventAny::Initialized(init.clone())]; Self { events, fill_totals: Some(FillTotals::default()), commissions: IndexMap::new(), venue_order_ids: Vec::new(), trade_ids: Vec::new(), previous_status: None, status: OrderStatus::Initialized, trader_id: init.trader_id, strategy_id: init.strategy_id, instrument_id: init.instrument_id, client_order_id: init.client_order_id, venue_order_id: None, position_id: None, account_id: None, last_trade_id: None, side: init.order_side, order_type: init.order_type, quantity: init.quantity, time_in_force: init.time_in_force, liquidity_side: Some(LiquiditySide::NoLiquiditySide), is_reduce_only: init.reduce_only, is_quote_quantity: init.quote_quantity, emulation_trigger: init.emulation_trigger, contingency_type: init.contingency_type, order_list_id: init.order_list_id, linked_order_ids: init.linked_order_ids, parent_order_id: init.parent_order_id, exec_algorithm_id: init.exec_algorithm_id, exec_algorithm_params: init.exec_algorithm_params, exec_spawn_id: init.exec_spawn_id, tags: init.tags, filled_qty: Quantity::zero(init.quantity.precision), voided_qty: Quantity::zero(init.quantity.precision), non_reopened_voided_qty: Quantity::zero(init.quantity.precision), leaves_qty: init.quantity, overfill_qty: Quantity::zero(init.quantity.precision), avg_px: None, slippage: None, init_id: init.event_id, ts_init: init.ts_event, ts_submitted: None, ts_accepted: None, ts_closed: None, ts_last: init.ts_event, } } /// Applies the `event` to the order. /// /// # Errors /// /// Returns an error if the event is invalid for the current order status, or if /// `event.client_order_id()` or `event.strategy_id()` does not match the order. /// A fill that overflows the filled quantity or cumulative commission is rejected /// without changing the order. pub fn apply(&mut self, event: OrderEventAny) -> Result<(), OrderError> { if self.client_order_id != event.client_order_id() { return Err(CorrectnessError::PredicateViolation { message: format!( "Event client_order_id {} does not match order client_order_id {}", event.client_order_id(), self.client_order_id ), } .into()); } if self.strategy_id != event.strategy_id() { return Err(CorrectnessError::PredicateViolation { message: format!( "Event strategy_id {} does not match order strategy_id {}", event.strategy_id(), self.strategy_id ), } .into()); } if let OrderEventAny::FillVoided(event) = &event { self.validate_fill_void(event)?; } let mut has_prior_fill_void = false; // Check for duplicate fill before state transition to maintain consistency if let OrderEventAny::Filled(fill) = &event { has_prior_fill_void = self.has_prior_fill_void(fill.trade_id); if self.is_price_restatement_fill(fill)? { self.restate_fill_price(event); return Ok(()); } } if matches!(event, OrderEventAny::Triggered(_)) && !TRIGGERABLE_ORDER_TYPES.contains(&self.order_type) { return Err(OrderError::InvalidOrderEvent); } match &event { OrderEventAny::Initialized(_) => return Err(OrderError::AlreadyInitialized), // A replayed expiry must not overwrite a later fill's status or timestamps OrderEventAny::Expired(_) if matches!(self.status, OrderStatus::Expired | OrderStatus::Filled) && self .events .iter() .rposition(|event| matches!(event, OrderEventAny::Expired(_))) .is_some_and(|index| { self.events[index + 1..] .iter() .any(|event| matches!(event, OrderEventAny::Filled(_))) }) => { // Cache persistence writes last_event after every successful apply self.events.push(event); return Ok(()); } _ => {} } let is_reclose_after_fill = self.status == OrderStatus::Canceled && matches!(event, OrderEventAny::Canceled(_)) && self .events .iter() .rev() .take_while(|event| !matches!(event, OrderEventAny::Canceled(_))) .any(|event| matches!(event, OrderEventAny::Filled(_))); let new_status = if is_reclose_after_fill { OrderStatus::Canceled } else { self.status.transition(&event)? }; if let OrderEventAny::Filled(fill) = &event && self.filled_qty.checked_add(fill.last_qty).is_none() { return Err(CorrectnessError::PredicateViolation { message: format!( "filled quantity overflowed Quantity raw bounds: {} + {}", self.filled_qty, fill.last_qty ), } .into()); } if let OrderEventAny::Filled(fill) = &event && let Some(commission) = fill.commission && let Some(total) = self.commissions.get(&commission.currency) && total.checked_add(commission).is_none() { return Err(CorrectnessError::PredicateViolation { message: format!( "cumulative commission overflowed Money bounds: {total} + {commission}" ), } .into()); } let rejection_status = if matches!( (&event, self.status), (OrderEventAny::ModifyRejected(_), OrderStatus::PendingUpdate) | (OrderEventAny::CancelRejected(_), OrderStatus::PendingCancel) ) { self.previous_status.ok_or(OrderError::NoPreviousState)? } else { self.status }; // Reject an update carrying fields the order type cannot hold before any // mutation. Runs after the identity and transition checks so an identity // mismatch or invalid transition is reported ahead of a field violation. if let OrderEventAny::Updated(update) = &event { let invalid_fields = match self.order_type { OrderType::Market => update.price.is_some() || update.trigger_price.is_some(), OrderType::StopMarket | OrderType::MarketIfTouched | OrderType::TrailingStopMarket => update.price.is_some(), OrderType::Limit | OrderType::MarketToLimit => update.trigger_price.is_some(), OrderType::StopLimit | OrderType::LimitIfTouched | OrderType::TrailingStopLimit => { false } }; if invalid_fields { return Err(OrderError::InvalidOrderEvent); } } let source_status = self.status; // Save current status as previous_status for ALL transitions except: // - Initialized (no prior state exists) // - ModifyRejected/CancelRejected (need to preserve the pre Pending state) // - When already in Pending* state (avoid overwriting the pre Pending state when receiving multiple pending requests) if !matches!( event, OrderEventAny::Initialized(_) | OrderEventAny::ModifyRejected(_) | OrderEventAny::CancelRejected(_) ) && !matches!( self.status, OrderStatus::PendingUpdate | OrderStatus::PendingCancel ) { self.previous_status = Some(self.status); } self.status = new_status; match &event { // Rejected by the pre-commit check above; kept for exhaustiveness OrderEventAny::Initialized(_) => return Err(OrderError::AlreadyInitialized), OrderEventAny::Denied(event) => self.denied(event), OrderEventAny::Emulated(event) => self.emulated(event), OrderEventAny::Released(event) => self.released(event), OrderEventAny::Submitted(event) => self.submitted(event), OrderEventAny::Rejected(event) => self.rejected(event), OrderEventAny::Accepted(event) => self.accepted(event), OrderEventAny::PendingUpdate(event) => self.pending_update(event), OrderEventAny::PendingCancel(event) => self.pending_cancel(event), OrderEventAny::ModifyRejected(event) => self.modify_rejected(event, rejection_status), OrderEventAny::CancelRejected(event) => self.cancel_rejected(event, rejection_status), OrderEventAny::Updated(event) => self.updated(event), OrderEventAny::Triggered(event) => self.triggered(event), OrderEventAny::Canceled(event) => self.canceled(event), OrderEventAny::Expired(event) => self.expired(event), OrderEventAny::Filled(event) => self.filled(event, source_status), OrderEventAny::FillVoided(event) => self.fill_voided(event, source_status), } if has_prior_fill_void { // Pending fills contribute in full, a restored correction applies once the fill // becomes historical, so the next fill must rebuild rather than reuse this fold. self.fill_totals = None; } self.ts_last = event.ts_event(); self.events.push(event); Ok(()) } fn denied(&mut self, event: &OrderDenied) { self.ts_closed = Some(event.ts_event); } fn emulated(&self, _event: &OrderEmulated) { // Do nothing else } fn released(&mut self, _event: &OrderReleased) { self.emulation_trigger = None; } fn submitted(&mut self, event: &OrderSubmitted) { self.account_id = Some(event.account_id); self.ts_submitted = Some(event.ts_event); } fn accepted(&mut self, event: &OrderAccepted) { self.account_id = Some(event.account_id); self.venue_order_id = Some(event.venue_order_id); self.venue_order_ids.push(event.venue_order_id); self.ts_accepted = Some(event.ts_event); } fn rejected(&mut self, event: &OrderRejected) { self.ts_closed = Some(event.ts_event); } fn pending_update(&self, _event: &OrderPendingUpdate) { // Do nothing else } fn pending_cancel(&self, _event: &OrderPendingCancel) { // Do nothing else } fn modify_rejected(&mut self, _event: &OrderModifyRejected, rejection_status: OrderStatus) { if self.status == OrderStatus::PendingUpdate { self.status = rejection_status; } } fn cancel_rejected(&mut self, _event: &OrderCancelRejected, previous_status: OrderStatus) { self.status = previous_status; } fn triggered(&self, _event: &OrderTriggered) {} fn canceled(&mut self, event: &OrderCanceled) { self.ts_closed = Some(event.ts_event); } fn expired(&mut self, event: &OrderExpired) { self.ts_closed = Some(event.ts_event); } fn validate_fill_void(&self, event: &OrderFillVoided) -> Result<(), OrderError> { let fill = self.events.iter().find_map(|candidate| match candidate { OrderEventAny::Filled(fill) if fill.trade_id == event.trade_id => Some(fill), _ => None, }); if event.voided_qty.is_zero() { return Err(OrderError::OverVoid(event.trade_id)); } let previous = self.latest_fill_void(event.trade_id); if let Some(previous) = previous { if event.voided_qty < previous.voided_qty { return Err(OrderError::StaleFillVoid(event.trade_id)); } if event.voided_qty == previous.voided_qty && event.commission_voided == previous.commission_voided && event.is_reopened == previous.is_reopened { return Err(OrderError::DuplicateFillVoid(event.trade_id)); } } let Some(fill) = fill else { if event.is_reopened { return Err(OrderError::InvalidOrderEvent); } if !self.matches_fill_void_identity(event) { return Err(OrderError::InvalidOrderEvent); } if event.voided_qty > self.quantity || event .commission_voided .is_some_and(|commission| !commission.is_zero()) { return Err(OrderError::OverVoid(event.trade_id)); } return Ok(()); }; if fill.venue_order_id != event.venue_order_id || fill.account_id != event.account_id || fill.order_side != event.order_side || fill.order_type != event.order_type || fill.last_px != event.last_px || fill.currency != event.currency || fill.liquidity_side != event.liquidity_side || fill.position_id != event.position_id { return Err(OrderError::InvalidOrderEvent); } if event.voided_qty > fill.last_qty { return Err(OrderError::OverVoid(event.trade_id)); } self.validate_fill_void_commission(fill, event, previous) } fn matches_fill_void_identity(&self, event: &OrderFillVoided) -> bool { event.trader_id == self.trader_id && event.instrument_id == self.instrument_id && event.order_side == self.side && event.order_type == self.order_type && (self.venue_order_id == Some(event.venue_order_id) || self.venue_order_ids.contains(&event.venue_order_id)) && self.account_id == Some(event.account_id) } fn validate_fill_void_commission( &self, fill: &OrderFilled, event: &OrderFillVoided, previous: Option<&OrderFillVoided>, ) -> Result<(), OrderError> { match (fill.commission, event.commission_voided) { (None, None) => Ok(()), (None, Some(commission)) if commission.is_zero() => Ok(()), (Some(_), None) if previous.and_then(|prior| prior.commission_voided).is_none() => { Ok(()) } (Some(commission), Some(voided)) if commission.currency == voided.currency && commission.is_positive() == voided.is_positive() && commission.is_negative() == voided.is_negative() && voided.abs() <= commission.abs() && previous .and_then(|prior| prior.commission_voided) .is_none_or(|prior| voided.abs() >= prior.abs()) => { Ok(()) } _ => Err(OrderError::InvalidFillVoidCommission(event.trade_id)), } } fn latest_fill_void(&self, trade_id: TradeId) -> Option<&OrderFillVoided> { self.events .iter() .rev() .find_map(|candidate| match candidate { OrderEventAny::FillVoided(event) if event.trade_id == trade_id => Some(event), _ => None, }) } fn fill_voided(&mut self, event: &OrderFillVoided, source_status: OrderStatus) { let has_local_fill = self.events.iter().any( |candidate| matches!(candidate, OrderEventAny::Filled(fill) if fill.trade_id == event.trade_id), ); let non_reopened_voided_qty = self.recompute_fill_state(Some(event)); let unfilled_qty = self.quantity.saturating_sub(self.filled_qty); let working_leaves = unfilled_qty.saturating_sub(non_reopened_voided_qty); match source_status { OrderStatus::Voided => { self.status = OrderStatus::Voided; self.leaves_qty = Quantity::zero(self.quantity.precision); } _ if !has_local_fill && !event.is_reopened => { self.status = OrderStatus::Voided; self.leaves_qty = Quantity::zero(self.quantity.precision); } OrderStatus::Canceled | OrderStatus::Expired => { self.status = source_status; self.leaves_qty = unfilled_qty; } _ if working_leaves.is_zero() => { self.status = OrderStatus::Voided; self.leaves_qty = working_leaves; } OrderStatus::PendingUpdate | OrderStatus::PendingCancel | OrderStatus::Triggered => { self.status = source_status; self.leaves_qty = working_leaves; } _ => { self.status = if self.filled_qty.is_zero() { OrderStatus::Accepted } else { OrderStatus::PartiallyFilled }; self.leaves_qty = working_leaves; } } if self.status.is_open() { self.ts_closed = None; } else if self.status.is_closed() { self.ts_closed = self.ts_closed.or(Some(event.ts_event)); } } fn recompute_fill_state(&mut self, additional: Option<&OrderFillVoided>) -> Quantity { let corrections = self.fill_corrections(additional); let mut totals = FillTotals::default(); let mut filled = Quantity::zero(self.quantity.precision); let mut voided = Quantity::zero(self.quantity.precision); let mut non_reopened_voided = Quantity::zero(self.quantity.precision); let mut commissions = IndexMap::::new(); let mut trade_ids = Vec::new(); let mut last_trade_id = None; let mut position_id = None; let mut liquidity_side = Some(LiquiditySide::NoLiquiditySide); let mut matched_corrections = AHashSet::new(); let prices = self.accounting_prices(); for fill in self.quantity_fills() { let correction = corrections.get(&fill.trade_id).copied(); if correction.is_some() { matched_corrections.insert(fill.trade_id); } let removed = Self::removed_fill_qty(fill, correction); let effective = fill.last_qty - removed; voided = voided.saturating_add(removed); if correction.is_some_and(|event| !event.is_reopened) { non_reopened_voided = non_reopened_voided.saturating_add(removed); } if !effective.is_zero() { let price = prices .get(&fill.trade_id) .copied() .unwrap_or(fill.last_px.as_decimal()); totals.add(effective.as_decimal(), price); filled = filled.saturating_add(effective); trade_ids.push(fill.trade_id); last_trade_id = Some(fill.trade_id); position_id = fill.position_id; liquidity_side = Some(fill.liquidity_side); } if let Some(commission) = fill.commission { let surviving = correction .and_then(|event| event.commission_voided) .filter(|voided| !voided.is_zero()) .map_or(commission, |voided| commission - voided); if !surviving.is_zero() { commissions .entry(surviving.currency) .and_modify(|total| *total = *total + surviving) .or_insert(surviving); } } } for (trade_id, correction) in corrections { if !matched_corrections.contains(&trade_id) { voided = voided.saturating_add(correction.voided_qty); } } self.filled_qty = filled; self.voided_qty = voided; self.non_reopened_voided_qty = non_reopened_voided; self.overfill_qty = self.filled_qty.saturating_sub(self.quantity); self.avg_px = totals.average(); self.fill_totals = Some(totals); self.commissions = commissions; self.trade_ids = trade_ids; self.last_trade_id = last_trade_id; self.position_id = position_id; self.liquidity_side = liquidity_side; non_reopened_voided } fn updated(&mut self, event: &OrderUpdated) { if matches!( self.status, OrderStatus::PendingUpdate | OrderStatus::PendingCancel, ) && let Some(previous) = self.previous_status { self.status = previous; } if event.reconciliation && !self.filled_qty.is_zero() && event.quantity == self.filled_qty { self.status = OrderStatus::Filled; self.ts_closed = Some(event.ts_event); } if let Some(venue_order_id) = &event.venue_order_id && (self.venue_order_id.is_none() || venue_order_id != self.venue_order_id.as_ref().unwrap()) { self.venue_order_id = Some(*venue_order_id); self.venue_order_ids.push(*venue_order_id); } self.is_quote_quantity = event.is_quote_quantity; } /// Applies a new order quantity from an `OrderUpdated` event, keeping `leaves_qty` net of any /// non-reopened voided quantity so it does not become working again (see /// `docs/concepts/events/order_fill_voided.md`). pub(crate) fn apply_updated_quantity(&mut self, new_quantity: Quantity) { self.quantity = new_quantity; let unfilled_qty = self.quantity.saturating_sub(self.filled_qty); let voided_qty = self.non_reopened_voided_qty; // A terminal update sets the quantity to the filled quantity, so a non-reopened void // exceeds what is unfilled without anything being wrong self.leaves_qty = if voided_qty > unfilled_qty { Quantity::zero(unfilled_qty.precision.max(voided_qty.precision)) } else { unfilled_qty - voided_qty }; } fn filled(&mut self, event: &OrderFilled, source_status: OrderStatus) { let new_filled_qty = self .filled_qty .checked_add(event.last_qty) .expect("fill quantity bounds pre-checked"); // Calculate overfill if any if new_filled_qty > self.quantity { let overfill = new_filled_qty - self.quantity; self.overfill_qty = self.overfill_qty.saturating_add(overfill); } let new_leaves_qty = self.leaves_qty.saturating_sub(event.last_qty); if new_filled_qty >= self.quantity { self.status = OrderStatus::Filled; self.ts_closed = Some(event.ts_event); } else if new_leaves_qty.is_zero() && !self.voided_qty.is_zero() { self.status = OrderStatus::Voided; self.ts_closed = Some(event.ts_event); } else if matches!(source_status, OrderStatus::Canceled | OrderStatus::Expired) { self.status = source_status; } else { self.status = OrderStatus::PartiallyFilled; if matches!( source_status, OrderStatus::PendingUpdate | OrderStatus::PendingCancel, ) { self.previous_status = Some(self.status); self.status = source_status; } } let is_historical_venue_order_id = self.venue_order_id.is_some_and(|current| { current != event.venue_order_id && self.venue_order_ids.contains(&event.venue_order_id) }); if !is_historical_venue_order_id { self.venue_order_id = Some(event.venue_order_id); } self.position_id = event.position_id; self.trade_ids.push(event.trade_id); self.last_trade_id = Some(event.trade_id); self.liquidity_side = Some(event.liquidity_side); self.filled_qty = new_filled_qty; self.leaves_qty = new_leaves_qty; self.ts_last = event.ts_event; if let Some(commission) = event.commission { let commission_currency = commission.currency; if let Some(existing_commission) = self.commissions.get_mut(&commission_currency) { *existing_commission = *existing_commission + commission; } else { self.commissions.insert(commission_currency, commission); } } if self.ts_accepted.is_none() { // Set ts_accepted to time of first fill if not previously set self.ts_accepted = Some(event.ts_event); } if self.fill_totals.is_none() { self.fill_totals = Some(self.fill_totals_from_events()); } let totals = self.fill_totals.as_mut().expect("fill totals initialized"); totals.add(event.last_qty.as_decimal(), event.last_px.as_decimal()); self.avg_px = totals.average(); debug_assert!( matches!( self.status, OrderStatus::PartiallyFilled | OrderStatus::Filled | OrderStatus::Voided ) || (self.status == source_status && matches!( source_status, OrderStatus::Canceled | OrderStatus::Expired | OrderStatus::PendingUpdate | OrderStatus::PendingCancel )), "Invariant: status must reflect the fill or preserve a closed or pending source status after fill handler (status={:?})", self.status ); debug_assert!( self.venue_order_id.is_some() && self.last_trade_id.is_some() && !self.trade_ids.is_empty(), "Invariant: venue_order_id, last_trade_id, and trade_ids must be set after fill" ); debug_assert!( self.filled_qty .saturating_add(self.voided_qty) .saturating_add(self.leaves_qty) >= self.quantity, "Invariant: filled_qty + voided_qty + leaves_qty >= quantity (filled={}, voided={}, leaves={}, quantity={})", self.filled_qty, self.voided_qty, self.leaves_qty, self.quantity ); } fn has_prior_fill_void(&self, trade_id: TradeId) -> bool { self.events.iter().any(|candidate| { matches!( candidate, OrderEventAny::FillVoided(existing) if existing.trade_id == trade_id ) }) } fn is_price_restatement_fill(&self, fill: &OrderFilled) -> Result { let mut latest = None; for candidate in &self.events { let OrderEventAny::Filled(existing) = candidate else { continue; }; if existing.trade_id != fill.trade_id { continue; } latest = Some(existing.last_px); } match latest { Some(price) if price == fill.last_px => Err(OrderError::DuplicateFill(fill.trade_id)), Some(_) => Ok(true), None => Ok(false), } } fn restate_fill_price(&mut self, event: OrderEventAny) { self.ts_last = event.ts_event(); self.events.push(event); let totals = self.fill_totals_from_events(); self.avg_px = totals.average(); self.fill_totals = Some(totals); } fn fill_totals_from_events(&self) -> FillTotals { let corrections = self.fill_corrections(None); let prices = self.accounting_prices(); let mut totals = FillTotals::default(); for fill in self.quantity_fills() { let removed = Self::removed_fill_qty(fill, corrections.get(&fill.trade_id).copied()); let effective = (fill.last_qty - removed).as_decimal(); if !effective.is_zero() { let price = prices .get(&fill.trade_id) .copied() .unwrap_or(fill.last_px.as_decimal()); totals.add(effective, price); } } totals } fn quantity_fills(&self) -> Vec<&OrderFilled> { let mut seen = AHashSet::new(); let mut fills = Vec::new(); for candidate in &self.events { let OrderEventAny::Filled(fill) = candidate else { continue; }; if seen.insert(fill.trade_id) { fills.push(fill); } } fills } fn accounting_prices(&self) -> AHashMap { let mut prices = AHashMap::new(); for candidate in &self.events { if let OrderEventAny::Filled(fill) = candidate { prices.insert(fill.trade_id, fill.last_px.as_decimal()); } } prices } fn fill_corrections<'a>( &'a self, additional: Option<&'a OrderFillVoided>, ) -> IndexMap { let mut corrections = IndexMap::new(); for candidate in &self.events { if let OrderEventAny::FillVoided(event) = candidate { corrections.insert(event.trade_id, event); } } if let Some(event) = additional { corrections.insert(event.trade_id, event); } corrections } fn removed_fill_qty(fill: &OrderFilled, correction: Option<&OrderFillVoided>) -> Quantity { correction.map_or_else( || Quantity::zero(fill.last_qty.precision), |event| event.voided_qty.min(fill.last_qty), ) } pub fn set_slippage(&mut self, price: Price) { self.slippage = self.avg_px.and_then(|avg_px| { let current_price = price.as_decimal(); match self.side { OrderSide::Buy if avg_px > current_price => avg_px.checked_sub(current_price), OrderSide::Sell if avg_px < current_price => current_price.checked_sub(avg_px), _ => None, } }); } /// Returns the opposite order side. #[must_use] pub fn opposite_side(side: OrderSide) -> OrderSide { side.opposite() } /// Returns the order side needed to close a position. #[must_use] pub fn closing_side(side: PositionSide) -> Option { match side { PositionSide::Long => Some(OrderSide::Sell), PositionSide::Short => Some(OrderSide::Buy), PositionSide::Flat => None, } } #[must_use] pub fn signed_decimal_qty(&self) -> Decimal { match self.side { OrderSide::Buy => self.quantity.as_decimal(), OrderSide::Sell => -self.quantity.as_decimal(), } } #[must_use] pub fn would_reduce_only(&self, side: PositionSide, position_qty: Quantity) -> bool { if side == PositionSide::Flat { return false; } match (self.side, side) { (OrderSide::Buy, PositionSide::Long) => false, (OrderSide::Buy, PositionSide::Short) => self.leaves_qty <= position_qty, (OrderSide::Sell, PositionSide::Short) => false, (OrderSide::Sell, PositionSide::Long) => self.leaves_qty <= position_qty, (_, PositionSide::Flat) => false, } } #[must_use] pub fn commission(&self, currency: &Currency) -> Option { self.commissions.get(currency).copied() } #[must_use] pub fn commissions(&self) -> IndexMap { self.commissions.clone() } #[must_use] pub fn commissions_vec(&self) -> Vec { self.commissions.values().copied().collect() } /// Returns the order events in application order. #[must_use] pub fn events(&self) -> &[OrderEventAny] { &self.events } /// Prepends historical events in iterator order without applying their state transitions. /// /// Used when transforming an order into another type. The next fill rebuilds the /// average-price fold from the combined history; other derived state is unchanged. pub fn prepend_events(&mut self, events: impl IntoIterator) { self.fill_totals = None; self.events.splice(..0, events); } #[must_use] pub fn init_event(&self) -> Option { self.events.first().cloned() } } // Keep the chronological fold, not a value reconstructed from the rounded average, // corrections rebuild it because saturation and Decimal rounding are not reversible. #[derive(Clone, Debug, Default)] struct FillTotals { notional: Decimal, quantity: Decimal, } impl FillTotals { fn add(&mut self, quantity: Decimal, price: Decimal) { self.notional = self.notional.saturating_add(quantity.saturating_mul(price)); self.quantity = self.quantity.saturating_add(quantity); } fn average(&self) -> Option { self.notional.checked_div(self.quantity) } } #[cfg(test)] mod tests { use std::str::FromStr; use rstest::rstest; use rust_decimal_macros::dec; use strum::IntoEnumIterator; use super::*; use crate::{ enums::{ LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSide, TrailingOffsetType, TriggerType, }, events::order::spec::{ OrderAcceptedSpec, OrderCancelRejectedSpec, OrderCanceledSpec, OrderDeniedSpec, OrderExpiredSpec, OrderFillVoidedSpec, OrderFilledSpec, OrderInitializedSpec, OrderModifyRejectedSpec, OrderPendingCancelSpec, OrderPendingUpdateSpec, OrderRejectedSpec, OrderSubmittedSpec, OrderTriggeredSpec, OrderUpdatedSpec, }, identifiers::InstrumentId, instruments::{CurrencyPair, Instrument, InstrumentAny, stubs::audusd_sim}, orders::{MarketOrder, builder::OrderTestBuilder, stubs::TestOrderStubs}, stubs::TestDefault, types::{ Price, Quantity, money::{MONEY_RAW_MAX, MONEY_RAW_MIN, MoneyRaw}, quantity::{QUANTITY_RAW_MAX, QuantityRaw}, }, }; // TODO: WIP // fn test_display_market_order() { // let order = MarketOrder::default(); // assert_eq!(order.events().len(), 1); // assert_eq!( // stringify!(order.events().get(0)), // stringify!(OrderInitialized) // ); // } #[rstest] #[case(OrderSide::Buy, OrderSide::Sell)] #[case(OrderSide::Sell, OrderSide::Buy)] fn test_order_opposite_side(#[case] order_side: OrderSide, #[case] expected_side: OrderSide) { let result = OrderCore::opposite_side(order_side); assert_eq!(result, expected_side); } #[rstest] #[case(PositionSide::Long, Some(OrderSide::Sell))] #[case(PositionSide::Short, Some(OrderSide::Buy))] #[case(PositionSide::Flat, None)] fn test_closing_side( #[case] position_side: PositionSide, #[case] expected_side: Option, ) { let result = OrderCore::closing_side(position_side); assert_eq!(result, expected_side); } #[rstest] #[case(OrderSide::Buy, dec!(10_000))] #[case(OrderSide::Sell, dec!(-10_000))] fn test_signed_decimal_qty(#[case] order_side: OrderSide, #[case] expected: Decimal) { let order: MarketOrder = OrderInitializedSpec::builder() .order_side(order_side) .quantity(Quantity::from(10_000)) .build() .try_into() .unwrap(); let result = order.signed_decimal_qty(); assert_eq!(result, expected); } #[rustfmt::skip] #[rstest] #[case(OrderSide::Buy, Quantity::from(100), PositionSide::Long, Quantity::from(50), false)] #[case(OrderSide::Buy, Quantity::from(50), PositionSide::Short, Quantity::from(50), true)] #[case(OrderSide::Buy, Quantity::from(50), PositionSide::Short, Quantity::from(100), true)] #[case(OrderSide::Buy, Quantity::from(50), PositionSide::Flat, Quantity::from(0), false)] #[case(OrderSide::Sell, Quantity::from(50), PositionSide::Flat, Quantity::from(0), false)] #[case(OrderSide::Sell, Quantity::from(50), PositionSide::Long, Quantity::from(50), true)] #[case(OrderSide::Sell, Quantity::from(50), PositionSide::Long, Quantity::from(100), true)] #[case(OrderSide::Sell, Quantity::from(100), PositionSide::Short, Quantity::from(50), false)] fn test_would_reduce_only( #[case] order_side: OrderSide, #[case] order_qty: Quantity, #[case] position_side: PositionSide, #[case] position_qty: Quantity, #[case] expected: bool, ) { let order: MarketOrder = OrderInitializedSpec::builder() .order_side(order_side) .quantity(order_qty) .build() .try_into() .unwrap(); assert_eq!( order.would_reduce_only(position_side, position_qty), expected ); } #[rstest] fn test_order_state_transition_denied() { let mut order: MarketOrder = OrderInitializedSpec::builder().build().try_into().unwrap(); let denied = OrderDeniedSpec::builder().build(); let event = OrderEventAny::Denied(denied); order.apply(event.clone()).unwrap(); assert_eq!(order.status, OrderStatus::Denied); assert!(order.is_closed()); assert!(!order.is_open()); assert_eq!(order.event_count(), 2); assert_eq!(order.last_event(), &event); } #[rstest] fn test_order_life_cycle_to_filled() { let init = OrderInitializedSpec::builder().build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let filled = OrderFilledSpec::builder().build(); let mut order: MarketOrder = init.clone().try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(filled)).unwrap(); assert_eq!(order.client_order_id, init.client_order_id); assert_eq!(order.status(), OrderStatus::Filled); assert_eq!(order.filled_qty(), Quantity::from(100_000)); assert_eq!(order.leaves_qty(), Quantity::from(0)); assert_eq!(order.avg_px(), Some(dec!(1.0))); assert!(!order.is_open()); assert!(order.is_closed()); assert_eq!(order.commission(&Currency::USD()), None); assert_eq!(order.commissions(), &IndexMap::new()); } #[rstest] fn test_order_life_cycle_fills_with_negative_prices() { // Options and spreads can legitimately trade at negative prices. The // weighted average-price update must not panic when `last_px` or the // prior `avg_px` is below zero. let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let fill1 = OrderFilledSpec::builder() .last_qty(Quantity::from(50_000)) .last_px(Price::from("-5.00000")) .trade_id(TradeId::from("TRADE-1")) .build(); let fill2 = OrderFilledSpec::builder() .last_qty(Quantity::from(50_000)) .last_px(Price::from("-7.00000")) .trade_id(TradeId::from("TRADE-2")) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(fill1)).unwrap(); order.apply(OrderEventAny::Filled(fill2)).unwrap(); assert_eq!(order.status(), OrderStatus::Filled); assert_eq!(order.filled_qty(), Quantity::from(100_000)); assert_eq!(order.leaves_qty(), Quantity::from(0)); // Weighted avg: (50_000 * -5.0 + 50_000 * -7.0) / 100_000 = -6.0 assert_eq!(order.avg_px(), Some(dec!(-6.0))); } fn fill(trade_id: &str, last_qty: &str, last_px: &str) -> (TradeId, Quantity, Price) { ( TradeId::from(trade_id), Quantity::from(last_qty), Price::from(last_px), ) } fn market_order_with_fills( order_side: OrderSide, quantity: Quantity, fills: &[(TradeId, Quantity, Price)], ) -> MarketOrder { let mut order: MarketOrder = OrderInitializedSpec::builder() .order_side(order_side) .quantity(quantity) .build() .try_into() .unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); for (trade_id, last_qty, last_px) in fills { order .apply(OrderEventAny::Filled( OrderFilledSpec::builder() .order_side(order_side) .trade_id(*trade_id) .last_qty(*last_qty) .last_px(*last_px) .build(), )) .unwrap(); } order } #[rstest] #[case::cloned("clone")] #[case::snapshot("snapshot")] #[case::replayed("replay")] fn test_fill_totals_survive_corrections_and_restoration(#[case] restoration: &str) { let mut order = market_order_with_fills(OrderSide::Buy, Quantity::from(20), &[]); let first = OrderFilledSpec::builder() .trade_id(TradeId::from("T-1")) .last_qty(Quantity::from(4)) .last_px(Price::from("1.00")) .build(); let second = OrderFilledSpec::builder() .trade_id(TradeId::from("T-2")) .last_qty(Quantity::from(2)) .last_px(Price::from("2.00")) .build(); let third = OrderFilledSpec::builder() .trade_id(TradeId::from("T-3")) .last_qty(Quantity::from(1)) .last_px(Price::from("-3.00")) .build(); let fourth = OrderFilledSpec::builder() .trade_id(TradeId::from("T-4")) .last_qty(Quantity::from(2)) .last_px(Price::from("0.15")) .build(); let steps = [ ( OrderEventAny::Filled(first.clone()), 4, 0, 16, Some(dec!(1)), ), ( OrderEventAny::Filled(second.clone()), 6, 0, 14, Some(dec!(1.3333333333333333333333333333)), ), ( OrderEventAny::FillVoided(matching_fill_void(&first, Quantity::from(1), None)), 5, 1, 15, Some(dec!(1.4)), ), ( OrderEventAny::FillVoided(matching_fill_void(&first, Quantity::from(3), None)), 3, 3, 17, Some(dec!(1.6666666666666666666666666667)), ), ( OrderEventAny::Filled(third.clone()), 4, 3, 16, Some(dec!(0.5)), ), ( OrderEventAny::FillVoided(matching_fill_void(&first, Quantity::from(4), None)), 3, 4, 17, Some(dec!(0.3333333333333333333333333333)), ), ( OrderEventAny::FillVoided(matching_fill_void(&second, Quantity::from(2), None)), 1, 6, 19, Some(dec!(-3)), ), ( OrderEventAny::FillVoided(matching_fill_void(&third, Quantity::from(1), None)), 0, 7, 20, None, ), (OrderEventAny::Filled(fourth), 2, 7, 18, Some(dec!(0.15))), ]; let mut expected_events = OrderCore::events(&order).to_vec(); for (event, filled, voided, leaves, average) in steps { let mut restored = match restoration { "clone" => order.clone(), "snapshot" => { let snapshot = serde_json::to_value(&order).unwrap(); assert!( !snapshot["core"] .as_object() .unwrap() .contains_key("fill_totals") ); serde_json::from_value(snapshot).unwrap() } "replay" => { let replayed = OrderAny::from_events(expected_events.clone()).unwrap(); let OrderAny::Market(replayed) = replayed else { panic!("Expected a market order"); }; replayed } _ => unreachable!(), }; order.apply(event.clone()).unwrap(); restored.apply(event.clone()).unwrap(); expected_events.push(event); assert_eq!(restored.avg_px(), average); assert_eq!(restored.filled_qty(), Quantity::from(filled)); assert_eq!(restored.voided_qty(), Quantity::from(voided)); assert_eq!(restored.leaves_qty(), Quantity::from(leaves)); assert_eq!(OrderCore::events(&restored), expected_events); assert_eq!( serde_json::to_value(&restored).unwrap(), serde_json::to_value(&order).unwrap() ); order = restored; } } #[rstest] fn test_prepended_events_invalidate_fill_totals_without_applying_state() { let mut order = market_order_with_fills( OrderSide::Buy, Quantity::from(10), &[fill("T-CURRENT", "1", "2.00")], ); let historical = [ OrderEventAny::Filled( OrderFilledSpec::builder() .trade_id(TradeId::from("T-PAST-1")) .last_qty(Quantity::from(2)) .last_px(Price::from("3.00")) .build(), ), OrderEventAny::Filled( OrderFilledSpec::builder() .trade_id(TradeId::from("T-PAST-2")) .last_qty(Quantity::from(1)) .last_px(Price::from("4.00")) .build(), ), ]; let mut expected_events = historical.to_vec(); expected_events.extend_from_slice(OrderCore::events(&order)); order.prepend_events(historical); assert_eq!(OrderCore::events(&order), expected_events); assert_eq!(order.avg_px(), Some(dec!(2))); assert_eq!(order.filled_qty(), Quantity::from(1)); assert_eq!(order.leaves_qty(), Quantity::from(9)); let next = OrderEventAny::Filled( OrderFilledSpec::builder() .trade_id(TradeId::from("T-NEXT")) .last_qty(Quantity::from(2)) .last_px(Price::from("3.00")) .build(), ); expected_events.push(next.clone()); order.apply(next).unwrap(); assert_eq!(OrderCore::events(&order), expected_events); assert_eq!(order.avg_px(), Some(dec!(3))); assert_eq!(order.filled_qty(), Quantity::from(3)); assert_eq!(order.leaves_qty(), Quantity::from(7)); assert_eq!(order.voided_qty(), Quantity::from(0)); } #[rstest] fn test_restored_fill_totals_use_history_instead_of_snapshot_average() { let order = market_order_with_fills( OrderSide::Buy, Quantity::from(10), &[fill("T-1", "1", "0.10"), fill("T-2", "2", "0.20")], ); let mut snapshot = serde_json::to_value(&order).unwrap(); snapshot["core"]["avg_px"] = serde_json::to_value(dec!(99)).unwrap(); let mut restored: MarketOrder = serde_json::from_value(snapshot).unwrap(); restored .apply(OrderEventAny::Filled( OrderFilledSpec::builder() .trade_id(TradeId::from("T-3")) .last_qty(Quantity::from(2)) .last_px(Price::from("0.35")) .build(), )) .unwrap(); assert_eq!(restored.avg_px(), Some(dec!(0.24))); assert_eq!(restored.filled_qty(), Quantity::from(5)); assert_eq!(restored.leaves_qty(), Quantity::from(5)); assert_eq!(restored.voided_qty(), Quantity::from(0)); } #[rstest] fn test_restored_unknown_fill_void_applies_after_matching_fill() { let mut order = market_order_with_fills(OrderSide::Buy, Quantity::from(10), &[]); let first = OrderFilledSpec::builder() .trade_id(TradeId::from("T-RESTORED")) .last_qty(Quantity::from(4)) .last_px(Price::from("1.00")) .build(); let mut correction = matching_fill_void(&first, Quantity::from(2), None); correction.is_reopened = false; order.apply(OrderEventAny::FillVoided(correction)).unwrap(); let mut snapshot = serde_json::to_value(&order).unwrap(); snapshot["core"]["status"] = serde_json::to_value(OrderStatus::Accepted).unwrap(); snapshot["core"]["leaves_qty"] = serde_json::to_value(Quantity::from(8)).unwrap(); let mut restored: MarketOrder = serde_json::from_value(snapshot).unwrap(); restored.apply(OrderEventAny::Filled(first)).unwrap(); assert_eq!(restored.avg_px(), Some(dec!(1))); restored .apply(OrderEventAny::Filled( OrderFilledSpec::builder() .trade_id(TradeId::from("T-NEXT")) .last_qty(Quantity::from(2)) .last_px(Price::from("3.00")) .build(), )) .unwrap(); assert_eq!(restored.avg_px(), Some(dec!(2))); assert_eq!(restored.filled_qty(), Quantity::from(6)); assert_eq!(restored.voided_qty(), Quantity::from(2)); assert_eq!(restored.leaves_qty(), Quantity::from(2)); } #[rstest] #[case::positive(Decimal::MAX, Decimal::ONE, Decimal::MAX)] #[case::negative(Decimal::MIN, -Decimal::ONE, Decimal::MIN)] fn test_fill_totals_preserve_saturation_order( #[case] price: Decimal, #[case] increment: Decimal, #[case] saturated: Decimal, ) { let mut totals = FillTotals::default(); totals.add(dec!(2), price); totals.add(Decimal::ONE, increment); totals.add(Decimal::ONE, -increment); assert_eq!(totals.notional, saturated - increment); assert_eq!(totals.quantity, dec!(4)); assert_eq!( totals.average(), (saturated - increment).checked_div(dec!(4)) ); } #[rstest] fn test_fill_totals_preserve_quantity_saturation() { let mut totals = FillTotals::default(); totals.add(Decimal::MAX, Decimal::ZERO); totals.add(Decimal::ONE, Decimal::ONE); assert_eq!(totals.notional, Decimal::ONE); assert_eq!(totals.quantity, Decimal::MAX); assert_eq!(totals.average(), Decimal::ONE.checked_div(Decimal::MAX)); } #[rstest] #[case(OrderSide::Buy)] #[case(OrderSide::Sell)] fn test_avg_px_weighted_average_is_exact(#[case] order_side: OrderSide) { // (1 * 0.07) + (2 * 0.13) + (3 * 0.29) = 1.20 over 6 filled, so the exact average is // 0.20. Accumulating the notional in f64 returns 0.19999999999999998 instead. let order = market_order_with_fills( order_side, Quantity::from(6), &[ fill("TRADE-1", "1", "0.07"), fill("TRADE-2", "2", "0.13"), fill("TRADE-3", "3", "0.29"), ], ); assert_eq!(order.status(), OrderStatus::Filled); assert_eq!(order.filled_qty(), Quantity::from(6)); assert_eq!(order.leaves_qty(), Quantity::from(0)); assert_eq!(order.voided_qty(), Quantity::from(0)); assert_eq!(order.avg_px(), Some(dec!(0.2))); } #[rstest] fn test_avg_px_keeps_a_quotient_no_f64_can_hold() { // (1 * 1.00) + (2 * 2.00) = 5.00 over 3 filled. The exact quotient repeats, so `Decimal` // carries it to its full 28-place scale where the widest `f64` holds 1.6666666666666667. let order = market_order_with_fills( OrderSide::Buy, Quantity::from(3), &[fill("TRADE-1", "1", "1.00"), fill("TRADE-2", "2", "2.00")], ); assert_eq!(order.status(), OrderStatus::Filled); assert_eq!(order.filled_qty(), Quantity::from(3)); assert_eq!(order.avg_px(), Some(dec!(1.6666666666666666666666666667))); assert_ne!( order.avg_px(), Some(Decimal::from_f64_retain(5.0_f64 / 3.0).unwrap()) ); } #[rstest] #[case(OrderSide::Buy, "1.07", "1.00", Some(dec!(0.07)))] #[case(OrderSide::Sell, "0.93", "1.00", Some(dec!(0.07)))] #[case(OrderSide::Buy, "0.93", "1.00", None)] #[case(OrderSide::Sell, "1.07", "1.00", None)] fn test_set_slippage_by_side( #[case] order_side: OrderSide, #[case] fill_px: &str, #[case] reference_px: &str, #[case] expected: Option, ) { // Slippage only accrues against the order: a buy filled above the reference price and a // sell filled below it. The opposite cases are price improvement and stay `None`. let mut order = market_order_with_fills(order_side, Quantity::from(1), &[fill("T-1", "1", fill_px)]); order.set_slippage(Price::from(reference_px)); assert_eq!(order.avg_px(), Some(Decimal::from_str(fill_px).unwrap())); assert_eq!(order.slippage(), expected); } #[rstest] #[case::limit(OrderType::Limit)] #[case::limit_if_touched(OrderType::LimitIfTouched)] #[case::market_if_touched(OrderType::MarketIfTouched)] #[case::market_to_limit(OrderType::MarketToLimit)] #[case::stop_limit(OrderType::StopLimit)] #[case::stop_market(OrderType::StopMarket)] #[case::trailing_stop_limit(OrderType::TrailingStopLimit)] #[case::trailing_stop_market(OrderType::TrailingStopMarket)] fn test_fill_void_recomputes_slippage(#[case] order_type: OrderType) { let venue_order_id = VenueOrderId::from("V-SLIPPAGE"); let account_id = AccountId::from("SIM-SLIPPAGE"); let mut order = OrderTestBuilder::new(order_type) .instrument_id(InstrumentId::from("ETHUSDT-LINEAR.BYBIT")) .client_order_id(ClientOrderId::from("O-SLIPPAGE")) .side(OrderSide::Buy) .quantity(Quantity::from(10)) .price(Price::from("100.00")) .trigger_price(Price::from("100.00")) .trigger_type(TriggerType::LastPrice) .limit_offset(dec!(1)) .trailing_offset(dec!(1)) .trailing_offset_type(TrailingOffsetType::Price) .build(); let accepted = OrderAcceptedSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .venue_order_id(venue_order_id) .account_id(account_id) .build(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); if order_type == OrderType::MarketToLimit { order .apply(OrderEventAny::Updated( OrderUpdatedSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .quantity(order.quantity()) .venue_order_id(venue_order_id) .account_id(account_id) .price(Price::from("100.00")) .build(), )) .unwrap(); } let fill = |trade_id: &str, last_px: &str| { OrderFilledSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .venue_order_id(venue_order_id) .account_id(account_id) .trade_id(TradeId::from(trade_id)) .order_side(order.order_side()) .order_type(order.order_type()) .last_qty(Quantity::from(5)) .last_px(Price::from(last_px)) .build() }; let unfavorable_fill = fill("T-SLIPPAGE-1", "110.00"); let reference_fill = fill("T-SLIPPAGE-2", "100.00"); order .apply(OrderEventAny::Filled(unfavorable_fill.clone())) .unwrap(); order.apply(OrderEventAny::Filled(reference_fill)).unwrap(); assert_eq!(order.avg_px(), Some(dec!(105))); assert_eq!(order.slippage(), Some(dec!(5))); let correction = matching_fill_void(&unfavorable_fill, unfavorable_fill.last_qty, None); let event_count = order.event_count(); order.apply(OrderEventAny::FillVoided(correction)).unwrap(); let replayed = OrderAny::from_events(order.events().into_iter().cloned().collect()).unwrap(); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.filled_qty(), Quantity::from(5)); assert_eq!(order.voided_qty(), Quantity::from(5)); assert_eq!(order.leaves_qty(), Quantity::from(5)); assert_eq!(order.avg_px(), Some(dec!(100))); assert_eq!(order.slippage(), None); assert_eq!(order.event_count(), event_count + 1); assert_eq!(replayed.status(), order.status()); assert_eq!(replayed.filled_qty(), order.filled_qty()); assert_eq!(replayed.voided_qty(), order.voided_qty()); assert_eq!(replayed.leaves_qty(), order.leaves_qty()); assert_eq!(replayed.avg_px(), order.avg_px()); assert_eq!(replayed.slippage(), order.slippage()); } #[rstest] fn test_avg_px_invariant_to_fill_arrival_order() { // (50_000 * 1.00001) + (30_000 * 1.00002) + (20_000 * 1.00003) = 100_001.7 over // 100_000 filled, so the exact average is 1.000017 whichever order the fills arrive // in. The f64 fold returns 1.0000170000000002 for the ascending sequence only. let mut fills = [ fill("TRADE-1", "50000", "1.00001"), fill("TRADE-2", "30000", "1.00002"), fill("TRADE-3", "20000", "1.00003"), ]; let ascending = market_order_with_fills(OrderSide::Buy, Quantity::from(100_000), &fills); fills.reverse(); let descending = market_order_with_fills(OrderSide::Buy, Quantity::from(100_000), &fills); assert_eq!(ascending.avg_px(), Some(dec!(1.000_017))); assert_eq!(descending.avg_px(), ascending.avg_px()); assert_eq!(descending.filled_qty(), ascending.filled_qty()); } #[rstest] fn test_avg_px_after_fill_void_matches_order_without_voided_fill() { let surviving = [fill("TRADE-1", "1", "0.05"), fill("TRADE-2", "3", "0.15")]; let voided = fill("TRADE-VOIDED", "2", "0.13"); let expected = market_order_with_fills(OrderSide::Buy, Quantity::from(6), &surviving); let mut corrected = market_order_with_fills( OrderSide::Buy, Quantity::from(6), &[surviving[0], surviving[1], voided], ); corrected .apply(OrderEventAny::FillVoided( OrderFillVoidedSpec::builder() .trade_id(voided.0) .voided_qty(voided.1) .last_px(voided.2) .build(), )) .unwrap(); let replayed = OrderAny::from_events(corrected.events().into_iter().cloned().collect()).unwrap(); // (1 * 0.05) + (3 * 0.15) = 0.50 over 4 filled: 0.125 exactly, whether the fold runs // incrementally over the fills, as the rebuild after the void drops the third fill, or // over the whole event stream on replay. The f64 rebuild returns 0.12499999999999999. assert_eq!(expected.avg_px(), Some(dec!(0.125))); assert_eq!(corrected.avg_px(), expected.avg_px()); assert_eq!(replayed.avg_px(), expected.avg_px()); assert_eq!(corrected.status(), OrderStatus::Voided); assert_eq!(corrected.filled_qty(), Quantity::from(4)); assert_eq!(corrected.voided_qty(), Quantity::from(2)); assert_eq!(corrected.leaves_qty(), Quantity::from(0)); assert_eq!(replayed.filled_qty(), corrected.filled_qty()); assert_eq!(replayed.voided_qty(), corrected.voided_qty()); } #[rstest] #[case::lower("1")] #[case::equal("1.000")] fn test_fractional_fill_correction_preserves_quantity_precision(#[case] quantity: &str) { let mut order = market_order_with_fills( OrderSide::Buy, Quantity::from(quantity), &[fill("TRADE-1", "0.750", "1.00")], ); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.filled_qty().as_decimal(), dec!(0.750)); assert_eq!(order.leaves_qty().as_decimal(), dec!(0.250)); order .apply(OrderEventAny::FillVoided( OrderFillVoidedSpec::builder() .trade_id(TradeId::from("TRADE-1")) .voided_qty(Quantity::from("0.125")) .last_px(Price::from("1.00")) .build(), )) .unwrap(); assert_eq!(order.filled_qty().as_decimal(), dec!(0.625)); assert_eq!(order.voided_qty().as_decimal(), dec!(0.125)); assert_eq!(order.leaves_qty().as_decimal(), dec!(0.250)); assert_eq!(order.overfill_qty().as_decimal(), dec!(0)); } #[rstest] #[case::lower("1")] #[case::equal("1.000")] fn test_fractional_overfill_preserves_quantity_precision( #[case] quantity: &str, #[values(false, true)] partial_fill_first: bool, ) { let fills = if partial_fill_first { vec![ fill("TRADE-1", "0.750", "1.00"), fill("TRADE-2", "0.500", "1.00"), ] } else { vec![fill("TRADE-1", "1.250", "1.00")] }; let order = market_order_with_fills(OrderSide::Buy, Quantity::from(quantity), &fills); assert_eq!(order.status(), OrderStatus::Filled); assert_eq!(order.filled_qty().as_decimal(), dec!(1.250)); assert_eq!(order.overfill_qty().as_decimal(), dec!(0.250)); assert_eq!(order.leaves_qty().as_decimal(), dec!(0)); } #[rstest] fn test_avg_px_recomputed_from_partially_voided_fill() { let mut order = market_order_with_fills( OrderSide::Buy, Quantity::from(6), &[fill("TRADE-1", "4", "1.00"), fill("TRADE-2", "2", "1.14")], ); order .apply(OrderEventAny::FillVoided( OrderFillVoidedSpec::builder() .trade_id(TradeId::from("TRADE-1")) .voided_qty(Quantity::from(2)) .last_px(Price::from("1.00")) .build(), )) .unwrap(); // Surviving (2 * 1.00) + (2 * 1.14) = 4.28 over 4 filled: 1.07 exactly, where the f64 // rebuild returns 1.0699999999999998. assert_eq!(order.status(), OrderStatus::Voided); assert_eq!(order.filled_qty(), Quantity::from(4)); assert_eq!(order.voided_qty(), Quantity::from(2)); assert_eq!(order.leaves_qty(), Quantity::from(0)); assert_eq!(order.avg_px(), Some(dec!(1.07))); } #[rstest] fn test_avg_px_over_reopened_fill_void_and_replacement_fill() { let mut order = market_order_with_fills( OrderSide::Buy, Quantity::from(6), &[fill("TRADE-1", "4", "1.00")], ); order .apply(OrderEventAny::FillVoided( OrderFillVoidedSpec::builder() .trade_id(TradeId::from("TRADE-1")) .voided_qty(Quantity::from(2)) .last_px(Price::from("1.00")) .is_reopened(true) .build(), )) .unwrap(); order .apply(OrderEventAny::Filled( OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-2")) .last_qty(Quantity::from(2)) .last_px(Price::from("1.14")) .build(), )) .unwrap(); order.set_slippage(Price::from("1.00")); // The reopened void leaves 2 of the first fill, so the replacement fill folds against // the surviving quantity: (2 * 1.00) + (2 * 1.14) = 4.28 over 4 filled, or 1.07 // exactly. The f64 update returns 1.0699999999999998, which slippage then inherits. assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.filled_qty(), Quantity::from(4)); assert_eq!(order.voided_qty(), Quantity::from(2)); assert_eq!(order.leaves_qty(), Quantity::from(2)); assert_eq!(order.avg_px(), Some(dec!(1.07))); assert_eq!(order.slippage(), Some(dec!(0.07))); } #[rstest] fn test_avg_px_cleared_when_every_fill_is_voided() { let mut order = market_order_with_fills( OrderSide::Buy, Quantity::from(6), &[fill("TRADE-1", "4", "1.50")], ); order .apply(OrderEventAny::FillVoided( OrderFillVoidedSpec::builder() .trade_id(TradeId::from("TRADE-1")) .voided_qty(Quantity::from(4)) .last_px(Price::from("1.50")) .build(), )) .unwrap(); assert_eq!(order.status(), OrderStatus::Accepted); assert_eq!(order.filled_qty(), Quantity::from(0)); assert_eq!(order.voided_qty(), Quantity::from(4)); assert_eq!(order.leaves_qty(), Quantity::from(2)); assert_eq!(order.avg_px(), None); } #[cfg(feature = "high-precision")] #[rstest] fn test_avg_px_exact_over_high_precision_quantities() { // Same weighting as `test_avg_px_invariant_to_fill_arrival_order`, at a quantity // precision only the wider raw backing can hold. let order = market_order_with_fills( OrderSide::Buy, Quantity::from("0.000000000010"), &[ fill("TRADE-1", "0.000000000005", "1.00001"), fill("TRADE-2", "0.000000000003", "1.00002"), fill("TRADE-3", "0.000000000002", "1.00003"), ], ); assert_eq!(order.status(), OrderStatus::Filled); assert_eq!(order.filled_qty(), Quantity::from("0.000000000010")); assert_eq!(order.avg_px(), Some(dec!(1.000_017))); } #[cfg(feature = "high-precision")] #[rstest] fn test_avg_px_exact_at_the_widest_representable_fill_scale() { // Pins the exactness bound: a 16-decimal quantity against a 12-decimal price needs the // 28 fractional digits Decimal carries, so no product rounds. let order = market_order_with_fills( OrderSide::Buy, Quantity::from("0.0000000000000008"), &[ fill("TRADE-1", "0.0000000000000002", "0.000000000001"), fill("TRADE-2", "0.0000000000000006", "0.000000000003"), ], ); // (2e-16 * 1e-12) + (6e-16 * 3e-12) = 2e-27 over 8e-16 filled: 2.5e-12 exactly. assert_eq!(order.status(), OrderStatus::Filled); assert_eq!(order.filled_qty(), Quantity::from("0.0000000000000008")); assert_eq!(order.avg_px(), Some(dec!(2.5e-12))); } #[rstest] fn test_order_life_cycle_accumulates_fill_commissions() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let fill1 = OrderFilledSpec::builder() .last_qty(Quantity::from(50_000)) .trade_id(TradeId::from("TRADE-1")) .commission(Money::from("1.25 USD")) .build(); let fill2 = OrderFilledSpec::builder() .last_qty(Quantity::from(50_000)) .trade_id(TradeId::from("TRADE-2")) .commission(Money::from("1.35 USD")) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(fill1)).unwrap(); order.apply(OrderEventAny::Filled(fill2)).unwrap(); assert_eq!(order.status(), OrderStatus::Filled); assert_eq!( order.commission(&Currency::USD()), Some(Money::from("2.60 USD")) ); assert_eq!(order.commissions_vec(), vec![Money::from("2.60 USD")]); } #[rstest] fn test_fill_void_reopens_only_when_explicitly_marked() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let filled = OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-VOID")) .last_qty(Quantity::from(100_000)) .commission(Money::from("1.00 USD")) .build(); let fill_voided = OrderFillVoidedSpec::builder() .trade_id(filled.trade_id) .voided_qty(Quantity::from(40_000)) .commission_voided(Money::from("0.40 USD")) .build(); let later_fill = OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-AFTER-REOPEN")) .last_qty(Quantity::from(40_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); order.apply(OrderEventAny::Filled(filled)).unwrap(); let mut terminal = order.clone(); terminal .apply(OrderEventAny::FillVoided(fill_voided.clone())) .unwrap(); let fill_while_terminal = terminal.apply(OrderEventAny::Filled(later_fill.clone())); order .apply(OrderEventAny::FillVoided(OrderFillVoided { is_reopened: true, ..fill_voided })) .unwrap(); let status_after_correction = order.status(); order.apply(OrderEventAny::Filled(later_fill)).unwrap(); assert!(matches!( fill_while_terminal, Err(OrderError::InvalidStateTransition) )); assert_eq!(terminal.status(), OrderStatus::Voided); assert_eq!(status_after_correction, OrderStatus::PartiallyFilled); assert_eq!(order.status(), OrderStatus::Filled); assert_eq!(order.filled_qty(), Quantity::from(100_000)); assert_eq!(order.voided_qty(), Quantity::from(40_000)); assert_eq!(order.leaves_qty(), Quantity::from(0)); assert_eq!( order.commission(&Currency::USD()), Some(Money::from("0.60 USD")) ); } #[rstest] fn test_fill_void_preserves_existing_working_remainder() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let filled = OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-VOID")) .last_qty(Quantity::from(40_000)) .build(); let later_fill = OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-REMAINDER")) .last_qty(Quantity::from(60_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); order.apply(OrderEventAny::Filled(filled.clone())).unwrap(); order .apply(OrderEventAny::FillVoided( OrderFillVoidedSpec::builder() .trade_id(filled.trade_id) .voided_qty(filled.last_qty) .build(), )) .unwrap(); assert_eq!(order.status(), OrderStatus::Accepted); assert_eq!(order.filled_qty(), Quantity::from(0)); assert_eq!(order.voided_qty(), Quantity::from(40_000)); assert_eq!(order.leaves_qty(), Quantity::from(60_000)); assert!(order.is_open()); order.apply(OrderEventAny::Filled(later_fill)).unwrap(); assert_eq!(order.status(), OrderStatus::Voided); assert_eq!(order.filled_qty(), Quantity::from(60_000)); assert_eq!(order.voided_qty(), Quantity::from(40_000)); assert_eq!(order.leaves_qty(), Quantity::from(0)); assert!(order.is_closed()); } #[rstest] fn test_order_updated_after_non_reopened_fill_void_keeps_void_out_of_leaves( #[values( OrderType::Market, OrderType::Limit, OrderType::LimitIfTouched, OrderType::MarketIfTouched, OrderType::MarketToLimit, OrderType::StopLimit, OrderType::StopMarket, OrderType::TrailingStopLimit, OrderType::TrailingStopMarket )] order_type: OrderType, #[values(8, 10, 12)] updated_qty: u64, ) { let venue_order_id = VenueOrderId::from("V-VOID-UPDATE"); let account_id = AccountId::from("SIM-VOID-UPDATE"); let mut order = OrderTestBuilder::new(order_type) .instrument_id(InstrumentId::from("ETHUSDT-LINEAR.BYBIT")) .client_order_id(ClientOrderId::from("O-VOID-UPDATE")) .side(OrderSide::Buy) .quantity(Quantity::from(10)) .price(Price::from("100.00")) .trigger_price(Price::from("100.00")) .trigger_type(TriggerType::LastPrice) .limit_offset(dec!(1)) .trailing_offset(dec!(1)) .trailing_offset_type(TrailingOffsetType::Price) .build(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .venue_order_id(venue_order_id) .account_id(account_id) .build(), )) .unwrap(); let fill = |trade_id: &str, last_qty: u64| { OrderFilledSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .venue_order_id(venue_order_id) .account_id(account_id) .trade_id(TradeId::from(trade_id)) .order_side(order.order_side()) .order_type(order.order_type()) .last_qty(Quantity::from(last_qty)) .last_px(Price::from("100.00")) .build() }; // The one non-reopened voided unit stays out of working leaves let expected_leaves = updated_qty - 4; let first_fill = fill("T-VOID-UPDATE-1", 4); let remainder_fill = fill("T-VOID-UPDATE-2", expected_leaves); order .apply(OrderEventAny::Filled(first_fill.clone())) .unwrap(); order .apply(OrderEventAny::FillVoided(OrderFillVoided { is_reopened: false, ..matching_fill_void(&first_fill, Quantity::from(1), None) })) .unwrap(); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.filled_qty(), Quantity::from(3)); assert_eq!(order.leaves_qty(), Quantity::from(6)); order .apply(OrderEventAny::Updated( OrderUpdatedSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .quantity(Quantity::from(updated_qty)) .venue_order_id(venue_order_id) .account_id(account_id) .build(), )) .unwrap(); assert_eq!(order.leaves_qty(), Quantity::from(expected_leaves)); order.apply(OrderEventAny::Filled(remainder_fill)).unwrap(); assert_eq!(order.status(), OrderStatus::Voided); assert_eq!(order.leaves_qty(), Quantity::from(0)); assert!(order.is_closed()); } /// Builds an accepted Limit order (qty 10) with a `T-VOID-UPDATE-1` fill of 4, for reuse /// across the `OrderUpdated`-after-fill-void boundary tests below. fn accepted_order_with_first_fill() -> (OrderAny, VenueOrderId, AccountId, OrderFilled) { let venue_order_id = VenueOrderId::from("V-VOID-UPDATE"); let account_id = AccountId::from("SIM-VOID-UPDATE"); let mut order = OrderTestBuilder::new(OrderType::Limit) .instrument_id(InstrumentId::from("ETHUSDT-LINEAR.BYBIT")) .client_order_id(ClientOrderId::from("O-VOID-UPDATE")) .side(OrderSide::Buy) .quantity(Quantity::from(10)) .price(Price::from("100.00")) .build(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .venue_order_id(venue_order_id) .account_id(account_id) .build(), )) .unwrap(); let first_fill = OrderFilledSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .venue_order_id(venue_order_id) .account_id(account_id) .trade_id(TradeId::from("T-VOID-UPDATE-1")) .order_side(order.order_side()) .order_type(order.order_type()) .last_qty(Quantity::from(4)) .last_px(Price::from("100.00")) .build(); order .apply(OrderEventAny::Filled(first_fill.clone())) .unwrap(); (order, venue_order_id, account_id, first_fill) } #[rstest] fn test_order_updated_after_reopened_fill_void_keeps_void_in_leaves() { let (mut order, venue_order_id, account_id, first_fill) = accepted_order_with_first_fill(); order .apply(OrderEventAny::FillVoided(OrderFillVoided { is_reopened: true, ..matching_fill_void(&first_fill, Quantity::from(1), None) })) .unwrap(); assert_eq!(order.filled_qty(), Quantity::from(3)); assert_eq!(order.non_reopened_voided_qty(), Quantity::from(0)); // A reopened void puts its quantity back into leaves rather than excluding it assert_eq!(order.leaves_qty(), Quantity::from(7)); order .apply(OrderEventAny::Updated( OrderUpdatedSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .quantity(Quantity::from(12)) .venue_order_id(venue_order_id) .account_id(account_id) .build(), )) .unwrap(); assert_eq!(order.non_reopened_voided_qty(), Quantity::from(0)); assert_eq!(order.leaves_qty(), Quantity::from(9)); } #[rstest] fn test_order_updated_uses_latest_superseding_correction() { let (mut order, venue_order_id, account_id, first_fill) = accepted_order_with_first_fill(); order .apply(OrderEventAny::FillVoided(OrderFillVoided { is_reopened: false, ..matching_fill_void(&first_fill, Quantity::from(1), None) })) .unwrap(); assert_eq!(order.non_reopened_voided_qty(), Quantity::from(1)); // A second, larger correction for the same trade supersedes the first rather than // stacking with it. order .apply(OrderEventAny::FillVoided(OrderFillVoided { is_reopened: false, ..matching_fill_void(&first_fill, Quantity::from(3), None) })) .unwrap(); assert_eq!(order.filled_qty(), Quantity::from(1)); assert_eq!(order.non_reopened_voided_qty(), Quantity::from(3)); order .apply(OrderEventAny::Updated( OrderUpdatedSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .quantity(Quantity::from(12)) .venue_order_id(venue_order_id) .account_id(account_id) .build(), )) .unwrap(); // 12 - filled(1) - superseding correction(3), not 12 - 1 - (1 + 3) assert_eq!(order.leaves_qty(), Quantity::from(8)); } #[rstest] fn test_order_updated_to_zero_leaves_sets_no_new_terminal_transition() { let (mut order, venue_order_id, account_id, first_fill) = accepted_order_with_first_fill(); order .apply(OrderEventAny::FillVoided(OrderFillVoided { is_reopened: false, ..matching_fill_void(&first_fill, Quantity::from(1), None) })) .unwrap(); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.filled_qty(), Quantity::from(3)); assert_eq!(order.non_reopened_voided_qty(), Quantity::from(1)); // 4 - filled(3) - non-reopened voided(1) = 0 leaves, with reconciliation left false so // the exception below does not apply and no new terminal transition should occur. order .apply(OrderEventAny::Updated( OrderUpdatedSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .quantity(Quantity::from(4)) .venue_order_id(venue_order_id) .account_id(account_id) .reconciliation(false) .build(), )) .unwrap(); assert_eq!(order.leaves_qty(), Quantity::from(0)); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert!(order.ts_closed().is_none()); assert!(!order.is_closed()); // The venue's own cancellation still applies normally afterward order .apply(OrderEventAny::Canceled( OrderCanceledSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .venue_order_id(venue_order_id) .account_id(account_id) .build(), )) .unwrap(); assert_eq!(order.status(), OrderStatus::Canceled); } #[rstest] fn test_reconciliation_to_filled_exception_fires_with_non_reopened_void_present() { let (mut order, venue_order_id, account_id, first_fill) = accepted_order_with_first_fill(); order .apply(OrderEventAny::FillVoided(OrderFillVoided { is_reopened: false, ..matching_fill_void(&first_fill, Quantity::from(1), None) })) .unwrap(); assert_eq!(order.filled_qty(), Quantity::from(3)); // event.quantity == filled_qty with reconciliation=true fires the exception even though // a non-reopened void is present on the order. order .apply(OrderEventAny::Updated( OrderUpdatedSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .quantity(Quantity::from(3)) .venue_order_id(venue_order_id) .account_id(account_id) .reconciliation(true) .build(), )) .unwrap(); assert_eq!(order.status(), OrderStatus::Filled); assert!(order.ts_closed().is_some()); assert_eq!(order.leaves_qty(), Quantity::from(0)); } #[rstest] fn test_order_updated_restores_pending_status_with_non_reopened_void_present() { let (mut order, venue_order_id, account_id, first_fill) = accepted_order_with_first_fill(); order .apply(OrderEventAny::FillVoided(OrderFillVoided { is_reopened: false, ..matching_fill_void(&first_fill, Quantity::from(1), None) })) .unwrap(); assert_eq!(order.status(), OrderStatus::PartiallyFilled); order .apply(OrderEventAny::PendingUpdate( OrderPendingUpdateSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .account_id(account_id) .build(), )) .unwrap(); assert_eq!(order.status(), OrderStatus::PendingUpdate); order .apply(OrderEventAny::Updated( OrderUpdatedSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .quantity(Quantity::from(12)) .venue_order_id(venue_order_id) .account_id(account_id) .build(), )) .unwrap(); // Restored to the pre-PendingUpdate status, with leaves still excluding the void assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.leaves_qty(), Quantity::from(8)); } #[rstest] fn test_order_updated_after_fill_void_replays_to_same_state() { let (mut order, venue_order_id, account_id, first_fill) = accepted_order_with_first_fill(); order .apply(OrderEventAny::FillVoided(OrderFillVoided { is_reopened: false, ..matching_fill_void(&first_fill, Quantity::from(1), None) })) .unwrap(); order .apply(OrderEventAny::Updated( OrderUpdatedSpec::builder() .trader_id(order.trader_id()) .strategy_id(order.strategy_id()) .instrument_id(order.instrument_id()) .client_order_id(order.client_order_id()) .quantity(Quantity::from(12)) .venue_order_id(venue_order_id) .account_id(account_id) .build(), )) .unwrap(); let replayed = OrderAny::from_events(order.events().into_iter().cloned().collect()).unwrap(); assert_eq!(replayed.status(), order.status()); assert_eq!(replayed.filled_qty(), order.filled_qty()); assert_eq!( replayed.non_reopened_voided_qty(), order.non_reopened_voided_qty() ); assert_eq!(replayed.leaves_qty(), order.leaves_qty()); assert_eq!(replayed.leaves_qty(), Quantity::from(8)); } #[rstest] fn test_canceled_order_with_voided_qty_becomes_voided_when_late_fill_exhausts_leaves() { let initial_trade_id = TradeId::from("TRADE-VOID"); let late_trade_id = TradeId::from("TRADE-LATE"); let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let accepted = OrderAcceptedSpec::builder() .ts_event(UnixNanos::from(1_000)) .build(); let filled = OrderFilledSpec::builder() .trade_id(initial_trade_id) .last_qty(Quantity::from(60_000)) .ts_event(UnixNanos::from(2_000)) .build(); let fill_voided = OrderFillVoidedSpec::builder() .trade_id(initial_trade_id) .voided_qty(Quantity::from(20_000)) .ts_event(UnixNanos::from(3_000)) .is_reopened(false) .build(); let canceled = OrderCanceledSpec::builder() .ts_event(UnixNanos::from(4_000)) .build(); let late_fill = OrderFilledSpec::builder() .trade_id(late_trade_id) .last_qty(Quantity::from(40_000)) .ts_event(UnixNanos::from(5_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(filled)).unwrap(); order.apply(OrderEventAny::FillVoided(fill_voided)).unwrap(); order.apply(OrderEventAny::Canceled(canceled)).unwrap(); assert_eq!(order.status(), OrderStatus::Canceled); assert_eq!(order.filled_qty(), Quantity::from(40_000)); assert_eq!(order.voided_qty(), Quantity::from(20_000)); assert_eq!(order.leaves_qty(), Quantity::from(40_000)); assert_eq!(order.ts_closed(), Some(UnixNanos::from(4_000))); assert_eq!(order.last_trade_id(), Some(initial_trade_id)); assert!(order.is_closed()); assert!(!order.is_open()); order.apply(OrderEventAny::Filled(late_fill)).unwrap(); assert_eq!(order.status(), OrderStatus::Voided); assert_eq!(order.filled_qty(), Quantity::from(80_000)); assert_eq!(order.voided_qty(), Quantity::from(20_000)); assert_eq!(order.leaves_qty(), Quantity::from(0)); assert_eq!(order.overfill_qty(), Quantity::from(0)); assert_eq!(order.ts_closed(), Some(UnixNanos::from(5_000))); assert_eq!(order.last_trade_id(), Some(late_trade_id)); assert_eq!(order.trade_ids(), vec![&initial_trade_id, &late_trade_id]); assert!(order.is_closed()); assert!(!order.is_open()); } #[rstest] fn test_reopened_fill_void_requires_existing_fill() { let trade_id = TradeId::from("TRADE-OUT-OF-ORDER"); let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let early_void = OrderFillVoidedSpec::builder() .trade_id(trade_id) .voided_qty(Quantity::from(10_000)) .is_reopened(true) .build(); let late_fill = OrderFilledSpec::builder() .trade_id(trade_id) .last_qty(Quantity::from(40_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); let result = order.apply(OrderEventAny::FillVoided(early_void)); order.apply(OrderEventAny::Filled(late_fill)).unwrap(); assert!(matches!(result, Err(OrderError::InvalidOrderEvent))); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.filled_qty(), Quantity::from(40_000)); assert_eq!(order.voided_qty(), Quantity::from(0)); assert_eq!(order.leaves_qty(), Quantity::from(60_000)); } #[rstest] #[case(OrderStatus::Accepted)] #[case(OrderStatus::Canceled)] #[case(OrderStatus::Expired)] fn test_terminal_fill_void_rejects_later_order_events(#[case] status_before_void: OrderStatus) { let trade_id = TradeId::from("TRADE-TERMINAL-VOID"); let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let terminal_void = OrderFillVoidedSpec::builder() .trade_id(trade_id) .voided_qty(Quantity::from(40_000)) .commission_voided(Money::from("0 USD")) .build(); let late_fill = OrderFilledSpec::builder() .trade_id(trade_id) .last_qty(Quantity::from(40_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); match status_before_void { OrderStatus::Accepted => {} OrderStatus::Canceled => order .apply(OrderEventAny::Canceled( OrderCanceledSpec::builder().build(), )) .unwrap(), OrderStatus::Expired => order .apply(OrderEventAny::Expired(OrderExpiredSpec::builder().build())) .unwrap(), _ => unreachable!(), } order .apply(OrderEventAny::FillVoided(terminal_void)) .unwrap(); let update_result = order.apply(OrderEventAny::Updated( OrderUpdatedSpec::builder() .quantity(Quantity::from(120_000)) .build(), )); let cancel_result = order.apply(OrderEventAny::Canceled( OrderCanceledSpec::builder().build(), )); let fill_result = order.apply(OrderEventAny::Filled(late_fill)); assert!(matches!( update_result, Err(OrderError::InvalidStateTransition) )); assert!(matches!( cancel_result, Err(OrderError::InvalidStateTransition) )); assert!(matches!( fill_result, Err(OrderError::InvalidStateTransition) )); assert_eq!(order.status(), OrderStatus::Voided); assert_eq!(order.filled_qty(), Quantity::from(0)); assert_eq!(order.voided_qty(), Quantity::from(40_000)); assert_eq!(order.leaves_qty(), Quantity::from(0)); } #[rstest] #[case(Quantity::from(100_001), None)] #[case(Quantity::from(100_000), Some(Money::from("1 USD")))] fn test_terminal_fill_void_rejects_invalid_economic_values( #[case] voided_qty: Quantity, #[case] commission_voided: Option, ) { let trade_id = TradeId::from("TRADE-TERMINAL-VOID"); let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); let mut invalid = OrderFillVoidedSpec::builder() .trade_id(trade_id) .voided_qty(voided_qty) .build(); invalid.commission_voided = commission_voided; let result = order.apply(OrderEventAny::FillVoided(invalid)); assert!(matches!( result, Err(OrderError::OverVoid(error_trade_id)) if error_trade_id == trade_id )); assert_eq!(order.status(), OrderStatus::Accepted); assert_eq!(order.voided_qty(), Quantity::from(0)); } #[rstest] fn test_terminal_fill_void_accepts_current_venue_order_id_after_fill_before_accept() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let submitted = OrderSubmittedSpec::builder().build(); let filled = OrderFilledSpec::builder() .account_id(submitted.account_id) .trade_id(TradeId::from("TRADE-APPLIED")) .last_qty(Quantity::from(40_000)) .build(); let terminal_void = OrderFillVoidedSpec::builder() .venue_order_id(filled.venue_order_id) .account_id(filled.account_id) .trade_id(TradeId::from("TRADE-TERMINAL-VOID")) .voided_qty(Quantity::from(10_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Filled(filled)).unwrap(); order .apply(OrderEventAny::FillVoided(terminal_void)) .unwrap(); assert_eq!(order.status(), OrderStatus::Voided); assert_eq!(order.filled_qty(), Quantity::from(40_000)); assert_eq!(order.voided_qty(), Quantity::from(10_000)); assert_eq!(order.leaves_qty(), Quantity::from(0)); } #[rstest] fn test_terminal_fill_void_requires_matching_order_identity() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); let mismatched = OrderFillVoidedSpec::builder() .instrument_id(InstrumentId::from("OTHER.SIM")) .trade_id(TradeId::from("TRADE-TERMINAL-VOID")) .voided_qty(Quantity::from(100_000)) .build(); let result = order.apply(OrderEventAny::FillVoided(mismatched)); assert!(matches!(result, Err(OrderError::InvalidOrderEvent))); assert_eq!(order.status(), OrderStatus::Accepted); assert_eq!(order.voided_qty(), Quantity::from(0)); } #[rstest] #[case(OrderStatus::Canceled)] #[case(OrderStatus::Expired)] fn test_fill_void_does_not_reopen_canceled_or_expired_order( #[case] terminal_status: OrderStatus, ) { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let filled = OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-TERMINAL-VOID")) .last_qty(Quantity::from(60_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); order.apply(OrderEventAny::Filled(filled.clone())).unwrap(); match terminal_status { OrderStatus::Canceled => order .apply(OrderEventAny::Canceled( OrderCanceledSpec::builder().build(), )) .unwrap(), OrderStatus::Expired => order .apply(OrderEventAny::Expired(OrderExpiredSpec::builder().build())) .unwrap(), _ => unreachable!(), } let voided = OrderFillVoidedSpec::builder() .trade_id(filled.trade_id) .voided_qty(Quantity::from(20_000)) .is_reopened(true) .build(); order.apply(OrderEventAny::FillVoided(voided)).unwrap(); assert_eq!(order.status(), terminal_status); assert_eq!(order.filled_qty(), Quantity::from(40_000)); assert_eq!(order.voided_qty(), Quantity::from(20_000)); assert!(order.is_closed()); assert!(order.ts_closed().is_some()); } #[rstest] fn test_fill_void_rejects_duplicate_stale_and_over_void() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let filled = OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-VOID")) .last_qty(Quantity::from(100_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); order.apply(OrderEventAny::Filled(filled)).unwrap(); order .apply(OrderEventAny::FillVoided( OrderFillVoidedSpec::builder() .trade_id(TradeId::from("TRADE-VOID")) .voided_qty(Quantity::from(40_000)) .build(), )) .unwrap(); let duplicate = order.apply(OrderEventAny::FillVoided( OrderFillVoidedSpec::builder() .trade_id(TradeId::from("TRADE-VOID")) .voided_qty(Quantity::from(40_000)) .build(), )); let stale = order.apply(OrderEventAny::FillVoided( OrderFillVoidedSpec::builder() .trade_id(TradeId::from("TRADE-VOID")) .voided_qty(Quantity::from(30_000)) .build(), )); let over_void = order.apply(OrderEventAny::FillVoided( OrderFillVoidedSpec::builder() .trade_id(TradeId::from("TRADE-VOID")) .voided_qty(Quantity::from(100_001)) .build(), )); assert!(matches!( duplicate, Err(OrderError::DuplicateFillVoid(trade_id)) if trade_id == TradeId::from("TRADE-VOID") )); assert!(matches!( stale, Err(OrderError::StaleFillVoid(trade_id)) if trade_id == TradeId::from("TRADE-VOID") )); assert!(matches!( over_void, Err(OrderError::OverVoid(trade_id)) if trade_id == TradeId::from("TRADE-VOID") )); assert_eq!(order.filled_qty(), Quantity::from(60_000)); assert_eq!(order.voided_qty(), Quantity::from(40_000)); } enum FillVoidIdentityField { VenueOrderId, AccountId, OrderSide, OrderType, LastPx, Currency, LiquiditySide, PositionId, } #[rstest] #[case::venue_order_id(FillVoidIdentityField::VenueOrderId)] #[case::account_id(FillVoidIdentityField::AccountId)] #[case::order_side(FillVoidIdentityField::OrderSide)] #[case::order_type(FillVoidIdentityField::OrderType)] #[case::last_px(FillVoidIdentityField::LastPx)] #[case::currency(FillVoidIdentityField::Currency)] #[case::liquidity_side(FillVoidIdentityField::LiquiditySide)] #[case::position_id(FillVoidIdentityField::PositionId)] fn test_fill_void_rejects_known_fill_identity_mismatch(#[case] field: FillVoidIdentityField) { let (mut order, fill) = filled_market_order_for_void(); let events_before: Vec<_> = order.events().into_iter().cloned().collect(); let mut correction = matching_fill_void(&fill, Quantity::from(40), Some(Money::from("0.40 USD"))); match field { FillVoidIdentityField::VenueOrderId => { correction.venue_order_id = VenueOrderId::from("V-OTHER"); } FillVoidIdentityField::AccountId => { correction.account_id = AccountId::from("SIM-OTHER"); } FillVoidIdentityField::OrderSide => correction.order_side = OrderSide::Sell, FillVoidIdentityField::OrderType => correction.order_type = OrderType::Limit, FillVoidIdentityField::LastPx => correction.last_px = Price::from("1.50"), FillVoidIdentityField::Currency => correction.currency = Currency::EUR(), FillVoidIdentityField::LiquiditySide => { correction.liquidity_side = LiquiditySide::Maker; } FillVoidIdentityField::PositionId => { correction.position_id = Some(PositionId::from("P-OTHER")); } } let result = order.apply(OrderEventAny::FillVoided(correction)); assert!(matches!(result, Err(OrderError::InvalidOrderEvent))); assert_fill_void_rejected_without_mutation(&order, &events_before); } #[rstest] #[case::currency("0.40 EUR")] #[case::sign("-0.40 USD")] #[case::amount("1.01 USD")] fn test_fill_void_rejects_invalid_commission_without_mutation(#[case] commission: &str) { let (mut order, fill) = filled_market_order_for_void(); let events_before: Vec<_> = order.events().into_iter().cloned().collect(); let correction = matching_fill_void(&fill, Quantity::from(40), Some(Money::from(commission))); let result = order.apply(OrderEventAny::FillVoided(correction)); assert!(matches!( result, Err(OrderError::InvalidFillVoidCommission(trade_id)) if trade_id == fill.trade_id )); assert_fill_void_rejected_without_mutation(&order, &events_before); } #[rstest] fn test_fill_void_rejects_zero_quantity_without_mutation() { let (mut order, fill) = filled_market_order_for_void(); let events_before: Vec<_> = order.events().into_iter().cloned().collect(); let correction = matching_fill_void( &fill, Quantity::zero(fill.last_qty.precision), Some(Money::from("0.00 USD")), ); let result = order.apply(OrderEventAny::FillVoided(correction)); assert!(matches!( result, Err(OrderError::OverVoid(trade_id)) if trade_id == fill.trade_id )); assert_fill_void_rejected_without_mutation(&order, &events_before); } #[rstest] fn test_fill_void_uses_latest_cumulative_correction() { let (mut order, fill) = filled_market_order_for_void(); order .apply(OrderEventAny::FillVoided(matching_fill_void( &fill, Quantity::from(40), Some(Money::from("0.40 USD")), ))) .unwrap(); let event_count = order.event_count(); let decreasing_commission = order.apply(OrderEventAny::FillVoided(matching_fill_void( &fill, Quantity::from(50), Some(Money::from("0.30 USD")), ))); assert!(matches!( decreasing_commission, Err(OrderError::InvalidFillVoidCommission(trade_id)) if trade_id == fill.trade_id )); assert_eq!(order.event_count(), event_count); assert_eq!(order.filled_qty(), Quantity::from(60)); assert_eq!(order.voided_qty(), Quantity::from(40)); assert_eq!( order.commission(&Currency::USD()), Some(Money::from("0.60 USD")), ); order .apply(OrderEventAny::FillVoided(matching_fill_void( &fill, Quantity::from(60), Some(Money::from("0.60 USD")), ))) .unwrap(); let replayed = OrderAny::from_events(order.events().into_iter().cloned().collect()).unwrap(); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.filled_qty(), Quantity::from(40)); assert_eq!(order.voided_qty(), Quantity::from(60)); assert_eq!(order.leaves_qty(), Quantity::from(60)); assert_eq!(order.avg_px(), Some(dec!(1.25))); assert_eq!( order.commission(&Currency::USD()), Some(Money::from("0.40 USD")), ); assert_eq!(order.trade_ids(), vec![&fill.trade_id]); assert_eq!(order.last_trade_id(), Some(fill.trade_id)); assert_eq!(order.event_count(), event_count + 1); assert_eq!(replayed.status(), order.status()); assert_eq!(replayed.filled_qty(), order.filled_qty()); assert_eq!(replayed.voided_qty(), order.voided_qty()); assert_eq!(replayed.leaves_qty(), order.leaves_qty()); assert_eq!(replayed.avg_px(), order.avg_px()); assert_eq!(replayed.commissions(), order.commissions()); assert_eq!(replayed.trade_ids(), order.trade_ids()); assert_eq!(replayed.last_trade_id(), order.last_trade_id()); } fn filled_market_order_for_void() -> (MarketOrder, OrderFilled) { let venue_order_id = VenueOrderId::from("V-FILL-VOID"); let account_id = AccountId::from("SIM-FILL-VOID"); let fill = OrderFilledSpec::builder() .venue_order_id(venue_order_id) .account_id(account_id) .trade_id(TradeId::from("T-FILL-VOID")) .last_qty(Quantity::from(100)) .last_px(Price::from("1.25")) .position_id(PositionId::from("P-FILL-VOID")) .commission(Money::from("1.00 USD")) .build(); let mut order: MarketOrder = OrderInitializedSpec::builder() .quantity(Quantity::from(100)) .build() .try_into() .unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder() .venue_order_id(venue_order_id) .account_id(account_id) .build(), )) .unwrap(); order.apply(OrderEventAny::Filled(fill.clone())).unwrap(); (order, fill) } fn matching_fill_void( fill: &OrderFilled, voided_qty: Quantity, commission_voided: Option, ) -> OrderFillVoided { OrderFillVoidedSpec::builder() .trader_id(fill.trader_id) .strategy_id(fill.strategy_id) .instrument_id(fill.instrument_id) .client_order_id(fill.client_order_id) .venue_order_id(fill.venue_order_id) .account_id(fill.account_id) .trade_id(fill.trade_id) .voided_qty(voided_qty) .order_side(fill.order_side) .order_type(fill.order_type) .last_px(fill.last_px) .currency(fill.currency) .liquidity_side(fill.liquidity_side) .maybe_position_id(fill.position_id) .maybe_commission_voided(commission_voided) .is_reopened(true) .build() } fn assert_fill_void_rejected_without_mutation( order: &MarketOrder, events_before: &[OrderEventAny], ) { assert_eq!(order.status(), OrderStatus::Filled); assert_eq!(order.filled_qty(), Quantity::from(100)); assert_eq!(order.voided_qty(), Quantity::from(0)); assert_eq!(order.leaves_qty(), Quantity::from(0)); assert_eq!(order.avg_px(), Some(dec!(1.25))); assert_eq!( order.commission(&Currency::USD()), Some(Money::from("1.00 USD")), ); assert_eq!( order.events().into_iter().cloned().collect::>(), events_before, ); } #[rstest] fn test_unknown_fill_void_does_not_reverse_surviving_fill() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let filled = OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-APPLIED")) .last_qty(Quantity::from(60_000)) .build(); let voided = OrderFillVoidedSpec::builder() .trade_id(TradeId::from("TRADE-NEVER-APPLIED")) .voided_qty(Quantity::from(40_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); order.apply(OrderEventAny::Filled(filled)).unwrap(); order.apply(OrderEventAny::FillVoided(voided)).unwrap(); order .apply(OrderEventAny::FillVoided( OrderFillVoidedSpec::builder() .trade_id(TradeId::from("TRADE-APPLIED")) .voided_qty(Quantity::from(10_000)) .is_reopened(true) .build(), )) .unwrap(); let late_fill = order.apply(OrderEventAny::Filled( OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-LATE")) .last_qty(Quantity::from(10_000)) .build(), )); assert_eq!(order.status(), OrderStatus::Voided); assert_eq!(order.filled_qty(), Quantity::from(50_000)); assert_eq!(order.voided_qty(), Quantity::from(50_000)); assert_eq!(order.leaves_qty(), Quantity::zero(0)); assert!(order.is_closed()); assert!(matches!(late_fill, Err(OrderError::InvalidStateTransition))); } #[rstest] fn test_order_state_transition_to_canceled() { let mut order: MarketOrder = OrderInitializedSpec::builder().build().try_into().unwrap(); let submitted = OrderSubmittedSpec::builder().build(); let canceled = OrderCanceledSpec::builder().build(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Canceled(canceled)).unwrap(); assert_eq!(order.status(), OrderStatus::Canceled); assert!(order.is_closed()); assert!(!order.is_open()); } #[rstest] fn test_order_life_cycle_to_partially_filled() { let init = OrderInitializedSpec::builder().build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let filled = OrderFilledSpec::builder() .last_qty(Quantity::from(50_000)) .build(); let mut order: MarketOrder = init.clone().try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(filled)).unwrap(); assert_eq!(order.client_order_id, init.client_order_id); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.filled_qty(), Quantity::from(50_000)); assert_eq!(order.leaves_qty(), Quantity::from(50_000)); assert!(order.is_open()); assert!(!order.is_closed()); } #[rstest] fn test_order_commission_calculation() { let mut order: MarketOrder = OrderInitializedSpec::builder().build().try_into().unwrap(); order .commissions .insert(Currency::USD(), Money::new(10.0, Currency::USD())); assert_eq!( order.commission(&Currency::USD()), Some(Money::new(10.0, Currency::USD())) ); assert_eq!( order.commissions_vec(), vec![Money::new(10.0, Currency::USD())] ); } #[rstest] fn test_order_is_primary() { let order: MarketOrder = OrderInitializedSpec::builder() .exec_algorithm_id(ExecAlgorithmId::from("ALGO-001")) .exec_spawn_id(ClientOrderId::from("O-001")) .client_order_id(ClientOrderId::from("O-001")) .build() .try_into() .unwrap(); assert!(order.is_primary()); assert!(!order.is_spawned()); } #[rstest] fn test_order_is_spawned() { let order: MarketOrder = OrderInitializedSpec::builder() .exec_algorithm_id(ExecAlgorithmId::from("ALGO-001")) .exec_spawn_id(ClientOrderId::from("O-002")) .client_order_id(ClientOrderId::from("O-001")) .build() .try_into() .unwrap(); assert!(!order.is_primary()); assert!(order.is_spawned()); } #[rstest] fn test_order_is_contingency() { let order: MarketOrder = OrderInitializedSpec::builder() .contingency_type(ContingencyType::Oto) .linked_order_ids(vec![ClientOrderId::from("O-LINKED")]) .build() .try_into() .unwrap(); assert!(order.is_contingency()); assert!(order.is_parent_order()); assert!(!order.is_child_order()); } #[rstest] fn test_order_is_child_order() { let order: MarketOrder = OrderInitializedSpec::builder() .parent_order_id(ClientOrderId::from("PARENT-001")) .build() .try_into() .unwrap(); assert!(order.is_child_order()); assert!(!order.is_parent_order()); } #[rstest] fn test_to_own_book_order_timestamp_ordering() { use crate::orders::limit::LimitOrder; // Create order with distinct timestamps to verify parameter ordering let init = OrderInitializedSpec::builder() .price(Price::from("100.00")) .build(); let submitted = OrderSubmittedSpec::builder() .ts_event(UnixNanos::from(1_000_000)) .build(); let accepted = OrderAcceptedSpec::builder() .ts_event(UnixNanos::from(2_000_000)) .build(); let mut order: LimitOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); let own_book_order = order.to_own_book_order(); // Verify timestamps are in correct positions assert_eq!(own_book_order.ts_submitted, UnixNanos::from(1_000_000)); assert_eq!(own_book_order.ts_accepted, UnixNanos::from(2_000_000)); assert_eq!(own_book_order.ts_last, UnixNanos::from(2_000_000)); } #[rstest] fn test_to_own_book_order_partial_fill_uses_leaves_qty() { use crate::orders::limit::LimitOrder; let init = OrderInitializedSpec::builder() .price(Price::from("100.00")) .quantity(Quantity::from(100_000)) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let filled = OrderFilledSpec::builder() .order_type(OrderType::Limit) .last_qty(Quantity::from(40_000)) .last_px(Price::from("100.00")) .build(); let mut order: LimitOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(filled)).unwrap(); let own_book_order = order.to_own_book_order(); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.leaves_qty(), Quantity::from(60_000)); assert_eq!(own_book_order.size, Quantity::from(60_000)); } #[rstest] fn test_order_accepted_without_submitted_sets_account_id() { // Test external order flow: Initialized -> Accepted (no Submitted) let init = OrderInitializedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder() .account_id(AccountId::from("EXTERNAL-001")) .build(); let mut order: MarketOrder = init.try_into().unwrap(); // Verify account_id is initially None assert_eq!(order.account_id(), None); // Apply accepted event directly (external order case) order.apply(OrderEventAny::Accepted(accepted)).unwrap(); // Verify account_id is now set from the accepted event assert_eq!(order.account_id(), Some(AccountId::from("EXTERNAL-001"))); assert_eq!(order.status(), OrderStatus::Accepted); } #[rstest] fn test_order_accepted_after_submitted_preserves_account_id() { // Test normal order flow: Initialized -> Submitted -> Accepted let init = OrderInitializedSpec::builder().build(); let submitted = OrderSubmittedSpec::builder() .account_id(AccountId::from("SUBMITTED-001")) .build(); let accepted = OrderAcceptedSpec::builder() .account_id(AccountId::from("ACCEPTED-001")) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); // After submitted, account_id should be set assert_eq!(order.account_id(), Some(AccountId::from("SUBMITTED-001"))); // Apply accepted event order.apply(OrderEventAny::Accepted(accepted)).unwrap(); // account_id should now be updated to the accepted event's account_id assert_eq!(order.account_id(), Some(AccountId::from("ACCEPTED-001"))); assert_eq!(order.status(), OrderStatus::Accepted); } #[rstest] fn test_overfill_tracks_overfill_qty() { // Test that overfill is tracked on the order let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let overfill = OrderFilledSpec::builder() .last_qty(Quantity::from(110_000)) // Overfill: 110k > 100k .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(overfill)).unwrap(); // Order should track overfill assert_eq!(order.overfill_qty(), Quantity::from(10_000)); assert_eq!(order.filled_qty(), Quantity::from(110_000)); assert_eq!(order.leaves_qty(), Quantity::from(0)); assert_eq!(order.status(), OrderStatus::Filled); } #[rstest] fn test_partial_fill_then_overfill() { // Test multiple fills resulting in overfill let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let fill1 = OrderFilledSpec::builder() .last_qty(Quantity::from(80_000)) .trade_id(TradeId::from("TRADE-1")) .build(); let fill2 = OrderFilledSpec::builder() .last_qty(Quantity::from(30_000)) // Total 110k > 100k .trade_id(TradeId::from("TRADE-2")) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(fill1)).unwrap(); // After first fill, no overfill assert_eq!(order.overfill_qty(), Quantity::from(0)); assert_eq!(order.filled_qty(), Quantity::from(80_000)); assert_eq!(order.leaves_qty(), Quantity::from(20_000)); order.apply(OrderEventAny::Filled(fill2)).unwrap(); // After second fill, overfill detected assert_eq!(order.overfill_qty(), Quantity::from(10_000)); assert_eq!(order.filled_qty(), Quantity::from(110_000)); assert_eq!(order.leaves_qty(), Quantity::from(0)); assert_eq!(order.status(), OrderStatus::Filled); } #[rstest] fn test_exact_fill_no_overfill() { // Test that exact fill doesn't trigger overfill tracking let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let filled = OrderFilledSpec::builder() .last_qty(Quantity::from(100_000)) // Exact fill .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(filled)).unwrap(); // No overfill assert_eq!(order.overfill_qty(), Quantity::from(0)); assert_eq!(order.filled_qty(), Quantity::from(100_000)); assert_eq!(order.leaves_qty(), Quantity::from(0)); } #[rstest] fn test_partial_fill_then_overfill_with_fractional_quantities() { // Simulates real exchange scenario with fractional fills: // Order for 2450.5 units, partially filled 1202.5, then fill of 1285.5 arrives // Total filled: 2488.0, overfill: 37.5 let init = OrderInitializedSpec::builder() .quantity(Quantity::from("2450.5")) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let fill1 = OrderFilledSpec::builder() .last_qty(Quantity::from("1202.5")) .trade_id(TradeId::from("TRADE-1")) .build(); let fill2 = OrderFilledSpec::builder() .last_qty(Quantity::from("1285.5")) // 1202.5 + 1285.5 = 2488 > 2450.5 .trade_id(TradeId::from("TRADE-2")) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(fill1)).unwrap(); // After first fill, no overfill assert_eq!(order.overfill_qty(), Quantity::from(0)); assert_eq!(order.filled_qty(), Quantity::from("1202.5")); assert_eq!(order.leaves_qty(), Quantity::from("1248.0")); assert_eq!(order.status(), OrderStatus::PartiallyFilled); order.apply(OrderEventAny::Filled(fill2)).unwrap(); // After second fill, overfill detected and tracked assert_eq!(order.overfill_qty(), Quantity::from("37.5")); assert_eq!(order.filled_qty(), Quantity::from("2488.0")); assert_eq!(order.leaves_qty(), Quantity::from(0)); assert_eq!(order.status(), OrderStatus::Filled); } #[rstest] fn test_calculate_overfill_returns_zero_when_no_overfill() { let order: MarketOrder = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build() .try_into() .unwrap(); // Fill qty less than order qty - no overfill let overfill = order.calculate_overfill(Quantity::from(50_000)); assert_eq!(overfill, Quantity::from(0)); // Fill qty equals order qty - no overfill let overfill = order.calculate_overfill(Quantity::from(100_000)); assert_eq!(overfill, Quantity::from(0)); } #[rstest] fn test_calculate_overfill_returns_overfill_amount() { let order: MarketOrder = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build() .try_into() .unwrap(); // Fill qty exceeds order qty let overfill = order.calculate_overfill(Quantity::from(110_000)); assert_eq!(overfill, Quantity::from(10_000)); } #[rstest] fn test_calculate_overfill_accounts_for_existing_fills() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let partial_fill = OrderFilledSpec::builder() .last_qty(Quantity::from(60_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(partial_fill)).unwrap(); // Order is 60k filled, 40k remaining // Fill of 50k would overfill by 10k let overfill = order.calculate_overfill(Quantity::from(50_000)); assert_eq!(overfill, Quantity::from(10_000)); // Fill of 40k would not overfill let overfill = order.calculate_overfill(Quantity::from(40_000)); assert_eq!(overfill, Quantity::from(0)); } #[rstest] fn test_calculate_overfill_with_fractional_quantities() { let order: MarketOrder = OrderInitializedSpec::builder() .quantity(Quantity::from("2450.5")) .build() .try_into() .unwrap(); // Simulates the exact scenario from user's log // Order for 2450.5, if fill of 2488.0 arrives let overfill = order.calculate_overfill(Quantity::from("2488.0")); assert_eq!(overfill, Quantity::from("37.5")); } #[rstest] fn test_calculate_overfill_zero_after_fractional_partial_fill() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from("1.000")) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let partial_fill = OrderFilledSpec::builder() .last_qty(Quantity::from("0.072")) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(partial_fill)).unwrap(); // After filling 0.072 of 1.000, another 0.072 fill should not overfill let overfill = order.calculate_overfill(Quantity::from("0.072")); assert_eq!(overfill, Quantity::from("0.000")); } #[rstest] fn test_duplicate_fill_rejected() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let fill1 = OrderFilledSpec::builder() .last_qty(Quantity::from(50_000)) .trade_id(TradeId::from("TRADE-001")) .build(); let fill2_duplicate = OrderFilledSpec::builder() .last_qty(Quantity::from(50_000)) .trade_id(TradeId::from("TRADE-001")) // Same trade_id as fill1 .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(fill1)).unwrap(); // Verify first fill applied successfully assert_eq!(order.filled_qty(), Quantity::from(50_000)); assert_eq!(order.status(), OrderStatus::PartiallyFilled); // Applying duplicate fill should return DuplicateFill error let result = order.apply(OrderEventAny::Filled(fill2_duplicate)); assert!(result.is_err()); match result.unwrap_err() { OrderError::DuplicateFill(trade_id) => { assert_eq!(trade_id, TradeId::from("TRADE-001")); } e => panic!("Expected DuplicateFill error, was: {e:?}"), } // Order state should be unchanged after rejected duplicate assert_eq!(order.filled_qty(), Quantity::from(50_000)); assert_eq!(order.status(), OrderStatus::PartiallyFilled); } #[rstest] fn test_same_trade_id_new_price_restates_average_and_void_matches_original_price() { let trade_id = TradeId::from("TRADE-1"); let qty = Quantity::from("2"); let original_px = Price::from("12"); let restated_px = Price::from("9.47"); let mut order = market_order_with_fills(OrderSide::Buy, qty, &[(trade_id, qty, original_px)]); let restatement = OrderFilledSpec::builder() .order_side(OrderSide::Buy) .trade_id(trade_id) .last_qty(qty) .last_px(restated_px) .event_id(UUID4::new()) .build(); order .apply(OrderEventAny::Filled(restatement.clone())) .unwrap(); let repeated = order.apply(OrderEventAny::Filled(restatement)); assert!(matches!(repeated, Err(OrderError::DuplicateFill(_)))); let stored_px = order.events().iter().find_map(|event| match event { OrderEventAny::Filled(fill) if fill.last_px == original_px => Some(fill.last_px), _ => None, }); let replayed = OrderAny::from_events(order.events().into_iter().cloned().collect()).unwrap(); assert_eq!(order.status(), OrderStatus::Filled); assert_eq!(order.filled_qty(), qty); assert!(order.leaves_qty().is_zero()); let returned = OrderFilledSpec::builder() .order_side(OrderSide::Buy) .trade_id(trade_id) .last_qty(qty) .last_px(original_px) .event_id(UUID4::new()) .build(); order.apply(OrderEventAny::Filled(returned)).unwrap(); assert_eq!(order.avg_px(), Some(dec!(12))); assert_eq!(order.filled_qty(), qty); assert_eq!(order.trade_ids().len(), 1); assert_eq!(stored_px, Some(original_px)); assert_eq!(replayed.status(), OrderStatus::Filled); assert_eq!(replayed.filled_qty(), qty); assert_eq!(replayed.avg_px(), Some(dec!(9.47))); order .apply(OrderEventAny::FillVoided( OrderFillVoidedSpec::builder() .trade_id(trade_id) .voided_qty(qty) .last_px(original_px) .order_side(OrderSide::Buy) .build(), )) .unwrap(); let mut mismatched = market_order_with_fills(OrderSide::Buy, qty, &[(trade_id, qty, original_px)]); mismatched .apply(OrderEventAny::Filled( OrderFilledSpec::builder() .order_side(OrderSide::Buy) .trade_id(trade_id) .last_qty(qty) .last_px(restated_px) .event_id(UUID4::new()) .build(), )) .unwrap(); let rejected = mismatched.apply(OrderEventAny::FillVoided( OrderFillVoidedSpec::builder() .trade_id(trade_id) .voided_qty(qty) .last_px(restated_px) .order_side(OrderSide::Buy) .build(), )); assert!(matches!(rejected, Err(OrderError::InvalidOrderEvent))); } #[rstest] fn test_fill_raw_overflow_rejected_without_mutation() { let unit = Quantity::from(1); let almost_max = Quantity::from_raw(QUANTITY_RAW_MAX - unit.raw(), 0); let max = Quantity::from_raw(QUANTITY_RAW_MAX, 0); let init = OrderInitializedSpec::builder().quantity(max).build(); let accepted = OrderAcceptedSpec::builder().build(); let fill1 = OrderFilledSpec::builder() .last_qty(almost_max) .trade_id(TradeId::from("TRADE-001")) .build(); let fill2 = OrderFilledSpec::builder() .last_qty(Quantity::from(2)) .trade_id(TradeId::from("TRADE-002")) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(fill1)).unwrap(); assert_eq!(order.filled_qty(), almost_max); assert_eq!(order.status(), OrderStatus::PartiallyFilled); let result = order.apply(OrderEventAny::Filled(fill2)); let Err(OrderError::Invariant(CorrectnessError::PredicateViolation { message })) = result else { panic!("Expected typed invariant error, was: {result:?}"); }; assert_eq!( message, format!("filled quantity overflowed Quantity raw bounds: {almost_max} + 2") ); assert_eq!(order.filled_qty(), almost_max); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.event_count(), 3); } #[rstest] #[case::domain_overflow(1)] #[case::integer_overflow(Money::from("1 USD").raw())] fn test_fill_commission_overflow_rejected_without_mutation( #[case] increment: MoneyRaw, #[values(false, true)] negative: bool, ) { let usd = Currency::USD(); let bound = if negative { MONEY_RAW_MIN } else { MONEY_RAW_MAX }; let commission = Money::from_raw_checked(bound, usd).unwrap(); let additional = Money::from_raw_checked(if negative { -increment } else { increment }, usd).unwrap(); let init = OrderInitializedSpec::builder() .quantity(Quantity::from(2)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); let first = OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-1")) .last_qty(Quantity::from(1)) .commission(commission) .ts_event(11.into()) .build(); let second = OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-2")) .last_qty(Quantity::from(1)) .commission(additional) .position_id(PositionId::from("POSITION-2")) .ts_event(29.into()) .build(); order.apply(OrderEventAny::Filled(first)).unwrap(); let before = serde_json::to_value(&order).unwrap(); let totals_before = order .fill_totals .as_ref() .map(|totals| (totals.notional, totals.quantity)); let result = order.apply(OrderEventAny::Filled(second)); let Err(OrderError::Invariant(CorrectnessError::PredicateViolation { message })) = result else { panic!("Expected typed invariant error, was: {result:?}"); }; assert_eq!( message, format!("cumulative commission overflowed Money bounds: {commission} + {additional}") ); assert_eq!(serde_json::to_value(&order).unwrap(), before); assert_eq!( order .fill_totals .as_ref() .map(|totals| (totals.notional, totals.quantity)), totals_before ); } #[rstest] fn test_fill_commission_bounds_survive_corrections_and_replay() { let usd = Currency::USD(); let aud = Currency::AUD(); let unit = Money::from("1 USD"); let max = Money::from_raw_checked(MONEY_RAW_MAX, usd).unwrap(); let other_max = Money::from_raw_checked(MONEY_RAW_MAX, aud).unwrap(); let init = OrderInitializedSpec::builder() .quantity(Quantity::from(6)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); let first = OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-1")) .last_qty(Quantity::from(2)) .commission(max - unit) .build(); order.apply(OrderEventAny::Filled(first.clone())).unwrap(); order .apply(OrderEventAny::Filled( OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-2")) .last_qty(Quantity::from(1)) .commission(unit) .build(), )) .unwrap(); order .apply(OrderEventAny::Filled( OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-3")) .last_qty(Quantity::from(1)) .commission(other_max) .build(), )) .unwrap(); order .apply(OrderEventAny::FillVoided(matching_fill_void( &first, Quantity::from(1), Some(unit), ))) .unwrap(); assert_eq!(order.commission(&usd), Some(max - unit)); order .apply(OrderEventAny::Filled( OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-4")) .last_qty(Quantity::from(1)) .commission(unit) .build(), )) .unwrap(); let replayed = OrderAny::from_events(order.events().into_iter().cloned().collect()).unwrap(); let before = serde_json::to_value(&order).unwrap(); let overflow = OrderFilledSpec::builder() .trade_id(TradeId::from("TRADE-5")) .last_qty(Quantity::from(1)) .commission(unit) .build(); let result = order.apply(OrderEventAny::Filled(overflow)); assert!(matches!(result, Err(OrderError::Invariant(_)))); assert_eq!(serde_json::to_value(&order).unwrap(), before); assert_eq!(order.commission(&usd), Some(max)); assert_eq!(order.commission(&aud), Some(other_max)); assert_eq!(order.filled_qty(), Quantity::from(4)); assert_eq!(order.leaves_qty(), Quantity::from(2)); assert_eq!(replayed.commissions(), order.commissions()); assert_eq!(replayed.filled_qty(), order.filled_qty()); assert_eq!(replayed.leaves_qty(), order.leaves_qty()); } #[rstest] fn test_late_fill_on_filled_order_is_invalid_transition_not_overflow() { let max = Quantity::from_raw(QUANTITY_RAW_MAX, 0); let init = OrderInitializedSpec::builder().quantity(max).build(); let accepted = OrderAcceptedSpec::builder().build(); let fill1 = OrderFilledSpec::builder() .last_qty(max) .trade_id(TradeId::from("TRADE-001")) .build(); let fill2 = OrderFilledSpec::builder() .last_qty(Quantity::from(1)) .trade_id(TradeId::from("TRADE-002")) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(fill1)).unwrap(); let result = order.apply(OrderEventAny::Filled(fill2)); assert!(matches!(result, Err(OrderError::InvalidStateTransition))); assert_eq!(order.filled_qty(), max); assert_eq!(order.status(), OrderStatus::Filled); } #[rstest] fn test_quantity_saturating_add_clamps_undef_sentinel() { let qty = Quantity::from_raw(QuantityRaw::MAX, 0).saturating_add(Quantity::zero(0)); assert_eq!(qty, Quantity::from_raw(QUANTITY_RAW_MAX, 0)); assert!(!qty.is_undefined()); } #[rstest] fn test_check_display_qty_returns_typed_invariant_with_stable_display() { let error = check_display_qty(Some(Quantity::from(2)), Quantity::from(1)).unwrap_err(); match error { OrderError::Invariant(CorrectnessError::PredicateViolation { ref message }) => { assert_eq!(message, "`display_qty` may not exceed `quantity`"); } other => panic!("Expected typed invariant error, was: {other:?}"), } assert_eq!(error.to_string(), "`display_qty` may not exceed `quantity`"); } #[rstest] fn test_check_time_in_force_returns_typed_invariant_with_stable_display() { let error = check_time_in_force(TimeInForce::Gtd, None).unwrap_err(); match error { OrderError::Invariant(CorrectnessError::PredicateViolation { ref message }) => { assert_eq!(message, "`expire_time` is required for `GTD` order"); } other => panic!("Expected typed invariant error, was: {other:?}"), } assert_eq!( error.to_string(), "`expire_time` is required for `GTD` order" ); } #[rstest] fn test_different_trade_ids_allowed() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let fill1 = OrderFilledSpec::builder() .last_qty(Quantity::from(50_000)) .trade_id(TradeId::from("TRADE-001")) .build(); let fill2 = OrderFilledSpec::builder() .last_qty(Quantity::from(50_000)) .trade_id(TradeId::from("TRADE-002")) // Different trade_id .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(fill1)).unwrap(); order.apply(OrderEventAny::Filled(fill2)).unwrap(); // Both fills should be applied assert_eq!(order.filled_qty(), Quantity::from(100_000)); assert_eq!(order.status(), OrderStatus::Filled); assert_eq!(order.trade_ids.len(), 2); } #[rstest] fn test_pending_update_order_restores_status_on_updated() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let pending_update = OrderPendingUpdateSpec::builder().build(); let fill = OrderFilledSpec::builder() .last_qty(Quantity::from(10_000)) .build(); let updated = OrderUpdatedSpec::builder() .quantity(Quantity::from(50_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); assert_eq!(order.status(), OrderStatus::Accepted); order .apply(OrderEventAny::PendingUpdate(pending_update)) .unwrap(); order.apply(OrderEventAny::Filled(fill)).unwrap(); assert_eq!(order.status(), OrderStatus::PendingUpdate); assert_eq!(order.previous_status(), Some(OrderStatus::PartiallyFilled)); assert_eq!(order.filled_qty(), Quantity::from(10_000)); order.apply(OrderEventAny::Updated(updated)).unwrap(); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.quantity(), Quantity::from(50_000)); assert_eq!(order.leaves_qty(), Quantity::from(40_000)); } #[rstest] fn test_pending_cancel_supersedes_pending_update_without_losing_previous_status() { let mut order: MarketOrder = OrderInitializedSpec::builder().build().try_into().unwrap(); order .apply(OrderEventAny::Submitted( OrderSubmittedSpec::builder().build(), )) .unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); order .apply(OrderEventAny::PendingUpdate( OrderPendingUpdateSpec::builder().build(), )) .unwrap(); assert_eq!(order.status(), OrderStatus::PendingUpdate); assert_eq!(order.previous_status(), Some(OrderStatus::Accepted)); order .apply(OrderEventAny::PendingCancel( OrderPendingCancelSpec::builder().build(), )) .unwrap(); assert_eq!(order.status(), OrderStatus::PendingCancel); assert_eq!(order.previous_status(), Some(OrderStatus::Accepted)); let repeated = OrderEventAny::PendingCancel(OrderPendingCancelSpec::builder().build()); order.apply(repeated.clone()).unwrap(); assert_eq!(order.status(), OrderStatus::PendingCancel); assert_eq!(order.previous_status(), Some(OrderStatus::Accepted)); assert_eq!(order.event_count(), 6); assert_eq!(order.last_event(), &repeated); } #[rstest] fn test_late_fill_for_historical_venue_order_keeps_current_venue_order_id() { let old_venue_order_id = VenueOrderId::from("V-OLD"); let new_venue_order_id = VenueOrderId::from("V-NEW"); let init = OrderInitializedSpec::builder() .quantity(Quantity::from(10)) .build(); let accepted = OrderAcceptedSpec::builder() .venue_order_id(old_venue_order_id) .build(); let updated = OrderUpdatedSpec::builder() .quantity(Quantity::from(10)) .venue_order_id(new_venue_order_id) .build(); let late_fill = OrderFilledSpec::builder() .venue_order_id(old_venue_order_id) .last_qty(Quantity::from(2)) .trade_id(TradeId::from("T-LATE")) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Updated(updated)).unwrap(); order.apply(OrderEventAny::Filled(late_fill)).unwrap(); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.quantity(), Quantity::from(10)); assert_eq!(order.filled_qty(), Quantity::from(2)); assert_eq!(order.leaves_qty(), Quantity::from(8)); assert_eq!(order.venue_order_id(), Some(new_venue_order_id)); assert_eq!( order .venue_order_ids() .into_iter() .copied() .collect::>(), vec![old_venue_order_id, new_venue_order_id], ); let OrderEventAny::Filled(event) = order.last_event() else { panic!("last event was not the late fill"); }; assert_eq!(event.venue_order_id, old_venue_order_id); } #[rstest] fn test_pending_update_accepts_delayed_submitted() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let submitted = OrderSubmittedSpec::builder() .ts_event(UnixNanos::from(1_000)) .build(); let accepted = OrderAcceptedSpec::builder().build(); let pending_update = OrderPendingUpdateSpec::builder().build(); // A distinct account and timestamp so the `submitted` handler's writes are // observable: with identical specs the assertions below cannot tell whether // the handler ran at all. let delayed_submitted = OrderSubmittedSpec::builder() .account_id(AccountId::from("SIM-002")) .ts_event(UnixNanos::from(3_000)) .build(); let updated = OrderUpdatedSpec::builder() .quantity(Quantity::from(50_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order .apply(OrderEventAny::PendingUpdate(pending_update)) .unwrap(); assert_eq!(order.status(), OrderStatus::PendingUpdate); assert_eq!(order.previous_status(), Some(OrderStatus::Accepted)); assert_eq!(order.account_id(), Some(AccountId::from("SIM-001"))); assert_eq!(order.ts_submitted(), Some(UnixNanos::from(1_000))); order .apply(OrderEventAny::Submitted(delayed_submitted)) .unwrap(); // The order holds PendingUpdate and keeps the pre-update status, so the // in-flight modify can still be resolved. assert_eq!(order.status(), OrderStatus::PendingUpdate); assert_eq!(order.previous_status(), Some(OrderStatus::Accepted)); assert_eq!(order.account_id(), Some(AccountId::from("SIM-002"))); assert_eq!(order.ts_submitted(), Some(UnixNanos::from(3_000))); order.apply(OrderEventAny::Updated(updated)).unwrap(); assert_eq!(order.status(), OrderStatus::Accepted); assert_eq!(order.quantity(), Quantity::from(50_000)); } #[rstest] #[case::submitted(false, OrderStatus::Submitted)] #[case::accepted(true, OrderStatus::Accepted)] fn test_rejected_event_preserves_status_restoration( #[case] accepted_first: bool, #[case] restored_status: OrderStatus, ) { let init = OrderInitializedSpec::builder().build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let invalid = OrderSubmittedSpec::builder().build(); let pending_update = OrderPendingUpdateSpec::builder().build(); let modify_rejected = OrderModifyRejectedSpec::builder().build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); if accepted_first { order.apply(OrderEventAny::Accepted(accepted)).unwrap(); } let previous_status = order.previous_status(); let result = order.apply(OrderEventAny::Submitted(invalid)); assert!(matches!(result, Err(OrderError::InvalidStateTransition))); assert_eq!(order.previous_status(), previous_status); order .apply(OrderEventAny::PendingUpdate(pending_update)) .unwrap(); order .apply(OrderEventAny::ModifyRejected(modify_rejected)) .unwrap(); assert_eq!(order.status(), restored_status); } #[rstest] #[case::submitted(OrderStatus::Submitted)] #[case::accepted(OrderStatus::Accepted)] #[case::partially_filled(OrderStatus::PartiallyFilled)] fn test_modify_rejected_after_venue_update(#[case] expected_status: OrderStatus) { let mut order = OrderTestBuilder::new(OrderType::Limit) .instrument_id(InstrumentId::test_default()) .quantity(Quantity::from(10)) .price(Price::from("98.00")) .build(); order .apply(OrderEventAny::Submitted( OrderSubmittedSpec::builder().build(), )) .unwrap(); if expected_status != OrderStatus::Submitted { order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); } let filled_qty = if expected_status == OrderStatus::PartiallyFilled { order .apply(OrderEventAny::Filled( OrderFilledSpec::builder() .order_type(OrderType::Limit) .last_qty(Quantity::from(3)) .build(), )) .unwrap(); Quantity::from(3) } else { Quantity::from(0) }; order .apply(OrderEventAny::PendingUpdate( OrderPendingUpdateSpec::builder().build(), )) .unwrap(); order .apply(OrderEventAny::Updated( OrderUpdatedSpec::builder() .quantity(Quantity::from(20)) .price(Price::from("97.00")) .build(), )) .unwrap(); assert_eq!(order.status(), expected_status); let previous_status = order.previous_status(); let events_before = order.events().len(); let rejection = OrderEventAny::ModifyRejected(OrderModifyRejectedSpec::builder().build()); order.apply(rejection.clone()).unwrap(); assert_eq!(order.status(), expected_status); assert_eq!(order.previous_status(), previous_status); assert_eq!(order.quantity(), Quantity::from(20)); assert_eq!(order.price(), Some(Price::from("97.00"))); assert_eq!(order.filled_qty(), filled_qty); assert_eq!(order.leaves_qty(), Quantity::from(20) - filled_qty); assert_eq!(order.events().len(), events_before + 1); assert_eq!(order.last_event(), &rejection); } #[rstest] fn test_modify_rejected_preserves_triggered_with_stale_previous_status() { let mut order = OrderTestBuilder::new(OrderType::StopLimit) .instrument_id(InstrumentId::test_default()) .quantity(Quantity::from(10)) .price(Price::from("98.00")) .trigger_price(Price::from("99.00")) .build(); order .apply(OrderEventAny::Submitted( OrderSubmittedSpec::builder().build(), )) .unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); order .apply(OrderEventAny::PendingUpdate( OrderPendingUpdateSpec::builder().build(), )) .unwrap(); order .apply(OrderEventAny::Updated( OrderUpdatedSpec::builder() .quantity(Quantity::from(20)) .price(Price::from("97.00")) .trigger_price(Price::from("98.00")) .build(), )) .unwrap(); order .apply(OrderEventAny::Triggered( OrderTriggeredSpec::builder().build(), )) .unwrap(); assert_eq!(order.status(), OrderStatus::Triggered); assert_eq!(order.previous_status(), Some(OrderStatus::Accepted)); let rejection = OrderEventAny::ModifyRejected(OrderModifyRejectedSpec::builder().build()); order.apply(rejection.clone()).unwrap(); assert_eq!(order.status(), OrderStatus::Triggered); assert_eq!(order.quantity(), Quantity::from(20)); assert_eq!(order.price(), Some(Price::from("97.00"))); assert_eq!(order.trigger_price(), Some(Price::from("98.00"))); assert_eq!(order.last_event(), &rejection); } #[rstest] #[case::submitted(OrderStatus::Submitted)] #[case::accepted(OrderStatus::Accepted)] #[case::pending_update(OrderStatus::PendingUpdate)] #[case::pending_cancel(OrderStatus::PendingCancel)] fn test_modify_rejected_without_previous_status(#[case] status: OrderStatus) { let mut order: MarketOrder = OrderInitializedSpec::builder() .quantity(Quantity::from(10)) .build() .try_into() .unwrap(); if status == OrderStatus::Submitted { order .apply(OrderEventAny::Submitted( OrderSubmittedSpec::builder().build(), )) .unwrap(); } else { order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); } match status { OrderStatus::PendingUpdate => order .apply(OrderEventAny::PendingUpdate( OrderPendingUpdateSpec::builder().build(), )) .unwrap(), OrderStatus::PendingCancel => order .apply(OrderEventAny::PendingCancel( OrderPendingCancelSpec::builder().build(), )) .unwrap(), _ => {} } order.previous_status = None; let events_before = order.events().len(); let rejection = OrderEventAny::ModifyRejected(OrderModifyRejectedSpec::builder().build()); let result = order.apply(rejection.clone()); if status == OrderStatus::PendingUpdate { assert!(matches!(result, Err(OrderError::NoPreviousState))); assert_eq!(order.events().len(), events_before); } else { result.unwrap(); assert_eq!(order.last_event(), &rejection); } assert_eq!(order.status(), status); assert_eq!(order.quantity(), Quantity::from(10)); assert_eq!(order.previous_status(), None); } #[rstest] #[case::submitted(false, OrderStatus::Submitted)] #[case::accepted(true, OrderStatus::Accepted)] fn test_modify_rejected_preserves_pending_cancel( #[case] accepted_first: bool, #[case] restored_status: OrderStatus, ) { let init = OrderInitializedSpec::builder().build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let pending_update = OrderPendingUpdateSpec::builder().build(); let pending_cancel = OrderPendingCancelSpec::builder().build(); let modify_rejected = OrderModifyRejectedSpec::builder().build(); let cancel_rejected = OrderCancelRejectedSpec::builder().build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); if accepted_first { order.apply(OrderEventAny::Accepted(accepted)).unwrap(); } order .apply(OrderEventAny::PendingUpdate(pending_update)) .unwrap(); order .apply(OrderEventAny::PendingCancel(pending_cancel)) .unwrap(); order .apply(OrderEventAny::ModifyRejected(modify_rejected)) .unwrap(); assert_eq!(order.status(), OrderStatus::PendingCancel); assert_eq!(order.previous_status(), Some(restored_status)); order .apply(OrderEventAny::CancelRejected(cancel_rejected)) .unwrap(); assert_eq!(order.status(), restored_status); } #[rstest] fn test_modify_rejected_transition_matrix() { let event = OrderEventAny::ModifyRejected(OrderModifyRejectedSpec::builder().build()); for status in OrderStatus::iter() { let expected = matches!( status, OrderStatus::Submitted | OrderStatus::Accepted | OrderStatus::Triggered | OrderStatus::PartiallyFilled | OrderStatus::PendingUpdate | OrderStatus::PendingCancel ); let mut current = status; let result = current.transition(&event); if expected { assert_eq!(result.unwrap(), status, "status={status}"); } else { assert!( matches!(result, Err(OrderError::InvalidStateTransition)), "status={status}" ); } assert_eq!(current, status); } } #[rstest] fn test_cancel_rejected_transition_matrix() { let event = OrderEventAny::CancelRejected(OrderCancelRejectedSpec::builder().build()); for status in OrderStatus::iter() { let expected = match status { OrderStatus::Submitted | OrderStatus::Accepted | OrderStatus::Triggered | OrderStatus::PartiallyFilled | OrderStatus::PendingUpdate | OrderStatus::PendingCancel => Some(status), OrderStatus::Initialized | OrderStatus::Emulated | OrderStatus::Released | OrderStatus::Denied | OrderStatus::Rejected | OrderStatus::Canceled | OrderStatus::Expired | OrderStatus::Filled | OrderStatus::Voided => None, }; let mut current = status; let result = current.transition(&event); if let Some(expected) = expected { assert_eq!(result.unwrap(), expected, "status={status}"); } else { assert!( matches!(result, Err(OrderError::InvalidStateTransition)), "status={status}" ); } assert_eq!(current, status); } } #[rstest] #[case::submitted(&[], OrderStatus::Submitted, false)] #[case::accepted(&[OrderStatus::Accepted], OrderStatus::Accepted, false)] #[case::pending_submitted(&[OrderStatus::PendingCancel], OrderStatus::Submitted, false)] #[case::pending_accepted(&[OrderStatus::Accepted, OrderStatus::PendingCancel], OrderStatus::Accepted, false)] #[case::acceptance_overtakes_submitted_cancel(&[OrderStatus::PendingCancel, OrderStatus::Accepted], OrderStatus::Accepted, false)] #[case::acceptance_overtakes_accepted_cancel(&[OrderStatus::Accepted, OrderStatus::PendingCancel, OrderStatus::Accepted], OrderStatus::Accepted, false)] #[case::triggered(&[OrderStatus::Accepted, OrderStatus::Triggered], OrderStatus::Triggered, false)] #[case::partially_filled(&[OrderStatus::Accepted, OrderStatus::PartiallyFilled], OrderStatus::PartiallyFilled, false)] #[case::pending_triggered(&[OrderStatus::Accepted, OrderStatus::Triggered, OrderStatus::PendingCancel], OrderStatus::Triggered, false)] #[case::pending_partially_filled(&[OrderStatus::Accepted, OrderStatus::PartiallyFilled, OrderStatus::PendingCancel], OrderStatus::PartiallyFilled, false)] #[case::fill_overtakes_cancel(&[OrderStatus::Accepted, OrderStatus::PendingCancel, OrderStatus::PartiallyFilled], OrderStatus::PartiallyFilled, false)] #[case::pending_update_accepted_updated(&[OrderStatus::Accepted, OrderStatus::PendingUpdate], OrderStatus::PendingUpdate, false)] #[case::pending_update_accepted_rejected(&[OrderStatus::Accepted, OrderStatus::PendingUpdate], OrderStatus::PendingUpdate, true)] #[case::pending_update_triggered_updated(&[OrderStatus::Accepted, OrderStatus::Triggered, OrderStatus::PendingUpdate], OrderStatus::PendingUpdate, false)] #[case::pending_update_triggered_rejected(&[OrderStatus::Accepted, OrderStatus::Triggered, OrderStatus::PendingUpdate], OrderStatus::PendingUpdate, true)] #[case::pending_update_partially_filled_updated(&[OrderStatus::Accepted, OrderStatus::PartiallyFilled, OrderStatus::PendingUpdate], OrderStatus::PendingUpdate, false)] #[case::pending_update_partially_filled_rejected(&[OrderStatus::Accepted, OrderStatus::PartiallyFilled, OrderStatus::PendingUpdate], OrderStatus::PendingUpdate, true)] fn test_cancel_rejected_preserves_order_state( #[case] statuses: &[OrderStatus], #[case] expected_status: OrderStatus, #[case] modify_rejected: bool, ) { let order_type = if statuses.contains(&OrderStatus::Triggered) { OrderType::StopLimit } else { OrderType::Market }; let mut order = OrderTestBuilder::new(order_type) .instrument_id(InstrumentId::test_default()) .quantity(Quantity::from(100_000)) .price(Price::from("1.00000")) .trigger_price(Price::from("1.00000")) .trigger_type(TriggerType::LastPrice) .build(); order .apply(OrderEventAny::Submitted( OrderSubmittedSpec::builder().build(), )) .unwrap(); for status in statuses { let source_status = order.status(); let event = match status { OrderStatus::Accepted => { OrderEventAny::Accepted(OrderAcceptedSpec::builder().build()) } OrderStatus::Triggered => { OrderEventAny::Triggered(OrderTriggeredSpec::builder().build()) } OrderStatus::PartiallyFilled => OrderEventAny::Filled( OrderFilledSpec::builder() .order_type(order_type) .last_qty(Quantity::from(40_000)) .build(), ), OrderStatus::PendingUpdate => { OrderEventAny::PendingUpdate(OrderPendingUpdateSpec::builder().build()) } OrderStatus::PendingCancel => { OrderEventAny::PendingCancel(OrderPendingCancelSpec::builder().build()) } _ => panic!("Unsupported setup status: {status}"), }; order.apply(event).unwrap(); let expected = if *status == OrderStatus::PartiallyFilled && source_status == OrderStatus::PendingCancel { OrderStatus::PendingCancel } else { *status }; assert_eq!(order.status(), expected); } let previous_status = order.previous_status(); let quantity = order.quantity(); let filled_qty = order.filled_qty(); let leaves_qty = order.leaves_qty(); let venue_order_id = order.venue_order_id(); let event_count = order.events().len(); let cancel_rejected = OrderEventAny::CancelRejected(OrderCancelRejectedSpec::builder().build()); order.apply(cancel_rejected.clone()).unwrap(); assert_eq!(order.status(), expected_status); assert_eq!(order.previous_status(), previous_status); assert_eq!(order.quantity(), quantity); assert_eq!(order.filled_qty(), filled_qty); assert_eq!(order.leaves_qty(), leaves_qty); assert_eq!(order.venue_order_id(), venue_order_id); assert_eq!(order.events().len(), event_count + 1); assert_eq!(order.last_event(), &cancel_rejected); if expected_status == OrderStatus::Submitted { order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); assert_eq!(order.status(), OrderStatus::Accepted); } if expected_status == OrderStatus::PendingUpdate { let restored_status = previous_status.unwrap(); let resolution = if modify_rejected { OrderEventAny::ModifyRejected(OrderModifyRejectedSpec::builder().build()) } else { OrderEventAny::Updated( OrderUpdatedSpec::builder() .quantity(Quantity::from(80_000)) .build(), ) }; order.apply(resolution).unwrap(); assert_eq!(order.status(), restored_status); assert_eq!(order.previous_status(), previous_status); assert_eq!( order.quantity(), if modify_rejected { quantity } else { Quantity::from(80_000) } ); assert_eq!(order.filled_qty(), filled_qty); assert_eq!(order.leaves_qty(), order.quantity() - filled_qty); assert_eq!(order.venue_order_id(), venue_order_id); } order .apply(OrderEventAny::PendingCancel( OrderPendingCancelSpec::builder().build(), )) .unwrap(); order .apply(OrderEventAny::Canceled( OrderCanceledSpec::builder().build(), )) .unwrap(); assert_eq!(order.status(), OrderStatus::Canceled); } #[rstest] fn test_rejected_event_preserves_cancel_rejected_restoration() { let init = OrderInitializedSpec::builder().build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let invalid = OrderSubmittedSpec::builder().build(); let pending_cancel = OrderPendingCancelSpec::builder().build(); let cancel_rejected = OrderCancelRejectedSpec::builder().build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); let previous_status = order.previous_status(); let result = order.apply(OrderEventAny::Submitted(invalid)); assert!(matches!(result, Err(OrderError::InvalidStateTransition))); assert_eq!(order.previous_status(), previous_status); order .apply(OrderEventAny::PendingCancel(pending_cancel)) .unwrap(); order .apply(OrderEventAny::CancelRejected(cancel_rejected)) .unwrap(); assert_eq!(order.status(), OrderStatus::Accepted); } #[rstest] #[case(true, false, "client_order_id")] #[case(false, true, "strategy_id")] fn test_update_identity_mismatch_precedes_field_validation( #[case] wrong_client: bool, #[case] wrong_strategy: bool, #[case] expected_field: &str, ) { let init = OrderInitializedSpec::builder().build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); let state = ( order.status(), order.previous_status(), order.ts_last(), order.events().len(), ); let client_order_id = if wrong_client { ClientOrderId::from("O-INVALID") } else { order.client_order_id() }; let strategy_id = if wrong_strategy { StrategyId::from("OTHER-001") } else { order.strategy_id() }; // Carry both a mismatched identity and a price a MarketOrder cannot hold: // the identity mismatch must be reported ahead of the field violation. let updated = OrderUpdatedSpec::builder() .client_order_id(client_order_id) .strategy_id(strategy_id) .price(Price::new(95.0, 2)) .build(); let result = order.apply(OrderEventAny::Updated(updated)); // The specific identity predicate must surface, not the field violation. let Err(OrderError::Invariant(CorrectnessError::PredicateViolation { message })) = result else { panic!("expected an identity predicate violation, was {result:?}"); }; assert!( message.contains(expected_field), "message did not name {expected_field}: {message}" ); assert_eq!(order.status(), state.0); assert_eq!(order.previous_status(), state.1); assert_eq!(order.ts_last(), state.2); assert_eq!(order.events().len(), state.3); } #[rstest] fn test_update_invalid_transition_precedes_field_validation() { let init = OrderInitializedSpec::builder().build(); let denied = OrderDeniedSpec::builder().build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Denied(denied)).unwrap(); let state = ( order.status(), order.previous_status(), order.ts_last(), order.events().len(), ); // No (Denied, Updated) transition exists, and the price is invalid for a // MarketOrder: the transition check must win over the field violation. let updated = OrderUpdatedSpec::builder() .price(Price::new(95.0, 2)) .build(); let result = order.apply(OrderEventAny::Updated(updated)); assert!(matches!(result, Err(OrderError::InvalidStateTransition))); assert_eq!(order.status(), state.0); assert_eq!(order.previous_status(), state.1); assert_eq!(order.ts_last(), state.2); assert_eq!(order.events().len(), state.3); } #[rstest] fn test_direct_update_panics_on_invalid_field_before_mutation() { let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); let original_quantity = order.quantity(); // A distinct quantity so any mutation would be observable, alongside a // price the type cannot hold. The low-level mutation path retains a // defensive guard: a direct update() panics on the invalid field before // it reaches the quantity assignment. let updated = OrderUpdatedSpec::builder() .quantity(Quantity::from(50_000)) .price(Price::new(95.0, 2)) .build(); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| order.update(&updated))); assert!(result.is_err()); assert_eq!(order.quantity(), original_quantity); } #[rstest] fn test_partially_filled_order_can_be_updated() { // Test that a partially filled order can receive an Updated event // and remain in PartiallyFilled status let init = OrderInitializedSpec::builder() .quantity(Quantity::from(100_000)) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let partial_fill = OrderFilledSpec::builder() .last_qty(Quantity::from(40_000)) .build(); let updated = OrderUpdatedSpec::builder() .quantity(Quantity::from(80_000)) // Reduce to 80k (still > 40k filled) .build(); let mut order: MarketOrder = init.try_into().unwrap(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(partial_fill)).unwrap(); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.filled_qty(), Quantity::from(40_000)); order.apply(OrderEventAny::Updated(updated)).unwrap(); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.quantity(), Quantity::from(80_000)); assert_eq!(order.leaves_qty(), Quantity::from(40_000)); // 80k - 40k filled } #[rstest] fn test_reconciliation_update_closes_order_at_filled_quantity() { let mut order: MarketOrder = OrderInitializedSpec::builder() .quantity(Quantity::from(100)) .build() .try_into() .unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); order .apply(OrderEventAny::Filled( OrderFilledSpec::builder() .last_qty(Quantity::from(40)) .build(), )) .unwrap(); let updated = OrderEventAny::Updated( OrderUpdatedSpec::builder() .quantity(Quantity::from(40)) .reconciliation(true) .ts_event(UnixNanos::from(5_000)) .build(), ); order.apply(updated.clone()).unwrap(); let replayed = OrderAny::from_events(order.events().into_iter().cloned().collect()).unwrap(); assert_eq!(order.status(), OrderStatus::Filled); assert_eq!(order.previous_status(), Some(OrderStatus::PartiallyFilled)); assert_eq!(order.quantity(), Quantity::from(40)); assert_eq!(order.filled_qty(), Quantity::from(40)); assert_eq!(order.leaves_qty(), Quantity::from(0)); assert_eq!(order.ts_closed(), Some(UnixNanos::from(5_000))); assert_eq!(order.ts_last(), UnixNanos::from(5_000)); assert_eq!(order.event_count(), 4); assert_eq!(order.last_event(), &updated); assert_eq!(replayed.status(), order.status()); assert_eq!(replayed.previous_status(), order.previous_status()); assert_eq!(replayed.quantity(), order.quantity()); assert_eq!(replayed.filled_qty(), order.filled_qty()); assert_eq!(replayed.leaves_qty(), order.leaves_qty()); assert_eq!(replayed.ts_closed(), order.ts_closed()); assert_eq!(replayed.ts_last(), order.ts_last()); } #[rstest] fn test_triggered_order_can_be_updated() { // Test that a triggered order can receive an Updated event // and remain in Triggered status let instrument_id = InstrumentId::from("ETHUSDT-LINEAR.BYBIT"); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let triggered = OrderTriggeredSpec::builder().build(); let updated = OrderUpdatedSpec::builder() .quantity(Quantity::from(80_000)) .build(); let mut order = OrderTestBuilder::new(OrderType::StopLimit) .instrument_id(instrument_id) .quantity(Quantity::from(100_000)) .price(Price::from("0.99500")) .trigger_price(Price::from("1.00000")) .trigger_type(TriggerType::LastPrice) .build(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Triggered(triggered)).unwrap(); assert_eq!(order.status(), OrderStatus::Triggered); order.apply(OrderEventAny::Updated(updated)).unwrap(); assert_eq!(order.status(), OrderStatus::Triggered); assert_eq!(order.quantity(), Quantity::from(80_000)); } #[rstest] fn test_order_updated_with_is_quote_quantity_clears_flag() { let init = OrderInitializedSpec::builder() .quantity(Quantity::new(10.0, 6)) .quote_quantity(true) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let updated = OrderUpdatedSpec::builder() .quantity(Quantity::new(47.393_365, 6)) .is_quote_quantity(false) .build(); let mut order: MarketOrder = init.try_into().unwrap(); assert!(order.is_quote_quantity()); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Updated(updated)).unwrap(); assert!(!order.is_quote_quantity()); assert_eq!(order.quantity(), Quantity::new(47.393_365, 6)); assert_eq!(order.leaves_qty(), Quantity::new(47.393_365, 6)); } #[rstest] fn test_order_updated_default_is_quote_quantity_clears_flag() { let init = OrderInitializedSpec::builder() .quantity(Quantity::new(10.0, 6)) .quote_quantity(true) .build(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); // Builder defaults is_quote_quantity to false let updated = OrderUpdatedSpec::builder() .quantity(Quantity::new(8.0, 6)) .build(); let mut order: MarketOrder = init.try_into().unwrap(); assert!(order.is_quote_quantity()); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Updated(updated)).unwrap(); assert!(!order.is_quote_quantity()); assert_eq!(order.quantity(), Quantity::new(8.0, 6)); } #[rstest] #[case::partial(40_000, OrderStatus::Expired, 10)] #[case::complete(100_000, OrderStatus::Filled, 20)] fn test_expired_order_receiving_fill( #[case] fill_qty: u64, #[case] expected_status: OrderStatus, #[case] closed_at: u64, ) { let mut order: LimitOrder = OrderInitializedSpec::builder() .order_type(OrderType::Limit) .quantity(Quantity::from(100_000)) .price(Price::from("1.00000")) .time_in_force(TimeInForce::Gtd) .expire_time(UnixNanos::from(10)) .build() .try_into() .unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); let expired = OrderEventAny::Expired( OrderExpiredSpec::builder() .ts_event(UnixNanos::from(10)) .build(), ); order.apply(expired).unwrap(); let fill = OrderEventAny::Filled( OrderFilledSpec::builder() .order_type(OrderType::Limit) .last_qty(Quantity::from(fill_qty)) .last_px(Price::from("1.25000")) .commission(Money::from("2 USD")) .ts_event(UnixNanos::from(20)) .build(), ); let mut persisted_events: Vec<_> = order.events().into_iter().cloned().collect(); order.apply(fill.clone()).unwrap(); persisted_events.push(order.last_event().clone()); let repeated_expiry = OrderEventAny::Expired( OrderExpiredSpec::builder() .ts_event(UnixNanos::from(30)) .build(), ); order.apply(repeated_expiry.clone()).unwrap(); persisted_events.push(order.last_event().clone()); let replayed = OrderAny::from_events(persisted_events).unwrap(); assert_eq!(order.status(), expected_status); assert_eq!(order.filled_qty(), Quantity::from(fill_qty)); assert_eq!(order.leaves_qty(), Quantity::from(100_000 - fill_qty)); assert_eq!(order.avg_px(), Some(dec!(1.25))); assert_eq!( order.commissions().get(&Currency::USD()), Some(&Money::from("2 USD")) ); assert_eq!(order.ts_closed(), Some(UnixNanos::from(closed_at))); assert_eq!(order.ts_last(), UnixNanos::from(20)); assert_eq!(order.events().len(), 5); assert_eq!(order.last_event(), &repeated_expiry); assert_eq!(replayed.status(), expected_status); assert_eq!(replayed.filled_qty(), Quantity::from(fill_qty)); assert_eq!(replayed.leaves_qty(), Quantity::from(100_000 - fill_qty)); assert_eq!(replayed.avg_px(), Some(dec!(1.25))); assert_eq!(replayed.commissions(), order.commissions()); assert_eq!(replayed.ts_closed(), Some(UnixNanos::from(closed_at))); assert_eq!(replayed.ts_last(), UnixNanos::from(20)); assert_eq!(replayed.events(), order.events()); assert!(matches!( order.apply(repeated_expiry), Err(OrderError::InvalidStateTransition) )); assert_eq!(order.events(), replayed.events()); assert!(order.is_closed()); assert!(!order.is_open()); assert!(matches!( order.apply(fill), Err(OrderError::DuplicateFill(_)) )); assert_eq!(order.filled_qty(), Quantity::from(fill_qty)); } #[rstest] fn test_expired_order_rejects_expiry_without_new_fill() { let mut order: MarketOrder = OrderInitializedSpec::builder().build().try_into().unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); order .apply(OrderEventAny::Expired( OrderExpiredSpec::builder() .ts_event(UnixNanos::from(10)) .build(), )) .unwrap(); let events: Vec<_> = order.events().into_iter().cloned().collect(); let result = order.apply(OrderEventAny::Expired( OrderExpiredSpec::builder() .ts_event(UnixNanos::from(30)) .build(), )); assert!(matches!(result, Err(OrderError::InvalidStateTransition))); assert_eq!(order.status(), OrderStatus::Expired); assert_eq!(order.ts_closed(), Some(UnixNanos::from(10))); assert_eq!(order.ts_last(), UnixNanos::from(10)); assert_eq!(order.events(), events.iter().collect::>()); } #[rstest] fn test_filled_order_rejects_expiry_without_prior_expiry() { let mut order: MarketOrder = OrderInitializedSpec::builder().build().try_into().unwrap(); order .apply(OrderEventAny::Accepted( OrderAcceptedSpec::builder().build(), )) .unwrap(); order .apply(OrderEventAny::Filled(OrderFilledSpec::builder().build())) .unwrap(); let result = order.apply(OrderEventAny::Expired(OrderExpiredSpec::builder().build())); assert!(matches!(result, Err(OrderError::InvalidStateTransition))); assert_eq!(order.status(), OrderStatus::Filled); assert_eq!(order.filled_qty(), Quantity::from(100_000)); assert_eq!(order.events().len(), 3); } #[rstest] fn test_canceled_order_accepts_repeated_cancel_after_partial_fill() { let mut order: MarketOrder = OrderInitializedSpec::builder().build().try_into().unwrap(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let canceled1 = OrderCanceledSpec::builder().build(); let fill = OrderFilledSpec::builder() .last_qty(Quantity::from(50_000)) .trade_id(TradeId::from("FILL-1")) .build(); let canceled2 = OrderCanceledSpec::builder().build(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Canceled(canceled1)).unwrap(); assert_eq!(order.status(), OrderStatus::Canceled); assert!(order.is_closed()); order.apply(OrderEventAny::Filled(fill)).unwrap(); assert_eq!(order.status(), OrderStatus::Canceled); assert_eq!(order.filled_qty(), Quantity::from(50_000)); assert_eq!(order.leaves_qty(), Quantity::from(50_000)); assert!(order.is_closed()); assert!(!order.is_open()); order.apply(OrderEventAny::Canceled(canceled2)).unwrap(); assert_eq!(order.status(), OrderStatus::Canceled); assert_eq!(order.filled_qty(), Quantity::from(50_000)); assert_eq!(order.leaves_qty(), Quantity::from(50_000)); assert_eq!( order .events() .iter() .filter(|event| matches!(event, OrderEventAny::Canceled(_))) .count(), 2 ); assert!(order.is_closed()); assert!(!order.is_open()); } #[rstest] fn test_updated_restores_status_before_pending_cancel() { let mut order: MarketOrder = OrderInitializedSpec::builder().build().try_into().unwrap(); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let pending_cancel = OrderPendingCancelSpec::builder().build(); let fill = OrderFilledSpec::builder() .last_qty(Quantity::from(10_000)) .build(); let updated = OrderUpdatedSpec::builder() .quantity(Quantity::from(150_000)) .build(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); let ts_accepted = order.ts_accepted(); let venue_order_ids: Vec = order.venue_order_ids().into_iter().copied().collect(); order .apply(OrderEventAny::PendingCancel(pending_cancel)) .unwrap(); order.apply(OrderEventAny::Filled(fill)).unwrap(); assert_eq!(order.status(), OrderStatus::PendingCancel); assert_eq!(order.previous_status(), Some(OrderStatus::PartiallyFilled)); order.apply(OrderEventAny::Updated(updated)).unwrap(); assert_eq!(order.status(), OrderStatus::PartiallyFilled); assert_eq!(order.quantity(), Quantity::from(150_000)); assert_eq!(order.filled_qty(), Quantity::from(10_000)); assert_eq!(order.leaves_qty(), Quantity::from(140_000)); assert_eq!(order.ts_accepted(), ts_accepted); assert_eq!( order .venue_order_ids() .into_iter() .copied() .collect::>(), venue_order_ids, ); } #[rstest] fn test_apply_triggered_to_stop_market_order_returns_error() { let instrument_id = InstrumentId::from("ETHUSDT-LINEAR.BYBIT"); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let triggered = OrderTriggeredSpec::builder().build(); let mut order = OrderTestBuilder::new(OrderType::StopMarket) .instrument_id(instrument_id) .quantity(Quantity::from(1)) .trigger_price(Price::from("1.00000")) .trigger_type(TriggerType::LastPrice) .build(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); let result = order.apply(OrderEventAny::Triggered(triggered)); assert!(result.is_err()); assert_eq!(order.status(), OrderStatus::Accepted); } #[rstest] fn test_apply_triggered_to_stop_limit_order_succeeds() { let instrument_id = InstrumentId::from("ETHUSDT-LINEAR.BYBIT"); let submitted = OrderSubmittedSpec::builder().build(); let accepted = OrderAcceptedSpec::builder().build(); let triggered = OrderTriggeredSpec::builder().build(); let mut order = OrderTestBuilder::new(OrderType::StopLimit) .instrument_id(instrument_id) .quantity(Quantity::from(1)) .price(Price::from("0.99500")) .trigger_price(Price::from("1.00000")) .trigger_type(TriggerType::LastPrice) .build(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Triggered(triggered)).unwrap(); assert_eq!(order.status(), OrderStatus::Triggered); } #[rstest] fn test_to_order_status_report_for_accepted_limit_order() { let order = OrderTestBuilder::new(OrderType::Limit) .instrument_id(InstrumentId::from("AUDUSD.SIM")) .side(OrderSide::Buy) .quantity(Quantity::from(100_000)) .price(Price::from("1.00000")) .build(); let accepted = TestOrderStubs::make_accepted_order(&order); let report = accepted.to_order_status_report(None).unwrap(); assert_eq!(report.account_id, AccountId::from("SIM-001")); assert_eq!(report.instrument_id, InstrumentId::from("AUDUSD.SIM")); assert_eq!(report.client_order_id, Some(accepted.client_order_id())); assert_eq!(report.venue_order_id, VenueOrderId::from("V-001")); assert_eq!(report.order_side, Some(OrderSide::Buy)); assert_eq!(report.order_type, OrderType::Limit); assert_eq!(report.time_in_force, accepted.time_in_force()); assert_eq!(report.order_status, OrderStatus::Accepted); assert_eq!(report.quantity, Quantity::from(100_000)); assert_eq!(report.filled_qty, Quantity::from(0)); assert_eq!(report.price, Some(Price::from("1.00000"))); assert_eq!(report.avg_px, None); assert_eq!(report.ts_accepted, accepted.ts_accepted().unwrap()); assert_eq!(report.ts_last, accepted.ts_last()); assert_eq!(report.ts_init, accepted.ts_init()); } #[rstest] fn test_to_order_status_report_for_filled_market_order(audusd_sim: CurrencyPair) { let audusd_sim = InstrumentAny::CurrencyPair(audusd_sim); let order = OrderTestBuilder::new(OrderType::Market) .instrument_id(audusd_sim.id()) .side(OrderSide::Buy) .quantity(Quantity::from(100_000)) .build(); let filled = TestOrderStubs::make_filled_order(&order, &audusd_sim, LiquiditySide::Maker); let report_id = UUID4::new(); let report = filled.to_order_status_report(Some(report_id)).unwrap(); assert_eq!(report.report_id, report_id); assert_eq!(report.order_status, OrderStatus::Filled); assert_eq!(report.quantity, Quantity::from(100_000)); assert_eq!(report.filled_qty, Quantity::from(100_000)); assert_eq!(report.price, None); assert_eq!(report.avg_px, Some(dec!(1))); assert_eq!(report.venue_position_id, Some(PositionId::from("1"))); assert_eq!(report.ts_accepted, filled.ts_accepted().unwrap()); assert_eq!(report.ts_last, filled.ts_last()); } #[rstest] fn test_to_order_status_report_maps_optional_fields() { let order = OrderTestBuilder::new(OrderType::StopLimit) .instrument_id(InstrumentId::from("AUDUSD.SIM")) .side(OrderSide::Sell) .quantity(Quantity::from(100_000)) .price(Price::from("0.99500")) .trigger_price(Price::from("1.00000")) .trigger_type(TriggerType::LastPrice) .time_in_force(TimeInForce::Gtd) .expire_time(UnixNanos::from(5_000_000_000)) .display_qty(Quantity::from(10_000)) .post_only(true) .reduce_only(true) .contingency_type(ContingencyType::Oto) .order_list_id(OrderListId::from("OL-001")) .linked_order_ids(vec![ClientOrderId::from("O-CHILD")]) .parent_order_id(ClientOrderId::from("O-PARENT")) .build(); let accepted = TestOrderStubs::make_accepted_order(&order); let report = accepted.to_order_status_report(None).unwrap(); assert_eq!(report.price, Some(Price::from("0.99500"))); assert_eq!(report.trigger_price, Some(Price::from("1.00000"))); assert_eq!(report.trigger_type, Some(TriggerType::LastPrice)); assert_eq!(report.expire_time, Some(UnixNanos::from(5_000_000_000))); assert_eq!(report.display_qty, Some(Quantity::from(10_000))); assert!(report.post_only); assert!(report.reduce_only); assert_eq!(report.contingency_type, Some(ContingencyType::Oto)); assert_eq!(report.order_list_id, Some(OrderListId::from("OL-001"))); assert_eq!( report.linked_order_ids, Some(vec![ClientOrderId::from("O-CHILD")]) ); assert_eq!( report.parent_order_id, Some(ClientOrderId::from("O-PARENT")) ); } #[rstest] fn test_to_order_status_report_maps_ts_triggered() { let order = OrderTestBuilder::new(OrderType::StopLimit) .instrument_id(InstrumentId::from("AUDUSD.SIM")) .side(OrderSide::Buy) .quantity(Quantity::from(100_000)) .price(Price::from("0.99500")) .trigger_price(Price::from("1.00000")) .trigger_type(TriggerType::LastPrice) .build(); let mut order = TestOrderStubs::make_accepted_order(&order); let triggered = OrderTriggeredSpec::builder() .ts_event(UnixNanos::from(1_500_000_000)) .build(); order.apply(OrderEventAny::Triggered(triggered)).unwrap(); let report = order.to_order_status_report(None).unwrap(); assert_eq!(report.order_status, OrderStatus::Triggered); assert_eq!(report.ts_triggered, Some(UnixNanos::from(1_500_000_000))); } #[rstest] fn test_to_order_status_report_maps_rejection_reason() { let order = OrderTestBuilder::new(OrderType::Limit) .instrument_id(InstrumentId::from("AUDUSD.SIM")) .side(OrderSide::Buy) .quantity(Quantity::from(100_000)) .price(Price::from("1.00000")) .build(); let mut order = TestOrderStubs::make_accepted_order(&order); let rejected = OrderRejectedSpec::builder() .reason(Ustr::from("INSUFFICIENT_MARGIN")) .build(); order.apply(OrderEventAny::Rejected(rejected)).unwrap(); let report = order.to_order_status_report(None).unwrap(); assert_eq!(report.order_status, OrderStatus::Rejected); assert_eq!( report.cancel_reason, Some("INSUFFICIENT_MARGIN".to_string()) ); } #[rstest] fn test_to_order_status_report_maps_distinct_timestamps() { let mut order = OrderTestBuilder::new(OrderType::Limit) .instrument_id(InstrumentId::from("AUDUSD.SIM")) .side(OrderSide::Buy) .quantity(Quantity::from(100_000)) .price(Price::from("1.00000")) .ts_init(UnixNanos::from(1_000)) .build(); let submitted = OrderSubmittedSpec::builder() .ts_event(UnixNanos::from(2_000)) .build(); let accepted = OrderAcceptedSpec::builder() .ts_event(UnixNanos::from(3_000)) .build(); let filled = OrderFilledSpec::builder() .last_qty(Quantity::from(50_000)) .ts_event(UnixNanos::from(4_000)) .build(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Accepted(accepted)).unwrap(); order.apply(OrderEventAny::Filled(filled)).unwrap(); let report = order.to_order_status_report(None).unwrap(); assert_eq!(report.order_status, OrderStatus::PartiallyFilled); assert_eq!(report.filled_qty, Quantity::from(50_000)); assert_eq!(report.ts_accepted, UnixNanos::from(3_000)); assert_eq!(report.ts_last, UnixNanos::from(4_000)); assert_eq!(report.ts_init, UnixNanos::from(1_000)); } #[rstest] fn test_to_order_status_report_ts_accepted_falls_back_to_ts_last() { // Venue ack can arrive through an update before any acceptance event let mut order = OrderTestBuilder::new(OrderType::Limit) .instrument_id(InstrumentId::from("AUDUSD.SIM")) .side(OrderSide::Buy) .quantity(Quantity::from(100_000)) .price(Price::from("1.00000")) .build(); let submitted = OrderSubmittedSpec::builder() .ts_event(UnixNanos::from(2_000)) .build(); let updated = OrderUpdatedSpec::builder() .venue_order_id(VenueOrderId::from("V-001")) .ts_event(UnixNanos::from(3_000)) .build(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); order.apply(OrderEventAny::Updated(updated)).unwrap(); let report = order.to_order_status_report(None).unwrap(); assert_eq!(report.order_status, OrderStatus::Submitted); assert_eq!(report.venue_order_id, VenueOrderId::from("V-001")); assert_eq!(report.ts_accepted, UnixNanos::from(3_000)); assert_eq!(report.ts_last, UnixNanos::from(3_000)); } #[rstest] fn test_to_order_status_report_maps_trailing_offsets() { let order = OrderTestBuilder::new(OrderType::TrailingStopLimit) .instrument_id(InstrumentId::from("AUDUSD.SIM")) .side(OrderSide::Sell) .quantity(Quantity::from(100_000)) .price(Price::from("0.99500")) .activation_price(Price::from("1.00500")) .trigger_price(Price::from("1.00000")) .trigger_type(TriggerType::LastPrice) .limit_offset(dec!(0.0001)) .trailing_offset(dec!(0.0002)) .trailing_offset_type(TrailingOffsetType::Price) .build(); let accepted = TestOrderStubs::make_accepted_order(&order); let report = accepted.to_order_status_report(None).unwrap(); assert_eq!(report.activation_price, Some(Price::from("1.00500"))); assert_eq!(report.limit_offset, Some(dec!(0.0001))); assert_eq!(report.trailing_offset, Some(dec!(0.0002))); assert_eq!(report.trailing_offset_type, Some(TrailingOffsetType::Price),); } #[rstest] fn test_to_order_status_report_returns_none_before_venue_ack() { let order = OrderTestBuilder::new(OrderType::Limit) .instrument_id(InstrumentId::from("AUDUSD.SIM")) .side(OrderSide::Buy) .quantity(Quantity::from(100_000)) .price(Price::from("1.00000")) .build(); assert!(order.to_order_status_report(None).is_none()); } #[rstest] fn test_to_order_status_report_returns_none_for_submitted_order() { // Inflight orders have an account_id but no venue_order_id yet let mut order = OrderTestBuilder::new(OrderType::Limit) .instrument_id(InstrumentId::from("AUDUSD.SIM")) .side(OrderSide::Buy) .quantity(Quantity::from(100_000)) .price(Price::from("1.00000")) .build(); let submitted = OrderSubmittedSpec::builder().build(); order.apply(OrderEventAny::Submitted(submitted)).unwrap(); assert!(order.account_id().is_some()); assert!(order.to_order_status_report(None).is_none()); } }