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