Skip to main content

nautilus_kraken/python/
websocket_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 WebSocket client.
17//!
18//! # Design Pattern: Clone and Share State
19//!
20//! The WebSocket client must be cloned for async operations because PyO3's `future_into_py`
21//! requires `'static` futures (cannot borrow from `self`). To ensure clones share the same
22//! connection state, key fields use `Arc`:
23//!
24//! - `ws_client: Option<Arc<WebSocketClient>>` - The WebSocket connection.
25//! - `subscriptions: Arc<DashMap<String, KrakenWsChannel>>` - Subscription tracking.
26//!
27//! Without shared state, clones would be independent, causing:
28//! - Lost WebSocket messages.
29//! - Missing subscription data.
30//! - Connection state desynchronization.
31//!
32//! ## Connection Flow
33//!
34//! 1. Clone the client for async operation.
35//! 2. Connect and populate shared state on the clone.
36//! 3. Spawn stream handler as background task.
37//! 4. Return immediately (non-blocking).
38//!
39//! ## Important Notes
40//!
41//! - Never use `block_on()` - it blocks the runtime.
42//! - Always clone before async blocks for lifetime requirements.
43
44use std::sync::{
45    Arc,
46    atomic::{AtomicU64, Ordering},
47};
48
49use ahash::AHashMap;
50use futures_util::StreamExt;
51use nautilus_common::live::get_runtime;
52use nautilus_core::{
53    AtomicMap, UnixNanos,
54    python::{call_python_threadsafe, to_pyruntime_err, to_pyvalue_err},
55    time::get_atomic_clock_realtime,
56};
57use nautilus_model::{
58    data::{BarType, Data, OrderBookDeltas_API},
59    identifiers::{
60        AccountId, ClientOrderId, InstrumentId, StrategyId, Symbol, TraderId, VenueOrderId,
61    },
62    instruments::{Instrument, InstrumentAny},
63    python::{data::data_to_pycapsule, instruments::pyobject_to_instrument_any},
64    reports::{FillReport, OrderStatusReport},
65};
66use pyo3::{IntoPyObjectExt, prelude::*};
67use tokio_util::sync::CancellationToken;
68use ustr::Ustr;
69
70use crate::{
71    common::{
72        consts::KRAKEN_VENUE,
73        enums::{KrakenEnvironment, KrakenProductType},
74        urls::get_kraken_ws_private_url,
75    },
76    config::KrakenDataClientConfig,
77    websocket::spot_v2::{
78        client::KrakenSpotWebSocketClient,
79        level_2::L2BookState,
80        level_3::{
81            BookOrderIdHasher, KrakenL3WsMessage,
82            resync::retry_l3_resync,
83            runtime::{L3Sink, L3State, process_l3_message},
84        },
85        messages::KrakenSpotWsMessage,
86        parse::{
87            parse_quote_tick, parse_trade_tick, parse_ws_bar, parse_ws_fill_report,
88            parse_ws_order_status_report,
89        },
90    },
91};
92
93#[pymethods]
94#[pyo3_stub_gen::derive::gen_stub_pymethods]
95impl KrakenSpotWebSocketClient {
96    /// WebSocket client for the Kraken Spot v2 streaming API.
97    #[new]
98    #[pyo3(signature = (
99        environment=None,
100        private=false,
101        base_url=None,
102        heartbeat_secs=None,
103        api_key=None,
104        api_secret=None,
105        proxy_url=None,
106        l3=false,
107        validate_l3_checksum=true,
108        base_url_http=None,
109    ))]
110    #[expect(clippy::too_many_arguments)]
111    fn py_new(
112        environment: Option<KrakenEnvironment>,
113        private: bool,
114        base_url: Option<String>,
115        heartbeat_secs: Option<u64>,
116        api_key: Option<String>,
117        api_secret: Option<String>,
118        proxy_url: Option<String>,
119        l3: bool,
120        validate_l3_checksum: bool,
121        base_url_http: Option<String>,
122    ) -> PyResult<Self> {
123        if l3 && private {
124            return Err(to_pyvalue_err("`l3` and `private` are mutually exclusive"));
125        }
126
127        let env = environment.unwrap_or(KrakenEnvironment::Live);
128
129        let (resolved_api_key, resolved_api_secret) =
130            crate::common::credential::KrakenCredential::resolve_spot(api_key, api_secret)
131                .map(|c| c.into_parts())
132                .map_or((None, None), |(k, s)| (Some(k), Some(s)));
133
134        let (ws_public_url, ws_private_url, ws_l3_url) = if l3 {
135            (None, None, base_url)
136        } else if private {
137            let private_url = base_url.unwrap_or_else(|| {
138                get_kraken_ws_private_url(KrakenProductType::Spot, env).to_string()
139            });
140            (None, Some(private_url), None)
141        } else {
142            (base_url, None, None)
143        };
144
145        let config = KrakenDataClientConfig {
146            environment: env,
147            base_url: base_url_http,
148            ws_public_url,
149            ws_private_url,
150            ws_l3_url,
151            heartbeat_interval_secs: heartbeat_secs
152                .unwrap_or(KrakenDataClientConfig::default().heartbeat_interval_secs),
153            api_key: resolved_api_key,
154            api_secret: resolved_api_secret,
155            proxy_url: proxy_url.clone(),
156            validate_l3_checksum,
157            ..Default::default()
158        };
159
160        let token = CancellationToken::new();
161        Ok(if l3 {
162            Self::l3(config, token, proxy_url)
163        } else {
164            Self::new(config, token, proxy_url)
165        })
166    }
167
168    /// Returns the WebSocket URL.
169    #[getter]
170    #[pyo3(name = "url")]
171    #[must_use]
172    pub fn py_url(&self) -> &str {
173        self.url()
174    }
175
176    /// Returns `true` if the client has API credentials configured
177    /// (post-environment-variable resolution).
178    #[getter]
179    #[pyo3(name = "has_credentials")]
180    fn py_has_credentials(&self) -> bool {
181        self.has_credentials()
182    }
183
184    /// Returns true if connected (not closed).
185    #[pyo3(name = "is_connected")]
186    fn py_is_connected(&self) -> bool {
187        self.is_connected()
188    }
189
190    /// Returns true if the connection is active.
191    #[pyo3(name = "is_active")]
192    fn py_is_active(&self) -> bool {
193        self.is_active()
194    }
195
196    /// Returns true if the connection is closed.
197    #[pyo3(name = "is_closed")]
198    fn py_is_closed(&self) -> bool {
199        self.is_closed()
200    }
201
202    /// Returns all active subscriptions.
203    #[pyo3(name = "get_subscriptions")]
204    fn py_get_subscriptions(&self) -> Vec<String> {
205        self.get_subscriptions()
206    }
207
208    /// Cancels all pending requests.
209    #[pyo3(name = "cancel_all_requests")]
210    fn py_cancel_all_requests(&self) {
211        self.cancel_all_requests();
212    }
213
214    /// Connects to the WebSocket server.
215    #[pyo3(name = "connect")]
216    #[expect(clippy::needless_pass_by_value)]
217    fn py_connect<'py>(
218        &mut self,
219        py: Python<'py>,
220        loop_: Py<PyAny>,
221        instruments: Vec<Py<PyAny>>,
222        callback: Py<PyAny>,
223    ) -> PyResult<Bound<'py, PyAny>> {
224        let call_soon: Py<PyAny> = loop_.getattr(py, "call_soon_threadsafe")?;
225
226        let instruments_map = Arc::new(AtomicMap::<InstrumentId, InstrumentAny>::new());
227
228        for inst in instruments {
229            let inst_any = pyobject_to_instrument_any(py, inst)?;
230            instruments_map.insert(inst_any.id(), inst_any.clone());
231            self.cache_instrument(inst_any);
232        }
233
234        let account_id = self.account_id_shared().clone();
235        let truncated_id_map = self.truncated_id_map().clone();
236        let mut client = self.clone();
237
238        pyo3_async_runtimes::tokio::future_into_py(py, async move {
239            client.connect().await.map_err(to_pyruntime_err)?;
240
241            let stream = client.stream().map_err(to_pyruntime_err)?;
242            let clock = get_atomic_clock_realtime();
243            let book_sequence = Arc::new(AtomicU64::new(0));
244
245            get_runtime().spawn(async move {
246                tokio::pin!(stream);
247                let order_qty_cache: Arc<AtomicMap<String, f64>> =
248                    Arc::new(AtomicMap::new());
249                let order_instrument_cache: Arc<AtomicMap<String, InstrumentAny>> =
250                    Arc::new(AtomicMap::new());
251
252                let mut l3_states: AHashMap<String, L3State> = AHashMap::new();
253                let l3_hasher = BookOrderIdHasher::new();
254                let l3_depths = client.l3_depths_handle();
255                let l2_depths = client.l2_depths_handle();
256                let l3_instruments = client.instruments_handle();
257                let l3_validate = client.validate_l3_checksum();
258                let client_for_l3_resync = client.clone();
259                let mut l2_books = L2BookState::default();
260
261                while let Some(msg) = stream.next().await {
262                    let ts_init = clock.get_time_ns();
263
264                    match msg {
265                        KrakenSpotWsMessage::Ticker(tickers) => {
266                            let instruments = instruments_map.load();
267
268                            for ticker in &tickers {
269                                let instrument_id = InstrumentId::new(
270                                    Symbol::new(ticker.symbol.as_str()),
271                                    *KRAKEN_VENUE,
272                                );
273                                let instrument = instruments.get(&instrument_id);
274
275                                if let Some(inst) = instrument {
276                                    match parse_quote_tick(ticker, inst, ts_init) {
277                                        Ok(quote) => {
278                                            Python::attach(|py| {
279                                                let py_obj =
280                                                    data_to_pycapsule(py, Data::Quote(quote));
281                                                call_python_threadsafe(
282                                                    py, &call_soon, &callback, py_obj,
283                                                );
284                                            });
285                                        }
286                                        Err(e) => {
287                                            log::error!("Failed to parse quote tick: {e}");
288                                        }
289                                    }
290                                }
291                            }
292                        }
293                        KrakenSpotWsMessage::Trade(trades) => {
294                            let instruments = instruments_map.load();
295
296                            for trade in &trades {
297                                let instrument_id = InstrumentId::new(
298                                    Symbol::new(trade.symbol.as_str()),
299                                    *KRAKEN_VENUE,
300                                );
301                                let instrument = instruments.get(&instrument_id);
302
303                                if let Some(inst) = instrument {
304                                    match parse_trade_tick(trade, inst, ts_init) {
305                                        Ok(tick) => {
306                                            Python::attach(|py| {
307                                                let py_obj =
308                                                    data_to_pycapsule(py, Data::Trade(tick));
309                                                call_python_threadsafe(
310                                                    py, &call_soon, &callback, py_obj,
311                                                );
312                                            });
313                                        }
314                                        Err(e) => {
315                                            log::error!("Failed to parse trade tick: {e}");
316                                        }
317                                    }
318                                }
319                            }
320                        }
321                        KrakenSpotWsMessage::Book {
322                            data,
323                            is_snapshot,
324                        } => {
325                            let instruments = instruments_map.load();
326
327                            for book in &data {
328                                let instrument_id = InstrumentId::new(
329                                    Symbol::new(book.symbol.as_str()),
330                                    *KRAKEN_VENUE,
331                                );
332                                let instrument = instruments.get(&instrument_id);
333
334                                if let Some(inst) = instrument {
335                                    let sequence = book_sequence.load(Ordering::Relaxed);
336                                    let depth = l2_depths.get(book.symbol.as_str());
337                                    match l2_books.process_book(
338                                        book,
339                                        inst,
340                                        sequence,
341                                        is_snapshot,
342                                        depth,
343                                        ts_init,
344                                    ) {
345                                        Ok(Some((deltas, next_sequence))) => {
346                                            book_sequence.store(next_sequence, Ordering::Relaxed);
347                                            Python::attach(|py| {
348                                                let py_obj = data_to_pycapsule(
349                                                    py,
350                                                    Data::Deltas(OrderBookDeltas_API::new(deltas)),
351                                                );
352                                                call_python_threadsafe(
353                                                    py, &call_soon, &callback, py_obj,
354                                                );
355                                            });
356                                        }
357                                        Ok(None) => {}
358                                        Err(e) => {
359                                            log::error!("Failed to parse book deltas: {e}");
360                                        }
361                                    }
362                                }
363                            }
364                        }
365                        KrakenSpotWsMessage::Ohlc(ohlc_data) => {
366                            let instruments = instruments_map.load();
367
368                            for ohlc in &ohlc_data {
369                                let instrument_id = InstrumentId::new(
370                                    Symbol::new(ohlc.symbol.as_str()),
371                                    *KRAKEN_VENUE,
372                                );
373                                let instrument = instruments.get(&instrument_id);
374
375                                if let Some(inst) = instrument {
376                                    match parse_ws_bar(ohlc, inst, ts_init) {
377                                        Ok(bar) => {
378                                            Python::attach(|py| {
379                                                let py_obj = data_to_pycapsule(py, Data::Bar(bar));
380                                                call_python_threadsafe(
381                                                    py, &call_soon, &callback, py_obj,
382                                                );
383                                            });
384                                        }
385                                        Err(e) => {
386                                            log::error!("Failed to parse bar: {e}");
387                                        }
388                                    }
389                                }
390                            }
391                        }
392                        KrakenSpotWsMessage::Execution(executions) => {
393                            let acct_id = account_id.read().ok().and_then(|g| *g);
394                            let Some(acct_id) = acct_id else {
395                                log::trace!(
396                                    "Execution message received but no account_id set (data-only client)"
397                                );
398                                continue;
399                            };
400
401                            let instruments = instruments_map.load();
402
403                            for exec in &executions {
404                                let inst = if let Some(ref symbol) = exec.symbol {
405                                    let instrument_id = InstrumentId::new(
406                                        Symbol::new(symbol.as_str()),
407                                        *KRAKEN_VENUE,
408                                    );
409                                    let Some(inst) = instruments.get(&instrument_id).cloned()
410                                    else {
411                                        log::warn!("No instrument for symbol: {symbol}");
412                                        continue;
413                                    };
414
415                                    if let Some(ref id) = exec.cl_ord_id {
416                                        order_instrument_cache.insert(id.clone(), inst.clone());
417                                    }
418                                    order_instrument_cache
419                                        .insert(exec.order_id.clone(), inst.clone());
420                                    inst
421                                } else {
422                                    let cache = order_instrument_cache.load();
423                                    let found = exec
424                                        .cl_ord_id
425                                        .as_ref()
426                                        .and_then(|id| cache.get(id).cloned())
427                                        .or_else(|| cache.get(&exec.order_id).cloned());
428                                    let Some(inst) = found else {
429                                        log::debug!(
430                                            "Execution without symbol and no cached instrument: \
431                                             exec_type={:?}, order_id={}",
432                                            exec.exec_type,
433                                            exec.order_id
434                                        );
435                                        continue;
436                                    };
437                                    inst
438                                };
439
440                                let cached_qty = exec.cl_ord_id.as_ref().and_then(|id| {
441                                    order_qty_cache.load().get(id).copied()
442                                });
443
444                                if let (Some(qty), Some(cl_ord_id)) =
445                                    (exec.order_qty, &exec.cl_ord_id)
446                                {
447                                    order_qty_cache.insert(cl_ord_id.clone(), qty);
448                                }
449
450                                match parse_ws_order_status_report(
451                                    exec, &inst, acct_id, cached_qty, ts_init,
452                                ) {
453                                    Ok(mut report) => {
454                                        if let Some(ref cl_ord_id) = exec.cl_ord_id {
455                                            let full_id = truncated_id_map
456                                                .load()
457                                                .get(cl_ord_id)
458                                                .copied()
459                                                .unwrap_or_else(|| ClientOrderId::new(cl_ord_id));
460                                            report = report.with_client_order_id(full_id);
461                                        }
462                                        dispatch_order_status_report(
463                                            report, &call_soon, &callback,
464                                        );
465                                    }
466                                    Err(e) => {
467                                        log::error!("Failed to parse order status report: {e}");
468                                    }
469                                }
470
471                                if exec.exec_id.is_some() {
472                                    match parse_ws_fill_report(exec, &inst, acct_id, ts_init) {
473                                        Ok(mut report) => {
474                                            if let Some(ref cl_ord_id) = exec.cl_ord_id {
475                                                let full_id = truncated_id_map
476                                                    .load()
477                                                    .get(cl_ord_id)
478                                                    .copied()
479                                                    .unwrap_or_else(|| {
480                                                        ClientOrderId::new(cl_ord_id)
481                                                    });
482                                                report.client_order_id = Some(full_id);
483                                            }
484                                            dispatch_fill_report(report, &call_soon, &callback);
485                                        }
486                                        Err(e) => {
487                                            log::error!("Failed to parse fill report: {e}");
488                                        }
489                                    }
490                                }
491                            }
492                        }
493                        KrakenSpotWsMessage::L3Snapshot(snap) => {
494                            let mut sink = PyDeltaSink {
495                                call_soon: &call_soon,
496                                callback: &callback,
497                            };
498                            run_l3_state(
499                                KrakenL3WsMessage::Snapshot(snap),
500                                &mut sink,
501                                &l3_instruments,
502                                &l3_depths,
503                                &mut l3_states,
504                                &l3_hasher,
505                                l3_validate,
506                                ts_init,
507                                &client_for_l3_resync,
508                            )
509                            .await;
510                        }
511                        KrakenSpotWsMessage::L3Update(update) => {
512                            let mut sink = PyDeltaSink {
513                                call_soon: &call_soon,
514                                callback: &callback,
515                            };
516                            run_l3_state(
517                                KrakenL3WsMessage::Update(update),
518                                &mut sink,
519                                &l3_instruments,
520                                &l3_depths,
521                                &mut l3_states,
522                                &l3_hasher,
523                                l3_validate,
524                                ts_init,
525                                &client_for_l3_resync,
526                            )
527                            .await;
528                        }
529                        KrakenSpotWsMessage::Reconnected => {
530                            log::info!("WebSocket reconnected");
531
532                            for state in l3_states.values_mut() {
533                                state.open_orders.clear();
534                                state.awaiting_snapshot = true;
535                            }
536                        }
537                        KrakenSpotWsMessage::OrderResponse(_) => {}
538                    }
539                }
540            });
541
542            Ok(())
543        })
544    }
545
546    /// Waits until the connection is active or timeout.
547    #[pyo3(name = "wait_until_active")]
548    fn py_wait_until_active<'py>(
549        &self,
550        py: Python<'py>,
551        timeout_secs: f64,
552    ) -> PyResult<Bound<'py, PyAny>> {
553        let client = self.clone();
554
555        pyo3_async_runtimes::tokio::future_into_py(py, async move {
556            client
557                .wait_until_active(timeout_secs)
558                .await
559                .map_err(to_pyruntime_err)?;
560            Ok(())
561        })
562    }
563
564    /// Authenticates with the Kraken API to enable private subscriptions.
565    #[pyo3(name = "authenticate")]
566    fn py_authenticate<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
567        let client = self.clone();
568
569        pyo3_async_runtimes::tokio::future_into_py(py, async move {
570            client.authenticate().await.map_err(to_pyruntime_err)?;
571            Ok(())
572        })
573    }
574
575    /// Returns true if the WebSocket is authenticated for private subscriptions.
576    #[pyo3(name = "is_authenticated")]
577    fn py_is_authenticated(&self) -> bool {
578        self.is_authenticated()
579    }
580
581    /// Waits until the WebSocket is authenticated or the timeout elapses.
582    ///
583    /// Returns an error on timeout or explicit auth failure.
584    #[pyo3(name = "wait_until_authenticated")]
585    fn py_wait_until_authenticated<'py>(
586        &self,
587        py: Python<'py>,
588        timeout_secs: f64,
589    ) -> PyResult<Bound<'py, PyAny>> {
590        let client = self.clone();
591
592        pyo3_async_runtimes::tokio::future_into_py(py, async move {
593            client
594                .wait_until_authenticated(timeout_secs)
595                .await
596                .map_err(to_pyruntime_err)?;
597            Ok(())
598        })
599    }
600
601    /// Disconnects from the WebSocket server.
602    #[pyo3(name = "disconnect")]
603    fn py_disconnect<'py>(&mut self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
604        let mut client = self.clone();
605
606        pyo3_async_runtimes::tokio::future_into_py(py, async move {
607            client.disconnect().await.map_err(to_pyruntime_err)?;
608            Ok(())
609        })
610    }
611
612    /// Sends a ping message to keep the connection alive.
613    #[pyo3(name = "send_ping")]
614    fn py_send_ping<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
615        let client = self.clone();
616
617        pyo3_async_runtimes::tokio::future_into_py(py, async move {
618            client.send_ping().await.map_err(to_pyruntime_err)?;
619            Ok(())
620        })
621    }
622
623    /// Closes the WebSocket connection.
624    #[pyo3(name = "close")]
625    fn py_close<'py>(&mut self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
626        let mut client = self.clone();
627
628        pyo3_async_runtimes::tokio::future_into_py(py, async move {
629            client.close().await.map_err(to_pyruntime_err)?;
630            Ok(())
631        })
632    }
633
634    /// Sets the account ID for execution report parsing.
635    #[pyo3(name = "set_account_id")]
636    fn py_set_account_id(&self, account_id: AccountId) {
637        self.set_account_id(account_id);
638    }
639
640    /// Caches an instrument for execution report parsing.
641    #[pyo3(name = "cache_instrument")]
642    fn py_cache_instrument(&self, py: Python, instrument: Py<PyAny>) -> PyResult<()> {
643        let inst_any = pyobject_to_instrument_any(py, instrument)?;
644        self.cache_instrument(inst_any);
645        Ok(())
646    }
647
648    /// Caches a client order for truncated ID resolution.
649    #[pyo3(name = "cache_client_order")]
650    fn py_cache_client_order(
651        &self,
652        client_order_id: ClientOrderId,
653        venue_order_id: Option<VenueOrderId>,
654        instrument_id: InstrumentId,
655        trader_id: TraderId,
656        strategy_id: StrategyId,
657    ) {
658        self.cache_client_order(
659            client_order_id,
660            venue_order_id,
661            instrument_id,
662            trader_id,
663            strategy_id,
664        );
665    }
666
667    /// Subscribes to order book updates for the given instrument.
668    #[pyo3(name = "subscribe_book")]
669    fn py_subscribe_book<'py>(
670        &self,
671        py: Python<'py>,
672        instrument_id: InstrumentId,
673        depth: Option<u32>,
674    ) -> PyResult<Bound<'py, PyAny>> {
675        let client = self.clone();
676
677        pyo3_async_runtimes::tokio::future_into_py(py, async move {
678            client
679                .subscribe_book(instrument_id, depth)
680                .await
681                .map_err(to_pyruntime_err)?;
682            Ok(())
683        })
684    }
685
686    /// Subscribes to the Kraken `level3` channel for the given symbol.
687    ///
688    /// `depth` must be 10, 100, or 1000.
689    ///
690    /// # Errors
691    ///
692    /// Returns an error if the auth token is not cached (call `authenticate()` first)
693    /// or the subscribe message cannot be sent.
694    #[pyo3(name = "subscribe_l3_book")]
695    #[expect(clippy::needless_pass_by_value)]
696    fn py_subscribe_l3_book<'py>(
697        &self,
698        py: Python<'py>,
699        symbol: String,
700        depth: u32,
701    ) -> PyResult<Bound<'py, PyAny>> {
702        let client = self.clone();
703        let symbol = Ustr::from(&symbol);
704        pyo3_async_runtimes::tokio::future_into_py(py, async move {
705            client
706                .subscribe_book_l3(symbol, depth)
707                .await
708                .map_err(to_pyruntime_err)
709        })
710    }
711
712    /// Unsubscribes from the Kraken `level3` channel for the given symbol.
713    #[pyo3(name = "unsubscribe_l3_book")]
714    #[expect(clippy::needless_pass_by_value)]
715    fn py_unsubscribe_l3_book<'py>(
716        &self,
717        py: Python<'py>,
718        symbol: String,
719    ) -> PyResult<Bound<'py, PyAny>> {
720        let client = self.clone();
721        let symbol = Ustr::from(&symbol);
722        pyo3_async_runtimes::tokio::future_into_py(py, async move {
723            client
724                .unsubscribe_book_l3(symbol)
725                .await
726                .map_err(to_pyruntime_err)
727        })
728    }
729
730    /// Subscribes to quote updates for the given instrument.
731    ///
732    /// Uses the Ticker channel with `event_trigger: "bbo"` for updates only on
733    /// best bid/offer changes.
734    #[pyo3(name = "subscribe_quotes")]
735    fn py_subscribe_quotes<'py>(
736        &self,
737        py: Python<'py>,
738        instrument_id: InstrumentId,
739    ) -> PyResult<Bound<'py, PyAny>> {
740        let client = self.clone();
741
742        pyo3_async_runtimes::tokio::future_into_py(py, async move {
743            client
744                .subscribe_quotes(instrument_id)
745                .await
746                .map_err(to_pyruntime_err)?;
747            Ok(())
748        })
749    }
750
751    /// Subscribes to trade updates for the given instrument.
752    #[pyo3(name = "subscribe_trades")]
753    fn py_subscribe_trades<'py>(
754        &self,
755        py: Python<'py>,
756        instrument_id: InstrumentId,
757    ) -> PyResult<Bound<'py, PyAny>> {
758        let client = self.clone();
759
760        pyo3_async_runtimes::tokio::future_into_py(py, async move {
761            client
762                .subscribe_trades(instrument_id)
763                .await
764                .map_err(to_pyruntime_err)?;
765            Ok(())
766        })
767    }
768
769    /// Subscribes to bar/OHLC updates for the given bar type.
770    #[pyo3(name = "subscribe_bars")]
771    fn py_subscribe_bars<'py>(
772        &self,
773        py: Python<'py>,
774        bar_type: BarType,
775    ) -> PyResult<Bound<'py, PyAny>> {
776        let client = self.clone();
777
778        pyo3_async_runtimes::tokio::future_into_py(py, async move {
779            client
780                .subscribe_bars(bar_type)
781                .await
782                .map_err(to_pyruntime_err)?;
783            Ok(())
784        })
785    }
786
787    /// Subscribes to execution updates (order and fill events).
788    ///
789    /// Requires authentication - call `authenticate()` first.
790    #[pyo3(name = "subscribe_executions")]
791    #[pyo3(signature = (snap_orders=true, snap_trades=true))]
792    fn py_subscribe_executions<'py>(
793        &self,
794        py: Python<'py>,
795        snap_orders: bool,
796        snap_trades: bool,
797    ) -> PyResult<Bound<'py, PyAny>> {
798        let client = self.clone();
799
800        pyo3_async_runtimes::tokio::future_into_py(py, async move {
801            client
802                .subscribe_executions(snap_orders, snap_trades)
803                .await
804                .map_err(to_pyruntime_err)?;
805            Ok(())
806        })
807    }
808
809    /// Unsubscribes from order book updates for the given instrument.
810    #[pyo3(name = "unsubscribe_book")]
811    fn py_unsubscribe_book<'py>(
812        &self,
813        py: Python<'py>,
814        instrument_id: InstrumentId,
815    ) -> PyResult<Bound<'py, PyAny>> {
816        let client = self.clone();
817
818        pyo3_async_runtimes::tokio::future_into_py(py, async move {
819            client
820                .unsubscribe_book(instrument_id)
821                .await
822                .map_err(to_pyruntime_err)?;
823            Ok(())
824        })
825    }
826
827    /// Unsubscribes from quote updates for the given instrument.
828    #[pyo3(name = "unsubscribe_quotes")]
829    fn py_unsubscribe_quotes<'py>(
830        &self,
831        py: Python<'py>,
832        instrument_id: InstrumentId,
833    ) -> PyResult<Bound<'py, PyAny>> {
834        let client = self.clone();
835
836        pyo3_async_runtimes::tokio::future_into_py(py, async move {
837            client
838                .unsubscribe_quotes(instrument_id)
839                .await
840                .map_err(to_pyruntime_err)?;
841            Ok(())
842        })
843    }
844
845    /// Unsubscribes from trade updates for the given instrument.
846    #[pyo3(name = "unsubscribe_trades")]
847    fn py_unsubscribe_trades<'py>(
848        &self,
849        py: Python<'py>,
850        instrument_id: InstrumentId,
851    ) -> PyResult<Bound<'py, PyAny>> {
852        let client = self.clone();
853
854        pyo3_async_runtimes::tokio::future_into_py(py, async move {
855            client
856                .unsubscribe_trades(instrument_id)
857                .await
858                .map_err(to_pyruntime_err)?;
859            Ok(())
860        })
861    }
862
863    /// Unsubscribes from bar/OHLC updates for the given bar type.
864    #[pyo3(name = "unsubscribe_bars")]
865    fn py_unsubscribe_bars<'py>(
866        &self,
867        py: Python<'py>,
868        bar_type: BarType,
869    ) -> PyResult<Bound<'py, PyAny>> {
870        let client = self.clone();
871
872        pyo3_async_runtimes::tokio::future_into_py(py, async move {
873            client
874                .unsubscribe_bars(bar_type)
875                .await
876                .map_err(to_pyruntime_err)?;
877            Ok(())
878        })
879    }
880}
881
882fn dispatch_order_status_report(
883    report: OrderStatusReport,
884    call_soon: &Py<PyAny>,
885    callback: &Py<PyAny>,
886) {
887    Python::attach(|py| match report.into_py_any(py) {
888        Ok(py_obj) => {
889            call_python_threadsafe(py, call_soon, callback, py_obj);
890        }
891        Err(e) => {
892            log::error!("Failed to convert OrderStatusReport to Python: {e}");
893        }
894    });
895}
896
897fn dispatch_fill_report(report: FillReport, call_soon: &Py<PyAny>, callback: &Py<PyAny>) {
898    Python::attach(|py| match report.into_py_any(py) {
899        Ok(py_obj) => {
900            call_python_threadsafe(py, call_soon, callback, py_obj);
901        }
902        Err(e) => {
903            log::error!("Failed to convert FillReport to Python: {e}");
904        }
905    });
906}
907
908struct PyDeltaSink<'a> {
909    call_soon: &'a Py<PyAny>,
910    callback: &'a Py<PyAny>,
911}
912
913impl L3Sink for PyDeltaSink<'_> {
914    fn emit_deltas(&mut self, deltas: OrderBookDeltas_API) {
915        Python::attach(|py| {
916            let py_obj = data_to_pycapsule(py, Data::Deltas(deltas));
917            call_python_threadsafe(py, self.call_soon, self.callback, py_obj);
918        });
919    }
920}
921
922#[expect(clippy::too_many_arguments)]
923async fn run_l3_state(
924    msg: KrakenL3WsMessage,
925    sink: &mut PyDeltaSink<'_>,
926    instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
927    depths: &Arc<std::sync::Mutex<AHashMap<String, u32>>>,
928    states: &mut AHashMap<String, L3State>,
929    hasher: &BookOrderIdHasher,
930    validate_checksum: bool,
931    ts_init: UnixNanos,
932    client: &KrakenSpotWebSocketClient,
933) {
934    let resync = process_l3_message(
935        msg,
936        sink,
937        instruments,
938        depths,
939        states,
940        hasher,
941        validate_checksum,
942        ts_init,
943    );
944
945    if let Some(request) = resync {
946        log::warn!(
947            "Resyncing Kraken L3 book: symbol={}, depth={}, reason={}",
948            request.symbol,
949            request.depth,
950            request.reason,
951        );
952        let symbol_ustr = Ustr::from(&request.symbol);
953        let client = client.clone();
954        get_runtime().spawn(async move {
955            retry_l3_resync(&client, symbol_ustr, request.depth).await;
956        });
957    }
958}