Skip to main content

nautilus_kraken/python/
http_spot.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 Kraken Spot HTTP client.
17
18use jiff::Timestamp;
19use nautilus_core::{
20    nanos::UnixNanos,
21    python::{to_pyruntime_err, to_pyvalue_err},
22};
23use nautilus_model::{
24    data::BarType,
25    enums::{AccountType, OrderSide, OrderType, TimeInForce, TriggerType},
26    identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
27    python::instruments::{instrument_any_to_pyobject, pyobject_to_instrument_any},
28    types::{Price, Quantity},
29};
30use pyo3::{
31    conversion::IntoPyObjectExt,
32    prelude::*,
33    types::{PyDict, PyList},
34};
35use rust_decimal::Decimal;
36use ustr::Ustr;
37
38use crate::{
39    common::{credential::KrakenCredential, enums::KrakenEnvironment},
40    http::KrakenSpotHttpClient,
41};
42
43#[pymethods]
44#[pyo3_stub_gen::derive::gen_stub_pymethods]
45impl KrakenSpotHttpClient {
46    /// High-level HTTP client for the Kraken Spot REST API.
47    ///
48    /// This client wraps the raw client and provides Nautilus domain types.
49    /// It maintains an instrument cache and uses it to parse venue responses
50    /// into Nautilus domain objects.
51    #[new]
52    #[pyo3(signature = (api_key=None, api_secret=None, base_url=None, timeout_secs=60, max_retries=None, retry_delay_ms=None, retry_delay_max_ms=None, proxy_url=None, max_requests_per_second=5))]
53    #[expect(clippy::too_many_arguments)]
54    fn py_new(
55        api_key: Option<String>,
56        api_secret: Option<String>,
57        base_url: Option<String>,
58        timeout_secs: u64,
59        max_retries: Option<u32>,
60        retry_delay_ms: Option<u64>,
61        retry_delay_max_ms: Option<u64>,
62        proxy_url: Option<String>,
63        max_requests_per_second: u32,
64    ) -> PyResult<Self> {
65        let environment = KrakenEnvironment::Live;
66
67        if let Some(cred) = KrakenCredential::resolve_spot(api_key, api_secret) {
68            let (k, s) = cred.into_parts();
69            Self::with_credentials(
70                k,
71                s,
72                environment,
73                base_url,
74                timeout_secs,
75                max_retries,
76                retry_delay_ms,
77                retry_delay_max_ms,
78                proxy_url,
79                max_requests_per_second,
80            )
81            .map_err(to_pyvalue_err)
82        } else {
83            Self::new(
84                environment,
85                base_url,
86                timeout_secs,
87                max_retries,
88                retry_delay_ms,
89                retry_delay_max_ms,
90                proxy_url,
91                max_requests_per_second,
92            )
93            .map_err(to_pyvalue_err)
94        }
95    }
96
97    #[getter]
98    #[pyo3(name = "base_url")]
99    #[must_use]
100    pub fn py_base_url(&self) -> String {
101        self.inner.base_url().to_string()
102    }
103
104    #[getter]
105    #[pyo3(name = "api_key")]
106    #[must_use]
107    pub fn py_api_key(&self) -> Option<&str> {
108        self.inner.credential().map(|c| c.api_key())
109    }
110
111    #[getter]
112    #[pyo3(name = "api_key_masked")]
113    #[must_use]
114    pub fn py_api_key_masked(&self) -> Option<String> {
115        self.inner.credential().map(|c| c.api_key_masked())
116    }
117
118    /// Caches an instrument for symbol lookup.
119    #[pyo3(name = "cache_instrument")]
120    fn py_cache_instrument(&self, py: Python, instrument: Py<PyAny>) -> PyResult<()> {
121        let inst_any = pyobject_to_instrument_any(py, instrument)?;
122        self.cache_instrument(inst_any);
123        Ok(())
124    }
125
126    /// Cancels all pending HTTP requests.
127    #[pyo3(name = "cancel_all_requests")]
128    fn py_cancel_all_requests(&self) {
129        self.cancel_all_requests();
130    }
131
132    #[pyo3(name = "get_server_time")]
133    fn py_get_server_time<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
134        let client = self.clone();
135
136        pyo3_async_runtimes::tokio::future_into_py(py, async move {
137            let server_time = client
138                .inner
139                .get_server_time()
140                .await
141                .map_err(to_pyruntime_err)?;
142
143            let json_string = serde_json::to_string(&server_time)
144                .map_err(|e| to_pyruntime_err(format!("Failed to serialize response: {e}")))?;
145
146            Ok(json_string)
147        })
148    }
149
150    /// Requests tradable instruments from Kraken.
151    ///
152    /// When `pairs` is `None` (loading all), also fetches tokenized asset pairs
153    /// (xStocks) and merges them with the default currency pairs.
154    #[pyo3(name = "request_instruments")]
155    #[pyo3(signature = (pairs=None))]
156    fn py_request_instruments<'py>(
157        &self,
158        py: Python<'py>,
159        pairs: Option<Vec<String>>,
160    ) -> PyResult<Bound<'py, PyAny>> {
161        let client = self.clone();
162
163        pyo3_async_runtimes::tokio::future_into_py(py, async move {
164            let instruments = client
165                .request_instruments(pairs)
166                .await
167                .map_err(to_pyruntime_err)?;
168
169            Python::attach(|py| {
170                let py_instruments: PyResult<Vec<_>> = instruments
171                    .into_iter()
172                    .map(|inst| instrument_any_to_pyobject(py, inst))
173                    .collect();
174                let pylist = PyList::new(py, py_instruments?)?;
175                Ok(pylist.unbind())
176            })
177        })
178    }
179
180    /// Requests the current market status for Kraken Spot instruments.
181    ///
182    /// Fetches both regular and tokenized asset pairs. The call returns an error if
183    /// either fetch fails so callers can avoid emitting partial snapshots that would
184    /// otherwise cause the missing tokenized symbols to be diffed as removed.
185    #[pyo3(name = "request_instrument_statuses")]
186    #[pyo3(signature = (pairs=None))]
187    fn py_request_instrument_statuses<'py>(
188        &self,
189        py: Python<'py>,
190        pairs: Option<Vec<String>>,
191    ) -> PyResult<Bound<'py, PyAny>> {
192        let client = self.clone();
193
194        pyo3_async_runtimes::tokio::future_into_py(py, async move {
195            let statuses = client
196                .request_instrument_statuses(pairs)
197                .await
198                .map_err(to_pyruntime_err)?;
199
200            Python::attach(|py| {
201                let dict = PyDict::new(py);
202                for (instrument_id, action) in statuses {
203                    dict.set_item(
204                        instrument_id.into_bound_py_any(py)?,
205                        action.into_bound_py_any(py)?,
206                    )?;
207                }
208                Ok(dict.into_any().unbind())
209            })
210        })
211    }
212
213    /// Requests historical trades for an instrument.
214    #[pyo3(name = "request_trades")]
215    #[pyo3(signature = (instrument_id, start=None, end=None, limit=None))]
216    fn py_request_trades<'py>(
217        &self,
218        py: Python<'py>,
219        instrument_id: InstrumentId,
220        start: Option<Timestamp>,
221        end: Option<Timestamp>,
222        limit: Option<u64>,
223    ) -> PyResult<Bound<'py, PyAny>> {
224        let client = self.clone();
225
226        pyo3_async_runtimes::tokio::future_into_py(py, async move {
227            let trades = client
228                .request_trades(instrument_id, start, end, limit)
229                .await
230                .map_err(to_pyruntime_err)?;
231
232            Python::attach(|py| {
233                let py_trades: PyResult<Vec<_>> = trades
234                    .into_iter()
235                    .map(|trade| trade.into_py_any(py))
236                    .collect();
237                let pylist = PyList::new(py, py_trades?)?.into_any().unbind();
238                Ok(pylist)
239            })
240        })
241    }
242
243    /// Requests an order book snapshot for an instrument.
244    #[pyo3(name = "request_book_snapshot")]
245    #[pyo3(signature = (instrument_id, depth=None))]
246    fn py_request_book_snapshot<'py>(
247        &self,
248        py: Python<'py>,
249        instrument_id: InstrumentId,
250        depth: Option<u32>,
251    ) -> PyResult<Bound<'py, PyAny>> {
252        let client = self.clone();
253
254        pyo3_async_runtimes::tokio::future_into_py(py, async move {
255            let book = client
256                .request_book_snapshot(instrument_id, depth)
257                .await
258                .map_err(to_pyruntime_err)?;
259
260            Python::attach(|py| book.into_py_any(py))
261        })
262    }
263
264    /// Requests historical bars/OHLC data for an instrument.
265    #[pyo3(name = "request_bars")]
266    #[pyo3(signature = (bar_type, start=None, end=None, limit=None))]
267    fn py_request_bars<'py>(
268        &self,
269        py: Python<'py>,
270        bar_type: BarType,
271        start: Option<Timestamp>,
272        end: Option<Timestamp>,
273        limit: Option<u64>,
274    ) -> PyResult<Bound<'py, PyAny>> {
275        let client = self.clone();
276
277        pyo3_async_runtimes::tokio::future_into_py(py, async move {
278            let bars = client
279                .request_bars(bar_type, start, end, limit)
280                .await
281                .map_err(to_pyruntime_err)?;
282
283            Python::attach(|py| {
284                let py_bars: PyResult<Vec<_>> =
285                    bars.into_iter().map(|bar| bar.into_py_any(py)).collect();
286                let pylist = PyList::new(py, py_bars?)?.into_any().unbind();
287                Ok(pylist)
288            })
289        })
290    }
291
292    /// Requests account state (balances) from Kraken.
293    ///
294    /// In cash mode returns wallet balances only.
295    /// In margin mode additionally calls `TradeBalance` to build `MarginBalance` entries.
296    /// `margin_balance_asset` selects the summary-display denomination for `TradeBalance`
297    /// (e.g. `"ZUSD"`, `"ZGBP"`); `None` lets Kraken default to `ZUSD`.
298    ///
299    /// Callers that also need the `TradeBalance` metrics dictionary should use
300    /// `Self.request_account_state_with_metrics` to avoid issuing two `TradeBalance`
301    /// HTTP requests per account update.
302    #[pyo3(name = "request_account_state")]
303    #[pyo3(signature = (account_id, account_type = AccountType::Cash, margin_balance_asset = None))]
304    fn py_request_account_state<'py>(
305        &self,
306        py: Python<'py>,
307        account_id: AccountId,
308        account_type: AccountType,
309        margin_balance_asset: Option<String>,
310    ) -> PyResult<Bound<'py, PyAny>> {
311        let client = self.clone();
312
313        pyo3_async_runtimes::tokio::future_into_py(py, async move {
314            let account_state = client
315                .request_account_state(account_id, account_type, margin_balance_asset.as_deref())
316                .await
317                .map_err(to_pyruntime_err)?;
318
319            Python::attach(|py| account_state.into_pyobject(py).map(|o| o.unbind()))
320        })
321    }
322
323    /// Returns a flattened snapshot of Kraken's `TradeBalance` margin metrics.
324    ///
325    /// Caller is expected to invoke this only when operating in margin mode; consumers
326    /// surface the values via `AccountState.info` (Python-side) for strategy access.
327    /// Strings preserve venue precision exactly. Keys: `equivalent_balance`,
328    /// `trade_balance`, `used_margin`, `unexecuted_value`, `unrealized_pnl`,
329    /// `cost_basis`, `valuation`, `equity`, `free_margin`, `margin_level` (omitted
330    /// when Kraken returns no value, i.e. no open positions), `asset`.
331    ///
332    /// When the metrics are needed alongside the `AccountState`, prefer
333    /// `Self.request_account_state_with_metrics` to share a single `TradeBalance`
334    /// HTTP request between both.
335    #[pyo3(name = "request_margin_metrics")]
336    #[pyo3(signature = (asset = None))]
337    fn py_request_margin_metrics<'py>(
338        &self,
339        py: Python<'py>,
340        asset: Option<String>,
341    ) -> PyResult<Bound<'py, PyAny>> {
342        let client = self.clone();
343
344        pyo3_async_runtimes::tokio::future_into_py(py, async move {
345            let metrics = client
346                .request_margin_metrics(asset.as_deref())
347                .await
348                .map_err(to_pyruntime_err)?;
349
350            Python::attach(|py| {
351                let dict = pyo3::types::PyDict::new(py);
352                for (k, v) in metrics {
353                    dict.set_item(k, v)?;
354                }
355                Ok::<_, PyErr>(dict.unbind().into_any())
356            })
357        })
358    }
359
360    /// Requests the full margin account snapshot in a single round-trip.
361    ///
362    /// Returns the `AccountState` (including `MarginBalance` entries when
363    /// `account_type` is `Margin`) and the `TradeBalance` metrics dictionary that
364    /// callers attach to `AccountState.info`. In cash mode the metrics map is empty
365    /// and `TradeBalance` is not called.
366    ///
367    /// In margin mode, replaces the raw `margin_balance_asset` wallet with a
368    /// synthetic `AccountBalance` using `total = e` and `free = mf` from
369    /// `TradeBalance`. Kraken reports these values across all collateral, which
370    /// avoids clamping free margin to one wallet bucket in multi-asset accounts.
371    ///
372    /// Wallet balances come from `BalanceEx`, whose per-asset `hold_trade` gives the amount
373    /// Kraken has reserved against resting orders and populates `locked`. Net credit
374    /// (`credit - credit_used`, returned only for credit-line accounts) is included in `total`,
375    /// so `free` derives to Kraken's available balance of
376    /// `balance + credit - credit_used - hold_trade`.
377    ///
378    /// The single shared fetch keeps Kraken rate-limit usage symmetric with `BalanceEx`
379    /// (one request per account update), instead of two as if `request_account_state`
380    /// and `request_margin_metrics` were called in sequence.
381    #[pyo3(name = "request_account_state_with_metrics")]
382    #[pyo3(signature = (account_id, account_type = AccountType::Cash, margin_balance_asset = None))]
383    fn py_request_account_state_with_metrics<'py>(
384        &self,
385        py: Python<'py>,
386        account_id: AccountId,
387        account_type: AccountType,
388        margin_balance_asset: Option<String>,
389    ) -> PyResult<Bound<'py, PyAny>> {
390        let client = self.clone();
391
392        pyo3_async_runtimes::tokio::future_into_py(py, async move {
393            let (account_state, metrics) = client
394                .request_account_state_with_metrics(
395                    account_id,
396                    account_type,
397                    margin_balance_asset.as_deref(),
398                )
399                .await
400                .map_err(to_pyruntime_err)?;
401
402            Python::attach(|py| {
403                let state_obj = account_state.into_pyobject(py)?.unbind();
404                let dict = pyo3::types::PyDict::new(py);
405                for (k, v) in metrics {
406                    dict.set_item(k, v)?;
407                }
408                let tuple = pyo3::types::PyTuple::new(
409                    py,
410                    [state_obj.into_any(), dict.unbind().into_any()],
411                )?;
412                Ok::<_, PyErr>(tuple.unbind().into_any())
413            })
414        })
415    }
416
417    /// Requests order status reports from Kraken.
418    #[pyo3(name = "request_order_status_reports")]
419    #[pyo3(signature = (account_id, instrument_id=None, start=None, end=None, open_only=false))]
420    fn py_request_order_status_reports<'py>(
421        &self,
422        py: Python<'py>,
423        account_id: AccountId,
424        instrument_id: Option<InstrumentId>,
425        start: Option<Timestamp>,
426        end: Option<Timestamp>,
427        open_only: bool,
428    ) -> PyResult<Bound<'py, PyAny>> {
429        let client = self.clone();
430
431        pyo3_async_runtimes::tokio::future_into_py(py, async move {
432            let reports = client
433                .request_order_status_reports(account_id, instrument_id, start, end, open_only)
434                .await
435                .map_err(to_pyruntime_err)?;
436
437            Python::attach(|py| {
438                let py_reports: PyResult<Vec<_>> = reports
439                    .into_iter()
440                    .map(|report| report.into_py_any(py))
441                    .collect();
442                let pylist = PyList::new(py, py_reports?)?.into_any().unbind();
443                Ok(pylist)
444            })
445        })
446    }
447
448    /// Requests fill/trade reports from Kraken.
449    #[pyo3(name = "request_fill_reports")]
450    #[pyo3(signature = (account_id, instrument_id=None, start=None, end=None))]
451    fn py_request_fill_reports<'py>(
452        &self,
453        py: Python<'py>,
454        account_id: AccountId,
455        instrument_id: Option<InstrumentId>,
456        start: Option<Timestamp>,
457        end: Option<Timestamp>,
458    ) -> PyResult<Bound<'py, PyAny>> {
459        let client = self.clone();
460
461        pyo3_async_runtimes::tokio::future_into_py(py, async move {
462            let reports = client
463                .request_fill_reports(account_id, instrument_id, start, end)
464                .await
465                .map_err(to_pyruntime_err)?;
466
467            Python::attach(|py| {
468                let py_reports: PyResult<Vec<_>> = reports
469                    .into_iter()
470                    .map(|report| report.into_py_any(py))
471                    .collect();
472                let pylist = PyList::new(py, py_reports?)?.into_any().unbind();
473                Ok(pylist)
474            })
475        })
476    }
477
478    /// Requests position status reports for SPOT instruments.
479    ///
480    /// In margin mode: calls `OpenPositions` and returns reports for each open leveraged position.
481    /// When `use_spot_position_reports` is enabled (cash mode): derives reports from wallet balances.
482    /// Otherwise returns an empty vector.
483    #[pyo3(name = "request_position_status_reports")]
484    #[pyo3(signature = (account_id, instrument_id=None, account_type=AccountType::Cash, use_spot_position_reports=false, quote_currency="USDT"))]
485    fn py_request_position_status_reports<'py>(
486        &self,
487        py: Python<'py>,
488        account_id: AccountId,
489        instrument_id: Option<InstrumentId>,
490        account_type: AccountType,
491        use_spot_position_reports: bool,
492        quote_currency: &str,
493    ) -> PyResult<Bound<'py, PyAny>> {
494        let client = self.clone();
495        let quote_currency = Ustr::from(quote_currency);
496
497        pyo3_async_runtimes::tokio::future_into_py(py, async move {
498            let reports = client
499                .request_position_status_reports(
500                    account_id,
501                    instrument_id,
502                    account_type,
503                    use_spot_position_reports,
504                    quote_currency,
505                )
506                .await
507                .map_err(to_pyruntime_err)?;
508
509            Python::attach(|py| {
510                let py_reports: PyResult<Vec<_>> = reports
511                    .into_iter()
512                    .map(|report| report.into_py_any(py))
513                    .collect();
514                let pylist = PyList::new(py, py_reports?)?.into_any().unbind();
515                Ok(pylist)
516            })
517        })
518    }
519
520    /// Submits a new order to the Kraken Spot exchange.
521    ///
522    /// Returns the venue order ID on success. WebSocket handles all execution events.
523    ///
524    /// # Errors
525    ///
526    /// Returns an error if:
527    /// - Credentials are missing.
528    /// - The instrument is not found in cache.
529    /// - The order type or time in force is not supported.
530    /// - The request fails.
531    /// - The order is rejected.
532    #[pyo3(name = "submit_order")]
533    #[pyo3(signature = (account_id, instrument_id, client_order_id, order_side, order_type, quantity, time_in_force, expire_time=None, price=None, trigger_price=None, trigger_type=None, trailing_offset=None, limit_offset=None, reduce_only=false, post_only=false, quote_quantity=false, display_qty=None, leverage=None, account_type=AccountType::Cash))]
534    #[expect(clippy::too_many_arguments)]
535    fn py_submit_order<'py>(
536        &self,
537        py: Python<'py>,
538        account_id: AccountId,
539        instrument_id: InstrumentId,
540        client_order_id: ClientOrderId,
541        order_side: OrderSide,
542        order_type: OrderType,
543        quantity: Quantity,
544        time_in_force: TimeInForce,
545        expire_time: Option<u64>,
546        price: Option<Price>,
547        trigger_price: Option<Price>,
548        trigger_type: Option<TriggerType>,
549        trailing_offset: Option<String>,
550        limit_offset: Option<String>,
551        reduce_only: bool,
552        post_only: bool,
553        quote_quantity: bool,
554        display_qty: Option<Quantity>,
555        leverage: Option<u16>,
556        account_type: AccountType,
557    ) -> PyResult<Bound<'py, PyAny>> {
558        let client = self.clone();
559        let expire_time = expire_time.map(UnixNanos::from);
560        let trailing_offset = trailing_offset
561            .map(|s| {
562                Decimal::from_str_exact(&s)
563                    .map_err(|e| to_pyvalue_err(format!("invalid trailing_offset: {e}")))
564            })
565            .transpose()?;
566        let limit_offset = limit_offset
567            .map(|s| {
568                Decimal::from_str_exact(&s)
569                    .map_err(|e| to_pyvalue_err(format!("invalid limit_offset: {e}")))
570            })
571            .transpose()?;
572
573        pyo3_async_runtimes::tokio::future_into_py(py, async move {
574            let venue_order_id = client
575                .submit_order(
576                    account_id,
577                    instrument_id,
578                    client_order_id,
579                    order_side,
580                    order_type,
581                    quantity,
582                    time_in_force,
583                    expire_time,
584                    price,
585                    trigger_price,
586                    trigger_type,
587                    trailing_offset,
588                    limit_offset,
589                    reduce_only,
590                    post_only,
591                    quote_quantity,
592                    display_qty,
593                    leverage,
594                    account_type,
595                )
596                .await
597                .map_err(to_pyruntime_err)?;
598
599            Python::attach(|py| venue_order_id.into_pyobject(py).map(|o| o.unbind()))
600        })
601    }
602
603    /// Cancels an order on the Kraken Spot exchange.
604    ///
605    /// # Errors
606    ///
607    /// Returns an error if:
608    /// - Credentials are missing.
609    /// - Neither client_order_id nor venue_order_id is provided.
610    /// - The request fails.
611    /// - The order cancellation is rejected.
612    #[pyo3(name = "cancel_order")]
613    #[pyo3(signature = (account_id, instrument_id, client_order_id=None, venue_order_id=None))]
614    fn py_cancel_order<'py>(
615        &self,
616        py: Python<'py>,
617        account_id: AccountId,
618        instrument_id: InstrumentId,
619        client_order_id: Option<ClientOrderId>,
620        venue_order_id: Option<VenueOrderId>,
621    ) -> PyResult<Bound<'py, PyAny>> {
622        let client = self.clone();
623
624        pyo3_async_runtimes::tokio::future_into_py(py, async move {
625            client
626                .cancel_order(account_id, instrument_id, client_order_id, venue_order_id)
627                .await
628                .map_err(to_pyruntime_err)
629        })
630    }
631
632    #[pyo3(name = "cancel_all_orders")]
633    fn py_cancel_all_orders<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
634        let client = self.clone();
635
636        pyo3_async_runtimes::tokio::future_into_py(py, async move {
637            let response = client
638                .inner
639                .cancel_all_orders()
640                .await
641                .map_err(to_pyruntime_err)?;
642
643            Ok(response.count)
644        })
645    }
646
647    /// Cancels multiple orders on the Kraken Spot exchange (batched, max 50 per request).
648    #[pyo3(name = "cancel_orders_batch")]
649    fn py_cancel_orders_batch<'py>(
650        &self,
651        py: Python<'py>,
652        venue_order_ids: Vec<VenueOrderId>,
653    ) -> PyResult<Bound<'py, PyAny>> {
654        let client = self.clone();
655
656        pyo3_async_runtimes::tokio::future_into_py(py, async move {
657            client
658                .cancel_orders_batch(venue_order_ids)
659                .await
660                .map_err(to_pyruntime_err)
661        })
662    }
663
664    /// Modifies an existing order on the Kraken Spot exchange using atomic amend.
665    ///
666    /// Uses the AmendOrder endpoint which modifies the order in-place,
667    /// keeping the same order ID and queue position.
668    ///
669    /// # Errors
670    ///
671    /// Returns an error if:
672    /// - Neither `client_order_id` nor `venue_order_id` is provided.
673    /// - The instrument is not found in cache.
674    /// - The request fails.
675    #[pyo3(name = "modify_order")]
676    #[pyo3(signature = (instrument_id, client_order_id=None, venue_order_id=None, quantity=None, price=None, trigger_price=None))]
677    #[expect(clippy::too_many_arguments)]
678    fn py_modify_order<'py>(
679        &self,
680        py: Python<'py>,
681        instrument_id: InstrumentId,
682        client_order_id: Option<ClientOrderId>,
683        venue_order_id: Option<VenueOrderId>,
684        quantity: Option<Quantity>,
685        price: Option<Price>,
686        trigger_price: Option<Price>,
687    ) -> PyResult<Bound<'py, PyAny>> {
688        let client = self.clone();
689
690        pyo3_async_runtimes::tokio::future_into_py(py, async move {
691            let new_venue_order_id = client
692                .modify_order(
693                    instrument_id,
694                    client_order_id,
695                    venue_order_id,
696                    quantity,
697                    price,
698                    trigger_price,
699                )
700                .await
701                .map_err(to_pyruntime_err)?;
702
703            Python::attach(|py| new_venue_order_id.into_pyobject(py).map(|o| o.unbind()))
704        })
705    }
706}
707
708// Separate block to avoid pyo3_stub_gen trait bound issues with batch-order tuples.
709// These methods are registered in DEFERRED_RUNTIME_METHODS until the generator
710// supports complex tuple parameter types.
711#[pymethods]
712impl KrakenSpotHttpClient {
713    /// Submits multiple orders to the Kraken Spot exchange.
714    ///
715    /// Automatically groups orders by pair and chunks batch requests at the venue
716    /// limit. Single-order groups fall back to `AddOrder`.
717    #[pyo3(name = "submit_orders_batch", signature = (orders, leverage=None, account_type=AccountType::Cash, per_order_leverages=None, per_order_reduce_only=None))]
718    #[expect(clippy::type_complexity)]
719    fn py_submit_orders_batch<'py>(
720        &self,
721        py: Python<'py>,
722        orders: Vec<(
723            InstrumentId,
724            ClientOrderId,
725            OrderSide,
726            OrderType,
727            Quantity,
728            TimeInForce,
729            Option<Price>,
730            Option<Price>,
731            Option<TriggerType>,
732            bool,
733            bool,
734            Option<Quantity>,
735        )>,
736        leverage: Option<u16>,
737        account_type: AccountType,
738        per_order_leverages: Option<Vec<Option<u16>>>,
739        per_order_reduce_only: Option<Vec<bool>>,
740    ) -> PyResult<Bound<'py, PyAny>> {
741        let client = self.clone();
742        let n = orders.len();
743
744        if let Some(ref v) = per_order_leverages
745            && v.len() != n
746        {
747            return Err(to_pyvalue_err(format!(
748                "per_order_leverages length must equal orders length, was {} for {n} orders",
749                v.len(),
750            )));
751        }
752
753        if let Some(ref v) = per_order_reduce_only
754            && v.len() != n
755        {
756            return Err(to_pyvalue_err(format!(
757                "per_order_reduce_only length must equal orders length, was {} for {n} orders",
758                v.len(),
759            )));
760        }
761
762        let leverages: Vec<Option<u16>> = match per_order_leverages {
763            Some(per_leverages) => per_leverages.into_iter().map(|v| v.or(leverage)).collect(),
764            None => vec![leverage; n],
765        };
766        let reduce_only_flags = per_order_reduce_only.unwrap_or_else(|| vec![false; n]);
767        let expanded_orders = orders
768            .into_iter()
769            .zip(leverages)
770            .zip(reduce_only_flags)
771            .map(
772                |(
773                    (
774                        (
775                            instrument_id,
776                            client_order_id,
777                            order_side,
778                            order_type,
779                            quantity,
780                            time_in_force,
781                            price,
782                            trigger_price,
783                            trigger_type,
784                            post_only,
785                            quote_quantity,
786                            display_qty,
787                        ),
788                        order_leverage,
789                    ),
790                    order_reduce_only,
791                )| {
792                    (
793                        instrument_id,
794                        client_order_id,
795                        order_side,
796                        order_type,
797                        quantity,
798                        time_in_force,
799                        None,
800                        price,
801                        trigger_price,
802                        trigger_type,
803                        None,
804                        None,
805                        order_reduce_only,
806                        post_only,
807                        quote_quantity,
808                        display_qty,
809                        order_leverage,
810                    )
811                },
812            )
813            .collect();
814
815        pyo3_async_runtimes::tokio::future_into_py(py, async move {
816            client
817                .submit_orders_batch(expanded_orders, account_type)
818                .await
819                .map_err(to_pyruntime_err)
820        })
821    }
822}