Skip to main content

nautilus_kraken/websocket/futures/
messages.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//! Data models for Kraken Futures WebSocket v1 API messages.
17
18use serde::{Deserialize, Deserializer, Serialize};
19use serde_json::Value;
20use strum::{AsRefStr, EnumString};
21use ustr::Ustr;
22
23use crate::common::enums::{KrakenFillType, KrakenFuturesOrderType, KrakenOrderSide};
24
25// Normalizes a float price field so `0.0` is treated as "no price set".
26// Kraken Futures wire messages send a literal `0.0` for absent prices
27// (e.g. `stop_price: 0.0` on pure limit orders) rather than omitting the
28// field or sending `null`. Without this, downstream code would see
29// `Some(0.0)` and emit bogus trigger prices on `OrderUpdated` events,
30// which the order model rejects for non-stop order types.
31fn deserialize_optional_price_zero_as_none<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
32where
33    D: Deserializer<'de>,
34{
35    let value = Option::<f64>::deserialize(deserializer)?;
36    Ok(value.filter(|v| *v != 0.0))
37}
38
39/// Output message types from the Futures WebSocket handler.
40#[derive(Clone, Debug)]
41#[expect(
42    clippy::large_enum_variant,
43    reason = "Messages are ephemeral and immediately consumed"
44)]
45pub enum KrakenFuturesWsMessage {
46    Ticker(KrakenFuturesTickerData),
47    Trade(KrakenFuturesTradeData),
48    BookSnapshot(KrakenFuturesBookSnapshot),
49    BookDelta(KrakenFuturesBookDelta),
50    OpenOrdersCancel(KrakenFuturesOpenOrdersCancel),
51    OpenOrdersDelta(KrakenFuturesOpenOrdersDelta),
52    FillsDelta(KrakenFuturesFillsDelta),
53    Challenge(String),
54    Reconnected,
55}
56
57/// Kraken Futures WebSocket feed types.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumString, AsRefStr)]
59#[serde(rename_all = "snake_case")]
60#[strum(serialize_all = "snake_case")]
61pub enum KrakenFuturesFeed {
62    Ticker,
63    Trade,
64    TradeSnapshot,
65    Book,
66    BookSnapshot,
67    Heartbeat,
68    OpenOrders,
69    OpenOrdersSnapshot,
70    Fills,
71    FillsSnapshot,
72}
73
74/// Kraken Futures WebSocket subscription channel types.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, AsRefStr)]
76#[strum(serialize_all = "snake_case")]
77pub enum KrakenFuturesChannel {
78    Book,
79    Deltas,
80    Trades,
81    Quotes,
82    Mark,
83    Index,
84    Funding,
85}
86
87/// Kraken Futures WebSocket event types.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum KrakenFuturesEvent {
91    Subscribe,
92    Unsubscribe,
93    Subscribed,
94    Unsubscribed,
95    Info,
96    Error,
97    Alert,
98    Challenge,
99}
100
101/// Message type classification for efficient routing.
102/// Used to classify incoming WebSocket messages without full deserialization.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum KrakenFuturesMessageType {
105    // Private feeds (execution)
106    OpenOrdersSnapshot,
107    OpenOrdersCancel,
108    OpenOrdersDelta,
109    FillsSnapshot,
110    FillsDelta,
111    // Public feeds (market data)
112    Ticker,
113    TradeSnapshot,
114    Trade,
115    BookSnapshot,
116    BookDelta,
117    // Control messages
118    Info,
119    Pong,
120    Subscribed,
121    Unsubscribed,
122    Challenge,
123    Heartbeat,
124    Error,
125    Alert,
126    Unknown,
127}
128
129#[must_use]
130pub fn classify_futures_message(value: &Value) -> KrakenFuturesMessageType {
131    if let Some(event) = value.get("event").and_then(|v| v.as_str()) {
132        return match event {
133            "info" => KrakenFuturesMessageType::Info,
134            "pong" => KrakenFuturesMessageType::Pong,
135            "subscribed" => KrakenFuturesMessageType::Subscribed,
136            "unsubscribed" => KrakenFuturesMessageType::Unsubscribed,
137            "challenge" => KrakenFuturesMessageType::Challenge,
138            "error" => KrakenFuturesMessageType::Error,
139            "alert" => KrakenFuturesMessageType::Alert,
140            _ => KrakenFuturesMessageType::Unknown,
141        };
142    }
143
144    if let Some(feed) = value.get("feed").and_then(|v| v.as_str()) {
145        return match feed {
146            "heartbeat" => KrakenFuturesMessageType::Heartbeat,
147            "open_orders_snapshot" => KrakenFuturesMessageType::OpenOrdersSnapshot,
148            "open_orders" => {
149                // Cancel messages have is_cancel=true but no "order" object
150                if value.get("is_cancel").and_then(|v| v.as_bool()) == Some(true) {
151                    if value.get("order").is_some() {
152                        KrakenFuturesMessageType::OpenOrdersDelta
153                    } else {
154                        KrakenFuturesMessageType::OpenOrdersCancel
155                    }
156                } else {
157                    KrakenFuturesMessageType::OpenOrdersDelta
158                }
159            }
160            "fills_snapshot" => KrakenFuturesMessageType::FillsSnapshot,
161            "fills" => KrakenFuturesMessageType::FillsDelta,
162            "ticker" => KrakenFuturesMessageType::Ticker,
163            "trade_snapshot" => KrakenFuturesMessageType::TradeSnapshot,
164            "trade" => KrakenFuturesMessageType::Trade,
165            "book_snapshot" => KrakenFuturesMessageType::BookSnapshot,
166            "book" => KrakenFuturesMessageType::BookDelta,
167            _ => KrakenFuturesMessageType::Unknown,
168        };
169    }
170
171    KrakenFuturesMessageType::Unknown
172}
173
174/// Subscribe/unsubscribe request for Kraken Futures WebSocket.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct KrakenFuturesRequest {
177    pub event: KrakenFuturesEvent,
178    pub feed: KrakenFuturesFeed,
179    pub product_ids: Vec<String>,
180}
181
182/// Response to a subscription request.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct KrakenFuturesSubscriptionResponse {
185    pub event: KrakenFuturesEvent,
186    pub feed: KrakenFuturesFeed,
187    pub product_ids: Vec<String>,
188}
189
190/// Error response from Kraken Futures WebSocket.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct KrakenFuturesErrorResponse {
193    pub event: KrakenFuturesEvent,
194    #[serde(default)]
195    pub message: Option<String>,
196}
197
198/// Info message from Kraken Futures WebSocket (sent on connection).
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct KrakenFuturesInfoMessage {
201    pub event: KrakenFuturesEvent,
202    pub version: i32,
203}
204
205/// Heartbeat message from Kraken Futures WebSocket.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct KrakenFuturesHeartbeat {
208    pub feed: KrakenFuturesFeed,
209    pub time: i64,
210}
211
212/// Ticker data from Kraken Futures WebSocket (uses snake_case).
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct KrakenFuturesTickerData {
215    pub feed: KrakenFuturesFeed,
216    pub product_id: Ustr,
217    #[serde(default)]
218    pub time: Option<i64>,
219    #[serde(default)]
220    pub bid: Option<f64>,
221    #[serde(default)]
222    pub ask: Option<f64>,
223    #[serde(default)]
224    pub bid_size: Option<f64>,
225    #[serde(default)]
226    pub ask_size: Option<f64>,
227    #[serde(default)]
228    pub last: Option<f64>,
229    #[serde(default)]
230    pub volume: Option<f64>,
231    #[serde(default)]
232    pub volume_quote: Option<f64>,
233    #[serde(default, rename = "openInterest")]
234    pub open_interest: Option<f64>,
235    #[serde(default)]
236    pub index: Option<f64>,
237    #[serde(default, rename = "markPrice")]
238    pub mark_price: Option<f64>,
239    #[serde(default)]
240    pub change: Option<f64>,
241    #[serde(default)]
242    pub open: Option<f64>,
243    #[serde(default)]
244    pub high: Option<f64>,
245    #[serde(default)]
246    pub low: Option<f64>,
247    #[serde(default)]
248    pub funding_rate: Option<f64>,
249    #[serde(default)]
250    pub funding_rate_prediction: Option<f64>,
251    #[serde(default)]
252    pub relative_funding_rate: Option<f64>,
253    #[serde(default)]
254    pub relative_funding_rate_prediction: Option<f64>,
255    #[serde(default)]
256    pub next_funding_rate_time: Option<f64>,
257    #[serde(default)]
258    pub tag: Option<String>,
259    #[serde(default)]
260    pub pair: Option<String>,
261    #[serde(default)]
262    pub leverage: Option<String>,
263    #[serde(default)]
264    pub dtm: Option<i64>,
265    #[serde(default, rename = "maturityTime")]
266    pub maturity_time: Option<i64>,
267    #[serde(default)]
268    pub suspended: Option<bool>,
269    #[serde(default)]
270    pub post_only: Option<bool>,
271}
272
273/// Trade data from Kraken Futures WebSocket (uses snake_case).
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct KrakenFuturesTradeData {
276    pub feed: KrakenFuturesFeed,
277    pub product_id: Ustr,
278    #[serde(default)]
279    pub uid: Option<String>,
280    pub side: KrakenOrderSide,
281    #[serde(rename = "type", default)]
282    pub trade_type: Option<String>,
283    pub seq: i64,
284    pub time: i64,
285    pub qty: f64,
286    pub price: f64,
287}
288
289/// Trade snapshot from Kraken Futures WebSocket (sent on subscription).
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct KrakenFuturesTradeSnapshot {
292    pub feed: KrakenFuturesFeed,
293    pub product_id: Ustr,
294    pub trades: Vec<KrakenFuturesTradeData>,
295}
296
297/// Book snapshot from Kraken Futures WebSocket (uses snake_case).
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct KrakenFuturesBookSnapshot {
300    pub feed: KrakenFuturesFeed,
301    pub product_id: Ustr,
302    pub timestamp: i64,
303    pub seq: i64,
304    #[serde(default, rename = "tickSize")]
305    pub tick_size: Option<f64>,
306    pub bids: Vec<KrakenFuturesBookLevel>,
307    pub asks: Vec<KrakenFuturesBookLevel>,
308}
309
310/// Book delta from Kraken Futures WebSocket (uses snake_case).
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct KrakenFuturesBookDelta {
313    pub feed: KrakenFuturesFeed,
314    pub product_id: Ustr,
315    pub side: KrakenOrderSide,
316    pub seq: i64,
317    pub price: f64,
318    pub qty: f64,
319    pub timestamp: i64,
320}
321
322/// Price level in order book.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct KrakenFuturesBookLevel {
325    pub price: f64,
326    pub qty: f64,
327}
328
329/// Challenge request for WebSocket authentication.
330#[derive(Debug, Clone, Serialize)]
331pub struct KrakenFuturesChallengeRequest {
332    pub event: KrakenFuturesEvent,
333    pub api_key: String,
334}
335
336/// Challenge response from WebSocket.
337#[derive(Debug, Clone, Deserialize)]
338pub struct KrakenFuturesChallengeResponse {
339    pub event: KrakenFuturesEvent,
340    pub message: String,
341}
342
343/// Authenticated subscription request for private feeds.
344#[derive(Debug, Clone, Serialize)]
345pub struct KrakenFuturesPrivateSubscribeRequest {
346    pub event: KrakenFuturesEvent,
347    pub feed: KrakenFuturesFeed,
348    pub api_key: String,
349    pub original_challenge: String,
350    pub signed_challenge: String,
351}
352
353/// Open order from Kraken Futures WebSocket.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct KrakenFuturesOpenOrder {
356    pub instrument: Ustr,
357    pub time: i64,
358    pub last_update_time: i64,
359    pub qty: f64,
360    pub filled: f64,
361    /// Limit price. Optional for stop/trigger orders which only have stop_price.
362    #[serde(default, deserialize_with = "deserialize_optional_price_zero_as_none")]
363    pub limit_price: Option<f64>,
364    #[serde(default, deserialize_with = "deserialize_optional_price_zero_as_none")]
365    pub stop_price: Option<f64>,
366    #[serde(rename = "type")]
367    pub order_type: KrakenFuturesOrderType,
368    pub order_id: String,
369    #[serde(default)]
370    pub cli_ord_id: Option<String>,
371    /// 0 = buy, 1 = sell
372    pub direction: i32,
373    #[serde(default)]
374    pub reduce_only: bool,
375    #[serde(default, rename = "triggerSignal")]
376    pub trigger_signal: Option<String>,
377}
378
379/// Open orders snapshot from Kraken Futures WebSocket.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct KrakenFuturesOpenOrdersSnapshot {
382    pub feed: KrakenFuturesFeed,
383    #[serde(default)]
384    pub account: Option<String>,
385    pub orders: Vec<KrakenFuturesOpenOrder>,
386}
387
388/// Open orders delta/update from Kraken Futures WebSocket.
389/// Used when full order details are provided (new orders, updates).
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct KrakenFuturesOpenOrdersDelta {
392    pub feed: KrakenFuturesFeed,
393    pub order: KrakenFuturesOpenOrder,
394    pub is_cancel: bool,
395    #[serde(default)]
396    pub reason: Option<String>,
397}
398
399impl KrakenFuturesOpenOrdersDelta {
400    /// Returns whether this delta represents a fill-driven removal from the book.
401    ///
402    /// Kraken Futures sends an open_orders delta with `is_cancel=true` and a
403    /// `full_fill`/`partial_fill` reason when an order leaves the book because
404    /// it filled. The actual fill data arrives via the fills feed, so callers
405    /// must skip these deltas to avoid emitting a spurious `OrderCanceled`
406    /// event before the real `OrderFilled`.
407    #[must_use]
408    pub fn is_fill_driven_cancel(&self) -> bool {
409        self.is_cancel && matches!(self.reason.as_deref(), Some("full_fill" | "partial_fill"))
410    }
411}
412
413/// Open orders cancel notification from Kraken Futures WebSocket.
414/// Used when an order is canceled - contains only order identifiers.
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct KrakenFuturesOpenOrdersCancel {
417    pub feed: KrakenFuturesFeed,
418    pub order_id: String,
419    pub cli_ord_id: Option<String>,
420    pub is_cancel: bool,
421    #[serde(default)]
422    pub reason: Option<String>,
423}
424
425/// Fill from Kraken Futures WebSocket.
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct KrakenFuturesFill {
428    #[serde(alias = "product_id")]
429    pub instrument: Option<Ustr>,
430    pub time: i64,
431    pub price: f64,
432    pub qty: f64,
433    pub order_id: String,
434    #[serde(default)]
435    pub cli_ord_id: Option<String>,
436    pub fill_id: String,
437    pub fill_type: KrakenFillType,
438    /// true = buy, false = sell
439    pub buy: bool,
440    #[serde(default)]
441    pub fee_paid: Option<f64>,
442    #[serde(default)]
443    pub fee_currency: Option<String>,
444}
445
446/// Fills snapshot from Kraken Futures WebSocket.
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct KrakenFuturesFillsSnapshot {
449    pub feed: KrakenFuturesFeed,
450    #[serde(default)]
451    pub account: Option<String>,
452    pub fills: Vec<KrakenFuturesFill>,
453}
454
455/// Fills delta/update from Kraken Futures WebSocket.
456/// Note: Kraken sends fills updates in array format (same as snapshot).
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct KrakenFuturesFillsDelta {
459    pub feed: KrakenFuturesFeed,
460    #[serde(default)]
461    pub username: Option<String>,
462    pub fills: Vec<KrakenFuturesFill>,
463}
464
465#[cfg(test)]
466mod tests {
467    use rstest::rstest;
468
469    use super::*;
470
471    #[rstest]
472    fn test_deserialize_ticker_data() {
473        // Kraken Futures WebSocket uses snake_case (unlike the REST API which uses camelCase)
474        let json = r#"{
475            "feed": "ticker",
476            "product_id": "PI_XBTUSD",
477            "time": 1700000000000,
478            "bid": 90650.5,
479            "ask": 90651.0,
480            "bid_size": 10.5,
481            "ask_size": 8.2,
482            "last": 90650.8,
483            "volume": 1234567.89,
484            "index": 90648.5,
485            "markPrice": 90649.2,
486            "funding_rate": 0.0001,
487            "openInterest": 50000000.0
488        }"#;
489
490        let ticker: KrakenFuturesTickerData = serde_json::from_str(json).unwrap();
491        assert_eq!(ticker.feed, KrakenFuturesFeed::Ticker);
492        assert_eq!(ticker.product_id, Ustr::from("PI_XBTUSD"));
493        assert_eq!(ticker.bid, Some(90650.5));
494        assert_eq!(ticker.ask, Some(90651.0));
495        assert_eq!(ticker.index, Some(90648.5));
496        assert_eq!(ticker.mark_price, Some(90649.2));
497        assert_eq!(ticker.funding_rate, Some(0.0001));
498    }
499
500    #[rstest]
501    fn test_serialize_subscribe_request() {
502        let request = KrakenFuturesRequest {
503            event: KrakenFuturesEvent::Subscribe,
504            feed: KrakenFuturesFeed::Ticker,
505            product_ids: vec!["PI_XBTUSD".to_string()],
506        };
507
508        let json = serde_json::to_string(&request).unwrap();
509        assert!(json.contains("\"event\":\"subscribe\""));
510        assert!(json.contains("\"feed\":\"ticker\""));
511        assert!(json.contains("PI_XBTUSD"));
512    }
513
514    #[rstest]
515    fn test_deserialize_ticker_from_fixture() {
516        let json = include_str!("../../../test_data/ws_futures_ticker.json");
517        let ticker: KrakenFuturesTickerData = serde_json::from_str(json).unwrap();
518
519        assert_eq!(ticker.feed, KrakenFuturesFeed::Ticker);
520        assert_eq!(ticker.product_id, Ustr::from("PI_XBTUSD"));
521        assert_eq!(ticker.bid, Some(21978.5));
522        assert_eq!(ticker.ask, Some(21987.0));
523        assert_eq!(ticker.bid_size, Some(2536.0));
524        assert_eq!(ticker.ask_size, Some(13948.0));
525        assert_eq!(ticker.index, Some(21984.54));
526        assert_eq!(ticker.mark_price, Some(21979.68641534714));
527        assert!(ticker.funding_rate.is_some());
528    }
529
530    #[rstest]
531    fn test_deserialize_trade_from_fixture() {
532        let json = include_str!("../../../test_data/ws_futures_trade.json");
533        let trade: KrakenFuturesTradeData = serde_json::from_str(json).unwrap();
534
535        assert_eq!(trade.feed, KrakenFuturesFeed::Trade);
536        assert_eq!(trade.product_id, Ustr::from("PI_XBTUSD"));
537        assert_eq!(trade.side, KrakenOrderSide::Sell);
538        assert_eq!(trade.qty, 15000.0);
539        assert_eq!(trade.price, 34969.5);
540        assert_eq!(trade.seq, 653355);
541    }
542
543    #[rstest]
544    fn test_deserialize_trade_snapshot_from_fixture() {
545        let json = include_str!("../../../test_data/ws_futures_trade_snapshot.json");
546        let snapshot: KrakenFuturesTradeSnapshot = serde_json::from_str(json).unwrap();
547
548        assert_eq!(snapshot.feed, KrakenFuturesFeed::TradeSnapshot);
549        assert_eq!(snapshot.product_id, Ustr::from("PI_XBTUSD"));
550        assert_eq!(snapshot.trades.len(), 2);
551        assert_eq!(snapshot.trades[0].price, 34893.0);
552        assert_eq!(snapshot.trades[1].price, 34891.0);
553    }
554
555    #[rstest]
556    fn test_deserialize_book_snapshot_from_fixture() {
557        let json = include_str!("../../../test_data/ws_futures_book_snapshot.json");
558        let snapshot: KrakenFuturesBookSnapshot = serde_json::from_str(json).unwrap();
559
560        assert_eq!(snapshot.feed, KrakenFuturesFeed::BookSnapshot);
561        assert_eq!(snapshot.product_id, Ustr::from("PI_XBTUSD"));
562        assert_eq!(snapshot.bids.len(), 2);
563        assert_eq!(snapshot.asks.len(), 2);
564        assert_eq!(snapshot.bids[0].price, 34892.5);
565        assert_eq!(snapshot.asks[0].price, 34911.5);
566    }
567
568    #[rstest]
569    fn test_deserialize_book_delta_from_fixture() {
570        let json = include_str!("../../../test_data/ws_futures_book_delta.json");
571        let delta: KrakenFuturesBookDelta = serde_json::from_str(json).unwrap();
572
573        assert_eq!(delta.feed, KrakenFuturesFeed::Book);
574        assert_eq!(delta.product_id, Ustr::from("PI_XBTUSD"));
575        assert_eq!(delta.side, KrakenOrderSide::Sell);
576        assert_eq!(delta.price, 34981.0);
577        assert_eq!(delta.qty, 0.0); // Delete action
578    }
579
580    #[rstest]
581    fn test_deserialize_open_orders_snapshot_from_fixture() {
582        let json = include_str!("../../../test_data/ws_futures_open_orders_snapshot.json");
583        let snapshot: KrakenFuturesOpenOrdersSnapshot = serde_json::from_str(json).unwrap();
584
585        assert_eq!(snapshot.feed, KrakenFuturesFeed::OpenOrdersSnapshot);
586        assert_eq!(snapshot.orders.len(), 1);
587        assert_eq!(snapshot.orders[0].instrument, Ustr::from("PI_XBTUSD"));
588        assert_eq!(snapshot.orders[0].qty, 1000.0);
589        assert_eq!(
590            snapshot.orders[0].order_type,
591            KrakenFuturesOrderType::StopLower
592        );
593    }
594
595    #[rstest]
596    fn test_deserialize_open_orders_delta_from_fixture() {
597        let json = include_str!("../../../test_data/ws_futures_open_orders_delta.json");
598        let delta: KrakenFuturesOpenOrdersDelta = serde_json::from_str(json).unwrap();
599
600        assert_eq!(delta.feed, KrakenFuturesFeed::OpenOrders);
601        assert!(!delta.is_cancel);
602        assert_eq!(delta.order.instrument, Ustr::from("PI_XBTUSD"));
603        assert_eq!(delta.order.qty, 304.0);
604        assert_eq!(delta.order.limit_price, Some(10640.0));
605        // Kraken sends stop_price: 0.0 on pure limit orders. The zero-as-none
606        // deserializer maps that back to None so downstream code does not emit
607        // a bogus trigger_price, which the order model rejects for limit types.
608        assert_eq!(delta.order.stop_price, None);
609    }
610
611    #[rstest]
612    fn test_deserialize_open_orders_delta_full_fill_is_fill_driven_cancel() {
613        // Regression for the spurious OrderCanceled bug: Kraken sends a delta with
614        // is_cancel=true, qty=0, filled=full, reason="full_fill" when an order leaves
615        // the book because it filled. The delta must be classified as fill-driven so
616        // the execution path skips it and lets the FillsDelta carry the actual fill.
617        let json = include_str!("../../../test_data/ws_futures_open_orders_delta_full_fill.json");
618        let delta: KrakenFuturesOpenOrdersDelta = serde_json::from_str(json).unwrap();
619
620        assert!(delta.is_cancel);
621        assert_eq!(delta.reason.as_deref(), Some("full_fill"));
622        assert_eq!(delta.order.qty, 0.0);
623        assert_eq!(delta.order.filled, 0.0001);
624        assert!(delta.is_fill_driven_cancel());
625    }
626
627    #[rstest]
628    #[case::placement(false, None, false)]
629    #[case::user_cancel(true, Some("cancelled_by_user"), false)]
630    #[case::post_only_reject(true, Some("post_order_failed_because_it_would_filled"), false)]
631    #[case::full_fill(true, Some("full_fill"), true)]
632    #[case::partial_fill(true, Some("partial_fill"), true)]
633    #[case::cancel_no_reason(true, None, false)]
634    fn test_open_orders_delta_is_fill_driven_cancel(
635        #[case] is_cancel: bool,
636        #[case] reason: Option<&'static str>,
637        #[case] expected: bool,
638    ) {
639        let delta = KrakenFuturesOpenOrdersDelta {
640            feed: KrakenFuturesFeed::OpenOrders,
641            order: KrakenFuturesOpenOrder {
642                instrument: Ustr::from("PF_XBTUSD"),
643                time: 0,
644                last_update_time: 0,
645                qty: 0.0001,
646                filled: 0.0,
647                limit_price: Some(70_000.0),
648                stop_price: None,
649                order_type: KrakenFuturesOrderType::Limit,
650                order_id: "test".to_string(),
651                cli_ord_id: None,
652                direction: 0,
653                reduce_only: false,
654                trigger_signal: None,
655            },
656            is_cancel,
657            reason: reason.map(str::to_string),
658        };
659
660        assert_eq!(delta.is_fill_driven_cancel(), expected);
661    }
662
663    #[rstest]
664    fn test_deserialize_open_orders_cancel_from_fixture() {
665        let json = include_str!("../../../test_data/ws_futures_open_orders_cancel.json");
666        let cancel: KrakenFuturesOpenOrdersCancel = serde_json::from_str(json).unwrap();
667
668        assert_eq!(cancel.feed, KrakenFuturesFeed::OpenOrders);
669        assert!(cancel.is_cancel);
670        assert_eq!(cancel.order_id, "660c6b23-8007-48c1-a7c9-4893f4572e8c");
671        assert_eq!(cancel.reason, Some("cancelled_by_user".to_string()));
672        assert!(cancel.cli_ord_id.is_none()); // Not in docs example
673    }
674
675    #[rstest]
676    fn test_deserialize_fills_snapshot_from_fixture() {
677        let json = include_str!("../../../test_data/ws_futures_fills_snapshot.json");
678        let snapshot: KrakenFuturesFillsSnapshot = serde_json::from_str(json).unwrap();
679
680        assert_eq!(snapshot.feed, KrakenFuturesFeed::FillsSnapshot);
681        assert_eq!(snapshot.fills.len(), 2);
682        assert_eq!(
683            snapshot.fills[0].instrument,
684            Some(Ustr::from("FI_XBTUSD_200925"))
685        );
686        assert!(snapshot.fills[0].buy);
687        assert_eq!(snapshot.fills[0].fill_type, KrakenFillType::Maker);
688    }
689
690    #[rstest]
691    fn test_classify_ticker_message() {
692        let json = include_str!("../../../test_data/ws_futures_ticker.json");
693        let value: Value = serde_json::from_str(json).unwrap();
694        assert_eq!(
695            classify_futures_message(&value),
696            KrakenFuturesMessageType::Ticker
697        );
698    }
699
700    #[rstest]
701    fn test_classify_trade_message() {
702        let json = include_str!("../../../test_data/ws_futures_trade.json");
703        let value: Value = serde_json::from_str(json).unwrap();
704        assert_eq!(
705            classify_futures_message(&value),
706            KrakenFuturesMessageType::Trade
707        );
708    }
709
710    #[rstest]
711    fn test_classify_trade_snapshot_message() {
712        let json = include_str!("../../../test_data/ws_futures_trade_snapshot.json");
713        let value: Value = serde_json::from_str(json).unwrap();
714        assert_eq!(
715            classify_futures_message(&value),
716            KrakenFuturesMessageType::TradeSnapshot
717        );
718    }
719
720    #[rstest]
721    fn test_classify_book_snapshot_message() {
722        let json = include_str!("../../../test_data/ws_futures_book_snapshot.json");
723        let value: Value = serde_json::from_str(json).unwrap();
724        assert_eq!(
725            classify_futures_message(&value),
726            KrakenFuturesMessageType::BookSnapshot
727        );
728    }
729
730    #[rstest]
731    fn test_classify_book_delta_message() {
732        let json = include_str!("../../../test_data/ws_futures_book_delta.json");
733        let value: Value = serde_json::from_str(json).unwrap();
734        assert_eq!(
735            classify_futures_message(&value),
736            KrakenFuturesMessageType::BookDelta
737        );
738    }
739
740    #[rstest]
741    fn test_classify_open_orders_delta_message() {
742        let json = include_str!("../../../test_data/ws_futures_open_orders_delta.json");
743        let value: Value = serde_json::from_str(json).unwrap();
744        assert_eq!(
745            classify_futures_message(&value),
746            KrakenFuturesMessageType::OpenOrdersDelta
747        );
748    }
749
750    #[rstest]
751    fn test_classify_open_orders_cancel_message() {
752        let json = include_str!("../../../test_data/ws_futures_open_orders_cancel.json");
753        let value: Value = serde_json::from_str(json).unwrap();
754        assert_eq!(
755            classify_futures_message(&value),
756            KrakenFuturesMessageType::OpenOrdersCancel
757        );
758    }
759
760    #[rstest]
761    fn test_classify_heartbeat_message() {
762        let json = r#"{"feed":"heartbeat","time":1700000000000}"#;
763        let value: Value = serde_json::from_str(json).unwrap();
764        assert_eq!(
765            classify_futures_message(&value),
766            KrakenFuturesMessageType::Heartbeat
767        );
768    }
769
770    #[rstest]
771    fn test_classify_info_event() {
772        let json = r#"{"event":"info","version":1}"#;
773        let value: Value = serde_json::from_str(json).unwrap();
774        assert_eq!(
775            classify_futures_message(&value),
776            KrakenFuturesMessageType::Info
777        );
778    }
779
780    #[rstest]
781    fn test_classify_subscribed_event() {
782        let json = r#"{"event":"subscribed","feed":"ticker","product_ids":["PI_XBTUSD"]}"#;
783        let value: Value = serde_json::from_str(json).unwrap();
784        assert_eq!(
785            classify_futures_message(&value),
786            KrakenFuturesMessageType::Subscribed
787        );
788    }
789
790    #[rstest]
791    fn test_classify_error_event() {
792        let json = r#"{"event":"error","message":"Unknown product_id"}"#;
793        let value: Value = serde_json::from_str(json).unwrap();
794        assert_eq!(
795            classify_futures_message(&value),
796            KrakenFuturesMessageType::Error
797        );
798    }
799
800    #[rstest]
801    fn test_classify_alert_event() {
802        let json = r#"{"event":"alert","message":"Rate limit exceeded"}"#;
803        let value: Value = serde_json::from_str(json).unwrap();
804        assert_eq!(
805            classify_futures_message(&value),
806            KrakenFuturesMessageType::Alert
807        );
808    }
809}