1pub 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#[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#[derive(Debug)]
110pub(super) struct Alpaca;
111
112#[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 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#[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) slots: Vec<Slot>,
157 pub(super) timeout: Duration,
158}
159
160#[derive(Debug)]
162pub(super) struct Awaiting {
163 asked: Asked,
164 slots: Vec<Slot>,
165 last: Option<FnvHashSet<Slot>>,
167}
168
169#[derive(Debug)]
170enum Asked {
171 Subscribed,
173 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 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 fn observe(&mut self, state: FnvHashSet<Slot>) {
210 debug!(held = state.len(), "Alpaca subscription state");
211 self.last = Some(state);
212 }
213
214 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 const CONNECTION_PER_ENDPOINT: bool = true;
244 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 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 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#[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
348impl SharedTransport for AlpacaAttachment {
350 type Attached = ();
351
352 fn take_attached(&mut self) {
353 self.0.take_attached()
354 }
355}
356
357fn 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#[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 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 "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
453fn 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)] mod 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 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 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 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}