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