Skip to main content

rustrade_data/exchange/massive/
mod.rs

1//! Massive (formerly Polygon.io) market data connectors.
2//!
3//! Provides access to institutional-grade market data across all major asset classes:
4//! stocks, options, indices, forex, crypto, and futures.
5//!
6//! # Data licensing — redistribution prohibited
7//!
8//! This crate's code is MIT-licensed; **the data it retrieves is not**. Massive's
9//! [Market Data Terms of Service](https://massive.com/legal/market-data-terms-of-service)
10//! (§5(c)) prohibit redistributing the data to third parties. Use it for your own purposes only,
11//! and do not commit it, publish it, or serve it onward.
12//!
13//! # Testing Status
14//!
15//! **Partially integration-tested.** We have a currencies subscription (crypto + forex)
16//! but not stocks, options, indices, or futures subscriptions. Verified endpoints:
17//!
18//! - ✅ REST aggregates (crypto, forex, stocks, options, futures via free tier)
19//! - ✅ REST tick trades (crypto)
20//! - ✅ REST quotes (forex)
21//! - ✅ WebSocket streaming (crypto, forex)
22//! - ✅ Reference data (tickers, exchanges, market status, holidays)
23//! - ✅ Corporate actions (dividends, splits)
24//! - ✅ Options contracts reference data (free tier)
25//! - ❌ Options snapshots with Greeks — requires Options Starter subscription
26//! - ❌ REST aggregates (indices) — requires indices subscription
27//! - ❌ REST tick trades (stocks) — requires stocks subscription
28//! - ❌ REST quotes (stocks) — requires stocks subscription
29//! - ❌ WebSocket (stocks, options) — requires a stocks or options subscription
30//!
31//! Unit tests for JSON transformation live inline in `reference.rs`, `options.rs`,
32//! `transformer.rs` and `message.rs`. Live API integration tests are in
33//! `tests/massive_integration.rs`.
34//!
35//! # Architecture
36//!
37//! - [`MassiveRestClient`](crate::exchange::massive::MassiveRestClient): Historical and intraday
38//!   data via REST API
39//! - [`MassiveSubscriber`](crate::exchange::massive::MassiveSubscriber) with the
40//!   [`MassiveStocks`](crate::exchange::massive::MassiveStocks),
41//!   [`MassiveCrypto`](crate::exchange::massive::MassiveCrypto),
42//!   [`MassiveForex`](crate::exchange::massive::MassiveForex) and
43//!   [`MassiveOptions`](crate::exchange::massive::MassiveOptions) connectors: real-time streaming
44//!   through [`Streams`](crate::streams::Streams), like any other exchange, or through
45//!   [`DynamicStreams`](crate::streams::builder::dynamic::DynamicStreams) with
46//!   [`DynamicSubscribers::with_massive`](crate::streams::builder::dynamic::DynamicSubscribers::with_massive)
47//!
48//! # Authentication
49//!
50//! Requires `MASSIVE_API_KEY` environment variable from an active Massive subscription.
51//! Get your API key from: <https://massive.com/dashboard/api-keys>
52//!
53//! # One connection per cluster
54//!
55//! Massive allows a key a fixed number of WebSocket connections **per cluster** — stocks,
56//! options, forex and crypto count separately — one on an individual plan. Past the limit it
57//! closes the *older* connection. Every stream a
58//! [`MassiveSubscriber`](crate::exchange::massive::MassiveSubscriber) and its clones open on a
59//! cluster therefore shares one socket to it. Pass clones of one subscriber to every stream on
60//! the key; see [`connection`](crate::exchange::massive::connection) for the details.
61//!
62//! # Symbol Conventions
63//!
64//! **Important**: REST API and WebSocket use different symbol formats!
65//!
66//! ## REST API Symbols
67//!
68//! - `X:BTCUSD` — Crypto
69//! - `C:EURUSD` — Forex
70//! - `O:AAPL251219C00150000` — Options
71//! - `I:SPX` — Indices
72//! - `AAPL` — Stocks
73//!
74//! ## WebSocket Symbols
75//!
76//! The connectors spell these from each instrument; see
77//! [`MassiveSymbols`](crate::exchange::massive::market::MassiveSymbols).
78//!
79//! - `BTC-USD` — Crypto (hyphenated)
80//! - `EUR/USD` — Forex (slashed: the cluster accepts `EUR-USD` too, but delivers its data as
81//!   `EUR/USD`)
82//! - `O:AAPL251219C00150000` — Options
83//! - `AAPL` — Stocks
84//!
85//! # Supported Streams
86//!
87//! | Kind | Stocks / Options | Crypto | Forex |
88//! |------|------------------|--------|-------|
89//! | [`PublicTrades`](crate::subscription::trade::PublicTrades) | `T` | `XT` | — (no trades) |
90//! | [`Quotes`](crate::subscription::quote::Quotes), [`OrderBooksL1`](crate::subscription::book::OrderBooksL1) | `Q` | `XQ` | `C` |
91//! | [`Candles`](crate::subscription::candle::Candles) at `Sec1` | `A` | `XAS` | `CAS` |
92//! | [`Candles`](crate::subscription::candle::Candles) at `Min1` | `AM` | `XA` | `CA` |
93//!
94//! Massive aggregates at no other interval, so a `Candles` subscription at any other is refused
95//! before anything is sent. Forex quotes carry no sizes, so their amounts are zero, and forex
96//! candles report no volume: Massive builds them from quote updates, not trades. Only crypto's
97//! trade conditions say which side took liquidity, so a stock or option trade has no side.
98//!
99//! # Examples
100//!
101//! ## REST Client
102//!
103//! ```ignore
104//! use rustrade_data::exchange::massive::MassiveRestClient;
105//!
106//! let client = MassiveRestClient::from_env()?;
107//! let candles = client.fetch_aggregates("X:BTCUSD", 1, "minute", from, to).await?;
108//! ```
109//!
110//! ## WebSocket Streams
111//!
112//! ```ignore
113//! use rustrade_data::exchange::massive::{MassiveCrypto, MassiveSubscriber};
114//! use rustrade_data::streams::Streams;
115//! use rustrade_data::subscription::trade::PublicTrades;
116//! use rustrade_instrument::instrument::market_data::kind::MarketDataInstrumentKind;
117//!
118//! // Load the key at construction time (fails fast if the variable is missing)
119//! let subscriber = MassiveSubscriber::from_env()?;
120//!
121//! let streams = Streams::<PublicTrades>::builder()
122//!     .subscribe(
123//!         subscriber,
124//!         [(MassiveCrypto::default(), "btc", "usd", MarketDataInstrumentKind::Spot, PublicTrades)],
125//!     )
126//!     .init()
127//!     .await?;
128//! ```
129
130use self::{
131    channel::MassiveChannel,
132    connection::{AttachRequest, MassiveAttachment, MassiveConnections, Slot},
133    market::{MassiveMarket, MassiveServer, MassiveSymbols, MassiveTradeServer},
134    message::MassiveTransformer,
135    stream::MassiveStream,
136};
137use crate::{
138    Identifier, NoInitialSnapshots,
139    exchange::{Connector, ExchangeServer, ExchangeSub, StreamSelector},
140    instrument::InstrumentData,
141    subscriber::{
142        Subscribed, Subscriber, mapper::SubscriptionMapper, validator::WebSocketSubValidator,
143    },
144    subscription::{
145        Subscription, SubscriptionKind, SubscriptionMeta,
146        book::OrderBooksL1,
147        candle::{CandleInterval, Candles},
148        quote::Quotes,
149        trade::PublicTrades,
150    },
151};
152use fnv::FnvHashSet;
153use rustrade_instrument::exchange::ExchangeId;
154use rustrade_integration::{Validator, error::SocketError, protocol::websocket::WsMessage};
155use serde::{Deserialize, Serialize};
156use smol_str::SmolStr;
157use std::{env, fmt, fmt::Debug, marker::PhantomData, sync::Arc};
158use tracing::debug;
159use url::Url;
160
161pub mod channel;
162pub mod connection;
163pub mod market;
164pub mod message;
165pub mod stream;
166
167// Side-effect-only module: provides `impl StockSplitSource for MassiveRestClient`. It exports no
168// types, so it is private — `use` it nowhere; the impl is in scope wherever the client is.
169mod corporate_action;
170mod error;
171pub(crate) mod options;
172mod pagination;
173pub(crate) mod reference;
174pub(crate) mod rest;
175pub(crate) mod transformer;
176
177pub use error::MassiveError;
178pub use options::{
179    MassiveOptionContract, MassiveOptionSnapshot, OptionContractQuery, OptionDayBar, OptionQuote,
180    OptionSnapshotQuery, OptionTrade, UnderlyingAsset,
181};
182pub use reference::{
183    Address, CurrencyStatus, Dividend, DividendFrequency, DividendQuery, Exchange, MarketHoliday,
184    MarketStatus, SortOrder, SplitQuery, StockSplit, Ticker, TickerDetails, TickerQuery,
185};
186pub use rest::MassiveRestClient;
187pub use transformer::FairMarketValue;
188
189/// Stocks WebSocket URL.
190pub const WEBSOCKET_URL_STOCKS: &str = "wss://socket.massive.com/stocks";
191
192/// Crypto WebSocket URL.
193pub const WEBSOCKET_URL_CRYPTO: &str = "wss://socket.massive.com/crypto";
194
195/// Forex WebSocket URL.
196pub const WEBSOCKET_URL_FOREX: &str = "wss://socket.massive.com/forex";
197
198/// Options WebSocket URL.
199pub const WEBSOCKET_URL_OPTIONS: &str = "wss://socket.massive.com/options";
200
201/// Massive stocks connector.
202pub type MassiveStocks = Massive<MassiveServerStocks>;
203
204/// Massive crypto connector.
205pub type MassiveCrypto = Massive<MassiveServerCrypto>;
206
207/// Massive forex connector. Forex publishes no trades, so it serves no [`PublicTrades`].
208pub type MassiveForex = Massive<MassiveServerForex>;
209
210/// Massive options connector (untested — requires an options subscription).
211pub type MassiveOptions = Massive<MassiveServerOptions>;
212
213/// Generic Massive WebSocket connector, one per cluster.
214///
215/// Use the aliases [`MassiveStocks`], [`MassiveCrypto`], [`MassiveForex`] or [`MassiveOptions`].
216#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
217pub struct Massive<Server>(PhantomData<Server>);
218
219/// The stocks cluster.
220#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
221pub struct MassiveServerStocks;
222
223/// The crypto cluster.
224#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
225pub struct MassiveServerCrypto;
226
227/// The forex cluster.
228#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
229pub struct MassiveServerForex;
230
231/// The options cluster.
232///
233/// **Warning**: requires an options subscription. Untested.
234#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
235pub struct MassiveServerOptions;
236
237impl ExchangeServer for MassiveServerStocks {
238    const ID: ExchangeId = ExchangeId::MassiveStocks;
239    fn websocket_url() -> &'static str {
240        WEBSOCKET_URL_STOCKS
241    }
242}
243
244impl ExchangeServer for MassiveServerCrypto {
245    const ID: ExchangeId = ExchangeId::MassiveCrypto;
246    fn websocket_url() -> &'static str {
247        WEBSOCKET_URL_CRYPTO
248    }
249}
250
251impl ExchangeServer for MassiveServerForex {
252    const ID: ExchangeId = ExchangeId::MassiveForex;
253    fn websocket_url() -> &'static str {
254        WEBSOCKET_URL_FOREX
255    }
256}
257
258impl ExchangeServer for MassiveServerOptions {
259    const ID: ExchangeId = ExchangeId::MassiveOptions;
260    fn websocket_url() -> &'static str {
261        WEBSOCKET_URL_OPTIONS
262    }
263}
264
265impl MassiveServer for MassiveServerStocks {
266    const SYMBOLS: MassiveSymbols = MassiveSymbols::Ticker;
267    const QUOTES: &'static str = "Q";
268    const SECOND_AGGREGATES: &'static str = "A";
269    const MINUTE_AGGREGATES: &'static str = "AM";
270}
271
272impl MassiveTradeServer for MassiveServerStocks {
273    const TRADES: &'static str = "T";
274}
275
276impl MassiveServer for MassiveServerCrypto {
277    const SYMBOLS: MassiveSymbols = MassiveSymbols::CryptoPair;
278    const QUOTES: &'static str = "XQ";
279    const SECOND_AGGREGATES: &'static str = "XAS";
280    const MINUTE_AGGREGATES: &'static str = "XA";
281}
282
283impl MassiveTradeServer for MassiveServerCrypto {
284    const TRADES: &'static str = "XT";
285}
286
287impl MassiveServer for MassiveServerForex {
288    const SYMBOLS: MassiveSymbols = MassiveSymbols::CurrencyPair;
289    const QUOTES: &'static str = "C";
290    const SECOND_AGGREGATES: &'static str = "CAS";
291    const MINUTE_AGGREGATES: &'static str = "CA";
292}
293
294impl MassiveServer for MassiveServerOptions {
295    const SYMBOLS: MassiveSymbols = MassiveSymbols::OptionContract;
296    const QUOTES: &'static str = "Q";
297    const SECOND_AGGREGATES: &'static str = "A";
298    const MINUTE_AGGREGATES: &'static str = "AM";
299}
300
301impl MassiveTradeServer for MassiveServerOptions {
302    const TRADES: &'static str = "T";
303}
304
305impl<Server> Connector for Massive<Server>
306where
307    Server: MassiveServer,
308{
309    const ID: ExchangeId = Server::ID;
310    type Channel = MassiveChannel;
311    type Market = MassiveMarket;
312    type Subscriber = MassiveSubscriber;
313    // Never called: `MassiveSubscriber` confirms each subscribe on the connection it shares.
314    type SubValidator = WebSocketSubValidator;
315    type SubResponse = MassiveSubResponse;
316
317    fn url() -> Result<Url, url::ParseError> {
318        Url::parse(Server::websocket_url())
319    }
320
321    /// The payload a socket of its own would be subscribed with.
322    ///
323    /// [`MassiveSubscriber`] subscribes through its shared connection instead, which alone knows
324    /// what the socket already holds; both build their payloads with the same function, so they
325    /// cannot disagree about its shape.
326    fn requests(exchange_subs: Vec<ExchangeSub<Self::Channel, Self::Market>>) -> Vec<WsMessage> {
327        let slots = exchange_subs.iter().map(Slot::from).collect::<Vec<_>>();
328        vec![connection::action("subscribe", &slots)]
329    }
330
331    // `expected_responses` is deliberately left at its default. `MassiveSubscriber` confirms a
332    // subscribe by the subscriptions Massive names, not by a response count.
333}
334
335impl<Instrument, Server> StreamSelector<Instrument, PublicTrades> for Massive<Server>
336where
337    Instrument: InstrumentData,
338    Server: MassiveTradeServer + Debug + Send + Sync,
339{
340    type SnapFetcher = NoInitialSnapshots;
341    type Stream = MassiveStream<MassiveTransformer<Self, Instrument::Key, PublicTrades>>;
342}
343
344impl<Instrument, Server> StreamSelector<Instrument, Quotes> for Massive<Server>
345where
346    Instrument: InstrumentData,
347    Server: MassiveServer + Debug + Send + Sync,
348{
349    type SnapFetcher = NoInitialSnapshots;
350    type Stream = MassiveStream<MassiveTransformer<Self, Instrument::Key, Quotes>>;
351}
352
353/// The quotes channel, as a top of book. See [`OrderBooksL1`]'s
354/// [`MassiveKind`](message::MassiveKind) implementation for how a quote maps onto one.
355impl<Instrument, Server> StreamSelector<Instrument, OrderBooksL1> for Massive<Server>
356where
357    Instrument: InstrumentData,
358    Server: MassiveServer + Debug + Send + Sync,
359{
360    type SnapFetcher = NoInitialSnapshots;
361    type Stream = MassiveStream<MassiveTransformer<Self, Instrument::Key, OrderBooksL1>>;
362}
363
364/// Per-second and per-minute aggregates. Any other interval is refused when subscribing.
365impl<Instrument, Server> StreamSelector<Instrument, Candles> for Massive<Server>
366where
367    Instrument: InstrumentData,
368    Server: MassiveServer + Debug + Send + Sync,
369{
370    type SnapFetcher = NoInitialSnapshots;
371    type Stream = MassiveStream<MassiveTransformer<Self, Instrument::Key, Candles>>;
372}
373
374impl<'de, Server> Deserialize<'de> for Massive<Server>
375where
376    Server: MassiveServer,
377{
378    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
379    where
380        D: serde::de::Deserializer<'de>,
381    {
382        let input = <String as Deserialize>::deserialize(deserializer)?;
383        if input.as_str() == Self::ID.as_str() {
384            Ok(Self::default())
385        } else {
386            Err(serde::de::Error::invalid_value(
387                serde::de::Unexpected::Str(input.as_str()),
388                &Self::ID.as_str(),
389            ))
390        }
391    }
392}
393
394impl<Server> Serialize for Massive<Server>
395where
396    Server: MassiveServer,
397{
398    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
399    where
400        S: serde::ser::Serializer,
401    {
402        serializer.serialize_str(Self::ID.as_str())
403    }
404}
405
406/// A Massive frame of status messages, as a subscribe is answered.
407///
408/// [`MassiveSubscriber`] reads Massive's answers on the connection it shares, so this is
409/// consulted only by a caller validating a socket of its own. Its [`Validator`] checks only that
410/// every status in the frame is a success. It does not confirm each subscription by name, so a
411/// subscription Massive left unanswered — as it does one to a channel the cluster does not
412/// publish — passes.
413#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
414pub struct MassiveSubResponse(pub Vec<MassiveStatus>);
415
416/// A Massive status message: `{"ev":"status","status":"success","message":"subscribed to: XT.BTC-USD"}`.
417#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
418pub struct MassiveStatus {
419    /// `success`, `error`, `max_connections`, `auth_failed` and so on.
420    pub status: SmolStr,
421    #[serde(default)]
422    pub message: String,
423}
424
425impl Validator for MassiveSubResponse {
426    type Error = SocketError;
427
428    /// Fails on the first status that is not a success.
429    fn validate(self) -> Result<Self, SocketError> {
430        match self.0.iter().find(|status| {
431            !matches!(
432                status.status.as_str(),
433                "success" | "connected" | "auth_success"
434            )
435        }) {
436            Some(MassiveStatus { status, message }) => Err(SocketError::Subscribe(format!(
437                "Massive refused the subscription: {status}: {message}"
438            ))),
439            None => Ok(self),
440        }
441    }
442}
443
444/// The API key authenticating to Massive's WebSocket.
445///
446/// `Debug` is implemented manually to redact the key, preventing accidental exposure of it in
447/// tracing or panic output.
448#[derive(Clone)]
449pub struct MassiveCredentials {
450    api_key: String,
451}
452
453impl fmt::Debug for MassiveCredentials {
454    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455        f.debug_struct("MassiveCredentials")
456            .field("api_key", &"[REDACTED]")
457            .finish()
458    }
459}
460
461impl MassiveCredentials {
462    /// Create credentials from an explicit key.
463    pub fn new(api_key: impl Into<String>) -> Self {
464        Self {
465            api_key: api_key.into(),
466        }
467    }
468
469    /// Load the key from the `MASSIVE_API_KEY` environment variable.
470    ///
471    /// # Errors
472    ///
473    /// Returns [`SocketError::Subscribe`] if the variable is not set.
474    pub fn from_env() -> Result<Self, SocketError> {
475        env::var(rest::ENV_API_KEY)
476            .map(Self::new)
477            .map_err(|error| SocketError::Subscribe(format!("{}: {error}", rest::ENV_API_KEY)))
478    }
479
480    pub(super) fn api_key(&self) -> &str {
481        &self.api_key
482    }
483}
484
485/// Massive market data subscriber: authenticates, and shares one connection per cluster among
486/// every stream it and its clones open.
487///
488/// # One connection per cluster, shared by clones
489/// Massive allows a key a fixed number of WebSocket connections per cluster — one on an
490/// individual plan — and past it closes the **older** connection. So every stream this subscriber
491/// opens on a cluster attaches to one socket to it: trades, quotes and candles, any number of
492/// markets, in as many `subscribe` calls as the caller likes. **Pass clones of one subscriber** to
493/// every stream on the key. A subscriber built separately, or another process using the key,
494/// opens a connection of its own and evicts this one. The eviction ends every stream on it, and
495/// the warning logged for the lost connection quotes Massive's `max_connections` status. Streams
496/// on different clusters run side by side, one socket each.
497///
498/// Each subscription — a channel and a market — is sent once however many streams hold it, and
499/// unsubscribed when the last stream holding it is dropped; the socket closes once none remains.
500///
501/// If the connection is lost, every stream on it ends together and reconnects through the usual
502/// reconnect wrapper, sharing one new connection. Massive replays nothing, so what it published
503/// while no socket was open is not recovered.
504///
505/// See [`connection`] for how frames reach each stream, and [`MassiveAttachment`] for why a
506/// stream must be kept drained.
507///
508/// # Example
509///
510/// ```ignore
511/// use rustrade_data::exchange::massive::{MassiveCredentials, MassiveSubscriber};
512///
513/// // Load the key at construction time (fails fast if the variable is missing)
514/// let subscriber = MassiveSubscriber::from_env()?;
515///
516/// // Or with an explicit key
517/// let subscriber = MassiveSubscriber::new(MassiveCredentials::new("key"));
518/// ```
519#[derive(Clone, Debug)]
520pub struct MassiveSubscriber {
521    connections: Arc<MassiveConnections>,
522}
523
524impl MassiveSubscriber {
525    /// Create a new subscriber with the provided credentials.
526    ///
527    /// The subscriber opens no connection until a stream subscribes.
528    pub fn new(credentials: MassiveCredentials) -> Self {
529        Self {
530            connections: Arc::new(MassiveConnections::new(credentials)),
531        }
532    }
533
534    /// Create a new subscriber using the key in the `MASSIVE_API_KEY` environment variable.
535    ///
536    /// Equivalent to `MassiveSubscriber::new(MassiveCredentials::from_env()?)`.
537    pub fn from_env() -> Result<Self, SocketError> {
538        Ok(Self::new(MassiveCredentials::from_env()?))
539    }
540
541    /// How many handles share this subscriber's connections: this subscriber and each clone of it.
542    #[cfg(test)]
543    pub(crate) fn connection_handles(&self) -> usize {
544        Arc::strong_count(&self.connections)
545    }
546}
547
548impl Subscriber for MassiveSubscriber {
549    type SubMapper = crate::subscriber::mapper::WebSocketSubMapper;
550    type Transport = MassiveAttachment;
551
552    /// Attach the batch to its cluster's shared connection, subscribing whatever the connection
553    /// does not already hold.
554    ///
555    /// Returns once Massive confirms every requested subscription by name. One another stream
556    /// already holds is not sent again, and needs no answer.
557    ///
558    /// # Errors
559    /// Returns [`SocketError::Subscribe`] — before connecting — if the batch is empty, asks for
560    /// candles at an interval other than one second or one minute, names a market holding a comma
561    /// or whitespace (as an instrument the cluster cannot spell is written), or on the options
562    /// cluster names an instrument that is not an option contract. Once connected, it returns one if
563    /// authentication is refused (a key without WebSocket access to the cluster among the
564    /// reasons). It returns [`SocketError::Unacknowledged`], naming the subscriptions Massive had
565    /// not confirmed, if Massive refuses one (its refusal names none, so the refused one is among
566    /// them), the connection is lost, or the subscription timeout passes before every
567    /// subscription is confirmed. Massive does not answer a subscription to a channel the cluster
568    /// does not publish, so that surfaces as the timeout.
569    async fn subscribe<Exchange, Instrument, Kind>(
570        &self,
571        subscriptions: &[Subscription<Exchange, Instrument, Kind>],
572    ) -> Result<Subscribed<Instrument::Key, Self::Transport>, SocketError>
573    where
574        Exchange: Connector + Send + Sync,
575        Kind: SubscriptionKind + Send + Sync,
576        Instrument: InstrumentData,
577        Subscription<Exchange, Instrument, Kind>:
578            Identifier<Exchange::Channel> + Identifier<Exchange::Market>,
579    {
580        let exchange = Exchange::ID;
581        let url = Exchange::url()?;
582        debug!(%exchange, %url, ?subscriptions, "subscribing to Massive WebSocket");
583
584        // Every subscription in a batch shares one `Kind`, and a batch has a slot per subscription.
585        let Some(kind) = subscriptions
586            .first()
587            .map(|subscription| subscription.kind.as_str())
588        else {
589            return Err(SocketError::Subscribe(format!(
590                "no subscriptions were given to subscribe to on {exchange}"
591            )));
592        };
593        let slots = requested_slots(exchange, subscriptions)?;
594
595        // Only the instrument map is taken from the mapper. The subscribe payload is built by the
596        // connection instead, because it alone knows what the socket already holds.
597        let SubscriptionMeta {
598            instrument_map,
599            ws_subscriptions: _,
600        } = Self::SubMapper::map::<Exchange, Instrument, Kind>(subscriptions);
601
602        let transport = self
603            .connections
604            .attach(AttachRequest {
605                exchange,
606                url,
607                kind,
608                slots,
609                timeout: Exchange::subscription_timeout(),
610            })
611            .await?;
612
613        debug!(%exchange, "attached to the Massive connection");
614        Ok(Subscribed {
615            transport,
616            map: instrument_map,
617            // The connection routes every frame for the batch into `transport` from the moment it
618            // is registered, so nothing is read ahead of it.
619            buffered_websocket_events: Vec::new(),
620        })
621    }
622}
623
624/// The distinct subscriptions a batch requests, in request order, or why the batch cannot be
625/// subscribed.
626fn requested_slots<Exchange, Instrument, Kind>(
627    exchange: ExchangeId,
628    subscriptions: &[Subscription<Exchange, Instrument, Kind>],
629) -> Result<Vec<Slot>, SocketError>
630where
631    Exchange: Connector,
632    Kind: SubscriptionKind,
633    Subscription<Exchange, Instrument, Kind>:
634        Identifier<Exchange::Channel> + Identifier<Exchange::Market>,
635{
636    let mut seen = FnvHashSet::default();
637    let mut slots = Vec::with_capacity(subscriptions.len());
638
639    for subscription in subscriptions {
640        let sub = ExchangeSub::<Exchange::Channel, Exchange::Market>::new(subscription);
641
642        // A candle interval Massive does not aggregate at spells no channel.
643        if sub.channel.as_ref().is_empty() {
644            return Err(SocketError::Subscribe(format!(
645                "{exchange} has no channel for {:?}: Massive aggregates per second \
646                 (CandleInterval::{:?}) and per minute (CandleInterval::{:?}) only",
647                subscription.kind,
648                CandleInterval::Sec1,
649                CandleInterval::Min1,
650            )));
651        }
652
653        let spelled = sub.market.as_ref();
654        if exchange == ExchangeId::MassiveOptions && !market::is_option_contract(spelled) {
655            return Err(SocketError::Subscribe(format!(
656                "{exchange} streams option contracts only, and {spelled} is not one"
657            )));
658        }
659
660        // No market Massive spells holds whitespace or a comma. A comma would split the market
661        // into two subscriptions in the comma-joined `params`, and whitespace marks an instrument
662        // the cluster cannot spell, whichever cluster type is subscribing.
663        if spelled.is_empty() || spelled.contains(|c: char| c == ',' || c.is_whitespace()) {
664            return Err(SocketError::Subscribe(format!(
665                "{exchange} cannot subscribe to the market {spelled:?}"
666            )));
667        }
668
669        let slot = Slot::from(&sub);
670        if seen.insert(slot.clone()) {
671            slots.push(slot);
672        }
673    }
674
675    Ok(slots)
676}
677
678#[cfg(test)]
679#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
680mod tests {
681    use super::*;
682    use rustrade_instrument::instrument::market_data::{
683        MarketDataInstrument, kind::MarketDataInstrumentKind,
684    };
685
686    fn subscription<Server, Kind>(
687        base: &str,
688        quote: &str,
689        kind: Kind,
690    ) -> Subscription<Massive<Server>, MarketDataInstrument, Kind>
691    where
692        Server: MassiveServer,
693    {
694        Subscription::new(
695            Massive::default(),
696            MarketDataInstrument::new(base, quote, MarketDataInstrumentKind::Spot),
697            kind,
698        )
699    }
700
701    #[test]
702    fn each_kind_reads_its_cluster_channel() {
703        let slots =
704            |subscriptions: &[Subscription<MassiveCrypto, MarketDataInstrument, Candles>]| {
705                requested_slots(ExchangeId::MassiveCrypto, subscriptions)
706                    .unwrap()
707                    .into_iter()
708                    .map(|slot| slot.to_string())
709                    .collect::<Vec<_>>()
710            };
711
712        assert_eq!(
713            slots(&[
714                subscription(
715                    "btc",
716                    "usd",
717                    Candles {
718                        interval: CandleInterval::Sec1
719                    }
720                ),
721                subscription(
722                    "btc",
723                    "usd",
724                    Candles {
725                        interval: CandleInterval::Min1
726                    }
727                ),
728                subscription(
729                    "btc",
730                    "usd",
731                    Candles {
732                        interval: CandleInterval::Sec1
733                    }
734                ),
735            ]),
736            ["XAS.BTC-USD", "XA.BTC-USD"],
737        );
738
739        let forex = requested_slots(
740            ExchangeId::MassiveForex,
741            &[subscription::<MassiveServerForex, _>("eur", "usd", Quotes)],
742        )
743        .unwrap();
744        assert_eq!(forex[0].to_string(), "C.EUR/USD");
745    }
746
747    #[test]
748    fn an_interval_massive_does_not_aggregate_at_is_refused_naming_it() {
749        let error = requested_slots(
750            ExchangeId::MassiveCrypto,
751            &[subscription::<MassiveServerCrypto, _>(
752                "btc",
753                "usd",
754                Candles {
755                    interval: CandleInterval::Min5,
756                },
757            )],
758        )
759        .unwrap_err()
760        .to_string();
761
762        assert!(error.contains("Min5"), "{error}");
763        assert!(error.contains("Sec1") && error.contains("Min1"), "{error}");
764    }
765
766    #[test]
767    fn the_options_cluster_refuses_an_instrument_that_is_not_a_contract() {
768        let error = requested_slots(
769            ExchangeId::MassiveOptions,
770            &[subscription::<MassiveServerOptions, _>(
771                "aapl", "usd", Quotes,
772            )],
773        )
774        .unwrap_err()
775        .to_string();
776
777        assert!(error.contains("option contracts only"), "{error}");
778    }
779
780    #[test]
781    fn a_market_no_cluster_spells_is_refused_whatever_the_cluster_id() {
782        // An options-spelling cluster of the caller's own, under another cluster's id.
783        let unspellable = requested_slots(
784            ExchangeId::MassiveStocks,
785            &[subscription::<MassiveServerOptions, _>(
786                "aapl", "usd", Quotes,
787            )],
788        )
789        .unwrap_err()
790        .to_string();
791        assert!(unspellable.contains("no OSI symbol"), "{unspellable}");
792
793        // A comma would smuggle a second subscription into the comma-joined `params`.
794        let smuggled = requested_slots(
795            ExchangeId::MassiveStocks,
796            &[subscription::<MassiveServerStocks, _>(
797                "aapl,t.msft",
798                "usd",
799                Quotes,
800            )],
801        )
802        .unwrap_err()
803        .to_string();
804        assert!(smuggled.contains("AAPL,T.MSFT"), "{smuggled}");
805    }
806
807    #[test]
808    fn a_status_frame_validates_only_when_every_status_succeeded() {
809        let response = |json: &str| serde_json::from_str::<MassiveSubResponse>(json).unwrap();
810
811        assert!(
812            response(
813                r#"[{"ev":"status","status":"success","message":"subscribed to: XT.BTC-USD"}]"#
814            )
815            .validate()
816            .is_ok()
817        );
818
819        let refused = response(
820            r#"[{"ev":"status","status":"success","message":"subscribed to: XT.BTC-USD"},
821                {"ev":"status","status":"error","message":"not authorized"}]"#,
822        )
823        .validate()
824        .unwrap_err()
825        .to_string();
826        assert!(refused.contains("not authorized"), "{refused}");
827    }
828
829    #[test]
830    fn credentials_debug_redacts_the_key() {
831        let rendered = format!("{:?}", MassiveCredentials::new("secret-key"));
832        assert!(!rendered.contains("secret-key"), "{rendered}");
833    }
834
835    #[test]
836    fn a_connector_round_trips_through_its_exchange_id() {
837        let json = serde_json::to_string(&MassiveForex::default()).unwrap();
838        assert_eq!(json, r#""massive_forex""#);
839        assert_eq!(
840            serde_json::from_str::<MassiveForex>(&json).unwrap(),
841            MassiveForex::default()
842        );
843        assert!(serde_json::from_str::<MassiveCrypto>(&json).is_err());
844    }
845}