Skip to main content

brokapi_bcs/map/
execution.rs

1//! Execution history mapping.
2
3use brokapi_core::Decimal;
4use std::time::Instant;
5
6use brokapi_bcs_protocol::http::models::TradeRecord;
7use brokapi_bcs_protocol::models::enums::ExecutionType;
8use brokapi_bcs_protocol::ws::models::OrderMessage;
9use brokapi_core::{BrokerOrderId, Execution, Money, Price, Qty, Side};
10use uuid::Uuid;
11
12use super::{instrument_id_ticker, parse_broker_ts, timestamps, unix_nanos};
13
14/// A trade of the history (`POST /trades/search`). The docs describe a flat record (`tradeNum`,
15/// `orderNum`, `tradeQuantity`, `tradeDateTime`, ...) without our `clientOrderId` and without a
16/// commission: `cid` is `None`, `commission` 0. The older `data` form is still read if it comes.
17pub fn map_trade_record(record: &TradeRecord, now: Instant) -> Option<Execution> {
18    let Some(data) = record.data.as_ref() else {
19        return map_flat_trade(record, now);
20    };
21    let ticker = data.ticker.as_deref()?;
22    let class_code = data.class_code.as_deref()?;
23    let order_id = data.order_id.as_ref()?;
24    let execution_id = data.execution_id.as_ref()?;
25    let price = data.price?;
26    let qty = data
27        .last_quantity
28        .or(data.executed_quantity)
29        .unwrap_or(Decimal::ZERO);
30    let side = match data.side.as_deref() {
31        Some("1") | Some("Buy") | Some("BUY") => Side::Buy,
32        Some("2") | Some("Sell") | Some("SELL") => Side::Sell,
33        // Not guessed: a trade with the wrong side would corrupt the position.
34        _ => return None,
35    };
36    let cid = record
37        .client_order_id
38        .as_deref()
39        .and_then(|s| Uuid::parse_str(s).ok());
40    let broker_ts = data
41        .transaction_time
42        .as_deref()
43        .and_then(|s| parse_broker_ts(Some(s)));
44
45    Some(Execution {
46        cid,
47        broker_order_id: BrokerOrderId::new(order_id.clone()),
48        instrument: instrument_id_ticker(ticker, class_code),
49        side,
50        price: Price::new(price),
51        qty: Qty::new(qty),
52        commission: Money::new(data.commission.unwrap_or(Decimal::ZERO)),
53        trade_id: execution_id.clone(),
54        ts: timestamps(now, broker_ts),
55    })
56}
57
58fn map_flat_trade(record: &TradeRecord, now: Instant) -> Option<Execution> {
59    let ticker = record.ticker.as_deref()?;
60    let class_code = record.class_code.as_deref()?;
61    let trade_num = record.trade_num?;
62    let price = record.price?;
63    let qty = record.trade_quantity?;
64    if qty <= Decimal::ZERO {
65        return None;
66    }
67    // An unknown side is not guessed: a trade with the wrong side would corrupt the position.
68    let side = match record.side.as_deref() {
69        Some("1") => Side::Buy,
70        Some("2") => Side::Sell,
71        _ => return None,
72    };
73    let broker_ts = record
74        .trade_date_time
75        .as_deref()
76        .and_then(|s| parse_broker_ts(Some(s)));
77    Some(Execution {
78        cid: None,
79        broker_order_id: BrokerOrderId::new(
80            record.order_num.map(|n| n.to_string()).unwrap_or_default(),
81        ),
82        instrument: instrument_id_ticker(ticker, class_code),
83        side,
84        price: Price::new(price),
85        qty: Qty::new(qty),
86        commission: Money::ZERO,
87        trade_id: trade_num.to_string(),
88        ts: timestamps(now, broker_ts),
89    })
90}
91
92/// What the broker had executed in one order after a message: the quantity and the value (price times
93/// quantity) of everything filled so far, summed over all fills of the order.
94///
95/// The `orders/events/ws` messages carry running totals (`executedQuantity`, `executionValue`,
96/// `averagePrice`), not the numbers of one fill. The docs sample: `lastQuantity` 50, `executedQuantity`
97/// 100, `averagePrice` 244.5, `executionValue` 24450. The price of one fill is the growth of the value
98/// divided by the growth of the quantity between two messages of the same order, see [`map_ws_execution`].
99#[derive(Debug, Clone, Copy, PartialEq)]
100pub struct Cumulative {
101    /// Quantity executed so far (`executedQuantity`), in pieces.
102    pub qty: Decimal,
103    /// Value executed so far, in the currency of the instrument: `executionValue`, else
104    /// `averagePrice * executedQuantity`.
105    pub value: Decimal,
106}
107
108/// The running totals of an order message, or `None` when the message does not carry enough to know
109/// them (no `executedQuantity`, or neither `executionValue` nor `averagePrice`).
110pub fn cumulative_of(
111    data: &brokapi_bcs_protocol::ws::models::OrderExecutionData,
112) -> Option<Cumulative> {
113    let qty = data.executed_quantity?;
114    let value = match (data.execution_value, data.average_price) {
115        (Some(v), _) if v > Decimal::ZERO => v,
116        (_, Some(avg)) if avg > Decimal::ZERO => avg * qty,
117        _ => return None,
118    };
119    Some(Cumulative { qty, value })
120}
121
122/// A fill from the `orders/events/ws` WebSocket. Only messages that carry a fill: `lastQuantity > 0`
123/// and an `executionId`, and not a corrected report (`executionType` 13, which restates an earlier
124/// message). The order's cid is read as [`crate::map::order::message_cid`] says.
125///
126/// **The price of the fill.** `before` is the running total of the same order from the previous message
127/// the connector saw ([`cumulative_of`]). With it the price is exact:
128///
129/// - the totals after this message minus `before` give the quantity and the value of this fill alone,
130///   and the price is value divided by quantity. A first fill of an order (`before` is `None` and
131///   `executedQuantity` equals `lastQuantity`) is the same with `before` of zero. Two fills at
132///   different prices give two different prices, not their average.
133/// - When the totals do not fit (a message was missed, so the growth of `executedQuantity` is not
134///   `lastQuantity`, or the message has no totals), the price is the best the message says:
135///   `averagePrice` of the order so far, else the price of the order. That is an average over earlier
136///   fills too, so it can be off by the difference between the fills. The price of every trade is in the
137///   history (`ExecutionSource::executions`), which is the way to settle it.
138pub fn map_ws_execution(
139    msg: &OrderMessage,
140    now: Instant,
141    before: Option<Cumulative>,
142) -> Option<Execution> {
143    let data = msg.data.as_ref()?;
144    // A corrected report (`executionType` 13, "Исправлено") restates an earlier one. It is not a new fill:
145    // counting it would count the fill twice. The history of trades settles a real correction.
146    if data.execution_type == Some(ExecutionType::Corrected) {
147        return None;
148    }
149    let qty = data.last_quantity;
150    if qty <= Decimal::ZERO {
151        return None;
152    }
153    let execution_id = data.execution_id.as_deref().filter(|s| !s.is_empty())?;
154    let cid = super::order::message_cid(msg);
155    let exact = cumulative_of(data).and_then(|after| {
156        let base = match before {
157            Some(b) => b,
158            // The first message of the order: the totals are those of this fill alone.
159            None if after.qty == qty => Cumulative {
160                qty: Decimal::ZERO,
161                value: Decimal::ZERO,
162            },
163            None => return None,
164        };
165        (after.qty - base.qty == qty && after.value > base.value)
166            .then(|| (after.value - base.value) / qty)
167    });
168    let price = exact.unwrap_or_else(|| match data.average_price {
169        Some(avg) if avg > Decimal::ZERO => avg,
170        _ => data.price,
171    });
172    // An unknown side is not guessed: a fill with the wrong side would corrupt the position.
173    let side = match data.side {
174        brokapi_bcs_protocol::models::enums::OrderSide::Buy => Side::Buy,
175        brokapi_bcs_protocol::models::enums::OrderSide::Sell => Side::Sell,
176        brokapi_bcs_protocol::models::enums::OrderSide::Unknown => return None,
177    };
178    Some(Execution {
179        cid,
180        broker_order_id: BrokerOrderId::new(data.order_id.clone().unwrap_or_default()),
181        instrument: instrument_id_ticker(&data.ticker, &data.class_code),
182        side,
183        price: Price::new(price),
184        qty: Qty::new(qty),
185        commission: Money::new(data.commission.unwrap_or(Decimal::ZERO)),
186        trade_id: execution_id.to_string(),
187        ts: timestamps(now, data.transaction_time.map(unix_nanos)),
188    })
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    fn record(side: Option<&str>) -> TradeRecord {
196        serde_json::from_value(serde_json::json!({
197            "data": {
198                "ticker": "SBER", "classCode": "TQBR", "orderId": "o1", "executionId": "e1",
199                "price": 100.0, "lastQuantity": 2.0, "side": side
200            }
201        }))
202        .unwrap()
203    }
204
205    #[test]
206    fn history_trade_side_of_the_older_form_is_read_and_never_guessed() {
207        let now = Instant::now();
208        assert_eq!(
209            map_trade_record(&record(Some("BUY")), now).unwrap().side,
210            Side::Buy
211        );
212        assert_eq!(
213            map_trade_record(&record(Some("2")), now).unwrap().side,
214            Side::Sell
215        );
216        assert!(map_trade_record(&record(None), now).is_none());
217        assert!(map_trade_record(&record(Some("???")), now).is_none());
218    }
219}