Skip to main content

nautilus_dydx/python/
http.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Python bindings for dYdX HTTP client.
17
18use std::str::FromStr;
19
20use jiff::Timestamp;
21use nautilus_core::python::{IntoPyObjectNautilusExt, to_pyvalue_err};
22use nautilus_model::{
23    data::BarType,
24    identifiers::{AccountId, InstrumentId},
25    instruments::InstrumentAny,
26    python::instruments::{instrument_any_to_pyobject, pyobject_to_instrument_any},
27};
28use pyo3::{
29    IntoPyObjectExt,
30    prelude::*,
31    types::{PyDict, PyList},
32};
33use rust_decimal::Decimal;
34
35use crate::{
36    common::{consts::DYDX_VENUE, enums::DydxNetwork},
37    http::client::DydxHttpClient,
38};
39
40#[pymethods]
41#[pyo3_stub_gen::derive::gen_stub_pymethods]
42impl DydxHttpClient {
43    /// Provides a higher-level HTTP client for the [dYdX v4](https://dydx.exchange) Indexer REST API.
44    ///
45    /// This client wraps the underlying `DydxRawHttpClient` to handle conversions
46    /// into the Nautilus domain model, following the two-layer pattern established
47    /// in OKX, Bybit, and BitMEX adapters.
48    ///
49    /// **Architecture:**
50    /// - **Raw client** (`DydxRawHttpClient`): Low-level HTTP methods matching dYdX Indexer API endpoints.
51    /// - **Domain client** (`DydxHttpClient`): High-level methods using Nautilus domain types.
52    ///
53    /// The domain client:
54    /// - Wraps the raw client in an `Arc` for efficient cloning (required for Python bindings).
55    /// - Maintains an instrument cache using `DashMap` for thread-safe concurrent access.
56    /// - Provides standard cache methods: `cache_instruments()`, `cache_instrument()`, `get_instrument()`.
57    /// - Tracks cache initialization state for optimizations.
58    #[new]
59    #[pyo3(signature = (base_url=None, network=DydxNetwork::Mainnet, proxy_url=None))]
60    fn py_new(
61        base_url: Option<String>,
62        network: DydxNetwork,
63        proxy_url: Option<String>,
64    ) -> PyResult<Self> {
65        Self::new(
66            base_url, 60, // timeout_secs
67            proxy_url, network, None, // retry_config
68        )
69        .map_err(to_pyvalue_err)
70    }
71
72    /// Returns `true` if this client is configured for testnet.
73    #[pyo3(name = "is_testnet")]
74    fn py_is_testnet(&self) -> bool {
75        self.is_testnet()
76    }
77
78    /// Returns the base URL used by this client.
79    #[pyo3(name = "base_url")]
80    fn py_base_url(&self) -> String {
81        self.base_url().to_string()
82    }
83
84    /// Requests instruments from the dYdX Indexer API and returns Nautilus domain types.
85    ///
86    /// This method does NOT automatically cache results. Use `fetch_and_cache_instruments()`
87    /// for automatic caching, or call `cache_instruments()` manually with the results.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the HTTP request or parsing fails.
92    /// Individual instrument parsing errors are logged as warnings.
93    #[pyo3(name = "request_instruments")]
94    fn py_request_instruments<'py>(
95        &self,
96        py: Python<'py>,
97        maker_fee: Option<&str>,
98        taker_fee: Option<&str>,
99    ) -> PyResult<Bound<'py, PyAny>> {
100        let maker = maker_fee
101            .map(Decimal::from_str)
102            .transpose()
103            .map_err(to_pyvalue_err)?;
104
105        let taker = taker_fee
106            .map(Decimal::from_str)
107            .transpose()
108            .map_err(to_pyvalue_err)?;
109
110        let client = self.clone();
111
112        pyo3_async_runtimes::tokio::future_into_py(py, async move {
113            let instruments = client
114                .request_instruments(None, maker, taker)
115                .await
116                .map_err(to_pyvalue_err)?;
117
118            Python::attach(|py| {
119                let py_instruments: PyResult<Vec<Py<PyAny>>> = instruments
120                    .into_iter()
121                    .map(|inst| instrument_any_to_pyobject(py, inst))
122                    .collect();
123                py_instruments
124            })
125        })
126    }
127
128    /// Fetches instruments from the API and caches them.
129    ///
130    /// This is a convenience method that fetches instruments and populates both
131    /// the symbol-based and CLOB pair ID-based caches.
132    ///
133    /// On success, existing caches are cleared and repopulated atomically.
134    /// On failure, existing caches are preserved (no partial updates).
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if the HTTP request fails.
139    #[pyo3(name = "fetch_and_cache_instruments")]
140    fn py_fetch_and_cache_instruments<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
141        let client = self.clone();
142        pyo3_async_runtimes::tokio::future_into_py(py, async move {
143            client
144                .fetch_and_cache_instruments()
145                .await
146                .map_err(to_pyvalue_err)?;
147            Ok(())
148        })
149    }
150
151    /// Fetches a single instrument by ticker and caches it.
152    ///
153    /// This is used for on-demand fetching of newly discovered instruments
154    /// via WebSocket.
155    ///
156    /// Returns `None` if the market is not found or inactive.
157    #[pyo3(name = "fetch_instrument")]
158    fn py_fetch_instrument<'py>(
159        &self,
160        py: Python<'py>,
161        ticker: String,
162    ) -> PyResult<Bound<'py, PyAny>> {
163        let client = self.clone();
164        pyo3_async_runtimes::tokio::future_into_py(py, async move {
165            match client.fetch_and_cache_single_instrument(&ticker).await {
166                Ok(Some(instrument)) => {
167                    Python::attach(|py| instrument_any_to_pyobject(py, instrument))
168                }
169                Ok(None) => Ok(Python::attach(|py| py.None())),
170                Err(e) => Err(to_pyvalue_err(e)),
171            }
172        })
173    }
174
175    /// Gets an instrument from the cache by InstrumentId.
176    #[pyo3(name = "get_instrument")]
177    fn py_get_instrument(&self, py: Python<'_>, symbol: &str) -> PyResult<Option<Py<PyAny>>> {
178        use nautilus_model::identifiers::Symbol;
179        let instrument_id = InstrumentId::new(Symbol::new(symbol), *DYDX_VENUE);
180        let instrument = self.get_instrument(&instrument_id);
181        match instrument {
182            Some(inst) => Ok(Some(instrument_any_to_pyobject(py, inst)?)),
183            None => Ok(None),
184        }
185    }
186
187    #[pyo3(name = "instrument_count")]
188    fn py_instrument_count(&self) -> usize {
189        self.cached_instruments_count()
190    }
191
192    #[pyo3(name = "instrument_symbols")]
193    fn py_instrument_symbols(&self) -> Vec<String> {
194        self.all_instrument_ids()
195            .into_iter()
196            .map(|id| id.symbol.to_string())
197            .collect()
198    }
199
200    /// Caches multiple instruments (symbol lookup only).
201    ///
202    /// Use `fetch_and_cache_instruments()` for full caching with market params.
203    /// Any existing instruments with the same symbols will be replaced.
204    #[pyo3(name = "cache_instruments")]
205    fn py_cache_instruments(
206        &self,
207        py: Python<'_>,
208        py_instruments: Vec<Bound<'_, PyAny>>,
209    ) -> PyResult<()> {
210        let instruments: Vec<InstrumentAny> = py_instruments
211            .into_iter()
212            .map(|py_inst| {
213                // Convert Bound<PyAny> to Py<PyAny> using unbind()
214                pyobject_to_instrument_any(py, py_inst.unbind())
215            })
216            .collect::<Result<Vec<_>, _>>()
217            .map_err(to_pyvalue_err)?;
218
219        self.cache_instruments(instruments);
220        Ok(())
221    }
222
223    #[pyo3(name = "get_orders")]
224    #[pyo3(signature = (address, subaccount_number, market=None, limit=None))]
225    fn py_get_orders<'py>(
226        &self,
227        py: Python<'py>,
228        address: String,
229        subaccount_number: u32,
230        market: Option<String>,
231        limit: Option<u32>,
232    ) -> PyResult<Bound<'py, PyAny>> {
233        let client = self.clone();
234        pyo3_async_runtimes::tokio::future_into_py(py, async move {
235            let response = client
236                .inner
237                .get_orders(&address, subaccount_number, market.as_deref(), limit)
238                .await
239                .map_err(to_pyvalue_err)?;
240            serde_json::to_string(&response).map_err(to_pyvalue_err)
241        })
242    }
243
244    #[pyo3(name = "get_fills")]
245    #[pyo3(signature = (address, subaccount_number, market=None, limit=None))]
246    fn py_get_fills<'py>(
247        &self,
248        py: Python<'py>,
249        address: String,
250        subaccount_number: u32,
251        market: Option<String>,
252        limit: Option<u32>,
253    ) -> PyResult<Bound<'py, PyAny>> {
254        let client = self.clone();
255        pyo3_async_runtimes::tokio::future_into_py(py, async move {
256            let response = client
257                .inner
258                .get_fills(&address, subaccount_number, market.as_deref(), limit)
259                .await
260                .map_err(to_pyvalue_err)?;
261            serde_json::to_string(&response).map_err(to_pyvalue_err)
262        })
263    }
264
265    #[pyo3(name = "get_subaccount")]
266    fn py_get_subaccount<'py>(
267        &self,
268        py: Python<'py>,
269        address: String,
270        subaccount_number: u32,
271    ) -> PyResult<Bound<'py, PyAny>> {
272        let client = self.clone();
273        pyo3_async_runtimes::tokio::future_into_py(py, async move {
274            let response = client
275                .inner
276                .get_subaccount(&address, subaccount_number)
277                .await
278                .map_err(to_pyvalue_err)?;
279            serde_json::to_string(&response).map_err(to_pyvalue_err)
280        })
281    }
282
283    /// Requests order status reports for a subaccount.
284    ///
285    /// Fetches orders from the dYdX Indexer API and converts them to Nautilus
286    /// `OrderStatusReport` objects.
287    ///
288    /// # Errors
289    ///
290    /// Returns an error if the HTTP request fails or parsing fails.
291    #[pyo3(name = "request_order_status_reports")]
292    #[pyo3(signature = (address, subaccount_number, account_id, instrument_id=None))]
293    fn py_request_order_status_reports<'py>(
294        &self,
295        py: Python<'py>,
296        address: String,
297        subaccount_number: u32,
298        account_id: AccountId,
299        instrument_id: Option<InstrumentId>,
300    ) -> PyResult<Bound<'py, PyAny>> {
301        let client = self.clone();
302        pyo3_async_runtimes::tokio::future_into_py(py, async move {
303            let reports = client
304                .request_order_status_reports(
305                    &address,
306                    subaccount_number,
307                    account_id,
308                    instrument_id,
309                )
310                .await
311                .map_err(to_pyvalue_err)?;
312
313            Python::attach(|py| {
314                let py_reports = reports
315                    .into_iter()
316                    .map(|report| report.into_py_any(py))
317                    .collect::<PyResult<Vec<_>>>()?;
318                let pylist = PyList::new(py, py_reports)?;
319                Ok(pylist.into_py_any_unwrap(py))
320            })
321        })
322    }
323
324    /// Requests fill reports for a subaccount.
325    ///
326    /// Fetches fills from the dYdX Indexer API and converts them to Nautilus
327    /// `FillReport` objects.
328    ///
329    /// # Errors
330    ///
331    /// Returns an error if the HTTP request fails or parsing fails.
332    #[pyo3(name = "request_fill_reports")]
333    #[pyo3(signature = (address, subaccount_number, account_id, instrument_id=None))]
334    fn py_request_fill_reports<'py>(
335        &self,
336        py: Python<'py>,
337        address: String,
338        subaccount_number: u32,
339        account_id: AccountId,
340        instrument_id: Option<InstrumentId>,
341    ) -> PyResult<Bound<'py, PyAny>> {
342        let client = self.clone();
343        pyo3_async_runtimes::tokio::future_into_py(py, async move {
344            let reports = client
345                .request_fill_reports(&address, subaccount_number, account_id, instrument_id)
346                .await
347                .map_err(to_pyvalue_err)?;
348
349            Python::attach(|py| {
350                let py_reports = reports
351                    .into_iter()
352                    .map(|report| report.into_py_any(py))
353                    .collect::<PyResult<Vec<_>>>()?;
354                let pylist = PyList::new(py, py_reports)?;
355                Ok(pylist.into_py_any_unwrap(py))
356            })
357        })
358    }
359
360    /// Requests position status reports for a subaccount.
361    ///
362    /// Fetches positions from the dYdX Indexer API and converts them to Nautilus
363    /// `PositionStatusReport` objects.
364    ///
365    /// # Errors
366    ///
367    /// Returns an error if the HTTP request fails or parsing fails.
368    #[pyo3(name = "request_position_status_reports")]
369    #[pyo3(signature = (address, subaccount_number, account_id, instrument_id=None))]
370    fn py_request_position_status_reports<'py>(
371        &self,
372        py: Python<'py>,
373        address: String,
374        subaccount_number: u32,
375        account_id: AccountId,
376        instrument_id: Option<InstrumentId>,
377    ) -> PyResult<Bound<'py, PyAny>> {
378        let client = self.clone();
379        pyo3_async_runtimes::tokio::future_into_py(py, async move {
380            let reports = client
381                .request_position_status_reports(
382                    &address,
383                    subaccount_number,
384                    account_id,
385                    instrument_id,
386                )
387                .await
388                .map_err(to_pyvalue_err)?;
389
390            Python::attach(|py| {
391                let py_reports = reports
392                    .into_iter()
393                    .map(|report| report.into_py_any(py))
394                    .collect::<PyResult<Vec<_>>>()?;
395                let pylist = PyList::new(py, py_reports)?;
396                Ok(pylist.into_py_any_unwrap(py))
397            })
398        })
399    }
400
401    /// Requests account state for a subaccount.
402    ///
403    /// Fetches the subaccount from the dYdX Indexer API and converts it to a Nautilus
404    /// `AccountState` with balances and margin calculations.
405    ///
406    /// # Errors
407    ///
408    /// Returns an error if the HTTP request fails or parsing fails.
409    #[pyo3(name = "request_account_state")]
410    fn py_request_account_state<'py>(
411        &self,
412        py: Python<'py>,
413        address: String,
414        subaccount_number: u32,
415        account_id: AccountId,
416    ) -> PyResult<Bound<'py, PyAny>> {
417        let client = self.clone();
418        pyo3_async_runtimes::tokio::future_into_py(py, async move {
419            let account_state = client
420                .request_account_state(&address, subaccount_number, account_id)
421                .await
422                .map_err(to_pyvalue_err)?;
423
424            Python::attach(|py| account_state.into_py_any(py))
425        })
426    }
427
428    /// Requests historical bars for an instrument with optional pagination.
429    ///
430    /// Fetches candle data from the dYdX Indexer API and converts to Nautilus
431    /// `Bar` objects. Supports time-chunked pagination for large date ranges.
432    ///
433    /// The resolution is derived internally from `bar_type` (no need to pass
434    /// `DydxCandleResolution`). Incomplete bars (where `ts_event >= now`) are
435    /// filtered out.
436    ///
437    /// Results are returned in chronological order (oldest first).
438    ///
439    /// # Errors
440    ///
441    /// Returns an error if:
442    /// - The bar type uses unsupported aggregation/price type.
443    /// - The HTTP request fails or response cannot be parsed.
444    /// - The instrument is not found in the cache.
445    #[pyo3(name = "request_bars")]
446    #[pyo3(signature = (bar_type, start=None, end=None, limit=None, timestamp_on_close=true))]
447    fn py_request_bars<'py>(
448        &self,
449        py: Python<'py>,
450        bar_type: BarType,
451        start: Option<Timestamp>,
452        end: Option<Timestamp>,
453        limit: Option<u32>,
454        timestamp_on_close: bool,
455    ) -> PyResult<Bound<'py, PyAny>> {
456        let client = self.clone();
457
458        pyo3_async_runtimes::tokio::future_into_py(py, async move {
459            let bars = client
460                .request_bars(bar_type, start, end, limit, timestamp_on_close)
461                .await
462                .map_err(to_pyvalue_err)?;
463
464            Python::attach(|py| {
465                let py_bars = bars
466                    .into_iter()
467                    .map(|bar| bar.into_py_any(py))
468                    .collect::<PyResult<Vec<_>>>()?;
469                let pylist = PyList::new(py, py_bars)?;
470                Ok(pylist.into_py_any_unwrap(py))
471            })
472        })
473    }
474
475    /// Requests historical trade ticks for an instrument with optional pagination.
476    ///
477    /// Fetches trade data from the dYdX Indexer API and converts them to Nautilus
478    /// `TradeTick` objects. Supports cursor-based pagination using block height
479    /// and client-side time filtering (the dYdX API has no timestamp filter).
480    ///
481    /// Results are returned in chronological order (oldest first).
482    ///
483    /// # Errors
484    ///
485    /// Returns an error if the HTTP request fails, response cannot be parsed,
486    /// or the instrument is not found in the cache.
487    #[pyo3(name = "request_trade_ticks")]
488    #[pyo3(signature = (instrument_id, start=None, end=None, limit=None))]
489    fn py_request_trade_ticks<'py>(
490        &self,
491        py: Python<'py>,
492        instrument_id: InstrumentId,
493        start: Option<Timestamp>,
494        end: Option<Timestamp>,
495        limit: Option<u32>,
496    ) -> PyResult<Bound<'py, PyAny>> {
497        let client = self.clone();
498
499        pyo3_async_runtimes::tokio::future_into_py(py, async move {
500            let trades = client
501                .request_trade_ticks(instrument_id, start, end, limit)
502                .await
503                .map_err(to_pyvalue_err)?;
504
505            Python::attach(|py| {
506                let py_trades = trades
507                    .into_iter()
508                    .map(|trade| trade.into_py_any(py))
509                    .collect::<PyResult<Vec<_>>>()?;
510                let pylist = PyList::new(py, py_trades)?;
511                Ok(pylist.into_py_any_unwrap(py))
512            })
513        })
514    }
515
516    /// Requests historical funding rates for an instrument.
517    ///
518    /// Fetches funding rate data from the dYdX Indexer API's
519    /// `/v4/historicalFunding/:ticker` endpoint and converts them to Nautilus
520    /// `FundingRateUpdate` objects.
521    ///
522    /// Results are returned in chronological order (oldest first).
523    ///
524    /// # Errors
525    ///
526    /// Returns an error if the HTTP request fails or response cannot be parsed.
527    #[pyo3(name = "request_funding_rates")]
528    #[pyo3(signature = (instrument_id, start=None, end=None, limit=None))]
529    fn py_request_funding_rates<'py>(
530        &self,
531        py: Python<'py>,
532        instrument_id: InstrumentId,
533        start: Option<Timestamp>,
534        end: Option<Timestamp>,
535        limit: Option<u32>,
536    ) -> PyResult<Bound<'py, PyAny>> {
537        let client = self.clone();
538
539        pyo3_async_runtimes::tokio::future_into_py(py, async move {
540            let funding_rates = client
541                .request_funding_rates(instrument_id, start, end, limit)
542                .await
543                .map_err(to_pyvalue_err)?;
544
545            Python::attach(|py| {
546                let py_rates = funding_rates
547                    .into_iter()
548                    .map(|rate| rate.into_py_any(py))
549                    .collect::<PyResult<Vec<_>>>()?;
550                let pylist = PyList::new(py, py_rates)?;
551                Ok(pylist.into_py_any_unwrap(py))
552            })
553        })
554    }
555
556    /// Requests an order book snapshot for a symbol.
557    ///
558    /// Fetches order book data from the dYdX Indexer API and converts it to Nautilus
559    /// `OrderBookDeltas`. The snapshot is represented as a sequence of deltas starting
560    /// with a CLEAR action followed by ADD actions for each level.
561    ///
562    /// # Errors
563    ///
564    /// Returns an error if the HTTP request fails, response cannot be parsed,
565    /// or the instrument is not found in the cache.
566    #[pyo3(name = "request_orderbook_snapshot")]
567    fn py_request_orderbook_snapshot<'py>(
568        &self,
569        py: Python<'py>,
570        instrument_id: InstrumentId,
571    ) -> PyResult<Bound<'py, PyAny>> {
572        let client = self.clone();
573
574        pyo3_async_runtimes::tokio::future_into_py(py, async move {
575            let deltas = client
576                .request_orderbook_snapshot(instrument_id)
577                .await
578                .map_err(to_pyvalue_err)?;
579
580            Python::attach(|py| deltas.into_py_any(py))
581        })
582    }
583
584    #[pyo3(name = "get_time")]
585    fn py_get_time<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
586        let client = self.clone();
587        pyo3_async_runtimes::tokio::future_into_py(py, async move {
588            let response = client.inner.get_time().await.map_err(to_pyvalue_err)?;
589            Python::attach(|py| {
590                let dict = PyDict::new(py);
591                dict.set_item("iso", response.iso.to_string())?;
592                dict.set_item("epoch", response.epoch_ms)?;
593                Ok(dict.into_py_any_unwrap(py))
594            })
595        })
596    }
597
598    #[pyo3(name = "get_height")]
599    fn py_get_height<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
600        let client = self.clone();
601        pyo3_async_runtimes::tokio::future_into_py(py, async move {
602            let response = client.inner.get_height().await.map_err(to_pyvalue_err)?;
603            Python::attach(|py| {
604                let dict = PyDict::new(py);
605                dict.set_item("height", response.height)?;
606                dict.set_item("time", response.time)?;
607                Ok(dict.into_py_any_unwrap(py))
608            })
609        })
610    }
611
612    #[pyo3(name = "get_transfers")]
613    #[pyo3(signature = (address, subaccount_number, limit=None))]
614    fn py_get_transfers<'py>(
615        &self,
616        py: Python<'py>,
617        address: String,
618        subaccount_number: u32,
619        limit: Option<u32>,
620    ) -> PyResult<Bound<'py, PyAny>> {
621        let client = self.clone();
622        pyo3_async_runtimes::tokio::future_into_py(py, async move {
623            let response = client
624                .inner
625                .get_transfers(&address, subaccount_number, limit)
626                .await
627                .map_err(to_pyvalue_err)?;
628            serde_json::to_string(&response).map_err(to_pyvalue_err)
629        })
630    }
631
632    fn __repr__(&self) -> String {
633        format!(
634            "DydxHttpClient(base_url='{}', is_testnet={}, cached_instruments={})",
635            self.base_url(),
636            self.is_testnet(),
637            self.cached_instruments_count()
638        )
639    }
640}