Skip to main content

brokapi_bybit/map/
order.rs

1//! Orders: the state words of the exchange and the snapshot of an order.
2
3use std::time::Instant;
4
5use brokapi_bybit_protocol::http::models::OrderRecord;
6use brokapi_core::{
7    BrokerOrderId, ClientOrderId, Decimal, OrderAck, OrderSnapshot, OrderState, OrderType, Price,
8    Qty,
9};
10
11use super::{broker_ts, side_of, timestamps};
12use crate::util::instrument_id;
13
14/// The state of an order by the status of the exchange.
15///
16/// `New` and the transient `Triggered` are working orders. A conditional order that has not fired (`Untriggered`)
17/// is working too, as far as this function goes: the connectors keep it in the list of stop orders (see
18/// [`is_untriggered`]). `Cancelled` and `Deactivated` are final even when part of the order was filled before.
19/// A word the connector does not know is a working order, so the bot does not place a second one in its stead.
20pub fn state_of(
21    status: &str,
22    cid: ClientOrderId,
23    broker_id: &str,
24    qty: Qty,
25    filled: Qty,
26    reject_reason: &str,
27) -> OrderState {
28    match status {
29        "Filled" => OrderState::Filled { cid },
30        "Cancelled" | "PartiallyFilledCanceled" | "Deactivated" => OrderState::Cancelled { cid },
31        "Rejected" => OrderState::Rejected {
32            cid,
33            reason: if reject_reason.is_empty() || reject_reason == "EC_NoError" {
34                "rejected by the exchange".to_string()
35            } else {
36                reject_reason.to_string()
37            },
38        },
39        _ if filled.is_positive() => OrderState::PartiallyFilled {
40            cid,
41            filled,
42            left: Qty::new((qty.decimal() - filled.decimal()).max(Decimal::ZERO)),
43        },
44        _ => OrderState::Acked {
45            cid,
46            broker_id: BrokerOrderId::new(broker_id),
47        },
48    }
49}
50
51/// Whether the order is a conditional order that has not fired.
52pub fn is_untriggered(r: &OrderRecord) -> bool {
53    r.order_status == "Untriggered"
54}
55
56/// Whether the order is closed for good.
57pub fn is_final(r: &OrderRecord) -> bool {
58    matches!(
59        r.order_status.as_str(),
60        "Filled" | "Cancelled" | "PartiallyFilledCanceled" | "Deactivated" | "Rejected"
61    )
62}
63
64/// The snapshot of an order. `category` names the instrument when the record does not.
65pub fn order_snapshot(
66    r: &OrderRecord,
67    cid: ClientOrderId,
68    category: &str,
69    now: Instant,
70) -> Option<OrderSnapshot> {
71    let qty = Qty::new(r.qty);
72    let filled = Qty::new(r.cum_exec_qty);
73    let side = side_of(&r.side)?;
74    let order_type = if r.order_type == "Market" {
75        OrderType::Market
76    } else {
77        OrderType::Limit
78    };
79    let category = if r.category.is_empty() {
80        category
81    } else {
82        &r.category
83    };
84    let broker_ts = broker_ts(r.updated_time).or_else(|| broker_ts(r.created_time));
85    Some(
86        OrderSnapshot::new(
87            cid,
88            instrument_id(&r.symbol, category),
89            state_of(
90                &r.order_status,
91                cid,
92                &r.order_id,
93                qty,
94                filled,
95                &r.reject_reason,
96            ),
97            side,
98            order_type,
99            qty,
100            timestamps(now, broker_ts),
101        )
102        .with_broker_id(Some(BrokerOrderId::new(r.order_id.clone())))
103        .with_filled(filled)
104        .with_price(r.price.filter(|p| !p.is_zero()).map(Price::new)),
105    )
106}
107
108/// The ack of a command.
109pub fn order_ack(cid: ClientOrderId, order_id: &str, now: Instant) -> OrderAck {
110    OrderAck {
111        cid,
112        broker_id: BrokerOrderId::new(order_id),
113        ts: timestamps(now, None),
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use uuid::Uuid;
121
122    fn rec(json: &str) -> OrderRecord {
123        serde_json::from_str(json).unwrap()
124    }
125
126    #[test]
127    fn working_orders_are_acked_or_partly_filled() {
128        let cid = Uuid::new_v4();
129        let r = rec(
130            r#"{"orderId":"77","symbol":"BTCUSDT","orderType":"Limit","side":"Buy","orderStatus":"New","qty":"0.20","cumExecQty":"0.00","price":"100.5"}"#,
131        );
132        let s = order_snapshot(&r, cid, "linear", Instant::now()).unwrap();
133        assert!(matches!(s.state, OrderState::Acked { .. }));
134        assert_eq!(s.price.unwrap().to_string(), "100.5");
135        assert_eq!(s.broker_id.unwrap().as_str(), "77");
136        let r = rec(
137            r#"{"orderId":"77","symbol":"BTCUSDT","orderType":"Limit","side":"Buy","orderStatus":"PartiallyFilled","qty":"0.20","cumExecQty":"0.05"}"#,
138        );
139        let s = order_snapshot(&r, cid, "linear", Instant::now()).unwrap();
140        match s.state {
141            OrderState::PartiallyFilled { filled, left, .. } => {
142                assert_eq!(
143                    (filled.to_string(), left.to_string()),
144                    ("0.05".into(), "0.15".into())
145                );
146            }
147            other => panic!("{other:?}"),
148        }
149    }
150
151    #[test]
152    fn final_states_follow_the_status() {
153        let cid = Uuid::new_v4();
154        let st = |s: &str| state_of(s, cid, "1", Qty::from(1), Qty::ZERO, "");
155        assert!(matches!(st("Filled"), OrderState::Filled { .. }));
156        assert!(matches!(st("Cancelled"), OrderState::Cancelled { .. }));
157        assert!(matches!(st("Deactivated"), OrderState::Cancelled { .. }));
158        assert!(matches!(st("Rejected"), OrderState::Rejected { .. }));
159        // A word the connector does not know is a working order.
160        assert!(matches!(st("SomethingNew"), OrderState::Acked { .. }));
161        // A cancelled order that was filled in part is still cancelled, and keeps the filled quantity.
162        let r = rec(
163            r#"{"orderId":"1","symbol":"BTCUSDT","side":"Buy","orderStatus":"Cancelled","qty":"1","cumExecQty":"0.4"}"#,
164        );
165        let s = order_snapshot(&r, cid, "linear", Instant::now()).unwrap();
166        assert!(matches!(s.state, OrderState::Cancelled { .. }));
167        assert_eq!(s.filled.to_string(), "0.4");
168    }
169
170    #[test]
171    fn the_reject_reason_is_kept_unless_there_is_none() {
172        let cid = Uuid::new_v4();
173        let reason = |r: &str| match state_of("Rejected", cid, "1", Qty::from(1), Qty::ZERO, r) {
174            OrderState::Rejected { reason, .. } => reason,
175            other => panic!("{other:?}"),
176        };
177        assert_eq!(reason("EC_PerCancelRequest"), "EC_PerCancelRequest");
178        assert_eq!(reason("EC_NoError"), "rejected by the exchange");
179    }
180
181    #[test]
182    fn a_record_with_a_side_the_connector_cannot_read_is_skipped() {
183        let r =
184            rec(r#"{"orderId":"1","symbol":"BTCUSDT","side":"","orderStatus":"New","qty":"1"}"#);
185        assert!(order_snapshot(&r, Uuid::new_v4(), "linear", Instant::now()).is_none());
186    }
187}