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