Skip to main content

rustrade_data/exchange/alpaca/
mod.rs

1//! Alpaca market data connectors for US equities and crypto.
2//!
3//! Connects to Alpaca's market data WebSocket streams:
4//! - IEX: `wss://stream.data.alpaca.markets/v2/iex` (free, US equities)
5//! - SIP: `wss://stream.data.alpaca.markets/v2/sip` (paid, consolidated tape)
6//! - Crypto: `wss://stream.data.alpaca.markets/v1beta3/crypto/us`
7//!
8//! # Testing Status
9//!
10//! **Tested locally, CI planned (free tier — paper trading allowed):**
11//! - Crypto streaming: trades and quotes (24/7)
12//! - IEX equity streaming: trades and quotes (market hours only)
13//!
14//! **NOT tested (requires Algo Trader Plus subscription):**
15//! - SIP equity streaming — implemented but unverified against real endpoints
16//!
17//! # Authentication
18//!
19//! Alpaca requires authentication via [`AlpacaCredentials`](crate::exchange::alpaca::AlpacaCredentials). Credentials can be:
20//! - Loaded from environment variables via [`AlpacaCredentials::from_env()`](crate::exchange::alpaca::AlpacaCredentials::from_env)
21//! - Provided explicitly via [`AlpacaCredentials::new()`](crate::exchange::alpaca::AlpacaCredentials::new)
22//!
23//! Auth message is sent immediately after WebSocket connection, before subscriptions.
24//!
25//! # One connection per feed
26//!
27//! Alpaca allows an account one market data connection per feed and refuses a second with
28//! `connection limit exceeded`. Every stream an
29//! [`AlpacaSubscriber`](crate::exchange::alpaca::AlpacaSubscriber) and its clones open on a feed
30//! shares one socket to it, so trades and quotes — any number of symbols, over any number of
31//! `subscribe` calls — can stream together. Pass clones of one subscriber to every stream on the
32//! account; see [`AlpacaSubscriber`](crate::exchange::alpaca::AlpacaSubscriber) and
33//! [`connection`](crate::exchange::alpaca::connection) for the details.
34//!
35//! # Example
36//!
37//! ```ignore
38//! use rustrade_data::exchange::alpaca::{AlpacaCredentials, AlpacaSubscriber, AlpacaIex};
39//! use rustrade_data::streams::Streams;
40//! use rustrade_data::subscription::trade::PublicTrades;
41//!
42//! // Load credentials at construction time (fails fast if env vars missing)
43//! let subscriber = AlpacaSubscriber::from_env()?;
44//!
45//! let streams = Streams::<PublicTrades>::builder()
46//!     .subscribe(subscriber, [(AlpacaIex::default(), "AAPL", "USD", PublicTrades)])
47//!     .init()
48//!     .await?;
49//! ```
50//!
51//! # Connectors
52//!
53//! - [`AlpacaIex`](crate::exchange::alpaca::AlpacaIex): Free IEX feed for US equities
54//! - [`AlpacaSip`](crate::exchange::alpaca::AlpacaSip): Paid consolidated SIP feed (untested — requires subscription)
55//! - [`AlpacaCrypto`](crate::exchange::alpaca::AlpacaCrypto): Crypto market data
56//!
57//! # Supported Streams
58//!
59//! - [`PublicTrades`](crate::subscription::trade::PublicTrades): Real-time trades
60//! - [`Quotes`](crate::subscription::quote::Quotes): Real-time quotes — IEX's own top of book on
61//!   the IEX feed, the NBBO on SIP, Alpaca's crypto venue on crypto
62//! - [`OrderBooksL1`](crate::subscription::book::OrderBooksL1): the same quotes as a top of book,
63//!   read from the same `quotes` channel. This is the kind
64//!   [`DynamicStreams`](crate::streams::builder::dynamic::DynamicStreams) serves, since the engine's
65//!   market data carries a top of book rather than a quote. A side Alpaca publishes at a zero price
66//!   has no quote and is `None`.
67//!
68//! # Subscription confirmation
69//!
70//! Alpaca answers a subscribe with one frame naming every symbol the connection holds.
71//! Initialisation does not return until **every** requested symbol has been named; if any is
72//! still outstanding when the subscription timeout expires, the subscribe fails and names what was
73//! missing. A partial subscription is therefore an error rather than a quietly reduced stream. The
74//! [`AlpacaSubscriber`](crate::exchange::alpaca::AlpacaSubscriber) confirms each subscribe on the
75//! connection it shares.
76//!
77//! **A confirmed symbol is not a promise of prompt data.** Alpaca's crypto feed publishes a quote
78//! when top-of-book changes, so the delay before a given symbol first ticks is large and highly
79//! variable -- 1s, 15s, 96s and 132s for four symbols confirmed on one connection in a single
80//! 300s window. Callers must not infer a failed subscription from silence, and must not use the
81//! first event as a readiness signal for any particular instrument.
82
83use self::{
84    channel::AlpacaChannel,
85    connection::{AlpacaAttachment, AlpacaConnections, AttachRequest, Slot},
86    market::AlpacaMarket,
87    quote::AlpacaQuoteTransformer,
88    stream::AlpacaStream,
89    subscription::AlpacaSubResponse,
90    trade::AlpacaTradeTransformer,
91};
92use crate::{
93    Identifier, NoInitialSnapshots,
94    exchange::{Connector, ExchangeServer, ExchangeSub, StreamSelector},
95    instrument::InstrumentData,
96    subscriber::{
97        Subscribed, Subscriber, mapper::SubscriptionMapper, validator::WebSocketSubValidator,
98    },
99    subscription::{
100        Subscription, SubscriptionKind, SubscriptionMeta, book::OrderBooksL1, quote::Quotes,
101        trade::PublicTrades,
102    },
103};
104use fnv::FnvHashSet;
105use futures::{SinkExt, StreamExt};
106use rustrade_instrument::exchange::ExchangeId;
107use rustrade_integration::{
108    error::SocketError,
109    protocol::websocket::{WebSocket, WsMessage},
110};
111use serde::{Deserialize, Serialize};
112use serde_json::json;
113use smol_str::SmolStr;
114use std::{env, fmt, fmt::Debug, marker::PhantomData, sync::Arc, time::Duration};
115use tracing::debug;
116use url::Url;
117
118pub mod channel;
119pub mod connection;
120pub mod market;
121pub mod options;
122pub mod quote;
123pub mod reference;
124pub mod rest;
125pub mod stream;
126pub mod subscription;
127pub mod trade;
128
129// `StockSplitSource` adapter for `AlpacaRestClient` (no public items of its own — just the impl).
130mod corporate_action;
131// Shared page-cap + `page_token` cycle guard for the paginated REST fetches.
132mod pagination;
133
134pub use reference::{AlpacaStockSplit, CorporateActionsQuery};
135pub use rest::{AlpacaRestClient, AlpacaRestError};
136
137/// IEX WebSocket URL (free US equities feed).
138pub const WEBSOCKET_URL_IEX: &str = "wss://stream.data.alpaca.markets/v2/iex";
139
140/// SIP WebSocket URL (paid consolidated US equities feed).
141///
142/// **Note**: Requires paid Alpaca market data subscription. This connector is
143/// implemented but untested — use at your own risk.
144pub const WEBSOCKET_URL_SIP: &str = "wss://stream.data.alpaca.markets/v2/sip";
145
146/// Crypto WebSocket URL.
147pub const WEBSOCKET_URL_CRYPTO: &str = "wss://stream.data.alpaca.markets/v1beta3/crypto/us";
148
149/// Alpaca IEX equities connector (free feed).
150pub type AlpacaIex = Alpaca<AlpacaServerIex>;
151
152/// Alpaca SIP equities connector (paid feed, untested).
153pub type AlpacaSip = Alpaca<AlpacaServerSip>;
154
155/// Alpaca crypto connector.
156pub type AlpacaCrypto = Alpaca<AlpacaServerCrypto>;
157
158/// Generic Alpaca market data connector.
159///
160/// Use type aliases [`AlpacaIex`], [`AlpacaSip`], or [`AlpacaCrypto`] for specific servers.
161#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
162pub struct Alpaca<Server>(PhantomData<Server>);
163
164/// IEX server (free US equities feed).
165#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
166pub struct AlpacaServerIex;
167
168/// SIP server (paid consolidated US equities feed).
169///
170/// **Warning**: Requires paid Alpaca market data subscription. Untested.
171#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
172pub struct AlpacaServerSip;
173
174/// Crypto server.
175#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
176pub struct AlpacaServerCrypto;
177
178impl ExchangeServer for AlpacaServerIex {
179    const ID: ExchangeId = ExchangeId::AlpacaIex;
180    fn websocket_url() -> &'static str {
181        WEBSOCKET_URL_IEX
182    }
183}
184
185impl ExchangeServer for AlpacaServerSip {
186    const ID: ExchangeId = ExchangeId::AlpacaSip;
187    fn websocket_url() -> &'static str {
188        WEBSOCKET_URL_SIP
189    }
190}
191
192impl ExchangeServer for AlpacaServerCrypto {
193    const ID: ExchangeId = ExchangeId::AlpacaCrypto;
194    fn websocket_url() -> &'static str {
195        WEBSOCKET_URL_CRYPTO
196    }
197}
198
199impl<Server> Connector for Alpaca<Server>
200where
201    Server: ExchangeServer,
202{
203    const ID: ExchangeId = Server::ID;
204    type Channel = AlpacaChannel;
205    type Market = AlpacaMarket;
206    type Subscriber = AlpacaSubscriber;
207    // Never called: `AlpacaSubscriber` confirms each subscribe on the connection it shares.
208    type SubValidator = WebSocketSubValidator;
209    type SubResponse = AlpacaSubResponse;
210
211    fn url() -> Result<Url, url::ParseError> {
212        Url::parse(Server::websocket_url())
213    }
214
215    /// The payload a socket of its own would be subscribed with.
216    ///
217    /// [`AlpacaSubscriber`] subscribes through its shared connection instead, which alone knows
218    /// what the socket already holds; both build their payloads with the same function, so they
219    /// cannot disagree about its shape.
220    fn requests(exchange_subs: Vec<ExchangeSub<Self::Channel, Self::Market>>) -> Vec<WsMessage> {
221        vec![channel_message(
222            "subscribe",
223            exchange_subs
224                .iter()
225                .map(|sub| (sub.channel, sub.market.as_ref())),
226        )]
227    }
228
229    // `expected_responses` is deliberately left at its default. `AlpacaSubscriber` confirms a
230    // subscribe on coverage of the requested subscriptions rather than on a response count, so
231    // no count it could return would be consulted.
232}
233
234impl<Instrument, Server> StreamSelector<Instrument, PublicTrades> for Alpaca<Server>
235where
236    Instrument: InstrumentData,
237    Server: ExchangeServer + Debug + Send + Sync,
238{
239    type SnapFetcher = NoInitialSnapshots;
240    type Stream = AlpacaStream<AlpacaTradeTransformer<Self, Instrument::Key>>;
241}
242
243impl<Instrument, Server> StreamSelector<Instrument, Quotes> for Alpaca<Server>
244where
245    Instrument: InstrumentData,
246    Server: ExchangeServer + Debug + Send + Sync,
247{
248    type SnapFetcher = NoInitialSnapshots;
249    type Stream = AlpacaStream<AlpacaQuoteTransformer<Self, Instrument::Key>>;
250}
251
252/// The same quotes as [`Quotes`], as a top of book. See the `OrderBookL1` conversion in
253/// [`quote`] for how a quote maps onto one.
254impl<Instrument, Server> StreamSelector<Instrument, OrderBooksL1> for Alpaca<Server>
255where
256    Instrument: InstrumentData,
257    Server: ExchangeServer + Debug + Send + Sync,
258{
259    type SnapFetcher = NoInitialSnapshots;
260    type Stream = AlpacaStream<AlpacaQuoteTransformer<Self, Instrument::Key, OrderBooksL1>>;
261}
262
263impl<'de, Server> Deserialize<'de> for Alpaca<Server>
264where
265    Server: ExchangeServer,
266{
267    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
268    where
269        D: serde::de::Deserializer<'de>,
270    {
271        let input = <String as Deserialize>::deserialize(deserializer)?;
272        if input.as_str() == Self::ID.as_str() {
273            Ok(Self::default())
274        } else {
275            Err(serde::de::Error::invalid_value(
276                serde::de::Unexpected::Str(input.as_str()),
277                &Self::ID.as_str(),
278            ))
279        }
280    }
281}
282
283impl<Server> Serialize for Alpaca<Server>
284where
285    Server: ExchangeServer,
286{
287    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
288    where
289        S: serde::ser::Serializer,
290    {
291        serializer.serialize_str(Self::ID.as_str())
292    }
293}
294
295/// Credentials for authenticating to Alpaca market data WebSocket.
296///
297/// `Debug` is implemented manually to redact `api_secret`, preventing accidental
298/// exposure of the secret in tracing or panic output.
299#[derive(Clone)]
300pub struct AlpacaCredentials {
301    api_key: String,
302    api_secret: String,
303}
304
305impl fmt::Debug for AlpacaCredentials {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        f.debug_struct("AlpacaCredentials")
308            .field("api_key", &self.api_key)
309            .field("api_secret", &"[REDACTED]")
310            .finish()
311    }
312}
313
314impl AlpacaCredentials {
315    /// Create credentials from explicit values.
316    pub fn new(api_key: impl Into<String>, api_secret: impl Into<String>) -> Self {
317        Self {
318            api_key: api_key.into(),
319            api_secret: api_secret.into(),
320        }
321    }
322
323    /// Load credentials from environment variables.
324    ///
325    /// Reads `ALPACA_API_KEY` and `ALPACA_SECRET_KEY` from environment.
326    ///
327    /// # Errors
328    ///
329    /// Returns error if either environment variable is not set.
330    pub fn from_env() -> Result<Self, SocketError> {
331        let api_key = env::var("ALPACA_API_KEY")
332            .map_err(|e| SocketError::Subscribe(format!("ALPACA_API_KEY: {e}")))?;
333        let api_secret = env::var("ALPACA_SECRET_KEY")
334            .map_err(|e| SocketError::Subscribe(format!("ALPACA_SECRET_KEY: {e}")))?;
335        Ok(Self {
336            api_key,
337            api_secret,
338        })
339    }
340}
341
342/// Alpaca market data subscriber: authenticates, and shares one connection per feed among every
343/// stream it and its clones open.
344///
345/// # One connection per feed, shared by clones
346/// Alpaca allows an account one market data connection per feed — crypto, IEX and SIP count
347/// separately — and refuses a second on the same feed with `connection limit exceeded`. So every
348/// stream this subscriber opens on a feed attaches to one socket to it: trades and quotes, any
349/// number of symbols, in as many `subscribe` calls as the caller likes. **Pass clones of one
350/// subscriber** to every stream on the account. A subscriber built separately opens a connection
351/// of its own, and Alpaca refuses it once another is open on that feed. Streams on different feeds
352/// run side by side, one socket each.
353///
354/// Each `(channel, symbol)` pair is subscribed once however many streams hold it, and unsubscribed
355/// when the last stream holding it is dropped; the socket closes once none remains. Alpaca caps the
356/// pairs one connection holds — 30 on the free IEX plan, as last measured — and a subscribe that
357/// would pass the cap fails naming it. The cap counts what every stream on the feed holds.
358///
359/// If the connection is lost, every stream on it ends together and reconnects through the usual
360/// reconnect wrapper, sharing one new connection. Alpaca replays nothing, so what it published
361/// while no socket was open is not recovered.
362///
363/// See [`connection`] for how frames reach each stream, and [`AlpacaAttachment`] for why a stream
364/// must be kept drained.
365///
366/// # Example
367///
368/// ```ignore
369/// use rustrade_data::exchange::alpaca::{AlpacaCredentials, AlpacaSubscriber};
370///
371/// // Load credentials at construction time (fails fast if env vars missing)
372/// let subscriber = AlpacaSubscriber::from_env()?;
373///
374/// // Or with explicit credentials
375/// let subscriber = AlpacaSubscriber::new(AlpacaCredentials::new("key", "secret"));
376/// ```
377#[derive(Clone, Debug)]
378pub struct AlpacaSubscriber {
379    connections: Arc<AlpacaConnections>,
380}
381
382impl AlpacaSubscriber {
383    /// Create a new subscriber with the provided credentials.
384    ///
385    /// The subscriber opens no connection until a stream subscribes.
386    pub fn new(credentials: AlpacaCredentials) -> Self {
387        Self {
388            connections: Arc::new(AlpacaConnections::new(credentials)),
389        }
390    }
391
392    /// Create a new subscriber using credentials from environment variables.
393    ///
394    /// Equivalent to `AlpacaSubscriber::new(AlpacaCredentials::from_env()?)`.
395    /// See [`AlpacaCredentials::from_env`](crate::exchange::alpaca::AlpacaCredentials::from_env) for the variables read and error conditions.
396    pub fn from_env() -> Result<Self, SocketError> {
397        Ok(Self::new(AlpacaCredentials::from_env()?))
398    }
399
400    /// How many handles share this subscriber's connections: this subscriber and each clone of it.
401    #[cfg(test)]
402    pub(crate) fn connection_handles(&self) -> usize {
403        Arc::strong_count(&self.connections)
404    }
405}
406
407impl Subscriber for AlpacaSubscriber {
408    type SubMapper = crate::subscriber::mapper::WebSocketSubMapper;
409    type Transport = AlpacaAttachment;
410
411    /// Attach the batch to its feed's shared connection, subscribing whatever the connection does
412    /// not already hold.
413    ///
414    /// Returns once Alpaca reports holding every requested `(channel, symbol)` pair. A pair
415    /// another stream already holds is not sent again, and needs no answer.
416    ///
417    /// # Errors
418    /// Returns [`SocketError::Subscribe`] if the batch is empty, authentication is refused — by
419    /// the credentials, or because another connection holds the feed — Alpaca refuses the
420    /// subscribe (the connection's pair cap among the reasons), or the subscription timeout passes
421    /// before every pair is confirmed.
422    async fn subscribe<Exchange, Instrument, Kind>(
423        &self,
424        subscriptions: &[Subscription<Exchange, Instrument, Kind>],
425    ) -> Result<Subscribed<Instrument::Key, Self::Transport>, SocketError>
426    where
427        Exchange: Connector + Send + Sync,
428        Kind: SubscriptionKind + Send + Sync,
429        Instrument: InstrumentData,
430        Subscription<Exchange, Instrument, Kind>:
431            Identifier<Exchange::Channel> + Identifier<Exchange::Market>,
432    {
433        let exchange = Exchange::ID;
434        let url = Exchange::url()?;
435        debug!(%exchange, %url, ?subscriptions, "subscribing to Alpaca WebSocket");
436
437        let slots = requested_slots(exchange, subscriptions)?;
438        // Every subscription in a batch shares one `Kind`, and a batch has a pair per subscription.
439        let Some(kind) = subscriptions
440            .first()
441            .map(|subscription| subscription.kind.as_str())
442        else {
443            return Err(SocketError::Subscribe(format!(
444                "no subscriptions were given to subscribe to on {exchange}"
445            )));
446        };
447
448        // Only the instrument map is taken from the mapper. The subscribe payload is built by the
449        // connection instead, because it alone knows what the socket already holds.
450        let SubscriptionMeta {
451            instrument_map,
452            ws_subscriptions: _,
453        } = Self::SubMapper::map::<Exchange, Instrument, Kind>(subscriptions);
454
455        let transport = self
456            .connections
457            .attach(AttachRequest {
458                exchange,
459                url,
460                kind,
461                slots,
462                timeout: Exchange::subscription_timeout(),
463            })
464            .await?;
465
466        debug!(%exchange, "attached to the Alpaca connection");
467        Ok(Subscribed {
468            transport,
469            map: instrument_map,
470            // The connection routes every frame for the batch into `transport` from the moment it
471            // is registered, so nothing is read ahead of it.
472            buffered_websocket_events: Vec::new(),
473        })
474    }
475}
476
477/// The distinct `(channel, symbol)` pairs a batch requests, in request order.
478fn requested_slots<Exchange, Instrument, Kind>(
479    exchange: ExchangeId,
480    subscriptions: &[Subscription<Exchange, Instrument, Kind>],
481) -> Result<Vec<Slot>, SocketError>
482where
483    Exchange: Connector,
484    Subscription<Exchange, Instrument, Kind>:
485        Identifier<Exchange::Channel> + Identifier<Exchange::Market>,
486{
487    let mut seen = FnvHashSet::default();
488    let mut slots = Vec::with_capacity(subscriptions.len());
489
490    for subscription in subscriptions {
491        let sub = ExchangeSub::<Exchange::Channel, Exchange::Market>::new(subscription);
492        let Some(channel) = AlpacaChannel::from_name(sub.channel.as_ref()) else {
493            return Err(SocketError::Subscribe(format!(
494                "{exchange} has no Alpaca channel named {}",
495                sub.channel.as_ref()
496            )));
497        };
498
499        let slot = Slot {
500            channel,
501            symbol: SmolStr::new(sub.market.as_ref()),
502        };
503        if seen.insert(slot.clone()) {
504            slots.push(slot);
505        }
506    }
507
508    Ok(slots)
509}
510
511/// Build a `subscribe` or `unsubscribe` payload for `pairs`, grouped by channel.
512fn channel_message<'a>(
513    action: &str,
514    pairs: impl IntoIterator<Item = (AlpacaChannel, &'a str)>,
515) -> WsMessage {
516    let mut trades: Vec<&str> = Vec::new();
517    let mut quotes: Vec<&str> = Vec::new();
518
519    for (channel, symbol) in pairs {
520        match channel {
521            AlpacaChannel::Trades => trades.push(symbol),
522            AlpacaChannel::Quotes => quotes.push(symbol),
523        }
524    }
525
526    let mut payload = json!({"action": action});
527    if !trades.is_empty() {
528        payload["trades"] = json!(trades);
529    }
530    if !quotes.is_empty() {
531        payload["quotes"] = json!(quotes);
532    }
533
534    WsMessage::text(payload.to_string())
535}
536
537/// Authenticate to Alpaca WebSocket using the provided credentials.
538async fn alpaca_authenticate(
539    ws: &mut WebSocket,
540    credentials: &AlpacaCredentials,
541) -> Result<(), SocketError> {
542    let auth_msg = json!({
543        "action": "auth",
544        "key": credentials.api_key,
545        "secret": credentials.api_secret,
546    })
547    .to_string();
548
549    ws.send(WsMessage::text(auth_msg))
550        .await
551        .map_err(|e| SocketError::WebSocket(Box::new(e)))?;
552
553    tokio::time::timeout(Duration::from_secs(10), async {
554        loop {
555            match ws.next().await {
556                Some(Ok(WsMessage::Text(text))) => {
557                    if let Some(result) = check_alpaca_auth_response(text.as_str()) {
558                        return result;
559                    }
560                }
561                Some(Ok(WsMessage::Binary(bytes))) => {
562                    if let Ok(text) = std::str::from_utf8(&bytes)
563                        && let Some(result) = check_alpaca_auth_response(text)
564                    {
565                        return result;
566                    }
567                }
568                Some(Err(e)) => {
569                    return Err(SocketError::WebSocket(Box::new(e)));
570                }
571                None => {
572                    return Err(SocketError::Subscribe(
573                        "WebSocket closed before auth response".to_owned(),
574                    ));
575                }
576                Some(Ok(WsMessage::Close(frame))) => {
577                    return Err(SocketError::Subscribe(format!(
578                        "WebSocket closed during auth: {frame:?}"
579                    )));
580                }
581                _ => {}
582            }
583        }
584    })
585    .await
586    .map_err(|_| SocketError::Subscribe("Alpaca auth timeout (10s)".to_owned()))?
587}
588
589fn check_alpaca_auth_response(text: &str) -> Option<Result<(), SocketError>> {
590    #[derive(Deserialize)]
591    struct AuthMsg<'a> {
592        #[serde(rename = "T")]
593        msg_type: &'a str,
594        #[serde(default)]
595        msg: Option<&'a str>,
596    }
597
598    // Alpaca sends messages as JSON arrays: [{"T":"success",...}]
599    // On connect, Alpaca sends [{"T":"success","msg":"connected"}]
600    // After auth, Alpaca sends [{"T":"success","msg":"authenticated"}]
601    // We must wait for "authenticated", not just any "success"
602    let messages: Vec<AuthMsg<'_>> = serde_json::from_str(text).ok()?;
603
604    for msg in &messages {
605        match (msg.msg_type, msg.msg) {
606            ("success", Some("authenticated")) => return Some(Ok(())),
607            ("error", _) => {
608                return Some(Err(SocketError::Subscribe(format!(
609                    "Alpaca auth failed: {}",
610                    msg.msg.unwrap_or("unknown error")
611                ))));
612            }
613            _ => {}
614        }
615    }
616    None
617}