1use anyhow::Context;
19use nautilus_core::{UUID4, nanos::UnixNanos};
20use nautilus_model::{
21 data::{Bar, BarSpecification, BarType},
22 enums::{
23 AccountType, AggregationSource, BarAggregation, LiquiditySide, OrderSide, OrderType,
24 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::{AxBalancesResponse, AxCandle, AxFill, AxInstrument, AxOpenOrder, AxPosition};
35use crate::common::{consts::AX_VENUE, enums::AxCandleWidth};
36
37fn decimal_to_price(value: Decimal, field_name: &str) -> anyhow::Result<Price> {
43 Price::from_decimal(value)
44 .with_context(|| format!("Failed to convert {field_name} Decimal to Price"))
45}
46
47fn decimal_to_quantity(value: Decimal, field_name: &str) -> anyhow::Result<Quantity> {
53 Quantity::from_decimal(value)
54 .with_context(|| format!("Failed to convert {field_name} Decimal to Quantity"))
55}
56
57fn decimal_to_price_dp(value: Decimal, precision: u8, field: &str) -> anyhow::Result<Price> {
59 Price::from_decimal_dp(value, precision).with_context(|| {
60 format!("Failed to construct Price for {field} with precision {precision}")
61 })
62}
63
64#[must_use]
66fn get_currency(code: &str) -> Currency {
67 Currency::from(code)
68}
69
70#[must_use]
72pub fn candle_width_to_bar_spec(width: AxCandleWidth) -> BarSpecification {
73 match width {
74 AxCandleWidth::Seconds1 => {
75 BarSpecification::new(1, BarAggregation::Second, PriceType::Last)
76 }
77 AxCandleWidth::Seconds5 => {
78 BarSpecification::new(5, BarAggregation::Second, PriceType::Last)
79 }
80 AxCandleWidth::Minutes1 => {
81 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
82 }
83 AxCandleWidth::Minutes5 => {
84 BarSpecification::new(5, BarAggregation::Minute, PriceType::Last)
85 }
86 AxCandleWidth::Minutes15 => {
87 BarSpecification::new(15, BarAggregation::Minute, PriceType::Last)
88 }
89 AxCandleWidth::Hours1 => BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
90 AxCandleWidth::Days1 => BarSpecification::new(1, BarAggregation::Day, PriceType::Last),
91 }
92}
93
94pub fn parse_bar(
100 candle: &AxCandle,
101 instrument: &InstrumentAny,
102 ts_init: UnixNanos,
103) -> anyhow::Result<Bar> {
104 let price_precision = instrument.price_precision();
105 let size_precision = instrument.size_precision();
106
107 let open = decimal_to_price_dp(candle.open, price_precision, "candle.open")?;
108 let high = decimal_to_price_dp(candle.high, price_precision, "candle.high")?;
109 let low = decimal_to_price_dp(candle.low, price_precision, "candle.low")?;
110 let close = decimal_to_price_dp(candle.close, price_precision, "candle.close")?;
111
112 let volume = Quantity::new(candle.volume as f64, size_precision);
114
115 let ts_event = UnixNanos::from(candle.tn.timestamp_nanos_opt().unwrap_or(0) as u64);
116
117 let bar_spec = candle_width_to_bar_spec(candle.width);
118 let bar_type = BarType::new(instrument.id(), bar_spec, AggregationSource::External);
119
120 Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
121 .context("Failed to construct Bar from Ax candle")
122}
123
124pub fn parse_perp_instrument(
130 definition: &AxInstrument,
131 maker_fee: Decimal,
132 taker_fee: Decimal,
133 ts_event: UnixNanos,
134 ts_init: UnixNanos,
135) -> anyhow::Result<InstrumentAny> {
136 let raw_symbol_str = definition.symbol.as_str();
138 let raw_symbol = Symbol::new(raw_symbol_str);
139 let instrument_id = InstrumentId::new(raw_symbol, *AX_VENUE);
140
141 let base_code = raw_symbol_str
142 .split('-')
143 .next()
144 .context("Failed to extract base currency from symbol")?;
145 let base_currency = get_currency(base_code);
146
147 let quote_currency = get_currency(&definition.quote_currency);
148 let settlement_currency = quote_currency;
149
150 let price_increment = decimal_to_price(definition.tick_size, "tick_size")?;
151 let size_increment = decimal_to_quantity(definition.minimum_order_size, "minimum_order_size")?;
152
153 let lot_size = Some(size_increment);
154 let min_quantity = Some(size_increment);
155
156 let margin_init = definition.initial_margin_pct;
157 let margin_maint = definition.maintenance_margin_pct;
158
159 let instrument = CryptoPerpetual::new(
160 instrument_id,
161 raw_symbol,
162 base_currency,
163 quote_currency,
164 settlement_currency,
165 false, price_increment.precision,
167 size_increment.precision,
168 price_increment,
169 size_increment,
170 None,
171 lot_size,
172 None,
173 min_quantity,
174 None,
175 None,
176 None,
177 None,
178 Some(margin_init),
179 Some(margin_maint),
180 Some(maker_fee),
181 Some(taker_fee),
182 ts_event,
183 ts_init,
184 );
185
186 Ok(InstrumentAny::CryptoPerpetual(instrument))
187}
188
189pub fn parse_account_state(
198 response: &AxBalancesResponse,
199 account_id: AccountId,
200 ts_event: UnixNanos,
201 ts_init: UnixNanos,
202) -> anyhow::Result<AccountState> {
203 let mut balances = Vec::new();
204
205 for balance in &response.balances {
206 let symbol_str = balance.symbol.as_str().trim();
207 if symbol_str.is_empty() {
208 log::debug!("Skipping balance with empty symbol");
209 continue;
210 }
211
212 let currency = Currency::from(symbol_str);
213
214 let total = Money::from_decimal(balance.amount, currency)
215 .with_context(|| format!("Failed to convert balance for {symbol_str}"))?;
216 let locked = Money::new(0.0, currency);
217 let free = total;
218
219 balances.push(AccountBalance::new(total, locked, free));
220 }
221
222 if balances.is_empty() {
223 let zero_currency = Currency::USD();
224 let zero_money = Money::new(0.0, zero_currency);
225 balances.push(AccountBalance::new(zero_money, zero_money, zero_money));
226 }
227
228 Ok(AccountState::new(
229 account_id,
230 AccountType::Margin,
231 balances,
232 vec![],
233 true,
234 UUID4::new(),
235 ts_event,
236 ts_init,
237 None,
238 ))
239}
240
241pub fn parse_order_status_report(
249 order: &AxOpenOrder,
250 account_id: AccountId,
251 instrument: &InstrumentAny,
252 ts_init: UnixNanos,
253) -> anyhow::Result<OrderStatusReport> {
254 let instrument_id = instrument.id();
255 let venue_order_id = VenueOrderId::new(&order.oid);
256 let order_side = order.d.into();
257 let order_status = order.o.into();
258 let time_in_force = order.tif.into();
259
260 let order_type = OrderType::Limit;
262
263 let quantity = Quantity::new(order.q as f64, instrument.size_precision());
265 let filled_qty = Quantity::new(order.xq as f64, instrument.size_precision());
266
267 let price = decimal_to_price_dp(order.p, instrument.price_precision(), "order.p")?;
269
270 let ts_event = UnixNanos::from((order.ts as u64) * 1_000_000_000);
272
273 let mut report = OrderStatusReport::new(
274 account_id,
275 instrument_id,
276 None,
277 venue_order_id,
278 order_side,
279 order_type,
280 time_in_force,
281 order_status,
282 quantity,
283 filled_qty,
284 ts_event,
285 ts_event,
286 ts_init,
287 Some(UUID4::new()),
288 );
289
290 if let Some(ref tag) = order.tag
292 && !tag.is_empty()
293 {
294 report = report.with_client_order_id(ClientOrderId::new(tag.as_str()));
295 }
296
297 report = report.with_price(price);
298
299 if order.xq > 0 {
301 let avg_px = price.as_f64();
302 report = report.with_avg_px(avg_px)?;
303 }
304
305 Ok(report)
306}
307
308pub fn parse_fill_report(
319 fill: &AxFill,
320 account_id: AccountId,
321 instrument: &InstrumentAny,
322 ts_init: UnixNanos,
323) -> anyhow::Result<FillReport> {
324 let instrument_id = instrument.id();
325
326 let venue_order_id = VenueOrderId::new(&fill.execution_id);
328 let trade_id =
329 TradeId::new_checked(&fill.execution_id).context("Invalid execution_id in Ax fill")?;
330
331 let order_side = if fill.quantity >= 0 {
333 OrderSide::Buy
334 } else {
335 OrderSide::Sell
336 };
337
338 let last_px = decimal_to_price_dp(fill.price, instrument.price_precision(), "fill.price")?;
339 let last_qty = Quantity::new(
340 fill.quantity.unsigned_abs() as f64,
341 instrument.size_precision(),
342 );
343
344 let currency = Currency::USD();
346 let commission = Money::from_decimal(-fill.fee, currency)
347 .context("Failed to convert fill.fee Decimal to Money")?;
348
349 let liquidity_side = if fill.is_taker {
350 LiquiditySide::Taker
351 } else {
352 LiquiditySide::Maker
353 };
354
355 let ts_event = UnixNanos::from(
356 fill.timestamp
357 .timestamp_nanos_opt()
358 .unwrap_or(0)
359 .unsigned_abs(),
360 );
361
362 Ok(FillReport::new(
363 account_id,
364 instrument_id,
365 venue_order_id,
366 trade_id,
367 order_side,
368 last_qty,
369 last_px,
370 commission,
371 liquidity_side,
372 None,
373 None,
374 ts_event,
375 ts_init,
376 None,
377 ))
378}
379
380pub fn parse_position_status_report(
388 position: &AxPosition,
389 account_id: AccountId,
390 instrument: &InstrumentAny,
391 ts_init: UnixNanos,
392) -> anyhow::Result<PositionStatusReport> {
393 let instrument_id = instrument.id();
394
395 let (position_side, quantity) = if position.open_quantity > 0 {
397 (
398 PositionSideSpecified::Long,
399 Quantity::new(position.open_quantity as f64, instrument.size_precision()),
400 )
401 } else if position.open_quantity < 0 {
402 (
403 PositionSideSpecified::Short,
404 Quantity::new(
405 position.open_quantity.unsigned_abs() as f64,
406 instrument.size_precision(),
407 ),
408 )
409 } else {
410 (
411 PositionSideSpecified::Flat,
412 Quantity::new(0.0, instrument.size_precision()),
413 )
414 };
415
416 let avg_px_open = if position.open_quantity != 0 {
418 let qty_dec = Decimal::from(position.open_quantity.abs());
419 Some(position.open_notional / qty_dec)
420 } else {
421 None
422 };
423
424 let ts_last = UnixNanos::from(
425 position
426 .timestamp
427 .timestamp_nanos_opt()
428 .unwrap_or(0)
429 .unsigned_abs(),
430 );
431
432 Ok(PositionStatusReport::new(
433 account_id,
434 instrument_id,
435 position_side,
436 quantity,
437 ts_last,
438 ts_init,
439 None,
440 None,
441 avg_px_open,
442 ))
443}
444
445#[cfg(test)]
446mod tests {
447 use nautilus_core::nanos::UnixNanos;
448 use rstest::rstest;
449 use rust_decimal_macros::dec;
450 use ustr::Ustr;
451
452 use super::*;
453 use crate::{common::enums::AxInstrumentState, http::models::AxInstrumentsResponse};
454
455 fn create_test_instrument() -> AxInstrument {
456 AxInstrument {
457 symbol: Ustr::from("BTC-PERP"),
458 state: AxInstrumentState::Open,
459 multiplier: dec!(1.0),
460 minimum_order_size: dec!(0.001),
461 tick_size: dec!(0.5),
462 quote_currency: Ustr::from("USD"),
463 finding_settlement_currency: Ustr::from("USD"),
464 maintenance_margin_pct: dec!(0.005),
465 initial_margin_pct: dec!(0.01),
466 contract_mark_price: Some(dec!(45000.50)),
467 contract_size: Some(dec!(1.0)),
468 description: Some("Bitcoin Perpetual Futures".to_string()),
469 funding_calendar_schedule: Some("0,8,16".to_string()),
470 funding_frequency: Some("8h".to_string()),
471 funding_rate_cap_lower_pct: Some(dec!(-0.0075)),
472 funding_rate_cap_upper_pct: Some(dec!(0.0075)),
473 price_band_lower_deviation_pct: Some(dec!(0.05)),
474 price_band_upper_deviation_pct: Some(dec!(0.05)),
475 price_bands: Some("dynamic".to_string()),
476 price_quotation: Some("USD".to_string()),
477 underlying_benchmark_price: Some(dec!(45000.00)),
478 }
479 }
480
481 #[rstest]
482 fn test_decimal_to_price() {
483 let price = decimal_to_price(dec!(100.50), "test_field").unwrap();
484 assert_eq!(price.as_f64(), 100.50);
485 }
486
487 #[rstest]
488 fn test_decimal_to_quantity() {
489 let qty = decimal_to_quantity(dec!(1.5), "test_field").unwrap();
490 assert_eq!(qty.as_f64(), 1.5);
491 }
492
493 #[rstest]
494 fn test_get_currency() {
495 let currency = get_currency("USD");
496 assert_eq!(currency.code, Ustr::from("USD"));
497 }
498
499 #[rstest]
500 fn test_parse_perp_instrument() {
501 let definition = create_test_instrument();
502 let maker_fee = Decimal::new(2, 4);
503 let taker_fee = Decimal::new(5, 4);
504 let ts_now = UnixNanos::default();
505
506 let result = parse_perp_instrument(&definition, maker_fee, taker_fee, ts_now, ts_now);
507 assert!(result.is_ok());
508
509 let instrument = result.unwrap();
510 match instrument {
511 InstrumentAny::CryptoPerpetual(perp) => {
512 assert_eq!(perp.id.symbol.as_str(), "BTC-PERP");
513 assert_eq!(perp.id.venue, *AX_VENUE);
514 assert_eq!(perp.base_currency.code.as_str(), "BTC");
515 assert_eq!(perp.quote_currency.code.as_str(), "USD");
516 assert!(!perp.is_inverse);
517 }
518 _ => panic!("Expected CryptoPerpetual instrument"),
519 }
520 }
521
522 #[rstest]
523 fn test_deserialize_instruments_from_test_data() {
524 let test_data = include_str!("../../test_data/http_get_instruments.json");
525 let response: AxInstrumentsResponse =
526 serde_json::from_str(test_data).expect("Failed to deserialize test data");
527
528 assert_eq!(response.instruments.len(), 3);
529
530 let btc = &response.instruments[0];
531 assert_eq!(btc.symbol.as_str(), "BTC-PERP");
532 assert_eq!(btc.state, AxInstrumentState::Open);
533 assert_eq!(btc.tick_size, dec!(0.5));
534 assert_eq!(btc.minimum_order_size, dec!(0.001));
535 assert!(btc.contract_mark_price.is_some());
536
537 let eth = &response.instruments[1];
538 assert_eq!(eth.symbol.as_str(), "ETH-PERP");
539 assert_eq!(eth.state, AxInstrumentState::Open);
540
541 let sol = &response.instruments[2];
543 assert_eq!(sol.symbol.as_str(), "SOL-PERP");
544 assert_eq!(sol.state, AxInstrumentState::Suspended);
545 assert!(sol.contract_mark_price.is_none());
546 assert!(sol.funding_frequency.is_none());
547 }
548
549 #[rstest]
550 fn test_parse_all_instruments_from_test_data() {
551 let test_data = include_str!("../../test_data/http_get_instruments.json");
552 let response: AxInstrumentsResponse =
553 serde_json::from_str(test_data).expect("Failed to deserialize test data");
554
555 let maker_fee = Decimal::new(2, 4);
556 let taker_fee = Decimal::new(5, 4);
557 let ts_now = UnixNanos::default();
558
559 let open_instruments: Vec<_> = response
560 .instruments
561 .iter()
562 .filter(|i| i.state == AxInstrumentState::Open)
563 .collect();
564
565 assert_eq!(open_instruments.len(), 2);
566
567 for instrument in open_instruments {
568 let result = parse_perp_instrument(instrument, maker_fee, taker_fee, ts_now, ts_now);
569 assert!(
570 result.is_ok(),
571 "Failed to parse {}: {:?}",
572 instrument.symbol,
573 result.err()
574 );
575 }
576 }
577}