Skip to main content

brokapi_tbank/map/
order.rs

1//! Orders: broker messages -> `OrderSnapshot`.
2
3use std::time::Instant;
4
5use brokapi_core::{
6    BrokerOrderId, ClientOrderId, InstrumentId, OrderSnapshot, OrderState, OrderType, Price, Qty,
7    Side,
8};
9use brokapi_tbank_protocol::v1 as pb;
10use brokapi_tbank_protocol::v1::order_state_stream_response as stream;
11
12use crate::cid::{parse_request_id, synthetic_cid};
13use crate::ids::{id_from_parts, price_of_money, stamps};
14use crate::lots::lots_to_qty;
15
16/// What every order message (unary state, placement response, stream update) has in common.
17#[derive(Debug, Clone)]
18pub struct OrderParts {
19    /// The exchange order id (`order_id`). Empty when the message has none.
20    pub exchange_id: String,
21    /// The `order_request_id`: the idempotency key we sent, as text. Empty for an order placed
22    /// elsewhere.
23    pub request_id: String,
24    /// The order status as the broker reports it.
25    pub status: pb::OrderExecutionReportStatus,
26    /// Lots requested.
27    pub lots_requested: i64,
28    /// Lots executed so far.
29    pub lots_executed: i64,
30    /// Units of the instrument in one lot. 1 until the caller sets it ([`OrderParts::with_lot`]): the
31    /// message of a unary call does not carry it, the order stream message does (`lot_size`).
32    pub lot_size: i64,
33    /// Price of one instrument, if the message carries a non-zero one.
34    pub unit_price: Option<Price>,
35    /// Buy or sell, as the broker reports it.
36    pub direction: pb::OrderDirection,
37    /// Limit, market or best price, as the broker reports it.
38    pub order_type: pb::OrderType,
39    /// The instrument, built from the uid, figi or ticker the message carries.
40    pub instrument: InstrumentId,
41    /// The broker's text for a refusal, if any (the placement response `message`, or the cause of a
42    /// stream status).
43    pub reason: String,
44    /// The broker's time of the message, if it gave one.
45    pub broker_ts: Option<prost_types::Timestamp>,
46}
47
48impl OrderParts {
49    /// The same parts with the lot size of the instrument set. A lot below 1 is read as 1.
50    pub fn with_lot(mut self, lot_size: i64) -> Self {
51        self.lot_size = lot_size.max(1);
52        self
53    }
54
55    /// Quantity requested, in units of the instrument.
56    pub fn qty_requested(&self) -> Qty {
57        lots_to_qty(self.lots_requested, self.lot_size)
58    }
59
60    /// Quantity executed so far, in units of the instrument.
61    pub fn qty_executed(&self) -> Qty {
62        lots_to_qty(self.lots_executed, self.lot_size)
63    }
64
65    /// Quantity not executed yet, in units of the instrument (never below zero).
66    pub fn qty_left(&self) -> Qty {
67        lots_to_qty(
68            (self.lots_requested - self.lots_executed).max(0),
69            self.lot_size,
70        )
71    }
72
73    /// Our cid, or a stable stand-in for an order placed elsewhere.
74    pub fn cid(&self) -> ClientOrderId {
75        parse_request_id(&self.request_id).unwrap_or_else(|| synthetic_cid(&self.exchange_id))
76    }
77
78    /// `true` when `request_id` is a cid of ours, so the order was placed by this library.
79    pub fn is_ours(&self) -> bool {
80        parse_request_id(&self.request_id).is_some()
81    }
82}
83
84/// Broker direction to [`Side`]. Anything but sell reads as buy.
85pub fn side(d: pb::OrderDirection) -> Side {
86    match d {
87        pb::OrderDirection::Sell => Side::Sell,
88        _ => Side::Buy,
89    }
90}
91
92/// [`Side`] to broker direction.
93pub fn direction(s: Side) -> pb::OrderDirection {
94    match s {
95        Side::Buy => pb::OrderDirection::Buy,
96        Side::Sell => pb::OrderDirection::Sell,
97    }
98}
99
100/// `ORDER_TYPE_BESTPRICE` has no counterpart in the domain model and reads as market.
101pub fn order_type(t: pb::OrderType) -> OrderType {
102    match t {
103        pb::OrderType::Limit => OrderType::Limit,
104        _ => OrderType::Market,
105    }
106}
107
108/// [`OrderType`] to broker order type (limit or market).
109pub fn wire_order_type(t: OrderType) -> pb::OrderType {
110    match t {
111        OrderType::Limit => pb::OrderType::Limit,
112        OrderType::Market => pb::OrderType::Market,
113    }
114}
115
116/// The order state of a status report.
117///
118/// Fill, cancel, reject, new and partial fill map to their states. Partial fill carries `filled` and
119/// `left` in units of the instrument. An unspecified status is `Unknown`, never a guess. A rejection without a reason
120/// gets the text `REJECTED`.
121pub fn state(p: &OrderParts, cid: ClientOrderId, now: Instant) -> OrderState {
122    use pb::OrderExecutionReportStatus as S;
123    let broker_id = BrokerOrderId::new(p.exchange_id.clone());
124    match p.status {
125        S::ExecutionReportStatusFill => OrderState::Filled { cid },
126        S::ExecutionReportStatusCancelled => OrderState::Cancelled { cid },
127        S::ExecutionReportStatusRejected => OrderState::Rejected {
128            cid,
129            reason: if p.reason.is_empty() {
130                "REJECTED".into()
131            } else {
132                p.reason.clone()
133            },
134        },
135        S::ExecutionReportStatusNew => OrderState::Acked { cid, broker_id },
136        S::ExecutionReportStatusPartiallyfill => OrderState::PartiallyFilled {
137            cid,
138            filled: p.qty_executed(),
139            left: p.qty_left(),
140        },
141        S::ExecutionReportStatusUnspecified => OrderState::Unknown { cid, since: now },
142    }
143}
144
145/// An [`OrderSnapshot`] from the parts of any order message.
146///
147/// `qty` and `filled` are in units of the instrument (the lot size set with [`OrderParts::with_lot`]),
148/// `price` is per one instrument. The cid is [`OrderParts::cid`].
149pub fn snapshot(p: &OrderParts, now: Instant) -> OrderSnapshot {
150    let cid = p.cid();
151    OrderSnapshot::new(
152        cid,
153        p.instrument.clone(),
154        state(p, cid, now),
155        side(p.direction),
156        order_type(p.order_type),
157        p.qty_requested(),
158        stamps(now, p.broker_ts.as_ref()),
159    )
160    .with_broker_id((!p.exchange_id.is_empty()).then(|| BrokerOrderId::new(p.exchange_id.clone())))
161    .with_filled(p.qty_executed())
162    .with_price(p.unit_price)
163}
164
165fn price_of(m: &Option<pb::MoneyValue>) -> Option<Price> {
166    m.as_ref().map(price_of_money).filter(|p| !p.is_zero())
167}
168
169/// `GetOrderState` / `GetOrders`.
170pub fn parts_from_state(o: &pb::OrderState) -> OrderParts {
171    OrderParts {
172        exchange_id: o.order_id.clone(),
173        request_id: o.order_request_id.clone(),
174        status: o.execution_report_status(),
175        lots_requested: o.lots_requested,
176        lots_executed: o.lots_executed,
177        lot_size: 1,
178        unit_price: price_of(&o.initial_security_price),
179        direction: o.direction(),
180        order_type: o.order_type(),
181        instrument: id_from_parts(&o.instrument_uid, &o.figi, &o.ticker, &o.class_code),
182        reason: String::new(),
183        broker_ts: o.order_date,
184    }
185}
186
187/// `PostOrder` / `ReplaceOrder` response.
188pub fn parts_from_post(r: &pb::PostOrderResponse) -> OrderParts {
189    OrderParts {
190        exchange_id: r.order_id.clone(),
191        request_id: r.order_request_id.clone(),
192        status: r.execution_report_status(),
193        lots_requested: r.lots_requested,
194        lots_executed: r.lots_executed,
195        lot_size: 1,
196        unit_price: price_of(&r.initial_security_price),
197        direction: r.direction(),
198        order_type: r.order_type(),
199        instrument: id_from_parts(&r.instrument_uid, &r.figi, &r.ticker, &r.class_code),
200        reason: r.message.clone(),
201        broker_ts: None,
202    }
203}
204
205/// `OrderStateStream` update. `order_price` is taken as the price of one instrument (the docs do not
206/// say; for our own orders the price we sent wins, see `UdCore`).
207pub fn parts_from_stream(o: &stream::OrderState) -> OrderParts {
208    let cause = o
209        .status_info
210        .and_then(|c| stream::StatusCauseInfo::try_from(c).ok())
211        .map(|c| c.as_str_name().to_string())
212        .unwrap_or_default();
213    OrderParts {
214        exchange_id: o.order_id.clone(),
215        request_id: o.order_request_id.clone().unwrap_or_default(),
216        status: o.execution_report_status(),
217        lots_requested: o.lots_requested,
218        lots_executed: o.lots_executed,
219        lot_size: i64::from(o.lot_size).max(1),
220        unit_price: price_of(&Some(o.order_price.clone().unwrap_or_default())),
221        direction: o.direction(),
222        order_type: o.order_type(),
223        instrument: id_from_parts(&o.instrument_uid, "", &o.ticker, &o.class_code),
224        reason: cause,
225        broker_ts: o.created_at,
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use brokapi_core::{Decimal, dec};
233    use brokapi_tbank_protocol::money::money;
234
235    fn base() -> pb::OrderState {
236        pb::OrderState {
237            order_id: "5001".into(),
238            execution_report_status: pb::OrderExecutionReportStatus::ExecutionReportStatusNew
239                as i32,
240            lots_requested: 10,
241            lots_executed: 0,
242            initial_security_price: Some(money("rub", "250.5")),
243            direction: pb::OrderDirection::Buy as i32,
244            order_type: pb::OrderType::Limit as i32,
245            order_request_id: "6f1c4f6e-0a54-4a43-8c46-0f6d3b2c8a11".into(),
246            ticker: "SBER".into(),
247            class_code: "TQBR".into(),
248            instrument_uid: "uid-1".into(),
249            ..Default::default()
250        }
251    }
252
253    fn snap(o: &pb::OrderState) -> OrderSnapshot {
254        snapshot(&parts_from_state(o), Instant::now())
255    }
256
257    #[test]
258    fn new_order_is_acked_with_our_cid() {
259        let s = snap(&base());
260        assert_eq!(s.cid.to_string(), "6f1c4f6e-0a54-4a43-8c46-0f6d3b2c8a11");
261        assert!(matches!(s.state, OrderState::Acked { .. }));
262        assert_eq!(s.broker_id.as_ref().unwrap().as_str(), "5001");
263        assert_eq!(
264            (s.qty.decimal(), s.filled.decimal()),
265            (dec!(10.0), dec!(0.0))
266        );
267        assert_eq!(s.price.unwrap().decimal(), dec!(250.5));
268        assert_eq!((s.side, s.order_type), (Side::Buy, OrderType::Limit));
269    }
270
271    #[test]
272    fn partial_fill_counts_left_lots() {
273        let mut o = base();
274        o.execution_report_status =
275            pb::OrderExecutionReportStatus::ExecutionReportStatusPartiallyfill as i32;
276        o.lots_executed = 4;
277        let s = snap(&o);
278        match s.state {
279            OrderState::PartiallyFilled { filled, left, .. } => {
280                assert_eq!((filled.decimal(), left.decimal()), (dec!(4.0), dec!(6.0)));
281            }
282            other => panic!("{other:?}"),
283        }
284    }
285
286    #[test]
287    fn terminal_statuses() {
288        use pb::OrderExecutionReportStatus as S;
289        for (st, check) in [
290            (S::ExecutionReportStatusFill, 0),
291            (S::ExecutionReportStatusCancelled, 1),
292            (S::ExecutionReportStatusRejected, 2),
293        ] {
294            let mut o = base();
295            o.execution_report_status = st as i32;
296            let s = snap(&o);
297            match (check, &s.state) {
298                (0, OrderState::Filled { .. }) | (1, OrderState::Cancelled { .. }) => {}
299                (2, OrderState::Rejected { reason, .. }) => assert_eq!(reason, "REJECTED"),
300                other => panic!("{other:?}"),
301            }
302        }
303    }
304
305    #[test]
306    fn unspecified_status_is_unknown() {
307        let mut o = base();
308        o.execution_report_status = 0;
309        assert!(matches!(snap(&o).state, OrderState::Unknown { .. }));
310    }
311
312    #[test]
313    fn foreign_order_gets_synthetic_cid() {
314        let mut o = base();
315        o.order_request_id.clear();
316        let p = parts_from_state(&o);
317        assert!(!p.is_ours());
318        assert!(!p.cid().is_nil());
319        assert_eq!(p.cid(), parts_from_state(&o).cid());
320    }
321
322    #[test]
323    fn stream_state_with_cause() {
324        let o = stream::OrderState {
325            order_id: "7".into(),
326            order_request_id: Some("6f1c4f6e-0a54-4a43-8c46-0f6d3b2c8a11".into()),
327            execution_report_status: pb::OrderExecutionReportStatus::ExecutionReportStatusRejected
328                as i32,
329            status_info: Some(stream::StatusCauseInfo::CauseRejectedByExchange as i32),
330            lots_requested: 1,
331            ticker: "SBER".into(),
332            class_code: "TQBR".into(),
333            ..Default::default()
334        };
335        let s = snapshot(&parts_from_stream(&o), Instant::now());
336        match s.state {
337            OrderState::Rejected { reason, .. } => assert_eq!(reason, "CAUSE_REJECTED_BY_EXCHANGE"),
338            other => panic!("{other:?}"),
339        }
340    }
341}