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), Massive refuses a subscription, or the subscription timeout passes before every
565    /// subscription is confirmed. Massive does not answer a subscription to a channel the cluster
566    /// does not publish, so that surfaces as the timeout, naming what was never confirmed.
567    async fn subscribe<Exchange, Instrument, Kind>(
568        &self,
569        subscriptions: &[Subscription<Exchange, Instrument, Kind>],
570    ) -> Result<Subscribed<Instrument::Key, Self::Transport>, SocketError>
571    where
572        Exchange: Connector + Send + Sync,
573        Kind: SubscriptionKind + Send + Sync,
574        Instrument: InstrumentData,
575        Subscription<Exchange, Instrument, Kind>:
576            Identifier<Exchange::Channel> + Identifier<Exchange::Market>,
577    {
578        let exchange = Exchange::ID;
579        let url = Exchange::url()?;
580        debug!(%exchange, %url, ?subscriptions, "subscribing to Massive WebSocket");
581
582        // Every subscription in a batch shares one `Kind`, and a batch has a slot per subscription.
583        let Some(kind) = subscriptions
584            .first()
585            .map(|subscription| subscription.kind.as_str())
586        else {
587            return Err(SocketError::Subscribe(format!(
588                "no subscriptions were given to subscribe to on {exchange}"
589            )));
590        };
591        let slots = requested_slots(exchange, subscriptions)?;
592
593        // Only the instrument map is taken from the mapper. The subscribe payload is built by the
594        // connection instead, because it alone knows what the socket already holds.
595        let SubscriptionMeta {
596            instrument_map,
597            ws_subscriptions: _,
598        } = Self::SubMapper::map::<Exchange, Instrument, Kind>(subscriptions);
599
600        let transport = self
601            .connections
602            .attach(AttachRequest {
603                exchange,
604                url,
605                kind,
606                slots,
607                timeout: Exchange::subscription_timeout(),
608            })
609            .await?;
610
611        debug!(%exchange, "attached to the Massive connection");
612        Ok(Subscribed {
613            transport,
614            map: instrument_map,
615            // The connection routes every frame for the batch into `transport` from the moment it
616            // is registered, so nothing is read ahead of it.
617            buffered_websocket_events: Vec::new(),
618        })
619    }
620}
621
622/// The distinct subscriptions a batch requests, in request order, or why the batch cannot be
623/// subscribed.
624fn requested_slots<Exchange, Instrument, Kind>(
625    exchange: ExchangeId,
626    subscriptions: &[Subscription<Exchange, Instrument, Kind>],
627) -> Result<Vec<Slot>, SocketError>
628where
629    Exchange: Connector,
630    Kind: SubscriptionKind,
631    Subscription<Exchange, Instrument, Kind>:
632        Identifier<Exchange::Channel> + Identifier<Exchange::Market>,
633{
634    let mut seen = FnvHashSet::default();
635    let mut slots = Vec::with_capacity(subscriptions.len());
636
637    for subscription in subscriptions {
638        let sub = ExchangeSub::<Exchange::Channel, Exchange::Market>::new(subscription);
639
640        // A candle interval Massive does not aggregate at spells no channel.
641        if sub.channel.as_ref().is_empty() {
642            return Err(SocketError::Subscribe(format!(
643                "{exchange} has no channel for {:?}: Massive aggregates per second \
644                 (CandleInterval::{:?}) and per minute (CandleInterval::{:?}) only",
645                subscription.kind,
646                CandleInterval::Sec1,
647                CandleInterval::Min1,
648            )));
649        }
650
651        let spelled = sub.market.as_ref();
652        if exchange == ExchangeId::MassiveOptions && !market::is_option_contract(spelled) {
653            return Err(SocketError::Subscribe(format!(
654                "{exchange} streams option contracts only, and {spelled} is not one"
655            )));
656        }
657
658        // No market Massive spells holds whitespace or a comma. A comma would split the market
659        // into two subscriptions in the comma-joined `params`, and whitespace marks an instrument
660        // the cluster cannot spell, whichever cluster type is subscribing.
661        if spelled.is_empty() || spelled.contains(|c: char| c == ',' || c.is_whitespace()) {
662            return Err(SocketError::Subscribe(format!(
663                "{exchange} cannot subscribe to the market {spelled:?}"
664            )));
665        }
666
667        let slot = Slot::from(&sub);
668        if seen.insert(slot.clone()) {
669            slots.push(slot);
670        }
671    }
672
673    Ok(slots)
674}
675
676#[cfg(test)]
677#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
678mod tests {
679    use super::*;
680    use rustrade_instrument::instrument::market_data::{
681        MarketDataInstrument, kind::MarketDataInstrumentKind,
682    };
683
684    fn subscription<Server, Kind>(
685        base: &str,
686        quote: &str,
687        kind: Kind,
688    ) -> Subscription<Massive<Server>, MarketDataInstrument, Kind>
689    where
690        Server: MassiveServer,
691    {
692        Subscription::new(
693            Massive::default(),
694            MarketDataInstrument::new(base, quote, MarketDataInstrumentKind::Spot),
695            kind,
696        )
697    }
698
699    #[test]
700    fn each_kind_reads_its_cluster_channel() {
701        let slots =
702            |subscriptions: &[Subscription<MassiveCrypto, MarketDataInstrument, Candles>]| {
703                requested_slots(ExchangeId::MassiveCrypto, subscriptions)
704                    .unwrap()
705                    .into_iter()
706                    .map(|slot| slot.to_string())
707                    .collect::<Vec<_>>()
708            };
709
710        assert_eq!(
711            slots(&[
712                subscription(
713                    "btc",
714                    "usd",
715                    Candles {
716                        interval: CandleInterval::Sec1
717                    }
718                ),
719                subscription(
720                    "btc",
721                    "usd",
722                    Candles {
723                        interval: CandleInterval::Min1
724                    }
725                ),
726                subscription(
727                    "btc",
728                    "usd",
729                    Candles {
730                        interval: CandleInterval::Sec1
731                    }
732                ),
733            ]),
734            ["XAS.BTC-USD", "XA.BTC-USD"],
735        );
736
737        let forex = requested_slots(
738            ExchangeId::MassiveForex,
739            &[subscription::<MassiveServerForex, _>("eur", "usd", Quotes)],
740        )
741        .unwrap();
742        assert_eq!(forex[0].to_string(), "C.EUR/USD");
743    }
744
745    #[test]
746    fn an_interval_massive_does_not_aggregate_at_is_refused_naming_it() {
747        let error = requested_slots(
748            ExchangeId::MassiveCrypto,
749            &[subscription::<MassiveServerCrypto, _>(
750                "btc",
751                "usd",
752                Candles {
753                    interval: CandleInterval::Min5,
754                },
755            )],
756        )
757        .unwrap_err()
758        .to_string();
759
760        assert!(error.contains("Min5"), "{error}");
761        assert!(error.contains("Sec1") && error.contains("Min1"), "{error}");
762    }
763
764    #[test]
765    fn the_options_cluster_refuses_an_instrument_that_is_not_a_contract() {
766        let error = requested_slots(
767            ExchangeId::MassiveOptions,
768            &[subscription::<MassiveServerOptions, _>(
769                "aapl", "usd", Quotes,
770            )],
771        )
772        .unwrap_err()
773        .to_string();
774
775        assert!(error.contains("option contracts only"), "{error}");
776    }
777
778    #[test]
779    fn a_market_no_cluster_spells_is_refused_whatever_the_cluster_id() {
780        // An options-spelling cluster of the caller's own, under another cluster's id.
781        let unspellable = requested_slots(
782            ExchangeId::MassiveStocks,
783            &[subscription::<MassiveServerOptions, _>(
784                "aapl", "usd", Quotes,
785            )],
786        )
787        .unwrap_err()
788        .to_string();
789        assert!(unspellable.contains("no OSI symbol"), "{unspellable}");
790
791        // A comma would smuggle a second subscription into the comma-joined `params`.
792        let smuggled = requested_slots(
793            ExchangeId::MassiveStocks,
794            &[subscription::<MassiveServerStocks, _>(
795                "aapl,t.msft",
796                "usd",
797                Quotes,
798            )],
799        )
800        .unwrap_err()
801        .to_string();
802        assert!(smuggled.contains("AAPL,T.MSFT"), "{smuggled}");
803    }
804
805    #[test]
806    fn a_status_frame_validates_only_when_every_status_succeeded() {
807        let response = |json: &str| serde_json::from_str::<MassiveSubResponse>(json).unwrap();
808
809        assert!(
810            response(
811                r#"[{"ev":"status","status":"success","message":"subscribed to: XT.BTC-USD"}]"#
812            )
813            .validate()
814            .is_ok()
815        );
816
817        let refused = response(
818            r#"[{"ev":"status","status":"success","message":"subscribed to: XT.BTC-USD"},
819                {"ev":"status","status":"error","message":"not authorized"}]"#,
820        )
821        .validate()
822        .unwrap_err()
823        .to_string();
824        assert!(refused.contains("not authorized"), "{refused}");
825    }
826
827    #[test]
828    fn credentials_debug_redacts_the_key() {
829        let rendered = format!("{:?}", MassiveCredentials::new("secret-key"));
830        assert!(!rendered.contains("secret-key"), "{rendered}");
831    }
832
833    #[test]
834    fn a_connector_round_trips_through_its_exchange_id() {
835        let json = serde_json::to_string(&MassiveForex::default()).unwrap();
836        assert_eq!(json, r#""massive_forex""#);
837        assert_eq!(
838            serde_json::from_str::<MassiveForex>(&json).unwrap(),
839            MassiveForex::default()
840        );
841        assert!(serde_json::from_str::<MassiveCrypto>(&json).is_err());
842    }
843}