Skip to main content

brokapi_tbank/map/
execution.rs

1//! Own trades: `TradesStream` and operation history.
2
3use std::time::Instant;
4
5use brokapi_core::{
6    BrokerOrderId, ClientOrderId, Decimal, Execution, InstrumentId, Money, Price, Qty,
7};
8use brokapi_tbank_protocol::v1 as pb;
9
10use crate::ids::{id_from_parts, money_of, price_of_money, price_of_quotation, stamps};
11use crate::map::order::side;
12
13/// One `Execution` per trade in an `OrderTrades` message. The stream carries no commission (zero) and
14/// no `order_request_id`: `cid` comes from the order ids the connector has seen.
15pub fn from_order_trades(
16    t: &pb::OrderTrades,
17    instrument: InstrumentId,
18    cid: Option<ClientOrderId>,
19    now: Instant,
20) -> Vec<Execution> {
21    t.trades
22        .iter()
23        .map(|tr| Execution {
24            cid,
25            broker_order_id: BrokerOrderId::new(t.order_id.clone()),
26            instrument: instrument.clone(),
27            side: side(t.direction()),
28            price: tr
29                .price
30                .as_ref()
31                .map(price_of_quotation)
32                .unwrap_or(Price::ZERO),
33            qty: Qty::from(tr.quantity),
34            commission: Money::ZERO,
35            trade_id: tr.trade_id.clone(),
36            ts: stamps(now, tr.date_time.as_ref().or(t.created_at.as_ref())),
37        })
38        .collect()
39}
40
41/// History from `GetOperationsByCursor`: BUY/SELL operations, one `Execution` per trade.
42/// `broker_order_id` is the operation id (the history has no order id), `cid` is `None`, the
43/// operation commission is spread over its trades in proportion to quantity. Quantities are in
44/// pieces here, unlike the lots of the live stream.
45pub fn from_operation(op: &pb::OperationItem, now: Instant) -> Vec<Execution> {
46    use pb::OperationType as T;
47    let side = match op.r#type() {
48        T::Buy | T::BuyCard | T::BuyMargin => brokapi_core::Side::Buy,
49        T::Sell | T::SellCard | T::SellMargin => brokapi_core::Side::Sell,
50        _ => return Vec::new(),
51    };
52    let Some(info) = &op.trades_info else {
53        return Vec::new();
54    };
55    let instrument = id_from_parts(&op.instrument_uid, &op.figi, &op.ticker, &op.class_code);
56    let total_qty: i64 = info.trades.iter().map(|t| t.quantity).sum();
57    let commission = op
58        .commission
59        .as_ref()
60        .map(money_of)
61        .unwrap_or(Money::ZERO)
62        .abs();
63    // The commission is split by quantity, in whole billionths (the broker's own precision). The
64    // last trade takes the rest, so the parts add up to the commission exactly.
65    let mut left = commission;
66    let last = info.trades.len().saturating_sub(1);
67    info.trades
68        .iter()
69        .enumerate()
70        .map(|(i, tr)| {
71            let share = if total_qty <= 0 {
72                Money::ZERO
73            } else if i == last {
74                left
75            } else {
76                let part =
77                    commission.decimal() * Decimal::from(tr.quantity) / Decimal::from(total_qty);
78                Money::new(part.trunc_with_scale(9))
79            };
80            left -= share;
81            (tr, share)
82        })
83        .map(|(tr, share)| Execution {
84            cid: None,
85            broker_order_id: BrokerOrderId::new(op.id.clone()),
86            instrument: instrument.clone(),
87            side,
88            price: tr.price.as_ref().map(price_of_money).unwrap_or(Price::ZERO),
89            qty: Qty::from(tr.quantity),
90            commission: share,
91            trade_id: tr.num.clone(),
92            ts: stamps(now, tr.date.as_ref()),
93        })
94        .collect()
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use brokapi_core::dec;
101    use brokapi_core::{InstrumentKey, NativeId, Side};
102    use brokapi_tbank_protocol::money::{money, quotation};
103
104    #[test]
105    fn stream_trades_one_execution_each() {
106        let t = pb::OrderTrades {
107            order_id: "55".into(),
108            direction: pb::OrderDirection::Sell as i32,
109            trades: vec![
110                pb::OrderTrade {
111                    price: Some(quotation("100.5")),
112                    quantity: 2,
113                    trade_id: "t1".into(),
114                    ..Default::default()
115                },
116                pb::OrderTrade {
117                    price: Some(quotation("100.6")),
118                    quantity: 3,
119                    trade_id: "t2".into(),
120                    ..Default::default()
121                },
122            ],
123            ..Default::default()
124        };
125        let inst = InstrumentId::new(
126            InstrumentKey::new("SBER", "TQBR"),
127            NativeId::Uid("u".into()),
128        );
129        let cid = ClientOrderId::from_bytes([7; 16]);
130        let ex = from_order_trades(&t, inst, Some(cid), Instant::now());
131        assert_eq!(ex.len(), 2);
132        assert_eq!(
133            (ex[0].side, ex[0].price.decimal(), ex[0].qty.decimal()),
134            (Side::Sell, dec!(100.5), dec!(2.0))
135        );
136        assert_eq!(ex[1].trade_id, "t2");
137        assert_eq!(ex[0].cid, Some(cid));
138        assert_eq!(ex[0].broker_order_id.as_str(), "55");
139    }
140
141    #[test]
142    fn operation_commission_is_spread_by_quantity() {
143        let op = pb::OperationItem {
144            id: "op1".into(),
145            r#type: pb::OperationType::Buy as i32,
146            ticker: "SBER".into(),
147            class_code: "TQBR".into(),
148            instrument_uid: "u".into(),
149            commission: Some(money("rub", "-4.0")),
150            trades_info: Some(pb::OperationItemTrades {
151                trades: vec![
152                    pb::OperationItemTrade {
153                        num: "a".into(),
154                        quantity: 1,
155                        price: Some(money("rub", "10.0")),
156                        ..Default::default()
157                    },
158                    pb::OperationItemTrade {
159                        num: "b".into(),
160                        quantity: 3,
161                        price: Some(money("rub", "11.0")),
162                        ..Default::default()
163                    },
164                ],
165            }),
166            ..Default::default()
167        };
168        let ex = from_operation(&op, Instant::now());
169        assert_eq!(ex.len(), 2);
170        assert_eq!(
171            (ex[0].commission.decimal(), ex[1].commission.decimal()),
172            (dec!(1.0), dec!(3.0))
173        );
174        // The parts add up to the commission exactly.
175        assert_eq!(ex[0].commission + ex[1].commission, Money::new(dec!(4)));
176        assert_eq!(ex[0].side, Side::Buy);
177    }
178
179    #[test]
180    fn non_trade_operations_are_skipped() {
181        let op = pb::OperationItem {
182            r#type: pb::OperationType::Dividend as i32,
183            ..Default::default()
184        };
185        assert!(from_operation(&op, Instant::now()).is_empty());
186    }
187}