Skip to main content

nautilus_dydx/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 utilities for converting dYdX v4 Indexer API responses into Nautilus domain models.
17//!
18//! This module contains functions that transform raw JSON data structures
19//! from the dYdX Indexer API into strongly-typed Nautilus data types such as
20//! instruments, trades, bars, account states, etc.
21//!
22//! # Design Principles
23//!
24//! - **Validation First**: All inputs are validated before parsing.
25//! - **Contextual Errors**: All errors include context about what was being parsed.
26//! - **Zero-Copy When Possible**: Uses references and borrows to minimize allocations.
27//! - **Type Safety**: Leverages Rust's type system to prevent invalid states.
28//!
29//! # Error Handling
30//!
31//! All parsing functions return `anyhow::Result<T>` with descriptive error messages
32//! that include context about the field being parsed and the value that failed.
33//! This makes debugging API changes or data issues much easier.
34
35use anyhow::Context;
36use nautilus_core::UnixNanos;
37use nautilus_model::{
38    data::{Bar, BarType, TradeTick},
39    enums::{AggressorSide, OrderSide, TimeInForce},
40    events::AccountState,
41    identifiers::{InstrumentId, Symbol, TradeId, Venue},
42    instruments::{CryptoPerpetual, InstrumentAny},
43    types::{Currency, Price, Quantity},
44};
45use rust_decimal::Decimal;
46
47use super::models::{Candle, PerpetualMarket, Trade};
48#[cfg(test)]
49use crate::common::enums::DydxTransferType;
50use crate::{
51    common::{
52        enums::{DydxMarketStatus, DydxOrderExecution, DydxOrderType, DydxTimeInForce},
53        parse::{parse_decimal, parse_instrument_id, parse_price, parse_quantity},
54    },
55    websocket::messages::DydxSubaccountInfo,
56};
57
58/// Parses a dYdX [`Trade`] into a Nautilus [`TradeTick`].
59///
60/// # Errors
61///
62/// Returns an error if price, size, or timestamp conversion fails.
63pub fn parse_trade_tick(
64    trade: &Trade,
65    instrument_id: InstrumentId,
66    price_precision: u8,
67    size_precision: u8,
68    ts_init: UnixNanos,
69) -> anyhow::Result<TradeTick> {
70    let aggressor_side = match trade.side {
71        OrderSide::Buy => AggressorSide::Buyer,
72        OrderSide::Sell => AggressorSide::Seller,
73        OrderSide::NoOrderSide => AggressorSide::NoAggressor,
74    };
75
76    let price = Price::from_decimal_dp(trade.price, price_precision)
77        .context(format!("failed to parse price for trade {}", trade.id))?;
78
79    let size = Quantity::from_decimal_dp(trade.size, size_precision)
80        .context(format!("failed to parse size for trade {}", trade.id))?;
81
82    let ts_event_nanos = trade
83        .created_at
84        .timestamp_nanos_opt()
85        .ok_or_else(|| anyhow::anyhow!("Timestamp out of range for trade {}", trade.id))?;
86    let ts_event = UnixNanos::from(ts_event_nanos as u64);
87
88    Ok(TradeTick::new(
89        instrument_id,
90        price,
91        size,
92        aggressor_side,
93        TradeId::new(&trade.id),
94        ts_event,
95        ts_init,
96    ))
97}
98
99/// Parses a dYdX [`Candle`] into a Nautilus [`Bar`].
100///
101/// When `timestamp_on_close` is true, `ts_event` is set to bar close time
102/// (started_at + interval). When false, uses the venue-native open time.
103///
104/// # Errors
105///
106/// Returns an error if OHLCV or timestamp conversion fails.
107pub fn parse_bar(
108    candle: &Candle,
109    bar_type: BarType,
110    price_precision: u8,
111    size_precision: u8,
112    timestamp_on_close: bool,
113    ts_init: UnixNanos,
114) -> anyhow::Result<Bar> {
115    let started_at_nanos = candle.started_at.timestamp_nanos_opt().ok_or_else(|| {
116        anyhow::anyhow!("Timestamp out of range for candle at {}", candle.started_at)
117    })?;
118    let mut ts_event = UnixNanos::from(started_at_nanos as u64);
119    if timestamp_on_close {
120        let interval_ns = bar_type
121            .spec()
122            .timedelta()
123            .num_nanoseconds()
124            .context("bar specification produced non-integer interval")?;
125        let interval_ns =
126            u64::try_from(interval_ns).context("bar interval overflowed u64 nanoseconds")?;
127        let updated = ts_event
128            .as_u64()
129            .checked_add(interval_ns)
130            .context("bar timestamp overflowed when adjusting to close time")?;
131        ts_event = UnixNanos::from(updated);
132    }
133
134    let open = Price::from_decimal_dp(candle.open, price_precision)
135        .context("failed to parse candle open price")?;
136    let high = Price::from_decimal_dp(candle.high, price_precision)
137        .context("failed to parse candle high price")?;
138    let low = Price::from_decimal_dp(candle.low, price_precision)
139        .context("failed to parse candle low price")?;
140    let close = Price::from_decimal_dp(candle.close, price_precision)
141        .context("failed to parse candle close price")?;
142    let volume = Quantity::from_decimal_dp(candle.base_token_volume, size_precision)
143        .context("failed to parse candle base_token_volume")?;
144
145    Ok(Bar::new(
146        bar_type, open, high, low, close, volume, ts_event, ts_init,
147    ))
148}
149
150/// Validates that a ticker has the correct format (BASE-QUOTE).
151///
152/// # Errors
153///
154/// Returns an error if the ticker is not in the format "BASE-QUOTE".
155///
156pub fn validate_ticker_format(ticker: &str) -> anyhow::Result<()> {
157    let parts: Vec<&str> = ticker.split('-').collect();
158    if parts.len() != 2 {
159        anyhow::bail!("Invalid ticker format '{ticker}', expected 'BASE-QUOTE' (e.g., 'BTC-USD')");
160    }
161    if parts[0].is_empty() || parts[1].is_empty() {
162        anyhow::bail!("Invalid ticker format '{ticker}', base and quote cannot be empty");
163    }
164    Ok(())
165}
166
167/// Parses base and quote currency codes from a ticker.
168///
169/// # Errors
170///
171/// Returns an error if the ticker format is invalid.
172///
173pub fn parse_ticker_currencies(ticker: &str) -> anyhow::Result<(&str, &str)> {
174    validate_ticker_format(ticker)?;
175    let parts: Vec<&str> = ticker.split('-').collect();
176    Ok((parts[0], parts[1]))
177}
178
179/// Returns true if the market status is Active.
180#[must_use]
181pub const fn is_market_active(status: &DydxMarketStatus) -> bool {
182    matches!(status, DydxMarketStatus::Active)
183}
184
185/// Calculate time-in-force for conditional orders.
186///
187/// # Errors
188///
189/// Returns an error if the combination of parameters is invalid.
190pub fn calculate_time_in_force(
191    order_type: DydxOrderType,
192    base_tif: DydxTimeInForce,
193    post_only: bool,
194    execution: Option<DydxOrderExecution>,
195) -> anyhow::Result<TimeInForce> {
196    match order_type {
197        DydxOrderType::Market => Ok(TimeInForce::Ioc),
198        DydxOrderType::Limit if post_only => Ok(TimeInForce::Gtc), // Post-only is GTC with post_only flag
199        DydxOrderType::Limit => match base_tif {
200            DydxTimeInForce::Gtt => Ok(TimeInForce::Gtc),
201            DydxTimeInForce::Fok => Ok(TimeInForce::Fok),
202            DydxTimeInForce::Ioc => Ok(TimeInForce::Ioc),
203        },
204
205        DydxOrderType::StopLimit | DydxOrderType::TakeProfitLimit => match execution {
206            Some(DydxOrderExecution::PostOnly) => Ok(TimeInForce::Gtc), // Post-only is GTC with post_only flag
207            Some(DydxOrderExecution::Fok) => Ok(TimeInForce::Fok),
208            Some(DydxOrderExecution::Ioc) => Ok(TimeInForce::Ioc),
209            Some(DydxOrderExecution::Default) | None => Ok(TimeInForce::Gtc), // Default for conditional limit
210        },
211
212        DydxOrderType::StopMarket | DydxOrderType::TakeProfitMarket => match execution {
213            Some(DydxOrderExecution::Fok) => Ok(TimeInForce::Fok),
214            Some(DydxOrderExecution::Ioc | DydxOrderExecution::Default) | None => {
215                Ok(TimeInForce::Ioc)
216            }
217            Some(DydxOrderExecution::PostOnly) => {
218                anyhow::bail!("Execution PostOnly not supported for {order_type:?}")
219            }
220        },
221
222        DydxOrderType::TrailingStop => Ok(TimeInForce::Gtc),
223    }
224}
225
226/// Validate conditional order parameters.
227///
228/// Ensures that trigger prices are set correctly relative to limit prices
229/// based on order type and side.
230///
231/// # Errors
232///
233/// Returns an error if:
234/// - Conditional order is missing trigger price.
235/// - Trigger price is on wrong side of limit price for the order type.
236pub fn validate_conditional_order(
237    order_type: DydxOrderType,
238    trigger_price: Option<Decimal>,
239    price: Decimal,
240    side: OrderSide,
241) -> anyhow::Result<()> {
242    if !order_type.is_conditional() {
243        return Ok(());
244    }
245
246    let trigger_price = trigger_price
247        .ok_or_else(|| anyhow::anyhow!("trigger_price required for {order_type:?}"))?;
248
249    // Validate trigger price relative to limit price
250    match order_type {
251        DydxOrderType::StopLimit | DydxOrderType::StopMarket => {
252            // Stop orders: trigger when price falls (sell) or rises (buy)
253            match side {
254                OrderSide::Buy if trigger_price < price => {
255                    anyhow::bail!(
256                        "Stop buy trigger_price ({trigger_price}) must be >= limit price ({price})"
257                    );
258                }
259                OrderSide::Sell if trigger_price > price => {
260                    anyhow::bail!(
261                        "Stop sell trigger_price ({trigger_price}) must be <= limit price ({price})"
262                    );
263                }
264                _ => {}
265            }
266        }
267        DydxOrderType::TakeProfitLimit | DydxOrderType::TakeProfitMarket => {
268            // Take profit: trigger when price rises (sell) or falls (buy)
269            match side {
270                OrderSide::Buy if trigger_price > price => {
271                    anyhow::bail!(
272                        "Take profit buy trigger_price ({trigger_price}) must be <= limit price ({price})"
273                    );
274                }
275                OrderSide::Sell if trigger_price < price => {
276                    anyhow::bail!(
277                        "Take profit sell trigger_price ({trigger_price}) must be >= limit price ({price})"
278                    );
279                }
280                _ => {}
281            }
282        }
283        _ => {}
284    }
285
286    Ok(())
287}
288
289/// Parses a dYdX perpetual market into a Nautilus [`InstrumentAny`].
290///
291/// dYdX v4 only supports perpetual markets, so this function creates a
292/// [`CryptoPerpetual`] instrument with the appropriate fields mapped from
293/// the dYdX market definition.
294///
295/// # Errors
296///
297/// Returns an error if:
298/// - Ticker format is invalid (not BASE-QUOTE).
299/// - Required fields are missing or invalid.
300/// - Price or quantity values cannot be parsed.
301/// - Currency parsing fails.
302/// - Margin fractions are out of valid range.
303///
304/// Note: Callers should pre-filter inactive markets using [`is_market_active`].
305pub fn parse_instrument_any(
306    definition: &PerpetualMarket,
307    maker_fee: Option<Decimal>,
308    taker_fee: Option<Decimal>,
309    ts_init: UnixNanos,
310) -> anyhow::Result<InstrumentAny> {
311    // Parse instrument ID with Nautilus perpetual suffix and keep raw symbol as venue ticker
312    let instrument_id = parse_instrument_id(definition.ticker);
313    let raw_symbol = Symbol::from(definition.ticker.as_str());
314
315    // Parse currencies from ticker using helper function
316    let (base_str, quote_str) = parse_ticker_currencies(&definition.ticker)
317        .context(format!("Failed to parse ticker '{}'", definition.ticker))?;
318
319    let base_currency = Currency::get_or_create_crypto_with_context(base_str, None);
320    let quote_currency = Currency::get_or_create_crypto_with_context(quote_str, None);
321    let settlement_currency = quote_currency; // dYdX perpetuals settle in quote currency
322
323    // Parse price and size increments with context
324    let price_increment =
325        parse_price(&definition.tick_size.to_string(), "tick_size").context(format!(
326            "Failed to parse tick_size '{}' for market '{}'",
327            definition.tick_size, definition.ticker
328        ))?;
329
330    let size_increment =
331        parse_quantity(&definition.step_size.to_string(), "step_size").context(format!(
332            "Failed to parse step_size '{}' for market '{}'",
333            definition.step_size, definition.ticker
334        ))?;
335
336    // Parse min order size with context (use step_size as fallback if not provided)
337    let min_quantity = Some(if let Some(min_size) = &definition.min_order_size {
338        parse_quantity(&min_size.to_string(), "min_order_size").context(format!(
339            "Failed to parse min_order_size '{}' for market '{}'",
340            min_size, definition.ticker
341        ))?
342    } else {
343        // Use step_size as minimum quantity if min_order_size not provided
344        parse_quantity(&definition.step_size.to_string(), "step_size").context(format!(
345            "Failed to parse step_size as min_quantity for market '{}'",
346            definition.ticker
347        ))?
348    });
349
350    // Parse margin fractions with validation
351    let margin_init = Some(
352        parse_decimal(
353            &definition.initial_margin_fraction.to_string(),
354            "initial_margin_fraction",
355        )
356        .context(format!(
357            "Failed to parse initial_margin_fraction '{}' for market '{}'",
358            definition.initial_margin_fraction, definition.ticker
359        ))?,
360    );
361
362    let margin_maint = Some(
363        parse_decimal(
364            &definition.maintenance_margin_fraction.to_string(),
365            "maintenance_margin_fraction",
366        )
367        .context(format!(
368            "Failed to parse maintenance_margin_fraction '{}' for market '{}'",
369            definition.maintenance_margin_fraction, definition.ticker
370        ))?,
371    );
372
373    // Create the perpetual instrument
374    let instrument = CryptoPerpetual::new(
375        instrument_id,
376        raw_symbol,
377        base_currency,
378        quote_currency,
379        settlement_currency,
380        false, // dYdX perpetuals are not inverse
381        price_increment.precision,
382        size_increment.precision,
383        price_increment,
384        size_increment,
385        None,                 // multiplier: not applicable for dYdX
386        Some(size_increment), // lot_size: same as size_increment
387        None,                 // max_quantity: not specified by dYdX
388        min_quantity,
389        None, // max_notional: not specified by dYdX
390        None, // min_notional: not specified by dYdX
391        None, // max_price: not specified by dYdX
392        None, // min_price: not specified by dYdX
393        margin_init,
394        margin_maint,
395        maker_fee,
396        taker_fee,
397        ts_init,
398        ts_init,
399    );
400
401    Ok(InstrumentAny::CryptoPerpetual(instrument))
402}
403
404#[cfg(test)]
405mod tests {
406    use std::str::FromStr;
407
408    use chrono::Utc;
409    use nautilus_model::{
410        data::BarType,
411        enums::{AggressorSide, OrderSide},
412        identifiers::InstrumentId,
413        instruments::Instrument,
414    };
415    use rstest::rstest;
416    use rust_decimal::Decimal;
417    use rust_decimal_macros::dec;
418    use ustr::Ustr;
419
420    use super::*;
421    use crate::{
422        common::{
423            enums::{DydxOrderExecution, DydxOrderType, DydxTickerType, DydxTimeInForce},
424            testing::load_json_result_fixture,
425        },
426        http::models::{
427            CandlesResponse, FillsResponse, MarketsResponse, Order, OrderbookResponse,
428            SubaccountResponse, TradesResponse, TransfersResponse,
429        },
430    };
431
432    fn create_test_market() -> PerpetualMarket {
433        PerpetualMarket {
434            clob_pair_id: 1,
435            ticker: Ustr::from("BTC-USD"),
436            status: DydxMarketStatus::Active,
437            base_asset: Some(Ustr::from("BTC")),
438            quote_asset: Some(Ustr::from("USD")),
439            step_size: Decimal::from_str("0.001").unwrap(),
440            tick_size: Decimal::from_str("1").unwrap(),
441            index_price: Some(Decimal::from_str("50000").unwrap()),
442            oracle_price: Decimal::from_str("50000").unwrap(),
443            price_change_24h: Decimal::ZERO,
444            next_funding_rate: Decimal::ZERO,
445            next_funding_at: Some(Utc::now()),
446            min_order_size: Some(Decimal::from_str("0.001").unwrap()),
447            market_type: Some(DydxTickerType::Perpetual),
448            initial_margin_fraction: Decimal::from_str("0.05").unwrap(),
449            maintenance_margin_fraction: Decimal::from_str("0.03").unwrap(),
450            base_position_notional: Some(Decimal::from_str("10000").unwrap()),
451            incremental_position_size: Some(Decimal::from_str("10000").unwrap()),
452            incremental_initial_margin_fraction: Some(Decimal::from_str("0.01").unwrap()),
453            max_position_size: Some(Decimal::from_str("100").unwrap()),
454            open_interest: Decimal::from_str("1000000").unwrap(),
455            atomic_resolution: -10,
456            quantum_conversion_exponent: -10,
457            subticks_per_tick: 100,
458            step_base_quantums: 1000,
459            is_reduce_only: false,
460        }
461    }
462
463    #[rstest]
464    fn test_parse_instrument_any_valid() {
465        let market = create_test_market();
466        let maker_fee = Some(Decimal::from_str("0.0002").unwrap());
467        let taker_fee = Some(Decimal::from_str("0.0005").unwrap());
468        let ts_init = UnixNanos::default();
469
470        let result = parse_instrument_any(&market, maker_fee, taker_fee, ts_init);
471        assert!(result.is_ok());
472
473        let instrument = result.unwrap();
474        if let InstrumentAny::CryptoPerpetual(perp) = instrument {
475            assert_eq!(perp.id.symbol.as_str(), "BTC-USD-PERP");
476            assert_eq!(perp.base_currency.code.as_str(), "BTC");
477            assert_eq!(perp.quote_currency.code.as_str(), "USD");
478            assert!(!perp.is_inverse);
479            assert_eq!(perp.price_increment.to_string(), "1");
480            assert_eq!(perp.size_increment.to_string(), "0.001");
481        } else {
482            panic!("Expected CryptoPerpetual instrument");
483        }
484    }
485
486    #[rstest]
487    fn test_is_market_active() {
488        assert!(is_market_active(&DydxMarketStatus::Active));
489        assert!(!is_market_active(&DydxMarketStatus::Paused));
490        assert!(!is_market_active(&DydxMarketStatus::CancelOnly));
491        assert!(!is_market_active(&DydxMarketStatus::PostOnly));
492        assert!(!is_market_active(&DydxMarketStatus::Initializing));
493        assert!(!is_market_active(&DydxMarketStatus::FinalSettlement));
494    }
495
496    #[rstest]
497    fn test_parse_instrument_any_invalid_ticker() {
498        let mut market = create_test_market();
499        market.ticker = Ustr::from("INVALID");
500
501        let result = parse_instrument_any(&market, None, None, UnixNanos::default());
502        assert!(result.is_err());
503        let error_msg = result.unwrap_err().to_string();
504        // The error message includes context, so check for key parts
505        assert!(
506            error_msg.contains("Invalid ticker format")
507                || error_msg.contains("Failed to parse ticker"),
508            "Expected ticker format error, was: {error_msg}"
509        );
510    }
511
512    #[rstest]
513    fn test_validate_ticker_format_valid() {
514        assert!(validate_ticker_format("BTC-USD").is_ok());
515        assert!(validate_ticker_format("ETH-USD").is_ok());
516        assert!(validate_ticker_format("ATOM-USD").is_ok());
517    }
518
519    #[rstest]
520    fn test_validate_ticker_format_invalid() {
521        // Missing hyphen
522        assert!(validate_ticker_format("BTCUSD").is_err());
523
524        // Too many parts
525        assert!(validate_ticker_format("BTC-USD-PERP").is_err());
526
527        // Empty base
528        assert!(validate_ticker_format("-USD").is_err());
529
530        // Empty quote
531        assert!(validate_ticker_format("BTC-").is_err());
532
533        // Just hyphen
534        assert!(validate_ticker_format("-").is_err());
535    }
536
537    #[rstest]
538    fn test_parse_ticker_currencies_valid() {
539        let (base, quote) = parse_ticker_currencies("BTC-USD").unwrap();
540        assert_eq!(base, "BTC");
541        assert_eq!(quote, "USD");
542
543        let (base, quote) = parse_ticker_currencies("ETH-USDC").unwrap();
544        assert_eq!(base, "ETH");
545        assert_eq!(quote, "USDC");
546    }
547
548    #[rstest]
549    fn test_parse_ticker_currencies_invalid() {
550        assert!(parse_ticker_currencies("INVALID").is_err());
551        assert!(parse_ticker_currencies("BTC-USD-PERP").is_err());
552    }
553
554    #[rstest]
555    fn test_validate_stop_limit_buy_valid() {
556        let result = validate_conditional_order(
557            DydxOrderType::StopLimit,
558            Some(dec!(51000)), // trigger
559            dec!(50000),       // limit price
560            OrderSide::Buy,
561        );
562        assert!(result.is_ok());
563    }
564
565    #[rstest]
566    fn test_validate_stop_limit_buy_invalid() {
567        // Invalid: trigger below limit
568        let result = validate_conditional_order(
569            DydxOrderType::StopLimit,
570            Some(dec!(49000)),
571            dec!(50000),
572            OrderSide::Buy,
573        );
574        assert!(result.is_err());
575        assert!(
576            result
577                .unwrap_err()
578                .to_string()
579                .contains("must be >= limit price")
580        );
581    }
582
583    #[rstest]
584    fn test_validate_stop_limit_sell_valid() {
585        let result = validate_conditional_order(
586            DydxOrderType::StopLimit,
587            Some(dec!(49000)), // trigger
588            dec!(50000),       // limit price
589            OrderSide::Sell,
590        );
591        assert!(result.is_ok());
592    }
593
594    #[rstest]
595    fn test_validate_stop_limit_sell_invalid() {
596        // Invalid: trigger above limit
597        let result = validate_conditional_order(
598            DydxOrderType::StopLimit,
599            Some(dec!(51000)),
600            dec!(50000),
601            OrderSide::Sell,
602        );
603        assert!(result.is_err());
604        assert!(
605            result
606                .unwrap_err()
607                .to_string()
608                .contains("must be <= limit price")
609        );
610    }
611
612    #[rstest]
613    fn test_validate_take_profit_sell_valid() {
614        let result = validate_conditional_order(
615            DydxOrderType::TakeProfitLimit,
616            Some(dec!(51000)), // trigger
617            dec!(50000),       // limit price
618            OrderSide::Sell,
619        );
620        assert!(result.is_ok());
621    }
622
623    #[rstest]
624    fn test_validate_take_profit_buy_valid() {
625        let result = validate_conditional_order(
626            DydxOrderType::TakeProfitLimit,
627            Some(dec!(49000)), // trigger
628            dec!(50000),       // limit price
629            OrderSide::Buy,
630        );
631        assert!(result.is_ok());
632    }
633
634    #[rstest]
635    fn test_validate_missing_trigger_price() {
636        let result =
637            validate_conditional_order(DydxOrderType::StopLimit, None, dec!(50000), OrderSide::Buy);
638        assert!(result.is_err());
639        assert!(
640            result
641                .unwrap_err()
642                .to_string()
643                .contains("trigger_price required")
644        );
645    }
646
647    #[rstest]
648    fn test_validate_non_conditional_order() {
649        // Should pass for non-conditional orders
650        let result =
651            validate_conditional_order(DydxOrderType::Limit, None, dec!(50000), OrderSide::Buy);
652        assert!(result.is_ok());
653    }
654
655    #[rstest]
656    fn test_calculate_tif_market() {
657        let tif = calculate_time_in_force(DydxOrderType::Market, DydxTimeInForce::Gtt, false, None)
658            .unwrap();
659        assert_eq!(tif, TimeInForce::Ioc);
660    }
661
662    #[rstest]
663    fn test_calculate_tif_limit_post_only() {
664        let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, true, None)
665            .unwrap();
666        assert_eq!(tif, TimeInForce::Gtc); // Post-only uses GTC with post_only flag
667    }
668
669    #[rstest]
670    fn test_calculate_tif_limit_gtc() {
671        let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, false, None)
672            .unwrap();
673        assert_eq!(tif, TimeInForce::Gtc);
674    }
675
676    #[rstest]
677    fn test_calculate_tif_stop_market_ioc() {
678        let tif = calculate_time_in_force(
679            DydxOrderType::StopMarket,
680            DydxTimeInForce::Gtt,
681            false,
682            Some(DydxOrderExecution::Ioc),
683        )
684        .unwrap();
685        assert_eq!(tif, TimeInForce::Ioc);
686    }
687
688    #[rstest]
689    fn test_calculate_tif_stop_limit_post_only() {
690        let tif = calculate_time_in_force(
691            DydxOrderType::StopLimit,
692            DydxTimeInForce::Gtt,
693            false,
694            Some(DydxOrderExecution::PostOnly),
695        )
696        .unwrap();
697        assert_eq!(tif, TimeInForce::Gtc); // Post-only uses GTC with post_only flag
698    }
699
700    #[rstest]
701    fn test_calculate_tif_stop_limit_gtc() {
702        let tif =
703            calculate_time_in_force(DydxOrderType::StopLimit, DydxTimeInForce::Gtt, false, None)
704                .unwrap();
705        assert_eq!(tif, TimeInForce::Gtc);
706    }
707
708    #[rstest]
709    fn test_calculate_tif_stop_market_invalid_post_only() {
710        let result = calculate_time_in_force(
711            DydxOrderType::StopMarket,
712            DydxTimeInForce::Gtt,
713            false,
714            Some(DydxOrderExecution::PostOnly),
715        );
716        assert!(result.is_err());
717        assert!(
718            result
719                .unwrap_err()
720                .to_string()
721                .contains("PostOnly not supported")
722        );
723    }
724
725    #[rstest]
726    fn test_calculate_tif_trailing_stop() {
727        let tif = calculate_time_in_force(
728            DydxOrderType::TrailingStop,
729            DydxTimeInForce::Gtt,
730            false,
731            None,
732        )
733        .unwrap();
734        assert_eq!(tif, TimeInForce::Gtc);
735    }
736
737    #[rstest]
738    fn test_parse_perpetual_markets() {
739        let json = load_json_result_fixture("http_get_perpetual_markets.json");
740        let response: MarketsResponse =
741            serde_json::from_value(json).expect("Failed to parse markets");
742
743        assert_eq!(response.markets.len(), 3);
744        assert!(response.markets.contains_key("BTC-USD"));
745        assert!(response.markets.contains_key("ETH-USD"));
746        assert!(response.markets.contains_key("SOL-USD"));
747
748        let btc = response.markets.get("BTC-USD").unwrap();
749        assert_eq!(btc.ticker, "BTC-USD");
750        assert_eq!(btc.clob_pair_id, 0);
751        assert_eq!(btc.atomic_resolution, -10);
752    }
753
754    #[rstest]
755    fn test_parse_instrument_from_market() {
756        let json = load_json_result_fixture("http_get_perpetual_markets.json");
757        let response: MarketsResponse =
758            serde_json::from_value(json).expect("Failed to parse markets");
759        let btc = response.markets.get("BTC-USD").unwrap();
760
761        let ts_init = UnixNanos::default();
762        let instrument =
763            parse_instrument_any(btc, None, None, ts_init).expect("Failed to parse instrument");
764
765        assert_eq!(instrument.id().symbol.as_str(), "BTC-USD-PERP");
766        assert_eq!(instrument.id().venue.as_str(), "DYDX");
767    }
768
769    #[rstest]
770    fn test_parse_orderbook_response() {
771        let json = load_json_result_fixture("http_get_orderbook.json");
772        let response: OrderbookResponse =
773            serde_json::from_value(json).expect("Failed to parse orderbook");
774
775        assert_eq!(response.bids.len(), 5);
776        assert_eq!(response.asks.len(), 5);
777
778        let best_bid = &response.bids[0];
779        assert_eq!(best_bid.price.to_string(), "89947");
780        assert_eq!(best_bid.size.to_string(), "0.0002");
781
782        let best_ask = &response.asks[0];
783        assert_eq!(best_ask.price.to_string(), "89958");
784        assert_eq!(best_ask.size.to_string(), "0.1177");
785    }
786
787    #[rstest]
788    fn test_parse_trades_response() {
789        let json = load_json_result_fixture("http_get_trades.json");
790        let response: TradesResponse =
791            serde_json::from_value(json).expect("Failed to parse trades");
792
793        assert_eq!(response.trades.len(), 3);
794
795        let first_trade = &response.trades[0];
796        assert_eq!(first_trade.id, "03f89a550000000200000002");
797        assert_eq!(first_trade.side, OrderSide::Buy);
798        assert_eq!(first_trade.price.to_string(), "89942");
799        assert_eq!(first_trade.size.to_string(), "0.0001");
800    }
801
802    #[rstest]
803    fn test_parse_candles_response() {
804        let json = load_json_result_fixture("http_get_candles.json");
805        let response: CandlesResponse =
806            serde_json::from_value(json).expect("Failed to parse candles");
807
808        assert_eq!(response.candles.len(), 3);
809
810        let first_candle = &response.candles[0];
811        assert_eq!(first_candle.ticker, "BTC-USD");
812        assert_eq!(first_candle.open.to_string(), "89934");
813        assert_eq!(first_candle.high.to_string(), "89970");
814        assert_eq!(first_candle.low.to_string(), "89911");
815        assert_eq!(first_candle.close.to_string(), "89941");
816    }
817
818    #[rstest]
819    fn test_parse_subaccount_response() {
820        let json = load_json_result_fixture("http_get_subaccount.json");
821        let response: SubaccountResponse =
822            serde_json::from_value(json).expect("Failed to parse subaccount");
823
824        let subaccount = &response.subaccount;
825        assert_eq!(subaccount.subaccount_number, 0);
826        assert_eq!(subaccount.equity.to_string(), "45.201296");
827        assert_eq!(subaccount.free_collateral.to_string(), "45.201296");
828        assert!(subaccount.margin_enabled);
829        assert_eq!(subaccount.open_perpetual_positions.len(), 0);
830    }
831
832    #[rstest]
833    fn test_parse_orders_response() {
834        let json = load_json_result_fixture("http_get_orders.json");
835        let response: Vec<Order> = serde_json::from_value(json).expect("Failed to parse orders");
836
837        assert_eq!(response.len(), 3);
838
839        let first_order = &response[0];
840        assert_eq!(first_order.id, "0f0981cb-152e-57d3-bea9-4d8e0dd5ed35");
841        assert_eq!(first_order.side, OrderSide::Buy);
842        assert_eq!(first_order.order_type, DydxOrderType::Limit);
843        assert!(first_order.reduce_only);
844
845        let second_order = &response[1];
846        assert_eq!(second_order.side, OrderSide::Sell);
847        assert!(!second_order.reduce_only);
848    }
849
850    #[rstest]
851    fn test_parse_fills_response() {
852        let json = load_json_result_fixture("http_get_fills.json");
853        let response: FillsResponse = serde_json::from_value(json).expect("Failed to parse fills");
854
855        assert_eq!(response.fills.len(), 3);
856
857        let first_fill = &response.fills[0];
858        assert_eq!(first_fill.id, "6450e369-1dc3-5229-8dc2-fb3b5d1cf2ab");
859        assert_eq!(first_fill.side, OrderSide::Buy);
860        assert_eq!(first_fill.market, "BTC-USD");
861        assert_eq!(first_fill.price.to_string(), "105117");
862    }
863
864    #[rstest]
865    fn test_parse_transfers_response() {
866        let json = load_json_result_fixture("http_get_transfers.json");
867        let response: TransfersResponse =
868            serde_json::from_value(json).expect("Failed to parse transfers");
869
870        assert_eq!(response.transfers.len(), 1);
871
872        let deposit = &response.transfers[0];
873        assert_eq!(deposit.transfer_type, DydxTransferType::Deposit);
874        assert_eq!(deposit.asset, "USDC");
875        assert_eq!(deposit.amount.to_string(), "45.334703");
876    }
877
878    #[rstest]
879    fn test_transfer_type_enum_serde() {
880        // Test all transfer type variants serialize/deserialize correctly
881        let test_cases = vec![
882            (DydxTransferType::Deposit, "\"DEPOSIT\""),
883            (DydxTransferType::Withdrawal, "\"WITHDRAWAL\""),
884            (DydxTransferType::TransferIn, "\"TRANSFER_IN\""),
885            (DydxTransferType::TransferOut, "\"TRANSFER_OUT\""),
886        ];
887
888        for (variant, expected_json) in test_cases {
889            // Test serialization
890            let serialized = serde_json::to_string(&variant).expect("Failed to serialize");
891            assert_eq!(
892                serialized, expected_json,
893                "Serialization failed for {variant:?}"
894            );
895
896            // Test deserialization
897            let deserialized: DydxTransferType =
898                serde_json::from_str(&serialized).expect("Failed to deserialize");
899            assert_eq!(
900                deserialized, variant,
901                "Deserialization failed for {variant:?}"
902            );
903        }
904    }
905
906    #[rstest]
907    fn test_parse_trade_tick() {
908        let json = load_json_result_fixture("http_get_trades.json");
909        let response: TradesResponse =
910            serde_json::from_value(json).expect("Failed to parse trades");
911
912        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
913        let ts_init = UnixNanos::from(1_000_000_000u64);
914
915        let tick = parse_trade_tick(&response.trades[0], instrument_id, 0, 4, ts_init)
916            .expect("Failed to parse trade tick");
917
918        assert_eq!(tick.instrument_id, instrument_id);
919        assert_eq!(tick.price.to_string(), "89942");
920        assert_eq!(tick.size.to_string(), "0.0001");
921        assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
922        assert_eq!(tick.trade_id.to_string(), "03f89a550000000200000002");
923        assert_eq!(tick.ts_init, ts_init);
924    }
925
926    #[rstest]
927    #[case(true)]
928    #[case(false)]
929    fn test_parse_bar_timestamp_on_close(#[case] timestamp_on_close: bool) {
930        let json = load_json_result_fixture("http_get_candles.json");
931        let response: CandlesResponse =
932            serde_json::from_value(json).expect("Failed to parse candles");
933
934        let bar_type = BarType::from_str("BTC-USD-PERP.DYDX-1-MINUTE-LAST-EXTERNAL")
935            .expect("Failed to parse bar type");
936        let ts_init = UnixNanos::from(1_000_000_000u64);
937
938        let bar = parse_bar(
939            &response.candles[0],
940            bar_type,
941            0,
942            4,
943            timestamp_on_close,
944            ts_init,
945        )
946        .expect("Failed to parse bar");
947
948        assert_eq!(bar.bar_type, bar_type);
949        assert_eq!(bar.open.to_string(), "89934");
950        assert_eq!(bar.high.to_string(), "89970");
951        assert_eq!(bar.low.to_string(), "89911");
952        assert_eq!(bar.close.to_string(), "89941");
953        assert_eq!(bar.volume.to_string(), "3.2767");
954
955        // 2025-12-08T16:11:00.000Z
956        let started_at_ns = 1_765_210_260_000_000_000u64;
957        let one_min_ns = 60_000_000_000u64;
958        if timestamp_on_close {
959            assert_eq!(bar.ts_event.as_u64(), started_at_ns + one_min_ns);
960        } else {
961            assert_eq!(bar.ts_event.as_u64(), started_at_ns);
962        }
963    }
964}
965
966use std::str::FromStr;
967
968use nautilus_core::UUID4;
969use nautilus_model::{
970    enums::{LiquiditySide, OrderStatus, PositionSide, TriggerType},
971    identifiers::{AccountId, ClientOrderId, PositionId, VenueOrderId},
972    instruments::Instrument,
973    reports::{FillReport, OrderStatusReport, PositionStatusReport},
974    types::Money,
975};
976
977use super::models::{Fill, Order, PerpetualPosition};
978use crate::common::enums::{DydxConditionType, DydxLiquidity, DydxOrderStatus};
979#[cfg(test)]
980use crate::common::enums::{DydxFillType, DydxPositionStatus, DydxTickerType};
981
982/// Map dYdX order status to Nautilus OrderStatus.
983fn parse_order_status(status: &DydxOrderStatus) -> OrderStatus {
984    match status {
985        DydxOrderStatus::Open => OrderStatus::Accepted,
986        DydxOrderStatus::Filled => OrderStatus::Filled,
987        DydxOrderStatus::Canceled => OrderStatus::Canceled,
988        DydxOrderStatus::BestEffortCanceled => OrderStatus::Canceled,
989        DydxOrderStatus::Untriggered => OrderStatus::Accepted, // Conditional orders waiting for trigger
990        DydxOrderStatus::BestEffortOpened => OrderStatus::Accepted,
991        DydxOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
992    }
993}
994
995/// Parse a dYdX Order into a Nautilus OrderStatusReport.
996///
997/// # Errors
998///
999/// Returns an error if required fields are missing or invalid.
1000pub fn parse_order_status_report(
1001    order: &Order,
1002    instrument: &InstrumentAny,
1003    account_id: AccountId,
1004    ts_init: UnixNanos,
1005) -> anyhow::Result<OrderStatusReport> {
1006    let instrument_id = instrument.id();
1007    let venue_order_id = VenueOrderId::new(&order.id);
1008    let client_order_id = if order.client_id.is_empty() {
1009        None
1010    } else {
1011        Some(ClientOrderId::new(&order.client_id))
1012    };
1013
1014    let order_type = order.order_type.into();
1015
1016    let execution = order.execution.or({
1017        // Infer execution type from post_only flag if not explicitly set
1018        if order.post_only {
1019            Some(DydxOrderExecution::PostOnly)
1020        } else {
1021            Some(DydxOrderExecution::Default)
1022        }
1023    });
1024    let time_in_force = calculate_time_in_force(
1025        order.order_type,
1026        order.time_in_force,
1027        order.reduce_only,
1028        execution,
1029    )?;
1030
1031    let order_side = order.side;
1032    let order_status = parse_order_status(&order.status);
1033
1034    let size_precision = instrument.size_precision();
1035    let quantity = Quantity::from_decimal_dp(order.size, size_precision)
1036        .context("failed to parse order size")?;
1037    let filled_qty = Quantity::from_decimal_dp(order.total_filled, size_precision)
1038        .context("failed to parse total_filled")?;
1039
1040    let price_precision = instrument.price_precision();
1041    let price = Price::from_decimal_dp(order.price, price_precision)
1042        .context("failed to parse order price")?;
1043
1044    // Use updated_at for both ts_accepted and ts_last (not good_til_block_time which is the expiry)
1045    let ts_accepted = order.updated_at.map_or(ts_init, |dt| {
1046        UnixNanos::from(dt.timestamp_millis() as u64 * 1_000_000)
1047    });
1048    let ts_last = ts_accepted;
1049
1050    let mut report = OrderStatusReport::new(
1051        account_id,
1052        instrument_id,
1053        client_order_id,
1054        venue_order_id,
1055        order_side,
1056        order_type,
1057        time_in_force,
1058        order_status,
1059        quantity,
1060        filled_qty,
1061        ts_accepted,
1062        ts_last,
1063        ts_init,
1064        Some(UUID4::new()),
1065    );
1066
1067    report = report.with_price(price);
1068
1069    if let Some(trigger_price_dec) = order.trigger_price {
1070        let trigger_price = Price::from_decimal_dp(trigger_price_dec, instrument.price_precision())
1071            .context("failed to parse trigger_price")?;
1072        report = report.with_trigger_price(trigger_price);
1073
1074        if let Some(condition_type) = order.condition_type {
1075            let trigger_type = match condition_type {
1076                DydxConditionType::StopLoss => TriggerType::LastPrice,
1077                DydxConditionType::TakeProfit => TriggerType::LastPrice,
1078                DydxConditionType::Unspecified => TriggerType::Default,
1079            };
1080            report = report.with_trigger_type(trigger_type);
1081        }
1082    }
1083
1084    Ok(report)
1085}
1086
1087/// Parse a dYdX Fill into a Nautilus FillReport.
1088///
1089/// # Errors
1090///
1091/// Returns an error if required fields are missing or invalid.
1092pub fn parse_fill_report(
1093    fill: &Fill,
1094    instrument: &InstrumentAny,
1095    account_id: AccountId,
1096    ts_init: UnixNanos,
1097) -> anyhow::Result<FillReport> {
1098    let instrument_id = instrument.id();
1099    let venue_order_id = VenueOrderId::new(&fill.order_id);
1100    let trade_id = TradeId::new(&fill.id);
1101    let order_side = fill.side;
1102
1103    let size_precision = instrument.size_precision();
1104    let price_precision = instrument.price_precision();
1105
1106    let last_qty = Quantity::from_decimal_dp(fill.size, size_precision)
1107        .context("failed to parse fill size")?;
1108    let last_px = Price::from_decimal_dp(fill.price, price_precision)
1109        .context("failed to parse fill price")?;
1110
1111    // dYdX sign convention matches Nautilus (positive = cost)
1112    let commission = Money::from_decimal(fill.fee, instrument.quote_currency())
1113        .context("failed to parse fee")?;
1114
1115    let liquidity_side = match fill.liquidity {
1116        DydxLiquidity::Maker => LiquiditySide::Maker,
1117        DydxLiquidity::Taker => LiquiditySide::Taker,
1118    };
1119
1120    let ts_event = UnixNanos::from(fill.created_at.timestamp_millis() as u64 * 1_000_000);
1121
1122    let report = FillReport::new(
1123        account_id,
1124        instrument_id,
1125        venue_order_id,
1126        trade_id,
1127        order_side,
1128        last_qty,
1129        last_px,
1130        commission,
1131        liquidity_side,
1132        None, // client_order_id - will be linked by execution engine
1133        None, // venue_position_id
1134        ts_event,
1135        ts_init,
1136        Some(UUID4::new()),
1137    );
1138
1139    Ok(report)
1140}
1141
1142/// Parse a dYdX PerpetualPosition into a Nautilus PositionStatusReport.
1143///
1144/// # Errors
1145///
1146/// Returns an error if required fields are missing or invalid.
1147pub fn parse_position_status_report(
1148    position: &PerpetualPosition,
1149    instrument: &InstrumentAny,
1150    account_id: AccountId,
1151    ts_init: UnixNanos,
1152) -> anyhow::Result<PositionStatusReport> {
1153    let instrument_id = instrument.id();
1154
1155    // Determine position side based on size (negative for short)
1156    let position_side = if position.size.is_zero() {
1157        PositionSide::Flat
1158    } else if position.size.is_sign_positive() {
1159        PositionSide::Long
1160    } else {
1161        PositionSide::Short
1162    };
1163
1164    // Create quantity (always positive)
1165    let quantity = Quantity::from_decimal_dp(position.size.abs(), instrument.size_precision())
1166        .context("failed to parse position size")?;
1167
1168    let avg_px_open = position.entry_price;
1169    let ts_last = UnixNanos::from(position.created_at.timestamp_millis() as u64 * 1_000_000);
1170
1171    let venue_position_id = Some(PositionId::new(format!(
1172        "{}_{}",
1173        account_id, position.market
1174    )));
1175
1176    Ok(PositionStatusReport::new(
1177        account_id,
1178        instrument_id,
1179        position_side.as_specified(),
1180        quantity,
1181        ts_last,
1182        ts_init,
1183        Some(UUID4::new()),
1184        venue_position_id,
1185        Some(avg_px_open),
1186    ))
1187}
1188
1189/// Parse a dYdX subaccount info into a Nautilus AccountState.
1190///
1191/// dYdX provides account-level balances with:
1192/// - `equity`: Total account value (total balance)
1193/// - `freeCollateral`: Available for new orders (free balance)
1194/// - `locked`: equity - freeCollateral (calculated)
1195///
1196/// Margin calculations per position:
1197/// - `initial_margin = margin_init * abs(position_size) * oracle_price`
1198/// - `maintenance_margin = margin_maint * abs(position_size) * oracle_price`
1199///
1200/// # Errors
1201///
1202/// Returns an error if balance fields cannot be parsed.
1203pub fn parse_account_state(
1204    subaccount: &DydxSubaccountInfo,
1205    account_id: AccountId,
1206    instruments: &std::collections::HashMap<InstrumentId, InstrumentAny>,
1207    oracle_prices: &std::collections::HashMap<InstrumentId, Decimal>,
1208    ts_event: UnixNanos,
1209    ts_init: UnixNanos,
1210) -> anyhow::Result<AccountState> {
1211    use std::collections::HashMap;
1212
1213    use nautilus_model::{
1214        enums::AccountType,
1215        events::AccountState,
1216        types::{AccountBalance, MarginBalance},
1217    };
1218
1219    let mut balances = Vec::new();
1220
1221    // Parse equity (total) and freeCollateral (free)
1222    let equity: Decimal = subaccount
1223        .equity
1224        .parse()
1225        .context(format!("Failed to parse equity '{}'", subaccount.equity))?;
1226
1227    let free_collateral: Decimal = subaccount.free_collateral.parse().context(format!(
1228        "Failed to parse freeCollateral '{}'",
1229        subaccount.free_collateral
1230    ))?;
1231
1232    // dYdX uses USDC as the settlement currency
1233    let currency = Currency::get_or_create_crypto_with_context("USDC", None);
1234
1235    let total = Money::from_decimal(equity, currency).context("failed to parse equity")?;
1236    let free = Money::from_decimal(free_collateral, currency)
1237        .context("failed to parse free collateral")?;
1238    let locked = total - free;
1239
1240    let balance = AccountBalance::new_checked(total, locked, free)
1241        .context("Failed to create AccountBalance from subaccount data")?;
1242    balances.push(balance);
1243
1244    // Calculate margin balances from open positions
1245    let mut margins = Vec::new();
1246    let mut initial_margins: HashMap<Currency, Decimal> = HashMap::new();
1247    let mut maintenance_margins: HashMap<Currency, Decimal> = HashMap::new();
1248
1249    if let Some(ref positions) = subaccount.open_perpetual_positions {
1250        for position in positions.values() {
1251            // Parse instrument ID from market symbol (e.g., "BTC-USD" -> "BTC-USD-PERP")
1252            let market_str = position.market.as_str();
1253            let instrument_id = parse_instrument_id(market_str);
1254
1255            // Get instrument to access margin parameters
1256            let instrument = match instruments.get(&instrument_id) {
1257                Some(inst) => inst,
1258                None => {
1259                    log::warn!(
1260                        "Cannot calculate margin for position {market_str}: instrument not found"
1261                    );
1262                    continue;
1263                }
1264            };
1265
1266            // Get margin parameters from instrument
1267            let (margin_init, margin_maint) = match instrument {
1268                InstrumentAny::CryptoPerpetual(perp) => (perp.margin_init, perp.margin_maint),
1269                _ => {
1270                    log::warn!(
1271                        "Instrument {instrument_id} is not a CryptoPerpetual, skipping margin calculation"
1272                    );
1273                    continue;
1274                }
1275            };
1276
1277            // Parse position size
1278            let position_size = match Decimal::from_str(&position.size) {
1279                Ok(size) => size.abs(),
1280                Err(e) => {
1281                    log::warn!(
1282                        "Failed to parse position size '{}' for {}: {}",
1283                        position.size,
1284                        market_str,
1285                        e
1286                    );
1287                    continue;
1288                }
1289            };
1290
1291            // Skip closed positions
1292            if position_size.is_zero() {
1293                continue;
1294            }
1295
1296            // Get oracle price, fallback to entry price
1297            let oracle_price = oracle_prices
1298                .get(&instrument_id)
1299                .copied()
1300                .or_else(|| Decimal::from_str(&position.entry_price).ok())
1301                .unwrap_or(Decimal::ZERO);
1302
1303            if oracle_price.is_zero() {
1304                log::warn!("No valid price for position {market_str}, skipping margin calculation");
1305                continue;
1306            }
1307
1308            // Calculate margins: margin_fraction * abs(size) * oracle_price
1309            let initial_margin = margin_init * position_size * oracle_price;
1310
1311            let maintenance_margin = margin_maint * position_size * oracle_price;
1312
1313            // Aggregate margins by currency
1314            let quote_currency = instrument.quote_currency();
1315            *initial_margins
1316                .entry(quote_currency)
1317                .or_insert(Decimal::ZERO) += initial_margin;
1318            *maintenance_margins
1319                .entry(quote_currency)
1320                .or_insert(Decimal::ZERO) += maintenance_margin;
1321        }
1322    }
1323
1324    // Create MarginBalance objects from aggregated margins
1325    for (currency, initial_margin) in initial_margins {
1326        let maintenance_margin = maintenance_margins
1327            .get(&currency)
1328            .copied()
1329            .unwrap_or(Decimal::ZERO);
1330
1331        let initial_money = Money::from_decimal(initial_margin, currency).context(format!(
1332            "Failed to create initial margin Money for {currency}"
1333        ))?;
1334        let maintenance_money = Money::from_decimal(maintenance_margin, currency).context(
1335            format!("Failed to create maintenance margin Money for {currency}"),
1336        )?;
1337
1338        // Create synthetic instrument ID for account-level margin
1339        // Format: ACCOUNT.DYDX (similar to OKX pattern)
1340        let margin_instrument_id = InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("DYDX"));
1341
1342        let margin_balance =
1343            MarginBalance::new(initial_money, maintenance_money, margin_instrument_id);
1344        margins.push(margin_balance);
1345    }
1346
1347    Ok(AccountState::new(
1348        account_id,
1349        AccountType::Margin, // dYdX uses cross-margin
1350        balances,
1351        margins,
1352        true, // is_reported - comes from venue
1353        UUID4::new(),
1354        ts_event,
1355        ts_init,
1356        None, // base_currency - dYdX settles in USDC
1357    ))
1358}
1359
1360#[cfg(test)]
1361mod reconciliation_tests {
1362    use chrono::Utc;
1363    use nautilus_model::{
1364        enums::{OrderSide, OrderStatus, TimeInForce},
1365        identifiers::{AccountId, InstrumentId, Symbol, Venue},
1366        instruments::{CryptoPerpetual, Instrument},
1367        types::Currency,
1368    };
1369    use rstest::rstest;
1370    use rust_decimal::prelude::ToPrimitive;
1371    use rust_decimal_macros::dec;
1372    use ustr::Ustr;
1373
1374    use super::*;
1375
1376    fn create_test_instrument() -> InstrumentAny {
1377        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), Venue::new("DYDX"));
1378
1379        InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1380            instrument_id,
1381            instrument_id.symbol,
1382            Currency::BTC(),
1383            Currency::USD(),
1384            Currency::USD(),
1385            false,
1386            2,                                // price_precision
1387            8,                                // size_precision
1388            Price::new(0.01, 2),              // price_increment
1389            Quantity::new(0.001, 8),          // size_increment
1390            Some(Quantity::new(1.0, 0)),      // multiplier
1391            Some(Quantity::new(0.001, 8)),    // lot_size
1392            Some(Quantity::new(100000.0, 8)), // max_quantity
1393            Some(Quantity::new(0.001, 8)),    // min_quantity
1394            None,                             // max_notional
1395            None,                             // min_notional
1396            Some(Price::new(1000000.0, 2)),   // max_price
1397            Some(Price::new(0.01, 2)),        // min_price
1398            Some(dec!(0.05)),                 // margin_init
1399            Some(dec!(0.03)),                 // margin_maint
1400            Some(dec!(0.0002)),               // maker_fee
1401            Some(dec!(0.0005)),               // taker_fee
1402            UnixNanos::default(),             // ts_event
1403            UnixNanos::default(),             // ts_init
1404        ))
1405    }
1406
1407    #[rstest]
1408    fn test_parse_order_status() {
1409        assert_eq!(
1410            parse_order_status(&DydxOrderStatus::Open),
1411            OrderStatus::Accepted
1412        );
1413        assert_eq!(
1414            parse_order_status(&DydxOrderStatus::Filled),
1415            OrderStatus::Filled
1416        );
1417        assert_eq!(
1418            parse_order_status(&DydxOrderStatus::Canceled),
1419            OrderStatus::Canceled
1420        );
1421        assert_eq!(
1422            parse_order_status(&DydxOrderStatus::PartiallyFilled),
1423            OrderStatus::PartiallyFilled
1424        );
1425        assert_eq!(
1426            parse_order_status(&DydxOrderStatus::Untriggered),
1427            OrderStatus::Accepted
1428        );
1429    }
1430
1431    #[rstest]
1432    fn test_parse_order_status_report_basic() {
1433        let instrument = create_test_instrument();
1434        let account_id = AccountId::new("DYDX-001");
1435        let ts_init = UnixNanos::default();
1436
1437        let order = Order {
1438            id: "order123".to_string(),
1439            subaccount_id: "subacct1".to_string(),
1440            client_id: "client1".to_string(),
1441            clob_pair_id: 1,
1442            side: OrderSide::Buy,
1443            size: dec!(1.5),
1444            total_filled: dec!(1.0),
1445            price: dec!(50000.0),
1446            status: DydxOrderStatus::PartiallyFilled,
1447            order_type: DydxOrderType::Limit,
1448            time_in_force: DydxTimeInForce::Gtt,
1449            reduce_only: false,
1450            post_only: false,
1451            order_flags: 0,
1452            good_til_block: None,
1453            good_til_block_time: Some(Utc::now()),
1454            created_at_height: Some(1000),
1455            client_metadata: 0,
1456            trigger_price: None,
1457            condition_type: None,
1458            conditional_order_trigger_subticks: None,
1459            execution: None,
1460            updated_at: Some(Utc::now()),
1461            updated_at_height: Some(1001),
1462            ticker: None,
1463            subaccount_number: 0,
1464            order_router_address: None,
1465        };
1466
1467        let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1468        if let Err(ref e) = result {
1469            eprintln!("Parse error: {e:?}");
1470        }
1471        assert!(result.is_ok());
1472
1473        let report = result.unwrap();
1474        assert_eq!(report.account_id, account_id);
1475        assert_eq!(report.instrument_id, instrument.id());
1476        assert_eq!(report.order_side, OrderSide::Buy);
1477        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1478        assert_eq!(report.time_in_force, TimeInForce::Gtc);
1479    }
1480
1481    #[rstest]
1482    fn test_parse_order_status_report_conditional() {
1483        let instrument = create_test_instrument();
1484        let account_id = AccountId::new("DYDX-001");
1485        let ts_init = UnixNanos::default();
1486
1487        let order = Order {
1488            id: "order456".to_string(),
1489            subaccount_id: "subacct1".to_string(),
1490            client_id: String::new(), // Empty client ID
1491            clob_pair_id: 1,
1492            side: OrderSide::Sell,
1493            size: dec!(2.0),
1494            total_filled: dec!(0.0),
1495            price: dec!(51000.0),
1496            status: DydxOrderStatus::Untriggered,
1497            order_type: DydxOrderType::StopLimit,
1498            time_in_force: DydxTimeInForce::Gtt,
1499            reduce_only: true,
1500            post_only: false,
1501            order_flags: 0,
1502            good_til_block: None,
1503            good_til_block_time: Some(Utc::now()),
1504            created_at_height: Some(1000),
1505            client_metadata: 0,
1506            trigger_price: Some(dec!(49000.0)),
1507            condition_type: Some(DydxConditionType::StopLoss),
1508            conditional_order_trigger_subticks: Some(490000),
1509            execution: None,
1510            updated_at: Some(Utc::now()),
1511            updated_at_height: Some(1001),
1512            ticker: None,
1513            subaccount_number: 0,
1514            order_router_address: None,
1515        };
1516
1517        let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1518        assert!(result.is_ok());
1519
1520        let report = result.unwrap();
1521        assert_eq!(report.client_order_id, None);
1522        assert!(report.trigger_price.is_some());
1523        assert_eq!(report.trigger_price.unwrap().as_f64(), 49000.0);
1524    }
1525
1526    #[rstest]
1527    fn test_parse_fill_report() {
1528        let instrument = create_test_instrument();
1529        let account_id = AccountId::new("DYDX-001");
1530        let ts_init = UnixNanos::default();
1531
1532        let fill = Fill {
1533            id: "fill789".to_string(),
1534            side: OrderSide::Buy,
1535            liquidity: DydxLiquidity::Taker,
1536            fill_type: DydxFillType::Limit,
1537            market: Ustr::from("BTC-USD"),
1538            market_type: DydxTickerType::Perpetual,
1539            price: dec!(50100.0),
1540            size: dec!(1.0),
1541            fee: dec!(-5.01),
1542            created_at: Utc::now(),
1543            created_at_height: 1000,
1544            order_id: "order123".to_string(),
1545            client_metadata: 0,
1546        };
1547
1548        let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1549        assert!(result.is_ok());
1550
1551        let report = result.unwrap();
1552        assert_eq!(report.account_id, account_id);
1553        assert_eq!(report.order_side, OrderSide::Buy);
1554        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1555        assert_eq!(report.last_px.as_f64(), 50100.0);
1556        assert_eq!(report.commission.as_decimal(), dec!(-5.01));
1557    }
1558
1559    #[rstest]
1560    fn test_parse_position_status_report_long() {
1561        let instrument = create_test_instrument();
1562        let account_id = AccountId::new("DYDX-001");
1563        let ts_init = UnixNanos::default();
1564
1565        let position = PerpetualPosition {
1566            market: Ustr::from("BTC-USD"),
1567            status: DydxPositionStatus::Open,
1568            side: OrderSide::Buy,
1569            size: dec!(2.5),
1570            max_size: dec!(3.0),
1571            entry_price: dec!(49500.0),
1572            exit_price: None,
1573            realized_pnl: dec!(100.0),
1574            created_at_height: 1000,
1575            created_at: Utc::now(),
1576            sum_open: dec!(2.5),
1577            sum_close: dec!(0.0),
1578            net_funding: dec!(-2.5),
1579            unrealized_pnl: dec!(250.0),
1580            closed_at: None,
1581        };
1582
1583        let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1584        assert!(result.is_ok());
1585
1586        let report = result.unwrap();
1587        assert_eq!(report.account_id, account_id);
1588        assert_eq!(report.position_side, PositionSide::Long.as_specified());
1589        assert_eq!(report.quantity.as_f64(), 2.5);
1590        assert_eq!(report.avg_px_open.unwrap().to_f64().unwrap(), 49500.0);
1591    }
1592
1593    #[rstest]
1594    fn test_parse_position_status_report_short() {
1595        let instrument = create_test_instrument();
1596        let account_id = AccountId::new("DYDX-001");
1597        let ts_init = UnixNanos::default();
1598
1599        let position = PerpetualPosition {
1600            market: Ustr::from("BTC-USD"),
1601            status: DydxPositionStatus::Open,
1602            side: OrderSide::Sell,
1603            size: dec!(-1.5),
1604            max_size: dec!(1.5),
1605            entry_price: dec!(51000.0),
1606            exit_price: None,
1607            realized_pnl: dec!(0.0),
1608            created_at_height: 1000,
1609            created_at: Utc::now(),
1610            sum_open: dec!(1.5),
1611            sum_close: dec!(0.0),
1612            net_funding: dec!(1.2),
1613            unrealized_pnl: dec!(-150.0),
1614            closed_at: None,
1615        };
1616
1617        let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1618        assert!(result.is_ok());
1619
1620        let report = result.unwrap();
1621        assert_eq!(report.position_side, PositionSide::Short.as_specified());
1622        assert_eq!(report.quantity.as_f64(), 1.5);
1623    }
1624
1625    #[rstest]
1626    fn test_parse_position_status_report_flat() {
1627        let instrument = create_test_instrument();
1628        let account_id = AccountId::new("DYDX-001");
1629        let ts_init = UnixNanos::default();
1630
1631        let position = PerpetualPosition {
1632            market: Ustr::from("BTC-USD"),
1633            status: DydxPositionStatus::Closed,
1634            side: OrderSide::Buy,
1635            size: dec!(0.0),
1636            max_size: dec!(2.0),
1637            entry_price: dec!(50000.0),
1638            exit_price: Some(dec!(51000.0)),
1639            realized_pnl: dec!(500.0),
1640            created_at_height: 1000,
1641            created_at: Utc::now(),
1642            sum_open: dec!(2.0),
1643            sum_close: dec!(2.0),
1644            net_funding: dec!(-5.0),
1645            unrealized_pnl: dec!(0.0),
1646            closed_at: Some(Utc::now()),
1647        };
1648
1649        let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1650        assert!(result.is_ok());
1651
1652        let report = result.unwrap();
1653        assert_eq!(report.position_side, PositionSide::Flat.as_specified());
1654        assert_eq!(report.quantity.as_f64(), 0.0);
1655    }
1656
1657    /// Test external order detection (orders not created by this client)
1658    #[rstest]
1659    fn test_parse_order_external_detection() {
1660        let instrument = create_test_instrument();
1661        let account_id = AccountId::new("DYDX-001");
1662        let ts_init = UnixNanos::default();
1663
1664        // External order: created by different client (e.g., web UI)
1665        let order = Order {
1666            id: "external-order-123".to_string(),
1667            subaccount_id: "dydx1test/0".to_string(),
1668            client_id: "99999".to_string(),
1669            clob_pair_id: 1,
1670            side: OrderSide::Buy,
1671            size: dec!(0.5),
1672            total_filled: dec!(0.0),
1673            price: dec!(50000.0),
1674            status: DydxOrderStatus::Open,
1675            order_type: DydxOrderType::Limit,
1676            time_in_force: DydxTimeInForce::Gtt,
1677            reduce_only: false,
1678            post_only: false,
1679            order_flags: 0,
1680            good_til_block: Some(1000),
1681            good_til_block_time: None,
1682            created_at_height: Some(900),
1683            client_metadata: 0,
1684            trigger_price: None,
1685            condition_type: None,
1686            conditional_order_trigger_subticks: None,
1687            execution: None,
1688            updated_at: Some(Utc::now()),
1689            updated_at_height: Some(900),
1690            ticker: None,
1691            subaccount_number: 0,
1692            order_router_address: None,
1693        };
1694
1695        let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1696        assert!(result.is_ok());
1697
1698        let report = result.unwrap();
1699        assert_eq!(report.account_id, account_id);
1700        assert_eq!(report.order_status, OrderStatus::Accepted);
1701        // External orders should still be reconciled correctly
1702        assert_eq!(report.filled_qty.as_f64(), 0.0);
1703    }
1704
1705    /// Test order reconciliation with partial fills
1706    #[rstest]
1707    fn test_parse_order_partial_fill_reconciliation() {
1708        let instrument = create_test_instrument();
1709        let account_id = AccountId::new("DYDX-001");
1710        let ts_init = UnixNanos::default();
1711
1712        let order = Order {
1713            id: "partial-order-123".to_string(),
1714            subaccount_id: "dydx1test/0".to_string(),
1715            client_id: "12345".to_string(),
1716            clob_pair_id: 1,
1717            side: OrderSide::Buy,
1718            size: dec!(2.0),
1719            total_filled: dec!(0.75),
1720            price: dec!(50000.0),
1721            status: DydxOrderStatus::PartiallyFilled,
1722            order_type: DydxOrderType::Limit,
1723            time_in_force: DydxTimeInForce::Gtt,
1724            reduce_only: false,
1725            post_only: false,
1726            order_flags: 0,
1727            good_til_block: Some(2000),
1728            good_til_block_time: None,
1729            created_at_height: Some(1500),
1730            client_metadata: 0,
1731            trigger_price: None,
1732            condition_type: None,
1733            conditional_order_trigger_subticks: None,
1734            execution: None,
1735            updated_at: Some(Utc::now()),
1736            updated_at_height: Some(1600),
1737            ticker: None,
1738            subaccount_number: 0,
1739            order_router_address: None,
1740        };
1741
1742        let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1743        assert!(result.is_ok());
1744
1745        let report = result.unwrap();
1746        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1747        assert_eq!(report.filled_qty.as_f64(), 0.75);
1748        assert_eq!(report.quantity.as_f64(), 2.0);
1749    }
1750
1751    /// Test reconciliation with multiple positions (long and short)
1752    #[rstest]
1753    fn test_parse_multiple_positions() {
1754        let instrument = create_test_instrument();
1755        let account_id = AccountId::new("DYDX-001");
1756        let ts_init = UnixNanos::default();
1757
1758        // Position 1: Long position
1759        let long_position = PerpetualPosition {
1760            market: Ustr::from("BTC-USD"),
1761            status: DydxPositionStatus::Open,
1762            side: OrderSide::Buy,
1763            size: dec!(1.5),
1764            max_size: dec!(1.5),
1765            entry_price: dec!(49000.0),
1766            exit_price: None,
1767            realized_pnl: dec!(0.0),
1768            created_at_height: 1000,
1769            created_at: Utc::now(),
1770            sum_open: dec!(1.5),
1771            sum_close: dec!(0.0),
1772            net_funding: dec!(-1.0),
1773            unrealized_pnl: dec!(150.0),
1774            closed_at: None,
1775        };
1776
1777        let result1 =
1778            parse_position_status_report(&long_position, &instrument, account_id, ts_init);
1779        assert!(result1.is_ok());
1780        let report1 = result1.unwrap();
1781        assert_eq!(report1.position_side, PositionSide::Long.as_specified());
1782
1783        // Position 2: Short position (should be handled separately if from different market)
1784        let short_position = PerpetualPosition {
1785            market: Ustr::from("BTC-USD"),
1786            status: DydxPositionStatus::Open,
1787            side: OrderSide::Sell,
1788            size: dec!(-2.0),
1789            max_size: dec!(2.0),
1790            entry_price: dec!(51000.0),
1791            exit_price: None,
1792            realized_pnl: dec!(0.0),
1793            created_at_height: 1100,
1794            created_at: Utc::now(),
1795            sum_open: dec!(2.0),
1796            sum_close: dec!(0.0),
1797            net_funding: dec!(0.5),
1798            unrealized_pnl: dec!(-200.0),
1799            closed_at: None,
1800        };
1801
1802        let result2 =
1803            parse_position_status_report(&short_position, &instrument, account_id, ts_init);
1804        assert!(result2.is_ok());
1805        let report2 = result2.unwrap();
1806        assert_eq!(report2.position_side, PositionSide::Short.as_specified());
1807    }
1808
1809    /// Test fill reconciliation with zero fee
1810    #[rstest]
1811    fn test_parse_fill_zero_fee() {
1812        let instrument = create_test_instrument();
1813        let account_id = AccountId::new("DYDX-001");
1814        let ts_init = UnixNanos::default();
1815
1816        let fill = Fill {
1817            id: "fill-zero-fee".to_string(),
1818            side: OrderSide::Sell,
1819            liquidity: DydxLiquidity::Maker,
1820            fill_type: DydxFillType::Limit,
1821            market: Ustr::from("BTC-USD"),
1822            market_type: DydxTickerType::Perpetual,
1823            price: dec!(50000.0),
1824            size: dec!(0.1),
1825            fee: dec!(0.0), // Zero fee (e.g., fee rebate or promotional period)
1826            created_at: Utc::now(),
1827            created_at_height: 1000,
1828            order_id: "order-zero-fee".to_string(),
1829            client_metadata: 0,
1830        };
1831
1832        let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1833        assert!(result.is_ok());
1834
1835        let report = result.unwrap();
1836        assert_eq!(report.commission.as_f64(), 0.0);
1837    }
1838
1839    /// Test fill reconciliation with maker rebate (negative fee)
1840    #[rstest]
1841    fn test_parse_fill_maker_rebate() {
1842        let instrument = create_test_instrument();
1843        let account_id = AccountId::new("DYDX-001");
1844        let ts_init = UnixNanos::default();
1845
1846        let fill = Fill {
1847            id: "fill-maker-rebate".to_string(),
1848            side: OrderSide::Buy,
1849            liquidity: DydxLiquidity::Maker,
1850            fill_type: DydxFillType::Limit,
1851            market: Ustr::from("BTC-USD"),
1852            market_type: DydxTickerType::Perpetual,
1853            price: dec!(50000.0),
1854            size: dec!(1.0),
1855            fee: dec!(-2.5), // Negative fee = rebate
1856            created_at: Utc::now(),
1857            created_at_height: 1000,
1858            order_id: "order-maker-rebate".to_string(),
1859            client_metadata: 0,
1860        };
1861
1862        let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1863        assert!(result.is_ok());
1864
1865        let report = result.unwrap();
1866        assert_eq!(report.commission.as_decimal(), dec!(-2.5));
1867        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
1868    }
1869}