1use 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
46pub 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
90pub 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
131pub 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
291pub 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 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
326fn 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), _ => anyhow::bail!("Unsupported Kraken OHLC interval: {interval}"),
339 };
340
341 Ok(BarSpecification::new(step, aggregation, PriceType::Last))
342}
343
344fn 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
369fn 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 Some(KrakenOrderType::TrailingStop) => OrderType::StopMarket,
380 Some(KrakenOrderType::TrailingStopLimit) => OrderType::StopLimit,
381 Some(KrakenOrderType::SettlePosition) => OrderType::Market,
382 None => OrderType::Limit,
383 }
384}
385
386fn parse_time_in_force(
388 time_in_force: Option<KrakenTimeInForce>,
389 post_only: Option<bool>,
390) -> TimeInForce {
391 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
409pub 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 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, 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 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 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 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
531pub 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 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, ts_event,
611 ts_init,
612 None, ))
614}
615
616pub 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 #[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 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), ];
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}