nautilus_architect_ax/websocket/data/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing functions to convert Ax WebSocket messages to Nautilus domain types.
17
18use anyhow::Context;
19use nautilus_core::nanos::UnixNanos;
20use nautilus_model::{
21    data::{Bar, BarType, BookOrder, OrderBookDelta, OrderBookDeltas, QuoteTick, TradeTick},
22    enums::{AggregationSource, AggressorSide, BookAction, OrderSide, RecordFlag},
23    identifiers::TradeId,
24    instruments::{Instrument, any::InstrumentAny},
25    types::{Price, Quantity},
26};
27use rust_decimal::Decimal;
28
29use crate::{
30    http::parse::candle_width_to_bar_spec,
31    websocket::messages::{
32        AxBookLevel, AxBookLevelL3, AxMdBookL1, AxMdBookL2, AxMdBookL3, AxMdCandle, AxMdTrade,
33    },
34};
35
36const NANOSECONDS_IN_SECOND: u64 = 1_000_000_000;
37
38/// Converts a Decimal to Price with specified precision.
39fn decimal_to_price_dp(value: Decimal, precision: u8, field: &str) -> anyhow::Result<Price> {
40    Price::from_decimal_dp(value, precision).with_context(|| {
41        format!("Failed to construct Price for {field} with precision {precision}")
42    })
43}
44
45/// Parses an Ax L1 book message into a [`QuoteTick`].
46///
47/// L1 contains best bid/ask only, which maps directly to a quote tick.
48///
49/// # Errors
50///
51/// Returns an error if price or quantity parsing fails.
52pub fn parse_book_l1_quote(
53    book: &AxMdBookL1,
54    instrument: &InstrumentAny,
55    ts_init: UnixNanos,
56) -> anyhow::Result<QuoteTick> {
57    let price_precision = instrument.price_precision();
58    let size_precision = instrument.size_precision();
59
60    let (bid_price, bid_size) = if let Some(bid) = book.b.first() {
61        (
62            decimal_to_price_dp(bid.p, price_precision, "book.bid.price")?,
63            Quantity::new(bid.q as f64, size_precision),
64        )
65    } else {
66        (
67            Price::new(0.0, price_precision),
68            Quantity::zero(size_precision),
69        )
70    };
71
72    let (ask_price, ask_size) = if let Some(ask) = book.a.first() {
73        (
74            decimal_to_price_dp(ask.p, price_precision, "book.ask.price")?,
75            Quantity::new(ask.q as f64, size_precision),
76        )
77    } else {
78        (
79            Price::new(0.0, price_precision),
80            Quantity::zero(size_precision),
81        )
82    };
83
84    let ts_event = UnixNanos::from((book.ts as u64) * NANOSECONDS_IN_SECOND);
85
86    QuoteTick::new_checked(
87        instrument.id(),
88        bid_price,
89        ask_price,
90        bid_size,
91        ask_size,
92        ts_event,
93        ts_init,
94    )
95    .context("Failed to construct QuoteTick from Ax L1 book")
96}
97
98/// Parses a book level into price and quantity.
99fn parse_book_level(
100    level: &AxBookLevel,
101    price_precision: u8,
102    size_precision: u8,
103) -> anyhow::Result<(Price, Quantity)> {
104    let price = decimal_to_price_dp(level.p, price_precision, "book.level.price")?;
105    let size = Quantity::new(level.q as f64, size_precision);
106    Ok((price, size))
107}
108
109/// Parses an Ax L2 book message into [`OrderBookDeltas`].
110///
111/// L2 contains aggregated price levels. Each message is treated as a snapshot
112/// that clears the book and adds all levels.
113///
114/// # Errors
115///
116/// Returns an error if price or quantity parsing fails.
117pub fn parse_book_l2_deltas(
118    book: &AxMdBookL2,
119    instrument: &InstrumentAny,
120    ts_init: UnixNanos,
121) -> anyhow::Result<OrderBookDeltas> {
122    let instrument_id = instrument.id();
123    let price_precision = instrument.price_precision();
124    let size_precision = instrument.size_precision();
125
126    let ts_event = UnixNanos::from((book.ts as u64) * NANOSECONDS_IN_SECOND);
127    let sequence = book.tn as u64;
128
129    let total_levels = book.b.len() + book.a.len();
130    let capacity = total_levels + 1;
131
132    let mut deltas = Vec::with_capacity(capacity);
133
134    deltas.push(OrderBookDelta::clear(
135        instrument_id,
136        sequence,
137        ts_event,
138        ts_init,
139    ));
140
141    let mut processed = 0_usize;
142
143    for level in &book.b {
144        let (price, size) = parse_book_level(level, price_precision, size_precision)?;
145        processed += 1;
146
147        let mut flags = RecordFlag::F_MBP as u8;
148        if processed == total_levels {
149            flags |= RecordFlag::F_LAST as u8;
150        }
151
152        let order = BookOrder::new(OrderSide::Buy, price, size, 0);
153        let delta = OrderBookDelta::new_checked(
154            instrument_id,
155            BookAction::Add,
156            order,
157            flags,
158            sequence,
159            ts_event,
160            ts_init,
161        )
162        .context("Failed to construct OrderBookDelta from Ax L2 bid level")?;
163
164        deltas.push(delta);
165    }
166
167    for level in &book.a {
168        let (price, size) = parse_book_level(level, price_precision, size_precision)?;
169        processed += 1;
170
171        let mut flags = RecordFlag::F_MBP as u8;
172        if processed == total_levels {
173            flags |= RecordFlag::F_LAST as u8;
174        }
175
176        let order = BookOrder::new(OrderSide::Sell, price, size, 0);
177        let delta = OrderBookDelta::new_checked(
178            instrument_id,
179            BookAction::Add,
180            order,
181            flags,
182            sequence,
183            ts_event,
184            ts_init,
185        )
186        .context("Failed to construct OrderBookDelta from Ax L2 ask level")?;
187
188        deltas.push(delta);
189    }
190
191    if total_levels == 0
192        && let Some(first) = deltas.first_mut()
193    {
194        first.flags |= RecordFlag::F_LAST as u8;
195    }
196
197    OrderBookDeltas::new_checked(instrument_id, deltas)
198        .context("Failed to assemble OrderBookDeltas from Ax L2 message")
199}
200
201/// Parses a L3 book level into price and quantity.
202fn parse_book_level_l3(
203    level: &AxBookLevelL3,
204    price_precision: u8,
205    size_precision: u8,
206) -> anyhow::Result<(Price, Quantity)> {
207    let price = decimal_to_price_dp(level.p, price_precision, "book.level.price")?;
208    let size = Quantity::new(level.q as f64, size_precision);
209    Ok((price, size))
210}
211
212/// Parses an Ax L3 book message into [`OrderBookDeltas`].
213///
214/// L3 contains individual order quantities at each price level.
215/// Each message is treated as a snapshot that clears the book and adds all orders.
216///
217/// # Errors
218///
219/// Returns an error if price or quantity parsing fails.
220pub fn parse_book_l3_deltas(
221    book: &AxMdBookL3,
222    instrument: &InstrumentAny,
223    ts_init: UnixNanos,
224) -> anyhow::Result<OrderBookDeltas> {
225    let instrument_id = instrument.id();
226    let price_precision = instrument.price_precision();
227    let size_precision = instrument.size_precision();
228
229    let ts_event = UnixNanos::from((book.ts as u64) * NANOSECONDS_IN_SECOND);
230    let sequence = book.tn as u64;
231
232    let total_orders: usize = book.b.iter().map(|l| l.o.len()).sum::<usize>()
233        + book.a.iter().map(|l| l.o.len()).sum::<usize>();
234    let capacity = total_orders + 1;
235
236    let mut deltas = Vec::with_capacity(capacity);
237
238    deltas.push(OrderBookDelta::clear(
239        instrument_id,
240        sequence,
241        ts_event,
242        ts_init,
243    ));
244
245    let mut processed = 0_usize;
246    let mut order_id_counter = 1_u64;
247
248    for level in &book.b {
249        let (price, _) = parse_book_level_l3(level, price_precision, size_precision)?;
250
251        for &order_qty in &level.o {
252            processed += 1;
253
254            let mut flags = 0_u8;
255            if processed == total_orders {
256                flags |= RecordFlag::F_LAST as u8;
257            }
258
259            let size = Quantity::new(order_qty as f64, size_precision);
260            let order = BookOrder::new(OrderSide::Buy, price, size, order_id_counter);
261            order_id_counter += 1;
262
263            let delta = OrderBookDelta::new_checked(
264                instrument_id,
265                BookAction::Add,
266                order,
267                flags,
268                sequence,
269                ts_event,
270                ts_init,
271            )
272            .context("Failed to construct OrderBookDelta from Ax L3 bid order")?;
273
274            deltas.push(delta);
275        }
276    }
277
278    for level in &book.a {
279        let (price, _) = parse_book_level_l3(level, price_precision, size_precision)?;
280
281        for &order_qty in &level.o {
282            processed += 1;
283
284            let mut flags = 0_u8;
285            if processed == total_orders {
286                flags |= RecordFlag::F_LAST as u8;
287            }
288
289            let size = Quantity::new(order_qty as f64, size_precision);
290            let order = BookOrder::new(OrderSide::Sell, price, size, order_id_counter);
291            order_id_counter += 1;
292
293            let delta = OrderBookDelta::new_checked(
294                instrument_id,
295                BookAction::Add,
296                order,
297                flags,
298                sequence,
299                ts_event,
300                ts_init,
301            )
302            .context("Failed to construct OrderBookDelta from Ax L3 ask order")?;
303
304            deltas.push(delta);
305        }
306    }
307
308    if total_orders == 0
309        && let Some(first) = deltas.first_mut()
310    {
311        first.flags |= RecordFlag::F_LAST as u8;
312    }
313
314    OrderBookDeltas::new_checked(instrument_id, deltas)
315        .context("Failed to assemble OrderBookDeltas from Ax L3 message")
316}
317
318/// Parses an Ax trade message into a [`TradeTick`].
319///
320/// # Errors
321///
322/// Returns an error if price or quantity parsing fails.
323pub fn parse_trade_tick(
324    trade: &AxMdTrade,
325    instrument: &InstrumentAny,
326    ts_init: UnixNanos,
327) -> anyhow::Result<TradeTick> {
328    let price_precision = instrument.price_precision();
329    let size_precision = instrument.size_precision();
330
331    let price = decimal_to_price_dp(trade.p, price_precision, "trade.price")?;
332    let size = Quantity::new(trade.q as f64, size_precision);
333    let aggressor_side: AggressorSide = trade.d.into();
334
335    // Use transaction number as trade ID
336    let trade_id = TradeId::new_checked(trade.tn.to_string())
337        .context("Failed to create TradeId from transaction number")?;
338
339    let ts_event = UnixNanos::from((trade.ts as u64) * NANOSECONDS_IN_SECOND);
340
341    TradeTick::new_checked(
342        instrument.id(),
343        price,
344        size,
345        aggressor_side,
346        trade_id,
347        ts_event,
348        ts_init,
349    )
350    .context("Failed to construct TradeTick from Ax trade message")
351}
352
353/// Parses an Ax candle message into a [`Bar`].
354///
355/// # Errors
356///
357/// Returns an error if price or quantity parsing fails.
358pub fn parse_candle_bar(
359    candle: &AxMdCandle,
360    instrument: &InstrumentAny,
361    ts_init: UnixNanos,
362) -> anyhow::Result<Bar> {
363    let price_precision = instrument.price_precision();
364    let size_precision = instrument.size_precision();
365
366    let open = decimal_to_price_dp(candle.open, price_precision, "candle.open")?;
367    let high = decimal_to_price_dp(candle.high, price_precision, "candle.high")?;
368    let low = decimal_to_price_dp(candle.low, price_precision, "candle.low")?;
369    let close = decimal_to_price_dp(candle.close, price_precision, "candle.close")?;
370    let volume = Quantity::new(candle.volume as f64, size_precision);
371
372    let ts_event = UnixNanos::from((candle.ts as u64) * NANOSECONDS_IN_SECOND);
373
374    let bar_spec = candle_width_to_bar_spec(candle.width);
375    let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
376
377    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
378        .context("Failed to construct Bar from Ax candle message")
379}
380
381#[cfg(test)]
382mod tests {
383    use nautilus_model::{
384        identifiers::{InstrumentId, Symbol},
385        instruments::CryptoPerpetual,
386        types::Currency,
387    };
388    use rstest::rstest;
389    use rust_decimal::Decimal;
390    use rust_decimal_macros::dec;
391    use ustr::Ustr;
392
393    use super::*;
394    use crate::{
395        common::{consts::AX_VENUE, enums::AxOrderSide},
396        websocket::messages::{AxMdBookL1, AxMdBookL2, AxMdBookL3, AxMdCandle, AxMdTrade},
397    };
398
399    fn create_test_instrument() -> InstrumentAny {
400        create_instrument_with_precision("BTC-PERP", 2, 3)
401    }
402
403    fn create_eurusd_instrument() -> InstrumentAny {
404        create_instrument_with_precision("EURUSD-PERP", 4, 0)
405    }
406
407    fn create_instrument_with_precision(
408        symbol: &str,
409        price_precision: u8,
410        size_precision: u8,
411    ) -> InstrumentAny {
412        let price_increment = Price::new(10f64.powi(-(price_precision as i32)), price_precision);
413        let size_increment = Quantity::new(10f64.powi(-(size_precision as i32)), size_precision);
414
415        let instrument = CryptoPerpetual::new(
416            InstrumentId::new(Symbol::new(symbol), *AX_VENUE),
417            Symbol::new(symbol),
418            Currency::USD(),
419            Currency::USD(),
420            Currency::USD(),
421            false,
422            price_precision,
423            size_precision,
424            price_increment,
425            size_increment,
426            None,
427            Some(size_increment),
428            None,
429            Some(size_increment),
430            None,
431            None,
432            None,
433            None,
434            Some(Decimal::new(1, 2)),
435            Some(Decimal::new(5, 3)),
436            Some(Decimal::new(2, 4)),
437            Some(Decimal::new(5, 4)),
438            UnixNanos::default(),
439            UnixNanos::default(),
440        );
441        InstrumentAny::CryptoPerpetual(instrument)
442    }
443
444    #[rstest]
445    fn test_parse_book_l1_quote() {
446        let book = AxMdBookL1 {
447            t: "1".to_string(),
448            ts: 1700000000,
449            tn: 12345,
450            s: Ustr::from("BTC-PERP"),
451            b: vec![AxBookLevel {
452                p: dec!(50000.50),
453                q: 100,
454            }],
455            a: vec![AxBookLevel {
456                p: dec!(50001.00),
457                q: 150,
458            }],
459        };
460
461        let instrument = create_test_instrument();
462        let ts_init = UnixNanos::default();
463
464        let quote = parse_book_l1_quote(&book, &instrument, ts_init).unwrap();
465
466        assert_eq!(quote.bid_price.as_f64(), 50000.50);
467        assert_eq!(quote.ask_price.as_f64(), 50001.00);
468        assert_eq!(quote.bid_size.as_f64(), 100.0);
469        assert_eq!(quote.ask_size.as_f64(), 150.0);
470    }
471
472    #[rstest]
473    fn test_parse_book_l2_deltas() {
474        let book = AxMdBookL2 {
475            t: "2".to_string(),
476            ts: 1700000000,
477            tn: 12345,
478            s: Ustr::from("BTC-PERP"),
479            b: vec![
480                AxBookLevel {
481                    p: dec!(50000.50),
482                    q: 100,
483                },
484                AxBookLevel {
485                    p: dec!(50000.00),
486                    q: 200,
487                },
488            ],
489            a: vec![
490                AxBookLevel {
491                    p: dec!(50001.00),
492                    q: 150,
493                },
494                AxBookLevel {
495                    p: dec!(50001.50),
496                    q: 250,
497                },
498            ],
499        };
500
501        let instrument = create_test_instrument();
502        let ts_init = UnixNanos::default();
503
504        let deltas = parse_book_l2_deltas(&book, &instrument, ts_init).unwrap();
505
506        // 1 clear + 4 levels
507        assert_eq!(deltas.deltas.len(), 5);
508        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
509        assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy);
510        assert_eq!(deltas.deltas[3].order.side, OrderSide::Sell);
511    }
512
513    #[rstest]
514    fn test_parse_book_l3_deltas() {
515        let book = AxMdBookL3 {
516            t: "3".to_string(),
517            ts: 1700000000,
518            tn: 12345,
519            s: Ustr::from("BTC-PERP"),
520            b: vec![AxBookLevelL3 {
521                p: dec!(50000.50),
522                q: 300,
523                o: vec![100, 200],
524            }],
525            a: vec![AxBookLevelL3 {
526                p: dec!(50001.00),
527                q: 250,
528                o: vec![150, 100],
529            }],
530        };
531
532        let instrument = create_test_instrument();
533        let ts_init = UnixNanos::default();
534
535        let deltas = parse_book_l3_deltas(&book, &instrument, ts_init).unwrap();
536
537        // 1 clear + 4 individual orders
538        assert_eq!(deltas.deltas.len(), 5);
539        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
540    }
541
542    #[rstest]
543    fn test_parse_trade_tick() {
544        let trade = AxMdTrade {
545            t: "s".to_string(),
546            ts: 1700000000,
547            tn: 12345,
548            s: Ustr::from("BTC-PERP"),
549            p: dec!(50000.50),
550            q: 100,
551            d: AxOrderSide::Buy,
552        };
553
554        let instrument = create_test_instrument();
555        let ts_init = UnixNanos::default();
556
557        let tick = parse_trade_tick(&trade, &instrument, ts_init).unwrap();
558
559        assert_eq!(tick.price.as_f64(), 50000.50);
560        assert_eq!(tick.size.as_f64(), 100.0);
561        assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
562    }
563
564    #[rstest]
565    fn test_parse_book_l1_from_captured_data() {
566        let json = include_str!("../../../test_data/ws_md_book_l1_captured.json");
567        let book: AxMdBookL1 = serde_json::from_str(json).unwrap();
568
569        assert_eq!(book.s.as_str(), "EURUSD-PERP");
570        assert_eq!(book.b.len(), 1);
571        assert_eq!(book.a.len(), 1);
572
573        let instrument = create_eurusd_instrument();
574        let ts_init = UnixNanos::default();
575
576        let quote = parse_book_l1_quote(&book, &instrument, ts_init).unwrap();
577
578        assert_eq!(quote.instrument_id.symbol.as_str(), "EURUSD-PERP");
579        assert_eq!(quote.bid_price.as_f64(), 1.1712);
580        assert_eq!(quote.ask_price.as_f64(), 1.1717);
581        assert_eq!(quote.bid_size.as_f64(), 300.0);
582        assert_eq!(quote.ask_size.as_f64(), 100.0);
583    }
584
585    #[rstest]
586    fn test_parse_book_l2_from_captured_data() {
587        let json = include_str!("../../../test_data/ws_md_book_l2_captured.json");
588        let book: AxMdBookL2 = serde_json::from_str(json).unwrap();
589
590        assert_eq!(book.s.as_str(), "EURUSD-PERP");
591        assert_eq!(book.b.len(), 13);
592        assert_eq!(book.a.len(), 12);
593
594        let instrument = create_eurusd_instrument();
595        let ts_init = UnixNanos::default();
596
597        let deltas = parse_book_l2_deltas(&book, &instrument, ts_init).unwrap();
598
599        // 1 clear + 13 bids + 12 asks = 26 deltas
600        assert_eq!(deltas.deltas.len(), 26);
601        assert_eq!(deltas.instrument_id.symbol.as_str(), "EURUSD-PERP");
602
603        // First delta should be clear
604        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
605
606        // Check first bid level
607        let first_bid = &deltas.deltas[1];
608        assert_eq!(first_bid.order.side, OrderSide::Buy);
609        assert_eq!(first_bid.order.price.as_f64(), 1.1712);
610        assert_eq!(first_bid.order.size.as_f64(), 300.0);
611
612        // Check first ask level (after 13 bids + 1 clear = index 14)
613        let first_ask = &deltas.deltas[14];
614        assert_eq!(first_ask.order.side, OrderSide::Sell);
615        assert_eq!(first_ask.order.price.as_f64(), 1.1719);
616        assert_eq!(first_ask.order.size.as_f64(), 400.0);
617
618        // Last delta should have F_LAST flag
619        let last_delta = deltas.deltas.last().unwrap();
620        assert!(last_delta.flags & RecordFlag::F_LAST as u8 != 0);
621    }
622
623    #[rstest]
624    fn test_parse_book_l3_from_captured_data() {
625        let json = include_str!("../../../test_data/ws_md_book_l3_captured.json");
626        let book: AxMdBookL3 = serde_json::from_str(json).unwrap();
627
628        assert_eq!(book.s.as_str(), "EURUSD-PERP");
629        assert_eq!(book.b.len(), 15);
630        assert_eq!(book.a.len(), 14);
631
632        let instrument = create_eurusd_instrument();
633        let ts_init = UnixNanos::default();
634
635        let deltas = parse_book_l3_deltas(&book, &instrument, ts_init).unwrap();
636
637        // 1 clear + individual orders from each level
638        // Each level has one order in the captured data
639        assert_eq!(deltas.deltas.len(), 30); // 1 clear + 15 bids + 14 asks
640        assert_eq!(deltas.instrument_id.symbol.as_str(), "EURUSD-PERP");
641
642        // First delta should be clear
643        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
644
645        // Check first bid order
646        let first_bid = &deltas.deltas[1];
647        assert_eq!(first_bid.order.side, OrderSide::Buy);
648        assert_eq!(first_bid.order.price.as_f64(), 1.1714);
649        assert_eq!(first_bid.order.size.as_f64(), 100.0);
650
651        // Last delta should have F_LAST flag
652        let last_delta = deltas.deltas.last().unwrap();
653        assert!(last_delta.flags & RecordFlag::F_LAST as u8 != 0);
654    }
655
656    #[rstest]
657    fn test_parse_trade_from_captured_data() {
658        let json = include_str!("../../../test_data/ws_md_trade_captured.json");
659        let trade: AxMdTrade = serde_json::from_str(json).unwrap();
660
661        assert_eq!(trade.s.as_str(), "EURUSD-PERP");
662        assert_eq!(trade.p, dec!(1.1719));
663        assert_eq!(trade.q, 400);
664        assert_eq!(trade.d, AxOrderSide::Buy);
665
666        let instrument = create_eurusd_instrument();
667        let ts_init = UnixNanos::default();
668
669        let tick = parse_trade_tick(&trade, &instrument, ts_init).unwrap();
670
671        assert_eq!(tick.instrument_id.symbol.as_str(), "EURUSD-PERP");
672        assert_eq!(tick.price.as_f64(), 1.1719);
673        assert_eq!(tick.size.as_f64(), 400.0);
674        assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
675        assert_eq!(tick.trade_id.to_string(), "334589144");
676    }
677
678    #[rstest]
679    fn test_parse_book_l1_empty_sides() {
680        let book = AxMdBookL1 {
681            t: "1".to_string(),
682            ts: 1700000000,
683            tn: 12345,
684            s: Ustr::from("TEST-PERP"),
685            b: vec![],
686            a: vec![],
687        };
688
689        let instrument = create_test_instrument();
690        let ts_init = UnixNanos::default();
691
692        let quote = parse_book_l1_quote(&book, &instrument, ts_init).unwrap();
693
694        assert_eq!(quote.bid_price.as_f64(), 0.0);
695        assert_eq!(quote.ask_price.as_f64(), 0.0);
696        assert_eq!(quote.bid_size.as_f64(), 0.0);
697        assert_eq!(quote.ask_size.as_f64(), 0.0);
698    }
699
700    #[rstest]
701    fn test_parse_book_l2_empty_book() {
702        let book = AxMdBookL2 {
703            t: "2".to_string(),
704            ts: 1700000000,
705            tn: 12345,
706            s: Ustr::from("TEST-PERP"),
707            b: vec![],
708            a: vec![],
709        };
710
711        let instrument = create_test_instrument();
712        let ts_init = UnixNanos::default();
713
714        let deltas = parse_book_l2_deltas(&book, &instrument, ts_init).unwrap();
715
716        // Just clear delta with F_LAST
717        assert_eq!(deltas.deltas.len(), 1);
718        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
719        assert!(deltas.deltas[0].flags & RecordFlag::F_LAST as u8 != 0);
720    }
721
722    #[rstest]
723    fn test_parse_candle_bar() {
724        use crate::common::enums::AxCandleWidth;
725
726        let candle = AxMdCandle {
727            t: "c".to_string(),
728            symbol: Ustr::from("BTC-PERP"),
729            ts: 1700000000,
730            open: dec!(50000.00),
731            high: dec!(51000.00),
732            low: dec!(49500.00),
733            close: dec!(50500.00),
734            volume: 1000,
735            buy_volume: 600,
736            sell_volume: 400,
737            width: AxCandleWidth::Minutes1,
738        };
739
740        let instrument = create_test_instrument();
741        let ts_init = UnixNanos::default();
742
743        let bar = parse_candle_bar(&candle, &instrument, ts_init).unwrap();
744
745        assert_eq!(bar.open.as_f64(), 50000.00);
746        assert_eq!(bar.high.as_f64(), 51000.00);
747        assert_eq!(bar.low.as_f64(), 49500.00);
748        assert_eq!(bar.close.as_f64(), 50500.00);
749        assert_eq!(bar.volume.as_f64(), 1000.0);
750        assert_eq!(bar.bar_type.instrument_id().symbol.as_str(), "BTC-PERP");
751    }
752
753    #[rstest]
754    fn test_parse_candle_from_test_data() {
755        let json = include_str!("../../../test_data/ws_md_candle.json");
756        let candle: AxMdCandle = serde_json::from_str(json).unwrap();
757
758        assert_eq!(candle.symbol.as_str(), "BTCUSD-PERP");
759        assert_eq!(candle.open, dec!(49500.00));
760        assert_eq!(candle.close, dec!(50000.00));
761
762        let instrument = create_instrument_with_precision("BTCUSD-PERP", 2, 3);
763        let ts_init = UnixNanos::default();
764
765        let bar = parse_candle_bar(&candle, &instrument, ts_init).unwrap();
766
767        assert_eq!(bar.open.as_f64(), 49500.00);
768        assert_eq!(bar.high.as_f64(), 50500.00);
769        assert_eq!(bar.low.as_f64(), 49000.00);
770        assert_eq!(bar.close.as_f64(), 50000.00);
771        assert_eq!(bar.volume.as_f64(), 5000.0);
772        assert_eq!(bar.bar_type.instrument_id().symbol.as_str(), "BTCUSD-PERP");
773    }
774}