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_json::json;
12use serde_repr::{Deserialize_repr, Serialize_repr};
13use serde_with::skip_serializing_none;
14use strum::{FromRepr, IntoStaticStr};
15use uuid::Uuid;
16
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
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    pub reason: OrderRejectReason,
246    #[serde(rename = "rm", skip_serializing_if = "Option::is_none")]
247    pub message: Option<String>,
248}
249
250impl OrderReject {
251    pub fn to_error_string(&self) -> String {
252        format!(
253            "order {} rejected: {} ({})",
254            self.order_id,
255            self.message.as_deref().unwrap_or("--"),
256            self.reason
257        )
258    }
259}
260
261#[derive(Debug, Display, FromStr, Clone, Copy, Serialize, Deserialize, JsonSchema)]
262#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
263#[cfg_attr(feature = "sqlx", sqlx(type_name = "TEXT"))]
264pub enum OrderRejectReason {
265    DuplicateOrderId,
266    NotAuthorized,
267    NoExecutionVenue,
268    NoAccount,
269    NoCpty,
270    UnsupportedOrderType,
271    UnsupportedExecutionVenue,
272    InsufficientCash,
273    InsufficientMargin,
274    NotEasyToBorrow,
275    #[serde(other)]
276    Unknown,
277}
278
279#[cfg(feature = "postgres")]
280crate::to_sql_display!(OrderRejectReason);
281
282#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
283#[cfg_attr(feature = "juniper", derive(juniper::GraphQLObject))]
284pub struct OrderCanceling {
285    #[serde(rename = "id")]
286    pub order_id: OrderId,
287    #[serde(rename = "xid", skip_serializing_if = "Option::is_none")]
288    pub cancel_id: Option<Uuid>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
292#[cfg_attr(feature = "juniper", derive(juniper::GraphQLObject))]
293pub struct OrderCanceled {
294    #[serde(rename = "id")]
295    pub order_id: OrderId,
296    #[serde(rename = "xid", skip_serializing_if = "Option::is_none")]
297    pub cancel_id: Option<Uuid>,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
301#[cfg_attr(feature = "graphql", derive(juniper::GraphQLObject))]
302pub struct OrderOut {
303    #[serde(rename = "id")]
304    pub order_id: OrderId,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
308#[cfg_attr(feature = "graphql", derive(juniper::GraphQLObject))]
309pub struct OrderStale {
310    #[serde(rename = "id")]
311    pub order_id: OrderId,
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::{oms::PlaceOrderRequest, AccountIdOrName, AccountName, TraderIdOrEmail};
318    use rust_decimal_macros::dec;
319
320    #[test]
321    fn test_place_order_request_json() {
322        #[rustfmt::skip]
323        let por: PlaceOrderRequest = serde_json::from_str(r#"
324            {
325                "id": "d3f97244-78e6-4549-abf6-90adfe0ab7fe:123",
326                "s": "BTC Crypto/USD",
327                "d": "BUY",
328                "q": "100",
329                "u": "trader1",
330                "a": "COINBASE:TEST",
331                "k": "LIMIT",
332                "p": "4500",
333                "po": true,
334                "tif": {
335                    "GTD": "2025-01-05T04:20:00Z"
336                } 
337            }
338        "#).unwrap();
339        let trader: UserId = "trader1".parse().unwrap();
340        assert_eq!(
341            por,
342            PlaceOrderRequest {
343                id: Some(OrderId {
344                    seqid: "d3f97244-78e6-4549-abf6-90adfe0ab7fe".parse().unwrap(),
345                    seqno: 123
346                }),
347                parent_id: None,
348                symbol: "BTC Crypto/USD".into(),
349                dir: Dir::Buy,
350                quantity: dec!(100),
351                trader: Some(TraderIdOrEmail::Id(trader)),
352                account: Some(AccountIdOrName::Name(
353                    AccountName::new("COINBASE", "TEST").unwrap()
354                )),
355                order_type: OrderType::Limit(LimitOrderType {
356                    limit_price: dec!(4500),
357                    post_only: true,
358                }),
359                time_in_force: TimeInForce::GoodTilDate(
360                    "2025-01-05T04:20:00Z".parse().unwrap()
361                ),
362                source: None,
363                execution_venue: None,
364            }
365        );
366    }
367
368    #[test]
369    fn test_order_json() {
370        let recv_time: DateTime<Utc> = "2025-01-01T04:20:00Z".parse().unwrap();
371        insta::assert_json_snapshot!(Order {
372            id: OrderId {
373                seqid: "d3f97244-78e6-4549-abf6-90adfe0ab7fe".parse().unwrap(),
374                seqno: 123
375            },
376            parent_id: None,
377            exchange_order_id: None,
378            recv_time: recv_time.timestamp(),
379            recv_time_ns: recv_time.timestamp_subsec_nanos(),
380            status: OrderStatus::Out,
381            reject_reason: Some(OrderRejectReason::DuplicateOrderId),
382            reject_message: None,
383            symbol: "BTC Crypto/USD".parse().unwrap(),
384            trader: UserId::anonymous(),
385            account: AccountId::nil(),
386            dir: Dir::Buy,
387            quantity: dec!(100),
388            filled_quantity: dec!(0),
389            average_fill_price: None,
390            order_type: OrderType::Limit(LimitOrderType {
391                limit_price: dec!(4500),
392                post_only: false,
393            }),
394            time_in_force: TimeInForce::GoodTilCancel,
395            source: OrderSource::API,
396            execution_venue: "BINANCE".into(),
397            is_short_sale: None,
398        }, @r###"
399        {
400          "id": "d3f97244-78e6-4549-abf6-90adfe0ab7fe:123",
401          "eid": null,
402          "ts": 1735705200,
403          "tn": 0,
404          "o": 127,
405          "r": "DuplicateOrderId",
406          "s": "BTC Crypto/USD",
407          "u": "00000000-0000-0000-0000-000000000000",
408          "a": "00000000-0000-0000-0000-000000000000",
409          "d": "BUY",
410          "q": "100",
411          "xq": "0",
412          "k": "LIMIT",
413          "p": "4500",
414          "po": false,
415          "tif": "GTC",
416          "src": 0,
417          "ve": "BINANCE"
418        }
419        "###);
420        let recv_time: DateTime<Utc> = "2025-01-01T04:20:00Z".parse().unwrap();
421        insta::assert_json_snapshot!(Order {
422            id: OrderId::nil(123),
423            parent_id: Some(OrderId {
424                seqid: "d3f97244-78e6-4549-abf6-90adfe0ab7fe".parse().unwrap(),
425                seqno: 456
426            }),
427            exchange_order_id: None,
428            recv_time: recv_time.timestamp(),
429            recv_time_ns: recv_time.timestamp_subsec_nanos(),
430            status: OrderStatus::Open,
431            reject_reason: None,
432            reject_message: None,
433            symbol: "ETH Crypto/USD".parse().unwrap(),
434            trader: UserId::anonymous(),
435            account: AccountId::nil(),
436            dir: Dir::Sell,
437            quantity: dec!(0.7050),
438            filled_quantity: dec!(0.7050),
439            average_fill_price: Some(dec!(4250)),
440            order_type: OrderType::StopLossLimit(StopLossLimitOrderType {
441                limit_price: dec!(4500),
442                trigger_price: dec!(4000),
443            }),
444            time_in_force: TimeInForce::GoodTilDate(
445                "2025-01-05T04:20:00Z".parse().unwrap()
446            ),
447            source: OrderSource::Telegram,
448            execution_venue: "BINANCE".into(),
449            is_short_sale: None,
450        }, @r###"
451        {
452          "id": "123",
453          "pid": "d3f97244-78e6-4549-abf6-90adfe0ab7fe:456",
454          "eid": null,
455          "ts": 1735705200,
456          "tn": 0,
457          "o": 1,
458          "s": "ETH Crypto/USD",
459          "u": "00000000-0000-0000-0000-000000000000",
460          "a": "00000000-0000-0000-0000-000000000000",
461          "d": "SELL",
462          "q": "0.7050",
463          "xq": "0.7050",
464          "xp": "4250",
465          "k": "STOP_LOSS_LIMIT",
466          "p": "4500",
467          "tp": "4000",
468          "tif": {
469            "GTD": "2025-01-05T04:20:00Z"
470          },
471          "src": 5,
472          "ve": "BINANCE"
473        }
474        "###);
475    }
476}