Skip to main content

nautilus_dydx/http/
parse.rs

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