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