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