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