use std::pin::Pin;
use std::task::{Context, Poll};
use futures::stream::Stream;
use serde::{Deserialize, Serialize};
use tokio_tungstenite::tungstenite::Message;
use crate::adapters::common::keyed::redact_key;
use crate::error::{FinanceError, Result};
const POLYGON_WEBSOCKET_BASE: &str = "wss://socket.massive.com";
const AUTH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClusterDTO {
Stocks,
Options,
Forex,
Crypto,
Futures,
Indices,
}
impl ClusterDTO {
fn as_str(&self) -> &'static str {
match self {
Self::Stocks => "stocks",
Self::Options => "options",
Self::Forex => "forex",
Self::Crypto => "crypto",
Self::Futures => "futures",
Self::Indices => "indices",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StreamTrade {
pub ev: Option<String>,
pub sym: Option<String>,
pub pair: Option<String>,
pub p: Option<f64>,
pub s: Option<f64>,
pub x: Option<i32>,
pub c: Option<Vec<i32>>,
pub t: Option<i64>,
pub i: Option<String>,
}
impl StreamTrade {
pub fn symbol(&self) -> Option<&str> {
self.sym.as_deref().or(self.pair.as_deref())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StreamQuote {
pub ev: Option<String>,
pub sym: Option<String>,
pub pair: Option<String>,
pub bp: Option<f64>,
pub bs: Option<f64>,
pub ap: Option<f64>,
#[serde(rename = "as")]
pub ask_size: Option<f64>,
pub bx: Option<i32>,
pub ax: Option<i32>,
pub c: Option<Vec<i32>>,
pub t: Option<i64>,
}
impl StreamQuote {
pub fn symbol(&self) -> Option<&str> {
self.sym.as_deref().or(self.pair.as_deref())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StreamAggregate {
pub ev: Option<String>,
pub sym: Option<String>,
pub pair: Option<String>,
pub o: Option<f64>,
pub h: Option<f64>,
pub l: Option<f64>,
pub c: Option<f64>,
pub v: Option<f64>,
pub vw: Option<f64>,
pub s: Option<i64>,
pub e: Option<i64>,
pub z: Option<u64>,
}
impl StreamAggregate {
pub fn symbol(&self) -> Option<&str> {
self.sym.as_deref().or(self.pair.as_deref())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StreamForexQuote {
pub ev: Option<String>,
pub p: Option<String>,
pub a: Option<f64>,
pub b: Option<f64>,
pub x: Option<i32>,
pub t: Option<i64>,
}
pub type BookSide = Vec<[f64; 2]>;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StreamLevel2 {
pub ev: Option<String>,
pub pair: Option<String>,
pub b: Option<BookSide>,
pub a: Option<BookSide>,
pub x: Option<i32>,
pub t: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StreamIndexValue {
pub ev: Option<String>,
#[serde(rename = "T")]
pub ticker: Option<String>,
pub val: Option<f64>,
pub t: Option<i64>,
}
#[derive(Debug, Clone)]
pub enum PolygonMessage {
Trade(StreamTrade),
Quote(StreamQuote),
Aggregate(StreamAggregate),
ForexQuote(StreamForexQuote),
Level2(StreamLevel2),
IndexValue(StreamIndexValue),
Status(serde_json::Value),
Unknown(String),
}
pub struct PolygonStreamBuilder {
api_key: String,
cluster: ClusterDTO,
subscriptions: Vec<String>,
}
impl PolygonStreamBuilder {
pub fn cluster(mut self, cluster: ClusterDTO) -> Self {
self.cluster = cluster;
self
}
pub fn subscribe(mut self, channels: &[&str]) -> Self {
self.subscriptions
.extend(channels.iter().map(|s| s.to_string()));
self
}
pub async fn build(self) -> Result<PolygonStream> {
let url = format!("{POLYGON_WEBSOCKET_BASE}/{}", self.cluster.as_str());
let (ws_stream, _) = tokio_tungstenite::connect_async(&url)
.await
.map_err(|e| FinanceError::ApiError(format!("Polygon WebSocket connect error: {e}")))?;
let (write, mut read) = futures::StreamExt::split(ws_stream);
let write = std::sync::Arc::new(tokio::sync::Mutex::new(write));
{
use futures::SinkExt;
let auth_msg = serde_json::json!({
"action": "auth",
"params": self.api_key
});
write
.lock()
.await
.send(Message::Text(auth_msg.to_string().into()))
.await
.map_err(|e| {
FinanceError::ApiError(format!("Polygon WebSocket auth error: {e}"))
})?;
}
wait_for_authentication(&mut read, &self.api_key).await?;
if !self.subscriptions.is_empty() {
use futures::SinkExt;
let sub_msg = serde_json::json!({
"action": "subscribe",
"params": self.subscriptions.join(",")
});
write
.lock()
.await
.send(Message::Text(sub_msg.to_string().into()))
.await
.map_err(|e| {
FinanceError::ApiError(format!("Polygon WebSocket subscribe error: {e}"))
})?;
}
Ok(PolygonStream {
read: Box::pin(read),
write,
pending: std::collections::VecDeque::new(),
})
}
}
pub struct PolygonStream {
read: Pin<
Box<
dyn Stream<Item = std::result::Result<Message, tokio_tungstenite::tungstenite::Error>>
+ Send,
>,
>,
write: SharedSink,
pending: std::collections::VecDeque<PolygonMessage>,
}
type SharedSink = std::sync::Arc<
tokio::sync::Mutex<
futures::stream::SplitSink<
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
Message,
>,
>,
>;
#[derive(Clone)]
pub struct PolygonSender {
write: SharedSink,
}
impl PolygonSender {
pub async fn subscribe_channels(&self, channels: &[String]) -> Result<()> {
self.send_action("subscribe", channels).await
}
pub async fn unsubscribe_channels(&self, channels: &[String]) -> Result<()> {
self.send_action("unsubscribe", channels).await
}
async fn send_action(&self, action: &str, channels: &[String]) -> Result<()> {
use futures::SinkExt;
if channels.is_empty() {
return Ok(());
}
let msg = serde_json::json!({ "action": action, "params": channels.join(",") });
self.write
.lock()
.await
.send(Message::Text(msg.to_string().into()))
.await
.map_err(|e| FinanceError::ApiError(format!("Polygon WebSocket {action} error: {e}")))
}
}
impl PolygonStream {
pub fn builder(api_key: impl Into<String>) -> Result<PolygonStreamBuilder> {
let api_key = api_key.into();
if api_key.trim().is_empty() {
return Err(FinanceError::InvalidParameter {
param: "polygon".to_string(),
reason: "API key must not be empty".to_string(),
});
}
Ok(PolygonStreamBuilder {
api_key,
cluster: ClusterDTO::Stocks,
subscriptions: Vec::new(),
})
}
pub fn from_singleton() -> Result<PolygonStreamBuilder> {
Self::builder(super::api_key()?)
}
pub fn sender(&self) -> PolygonSender {
PolygonSender {
write: std::sync::Arc::clone(&self.write),
}
}
}
async fn wait_for_authentication<S>(read: &mut S, api_key: &str) -> Result<()>
where
S: Stream<Item = std::result::Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
{
tokio::time::timeout(AUTH_TIMEOUT, async {
while let Some(frame) = futures::StreamExt::next(read).await {
let frame = frame.map_err(|error| {
FinanceError::ApiError(format!("Polygon WebSocket auth error: {error}"))
})?;
let Message::Text(text) = frame else {
continue;
};
let events: Vec<serde_json::Value> = serde_json::from_str(&text).map_err(|error| {
FinanceError::ResponseStructureError {
field: "polygon.websocket.auth".to_string(),
context: format!("Invalid authentication response: {error}"),
}
})?;
for event in events {
if event.get("ev").and_then(|value| value.as_str()) != Some("status") {
continue;
}
let status = event
.get("status")
.and_then(|value| value.as_str())
.unwrap_or_default();
let message = event
.get("message")
.and_then(|value| value.as_str())
.unwrap_or(status);
if status == "auth_success" {
return Ok(());
}
if status == "auth_failed" || status == "not_authorized" {
return Err(FinanceError::AuthenticationFailed {
context: redact_key(message, api_key),
});
}
}
}
Err(FinanceError::ApiError(
"Polygon WebSocket closed before authentication completed".to_string(),
))
})
.await
.map_err(|_| FinanceError::AuthenticationFailed {
context: "Polygon WebSocket authentication timed out".to_string(),
})?
}
impl Stream for PolygonStream {
type Item = PolygonMessage;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
loop {
if let Some(msg) = self.pending.pop_front() {
return Poll::Ready(Some(msg));
}
match self.read.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(Message::Text(text)))) => {
self.pending.extend(parse_messages(&text));
}
Poll::Ready(Some(Ok(Message::Close(_)))) | Poll::Ready(None) => {
return Poll::Ready(None);
}
Poll::Ready(Some(Ok(_))) => continue, Poll::Ready(Some(Err(_))) => return Poll::Ready(None),
Poll::Pending => return Poll::Pending,
}
}
}
}
pub(crate) fn parse_messages(text: &str) -> Vec<PolygonMessage> {
let events: Vec<serde_json::Value> = match serde_json::from_str(text) {
Ok(v) => v,
Err(_) => return vec![PolygonMessage::Unknown(text.to_string())],
};
let mut out = Vec::with_capacity(events.len());
for event in events {
let ev = event.get("ev").and_then(|v| v.as_str()).unwrap_or("");
let parsed = match ev {
"T" | "XT" => serde_json::from_value(event)
.ok()
.map(PolygonMessage::Trade),
"Q" | "XQ" => serde_json::from_value(event)
.ok()
.map(PolygonMessage::Quote),
"A" | "AM" | "XA" | "XAM" | "CA" | "CAS" => serde_json::from_value(event)
.ok()
.map(PolygonMessage::Aggregate),
"C" => serde_json::from_value(event)
.ok()
.map(PolygonMessage::ForexQuote),
"XL2" => serde_json::from_value(event)
.ok()
.map(PolygonMessage::Level2),
"V" => serde_json::from_value(event)
.ok()
.map(PolygonMessage::IndexValue),
"status" => Some(PolygonMessage::Status(event)),
_ => None,
};
if let Some(msg) = parsed {
out.push(msg);
}
}
if out.is_empty() {
out.push(PolygonMessage::Unknown(text.to_string()));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn first(text: &str) -> PolygonMessage {
parse_messages(text).into_iter().next().expect("no events")
}
#[test]
fn test_parse_trade_message() {
let msg =
r#"[{"ev":"T","sym":"AAPL","p":186.19,"s":100,"x":4,"c":[12,37],"t":1705363200000}]"#;
match first(msg) {
PolygonMessage::Trade(t) => {
assert_eq!(t.sym.as_deref(), Some("AAPL"));
assert!((t.p.unwrap() - 186.19).abs() < 0.01);
assert_eq!(t.s.unwrap() as u64, 100);
}
other => panic!("Expected Trade, got {:?}", other),
}
}
#[test]
fn test_parse_quote_message() {
let msg = r#"[{"ev":"Q","sym":"AAPL","bp":186.18,"bs":2,"ap":186.25,"as":3,"bx":19,"ax":11,"t":1705363200000}]"#;
match first(msg) {
PolygonMessage::Quote(q) => {
assert_eq!(q.sym.as_deref(), Some("AAPL"));
assert!((q.bp.unwrap() - 186.18).abs() < 0.01);
assert!((q.ap.unwrap() - 186.25).abs() < 0.01);
}
other => panic!("Expected Quote, got {:?}", other),
}
}
#[test]
fn test_parse_aggregate_message() {
let msg = r#"[{"ev":"AM","sym":"AAPL","o":186.0,"h":186.25,"l":185.90,"c":186.19,"v":1500000,"vw":186.05,"s":1705363200000,"e":1705363260000,"z":823}]"#;
match first(msg) {
PolygonMessage::Aggregate(a) => {
assert_eq!(a.sym.as_deref(), Some("AAPL"));
assert!((a.c.unwrap() - 186.19).abs() < 0.01);
assert_eq!(a.ev.as_deref(), Some("AM"));
}
other => panic!("Expected Aggregate, got {:?}", other),
}
}
#[test]
fn test_parse_index_value_message() {
let msg = r#"[{"ev":"V","val":3988.5,"T":"I:SPX","t":1678220098130}]"#;
match first(msg) {
PolygonMessage::IndexValue(v) => {
assert_eq!(v.ev.as_deref(), Some("V"));
assert_eq!(v.ticker.as_deref(), Some("I:SPX"));
assert!((v.val.unwrap() - 3988.5).abs() < 0.01);
assert_eq!(v.t, Some(1678220098130));
}
other => panic!("Expected IndexValue, got {:?}", other),
}
}
#[test]
fn test_index_value_not_dropped_as_unknown() {
let msg = r#"[{"ev":"V","val":3988.5,"T":"I:SPX","t":1678220098130}]"#;
assert!(!matches!(first(msg), PolygonMessage::Unknown(_)));
}
#[test]
fn test_parse_status_message() {
let msg = r#"[{"ev":"status","status":"auth_success","message":"authenticated"}]"#;
match first(msg) {
PolygonMessage::Status(v) => {
assert_eq!(v.get("status").unwrap().as_str().unwrap(), "auth_success");
}
other => panic!("Expected Status, got {:?}", other),
}
}
#[test]
fn test_parse_unknown_message() {
let msg = "not json at all";
assert!(matches!(first(msg), PolygonMessage::Unknown(_)));
}
#[test]
fn test_cluster_as_str() {
assert_eq!(ClusterDTO::Stocks.as_str(), "stocks");
assert_eq!(ClusterDTO::Options.as_str(), "options");
assert_eq!(ClusterDTO::Crypto.as_str(), "crypto");
assert_eq!(ClusterDTO::Futures.as_str(), "futures");
assert_eq!(ClusterDTO::Indices.as_str(), "indices");
}
#[test]
fn explicit_builder_rejects_an_empty_key() {
assert!(matches!(
PolygonStream::builder(" "),
Err(FinanceError::InvalidParameter { .. })
));
}
#[tokio::test]
async fn authentication_waits_for_auth_success() {
let frames = vec![
Ok(Message::Text(
r#"[{"ev":"status","status":"connected"}]"#.into(),
)),
Ok(Message::Text(
r#"[{"ev":"status","status":"auth_success","message":"authenticated"}]"#.into(),
)),
];
let mut stream = futures::stream::iter(frames);
wait_for_authentication(&mut stream, "test-key")
.await
.unwrap();
}
#[tokio::test]
async fn authentication_failure_is_typed() {
let frames = vec![Ok(Message::Text(
r#"[{"ev":"status","status":"auth_failed","message":"invalid key"}]"#.into(),
))];
let mut stream = futures::stream::iter(frames);
assert!(matches!(
wait_for_authentication(&mut stream, "test-key").await,
Err(FinanceError::AuthenticationFailed { .. })
));
}
#[tokio::test]
async fn authentication_failure_redacts_the_api_key() {
const KEY: &str = "abc123";
let frames = vec![Ok(Message::Text(
format!(
r#"[{{"ev":"status","status":"auth_failed","message":"key {KEY} is invalid"}}]"#
)
.into(),
))];
let mut stream = futures::stream::iter(frames);
let err = wait_for_authentication(&mut stream, KEY).await.unwrap_err();
assert!(!format!("{err}").contains(KEY), "{err}");
}
}