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 std::collections::HashMap;
19
20use anyhow::Context;
21use indexmap::IndexMap;
22use jiff::tz::Offset;
23use nautilus_core::{Params, UUID4, datetime::datetime_to_unix_nanos, nanos::UnixNanos};
24use nautilus_model::{
25    data::{Bar, BarSpecification, BarType, FundingRateUpdate, TradeTick},
26    enums::{
27        AccountType, AggregationSource, AggressorSide, AssetClass, AvgPxReconciliation,
28        BarAggregation, CurrencyType, LiquiditySide, OrderSide, OrderStatus, OrderType,
29        PositionSide, PriceType, TimeInForce,
30    },
31    events::AccountState,
32    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
33    instruments::{FuturesContract, Instrument, PerpetualContract, any::InstrumentAny},
34    reports::{FillReport, OrderStatusReport, PositionStatusReport},
35    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
36};
37use rust_decimal::Decimal;
38use serde_json::json;
39use ustr::Ustr;
40
41use super::models::{
42    AxBalancesResponse, AxCandle, AxFill, AxFundingRate, AxInstrument, AxOpenOrder, AxOrderDetail,
43    AxOrderRejectReason, AxPosition, AxRestTrade, AxRiskSnapshot,
44};
45use crate::common::{
46    consts::{AX_FILL_ORDER_PREFIX, AX_VENUE},
47    enums::{AxCandleWidth, AxOrderSide, AxOrderStatus, AxTimeInForce},
48    parse::{
49        ax_timestamp_ns_to_unix_nanos, ax_timestamp_s_to_unix_nanos,
50        ax_timestamp_stn_to_unix_nanos, cid_to_client_order_id, create_architect_trade_id,
51    },
52};
53
54fn decimal_to_price(value: Decimal, field_name: &str) -> anyhow::Result<Price> {
55    Price::from_decimal(value)
56        .with_context(|| format!("Failed to convert {field_name} Decimal to Price"))
57}
58
59fn decimal_to_quantity(value: Decimal, field_name: &str) -> anyhow::Result<Quantity> {
60    Quantity::from_decimal(value)
61        .with_context(|| format!("Failed to convert {field_name} Decimal to Quantity"))
62}
63
64fn decimal_to_price_dp(value: Decimal, precision: u8, field: &str) -> anyhow::Result<Price> {
65    Price::from_decimal_dp(value, precision).with_context(|| {
66        format!("Failed to construct Price for {field} with precision {precision}")
67    })
68}
69
70fn get_currency(code: &str) -> Currency {
71    Currency::try_from_str(code).unwrap_or_else(|| {
72        // Create new currency with precision 0 (whole units for equity perps)
73        let currency = Currency::new(code, 0, 0, code, CurrencyType::Crypto);
74        if let Err(e) = Currency::register(currency, false) {
75            log::warn!("Failed to register currency '{code}': {e}");
76        }
77        currency
78    })
79}
80
81/// Converts an Ax candle width to a Nautilus bar specification.
82#[must_use]
83pub fn candle_width_to_bar_spec(width: AxCandleWidth) -> BarSpecification {
84    match width {
85        AxCandleWidth::Seconds1 => {
86            BarSpecification::new(1, BarAggregation::Second, PriceType::Last)
87        }
88        AxCandleWidth::Seconds5 => {
89            BarSpecification::new(5, BarAggregation::Second, PriceType::Last)
90        }
91        AxCandleWidth::Minutes1 => {
92            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
93        }
94        AxCandleWidth::Minutes5 => {
95            BarSpecification::new(5, BarAggregation::Minute, PriceType::Last)
96        }
97        AxCandleWidth::Minutes15 => {
98            BarSpecification::new(15, BarAggregation::Minute, PriceType::Last)
99        }
100        AxCandleWidth::Hours1 => BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
101        AxCandleWidth::Days1 => BarSpecification::new(1, BarAggregation::Day, PriceType::Last),
102    }
103}
104
105/// Parses an Ax candle into a Nautilus Bar.
106///
107/// # Errors
108///
109/// Returns an error if any OHLCV field cannot be parsed.
110pub fn parse_bar(
111    candle: &AxCandle,
112    instrument: &InstrumentAny,
113    ts_init: UnixNanos,
114) -> anyhow::Result<Bar> {
115    let price_precision = instrument.price_precision();
116    let size_precision = instrument.size_precision();
117
118    let open = decimal_to_price_dp(candle.open, price_precision, "candle.open")?;
119    let high = decimal_to_price_dp(candle.high, price_precision, "candle.high")?;
120    let low = decimal_to_price_dp(candle.low, price_precision, "candle.low")?;
121    let close = decimal_to_price_dp(candle.close, price_precision, "candle.close")?;
122
123    // Ax provides volume as i64 contracts
124    let volume = Quantity::from_decimal_dp(Decimal::from(candle.volume), size_precision)?;
125
126    let ts_event = ax_timestamp_s_to_unix_nanos(candle.ts)?;
127
128    let bar_spec = candle_width_to_bar_spec(candle.width);
129    let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
130
131    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
132        .context("Failed to construct Bar from Ax candle")
133}
134
135/// Parses an Ax funding rate into a Nautilus [`FundingRateUpdate`].
136///
137/// # Errors
138///
139/// Returns an error if the timestamp is invalid.
140pub fn parse_funding_rate(
141    ax_rate: &AxFundingRate,
142    instrument_id: InstrumentId,
143    ts_init: UnixNanos,
144) -> anyhow::Result<FundingRateUpdate> {
145    Ok(FundingRateUpdate::new(
146        instrument_id,
147        ax_rate.funding_rate,
148        None,
149        None, // AX doesn't provide next funding time
150        ax_timestamp_ns_to_unix_nanos(ax_rate.timestamp_ns)?,
151        ts_init,
152    ))
153}
154
155/// Parses an Ax instrument into a Nautilus perpetual or dated futures contract.
156///
157/// # Errors
158///
159/// Returns an error if any required field cannot be parsed or is invalid.
160pub fn parse_instrument(
161    definition: &AxInstrument,
162    ts_event: UnixNanos,
163    ts_init: UnixNanos,
164) -> anyhow::Result<InstrumentAny> {
165    let raw_symbol_str = definition.symbol.as_str();
166    let raw_symbol = Symbol::new(raw_symbol_str);
167    let instrument_id = InstrumentId::new(raw_symbol, *AX_VENUE);
168
169    let symbol_prefix = raw_symbol_str
170        .split('-')
171        .next()
172        .context("Failed to extract symbol prefix")?;
173
174    let underlying = match definition.product {
175        Some(product) => {
176            let trimmed = product.trim();
177            anyhow::ensure!(
178                !trimmed.is_empty() && trimmed == product,
179                "AX instrument product must be non-empty without surrounding whitespace, was '{product}'"
180            );
181            product
182        }
183        None => Ustr::from(symbol_prefix),
184    };
185
186    // Derive base code by stripping quote currency suffix if present
187    // e.g. JPYUSD-PERP → base=JPY, BTC-PERP → base=BTC
188    let quote_code = definition.quote_currency.as_str();
189    let base_code = if symbol_prefix.ends_with(quote_code) && symbol_prefix.len() > quote_code.len()
190    {
191        &symbol_prefix[..symbol_prefix.len() - quote_code.len()]
192    } else {
193        symbol_prefix
194    };
195
196    let asset_class = AssetClass::from(definition.category);
197
198    // Only resolve base currency for FX/crypto where the base code is a currency
199    let base_currency = match asset_class {
200        AssetClass::FX | AssetClass::Cryptocurrency => Some(get_currency(base_code)),
201        _ => None,
202    };
203
204    let quote_currency = get_currency(quote_code);
205    let settlement_currency = get_currency(definition.funding_settlement_currency.as_str());
206
207    let price_increment = decimal_to_price(definition.tick_size, "tick_size")?;
208    anyhow::ensure!(
209        definition.minimum_order_size > Decimal::ZERO
210            && definition.minimum_order_size.fract().is_zero(),
211        "AX minimum_order_size must be a positive whole number, was {}",
212        definition.minimum_order_size
213    );
214    let size_increment = decimal_to_quantity(Decimal::ONE, "size_increment")?;
215    let lot_size = Some(size_increment);
216    let min_quantity = Some(decimal_to_quantity(
217        definition.minimum_order_size.normalize(),
218        "minimum_order_size",
219    )?);
220
221    let (margin_init, margin_maint) = parse_margin_rates(
222        definition.initial_margin_pct,
223        definition.maintenance_margin_pct,
224    )?;
225
226    let mut info = IndexMap::new();
227    if let Some(ref desc) = definition.description {
228        info.insert("description".to_string(), json!(desc));
229    }
230
231    if let Some(product) = definition.product {
232        info.insert("product".to_string(), json!(product.as_str()));
233    }
234
235    info.insert(
236        "initial_margin_pct".to_string(),
237        json!(definition.initial_margin_pct.to_string()),
238    );
239    info.insert(
240        "maintenance_margin_pct".to_string(),
241        json!(definition.maintenance_margin_pct.to_string()),
242    );
243    info.insert(
244        "quantity_increment_source".to_string(),
245        json!("integer_contract_wire_quantity"),
246    );
247
248    if let Some(ref s) = definition.contract_size {
249        info.insert("contract_size".to_string(), json!(s));
250    }
251
252    if let Some(ref s) = definition.contract_mark_price {
253        info.insert("contract_mark_price".to_string(), json!(s));
254    }
255
256    if let Some(ref s) = definition.price_quotation {
257        info.insert("price_quotation".to_string(), json!(s));
258    }
259
260    if let Some(ref s) = definition.underlying_benchmark_price {
261        info.insert("underlying_benchmark_price".to_string(), json!(s));
262    }
263
264    if let Some(ref s) = definition.price_bands {
265        info.insert("price_bands".to_string(), json!(s));
266    }
267
268    if let Some(v) = definition.funding_rate_cap_upper_pct {
269        info.insert(
270            "funding_rate_cap_upper_pct".to_string(),
271            json!(v.to_string()),
272        );
273    }
274
275    if let Some(v) = definition.funding_rate_cap_lower_pct {
276        info.insert(
277            "funding_rate_cap_lower_pct".to_string(),
278            json!(v.to_string()),
279        );
280    }
281
282    if let Some(v) = definition.price_band_upper_deviation_pct {
283        info.insert(
284            "price_band_upper_deviation_pct".to_string(),
285            json!(v.to_string()),
286        );
287    }
288
289    if let Some(v) = definition.price_band_lower_deviation_pct {
290        info.insert(
291            "price_band_lower_deviation_pct".to_string(),
292            json!(v.to_string()),
293        );
294    }
295
296    if let Some(expiration) = definition.expiration {
297        anyhow::ensure!(
298            definition.quote_currency == definition.funding_settlement_currency,
299            "AX dated contract {} has different quote and settlement currencies: {} and {}",
300            definition.symbol,
301            definition.quote_currency,
302            definition.funding_settlement_currency
303        );
304        let expiration_ns = datetime_to_unix_nanos(Some(expiration))
305            .context("Failed to convert AX contract expiration to Unix nanoseconds")?;
306        let multiplier = decimal_to_quantity(definition.multiplier, "multiplier")?;
307        info.insert(
308            "expiration".to_string(),
309            json!(expiration.display_with_offset(Offset::UTC).to_string()),
310        );
311        info.insert(
312            "activation_source".to_string(),
313            json!("unavailable_from_ax"),
314        );
315
316        let instrument = FuturesContract::builder()
317            .instrument_id(instrument_id)
318            .raw_symbol(raw_symbol)
319            .asset_class(asset_class)
320            .underlying(underlying)
321            .activation_ns(UnixNanos::default())
322            .expiration_ns(expiration_ns)
323            .currency(quote_currency)
324            .price_precision(price_increment.precision)
325            .price_increment(price_increment)
326            .multiplier(multiplier)
327            .lot_size(size_increment)
328            .maybe_min_quantity(min_quantity)
329            .margin_init(margin_init)
330            .margin_maint(margin_maint)
331            .info(Params::new(info))
332            .ts_event(ts_event)
333            .ts_init(ts_init)
334            .build()
335            .context("Failed to construct AX dated futures contract")?;
336
337        return Ok(InstrumentAny::FuturesContract(instrument));
338    }
339
340    let instrument = PerpetualContract::builder()
341        .instrument_id(instrument_id)
342        .raw_symbol(raw_symbol)
343        .underlying(underlying)
344        .asset_class(asset_class)
345        .maybe_base_currency(base_currency)
346        .quote_currency(quote_currency)
347        .settlement_currency(settlement_currency)
348        .is_inverse(false)
349        .multiplier(decimal_to_quantity(definition.multiplier, "multiplier")?)
350        .price_precision(price_increment.precision)
351        .size_precision(size_increment.precision)
352        .price_increment(price_increment)
353        .size_increment(size_increment)
354        .maybe_lot_size(lot_size)
355        .maybe_min_quantity(min_quantity)
356        .margin_init(margin_init)
357        .margin_maint(margin_maint)
358        .info(Params::new(info))
359        .ts_event(ts_event)
360        .ts_init(ts_init)
361        .build()
362        .context("failed to construct AX perpetual contract")?;
363
364    Ok(InstrumentAny::PerpetualContract(instrument))
365}
366
367fn parse_margin_rates(
368    initial_margin_pct: Decimal,
369    maintenance_margin_pct: Decimal,
370) -> anyhow::Result<(Decimal, Decimal)> {
371    anyhow::ensure!(
372        initial_margin_pct > Decimal::ZERO,
373        "AX initial_margin_pct must be positive, was {initial_margin_pct}"
374    );
375    anyhow::ensure!(
376        maintenance_margin_pct > Decimal::ZERO,
377        "AX maintenance_margin_pct must be positive, was {maintenance_margin_pct}"
378    );
379    anyhow::ensure!(
380        maintenance_margin_pct <= initial_margin_pct,
381        "AX maintenance_margin_pct {maintenance_margin_pct} exceeds initial_margin_pct {initial_margin_pct}"
382    );
383
384    Ok((
385        margin_percent_to_rate(initial_margin_pct, "initial_margin_pct")?,
386        margin_percent_to_rate(maintenance_margin_pct, "maintenance_margin_pct")?,
387    ))
388}
389
390fn margin_percent_to_rate(value: Decimal, field: &str) -> anyhow::Result<Decimal> {
391    let normalized = value.normalize();
392    let scale = normalized.scale();
393    anyhow::ensure!(
394        scale <= 26,
395        "AX {field} scale must not exceed 26 for exact percent conversion, was {scale}"
396    );
397    Decimal::try_from_i128_with_scale(normalized.mantissa(), scale + 2)
398        .with_context(|| format!("Failed to convert AX {field} percentage to a rate"))
399}
400
401/// Parses an Ax balances response into a Nautilus [`AccountState`].
402///
403/// Ax provides a simple balance structure with symbol and amount. When a
404/// [`AxRiskSnapshot`] is supplied, its USD margin requirements populate the
405/// USD balance's locked funds and the account's margin balances; without one,
406/// locked stays zero for every currency.
407///
408/// # Errors
409///
410/// Returns an error if balance amount parsing fails.
411pub fn parse_account_state(
412    response: &AxBalancesResponse,
413    risk: Option<&AxRiskSnapshot>,
414    account_id: AccountId,
415    ts_event: UnixNanos,
416    ts_init: UnixNanos,
417) -> anyhow::Result<AccountState> {
418    // Pass the raw USD requirement through; from_total_and_locked clamps a disagreement
419    let usd_locked = risk.map_or(Decimal::ZERO, |risk| risk.initial_margin_required_total);
420
421    let mut balances = Vec::with_capacity(response.balances.len());
422
423    for balance in &response.balances {
424        let symbol_str = balance.symbol.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        let locked = if currency == Currency::USD() {
433            usd_locked
434        } else {
435            Decimal::ZERO
436        };
437
438        let balance = AccountBalance::from_total_and_locked(balance.amount, locked, currency)
439            .with_context(|| format!("Failed to convert balance for {symbol_str}"))?;
440        balances.push(balance);
441    }
442
443    if balances.is_empty() {
444        let zero_currency = Currency::USD();
445        let zero_money = Money::zero(zero_currency);
446        balances.push(AccountBalance::new(zero_money, zero_money, zero_money));
447    }
448
449    let margins = risk
450        .map(|risk| {
451            vec![MarginBalance::new(
452                Money::from_decimal(risk.initial_margin_required_total, Currency::USD())
453                    .unwrap_or_else(|_| Money::zero(Currency::USD())),
454                Money::from_decimal(risk.maintenance_margin_required, Currency::USD())
455                    .unwrap_or_else(|_| Money::zero(Currency::USD())),
456                None,
457            )]
458        })
459        .unwrap_or_default();
460
461    Ok(AccountState::new(
462        account_id,
463        AccountType::Margin,
464        balances,
465        margins,
466        true,
467        UUID4::new(),
468        ts_event,
469        ts_init,
470        None,
471    ))
472}
473
474/// Parses an Ax open order into a Nautilus [`OrderStatusReport`].
475///
476/// The `cid_resolver` parameter is an optional function that resolves a `cid` (u64)
477/// to a `ClientOrderId`. This is needed because orders submitted via WebSocket use
478/// a hashed `cid` for correlation rather than storing the full `ClientOrderId` in the tag.
479///
480/// # Errors
481///
482/// Returns an error if:
483/// - Price or quantity fields cannot be parsed.
484/// - Timestamp conversion fails.
485pub fn parse_order_status_report<F>(
486    order: &AxOpenOrder,
487    account_id: AccountId,
488    instrument: &InstrumentAny,
489    ts_init: UnixNanos,
490    cid_resolver: Option<&F>,
491) -> anyhow::Result<OrderStatusReport>
492where
493    F: Fn(u64) -> Option<ClientOrderId>,
494{
495    parse_order_status_report_fields(
496        &OrderStatusReportFields::from(order),
497        account_id,
498        instrument,
499        ts_init,
500        cid_resolver,
501    )
502}
503
504/// Parses an Ax historical order into a Nautilus [`OrderStatusReport`].
505///
506/// The `cid_resolver` parameter is an optional function that resolves a `cid` (u64)
507/// to a `ClientOrderId`. This is needed because orders submitted via WebSocket use
508/// a hashed `cid` for correlation rather than storing the full `ClientOrderId` in the tag.
509///
510/// # Errors
511///
512/// Returns an error if:
513/// - Price or quantity fields cannot be parsed.
514/// - Timestamp conversion fails.
515pub fn parse_order_detail_status_report<F>(
516    order: &AxOrderDetail,
517    account_id: AccountId,
518    instrument: &InstrumentAny,
519    ts_init: UnixNanos,
520    cid_resolver: Option<&F>,
521) -> anyhow::Result<OrderStatusReport>
522where
523    F: Fn(u64) -> Option<ClientOrderId>,
524{
525    parse_order_status_report_fields(
526        &OrderStatusReportFields::from(order),
527        account_id,
528        instrument,
529        ts_init,
530        cid_resolver,
531    )
532}
533
534pub(crate) fn update_order_report_timestamps(
535    reports: &mut [OrderStatusReport],
536    fills: &[FillReport],
537) {
538    let mut last_fill_timestamps = HashMap::new();
539    for fill in fills {
540        last_fill_timestamps
541            .entry((fill.account_id, fill.venue_order_id))
542            .and_modify(|ts: &mut UnixNanos| *ts = (*ts).max(fill.ts_event))
543            .or_insert(fill.ts_event);
544    }
545
546    for report in reports {
547        if !report.order_status.is_closed() {
548            continue;
549        }
550
551        if let Some(ts_last_fill) =
552            last_fill_timestamps.get(&(report.account_id, report.venue_order_id))
553        {
554            report.ts_last = report.ts_last.max(*ts_last_fill);
555        }
556    }
557}
558
559struct OrderStatusReportFields<'a> {
560    ts: i64,
561    tn: i64,
562    post_only: bool,
563    oid: &'a str,
564    price: Decimal,
565    quantity: u64,
566    filled_qty: u64,
567    status: AxOrderStatus,
568    side: AxOrderSide,
569    time_in_force: AxTimeInForce,
570    cid: Option<u64>,
571    reject_reason: Option<AxOrderRejectReason>,
572    text: Option<&'a str>,
573}
574
575impl<'a> From<&'a AxOpenOrder> for OrderStatusReportFields<'a> {
576    fn from(order: &'a AxOpenOrder) -> Self {
577        Self {
578            ts: order.ts,
579            tn: order.tn,
580            post_only: order.po,
581            oid: &order.oid,
582            price: order.p,
583            quantity: order.q,
584            filled_qty: order.xq,
585            status: order.o,
586            side: order.d,
587            time_in_force: order.tif,
588            cid: order.cid,
589            reject_reason: order.r,
590            text: order.txt.as_deref(),
591        }
592    }
593}
594
595impl<'a> From<&'a AxOrderDetail> for OrderStatusReportFields<'a> {
596    fn from(order: &'a AxOrderDetail) -> Self {
597        Self {
598            ts: order.ts,
599            tn: order.tn,
600            post_only: order.po,
601            oid: &order.oid,
602            price: order.p,
603            quantity: order.q,
604            filled_qty: order.xq,
605            status: order.o,
606            side: order.d,
607            time_in_force: order.tif,
608            cid: order.cid,
609            reject_reason: order.r,
610            text: order.txt.as_deref(),
611        }
612    }
613}
614
615fn parse_order_status_report_fields<F>(
616    order: &OrderStatusReportFields<'_>,
617    account_id: AccountId,
618    instrument: &InstrumentAny,
619    ts_init: UnixNanos,
620    cid_resolver: Option<&F>,
621) -> anyhow::Result<OrderStatusReport>
622where
623    F: Fn(u64) -> Option<ClientOrderId>,
624{
625    let instrument_id = instrument.id();
626    let venue_order_id = VenueOrderId::new(order.oid);
627    let order_side = OrderSide::from(order.side);
628    let time_in_force = TimeInForce::try_from(order.time_in_force)?;
629    let order_status = parse_order_status(order.status, time_in_force)?;
630
631    // The current AX wire shape maps to a Nautilus limit order.
632    let order_type = OrderType::Limit;
633
634    // Parse quantity (Ax uses i64 contracts)
635    let quantity =
636        Quantity::from_decimal_dp(Decimal::from(order.quantity), instrument.size_precision())?;
637    let filled_qty =
638        Quantity::from_decimal_dp(Decimal::from(order.filled_qty), instrument.size_precision())?;
639
640    // Parse price
641    let price = decimal_to_price_dp(order.price, instrument.price_precision(), "order.p")?;
642
643    // Ax timestamps are in Unix epoch seconds
644    let ts_event = ax_timestamp_stn_to_unix_nanos(order.ts, order.tn)?;
645
646    let mut report = OrderStatusReport::new(
647        account_id,
648        instrument_id,
649        None,
650        venue_order_id,
651        order_side.into(),
652        order_type,
653        time_in_force,
654        order_status,
655        quantity,
656        filled_qty,
657        ts_event,
658        ts_event,
659        ts_init,
660        Some(UUID4::new()),
661    );
662
663    if let Some(cid) = order.cid {
664        let client_order_id = cid_resolver
665            .and_then(|resolver| resolver(cid))
666            .unwrap_or_else(|| cid_to_client_order_id(cid));
667        report = report.with_client_order_id(client_order_id);
668    }
669
670    report = report.with_price(price).with_post_only(order.post_only);
671
672    // Keep reconciled terminal reasons identical to live WS events
673    if let Some(reason) = AxOrderRejectReason::reason_str(order.reject_reason, order.text) {
674        report = report.with_cancel_reason(reason);
675    }
676
677    // We don't set avg_px here since the order endpoint only provides the
678    // limit price, not actual fill prices. True average would need to be
679    // calculated from fill reports.
680
681    Ok(report)
682}
683
684pub(super) fn parse_order_status(
685    status: AxOrderStatus,
686    time_in_force: TimeInForce,
687) -> anyhow::Result<OrderStatus> {
688    if status == AxOrderStatus::Expired
689        && matches!(time_in_force, TimeInForce::Ioc | TimeInForce::Fok)
690    {
691        return Ok(OrderStatus::Canceled);
692    }
693
694    OrderStatus::try_from(status)
695}
696
697/// Parses an Ax fill into a Nautilus [`FillReport`].
698///
699/// AX may omit an order ID for block trades and final settlement fills. Those
700/// records receive a deterministic reconciliation ID derived from the trade ID.
701/// The special-fill classifications are optional for regular fills with an
702/// order ID.
703///
704/// # Errors
705///
706/// Returns an error if:
707/// - Price or quantity fields cannot be parsed.
708/// - Fee parsing fails.
709/// - Fill classification is inconsistent.
710/// - A fill is neither explicitly special nor linked to a valid order ID.
711pub fn parse_fill_report(
712    fill: &AxFill,
713    account_id: AccountId,
714    instrument: &InstrumentAny,
715    ts_init: UnixNanos,
716) -> anyhow::Result<FillReport> {
717    let instrument_id = instrument.id();
718
719    let trade_id = TradeId::new_checked(&fill.trade_id).context("Invalid trade_id in Ax fill")?;
720    let is_block_trade = fill.is_block_trade;
721    let is_final_settlement = fill.is_final_settlement;
722    anyhow::ensure!(
723        !(is_final_settlement == Some(true) && is_block_trade == Some(false)),
724        "AX final-settlement fill must also be classified as a block trade"
725    );
726
727    let is_special_fill = is_block_trade == Some(true) || is_final_settlement == Some(true);
728
729    let venue_order_id = if is_special_fill {
730        VenueOrderId::new_checked(format!("{AX_FILL_ORDER_PREFIX}{}", fill.trade_id))
731            .context("Invalid synthetic venue order ID for AX fill")?
732    } else {
733        let order_id = fill
734            .order_id
735            .as_deref()
736            .context("AX fill is missing order_id and explicit special-fill classification")?;
737        anyhow::ensure!(
738            !order_id.is_empty(),
739            "AX regular fill has an empty order_id"
740        );
741        VenueOrderId::new_checked(order_id).context("Invalid order_id in AX fill")?
742    };
743
744    // Use explicit side field from fill
745    let order_side = OrderSide::from(fill.side);
746
747    let last_px = decimal_to_price_dp(fill.price, instrument.price_precision(), "fill.price")?;
748    let last_qty =
749        Quantity::from_decimal_dp(Decimal::from(fill.quantity), instrument.size_precision())?;
750
751    let currency = Currency::USD();
752    let commission = Money::from_decimal(fill.fee, currency)
753        .context("Failed to convert fill.fee Decimal to Money")?;
754
755    let liquidity_side = if fill.is_taker {
756        LiquiditySide::Taker
757    } else {
758        LiquiditySide::Maker
759    };
760
761    let ts_event = match u64::try_from(fill.timestamp.as_nanosecond().unsigned_abs()) {
762        Ok(nanos) => UnixNanos::from(nanos),
763        Err(_) => {
764            log::warn!(
765                "Timestamp overflow for fill {} (timestamp={}), defaulting to 0",
766                fill.trade_id,
767                fill.timestamp
768            );
769            UnixNanos::from(0u64)
770        }
771    };
772
773    Ok(FillReport::new(
774        account_id,
775        instrument_id,
776        venue_order_id,
777        trade_id,
778        order_side,
779        last_qty,
780        last_px,
781        commission,
782        liquidity_side,
783        None,
784        None,
785        ts_event,
786        ts_init,
787        None,
788    ))
789}
790
791/// Parses an Ax position into a Nautilus [`PositionStatusReport`].
792///
793/// Reports the signed fill-ledger cost-basis average with
794/// [`AvgPxReconciliation::OpeningOnly`] semantics.
795///
796/// # Errors
797///
798/// Returns an error if:
799/// - Position quantity parsing fails.
800/// - Timestamp conversion fails.
801pub fn parse_position_status_report(
802    position: &AxPosition,
803    account_id: AccountId,
804    instrument: &InstrumentAny,
805    ts_init: UnixNanos,
806) -> anyhow::Result<PositionStatusReport> {
807    let instrument_id = instrument.id();
808
809    // Determine position side and quantity from signed_quantity sign
810    let (position_side, quantity) = if position.signed_quantity > 0 {
811        (
812            PositionSide::Long,
813            Quantity::from_decimal_dp(
814                Decimal::from(position.signed_quantity),
815                instrument.size_precision(),
816            )?,
817        )
818    } else if position.signed_quantity < 0 {
819        (
820            PositionSide::Short,
821            Quantity::from_decimal_dp(
822                Decimal::from(position.signed_quantity.unsigned_abs()),
823                instrument.size_precision(),
824            )?,
825        )
826    } else {
827        (
828            PositionSide::Flat,
829            Quantity::zero(instrument.size_precision()),
830        )
831    };
832
833    let avg_px_open = if position.signed_quantity != 0 {
834        let qty_dec = Decimal::from(position.signed_quantity);
835        Some(
836            position
837                .signed_cost_basis
838                .unwrap_or(position.signed_notional)
839                / qty_dec,
840        )
841    } else {
842        None
843    };
844
845    let ts_last = match u64::try_from(position.timestamp.as_nanosecond().unsigned_abs()) {
846        Ok(nanos) => UnixNanos::from(nanos),
847        Err(_) => {
848            log::warn!(
849                "Timestamp overflow for position {} (timestamp={}), defaulting to 0",
850                position.symbol,
851                position.timestamp
852            );
853            UnixNanos::from(0u64)
854        }
855    };
856
857    Ok(PositionStatusReport::new(
858        account_id,
859        instrument_id,
860        position_side,
861        quantity,
862        ts_last,
863        ts_init,
864        None,
865        None,
866        avg_px_open,
867    )
868    .with_avg_px_open_reconciliation(AvgPxReconciliation::OpeningOnly))
869}
870
871/// Parses an Ax REST trade into a Nautilus [`TradeTick`].
872///
873/// # Errors
874///
875/// Returns an error if any field cannot be parsed.
876pub fn parse_trade_tick(
877    trade: &AxRestTrade,
878    instrument: &InstrumentAny,
879    ts_init: UnixNanos,
880) -> anyhow::Result<TradeTick> {
881    let price = decimal_to_price_dp(trade.p, instrument.price_precision(), "trade.p")?;
882    let size = Quantity::from_decimal_dp(Decimal::from(trade.q), instrument.size_precision())?;
883    let aggressor_side: AggressorSide = trade.d.into();
884
885    let ts_event = ax_timestamp_stn_to_unix_nanos(trade.ts, trade.tn)?;
886    let trade_id = create_architect_trade_id(ts_event, price, size, aggressor_side)?;
887
888    TradeTick::new_checked(
889        instrument.id(),
890        price,
891        size,
892        aggressor_side,
893        trade_id,
894        ts_event,
895        ts_init,
896    )
897    .context("Failed to construct TradeTick from Ax REST trade")
898}
899
900#[cfg(test)]
901mod tests {
902    use jiff::Timestamp;
903    use nautilus_core::nanos::UnixNanos;
904    use nautilus_model::enums::OrderStatus;
905    use rstest::rstest;
906    use rust_decimal_macros::dec;
907    use ustr::Ustr;
908
909    use super::*;
910    use crate::{
911        common::enums::{AxCategory, AxInstrumentState, AxOrderSide, AxOrderStatus, AxTimeInForce},
912        http::models::{
913            AxBalance, AxBalancesResponse, AxFundingRatesResponse, AxInstrumentsResponse,
914            AxOpenOrder, AxOrderDetail, AxOrderRejectReason,
915        },
916    };
917
918    fn create_eurusd_instrument() -> AxInstrument {
919        AxInstrument {
920            price_scale: None,
921            additional_product_specs: Default::default(),
922            delisted_at: None,
923            is_closing: false,
924            estimated_funding_supported: false,
925            funding_schedule_calendar_description: None,
926            funding_schedule_time_description: None,
927            funding_schedule: None,
928            trading_schedule: None,
929            symbol: Ustr::from("EURUSD-PERP"),
930            product: Some(Ustr::from("EURUSD")),
931            state: AxInstrumentState::Open,
932            multiplier: dec!(1),
933            minimum_order_size: dec!(100),
934            tick_size: dec!(0.0001),
935            quote_currency: Ustr::from("USD"),
936            funding_settlement_currency: Ustr::from("USD"),
937            category: AxCategory::Fx,
938            maintenance_margin_pct: dec!(4.0),
939            initial_margin_pct: dec!(8.0),
940            contract_mark_price: Some("Average price on AX at London 4pm".to_string()),
941            contract_size: Some("1 Euro per contract".to_string()),
942            description: Some("Euro / US Dollar FX Perpetual Future".to_string()),
943            expiration: None,
944            funding_calendar_schedule: None,
945            funding_frequency: None,
946            funding_rate_cap_lower_pct: Some(dec!(-1.0)),
947            funding_rate_cap_upper_pct: Some(dec!(1.0)),
948            price_band_lower_deviation_pct: Some(dec!(10)),
949            price_band_upper_deviation_pct: Some(dec!(10)),
950            price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
951            price_quotation: Some("U.S. dollars per Euro".to_string()),
952            underlying_benchmark_price: Some("WMR London 4pm Closing Spot Rate".to_string()),
953        }
954    }
955
956    fn create_nvda_instrument() -> AxInstrument {
957        AxInstrument {
958            price_scale: None,
959            additional_product_specs: Default::default(),
960            delisted_at: None,
961            is_closing: false,
962            estimated_funding_supported: false,
963            funding_schedule_calendar_description: None,
964            funding_schedule_time_description: None,
965            funding_schedule: None,
966            trading_schedule: None,
967            symbol: Ustr::from("NVDA-PERP"),
968            product: Some(Ustr::from("NVDA")),
969            state: AxInstrumentState::Open,
970            multiplier: dec!(1),
971            minimum_order_size: dec!(1),
972            tick_size: dec!(0.01),
973            quote_currency: Ustr::from("USD"),
974            funding_settlement_currency: Ustr::from("USD"),
975            category: AxCategory::Equities,
976            maintenance_margin_pct: dec!(10),
977            initial_margin_pct: dec!(20),
978            contract_mark_price: Some(
979                "Average price on ArchitectX at 4pm New York Time".to_string(),
980            ),
981            contract_size: Some("1 share per contract".to_string()),
982            description: Some("NVIDIA Corp US Equity Perpetual Future".to_string()),
983            expiration: None,
984            funding_calendar_schedule: None,
985            funding_frequency: None,
986            funding_rate_cap_lower_pct: Some(dec!(-1)),
987            funding_rate_cap_upper_pct: Some(dec!(1)),
988            price_band_lower_deviation_pct: Some(dec!(10)),
989            price_band_upper_deviation_pct: Some(dec!(10)),
990            price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
991            price_quotation: Some("U.S. dollars per share".to_string()),
992            underlying_benchmark_price: Some("Nasdaq Official Closing Price".to_string()),
993        }
994    }
995
996    fn create_xau_instrument() -> AxInstrument {
997        AxInstrument {
998            price_scale: None,
999            additional_product_specs: Default::default(),
1000            delisted_at: None,
1001            is_closing: false,
1002            estimated_funding_supported: false,
1003            funding_schedule_calendar_description: None,
1004            funding_schedule_time_description: None,
1005            funding_schedule: None,
1006            trading_schedule: None,
1007            symbol: Ustr::from("XAU-PERP"),
1008            product: Some(Ustr::from("XAU")),
1009            state: AxInstrumentState::Open,
1010            multiplier: dec!(1),
1011            minimum_order_size: dec!(1),
1012            tick_size: dec!(0.1),
1013            quote_currency: Ustr::from("USD"),
1014            funding_settlement_currency: Ustr::from("USD"),
1015            category: AxCategory::Metals,
1016            maintenance_margin_pct: dec!(5),
1017            initial_margin_pct: dec!(10),
1018            contract_mark_price: Some("Average price on ArchitectX at London 4pm".to_string()),
1019            contract_size: Some("1 ounce per contract".to_string()),
1020            description: Some("Gold Metals Perpetual Future".to_string()),
1021            expiration: None,
1022            funding_calendar_schedule: None,
1023            funding_frequency: None,
1024            funding_rate_cap_lower_pct: Some(dec!(-1)),
1025            funding_rate_cap_upper_pct: Some(dec!(1)),
1026            price_band_lower_deviation_pct: Some(dec!(10)),
1027            price_band_upper_deviation_pct: Some(dec!(10)),
1028            price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
1029            price_quotation: Some("U.S. dollars per ounce".to_string()),
1030            underlying_benchmark_price: Some("XAU WMR Metals Daily Closing Rate".to_string()),
1031        }
1032    }
1033
1034    fn create_fill() -> AxFill {
1035        AxFill {
1036            trade_id: "T-01ARZ3NDEKTSV4RRFFQ69G5FAV".to_string(),
1037            order_id: Some("O-01ARZ3NDEKTSV4RRFFQ69G5FAV".to_string()),
1038            fee: dec!(0.10),
1039            is_taker: true,
1040            is_block_trade: Some(false),
1041            is_final_settlement: Some(false),
1042            price: dec!(1.0845),
1043            quantity: 100,
1044            side: AxOrderSide::Buy,
1045            symbol: Ustr::from("EURUSD-PERP"),
1046            timestamp: "2026-07-17T00:00:00Z".parse::<Timestamp>().unwrap(),
1047            account_id: Ustr::from("account-1"),
1048            realized_pnl: None,
1049        }
1050    }
1051
1052    #[rstest]
1053    fn test_decimal_to_price() {
1054        let price = decimal_to_price(dec!(100.50), "test_field").unwrap();
1055        assert_eq!(price.as_f64(), 100.50);
1056    }
1057
1058    #[rstest]
1059    fn test_decimal_to_quantity() {
1060        let qty = decimal_to_quantity(dec!(1.5), "test_field").unwrap();
1061        assert_eq!(qty.as_f64(), 1.5);
1062    }
1063
1064    #[rstest]
1065    fn test_get_currency_known() {
1066        let currency = get_currency("USD");
1067        assert_eq!(currency.code, Ustr::from("USD"));
1068        assert_eq!(currency.precision, 2);
1069    }
1070
1071    #[rstest]
1072    fn test_get_currency_unknown_creates_new() {
1073        let currency = get_currency("NVDA");
1074        assert_eq!(currency.code, Ustr::from("NVDA"));
1075        assert_eq!(currency.precision, 0);
1076    }
1077
1078    fn create_order_detail(
1079        status: AxOrderStatus,
1080        reject_reason: Option<AxOrderRejectReason>,
1081        text: Option<&str>,
1082    ) -> AxOrderDetail {
1083        AxOrderDetail {
1084            ts: 1_609_459_200,
1085            tn: 0,
1086            oid: "O-REJECTED".to_string(),
1087            aid: Some(Ustr::from("account-1")),
1088            u: Ustr::from("user"),
1089            s: Ustr::from("EURUSD-PERP"),
1090            p: dec!(1.0845),
1091            q: 100,
1092            xq: 0,
1093            rq: 100,
1094            o: status,
1095            d: AxOrderSide::Buy,
1096            tif: AxTimeInForce::Gtc,
1097            cid: Some(42),
1098            r: reject_reason,
1099            tag: None,
1100            txt: text.map(str::to_string),
1101            po: false,
1102            rb: None,
1103        }
1104    }
1105
1106    #[rstest]
1107    #[case::canceled(AxOrderStatus::Canceled, "matching", 1_704_067_200_508_000_123)]
1108    #[case::expired(AxOrderStatus::Expired, "matching", 1_704_067_200_508_000_123)]
1109    #[case::filled(AxOrderStatus::Filled, "matching", 1_704_067_200_508_000_123)]
1110    #[case::open(AxOrderStatus::PartiallyFilled, "matching", 1_704_067_200_500_000_000)]
1111    #[case::empty(AxOrderStatus::Canceled, "empty", 1_704_067_200_500_000_000)]
1112    #[case::older(AxOrderStatus::Canceled, "older", 1_704_067_200_500_000_000)]
1113    #[case::other_account(AxOrderStatus::Canceled, "other_account", 1_704_067_200_500_000_000)]
1114    #[case::later_report(AxOrderStatus::Canceled, "later_report", 1_704_067_200_600_000_000)]
1115    fn test_closed_order_report_uses_latest_fill_timestamp(
1116        #[case] status: AxOrderStatus,
1117        #[case] scenario: &str,
1118        #[case] expected_last: u64,
1119    ) {
1120        let orders: crate::http::models::AxOrdersResponse = serde_json::from_str(include_str!(
1121            "../../test_data/http_get_orders_partial_canceled.json"
1122        ))
1123        .unwrap();
1124        let fills: crate::http::models::AxFillsResponse = serde_json::from_str(include_str!(
1125            "../../test_data/http_get_fills_partial_canceled.json"
1126        ))
1127        .unwrap();
1128        let instrument = parse_instrument(
1129            &create_eurusd_instrument(),
1130            UnixNanos::default(),
1131            UnixNanos::default(),
1132        )
1133        .unwrap();
1134        let account_id = AccountId::from("AX-001");
1135        let ts_init = UnixNanos::from(1_704_067_201_000_000_000_u64);
1136        let mut order = orders.orders[0].clone();
1137        order.o = status;
1138        order.q = if status == AxOrderStatus::Filled {
1139            100
1140        } else {
1141            200
1142        };
1143
1144        order.rq = if status == AxOrderStatus::PartiallyFilled {
1145            100
1146        } else {
1147            0
1148        };
1149
1150        let mut reports = [parse_order_detail_status_report(
1151            &order,
1152            account_id,
1153            &instrument,
1154            ts_init,
1155            None::<&fn(u64) -> Option<ClientOrderId>>,
1156        )
1157        .unwrap()];
1158        let mut fills: Vec<_> = fills
1159            .fills
1160            .iter()
1161            .map(|fill| parse_fill_report(fill, account_id, &instrument, ts_init).unwrap())
1162            .collect();
1163
1164        match scenario {
1165            "empty" => fills.clear(),
1166            "older" => {
1167                for fill in &mut fills {
1168                    fill.ts_event = UnixNanos::from(1_704_067_200_400_000_000_u64);
1169                }
1170            }
1171            "other_account" => {
1172                for fill in &mut fills {
1173                    fill.account_id = AccountId::from("AX-OTHER");
1174                }
1175            }
1176            "later_report" => {
1177                reports[0].ts_last = UnixNanos::from(1_704_067_200_600_000_000_u64);
1178            }
1179            "matching" => {}
1180            _ => panic!("unexpected test scenario: {scenario}"),
1181        }
1182
1183        let mut expected = reports[0].clone();
1184        expected.ts_last = UnixNanos::from(expected_last);
1185
1186        update_order_report_timestamps(&mut reports, &fills);
1187
1188        let report = &reports[0];
1189        assert_eq!(*report, expected);
1190        assert_eq!(
1191            report.ts_accepted,
1192            UnixNanos::from(1_704_067_200_500_000_000_u64)
1193        );
1194        assert_eq!(report.ts_last, UnixNanos::from(expected_last));
1195        assert_eq!(report.ts_init, ts_init);
1196        assert_eq!(report.quantity.as_decimal(), Decimal::from(order.q));
1197        assert_eq!(report.filled_qty.as_decimal(), dec!(100));
1198        assert_eq!(
1199            report.venue_order_id,
1200            VenueOrderId::from("O-PARTIAL-CANCELED")
1201        );
1202    }
1203
1204    #[rstest]
1205    fn test_parse_order_detail_status_report_carries_reject_reason() {
1206        let instrument = parse_instrument(
1207            &create_eurusd_instrument(),
1208            UnixNanos::default(),
1209            UnixNanos::default(),
1210        )
1211        .unwrap();
1212        let order = create_order_detail(
1213            AxOrderStatus::Rejected,
1214            Some(AxOrderRejectReason::PriceOutOfBounds),
1215            None,
1216        );
1217
1218        let report = parse_order_detail_status_report(
1219            &order,
1220            AccountId::from("AX-001"),
1221            &instrument,
1222            UnixNanos::default(),
1223            None::<&fn(u64) -> Option<ClientOrderId>>,
1224        )
1225        .unwrap();
1226
1227        assert_eq!(report.order_status, OrderStatus::Rejected);
1228        assert_eq!(
1229            report.cancel_reason,
1230            Some("PRICE_OUT_OF_BOUNDS".to_string())
1231        );
1232    }
1233
1234    #[rstest]
1235    fn test_parse_order_detail_status_report_falls_back_to_text_reason() {
1236        let instrument = parse_instrument(
1237            &create_eurusd_instrument(),
1238            UnixNanos::default(),
1239            UnixNanos::default(),
1240        )
1241        .unwrap();
1242        let order = create_order_detail(
1243            AxOrderStatus::Rejected,
1244            Some(AxOrderRejectReason::Unknown),
1245            Some("risk blocked"),
1246        );
1247
1248        let report = parse_order_detail_status_report(
1249            &order,
1250            AccountId::from("AX-001"),
1251            &instrument,
1252            UnixNanos::default(),
1253            None::<&fn(u64) -> Option<ClientOrderId>>,
1254        )
1255        .unwrap();
1256
1257        assert_eq!(report.cancel_reason, Some("risk blocked".to_string()));
1258    }
1259
1260    #[rstest]
1261    fn test_parse_order_detail_status_report_without_reason_leaves_cancel_reason_unset() {
1262        let instrument = parse_instrument(
1263            &create_eurusd_instrument(),
1264            UnixNanos::default(),
1265            UnixNanos::default(),
1266        )
1267        .unwrap();
1268        let order = create_order_detail(AxOrderStatus::Filled, None, None);
1269
1270        let report = parse_order_detail_status_report(
1271            &order,
1272            AccountId::from("AX-001"),
1273            &instrument,
1274            UnixNanos::default(),
1275            None::<&fn(u64) -> Option<ClientOrderId>>,
1276        )
1277        .unwrap();
1278
1279        assert_eq!(report.cancel_reason, None);
1280    }
1281
1282    fn create_risk_snapshot() -> AxRiskSnapshot {
1283        AxRiskSnapshot {
1284            account_id: Ustr::from("account-1"),
1285            timestamp_ns: "2024-01-15T10:30:00Z".parse::<Timestamp>().unwrap(),
1286            balance_usd: dec!(100000.50),
1287            equity: dec!(102500.75),
1288            initial_margin_available: dec!(95500.75),
1289            initial_margin_required_for_open_orders: dec!(2000.00),
1290            initial_margin_required_for_positions: dec!(5000.00),
1291            initial_margin_required_total: dec!(7000.00),
1292            maintenance_margin_available: dec!(99000.75),
1293            maintenance_margin_required: dec!(3500.00),
1294            unrealized_pnl: dec!(2500.25),
1295            per_symbol: Default::default(),
1296        }
1297    }
1298
1299    fn create_balances_response() -> AxBalancesResponse {
1300        AxBalancesResponse {
1301            usd_borrow: None,
1302            balances: vec![
1303                AxBalance {
1304                    account_id: None,
1305                    symbol: Ustr::from("USD"),
1306                    amount: dec!(100000.50),
1307                },
1308                AxBalance {
1309                    account_id: None,
1310                    symbol: Ustr::from("BTC"),
1311                    amount: dec!(1.25),
1312                },
1313            ],
1314        }
1315    }
1316
1317    #[rstest]
1318    fn test_parse_account_state_applies_risk_snapshot_margins() {
1319        let risk = create_risk_snapshot();
1320        let state = parse_account_state(
1321            &create_balances_response(),
1322            Some(&risk),
1323            AccountId::from("AX-001"),
1324            UnixNanos::default(),
1325            UnixNanos::default(),
1326        )
1327        .unwrap();
1328
1329        let usd = state
1330            .balances
1331            .iter()
1332            .find(|b| b.total.currency == Currency::USD())
1333            .unwrap();
1334        assert_eq!(usd.locked.as_decimal(), dec!(7000.00));
1335        assert_eq!(usd.free.as_decimal(), dec!(93000.50));
1336
1337        let btc = state
1338            .balances
1339            .iter()
1340            .find(|b| b.total.currency.code.as_str() == "BTC")
1341            .unwrap();
1342        assert_eq!(btc.locked.as_decimal(), Decimal::ZERO);
1343        assert_eq!(btc.free.as_decimal(), dec!(1.25));
1344
1345        assert_eq!(state.margins.len(), 1);
1346        assert_eq!(state.margins[0].initial.as_decimal(), dec!(7000.00));
1347        assert_eq!(state.margins[0].maintenance.as_decimal(), dec!(3500.00));
1348        assert_eq!(state.margins[0].currency, Currency::USD());
1349    }
1350
1351    #[rstest]
1352    fn test_parse_account_state_without_risk_snapshot_locks_zero() {
1353        let state = parse_account_state(
1354            &create_balances_response(),
1355            None,
1356            AccountId::from("AX-001"),
1357            UnixNanos::default(),
1358            UnixNanos::default(),
1359        )
1360        .unwrap();
1361
1362        let usd = state
1363            .balances
1364            .iter()
1365            .find(|b| b.total.currency == Currency::USD())
1366            .unwrap();
1367        assert_eq!(usd.locked.as_decimal(), Decimal::ZERO);
1368        assert_eq!(usd.free.as_decimal(), dec!(100000.50));
1369        assert!(state.margins.is_empty());
1370    }
1371
1372    #[rstest]
1373    fn test_parse_account_state_clamps_locked_to_balance() {
1374        let mut risk = create_risk_snapshot();
1375        risk.initial_margin_required_total = dec!(500000.00);
1376        let state = parse_account_state(
1377            &create_balances_response(),
1378            Some(&risk),
1379            AccountId::from("AX-001"),
1380            UnixNanos::default(),
1381            UnixNanos::default(),
1382        )
1383        .unwrap();
1384
1385        let usd = state
1386            .balances
1387            .iter()
1388            .find(|b| b.total.currency == Currency::USD())
1389            .unwrap();
1390        assert_eq!(usd.locked.as_decimal(), dec!(100000.50));
1391        assert_eq!(usd.free.as_decimal(), Decimal::ZERO);
1392    }
1393
1394    #[rstest]
1395    fn test_parse_account_state_negative_balance_preserves_locked() {
1396        // A borrowed balance keeps the raw margin requirement and carries the shortfall in free
1397        let mut balances = create_balances_response();
1398        balances.balances[0].amount = dec!(-500.00);
1399
1400        let state = parse_account_state(
1401            &balances,
1402            Some(&create_risk_snapshot()),
1403            AccountId::from("AX-001"),
1404            UnixNanos::default(),
1405            UnixNanos::default(),
1406        )
1407        .unwrap();
1408
1409        let usd = state
1410            .balances
1411            .iter()
1412            .find(|b| b.total.currency == Currency::USD())
1413            .unwrap();
1414        assert_eq!(usd.total.as_decimal(), dec!(-500.00));
1415        assert_eq!(usd.locked.as_decimal(), dec!(7000.00));
1416        assert_eq!(usd.free.as_decimal(), dec!(-7500.00));
1417    }
1418
1419    #[rstest]
1420    fn test_parse_order_status_report_uses_cid_resolver() {
1421        let instrument = parse_instrument(
1422            &create_eurusd_instrument(),
1423            UnixNanos::default(),
1424            UnixNanos::default(),
1425        )
1426        .unwrap();
1427        let order = AxOpenOrder {
1428            aid: None,
1429            r: None,
1430            txt: None,
1431            tn: 0,
1432            ts: 1_609_459_200,
1433            d: AxOrderSide::Buy,
1434            o: AxOrderStatus::Accepted,
1435            oid: "O-NEW".to_string(),
1436            p: dec!(1.0845),
1437            q: 100,
1438            rq: 100,
1439            s: Ustr::from("EURUSD-PERP"),
1440            tif: AxTimeInForce::Gtc,
1441            u: Ustr::from("user"),
1442            xq: 0,
1443            cid: Some(42),
1444            tag: None,
1445            po: true,
1446            rb: None,
1447        };
1448        let expected_client_order_id = ClientOrderId::from("O-PERSISTED");
1449        let resolver = |cid| (cid == 42).then_some(expected_client_order_id);
1450
1451        let report = parse_order_status_report(
1452            &order,
1453            AccountId::from("AX-001"),
1454            &instrument,
1455            UnixNanos::default(),
1456            Some(&resolver),
1457        )
1458        .unwrap();
1459
1460        assert_eq!(report.client_order_id, Some(expected_client_order_id));
1461        assert_eq!(report.venue_order_id, VenueOrderId::from("O-NEW"));
1462    }
1463
1464    #[rstest]
1465    #[case(Some(false), Some(false))]
1466    #[case(None, Some(false))]
1467    #[case(Some(false), None)]
1468    #[case(None, None)]
1469    fn test_parse_fill_report_uses_real_order_id_for_regular_fill(
1470        #[case] is_block_trade: Option<bool>,
1471        #[case] is_final_settlement: Option<bool>,
1472    ) {
1473        let instrument = parse_instrument(
1474            &create_eurusd_instrument(),
1475            UnixNanos::default(),
1476            UnixNanos::default(),
1477        )
1478        .unwrap();
1479        let mut fill = create_fill();
1480        fill.is_block_trade = is_block_trade;
1481        fill.is_final_settlement = is_final_settlement;
1482
1483        let report = parse_fill_report(
1484            &fill,
1485            AccountId::from("AX-001"),
1486            &instrument,
1487            UnixNanos::default(),
1488        )
1489        .unwrap();
1490
1491        assert_eq!(
1492            report.venue_order_id.as_str(),
1493            "O-01ARZ3NDEKTSV4RRFFQ69G5FAV"
1494        );
1495    }
1496
1497    #[rstest]
1498    fn test_parse_fill_report_rounds_sub_cent_fee_to_usd_precision() {
1499        let instrument = parse_instrument(
1500            &create_eurusd_instrument(),
1501            UnixNanos::default(),
1502            UnixNanos::default(),
1503        )
1504        .unwrap();
1505        let mut fill = create_fill();
1506        fill.fee = dec!(0.012188);
1507
1508        let report = parse_fill_report(
1509            &fill,
1510            AccountId::from("AX-001"),
1511            &instrument,
1512            UnixNanos::default(),
1513        )
1514        .unwrap();
1515
1516        assert_eq!(report.commission, Money::from("0.01 USD"));
1517    }
1518
1519    #[rstest]
1520    fn test_parse_fill_report_rejects_unrepresentable_fee() {
1521        let instrument = parse_instrument(
1522            &create_eurusd_instrument(),
1523            UnixNanos::default(),
1524            UnixNanos::default(),
1525        )
1526        .unwrap();
1527        let mut fill = create_fill();
1528        fill.fee = dec!(9999999999999999999999999999);
1529
1530        let error = parse_fill_report(
1531            &fill,
1532            AccountId::from("AX-001"),
1533            &instrument,
1534            UnixNanos::default(),
1535        )
1536        .unwrap_err();
1537
1538        assert!(
1539            error
1540                .to_string()
1541                .contains("Failed to convert fill.fee Decimal to Money"),
1542            "expected fee conversion failure, was {error}"
1543        );
1544    }
1545
1546    #[rstest]
1547    #[case(None)]
1548    #[case(Some("O-01ARZ3NDEKTSV4RRFFQ69G5FAV"))]
1549    fn test_parse_fill_report_uses_stable_surrogate_for_block_fill(#[case] order_id: Option<&str>) {
1550        let instrument = parse_instrument(
1551            &create_eurusd_instrument(),
1552            UnixNanos::default(),
1553            UnixNanos::default(),
1554        )
1555        .unwrap();
1556        let mut fill = create_fill();
1557        fill.order_id = order_id.map(str::to_string);
1558        fill.is_block_trade = Some(true);
1559
1560        let report = parse_fill_report(
1561            &fill,
1562            AccountId::from("AX-001"),
1563            &instrument,
1564            UnixNanos::default(),
1565        )
1566        .unwrap();
1567
1568        assert_eq!(
1569            report.venue_order_id.as_str(),
1570            "AX-FILL-T-01ARZ3NDEKTSV4RRFFQ69G5FAV"
1571        );
1572    }
1573
1574    #[rstest]
1575    fn test_parse_fill_report_uses_stable_surrogate_for_final_settlement() {
1576        let instrument = parse_instrument(
1577            &create_eurusd_instrument(),
1578            UnixNanos::default(),
1579            UnixNanos::default(),
1580        )
1581        .unwrap();
1582        let mut fill = create_fill();
1583        fill.order_id = None;
1584        fill.is_block_trade = Some(true);
1585        fill.is_final_settlement = Some(true);
1586
1587        let report = parse_fill_report(
1588            &fill,
1589            AccountId::from("AX-001"),
1590            &instrument,
1591            UnixNanos::default(),
1592        )
1593        .unwrap();
1594
1595        assert_eq!(
1596            report.venue_order_id.as_str(),
1597            "AX-FILL-T-01ARZ3NDEKTSV4RRFFQ69G5FAV"
1598        );
1599    }
1600
1601    #[rstest]
1602    fn test_parse_fill_report_rejects_final_settlement_without_block_classification() {
1603        let instrument = parse_instrument(
1604            &create_eurusd_instrument(),
1605            UnixNanos::default(),
1606            UnixNanos::default(),
1607        )
1608        .unwrap();
1609        let mut fill = create_fill();
1610        fill.is_block_trade = Some(false);
1611        fill.is_final_settlement = Some(true);
1612
1613        let error = parse_fill_report(
1614            &fill,
1615            AccountId::from("AX-001"),
1616            &instrument,
1617            UnixNanos::default(),
1618        )
1619        .unwrap_err();
1620
1621        assert_eq!(
1622            error.to_string(),
1623            "AX final-settlement fill must also be classified as a block trade"
1624        );
1625    }
1626
1627    #[rstest]
1628    fn test_parse_fill_report_rejects_missing_identity() {
1629        let instrument = parse_instrument(
1630            &create_eurusd_instrument(),
1631            UnixNanos::default(),
1632            UnixNanos::default(),
1633        )
1634        .unwrap();
1635        let mut fill = create_fill();
1636        fill.order_id = None;
1637        fill.is_block_trade = None;
1638        fill.is_final_settlement = None;
1639
1640        let error = parse_fill_report(
1641            &fill,
1642            AccountId::from("AX-001"),
1643            &instrument,
1644            UnixNanos::default(),
1645        )
1646        .unwrap_err();
1647
1648        assert_eq!(
1649            error.to_string(),
1650            "AX fill is missing order_id and explicit special-fill classification"
1651        );
1652    }
1653
1654    #[rstest]
1655    fn test_parse_fill_report_rejects_regular_fill_without_order_id() {
1656        let instrument = parse_instrument(
1657            &create_eurusd_instrument(),
1658            UnixNanos::default(),
1659            UnixNanos::default(),
1660        )
1661        .unwrap();
1662        let mut fill = create_fill();
1663        fill.order_id = None;
1664
1665        let result = parse_fill_report(
1666            &fill,
1667            AccountId::from("AX-001"),
1668            &instrument,
1669            UnixNanos::default(),
1670        );
1671
1672        assert!(result.is_err());
1673    }
1674
1675    #[rstest]
1676    #[case("")]
1677    #[case(" ")]
1678    #[case("O-α")]
1679    fn test_parse_fill_report_rejects_invalid_regular_order_id(#[case] order_id: &str) {
1680        let instrument = parse_instrument(
1681            &create_eurusd_instrument(),
1682            UnixNanos::default(),
1683            UnixNanos::default(),
1684        )
1685        .unwrap();
1686        let mut fill = create_fill();
1687        fill.order_id = Some(order_id.to_string());
1688        fill.is_block_trade = None;
1689        fill.is_final_settlement = None;
1690
1691        let result = parse_fill_report(
1692            &fill,
1693            AccountId::from("AX-001"),
1694            &instrument,
1695            UnixNanos::default(),
1696        );
1697
1698        assert!(result.is_err());
1699    }
1700
1701    #[rstest]
1702    fn test_parse_fx_instrument() {
1703        let definition = create_eurusd_instrument();
1704        let ts_now = UnixNanos::default();
1705
1706        let result = parse_instrument(&definition, ts_now, ts_now);
1707        assert!(result.is_ok());
1708
1709        let instrument = result.unwrap();
1710        match instrument {
1711            InstrumentAny::PerpetualContract(perp) => {
1712                assert_eq!(perp.id.symbol.as_str(), "EURUSD-PERP");
1713                assert_eq!(perp.id.venue, *AX_VENUE);
1714                assert_eq!(perp.underlying, "EURUSD");
1715                assert_eq!(perp.asset_class, AssetClass::FX);
1716                assert_eq!(perp.base_currency.unwrap().code, "EUR");
1717                assert_eq!(perp.quote_currency.code, "USD");
1718                assert_eq!(perp.settlement_currency.code, "USD");
1719                assert_eq!(perp.price_precision, 4);
1720                assert_eq!(perp.size_increment.as_decimal(), Decimal::ONE);
1721                assert_eq!(perp.lot_size.as_decimal(), Decimal::ONE);
1722                assert_eq!(perp.min_quantity.unwrap().as_decimal(), dec!(100));
1723                assert_eq!(perp.margin_init, dec!(0.08));
1724                assert_eq!(perp.margin_maint, dec!(0.04));
1725                let info = perp.info.as_ref().unwrap();
1726                assert_eq!(info["initial_margin_pct"], json!("8.0"));
1727                assert_eq!(info["maintenance_margin_pct"], json!("4.0"));
1728                assert_eq!(
1729                    info["quantity_increment_source"],
1730                    json!("integer_contract_wire_quantity")
1731                );
1732                assert!(!perp.is_inverse);
1733            }
1734            _ => panic!("Expected PerpetualContract instrument"),
1735        }
1736    }
1737
1738    #[rstest]
1739    fn test_parse_equity_instrument() {
1740        let definition = create_nvda_instrument();
1741        let ts_now = UnixNanos::default();
1742
1743        let result = parse_instrument(&definition, ts_now, ts_now);
1744        assert!(result.is_ok());
1745
1746        let instrument = result.unwrap();
1747        match instrument {
1748            InstrumentAny::PerpetualContract(perp) => {
1749                assert_eq!(perp.id.symbol.as_str(), "NVDA-PERP");
1750                assert_eq!(perp.id.venue, *AX_VENUE);
1751                assert_eq!(perp.underlying, "NVDA");
1752                assert_eq!(perp.asset_class, AssetClass::Equity);
1753                assert_eq!(perp.quote_currency.code, "USD");
1754                assert_eq!(perp.settlement_currency.code, "USD");
1755                assert_eq!(perp.price_precision, 2);
1756                assert!(!perp.is_inverse);
1757            }
1758            _ => panic!("Expected PerpetualContract instrument"),
1759        }
1760    }
1761
1762    #[rstest]
1763    fn test_parse_metals_instrument() {
1764        let definition = create_xau_instrument();
1765        let ts_now = UnixNanos::default();
1766
1767        let result = parse_instrument(&definition, ts_now, ts_now);
1768        let instrument = result.unwrap();
1769        match instrument {
1770            InstrumentAny::PerpetualContract(perp) => {
1771                assert_eq!(perp.id.symbol.as_str(), "XAU-PERP");
1772                assert_eq!(perp.underlying, "XAU");
1773                assert_eq!(perp.asset_class, AssetClass::Commodity);
1774                assert!(perp.base_currency.is_none());
1775                assert_eq!(perp.quote_currency.code, "USD");
1776                assert_eq!(perp.price_precision, 1);
1777            }
1778            _ => panic!("Expected PerpetualContract instrument"),
1779        }
1780    }
1781
1782    #[rstest]
1783    fn test_parse_current_dated_instruments() {
1784        let test_data = include_str!("../../test_data/http_get_dated_instruments.json");
1785        let response: AxInstrumentsResponse = serde_json::from_str(test_data).unwrap();
1786        let ts_now = UnixNanos::default();
1787
1788        let instruments = response
1789            .instruments
1790            .iter()
1791            .map(|definition| parse_instrument(definition, ts_now, ts_now).unwrap())
1792            .collect::<Vec<_>>();
1793
1794        assert_eq!(instruments.len(), 2);
1795        for (instrument, expected_symbol, expected_expiration) in [
1796            (&instruments[0], "XAU-2026-SEP", "2026-09-30T15:00:00Z"),
1797            (&instruments[1], "XAU-2026-DEC", "2026-12-31T16:00:00Z"),
1798        ] {
1799            let InstrumentAny::FuturesContract(future) = instrument else {
1800                panic!("Expected FuturesContract instrument");
1801            };
1802            let expected_expiration_ns = u64::try_from(
1803                expected_expiration
1804                    .parse::<Timestamp>()
1805                    .unwrap()
1806                    .as_nanosecond(),
1807            )
1808            .unwrap();
1809            let info = future.info.as_ref().unwrap();
1810
1811            assert_eq!(future.id.symbol.as_str(), expected_symbol);
1812            assert_eq!(future.id.venue, *AX_VENUE);
1813            assert_eq!(future.underlying, Ustr::from("XAU"));
1814            assert_eq!(future.asset_class, AssetClass::Commodity);
1815            assert_eq!(future.activation_ns, UnixNanos::default());
1816            assert_eq!(
1817                future.expiration_ns,
1818                UnixNanos::from(expected_expiration_ns)
1819            );
1820            assert_eq!(future.currency.code, "USD");
1821            assert_eq!(future.price_increment.as_decimal(), dec!(0.1));
1822            assert_eq!(future.size_increment.as_decimal(), Decimal::ONE);
1823            assert_eq!(future.lot_size.as_decimal(), Decimal::ONE);
1824            assert_eq!(future.min_quantity.unwrap().as_decimal(), Decimal::ONE);
1825            assert_eq!(future.multiplier.as_decimal(), Decimal::ONE);
1826            assert_eq!(future.margin_init, dec!(0.125));
1827            assert_eq!(future.margin_maint, dec!(0.075));
1828            assert_eq!(info["product"], json!("XAU"));
1829            assert_eq!(
1830                info["expiration"],
1831                json!(expected_expiration.replace('Z', "+00:00"))
1832            );
1833            assert_eq!(info["activation_source"], json!("unavailable_from_ax"));
1834            assert_eq!(
1835                info["quantity_increment_source"],
1836                json!("integer_contract_wire_quantity")
1837            );
1838            assert_eq!(info["initial_margin_pct"], json!("12.5"));
1839            assert_eq!(info["maintenance_margin_pct"], json!("7.5"));
1840        }
1841    }
1842
1843    #[rstest]
1844    fn test_parse_dated_instrument_keeps_numeric_fields_distinct() {
1845        let test_data = include_str!("../../test_data/http_get_dated_instruments.json");
1846        let mut response: AxInstrumentsResponse = serde_json::from_str(test_data).unwrap();
1847        let definition = &mut response.instruments[0];
1848        definition.multiplier = dec!(2.5);
1849        definition.minimum_order_size = dec!(5);
1850
1851        let instrument =
1852            parse_instrument(definition, UnixNanos::default(), UnixNanos::default()).unwrap();
1853        let InstrumentAny::FuturesContract(future) = instrument else {
1854            panic!("Expected FuturesContract instrument");
1855        };
1856
1857        assert_eq!(future.size_increment.as_decimal(), Decimal::ONE);
1858        assert_eq!(future.lot_size.as_decimal(), Decimal::ONE);
1859        assert_eq!(future.min_quantity.unwrap().as_decimal(), dec!(5));
1860        assert_eq!(future.multiplier.as_decimal(), dec!(2.5));
1861    }
1862
1863    #[rstest]
1864    fn test_parse_perpetual_instrument_preserves_multiplier() {
1865        let mut definition = create_eurusd_instrument();
1866        definition.multiplier = dec!(2.5);
1867        let instrument =
1868            parse_instrument(&definition, UnixNanos::default(), UnixNanos::default()).unwrap();
1869
1870        assert_eq!(instrument.multiplier().as_decimal(), dec!(2.5));
1871        assert_eq!(instrument.size_increment().as_decimal(), Decimal::ONE);
1872    }
1873
1874    #[rstest]
1875    fn test_parse_perpetual_instrument_rejects_zero_multiplier() {
1876        let mut definition = create_eurusd_instrument();
1877        definition.multiplier = Decimal::ZERO;
1878        let error =
1879            parse_instrument(&definition, UnixNanos::default(), UnixNanos::default()).unwrap_err();
1880
1881        assert_eq!(
1882            error.to_string(),
1883            "failed to construct AX perpetual contract"
1884        );
1885    }
1886
1887    #[rstest]
1888    fn test_parse_dated_instrument_without_product_uses_symbol_fallback() {
1889        let test_data = include_str!("../../test_data/http_get_dated_instruments.json");
1890        let mut response: AxInstrumentsResponse = serde_json::from_str(test_data).unwrap();
1891        let definition = &mut response.instruments[0];
1892        definition.product = None;
1893
1894        let instrument =
1895            parse_instrument(definition, UnixNanos::default(), UnixNanos::default()).unwrap();
1896        let InstrumentAny::FuturesContract(future) = instrument else {
1897            panic!("Expected FuturesContract instrument");
1898        };
1899
1900        assert_eq!(future.underlying, Ustr::from("XAU"));
1901    }
1902
1903    #[rstest]
1904    fn test_parse_instrument_rejects_blank_product() {
1905        let mut definition = create_xau_instrument();
1906        definition.product = Some(Ustr::from(" "));
1907
1908        let result = parse_instrument(&definition, UnixNanos::default(), UnixNanos::default());
1909
1910        assert!(result.is_err());
1911    }
1912
1913    #[rstest]
1914    #[case(Decimal::ZERO)]
1915    #[case(dec!(-1))]
1916    #[case(dec!(1.5))]
1917    fn test_parse_instrument_rejects_invalid_minimum_order_size(
1918        #[case] minimum_order_size: Decimal,
1919    ) {
1920        let mut definition = create_xau_instrument();
1921        definition.minimum_order_size = minimum_order_size;
1922
1923        let result = parse_instrument(&definition, UnixNanos::default(), UnixNanos::default());
1924
1925        assert!(result.is_err());
1926    }
1927
1928    #[rstest]
1929    fn test_parse_dated_instrument_rejects_currency_mismatch() {
1930        let test_data = include_str!("../../test_data/http_get_dated_instruments.json");
1931        let mut response: AxInstrumentsResponse = serde_json::from_str(test_data).unwrap();
1932        let definition = &mut response.instruments[0];
1933        definition.funding_settlement_currency = Ustr::from("EUR");
1934
1935        let result = parse_instrument(definition, UnixNanos::default(), UnixNanos::default());
1936
1937        assert!(result.is_err());
1938    }
1939
1940    #[rstest]
1941    fn test_parse_settlement_differs_from_quote() {
1942        let mut definition = create_eurusd_instrument();
1943        definition.funding_settlement_currency = Ustr::from("EUR");
1944        let ts_now = UnixNanos::default();
1945
1946        let result = parse_instrument(&definition, ts_now, ts_now);
1947        let instrument = result.unwrap();
1948        match instrument {
1949            InstrumentAny::PerpetualContract(perp) => {
1950                assert_eq!(perp.quote_currency.code, "USD");
1951                assert_eq!(perp.settlement_currency.code, "EUR");
1952            }
1953            _ => panic!("Expected PerpetualContract instrument"),
1954        }
1955    }
1956
1957    #[rstest]
1958    fn test_margin_percent_to_rate_preserves_exact_scale() {
1959        let value = Decimal::from_i128_with_scale(1, 26);
1960        let result = margin_percent_to_rate(value, "test").unwrap();
1961
1962        assert_eq!(result.mantissa(), 1);
1963        assert_eq!(result.scale(), 28);
1964    }
1965
1966    #[rstest]
1967    fn test_margin_percent_to_rate_normalizes_trailing_zero() {
1968        let value = Decimal::from_i128_with_scale(10, 27);
1969        let result = margin_percent_to_rate(value, "test").unwrap();
1970
1971        assert_eq!(result.mantissa(), 1);
1972        assert_eq!(result.scale(), 28);
1973    }
1974
1975    #[rstest]
1976    fn test_margin_percent_to_rate_rejects_unrepresentable_scale() {
1977        let value = Decimal::from_i128_with_scale(1, 27);
1978        let result = margin_percent_to_rate(value, "test");
1979
1980        assert!(result.is_err());
1981    }
1982
1983    #[rstest]
1984    #[case(Decimal::ZERO, dec!(1))]
1985    #[case(dec!(-1), dec!(1))]
1986    #[case(dec!(1), Decimal::ZERO)]
1987    #[case(dec!(1), dec!(-1))]
1988    #[case(dec!(4), dec!(8))]
1989    fn test_parse_margin_rates_rejects_invalid_values(
1990        #[case] initial_margin_pct: Decimal,
1991        #[case] maintenance_margin_pct: Decimal,
1992    ) {
1993        let result = parse_margin_rates(initial_margin_pct, maintenance_margin_pct);
1994
1995        assert!(result.is_err());
1996    }
1997
1998    #[rstest]
1999    fn test_parse_unknown_category_falls_back_to_alternative() {
2000        let mut definition = create_eurusd_instrument();
2001        definition.category = AxCategory::Unknown;
2002        let ts_now = UnixNanos::default();
2003
2004        let result = parse_instrument(&definition, ts_now, ts_now);
2005        let instrument = result.unwrap();
2006        match instrument {
2007            InstrumentAny::PerpetualContract(perp) => {
2008                assert_eq!(perp.asset_class, AssetClass::Alternative);
2009            }
2010            _ => panic!("Expected PerpetualContract instrument"),
2011        }
2012    }
2013
2014    #[rstest]
2015    fn test_deserialize_instruments_from_test_data() {
2016        let test_data = include_str!("../../test_data/http_get_instruments.json");
2017        let response: AxInstrumentsResponse =
2018            serde_json::from_str(test_data).expect("Failed to deserialize test data");
2019
2020        assert_eq!(response.instruments.len(), 3);
2021
2022        let eurusd = &response.instruments[0];
2023        assert_eq!(eurusd.symbol, "EURUSD-PERP");
2024        assert_eq!(eurusd.category, AxCategory::Fx);
2025        assert_eq!(eurusd.tick_size, dec!(0.0001));
2026        assert_eq!(eurusd.minimum_order_size, dec!(100));
2027
2028        let xau = &response.instruments[1];
2029        assert_eq!(xau.symbol, "XAU-PERP");
2030        assert_eq!(xau.category, AxCategory::Metals);
2031
2032        let nvda = &response.instruments[2];
2033        assert_eq!(nvda.symbol, "NVDA-PERP");
2034        assert_eq!(nvda.category, AxCategory::Equities);
2035    }
2036
2037    #[rstest]
2038    fn test_parse_all_instruments_from_test_data() {
2039        let test_data = include_str!("../../test_data/http_get_instruments.json");
2040        let response: AxInstrumentsResponse =
2041            serde_json::from_str(test_data).expect("Failed to deserialize test data");
2042
2043        let ts_now = UnixNanos::default();
2044
2045        let open_instruments: Vec<_> = response
2046            .instruments
2047            .iter()
2048            .filter(|i| i.state == AxInstrumentState::Open)
2049            .collect();
2050
2051        assert_eq!(open_instruments.len(), 3);
2052
2053        for instrument in open_instruments {
2054            let result = parse_instrument(instrument, ts_now, ts_now);
2055            assert!(
2056                result.is_ok(),
2057                "Failed to parse {}: {:?}",
2058                instrument.symbol,
2059                result.err()
2060            );
2061        }
2062    }
2063
2064    fn create_rest_trade() -> AxRestTrade {
2065        AxRestTrade {
2066            ts: 1_766_193_240,
2067            tn: 334_589_144,
2068            p: dec!(1.1719),
2069            q: 400,
2070            s: Ustr::from("EURUSD-PERP"),
2071            d: AxOrderSide::Buy,
2072        }
2073    }
2074
2075    #[rstest]
2076    fn test_parse_trade_tick_derives_trade_id_from_timestamp_and_content() {
2077        let instrument = parse_instrument(
2078            &create_eurusd_instrument(),
2079            UnixNanos::default(),
2080            UnixNanos::default(),
2081        )
2082        .unwrap();
2083        let trade = create_rest_trade();
2084
2085        let tick = parse_trade_tick(&trade, &instrument, UnixNanos::from(7u64)).unwrap();
2086
2087        assert_eq!(tick.instrument_id, instrument.id());
2088        assert_eq!(
2089            tick.trade_id.to_string(),
2090            "1766193240334589144-38b4fe5a94a253d0"
2091        );
2092        assert_eq!(tick.price, Price::from("1.1719"));
2093        assert_eq!(tick.size, Quantity::from(400));
2094        assert_eq!(tick.aggressor_side, AggressorSide::Buy);
2095        assert_eq!(tick.ts_event, UnixNanos::from(1_766_193_240_334_589_144u64));
2096        assert_eq!(tick.ts_init, UnixNanos::from(7u64));
2097    }
2098
2099    #[rstest]
2100    fn test_parse_trade_tick_separates_prints_within_one_timestamp() {
2101        // Two prints from one sweep share `ts` and `tn`, so only the content digest separates them
2102        let instrument = parse_instrument(
2103            &create_eurusd_instrument(),
2104            UnixNanos::default(),
2105            UnixNanos::default(),
2106        )
2107        .unwrap();
2108        let first = create_rest_trade();
2109        let mut second = create_rest_trade();
2110        second.p = dec!(1.1720);
2111        second.q = 100;
2112
2113        let first_tick = parse_trade_tick(&first, &instrument, UnixNanos::default()).unwrap();
2114        let second_tick = parse_trade_tick(&second, &instrument, UnixNanos::default()).unwrap();
2115
2116        assert_eq!(first_tick.ts_event, second_tick.ts_event);
2117        assert_ne!(first_tick.price, second_tick.price);
2118        assert_ne!(first_tick.size, second_tick.size);
2119        assert_ne!(first_tick.trade_id, second_tick.trade_id);
2120    }
2121
2122    #[rstest]
2123    fn test_parse_trade_tick_rejects_negative_timestamp() {
2124        let instrument = parse_instrument(
2125            &create_eurusd_instrument(),
2126            UnixNanos::default(),
2127            UnixNanos::default(),
2128        )
2129        .unwrap();
2130        let mut trade = create_rest_trade();
2131        trade.ts = -1;
2132
2133        let error = parse_trade_tick(&trade, &instrument, UnixNanos::default()).unwrap_err();
2134
2135        assert_eq!(
2136            error.to_string(),
2137            "AX timestamp must be non-negative, was -1"
2138        );
2139    }
2140
2141    #[rstest]
2142    fn test_deserialize_and_parse_funding_rates() {
2143        let test_data = include_str!("../../test_data/http_get_funding_rates.json");
2144        let response: AxFundingRatesResponse =
2145            serde_json::from_str(test_data).expect("Failed to deserialize test data");
2146
2147        assert_eq!(response.funding_rates.len(), 2);
2148        assert_eq!(response.funding_rates[0].symbol, "JPYUSD-PERP");
2149        assert_eq!(response.funding_rates[0].funding_rate, dec!(0.001234560000));
2150
2151        let instrument_id = InstrumentId::new(Symbol::new("JPYUSD-PERP"), *AX_VENUE);
2152        let ts_init = UnixNanos::from(1_000_000_000u64);
2153
2154        let update =
2155            parse_funding_rate(&response.funding_rates[1], instrument_id, ts_init).unwrap();
2156
2157        assert_eq!(update.instrument_id, instrument_id);
2158        assert_eq!(update.rate, dec!(0.003558290026));
2159        assert_eq!(update.next_funding_ns, None);
2160        assert_eq!(update.ts_event, UnixNanos::from(1770393600000000000u64));
2161        assert_eq!(update.ts_init, ts_init);
2162    }
2163
2164    #[rstest]
2165    #[case(true)]
2166    #[case(false)]
2167    fn test_order_reports_preserve_post_only_and_nanoseconds(#[case] post_only: bool) {
2168        let instrument = parse_instrument(
2169            &create_eurusd_instrument(),
2170            UnixNanos::default(),
2171            UnixNanos::default(),
2172        )
2173        .unwrap();
2174        let mut detail = create_order_detail(AxOrderStatus::Accepted, None, None);
2175        detail.po = post_only;
2176        detail.ts = 1700000000;
2177        detail.tn = 123456789;
2178        let open: AxOpenOrder =
2179            serde_json::from_value(serde_json::to_value(&detail).unwrap()).unwrap();
2180        let reports = [
2181            parse_order_detail_status_report(
2182                &detail,
2183                AccountId::from("AX-001"),
2184                &instrument,
2185                UnixNanos::default(),
2186                None::<&fn(u64) -> Option<ClientOrderId>>,
2187            )
2188            .unwrap(),
2189            parse_order_status_report(
2190                &open,
2191                AccountId::from("AX-001"),
2192                &instrument,
2193                UnixNanos::default(),
2194                None::<&fn(u64) -> Option<ClientOrderId>>,
2195            )
2196            .unwrap(),
2197        ];
2198
2199        for report in reports {
2200            assert_eq!(report.post_only, post_only);
2201            assert_eq!(report.ts_last, UnixNanos::from(1700000000123456789));
2202            assert_eq!(report.ts_accepted, UnixNanos::from(1700000000123456789));
2203            assert_eq!(report.quantity.as_decimal(), Decimal::from(detail.q));
2204            assert_eq!(report.filled_qty.as_decimal(), Decimal::from(detail.xq));
2205        }
2206    }
2207
2208    #[rstest]
2209    fn test_unknown_order_status_cannot_become_initialized_report() {
2210        let instrument = parse_instrument(
2211            &create_eurusd_instrument(),
2212            UnixNanos::default(),
2213            UnixNanos::default(),
2214        )
2215        .unwrap();
2216        let detail = create_order_detail(AxOrderStatus::Unknown, None, None);
2217        let error = parse_order_detail_status_report(
2218            &detail,
2219            AccountId::from("AX-001"),
2220            &instrument,
2221            UnixNanos::default(),
2222            None::<&fn(u64) -> Option<ClientOrderId>>,
2223        )
2224        .unwrap_err();
2225        assert_eq!(error.to_string(), "Unmapped AX order status");
2226    }
2227
2228    #[rstest]
2229    #[case(Some(dec!(375)), dec!(1.25))]
2230    #[case(None, dec!(1.5))]
2231    fn test_position_prefers_cost_basis(
2232        #[case] cost_basis: Option<Decimal>,
2233        #[case] expected: Decimal,
2234    ) {
2235        let mut response: crate::http::models::AxPositionsResponse =
2236            serde_json::from_str(include_str!("../../test_data/http_get_positions.json")).unwrap();
2237        let position = &mut response.positions[0];
2238        position.signed_quantity = -300;
2239        position.signed_notional = dec!(-450);
2240        position.signed_cost_basis = cost_basis.map(|value| -value);
2241        let instrument = parse_instrument(
2242            &create_eurusd_instrument(),
2243            UnixNanos::default(),
2244            UnixNanos::default(),
2245        )
2246        .unwrap();
2247
2248        let report = parse_position_status_report(
2249            position,
2250            AccountId::from("AX-001"),
2251            &instrument,
2252            UnixNanos::default(),
2253        )
2254        .unwrap();
2255
2256        assert_eq!(report.avg_px_open, Some(expected));
2257        assert_eq!(report.position_side, PositionSide::Short);
2258        assert_eq!(report.quantity.as_decimal(), dec!(300));
2259    }
2260
2261    #[rstest]
2262    #[case::reduction(1, dec!(2) * dec!(100) - dec!(150), dec!(50))]
2263    #[case::short_reduction(-1, -dec!(2) * dec!(100) + dec!(150), dec!(50))]
2264    #[case::reversal(-1, dec!(2) * dec!(100) - dec!(3) * dec!(120), dec!(160))]
2265    #[case::short_reversal(1, -dec!(2) * dec!(100) + dec!(3) * dec!(120), dec!(160))]
2266    #[case::negative(1, dec!(-100), dec!(-100))]
2267    #[case::zero(1, dec!(0), dec!(0))]
2268    #[case::fraction(4, dec!(99), dec!(24.75))]
2269    #[case::flat(0, dec!(100), dec!(0))]
2270    fn test_position_ledger_average_contract(
2271        #[case] quantity: i64,
2272        #[case] basis: Decimal,
2273        #[case] expected: Decimal,
2274        #[values(false, true)] legacy_alias: bool,
2275    ) {
2276        let mut response: crate::http::models::AxPositionsResponse =
2277            serde_json::from_str(include_str!("../../test_data/http_get_positions.json")).unwrap();
2278        let position = &mut response.positions[0];
2279        position.signed_quantity = quantity;
2280
2281        position.signed_notional = if legacy_alias { basis } else { dec!(999) };
2282        position.signed_cost_basis = (!legacy_alias).then_some(basis);
2283        let instrument = parse_instrument(
2284            &create_eurusd_instrument(),
2285            UnixNanos::default(),
2286            UnixNanos::default(),
2287        )
2288        .unwrap();
2289        let result = parse_position_status_report(
2290            position,
2291            AccountId::from("AX-001"),
2292            &instrument,
2293            UnixNanos::default(),
2294        );
2295
2296        let report = result.unwrap();
2297        assert_eq!(report.avg_px_open, (quantity != 0).then_some(expected));
2298        assert_eq!(
2299            report.avg_px_open_reconciliation,
2300            AvgPxReconciliation::OpeningOnly
2301        );
2302        assert_eq!(report.avg_px_open_precision, None);
2303        assert_eq!(report.quantity.as_decimal(), Decimal::from(quantity).abs());
2304        assert_eq!(
2305            report.position_side,
2306            if quantity > 0 {
2307                PositionSide::Long
2308            } else if quantity < 0 {
2309                PositionSide::Short
2310            } else {
2311                PositionSide::Flat
2312            }
2313        );
2314    }
2315
2316    #[rstest]
2317    #[case(TimeInForce::Ioc, OrderStatus::Canceled)]
2318    #[case(TimeInForce::Fok, OrderStatus::Canceled)]
2319    #[case(TimeInForce::Gtc, OrderStatus::Expired)]
2320    #[case(TimeInForce::Day, OrderStatus::Expired)]
2321    fn test_expired_http_status_matches_websocket(
2322        #[case] tif: TimeInForce,
2323        #[case] expected: OrderStatus,
2324    ) {
2325        assert_eq!(
2326            parse_order_status(AxOrderStatus::Expired, tif).unwrap(),
2327            expected
2328        );
2329    }
2330    #[rstest]
2331    fn test_replaced_history_is_terminal() {
2332        let response: crate::http::models::AxOrdersResponse = serde_json::from_str(include_str!(
2333            "../../test_data/captured/history-replaced.json"
2334        ))
2335        .unwrap();
2336        let instrument = parse_instrument(
2337            &create_eurusd_instrument(),
2338            UnixNanos::default(),
2339            UnixNanos::default(),
2340        )
2341        .unwrap();
2342        let order = &response.orders[0];
2343        let report = parse_order_detail_status_report(
2344            order,
2345            AccountId::from("AX-001"),
2346            &instrument,
2347            UnixNanos::default(),
2348            None::<&fn(u64) -> Option<ClientOrderId>>,
2349        )
2350        .unwrap();
2351        assert_eq!(order.o, AxOrderStatus::Replaced);
2352        assert_eq!(report.order_status, OrderStatus::Canceled);
2353        assert!(report.order_status.is_closed());
2354        assert_eq!(report.filled_qty.as_decimal(), dec!(0));
2355        assert_eq!(report.venue_order_id.as_str(), order.oid);
2356    }
2357}