Skip to main content

rustrade_data/exchange/alpaca/
connection.rs

1//! One WebSocket per feed, shared by every stream an [`AlpacaSubscriber`](super::AlpacaSubscriber)
2//! and its clones open on it.
3//!
4//! # Why the connection is shared
5//! Alpaca allows an account one market data connection **per feed** — crypto, IEX and SIP each
6//! count separately — and refuses a second on the same feed at authentication with
7//! `connection limit exceeded`. One socket per stream could therefore never stream trades and
8//! quotes from one feed at once.
9//!
10//! # Shape
11//! A task per feed owns that feed's socket. Each stream *attaches* to it and is handed an
12//! [`AlpacaAttachment`]: the frames carrying its own subscriptions, which it parses exactly as it
13//! would parse a socket of its own.
14//!
15//! Alpaca packs messages for several symbols and channels into one frame — a JSON array such as
16//! `[{"T":"t","S":"AAPL",..},{"T":"q","S":"MSFT",..}]` — so the connection splits frames rather
17//! than forwarding them whole: a stream decoding an element for a subscription it does not hold
18//! would report it as an error. The connection reads only each element's type `T` and symbol `S`.
19//! A stream every element of a frame belongs to receives the frame itself, a reference-counted
20//! clone; any other receives a frame holding only its own elements, copied verbatim. Each stream's
21//! decoder stays the only full parse.
22//!
23//! - **Connect lazily, close when idle.** A feed's socket opens on its first attach and closes once
24//!   no stream remains attached to it.
25//! - **Subscribe once per channel and symbol.** A `(channel, symbol)` pair the socket already holds
26//!   is not sent again, and its elements reach every stream holding it.
27//! - **Unsubscribe on the last detach.** Dropping an attachment unsubscribes every pair no other
28//!   stream holds.
29//! - **One handshake at a time.** Alpaca answers both a subscribe and an unsubscribe with the
30//!   connection's whole subscription state, and a refusal names nothing, so a frame answers only
31//!   the request in flight. Attaches and detaches are therefore serialised, each waiting for its
32//!   own answer; frames keep flowing to every attached stream meanwhile.
33//! - **The subscription cap is the connection's.** Alpaca limits how many pairs one connection
34//!   holds — 30 on the free IEX plan, as last measured — and refuses a subscribe that would pass
35//!   it, adding none of it. The cap depends on the plan and is not announced, so it is not checked
36//!   here: the refusal fails the attach, and says the cap is shared.
37//!
38//! # Reconnect
39//! Every stream on a socket **ends together** when it is lost, and each is re-initialised by the
40//! usual reconnect wrapper. The first to re-attach reconnects and re-subscribes everything the lost
41//! socket held; the rest re-attach to that one reconnect. Alpaca replays nothing, so what the
42//! provider sent while no socket was open is missed.
43//!
44//! A stream keeps its place for [`REATTACH_GRACE`] after the socket is lost. Frames for a stream
45//! that has not re-attached yet are held until then, and discarded with a warning if it has not;
46//! its pairs are then released. A stream dropped while the socket was down is forgotten the same
47//! way, so no later reconnect re-subscribes its pairs — which could otherwise push every reconnect
48//! over the pair cap. The grace runs from the loss, and a reconnect that fails does not extend it.
49//!
50//! # Sharing is by clone, and only by clone
51//! Clones of one subscriber share one connection per feed. Two subscribers built separately for
52//! one account open two sockets, and Alpaca refuses the second — visibly, which is left to surface
53//! rather than papered over by a process-wide registry.
54
55pub use crate::subscriber::shared::REATTACH_GRACE;
56
57use super::{
58    AlpacaCredentials, alpaca_authenticate,
59    channel::AlpacaChannel,
60    channel_message,
61    subscription::{AlpacaSubResponse, AlpacaSubResponseInner},
62};
63use crate::subscriber::shared::{
64    self, AttachId, Attachment, Connections, Frame, Handshake, Protocol, Registry,
65    elements::{self, Element, Elements, excerpt},
66};
67use crate::subscriber::shared_stream::{SharedTransport, sealed};
68use fnv::FnvHashSet;
69use futures::Stream;
70use rustrade_instrument::exchange::ExchangeId;
71use rustrade_integration::{
72    Validator,
73    error::SocketError,
74    protocol::websocket::{WebSocket, WsError, WsMessage, connect},
75};
76use serde::Deserialize;
77use serde_json::value::RawValue;
78use smol_str::SmolStr;
79use std::{
80    pin::Pin,
81    task::{Context, Poll},
82    time::Duration,
83};
84use tracing::{debug, warn};
85use url::Url;
86
87/// One subscription on the socket: a channel and a symbol, spelled as Alpaca spells it.
88#[derive(Clone, PartialEq, Eq, Hash, Debug)]
89pub(super) struct Slot {
90    pub(super) channel: AlpacaChannel,
91    pub(super) symbol: SmolStr,
92}
93
94fn subscribe_message(slots: &[Slot]) -> WsMessage {
95    channel_message("subscribe", slot_pairs(slots))
96}
97
98fn unsubscribe_message(slots: &[Slot]) -> WsMessage {
99    channel_message("unsubscribe", slot_pairs(slots))
100}
101
102fn slot_pairs(slots: &[Slot]) -> impl Iterator<Item = (AlpacaChannel, &str)> {
103    slots
104        .iter()
105        .map(|slot| (slot.channel, slot.symbol.as_str()))
106}
107
108/// The Alpaca side of a shared connection.
109#[derive(Debug)]
110pub(super) struct Alpaca;
111
112/// The handle an `AlpacaSubscriber` and its clones share: the way into each feed's connection task.
113#[derive(Debug)]
114pub(super) struct AlpacaConnections(Connections<Alpaca>);
115
116impl AlpacaConnections {
117    pub(super) fn new(credentials: AlpacaCredentials) -> Self {
118        Self(Connections::new(credentials))
119    }
120
121    /// Attach one stream's batch to its feed's connection, connecting first if nothing is attached.
122    pub(super) async fn attach(
123        &self,
124        request: AttachRequest,
125    ) -> Result<AlpacaAttachment, SocketError> {
126        let AttachRequest {
127            exchange,
128            url,
129            kind,
130            slots,
131            timeout,
132        } = request;
133
134        self.0
135            .attach(shared::AttachRequest {
136                exchange,
137                url,
138                kind,
139                slots,
140                timeout,
141                batch: (),
142            })
143            .await
144            .map(AlpacaAttachment)
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 pairs requested, in request order.
156    pub(super) slots: Vec<Slot>,
157    pub(super) timeout: Duration,
158}
159
160/// What a handshake waits for Alpaca to report.
161#[derive(Debug)]
162pub(super) struct Awaiting {
163    asked: Asked,
164    slots: Vec<Slot>,
165    /// The latest state Alpaca reported, so a timeout can name what it never reported.
166    last: Option<FnvHashSet<Slot>>,
167}
168
169#[derive(Debug)]
170enum Asked {
171    /// Every pair subscribed.
172    Subscribed,
173    /// None of the pairs subscribed any longer.
174    Released,
175}
176
177impl Awaiting {
178    fn new(asked: Asked, slots: &[Slot]) -> Self {
179        Self {
180            asked,
181            slots: slots.to_vec(),
182            last: None,
183        }
184    }
185
186    /// Whether `state` reports `slot` the way this handshake asks for.
187    fn settles(&self, state: &FnvHashSet<Slot>, slot: &Slot) -> bool {
188        match self.asked {
189            Asked::Subscribed => state.contains(slot),
190            Asked::Released => !state.contains(slot),
191        }
192    }
193
194    fn is_answered_by(&self, state: &FnvHashSet<Slot>) -> bool {
195        self.slots.iter().all(|slot| self.settles(state, slot))
196    }
197}
198
199impl Handshake<FnvHashSet<Slot>> for Awaiting {
200    fn is_settled(&self) -> bool {
201        self.slots.is_empty()
202            || self
203                .last
204                .as_ref()
205                .is_some_and(|state| self.is_answered_by(state))
206    }
207
208    /// Alpaca reports the connection's whole state, so the latest answer is the one that counts.
209    fn observe(&mut self, state: FnvHashSet<Slot>) {
210        debug!(held = state.len(), "Alpaca subscription state");
211        self.last = Some(state);
212    }
213
214    /// The pairs it still waits on, in request order. With no state reported, that is every pair.
215    fn timed_out(&self, timeout: Duration) -> String {
216        let asked = match self.asked {
217            Asked::Subscribed => "subscribed",
218            Asked::Released => "unsubscribed",
219        };
220
221        let outstanding = self
222            .slots
223            .iter()
224            .filter(|slot| {
225                self.last
226                    .as_ref()
227                    .is_none_or(|state| !self.settles(state, slot))
228            })
229            .map(|slot| format!("{} {}", slot.channel.as_ref(), slot.symbol))
230            .collect::<Vec<_>>()
231            .join(", ");
232
233        format!(
234            "subscription validation timeout reached: {timeout:?}; Alpaca never reported as \
235             {asked}: {outstanding}"
236        )
237    }
238}
239
240impl Protocol for Alpaca {
241    const NAME: &'static str = "Alpaca";
242    // Alpaca caps connections per feed, and each feed has an endpoint of its own.
243    const CONNECTION_PER_ENDPOINT: bool = true;
244    // Alpaca refuses a subscribe whole, adding none of it.
245    const REFUSAL_IS_ATOMIC: bool = true;
246
247    type Credentials = AlpacaCredentials;
248    type Slot = Slot;
249    type Batch = ();
250    type Session = ();
251    type Answer = FnvHashSet<Slot>;
252    type Handshake = Awaiting;
253
254    async fn connect(
255        credentials: &AlpacaCredentials,
256        url: &Url,
257    ) -> Result<(WebSocket, ()), SocketError> {
258        let mut websocket = connect(url.clone()).await?;
259        alpaca_authenticate(&mut websocket, credentials)
260            .await
261            .map_err(connection_limit_context)?;
262        debug!(%url, "authenticated to Alpaca WebSocket");
263
264        Ok((websocket, ()))
265    }
266
267    fn subscribe(
268        _registry: &mut Registry<Self>,
269        _id: AttachId,
270        sending: &[Slot],
271    ) -> (Vec<WsMessage>, Awaiting) {
272        let messages = if sending.is_empty() {
273            Vec::new()
274        } else {
275            vec![subscribe_message(sending)]
276        };
277
278        (messages, Awaiting::new(Asked::Subscribed, sending))
279    }
280
281    /// Answered, like a subscribe, with the connection's whole state, which the next subscribe
282    /// could otherwise take for its own answer.
283    fn release(slots: &[Slot]) -> (Vec<WsMessage>, Option<Awaiting>) {
284        (
285            vec![unsubscribe_message(slots)],
286            Some(Awaiting::new(Asked::Released, slots)),
287        )
288    }
289
290    fn read(registry: &mut Registry<Self>, message: &WsMessage) -> Frame<FnvHashSet<Slot>> {
291        match classify(message) {
292            Classified::Elements {
293                total,
294                data,
295                answers,
296            } => {
297                elements::route(registry, message, total, &data);
298                Frame::Answers(answers)
299            }
300            Classified::Closed(cause) => Frame::Closed(cause),
301            Classified::Ignored => Frame::Answers(Vec::new()),
302        }
303    }
304
305    /// Add to a refusal for the subscription cap what the stream cannot see: the cap is shared.
306    fn refused(error: SocketError) -> SocketError {
307        match error {
308            SocketError::Subscribe(message) if message.contains("symbol limit exceeded") => {
309                SocketError::Subscribe(format!(
310                    "{message} (the connection and its subscription cap are shared by every \
311                     stream this subscriber and its clones open on the feed)"
312                ))
313            }
314            other => other,
315        }
316    }
317}
318
319/// One stream's view of an Alpaca feed's shared connection.
320///
321/// A stream of frames holding only the stream's own subscriptions, in the order the socket
322/// delivered them, so it is parsed exactly as a socket of the stream's own would be. It ends when
323/// the connection is lost, which is what drives the stream's reconnect.
324///
325/// Dropping it detaches the stream: every subscription no other stream holds is unsubscribed, and
326/// the socket closes once nothing remains attached.
327///
328/// # Keep it drained
329/// The queue behind it is unbounded. The connection reads one socket for every stream sharing it,
330/// so it never waits for a slow one — that would stall the rest — and a stream it serves is not
331/// back-pressured the way a socket of its own would be. Frames for a stream that stops being polled
332/// accumulate in memory until it is polled again or dropped.
333///
334/// See [`connection`](self) for how the connection is shared.
335#[derive(Debug)]
336pub struct AlpacaAttachment(Attachment<Alpaca>);
337
338impl Stream for AlpacaAttachment {
339    type Item = Result<WsMessage, WsError>;
340
341    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
342        Pin::new(&mut self.0).poll_next(cx)
343    }
344}
345
346impl sealed::Sealed for AlpacaAttachment {}
347
348/// Alpaca hands a stream nothing on attaching.
349impl SharedTransport for AlpacaAttachment {
350    type Attached = ();
351
352    fn take_attached(&mut self) {
353        self.0.take_attached()
354    }
355}
356
357/// Add to a refused authentication what usually causes it.
358fn connection_limit_context(error: SocketError) -> SocketError {
359    match error {
360        SocketError::Subscribe(message) if message.contains("connection limit exceeded") => {
361            SocketError::Subscribe(format!(
362                "{message} (Alpaca allows one connection per feed: another subscriber built \
363                 separately, or another process, holds it. Streams sharing a feed must be opened \
364                 by clones of one subscriber)"
365            ))
366        }
367        other => other,
368    }
369}
370
371/// The part of a frame element routing reads.
372///
373/// Short strings deserialise inline — a message type and a symbol both fit — so this allocates
374/// nothing per element, whether or not the provider escapes a character.
375#[derive(Deserialize)]
376struct Envelope {
377    #[serde(rename = "T")]
378    kind: SmolStr,
379    #[serde(rename = "S", default)]
380    symbol: Option<SmolStr>,
381}
382
383enum Classified<'a> {
384    Elements {
385        /// Every element in the frame, of every kind.
386        total: usize,
387        data: Vec<Element<'a, Slot>>,
388        answers: Vec<Result<FnvHashSet<Slot>, SocketError>>,
389    },
390    Closed(String),
391    Ignored,
392}
393
394fn classify(message: &WsMessage) -> Classified<'_> {
395    let elements = match elements::elements::<Alpaca>(message) {
396        Elements::Array(elements) => elements,
397        Elements::Closed(cause) => return Classified::Closed(cause),
398        Elements::Ignored => return Classified::Ignored,
399    };
400
401    let total = elements.len();
402    let mut data = Vec::with_capacity(total);
403    let mut answers = Vec::new();
404
405    for raw in elements {
406        let envelope = match serde_json::from_str::<Envelope>(raw.get()) {
407            Ok(envelope) => envelope,
408            Err(error) => {
409                warn!(
410                    %error,
411                    element = %excerpt(raw.get()),
412                    "Alpaca sent a message with no type; ignored",
413                );
414                continue;
415            }
416        };
417
418        let channel = match envelope.kind.as_str() {
419            "subscription" | "error" => {
420                answers.push(answer(raw));
421                continue;
422            }
423            // Corrections and cancel errors accompany trades, and belong to whoever holds them.
424            "t" | "c" | "x" => AlpacaChannel::Trades,
425            "q" => AlpacaChannel::Quotes,
426            kind => {
427                debug!(kind, "Alpaca message of a type no stream subscribes to");
428                continue;
429            }
430        };
431
432        let Some(symbol) = envelope.symbol else {
433            warn!(
434                element = %excerpt(raw.get()),
435                "Alpaca sent a market data message with no symbol; ignored",
436            );
437            continue;
438        };
439
440        data.push(Element {
441            slot: Slot { channel, symbol },
442            raw,
443        });
444    }
445
446    Classified::Elements {
447        total,
448        data,
449        answers,
450    }
451}
452
453/// The connection's subscription state a `subscription` message reports, or the refusal an `error`
454/// message carries.
455fn answer(raw: &RawValue) -> Result<FnvHashSet<Slot>, SocketError> {
456    let inner = serde_json::from_str::<AlpacaSubResponseInner>(raw.get()).map_err(|error| {
457        SocketError::Deserialise {
458            error,
459            payload: raw.get().to_owned(),
460        }
461    })?;
462
463    let response = AlpacaSubResponse(vec![inner]).validate()?;
464
465    Ok(response
466        .0
467        .iter()
468        .flat_map(AlpacaSubResponseInner::covered)
469        .map(|(channel, symbol)| Slot {
470            channel,
471            symbol: symbol.clone(),
472        })
473        .collect())
474}
475
476#[cfg(test)]
477#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
478mod tests {
479    use super::*;
480    use serde_json::{Value, json};
481    use tokio::sync::mpsc;
482
483    const TRADES: AlpacaChannel = AlpacaChannel::Trades;
484    const QUOTES: AlpacaChannel = AlpacaChannel::Quotes;
485
486    fn slot(channel: AlpacaChannel, symbol: &str) -> Slot {
487        Slot {
488            channel,
489            symbol: SmolStr::new(symbol),
490        }
491    }
492
493    fn attach(
494        registry: &mut Registry<Alpaca>,
495        pairs: &[(AlpacaChannel, &str)],
496    ) -> (AttachId, mpsc::UnboundedReceiver<WsMessage>) {
497        let (tx, rx) = mpsc::unbounded_channel();
498        let request = shared::AttachRequest {
499            exchange: ExchangeId::AlpacaIex,
500            url: Url::parse("ws://127.0.0.1:1").unwrap(),
501            kind: "public_trades",
502            slots: pairs
503                .iter()
504                .map(|(channel, symbol)| slot(*channel, symbol))
505                .collect(),
506            timeout: Duration::from_secs(1),
507            batch: (),
508        };
509
510        (registry.insert_live(request, tx), rx)
511    }
512
513    fn trade(symbol: &str) -> Value {
514        json!({"T": "t", "S": symbol, "i": 1, "p": 1.5, "s": 2, "t": "2026-09-25T10:00:00Z"})
515    }
516
517    fn quote(symbol: &str) -> Value {
518        json!({"T": "q", "S": symbol, "bp": 1.0, "bs": 1, "ap": 2.0, "as": 1,
519               "t": "2026-09-25T10:00:00Z"})
520    }
521
522    fn frame(elements: &[Value]) -> WsMessage {
523        WsMessage::text(Value::Array(elements.to_vec()).to_string())
524    }
525
526    /// Route one frame through the registry exactly as the connection task does, returning what
527    /// it answers.
528    fn deliver(
529        registry: &mut Registry<Alpaca>,
530        message: &WsMessage,
531    ) -> Vec<Result<FnvHashSet<Slot>, SocketError>> {
532        match Alpaca::read(registry, message) {
533            Frame::Answers(answers) => answers,
534            Frame::Closed(cause) => panic!("expected a frame of elements, got a close: {cause}"),
535        }
536    }
537
538    /// Every frame waiting on `rx`, each parsed back into its elements.
539    fn received(rx: &mut mpsc::UnboundedReceiver<WsMessage>) -> Vec<Vec<Value>> {
540        std::iter::from_fn(|| rx.try_recv().ok())
541            .map(|message| match message {
542                WsMessage::Text(text) => serde_json::from_str(text.as_str()).unwrap(),
543                other => panic!("expected a text frame, got {other:?}"),
544            })
545            .collect()
546    }
547
548    fn answers(message: &WsMessage) -> Vec<Result<FnvHashSet<Slot>, SocketError>> {
549        deliver(&mut Registry::default(), message)
550    }
551
552    fn only<T: std::fmt::Debug>(mut items: Vec<T>) -> T {
553        assert_eq!(items.len(), 1, "expected exactly one, got {items:?}");
554        items.remove(0)
555    }
556
557    fn state(slots: &[Slot]) -> FnvHashSet<Slot> {
558        slots.iter().cloned().collect()
559    }
560
561    #[test]
562    fn a_mixed_frame_is_split_per_stream_in_its_original_order() {
563        let mut registry = Registry::default();
564        let (_, mut apple) = attach(&mut registry, &[(TRADES, "AAPL")]);
565        let (_, mut microsoft) = attach(&mut registry, &[(QUOTES, "MSFT"), (TRADES, "MSFT")]);
566
567        deliver(
568            &mut registry,
569            &frame(&[trade("MSFT"), trade("AAPL"), quote("MSFT"), quote("AAPL")]),
570        );
571
572        assert_eq!(received(&mut apple), [vec![trade("AAPL")]]);
573        assert_eq!(
574            received(&mut microsoft),
575            [vec![trade("MSFT"), quote("MSFT")]],
576            "a stream's share keeps the order the provider sent it in",
577        );
578    }
579
580    #[test]
581    fn a_frame_wholly_for_one_stream_is_forwarded_as_sent() {
582        let mut registry = Registry::default();
583        let (_, mut rx) = attach(&mut registry, &[(TRADES, "BTC/USD"), (QUOTES, "BTC/USD")]);
584
585        // Spacing no re-serialisation would reproduce, so an unchanged frame is distinguishable.
586        let sent = WsMessage::text(format!("[ {} ,{}]", trade("BTC/USD"), quote("BTC/USD")));
587        deliver(&mut registry, &sent);
588
589        assert_eq!(rx.try_recv().unwrap(), sent);
590        assert!(rx.try_recv().is_err());
591    }
592
593    #[test]
594    fn a_pair_held_by_two_streams_reaches_both_and_an_unheld_one_neither() {
595        let mut registry = Registry::default();
596        let (_, mut first) = attach(&mut registry, &[(TRADES, "AAPL")]);
597        let (_, mut second) = attach(&mut registry, &[(TRADES, "AAPL")]);
598
599        deliver(&mut registry, &frame(&[trade("AAPL"), trade("TSLA")]));
600
601        assert_eq!(received(&mut first), [vec![trade("AAPL")]]);
602        assert_eq!(received(&mut second), [vec![trade("AAPL")]]);
603    }
604
605    #[test]
606    fn corrections_and_cancel_errors_reach_the_streams_holding_the_trades() {
607        let mut registry = Registry::default();
608        let (_, mut trades) = attach(&mut registry, &[(TRADES, "AAPL")]);
609        let (_, mut quotes) = attach(&mut registry, &[(QUOTES, "AAPL")]);
610
611        let correction = json!({"T": "c", "S": "AAPL", "x": "V", "oi": 1, "ci": 2});
612        let cancel = json!({"T": "x", "S": "AAPL", "i": 1, "a": "C"});
613        deliver(&mut registry, &frame(&[correction.clone(), cancel.clone()]));
614
615        assert_eq!(received(&mut trades), [vec![correction, cancel]]);
616        assert!(received(&mut quotes).is_empty());
617    }
618
619    #[test]
620    fn a_frame_carrying_control_messages_is_never_forwarded_whole() {
621        let mut registry = Registry::default();
622        let (_, mut rx) = attach(&mut registry, &[(TRADES, "AAPL")]);
623
624        let state = json!({"T": "subscription", "trades": ["AAPL"], "quotes": []});
625        deliver(&mut registry, &frame(&[state, trade("AAPL")]));
626
627        assert_eq!(received(&mut rx), [vec![trade("AAPL")]]);
628    }
629
630    #[test]
631    fn a_subscription_message_reports_the_whole_state_and_an_error_refuses() {
632        let state = frame(&[json!({
633            "T": "subscription",
634            "trades": ["BTC/USD"],
635            "quotes": ["ETH/USD", "BTC/USD"],
636            "orderbooks": [],
637            "bars": [],
638        })]);
639        let held = only(answers(&state)).unwrap();
640        assert_eq!(
641            held,
642            FnvHashSet::from_iter([
643                slot(TRADES, "BTC/USD"),
644                slot(QUOTES, "ETH/USD"),
645                slot(QUOTES, "BTC/USD"),
646            ]),
647        );
648
649        let refusal = frame(&[json!({"T": "error", "code": 405, "msg": "symbol limit exceeded"})]);
650        let error = only(answers(&refusal)).unwrap_err();
651        assert!(
652            error.to_string().contains("symbol limit exceeded"),
653            "{error}"
654        );
655    }
656
657    #[test]
658    fn a_success_message_answers_nothing_and_reaches_no_stream() {
659        let mut registry = Registry::default();
660        let (_, mut rx) = attach(&mut registry, &[(TRADES, "AAPL")]);
661
662        let success = frame(&[json!({"T": "success", "msg": "authenticated"})]);
663        assert!(deliver(&mut registry, &success).is_empty());
664
665        assert!(received(&mut rx).is_empty());
666    }
667
668    #[test]
669    fn a_market_data_message_with_no_symbol_is_dropped_and_the_rest_routed() {
670        let mut registry = Registry::default();
671        let (_, mut rx) = attach(&mut registry, &[(TRADES, "AAPL")]);
672
673        let anonymous = json!({"T": "t", "i": 1, "p": 1.0});
674        deliver(&mut registry, &frame(&[anonymous, trade("AAPL")]));
675
676        assert_eq!(received(&mut rx), [vec![trade("AAPL")]]);
677    }
678
679    #[test]
680    fn only_a_cap_refusal_is_told_the_cap_is_shared() {
681        let refusal = |msg: &str| {
682            let frame = frame(&[json!({"T": "error", "code": 405, "msg": msg})]);
683            Alpaca::refused(only(answers(&frame)).unwrap_err()).to_string()
684        };
685
686        assert!(refusal("symbol limit exceeded").contains("shared by every stream"));
687        assert!(!refusal("invalid syntax").contains("shared by every stream"));
688    }
689
690    #[test]
691    fn a_timed_out_handshake_names_what_alpaca_never_reported() {
692        let slots = [slot(TRADES, "AAPL"), slot(QUOTES, "MSFT")];
693        let timeout = Duration::from_secs(1);
694        let reported = |asked, last: Option<&[Slot]>| {
695            let mut awaiting = Awaiting::new(asked, &slots);
696            if let Some(last) = last {
697                awaiting.observe(state(last));
698            }
699            awaiting.timed_out(timeout)
700        };
701
702        let subscribing = reported(Asked::Subscribed, Some(&slots[..1]));
703        assert!(
704            subscribing.ends_with("subscribed: quotes MSFT"),
705            "{subscribing}"
706        );
707
708        let nothing_reported = reported(Asked::Subscribed, None);
709        assert!(
710            nothing_reported.ends_with("trades AAPL, quotes MSFT"),
711            "{nothing_reported}"
712        );
713
714        let releasing = reported(Asked::Released, Some(&slots[..1]));
715        assert!(
716            releasing.ends_with("unsubscribed: trades AAPL"),
717            "{releasing}"
718        );
719
720        let nothing_released = reported(Asked::Released, None);
721        assert!(
722            nothing_released.ends_with("unsubscribed: trades AAPL, quotes MSFT"),
723            "{nothing_released}"
724        );
725    }
726
727    #[test]
728    fn a_frame_that_is_not_an_array_is_ignored() {
729        assert!(matches!(
730            classify(&WsMessage::text(r#"{"T":"t"}"#)),
731            Classified::Ignored
732        ));
733    }
734
735    #[test]
736    fn a_state_answers_a_subscribe_only_once_it_holds_every_pair() {
737        let sending = [slot(TRADES, "AAPL"), slot(QUOTES, "AAPL")];
738        let mut awaiting = Awaiting::new(Asked::Subscribed, &sending);
739        assert!(!awaiting.is_settled());
740
741        awaiting.observe(state(&[slot(TRADES, "AAPL")]));
742        assert!(!awaiting.is_settled());
743
744        awaiting.observe(state(&[
745            slot(TRADES, "AAPL"),
746            slot(QUOTES, "AAPL"),
747            slot(TRADES, "MSFT"),
748        ]));
749        assert!(awaiting.is_settled());
750    }
751
752    #[test]
753    fn a_state_answers_an_unsubscribe_only_once_it_holds_none_of_the_pairs() {
754        let releasing = [slot(TRADES, "AAPL")];
755        let mut awaiting = Awaiting::new(Asked::Released, &releasing);
756
757        awaiting.observe(state(&[slot(TRADES, "AAPL")]));
758        assert!(!awaiting.is_settled());
759
760        awaiting.observe(state(&[slot(QUOTES, "AAPL")]));
761        assert!(awaiting.is_settled());
762    }
763
764    #[test]
765    fn an_attach_sending_nothing_waits_for_nothing() {
766        let mut registry = Registry::default();
767        let (id, _rx) = attach(&mut registry, &[(TRADES, "AAPL")]);
768
769        let (messages, awaiting) = Alpaca::subscribe(&mut registry, id, &[]);
770
771        assert!(messages.is_empty());
772        assert!(awaiting.is_settled());
773    }
774
775    #[test]
776    fn subscribe_and_unsubscribe_payloads_group_pairs_by_channel() {
777        let pairs = [
778            slot(TRADES, "AAPL"),
779            slot(QUOTES, "MSFT"),
780            slot(TRADES, "SPY"),
781        ];
782
783        let parse = |message: &WsMessage| match message {
784            WsMessage::Text(text) => serde_json::from_str::<Value>(text.as_str()).unwrap(),
785            other => panic!("expected a text frame, got {other:?}"),
786        };
787
788        let (subscribe, _) = Alpaca::subscribe(&mut Registry::default(), 0, &pairs);
789        assert_eq!(
790            parse(&only(subscribe)),
791            json!({"action": "subscribe", "trades": ["AAPL", "SPY"], "quotes": ["MSFT"]}),
792        );
793
794        let (unsubscribe, awaiting) = Alpaca::release(&pairs[..1]);
795        assert_eq!(
796            parse(&only(unsubscribe)),
797            json!({"action": "unsubscribe", "trades": ["AAPL"]}),
798        );
799        assert!(awaiting.is_some(), "an unsubscribe is awaited");
800    }
801}