Skip to main content

nautilus_dydx/websocket/
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 dYdX WebSocket messages.
17//!
18//! Converts WebSocket-specific message formats into Nautilus domain types
19//! by transforming them into HTTP-equivalent structures and delegating to
20//! the HTTP parser for consistency.
21
22use std::str::FromStr;
23
24use anyhow::Context;
25use chrono::{DateTime, Utc};
26use dashmap::DashMap;
27use nautilus_core::UnixNanos;
28use nautilus_model::{
29    data::{Bar, BarType, BookOrder, Data, OrderBookDelta, OrderBookDeltas, TradeTick},
30    enums::{AggressorSide, BookAction, OrderSide, OrderStatus, RecordFlag},
31    identifiers::{AccountId, InstrumentId, TradeId},
32    instruments::{Instrument, InstrumentAny},
33    reports::{FillReport, OrderStatusReport, PositionStatusReport},
34    types::{Price, Quantity},
35};
36use rust_decimal::Decimal;
37
38use super::{DydxWsError, DydxWsResult};
39use crate::{
40    common::{
41        enums::{DydxOrderStatus, DydxTickerType},
42        instrument_cache::InstrumentCache,
43    },
44    execution::{encoder::ClientOrderIdEncoder, types::OrderContext},
45    http::{
46        models::{Fill, Order, PerpetualPosition},
47        parse::{parse_fill_report, parse_order_status_report, parse_position_status_report},
48    },
49    websocket::messages::{
50        DydxCandle, DydxOrderbookContents, DydxOrderbookSnapshotContents, DydxPerpetualPosition,
51        DydxTradeContents, DydxWsFillSubaccountMessageContents,
52        DydxWsOrderSubaccountMessageContents,
53    },
54};
55
56/// Parses a WebSocket order update into an OrderStatusReport.
57///
58/// Converts the WebSocket order format to the HTTP Order format, then delegates
59/// to the existing HTTP parser for consistency.
60///
61/// # Arguments
62///
63/// - `ws_order` - The WebSocket order message to parse
64/// - `instrument_cache` - Cache for looking up instruments by clob_pair_id
65/// - `order_contexts` - Map of dYdX u32 client IDs to order contexts
66/// - `encoder` - Bidirectional encoder for ClientOrderId ↔ u32 mapping
67/// - `account_id` - Account ID for the report
68/// - `ts_init` - Timestamp for initialization
69///
70/// # Errors
71///
72/// Returns an error if:
73/// - clob_pair_id cannot be parsed from string.
74/// - Instrument lookup fails for the clob_pair_id.
75/// - Field parsing fails (price, size, etc.).
76/// - HTTP parser fails.
77pub fn parse_ws_order_report(
78    ws_order: &DydxWsOrderSubaccountMessageContents,
79    instrument_cache: &InstrumentCache,
80    order_contexts: &DashMap<u32, OrderContext>,
81    encoder: &ClientOrderIdEncoder,
82    account_id: AccountId,
83    ts_init: UnixNanos,
84) -> anyhow::Result<OrderStatusReport> {
85    let clob_pair_id: u32 = ws_order.clob_pair_id.parse().context(format!(
86        "Failed to parse clob_pair_id '{}'",
87        ws_order.clob_pair_id
88    ))?;
89
90    let instrument = instrument_cache
91        .get_by_clob_id(clob_pair_id)
92        .ok_or_else(|| {
93            instrument_cache.log_missing_clob_pair_id(clob_pair_id);
94            anyhow::anyhow!("No instrument cached for clob_pair_id {clob_pair_id}")
95        })?;
96
97    let http_order = convert_ws_order_to_http(ws_order)?;
98    let mut report = parse_order_status_report(&http_order, &instrument, account_id, ts_init)?;
99
100    let dydx_client_id = ws_order.client_id.parse::<u32>().ok();
101    let dydx_client_metadata = ws_order
102        .client_metadata
103        .as_ref()
104        .and_then(|s| s.parse::<u32>().ok())
105        .unwrap_or(crate::grpc::DEFAULT_RUST_CLIENT_METADATA);
106
107    log::debug!(
108        "[WS_ORDER_RECV] dYdX client_id='{}' meta={:#x} (parsed u32={:?}) | status={:?} | clob_pair={} | side={:?} | size={} | filled={}",
109        ws_order.client_id,
110        dydx_client_metadata,
111        dydx_client_id,
112        ws_order.status,
113        ws_order.clob_pair_id,
114        ws_order.side,
115        ws_order.size,
116        ws_order.total_filled.as_deref().unwrap_or("?")
117    );
118
119    // Look up the original Nautilus client_order_id from the order context first,
120    // then fall back to encoder.decode_if_known() if not found in context
121    if let Some(client_id) = dydx_client_id {
122        if let Some(ctx) = order_contexts.get(&client_id) {
123            log::debug!(
124                "[WS_ORDER_RECV] DECODE via order_contexts: dYdX u32={} -> Nautilus '{}'",
125                client_id,
126                ctx.client_order_id
127            );
128            report.client_order_id = Some(ctx.client_order_id);
129        } else if let Some(client_order_id) =
130            encoder.decode_if_known(client_id, dydx_client_metadata)
131        {
132            log::debug!(
133                "[WS_ORDER_RECV] DECODE via encoder fallback: dYdX u32={client_id} meta={dydx_client_metadata:#x} -> Nautilus '{client_order_id}'"
134            );
135            report.client_order_id = Some(client_order_id);
136        } else {
137            log::debug!(
138                "[WS_ORDER_RECV] Unknown order: dYdX u32={client_id} meta={dydx_client_metadata:#x} (external or previous session)"
139            );
140        }
141    } else {
142        log::warn!(
143            "[WS_ORDER_RECV] Could not parse client_id '{}' as u32",
144            ws_order.client_id
145        );
146    }
147
148    // For untriggered conditional orders with an explicit trigger price we
149    // surface `PendingUpdate` to match Nautilus semantics and existing dYdX
150    // enum mapping.
151    if matches!(ws_order.status, DydxOrderStatus::Untriggered) && ws_order.trigger_price.is_some() {
152        report.order_status = OrderStatus::PendingUpdate;
153    }
154
155    Ok(report)
156}
157
158/// Converts a WebSocket order message to HTTP Order format.
159///
160/// # Errors
161///
162/// Returns an error if any field parsing fails.
163fn convert_ws_order_to_http(
164    ws_order: &DydxWsOrderSubaccountMessageContents,
165) -> anyhow::Result<Order> {
166    let clob_pair_id: u32 = ws_order
167        .clob_pair_id
168        .parse()
169        .context("Failed to parse clob_pair_id")?;
170
171    let size: Decimal = ws_order.size.parse().context("Failed to parse size")?;
172
173    let total_filled: Decimal = ws_order
174        .total_filled
175        .as_ref()
176        .map(|s| s.parse())
177        .transpose()
178        .context("Failed to parse total_filled")?
179        .unwrap_or(Decimal::ZERO);
180
181    // Saturate to zero if total_filled exceeds size (edge case: rounding or partial fills)
182    let remaining_size = (size - total_filled).max(Decimal::ZERO);
183
184    let price: Decimal = ws_order.price.parse().context("Failed to parse price")?;
185
186    let created_at_height: u64 = ws_order
187        .created_at_height
188        .as_ref()
189        .map(|s| s.parse())
190        .transpose()
191        .context("Failed to parse created_at_height")?
192        .unwrap_or(0);
193
194    let client_metadata: u32 = ws_order
195        .client_metadata
196        .as_ref()
197        .ok_or_else(|| anyhow::anyhow!("Missing required field: client_metadata"))?
198        .parse()
199        .context("Failed to parse client_metadata")?;
200
201    let order_flags: u32 = ws_order
202        .order_flags
203        .parse()
204        .context("Failed to parse order_flags")?;
205
206    let good_til_block = ws_order
207        .good_til_block
208        .as_ref()
209        .and_then(|s| s.parse::<u64>().ok());
210
211    let good_til_block_time = ws_order
212        .good_til_block_time
213        .as_ref()
214        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
215        .map(|dt| dt.with_timezone(&Utc));
216
217    let trigger_price = ws_order
218        .trigger_price
219        .as_ref()
220        .and_then(|s| Decimal::from_str(s).ok());
221
222    // Parse updated_at (optional for BEST_EFFORT_OPENED orders)
223    let updated_at = ws_order
224        .updated_at
225        .as_ref()
226        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
227        .map(|dt| dt.with_timezone(&Utc));
228
229    // Parse updated_at_height (optional for BEST_EFFORT_OPENED orders)
230    let updated_at_height = ws_order
231        .updated_at_height
232        .as_ref()
233        .and_then(|s| s.parse::<u64>().ok());
234
235    let total_filled = size.checked_sub(remaining_size).unwrap_or(Decimal::ZERO);
236
237    Ok(Order {
238        id: ws_order.id.clone(),
239        subaccount_id: ws_order.subaccount_id.clone(),
240        client_id: ws_order.client_id.clone(),
241        clob_pair_id,
242        side: ws_order.side,
243        size,
244        total_filled,
245        price,
246        status: ws_order.status,
247        order_type: ws_order.order_type,
248        time_in_force: ws_order.time_in_force,
249        reduce_only: ws_order.reduce_only,
250        post_only: ws_order.post_only,
251        order_flags,
252        good_til_block,
253        good_til_block_time,
254        created_at_height: Some(created_at_height),
255        client_metadata,
256        trigger_price,
257        condition_type: None, // Not provided in WebSocket messages
258        conditional_order_trigger_subticks: None, // Not provided in WebSocket messages
259        execution: None,      // Inferred from post_only flag by HTTP parser
260        updated_at,
261        updated_at_height,
262        ticker: None,               // Not provided in WebSocket messages
263        subaccount_number: 0,       // Default to 0 for WebSocket messages
264        order_router_address: None, // Not provided in WebSocket messages
265    })
266}
267
268/// Parses a WebSocket fill update into a FillReport.
269///
270/// Converts the WebSocket fill format to the HTTP Fill format, then delegates
271/// to the existing HTTP parser for consistency. Correlates the fill back to the
272/// originating order using the `order_id_map` (built from WS order updates).
273///
274/// # Errors
275///
276/// Returns an error if:
277/// - Instrument lookup fails for the market symbol.
278/// - Field parsing fails (price, size, fee, etc.).
279/// - HTTP parser fails.
280pub fn parse_ws_fill_report(
281    ws_fill: &DydxWsFillSubaccountMessageContents,
282    instrument_cache: &InstrumentCache,
283    order_id_map: &DashMap<String, (u32, u32)>,
284    order_contexts: &DashMap<u32, OrderContext>,
285    encoder: &ClientOrderIdEncoder,
286    account_id: AccountId,
287    ts_init: UnixNanos,
288) -> anyhow::Result<FillReport> {
289    let instrument = instrument_cache
290        .get_by_market(&ws_fill.market)
291        .ok_or_else(|| {
292            let available: Vec<String> = instrument_cache
293                .all_instruments()
294                .into_iter()
295                .map(|inst| inst.id().symbol.to_string())
296                .collect();
297            anyhow::anyhow!(
298                "No instrument cached for market '{}'. Available: {:?}",
299                ws_fill.market,
300                available
301            )
302        })?;
303
304    let http_fill = convert_ws_fill_to_http(ws_fill)?;
305    let mut report = parse_fill_report(&http_fill, &instrument, account_id, ts_init)?;
306
307    // Correlate fill to order via order_id → (client_id, client_metadata) → client_order_id
308    if let Some(ref order_id) = ws_fill.order_id {
309        if let Some(entry) = order_id_map.get(order_id) {
310            let (client_id, client_metadata) = *entry.value();
311            if let Some(ctx) = order_contexts.get(&client_id) {
312                report.client_order_id = Some(ctx.client_order_id);
313            } else if let Some(client_order_id) =
314                encoder.decode_if_known(client_id, client_metadata)
315            {
316                report.client_order_id = Some(client_order_id);
317            } else {
318                log::debug!(
319                    "[WS_FILL_RECV] Unknown order: order_id={order_id} -> client_id={client_id} meta={client_metadata:#x} (external or previous session)",
320                );
321            }
322        } else {
323            log::warn!(
324                "[WS_FILL_RECV] No order_id mapping for '{order_id}', fill cannot be correlated",
325            );
326        }
327    }
328
329    Ok(report)
330}
331
332/// Converts a WebSocket fill message to HTTP Fill format.
333///
334/// # Errors
335///
336/// Returns an error if any field parsing fails.
337fn convert_ws_fill_to_http(ws_fill: &DydxWsFillSubaccountMessageContents) -> anyhow::Result<Fill> {
338    let price: Decimal = ws_fill.price.parse().context("Failed to parse price")?;
339    let size: Decimal = ws_fill.size.parse().context("Failed to parse size")?;
340    let fee: Decimal = ws_fill.fee.parse().context("Failed to parse fee")?;
341
342    let created_at_height: u64 = ws_fill
343        .created_at_height
344        .as_ref()
345        .map(|s| s.parse())
346        .transpose()
347        .context("Failed to parse created_at_height")?
348        .unwrap_or(0);
349
350    let client_metadata: u32 = ws_fill
351        .client_metadata
352        .as_ref()
353        .ok_or_else(|| anyhow::anyhow!("Missing required field: client_metadata"))?
354        .parse()
355        .context("Failed to parse client_metadata")?;
356
357    let order_id = ws_fill
358        .order_id
359        .clone()
360        .ok_or_else(|| anyhow::anyhow!("Missing required field: order_id"))?;
361
362    let created_at = DateTime::parse_from_rfc3339(&ws_fill.created_at)
363        .context("Failed to parse created_at")?
364        .with_timezone(&Utc);
365
366    Ok(Fill {
367        id: ws_fill.id.clone(),
368        side: ws_fill.side,
369        liquidity: ws_fill.liquidity,
370        fill_type: ws_fill.fill_type,
371        market: ws_fill.market,
372        market_type: ws_fill.market_type.unwrap_or(DydxTickerType::Perpetual),
373        price,
374        size,
375        fee,
376        created_at,
377        created_at_height,
378        order_id,
379        client_metadata,
380    })
381}
382
383/// Parses a WebSocket position into a PositionStatusReport.
384///
385/// Converts the WebSocket position format to the HTTP PerpetualPosition format,
386/// then delegates to the existing HTTP parser for consistency.
387///
388/// # Errors
389///
390/// Returns an error if:
391/// - Instrument lookup fails for the market symbol.
392/// - Field parsing fails (size, prices, etc.).
393/// - HTTP parser fails.
394pub fn parse_ws_position_report(
395    ws_position: &DydxPerpetualPosition,
396    instrument_cache: &InstrumentCache,
397    account_id: AccountId,
398    ts_init: UnixNanos,
399) -> anyhow::Result<PositionStatusReport> {
400    let instrument = instrument_cache
401        .get_by_market(&ws_position.market)
402        .ok_or_else(|| {
403            let available: Vec<String> = instrument_cache
404                .all_instruments()
405                .into_iter()
406                .map(|inst| inst.id().symbol.to_string())
407                .collect();
408            anyhow::anyhow!(
409                "No instrument cached for market '{}'. Available: {:?}",
410                ws_position.market,
411                available
412            )
413        })?;
414
415    let http_position = convert_ws_position_to_http(ws_position)?;
416    parse_position_status_report(&http_position, &instrument, account_id, ts_init)
417}
418
419/// Converts a WebSocket position to HTTP PerpetualPosition format.
420///
421/// # Errors
422///
423/// Returns an error if any field parsing fails.
424fn convert_ws_position_to_http(
425    ws_position: &DydxPerpetualPosition,
426) -> anyhow::Result<PerpetualPosition> {
427    let size: Decimal = ws_position.size.parse().context("Failed to parse size")?;
428
429    let max_size: Decimal = ws_position
430        .max_size
431        .parse()
432        .context("Failed to parse max_size")?;
433
434    let entry_price: Decimal = ws_position
435        .entry_price
436        .parse()
437        .context("Failed to parse entry_price")?;
438
439    let exit_price: Option<Decimal> = ws_position
440        .exit_price
441        .as_ref()
442        .map(|s| s.parse())
443        .transpose()
444        .context("Failed to parse exit_price")?;
445
446    let realized_pnl: Decimal = ws_position
447        .realized_pnl
448        .parse()
449        .context("Failed to parse realized_pnl")?;
450
451    let unrealized_pnl: Decimal = ws_position
452        .unrealized_pnl
453        .parse()
454        .context("Failed to parse unrealized_pnl")?;
455
456    let sum_open: Decimal = ws_position
457        .sum_open
458        .parse()
459        .context("Failed to parse sum_open")?;
460
461    let sum_close: Decimal = ws_position
462        .sum_close
463        .parse()
464        .context("Failed to parse sum_close")?;
465
466    let net_funding: Decimal = ws_position
467        .net_funding
468        .parse()
469        .context("Failed to parse net_funding")?;
470
471    let created_at = DateTime::parse_from_rfc3339(&ws_position.created_at)
472        .context("Failed to parse created_at")?
473        .with_timezone(&Utc);
474
475    let closed_at = ws_position
476        .closed_at
477        .as_ref()
478        .map(|s| DateTime::parse_from_rfc3339(s))
479        .transpose()
480        .context("Failed to parse closed_at")?
481        .map(|dt| dt.with_timezone(&Utc));
482
483    // Preserve the venue-supplied side; only derive from size sign when side is absent
484    // (the WS schema always provides it, but this keeps the behavior explicit).
485    let side = ws_position.side;
486
487    Ok(PerpetualPosition {
488        market: ws_position.market,
489        status: ws_position.status,
490        side,
491        size,
492        max_size,
493        entry_price,
494        exit_price,
495        realized_pnl,
496        created_at_height: 0, // Not provided in WebSocket messages
497        created_at,
498        sum_open,
499        sum_close,
500        net_funding,
501        unrealized_pnl,
502        closed_at,
503    })
504}
505
506// ---------------------------------------------------------------------------
507//  Market data parsing functions
508// ---------------------------------------------------------------------------
509
510/// Parses an orderbook snapshot into [`OrderBookDeltas`].
511///
512/// # Errors
513///
514/// Returns an error if price/size parsing fails.
515pub fn parse_orderbook_snapshot(
516    instrument_id: &InstrumentId,
517    contents: &DydxOrderbookSnapshotContents,
518    price_precision: u8,
519    size_precision: u8,
520    ts_init: UnixNanos,
521) -> DydxWsResult<OrderBookDeltas> {
522    let bids = contents.bids.as_deref().unwrap_or(&[]);
523    let asks = contents.asks.as_deref().unwrap_or(&[]);
524
525    let mut deltas = Vec::with_capacity(1 + bids.len() + asks.len());
526    let snapshot_flag = RecordFlag::F_SNAPSHOT as u8;
527
528    // Empty book snapshot: Clear alone must carry F_SNAPSHOT | F_LAST
529    if bids.is_empty() && asks.is_empty() {
530        let clear_flags = snapshot_flag | RecordFlag::F_LAST as u8;
531        let mut clear_delta = OrderBookDelta::clear(*instrument_id, 0, ts_init, ts_init);
532        clear_delta.flags = clear_flags;
533        deltas.push(clear_delta);
534        return Ok(OrderBookDeltas::new(*instrument_id, deltas));
535    }
536
537    // Non-empty: Clear carries F_SNAPSHOT (not last)
538    let mut clear_delta = OrderBookDelta::clear(*instrument_id, 0, ts_init, ts_init);
539    clear_delta.flags = snapshot_flag;
540    deltas.push(clear_delta);
541
542    let bids_len = bids.len();
543    let asks_len = asks.len();
544
545    for (idx, bid) in bids.iter().enumerate() {
546        let is_last = idx == bids_len - 1 && asks_len == 0;
547        let flags = if is_last {
548            snapshot_flag | RecordFlag::F_LAST as u8
549        } else {
550            snapshot_flag
551        };
552
553        let price = Decimal::from_str(&bid.price)
554            .map_err(|e| DydxWsError::Parse(format!("Failed to parse bid price: {e}")))?;
555
556        let size = Decimal::from_str(&bid.size)
557            .map_err(|e| DydxWsError::Parse(format!("Failed to parse bid size: {e}")))?;
558
559        let order = BookOrder::new(
560            OrderSide::Buy,
561            Price::from_decimal_dp(price, price_precision).map_err(|e| {
562                DydxWsError::Parse(format!("Failed to create Price from decimal: {e}"))
563            })?,
564            Quantity::from_decimal_dp(size, size_precision).map_err(|e| {
565                DydxWsError::Parse(format!("Failed to create Quantity from decimal: {e}"))
566            })?,
567            0,
568        );
569
570        deltas.push(OrderBookDelta::new(
571            *instrument_id,
572            BookAction::Add,
573            order,
574            flags,
575            0,
576            ts_init,
577            ts_init,
578        ));
579    }
580
581    for (idx, ask) in asks.iter().enumerate() {
582        let is_last = idx == asks_len - 1;
583        let flags = if is_last {
584            snapshot_flag | RecordFlag::F_LAST as u8
585        } else {
586            snapshot_flag
587        };
588
589        let price = Decimal::from_str(&ask.price)
590            .map_err(|e| DydxWsError::Parse(format!("Failed to parse ask price: {e}")))?;
591
592        let size = Decimal::from_str(&ask.size)
593            .map_err(|e| DydxWsError::Parse(format!("Failed to parse ask size: {e}")))?;
594
595        let order = BookOrder::new(
596            OrderSide::Sell,
597            Price::from_decimal_dp(price, price_precision).map_err(|e| {
598                DydxWsError::Parse(format!("Failed to create Price from decimal: {e}"))
599            })?,
600            Quantity::from_decimal_dp(size, size_precision).map_err(|e| {
601                DydxWsError::Parse(format!("Failed to create Quantity from decimal: {e}"))
602            })?,
603            0,
604        );
605
606        deltas.push(OrderBookDelta::new(
607            *instrument_id,
608            BookAction::Add,
609            order,
610            flags,
611            0,
612            ts_init,
613            ts_init,
614        ));
615    }
616
617    Ok(OrderBookDeltas::new(*instrument_id, deltas))
618}
619
620/// Parses orderbook deltas (marks as last message by default).
621///
622/// # Errors
623///
624/// Returns an error if price/size parsing fails.
625pub fn parse_orderbook_deltas(
626    instrument_id: &InstrumentId,
627    contents: &DydxOrderbookContents,
628    price_precision: u8,
629    size_precision: u8,
630    ts_init: UnixNanos,
631) -> DydxWsResult<OrderBookDeltas> {
632    let deltas = parse_orderbook_deltas_with_flag(
633        instrument_id,
634        contents,
635        price_precision,
636        size_precision,
637        ts_init,
638        true,
639    )?;
640    Ok(OrderBookDeltas::new(*instrument_id, deltas))
641}
642
643/// Parses orderbook deltas with explicit last-message flag for batch processing.
644///
645/// # Errors
646///
647/// Returns an error if price/size parsing fails.
648pub fn parse_orderbook_deltas_with_flag(
649    instrument_id: &InstrumentId,
650    contents: &DydxOrderbookContents,
651    price_precision: u8,
652    size_precision: u8,
653    ts_init: UnixNanos,
654    is_last_message: bool,
655) -> DydxWsResult<Vec<OrderBookDelta>> {
656    let mut deltas = Vec::new();
657
658    let bids = contents.bids.as_deref().unwrap_or(&[]);
659    let asks = contents.asks.as_deref().unwrap_or(&[]);
660
661    let bids_len = bids.len();
662    let asks_len = asks.len();
663
664    for (idx, (price_str, size_str)) in bids.iter().enumerate() {
665        let is_last = is_last_message && idx == bids_len - 1 && asks_len == 0;
666        let flags = if is_last { RecordFlag::F_LAST as u8 } else { 0 };
667
668        let price = Decimal::from_str(price_str)
669            .map_err(|e| DydxWsError::Parse(format!("Failed to parse bid price: {e}")))?;
670
671        let size = Decimal::from_str(size_str)
672            .map_err(|e| DydxWsError::Parse(format!("Failed to parse bid size: {e}")))?;
673
674        let qty = Quantity::from_decimal_dp(size, size_precision).map_err(|e| {
675            DydxWsError::Parse(format!("Failed to create Quantity from decimal: {e}"))
676        })?;
677        let action = if qty.is_zero() {
678            BookAction::Delete
679        } else {
680            BookAction::Update
681        };
682
683        let order = BookOrder::new(
684            OrderSide::Buy,
685            Price::from_decimal_dp(price, price_precision).map_err(|e| {
686                DydxWsError::Parse(format!("Failed to create Price from decimal: {e}"))
687            })?,
688            qty,
689            0,
690        );
691
692        deltas.push(OrderBookDelta::new(
693            *instrument_id,
694            action,
695            order,
696            flags,
697            0,
698            ts_init,
699            ts_init,
700        ));
701    }
702
703    for (idx, (price_str, size_str)) in asks.iter().enumerate() {
704        let is_last = is_last_message && idx == asks_len - 1;
705        let flags = if is_last { RecordFlag::F_LAST as u8 } else { 0 };
706
707        let price = Decimal::from_str(price_str)
708            .map_err(|e| DydxWsError::Parse(format!("Failed to parse ask price: {e}")))?;
709
710        let size = Decimal::from_str(size_str)
711            .map_err(|e| DydxWsError::Parse(format!("Failed to parse ask size: {e}")))?;
712
713        let qty = Quantity::from_decimal_dp(size, size_precision).map_err(|e| {
714            DydxWsError::Parse(format!("Failed to create Quantity from decimal: {e}"))
715        })?;
716        let action = if qty.is_zero() {
717            BookAction::Delete
718        } else {
719            BookAction::Update
720        };
721
722        let order = BookOrder::new(
723            OrderSide::Sell,
724            Price::from_decimal_dp(price, price_precision).map_err(|e| {
725                DydxWsError::Parse(format!("Failed to create Price from decimal: {e}"))
726            })?,
727            qty,
728            0,
729        );
730
731        deltas.push(OrderBookDelta::new(
732            *instrument_id,
733            action,
734            order,
735            flags,
736            0,
737            ts_init,
738            ts_init,
739        ));
740    }
741
742    Ok(deltas)
743}
744
745/// Parses trade ticks from trade contents.
746///
747/// # Errors
748///
749/// Returns an error if price/size/timestamp parsing fails.
750pub fn parse_trade_ticks(
751    instrument_id: InstrumentId,
752    instrument: &InstrumentAny,
753    contents: &DydxTradeContents,
754    ts_init: UnixNanos,
755) -> DydxWsResult<Vec<Data>> {
756    let mut ticks = Vec::new();
757
758    for trade in &contents.trades {
759        let aggressor_side = match trade.side {
760            OrderSide::Buy => AggressorSide::Buyer,
761            OrderSide::Sell => AggressorSide::Seller,
762            _ => continue,
763        };
764
765        let price = Decimal::from_str(&trade.price)
766            .map_err(|e| DydxWsError::Parse(format!("Failed to parse trade price: {e}")))?;
767
768        let size = Decimal::from_str(&trade.size)
769            .map_err(|e| DydxWsError::Parse(format!("Failed to parse trade size: {e}")))?;
770
771        let trade_ts = trade.created_at.timestamp_nanos_opt().ok_or_else(|| {
772            DydxWsError::Parse(format!("Timestamp out of range for trade {}", trade.id))
773        })?;
774
775        let tick = TradeTick::new(
776            instrument_id,
777            Price::from_decimal_dp(price, instrument.price_precision()).map_err(|e| {
778                DydxWsError::Parse(format!("Failed to create Price from decimal: {e}"))
779            })?,
780            Quantity::from_decimal_dp(size, instrument.size_precision()).map_err(|e| {
781                DydxWsError::Parse(format!("Failed to create Quantity from decimal: {e}"))
782            })?,
783            aggressor_side,
784            TradeId::new(&trade.id),
785            UnixNanos::from(trade_ts as u64),
786            ts_init,
787        );
788        ticks.push(Data::Trade(tick));
789    }
790
791    Ok(ticks)
792}
793
794/// Parses a single candle into a [`Bar`].
795///
796/// When `timestamp_on_close` is true, `ts_event` is set to bar close time
797/// (started_at + interval). When false, uses the venue-native open time.
798///
799/// # Errors
800///
801/// Returns an error if OHLCV/timestamp parsing fails.
802pub fn parse_candle_bar(
803    bar_type: BarType,
804    instrument: &InstrumentAny,
805    candle: &DydxCandle,
806    timestamp_on_close: bool,
807    ts_init: UnixNanos,
808) -> DydxWsResult<Bar> {
809    let open = Decimal::from_str(&candle.open)
810        .map_err(|e| DydxWsError::Parse(format!("Failed to parse open: {e}")))?;
811    let high = Decimal::from_str(&candle.high)
812        .map_err(|e| DydxWsError::Parse(format!("Failed to parse high: {e}")))?;
813    let low = Decimal::from_str(&candle.low)
814        .map_err(|e| DydxWsError::Parse(format!("Failed to parse low: {e}")))?;
815    let close = Decimal::from_str(&candle.close)
816        .map_err(|e| DydxWsError::Parse(format!("Failed to parse close: {e}")))?;
817    let volume = candle
818        .base_token_volume
819        .as_deref()
820        .map(Decimal::from_str)
821        .transpose()
822        .map_err(|e| DydxWsError::Parse(format!("Failed to parse volume: {e}")))?
823        .unwrap_or(Decimal::ZERO);
824
825    let started_at_nanos = candle.started_at.timestamp_nanos_opt().ok_or_else(|| {
826        DydxWsError::Parse(format!(
827            "Timestamp out of range for candle at {}",
828            candle.started_at
829        ))
830    })?;
831    let mut ts_event = UnixNanos::from(started_at_nanos as u64);
832
833    if timestamp_on_close {
834        let interval_ns = bar_type
835            .spec()
836            .timedelta()
837            .num_nanoseconds()
838            .ok_or_else(|| DydxWsError::Parse("Bar interval overflow".to_string()))?;
839        let updated = (started_at_nanos as u64)
840            .checked_add(interval_ns as u64)
841            .ok_or_else(|| {
842                DydxWsError::Parse("Bar timestamp overflowed adjusting to close time".to_string())
843            })?;
844        ts_event = UnixNanos::from(updated);
845    }
846
847    let bar = Bar::new(
848        bar_type,
849        Price::from_decimal_dp(open, instrument.price_precision()).map_err(|e| {
850            DydxWsError::Parse(format!("Failed to create open Price from decimal: {e}"))
851        })?,
852        Price::from_decimal_dp(high, instrument.price_precision()).map_err(|e| {
853            DydxWsError::Parse(format!("Failed to create high Price from decimal: {e}"))
854        })?,
855        Price::from_decimal_dp(low, instrument.price_precision()).map_err(|e| {
856            DydxWsError::Parse(format!("Failed to create low Price from decimal: {e}"))
857        })?,
858        Price::from_decimal_dp(close, instrument.price_precision()).map_err(|e| {
859            DydxWsError::Parse(format!("Failed to create close Price from decimal: {e}"))
860        })?,
861        Quantity::from_decimal_dp(volume, instrument.size_precision()).map_err(|e| {
862            DydxWsError::Parse(format!(
863                "Failed to create volume Quantity from decimal: {e}"
864            ))
865        })?,
866        ts_event,
867        ts_init,
868    );
869
870    Ok(bar)
871}
872
873#[cfg(test)]
874mod tests {
875    use std::str::FromStr;
876
877    use nautilus_model::{
878        data::{BarType, Data},
879        enums::{
880            AggressorSide, BookAction, LiquiditySide, OrderSide, OrderStatus, OrderType,
881            PositionSideSpecified,
882        },
883        identifiers::{AccountId, InstrumentId, Symbol},
884        instruments::{CryptoPerpetual, InstrumentAny},
885        types::{Currency, Price, Quantity},
886    };
887    use rstest::rstest;
888    use rust_decimal_macros::dec;
889    use ustr::Ustr;
890
891    use super::*;
892    use crate::{
893        common::{
894            consts::DYDX_VENUE,
895            enums::{
896                DydxFillType, DydxLiquidity, DydxMarketStatus, DydxOrderStatus, DydxOrderType,
897                DydxPositionSide, DydxPositionStatus, DydxTickerType, DydxTimeInForce,
898            },
899            testing::load_json_fixture,
900        },
901        http::models::PerpetualMarket,
902        websocket::messages::{DydxPerpetualPosition, DydxWsFillSubaccountMessageContents},
903    };
904
905    /// Creates a test market with BTC-USD ticker and specified clob_pair_id.
906    fn create_test_market(ticker: &str, clob_pair_id: u32) -> PerpetualMarket {
907        PerpetualMarket {
908            clob_pair_id,
909            ticker: Ustr::from(ticker),
910            status: DydxMarketStatus::Active,
911            base_asset: Some(Ustr::from("BTC")),
912            quote_asset: Some(Ustr::from("USD")),
913            step_size: dec!(0.001),
914            tick_size: dec!(0.01),
915            index_price: Some(dec!(50000)),
916            oracle_price: Some(dec!(50000)),
917            price_change_24h: dec!(0),
918            next_funding_rate: dec!(0),
919            next_funding_at: None,
920            min_order_size: Some(dec!(0.001)),
921            market_type: None,
922            initial_margin_fraction: dec!(0.05),
923            maintenance_margin_fraction: dec!(0.03),
924            base_position_notional: None,
925            incremental_position_size: None,
926            incremental_initial_margin_fraction: None,
927            max_position_size: None,
928            open_interest: dec!(1000),
929            atomic_resolution: -10,
930            quantum_conversion_exponent: -9,
931            subticks_per_tick: 1000000,
932            step_base_quantums: 1000000,
933            is_reduce_only: false,
934        }
935    }
936
937    /// Creates an InstrumentCache populated with the test instrument.
938    fn create_test_instrument_cache() -> InstrumentCache {
939        let cache = InstrumentCache::new();
940        let instrument = create_test_instrument();
941        let market = create_test_market("BTC-USD", 1);
942        cache.insert(instrument, market);
943        cache
944    }
945
946    fn create_test_instrument() -> InstrumentAny {
947        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD-PERP"), *DYDX_VENUE);
948
949        InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
950            instrument_id,
951            Symbol::new("BTC-USD"),
952            Currency::BTC(),
953            Currency::USD(),
954            Currency::USD(),
955            false,
956            2,
957            8,
958            Price::new(0.01, 2),
959            Quantity::new(0.001, 8),
960            Some(Quantity::new(1.0, 0)),
961            Some(Quantity::new(0.001, 8)),
962            Some(Quantity::new(100000.0, 8)),
963            Some(Quantity::new(0.001, 8)),
964            None,
965            None,
966            Some(Price::new(1000000.0, 2)),
967            Some(Price::new(0.01, 2)),
968            Some(rust_decimal_macros::dec!(0.05)),
969            Some(rust_decimal_macros::dec!(0.03)),
970            Some(rust_decimal_macros::dec!(0.0002)),
971            Some(rust_decimal_macros::dec!(0.0005)),
972            None,
973            None, // info: Option<Params>
974            UnixNanos::default(),
975            UnixNanos::default(),
976        ))
977    }
978
979    #[rstest]
980    fn test_convert_ws_order_to_http_basic() {
981        let ws_order = DydxWsOrderSubaccountMessageContents {
982            id: "order123".to_string(),
983            subaccount_id: "dydx1test/0".to_string(),
984            client_id: "12345".to_string(),
985            clob_pair_id: "1".to_string(),
986            side: OrderSide::Buy,
987            size: "1.5".to_string(),
988            price: "50000.0".to_string(),
989            status: DydxOrderStatus::PartiallyFilled,
990            order_type: DydxOrderType::Limit,
991            time_in_force: DydxTimeInForce::Gtt,
992            post_only: false,
993            reduce_only: false,
994            order_flags: "0".to_string(),
995            good_til_block: Some("1000".to_string()),
996            good_til_block_time: None,
997            created_at_height: Some("900".to_string()),
998            client_metadata: Some("0".to_string()),
999            trigger_price: None,
1000            total_filled: Some("0.5".to_string()),
1001            updated_at: Some("2024-11-14T10:00:00Z".to_string()),
1002            updated_at_height: Some("950".to_string()),
1003        };
1004
1005        let result = convert_ws_order_to_http(&ws_order);
1006        assert!(result.is_ok());
1007
1008        let http_order = result.unwrap();
1009        assert_eq!(http_order.id, "order123");
1010        assert_eq!(http_order.clob_pair_id, 1);
1011        assert_eq!(http_order.size.to_string(), "1.5");
1012        assert_eq!(http_order.total_filled, rust_decimal_macros::dec!(0.5)); // 0.5 filled
1013        assert_eq!(http_order.status, DydxOrderStatus::PartiallyFilled);
1014    }
1015
1016    #[rstest]
1017    fn test_parse_ws_order_report_success() {
1018        let ws_order = DydxWsOrderSubaccountMessageContents {
1019            id: "order456".to_string(),
1020            subaccount_id: "dydx1test/0".to_string(),
1021            client_id: "67890".to_string(),
1022            clob_pair_id: "1".to_string(),
1023            side: OrderSide::Sell,
1024            size: "2.0".to_string(),
1025            price: "51000.0".to_string(),
1026            status: DydxOrderStatus::Open,
1027            order_type: DydxOrderType::Limit,
1028            time_in_force: DydxTimeInForce::Gtt,
1029            post_only: true,
1030            reduce_only: false,
1031            order_flags: "0".to_string(),
1032            good_til_block: Some("2000".to_string()),
1033            good_til_block_time: None,
1034            created_at_height: Some("1800".to_string()),
1035            client_metadata: Some("0".to_string()),
1036            trigger_price: None,
1037            total_filled: Some("0.0".to_string()),
1038            updated_at: None,
1039            updated_at_height: None,
1040        };
1041
1042        let instrument_cache = create_test_instrument_cache();
1043        let encoder = ClientOrderIdEncoder::new();
1044
1045        let account_id = AccountId::new("DYDX-001");
1046        let ts_init = UnixNanos::default();
1047        let order_contexts: DashMap<u32, OrderContext> = DashMap::new();
1048
1049        let result = parse_ws_order_report(
1050            &ws_order,
1051            &instrument_cache,
1052            &order_contexts,
1053            &encoder,
1054            account_id,
1055            ts_init,
1056        );
1057
1058        assert!(result.is_ok());
1059        let report = result.unwrap();
1060        assert_eq!(report.account_id, account_id);
1061        assert_eq!(report.order_side, OrderSide::Sell);
1062    }
1063
1064    #[rstest]
1065    fn test_parse_ws_order_report_missing_instrument() {
1066        let ws_order = DydxWsOrderSubaccountMessageContents {
1067            id: "order789".to_string(),
1068            subaccount_id: "dydx1test/0".to_string(),
1069            client_id: "11111".to_string(),
1070            clob_pair_id: "99".to_string(), // Non-existent
1071            side: OrderSide::Buy,
1072            size: "1.0".to_string(),
1073            price: "50000.0".to_string(),
1074            status: DydxOrderStatus::Open,
1075            order_type: DydxOrderType::Market,
1076            time_in_force: DydxTimeInForce::Ioc,
1077            post_only: false,
1078            reduce_only: false,
1079            order_flags: "0".to_string(),
1080            good_til_block: Some("1000".to_string()),
1081            good_til_block_time: None,
1082            created_at_height: Some("900".to_string()),
1083            client_metadata: Some("0".to_string()),
1084            trigger_price: None,
1085            total_filled: Some("0.0".to_string()),
1086            updated_at: None,
1087            updated_at_height: None,
1088        };
1089
1090        let instrument_cache = InstrumentCache::new(); // Empty cache
1091        let encoder = ClientOrderIdEncoder::new();
1092        let account_id = AccountId::new("DYDX-001");
1093        let ts_init = UnixNanos::default();
1094        let order_contexts: DashMap<u32, OrderContext> = DashMap::new();
1095
1096        let result = parse_ws_order_report(
1097            &ws_order,
1098            &instrument_cache,
1099            &order_contexts,
1100            &encoder,
1101            account_id,
1102            ts_init,
1103        );
1104
1105        assert!(result.is_err());
1106        assert!(
1107            result
1108                .unwrap_err()
1109                .to_string()
1110                .contains("No instrument cached")
1111        );
1112    }
1113
1114    #[rstest]
1115    fn test_convert_ws_fill_to_http() {
1116        let ws_fill = DydxWsFillSubaccountMessageContents {
1117            id: "fill123".to_string(),
1118            subaccount_id: "sub1".to_string(),
1119            side: OrderSide::Buy,
1120            liquidity: DydxLiquidity::Maker,
1121            fill_type: DydxFillType::Limit,
1122            market: "BTC-USD".into(),
1123            market_type: Some(DydxTickerType::Perpetual),
1124            price: "50000.5".to_string(),
1125            size: "0.1".to_string(),
1126            fee: "-2.5".to_string(), // Negative for maker rebate
1127            created_at: "2024-01-15T10:30:00Z".to_string(),
1128            created_at_height: Some("12345".to_string()),
1129            order_id: Some("order456".to_string()),
1130            client_metadata: Some("999".to_string()),
1131        };
1132
1133        let result = convert_ws_fill_to_http(&ws_fill);
1134        assert!(result.is_ok());
1135
1136        let http_fill = result.unwrap();
1137        assert_eq!(http_fill.id, "fill123");
1138        assert_eq!(http_fill.side, OrderSide::Buy);
1139        assert_eq!(http_fill.liquidity, DydxLiquidity::Maker);
1140        assert_eq!(http_fill.price, rust_decimal_macros::dec!(50000.5));
1141        assert_eq!(http_fill.size, rust_decimal_macros::dec!(0.1));
1142        assert_eq!(http_fill.fee, rust_decimal_macros::dec!(-2.5));
1143        assert_eq!(http_fill.created_at_height, 12345);
1144        assert_eq!(http_fill.order_id, "order456");
1145        assert_eq!(http_fill.client_metadata, 999);
1146    }
1147
1148    #[rstest]
1149    fn test_parse_ws_fill_report_success() {
1150        let instrument_cache = create_test_instrument_cache();
1151        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD-PERP"), *DYDX_VENUE);
1152
1153        // dYdX WS fills use market format "BTC-USD" (not "BTC-USD-PERP")
1154        // but the instrument symbol is "BTC-USD-PERP"
1155        let ws_fill = DydxWsFillSubaccountMessageContents {
1156            id: "fill789".to_string(),
1157            subaccount_id: "sub1".to_string(),
1158            side: OrderSide::Sell,
1159            liquidity: DydxLiquidity::Taker,
1160            fill_type: DydxFillType::Limit,
1161            market: "BTC-USD".into(),
1162            market_type: Some(DydxTickerType::Perpetual),
1163            price: "49500.0".to_string(),
1164            size: "0.5".to_string(),
1165            fee: "12.375".to_string(), // Positive for taker fee
1166            created_at: "2024-01-15T11:00:00Z".to_string(),
1167            created_at_height: Some("12400".to_string()),
1168            order_id: Some("order999".to_string()),
1169            client_metadata: Some("888".to_string()),
1170        };
1171
1172        let account_id = AccountId::new("DYDX-001");
1173        let ts_init = UnixNanos::default();
1174        let order_id_map = DashMap::new();
1175        let order_contexts = DashMap::new();
1176        let encoder = ClientOrderIdEncoder::new();
1177
1178        let result = parse_ws_fill_report(
1179            &ws_fill,
1180            &instrument_cache,
1181            &order_id_map,
1182            &order_contexts,
1183            &encoder,
1184            account_id,
1185            ts_init,
1186        );
1187        assert!(result.is_ok());
1188
1189        let fill_report = result.unwrap();
1190        assert_eq!(fill_report.instrument_id, instrument_id);
1191        assert_eq!(fill_report.venue_order_id.as_str(), "order999");
1192        assert_eq!(fill_report.last_qty.as_f64(), 0.5);
1193        assert_eq!(fill_report.last_px.as_f64(), 49500.0);
1194        assert_eq!(fill_report.commission.as_decimal(), dec!(12.38));
1195    }
1196
1197    #[rstest]
1198    fn test_parse_ws_fill_report_missing_instrument() {
1199        let instrument_cache = InstrumentCache::new(); // Empty - no instruments cached
1200
1201        let ws_fill = DydxWsFillSubaccountMessageContents {
1202            id: "fill000".to_string(),
1203            subaccount_id: "sub1".to_string(),
1204            side: OrderSide::Buy,
1205            liquidity: DydxLiquidity::Maker,
1206            fill_type: DydxFillType::Limit,
1207            market: "ETH-USD-PERP".into(),
1208            market_type: Some(DydxTickerType::Perpetual),
1209            price: "3000.0".to_string(),
1210            size: "1.0".to_string(),
1211            fee: "-1.5".to_string(),
1212            created_at: "2024-01-15T12:00:00Z".to_string(),
1213            created_at_height: Some("12500".to_string()),
1214            order_id: Some("order111".to_string()),
1215            client_metadata: Some("777".to_string()),
1216        };
1217
1218        let account_id = AccountId::new("DYDX-001");
1219        let ts_init = UnixNanos::default();
1220        let order_id_map = DashMap::new();
1221        let order_contexts = DashMap::new();
1222        let encoder = ClientOrderIdEncoder::new();
1223
1224        let result = parse_ws_fill_report(
1225            &ws_fill,
1226            &instrument_cache,
1227            &order_id_map,
1228            &order_contexts,
1229            &encoder,
1230            account_id,
1231            ts_init,
1232        );
1233        assert!(result.is_err());
1234        assert!(
1235            result
1236                .unwrap_err()
1237                .to_string()
1238                .contains("No instrument cached for market")
1239        );
1240    }
1241
1242    #[rstest]
1243    fn test_convert_ws_position_to_http() {
1244        let ws_position = DydxPerpetualPosition {
1245            market: "BTC-USD".into(),
1246            status: DydxPositionStatus::Open,
1247            side: DydxPositionSide::Long,
1248            size: "1.5".to_string(),
1249            max_size: "2.0".to_string(),
1250            entry_price: "50000.0".to_string(),
1251            exit_price: None,
1252            realized_pnl: "100.0".to_string(),
1253            unrealized_pnl: "250.5".to_string(),
1254            created_at: "2024-01-15T10:00:00Z".to_string(),
1255            closed_at: None,
1256            sum_open: "5.0".to_string(),
1257            sum_close: "3.5".to_string(),
1258            net_funding: "-10.25".to_string(),
1259        };
1260
1261        let result = convert_ws_position_to_http(&ws_position);
1262        assert!(result.is_ok());
1263
1264        let http_position = result.unwrap();
1265        assert_eq!(http_position.market, "BTC-USD");
1266        assert_eq!(http_position.status, DydxPositionStatus::Open);
1267        assert_eq!(http_position.side, DydxPositionSide::Long); // Positive size = Long
1268        assert_eq!(http_position.size, rust_decimal_macros::dec!(1.5));
1269        assert_eq!(http_position.max_size, rust_decimal_macros::dec!(2.0));
1270        assert_eq!(
1271            http_position.entry_price,
1272            rust_decimal_macros::dec!(50000.0)
1273        );
1274        assert_eq!(http_position.exit_price, None);
1275        assert_eq!(http_position.realized_pnl, rust_decimal_macros::dec!(100.0));
1276        assert_eq!(
1277            http_position.unrealized_pnl,
1278            rust_decimal_macros::dec!(250.5)
1279        );
1280        assert_eq!(http_position.sum_open, rust_decimal_macros::dec!(5.0));
1281        assert_eq!(http_position.sum_close, rust_decimal_macros::dec!(3.5));
1282        assert_eq!(http_position.net_funding, rust_decimal_macros::dec!(-10.25));
1283    }
1284
1285    /// The converter must preserve the venue-supplied `side`, not re-derive it from
1286    /// the sign of `size`. A zero-size position reported as `Long` must stay `Long`,
1287    /// and a mismatched (Long, negative-size) payload must retain the venue side.
1288    #[rstest]
1289    #[case::long_positive(DydxPositionSide::Long, "1.0", DydxPositionSide::Long)]
1290    #[case::short_negative(DydxPositionSide::Short, "-1.0", DydxPositionSide::Short)]
1291    #[case::long_zero(DydxPositionSide::Long, "0.0", DydxPositionSide::Long)]
1292    #[case::short_zero(DydxPositionSide::Short, "0.0", DydxPositionSide::Short)]
1293    #[case::long_with_negative_size(DydxPositionSide::Long, "-1.0", DydxPositionSide::Long)]
1294    #[case::short_with_positive_size(DydxPositionSide::Short, "1.0", DydxPositionSide::Short)]
1295    fn test_convert_ws_position_preserves_venue_side(
1296        #[case] venue_side: DydxPositionSide,
1297        #[case] size: &str,
1298        #[case] expected_side: DydxPositionSide,
1299    ) {
1300        let ws_position = DydxPerpetualPosition {
1301            market: "BTC-USD".into(),
1302            status: DydxPositionStatus::Open,
1303            side: venue_side,
1304            size: size.to_string(),
1305            max_size: "1.0".to_string(),
1306            entry_price: "50000.0".to_string(),
1307            exit_price: None,
1308            realized_pnl: "0.0".to_string(),
1309            unrealized_pnl: "0.0".to_string(),
1310            created_at: "2024-01-15T10:00:00Z".to_string(),
1311            closed_at: None,
1312            sum_open: "0.0".to_string(),
1313            sum_close: "0.0".to_string(),
1314            net_funding: "0.0".to_string(),
1315        };
1316
1317        let http_position =
1318            convert_ws_position_to_http(&ws_position).expect("conversion should succeed");
1319        assert_eq!(http_position.side, expected_side);
1320    }
1321
1322    /// End-to-end verification that the venue-supplied side flows through to the
1323    /// emitted `PositionStatusReport`. The previous implementation re-derived side
1324    /// from `size.is_sign_positive()` inside `parse_position_status_report`, which
1325    /// silently overrode the venue side for the mismatched case below.
1326    #[rstest]
1327    fn test_ws_position_report_emits_venue_side_for_mismatched_size() {
1328        use nautilus_model::enums::PositionSideSpecified;
1329
1330        let instrument_cache = create_test_instrument_cache();
1331        // Venue reports a Short position but the `size` field would round to
1332        // positive via the legacy sign check. The report must show Short.
1333        let ws_position = DydxPerpetualPosition {
1334            market: "BTC-USD".into(),
1335            status: DydxPositionStatus::Open,
1336            side: DydxPositionSide::Short,
1337            size: "1.0".to_string(),
1338            max_size: "1.0".to_string(),
1339            entry_price: "50000.0".to_string(),
1340            exit_price: None,
1341            realized_pnl: "0.0".to_string(),
1342            unrealized_pnl: "0.0".to_string(),
1343            created_at: "2024-01-15T10:00:00Z".to_string(),
1344            closed_at: None,
1345            sum_open: "0.0".to_string(),
1346            sum_close: "0.0".to_string(),
1347            net_funding: "0.0".to_string(),
1348        };
1349
1350        let report = parse_ws_position_report(
1351            &ws_position,
1352            &instrument_cache,
1353            AccountId::new("DYDX-001"),
1354            UnixNanos::default(),
1355        )
1356        .expect("parse should succeed");
1357        assert_eq!(report.position_side, PositionSideSpecified::Short);
1358    }
1359
1360    #[rstest]
1361    fn test_parse_ws_position_report_success() {
1362        let instrument_cache = create_test_instrument_cache();
1363        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD-PERP"), *DYDX_VENUE);
1364
1365        let ws_position = DydxPerpetualPosition {
1366            market: "BTC-USD".into(),
1367            status: DydxPositionStatus::Open,
1368            side: DydxPositionSide::Long,
1369            size: "0.5".to_string(),
1370            max_size: "1.0".to_string(),
1371            entry_price: "49500.0".to_string(),
1372            exit_price: None,
1373            realized_pnl: "0.0".to_string(),
1374            unrealized_pnl: "125.0".to_string(),
1375            created_at: "2024-01-15T09:00:00Z".to_string(),
1376            closed_at: None,
1377            sum_open: "0.5".to_string(),
1378            sum_close: "0.0".to_string(),
1379            net_funding: "-2.5".to_string(),
1380        };
1381
1382        let account_id = AccountId::new("DYDX-001");
1383        let ts_init = UnixNanos::default();
1384
1385        let result = parse_ws_position_report(&ws_position, &instrument_cache, account_id, ts_init);
1386        assert!(result.is_ok());
1387
1388        let position_report = result.unwrap();
1389        assert_eq!(position_report.instrument_id, instrument_id);
1390        assert_eq!(position_report.position_side, PositionSideSpecified::Long);
1391        assert_eq!(position_report.quantity.as_f64(), 0.5);
1392        // avg_px_open should be entry_price
1393        assert!(position_report.avg_px_open.is_some());
1394    }
1395
1396    #[rstest]
1397    fn test_parse_ws_position_report_short() {
1398        let instrument_cache = create_test_instrument_cache();
1399        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD-PERP"), *DYDX_VENUE);
1400
1401        let ws_position = DydxPerpetualPosition {
1402            market: "BTC-USD".into(),
1403            status: DydxPositionStatus::Open,
1404            side: DydxPositionSide::Short,
1405            size: "-0.25".to_string(), // Negative for short
1406            max_size: "0.5".to_string(),
1407            entry_price: "51000.0".to_string(),
1408            exit_price: None,
1409            realized_pnl: "50.0".to_string(),
1410            unrealized_pnl: "-75.25".to_string(),
1411            created_at: "2024-01-15T08:00:00Z".to_string(),
1412            closed_at: None,
1413            sum_open: "0.25".to_string(),
1414            sum_close: "0.0".to_string(),
1415            net_funding: "1.5".to_string(),
1416        };
1417
1418        let account_id = AccountId::new("DYDX-001");
1419        let ts_init = UnixNanos::default();
1420
1421        let result = parse_ws_position_report(&ws_position, &instrument_cache, account_id, ts_init);
1422        assert!(result.is_ok());
1423
1424        let position_report = result.unwrap();
1425        assert_eq!(position_report.instrument_id, instrument_id);
1426        assert_eq!(position_report.position_side, PositionSideSpecified::Short);
1427        assert_eq!(position_report.quantity.as_f64(), 0.25); // Quantity is always positive
1428    }
1429
1430    #[rstest]
1431    fn test_parse_ws_position_report_missing_instrument() {
1432        let instrument_cache = InstrumentCache::new(); // Empty - no instruments cached
1433
1434        let ws_position = DydxPerpetualPosition {
1435            market: "ETH-USD-PERP".into(),
1436            status: DydxPositionStatus::Open,
1437            side: DydxPositionSide::Long,
1438            size: "5.0".to_string(),
1439            max_size: "10.0".to_string(),
1440            entry_price: "3000.0".to_string(),
1441            exit_price: None,
1442            realized_pnl: "0.0".to_string(),
1443            unrealized_pnl: "500.0".to_string(),
1444            created_at: "2024-01-15T07:00:00Z".to_string(),
1445            closed_at: None,
1446            sum_open: "5.0".to_string(),
1447            sum_close: "0.0".to_string(),
1448            net_funding: "-5.0".to_string(),
1449        };
1450
1451        let account_id = AccountId::new("DYDX-001");
1452        let ts_init = UnixNanos::default();
1453
1454        let result = parse_ws_position_report(&ws_position, &instrument_cache, account_id, ts_init);
1455        assert!(result.is_err());
1456        assert!(
1457            result
1458                .unwrap_err()
1459                .to_string()
1460                .contains("No instrument cached for market")
1461        );
1462    }
1463
1464    #[rstest]
1465    #[case(DydxOrderStatus::Filled, "2.0")]
1466    #[case(DydxOrderStatus::Canceled, "0.0")]
1467    #[case(DydxOrderStatus::BestEffortCanceled, "0.5")]
1468    #[case(DydxOrderStatus::BestEffortOpened, "0.0")]
1469    #[case(DydxOrderStatus::Untriggered, "0.0")]
1470    fn test_parse_ws_order_various_statuses(
1471        #[case] status: DydxOrderStatus,
1472        #[case] total_filled: &str,
1473    ) {
1474        let ws_order = DydxWsOrderSubaccountMessageContents {
1475            id: format!("order_{status:?}"),
1476            subaccount_id: "dydx1test/0".to_string(),
1477            client_id: "99999".to_string(),
1478            clob_pair_id: "1".to_string(),
1479            side: OrderSide::Buy,
1480            size: "2.0".to_string(),
1481            price: "50000.0".to_string(),
1482            status,
1483            order_type: DydxOrderType::Limit,
1484            time_in_force: DydxTimeInForce::Gtt,
1485            post_only: false,
1486            reduce_only: false,
1487            order_flags: "0".to_string(),
1488            good_til_block: Some("1000".to_string()),
1489            good_til_block_time: None,
1490            created_at_height: Some("900".to_string()),
1491            client_metadata: Some("0".to_string()),
1492            trigger_price: None,
1493            total_filled: Some(total_filled.to_string()),
1494            updated_at: Some("2024-11-14T10:00:00Z".to_string()),
1495            updated_at_height: Some("950".to_string()),
1496        };
1497
1498        let instrument_cache = create_test_instrument_cache();
1499        let encoder = ClientOrderIdEncoder::new();
1500
1501        let account_id = AccountId::new("DYDX-001");
1502        let ts_init = UnixNanos::default();
1503        let order_contexts: DashMap<u32, OrderContext> = DashMap::new();
1504
1505        let result = parse_ws_order_report(
1506            &ws_order,
1507            &instrument_cache,
1508            &order_contexts,
1509            &encoder,
1510            account_id,
1511            ts_init,
1512        );
1513
1514        assert!(
1515            result.is_ok(),
1516            "Failed to parse order with status {status:?}"
1517        );
1518        let report = result.unwrap();
1519
1520        // Verify status conversion
1521        let expected_status = match status {
1522            DydxOrderStatus::Open
1523            | DydxOrderStatus::BestEffortOpened
1524            | DydxOrderStatus::Untriggered => OrderStatus::Accepted,
1525            DydxOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
1526            DydxOrderStatus::Filled => OrderStatus::Filled,
1527            DydxOrderStatus::Canceled | DydxOrderStatus::BestEffortCanceled => {
1528                OrderStatus::Canceled
1529            }
1530        };
1531        assert_eq!(report.order_status, expected_status);
1532    }
1533
1534    #[rstest]
1535    fn test_parse_ws_order_with_trigger_price() {
1536        let ws_order = DydxWsOrderSubaccountMessageContents {
1537            id: "conditional_order".to_string(),
1538            subaccount_id: "dydx1test/0".to_string(),
1539            client_id: "88888".to_string(),
1540            clob_pair_id: "1".to_string(),
1541            side: OrderSide::Sell,
1542            size: "1.0".to_string(),
1543            price: "52000.0".to_string(),
1544            status: DydxOrderStatus::Untriggered,
1545            order_type: DydxOrderType::StopLimit,
1546            time_in_force: DydxTimeInForce::Gtt,
1547            post_only: false,
1548            reduce_only: true,
1549            order_flags: "32".to_string(),
1550            good_til_block: None,
1551            good_til_block_time: Some("2024-12-31T23:59:59Z".to_string()),
1552            created_at_height: Some("1000".to_string()),
1553            client_metadata: Some("100".to_string()),
1554            trigger_price: Some("51500.0".to_string()),
1555            total_filled: Some("0.0".to_string()),
1556            updated_at: Some("2024-11-14T11:00:00Z".to_string()),
1557            updated_at_height: Some("1050".to_string()),
1558        };
1559
1560        let instrument_cache = create_test_instrument_cache();
1561        let encoder = ClientOrderIdEncoder::new();
1562
1563        let account_id = AccountId::new("DYDX-001");
1564        let ts_init = UnixNanos::default();
1565        let order_contexts: DashMap<u32, OrderContext> = DashMap::new();
1566
1567        let result = parse_ws_order_report(
1568            &ws_order,
1569            &instrument_cache,
1570            &order_contexts,
1571            &encoder,
1572            account_id,
1573            ts_init,
1574        );
1575
1576        assert!(result.is_ok());
1577        let report = result.unwrap();
1578        assert_eq!(report.order_status, OrderStatus::PendingUpdate);
1579        // Trigger price should be parsed and available in the report
1580        assert!(report.trigger_price.is_some());
1581    }
1582
1583    #[rstest]
1584    fn test_parse_ws_order_market_type() {
1585        let ws_order = DydxWsOrderSubaccountMessageContents {
1586            id: "market_order".to_string(),
1587            subaccount_id: "dydx1test/0".to_string(),
1588            client_id: "77777".to_string(),
1589            clob_pair_id: "1".to_string(),
1590            side: OrderSide::Buy,
1591            size: "0.5".to_string(),
1592            price: "50000.0".to_string(), // Market orders still have a price
1593            status: DydxOrderStatus::Filled,
1594            order_type: DydxOrderType::Market,
1595            time_in_force: DydxTimeInForce::Ioc,
1596            post_only: false,
1597            reduce_only: false,
1598            order_flags: "0".to_string(),
1599            good_til_block: Some("1000".to_string()),
1600            good_til_block_time: None,
1601            created_at_height: Some("900".to_string()),
1602            client_metadata: Some("0".to_string()),
1603            trigger_price: None,
1604            total_filled: Some("0.5".to_string()),
1605            updated_at: Some("2024-11-14T10:01:00Z".to_string()),
1606            updated_at_height: Some("901".to_string()),
1607        };
1608
1609        let instrument_cache = create_test_instrument_cache();
1610        let encoder = ClientOrderIdEncoder::new();
1611
1612        let account_id = AccountId::new("DYDX-001");
1613        let ts_init = UnixNanos::default();
1614        let order_contexts: DashMap<u32, OrderContext> = DashMap::new();
1615
1616        let result = parse_ws_order_report(
1617            &ws_order,
1618            &instrument_cache,
1619            &order_contexts,
1620            &encoder,
1621            account_id,
1622            ts_init,
1623        );
1624
1625        assert!(result.is_ok());
1626        let report = result.unwrap();
1627        assert_eq!(report.order_type, OrderType::Market);
1628        assert_eq!(report.order_status, OrderStatus::Filled);
1629    }
1630
1631    #[rstest]
1632    fn test_parse_ws_order_invalid_clob_pair_id() {
1633        let ws_order = DydxWsOrderSubaccountMessageContents {
1634            id: "bad_order".to_string(),
1635            subaccount_id: "dydx1test/0".to_string(),
1636            client_id: "12345".to_string(),
1637            clob_pair_id: "not_a_number".to_string(), // Invalid
1638            side: OrderSide::Buy,
1639            size: "1.0".to_string(),
1640            price: "50000.0".to_string(),
1641            status: DydxOrderStatus::Open,
1642            order_type: DydxOrderType::Limit,
1643            time_in_force: DydxTimeInForce::Gtt,
1644            post_only: false,
1645            reduce_only: false,
1646            order_flags: "0".to_string(),
1647            good_til_block: Some("1000".to_string()),
1648            good_til_block_time: None,
1649            created_at_height: Some("900".to_string()),
1650            client_metadata: Some("0".to_string()),
1651            trigger_price: None,
1652            total_filled: Some("0.0".to_string()),
1653            updated_at: None,
1654            updated_at_height: None,
1655        };
1656
1657        let instrument_cache = InstrumentCache::new(); // Empty cache
1658        let encoder = ClientOrderIdEncoder::new();
1659        let account_id = AccountId::new("DYDX-001");
1660        let ts_init = UnixNanos::default();
1661        let order_contexts: DashMap<u32, OrderContext> = DashMap::new();
1662
1663        let result = parse_ws_order_report(
1664            &ws_order,
1665            &instrument_cache,
1666            &order_contexts,
1667            &encoder,
1668            account_id,
1669            ts_init,
1670        );
1671
1672        assert!(result.is_err());
1673        assert!(
1674            result
1675                .unwrap_err()
1676                .to_string()
1677                .contains("Failed to parse clob_pair_id")
1678        );
1679    }
1680
1681    #[rstest]
1682    fn test_parse_ws_position_closed() {
1683        let instrument_cache = create_test_instrument_cache();
1684        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD-PERP"), *DYDX_VENUE);
1685
1686        let ws_position = DydxPerpetualPosition {
1687            market: "BTC-USD".into(),
1688            status: DydxPositionStatus::Closed,
1689            side: DydxPositionSide::Long,
1690            size: "0.0".to_string(), // Closed = zero size
1691            max_size: "2.0".to_string(),
1692            entry_price: "48000.0".to_string(),
1693            exit_price: Some("52000.0".to_string()),
1694            realized_pnl: "2000.0".to_string(),
1695            unrealized_pnl: "0.0".to_string(),
1696            created_at: "2024-01-10T09:00:00Z".to_string(),
1697            closed_at: Some("2024-01-15T14:00:00Z".to_string()),
1698            sum_open: "5.0".to_string(),
1699            sum_close: "5.0".to_string(), // Fully closed
1700            net_funding: "-25.5".to_string(),
1701        };
1702
1703        let account_id = AccountId::new("DYDX-001");
1704        let ts_init = UnixNanos::default();
1705
1706        let result = parse_ws_position_report(&ws_position, &instrument_cache, account_id, ts_init);
1707        assert!(result.is_ok());
1708
1709        let position_report = result.unwrap();
1710        assert_eq!(position_report.instrument_id, instrument_id);
1711        // Closed position should have zero quantity
1712        assert_eq!(position_report.quantity.as_f64(), 0.0);
1713    }
1714
1715    #[rstest]
1716    fn test_parse_ws_fill_with_maker_rebate() {
1717        let instrument_cache = create_test_instrument_cache();
1718
1719        let ws_fill = DydxWsFillSubaccountMessageContents {
1720            id: "fill_rebate".to_string(),
1721            subaccount_id: "sub1".to_string(),
1722            side: OrderSide::Buy,
1723            liquidity: DydxLiquidity::Maker,
1724            fill_type: DydxFillType::Limit,
1725            market: "BTC-USD".into(),
1726            market_type: Some(DydxTickerType::Perpetual),
1727            price: "50000.0".to_string(),
1728            size: "1.0".to_string(),
1729            fee: "-15.0".to_string(), // Negative fee = rebate
1730            created_at: "2024-01-15T13:00:00Z".to_string(),
1731            created_at_height: Some("13000".to_string()),
1732            order_id: Some("order_maker".to_string()),
1733            client_metadata: Some("200".to_string()),
1734        };
1735
1736        let account_id = AccountId::new("DYDX-001");
1737        let ts_init = UnixNanos::default();
1738        let order_id_map = DashMap::new();
1739        let order_contexts = DashMap::new();
1740        let encoder = ClientOrderIdEncoder::new();
1741
1742        let result = parse_ws_fill_report(
1743            &ws_fill,
1744            &instrument_cache,
1745            &order_id_map,
1746            &order_contexts,
1747            &encoder,
1748            account_id,
1749            ts_init,
1750        );
1751        assert!(result.is_ok());
1752
1753        let fill_report = result.unwrap();
1754        assert_eq!(fill_report.liquidity_side, LiquiditySide::Maker);
1755        assert!(fill_report.commission.as_decimal() < dec!(0));
1756    }
1757
1758    #[rstest]
1759    fn test_parse_ws_fill_taker_with_fee() {
1760        let instrument_cache = create_test_instrument_cache();
1761
1762        let ws_fill = DydxWsFillSubaccountMessageContents {
1763            id: "fill_taker".to_string(),
1764            subaccount_id: "sub2".to_string(),
1765            side: OrderSide::Sell,
1766            liquidity: DydxLiquidity::Taker,
1767            fill_type: DydxFillType::Limit,
1768            market: "BTC-USD".into(),
1769            market_type: Some(DydxTickerType::Perpetual),
1770            price: "49800.0".to_string(),
1771            size: "0.75".to_string(),
1772            fee: "18.675".to_string(), // Positive fee for taker
1773            created_at: "2024-01-15T14:00:00Z".to_string(),
1774            created_at_height: Some("14000".to_string()),
1775            order_id: Some("order_taker".to_string()),
1776            client_metadata: Some("300".to_string()),
1777        };
1778
1779        let account_id = AccountId::new("DYDX-001");
1780        let ts_init = UnixNanos::default();
1781        let order_id_map = DashMap::new();
1782        let order_contexts = DashMap::new();
1783        let encoder = ClientOrderIdEncoder::new();
1784
1785        let result = parse_ws_fill_report(
1786            &ws_fill,
1787            &instrument_cache,
1788            &order_id_map,
1789            &order_contexts,
1790            &encoder,
1791            account_id,
1792            ts_init,
1793        );
1794        assert!(result.is_ok());
1795
1796        let fill_report = result.unwrap();
1797        assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
1798        assert_eq!(fill_report.order_side, OrderSide::Sell);
1799        assert!(fill_report.commission.as_decimal() > dec!(0));
1800    }
1801
1802    #[rstest]
1803    fn test_parse_orderbook_snapshot() {
1804        let json = load_json_fixture("ws_orderbook_subscribed.json");
1805        let contents: DydxOrderbookSnapshotContents =
1806            serde_json::from_value(json["contents"].clone())
1807                .expect("Failed to parse orderbook snapshot contents");
1808
1809        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
1810        let ts_init = UnixNanos::from(1_000_000_000u64);
1811
1812        let deltas = parse_orderbook_snapshot(&instrument_id, &contents, 2, 8, ts_init)
1813            .expect("Failed to parse orderbook snapshot");
1814
1815        // 1 clear + 3 bids + 3 asks = 7 deltas
1816        assert_eq!(deltas.deltas.len(), 7);
1817
1818        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1819        assert_eq!(deltas.deltas[1].action, BookAction::Add);
1820        assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy);
1821        assert_eq!(deltas.deltas[1].order.price.to_string(), "43240.00");
1822        assert_eq!(deltas.deltas[1].order.size.to_string(), "1.50000000");
1823
1824        assert_eq!(deltas.deltas[4].action, BookAction::Add);
1825        assert_eq!(deltas.deltas[4].order.side, OrderSide::Sell);
1826        assert_eq!(deltas.deltas[4].order.price.to_string(), "43250.00");
1827        assert_eq!(deltas.deltas[4].order.size.to_string(), "1.20000000");
1828
1829        // Every snapshot delta must carry F_SNAPSHOT. The Clear carries F_SNAPSHOT only
1830        // (not last); every intermediate delta carries F_SNAPSHOT only; the terminator
1831        // carries F_SNAPSHOT | F_LAST.
1832        let snapshot = RecordFlag::F_SNAPSHOT as u8;
1833        let last_flag = RecordFlag::F_LAST as u8;
1834
1835        assert_eq!(deltas.deltas[0].flags, snapshot, "Clear missing F_SNAPSHOT");
1836        for (idx, delta) in deltas.deltas.iter().enumerate().skip(1) {
1837            let expected = if idx == deltas.deltas.len() - 1 {
1838                snapshot | last_flag
1839            } else {
1840                snapshot
1841            };
1842            assert_eq!(
1843                delta.flags, expected,
1844                "delta at index {idx} has wrong flags: got {:#010b}, expected {expected:#010b}",
1845                delta.flags,
1846            );
1847        }
1848    }
1849
1850    #[rstest]
1851    #[case::empty_book(vec![], vec![], 1)]
1852    #[case::bids_only(vec![("100.0", "1.0")], vec![], 2)]
1853    #[case::asks_only(vec![], vec![("101.0", "2.0")], 2)]
1854    fn test_parse_orderbook_snapshot_flag_shapes(
1855        #[case] bids: Vec<(&str, &str)>,
1856        #[case] asks: Vec<(&str, &str)>,
1857        #[case] expected_len: usize,
1858    ) {
1859        use crate::websocket::messages::DydxPriceLevel;
1860        let contents = DydxOrderbookSnapshotContents {
1861            bids: if bids.is_empty() {
1862                None
1863            } else {
1864                Some(
1865                    bids.into_iter()
1866                        .map(|(p, s)| DydxPriceLevel {
1867                            price: p.to_string(),
1868                            size: s.to_string(),
1869                        })
1870                        .collect(),
1871                )
1872            },
1873            asks: if asks.is_empty() {
1874                None
1875            } else {
1876                Some(
1877                    asks.into_iter()
1878                        .map(|(p, s)| DydxPriceLevel {
1879                            price: p.to_string(),
1880                            size: s.to_string(),
1881                        })
1882                        .collect(),
1883                )
1884            },
1885        };
1886        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
1887        let ts_init = UnixNanos::from(1_000_000_000u64);
1888
1889        let deltas = parse_orderbook_snapshot(&instrument_id, &contents, 2, 8, ts_init)
1890            .expect("Failed to parse orderbook snapshot");
1891
1892        let snapshot = RecordFlag::F_SNAPSHOT as u8;
1893        let last_flag = RecordFlag::F_LAST as u8;
1894
1895        assert_eq!(deltas.deltas.len(), expected_len);
1896
1897        if expected_len == 1 {
1898            // Empty book: Clear alone must carry F_SNAPSHOT | F_LAST so buffered
1899            // subscribers flush when the book is empty.
1900            assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1901            assert_eq!(deltas.deltas[0].flags, snapshot | last_flag);
1902        } else {
1903            // Non-empty: Clear carries F_SNAPSHOT only; terminator carries both.
1904            assert_eq!(deltas.deltas[0].flags, snapshot);
1905            let terminator = deltas.deltas.last().unwrap();
1906            assert_eq!(terminator.flags, snapshot | last_flag);
1907        }
1908    }
1909
1910    #[rstest]
1911    fn test_parse_orderbook_deltas_update() {
1912        let json = load_json_fixture("ws_orderbook_update.json");
1913        let contents: DydxOrderbookContents = serde_json::from_value(json["contents"].clone())
1914            .expect("Failed to parse orderbook update contents");
1915
1916        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
1917        let ts_init = UnixNanos::from(1_000_000_000u64);
1918
1919        let deltas = parse_orderbook_deltas(&instrument_id, &contents, 2, 8, ts_init)
1920            .expect("Failed to parse orderbook deltas");
1921
1922        // 2 bids + 2 asks = 4 deltas
1923        assert_eq!(deltas.deltas.len(), 4);
1924
1925        assert_eq!(deltas.deltas[0].action, BookAction::Update);
1926        assert_eq!(deltas.deltas[0].order.side, OrderSide::Buy);
1927        assert_eq!(deltas.deltas[0].order.price.to_string(), "43240.00");
1928
1929        // First ask with size 0.0 should be a Delete
1930        assert_eq!(deltas.deltas[2].action, BookAction::Delete);
1931        assert_eq!(deltas.deltas[2].order.side, OrderSide::Sell);
1932        assert_eq!(deltas.deltas[2].order.price.to_string(), "43250.00");
1933
1934        assert_eq!(deltas.deltas[3].action, BookAction::Update);
1935        assert_eq!(deltas.deltas[3].order.side, OrderSide::Sell);
1936    }
1937
1938    #[rstest]
1939    fn test_parse_trade_ticks_ws() {
1940        let json = load_json_fixture("ws_trades_subscribed.json");
1941        let contents: DydxTradeContents = serde_json::from_value(json["contents"].clone())
1942            .expect("Failed to parse trade contents");
1943
1944        let instrument = create_test_instrument();
1945        let instrument_id = instrument.id();
1946        let ts_init = UnixNanos::from(1_000_000_000u64);
1947
1948        let ticks = parse_trade_ticks(instrument_id, &instrument, &contents, ts_init)
1949            .expect("Failed to parse trade ticks");
1950
1951        assert_eq!(ticks.len(), 1);
1952        if let Data::Trade(tick) = &ticks[0] {
1953            assert_eq!(tick.instrument_id, instrument_id);
1954            assert_eq!(tick.price.to_string(), "43250.00");
1955            assert_eq!(tick.size.to_string(), "0.50000000");
1956            assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
1957            assert_eq!(tick.trade_id.to_string(), "trade-001");
1958        } else {
1959            panic!("Expected Trade data");
1960        }
1961    }
1962
1963    #[rstest]
1964    #[case(true)]
1965    #[case(false)]
1966    fn test_parse_candle_bar_timestamp_on_close(#[case] timestamp_on_close: bool) {
1967        let json = load_json_fixture("ws_candles_subscribed.json");
1968        let candles_value = &json["contents"]["candles"];
1969        let candles: Vec<DydxCandle> =
1970            serde_json::from_value(candles_value.clone()).expect("Failed to parse candle array");
1971
1972        let instrument = create_test_instrument();
1973        let bar_type = BarType::from_str("BTC-USD-PERP.DYDX-1-MINUTE-LAST-EXTERNAL")
1974            .expect("Failed to parse bar type");
1975        let ts_init = UnixNanos::from(1_000_000_000u64);
1976
1977        let bar = parse_candle_bar(
1978            bar_type,
1979            &instrument,
1980            &candles[0],
1981            timestamp_on_close,
1982            ts_init,
1983        )
1984        .expect("Failed to parse candle bar");
1985
1986        assert_eq!(bar.bar_type, bar_type);
1987        assert_eq!(bar.open.to_string(), "43100.00");
1988        assert_eq!(bar.high.to_string(), "43500.00");
1989        assert_eq!(bar.low.to_string(), "43000.00");
1990        assert_eq!(bar.close.to_string(), "43400.00");
1991        assert_eq!(bar.volume.to_string(), "12.34500000");
1992
1993        // 2024-01-01T00:00:00.000Z = 1_704_067_200_000_000_000 ns
1994        let started_at_ns = 1_704_067_200_000_000_000u64;
1995        let one_min_ns = 60_000_000_000u64;
1996
1997        if timestamp_on_close {
1998            assert_eq!(bar.ts_event.as_u64(), started_at_ns + one_min_ns);
1999        } else {
2000            assert_eq!(bar.ts_event.as_u64(), started_at_ns);
2001        }
2002    }
2003
2004    #[rstest]
2005    fn test_deserialize_market_trading_update_with_status() {
2006        let json = load_json_fixture("ws_markets_status_update.json");
2007        let contents: super::super::messages::DydxMarketsContents =
2008            serde_json::from_value(json["contents"].clone())
2009                .expect("Failed to deserialize markets contents");
2010
2011        let trading = contents.trading.expect("Expected trading data");
2012        assert_eq!(trading.len(), 2);
2013
2014        let btc = trading.get("BTC-USD").expect("Expected BTC-USD");
2015        assert_eq!(btc.status, Some(DydxMarketStatus::Paused));
2016        assert_eq!(btc.next_funding_rate, Some("0.0001".to_string()));
2017
2018        let eth = trading.get("ETH-USD").expect("Expected ETH-USD");
2019        assert_eq!(eth.status, Some(DydxMarketStatus::Active));
2020    }
2021
2022    #[rstest]
2023    #[case("ACTIVE", DydxMarketStatus::Active)]
2024    #[case("PAUSED", DydxMarketStatus::Paused)]
2025    #[case("CANCEL_ONLY", DydxMarketStatus::CancelOnly)]
2026    #[case("POST_ONLY", DydxMarketStatus::PostOnly)]
2027    #[case("INITIALIZING", DydxMarketStatus::Initializing)]
2028    #[case("FINAL_SETTLEMENT", DydxMarketStatus::FinalSettlement)]
2029    fn test_deserialize_market_status_variants(
2030        #[case] status_str: &str,
2031        #[case] expected: DydxMarketStatus,
2032    ) {
2033        let json_str = format!(r#"{{"status": "{status_str}"}}"#);
2034        let update: super::super::messages::DydxMarketTradingUpdate =
2035            serde_json::from_str(&json_str).expect("Failed to deserialize");
2036        assert_eq!(update.status, Some(expected));
2037    }
2038
2039    #[rstest]
2040    fn test_deserialize_market_trading_update_without_status() {
2041        let json_str = r#"{"nextFundingRate": "0.0001"}"#;
2042        let update: super::super::messages::DydxMarketTradingUpdate =
2043            serde_json::from_str(json_str).expect("Failed to deserialize");
2044        assert_eq!(update.status, None);
2045        assert_eq!(update.next_funding_rate, Some("0.0001".to_string()));
2046    }
2047}