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