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 None,
290 Some(info),
291 ts_event,
292 ts_init,
293 );
294
295 Ok(InstrumentAny::PerpetualContract(instrument))
296}
297
298pub fn parse_account_state(
307 response: &AxBalancesResponse,
308 account_id: AccountId,
309 ts_event: UnixNanos,
310 ts_init: UnixNanos,
311) -> anyhow::Result<AccountState> {
312 let mut balances = Vec::with_capacity(response.balances.len());
313
314 for balance in &response.balances {
315 let symbol_str = balance.symbol.as_str().trim();
316 if symbol_str.is_empty() {
317 log::debug!("Skipping balance with empty symbol");
318 continue;
319 }
320
321 let currency = get_currency(symbol_str);
322
323 let balance =
328 AccountBalance::from_total_and_locked(balance.amount, Decimal::ZERO, currency)
329 .with_context(|| format!("Failed to convert balance for {symbol_str}"))?;
330 balances.push(balance);
331 }
332
333 if balances.is_empty() {
334 let zero_currency = Currency::USD();
335 let zero_money = Money::zero(zero_currency);
336 balances.push(AccountBalance::new(zero_money, zero_money, zero_money));
337 }
338
339 Ok(AccountState::new(
340 account_id,
341 AccountType::Margin,
342 balances,
343 vec![],
344 true,
345 UUID4::new(),
346 ts_event,
347 ts_init,
348 None,
349 ))
350}
351
352pub fn parse_order_status_report<F>(
364 order: &AxOpenOrder,
365 account_id: AccountId,
366 instrument: &InstrumentAny,
367 ts_init: UnixNanos,
368 cid_resolver: Option<&F>,
369) -> anyhow::Result<OrderStatusReport>
370where
371 F: Fn(u64) -> Option<ClientOrderId>,
372{
373 let instrument_id = instrument.id();
374 let venue_order_id = VenueOrderId::new(&order.oid);
375 let order_side = order.d.into();
376 let order_status = order.o.into();
377 let time_in_force = order.tif.into();
378
379 let order_type = OrderType::Limit;
381
382 let quantity = Quantity::new(order.q as f64, instrument.size_precision());
384 let filled_qty = Quantity::new(order.xq as f64, instrument.size_precision());
385
386 let price = decimal_to_price_dp(order.p, instrument.price_precision(), "order.p")?;
388
389 let ts_event = ax_timestamp_s_to_unix_nanos(order.ts)?;
391
392 let mut report = OrderStatusReport::new(
393 account_id,
394 instrument_id,
395 None,
396 venue_order_id,
397 order_side,
398 order_type,
399 time_in_force,
400 order_status,
401 quantity,
402 filled_qty,
403 ts_event,
404 ts_event,
405 ts_init,
406 Some(UUID4::new()),
407 );
408
409 if let Some(cid) = order.cid {
410 let client_order_id = cid_resolver
411 .and_then(|resolver| resolver(cid))
412 .unwrap_or_else(|| cid_to_client_order_id(cid));
413 report = report.with_client_order_id(client_order_id);
414 }
415
416 report = report.with_price(price);
417
418 Ok(report)
423}
424
425pub fn parse_fill_report(
436 fill: &AxFill,
437 account_id: AccountId,
438 instrument: &InstrumentAny,
439 ts_init: UnixNanos,
440) -> anyhow::Result<FillReport> {
441 let instrument_id = instrument.id();
442
443 let venue_order_id = VenueOrderId::new(&fill.order_id);
444 let trade_id = TradeId::new_checked(&fill.trade_id).context("Invalid trade_id in Ax fill")?;
445
446 let order_side: OrderSide = fill.side.into();
448
449 let last_px = decimal_to_price_dp(fill.price, instrument.price_precision(), "fill.price")?;
450 let last_qty = Quantity::new(fill.quantity as f64, instrument.size_precision());
451
452 let currency = Currency::USD();
453 let commission = Money::from_decimal(fill.fee, currency)
454 .context("Failed to convert fill.fee Decimal to Money")?;
455
456 let liquidity_side = if fill.is_taker {
457 LiquiditySide::Taker
458 } else {
459 LiquiditySide::Maker
460 };
461
462 let ts_event = match fill.timestamp.timestamp_nanos_opt() {
463 Some(nanos) => UnixNanos::from(nanos.unsigned_abs()),
464 None => {
465 log::warn!(
466 "Timestamp overflow for fill {} (timestamp={}), defaulting to 0",
467 fill.trade_id,
468 fill.timestamp
469 );
470 UnixNanos::from(0u64)
471 }
472 };
473
474 Ok(FillReport::new(
475 account_id,
476 instrument_id,
477 venue_order_id,
478 trade_id,
479 order_side,
480 last_qty,
481 last_px,
482 commission,
483 liquidity_side,
484 None,
485 None,
486 ts_event,
487 ts_init,
488 None,
489 ))
490}
491
492pub fn parse_position_status_report(
500 position: &AxPosition,
501 account_id: AccountId,
502 instrument: &InstrumentAny,
503 ts_init: UnixNanos,
504) -> anyhow::Result<PositionStatusReport> {
505 let instrument_id = instrument.id();
506
507 let (position_side, quantity) = if position.signed_quantity > 0 {
509 (
510 PositionSideSpecified::Long,
511 Quantity::new(position.signed_quantity as f64, instrument.size_precision()),
512 )
513 } else if position.signed_quantity < 0 {
514 (
515 PositionSideSpecified::Short,
516 Quantity::new(
517 position.signed_quantity.unsigned_abs() as f64,
518 instrument.size_precision(),
519 ),
520 )
521 } else {
522 (
523 PositionSideSpecified::Flat,
524 Quantity::zero(instrument.size_precision()),
525 )
526 };
527
528 let avg_px_open = if position.signed_quantity != 0 {
531 let qty_dec = Decimal::from(position.signed_quantity.abs());
532 Some(position.signed_notional.abs() / qty_dec)
533 } else {
534 None
535 };
536
537 let ts_last = match position.timestamp.timestamp_nanos_opt() {
538 Some(nanos) => UnixNanos::from(nanos.unsigned_abs()),
539 None => {
540 log::warn!(
541 "Timestamp overflow for position {} (timestamp={}), defaulting to 0",
542 position.symbol,
543 position.timestamp
544 );
545 UnixNanos::from(0u64)
546 }
547 };
548
549 Ok(PositionStatusReport::new(
550 account_id,
551 instrument_id,
552 position_side,
553 quantity,
554 ts_last,
555 ts_init,
556 None,
557 None,
558 avg_px_open,
559 ))
560}
561
562pub fn parse_trade_tick(
568 trade: &AxRestTrade,
569 instrument: &InstrumentAny,
570 ts_init: UnixNanos,
571) -> anyhow::Result<TradeTick> {
572 let price = decimal_to_price_dp(trade.p, instrument.price_precision(), "trade.p")?;
573 let size = Quantity::new(trade.q as f64, instrument.size_precision());
574 let aggressor_side: AggressorSide = trade.d.into();
575
576 let ts_event = UnixNanos::from(trade.ts as u64 * 1_000_000_000 + trade.tn as u64);
578
579 let mut buf = itoa::Buffer::new();
581 let trade_id =
582 TradeId::new_checked(buf.format(ts_event.as_u64())).context("Failed to create TradeId")?;
583
584 TradeTick::new_checked(
585 instrument.id(),
586 price,
587 size,
588 aggressor_side,
589 trade_id,
590 ts_event,
591 ts_init,
592 )
593 .context("Failed to construct TradeTick from Ax REST trade")
594}
595
596#[cfg(test)]
597mod tests {
598 use nautilus_core::nanos::UnixNanos;
599 use rstest::rstest;
600 use rust_decimal_macros::dec;
601 use ustr::Ustr;
602
603 use super::*;
604 use crate::{
605 common::enums::{AxCategory, AxInstrumentState},
606 http::models::{AxFundingRatesResponse, AxInstrumentsResponse},
607 };
608
609 fn create_eurusd_instrument() -> AxInstrument {
610 AxInstrument {
611 symbol: Ustr::from("EURUSD-PERP"),
612 state: AxInstrumentState::Open,
613 multiplier: dec!(1),
614 minimum_order_size: dec!(100),
615 tick_size: dec!(0.0001),
616 quote_currency: Ustr::from("USD"),
617 funding_settlement_currency: Ustr::from("USD"),
618 category: Some(AxCategory::Fx),
619 maintenance_margin_pct: dec!(4.0),
620 initial_margin_pct: dec!(8.0),
621 contract_mark_price: Some("Average price on AX at London 4pm".to_string()),
622 contract_size: Some("1 Euro per contract".to_string()),
623 description: Some("Euro / US Dollar FX Perpetual Future".to_string()),
624 funding_calendar_schedule: None,
625 funding_frequency: None,
626 funding_rate_cap_lower_pct: Some(dec!(-1.0)),
627 funding_rate_cap_upper_pct: Some(dec!(1.0)),
628 price_band_lower_deviation_pct: Some(dec!(10)),
629 price_band_upper_deviation_pct: Some(dec!(10)),
630 price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
631 price_quotation: Some("U.S. dollars per Euro".to_string()),
632 underlying_benchmark_price: Some("WMR London 4pm Closing Spot Rate".to_string()),
633 }
634 }
635
636 fn create_nvda_instrument() -> AxInstrument {
637 AxInstrument {
638 symbol: Ustr::from("NVDA-PERP"),
639 state: AxInstrumentState::Open,
640 multiplier: dec!(1),
641 minimum_order_size: dec!(1),
642 tick_size: dec!(0.01),
643 quote_currency: Ustr::from("USD"),
644 funding_settlement_currency: Ustr::from("USD"),
645 category: Some(AxCategory::Equities),
646 maintenance_margin_pct: dec!(10),
647 initial_margin_pct: dec!(20),
648 contract_mark_price: Some(
649 "Average price on ArchitectX at 4pm New York Time".to_string(),
650 ),
651 contract_size: Some("1 share per contract".to_string()),
652 description: Some("NVIDIA Corp US Equity Perpetual Future".to_string()),
653 funding_calendar_schedule: None,
654 funding_frequency: None,
655 funding_rate_cap_lower_pct: Some(dec!(-1)),
656 funding_rate_cap_upper_pct: Some(dec!(1)),
657 price_band_lower_deviation_pct: Some(dec!(10)),
658 price_band_upper_deviation_pct: Some(dec!(10)),
659 price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
660 price_quotation: Some("U.S. dollars per share".to_string()),
661 underlying_benchmark_price: Some("Nasdaq Official Closing Price".to_string()),
662 }
663 }
664
665 fn create_xau_instrument() -> AxInstrument {
666 AxInstrument {
667 symbol: Ustr::from("XAU-PERP"),
668 state: AxInstrumentState::Open,
669 multiplier: dec!(1),
670 minimum_order_size: dec!(1),
671 tick_size: dec!(0.1),
672 quote_currency: Ustr::from("USD"),
673 funding_settlement_currency: Ustr::from("USD"),
674 category: Some(AxCategory::Metals),
675 maintenance_margin_pct: dec!(5),
676 initial_margin_pct: dec!(10),
677 contract_mark_price: Some("Average price on ArchitectX at London 4pm".to_string()),
678 contract_size: Some("1 ounce per contract".to_string()),
679 description: Some("Gold Metals Perpetual Future".to_string()),
680 funding_calendar_schedule: None,
681 funding_frequency: None,
682 funding_rate_cap_lower_pct: Some(dec!(-1)),
683 funding_rate_cap_upper_pct: Some(dec!(1)),
684 price_band_lower_deviation_pct: Some(dec!(10)),
685 price_band_upper_deviation_pct: Some(dec!(10)),
686 price_bands: Some("+/- 10% from prior Contract Mark Price".to_string()),
687 price_quotation: Some("U.S. dollars per ounce".to_string()),
688 underlying_benchmark_price: Some("XAU WMR Metals Daily Closing Rate".to_string()),
689 }
690 }
691
692 #[rstest]
693 fn test_decimal_to_price() {
694 let price = decimal_to_price(dec!(100.50), "test_field").unwrap();
695 assert_eq!(price.as_f64(), 100.50);
696 }
697
698 #[rstest]
699 fn test_decimal_to_quantity() {
700 let qty = decimal_to_quantity(dec!(1.5), "test_field").unwrap();
701 assert_eq!(qty.as_f64(), 1.5);
702 }
703
704 #[rstest]
705 fn test_get_currency_known() {
706 let currency = get_currency("USD");
707 assert_eq!(currency.code, Ustr::from("USD"));
708 assert_eq!(currency.precision, 2);
709 }
710
711 #[rstest]
712 fn test_get_currency_unknown_creates_new() {
713 let currency = get_currency("NVDA");
714 assert_eq!(currency.code, Ustr::from("NVDA"));
715 assert_eq!(currency.precision, 0);
716 }
717
718 #[rstest]
719 fn test_parse_fx_instrument() {
720 let definition = create_eurusd_instrument();
721 let maker_fee = Decimal::new(2, 5);
722 let taker_fee = Decimal::new(2, 5);
723 let ts_now = UnixNanos::default();
724
725 let result = parse_perp_instrument(&definition, maker_fee, taker_fee, ts_now, ts_now);
726 assert!(result.is_ok());
727
728 let instrument = result.unwrap();
729 match instrument {
730 InstrumentAny::PerpetualContract(perp) => {
731 assert_eq!(perp.id.symbol.as_str(), "EURUSD-PERP");
732 assert_eq!(perp.id.venue, *AX_VENUE);
733 assert_eq!(perp.underlying.as_str(), "EURUSD");
734 assert_eq!(perp.asset_class, AssetClass::FX);
735 assert_eq!(perp.base_currency.unwrap().code.as_str(), "EUR");
736 assert_eq!(perp.quote_currency.code.as_str(), "USD");
737 assert_eq!(perp.settlement_currency.code.as_str(), "USD");
738 assert_eq!(perp.price_precision, 4);
739 assert!(!perp.is_inverse);
740 }
741 _ => panic!("Expected PerpetualContract instrument"),
742 }
743 }
744
745 #[rstest]
746 fn test_parse_equity_instrument() {
747 let definition = create_nvda_instrument();
748 let maker_fee = Decimal::new(2, 5);
749 let taker_fee = Decimal::new(2, 5);
750 let ts_now = UnixNanos::default();
751
752 let result = parse_perp_instrument(&definition, maker_fee, taker_fee, ts_now, ts_now);
753 assert!(result.is_ok());
754
755 let instrument = result.unwrap();
756 match instrument {
757 InstrumentAny::PerpetualContract(perp) => {
758 assert_eq!(perp.id.symbol.as_str(), "NVDA-PERP");
759 assert_eq!(perp.id.venue, *AX_VENUE);
760 assert_eq!(perp.underlying.as_str(), "NVDA");
761 assert_eq!(perp.asset_class, AssetClass::Equity);
762 assert_eq!(perp.quote_currency.code.as_str(), "USD");
763 assert_eq!(perp.settlement_currency.code.as_str(), "USD");
764 assert_eq!(perp.price_precision, 2);
765 assert!(!perp.is_inverse);
766 }
767 _ => panic!("Expected PerpetualContract instrument"),
768 }
769 }
770
771 #[rstest]
772 fn test_parse_metals_instrument() {
773 let definition = create_xau_instrument();
774 let ts_now = UnixNanos::default();
775
776 let result =
777 parse_perp_instrument(&definition, Decimal::ZERO, Decimal::ZERO, ts_now, ts_now);
778 let instrument = result.unwrap();
779 match instrument {
780 InstrumentAny::PerpetualContract(perp) => {
781 assert_eq!(perp.id.symbol.as_str(), "XAU-PERP");
782 assert_eq!(perp.underlying.as_str(), "XAU");
783 assert_eq!(perp.asset_class, AssetClass::Commodity);
784 assert!(perp.base_currency.is_none());
785 assert_eq!(perp.quote_currency.code.as_str(), "USD");
786 assert_eq!(perp.price_precision, 1);
787 }
788 _ => panic!("Expected PerpetualContract instrument"),
789 }
790 }
791
792 #[rstest]
793 fn test_parse_settlement_differs_from_quote() {
794 let mut definition = create_eurusd_instrument();
795 definition.funding_settlement_currency = Ustr::from("EUR");
796 let ts_now = UnixNanos::default();
797
798 let result =
799 parse_perp_instrument(&definition, Decimal::ZERO, Decimal::ZERO, ts_now, ts_now);
800 let instrument = result.unwrap();
801 match instrument {
802 InstrumentAny::PerpetualContract(perp) => {
803 assert_eq!(perp.quote_currency.code.as_str(), "USD");
804 assert_eq!(perp.settlement_currency.code.as_str(), "EUR");
805 }
806 _ => panic!("Expected PerpetualContract instrument"),
807 }
808 }
809
810 #[rstest]
811 fn test_parse_unknown_category_falls_back_to_alternative() {
812 let mut definition = create_eurusd_instrument();
813 definition.category = Some(AxCategory::Unknown);
814 let ts_now = UnixNanos::default();
815
816 let result =
817 parse_perp_instrument(&definition, Decimal::ZERO, Decimal::ZERO, ts_now, ts_now);
818 let instrument = result.unwrap();
819 match instrument {
820 InstrumentAny::PerpetualContract(perp) => {
821 assert_eq!(perp.asset_class, AssetClass::Alternative);
822 }
823 _ => panic!("Expected PerpetualContract instrument"),
824 }
825 }
826
827 #[rstest]
828 fn test_deserialize_instruments_from_test_data() {
829 let test_data = include_str!("../../test_data/http_get_instruments.json");
830 let response: AxInstrumentsResponse =
831 serde_json::from_str(test_data).expect("Failed to deserialize test data");
832
833 assert_eq!(response.instruments.len(), 3);
834
835 let eurusd = &response.instruments[0];
836 assert_eq!(eurusd.symbol.as_str(), "EURUSD-PERP");
837 assert_eq!(eurusd.category, Some(AxCategory::Fx));
838 assert_eq!(eurusd.tick_size, dec!(0.0001));
839 assert_eq!(eurusd.minimum_order_size, dec!(100));
840
841 let xau = &response.instruments[1];
842 assert_eq!(xau.symbol.as_str(), "XAU-PERP");
843 assert_eq!(xau.category, Some(AxCategory::Metals));
844
845 let nvda = &response.instruments[2];
846 assert_eq!(nvda.symbol.as_str(), "NVDA-PERP");
847 assert_eq!(nvda.category, Some(AxCategory::Equities));
848 }
849
850 #[rstest]
851 fn test_parse_all_instruments_from_test_data() {
852 let test_data = include_str!("../../test_data/http_get_instruments.json");
853 let response: AxInstrumentsResponse =
854 serde_json::from_str(test_data).expect("Failed to deserialize test data");
855
856 let maker_fee = Decimal::new(2, 4);
857 let taker_fee = Decimal::new(5, 4);
858 let ts_now = UnixNanos::default();
859
860 let open_instruments: Vec<_> = response
861 .instruments
862 .iter()
863 .filter(|i| i.state == AxInstrumentState::Open)
864 .collect();
865
866 assert_eq!(open_instruments.len(), 3);
867
868 for instrument in open_instruments {
869 let result = parse_perp_instrument(instrument, maker_fee, taker_fee, ts_now, ts_now);
870 assert!(
871 result.is_ok(),
872 "Failed to parse {}: {:?}",
873 instrument.symbol,
874 result.err()
875 );
876 }
877 }
878
879 #[rstest]
880 fn test_deserialize_and_parse_funding_rates() {
881 let test_data = include_str!("../../test_data/http_get_funding_rates.json");
882 let response: AxFundingRatesResponse =
883 serde_json::from_str(test_data).expect("Failed to deserialize test data");
884
885 assert_eq!(response.funding_rates.len(), 2);
886 assert_eq!(response.funding_rates[0].symbol.as_str(), "JPYUSD-PERP");
887 assert_eq!(response.funding_rates[0].funding_rate, dec!(0.001234560000));
888
889 let instrument_id = InstrumentId::new(Symbol::new("JPYUSD-PERP"), *AX_VENUE);
890 let ts_init = UnixNanos::from(1_000_000_000u64);
891
892 let update =
893 parse_funding_rate(&response.funding_rates[1], instrument_id, ts_init).unwrap();
894
895 assert_eq!(update.instrument_id, instrument_id);
896 assert_eq!(update.rate, dec!(0.003558290026));
897 assert_eq!(update.next_funding_ns, None);
898 assert_eq!(update.ts_event, UnixNanos::from(1770393600000000000u64));
899 assert_eq!(update.ts_init, ts_init);
900 }
901}