nautilus_dydx/http/
parse.rs

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