Skip to main content

brokapi_tbank/
lots.rs

1//! Lots. T-Bank counts orders, order book levels, trades and candle volumes in LOTS, the library
2//! counts in units of the instrument (see [`Qty`]). This module is the bridge: the lot size of each
3//! instrument, learned from the directory once and remembered, and the conversions both ways.
4//!
5//! The lot size is `Instrument.lot` of `GetInstrumentBy`, and `lot_size` of an order state message of
6//! the order stream. A connector looks it up before the first order or subscription on an
7//! instrument (one `GetInstrumentBy`, against the quota of that method) and never again.
8//!
9//! # Examples
10//!
11//! ```
12//! use brokapi_core::Qty;
13//! use brokapi_tbank::lots::{lots_to_qty, pieces_to_lots};
14//!
15//! // A lot of SBER is 10 shares: 30 shares are 3 lots, 3 lots are 30 shares.
16//! assert_eq!(pieces_to_lots(Qty::from(30), 10)?, 3);
17//! assert_eq!(lots_to_qty(3, 10), Qty::from(30));
18//! // Not a whole number of lots: refused, never rounded.
19//! assert!(pieces_to_lots(Qty::from(25), 10).is_err());
20//! # Ok::<(), brokapi_core::Error>(())
21//! ```
22
23use std::collections::HashMap;
24use std::sync::Mutex;
25
26use brokapi_core::{
27    BookLevel, Candle, Decimal, Error, Event, InstrumentId, MarketTrade, NativeId, OrderBook, Qty,
28};
29use brokapi_tbank_protocol::v1 as pb;
30
31use crate::ids::instrument_ref;
32
33/// The lot sizes of the instruments this connector has met. Cheap to share behind an `Arc`.
34///
35/// An instrument is remembered under every name a message can call it: the uid, the figi and the
36/// `TICKER_CLASSCODE` form.
37#[derive(Debug, Default)]
38pub struct LotBook {
39    by_name: Mutex<HashMap<String, i64>>,
40}
41
42fn names(uid: &str, figi: &str, ticker: &str, class_code: &str) -> Vec<String> {
43    let mut v = Vec::new();
44    for n in [uid, figi] {
45        if !n.is_empty() {
46            v.push(n.to_string());
47        }
48    }
49    if !ticker.is_empty() {
50        v.push(format!("{ticker}_{class_code}").to_uppercase());
51    }
52    v
53}
54
55impl LotBook {
56    /// An empty book.
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Remembers the lot of an instrument under its uid, figi and ticker with class code. A lot below 1
62    /// is ignored.
63    pub fn put(&self, uid: &str, figi: &str, ticker: &str, class_code: &str, lot: i64) {
64        if lot < 1 {
65            return;
66        }
67        let mut map = self.by_name.lock().expect("lot book poisoned");
68        for n in names(uid, figi, ticker, class_code) {
69            map.insert(n, lot);
70        }
71    }
72
73    /// Remembers the lot from a directory record, under the name the caller asked with too.
74    pub fn put_instrument(&self, asked: &InstrumentId, i: &pb::Instrument) {
75        self.put(&i.uid, &i.figi, &i.ticker, &i.class_code, i64::from(i.lot));
76        self.put_id(asked, i64::from(i.lot));
77    }
78
79    /// Remembers the lot under the names of an [`InstrumentId`].
80    pub fn put_id(&self, id: &InstrumentId, lot: i64) {
81        if lot < 1 {
82            return;
83        }
84        let mut map = self.by_name.lock().expect("lot book poisoned");
85        map.insert(instrument_ref(id).to_uppercase(), lot);
86        match &id.native {
87            NativeId::Uid(u) | NativeId::Figi(u) => {
88                map.insert(u.clone(), lot);
89            }
90            _ => {}
91        }
92        map.insert(
93            format!("{}_{}", id.key.symbol, id.key.mic).to_uppercase(),
94            lot,
95        );
96    }
97
98    /// The lot of an instrument by the names a message carries, if it is known.
99    pub fn get_parts(&self, uid: &str, figi: &str, ticker: &str, class_code: &str) -> Option<i64> {
100        let map = self.by_name.lock().expect("lot book poisoned");
101        names(uid, figi, ticker, class_code)
102            .iter()
103            .find_map(|n| map.get(n).copied())
104    }
105
106    /// The lot of an [`InstrumentId`], if it is known.
107    pub fn get(&self, id: &InstrumentId) -> Option<i64> {
108        let map = self.by_name.lock().expect("lot book poisoned");
109        let mut tries = vec![
110            instrument_ref(id).to_uppercase(),
111            format!("{}_{}", id.key.symbol, id.key.mic).to_uppercase(),
112        ];
113        if let NativeId::Uid(u) | NativeId::Figi(u) = &id.native {
114            tries.push(u.clone());
115        }
116        tries.iter().find_map(|n| map.get(n).copied())
117    }
118}
119
120/// Units to lots: the quantity must be a whole number of lots, at least one.
121///
122/// # Errors
123///
124/// [`Error::Rejected`] with code `invalid_qty` for zero, a negative number, a fraction, or a number that
125/// is not a multiple of `lot`. Nothing is rounded.
126pub fn pieces_to_lots(qty: Qty, lot: i64) -> Result<i64, Error> {
127    let lot = lot.max(1);
128    let bad = |why: String| Error::Rejected {
129        code: "invalid_qty".into(),
130        message: why,
131    };
132    let pieces = qty
133        .to_whole()
134        .filter(|n| *n >= 1)
135        .ok_or_else(|| bad(format!("quantity {qty} is not a positive whole number")))?;
136    if pieces % lot != 0 {
137        return Err(bad(format!(
138            "quantity {qty} is not a whole number of lots (one lot is {lot} units)"
139        )));
140    }
141    Ok(pieces / lot)
142}
143
144/// Lots to units.
145pub fn lots_to_qty(lots: i64, lot: i64) -> Qty {
146    Qty::new(Decimal::from(lots) * Decimal::from(lot.max(1)))
147}
148
149fn level(l: &mut BookLevel, lot: Decimal) {
150    l.qty = Qty::new(l.qty.decimal() * lot);
151}
152
153/// Multiplies the quantities of a market data event by the lot size: the levels of a book, the
154/// best levels of a quote, the quantity of a trade, the volume of a candle. Other events stay as they are.
155pub fn scale_event(ev: &mut Event, lot: i64) {
156    let lot = Decimal::from(lot.max(1));
157    match ev {
158        Event::OrderBook(b) => scale_book(b, lot),
159        Event::Quote(q) => {
160            q.bid.iter_mut().for_each(|l| level(l, lot));
161            q.ask.iter_mut().for_each(|l| level(l, lot));
162        }
163        Event::MarketTrade(t) => scale_trade(t, lot),
164        Event::Candle(c) => scale_candle(c, lot),
165        _ => {}
166    }
167}
168
169/// The levels of a book, in units.
170pub fn scale_book(book: &mut OrderBook, lot: Decimal) {
171    match book {
172        OrderBook::Snapshot { bids, asks, .. } | OrderBook::Increment { bids, asks, .. } => {
173            bids.iter_mut()
174                .chain(asks.iter_mut())
175                .for_each(|l| level(l, lot));
176        }
177    }
178}
179
180/// The quantity of a trade, in units.
181pub fn scale_trade(t: &mut MarketTrade, lot: Decimal) {
182    t.qty = Qty::new(t.qty.decimal() * lot);
183}
184
185/// The volume of a candle, in units.
186pub fn scale_candle(c: &mut Candle, lot: Decimal) {
187    c.volume = Qty::new(c.volume.decimal() * lot);
188}
189
190/// The instrument a market data event is about, if it has one.
191pub fn event_instrument(ev: &Event) -> Option<&InstrumentId> {
192    match ev {
193        Event::OrderBook(OrderBook::Snapshot { instrument, .. })
194        | Event::OrderBook(OrderBook::Increment { instrument, .. }) => Some(instrument),
195        Event::Quote(q) => Some(&q.instrument),
196        Event::MarketTrade(t) => Some(&t.instrument),
197        Event::Candle(c) => Some(&c.instrument),
198        _ => None,
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use brokapi_core::{InstrumentKey, dec};
206
207    fn sber() -> InstrumentId {
208        InstrumentId::new(
209            InstrumentKey::new("SBER", "TQBR"),
210            NativeId::Uid("uid-sber".into()),
211        )
212    }
213
214    #[test]
215    fn conversions_refuse_what_is_not_whole_lots() {
216        assert_eq!(pieces_to_lots(Qty::from(10), 10).unwrap(), 1);
217        assert_eq!(pieces_to_lots(Qty::from(7), 1).unwrap(), 7);
218        for bad in [
219            Qty::ZERO,
220            Qty::from(-10),
221            Qty::new(dec!(5.5)),
222            Qty::from(15),
223        ] {
224            let e = pieces_to_lots(bad, 10).unwrap_err();
225            assert!(
226                matches!(e, Error::Rejected { ref code, .. } if code == "invalid_qty"),
227                "{bad}"
228            );
229        }
230        assert_eq!(lots_to_qty(4, 10), Qty::from(40));
231        // A lot below 1 is read as 1, so a broken record cannot divide by zero.
232        assert_eq!(lots_to_qty(4, 0), Qty::from(4));
233        assert_eq!(pieces_to_lots(Qty::from(3), 0).unwrap(), 3);
234    }
235
236    #[test]
237    fn the_book_knows_an_instrument_under_every_name() {
238        let book = LotBook::new();
239        assert_eq!(book.get(&sber()), None);
240        book.put("uid-sber", "BBG004730N88", "SBER", "TQBR", 10);
241        assert_eq!(book.get(&sber()), Some(10));
242        assert_eq!(book.get_parts("", "BBG004730N88", "", ""), Some(10));
243        assert_eq!(book.get_parts("", "", "sber", "tqbr"), Some(10));
244        assert_eq!(book.get_parts("other", "", "", ""), None);
245        // A lot below one is ignored.
246        book.put("u2", "", "X", "TQBR", 0);
247        assert_eq!(book.get_parts("u2", "", "", ""), None);
248        let ticker_id = InstrumentId::new(
249            InstrumentKey::new("GAZP", "TQBR"),
250            NativeId::TickerClass {
251                ticker: "GAZP".into(),
252                class_code: "TQBR".into(),
253            },
254        );
255        book.put_id(&ticker_id, 10);
256        assert_eq!(book.get(&ticker_id), Some(10));
257        book.put_id(&sber(), 0);
258        let figi_id = InstrumentId::new(
259            InstrumentKey::new("LKOH", "TQBR"),
260            NativeId::Figi("BBG004731032".into()),
261        );
262        book.put_id(&figi_id, 1);
263        assert_eq!(book.get(&figi_id), Some(1));
264    }
265
266    #[test]
267    fn events_are_scaled_by_the_lot() {
268        use brokapi_core::{Price, Quote, Side, Timestamps, UnixNanos};
269        let ts = Timestamps::new(UnixNanos::ZERO, std::time::Instant::now());
270        let level = |q: i64| BookLevel {
271            price: Price::from(1),
272            qty: Qty::from(q),
273        };
274        let mut book = Event::OrderBook(OrderBook::Snapshot {
275            instrument: sber(),
276            bids: vec![level(1), level(2)],
277            asks: vec![level(3)],
278            ts,
279        });
280        scale_event(&mut book, 10);
281        let Event::OrderBook(OrderBook::Snapshot { bids, asks, .. }) = &book else {
282            panic!()
283        };
284        assert_eq!((bids[1].qty, asks[0].qty), (Qty::from(20), Qty::from(30)));
285        assert_eq!(event_instrument(&book), Some(&sber()));
286
287        let mut inc = Event::OrderBook(OrderBook::Increment {
288            instrument: sber(),
289            seq: 1,
290            bids: vec![level(1)],
291            asks: vec![],
292            ts,
293        });
294        scale_event(&mut inc, 10);
295        assert!(
296            matches!(&inc, Event::OrderBook(OrderBook::Increment { bids, .. }) if bids[0].qty == Qty::from(10))
297        );
298        assert!(event_instrument(&inc).is_some());
299
300        let mut quote = Event::Quote(Quote {
301            instrument: sber(),
302            bid: Some(level(2)),
303            ask: None,
304            ts,
305        });
306        scale_event(&mut quote, 10);
307        assert!(
308            matches!(&quote, Event::Quote(q) if q.bid.as_ref().unwrap().qty == Qty::from(20) && q.ask.is_none())
309        );
310        assert!(event_instrument(&quote).is_some());
311
312        let mut trade = Event::MarketTrade(MarketTrade {
313            instrument: sber(),
314            price: Price::from(1),
315            qty: Qty::from(5),
316            side: Side::Buy,
317            trade_id: None,
318            ts,
319        });
320        scale_event(&mut trade, 10);
321        assert!(matches!(&trade, Event::MarketTrade(t) if t.qty == Qty::from(50)));
322        assert!(event_instrument(&trade).is_some());
323
324        let mut other = Event::Balances(vec![]);
325        scale_event(&mut other, 10);
326        assert!(event_instrument(&other).is_none());
327    }
328}