Skip to main content

nautilus_architect_ax/http/
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 HTTP responses to Nautilus domain types.
17
18use anyhow::Context;
19use nautilus_core::{UUID4, nanos::UnixNanos};
20use nautilus_model::{
21    data::{Bar, BarSpecification, BarType, FundingRateUpdate, TradeTick},
22    enums::{
23        AccountType, AggregationSource, AggressorSide, BarAggregation, CurrencyType, LiquiditySide,
24        OrderSide, OrderType, PositionSideSpecified, PriceType,
25    },
26    events::AccountState,
27    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
28    instruments::{CryptoPerpetual, Instrument, any::InstrumentAny},
29    reports::{FillReport, OrderStatusReport, PositionStatusReport},
30    types::{AccountBalance, Currency, Money, Price, Quantity},
31};
32use rust_decimal::Decimal;
33
34use super::models::{
35    AxBalancesResponse, AxCandle, AxFill, AxFundingRate, AxInstrument, AxOpenOrder, AxPosition,
36    AxRestTrade,
37};
38use crate::common::{
39    consts::AX_VENUE,
40    enums::AxCandleWidth,
41    parse::{ax_timestamp_ns_to_unix_nanos, ax_timestamp_s_to_unix_nanos, cid_to_client_order_id},
42};
43
44fn decimal_to_price(value: Decimal, field_name: &str) -> anyhow::Result<Price> {
45    Price::from_decimal(value)
46        .with_context(|| format!("Failed to convert {field_name} Decimal to Price"))
47}
48
49fn decimal_to_quantity(value: Decimal, field_name: &str) -> anyhow::Result<Quantity> {
50    Quantity::from_decimal(value)
51        .with_context(|| format!("Failed to convert {field_name} Decimal to Quantity"))
52}
53
54fn decimal_to_price_dp(value: Decimal, precision: u8, field: &str) -> anyhow::Result<Price> {
55    Price::from_decimal_dp(value, precision).with_context(|| {
56        format!("Failed to construct Price for {field} with precision {precision}")
57    })
58}
59
60// TODO: Define a new instrument type for equity perpetuals rather than using CryptoPerpetual
61// with a synthetic currency for the underlying stock. CurrencyType has no Equity variant,
62// so we use Crypto as a placeholder for these synthetic assets.
63fn get_currency(code: &str) -> Currency {
64    Currency::try_from_str(code).unwrap_or_else(|| {
65        // Create new currency with precision 0 (whole units for equity perps)
66        let currency = Currency::new(code, 0, 0, code, CurrencyType::Crypto);
67        if let Err(e) = Currency::register(currency, false) {
68            log::warn!("Failed to register currency '{code}': {e}");
69        }
70        currency
71    })
72}
73
74/// Converts an Ax candle width to a Nautilus bar specification.
75#[must_use]
76pub fn candle_width_to_bar_spec(width: AxCandleWidth) -> BarSpecification {
77    match width {
78        AxCandleWidth::Seconds1 => {
79            BarSpecification::new(1, BarAggregation::Second, PriceType::Last)
80        }
81        AxCandleWidth::Seconds5 => {
82            BarSpecification::new(5, BarAggregation::Second, PriceType::Last)
83        }
84        AxCandleWidth::Minutes1 => {
85            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
86        }
87        AxCandleWidth::Minutes5 => {
88            BarSpecification::new(5, BarAggregation::Minute, PriceType::Last)
89        }
90        AxCandleWidth::Minutes15 => {
91            BarSpecification::new(15, BarAggregation::Minute, PriceType::Last)
92        }
93        AxCandleWidth::Hours1 => BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
94        AxCandleWidth::Days1 => BarSpecification::new(1, BarAggregation::Day, PriceType::Last),
95    }
96}
97
98/// Parses an Ax candle into a Nautilus Bar.
99///
100/// # Errors
101///
102/// Returns an error if any OHLCV field cannot be parsed.
103pub fn parse_bar(
104    candle: &AxCandle,
105    instrument: &InstrumentAny,
106    ts_init: UnixNanos,
107) -> anyhow::Result<Bar> {
108    let price_precision = instrument.price_precision();
109    let size_precision = instrument.size_precision();
110
111    let open = decimal_to_price_dp(candle.open, price_precision, "candle.open")?;
112    let high = decimal_to_price_dp(candle.high, price_precision, "candle.high")?;
113    let low = decimal_to_price_dp(candle.low, price_precision, "candle.low")?;
114    let close = decimal_to_price_dp(candle.close, price_precision, "candle.close")?;
115
116    // Ax provides volume as i64 contracts
117    let volume = Quantity::new(candle.volume as f64, size_precision);
118
119    let ts_event = ax_timestamp_s_to_unix_nanos(candle.ts);
120
121    let bar_spec = candle_width_to_bar_spec(candle.width);
122    let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
123
124    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
125        .context("Failed to construct Bar from Ax candle")
126}
127
128/// Parses an Ax funding rate into a Nautilus [`FundingRateUpdate`].
129#[must_use]
130pub fn parse_funding_rate(
131    ax_rate: &AxFundingRate,
132    instrument_id: InstrumentId,
133    ts_init: UnixNanos,
134) -> FundingRateUpdate {
135    FundingRateUpdate::new(
136        instrument_id,
137        ax_rate.funding_rate,
138        None, // AX doesn't provide next funding time
139        ax_timestamp_ns_to_unix_nanos(ax_rate.timestamp_ns),
140        ts_init,
141    )
142}
143
144/// Parses an Ax perpetual futures instrument into a Nautilus CryptoPerpetual.
145///
146/// # Errors
147///
148/// Returns an error if any required field cannot be parsed or is invalid.
149pub fn parse_perp_instrument(
150    definition: &AxInstrument,
151    maker_fee: Decimal,
152    taker_fee: Decimal,
153    ts_event: UnixNanos,
154    ts_init: UnixNanos,
155) -> anyhow::Result<InstrumentAny> {
156    let raw_symbol_str = definition.symbol.as_str();
157    let raw_symbol = Symbol::new(raw_symbol_str);
158    let instrument_id = InstrumentId::new(raw_symbol, *AX_VENUE);
159
160    // Extract base currency from symbol:
161    // - Crypto: BTC-PERP → base=BTC
162    // - FX: JPYUSD-PERP → base=JPY (strip quote currency suffix)
163    let symbol_prefix = raw_symbol_str
164        .split('-')
165        .next()
166        .context("Failed to extract symbol prefix")?;
167
168    let quote_code = definition.quote_currency.as_str();
169    let base_code = if symbol_prefix.ends_with(quote_code) && symbol_prefix.len() > quote_code.len()
170    {
171        &symbol_prefix[..symbol_prefix.len() - quote_code.len()]
172    } else {
173        symbol_prefix
174    };
175    let base_currency = get_currency(base_code);
176    let quote_currency = get_currency(quote_code);
177    let settlement_currency = quote_currency;
178
179    let price_increment = decimal_to_price(definition.tick_size, "tick_size")?;
180    let size_increment = decimal_to_quantity(definition.minimum_order_size, "minimum_order_size")?;
181
182    let lot_size = Some(size_increment);
183    let min_quantity = Some(size_increment);
184
185    let margin_init = definition.initial_margin_pct;
186    let margin_maint = definition.maintenance_margin_pct;
187
188    let instrument = CryptoPerpetual::new(
189        instrument_id,
190        raw_symbol,
191        base_currency,
192        quote_currency,
193        settlement_currency,
194        false, // Ax perps are linear/USDT-margined
195        price_increment.precision,
196        size_increment.precision,
197        price_increment,
198        size_increment,
199        None,
200        lot_size,
201        None,
202        min_quantity,
203        None,
204        None,
205        None,
206        None,
207        Some(margin_init),
208        Some(margin_maint),
209        Some(maker_fee),
210        Some(taker_fee),
211        ts_event,
212        ts_init,
213    );
214
215    Ok(InstrumentAny::CryptoPerpetual(instrument))
216}
217
218/// Parses an Ax balances response into a Nautilus [`AccountState`].
219///
220/// Ax provides a simple balance structure with symbol and amount.
221/// The amount is treated as both total and free balance (no locked funds tracking).
222///
223/// # Errors
224///
225/// Returns an error if balance amount parsing fails.
226pub fn parse_account_state(
227    response: &AxBalancesResponse,
228    account_id: AccountId,
229    ts_event: UnixNanos,
230    ts_init: UnixNanos,
231) -> anyhow::Result<AccountState> {
232    let mut balances = Vec::with_capacity(response.balances.len());
233
234    for balance in &response.balances {
235        let symbol_str = balance.symbol.as_str().trim();
236        if symbol_str.is_empty() {
237            log::debug!("Skipping balance with empty symbol");
238            continue;
239        }
240
241        let currency = get_currency(symbol_str);
242
243        let total = Money::from_decimal(balance.amount, currency)
244            .with_context(|| format!("Failed to convert balance for {symbol_str}"))?;
245        let locked = Money::new(0.0, currency);
246        let free = total;
247
248        balances.push(AccountBalance::new(total, locked, free));
249    }
250
251    if balances.is_empty() {
252        let zero_currency = Currency::USD();
253        let zero_money = Money::new(0.0, zero_currency);
254        balances.push(AccountBalance::new(zero_money, zero_money, zero_money));
255    }
256
257    Ok(AccountState::new(
258        account_id,
259        AccountType::Margin,
260        balances,
261        vec![],
262        true,
263        UUID4::new(),
264        ts_event,
265        ts_init,
266        None,
267    ))
268}
269
270/// Parses an Ax open order into a Nautilus [`OrderStatusReport`].
271///
272/// The `cid_resolver` parameter is an optional function that resolves a `cid` (u64)
273/// to a `ClientOrderId`. This is needed because orders submitted via WebSocket use
274/// a hashed `cid` for correlation rather than storing the full `ClientOrderId` in the tag.
275///
276/// # Errors
277///
278/// Returns an error if:
279/// - Price or quantity fields cannot be parsed.
280/// - Timestamp conversion fails.
281pub fn parse_order_status_report<F>(
282    order: &AxOpenOrder,
283    account_id: AccountId,
284    instrument: &InstrumentAny,
285    ts_init: UnixNanos,
286    cid_resolver: Option<F>,
287) -> anyhow::Result<OrderStatusReport>
288where
289    F: Fn(u64) -> Option<ClientOrderId>,
290{
291    let instrument_id = instrument.id();
292    let venue_order_id = VenueOrderId::new(&order.oid);
293    let order_side = order.d.into();
294    let order_status = order.o.into();
295    let time_in_force = order.tif.into();
296
297    // Ax only supports limit orders currently
298    let order_type = OrderType::Limit;
299
300    // Parse quantity (Ax uses i64 contracts)
301    let quantity = Quantity::new(order.q as f64, instrument.size_precision());
302    let filled_qty = Quantity::new(order.xq as f64, instrument.size_precision());
303
304    // Parse price
305    let price = decimal_to_price_dp(order.p, instrument.price_precision(), "order.p")?;
306
307    // Ax timestamps are in Unix epoch seconds
308    let ts_event = ax_timestamp_s_to_unix_nanos(order.ts);
309
310    let mut report = OrderStatusReport::new(
311        account_id,
312        instrument_id,
313        None,
314        venue_order_id,
315        order_side,
316        order_type,
317        time_in_force,
318        order_status,
319        quantity,
320        filled_qty,
321        ts_event,
322        ts_event,
323        ts_init,
324        Some(UUID4::new()),
325    );
326
327    if let Some(cid) = order.cid {
328        let client_order_id = cid_resolver
329            .as_ref()
330            .and_then(|resolver| resolver(cid))
331            .unwrap_or_else(|| cid_to_client_order_id(cid));
332        report = report.with_client_order_id(client_order_id);
333    }
334
335    report = report.with_price(price);
336
337    // We don't set avg_px here since the order endpoint only provides the
338    // limit price, not actual fill prices. True average would need to be
339    // calculated from fill reports.
340
341    Ok(report)
342}
343
344/// Parses an Ax fill into a Nautilus [`FillReport`].
345///
346/// Note: Ax fills don't include order ID, side, or liquidity information
347/// in the fills endpoint response, so we use default values where necessary.
348///
349/// # Errors
350///
351/// Returns an error if:
352/// - Price or quantity fields cannot be parsed.
353/// - Fee parsing fails.
354pub fn parse_fill_report(
355    fill: &AxFill,
356    account_id: AccountId,
357    instrument: &InstrumentAny,
358    ts_init: UnixNanos,
359) -> anyhow::Result<FillReport> {
360    let instrument_id = instrument.id();
361
362    let venue_order_id = VenueOrderId::new(&fill.order_id);
363    let trade_id = TradeId::new_checked(&fill.trade_id).context("Invalid trade_id in Ax fill")?;
364
365    // Use explicit side field from fill
366    let order_side: OrderSide = fill.side.into();
367
368    let last_px = decimal_to_price_dp(fill.price, instrument.price_precision(), "fill.price")?;
369    let last_qty = Quantity::new(fill.quantity as f64, instrument.size_precision());
370
371    let currency = Currency::USD();
372    let commission = Money::from_decimal(fill.fee, currency)
373        .context("Failed to convert fill.fee Decimal to Money")?;
374
375    let liquidity_side = if fill.is_taker {
376        LiquiditySide::Taker
377    } else {
378        LiquiditySide::Maker
379    };
380
381    let ts_event = UnixNanos::from(
382        fill.timestamp
383            .timestamp_nanos_opt()
384            .unwrap_or(0)
385            .unsigned_abs(),
386    );
387
388    Ok(FillReport::new(
389        account_id,
390        instrument_id,
391        venue_order_id,
392        trade_id,
393        order_side,
394        last_qty,
395        last_px,
396        commission,
397        liquidity_side,
398        None,
399        None,
400        ts_event,
401        ts_init,
402        None,
403    ))
404}
405
406/// Parses an Ax position into a Nautilus [`PositionStatusReport`].
407///
408/// # Errors
409///
410/// Returns an error if:
411/// - Position quantity parsing fails.
412/// - Timestamp conversion fails.
413pub fn parse_position_status_report(
414    position: &AxPosition,
415    account_id: AccountId,
416    instrument: &InstrumentAny,
417    ts_init: UnixNanos,
418) -> anyhow::Result<PositionStatusReport> {
419    let instrument_id = instrument.id();
420
421    // Determine position side and quantity from signed_quantity sign
422    let (position_side, quantity) = if position.signed_quantity > 0 {
423        (
424            PositionSideSpecified::Long,
425            Quantity::new(position.signed_quantity as f64, instrument.size_precision()),
426        )
427    } else if position.signed_quantity < 0 {
428        (
429            PositionSideSpecified::Short,
430            Quantity::new(
431                position.signed_quantity.unsigned_abs() as f64,
432                instrument.size_precision(),
433            ),
434        )
435    } else {
436        (
437            PositionSideSpecified::Flat,
438            Quantity::new(0.0, instrument.size_precision()),
439        )
440    };
441
442    // Calculate average entry price from notional / quantity
443    // Both signed_notional and signed_quantity are negative for shorts
444    let avg_px_open = if position.signed_quantity != 0 {
445        let qty_dec = Decimal::from(position.signed_quantity.abs());
446        Some(position.signed_notional.abs() / qty_dec)
447    } else {
448        None
449    };
450
451    let ts_last = UnixNanos::from(
452        position
453            .timestamp
454            .timestamp_nanos_opt()
455            .unwrap_or(0)
456            .unsigned_abs(),
457    );
458
459    Ok(PositionStatusReport::new(
460        account_id,
461        instrument_id,
462        position_side,
463        quantity,
464        ts_last,
465        ts_init,
466        None,
467        None,
468        avg_px_open,
469    ))
470}
471
472/// Parses an Ax REST trade into a Nautilus [`TradeTick`].
473///
474/// # Errors
475///
476/// Returns an error if any field cannot be parsed.
477pub fn parse_trade_tick(
478    trade: &AxRestTrade,
479    instrument: &InstrumentAny,
480    ts_init: UnixNanos,
481) -> anyhow::Result<TradeTick> {
482    let price = decimal_to_price_dp(trade.p, instrument.price_precision(), "trade.p")?;
483    let size = Quantity::new(trade.q as f64, instrument.size_precision());
484    let aggressor_side: AggressorSide = trade.d.into();
485
486    // Combine seconds + nanoseconds into full timestamp
487    let ts_event = UnixNanos::from(trade.ts as u64 * 1_000_000_000 + trade.tn as u64);
488
489    // Use nanosecond timestamp as trade ID (unique per trade)
490    let mut buf = itoa::Buffer::new();
491    let trade_id =
492        TradeId::new_checked(buf.format(ts_event.as_u64())).context("Failed to create TradeId")?;
493
494    TradeTick::new_checked(
495        instrument.id(),
496        price,
497        size,
498        aggressor_side,
499        trade_id,
500        ts_event,
501        ts_init,
502    )
503    .context("Failed to construct TradeTick from Ax REST trade")
504}
505
506#[cfg(test)]
507mod tests {
508    use nautilus_core::nanos::UnixNanos;
509    use rstest::rstest;
510    use rust_decimal_macros::dec;
511    use ustr::Ustr;
512
513    use super::*;
514    use crate::{
515        common::enums::AxInstrumentState,
516        http::models::{AxFundingRatesResponse, AxInstrumentsResponse},
517    };
518
519    fn create_test_instrument() -> AxInstrument {
520        AxInstrument {
521            symbol: Ustr::from("BTC-PERP"),
522            state: AxInstrumentState::Open,
523            multiplier: dec!(1.0),
524            minimum_order_size: dec!(0.001),
525            tick_size: dec!(0.5),
526            quote_currency: Ustr::from("USD"),
527            funding_settlement_currency: Ustr::from("USD"),
528            maintenance_margin_pct: dec!(0.005),
529            initial_margin_pct: dec!(0.01),
530            contract_mark_price: Some("45000.50".to_string()),
531            contract_size: Some("1 BTC per contract".to_string()),
532            description: Some("Bitcoin Perpetual Futures".to_string()),
533            funding_calendar_schedule: Some("0,8,16".to_string()),
534            funding_frequency: Some("8h".to_string()),
535            funding_rate_cap_lower_pct: Some(dec!(-0.0075)),
536            funding_rate_cap_upper_pct: Some(dec!(0.0075)),
537            price_band_lower_deviation_pct: Some(dec!(0.05)),
538            price_band_upper_deviation_pct: Some(dec!(0.05)),
539            price_bands: Some("dynamic".to_string()),
540            price_quotation: Some("USD".to_string()),
541            underlying_benchmark_price: Some("CME CF BRR".to_string()),
542        }
543    }
544
545    #[rstest]
546    fn test_decimal_to_price() {
547        let price = decimal_to_price(dec!(100.50), "test_field").unwrap();
548        assert_eq!(price.as_f64(), 100.50);
549    }
550
551    #[rstest]
552    fn test_decimal_to_quantity() {
553        let qty = decimal_to_quantity(dec!(1.5), "test_field").unwrap();
554        assert_eq!(qty.as_f64(), 1.5);
555    }
556
557    #[rstest]
558    fn test_get_currency_known() {
559        let currency = get_currency("USD");
560        assert_eq!(currency.code, Ustr::from("USD"));
561        assert_eq!(currency.precision, 2);
562    }
563
564    #[rstest]
565    fn test_get_currency_unknown_creates_new() {
566        // Unknown currencies (like stock tickers) should be created with precision 0
567        let currency = get_currency("NVDA");
568        assert_eq!(currency.code, Ustr::from("NVDA"));
569        assert_eq!(currency.precision, 0);
570    }
571
572    #[rstest]
573    fn test_parse_perp_instrument() {
574        let definition = create_test_instrument();
575        let maker_fee = Decimal::new(2, 4);
576        let taker_fee = Decimal::new(5, 4);
577        let ts_now = UnixNanos::default();
578
579        let result = parse_perp_instrument(&definition, maker_fee, taker_fee, ts_now, ts_now);
580        assert!(result.is_ok());
581
582        let instrument = result.unwrap();
583        match instrument {
584            InstrumentAny::CryptoPerpetual(perp) => {
585                assert_eq!(perp.id.symbol.as_str(), "BTC-PERP");
586                assert_eq!(perp.id.venue, *AX_VENUE);
587                assert_eq!(perp.base_currency.code.as_str(), "BTC");
588                assert_eq!(perp.quote_currency.code.as_str(), "USD");
589                assert!(!perp.is_inverse);
590            }
591            _ => panic!("Expected CryptoPerpetual instrument"),
592        }
593    }
594
595    #[rstest]
596    fn test_deserialize_instruments_from_test_data() {
597        let test_data = include_str!("../../test_data/http_get_instruments.json");
598        let response: AxInstrumentsResponse =
599            serde_json::from_str(test_data).expect("Failed to deserialize test data");
600
601        assert_eq!(response.instruments.len(), 4);
602
603        let btcusd = &response.instruments[0];
604        assert_eq!(btcusd.symbol.as_str(), "BTCUSD-PERP");
605        assert_eq!(btcusd.state, AxInstrumentState::Open);
606
607        let btc = &response.instruments[1];
608        assert_eq!(btc.symbol.as_str(), "BTC-PERP");
609        assert_eq!(btc.state, AxInstrumentState::Open);
610        assert_eq!(btc.tick_size, dec!(0.5));
611        assert_eq!(btc.minimum_order_size, dec!(0.001));
612        assert!(btc.contract_mark_price.is_some());
613
614        let eth = &response.instruments[2];
615        assert_eq!(eth.symbol.as_str(), "ETH-PERP");
616        assert_eq!(eth.state, AxInstrumentState::Open);
617
618        // SOL-PERP is suspended with null optional fields
619        let sol = &response.instruments[3];
620        assert_eq!(sol.symbol.as_str(), "SOL-PERP");
621        assert_eq!(sol.state, AxInstrumentState::Suspended);
622        assert!(sol.contract_mark_price.is_none());
623        assert!(sol.funding_frequency.is_none());
624    }
625
626    #[rstest]
627    fn test_parse_all_instruments_from_test_data() {
628        let test_data = include_str!("../../test_data/http_get_instruments.json");
629        let response: AxInstrumentsResponse =
630            serde_json::from_str(test_data).expect("Failed to deserialize test data");
631
632        let maker_fee = Decimal::new(2, 4);
633        let taker_fee = Decimal::new(5, 4);
634        let ts_now = UnixNanos::default();
635
636        let open_instruments: Vec<_> = response
637            .instruments
638            .iter()
639            .filter(|i| i.state == AxInstrumentState::Open)
640            .collect();
641
642        assert_eq!(open_instruments.len(), 3);
643
644        for instrument in open_instruments {
645            let result = parse_perp_instrument(instrument, maker_fee, taker_fee, ts_now, ts_now);
646            assert!(
647                result.is_ok(),
648                "Failed to parse {}: {:?}",
649                instrument.symbol,
650                result.err()
651            );
652        }
653    }
654
655    #[rstest]
656    fn test_deserialize_and_parse_funding_rates() {
657        let test_data = include_str!("../../test_data/http_get_funding_rates.json");
658        let response: AxFundingRatesResponse =
659            serde_json::from_str(test_data).expect("Failed to deserialize test data");
660
661        assert_eq!(response.funding_rates.len(), 2);
662        assert_eq!(response.funding_rates[0].symbol.as_str(), "JPYUSD-PERP");
663        assert_eq!(response.funding_rates[0].funding_rate, dec!(0.001234560000));
664
665        let instrument_id = InstrumentId::new(Symbol::new("JPYUSD-PERP"), *AX_VENUE);
666        let ts_init = UnixNanos::from(1_000_000_000u64);
667
668        let update = parse_funding_rate(&response.funding_rates[1], instrument_id, ts_init);
669
670        assert_eq!(update.instrument_id, instrument_id);
671        assert_eq!(update.rate, dec!(0.003558290026));
672        assert_eq!(update.next_funding_ns, None);
673        assert_eq!(update.ts_event, UnixNanos::from(1770393600000000000u64));
674        assert_eq!(update.ts_init, ts_init);
675    }
676}