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 chrono::{DateTime, Utc};
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    prelude::*,
30    types::{PyDict, PyList},
31};
32use rust_decimal::Decimal;
33
34use crate::{common::enums::DydxNetwork, http::client::DydxHttpClient};
35
36#[pymethods]
37#[pyo3_stub_gen::derive::gen_stub_pymethods]
38impl DydxHttpClient {
39    /// Provides a higher-level HTTP client for the [dYdX v4](https://dydx.exchange) Indexer REST API.
40    ///
41    /// This client wraps the underlying `DydxRawHttpClient` to handle conversions
42    /// into the Nautilus domain model, following the two-layer pattern established
43    /// in OKX, Bybit, and BitMEX adapters.
44    ///
45    /// **Architecture:**
46    /// - **Raw client** (`DydxRawHttpClient`): Low-level HTTP methods matching dYdX Indexer API endpoints.
47    /// - **Domain client** (`DydxHttpClient`): High-level methods using Nautilus domain types.
48    ///
49    /// The domain client:
50    /// - Wraps the raw client in an `Arc` for efficient cloning (required for Python bindings).
51    /// - Maintains an instrument cache using `DashMap` for thread-safe concurrent access.
52    /// - Provides standard cache methods: `cache_instruments()`, `cache_instrument()`, `get_instrument()`.
53    /// - Tracks cache initialization state for optimizations.
54    #[new]
55    #[pyo3(signature = (base_url=None, network=DydxNetwork::Mainnet, proxy_url=None))]
56    fn py_new(
57        base_url: Option<String>,
58        network: DydxNetwork,
59        proxy_url: Option<String>,
60    ) -> PyResult<Self> {
61        Self::new(
62            base_url, 60, // timeout_secs
63            proxy_url, network, None, // retry_config
64        )
65        .map_err(to_pyvalue_err)
66    }
67
68    /// Returns `true` if this client is configured for testnet.
69    #[pyo3(name = "is_testnet")]
70    fn py_is_testnet(&self) -> bool {
71        self.is_testnet()
72    }
73
74    /// Returns the base URL used by this client.
75    #[pyo3(name = "base_url")]
76    fn py_base_url(&self) -> String {
77        self.base_url().to_string()
78    }
79
80    /// Requests instruments from the dYdX Indexer API and returns Nautilus domain types.
81    ///
82    /// This method does NOT automatically cache results. Use `fetch_and_cache_instruments()`
83    /// for automatic caching, or call `cache_instruments()` manually with the results.
84    #[pyo3(name = "request_instruments")]
85    fn py_request_instruments<'py>(
86        &self,
87        py: Python<'py>,
88        maker_fee: Option<&str>,
89        taker_fee: Option<&str>,
90    ) -> PyResult<Bound<'py, PyAny>> {
91        let maker = maker_fee
92            .map(Decimal::from_str)
93            .transpose()
94            .map_err(to_pyvalue_err)?;
95
96        let taker = taker_fee
97            .map(Decimal::from_str)
98            .transpose()
99            .map_err(to_pyvalue_err)?;
100
101        let client = self.clone();
102
103        pyo3_async_runtimes::tokio::future_into_py(py, async move {
104            let instruments = client
105                .request_instruments(None, maker, taker)
106                .await
107                .map_err(to_pyvalue_err)?;
108
109            Python::attach(|py| {
110                let py_instruments: PyResult<Vec<Py<PyAny>>> = instruments
111                    .into_iter()
112                    .map(|inst| instrument_any_to_pyobject(py, inst))
113                    .collect();
114                py_instruments
115            })
116        })
117    }
118
119    /// Fetches instruments from the API and caches them.
120    ///
121    /// This is a convenience method that fetches instruments and populates both
122    /// the symbol-based and CLOB pair ID-based caches.
123    ///
124    /// On success, existing caches are cleared and repopulated atomically.
125    /// On failure, existing caches are preserved (no partial updates).
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the HTTP request fails.
130    #[pyo3(name = "fetch_and_cache_instruments")]
131    fn py_fetch_and_cache_instruments<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
132        let client = self.clone();
133        pyo3_async_runtimes::tokio::future_into_py(py, async move {
134            client
135                .fetch_and_cache_instruments()
136                .await
137                .map_err(to_pyvalue_err)?;
138            Ok(())
139        })
140    }
141
142    /// Fetches a single instrument by ticker and caches it.
143    ///
144    /// This is used for on-demand fetching of newly discovered instruments
145    /// via WebSocket.
146    ///
147    /// Returns `None` if the market is not found or inactive.
148    #[pyo3(name = "fetch_instrument")]
149    fn py_fetch_instrument<'py>(
150        &self,
151        py: Python<'py>,
152        ticker: String,
153    ) -> PyResult<Bound<'py, PyAny>> {
154        let client = self.clone();
155        pyo3_async_runtimes::tokio::future_into_py(py, async move {
156            match client.fetch_and_cache_single_instrument(&ticker).await {
157                Ok(Some(instrument)) => {
158                    Python::attach(|py| instrument_any_to_pyobject(py, instrument))
159                }
160                Ok(None) => Ok(Python::attach(|py| py.None())),
161                Err(e) => Err(to_pyvalue_err(e)),
162            }
163        })
164    }
165
166    /// Gets an instrument from the cache by InstrumentId.
167    #[pyo3(name = "get_instrument")]
168    fn py_get_instrument(&self, py: Python<'_>, symbol: &str) -> PyResult<Option<Py<PyAny>>> {
169        use nautilus_model::identifiers::{Symbol, Venue};
170        let instrument_id = InstrumentId::new(Symbol::new(symbol), Venue::new("DYDX"));
171        let instrument = self.get_instrument(&instrument_id);
172        match instrument {
173            Some(inst) => Ok(Some(instrument_any_to_pyobject(py, inst)?)),
174            None => Ok(None),
175        }
176    }
177
178    #[pyo3(name = "instrument_count")]
179    fn py_instrument_count(&self) -> usize {
180        self.cached_instruments_count()
181    }
182
183    #[pyo3(name = "instrument_symbols")]
184    fn py_instrument_symbols(&self) -> Vec<String> {
185        self.all_instrument_ids()
186            .into_iter()
187            .map(|id| id.symbol.to_string())
188            .collect()
189    }
190
191    /// Caches multiple instruments (symbol lookup only).
192    ///
193    /// Use `fetch_and_cache_instruments()` for full caching with market params.
194    /// Any existing instruments with the same symbols will be replaced.
195    #[pyo3(name = "cache_instruments")]
196    fn py_cache_instruments(
197        &self,
198        py: Python<'_>,
199        py_instruments: Vec<Bound<'_, PyAny>>,
200    ) -> PyResult<()> {
201        let instruments: Vec<InstrumentAny> = py_instruments
202            .into_iter()
203            .map(|py_inst| {
204                // Convert Bound<PyAny> to Py<PyAny> using unbind()
205                pyobject_to_instrument_any(py, py_inst.unbind())
206            })
207            .collect::<Result<Vec<_>, _>>()
208            .map_err(to_pyvalue_err)?;
209
210        self.cache_instruments(instruments);
211        Ok(())
212    }
213
214    #[pyo3(name = "get_orders")]
215    #[pyo3(signature = (address, subaccount_number, market=None, limit=None))]
216    fn py_get_orders<'py>(
217        &self,
218        py: Python<'py>,
219        address: String,
220        subaccount_number: u32,
221        market: Option<String>,
222        limit: Option<u32>,
223    ) -> PyResult<Bound<'py, PyAny>> {
224        let client = self.clone();
225        pyo3_async_runtimes::tokio::future_into_py(py, async move {
226            let response = client
227                .inner
228                .get_orders(&address, subaccount_number, market.as_deref(), limit)
229                .await
230                .map_err(to_pyvalue_err)?;
231            serde_json::to_string(&response).map_err(to_pyvalue_err)
232        })
233    }
234
235    #[pyo3(name = "get_fills")]
236    #[pyo3(signature = (address, subaccount_number, market=None, limit=None))]
237    fn py_get_fills<'py>(
238        &self,
239        py: Python<'py>,
240        address: String,
241        subaccount_number: u32,
242        market: Option<String>,
243        limit: Option<u32>,
244    ) -> PyResult<Bound<'py, PyAny>> {
245        let client = self.clone();
246        pyo3_async_runtimes::tokio::future_into_py(py, async move {
247            let response = client
248                .inner
249                .get_fills(&address, subaccount_number, market.as_deref(), limit)
250                .await
251                .map_err(to_pyvalue_err)?;
252            serde_json::to_string(&response).map_err(to_pyvalue_err)
253        })
254    }
255
256    #[pyo3(name = "get_subaccount")]
257    fn py_get_subaccount<'py>(
258        &self,
259        py: Python<'py>,
260        address: String,
261        subaccount_number: u32,
262    ) -> PyResult<Bound<'py, PyAny>> {
263        let client = self.clone();
264        pyo3_async_runtimes::tokio::future_into_py(py, async move {
265            let response = client
266                .inner
267                .get_subaccount(&address, subaccount_number)
268                .await
269                .map_err(to_pyvalue_err)?;
270            serde_json::to_string(&response).map_err(to_pyvalue_err)
271        })
272    }
273
274    /// Requests order status reports for a subaccount.
275    ///
276    /// Fetches orders from the dYdX Indexer API and converts them to Nautilus
277    /// `OrderStatusReport` objects.
278    #[pyo3(name = "request_order_status_reports")]
279    #[pyo3(signature = (address, subaccount_number, account_id, instrument_id=None))]
280    fn py_request_order_status_reports<'py>(
281        &self,
282        py: Python<'py>,
283        address: String,
284        subaccount_number: u32,
285        account_id: AccountId,
286        instrument_id: Option<InstrumentId>,
287    ) -> PyResult<Bound<'py, PyAny>> {
288        let client = self.clone();
289        pyo3_async_runtimes::tokio::future_into_py(py, async move {
290            let reports = client
291                .request_order_status_reports(
292                    &address,
293                    subaccount_number,
294                    account_id,
295                    instrument_id,
296                )
297                .await
298                .map_err(to_pyvalue_err)?;
299
300            Python::attach(|py| {
301                let pylist =
302                    PyList::new(py, reports.into_iter().map(|r| r.into_py_any_unwrap(py)))?;
303                Ok(pylist.into_py_any_unwrap(py))
304            })
305        })
306    }
307
308    /// Requests fill reports for a subaccount.
309    ///
310    /// Fetches fills from the dYdX Indexer API and converts them to Nautilus
311    /// `FillReport` objects.
312    #[pyo3(name = "request_fill_reports")]
313    #[pyo3(signature = (address, subaccount_number, account_id, instrument_id=None))]
314    fn py_request_fill_reports<'py>(
315        &self,
316        py: Python<'py>,
317        address: String,
318        subaccount_number: u32,
319        account_id: AccountId,
320        instrument_id: Option<InstrumentId>,
321    ) -> PyResult<Bound<'py, PyAny>> {
322        let client = self.clone();
323        pyo3_async_runtimes::tokio::future_into_py(py, async move {
324            let reports = client
325                .request_fill_reports(&address, subaccount_number, account_id, instrument_id)
326                .await
327                .map_err(to_pyvalue_err)?;
328
329            Python::attach(|py| {
330                let pylist =
331                    PyList::new(py, reports.into_iter().map(|r| r.into_py_any_unwrap(py)))?;
332                Ok(pylist.into_py_any_unwrap(py))
333            })
334        })
335    }
336
337    /// Requests position status reports for a subaccount.
338    ///
339    /// Fetches positions from the dYdX Indexer API and converts them to Nautilus
340    /// `PositionStatusReport` objects.
341    #[pyo3(name = "request_position_status_reports")]
342    #[pyo3(signature = (address, subaccount_number, account_id, instrument_id=None))]
343    fn py_request_position_status_reports<'py>(
344        &self,
345        py: Python<'py>,
346        address: String,
347        subaccount_number: u32,
348        account_id: AccountId,
349        instrument_id: Option<InstrumentId>,
350    ) -> PyResult<Bound<'py, PyAny>> {
351        let client = self.clone();
352        pyo3_async_runtimes::tokio::future_into_py(py, async move {
353            let reports = client
354                .request_position_status_reports(
355                    &address,
356                    subaccount_number,
357                    account_id,
358                    instrument_id,
359                )
360                .await
361                .map_err(to_pyvalue_err)?;
362
363            Python::attach(|py| {
364                let pylist =
365                    PyList::new(py, reports.into_iter().map(|r| r.into_py_any_unwrap(py)))?;
366                Ok(pylist.into_py_any_unwrap(py))
367            })
368        })
369    }
370
371    /// Requests account state for a subaccount.
372    ///
373    /// Fetches the subaccount from the dYdX Indexer API and converts it to a Nautilus
374    /// `AccountState` with balances and margin calculations.
375    #[pyo3(name = "request_account_state")]
376    fn py_request_account_state<'py>(
377        &self,
378        py: Python<'py>,
379        address: String,
380        subaccount_number: u32,
381        account_id: AccountId,
382    ) -> PyResult<Bound<'py, PyAny>> {
383        let client = self.clone();
384        pyo3_async_runtimes::tokio::future_into_py(py, async move {
385            let account_state = client
386                .request_account_state(&address, subaccount_number, account_id)
387                .await
388                .map_err(to_pyvalue_err)?;
389
390            Python::attach(|py| Ok(account_state.into_py_any_unwrap(py)))
391        })
392    }
393
394    /// Requests historical bars for an instrument with optional pagination.
395    ///
396    /// Fetches candle data from the dYdX Indexer API and converts to Nautilus
397    /// `Bar` objects. Supports time-chunked pagination for large date ranges.
398    ///
399    /// The resolution is derived internally from `bar_type` (no need to pass
400    /// `DydxCandleResolution`). Incomplete bars (where `ts_event >= now`) are
401    /// filtered out.
402    ///
403    /// Results are returned in chronological order (oldest first).
404    #[pyo3(name = "request_bars")]
405    #[pyo3(signature = (bar_type, start=None, end=None, limit=None, timestamp_on_close=true))]
406    fn py_request_bars<'py>(
407        &self,
408        py: Python<'py>,
409        bar_type: BarType,
410        start: Option<DateTime<Utc>>,
411        end: Option<DateTime<Utc>>,
412        limit: Option<u32>,
413        timestamp_on_close: bool,
414    ) -> PyResult<Bound<'py, PyAny>> {
415        let client = self.clone();
416
417        pyo3_async_runtimes::tokio::future_into_py(py, async move {
418            let bars = client
419                .request_bars(bar_type, start, end, limit, timestamp_on_close)
420                .await
421                .map_err(to_pyvalue_err)?;
422
423            Python::attach(|py| {
424                let pylist = PyList::new(py, bars.into_iter().map(|b| b.into_py_any_unwrap(py)))?;
425                Ok(pylist.into_py_any_unwrap(py))
426            })
427        })
428    }
429
430    /// Requests historical trade ticks for an instrument with optional pagination.
431    ///
432    /// Fetches trade data from the dYdX Indexer API and converts them to Nautilus
433    /// `TradeTick` objects. Supports cursor-based pagination using block height
434    /// and client-side time filtering (the dYdX API has no timestamp filter).
435    ///
436    /// Results are returned in chronological order (oldest first).
437    #[pyo3(name = "request_trade_ticks")]
438    #[pyo3(signature = (instrument_id, start=None, end=None, limit=None))]
439    fn py_request_trade_ticks<'py>(
440        &self,
441        py: Python<'py>,
442        instrument_id: InstrumentId,
443        start: Option<DateTime<Utc>>,
444        end: Option<DateTime<Utc>>,
445        limit: Option<u32>,
446    ) -> PyResult<Bound<'py, PyAny>> {
447        let client = self.clone();
448
449        pyo3_async_runtimes::tokio::future_into_py(py, async move {
450            let trades = client
451                .request_trade_ticks(instrument_id, start, end, limit)
452                .await
453                .map_err(to_pyvalue_err)?;
454
455            Python::attach(|py| {
456                let pylist = PyList::new(py, trades.into_iter().map(|t| t.into_py_any_unwrap(py)))?;
457                Ok(pylist.into_py_any_unwrap(py))
458            })
459        })
460    }
461
462    /// Requests an order book snapshot for a symbol.
463    ///
464    /// Fetches order book data from the dYdX Indexer API and converts it to Nautilus
465    /// `OrderBookDeltas`. The snapshot is represented as a sequence of deltas starting
466    /// with a CLEAR action followed by ADD actions for each level.
467    #[pyo3(name = "request_orderbook_snapshot")]
468    fn py_request_orderbook_snapshot<'py>(
469        &self,
470        py: Python<'py>,
471        instrument_id: InstrumentId,
472    ) -> PyResult<Bound<'py, PyAny>> {
473        let client = self.clone();
474
475        pyo3_async_runtimes::tokio::future_into_py(py, async move {
476            let deltas = client
477                .request_orderbook_snapshot(instrument_id)
478                .await
479                .map_err(to_pyvalue_err)?;
480
481            Python::attach(|py| Ok(deltas.into_py_any_unwrap(py)))
482        })
483    }
484
485    #[pyo3(name = "get_time")]
486    fn py_get_time<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
487        let client = self.clone();
488        pyo3_async_runtimes::tokio::future_into_py(py, async move {
489            let response = client.inner.get_time().await.map_err(to_pyvalue_err)?;
490            Python::attach(|py| {
491                let dict = PyDict::new(py);
492                dict.set_item("iso", response.iso.to_string())?;
493                dict.set_item("epoch", response.epoch_ms)?;
494                Ok(dict.into_py_any_unwrap(py))
495            })
496        })
497    }
498
499    #[pyo3(name = "get_height")]
500    fn py_get_height<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
501        let client = self.clone();
502        pyo3_async_runtimes::tokio::future_into_py(py, async move {
503            let response = client.inner.get_height().await.map_err(to_pyvalue_err)?;
504            Python::attach(|py| {
505                let dict = PyDict::new(py);
506                dict.set_item("height", response.height)?;
507                dict.set_item("time", response.time)?;
508                Ok(dict.into_py_any_unwrap(py))
509            })
510        })
511    }
512
513    #[pyo3(name = "get_transfers")]
514    #[pyo3(signature = (address, subaccount_number, limit=None))]
515    fn py_get_transfers<'py>(
516        &self,
517        py: Python<'py>,
518        address: String,
519        subaccount_number: u32,
520        limit: Option<u32>,
521    ) -> PyResult<Bound<'py, PyAny>> {
522        let client = self.clone();
523        pyo3_async_runtimes::tokio::future_into_py(py, async move {
524            let response = client
525                .inner
526                .get_transfers(&address, subaccount_number, limit)
527                .await
528                .map_err(to_pyvalue_err)?;
529            serde_json::to_string(&response).map_err(to_pyvalue_err)
530        })
531    }
532
533    fn __repr__(&self) -> String {
534        format!(
535            "DydxHttpClient(base_url='{}', is_testnet={}, cached_instruments={})",
536            self.base_url(),
537            self.is_testnet(),
538            self.cached_instruments_count()
539        )
540    }
541}