nautilus_dydx/http/
parse.rs

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