1use 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 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#[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
105pub 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 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
135pub 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_timestamp_ns_to_unix_nanos(ax_rate.timestamp_ns)?,
151 ts_init,
152 ))
153}
154
155pub 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 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 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
401pub 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 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
474pub 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
504pub 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 let order_type = OrderType::Limit;
633
634 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 let price = decimal_to_price_dp(order.price, instrument.price_precision(), "order.p")?;
642
643 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 if let Some(reason) = AxOrderRejectReason::reason_str(order.reject_reason, order.text) {
674 report = report.with_cancel_reason(reason);
675 }
676
677 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
697pub 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 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
791pub 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 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
871pub 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 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 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}