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                .ts_event(UnixNanos::default())
964                .ts_init(UnixNanos::default())
965                .build()
966                .unwrap(),
967        )
968    }
969
970    #[rstest]
971    fn test_convert_ws_order_to_http_basic() {
972        let ws_order = DydxWsOrderSubaccountMessageContents {
973            id: "order123".to_string(),
974            subaccount_id: "dydx1test/0".to_string(),
975            client_id: "12345".to_string(),
976            clob_pair_id: "1".to_string(),
977            side: OrderSide::Buy,
978            size: "1.5".to_string(),
979            price: "50000.0".to_string(),
980            status: DydxOrderStatus::PartiallyFilled,
981            order_type: DydxOrderType::Limit,
982            time_in_force: DydxTimeInForce::Gtt,
983            post_only: false,
984            reduce_only: false,
985            order_flags: "0".to_string(),
986            good_til_block: Some("1000".to_string()),
987            good_til_block_time: None,
988            created_at_height: Some("900".to_string()),
989            client_metadata: Some("0".to_string()),
990            trigger_price: None,
991            total_filled: Some("0.5".to_string()),
992            updated_at: Some("2024-11-14T10:00:00Z".to_string()),
993            updated_at_height: Some("950".to_string()),
994        };
995
996        let result = convert_ws_order_to_http(&ws_order);
997        assert!(result.is_ok());
998
999        let http_order = result.unwrap();
1000        assert_eq!(http_order.id, "order123");
1001        assert_eq!(http_order.clob_pair_id, 1);
1002        assert_eq!(http_order.size.to_string(), "1.5");
1003        assert_eq!(http_order.total_filled, rust_decimal_macros::dec!(0.5)); // 0.5 filled
1004        assert_eq!(http_order.status, DydxOrderStatus::PartiallyFilled);
1005    }
1006
1007    #[rstest]
1008    fn test_parse_ws_order_report_success() {
1009        let ws_order = DydxWsOrderSubaccountMessageContents {
1010            id: "order456".to_string(),
1011            subaccount_id: "dydx1test/0".to_string(),
1012            client_id: "67890".to_string(),
1013            clob_pair_id: "1".to_string(),
1014            side: OrderSide::Sell,
1015            size: "2.0".to_string(),
1016            price: "51000.0".to_string(),
1017            status: DydxOrderStatus::Open,
1018            order_type: DydxOrderType::Limit,
1019            time_in_force: DydxTimeInForce::Gtt,
1020            post_only: true,
1021            reduce_only: false,
1022            order_flags: "0".to_string(),
1023            good_til_block: Some("2000".to_string()),
1024            good_til_block_time: None,
1025            created_at_height: Some("1800".to_string()),
1026            client_metadata: Some("0".to_string()),
1027            trigger_price: None,
1028            total_filled: Some("0.0".to_string()),
1029            updated_at: None,
1030            updated_at_height: None,
1031        };
1032
1033        let instrument_cache = create_test_instrument_cache();
1034        let encoder = ClientOrderIdEncoder::new();
1035
1036        let account_id = AccountId::new("DYDX-001");
1037        let ts_init = UnixNanos::default();
1038        let order_contexts: DashMap<u32, OrderContext> = DashMap::new();
1039
1040        let result = parse_ws_order_report(
1041            &ws_order,
1042            &instrument_cache,
1043            &order_contexts,
1044            &encoder,
1045            account_id,
1046            ts_init,
1047        );
1048
1049        assert!(result.is_ok());
1050        let report = result.unwrap();
1051        assert_eq!(report.account_id, account_id);
1052        assert_eq!(report.order_side, Some(OrderSide::Sell));
1053    }
1054
1055    #[rstest]
1056    fn test_parse_ws_order_report_missing_instrument() {
1057        let ws_order = DydxWsOrderSubaccountMessageContents {
1058            id: "order789".to_string(),
1059            subaccount_id: "dydx1test/0".to_string(),
1060            client_id: "11111".to_string(),
1061            clob_pair_id: "99".to_string(), // Non-existent
1062            side: OrderSide::Buy,
1063            size: "1.0".to_string(),
1064            price: "50000.0".to_string(),
1065            status: DydxOrderStatus::Open,
1066            order_type: DydxOrderType::Market,
1067            time_in_force: DydxTimeInForce::Ioc,
1068            post_only: false,
1069            reduce_only: false,
1070            order_flags: "0".to_string(),
1071            good_til_block: Some("1000".to_string()),
1072            good_til_block_time: None,
1073            created_at_height: Some("900".to_string()),
1074            client_metadata: Some("0".to_string()),
1075            trigger_price: None,
1076            total_filled: Some("0.0".to_string()),
1077            updated_at: None,
1078            updated_at_height: None,
1079        };
1080
1081        let instrument_cache = InstrumentCache::new(); // Empty cache
1082        let encoder = ClientOrderIdEncoder::new();
1083        let account_id = AccountId::new("DYDX-001");
1084        let ts_init = UnixNanos::default();
1085        let order_contexts: DashMap<u32, OrderContext> = DashMap::new();
1086
1087        let result = parse_ws_order_report(
1088            &ws_order,
1089            &instrument_cache,
1090            &order_contexts,
1091            &encoder,
1092            account_id,
1093            ts_init,
1094        );
1095
1096        assert!(result.is_err());
1097        assert!(
1098            result
1099                .unwrap_err()
1100                .to_string()
1101                .contains("No instrument cached")
1102        );
1103    }
1104
1105    #[rstest]
1106    fn test_convert_ws_fill_to_http() {
1107        let ws_fill = DydxWsFillSubaccountMessageContents {
1108            id: "fill123".to_string(),
1109            subaccount_id: "sub1".to_string(),
1110            side: OrderSide::Buy,
1111            liquidity: DydxLiquidity::Maker,
1112            fill_type: DydxFillType::Limit,
1113            market: "BTC-USD".into(),
1114            market_type: Some(DydxTickerType::Perpetual),
1115            price: "50000.5".to_string(),
1116            size: "0.1".to_string(),
1117            fee: "-2.5".to_string(), // Negative for maker rebate
1118            created_at: "2024-01-15T10:30:00Z".to_string(),
1119            created_at_height: Some("12345".to_string()),
1120            order_id: Some("order456".to_string()),
1121            client_metadata: Some("999".to_string()),
1122        };
1123
1124        let result = convert_ws_fill_to_http(&ws_fill);
1125        assert!(result.is_ok());
1126
1127        let http_fill = result.unwrap();
1128        assert_eq!(http_fill.id, "fill123");
1129        assert_eq!(http_fill.side, OrderSide::Buy);
1130        assert_eq!(http_fill.liquidity, DydxLiquidity::Maker);
1131        assert_eq!(http_fill.price, rust_decimal_macros::dec!(50000.5));
1132        assert_eq!(http_fill.size, rust_decimal_macros::dec!(0.1));
1133        assert_eq!(http_fill.fee, rust_decimal_macros::dec!(-2.5));
1134        assert_eq!(http_fill.created_at_height, 12345);
1135        assert_eq!(http_fill.order_id, "order456");
1136        assert_eq!(http_fill.client_metadata, 999);
1137    }
1138
1139    #[rstest]
1140    fn test_parse_ws_fill_report_success() {
1141        let instrument_cache = create_test_instrument_cache();
1142        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD-PERP"), *DYDX_VENUE);
1143
1144        // dYdX WS fills use market format "BTC-USD" (not "BTC-USD-PERP")
1145        // but the instrument symbol is "BTC-USD-PERP"
1146        let ws_fill = DydxWsFillSubaccountMessageContents {
1147            id: "fill789".to_string(),
1148            subaccount_id: "sub1".to_string(),
1149            side: OrderSide::Sell,
1150            liquidity: DydxLiquidity::Taker,
1151            fill_type: DydxFillType::Limit,
1152            market: "BTC-USD".into(),
1153            market_type: Some(DydxTickerType::Perpetual),
1154            price: "49500.0".to_string(),
1155            size: "0.5".to_string(),
1156            fee: "12.375".to_string(), // Positive for taker fee
1157            created_at: "2024-01-15T11:00:00Z".to_string(),
1158            created_at_height: Some("12400".to_string()),
1159            order_id: Some("order999".to_string()),
1160            client_metadata: Some("888".to_string()),
1161        };
1162
1163        let account_id = AccountId::new("DYDX-001");
1164        let ts_init = UnixNanos::default();
1165        let order_id_map = DashMap::new();
1166        let order_contexts = DashMap::new();
1167        let encoder = ClientOrderIdEncoder::new();
1168
1169        let result = parse_ws_fill_report(
1170            &ws_fill,
1171            &instrument_cache,
1172            &order_id_map,
1173            &order_contexts,
1174            &encoder,
1175            account_id,
1176            ts_init,
1177        );
1178        assert!(result.is_ok());
1179
1180        let fill_report = result.unwrap();
1181        assert_eq!(fill_report.instrument_id, instrument_id);
1182        assert_eq!(fill_report.venue_order_id.as_str(), "order999");
1183        assert_eq!(fill_report.last_qty.as_f64(), 0.5);
1184        assert_eq!(fill_report.last_px.as_f64(), 49500.0);
1185        assert_eq!(fill_report.commission.as_decimal(), dec!(12.38));
1186    }
1187
1188    #[rstest]
1189    fn test_parse_ws_fill_report_missing_instrument() {
1190        let instrument_cache = InstrumentCache::new(); // Empty - no instruments cached
1191
1192        let ws_fill = DydxWsFillSubaccountMessageContents {
1193            id: "fill000".to_string(),
1194            subaccount_id: "sub1".to_string(),
1195            side: OrderSide::Buy,
1196            liquidity: DydxLiquidity::Maker,
1197            fill_type: DydxFillType::Limit,
1198            market: "ETH-USD-PERP".into(),
1199            market_type: Some(DydxTickerType::Perpetual),
1200            price: "3000.0".to_string(),
1201            size: "1.0".to_string(),
1202            fee: "-1.5".to_string(),
1203            created_at: "2024-01-15T12:00:00Z".to_string(),
1204            created_at_height: Some("12500".to_string()),
1205            order_id: Some("order111".to_string()),
1206            client_metadata: Some("777".to_string()),
1207        };
1208
1209        let account_id = AccountId::new("DYDX-001");
1210        let ts_init = UnixNanos::default();
1211        let order_id_map = DashMap::new();
1212        let order_contexts = DashMap::new();
1213        let encoder = ClientOrderIdEncoder::new();
1214
1215        let result = parse_ws_fill_report(
1216            &ws_fill,
1217            &instrument_cache,
1218            &order_id_map,
1219            &order_contexts,
1220            &encoder,
1221            account_id,
1222            ts_init,
1223        );
1224        assert!(result.is_err());
1225        assert!(
1226            result
1227                .unwrap_err()
1228                .to_string()
1229                .contains("No instrument cached for market")
1230        );
1231    }
1232
1233    #[rstest]
1234    fn test_convert_ws_position_to_http() {
1235        let ws_position = DydxPerpetualPosition {
1236            market: "BTC-USD".into(),
1237            status: DydxPositionStatus::Open,
1238            side: DydxPositionSide::Long,
1239            size: "1.5".to_string(),
1240            max_size: "2.0".to_string(),
1241            entry_price: "50000.0".to_string(),
1242            exit_price: None,
1243            realized_pnl: "100.0".to_string(),
1244            unrealized_pnl: "250.5".to_string(),
1245            created_at: "2024-01-15T10:00:00Z".to_string(),
1246            closed_at: None,
1247            sum_open: "5.0".to_string(),
1248            sum_close: "3.5".to_string(),
1249            net_funding: "-10.25".to_string(),
1250        };
1251
1252        let result = convert_ws_position_to_http(&ws_position);
1253        assert!(result.is_ok());
1254
1255        let http_position = result.unwrap();
1256        assert_eq!(http_position.market, "BTC-USD");
1257        assert_eq!(http_position.status, DydxPositionStatus::Open);
1258        assert_eq!(http_position.side, DydxPositionSide::Long); // Positive size = Long
1259        assert_eq!(http_position.size, rust_decimal_macros::dec!(1.5));
1260        assert_eq!(http_position.max_size, rust_decimal_macros::dec!(2.0));
1261        assert_eq!(
1262            http_position.entry_price,
1263            rust_decimal_macros::dec!(50000.0)
1264        );
1265        assert_eq!(http_position.exit_price, None);
1266        assert_eq!(http_position.realized_pnl, rust_decimal_macros::dec!(100.0));
1267        assert_eq!(
1268            http_position.unrealized_pnl,
1269            rust_decimal_macros::dec!(250.5)
1270        );
1271        assert_eq!(http_position.sum_open, rust_decimal_macros::dec!(5.0));
1272        assert_eq!(http_position.sum_close, rust_decimal_macros::dec!(3.5));
1273        assert_eq!(http_position.net_funding, rust_decimal_macros::dec!(-10.25));
1274    }
1275
1276    /// The converter must preserve the venue-supplied `side`, not re-derive it from
1277    /// the sign of `size`. A zero-size position reported as `Long` must stay `Long`,
1278    /// and a mismatched (Long, negative-size) payload must retain the venue side.
1279    #[rstest]
1280    #[case::long_positive(DydxPositionSide::Long, "1.0", DydxPositionSide::Long)]
1281    #[case::short_negative(DydxPositionSide::Short, "-1.0", DydxPositionSide::Short)]
1282    #[case::long_zero(DydxPositionSide::Long, "0.0", DydxPositionSide::Long)]
1283    #[case::short_zero(DydxPositionSide::Short, "0.0", DydxPositionSide::Short)]
1284    #[case::long_with_negative_size(DydxPositionSide::Long, "-1.0", DydxPositionSide::Long)]
1285    #[case::short_with_positive_size(DydxPositionSide::Short, "1.0", DydxPositionSide::Short)]
1286    fn test_convert_ws_position_preserves_venue_side(
1287        #[case] venue_side: DydxPositionSide,
1288        #[case] size: &str,
1289        #[case] expected_side: DydxPositionSide,
1290    ) {
1291        let ws_position = DydxPerpetualPosition {
1292            market: "BTC-USD".into(),
1293            status: DydxPositionStatus::Open,
1294            side: venue_side,
1295            size: size.to_string(),
1296            max_size: "1.0".to_string(),
1297            entry_price: "50000.0".to_string(),
1298            exit_price: None,
1299            realized_pnl: "0.0".to_string(),
1300            unrealized_pnl: "0.0".to_string(),
1301            created_at: "2024-01-15T10:00:00Z".to_string(),
1302            closed_at: None,
1303            sum_open: "0.0".to_string(),
1304            sum_close: "0.0".to_string(),
1305            net_funding: "0.0".to_string(),
1306        };
1307
1308        let http_position =
1309            convert_ws_position_to_http(&ws_position).expect("conversion should succeed");
1310        assert_eq!(http_position.side, expected_side);
1311    }
1312
1313    /// End-to-end verification that the venue-supplied side flows through to the
1314    /// emitted `PositionStatusReport`. The previous implementation re-derived side
1315    /// from `size.is_sign_positive()` inside `parse_position_status_report`, which
1316    /// silently overrode the venue side for the mismatched case below.
1317    #[rstest]
1318    fn test_ws_position_report_emits_venue_side_for_mismatched_size() {
1319        use nautilus_model::enums::PositionSide;
1320
1321        let instrument_cache = create_test_instrument_cache();
1322        // Venue reports a Short position but the `size` field would round to
1323        // positive via the legacy sign check. The report must show Short.
1324        let ws_position = DydxPerpetualPosition {
1325            market: "BTC-USD".into(),
1326            status: DydxPositionStatus::Open,
1327            side: DydxPositionSide::Short,
1328            size: "1.0".to_string(),
1329            max_size: "1.0".to_string(),
1330            entry_price: "50000.0".to_string(),
1331            exit_price: None,
1332            realized_pnl: "0.0".to_string(),
1333            unrealized_pnl: "0.0".to_string(),
1334            created_at: "2024-01-15T10:00:00Z".to_string(),
1335            closed_at: None,
1336            sum_open: "0.0".to_string(),
1337            sum_close: "0.0".to_string(),
1338            net_funding: "0.0".to_string(),
1339        };
1340
1341        let report = parse_ws_position_report(
1342            &ws_position,
1343            &instrument_cache,
1344            AccountId::new("DYDX-001"),
1345            UnixNanos::default(),
1346        )
1347        .expect("parse should succeed");
1348        assert_eq!(report.position_side, PositionSide::Short);
1349    }
1350
1351    #[rstest]
1352    fn test_parse_ws_position_report_success() {
1353        let instrument_cache = create_test_instrument_cache();
1354        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD-PERP"), *DYDX_VENUE);
1355
1356        let ws_position = DydxPerpetualPosition {
1357            market: "BTC-USD".into(),
1358            status: DydxPositionStatus::Open,
1359            side: DydxPositionSide::Long,
1360            size: "0.5".to_string(),
1361            max_size: "1.0".to_string(),
1362            entry_price: "49500.0".to_string(),
1363            exit_price: None,
1364            realized_pnl: "0.0".to_string(),
1365            unrealized_pnl: "125.0".to_string(),
1366            created_at: "2024-01-15T09:00:00Z".to_string(),
1367            closed_at: None,
1368            sum_open: "0.5".to_string(),
1369            sum_close: "0.0".to_string(),
1370            net_funding: "-2.5".to_string(),
1371        };
1372
1373        let account_id = AccountId::new("DYDX-001");
1374        let ts_init = UnixNanos::default();
1375
1376        let result = parse_ws_position_report(&ws_position, &instrument_cache, account_id, ts_init);
1377        assert!(result.is_ok());
1378
1379        let position_report = result.unwrap();
1380        assert_eq!(position_report.instrument_id, instrument_id);
1381        assert_eq!(position_report.position_side, PositionSide::Long);
1382        assert_eq!(position_report.quantity.as_f64(), 0.5);
1383        // avg_px_open should be entry_price
1384        assert!(position_report.avg_px_open.is_some());
1385    }
1386
1387    #[rstest]
1388    fn test_parse_ws_position_report_short() {
1389        let instrument_cache = create_test_instrument_cache();
1390        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD-PERP"), *DYDX_VENUE);
1391
1392        let ws_position = DydxPerpetualPosition {
1393            market: "BTC-USD".into(),
1394            status: DydxPositionStatus::Open,
1395            side: DydxPositionSide::Short,
1396            size: "-0.25".to_string(), // Negative for short
1397            max_size: "0.5".to_string(),
1398            entry_price: "51000.0".to_string(),
1399            exit_price: None,
1400            realized_pnl: "50.0".to_string(),
1401            unrealized_pnl: "-75.25".to_string(),
1402            created_at: "2024-01-15T08:00:00Z".to_string(),
1403            closed_at: None,
1404            sum_open: "0.25".to_string(),
1405            sum_close: "0.0".to_string(),
1406            net_funding: "1.5".to_string(),
1407        };
1408
1409        let account_id = AccountId::new("DYDX-001");
1410        let ts_init = UnixNanos::default();
1411
1412        let result = parse_ws_position_report(&ws_position, &instrument_cache, account_id, ts_init);
1413        assert!(result.is_ok());
1414
1415        let position_report = result.unwrap();
1416        assert_eq!(position_report.instrument_id, instrument_id);
1417        assert_eq!(position_report.position_side, PositionSide::Short);
1418        assert_eq!(position_report.quantity.as_f64(), 0.25); // Quantity is always positive
1419    }
1420
1421    #[rstest]
1422    fn test_parse_ws_position_report_missing_instrument() {
1423        let instrument_cache = InstrumentCache::new(); // Empty - no instruments cached
1424
1425        let ws_position = DydxPerpetualPosition {
1426            market: "ETH-USD-PERP".into(),
1427            status: DydxPositionStatus::Open,
1428            side: DydxPositionSide::Long,
1429            size: "5.0".to_string(),
1430            max_size: "10.0".to_string(),
1431            entry_price: "3000.0".to_string(),
1432            exit_price: None,
1433            realized_pnl: "0.0".to_string(),
1434            unrealized_pnl: "500.0".to_string(),
1435            created_at: "2024-01-15T07:00:00Z".to_string(),
1436            closed_at: None,
1437            sum_open: "5.0".to_string(),
1438            sum_close: "0.0".to_string(),
1439            net_funding: "-5.0".to_string(),
1440        };
1441
1442        let account_id = AccountId::new("DYDX-001");
1443        let ts_init = UnixNanos::default();
1444
1445        let result = parse_ws_position_report(&ws_position, &instrument_cache, account_id, ts_init);
1446        assert!(result.is_err());
1447        assert!(
1448            result
1449                .unwrap_err()
1450                .to_string()
1451                .contains("No instrument cached for market")
1452        );
1453    }
1454
1455    #[rstest]
1456    #[case(DydxOrderStatus::Filled, "2.0")]
1457    #[case(DydxOrderStatus::Canceled, "0.0")]
1458    #[case(DydxOrderStatus::BestEffortCanceled, "0.5")]
1459    #[case(DydxOrderStatus::BestEffortOpened, "0.0")]
1460    #[case(DydxOrderStatus::Untriggered, "0.0")]
1461    fn test_parse_ws_order_various_statuses(
1462        #[case] status: DydxOrderStatus,
1463        #[case] total_filled: &str,
1464    ) {
1465        let ws_order = DydxWsOrderSubaccountMessageContents {
1466            id: format!("order_{status:?}"),
1467            subaccount_id: "dydx1test/0".to_string(),
1468            client_id: "99999".to_string(),
1469            clob_pair_id: "1".to_string(),
1470            side: OrderSide::Buy,
1471            size: "2.0".to_string(),
1472            price: "50000.0".to_string(),
1473            status,
1474            order_type: DydxOrderType::Limit,
1475            time_in_force: DydxTimeInForce::Gtt,
1476            post_only: false,
1477            reduce_only: false,
1478            order_flags: "0".to_string(),
1479            good_til_block: Some("1000".to_string()),
1480            good_til_block_time: None,
1481            created_at_height: Some("900".to_string()),
1482            client_metadata: Some("0".to_string()),
1483            trigger_price: None,
1484            total_filled: Some(total_filled.to_string()),
1485            updated_at: Some("2024-11-14T10:00:00Z".to_string()),
1486            updated_at_height: Some("950".to_string()),
1487        };
1488
1489        let instrument_cache = create_test_instrument_cache();
1490        let encoder = ClientOrderIdEncoder::new();
1491
1492        let account_id = AccountId::new("DYDX-001");
1493        let ts_init = UnixNanos::default();
1494        let order_contexts: DashMap<u32, OrderContext> = DashMap::new();
1495
1496        let result = parse_ws_order_report(
1497            &ws_order,
1498            &instrument_cache,
1499            &order_contexts,
1500            &encoder,
1501            account_id,
1502            ts_init,
1503        );
1504
1505        assert!(
1506            result.is_ok(),
1507            "Failed to parse order with status {status:?}"
1508        );
1509        let report = result.unwrap();
1510
1511        // Verify status conversion
1512        let expected_status = match status {
1513            DydxOrderStatus::Open
1514            | DydxOrderStatus::BestEffortOpened
1515            | DydxOrderStatus::Untriggered => OrderStatus::Accepted,
1516            DydxOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
1517            DydxOrderStatus::Filled => OrderStatus::Filled,
1518            DydxOrderStatus::Canceled | DydxOrderStatus::BestEffortCanceled => {
1519                OrderStatus::Canceled
1520            }
1521        };
1522        assert_eq!(report.order_status, expected_status);
1523    }
1524
1525    #[rstest]
1526    fn test_parse_ws_order_with_trigger_price() {
1527        let ws_order = DydxWsOrderSubaccountMessageContents {
1528            id: "conditional_order".to_string(),
1529            subaccount_id: "dydx1test/0".to_string(),
1530            client_id: "88888".to_string(),
1531            clob_pair_id: "1".to_string(),
1532            side: OrderSide::Sell,
1533            size: "1.0".to_string(),
1534            price: "52000.0".to_string(),
1535            status: DydxOrderStatus::Untriggered,
1536            order_type: DydxOrderType::StopLimit,
1537            time_in_force: DydxTimeInForce::Gtt,
1538            post_only: false,
1539            reduce_only: true,
1540            order_flags: "32".to_string(),
1541            good_til_block: None,
1542            good_til_block_time: Some("2024-12-31T23:59:59Z".to_string()),
1543            created_at_height: Some("1000".to_string()),
1544            client_metadata: Some("100".to_string()),
1545            trigger_price: Some("51500.0".to_string()),
1546            total_filled: Some("0.0".to_string()),
1547            updated_at: Some("2024-11-14T11:00:00Z".to_string()),
1548            updated_at_height: Some("1050".to_string()),
1549        };
1550
1551        let instrument_cache = create_test_instrument_cache();
1552        let encoder = ClientOrderIdEncoder::new();
1553
1554        let account_id = AccountId::new("DYDX-001");
1555        let ts_init = UnixNanos::default();
1556        let order_contexts: DashMap<u32, OrderContext> = DashMap::new();
1557
1558        let result = parse_ws_order_report(
1559            &ws_order,
1560            &instrument_cache,
1561            &order_contexts,
1562            &encoder,
1563            account_id,
1564            ts_init,
1565        );
1566
1567        assert!(result.is_ok());
1568        let report = result.unwrap();
1569        assert_eq!(report.order_status, OrderStatus::PendingUpdate);
1570        // Trigger price should be parsed and available in the report
1571        assert!(report.trigger_price.is_some());
1572    }
1573
1574    #[rstest]
1575    fn test_parse_ws_order_market_type() {
1576        let ws_order = DydxWsOrderSubaccountMessageContents {
1577            id: "market_order".to_string(),
1578            subaccount_id: "dydx1test/0".to_string(),
1579            client_id: "77777".to_string(),
1580            clob_pair_id: "1".to_string(),
1581            side: OrderSide::Buy,
1582            size: "0.5".to_string(),
1583            price: "50000.0".to_string(), // Market orders still have a price
1584            status: DydxOrderStatus::Filled,
1585            order_type: DydxOrderType::Market,
1586            time_in_force: DydxTimeInForce::Ioc,
1587            post_only: false,
1588            reduce_only: false,
1589            order_flags: "0".to_string(),
1590            good_til_block: Some("1000".to_string()),
1591            good_til_block_time: None,
1592            created_at_height: Some("900".to_string()),
1593            client_metadata: Some("0".to_string()),
1594            trigger_price: None,
1595            total_filled: Some("0.5".to_string()),
1596            updated_at: Some("2024-11-14T10:01:00Z".to_string()),
1597            updated_at_height: Some("901".to_string()),
1598        };
1599
1600        let instrument_cache = create_test_instrument_cache();
1601        let encoder = ClientOrderIdEncoder::new();
1602
1603        let account_id = AccountId::new("DYDX-001");
1604        let ts_init = UnixNanos::default();
1605        let order_contexts: DashMap<u32, OrderContext> = DashMap::new();
1606
1607        let result = parse_ws_order_report(
1608            &ws_order,
1609            &instrument_cache,
1610            &order_contexts,
1611            &encoder,
1612            account_id,
1613            ts_init,
1614        );
1615
1616        assert!(result.is_ok());
1617        let report = result.unwrap();
1618        assert_eq!(report.order_type, OrderType::Market);
1619        assert_eq!(report.order_status, OrderStatus::Filled);
1620    }
1621
1622    #[rstest]
1623    fn test_parse_ws_order_invalid_clob_pair_id() {
1624        let ws_order = DydxWsOrderSubaccountMessageContents {
1625            id: "bad_order".to_string(),
1626            subaccount_id: "dydx1test/0".to_string(),
1627            client_id: "12345".to_string(),
1628            clob_pair_id: "not_a_number".to_string(), // Invalid
1629            side: OrderSide::Buy,
1630            size: "1.0".to_string(),
1631            price: "50000.0".to_string(),
1632            status: DydxOrderStatus::Open,
1633            order_type: DydxOrderType::Limit,
1634            time_in_force: DydxTimeInForce::Gtt,
1635            post_only: false,
1636            reduce_only: false,
1637            order_flags: "0".to_string(),
1638            good_til_block: Some("1000".to_string()),
1639            good_til_block_time: None,
1640            created_at_height: Some("900".to_string()),
1641            client_metadata: Some("0".to_string()),
1642            trigger_price: None,
1643            total_filled: Some("0.0".to_string()),
1644            updated_at: None,
1645            updated_at_height: None,
1646        };
1647
1648        let instrument_cache = InstrumentCache::new(); // Empty cache
1649        let encoder = ClientOrderIdEncoder::new();
1650        let account_id = AccountId::new("DYDX-001");
1651        let ts_init = UnixNanos::default();
1652        let order_contexts: DashMap<u32, OrderContext> = DashMap::new();
1653
1654        let result = parse_ws_order_report(
1655            &ws_order,
1656            &instrument_cache,
1657            &order_contexts,
1658            &encoder,
1659            account_id,
1660            ts_init,
1661        );
1662
1663        assert!(result.is_err());
1664        assert!(
1665            result
1666                .unwrap_err()
1667                .to_string()
1668                .contains("Failed to parse clob_pair_id")
1669        );
1670    }
1671
1672    #[rstest]
1673    fn test_parse_ws_position_closed() {
1674        let instrument_cache = create_test_instrument_cache();
1675        let instrument_id = InstrumentId::new(Symbol::new("BTC-USD-PERP"), *DYDX_VENUE);
1676
1677        let ws_position = DydxPerpetualPosition {
1678            market: "BTC-USD".into(),
1679            status: DydxPositionStatus::Closed,
1680            side: DydxPositionSide::Long,
1681            size: "0.0".to_string(), // Closed = zero size
1682            max_size: "2.0".to_string(),
1683            entry_price: "48000.0".to_string(),
1684            exit_price: Some("52000.0".to_string()),
1685            realized_pnl: "2000.0".to_string(),
1686            unrealized_pnl: "0.0".to_string(),
1687            created_at: "2024-01-10T09:00:00Z".to_string(),
1688            closed_at: Some("2024-01-15T14:00:00Z".to_string()),
1689            sum_open: "5.0".to_string(),
1690            sum_close: "5.0".to_string(), // Fully closed
1691            net_funding: "-25.5".to_string(),
1692        };
1693
1694        let account_id = AccountId::new("DYDX-001");
1695        let ts_init = UnixNanos::default();
1696
1697        let result = parse_ws_position_report(&ws_position, &instrument_cache, account_id, ts_init);
1698        assert!(result.is_ok());
1699
1700        let position_report = result.unwrap();
1701        assert_eq!(position_report.instrument_id, instrument_id);
1702        // Closed position should have zero quantity
1703        assert_eq!(position_report.quantity.as_f64(), 0.0);
1704    }
1705
1706    #[rstest]
1707    fn test_parse_ws_fill_with_maker_rebate() {
1708        let instrument_cache = create_test_instrument_cache();
1709
1710        let ws_fill = DydxWsFillSubaccountMessageContents {
1711            id: "fill_rebate".to_string(),
1712            subaccount_id: "sub1".to_string(),
1713            side: OrderSide::Buy,
1714            liquidity: DydxLiquidity::Maker,
1715            fill_type: DydxFillType::Limit,
1716            market: "BTC-USD".into(),
1717            market_type: Some(DydxTickerType::Perpetual),
1718            price: "50000.0".to_string(),
1719            size: "1.0".to_string(),
1720            fee: "-15.0".to_string(), // Negative fee = rebate
1721            created_at: "2024-01-15T13:00:00Z".to_string(),
1722            created_at_height: Some("13000".to_string()),
1723            order_id: Some("order_maker".to_string()),
1724            client_metadata: Some("200".to_string()),
1725        };
1726
1727        let account_id = AccountId::new("DYDX-001");
1728        let ts_init = UnixNanos::default();
1729        let order_id_map = DashMap::new();
1730        let order_contexts = DashMap::new();
1731        let encoder = ClientOrderIdEncoder::new();
1732
1733        let result = parse_ws_fill_report(
1734            &ws_fill,
1735            &instrument_cache,
1736            &order_id_map,
1737            &order_contexts,
1738            &encoder,
1739            account_id,
1740            ts_init,
1741        );
1742        assert!(result.is_ok());
1743
1744        let fill_report = result.unwrap();
1745        assert_eq!(fill_report.liquidity_side, LiquiditySide::Maker);
1746        assert!(fill_report.commission.as_decimal() < dec!(0));
1747    }
1748
1749    #[rstest]
1750    fn test_parse_ws_fill_taker_with_fee() {
1751        let instrument_cache = create_test_instrument_cache();
1752
1753        let ws_fill = DydxWsFillSubaccountMessageContents {
1754            id: "fill_taker".to_string(),
1755            subaccount_id: "sub2".to_string(),
1756            side: OrderSide::Sell,
1757            liquidity: DydxLiquidity::Taker,
1758            fill_type: DydxFillType::Limit,
1759            market: "BTC-USD".into(),
1760            market_type: Some(DydxTickerType::Perpetual),
1761            price: "49800.0".to_string(),
1762            size: "0.75".to_string(),
1763            fee: "18.675".to_string(), // Positive fee for taker
1764            created_at: "2024-01-15T14:00:00Z".to_string(),
1765            created_at_height: Some("14000".to_string()),
1766            order_id: Some("order_taker".to_string()),
1767            client_metadata: Some("300".to_string()),
1768        };
1769
1770        let account_id = AccountId::new("DYDX-001");
1771        let ts_init = UnixNanos::default();
1772        let order_id_map = DashMap::new();
1773        let order_contexts = DashMap::new();
1774        let encoder = ClientOrderIdEncoder::new();
1775
1776        let result = parse_ws_fill_report(
1777            &ws_fill,
1778            &instrument_cache,
1779            &order_id_map,
1780            &order_contexts,
1781            &encoder,
1782            account_id,
1783            ts_init,
1784        );
1785        assert!(result.is_ok());
1786
1787        let fill_report = result.unwrap();
1788        assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
1789        assert_eq!(fill_report.order_side, OrderSide::Sell);
1790        assert!(fill_report.commission.as_decimal() > dec!(0));
1791    }
1792
1793    #[rstest]
1794    fn test_parse_orderbook_snapshot() {
1795        let json = load_json_fixture("ws_orderbook_subscribed.json");
1796        let contents: DydxOrderbookSnapshotContents =
1797            serde_json::from_value(json["contents"].clone())
1798                .expect("Failed to parse orderbook snapshot contents");
1799
1800        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
1801        let ts_init = UnixNanos::from(1_000_000_000u64);
1802
1803        let deltas = parse_orderbook_snapshot(&instrument_id, &contents, 2, 8, ts_init)
1804            .expect("Failed to parse orderbook snapshot");
1805
1806        // 1 clear + 3 bids + 3 asks = 7 deltas
1807        assert_eq!(deltas.deltas.len(), 7);
1808
1809        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1810        assert_eq!(deltas.deltas[1].action, BookAction::Add);
1811        assert_eq!(deltas.deltas[1].order.side, Some(OrderSide::Buy));
1812        assert_eq!(deltas.deltas[1].order.price.to_string(), "43240.00");
1813        assert_eq!(deltas.deltas[1].order.size.to_string(), "1.50000000");
1814
1815        assert_eq!(deltas.deltas[4].action, BookAction::Add);
1816        assert_eq!(deltas.deltas[4].order.side, Some(OrderSide::Sell));
1817        assert_eq!(deltas.deltas[4].order.price.to_string(), "43250.00");
1818        assert_eq!(deltas.deltas[4].order.size.to_string(), "1.20000000");
1819
1820        // Every snapshot delta must carry F_SNAPSHOT. The Clear carries F_SNAPSHOT only
1821        // (not last); every intermediate delta carries F_SNAPSHOT only; the terminator
1822        // carries F_SNAPSHOT | F_LAST.
1823        let snapshot = RecordFlag::F_SNAPSHOT as u8;
1824        let last_flag = RecordFlag::F_LAST as u8;
1825
1826        assert_eq!(deltas.deltas[0].flags, snapshot, "Clear missing F_SNAPSHOT");
1827        for (idx, delta) in deltas.deltas.iter().enumerate().skip(1) {
1828            let expected = if idx == deltas.deltas.len() - 1 {
1829                snapshot | last_flag
1830            } else {
1831                snapshot
1832            };
1833            assert_eq!(
1834                delta.flags, expected,
1835                "delta at index {idx} has wrong flags: got {:#010b}, expected {expected:#010b}",
1836                delta.flags,
1837            );
1838        }
1839    }
1840
1841    #[rstest]
1842    #[case::empty_book(vec![], vec![], 1)]
1843    #[case::bids_only(vec![("100.0", "1.0")], vec![], 2)]
1844    #[case::asks_only(vec![], vec![("101.0", "2.0")], 2)]
1845    fn test_parse_orderbook_snapshot_flag_shapes(
1846        #[case] bids: Vec<(&str, &str)>,
1847        #[case] asks: Vec<(&str, &str)>,
1848        #[case] expected_len: usize,
1849    ) {
1850        use crate::websocket::messages::DydxPriceLevel;
1851        let contents = DydxOrderbookSnapshotContents {
1852            bids: if bids.is_empty() {
1853                None
1854            } else {
1855                Some(
1856                    bids.into_iter()
1857                        .map(|(p, s)| DydxPriceLevel {
1858                            price: p.to_string(),
1859                            size: s.to_string(),
1860                        })
1861                        .collect(),
1862                )
1863            },
1864            asks: if asks.is_empty() {
1865                None
1866            } else {
1867                Some(
1868                    asks.into_iter()
1869                        .map(|(p, s)| DydxPriceLevel {
1870                            price: p.to_string(),
1871                            size: s.to_string(),
1872                        })
1873                        .collect(),
1874                )
1875            },
1876        };
1877        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
1878        let ts_init = UnixNanos::from(1_000_000_000u64);
1879
1880        let deltas = parse_orderbook_snapshot(&instrument_id, &contents, 2, 8, ts_init)
1881            .expect("Failed to parse orderbook snapshot");
1882
1883        let snapshot = RecordFlag::F_SNAPSHOT as u8;
1884        let last_flag = RecordFlag::F_LAST as u8;
1885
1886        assert_eq!(deltas.deltas.len(), expected_len);
1887
1888        if expected_len == 1 {
1889            // Empty book: Clear alone must carry F_SNAPSHOT | F_LAST so buffered
1890            // subscribers flush when the book is empty.
1891            assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1892            assert_eq!(deltas.deltas[0].flags, snapshot | last_flag);
1893        } else {
1894            // Non-empty: Clear carries F_SNAPSHOT only; terminator carries both.
1895            assert_eq!(deltas.deltas[0].flags, snapshot);
1896            let terminator = deltas.deltas.last().unwrap();
1897            assert_eq!(terminator.flags, snapshot | last_flag);
1898        }
1899    }
1900
1901    #[rstest]
1902    fn test_parse_orderbook_deltas_update() {
1903        let json = load_json_fixture("ws_orderbook_update.json");
1904        let contents: DydxOrderbookContents = serde_json::from_value(json["contents"].clone())
1905            .expect("Failed to parse orderbook update contents");
1906
1907        let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
1908        let ts_init = UnixNanos::from(1_000_000_000u64);
1909
1910        let deltas = parse_orderbook_deltas(&instrument_id, &contents, 2, 8, ts_init)
1911            .expect("Failed to parse orderbook deltas");
1912
1913        // 2 bids + 2 asks = 4 deltas
1914        assert_eq!(deltas.deltas.len(), 4);
1915
1916        assert_eq!(deltas.deltas[0].action, BookAction::Update);
1917        assert_eq!(deltas.deltas[0].order.side, Some(OrderSide::Buy));
1918        assert_eq!(deltas.deltas[0].order.price.to_string(), "43240.00");
1919
1920        // First ask with size 0.0 should be a Delete
1921        assert_eq!(deltas.deltas[2].action, BookAction::Delete);
1922        assert_eq!(deltas.deltas[2].order.side, Some(OrderSide::Sell));
1923        assert_eq!(deltas.deltas[2].order.price.to_string(), "43250.00");
1924
1925        assert_eq!(deltas.deltas[3].action, BookAction::Update);
1926        assert_eq!(deltas.deltas[3].order.side, Some(OrderSide::Sell));
1927    }
1928
1929    #[rstest]
1930    fn test_parse_trade_ticks_ws() {
1931        let json = load_json_fixture("ws_trades_subscribed.json");
1932        let contents: DydxTradeContents = serde_json::from_value(json["contents"].clone())
1933            .expect("Failed to parse trade contents");
1934
1935        let instrument = create_test_instrument();
1936        let instrument_id = instrument.id();
1937        let ts_init = UnixNanos::from(1_000_000_000u64);
1938
1939        let ticks = parse_trade_ticks(instrument_id, &instrument, &contents, ts_init)
1940            .expect("Failed to parse trade ticks");
1941
1942        assert_eq!(ticks.len(), 1);
1943        if let Data::Trade(tick) = &ticks[0] {
1944            assert_eq!(tick.instrument_id, instrument_id);
1945            assert_eq!(tick.price.to_string(), "43250.00");
1946            assert_eq!(tick.size.to_string(), "0.50000000");
1947            assert_eq!(tick.aggressor_side, AggressorSide::Buy);
1948            assert_eq!(tick.trade_id.to_string(), "trade-001");
1949        } else {
1950            panic!("Expected Trade data");
1951        }
1952    }
1953
1954    #[rstest]
1955    #[case(true)]
1956    #[case(false)]
1957    fn test_parse_candle_bar_timestamp_on_close(#[case] timestamp_on_close: bool) {
1958        let json = load_json_fixture("ws_candles_subscribed.json");
1959        let candles_value = &json["contents"]["candles"];
1960        let candles: Vec<DydxCandle> =
1961            serde_json::from_value(candles_value.clone()).expect("Failed to parse candle array");
1962
1963        let instrument = create_test_instrument();
1964        let bar_type = BarType::from_str("BTC-USD-PERP.DYDX-1-MINUTE-LAST-EXTERNAL")
1965            .expect("Failed to parse bar type");
1966        let ts_init = UnixNanos::from(1_000_000_000u64);
1967
1968        let bar = parse_candle_bar(
1969            bar_type,
1970            &instrument,
1971            &candles[0],
1972            timestamp_on_close,
1973            ts_init,
1974        )
1975        .expect("Failed to parse candle bar");
1976
1977        assert_eq!(bar.bar_type, bar_type);
1978        assert_eq!(bar.open.to_string(), "43100.00");
1979        assert_eq!(bar.high.to_string(), "43500.00");
1980        assert_eq!(bar.low.to_string(), "43000.00");
1981        assert_eq!(bar.close.to_string(), "43400.00");
1982        assert_eq!(bar.volume.to_string(), "12.34500000");
1983
1984        // 2024-01-01T00:00:00.000Z = 1_704_067_200_000_000_000 ns
1985        let started_at_ns = 1_704_067_200_000_000_000u64;
1986        let one_min_ns = 60_000_000_000u64;
1987
1988        if timestamp_on_close {
1989            assert_eq!(bar.ts_event.as_u64(), started_at_ns + one_min_ns);
1990        } else {
1991            assert_eq!(bar.ts_event.as_u64(), started_at_ns);
1992        }
1993    }
1994
1995    #[rstest]
1996    fn test_deserialize_market_trading_update_with_status() {
1997        let json = load_json_fixture("ws_markets_status_update.json");
1998        let contents: super::super::messages::DydxMarketsContents =
1999            serde_json::from_value(json["contents"].clone())
2000                .expect("Failed to deserialize markets contents");
2001
2002        let trading = contents.trading.expect("Expected trading data");
2003        assert_eq!(trading.len(), 2);
2004
2005        let btc = trading.get("BTC-USD").expect("Expected BTC-USD");
2006        assert_eq!(btc.status, Some(DydxMarketStatus::Paused));
2007        assert_eq!(btc.next_funding_rate, Some("0.0001".to_string()));
2008
2009        let eth = trading.get("ETH-USD").expect("Expected ETH-USD");
2010        assert_eq!(eth.status, Some(DydxMarketStatus::Active));
2011    }
2012
2013    #[rstest]
2014    #[case("ACTIVE", DydxMarketStatus::Active)]
2015    #[case("PAUSED", DydxMarketStatus::Paused)]
2016    #[case("CANCEL_ONLY", DydxMarketStatus::CancelOnly)]
2017    #[case("POST_ONLY", DydxMarketStatus::PostOnly)]
2018    #[case("INITIALIZING", DydxMarketStatus::Initializing)]
2019    #[case("FINAL_SETTLEMENT", DydxMarketStatus::FinalSettlement)]
2020    fn test_deserialize_market_status_variants(
2021        #[case] status_str: &str,
2022        #[case] expected: DydxMarketStatus,
2023    ) {
2024        let json_str = format!(r#"{{"status": "{status_str}"}}"#);
2025        let update: super::super::messages::DydxMarketTradingUpdate =
2026            serde_json::from_str(&json_str).expect("Failed to deserialize");
2027        assert_eq!(update.status, Some(expected));
2028    }
2029
2030    #[rstest]
2031    fn test_deserialize_market_trading_update_without_status() {
2032        let json_str = r#"{"nextFundingRate": "0.0001"}"#;
2033        let update: super::super::messages::DydxMarketTradingUpdate =
2034            serde_json::from_str(json_str).expect("Failed to deserialize");
2035        assert_eq!(update.status, None);
2036        assert_eq!(update.next_funding_rate, Some("0.0001".to_string()));
2037    }
2038}