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 std::collections::HashMap;
36
37use anyhow::Context;
38use nautilus_core::UnixNanos;
39use nautilus_model::{
40    data::{Bar, BarType, TradeTick},
41    enums::{AccountType, AggressorSide, OrderSide, TimeInForce},
42    events::AccountState,
43    identifiers::{InstrumentId, Symbol, TradeId},
44    instruments::{CryptoPerpetual, InstrumentAny},
45    types::{AccountBalance, Currency, MarginBalance, Price, Quantity},
46};
47use rust_decimal::Decimal;
48
49use super::models::{Candle, PerpetualMarket, Subaccount, Trade};
50#[cfg(test)]
51use crate::common::enums::DydxTransferType;
52use crate::{
53    common::{
54        enums::{DydxMarketStatus, DydxOrderExecution, DydxOrderType, DydxTimeInForce},
55        parse::{parse_decimal, parse_instrument_id, parse_price, parse_quantity},
56    },
57    websocket::messages::DydxSubaccountInfo,
58};
59
60/// Parses a dYdX [`Trade`] into a Nautilus [`TradeTick`].
61///
62/// # Errors
63///
64/// Returns an error if price, size, or timestamp conversion fails.
65pub fn parse_trade_tick(
66    trade: &Trade,
67    instrument_id: InstrumentId,
68    price_precision: u8,
69    size_precision: u8,
70    ts_init: UnixNanos,
71) -> anyhow::Result<TradeTick> {
72    let aggressor_side = match trade.side {
73        OrderSide::Buy => AggressorSide::Buyer,
74        OrderSide::Sell => AggressorSide::Seller,
75        OrderSide::NoOrderSide => AggressorSide::NoAggressor,
76    };
77
78    let price = Price::from_decimal_dp(trade.price, price_precision)
79        .context(format!("failed to parse price for trade {}", trade.id))?;
80
81    let size = Quantity::from_decimal_dp(trade.size, size_precision)
82        .context(format!("failed to parse size for trade {}", trade.id))?;
83
84    let ts_event_nanos = trade
85        .created_at
86        .timestamp_nanos_opt()
87        .ok_or_else(|| anyhow::anyhow!("Timestamp out of range for trade {}", trade.id))?;
88    let ts_event = UnixNanos::from(ts_event_nanos as u64);
89
90    Ok(TradeTick::new(
91        instrument_id,
92        price,
93        size,
94        aggressor_side,
95        TradeId::new(&trade.id),
96        ts_event,
97        ts_init,
98    ))
99}
100
101/// Parses a dYdX [`Candle`] into a Nautilus [`Bar`].
102///
103/// When `timestamp_on_close` is true, `ts_event` is set to bar close time
104/// (started_at + interval). When false, uses the venue-native open time.
105///
106/// # Errors
107///
108/// Returns an error if OHLCV or timestamp conversion fails.
109pub fn parse_bar(
110    candle: &Candle,
111    bar_type: BarType,
112    price_precision: u8,
113    size_precision: u8,
114    timestamp_on_close: bool,
115    ts_init: UnixNanos,
116) -> anyhow::Result<Bar> {
117    let started_at_nanos = candle.started_at.timestamp_nanos_opt().ok_or_else(|| {
118        anyhow::anyhow!("Timestamp out of range for candle at {}", candle.started_at)
119    })?;
120    let mut ts_event = UnixNanos::from(started_at_nanos as u64);
121
122    if timestamp_on_close {
123        let interval_ns = bar_type
124            .spec()
125            .timedelta()
126            .num_nanoseconds()
127            .context("bar specification produced non-integer interval")?;
128        let interval_ns =
129            u64::try_from(interval_ns).context("bar interval overflowed u64 nanoseconds")?;
130        let updated = ts_event
131            .as_u64()
132            .checked_add(interval_ns)
133            .context("bar timestamp overflowed when adjusting to close time")?;
134        ts_event = UnixNanos::from(updated);
135    }
136
137    let open = Price::from_decimal_dp(candle.open, price_precision)
138        .context("failed to parse candle open price")?;
139    let high = Price::from_decimal_dp(candle.high, price_precision)
140        .context("failed to parse candle high price")?;
141    let low = Price::from_decimal_dp(candle.low, price_precision)
142        .context("failed to parse candle low price")?;
143    let close = Price::from_decimal_dp(candle.close, price_precision)
144        .context("failed to parse candle close price")?;
145    let volume = Quantity::from_decimal_dp(candle.base_token_volume, size_precision)
146        .context("failed to parse candle base_token_volume")?;
147
148    Ok(Bar::new(
149        bar_type, open, high, low, close, volume, ts_event, ts_init,
150    ))
151}
152
153/// Validates that a ticker has the correct format (BASE-QUOTE).
154///
155/// # Errors
156///
157/// Returns an error if the ticker is not in the format "BASE-QUOTE".
158///
159pub fn validate_ticker_format(ticker: &str) -> anyhow::Result<()> {
160    let parts: Vec<&str> = ticker.split('-').collect();
161    if parts.len() != 2 {
162        anyhow::bail!("Invalid ticker format '{ticker}', expected 'BASE-QUOTE' (e.g., 'BTC-USD')");
163    }
164
165    if parts[0].is_empty() || parts[1].is_empty() {
166        anyhow::bail!("Invalid ticker format '{ticker}', base and quote cannot be empty");
167    }
168    Ok(())
169}
170
171/// Parses base and quote currency codes from a ticker.
172///
173/// # Errors
174///
175/// Returns an error if the ticker format is invalid.
176///
177pub fn parse_ticker_currencies(ticker: &str) -> anyhow::Result<(&str, &str)> {
178    validate_ticker_format(ticker)?;
179    let parts: Vec<&str> = ticker.split('-').collect();
180    Ok((parts[0], parts[1]))
181}
182
183/// Returns true if the market status is Active.
184#[must_use]
185pub const fn is_market_active(status: &DydxMarketStatus) -> bool {
186    matches!(status, DydxMarketStatus::Active)
187}
188
189/// Calculate time-in-force for conditional orders.
190///
191/// # Errors
192///
193/// Returns an error if the combination of parameters is invalid.
194pub fn calculate_time_in_force(
195    order_type: DydxOrderType,
196    base_tif: DydxTimeInForce,
197    post_only: bool,
198    execution: Option<DydxOrderExecution>,
199) -> anyhow::Result<TimeInForce> {
200    match order_type {
201        DydxOrderType::Market => Ok(TimeInForce::Ioc),
202        DydxOrderType::Limit if post_only => Ok(TimeInForce::Gtc), // Post-only is GTC with post_only flag
203        DydxOrderType::Limit => match base_tif {
204            DydxTimeInForce::Gtt => Ok(TimeInForce::Gtc),
205            DydxTimeInForce::Fok => Ok(TimeInForce::Fok),
206            DydxTimeInForce::Ioc => Ok(TimeInForce::Ioc),
207        },
208
209        DydxOrderType::StopLimit | DydxOrderType::TakeProfitLimit => match execution {
210            Some(DydxOrderExecution::PostOnly) => Ok(TimeInForce::Gtc), // Post-only is GTC with post_only flag
211            Some(DydxOrderExecution::Fok) => Ok(TimeInForce::Fok),
212            Some(DydxOrderExecution::Ioc) => Ok(TimeInForce::Ioc),
213            Some(DydxOrderExecution::Default) | None => Ok(TimeInForce::Gtc), // Default for conditional limit
214        },
215
216        DydxOrderType::StopMarket | DydxOrderType::TakeProfitMarket => match execution {
217            Some(DydxOrderExecution::Fok) => Ok(TimeInForce::Fok),
218            Some(DydxOrderExecution::Ioc | DydxOrderExecution::Default) | None => {
219                Ok(TimeInForce::Ioc)
220            }
221            Some(DydxOrderExecution::PostOnly) => {
222                anyhow::bail!("Execution PostOnly not supported for {order_type:?}")
223            }
224        },
225
226        DydxOrderType::TrailingStop => Ok(TimeInForce::Gtc),
227    }
228}
229
230/// Validate conditional order parameters.
231///
232/// Ensures that trigger prices are set correctly relative to limit prices
233/// based on order type and side.
234///
235/// # Errors
236///
237/// Returns an error if:
238/// - Conditional order is missing trigger price.
239/// - Trigger price is on wrong side of limit price for the order type.
240pub fn validate_conditional_order(
241    order_type: DydxOrderType,
242    trigger_price: Option<Decimal>,
243    price: Decimal,
244    side: OrderSide,
245) -> anyhow::Result<()> {
246    if !order_type.is_conditional() {
247        return Ok(());
248    }
249
250    let trigger_price = trigger_price
251        .ok_or_else(|| anyhow::anyhow!("trigger_price required for {order_type:?}"))?;
252
253    // Validate trigger price relative to limit price
254    match order_type {
255        DydxOrderType::StopLimit | DydxOrderType::StopMarket => {
256            // Stop orders: trigger when price falls (sell) or rises (buy)
257            match side {
258                OrderSide::Buy if trigger_price < price => {
259                    anyhow::bail!(
260                        "Stop buy trigger_price ({trigger_price}) must be >= limit price ({price})"
261                    );
262                }
263                OrderSide::Sell if trigger_price > price => {
264                    anyhow::bail!(
265                        "Stop sell trigger_price ({trigger_price}) must be <= limit price ({price})"
266                    );
267                }
268                _ => {}
269            }
270        }
271        DydxOrderType::TakeProfitLimit | DydxOrderType::TakeProfitMarket => {
272            // Take profit: trigger when price rises (sell) or falls (buy)
273            match side {
274                OrderSide::Buy if trigger_price > price => {
275                    anyhow::bail!(
276                        "Take profit buy trigger_price ({trigger_price}) must be <= limit price ({price})"
277                    );
278                }
279                OrderSide::Sell if trigger_price < price => {
280                    anyhow::bail!(
281                        "Take profit sell trigger_price ({trigger_price}) must be >= limit price ({price})"
282                    );
283                }
284                _ => {}
285            }
286        }
287        _ => {}
288    }
289
290    Ok(())
291}
292
293/// Parses a dYdX perpetual market into a Nautilus [`InstrumentAny`].
294///
295/// dYdX v4 only supports perpetual markets, so this function creates a
296/// [`CryptoPerpetual`] instrument with the appropriate fields mapped from
297/// the dYdX market definition.
298///
299/// # Errors
300///
301/// Returns an error if:
302/// - Ticker format is invalid (not BASE-QUOTE).
303/// - Required fields are missing or invalid.
304/// - Price or quantity values cannot be parsed.
305/// - Currency parsing fails.
306/// - Margin fractions are out of valid range.
307///
308/// Note: Callers should pre-filter inactive markets using [`is_market_active`].
309pub fn parse_instrument_any(
310    definition: &PerpetualMarket,
311    maker_fee: Option<Decimal>,
312    taker_fee: Option<Decimal>,
313    ts_init: UnixNanos,
314) -> anyhow::Result<InstrumentAny> {
315    // Parse instrument ID with Nautilus perpetual suffix and keep raw symbol as venue ticker
316    let instrument_id = parse_instrument_id(definition.ticker);
317    let raw_symbol = Symbol::from(definition.ticker.as_str());
318
319    // Parse currencies from ticker using helper function
320    let (base_str, quote_str) = parse_ticker_currencies(&definition.ticker)
321        .context(format!("Failed to parse ticker '{}'", definition.ticker))?;
322
323    let base_currency = Currency::get_or_create_crypto_with_context(base_str, None);
324    let quote_currency = Currency::get_or_create_crypto_with_context(quote_str, None);
325    let settlement_currency = quote_currency; // dYdX perpetuals settle in quote currency
326
327    // Parse price and size increments with context
328    let price_increment =
329        parse_price(&definition.tick_size.to_string(), "tick_size").context(format!(
330            "Failed to parse tick_size '{}' for market '{}'",
331            definition.tick_size, definition.ticker
332        ))?;
333
334    let size_increment =
335        parse_quantity(&definition.step_size.to_string(), "step_size").context(format!(
336            "Failed to parse step_size '{}' for market '{}'",
337            definition.step_size, definition.ticker
338        ))?;
339
340    // Parse min order size with context (use step_size as fallback if not provided)
341    let min_quantity = Some(if let Some(min_size) = &definition.min_order_size {
342        parse_quantity(&min_size.to_string(), "min_order_size").context(format!(
343            "Failed to parse min_order_size '{}' for market '{}'",
344            min_size, definition.ticker
345        ))?
346    } else {
347        // Use step_size as minimum quantity if min_order_size not provided
348        parse_quantity(&definition.step_size.to_string(), "step_size").context(format!(
349            "Failed to parse step_size as min_quantity for market '{}'",
350            definition.ticker
351        ))?
352    });
353
354    // Parse margin fractions with validation
355    let margin_init = Some(
356        parse_decimal(
357            &definition.initial_margin_fraction.to_string(),
358            "initial_margin_fraction",
359        )
360        .context(format!(
361            "Failed to parse initial_margin_fraction '{}' for market '{}'",
362            definition.initial_margin_fraction, definition.ticker
363        ))?,
364    );
365
366    let margin_maint = Some(
367        parse_decimal(
368            &definition.maintenance_margin_fraction.to_string(),
369            "maintenance_margin_fraction",
370        )
371        .context(format!(
372            "Failed to parse maintenance_margin_fraction '{}' for market '{}'",
373            definition.maintenance_margin_fraction, definition.ticker
374        ))?,
375    );
376
377    // Create the perpetual instrument
378    let instrument = CryptoPerpetual::new(
379        instrument_id,
380        raw_symbol,
381        base_currency,
382        quote_currency,
383        settlement_currency,
384        false, // dYdX perpetuals are not inverse
385        price_increment.precision,
386        size_increment.precision,
387        price_increment,
388        size_increment,
389        None,                 // multiplier: not applicable for dYdX
390        Some(size_increment), // lot_size: same as size_increment
391        None,                 // max_quantity: not specified by dYdX
392        min_quantity,
393        None, // max_notional: not specified by dYdX
394        None, // min_notional: not specified by dYdX
395        None, // max_price: not specified by dYdX
396        None, // min_price: not specified by dYdX
397        margin_init,
398        margin_maint,
399        maker_fee,
400        taker_fee,
401        None,
402        None, // info: Option<Params>
403        ts_init,
404        ts_init,
405    );
406
407    Ok(InstrumentAny::CryptoPerpetual(instrument))
408}
409
410/// Serde helper for fields encoded as a string of a `Display`/`FromStr` value.
411pub(super) mod display_fromstr {
412    use std::{fmt::Display, str::FromStr};
413
414    use serde::{Deserialize, Deserializer, Serializer, de};
415
416    pub(crate) fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
417    where
418        T: Display,
419        S: Serializer,
420    {
421        serializer.collect_str(value)
422    }
423
424    pub(crate) fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
425    where
426        T: FromStr,
427        T::Err: Display,
428        D: Deserializer<'de>,
429    {
430        let s = String::deserialize(deserializer)?;
431        s.parse().map_err(de::Error::custom)
432    }
433}
434
435/// Serde helper for `Option<T>` fields encoded as a string (or null/missing) of a
436/// `Display`/`FromStr` value. Pair with `#[serde(default)]` so missing fields parse as `None`.
437pub(super) mod display_fromstr_opt {
438    use std::{fmt::Display, str::FromStr};
439
440    use serde::{Deserialize, Deserializer, Serializer, de};
441
442    pub(crate) fn serialize<T, S>(value: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
443    where
444        T: Display,
445        S: Serializer,
446    {
447        match value {
448            Some(v) => serializer.collect_str(v),
449            None => serializer.serialize_none(),
450        }
451    }
452
453    pub(crate) fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
454    where
455        T: FromStr,
456        T::Err: Display,
457        D: Deserializer<'de>,
458    {
459        match Option::<String>::deserialize(deserializer)? {
460            Some(s) => s.parse().map(Some).map_err(de::Error::custom),
461            None => Ok(None),
462        }
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use std::str::FromStr;
469
470    use chrono::Utc;
471    use nautilus_model::{
472        data::BarType,
473        enums::{AggressorSide, OrderSide},
474        identifiers::InstrumentId,
475        instruments::Instrument,
476    };
477    use rstest::rstest;
478    use rust_decimal::Decimal;
479    use rust_decimal_macros::dec;
480    use ustr::Ustr;
481
482    use super::*;
483    use crate::{
484        common::{
485            enums::{DydxOrderExecution, DydxOrderType, DydxTickerType, DydxTimeInForce},
486            testing::load_json_result_fixture,
487        },
488        http::models::{
489            CandlesResponse, FillsResponse, MarketsResponse, Order, OrderbookResponse,
490            SubaccountResponse, TradesResponse, TransfersResponse,
491        },
492    };
493
494    fn create_test_market() -> PerpetualMarket {
495        PerpetualMarket {
496            clob_pair_id: 1,
497            ticker: Ustr::from("BTC-USD"),
498            status: DydxMarketStatus::Active,
499            base_asset: Some(Ustr::from("BTC")),
500            quote_asset: Some(Ustr::from("USD")),
501            step_size: Decimal::from_str("0.001").unwrap(),
502            tick_size: Decimal::from_str("1").unwrap(),
503            index_price: Some(Decimal::from_str("50000").unwrap()),
504            oracle_price: Some(Decimal::from_str("50000").unwrap()),
505            price_change_24h: Decimal::ZERO,
506            next_funding_rate: Decimal::ZERO,
507            next_funding_at: Some(Utc::now()),
508            min_order_size: Some(Decimal::from_str("0.001").unwrap()),
509            market_type: Some(DydxTickerType::Perpetual),
510            initial_margin_fraction: Decimal::from_str("0.05").unwrap(),
511            maintenance_margin_fraction: Decimal::from_str("0.03").unwrap(),
512            base_position_notional: Some(Decimal::from_str("10000").unwrap()),
513            incremental_position_size: Some(Decimal::from_str("10000").unwrap()),
514            incremental_initial_margin_fraction: Some(Decimal::from_str("0.01").unwrap()),
515            max_position_size: Some(Decimal::from_str("100").unwrap()),
516            open_interest: Decimal::from_str("1000000").unwrap(),
517            atomic_resolution: -10,
518            quantum_conversion_exponent: -10,
519            subticks_per_tick: 100,
520            step_base_quantums: 1000,
521            is_reduce_only: false,
522        }
523    }
524
525    #[rstest]
526    fn test_parse_instrument_any_valid() {
527        let market = create_test_market();
528        let maker_fee = Some(Decimal::from_str("0.0002").unwrap());
529        let taker_fee = Some(Decimal::from_str("0.0005").unwrap());
530        let ts_init = UnixNanos::default();
531
532        let result = parse_instrument_any(&market, maker_fee, taker_fee, ts_init);
533        assert!(result.is_ok());
534
535        let instrument = result.unwrap();
536        if let InstrumentAny::CryptoPerpetual(perp) = instrument {
537            assert_eq!(perp.id.symbol.as_str(), "BTC-USD-PERP");
538            assert_eq!(perp.base_currency.code.as_str(), "BTC");
539            assert_eq!(perp.quote_currency.code.as_str(), "USD");
540            assert!(!perp.is_inverse);
541            assert_eq!(perp.price_increment.to_string(), "1");
542            assert_eq!(perp.size_increment.to_string(), "0.001");
543        } else {
544            panic!("Expected CryptoPerpetual instrument");
545        }
546    }
547
548    #[rstest]
549    fn test_is_market_active() {
550        assert!(is_market_active(&DydxMarketStatus::Active));
551        assert!(!is_market_active(&DydxMarketStatus::Paused));
552        assert!(!is_market_active(&DydxMarketStatus::CancelOnly));
553        assert!(!is_market_active(&DydxMarketStatus::PostOnly));
554        assert!(!is_market_active(&DydxMarketStatus::Initializing));
555        assert!(!is_market_active(&DydxMarketStatus::FinalSettlement));
556    }
557
558    #[rstest]
559    fn test_parse_instrument_any_invalid_ticker() {
560        let mut market = create_test_market();
561        market.ticker = Ustr::from("INVALID");
562
563        let result = parse_instrument_any(&market, None, None, UnixNanos::default());
564        assert!(result.is_err());
565        let error_msg = result.unwrap_err().to_string();
566        // The error message includes context, so check for key parts
567        assert!(
568            error_msg.contains("Invalid ticker format")
569                || error_msg.contains("Failed to parse ticker"),
570            "Expected ticker format error, was: {error_msg}"
571        );
572    }
573
574    #[rstest]
575    fn test_validate_ticker_format_valid() {
576        assert!(validate_ticker_format("BTC-USD").is_ok());
577        assert!(validate_ticker_format("ETH-USD").is_ok());
578        assert!(validate_ticker_format("ATOM-USD").is_ok());
579    }
580
581    #[rstest]
582    fn test_validate_ticker_format_invalid() {
583        // Missing hyphen
584        assert!(validate_ticker_format("BTCUSD").is_err());
585
586        // Too many parts
587        assert!(validate_ticker_format("BTC-USD-PERP").is_err());
588
589        // Empty base
590        assert!(validate_ticker_format("-USD").is_err());
591
592        // Empty quote
593        assert!(validate_ticker_format("BTC-").is_err());
594
595        // Just hyphen
596        assert!(validate_ticker_format("-").is_err());
597    }
598
599    #[rstest]
600    fn test_parse_ticker_currencies_valid() {
601        let (base, quote) = parse_ticker_currencies("BTC-USD").unwrap();
602        assert_eq!(base, "BTC");
603        assert_eq!(quote, "USD");
604
605        let (base, quote) = parse_ticker_currencies("ETH-USDC").unwrap();
606        assert_eq!(base, "ETH");
607        assert_eq!(quote, "USDC");
608    }
609
610    #[rstest]
611    fn test_parse_ticker_currencies_invalid() {
612        assert!(parse_ticker_currencies("INVALID").is_err());
613        assert!(parse_ticker_currencies("BTC-USD-PERP").is_err());
614    }
615
616    #[rstest]
617    fn test_validate_stop_limit_buy_valid() {
618        let result = validate_conditional_order(
619            DydxOrderType::StopLimit,
620            Some(dec!(51000)), // trigger
621            dec!(50000),       // limit price
622            OrderSide::Buy,
623        );
624        assert!(result.is_ok());
625    }
626
627    #[rstest]
628    fn test_validate_stop_limit_buy_invalid() {
629        // Invalid: trigger below limit
630        let result = validate_conditional_order(
631            DydxOrderType::StopLimit,
632            Some(dec!(49000)),
633            dec!(50000),
634            OrderSide::Buy,
635        );
636        assert!(result.is_err());
637        assert!(
638            result
639                .unwrap_err()
640                .to_string()
641                .contains("must be >= limit price")
642        );
643    }
644
645    #[rstest]
646    fn test_validate_stop_limit_sell_valid() {
647        let result = validate_conditional_order(
648            DydxOrderType::StopLimit,
649            Some(dec!(49000)), // trigger
650            dec!(50000),       // limit price
651            OrderSide::Sell,
652        );
653        assert!(result.is_ok());
654    }
655
656    #[rstest]
657    fn test_validate_stop_limit_sell_invalid() {
658        // Invalid: trigger above limit
659        let result = validate_conditional_order(
660            DydxOrderType::StopLimit,
661            Some(dec!(51000)),
662            dec!(50000),
663            OrderSide::Sell,
664        );
665        assert!(result.is_err());
666        assert!(
667            result
668                .unwrap_err()
669                .to_string()
670                .contains("must be <= limit price")
671        );
672    }
673
674    #[rstest]
675    fn test_validate_take_profit_sell_valid() {
676        let result = validate_conditional_order(
677            DydxOrderType::TakeProfitLimit,
678            Some(dec!(51000)), // trigger
679            dec!(50000),       // limit price
680            OrderSide::Sell,
681        );
682        assert!(result.is_ok());
683    }
684
685    #[rstest]
686    fn test_validate_take_profit_buy_valid() {
687        let result = validate_conditional_order(
688            DydxOrderType::TakeProfitLimit,
689            Some(dec!(49000)), // trigger
690            dec!(50000),       // limit price
691            OrderSide::Buy,
692        );
693        assert!(result.is_ok());
694    }
695
696    #[rstest]
697    fn test_validate_missing_trigger_price() {
698        let result =
699            validate_conditional_order(DydxOrderType::StopLimit, None, dec!(50000), OrderSide::Buy);
700        assert!(result.is_err());
701        assert!(
702            result
703                .unwrap_err()
704                .to_string()
705                .contains("trigger_price required")
706        );
707    }
708
709    #[rstest]
710    fn test_validate_non_conditional_order() {
711        // Should pass for non-conditional orders
712        let result =
713            validate_conditional_order(DydxOrderType::Limit, None, dec!(50000), OrderSide::Buy);
714        assert!(result.is_ok());
715    }
716
717    #[rstest]
718    fn test_calculate_tif_market() {
719        let tif = calculate_time_in_force(DydxOrderType::Market, DydxTimeInForce::Gtt, false, None)
720            .unwrap();
721        assert_eq!(tif, TimeInForce::Ioc);
722    }
723
724    #[rstest]
725    fn test_calculate_tif_limit_post_only() {
726        let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, true, None)
727            .unwrap();
728        assert_eq!(tif, TimeInForce::Gtc); // Post-only uses GTC with post_only flag
729    }
730
731    #[rstest]
732    fn test_calculate_tif_limit_gtc() {
733        let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, false, None)
734            .unwrap();
735        assert_eq!(tif, TimeInForce::Gtc);
736    }
737
738    #[rstest]
739    fn test_calculate_tif_stop_market_ioc() {
740        let tif = calculate_time_in_force(
741            DydxOrderType::StopMarket,
742            DydxTimeInForce::Gtt,
743            false,
744            Some(DydxOrderExecution::Ioc),
745        )
746        .unwrap();
747        assert_eq!(tif, TimeInForce::Ioc);
748    }
749
750    #[rstest]
751    fn test_calculate_tif_stop_limit_post_only() {
752        let tif = calculate_time_in_force(
753            DydxOrderType::StopLimit,
754            DydxTimeInForce::Gtt,
755            false,
756            Some(DydxOrderExecution::PostOnly),
757        )
758        .unwrap();
759        assert_eq!(tif, TimeInForce::Gtc); // Post-only uses GTC with post_only flag
760    }
761
762    #[rstest]
763    fn test_calculate_tif_stop_limit_gtc() {
764        let tif =
765            calculate_time_in_force(DydxOrderType::StopLimit, DydxTimeInForce::Gtt, false, None)
766                .unwrap();
767        assert_eq!(tif, TimeInForce::Gtc);
768    }
769
770    #[rstest]
771    fn test_calculate_tif_stop_market_invalid_post_only() {
772        let result = calculate_time_in_force(
773            DydxOrderType::StopMarket,
774            DydxTimeInForce::Gtt,
775            false,
776            Some(DydxOrderExecution::PostOnly),
777        );
778        assert!(result.is_err());
779        assert!(
780            result
781                .unwrap_err()
782                .to_string()
783                .contains("PostOnly not supported")
784        );
785    }
786
787    #[rstest]
788    fn test_calculate_tif_trailing_stop() {
789        let tif = calculate_time_in_force(
790            DydxOrderType::TrailingStop,
791            DydxTimeInForce::Gtt,
792            false,
793            None,
794        )
795        .unwrap();
796        assert_eq!(tif, TimeInForce::Gtc);
797    }
798
799    #[rstest]
800    fn test_parse_perpetual_markets() {
801        let json = load_json_result_fixture("http_get_perpetual_markets.json");
802        let response: MarketsResponse =
803            serde_json::from_value(json).expect("Failed to parse markets");
804
805        assert_eq!(response.markets.len(), 3);
806        assert!(response.markets.contains_key("BTC-USD"));
807        assert!(response.markets.contains_key("ETH-USD"));
808        assert!(response.markets.contains_key("SOL-USD"));
809
810        let btc = response.markets.get("BTC-USD").unwrap();
811        assert_eq!(btc.ticker, "BTC-USD");
812        assert_eq!(btc.clob_pair_id, 0);
813        assert_eq!(btc.atomic_resolution, -10);
814    }
815
816    #[rstest]
817    fn test_parse_perpetual_market_with_null_oracle_price() {
818        let json = serde_json::json!({
819            "markets": {
820                "WTI-USD": {
821                    "clobPairId": "99",
822                    "ticker": "WTI-USD",
823                    "status": "ACTIVE",
824                    "oraclePrice": null,
825                    "priceChange24H": "0",
826                    "nextFundingRate": "0",
827                    "initialMarginFraction": "0.1",
828                    "maintenanceMarginFraction": "0.05",
829                    "openInterest": "0",
830                    "atomicResolution": -7,
831                    "quantumConversionExponent": -9,
832                    "tickSize": "0.01",
833                    "stepSize": "0.1",
834                    "stepBaseQuantums": 1000000,
835                    "subticksPerTick": 1000000
836                }
837            }
838        });
839        let response: MarketsResponse =
840            serde_json::from_value(json).expect("Failed to parse market with null oraclePrice");
841
842        let wti = response.markets.get("WTI-USD").unwrap();
843        assert_eq!(wti.ticker.as_str(), "WTI-USD");
844        assert_eq!(wti.oracle_price, None);
845    }
846
847    #[rstest]
848    fn test_parse_perpetual_market_with_missing_oracle_price() {
849        let json = serde_json::json!({
850            "markets": {
851                "WTI-USD": {
852                    "clobPairId": "99",
853                    "ticker": "WTI-USD",
854                    "status": "ACTIVE",
855                    "priceChange24H": "0",
856                    "nextFundingRate": "0",
857                    "initialMarginFraction": "0.1",
858                    "maintenanceMarginFraction": "0.05",
859                    "openInterest": "0",
860                    "atomicResolution": -7,
861                    "quantumConversionExponent": -9,
862                    "tickSize": "0.01",
863                    "stepSize": "0.1",
864                    "stepBaseQuantums": 1000000,
865                    "subticksPerTick": 1000000
866                }
867            }
868        });
869        let response: MarketsResponse =
870            serde_json::from_value(json).expect("Failed to parse market with missing oraclePrice");
871
872        let wti = response.markets.get("WTI-USD").unwrap();
873        assert_eq!(wti.oracle_price, None);
874    }
875
876    #[rstest]
877    fn test_parse_instrument_from_market() {
878        let json = load_json_result_fixture("http_get_perpetual_markets.json");
879        let response: MarketsResponse =
880            serde_json::from_value(json).expect("Failed to parse markets");
881        let btc = response.markets.get("BTC-USD").unwrap();
882
883        let ts_init = UnixNanos::default();
884        let instrument =
885            parse_instrument_any(btc, None, None, ts_init).expect("Failed to parse instrument");
886
887        assert_eq!(instrument.id().symbol.as_str(), "BTC-USD-PERP");
888        assert_eq!(instrument.id().venue.as_str(), "DYDX");
889    }
890
891    #[rstest]
892    fn test_parse_orderbook_response() {
893        let json = load_json_result_fixture("http_get_orderbook.json");
894        let response: OrderbookResponse =
895            serde_json::from_value(json).expect("Failed to parse orderbook");
896
897        assert_eq!(response.bids.len(), 5);
898        assert_eq!(response.asks.len(), 5);
899
900        let best_bid = &response.bids[0];
901        assert_eq!(best_bid.price.to_string(), "89947");
902        assert_eq!(best_bid.size.to_string(), "0.0002");
903
904        let best_ask = &response.asks[0];
905        assert_eq!(best_ask.price.to_string(), "89958");
906        assert_eq!(best_ask.size.to_string(), "0.1177");
907    }
908
909    #[rstest]
910    fn test_parse_trades_response() {
911        let json = load_json_result_fixture("http_get_trades.json");
912        let response: TradesResponse =
913            serde_json::from_value(json).expect("Failed to parse trades");
914
915        assert_eq!(response.trades.len(), 3);
916
917        let first_trade = &response.trades[0];
918        assert_eq!(first_trade.id, "03f89a550000000200000002");
919        assert_eq!(first_trade.side, OrderSide::Buy);
920        assert_eq!(first_trade.price.to_string(), "89942");
921        assert_eq!(first_trade.size.to_string(), "0.0001");
922    }
923
924    #[rstest]
925    fn test_parse_candles_response() {
926        let json = load_json_result_fixture("http_get_candles.json");
927        let response: CandlesResponse =
928            serde_json::from_value(json).expect("Failed to parse candles");
929
930        assert_eq!(response.candles.len(), 3);
931
932        let first_candle = &response.candles[0];
933        assert_eq!(first_candle.ticker, "BTC-USD");
934        assert_eq!(first_candle.open.to_string(), "89934");
935        assert_eq!(first_candle.high.to_string(), "89970");
936        assert_eq!(first_candle.low.to_string(), "89911");
937        assert_eq!(first_candle.close.to_string(), "89941");
938    }
939
940    #[rstest]
941    fn test_parse_subaccount_response() {
942        let json = load_json_result_fixture("http_get_subaccount.json");
943        let response: SubaccountResponse =
944            serde_json::from_value(json).expect("Failed to parse subaccount");
945
946        let subaccount = &response.subaccount;
947        assert_eq!(subaccount.subaccount_number, 0);
948        assert_eq!(subaccount.equity.to_string(), "45.201296");
949        assert_eq!(subaccount.free_collateral.to_string(), "45.201296");
950        assert!(subaccount.margin_enabled);
951        assert_eq!(subaccount.open_perpetual_positions.len(), 0);
952    }
953
954    #[rstest]
955    fn test_parse_orders_response() {
956        let json = load_json_result_fixture("http_get_orders.json");
957        let response: Vec<Order> = serde_json::from_value(json).expect("Failed to parse orders");
958
959        assert_eq!(response.len(), 3);
960
961        let first_order = &response[0];
962        assert_eq!(first_order.id, "0f0981cb-152e-57d3-bea9-4d8e0dd5ed35");
963        assert_eq!(first_order.side, OrderSide::Buy);
964        assert_eq!(first_order.order_type, DydxOrderType::Limit);
965        assert!(first_order.reduce_only);
966
967        let second_order = &response[1];
968        assert_eq!(second_order.side, OrderSide::Sell);
969        assert!(!second_order.reduce_only);
970    }
971
972    #[rstest]
973    fn test_parse_fills_response() {
974        let json = load_json_result_fixture("http_get_fills.json");
975        let response: FillsResponse = serde_json::from_value(json).expect("Failed to parse fills");
976
977        assert_eq!(response.fills.len(), 3);
978
979        let first_fill = &response.fills[0];
980        assert_eq!(first_fill.id, "6450e369-1dc3-5229-8dc2-fb3b5d1cf2ab");
981        assert_eq!(first_fill.side, OrderSide::Buy);
982        assert_eq!(first_fill.market, "BTC-USD");
983        assert_eq!(first_fill.price.to_string(), "105117");
984    }
985
986    #[rstest]
987    fn test_parse_transfers_response() {
988        let json = load_json_result_fixture("http_get_transfers.json");
989        let response: TransfersResponse =
990            serde_json::from_value(json).expect("Failed to parse transfers");
991
992        assert_eq!(response.transfers.len(), 1);
993
994        let deposit = &response.transfers[0];
995        assert_eq!(deposit.transfer_type, DydxTransferType::Deposit);
996        assert_eq!(deposit.asset, "USDC");
997        assert_eq!(deposit.amount.to_string(), "45.334703");
998    }
999
1000    #[rstest]
1001    fn test_transfer_type_enum_serde() {
1002        // Test all transfer type variants serialize/deserialize correctly
1003        let test_cases = vec![
1004            (DydxTransferType::Deposit, "\"DEPOSIT\""),
1005            (DydxTransferType::Withdrawal, "\"WITHDRAWAL\""),
1006            (DydxTransferType::TransferIn, "\"TRANSFER_IN\""),
1007            (DydxTransferType::TransferOut, "\"TRANSFER_OUT\""),
1008        ];
1009
1010        for (variant, expected_json) in test_cases {
1011            // Test serialization
1012            let serialized = serde_json::to_string(&variant).expect("Failed to serialize");
1013            assert_eq!(
1014                serialized, expected_json,
1015                "Serialization failed for {variant:?}"
1016            );
1017
1018            // Test deserialization
1019            let deserialized: DydxTransferType =
1020                serde_json::from_str(&serialized).expect("Failed to deserialize");
1021            assert_eq!(
1022                deserialized, variant,
1023                "Deserialization failed for {variant:?}"
1024            );
1025        }
1026    }
1027
1028    #[rstest]
1029    fn test_parse_trade_tick() {
1030        let json = load_json_result_fixture("http_get_trades.json");
1031        let response: TradesResponse =
1032            serde_json::from_value(json).expect("Failed to parse trades");
1033
1034        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
1035        let ts_init = UnixNanos::from(1_000_000_000u64);
1036
1037        let tick = parse_trade_tick(&response.trades[0], instrument_id, 0, 4, ts_init)
1038            .expect("Failed to parse trade tick");
1039
1040        assert_eq!(tick.instrument_id, instrument_id);
1041        assert_eq!(tick.price.to_string(), "89942");
1042        assert_eq!(tick.size.to_string(), "0.0001");
1043        assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
1044        assert_eq!(tick.trade_id.to_string(), "03f89a550000000200000002");
1045        assert_eq!(tick.ts_init, ts_init);
1046    }
1047
1048    #[rstest]
1049    #[case(true)]
1050    #[case(false)]
1051    fn test_parse_bar_timestamp_on_close(#[case] timestamp_on_close: bool) {
1052        let json = load_json_result_fixture("http_get_candles.json");
1053        let response: CandlesResponse =
1054            serde_json::from_value(json).expect("Failed to parse candles");
1055
1056        let bar_type = BarType::from_str("BTC-USD-PERP.DYDX-1-MINUTE-LAST-EXTERNAL")
1057            .expect("Failed to parse bar type");
1058        let ts_init = UnixNanos::from(1_000_000_000u64);
1059
1060        let bar = parse_bar(
1061            &response.candles[0],
1062            bar_type,
1063            0,
1064            4,
1065            timestamp_on_close,
1066            ts_init,
1067        )
1068        .expect("Failed to parse bar");
1069
1070        assert_eq!(bar.bar_type, bar_type);
1071        assert_eq!(bar.open.to_string(), "89934");
1072        assert_eq!(bar.high.to_string(), "89970");
1073        assert_eq!(bar.low.to_string(), "89911");
1074        assert_eq!(bar.close.to_string(), "89941");
1075        assert_eq!(bar.volume.to_string(), "3.2767");
1076
1077        // 2025-12-08T16:11:00.000Z
1078        let started_at_ns = 1_765_210_260_000_000_000u64;
1079        let one_min_ns = 60_000_000_000u64;
1080
1081        if timestamp_on_close {
1082            assert_eq!(bar.ts_event.as_u64(), started_at_ns + one_min_ns);
1083        } else {
1084            assert_eq!(bar.ts_event.as_u64(), started_at_ns);
1085        }
1086    }
1087}
1088
1089use std::str::FromStr;
1090
1091use nautilus_core::UUID4;
1092use nautilus_model::{
1093    enums::{LiquiditySide, OrderStatus, OrderType, PositionSide, TriggerType},
1094    identifiers::{AccountId, ClientOrderId, VenueOrderId},
1095    instruments::Instrument,
1096    reports::{FillReport, OrderStatusReport, PositionStatusReport},
1097    types::Money,
1098};
1099
1100use super::models::{Fill, Order, PerpetualPosition};
1101use crate::common::enums::{DydxConditionType, DydxLiquidity, DydxOrderStatus};
1102#[cfg(test)]
1103use crate::common::enums::{DydxFillType, DydxPositionSide, DydxPositionStatus, DydxTickerType};
1104
1105/// Map dYdX order status to Nautilus OrderStatus.
1106fn parse_order_status(status: &DydxOrderStatus) -> OrderStatus {
1107    match status {
1108        DydxOrderStatus::Open => OrderStatus::Accepted,
1109        DydxOrderStatus::Filled => OrderStatus::Filled,
1110        DydxOrderStatus::Canceled => OrderStatus::Canceled,
1111        DydxOrderStatus::BestEffortCanceled => OrderStatus::Canceled,
1112        DydxOrderStatus::Untriggered => OrderStatus::Accepted, // Conditional orders waiting for trigger
1113        DydxOrderStatus::BestEffortOpened => OrderStatus::Accepted,
1114        DydxOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
1115    }
1116}
1117
1118/// Parse a dYdX Order into a Nautilus OrderStatusReport.
1119///
1120/// # Errors
1121///
1122/// Returns an error if required fields are missing or invalid.
1123pub fn parse_order_status_report(
1124    order: &Order,
1125    instrument: &InstrumentAny,
1126    account_id: AccountId,
1127    ts_init: UnixNanos,
1128) -> anyhow::Result<OrderStatusReport> {
1129    let instrument_id = instrument.id();
1130    let venue_order_id = VenueOrderId::new(&order.id);
1131    let client_order_id = if order.client_id.is_empty() {
1132        None
1133    } else {
1134        Some(ClientOrderId::new(&order.client_id))
1135    };
1136
1137    let mut order_type: OrderType = order.order_type.into();
1138    // Track the dYdX-side type alongside the Nautilus type so the TIF resolver
1139    // sees the same reclassification (e.g. TakeProfitLimit -> TakeProfitMarket).
1140    let mut dydx_order_type = order.order_type;
1141
1142    // Disambiguate MarketIfTouched vs LimitIfTouched on reconcile.
1143    //
1144    // dYdX's Indexer reports both submitted variants under `TAKE_PROFIT`, so
1145    // `DydxOrderType::TakeProfitLimit` (the deserialized form) maps to Nautilus
1146    // `LimitIfTouched` by default. We submit `MarketIfTouched` with the limit price set
1147    // to the 5% pay-through worst case (see `DEFAULT_MARKET_ORDER_SLIPPAGE`), so when the
1148    // limit price is far from the trigger price we infer the original was a market-style
1149    // take-profit. Threshold of 2% safely separates pay-through (~5%) from typical LIT
1150    // user offsets (well under 1%).
1151    if order_type == OrderType::LimitIfTouched
1152        && let Some(trigger_dec) = order.trigger_price
1153        && !trigger_dec.is_zero()
1154    {
1155        let drift = (order.price - trigger_dec).abs() / trigger_dec;
1156        if drift >= rust_decimal::Decimal::new(2, 2) {
1157            order_type = OrderType::MarketIfTouched;
1158            dydx_order_type = DydxOrderType::TakeProfitMarket;
1159        }
1160    }
1161
1162    let execution = order.execution.or({
1163        // Infer execution type from post_only flag if not explicitly set
1164        if order.post_only {
1165            Some(DydxOrderExecution::PostOnly)
1166        } else {
1167            Some(DydxOrderExecution::Default)
1168        }
1169    });
1170    let time_in_force = calculate_time_in_force(
1171        dydx_order_type,
1172        order.time_in_force,
1173        order.reduce_only,
1174        execution,
1175    )?;
1176
1177    let order_side = order.side;
1178    let order_status = parse_order_status(&order.status);
1179
1180    let size_precision = instrument.size_precision();
1181    let quantity = Quantity::from_decimal_dp(order.size, size_precision)
1182        .context("failed to parse order size")?;
1183    let filled_qty = Quantity::from_decimal_dp(order.total_filled, size_precision)
1184        .context("failed to parse total_filled")?;
1185
1186    let price_precision = instrument.price_precision();
1187    let price = Price::from_decimal_dp(order.price, price_precision)
1188        .context("failed to parse order price")?;
1189
1190    // Use updated_at for both ts_accepted and ts_last (not good_til_block_time which is the expiry)
1191    let ts_accepted = order.updated_at.map_or(ts_init, |dt| {
1192        UnixNanos::from(dt.timestamp_millis() as u64 * 1_000_000)
1193    });
1194    let ts_last = ts_accepted;
1195
1196    let mut report = OrderStatusReport::new(
1197        account_id,
1198        instrument_id,
1199        client_order_id,
1200        venue_order_id,
1201        order_side,
1202        order_type,
1203        time_in_force,
1204        order_status,
1205        quantity,
1206        filled_qty,
1207        ts_accepted,
1208        ts_last,
1209        ts_init,
1210        Some(UUID4::new()),
1211    );
1212
1213    report = report.with_price(price);
1214
1215    if let Some(trigger_price_dec) = order.trigger_price {
1216        let trigger_price = Price::from_decimal_dp(trigger_price_dec, instrument.price_precision())
1217            .context("failed to parse trigger_price")?;
1218        report = report.with_trigger_price(trigger_price);
1219
1220        let trigger_type = match order.condition_type {
1221            Some(DydxConditionType::StopLoss) => TriggerType::LastPrice,
1222            Some(DydxConditionType::TakeProfit) => TriggerType::LastPrice,
1223            Some(DydxConditionType::Unspecified) | None => TriggerType::Default,
1224        };
1225        report = report.with_trigger_type(trigger_type);
1226    }
1227
1228    if let Some(good_til_block_time) = order.good_til_block_time {
1229        let expire_ns = good_til_block_time.timestamp_millis() as u64 * 1_000_000;
1230        report = report.with_expire_time(UnixNanos::from(expire_ns));
1231
1232        // dYdX reports a long-term order that has crossed `good_til_block_time`
1233        // as `Canceled`. Reclassify to `Expired` so reconciliation surfaces
1234        // `OrderExpired` (matching the WS dispatch path), not `OrderCanceled`.
1235        if report.order_status == OrderStatus::Canceled
1236            && report.ts_last >= UnixNanos::from(expire_ns)
1237        {
1238            report.order_status = OrderStatus::Expired;
1239        }
1240    }
1241
1242    Ok(report)
1243}
1244
1245/// Parse a dYdX Fill into a Nautilus FillReport.
1246///
1247/// # Errors
1248///
1249/// Returns an error if required fields are missing or invalid.
1250pub fn parse_fill_report(
1251    fill: &Fill,
1252    instrument: &InstrumentAny,
1253    account_id: AccountId,
1254    ts_init: UnixNanos,
1255) -> anyhow::Result<FillReport> {
1256    let instrument_id = instrument.id();
1257    let venue_order_id = VenueOrderId::new(&fill.order_id);
1258    let trade_id = TradeId::new(&fill.id);
1259    let order_side = fill.side;
1260
1261    // On dYdX v4 the indexer tags protocol-generated fills via the `type` field:
1262    // LIQUIDATED / LIQUIDATION mark the undercollateralised account and the
1263    // matching insurance-fund counterparty; DELEVERAGED / OFFSETTING mark
1264    // deleveraging (ADL) events when the insurance fund is exhausted.
1265    match fill.fill_type {
1266        crate::common::enums::DydxFillType::Liquidated
1267        | crate::common::enums::DydxFillType::Liquidation => {
1268            log::warn!(
1269                "Liquidation fill: {} id={} order_id={} type={:?} side={:?} size={} price={}",
1270                instrument_id,
1271                fill.id,
1272                fill.order_id,
1273                fill.fill_type,
1274                order_side,
1275                fill.size,
1276                fill.price,
1277            );
1278        }
1279        crate::common::enums::DydxFillType::Deleveraged
1280        | crate::common::enums::DydxFillType::Offsetting => {
1281            log::warn!(
1282                "Deleveraging (ADL) fill: {} id={} order_id={} type={:?} side={:?} size={} price={}",
1283                instrument_id,
1284                fill.id,
1285                fill.order_id,
1286                fill.fill_type,
1287                order_side,
1288                fill.size,
1289                fill.price,
1290            );
1291        }
1292        crate::common::enums::DydxFillType::Limit => {}
1293        crate::common::enums::DydxFillType::Unknown => {
1294            log::warn!(
1295                "Unmodeled dYdX fill type: {} id={} order_id={} side={:?} size={} price={}",
1296                instrument_id,
1297                fill.id,
1298                fill.order_id,
1299                order_side,
1300                fill.size,
1301                fill.price,
1302            );
1303        }
1304    }
1305
1306    let size_precision = instrument.size_precision();
1307    let price_precision = instrument.price_precision();
1308
1309    let last_qty = Quantity::from_decimal_dp(fill.size, size_precision)
1310        .context("failed to parse fill size")?;
1311    let last_px = Price::from_decimal_dp(fill.price, price_precision)
1312        .context("failed to parse fill price")?;
1313
1314    // dYdX sign convention matches Nautilus (positive = cost)
1315    let commission = Money::from_decimal(fill.fee, instrument.quote_currency())
1316        .context("failed to parse fee")?;
1317
1318    let liquidity_side = match fill.liquidity {
1319        DydxLiquidity::Maker => LiquiditySide::Maker,
1320        DydxLiquidity::Taker => LiquiditySide::Taker,
1321    };
1322
1323    let ts_event = UnixNanos::from(fill.created_at.timestamp_millis() as u64 * 1_000_000);
1324
1325    let report = FillReport::new(
1326        account_id,
1327        instrument_id,
1328        venue_order_id,
1329        trade_id,
1330        order_side,
1331        last_qty,
1332        last_px,
1333        commission,
1334        liquidity_side,
1335        None, // client_order_id - will be linked by execution engine
1336        None, // venue_position_id
1337        ts_event,
1338        ts_init,
1339        Some(UUID4::new()),
1340    );
1341
1342    Ok(report)
1343}
1344
1345/// Parse a dYdX PerpetualPosition into a Nautilus PositionStatusReport.
1346///
1347/// # Errors
1348///
1349/// Returns an error if required fields are missing or invalid.
1350pub fn parse_position_status_report(
1351    position: &PerpetualPosition,
1352    instrument: &InstrumentAny,
1353    account_id: AccountId,
1354    ts_init: UnixNanos,
1355) -> anyhow::Result<PositionStatusReport> {
1356    let instrument_id = instrument.id();
1357
1358    // Trust the venue-supplied `side` for open positions; fall back to Flat only
1359    // when size is zero or the position is closed/liquidated. The prior logic
1360    // derived the side from `size.is_sign_positive()`, which silently overrode the
1361    // venue side for edge cases (e.g. an explicit Short reported with zero size).
1362    let position_side = if position.status.is_closed() || position.size.is_zero() {
1363        PositionSide::Flat
1364    } else {
1365        PositionSide::from(position.side)
1366    };
1367
1368    // Create quantity (always positive)
1369    let quantity = Quantity::from_decimal_dp(position.size.abs(), instrument.size_precision())
1370        .context("failed to parse position size")?;
1371
1372    let avg_px_open = position.entry_price;
1373    let ts_last = UnixNanos::from(position.created_at.timestamp_millis() as u64 * 1_000_000);
1374
1375    Ok(PositionStatusReport::new(
1376        account_id,
1377        instrument_id,
1378        position_side.as_specified(),
1379        quantity,
1380        ts_last,
1381        ts_init,
1382        Some(UUID4::new()),
1383        None, // venue_position_id: None for NETTING mode
1384        Some(avg_px_open),
1385    ))
1386}
1387
1388/// Parse a dYdX subaccount info into a Nautilus AccountState.
1389///
1390/// dYdX provides account-level balances with:
1391/// - `equity`: Total account value (total balance)
1392/// - `freeCollateral`: Available for new orders (free balance)
1393/// - `locked`: equity - freeCollateral (calculated)
1394///
1395/// Margin calculations per position:
1396/// - `initial_margin = margin_init * abs(position_size) * oracle_price`
1397/// - `maintenance_margin = margin_maint * abs(position_size) * oracle_price`
1398///
1399/// # Errors
1400///
1401/// Returns an error if balance fields cannot be parsed.
1402pub fn parse_account_state(
1403    subaccount: &DydxSubaccountInfo,
1404    account_id: AccountId,
1405    instruments: &std::collections::HashMap<InstrumentId, InstrumentAny>,
1406    oracle_prices: &std::collections::HashMap<InstrumentId, Decimal>,
1407    ts_event: UnixNanos,
1408    ts_init: UnixNanos,
1409) -> anyhow::Result<AccountState> {
1410    use std::collections::HashMap;
1411
1412    use nautilus_model::{
1413        enums::AccountType,
1414        events::AccountState,
1415        types::{AccountBalance, MarginBalance},
1416    };
1417
1418    let mut balances = Vec::new();
1419
1420    // Parse equity (total) and freeCollateral (free)
1421    let equity: Decimal = if subaccount.equity.is_empty() {
1422        Decimal::ZERO
1423    } else {
1424        subaccount
1425            .equity
1426            .parse()
1427            .context(format!("Failed to parse equity '{}'", subaccount.equity))?
1428    };
1429
1430    let free_collateral: Decimal = if subaccount.free_collateral.is_empty() {
1431        Decimal::ZERO
1432    } else {
1433        subaccount.free_collateral.parse().context(format!(
1434            "Failed to parse freeCollateral '{}'",
1435            subaccount.free_collateral
1436        ))?
1437    };
1438
1439    // dYdX uses USDC as the settlement currency
1440    let currency = Currency::get_or_create_crypto_with_context("USDC", None);
1441
1442    let balance = AccountBalance::from_total_and_free(equity, free_collateral, currency)
1443        .context("failed to derive account balance from subaccount data")?;
1444    balances.push(balance);
1445
1446    // Calculate margin balances from open positions
1447    let mut margins = Vec::new();
1448    let mut initial_margins: HashMap<Currency, Decimal> = HashMap::new();
1449    let mut maintenance_margins: HashMap<Currency, Decimal> = HashMap::new();
1450
1451    if let Some(ref positions) = subaccount.open_perpetual_positions {
1452        for position in positions.values() {
1453            // Parse instrument ID from market symbol (e.g., "BTC-USD" -> "BTC-USD-PERP")
1454            let market_str = position.market.as_str();
1455            let instrument_id = parse_instrument_id(market_str);
1456
1457            // Get instrument to access margin parameters
1458            let instrument = match instruments.get(&instrument_id) {
1459                Some(inst) => inst,
1460                None => {
1461                    log::warn!(
1462                        "Cannot calculate margin for position {market_str}: instrument not found"
1463                    );
1464                    continue;
1465                }
1466            };
1467
1468            // Get margin parameters from instrument
1469            let (margin_init, margin_maint) = match instrument {
1470                InstrumentAny::CryptoPerpetual(perp) => (perp.margin_init, perp.margin_maint),
1471                _ => {
1472                    log::warn!(
1473                        "Instrument {instrument_id} is not a CryptoPerpetual, skipping margin calculation"
1474                    );
1475                    continue;
1476                }
1477            };
1478
1479            // Parse position size
1480            let position_size = match Decimal::from_str(&position.size) {
1481                Ok(size) => size.abs(),
1482                Err(e) => {
1483                    log::warn!(
1484                        "Failed to parse position size '{}' for {}: {}",
1485                        position.size,
1486                        market_str,
1487                        e
1488                    );
1489                    continue;
1490                }
1491            };
1492
1493            // Skip closed positions
1494            if position_size.is_zero() {
1495                continue;
1496            }
1497
1498            // Get oracle price, fallback to entry price
1499            let oracle_price = oracle_prices
1500                .get(&instrument_id)
1501                .copied()
1502                .or_else(|| Decimal::from_str(&position.entry_price).ok())
1503                .unwrap_or(Decimal::ZERO);
1504
1505            if oracle_price.is_zero() {
1506                log::warn!("No valid price for position {market_str}, skipping margin calculation");
1507                continue;
1508            }
1509
1510            // Calculate margins: margin_fraction * abs(size) * oracle_price
1511            let initial_margin = margin_init * position_size * oracle_price;
1512
1513            let maintenance_margin = margin_maint * position_size * oracle_price;
1514
1515            // Aggregate margins by currency
1516            let quote_currency = instrument.quote_currency();
1517            *initial_margins
1518                .entry(quote_currency)
1519                .or_insert(Decimal::ZERO) += initial_margin;
1520            *maintenance_margins
1521                .entry(quote_currency)
1522                .or_insert(Decimal::ZERO) += maintenance_margin;
1523        }
1524    }
1525
1526    // Create MarginBalance objects from aggregated margins
1527    for (currency, initial_margin) in initial_margins {
1528        let maintenance_margin = maintenance_margins
1529            .get(&currency)
1530            .copied()
1531            .unwrap_or(Decimal::ZERO);
1532
1533        let initial_money = Money::from_decimal(initial_margin, currency).context(format!(
1534            "Failed to create initial margin Money for {currency}"
1535        ))?;
1536        let maintenance_money = Money::from_decimal(maintenance_margin, currency).context(
1537            format!("Failed to create maintenance margin Money for {currency}"),
1538        )?;
1539
1540        // dYdX cross-margin margins are computed per collateral currency; emit as
1541        // account-wide entries keyed by that currency.
1542        let margin_balance = MarginBalance::new(initial_money, maintenance_money, None);
1543        margins.push(margin_balance);
1544    }
1545
1546    Ok(AccountState::new(
1547        account_id,
1548        AccountType::Margin, // dYdX uses cross-margin
1549        balances,
1550        margins,
1551        true, // is_reported - comes from venue
1552        UUID4::new(),
1553        ts_event,
1554        ts_init,
1555        None, // base_currency - dYdX settles in USDC
1556    ))
1557}
1558
1559/// Parse a dYdX HTTP [`Subaccount`] response into a Nautilus [`AccountState`].
1560///
1561/// This is the HTTP variant of [`parse_account_state`] which takes the WebSocket
1562/// `DydxSubaccountInfo` type (String fields). The HTTP `Subaccount` type uses
1563/// `Decimal` fields directly (parsed via `serde_as`), so no string-to-decimal
1564/// conversion is needed.
1565///
1566/// # Errors
1567///
1568/// Returns an error if balance or margin calculation fails.
1569pub fn parse_account_state_from_http(
1570    subaccount: &Subaccount,
1571    account_id: AccountId,
1572    instruments: &HashMap<InstrumentId, InstrumentAny>,
1573    oracle_prices: &HashMap<InstrumentId, Decimal>,
1574    ts_event: UnixNanos,
1575    ts_init: UnixNanos,
1576) -> anyhow::Result<AccountState> {
1577    let mut balances = Vec::new();
1578
1579    let equity = subaccount.equity;
1580    let free_collateral = subaccount.free_collateral;
1581
1582    // dYdX uses USDC as the settlement currency
1583    let currency = Currency::get_or_create_crypto_with_context("USDC", None);
1584
1585    let balance = AccountBalance::from_total_and_free(equity, free_collateral, currency)
1586        .context("failed to derive account balance from subaccount data")?;
1587    balances.push(balance);
1588
1589    // Calculate margin balances from open positions
1590    let mut margins = Vec::new();
1591    let mut initial_margins: HashMap<Currency, Decimal> = HashMap::new();
1592    let mut maintenance_margins: HashMap<Currency, Decimal> = HashMap::new();
1593
1594    for position in subaccount.open_perpetual_positions.values() {
1595        let market_str = position.market.as_str();
1596        let instrument_id = parse_instrument_id(market_str);
1597
1598        let instrument = match instruments.get(&instrument_id) {
1599            Some(inst) => inst,
1600            None => {
1601                log::warn!(
1602                    "Cannot calculate margin for position {market_str}: instrument not found"
1603                );
1604                continue;
1605            }
1606        };
1607
1608        let (margin_init, margin_maint) = match instrument {
1609            InstrumentAny::CryptoPerpetual(perp) => (perp.margin_init, perp.margin_maint),
1610            _ => {
1611                log::warn!(
1612                    "Instrument {instrument_id} is not a CryptoPerpetual, skipping margin calculation"
1613                );
1614                continue;
1615            }
1616        };
1617
1618        let position_size = position.size.abs();
1619
1620        if position_size.is_zero() {
1621            continue;
1622        }
1623
1624        // Get oracle price, fallback to entry price
1625        let oracle_price = oracle_prices
1626            .get(&instrument_id)
1627            .copied()
1628            .unwrap_or(position.entry_price);
1629
1630        if oracle_price.is_zero() {
1631            log::warn!("No valid price for position {market_str}, skipping margin calculation");
1632            continue;
1633        }
1634
1635        let initial_margin = margin_init * position_size * oracle_price;
1636        let maintenance_margin = margin_maint * position_size * oracle_price;
1637
1638        let quote_currency = instrument.quote_currency();
1639        *initial_margins
1640            .entry(quote_currency)
1641            .or_insert(Decimal::ZERO) += initial_margin;
1642        *maintenance_margins
1643            .entry(quote_currency)
1644            .or_insert(Decimal::ZERO) += maintenance_margin;
1645    }
1646
1647    for (currency, initial_margin) in initial_margins {
1648        let maintenance_margin = maintenance_margins
1649            .get(&currency)
1650            .copied()
1651            .unwrap_or(Decimal::ZERO);
1652
1653        let initial_money = Money::from_decimal(initial_margin, currency).context(format!(
1654            "Failed to create initial margin Money for {currency}"
1655        ))?;
1656        let maintenance_money = Money::from_decimal(maintenance_margin, currency).context(
1657            format!("Failed to create maintenance margin Money for {currency}"),
1658        )?;
1659
1660        let margin_balance = MarginBalance::new(initial_money, maintenance_money, None);
1661        margins.push(margin_balance);
1662    }
1663
1664    Ok(AccountState::new(
1665        account_id,
1666        AccountType::Margin,
1667        balances,
1668        margins,
1669        true, // is_reported - comes from venue
1670        UUID4::new(),
1671        ts_event,
1672        ts_init,
1673        None, // base_currency - dYdX settles in USDC
1674    ))
1675}
1676
1677#[cfg(test)]
1678mod reconciliation_tests {
1679    use chrono::Utc;
1680    use nautilus_model::{
1681        enums::{OrderSide, OrderStatus, TimeInForce},
1682        identifiers::{AccountId, InstrumentId, Symbol},
1683        instruments::{CryptoPerpetual, Instrument},
1684        types::Currency,
1685    };
1686    use rstest::rstest;
1687    use rust_decimal::prelude::ToPrimitive;
1688    use rust_decimal_macros::dec;
1689    use ustr::Ustr;
1690
1691    use super::*;
1692    use crate::common::consts::DYDX_VENUE;
1693
1694    fn create_test_instrument() -> InstrumentAny {
1695        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), *DYDX_VENUE);
1696
1697        InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1698            instrument_id,
1699            instrument_id.symbol,
1700            Currency::BTC(),
1701            Currency::USD(),
1702            Currency::USD(),
1703            false,
1704            2,                                // price_precision
1705            8,                                // size_precision
1706            Price::new(0.01, 2),              // price_increment
1707            Quantity::new(0.001, 8),          // size_increment
1708            Some(Quantity::new(1.0, 0)),      // multiplier
1709            Some(Quantity::new(0.001, 8)),    // lot_size
1710            Some(Quantity::new(100000.0, 8)), // max_quantity
1711            Some(Quantity::new(0.001, 8)),    // min_quantity
1712            None,                             // max_notional
1713            None,                             // min_notional
1714            Some(Price::new(1000000.0, 2)),   // max_price
1715            Some(Price::new(0.01, 2)),        // min_price
1716            Some(dec!(0.05)),                 // margin_init
1717            Some(dec!(0.03)),                 // margin_maint
1718            Some(dec!(0.0002)),               // maker_fee
1719            Some(dec!(0.0005)),               // taker_fee
1720            None,                             // tick_scheme
1721            None,                             // info: Option<Params>
1722            UnixNanos::default(),             // ts_event
1723            UnixNanos::default(),             // ts_init
1724        ))
1725    }
1726
1727    #[rstest]
1728    fn test_parse_order_status() {
1729        assert_eq!(
1730            parse_order_status(&DydxOrderStatus::Open),
1731            OrderStatus::Accepted
1732        );
1733        assert_eq!(
1734            parse_order_status(&DydxOrderStatus::Filled),
1735            OrderStatus::Filled
1736        );
1737        assert_eq!(
1738            parse_order_status(&DydxOrderStatus::Canceled),
1739            OrderStatus::Canceled
1740        );
1741        assert_eq!(
1742            parse_order_status(&DydxOrderStatus::PartiallyFilled),
1743            OrderStatus::PartiallyFilled
1744        );
1745        assert_eq!(
1746            parse_order_status(&DydxOrderStatus::Untriggered),
1747            OrderStatus::Accepted
1748        );
1749    }
1750
1751    #[rstest]
1752    fn test_parse_order_status_report_basic() {
1753        let instrument = create_test_instrument();
1754        let account_id = AccountId::new("DYDX-001");
1755        let ts_init = UnixNanos::default();
1756
1757        let order = Order {
1758            id: "order123".to_string(),
1759            subaccount_id: "subacct1".to_string(),
1760            client_id: "client1".to_string(),
1761            clob_pair_id: 1,
1762            side: OrderSide::Buy,
1763            size: dec!(1.5),
1764            total_filled: dec!(1.0),
1765            price: dec!(50000.0),
1766            status: DydxOrderStatus::PartiallyFilled,
1767            order_type: DydxOrderType::Limit,
1768            time_in_force: DydxTimeInForce::Gtt,
1769            reduce_only: false,
1770            post_only: false,
1771            order_flags: 0,
1772            good_til_block: None,
1773            good_til_block_time: Some(Utc::now()),
1774            created_at_height: Some(1000),
1775            client_metadata: 0,
1776            trigger_price: None,
1777            condition_type: None,
1778            conditional_order_trigger_subticks: None,
1779            execution: None,
1780            updated_at: Some(Utc::now()),
1781            updated_at_height: Some(1001),
1782            ticker: None,
1783            subaccount_number: 0,
1784            order_router_address: None,
1785        };
1786
1787        let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1788        if let Err(ref e) = result {
1789            eprintln!("Parse error: {e:?}");
1790        }
1791        assert!(result.is_ok());
1792
1793        let report = result.unwrap();
1794        assert_eq!(report.account_id, account_id);
1795        assert_eq!(report.instrument_id, instrument.id());
1796        assert_eq!(report.order_side, OrderSide::Buy);
1797        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1798        assert_eq!(report.time_in_force, TimeInForce::Gtc);
1799    }
1800
1801    #[rstest]
1802    fn test_parse_order_status_report_conditional() {
1803        let instrument = create_test_instrument();
1804        let account_id = AccountId::new("DYDX-001");
1805        let ts_init = UnixNanos::default();
1806
1807        let order = Order {
1808            id: "order456".to_string(),
1809            subaccount_id: "subacct1".to_string(),
1810            client_id: String::new(), // Empty client ID
1811            clob_pair_id: 1,
1812            side: OrderSide::Sell,
1813            size: dec!(2.0),
1814            total_filled: dec!(0.0),
1815            price: dec!(51000.0),
1816            status: DydxOrderStatus::Untriggered,
1817            order_type: DydxOrderType::StopLimit,
1818            time_in_force: DydxTimeInForce::Gtt,
1819            reduce_only: true,
1820            post_only: false,
1821            order_flags: 0,
1822            good_til_block: None,
1823            good_til_block_time: Some(Utc::now()),
1824            created_at_height: Some(1000),
1825            client_metadata: 0,
1826            trigger_price: Some(dec!(49000.0)),
1827            condition_type: Some(DydxConditionType::StopLoss),
1828            conditional_order_trigger_subticks: Some(490000),
1829            execution: None,
1830            updated_at: Some(Utc::now()),
1831            updated_at_height: Some(1001),
1832            ticker: None,
1833            subaccount_number: 0,
1834            order_router_address: None,
1835        };
1836
1837        let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1838        assert!(result.is_ok());
1839
1840        let report = result.unwrap();
1841        assert_eq!(report.client_order_id, None);
1842        assert!(report.trigger_price.is_some());
1843        assert_eq!(report.trigger_price.unwrap().as_f64(), 49000.0);
1844    }
1845
1846    /// dYdX reports a long-term order that crossed `good_til_block_time`
1847    /// as `Canceled`. The parser must reclassify these to `Expired` so
1848    /// reconciliation surfaces `OrderExpired`, matching the WS dispatch path.
1849    #[rstest]
1850    fn test_parse_order_status_report_canceled_after_expiry_becomes_expired() {
1851        use chrono::Duration;
1852
1853        let instrument = create_test_instrument();
1854        let account_id = AccountId::new("DYDX-001");
1855        let now = Utc::now();
1856        let ts_init = UnixNanos::from(now.timestamp_millis() as u64 * 1_000_000);
1857
1858        // good_til_block_time is one hour in the past; updated_at after it.
1859        let expired_at = now - Duration::hours(1);
1860
1861        let order = Order {
1862            id: "order-expired".to_string(),
1863            subaccount_id: "subacct1".to_string(),
1864            client_id: "client1".to_string(),
1865            clob_pair_id: 1,
1866            side: OrderSide::Buy,
1867            size: dec!(1.0),
1868            total_filled: dec!(0),
1869            price: dec!(50000.0),
1870            status: DydxOrderStatus::Canceled,
1871            order_type: DydxOrderType::Limit,
1872            time_in_force: DydxTimeInForce::Gtt,
1873            reduce_only: false,
1874            post_only: false,
1875            order_flags: 0,
1876            good_til_block: None,
1877            good_til_block_time: Some(expired_at),
1878            created_at_height: Some(1000),
1879            client_metadata: 0,
1880            trigger_price: None,
1881            condition_type: None,
1882            conditional_order_trigger_subticks: None,
1883            execution: None,
1884            updated_at: Some(now),
1885            updated_at_height: Some(1001),
1886            ticker: None,
1887            subaccount_number: 0,
1888            order_router_address: None,
1889        };
1890
1891        let report = parse_order_status_report(&order, &instrument, account_id, ts_init).unwrap();
1892        assert_eq!(report.order_status, OrderStatus::Expired);
1893        assert!(report.expire_time.is_some());
1894    }
1895
1896    /// A `Canceled` order whose `good_til_block_time` is still in the future
1897    /// must remain `Canceled` (user/system cancel, not expiry).
1898    #[rstest]
1899    fn test_parse_order_status_report_canceled_before_expiry_stays_canceled() {
1900        use chrono::Duration;
1901
1902        let instrument = create_test_instrument();
1903        let account_id = AccountId::new("DYDX-001");
1904        let now = Utc::now();
1905        let ts_init = UnixNanos::from(now.timestamp_millis() as u64 * 1_000_000);
1906        let future_expiry = now + Duration::hours(1);
1907
1908        let order = Order {
1909            id: "order-cancel".to_string(),
1910            subaccount_id: "subacct1".to_string(),
1911            client_id: "client1".to_string(),
1912            clob_pair_id: 1,
1913            side: OrderSide::Buy,
1914            size: dec!(1.0),
1915            total_filled: dec!(0),
1916            price: dec!(50000.0),
1917            status: DydxOrderStatus::Canceled,
1918            order_type: DydxOrderType::Limit,
1919            time_in_force: DydxTimeInForce::Gtt,
1920            reduce_only: false,
1921            post_only: false,
1922            order_flags: 0,
1923            good_til_block: None,
1924            good_til_block_time: Some(future_expiry),
1925            created_at_height: Some(1000),
1926            client_metadata: 0,
1927            trigger_price: None,
1928            condition_type: None,
1929            conditional_order_trigger_subticks: None,
1930            execution: None,
1931            updated_at: Some(now),
1932            updated_at_height: Some(1001),
1933            ticker: None,
1934            subaccount_number: 0,
1935            order_router_address: None,
1936        };
1937
1938        let report = parse_order_status_report(&order, &instrument, account_id, ts_init).unwrap();
1939        assert_eq!(report.order_status, OrderStatus::Canceled);
1940    }
1941
1942    // dYdX's Indexer collapses both submitted variants (TakeProfitMarket,
1943    // TakeProfitLimit) under `TAKE_PROFIT`. The parser disambiguates by drift:
1944    // a price `>= 2%` away from the trigger means the original was a market-style
1945    // pay-through order, so we reclassify to MarketIfTouched. The companion
1946    // `dydx_order_type` reclassification ensures the resulting TIF is IOC for
1947    // MIT (vs the default Gtc the LimitIfTouched branch returns).
1948    #[rstest]
1949    #[case(OrderSide::Buy, dec!(50000.0), dec!(50100.0), OrderType::LimitIfTouched, TimeInForce::Gtc)]
1950    #[case(OrderSide::Buy, dec!(50000.0), dec!(52500.0), OrderType::MarketIfTouched, TimeInForce::Ioc)]
1951    #[case(OrderSide::Sell, dec!(50000.0), dec!(49900.0), OrderType::LimitIfTouched, TimeInForce::Gtc)]
1952    #[case(OrderSide::Sell, dec!(50000.0), dec!(47500.0), OrderType::MarketIfTouched, TimeInForce::Ioc)]
1953    #[case(OrderSide::Buy, dec!(50000.0), dec!(51000.0), OrderType::MarketIfTouched, TimeInForce::Ioc)]
1954    fn test_parse_order_status_report_take_profit_disambiguation(
1955        #[case] side: OrderSide,
1956        #[case] trigger: rust_decimal::Decimal,
1957        #[case] price: rust_decimal::Decimal,
1958        #[case] expected_type: OrderType,
1959        #[case] expected_tif: TimeInForce,
1960    ) {
1961        let instrument = create_test_instrument();
1962        let account_id = AccountId::new("DYDX-001");
1963        let ts_init = UnixNanos::default();
1964
1965        let order = Order {
1966            id: "order-tp".to_string(),
1967            subaccount_id: "subacct1".to_string(),
1968            client_id: "client1".to_string(),
1969            clob_pair_id: 1,
1970            side,
1971            size: dec!(1.0),
1972            total_filled: dec!(0),
1973            price,
1974            status: DydxOrderStatus::Untriggered,
1975            order_type: DydxOrderType::TakeProfitLimit,
1976            time_in_force: DydxTimeInForce::Gtt,
1977            reduce_only: false,
1978            post_only: false,
1979            order_flags: 0,
1980            good_til_block: None,
1981            good_til_block_time: Some(Utc::now()),
1982            created_at_height: Some(1000),
1983            client_metadata: 0,
1984            trigger_price: Some(trigger),
1985            condition_type: None,
1986            conditional_order_trigger_subticks: Some(490_000),
1987            execution: None,
1988            updated_at: Some(Utc::now()),
1989            updated_at_height: Some(1001),
1990            ticker: None,
1991            subaccount_number: 0,
1992            order_router_address: None,
1993        };
1994
1995        let report = parse_order_status_report(&order, &instrument, account_id, ts_init).unwrap();
1996        assert_eq!(report.order_type, expected_type);
1997        assert_eq!(report.time_in_force, expected_tif);
1998    }
1999
2000    // When the dYdX Indexer omits `condition_type` (typical for WebSocket-fed
2001    // reports rebuilt through this parser) but a trigger price is set, the
2002    // parser must default to `TriggerType::Default` so the Python
2003    // `OrderStatusReport.__init__` validator accepts the report. Without this
2004    // default, reports historically failed reconciliation with
2005    // `Condition.not_equal(trigger_type, NO_TRIGGER, ...)`.
2006    #[rstest]
2007    fn test_parse_order_status_report_default_trigger_type_when_condition_none() {
2008        let instrument = create_test_instrument();
2009        let account_id = AccountId::new("DYDX-001");
2010        let ts_init = UnixNanos::default();
2011
2012        let order = Order {
2013            id: "order-default-trigger".to_string(),
2014            subaccount_id: "subacct1".to_string(),
2015            client_id: "client1".to_string(),
2016            clob_pair_id: 1,
2017            side: OrderSide::Buy,
2018            size: dec!(1.0),
2019            total_filled: dec!(0),
2020            price: dec!(50000.0),
2021            status: DydxOrderStatus::Untriggered,
2022            order_type: DydxOrderType::StopLimit,
2023            time_in_force: DydxTimeInForce::Gtt,
2024            reduce_only: false,
2025            post_only: false,
2026            order_flags: 0,
2027            good_til_block: None,
2028            good_til_block_time: Some(Utc::now()),
2029            created_at_height: Some(1000),
2030            client_metadata: 0,
2031            trigger_price: Some(dec!(49000.0)),
2032            condition_type: None,
2033            conditional_order_trigger_subticks: Some(490_000),
2034            execution: None,
2035            updated_at: Some(Utc::now()),
2036            updated_at_height: Some(1001),
2037            ticker: None,
2038            subaccount_number: 0,
2039            order_router_address: None,
2040        };
2041
2042        let report = parse_order_status_report(&order, &instrument, account_id, ts_init).unwrap();
2043        assert_eq!(report.trigger_type, Some(TriggerType::Default));
2044    }
2045
2046    // A `Canceled` report whose `ts_last` matches the expiry boundary exactly
2047    // must still reclassify to `Expired`. Locks the `>=` semantics from
2048    // accidentally drifting to `>`.
2049    #[rstest]
2050    fn test_parse_order_status_report_canceled_at_expiry_boundary_becomes_expired() {
2051        let instrument = create_test_instrument();
2052        let account_id = AccountId::new("DYDX-001");
2053        let expire_at = Utc::now();
2054        let ts_init = UnixNanos::from(expire_at.timestamp_millis() as u64 * 1_000_000);
2055
2056        let order = Order {
2057            id: "order-expired-boundary".to_string(),
2058            subaccount_id: "subacct1".to_string(),
2059            client_id: "client1".to_string(),
2060            clob_pair_id: 1,
2061            side: OrderSide::Buy,
2062            size: dec!(1.0),
2063            total_filled: dec!(0),
2064            price: dec!(50000.0),
2065            status: DydxOrderStatus::Canceled,
2066            order_type: DydxOrderType::Limit,
2067            time_in_force: DydxTimeInForce::Gtt,
2068            reduce_only: false,
2069            post_only: false,
2070            order_flags: 0,
2071            good_til_block: None,
2072            good_til_block_time: Some(expire_at),
2073            created_at_height: Some(1000),
2074            client_metadata: 0,
2075            trigger_price: None,
2076            condition_type: None,
2077            conditional_order_trigger_subticks: None,
2078            execution: None,
2079            updated_at: Some(expire_at),
2080            updated_at_height: Some(1001),
2081            ticker: None,
2082            subaccount_number: 0,
2083            order_router_address: None,
2084        };
2085
2086        let report = parse_order_status_report(&order, &instrument, account_id, ts_init).unwrap();
2087        assert_eq!(report.order_status, OrderStatus::Expired);
2088    }
2089
2090    #[rstest]
2091    fn test_parse_fill_report() {
2092        let instrument = create_test_instrument();
2093        let account_id = AccountId::new("DYDX-001");
2094        let ts_init = UnixNanos::default();
2095
2096        let fill = Fill {
2097            id: "fill789".to_string(),
2098            side: OrderSide::Buy,
2099            liquidity: DydxLiquidity::Taker,
2100            fill_type: DydxFillType::Limit,
2101            market: Ustr::from("BTC-USD"),
2102            market_type: DydxTickerType::Perpetual,
2103            price: dec!(50100.0),
2104            size: dec!(1.0),
2105            fee: dec!(-5.01),
2106            created_at: Utc::now(),
2107            created_at_height: 1000,
2108            order_id: "order123".to_string(),
2109            client_metadata: 0,
2110        };
2111
2112        let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
2113        assert!(result.is_ok());
2114
2115        let report = result.unwrap();
2116        assert_eq!(report.account_id, account_id);
2117        assert_eq!(report.order_side, OrderSide::Buy);
2118        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
2119        assert_eq!(report.last_px.as_f64(), 50100.0);
2120        assert_eq!(report.commission.as_decimal(), dec!(-5.01));
2121    }
2122
2123    #[rstest]
2124    fn test_parse_position_status_report_long() {
2125        let instrument = create_test_instrument();
2126        let account_id = AccountId::new("DYDX-001");
2127        let ts_init = UnixNanos::default();
2128
2129        let position = PerpetualPosition {
2130            market: Ustr::from("BTC-USD"),
2131            status: DydxPositionStatus::Open,
2132            side: DydxPositionSide::Long,
2133            size: dec!(2.5),
2134            max_size: dec!(3.0),
2135            entry_price: dec!(49500.0),
2136            exit_price: None,
2137            realized_pnl: dec!(100.0),
2138            created_at_height: 1000,
2139            created_at: Utc::now(),
2140            sum_open: dec!(2.5),
2141            sum_close: dec!(0.0),
2142            net_funding: dec!(-2.5),
2143            unrealized_pnl: dec!(250.0),
2144            closed_at: None,
2145        };
2146
2147        let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
2148        assert!(result.is_ok());
2149
2150        let report = result.unwrap();
2151        assert_eq!(report.account_id, account_id);
2152        assert_eq!(report.position_side, PositionSide::Long.as_specified());
2153        assert_eq!(report.quantity.as_f64(), 2.5);
2154        assert_eq!(report.avg_px_open.unwrap().to_f64().unwrap(), 49500.0);
2155    }
2156
2157    #[rstest]
2158    fn test_parse_position_status_report_short() {
2159        let instrument = create_test_instrument();
2160        let account_id = AccountId::new("DYDX-001");
2161        let ts_init = UnixNanos::default();
2162
2163        let position = PerpetualPosition {
2164            market: Ustr::from("BTC-USD"),
2165            status: DydxPositionStatus::Open,
2166            side: DydxPositionSide::Short,
2167            size: dec!(-1.5),
2168            max_size: dec!(1.5),
2169            entry_price: dec!(51000.0),
2170            exit_price: None,
2171            realized_pnl: dec!(0.0),
2172            created_at_height: 1000,
2173            created_at: Utc::now(),
2174            sum_open: dec!(1.5),
2175            sum_close: dec!(0.0),
2176            net_funding: dec!(1.2),
2177            unrealized_pnl: dec!(-150.0),
2178            closed_at: None,
2179        };
2180
2181        let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
2182        assert!(result.is_ok());
2183
2184        let report = result.unwrap();
2185        assert_eq!(report.position_side, PositionSide::Short.as_specified());
2186        assert_eq!(report.quantity.as_f64(), 1.5);
2187    }
2188
2189    #[rstest]
2190    fn test_parse_position_status_report_flat() {
2191        let instrument = create_test_instrument();
2192        let account_id = AccountId::new("DYDX-001");
2193        let ts_init = UnixNanos::default();
2194
2195        let position = PerpetualPosition {
2196            market: Ustr::from("BTC-USD"),
2197            status: DydxPositionStatus::Closed,
2198            side: DydxPositionSide::Long,
2199            size: dec!(0.0),
2200            max_size: dec!(2.0),
2201            entry_price: dec!(50000.0),
2202            exit_price: Some(dec!(51000.0)),
2203            realized_pnl: dec!(500.0),
2204            created_at_height: 1000,
2205            created_at: Utc::now(),
2206            sum_open: dec!(2.0),
2207            sum_close: dec!(2.0),
2208            net_funding: dec!(-5.0),
2209            unrealized_pnl: dec!(0.0),
2210            closed_at: Some(Utc::now()),
2211        };
2212
2213        let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
2214        assert!(result.is_ok());
2215
2216        let report = result.unwrap();
2217        assert_eq!(report.position_side, PositionSide::Flat.as_specified());
2218        assert_eq!(report.quantity.as_f64(), 0.0);
2219    }
2220
2221    /// Test external order detection (orders not created by this client)
2222    #[rstest]
2223    fn test_parse_order_external_detection() {
2224        let instrument = create_test_instrument();
2225        let account_id = AccountId::new("DYDX-001");
2226        let ts_init = UnixNanos::default();
2227
2228        // External order: created by different client (e.g., web UI)
2229        let order = Order {
2230            id: "external-order-123".to_string(),
2231            subaccount_id: "dydx1test/0".to_string(),
2232            client_id: "99999".to_string(),
2233            clob_pair_id: 1,
2234            side: OrderSide::Buy,
2235            size: dec!(0.5),
2236            total_filled: dec!(0.0),
2237            price: dec!(50000.0),
2238            status: DydxOrderStatus::Open,
2239            order_type: DydxOrderType::Limit,
2240            time_in_force: DydxTimeInForce::Gtt,
2241            reduce_only: false,
2242            post_only: false,
2243            order_flags: 0,
2244            good_til_block: Some(1000),
2245            good_til_block_time: None,
2246            created_at_height: Some(900),
2247            client_metadata: 0,
2248            trigger_price: None,
2249            condition_type: None,
2250            conditional_order_trigger_subticks: None,
2251            execution: None,
2252            updated_at: Some(Utc::now()),
2253            updated_at_height: Some(900),
2254            ticker: None,
2255            subaccount_number: 0,
2256            order_router_address: None,
2257        };
2258
2259        let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
2260        assert!(result.is_ok());
2261
2262        let report = result.unwrap();
2263        assert_eq!(report.account_id, account_id);
2264        assert_eq!(report.order_status, OrderStatus::Accepted);
2265        // External orders should still be reconciled correctly
2266        assert_eq!(report.filled_qty.as_f64(), 0.0);
2267    }
2268
2269    /// Test order reconciliation with partial fills
2270    #[rstest]
2271    fn test_parse_order_partial_fill_reconciliation() {
2272        let instrument = create_test_instrument();
2273        let account_id = AccountId::new("DYDX-001");
2274        let ts_init = UnixNanos::default();
2275
2276        let order = Order {
2277            id: "partial-order-123".to_string(),
2278            subaccount_id: "dydx1test/0".to_string(),
2279            client_id: "12345".to_string(),
2280            clob_pair_id: 1,
2281            side: OrderSide::Buy,
2282            size: dec!(2.0),
2283            total_filled: dec!(0.75),
2284            price: dec!(50000.0),
2285            status: DydxOrderStatus::PartiallyFilled,
2286            order_type: DydxOrderType::Limit,
2287            time_in_force: DydxTimeInForce::Gtt,
2288            reduce_only: false,
2289            post_only: false,
2290            order_flags: 0,
2291            good_til_block: Some(2000),
2292            good_til_block_time: None,
2293            created_at_height: Some(1500),
2294            client_metadata: 0,
2295            trigger_price: None,
2296            condition_type: None,
2297            conditional_order_trigger_subticks: None,
2298            execution: None,
2299            updated_at: Some(Utc::now()),
2300            updated_at_height: Some(1600),
2301            ticker: None,
2302            subaccount_number: 0,
2303            order_router_address: None,
2304        };
2305
2306        let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
2307        assert!(result.is_ok());
2308
2309        let report = result.unwrap();
2310        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2311        assert_eq!(report.filled_qty.as_f64(), 0.75);
2312        assert_eq!(report.quantity.as_f64(), 2.0);
2313    }
2314
2315    /// Test reconciliation with multiple positions (long and short)
2316    #[rstest]
2317    fn test_parse_multiple_positions() {
2318        let instrument = create_test_instrument();
2319        let account_id = AccountId::new("DYDX-001");
2320        let ts_init = UnixNanos::default();
2321
2322        // Position 1: Long position
2323        let long_position = PerpetualPosition {
2324            market: Ustr::from("BTC-USD"),
2325            status: DydxPositionStatus::Open,
2326            side: DydxPositionSide::Long,
2327            size: dec!(1.5),
2328            max_size: dec!(1.5),
2329            entry_price: dec!(49000.0),
2330            exit_price: None,
2331            realized_pnl: dec!(0.0),
2332            created_at_height: 1000,
2333            created_at: Utc::now(),
2334            sum_open: dec!(1.5),
2335            sum_close: dec!(0.0),
2336            net_funding: dec!(-1.0),
2337            unrealized_pnl: dec!(150.0),
2338            closed_at: None,
2339        };
2340
2341        let result1 =
2342            parse_position_status_report(&long_position, &instrument, account_id, ts_init);
2343        assert!(result1.is_ok());
2344        let report1 = result1.unwrap();
2345        assert_eq!(report1.position_side, PositionSide::Long.as_specified());
2346
2347        // Position 2: Short position (should be handled separately if from different market)
2348        let short_position = PerpetualPosition {
2349            market: Ustr::from("BTC-USD"),
2350            status: DydxPositionStatus::Open,
2351            side: DydxPositionSide::Short,
2352            size: dec!(-2.0),
2353            max_size: dec!(2.0),
2354            entry_price: dec!(51000.0),
2355            exit_price: None,
2356            realized_pnl: dec!(0.0),
2357            created_at_height: 1100,
2358            created_at: Utc::now(),
2359            sum_open: dec!(2.0),
2360            sum_close: dec!(0.0),
2361            net_funding: dec!(0.5),
2362            unrealized_pnl: dec!(-200.0),
2363            closed_at: None,
2364        };
2365
2366        let result2 =
2367            parse_position_status_report(&short_position, &instrument, account_id, ts_init);
2368        assert!(result2.is_ok());
2369        let report2 = result2.unwrap();
2370        assert_eq!(report2.position_side, PositionSide::Short.as_specified());
2371    }
2372
2373    /// Test fill reconciliation with zero fee
2374    #[rstest]
2375    fn test_parse_fill_zero_fee() {
2376        let instrument = create_test_instrument();
2377        let account_id = AccountId::new("DYDX-001");
2378        let ts_init = UnixNanos::default();
2379
2380        let fill = Fill {
2381            id: "fill-zero-fee".to_string(),
2382            side: OrderSide::Sell,
2383            liquidity: DydxLiquidity::Maker,
2384            fill_type: DydxFillType::Limit,
2385            market: Ustr::from("BTC-USD"),
2386            market_type: DydxTickerType::Perpetual,
2387            price: dec!(50000.0),
2388            size: dec!(0.1),
2389            fee: dec!(0.0), // Zero fee (e.g., fee rebate or promotional period)
2390            created_at: Utc::now(),
2391            created_at_height: 1000,
2392            order_id: "order-zero-fee".to_string(),
2393            client_metadata: 0,
2394        };
2395
2396        let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
2397        assert!(result.is_ok());
2398
2399        let report = result.unwrap();
2400        assert_eq!(report.commission.as_f64(), 0.0);
2401    }
2402
2403    /// Test fill reconciliation with maker rebate (negative fee)
2404    #[rstest]
2405    fn test_parse_fill_maker_rebate() {
2406        let instrument = create_test_instrument();
2407        let account_id = AccountId::new("DYDX-001");
2408        let ts_init = UnixNanos::default();
2409
2410        let fill = Fill {
2411            id: "fill-maker-rebate".to_string(),
2412            side: OrderSide::Buy,
2413            liquidity: DydxLiquidity::Maker,
2414            fill_type: DydxFillType::Limit,
2415            market: Ustr::from("BTC-USD"),
2416            market_type: DydxTickerType::Perpetual,
2417            price: dec!(50000.0),
2418            size: dec!(1.0),
2419            fee: dec!(-2.5), // Negative fee = rebate
2420            created_at: Utc::now(),
2421            created_at_height: 1000,
2422            order_id: "order-maker-rebate".to_string(),
2423            client_metadata: 0,
2424        };
2425
2426        let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
2427        assert!(result.is_ok());
2428
2429        let report = result.unwrap();
2430        assert_eq!(report.commission.as_decimal(), dec!(-2.5));
2431        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
2432    }
2433
2434    #[rstest]
2435    fn test_parse_account_state_empty_balance() {
2436        use crate::websocket::messages::DydxSubaccountInfo;
2437
2438        let subaccount = DydxSubaccountInfo {
2439            address: "dydx1abc".to_string(),
2440            subaccount_number: 0,
2441            equity: String::new(),
2442            free_collateral: String::new(),
2443            open_perpetual_positions: None,
2444            asset_positions: None,
2445            margin_enabled: true,
2446            updated_at_height: "0".to_string(),
2447            latest_processed_block_height: "0".to_string(),
2448        };
2449
2450        let account_id = AccountId::new("DYDX-001");
2451        let instruments = std::collections::HashMap::new();
2452        let oracle_prices = std::collections::HashMap::new();
2453        let ts = UnixNanos::default();
2454
2455        let state = parse_account_state(
2456            &subaccount,
2457            account_id,
2458            &instruments,
2459            &oracle_prices,
2460            ts,
2461            ts,
2462        )
2463        .unwrap();
2464
2465        assert_eq!(state.account_id, account_id);
2466        assert_eq!(state.balances.len(), 1);
2467        let balance = &state.balances[0];
2468        assert_eq!(balance.total.as_f64(), 0.0);
2469        assert_eq!(balance.free.as_f64(), 0.0);
2470        assert_eq!(balance.locked.as_f64(), 0.0);
2471    }
2472
2473    #[rstest]
2474    fn test_parse_account_state_nonzero_balance() {
2475        use crate::websocket::messages::DydxSubaccountInfo;
2476
2477        // Exercises the `from_total_and_free(equity, free_collateral, USDC)` path
2478        // in the WebSocket subaccount parser, locking in the argument order so a
2479        // later swap would fail.
2480        let subaccount = DydxSubaccountInfo {
2481            address: "dydx1abc".to_string(),
2482            subaccount_number: 0,
2483            equity: "15000".to_string(),
2484            free_collateral: "12500".to_string(),
2485            open_perpetual_positions: None,
2486            asset_positions: None,
2487            margin_enabled: true,
2488            updated_at_height: "0".to_string(),
2489            latest_processed_block_height: "0".to_string(),
2490        };
2491
2492        let account_id = AccountId::new("DYDX-001");
2493        let instruments = std::collections::HashMap::new();
2494        let oracle_prices = std::collections::HashMap::new();
2495        let ts = UnixNanos::default();
2496
2497        let state = parse_account_state(
2498            &subaccount,
2499            account_id,
2500            &instruments,
2501            &oracle_prices,
2502            ts,
2503            ts,
2504        )
2505        .unwrap();
2506
2507        assert_eq!(state.balances.len(), 1);
2508        let balance = &state.balances[0];
2509        assert_eq!(balance.currency.code.as_str(), "USDC");
2510        assert_eq!(balance.total.as_decimal(), dec!(15000));
2511        assert_eq!(balance.free.as_decimal(), dec!(12500));
2512        assert_eq!(balance.locked.as_decimal(), dec!(2500));
2513    }
2514
2515    #[rstest]
2516    fn test_parse_account_state_from_http_nonzero_balance() {
2517        use crate::http::models::Subaccount;
2518
2519        // Exercises the HTTP variant of the subaccount parser. Both variants
2520        // route through `from_total_and_free(equity, free_collateral, …)`, so a
2521        // swap in either path must be caught independently.
2522        let subaccount = Subaccount {
2523            address: "dydx1abc".to_string(),
2524            subaccount_number: 0,
2525            equity: dec!(15000),
2526            free_collateral: dec!(12500),
2527            open_perpetual_positions: std::collections::HashMap::new(),
2528            asset_positions: std::collections::HashMap::new(),
2529            margin_enabled: true,
2530            updated_at_height: 0,
2531            latest_processed_block_height: None,
2532        };
2533
2534        let account_id = AccountId::new("DYDX-001");
2535        let instruments = std::collections::HashMap::new();
2536        let oracle_prices = std::collections::HashMap::new();
2537        let ts = UnixNanos::default();
2538
2539        let state = parse_account_state_from_http(
2540            &subaccount,
2541            account_id,
2542            &instruments,
2543            &oracle_prices,
2544            ts,
2545            ts,
2546        )
2547        .unwrap();
2548
2549        assert_eq!(state.balances.len(), 1);
2550        let balance = &state.balances[0];
2551        assert_eq!(balance.currency.code.as_str(), "USDC");
2552        assert_eq!(balance.total.as_decimal(), dec!(15000));
2553        assert_eq!(balance.free.as_decimal(), dec!(12500));
2554        assert_eq!(balance.locked.as_decimal(), dec!(2500));
2555    }
2556}