1use std::{
19 str::FromStr,
20 sync::atomic::Ordering,
21 time::{Duration, Instant},
22};
23
24use ahash::AHashMap;
25use dashmap::DashMap;
26use nautilus_common::live::get_runtime;
27use nautilus_core::{
28 UUID4,
29 python::{call_python_threadsafe, to_pyvalue_err},
30 time::get_atomic_clock_realtime,
31};
32use nautilus_model::{
33 data::{
34 Bar, BarType, Data, FundingRateUpdate, IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate,
35 OrderBookDeltas, OrderBookDeltas_API,
36 },
37 enums::{AccountType, MarketStatusAction, OrderSide, OrderStatus, OrderType},
38 events::{AccountState, OrderAccepted, OrderCanceled},
39 identifiers::{
40 AccountId, ClientOrderId, InstrumentId, StrategyId, Symbol, TraderId, VenueOrderId,
41 },
42 instruments::{Instrument, InstrumentAny},
43 python::{data::data_to_pycapsule, instruments::pyobject_to_instrument_any},
44 types::{AccountBalance, Currency, Money},
45};
46use nautilus_network::mode::ConnectionMode;
47use pyo3::{IntoPyObjectExt, prelude::*, types::PyDict};
48use rust_decimal::Decimal;
49use ustr::Ustr;
50
51use crate::{
52 common::{
53 consts::DYDX_VENUE,
54 credential::DydxCredential,
55 enums::{DydxCandleResolution, DydxMarketStatus},
56 parse::{extract_raw_symbol, parse_price},
57 },
58 execution::types::OrderContext,
59 http::{client::DydxHttpClient, parse::parse_account_state},
60 python::encoder::PyDydxClientOrderIdEncoder,
61 websocket::{
62 DydxWsDispatchState, OrderIdentity,
63 client::{DydxWebSocketClient, candle_ids_from_topics},
64 enums::DydxWsOutputMessage,
65 fill_report_to_order_filled, parse as ws_parse,
66 parse::{parse_ws_fill_report, parse_ws_order_report, parse_ws_position_report},
67 },
68};
69
70#[pymethods]
71#[pyo3_stub_gen::derive::gen_stub_pymethods]
72impl DydxWebSocketClient {
73 #[staticmethod]
78 #[pyo3(name = "new_public")]
79 #[pyo3(signature = (url, heartbeat=None, proxy_url=None))]
80 fn py_new_public(url: String, heartbeat: Option<u64>, proxy_url: Option<String>) -> Self {
81 Self::new_public(url, heartbeat, proxy_url)
82 }
83
84 #[staticmethod]
89 #[pyo3(name = "new_private")]
90 #[pyo3(signature = (url, private_key, authenticator_ids, account_id, heartbeat=None, proxy_url=None))]
91 fn py_new_private(
92 url: String,
93 private_key: &str,
94 authenticator_ids: Vec<u64>,
95 account_id: AccountId,
96 heartbeat: Option<u64>,
97 proxy_url: Option<String>,
98 ) -> PyResult<Self> {
99 let credential = DydxCredential::from_private_key(private_key, authenticator_ids)
100 .map_err(to_pyvalue_err)?;
101 Ok(Self::new_private(
102 url, credential, account_id, heartbeat, proxy_url,
103 ))
104 }
105
106 #[pyo3(name = "is_connected")]
108 fn py_is_connected(&self) -> bool {
109 self.is_connected()
110 }
111
112 #[pyo3(name = "set_account_id")]
114 fn py_set_account_id(&mut self, account_id: AccountId) {
115 self.set_account_id(account_id);
116 }
117
118 #[pyo3(name = "set_bars_timestamp_on_close")]
120 fn py_set_bars_timestamp_on_close(&self, value: bool) {
121 self.set_bars_timestamp_on_close(value);
122 }
123
124 #[pyo3(name = "share_instrument_cache")]
130 fn py_share_instrument_cache(&mut self, http_client: &DydxHttpClient) {
131 self.set_instrument_cache(http_client.instrument_cache().clone());
132 }
133
134 #[pyo3(name = "register_order_identity")]
135 fn py_register_order_identity(
136 &self,
137 client_order_id: ClientOrderId,
138 instrument_id: InstrumentId,
139 strategy_id: StrategyId,
140 order_side: OrderSide,
141 order_type: OrderType,
142 ) {
143 self.ws_dispatch_state().order_identities.insert(
144 client_order_id,
145 OrderIdentity {
146 instrument_id,
147 strategy_id,
148 order_side,
149 order_type,
150 },
151 );
152 }
153
154 #[pyo3(name = "remove_order_identity")]
155 fn py_remove_order_identity(&self, client_order_id: ClientOrderId) {
156 self.ws_dispatch_state()
157 .order_identities
158 .remove(&client_order_id);
159 }
160
161 #[pyo3(name = "account_id")]
163 fn py_account_id(&self) -> Option<AccountId> {
164 self.account_id()
165 }
166
167 #[pyo3(name = "encoder")]
169 fn py_encoder(&self) -> PyDydxClientOrderIdEncoder {
170 PyDydxClientOrderIdEncoder::from_arc(self.encoder().clone())
171 }
172
173 #[getter]
175 fn py_url(&self) -> String {
176 self.url().to_string()
177 }
178
179 #[pyo3(name = "connect")]
184 #[pyo3(signature = (loop_, instruments, callback, trader_id=None))]
185 #[expect(clippy::needless_pass_by_value)]
186 fn py_connect<'py>(
187 &mut self,
188 py: Python<'py>,
189 loop_: Py<PyAny>,
190 instruments: Vec<Py<PyAny>>,
191 callback: Py<PyAny>,
192 trader_id: Option<TraderId>,
193 ) -> PyResult<Bound<'py, PyAny>> {
194 let call_soon = loop_.getattr(py, "call_soon_threadsafe")?;
195
196 let mut instruments_any = Vec::new();
197
198 for inst in instruments {
199 let inst_any = pyobject_to_instrument_any(py, inst)?;
200 instruments_any.push(inst_any);
201 }
202
203 self.cache_instruments(instruments_any);
204
205 let mut client = self.clone();
206 let bar_types = self.bar_types().clone();
207 let dispatch_state = self.ws_dispatch_state().clone();
208 let trader_id = trader_id.unwrap_or(TraderId::from("TRADER-000"));
209
210 pyo3_async_runtimes::tokio::future_into_py(py, async move {
211 client.connect().await.map_err(to_pyvalue_err)?;
212
213 if let Some(mut rx) = client.take_receiver() {
214 get_runtime().spawn(async move {
215 let _client = client; let clock = get_atomic_clock_realtime();
217 let order_contexts: DashMap<u32, OrderContext> = DashMap::new();
218 let order_id_map: DashMap<String, (u32, u32)> = DashMap::new();
219 let bars_timestamp_on_close = _client.bars_timestamp_on_close();
220 let mut pending_bars: AHashMap<String, Bar> = AHashMap::new();
221 let mut seen_tickers: ahash::AHashSet<Ustr> = ahash::AHashSet::new();
222
223 while let Some(msg) = rx.recv().await {
224 let ts_init = clock.get_time_ns();
225
226 match msg {
227 DydxWsOutputMessage::Trades { id, contents } => {
228 let Some(instrument) = _client.instrument_cache().get_by_market(&id) else {
229 log::warn!("No instrument cached for market {id}");
230 continue;
231 };
232 let instrument_id = instrument.id();
233
234 match ws_parse::parse_trade_ticks(instrument_id, &instrument, &contents, ts_init) {
235 Ok(items) => {
236 Python::attach(|py| {
237 for data in items {
238 let py_obj = data_to_pycapsule(py, data);
239 call_python_threadsafe(py, &call_soon, &callback, py_obj);
240 }
241 });
242 }
243 Err(e) => log::error!("Failed to parse trade ticks for {id}: {e}"),
244 }
245 }
246 DydxWsOutputMessage::OrderbookSnapshot { id, contents } => {
247 let Some(instrument) = _client.instrument_cache().get_by_market(&id) else {
248 log::warn!("No instrument cached for market {id}");
249 continue;
250 };
251 let instrument_id = instrument.id();
252 let price_precision = instrument.price_precision();
253 let size_precision = instrument.size_precision();
254
255 match ws_parse::parse_orderbook_snapshot(
256 &instrument_id,
257 &contents,
258 price_precision,
259 size_precision,
260 ts_init,
261 ) {
262 Ok(deltas) => {
263 Python::attach(|py| {
264 let data = Data::Deltas(OrderBookDeltas_API::new(deltas));
265 let py_obj = data_to_pycapsule(py, data);
266 call_python_threadsafe(py, &call_soon, &callback, py_obj);
267 });
268 }
269 Err(e) => log::error!("Failed to parse orderbook snapshot for {id}: {e}"),
270 }
271 }
272 DydxWsOutputMessage::OrderbookUpdate { id, contents } => {
273 let Some(instrument) = _client.instrument_cache().get_by_market(&id) else {
274 log::warn!("No instrument cached for market {id}");
275 continue;
276 };
277 let instrument_id = instrument.id();
278 let price_precision = instrument.price_precision();
279 let size_precision = instrument.size_precision();
280
281 match ws_parse::parse_orderbook_deltas(
282 &instrument_id,
283 &contents,
284 price_precision,
285 size_precision,
286 ts_init,
287 ) {
288 Ok(deltas) => {
289 Python::attach(|py| {
290 let data = Data::Deltas(OrderBookDeltas_API::new(deltas));
291 let py_obj = data_to_pycapsule(py, data);
292 call_python_threadsafe(py, &call_soon, &callback, py_obj);
293 });
294 }
295 Err(e) => log::error!("Failed to parse orderbook deltas for {id}: {e}"),
296 }
297 }
298 DydxWsOutputMessage::OrderbookBatch { id, updates } => {
299 let Some(instrument) = _client.instrument_cache().get_by_market(&id) else {
300 log::warn!("No instrument cached for market {id}");
301 continue;
302 };
303 let instrument_id = instrument.id();
304 let price_precision = instrument.price_precision();
305 let size_precision = instrument.size_precision();
306
307 let mut all_deltas = Vec::new();
308 let last_idx = updates.len().saturating_sub(1);
309 let mut parse_ok = true;
310
311 for (idx, update) in updates.iter().enumerate() {
312 if idx < last_idx {
313 match ws_parse::parse_orderbook_deltas_with_flag(
314 &instrument_id,
315 update,
316 price_precision,
317 size_precision,
318 ts_init,
319 false,
320 ) {
321 Ok(deltas) => all_deltas.extend(deltas),
322 Err(e) => {
323 log::error!("Failed to parse batch orderbook deltas for {id}: {e}");
324 parse_ok = false;
325 break;
326 }
327 }
328 } else {
329 match ws_parse::parse_orderbook_deltas(
330 &instrument_id,
331 update,
332 price_precision,
333 size_precision,
334 ts_init,
335 ) {
336 Ok(last_deltas) => all_deltas.extend(last_deltas.deltas),
337 Err(e) => {
338 log::error!("Failed to parse batch orderbook deltas for {id}: {e}");
339 parse_ok = false;
340 break;
341 }
342 }
343 }
344 }
345
346 if parse_ok && !all_deltas.is_empty() {
347 let combined = OrderBookDeltas::new(instrument_id, all_deltas);
348 Python::attach(|py| {
349 let data = Data::Deltas(OrderBookDeltas_API::new(combined));
350 let py_obj = data_to_pycapsule(py, data);
351 call_python_threadsafe(py, &call_soon, &callback, py_obj);
352 });
353 }
354 }
355 DydxWsOutputMessage::Candles { id, contents } => {
356 let ticker = id.split('/').next().unwrap_or(&id);
357
358 let Some(bar_type) = bar_types.get(&id).map(|r| *r) else {
359 log::debug!("No bar type registered for candle topic {id}");
360 continue;
361 };
362
363 let Some(instrument) = _client.instrument_cache().get_by_market(ticker) else {
364 log::warn!("No instrument cached for market {ticker}");
365 continue;
366 };
367
368 match ws_parse::parse_candle_bar(
369 bar_type,
370 &instrument,
371 &contents,
372 bars_timestamp_on_close,
373 ts_init,
374 ) {
375 Ok(bar) => {
376 if let Some(prev_bar) = pending_bars.get(&id) {
377 if bar.ts_event == prev_bar.ts_event {
378 pending_bars.insert(id, bar);
379 } else {
380 let emit_bar = *prev_bar;
381 pending_bars.insert(id.clone(), bar);
382 Python::attach(|py| {
383 let py_obj = data_to_pycapsule(py, Data::Bar(emit_bar));
384 call_python_threadsafe(py, &call_soon, &callback, py_obj);
385 });
386 }
387 } else {
388 pending_bars.insert(id, bar);
389 }
390 }
391 Err(e) => log::error!("Failed to parse candle bar for {id}: {e}"),
392 }
393 }
394 DydxWsOutputMessage::Markets(contents) => {
395 if let Some(ref oracle_prices) = contents.oracle_prices {
396 for (ticker, oracle_data) in oracle_prices {
397 let Some(instrument) = _client.instrument_cache().get_by_market(ticker) else {
398 continue;
399 };
400 let instrument_id = instrument.id();
401
402 let Ok(price) = parse_price(&oracle_data.oracle_price, "oracle_price") else {
403 log::warn!("Failed to parse oracle price for {ticker}");
404 continue;
405 };
406
407 let mark_price = MarkPriceUpdate::new(
408 instrument_id,
409 price,
410 ts_init,
411 ts_init,
412 );
413 Python::attach(|py| {
414 match mark_price.into_py_any(py) {
415 Ok(py_obj) => {
416 call_python_threadsafe(py, &call_soon, &callback, py_obj);
417 }
418 Err(e) => log::error!("Failed to convert MarkPriceUpdate to Python: {e}"),
419 }
420 });
421
422 let index_price = IndexPriceUpdate::new(
423 instrument_id,
424 price,
425 ts_init,
426 ts_init,
427 );
428 Python::attach(|py| {
429 match index_price.into_py_any(py) {
430 Ok(py_obj) => {
431 call_python_threadsafe(py, &call_soon, &callback, py_obj);
432 }
433 Err(e) => log::error!("Failed to convert IndexPriceUpdate to Python: {e}"),
434 }
435 });
436 }
437 }
438
439 handle_markets_trading_data(
440 contents.trading.as_ref(),
441 _client.instrument_cache(),
442 &mut seen_tickers,
443 &call_soon,
444 &callback,
445 ts_init,
446 );
447 handle_markets_trading_data(
448 contents.markets.as_ref(),
449 _client.instrument_cache(),
450 &mut seen_tickers,
451 &call_soon,
452 &callback,
453 ts_init,
454 );
455
456 if let Some(ref markets_map) = contents.markets {
458 for (ticker, update) in markets_map {
459 if let Some(ref oracle_price_str) = update.oracle_price {
460 let Some(instrument) = _client.instrument_cache().get_by_market(ticker) else {
461 continue;
462 };
463 let instrument_id = instrument.id();
464 let Ok(price) = parse_price(oracle_price_str, "oracle_price") else {
465 log::warn!("Failed to parse oracle price for {ticker}");
466 continue;
467 };
468
469 let mark_price = MarkPriceUpdate::new(
470 instrument_id,
471 price,
472 ts_init,
473 ts_init,
474 );
475 Python::attach(|py| {
476 match mark_price.into_py_any(py) {
477 Ok(py_obj) => {
478 call_python_threadsafe(py, &call_soon, &callback, py_obj);
479 }
480 Err(e) => log::error!("Failed to convert MarkPriceUpdate to Python: {e}"),
481 }
482 });
483
484 let index_price = IndexPriceUpdate::new(
485 instrument_id,
486 price,
487 ts_init,
488 ts_init,
489 );
490 Python::attach(|py| {
491 match index_price.into_py_any(py) {
492 Ok(py_obj) => {
493 call_python_threadsafe(py, &call_soon, &callback, py_obj);
494 }
495 Err(e) => log::error!("Failed to convert IndexPriceUpdate to Python: {e}"),
496 }
497 });
498 }
499 }
500 }
501 }
502 DydxWsOutputMessage::SubaccountSubscribed(data) => {
503 let Some(account_id) = _client.account_id() else {
504 log::warn!("Cannot parse subaccount subscription: account_id not set");
505 continue;
506 };
507
508 let instrument_cache = _client.instrument_cache();
509
510 let inst_map = instrument_cache.to_instrument_id_map();
511 let oracle_map = instrument_cache.to_oracle_prices_map();
512
513 if let Some(ref subaccount) = data.contents.subaccount {
514 match parse_account_state(
515 subaccount,
516 account_id,
517 &inst_map,
518 &oracle_map,
519 ts_init,
520 ts_init,
521 ) {
522 Ok(account_state) => {
523 Python::attach(|py| {
524 match account_state.into_py_any(py) {
525 Ok(py_obj) => {
526 call_python_threadsafe(py, &call_soon, &callback, py_obj);
527 }
528 Err(e) => log::error!("Failed to convert AccountState to Python: {e}"),
529 }
530 });
531 }
532 Err(e) => log::error!("Failed to parse account state: {e}"),
533 }
534
535 if let Some(ref positions) = subaccount.open_perpetual_positions {
536 for (market, ws_position) in positions {
537 match parse_ws_position_report(
538 ws_position,
539 instrument_cache,
540 account_id,
541 ts_init,
542 ) {
543 Ok(report) => {
544 Python::attach(|py| {
545 match pyo3::Py::new(py, report) {
546 Ok(py_obj) => {
547 call_python_threadsafe(py, &call_soon, &callback, py_obj.into_any());
548 }
549 Err(e) => log::error!("Failed to convert PositionStatusReport to Python: {e}"),
550 }
551 });
552 }
553 Err(e) => log::error!("Failed to parse position for {market}: {e}"),
554 }
555 }
556 }
557 } else {
558 log::warn!("Subaccount subscription without initial state (new/empty subaccount)");
559
560 let currency = Currency::get_or_create_crypto_with_context("USDC", None);
562 let zero = Money::zero(currency);
563 let balance = AccountBalance::new_checked(zero, zero, zero)
564 .expect("zero balance should always be valid");
565 let account_state = AccountState::new(
566 account_id,
567 AccountType::Margin,
568 vec![balance],
569 vec![],
570 true,
571 UUID4::new(),
572 ts_init,
573 ts_init,
574 None,
575 );
576 Python::attach(|py| {
577 match account_state.into_py_any(py) {
578 Ok(py_obj) => {
579 call_python_threadsafe(py, &call_soon, &callback, py_obj);
580 }
581 Err(e) => log::error!("Failed to convert AccountState to Python: {e}"),
582 }
583 });
584 }
585 }
586 DydxWsOutputMessage::SubaccountsChannelData(data) => {
587 let Some(account_id) = _client.account_id() else {
588 log::warn!("Cannot parse SubaccountsChannelData: account_id not set");
589 continue;
590 };
591
592 let instrument_cache = _client.instrument_cache();
593 let encoder = _client.encoder();
594
595 let mut terminal_orders: Vec<(u32, u32, String)> = Vec::new();
596 let mut cum_fill_totals: AHashMap<VenueOrderId, (Decimal, Decimal)> = AHashMap::new();
597
598 let mut pending_order_reports = Vec::new();
600
601 if let Some(ref orders) = data.contents.orders {
602 for ws_order in orders {
603 if let Ok(client_id_u32) = ws_order.client_id.parse::<u32>() {
604 let client_meta = ws_order.client_metadata
605 .as_ref()
606 .and_then(|s| s.parse::<u32>().ok())
607 .unwrap_or(crate::grpc::DEFAULT_RUST_CLIENT_METADATA);
608 order_id_map.insert(ws_order.id.clone(), (client_id_u32, client_meta));
609 }
610
611 match parse_ws_order_report(
612 ws_order,
613 instrument_cache,
614 &order_contexts,
615 encoder,
616 account_id,
617 ts_init,
618 ) {
619 Ok(report) => {
620 if !report.order_status.is_open()
621 && let Ok(cid) = ws_order.client_id.parse::<u32>()
622 {
623 let meta = ws_order.client_metadata
624 .as_ref()
625 .and_then(|s| s.parse::<u32>().ok())
626 .unwrap_or(crate::grpc::DEFAULT_RUST_CLIENT_METADATA);
627 terminal_orders.push((cid, meta, ws_order.id.clone()));
628 }
629 pending_order_reports.push(report);
630 }
631 Err(e) => log::error!("Failed to parse WS order: {e}"),
632 }
633 }
634 }
635
636 if let Some(ref fills) = data.contents.fills {
638 for ws_fill in fills {
639 match parse_ws_fill_report(
640 ws_fill,
641 instrument_cache,
642 &order_id_map,
643 &order_contexts,
644 encoder,
645 account_id,
646 ts_init,
647 ) {
648 Ok(report) => {
649 let identity = report.client_order_id.and_then(|cid| {
650 dispatch_state.order_identities.get(&cid).map(|r| (cid, r.clone()))
651 });
652
653 if let Some((cid, ident)) = identity {
654 ensure_accepted_to_python(
655 cid,
656 account_id,
657 report.venue_order_id,
658 &ident,
659 &dispatch_state,
660 trader_id,
661 ts_init,
662 &call_soon,
663 &callback,
664 );
665 dispatch_state.insert_filled(cid);
666 let quote_currency = instrument_cache
667 .get(&report.instrument_id)
668 .map_or_else(Currency::USD, |i: InstrumentAny| i.quote_currency());
669 let filled = fill_report_to_order_filled(
670 &report, trader_id, &ident, quote_currency,
671 );
672 send_to_python(filled, &call_soon, &callback);
673 } else {
674 let entry = cum_fill_totals
675 .entry(report.venue_order_id)
676 .or_default();
677 let qty = report.last_qty.as_decimal();
678 entry.0 += report.last_px.as_decimal() * qty;
679 entry.1 += qty;
680 send_to_python(report, &call_soon, &callback);
681 }
682 }
683 Err(e) => log::error!("Failed to parse WS fill: {e}"),
684 }
685 }
686 }
687
688 for report in &mut pending_order_reports {
690 if let Some((notional, total_qty)) =
691 cum_fill_totals.get(&report.venue_order_id)
692 && !total_qty.is_zero()
693 {
694 report.avg_px = Some(notional / total_qty);
695 }
696 }
697
698 for report in pending_order_reports {
699 let identity = report.client_order_id.and_then(|cid| {
700 dispatch_state.order_identities.get(&cid).map(|r| (cid, r.clone()))
701 });
702
703 if let Some((cid, ident)) = identity {
704 match report.order_status {
705 OrderStatus::Accepted => {
706 if dispatch_state.emitted_accepted.contains(&cid)
707 || dispatch_state.filled_orders.contains(&cid)
708 {
709 log::debug!("Skipping duplicate Accepted for {cid}");
710 continue;
711 }
712 dispatch_state.insert_accepted(cid);
713 let accepted = OrderAccepted::new(
714 trader_id,
715 ident.strategy_id,
716 ident.instrument_id,
717 cid,
718 report.venue_order_id,
719 account_id,
720 UUID4::new(),
721 report.ts_last,
722 ts_init,
723 false,
724 );
725 send_to_python(accepted, &call_soon, &callback);
726 }
727 OrderStatus::Canceled => {
728 ensure_accepted_to_python(
729 cid,
730 account_id,
731 report.venue_order_id,
732 &ident,
733 &dispatch_state,
734 trader_id,
735 ts_init,
736 &call_soon,
737 &callback,
738 );
739 let canceled = OrderCanceled::new(
740 trader_id,
741 ident.strategy_id,
742 ident.instrument_id,
743 cid,
744 UUID4::new(),
745 report.ts_last,
746 ts_init,
747 false,
748 Some(report.venue_order_id),
749 Some(account_id),
750 );
751 send_to_python(canceled, &call_soon, &callback);
752 dispatch_state.cleanup_terminal(&cid);
753 }
754 OrderStatus::Filled => {
755 dispatch_state.cleanup_terminal(&cid);
756 }
757 _ => {
758 send_to_python(report, &call_soon, &callback);
759 }
760 }
761 } else {
762 send_to_python(report, &call_soon, &callback);
763 }
764 }
765
766 for (client_id, client_metadata, order_id) in terminal_orders {
768 order_contexts.remove(&client_id);
769 encoder.remove(client_id, client_metadata);
770 order_id_map.remove(&order_id);
771 }
772 }
773 DydxWsOutputMessage::BlockHeight { height, time } => {
774 Python::attach(|py| {
775 let dict = PyDict::new(py);
776 let _ = dict.set_item("type", "block_height");
777 let _ = dict.set_item("height", height);
778 let _ = dict.set_item("time", time.to_rfc3339());
779 if let Ok(py_obj) = dict.into_py_any(py) {
780 call_python_threadsafe(py, &call_soon, &callback, py_obj);
781 }
782 });
783 }
784 DydxWsOutputMessage::Error(err) => {
785 log::warn!("dYdX WebSocket error: {err}");
786 }
787 DydxWsOutputMessage::Reconnected { topics } => {
788 log::info!("dYdX WebSocket reconnected");
789 let reconnected_candles = candle_ids_from_topics(&topics);
790 pending_bars.retain(|id, _| !reconnected_candles.contains(id));
791 }
792 }
793 }
794 });
795 }
796
797 Ok(())
798 })
799 }
800
801 #[pyo3(name = "disconnect")]
807 fn py_disconnect<'py>(&mut self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
808 let mut client = self.clone();
809 pyo3_async_runtimes::tokio::future_into_py(py, async move {
810 client.disconnect().await.map_err(to_pyvalue_err)?;
811 Ok(())
812 })
813 }
814
815 #[pyo3(name = "wait_until_active")]
816 fn py_wait_until_active<'py>(
817 &self,
818 py: Python<'py>,
819 timeout_secs: f64,
820 ) -> PyResult<Bound<'py, PyAny>> {
821 let connection_mode = self.connection_mode_atomic();
822
823 pyo3_async_runtimes::tokio::future_into_py(py, async move {
824 let timeout = Duration::from_secs_f64(timeout_secs);
825 let start = Instant::now();
826
827 loop {
828 let mode = connection_mode.load();
829 let mode_u8 = mode.load(Ordering::Relaxed);
830 let is_connected = matches!(
831 mode_u8,
832 x if x == ConnectionMode::Active as u8 || x == ConnectionMode::Reconnect as u8
833 );
834
835 if is_connected {
836 break;
837 }
838
839 if start.elapsed() > timeout {
840 return Err(to_pyvalue_err(std::io::Error::new(
841 std::io::ErrorKind::TimedOut,
842 format!("Client did not become active within {timeout_secs}s"),
843 )));
844 }
845 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
846 }
847
848 Ok(())
849 })
850 }
851
852 #[pyo3(name = "cache_instrument")]
856 fn py_cache_instrument(&self, instrument: Py<PyAny>, py: Python<'_>) -> PyResult<()> {
857 let inst_any = pyobject_to_instrument_any(py, instrument)?;
858 self.cache_instrument(inst_any);
859 Ok(())
860 }
861
862 #[pyo3(name = "cache_instruments")]
866 fn py_cache_instruments(&self, instruments: Vec<Py<PyAny>>, py: Python<'_>) -> PyResult<()> {
867 let mut instruments_any = Vec::new();
868
869 for inst in instruments {
870 let inst_any = pyobject_to_instrument_any(py, inst)?;
871 instruments_any.push(inst_any);
872 }
873 self.cache_instruments(instruments_any);
874 Ok(())
875 }
876
877 #[pyo3(name = "is_closed")]
878 fn py_is_closed(&self) -> bool {
879 !self.is_connected()
880 }
881
882 #[pyo3(name = "subscribe_trades")]
888 fn py_subscribe_trades<'py>(
889 &self,
890 py: Python<'py>,
891 instrument_id: InstrumentId,
892 ) -> PyResult<Bound<'py, PyAny>> {
893 let client = self.clone();
894 pyo3_async_runtimes::tokio::future_into_py(py, async move {
895 client
896 .subscribe_trades(instrument_id)
897 .await
898 .map_err(to_pyvalue_err)?;
899 Ok(())
900 })
901 }
902
903 #[pyo3(name = "unsubscribe_trades")]
905 fn py_unsubscribe_trades<'py>(
906 &self,
907 py: Python<'py>,
908 instrument_id: InstrumentId,
909 ) -> PyResult<Bound<'py, PyAny>> {
910 let client = self.clone();
911 pyo3_async_runtimes::tokio::future_into_py(py, async move {
912 client
913 .unsubscribe_trades(instrument_id)
914 .await
915 .map_err(to_pyvalue_err)?;
916 Ok(())
917 })
918 }
919
920 #[pyo3(name = "subscribe_orderbook")]
926 fn py_subscribe_orderbook<'py>(
927 &self,
928 py: Python<'py>,
929 instrument_id: InstrumentId,
930 ) -> PyResult<Bound<'py, PyAny>> {
931 let client = self.clone();
932 pyo3_async_runtimes::tokio::future_into_py(py, async move {
933 client
934 .subscribe_orderbook(instrument_id)
935 .await
936 .map_err(to_pyvalue_err)?;
937 Ok(())
938 })
939 }
940
941 #[pyo3(name = "unsubscribe_orderbook")]
943 fn py_unsubscribe_orderbook<'py>(
944 &self,
945 py: Python<'py>,
946 instrument_id: InstrumentId,
947 ) -> PyResult<Bound<'py, PyAny>> {
948 let client = self.clone();
949 pyo3_async_runtimes::tokio::future_into_py(py, async move {
950 client
951 .unsubscribe_orderbook(instrument_id)
952 .await
953 .map_err(to_pyvalue_err)?;
954 Ok(())
955 })
956 }
957
958 #[pyo3(name = "subscribe_bars")]
959 fn py_subscribe_bars<'py>(
960 &self,
961 py: Python<'py>,
962 bar_type: BarType,
963 ) -> PyResult<Bound<'py, PyAny>> {
964 let spec = bar_type.spec();
965 let resolution = DydxCandleResolution::from_bar_spec(&spec).map_err(to_pyvalue_err)?;
966 let resolution = resolution.to_string();
967
968 let client = self.clone();
969 let instrument_id = bar_type.instrument_id();
970 let bar_types = self.bar_types().clone();
971
972 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
974 let topic = format!("{ticker}/{resolution}");
975
976 pyo3_async_runtimes::tokio::future_into_py(py, async move {
977 bar_types.insert(topic, bar_type);
978
979 client
980 .subscribe_candles(instrument_id, &resolution)
981 .await
982 .map_err(to_pyvalue_err)?;
983 Ok(())
984 })
985 }
986
987 #[pyo3(name = "unsubscribe_bars")]
988 fn py_unsubscribe_bars<'py>(
989 &self,
990 py: Python<'py>,
991 bar_type: BarType,
992 ) -> PyResult<Bound<'py, PyAny>> {
993 let spec = bar_type.spec();
994 let resolution = DydxCandleResolution::from_bar_spec(&spec).map_err(to_pyvalue_err)?;
995 let resolution = resolution.to_string();
996
997 let client = self.clone();
998 let instrument_id = bar_type.instrument_id();
999 let bar_types = self.bar_types().clone();
1000
1001 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
1003 let topic = format!("{ticker}/{resolution}");
1004
1005 pyo3_async_runtimes::tokio::future_into_py(py, async move {
1006 client
1007 .unsubscribe_candles(instrument_id, &resolution)
1008 .await
1009 .map_err(to_pyvalue_err)?;
1010
1011 bar_types.remove(&topic);
1012
1013 Ok(())
1014 })
1015 }
1016
1017 #[pyo3(name = "subscribe_markets")]
1027 fn py_subscribe_markets<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
1028 let client = self.clone();
1029 pyo3_async_runtimes::tokio::future_into_py(py, async move {
1030 client.subscribe_markets().await.map_err(to_pyvalue_err)?;
1031 Ok(())
1032 })
1033 }
1034
1035 #[pyo3(name = "unsubscribe_markets")]
1041 fn py_unsubscribe_markets<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
1042 let client = self.clone();
1043 pyo3_async_runtimes::tokio::future_into_py(py, async move {
1044 client.unsubscribe_markets().await.map_err(to_pyvalue_err)?;
1045 Ok(())
1046 })
1047 }
1048
1049 #[pyo3(name = "subscribe_subaccount")]
1061 fn py_subscribe_subaccount<'py>(
1062 &self,
1063 py: Python<'py>,
1064 address: String,
1065 subaccount_number: u32,
1066 ) -> PyResult<Bound<'py, PyAny>> {
1067 let client = self.clone();
1068 pyo3_async_runtimes::tokio::future_into_py(py, async move {
1069 client
1070 .subscribe_subaccount(&address, subaccount_number)
1071 .await
1072 .map_err(to_pyvalue_err)?;
1073 Ok(())
1074 })
1075 }
1076
1077 #[pyo3(name = "unsubscribe_subaccount")]
1079 fn py_unsubscribe_subaccount<'py>(
1080 &self,
1081 py: Python<'py>,
1082 address: String,
1083 subaccount_number: u32,
1084 ) -> PyResult<Bound<'py, PyAny>> {
1085 let client = self.clone();
1086 pyo3_async_runtimes::tokio::future_into_py(py, async move {
1087 client
1088 .unsubscribe_subaccount(&address, subaccount_number)
1089 .await
1090 .map_err(to_pyvalue_err)?;
1091 Ok(())
1092 })
1093 }
1094
1095 #[pyo3(name = "subscribe_block_height")]
1105 fn py_subscribe_block_height<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
1106 let client = self.clone();
1107 pyo3_async_runtimes::tokio::future_into_py(py, async move {
1108 client
1109 .subscribe_block_height()
1110 .await
1111 .map_err(to_pyvalue_err)?;
1112 Ok(())
1113 })
1114 }
1115
1116 #[pyo3(name = "unsubscribe_block_height")]
1122 fn py_unsubscribe_block_height<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
1123 let client = self.clone();
1124 pyo3_async_runtimes::tokio::future_into_py(py, async move {
1125 client
1126 .unsubscribe_block_height()
1127 .await
1128 .map_err(to_pyvalue_err)?;
1129 Ok(())
1130 })
1131 }
1132}
1133
1134fn instrument_id_from_ticker(ticker: &str) -> InstrumentId {
1135 let symbol = format!("{ticker}-PERP");
1136 InstrumentId::new(Symbol::new(&symbol), *DYDX_VENUE)
1137}
1138
1139fn handle_markets_trading_data(
1140 trading: Option<
1141 &std::collections::HashMap<String, crate::websocket::messages::DydxMarketTradingUpdate>,
1142 >,
1143 instrument_cache: &std::sync::Arc<crate::common::instrument_cache::InstrumentCache>,
1144 seen_tickers: &mut ahash::AHashSet<Ustr>,
1145 call_soon: &Py<PyAny>,
1146 callback: &Py<PyAny>,
1147 ts_init: nautilus_core::UnixNanos,
1148) {
1149 let Some(trading_map) = trading else {
1150 return;
1151 };
1152
1153 for (ticker, update) in trading_map {
1154 let instrument_id = instrument_id_from_ticker(ticker);
1155
1156 if let Some(status) = &update.status {
1157 if *status == DydxMarketStatus::Unknown {
1158 log::warn!("Skipping unmodeled dYdX market status for {instrument_id}");
1159 } else {
1160 let action = MarketStatusAction::from(*status);
1161 let is_trading = matches!(status, DydxMarketStatus::Active);
1162
1163 let instrument_status = InstrumentStatus::new(
1164 instrument_id,
1165 action,
1166 ts_init,
1167 ts_init,
1168 None,
1169 None,
1170 Some(is_trading),
1171 None,
1172 None,
1173 );
1174
1175 if instrument_cache.get_by_market(ticker).is_some() {
1176 Python::attach(|py| match instrument_status.into_py_any(py) {
1177 Ok(py_obj) => {
1178 call_python_threadsafe(py, call_soon, callback, py_obj);
1179 }
1180 Err(e) => {
1181 log::error!("Failed to convert InstrumentStatus to Python: {e}");
1182 }
1183 });
1184 }
1185 }
1186 }
1187
1188 let ticker_ustr = Ustr::from(ticker.as_str());
1189 if !seen_tickers.contains(&ticker_ustr) {
1190 let is_active = update
1191 .status
1192 .as_ref()
1193 .is_none_or(|s| matches!(s, crate::common::enums::DydxMarketStatus::Active));
1194 if instrument_cache.get_by_market(ticker).is_some() {
1195 seen_tickers.insert(ticker_ustr);
1196 } else if is_active {
1197 seen_tickers.insert(ticker_ustr);
1198 log::debug!("New instrument discovered via WebSocket: {ticker}");
1199 Python::attach(|py| {
1200 let dict = PyDict::new(py);
1201 let _ = dict.set_item("type", "new_instrument_discovered");
1202 let _ = dict.set_item("ticker", ticker);
1203 if let Ok(py_obj) = dict.into_py_any(py) {
1204 call_python_threadsafe(py, call_soon, callback, py_obj);
1205 }
1206 });
1207 }
1208 }
1209
1210 if let Some(ref rate_str) = update.next_funding_rate {
1211 if let Ok(rate) = Decimal::from_str(rate_str) {
1212 let funding_rate = FundingRateUpdate {
1213 instrument_id,
1214 rate,
1215 interval: Some(60),
1216 next_funding_ns: None,
1217 ts_event: ts_init,
1218 ts_init,
1219 };
1220 Python::attach(|py| match funding_rate.into_py_any(py) {
1221 Ok(py_obj) => {
1222 call_python_threadsafe(py, call_soon, callback, py_obj);
1223 }
1224 Err(e) => log::error!("Failed to convert FundingRateUpdate to Python: {e}"),
1225 });
1226 } else {
1227 log::warn!("Failed to parse next_funding_rate for {ticker}: {rate_str}");
1228 }
1229 }
1230 }
1231}
1232
1233#[expect(clippy::too_many_arguments)]
1234fn ensure_accepted_to_python(
1235 client_order_id: ClientOrderId,
1236 account_id: AccountId,
1237 venue_order_id: VenueOrderId,
1238 identity: &OrderIdentity,
1239 state: &DydxWsDispatchState,
1240 trader_id: TraderId,
1241 ts_init: nautilus_core::UnixNanos,
1242 call_soon: &Py<PyAny>,
1243 callback: &Py<PyAny>,
1244) {
1245 if state.emitted_accepted.contains(&client_order_id) {
1246 return;
1247 }
1248 state.insert_accepted(client_order_id);
1249 let accepted = OrderAccepted::new(
1250 trader_id,
1251 identity.strategy_id,
1252 identity.instrument_id,
1253 client_order_id,
1254 venue_order_id,
1255 account_id,
1256 UUID4::new(),
1257 ts_init,
1258 ts_init,
1259 false,
1260 );
1261 send_to_python(accepted, call_soon, callback);
1262}
1263
1264fn send_to_python<T: for<'py> IntoPyObjectExt<'py>>(
1265 value: T,
1266 call_soon: &Py<PyAny>,
1267 callback: &Py<PyAny>,
1268) {
1269 Python::attach(|py| match value.into_py_any(py) {
1270 Ok(py_obj) => {
1271 call_python_threadsafe(py, call_soon, callback, py_obj);
1272 }
1273 Err(e) => log::error!("Failed to convert to Python: {e}"),
1274 });
1275}