1use anyhow::Context;
19use nautilus_core::{Params, UUID4, nanos::UnixNanos};
20use nautilus_model::{
21 data::{Bar, BarSpecification, BarType, FundingRateUpdate, TradeTick},
22 enums::{
23 AccountType, AggregationSource, AggressorSide, AssetClass, BarAggregation, CurrencyType,
24 LiquiditySide, OrderSide, OrderType, PositionSideSpecified, PriceType,
25 },
26 events::AccountState,
27 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
28 instruments::{Instrument, PerpetualContract, any::InstrumentAny},
29 reports::{FillReport, OrderStatusReport, PositionStatusReport},
30 types::{AccountBalance, Currency, Money, Price, Quantity},
31};
32use rust_decimal::Decimal;
33use serde_json::json;
34use ustr::Ustr;
35
36use super::models::{
37 AxBalancesResponse, AxCandle, AxFill, AxFundingRate, AxInstrument, AxOpenOrder, AxPosition,
38 AxRestTrade,
39};
40use crate::common::{
41 consts::AX_VENUE,
42 enums::AxCandleWidth,
43 parse::{ax_timestamp_ns_to_unix_nanos, ax_timestamp_s_to_unix_nanos, cid_to_client_order_id},
44};
45
46fn decimal_to_price(value: Decimal, field_name: &str) -> anyhow::Result<Price> {
47 Price::from_decimal(value)
48 .with_context(|| format!("Failed to convert {field_name} Decimal to Price"))
49}
50
51fn decimal_to_quantity(value: Decimal, field_name: &str) -> anyhow::Result<Quantity> {
52 Quantity::from_decimal(value)
53 .with_context(|| format!("Failed to convert {field_name} Decimal to Quantity"))
54}
55
56fn decimal_to_price_dp(value: Decimal, precision: u8, field: &str) -> anyhow::Result<Price> {
57 Price::from_decimal_dp(value, precision).with_context(|| {
58 format!("Failed to construct Price for {field} with precision {precision}")
59 })
60}
61
62fn get_currency(code: &str) -> Currency {
63 Currency::try_from_str(code).unwrap_or_else(|| {
64 let currency = Currency::new(code, 0, 0, code, CurrencyType::Crypto);
66 if let Err(e) = Currency::register(currency, false) {
67 log::warn!("Failed to register currency '{code}': {e}");
68 }
69 currency
70 })
71}
72
73#[must_use]
75pub fn candle_width_to_bar_spec(width: AxCandleWidth) -> BarSpecification {
76 match width {
77 AxCandleWidth::Seconds1 => {
78 BarSpecification::new(1, BarAggregation::Second, PriceType::Last)
79 }
80 AxCandleWidth::Seconds5 => {
81 BarSpecification::new(5, BarAggregation::Second, PriceType::Last)
82 }
83 AxCandleWidth::Minutes1 => {
84 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
85 }
86 AxCandleWidth::Minutes5 => {
87 BarSpecification::new(5, BarAggregation::Minute, PriceType::Last)
88 }
89 AxCandleWidth::Minutes15 => {
90 BarSpecification::new(15, BarAggregation::Minute, PriceType::Last)
91 }
92 AxCandleWidth::Hours1 => BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
93 AxCandleWidth::Days1 => BarSpecification::new(1, BarAggregation::Day, PriceType::Last),
94 }
95}
96
97pub fn parse_bar(
103 candle: &AxCandle,
104 instrument: &InstrumentAny,
105 ts_init: UnixNanos,
106) -> anyhow::Result<Bar> {
107 let price_precision = instrument.price_precision();
108 let size_precision = instrument.size_precision();
109
110 let open = decimal_to_price_dp(candle.open, price_precision, "candle.open")?;
111 let high = decimal_to_price_dp(candle.high, price_precision, "candle.high")?;
112 let low = decimal_to_price_dp(candle.low, price_precision, "candle.low")?;
113 let close = decimal_to_price_dp(candle.close, price_precision, "candle.close")?;
114
115 let volume = Quantity::new(candle.volume as f64, size_precision);
117
118 let ts_event = ax_timestamp_s_to_unix_nanos(candle.ts)?;
119
120 let bar_spec = candle_width_to_bar_spec(candle.width);
121 let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
122
123 Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
124 .context("Failed to construct Bar from Ax candle")
125}
126
127pub fn parse_funding_rate(
133 ax_rate: &AxFundingRate,
134 instrument_id: InstrumentId,
135 ts_init: UnixNanos,
136) -> anyhow::Result<FundingRateUpdate> {
137 Ok(FundingRateUpdate::new(
138 instrument_id,
139 ax_rate.funding_rate,
140 None,
141 None, ax_timestamp_ns_to_unix_nanos(ax_rate.timestamp_ns)?,
143 ts_init,
144 ))
145}
146
147pub fn parse_perp_instrument(
153 definition: &AxInstrument,
154 maker_fee: Decimal,
155 taker_fee: Decimal,
156 ts_event: UnixNanos,
157 ts_init: UnixNanos,
158) -> anyhow::Result<InstrumentAny> {
159 let raw_symbol_str = definition.symbol.as_str();
160 let raw_symbol = Symbol::new(raw_symbol_str);
161 let instrument_id = InstrumentId::new(raw_symbol, *AX_VENUE);
162
163 let symbol_prefix = raw_symbol_str
164 .split('-')
165 .next()
166 .context("Failed to extract symbol prefix")?;
167
168 let underlying = Ustr::from(symbol_prefix);
169
170 let quote_code = definition.quote_currency.as_str();
173 let base_code = if symbol_prefix.ends_with(quote_code) && symbol_prefix.len() > quote_code.len()
174 {
175 &symbol_prefix[..symbol_prefix.len() - quote_code.len()]
176 } else {
177 symbol_prefix
178 };
179
180 let asset_class = match definition.category {
181 Some(category) => AssetClass::from(category),
182 None => match Currency::try_from_str(base_code) {
183 Some(currency) => match currency.currency_type {
184 CurrencyType::Fiat => AssetClass::FX,
185 CurrencyType::Crypto => AssetClass::Cryptocurrency,
186 CurrencyType::CommodityBacked => AssetClass::Commodity,
187 },
188 None => AssetClass::Alternative,
189 },
190 };
191
192 let base_currency = match asset_class {
194 AssetClass::FX | AssetClass::Cryptocurrency => Some(get_currency(base_code)),
195 _ => None,
196 };
197
198 let quote_currency = get_currency(quote_code);
199 let settlement_currency = get_currency(definition.funding_settlement_currency.as_str());
200
201 let price_increment = decimal_to_price(definition.tick_size, "tick_size")?;
202 let size_increment = decimal_to_quantity(definition.minimum_order_size, "minimum_order_size")?;
203
204 let lot_size = Some(size_increment);
205 let min_quantity = Some(size_increment);
206
207 let margin_init = definition.initial_margin_pct;
208 let margin_maint = definition.maintenance_margin_pct;
209
210 let mut info = Params::new();
211
212 if let Some(ref desc) = definition.description {
213 info.insert("description".to_string(), json!(desc));
214 }
215
216 if let Some(ref s) = definition.contract_size {
217 info.insert("contract_size".to_string(), json!(s));
218 }
219
220 if let Some(ref s) = definition.contract_mark_price {
221 info.insert("contract_mark_price".to_string(), json!(s));
222 }
223
224 if let Some(ref s) = definition.price_quotation {
225 info.insert("price_quotation".to_string(), json!(s));
226 }
227
228 if let Some(ref s) = definition.underlying_benchmark_price {
229 info.insert("underlying_benchmark_price".to_string(), json!(s));
230 }
231
232 if let Some(ref s) = definition.price_bands {
233 info.insert("price_bands".to_string(), json!(s));
234 }
235
236 if let Some(v) = definition.funding_rate_cap_upper_pct {
237 info.insert(
238 "funding_rate_cap_upper_pct".to_string(),
239 json!(v.to_string()),
240 );
241 }
242
243 if let Some(v) = definition.funding_rate_cap_lower_pct {
244 info.insert(
245 "funding_rate_cap_lower_pct".to_string(),
246 json!(v.to_string()),
247 );
248 }
249
250 if let Some(v) = definition.price_band_upper_deviation_pct {
251 info.insert(
252 "price_band_upper_deviation_pct".to_string(),
253 json!(v.to_string()),
254 );
255 }
256
257 if let Some(v) = definition.price_band_lower_deviation_pct {
258 info.insert(
259 "price_band_lower_deviation_pct".to_string(),
260 json!(v.to_string()),
261 );
262 }
263
264 let instrument = PerpetualContract::new(
265 instrument_id,
266 raw_symbol,
267 underlying,
268 asset_class,
269 base_currency,
270 quote_currency,
271 settlement_currency,
272 false,
273 price_increment.precision,
274 size_increment.precision,
275 price_increment,
276 size_increment,
277 None,
278 lot_size,
279 None,
280 min_quantity,
281 None,
282 None,
283 None,
284 None,
285 Some(margin_init),
286 Some(margin_maint),
287 Some(maker_fee),
288 Some(taker_fee),
289 Some(info),
290 ts_event,
291 ts_init,
292 );
293
294 Ok(InstrumentAny::PerpetualContract(instrument))
295}
296
297pub fn parse_account_state(
306 response: &AxBalancesResponse,
307 account_id: AccountId,
308 ts_event: UnixNanos,
309 ts_init: UnixNanos,
310) -> anyhow::Result<AccountState> {
311 let mut balances = Vec::with_capacity(response.balances.len());
312
313 for balance in &response.balances {
314 let symbol_str = balance.symbol.as_str().trim();
315 if symbol_str.is_empty() {
316 log::debug!("Skipping balance with empty symbol");
317 continue;
318 }
319
320 let currency = get_currency(symbol_str);
321
322 let balance =
327 AccountBalance::from_total_and_locked(balance.amount, Decimal::ZERO, currency)
328 .with_context(|| format!("Failed to convert balance for {symbol_str}"))?;
329 balances.push(balance);
330 }
331
332 if balances.is_empty() {
333 let zero_currency = Currency::USD();
334 let zero_money = Money::new(0.0, zero_currency);
335 balances.push(AccountBalance::new(zero_money, zero_money, zero_money));
336 }
337
338 Ok(AccountState::new(
339 account_id,
340 AccountType::Margin,
341 balances,
342 vec![],
343 true,
344 UUID4::new(),
345 ts_event,
346 ts_init,
347 None,
348 ))
349}
350
351pub fn parse_order_status_report<F>(
363 order: &AxOpenOrder,
364 account_id: AccountId,
365 instrument: &InstrumentAny,
366 ts_init: UnixNanos,
367 cid_resolver: Option<&F>,
368) -> anyhow::Result<OrderStatusReport>
369where
370 F: Fn(u64) -> Option<ClientOrderId>,
371{
372 let instrument_id = instrument.id();
373 let venue_order_id = VenueOrderId::new(&order.oid);
374 let order_side = order.d.into();
375 let order_status = order.o.into();
376 let time_in_force = order.tif.into();
377
378 let order_type = OrderType::Limit;
380
381 let quantity = Quantity::new(order.q as f64, instrument.size_precision());
383 let filled_qty = Quantity::new(order.xq as f64, instrument.size_precision());
384
385 let price = decimal_to_price_dp(order.p, instrument.price_precision(), "order.p")?;
387
388 let ts_event = ax_timestamp_s_to_unix_nanos(order.ts)?;
390
391 let mut report = OrderStatusReport::new(
392 account_id,
393 instrument_id,
394 None,
395 venue_order_id,
396 order_side,
397 order_type,
398 time_in_force,
399 order_status,
400 quantity,
401 filled_qty,
402 ts_event,
403 ts_event,
404 ts_init,
405 Some(UUID4::new()),
406 );
407
408 if let Some(cid) = order.cid {
409 let client_order_id = cid_resolver
410 .and_then(|resolver| resolver(cid))
411 .unwrap_or_else(|| cid_to_client_order_id(cid));
412 report = report.with_client_order_id(client_order_id);
413 }
414
415 report = report.with_price(price);
416
417 Ok(report)
422}
423
424pub fn parse_fill_report(
435 fill: &AxFill,
436 account_id: AccountId,
437 instrument: &InstrumentAny,
438 ts_init: UnixNanos,
439) -> anyhow::Result<FillReport> {
440 let instrument_id = instrument.id();
441
442 let venue_order_id = VenueOrderId::new(&fill.order_id);
443 let trade_id = TradeId::new_checked(&fill.trade_id).context("Invalid trade_id in Ax fill")?;
444
445 let order_side: OrderSide = fill.side.into();
447
448 let last_px = decimal_to_price_dp(fill.price, instrument.price_precision(), "fill.price")?;
449 let last_qty = Quantity::new(fill.quantity as f64, instrument.size_precision());
450
451 let currency = Currency::USD();
452 let commission = Money::from_decimal(fill.fee, currency)
453 .context("Failed to convert fill.fee Decimal to Money")?;
454
455 let liquidity_side = if fill.is_taker {
456 LiquiditySide::Taker
457 } else {
458 LiquiditySide::Maker
459 };
460
461 let ts_event = match fill.timestamp.timestamp_nanos_opt() {
462 Some(nanos) => UnixNanos::from(nanos.unsigned_abs()),
463 None => {
464 log::warn!(
465 "Timestamp overflow for fill {} (timestamp={}), defaulting to 0",
466 fill.trade_id,
467 fill.timestamp
468 );
469 UnixNanos::from(0u64)
470 }
471 };
472
473 Ok(FillReport::new(
474 account_id,
475 instrument_id,
476 venue_order_id,
477 trade_id,
478 order_side,
479 last_qty,
480 last_px,
481 commission,
482 liquidity_side,
483 None,
484 None,
485 ts_event,
486 ts_init,
487 None,
488 ))
489}
490
491pub fn parse_position_status_report(
499 position: &AxPosition,
500 account_id: AccountId,
501 instrument: &InstrumentAny,
502 ts_init: UnixNanos,
503) -> anyhow::Result<PositionStatusReport> {
504 let instrument_id = instrument.id();
505
506 let (position_side, quantity) = if position.signed_quantity > 0 {
508 (
509 PositionSideSpecified::Long,
510 Quantity::new(position.signed_quantity as f64, instrument.size_precision()),
511 )
512 } else if position.signed_quantity < 0 {
513 (
514 PositionSideSpecified::Short,
515 Quantity::new(
516 position.signed_quantity.unsigned_abs() as f64,
517 instrument.size_precision(),
518 ),
519 )
520 } else {
521 (
522 PositionSideSpecified::Flat,
523 Quantity::new(0.0, instrument.size_precision()),
524 )
525 };
526
527 let avg_px_open = if position.signed_quantity != 0 {
530 let qty_dec = Decimal::from(position.signed_quantity.abs());
531 Some(position.signed_notional.abs() / qty_dec)
532 } else {
533 None
534 };
535
536 let ts_last = match position.timestamp.timestamp_nanos_opt() {
537 Some(nanos) => UnixNanos::from(nanos.unsigned_abs()),
538 None => {
539 log::warn!(
540 "Timestamp overflow for position {} (timestamp={}), defaulting to 0",
541 position.symbol,
542 position.timestamp
543 );
544 UnixNanos::from(0u64)
545 }
546 };
547
548 Ok(PositionStatusReport::new(
549 account_id,
550 instrument_id,
551 position_side,
552 quantity,
553 ts_last,
554 ts_init,
555 None,
556 None,
557 avg_px_open,
558 ))
559}
560
561pub fn parse_trade_tick(
567 trade: &AxRestTrade,
568 instrument: &InstrumentAny,
569 ts_init: UnixNanos,
570) -> anyhow::Result<TradeTick> {
571 let price = decimal_to_price_dp(trade.p, instrument.price_precision(), "trade.p")?;
572 let size = Quantity::new(trade.q as f64, instrument.size_precision());
573 let aggressor_side: AggressorSide = trade.d.into();
574
575 let ts_event = UnixNanos::from(trade.ts as u64 * 1_000_000_000 + trade.tn as u64);
577
578 let mut buf = itoa::Buffer::new();
580 let trade_id =
581 TradeId::new_checked(buf.format(ts_event.as_u64())).context("Failed to create TradeId")?;
582
583 TradeTick::new_checked(
584 instrument.id(),
585 price,
586 size,
587 aggressor_side,
588 trade_id,
589 ts_event,
590 ts_init,
591 )
592 .context("Failed to construct TradeTick from Ax REST trade")
593}
594
595#[cfg(test)]
596mod tests {
597 use nautilus_core::nanos::UnixNanos;
598 use rstest::rstest;
599 use rust_decimal_macros::dec;
600 use ustr::Ustr;
601
602 use super::*;
603 use crate::{
604 common::enums::{AxCategory, AxInstrumentState},
605 http::models::{AxFundingRatesResponse, AxInstrumentsResponse},
606 };
607
608 fn create_eurusd_instrument() -> AxInstrument {
609 AxInstrument {
610 symbol: Ustr::from("EURUSD-PERP"),
611 state: AxInstrumentState::Open,
612 multiplier: dec!(1),
613 minimum_order_size: dec!(100),
614 tick_size: dec!(0.0001),
615 quote_currency: Ustr::from("USD"),
616 funding_settlement_currency: Ustr::from("USD"),
617 category: Some(AxCategory::Fx),
618 maintenance_margin_pct: dec!(4.0),
619 initial_margin_pct: dec!(8.0),
620 contract_mark_price: Some("Average price on AX at London 4pm".to_string()),
621 contract_size: Some("1 Euro per contract".to_string()),
622 description: Some("Euro / US Dollar FX Perpetual Future".to_string()),
623 funding_calendar_schedule: None,
624 funding_frequency: None,
625 funding_rate_cap_lower_pct: Some(dec!(-1.0)),
626 funding_rate_cap_upper_pct: Some(dec!(1.0)),
627 price_band_lower_deviation_pct: Some(dec!(10)),
628 price_band_upper_deviation_pct: Some(dec!(10)),
629 price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
630 price_quotation: Some("U.S. dollars per Euro".to_string()),
631 underlying_benchmark_price: Some("WMR London 4pm Closing Spot Rate".to_string()),
632 }
633 }
634
635 fn create_nvda_instrument() -> AxInstrument {
636 AxInstrument {
637 symbol: Ustr::from("NVDA-PERP"),
638 state: AxInstrumentState::Open,
639 multiplier: dec!(1),
640 minimum_order_size: dec!(1),
641 tick_size: dec!(0.01),
642 quote_currency: Ustr::from("USD"),
643 funding_settlement_currency: Ustr::from("USD"),
644 category: Some(AxCategory::Equities),
645 maintenance_margin_pct: dec!(10),
646 initial_margin_pct: dec!(20),
647 contract_mark_price: Some(
648 "Average price on ArchitectX at 4pm New York Time".to_string(),
649 ),
650 contract_size: Some("1 share per contract".to_string()),
651 description: Some("NVIDIA Corp US Equity Perpetual Future".to_string()),
652 funding_calendar_schedule: None,
653 funding_frequency: None,
654 funding_rate_cap_lower_pct: Some(dec!(-1)),
655 funding_rate_cap_upper_pct: Some(dec!(1)),
656 price_band_lower_deviation_pct: Some(dec!(10)),
657 price_band_upper_deviation_pct: Some(dec!(10)),
658 price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
659 price_quotation: Some("U.S. dollars per share".to_string()),
660 underlying_benchmark_price: Some("Nasdaq Official Closing Price".to_string()),
661 }
662 }
663
664 fn create_xau_instrument() -> AxInstrument {
665 AxInstrument {
666 symbol: Ustr::from("XAU-PERP"),
667 state: AxInstrumentState::Open,
668 multiplier: dec!(1),
669 minimum_order_size: dec!(1),
670 tick_size: dec!(0.1),
671 quote_currency: Ustr::from("USD"),
672 funding_settlement_currency: Ustr::from("USD"),
673 category: Some(AxCategory::Metals),
674 maintenance_margin_pct: dec!(5),
675 initial_margin_pct: dec!(10),
676 contract_mark_price: Some("Average price on ArchitectX at London 4pm".to_string()),
677 contract_size: Some("1 ounce per contract".to_string()),
678 description: Some("Gold Metals Perpetual Future".to_string()),
679 funding_calendar_schedule: None,
680 funding_frequency: None,
681 funding_rate_cap_lower_pct: Some(dec!(-1)),
682 funding_rate_cap_upper_pct: Some(dec!(1)),
683 price_band_lower_deviation_pct: Some(dec!(10)),
684 price_band_upper_deviation_pct: Some(dec!(10)),
685 price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
686 price_quotation: Some("U.S. dollars per ounce".to_string()),
687 underlying_benchmark_price: Some("XAU WMR Metals Daily Closing Rate".to_string()),
688 }
689 }
690
691 #[rstest]
692 fn test_decimal_to_price() {
693 let price = decimal_to_price(dec!(100.50), "test_field").unwrap();
694 assert_eq!(price.as_f64(), 100.50);
695 }
696
697 #[rstest]
698 fn test_decimal_to_quantity() {
699 let qty = decimal_to_quantity(dec!(1.5), "test_field").unwrap();
700 assert_eq!(qty.as_f64(), 1.5);
701 }
702
703 #[rstest]
704 fn test_get_currency_known() {
705 let currency = get_currency("USD");
706 assert_eq!(currency.code, Ustr::from("USD"));
707 assert_eq!(currency.precision, 2);
708 }
709
710 #[rstest]
711 fn test_get_currency_unknown_creates_new() {
712 let currency = get_currency("NVDA");
713 assert_eq!(currency.code, Ustr::from("NVDA"));
714 assert_eq!(currency.precision, 0);
715 }
716
717 #[rstest]
718 fn test_parse_fx_instrument() {
719 let definition = create_eurusd_instrument();
720 let maker_fee = Decimal::new(2, 5);
721 let taker_fee = Decimal::new(2, 5);
722 let ts_now = UnixNanos::default();
723
724 let result = parse_perp_instrument(&definition, maker_fee, taker_fee, ts_now, ts_now);
725 assert!(result.is_ok());
726
727 let instrument = result.unwrap();
728 match instrument {
729 InstrumentAny::PerpetualContract(perp) => {
730 assert_eq!(perp.id.symbol.as_str(), "EURUSD-PERP");
731 assert_eq!(perp.id.venue, *AX_VENUE);
732 assert_eq!(perp.underlying.as_str(), "EURUSD");
733 assert_eq!(perp.asset_class, AssetClass::FX);
734 assert_eq!(perp.base_currency.unwrap().code.as_str(), "EUR");
735 assert_eq!(perp.quote_currency.code.as_str(), "USD");
736 assert_eq!(perp.settlement_currency.code.as_str(), "USD");
737 assert_eq!(perp.price_precision, 4);
738 assert!(!perp.is_inverse);
739 }
740 _ => panic!("Expected PerpetualContract instrument"),
741 }
742 }
743
744 #[rstest]
745 fn test_parse_equity_instrument() {
746 let definition = create_nvda_instrument();
747 let maker_fee = Decimal::new(2, 5);
748 let taker_fee = Decimal::new(2, 5);
749 let ts_now = UnixNanos::default();
750
751 let result = parse_perp_instrument(&definition, maker_fee, taker_fee, ts_now, ts_now);
752 assert!(result.is_ok());
753
754 let instrument = result.unwrap();
755 match instrument {
756 InstrumentAny::PerpetualContract(perp) => {
757 assert_eq!(perp.id.symbol.as_str(), "NVDA-PERP");
758 assert_eq!(perp.id.venue, *AX_VENUE);
759 assert_eq!(perp.underlying.as_str(), "NVDA");
760 assert_eq!(perp.asset_class, AssetClass::Equity);
761 assert_eq!(perp.quote_currency.code.as_str(), "USD");
762 assert_eq!(perp.settlement_currency.code.as_str(), "USD");
763 assert_eq!(perp.price_precision, 2);
764 assert!(!perp.is_inverse);
765 }
766 _ => panic!("Expected PerpetualContract instrument"),
767 }
768 }
769
770 #[rstest]
771 fn test_parse_metals_instrument() {
772 let definition = create_xau_instrument();
773 let ts_now = UnixNanos::default();
774
775 let result =
776 parse_perp_instrument(&definition, Decimal::ZERO, Decimal::ZERO, ts_now, ts_now);
777 let instrument = result.unwrap();
778 match instrument {
779 InstrumentAny::PerpetualContract(perp) => {
780 assert_eq!(perp.id.symbol.as_str(), "XAU-PERP");
781 assert_eq!(perp.underlying.as_str(), "XAU");
782 assert_eq!(perp.asset_class, AssetClass::Commodity);
783 assert!(perp.base_currency.is_none());
784 assert_eq!(perp.quote_currency.code.as_str(), "USD");
785 assert_eq!(perp.price_precision, 1);
786 }
787 _ => panic!("Expected PerpetualContract instrument"),
788 }
789 }
790
791 #[rstest]
792 fn test_parse_settlement_differs_from_quote() {
793 let mut definition = create_eurusd_instrument();
794 definition.funding_settlement_currency = Ustr::from("EUR");
795 let ts_now = UnixNanos::default();
796
797 let result =
798 parse_perp_instrument(&definition, Decimal::ZERO, Decimal::ZERO, ts_now, ts_now);
799 let instrument = result.unwrap();
800 match instrument {
801 InstrumentAny::PerpetualContract(perp) => {
802 assert_eq!(perp.quote_currency.code.as_str(), "USD");
803 assert_eq!(perp.settlement_currency.code.as_str(), "EUR");
804 }
805 _ => panic!("Expected PerpetualContract instrument"),
806 }
807 }
808
809 #[rstest]
810 fn test_parse_unknown_category_falls_back_to_alternative() {
811 let mut definition = create_eurusd_instrument();
812 definition.category = Some(AxCategory::Unknown);
813 let ts_now = UnixNanos::default();
814
815 let result =
816 parse_perp_instrument(&definition, Decimal::ZERO, Decimal::ZERO, ts_now, ts_now);
817 let instrument = result.unwrap();
818 match instrument {
819 InstrumentAny::PerpetualContract(perp) => {
820 assert_eq!(perp.asset_class, AssetClass::Alternative);
821 }
822 _ => panic!("Expected PerpetualContract instrument"),
823 }
824 }
825
826 #[rstest]
827 fn test_deserialize_instruments_from_test_data() {
828 let test_data = include_str!("../../test_data/http_get_instruments.json");
829 let response: AxInstrumentsResponse =
830 serde_json::from_str(test_data).expect("Failed to deserialize test data");
831
832 assert_eq!(response.instruments.len(), 3);
833
834 let eurusd = &response.instruments[0];
835 assert_eq!(eurusd.symbol.as_str(), "EURUSD-PERP");
836 assert_eq!(eurusd.category, Some(AxCategory::Fx));
837 assert_eq!(eurusd.tick_size, dec!(0.0001));
838 assert_eq!(eurusd.minimum_order_size, dec!(100));
839
840 let xau = &response.instruments[1];
841 assert_eq!(xau.symbol.as_str(), "XAU-PERP");
842 assert_eq!(xau.category, Some(AxCategory::Metals));
843
844 let nvda = &response.instruments[2];
845 assert_eq!(nvda.symbol.as_str(), "NVDA-PERP");
846 assert_eq!(nvda.category, Some(AxCategory::Equities));
847 }
848
849 #[rstest]
850 fn test_parse_all_instruments_from_test_data() {
851 let test_data = include_str!("../../test_data/http_get_instruments.json");
852 let response: AxInstrumentsResponse =
853 serde_json::from_str(test_data).expect("Failed to deserialize test data");
854
855 let maker_fee = Decimal::new(2, 4);
856 let taker_fee = Decimal::new(5, 4);
857 let ts_now = UnixNanos::default();
858
859 let open_instruments: Vec<_> = response
860 .instruments
861 .iter()
862 .filter(|i| i.state == AxInstrumentState::Open)
863 .collect();
864
865 assert_eq!(open_instruments.len(), 3);
866
867 for instrument in open_instruments {
868 let result = parse_perp_instrument(instrument, maker_fee, taker_fee, ts_now, ts_now);
869 assert!(
870 result.is_ok(),
871 "Failed to parse {}: {:?}",
872 instrument.symbol,
873 result.err()
874 );
875 }
876 }
877
878 #[rstest]
879 fn test_deserialize_and_parse_funding_rates() {
880 let test_data = include_str!("../../test_data/http_get_funding_rates.json");
881 let response: AxFundingRatesResponse =
882 serde_json::from_str(test_data).expect("Failed to deserialize test data");
883
884 assert_eq!(response.funding_rates.len(), 2);
885 assert_eq!(response.funding_rates[0].symbol.as_str(), "JPYUSD-PERP");
886 assert_eq!(response.funding_rates[0].funding_rate, dec!(0.001234560000));
887
888 let instrument_id = InstrumentId::new(Symbol::new("JPYUSD-PERP"), *AX_VENUE);
889 let ts_init = UnixNanos::from(1_000_000_000u64);
890
891 let update =
892 parse_funding_rate(&response.funding_rates[1], instrument_id, ts_init).unwrap();
893
894 assert_eq!(update.instrument_id, instrument_id);
895 assert_eq!(update.rate, dec!(0.003558290026));
896 assert_eq!(update.next_funding_ns, None);
897 assert_eq!(update.ts_event, UnixNanos::from(1770393600000000000u64));
898 assert_eq!(update.ts_init, ts_init);
899 }
900}