Skip to main content

rustrade_execution/order/
mod.rs

1use crate::order::{
2    id::StrategyId,
3    request::{OrderRequestCancel, OrderRequestOpen, RequestCancel, RequestOpen},
4    state::UnindexedOrderState,
5};
6use chrono::{DateTime, Utc};
7use derive_more::{Constructor, Display};
8use id::ClientOrderId;
9use rust_decimal::Decimal;
10use rustrade_instrument::{
11    Side,
12    asset::{AssetIndex, name::AssetNameExchange},
13    exchange::{ExchangeId, ExchangeIndex},
14    instrument::{InstrumentIndex, name::InstrumentNameExchange},
15};
16use serde::{Deserialize, Serialize};
17use state::{
18    ActiveOrderState, Cancelled, Expired, InactiveOrderState, Open, OpenInFlight, OrderState,
19};
20
21/// `Order` related identifiers.
22pub mod id;
23
24/// `Order` states.
25///
26/// eg/ `OpenInFlight`, `Open`, `Rejected`, `Expired`, etc.
27pub mod state;
28
29/// Order open and cancel request types.
30///
31/// ie/ `OrderRequestOpen` & `OrderRequestCancel`.
32pub mod request;
33
34/// Bracket order types for the [`BracketOrderClient`](crate::client::BracketOrderClient) trait.
35pub mod bracket;
36
37/// Convenient type alias for an [`Order`] keyed with [`ExchangeId`] and [`InstrumentNameExchange`].
38pub type UnindexedOrder = Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>;
39
40/// Convenient type alias for an [`OrderKey`] keyed with [`ExchangeId`]
41/// and [`InstrumentNameExchange`].
42pub type UnindexedOrderKey = OrderKey<ExchangeId, InstrumentNameExchange>;
43
44/// Convenient type alias for an [`Order`] that has ended, keyed with [`ExchangeId`] and
45/// [`InstrumentNameExchange`].
46pub type UnindexedInactiveOrder =
47    Order<ExchangeId, InstrumentNameExchange, state::UnindexedInactiveOrderState>;
48
49/// Convenient type alias for an [`OrderSnapshot`] keyed with [`ExchangeId`], [`AssetNameExchange`],
50/// and [`InstrumentNameExchange`].
51pub type UnindexedOrderSnapshot = Order<
52    ExchangeId,
53    InstrumentNameExchange,
54    OrderState<AssetNameExchange, InstrumentNameExchange>,
55>;
56
57/// Convenient type alias for an [`Order`] [`OrderState`] snapshot.
58pub type OrderSnapshot<
59    ExchangeKey = ExchangeIndex,
60    AssetKey = AssetIndex,
61    InstrumentKey = InstrumentIndex,
62> = Order<ExchangeKey, InstrumentKey, OrderState<AssetKey, InstrumentKey>>;
63
64#[derive(
65    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor,
66)]
67
68pub struct OrderEvent<State, ExchangeKey = ExchangeIndex, InstrumentKey = InstrumentIndex> {
69    pub key: OrderKey<ExchangeKey, InstrumentKey>,
70    pub state: State,
71}
72
73#[derive(
74    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor,
75)]
76pub struct OrderKey<ExchangeKey = ExchangeIndex, InstrumentKey = InstrumentIndex> {
77    pub exchange: ExchangeKey,
78    pub instrument: InstrumentKey,
79    pub strategy: StrategyId,
80    pub cid: ClientOrderId,
81}
82
83impl<ExchangeKey, InstrumentKey> OrderKey<ExchangeKey, &InstrumentKey>
84where
85    InstrumentKey: Clone,
86{
87    /// Clones the borrowed instrument key, producing a key that outlives what it was read from.
88    ///
89    /// An index that maps an [`InstrumentIndex`] back to an exchange-native name hands out a
90    /// borrow of the name it owns. Anything that stores the key, sends it to another task, or
91    /// hands it to a venue needs it owned, and every such site would otherwise write out this
92    /// same four-field rebuild.
93    pub fn into_owned_instrument(self) -> OrderKey<ExchangeKey, InstrumentKey> {
94        let Self {
95            exchange,
96            instrument,
97            strategy,
98            cid,
99        } = self;
100
101        OrderKey {
102            exchange,
103            instrument: instrument.clone(),
104            strategy,
105            cid,
106        }
107    }
108}
109
110impl<State, ExchangeKey, InstrumentKey> OrderEvent<State, ExchangeKey, &InstrumentKey>
111where
112    InstrumentKey: Clone,
113{
114    /// Clones the borrowed instrument key, producing an event that outlives what it was read from.
115    ///
116    /// See [`OrderKey::into_owned_instrument`]; the state travels untouched.
117    pub fn into_owned_instrument(self) -> OrderEvent<State, ExchangeKey, InstrumentKey> {
118        let Self { key, state } = self;
119
120        OrderEvent {
121            key: key.into_owned_instrument(),
122            state,
123        }
124    }
125}
126
127#[derive(
128    Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Constructor,
129)]
130pub struct Order<ExchangeKey = ExchangeIndex, InstrumentKey = InstrumentIndex, State = OrderState> {
131    pub key: OrderKey<ExchangeKey, InstrumentKey>,
132    pub side: Side,
133    /// Limit price for the order. `None` for Market/Stop/TrailingStop orders, and for a
134    /// TrailingStopLimit, whose limit follows its stop; the fixed limit of a
135    /// TrailingStopFixedLimit.
136    pub price: Option<Decimal>,
137    pub quantity: Decimal,
138    pub kind: OrderKind,
139    pub time_in_force: TimeInForce,
140    pub state: State,
141}
142
143impl<ExchangeKey, InstrumentKey, State> Order<ExchangeKey, InstrumentKey, State> {
144    /// This order with its state replaced by `f` of it, every other field unchanged.
145    pub fn map_state<NewState>(
146        self,
147        f: impl FnOnce(State) -> NewState,
148    ) -> Order<ExchangeKey, InstrumentKey, NewState> {
149        let Order {
150            key,
151            side,
152            price,
153            quantity,
154            kind,
155            time_in_force,
156            state,
157        } = self;
158
159        Order {
160            key,
161            side,
162            price,
163            quantity,
164            kind,
165            time_in_force,
166            state: f(state),
167        }
168    }
169}
170
171impl<ExchangeKey, AssetKey, InstrumentKey>
172    Order<ExchangeKey, InstrumentKey, OrderState<AssetKey, InstrumentKey>>
173{
174    pub fn to_active(&self) -> Option<Order<ExchangeKey, InstrumentKey, ActiveOrderState>>
175    where
176        ExchangeKey: Clone,
177        InstrumentKey: Clone,
178    {
179        let OrderState::Active(state) = &self.state else {
180            return None;
181        };
182
183        Some(Order {
184            key: self.key.clone(),
185            side: self.side,
186            price: self.price,
187            quantity: self.quantity,
188            kind: self.kind,
189            time_in_force: self.time_in_force,
190            state: state.clone(),
191        })
192    }
193
194    pub fn to_inactive(
195        &self,
196    ) -> Option<Order<ExchangeKey, InstrumentKey, InactiveOrderState<AssetKey, InstrumentKey>>>
197    where
198        ExchangeKey: Clone,
199        AssetKey: Clone,
200        InstrumentKey: Clone,
201    {
202        let OrderState::Inactive(state) = &self.state else {
203            return None;
204        };
205
206        Some(Order {
207            key: self.key.clone(),
208            side: self.side,
209            price: self.price,
210            quantity: self.quantity,
211            kind: self.kind,
212            time_in_force: self.time_in_force,
213            state: state.clone(),
214        })
215    }
216}
217
218impl<ExchangeKey, InstrumentKey> Order<ExchangeKey, InstrumentKey, ActiveOrderState>
219where
220    ExchangeKey: Clone,
221    InstrumentKey: Clone,
222{
223    pub fn to_request_cancel(&self) -> Option<OrderRequestCancel<ExchangeKey, InstrumentKey>> {
224        let Order { key, state, .. } = self;
225
226        let request_cancel = match state {
227            ActiveOrderState::OpenInFlight(_) => RequestCancel { id: None },
228            ActiveOrderState::Open(open) => RequestCancel {
229                id: Some(open.id.clone()),
230            },
231            _ => return None,
232        };
233
234        Some(OrderRequestCancel {
235            key: key.clone(),
236            state: request_cancel,
237        })
238    }
239}
240
241/// Specifies how the trailing offset is measured for trailing stop orders.
242///
243/// Different exchanges support different offset types:
244/// - IBKR: Absolute (dollar amount) and Percentage
245/// - Binance: BasisPoints (1/100th of 1%)
246/// - Alpaca/Coinbase: Do not support trailing stops
247#[derive(
248    Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Display,
249)]
250pub enum TrailingOffsetType {
251    /// Absolute dollar/currency amount (e.g., $2.00 trailing distance).
252    Absolute,
253    /// Percentage of the current price (e.g., 5% trailing distance).
254    Percentage,
255    /// Basis points (1/100th of 1%). What Binance's `trailingDelta` takes.
256    BasisPoints,
257}
258
259#[derive(
260    Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Display,
261)]
262#[non_exhaustive]
263pub enum OrderKind {
264    Market,
265    Limit,
266    /// Stop (market) order - triggers a market order when trigger_price is reached.
267    #[display("Stop({trigger_price})")]
268    Stop {
269        trigger_price: Decimal,
270    },
271    /// Stop-limit order - triggers a limit order at Order.price when trigger_price is reached.
272    #[display("StopLimit({trigger_price})")]
273    StopLimit {
274        trigger_price: Decimal,
275    },
276    /// Take-profit (market) order - triggers a market order when trigger_price is reached.
277    /// Opposite of Stop: triggers when price moves favorably (above for longs, below for shorts).
278    #[display("TakeProfit({trigger_price})")]
279    TakeProfit {
280        trigger_price: Decimal,
281    },
282    /// Take-profit limit order - triggers a limit order at Order.price when trigger_price is reached.
283    #[display("TakeProfitLimit({trigger_price})")]
284    TakeProfitLimit {
285        trigger_price: Decimal,
286    },
287    /// Trailing stop order - stop price trails the market by a specified offset.
288    #[display("TrailingStop({offset}, {offset_type}, activation: {activation_price:?})")]
289    TrailingStop {
290        offset: Decimal,
291        offset_type: TrailingOffsetType,
292        /// The price the market must reach before the stop starts trailing, or `None` to trail
293        /// from the next trade.
294        ///
295        /// Which way the market must reach it is the venue's. On Binance spot and margin, whose
296        /// trailing stop is a `STOP_LOSS` with a `stopPrice`, a sell starts trailing once the
297        /// market is at or below it and a buy once at or above it. A client whose venue has no
298        /// activation price rejects `Some` with
299        /// [`OrderError::UnsupportedOrderType`](crate::error::OrderError::UnsupportedOrderType),
300        /// and one that sends it refuses a price that is not positive.
301        ///
302        /// Read back from a venue, it is the price the venue reports in the same field, which
303        /// Binance does not document as fixed once the stop activates.
304        activation_price: Option<Decimal>,
305    },
306    /// Trailing stop-limit order - when triggered, submits a limit order offset from the stop.
307    ///
308    /// The limit price follows from the stop and `limit_offset`, so the order takes no `price`.
309    #[display("TrailingStopLimit({offset}, {offset_type}, {stop_price}, {limit_offset})")]
310    TrailingStopLimit {
311        offset: Decimal,
312        offset_type: TrailingOffsetType,
313        /// The stop price at submission; it then trails the market by `offset`. Must be positive.
314        ///
315        /// The venue may trail it straight away. On IBKR paper, a stop within `offset` of the
316        /// market was kept as placed, and one further away moved to `offset` from the market
317        /// within seconds of IB accepting the order, for buys and sells, absolute and percentage
318        /// trails. So an order read back from a venue listing may report the stop after it has
319        /// trailed, not the one it was placed with.
320        stop_price: Decimal,
321        /// Offset from the triggered stop price to set the limit price.
322        limit_offset: Decimal,
323    },
324    /// Trailing stop whose limit price is fixed when it is placed: only the trigger trails, and
325    /// once it triggers the order works at [`OrderRequestOpen`]'s `price`, as a
326    /// [`StopLimit`](Self::StopLimit) does. Binance spot and margin's `STOP_LOSS_LIMIT` with a
327    /// `trailingDelta`.
328    ///
329    /// Unlike [`TrailingStopLimit`](Self::TrailingStopLimit), whose limit follows the triggered
330    /// stop. A client whose venue has no such order rejects it with
331    /// [`OrderError::UnsupportedOrderType`](crate::error::OrderError::UnsupportedOrderType).
332    #[display("TrailingStopFixedLimit({offset}, {offset_type}, activation: {activation_price:?})")]
333    TrailingStopFixedLimit {
334        offset: Decimal,
335        offset_type: TrailingOffsetType,
336        /// As [`TrailingStop`](Self::TrailingStop)'s `activation_price`.
337        activation_price: Option<Decimal>,
338    },
339    /// A conditional order a venue reported without saying whether its trigger is fixed or
340    /// trails: one of [`family`](ConditionalFamily)'s fixed-trigger kind or its trailing
341    /// counterpart, which the report cannot tell apart.
342    ///
343    /// Only read back from a venue, never placed: a client refuses one with
344    /// [`OrderError::UnsupportedOrderType`](crate::error::OrderError::UnsupportedOrderType). A
345    /// client reports it only for a live or ended order whose kind it does not know otherwise.
346    /// When it knows the kind, from placing the order, from an earlier report that described it,
347    /// or from the caller, it reports that kind instead. The order's client order id, venue id,
348    /// side, quantity and any limit price are on the [`Order`] as usual, so a caller can match it
349    /// to its own records.
350    ///
351    /// Binance margin is the venue that needs it: its REST order queries never report a trailing
352    /// delta, and its isolated-margin account stream reports none even for a trailing order.
353    #[display("ConditionalUnresolved({family}, trigger: {trigger_price:?})")]
354    ConditionalUnresolved {
355        family: ConditionalFamily,
356        /// The price the venue reports for the order's trigger: the trigger of a fixed-trigger
357        /// order, or the activation price of a trailing one. `None` when the venue reports none,
358        /// as for a trailing order placed without an activation price.
359        trigger_price: Option<Decimal>,
360    },
361}
362
363impl OrderKind {
364    /// Whether this is an [`OrderKind::ConditionalUnresolved`], which leaves open whether the
365    /// order trails.
366    #[cfg(any(
367        feature = "alpaca",
368        feature = "binance",
369        feature = "hyperliquid",
370        feature = "ibkr"
371    ))]
372    pub(crate) fn is_conditional_unresolved(&self) -> bool {
373        matches!(self, Self::ConditionalUnresolved { .. })
374    }
375}
376
377/// Which conditional order an [`OrderKind::ConditionalUnresolved`] is, before whether its trigger
378/// trails is known.
379///
380/// `#[non_exhaustive]`, so a family another venue needs can be added without breaking a caller.
381#[derive(
382    Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Display,
383)]
384#[non_exhaustive]
385pub enum ConditionalFamily {
386    /// [`OrderKind::Stop`] or [`OrderKind::TrailingStop`].
387    Stop,
388    /// [`OrderKind::StopLimit`] or a trailing stop with a limit, such as
389    /// [`OrderKind::TrailingStopFixedLimit`].
390    StopLimit,
391    /// [`OrderKind::TakeProfit`] or a trailing take-profit, which no other [`OrderKind`]
392    /// describes.
393    TakeProfit,
394    /// [`OrderKind::TakeProfitLimit`] or a trailing take-profit with a limit, which no other
395    /// [`OrderKind`] describes.
396    TakeProfitLimit,
397}
398
399impl ConditionalFamily {
400    /// Whether orders of this family carry a limit price.
401    pub fn has_limit_price(self) -> bool {
402        matches!(self, Self::StopLimit | Self::TakeProfitLimit)
403    }
404}
405
406#[derive(
407    Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Display,
408)]
409pub enum TimeInForce {
410    GoodUntilCancelled {
411        post_only: bool,
412    },
413    GoodUntilEndOfDay,
414    FillOrKill,
415    ImmediateOrCancel,
416    /// Good until a specific date/time. Order expires if not filled by the specified time.
417    #[display("GoodTillDate({expiry})")]
418    GoodTillDate {
419        expiry: DateTime<Utc>,
420    },
421    /// Execute at market open (OPG). Valid for various order types.
422    AtOpen,
423    /// Execute at market close. Only valid with Market (→ MOC) or Limit (→ LOC) orders.
424    AtClose,
425}
426
427impl<ExchangeKey, InstrumentKey> Order<ExchangeKey, InstrumentKey, ActiveOrderState>
428where
429    ExchangeKey: Clone,
430    InstrumentKey: Clone,
431{
432    /// The order an open request describes, [`OpenInFlight`] since `time_sent`.
433    pub fn open_in_flight(
434        request: &OrderRequestOpen<ExchangeKey, InstrumentKey>,
435        time_sent: DateTime<Utc>,
436    ) -> Self {
437        let OrderRequestOpen {
438            key,
439            state:
440                RequestOpen {
441                    side,
442                    price,
443                    quantity,
444                    kind,
445                    time_in_force,
446                    position_id: _,
447                    reduce_only: _, // used by adapters (e.g., Alpaca) to derive position_intent
448                    market: _,      // decision-time provenance; not part of the resulting Order
449                    margin_effect: _, // an instruction to the venue; not part of the resulting Order
450                },
451        } = request;
452
453        Self {
454            key: key.clone(),
455            side: *side,
456            price: *price,
457            quantity: *quantity,
458            kind: *kind,
459            time_in_force: *time_in_force,
460            state: ActiveOrderState::OpenInFlight(OpenInFlight::new(time_sent)),
461        }
462    }
463}
464
465impl<ExchangeKey, InstrumentKey> From<Order<ExchangeKey, InstrumentKey, Open>>
466    for Order<ExchangeKey, InstrumentKey, ActiveOrderState>
467{
468    fn from(value: Order<ExchangeKey, InstrumentKey, Open>) -> Self {
469        value.map_state(ActiveOrderState::Open)
470    }
471}
472
473impl<ExchangeKey, AssetKey, InstrumentKey> From<Order<ExchangeKey, InstrumentKey, Open>>
474    for Order<ExchangeKey, InstrumentKey, OrderState<AssetKey, InstrumentKey>>
475{
476    fn from(value: Order<ExchangeKey, InstrumentKey, Open>) -> Self {
477        value.map_state(|state| OrderState::Active(ActiveOrderState::Open(state)))
478    }
479}
480
481impl<ExchangeKey, AssetKey, InstrumentKey> From<Order<ExchangeKey, InstrumentKey, Cancelled>>
482    for Order<ExchangeKey, InstrumentKey, OrderState<AssetKey, InstrumentKey>>
483{
484    fn from(value: Order<ExchangeKey, InstrumentKey, Cancelled>) -> Self {
485        value.map_state(|state| OrderState::Inactive(InactiveOrderState::Cancelled(state)))
486    }
487}
488
489impl<ExchangeKey, AssetKey, InstrumentKey> From<Order<ExchangeKey, InstrumentKey, Expired>>
490    for Order<ExchangeKey, InstrumentKey, OrderState<AssetKey, InstrumentKey>>
491{
492    fn from(value: Order<ExchangeKey, InstrumentKey, Expired>) -> Self {
493        value.map_state(|state| OrderState::Inactive(InactiveOrderState::Expired(state)))
494    }
495}
496
497#[cfg(test)]
498#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
499mod tests {
500    use super::*;
501    use rust_decimal_macros::dec;
502
503    #[test]
504    fn order_kind_display_take_profit() {
505        let tp = OrderKind::TakeProfit {
506            trigger_price: dec!(100.50),
507        };
508        assert_eq!(tp.to_string(), "TakeProfit(100.50)");
509    }
510
511    #[test]
512    fn order_kind_display_take_profit_limit() {
513        let tpl = OrderKind::TakeProfitLimit {
514            trigger_price: dec!(200.25),
515        };
516        assert_eq!(tpl.to_string(), "TakeProfitLimit(200.25)");
517    }
518
519    #[test]
520    fn order_kind_serde_roundtrip_take_profit() {
521        let original = OrderKind::TakeProfit {
522            trigger_price: dec!(150.00),
523        };
524        let json = serde_json::to_string(&original).unwrap();
525        let deserialized: OrderKind = serde_json::from_str(&json).unwrap();
526        assert_eq!(original, deserialized);
527    }
528
529    #[test]
530    fn order_kind_serde_roundtrip_take_profit_limit() {
531        let original = OrderKind::TakeProfitLimit {
532            trigger_price: dec!(250.75),
533        };
534        let json = serde_json::to_string(&original).unwrap();
535        let deserialized: OrderKind = serde_json::from_str(&json).unwrap();
536        assert_eq!(original, deserialized);
537    }
538    /// The trailing kinds round-trip with and without an activation price, and a `TrailingStop`
539    /// stored before it had one still loads, without one. Display shows it.
540    #[test]
541    fn trailing_kinds_round_trip_and_a_stored_trailing_stop_loads() {
542        for activation_price in [None, Some(dec!(105))] {
543            for original in [
544                OrderKind::TrailingStop {
545                    offset: dec!(1),
546                    offset_type: TrailingOffsetType::Percentage,
547                    activation_price,
548                },
549                OrderKind::TrailingStopFixedLimit {
550                    offset: dec!(25),
551                    offset_type: TrailingOffsetType::BasisPoints,
552                    activation_price,
553                },
554            ] {
555                let json = serde_json::to_string(&original).unwrap();
556                let deserialized: OrderKind = serde_json::from_str(&json).unwrap();
557                assert_eq!(original, deserialized);
558            }
559        }
560        let stored: OrderKind =
561            serde_json::from_str(r#"{"TrailingStop":{"offset":"1","offset_type":"Percentage"}}"#)
562                .unwrap();
563        assert_eq!(
564            stored,
565            OrderKind::TrailingStop {
566                offset: dec!(1),
567                offset_type: TrailingOffsetType::Percentage,
568                activation_price: None,
569            }
570        );
571        assert_eq!(
572            OrderKind::TrailingStop {
573                offset: dec!(1),
574                offset_type: TrailingOffsetType::Percentage,
575                activation_price: Some(dec!(105)),
576            }
577            .to_string(),
578            "TrailingStop(1, Percentage, activation: Some(105))"
579        );
580    }
581}