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