1use 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
167mod 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
189pub const WEBSOCKET_URL_STOCKS: &str = "wss://socket.massive.com/stocks";
191
192pub const WEBSOCKET_URL_CRYPTO: &str = "wss://socket.massive.com/crypto";
194
195pub const WEBSOCKET_URL_FOREX: &str = "wss://socket.massive.com/forex";
197
198pub const WEBSOCKET_URL_OPTIONS: &str = "wss://socket.massive.com/options";
200
201pub type MassiveStocks = Massive<MassiveServerStocks>;
203
204pub type MassiveCrypto = Massive<MassiveServerCrypto>;
206
207pub type MassiveForex = Massive<MassiveServerForex>;
209
210pub type MassiveOptions = Massive<MassiveServerOptions>;
212
213#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
217pub struct Massive<Server>(PhantomData<Server>);
218
219#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
221pub struct MassiveServerStocks;
222
223#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
225pub struct MassiveServerCrypto;
226
227#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
229pub struct MassiveServerForex;
230
231#[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 type SubValidator = WebSocketSubValidator;
315 type SubResponse = MassiveSubResponse;
316
317 fn url() -> Result<Url, url::ParseError> {
318 Url::parse(Server::websocket_url())
319 }
320
321 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 }
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
353impl<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
364impl<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#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
414pub struct MassiveSubResponse(pub Vec<MassiveStatus>);
415
416#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
418pub struct MassiveStatus {
419 pub status: SmolStr,
421 #[serde(default)]
422 pub message: String,
423}
424
425impl Validator for MassiveSubResponse {
426 type Error = SocketError;
427
428 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#[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 pub fn new(api_key: impl Into<String>) -> Self {
464 Self {
465 api_key: api_key.into(),
466 }
467 }
468
469 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#[derive(Clone, Debug)]
520pub struct MassiveSubscriber {
521 connections: Arc<MassiveConnections>,
522}
523
524impl MassiveSubscriber {
525 pub fn new(credentials: MassiveCredentials) -> Self {
529 Self {
530 connections: Arc::new(MassiveConnections::new(credentials)),
531 }
532 }
533
534 pub fn from_env() -> Result<Self, SocketError> {
538 Ok(Self::new(MassiveCredentials::from_env()?))
539 }
540
541 #[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 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 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 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 buffered_websocket_events: Vec::new(),
618 })
619 }
620}
621
622fn 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 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 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)] mod 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 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 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}