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