Skip to main content

nautilus_dydx/http/
parse.rs

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