Skip to main content

brokapi_alor/map/
order.rs

1//! Order mapping: ALOR orders to `brokapi-core` order states and snapshots.
2
3use std::time::Instant;
4
5use brokapi_alor_protocol::http::models::OrderRecord;
6use brokapi_core::{
7    BrokerOrderId, ClientOrderId, OrderAck, OrderSnapshot, OrderState, OrderType, Price, Qty,
8};
9
10use super::market::side_of;
11use super::{parse_time, timestamps};
12use crate::util::instrument_id;
13
14/// The state of an order from the `status` of the broker and the quantities.
15///
16/// | `status` | State |
17/// |---|---|
18/// | `working`, nothing filled | `Acked` |
19/// | `working`, partly filled | `PartiallyFilled` |
20/// | `filled` | `Filled` |
21/// | `canceled` | `Cancelled` |
22/// | `rejected` | `Rejected` |
23/// | anything else (new words of the broker) | `Acked`, a working order: the safe reading, the bot must not place a second one in its stead |
24pub fn state_of(
25    status: Option<&str>,
26    cid: ClientOrderId,
27    broker_id: &str,
28    qty: Qty,
29    filled: Qty,
30) -> OrderState {
31    match status.map(str::to_ascii_lowercase).as_deref() {
32        Some("filled") => OrderState::Filled { cid },
33        Some("canceled") | Some("cancelled") => OrderState::Cancelled { cid },
34        Some("rejected") => OrderState::Rejected {
35            cid,
36            reason: "rejected by the broker or the exchange".to_string(),
37        },
38        _ if filled.is_positive() => OrderState::PartiallyFilled {
39            cid,
40            filled,
41            left: Qty::new((qty.decimal() - filled.decimal()).max(brokapi_core::Decimal::ZERO)),
42        },
43        _ => OrderState::Acked {
44            cid,
45            broker_id: BrokerOrderId::new(broker_id),
46        },
47    }
48}
49
50/// A snapshot of an order of the portfolio. `None` if the record has no pieces quantity.
51pub fn order_snapshot(r: &OrderRecord, cid: ClientOrderId, now: Instant) -> Option<OrderSnapshot> {
52    let qty = Qty::new(r.qty_units?);
53    let filled = Qty::new(r.filled_qty_units.unwrap_or_default());
54    let side = side_of(r.side.as_deref())?;
55    let order_type = match r.kind.as_deref() {
56        Some("market") => OrderType::Market,
57        _ => OrderType::Limit,
58    };
59    let instrument = instrument_id(&r.symbol, r.board.as_deref().unwrap_or_default());
60    let broker_ts =
61        parse_time(r.update_time.as_deref()).or_else(|| parse_time(r.trans_time.as_deref()));
62    Some(
63        OrderSnapshot::new(
64            cid,
65            instrument,
66            state_of(r.status.as_deref(), cid, &r.id, qty, filled),
67            side,
68            order_type,
69            qty,
70            timestamps(now, broker_ts),
71        )
72        .with_broker_id(Some(BrokerOrderId::new(r.id.clone())))
73        .with_filled(filled)
74        .with_price(r.price.filter(|p| !p.is_zero()).map(Price::new)),
75    )
76}
77
78/// The ack of a command: the cid and the number of the order the broker made.
79pub fn order_ack(cid: ClientOrderId, order_number: &str, now: Instant) -> OrderAck {
80    OrderAck {
81        cid,
82        broker_id: BrokerOrderId::new(order_number),
83        ts: timestamps(now, None),
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use uuid::Uuid;
91
92    fn rec(json: &str) -> OrderRecord {
93        serde_json::from_str(json).unwrap()
94    }
95
96    #[test]
97    fn working_orders_are_acked_or_partly_filled() {
98        let cid = Uuid::new_v4();
99        let r = rec(
100            r#"{"id":"77","symbol":"SBER","board":"TQBR","type":"limit","side":"buy","status":"working","qtyUnits":20,"filledQtyUnits":0,"price":100.5}"#,
101        );
102        let s = order_snapshot(&r, cid, Instant::now()).unwrap();
103        assert!(matches!(s.state, OrderState::Acked { .. }));
104        assert_eq!(s.price.unwrap().to_string(), "100.5");
105        assert_eq!(s.broker_id.unwrap().as_str(), "77");
106
107        let r = rec(
108            r#"{"id":"77","symbol":"SBER","board":"TQBR","type":"limit","side":"buy","status":"working","qtyUnits":20,"filledQtyUnits":10}"#,
109        );
110        let s = order_snapshot(&r, cid, Instant::now()).unwrap();
111        match s.state {
112            OrderState::PartiallyFilled { filled, left, .. } => {
113                assert_eq!((filled, left), (Qty::from(10), Qty::from(10)));
114            }
115            other => panic!("{other:?}"),
116        }
117    }
118
119    #[test]
120    fn final_states_follow_the_status() {
121        let cid = Uuid::new_v4();
122        let st = |s: &str| state_of(Some(s), cid, "1", Qty::from(1), Qty::ZERO);
123        assert!(matches!(st("filled"), OrderState::Filled { .. }));
124        assert!(matches!(st("canceled"), OrderState::Cancelled { .. }));
125        assert!(matches!(st("rejected"), OrderState::Rejected { .. }));
126        // A word the connector does not know is a working order.
127        assert!(matches!(st("something_new"), OrderState::Acked { .. }));
128    }
129
130    #[test]
131    fn a_record_without_pieces_is_skipped() {
132        let r = rec(r#"{"id":"1","symbol":"SBER","side":"buy","status":"working","qty":2}"#);
133        assert!(order_snapshot(&r, Uuid::new_v4(), Instant::now()).is_none());
134    }
135}