Skip to main content

rustrade_data/exchange/bitfinex/
validator.rs

1use super::subscription::{BitfinexPlatformEvent, BitfinexSubResponse};
2use crate::{
3    Identifier,
4    exchange::{Connector, ExchangeSub},
5    subscriber::validator::SubscriptionValidator,
6    subscription::{Map, SubscriptionKind},
7};
8use fnv::FnvHashSet;
9use futures::{Stream, StreamExt};
10use rustrade_integration::{
11    Validator,
12    error::SocketError,
13    protocol::{
14        StreamParser,
15        websocket::{WebSocket, WebSocketSerdeParser, WsError, WsMessage},
16    },
17    subscription::SubscriptionId,
18};
19use serde::{Deserialize, Serialize};
20use smol_str::ToSmolStr;
21use std::time::Duration;
22use tracing::debug;
23
24/// [`Bitfinex`](super::Bitfinex) specific [`SubscriptionValidator`].
25///
26/// ### Notes
27/// - Required because Bitfinex has a non-self-describing data format after subscriptions have been
28///   validated.
29/// - The [`BitfinexChannelId`](super::subscription::BitfinexChannelId) is used to identify the
30///   [`Subscription`](crate::subscription::Subscription) associated with incoming
31///   events, rather than a `String` channel-market identifier.
32/// - Therefore the [`SubscriptionId`] format must change during [`BitfinexWebSocketSubValidator::validate`]
33///   to use the [`BitfinexChannelId`](super::subscription::BitfinexChannelId)
34///   (see module level "SubscriptionId" documentation notes for more details).
35///
36/// A batch that fails names the subscriptions Bitfinex had not acknowledged, in
37/// [`SocketError::Unacknowledged`]. Bitfinex echoes each subscription it accepts, so the rest
38/// are left out.
39#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)]
40pub struct BitfinexWebSocketSubValidator;
41
42impl SubscriptionValidator for BitfinexWebSocketSubValidator {
43    type Parser = WebSocketSerdeParser;
44
45    async fn validate<Exchange, Instrument, Kind>(
46        instrument_map: Map<Instrument>,
47        websocket: &mut WebSocket,
48    ) -> Result<(Map<Instrument>, Vec<WsMessage>), SocketError>
49    where
50        Exchange: Connector + Send,
51        Instrument: Send,
52        Kind: SubscriptionKind + Send,
53    {
54        validate_responses::<Exchange, _, _>(
55            instrument_map,
56            websocket,
57            Exchange::subscription_timeout(),
58        )
59        .await
60    }
61}
62
63/// Reads `messages` until every subscription has been acknowledged and has sent its initial
64/// snapshot, re-keying each acknowledged one by its channel id, or fails naming the
65/// subscriptions left unacknowledged.
66///
67/// Generic over the message source so the validation logic is testable without a socket.
68async fn validate_responses<Exchange, Instrument, Messages>(
69    mut instrument_map: Map<Instrument>,
70    messages: &mut Messages,
71    timeout: Duration,
72) -> Result<(Map<Instrument>, Vec<WsMessage>), SocketError>
73where
74    Exchange: Connector,
75    Messages: Stream<Item = Result<WsMessage, WsError>> + Unpin,
76{
77    let expected_responses = Exchange::expected_responses(&instrument_map);
78    let mut success_responses = 0usize;
79    let mut init_snapshots_received = 0usize;
80
81    // The subscriptions not yet acknowledged, by the id the mapper keyed them under
82    let mut unacknowledged = instrument_map.0.keys().cloned().collect::<FnvHashSet<_>>();
83
84    // The channels acknowledged whose initial snapshot has not yet arrived
85    let mut awaiting_snapshot = FnvHashSet::default();
86
87    // Buffer any active Subscription market events that are received during validation
88    let mut buff_active_subscription_events = Vec::new();
89
90    // One deadline for the whole validation. A sleep re-armed per message would never expire
91    // while an acknowledged subscription keeps streaming events.
92    let deadline = tokio::time::sleep(timeout);
93    tokio::pin!(deadline);
94
95    loop {
96        // Break if all Subscriptions were a success
97        if success_responses == expected_responses && init_snapshots_received == expected_responses
98        {
99            debug!(exchange = %Exchange::ID, "validated exchange WebSocket subscriptions");
100            break Ok((instrument_map, buff_active_subscription_events));
101        }
102
103        let failure = tokio::select! {
104            _ = &mut deadline => format!("subscription validation timeout reached: {timeout:?}"),
105            message = messages.next() => {
106                let Some(response) = message else {
107                    break Err(failure_error(
108                        "WebSocket stream terminated unexpectedly".to_owned(),
109                        unacknowledged,
110                        init_snapshots_received,
111                        expected_responses,
112                    ));
113                };
114
115                match <WebSocketSerdeParser as StreamParser<BitfinexPlatformEvent>>::parse(response) {
116                    Some(Ok(response)) => match response.validate() {
117                        // Bitfinex platform status is online
118                        Ok(BitfinexPlatformEvent::PlatformStatus(status)) => {
119                            debug!(
120                                exchange = %Exchange::ID,
121                                %success_responses,
122                                %expected_responses,
123                                payload = ?status,
124                                "received Bitfinex platform status",
125                            );
126                            continue
127                        }
128
129                        // Subscription success
130                        Ok(BitfinexPlatformEvent::Subscribed(response)) => {
131                            // Determine SubscriptionId associated with the success response
132                            let BitfinexSubResponse { channel, market, channel_id } = &response;
133                            let subscription_id = ExchangeSub::from((channel, market)).id();
134
135                            // Replace SubscriptionId with SubscriptionId(channel_id)
136                            if let Some(subscription) = instrument_map.0.remove(&subscription_id) {
137                                success_responses += 1;
138                                unacknowledged.remove(&subscription_id);
139                                awaiting_snapshot.insert(channel_id.0);
140                                instrument_map
141                                    .0
142                                    .insert(SubscriptionId(channel_id.0.to_smolstr()), subscription);
143
144                                debug!(
145                                    exchange = %Exchange::ID,
146                                    %success_responses,
147                                    %expected_responses,
148                                    payload = ?response,
149                                    "received valid Ok subscription response",
150                                );
151                            }
152                            continue
153                        }
154
155                        // Subscription failure: the venue's own message, without the
156                        // `Subscribe` wrapper `Unacknowledged` would repeat
157                        Err(SocketError::Subscribe(message)) => message,
158                        Err(error) => error.to_string(),
159
160                        // `validate` turns every error event into an `Err`
161                        Ok(BitfinexPlatformEvent::Error(error)) => format!("{error:?}"),
162                    }
163                    Some(Err(SocketError::Deserialise { error: _, payload }))
164                        if success_responses >= 1 =>
165                    {
166                        // A channel's first frame after its acknowledgement, other than a
167                        // heartbeat, is its initial snapshot. Every frame is kept for processing
168                        // once validated.
169                        if snapshot_channel(&payload)
170                            .is_some_and(|channel_id| awaiting_snapshot.remove(&channel_id))
171                        {
172                            init_snapshots_received += 1;
173                        }
174                        buff_active_subscription_events.push(WsMessage::text(payload));
175                        continue
176                    }
177                    Some(Err(SocketError::Terminated(close_frame))) => {
178                        format!("received WebSocket CloseFrame: {close_frame}")
179                    }
180                    _ => {
181                        // Pings, Pongs, Frames, etc.
182                        continue
183                    }
184                }
185            }
186        };
187
188        break Err(failure_error(
189            failure,
190            unacknowledged,
191            init_snapshots_received,
192            expected_responses,
193        ));
194    }
195}
196
197/// The channel id of a data frame, `[CHANNEL_ID, ...]`, unless it is a heartbeat,
198/// `[CHANNEL_ID, "hb"]`.
199fn snapshot_channel(payload: &str) -> Option<u32> {
200    let frame = serde_json::from_str::<serde_json::Value>(payload).ok()?;
201    let frame = frame.as_array()?;
202    if frame.get(1).and_then(serde_json::Value::as_str) == Some("hb") {
203        return None;
204    }
205    u32::try_from(frame.first()?.as_u64()?).ok()
206}
207
208/// The error a validation failing for `reason` ends with: [`SocketError::Unacknowledged`] naming
209/// the subscriptions not acknowledged, or, once every one has been, a [`SocketError::Subscribe`]
210/// saying how many initial snapshots had arrived.
211fn failure_error(
212    reason: String,
213    unacknowledged: FnvHashSet<SubscriptionId>,
214    init_snapshots_received: usize,
215    expected_responses: usize,
216) -> SocketError {
217    if unacknowledged.is_empty() {
218        return SocketError::Subscribe(format!(
219            "{reason} (every subscription was acknowledged; {init_snapshots_received} of \
220             {expected_responses} initial snapshots had arrived)"
221        ));
222    }
223
224    let mut unacknowledged = unacknowledged.into_iter().collect::<Vec<_>>();
225    unacknowledged.sort_unstable();
226    SocketError::Unacknowledged {
227        reason,
228        unacknowledged,
229    }
230}
231
232#[cfg(test)]
233#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
234mod tests {
235    use super::*;
236    use crate::exchange::bitfinex::Bitfinex;
237    use futures::stream;
238
239    const TIMEOUT: Duration = Duration::from_secs(10);
240
241    fn map(ids: &[&str]) -> Map<usize> {
242        ids.iter()
243            .enumerate()
244            .map(|(key, id)| (SubscriptionId::from(*id), key))
245            .collect()
246    }
247
248    fn ids(ids: &[&str]) -> Vec<SubscriptionId> {
249        ids.iter().map(|id| SubscriptionId::from(*id)).collect()
250    }
251
252    fn unacknowledged(error: SocketError) -> (String, Vec<SubscriptionId>) {
253        match error {
254            SocketError::Unacknowledged {
255                reason,
256                unacknowledged,
257            } => (reason, unacknowledged),
258            other => panic!("expected Unacknowledged, got {other:?}"),
259        }
260    }
261
262    fn text(message: &str) -> Result<WsMessage, WsError> {
263        Ok(WsMessage::text(message.to_owned()))
264    }
265
266    fn subscribed(channel: &str, symbol: &str, channel_id: u32) -> Result<WsMessage, WsError> {
267        Ok(WsMessage::text(format!(
268            r#"{{"event":"subscribed","channel":"{channel}","chanId":{channel_id},"symbol":"{symbol}","pair":"X"}}"#
269        )))
270    }
271
272    fn snapshot(channel_id: u32) -> Result<WsMessage, WsError> {
273        Ok(WsMessage::text(format!(
274            "[{channel_id},[[1,1791679984451,0.1,82995]]]"
275        )))
276    }
277
278    const INFO: &str = r#"{"event":"info","version":2,"serverId":"x","platform":{"status":1}}"#;
279
280    #[tokio::test(start_paused = true)]
281    async fn every_acknowledgement_rekeys_its_subscription_by_channel_id() {
282        let mut messages = stream::iter([
283            text(INFO),
284            subscribed("trades", "tBTCUSD", 53),
285            snapshot(53),
286            subscribed("trades", "tETHUSD", 54),
287            snapshot(54),
288        ]);
289
290        let (map, buffered) = validate_responses::<Bitfinex, _, _>(
291            map(&["trades|tBTCUSD", "trades|tETHUSD"]),
292            &mut messages,
293            TIMEOUT,
294        )
295        .await
296        .unwrap();
297
298        assert_eq!(map.find("53").unwrap(), &0);
299        assert_eq!(map.find("54").unwrap(), &1);
300        assert_eq!(buffered.len(), 2);
301    }
302
303    #[tokio::test(start_paused = true)]
304    async fn updates_and_heartbeats_are_kept_but_not_counted_as_snapshots() {
305        // A channel can stream before the batch's last acknowledgement arrives
306        let mut messages = stream::iter([
307            subscribed("trades", "tBTCUSD", 53),
308            text(r#"[53,"hb"]"#),
309            snapshot(53),
310            text(r#"[53,"te",[2,1791679999877,0.002,82997]]"#),
311            snapshot(53),
312            subscribed("trades", "tETHUSD", 54),
313            snapshot(54),
314        ]);
315
316        let (map, buffered) = validate_responses::<Bitfinex, _, _>(
317            map(&["trades|tBTCUSD", "trades|tETHUSD"]),
318            &mut messages,
319            TIMEOUT,
320        )
321        .await
322        .unwrap();
323
324        assert_eq!(map.0.len(), 2);
325        assert_eq!(buffered.len(), 5);
326    }
327
328    #[test]
329    fn a_snapshot_frame_names_its_channel_and_a_heartbeat_none() {
330        assert_eq!(snapshot_channel("[53,[[1,2,0.1,3]]]"), Some(53));
331        assert_eq!(snapshot_channel(r#"[53,"te",[1,2,0.1,3]]"#), Some(53));
332        assert_eq!(snapshot_channel(r#"[53,"hb"]"#), None);
333        assert_eq!(snapshot_channel(r#"{"event":"pong"}"#), None);
334    }
335
336    #[tokio::test(start_paused = true)]
337    async fn a_refusal_names_the_subscriptions_left_unacknowledged() {
338        // As Bitfinex answers an unknown symbol: an error naming the subscription it refuses
339        let mut messages = stream::iter([
340            text(INFO),
341            subscribed("trades", "tBTCUSD", 53),
342            text(
343                r#"{"channel":"trades","symbol":"tNOSUCH","event":"error","msg":"symbol: invalid","code":10300,"pair":"NOSUCH"}"#,
344            ),
345        ]);
346
347        let error = validate_responses::<Bitfinex, _, _>(
348            map(&["trades|tBTCUSD", "trades|tNOSUCH", "trades|tETHUSD"]),
349            &mut messages,
350            TIMEOUT,
351        )
352        .await
353        .unwrap_err();
354
355        let (reason, unacknowledged) = unacknowledged(error);
356        assert_eq!(
357            reason,
358            "Bitfinex refused trades tNOSUCH: symbol: invalid (code 10300)"
359        );
360        assert_eq!(unacknowledged, ids(&["trades|tETHUSD", "trades|tNOSUCH"]));
361    }
362
363    #[tokio::test(start_paused = true)]
364    async fn a_dropped_connection_names_the_subscriptions_left_unacknowledged() {
365        let mut messages = stream::iter([subscribed("trades", "tBTCUSD", 53)]);
366
367        let error = validate_responses::<Bitfinex, _, _>(
368            map(&["trades|tBTCUSD", "book|tETHUSD"]),
369            &mut messages,
370            TIMEOUT,
371        )
372        .await
373        .unwrap_err();
374
375        let (reason, unacknowledged) = unacknowledged(error);
376        assert_eq!(reason, "WebSocket stream terminated unexpectedly");
377        assert_eq!(unacknowledged, ids(&["book|tETHUSD"]));
378    }
379
380    #[tokio::test(start_paused = true)]
381    async fn the_timeout_fires_while_an_acknowledged_subscription_keeps_streaming() {
382        // One heartbeat a second, forever: a sleep re-armed per message would never expire
383        let heartbeats = stream::unfold((), |()| async {
384            tokio::time::sleep(Duration::from_secs(1)).await;
385            Some((text(r#"[53,"hb"]"#), ()))
386        });
387        let mut messages =
388            Box::pin(stream::iter([subscribed("trades", "tBTCUSD", 53)]).chain(heartbeats));
389        let started = tokio::time::Instant::now();
390
391        let error = validate_responses::<Bitfinex, _, _>(
392            map(&["trades|tBTCUSD", "trades|tETHUSD"]),
393            &mut messages,
394            TIMEOUT,
395        )
396        .await
397        .unwrap_err();
398
399        assert_eq!(started.elapsed(), TIMEOUT);
400        let (reason, unacknowledged) = unacknowledged(error);
401        assert_eq!(
402            reason,
403            format!("subscription validation timeout reached: {TIMEOUT:?}")
404        );
405        assert_eq!(unacknowledged, ids(&["trades|tETHUSD"]));
406    }
407
408    #[tokio::test(start_paused = true)]
409    async fn a_missing_snapshot_after_every_acknowledgement_says_so() {
410        let mut messages = stream::iter([
411            subscribed("trades", "tBTCUSD", 53),
412            subscribed("trades", "tETHUSD", 54),
413            snapshot(53),
414        ]);
415
416        let error = validate_responses::<Bitfinex, _, _>(
417            map(&["trades|tBTCUSD", "trades|tETHUSD"]),
418            &mut messages,
419            TIMEOUT,
420        )
421        .await
422        .unwrap_err();
423
424        let SocketError::Subscribe(message) = error else {
425            panic!("expected Subscribe, got {error:?}");
426        };
427        assert_eq!(
428            message,
429            "WebSocket stream terminated unexpectedly (every subscription was acknowledged; 1 of \
430             2 initial snapshots had arrived)"
431        );
432    }
433}