Skip to main content

nautilus_bybit/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 the Bybit HTTP client.
17
18use std::collections::HashSet;
19
20use chrono::{DateTime, Utc};
21use nautilus_core::{
22    UnixNanos,
23    python::{to_pyruntime_err, to_pyvalue_err},
24};
25use nautilus_model::{
26    data::{BarType, forward::ForwardPrice},
27    enums::{OrderSide, OrderType, TimeInForce},
28    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
29    python::instruments::{instrument_any_to_pyobject, pyobject_to_instrument_any},
30    types::{Price, Quantity},
31};
32use pyo3::{
33    conversion::IntoPyObjectExt,
34    prelude::*,
35    types::{PyDict, PyList},
36};
37use ustr::Ustr;
38
39use crate::{
40    common::{
41        enums::{
42            BybitMarginMode, BybitOpenOnly, BybitOrderFilter, BybitPositionIdx, BybitPositionMode,
43            BybitProductType,
44        },
45        parse::{extract_raw_symbol, parse_bbo_level, parse_bbo_side_type},
46    },
47    http::{
48        client::{BybitHttpClient, BybitRawHttpClient},
49        error::BybitHttpError,
50        models::BybitOrderCursorList,
51        query::BybitNativeTpSlParams as RustNativeTpSlParams,
52    },
53    python::params::BybitNativeTpSlParams,
54};
55
56#[pymethods]
57#[pyo3_stub_gen::derive::gen_stub_pymethods]
58impl BybitRawHttpClient {
59    /// Raw HTTP client for low-level Bybit API operations.
60    ///
61    /// This client handles request/response operations with the Bybit API,
62    /// returning venue-specific response types. It does not parse to Nautilus domain types.
63    #[new]
64    #[pyo3(signature = (api_key=None, api_secret=None, base_url=None, demo=false, testnet=false, timeout_secs=60, max_retries=3, retry_delay_ms=1000, retry_delay_max_ms=10_000, recv_window_ms=5_000, proxy_url=None))]
65    #[expect(clippy::too_many_arguments)]
66    fn py_new(
67        api_key: Option<String>,
68        api_secret: Option<String>,
69        base_url: Option<String>,
70        demo: bool,
71        testnet: bool,
72        timeout_secs: u64,
73        max_retries: u32,
74        retry_delay_ms: u64,
75        retry_delay_max_ms: u64,
76        recv_window_ms: u64,
77        proxy_url: Option<String>,
78    ) -> PyResult<Self> {
79        Self::new_with_env(
80            api_key,
81            api_secret,
82            base_url,
83            demo,
84            testnet,
85            timeout_secs,
86            max_retries,
87            retry_delay_ms,
88            retry_delay_max_ms,
89            recv_window_ms,
90            proxy_url,
91        )
92        .map_err(to_pyvalue_err)
93    }
94
95    /// Returns the base URL used for requests.
96    #[getter]
97    #[pyo3(name = "base_url")]
98    #[must_use]
99    pub fn py_base_url(&self) -> &str {
100        self.base_url()
101    }
102
103    #[getter]
104    #[pyo3(name = "api_key")]
105    #[must_use]
106    pub fn py_api_key(&self) -> Option<String> {
107        self.credential().map(|c| c.api_key().to_string())
108    }
109
110    /// Returns the configured receive window in milliseconds.
111    #[getter]
112    #[pyo3(name = "recv_window_ms")]
113    #[must_use]
114    pub fn py_recv_window_ms(&self) -> u64 {
115        self.recv_window_ms()
116    }
117
118    /// Cancels all pending HTTP requests.
119    #[pyo3(name = "cancel_all_requests")]
120    fn py_cancel_all_requests(&self) {
121        self.cancel_all_requests();
122    }
123
124    /// Fetches the current server time from Bybit.
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if the request fails or the response cannot be parsed.
129    ///
130    /// # References
131    ///
132    /// - <https://bybit-exchange.github.io/docs/v5/market/time>
133    #[pyo3(name = "get_server_time")]
134    fn py_get_server_time<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
135        let client = self.clone();
136
137        pyo3_async_runtimes::tokio::future_into_py(py, async move {
138            let response = client.get_server_time().await.map_err(to_pyvalue_err)?;
139
140            Python::attach(|py| {
141                let server_time = Py::new(py, response.result)?;
142                Ok(server_time.into_any())
143            })
144        })
145    }
146
147    /// Fetches open orders (requires authentication).
148    ///
149    /// # References
150    ///
151    /// - <https://bybit-exchange.github.io/docs/v5/order/open-order>
152    #[pyo3(name = "get_open_orders")]
153    #[pyo3(signature = (category, symbol=None, base_coin=None, settle_coin=None, order_id=None, order_link_id=None, open_only=None, order_filter=None, limit=None, cursor=None))]
154    #[expect(clippy::too_many_arguments)]
155    fn py_get_open_orders<'py>(
156        &self,
157        py: Python<'py>,
158        category: BybitProductType,
159        symbol: Option<String>,
160        base_coin: Option<String>,
161        settle_coin: Option<String>,
162        order_id: Option<String>,
163        order_link_id: Option<String>,
164        open_only: Option<BybitOpenOnly>,
165        order_filter: Option<BybitOrderFilter>,
166        limit: Option<u32>,
167        cursor: Option<String>,
168    ) -> PyResult<Bound<'py, PyAny>> {
169        let client = self.clone();
170
171        pyo3_async_runtimes::tokio::future_into_py(py, async move {
172            let response = client
173                .get_open_orders(
174                    category,
175                    symbol,
176                    base_coin,
177                    settle_coin,
178                    order_id,
179                    order_link_id,
180                    open_only,
181                    order_filter,
182                    limit,
183                    cursor,
184                )
185                .await
186                .map_err(to_pyvalue_err)?;
187
188            Python::attach(|py| {
189                let open_orders = BybitOrderCursorList::from(response.result);
190                let py_open_orders = Py::new(py, open_orders)?;
191                Ok(py_open_orders.into_any())
192            })
193        })
194    }
195}
196
197#[pymethods]
198#[pyo3_stub_gen::derive::gen_stub_pymethods]
199impl BybitHttpClient {
200    /// Provides a HTTP client for connecting to the [Bybit](https://bybit.com) REST API.
201    /// High-level HTTP client that wraps the raw client and provides Nautilus domain types.
202    ///
203    /// This client maintains an instrument cache and uses it to parse venue responses
204    /// into Nautilus domain objects.
205    #[new]
206    #[pyo3(signature = (api_key=None, api_secret=None, base_url=None, demo=false, testnet=false, timeout_secs=60, max_retries=3, retry_delay_ms=1000, retry_delay_max_ms=10_000, recv_window_ms=5_000, proxy_url=None))]
207    #[expect(clippy::too_many_arguments)]
208    fn py_new(
209        api_key: Option<String>,
210        api_secret: Option<String>,
211        base_url: Option<String>,
212        demo: bool,
213        testnet: bool,
214        timeout_secs: u64,
215        max_retries: u32,
216        retry_delay_ms: u64,
217        retry_delay_max_ms: u64,
218        recv_window_ms: u64,
219        proxy_url: Option<String>,
220    ) -> PyResult<Self> {
221        Self::new_with_env(
222            api_key,
223            api_secret,
224            base_url,
225            demo,
226            testnet,
227            timeout_secs,
228            max_retries,
229            retry_delay_ms,
230            retry_delay_max_ms,
231            recv_window_ms,
232            proxy_url,
233        )
234        .map_err(to_pyvalue_err)
235    }
236
237    #[getter]
238    #[pyo3(name = "base_url")]
239    #[must_use]
240    pub fn py_base_url(&self) -> &str {
241        self.base_url()
242    }
243
244    #[getter]
245    #[pyo3(name = "api_key")]
246    #[must_use]
247    pub fn py_api_key(&self) -> Option<&str> {
248        self.credential().map(|c| c.api_key())
249    }
250
251    #[getter]
252    #[pyo3(name = "api_key_masked")]
253    #[must_use]
254    pub fn py_api_key_masked(&self) -> Option<String> {
255        self.credential().map(|c| c.api_key_masked())
256    }
257
258    /// Any existing instrument with the same symbol will be replaced.
259    #[pyo3(name = "cache_instrument")]
260    fn py_cache_instrument(&self, py: Python, instrument: Py<PyAny>) -> PyResult<()> {
261        let inst_any = pyobject_to_instrument_any(py, instrument)?;
262        self.cache_instrument(inst_any);
263        Ok(())
264    }
265
266    #[pyo3(name = "cancel_all_requests")]
267    fn py_cancel_all_requests(&self) {
268        self.cancel_all_requests();
269    }
270
271    #[pyo3(name = "set_use_spot_position_reports")]
272    fn py_set_use_spot_position_reports(&self, value: bool) {
273        self.set_use_spot_position_reports(value);
274    }
275
276    /// Sets margin mode (requires authentication).
277    ///
278    /// # References
279    ///
280    /// - <https://bybit-exchange.github.io/docs/v5/account/set-margin-mode>
281    #[pyo3(name = "set_margin_mode")]
282    fn py_set_margin_mode<'py>(
283        &self,
284        py: Python<'py>,
285        margin_mode: BybitMarginMode,
286    ) -> PyResult<Bound<'py, PyAny>> {
287        let client = self.clone();
288
289        pyo3_async_runtimes::tokio::future_into_py(py, async move {
290            client
291                .set_margin_mode(margin_mode)
292                .await
293                .map_err(to_pyvalue_err)?;
294
295            Python::attach(|py| Ok(py.None()))
296        })
297    }
298
299    /// Fetches API key information including account details (requires authentication).
300    ///
301    /// # Errors
302    ///
303    /// Returns an error if:
304    /// - The request fails.
305    /// - The response cannot be parsed.
306    ///
307    /// # References
308    ///
309    /// - <https://bybit-exchange.github.io/docs/v5/user/apikey-info>
310    #[pyo3(name = "get_account_details")]
311    fn py_get_account_details<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
312        let client = self.clone();
313
314        pyo3_async_runtimes::tokio::future_into_py(py, async move {
315            let response = client.get_account_details().await.map_err(to_pyvalue_err)?;
316
317            Python::attach(|py| {
318                let account_details = Py::new(py, response.result)?;
319                Ok(account_details.into_any())
320            })
321        })
322    }
323
324    /// Sets leverage for a symbol (requires authentication).
325    ///
326    /// # References
327    ///
328    /// - <https://bybit-exchange.github.io/docs/v5/position/leverage>
329    #[pyo3(name = "set_leverage")]
330    #[pyo3(signature = (product_type, symbol, buy_leverage, sell_leverage))]
331    fn py_set_leverage<'py>(
332        &self,
333        py: Python<'py>,
334        product_type: BybitProductType,
335        symbol: String,
336        buy_leverage: String,
337        sell_leverage: String,
338    ) -> PyResult<Bound<'py, PyAny>> {
339        let client = self.clone();
340
341        pyo3_async_runtimes::tokio::future_into_py(py, async move {
342            client
343                .set_leverage(product_type, &symbol, &buy_leverage, &sell_leverage)
344                .await
345                .map_err(to_pyvalue_err)?;
346
347            Python::attach(|py| Ok(py.None()))
348        })
349    }
350
351    /// Switches position mode (requires authentication).
352    ///
353    /// # References
354    ///
355    /// - <https://bybit-exchange.github.io/docs/v5/position/position-mode>
356    #[pyo3(name = "switch_mode")]
357    #[pyo3(signature = (product_type, mode, symbol=None, coin=None))]
358    fn py_switch_mode<'py>(
359        &self,
360        py: Python<'py>,
361        product_type: BybitProductType,
362        mode: BybitPositionMode,
363        symbol: Option<String>,
364        coin: Option<String>,
365    ) -> PyResult<Bound<'py, PyAny>> {
366        let client = self.clone();
367
368        pyo3_async_runtimes::tokio::future_into_py(py, async move {
369            client
370                .switch_mode(product_type, mode, symbol, coin)
371                .await
372                .map_err(to_pyvalue_err)?;
373
374            Python::attach(|py| Ok(py.None()))
375        })
376    }
377
378    /// Get the outstanding spot borrow amount for a specific coin.
379    ///
380    /// Returns zero if no borrow exists.
381    ///
382    /// # Parameters
383    ///
384    /// - `coin`: The coin to check (e.g., "BTC", "ETH")
385    #[pyo3(name = "get_spot_borrow_amount")]
386    fn py_get_spot_borrow_amount<'py>(
387        &self,
388        py: Python<'py>,
389        coin: String,
390    ) -> PyResult<Bound<'py, PyAny>> {
391        let client = self.clone();
392
393        pyo3_async_runtimes::tokio::future_into_py(py, async move {
394            let borrow_amount = client
395                .get_spot_borrow_amount(&coin)
396                .await
397                .map_err(to_pyvalue_err)?;
398
399            Ok(borrow_amount)
400        })
401    }
402
403    /// Borrows coins for spot margin trading.
404    ///
405    /// This should be called before opening short spot positions.
406    ///
407    /// # Parameters
408    ///
409    /// - `coin`: The coin to repay (e.g., "BTC", "ETH")
410    /// - `amount`: Optional amount to borrow. If None, repays all outstanding borrows.
411    #[pyo3(name = "borrow_spot")]
412    #[pyo3(signature = (coin, amount))]
413    fn py_borrow_spot<'py>(
414        &self,
415        py: Python<'py>,
416        coin: String,
417        amount: Quantity,
418    ) -> PyResult<Bound<'py, PyAny>> {
419        let client = self.clone();
420
421        pyo3_async_runtimes::tokio::future_into_py(py, async move {
422            client
423                .borrow_spot(&coin, amount)
424                .await
425                .map_err(to_pyvalue_err)?;
426
427            Python::attach(|py| Ok(py.None()))
428        })
429    }
430
431    /// Repays spot borrows for a specific coin.
432    ///
433    /// This should be called after closing short spot positions to avoid accruing interest.
434    ///
435    /// # Parameters
436    ///
437    /// - `coin`: The coin to repay (e.g., "BTC", "ETH")
438    /// - `amount`: Optional amount to repay. If None, repays all outstanding borrows.
439    #[pyo3(name = "repay_spot_borrow")]
440    #[pyo3(signature = (coin, amount=None))]
441    fn py_repay_spot_borrow<'py>(
442        &self,
443        py: Python<'py>,
444        coin: String,
445        amount: Option<Quantity>,
446    ) -> PyResult<Bound<'py, PyAny>> {
447        let client = self.clone();
448
449        pyo3_async_runtimes::tokio::future_into_py(py, async move {
450            client
451                .repay_spot_borrow(&coin, amount)
452                .await
453                .map_err(to_pyvalue_err)?;
454
455            Python::attach(|py| Ok(py.None()))
456        })
457    }
458
459    /// Request instruments for a given product type.
460    ///
461    /// When `base_coin` is provided, the request is narrowed to that base coin.
462    /// This is required for `Option`: Bybit's API returns only `BTC` options when
463    /// `baseCoin` is omitted.
464    #[pyo3(name = "request_instruments")]
465    #[pyo3(signature = (product_type, symbol=None, base_coin=None))]
466    fn py_request_instruments<'py>(
467        &self,
468        py: Python<'py>,
469        product_type: BybitProductType,
470        symbol: Option<String>,
471        base_coin: Option<String>,
472    ) -> PyResult<Bound<'py, PyAny>> {
473        let client = self.clone();
474        let base_coin = base_coin.map(|s| Ustr::from(&s));
475
476        pyo3_async_runtimes::tokio::future_into_py(py, async move {
477            let instruments = client
478                .request_instruments(product_type, symbol, base_coin)
479                .await
480                .map_err(to_pyvalue_err)?;
481
482            Python::attach(|py| {
483                let py_instruments: PyResult<Vec<_>> = instruments
484                    .into_iter()
485                    .map(|inst| instrument_any_to_pyobject(py, inst))
486                    .collect();
487                let pylist = PyList::new(py, py_instruments?)
488                    .unwrap()
489                    .into_any()
490                    .unbind();
491                Ok(pylist)
492            })
493        })
494    }
495
496    /// Fetches instrument info and returns the current status of each symbol.
497    ///
498    /// Paginates through the instruments endpoint collecting only
499    /// `(InstrumentId, MarketStatusAction)` pairs. This avoids fee-rate
500    /// fetching and full instrument parsing.
501    #[pyo3(name = "request_instrument_statuses")]
502    fn py_request_instrument_statuses<'py>(
503        &self,
504        py: Python<'py>,
505        product_type: BybitProductType,
506    ) -> PyResult<Bound<'py, PyAny>> {
507        let client = self.clone();
508
509        pyo3_async_runtimes::tokio::future_into_py(py, async move {
510            let statuses = client
511                .request_instrument_statuses(product_type)
512                .await
513                .map_err(to_pyvalue_err)?;
514
515            Python::attach(|py| {
516                let dict = PyDict::new(py);
517                for (instrument_id, action) in statuses {
518                    dict.set_item(
519                        instrument_id.into_bound_py_any(py)?,
520                        action.into_bound_py_any(py)?,
521                    )?;
522                }
523                Ok(dict.into_any().unbind())
524            })
525        })
526    }
527
528    /// Request ticker information for market data.
529    ///
530    /// Fetches ticker data from Bybit's `/v5/market/tickers` endpoint and returns
531    /// a unified `BybitTickerData` structure compatible with all product types.
532    ///
533    /// # References
534    ///
535    /// <https://bybit-exchange.github.io/docs/v5/market/tickers>
536    #[pyo3(name = "request_tickers")]
537    fn py_request_tickers<'py>(
538        &self,
539        py: Python<'py>,
540        params: crate::python::params::BybitTickersParams,
541    ) -> PyResult<Bound<'py, PyAny>> {
542        let client = self.clone();
543
544        pyo3_async_runtimes::tokio::future_into_py(py, async move {
545            let tickers = client
546                .request_tickers(&params.into())
547                .await
548                .map_err(to_pyvalue_err)?;
549
550            Python::attach(|py| {
551                let py_tickers: PyResult<Vec<_>> = tickers
552                    .into_iter()
553                    .map(|ticker| Py::new(py, ticker))
554                    .collect();
555                let pylist = PyList::new(py, py_tickers?).unwrap().into_any().unbind();
556                Ok(pylist)
557            })
558        })
559    }
560
561    /// Submit a new order.
562    #[pyo3(name = "submit_order")]
563    #[pyo3(signature = (
564        account_id,
565        product_type,
566        instrument_id,
567        client_order_id,
568        order_side,
569        order_type,
570        quantity,
571        time_in_force = None,
572        price = None,
573        trigger_price = None,
574        post_only = None,
575        reduce_only = false,
576        is_quote_quantity = false,
577        is_leverage = false,
578        position_idx = None,
579        bbo_side_type = None,
580        bbo_level = None,
581        native_tp_sl = None,
582    ))]
583    #[expect(clippy::too_many_arguments)]
584    fn py_submit_order<'py>(
585        &self,
586        py: Python<'py>,
587        account_id: AccountId,
588        product_type: BybitProductType,
589        instrument_id: InstrumentId,
590        client_order_id: ClientOrderId,
591        order_side: OrderSide,
592        order_type: OrderType,
593        quantity: Quantity,
594        time_in_force: Option<TimeInForce>,
595        price: Option<Price>,
596        trigger_price: Option<Price>,
597        post_only: Option<bool>,
598        reduce_only: bool,
599        is_quote_quantity: bool,
600        is_leverage: bool,
601        position_idx: Option<BybitPositionIdx>,
602        bbo_side_type: Option<String>,
603        bbo_level: Option<String>,
604        native_tp_sl: Option<BybitNativeTpSlParams>,
605    ) -> PyResult<Bound<'py, PyAny>> {
606        let client = self.clone();
607        let bbo_side_type = bbo_side_type
608            .map(|value| parse_bbo_side_type(&value))
609            .transpose()
610            .map_err(to_pyvalue_err)?;
611        let bbo_level = bbo_level
612            .map(parse_bbo_level)
613            .transpose()
614            .map_err(to_pyvalue_err)?;
615        if bbo_side_type.is_some() != bbo_level.is_some() {
616            return Err(to_pyvalue_err(anyhow::anyhow!(
617                "'bbo_side_type' and 'bbo_level' must be provided together"
618            )));
619        }
620
621        let native_tp_sl: Option<RustNativeTpSlParams> = native_tp_sl
622            .map(RustNativeTpSlParams::try_from)
623            .transpose()
624            .map_err(to_pyvalue_err)?;
625
626        pyo3_async_runtimes::tokio::future_into_py(py, async move {
627            let report = client
628                .submit_order(
629                    account_id,
630                    product_type,
631                    instrument_id,
632                    client_order_id,
633                    order_side,
634                    order_type,
635                    quantity,
636                    time_in_force,
637                    price,
638                    trigger_price,
639                    post_only,
640                    reduce_only,
641                    is_quote_quantity,
642                    is_leverage,
643                    position_idx,
644                    bbo_side_type,
645                    bbo_level,
646                    native_tp_sl.as_ref(),
647                )
648                .await
649                .map_err(to_pyvalue_err)?;
650
651            Python::attach(|py| report.into_py_any(py))
652        })
653    }
654
655    /// Modify an existing order.
656    #[pyo3(name = "modify_order")]
657    #[pyo3(signature = (
658        account_id,
659        product_type,
660        instrument_id,
661        client_order_id=None,
662        venue_order_id=None,
663        quantity=None,
664        price=None
665    ))]
666    #[expect(clippy::too_many_arguments)]
667    fn py_modify_order<'py>(
668        &self,
669        py: Python<'py>,
670        account_id: AccountId,
671        product_type: BybitProductType,
672        instrument_id: InstrumentId,
673        client_order_id: Option<ClientOrderId>,
674        venue_order_id: Option<VenueOrderId>,
675        quantity: Option<Quantity>,
676        price: Option<Price>,
677    ) -> PyResult<Bound<'py, PyAny>> {
678        let client = self.clone();
679
680        pyo3_async_runtimes::tokio::future_into_py(py, async move {
681            let report = client
682                .modify_order(
683                    account_id,
684                    product_type,
685                    instrument_id,
686                    client_order_id,
687                    venue_order_id,
688                    quantity,
689                    price,
690                )
691                .await
692                .map_err(to_pyvalue_err)?;
693
694            Python::attach(|py| report.into_py_any(py))
695        })
696    }
697
698    /// Cancel an order.
699    #[pyo3(name = "cancel_order")]
700    #[pyo3(signature = (account_id, product_type, instrument_id, client_order_id=None, venue_order_id=None))]
701    fn py_cancel_order<'py>(
702        &self,
703        py: Python<'py>,
704        account_id: AccountId,
705        product_type: BybitProductType,
706        instrument_id: InstrumentId,
707        client_order_id: Option<ClientOrderId>,
708        venue_order_id: Option<VenueOrderId>,
709    ) -> PyResult<Bound<'py, PyAny>> {
710        let client = self.clone();
711
712        pyo3_async_runtimes::tokio::future_into_py(py, async move {
713            let report = client
714                .cancel_order(
715                    account_id,
716                    product_type,
717                    instrument_id,
718                    client_order_id,
719                    venue_order_id,
720                )
721                .await
722                .map_err(to_pyvalue_err)?;
723
724            Python::attach(|py| report.into_py_any(py))
725        })
726    }
727
728    /// Cancel all orders for an instrument.
729    #[pyo3(name = "cancel_all_orders")]
730    fn py_cancel_all_orders<'py>(
731        &self,
732        py: Python<'py>,
733        account_id: AccountId,
734        product_type: BybitProductType,
735        instrument_id: InstrumentId,
736    ) -> PyResult<Bound<'py, PyAny>> {
737        let client = self.clone();
738
739        pyo3_async_runtimes::tokio::future_into_py(py, async move {
740            let reports = client
741                .cancel_all_orders(account_id, product_type, instrument_id)
742                .await
743                .map_err(to_pyvalue_err)?;
744
745            Python::attach(|py| {
746                let py_reports: PyResult<Vec<_>> = reports
747                    .into_iter()
748                    .map(|report| report.into_py_any(py))
749                    .collect();
750                let pylist = PyList::new(py, py_reports?).unwrap().into_any().unbind();
751                Ok(pylist)
752            })
753        })
754    }
755
756    /// Query a single order by client order ID or venue order ID.
757    #[pyo3(name = "query_order")]
758    #[pyo3(signature = (account_id, product_type, instrument_id, client_order_id=None, venue_order_id=None))]
759    fn py_query_order<'py>(
760        &self,
761        py: Python<'py>,
762        account_id: AccountId,
763        product_type: BybitProductType,
764        instrument_id: InstrumentId,
765        client_order_id: Option<ClientOrderId>,
766        venue_order_id: Option<VenueOrderId>,
767    ) -> PyResult<Bound<'py, PyAny>> {
768        let client = self.clone();
769
770        pyo3_async_runtimes::tokio::future_into_py(py, async move {
771            match client
772                .query_order(
773                    account_id,
774                    product_type,
775                    instrument_id,
776                    client_order_id,
777                    venue_order_id,
778                )
779                .await
780            {
781                Ok(Some(report)) => Python::attach(|py| report.into_py_any(py)),
782                Ok(None) => Ok(Python::attach(|py| py.None())),
783                Err(e) => Err(to_pyvalue_err(e)),
784            }
785        })
786    }
787
788    /// Request recent trade tick history for a given symbol.
789    ///
790    /// Returns the most recent public trades from Bybit's `/v5/market/recent-trade` endpoint.
791    /// This endpoint only provides recent trades (up to 1000 most recent), typically covering
792    /// only the last few minutes for active markets.
793    ///
794    /// **Note**: For historical trade data with time ranges, use the klines endpoint instead.
795    /// The Bybit public API does not support fetching historical trades by time range.
796    ///
797    /// # References
798    ///
799    /// <https://bybit-exchange.github.io/docs/v5/market/recent-trade>
800    #[pyo3(name = "request_trades")]
801    #[pyo3(signature = (product_type, instrument_id, limit=None))]
802    fn py_request_trades<'py>(
803        &self,
804        py: Python<'py>,
805        product_type: BybitProductType,
806        instrument_id: InstrumentId,
807        limit: Option<u32>,
808    ) -> PyResult<Bound<'py, PyAny>> {
809        let client = self.clone();
810
811        pyo3_async_runtimes::tokio::future_into_py(py, async move {
812            let trades = client
813                .request_trades(product_type, instrument_id, limit)
814                .await
815                .map_err(to_pyvalue_err)?;
816
817            Python::attach(|py| {
818                let py_trades: PyResult<Vec<_>> = trades
819                    .into_iter()
820                    .map(|trade| trade.into_py_any(py))
821                    .collect();
822                let pylist = PyList::new(py, py_trades?).unwrap().into_any().unbind();
823                Ok(pylist)
824            })
825        })
826    }
827
828    /// Request funding rate history for a given symbol.
829    ///
830    /// # References
831    ///
832    /// <https://bybit-exchange.github.io/docs/v5/market/history-fund-rate>
833    #[pyo3(name = "request_funding_rates")]
834    #[pyo3(signature = (product_type, instrument_id, start=None, end=None, limit=None))]
835    fn py_request_funding_rates<'py>(
836        &self,
837        py: Python<'py>,
838        product_type: BybitProductType,
839        instrument_id: InstrumentId,
840        start: Option<DateTime<Utc>>,
841        end: Option<DateTime<Utc>>,
842        limit: Option<u32>,
843    ) -> PyResult<Bound<'py, PyAny>> {
844        let client = self.clone();
845
846        pyo3_async_runtimes::tokio::future_into_py(py, async move {
847            let funding_rates = client
848                .request_funding_rates(product_type, instrument_id, start, end, limit)
849                .await
850                .map_err(to_pyvalue_err)?;
851
852            Python::attach(|py| {
853                let py_funding_rates: PyResult<Vec<_>> = funding_rates
854                    .into_iter()
855                    .map(|funding_rate| funding_rate.into_py_any(py))
856                    .collect();
857                let pylist = PyList::new(py, py_funding_rates?)
858                    .unwrap()
859                    .into_any()
860                    .unbind();
861                Ok(pylist)
862            })
863        })
864    }
865
866    /// Request an orderbook snapshot for a given symbol.
867    ///
868    /// Bybit limits the amount of levels (depth) for each product type to:
869    /// - Spot: `1..=200` (default: `1`)
870    /// - Linear & Inverse: `1..=500` (default: `25`)
871    /// - Options: `1..=25` (default: `1`)
872    ///
873    /// # References
874    ///
875    /// <https://bybit-exchange.github.io/docs/v5/market/orderbook>
876    #[pyo3(name = "request_orderbook_snapshot")]
877    #[pyo3(signature = (product_type, instrument_id, limit=None))]
878    fn py_request_orderbook_snapshot<'py>(
879        &self,
880        py: Python<'py>,
881        product_type: BybitProductType,
882        instrument_id: InstrumentId,
883        limit: Option<u32>,
884    ) -> PyResult<Bound<'py, PyAny>> {
885        let client = self.clone();
886
887        pyo3_async_runtimes::tokio::future_into_py(py, async move {
888            let deltas = client
889                .request_orderbook_snapshot(product_type, instrument_id, limit)
890                .await
891                .map_err(to_pyvalue_err)?;
892
893            Python::attach(|py| Ok(deltas.into_py_any(py).unwrap()))
894        })
895    }
896
897    /// Request bar/kline history for a given symbol.
898    ///
899    /// # References
900    ///
901    /// <https://bybit-exchange.github.io/docs/v5/market/kline>
902    #[pyo3(name = "request_bars")]
903    #[pyo3(signature = (product_type, bar_type, start=None, end=None, limit=None, timestamp_on_close=true))]
904    #[expect(clippy::too_many_arguments)]
905    fn py_request_bars<'py>(
906        &self,
907        py: Python<'py>,
908        product_type: BybitProductType,
909        bar_type: BarType,
910        start: Option<DateTime<Utc>>,
911        end: Option<DateTime<Utc>>,
912        limit: Option<u32>,
913        timestamp_on_close: bool,
914    ) -> PyResult<Bound<'py, PyAny>> {
915        let client = self.clone();
916
917        pyo3_async_runtimes::tokio::future_into_py(py, async move {
918            let bars = client
919                .request_bars(
920                    product_type,
921                    bar_type,
922                    start,
923                    end,
924                    limit,
925                    timestamp_on_close,
926                )
927                .await
928                .map_err(to_pyvalue_err)?;
929
930            Python::attach(|py| {
931                let py_bars: PyResult<Vec<_>> =
932                    bars.into_iter().map(|bar| bar.into_py_any(py)).collect();
933                let pylist = PyList::new(py, py_bars?).unwrap().into_any().unbind();
934                Ok(pylist)
935            })
936        })
937    }
938
939    /// Requests trading fee rates for the specified product type and optional filters.
940    ///
941    /// # References
942    ///
943    /// <https://bybit-exchange.github.io/docs/v5/account/fee-rate>
944    #[pyo3(name = "request_fee_rates")]
945    #[pyo3(signature = (product_type, symbol=None, base_coin=None))]
946    fn py_request_fee_rates<'py>(
947        &self,
948        py: Python<'py>,
949        product_type: BybitProductType,
950        symbol: Option<String>,
951        base_coin: Option<String>,
952    ) -> PyResult<Bound<'py, PyAny>> {
953        let client = self.clone();
954
955        pyo3_async_runtimes::tokio::future_into_py(py, async move {
956            let fee_rates = client
957                .request_fee_rates(product_type, symbol, base_coin)
958                .await
959                .map_err(to_pyvalue_err)?;
960
961            Python::attach(|py| {
962                let py_fee_rates: PyResult<Vec<_>> = fee_rates
963                    .into_iter()
964                    .map(|rate| Py::new(py, rate))
965                    .collect();
966                let pylist = PyList::new(py, py_fee_rates?).unwrap().into_any().unbind();
967                Ok(pylist)
968            })
969        })
970    }
971
972    /// Requests the current account state for the specified account type.
973    ///
974    /// # References
975    ///
976    /// <https://bybit-exchange.github.io/docs/v5/account/wallet-balance>
977    #[pyo3(name = "request_account_state")]
978    fn py_request_account_state<'py>(
979        &self,
980        py: Python<'py>,
981        account_type: crate::common::enums::BybitAccountType,
982        account_id: AccountId,
983    ) -> PyResult<Bound<'py, PyAny>> {
984        let client = self.clone();
985
986        pyo3_async_runtimes::tokio::future_into_py(py, async move {
987            let account_state = client
988                .request_account_state(account_type, account_id)
989                .await
990                .map_err(to_pyvalue_err)?;
991
992            Python::attach(|py| account_state.into_py_any(py))
993        })
994    }
995
996    /// Request multiple order status reports.
997    ///
998    /// Orders for instruments not currently loaded in cache will be skipped.
999    #[pyo3(name = "request_order_status_reports")]
1000    #[pyo3(signature = (account_id, product_type, instrument_id=None, open_only=false, start=None, end=None, limit=None))]
1001    #[expect(clippy::too_many_arguments)]
1002    fn py_request_order_status_reports<'py>(
1003        &self,
1004        py: Python<'py>,
1005        account_id: AccountId,
1006        product_type: BybitProductType,
1007        instrument_id: Option<InstrumentId>,
1008        open_only: bool,
1009        start: Option<DateTime<Utc>>,
1010        end: Option<DateTime<Utc>>,
1011        limit: Option<u32>,
1012    ) -> PyResult<Bound<'py, PyAny>> {
1013        let client = self.clone();
1014
1015        pyo3_async_runtimes::tokio::future_into_py(py, async move {
1016            let reports = client
1017                .request_order_status_reports(
1018                    account_id,
1019                    product_type,
1020                    instrument_id,
1021                    open_only,
1022                    start,
1023                    end,
1024                    limit,
1025                )
1026                .await
1027                .map_err(to_pyvalue_err)?;
1028
1029            Python::attach(|py| {
1030                let py_reports: PyResult<Vec<_>> = reports
1031                    .into_iter()
1032                    .map(|report| report.into_py_any(py))
1033                    .collect();
1034                let pylist = PyList::new(py, py_reports?).unwrap().into_any().unbind();
1035                Ok(pylist)
1036            })
1037        })
1038    }
1039
1040    /// Fetches execution history (fills) for the account and returns a list of `FillReport`s.
1041    ///
1042    /// Executions for instruments not currently loaded in cache will be skipped.
1043    ///
1044    /// # References
1045    ///
1046    /// <https://bybit-exchange.github.io/docs/v5/order/execution>
1047    #[pyo3(name = "request_fill_reports")]
1048    #[pyo3(signature = (account_id, product_type, instrument_id=None, start=None, end=None, limit=None))]
1049    #[expect(clippy::too_many_arguments)]
1050    fn py_request_fill_reports<'py>(
1051        &self,
1052        py: Python<'py>,
1053        account_id: AccountId,
1054        product_type: BybitProductType,
1055        instrument_id: Option<InstrumentId>,
1056        start: Option<i64>,
1057        end: Option<i64>,
1058        limit: Option<u32>,
1059    ) -> PyResult<Bound<'py, PyAny>> {
1060        let client = self.clone();
1061
1062        pyo3_async_runtimes::tokio::future_into_py(py, async move {
1063            let reports = client
1064                .request_fill_reports(account_id, product_type, instrument_id, start, end, limit)
1065                .await
1066                .map_err(to_pyvalue_err)?;
1067
1068            Python::attach(|py| {
1069                let py_reports: PyResult<Vec<_>> = reports
1070                    .into_iter()
1071                    .map(|report| report.into_py_any(py))
1072                    .collect();
1073                let pylist = PyList::new(py, py_reports?).unwrap().into_any().unbind();
1074                Ok(pylist)
1075            })
1076        })
1077    }
1078
1079    /// Fetches position information for the account and returns a list of `PositionStatusReport`s.
1080    ///
1081    /// Positions for instruments not currently loaded in cache will be skipped.
1082    ///
1083    /// # References
1084    ///
1085    /// <https://bybit-exchange.github.io/docs/v5/position>
1086    #[pyo3(name = "request_position_status_reports")]
1087    #[pyo3(signature = (account_id, product_type, instrument_id=None))]
1088    fn py_request_position_status_reports<'py>(
1089        &self,
1090        py: Python<'py>,
1091        account_id: AccountId,
1092        product_type: BybitProductType,
1093        instrument_id: Option<InstrumentId>,
1094    ) -> PyResult<Bound<'py, PyAny>> {
1095        let client = self.clone();
1096
1097        pyo3_async_runtimes::tokio::future_into_py(py, async move {
1098            let reports = client
1099                .request_position_status_reports(account_id, product_type, instrument_id)
1100                .await
1101                .map_err(to_pyvalue_err)?;
1102
1103            Python::attach(|py| {
1104                let py_reports: PyResult<Vec<_>> = reports
1105                    .into_iter()
1106                    .map(|report| report.into_py_any(py))
1107                    .collect();
1108                let pylist = PyList::new(py, py_reports?).unwrap().into_any().unbind();
1109                Ok(pylist)
1110            })
1111        })
1112    }
1113
1114    /// Request forward prices for option chain ATM determination.
1115    ///
1116    /// Single-instrument path (1 HTTP call) if `instrument_id` is provided,
1117    /// otherwise bulk path via option tickers.
1118    #[pyo3(name = "request_forward_prices")]
1119    #[pyo3(signature = (base_coin, instrument_id=None))]
1120    fn py_request_forward_prices<'py>(
1121        &self,
1122        py: Python<'py>,
1123        base_coin: String,
1124        instrument_id: Option<InstrumentId>,
1125    ) -> PyResult<Bound<'py, PyAny>> {
1126        let client = self.clone();
1127
1128        pyo3_async_runtimes::tokio::future_into_py(py, async move {
1129            let forward_prices: Vec<ForwardPrice> = if let Some(inst_id) = instrument_id {
1130                // Single-instrument path: fetch ticker for one symbol
1131                let raw_symbol = extract_raw_symbol(inst_id.symbol.as_str()).to_string();
1132                let params = crate::http::query::BybitTickersParams {
1133                    category: BybitProductType::Option,
1134                    symbol: Some(raw_symbol),
1135                    base_coin: None,
1136                    exp_date: None,
1137                };
1138                let tickers = client
1139                    .request_option_tickers_raw_with_params(&params)
1140                    .await
1141                    .map_err(to_pyvalue_err)?;
1142
1143                let ts = UnixNanos::default();
1144                tickers
1145                    .into_iter()
1146                    .filter_map(|t| {
1147                        let up: rust_decimal::Decimal = t.underlying_price.parse().ok()?;
1148                        if up.is_zero() {
1149                            return None;
1150                        }
1151                        Some(ForwardPrice::new(inst_id, up, None, ts, ts))
1152                    })
1153                    .collect()
1154            } else {
1155                // Bulk path: fetch all option tickers for base coin
1156                let tickers = client
1157                    .request_option_tickers_raw(&base_coin)
1158                    .await
1159                    .map_err(to_pyvalue_err)?;
1160
1161                let ts = nautilus_core::UnixNanos::default();
1162                let mut seen_expiries = HashSet::new();
1163                tickers
1164                    .into_iter()
1165                    .filter_map(|t| {
1166                        let up: rust_decimal::Decimal = t.underlying_price.parse().ok()?;
1167                        if up.is_zero() {
1168                            return None;
1169                        }
1170                        let parts: Vec<&str> = t.symbol.splitn(3, '-').collect();
1171                        let expiry_key = if parts.len() >= 2 {
1172                            format!("{}-{}", parts[0], parts[1])
1173                        } else {
1174                            t.symbol.to_string()
1175                        };
1176
1177                        if !seen_expiries.insert(expiry_key) {
1178                            return None;
1179                        }
1180                        let symbol_str = format!("{}-OPTION", t.symbol);
1181                        let inst_id = InstrumentId::new(
1182                            Symbol::new(&symbol_str),
1183                            *crate::common::consts::BYBIT_VENUE,
1184                        );
1185                        Some(ForwardPrice::new(inst_id, up, None, ts, ts))
1186                    })
1187                    .collect()
1188            };
1189
1190            Python::attach(|py| {
1191                let py_prices: PyResult<Vec<_>> = forward_prices
1192                    .into_iter()
1193                    .map(|fp| Py::new(py, fp))
1194                    .collect();
1195                let pylist = PyList::new(py, py_prices?)?.into_any().unbind();
1196                Ok(pylist)
1197            })
1198        })
1199    }
1200}
1201
1202impl From<BybitHttpError> for PyErr {
1203    fn from(error: BybitHttpError) -> Self {
1204        match error {
1205            // Runtime/operational errors
1206            BybitHttpError::Canceled(msg) => to_pyruntime_err(format!("Request canceled: {msg}")),
1207            BybitHttpError::NetworkError(msg) => to_pyruntime_err(format!("Network error: {msg}")),
1208            BybitHttpError::UnexpectedStatus { status, body } => {
1209                to_pyruntime_err(format!("Unexpected HTTP status code {status}: {body}"))
1210            }
1211            // Validation/configuration errors
1212            BybitHttpError::MissingCredentials => {
1213                to_pyvalue_err("Missing credentials for authenticated request")
1214            }
1215            BybitHttpError::ValidationError(msg) => {
1216                to_pyvalue_err(format!("Parameter validation error: {msg}"))
1217            }
1218            BybitHttpError::JsonError(msg) => to_pyvalue_err(format!("JSON error: {msg}")),
1219            BybitHttpError::BuildError(e) => to_pyvalue_err(format!("Build error: {e}")),
1220            BybitHttpError::BybitError {
1221                error_code,
1222                message,
1223            } => to_pyvalue_err(format!("Bybit error {error_code}: {message}")),
1224        }
1225    }
1226}