1use std::{
19 collections::VecDeque,
20 sync::{
21 Arc,
22 atomic::{AtomicBool, Ordering},
23 },
24};
25
26#[cfg(test)]
27use nautilus_core::string::secret::REDACTED;
28use nautilus_core::string::secret::SecretString;
29use nautilus_network::{
30 RECONNECTED,
31 websocket::{SubscriptionState, WebSocketClient},
32};
33use serde::Deserialize;
34use serde_json::{Value, value::RawValue};
35use tokio_tungstenite::tungstenite::Message;
36
37use super::{
38 enums::{KrakenWsChannel, KrakenWsMessageType},
39 messages::{
40 KrakenSpotWsMessage, KrakenWsBookData, KrakenWsExecutionData, KrakenWsOhlcData,
41 KrakenWsRawMessage, KrakenWsResponse, KrakenWsTickerData, KrakenWsTradeData,
42 },
43 parse::parse_order_response,
44};
45use crate::{
46 common::consts::{KRAKEN_RATE_LIMIT_KEY_ORDER, KRAKEN_RATE_LIMIT_KEY_SUBSCRIPTION},
47 websocket::spot_v2::level_3::messages::{KrakenL3Snapshot, KrakenL3UpdateData},
48};
49
50#[derive(Debug)]
52pub enum SpotHandlerCommand {
53 SetClient(WebSocketClient),
54 Disconnect,
55 Subscribe { payload: SecretString },
56 Unsubscribe { payload: SecretString },
57 Ping { payload: SecretString },
58 SendOrderRequest { req_id: u64, payload: SecretString },
59}
60
61pub(super) struct SpotFeedHandler {
63 signal: Arc<AtomicBool>,
64 inner: Option<WebSocketClient>,
65 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<SpotHandlerCommand>,
66 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
67 subscriptions: SubscriptionState,
68 pending_messages: VecDeque<KrakenSpotWsMessage>,
69}
70
71impl SpotFeedHandler {
72 pub(super) fn new(
74 signal: Arc<AtomicBool>,
75 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<SpotHandlerCommand>,
76 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
77 subscriptions: SubscriptionState,
78 ) -> Self {
79 Self {
80 signal,
81 inner: None,
82 cmd_rx,
83 raw_rx,
84 subscriptions,
85 pending_messages: VecDeque::new(),
86 }
87 }
88
89 pub(super) fn is_stopped(&self) -> bool {
90 self.signal.load(Ordering::Relaxed)
91 }
92
93 fn is_subscribed(&self, topic: &str) -> bool {
94 self.subscriptions.all_topics().iter().any(|t| t == topic)
95 }
96
97 pub(super) async fn next(&mut self) -> Option<KrakenSpotWsMessage> {
99 if let Some(msg) = self.pending_messages.pop_front() {
100 return Some(msg);
101 }
102
103 loop {
104 tokio::select! {
105 Some(cmd) = self.cmd_rx.recv() => {
106 match cmd {
107 SpotHandlerCommand::SetClient(client) => {
108 log::debug!("WebSocketClient received by handler");
109 self.inner = Some(client);
110 }
111 SpotHandlerCommand::Disconnect => {
112 log::debug!("Disconnect command received");
113
114 if let Some(client) = self.inner.take() {
115 client.disconnect().await;
116 }
117 }
118 SpotHandlerCommand::Subscribe { payload }
119 | SpotHandlerCommand::Unsubscribe { payload } => {
120 if let Some(client) = &self.inner
121 && let Err(e) = client.send_text(payload.expose_secret().to_owned(), Some(KRAKEN_RATE_LIMIT_KEY_SUBSCRIPTION.as_slice())).await
122 {
123 log::error!("Failed to send text: {e}");
124 }
125 }
126 SpotHandlerCommand::Ping { payload } => {
127 if let Some(client) = &self.inner
128 && let Err(e) = client.send_text(payload.expose_secret().to_owned(), None).await
129 {
130 log::error!("Failed to send text: {e}");
131 }
132 }
133 SpotHandlerCommand::SendOrderRequest { req_id, payload } => {
134 if let Some(client) = &self.inner {
135 if let Err(e) = client.send_text(payload.expose_secret().to_owned(), Some(KRAKEN_RATE_LIMIT_KEY_ORDER.as_slice())).await {
136 log::error!(
137 "Kraken WS send_order_request failed req_id={req_id}: {e}"
138 );
139 } else {
140 log::debug!(
141 "Kraken WS send_order_request enqueued req_id={req_id}"
142 );
143 }
144 } else {
145 log::error!(
146 "Kraken WS send_order_request without active client req_id={req_id}"
147 );
148 }
149 }
150 }
151 }
152
153 msg = self.raw_rx.recv() => {
154 let msg = match msg {
155 Some(msg) => msg,
156 None => {
157 log::debug!("WebSocket stream closed");
158 return None;
159 }
160 };
161
162 if let Message::Ping(data) = &msg {
163 log::trace!("Received ping frame with {} bytes", data.len());
164
165 if let Some(client) = &self.inner
166 && let Err(e) = client.send_pong(data.to_vec()).await
167 {
168 log::warn!("Failed to send pong frame: {e}");
169 }
170 continue;
171 }
172
173 if self.signal.load(Ordering::Relaxed) {
174 log::debug!("Stop signal received");
175 return None;
176 }
177
178 let text = match msg {
179 Message::Text(text) => text.to_string(),
180 Message::Binary(data) => {
181 match String::from_utf8(data.to_vec()) {
182 Ok(text) => text,
183 Err(e) => {
184 log::warn!("Failed to decode binary message: {e}");
185 continue;
186 }
187 }
188 }
189 Message::Pong(_) => {
190 log::trace!("Received pong");
191 continue;
192 }
193 Message::Close(_) => {
194 log::debug!("WebSocket connection closed");
195 return None;
196 }
197 Message::Frame(_) => {
198 log::trace!("Received raw frame");
199 continue;
200 }
201 _ => continue,
202 };
203
204 if text == RECONNECTED {
205 log::debug!("Received WebSocket reconnected signal");
206 return Some(KrakenSpotWsMessage::Reconnected);
207 }
208
209 if let Some(msg) = self.parse_message(&text) {
210 return Some(msg);
211 }
212 }
213 }
214 }
215 }
216
217 fn parse_message(&self, text: &str) -> Option<KrakenSpotWsMessage> {
218 if text.len() < 50 && text.starts_with("{\"channel\":\"") {
220 if text.contains("heartbeat") {
221 log::trace!("Received heartbeat");
222 return None;
223 }
224
225 if text.contains("status") {
226 log::debug!("Received status message");
227 return None;
228 }
229 }
230
231 if text.contains("\"level3\"")
232 && let Some(msg) = parse_level3_text(text)
233 {
234 return self.handle_l3_message(msg);
235 }
236
237 let value: Value = match serde_json::from_str(text) {
238 Ok(v) => v,
239 Err(e) => {
240 log::warn!("Failed to parse message: {e}");
241 return None;
242 }
243 };
244
245 if value.get("method").is_some() {
246 match parse_order_response(text) {
247 Ok(Some(msg)) => return Some(msg),
248 Ok(None) => {}
249 Err(e) => log::warn!("Failed to parse order response: {e}"),
250 }
251 return self.handle_control_message(value);
252 }
253
254 if value.get("channel").is_some() && value.get("data").is_some() {
255 match serde_json::from_str::<KrakenWsRawMessage>(text) {
256 Ok(msg) => return self.handle_data_message(msg),
257 Err(e) => {
258 log::debug!("Failed to parse data message: {e}");
259 return None;
260 }
261 }
262 }
263
264 log::debug!("Unhandled message structure: {text}");
265 None
266 }
267
268 fn handle_control_message(&self, value: Value) -> Option<KrakenSpotWsMessage> {
271 match serde_json::from_value::<KrakenWsResponse>(value) {
272 Ok(response) => match response {
273 KrakenWsResponse::Subscribe(sub) => {
274 if sub.success {
275 if let Some(result) = &sub.result {
276 log::debug!(
277 "Subscription confirmed: channel={:?}, req_id={:?}",
278 result.channel,
279 sub.req_id
280 );
281 } else {
282 log::debug!("Subscription confirmed: req_id={:?}", sub.req_id);
283 }
284 } else {
285 log::warn!(
286 "Subscription failed: error={:?}, req_id={:?}, symbol={:?}",
287 sub.error,
288 sub.req_id,
289 sub.symbol
290 );
291 }
292
293 Some(KrakenSpotWsMessage::SubscriptionAck {
294 req_id: sub.req_id,
295 symbol: sub.symbol,
296 success: sub.success,
297 error: sub.error,
298 })
299 }
300 KrakenWsResponse::Unsubscribe(unsub) => {
301 if unsub.success {
302 log::debug!("Unsubscription confirmed: req_id={:?}", unsub.req_id);
303 } else {
304 log::warn!(
305 "Unsubscription failed: error={:?}, req_id={:?}, symbol={:?}",
306 unsub.error,
307 unsub.req_id,
308 unsub.symbol
309 );
310 }
311 None
312 }
313 KrakenWsResponse::Pong(pong) => {
314 log::trace!("Received pong: req_id={:?}", pong.req_id);
315 None
316 }
317 KrakenWsResponse::Other => {
318 log::debug!("Received unknown control response");
319 None
320 }
321 },
322 Err(_) => {
323 log::debug!("Received control message (failed to parse details)");
324 None
325 }
326 }
327 }
328
329 fn handle_data_message(&self, msg: KrakenWsRawMessage) -> Option<KrakenSpotWsMessage> {
330 match msg.channel {
331 KrakenWsChannel::Book => self.handle_book_message(msg),
332 KrakenWsChannel::Ticker => self.handle_ticker_message(msg),
333 KrakenWsChannel::Trade => self.handle_trade_message(msg),
334 KrakenWsChannel::Ohlc => self.handle_ohlc_message(msg),
335 KrakenWsChannel::Executions => self.handle_executions_message(msg),
336 KrakenWsChannel::Level3 => {
337 log::warn!("Dropping malformed level3 message");
340 None
341 }
342 _ => {
343 log::warn!("Unhandled channel: {:?}", msg.channel);
344 None
345 }
346 }
347 }
348
349 fn handle_book_message(&self, msg: KrakenWsRawMessage) -> Option<KrakenSpotWsMessage> {
350 let is_snapshot = msg.event_type == KrakenWsMessageType::Snapshot;
351 let mut book_data = Vec::new();
352
353 for data in msg.data {
354 match serde_json::from_str::<KrakenWsBookData>(data.get()) {
355 Ok(bd) => {
356 if !self.is_subscribed(&format!("book:{}", bd.symbol)) {
357 continue;
358 }
359 book_data.push(bd);
360 }
361 Err(e) => log::error!("Failed to deserialize book data: {e}"),
362 }
363 }
364
365 if book_data.is_empty() {
366 None
367 } else {
368 Some(KrakenSpotWsMessage::Book {
369 data: book_data,
370 is_snapshot,
371 })
372 }
373 }
374
375 fn handle_ticker_message(&self, msg: KrakenWsRawMessage) -> Option<KrakenSpotWsMessage> {
376 let mut tickers = Vec::new();
377
378 for data in msg.data {
379 match serde_json::from_str::<KrakenWsTickerData>(data.get()) {
380 Ok(td) => {
381 let symbol = &td.symbol;
382 let quotes_key = format!("quotes:{symbol}");
383 let ticker_key = format!("ticker:{symbol}");
384 if !self.is_subscribed("es_key) && !self.is_subscribed(&ticker_key) {
385 continue;
386 }
387 tickers.push(td);
388 }
389 Err(e) => log::error!("Failed to deserialize ticker data: {e}"),
390 }
391 }
392
393 if tickers.is_empty() {
394 None
395 } else {
396 Some(KrakenSpotWsMessage::Ticker(tickers))
397 }
398 }
399
400 fn handle_trade_message(&self, msg: KrakenWsRawMessage) -> Option<KrakenSpotWsMessage> {
401 let mut trades = Vec::new();
402
403 for data in msg.data {
404 match serde_json::from_str::<KrakenWsTradeData>(data.get()) {
405 Ok(td) => trades.push(td),
406 Err(e) => log::error!("Failed to deserialize trade data: {e}"),
407 }
408 }
409
410 if trades.is_empty() {
411 None
412 } else {
413 Some(KrakenSpotWsMessage::Trade(trades))
414 }
415 }
416
417 fn handle_ohlc_message(&self, msg: KrakenWsRawMessage) -> Option<KrakenSpotWsMessage> {
418 let mut ohlc_data = Vec::new();
419
420 for data in msg.data {
421 match serde_json::from_str::<KrakenWsOhlcData>(data.get()) {
422 Ok(od) => ohlc_data.push(od),
423 Err(e) => log::error!("Failed to deserialize OHLC data: {e}"),
424 }
425 }
426
427 if ohlc_data.is_empty() {
428 None
429 } else {
430 Some(KrakenSpotWsMessage::Ohlc(ohlc_data))
431 }
432 }
433
434 fn handle_executions_message(&self, msg: KrakenWsRawMessage) -> Option<KrakenSpotWsMessage> {
435 let mut executions = Vec::new();
436
437 for data in msg.data {
438 match serde_json::from_str::<KrakenWsExecutionData>(data.get()) {
439 Ok(ed) => executions.push(ed),
440 Err(e) => log::error!("Failed to deserialize execution data: {e}"),
441 }
442 }
443
444 if executions.is_empty() {
445 None
446 } else {
447 Some(KrakenSpotWsMessage::Execution(executions))
448 }
449 }
450}
451
452#[derive(Deserialize)]
453struct Level3RawMessage<'a> {
454 channel: &'a str,
455 #[serde(rename = "type")]
456 msg_type: &'a str,
457 #[serde(borrow)]
458 data: Vec<&'a RawValue>,
459}
460
461fn parse_level3_text(text: &str) -> Option<KrakenSpotWsMessage> {
462 let msg: Level3RawMessage<'_> = serde_json::from_str(text).ok()?;
463 if msg.channel != "level3" {
464 return None;
465 }
466 let first = msg.data.first()?.get();
467
468 match msg.msg_type {
469 "snapshot" => match serde_json::from_str::<KrakenL3Snapshot>(first) {
470 Ok(snap) => Some(KrakenSpotWsMessage::L3Snapshot(snap)),
471 Err(e) => {
472 log::warn!("Failed to deserialize L3 snapshot: {e}");
473 None
474 }
475 },
476 "update" => match serde_json::from_str::<KrakenL3UpdateData>(first) {
477 Ok(update) => Some(KrakenSpotWsMessage::L3Update(update)),
478 Err(e) => {
479 log::warn!("Failed to deserialize L3 update: {e}");
480 None
481 }
482 },
483 _ => None,
484 }
485}
486
487impl SpotFeedHandler {
488 fn handle_l3_message(&self, msg: KrakenSpotWsMessage) -> Option<KrakenSpotWsMessage> {
489 let symbol = match &msg {
490 KrakenSpotWsMessage::L3Snapshot(s) => &s.symbol,
491 KrakenSpotWsMessage::L3Update(u) => &u.symbol,
492 _ => return None,
493 };
494
495 if !self.is_subscribed(&format!("level3:{symbol}")) {
496 return None;
497 }
498 Some(msg)
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use rstest::rstest;
505 use rust_decimal_macros::dec;
506
507 use super::*;
508
509 fn create_test_handler() -> SpotFeedHandler {
510 let signal = Arc::new(AtomicBool::new(false));
511 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
512 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
513 let subscriptions = SubscriptionState::new(':');
514
515 SpotFeedHandler::new(signal, cmd_rx, raw_rx, subscriptions)
516 }
517
518 #[rstest]
519 fn test_ticker_message_filtered_without_quotes_subscription() {
520 let handler = create_test_handler();
521
522 let json = r#"{
523 "channel": "ticker",
524 "type": "snapshot",
525 "data": [{
526 "symbol": "BTC/USD",
527 "bid": 105944.20,
528 "bid_qty": 2.5,
529 "ask": 105944.30,
530 "ask_qty": 3.2,
531 "last": 105899.40,
532 "volume": 163.28908096,
533 "vwap": 105904.39279,
534 "low": 104711.00,
535 "high": 106613.10,
536 "change": 250.00,
537 "change_pct": 0.24,
538 "timestamp": "2022-12-25T09:30:59.123456Z"
539 }]
540 }"#;
541
542 let result = handler.parse_message(json);
543 assert!(
544 result.is_none(),
545 "Ticker message should be filtered when no quotes subscription exists"
546 );
547 }
548
549 #[rstest]
550 fn test_ticker_message_passes_with_quotes_subscription() {
551 let handler = create_test_handler();
552 handler.subscriptions.mark_subscribe("quotes:BTC/USD");
553 handler.subscriptions.confirm_subscribe("quotes:BTC/USD");
554
555 let json = r#"{
556 "channel": "ticker",
557 "type": "snapshot",
558 "data": [{
559 "symbol": "BTC/USD",
560 "bid": 105944.20,
561 "bid_qty": 2.5,
562 "ask": 105944.30,
563 "ask_qty": 3.2,
564 "last": 105899.40,
565 "volume": 163.28908096,
566 "vwap": 105904.39279,
567 "low": 104711.00,
568 "high": 106613.10,
569 "change": 250.00,
570 "change_pct": 0.24,
571 "timestamp": "2022-12-25T09:30:59.123456Z"
572 }]
573 }"#;
574
575 let result = handler.parse_message(json);
576 assert!(
577 result.is_some(),
578 "Ticker message should pass with quotes subscription"
579 );
580
581 match result.unwrap() {
582 KrakenSpotWsMessage::Ticker(data) => {
583 assert!(!data.is_empty(), "Should have ticker data");
584 }
585 _ => panic!("Expected Ticker message"),
586 }
587 }
588
589 #[rstest]
590 fn test_ticker_message_passes_with_ticker_subscription() {
591 let handler = create_test_handler();
592 handler.subscriptions.mark_subscribe("ticker:BTC/USD");
593 handler.subscriptions.confirm_subscribe("ticker:BTC/USD");
594
595 let json = r#"{
596 "channel": "ticker",
597 "type": "snapshot",
598 "data": [{
599 "symbol": "BTC/USD",
600 "bid": 105944.20,
601 "bid_qty": 2.5,
602 "ask": 105944.30,
603 "ask_qty": 3.2,
604 "last": 105899.40,
605 "volume": 163.28908096,
606 "vwap": 105904.39279,
607 "low": 104711.00,
608 "high": 106613.10,
609 "change": 250.00,
610 "change_pct": 0.24,
611 "timestamp": "2022-12-25T09:30:59.123456Z"
612 }]
613 }"#;
614
615 let result = handler.parse_message(json);
616 assert!(
617 result.is_some(),
618 "Ticker message should pass with ticker: subscription"
619 );
620
621 match result.unwrap() {
622 KrakenSpotWsMessage::Ticker(data) => {
623 assert!(!data.is_empty(), "Should have ticker data");
624 }
625 _ => panic!("Expected Ticker message"),
626 }
627 }
628
629 #[rstest]
630 fn test_ticker_message_preserves_decimal_precision() {
631 let handler = create_test_handler();
632 handler.subscriptions.mark_subscribe("ticker:BTC/USD");
633 handler.subscriptions.confirm_subscribe("ticker:BTC/USD");
634 let json = include_str!("../../../test_data/ws_ticker_precision.json");
635
636 let message = handler.parse_message(json).unwrap();
637
638 let KrakenSpotWsMessage::Ticker(data) = message else {
639 panic!("Expected Ticker message, was {message:?}");
640 };
641 let ticker = &data[0];
642 assert_eq!(ticker.symbol, "BTC/USD");
643 assert_eq!(ticker.bid, dec!(123456789.123456789));
644 assert_eq!(ticker.bid_qty, dec!(0.1234567890123456789012345678));
645 assert_eq!(ticker.ask, dec!(123456789.223456789));
646 assert_eq!(ticker.ask_qty, dec!(0.2234567890123456789012345678));
647 assert_eq!(ticker.last, dec!(123456789.323456789));
648 assert_eq!(ticker.volume, dec!(123456789.423456789));
649 assert_eq!(ticker.vwap, dec!(123456789.523456789));
650 assert_eq!(ticker.low, dec!(123456789.623456789));
651 assert_eq!(ticker.high, dec!(123456789.723456789));
652 assert_eq!(ticker.change, dec!(123456789.823456789));
653 assert_eq!(ticker.change_pct, dec!(0.9234567890123456789012345678));
654 assert_eq!(
655 ticker.timestamp,
656 "2022-12-25T09:30:59.123456Z"
657 .parse::<jiff::Timestamp>()
658 .unwrap()
659 );
660 }
661
662 #[rstest]
663 fn test_book_message_filtered_without_book_subscription() {
664 let handler = create_test_handler();
665
666 let json = r#"{
667 "channel": "book",
668 "type": "snapshot",
669 "data": [{
670 "symbol": "BTC/USD",
671 "bids": [{"price": 105944.20, "qty": 2.5}],
672 "asks": [{"price": 105944.30, "qty": 3.2}],
673 "checksum": 12345,
674 "timestamp": "2023-10-06T17:35:55.440295Z"
675 }]
676 }"#;
677
678 let result = handler.parse_message(json);
679 assert!(
680 result.is_none(),
681 "Book message should be filtered when no book subscription exists"
682 );
683 }
684
685 #[rstest]
686 fn test_book_message_passes_with_book_subscription() {
687 let handler = create_test_handler();
688 handler.subscriptions.mark_subscribe("book:BTC/USD");
689 handler.subscriptions.confirm_subscribe("book:BTC/USD");
690
691 let json = r#"{
692 "channel": "book",
693 "type": "snapshot",
694 "data": [{
695 "symbol": "BTC/USD",
696 "bids": [{"price": 105944.20, "qty": 2.5}],
697 "asks": [{"price": 105944.30, "qty": 3.2}],
698 "checksum": 12345,
699 "timestamp": "2023-10-06T17:35:55.440295Z"
700 }]
701 }"#;
702
703 let result = handler.parse_message(json);
704 assert!(
705 result.is_some(),
706 "Book message should pass with book subscription"
707 );
708
709 match result.unwrap() {
710 KrakenSpotWsMessage::Book { data, is_snapshot } => {
711 assert!(!data.is_empty());
712 assert!(is_snapshot);
713 }
714 _ => panic!("Expected Book message"),
715 }
716 }
717
718 #[rstest]
719 fn test_send_order_request_variant_construction() {
720 let cmd = SpotHandlerCommand::SendOrderRequest {
721 req_id: 7,
722 payload: SecretString::from(r#"{"method":"add_order","req_id":7}"#.to_string()),
723 };
724
725 match cmd {
726 SpotHandlerCommand::SendOrderRequest { req_id, payload } => {
727 assert_eq!(req_id, 7);
728 assert!(payload.expose_secret().contains("add_order"));
729 }
730 _ => panic!("Expected SendOrderRequest, was a different variant"),
731 }
732 }
733
734 #[rstest]
735 fn test_command_debug_redacts_payload() {
736 let cmd = SpotHandlerCommand::SendOrderRequest {
737 req_id: 7,
738 payload: SecretString::from("auth-token".to_string()),
739 };
740
741 let debug = format!("{cmd:?}");
742
743 assert!(debug.contains(REDACTED));
744 assert!(!debug.contains("auth-token"));
745 }
746
747 #[rstest]
748 #[tokio::test]
749 async fn test_send_order_request_without_active_client_does_not_panic() {
750 let signal = Arc::new(AtomicBool::new(false));
751 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
752 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
753 let subscriptions = SubscriptionState::new(':');
754
755 let mut handler = SpotFeedHandler::new(signal.clone(), cmd_rx, raw_rx, subscriptions);
756
757 cmd_tx
758 .send(SpotHandlerCommand::SendOrderRequest {
759 req_id: 42,
760 payload: SecretString::from(r#"{"method":"add_order","req_id":42}"#.to_string()),
761 })
762 .unwrap();
763
764 drop(cmd_tx);
765 drop(raw_tx);
766
767 let result = handler.next().await;
768 assert!(
769 result.is_none(),
770 "Handler should return None when streams close"
771 );
772 }
773
774 #[rstest]
775 fn test_quotes_and_book_subscriptions_independent() {
776 let handler = create_test_handler();
777 handler.subscriptions.mark_subscribe("quotes:BTC/USD");
778 handler.subscriptions.confirm_subscribe("quotes:BTC/USD");
779
780 let book_json = r#"{
781 "channel": "book",
782 "type": "snapshot",
783 "data": [{
784 "symbol": "BTC/USD",
785 "bids": [{"price": 105944.20, "qty": 2.5}],
786 "asks": [{"price": 105944.30, "qty": 3.2}],
787 "checksum": 12345,
788 "timestamp": "2023-10-06T17:35:55.440295Z"
789 }]
790 }"#;
791
792 let book_result = handler.parse_message(book_json);
793 assert!(
794 book_result.is_none(),
795 "Book message should be filtered without book: subscription"
796 );
797
798 let ticker_json = r#"{
799 "channel": "ticker",
800 "type": "snapshot",
801 "data": [{
802 "symbol": "BTC/USD",
803 "bid": 105944.20,
804 "bid_qty": 2.5,
805 "ask": 105944.30,
806 "ask_qty": 3.2,
807 "last": 105899.40,
808 "volume": 163.28908096,
809 "vwap": 105904.39279,
810 "low": 104711.00,
811 "high": 106613.10,
812 "change": 250.00,
813 "change_pct": 0.24,
814 "timestamp": "2022-12-25T09:30:59.123456Z"
815 }]
816 }"#;
817
818 let ticker_result = handler.parse_message(ticker_json);
819 assert!(
820 ticker_result.is_some(),
821 "Ticker should pass with quotes subscription"
822 );
823 }
824
825 #[rstest]
826 fn test_parse_message_routes_add_order_response_to_order_response_variant() {
827 use super::super::enums::KrakenWsMethod;
828
829 let handler = create_test_handler();
830 let json = r#"{"method":"add_order","req_id":42,"success":true,"time_in":"2026-05-05T10:00:00.123Z","time_out":"2026-05-05T10:00:00.125Z","result":{"order_id":"OABCDE-12345-FGHIJ","cl_ord_id":"O-20260505-000001","order_userref":0}}"#;
831
832 let result = handler.parse_message(json);
833 match result {
834 Some(KrakenSpotWsMessage::OrderResponse(resp)) => {
835 assert_eq!(resp.method, KrakenWsMethod::AddOrder);
836 assert_eq!(resp.req_id, Some(42));
837 assert!(resp.success);
838 }
839 other => panic!("expected OrderResponse, was {other:?}"),
840 }
841 }
842
843 #[rstest]
846 fn test_parse_message_yields_a_subscribe_rejection() {
847 let handler = create_test_handler();
848 let json = r#"{"error":"Currency pair not supported BOGUS/NOPE","method":"subscribe","req_id":7,"success":false,"symbol":"BOGUS/NOPE","time_in":"2026-05-05T10:00:00.123Z","time_out":"2026-05-05T10:00:00.125Z"}"#;
849
850 let result = handler.parse_message(json);
851
852 let Some(KrakenSpotWsMessage::SubscriptionAck {
853 req_id,
854 symbol,
855 success,
856 error,
857 }) = result
858 else {
859 panic!("expected a SubscriptionAck, was {result:?}");
860 };
861 assert_eq!(req_id, Some(7));
862 assert_eq!(symbol, Some(ustr::Ustr::from("BOGUS/NOPE")));
863 assert!(!success);
864 assert_eq!(
865 error.as_deref(),
866 Some("Currency pair not supported BOGUS/NOPE")
867 );
868 }
869
870 #[rstest]
872 fn test_parse_message_yields_a_subscribe_confirmation() {
873 let handler = create_test_handler();
874 let json = r#"{"method":"subscribe","result":{"channel":"book","symbol":"BTC/USD","depth":10,"snapshot":true},"success":true,"time_in":"2026-05-05T10:00:00.123Z","time_out":"2026-05-05T10:00:00.125Z","req_id":3}"#;
875
876 let result = handler.parse_message(json);
877
878 let Some(KrakenSpotWsMessage::SubscriptionAck {
879 req_id,
880 symbol,
881 success,
882 error,
883 }) = result
884 else {
885 panic!("expected a SubscriptionAck, was {result:?}");
886 };
887 assert_eq!(req_id, Some(3));
888 assert_eq!(symbol, None, "a confirmation names the pair in result only");
889 assert!(success);
890 assert!(error.is_none());
891
892 let unsubscribe = r#"{"method":"unsubscribe","result":{"channel":"book","symbol":"BTC/USD"},"success":true,"time_in":"2026-05-05T10:00:00.123Z","time_out":"2026-05-05T10:00:00.125Z","req_id":4}"#;
893 assert!(
894 handler.parse_message(unsubscribe).is_none(),
895 "only subscribe answers reach the stream"
896 );
897 }
898
899 #[rstest]
903 #[tokio::test]
904 async fn test_next_yields_answers_and_book_frames_in_wire_order() {
905 let signal = Arc::new(AtomicBool::new(false));
906 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
907 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
908 let subscriptions = SubscriptionState::new(':');
909 subscriptions.mark_subscribe("book:BTC/USD");
910 subscriptions.confirm_subscribe("book:BTC/USD");
911 let mut handler = SpotFeedHandler::new(signal, cmd_rx, raw_rx, subscriptions);
912
913 for text in [
914 r#"{"method":"subscribe","result":{"channel":"book","symbol":"BTC/USD","depth":10,"snapshot":true},"success":true,"time_in":"2026-05-05T10:00:00.123Z","time_out":"2026-05-05T10:00:00.125Z","req_id":3}"#,
915 r#"{"channel":"book","type":"snapshot","data":[{"symbol":"BTC/USD","bids":[{"price":105944.20,"qty":2.5}],"asks":[{"price":105944.30,"qty":3.2}],"checksum":12345,"timestamp":"2023-10-06T17:35:55.440295Z"}]}"#,
916 r#"{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":105944.20,"qty":2.0}],"asks":[],"checksum":12346,"timestamp":"2023-10-06T17:35:55.540295Z"}]}"#,
917 r#"{"error":"Subscription with depth 10 not Found BTC/USD","method":"subscribe","req_id":502,"success":false,"symbol":"BTC/USD","time_in":"2026-05-05T10:00:01.123Z","time_out":"2026-05-05T10:00:01.125Z"}"#,
918 ] {
919 raw_tx.send(Message::Text(text.into())).unwrap();
920 }
921
922 let Some(KrakenSpotWsMessage::SubscriptionAck {
923 req_id: Some(3),
924 success: true,
925 ..
926 }) = handler.next().await
927 else {
928 panic!("expected the confirmation first");
929 };
930 let Some(KrakenSpotWsMessage::Book {
931 is_snapshot: true, ..
932 }) = handler.next().await
933 else {
934 panic!("expected the snapshot second");
935 };
936 let Some(KrakenSpotWsMessage::Book {
937 is_snapshot: false, ..
938 }) = handler.next().await
939 else {
940 panic!("expected the update third");
941 };
942 let failed_unsubscribe = handler.next().await;
943 let Some(KrakenSpotWsMessage::SubscriptionAck {
944 req_id: Some(502),
945 symbol: Some(symbol),
946 success: false,
947 ..
948 }) = failed_unsubscribe
949 else {
950 panic!(
951 "expected the failed unsubscribe as a subscribe answer, was {failed_unsubscribe:?}"
952 );
953 };
954 assert_eq!(symbol, ustr::Ustr::from("BTC/USD"));
955 }
956
957 #[rstest]
958 fn test_parse_level3_snapshot_with_subscription_passes() {
959 let handler = create_test_handler();
960 handler.subscriptions.mark_subscribe("level3:BTC/USD");
961 handler.subscriptions.confirm_subscribe("level3:BTC/USD");
962
963 let json = r#"{
964 "channel": "level3",
965 "type": "snapshot",
966 "data": [{
967 "symbol": "BTC/USD",
968 "bids": [],
969 "asks": [],
970 "checksum": 0,
971 "timestamp": "2024-01-01T00:00:00Z"
972 }]
973 }"#;
974
975 let result = handler.parse_message(json);
976 assert!(matches!(result, Some(KrakenSpotWsMessage::L3Snapshot(_))));
977 }
978
979 #[rstest]
980 fn test_parse_level3_update_without_subscription_filtered() {
981 let handler = create_test_handler();
982 let json = r#"{
983 "channel": "level3",
984 "type": "update",
985 "data": [{
986 "symbol": "BTC/USD",
987 "bids": [],
988 "asks": [],
989 "checksum": 0,
990 "timestamp": "2024-01-01T00:00:00Z"
991 }]
992 }"#;
993
994 let result = handler.parse_message(json);
995 assert!(result.is_none());
996 }
997
998 #[rstest]
999 #[case::empty_data(r#"{"channel":"level3","type":"update","data":[]}"#)]
1000 #[case::snapshot_missing_bids(
1001 r#"{"channel":"level3","type":"snapshot","data":[{"symbol":"BTC/USD","asks":[],"checksum":0,"timestamp":"2024-01-01T00:00:00Z"}]}"#
1002 )]
1003 #[case::unexpected_type(r#"{"channel":"level3","type":"error","data":[{}]}"#)]
1004 fn test_parse_level3_malformed_message_is_dropped(#[case] json: &str) {
1005 let handler = create_test_handler();
1006 handler.subscriptions.mark_subscribe("level3:BTC/USD");
1007 handler.subscriptions.confirm_subscribe("level3:BTC/USD");
1008
1009 let result = handler.parse_message(json);
1010 assert!(result.is_none());
1011 }
1012
1013 #[rstest]
1014 fn test_parse_level3_snapshot_compact_json() {
1015 let handler = create_test_handler();
1016 handler.subscriptions.mark_subscribe("level3:BTC/USD");
1017 handler.subscriptions.confirm_subscribe("level3:BTC/USD");
1018 let json = r#"{"channel":"level3","type":"snapshot","data":[{"symbol":"BTC/USD","bids":[],"asks":[],"checksum":0,"timestamp":"2024-01-01T00:00:00Z"}]}"#;
1019 assert!(matches!(
1020 handler.parse_message(json),
1021 Some(KrakenSpotWsMessage::L3Snapshot(_))
1022 ));
1023 }
1024
1025 #[rstest]
1026 fn test_parse_level3_snapshot_preserves_raw_decimal() {
1027 let handler = create_test_handler();
1028 handler.subscriptions.mark_subscribe("level3:BTC/USD");
1029 handler.subscriptions.confirm_subscribe("level3:BTC/USD");
1030
1031 let json = r#"{
1032 "channel": "level3",
1033 "type": "snapshot",
1034 "data": [{
1035 "symbol": "BTC/USD",
1036 "bids": [{
1037 "order_id": "order-bid-1",
1038 "limit_price": 42000.50000,
1039 "order_qty": 0.01000000,
1040 "timestamp": "2024-01-01T00:00:00Z"
1041 }],
1042 "asks": [],
1043 "checksum": 0,
1044 "timestamp": "2024-01-01T00:00:00Z"
1045 }]
1046 }"#;
1047
1048 let Some(KrakenSpotWsMessage::L3Snapshot(snap)) = handler.parse_message(json) else {
1049 panic!("expected L3 snapshot");
1050 };
1051 assert_eq!(snap.bids[0].limit_price.raw, "42000.50000");
1052 assert_eq!(snap.bids[0].order_qty.raw, "0.01000000");
1053 }
1054}