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}