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>(
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 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 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 buffered_websocket_events: Vec::new(),
620 })
621 }
622}
623
624fn 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 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 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)] mod 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 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 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}