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