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 jiff::tz::Offset;
20use nautilus_core::{Params, UUID4, datetime::datetime_to_unix_nanos, nanos::UnixNanos};
21use nautilus_model::{
22    data::{Bar, BarSpecification, BarType, FundingRateUpdate, TradeTick},
23    enums::{
24        AccountType, AggregationSource, AggressorSide, AssetClass, BarAggregation, CurrencyType,
25        LiquiditySide, OrderSide, OrderType, PositionSide, PriceType,
26    },
27    events::AccountState,
28    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
29    instruments::{FuturesContract, Instrument, PerpetualContract, any::InstrumentAny},
30    reports::{FillReport, OrderStatusReport, PositionStatusReport},
31    types::{AccountBalance, Currency, Money, Price, Quantity},
32};
33use rust_decimal::Decimal;
34use serde_json::json;
35use ustr::Ustr;
36
37use super::models::{
38    AxBalancesResponse, AxCandle, AxFill, AxFundingRate, AxInstrument, AxOpenOrder, AxOrderDetail,
39    AxPosition, AxRestTrade,
40};
41use crate::common::{
42    consts::AX_VENUE,
43    enums::{AxCandleWidth, AxOrderSide, AxOrderStatus, AxTimeInForce},
44    parse::{
45        ax_timestamp_ns_to_unix_nanos, ax_timestamp_s_to_unix_nanos,
46        ax_timestamp_stn_to_unix_nanos, cid_to_client_order_id, create_architect_trade_id,
47    },
48};
49
50fn decimal_to_price(value: Decimal, field_name: &str) -> anyhow::Result<Price> {
51    Price::from_decimal(value)
52        .with_context(|| format!("Failed to convert {field_name} Decimal to Price"))
53}
54
55fn decimal_to_quantity(value: Decimal, field_name: &str) -> anyhow::Result<Quantity> {
56    Quantity::from_decimal(value)
57        .with_context(|| format!("Failed to convert {field_name} Decimal to Quantity"))
58}
59
60fn decimal_to_price_dp(value: Decimal, precision: u8, field: &str) -> anyhow::Result<Price> {
61    Price::from_decimal_dp(value, precision).with_context(|| {
62        format!("Failed to construct Price for {field} with precision {precision}")
63    })
64}
65
66fn get_currency(code: &str) -> Currency {
67    Currency::try_from_str(code).unwrap_or_else(|| {
68        // Create new currency with precision 0 (whole units for equity perps)
69        let currency = Currency::new(code, 0, 0, code, CurrencyType::Crypto);
70        if let Err(e) = Currency::register(currency, false) {
71            log::warn!("Failed to register currency '{code}': {e}");
72        }
73        currency
74    })
75}
76
77/// Converts an Ax candle width to a Nautilus bar specification.
78#[must_use]
79pub fn candle_width_to_bar_spec(width: AxCandleWidth) -> BarSpecification {
80    match width {
81        AxCandleWidth::Seconds1 => {
82            BarSpecification::new(1, BarAggregation::Second, PriceType::Last)
83        }
84        AxCandleWidth::Seconds5 => {
85            BarSpecification::new(5, BarAggregation::Second, PriceType::Last)
86        }
87        AxCandleWidth::Minutes1 => {
88            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
89        }
90        AxCandleWidth::Minutes5 => {
91            BarSpecification::new(5, BarAggregation::Minute, PriceType::Last)
92        }
93        AxCandleWidth::Minutes15 => {
94            BarSpecification::new(15, BarAggregation::Minute, PriceType::Last)
95        }
96        AxCandleWidth::Hours1 => BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
97        AxCandleWidth::Days1 => BarSpecification::new(1, BarAggregation::Day, PriceType::Last),
98    }
99}
100
101/// Parses an Ax candle into a Nautilus Bar.
102///
103/// # Errors
104///
105/// Returns an error if any OHLCV field cannot be parsed.
106pub fn parse_bar(
107    candle: &AxCandle,
108    instrument: &InstrumentAny,
109    ts_init: UnixNanos,
110) -> anyhow::Result<Bar> {
111    let price_precision = instrument.price_precision();
112    let size_precision = instrument.size_precision();
113
114    let open = decimal_to_price_dp(candle.open, price_precision, "candle.open")?;
115    let high = decimal_to_price_dp(candle.high, price_precision, "candle.high")?;
116    let low = decimal_to_price_dp(candle.low, price_precision, "candle.low")?;
117    let close = decimal_to_price_dp(candle.close, price_precision, "candle.close")?;
118
119    // Ax provides volume as i64 contracts
120    let volume = Quantity::new(candle.volume as f64, size_precision);
121
122    let ts_event = ax_timestamp_s_to_unix_nanos(candle.ts)?;
123
124    let bar_spec = candle_width_to_bar_spec(candle.width);
125    let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
126
127    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
128        .context("Failed to construct Bar from Ax candle")
129}
130
131/// Parses an Ax funding rate into a Nautilus [`FundingRateUpdate`].
132///
133/// # Errors
134///
135/// Returns an error if the timestamp is invalid.
136pub fn parse_funding_rate(
137    ax_rate: &AxFundingRate,
138    instrument_id: InstrumentId,
139    ts_init: UnixNanos,
140) -> anyhow::Result<FundingRateUpdate> {
141    Ok(FundingRateUpdate::new(
142        instrument_id,
143        ax_rate.funding_rate,
144        None,
145        None, // AX doesn't provide next funding time
146        ax_timestamp_ns_to_unix_nanos(ax_rate.timestamp_ns)?,
147        ts_init,
148    ))
149}
150
151/// Parses an Ax instrument into a Nautilus perpetual or dated futures contract.
152///
153/// # Errors
154///
155/// Returns an error if any required field cannot be parsed or is invalid.
156///
157/// # Panics
158///
159/// Panics if the constructed perpetual instrument fails validation.
160pub fn parse_instrument(
161    definition: &AxInstrument,
162    maker_fee: Decimal,
163    taker_fee: Decimal,
164    ts_event: UnixNanos,
165    ts_init: UnixNanos,
166) -> anyhow::Result<InstrumentAny> {
167    let raw_symbol_str = definition.symbol.as_str();
168    let raw_symbol = Symbol::new(raw_symbol_str);
169    let instrument_id = InstrumentId::new(raw_symbol, *AX_VENUE);
170
171    let symbol_prefix = raw_symbol_str
172        .split('-')
173        .next()
174        .context("Failed to extract symbol prefix")?;
175
176    let underlying = match definition.product {
177        Some(product) => {
178            let trimmed = product.as_str().trim();
179            anyhow::ensure!(
180                !trimmed.is_empty() && trimmed == product.as_str(),
181                "AX instrument product must be non-empty without surrounding whitespace, was '{product}'"
182            );
183            product
184        }
185        None => Ustr::from(symbol_prefix),
186    };
187
188    // Derive base code by stripping quote currency suffix if present
189    // e.g. JPYUSD-PERP → base=JPY, BTC-PERP → base=BTC
190    let quote_code = definition.quote_currency.as_str();
191    let base_code = if symbol_prefix.ends_with(quote_code) && symbol_prefix.len() > quote_code.len()
192    {
193        &symbol_prefix[..symbol_prefix.len() - quote_code.len()]
194    } else {
195        symbol_prefix
196    };
197
198    let asset_class = AssetClass::from(definition.category);
199
200    // Only resolve base currency for FX/crypto where the base code is a currency
201    let base_currency = match asset_class {
202        AssetClass::FX | AssetClass::Cryptocurrency => Some(get_currency(base_code)),
203        _ => None,
204    };
205
206    let quote_currency = get_currency(quote_code);
207    let settlement_currency = get_currency(definition.funding_settlement_currency.as_str());
208
209    let price_increment = decimal_to_price(definition.tick_size, "tick_size")?;
210    anyhow::ensure!(
211        definition.minimum_order_size > Decimal::ZERO
212            && definition.minimum_order_size.fract().is_zero(),
213        "AX minimum_order_size must be a positive whole number, was {}",
214        definition.minimum_order_size
215    );
216    let size_increment = decimal_to_quantity(Decimal::ONE, "size_increment")?;
217    let lot_size = Some(size_increment);
218    let min_quantity = Some(decimal_to_quantity(
219        definition.minimum_order_size.normalize(),
220        "minimum_order_size",
221    )?);
222
223    let (margin_init, margin_maint) = parse_margin_rates(
224        definition.initial_margin_pct,
225        definition.maintenance_margin_pct,
226    )?;
227
228    let mut info = Params::new();
229
230    if let Some(ref desc) = definition.description {
231        info.insert("description".to_string(), json!(desc));
232    }
233
234    if let Some(product) = definition.product {
235        info.insert("product".to_string(), json!(product.as_str()));
236    }
237
238    info.insert(
239        "initial_margin_pct".to_string(),
240        json!(definition.initial_margin_pct.to_string()),
241    );
242    info.insert(
243        "maintenance_margin_pct".to_string(),
244        json!(definition.maintenance_margin_pct.to_string()),
245    );
246    info.insert(
247        "quantity_increment_source".to_string(),
248        json!("integer_contract_wire_quantity"),
249    );
250
251    if let Some(ref s) = definition.contract_size {
252        info.insert("contract_size".to_string(), json!(s));
253    }
254
255    if let Some(ref s) = definition.contract_mark_price {
256        info.insert("contract_mark_price".to_string(), json!(s));
257    }
258
259    if let Some(ref s) = definition.price_quotation {
260        info.insert("price_quotation".to_string(), json!(s));
261    }
262
263    if let Some(ref s) = definition.underlying_benchmark_price {
264        info.insert("underlying_benchmark_price".to_string(), json!(s));
265    }
266
267    if let Some(ref s) = definition.price_bands {
268        info.insert("price_bands".to_string(), json!(s));
269    }
270
271    if let Some(v) = definition.funding_rate_cap_upper_pct {
272        info.insert(
273            "funding_rate_cap_upper_pct".to_string(),
274            json!(v.to_string()),
275        );
276    }
277
278    if let Some(v) = definition.funding_rate_cap_lower_pct {
279        info.insert(
280            "funding_rate_cap_lower_pct".to_string(),
281            json!(v.to_string()),
282        );
283    }
284
285    if let Some(v) = definition.price_band_upper_deviation_pct {
286        info.insert(
287            "price_band_upper_deviation_pct".to_string(),
288            json!(v.to_string()),
289        );
290    }
291
292    if let Some(v) = definition.price_band_lower_deviation_pct {
293        info.insert(
294            "price_band_lower_deviation_pct".to_string(),
295            json!(v.to_string()),
296        );
297    }
298
299    if let Some(expiration) = definition.expiration {
300        anyhow::ensure!(
301            definition.quote_currency == definition.funding_settlement_currency,
302            "AX dated contract {} has different quote and settlement currencies: {} and {}",
303            definition.symbol,
304            definition.quote_currency,
305            definition.funding_settlement_currency
306        );
307        let expiration_ns = datetime_to_unix_nanos(Some(expiration))
308            .context("Failed to convert AX contract expiration to Unix nanoseconds")?;
309        let multiplier = decimal_to_quantity(definition.multiplier, "multiplier")?;
310        info.insert(
311            "expiration".to_string(),
312            json!(expiration.display_with_offset(Offset::UTC).to_string()),
313        );
314        info.insert(
315            "activation_source".to_string(),
316            json!("unavailable_from_ax"),
317        );
318
319        let instrument = FuturesContract::builder()
320            .instrument_id(instrument_id)
321            .raw_symbol(raw_symbol)
322            .asset_class(asset_class)
323            .underlying(underlying)
324            .activation_ns(UnixNanos::default())
325            .expiration_ns(expiration_ns)
326            .currency(quote_currency)
327            .price_precision(price_increment.precision)
328            .price_increment(price_increment)
329            .multiplier(multiplier)
330            .lot_size(size_increment)
331            .maybe_min_quantity(min_quantity)
332            .margin_init(margin_init)
333            .margin_maint(margin_maint)
334            .maker_fee(maker_fee)
335            .taker_fee(taker_fee)
336            .info(info)
337            .ts_event(ts_event)
338            .ts_init(ts_init)
339            .build()
340            .context("Failed to construct AX dated futures contract")?;
341
342        return Ok(InstrumentAny::FuturesContract(instrument));
343    }
344
345    let instrument = PerpetualContract::builder()
346        .instrument_id(instrument_id)
347        .raw_symbol(raw_symbol)
348        .underlying(underlying)
349        .asset_class(asset_class)
350        .maybe_base_currency(base_currency)
351        .quote_currency(quote_currency)
352        .settlement_currency(settlement_currency)
353        .is_inverse(false)
354        .price_precision(price_increment.precision)
355        .size_precision(size_increment.precision)
356        .price_increment(price_increment)
357        .size_increment(size_increment)
358        .maybe_lot_size(lot_size)
359        .maybe_min_quantity(min_quantity)
360        .margin_init(margin_init)
361        .margin_maint(margin_maint)
362        .maker_fee(maker_fee)
363        .taker_fee(taker_fee)
364        .info(info)
365        .ts_event(ts_event)
366        .ts_init(ts_init)
367        .build()
368        .unwrap();
369
370    Ok(InstrumentAny::PerpetualContract(instrument))
371}
372
373fn parse_margin_rates(
374    initial_margin_pct: Decimal,
375    maintenance_margin_pct: Decimal,
376) -> anyhow::Result<(Decimal, Decimal)> {
377    anyhow::ensure!(
378        initial_margin_pct > Decimal::ZERO,
379        "AX initial_margin_pct must be positive, was {initial_margin_pct}"
380    );
381    anyhow::ensure!(
382        maintenance_margin_pct > Decimal::ZERO,
383        "AX maintenance_margin_pct must be positive, was {maintenance_margin_pct}"
384    );
385    anyhow::ensure!(
386        maintenance_margin_pct <= initial_margin_pct,
387        "AX maintenance_margin_pct {maintenance_margin_pct} exceeds initial_margin_pct {initial_margin_pct}"
388    );
389
390    Ok((
391        margin_percent_to_rate(initial_margin_pct, "initial_margin_pct")?,
392        margin_percent_to_rate(maintenance_margin_pct, "maintenance_margin_pct")?,
393    ))
394}
395
396fn margin_percent_to_rate(value: Decimal, field: &str) -> anyhow::Result<Decimal> {
397    let normalized = value.normalize();
398    let scale = normalized.scale();
399    anyhow::ensure!(
400        scale <= 26,
401        "AX {field} scale must not exceed 26 for exact percent conversion, was {scale}"
402    );
403    Decimal::try_from_i128_with_scale(normalized.mantissa(), scale + 2)
404        .with_context(|| format!("Failed to convert AX {field} percentage to a rate"))
405}
406
407/// Parses an Ax balances response into a Nautilus [`AccountState`].
408///
409/// Ax provides a simple balance structure with symbol and amount.
410/// The amount is treated as both total and free balance (no locked funds tracking).
411///
412/// # Errors
413///
414/// Returns an error if balance amount parsing fails.
415pub fn parse_account_state(
416    response: &AxBalancesResponse,
417    account_id: AccountId,
418    ts_event: UnixNanos,
419    ts_init: UnixNanos,
420) -> anyhow::Result<AccountState> {
421    let mut balances = Vec::with_capacity(response.balances.len());
422
423    for balance in &response.balances {
424        let symbol_str = balance.symbol.as_str().trim();
425        if symbol_str.is_empty() {
426            log::debug!("Skipping balance with empty symbol");
427            continue;
428        }
429
430        let currency = get_currency(symbol_str);
431
432        // The /balances endpoint does not include margin data, so locked
433        // is always zero here. The /risk-snapshot endpoint provides
434        // initial_margin_required_total which could be used, but that
435        // requires an additional HTTP call on every account state refresh.
436        let balance =
437            AccountBalance::from_total_and_locked(balance.amount, Decimal::ZERO, currency)
438                .with_context(|| format!("Failed to convert balance for {symbol_str}"))?;
439        balances.push(balance);
440    }
441
442    if balances.is_empty() {
443        let zero_currency = Currency::USD();
444        let zero_money = Money::zero(zero_currency);
445        balances.push(AccountBalance::new(zero_money, zero_money, zero_money));
446    }
447
448    Ok(AccountState::new(
449        account_id,
450        AccountType::Margin,
451        balances,
452        vec![],
453        true,
454        UUID4::new(),
455        ts_event,
456        ts_init,
457        None,
458    ))
459}
460
461/// Parses an Ax open order into a Nautilus [`OrderStatusReport`].
462///
463/// The `cid_resolver` parameter is an optional function that resolves a `cid` (u64)
464/// to a `ClientOrderId`. This is needed because orders submitted via WebSocket use
465/// a hashed `cid` for correlation rather than storing the full `ClientOrderId` in the tag.
466///
467/// # Errors
468///
469/// Returns an error if:
470/// - Price or quantity fields cannot be parsed.
471/// - Timestamp conversion fails.
472pub fn parse_order_status_report<F>(
473    order: &AxOpenOrder,
474    account_id: AccountId,
475    instrument: &InstrumentAny,
476    ts_init: UnixNanos,
477    cid_resolver: Option<&F>,
478) -> anyhow::Result<OrderStatusReport>
479where
480    F: Fn(u64) -> Option<ClientOrderId>,
481{
482    parse_order_status_report_fields(
483        &OrderStatusReportFields::from(order),
484        account_id,
485        instrument,
486        ts_init,
487        cid_resolver,
488    )
489}
490
491/// Parses an Ax historical order into a Nautilus [`OrderStatusReport`].
492///
493/// The `cid_resolver` parameter is an optional function that resolves a `cid` (u64)
494/// to a `ClientOrderId`. This is needed because orders submitted via WebSocket use
495/// a hashed `cid` for correlation rather than storing the full `ClientOrderId` in the tag.
496///
497/// # Errors
498///
499/// Returns an error if:
500/// - Price or quantity fields cannot be parsed.
501/// - Timestamp conversion fails.
502pub fn parse_order_detail_status_report<F>(
503    order: &AxOrderDetail,
504    account_id: AccountId,
505    instrument: &InstrumentAny,
506    ts_init: UnixNanos,
507    cid_resolver: Option<&F>,
508) -> anyhow::Result<OrderStatusReport>
509where
510    F: Fn(u64) -> Option<ClientOrderId>,
511{
512    parse_order_status_report_fields(
513        &OrderStatusReportFields::from(order),
514        account_id,
515        instrument,
516        ts_init,
517        cid_resolver,
518    )
519}
520
521struct OrderStatusReportFields<'a> {
522    ts: i64,
523    oid: &'a str,
524    price: Decimal,
525    quantity: u64,
526    filled_qty: u64,
527    status: AxOrderStatus,
528    side: AxOrderSide,
529    time_in_force: AxTimeInForce,
530    cid: Option<u64>,
531}
532
533impl<'a> From<&'a AxOpenOrder> for OrderStatusReportFields<'a> {
534    fn from(order: &'a AxOpenOrder) -> Self {
535        Self {
536            ts: order.ts,
537            oid: &order.oid,
538            price: order.p,
539            quantity: order.q,
540            filled_qty: order.xq,
541            status: order.o,
542            side: order.d,
543            time_in_force: order.tif,
544            cid: order.cid,
545        }
546    }
547}
548
549impl<'a> From<&'a AxOrderDetail> for OrderStatusReportFields<'a> {
550    fn from(order: &'a AxOrderDetail) -> Self {
551        Self {
552            ts: order.ts,
553            oid: &order.oid,
554            price: order.p,
555            quantity: order.q,
556            filled_qty: order.xq,
557            status: order.o,
558            side: order.d,
559            time_in_force: order.tif,
560            cid: order.cid,
561        }
562    }
563}
564
565fn parse_order_status_report_fields<F>(
566    order: &OrderStatusReportFields<'_>,
567    account_id: AccountId,
568    instrument: &InstrumentAny,
569    ts_init: UnixNanos,
570    cid_resolver: Option<&F>,
571) -> anyhow::Result<OrderStatusReport>
572where
573    F: Fn(u64) -> Option<ClientOrderId>,
574{
575    let instrument_id = instrument.id();
576    let venue_order_id = VenueOrderId::new(order.oid);
577    let order_side = OrderSide::from(order.side);
578    let order_status = order.status.into();
579    let time_in_force = order.time_in_force.into();
580
581    // The current AX wire shape maps to a Nautilus limit order.
582    let order_type = OrderType::Limit;
583
584    // Parse quantity (Ax uses i64 contracts)
585    let quantity = Quantity::new(order.quantity as f64, instrument.size_precision());
586    let filled_qty = Quantity::new(order.filled_qty as f64, instrument.size_precision());
587
588    // Parse price
589    let price = decimal_to_price_dp(order.price, instrument.price_precision(), "order.p")?;
590
591    // Ax timestamps are in Unix epoch seconds
592    let ts_event = ax_timestamp_s_to_unix_nanos(order.ts)?;
593
594    let mut report = OrderStatusReport::new(
595        account_id,
596        instrument_id,
597        None,
598        venue_order_id,
599        order_side.into(),
600        order_type,
601        time_in_force,
602        order_status,
603        quantity,
604        filled_qty,
605        ts_event,
606        ts_event,
607        ts_init,
608        Some(UUID4::new()),
609    );
610
611    if let Some(cid) = order.cid {
612        let client_order_id = cid_resolver
613            .and_then(|resolver| resolver(cid))
614            .unwrap_or_else(|| cid_to_client_order_id(cid));
615        report = report.with_client_order_id(client_order_id);
616    }
617
618    report = report.with_price(price);
619
620    // We don't set avg_px here since the order endpoint only provides the
621    // limit price, not actual fill prices. True average would need to be
622    // calculated from fill reports.
623
624    Ok(report)
625}
626
627/// Parses an Ax fill into a Nautilus [`FillReport`].
628///
629/// AX may omit an order ID for block trades and final settlement fills. Those
630/// records receive a deterministic reconciliation ID derived from the trade ID.
631/// The special-fill classifications are optional for regular fills with an
632/// order ID.
633///
634/// # Errors
635///
636/// Returns an error if:
637/// - Price or quantity fields cannot be parsed.
638/// - Fee parsing fails.
639/// - Fill classification is inconsistent.
640/// - A fill is neither explicitly special nor linked to a valid order ID.
641pub fn parse_fill_report(
642    fill: &AxFill,
643    account_id: AccountId,
644    instrument: &InstrumentAny,
645    ts_init: UnixNanos,
646) -> anyhow::Result<FillReport> {
647    let instrument_id = instrument.id();
648
649    let trade_id = TradeId::new_checked(&fill.trade_id).context("Invalid trade_id in Ax fill")?;
650    let is_block_trade = fill.is_block_trade;
651    let is_final_settlement = fill.is_final_settlement;
652    anyhow::ensure!(
653        !(is_final_settlement == Some(true) && is_block_trade == Some(false)),
654        "AX final-settlement fill must also be classified as a block trade"
655    );
656
657    let is_special_fill = is_block_trade == Some(true) || is_final_settlement == Some(true);
658    let venue_order_id = if is_special_fill {
659        VenueOrderId::new_checked(format!("AX-FILL-{}", fill.trade_id))
660            .context("Invalid synthetic venue order ID for AX fill")?
661    } else {
662        let order_id = fill
663            .order_id
664            .as_deref()
665            .context("AX fill is missing order_id and explicit special-fill classification")?;
666        anyhow::ensure!(
667            !order_id.is_empty(),
668            "AX regular fill has an empty order_id"
669        );
670        VenueOrderId::new_checked(order_id).context("Invalid order_id in AX fill")?
671    };
672
673    // Use explicit side field from fill
674    let order_side = OrderSide::from(fill.side);
675
676    let last_px = decimal_to_price_dp(fill.price, instrument.price_precision(), "fill.price")?;
677    let last_qty = Quantity::new(fill.quantity as f64, instrument.size_precision());
678
679    let currency = Currency::USD();
680    let commission = Money::from_decimal(fill.fee, currency)
681        .context("Failed to convert fill.fee Decimal to Money")?;
682
683    let liquidity_side = if fill.is_taker {
684        LiquiditySide::Taker
685    } else {
686        LiquiditySide::Maker
687    };
688
689    let ts_event = match u64::try_from(fill.timestamp.as_nanosecond().unsigned_abs()) {
690        Ok(nanos) => UnixNanos::from(nanos),
691        Err(_) => {
692            log::warn!(
693                "Timestamp overflow for fill {} (timestamp={}), defaulting to 0",
694                fill.trade_id,
695                fill.timestamp
696            );
697            UnixNanos::from(0u64)
698        }
699    };
700
701    Ok(FillReport::new(
702        account_id,
703        instrument_id,
704        venue_order_id,
705        trade_id,
706        order_side,
707        last_qty,
708        last_px,
709        commission,
710        liquidity_side,
711        None,
712        None,
713        ts_event,
714        ts_init,
715        None,
716    ))
717}
718
719/// Parses an Ax position into a Nautilus [`PositionStatusReport`].
720///
721/// # Errors
722///
723/// Returns an error if:
724/// - Position quantity parsing fails.
725/// - Timestamp conversion fails.
726pub fn parse_position_status_report(
727    position: &AxPosition,
728    account_id: AccountId,
729    instrument: &InstrumentAny,
730    ts_init: UnixNanos,
731) -> anyhow::Result<PositionStatusReport> {
732    let instrument_id = instrument.id();
733
734    // Determine position side and quantity from signed_quantity sign
735    let (position_side, quantity) = if position.signed_quantity > 0 {
736        (
737            PositionSide::Long,
738            Quantity::new(position.signed_quantity as f64, instrument.size_precision()),
739        )
740    } else if position.signed_quantity < 0 {
741        (
742            PositionSide::Short,
743            Quantity::new(
744                position.signed_quantity.unsigned_abs() as f64,
745                instrument.size_precision(),
746            ),
747        )
748    } else {
749        (
750            PositionSide::Flat,
751            Quantity::zero(instrument.size_precision()),
752        )
753    };
754
755    // Calculate average entry price from notional / quantity
756    // Both signed_notional and signed_quantity are negative for shorts
757    let avg_px_open = if position.signed_quantity != 0 {
758        let qty_dec = Decimal::from(position.signed_quantity.abs());
759        Some(position.signed_notional.abs() / qty_dec)
760    } else {
761        None
762    };
763
764    let ts_last = match u64::try_from(position.timestamp.as_nanosecond().unsigned_abs()) {
765        Ok(nanos) => UnixNanos::from(nanos),
766        Err(_) => {
767            log::warn!(
768                "Timestamp overflow for position {} (timestamp={}), defaulting to 0",
769                position.symbol,
770                position.timestamp
771            );
772            UnixNanos::from(0u64)
773        }
774    };
775
776    Ok(PositionStatusReport::new(
777        account_id,
778        instrument_id,
779        position_side,
780        quantity,
781        ts_last,
782        ts_init,
783        None,
784        None,
785        avg_px_open,
786    ))
787}
788
789/// Parses an Ax REST trade into a Nautilus [`TradeTick`].
790///
791/// # Errors
792///
793/// Returns an error if any field cannot be parsed.
794pub fn parse_trade_tick(
795    trade: &AxRestTrade,
796    instrument: &InstrumentAny,
797    ts_init: UnixNanos,
798) -> anyhow::Result<TradeTick> {
799    let price = decimal_to_price_dp(trade.p, instrument.price_precision(), "trade.p")?;
800    let size = Quantity::new(trade.q as f64, instrument.size_precision());
801    let aggressor_side: AggressorSide = trade.d.into();
802
803    let ts_event = ax_timestamp_stn_to_unix_nanos(trade.ts, trade.tn)?;
804    let trade_id = create_architect_trade_id(ts_event, price, size, aggressor_side)?;
805
806    TradeTick::new_checked(
807        instrument.id(),
808        price,
809        size,
810        aggressor_side,
811        trade_id,
812        ts_event,
813        ts_init,
814    )
815    .context("Failed to construct TradeTick from Ax REST trade")
816}
817
818#[cfg(test)]
819mod tests {
820    use jiff::Timestamp;
821    use nautilus_core::nanos::UnixNanos;
822    use rstest::rstest;
823    use rust_decimal_macros::dec;
824    use ustr::Ustr;
825
826    use super::*;
827    use crate::{
828        common::enums::{AxCategory, AxInstrumentState, AxOrderSide, AxOrderStatus, AxTimeInForce},
829        http::models::{AxFundingRatesResponse, AxInstrumentsResponse, AxOpenOrder},
830    };
831
832    fn create_eurusd_instrument() -> AxInstrument {
833        AxInstrument {
834            symbol: Ustr::from("EURUSD-PERP"),
835            product: Some(Ustr::from("EURUSD")),
836            state: AxInstrumentState::Open,
837            multiplier: dec!(1),
838            minimum_order_size: dec!(100),
839            tick_size: dec!(0.0001),
840            quote_currency: Ustr::from("USD"),
841            funding_settlement_currency: Ustr::from("USD"),
842            category: AxCategory::Fx,
843            maintenance_margin_pct: dec!(4.0),
844            initial_margin_pct: dec!(8.0),
845            contract_mark_price: Some("Average price on AX at London 4pm".to_string()),
846            contract_size: Some("1 Euro per contract".to_string()),
847            description: Some("Euro / US Dollar FX Perpetual Future".to_string()),
848            expiration: None,
849            funding_calendar_schedule: None,
850            funding_frequency: None,
851            funding_rate_cap_lower_pct: Some(dec!(-1.0)),
852            funding_rate_cap_upper_pct: Some(dec!(1.0)),
853            price_band_lower_deviation_pct: Some(dec!(10)),
854            price_band_upper_deviation_pct: Some(dec!(10)),
855            price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
856            price_quotation: Some("U.S. dollars per Euro".to_string()),
857            underlying_benchmark_price: Some("WMR London 4pm Closing Spot Rate".to_string()),
858        }
859    }
860
861    fn create_nvda_instrument() -> AxInstrument {
862        AxInstrument {
863            symbol: Ustr::from("NVDA-PERP"),
864            product: Some(Ustr::from("NVDA")),
865            state: AxInstrumentState::Open,
866            multiplier: dec!(1),
867            minimum_order_size: dec!(1),
868            tick_size: dec!(0.01),
869            quote_currency: Ustr::from("USD"),
870            funding_settlement_currency: Ustr::from("USD"),
871            category: AxCategory::Equities,
872            maintenance_margin_pct: dec!(10),
873            initial_margin_pct: dec!(20),
874            contract_mark_price: Some(
875                "Average price on ArchitectX at 4pm New York Time".to_string(),
876            ),
877            contract_size: Some("1 share per contract".to_string()),
878            description: Some("NVIDIA Corp US Equity Perpetual Future".to_string()),
879            expiration: None,
880            funding_calendar_schedule: None,
881            funding_frequency: None,
882            funding_rate_cap_lower_pct: Some(dec!(-1)),
883            funding_rate_cap_upper_pct: Some(dec!(1)),
884            price_band_lower_deviation_pct: Some(dec!(10)),
885            price_band_upper_deviation_pct: Some(dec!(10)),
886            price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
887            price_quotation: Some("U.S. dollars per share".to_string()),
888            underlying_benchmark_price: Some("Nasdaq Official Closing Price".to_string()),
889        }
890    }
891
892    fn create_xau_instrument() -> AxInstrument {
893        AxInstrument {
894            symbol: Ustr::from("XAU-PERP"),
895            product: Some(Ustr::from("XAU")),
896            state: AxInstrumentState::Open,
897            multiplier: dec!(1),
898            minimum_order_size: dec!(1),
899            tick_size: dec!(0.1),
900            quote_currency: Ustr::from("USD"),
901            funding_settlement_currency: Ustr::from("USD"),
902            category: AxCategory::Metals,
903            maintenance_margin_pct: dec!(5),
904            initial_margin_pct: dec!(10),
905            contract_mark_price: Some("Average price on ArchitectX at London 4pm".to_string()),
906            contract_size: Some("1 ounce per contract".to_string()),
907            description: Some("Gold Metals Perpetual Future".to_string()),
908            expiration: None,
909            funding_calendar_schedule: None,
910            funding_frequency: None,
911            funding_rate_cap_lower_pct: Some(dec!(-1)),
912            funding_rate_cap_upper_pct: Some(dec!(1)),
913            price_band_lower_deviation_pct: Some(dec!(10)),
914            price_band_upper_deviation_pct: Some(dec!(10)),
915            price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
916            price_quotation: Some("U.S. dollars per ounce".to_string()),
917            underlying_benchmark_price: Some("XAU WMR Metals Daily Closing Rate".to_string()),
918        }
919    }
920
921    fn create_fill() -> AxFill {
922        AxFill {
923            trade_id: "T-01ARZ3NDEKTSV4RRFFQ69G5FAV".to_string(),
924            order_id: Some("O-01ARZ3NDEKTSV4RRFFQ69G5FAV".to_string()),
925            fee: dec!(0.10),
926            is_taker: true,
927            is_block_trade: Some(false),
928            is_final_settlement: Some(false),
929            price: dec!(1.0845),
930            quantity: 100,
931            side: AxOrderSide::Buy,
932            symbol: Ustr::from("EURUSD-PERP"),
933            timestamp: "2026-07-17T00:00:00Z".parse::<Timestamp>().unwrap(),
934            account_id: Ustr::from("account-1"),
935            realized_pnl: None,
936        }
937    }
938
939    #[rstest]
940    fn test_decimal_to_price() {
941        let price = decimal_to_price(dec!(100.50), "test_field").unwrap();
942        assert_eq!(price.as_f64(), 100.50);
943    }
944
945    #[rstest]
946    fn test_decimal_to_quantity() {
947        let qty = decimal_to_quantity(dec!(1.5), "test_field").unwrap();
948        assert_eq!(qty.as_f64(), 1.5);
949    }
950
951    #[rstest]
952    fn test_get_currency_known() {
953        let currency = get_currency("USD");
954        assert_eq!(currency.code, Ustr::from("USD"));
955        assert_eq!(currency.precision, 2);
956    }
957
958    #[rstest]
959    fn test_get_currency_unknown_creates_new() {
960        let currency = get_currency("NVDA");
961        assert_eq!(currency.code, Ustr::from("NVDA"));
962        assert_eq!(currency.precision, 0);
963    }
964
965    #[rstest]
966    fn test_parse_order_status_report_uses_cid_resolver() {
967        let instrument = parse_instrument(
968            &create_eurusd_instrument(),
969            Decimal::ZERO,
970            Decimal::ZERO,
971            UnixNanos::default(),
972            UnixNanos::default(),
973        )
974        .unwrap();
975        let order = AxOpenOrder {
976            tn: 0,
977            ts: 1_609_459_200,
978            d: AxOrderSide::Buy,
979            o: AxOrderStatus::Accepted,
980            oid: "O-NEW".to_string(),
981            p: dec!(1.0845),
982            q: 100,
983            rq: 100,
984            s: Ustr::from("EURUSD-PERP"),
985            tif: AxTimeInForce::Gtc,
986            u: "user".to_string(),
987            xq: 0,
988            cid: Some(42),
989            tag: None,
990            po: true,
991        };
992        let expected_client_order_id = ClientOrderId::from("O-PERSISTED");
993        let resolver = |cid| (cid == 42).then_some(expected_client_order_id);
994
995        let report = parse_order_status_report(
996            &order,
997            AccountId::from("AX-001"),
998            &instrument,
999            UnixNanos::default(),
1000            Some(&resolver),
1001        )
1002        .unwrap();
1003
1004        assert_eq!(report.client_order_id, Some(expected_client_order_id));
1005        assert_eq!(report.venue_order_id, VenueOrderId::from("O-NEW"));
1006    }
1007
1008    #[rstest]
1009    #[case(Some(false), Some(false))]
1010    #[case(None, Some(false))]
1011    #[case(Some(false), None)]
1012    #[case(None, None)]
1013    fn test_parse_fill_report_uses_real_order_id_for_regular_fill(
1014        #[case] is_block_trade: Option<bool>,
1015        #[case] is_final_settlement: Option<bool>,
1016    ) {
1017        let instrument = parse_instrument(
1018            &create_eurusd_instrument(),
1019            Decimal::ZERO,
1020            Decimal::ZERO,
1021            UnixNanos::default(),
1022            UnixNanos::default(),
1023        )
1024        .unwrap();
1025        let mut fill = create_fill();
1026        fill.is_block_trade = is_block_trade;
1027        fill.is_final_settlement = is_final_settlement;
1028
1029        let report = parse_fill_report(
1030            &fill,
1031            AccountId::from("AX-001"),
1032            &instrument,
1033            UnixNanos::default(),
1034        )
1035        .unwrap();
1036
1037        assert_eq!(
1038            report.venue_order_id.as_str(),
1039            "O-01ARZ3NDEKTSV4RRFFQ69G5FAV"
1040        );
1041    }
1042
1043    #[rstest]
1044    #[case(None)]
1045    #[case(Some("O-01ARZ3NDEKTSV4RRFFQ69G5FAV"))]
1046    fn test_parse_fill_report_uses_stable_surrogate_for_block_fill(#[case] order_id: Option<&str>) {
1047        let instrument = parse_instrument(
1048            &create_eurusd_instrument(),
1049            Decimal::ZERO,
1050            Decimal::ZERO,
1051            UnixNanos::default(),
1052            UnixNanos::default(),
1053        )
1054        .unwrap();
1055        let mut fill = create_fill();
1056        fill.order_id = order_id.map(str::to_string);
1057        fill.is_block_trade = Some(true);
1058
1059        let report = parse_fill_report(
1060            &fill,
1061            AccountId::from("AX-001"),
1062            &instrument,
1063            UnixNanos::default(),
1064        )
1065        .unwrap();
1066
1067        assert_eq!(
1068            report.venue_order_id.as_str(),
1069            "AX-FILL-T-01ARZ3NDEKTSV4RRFFQ69G5FAV"
1070        );
1071    }
1072
1073    #[rstest]
1074    fn test_parse_fill_report_uses_stable_surrogate_for_final_settlement() {
1075        let instrument = parse_instrument(
1076            &create_eurusd_instrument(),
1077            Decimal::ZERO,
1078            Decimal::ZERO,
1079            UnixNanos::default(),
1080            UnixNanos::default(),
1081        )
1082        .unwrap();
1083        let mut fill = create_fill();
1084        fill.order_id = None;
1085        fill.is_block_trade = Some(true);
1086        fill.is_final_settlement = Some(true);
1087
1088        let report = parse_fill_report(
1089            &fill,
1090            AccountId::from("AX-001"),
1091            &instrument,
1092            UnixNanos::default(),
1093        )
1094        .unwrap();
1095
1096        assert_eq!(
1097            report.venue_order_id.as_str(),
1098            "AX-FILL-T-01ARZ3NDEKTSV4RRFFQ69G5FAV"
1099        );
1100    }
1101
1102    #[rstest]
1103    fn test_parse_fill_report_rejects_final_settlement_without_block_classification() {
1104        let instrument = parse_instrument(
1105            &create_eurusd_instrument(),
1106            Decimal::ZERO,
1107            Decimal::ZERO,
1108            UnixNanos::default(),
1109            UnixNanos::default(),
1110        )
1111        .unwrap();
1112        let mut fill = create_fill();
1113        fill.is_block_trade = Some(false);
1114        fill.is_final_settlement = Some(true);
1115
1116        let error = parse_fill_report(
1117            &fill,
1118            AccountId::from("AX-001"),
1119            &instrument,
1120            UnixNanos::default(),
1121        )
1122        .unwrap_err();
1123
1124        assert_eq!(
1125            error.to_string(),
1126            "AX final-settlement fill must also be classified as a block trade"
1127        );
1128    }
1129
1130    #[rstest]
1131    fn test_parse_fill_report_rejects_missing_identity() {
1132        let instrument = parse_instrument(
1133            &create_eurusd_instrument(),
1134            Decimal::ZERO,
1135            Decimal::ZERO,
1136            UnixNanos::default(),
1137            UnixNanos::default(),
1138        )
1139        .unwrap();
1140        let mut fill = create_fill();
1141        fill.order_id = None;
1142        fill.is_block_trade = None;
1143        fill.is_final_settlement = None;
1144
1145        let error = parse_fill_report(
1146            &fill,
1147            AccountId::from("AX-001"),
1148            &instrument,
1149            UnixNanos::default(),
1150        )
1151        .unwrap_err();
1152
1153        assert_eq!(
1154            error.to_string(),
1155            "AX fill is missing order_id and explicit special-fill classification"
1156        );
1157    }
1158
1159    #[rstest]
1160    fn test_parse_fill_report_rejects_regular_fill_without_order_id() {
1161        let instrument = parse_instrument(
1162            &create_eurusd_instrument(),
1163            Decimal::ZERO,
1164            Decimal::ZERO,
1165            UnixNanos::default(),
1166            UnixNanos::default(),
1167        )
1168        .unwrap();
1169        let mut fill = create_fill();
1170        fill.order_id = None;
1171
1172        let result = parse_fill_report(
1173            &fill,
1174            AccountId::from("AX-001"),
1175            &instrument,
1176            UnixNanos::default(),
1177        );
1178
1179        assert!(result.is_err());
1180    }
1181
1182    #[rstest]
1183    #[case("")]
1184    #[case(" ")]
1185    #[case("O-α")]
1186    fn test_parse_fill_report_rejects_invalid_regular_order_id(#[case] order_id: &str) {
1187        let instrument = parse_instrument(
1188            &create_eurusd_instrument(),
1189            Decimal::ZERO,
1190            Decimal::ZERO,
1191            UnixNanos::default(),
1192            UnixNanos::default(),
1193        )
1194        .unwrap();
1195        let mut fill = create_fill();
1196        fill.order_id = Some(order_id.to_string());
1197        fill.is_block_trade = None;
1198        fill.is_final_settlement = None;
1199
1200        let result = parse_fill_report(
1201            &fill,
1202            AccountId::from("AX-001"),
1203            &instrument,
1204            UnixNanos::default(),
1205        );
1206
1207        assert!(result.is_err());
1208    }
1209
1210    #[rstest]
1211    fn test_parse_fx_instrument() {
1212        let definition = create_eurusd_instrument();
1213        let maker_fee = Decimal::new(2, 5);
1214        let taker_fee = Decimal::new(2, 5);
1215        let ts_now = UnixNanos::default();
1216
1217        let result = parse_instrument(&definition, maker_fee, taker_fee, ts_now, ts_now);
1218        assert!(result.is_ok());
1219
1220        let instrument = result.unwrap();
1221        match instrument {
1222            InstrumentAny::PerpetualContract(perp) => {
1223                assert_eq!(perp.id.symbol.as_str(), "EURUSD-PERP");
1224                assert_eq!(perp.id.venue, *AX_VENUE);
1225                assert_eq!(perp.underlying.as_str(), "EURUSD");
1226                assert_eq!(perp.asset_class, AssetClass::FX);
1227                assert_eq!(perp.base_currency.unwrap().code.as_str(), "EUR");
1228                assert_eq!(perp.quote_currency.code.as_str(), "USD");
1229                assert_eq!(perp.settlement_currency.code.as_str(), "USD");
1230                assert_eq!(perp.price_precision, 4);
1231                assert_eq!(perp.size_increment.as_decimal(), Decimal::ONE);
1232                assert_eq!(perp.lot_size.as_decimal(), Decimal::ONE);
1233                assert_eq!(perp.min_quantity.unwrap().as_decimal(), dec!(100));
1234                assert_eq!(perp.margin_init, dec!(0.08));
1235                assert_eq!(perp.margin_maint, dec!(0.04));
1236                let info = perp.info.as_ref().unwrap();
1237                assert_eq!(info["initial_margin_pct"], json!("8.0"));
1238                assert_eq!(info["maintenance_margin_pct"], json!("4.0"));
1239                assert_eq!(
1240                    info["quantity_increment_source"],
1241                    json!("integer_contract_wire_quantity")
1242                );
1243                assert!(!perp.is_inverse);
1244            }
1245            _ => panic!("Expected PerpetualContract instrument"),
1246        }
1247    }
1248
1249    #[rstest]
1250    fn test_parse_equity_instrument() {
1251        let definition = create_nvda_instrument();
1252        let maker_fee = Decimal::new(2, 5);
1253        let taker_fee = Decimal::new(2, 5);
1254        let ts_now = UnixNanos::default();
1255
1256        let result = parse_instrument(&definition, maker_fee, taker_fee, ts_now, ts_now);
1257        assert!(result.is_ok());
1258
1259        let instrument = result.unwrap();
1260        match instrument {
1261            InstrumentAny::PerpetualContract(perp) => {
1262                assert_eq!(perp.id.symbol.as_str(), "NVDA-PERP");
1263                assert_eq!(perp.id.venue, *AX_VENUE);
1264                assert_eq!(perp.underlying.as_str(), "NVDA");
1265                assert_eq!(perp.asset_class, AssetClass::Equity);
1266                assert_eq!(perp.quote_currency.code.as_str(), "USD");
1267                assert_eq!(perp.settlement_currency.code.as_str(), "USD");
1268                assert_eq!(perp.price_precision, 2);
1269                assert!(!perp.is_inverse);
1270            }
1271            _ => panic!("Expected PerpetualContract instrument"),
1272        }
1273    }
1274
1275    #[rstest]
1276    fn test_parse_metals_instrument() {
1277        let definition = create_xau_instrument();
1278        let ts_now = UnixNanos::default();
1279
1280        let result = parse_instrument(&definition, Decimal::ZERO, Decimal::ZERO, ts_now, ts_now);
1281        let instrument = result.unwrap();
1282        match instrument {
1283            InstrumentAny::PerpetualContract(perp) => {
1284                assert_eq!(perp.id.symbol.as_str(), "XAU-PERP");
1285                assert_eq!(perp.underlying.as_str(), "XAU");
1286                assert_eq!(perp.asset_class, AssetClass::Commodity);
1287                assert!(perp.base_currency.is_none());
1288                assert_eq!(perp.quote_currency.code.as_str(), "USD");
1289                assert_eq!(perp.price_precision, 1);
1290            }
1291            _ => panic!("Expected PerpetualContract instrument"),
1292        }
1293    }
1294
1295    #[rstest]
1296    fn test_parse_current_dated_instruments() {
1297        let test_data = include_str!("../../test_data/http_get_dated_instruments.json");
1298        let response: AxInstrumentsResponse = serde_json::from_str(test_data).unwrap();
1299        let maker_fee = dec!(0.0002);
1300        let taker_fee = dec!(0.0005);
1301        let ts_now = UnixNanos::default();
1302
1303        let instruments = response
1304            .instruments
1305            .iter()
1306            .map(|definition| {
1307                parse_instrument(definition, maker_fee, taker_fee, ts_now, ts_now).unwrap()
1308            })
1309            .collect::<Vec<_>>();
1310
1311        assert_eq!(instruments.len(), 2);
1312        for (instrument, expected_symbol, expected_expiration) in [
1313            (&instruments[0], "XAU-2026-SEP", "2026-09-30T15:00:00Z"),
1314            (&instruments[1], "XAU-2026-DEC", "2026-12-31T16:00:00Z"),
1315        ] {
1316            let InstrumentAny::FuturesContract(future) = instrument else {
1317                panic!("Expected FuturesContract instrument");
1318            };
1319            let expected_expiration_ns = u64::try_from(
1320                expected_expiration
1321                    .parse::<Timestamp>()
1322                    .unwrap()
1323                    .as_nanosecond(),
1324            )
1325            .unwrap();
1326            let info = future.info.as_ref().unwrap();
1327
1328            assert_eq!(future.id.symbol.as_str(), expected_symbol);
1329            assert_eq!(future.id.venue, *AX_VENUE);
1330            assert_eq!(future.underlying, Ustr::from("XAU"));
1331            assert_eq!(future.asset_class, AssetClass::Commodity);
1332            assert_eq!(future.activation_ns, UnixNanos::default());
1333            assert_eq!(
1334                future.expiration_ns,
1335                UnixNanos::from(expected_expiration_ns)
1336            );
1337            assert_eq!(future.currency.code.as_str(), "USD");
1338            assert_eq!(future.price_increment.as_decimal(), dec!(0.1));
1339            assert_eq!(future.size_increment.as_decimal(), Decimal::ONE);
1340            assert_eq!(future.lot_size.as_decimal(), Decimal::ONE);
1341            assert_eq!(future.min_quantity.unwrap().as_decimal(), Decimal::ONE);
1342            assert_eq!(future.multiplier.as_decimal(), Decimal::ONE);
1343            assert_eq!(future.margin_init, dec!(0.125));
1344            assert_eq!(future.margin_maint, dec!(0.075));
1345            assert_eq!(future.maker_fee, maker_fee);
1346            assert_eq!(future.taker_fee, taker_fee);
1347            assert_eq!(info["product"], json!("XAU"));
1348            assert_eq!(
1349                info["expiration"],
1350                json!(expected_expiration.replace('Z', "+00:00"))
1351            );
1352            assert_eq!(info["activation_source"], json!("unavailable_from_ax"));
1353            assert_eq!(
1354                info["quantity_increment_source"],
1355                json!("integer_contract_wire_quantity")
1356            );
1357            assert_eq!(info["initial_margin_pct"], json!("12.5"));
1358            assert_eq!(info["maintenance_margin_pct"], json!("7.5"));
1359        }
1360    }
1361
1362    #[rstest]
1363    fn test_parse_dated_instrument_keeps_numeric_fields_distinct() {
1364        let test_data = include_str!("../../test_data/http_get_dated_instruments.json");
1365        let mut response: AxInstrumentsResponse = serde_json::from_str(test_data).unwrap();
1366        let definition = &mut response.instruments[0];
1367        definition.multiplier = dec!(2.5);
1368        definition.minimum_order_size = dec!(5);
1369
1370        let instrument = parse_instrument(
1371            definition,
1372            Decimal::ZERO,
1373            Decimal::ZERO,
1374            UnixNanos::default(),
1375            UnixNanos::default(),
1376        )
1377        .unwrap();
1378        let InstrumentAny::FuturesContract(future) = instrument else {
1379            panic!("Expected FuturesContract instrument");
1380        };
1381
1382        assert_eq!(future.size_increment.as_decimal(), Decimal::ONE);
1383        assert_eq!(future.lot_size.as_decimal(), Decimal::ONE);
1384        assert_eq!(future.min_quantity.unwrap().as_decimal(), dec!(5));
1385        assert_eq!(future.multiplier.as_decimal(), dec!(2.5));
1386    }
1387
1388    #[rstest]
1389    fn test_parse_dated_instrument_without_product_uses_symbol_fallback() {
1390        let test_data = include_str!("../../test_data/http_get_dated_instruments.json");
1391        let mut response: AxInstrumentsResponse = serde_json::from_str(test_data).unwrap();
1392        let definition = &mut response.instruments[0];
1393        definition.product = None;
1394
1395        let instrument = parse_instrument(
1396            definition,
1397            Decimal::ZERO,
1398            Decimal::ZERO,
1399            UnixNanos::default(),
1400            UnixNanos::default(),
1401        )
1402        .unwrap();
1403        let InstrumentAny::FuturesContract(future) = instrument else {
1404            panic!("Expected FuturesContract instrument");
1405        };
1406
1407        assert_eq!(future.underlying, Ustr::from("XAU"));
1408    }
1409
1410    #[rstest]
1411    fn test_parse_instrument_rejects_blank_product() {
1412        let mut definition = create_xau_instrument();
1413        definition.product = Some(Ustr::from(" "));
1414
1415        let result = parse_instrument(
1416            &definition,
1417            Decimal::ZERO,
1418            Decimal::ZERO,
1419            UnixNanos::default(),
1420            UnixNanos::default(),
1421        );
1422
1423        assert!(result.is_err());
1424    }
1425
1426    #[rstest]
1427    #[case(Decimal::ZERO)]
1428    #[case(dec!(-1))]
1429    #[case(dec!(1.5))]
1430    fn test_parse_instrument_rejects_invalid_minimum_order_size(
1431        #[case] minimum_order_size: Decimal,
1432    ) {
1433        let mut definition = create_xau_instrument();
1434        definition.minimum_order_size = minimum_order_size;
1435
1436        let result = parse_instrument(
1437            &definition,
1438            Decimal::ZERO,
1439            Decimal::ZERO,
1440            UnixNanos::default(),
1441            UnixNanos::default(),
1442        );
1443
1444        assert!(result.is_err());
1445    }
1446
1447    #[rstest]
1448    fn test_parse_dated_instrument_rejects_currency_mismatch() {
1449        let test_data = include_str!("../../test_data/http_get_dated_instruments.json");
1450        let mut response: AxInstrumentsResponse = serde_json::from_str(test_data).unwrap();
1451        let definition = &mut response.instruments[0];
1452        definition.funding_settlement_currency = Ustr::from("EUR");
1453
1454        let result = parse_instrument(
1455            definition,
1456            Decimal::ZERO,
1457            Decimal::ZERO,
1458            UnixNanos::default(),
1459            UnixNanos::default(),
1460        );
1461
1462        assert!(result.is_err());
1463    }
1464
1465    #[rstest]
1466    fn test_parse_settlement_differs_from_quote() {
1467        let mut definition = create_eurusd_instrument();
1468        definition.funding_settlement_currency = Ustr::from("EUR");
1469        let ts_now = UnixNanos::default();
1470
1471        let result = parse_instrument(&definition, Decimal::ZERO, Decimal::ZERO, ts_now, ts_now);
1472        let instrument = result.unwrap();
1473        match instrument {
1474            InstrumentAny::PerpetualContract(perp) => {
1475                assert_eq!(perp.quote_currency.code.as_str(), "USD");
1476                assert_eq!(perp.settlement_currency.code.as_str(), "EUR");
1477            }
1478            _ => panic!("Expected PerpetualContract instrument"),
1479        }
1480    }
1481
1482    #[rstest]
1483    fn test_margin_percent_to_rate_preserves_exact_scale() {
1484        let value = Decimal::from_i128_with_scale(1, 26);
1485        let result = margin_percent_to_rate(value, "test").unwrap();
1486
1487        assert_eq!(result.mantissa(), 1);
1488        assert_eq!(result.scale(), 28);
1489    }
1490
1491    #[rstest]
1492    fn test_margin_percent_to_rate_normalizes_trailing_zero() {
1493        let value = Decimal::from_i128_with_scale(10, 27);
1494        let result = margin_percent_to_rate(value, "test").unwrap();
1495
1496        assert_eq!(result.mantissa(), 1);
1497        assert_eq!(result.scale(), 28);
1498    }
1499
1500    #[rstest]
1501    fn test_margin_percent_to_rate_rejects_unrepresentable_scale() {
1502        let value = Decimal::from_i128_with_scale(1, 27);
1503        let result = margin_percent_to_rate(value, "test");
1504
1505        assert!(result.is_err());
1506    }
1507
1508    #[rstest]
1509    #[case(Decimal::ZERO, dec!(1))]
1510    #[case(dec!(-1), dec!(1))]
1511    #[case(dec!(1), Decimal::ZERO)]
1512    #[case(dec!(1), dec!(-1))]
1513    #[case(dec!(4), dec!(8))]
1514    fn test_parse_margin_rates_rejects_invalid_values(
1515        #[case] initial_margin_pct: Decimal,
1516        #[case] maintenance_margin_pct: Decimal,
1517    ) {
1518        let result = parse_margin_rates(initial_margin_pct, maintenance_margin_pct);
1519
1520        assert!(result.is_err());
1521    }
1522
1523    #[rstest]
1524    fn test_parse_unknown_category_falls_back_to_alternative() {
1525        let mut definition = create_eurusd_instrument();
1526        definition.category = AxCategory::Unknown;
1527        let ts_now = UnixNanos::default();
1528
1529        let result = parse_instrument(&definition, Decimal::ZERO, Decimal::ZERO, ts_now, ts_now);
1530        let instrument = result.unwrap();
1531        match instrument {
1532            InstrumentAny::PerpetualContract(perp) => {
1533                assert_eq!(perp.asset_class, AssetClass::Alternative);
1534            }
1535            _ => panic!("Expected PerpetualContract instrument"),
1536        }
1537    }
1538
1539    #[rstest]
1540    fn test_deserialize_instruments_from_test_data() {
1541        let test_data = include_str!("../../test_data/http_get_instruments.json");
1542        let response: AxInstrumentsResponse =
1543            serde_json::from_str(test_data).expect("Failed to deserialize test data");
1544
1545        assert_eq!(response.instruments.len(), 3);
1546
1547        let eurusd = &response.instruments[0];
1548        assert_eq!(eurusd.symbol.as_str(), "EURUSD-PERP");
1549        assert_eq!(eurusd.category, AxCategory::Fx);
1550        assert_eq!(eurusd.tick_size, dec!(0.0001));
1551        assert_eq!(eurusd.minimum_order_size, dec!(100));
1552
1553        let xau = &response.instruments[1];
1554        assert_eq!(xau.symbol.as_str(), "XAU-PERP");
1555        assert_eq!(xau.category, AxCategory::Metals);
1556
1557        let nvda = &response.instruments[2];
1558        assert_eq!(nvda.symbol.as_str(), "NVDA-PERP");
1559        assert_eq!(nvda.category, AxCategory::Equities);
1560    }
1561
1562    #[rstest]
1563    fn test_parse_all_instruments_from_test_data() {
1564        let test_data = include_str!("../../test_data/http_get_instruments.json");
1565        let response: AxInstrumentsResponse =
1566            serde_json::from_str(test_data).expect("Failed to deserialize test data");
1567
1568        let maker_fee = Decimal::new(2, 4);
1569        let taker_fee = Decimal::new(5, 4);
1570        let ts_now = UnixNanos::default();
1571
1572        let open_instruments: Vec<_> = response
1573            .instruments
1574            .iter()
1575            .filter(|i| i.state == AxInstrumentState::Open)
1576            .collect();
1577
1578        assert_eq!(open_instruments.len(), 3);
1579
1580        for instrument in open_instruments {
1581            let result = parse_instrument(instrument, maker_fee, taker_fee, ts_now, ts_now);
1582            assert!(
1583                result.is_ok(),
1584                "Failed to parse {}: {:?}",
1585                instrument.symbol,
1586                result.err()
1587            );
1588        }
1589    }
1590
1591    fn create_rest_trade() -> AxRestTrade {
1592        AxRestTrade {
1593            ts: 1_766_193_240,
1594            tn: 334_589_144,
1595            p: dec!(1.1719),
1596            q: 400,
1597            s: Ustr::from("EURUSD-PERP"),
1598            d: AxOrderSide::Buy,
1599        }
1600    }
1601
1602    #[rstest]
1603    fn test_parse_trade_tick_derives_trade_id_from_timestamp_and_content() {
1604        let instrument = parse_instrument(
1605            &create_eurusd_instrument(),
1606            Decimal::ZERO,
1607            Decimal::ZERO,
1608            UnixNanos::default(),
1609            UnixNanos::default(),
1610        )
1611        .unwrap();
1612        let trade = create_rest_trade();
1613
1614        let tick = parse_trade_tick(&trade, &instrument, UnixNanos::from(7u64)).unwrap();
1615
1616        assert_eq!(tick.instrument_id, instrument.id());
1617        assert_eq!(
1618            tick.trade_id.to_string(),
1619            "1766193240334589144-38b4fe5a94a253d0"
1620        );
1621        assert_eq!(tick.price, Price::from("1.1719"));
1622        assert_eq!(tick.size, Quantity::from(400));
1623        assert_eq!(tick.aggressor_side, AggressorSide::Buy);
1624        assert_eq!(tick.ts_event, UnixNanos::from(1_766_193_240_334_589_144u64));
1625        assert_eq!(tick.ts_init, UnixNanos::from(7u64));
1626    }
1627
1628    #[rstest]
1629    fn test_parse_trade_tick_separates_prints_within_one_timestamp() {
1630        // Two prints from one sweep share `ts` and `tn`, so only the content digest separates them
1631        let instrument = parse_instrument(
1632            &create_eurusd_instrument(),
1633            Decimal::ZERO,
1634            Decimal::ZERO,
1635            UnixNanos::default(),
1636            UnixNanos::default(),
1637        )
1638        .unwrap();
1639        let first = create_rest_trade();
1640        let mut second = create_rest_trade();
1641        second.p = dec!(1.1720);
1642        second.q = 100;
1643
1644        let first_tick = parse_trade_tick(&first, &instrument, UnixNanos::default()).unwrap();
1645        let second_tick = parse_trade_tick(&second, &instrument, UnixNanos::default()).unwrap();
1646
1647        assert_eq!(first_tick.ts_event, second_tick.ts_event);
1648        assert_ne!(first_tick.price, second_tick.price);
1649        assert_ne!(first_tick.size, second_tick.size);
1650        assert_ne!(first_tick.trade_id, second_tick.trade_id);
1651    }
1652
1653    #[rstest]
1654    fn test_parse_trade_tick_rejects_negative_timestamp() {
1655        let instrument = parse_instrument(
1656            &create_eurusd_instrument(),
1657            Decimal::ZERO,
1658            Decimal::ZERO,
1659            UnixNanos::default(),
1660            UnixNanos::default(),
1661        )
1662        .unwrap();
1663        let mut trade = create_rest_trade();
1664        trade.ts = -1;
1665
1666        let error = parse_trade_tick(&trade, &instrument, UnixNanos::default()).unwrap_err();
1667
1668        assert_eq!(
1669            error.to_string(),
1670            "AX timestamp must be non-negative, was -1"
1671        );
1672    }
1673
1674    #[rstest]
1675    fn test_deserialize_and_parse_funding_rates() {
1676        let test_data = include_str!("../../test_data/http_get_funding_rates.json");
1677        let response: AxFundingRatesResponse =
1678            serde_json::from_str(test_data).expect("Failed to deserialize test data");
1679
1680        assert_eq!(response.funding_rates.len(), 2);
1681        assert_eq!(response.funding_rates[0].symbol.as_str(), "JPYUSD-PERP");
1682        assert_eq!(response.funding_rates[0].funding_rate, dec!(0.001234560000));
1683
1684        let instrument_id = InstrumentId::new(Symbol::new("JPYUSD-PERP"), *AX_VENUE);
1685        let ts_init = UnixNanos::from(1_000_000_000u64);
1686
1687        let update =
1688            parse_funding_rate(&response.funding_rates[1], instrument_id, ts_init).unwrap();
1689
1690        assert_eq!(update.instrument_id, instrument_id);
1691        assert_eq!(update.rate, dec!(0.003558290026));
1692        assert_eq!(update.next_funding_ns, None);
1693        assert_eq!(update.ts_event, UnixNanos::from(1770393600000000000u64));
1694        assert_eq!(update.ts_init, ts_init);
1695    }
1696}