Skip to main content

rustrade_data/subscriber/
validator.rs

1use crate::{
2    exchange::Connector,
3    subscription::{Map, SubscriptionKind},
4};
5use fnv::FnvHashSet;
6use futures::{Stream, StreamExt};
7use rustrade_integration::{
8    Validator,
9    error::SocketError,
10    protocol::{
11        StreamParser,
12        websocket::{WebSocket, WebSocketSerdeParser, WsError, WsMessage},
13    },
14    subscription::SubscriptionId,
15};
16use serde::{Deserialize, Serialize};
17use std::{future::Future, time::Duration};
18use tracing::debug;
19
20/// Defines how to validate that actioned market data
21/// [`Subscription`](crate::subscription::Subscription)s were accepted by the exchange.
22pub trait SubscriptionValidator {
23    type Parser;
24
25    fn validate<Exchange, InstrumentKey, Kind>(
26        instrument_map: Map<InstrumentKey>,
27        websocket: &mut WebSocket,
28    ) -> impl Future<Output = Result<(Map<InstrumentKey>, Vec<WsMessage>), SocketError>> + Send
29    where
30        Exchange: Connector + Send,
31        InstrumentKey: Send,
32        Kind: SubscriptionKind + Send;
33}
34
35/// Standard [`SubscriptionValidator`] for [`WebSocket`]s suitable for most exchanges.
36#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)]
37pub struct WebSocketSubValidator;
38
39impl SubscriptionValidator for WebSocketSubValidator {
40    type Parser = WebSocketSerdeParser;
41
42    async fn validate<Exchange, Instrument, Kind>(
43        instrument_map: Map<Instrument>,
44        websocket: &mut WebSocket,
45    ) -> Result<(Map<Instrument>, Vec<WsMessage>), SocketError>
46    where
47        Exchange: Connector + Send,
48        Instrument: Send,
49        Kind: SubscriptionKind + Send,
50    {
51        validate_responses::<Exchange, _, _>(
52            instrument_map,
53            websocket,
54            Exchange::subscription_timeout(),
55        )
56        .await
57    }
58}
59
60/// Reads `messages` until every expected acknowledgement has arrived, or fails naming the
61/// subscriptions left unacknowledged.
62///
63/// Generic over the message source so the validation logic is testable without a socket.
64async fn validate_responses<Exchange, Instrument, Messages>(
65    instrument_map: Map<Instrument>,
66    messages: &mut Messages,
67    timeout: Duration,
68) -> Result<(Map<Instrument>, Vec<WsMessage>), SocketError>
69where
70    Exchange: Connector,
71    Messages: Stream<Item = Result<WsMessage, WsError>> + Unpin,
72{
73    let expected_responses = Exchange::expected_responses(&instrument_map);
74    let mut acknowledged = Acknowledged::default();
75
76    // Buffer any active Subscription market events that are received during validation
77    let mut buff_active_subscription_events = Vec::new();
78
79    // One deadline for the whole validation. A sleep re-armed per message would never expire
80    // while an acknowledged subscription keeps streaming events.
81    let deadline = tokio::time::sleep(timeout);
82    tokio::pin!(deadline);
83
84    loop {
85        // Break if all Subscriptions were a success
86        if acknowledged.count == expected_responses {
87            debug!(exchange = %Exchange::ID, "validated exchange WebSocket subscriptions");
88            break Ok((instrument_map, buff_active_subscription_events));
89        }
90
91        let failure = tokio::select! {
92            _ = &mut deadline => format!("subscription validation timeout reached: {timeout:?}"),
93            message = messages.next() => {
94                let Some(response) = message else {
95                    break Err(acknowledged.into_error(
96                        "WebSocket stream terminated unexpectedly".to_owned(),
97                        &instrument_map,
98                    ));
99                };
100
101                match <WebSocketSerdeParser as StreamParser<Exchange::SubResponse>>::parse(response) {
102                    Some(Ok(response)) => match response.validate() {
103                        // Subscription success
104                        Ok(response) => {
105                            acknowledged.record(
106                                Exchange::acknowledged_subscription(&response),
107                                &instrument_map,
108                            );
109                            debug!(
110                                exchange = %Exchange::ID,
111                                success_responses = %acknowledged.count,
112                                %expected_responses,
113                                payload = ?response,
114                                "received valid Ok subscription response",
115                            );
116                            continue
117                        }
118
119                        // Subscription failure: the venue's own message, without the
120                        // `Subscribe` wrapper `Unacknowledged` would repeat
121                        Err(SocketError::Subscribe(message)) => message,
122                        Err(error) => error.to_string(),
123                    }
124                    Some(Err(SocketError::Deserialise { error: _, payload })) => {
125                        // Most likely already active subscription payload, so add to market
126                        // event buffer for post validation processing
127                        buff_active_subscription_events.push(WsMessage::text(payload));
128                        continue
129                    }
130                    Some(Err(SocketError::Terminated(close_frame))) => {
131                        format!("received WebSocket CloseFrame: {close_frame}")
132                    }
133                    _ => {
134                        // Pings, Pongs, Frames, etc.
135                        continue
136                    }
137                }
138            }
139        };
140
141        break Err(acknowledged.into_error(failure, &instrument_map));
142    }
143}
144
145/// The acknowledgements a validation has received so far.
146#[derive(Debug, Default)]
147struct Acknowledged {
148    /// Every successful response, matched to a subscription or not.
149    count: usize,
150    /// The subscriptions the venue's responses named.
151    matched: FnvHashSet<SubscriptionId>,
152}
153
154impl Acknowledged {
155    fn record<Instrument>(&mut self, id: Option<SubscriptionId>, map: &Map<Instrument>) {
156        self.count += 1;
157        if let Some(id) = id.filter(|id| map.0.contains_key(id)) {
158            self.matched.insert(id);
159        }
160    }
161
162    /// The error naming every subscription not shown to be acknowledged.
163    ///
164    /// Only when each acknowledgement named a distinct subscription in the batch are the matched
165    /// ones left out. Otherwise — a venue that does not echo the subscription, one acknowledgement
166    /// for the whole batch, or a response that named nothing recognisable — any subscription could
167    /// be the unanswered one, so the whole batch is named.
168    fn into_error<Instrument>(self, reason: String, map: &Map<Instrument>) -> SocketError {
169        let narrowed = self.matched.len() == self.count;
170        let mut unacknowledged = map
171            .0
172            .keys()
173            .filter(|id| !(narrowed && self.matched.contains(*id)))
174            .cloned()
175            .collect::<Vec<_>>();
176        unacknowledged.sort_unstable();
177
178        SocketError::Unacknowledged {
179            reason,
180            unacknowledged,
181        }
182    }
183}
184
185#[cfg(test)]
186#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
187mod tests {
188    use super::*;
189    use crate::exchange::okx::Okx;
190    use futures::stream;
191
192    const TIMEOUT: Duration = Duration::from_secs(10);
193
194    fn map(ids: &[&str]) -> Map<usize> {
195        ids.iter()
196            .enumerate()
197            .map(|(key, id)| (SubscriptionId::from(*id), key))
198            .collect()
199    }
200
201    fn unacknowledged(error: SocketError) -> (String, Vec<SubscriptionId>) {
202        match error {
203            SocketError::Unacknowledged {
204                reason,
205                unacknowledged,
206            } => (reason, unacknowledged),
207            other => panic!("expected Unacknowledged, got {other:?}"),
208        }
209    }
210
211    fn ids(ids: &[&str]) -> Vec<SubscriptionId> {
212        ids.iter().map(|id| SubscriptionId::from(*id)).collect()
213    }
214
215    #[tokio::test(start_paused = true)]
216    async fn a_venue_that_does_not_echo_its_subscriptions_names_the_whole_batch() {
217        let mut messages = stream::iter([Ok(WsMessage::text(r#"{"event":"subscribe"}"#))]);
218
219        let error = validate_responses::<Okx, _, _>(
220            map(&["trades|BTC-USDT", "trades|ETH-USDT"]),
221            &mut messages,
222            TIMEOUT,
223        )
224        .await
225        .unwrap_err();
226
227        let (_, unacknowledged) = unacknowledged(error);
228        assert_eq!(unacknowledged, ids(&["trades|BTC-USDT", "trades|ETH-USDT"]));
229    }
230
231    mod kraken {
232        use super::*;
233        use crate::exchange::kraken::Kraken;
234
235        fn kraken_ack(channel: &str, symbol: &str) -> Result<WsMessage, WsError> {
236            Ok(WsMessage::text(format!(
237                r#"{{"method":"subscribe","result":{{"channel":"{channel}","snapshot":false,"symbol":"{symbol}"}},"success":true,"time_in":"2018-11-12T21:14:59.545897Z","time_out":"2018-11-12T21:14:59.545901Z"}}"#
238            )))
239        }
240
241        fn kraken_status() -> Result<WsMessage, WsError> {
242            Ok(WsMessage::text(
243                r#"{"channel":"status","type":"update","data":[{"api_version":"v2","connection_id":1,"system":"online","version":"2.0.0"}]}"#,
244            ))
245        }
246
247        #[tokio::test(start_paused = true)]
248        async fn every_acknowledgement_validates_past_the_status_message() {
249            let mut messages = stream::iter([
250                kraken_status(),
251                kraken_ack("trade", "BTC/USD"),
252                Ok(WsMessage::text(r#"{"channel":"heartbeat"}"#)),
253                kraken_ack("trade", "ETH/USD"),
254            ]);
255
256            let (map, buffered) = validate_responses::<Kraken, _, _>(
257                map(&["trade|BTC/USD", "trade|ETH/USD"]),
258                &mut messages,
259                TIMEOUT,
260            )
261            .await
262            .unwrap();
263
264            assert_eq!(map.0.len(), 2);
265            assert_eq!(buffered.len(), 2);
266        }
267
268        #[tokio::test(start_paused = true)]
269        async fn a_refused_symbol_is_named_alone() {
270            let mut messages = stream::iter([
271                kraken_status(),
272                kraken_ack("ticker", "BTC/USD"),
273                Ok(WsMessage::text(
274                    r#"{"error":"Currency pair not supported XBT/USD","method":"subscribe","success":false,"symbol":"XBT/USD","time_in":"2018-11-12T21:14:59.545897Z","time_out":"2018-11-12T21:14:59.545901Z"}"#,
275                )),
276            ]);
277
278            let error = validate_responses::<Kraken, _, _>(
279                map(&["ticker|BTC/USD", "ticker|XBT/USD"]),
280                &mut messages,
281                TIMEOUT,
282            )
283            .await
284            .unwrap_err();
285
286            let (reason, unacknowledged) = unacknowledged(error);
287            assert_eq!(
288                reason,
289                "received failure subscription response for XBT/USD: Currency pair not supported XBT/USD"
290            );
291            assert_eq!(unacknowledged, ids(&["ticker|XBT/USD"]));
292        }
293    }
294
295    #[cfg(feature = "hyperliquid")]
296    mod hyperliquid {
297        use super::*;
298        use crate::exchange::hyperliquid::Hyperliquid;
299
300        fn hyperliquid_ack(kind: &str, coin: &str) -> Result<WsMessage, WsError> {
301            Ok(WsMessage::text(format!(
302                r#"{{"channel":"subscriptionResponse","data":{{"method":"subscribe","subscription":{{"type":"{kind}","coin":"{coin}"}}}}}}"#
303            )))
304        }
305
306        fn hyperliquid_trade() -> Result<WsMessage, WsError> {
307            Ok(WsMessage::text(
308                r#"{"channel":"trades","data":[{"coin":"BTC","side":"B","px":"1","sz":"1","time":1,"hash":"0x","tid":1}]}"#,
309            ))
310        }
311
312        #[tokio::test(start_paused = true)]
313        async fn every_acknowledgement_validates_and_keeps_early_events() {
314            let mut messages = stream::iter([
315                hyperliquid_ack("trades", "BTC"),
316                hyperliquid_trade(),
317                hyperliquid_ack("l2Book", "ETH"),
318            ]);
319
320            let (map, buffered) = validate_responses::<Hyperliquid, _, _>(
321                map(&["trades|BTC", "l2Book|ETH"]),
322                &mut messages,
323                TIMEOUT,
324            )
325            .await
326            .unwrap();
327
328            assert_eq!(map.0.len(), 2);
329            assert_eq!(buffered.len(), 1);
330        }
331
332        #[tokio::test(start_paused = true)]
333        async fn a_dropped_connection_names_the_subscriptions_left_unacknowledged() {
334            // As Hyperliquid mainnet behaves: a bad coin is never acknowledged and the connection
335            // drops, so the subscription sent after it goes unanswered too.
336            let mut messages = stream::iter([
337                hyperliquid_ack("trades", "BTC"),
338                hyperliquid_ack("l2Book", "ETH"),
339            ]);
340
341            let error = validate_responses::<Hyperliquid, _, _>(
342                map(&["trades|BTC", "l2Book|ETH", "l2Book|XYZ:TSLA", "trades|@107"]),
343                &mut messages,
344                TIMEOUT,
345            )
346            .await
347            .unwrap_err();
348
349            let (reason, unacknowledged) = unacknowledged(error);
350            assert_eq!(reason, "WebSocket stream terminated unexpectedly");
351            assert_eq!(unacknowledged, ids(&["l2Book|XYZ:TSLA", "trades|@107"]));
352        }
353
354        #[tokio::test(start_paused = true)]
355        async fn the_timeout_fires_while_an_acknowledged_subscription_keeps_streaming() {
356            // One event a second, forever: a sleep re-armed per message would never expire
357            let events = stream::unfold((), |()| async {
358                tokio::time::sleep(Duration::from_secs(1)).await;
359                Some((hyperliquid_trade(), ()))
360            });
361            let mut messages =
362                Box::pin(stream::iter([hyperliquid_ack("trades", "BTC")]).chain(events));
363            let started = tokio::time::Instant::now();
364
365            let error = validate_responses::<Hyperliquid, _, _>(
366                map(&["trades|BTC", "l2Book|ETH"]),
367                &mut messages,
368                TIMEOUT,
369            )
370            .await
371            .unwrap_err();
372
373            assert_eq!(started.elapsed(), TIMEOUT);
374            let (reason, unacknowledged) = unacknowledged(error);
375            assert!(reason.contains("timeout"), "{reason}");
376            assert_eq!(unacknowledged, ids(&["l2Book|ETH"]));
377        }
378
379        #[tokio::test(start_paused = true)]
380        async fn an_error_response_names_the_unacknowledged_subscriptions_with_its_reason() {
381            let mut messages = stream::iter([
382                hyperliquid_ack("trades", "BTC"),
383                Ok(WsMessage::text(
384                    r#"{"channel":"error","data":"invalid subscription"}"#,
385                )),
386            ]);
387
388            let error = validate_responses::<Hyperliquid, _, _>(
389                map(&["trades|BTC", "l2Book|ETH"]),
390                &mut messages,
391                TIMEOUT,
392            )
393            .await
394            .unwrap_err();
395
396            let (reason, unacknowledged) = unacknowledged(error);
397            assert_eq!(
398                reason,
399                "received failure subscription response: invalid subscription"
400            );
401            assert_eq!(unacknowledged, ids(&["l2Book|ETH"]));
402        }
403
404        #[tokio::test(start_paused = true)]
405        async fn a_repeated_acknowledgement_names_the_whole_batch() {
406            // Two acknowledgements, one subscription matched: the second answered something else
407            let mut messages = stream::iter([
408                hyperliquid_ack("trades", "BTC"),
409                hyperliquid_ack("trades", "BTC"),
410            ]);
411
412            let error = validate_responses::<Hyperliquid, _, _>(
413                map(&["trades|BTC", "l2Book|ETH", "l2Book|SOL"]),
414                &mut messages,
415                TIMEOUT,
416            )
417            .await
418            .unwrap_err();
419
420            let (_, unacknowledged) = unacknowledged(error);
421            assert_eq!(
422                unacknowledged,
423                ids(&["l2Book|ETH", "l2Book|SOL", "trades|BTC"])
424            );
425        }
426
427        #[tokio::test(start_paused = true)]
428        async fn an_acknowledgement_naming_no_subscription_in_the_batch_names_the_whole_batch() {
429            // Which subscription it answered is unknown, so any of them may be the unanswered one
430            let mut messages = stream::iter([hyperliquid_ack("trades", "SOL")]);
431
432            let error = validate_responses::<Hyperliquid, _, _>(
433                map(&["trades|BTC", "l2Book|ETH"]),
434                &mut messages,
435                TIMEOUT,
436            )
437            .await
438            .unwrap_err();
439
440            let (_, unacknowledged) = unacknowledged(error);
441            assert_eq!(unacknowledged, ids(&["l2Book|ETH", "trades|BTC"]));
442        }
443    }
444}