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