architect_api/orderflow/
order.rs

1use super::order_types::*;
2use crate::{
3    symbology::{ExecutionVenue, TradableProduct},
4    AccountId, Dir, OrderId, UserId,
5};
6use chrono::{DateTime, Utc};
7use derive_more::{Display, FromStr};
8use rust_decimal::Decimal;
9use schemars::{JsonSchema, JsonSchema_repr};
10use serde::{Deserialize, Serialize};
11use serde_repr::{Deserialize_repr, Serialize_repr};
12use serde_with::skip_serializing_none;
13use strum::{FromRepr, IntoStaticStr};
14use uuid::Uuid;
15
16#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
17/// <!-- py: unflatten=k/order_type/OrderType, tag=k -->
18pub struct Order {
19    pub id: OrderId,
20    #[serde(rename = "pid")]
21    #[schemars(title = "parent_id")]
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub parent_id: Option<OrderId>,
24    #[serde(rename = "eid")]
25    #[schemars(title = "exchange_order_id")]
26    pub exchange_order_id: Option<String>,
27    #[serde(rename = "ts")]
28    #[schemars(title = "recv_time")]
29    pub recv_time: i64,
30    #[serde(rename = "tn")]
31    #[schemars(title = "recv_time_ns")]
32    pub recv_time_ns: u32,
33    #[serde(rename = "o")]
34    #[schemars(title = "status")]
35    pub status: OrderStatus,
36    #[serde(rename = "r", skip_serializing_if = "Option::is_none")]
37    #[schemars(title = "reject_reason")]
38    pub reject_reason: Option<OrderRejectReason>,
39    #[serde(rename = "rm", skip_serializing_if = "Option::is_none")]
40    #[schemars(title = "reject_message")]
41    pub reject_message: Option<String>,
42    #[serde(rename = "s")]
43    #[schemars(title = "symbol")]
44    pub symbol: TradableProduct,
45    #[serde(rename = "u")]
46    #[schemars(title = "trader")]
47    pub trader: UserId,
48    #[serde(rename = "a")]
49    #[schemars(title = "account")]
50    pub account: AccountId,
51    #[serde(rename = "d")]
52    #[schemars(title = "dir")]
53    pub dir: Dir,
54    #[serde(rename = "q")]
55    #[schemars(title = "quantity")]
56    pub quantity: Decimal,
57    #[serde(rename = "xq")]
58    #[schemars(title = "filled_quantity")]
59    pub filled_quantity: Decimal,
60    #[serde(rename = "xp")]
61    #[schemars(title = "average_fill_price")]
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub average_fill_price: Option<Decimal>,
64    #[serde(flatten)]
65    pub order_type: OrderType,
66    #[serde(rename = "tif")]
67    #[schemars(title = "time_in_force")]
68    pub time_in_force: TimeInForce,
69    #[serde(rename = "src")]
70    #[schemars(title = "source")]
71    pub source: OrderSource,
72    #[serde(rename = "ve")]
73    #[schemars(title = "execution_venue")]
74    pub execution_venue: ExecutionVenue,
75    #[serde(rename = "ss", skip_serializing_if = "Option::is_none")]
76    #[schemars(title = "is_short_sale")]
77    pub is_short_sale: Option<bool>,
78}
79
80impl Order {
81    pub fn recv_time(&self) -> Option<DateTime<Utc>> {
82        DateTime::from_timestamp(self.recv_time, self.recv_time_ns)
83    }
84}
85
86#[skip_serializing_none]
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct OrderUpdate {
89    pub id: OrderId,
90    #[serde(rename = "ts")]
91    pub timestamp: i64,
92    #[serde(rename = "tn")]
93    pub timestamp_ns: u32,
94    #[serde(rename = "o")]
95    pub status: Option<OrderStatus>,
96    #[serde(rename = "r")]
97    pub reject_reason: Option<OrderRejectReason>,
98    #[serde(rename = "rm")]
99    pub reject_message: Option<String>,
100    #[serde(rename = "xq")]
101    pub filled_quantity: Option<Decimal>,
102    #[serde(rename = "xp")]
103    pub average_fill_price: Option<Decimal>,
104}
105
106impl OrderUpdate {
107    pub fn timestamp(&self) -> Option<DateTime<Utc>> {
108        DateTime::from_timestamp(self.timestamp, self.timestamp_ns)
109    }
110}
111
112#[derive(
113    Debug, Clone, Copy, IntoStaticStr, Serialize, Deserialize, PartialEq, Eq, JsonSchema,
114)]
115pub enum TimeInForce {
116    #[serde(rename = "GTC")]
117    #[strum(serialize = "GTC")]
118    #[schemars(title = "GoodTilCancel")]
119    GoodTilCancel,
120    #[serde(rename = "GTD")]
121    #[strum(serialize = "GTD")]
122    #[schemars(title = "GoodTilDate")]
123    GoodTilDate(DateTime<Utc>),
124    /// Day order--the specific time which this expires
125    /// will be dependent on the venue
126    #[serde(rename = "DAY")]
127    #[strum(serialize = "DAY")]
128    #[schemars(title = "GoodTilDay")]
129    GoodTilDay,
130    #[serde(rename = "IOC")]
131    #[strum(serialize = "IOC")]
132    #[schemars(title = "ImmediateOrCancel")]
133    ImmediateOrCancel,
134    #[serde(rename = "FOK")]
135    #[strum(serialize = "FOK")]
136    #[schemars(title = "FillOrKill")]
137    FillOrKill,
138    #[serde(rename = "ATO")]
139    #[strum(serialize = "ATO")]
140    #[schemars(title = "AtTheOpen")]
141    AtTheOpen,
142    #[serde(rename = "ATC")]
143    #[strum(serialize = "ATC")]
144    #[schemars(title = "AtTheClose")]
145    AtTheClose,
146}
147
148impl TimeInForce {
149    pub fn good_til_date(&self) -> Option<DateTime<Utc>> {
150        match self {
151            Self::GoodTilDate(dt) => Some(*dt),
152            _ => None,
153        }
154    }
155}
156
157#[derive(
158    Debug,
159    Display,
160    FromStr,
161    Clone,
162    Copy,
163    Serialize_repr,
164    Deserialize_repr,
165    PartialEq,
166    Eq,
167    JsonSchema_repr,
168)]
169#[cfg_attr(feature = "juniper", derive(juniper::GraphQLEnum))]
170#[repr(u8)]
171pub enum OrderSource {
172    API = 0,
173    GUI = 1,
174    Algo = 2,
175    Reconciled = 3,
176    CLI = 4,
177    Telegram = 5,
178    #[serde(other)]
179    Other = 255,
180}
181
182#[cfg(feature = "postgres")]
183crate::to_sql_display!(OrderSource);
184
185#[derive(
186    Debug,
187    Display,
188    FromStr,
189    FromRepr,
190    Clone,
191    Copy,
192    Serialize_repr,
193    Deserialize_repr,
194    PartialEq,
195    Eq,
196    JsonSchema_repr,
197)]
198#[cfg_attr(feature = "juniper", derive(juniper::GraphQLEnum))]
199#[repr(u8)]
200pub enum OrderStatus {
201    Pending = 0,
202    Open = 1,
203    Rejected = 2,
204    Out = 127,
205    Canceling = 128,
206    Canceled = 129,
207    ReconciledOut = 130,
208    Stale = 254,
209    Unknown = 255,
210}
211
212impl OrderStatus {
213    pub fn is_alive(&self) -> bool {
214        match self {
215            Self::Pending
216            | Self::Open
217            | Self::Canceling
218            | Self::Stale
219            | Self::Unknown => true,
220            Self::Out | Self::Canceled | Self::Rejected | Self::ReconciledOut => false,
221        }
222    }
223
224    pub fn is_dead(&self) -> bool {
225        !self.is_alive()
226    }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
230#[cfg_attr(feature = "juniper", derive(juniper::GraphQLObject))]
231pub struct OrderAck {
232    #[serde(rename = "id")]
233    #[schemars(title = "order_id")]
234    pub order_id: OrderId,
235    #[serde(rename = "eid")]
236    #[schemars(title = "exchange_order_id")]
237    pub exchange_order_id: Option<String>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
241pub struct OrderReject {
242    #[serde(rename = "id")]
243    pub order_id: OrderId,
244    #[serde(rename = "r")]
245    #[schemars(title = "reject_reason")]
246    pub reason: OrderRejectReason,
247    #[serde(rename = "rm", skip_serializing_if = "Option::is_none")]
248    #[schemars(title = "message")]
249    pub message: Option<String>,
250}
251
252impl OrderReject {
253    pub fn to_error_string(&self) -> String {
254        format!(
255            "order {} rejected: {} ({})",
256            self.order_id,
257            self.message.as_deref().unwrap_or("--"),
258            self.reason
259        )
260    }
261}
262
263#[derive(Debug, Display, FromStr, Clone, Copy, Serialize, Deserialize, JsonSchema)]
264#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
265#[cfg_attr(feature = "sqlx", sqlx(type_name = "TEXT"))]
266pub enum OrderRejectReason {
267    DuplicateOrderId,
268    NotAuthorized,
269    NoExecutionVenue,
270    NoAccount,
271    NoCpty,
272    UnsupportedOrderType,
273    UnsupportedExecutionVenue,
274    InsufficientCash,
275    InsufficientMargin,
276    NotEasyToBorrow,
277    #[serde(other)]
278    Unknown,
279}
280
281#[cfg(feature = "postgres")]
282crate::to_sql_display!(OrderRejectReason);
283
284#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
285#[cfg_attr(feature = "juniper", derive(juniper::GraphQLObject))]
286pub struct OrderCanceling {
287    #[serde(rename = "id")]
288    pub order_id: OrderId,
289    #[serde(rename = "xid", skip_serializing_if = "Option::is_none")]
290    pub cancel_id: Option<Uuid>,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
294#[cfg_attr(feature = "juniper", derive(juniper::GraphQLObject))]
295pub struct OrderCanceled {
296    #[serde(rename = "id")]
297    pub order_id: OrderId,
298    #[serde(rename = "xid", skip_serializing_if = "Option::is_none")]
299    pub cancel_id: Option<Uuid>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
303#[cfg_attr(feature = "graphql", derive(juniper::GraphQLObject))]
304pub struct OrderOut {
305    #[serde(rename = "id")]
306    pub order_id: OrderId,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
310#[cfg_attr(feature = "graphql", derive(juniper::GraphQLObject))]
311pub struct OrderStale {
312    #[serde(rename = "id")]
313    pub order_id: OrderId,
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::{oms::PlaceOrderRequest, AccountIdOrName, AccountName, TraderIdOrEmail};
320    use rust_decimal_macros::dec;
321
322    #[test]
323    fn test_place_order_request_json() {
324        #[rustfmt::skip]
325        let por: PlaceOrderRequest = serde_json::from_str(r#"
326            {
327                "id": "d3f97244-78e6-4549-abf6-90adfe0ab7fe:123",
328                "s": "BTC Crypto/USD",
329                "d": "BUY",
330                "q": "100",
331                "u": "trader1",
332                "a": "COINBASE:TEST",
333                "k": "LIMIT",
334                "p": "4500",
335                "po": true,
336                "tif": {
337                    "GTD": "2025-01-05T04:20:00Z"
338                } 
339            }
340        "#).unwrap();
341        let trader: UserId = "trader1".parse().unwrap();
342        assert_eq!(
343            por,
344            PlaceOrderRequest {
345                id: Some(OrderId {
346                    seqid: "d3f97244-78e6-4549-abf6-90adfe0ab7fe".parse().unwrap(),
347                    seqno: 123
348                }),
349                parent_id: None,
350                symbol: "BTC Crypto/USD".into(),
351                dir: Dir::Buy,
352                quantity: dec!(100),
353                trader: Some(TraderIdOrEmail::Id(trader)),
354                account: Some(AccountIdOrName::Name(
355                    AccountName::new("COINBASE", "TEST").unwrap()
356                )),
357                order_type: OrderType::Limit(LimitOrderType {
358                    limit_price: dec!(4500),
359                    post_only: true,
360                }),
361                time_in_force: TimeInForce::GoodTilDate(
362                    "2025-01-05T04:20:00Z".parse().unwrap()
363                ),
364                source: None,
365                execution_venue: None,
366            }
367        );
368    }
369
370    #[test]
371    fn test_order_json() {
372        let recv_time: DateTime<Utc> = "2025-01-01T04:20:00Z".parse().unwrap();
373        insta::assert_json_snapshot!(Order {
374            id: OrderId {
375                seqid: "d3f97244-78e6-4549-abf6-90adfe0ab7fe".parse().unwrap(),
376                seqno: 123
377            },
378            parent_id: None,
379            exchange_order_id: None,
380            recv_time: recv_time.timestamp(),
381            recv_time_ns: recv_time.timestamp_subsec_nanos(),
382            status: OrderStatus::Out,
383            reject_reason: Some(OrderRejectReason::DuplicateOrderId),
384            reject_message: None,
385            symbol: "BTC Crypto/USD".parse().unwrap(),
386            trader: UserId::anonymous(),
387            account: AccountId::nil(),
388            dir: Dir::Buy,
389            quantity: dec!(100),
390            filled_quantity: dec!(0),
391            average_fill_price: None,
392            order_type: OrderType::Limit(LimitOrderType {
393                limit_price: dec!(4500),
394                post_only: false,
395            }),
396            time_in_force: TimeInForce::GoodTilCancel,
397            source: OrderSource::API,
398            execution_venue: "BINANCE".into(),
399            is_short_sale: None,
400        }, @r###"
401        {
402          "id": "d3f97244-78e6-4549-abf6-90adfe0ab7fe:123",
403          "eid": null,
404          "ts": 1735705200,
405          "tn": 0,
406          "o": 127,
407          "r": "DuplicateOrderId",
408          "s": "BTC Crypto/USD",
409          "u": "00000000-0000-0000-0000-000000000000",
410          "a": "00000000-0000-0000-0000-000000000000",
411          "d": "BUY",
412          "q": "100",
413          "xq": "0",
414          "k": "LIMIT",
415          "p": "4500",
416          "po": false,
417          "tif": "GTC",
418          "src": 0,
419          "ve": "BINANCE"
420        }
421        "###);
422        let recv_time: DateTime<Utc> = "2025-01-01T04:20:00Z".parse().unwrap();
423        insta::assert_json_snapshot!(Order {
424            id: OrderId::nil(123),
425            parent_id: Some(OrderId {
426                seqid: "d3f97244-78e6-4549-abf6-90adfe0ab7fe".parse().unwrap(),
427                seqno: 456
428            }),
429            exchange_order_id: None,
430            recv_time: recv_time.timestamp(),
431            recv_time_ns: recv_time.timestamp_subsec_nanos(),
432            status: OrderStatus::Open,
433            reject_reason: None,
434            reject_message: None,
435            symbol: "ETH Crypto/USD".parse().unwrap(),
436            trader: UserId::anonymous(),
437            account: AccountId::nil(),
438            dir: Dir::Sell,
439            quantity: dec!(0.7050),
440            filled_quantity: dec!(0.7050),
441            average_fill_price: Some(dec!(4250)),
442            order_type: OrderType::StopLossLimit(StopLossLimitOrderType {
443                limit_price: dec!(4500),
444                trigger_price: dec!(4000),
445            }),
446            time_in_force: TimeInForce::GoodTilDate(
447                "2025-01-05T04:20:00Z".parse().unwrap()
448            ),
449            source: OrderSource::Telegram,
450            execution_venue: "BINANCE".into(),
451            is_short_sale: None,
452        }, @r###"
453        {
454          "id": "123",
455          "pid": "d3f97244-78e6-4549-abf6-90adfe0ab7fe:456",
456          "eid": null,
457          "ts": 1735705200,
458          "tn": 0,
459          "o": 1,
460          "s": "ETH Crypto/USD",
461          "u": "00000000-0000-0000-0000-000000000000",
462          "a": "00000000-0000-0000-0000-000000000000",
463          "d": "SELL",
464          "q": "0.7050",
465          "xq": "0.7050",
466          "xp": "4250",
467          "k": "STOP_LOSS_LIMIT",
468          "p": "4500",
469          "tp": "4000",
470          "tif": {
471            "GTD": "2025-01-05T04:20:00Z"
472          },
473          "src": 5,
474          "ve": "BINANCE"
475        }
476        "###);
477    }
478}