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