use crate::auth::{unix_timestamp_millis, UsAuth};
use crate::error::PolymarketUsError;
use futures_util::{SinkExt, StreamExt};
use http::HeaderValue;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Map, Value};
use std::future::Future;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, Notify};
use tokio_tungstenite::{
connect_async,
tungstenite::{client::IntoClientRequest, Message},
};
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1);
const DEFAULT_STREAM_HOST: &str = "wss://api.polymarket.us";
type WebSocket =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StreamEndpoint {
Markets,
Private,
}
impl StreamEndpoint {
pub fn path(self) -> &'static str {
match self {
Self::Markets => "/v1/ws/markets",
Self::Private => "/v1/ws/private",
}
}
pub fn default_url(self) -> String {
format!("{DEFAULT_STREAM_HOST}{}", self.path())
}
}
impl std::fmt::Display for StreamEndpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Markets => "markets",
Self::Private => "private",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SubscriptionType {
MarketData,
MarketDataLite,
Trade,
Order,
Position,
AccountBalance,
Other(String),
}
impl SubscriptionType {
pub fn as_wire(&self) -> &str {
match self {
Self::MarketData => "SUBSCRIPTION_TYPE_MARKET_DATA",
Self::MarketDataLite => "SUBSCRIPTION_TYPE_MARKET_DATA_LITE",
Self::Trade => "SUBSCRIPTION_TYPE_TRADE",
Self::Order => "SUBSCRIPTION_TYPE_ORDER",
Self::Position => "SUBSCRIPTION_TYPE_POSITION",
Self::AccountBalance => "SUBSCRIPTION_TYPE_ACCOUNT_BALANCE",
Self::Other(raw) => raw,
}
}
pub fn from_wire(raw: &str) -> Self {
match raw {
"SUBSCRIPTION_TYPE_MARKET_DATA" => Self::MarketData,
"SUBSCRIPTION_TYPE_MARKET_DATA_LITE" => Self::MarketDataLite,
"SUBSCRIPTION_TYPE_TRADE" => Self::Trade,
"SUBSCRIPTION_TYPE_ORDER" => Self::Order,
"SUBSCRIPTION_TYPE_POSITION" => Self::Position,
"SUBSCRIPTION_TYPE_ACCOUNT_BALANCE" => Self::AccountBalance,
other => Self::Other(other.to_string()),
}
}
pub fn endpoint(&self) -> Option<StreamEndpoint> {
match self {
Self::MarketData | Self::MarketDataLite | Self::Trade => Some(StreamEndpoint::Markets),
Self::Order | Self::Position | Self::AccountBalance => Some(StreamEndpoint::Private),
Self::Other(_) => None,
}
}
fn requires_market_slugs(&self) -> bool {
matches!(self, Self::MarketData | Self::MarketDataLite | Self::Trade)
}
}
impl std::fmt::Display for SubscriptionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_wire())
}
}
impl Serialize for SubscriptionType {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_wire())
}
}
impl<'de> Deserialize<'de> for SubscriptionType {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Ok(Self::from_wire(&raw))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Subscription {
pub request_id: String,
pub subscription_type: SubscriptionType,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub market_slugs: Vec<String>,
#[serde(default, flatten)]
pub extra: Map<String, Value>,
}
impl Subscription {
fn new(subscription_type: SubscriptionType) -> Self {
Self {
request_id: next_request_id("sub"),
subscription_type,
market_slugs: Vec::new(),
extra: Map::new(),
}
}
fn frame(&self) -> Value {
serde_json::json!({ "subscribe": self })
}
fn validate(&self, endpoint: StreamEndpoint) -> Result<(), PolymarketUsError> {
if let Some(required) = self.subscription_type.endpoint() {
if required != endpoint {
return Err(PolymarketUsError::InvalidStreamConfig(format!(
"{} is served by the {required} endpoint, not {endpoint}",
self.subscription_type
)));
}
}
if self.subscription_type.requires_market_slugs() && self.market_slugs.is_empty() {
return Err(PolymarketUsError::InvalidStreamConfig(format!(
"{} requires at least one market slug",
self.subscription_type
)));
}
Ok(())
}
}
macro_rules! subscription_accessors {
($ty:ident) => {
impl $ty {
pub fn request_id(&self) -> &str {
&self.0.request_id
}
pub fn subscription_type(&self) -> &SubscriptionType {
&self.0.subscription_type
}
pub fn market_slugs(&self) -> &[String] {
&self.0.market_slugs
}
pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
self.0.request_id = request_id.into();
self
}
pub fn with_market_slugs<I, S>(mut self, slugs: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.0.market_slugs = slugs.into_iter().map(Into::into).collect();
self
}
pub fn add_market_slug(mut self, slug: impl Into<String>) -> Self {
self.0.market_slugs.push(slug.into());
self
}
pub fn insert_extra(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
self.0.extra.insert(key.into(), value.into());
self
}
pub fn frame(&self) -> Value {
self.0.frame()
}
}
};
}
#[derive(Debug, Clone)]
pub struct MarketSubscription(Subscription);
impl MarketSubscription {
pub fn market_data<I, S>(market_slugs: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self(Subscription::new(SubscriptionType::MarketData)).with_market_slugs(market_slugs)
}
pub fn market_data_lite<I, S>(market_slugs: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self(Subscription::new(SubscriptionType::MarketDataLite)).with_market_slugs(market_slugs)
}
pub fn trades<I, S>(market_slugs: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self(Subscription::new(SubscriptionType::Trade)).with_market_slugs(market_slugs)
}
pub fn custom(subscription_type: impl Into<String>) -> Self {
Self(Subscription::new(SubscriptionType::from_wire(
&subscription_type.into(),
)))
}
}
#[derive(Debug, Clone)]
pub struct PrivateSubscription(Subscription);
impl PrivateSubscription {
pub fn orders() -> Self {
Self(Subscription::new(SubscriptionType::Order))
}
pub fn positions() -> Self {
Self(Subscription::new(SubscriptionType::Position))
}
pub fn account_balances() -> Self {
Self(Subscription::new(SubscriptionType::AccountBalance))
}
pub fn custom(subscription_type: impl Into<String>) -> Self {
Self(Subscription::new(SubscriptionType::from_wire(
&subscription_type.into(),
)))
}
}
subscription_accessors!(MarketSubscription);
subscription_accessors!(PrivateSubscription);
#[derive(Clone)]
pub struct MarketStreamClient {
base_url: String,
auth: Option<UsAuth>,
}
#[derive(Clone)]
pub struct PrivateStreamClient {
base_url: String,
auth: UsAuth,
}
impl MarketStreamClient {
pub fn new(auth: Option<UsAuth>) -> Self {
Self {
base_url: StreamEndpoint::Markets.default_url(),
auth,
}
}
pub fn with_base_url(base_url: impl Into<String>, auth: Option<UsAuth>) -> Self {
Self {
base_url: normalize_stream_url(base_url.into(), StreamEndpoint::Markets),
auth,
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn connect(
&self,
subscriptions: Vec<MarketSubscription>,
) -> Result<MarketStream, PolymarketUsError> {
self.connect_with_config(subscriptions, StreamConnectConfig::default())
.await
}
pub async fn connect_with_config(
&self,
subscriptions: Vec<MarketSubscription>,
config: StreamConnectConfig,
) -> Result<MarketStream, PolymarketUsError> {
let inner = spawn_stream(
self.base_url.clone(),
self.auth.clone(),
StreamEndpoint::Markets,
subscriptions.into_iter().map(|sub| sub.0).collect(),
config,
)?;
Ok(MarketStream { inner })
}
pub async fn run<F, Fut>(
&self,
subscriptions: Vec<MarketSubscription>,
config: StreamConnectConfig,
mut on_message: F,
) -> Result<(), PolymarketUsError>
where
F: FnMut(StreamMessage) -> Fut,
Fut: Future<Output = ()>,
{
let mut stream = self.connect_with_config(subscriptions, config).await?;
while let Some(message) = stream.next().await {
on_message(message).await;
}
Ok(())
}
}
impl PrivateStreamClient {
pub fn new(auth: UsAuth) -> Self {
Self {
base_url: StreamEndpoint::Private.default_url(),
auth,
}
}
pub fn with_base_url(base_url: impl Into<String>, auth: UsAuth) -> Self {
Self {
base_url: normalize_stream_url(base_url.into(), StreamEndpoint::Private),
auth,
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn connect(
&self,
subscriptions: Vec<PrivateSubscription>,
) -> Result<PrivateStream, PolymarketUsError> {
self.connect_with_config(subscriptions, StreamConnectConfig::default())
.await
}
pub async fn connect_with_config(
&self,
subscriptions: Vec<PrivateSubscription>,
config: StreamConnectConfig,
) -> Result<PrivateStream, PolymarketUsError> {
let inner = spawn_stream(
self.base_url.clone(),
Some(self.auth.clone()),
StreamEndpoint::Private,
subscriptions.into_iter().map(|sub| sub.0).collect(),
config,
)?;
Ok(PrivateStream { inner })
}
pub async fn run<F, Fut>(
&self,
subscriptions: Vec<PrivateSubscription>,
config: StreamConnectConfig,
mut on_message: F,
) -> Result<(), PolymarketUsError>
where
F: FnMut(StreamMessage) -> Fut,
Fut: Future<Output = ()>,
{
let mut stream = self.connect_with_config(subscriptions, config).await?;
while let Some(message) = stream.next().await {
on_message(message).await;
}
Ok(())
}
}
fn spawn_stream(
base_url: String,
auth: Option<UsAuth>,
endpoint: StreamEndpoint,
subscriptions: Vec<Subscription>,
config: StreamConnectConfig,
) -> Result<StreamHandle, PolymarketUsError> {
if subscriptions.is_empty() {
return Err(PolymarketUsError::InvalidStreamConfig(
"at least one subscription is required".to_string(),
));
}
for subscription in &subscriptions {
subscription.validate(endpoint)?;
}
let (tx, rx) = mpsc::channel(256);
let (cmd_tx, cmd_rx) = mpsc::channel(64);
let shutdown = Arc::new(StreamShutdown::new());
let shutdown_task = shutdown.clone();
tokio::spawn(async move {
let runner = StreamRunner {
base_url,
auth,
subscriptions,
config,
tx,
shutdown: shutdown_task,
cmd_rx,
};
runner.run().await;
});
Ok(StreamHandle {
receiver: rx,
shutdown,
cmd_tx,
endpoint,
})
}
struct StreamHandle {
receiver: mpsc::Receiver<StreamMessage>,
shutdown: Arc<StreamShutdown>,
cmd_tx: mpsc::Sender<StreamCommand>,
endpoint: StreamEndpoint,
}
impl StreamHandle {
async fn subscribe(&self, subscription: Subscription) -> Result<(), PolymarketUsError> {
subscription.validate(self.endpoint)?;
self.cmd_tx
.send(StreamCommand::Subscribe(subscription))
.await
.map_err(|_| PolymarketUsError::InvalidStreamConfig("stream is closed".to_string()))
}
}
macro_rules! stream_handle {
($ty:ident, $sub:ident, $endpoint:expr) => {
impl $ty {
pub async fn next(&mut self) -> Option<StreamMessage> {
self.inner.receiver.recv().await
}
pub fn endpoint(&self) -> StreamEndpoint {
$endpoint
}
pub fn shutdown(&self) {
self.inner.shutdown.shutdown();
}
pub fn is_shutdown(&self) -> bool {
self.inner.shutdown.is_shutdown()
}
pub async fn subscribe(&self, subscription: $sub) -> Result<(), PolymarketUsError> {
self.inner.subscribe(subscription.0).await
}
pub async fn unsubscribe(&self, request_id: &str) -> Result<(), PolymarketUsError> {
self.inner
.cmd_tx
.send(StreamCommand::Unsubscribe(request_id.to_string()))
.await
.map_err(|_| {
PolymarketUsError::InvalidStreamConfig("stream is closed".to_string())
})
}
}
};
}
pub struct MarketStream {
inner: StreamHandle,
}
pub struct PrivateStream {
inner: StreamHandle,
}
stream_handle!(MarketStream, MarketSubscription, StreamEndpoint::Markets);
stream_handle!(PrivateStream, PrivateSubscription, StreamEndpoint::Private);
enum StreamCommand {
Subscribe(Subscription),
Unsubscribe(String), }
#[derive(Debug, Clone)]
pub struct StreamConnectConfig {
pub session_id: String,
pub reconnect: ReconnectConfig,
pub idle_timeout: Option<Duration>,
pub keepalive_interval: Option<Duration>,
}
impl Default for StreamConnectConfig {
fn default() -> Self {
Self {
session_id: next_request_id("session"),
reconnect: ReconnectConfig::default(),
idle_timeout: Some(Duration::from_secs(60)),
keepalive_interval: Some(Duration::from_secs(20)),
}
}
}
impl StreamConnectConfig {
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = session_id.into();
self
}
pub fn with_reconnect(mut self, reconnect: ReconnectConfig) -> Self {
self.reconnect = reconnect;
self
}
pub fn with_idle_timeout(mut self, idle_timeout: Option<Duration>) -> Self {
self.idle_timeout = idle_timeout;
self
}
pub fn with_keepalive_interval(mut self, keepalive_interval: Option<Duration>) -> Self {
self.keepalive_interval = keepalive_interval;
self
}
}
#[derive(Debug, Clone)]
pub struct ReconnectConfig {
pub enabled: bool,
pub max_attempts: Option<usize>,
pub initial_delay: Duration,
pub max_delay: Duration,
pub multiplier: f64,
}
impl Default for ReconnectConfig {
fn default() -> Self {
Self {
enabled: true,
max_attempts: None,
initial_delay: Duration::from_millis(250),
max_delay: Duration::from_secs(10),
multiplier: 2.0,
}
}
}
impl ReconnectConfig {
pub fn disabled() -> Self {
Self {
enabled: false,
..Self::default()
}
}
pub fn delay_for_attempt(&self, attempt: usize) -> Duration {
if attempt == 0 {
return self.initial_delay.min(self.max_delay);
}
let scaled = self
.initial_delay
.mul_f64(self.multiplier.powi(attempt.saturating_sub(1) as i32));
scaled.min(self.max_delay)
}
}
#[derive(Debug, Clone)]
pub struct StreamMessage {
pub request_id: Option<String>,
pub kind: StreamMessageKind,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum StreamMessageKind {
Data(StreamDataEvent),
Control(StreamControlEvent),
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum StreamDataEvent {
OrderSnapshot(Value),
OrderUpdate(Value),
MarketData(Value),
MarketDataLite(Value),
OrderBookDelta(Value),
PositionSnapshot(Value),
PositionUpdate(Value),
BalanceSnapshot(Value),
BalanceUpdate(Value),
Trade(Value),
Heartbeat,
Other { event_type: String, payload: Value },
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum StreamControlEvent {
Connected { session_id: String },
SubscriptionAck { event_type: String, payload: Value },
Reconnecting { attempt: usize, delay_ms: u64 },
Closed,
Error(String),
}
impl StreamMessage {
pub fn control(request_id: Option<String>, event: StreamControlEvent) -> Self {
Self {
request_id,
kind: StreamMessageKind::Control(event),
}
}
pub fn data(request_id: Option<String>, event: StreamDataEvent) -> Self {
Self {
request_id,
kind: StreamMessageKind::Data(event),
}
}
}
struct StreamRunner {
base_url: String,
auth: Option<UsAuth>,
subscriptions: Vec<Subscription>,
config: StreamConnectConfig,
tx: mpsc::Sender<StreamMessage>,
shutdown: Arc<StreamShutdown>,
cmd_rx: mpsc::Receiver<StreamCommand>,
}
impl StreamRunner {
async fn run(mut self) {
let mut attempt = 0usize;
loop {
if self.shutdown.is_shutdown() || self.tx.is_closed() {
break;
}
match self.connect_and_consume().await {
Ok(()) => {
if !self.config.reconnect.enabled {
break;
}
}
Err(err) => {
if !self
.emit(StreamMessage::control(
Some(self.config.session_id.clone()),
StreamControlEvent::Error(err.to_string()),
))
.await
{
break;
}
}
}
if !self.config.reconnect.enabled {
break;
}
attempt += 1;
if let Some(max_attempts) = self.config.reconnect.max_attempts {
if attempt > max_attempts {
break;
}
}
let delay = self.config.reconnect.delay_for_attempt(attempt);
if !self
.emit(StreamMessage::control(
Some(self.config.session_id.clone()),
StreamControlEvent::Reconnecting {
attempt,
delay_ms: delay.as_millis() as u64,
},
))
.await
{
break;
}
let shutdown = Arc::clone(&self.shutdown);
tokio::select! {
_ = shutdown.notified() => break,
_ = tokio::time::sleep(delay) => {}
}
}
let _ = self
.emit(StreamMessage::control(
Some(self.config.session_id.clone()),
StreamControlEvent::Closed,
))
.await;
}
async fn connect_and_consume(&mut self) -> Result<(), PolymarketUsError> {
let mut request = self
.base_url
.as_str()
.into_client_request()
.map_err(|err| {
PolymarketUsError::InvalidStreamConfig(format!(
"invalid websocket URL {}: {err}",
self.base_url
))
})?;
if let Some(auth) = &self.auth {
let path = request
.uri()
.path_and_query()
.map(|path| path.as_str())
.unwrap_or("/");
for (name, value) in auth.signed_headers("GET", path) {
let header_value = HeaderValue::from_str(&value).map_err(|err| {
PolymarketUsError::InvalidStreamConfig(format!(
"invalid websocket auth header value for {name}: {err}"
))
})?;
request.headers_mut().insert(name, header_value);
}
}
let (mut websocket, _) = connect_async(request).await?;
let _ = self
.emit(StreamMessage::control(
Some(self.config.session_id.clone()),
StreamControlEvent::Connected {
session_id: self.config.session_id.clone(),
},
))
.await;
self.send_all_subscriptions(&mut websocket).await?;
let shutdown = Arc::clone(&self.shutdown);
let shutdown_wait = shutdown.notified();
tokio::pin!(shutdown_wait);
let idle_timeout = self.config.idle_timeout;
let idle_deadline =
tokio::time::sleep(idle_timeout.unwrap_or_else(|| Duration::from_secs(3600)));
tokio::pin!(idle_deadline);
let keepalive_interval = self.config.keepalive_interval;
let keepalive =
tokio::time::sleep(keepalive_interval.unwrap_or_else(|| Duration::from_secs(3600)));
tokio::pin!(keepalive);
loop {
tokio::select! {
_ = &mut shutdown_wait => {
let _ = websocket.close(None).await;
break;
}
_ = &mut idle_deadline, if idle_timeout.is_some() => {
let _ = websocket.close(None).await;
return Err(PolymarketUsError::StreamIdle(
idle_timeout.expect("guarded by idle_timeout.is_some()"),
));
}
_ = &mut keepalive, if keepalive_interval.is_some() => {
let interval = keepalive_interval.expect("guarded by is_some()");
keepalive.as_mut().reset(tokio::time::Instant::now() + interval);
websocket.send(Message::Ping(Vec::new().into())).await?;
}
message = websocket.next() => {
if let Some(timeout) = idle_timeout {
idle_deadline.as_mut().reset(tokio::time::Instant::now() + timeout);
}
let Some(message) = message else {
break;
};
match message {
Ok(Message::Text(text)) => {
self.handle_text(&text).await?;
}
Ok(Message::Binary(bytes)) => {
let text = String::from_utf8(bytes.to_vec()).map_err(|err| {
PolymarketUsError::InvalidStreamConfig(format!(
"received non-UTF8 websocket payload: {err}"
))
})?;
self.handle_text(&text).await?;
}
Ok(Message::Close(_)) => break,
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
Ok(_) => {}
Err(err) => return Err(err.into()),
}
}
cmd = self.cmd_rx.recv() => {
match cmd {
Some(StreamCommand::Subscribe(sub)) => {
self.send_subscription(&mut websocket, &sub).await?;
self.subscriptions.push(sub);
}
Some(StreamCommand::Unsubscribe(request_id)) => {
self.subscriptions.retain(|s| s.request_id != request_id);
let frame = serde_json::json!({
"unsubscribe": { "requestId": request_id },
});
let _ = websocket
.send(Message::Text(frame.to_string().into()))
.await;
}
None => break,
}
}
}
}
Ok(())
}
async fn send_all_subscriptions(
&self,
websocket: &mut WebSocket,
) -> Result<(), PolymarketUsError> {
for subscription in &self.subscriptions {
self.send_subscription(websocket, subscription).await?;
}
Ok(())
}
async fn send_subscription(
&self,
websocket: &mut WebSocket,
subscription: &Subscription,
) -> Result<(), PolymarketUsError> {
let payload = serde_json::to_string(&subscription.frame())?;
websocket.send(Message::Text(payload.into())).await?;
Ok(())
}
async fn handle_text(&self, text: &str) -> Result<(), PolymarketUsError> {
let json: Value = serde_json::from_str(text)?;
if let Some(message) = parse_stream_message(json) {
if !self.emit(message).await {
return Ok(());
}
}
Ok(())
}
async fn emit(&self, message: StreamMessage) -> bool {
self.tx.send(message).await.is_ok()
}
}
struct StreamShutdown {
requested: AtomicBool,
notify: Notify,
}
impl StreamShutdown {
fn new() -> Self {
Self {
requested: AtomicBool::new(false),
notify: Notify::new(),
}
}
fn shutdown(&self) {
if !self.requested.swap(true, Ordering::SeqCst) {
self.notify.notify_waiters();
}
}
fn is_shutdown(&self) -> bool {
self.requested.load(Ordering::SeqCst)
}
fn notified(&self) -> impl Future<Output = ()> + '_ {
self.notify.notified()
}
}
const ENVELOPE_META_KEYS: &[&str] = &[
"requestId",
"request_id",
"trackingId",
"tracking_id",
"id",
"timestamp",
"ts",
"time",
"seq",
"sequence",
"type",
"event",
"channel",
"topic",
"name",
"subscriptionType",
"subscription_type",
];
fn parse_stream_message(json: Value) -> Option<StreamMessage> {
match json {
Value::Object(map) => {
let request_id = extract_request_id(&map);
let event_type = extract_event_type(&map);
let payload = extract_payload(&map);
let kind = match event_type.as_str() {
"order_snapshot" | "orderSnapshot" => {
StreamMessageKind::Data(StreamDataEvent::OrderSnapshot(payload))
}
"order" | "orders" | "order_update" | "order_updates" | "orderUpdate"
| "user_order" | "fill" => {
StreamMessageKind::Data(StreamDataEvent::OrderUpdate(payload))
}
"market_data" | "marketData" => {
StreamMessageKind::Data(StreamDataEvent::MarketData(payload))
}
"market_data_lite" | "marketDataLite" => {
StreamMessageKind::Data(StreamDataEvent::MarketDataLite(payload))
}
"order_book_delta" | "orderbook_delta" | "book_delta" | "bookDelta" => {
StreamMessageKind::Data(StreamDataEvent::OrderBookDelta(payload))
}
"trade" | "trades" => StreamMessageKind::Data(StreamDataEvent::Trade(payload)),
"position_snapshot" | "positionSnapshot" => {
StreamMessageKind::Data(StreamDataEvent::PositionSnapshot(payload))
}
"position" | "positions" | "position_update" | "positionUpdate" => {
StreamMessageKind::Data(StreamDataEvent::PositionUpdate(payload))
}
"balance_snapshot" | "balanceSnapshot" | "account_balance_snapshot" => {
StreamMessageKind::Data(StreamDataEvent::BalanceSnapshot(payload))
}
"balance" | "balances" | "balance_update" | "balanceUpdate" | "account_balance"
| "accountBalance" => {
StreamMessageKind::Data(StreamDataEvent::BalanceUpdate(payload))
}
"heartbeat" | "ping" | "pong" => {
StreamMessageKind::Data(StreamDataEvent::Heartbeat)
}
"subscription" | "subscribe" | "subscribed" | "subscribeAck" | "ack"
| "unsubscribe" | "unsubscribed" => {
StreamMessageKind::Control(StreamControlEvent::SubscriptionAck {
event_type: event_type.clone(),
payload,
})
}
"error" => {
StreamMessageKind::Control(StreamControlEvent::Error(payload.to_string()))
}
_ => StreamMessageKind::Data(StreamDataEvent::Other {
event_type: event_type.clone(),
payload,
}),
};
Some(StreamMessage { request_id, kind })
}
other => Some(StreamMessage::data(
None,
StreamDataEvent::Other {
event_type: "unknown".to_string(),
payload: other,
},
)),
}
}
fn extract_request_id(map: &Map<String, Value>) -> Option<String> {
["requestId", "request_id", "trackingId", "tracking_id", "id"]
.iter()
.find_map(|key| map.get(*key).and_then(Value::as_str).map(ToOwned::to_owned))
}
const PAYLOAD_KEYS: &[&str] = &["data", "payload", "body", "message", "result"];
fn sole_content_key(map: &Map<String, Value>) -> Option<&String> {
let mut content = map
.keys()
.filter(|key| !ENVELOPE_META_KEYS.contains(&key.as_str()));
let first = content.next()?;
content.next().is_none().then_some(first)
}
fn extract_event_type(map: &Map<String, Value>) -> String {
for key in ["event", "type", "channel", "name", "topic"] {
if let Some(value) = map.get(key).and_then(Value::as_str) {
return normalize_event_type(value);
}
}
if let Some(value) = map.get("subscriptionType").and_then(Value::as_str) {
return normalize_event_type(value);
}
if let Some(key) = sole_content_key(map).filter(|key| !PAYLOAD_KEYS.contains(&key.as_str())) {
return normalize_event_type(key);
}
"unknown".to_string()
}
fn normalize_event_type(raw: &str) -> String {
match raw.strip_prefix("SUBSCRIPTION_TYPE_") {
Some(rest) => rest.to_ascii_lowercase(),
None => raw.to_string(),
}
}
fn extract_payload(map: &Map<String, Value>) -> Value {
for key in PAYLOAD_KEYS {
if let Some(value) = map.get(*key) {
return value.clone();
}
}
if let Some(key) = sole_content_key(map) {
return map.get(key).cloned().unwrap_or(Value::Null);
}
Value::Object(map.clone())
}
fn next_request_id(prefix: &str) -> String {
let ordinal = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{prefix}-{}-{ordinal}", unix_timestamp_millis())
}
fn normalize_stream_url(url: String, endpoint: StreamEndpoint) -> String {
let trimmed = url.trim_end_matches('/');
let with_scheme = if trimmed.starts_with("ws://") || trimmed.starts_with("wss://") {
trimmed.to_string()
} else if let Some(rest) = trimmed.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = trimmed.strip_prefix("http://") {
format!("ws://{rest}")
} else {
format!("wss://{trimmed}")
};
let authority_start = with_scheme
.find("://")
.map(|index| index + 3)
.unwrap_or_default();
let has_path = with_scheme[authority_start..].contains('/');
if has_path {
with_scheme
} else {
format!("{with_scheme}{}", endpoint.path())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn reconnect_delay_caps_at_max() {
let policy = ReconnectConfig {
enabled: true,
max_attempts: None,
initial_delay: Duration::from_millis(250),
max_delay: Duration::from_secs(1),
multiplier: 3.0,
};
assert_eq!(policy.delay_for_attempt(0), Duration::from_millis(250));
assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(250));
assert_eq!(policy.delay_for_attempt(2), Duration::from_millis(750));
assert_eq!(policy.delay_for_attempt(3), Duration::from_secs(1));
assert_eq!(policy.delay_for_attempt(10), Duration::from_secs(1));
}
#[test]
fn subscribe_frame_matches_the_documented_contract() {
let subscription =
MarketSubscription::market_data(["btc-100k-2025"]).with_request_id("md-sub-1");
assert_eq!(
subscription.frame(),
json!({
"subscribe": {
"requestId": "md-sub-1",
"subscriptionType": "SUBSCRIPTION_TYPE_MARKET_DATA",
"marketSlugs": ["btc-100k-2025"],
}
})
);
}
#[test]
fn subscribe_frame_carries_no_undocumented_fields() {
let frame = MarketSubscription::trades(["btc-100k-2025"]).frame();
let body = frame["subscribe"].as_object().expect("subscribe object");
let mut keys: Vec<&str> = body.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys, ["marketSlugs", "requestId", "subscriptionType"]);
}
#[test]
fn private_subscribe_frame_omits_empty_market_slugs() {
let frame = PrivateSubscription::orders().with_request_id("p-1").frame();
assert_eq!(
frame,
json!({
"subscribe": {
"requestId": "p-1",
"subscriptionType": "SUBSCRIPTION_TYPE_ORDER",
}
})
);
}
#[test]
fn multiple_market_slugs_serialize_as_an_array() {
let frame = MarketSubscription::market_data_lite(["a-market", "b-market"])
.add_market_slug("c-market")
.frame();
assert_eq!(
frame["subscribe"]["marketSlugs"],
json!(["a-market", "b-market", "c-market"])
);
}
#[test]
fn extras_are_only_added_when_asked_for() {
let frame = MarketSubscription::market_data(["x"])
.insert_extra("bookLevels", json!(2))
.frame();
assert_eq!(frame["subscribe"]["bookLevels"], 2);
}
#[test]
fn subscription_type_round_trips_through_the_wire_form() {
for variant in [
SubscriptionType::MarketData,
SubscriptionType::MarketDataLite,
SubscriptionType::Trade,
SubscriptionType::Order,
SubscriptionType::Position,
SubscriptionType::AccountBalance,
] {
assert_eq!(SubscriptionType::from_wire(variant.as_wire()), variant);
assert!(variant.as_wire().starts_with("SUBSCRIPTION_TYPE_"));
}
assert_eq!(
SubscriptionType::from_wire("SUBSCRIPTION_TYPE_FUTURE"),
SubscriptionType::Other("SUBSCRIPTION_TYPE_FUTURE".to_string())
);
}
#[test]
fn custom_subscription_type_is_sent_verbatim() {
let frame = MarketSubscription::custom("SUBSCRIPTION_TYPE_FUTURE")
.with_market_slugs(["x"])
.frame();
assert_eq!(
frame["subscribe"]["subscriptionType"],
"SUBSCRIPTION_TYPE_FUTURE"
);
}
#[test]
fn subscription_types_know_their_endpoint() {
assert_eq!(
SubscriptionType::Trade.endpoint(),
Some(StreamEndpoint::Markets)
);
assert_eq!(
SubscriptionType::AccountBalance.endpoint(),
Some(StreamEndpoint::Private)
);
assert_eq!(SubscriptionType::Other("X".into()).endpoint(), None);
}
#[test]
fn a_private_type_is_rejected_on_the_markets_socket() {
let smuggled = MarketSubscription::custom("SUBSCRIPTION_TYPE_ORDER");
let err = smuggled
.0
.validate(StreamEndpoint::Markets)
.expect_err("should be rejected");
assert!(
err.to_string().contains("private"),
"error should name the right endpoint: {err}"
);
}
#[test]
fn market_data_without_a_slug_is_rejected_before_connecting() {
let err = MarketSubscription::market_data(Vec::<String>::new())
.0
.validate(StreamEndpoint::Markets)
.expect_err("should be rejected");
assert!(err.to_string().contains("market slug"), "got: {err}");
}
#[test]
fn private_subscriptions_need_no_slug() {
assert!(PrivateSubscription::positions()
.0
.validate(StreamEndpoint::Private)
.is_ok());
}
#[test]
fn parses_a_frame_keyed_by_subscription_type() {
let message = parse_stream_message(json!({
"requestId": "md-sub-1",
"subscriptionType": "SUBSCRIPTION_TYPE_MARKET_DATA",
"data": { "bids": [1, 2], "asks": [3, 4] }
}))
.expect("message");
assert_eq!(message.request_id.as_deref(), Some("md-sub-1"));
match message.kind {
StreamMessageKind::Data(StreamDataEvent::MarketData(payload)) => {
assert_eq!(payload["bids"][0], 1);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn parses_a_frame_wrapped_in_a_named_envelope() {
let message = parse_stream_message(json!({
"requestId": "md-sub-2",
"marketDataLite": { "bid": "0.50", "ask": "0.55" }
}))
.expect("message");
assert_eq!(message.request_id.as_deref(), Some("md-sub-2"));
match message.kind {
StreamMessageKind::Data(StreamDataEvent::MarketDataLite(payload)) => {
assert_eq!(payload["bid"], "0.50");
}
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn parses_order_snapshot_event() {
let message = parse_stream_message(json!({
"event": "order_snapshot",
"requestId": "abc-123",
"data": { "orders": [] }
}))
.expect("message");
assert_eq!(message.request_id.as_deref(), Some("abc-123"));
assert!(matches!(
message.kind,
StreamMessageKind::Data(StreamDataEvent::OrderSnapshot(_))
));
}
#[test]
fn parses_account_balance_event() {
let message = parse_stream_message(json!({
"subscriptionType": "SUBSCRIPTION_TYPE_ACCOUNT_BALANCE",
"data": { "currency": "USD", "balance": "1000.00" }
}))
.expect("message");
assert!(
matches!(
message.kind,
StreamMessageKind::Data(StreamDataEvent::BalanceUpdate(_))
),
"expected BalanceUpdate, got {:?}",
message.kind
);
}
#[test]
fn parses_position_event() {
let message = parse_stream_message(json!({
"subscriptionType": "SUBSCRIPTION_TYPE_POSITION",
"data": { "positions": [] }
}))
.expect("message");
assert!(matches!(
message.kind,
StreamMessageKind::Data(StreamDataEvent::PositionUpdate(_))
));
}
#[test]
fn parses_trade_event() {
let message = parse_stream_message(json!({
"event": "trade",
"data": { "price": "0.55", "size": "100" }
}))
.expect("message");
assert!(matches!(
message.kind,
StreamMessageKind::Data(StreamDataEvent::Trade(_))
));
}
#[test]
fn parses_heartbeat_event() {
let message = parse_stream_message(json!({ "event": "heartbeat" })).expect("message");
assert!(matches!(
message.kind,
StreamMessageKind::Data(StreamDataEvent::Heartbeat)
));
}
#[test]
fn parses_subscription_ack() {
let message = parse_stream_message(json!({
"subscribed": { "requestId": "md-sub-1" }
}))
.expect("message");
assert!(matches!(
message.kind,
StreamMessageKind::Control(StreamControlEvent::SubscriptionAck { .. })
));
}
#[test]
fn parses_server_error() {
let message = parse_stream_message(json!({ "error": "invalid_message" })).expect("message");
match message.kind {
StreamMessageKind::Control(StreamControlEvent::Error(err)) => {
assert!(err.contains("invalid_message"));
}
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn endpoints_have_the_documented_paths() {
assert_eq!(
StreamEndpoint::Markets.default_url(),
"wss://api.polymarket.us/v1/ws/markets"
);
assert_eq!(
StreamEndpoint::Private.default_url(),
"wss://api.polymarket.us/v1/ws/private"
);
}
#[test]
fn clients_default_to_their_own_endpoint() {
assert_eq!(
MarketStreamClient::new(None).base_url(),
"wss://api.polymarket.us/v1/ws/markets"
);
}
#[test]
fn a_host_only_base_url_gets_the_endpoint_path() {
assert_eq!(
normalize_stream_url(
"https://staging.example.com".to_string(),
StreamEndpoint::Private
),
"wss://staging.example.com/v1/ws/private"
);
assert_eq!(
normalize_stream_url("ws://127.0.0.1:8080".to_string(), StreamEndpoint::Markets),
"ws://127.0.0.1:8080/v1/ws/markets"
);
}
#[test]
fn an_explicit_path_is_left_alone() {
assert_eq!(
normalize_stream_url(
"wss://custom.example/socket".to_string(),
StreamEndpoint::Markets
),
"wss://custom.example/socket"
);
}
}