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<rust_decimal::Decimal>,
215    taker_fee: Option<rust_decimal::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
808////////////////////////////////////////////////////////////////////////////////
809// Order, Fill, and Position Report Parsing
810////////////////////////////////////////////////////////////////////////////////
811
812use std::str::FromStr;
813
814use nautilus_core::UUID4;
815use nautilus_model::{
816    enums::{LiquiditySide, OrderStatus, PositionSide, TriggerType},
817    identifiers::{AccountId, ClientOrderId, PositionId, TradeId, VenueOrderId},
818    instruments::Instrument,
819    reports::{FillReport, OrderStatusReport, PositionStatusReport},
820    types::{Money, Price, Quantity},
821};
822use rust_decimal::prelude::ToPrimitive;
823
824use super::models::{Fill, Order, PerpetualPosition};
825use crate::common::enums::{DydxLiquidity, DydxOrderStatus};
826
827/// Map dYdX order status to Nautilus OrderStatus.
828fn parse_order_status(status: &DydxOrderStatus) -> OrderStatus {
829    match status {
830        DydxOrderStatus::Open => OrderStatus::Accepted,
831        DydxOrderStatus::Filled => OrderStatus::Filled,
832        DydxOrderStatus::Canceled => OrderStatus::Canceled,
833        DydxOrderStatus::BestEffortCanceled => OrderStatus::Canceled,
834        DydxOrderStatus::Untriggered => OrderStatus::Accepted, // Conditional orders waiting for trigger
835        DydxOrderStatus::BestEffortOpened => OrderStatus::Accepted,
836        DydxOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
837    }
838}
839
840/// Parse a dYdX Order into a Nautilus OrderStatusReport.
841///
842/// # Errors
843///
844/// Returns an error if required fields are missing or invalid.
845pub fn parse_order_status_report(
846    order: &Order,
847    instrument: &InstrumentAny,
848    account_id: AccountId,
849    ts_init: UnixNanos,
850) -> anyhow::Result<OrderStatusReport> {
851    let instrument_id = instrument.id();
852    let venue_order_id = VenueOrderId::new(&order.id);
853    let client_order_id = if order.client_id.is_empty() {
854        None
855    } else {
856        Some(ClientOrderId::new(&order.client_id))
857    };
858
859    // Parse order type and time-in-force
860    let order_type = order.order_type.into();
861
862    let execution = order.execution.or({
863        // Infer execution type from post_only flag if not explicitly set
864        if order.post_only {
865            Some(DydxOrderExecution::PostOnly)
866        } else {
867            Some(DydxOrderExecution::Default)
868        }
869    });
870    let time_in_force = calculate_time_in_force(
871        order.order_type,
872        order.time_in_force,
873        order.reduce_only,
874        execution,
875    )?;
876
877    let order_side = order.side;
878    let order_status = parse_order_status(&order.status);
879
880    // Parse quantities using Nautilus types directly
881    let size_precision = instrument.size_precision();
882    let quantity = Quantity::new(
883        order
884            .size
885            .to_f64()
886            .context("failed to convert order size to f64")?,
887        size_precision,
888    );
889    let filled_qty = Quantity::new(
890        order
891            .total_filled
892            .to_f64()
893            .context("failed to convert total_filled to f64")?,
894        size_precision,
895    );
896
897    // Parse price using Nautilus types directly
898    let price_precision = instrument.price_precision();
899    let price = Price::new(
900        order
901            .price
902            .to_f64()
903            .context("failed to convert order price to f64")?,
904        price_precision,
905    );
906
907    // Parse timestamps
908    let ts_accepted = order.good_til_block_time.map_or(ts_init, |dt| {
909        UnixNanos::from(dt.timestamp_millis() as u64 * 1_000_000)
910    });
911    let ts_last = order.updated_at.map_or(ts_init, |dt| {
912        UnixNanos::from(dt.timestamp_millis() as u64 * 1_000_000)
913    });
914
915    // Build the report
916    let mut report = OrderStatusReport::new(
917        account_id,
918        instrument_id,
919        client_order_id,
920        venue_order_id,
921        order_side,
922        order_type,
923        time_in_force,
924        order_status,
925        quantity,
926        filled_qty,
927        ts_accepted,
928        ts_last,
929        ts_init,
930        Some(UUID4::new()),
931    );
932
933    // Add price
934    report = report.with_price(price);
935
936    // Add trigger price for conditional orders
937    if let Some(trigger_price_dec) = order.trigger_price {
938        let trigger_price = Price::new(
939            trigger_price_dec
940                .to_f64()
941                .context("failed to convert trigger_price to f64")?,
942            instrument.price_precision(),
943        );
944        report = report.with_trigger_price(trigger_price);
945
946        // Add trigger type based on condition type
947        if let Some(condition_type) = order.condition_type {
948            let trigger_type = match condition_type {
949                crate::common::enums::DydxConditionType::StopLoss => TriggerType::LastPrice,
950                crate::common::enums::DydxConditionType::TakeProfit => TriggerType::LastPrice,
951                crate::common::enums::DydxConditionType::Unspecified => TriggerType::Default,
952            };
953            report = report.with_trigger_type(trigger_type);
954        }
955    }
956
957    Ok(report)
958}
959
960/// Parse a dYdX Fill into a Nautilus FillReport.
961///
962/// # Errors
963///
964/// Returns an error if required fields are missing or invalid.
965pub fn parse_fill_report(
966    fill: &Fill,
967    instrument: &InstrumentAny,
968    account_id: AccountId,
969    ts_init: UnixNanos,
970) -> anyhow::Result<FillReport> {
971    let instrument_id = instrument.id();
972    let venue_order_id = VenueOrderId::new(&fill.order_id);
973
974    // Construct trade_id from fill ID
975    let trade_id = TradeId::new(&fill.id);
976
977    let order_side = fill.side;
978
979    // Parse quantity and price using Nautilus types directly
980    let size_precision = instrument.size_precision();
981    let price_precision = instrument.price_precision();
982
983    let last_qty = Quantity::new(
984        fill.size
985            .to_f64()
986            .context("failed to convert fill size to f64")?,
987        size_precision,
988    );
989    let last_px = Price::new(
990        fill.price
991            .to_f64()
992            .context("failed to convert fill price to f64")?,
993        price_precision,
994    );
995
996    // Parse commission (fee)
997    //
998    // Negate dYdX fee to match Nautilus conventions:
999    // - dYdX: negative fee = rebate, positive fee = cost
1000    // - Nautilus: positive commission = rebate, negative commission = cost
1001    // Reference: OKX and Bybit adapters also negate venue fees
1002    let commission = Money::new(
1003        -fill.fee.to_f64().context("failed to convert fee to f64")?,
1004        instrument.quote_currency(),
1005    );
1006
1007    // Parse liquidity side
1008    let liquidity_side = match fill.liquidity {
1009        DydxLiquidity::Maker => LiquiditySide::Maker,
1010        DydxLiquidity::Taker => LiquiditySide::Taker,
1011    };
1012
1013    // Parse timestamp
1014    let ts_event = UnixNanos::from(fill.created_at.timestamp_millis() as u64 * 1_000_000);
1015
1016    let report = FillReport::new(
1017        account_id,
1018        instrument_id,
1019        venue_order_id,
1020        trade_id,
1021        order_side,
1022        last_qty,
1023        last_px,
1024        commission,
1025        liquidity_side,
1026        None, // client_order_id - will be linked by execution engine
1027        None, // venue_position_id
1028        ts_event,
1029        ts_init,
1030        Some(UUID4::new()),
1031    );
1032
1033    Ok(report)
1034}
1035
1036/// Parse a dYdX PerpetualPosition into a Nautilus PositionStatusReport.
1037///
1038/// # Errors
1039///
1040/// Returns an error if required fields are missing or invalid.
1041pub fn parse_position_status_report(
1042    position: &PerpetualPosition,
1043    instrument: &InstrumentAny,
1044    account_id: AccountId,
1045    ts_init: UnixNanos,
1046) -> anyhow::Result<PositionStatusReport> {
1047    let instrument_id = instrument.id();
1048
1049    // Determine position side based on size (negative for short)
1050    let position_side = if position.size.is_zero() {
1051        PositionSide::Flat
1052    } else if position.size.is_sign_positive() {
1053        PositionSide::Long
1054    } else {
1055        PositionSide::Short
1056    };
1057
1058    // Create quantity (always positive)
1059    let quantity = Quantity::new(
1060        position
1061            .size
1062            .abs()
1063            .to_f64()
1064            .context("failed to convert position size to f64")?,
1065        instrument.size_precision(),
1066    );
1067
1068    // Parse entry price
1069    let avg_px_open = position.entry_price;
1070
1071    // Use position creation time as ts_last
1072    let ts_last = UnixNanos::from(position.created_at.timestamp_millis() as u64 * 1_000_000);
1073
1074    // Create venue position ID from market
1075    let venue_position_id = Some(PositionId::new(format!(
1076        "{}_{}",
1077        account_id, position.market
1078    )));
1079
1080    Ok(PositionStatusReport::new(
1081        account_id,
1082        instrument_id,
1083        position_side.as_specified(),
1084        quantity,
1085        ts_last,
1086        ts_init,
1087        Some(UUID4::new()),
1088        venue_position_id,
1089        Some(avg_px_open),
1090    ))
1091}
1092
1093/// Parse a dYdX subaccount info into a Nautilus AccountState.
1094///
1095/// dYdX provides account-level balances with:
1096/// - `equity`: Total account value (total balance)
1097/// - `freeCollateral`: Available for new orders (free balance)
1098/// - `locked`: equity - freeCollateral (calculated)
1099///
1100/// Margin calculations per position:
1101/// - `initial_margin = margin_init * abs(position_size) * oracle_price`
1102/// - `maintenance_margin = margin_maint * abs(position_size) * oracle_price`
1103///
1104/// # Errors
1105///
1106/// Returns an error if balance fields cannot be parsed.
1107pub fn parse_account_state(
1108    subaccount: &DydxSubaccountInfo,
1109    account_id: AccountId,
1110    instruments: &std::collections::HashMap<InstrumentId, InstrumentAny>,
1111    oracle_prices: &std::collections::HashMap<InstrumentId, Decimal>,
1112    ts_event: UnixNanos,
1113    ts_init: UnixNanos,
1114) -> anyhow::Result<AccountState> {
1115    use std::collections::HashMap;
1116
1117    use nautilus_model::{
1118        enums::AccountType,
1119        events::AccountState,
1120        types::{AccountBalance, MarginBalance},
1121    };
1122
1123    let mut balances = Vec::new();
1124
1125    // Parse equity (total) and freeCollateral (free)
1126    let equity_f64 = subaccount.equity.parse::<f64>().context(format!(
1127        "Failed to parse equity '{}' as f64",
1128        subaccount.equity
1129    ))?;
1130
1131    let free_collateral_f64 = subaccount.free_collateral.parse::<f64>().context(format!(
1132        "Failed to parse freeCollateral '{}' as f64",
1133        subaccount.free_collateral
1134    ))?;
1135
1136    // dYdX uses USDC as the settlement currency
1137    let currency = Currency::get_or_create_crypto_with_context("USDC", None);
1138
1139    let total = Money::new(equity_f64, currency);
1140    let free = Money::new(free_collateral_f64, currency);
1141    let locked = total - free;
1142
1143    let balance = AccountBalance::new_checked(total, locked, free)
1144        .context("Failed to create AccountBalance from subaccount data")?;
1145    balances.push(balance);
1146
1147    // Calculate margin balances from open positions
1148    let mut margins = Vec::new();
1149    let mut initial_margins: HashMap<Currency, Decimal> = HashMap::new();
1150    let mut maintenance_margins: HashMap<Currency, Decimal> = HashMap::new();
1151
1152    if let Some(ref positions) = subaccount.open_perpetual_positions {
1153        for position in positions.values() {
1154            // Parse instrument ID from market symbol (e.g., "BTC-USD" -> "BTC-USD-PERP")
1155            let market_str = position.market.as_str();
1156            let instrument_id = parse_instrument_id(market_str);
1157
1158            // Get instrument to access margin parameters
1159            let instrument = match instruments.get(&instrument_id) {
1160                Some(inst) => inst,
1161                None => {
1162                    tracing::warn!(
1163                        "Cannot calculate margin for position {}: instrument not found",
1164                        market_str
1165                    );
1166                    continue;
1167                }
1168            };
1169
1170            // Get margin parameters from instrument
1171            let (margin_init, margin_maint) = match instrument {
1172                InstrumentAny::CryptoPerpetual(perp) => (perp.margin_init, perp.margin_maint),
1173                _ => {
1174                    tracing::warn!(
1175                        "Instrument {} is not a CryptoPerpetual, skipping margin calculation",
1176                        instrument_id
1177                    );
1178                    continue;
1179                }
1180            };
1181
1182            // Parse position size
1183            let position_size = match Decimal::from_str(&position.size) {
1184                Ok(size) => size.abs(),
1185                Err(e) => {
1186                    tracing::warn!(
1187                        "Failed to parse position size '{}' for {}: {}",
1188                        position.size,
1189                        market_str,
1190                        e
1191                    );
1192                    continue;
1193                }
1194            };
1195
1196            // Skip closed positions
1197            if position_size.is_zero() {
1198                continue;
1199            }
1200
1201            // Get oracle price, fallback to entry price
1202            let oracle_price = oracle_prices
1203                .get(&instrument_id)
1204                .copied()
1205                .or_else(|| Decimal::from_str(&position.entry_price).ok())
1206                .unwrap_or(Decimal::ZERO);
1207
1208            if oracle_price.is_zero() {
1209                tracing::warn!(
1210                    "No valid price for position {}, skipping margin calculation",
1211                    market_str
1212                );
1213                continue;
1214            }
1215
1216            // Calculate margins: margin_fraction * abs(size) * oracle_price
1217            let initial_margin = margin_init * position_size * oracle_price;
1218
1219            let maintenance_margin = margin_maint * position_size * oracle_price;
1220
1221            // Aggregate margins by currency
1222            let quote_currency = instrument.quote_currency();
1223            *initial_margins
1224                .entry(quote_currency)
1225                .or_insert(Decimal::ZERO) += initial_margin;
1226            *maintenance_margins
1227                .entry(quote_currency)
1228                .or_insert(Decimal::ZERO) += maintenance_margin;
1229        }
1230    }
1231
1232    // Create MarginBalance objects from aggregated margins
1233    for (currency, initial_margin) in initial_margins {
1234        let maintenance_margin = maintenance_margins
1235            .get(&currency)
1236            .copied()
1237            .unwrap_or(Decimal::ZERO);
1238
1239        let initial_money = Money::from_decimal(initial_margin, currency).context(format!(
1240            "Failed to create initial margin Money for {currency}"
1241        ))?;
1242        let maintenance_money = Money::from_decimal(maintenance_margin, currency).context(
1243            format!("Failed to create maintenance margin Money for {currency}"),
1244        )?;
1245
1246        // Create synthetic instrument ID for account-level margin
1247        // Format: ACCOUNT.DYDX (similar to OKX pattern)
1248        let margin_instrument_id = InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("DYDX"));
1249
1250        let margin_balance =
1251            MarginBalance::new(initial_money, maintenance_money, margin_instrument_id);
1252        margins.push(margin_balance);
1253    }
1254
1255    Ok(AccountState::new(
1256        account_id,
1257        AccountType::Margin, // dYdX uses cross-margin
1258        balances,
1259        margins,
1260        true, // is_reported - comes from venue
1261        UUID4::new(),
1262        ts_event,
1263        ts_init,
1264        None, // base_currency - dYdX settles in USDC
1265    ))
1266}
1267
1268#[cfg(test)]
1269mod reconciliation_tests {
1270    use chrono::Utc;
1271    use nautilus_model::{
1272        enums::{OrderSide, OrderStatus, TimeInForce},
1273        identifiers::{AccountId, InstrumentId, Symbol, Venue},
1274        instruments::{CryptoPerpetual, Instrument},
1275        types::Currency,
1276    };
1277    use rstest::rstest;
1278    use rust_decimal_macros::dec;
1279
1280    use super::*;
1281
1282    fn create_test_instrument() -> InstrumentAny {
1283        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), Venue::new("DYDX"));
1284
1285        InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1286            instrument_id,
1287            instrument_id.symbol,
1288            Currency::BTC(),
1289            Currency::USD(),
1290            Currency::USD(),
1291            false,
1292            2,                                // price_precision
1293            8,                                // size_precision
1294            Price::new(0.01, 2),              // price_increment
1295            Quantity::new(0.001, 8),          // size_increment
1296            Some(Quantity::new(1.0, 0)),      // multiplier
1297            Some(Quantity::new(0.001, 8)),    // lot_size
1298            Some(Quantity::new(100000.0, 8)), // max_quantity
1299            Some(Quantity::new(0.001, 8)),    // min_quantity
1300            None,                             // max_notional
1301            None,                             // min_notional
1302            Some(Price::new(1000000.0, 2)),   // max_price
1303            Some(Price::new(0.01, 2)),        // min_price
1304            Some(dec!(0.05)),                 // margin_init
1305            Some(dec!(0.03)),                 // margin_maint
1306            Some(dec!(0.0002)),               // maker_fee
1307            Some(dec!(0.0005)),               // taker_fee
1308            UnixNanos::default(),             // ts_event
1309            UnixNanos::default(),             // ts_init
1310        ))
1311    }
1312
1313    #[rstest]
1314    fn test_parse_order_status() {
1315        assert_eq!(
1316            parse_order_status(&DydxOrderStatus::Open),
1317            OrderStatus::Accepted
1318        );
1319        assert_eq!(
1320            parse_order_status(&DydxOrderStatus::Filled),
1321            OrderStatus::Filled
1322        );
1323        assert_eq!(
1324            parse_order_status(&DydxOrderStatus::Canceled),
1325            OrderStatus::Canceled
1326        );
1327        assert_eq!(
1328            parse_order_status(&DydxOrderStatus::PartiallyFilled),
1329            OrderStatus::PartiallyFilled
1330        );
1331        assert_eq!(
1332            parse_order_status(&DydxOrderStatus::Untriggered),
1333            OrderStatus::Accepted
1334        );
1335    }
1336
1337    #[rstest]
1338    fn test_parse_order_status_report_basic() {
1339        let instrument = create_test_instrument();
1340        let account_id = AccountId::new("DYDX-001");
1341        let ts_init = UnixNanos::default();
1342
1343        let order = Order {
1344            id: "order123".to_string(),
1345            subaccount_id: "subacct1".to_string(),
1346            client_id: "client1".to_string(),
1347            clob_pair_id: 1,
1348            side: OrderSide::Buy,
1349            size: dec!(1.5),
1350            total_filled: dec!(1.0),
1351            price: dec!(50000.0),
1352            status: DydxOrderStatus::PartiallyFilled,
1353            order_type: DydxOrderType::Limit,
1354            time_in_force: DydxTimeInForce::Gtt,
1355            reduce_only: false,
1356            post_only: false,
1357            order_flags: 0,
1358            good_til_block: None,
1359            good_til_block_time: Some(Utc::now()),
1360            created_at_height: Some(1000),
1361            client_metadata: 0,
1362            trigger_price: None,
1363            condition_type: None,
1364            conditional_order_trigger_subticks: None,
1365            execution: None,
1366            updated_at: Some(Utc::now()),
1367            updated_at_height: Some(1001),
1368            ticker: None,
1369            subaccount_number: 0,
1370            order_router_address: None,
1371        };
1372
1373        let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1374        if let Err(ref e) = result {
1375            eprintln!("Parse error: {e:?}");
1376        }
1377        assert!(result.is_ok());
1378
1379        let report = result.unwrap();
1380        assert_eq!(report.account_id, account_id);
1381        assert_eq!(report.instrument_id, instrument.id());
1382        assert_eq!(report.order_side, OrderSide::Buy);
1383        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1384        assert_eq!(report.time_in_force, TimeInForce::Gtc);
1385    }
1386
1387    #[rstest]
1388    fn test_parse_order_status_report_conditional() {
1389        let instrument = create_test_instrument();
1390        let account_id = AccountId::new("DYDX-001");
1391        let ts_init = UnixNanos::default();
1392
1393        let order = Order {
1394            id: "order456".to_string(),
1395            subaccount_id: "subacct1".to_string(),
1396            client_id: String::new(), // Empty client ID
1397            clob_pair_id: 1,
1398            side: OrderSide::Sell,
1399            size: dec!(2.0),
1400            total_filled: dec!(0.0),
1401            price: dec!(51000.0),
1402            status: DydxOrderStatus::Untriggered,
1403            order_type: DydxOrderType::StopLimit,
1404            time_in_force: DydxTimeInForce::Gtt,
1405            reduce_only: true,
1406            post_only: false,
1407            order_flags: 0,
1408            good_til_block: None,
1409            good_til_block_time: Some(Utc::now()),
1410            created_at_height: Some(1000),
1411            client_metadata: 0,
1412            trigger_price: Some(dec!(49000.0)),
1413            condition_type: Some(crate::common::enums::DydxConditionType::StopLoss),
1414            conditional_order_trigger_subticks: Some(490000),
1415            execution: None,
1416            updated_at: Some(Utc::now()),
1417            updated_at_height: Some(1001),
1418            ticker: None,
1419            subaccount_number: 0,
1420            order_router_address: None,
1421        };
1422
1423        let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1424        assert!(result.is_ok());
1425
1426        let report = result.unwrap();
1427        assert_eq!(report.client_order_id, None);
1428        assert!(report.trigger_price.is_some());
1429        assert_eq!(report.trigger_price.unwrap().as_f64(), 49000.0);
1430    }
1431
1432    #[rstest]
1433    fn test_parse_fill_report() {
1434        let instrument = create_test_instrument();
1435        let account_id = AccountId::new("DYDX-001");
1436        let ts_init = UnixNanos::default();
1437
1438        let fill = Fill {
1439            id: "fill789".to_string(),
1440            side: OrderSide::Buy,
1441            liquidity: DydxLiquidity::Taker,
1442            fill_type: crate::common::enums::DydxFillType::Limit,
1443            market: "BTC-USD".to_string(),
1444            market_type: crate::common::enums::DydxTickerType::Perpetual,
1445            price: dec!(50100.0),
1446            size: dec!(1.0),
1447            fee: dec!(-5.01),
1448            created_at: Utc::now(),
1449            created_at_height: 1000,
1450            order_id: "order123".to_string(),
1451            client_metadata: 0,
1452        };
1453
1454        let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1455        assert!(result.is_ok());
1456
1457        let report = result.unwrap();
1458        assert_eq!(report.account_id, account_id);
1459        assert_eq!(report.order_side, OrderSide::Buy);
1460        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1461        assert_eq!(report.last_px.as_f64(), 50100.0);
1462        assert_eq!(report.commission.as_f64(), 5.01);
1463    }
1464
1465    #[rstest]
1466    fn test_parse_position_status_report_long() {
1467        let instrument = create_test_instrument();
1468        let account_id = AccountId::new("DYDX-001");
1469        let ts_init = UnixNanos::default();
1470
1471        let position = PerpetualPosition {
1472            market: "BTC-USD".to_string(),
1473            status: crate::common::enums::DydxPositionStatus::Open,
1474            side: OrderSide::Buy,
1475            size: dec!(2.5),
1476            max_size: dec!(3.0),
1477            entry_price: dec!(49500.0),
1478            exit_price: None,
1479            realized_pnl: dec!(100.0),
1480            created_at_height: 1000,
1481            created_at: Utc::now(),
1482            sum_open: dec!(2.5),
1483            sum_close: dec!(0.0),
1484            net_funding: dec!(-2.5),
1485            unrealized_pnl: dec!(250.0),
1486            closed_at: None,
1487        };
1488
1489        let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1490        assert!(result.is_ok());
1491
1492        let report = result.unwrap();
1493        assert_eq!(report.account_id, account_id);
1494        assert_eq!(report.position_side, PositionSide::Long.as_specified());
1495        assert_eq!(report.quantity.as_f64(), 2.5);
1496        assert_eq!(report.avg_px_open.unwrap().to_f64().unwrap(), 49500.0);
1497    }
1498
1499    #[rstest]
1500    fn test_parse_position_status_report_short() {
1501        let instrument = create_test_instrument();
1502        let account_id = AccountId::new("DYDX-001");
1503        let ts_init = UnixNanos::default();
1504
1505        let position = PerpetualPosition {
1506            market: "BTC-USD".to_string(),
1507            status: crate::common::enums::DydxPositionStatus::Open,
1508            side: OrderSide::Sell,
1509            size: dec!(-1.5),
1510            max_size: dec!(1.5),
1511            entry_price: dec!(51000.0),
1512            exit_price: None,
1513            realized_pnl: dec!(0.0),
1514            created_at_height: 1000,
1515            created_at: Utc::now(),
1516            sum_open: dec!(1.5),
1517            sum_close: dec!(0.0),
1518            net_funding: dec!(1.2),
1519            unrealized_pnl: dec!(-150.0),
1520            closed_at: None,
1521        };
1522
1523        let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1524        assert!(result.is_ok());
1525
1526        let report = result.unwrap();
1527        assert_eq!(report.position_side, PositionSide::Short.as_specified());
1528        assert_eq!(report.quantity.as_f64(), 1.5);
1529    }
1530
1531    #[rstest]
1532    fn test_parse_position_status_report_flat() {
1533        let instrument = create_test_instrument();
1534        let account_id = AccountId::new("DYDX-001");
1535        let ts_init = UnixNanos::default();
1536
1537        let position = PerpetualPosition {
1538            market: "BTC-USD".to_string(),
1539            status: crate::common::enums::DydxPositionStatus::Closed,
1540            side: OrderSide::Buy,
1541            size: dec!(0.0),
1542            max_size: dec!(2.0),
1543            entry_price: dec!(50000.0),
1544            exit_price: Some(dec!(51000.0)),
1545            realized_pnl: dec!(500.0),
1546            created_at_height: 1000,
1547            created_at: Utc::now(),
1548            sum_open: dec!(2.0),
1549            sum_close: dec!(2.0),
1550            net_funding: dec!(-5.0),
1551            unrealized_pnl: dec!(0.0),
1552            closed_at: Some(Utc::now()),
1553        };
1554
1555        let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1556        assert!(result.is_ok());
1557
1558        let report = result.unwrap();
1559        assert_eq!(report.position_side, PositionSide::Flat.as_specified());
1560        assert_eq!(report.quantity.as_f64(), 0.0);
1561    }
1562
1563    /// Test external order detection (orders not created by this client)
1564    #[rstest]
1565    fn test_parse_order_external_detection() {
1566        let instrument = create_test_instrument();
1567        let account_id = AccountId::new("DYDX-001");
1568        let ts_init = UnixNanos::default();
1569
1570        // External order: created by different client (e.g., web UI)
1571        let order = Order {
1572            id: "external-order-123".to_string(),
1573            subaccount_id: "dydx1test/0".to_string(),
1574            client_id: "99999".to_string(),
1575            clob_pair_id: 1,
1576            side: OrderSide::Buy,
1577            size: dec!(0.5),
1578            total_filled: dec!(0.0),
1579            price: dec!(50000.0),
1580            status: DydxOrderStatus::Open,
1581            order_type: DydxOrderType::Limit,
1582            time_in_force: DydxTimeInForce::Gtt,
1583            reduce_only: false,
1584            post_only: false,
1585            order_flags: 0,
1586            good_til_block: Some(1000),
1587            good_til_block_time: None,
1588            created_at_height: Some(900),
1589            client_metadata: 0,
1590            trigger_price: None,
1591            condition_type: None,
1592            conditional_order_trigger_subticks: None,
1593            execution: None,
1594            updated_at: Some(Utc::now()),
1595            updated_at_height: Some(900),
1596            ticker: None,
1597            subaccount_number: 0,
1598            order_router_address: None,
1599        };
1600
1601        let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1602        assert!(result.is_ok());
1603
1604        let report = result.unwrap();
1605        assert_eq!(report.account_id, account_id);
1606        assert_eq!(report.order_status, OrderStatus::Accepted);
1607        // External orders should still be reconciled correctly
1608        assert_eq!(report.filled_qty.as_f64(), 0.0);
1609    }
1610
1611    /// Test order reconciliation with partial fills
1612    #[rstest]
1613    fn test_parse_order_partial_fill_reconciliation() {
1614        let instrument = create_test_instrument();
1615        let account_id = AccountId::new("DYDX-001");
1616        let ts_init = UnixNanos::default();
1617
1618        let order = Order {
1619            id: "partial-order-123".to_string(),
1620            subaccount_id: "dydx1test/0".to_string(),
1621            client_id: "12345".to_string(),
1622            clob_pair_id: 1,
1623            side: OrderSide::Buy,
1624            size: dec!(2.0),
1625            total_filled: dec!(0.75),
1626            price: dec!(50000.0),
1627            status: DydxOrderStatus::PartiallyFilled,
1628            order_type: DydxOrderType::Limit,
1629            time_in_force: DydxTimeInForce::Gtt,
1630            reduce_only: false,
1631            post_only: false,
1632            order_flags: 0,
1633            good_til_block: Some(2000),
1634            good_til_block_time: None,
1635            created_at_height: Some(1500),
1636            client_metadata: 0,
1637            trigger_price: None,
1638            condition_type: None,
1639            conditional_order_trigger_subticks: None,
1640            execution: None,
1641            updated_at: Some(Utc::now()),
1642            updated_at_height: Some(1600),
1643            ticker: None,
1644            subaccount_number: 0,
1645            order_router_address: None,
1646        };
1647
1648        let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1649        assert!(result.is_ok());
1650
1651        let report = result.unwrap();
1652        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1653        assert_eq!(report.filled_qty.as_f64(), 0.75);
1654        assert_eq!(report.quantity.as_f64(), 2.0);
1655    }
1656
1657    /// Test reconciliation with multiple positions (long and short)
1658    #[rstest]
1659    fn test_parse_multiple_positions() {
1660        let instrument = create_test_instrument();
1661        let account_id = AccountId::new("DYDX-001");
1662        let ts_init = UnixNanos::default();
1663
1664        // Position 1: Long position
1665        let long_position = PerpetualPosition {
1666            market: "BTC-USD".to_string(),
1667            status: crate::common::enums::DydxPositionStatus::Open,
1668            side: OrderSide::Buy,
1669            size: dec!(1.5),
1670            max_size: dec!(1.5),
1671            entry_price: dec!(49000.0),
1672            exit_price: None,
1673            realized_pnl: dec!(0.0),
1674            created_at_height: 1000,
1675            created_at: Utc::now(),
1676            sum_open: dec!(1.5),
1677            sum_close: dec!(0.0),
1678            net_funding: dec!(-1.0),
1679            unrealized_pnl: dec!(150.0),
1680            closed_at: None,
1681        };
1682
1683        let result1 =
1684            parse_position_status_report(&long_position, &instrument, account_id, ts_init);
1685        assert!(result1.is_ok());
1686        let report1 = result1.unwrap();
1687        assert_eq!(report1.position_side, PositionSide::Long.as_specified());
1688
1689        // Position 2: Short position (should be handled separately if from different market)
1690        let short_position = PerpetualPosition {
1691            market: "BTC-USD".to_string(),
1692            status: crate::common::enums::DydxPositionStatus::Open,
1693            side: OrderSide::Sell,
1694            size: dec!(-2.0),
1695            max_size: dec!(2.0),
1696            entry_price: dec!(51000.0),
1697            exit_price: None,
1698            realized_pnl: dec!(0.0),
1699            created_at_height: 1100,
1700            created_at: Utc::now(),
1701            sum_open: dec!(2.0),
1702            sum_close: dec!(0.0),
1703            net_funding: dec!(0.5),
1704            unrealized_pnl: dec!(-200.0),
1705            closed_at: None,
1706        };
1707
1708        let result2 =
1709            parse_position_status_report(&short_position, &instrument, account_id, ts_init);
1710        assert!(result2.is_ok());
1711        let report2 = result2.unwrap();
1712        assert_eq!(report2.position_side, PositionSide::Short.as_specified());
1713    }
1714
1715    /// Test fill reconciliation with zero fee
1716    #[rstest]
1717    fn test_parse_fill_zero_fee() {
1718        let instrument = create_test_instrument();
1719        let account_id = AccountId::new("DYDX-001");
1720        let ts_init = UnixNanos::default();
1721
1722        let fill = Fill {
1723            id: "fill-zero-fee".to_string(),
1724            side: OrderSide::Sell,
1725            liquidity: DydxLiquidity::Maker,
1726            fill_type: crate::common::enums::DydxFillType::Limit,
1727            market: "BTC-USD".to_string(),
1728            market_type: crate::common::enums::DydxTickerType::Perpetual,
1729            price: dec!(50000.0),
1730            size: dec!(0.1),
1731            fee: dec!(0.0), // Zero fee (e.g., fee rebate or promotional period)
1732            created_at: Utc::now(),
1733            created_at_height: 1000,
1734            order_id: "order-zero-fee".to_string(),
1735            client_metadata: 0,
1736        };
1737
1738        let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1739        assert!(result.is_ok());
1740
1741        let report = result.unwrap();
1742        assert_eq!(report.commission.as_f64(), 0.0);
1743    }
1744
1745    /// Test fill reconciliation with maker rebate (negative fee)
1746    #[rstest]
1747    fn test_parse_fill_maker_rebate() {
1748        let instrument = create_test_instrument();
1749        let account_id = AccountId::new("DYDX-001");
1750        let ts_init = UnixNanos::default();
1751
1752        let fill = Fill {
1753            id: "fill-maker-rebate".to_string(),
1754            side: OrderSide::Buy,
1755            liquidity: DydxLiquidity::Maker,
1756            fill_type: crate::common::enums::DydxFillType::Limit,
1757            market: "BTC-USD".to_string(),
1758            market_type: crate::common::enums::DydxTickerType::Perpetual,
1759            price: dec!(50000.0),
1760            size: dec!(1.0),
1761            fee: dec!(-2.5), // Negative fee = rebate
1762            created_at: Utc::now(),
1763            created_at_height: 1000,
1764            order_id: "order-maker-rebate".to_string(),
1765            client_metadata: 0,
1766        };
1767
1768        let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1769        assert!(result.is_ok());
1770
1771        let report = result.unwrap();
1772        // Commission should be negated: -(-2.5) = 2.5 (positive = rebate)
1773        assert_eq!(report.commission.as_f64(), 2.5);
1774        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
1775    }
1776}