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    instruments::{Instrument, any::InstrumentAny},
24    types::{Price, Quantity},
25};
26use rust_decimal::Decimal;
27
28use crate::{
29    common::parse::{ax_timestamp_stn_to_unix_nanos, create_architect_trade_id},
30    http::parse::candle_width_to_bar_spec,
31    websocket::messages::{
32        AxBookLevel, AxBookLevelL3, AxMdBookL1, AxMdBookL2, AxMdBookL3, AxMdCandle, AxMdTrade,
33    },
34};
35
36/// Converts a Decimal to Price with specified precision.
37fn decimal_to_price_dp(value: Decimal, precision: u8, field: &str) -> anyhow::Result<Price> {
38    Price::from_decimal_dp(value, precision).with_context(|| {
39        format!("Failed to construct Price for {field} with precision {precision}")
40    })
41}
42
43/// Parses an Ax L1 book message into a [`QuoteTick`].
44///
45/// L1 contains best bid/ask only, which maps directly to a quote tick.
46///
47/// # Errors
48///
49/// Returns an error if price or quantity parsing fails.
50pub fn parse_book_l1_quote(
51    book: &AxMdBookL1,
52    instrument: &InstrumentAny,
53    ts_init: UnixNanos,
54) -> anyhow::Result<QuoteTick> {
55    parse_top_of_book_quote(
56        book.ts,
57        book.tn,
58        book.b.first().map(|level| (level.p, level.q)),
59        book.a.first().map(|level| (level.p, level.q)),
60        instrument,
61        ts_init,
62    )
63}
64
65/// Parses the top levels of an Ax L2 book message into a [`QuoteTick`].
66///
67/// # Errors
68///
69/// Returns an error if price or quantity parsing fails.
70pub fn parse_book_l2_quote(
71    book: &AxMdBookL2,
72    instrument: &InstrumentAny,
73    ts_init: UnixNanos,
74) -> anyhow::Result<QuoteTick> {
75    parse_top_of_book_quote(
76        book.ts,
77        book.tn,
78        book.b
79            .iter()
80            .max_by_key(|level| level.p)
81            .map(|level| (level.p, level.q)),
82        book.a
83            .iter()
84            .min_by_key(|level| level.p)
85            .map(|level| (level.p, level.q)),
86        instrument,
87        ts_init,
88    )
89}
90
91/// Parses the top levels of an Ax L3 book message into a [`QuoteTick`].
92///
93/// # Errors
94///
95/// Returns an error if price or quantity parsing fails.
96pub fn parse_book_l3_quote(
97    book: &AxMdBookL3,
98    instrument: &InstrumentAny,
99    ts_init: UnixNanos,
100) -> anyhow::Result<QuoteTick> {
101    parse_top_of_book_quote(
102        book.ts,
103        book.tn,
104        book.b
105            .iter()
106            .max_by_key(|level| level.p)
107            .map(|level| (level.p, level.q)),
108        book.a
109            .iter()
110            .min_by_key(|level| level.p)
111            .map(|level| (level.p, level.q)),
112        instrument,
113        ts_init,
114    )
115}
116
117fn parse_top_of_book_quote(
118    ts: i64,
119    tn: i64,
120    bid: Option<(Decimal, u64)>,
121    ask: Option<(Decimal, u64)>,
122    instrument: &InstrumentAny,
123    ts_init: UnixNanos,
124) -> anyhow::Result<QuoteTick> {
125    let price_precision = instrument.price_precision();
126    let size_precision = instrument.size_precision();
127
128    let (bid_price, bid_size) = if let Some((price, quantity)) = bid {
129        (
130            decimal_to_price_dp(price, price_precision, "book.bid.price")?,
131            Quantity::new(quantity as f64, size_precision),
132        )
133    } else {
134        (Price::zero(price_precision), Quantity::zero(size_precision))
135    };
136
137    let (ask_price, ask_size) = if let Some((price, quantity)) = ask {
138        (
139            decimal_to_price_dp(price, price_precision, "book.ask.price")?,
140            Quantity::new(quantity as f64, size_precision),
141        )
142    } else {
143        (Price::zero(price_precision), Quantity::zero(size_precision))
144    };
145
146    let ts_event = ax_timestamp_stn_to_unix_nanos(ts, tn)?;
147
148    QuoteTick::new_checked(
149        instrument.id(),
150        bid_price,
151        ask_price,
152        bid_size,
153        ask_size,
154        ts_event,
155        ts_init,
156    )
157    .context("Failed to construct QuoteTick from Ax L1 book")
158}
159
160/// Parses a book level into price and quantity.
161fn parse_book_level(
162    level: &AxBookLevel,
163    price_precision: u8,
164    size_precision: u8,
165) -> anyhow::Result<(Price, Quantity)> {
166    let price = decimal_to_price_dp(level.p, price_precision, "book.level.price")?;
167    let size = Quantity::new(level.q as f64, size_precision);
168    Ok((price, size))
169}
170
171/// Parses an Ax L2 book message into [`OrderBookDeltas`].
172///
173/// L2 contains aggregated price levels. Each message is treated as a snapshot
174/// that clears the book and adds all levels.
175///
176/// # Errors
177///
178/// Returns an error if price or quantity parsing fails.
179pub fn parse_book_l2_deltas(
180    book: &AxMdBookL2,
181    instrument: &InstrumentAny,
182    sequence: u64,
183    ts_init: UnixNanos,
184) -> anyhow::Result<OrderBookDeltas> {
185    let instrument_id = instrument.id();
186    let price_precision = instrument.price_precision();
187    let size_precision = instrument.size_precision();
188
189    let ts_event = ax_timestamp_stn_to_unix_nanos(book.ts, book.tn)?;
190
191    let total_levels = book.b.len() + book.a.len();
192    let capacity = total_levels + 1;
193
194    let mut deltas = Vec::with_capacity(capacity);
195
196    deltas.push(OrderBookDelta::clear(
197        instrument_id,
198        sequence,
199        ts_event,
200        ts_init,
201    ));
202
203    let mut processed = 0_usize;
204
205    for level in &book.b {
206        let (price, size) = parse_book_level(level, price_precision, size_precision)?;
207        processed += 1;
208
209        let mut flags = RecordFlag::F_MBP as u8 | RecordFlag::F_SNAPSHOT as u8;
210
211        if processed == total_levels {
212            flags |= RecordFlag::F_LAST as u8;
213        }
214
215        let order = BookOrder::new(OrderSide::Buy, price, size, 0);
216        let delta = OrderBookDelta::new_checked(
217            instrument_id,
218            BookAction::Add,
219            order,
220            flags,
221            sequence,
222            ts_event,
223            ts_init,
224        )
225        .context("Failed to construct OrderBookDelta from Ax L2 bid level")?;
226
227        deltas.push(delta);
228    }
229
230    for level in &book.a {
231        let (price, size) = parse_book_level(level, price_precision, size_precision)?;
232        processed += 1;
233
234        let mut flags = RecordFlag::F_MBP as u8 | RecordFlag::F_SNAPSHOT as u8;
235
236        if processed == total_levels {
237            flags |= RecordFlag::F_LAST as u8;
238        }
239
240        let order = BookOrder::new(OrderSide::Sell, price, size, 0);
241        let delta = OrderBookDelta::new_checked(
242            instrument_id,
243            BookAction::Add,
244            order,
245            flags,
246            sequence,
247            ts_event,
248            ts_init,
249        )
250        .context("Failed to construct OrderBookDelta from Ax L2 ask level")?;
251
252        deltas.push(delta);
253    }
254
255    if total_levels == 0
256        && let Some(first) = deltas.first_mut()
257    {
258        first.flags |= RecordFlag::F_LAST as u8;
259    }
260
261    OrderBookDeltas::new_checked(instrument_id, deltas)
262        .context("Failed to assemble OrderBookDeltas from Ax L2 message")
263}
264
265/// Parses a L3 book level into price and quantity.
266fn parse_book_level_l3(
267    level: &AxBookLevelL3,
268    price_precision: u8,
269    size_precision: u8,
270) -> anyhow::Result<(Price, Quantity)> {
271    let price = decimal_to_price_dp(level.p, price_precision, "book.level.price")?;
272    let size = Quantity::new(level.q as f64, size_precision);
273    Ok((price, size))
274}
275
276/// Parses an Ax L3 book message into [`OrderBookDeltas`].
277///
278/// L3 contains individual order quantities at each price level.
279/// Each message is treated as a snapshot that clears the book and adds all orders.
280///
281/// # Errors
282///
283/// Returns an error if price or quantity parsing fails.
284pub fn parse_book_l3_deltas(
285    book: &AxMdBookL3,
286    instrument: &InstrumentAny,
287    sequence: u64,
288    ts_init: UnixNanos,
289) -> anyhow::Result<OrderBookDeltas> {
290    let instrument_id = instrument.id();
291    let price_precision = instrument.price_precision();
292    let size_precision = instrument.size_precision();
293
294    let ts_event = ax_timestamp_stn_to_unix_nanos(book.ts, book.tn)?;
295
296    let total_orders: usize = book.b.iter().map(|l| l.o.len()).sum::<usize>()
297        + book.a.iter().map(|l| l.o.len()).sum::<usize>();
298    let capacity = total_orders + 1;
299
300    let mut deltas = Vec::with_capacity(capacity);
301
302    deltas.push(OrderBookDelta::clear(
303        instrument_id,
304        sequence,
305        ts_event,
306        ts_init,
307    ));
308
309    let mut processed = 0_usize;
310    let mut order_id_counter = 1_u64;
311
312    for level in &book.b {
313        let (price, _) = parse_book_level_l3(level, price_precision, size_precision)?;
314
315        for &order_qty in &level.o {
316            processed += 1;
317
318            let mut flags = RecordFlag::F_SNAPSHOT as u8;
319
320            if processed == total_orders {
321                flags |= RecordFlag::F_LAST as u8;
322            }
323
324            let size = Quantity::new(order_qty as f64, size_precision);
325            let order = BookOrder::new(OrderSide::Buy, price, size, order_id_counter);
326            order_id_counter += 1;
327
328            let delta = OrderBookDelta::new_checked(
329                instrument_id,
330                BookAction::Add,
331                order,
332                flags,
333                sequence,
334                ts_event,
335                ts_init,
336            )
337            .context("Failed to construct OrderBookDelta from Ax L3 bid order")?;
338
339            deltas.push(delta);
340        }
341    }
342
343    for level in &book.a {
344        let (price, _) = parse_book_level_l3(level, price_precision, size_precision)?;
345
346        for &order_qty in &level.o {
347            processed += 1;
348
349            let mut flags = RecordFlag::F_SNAPSHOT as u8;
350
351            if processed == total_orders {
352                flags |= RecordFlag::F_LAST as u8;
353            }
354
355            let size = Quantity::new(order_qty as f64, size_precision);
356            let order = BookOrder::new(OrderSide::Sell, price, size, order_id_counter);
357            order_id_counter += 1;
358
359            let delta = OrderBookDelta::new_checked(
360                instrument_id,
361                BookAction::Add,
362                order,
363                flags,
364                sequence,
365                ts_event,
366                ts_init,
367            )
368            .context("Failed to construct OrderBookDelta from Ax L3 ask order")?;
369
370            deltas.push(delta);
371        }
372    }
373
374    if total_orders == 0
375        && let Some(first) = deltas.first_mut()
376    {
377        first.flags |= RecordFlag::F_LAST as u8;
378    }
379
380    OrderBookDeltas::new_checked(instrument_id, deltas)
381        .context("Failed to assemble OrderBookDeltas from Ax L3 message")
382}
383
384/// Parses an Ax trade message into a [`TradeTick`].
385///
386/// # Errors
387///
388/// Returns an error if price or quantity parsing fails.
389pub fn parse_trade_tick(
390    trade: &AxMdTrade,
391    instrument: &InstrumentAny,
392    ts_init: UnixNanos,
393) -> anyhow::Result<TradeTick> {
394    let price_precision = instrument.price_precision();
395    let size_precision = instrument.size_precision();
396
397    let price = decimal_to_price_dp(trade.p, price_precision, "trade.price")?;
398    let size = Quantity::new(trade.q as f64, size_precision);
399    let aggressor_side: AggressorSide = trade.d.map_or(AggressorSide::NoAggressor, |d| d.into());
400
401    let ts_event = ax_timestamp_stn_to_unix_nanos(trade.ts, trade.tn)?;
402    let trade_id = create_architect_trade_id(ts_event, price, size, aggressor_side)?;
403
404    TradeTick::new_checked(
405        instrument.id(),
406        price,
407        size,
408        aggressor_side,
409        trade_id,
410        ts_event,
411        ts_init,
412    )
413    .context("Failed to construct TradeTick from Ax trade message")
414}
415
416/// Parses an Ax candle message into a [`Bar`].
417///
418/// # Errors
419///
420/// Returns an error if price or quantity parsing fails.
421pub fn parse_candle_bar(
422    candle: &AxMdCandle,
423    instrument: &InstrumentAny,
424    ts_init: UnixNanos,
425) -> anyhow::Result<Bar> {
426    let price_precision = instrument.price_precision();
427    let size_precision = instrument.size_precision();
428
429    let open = decimal_to_price_dp(candle.open, price_precision, "candle.open")?;
430    let high = decimal_to_price_dp(candle.high, price_precision, "candle.high")?;
431    let low = decimal_to_price_dp(candle.low, price_precision, "candle.low")?;
432    let close = decimal_to_price_dp(candle.close, price_precision, "candle.close")?;
433    let volume = Quantity::new(candle.volume as f64, size_precision);
434
435    let ts_event = ax_timestamp_stn_to_unix_nanos(candle.ts, 0)?;
436
437    let bar_spec = candle_width_to_bar_spec(candle.width);
438    let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
439
440    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
441        .context("Failed to construct Bar from Ax candle message")
442}
443
444#[cfg(test)]
445mod tests {
446    use nautilus_model::{
447        enums::AssetClass,
448        identifiers::{InstrumentId, Symbol},
449        instruments::PerpetualContract,
450        types::Currency,
451    };
452    use rstest::rstest;
453    use rust_decimal::Decimal;
454    use rust_decimal_macros::dec;
455    use ustr::Ustr;
456
457    use super::*;
458    use crate::{
459        common::{consts::AX_VENUE, enums::AxOrderSide},
460        http::{models::AxRestTrade, parse::parse_trade_tick as parse_rest_trade_tick},
461        websocket::messages::{AxMdBookL1, AxMdBookL2, AxMdBookL3, AxMdCandle, AxMdTrade},
462    };
463
464    fn create_test_instrument() -> InstrumentAny {
465        create_instrument_with_precision("BTC-PERP", 2, 3)
466    }
467
468    fn create_eurusd_instrument() -> InstrumentAny {
469        create_instrument_with_precision("EURUSD-PERP", 4, 0)
470    }
471
472    fn create_instrument_with_precision(
473        symbol: &str,
474        price_precision: u8,
475        size_precision: u8,
476    ) -> InstrumentAny {
477        let underlying = Ustr::from(symbol.split('-').next().unwrap_or(symbol));
478        let price_increment =
479            Price::from_decimal_dp(Decimal::new(1, price_precision as u32), price_precision)
480                .unwrap();
481        let size_increment =
482            Quantity::from_decimal_dp(Decimal::new(1, size_precision as u32), size_precision)
483                .unwrap();
484
485        let instrument = PerpetualContract::builder()
486            .instrument_id(InstrumentId::new(Symbol::new(symbol), *AX_VENUE))
487            .raw_symbol(Symbol::new(symbol))
488            .underlying(underlying)
489            .asset_class(AssetClass::Cryptocurrency)
490            .quote_currency(Currency::USD())
491            .settlement_currency(Currency::USD())
492            .is_inverse(false)
493            .price_precision(price_precision)
494            .size_precision(size_precision)
495            .price_increment(price_increment)
496            .size_increment(size_increment)
497            .lot_size(size_increment)
498            .min_quantity(size_increment)
499            .margin_init(Decimal::new(1, 2))
500            .margin_maint(Decimal::new(5, 3))
501            .maker_fee(Decimal::new(2, 4))
502            .taker_fee(Decimal::new(5, 4))
503            .ts_event(UnixNanos::default())
504            .ts_init(UnixNanos::default())
505            .build()
506            .unwrap();
507        InstrumentAny::PerpetualContract(instrument)
508    }
509
510    #[rstest]
511    fn test_parse_book_l1_quote() {
512        let book = AxMdBookL1 {
513            ts: 1700000000,
514            tn: 12345,
515            s: Ustr::from("BTC-PERP"),
516            b: vec![AxBookLevel {
517                p: dec!(50000.50),
518                q: 100,
519            }],
520            a: vec![AxBookLevel {
521                p: dec!(50001.00),
522                q: 150,
523            }],
524        };
525
526        let instrument = create_test_instrument();
527        let ts_init = UnixNanos::default();
528
529        let quote = parse_book_l1_quote(&book, &instrument, ts_init).unwrap();
530
531        assert_eq!(quote.bid_price.as_f64(), 50000.50);
532        assert_eq!(quote.ask_price.as_f64(), 50001.00);
533        assert_eq!(quote.bid_size.as_f64(), 100.0);
534        assert_eq!(quote.ask_size.as_f64(), 150.0);
535    }
536
537    #[rstest]
538    fn test_parse_book_quotes_select_best_unsorted_levels() {
539        let l2 = AxMdBookL2 {
540            ts: 1700000000,
541            tn: 12345,
542            s: Ustr::from("BTC-PERP"),
543            b: vec![
544                AxBookLevel {
545                    p: dec!(50000.00),
546                    q: 200,
547                },
548                AxBookLevel {
549                    p: dec!(50000.50),
550                    q: 100,
551                },
552            ],
553            a: vec![
554                AxBookLevel {
555                    p: dec!(50001.50),
556                    q: 250,
557                },
558                AxBookLevel {
559                    p: dec!(50001.00),
560                    q: 150,
561                },
562            ],
563            st: false,
564        };
565        let l3 = AxMdBookL3 {
566            ts: 1700000000,
567            tn: 12345,
568            s: Ustr::from("BTC-PERP"),
569            b: vec![
570                AxBookLevelL3 {
571                    p: dec!(50000.00),
572                    q: 200,
573                    o: vec![200],
574                },
575                AxBookLevelL3 {
576                    p: dec!(50000.50),
577                    q: 100,
578                    o: vec![100],
579                },
580            ],
581            a: vec![
582                AxBookLevelL3 {
583                    p: dec!(50001.50),
584                    q: 250,
585                    o: vec![250],
586                },
587                AxBookLevelL3 {
588                    p: dec!(50001.00),
589                    q: 150,
590                    o: vec![150],
591                },
592            ],
593            st: false,
594        };
595        let instrument = create_test_instrument();
596        let ts_init = UnixNanos::default();
597
598        let l2_quote = parse_book_l2_quote(&l2, &instrument, ts_init).unwrap();
599        let l3_quote = parse_book_l3_quote(&l3, &instrument, ts_init).unwrap();
600
601        assert_eq!(l2_quote.bid_price.as_f64(), 50000.50);
602        assert_eq!(l2_quote.bid_size.as_f64(), 100.0);
603        assert_eq!(l2_quote.ask_price.as_f64(), 50001.00);
604        assert_eq!(l2_quote.ask_size.as_f64(), 150.0);
605        assert_eq!(l3_quote.bid_price.as_f64(), 50000.50);
606        assert_eq!(l3_quote.bid_size.as_f64(), 100.0);
607        assert_eq!(l3_quote.ask_price.as_f64(), 50001.00);
608        assert_eq!(l3_quote.ask_size.as_f64(), 150.0);
609    }
610
611    #[rstest]
612    fn test_parse_book_l2_deltas() {
613        let book = AxMdBookL2 {
614            ts: 1700000000,
615            tn: 12345,
616            s: Ustr::from("BTC-PERP"),
617            b: vec![
618                AxBookLevel {
619                    p: dec!(50000.50),
620                    q: 100,
621                },
622                AxBookLevel {
623                    p: dec!(50000.00),
624                    q: 200,
625                },
626            ],
627            a: vec![
628                AxBookLevel {
629                    p: dec!(50001.00),
630                    q: 150,
631                },
632                AxBookLevel {
633                    p: dec!(50001.50),
634                    q: 250,
635                },
636            ],
637            st: false,
638        };
639
640        let instrument = create_test_instrument();
641        let ts_init = UnixNanos::default();
642
643        let deltas = parse_book_l2_deltas(&book, &instrument, 1, ts_init).unwrap();
644
645        // 1 clear + 4 levels
646        assert_eq!(deltas.deltas.len(), 5);
647        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
648        assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy.into());
649        assert_eq!(deltas.deltas[3].order.side, OrderSide::Sell.into());
650
651        // Every delta in the snapshot sequence carries F_SNAPSHOT, and only the last F_LAST
652        for delta in &deltas.deltas {
653            assert_ne!(delta.flags & RecordFlag::F_SNAPSHOT as u8, 0);
654        }
655
656        for delta in &deltas.deltas[..deltas.deltas.len() - 1] {
657            assert_eq!(delta.flags & RecordFlag::F_LAST as u8, 0);
658        }
659
660        assert_ne!(
661            deltas.deltas.last().unwrap().flags & RecordFlag::F_LAST as u8,
662            0
663        );
664    }
665
666    #[rstest]
667    fn test_parse_book_l3_deltas() {
668        let book = AxMdBookL3 {
669            ts: 1700000000,
670            tn: 12345,
671            s: Ustr::from("BTC-PERP"),
672            b: vec![AxBookLevelL3 {
673                p: dec!(50000.50),
674                q: 300,
675                o: vec![100, 200],
676            }],
677            a: vec![AxBookLevelL3 {
678                p: dec!(50001.00),
679                q: 250,
680                o: vec![150, 100],
681            }],
682            st: false,
683        };
684
685        let instrument = create_test_instrument();
686        let ts_init = UnixNanos::default();
687
688        let deltas = parse_book_l3_deltas(&book, &instrument, 1, ts_init).unwrap();
689
690        // 1 clear + 4 individual orders
691        assert_eq!(deltas.deltas.len(), 5);
692        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
693
694        // Every delta in the snapshot sequence carries F_SNAPSHOT, and L3 orders are not MBP
695        for delta in &deltas.deltas {
696            assert_ne!(delta.flags & RecordFlag::F_SNAPSHOT as u8, 0);
697        }
698
699        for delta in &deltas.deltas[1..] {
700            assert_eq!(delta.flags & RecordFlag::F_MBP as u8, 0);
701        }
702    }
703
704    #[rstest]
705    fn test_parse_trade_tick() {
706        let trade = AxMdTrade {
707            ts: 1700000000,
708            tn: 12345,
709            s: Ustr::from("BTC-PERP"),
710            p: dec!(50000.50),
711            q: 100,
712            d: Some(AxOrderSide::Buy),
713        };
714
715        let instrument = create_test_instrument();
716        let ts_init = UnixNanos::default();
717
718        let tick = parse_trade_tick(&trade, &instrument, ts_init).unwrap();
719
720        assert_eq!(tick.price.as_f64(), 50000.50);
721        assert_eq!(tick.size.as_f64(), 100.0);
722        assert_eq!(tick.aggressor_side, AggressorSide::Buy);
723    }
724
725    #[rstest]
726    fn test_parse_book_l1_from_captured_data() {
727        let json = include_str!("../../../test_data/ws_md_book_l1_captured.json");
728        let book: AxMdBookL1 = serde_json::from_str(json).unwrap();
729
730        assert_eq!(book.s.as_str(), "EURUSD-PERP");
731        assert_eq!(book.b.len(), 1);
732        assert_eq!(book.a.len(), 1);
733
734        let instrument = create_eurusd_instrument();
735        let ts_init = UnixNanos::default();
736
737        let quote = parse_book_l1_quote(&book, &instrument, ts_init).unwrap();
738
739        assert_eq!(quote.instrument_id.symbol.as_str(), "EURUSD-PERP");
740        assert_eq!(quote.bid_price.as_f64(), 1.1712);
741        assert_eq!(quote.ask_price.as_f64(), 1.1717);
742        assert_eq!(quote.bid_size.as_f64(), 300.0);
743        assert_eq!(quote.ask_size.as_f64(), 100.0);
744    }
745
746    #[rstest]
747    fn test_parse_book_l2_from_captured_data() {
748        let json = include_str!("../../../test_data/ws_md_book_l2_captured.json");
749        let book: AxMdBookL2 = serde_json::from_str(json).unwrap();
750
751        assert_eq!(book.s.as_str(), "EURUSD-PERP");
752        assert_eq!(book.b.len(), 13);
753        assert_eq!(book.a.len(), 12);
754
755        let instrument = create_eurusd_instrument();
756        let ts_init = UnixNanos::default();
757
758        let deltas = parse_book_l2_deltas(&book, &instrument, 1, ts_init).unwrap();
759
760        // 1 clear + 13 bids + 12 asks = 26 deltas
761        assert_eq!(deltas.deltas.len(), 26);
762        assert_eq!(deltas.instrument_id.symbol.as_str(), "EURUSD-PERP");
763
764        // First delta should be clear
765        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
766
767        // Check first bid level
768        let first_bid = &deltas.deltas[1];
769        assert_eq!(first_bid.order.side, OrderSide::Buy.into());
770        assert_eq!(first_bid.order.price.as_f64(), 1.1712);
771        assert_eq!(first_bid.order.size.as_f64(), 300.0);
772
773        // Check first ask level (after 13 bids + 1 clear = index 14)
774        let first_ask = &deltas.deltas[14];
775        assert_eq!(first_ask.order.side, OrderSide::Sell.into());
776        assert_eq!(first_ask.order.price.as_f64(), 1.1719);
777        assert_eq!(first_ask.order.size.as_f64(), 400.0);
778
779        // Last delta should have F_LAST flag
780        let last_delta = deltas.deltas.last().unwrap();
781        assert!(last_delta.flags & RecordFlag::F_LAST as u8 != 0);
782    }
783
784    #[rstest]
785    fn test_parse_book_l3_from_captured_data() {
786        let json = include_str!("../../../test_data/ws_md_book_l3_captured.json");
787        let book: AxMdBookL3 = serde_json::from_str(json).unwrap();
788
789        assert_eq!(book.s.as_str(), "EURUSD-PERP");
790        assert_eq!(book.b.len(), 15);
791        assert_eq!(book.a.len(), 14);
792
793        let instrument = create_eurusd_instrument();
794        let ts_init = UnixNanos::default();
795
796        let deltas = parse_book_l3_deltas(&book, &instrument, 1, ts_init).unwrap();
797
798        // 1 clear + individual orders from each level
799        // Each level has one order in the captured data
800        assert_eq!(deltas.deltas.len(), 30); // 1 clear + 15 bids + 14 asks
801        assert_eq!(deltas.instrument_id.symbol.as_str(), "EURUSD-PERP");
802
803        // First delta should be clear
804        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
805
806        // Check first bid order
807        let first_bid = &deltas.deltas[1];
808        assert_eq!(first_bid.order.side, OrderSide::Buy.into());
809        assert_eq!(first_bid.order.price.as_f64(), 1.1714);
810        assert_eq!(first_bid.order.size.as_f64(), 100.0);
811
812        // Last delta should have F_LAST flag
813        let last_delta = deltas.deltas.last().unwrap();
814        assert!(last_delta.flags & RecordFlag::F_LAST as u8 != 0);
815    }
816
817    #[rstest]
818    fn test_parse_trade_from_captured_data() {
819        let json = include_str!("../../../test_data/ws_md_trade_captured.json");
820        let trade: AxMdTrade = serde_json::from_str(json).unwrap();
821
822        assert_eq!(trade.s.as_str(), "EURUSD-PERP");
823        assert_eq!(trade.p, dec!(1.1719));
824        assert_eq!(trade.q, 400);
825        assert_eq!(trade.d, Some(AxOrderSide::Buy));
826
827        let instrument = create_eurusd_instrument();
828        let ts_init = UnixNanos::default();
829
830        let tick = parse_trade_tick(&trade, &instrument, ts_init).unwrap();
831
832        assert_eq!(tick.instrument_id.symbol.as_str(), "EURUSD-PERP");
833        assert_eq!(tick.price.as_f64(), 1.1719);
834        assert_eq!(tick.size.as_f64(), 400.0);
835        assert_eq!(tick.aggressor_side, AggressorSide::Buy);
836        assert_eq!(
837            tick.trade_id.to_string(),
838            "1766193240334589144-38b4fe5a94a253d0"
839        );
840    }
841
842    #[rstest]
843    fn test_parse_trade_tick_matches_rest_trade_id_for_the_same_trade() {
844        // The same trade fetched from `GET /trades` and received on the market-data WebSocket must
845        // carry one identity, otherwise a request and subscribe overlap double-counts it.
846        let json = include_str!("../../../test_data/ws_md_trade_captured.json");
847        let ws_trade: AxMdTrade = serde_json::from_str(json).unwrap();
848        let rest_trade = AxRestTrade {
849            ts: ws_trade.ts,
850            tn: ws_trade.tn,
851            p: ws_trade.p,
852            q: ws_trade.q as i64,
853            s: ws_trade.s,
854            d: ws_trade.d.unwrap(),
855        };
856        let instrument = create_eurusd_instrument();
857        let ts_init = UnixNanos::default();
858
859        let ws_tick = parse_trade_tick(&ws_trade, &instrument, ts_init).unwrap();
860        let rest_tick = parse_rest_trade_tick(&rest_trade, &instrument, ts_init).unwrap();
861
862        assert_eq!(ws_tick.trade_id, rest_tick.trade_id);
863        assert_eq!(ws_tick.ts_event, rest_tick.ts_event);
864        assert_eq!(ws_tick.price, rest_tick.price);
865        assert_eq!(ws_tick.size, rest_tick.size);
866        assert_eq!(ws_tick.aggressor_side, rest_tick.aggressor_side);
867    }
868
869    #[rstest]
870    fn test_parse_book_l1_empty_sides() {
871        let book = AxMdBookL1 {
872            ts: 1700000000,
873            tn: 12345,
874            s: Ustr::from("TEST-PERP"),
875            b: vec![],
876            a: vec![],
877        };
878
879        let instrument = create_test_instrument();
880        let ts_init = UnixNanos::default();
881
882        let quote = parse_book_l1_quote(&book, &instrument, ts_init).unwrap();
883
884        assert_eq!(quote.bid_price.as_f64(), 0.0);
885        assert_eq!(quote.ask_price.as_f64(), 0.0);
886        assert_eq!(quote.bid_size.as_f64(), 0.0);
887        assert_eq!(quote.ask_size.as_f64(), 0.0);
888    }
889
890    #[rstest]
891    fn test_parse_book_l2_empty_book() {
892        let book = AxMdBookL2 {
893            ts: 1700000000,
894            tn: 12345,
895            s: Ustr::from("TEST-PERP"),
896            b: vec![],
897            a: vec![],
898            st: false,
899        };
900
901        let instrument = create_test_instrument();
902        let ts_init = UnixNanos::default();
903
904        let deltas = parse_book_l2_deltas(&book, &instrument, 1, ts_init).unwrap();
905
906        // Just clear delta with F_LAST
907        assert_eq!(deltas.deltas.len(), 1);
908        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
909        assert!(deltas.deltas[0].flags & RecordFlag::F_LAST as u8 != 0);
910    }
911
912    #[rstest]
913    fn test_parse_candle_bar() {
914        use crate::common::enums::AxCandleWidth;
915
916        let candle = AxMdCandle {
917            symbol: Ustr::from("BTC-PERP"),
918            ts: 1700000000,
919            open: dec!(50000.00),
920            high: dec!(51000.00),
921            low: dec!(49500.00),
922            close: dec!(50500.00),
923            volume: 1000,
924            buy_volume: 600,
925            sell_volume: 400,
926            width: AxCandleWidth::Minutes1,
927        };
928
929        let instrument = create_test_instrument();
930        let ts_init = UnixNanos::default();
931
932        let bar = parse_candle_bar(&candle, &instrument, ts_init).unwrap();
933
934        assert_eq!(bar.open.as_f64(), 50000.00);
935        assert_eq!(bar.high.as_f64(), 51000.00);
936        assert_eq!(bar.low.as_f64(), 49500.00);
937        assert_eq!(bar.close.as_f64(), 50500.00);
938        assert_eq!(bar.volume.as_f64(), 1000.0);
939        assert_eq!(bar.bar_type.instrument_id().symbol.as_str(), "BTC-PERP");
940    }
941
942    #[rstest]
943    fn test_parse_candle_from_test_data() {
944        let json = include_str!("../../../test_data/ws_md_candle.json");
945        let candle: AxMdCandle = serde_json::from_str(json).unwrap();
946
947        assert_eq!(candle.symbol.as_str(), "EURUSD-PERP");
948        assert_eq!(candle.open, dec!(49500.00));
949        assert_eq!(candle.close, dec!(50000.00));
950
951        let instrument = create_instrument_with_precision("EURUSD-PERP", 2, 3);
952        let ts_init = UnixNanos::default();
953
954        let bar = parse_candle_bar(&candle, &instrument, ts_init).unwrap();
955
956        assert_eq!(bar.open.as_f64(), 49500.00);
957        assert_eq!(bar.high.as_f64(), 50500.00);
958        assert_eq!(bar.low.as_f64(), 49000.00);
959        assert_eq!(bar.close.as_f64(), 50000.00);
960        assert_eq!(bar.volume.as_f64(), 5000.0);
961        assert_eq!(bar.bar_type.instrument_id().symbol.as_str(), "EURUSD-PERP");
962    }
963}