Skip to main content

rustrade_data/exchange/lse/
connection.rs

1//! One WebSocket per API key, shared by every stream a subscriber and its clones open.
2//!
3//! # Why the connection is shared
4//! The provider allows a key a single WebSocket — a second is refused with `TOO_MANY_CONNECTIONS`
5//! on the free `registered` tier — and that one socket serves every dataset, subscription kind and
6//! option underlying against one subscription cap. A socket per stream could therefore never run
7//! two streams on one key.
8//!
9//! # Shape
10//! An actor task owns the socket. Each stream a [`LseSubscriber`](super::live::LseSubscriber) opens
11//! *attaches* to it and is
12//! handed an [`LseAttachment`]: the raw frames for its own symbols, which it parses exactly as it
13//! would parse a socket of its own. The actor reads only what routing needs — a frame's `type`,
14//! `symbol` and `replay` stamp — and forwards the frame itself, a reference-counted clone, so each
15//! stream's decoder stays the only full parse.
16//!
17//! - **Connect lazily, close when idle.** The socket opens on the first attach and closes once no
18//!   stream remains attached.
19//! - **Subscribe once per symbol.** A symbol or option underlying the socket already holds is not
20//!   sent again: the attach is confirmed at once, and its frames fan out to every stream holding
21//!   it. One symbol served by two datasets or two kinds costs one slot.
22//! - **Unsubscribe on the last detach.** Dropping an attachment frees every symbol and underlying
23//!   no other stream holds.
24//! - **One subscribe handshake at a time.** Attaches are serialised, so a rejection naming no
25//!   symbol (`LIMIT_REACHED`, `INVALID_START`) belongs to the attach in flight and fails it. One
26//!   arriving outside a handshake is logged once, by the connection. A detach waits for the
27//!   handshake in flight too, so a dropped stream's symbols stay subscribed until that handshake
28//!   ends; frames keep flowing to every attached stream meanwhile.
29//! - **Every guard is per attach, and the cap is per connection.** The offered-symbol check runs
30//!   against each attach's own batch, before anything is sent; the subscription cap is checked
31//!   against everything the connection would then hold, because every stream sharing it draws on
32//!   the same slots.
33//!
34//! # Reconnect
35//! Every stream on the connection **ends together** when the socket is lost, and each is
36//! re-initialised by the usual reconnect wrapper. The first to re-attach reconnects and
37//! re-subscribes everything the lost socket held; the rest re-attach to that one reconnect.
38//!
39//! - **A symbol has one replay window per connection.** The provider answers a repeated subscribe
40//!   carrying `start` with "Already subscribed" and replays nothing, so each resumed symbol's window
41//!   opens from the **earliest** watermark among the streams holding it. Each stream then drops,
42//!   silently, the ticks before its own watermark — ones it had already delivered — and skips the
43//!   delivered prefix at its watermark exactly as a stream of its own would. See
44//!   [`LseSubscriber::with_resume`](super::live::LseSubscriber::with_resume).
45//! - **Replayed frames reach only the streams that asked for them.** A stream that holds a resumed
46//!   symbol without a watermark of its own — it does not resume, or has delivered nothing for that
47//!   symbol yet — receives the live frames and none of the replay. This relies on the provider
48//!   stamping replayed ticks `replay: true`, as it does today.
49//! - **A stream keeps its place for [`REATTACH_GRACE`] after the socket is lost.** Frames for a
50//!   stream that has not re-attached yet are held until then, and discarded with a warning if it
51//!   has not; its symbols are then released. A stream dropped while the socket was down is
52//!   forgotten the same way, so no later reconnect re-subscribes its symbols. The grace runs from
53//!   the loss, and a reconnect that fails does not extend it.
54//!
55//! A stream attaching to a symbol the socket already holds cannot be given a replay at all, for
56//! the same reason; if it has a watermark to resume from, that is reported rather than passed over.
57//!
58//! # Sharing is by clone, and only by clone
59//! Clones of one subscriber share one connection. Two subscribers built separately for one key open
60//! two sockets, and the provider refuses the second — visibly, with `TOO_MANY_CONNECTIONS`, which is
61//! left to surface rather than papered over by a process-wide registry.
62
63pub use crate::subscriber::shared::REATTACH_GRACE;
64
65use super::{
66    live::{
67        LseCredentials, OfferedSymbols, authenticate, check_subscription_cap,
68        check_symbols_are_offered, subscribe_message, subscribe_options_message,
69        unsubscribe_message, unsubscribe_options_message,
70    },
71    mapper::subscription_id,
72    resume::{LseResumeKey, LseResumeState},
73    subscription::LseSubResponse,
74    transformer::{LseResume, ResumeContext},
75};
76use crate::exchange::osi;
77use crate::subscriber::shared::{
78    self, AttachId, Attachment, Batch, Connections, Frame, Handshake, Protocol, Registration,
79    Registry,
80};
81use crate::subscriber::shared_stream::{SharedTransport, sealed};
82use chrono::{DateTime, Utc};
83use fnv::{FnvHashMap, FnvHashSet};
84use futures::Stream;
85use rustrade_instrument::exchange::ExchangeId;
86use rustrade_integration::{
87    Validator,
88    error::SocketError,
89    protocol::websocket::{WebSocket, WsError, WsMessage, connect},
90    subscription::SubscriptionId,
91};
92use serde::Deserialize;
93use smol_str::SmolStr;
94use std::{
95    pin::Pin,
96    sync::Arc,
97    task::{Context, Poll},
98    time::Duration,
99};
100use tracing::{debug, warn};
101use url::Url;
102
103/// What one subscribe occupies on the socket: one slot of the connection's shared cap.
104#[derive(Clone, PartialEq, Eq, Hash, Debug)]
105pub(super) enum Slot {
106    Symbol(SmolStr),
107    Underlying(SmolStr),
108}
109
110impl Slot {
111    fn subscribe(&self, start: Option<DateTime<Utc>>) -> WsMessage {
112        match self {
113            Self::Symbol(symbol) => subscribe_message(symbol, start),
114            Self::Underlying(underlying) => subscribe_options_message(underlying),
115        }
116    }
117
118    fn unsubscribe(&self) -> WsMessage {
119        match self {
120            Self::Symbol(symbol) => unsubscribe_message(symbol),
121            Self::Underlying(underlying) => unsubscribe_options_message(underlying),
122        }
123    }
124}
125
126/// The London Strategic Edge side of a shared connection.
127#[derive(Debug)]
128pub(super) struct Lse;
129
130/// The handle an `LseSubscriber` and its clones share: the way into the connection task.
131#[derive(Debug)]
132pub(super) struct LseConnection(Connections<Lse>);
133
134impl LseConnection {
135    pub(super) fn new(credentials: LseCredentials) -> Self {
136        Self(Connections::new(credentials))
137    }
138
139    /// Attach one stream's batch to the connection, connecting first if nothing is attached.
140    pub(super) async fn attach(
141        &self,
142        request: AttachRequest,
143    ) -> Result<LseAttachment, SocketError> {
144        self.0.attach(request.into()).await.map(LseAttachment)
145    }
146}
147
148/// One stream's batch, as the connection needs it.
149#[derive(Debug)]
150pub(super) struct AttachRequest {
151    pub(super) exchange: ExchangeId,
152    pub(super) url: Url,
153    /// [`SubscriptionKind::as_str`](crate::subscription::SubscriptionKind::as_str) of the batch.
154    pub(super) kind: &'static str,
155    /// The distinct symbols requested, in request order. Option contracts on the options dataset.
156    pub(super) markets: Vec<SmolStr>,
157    /// The distinct option underlyings, on the options dataset only.
158    pub(super) underlyings: Option<Vec<SmolStr>>,
159    pub(super) timeout: Duration,
160    pub(super) resume: Option<Arc<LseResumeState>>,
161}
162
163impl From<AttachRequest> for shared::AttachRequest<Lse> {
164    fn from(request: AttachRequest) -> Self {
165        let AttachRequest {
166            exchange,
167            url,
168            kind,
169            markets,
170            underlyings,
171            timeout,
172            resume,
173        } = request;
174
175        let per_underlying = underlyings.is_some();
176        let slots = match underlyings {
177            Some(underlyings) => underlyings.into_iter().map(Slot::Underlying).collect(),
178            None => markets.iter().cloned().map(Slot::Symbol).collect(),
179        };
180
181        Self {
182            exchange,
183            url,
184            kind,
185            slots,
186            timeout,
187            batch: LseBatch {
188                kind,
189                markets,
190                per_underlying,
191                resume,
192                starts: FnvHashMap::default(),
193                replayed: FnvHashSet::default(),
194            },
195        }
196    }
197}
198
199/// What a London Strategic Edge registration keeps beyond its exchange, kind and slots.
200#[derive(Debug)]
201pub(super) struct LseBatch {
202    /// [`SubscriptionKind::as_str`](crate::subscription::SubscriptionKind::as_str) of the batch,
203    /// which the resume state is partitioned by.
204    kind: &'static str,
205    /// The symbols requested, in request order: option contracts on the options dataset.
206    markets: Vec<SmolStr>,
207    /// Whether the batch subscribes per option underlying rather than per symbol.
208    per_underlying: bool,
209    resume: Option<Arc<LseResumeState>>,
210
211    /// The replay windows opened on this registration's behalf on the current socket.
212    starts: FnvHashMap<SubscriptionId, DateTime<Utc>>,
213    /// The symbols among `starts`, spelled as the frames spell them, so a replayed frame can be
214    /// matched without rebuilding a subscription identifier per frame.
215    replayed: FnvHashSet<SmolStr>,
216}
217
218impl Batch for LseBatch {
219    type Attached = LseResume;
220
221    /// Equal markets, and the same resume state by identity: the replay windows opened for a
222    /// registration were chosen from its own state, so a clone resuming from a different one, or
223    /// not at all, is a different stream. The markets tell two option batches on the same
224    /// underlyings apart.
225    fn is_same_stream(&self, other: &Self) -> bool {
226        let same_resume = match (&self.resume, &other.resume) {
227            (Some(held), Some(requested)) => Arc::ptr_eq(held, requested),
228            (None, None) => true,
229            _ => false,
230        };
231
232        self.markets == other.markets && same_resume
233    }
234
235    /// The resume state, the kind it is filed under, and the replay windows opened for the stream
236    /// on the current socket. A window may open earlier than the stream's own watermark, because a
237    /// symbol has one per connection, opened at the earliest watermark any stream holding it
238    /// needs.
239    ///
240    /// Nothing for option contracts, which never resume -- see `LseOptions`. Handed over, the
241    /// state would skip live prints at the watermark's instant as though a replay had re-sent
242    /// them, when no replay was asked for.
243    fn attached(&self) -> LseResume {
244        if self.per_underlying {
245            return LseResume::default();
246        }
247
248        LseResume(self.resume.as_ref().map(|state| ResumeContext {
249            state: Arc::clone(state),
250            kind: self.kind,
251            starts: self.starts.clone(),
252        }))
253    }
254
255    fn reset(&mut self) {
256        self.starts.clear();
257        self.replayed.clear();
258    }
259}
260
261/// What the `authenticated` frame said about the socket.
262pub(super) struct Session {
263    offered: OfferedSymbols,
264    max_subscriptions: Option<u32>,
265}
266
267/// Waits for one confirmation per subscribe sent.
268#[derive(Debug)]
269pub(super) struct Confirmations {
270    expected: usize,
271    confirmed: usize,
272}
273
274impl Handshake<LseSubResponse> for Confirmations {
275    fn is_settled(&self) -> bool {
276        self.confirmed >= self.expected
277    }
278
279    fn observe(&mut self, response: LseSubResponse) {
280        self.confirmed += 1;
281        debug!(
282            confirmed = self.confirmed,
283            expected = self.expected,
284            ?response,
285            "London Strategic Edge confirmation"
286        );
287    }
288
289    fn timed_out(&self, timeout: Duration) -> String {
290        format!(
291            "subscription validation timeout reached: {timeout:?}; {} of {} subscriptions confirmed",
292            self.confirmed, self.expected
293        )
294    }
295}
296
297impl Protocol for Lse {
298    const NAME: &'static str = "London Strategic Edge";
299    // The provider allows a key one socket, whatever the endpoint.
300    const CONNECTION_PER_ENDPOINT: bool = false;
301    // Each slot is its own subscribe, so a refusal says nothing of those sent before it.
302    const REFUSAL_IS_ATOMIC: bool = false;
303    const DISCARDED_ON_LOSS: &'static str = ". A stream that resumes asks for them again from its \
304        watermark on the next reconnect; one that does not never receives them";
305    const DISCARDED_ON_EXPIRY: &'static str =
306        ", which gets no replay for a symbol another stream still holds";
307
308    type Credentials = LseCredentials;
309    type Slot = Slot;
310    type Batch = LseBatch;
311    type Session = Session;
312    type Answer = LseSubResponse;
313    type Handshake = Confirmations;
314
315    async fn connect(
316        credentials: &LseCredentials,
317        url: &Url,
318    ) -> Result<(WebSocket, Session), SocketError> {
319        let mut websocket = connect(url.clone()).await?;
320        let authenticated = authenticate(&mut websocket, credentials).await?;
321        debug!(
322            %url,
323            tier = ?authenticated.tier,
324            offered = authenticated.symbols.len(),
325            "authenticated to London Strategic Edge WebSocket",
326        );
327
328        Ok((
329            websocket,
330            Session {
331                offered: OfferedSymbols::new(authenticated.symbols),
332                max_subscriptions: authenticated.max_subscriptions,
333            },
334        ))
335    }
336
337    fn check(
338        request: &shared::AttachRequest<Self>,
339        session: &Session,
340        registry: &Registry<Self>,
341    ) -> Result<(), SocketError> {
342        // The offered-symbol list does not hold every underlying that has options, and an
343        // underlying without them is rejected by name rather than confirmed - see `LseOptions`.
344        if !request.batch.per_underlying {
345            check_symbols_are_offered(request.exchange, &request.batch.markets, &session.offered)?;
346        }
347
348        let added = request
349            .slots
350            .iter()
351            .filter(|slot| !registry.holds(slot))
352            .count();
353
354        check_subscription_cap(
355            request.exchange,
356            added,
357            registry.held(),
358            session.max_subscriptions,
359        )
360    }
361
362    fn subscribe(
363        registry: &mut Registry<Self>,
364        id: AttachId,
365        sending: &[Slot],
366    ) -> (Vec<WsMessage>, Confirmations) {
367        let starts = plan_starts(registry, sending);
368
369        let joined = joined_without_replay(registry, id, sending);
370        if !joined.is_empty()
371            && let Some(registration) = registry.get(id)
372        {
373            warn!(
374                exchange = %registration.exchange,
375                symbols = ?joined,
376                "London Strategic Edge symbols this stream resumes were already streaming on the \
377                 shared connection, and the provider replays nothing for a symbol it already \
378                 streams; they resume live, and what was missed since the last delivery is not \
379                 recovered",
380            );
381        }
382
383        let messages = sending
384            .iter()
385            .map(|slot| {
386                let start = match slot {
387                    Slot::Symbol(symbol) => starts.get(symbol.as_str()).copied(),
388                    Slot::Underlying(_) => None,
389                };
390                slot.subscribe(start)
391            })
392            .collect();
393
394        (
395            messages,
396            Confirmations {
397                expected: sending.len(),
398                confirmed: 0,
399            },
400        )
401    }
402
403    /// Answered by an `unsubscribed` frame, which no handshake could mistake for a confirmation.
404    fn release(slots: &[Slot]) -> (Vec<WsMessage>, Option<Confirmations>) {
405        (slots.iter().map(Slot::unsubscribe).collect(), None)
406    }
407
408    fn read(registry: &mut Registry<Self>, message: &WsMessage) -> Frame<LseSubResponse> {
409        match classify(message) {
410            Classified::Route { symbol, replay } => {
411                route(registry, symbol, replay, message);
412                Frame::Answers(Vec::new())
413            }
414            Classified::Answer(answer) => Frame::Answers(vec![answer]),
415            Classified::Closed(cause) => Frame::Closed(cause),
416            Classified::Ignored => Frame::Answers(Vec::new()),
417        }
418    }
419
420    /// Prefix an in-handshake rejection with what the stream cannot see: its cap is shared.
421    fn refused(error: SocketError) -> SocketError {
422        match error {
423            SocketError::Subscribe(message) => SocketError::Subscribe(format!(
424                "{message} (the connection and its subscription cap are shared by every stream \
425                 opened by this subscriber and its clones)"
426            )),
427            other => other,
428        }
429    }
430}
431
432/// One stream's view of the shared London Strategic Edge connection.
433///
434/// A stream of the raw frames addressed to the stream's own symbols, in the order the socket
435/// delivered them, so it is parsed exactly as a socket of the stream's own would be. It ends when
436/// the connection is lost, which is what drives the stream's reconnect.
437///
438/// Dropping it detaches the stream: every symbol no other stream holds is unsubscribed, and the
439/// socket closes once nothing remains attached.
440///
441/// # Keep it drained
442/// The queue behind it is unbounded. The connection reads the one socket for every stream sharing
443/// it, so it never waits for a slow one — that would stall the rest — and a stream it serves is
444/// not back-pressured the way a socket of its own would be. Frames for a stream that stops being
445/// polled accumulate in memory until it is polled again or dropped.
446///
447/// See [`connection`](self) for how the connection is shared.
448#[derive(Debug)]
449pub struct LseAttachment(Attachment<Lse>);
450
451impl Stream for LseAttachment {
452    type Item = Result<WsMessage, WsError>;
453
454    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
455        Pin::new(&mut self.0).poll_next(cx)
456    }
457}
458
459impl sealed::Sealed for LseAttachment {}
460
461/// A stream is handed what it needs to resume, if it resumes.
462impl SharedTransport for LseAttachment {
463    type Attached = LseResume;
464
465    fn take_attached(&mut self) -> LseResume {
466        self.0.take_attached()
467    }
468}
469
470/// The instant `registration` last delivered `symbol` at, if it resumes and has.
471fn watermark(registration: &Registration<Lse>, symbol: &str) -> Option<DateTime<Utc>> {
472    let key = LseResumeKey::new(
473        registration.exchange,
474        subscription_id(symbol),
475        registration.kind,
476    );
477
478    registration
479        .batch
480        .resume
481        .as_ref()?
482        .watermark(&key)
483        .map(|watermark| watermark.time_exchange)
484}
485
486/// Open each resumed symbol in `sending` at the earliest watermark among the registrations holding
487/// it, record the window on each of them, and return the `start` to send per symbol.
488///
489/// A registration with no watermark for a symbol asks for no window, and does not hold the
490/// symbol's start back either.
491fn plan_starts(
492    registry: &mut Registry<Lse>,
493    sending: &[Slot],
494) -> FnvHashMap<SmolStr, DateTime<Utc>> {
495    let mut starts = FnvHashMap::default();
496
497    for slot in sending {
498        let Slot::Symbol(symbol) = slot else {
499            continue;
500        };
501        let Some(holders) = registry.holders(slot) else {
502            continue;
503        };
504
505        let marks = holders
506            .iter()
507            .filter_map(|id| Some((*id, watermark(registry.get(*id)?, symbol)?)))
508            .collect::<Vec<_>>();
509
510        let Some(start) = marks.iter().map(|(_, watermark)| *watermark).min() else {
511            continue;
512        };
513
514        starts.insert(symbol.clone(), start);
515
516        for (id, _) in marks {
517            if let Some(registration) = registry.get_mut(id) {
518                registration
519                    .batch
520                    .starts
521                    .insert(subscription_id(symbol), start);
522                registration.batch.replayed.insert(symbol.clone());
523            }
524        }
525    }
526
527    starts
528}
529
530/// The symbols `id` holds that were already on the socket, and that it has a watermark for: the
531/// ones it wanted a replay of and cannot be given one.
532fn joined_without_replay(registry: &Registry<Lse>, id: AttachId, sending: &[Slot]) -> Vec<SmolStr> {
533    let Some(registration) = registry.get(id) else {
534        return Vec::new();
535    };
536
537    registration
538        .slots
539        .iter()
540        .filter(|slot| !sending.contains(slot))
541        .filter_map(|slot| match slot {
542            Slot::Symbol(symbol) => watermark(registration, symbol)
543                .is_some()
544                .then(|| symbol.clone()),
545            Slot::Underlying(_) => None,
546        })
547        .collect()
548}
549
550/// Deliver one frame to every registration holding `symbol`.
551///
552/// An exact symbol reaches plain subscriptions; an option contract reaches the options
553/// subscriptions holding its underlying. A replayed frame reaches only the registrations a window
554/// was opened for.
555fn route(registry: &mut Registry<Lse>, symbol: SmolStr, replay: bool, frame: &WsMessage) {
556    let slot = Slot::Symbol(symbol);
557    let Slot::Symbol(symbol) = &slot else {
558        return;
559    };
560    let accepts =
561        |registration: &Registration<Lse>| !replay || registration.batch.replayed.contains(symbol);
562
563    if registry.route(&slot, accepts, frame) {
564        return;
565    }
566
567    // An underlying is short enough to be held inline, so this allocates nothing.
568    if let Some(underlying) = osi::root(symbol)
569        && registry.route(&Slot::Underlying(SmolStr::new(underlying)), accepts, frame)
570    {
571        return;
572    }
573
574    // Normal for a moment after an unsubscribe, while frames already in flight arrive.
575    debug!(
576        %symbol,
577        "London Strategic Edge frame for a symbol no stream holds"
578    );
579}
580
581/// The part of a frame routing reads.
582///
583/// Short strings deserialise inline — a symbol, an OSI contract and a frame type all fit — so this
584/// allocates nothing on the per-frame path, whether or not the provider escapes a character.
585#[derive(Deserialize)]
586struct Envelope {
587    #[serde(rename = "type", default)]
588    kind: Option<SmolStr>,
589    #[serde(default)]
590    symbol: Option<SmolStr>,
591    #[serde(default)]
592    replay: Option<bool>,
593}
594
595enum Classified {
596    Route { symbol: SmolStr, replay: bool },
597    Answer(Result<LseSubResponse, SocketError>),
598    Closed(String),
599    Ignored,
600}
601
602fn classify(message: &WsMessage) -> Classified {
603    let text = match message {
604        WsMessage::Text(text) => text.as_str(),
605        WsMessage::Binary(bytes) => match std::str::from_utf8(bytes) {
606            Ok(text) => text,
607            Err(_) => {
608                warn!(
609                    len = bytes.len(),
610                    "London Strategic Edge sent a binary frame that is not UTF-8; ignored"
611                );
612                return Classified::Ignored;
613            }
614        },
615        WsMessage::Close(frame) => {
616            return Classified::Closed(format!("closed by the provider: {frame:?}"));
617        }
618        // Pings are answered by the socket itself.
619        _ => return Classified::Ignored,
620    };
621
622    let envelope = match serde_json::from_str::<Envelope>(text) {
623        Ok(envelope) => envelope,
624        Err(error) => {
625            warn!(
626                %error,
627                frame = %text.chars().take(200).collect::<String>(),
628                "London Strategic Edge sent a frame that is not a JSON object; ignored",
629            );
630            return Classified::Ignored;
631        }
632    };
633
634    match (envelope.kind.as_deref(), envelope.symbol) {
635        (Some("subscribed" | "options_subscribed" | "error"), _) => Classified::Answer(
636            serde_json::from_str::<LseSubResponse>(text)
637                .map_err(|error| SocketError::Deserialise {
638                    error,
639                    payload: text.to_owned(),
640                })
641                .and_then(Validator::validate),
642        ),
643        // The answer to an unsubscribe, which nothing awaits.
644        (Some("unsubscribed" | "options_unsubscribed"), _) => {
645            debug!(
646                frame = text,
647                "London Strategic Edge released a subscription"
648            );
649            Classified::Ignored
650        }
651        // A replay's boundaries belong to the replay: only a stream that asked for one reads them.
652        (Some("replay_started" | "replay_complete"), Some(symbol)) => Classified::Route {
653            symbol,
654            replay: true,
655        },
656        (_, Some(symbol)) => Classified::Route {
657            symbol,
658            replay: envelope.replay.unwrap_or(false),
659        },
660        (kind, None) => {
661            debug!(?kind, "London Strategic Edge control frame");
662            Classified::Ignored
663        }
664    }
665}
666
667#[cfg(test)]
668#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
669mod tests {
670    use super::*;
671    use crate::subscription::{SubscriptionKind, book::OrderBooksL1, trade::PublicTrades};
672    use tokio::sync::mpsc;
673
674    const TRADES: &str = "public_trades";
675
676    fn at(spelling: &str) -> DateTime<Utc> {
677        spelling.parse::<DateTime<Utc>>().unwrap()
678    }
679
680    fn text(value: serde_json::Value) -> WsMessage {
681        WsMessage::text(value.to_string())
682    }
683
684    fn request(
685        exchange: ExchangeId,
686        kind: &'static str,
687        markets: &[&str],
688        resume: Option<&Arc<LseResumeState>>,
689    ) -> AttachRequest {
690        AttachRequest {
691            exchange,
692            url: Url::parse("ws://127.0.0.1:1").unwrap(),
693            kind,
694            markets: markets.iter().map(SmolStr::new).collect(),
695            underlyings: None,
696            timeout: Duration::from_secs(1),
697            resume: resume.cloned(),
698        }
699    }
700
701    fn options_request(contracts: &[&str], underlyings: &[&str]) -> AttachRequest {
702        AttachRequest {
703            underlyings: Some(underlyings.iter().map(SmolStr::new).collect()),
704            ..request(ExchangeId::LseOptions, TRADES, contracts, None)
705        }
706    }
707
708    fn live(
709        registry: &mut Registry<Lse>,
710        request: AttachRequest,
711    ) -> (AttachId, mpsc::UnboundedReceiver<WsMessage>) {
712        let (tx, rx) = mpsc::unbounded_channel();
713        (registry.insert_live(request.into(), tx), rx)
714    }
715
716    fn record(
717        state: &LseResumeState,
718        exchange: ExchangeId,
719        symbol: &str,
720        kind: &'static str,
721        time: &str,
722    ) {
723        state.record(
724            &LseResumeKey::new(exchange, subscription_id(symbol), kind),
725            at(time),
726        );
727    }
728
729    fn drain(rx: &mut mpsc::UnboundedReceiver<WsMessage>) -> Vec<WsMessage> {
730        std::iter::from_fn(|| rx.try_recv().ok()).collect()
731    }
732
733    fn symbol(name: &str) -> Slot {
734        Slot::Symbol(SmolStr::new(name))
735    }
736
737    /// Route one frame exactly as the connection task does.
738    fn deliver(registry: &mut Registry<Lse>, frame: &WsMessage) {
739        let answers = match Lse::read(registry, frame) {
740            Frame::Answers(answers) => answers,
741            Frame::Closed(cause) => panic!("expected a routed frame, got a close: {cause}"),
742        };
743        assert!(answers.is_empty(), "expected a routed frame, got answers");
744    }
745
746    fn starts(registry: &Registry<Lse>, id: AttachId) -> FnvHashMap<SubscriptionId, DateTime<Utc>> {
747        registry.get(id).unwrap().batch.starts.clone()
748    }
749
750    #[test]
751    fn a_symbol_held_by_two_streams_is_one_slot_and_reaches_both() {
752        let mut registry = Registry::default();
753        let (_, mut trades) = live(
754            &mut registry,
755            request(ExchangeId::LseCrypto, TRADES, &["BTC/USD"], None),
756        );
757        let (_, mut books) = live(
758            &mut registry,
759            request(
760                ExchangeId::LseCrypto,
761                OrderBooksL1.as_str(),
762                &["BTC/USD", "ETH/USD"],
763                None,
764            ),
765        );
766
767        assert_eq!(registry.held(), 2);
768
769        let frame = text(serde_json::json!({"type": "tick", "symbol": "BTC/USD"}));
770        deliver(&mut registry, &frame);
771
772        assert_eq!(drain(&mut trades), std::slice::from_ref(&frame));
773        assert_eq!(drain(&mut books), [frame]);
774    }
775
776    #[test]
777    fn an_option_contract_reaches_the_streams_holding_its_underlying() {
778        let mut registry = Registry::default();
779        let (_, mut options) = live(
780            &mut registry,
781            options_request(&["SPY260930C00700000"], &["SPY"]),
782        );
783        let (_, mut equities) = live(
784            &mut registry,
785            request(ExchangeId::LseEquities, TRADES, &["SPY"], None),
786        );
787
788        let contract = text(serde_json::json!({"symbol": "SPY260930C00701000"}));
789        let share = text(serde_json::json!({"symbol": "SPY"}));
790        deliver(&mut registry, &contract);
791        deliver(&mut registry, &share);
792
793        assert_eq!(drain(&mut options), [contract]);
794        assert_eq!(drain(&mut equities), [share]);
795    }
796
797    #[test]
798    fn two_option_batches_on_one_underlying_are_different_streams() {
799        let held = || options_request(&["SPY260930C00700000"], &["SPY"]).into();
800        let other: shared::AttachRequest<Lse> =
801            options_request(&["SPY260930P00600000"], &["SPY"]).into();
802
803        let registration: shared::AttachRequest<Lse> = held();
804        assert!(registration.batch.is_same_stream(&held().batch));
805        assert!(!registration.batch.is_same_stream(&other.batch));
806    }
807
808    #[test]
809    fn a_different_resume_state_is_a_different_stream() {
810        let state = Arc::new(LseResumeState::new());
811        let batch = |resume| -> shared::AttachRequest<Lse> {
812            request(ExchangeId::LseCrypto, TRADES, &["BTC/USD"], resume).into()
813        };
814
815        assert!(
816            batch(Some(&state))
817                .batch
818                .is_same_stream(&batch(Some(&state)).batch)
819        );
820        assert!(!batch(Some(&state)).batch.is_same_stream(&batch(None).batch));
821        assert!(
822            !batch(Some(&state))
823                .batch
824                .is_same_stream(&batch(Some(&Arc::new(LseResumeState::new()))).batch)
825        );
826    }
827
828    /// The provider replays nothing for a repeated subscribe, so each symbol gets one window: the
829    /// earliest any holder needs. Every holder with a watermark is told the window it was given.
830    #[test]
831    fn a_shared_symbol_opens_one_window_at_the_earliest_watermark() {
832        let state = Arc::new(LseResumeState::new());
833        record(
834            &state,
835            ExchangeId::LseCrypto,
836            "BTC/USD",
837            TRADES,
838            "2026-08-14T10:00:02Z",
839        );
840        let books = OrderBooksL1.as_str();
841        record(
842            &state,
843            ExchangeId::LseCrypto,
844            "BTC/USD",
845            books,
846            "2026-08-14T10:00:01Z",
847        );
848
849        let mut registry = Registry::default();
850        let (trades, _rx1) = live(
851            &mut registry,
852            request(ExchangeId::LseCrypto, TRADES, &["BTC/USD"], Some(&state)),
853        );
854        let (quotes, _rx2) = live(
855            &mut registry,
856            request(ExchangeId::LseCrypto, books, &["BTC/USD"], Some(&state)),
857        );
858
859        let planned = plan_starts(&mut registry, &[symbol("BTC/USD")]);
860
861        assert_eq!(planned.get("BTC/USD"), Some(&at("2026-08-14T10:00:01Z")));
862        for id in [trades, quotes] {
863            assert_eq!(
864                starts(&registry, id).get(&subscription_id("BTC/USD")),
865                Some(&at("2026-08-14T10:00:01Z")),
866            );
867        }
868    }
869
870    /// A stream is handed its subscriber's resume state, filed under its own kind, with the windows
871    /// opened for it; one that does not resume, and an option batch, which never does, nothing.
872    #[test]
873    fn a_stream_is_handed_what_it_resumes_from_and_an_option_batch_nothing() {
874        let state = Arc::new(LseResumeState::new());
875        record(
876            &state,
877            ExchangeId::LseCrypto,
878            "BTC/USD",
879            TRADES,
880            "2026-08-14T10:00:01Z",
881        );
882
883        let mut registry = Registry::default();
884        let (resumed, _rx1) = live(
885            &mut registry,
886            request(ExchangeId::LseCrypto, TRADES, &["BTC/USD"], Some(&state)),
887        );
888        let (unresumed, _rx2) = live(
889            &mut registry,
890            request(ExchangeId::LseCrypto, TRADES, &["ETH/USD"], None),
891        );
892        let (options, _rx3) = live(
893            &mut registry,
894            AttachRequest {
895                resume: Some(Arc::clone(&state)),
896                ..options_request(&["SPY260930C00700000"], &["SPY"])
897            },
898        );
899        plan_starts(&mut registry, &[symbol("BTC/USD")]);
900
901        let attached = |id| registry.get(id).unwrap().batch.attached().0;
902
903        let context = attached(resumed).expect("a resuming stream was handed nothing");
904        assert!(Arc::ptr_eq(&context.state, &state));
905        assert_eq!(context.kind, TRADES);
906        assert_eq!(
907            context.starts.get(&subscription_id("BTC/USD")),
908            Some(&at("2026-08-14T10:00:01Z")),
909        );
910        assert!(attached(unresumed).is_none());
911        assert!(attached(options).is_none());
912    }
913
914    /// A window is asked for on the strength of what the holder itself delivered, per dataset and
915    /// per kind, never what another dataset or kind did.
916    #[test]
917    fn a_holder_with_no_watermark_of_its_own_asks_for_no_window() {
918        let state = Arc::new(LseResumeState::new());
919        record(
920            &state,
921            ExchangeId::LseEquities,
922            "AAPL",
923            TRADES,
924            "2026-08-14T10:00:00Z",
925        );
926
927        let mut registry = Registry::default();
928        let (futures, _rx1) = live(
929            &mut registry,
930            request(ExchangeId::LseFutures, TRADES, &["AAPL"], Some(&state)),
931        );
932        let (books, _rx2) = live(
933            &mut registry,
934            request(
935                ExchangeId::LseEquities,
936                OrderBooksL1.as_str(),
937                &["AAPL"],
938                Some(&state),
939            ),
940        );
941
942        assert!(plan_starts(&mut registry, &[symbol("AAPL")]).is_empty());
943        assert!(starts(&registry, futures).is_empty());
944        assert!(starts(&registry, books).is_empty());
945    }
946
947    #[test]
948    fn a_replayed_frame_reaches_only_the_holders_a_window_was_opened_for() {
949        let state = Arc::new(LseResumeState::new());
950        record(
951            &state,
952            ExchangeId::LseCrypto,
953            "BTC/USD",
954            TRADES,
955            "2026-08-14T10:00:00Z",
956        );
957
958        let mut registry = Registry::default();
959        let (_, mut resumed) = live(
960            &mut registry,
961            request(ExchangeId::LseCrypto, TRADES, &["BTC/USD"], Some(&state)),
962        );
963        let (_, mut plain) = live(
964            &mut registry,
965            request(
966                ExchangeId::LseCrypto,
967                OrderBooksL1.as_str(),
968                &["BTC/USD"],
969                None,
970            ),
971        );
972        plan_starts(&mut registry, &[symbol("BTC/USD")]);
973
974        let replayed = text(serde_json::json!({"symbol": "BTC/USD", "replay": true}));
975        let current = text(serde_json::json!({"symbol": "BTC/USD"}));
976        deliver(&mut registry, &replayed);
977        deliver(&mut registry, &current);
978
979        assert_eq!(drain(&mut resumed), [replayed, current.clone()]);
980        assert_eq!(drain(&mut plain), [current]);
981    }
982
983    #[test]
984    fn a_resumed_symbol_already_on_the_socket_is_reported_as_joined_without_a_replay() {
985        let state = Arc::new(LseResumeState::new());
986        record(
987            &state,
988            ExchangeId::LseCrypto,
989            "BTC/USD",
990            TRADES,
991            "2026-08-14T10:00:00Z",
992        );
993
994        let mut registry = Registry::default();
995        let (id, _rx) = live(
996            &mut registry,
997            request(
998                ExchangeId::LseCrypto,
999                TRADES,
1000                &["BTC/USD", "ETH/USD"],
1001                Some(&state),
1002            ),
1003        );
1004
1005        assert_eq!(
1006            joined_without_replay(&registry, id, &[symbol("ETH/USD")]),
1007            ["BTC/USD"]
1008        );
1009        assert!(
1010            joined_without_replay(&registry, id, &[symbol("BTC/USD"), symbol("ETH/USD")])
1011                .is_empty()
1012        );
1013    }
1014
1015    #[test]
1016    fn frames_are_classified_by_their_routing_key_alone() {
1017        let classify_json = |value: serde_json::Value| classify(&text(value));
1018
1019        assert!(matches!(
1020            classify_json(serde_json::json!({"type": "tick", "symbol": "BTC/USD", "price": 1.0})),
1021            Classified::Route { symbol, replay: false } if symbol == "BTC/USD"
1022        ));
1023        assert!(matches!(
1024            classify_json(serde_json::json!({"type": "tick", "symbol": "BTC/USD", "replay": true})),
1025            Classified::Route { replay: true, .. }
1026        ));
1027        assert!(matches!(
1028            classify_json(serde_json::json!({"type": "replay_started", "symbol": "BTC/USD"})),
1029            Classified::Route { replay: true, .. }
1030        ));
1031        assert!(matches!(
1032            classify_json(
1033                serde_json::json!({"type": "subscribed", "symbol": "BTC/USD",
1034                "message": "Already subscribed"})
1035            ),
1036            Classified::Answer(Ok(_))
1037        ));
1038        assert!(matches!(
1039            classify_json(serde_json::json!({"type": "options_subscribed", "underlying": "SPY"})),
1040            Classified::Answer(Ok(_))
1041        ));
1042        assert!(matches!(
1043            classify_json(serde_json::json!({"type": "error", "code": "LIMIT_REACHED"})),
1044            Classified::Answer(Err(_))
1045        ));
1046        assert!(matches!(
1047            classify_json(serde_json::json!({"type": "unsubscribed", "symbol": "BTC/USD"})),
1048            Classified::Ignored
1049        ));
1050        assert!(matches!(
1051            classify_json(serde_json::json!({"type": "welcome"})),
1052            Classified::Ignored
1053        ));
1054        assert!(matches!(
1055            classify(&WsMessage::Close(None)),
1056            Classified::Closed(_)
1057        ));
1058    }
1059
1060    #[test]
1061    fn a_handshake_settles_once_every_subscribe_sent_is_confirmed() {
1062        let mut registry = Registry::default();
1063        let (id, _rx) = live(
1064            &mut registry,
1065            request(ExchangeId::LseCrypto, TRADES, &["BTC/USD", "ETH/USD"], None),
1066        );
1067        let sending = [symbol("BTC/USD"), symbol("ETH/USD")];
1068
1069        let (messages, mut handshake) = Lse::subscribe(&mut registry, id, &sending);
1070        assert_eq!(
1071            messages,
1072            [
1073                text(serde_json::json!({"action": "subscribe", "symbol": "BTC/USD"})),
1074                text(serde_json::json!({"action": "subscribe", "symbol": "ETH/USD"})),
1075            ]
1076        );
1077
1078        let confirmation = || {
1079            let frame = text(serde_json::json!({"type": "subscribed", "symbol": "BTC/USD"}));
1080            match classify(&frame) {
1081                Classified::Answer(answer) => answer.unwrap(),
1082                _ => panic!("expected an answer"),
1083            }
1084        };
1085
1086        assert!(!handshake.is_settled());
1087        handshake.observe(confirmation());
1088        assert!(!handshake.is_settled());
1089        assert!(
1090            handshake
1091                .timed_out(Duration::from_secs(1))
1092                .ends_with("1 of 2 subscriptions confirmed")
1093        );
1094        handshake.observe(confirmation());
1095        assert!(handshake.is_settled());
1096
1097        let (nothing, handshake) = Lse::subscribe(&mut registry, id, &[]);
1098        assert!(nothing.is_empty());
1099        assert!(
1100            handshake.is_settled(),
1101            "an attach sending nothing waits for nothing"
1102        );
1103    }
1104
1105    #[test]
1106    fn the_symbol_a_slot_occupies_is_released_with_the_matching_unsubscribe() {
1107        let (messages, handshake) =
1108            Lse::release(&[symbol("BTC/USD"), Slot::Underlying("SPY".into())]);
1109
1110        assert_eq!(
1111            messages,
1112            [
1113                text(serde_json::json!({"action": "unsubscribe", "symbol": "BTC/USD"})),
1114                text(serde_json::json!({"action": "unsubscribe_options", "underlying": "SPY"})),
1115            ]
1116        );
1117        assert!(handshake.is_none(), "an unsubscribe is not awaited");
1118    }
1119
1120    #[test]
1121    fn a_trades_kind_spells_itself_as_the_tests_assume() {
1122        assert_eq!(PublicTrades.as_str(), TRADES);
1123    }
1124}