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 jiff::Timestamp;
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    /// # Errors
150    ///
151    /// Returns an error if the request fails or the response cannot be parsed.
152    ///
153    /// # References
154    ///
155    /// - <https://bybit-exchange.github.io/docs/v5/order/open-order>
156    #[pyo3(name = "get_open_orders")]
157    #[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))]
158    #[expect(clippy::too_many_arguments)]
159    fn py_get_open_orders<'py>(
160        &self,
161        py: Python<'py>,
162        category: BybitProductType,
163        symbol: Option<String>,
164        base_coin: Option<String>,
165        settle_coin: Option<String>,
166        order_id: Option<String>,
167        order_link_id: Option<String>,
168        open_only: Option<BybitOpenOnly>,
169        order_filter: Option<BybitOrderFilter>,
170        limit: Option<u32>,
171        cursor: Option<String>,
172    ) -> PyResult<Bound<'py, PyAny>> {
173        let client = self.clone();
174
175        pyo3_async_runtimes::tokio::future_into_py(py, async move {
176            let response = client
177                .get_open_orders(
178                    category,
179                    symbol,
180                    base_coin,
181                    settle_coin,
182                    order_id,
183                    order_link_id,
184                    open_only,
185                    order_filter,
186                    limit,
187                    cursor,
188                )
189                .await
190                .map_err(to_pyvalue_err)?;
191
192            Python::attach(|py| {
193                let open_orders = BybitOrderCursorList::from(response.result);
194                let py_open_orders = Py::new(py, open_orders)?;
195                Ok(py_open_orders.into_any())
196            })
197        })
198    }
199}
200
201#[pymethods]
202#[pyo3_stub_gen::derive::gen_stub_pymethods]
203impl BybitHttpClient {
204    /// Provides a HTTP client for connecting to the [Bybit](https://bybit.com) REST API.
205    /// High-level HTTP client that wraps the raw client and provides Nautilus domain types.
206    ///
207    /// This client maintains an instrument cache and uses it to parse venue responses
208    /// into Nautilus domain objects.
209    #[new]
210    #[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))]
211    #[expect(clippy::too_many_arguments)]
212    fn py_new(
213        api_key: Option<String>,
214        api_secret: Option<String>,
215        base_url: Option<String>,
216        demo: bool,
217        testnet: bool,
218        timeout_secs: u64,
219        max_retries: u32,
220        retry_delay_ms: u64,
221        retry_delay_max_ms: u64,
222        recv_window_ms: u64,
223        proxy_url: Option<String>,
224    ) -> PyResult<Self> {
225        Self::new_with_env(
226            api_key,
227            api_secret,
228            base_url,
229            demo,
230            testnet,
231            timeout_secs,
232            max_retries,
233            retry_delay_ms,
234            retry_delay_max_ms,
235            recv_window_ms,
236            proxy_url,
237        )
238        .map_err(to_pyvalue_err)
239    }
240
241    #[getter]
242    #[pyo3(name = "base_url")]
243    #[must_use]
244    pub fn py_base_url(&self) -> &str {
245        self.base_url()
246    }
247
248    #[getter]
249    #[pyo3(name = "api_key")]
250    #[must_use]
251    pub fn py_api_key(&self) -> Option<&str> {
252        self.credential().map(|c| c.api_key())
253    }
254
255    #[getter]
256    #[pyo3(name = "api_key_masked")]
257    #[must_use]
258    pub fn py_api_key_masked(&self) -> Option<String> {
259        self.credential().map(|c| c.api_key_masked())
260    }
261
262    /// Any existing instrument with the same symbol will be replaced.
263    #[pyo3(name = "cache_instrument")]
264    fn py_cache_instrument(&self, py: Python, instrument: Py<PyAny>) -> PyResult<()> {
265        let inst_any = pyobject_to_instrument_any(py, instrument)?;
266        self.cache_instrument(inst_any);
267        Ok(())
268    }
269
270    #[pyo3(name = "cancel_all_requests")]
271    fn py_cancel_all_requests(&self) {
272        self.cancel_all_requests();
273    }
274
275    #[pyo3(name = "set_use_spot_position_reports")]
276    fn py_set_use_spot_position_reports(&self, value: bool) {
277        self.set_use_spot_position_reports(value);
278    }
279
280    /// Sets margin mode (requires authentication).
281    ///
282    /// # Errors
283    ///
284    /// Returns an error if:
285    /// - Credentials are missing.
286    /// - The request fails.
287    /// - The API returns an error.
288    ///
289    /// # References
290    ///
291    /// - <https://bybit-exchange.github.io/docs/v5/account/set-margin-mode>
292    #[pyo3(name = "set_margin_mode")]
293    fn py_set_margin_mode<'py>(
294        &self,
295        py: Python<'py>,
296        margin_mode: BybitMarginMode,
297    ) -> PyResult<Bound<'py, PyAny>> {
298        let client = self.clone();
299
300        pyo3_async_runtimes::tokio::future_into_py(py, async move {
301            client
302                .set_margin_mode(margin_mode)
303                .await
304                .map_err(to_pyvalue_err)?;
305
306            Python::attach(|py| Ok(py.None()))
307        })
308    }
309
310    /// Fetches API key information including account details (requires authentication).
311    ///
312    /// # Errors
313    ///
314    /// Returns an error if:
315    /// - The request fails.
316    /// - The response cannot be parsed.
317    ///
318    /// # References
319    ///
320    /// - <https://bybit-exchange.github.io/docs/v5/user/apikey-info>
321    #[pyo3(name = "get_account_details")]
322    fn py_get_account_details<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
323        let client = self.clone();
324
325        pyo3_async_runtimes::tokio::future_into_py(py, async move {
326            let response = client.get_account_details().await.map_err(to_pyvalue_err)?;
327
328            Python::attach(|py| {
329                let account_details = Py::new(py, response.result)?;
330                Ok(account_details.into_any())
331            })
332        })
333    }
334
335    /// Sets leverage for a symbol (requires authentication).
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if:
340    /// - Credentials are missing.
341    /// - The request fails.
342    /// - The API returns an error.
343    ///
344    /// # References
345    ///
346    /// - <https://bybit-exchange.github.io/docs/v5/position/leverage>
347    #[pyo3(name = "set_leverage")]
348    #[pyo3(signature = (product_type, symbol, buy_leverage, sell_leverage))]
349    fn py_set_leverage<'py>(
350        &self,
351        py: Python<'py>,
352        product_type: BybitProductType,
353        symbol: String,
354        buy_leverage: String,
355        sell_leverage: String,
356    ) -> PyResult<Bound<'py, PyAny>> {
357        let client = self.clone();
358
359        pyo3_async_runtimes::tokio::future_into_py(py, async move {
360            client
361                .set_leverage(product_type, &symbol, &buy_leverage, &sell_leverage)
362                .await
363                .map_err(to_pyvalue_err)?;
364
365            Python::attach(|py| Ok(py.None()))
366        })
367    }
368
369    /// Switches position mode (requires authentication).
370    ///
371    /// # Errors
372    ///
373    /// Returns an error if:
374    /// - Credentials are missing.
375    /// - The request fails.
376    /// - The API returns an error.
377    ///
378    /// # References
379    ///
380    /// - <https://bybit-exchange.github.io/docs/v5/position/position-mode>
381    #[pyo3(name = "switch_mode")]
382    #[pyo3(signature = (product_type, mode, symbol=None, coin=None))]
383    fn py_switch_mode<'py>(
384        &self,
385        py: Python<'py>,
386        product_type: BybitProductType,
387        mode: BybitPositionMode,
388        symbol: Option<String>,
389        coin: Option<String>,
390    ) -> PyResult<Bound<'py, PyAny>> {
391        let client = self.clone();
392
393        pyo3_async_runtimes::tokio::future_into_py(py, async move {
394            client
395                .switch_mode(product_type, mode, symbol, coin)
396                .await
397                .map_err(to_pyvalue_err)?;
398
399            Python::attach(|py| Ok(py.None()))
400        })
401    }
402
403    /// Get the outstanding spot borrow amount for a specific coin.
404    ///
405    /// Returns zero if no borrow exists.
406    ///
407    /// # Parameters
408    ///
409    /// - `coin`: The coin to check (e.g., "BTC", "ETH")
410    ///
411    /// # Errors
412    ///
413    /// Returns an error if:
414    /// - Credentials are missing.
415    /// - The request fails.
416    /// - The coin is not found in the wallet.
417    #[pyo3(name = "get_spot_borrow_amount")]
418    fn py_get_spot_borrow_amount<'py>(
419        &self,
420        py: Python<'py>,
421        coin: String,
422    ) -> PyResult<Bound<'py, PyAny>> {
423        let client = self.clone();
424
425        pyo3_async_runtimes::tokio::future_into_py(py, async move {
426            let borrow_amount = client
427                .get_spot_borrow_amount(&coin)
428                .await
429                .map_err(to_pyvalue_err)?;
430
431            Ok(borrow_amount)
432        })
433    }
434
435    /// Borrows coins for spot margin trading.
436    ///
437    /// This should be called before opening short spot positions.
438    ///
439    /// # Parameters
440    ///
441    /// - `coin`: The coin to repay (e.g., "BTC", "ETH")
442    /// - `amount`: Optional amount to borrow. If None, repays all outstanding borrows.
443    ///
444    /// # Errors
445    ///
446    /// Returns an error if:
447    /// - Credentials are missing.
448    /// - The request fails.
449    /// - Insufficient collateral for the borrow.
450    #[pyo3(name = "borrow_spot")]
451    #[pyo3(signature = (coin, amount))]
452    fn py_borrow_spot<'py>(
453        &self,
454        py: Python<'py>,
455        coin: String,
456        amount: Quantity,
457    ) -> PyResult<Bound<'py, PyAny>> {
458        let client = self.clone();
459
460        pyo3_async_runtimes::tokio::future_into_py(py, async move {
461            client
462                .borrow_spot(&coin, amount)
463                .await
464                .map_err(to_pyvalue_err)?;
465
466            Python::attach(|py| Ok(py.None()))
467        })
468    }
469
470    /// Repays spot borrows for a specific coin.
471    ///
472    /// This should be called after closing short spot positions to avoid accruing interest.
473    ///
474    /// # Parameters
475    ///
476    /// - `coin`: The coin to repay (e.g., "BTC", "ETH")
477    /// - `amount`: Optional amount to repay. If None, repays all outstanding borrows.
478    ///
479    /// # Errors
480    ///
481    /// Returns an error if:
482    /// - Credentials are missing.
483    /// - The request fails.
484    /// - Called during the hourly interest-calculation window (mm:04:00-mm:05:30 UTC each hour).
485    /// - Insufficient spot balance for repayment.
486    #[pyo3(name = "repay_spot_borrow")]
487    #[pyo3(signature = (coin, amount=None))]
488    fn py_repay_spot_borrow<'py>(
489        &self,
490        py: Python<'py>,
491        coin: String,
492        amount: Option<Quantity>,
493    ) -> PyResult<Bound<'py, PyAny>> {
494        let client = self.clone();
495
496        pyo3_async_runtimes::tokio::future_into_py(py, async move {
497            client
498                .repay_spot_borrow(&coin, amount)
499                .await
500                .map_err(to_pyvalue_err)?;
501
502            Python::attach(|py| Ok(py.None()))
503        })
504    }
505
506    /// Repays spot borrows for a specific coin, converting other assets if required.
507    ///
508    /// Unlike `Self.repay_spot_borrow`, this uses the venue's manual repay endpoint,
509    /// which may draw on other holdings when the debt coin's spot balance is insufficient.
510    ///
511    /// # Parameters
512    ///
513    /// - `coin`: The coin to repay (e.g., "BTC", "ETH")
514    /// - `amount`: Optional amount to repay. If None, repays all outstanding borrows.
515    ///
516    /// # Errors
517    ///
518    /// Returns an error if:
519    /// - Credentials are missing.
520    /// - The request fails.
521    /// - Called during the hourly interest-calculation window (mm:04:00-mm:05:30 UTC each hour).
522    /// - Insufficient balance for repayment.
523    #[pyo3(name = "repay_spot_borrow_with_conversion")]
524    #[pyo3(signature = (coin, amount=None))]
525    fn py_repay_spot_borrow_with_conversion<'py>(
526        &self,
527        py: Python<'py>,
528        coin: String,
529        amount: Option<Quantity>,
530    ) -> PyResult<Bound<'py, PyAny>> {
531        let client = self.clone();
532
533        pyo3_async_runtimes::tokio::future_into_py(py, async move {
534            client
535                .repay_spot_borrow_with_conversion(&coin, amount)
536                .await
537                .map_err(to_pyvalue_err)?;
538
539            Python::attach(|py| Ok(py.None()))
540        })
541    }
542
543    /// Request instruments for a given product type.
544    ///
545    /// When `base_coin` is provided, the request is narrowed to that base coin.
546    /// This is required for `Option`: Bybit's API returns only `BTC` options when
547    /// `baseCoin` is omitted.
548    ///
549    /// # Errors
550    ///
551    /// Returns an error if the request fails or parsing fails.
552    #[pyo3(name = "request_instruments")]
553    #[pyo3(signature = (product_type, symbol=None, base_coin=None))]
554    fn py_request_instruments<'py>(
555        &self,
556        py: Python<'py>,
557        product_type: BybitProductType,
558        symbol: Option<String>,
559        base_coin: Option<String>,
560    ) -> PyResult<Bound<'py, PyAny>> {
561        let client = self.clone();
562        let base_coin = base_coin.map(|s| Ustr::from(&s));
563
564        pyo3_async_runtimes::tokio::future_into_py(py, async move {
565            let instruments = client
566                .request_instruments(product_type, symbol, base_coin)
567                .await
568                .map_err(to_pyvalue_err)?;
569
570            Python::attach(|py| {
571                let py_instruments: PyResult<Vec<_>> = instruments
572                    .into_iter()
573                    .map(|inst| instrument_any_to_pyobject(py, inst))
574                    .collect();
575                let pylist = PyList::new(py, py_instruments?)?.into_any().unbind();
576                Ok(pylist)
577            })
578        })
579    }
580
581    /// Fetches instrument info and returns the current status of each symbol.
582    ///
583    /// Paginates through the instruments endpoint collecting only
584    /// `(InstrumentId, MarketStatusAction)` pairs. This avoids fee-rate
585    /// fetching and full instrument parsing.
586    ///
587    /// # Errors
588    ///
589    /// Returns an error if the request fails.
590    #[pyo3(name = "request_instrument_statuses")]
591    fn py_request_instrument_statuses<'py>(
592        &self,
593        py: Python<'py>,
594        product_type: BybitProductType,
595    ) -> PyResult<Bound<'py, PyAny>> {
596        let client = self.clone();
597
598        pyo3_async_runtimes::tokio::future_into_py(py, async move {
599            let statuses = client
600                .request_instrument_statuses(product_type)
601                .await
602                .map_err(to_pyvalue_err)?;
603
604            Python::attach(|py| {
605                let dict = PyDict::new(py);
606                for (instrument_id, action) in statuses {
607                    dict.set_item(
608                        instrument_id.into_bound_py_any(py)?,
609                        action.into_bound_py_any(py)?,
610                    )?;
611                }
612                Ok(dict.into_any().unbind())
613            })
614        })
615    }
616
617    /// Request ticker information for market data.
618    ///
619    /// Fetches ticker data from Bybit's `/v5/market/tickers` endpoint and returns
620    /// a unified `BybitTickerData` structure compatible with all product types.
621    ///
622    /// # Errors
623    ///
624    /// Returns an error if the request fails or parsing fails.
625    ///
626    /// # References
627    ///
628    /// <https://bybit-exchange.github.io/docs/v5/market/tickers>
629    #[pyo3(name = "request_tickers")]
630    fn py_request_tickers<'py>(
631        &self,
632        py: Python<'py>,
633        params: crate::python::params::BybitTickersParams,
634    ) -> PyResult<Bound<'py, PyAny>> {
635        let client = self.clone();
636
637        pyo3_async_runtimes::tokio::future_into_py(py, async move {
638            let tickers = client
639                .request_tickers(&params.into())
640                .await
641                .map_err(to_pyvalue_err)?;
642
643            Python::attach(|py| {
644                let py_tickers: PyResult<Vec<_>> = tickers
645                    .into_iter()
646                    .map(|ticker| Py::new(py, ticker))
647                    .collect();
648                let pylist = PyList::new(py, py_tickers?)?.into_any().unbind();
649                Ok(pylist)
650            })
651        })
652    }
653
654    /// Submit a new order.
655    ///
656    /// # Errors
657    ///
658    /// Returns an error if:
659    /// - Credentials are missing.
660    /// - The request fails.
661    /// - Order validation fails.
662    /// - The order is rejected.
663    /// - The API returns an error.
664    #[pyo3(name = "submit_order")]
665    #[pyo3(signature = (
666        account_id,
667        product_type,
668        instrument_id,
669        client_order_id,
670        order_side,
671        order_type,
672        quantity,
673        time_in_force = None,
674        price = None,
675        trigger_price = None,
676        post_only = None,
677        reduce_only = false,
678        is_quote_quantity = false,
679        is_leverage = false,
680        position_idx = None,
681        bbo_side_type = None,
682        bbo_level = None,
683        native_tp_sl = None,
684    ))]
685    #[expect(clippy::too_many_arguments)]
686    fn py_submit_order<'py>(
687        &self,
688        py: Python<'py>,
689        account_id: AccountId,
690        product_type: BybitProductType,
691        instrument_id: InstrumentId,
692        client_order_id: ClientOrderId,
693        order_side: OrderSide,
694        order_type: OrderType,
695        quantity: Quantity,
696        time_in_force: Option<TimeInForce>,
697        price: Option<Price>,
698        trigger_price: Option<Price>,
699        post_only: Option<bool>,
700        reduce_only: bool,
701        is_quote_quantity: bool,
702        is_leverage: bool,
703        position_idx: Option<BybitPositionIdx>,
704        bbo_side_type: Option<String>,
705        bbo_level: Option<String>,
706        native_tp_sl: Option<BybitNativeTpSlParams>,
707    ) -> PyResult<Bound<'py, PyAny>> {
708        let client = self.clone();
709        let bbo_side_type = bbo_side_type
710            .map(|value| parse_bbo_side_type(&value))
711            .transpose()
712            .map_err(to_pyvalue_err)?;
713        let bbo_level = bbo_level
714            .map(parse_bbo_level)
715            .transpose()
716            .map_err(to_pyvalue_err)?;
717        if bbo_side_type.is_some() != bbo_level.is_some() {
718            return Err(to_pyvalue_err(anyhow::anyhow!(
719                "'bbo_side_type' and 'bbo_level' must be provided together"
720            )));
721        }
722
723        let native_tp_sl: Option<RustNativeTpSlParams> = native_tp_sl
724            .map(RustNativeTpSlParams::try_from)
725            .transpose()
726            .map_err(to_pyvalue_err)?;
727
728        pyo3_async_runtimes::tokio::future_into_py(py, async move {
729            let report = client
730                .submit_order(
731                    account_id,
732                    product_type,
733                    instrument_id,
734                    client_order_id,
735                    order_side,
736                    order_type,
737                    quantity,
738                    time_in_force,
739                    price,
740                    trigger_price,
741                    post_only,
742                    reduce_only,
743                    is_quote_quantity,
744                    is_leverage,
745                    position_idx,
746                    bbo_side_type,
747                    bbo_level,
748                    native_tp_sl.as_ref(),
749                )
750                .await
751                .map_err(to_pyvalue_err)?;
752
753            Python::attach(|py| report.into_py_any(py))
754        })
755    }
756
757    /// Modify an existing order.
758    ///
759    /// # Errors
760    ///
761    /// Returns an error if:
762    /// - Credentials are missing.
763    /// - The request fails.
764    /// - The order doesn't exist.
765    /// - The order is already closed.
766    /// - The API returns an error.
767    #[pyo3(name = "modify_order")]
768    #[pyo3(signature = (
769        account_id,
770        product_type,
771        instrument_id,
772        client_order_id=None,
773        venue_order_id=None,
774        quantity=None,
775        price=None
776    ))]
777    #[expect(clippy::too_many_arguments)]
778    fn py_modify_order<'py>(
779        &self,
780        py: Python<'py>,
781        account_id: AccountId,
782        product_type: BybitProductType,
783        instrument_id: InstrumentId,
784        client_order_id: Option<ClientOrderId>,
785        venue_order_id: Option<VenueOrderId>,
786        quantity: Option<Quantity>,
787        price: Option<Price>,
788    ) -> PyResult<Bound<'py, PyAny>> {
789        let client = self.clone();
790
791        pyo3_async_runtimes::tokio::future_into_py(py, async move {
792            let report = client
793                .modify_order(
794                    account_id,
795                    product_type,
796                    instrument_id,
797                    client_order_id,
798                    venue_order_id,
799                    quantity,
800                    price,
801                )
802                .await
803                .map_err(to_pyvalue_err)?;
804
805            Python::attach(|py| report.into_py_any(py))
806        })
807    }
808
809    /// Cancel an order.
810    ///
811    /// # Errors
812    ///
813    /// Returns an error if:
814    /// - Credentials are missing.
815    /// - The request fails.
816    /// - The order doesn't exist.
817    /// - The API returns an error.
818    #[pyo3(name = "cancel_order")]
819    #[pyo3(signature = (account_id, product_type, instrument_id, client_order_id=None, venue_order_id=None))]
820    fn py_cancel_order<'py>(
821        &self,
822        py: Python<'py>,
823        account_id: AccountId,
824        product_type: BybitProductType,
825        instrument_id: InstrumentId,
826        client_order_id: Option<ClientOrderId>,
827        venue_order_id: Option<VenueOrderId>,
828    ) -> PyResult<Bound<'py, PyAny>> {
829        let client = self.clone();
830
831        pyo3_async_runtimes::tokio::future_into_py(py, async move {
832            let report = client
833                .cancel_order(
834                    account_id,
835                    product_type,
836                    instrument_id,
837                    client_order_id,
838                    venue_order_id,
839                )
840                .await
841                .map_err(to_pyvalue_err)?;
842
843            Python::attach(|py| report.into_py_any(py))
844        })
845    }
846
847    /// Cancel all orders for an instrument.
848    ///
849    /// # Errors
850    ///
851    /// Returns an error if:
852    /// - Credentials are missing.
853    /// - The request fails.
854    /// - The API returns an error.
855    #[pyo3(name = "cancel_all_orders")]
856    fn py_cancel_all_orders<'py>(
857        &self,
858        py: Python<'py>,
859        account_id: AccountId,
860        product_type: BybitProductType,
861        instrument_id: InstrumentId,
862    ) -> PyResult<Bound<'py, PyAny>> {
863        let client = self.clone();
864
865        pyo3_async_runtimes::tokio::future_into_py(py, async move {
866            let reports = client
867                .cancel_all_orders(account_id, product_type, instrument_id)
868                .await
869                .map_err(to_pyvalue_err)?;
870
871            Python::attach(|py| {
872                let py_reports: PyResult<Vec<_>> = reports
873                    .into_iter()
874                    .map(|report| report.into_py_any(py))
875                    .collect();
876                let pylist = PyList::new(py, py_reports?)?.into_any().unbind();
877                Ok(pylist)
878            })
879        })
880    }
881
882    /// Query a single order by client order ID or venue order ID.
883    ///
884    /// # Errors
885    ///
886    /// Returns an error if:
887    /// - Credentials are missing.
888    /// - The request fails.
889    /// - The API returns an error.
890    #[pyo3(name = "query_order")]
891    #[pyo3(signature = (account_id, product_type, instrument_id, client_order_id=None, venue_order_id=None))]
892    fn py_query_order<'py>(
893        &self,
894        py: Python<'py>,
895        account_id: AccountId,
896        product_type: BybitProductType,
897        instrument_id: InstrumentId,
898        client_order_id: Option<ClientOrderId>,
899        venue_order_id: Option<VenueOrderId>,
900    ) -> PyResult<Bound<'py, PyAny>> {
901        let client = self.clone();
902
903        pyo3_async_runtimes::tokio::future_into_py(py, async move {
904            match client
905                .query_order(
906                    account_id,
907                    product_type,
908                    instrument_id,
909                    client_order_id,
910                    venue_order_id,
911                )
912                .await
913            {
914                Ok(Some(report)) => Python::attach(|py| report.into_py_any(py)),
915                Ok(None) => Ok(Python::attach(|py| py.None())),
916                Err(e) => Err(to_pyvalue_err(e)),
917            }
918        })
919    }
920
921    /// Request recent trade tick history for a given symbol.
922    ///
923    /// Returns the most recent public trades from Bybit's `/v5/market/recent-trade` endpoint.
924    /// This endpoint only provides recent trades (up to 1000 most recent), typically covering
925    /// only the last few minutes for active markets.
926    ///
927    /// **Note**: For historical trade data with time ranges, use the klines endpoint instead.
928    /// The Bybit public API does not support fetching historical trades by time range.
929    ///
930    /// # Errors
931    ///
932    /// Returns an error if:
933    /// - The instrument is not found in cache.
934    /// - The request fails.
935    /// - Parsing fails.
936    ///
937    /// # References
938    ///
939    /// <https://bybit-exchange.github.io/docs/v5/market/recent-trade>
940    #[pyo3(name = "request_trades")]
941    #[pyo3(signature = (product_type, instrument_id, limit=None))]
942    fn py_request_trades<'py>(
943        &self,
944        py: Python<'py>,
945        product_type: BybitProductType,
946        instrument_id: InstrumentId,
947        limit: Option<u32>,
948    ) -> PyResult<Bound<'py, PyAny>> {
949        let client = self.clone();
950
951        pyo3_async_runtimes::tokio::future_into_py(py, async move {
952            let trades = client
953                .request_trades(product_type, instrument_id, limit)
954                .await
955                .map_err(to_pyvalue_err)?;
956
957            Python::attach(|py| {
958                let py_trades: PyResult<Vec<_>> = trades
959                    .into_iter()
960                    .map(|trade| trade.into_py_any(py))
961                    .collect();
962                let pylist = PyList::new(py, py_trades?)?.into_any().unbind();
963                Ok(pylist)
964            })
965        })
966    }
967
968    /// Request funding rate history for a given symbol.
969    ///
970    /// # Errors
971    ///
972    /// Returns an error if:
973    /// - The instrument is not found in cache.
974    /// - The request fails.
975    /// - Parsing fails.
976    ///
977    /// # References
978    ///
979    /// <https://bybit-exchange.github.io/docs/v5/market/history-fund-rate>
980    #[pyo3(name = "request_funding_rates")]
981    #[pyo3(signature = (product_type, instrument_id, start=None, end=None, limit=None))]
982    fn py_request_funding_rates<'py>(
983        &self,
984        py: Python<'py>,
985        product_type: BybitProductType,
986        instrument_id: InstrumentId,
987        start: Option<Timestamp>,
988        end: Option<Timestamp>,
989        limit: Option<u32>,
990    ) -> PyResult<Bound<'py, PyAny>> {
991        let client = self.clone();
992
993        pyo3_async_runtimes::tokio::future_into_py(py, async move {
994            let funding_rates = client
995                .request_funding_rates(product_type, instrument_id, start, end, limit)
996                .await
997                .map_err(to_pyvalue_err)?;
998
999            Python::attach(|py| {
1000                let py_funding_rates: PyResult<Vec<_>> = funding_rates
1001                    .into_iter()
1002                    .map(|funding_rate| funding_rate.into_py_any(py))
1003                    .collect();
1004                let pylist = PyList::new(py, py_funding_rates?)?.into_any().unbind();
1005                Ok(pylist)
1006            })
1007        })
1008    }
1009
1010    /// Request an orderbook snapshot for a given symbol.
1011    ///
1012    /// Bybit limits the amount of levels (depth) for each product type to:
1013    /// - Spot: `1..=200` (default: `1`)
1014    /// - Linear & Inverse: `1..=500` (default: `25`)
1015    /// - Options: `1..=25` (default: `1`)
1016    ///
1017    /// # Errors
1018    ///
1019    /// Returns an error if:
1020    /// - The instrument is not found in cache.
1021    /// - The request fails.
1022    /// - Parsing fails.
1023    ///
1024    /// # References
1025    ///
1026    /// <https://bybit-exchange.github.io/docs/v5/market/orderbook>
1027    #[pyo3(name = "request_orderbook_snapshot")]
1028    #[pyo3(signature = (product_type, instrument_id, limit=None))]
1029    fn py_request_orderbook_snapshot<'py>(
1030        &self,
1031        py: Python<'py>,
1032        product_type: BybitProductType,
1033        instrument_id: InstrumentId,
1034        limit: Option<u32>,
1035    ) -> PyResult<Bound<'py, PyAny>> {
1036        let client = self.clone();
1037
1038        pyo3_async_runtimes::tokio::future_into_py(py, async move {
1039            let deltas = client
1040                .request_orderbook_snapshot(product_type, instrument_id, limit)
1041                .await
1042                .map_err(to_pyvalue_err)?;
1043
1044            Python::attach(|py| deltas.into_py_any(py))
1045        })
1046    }
1047
1048    /// Request bar/kline history for a given symbol.
1049    ///
1050    /// # Errors
1051    ///
1052    /// Returns an error if:
1053    /// - The instrument is not found in cache.
1054    /// - The request fails.
1055    /// - Parsing fails.
1056    ///
1057    /// # References
1058    ///
1059    /// <https://bybit-exchange.github.io/docs/v5/market/kline>
1060    #[pyo3(name = "request_bars")]
1061    #[pyo3(signature = (product_type, bar_type, start=None, end=None, limit=None, timestamp_on_close=true))]
1062    #[expect(clippy::too_many_arguments)]
1063    fn py_request_bars<'py>(
1064        &self,
1065        py: Python<'py>,
1066        product_type: BybitProductType,
1067        bar_type: BarType,
1068        start: Option<Timestamp>,
1069        end: Option<Timestamp>,
1070        limit: Option<u32>,
1071        timestamp_on_close: bool,
1072    ) -> PyResult<Bound<'py, PyAny>> {
1073        let client = self.clone();
1074
1075        pyo3_async_runtimes::tokio::future_into_py(py, async move {
1076            let bars = client
1077                .request_bars(
1078                    product_type,
1079                    bar_type,
1080                    start,
1081                    end,
1082                    limit,
1083                    timestamp_on_close,
1084                )
1085                .await
1086                .map_err(to_pyvalue_err)?;
1087
1088            Python::attach(|py| {
1089                let py_bars: PyResult<Vec<_>> =
1090                    bars.into_iter().map(|bar| bar.into_py_any(py)).collect();
1091                let pylist = PyList::new(py, py_bars?)?.into_any().unbind();
1092                Ok(pylist)
1093            })
1094        })
1095    }
1096
1097    /// Requests trading fee rates for the specified product type and optional filters.
1098    ///
1099    /// # Errors
1100    ///
1101    /// Returns an error if:
1102    /// - The request fails.
1103    /// - Parsing fails.
1104    ///
1105    /// # References
1106    ///
1107    /// <https://bybit-exchange.github.io/docs/v5/account/fee-rate>
1108    #[pyo3(name = "request_fee_rates")]
1109    #[pyo3(signature = (product_type, symbol=None, base_coin=None))]
1110    fn py_request_fee_rates<'py>(
1111        &self,
1112        py: Python<'py>,
1113        product_type: BybitProductType,
1114        symbol: Option<String>,
1115        base_coin: Option<String>,
1116    ) -> PyResult<Bound<'py, PyAny>> {
1117        let client = self.clone();
1118
1119        pyo3_async_runtimes::tokio::future_into_py(py, async move {
1120            let fee_rates = client
1121                .request_fee_rates(product_type, symbol, base_coin)
1122                .await
1123                .map_err(to_pyvalue_err)?;
1124
1125            Python::attach(|py| {
1126                let py_fee_rates: PyResult<Vec<_>> = fee_rates
1127                    .into_iter()
1128                    .map(|rate| Py::new(py, rate))
1129                    .collect();
1130                let pylist = PyList::new(py, py_fee_rates?)?.into_any().unbind();
1131                Ok(pylist)
1132            })
1133        })
1134    }
1135
1136    /// Requests the current account state for the specified account type.
1137    ///
1138    /// # Errors
1139    ///
1140    /// Returns an error if:
1141    /// - The request fails.
1142    /// - Parsing fails.
1143    ///
1144    /// # References
1145    ///
1146    /// <https://bybit-exchange.github.io/docs/v5/account/wallet-balance>
1147    #[pyo3(name = "request_account_state")]
1148    fn py_request_account_state<'py>(
1149        &self,
1150        py: Python<'py>,
1151        account_type: crate::common::enums::BybitAccountType,
1152        account_id: AccountId,
1153    ) -> PyResult<Bound<'py, PyAny>> {
1154        let client = self.clone();
1155
1156        pyo3_async_runtimes::tokio::future_into_py(py, async move {
1157            let account_state = client
1158                .request_account_state(account_type, account_id)
1159                .await
1160                .map_err(to_pyvalue_err)?;
1161
1162            Python::attach(|py| account_state.into_py_any(py))
1163        })
1164    }
1165
1166    /// Request multiple order status reports.
1167    ///
1168    /// Orders for instruments not currently loaded in cache will be skipped.
1169    ///
1170    /// When `open_only` is true the realtime endpoint is queried for currently
1171    /// open orders and again for recently closed orders, so terminal reports
1172    /// are included. The closed pass fetches the most recent page only and is
1173    /// not constrained by `start` or `end`.
1174    ///
1175    /// # Errors
1176    ///
1177    /// Returns an error if:
1178    /// - Credentials are missing.
1179    /// - The request fails.
1180    /// - The API returns an error.
1181    #[pyo3(name = "request_order_status_reports")]
1182    #[pyo3(signature = (account_id, product_type, instrument_id=None, open_only=false, start=None, end=None, limit=None))]
1183    #[expect(clippy::too_many_arguments)]
1184    fn py_request_order_status_reports<'py>(
1185        &self,
1186        py: Python<'py>,
1187        account_id: AccountId,
1188        product_type: BybitProductType,
1189        instrument_id: Option<InstrumentId>,
1190        open_only: bool,
1191        start: Option<Timestamp>,
1192        end: Option<Timestamp>,
1193        limit: Option<u32>,
1194    ) -> PyResult<Bound<'py, PyAny>> {
1195        let client = self.clone();
1196
1197        pyo3_async_runtimes::tokio::future_into_py(py, async move {
1198            let reports = client
1199                .request_order_status_reports(
1200                    account_id,
1201                    product_type,
1202                    instrument_id,
1203                    open_only,
1204                    start,
1205                    end,
1206                    limit,
1207                )
1208                .await
1209                .map_err(to_pyvalue_err)?;
1210
1211            Python::attach(|py| {
1212                let py_reports: PyResult<Vec<_>> = reports
1213                    .into_iter()
1214                    .map(|report| report.into_py_any(py))
1215                    .collect();
1216                let pylist = PyList::new(py, py_reports?)?.into_any().unbind();
1217                Ok(pylist)
1218            })
1219        })
1220    }
1221
1222    /// Fetches execution history (fills) for the account and returns a list of `FillReport`s.
1223    ///
1224    /// Executions for instruments not currently loaded in cache will be skipped.
1225    ///
1226    /// # Errors
1227    ///
1228    /// This function returns an error if the request fails.
1229    ///
1230    /// # References
1231    ///
1232    /// <https://bybit-exchange.github.io/docs/v5/order/execution>
1233    #[pyo3(name = "request_fill_reports")]
1234    #[pyo3(signature = (account_id, product_type, instrument_id=None, start=None, end=None, limit=None))]
1235    #[expect(clippy::too_many_arguments)]
1236    fn py_request_fill_reports<'py>(
1237        &self,
1238        py: Python<'py>,
1239        account_id: AccountId,
1240        product_type: BybitProductType,
1241        instrument_id: Option<InstrumentId>,
1242        start: Option<i64>,
1243        end: Option<i64>,
1244        limit: Option<u32>,
1245    ) -> PyResult<Bound<'py, PyAny>> {
1246        let client = self.clone();
1247
1248        pyo3_async_runtimes::tokio::future_into_py(py, async move {
1249            let reports = client
1250                .request_fill_reports(account_id, product_type, instrument_id, start, end, limit)
1251                .await
1252                .map_err(to_pyvalue_err)?;
1253
1254            Python::attach(|py| {
1255                let py_reports: PyResult<Vec<_>> = reports
1256                    .into_iter()
1257                    .map(|report| report.into_py_any(py))
1258                    .collect();
1259                let pylist = PyList::new(py, py_reports?)?.into_any().unbind();
1260                Ok(pylist)
1261            })
1262        })
1263    }
1264
1265    /// Fetches position information for the account and returns a list of `PositionStatusReport`s.
1266    ///
1267    /// Positions for instruments not currently loaded in cache will be skipped.
1268    ///
1269    /// # Errors
1270    ///
1271    /// This function returns an error if the request fails, or if SPOT position reports are enabled
1272    /// and no instrument is specified, because wallet balances carry no pair identity.
1273    ///
1274    /// # References
1275    ///
1276    /// <https://bybit-exchange.github.io/docs/v5/position>
1277    #[pyo3(name = "request_position_status_reports")]
1278    #[pyo3(signature = (account_id, product_type, instrument_id=None))]
1279    fn py_request_position_status_reports<'py>(
1280        &self,
1281        py: Python<'py>,
1282        account_id: AccountId,
1283        product_type: BybitProductType,
1284        instrument_id: Option<InstrumentId>,
1285    ) -> PyResult<Bound<'py, PyAny>> {
1286        let client = self.clone();
1287
1288        pyo3_async_runtimes::tokio::future_into_py(py, async move {
1289            let reports = client
1290                .request_position_status_reports(account_id, product_type, instrument_id)
1291                .await
1292                .map_err(to_pyvalue_err)?;
1293
1294            Python::attach(|py| {
1295                let py_reports: PyResult<Vec<_>> = reports
1296                    .into_iter()
1297                    .map(|report| report.into_py_any(py))
1298                    .collect();
1299                let pylist = PyList::new(py, py_reports?)?.into_any().unbind();
1300                Ok(pylist)
1301            })
1302        })
1303    }
1304
1305    /// Request forward prices for option chain ATM determination.
1306    ///
1307    /// Single-instrument path (1 HTTP call) if `instrument_id` is provided,
1308    /// otherwise bulk path via option tickers.
1309    #[pyo3(name = "request_forward_prices")]
1310    #[pyo3(signature = (base_coin, instrument_id=None))]
1311    fn py_request_forward_prices<'py>(
1312        &self,
1313        py: Python<'py>,
1314        base_coin: String,
1315        instrument_id: Option<InstrumentId>,
1316    ) -> PyResult<Bound<'py, PyAny>> {
1317        let client = self.clone();
1318
1319        pyo3_async_runtimes::tokio::future_into_py(py, async move {
1320            let forward_prices: Vec<ForwardPrice> = if let Some(inst_id) = instrument_id {
1321                // Single-instrument path: fetch ticker for one symbol
1322                let raw_symbol = extract_raw_symbol(inst_id.symbol.as_str()).to_string();
1323                let params = crate::http::query::BybitTickersParams {
1324                    category: BybitProductType::Option,
1325                    symbol: Some(raw_symbol),
1326                    base_coin: None,
1327                    exp_date: None,
1328                };
1329                let tickers = client
1330                    .request_option_tickers_raw_with_params(&params)
1331                    .await
1332                    .map_err(to_pyvalue_err)?;
1333
1334                let ts = UnixNanos::default();
1335                tickers
1336                    .into_iter()
1337                    .filter_map(|t| {
1338                        let up: rust_decimal::Decimal = t.underlying_price.parse().ok()?;
1339                        if up.is_zero() {
1340                            return None;
1341                        }
1342                        Some(ForwardPrice::new(inst_id, up, None, ts, ts))
1343                    })
1344                    .collect()
1345            } else {
1346                // Bulk path: fetch all option tickers for base coin
1347                let tickers = client
1348                    .request_option_tickers_raw(&base_coin)
1349                    .await
1350                    .map_err(to_pyvalue_err)?;
1351
1352                let ts = nautilus_core::UnixNanos::default();
1353                let mut seen_expiries = HashSet::new();
1354                tickers
1355                    .into_iter()
1356                    .filter_map(|t| {
1357                        let up: rust_decimal::Decimal = t.underlying_price.parse().ok()?;
1358                        if up.is_zero() {
1359                            return None;
1360                        }
1361                        let parts: Vec<&str> = t.symbol.splitn(3, '-').collect();
1362                        let expiry_key = if parts.len() >= 2 {
1363                            format!("{}-{}", parts[0], parts[1])
1364                        } else {
1365                            t.symbol.to_string()
1366                        };
1367
1368                        if !seen_expiries.insert(expiry_key) {
1369                            return None;
1370                        }
1371                        let symbol_str = format!("{}-OPTION", t.symbol);
1372                        let inst_id = InstrumentId::new(
1373                            Symbol::new(&symbol_str),
1374                            *crate::common::consts::BYBIT_VENUE,
1375                        );
1376                        Some(ForwardPrice::new(inst_id, up, None, ts, ts))
1377                    })
1378                    .collect()
1379            };
1380
1381            Python::attach(|py| {
1382                let py_prices: PyResult<Vec<_>> = forward_prices
1383                    .into_iter()
1384                    .map(|fp| Py::new(py, fp))
1385                    .collect();
1386                let pylist = PyList::new(py, py_prices?)?.into_any().unbind();
1387                Ok(pylist)
1388            })
1389        })
1390    }
1391}
1392
1393impl From<BybitHttpError> for PyErr {
1394    fn from(error: BybitHttpError) -> Self {
1395        match error {
1396            // Runtime/operational errors
1397            BybitHttpError::Canceled(msg) => to_pyruntime_err(format!("Request canceled: {msg}")),
1398            BybitHttpError::NetworkError(msg) => to_pyruntime_err(format!("Network error: {msg}")),
1399            BybitHttpError::UnexpectedStatus { status, body } => {
1400                to_pyruntime_err(format!("Unexpected HTTP status code {status}: {body}"))
1401            }
1402            // Validation/configuration errors
1403            BybitHttpError::MissingCredentials => {
1404                to_pyvalue_err("Missing credentials for authenticated request")
1405            }
1406            BybitHttpError::ValidationError(msg) => {
1407                to_pyvalue_err(format!("Parameter validation error: {msg}"))
1408            }
1409            BybitHttpError::JsonError(msg) => to_pyvalue_err(format!("JSON error: {msg}")),
1410            BybitHttpError::BuildError(e) => to_pyvalue_err(format!("Build error: {e}")),
1411            BybitHttpError::BybitError {
1412                error_code,
1413                message,
1414            } => to_pyvalue_err(format!("Bybit error {error_code}: {message}")),
1415        }
1416    }
1417}