use self::{
channel::MassiveChannel,
connection::{AttachRequest, MassiveAttachment, MassiveConnections, Slot},
market::{MassiveMarket, MassiveServer, MassiveSymbols, MassiveTradeServer},
message::MassiveTransformer,
stream::MassiveStream,
};
use crate::{
Identifier, NoInitialSnapshots,
exchange::{Connector, ExchangeServer, ExchangeSub, StreamSelector},
instrument::InstrumentData,
subscriber::{
Subscribed, Subscriber, mapper::SubscriptionMapper, validator::WebSocketSubValidator,
},
subscription::{
Subscription, SubscriptionKind, SubscriptionMeta,
book::OrderBooksL1,
candle::{CandleInterval, Candles},
quote::Quotes,
trade::PublicTrades,
},
};
use fnv::FnvHashSet;
use rustrade_instrument::exchange::ExchangeId;
use rustrade_integration::{Validator, error::SocketError, protocol::websocket::WsMessage};
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
use std::{env, fmt, fmt::Debug, marker::PhantomData, sync::Arc};
use tracing::debug;
use url::Url;
pub mod channel;
pub mod connection;
pub mod market;
pub mod message;
pub mod stream;
mod corporate_action;
mod error;
pub(crate) mod options;
mod pagination;
pub(crate) mod reference;
pub(crate) mod rest;
pub(crate) mod transformer;
pub use error::MassiveError;
pub use options::{
MassiveOptionContract, MassiveOptionSnapshot, OptionContractQuery, OptionDayBar, OptionQuote,
OptionSnapshotQuery, OptionTrade, UnderlyingAsset,
};
pub use reference::{
Address, CurrencyStatus, Dividend, DividendFrequency, DividendQuery, Exchange, MarketHoliday,
MarketStatus, SortOrder, SplitQuery, StockSplit, Ticker, TickerDetails, TickerQuery,
};
pub use rest::MassiveRestClient;
pub use transformer::FairMarketValue;
pub const WEBSOCKET_URL_STOCKS: &str = "wss://socket.massive.com/stocks";
pub const WEBSOCKET_URL_CRYPTO: &str = "wss://socket.massive.com/crypto";
pub const WEBSOCKET_URL_FOREX: &str = "wss://socket.massive.com/forex";
pub const WEBSOCKET_URL_OPTIONS: &str = "wss://socket.massive.com/options";
pub type MassiveStocks = Massive<MassiveServerStocks>;
pub type MassiveCrypto = Massive<MassiveServerCrypto>;
pub type MassiveForex = Massive<MassiveServerForex>;
pub type MassiveOptions = Massive<MassiveServerOptions>;
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
pub struct Massive<Server>(PhantomData<Server>);
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
pub struct MassiveServerStocks;
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
pub struct MassiveServerCrypto;
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
pub struct MassiveServerForex;
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
pub struct MassiveServerOptions;
impl ExchangeServer for MassiveServerStocks {
const ID: ExchangeId = ExchangeId::MassiveStocks;
fn websocket_url() -> &'static str {
WEBSOCKET_URL_STOCKS
}
}
impl ExchangeServer for MassiveServerCrypto {
const ID: ExchangeId = ExchangeId::MassiveCrypto;
fn websocket_url() -> &'static str {
WEBSOCKET_URL_CRYPTO
}
}
impl ExchangeServer for MassiveServerForex {
const ID: ExchangeId = ExchangeId::MassiveForex;
fn websocket_url() -> &'static str {
WEBSOCKET_URL_FOREX
}
}
impl ExchangeServer for MassiveServerOptions {
const ID: ExchangeId = ExchangeId::MassiveOptions;
fn websocket_url() -> &'static str {
WEBSOCKET_URL_OPTIONS
}
}
impl MassiveServer for MassiveServerStocks {
const SYMBOLS: MassiveSymbols = MassiveSymbols::Ticker;
const QUOTES: &'static str = "Q";
const SECOND_AGGREGATES: &'static str = "A";
const MINUTE_AGGREGATES: &'static str = "AM";
}
impl MassiveTradeServer for MassiveServerStocks {
const TRADES: &'static str = "T";
}
impl MassiveServer for MassiveServerCrypto {
const SYMBOLS: MassiveSymbols = MassiveSymbols::CryptoPair;
const QUOTES: &'static str = "XQ";
const SECOND_AGGREGATES: &'static str = "XAS";
const MINUTE_AGGREGATES: &'static str = "XA";
}
impl MassiveTradeServer for MassiveServerCrypto {
const TRADES: &'static str = "XT";
}
impl MassiveServer for MassiveServerForex {
const SYMBOLS: MassiveSymbols = MassiveSymbols::CurrencyPair;
const QUOTES: &'static str = "C";
const SECOND_AGGREGATES: &'static str = "CAS";
const MINUTE_AGGREGATES: &'static str = "CA";
}
impl MassiveServer for MassiveServerOptions {
const SYMBOLS: MassiveSymbols = MassiveSymbols::OptionContract;
const QUOTES: &'static str = "Q";
const SECOND_AGGREGATES: &'static str = "A";
const MINUTE_AGGREGATES: &'static str = "AM";
}
impl MassiveTradeServer for MassiveServerOptions {
const TRADES: &'static str = "T";
}
impl<Server> Connector for Massive<Server>
where
Server: MassiveServer,
{
const ID: ExchangeId = Server::ID;
type Channel = MassiveChannel;
type Market = MassiveMarket;
type Subscriber = MassiveSubscriber;
type SubValidator = WebSocketSubValidator;
type SubResponse = MassiveSubResponse;
fn url() -> Result<Url, url::ParseError> {
Url::parse(Server::websocket_url())
}
fn requests(exchange_subs: Vec<ExchangeSub<Self::Channel, Self::Market>>) -> Vec<WsMessage> {
let slots = exchange_subs.iter().map(Slot::from).collect::<Vec<_>>();
vec![connection::action("subscribe", &slots)]
}
}
impl<Instrument, Server> StreamSelector<Instrument, PublicTrades> for Massive<Server>
where
Instrument: InstrumentData,
Server: MassiveTradeServer + Debug + Send + Sync,
{
type SnapFetcher = NoInitialSnapshots;
type Stream = MassiveStream<MassiveTransformer<Self, Instrument::Key, PublicTrades>>;
}
impl<Instrument, Server> StreamSelector<Instrument, Quotes> for Massive<Server>
where
Instrument: InstrumentData,
Server: MassiveServer + Debug + Send + Sync,
{
type SnapFetcher = NoInitialSnapshots;
type Stream = MassiveStream<MassiveTransformer<Self, Instrument::Key, Quotes>>;
}
impl<Instrument, Server> StreamSelector<Instrument, OrderBooksL1> for Massive<Server>
where
Instrument: InstrumentData,
Server: MassiveServer + Debug + Send + Sync,
{
type SnapFetcher = NoInitialSnapshots;
type Stream = MassiveStream<MassiveTransformer<Self, Instrument::Key, OrderBooksL1>>;
}
impl<Instrument, Server> StreamSelector<Instrument, Candles> for Massive<Server>
where
Instrument: InstrumentData,
Server: MassiveServer + Debug + Send + Sync,
{
type SnapFetcher = NoInitialSnapshots;
type Stream = MassiveStream<MassiveTransformer<Self, Instrument::Key, Candles>>;
}
impl<'de, Server> Deserialize<'de> for Massive<Server>
where
Server: MassiveServer,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
let input = <String as Deserialize>::deserialize(deserializer)?;
if input.as_str() == Self::ID.as_str() {
Ok(Self::default())
} else {
Err(serde::de::Error::invalid_value(
serde::de::Unexpected::Str(input.as_str()),
&Self::ID.as_str(),
))
}
}
}
impl<Server> Serialize for Massive<Server>
where
Server: MassiveServer,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(Self::ID.as_str())
}
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct MassiveSubResponse(pub Vec<MassiveStatus>);
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct MassiveStatus {
pub status: SmolStr,
#[serde(default)]
pub message: String,
}
impl Validator for MassiveSubResponse {
type Error = SocketError;
fn validate(self) -> Result<Self, SocketError> {
match self.0.iter().find(|status| {
!matches!(
status.status.as_str(),
"success" | "connected" | "auth_success"
)
}) {
Some(MassiveStatus { status, message }) => Err(SocketError::Subscribe(format!(
"Massive refused the subscription: {status}: {message}"
))),
None => Ok(self),
}
}
}
#[derive(Clone)]
pub struct MassiveCredentials {
api_key: String,
}
impl fmt::Debug for MassiveCredentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MassiveCredentials")
.field("api_key", &"[REDACTED]")
.finish()
}
}
impl MassiveCredentials {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
}
}
pub fn from_env() -> Result<Self, SocketError> {
env::var(rest::ENV_API_KEY)
.map(Self::new)
.map_err(|error| SocketError::Subscribe(format!("{}: {error}", rest::ENV_API_KEY)))
}
pub(super) fn api_key(&self) -> &str {
&self.api_key
}
}
#[derive(Clone, Debug)]
pub struct MassiveSubscriber {
connections: Arc<MassiveConnections>,
}
impl MassiveSubscriber {
pub fn new(credentials: MassiveCredentials) -> Self {
Self {
connections: Arc::new(MassiveConnections::new(credentials)),
}
}
pub fn from_env() -> Result<Self, SocketError> {
Ok(Self::new(MassiveCredentials::from_env()?))
}
#[cfg(test)]
pub(crate) fn connection_handles(&self) -> usize {
Arc::strong_count(&self.connections)
}
}
impl Subscriber for MassiveSubscriber {
type SubMapper = crate::subscriber::mapper::WebSocketSubMapper;
type Transport = MassiveAttachment;
async fn subscribe<Exchange, Instrument, Kind>(
&self,
subscriptions: &[Subscription<Exchange, Instrument, Kind>],
) -> Result<Subscribed<Instrument::Key, Self::Transport>, SocketError>
where
Exchange: Connector + Send + Sync,
Kind: SubscriptionKind + Send + Sync,
Instrument: InstrumentData,
Subscription<Exchange, Instrument, Kind>:
Identifier<Exchange::Channel> + Identifier<Exchange::Market>,
{
let exchange = Exchange::ID;
let url = Exchange::url()?;
debug!(%exchange, %url, ?subscriptions, "subscribing to Massive WebSocket");
let Some(kind) = subscriptions
.first()
.map(|subscription| subscription.kind.as_str())
else {
return Err(SocketError::Subscribe(format!(
"no subscriptions were given to subscribe to on {exchange}"
)));
};
let slots = requested_slots(exchange, subscriptions)?;
let SubscriptionMeta {
instrument_map,
ws_subscriptions: _,
} = Self::SubMapper::map::<Exchange, Instrument, Kind>(subscriptions);
let transport = self
.connections
.attach(AttachRequest {
exchange,
url,
kind,
slots,
timeout: Exchange::subscription_timeout(),
})
.await?;
debug!(%exchange, "attached to the Massive connection");
Ok(Subscribed {
transport,
map: instrument_map,
buffered_websocket_events: Vec::new(),
})
}
}
fn requested_slots<Exchange, Instrument, Kind>(
exchange: ExchangeId,
subscriptions: &[Subscription<Exchange, Instrument, Kind>],
) -> Result<Vec<Slot>, SocketError>
where
Exchange: Connector,
Kind: SubscriptionKind,
Subscription<Exchange, Instrument, Kind>:
Identifier<Exchange::Channel> + Identifier<Exchange::Market>,
{
let mut seen = FnvHashSet::default();
let mut slots = Vec::with_capacity(subscriptions.len());
for subscription in subscriptions {
let sub = ExchangeSub::<Exchange::Channel, Exchange::Market>::new(subscription);
if sub.channel.as_ref().is_empty() {
return Err(SocketError::Subscribe(format!(
"{exchange} has no channel for {:?}: Massive aggregates per second \
(CandleInterval::{:?}) and per minute (CandleInterval::{:?}) only",
subscription.kind,
CandleInterval::Sec1,
CandleInterval::Min1,
)));
}
let spelled = sub.market.as_ref();
if exchange == ExchangeId::MassiveOptions && !market::is_option_contract(spelled) {
return Err(SocketError::Subscribe(format!(
"{exchange} streams option contracts only, and {spelled} is not one"
)));
}
if spelled.is_empty() || spelled.contains(|c: char| c == ',' || c.is_whitespace()) {
return Err(SocketError::Subscribe(format!(
"{exchange} cannot subscribe to the market {spelled:?}"
)));
}
let slot = Slot::from(&sub);
if seen.insert(slot.clone()) {
slots.push(slot);
}
}
Ok(slots)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use rustrade_instrument::instrument::market_data::{
MarketDataInstrument, kind::MarketDataInstrumentKind,
};
fn subscription<Server, Kind>(
base: &str,
quote: &str,
kind: Kind,
) -> Subscription<Massive<Server>, MarketDataInstrument, Kind>
where
Server: MassiveServer,
{
Subscription::new(
Massive::default(),
MarketDataInstrument::new(base, quote, MarketDataInstrumentKind::Spot),
kind,
)
}
#[test]
fn each_kind_reads_its_cluster_channel() {
let slots =
|subscriptions: &[Subscription<MassiveCrypto, MarketDataInstrument, Candles>]| {
requested_slots(ExchangeId::MassiveCrypto, subscriptions)
.unwrap()
.into_iter()
.map(|slot| slot.to_string())
.collect::<Vec<_>>()
};
assert_eq!(
slots(&[
subscription(
"btc",
"usd",
Candles {
interval: CandleInterval::Sec1
}
),
subscription(
"btc",
"usd",
Candles {
interval: CandleInterval::Min1
}
),
subscription(
"btc",
"usd",
Candles {
interval: CandleInterval::Sec1
}
),
]),
["XAS.BTC-USD", "XA.BTC-USD"],
);
let forex = requested_slots(
ExchangeId::MassiveForex,
&[subscription::<MassiveServerForex, _>("eur", "usd", Quotes)],
)
.unwrap();
assert_eq!(forex[0].to_string(), "C.EUR/USD");
}
#[test]
fn an_interval_massive_does_not_aggregate_at_is_refused_naming_it() {
let error = requested_slots(
ExchangeId::MassiveCrypto,
&[subscription::<MassiveServerCrypto, _>(
"btc",
"usd",
Candles {
interval: CandleInterval::Min5,
},
)],
)
.unwrap_err()
.to_string();
assert!(error.contains("Min5"), "{error}");
assert!(error.contains("Sec1") && error.contains("Min1"), "{error}");
}
#[test]
fn the_options_cluster_refuses_an_instrument_that_is_not_a_contract() {
let error = requested_slots(
ExchangeId::MassiveOptions,
&[subscription::<MassiveServerOptions, _>(
"aapl", "usd", Quotes,
)],
)
.unwrap_err()
.to_string();
assert!(error.contains("option contracts only"), "{error}");
}
#[test]
fn a_market_no_cluster_spells_is_refused_whatever_the_cluster_id() {
let unspellable = requested_slots(
ExchangeId::MassiveStocks,
&[subscription::<MassiveServerOptions, _>(
"aapl", "usd", Quotes,
)],
)
.unwrap_err()
.to_string();
assert!(unspellable.contains("no OSI symbol"), "{unspellable}");
let smuggled = requested_slots(
ExchangeId::MassiveStocks,
&[subscription::<MassiveServerStocks, _>(
"aapl,t.msft",
"usd",
Quotes,
)],
)
.unwrap_err()
.to_string();
assert!(smuggled.contains("AAPL,T.MSFT"), "{smuggled}");
}
#[test]
fn a_status_frame_validates_only_when_every_status_succeeded() {
let response = |json: &str| serde_json::from_str::<MassiveSubResponse>(json).unwrap();
assert!(
response(
r#"[{"ev":"status","status":"success","message":"subscribed to: XT.BTC-USD"}]"#
)
.validate()
.is_ok()
);
let refused = response(
r#"[{"ev":"status","status":"success","message":"subscribed to: XT.BTC-USD"},
{"ev":"status","status":"error","message":"not authorized"}]"#,
)
.validate()
.unwrap_err()
.to_string();
assert!(refused.contains("not authorized"), "{refused}");
}
#[test]
fn credentials_debug_redacts_the_key() {
let rendered = format!("{:?}", MassiveCredentials::new("secret-key"));
assert!(!rendered.contains("secret-key"), "{rendered}");
}
#[test]
fn a_connector_round_trips_through_its_exchange_id() {
let json = serde_json::to_string(&MassiveForex::default()).unwrap();
assert_eq!(json, r#""massive_forex""#);
assert_eq!(
serde_json::from_str::<MassiveForex>(&json).unwrap(),
MassiveForex::default()
);
assert!(serde_json::from_str::<MassiveCrypto>(&json).is_err());
}
}