Skip to main content

rustrade_data/exchange/kraken/
message.rs

1use super::channel::KrakenChannel;
2use crate::{Identifier, exchange::ExchangeSub};
3use rustrade_integration::subscription::SubscriptionId;
4use serde::{
5    Deserialize, Serialize,
6    de::{self, DeserializeOwned, IgnoredAny, MapAccess, Visitor},
7};
8use smol_str::SmolStr;
9use std::marker::PhantomData;
10
11/// [`Kraken`](super::Kraken) message variants that can be received over
12/// [`WebSocket`](rustrade_integration::protocol::websocket::WebSocket).
13///
14/// ### Raw Payload Examples
15/// See docs: <https://docs.kraken.com/exchange/api-reference/spot-websocket-v2/trade>
16///
17/// #### OrderBookL1
18/// See docs: <https://docs.kraken.com/exchange/api-reference/spot-websocket-v2/ticker>
19/// ```json
20/// {
21///     "channel": "ticker",
22///     "type": "update",
23///     "data": [
24///         {
25///             "symbol": "BTC/USD",
26///             "bid": 5698.4,
27///             "bid_qty": 1.01234567,
28///             "ask": 5700.0,
29///             "ask_qty": 0.98765432,
30///             "last": 5699.1,
31///             "volume": 1234.5,
32///             "vwap": 5650.2,
33///             "low": 5600.0,
34///             "high": 5710.0,
35///             "change": 12.3,
36///             "change_pct": 0.22,
37///             "timestamp": "2018-11-12T21:14:59.545897Z"
38///         }
39///     ]
40/// }
41/// ```
42///
43/// #### Trades
44/// See docs: <https://docs.kraken.com/exchange/api-reference/spot-websocket-v2/trade>
45/// ```json
46/// {
47///     "channel": "trade",
48///     "type": "update",
49///     "data": [
50///         {
51///             "symbol": "BTC/USD",
52///             "side": "sell",
53///             "price": 5541.2,
54///             "qty": 0.15850568,
55///             "ord_type": "limit",
56///             "trade_id": 1001,
57///             "timestamp": "2018-08-18T17:40:57.321597Z"
58///         }
59///     ]
60/// }
61/// ```
62///
63/// #### Heartbeat
64/// See docs: <https://docs.kraken.com/exchange/api-reference/spot-websocket-v2/heartbeat>
65/// ```json
66/// {
67///     "channel": "heartbeat"
68/// }
69/// ```
70///
71/// #### Status
72/// See docs: <https://docs.kraken.com/exchange/api-reference/spot-websocket-v2/status>
73/// ```json
74/// {
75///     "channel": "status",
76///     "type": "update",
77///     "data": [
78///         {
79///             "api_version": "v2",
80///             "connection_id": 1234567890,
81///             "system": "online",
82///             "version": "2.0.0"
83///         }
84///     ]
85/// }
86/// ```
87///
88/// #### Error
89/// ```json
90/// {
91///     "error": "Malformed request",
92///     "method": "error",
93///     "success": false,
94///     "time_in": "2018-11-12T21:14:59.545897Z",
95///     "time_out": "2018-11-12T21:14:59.545901Z"
96/// }
97/// ```
98///
99/// ### Parsing
100/// A message is told apart by its `channel`: the data channel `T` streams, `heartbeat` or
101/// `status`. One without a `channel` is a response to a request, such as a pong. Parsing fails,
102/// with a reason naming the cause, for:
103/// - a response carrying an `error`, which Kraken sends to a request it refused;
104/// - a channel other than these;
105/// - a data message the [`KrakenData`] rules refuse, or whose items do not parse.
106#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize)]
107#[serde(untagged)]
108pub enum KrakenMessage<T> {
109    Data(T),
110    Event(KrakenEvent),
111}
112
113impl<T> Identifier<Option<SubscriptionId>> for KrakenMessage<T>
114where
115    T: Identifier<Option<SubscriptionId>>,
116{
117    fn id(&self) -> Option<SubscriptionId> {
118        match self {
119            Self::Data(data) => data.id(),
120            Self::Event(_) => None,
121        }
122    }
123}
124
125impl<'de, T> Deserialize<'de> for KrakenMessage<KrakenData<T>>
126where
127    T: KrakenChannelData,
128{
129    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
130    where
131        D: serde::Deserializer<'de>,
132    {
133        deserializer.deserialize_map(KrakenMessageVisitor(PhantomData))
134    }
135}
136
137/// Dispatches a [`KrakenMessage`] on its `channel` as it reads the message, rather than buffering
138/// it to try each variant in turn, so a refused data message keeps its own reason.
139struct KrakenMessageVisitor<T>(PhantomData<T>);
140
141impl<'de, T> Visitor<'de> for KrakenMessageVisitor<T>
142where
143    T: KrakenChannelData,
144{
145    type Value = KrakenMessage<KrakenData<T>>;
146
147    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        write!(formatter, "a Kraken WebSocket v2 message")
149    }
150
151    fn visit_map<Map>(self, mut map: Map) -> Result<Self::Value, Map::Error>
152    where
153        Map: MapAccess<'de>,
154    {
155        let mut channel: Option<SmolStr> = None;
156        let mut data: Option<Vec<T>> = None;
157        // `data` read before `channel` named its type. Kraken sends `channel` first, so this is
158        // only a fallback for a reordered message
159        let mut data_unread: Option<serde_json::Value> = None;
160        let mut method: Option<SmolStr> = None;
161        let mut error: Option<String> = None;
162
163        while let Some(key) = map.next_key::<SmolStr>()? {
164            match key.as_str() {
165                "channel" => channel = Some(map.next_value()?),
166                "data" if channel.as_deref() == Some(T::CHANNEL.as_ref()) => {
167                    data = Some(map.next_value()?)
168                }
169                "data" if channel.is_none() => data_unread = Some(map.next_value()?),
170                "method" => method = Some(map.next_value()?),
171                "error" => error = Some(map.next_value()?),
172                _ => {
173                    map.next_value::<IgnoredAny>()?;
174                }
175            }
176        }
177
178        match (channel, method) {
179            (Some(channel), _) if channel == T::CHANNEL.as_ref() => {
180                let data = match (data, data_unread) {
181                    (Some(data), _) => data,
182                    (None, Some(unread)) => {
183                        Vec::<T>::deserialize(unread).map_err(de::Error::custom)?
184                    }
185                    (None, None) => return Err(de::Error::missing_field("data")),
186                };
187                KrakenData::new(data)
188                    .map(KrakenMessage::Data)
189                    .map_err(de::Error::custom)
190            }
191            (Some(channel), _) => match channel.as_str() {
192                "heartbeat" => Ok(KrakenMessage::Event(KrakenEvent::Heartbeat)),
193                "status" => Ok(KrakenMessage::Event(KrakenEvent::Status)),
194                other => Err(de::Error::custom(format_args!(
195                    "unexpected Kraken channel {other} on a {} stream",
196                    T::CHANNEL.as_ref()
197                ))),
198            },
199            (None, Some(method)) => match error {
200                None => Ok(KrakenMessage::Event(KrakenEvent::Response { method })),
201                Some(error) => Err(de::Error::custom(format_args!(
202                    "Kraken refused a request ({method}): {error}"
203                ))),
204            },
205            (None, None) => Err(de::Error::custom(
206                "Kraken message names neither a channel nor a method",
207            )),
208        }
209    }
210}
211
212/// An item a [`Kraken`](super::Kraken) data channel streams, such as one trade.
213pub trait KrakenChannelData: DeserializeOwned {
214    /// The channel that streams this item.
215    const CHANNEL: KrakenChannel;
216
217    /// The symbol the item belongs to, eg/ `BTC/USD`.
218    fn symbol(&self) -> &str;
219}
220
221/// One [`Kraken`](super::Kraken) data channel message: a batch of items for one symbol, with the
222/// [`SubscriptionId`] they route to (eg/ "trade|BTC/USD").
223///
224/// A message whose items name more than one symbol is refused, since a message is routed to a
225/// single subscription.
226///
227/// See [`KrakenMessage`] for full raw payload examples.
228#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize)]
229pub struct KrakenData<T> {
230    /// The subscription the items route to. `None` for a message carrying no items, which is
231    /// dropped.
232    pub subscription_id: Option<SubscriptionId>,
233    pub data: Vec<T>,
234}
235
236impl<T> KrakenData<T>
237where
238    T: KrakenChannelData,
239{
240    /// The batch `data`, routed by its items' symbol; an error naming two of them if they differ.
241    fn new(data: Vec<T>) -> Result<Self, String> {
242        let subscription_id = match data.split_first() {
243            None => None,
244            Some((first, rest)) => {
245                if let Some(other) = rest.iter().find(|item| item.symbol() != first.symbol()) {
246                    return Err(format!(
247                        "Kraken {} message mixes symbols {} and {}",
248                        T::CHANNEL.as_ref(),
249                        first.symbol(),
250                        other.symbol()
251                    ));
252                }
253                Some(ExchangeSub::from((T::CHANNEL, first.symbol())).id())
254            }
255        };
256
257        Ok(Self {
258            subscription_id,
259            data,
260        })
261    }
262}
263
264impl<T> Identifier<Option<SubscriptionId>> for KrakenData<T> {
265    fn id(&self) -> Option<SubscriptionId> {
266        self.subscription_id.clone()
267    }
268}
269
270/// [`Kraken`](super::Kraken) messages received over the WebSocket which are not subscription data.
271///
272/// eg/ [`Kraken`](super::Kraken) sends a [`KrakenEvent::Heartbeat`] each second while
273/// subscriptions are active and no other message is due.
274///
275/// See [`KrakenMessage`] for full raw payload examples.
276///
277/// See docs: <https://docs.kraken.com/exchange/api-reference/spot-websocket-v2/heartbeat> <br>
278/// See docs: <https://docs.kraken.com/exchange/api-reference/spot-websocket-v2/status>
279#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize)]
280pub enum KrakenEvent {
281    /// The `heartbeat` channel.
282    Heartbeat,
283    /// The `status` channel, sent on connecting and when the venue's state changes.
284    Status,
285    /// A response to a request that succeeded, such as a pong.
286    Response { method: SmolStr },
287}
288
289/// [`Kraken`](super::Kraken) error message received over the WebSocket, naming the symbol it
290/// concerns when there is one.
291///
292/// Used as a [`KrakenSubResponse::Error`](super::subscription::KrakenSubResponse).
293///
294/// See [`KrakenMessage`] for full raw payload examples.
295#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)]
296pub struct KrakenError {
297    #[serde(rename = "error")]
298    pub message: String,
299    #[serde(default)]
300    pub symbol: Option<String>,
301}
302
303#[cfg(test)]
304#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
305mod tests {
306    use super::*;
307    use crate::exchange::kraken::trade::KrakenTrades;
308
309    const TIMES: &str =
310        r#""time_in":"2018-11-12T21:14:59.545897Z","time_out":"2018-11-12T21:14:59.545901Z""#;
311
312    fn trade(symbol: &str) -> String {
313        format!(
314            r#"{{"symbol":"{symbol}","side":"buy","price":1.5,"qty":2,"ord_type":"limit","trade_id":1,"timestamp":"2018-11-12T21:14:59.545897Z"}}"#
315        )
316    }
317
318    fn parse_error(message: &str) -> String {
319        serde_json::from_str::<KrakenTrades>(message)
320            .unwrap_err()
321            .to_string()
322    }
323
324    #[test]
325    fn heartbeat_and_status_are_events() {
326        let heartbeat: KrakenTrades = serde_json::from_str(r#"{"channel":"heartbeat"}"#).unwrap();
327        assert_eq!(heartbeat, KrakenMessage::Event(KrakenEvent::Heartbeat));
328
329        let status: KrakenTrades = serde_json::from_str(
330            r#"{"channel":"status","type":"update","data":[{"api_version":"v2","connection_id":1,"system":"online","version":"2.0.0"}]}"#,
331        )
332        .unwrap();
333        assert_eq!(status, KrakenMessage::Event(KrakenEvent::Status));
334    }
335
336    #[test]
337    fn a_successful_response_is_an_event() {
338        let pong: KrakenTrades =
339            serde_json::from_str(&format!(r#"{{"method":"pong","req_id":1,{TIMES}}}"#)).unwrap();
340        assert_eq!(
341            pong,
342            KrakenMessage::Event(KrakenEvent::Response {
343                method: "pong".into()
344            })
345        );
346    }
347
348    #[test]
349    fn a_refused_request_is_an_error_quoting_kraken() {
350        let error = parse_error(&format!(
351            r#"{{"error":"Malformed request","method":"error","success":false,{TIMES}}}"#
352        ));
353        assert!(
354            error.contains("Kraken refused a request (error): Malformed request"),
355            "{error}"
356        );
357    }
358
359    #[test]
360    fn a_message_mixing_symbols_is_refused_naming_them() {
361        let error = parse_error(&format!(
362            r#"{{"channel":"trade","type":"update","data":[{},{}]}}"#,
363            trade("BTC/USD"),
364            trade("ETH/USD")
365        ));
366        assert!(
367            error.contains("Kraken trade message mixes symbols BTC/USD and ETH/USD"),
368            "{error}"
369        );
370    }
371
372    #[test]
373    fn another_data_channel_is_refused_naming_it() {
374        let error = parse_error(
375            r#"{"channel":"ticker","type":"update","data":[{"symbol":"BTC/USD","bid":1.0,"bid_qty":1.0,"ask":2.0,"ask_qty":1.0,"timestamp":"2018-11-12T21:14:59.545897Z"}]}"#,
376        );
377        assert!(
378            error.contains("unexpected Kraken channel ticker on a trade stream"),
379            "{error}"
380        );
381    }
382
383    #[test]
384    fn an_item_that_does_not_parse_keeps_its_reason() {
385        let error = parse_error(
386            r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD","side":"buy","price":1.5,"qty":2,"ord_type":"limit","timestamp":"2018-11-12T21:14:59.545897Z"}]}"#,
387        );
388        assert!(error.contains("missing field `trade_id`"), "{error}");
389    }
390
391    #[test]
392    fn a_malformed_envelope_is_refused_naming_why() {
393        let error = parse_error("{}");
394        assert!(
395            error.contains("Kraken message names neither a channel nor a method"),
396            "{error}"
397        );
398        let error = parse_error(r#"{"channel":"trade","type":"update"}"#);
399        assert!(error.contains("missing field `data`"), "{error}");
400    }
401
402    #[test]
403    fn data_before_another_channel_is_not_read_as_items() {
404        let status: KrakenTrades =
405            serde_json::from_str(r#"{"data":[{"system":"online"}],"channel":"status"}"#).unwrap();
406        assert_eq!(status, KrakenMessage::Event(KrakenEvent::Status));
407    }
408
409    #[test]
410    fn data_before_its_channel_still_parses() {
411        let trades: KrakenTrades = serde_json::from_str(&format!(
412            r#"{{"data":[{}],"type":"update","channel":"trade"}}"#,
413            trade("BTC/USD")
414        ))
415        .unwrap();
416
417        let KrakenMessage::Data(trades) = trades else {
418            panic!("expected data, got {trades:?}");
419        };
420        assert_eq!(
421            trades.subscription_id,
422            Some(SubscriptionId::from("trade|BTC/USD"))
423        );
424        assert_eq!(trades.data.len(), 1);
425        assert_eq!(trades.data[0].symbol, "BTC/USD");
426    }
427}