Skip to main content

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