1use 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#[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 pub fn new() -> Self {
58 Self::default()
59 }
60
61 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 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 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 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 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
120pub 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
144pub 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
153pub 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
169pub 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
180pub fn scale_trade(t: &mut MarketTrade, lot: Decimal) {
182 t.qty = Qty::new(t.qty.decimal() * lot);
183}
184
185pub fn scale_candle(c: &mut Candle, lot: Decimal) {
187 c.volume = Qty::new(c.volume.decimal() * lot);
188}
189
190pub 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 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 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!("e, Event::Quote(q) if q.bid.as_ref().unwrap().qty == Qty::from(20) && q.ask.is_none())
309 );
310 assert!(event_instrument("e).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}