1pub 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#[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#[derive(Debug)]
128pub(super) struct Lse;
129
130#[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 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#[derive(Debug)]
150pub(super) struct AttachRequest {
151 pub(super) exchange: ExchangeId,
152 pub(super) url: Url,
153 pub(super) kind: &'static str,
155 pub(super) markets: Vec<SmolStr>,
157 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#[derive(Debug)]
201pub(super) struct LseBatch {
202 kind: &'static str,
205 markets: Vec<SmolStr>,
207 per_underlying: bool,
209 resume: Option<Arc<LseResumeState>>,
210
211 starts: FnvHashMap<SubscriptionId, DateTime<Utc>>,
213 replayed: FnvHashSet<SmolStr>,
216}
217
218impl Batch for LseBatch {
219 type Attached = LseResume;
220
221 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 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
261pub(super) struct Session {
263 offered: OfferedSymbols,
264 max_subscriptions: Option<u32>,
265}
266
267#[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 const CONNECTION_PER_ENDPOINT: bool = false;
301 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 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 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 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#[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
461impl SharedTransport for LseAttachment {
463 type Attached = LseResume;
464
465 fn take_attached(&mut self) -> LseResume {
466 self.0.take_attached()
467 }
468}
469
470fn 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
486fn 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
530fn 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
550fn 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 if let Some(underlying) = osi::root(symbol)
569 && registry.route(&Slot::Underlying(SmolStr::new(underlying)), accepts, frame)
570 {
571 return;
572 }
573
574 debug!(
576 %symbol,
577 "London Strategic Edge frame for a symbol no stream holds"
578 );
579}
580
581#[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 _ => 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 (Some("unsubscribed" | "options_unsubscribed"), _) => {
645 debug!(
646 frame = text,
647 "London Strategic Edge released a subscription"
648 );
649 Classified::Ignored
650 }
651 (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)] mod 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 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 #[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(®istry, id).get(&subscription_id("BTC/USD")),
865 Some(&at("2026-08-14T10:00:01Z")),
866 );
867 }
868 }
869
870 #[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 #[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(®istry, futures).is_empty());
944 assert!(starts(®istry, 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, ¤t);
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(®istry, id, &[symbol("ETH/USD")]),
1007 ["BTC/USD"]
1008 );
1009 assert!(
1010 joined_without_replay(®istry, 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}