1use std::time::Instant;
4
5use brokapi_alor_protocol::http::models::{
6 Bar, BookLevel as WireLevel, HistoryResponse, MarketTradeRecord, OrderBook as WireBook,
7 QuoteRecord,
8};
9use brokapi_core::{
10 BookLevel, Candle, InstrumentId, MarketTrade, OrderBook, Price, Qty, Quote, Side, Timeframe,
11};
12
13use super::{ms_to_nanos, secs_to_nanos, timestamps};
14
15fn levels(levels: &[WireLevel]) -> Vec<BookLevel> {
16 levels
17 .iter()
18 .map(|l| BookLevel {
19 price: Price::new(l.price),
20 qty: Qty::new(l.volume),
21 })
22 .collect()
23}
24
25pub fn order_book(book: &WireBook, instrument: &InstrumentId, now: Instant) -> OrderBook {
28 OrderBook::Snapshot {
29 instrument: instrument.clone(),
30 bids: levels(&book.bids),
31 asks: levels(&book.asks),
32 ts: timestamps(now, book.ms_timestamp.map(ms_to_nanos)),
33 }
34}
35
36pub fn candle(bar: &Bar, instrument: &InstrumentId, tf: Timeframe, now: Instant) -> Candle {
39 let open_ts = secs_to_nanos(bar.time);
40 let close_ts = secs_to_nanos(bar.time.saturating_add(tf.seconds() as i64));
41 Candle {
42 instrument: instrument.clone(),
43 timeframe: tf,
44 open: Price::new(bar.open),
45 high: Price::new(bar.high),
46 low: Price::new(bar.low),
47 close: Price::new(bar.close),
48 volume: Qty::new(bar.volume),
49 open_ts,
50 close_ts,
51 ts: timestamps(now, Some(open_ts)),
52 }
53}
54
55pub fn candles(
57 resp: &HistoryResponse,
58 instrument: &InstrumentId,
59 tf: Timeframe,
60 now: Instant,
61) -> Vec<Candle> {
62 resp.history
63 .iter()
64 .map(|b| candle(b, instrument, tf, now))
65 .collect()
66}
67
68pub fn side_of(raw: Option<&str>) -> Option<Side> {
71 match raw?.to_ascii_lowercase().as_str() {
72 "buy" => Some(Side::Buy),
73 "sell" => Some(Side::Sell),
74 _ => None,
75 }
76}
77
78pub fn market_trade(
80 r: &MarketTradeRecord,
81 instrument: &InstrumentId,
82 now: Instant,
83) -> Option<MarketTrade> {
84 let side = side_of(r.side.as_deref())?;
85 Some(MarketTrade {
86 instrument: instrument.clone(),
87 price: Price::new(r.price),
88 qty: Qty::new(r.qty),
89 side,
90 trade_id: r.id.map(|i| i.to_string()),
91 ts: timestamps(now, r.timestamp.map(ms_to_nanos)),
92 })
93}
94
95pub fn quote(q: &QuoteRecord, instrument: &InstrumentId, now: Instant) -> Quote {
98 let lot = q.lotsize.unwrap_or(brokapi_core::Decimal::ONE);
99 let level = |price: Option<brokapi_core::Decimal>, vol: Option<brokapi_core::Decimal>| {
100 price.filter(|p| !p.is_zero()).map(|p| BookLevel {
101 price: Price::new(p),
102 qty: Qty::new(vol.unwrap_or_default() * lot),
103 })
104 };
105 Quote {
106 instrument: instrument.clone(),
107 bid: level(q.bid, q.bid_vol),
108 ask: level(q.ask, q.ask_vol),
109 ts: timestamps(now, q.ob_ms_timestamp.map(ms_to_nanos)),
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use crate::util::instrument_id;
117 use brokapi_core::{Decimal, dec};
118
119 fn sber() -> InstrumentId {
120 instrument_id("SBER", "TQBR")
121 }
122
123 #[test]
124 fn a_book_keeps_the_digits_of_the_prices() {
125 let wire: WireBook = serde_json::from_str(
126 r#"{"bids":[{"price":244.30,"volume":157}],"asks":[{"price":244.31,"volume":288}],"msTimestamp":1702631123780,"existing":true}"#,
127 )
128 .unwrap();
129 let OrderBook::Snapshot { bids, asks, ts, .. } = order_book(&wire, &sber(), Instant::now())
130 else {
131 panic!("a snapshot");
132 };
133 assert_eq!(bids[0].price.to_string(), "244.30");
134 assert_eq!(asks[0].qty, Qty::from(288));
135 assert_eq!(ts.broker_ts.unwrap().as_nanos(), 1_702_631_123_780_000_000);
136 }
137
138 #[test]
139 fn a_candle_closes_one_timeframe_after_it_opens() {
140 let bar = Bar {
141 time: 1_700_000_000,
142 open: dec!(1),
143 high: dec!(2),
144 low: dec!(1),
145 close: dec!(2),
146 volume: dec!(5),
147 };
148 let c = candle(&bar, &sber(), Timeframe::M5, Instant::now());
149 assert_eq!(
150 c.close_ts.as_nanos() - c.open_ts.as_nanos(),
151 300_000_000_000
152 );
153 }
154
155 #[test]
156 fn quote_volumes_are_lots_made_pieces() {
157 let q: QuoteRecord = serde_json::from_str(
158 r#"{"symbol":"SBER","bid":100.5,"ask":100.6,"bid_vol":3,"ask_vol":2,"lotsize":10}"#,
159 )
160 .unwrap();
161 let out = quote(&q, &sber(), Instant::now());
162 assert_eq!(out.bid.unwrap().qty, Qty::from(30));
163 assert_eq!(out.ask.unwrap().qty, Qty::from(20));
164 }
165
166 #[test]
167 fn a_trade_without_a_side_is_dropped() {
168 let r: MarketTradeRecord = serde_json::from_str(r#"{"id":1,"qty":1,"price":1}"#).unwrap();
169 assert!(market_trade(&r, &sber(), Instant::now()).is_none());
170 let r: MarketTradeRecord =
171 serde_json::from_str(r#"{"id":1,"qty":1,"price":1,"side":"sell"}"#).unwrap();
172 assert_eq!(
173 market_trade(&r, &sber(), Instant::now()).unwrap().side,
174 Side::Sell
175 );
176 }
177}