Skip to main content

nautilus_kraken/websocket/spot_v2/
parse.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//! WebSocket message parsers for converting Kraken streaming data to Nautilus domain models.
17
18use anyhow::Context;
19use jiff::Timestamp;
20use nautilus_core::{UUID4, nanos::UnixNanos};
21use nautilus_model::{
22    data::{Bar, BarSpecification, BarType, BookOrder, OrderBookDelta, QuoteTick, TradeTick},
23    enums::{
24        AggregationSource, AggressorSide, BarAggregation, BookAction, LiquiditySide, OrderSide,
25        OrderStatus, OrderType, PriceType, RecordFlag, TimeInForce, TriggerType,
26    },
27    identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
28    instruments::{Instrument, any::InstrumentAny},
29    reports::{FillReport, OrderStatusReport},
30    types::{Money, Price, Quantity},
31};
32use rust_decimal::Decimal;
33
34use super::{
35    enums::{KrakenExecType, KrakenLiquidityInd, KrakenWsOrderStatus},
36    messages::{
37        KrakenSpotWsMessage, KrakenWsBookData, KrakenWsBookLevel, KrakenWsExecutionData,
38        KrakenWsOhlcData, KrakenWsOrderResponse, KrakenWsTickerData, KrakenWsTradeData,
39    },
40};
41use crate::common::{
42    enums::{KrakenOrderSide, KrakenOrderType, KrakenTimeInForce},
43    parse::get_currency,
44};
45
46/// Parses Kraken WebSocket ticker data into a Nautilus quote tick.
47///
48/// # Errors
49///
50/// Returns an error if:
51/// - Bid or ask price/quantity cannot be parsed.
52pub fn parse_quote_tick(
53    ticker: &KrakenWsTickerData,
54    instrument: &InstrumentAny,
55    ts_init: UnixNanos,
56) -> anyhow::Result<QuoteTick> {
57    let instrument_id = instrument.id();
58    let price_precision = instrument.price_precision();
59    let size_precision = instrument.size_precision();
60
61    let bid_price = Price::from_decimal_dp(ticker.bid, price_precision).with_context(|| {
62        format!("Failed to construct bid Price with precision {price_precision}")
63    })?;
64    let bid_size =
65        Quantity::from_decimal_dp(ticker.bid_qty, size_precision).with_context(|| {
66            format!("Failed to construct bid Quantity with precision {size_precision}")
67        })?;
68
69    let ask_price = Price::from_decimal_dp(ticker.ask, price_precision).with_context(|| {
70        format!("Failed to construct ask Price with precision {price_precision}")
71    })?;
72    let ask_size =
73        Quantity::from_decimal_dp(ticker.ask_qty, size_precision).with_context(|| {
74            format!("Failed to construct ask Quantity with precision {size_precision}")
75        })?;
76
77    let ts_event = datetime_to_nanos(ticker.timestamp, "ticker.timestamp")?;
78
79    Ok(QuoteTick::new(
80        instrument_id,
81        bid_price,
82        ask_price,
83        bid_size,
84        ask_size,
85        ts_event,
86        ts_init,
87    ))
88}
89
90/// Parses Kraken WebSocket trade data into a Nautilus trade tick.
91///
92/// # Errors
93///
94/// Returns an error if:
95/// - Price or quantity cannot be parsed.
96/// - Timestamp is invalid.
97pub fn parse_trade_tick(
98    trade: &KrakenWsTradeData,
99    instrument: &InstrumentAny,
100    ts_init: UnixNanos,
101) -> anyhow::Result<TradeTick> {
102    let instrument_id = instrument.id();
103    let price_precision = instrument.price_precision();
104    let size_precision = instrument.size_precision();
105
106    let price = Price::from_decimal_dp(trade.price, price_precision)
107        .with_context(|| format!("Failed to construct Price with precision {price_precision}"))?;
108    let size = Quantity::from_decimal_dp(trade.qty, size_precision)
109        .with_context(|| format!("Failed to construct Quantity with precision {size_precision}"))?;
110
111    let aggressor = match trade.side {
112        KrakenOrderSide::Buy => AggressorSide::Buy,
113        KrakenOrderSide::Sell => AggressorSide::Sell,
114    };
115
116    let trade_id = TradeId::new_checked(trade.trade_id.to_string())?;
117    let ts_event = datetime_to_nanos(trade.timestamp, "trade.timestamp")?;
118
119    TradeTick::new_checked(
120        instrument_id,
121        price,
122        size,
123        aggressor,
124        trade_id,
125        ts_event,
126        ts_init,
127    )
128    .context("Failed to construct TradeTick from Kraken WebSocket trade")
129}
130
131/// Parses Kraken WebSocket book data into Nautilus order book deltas.
132///
133/// Returns a vector of deltas, one for each bid and ask level.
134///
135/// # Errors
136///
137/// Returns an error if:
138/// - Price or quantity cannot be parsed.
139/// - Timestamp is invalid.
140pub fn parse_book_deltas(
141    book: &KrakenWsBookData,
142    instrument: &InstrumentAny,
143    sequence: u64,
144    is_snapshot: bool,
145    ts_init: UnixNanos,
146) -> anyhow::Result<Vec<OrderBookDelta>> {
147    let instrument_id = instrument.id();
148    let price_precision = instrument.price_precision();
149    let size_precision = instrument.size_precision();
150
151    let ts_event = datetime_to_nanos(book.timestamp, "book.timestamp")?;
152
153    let mut current_sequence = sequence;
154    let mut deltas = Vec::new();
155
156    if is_snapshot {
157        deltas.push(OrderBookDelta::clear(
158            instrument_id,
159            current_sequence,
160            ts_event,
161            ts_init,
162        ));
163        current_sequence += 1;
164    }
165
166    if let Some(ref bids) = book.bids {
167        parse_book_side_levels(
168            bids,
169            OrderSide::Buy,
170            is_snapshot,
171            instrument_id,
172            price_precision,
173            size_precision,
174            ts_event,
175            ts_init,
176            &mut current_sequence,
177            &mut deltas,
178        )?;
179    }
180
181    if let Some(ref asks) = book.asks {
182        parse_book_side_levels(
183            asks,
184            OrderSide::Sell,
185            is_snapshot,
186            instrument_id,
187            price_precision,
188            size_precision,
189            ts_event,
190            ts_init,
191            &mut current_sequence,
192            &mut deltas,
193        )?;
194    }
195
196    if let Some(last) = deltas.last_mut() {
197        last.flags |= RecordFlag::F_LAST as u8;
198    }
199
200    Ok(deltas)
201}
202
203#[expect(clippy::too_many_arguments)]
204fn parse_book_side_levels(
205    levels: &[KrakenWsBookLevel],
206    side: OrderSide,
207    is_snapshot: bool,
208    instrument_id: InstrumentId,
209    price_precision: u8,
210    size_precision: u8,
211    ts_event: UnixNanos,
212    ts_init: UnixNanos,
213    current_sequence: &mut u64,
214    deltas: &mut Vec<OrderBookDelta>,
215) -> anyhow::Result<()> {
216    for level in levels {
217        let Some(delta) = parse_book_level(
218            level,
219            side,
220            is_snapshot,
221            instrument_id,
222            price_precision,
223            size_precision,
224            *current_sequence,
225            ts_event,
226            ts_init,
227        )?
228        else {
229            continue;
230        };
231        deltas.push(delta);
232        *current_sequence += 1;
233    }
234
235    Ok(())
236}
237
238#[expect(clippy::too_many_arguments)]
239fn parse_book_level(
240    level: &KrakenWsBookLevel,
241    side: OrderSide,
242    is_snapshot: bool,
243    instrument_id: InstrumentId,
244    price_precision: u8,
245    size_precision: u8,
246    sequence: u64,
247    ts_event: UnixNanos,
248    ts_init: UnixNanos,
249) -> anyhow::Result<Option<OrderBookDelta>> {
250    let price = Price::from_decimal_dp(level.price, price_precision)
251        .with_context(|| format!("Failed to construct Price with precision {price_precision}"))?;
252    let size = Quantity::from_decimal_dp(level.qty, size_precision)
253        .with_context(|| format!("Failed to construct Quantity with precision {size_precision}"))?;
254
255    let action = if is_snapshot {
256        if size.is_zero() {
257            return Ok(None);
258        }
259        BookAction::Add
260    } else if size.is_zero() {
261        BookAction::Delete
262    } else {
263        BookAction::Update
264    };
265
266    let order_id = price.raw() as u64;
267
268    let order = BookOrder::new(side, price, size, order_id);
269    let mut flags = RecordFlag::F_MBP as u8;
270    if is_snapshot {
271        flags |= RecordFlag::F_SNAPSHOT as u8;
272    }
273
274    Ok(Some(OrderBookDelta::new(
275        instrument_id,
276        action,
277        order,
278        flags,
279        sequence,
280        ts_event,
281        ts_init,
282    )))
283}
284
285pub(super) fn datetime_to_nanos(value: Timestamp, field: &str) -> anyhow::Result<UnixNanos> {
286    let nanos = u64::try_from(value.as_nanosecond())
287        .with_context(|| format!("Timestamp predates Unix epoch: {field}='{value}'"))?;
288    Ok(UnixNanos::from(nanos))
289}
290
291/// Parses Kraken WebSocket OHLC data into a Nautilus bar.
292///
293/// The bar's `ts_event` is computed as `interval_begin` + `interval` minutes.
294///
295/// # Errors
296///
297/// Returns an error if:
298/// - Price or quantity values cannot be parsed.
299/// - The interval cannot be converted to a valid bar specification.
300pub fn parse_ws_bar(
301    ohlc: &KrakenWsOhlcData,
302    instrument: &InstrumentAny,
303    ts_init: UnixNanos,
304) -> anyhow::Result<Bar> {
305    let instrument_id = instrument.id();
306    let price_precision = instrument.price_precision();
307    let size_precision = instrument.size_precision();
308
309    let open = Price::from_decimal_dp(ohlc.open, price_precision)?;
310    let high = Price::from_decimal_dp(ohlc.high, price_precision)?;
311    let low = Price::from_decimal_dp(ohlc.low, price_precision)?;
312    let close = Price::from_decimal_dp(ohlc.close, price_precision)?;
313    let volume = Quantity::from_decimal_dp(ohlc.volume, size_precision)?;
314
315    let bar_spec = interval_to_bar_spec(ohlc.interval)?;
316    let bar_type = BarType::new(instrument_id, bar_spec, AggregationSource::External);
317
318    // Compute bar close time: interval_begin + interval minutes
319    let interval_secs = i64::from(ohlc.interval) * 60;
320    let close_time = ohlc.interval_begin + jiff::SignedDuration::from_secs(interval_secs);
321    let ts_event = UnixNanos::from(u64::try_from(close_time.as_nanosecond()).unwrap_or(0));
322
323    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
324}
325
326/// Converts a Kraken OHLC interval (minutes) to a Nautilus bar specification.
327fn interval_to_bar_spec(interval: u32) -> anyhow::Result<BarSpecification> {
328    let (step, aggregation) = match interval {
329        1 => (1, BarAggregation::Minute),
330        5 => (5, BarAggregation::Minute),
331        15 => (15, BarAggregation::Minute),
332        30 => (30, BarAggregation::Minute),
333        60 => (1, BarAggregation::Hour),
334        240 => (4, BarAggregation::Hour),
335        1440 => (1, BarAggregation::Day),
336        10080 => (1, BarAggregation::Week),
337        21600 => (15, BarAggregation::Day), // 21600 min = 360 hours = 15 days
338        _ => anyhow::bail!("Unsupported Kraken OHLC interval: {interval}"),
339    };
340
341    Ok(BarSpecification::new(step, aggregation, PriceType::Last))
342}
343
344/// Parses Kraken execution type and order status to Nautilus order status.
345fn parse_order_status(
346    exec_type: KrakenExecType,
347    order_status: Option<KrakenWsOrderStatus>,
348) -> OrderStatus {
349    match exec_type {
350        KrakenExecType::Canceled => return OrderStatus::Canceled,
351        KrakenExecType::Expired => return OrderStatus::Expired,
352        KrakenExecType::Filled => return OrderStatus::Filled,
353        KrakenExecType::Trade => {
354            return match order_status {
355                Some(KrakenWsOrderStatus::Filled) => OrderStatus::Filled,
356                Some(KrakenWsOrderStatus::PartiallyFilled) | None => OrderStatus::PartiallyFilled,
357                Some(status) => status.into(),
358            };
359        }
360        _ => {}
361    }
362
363    match order_status {
364        Some(status) => status.into(),
365        None => OrderStatus::Accepted,
366    }
367}
368
369/// Parses Kraken order type to Nautilus order type.
370fn parse_order_type(order_type: Option<KrakenOrderType>) -> OrderType {
371    match order_type {
372        Some(KrakenOrderType::Market) => OrderType::Market,
373        Some(KrakenOrderType::Limit) => OrderType::Limit,
374        Some(KrakenOrderType::StopLoss) => OrderType::StopMarket,
375        Some(KrakenOrderType::TakeProfit) => OrderType::MarketIfTouched,
376        Some(KrakenOrderType::StopLossLimit) => OrderType::StopLimit,
377        Some(KrakenOrderType::TakeProfitLimit) => OrderType::LimitIfTouched,
378        // Trailing stops lack offset fields in WS reports, map to non-trailing equivalents
379        Some(KrakenOrderType::TrailingStop) => OrderType::StopMarket,
380        Some(KrakenOrderType::TrailingStopLimit) => OrderType::StopLimit,
381        Some(KrakenOrderType::SettlePosition) => OrderType::Market,
382        None => OrderType::Limit,
383    }
384}
385
386/// Parses Kraken time-in-force to Nautilus time-in-force.
387fn parse_time_in_force(
388    time_in_force: Option<KrakenTimeInForce>,
389    post_only: Option<bool>,
390) -> TimeInForce {
391    // Handle post_only flag
392    if post_only == Some(true) {
393        return TimeInForce::Gtc;
394    }
395
396    match time_in_force {
397        Some(KrakenTimeInForce::GoodTilCancelled) => TimeInForce::Gtc,
398        Some(KrakenTimeInForce::ImmediateOrCancel) => TimeInForce::Ioc,
399        Some(KrakenTimeInForce::GoodTilDate) => TimeInForce::Gtd,
400        Some(KrakenTimeInForce::FillOrKill) => TimeInForce::Fok,
401        None => TimeInForce::Gtc,
402    }
403}
404
405fn parse_liquidity_side(liquidity_ind: Option<KrakenLiquidityInd>) -> LiquiditySide {
406    liquidity_ind.map_or(LiquiditySide::NoLiquiditySide, Into::into)
407}
408
409/// Parses a Kraken WebSocket execution message into an [`OrderStatusReport`].
410///
411/// # Errors
412///
413/// Returns an error if required fields are missing or cannot be parsed.
414pub fn parse_ws_order_status_report(
415    exec: &KrakenWsExecutionData,
416    instrument: &InstrumentAny,
417    account_id: AccountId,
418    cached_order_qty: Option<Decimal>,
419    ts_init: UnixNanos,
420) -> anyhow::Result<OrderStatusReport> {
421    let instrument_id = instrument.id();
422    let venue_order_id = VenueOrderId::new(&exec.order_id);
423    let order_side = exec.side.map(Into::into);
424    let order_type = parse_order_type(exec.order_type);
425    let time_in_force = parse_time_in_force(exec.time_in_force, exec.post_only);
426    let order_status = parse_order_status(exec.exec_type, exec.order_status);
427
428    let price_precision = instrument.price_precision();
429    let size_precision = instrument.size_precision();
430
431    // Quantity fallback: order_qty -> cached -> cum_qty -> last_qty (for trade snapshots)
432    let last_qty = exec
433        .last_qty
434        .map(|qty| Quantity::from_decimal_dp(qty, size_precision))
435        .transpose()
436        .context("Failed to parse last_qty")?;
437
438    let filled_qty = exec
439        .cum_qty
440        .map(|qty| Quantity::from_decimal_dp(qty, size_precision))
441        .transpose()
442        .context("Failed to parse cum_qty")?
443        .or(last_qty)
444        .unwrap_or_else(|| Quantity::zero(size_precision));
445
446    let quantity = exec
447        .order_qty
448        .or(cached_order_qty)
449        .map(|qty| Quantity::from_decimal_dp(qty, size_precision))
450        .transpose()
451        .context("Failed to parse order_qty")?
452        .unwrap_or(filled_qty);
453
454    let ts_event = datetime_to_nanos(exec.timestamp, "execution.timestamp")?;
455
456    let mut report = OrderStatusReport::new(
457        account_id,
458        instrument_id,
459        None, // client_order_id set below if present
460        venue_order_id,
461        order_side,
462        order_type,
463        time_in_force,
464        order_status,
465        quantity,
466        filled_qty,
467        ts_event,
468        ts_event,
469        ts_init,
470        Some(UUID4::new()),
471    );
472
473    if let Some(ref cl_ord_id) = exec.cl_ord_id
474        && !cl_ord_id.is_empty()
475    {
476        report = report.with_client_order_id(ClientOrderId::new(cl_ord_id));
477    }
478
479    // The order's price is its limit alone: a fill price is `avg_px`, and a frame without the
480    // limit states no price
481    if let Some(px) = exec.limit_price.filter(|p| *p > Decimal::ZERO) {
482        let price =
483            Price::from_decimal_dp(px, price_precision).context("Failed to parse order price")?;
484        report = report.with_price(price);
485    }
486
487    // avg_px fallback: avg_price -> cum_cost / cum_qty -> last_price (for single trades/snapshots)
488    let avg_px = exec
489        .avg_price
490        .filter(|p| *p > Decimal::ZERO)
491        .or_else(|| match (exec.cum_cost, exec.cum_qty) {
492            (Some(cost), Some(qty)) if qty > Decimal::ZERO => Some(cost / qty),
493            _ => None,
494        })
495        .or_else(|| exec.last_price.filter(|p| *p > Decimal::ZERO));
496
497    if let Some(avg_price) = avg_px {
498        report.avg_px = Some(avg_price);
499    }
500
501    if exec.post_only == Some(true) {
502        report = report.with_post_only(true);
503    }
504
505    if exec.reduce_only == Some(true) {
506        report = report.with_reduce_only(true);
507    }
508
509    if let Some(ref reason) = exec.reason
510        && !reason.is_empty()
511    {
512        report = report.with_cancel_reason(reason.clone());
513    }
514
515    // Set trigger type for conditional orders (WebSocket doesn't provide trigger field)
516    let is_conditional = matches!(
517        order_type,
518        OrderType::StopMarket
519            | OrderType::StopLimit
520            | OrderType::MarketIfTouched
521            | OrderType::LimitIfTouched
522    );
523
524    if is_conditional {
525        report = report.with_trigger_type(TriggerType::Default);
526    }
527
528    Ok(report)
529}
530
531/// Parses a Kraken WebSocket trade execution into a [`FillReport`].
532///
533/// This should only be called when exec_type is "trade".
534///
535/// # Errors
536///
537/// Returns an error if required fields are missing or cannot be parsed.
538pub fn parse_ws_fill_report(
539    exec: &KrakenWsExecutionData,
540    instrument: &InstrumentAny,
541    account_id: AccountId,
542    ts_init: UnixNanos,
543) -> anyhow::Result<FillReport> {
544    let instrument_id = instrument.id();
545    let venue_order_id = VenueOrderId::new(&exec.order_id);
546
547    let exec_id = exec
548        .exec_id
549        .as_ref()
550        .context("Missing exec_id for trade execution")?;
551    let trade_id =
552        TradeId::new_checked(exec_id).context("Invalid exec_id in Kraken trade execution")?;
553
554    let order_side = exec
555        .side
556        .map(Into::into)
557        .context("Missing side for trade execution")?;
558
559    let price_precision = instrument.price_precision();
560    let size_precision = instrument.size_precision();
561
562    let last_qty = exec
563        .last_qty
564        .map(|qty| Quantity::from_decimal_dp(qty, size_precision))
565        .transpose()
566        .context("Failed to parse last_qty")?
567        .context("Missing last_qty for trade execution")?;
568
569    let last_px = exec
570        .last_price
571        .map(|px| Price::from_decimal_dp(px, price_precision))
572        .transpose()
573        .context("Failed to parse last_price")?
574        .context("Missing last_price for trade execution")?;
575
576    let liquidity_side = parse_liquidity_side(exec.liquidity_ind);
577
578    // Calculate commission from fees array
579    let commission = if let Some(ref fees) = exec.fees {
580        if let Some(fee) = fees.first() {
581            let currency = get_currency(&fee.asset);
582            Money::from_decimal(fee.qty.abs(), currency).context("Failed to parse fill fee")?
583        } else {
584            Money::zero(instrument.quote_currency())
585        }
586    } else {
587        Money::zero(instrument.quote_currency())
588    };
589
590    let ts_event = datetime_to_nanos(exec.timestamp, "execution.timestamp")?;
591
592    let client_order_id = exec
593        .cl_ord_id
594        .as_ref()
595        .filter(|s| !s.is_empty())
596        .map(ClientOrderId::new);
597
598    Ok(FillReport::new(
599        account_id,
600        instrument_id,
601        venue_order_id,
602        trade_id,
603        order_side,
604        last_qty,
605        last_px,
606        commission,
607        liquidity_side,
608        client_order_id,
609        None, // venue_position_id
610        ts_event,
611        ts_init,
612        None, // report_id
613    ))
614}
615
616/// Parses a raw WebSocket JSON string and returns [`KrakenSpotWsMessage::OrderResponse`] if the
617/// message is an order-method response envelope, or `Ok(None)` for unrecognized messages.
618///
619/// # Errors
620///
621/// Returns an error if the message appears to be an order response but cannot be deserialized.
622pub fn parse_order_response(text: &str) -> anyhow::Result<Option<KrakenSpotWsMessage>> {
623    let value: serde_json::Value =
624        serde_json::from_str(text).with_context(|| format!("Failed to parse JSON: {text}"))?;
625
626    let method_str = match value.get("method").and_then(|m| m.as_str()) {
627        Some(s) => s.to_owned(),
628        None => return Ok(None),
629    };
630
631    if !matches!(
632        method_str.as_str(),
633        "add_order" | "amend_order" | "cancel_order" | "batch_add"
634    ) {
635        return Ok(None);
636    }
637
638    let response: KrakenWsOrderResponse = serde_json::from_value(value).with_context(|| {
639        format!("Failed to deserialize order response for method '{method_str}'")
640    })?;
641    Ok(Some(KrakenSpotWsMessage::OrderResponse(response)))
642}
643
644#[cfg(test)]
645mod tests {
646    use nautilus_model::{identifiers::Symbol, types::Currency};
647    use rstest::rstest;
648    use rust_decimal_macros::dec;
649    use ustr::Ustr;
650
651    use super::*;
652    use crate::{common::consts::KRAKEN_VENUE, websocket::spot_v2::messages::KrakenWsRawMessage};
653
654    const TS: UnixNanos = UnixNanos::new(1_700_000_000_000_000_000);
655
656    #[rstest]
657    fn test_parse_time_in_force_fok() {
658        assert_eq!(
659            parse_time_in_force(Some(KrakenTimeInForce::FillOrKill), None),
660            TimeInForce::Fok
661        );
662    }
663
664    fn load_test_json(filename: &str) -> String {
665        let path = format!("test_data/{filename}");
666        std::fs::read_to_string(&path)
667            .unwrap_or_else(|e| panic!("Failed to load test data from {path}: {e}"))
668    }
669
670    fn create_mock_instrument() -> InstrumentAny {
671        use nautilus_model::instruments::currency_pair::CurrencyPair;
672
673        let instrument_id = InstrumentId::new(Symbol::new("BTC/USD"), *KRAKEN_VENUE);
674        InstrumentAny::CurrencyPair(
675            CurrencyPair::builder()
676                .instrument_id(instrument_id)
677                .raw_symbol(Symbol::new("XBTUSDT"))
678                .base_currency(Currency::BTC())
679                .quote_currency(Currency::USDT())
680                .price_precision(1)
681                .size_precision(8)
682                .price_increment(Price::from("0.1"))
683                .size_increment(Quantity::from("0.00000001"))
684                .ts_event(TS)
685                .ts_init(TS)
686                .build()
687                .unwrap(),
688        )
689    }
690
691    #[rstest]
692    fn test_parse_quote_tick() {
693        let json = load_test_json("ws_ticker_snapshot.json");
694        let message: KrakenWsRawMessage = serde_json::from_str(&json).unwrap();
695        let ticker: KrakenWsTickerData = serde_json::from_str(message.data[0].get()).unwrap();
696
697        let instrument = create_mock_instrument();
698        let quote_tick = parse_quote_tick(&ticker, &instrument, TS).unwrap();
699
700        assert_eq!(quote_tick.instrument_id, instrument.id());
701        assert_eq!(quote_tick.bid_price, Price::from("105944.20"));
702        assert_eq!(quote_tick.ask_price, Price::from("105944.30"));
703        assert_eq!(quote_tick.bid_size, Quantity::from("2.5"));
704        assert_eq!(quote_tick.ask_size, Quantity::from("3.2"));
705        assert_eq!(
706            quote_tick.ts_event,
707            UnixNanos::from(1_671_960_659_123_456_000)
708        );
709        assert_eq!(quote_tick.ts_init, TS);
710    }
711
712    #[rstest]
713    fn test_parse_trade_tick() {
714        let json = load_test_json("ws_trade_update.json");
715        let message: KrakenWsRawMessage = serde_json::from_str(&json).unwrap();
716        let trade: KrakenWsTradeData = serde_json::from_str(message.data[0].get()).unwrap();
717
718        let instrument = create_mock_instrument();
719        let trade_tick = parse_trade_tick(&trade, &instrument, TS).unwrap();
720
721        assert_eq!(trade_tick.instrument_id, instrument.id());
722        assert_eq!(trade_tick.price, Price::from("105944.20"));
723        assert_eq!(trade_tick.size, Quantity::from("0.00027625"));
724        assert!(matches!(
725            trade_tick.aggressor_side,
726            AggressorSide::Buy | AggressorSide::Sell
727        ));
728        assert_eq!(
729            trade_tick.ts_event,
730            UnixNanos::from(1_696_613_755_440_295_000)
731        );
732        assert_eq!(trade_tick.ts_init, TS);
733    }
734
735    #[rstest]
736    fn test_parse_book_deltas_snapshot() {
737        let json = load_test_json("ws_book_snapshot.json");
738        let message: KrakenWsRawMessage = serde_json::from_str(&json).unwrap();
739        let book: KrakenWsBookData = serde_json::from_str(message.data[0].get()).unwrap();
740
741        let instrument = create_mock_instrument();
742        let deltas = parse_book_deltas(&book, &instrument, 1, true, TS).unwrap();
743
744        assert!(!deltas.is_empty());
745
746        let bid_count = deltas
747            .iter()
748            .filter(|d| d.order.side == OrderSide::Buy.into())
749            .count();
750        let ask_count = deltas
751            .iter()
752            .filter(|d| d.order.side == OrderSide::Sell.into())
753            .count();
754
755        assert!(bid_count > 0);
756        assert!(ask_count > 0);
757
758        let first_delta = &deltas[0];
759        assert_eq!(first_delta.instrument_id, instrument.id());
760        assert_eq!(first_delta.action, BookAction::Clear);
761        assert!(RecordFlag::F_SNAPSHOT.matches(first_delta.flags));
762        assert!(!RecordFlag::F_LAST.matches(first_delta.flags));
763
764        assert!(deltas[1..].iter().all(|d| d.action == BookAction::Add));
765        assert!(
766            deltas[1..]
767                .iter()
768                .all(|d| RecordFlag::F_MBP.matches(d.flags))
769        );
770        assert!(
771            deltas[1..]
772                .iter()
773                .all(|d| RecordFlag::F_SNAPSHOT.matches(d.flags))
774        );
775        assert!(RecordFlag::F_LAST.matches(deltas.last().unwrap().flags));
776
777        let expected_ts_event = UnixNanos::from(1_696_613_755_440_295_000);
778        assert!(deltas.iter().all(|d| d.ts_event == expected_ts_event));
779        assert!(deltas.iter().all(|d| d.ts_init == TS));
780    }
781
782    #[rstest]
783    fn test_parse_book_deltas_update() {
784        let json = load_test_json("ws_book_update.json");
785        let message: KrakenWsRawMessage = serde_json::from_str(&json).unwrap();
786        let book: KrakenWsBookData = serde_json::from_str(message.data[0].get()).unwrap();
787
788        let instrument = create_mock_instrument();
789        let deltas = parse_book_deltas(&book, &instrument, 1, false, TS).unwrap();
790
791        assert!(!deltas.is_empty());
792
793        let first_delta = &deltas[0];
794        assert_eq!(first_delta.instrument_id, instrument.id());
795        assert_eq!(first_delta.action, BookAction::Update);
796        assert_eq!(first_delta.order.side, OrderSide::Buy.into());
797        assert_eq!(first_delta.order.price, Price::from("45283.5"));
798        assert!(RecordFlag::F_MBP.matches(first_delta.flags));
799        assert!(RecordFlag::F_LAST.matches(first_delta.flags));
800        assert!(!RecordFlag::F_SNAPSHOT.matches(first_delta.flags));
801
802        let expected_ts_event = UnixNanos::from(1_696_613_755_440_295_000);
803        assert!(deltas.iter().all(|d| d.ts_event == expected_ts_event));
804        assert!(deltas.iter().all(|d| d.ts_init == TS));
805    }
806
807    #[rstest]
808    fn test_parse_book_deltas_snapshot_skips_zero_qty_levels() {
809        let book = KrakenWsBookData {
810            symbol: Ustr::from("BTC/USD"),
811            bids: Some(vec![KrakenWsBookLevel {
812                price: dec!(100),
813                qty: Decimal::ZERO,
814            }]),
815            asks: Some(vec![KrakenWsBookLevel {
816                price: dec!(101),
817                qty: dec!(2),
818            }]),
819            checksum: Some(0),
820            timestamp: "2024-01-01T00:00:00Z".parse().unwrap(),
821        };
822
823        let instrument = create_mock_instrument();
824        let deltas = parse_book_deltas(&book, &instrument, 7, true, TS).unwrap();
825
826        assert_eq!(deltas.len(), 2);
827        assert_eq!(deltas[0].action, BookAction::Clear);
828        assert_eq!(deltas[0].sequence, 7);
829        assert!(RecordFlag::F_SNAPSHOT.matches(deltas[0].flags));
830        assert!(!RecordFlag::F_LAST.matches(deltas[0].flags));
831
832        let add = &deltas[1];
833        assert_eq!(add.action, BookAction::Add);
834        assert_eq!(add.sequence, 8);
835        assert_eq!(add.order.side, OrderSide::Sell.into());
836        assert_eq!(add.order.price, Price::from("101.0"));
837        assert!(RecordFlag::F_MBP.matches(add.flags));
838        assert!(RecordFlag::F_SNAPSHOT.matches(add.flags));
839        assert!(RecordFlag::F_LAST.matches(add.flags));
840    }
841
842    #[rstest]
843    fn test_parse_book_deltas_update_zero_qty_deletes_level() {
844        let book = KrakenWsBookData {
845            symbol: Ustr::from("BTC/USD"),
846            bids: Some(vec![KrakenWsBookLevel {
847                price: dec!(100),
848                qty: Decimal::ZERO,
849            }]),
850            asks: Some(vec![]),
851            checksum: Some(0),
852            timestamp: "2024-01-01T00:00:00Z".parse().unwrap(),
853        };
854
855        let instrument = create_mock_instrument();
856        let deltas = parse_book_deltas(&book, &instrument, 11, false, TS).unwrap();
857
858        assert_eq!(deltas.len(), 1);
859        let delete = &deltas[0];
860        assert_eq!(delete.action, BookAction::Delete);
861        assert_eq!(delete.sequence, 11);
862        assert_eq!(delete.order.side, OrderSide::Buy.into());
863        assert_eq!(delete.order.price, Price::from("100.0"));
864        assert_eq!(delete.order.size.raw(), 0);
865        assert!(RecordFlag::F_MBP.matches(delete.flags));
866        assert!(RecordFlag::F_LAST.matches(delete.flags));
867        assert!(!RecordFlag::F_SNAPSHOT.matches(delete.flags));
868    }
869
870    #[rstest]
871    fn test_parse_ws_order_status_report_preserves_decimal_avg_px() {
872        let execution = ws_execution_data(Some(KrakenOrderSide::Buy));
873
874        let report = parse_ws_order_status_report(
875            &execution,
876            &create_mock_instrument(),
877            AccountId::from("KRAKEN-001"),
878            None,
879            TS,
880        )
881        .unwrap();
882
883        assert_eq!(report.avg_px, Some(dec!(0.1234567890123456789012345678)));
884    }
885
886    #[rstest]
887    fn test_parse_ws_order_status_report_preserves_missing_side() {
888        let execution = ws_execution_data(None);
889
890        let report = parse_ws_order_status_report(
891            &execution,
892            &create_mock_instrument(),
893            AccountId::from("KRAKEN-001"),
894            None,
895            TS,
896        )
897        .unwrap();
898
899        assert_eq!(report.order_side, None);
900    }
901
902    /// A report's price is the order's limit alone, never a fill price. Kraken's executions
903    /// example places a sell limit at 26500.0, then fills part of it at 26599.9 in a trade frame,
904    /// which carries no limit and so states no price.
905    #[rstest]
906    #[case::pending_new_frame("ws_executions_pending_new_doc_example.json", Some("26500.0"), None)]
907    #[case::trade_frame(
908        "ws_executions_trade_doc_example.json",
909        None,
910        Some(dec!(26599.9))
911    )]
912    fn test_parse_ws_order_status_report_prices_the_order_at_its_limit(
913        #[case] filename: &str,
914        #[case] expected_price: Option<&str>,
915        #[case] expected_avg_px: Option<Decimal>,
916    ) {
917        let message: KrakenWsRawMessage = serde_json::from_str(&load_test_json(filename)).unwrap();
918        let execution: KrakenWsExecutionData = serde_json::from_str(message.data[0].get()).unwrap();
919
920        let report = parse_ws_order_status_report(
921            &execution,
922            &create_mock_instrument(),
923            AccountId::from("KRAKEN-001"),
924            None,
925            TS,
926        )
927        .unwrap();
928
929        assert_eq!(report.price, expected_price.map(Price::from));
930        assert_eq!(report.avg_px, expected_avg_px);
931    }
932
933    #[rstest]
934    fn test_parse_ws_fill_report_rejects_missing_side() {
935        let mut execution = ws_execution_data(None);
936        execution.exec_type = KrakenExecType::Trade;
937        execution.exec_id = Some("TRADE-1".to_string());
938        execution.last_qty = Some(dec!(1));
939        execution.last_price = Some(dec!(100));
940
941        let error = parse_ws_fill_report(
942            &execution,
943            &create_mock_instrument(),
944            AccountId::from("KRAKEN-001"),
945            TS,
946        )
947        .expect_err("a trade execution without a side must be rejected");
948
949        assert_eq!(error.to_string(), "Missing side for trade execution");
950    }
951
952    fn ws_execution_data(side: Option<KrakenOrderSide>) -> KrakenWsExecutionData {
953        KrakenWsExecutionData {
954            exec_type: KrakenExecType::Status,
955            order_id: "ORDER-1".to_string(),
956            cl_ord_id: Some("CLIENT-1".to_string()),
957            symbol: Some("BTC/USD".to_string()),
958            side,
959            order_type: Some(KrakenOrderType::Limit),
960            order_qty: Some(dec!(3)),
961            limit_price: None,
962            order_status: Some(KrakenWsOrderStatus::PartiallyFilled),
963            cum_qty: Some(dec!(3)),
964            cum_cost: Some(dec!(0.3703703670370370367037037034)),
965            avg_price: None,
966            time_in_force: Some(KrakenTimeInForce::GoodTilCancelled),
967            post_only: Some(false),
968            reduce_only: Some(false),
969            timestamp: "2024-01-01T00:00:00Z".parse().unwrap(),
970            exec_id: None,
971            last_qty: None,
972            last_price: None,
973            cost: None,
974            liquidity_ind: None,
975            fees: None,
976            fee_usd_equiv: None,
977            reason: None,
978        }
979    }
980
981    #[rstest]
982    fn test_datetime_to_nanos() {
983        let dt = "2023-10-06T17:35:55.440295Z".parse::<Timestamp>().unwrap();
984        let result = datetime_to_nanos(dt, "test").unwrap();
985        assert_eq!(result, UnixNanos::from(1_696_613_755_440_295_000));
986    }
987
988    #[rstest]
989    fn test_datetime_to_nanos_out_of_range_errors() {
990        let dt = "1500-01-01T00:00:00Z".parse::<Timestamp>().unwrap();
991        let result = datetime_to_nanos(dt, "test");
992        assert!(result.is_err());
993        let err = result.unwrap_err().to_string();
994        assert!(err.contains("test"));
995    }
996
997    #[rstest]
998    fn test_parse_ws_bar() {
999        let json = load_test_json("ws_ohlc_update.json");
1000        let message: KrakenWsRawMessage = serde_json::from_str(&json).unwrap();
1001        let ohlc: KrakenWsOhlcData = serde_json::from_str(message.data[0].get()).unwrap();
1002
1003        let instrument = create_mock_instrument();
1004        let bar = parse_ws_bar(&ohlc, &instrument, TS).unwrap();
1005
1006        assert_eq!(bar.bar_type.instrument_id(), instrument.id());
1007        assert_eq!(bar.open, Price::from("106038.2"));
1008        assert_eq!(bar.high, Price::from("106044.3"));
1009        assert_eq!(bar.low, Price::from("106038.1"));
1010        assert_eq!(bar.close, Price::from("106040.1"));
1011        assert_eq!(bar.volume, Quantity::from("30927.68066226"));
1012
1013        let spec = bar.bar_type.spec();
1014        assert_eq!(spec.step.get(), 1);
1015        assert_eq!(spec.aggregation, BarAggregation::Minute);
1016        assert_eq!(spec.price_type, PriceType::Last);
1017
1018        // Verify ts_event is computed as interval_begin + interval (close time)
1019        // interval_begin is 2023-10-04T16:25:00Z, interval is 1 minute, so close is 16:26:00Z
1020        let expected_close = ohlc.interval_begin + jiff::SignedDuration::from_mins(1);
1021        let expected_ts_event =
1022            UnixNanos::from(u64::try_from(expected_close.as_nanosecond()).unwrap());
1023        assert_eq!(bar.ts_event, expected_ts_event);
1024    }
1025
1026    #[rstest]
1027    fn test_interval_to_bar_spec() {
1028        let test_cases = [
1029            (1, 1, BarAggregation::Minute),
1030            (5, 5, BarAggregation::Minute),
1031            (15, 15, BarAggregation::Minute),
1032            (30, 30, BarAggregation::Minute),
1033            (60, 1, BarAggregation::Hour),
1034            (240, 4, BarAggregation::Hour),
1035            (1440, 1, BarAggregation::Day),
1036            (10080, 1, BarAggregation::Week),
1037            (21600, 15, BarAggregation::Day), // 21600 min = 15 days
1038        ];
1039
1040        for (interval, expected_step, expected_aggregation) in test_cases {
1041            let spec = interval_to_bar_spec(interval).unwrap();
1042            assert_eq!(
1043                spec.step.get(),
1044                expected_step,
1045                "Failed for interval {interval}"
1046            );
1047            assert_eq!(
1048                spec.aggregation, expected_aggregation,
1049                "Failed for interval {interval}"
1050            );
1051            assert_eq!(spec.price_type, PriceType::Last);
1052        }
1053    }
1054
1055    #[rstest]
1056    fn test_interval_to_bar_spec_invalid() {
1057        let result = interval_to_bar_spec(999);
1058        assert!(result.is_err());
1059    }
1060
1061    #[rstest]
1062    fn test_parse_order_response_envelope_returns_order_response_variant() {
1063        use crate::websocket::spot_v2::enums::KrakenWsMethod;
1064
1065        let raw = load_test_json("ws_add_order_response_success.json");
1066        let parsed = parse_order_response(&raw).expect("parse ok");
1067        match parsed {
1068            Some(KrakenSpotWsMessage::OrderResponse(resp)) => {
1069                assert_eq!(resp.method, KrakenWsMethod::AddOrder);
1070                assert_eq!(resp.req_id, Some(42));
1071                assert!(resp.success);
1072            }
1073            other => panic!("expected OrderResponse, was {other:?}"),
1074        }
1075    }
1076
1077    #[rstest]
1078    fn test_parse_order_response_returns_none_for_non_order_method() {
1079        let json = r#"{"method":"subscribe","req_id":1,"success":true}"#;
1080        let result = parse_order_response(json).expect("parse ok");
1081        assert!(result.is_none());
1082    }
1083
1084    #[rstest]
1085    fn test_parse_order_response_returns_none_for_data_message() {
1086        let json = r#"{"channel":"ticker","type":"snapshot","data":[]}"#;
1087        let result = parse_order_response(json).expect("parse ok");
1088        assert!(result.is_none());
1089    }
1090}