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