Skip to main content

brokapi_alor/map/
market.rs

1//! Market data mapping: ALOR wire models to `brokapi-core` domain types.
2
3use 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
25/// An order book, from the REST answer or a stream message: always a whole snapshot (the broker sends the whole
26/// book, to the depth asked, in every message). The volume is as the broker reports it.
27pub 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
36/// One candle of the timeframe `tf`. The broker gives the open time; the close is the open plus the length of the
37/// timeframe.
38pub 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
55/// The candles of a history answer, oldest first.
56pub 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
68/// The side of a trade of the market is the side of the aggressor (`side` of the record). A trade without a
69/// usable side is dropped: the domain type needs one.
70pub 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
78/// A trade of the market. The quantity is as the broker reports it.
79pub 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
95/// A quotation: the best bid and ask. The broker gives their volumes in lots; with the lot size of the record
96/// they become pieces. A side with no price (an empty book side) is `None`.
97pub 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}