1use 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 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#[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
101pub 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 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
131pub 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_timestamp_ns_to_unix_nanos(ax_rate.timestamp_ns)?,
147 ts_init,
148 ))
149}
150
151pub 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 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 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
398pub 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 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
471pub 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
501pub 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 let order_type = OrderType::Limit;
605
606 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 let price = decimal_to_price_dp(order.price, instrument.price_precision(), "order.p")?;
614
615 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 if let Some(reason) = AxOrderRejectReason::reason_str(order.reject_reason, order.text) {
646 report = report.with_cancel_reason(reason);
647 }
648
649 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
669pub 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 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
762pub 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 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
839pub 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 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 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}