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