Skip to main content

nautilus_deribit/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 Deribit HTTP client.
17
18use std::collections::HashSet;
19
20use jiff::Timestamp;
21use nautilus_core::{
22    UnixNanos,
23    python::{IntoPyObjectNautilusExt, to_pyruntime_err, to_pyvalue_err},
24    time::get_atomic_clock_realtime,
25};
26use nautilus_model::{
27    data::{BarType, forward::ForwardPrice},
28    identifiers::{AccountId, InstrumentId, Symbol},
29    python::instruments::{instrument_any_to_pyobject, pyobject_to_instrument_any},
30};
31use pyo3::{conversion::IntoPyObjectExt, prelude::*, types::PyList};
32
33use crate::{
34    common::{consts::DERIBIT_VENUE, enums::DeribitEnvironment},
35    data_types::DeribitBookSummary,
36    http::{
37        client::DeribitHttpClient,
38        error::DeribitHttpError,
39        models::{DeribitCurrency, DeribitProductType},
40    },
41};
42
43#[pymethods]
44#[pyo3_stub_gen::derive::gen_stub_pymethods]
45impl DeribitHttpClient {
46    /// High-level Deribit HTTP client with domain-level abstractions.
47    ///
48    /// This client wraps the raw HTTP client and provides methods that use Nautilus
49    /// domain types. It maintains an instrument cache for efficient lookups.
50    #[new]
51    #[pyo3(signature = (
52        api_key=None,
53        api_secret=None,
54        base_url=None,
55        environment=DeribitEnvironment::Mainnet,
56        timeout_secs=10,
57        max_retries=3,
58        retry_delay_ms=1000,
59        retry_delay_max_ms=10_000,
60        proxy_url=None,
61    ))]
62    #[expect(clippy::too_many_arguments)]
63    #[allow(unused_variables)]
64    fn py_new(
65        api_key: Option<String>,
66        api_secret: Option<String>,
67        base_url: Option<String>,
68        environment: DeribitEnvironment,
69        timeout_secs: u64,
70        max_retries: u32,
71        retry_delay_ms: u64,
72        retry_delay_max_ms: u64,
73        proxy_url: Option<String>,
74    ) -> PyResult<Self> {
75        Self::new_with_env(
76            api_key,
77            api_secret,
78            base_url,
79            environment,
80            timeout_secs,
81            max_retries,
82            retry_delay_ms,
83            retry_delay_max_ms,
84            proxy_url,
85        )
86        .map_err(to_pyvalue_err)
87    }
88
89    /// Returns whether this client is connected to testnet.
90    #[getter]
91    #[pyo3(name = "is_testnet")]
92    #[must_use]
93    pub fn py_is_testnet(&self) -> bool {
94        self.is_testnet()
95    }
96
97    #[pyo3(name = "is_initialized")]
98    #[must_use]
99    pub fn py_is_initialized(&self) -> bool {
100        self.is_cache_initialized()
101    }
102
103    /// Caches instruments for later retrieval.
104    #[pyo3(name = "cache_instruments")]
105    pub fn py_cache_instruments(
106        &self,
107        py: Python<'_>,
108        instruments: Vec<Py<PyAny>>,
109    ) -> PyResult<()> {
110        let instruments: Result<Vec<_>, _> = instruments
111            .into_iter()
112            .map(|inst| pyobject_to_instrument_any(py, inst))
113            .collect();
114        self.cache_instruments(&instruments?);
115        Ok(())
116    }
117
118    /// # Errors
119    ///
120    /// Returns a Python exception if adding the instrument to the cache fails.
121    #[pyo3(name = "cache_instrument")]
122    pub fn py_cache_instrument(&self, py: Python<'_>, instrument: Py<PyAny>) -> PyResult<()> {
123        let inst = pyobject_to_instrument_any(py, instrument)?;
124        self.cache_instruments(std::slice::from_ref(&inst));
125        Ok(())
126    }
127
128    /// Requests instruments for a specific currency.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the request fails or instruments cannot be parsed.
133    #[pyo3(name = "request_instruments")]
134    #[pyo3(signature = (currency, product_type=None))]
135    fn py_request_instruments<'py>(
136        &self,
137        py: Python<'py>,
138        currency: DeribitCurrency,
139        product_type: Option<DeribitProductType>,
140    ) -> PyResult<Bound<'py, PyAny>> {
141        let client = self.clone();
142
143        pyo3_async_runtimes::tokio::future_into_py(py, async move {
144            let instruments = client
145                .request_instruments(currency, product_type)
146                .await
147                .map_err(to_pyvalue_err)?;
148
149            Python::attach(|py| {
150                let py_instruments: PyResult<Vec<_>> = instruments
151                    .into_iter()
152                    .map(|inst| instrument_any_to_pyobject(py, inst))
153                    .collect();
154                let pylist = PyList::new(py, py_instruments?)?.into_any().unbind();
155                Ok(pylist)
156            })
157        })
158    }
159
160    /// Requests traded option expirations for a settlement currency.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if the request fails.
165    #[pyo3(name = "request_option_expirations")]
166    fn py_request_option_expirations<'py>(
167        &self,
168        py: Python<'py>,
169        currency: DeribitCurrency,
170    ) -> PyResult<Bound<'py, PyAny>> {
171        let client = self.clone();
172
173        pyo3_async_runtimes::tokio::future_into_py(py, async move {
174            let expirations = client
175                .request_option_expirations(currency)
176                .await
177                .map_err(to_pyvalue_err)?;
178
179            Python::attach(|py| {
180                let pylist = PyList::new(py, expirations)?.into_any().unbind();
181                Ok(pylist)
182            })
183        })
184    }
185
186    /// Requests a specific instrument by its Nautilus instrument ID.
187    ///
188    /// This is a high-level method that fetches the raw instrument data from Deribit
189    /// and converts it to a Nautilus `InstrumentAny` type.
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if:
194    /// - The instrument name format is invalid (error code `-32602`)
195    /// - The instrument doesn't exist (error code `13020`)
196    /// - Network or API errors occur
197    #[pyo3(name = "request_instrument")]
198    fn py_request_instrument<'py>(
199        &self,
200        py: Python<'py>,
201        instrument_id: InstrumentId,
202    ) -> PyResult<Bound<'py, PyAny>> {
203        let client = self.clone();
204
205        pyo3_async_runtimes::tokio::future_into_py(py, async move {
206            let instrument = client
207                .request_instrument(instrument_id)
208                .await
209                .map_err(to_pyvalue_err)?;
210
211            Python::attach(|py| instrument_any_to_pyobject(py, instrument))
212        })
213    }
214
215    /// Requests account state for all currencies.
216    ///
217    /// Fetches account balance and margin information for all currencies from Deribit
218    /// and converts it to Nautilus `AccountState` event.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if:
223    /// - The request fails
224    /// - Currency conversion fails
225    #[pyo3(name = "request_account_state")]
226    fn py_request_account_state<'py>(
227        &self,
228        py: Python<'py>,
229        account_id: AccountId,
230    ) -> PyResult<Bound<'py, PyAny>> {
231        let client = self.clone();
232
233        pyo3_async_runtimes::tokio::future_into_py(py, async move {
234            let account_state = client
235                .request_account_state(account_id)
236                .await
237                .map_err(to_pyvalue_err)?;
238
239            Python::attach(|py| account_state.into_py_any(py))
240        })
241    }
242
243    /// Requests historical trades for an instrument within a time range.
244    ///
245    /// Fetches trade ticks from Deribit and converts them to Nautilus `TradeTick` objects.
246    ///
247    /// # Arguments
248    ///
249    /// * `instrument_id` - The instrument to fetch trades for
250    /// * `start` - Optional start time filter
251    /// * `end` - Optional end time filter
252    /// * `limit` - Optional limit on number of trades (max 1000)
253    ///
254    /// # Errors
255    ///
256    /// Returns an error if:
257    /// - The instrument is not found in cache
258    /// - The request fails
259    /// - Trade parsing fails
260    ///
261    /// # Pagination
262    ///
263    /// When `limit` is `None`, this function automatically paginates through all available
264    /// trades in the time range using the `has_more` field from the API response.
265    /// When `limit` is specified, pagination stops once that many trades are collected.
266    #[pyo3(name = "request_trades")]
267    #[pyo3(signature = (instrument_id, start=None, end=None, limit=None))]
268    fn py_request_trades<'py>(
269        &self,
270        py: Python<'py>,
271        instrument_id: InstrumentId,
272        start: Option<Timestamp>,
273        end: Option<Timestamp>,
274        limit: Option<u32>,
275    ) -> PyResult<Bound<'py, PyAny>> {
276        let client = self.clone();
277
278        pyo3_async_runtimes::tokio::future_into_py(py, async move {
279            let trades = client
280                .request_trades(instrument_id, start, end, limit)
281                .await
282                .map_err(to_pyvalue_err)?;
283
284            Python::attach(|py| {
285                let py_trades = trades
286                    .into_iter()
287                    .map(|trade| trade.into_py_any(py))
288                    .collect::<PyResult<Vec<_>>>()?;
289                let pylist = PyList::new(py, py_trades)?;
290                Ok(pylist.into_py_any_unwrap(py))
291            })
292        })
293    }
294
295    /// Requests historical bars (OHLCV) for an instrument.
296    ///
297    /// Uses the `public/get_tradingview_chart_data` endpoint to fetch candlestick data.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error if:
302    /// - Aggregation source is not EXTERNAL
303    /// - Bar aggregation type is not supported by Deribit
304    /// - The instrument is not found in cache
305    /// - The request fails or response cannot be parsed
306    ///
307    /// # Supported Resolutions
308    ///
309    /// Deribit supports: 1, 3, 5, 10, 15, 30, 60, 120, 180, 360, 720 minutes, and 1D (daily)
310    #[pyo3(name = "request_bars")]
311    #[pyo3(signature = (bar_type, start=None, end=None, limit=None))]
312    fn py_request_bars<'py>(
313        &self,
314        py: Python<'py>,
315        bar_type: BarType,
316        start: Option<Timestamp>,
317        end: Option<Timestamp>,
318        limit: Option<u32>,
319    ) -> PyResult<Bound<'py, PyAny>> {
320        let client = self.clone();
321
322        pyo3_async_runtimes::tokio::future_into_py(py, async move {
323            let bars = client
324                .request_bars(bar_type, start, end, limit)
325                .await
326                .map_err(to_pyvalue_err)?;
327
328            Python::attach(|py| {
329                let py_bars = bars
330                    .into_iter()
331                    .map(|bar| bar.into_py_any(py))
332                    .collect::<PyResult<Vec<_>>>()?;
333                let pylist = PyList::new(py, py_bars)?;
334                Ok(pylist.into_py_any_unwrap(py))
335            })
336        })
337    }
338
339    /// Requests a snapshot of the order book for an instrument.
340    ///
341    /// Fetches the order book from Deribit and converts it to a Nautilus `OrderBook`.
342    ///
343    /// # Arguments
344    ///
345    /// * `instrument_id` - The instrument to fetch the order book for
346    /// * `depth` - Optional depth limit (valid values: 1, 5, 10, 20, 50, 100, 1000, 10000)
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if:
351    /// - The instrument is not found in cache
352    /// - The request fails
353    /// - Order book parsing fails
354    #[pyo3(name = "request_book_snapshot")]
355    #[pyo3(signature = (instrument_id, depth=None))]
356    fn py_request_book_snapshot<'py>(
357        &self,
358        py: Python<'py>,
359        instrument_id: InstrumentId,
360        depth: Option<u32>,
361    ) -> PyResult<Bound<'py, PyAny>> {
362        let client = self.clone();
363
364        pyo3_async_runtimes::tokio::future_into_py(py, async move {
365            let book = client
366                .request_book_snapshot(instrument_id, depth)
367                .await
368                .map_err(to_pyvalue_err)?;
369
370            Python::attach(|py| book.into_py_any(py))
371        })
372    }
373
374    /// Requests order status reports for reconciliation.
375    ///
376    /// Fetches order statuses from Deribit and converts them to Nautilus `OrderStatusReport`.
377    ///
378    /// # Strategy
379    /// - Uses `/private/get_open_orders` for all open orders (single efficient API call)
380    /// - Uses `/private/get_open_orders_by_instrument` when specific instrument is provided
381    /// - For historical orders (when `open_only=false`), iterates over currencies
382    ///
383    /// # Errors
384    ///
385    /// Returns an error if the request fails or parsing fails.
386    #[pyo3(name = "request_order_status_reports")]
387    #[pyo3(signature = (account_id, instrument_id=None, start=None, end=None, open_only=true))]
388    fn py_request_order_status_reports<'py>(
389        &self,
390        py: Python<'py>,
391        account_id: AccountId,
392        instrument_id: Option<InstrumentId>,
393        start: Option<u64>,
394        end: Option<u64>,
395        open_only: bool,
396    ) -> PyResult<Bound<'py, PyAny>> {
397        let client = self.clone();
398
399        pyo3_async_runtimes::tokio::future_into_py(py, async move {
400            let reports = client
401                .request_order_status_reports(
402                    account_id,
403                    instrument_id,
404                    start.map(nautilus_core::UnixNanos::from),
405                    end.map(nautilus_core::UnixNanos::from),
406                    open_only,
407                )
408                .await
409                .map_err(to_pyvalue_err)?;
410
411            Python::attach(|py| {
412                let py_reports: PyResult<Vec<_>> = reports
413                    .into_iter()
414                    .map(|report| report.into_py_any(py))
415                    .collect();
416                let pylist = PyList::new(py, py_reports?)?.into_any().unbind();
417                Ok(pylist)
418            })
419        })
420    }
421
422    /// Requests fill reports for reconciliation.
423    ///
424    /// Fetches user trades from Deribit and converts them to Nautilus `FillReport`.
425    /// Automatically paginates through all results using time-cursor advancement.
426    ///
427    /// # Strategy
428    /// - Uses `/private/get_user_trades_by_instrument_and_time` when instrument is provided
429    /// - Otherwise iterates over currencies using `/private/get_user_trades_by_currency_and_time`
430    ///
431    /// # Errors
432    ///
433    /// Returns an error if the request fails or parsing fails.
434    #[pyo3(name = "request_fill_reports")]
435    #[pyo3(signature = (account_id, instrument_id=None, start=None, end=None))]
436    fn py_request_fill_reports<'py>(
437        &self,
438        py: Python<'py>,
439        account_id: AccountId,
440        instrument_id: Option<InstrumentId>,
441        start: Option<u64>,
442        end: Option<u64>,
443    ) -> PyResult<Bound<'py, PyAny>> {
444        let client = self.clone();
445
446        pyo3_async_runtimes::tokio::future_into_py(py, async move {
447            let reports = client
448                .request_fill_reports(
449                    account_id,
450                    instrument_id,
451                    start.map(nautilus_core::UnixNanos::from),
452                    end.map(nautilus_core::UnixNanos::from),
453                )
454                .await
455                .map_err(to_pyvalue_err)?;
456
457            Python::attach(|py| {
458                let py_reports: PyResult<Vec<_>> = reports
459                    .into_iter()
460                    .map(|report| report.into_py_any(py))
461                    .collect();
462                let pylist = PyList::new(py, py_reports?)?.into_any().unbind();
463                Ok(pylist)
464            })
465        })
466    }
467
468    /// Requests position status reports for reconciliation.
469    ///
470    /// Fetches positions from Deribit and converts them to Nautilus `PositionStatusReport`.
471    ///
472    /// # Strategy
473    /// - Uses `currency=any` to fetch all positions in one call
474    /// - Filters by instrument_id if provided
475    ///
476    /// # Errors
477    ///
478    /// Returns an error if the request fails or parsing fails.
479    #[pyo3(name = "request_position_status_reports")]
480    #[pyo3(signature = (account_id, instrument_id=None))]
481    fn py_request_position_status_reports<'py>(
482        &self,
483        py: Python<'py>,
484        account_id: AccountId,
485        instrument_id: Option<InstrumentId>,
486    ) -> PyResult<Bound<'py, PyAny>> {
487        let client = self.clone();
488
489        pyo3_async_runtimes::tokio::future_into_py(py, async move {
490            let reports = client
491                .request_position_status_reports(account_id, instrument_id)
492                .await
493                .map_err(to_pyvalue_err)?;
494
495            Python::attach(|py| {
496                let py_reports: PyResult<Vec<_>> = reports
497                    .into_iter()
498                    .map(|report| report.into_py_any(py))
499                    .collect();
500                let pylist = PyList::new(py, py_reports?)?.into_any().unbind();
501                Ok(pylist)
502            })
503        })
504    }
505
506    /// Requests book summaries for a currency via `public/get_book_summary_by_currency`.
507    ///
508    /// Defaults to product kind `option`.
509    /// Entries include mark/IV, bid-ask, volumes, and `underlying_price` (forward) when present.
510    ///
511    /// # Errors
512    ///
513    /// Returns an error if the request fails.
514    #[pyo3(name = "request_book_summaries")]
515    #[pyo3(signature = (currency, kind=None))]
516    fn py_request_book_summaries<'py>(
517        &self,
518        py: Python<'py>,
519        currency: String,
520        kind: Option<String>,
521    ) -> PyResult<Bound<'py, PyAny>> {
522        let client = self.clone();
523
524        pyo3_async_runtimes::tokio::future_into_py(py, async move {
525            let currency = currency.trim().to_ascii_uppercase();
526            if currency.is_empty() {
527                return Err(to_pyvalue_err(
528                    "request_book_summaries requires a non-empty currency",
529                ));
530            }
531            let kind = kind
532                .as_deref()
533                .map(str::trim)
534                .filter(|value| !value.is_empty())
535                .map_or_else(|| "option".to_string(), str::to_ascii_lowercase);
536            let summaries = client
537                .request_book_summaries_kind(&currency, Some(kind.as_str()))
538                .await
539                .map_err(to_pyvalue_err)?;
540            // Stamp observed time after the HTTP round-trip completes.
541            let ts = get_atomic_clock_realtime().get_time_ns();
542
543            Python::attach(|py| {
544                let py_items: PyResult<Vec<_>> = summaries
545                    .into_iter()
546                    .map(|raw| Py::new(py, DeribitBookSummary::from_raw(raw, ts)))
547                    .collect();
548                let pylist = PyList::new(py, py_items?)?.into_any().unbind();
549                Ok(pylist)
550            })
551        })
552    }
553
554    /// Request forward prices for option chain ATM determination.
555    ///
556    /// Single-instrument path (1 HTTP call) if `instrument_id` is provided,
557    /// otherwise bulk path via book summaries.
558    #[pyo3(name = "request_forward_prices")]
559    #[pyo3(signature = (currency, instrument_id=None))]
560    fn py_request_forward_prices<'py>(
561        &self,
562        py: Python<'py>,
563        currency: String,
564        instrument_id: Option<InstrumentId>,
565    ) -> PyResult<Bound<'py, PyAny>> {
566        let client = self.clone();
567
568        pyo3_async_runtimes::tokio::future_into_py(py, async move {
569            let forward_prices = if let Some(inst_id) = instrument_id {
570                // Single-instrument path: 1 HTTP call to public/ticker
571                let instrument_name = inst_id.symbol.to_string();
572                let ticker = client
573                    .request_ticker(&instrument_name)
574                    .await
575                    .map_err(to_pyvalue_err)?;
576
577                let ts = UnixNanos::default();
578                ticker
579                    .underlying_price
580                    .map(|up| {
581                        vec![ForwardPrice::new(
582                            inst_id,
583                            up,
584                            ticker.underlying_index.filter(|s| !s.is_empty()),
585                            ts,
586                            ts,
587                        )]
588                    })
589                    .unwrap_or_default()
590            } else {
591                // Bulk path: fetch all book summaries
592                let summaries = client
593                    .request_book_summaries(&currency)
594                    .await
595                    .map_err(to_pyvalue_err)?;
596
597                let ts = nautilus_core::UnixNanos::default();
598                let mut seen_indices = HashSet::new();
599                summaries
600                    .into_iter()
601                    .filter_map(|s| {
602                        let up = s.underlying_price?;
603                        let idx = s.underlying_index.clone().unwrap_or_default();
604                        if !seen_indices.insert(idx.clone()) {
605                            return None;
606                        }
607                        Some(ForwardPrice::new(
608                            InstrumentId::new(Symbol::new(&s.instrument_name), *DERIBIT_VENUE),
609                            up,
610                            Some(idx).filter(|s| !s.is_empty()),
611                            ts,
612                            ts,
613                        ))
614                    })
615                    .collect()
616            };
617
618            Python::attach(|py| {
619                let py_prices: PyResult<Vec<_>> = forward_prices
620                    .into_iter()
621                    .map(|fp| Py::new(py, fp))
622                    .collect();
623                let pylist = PyList::new(py, py_prices?)?.into_any().unbind();
624                Ok(pylist)
625            })
626        })
627    }
628}
629
630impl From<DeribitHttpError> for PyErr {
631    fn from(error: DeribitHttpError) -> Self {
632        match error {
633            // Runtime/operational errors
634            DeribitHttpError::Canceled(msg) => to_pyruntime_err(format!("Request canceled: {msg}")),
635            DeribitHttpError::NetworkError(msg) => {
636                to_pyruntime_err(format!("Network error: {msg}"))
637            }
638            DeribitHttpError::UnexpectedStatus { status, body } => {
639                to_pyruntime_err(format!("Unexpected HTTP status code {status}: {body}"))
640            }
641            DeribitHttpError::Timeout(msg) => to_pyruntime_err(format!("Request timeout: {msg}")),
642            // Validation/configuration errors
643            DeribitHttpError::MissingCredentials => {
644                to_pyvalue_err("Missing credentials for authenticated request")
645            }
646            DeribitHttpError::ValidationError(msg) => {
647                to_pyvalue_err(format!("Parameter validation error: {msg}"))
648            }
649            DeribitHttpError::JsonError(msg) => to_pyvalue_err(format!("JSON error: {msg}")),
650            DeribitHttpError::DeribitError {
651                error_code,
652                message,
653            } => to_pyvalue_err(format!("Deribit error {error_code}: {message}")),
654        }
655    }
656}