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::{Params, UUID4, nanos::UnixNanos};
20use nautilus_model::{
21    data::{Bar, BarSpecification, BarType, FundingRateUpdate, TradeTick},
22    enums::{
23        AccountType, AggregationSource, AggressorSide, AssetClass, BarAggregation, CurrencyType,
24        LiquiditySide, OrderSide, OrderType, PositionSideSpecified, PriceType,
25    },
26    events::AccountState,
27    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
28    instruments::{Instrument, PerpetualContract, any::InstrumentAny},
29    reports::{FillReport, OrderStatusReport, PositionStatusReport},
30    types::{AccountBalance, Currency, Money, Price, Quantity},
31};
32use rust_decimal::Decimal;
33use serde_json::json;
34use ustr::Ustr;
35
36use super::models::{
37    AxBalancesResponse, AxCandle, AxFill, AxFundingRate, AxInstrument, AxOpenOrder, AxPosition,
38    AxRestTrade,
39};
40use crate::common::{
41    consts::AX_VENUE,
42    enums::AxCandleWidth,
43    parse::{ax_timestamp_ns_to_unix_nanos, ax_timestamp_s_to_unix_nanos, cid_to_client_order_id},
44};
45
46fn decimal_to_price(value: Decimal, field_name: &str) -> anyhow::Result<Price> {
47    Price::from_decimal(value)
48        .with_context(|| format!("Failed to convert {field_name} Decimal to Price"))
49}
50
51fn decimal_to_quantity(value: Decimal, field_name: &str) -> anyhow::Result<Quantity> {
52    Quantity::from_decimal(value)
53        .with_context(|| format!("Failed to convert {field_name} Decimal to Quantity"))
54}
55
56fn decimal_to_price_dp(value: Decimal, precision: u8, field: &str) -> anyhow::Result<Price> {
57    Price::from_decimal_dp(value, precision).with_context(|| {
58        format!("Failed to construct Price for {field} with precision {precision}")
59    })
60}
61
62fn get_currency(code: &str) -> Currency {
63    Currency::try_from_str(code).unwrap_or_else(|| {
64        // Create new currency with precision 0 (whole units for equity perps)
65        let currency = Currency::new(code, 0, 0, code, CurrencyType::Crypto);
66        if let Err(e) = Currency::register(currency, false) {
67            log::warn!("Failed to register currency '{code}': {e}");
68        }
69        currency
70    })
71}
72
73/// Converts an Ax candle width to a Nautilus bar specification.
74#[must_use]
75pub fn candle_width_to_bar_spec(width: AxCandleWidth) -> BarSpecification {
76    match width {
77        AxCandleWidth::Seconds1 => {
78            BarSpecification::new(1, BarAggregation::Second, PriceType::Last)
79        }
80        AxCandleWidth::Seconds5 => {
81            BarSpecification::new(5, BarAggregation::Second, PriceType::Last)
82        }
83        AxCandleWidth::Minutes1 => {
84            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
85        }
86        AxCandleWidth::Minutes5 => {
87            BarSpecification::new(5, BarAggregation::Minute, PriceType::Last)
88        }
89        AxCandleWidth::Minutes15 => {
90            BarSpecification::new(15, BarAggregation::Minute, PriceType::Last)
91        }
92        AxCandleWidth::Hours1 => BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
93        AxCandleWidth::Days1 => BarSpecification::new(1, BarAggregation::Day, PriceType::Last),
94    }
95}
96
97/// Parses an Ax candle into a Nautilus Bar.
98///
99/// # Errors
100///
101/// Returns an error if any OHLCV field cannot be parsed.
102pub fn parse_bar(
103    candle: &AxCandle,
104    instrument: &InstrumentAny,
105    ts_init: UnixNanos,
106) -> anyhow::Result<Bar> {
107    let price_precision = instrument.price_precision();
108    let size_precision = instrument.size_precision();
109
110    let open = decimal_to_price_dp(candle.open, price_precision, "candle.open")?;
111    let high = decimal_to_price_dp(candle.high, price_precision, "candle.high")?;
112    let low = decimal_to_price_dp(candle.low, price_precision, "candle.low")?;
113    let close = decimal_to_price_dp(candle.close, price_precision, "candle.close")?;
114
115    // Ax provides volume as i64 contracts
116    let volume = Quantity::new(candle.volume as f64, size_precision);
117
118    let ts_event = ax_timestamp_s_to_unix_nanos(candle.ts)?;
119
120    let bar_spec = candle_width_to_bar_spec(candle.width);
121    let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
122
123    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
124        .context("Failed to construct Bar from Ax candle")
125}
126
127/// Parses an Ax funding rate into a Nautilus [`FundingRateUpdate`].
128///
129/// # Errors
130///
131/// Returns an error if the timestamp is invalid.
132pub fn parse_funding_rate(
133    ax_rate: &AxFundingRate,
134    instrument_id: InstrumentId,
135    ts_init: UnixNanos,
136) -> anyhow::Result<FundingRateUpdate> {
137    Ok(FundingRateUpdate::new(
138        instrument_id,
139        ax_rate.funding_rate,
140        None,
141        None, // AX doesn't provide next funding time
142        ax_timestamp_ns_to_unix_nanos(ax_rate.timestamp_ns)?,
143        ts_init,
144    ))
145}
146
147/// Parses an Ax perpetual futures instrument into a Nautilus [`PerpetualContract`].
148///
149/// # Errors
150///
151/// Returns an error if any required field cannot be parsed or is invalid.
152pub fn parse_perp_instrument(
153    definition: &AxInstrument,
154    maker_fee: Decimal,
155    taker_fee: Decimal,
156    ts_event: UnixNanos,
157    ts_init: UnixNanos,
158) -> anyhow::Result<InstrumentAny> {
159    let raw_symbol_str = definition.symbol.as_str();
160    let raw_symbol = Symbol::new(raw_symbol_str);
161    let instrument_id = InstrumentId::new(raw_symbol, *AX_VENUE);
162
163    let symbol_prefix = raw_symbol_str
164        .split('-')
165        .next()
166        .context("Failed to extract symbol prefix")?;
167
168    let underlying = Ustr::from(symbol_prefix);
169
170    // Derive base code by stripping quote currency suffix if present
171    // e.g. JPYUSD-PERP → base=JPY, BTC-PERP → base=BTC
172    let quote_code = definition.quote_currency.as_str();
173    let base_code = if symbol_prefix.ends_with(quote_code) && symbol_prefix.len() > quote_code.len()
174    {
175        &symbol_prefix[..symbol_prefix.len() - quote_code.len()]
176    } else {
177        symbol_prefix
178    };
179
180    let asset_class = match definition.category {
181        Some(category) => AssetClass::from(category),
182        None => match Currency::try_from_str(base_code) {
183            Some(currency) => match currency.currency_type {
184                CurrencyType::Fiat => AssetClass::FX,
185                CurrencyType::Crypto => AssetClass::Cryptocurrency,
186                CurrencyType::CommodityBacked => AssetClass::Commodity,
187            },
188            None => AssetClass::Alternative,
189        },
190    };
191
192    // Only resolve base currency for FX/crypto where the base code is a currency
193    let base_currency = match asset_class {
194        AssetClass::FX | AssetClass::Cryptocurrency => Some(get_currency(base_code)),
195        _ => None,
196    };
197
198    let quote_currency = get_currency(quote_code);
199    let settlement_currency = get_currency(definition.funding_settlement_currency.as_str());
200
201    let price_increment = decimal_to_price(definition.tick_size, "tick_size")?;
202    let size_increment = decimal_to_quantity(definition.minimum_order_size, "minimum_order_size")?;
203
204    let lot_size = Some(size_increment);
205    let min_quantity = Some(size_increment);
206
207    let margin_init = definition.initial_margin_pct;
208    let margin_maint = definition.maintenance_margin_pct;
209
210    let mut info = Params::new();
211
212    if let Some(ref desc) = definition.description {
213        info.insert("description".to_string(), json!(desc));
214    }
215
216    if let Some(ref s) = definition.contract_size {
217        info.insert("contract_size".to_string(), json!(s));
218    }
219
220    if let Some(ref s) = definition.contract_mark_price {
221        info.insert("contract_mark_price".to_string(), json!(s));
222    }
223
224    if let Some(ref s) = definition.price_quotation {
225        info.insert("price_quotation".to_string(), json!(s));
226    }
227
228    if let Some(ref s) = definition.underlying_benchmark_price {
229        info.insert("underlying_benchmark_price".to_string(), json!(s));
230    }
231
232    if let Some(ref s) = definition.price_bands {
233        info.insert("price_bands".to_string(), json!(s));
234    }
235
236    if let Some(v) = definition.funding_rate_cap_upper_pct {
237        info.insert(
238            "funding_rate_cap_upper_pct".to_string(),
239            json!(v.to_string()),
240        );
241    }
242
243    if let Some(v) = definition.funding_rate_cap_lower_pct {
244        info.insert(
245            "funding_rate_cap_lower_pct".to_string(),
246            json!(v.to_string()),
247        );
248    }
249
250    if let Some(v) = definition.price_band_upper_deviation_pct {
251        info.insert(
252            "price_band_upper_deviation_pct".to_string(),
253            json!(v.to_string()),
254        );
255    }
256
257    if let Some(v) = definition.price_band_lower_deviation_pct {
258        info.insert(
259            "price_band_lower_deviation_pct".to_string(),
260            json!(v.to_string()),
261        );
262    }
263
264    let instrument = PerpetualContract::new(
265        instrument_id,
266        raw_symbol,
267        underlying,
268        asset_class,
269        base_currency,
270        quote_currency,
271        settlement_currency,
272        false,
273        price_increment.precision,
274        size_increment.precision,
275        price_increment,
276        size_increment,
277        None,
278        lot_size,
279        None,
280        min_quantity,
281        None,
282        None,
283        None,
284        None,
285        Some(margin_init),
286        Some(margin_maint),
287        Some(maker_fee),
288        Some(taker_fee),
289        Some(info),
290        ts_event,
291        ts_init,
292    );
293
294    Ok(InstrumentAny::PerpetualContract(instrument))
295}
296
297/// Parses an Ax balances response into a Nautilus [`AccountState`].
298///
299/// Ax provides a simple balance structure with symbol and amount.
300/// The amount is treated as both total and free balance (no locked funds tracking).
301///
302/// # Errors
303///
304/// Returns an error if balance amount parsing fails.
305pub fn parse_account_state(
306    response: &AxBalancesResponse,
307    account_id: AccountId,
308    ts_event: UnixNanos,
309    ts_init: UnixNanos,
310) -> anyhow::Result<AccountState> {
311    let mut balances = Vec::with_capacity(response.balances.len());
312
313    for balance in &response.balances {
314        let symbol_str = balance.symbol.as_str().trim();
315        if symbol_str.is_empty() {
316            log::debug!("Skipping balance with empty symbol");
317            continue;
318        }
319
320        let currency = get_currency(symbol_str);
321
322        // The /balances endpoint does not include margin data, so locked
323        // is always zero here. The /risk-snapshot endpoint provides
324        // initial_margin_required_total which could be used, but that
325        // requires an additional HTTP call on every account state refresh.
326        let balance =
327            AccountBalance::from_total_and_locked(balance.amount, Decimal::ZERO, currency)
328                .with_context(|| format!("Failed to convert balance for {symbol_str}"))?;
329        balances.push(balance);
330    }
331
332    if balances.is_empty() {
333        let zero_currency = Currency::USD();
334        let zero_money = Money::new(0.0, zero_currency);
335        balances.push(AccountBalance::new(zero_money, zero_money, zero_money));
336    }
337
338    Ok(AccountState::new(
339        account_id,
340        AccountType::Margin,
341        balances,
342        vec![],
343        true,
344        UUID4::new(),
345        ts_event,
346        ts_init,
347        None,
348    ))
349}
350
351/// Parses an Ax open order into a Nautilus [`OrderStatusReport`].
352///
353/// The `cid_resolver` parameter is an optional function that resolves a `cid` (u64)
354/// to a `ClientOrderId`. This is needed because orders submitted via WebSocket use
355/// a hashed `cid` for correlation rather than storing the full `ClientOrderId` in the tag.
356///
357/// # Errors
358///
359/// Returns an error if:
360/// - Price or quantity fields cannot be parsed.
361/// - Timestamp conversion fails.
362pub fn parse_order_status_report<F>(
363    order: &AxOpenOrder,
364    account_id: AccountId,
365    instrument: &InstrumentAny,
366    ts_init: UnixNanos,
367    cid_resolver: Option<&F>,
368) -> anyhow::Result<OrderStatusReport>
369where
370    F: Fn(u64) -> Option<ClientOrderId>,
371{
372    let instrument_id = instrument.id();
373    let venue_order_id = VenueOrderId::new(&order.oid);
374    let order_side = order.d.into();
375    let order_status = order.o.into();
376    let time_in_force = order.tif.into();
377
378    // Ax only supports limit orders currently
379    let order_type = OrderType::Limit;
380
381    // Parse quantity (Ax uses i64 contracts)
382    let quantity = Quantity::new(order.q as f64, instrument.size_precision());
383    let filled_qty = Quantity::new(order.xq as f64, instrument.size_precision());
384
385    // Parse price
386    let price = decimal_to_price_dp(order.p, instrument.price_precision(), "order.p")?;
387
388    // Ax timestamps are in Unix epoch seconds
389    let ts_event = ax_timestamp_s_to_unix_nanos(order.ts)?;
390
391    let mut report = OrderStatusReport::new(
392        account_id,
393        instrument_id,
394        None,
395        venue_order_id,
396        order_side,
397        order_type,
398        time_in_force,
399        order_status,
400        quantity,
401        filled_qty,
402        ts_event,
403        ts_event,
404        ts_init,
405        Some(UUID4::new()),
406    );
407
408    if let Some(cid) = order.cid {
409        let client_order_id = cid_resolver
410            .and_then(|resolver| resolver(cid))
411            .unwrap_or_else(|| cid_to_client_order_id(cid));
412        report = report.with_client_order_id(client_order_id);
413    }
414
415    report = report.with_price(price);
416
417    // We don't set avg_px here since the order endpoint only provides the
418    // limit price, not actual fill prices. True average would need to be
419    // calculated from fill reports.
420
421    Ok(report)
422}
423
424/// Parses an Ax fill into a Nautilus [`FillReport`].
425///
426/// Note: Ax fills don't include order ID, side, or liquidity information
427/// in the fills endpoint response, so we use default values where necessary.
428///
429/// # Errors
430///
431/// Returns an error if:
432/// - Price or quantity fields cannot be parsed.
433/// - Fee parsing fails.
434pub fn parse_fill_report(
435    fill: &AxFill,
436    account_id: AccountId,
437    instrument: &InstrumentAny,
438    ts_init: UnixNanos,
439) -> anyhow::Result<FillReport> {
440    let instrument_id = instrument.id();
441
442    let venue_order_id = VenueOrderId::new(&fill.order_id);
443    let trade_id = TradeId::new_checked(&fill.trade_id).context("Invalid trade_id in Ax fill")?;
444
445    // Use explicit side field from fill
446    let order_side: OrderSide = fill.side.into();
447
448    let last_px = decimal_to_price_dp(fill.price, instrument.price_precision(), "fill.price")?;
449    let last_qty = Quantity::new(fill.quantity as f64, instrument.size_precision());
450
451    let currency = Currency::USD();
452    let commission = Money::from_decimal(fill.fee, currency)
453        .context("Failed to convert fill.fee Decimal to Money")?;
454
455    let liquidity_side = if fill.is_taker {
456        LiquiditySide::Taker
457    } else {
458        LiquiditySide::Maker
459    };
460
461    let ts_event = match fill.timestamp.timestamp_nanos_opt() {
462        Some(nanos) => UnixNanos::from(nanos.unsigned_abs()),
463        None => {
464            log::warn!(
465                "Timestamp overflow for fill {} (timestamp={}), defaulting to 0",
466                fill.trade_id,
467                fill.timestamp
468            );
469            UnixNanos::from(0u64)
470        }
471    };
472
473    Ok(FillReport::new(
474        account_id,
475        instrument_id,
476        venue_order_id,
477        trade_id,
478        order_side,
479        last_qty,
480        last_px,
481        commission,
482        liquidity_side,
483        None,
484        None,
485        ts_event,
486        ts_init,
487        None,
488    ))
489}
490
491/// Parses an Ax position into a Nautilus [`PositionStatusReport`].
492///
493/// # Errors
494///
495/// Returns an error if:
496/// - Position quantity parsing fails.
497/// - Timestamp conversion fails.
498pub fn parse_position_status_report(
499    position: &AxPosition,
500    account_id: AccountId,
501    instrument: &InstrumentAny,
502    ts_init: UnixNanos,
503) -> anyhow::Result<PositionStatusReport> {
504    let instrument_id = instrument.id();
505
506    // Determine position side and quantity from signed_quantity sign
507    let (position_side, quantity) = if position.signed_quantity > 0 {
508        (
509            PositionSideSpecified::Long,
510            Quantity::new(position.signed_quantity as f64, instrument.size_precision()),
511        )
512    } else if position.signed_quantity < 0 {
513        (
514            PositionSideSpecified::Short,
515            Quantity::new(
516                position.signed_quantity.unsigned_abs() as f64,
517                instrument.size_precision(),
518            ),
519        )
520    } else {
521        (
522            PositionSideSpecified::Flat,
523            Quantity::new(0.0, instrument.size_precision()),
524        )
525    };
526
527    // Calculate average entry price from notional / quantity
528    // Both signed_notional and signed_quantity are negative for shorts
529    let avg_px_open = if position.signed_quantity != 0 {
530        let qty_dec = Decimal::from(position.signed_quantity.abs());
531        Some(position.signed_notional.abs() / qty_dec)
532    } else {
533        None
534    };
535
536    let ts_last = match position.timestamp.timestamp_nanos_opt() {
537        Some(nanos) => UnixNanos::from(nanos.unsigned_abs()),
538        None => {
539            log::warn!(
540                "Timestamp overflow for position {} (timestamp={}), defaulting to 0",
541                position.symbol,
542                position.timestamp
543            );
544            UnixNanos::from(0u64)
545        }
546    };
547
548    Ok(PositionStatusReport::new(
549        account_id,
550        instrument_id,
551        position_side,
552        quantity,
553        ts_last,
554        ts_init,
555        None,
556        None,
557        avg_px_open,
558    ))
559}
560
561/// Parses an Ax REST trade into a Nautilus [`TradeTick`].
562///
563/// # Errors
564///
565/// Returns an error if any field cannot be parsed.
566pub fn parse_trade_tick(
567    trade: &AxRestTrade,
568    instrument: &InstrumentAny,
569    ts_init: UnixNanos,
570) -> anyhow::Result<TradeTick> {
571    let price = decimal_to_price_dp(trade.p, instrument.price_precision(), "trade.p")?;
572    let size = Quantity::new(trade.q as f64, instrument.size_precision());
573    let aggressor_side: AggressorSide = trade.d.into();
574
575    // Combine seconds + nanoseconds into full timestamp
576    let ts_event = UnixNanos::from(trade.ts as u64 * 1_000_000_000 + trade.tn as u64);
577
578    // Use nanosecond timestamp as trade ID (unique per trade)
579    let mut buf = itoa::Buffer::new();
580    let trade_id =
581        TradeId::new_checked(buf.format(ts_event.as_u64())).context("Failed to create TradeId")?;
582
583    TradeTick::new_checked(
584        instrument.id(),
585        price,
586        size,
587        aggressor_side,
588        trade_id,
589        ts_event,
590        ts_init,
591    )
592    .context("Failed to construct TradeTick from Ax REST trade")
593}
594
595#[cfg(test)]
596mod tests {
597    use nautilus_core::nanos::UnixNanos;
598    use rstest::rstest;
599    use rust_decimal_macros::dec;
600    use ustr::Ustr;
601
602    use super::*;
603    use crate::{
604        common::enums::{AxCategory, AxInstrumentState},
605        http::models::{AxFundingRatesResponse, AxInstrumentsResponse},
606    };
607
608    fn create_eurusd_instrument() -> AxInstrument {
609        AxInstrument {
610            symbol: Ustr::from("EURUSD-PERP"),
611            state: AxInstrumentState::Open,
612            multiplier: dec!(1),
613            minimum_order_size: dec!(100),
614            tick_size: dec!(0.0001),
615            quote_currency: Ustr::from("USD"),
616            funding_settlement_currency: Ustr::from("USD"),
617            category: Some(AxCategory::Fx),
618            maintenance_margin_pct: dec!(4.0),
619            initial_margin_pct: dec!(8.0),
620            contract_mark_price: Some("Average price on AX at London 4pm".to_string()),
621            contract_size: Some("1 Euro per contract".to_string()),
622            description: Some("Euro / US Dollar FX Perpetual Future".to_string()),
623            funding_calendar_schedule: None,
624            funding_frequency: None,
625            funding_rate_cap_lower_pct: Some(dec!(-1.0)),
626            funding_rate_cap_upper_pct: Some(dec!(1.0)),
627            price_band_lower_deviation_pct: Some(dec!(10)),
628            price_band_upper_deviation_pct: Some(dec!(10)),
629            price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
630            price_quotation: Some("U.S. dollars per Euro".to_string()),
631            underlying_benchmark_price: Some("WMR London 4pm Closing Spot Rate".to_string()),
632        }
633    }
634
635    fn create_nvda_instrument() -> AxInstrument {
636        AxInstrument {
637            symbol: Ustr::from("NVDA-PERP"),
638            state: AxInstrumentState::Open,
639            multiplier: dec!(1),
640            minimum_order_size: dec!(1),
641            tick_size: dec!(0.01),
642            quote_currency: Ustr::from("USD"),
643            funding_settlement_currency: Ustr::from("USD"),
644            category: Some(AxCategory::Equities),
645            maintenance_margin_pct: dec!(10),
646            initial_margin_pct: dec!(20),
647            contract_mark_price: Some(
648                "Average price on ArchitectX at 4pm New York Time".to_string(),
649            ),
650            contract_size: Some("1 share per contract".to_string()),
651            description: Some("NVIDIA Corp US Equity Perpetual Future".to_string()),
652            funding_calendar_schedule: None,
653            funding_frequency: None,
654            funding_rate_cap_lower_pct: Some(dec!(-1)),
655            funding_rate_cap_upper_pct: Some(dec!(1)),
656            price_band_lower_deviation_pct: Some(dec!(10)),
657            price_band_upper_deviation_pct: Some(dec!(10)),
658            price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
659            price_quotation: Some("U.S. dollars per share".to_string()),
660            underlying_benchmark_price: Some("Nasdaq Official Closing Price".to_string()),
661        }
662    }
663
664    fn create_xau_instrument() -> AxInstrument {
665        AxInstrument {
666            symbol: Ustr::from("XAU-PERP"),
667            state: AxInstrumentState::Open,
668            multiplier: dec!(1),
669            minimum_order_size: dec!(1),
670            tick_size: dec!(0.1),
671            quote_currency: Ustr::from("USD"),
672            funding_settlement_currency: Ustr::from("USD"),
673            category: Some(AxCategory::Metals),
674            maintenance_margin_pct: dec!(5),
675            initial_margin_pct: dec!(10),
676            contract_mark_price: Some("Average price on ArchitectX at London 4pm".to_string()),
677            contract_size: Some("1 ounce per contract".to_string()),
678            description: Some("Gold Metals Perpetual Future".to_string()),
679            funding_calendar_schedule: None,
680            funding_frequency: None,
681            funding_rate_cap_lower_pct: Some(dec!(-1)),
682            funding_rate_cap_upper_pct: Some(dec!(1)),
683            price_band_lower_deviation_pct: Some(dec!(10)),
684            price_band_upper_deviation_pct: Some(dec!(10)),
685            price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
686            price_quotation: Some("U.S. dollars per ounce".to_string()),
687            underlying_benchmark_price: Some("XAU WMR Metals Daily Closing Rate".to_string()),
688        }
689    }
690
691    #[rstest]
692    fn test_decimal_to_price() {
693        let price = decimal_to_price(dec!(100.50), "test_field").unwrap();
694        assert_eq!(price.as_f64(), 100.50);
695    }
696
697    #[rstest]
698    fn test_decimal_to_quantity() {
699        let qty = decimal_to_quantity(dec!(1.5), "test_field").unwrap();
700        assert_eq!(qty.as_f64(), 1.5);
701    }
702
703    #[rstest]
704    fn test_get_currency_known() {
705        let currency = get_currency("USD");
706        assert_eq!(currency.code, Ustr::from("USD"));
707        assert_eq!(currency.precision, 2);
708    }
709
710    #[rstest]
711    fn test_get_currency_unknown_creates_new() {
712        let currency = get_currency("NVDA");
713        assert_eq!(currency.code, Ustr::from("NVDA"));
714        assert_eq!(currency.precision, 0);
715    }
716
717    #[rstest]
718    fn test_parse_fx_instrument() {
719        let definition = create_eurusd_instrument();
720        let maker_fee = Decimal::new(2, 5);
721        let taker_fee = Decimal::new(2, 5);
722        let ts_now = UnixNanos::default();
723
724        let result = parse_perp_instrument(&definition, maker_fee, taker_fee, ts_now, ts_now);
725        assert!(result.is_ok());
726
727        let instrument = result.unwrap();
728        match instrument {
729            InstrumentAny::PerpetualContract(perp) => {
730                assert_eq!(perp.id.symbol.as_str(), "EURUSD-PERP");
731                assert_eq!(perp.id.venue, *AX_VENUE);
732                assert_eq!(perp.underlying.as_str(), "EURUSD");
733                assert_eq!(perp.asset_class, AssetClass::FX);
734                assert_eq!(perp.base_currency.unwrap().code.as_str(), "EUR");
735                assert_eq!(perp.quote_currency.code.as_str(), "USD");
736                assert_eq!(perp.settlement_currency.code.as_str(), "USD");
737                assert_eq!(perp.price_precision, 4);
738                assert!(!perp.is_inverse);
739            }
740            _ => panic!("Expected PerpetualContract instrument"),
741        }
742    }
743
744    #[rstest]
745    fn test_parse_equity_instrument() {
746        let definition = create_nvda_instrument();
747        let maker_fee = Decimal::new(2, 5);
748        let taker_fee = Decimal::new(2, 5);
749        let ts_now = UnixNanos::default();
750
751        let result = parse_perp_instrument(&definition, maker_fee, taker_fee, ts_now, ts_now);
752        assert!(result.is_ok());
753
754        let instrument = result.unwrap();
755        match instrument {
756            InstrumentAny::PerpetualContract(perp) => {
757                assert_eq!(perp.id.symbol.as_str(), "NVDA-PERP");
758                assert_eq!(perp.id.venue, *AX_VENUE);
759                assert_eq!(perp.underlying.as_str(), "NVDA");
760                assert_eq!(perp.asset_class, AssetClass::Equity);
761                assert_eq!(perp.quote_currency.code.as_str(), "USD");
762                assert_eq!(perp.settlement_currency.code.as_str(), "USD");
763                assert_eq!(perp.price_precision, 2);
764                assert!(!perp.is_inverse);
765            }
766            _ => panic!("Expected PerpetualContract instrument"),
767        }
768    }
769
770    #[rstest]
771    fn test_parse_metals_instrument() {
772        let definition = create_xau_instrument();
773        let ts_now = UnixNanos::default();
774
775        let result =
776            parse_perp_instrument(&definition, Decimal::ZERO, Decimal::ZERO, ts_now, ts_now);
777        let instrument = result.unwrap();
778        match instrument {
779            InstrumentAny::PerpetualContract(perp) => {
780                assert_eq!(perp.id.symbol.as_str(), "XAU-PERP");
781                assert_eq!(perp.underlying.as_str(), "XAU");
782                assert_eq!(perp.asset_class, AssetClass::Commodity);
783                assert!(perp.base_currency.is_none());
784                assert_eq!(perp.quote_currency.code.as_str(), "USD");
785                assert_eq!(perp.price_precision, 1);
786            }
787            _ => panic!("Expected PerpetualContract instrument"),
788        }
789    }
790
791    #[rstest]
792    fn test_parse_settlement_differs_from_quote() {
793        let mut definition = create_eurusd_instrument();
794        definition.funding_settlement_currency = Ustr::from("EUR");
795        let ts_now = UnixNanos::default();
796
797        let result =
798            parse_perp_instrument(&definition, Decimal::ZERO, Decimal::ZERO, ts_now, ts_now);
799        let instrument = result.unwrap();
800        match instrument {
801            InstrumentAny::PerpetualContract(perp) => {
802                assert_eq!(perp.quote_currency.code.as_str(), "USD");
803                assert_eq!(perp.settlement_currency.code.as_str(), "EUR");
804            }
805            _ => panic!("Expected PerpetualContract instrument"),
806        }
807    }
808
809    #[rstest]
810    fn test_parse_unknown_category_falls_back_to_alternative() {
811        let mut definition = create_eurusd_instrument();
812        definition.category = Some(AxCategory::Unknown);
813        let ts_now = UnixNanos::default();
814
815        let result =
816            parse_perp_instrument(&definition, Decimal::ZERO, Decimal::ZERO, ts_now, ts_now);
817        let instrument = result.unwrap();
818        match instrument {
819            InstrumentAny::PerpetualContract(perp) => {
820                assert_eq!(perp.asset_class, AssetClass::Alternative);
821            }
822            _ => panic!("Expected PerpetualContract instrument"),
823        }
824    }
825
826    #[rstest]
827    fn test_deserialize_instruments_from_test_data() {
828        let test_data = include_str!("../../test_data/http_get_instruments.json");
829        let response: AxInstrumentsResponse =
830            serde_json::from_str(test_data).expect("Failed to deserialize test data");
831
832        assert_eq!(response.instruments.len(), 3);
833
834        let eurusd = &response.instruments[0];
835        assert_eq!(eurusd.symbol.as_str(), "EURUSD-PERP");
836        assert_eq!(eurusd.category, Some(AxCategory::Fx));
837        assert_eq!(eurusd.tick_size, dec!(0.0001));
838        assert_eq!(eurusd.minimum_order_size, dec!(100));
839
840        let xau = &response.instruments[1];
841        assert_eq!(xau.symbol.as_str(), "XAU-PERP");
842        assert_eq!(xau.category, Some(AxCategory::Metals));
843
844        let nvda = &response.instruments[2];
845        assert_eq!(nvda.symbol.as_str(), "NVDA-PERP");
846        assert_eq!(nvda.category, Some(AxCategory::Equities));
847    }
848
849    #[rstest]
850    fn test_parse_all_instruments_from_test_data() {
851        let test_data = include_str!("../../test_data/http_get_instruments.json");
852        let response: AxInstrumentsResponse =
853            serde_json::from_str(test_data).expect("Failed to deserialize test data");
854
855        let maker_fee = Decimal::new(2, 4);
856        let taker_fee = Decimal::new(5, 4);
857        let ts_now = UnixNanos::default();
858
859        let open_instruments: Vec<_> = response
860            .instruments
861            .iter()
862            .filter(|i| i.state == AxInstrumentState::Open)
863            .collect();
864
865        assert_eq!(open_instruments.len(), 3);
866
867        for instrument in open_instruments {
868            let result = parse_perp_instrument(instrument, maker_fee, taker_fee, ts_now, ts_now);
869            assert!(
870                result.is_ok(),
871                "Failed to parse {}: {:?}",
872                instrument.symbol,
873                result.err()
874            );
875        }
876    }
877
878    #[rstest]
879    fn test_deserialize_and_parse_funding_rates() {
880        let test_data = include_str!("../../test_data/http_get_funding_rates.json");
881        let response: AxFundingRatesResponse =
882            serde_json::from_str(test_data).expect("Failed to deserialize test data");
883
884        assert_eq!(response.funding_rates.len(), 2);
885        assert_eq!(response.funding_rates[0].symbol.as_str(), "JPYUSD-PERP");
886        assert_eq!(response.funding_rates[0].funding_rate, dec!(0.001234560000));
887
888        let instrument_id = InstrumentId::new(Symbol::new("JPYUSD-PERP"), *AX_VENUE);
889        let ts_init = UnixNanos::from(1_000_000_000u64);
890
891        let update =
892            parse_funding_rate(&response.funding_rates[1], instrument_id, ts_init).unwrap();
893
894        assert_eq!(update.instrument_id, instrument_id);
895        assert_eq!(update.rate, dec!(0.003558290026));
896        assert_eq!(update.next_funding_ns, None);
897        assert_eq!(update.ts_event, UnixNanos::from(1770393600000000000u64));
898        assert_eq!(update.ts_init, ts_init);
899    }
900}