Skip to main content

bulk_client/msgs/
md.rs

1//! Incoming market-data message types.
2//!
3//! These structs are deserialized from the WebSocket JSON feed and correspond
4//! to the Python definitions in `md.py`.
5
6use serde::{Deserialize, Deserializer, Serialize};
7use crate::common::side::Side;
8use crate::transaction::ActionMeta;
9
10// ============================================================================
11// Matrix MD
12// ============================================================================
13
14
15/// Matrix with named columns and rows
16#[derive(Clone, Debug, Deserialize, Serialize)]
17pub struct Matrix {
18    /// named labels for the matrix
19    pub index: Vec<String>,
20    pub matrix: Vec<Vec<f64>>,
21
22    #[serde(skip)]
23    pub meta: ActionMeta,
24}
25
26// ============================================================================
27// Summary-level Data
28// ============================================================================
29
30/// Market ticker data.
31///
32/// Received on the `"ticker"` channel.
33#[derive(Debug, Clone, Deserialize)]
34#[serde(rename_all = "camelCase")]
35#[allow(unused)]
36pub struct Ticker {
37    pub symbol: String,
38    #[serde(deserialize_with = "f64_or_nan")]
39    pub last_price: f64,
40    #[serde(deserialize_with = "f64_or_nan")]
41    pub mark_price: f64,
42    #[serde(deserialize_with = "f64_or_nan")]
43    pub oracle_price: f64,
44    #[serde(deserialize_with = "f64_or_nan")]
45    pub price_change: f64,
46    #[serde(deserialize_with = "f64_or_nan")]
47    pub price_change_percent: f64,
48    #[serde(deserialize_with = "f64_or_nan")]
49    pub high_price: f64,
50    #[serde(deserialize_with = "f64_or_nan")]
51    pub low_price: f64,
52    #[serde(deserialize_with = "f64_or_nan")]
53    pub volume: f64,
54    #[serde(deserialize_with = "f64_or_nan")]
55    pub quote_volume: f64,
56    #[serde(deserialize_with = "f64_or_nan")]
57    pub open_interest: f64,
58    #[serde(deserialize_with = "f64_or_nan")]
59    pub funding_rate: f64,
60}
61
62// ============================================================================
63// Candles
64// ============================================================================
65
66
67/// OHLCV candlestick data.
68///
69/// Received on the `"candle"` channel.
70/// The `symbol` and `interval` are populated from the subscription topic,
71/// not from the candle payload itself.
72#[derive(Debug, Clone,Deserialize)]
73#[allow(unused)]
74pub struct Candle {
75    #[serde(rename="t")]
76    pub open_time: u64,
77    #[serde(rename = "T")]
78    pub close_time: u64,
79    #[serde(skip)]
80    pub symbol: String,
81    #[serde(skip)]
82    pub interval: String,
83    #[serde(rename = "o")]
84    pub open: f64,
85    #[serde(rename = "h")]
86    pub high: f64,
87    #[serde(rename = "l")]
88    pub low: f64,
89    #[serde(rename = "c")]
90    pub close: f64,
91    #[serde(rename = "v")]
92    pub volume: f64,
93    #[serde(rename = "n")]
94    pub num_trades: u64,
95}
96
97
98// ============================================================================
99// Trades
100// ============================================================================
101
102/// A single public trade.
103///
104/// Received on the `"trades"` channel (as a list).
105#[derive(Debug, Clone,Deserialize)]
106#[allow(unused)]
107pub struct Trade {
108    #[serde(rename="time")]
109    pub timestamp: u64,
110    #[serde(rename="s")]
111    pub symbol: String,
112    #[serde(rename="b")]
113    pub side: Side,
114    #[serde(rename="sz")]
115    pub size: f64,
116    #[serde(rename="px")]
117    pub price: f64,
118    pub maker: String,
119    pub taker: String,
120}
121
122// ============================================================================
123// Order Book
124// ============================================================================
125
126/// Single price level in the order book.
127#[derive(Debug, Clone, PartialEq, Deserialize)]
128#[allow(unused)]
129pub struct OrderBookLevel {
130    /// Price
131    #[serde(rename="px")]
132    pub price: f64,
133    /// Size (aggregate quantity at this price)
134    #[serde(rename="sz")]
135    pub size: f64,
136    /// Number of orders at this level
137    #[serde(rename="n")]
138    pub num_orders: u32,
139}
140
141impl std::fmt::Display for OrderBookLevel {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        write!(f, "{} @ {}", self.size, self.price)
144    }
145}
146
147
148/// Full L2 order-book snapshot.
149///
150/// Received on the `"l2Snapshot"` channel.
151#[derive(Debug, Clone, Deserialize)]
152#[allow(unused)]
153pub struct L2Snapshot {
154    pub timestamp: u64,
155    pub symbol: String,
156    /// `[[bid_levels], [ask_levels]]`
157    pub levels: (Vec<OrderBookLevel>, Vec<OrderBookLevel>),
158}
159
160
161// ============================================================================
162// Helpers
163// ============================================================================
164
165/// Deserialize an `f64` that may be `null` in JSON, mapping `null` → `NaN`.
166pub fn f64_or_nan<'de, D>(deserializer: D) -> Result<f64, D::Error>
167where
168    D: Deserializer<'de>,
169{
170    Ok(Option::<f64>::deserialize(deserializer)?.unwrap_or(f64::NAN))
171}
172
173
174// ============================================================================
175// Optional SDK integration
176// ============================================================================
177//
178//
179// #[cfg(feature = "with-sdk")]
180// use bulk_sdk_core::{
181//     markets::MktId,
182//     data::L2Snapshot as SDKL2Snapshot,
183//     data::PriceLevel as SDKPriceLevel,
184//     data::L2Delta as SDKL2Delta,
185// };
186//
187// #[cfg(feature = "with-sdk")]
188// impl From<&L2Snapshot> for SDKL2Snapshot {
189//     fn from(snap: &L2Snapshot) -> Self {
190//         let (bids, asks) = &snap.levels;
191//
192//         let instrument = MktId::new(snap.symbol.as_str()).unwrap();
193//         let newbids = bids.iter().map(|x| {
194//             SDKPriceLevel {
195//                 amount: x.size,
196//                 price: x.price,
197//                 num_orders: x.num_orders,
198//                 cum_vwap: 0.0,
199//                 cum_amount: 0.0,
200//             }
201//         }).collect();
202//         let newasks = asks.iter().map(|x| {
203//             SDKPriceLevel {
204//                 amount: x.size,
205//                 price: x.price,
206//                 num_orders: x.num_orders,
207//                 cum_vwap: 0.0,
208//                 cum_amount: 0.0,
209//             }
210//         }).collect();
211//
212//         SDKL2Snapshot {
213//             stamp: snap.timestamp,
214//             instrument,
215//             bids: newbids,
216//             asks: newasks,
217//             trackable_id: Default::default(),
218//         }
219//     }
220// }
221//
222// #[cfg(feature = "with-sdk")]
223// impl From<&OrderBookLevel> for SDKL2Delta {
224//     fn from(level: &OrderBookLevel) -> Self {
225//         SDKL2Delta {
226//             stamp: 0,
227//             instrument: Default::default(),
228//             side: Default::default(),
229//             amount: level.size,
230//             price: level.price,
231//         }
232//     }
233// }
234
235//
236// Unit Tests
237//
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_ticker_deserialize_with_nulls() {
245        let json = r#"{
246            "symbol": "BTC-USD",
247            "priceChange": 0.0,
248            "priceChangePercent": 0.0,
249            "lastPrice": 100000.1824189499,
250            "highPrice": 100000.52095557356,
251            "lowPrice": 99999.62809703613,
252            "volume": 0.0,
253            "quoteVolume": 0.0,
254            "markPrice": null,
255            "oraclePrice": null,
256            "openInterest": 0.0,
257            "fundingRate": 0.0
258        }"#;
259
260        let ticker: Ticker = serde_json::from_str(json).unwrap();
261
262        assert_eq!(ticker.symbol, "BTC-USD");
263        assert!((ticker.last_price - 100000.1824189499).abs() < 1e-6);
264        assert!((ticker.high_price - 100000.52095557356).abs() < 1e-6);
265        assert!((ticker.low_price - 99999.62809703613).abs() < 1e-6);
266        assert_eq!(ticker.price_change, 0.0);
267        assert_eq!(ticker.price_change_percent, 0.0);
268        assert_eq!(ticker.volume, 0.0);
269        assert_eq!(ticker.quote_volume, 0.0);
270        assert_eq!(ticker.open_interest, 0.0);
271        assert_eq!(ticker.funding_rate, 0.0);
272
273        // null → NaN
274        assert!(ticker.mark_price.is_nan());
275        assert!(ticker.oracle_price.is_nan());
276    }
277
278    #[test]
279    fn test_ticker_deserialize_with_values() {
280        let json = r#"{
281            "symbol": "ETH-USD",
282            "priceChange": 10.5,
283            "priceChangePercent": 0.33,
284            "lastPrice": 3200.0,
285            "highPrice": 3250.0,
286            "lowPrice": 3150.0,
287            "volume": 1234.56,
288            "quoteVolume": 3950000.0,
289            "markPrice": 3201.5,
290            "oraclePrice": 3200.8,
291            "openInterest": 50000.0,
292            "fundingRate": 0.0001
293        }"#;
294
295        let ticker: Ticker = serde_json::from_str(json).unwrap();
296
297        assert_eq!(ticker.symbol, "ETH-USD");
298        assert!((ticker.mark_price - 3201.5).abs() < 1e-6);
299        assert!((ticker.oracle_price - 3200.8).abs() < 1e-6);
300        assert!(!ticker.mark_price.is_nan());
301        assert!(!ticker.oracle_price.is_nan());
302    }
303
304    #[test]
305    fn test_l2_snapshot_deserialize() {
306        let json = serde_json::json!({
307            "timestamp": 1770906894450242133u64,
308            "symbol": "ETH-USD",
309            "updateType": "snapshot",
310            "levels": [
311                [
312                    {"px": 1988.36, "sz": 50.0000001, "n": 5},
313                    {"px": 1988.35, "sz": 71.3240001, "n": 5},
314                    {"px": 1988.34, "sz": 40.00000006, "n": 4},
315                    {"px": 1988.32, "sz": 102.8540001, "n": 5},
316                    {"px": 1988.31, "sz": 30.00000003, "n": 3},
317                    {"px": 1988.30, "sz": 30.00000003, "n": 3},
318                    {"px": 1988.29, "sz": 50.8950001, "n": 5},
319                    {"px": 1988.28, "sz": 64.4280001, "n": 5},
320                    {"px": 1988.27, "sz": 53.7410001, "n": 5},
321                    {"px": 1988.25, "sz": 121.9040001, "n": 5}
322                ],
323                [
324                    {"px": 1988.47, "sz": 10.0, "n": 1},
325                    {"px": 1988.75, "sz": 70.0, "n": 7},
326                    {"px": 1989.00, "sz": 230.00000006, "n": 23},
327                    {"px": 1989.25, "sz": 140.0, "n": 14},
328                    {"px": 1989.50, "sz": 440.25680003, "n": 44},
329                    {"px": 1989.75, "sz": 230.00000007, "n": 23},
330                    {"px": 1990.00, "sz": 200.0, "n": 20},
331                    {"px": 1990.25, "sz": 230.00000009, "n": 23},
332                    {"px": 1990.50, "sz": 340.0, "n": 34},
333                    {"px": 1990.75, "sz": 250.0, "n": 25}
334                ]
335            ]
336        });
337
338        let snap: L2Snapshot = serde_json::from_value(json).expect("deserialize L2Snapshot");
339
340        assert_eq!(snap.symbol, "ETH-USD");
341        assert_eq!(snap.timestamp, 1770906894450242133);
342
343        let (bids, asks) = &snap.levels;
344        assert_eq!(bids.len(), 10);
345        assert_eq!(asks.len(), 10);
346
347        // Best bid
348        assert_eq!(bids[0].price, 1988.36);
349        assert_eq!(bids[0].size, 50.0000001);
350        assert_eq!(bids[0].num_orders, 5);
351
352        // Best ask
353        assert_eq!(asks[0].price, 1988.47);
354        assert_eq!(asks[0].size, 10.0);
355        assert_eq!(asks[0].num_orders, 1);
356
357        // Last bid
358        assert_eq!(bids[9].price, 1988.25);
359        assert_eq!(bids[9].size, 121.9040001);
360        assert_eq!(bids[9].num_orders, 5);
361
362        // Last ask
363        assert_eq!(asks[9].price, 1990.75);
364        assert_eq!(asks[9].size, 250.0);
365        assert_eq!(asks[9].num_orders, 25);
366    }
367}
368
369