1use anyhow::Context;
19use nautilus_core::{UUID4, nanos::UnixNanos};
20use nautilus_model::{
21 data::{Bar, BarSpecification, BarType, FundingRateUpdate, TradeTick},
22 enums::{
23 AccountType, AggregationSource, AggressorSide, BarAggregation, CurrencyType, LiquiditySide,
24 OrderSide, OrderType, PositionSideSpecified, PriceType,
25 },
26 events::AccountState,
27 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
28 instruments::{CryptoPerpetual, Instrument, any::InstrumentAny},
29 reports::{FillReport, OrderStatusReport, PositionStatusReport},
30 types::{AccountBalance, Currency, Money, Price, Quantity},
31};
32use rust_decimal::Decimal;
33
34use super::models::{
35 AxBalancesResponse, AxCandle, AxFill, AxFundingRate, AxInstrument, AxOpenOrder, AxPosition,
36 AxRestTrade,
37};
38use crate::common::{
39 consts::AX_VENUE,
40 enums::AxCandleWidth,
41 parse::{ax_timestamp_ns_to_unix_nanos, ax_timestamp_s_to_unix_nanos, cid_to_client_order_id},
42};
43
44fn decimal_to_price(value: Decimal, field_name: &str) -> anyhow::Result<Price> {
45 Price::from_decimal(value)
46 .with_context(|| format!("Failed to convert {field_name} Decimal to Price"))
47}
48
49fn decimal_to_quantity(value: Decimal, field_name: &str) -> anyhow::Result<Quantity> {
50 Quantity::from_decimal(value)
51 .with_context(|| format!("Failed to convert {field_name} Decimal to Quantity"))
52}
53
54fn decimal_to_price_dp(value: Decimal, precision: u8, field: &str) -> anyhow::Result<Price> {
55 Price::from_decimal_dp(value, precision).with_context(|| {
56 format!("Failed to construct Price for {field} with precision {precision}")
57 })
58}
59
60fn get_currency(code: &str) -> Currency {
64 Currency::try_from_str(code).unwrap_or_else(|| {
65 let currency = Currency::new(code, 0, 0, code, CurrencyType::Crypto);
67 if let Err(e) = Currency::register(currency, false) {
68 log::warn!("Failed to register currency '{code}': {e}");
69 }
70 currency
71 })
72}
73
74#[must_use]
76pub fn candle_width_to_bar_spec(width: AxCandleWidth) -> BarSpecification {
77 match width {
78 AxCandleWidth::Seconds1 => {
79 BarSpecification::new(1, BarAggregation::Second, PriceType::Last)
80 }
81 AxCandleWidth::Seconds5 => {
82 BarSpecification::new(5, BarAggregation::Second, PriceType::Last)
83 }
84 AxCandleWidth::Minutes1 => {
85 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
86 }
87 AxCandleWidth::Minutes5 => {
88 BarSpecification::new(5, BarAggregation::Minute, PriceType::Last)
89 }
90 AxCandleWidth::Minutes15 => {
91 BarSpecification::new(15, BarAggregation::Minute, PriceType::Last)
92 }
93 AxCandleWidth::Hours1 => BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
94 AxCandleWidth::Days1 => BarSpecification::new(1, BarAggregation::Day, PriceType::Last),
95 }
96}
97
98pub fn parse_bar(
104 candle: &AxCandle,
105 instrument: &InstrumentAny,
106 ts_init: UnixNanos,
107) -> anyhow::Result<Bar> {
108 let price_precision = instrument.price_precision();
109 let size_precision = instrument.size_precision();
110
111 let open = decimal_to_price_dp(candle.open, price_precision, "candle.open")?;
112 let high = decimal_to_price_dp(candle.high, price_precision, "candle.high")?;
113 let low = decimal_to_price_dp(candle.low, price_precision, "candle.low")?;
114 let close = decimal_to_price_dp(candle.close, price_precision, "candle.close")?;
115
116 let volume = Quantity::new(candle.volume as f64, size_precision);
118
119 let ts_event = ax_timestamp_s_to_unix_nanos(candle.ts);
120
121 let bar_spec = candle_width_to_bar_spec(candle.width);
122 let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
123
124 Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
125 .context("Failed to construct Bar from Ax candle")
126}
127
128#[must_use]
130pub fn parse_funding_rate(
131 ax_rate: &AxFundingRate,
132 instrument_id: InstrumentId,
133 ts_init: UnixNanos,
134) -> FundingRateUpdate {
135 FundingRateUpdate::new(
136 instrument_id,
137 ax_rate.funding_rate,
138 None, ax_timestamp_ns_to_unix_nanos(ax_rate.timestamp_ns),
140 ts_init,
141 )
142}
143
144pub fn parse_perp_instrument(
150 definition: &AxInstrument,
151 maker_fee: Decimal,
152 taker_fee: Decimal,
153 ts_event: UnixNanos,
154 ts_init: UnixNanos,
155) -> anyhow::Result<InstrumentAny> {
156 let raw_symbol_str = definition.symbol.as_str();
157 let raw_symbol = Symbol::new(raw_symbol_str);
158 let instrument_id = InstrumentId::new(raw_symbol, *AX_VENUE);
159
160 let symbol_prefix = raw_symbol_str
164 .split('-')
165 .next()
166 .context("Failed to extract symbol prefix")?;
167
168 let quote_code = definition.quote_currency.as_str();
169 let base_code = if symbol_prefix.ends_with(quote_code) && symbol_prefix.len() > quote_code.len()
170 {
171 &symbol_prefix[..symbol_prefix.len() - quote_code.len()]
172 } else {
173 symbol_prefix
174 };
175 let base_currency = get_currency(base_code);
176 let quote_currency = get_currency(quote_code);
177 let settlement_currency = quote_currency;
178
179 let price_increment = decimal_to_price(definition.tick_size, "tick_size")?;
180 let size_increment = decimal_to_quantity(definition.minimum_order_size, "minimum_order_size")?;
181
182 let lot_size = Some(size_increment);
183 let min_quantity = Some(size_increment);
184
185 let margin_init = definition.initial_margin_pct;
186 let margin_maint = definition.maintenance_margin_pct;
187
188 let instrument = CryptoPerpetual::new(
189 instrument_id,
190 raw_symbol,
191 base_currency,
192 quote_currency,
193 settlement_currency,
194 false, price_increment.precision,
196 size_increment.precision,
197 price_increment,
198 size_increment,
199 None,
200 lot_size,
201 None,
202 min_quantity,
203 None,
204 None,
205 None,
206 None,
207 Some(margin_init),
208 Some(margin_maint),
209 Some(maker_fee),
210 Some(taker_fee),
211 ts_event,
212 ts_init,
213 );
214
215 Ok(InstrumentAny::CryptoPerpetual(instrument))
216}
217
218pub fn parse_account_state(
227 response: &AxBalancesResponse,
228 account_id: AccountId,
229 ts_event: UnixNanos,
230 ts_init: UnixNanos,
231) -> anyhow::Result<AccountState> {
232 let mut balances = Vec::with_capacity(response.balances.len());
233
234 for balance in &response.balances {
235 let symbol_str = balance.symbol.as_str().trim();
236 if symbol_str.is_empty() {
237 log::debug!("Skipping balance with empty symbol");
238 continue;
239 }
240
241 let currency = get_currency(symbol_str);
242
243 let total = Money::from_decimal(balance.amount, currency)
244 .with_context(|| format!("Failed to convert balance for {symbol_str}"))?;
245 let locked = Money::new(0.0, currency);
246 let free = total;
247
248 balances.push(AccountBalance::new(total, locked, free));
249 }
250
251 if balances.is_empty() {
252 let zero_currency = Currency::USD();
253 let zero_money = Money::new(0.0, zero_currency);
254 balances.push(AccountBalance::new(zero_money, zero_money, zero_money));
255 }
256
257 Ok(AccountState::new(
258 account_id,
259 AccountType::Margin,
260 balances,
261 vec![],
262 true,
263 UUID4::new(),
264 ts_event,
265 ts_init,
266 None,
267 ))
268}
269
270pub fn parse_order_status_report<F>(
282 order: &AxOpenOrder,
283 account_id: AccountId,
284 instrument: &InstrumentAny,
285 ts_init: UnixNanos,
286 cid_resolver: Option<F>,
287) -> anyhow::Result<OrderStatusReport>
288where
289 F: Fn(u64) -> Option<ClientOrderId>,
290{
291 let instrument_id = instrument.id();
292 let venue_order_id = VenueOrderId::new(&order.oid);
293 let order_side = order.d.into();
294 let order_status = order.o.into();
295 let time_in_force = order.tif.into();
296
297 let order_type = OrderType::Limit;
299
300 let quantity = Quantity::new(order.q as f64, instrument.size_precision());
302 let filled_qty = Quantity::new(order.xq as f64, instrument.size_precision());
303
304 let price = decimal_to_price_dp(order.p, instrument.price_precision(), "order.p")?;
306
307 let ts_event = ax_timestamp_s_to_unix_nanos(order.ts);
309
310 let mut report = OrderStatusReport::new(
311 account_id,
312 instrument_id,
313 None,
314 venue_order_id,
315 order_side,
316 order_type,
317 time_in_force,
318 order_status,
319 quantity,
320 filled_qty,
321 ts_event,
322 ts_event,
323 ts_init,
324 Some(UUID4::new()),
325 );
326
327 if let Some(cid) = order.cid {
328 let client_order_id = cid_resolver
329 .as_ref()
330 .and_then(|resolver| resolver(cid))
331 .unwrap_or_else(|| cid_to_client_order_id(cid));
332 report = report.with_client_order_id(client_order_id);
333 }
334
335 report = report.with_price(price);
336
337 Ok(report)
342}
343
344pub fn parse_fill_report(
355 fill: &AxFill,
356 account_id: AccountId,
357 instrument: &InstrumentAny,
358 ts_init: UnixNanos,
359) -> anyhow::Result<FillReport> {
360 let instrument_id = instrument.id();
361
362 let venue_order_id = VenueOrderId::new(&fill.order_id);
363 let trade_id = TradeId::new_checked(&fill.trade_id).context("Invalid trade_id in Ax fill")?;
364
365 let order_side: OrderSide = fill.side.into();
367
368 let last_px = decimal_to_price_dp(fill.price, instrument.price_precision(), "fill.price")?;
369 let last_qty = Quantity::new(fill.quantity as f64, instrument.size_precision());
370
371 let currency = Currency::USD();
372 let commission = Money::from_decimal(fill.fee, currency)
373 .context("Failed to convert fill.fee Decimal to Money")?;
374
375 let liquidity_side = if fill.is_taker {
376 LiquiditySide::Taker
377 } else {
378 LiquiditySide::Maker
379 };
380
381 let ts_event = UnixNanos::from(
382 fill.timestamp
383 .timestamp_nanos_opt()
384 .unwrap_or(0)
385 .unsigned_abs(),
386 );
387
388 Ok(FillReport::new(
389 account_id,
390 instrument_id,
391 venue_order_id,
392 trade_id,
393 order_side,
394 last_qty,
395 last_px,
396 commission,
397 liquidity_side,
398 None,
399 None,
400 ts_event,
401 ts_init,
402 None,
403 ))
404}
405
406pub fn parse_position_status_report(
414 position: &AxPosition,
415 account_id: AccountId,
416 instrument: &InstrumentAny,
417 ts_init: UnixNanos,
418) -> anyhow::Result<PositionStatusReport> {
419 let instrument_id = instrument.id();
420
421 let (position_side, quantity) = if position.signed_quantity > 0 {
423 (
424 PositionSideSpecified::Long,
425 Quantity::new(position.signed_quantity as f64, instrument.size_precision()),
426 )
427 } else if position.signed_quantity < 0 {
428 (
429 PositionSideSpecified::Short,
430 Quantity::new(
431 position.signed_quantity.unsigned_abs() as f64,
432 instrument.size_precision(),
433 ),
434 )
435 } else {
436 (
437 PositionSideSpecified::Flat,
438 Quantity::new(0.0, instrument.size_precision()),
439 )
440 };
441
442 let avg_px_open = if position.signed_quantity != 0 {
445 let qty_dec = Decimal::from(position.signed_quantity.abs());
446 Some(position.signed_notional.abs() / qty_dec)
447 } else {
448 None
449 };
450
451 let ts_last = UnixNanos::from(
452 position
453 .timestamp
454 .timestamp_nanos_opt()
455 .unwrap_or(0)
456 .unsigned_abs(),
457 );
458
459 Ok(PositionStatusReport::new(
460 account_id,
461 instrument_id,
462 position_side,
463 quantity,
464 ts_last,
465 ts_init,
466 None,
467 None,
468 avg_px_open,
469 ))
470}
471
472pub fn parse_trade_tick(
478 trade: &AxRestTrade,
479 instrument: &InstrumentAny,
480 ts_init: UnixNanos,
481) -> anyhow::Result<TradeTick> {
482 let price = decimal_to_price_dp(trade.p, instrument.price_precision(), "trade.p")?;
483 let size = Quantity::new(trade.q as f64, instrument.size_precision());
484 let aggressor_side: AggressorSide = trade.d.into();
485
486 let ts_event = UnixNanos::from(trade.ts as u64 * 1_000_000_000 + trade.tn as u64);
488
489 let mut buf = itoa::Buffer::new();
491 let trade_id =
492 TradeId::new_checked(buf.format(ts_event.as_u64())).context("Failed to create TradeId")?;
493
494 TradeTick::new_checked(
495 instrument.id(),
496 price,
497 size,
498 aggressor_side,
499 trade_id,
500 ts_event,
501 ts_init,
502 )
503 .context("Failed to construct TradeTick from Ax REST trade")
504}
505
506#[cfg(test)]
507mod tests {
508 use nautilus_core::nanos::UnixNanos;
509 use rstest::rstest;
510 use rust_decimal_macros::dec;
511 use ustr::Ustr;
512
513 use super::*;
514 use crate::{
515 common::enums::AxInstrumentState,
516 http::models::{AxFundingRatesResponse, AxInstrumentsResponse},
517 };
518
519 fn create_test_instrument() -> AxInstrument {
520 AxInstrument {
521 symbol: Ustr::from("BTC-PERP"),
522 state: AxInstrumentState::Open,
523 multiplier: dec!(1.0),
524 minimum_order_size: dec!(0.001),
525 tick_size: dec!(0.5),
526 quote_currency: Ustr::from("USD"),
527 funding_settlement_currency: Ustr::from("USD"),
528 maintenance_margin_pct: dec!(0.005),
529 initial_margin_pct: dec!(0.01),
530 contract_mark_price: Some("45000.50".to_string()),
531 contract_size: Some("1 BTC per contract".to_string()),
532 description: Some("Bitcoin Perpetual Futures".to_string()),
533 funding_calendar_schedule: Some("0,8,16".to_string()),
534 funding_frequency: Some("8h".to_string()),
535 funding_rate_cap_lower_pct: Some(dec!(-0.0075)),
536 funding_rate_cap_upper_pct: Some(dec!(0.0075)),
537 price_band_lower_deviation_pct: Some(dec!(0.05)),
538 price_band_upper_deviation_pct: Some(dec!(0.05)),
539 price_bands: Some("dynamic".to_string()),
540 price_quotation: Some("USD".to_string()),
541 underlying_benchmark_price: Some("CME CF BRR".to_string()),
542 }
543 }
544
545 #[rstest]
546 fn test_decimal_to_price() {
547 let price = decimal_to_price(dec!(100.50), "test_field").unwrap();
548 assert_eq!(price.as_f64(), 100.50);
549 }
550
551 #[rstest]
552 fn test_decimal_to_quantity() {
553 let qty = decimal_to_quantity(dec!(1.5), "test_field").unwrap();
554 assert_eq!(qty.as_f64(), 1.5);
555 }
556
557 #[rstest]
558 fn test_get_currency_known() {
559 let currency = get_currency("USD");
560 assert_eq!(currency.code, Ustr::from("USD"));
561 assert_eq!(currency.precision, 2);
562 }
563
564 #[rstest]
565 fn test_get_currency_unknown_creates_new() {
566 let currency = get_currency("NVDA");
568 assert_eq!(currency.code, Ustr::from("NVDA"));
569 assert_eq!(currency.precision, 0);
570 }
571
572 #[rstest]
573 fn test_parse_perp_instrument() {
574 let definition = create_test_instrument();
575 let maker_fee = Decimal::new(2, 4);
576 let taker_fee = Decimal::new(5, 4);
577 let ts_now = UnixNanos::default();
578
579 let result = parse_perp_instrument(&definition, maker_fee, taker_fee, ts_now, ts_now);
580 assert!(result.is_ok());
581
582 let instrument = result.unwrap();
583 match instrument {
584 InstrumentAny::CryptoPerpetual(perp) => {
585 assert_eq!(perp.id.symbol.as_str(), "BTC-PERP");
586 assert_eq!(perp.id.venue, *AX_VENUE);
587 assert_eq!(perp.base_currency.code.as_str(), "BTC");
588 assert_eq!(perp.quote_currency.code.as_str(), "USD");
589 assert!(!perp.is_inverse);
590 }
591 _ => panic!("Expected CryptoPerpetual instrument"),
592 }
593 }
594
595 #[rstest]
596 fn test_deserialize_instruments_from_test_data() {
597 let test_data = include_str!("../../test_data/http_get_instruments.json");
598 let response: AxInstrumentsResponse =
599 serde_json::from_str(test_data).expect("Failed to deserialize test data");
600
601 assert_eq!(response.instruments.len(), 4);
602
603 let btcusd = &response.instruments[0];
604 assert_eq!(btcusd.symbol.as_str(), "BTCUSD-PERP");
605 assert_eq!(btcusd.state, AxInstrumentState::Open);
606
607 let btc = &response.instruments[1];
608 assert_eq!(btc.symbol.as_str(), "BTC-PERP");
609 assert_eq!(btc.state, AxInstrumentState::Open);
610 assert_eq!(btc.tick_size, dec!(0.5));
611 assert_eq!(btc.minimum_order_size, dec!(0.001));
612 assert!(btc.contract_mark_price.is_some());
613
614 let eth = &response.instruments[2];
615 assert_eq!(eth.symbol.as_str(), "ETH-PERP");
616 assert_eq!(eth.state, AxInstrumentState::Open);
617
618 let sol = &response.instruments[3];
620 assert_eq!(sol.symbol.as_str(), "SOL-PERP");
621 assert_eq!(sol.state, AxInstrumentState::Suspended);
622 assert!(sol.contract_mark_price.is_none());
623 assert!(sol.funding_frequency.is_none());
624 }
625
626 #[rstest]
627 fn test_parse_all_instruments_from_test_data() {
628 let test_data = include_str!("../../test_data/http_get_instruments.json");
629 let response: AxInstrumentsResponse =
630 serde_json::from_str(test_data).expect("Failed to deserialize test data");
631
632 let maker_fee = Decimal::new(2, 4);
633 let taker_fee = Decimal::new(5, 4);
634 let ts_now = UnixNanos::default();
635
636 let open_instruments: Vec<_> = response
637 .instruments
638 .iter()
639 .filter(|i| i.state == AxInstrumentState::Open)
640 .collect();
641
642 assert_eq!(open_instruments.len(), 3);
643
644 for instrument in open_instruments {
645 let result = parse_perp_instrument(instrument, maker_fee, taker_fee, ts_now, ts_now);
646 assert!(
647 result.is_ok(),
648 "Failed to parse {}: {:?}",
649 instrument.symbol,
650 result.err()
651 );
652 }
653 }
654
655 #[rstest]
656 fn test_deserialize_and_parse_funding_rates() {
657 let test_data = include_str!("../../test_data/http_get_funding_rates.json");
658 let response: AxFundingRatesResponse =
659 serde_json::from_str(test_data).expect("Failed to deserialize test data");
660
661 assert_eq!(response.funding_rates.len(), 2);
662 assert_eq!(response.funding_rates[0].symbol.as_str(), "JPYUSD-PERP");
663 assert_eq!(response.funding_rates[0].funding_rate, dec!(0.001234560000));
664
665 let instrument_id = InstrumentId::new(Symbol::new("JPYUSD-PERP"), *AX_VENUE);
666 let ts_init = UnixNanos::from(1_000_000_000u64);
667
668 let update = parse_funding_rate(&response.funding_rates[1], instrument_id, ts_init);
669
670 assert_eq!(update.instrument_id, instrument_id);
671 assert_eq!(update.rate, dec!(0.003558290026));
672 assert_eq!(update.next_funding_ns, None);
673 assert_eq!(update.ts_event, UnixNanos::from(1770393600000000000u64));
674 assert_eq!(update.ts_init, ts_init);
675 }
676}