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