nautilus_dydx/http/
parse.rs

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