Skip to main content

nautilus_kraken/websocket/spot_v2/
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 WebSocket v2 API messages.
17
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use ustr::Ustr;
22
23use super::enums::{
24    KrakenExecType, KrakenLiquidityInd, KrakenWsChannel, KrakenWsMessageType, KrakenWsMethod,
25    KrakenWsOrderStatus,
26};
27use crate::{
28    common::enums::{KrakenOrderSide, KrakenOrderType, KrakenSpotTrigger, KrakenTimeInForce},
29    websocket::spot_v2::level_3::messages::{KrakenL3Snapshot, KrakenL3UpdateData},
30};
31
32/// Output message types from the Kraken Spot v2 WebSocket handler.
33#[derive(Clone, Debug)]
34pub enum KrakenSpotWsMessage {
35    Ticker(Vec<KrakenWsTickerData>),
36    Trade(Vec<KrakenWsTradeData>),
37    Book {
38        data: Vec<KrakenWsBookData>,
39        is_snapshot: bool,
40    },
41    Ohlc(Vec<KrakenWsOhlcData>),
42    Execution(Vec<KrakenWsExecutionData>),
43    OrderResponse(KrakenWsOrderResponse),
44    L3Snapshot(KrakenL3Snapshot),
45    L3Update(KrakenL3UpdateData),
46    Reconnected,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct KrakenWsRequest {
51    pub method: KrakenWsMethod,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub params: Option<KrakenWsParams>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub req_id: Option<u64>,
56}
57
58/// Parameters for a Kraken WebSocket request, covering both channel subscriptions and order methods.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(untagged)]
61pub enum KrakenWsParams {
62    /// Parameters for subscribe/unsubscribe channel requests.
63    Channel(KrakenWsChannelParams),
64    /// Parameters for the `add_order` method.
65    AddOrder(KrakenWsAddOrderParams),
66    /// Parameters for the `amend_order` method.
67    AmendOrder(KrakenWsAmendOrderParams),
68    /// Parameters for the `cancel_order` method.
69    CancelOrder(KrakenWsCancelOrderParams),
70    /// Parameters for the `batch_add` method.
71    BatchAdd(KrakenWsBatchAddParams),
72}
73
74/// Parameters for channel subscribe/unsubscribe requests.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct KrakenWsChannelParams {
77    /// Channel to subscribe or unsubscribe.
78    pub channel: KrakenWsChannel,
79    /// Symbols to subscribe for (market data channels).
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub symbol: Option<Vec<Ustr>>,
82    /// Whether to receive a snapshot on subscribe.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub snapshot: Option<bool>,
85    /// Order book depth (book channel only).
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub depth: Option<u32>,
88    /// OHLC interval in minutes (ohlc channel only).
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub interval: Option<u32>,
91    /// Event trigger filter (ticker channel, e.g. `"bbo"`).
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub event_trigger: Option<String>,
94    /// Authentication token (private channels).
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub token: Option<String>,
97    /// Whether to include a snapshot of open orders (executions channel).
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub snap_orders: Option<bool>,
100    /// Whether to include a snapshot of recent trades (executions channel).
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub snap_trades: Option<bool>,
103}
104
105/// Parameters for the `add_order` WebSocket method.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct KrakenWsAddOrderParams {
108    /// Order type (limit, market, etc.).
109    pub order_type: KrakenOrderType,
110    /// Order side (buy or sell).
111    pub side: KrakenOrderSide,
112    /// Order quantity in base currency.
113    pub order_qty: f64,
114    /// Trading pair symbol (e.g. `"BTC/USD"`).
115    pub symbol: String,
116    /// Authentication token.
117    pub token: String,
118    /// Limit price (required for limit orders).
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub limit_price: Option<f64>,
121    /// Time in force policy.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub time_in_force: Option<KrakenTimeInForce>,
124    /// Expiration timestamp for `GoodTilDate` orders. Required by Kraken whenever
125    /// `time_in_force = GTD`. Accepts an RFC3339 timestamp (`"2026-12-31T23:59:59Z"`)
126    /// or a relative duration (`"+30s"`, `"+1h"`, `"+2D"`).
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub expire_time: Option<String>,
129    /// Client-assigned order ID.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub cl_ord_id: Option<String>,
132    /// Whether the order must be a passive post-only order.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub post_only: Option<bool>,
135    /// Whether the order may only reduce an existing position.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub reduce_only: Option<bool>,
138    /// Trigger parameters for stop/take-profit orders.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub trigger: Option<KrakenWsTriggerParams>,
141    /// Leverage multiplier for margin orders; omit for non-margin (cash) orders.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub leverage: Option<u16>,
144    /// Conditional close order attached to the parent order.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub conditional: Option<KrakenWsConditionalParams>,
147}
148
149/// Parameters for the `amend_order` WebSocket method.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct KrakenWsAmendOrderParams {
152    /// Authentication token.
153    pub token: String,
154    /// Kraken order ID to amend (preferred over `cl_ord_id`).
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub order_id: Option<String>,
157    /// Client-assigned order ID to amend (used when `order_id` is unavailable).
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub cl_ord_id: Option<String>,
160    /// New order quantity (replaces the existing quantity).
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub order_qty: Option<f64>,
163    /// New limit price.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub limit_price: Option<f64>,
166    /// New trigger price (for conditional orders).
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub trigger_price: Option<f64>,
169}
170
171/// Parameters for the `cancel_order` WebSocket method.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct KrakenWsCancelOrderParams {
174    /// Authentication token.
175    pub token: String,
176    /// One or more Kraken order IDs to cancel (preferred over `cl_ord_id`).
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub order_id: Option<Vec<String>>,
179    /// One or more client-assigned order IDs to cancel (used when `order_id` is unavailable).
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub cl_ord_id: Option<Vec<String>>,
182}
183
184/// Parameters for the `batch_add` WebSocket method.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct KrakenWsBatchAddParams {
187    /// Trading pair symbol shared by all orders in the batch.
188    pub symbol: String,
189    /// List of orders to submit.
190    pub orders: Vec<KrakenWsBatchAddOrder>,
191    /// Authentication token.
192    pub token: String,
193}
194
195/// A single order entry within a `batch_add` request.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct KrakenWsBatchAddOrder {
198    /// Order type.
199    pub order_type: KrakenOrderType,
200    /// Order side.
201    pub side: KrakenOrderSide,
202    /// Order quantity.
203    pub order_qty: f64,
204    /// Limit price (required for limit orders).
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub limit_price: Option<f64>,
207    /// Client-assigned order ID.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub cl_ord_id: Option<String>,
210    /// Time in force policy.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub time_in_force: Option<KrakenTimeInForce>,
213    /// Expiration timestamp for `GoodTilDate` legs. Required by Kraken whenever
214    /// `time_in_force = GTD`. RFC3339 timestamp or relative duration string.
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub expire_time: Option<String>,
217    /// Whether the order must be a passive post-only order.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub post_only: Option<bool>,
220    /// Whether the order may only reduce an existing position.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub reduce_only: Option<bool>,
223    /// Leverage multiplier for margin orders; omit for non-margin (cash) orders.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub leverage: Option<u16>,
226    /// Trigger parameters for stop-loss and take-profit order types.
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub trigger: Option<KrakenWsTriggerParams>,
229}
230
231/// Trigger parameters for stop/take-profit order types.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct KrakenWsTriggerParams {
234    /// Reference price for the trigger.
235    pub reference: KrakenSpotTrigger,
236    /// Trigger price level.
237    pub price: f64,
238    /// Price direction for the trigger (above or below).
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub price_type: Option<String>,
241}
242
243/// Conditional close order attached to a parent order.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct KrakenWsConditionalParams {
246    /// Order type for the conditional leg.
247    pub order_type: KrakenOrderType,
248    /// Limit price for the conditional leg.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub limit_price: Option<f64>,
251    /// Stop price for the conditional leg.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub trigger_price: Option<f64>,
254}
255
256/// Response envelope for order-method WebSocket responses.
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct KrakenWsOrderResponse {
259    /// The method that triggered this response.
260    pub method: KrakenWsMethod,
261    /// Echo of the request ID (only present when the client sent one).
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub req_id: Option<u64>,
264    /// Whether the request succeeded.
265    pub success: bool,
266    /// ISO 8601 timestamp when the request was received.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub time_in: Option<String>,
269    /// ISO 8601 timestamp when the response was sent.
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub time_out: Option<String>,
272    /// Error message when `success` is `false`.
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub error: Option<String>,
275    /// Result payload when `success` is `true`.
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub result: Option<KrakenWsOrderResult>,
278}
279
280/// Result payload for single-order responses (`add_order`, `amend_order`, `cancel_order`).
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct KrakenWsOrderResult {
283    /// Kraken-assigned order ID.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub order_id: Option<String>,
286    /// Client-assigned order ID echoed back.
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub cl_ord_id: Option<String>,
289    /// Integer user reference echoed back.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub order_userref: Option<i64>,
292    /// Non-fatal warnings associated with the order.
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub warning: Option<Vec<String>>,
295    /// Per-order results for `batch_add` responses.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub orders: Option<Vec<KrakenWsBatchOrderResult>>,
298}
299
300/// Per-order outcome within a `batch_add` response.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct KrakenWsBatchOrderResult {
303    /// Whether this individual order succeeded.
304    pub success: bool,
305    /// Kraken-assigned order ID (present when `success` is `true`).
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub order_id: Option<String>,
308    /// Client-assigned order ID.
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub cl_ord_id: Option<String>,
311    /// Error message (present when `success` is `false`).
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub error: Option<String>,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
317#[serde(tag = "method")]
318pub enum KrakenWsResponse {
319    #[serde(rename = "pong")]
320    Pong(KrakenWsPong),
321    #[serde(rename = "subscribe")]
322    Subscribe(KrakenWsSubscribeResponse),
323    #[serde(rename = "unsubscribe")]
324    Unsubscribe(KrakenWsUnsubscribeResponse),
325    #[serde(other)]
326    Other,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct KrakenWsPong {
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub req_id: Option<u64>,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct KrakenWsSubscribeResponse {
337    pub success: bool,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub error: Option<String>,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub req_id: Option<u64>,
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub result: Option<KrakenWsSubscriptionResult>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct KrakenWsUnsubscribeResponse {
348    pub success: bool,
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub error: Option<String>,
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub req_id: Option<u64>,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct KrakenWsSubscriptionResult {
357    pub channel: KrakenWsChannel,
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub snapshot: Option<bool>,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct KrakenWsMessage {
364    pub channel: KrakenWsChannel,
365    #[serde(rename = "type")]
366    pub event_type: KrakenWsMessageType,
367    pub data: Vec<Value>,
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub symbol: Option<Ustr>,
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub timestamp: Option<DateTime<Utc>>,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct KrakenWsTickerData {
376    pub symbol: Ustr,
377    pub bid: f64,
378    pub bid_qty: f64,
379    pub ask: f64,
380    pub ask_qty: f64,
381    pub last: f64,
382    pub volume: f64,
383    pub vwap: f64,
384    pub low: f64,
385    pub high: f64,
386    pub change: f64,
387    pub change_pct: f64,
388    pub timestamp: DateTime<Utc>,
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct KrakenWsTradeData {
393    pub symbol: Ustr,
394    pub side: KrakenOrderSide,
395    pub price: f64,
396    pub qty: f64,
397    pub ord_type: KrakenOrderType,
398    pub trade_id: i64,
399    pub timestamp: DateTime<Utc>,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct KrakenWsBookData {
404    pub symbol: Ustr,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub bids: Option<Vec<KrakenWsBookLevel>>,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub asks: Option<Vec<KrakenWsBookLevel>>,
409    pub checksum: Option<u32>,
410    pub timestamp: DateTime<Utc>,
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct KrakenWsBookLevel {
415    pub price: f64,
416    pub qty: f64,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct KrakenWsOhlcData {
421    pub symbol: Ustr,
422    pub interval: u32,
423    pub interval_begin: DateTime<Utc>,
424    pub open: f64,
425    pub high: f64,
426    pub low: f64,
427    pub close: f64,
428    pub volume: f64,
429    pub vwap: f64,
430    pub trades: i64,
431}
432
433/// Execution message from the Kraken executions channel.
434#[derive(Debug, Clone, Serialize, Deserialize)]
435pub struct KrakenWsExecutionData {
436    /// Execution type.
437    pub exec_type: KrakenExecType,
438    /// Kraken order ID.
439    pub order_id: String,
440    /// Client order ID (if provided when order was submitted).
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub cl_ord_id: Option<String>,
443    /// Trading pair symbol.
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub symbol: Option<String>,
446    /// Order side.
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub side: Option<KrakenOrderSide>,
449    /// Order type.
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub order_type: Option<KrakenOrderType>,
452    /// Order quantity.
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub order_qty: Option<f64>,
455    /// Limit price.
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub limit_price: Option<f64>,
458    /// Order status.
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub order_status: Option<KrakenWsOrderStatus>,
461    /// Cumulative filled quantity.
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub cum_qty: Option<f64>,
464    /// Cumulative cost.
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub cum_cost: Option<f64>,
467    /// Average fill price.
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub avg_price: Option<f64>,
470    /// Time in force.
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub time_in_force: Option<KrakenTimeInForce>,
473    /// Post only flag.
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub post_only: Option<bool>,
476    /// Reduce only flag.
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub reduce_only: Option<bool>,
479    /// Event timestamp.
480    pub timestamp: DateTime<Utc>,
481    /// Execution/trade ID.
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub exec_id: Option<String>,
484    /// Last fill quantity.
485    #[serde(skip_serializing_if = "Option::is_none")]
486    pub last_qty: Option<f64>,
487    /// Last fill price.
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub last_price: Option<f64>,
490    /// Trade cost.
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub cost: Option<f64>,
493    /// Liquidity indicator.
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub liquidity_ind: Option<KrakenLiquidityInd>,
496    /// Fees array.
497    #[serde(skip_serializing_if = "Option::is_none")]
498    pub fees: Option<Vec<KrakenWsFee>>,
499    /// Fee in USD equivalent.
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub fee_usd_equiv: Option<f64>,
502    /// Cancel reason (when exec_type is Canceled/Expired).
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub reason: Option<String>,
505}
506
507/// Fee information from execution messages.
508#[derive(Debug, Clone, Serialize, Deserialize)]
509pub struct KrakenWsFee {
510    /// Fee asset.
511    pub asset: String,
512    /// Fee quantity.
513    pub qty: f64,
514}
515
516#[cfg(test)]
517mod tests {
518    use rstest::rstest;
519
520    use super::*;
521
522    fn load_test_data(filename: &str) -> String {
523        let path = format!("test_data/{filename}");
524        std::fs::read_to_string(&path)
525            .unwrap_or_else(|e| panic!("Failed to load test data from {path}: {e}"))
526    }
527
528    #[rstest]
529    fn test_parse_subscribe_response() {
530        let data = load_test_data("ws_subscribe_response.json");
531        let response: KrakenWsResponse =
532            serde_json::from_str(&data).expect("Failed to parse subscribe response");
533
534        match response {
535            KrakenWsResponse::Subscribe(sub) => {
536                assert!(sub.success);
537                assert_eq!(sub.req_id, Some(1));
538                assert!(sub.result.is_some());
539                let result = sub.result.unwrap();
540                assert_eq!(result.channel, KrakenWsChannel::Ticker);
541            }
542            _ => panic!("Expected Subscribe response"),
543        }
544    }
545
546    #[rstest]
547    fn test_parse_pong() {
548        let data = load_test_data("ws_pong.json");
549        let response: KrakenWsResponse = serde_json::from_str(&data).expect("Failed to parse pong");
550
551        match response {
552            KrakenWsResponse::Pong(pong) => {
553                assert_eq!(pong.req_id, Some(42));
554            }
555            _ => panic!("Expected Pong response"),
556        }
557    }
558
559    #[rstest]
560    fn test_parse_ticker_snapshot() {
561        let data = load_test_data("ws_ticker_snapshot.json");
562        let message: KrakenWsMessage =
563            serde_json::from_str(&data).expect("Failed to parse ticker snapshot");
564
565        assert_eq!(message.channel, KrakenWsChannel::Ticker);
566        assert_eq!(message.event_type, KrakenWsMessageType::Snapshot);
567        assert!(!message.data.is_empty());
568
569        let ticker: KrakenWsTickerData =
570            serde_json::from_value(message.data[0].clone()).expect("Failed to parse ticker data");
571        assert_eq!(ticker.symbol.as_str(), "BTC/USD");
572        assert!(ticker.bid.is_finite() && ticker.bid > 0.0);
573        assert!(ticker.ask.is_finite() && ticker.ask > 0.0);
574        assert!(ticker.last.is_finite() && ticker.last > 0.0);
575        assert_eq!(
576            ticker.timestamp.timestamp_nanos_opt().unwrap(),
577            1_671_960_659_123_456_000
578        );
579    }
580
581    #[rstest]
582    fn test_parse_trade_update() {
583        let data = load_test_data("ws_trade_update.json");
584        let message: KrakenWsMessage =
585            serde_json::from_str(&data).expect("Failed to parse trade update");
586
587        assert_eq!(message.channel, KrakenWsChannel::Trade);
588        assert_eq!(message.event_type, KrakenWsMessageType::Update);
589        assert_eq!(message.data.len(), 2);
590
591        let trade: KrakenWsTradeData =
592            serde_json::from_value(message.data[0].clone()).expect("Failed to parse trade data");
593        assert_eq!(trade.symbol.as_str(), "BTC/USD");
594        assert!(trade.price.is_finite() && trade.price > 0.0);
595        assert!(trade.qty.is_finite() && trade.qty > 0.0);
596        assert!(trade.trade_id > 0);
597    }
598
599    #[rstest]
600    fn test_parse_book_snapshot() {
601        let data = load_test_data("ws_book_snapshot.json");
602        let message: KrakenWsMessage =
603            serde_json::from_str(&data).expect("Failed to parse book snapshot");
604
605        assert_eq!(message.channel, KrakenWsChannel::Book);
606        assert_eq!(message.event_type, KrakenWsMessageType::Snapshot);
607
608        let book: KrakenWsBookData =
609            serde_json::from_value(message.data[0].clone()).expect("Failed to parse book data");
610        assert_eq!(book.symbol.as_str(), "BTC/USD");
611        assert!(book.bids.is_some());
612        assert!(book.asks.is_some());
613        assert!(book.checksum.is_some());
614        assert_eq!(
615            book.timestamp.timestamp_nanos_opt().unwrap(),
616            1_696_613_755_440_295_000
617        );
618
619        let bids = book.bids.unwrap();
620        assert_eq!(bids.len(), 3);
621        assert!(bids[0].price.is_finite() && bids[0].price > 0.0);
622        assert!(bids[0].qty.is_finite() && bids[0].qty > 0.0);
623    }
624
625    #[rstest]
626    fn test_parse_book_update() {
627        let data = load_test_data("ws_book_update.json");
628        let message: KrakenWsMessage =
629            serde_json::from_str(&data).expect("Failed to parse book update");
630
631        assert_eq!(message.channel, KrakenWsChannel::Book);
632        assert_eq!(message.event_type, KrakenWsMessageType::Update);
633
634        let book: KrakenWsBookData =
635            serde_json::from_value(message.data[0].clone()).expect("Failed to parse book data");
636        assert_eq!(
637            book.timestamp.timestamp_nanos_opt().unwrap(),
638            1_696_613_755_440_295_000
639        );
640        assert!(book.checksum.is_some());
641    }
642
643    #[rstest]
644    fn test_parse_ohlc_update() {
645        let data = load_test_data("ws_ohlc_update.json");
646        let message: KrakenWsMessage =
647            serde_json::from_str(&data).expect("Failed to parse OHLC update");
648
649        assert_eq!(message.channel, KrakenWsChannel::Ohlc);
650        assert_eq!(message.event_type, KrakenWsMessageType::Update);
651
652        let ohlc: KrakenWsOhlcData =
653            serde_json::from_value(message.data[0].clone()).expect("Failed to parse OHLC data");
654        assert_eq!(ohlc.symbol.as_str(), "BTC/USD");
655        assert!(ohlc.open.is_finite() && ohlc.open > 0.0);
656        assert!(ohlc.high.is_finite() && ohlc.high > 0.0);
657        assert!(ohlc.low.is_finite() && ohlc.low > 0.0);
658        assert!(ohlc.close.is_finite() && ohlc.close > 0.0);
659        assert_eq!(ohlc.interval, 1);
660        assert!(ohlc.trades > 0);
661    }
662
663    #[rstest]
664    fn test_serialize_add_order_request() {
665        let request = KrakenWsRequest {
666            method: KrakenWsMethod::AddOrder,
667            params: Some(KrakenWsParams::AddOrder(KrakenWsAddOrderParams {
668                order_type: KrakenOrderType::Limit,
669                side: KrakenOrderSide::Buy,
670                order_qty: 0.01,
671                symbol: "BTC/USD".to_string(),
672                limit_price: Some(30000.0),
673                time_in_force: Some(KrakenTimeInForce::GoodTilCancelled),
674                expire_time: None,
675                cl_ord_id: Some("O-20260505-000001".to_string()),
676                post_only: Some(true),
677                reduce_only: None,
678                leverage: None,
679                trigger: None,
680                conditional: None,
681                token: "TESTTOKEN".to_string(),
682            })),
683            req_id: Some(42),
684        };
685
686        let serialized = serde_json::to_string(&request).expect("Failed to serialize");
687        let expected: serde_json::Value =
688            serde_json::from_str(&load_test_data("ws_add_order_request.json"))
689                .expect("Failed to parse fixture");
690        let actual: serde_json::Value =
691            serde_json::from_str(&serialized).expect("Failed to parse serialized");
692        assert_eq!(actual, expected);
693    }
694
695    #[rstest]
696    fn test_serialize_amend_order_request() {
697        let request = KrakenWsRequest {
698            method: KrakenWsMethod::AmendOrder,
699            params: Some(KrakenWsParams::AmendOrder(KrakenWsAmendOrderParams {
700                order_id: Some("OABCDE-12345-FGHIJ".to_string()),
701                cl_ord_id: None,
702                order_qty: Some(0.005),
703                limit_price: None,
704                trigger_price: None,
705                token: "TESTTOKEN".to_string(),
706            })),
707            req_id: Some(43),
708        };
709
710        let serialized = serde_json::to_string(&request).expect("Failed to serialize");
711        let expected: serde_json::Value =
712            serde_json::from_str(&load_test_data("ws_amend_order_request.json"))
713                .expect("Failed to parse fixture");
714        let actual: serde_json::Value =
715            serde_json::from_str(&serialized).expect("Failed to parse serialized");
716        assert_eq!(actual, expected);
717    }
718
719    #[rstest]
720    fn test_serialize_cancel_order_request() {
721        let request = KrakenWsRequest {
722            method: KrakenWsMethod::CancelOrder,
723            params: Some(KrakenWsParams::CancelOrder(KrakenWsCancelOrderParams {
724                order_id: Some(vec!["OABCDE-12345-FGHIJ".to_string()]),
725                cl_ord_id: None,
726                token: "TESTTOKEN".to_string(),
727            })),
728            req_id: Some(44),
729        };
730
731        let serialized = serde_json::to_string(&request).expect("Failed to serialize");
732        let expected: serde_json::Value =
733            serde_json::from_str(&load_test_data("ws_cancel_order_request.json"))
734                .expect("Failed to parse fixture");
735        let actual: serde_json::Value =
736            serde_json::from_str(&serialized).expect("Failed to parse serialized");
737        assert_eq!(actual, expected);
738    }
739
740    #[rstest]
741    fn test_serialize_batch_add_request() {
742        let request = KrakenWsRequest {
743            method: KrakenWsMethod::BatchAdd,
744            params: Some(KrakenWsParams::BatchAdd(KrakenWsBatchAddParams {
745                symbol: "BTC/USD".to_string(),
746                orders: vec![
747                    KrakenWsBatchAddOrder {
748                        order_type: KrakenOrderType::Limit,
749                        side: KrakenOrderSide::Buy,
750                        order_qty: 0.01,
751                        limit_price: Some(30000.0),
752                        cl_ord_id: Some("O-A".to_string()),
753                        time_in_force: None,
754                        expire_time: None,
755                        post_only: None,
756                        reduce_only: None,
757                        leverage: None,
758                        trigger: None,
759                    },
760                    KrakenWsBatchAddOrder {
761                        order_type: KrakenOrderType::Limit,
762                        side: KrakenOrderSide::Sell,
763                        order_qty: 0.01,
764                        limit_price: Some(31000.0),
765                        cl_ord_id: Some("O-B".to_string()),
766                        time_in_force: None,
767                        expire_time: None,
768                        post_only: None,
769                        reduce_only: None,
770                        leverage: None,
771                        trigger: None,
772                    },
773                ],
774                token: "TESTTOKEN".to_string(),
775            })),
776            req_id: Some(45),
777        };
778
779        let serialized = serde_json::to_string(&request).expect("Failed to serialize");
780        let expected: serde_json::Value =
781            serde_json::from_str(&load_test_data("ws_batch_add_request.json"))
782                .expect("Failed to parse fixture");
783        let actual: serde_json::Value =
784            serde_json::from_str(&serialized).expect("Failed to parse serialized");
785        assert_eq!(actual, expected);
786    }
787
788    #[rstest]
789    fn test_add_order_params_serializes_expire_time_for_gtd() {
790        let params = KrakenWsAddOrderParams {
791            order_type: KrakenOrderType::Limit,
792            side: KrakenOrderSide::Buy,
793            order_qty: 0.01,
794            symbol: "BTC/USD".to_string(),
795            token: "TKN".to_string(),
796            limit_price: Some(30000.0),
797            time_in_force: Some(KrakenTimeInForce::GoodTilDate),
798            expire_time: Some("2026-12-31T23:59:59+00:00".to_string()),
799            cl_ord_id: None,
800            post_only: None,
801            reduce_only: None,
802            leverage: None,
803            trigger: None,
804            conditional: None,
805        };
806        let value: serde_json::Value =
807            serde_json::from_str(&serde_json::to_string(&params).expect("serialize"))
808                .expect("json");
809
810        assert_eq!(value["time_in_force"], "GTD");
811        assert_eq!(value["expire_time"], "2026-12-31T23:59:59+00:00");
812    }
813
814    #[rstest]
815    fn test_add_order_params_omits_expire_time_when_absent() {
816        let params = KrakenWsAddOrderParams {
817            order_type: KrakenOrderType::Limit,
818            side: KrakenOrderSide::Buy,
819            order_qty: 0.01,
820            symbol: "BTC/USD".to_string(),
821            token: "TKN".to_string(),
822            limit_price: Some(30000.0),
823            time_in_force: None,
824            expire_time: None,
825            cl_ord_id: None,
826            post_only: None,
827            reduce_only: None,
828            leverage: None,
829            trigger: None,
830            conditional: None,
831        };
832        let value: serde_json::Value =
833            serde_json::from_str(&serde_json::to_string(&params).expect("serialize"))
834                .expect("json");
835
836        assert!(value.get("expire_time").is_none());
837    }
838
839    #[rstest]
840    fn test_batch_add_order_serializes_leverage_and_trigger() {
841        let order = KrakenWsBatchAddOrder {
842            order_type: KrakenOrderType::StopLossLimit,
843            side: KrakenOrderSide::Buy,
844            order_qty: 0.01,
845            limit_price: Some(31000.0),
846            cl_ord_id: Some("O-CONDITIONAL".to_string()),
847            time_in_force: None,
848            expire_time: None,
849            post_only: None,
850            reduce_only: None,
851            leverage: Some(2),
852            trigger: Some(KrakenWsTriggerParams {
853                reference: KrakenSpotTrigger::Last,
854                price: 30500.0,
855                price_type: None,
856            }),
857        };
858        let value: serde_json::Value =
859            serde_json::from_str(&serde_json::to_string(&order).expect("serialize")).expect("json");
860
861        assert_eq!(
862            value["leverage"], 2,
863            "leverage must be serialized for margin batch legs",
864        );
865        assert!(
866            value.get("trigger").is_some(),
867            "trigger must be serialized for conditional batch legs",
868        );
869        assert_eq!(value["trigger"]["reference"], "last");
870        assert_eq!(value["trigger"]["price"], 30500.0);
871    }
872
873    #[rstest]
874    fn test_batch_add_order_omits_leverage_and_trigger_when_absent() {
875        let order = KrakenWsBatchAddOrder {
876            order_type: KrakenOrderType::Limit,
877            side: KrakenOrderSide::Buy,
878            order_qty: 0.01,
879            limit_price: Some(30000.0),
880            cl_ord_id: Some("O-PLAIN".to_string()),
881            time_in_force: None,
882            expire_time: None,
883            post_only: None,
884            reduce_only: None,
885            leverage: None,
886            trigger: None,
887        };
888        let value: serde_json::Value =
889            serde_json::from_str(&serde_json::to_string(&order).expect("serialize")).expect("json");
890
891        assert!(
892            value.get("leverage").is_none(),
893            "leverage must be omitted when None"
894        );
895        assert!(
896            value.get("trigger").is_none(),
897            "trigger must be omitted when None"
898        );
899    }
900
901    #[rstest]
902    fn test_deserialize_add_order_response_success() {
903        let data = load_test_data("ws_add_order_response_success.json");
904        let response: KrakenWsOrderResponse =
905            serde_json::from_str(&data).expect("Failed to parse add_order success response");
906
907        assert_eq!(response.method, KrakenWsMethod::AddOrder);
908        assert_eq!(response.req_id, Some(42));
909        assert!(response.success);
910        assert!(response.error.is_none());
911
912        let result = response.result.expect("Expected result");
913        assert_eq!(result.order_id.as_deref(), Some("OABCDE-12345-FGHIJ"));
914        assert_eq!(result.cl_ord_id.as_deref(), Some("O-20260505-000001"));
915        assert_eq!(result.order_userref, Some(0));
916    }
917
918    #[rstest]
919    fn test_deserialize_add_order_response_failure() {
920        let data = load_test_data("ws_add_order_response_failure.json");
921        let response: KrakenWsOrderResponse =
922            serde_json::from_str(&data).expect("Failed to parse add_order failure response");
923
924        assert_eq!(response.method, KrakenWsMethod::AddOrder);
925        assert_eq!(response.req_id, Some(99));
926        assert!(!response.success);
927        assert_eq!(response.error.as_deref(), Some("EOrder:Insufficient funds"));
928        assert!(response.result.is_none());
929    }
930
931    #[rstest]
932    fn test_deserialize_batch_add_response_partial() {
933        let data = load_test_data("ws_batch_add_response_partial.json");
934        let response: KrakenWsOrderResponse =
935            serde_json::from_str(&data).expect("Failed to parse batch_add partial response");
936
937        assert_eq!(response.method, KrakenWsMethod::BatchAdd);
938        assert_eq!(response.req_id, Some(45));
939        assert!(response.success);
940
941        let result = response.result.expect("Expected result");
942        let orders = result.orders.expect("Expected orders");
943        assert_eq!(orders.len(), 2);
944
945        assert!(orders[0].success);
946        assert_eq!(orders[0].order_id.as_deref(), Some("O1"));
947        assert_eq!(orders[0].cl_ord_id.as_deref(), Some("O-A"));
948        assert!(orders[0].error.is_none());
949
950        assert!(!orders[1].success);
951        assert!(orders[1].order_id.is_none());
952        assert_eq!(orders[1].cl_ord_id.as_deref(), Some("O-B"));
953        assert_eq!(orders[1].error.as_deref(), Some("EOrder:Invalid price"));
954    }
955}