nautilus_architect_ax/http/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing functions to convert Ax HTTP responses to Nautilus domain types.
17
18use 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
37/// Converts a Decimal value to a Price.
38///
39/// # Errors
40///
41/// Returns an error if the Decimal cannot be converted to Price.
42fn 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
47/// Converts a Decimal value to a Quantity.
48///
49/// # Errors
50///
51/// Returns an error if the Decimal cannot be converted to Quantity.
52fn 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
57/// Converts a Decimal to a Price with specific precision.
58fn 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/// Gets or creates a Currency from a currency code string.
65#[must_use]
66fn get_currency(code: &str) -> Currency {
67    Currency::from(code)
68}
69
70/// Converts an Ax candle width to a Nautilus bar specification.
71#[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
94/// Parses an Ax candle into a Nautilus Bar.
95///
96/// # Errors
97///
98/// Returns an error if any OHLCV field cannot be parsed.
99pub 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    // Ax provides volume as i64 contracts
113    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
124/// Parses an Ax perpetual futures instrument into a Nautilus CryptoPerpetual.
125///
126/// # Errors
127///
128/// Returns an error if any required field cannot be parsed or is invalid.
129pub 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    // Ax perpetuals use format: {BASE}-PERP, quoted in USD
137    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, // Ax perps are linear/USDT-margined
166        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
189/// Parses an Ax balances response into a Nautilus [`AccountState`].
190///
191/// Ax provides a simple balance structure with symbol and amount.
192/// The amount is treated as both total and free balance (no locked funds tracking).
193///
194/// # Errors
195///
196/// Returns an error if balance amount parsing fails.
197pub 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
241/// Parses an Ax open order into a Nautilus [`OrderStatusReport`].
242///
243/// # Errors
244///
245/// Returns an error if:
246/// - Price or quantity fields cannot be parsed.
247/// - Timestamp conversion fails.
248pub 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    // Ax only supports limit orders currently
261    let order_type = OrderType::Limit;
262
263    // Parse quantity (Ax uses i64 contracts)
264    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    // Parse price
268    let price = decimal_to_price_dp(order.p, instrument.price_precision(), "order.p")?;
269
270    // Ax timestamps are in Unix epoch seconds
271    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    // Add client order ID if tag is present
291    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    // Calculate average price if there are fills
300    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
308/// Parses an Ax fill into a Nautilus [`FillReport`].
309///
310/// Note: Ax fills don't include order ID, side, or liquidity information
311/// in the fills endpoint response, so we use default values where necessary.
312///
313/// # Errors
314///
315/// Returns an error if:
316/// - Price or quantity fields cannot be parsed.
317/// - Fee parsing fails.
318pub 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    // Ax fills use execution_id as the unique identifier
327    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    // Ax doesn't provide order side in fills, infer from quantity sign
332    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    // Parse fee (Ax returns positive fee, Nautilus uses negative for costs)
345    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
380/// Parses an Ax position into a Nautilus [`PositionStatusReport`].
381///
382/// # Errors
383///
384/// Returns an error if:
385/// - Position quantity parsing fails.
386/// - Timestamp conversion fails.
387pub 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    // Determine position side and quantity from open_quantity sign
396    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    // Calculate average entry price from notional / quantity
417    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        // SOL-PERP is suspended with null optional fields
542        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}