use super::{
channel::AlpacaChannel,
subscription::{AlpacaSubResponse, AlpacaSubResponseInner},
};
use crate::{
Identifier,
exchange::{Connector, ExchangeSub},
subscriber::validator::SubscriptionValidator,
subscription::{Map, SubscriptionKind},
};
use futures::StreamExt;
use rustrade_integration::{
Validator,
error::SocketError,
protocol::{
StreamParser,
websocket::{WebSocket, WebSocketSerdeParser, WsMessage},
},
subscription::SubscriptionId,
};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use tracing::debug;
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)]
pub struct AlpacaWebSocketSubValidator;
impl SubscriptionValidator for AlpacaWebSocketSubValidator {
type Parser = WebSocketSerdeParser;
async fn validate<Exchange, Instrument, Kind>(
instrument_map: Map<Instrument>,
websocket: &mut WebSocket,
) -> Result<(Map<Instrument>, Vec<WsMessage>), SocketError>
where
Exchange: Connector + Send,
Instrument: Send,
Kind: SubscriptionKind + Send,
{
let timeout = Exchange::subscription_timeout();
let mut awaiting: HashSet<SubscriptionId> = instrument_map.0.keys().cloned().collect();
let mut buff_active_subscription_events = Vec::new();
loop {
if awaiting.is_empty() {
debug!(exchange = %Exchange::ID, "validated exchange WebSocket subscriptions");
break Ok((instrument_map, buff_active_subscription_events));
}
tokio::select! {
_ = tokio::time::sleep(timeout) => {
break Err(SocketError::Subscribe(format!(
"subscription validation timeout reached: {timeout:?}; \
Alpaca never confirmed: {}",
display_awaiting(&awaiting)
)))
},
message = websocket.next() => {
let response = match message {
Some(response) => response,
None => break Err(SocketError::Subscribe(
"WebSocket stream terminated unexpectedly".to_string()
))
};
match <WebSocketSerdeParser as StreamParser<AlpacaSubResponse>>::parse(response) {
Some(Ok(response)) => match response.validate() {
Ok(response) => {
for inner in &response.0 {
confirm(&mut awaiting, inner);
}
debug!(
exchange = %Exchange::ID,
outstanding = %display_awaiting(&awaiting),
payload = ?response,
"received Alpaca subscription response",
);
}
Err(err) => break Err(err),
}
Some(Err(SocketError::Deserialise { error: _, payload })) => {
buff_active_subscription_events.push(WsMessage::text(payload));
continue
}
Some(Err(SocketError::Terminated(close_frame))) => {
break Err(SocketError::Subscribe(
format!("received WebSocket CloseFrame: {close_frame}")
))
}
_ => {
continue
}
}
}
}
}
}
}
fn confirm(awaiting: &mut HashSet<SubscriptionId>, inner: &AlpacaSubResponseInner) {
let AlpacaSubResponseInner::Subscription {
trades,
quotes,
bars: _,
} = inner
else {
return;
};
for (channel, markets) in [
(AlpacaChannel::Trades, trades),
(AlpacaChannel::Quotes, quotes),
] {
for market in markets {
awaiting.remove(&ExchangeSub::from((channel, market.as_str())).id());
}
}
}
fn display_awaiting(awaiting: &HashSet<SubscriptionId>) -> String {
let mut ids = awaiting.iter().map(|id| id.0.as_str()).collect::<Vec<_>>();
ids.sort_unstable();
ids.join(", ")
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use smol_str::SmolStr;
fn id(channel: AlpacaChannel, market: &str) -> SubscriptionId {
ExchangeSub::from((channel, market)).id()
}
fn awaiting(ids: impl IntoIterator<Item = SubscriptionId>) -> HashSet<SubscriptionId> {
ids.into_iter().collect()
}
fn subscription(trades: &[&str], quotes: &[&str]) -> AlpacaSubResponseInner {
AlpacaSubResponseInner::Subscription {
trades: trades.iter().map(|s| SmolStr::new(*s)).collect(),
quotes: quotes.iter().map(|s| SmolStr::new(*s)).collect(),
bars: vec![],
}
}
#[test]
fn a_confirmation_naming_every_requested_symbol_clears_them_all() {
let mut outstanding = awaiting([
id(AlpacaChannel::Quotes, "BTC/USD"),
id(AlpacaChannel::Quotes, "ETH/USD"),
]);
confirm(
&mut outstanding,
&subscription(&[], &["BTC/USD", "ETH/USD"]),
);
assert!(outstanding.is_empty());
}
#[test]
fn a_confirmation_that_omits_a_requested_symbol_leaves_it_outstanding() {
let eth = id(AlpacaChannel::Quotes, "ETH/USD");
let mut outstanding = awaiting([id(AlpacaChannel::Quotes, "BTC/USD"), eth.clone()]);
confirm(&mut outstanding, &subscription(&[], &["BTC/USD"]));
assert_eq!(outstanding, awaiting([eth]));
assert_eq!(display_awaiting(&outstanding), "quotes|ETH/USD");
}
#[test]
fn a_success_message_confirms_nothing() {
let requested = awaiting([id(AlpacaChannel::Quotes, "BTC/USD")]);
let mut outstanding = requested.clone();
confirm(
&mut outstanding,
&AlpacaSubResponseInner::Success {
msg: SmolStr::new("authenticated"),
},
);
assert_eq!(outstanding, requested);
}
#[test]
fn symbols_confirmed_but_never_requested_are_ignored() {
let btc = id(AlpacaChannel::Quotes, "BTC/USD");
let mut outstanding = awaiting([btc.clone()]);
confirm(&mut outstanding, &subscription(&[], &["SOL/USD"]));
assert_eq!(outstanding, awaiting([btc]));
}
#[test]
fn a_confirmation_on_one_channel_does_not_satisfy_the_other() {
let quotes = id(AlpacaChannel::Quotes, "BTC/USD");
let mut outstanding = awaiting([quotes.clone()]);
confirm(&mut outstanding, &subscription(&["BTC/USD"], &[]));
assert_eq!(outstanding, awaiting([quotes]));
}
#[test]
fn outstanding_subscriptions_are_reported_in_a_stable_order() {
let outstanding = awaiting([
id(AlpacaChannel::Quotes, "SOL/USD"),
id(AlpacaChannel::Quotes, "BTC/USD"),
id(AlpacaChannel::Trades, "ETH/USD"),
]);
assert_eq!(
display_awaiting(&outstanding),
"quotes|BTC/USD, quotes|SOL/USD, trades|ETH/USD"
);
}
}