1use 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
42pub 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
84pub 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
125pub 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
285pub 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 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
320fn 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), _ => anyhow::bail!("Unsupported Kraken OHLC interval: {interval}"),
333 };
334
335 Ok(BarSpecification::new(step, aggregation, PriceType::Last))
336}
337
338fn 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
363fn 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 Some(KrakenOrderType::TrailingStop) => OrderType::StopMarket,
374 Some(KrakenOrderType::TrailingStopLimit) => OrderType::StopLimit,
375 Some(KrakenOrderType::SettlePosition) => OrderType::Market,
376 None => OrderType::Limit,
377 }
378}
379
380fn 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
389fn parse_time_in_force(
391 time_in_force: Option<KrakenTimeInForce>,
392 post_only: Option<bool>,
393) -> TimeInForce {
394 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
412pub 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 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, 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 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 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 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
541pub 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 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, ts_event,
618 ts_init,
619 None, ))
621}
622
623pub 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, 8, 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, 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 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), ];
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}