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