1use crate::auth::{unix_timestamp_millis, UsAuth};
2use crate::error::PolymarketUsError;
3use futures_util::{SinkExt, StreamExt};
4use http::HeaderValue;
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use serde_json::{Map, Value};
7use std::future::Future;
8use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::{mpsc, Notify};
12use tokio_tungstenite::{
13 connect_async,
14 tungstenite::{client::IntoClientRequest, Message},
15};
16
17static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1);
18
19const DEFAULT_STREAM_HOST: &str = "wss://api.polymarket.us";
22
23type WebSocket =
24 tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum StreamEndpoint {
37 Markets,
39 Private,
41}
42
43impl StreamEndpoint {
44 pub fn path(self) -> &'static str {
46 match self {
47 Self::Markets => "/v1/ws/markets",
48 Self::Private => "/v1/ws/private",
49 }
50 }
51
52 pub fn default_url(self) -> String {
54 format!("{DEFAULT_STREAM_HOST}{}", self.path())
55 }
56}
57
58impl std::fmt::Display for StreamEndpoint {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.write_str(match self {
61 Self::Markets => "markets",
62 Self::Private => "private",
63 })
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Hash)]
77#[non_exhaustive]
78pub enum SubscriptionType {
79 MarketData,
81 MarketDataLite,
83 Trade,
85 Order,
87 Position,
89 AccountBalance,
91 Other(String),
97}
98
99impl SubscriptionType {
100 pub fn as_wire(&self) -> &str {
102 match self {
103 Self::MarketData => "SUBSCRIPTION_TYPE_MARKET_DATA",
104 Self::MarketDataLite => "SUBSCRIPTION_TYPE_MARKET_DATA_LITE",
105 Self::Trade => "SUBSCRIPTION_TYPE_TRADE",
106 Self::Order => "SUBSCRIPTION_TYPE_ORDER",
107 Self::Position => "SUBSCRIPTION_TYPE_POSITION",
108 Self::AccountBalance => "SUBSCRIPTION_TYPE_ACCOUNT_BALANCE",
109 Self::Other(raw) => raw,
110 }
111 }
112
113 pub fn from_wire(raw: &str) -> Self {
116 match raw {
117 "SUBSCRIPTION_TYPE_MARKET_DATA" => Self::MarketData,
118 "SUBSCRIPTION_TYPE_MARKET_DATA_LITE" => Self::MarketDataLite,
119 "SUBSCRIPTION_TYPE_TRADE" => Self::Trade,
120 "SUBSCRIPTION_TYPE_ORDER" => Self::Order,
121 "SUBSCRIPTION_TYPE_POSITION" => Self::Position,
122 "SUBSCRIPTION_TYPE_ACCOUNT_BALANCE" => Self::AccountBalance,
123 other => Self::Other(other.to_string()),
124 }
125 }
126
127 pub fn endpoint(&self) -> Option<StreamEndpoint> {
129 match self {
130 Self::MarketData | Self::MarketDataLite | Self::Trade => Some(StreamEndpoint::Markets),
131 Self::Order | Self::Position | Self::AccountBalance => Some(StreamEndpoint::Private),
132 Self::Other(_) => None,
133 }
134 }
135
136 fn requires_market_slugs(&self) -> bool {
138 matches!(self, Self::MarketData | Self::MarketDataLite | Self::Trade)
139 }
140}
141
142impl std::fmt::Display for SubscriptionType {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 f.write_str(self.as_wire())
145 }
146}
147
148impl Serialize for SubscriptionType {
149 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
150 serializer.serialize_str(self.as_wire())
151 }
152}
153
154impl<'de> Deserialize<'de> for SubscriptionType {
155 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
156 let raw = String::deserialize(deserializer)?;
157 Ok(Self::from_wire(&raw))
158 }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct Subscription {
175 pub request_id: String,
178 pub subscription_type: SubscriptionType,
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 pub market_slugs: Vec<String>,
181 #[serde(default, flatten)]
182 pub extra: Map<String, Value>,
183}
184
185impl Subscription {
186 fn new(subscription_type: SubscriptionType) -> Self {
187 Self {
188 request_id: next_request_id("sub"),
189 subscription_type,
190 market_slugs: Vec::new(),
191 extra: Map::new(),
192 }
193 }
194
195 fn frame(&self) -> Value {
197 serde_json::json!({ "subscribe": self })
198 }
199
200 fn validate(&self, endpoint: StreamEndpoint) -> Result<(), PolymarketUsError> {
201 if let Some(required) = self.subscription_type.endpoint() {
202 if required != endpoint {
203 return Err(PolymarketUsError::InvalidStreamConfig(format!(
204 "{} is served by the {required} endpoint, not {endpoint}",
205 self.subscription_type
206 )));
207 }
208 }
209
210 if self.subscription_type.requires_market_slugs() && self.market_slugs.is_empty() {
211 return Err(PolymarketUsError::InvalidStreamConfig(format!(
212 "{} requires at least one market slug",
213 self.subscription_type
214 )));
215 }
216
217 Ok(())
218 }
219}
220
221macro_rules! subscription_accessors {
223 ($ty:ident) => {
224 impl $ty {
225 pub fn request_id(&self) -> &str {
228 &self.0.request_id
229 }
230
231 pub fn subscription_type(&self) -> &SubscriptionType {
232 &self.0.subscription_type
233 }
234
235 pub fn market_slugs(&self) -> &[String] {
236 &self.0.market_slugs
237 }
238
239 pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
241 self.0.request_id = request_id.into();
242 self
243 }
244
245 pub fn with_market_slugs<I, S>(mut self, slugs: I) -> Self
247 where
248 I: IntoIterator<Item = S>,
249 S: Into<String>,
250 {
251 self.0.market_slugs = slugs.into_iter().map(Into::into).collect();
252 self
253 }
254
255 pub fn add_market_slug(mut self, slug: impl Into<String>) -> Self {
257 self.0.market_slugs.push(slug.into());
258 self
259 }
260
261 pub fn insert_extra(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
267 self.0.extra.insert(key.into(), value.into());
268 self
269 }
270
271 pub fn frame(&self) -> Value {
274 self.0.frame()
275 }
276 }
277 };
278}
279
280#[derive(Debug, Clone)]
282pub struct MarketSubscription(Subscription);
283
284impl MarketSubscription {
285 pub fn market_data<I, S>(market_slugs: I) -> Self
287 where
288 I: IntoIterator<Item = S>,
289 S: Into<String>,
290 {
291 Self(Subscription::new(SubscriptionType::MarketData)).with_market_slugs(market_slugs)
292 }
293
294 pub fn market_data_lite<I, S>(market_slugs: I) -> Self
296 where
297 I: IntoIterator<Item = S>,
298 S: Into<String>,
299 {
300 Self(Subscription::new(SubscriptionType::MarketDataLite)).with_market_slugs(market_slugs)
301 }
302
303 pub fn trades<I, S>(market_slugs: I) -> Self
305 where
306 I: IntoIterator<Item = S>,
307 S: Into<String>,
308 {
309 Self(Subscription::new(SubscriptionType::Trade)).with_market_slugs(market_slugs)
310 }
311
312 pub fn custom(subscription_type: impl Into<String>) -> Self {
319 Self(Subscription::new(SubscriptionType::from_wire(
320 &subscription_type.into(),
321 )))
322 }
323}
324
325#[derive(Debug, Clone)]
327pub struct PrivateSubscription(Subscription);
328
329impl PrivateSubscription {
330 pub fn orders() -> Self {
333 Self(Subscription::new(SubscriptionType::Order))
334 }
335
336 pub fn positions() -> Self {
338 Self(Subscription::new(SubscriptionType::Position))
339 }
340
341 pub fn account_balances() -> Self {
343 Self(Subscription::new(SubscriptionType::AccountBalance))
344 }
345
346 pub fn custom(subscription_type: impl Into<String>) -> Self {
352 Self(Subscription::new(SubscriptionType::from_wire(
353 &subscription_type.into(),
354 )))
355 }
356}
357
358subscription_accessors!(MarketSubscription);
359subscription_accessors!(PrivateSubscription);
360
361#[derive(Clone)]
367pub struct MarketStreamClient {
368 base_url: String,
369 auth: Option<UsAuth>,
370}
371
372#[derive(Clone)]
377pub struct PrivateStreamClient {
378 base_url: String,
379 auth: UsAuth,
380}
381
382impl MarketStreamClient {
383 pub fn new(auth: Option<UsAuth>) -> Self {
389 Self {
390 base_url: StreamEndpoint::Markets.default_url(),
391 auth,
392 }
393 }
394
395 pub fn with_base_url(base_url: impl Into<String>, auth: Option<UsAuth>) -> Self {
401 Self {
402 base_url: normalize_stream_url(base_url.into(), StreamEndpoint::Markets),
403 auth,
404 }
405 }
406
407 pub fn base_url(&self) -> &str {
408 &self.base_url
409 }
410
411 pub async fn connect(
412 &self,
413 subscriptions: Vec<MarketSubscription>,
414 ) -> Result<MarketStream, PolymarketUsError> {
415 self.connect_with_config(subscriptions, StreamConnectConfig::default())
416 .await
417 }
418
419 pub async fn connect_with_config(
420 &self,
421 subscriptions: Vec<MarketSubscription>,
422 config: StreamConnectConfig,
423 ) -> Result<MarketStream, PolymarketUsError> {
424 let inner = spawn_stream(
425 self.base_url.clone(),
426 self.auth.clone(),
427 StreamEndpoint::Markets,
428 subscriptions.into_iter().map(|sub| sub.0).collect(),
429 config,
430 )?;
431 Ok(MarketStream { inner })
432 }
433
434 pub async fn run<F, Fut>(
436 &self,
437 subscriptions: Vec<MarketSubscription>,
438 config: StreamConnectConfig,
439 mut on_message: F,
440 ) -> Result<(), PolymarketUsError>
441 where
442 F: FnMut(StreamMessage) -> Fut,
443 Fut: Future<Output = ()>,
444 {
445 let mut stream = self.connect_with_config(subscriptions, config).await?;
446 while let Some(message) = stream.next().await {
447 on_message(message).await;
448 }
449 Ok(())
450 }
451}
452
453impl PrivateStreamClient {
454 pub fn new(auth: UsAuth) -> Self {
456 Self {
457 base_url: StreamEndpoint::Private.default_url(),
458 auth,
459 }
460 }
461
462 pub fn with_base_url(base_url: impl Into<String>, auth: UsAuth) -> Self {
465 Self {
466 base_url: normalize_stream_url(base_url.into(), StreamEndpoint::Private),
467 auth,
468 }
469 }
470
471 pub fn base_url(&self) -> &str {
472 &self.base_url
473 }
474
475 pub async fn connect(
476 &self,
477 subscriptions: Vec<PrivateSubscription>,
478 ) -> Result<PrivateStream, PolymarketUsError> {
479 self.connect_with_config(subscriptions, StreamConnectConfig::default())
480 .await
481 }
482
483 pub async fn connect_with_config(
484 &self,
485 subscriptions: Vec<PrivateSubscription>,
486 config: StreamConnectConfig,
487 ) -> Result<PrivateStream, PolymarketUsError> {
488 let inner = spawn_stream(
489 self.base_url.clone(),
490 Some(self.auth.clone()),
491 StreamEndpoint::Private,
492 subscriptions.into_iter().map(|sub| sub.0).collect(),
493 config,
494 )?;
495 Ok(PrivateStream { inner })
496 }
497
498 pub async fn run<F, Fut>(
500 &self,
501 subscriptions: Vec<PrivateSubscription>,
502 config: StreamConnectConfig,
503 mut on_message: F,
504 ) -> Result<(), PolymarketUsError>
505 where
506 F: FnMut(StreamMessage) -> Fut,
507 Fut: Future<Output = ()>,
508 {
509 let mut stream = self.connect_with_config(subscriptions, config).await?;
510 while let Some(message) = stream.next().await {
511 on_message(message).await;
512 }
513 Ok(())
514 }
515}
516
517fn spawn_stream(
518 base_url: String,
519 auth: Option<UsAuth>,
520 endpoint: StreamEndpoint,
521 subscriptions: Vec<Subscription>,
522 config: StreamConnectConfig,
523) -> Result<StreamHandle, PolymarketUsError> {
524 if subscriptions.is_empty() {
525 return Err(PolymarketUsError::InvalidStreamConfig(
526 "at least one subscription is required".to_string(),
527 ));
528 }
529
530 for subscription in &subscriptions {
531 subscription.validate(endpoint)?;
532 }
533
534 let (tx, rx) = mpsc::channel(256);
535 let (cmd_tx, cmd_rx) = mpsc::channel(64);
536 let shutdown = Arc::new(StreamShutdown::new());
537 let shutdown_task = shutdown.clone();
538
539 tokio::spawn(async move {
540 let runner = StreamRunner {
541 base_url,
542 auth,
543 subscriptions,
544 config,
545 tx,
546 shutdown: shutdown_task,
547 cmd_rx,
548 };
549 runner.run().await;
550 });
551
552 Ok(StreamHandle {
553 receiver: rx,
554 shutdown,
555 cmd_tx,
556 endpoint,
557 })
558}
559
560struct StreamHandle {
565 receiver: mpsc::Receiver<StreamMessage>,
566 shutdown: Arc<StreamShutdown>,
567 cmd_tx: mpsc::Sender<StreamCommand>,
568 endpoint: StreamEndpoint,
569}
570
571impl StreamHandle {
572 async fn subscribe(&self, subscription: Subscription) -> Result<(), PolymarketUsError> {
573 subscription.validate(self.endpoint)?;
574 self.cmd_tx
575 .send(StreamCommand::Subscribe(subscription))
576 .await
577 .map_err(|_| PolymarketUsError::InvalidStreamConfig("stream is closed".to_string()))
578 }
579}
580
581macro_rules! stream_handle {
584 ($ty:ident, $sub:ident, $endpoint:expr) => {
585 impl $ty {
586 pub async fn next(&mut self) -> Option<StreamMessage> {
588 self.inner.receiver.recv().await
589 }
590
591 pub fn endpoint(&self) -> StreamEndpoint {
593 $endpoint
594 }
595
596 pub fn shutdown(&self) {
598 self.inner.shutdown.shutdown();
599 }
600
601 pub fn is_shutdown(&self) -> bool {
602 self.inner.shutdown.is_shutdown()
603 }
604
605 pub async fn subscribe(&self, subscription: $sub) -> Result<(), PolymarketUsError> {
610 self.inner.subscribe(subscription.0).await
611 }
612
613 pub async fn unsubscribe(&self, request_id: &str) -> Result<(), PolymarketUsError> {
618 self.inner
619 .cmd_tx
620 .send(StreamCommand::Unsubscribe(request_id.to_string()))
621 .await
622 .map_err(|_| {
623 PolymarketUsError::InvalidStreamConfig("stream is closed".to_string())
624 })
625 }
626 }
627 };
628}
629
630pub struct MarketStream {
632 inner: StreamHandle,
633}
634
635pub struct PrivateStream {
637 inner: StreamHandle,
638}
639
640stream_handle!(MarketStream, MarketSubscription, StreamEndpoint::Markets);
641stream_handle!(PrivateStream, PrivateSubscription, StreamEndpoint::Private);
642
643enum StreamCommand {
648 Subscribe(Subscription),
649 Unsubscribe(String), }
651
652#[derive(Debug, Clone)]
653pub struct StreamConnectConfig {
654 pub session_id: String,
657 pub reconnect: ReconnectConfig,
658
659 pub idle_timeout: Option<Duration>,
666
667 pub keepalive_interval: Option<Duration>,
673}
674
675impl Default for StreamConnectConfig {
676 fn default() -> Self {
677 Self {
678 session_id: next_request_id("session"),
679 reconnect: ReconnectConfig::default(),
680 idle_timeout: Some(Duration::from_secs(60)),
681 keepalive_interval: Some(Duration::from_secs(20)),
682 }
683 }
684}
685
686impl StreamConnectConfig {
687 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
688 self.session_id = session_id.into();
689 self
690 }
691
692 pub fn with_reconnect(mut self, reconnect: ReconnectConfig) -> Self {
693 self.reconnect = reconnect;
694 self
695 }
696
697 pub fn with_idle_timeout(mut self, idle_timeout: Option<Duration>) -> Self {
699 self.idle_timeout = idle_timeout;
700 self
701 }
702
703 pub fn with_keepalive_interval(mut self, keepalive_interval: Option<Duration>) -> Self {
705 self.keepalive_interval = keepalive_interval;
706 self
707 }
708}
709
710#[derive(Debug, Clone)]
711pub struct ReconnectConfig {
712 pub enabled: bool,
713 pub max_attempts: Option<usize>,
714 pub initial_delay: Duration,
715 pub max_delay: Duration,
716 pub multiplier: f64,
717}
718
719impl Default for ReconnectConfig {
720 fn default() -> Self {
721 Self {
722 enabled: true,
723 max_attempts: None,
724 initial_delay: Duration::from_millis(250),
725 max_delay: Duration::from_secs(10),
726 multiplier: 2.0,
727 }
728 }
729}
730
731impl ReconnectConfig {
732 pub fn disabled() -> Self {
733 Self {
734 enabled: false,
735 ..Self::default()
736 }
737 }
738
739 pub fn delay_for_attempt(&self, attempt: usize) -> Duration {
740 if attempt == 0 {
741 return self.initial_delay.min(self.max_delay);
742 }
743
744 let scaled = self
745 .initial_delay
746 .mul_f64(self.multiplier.powi(attempt.saturating_sub(1) as i32));
747 scaled.min(self.max_delay)
748 }
749}
750
751#[derive(Debug, Clone)]
756pub struct StreamMessage {
757 pub request_id: Option<String>,
760 pub kind: StreamMessageKind,
761}
762
763#[derive(Debug, Clone)]
764#[non_exhaustive]
765pub enum StreamMessageKind {
766 Data(StreamDataEvent),
767 Control(StreamControlEvent),
768}
769
770#[derive(Debug, Clone)]
771#[non_exhaustive]
772pub enum StreamDataEvent {
773 OrderSnapshot(Value),
775 OrderUpdate(Value),
777 MarketData(Value),
779 MarketDataLite(Value),
781 OrderBookDelta(Value),
783 PositionSnapshot(Value),
785 PositionUpdate(Value),
787 BalanceSnapshot(Value),
789 BalanceUpdate(Value),
791 Trade(Value),
793 Heartbeat,
795 Other { event_type: String, payload: Value },
797}
798
799#[derive(Debug, Clone)]
800#[non_exhaustive]
801pub enum StreamControlEvent {
802 Connected { session_id: String },
803 SubscriptionAck { event_type: String, payload: Value },
804 Reconnecting { attempt: usize, delay_ms: u64 },
805 Closed,
806 Error(String),
807}
808
809impl StreamMessage {
810 pub fn control(request_id: Option<String>, event: StreamControlEvent) -> Self {
811 Self {
812 request_id,
813 kind: StreamMessageKind::Control(event),
814 }
815 }
816
817 pub fn data(request_id: Option<String>, event: StreamDataEvent) -> Self {
818 Self {
819 request_id,
820 kind: StreamMessageKind::Data(event),
821 }
822 }
823}
824
825struct StreamRunner {
830 base_url: String,
831 auth: Option<UsAuth>,
832 subscriptions: Vec<Subscription>,
833 config: StreamConnectConfig,
834 tx: mpsc::Sender<StreamMessage>,
835 shutdown: Arc<StreamShutdown>,
836 cmd_rx: mpsc::Receiver<StreamCommand>,
837}
838
839impl StreamRunner {
840 async fn run(mut self) {
841 let mut attempt = 0usize;
842
843 loop {
844 if self.shutdown.is_shutdown() || self.tx.is_closed() {
845 break;
846 }
847
848 match self.connect_and_consume().await {
849 Ok(()) => {
850 if !self.config.reconnect.enabled {
851 break;
852 }
853 }
854 Err(err) => {
855 if !self
856 .emit(StreamMessage::control(
857 Some(self.config.session_id.clone()),
858 StreamControlEvent::Error(err.to_string()),
859 ))
860 .await
861 {
862 break;
863 }
864 }
865 }
866
867 if !self.config.reconnect.enabled {
868 break;
869 }
870
871 attempt += 1;
872 if let Some(max_attempts) = self.config.reconnect.max_attempts {
873 if attempt > max_attempts {
874 break;
875 }
876 }
877
878 let delay = self.config.reconnect.delay_for_attempt(attempt);
879 if !self
880 .emit(StreamMessage::control(
881 Some(self.config.session_id.clone()),
882 StreamControlEvent::Reconnecting {
883 attempt,
884 delay_ms: delay.as_millis() as u64,
885 },
886 ))
887 .await
888 {
889 break;
890 }
891
892 let shutdown = Arc::clone(&self.shutdown);
893 tokio::select! {
894 _ = shutdown.notified() => break,
895 _ = tokio::time::sleep(delay) => {}
896 }
897 }
898
899 let _ = self
900 .emit(StreamMessage::control(
901 Some(self.config.session_id.clone()),
902 StreamControlEvent::Closed,
903 ))
904 .await;
905 }
906
907 async fn connect_and_consume(&mut self) -> Result<(), PolymarketUsError> {
908 let mut request = self
909 .base_url
910 .as_str()
911 .into_client_request()
912 .map_err(|err| {
913 PolymarketUsError::InvalidStreamConfig(format!(
914 "invalid websocket URL {}: {err}",
915 self.base_url
916 ))
917 })?;
918
919 if let Some(auth) = &self.auth {
920 let path = request
921 .uri()
922 .path_and_query()
923 .map(|path| path.as_str())
924 .unwrap_or("/");
925 for (name, value) in auth.signed_headers("GET", path) {
926 let header_value = HeaderValue::from_str(&value).map_err(|err| {
927 PolymarketUsError::InvalidStreamConfig(format!(
928 "invalid websocket auth header value for {name}: {err}"
929 ))
930 })?;
931 request.headers_mut().insert(name, header_value);
932 }
933 }
934
935 let (mut websocket, _) = connect_async(request).await?;
936 let _ = self
937 .emit(StreamMessage::control(
938 Some(self.config.session_id.clone()),
939 StreamControlEvent::Connected {
940 session_id: self.config.session_id.clone(),
941 },
942 ))
943 .await;
944
945 self.send_all_subscriptions(&mut websocket).await?;
946
947 let shutdown = Arc::clone(&self.shutdown);
950 let shutdown_wait = shutdown.notified();
951 tokio::pin!(shutdown_wait);
952
953 let idle_timeout = self.config.idle_timeout;
957 let idle_deadline =
958 tokio::time::sleep(idle_timeout.unwrap_or_else(|| Duration::from_secs(3600)));
959 tokio::pin!(idle_deadline);
960
961 let keepalive_interval = self.config.keepalive_interval;
964 let keepalive =
965 tokio::time::sleep(keepalive_interval.unwrap_or_else(|| Duration::from_secs(3600)));
966 tokio::pin!(keepalive);
967
968 loop {
969 tokio::select! {
970 _ = &mut shutdown_wait => {
971 let _ = websocket.close(None).await;
972 break;
973 }
974 _ = &mut idle_deadline, if idle_timeout.is_some() => {
975 let _ = websocket.close(None).await;
978 return Err(PolymarketUsError::StreamIdle(
979 idle_timeout.expect("guarded by idle_timeout.is_some()"),
980 ));
981 }
982 _ = &mut keepalive, if keepalive_interval.is_some() => {
983 let interval = keepalive_interval.expect("guarded by is_some()");
984 keepalive.as_mut().reset(tokio::time::Instant::now() + interval);
985 websocket.send(Message::Ping(Vec::new().into())).await?;
986 }
987 message = websocket.next() => {
988 if let Some(timeout) = idle_timeout {
990 idle_deadline.as_mut().reset(tokio::time::Instant::now() + timeout);
991 }
992
993 let Some(message) = message else {
994 break;
995 };
996
997 match message {
998 Ok(Message::Text(text)) => {
999 self.handle_text(&text).await?;
1000 }
1001 Ok(Message::Binary(bytes)) => {
1002 let text = String::from_utf8(bytes.to_vec()).map_err(|err| {
1003 PolymarketUsError::InvalidStreamConfig(format!(
1004 "received non-UTF8 websocket payload: {err}"
1005 ))
1006 })?;
1007 self.handle_text(&text).await?;
1008 }
1009 Ok(Message::Close(_)) => break,
1010 Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
1011 Ok(_) => {}
1012 Err(err) => return Err(err.into()),
1013 }
1014 }
1015 cmd = self.cmd_rx.recv() => {
1016 match cmd {
1017 Some(StreamCommand::Subscribe(sub)) => {
1018 self.send_subscription(&mut websocket, &sub).await?;
1019 self.subscriptions.push(sub);
1020 }
1021 Some(StreamCommand::Unsubscribe(request_id)) => {
1022 self.subscriptions.retain(|s| s.request_id != request_id);
1023 let frame = serde_json::json!({
1024 "unsubscribe": { "requestId": request_id },
1025 });
1026 let _ = websocket
1027 .send(Message::Text(frame.to_string().into()))
1028 .await;
1029 }
1030 None => break,
1031 }
1032 }
1033 }
1034 }
1035
1036 Ok(())
1037 }
1038
1039 async fn send_all_subscriptions(
1040 &self,
1041 websocket: &mut WebSocket,
1042 ) -> Result<(), PolymarketUsError> {
1043 for subscription in &self.subscriptions {
1044 self.send_subscription(websocket, subscription).await?;
1045 }
1046 Ok(())
1047 }
1048
1049 async fn send_subscription(
1050 &self,
1051 websocket: &mut WebSocket,
1052 subscription: &Subscription,
1053 ) -> Result<(), PolymarketUsError> {
1054 let payload = serde_json::to_string(&subscription.frame())?;
1055 websocket.send(Message::Text(payload.into())).await?;
1056 Ok(())
1057 }
1058
1059 async fn handle_text(&self, text: &str) -> Result<(), PolymarketUsError> {
1060 let json: Value = serde_json::from_str(text)?;
1061 if let Some(message) = parse_stream_message(json) {
1062 if !self.emit(message).await {
1063 return Ok(());
1064 }
1065 }
1066 Ok(())
1067 }
1068
1069 async fn emit(&self, message: StreamMessage) -> bool {
1070 self.tx.send(message).await.is_ok()
1071 }
1072}
1073
1074struct StreamShutdown {
1075 requested: AtomicBool,
1076 notify: Notify,
1077}
1078
1079impl StreamShutdown {
1080 fn new() -> Self {
1081 Self {
1082 requested: AtomicBool::new(false),
1083 notify: Notify::new(),
1084 }
1085 }
1086
1087 fn shutdown(&self) {
1088 if !self.requested.swap(true, Ordering::SeqCst) {
1089 self.notify.notify_waiters();
1090 }
1091 }
1092
1093 fn is_shutdown(&self) -> bool {
1094 self.requested.load(Ordering::SeqCst)
1095 }
1096
1097 fn notified(&self) -> impl Future<Output = ()> + '_ {
1098 self.notify.notified()
1099 }
1100}
1101
1102const ENVELOPE_META_KEYS: &[&str] = &[
1109 "requestId",
1110 "request_id",
1111 "trackingId",
1112 "tracking_id",
1113 "id",
1114 "timestamp",
1115 "ts",
1116 "time",
1117 "seq",
1118 "sequence",
1119 "type",
1120 "event",
1121 "channel",
1122 "topic",
1123 "name",
1124 "subscriptionType",
1125 "subscription_type",
1126];
1127
1128fn parse_stream_message(json: Value) -> Option<StreamMessage> {
1129 match json {
1130 Value::Object(map) => {
1131 let request_id = extract_request_id(&map);
1132 let event_type = extract_event_type(&map);
1133 let payload = extract_payload(&map);
1134
1135 let kind = match event_type.as_str() {
1136 "order_snapshot" | "orderSnapshot" => {
1138 StreamMessageKind::Data(StreamDataEvent::OrderSnapshot(payload))
1139 }
1140 "order" | "orders" | "order_update" | "order_updates" | "orderUpdate"
1141 | "user_order" | "fill" => {
1142 StreamMessageKind::Data(StreamDataEvent::OrderUpdate(payload))
1143 }
1144 "market_data" | "marketData" => {
1146 StreamMessageKind::Data(StreamDataEvent::MarketData(payload))
1147 }
1148 "market_data_lite" | "marketDataLite" => {
1149 StreamMessageKind::Data(StreamDataEvent::MarketDataLite(payload))
1150 }
1151 "order_book_delta" | "orderbook_delta" | "book_delta" | "bookDelta" => {
1152 StreamMessageKind::Data(StreamDataEvent::OrderBookDelta(payload))
1153 }
1154 "trade" | "trades" => StreamMessageKind::Data(StreamDataEvent::Trade(payload)),
1155 "position_snapshot" | "positionSnapshot" => {
1157 StreamMessageKind::Data(StreamDataEvent::PositionSnapshot(payload))
1158 }
1159 "position" | "positions" | "position_update" | "positionUpdate" => {
1160 StreamMessageKind::Data(StreamDataEvent::PositionUpdate(payload))
1161 }
1162 "balance_snapshot" | "balanceSnapshot" | "account_balance_snapshot" => {
1164 StreamMessageKind::Data(StreamDataEvent::BalanceSnapshot(payload))
1165 }
1166 "balance" | "balances" | "balance_update" | "balanceUpdate" | "account_balance"
1167 | "accountBalance" => {
1168 StreamMessageKind::Data(StreamDataEvent::BalanceUpdate(payload))
1169 }
1170 "heartbeat" | "ping" | "pong" => {
1172 StreamMessageKind::Data(StreamDataEvent::Heartbeat)
1173 }
1174 "subscription" | "subscribe" | "subscribed" | "subscribeAck" | "ack"
1176 | "unsubscribe" | "unsubscribed" => {
1177 StreamMessageKind::Control(StreamControlEvent::SubscriptionAck {
1178 event_type: event_type.clone(),
1179 payload,
1180 })
1181 }
1182 "error" => {
1183 StreamMessageKind::Control(StreamControlEvent::Error(payload.to_string()))
1184 }
1185 _ => StreamMessageKind::Data(StreamDataEvent::Other {
1186 event_type: event_type.clone(),
1187 payload,
1188 }),
1189 };
1190
1191 Some(StreamMessage { request_id, kind })
1192 }
1193 other => Some(StreamMessage::data(
1194 None,
1195 StreamDataEvent::Other {
1196 event_type: "unknown".to_string(),
1197 payload: other,
1198 },
1199 )),
1200 }
1201}
1202
1203fn extract_request_id(map: &Map<String, Value>) -> Option<String> {
1204 ["requestId", "request_id", "trackingId", "tracking_id", "id"]
1205 .iter()
1206 .find_map(|key| map.get(*key).and_then(Value::as_str).map(ToOwned::to_owned))
1207}
1208
1209const PAYLOAD_KEYS: &[&str] = &["data", "payload", "body", "message", "result"];
1213
1214fn sole_content_key(map: &Map<String, Value>) -> Option<&String> {
1217 let mut content = map
1218 .keys()
1219 .filter(|key| !ENVELOPE_META_KEYS.contains(&key.as_str()));
1220 let first = content.next()?;
1221 content.next().is_none().then_some(first)
1222}
1223
1224fn extract_event_type(map: &Map<String, Value>) -> String {
1225 for key in ["event", "type", "channel", "name", "topic"] {
1226 if let Some(value) = map.get(key).and_then(Value::as_str) {
1227 return normalize_event_type(value);
1228 }
1229 }
1230
1231 if let Some(value) = map.get("subscriptionType").and_then(Value::as_str) {
1232 return normalize_event_type(value);
1233 }
1234
1235 if let Some(key) = sole_content_key(map).filter(|key| !PAYLOAD_KEYS.contains(&key.as_str())) {
1237 return normalize_event_type(key);
1238 }
1239
1240 "unknown".to_string()
1241}
1242
1243fn normalize_event_type(raw: &str) -> String {
1246 match raw.strip_prefix("SUBSCRIPTION_TYPE_") {
1247 Some(rest) => rest.to_ascii_lowercase(),
1248 None => raw.to_string(),
1249 }
1250}
1251
1252fn extract_payload(map: &Map<String, Value>) -> Value {
1253 for key in PAYLOAD_KEYS {
1254 if let Some(value) = map.get(*key) {
1255 return value.clone();
1256 }
1257 }
1258
1259 if let Some(key) = sole_content_key(map) {
1260 return map.get(key).cloned().unwrap_or(Value::Null);
1261 }
1262
1263 Value::Object(map.clone())
1264}
1265
1266fn next_request_id(prefix: &str) -> String {
1271 let ordinal = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
1272 format!("{prefix}-{}-{ordinal}", unix_timestamp_millis())
1273}
1274
1275fn normalize_stream_url(url: String, endpoint: StreamEndpoint) -> String {
1281 let trimmed = url.trim_end_matches('/');
1282
1283 let with_scheme = if trimmed.starts_with("ws://") || trimmed.starts_with("wss://") {
1284 trimmed.to_string()
1285 } else if let Some(rest) = trimmed.strip_prefix("https://") {
1286 format!("wss://{rest}")
1287 } else if let Some(rest) = trimmed.strip_prefix("http://") {
1288 format!("ws://{rest}")
1289 } else {
1290 format!("wss://{trimmed}")
1291 };
1292
1293 let authority_start = with_scheme
1294 .find("://")
1295 .map(|index| index + 3)
1296 .unwrap_or_default();
1297 let has_path = with_scheme[authority_start..].contains('/');
1298
1299 if has_path {
1300 with_scheme
1301 } else {
1302 format!("{with_scheme}{}", endpoint.path())
1303 }
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308 use super::*;
1309 use serde_json::json;
1310
1311 #[test]
1312 fn reconnect_delay_caps_at_max() {
1313 let policy = ReconnectConfig {
1314 enabled: true,
1315 max_attempts: None,
1316 initial_delay: Duration::from_millis(250),
1317 max_delay: Duration::from_secs(1),
1318 multiplier: 3.0,
1319 };
1320
1321 assert_eq!(policy.delay_for_attempt(0), Duration::from_millis(250));
1322 assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(250));
1323 assert_eq!(policy.delay_for_attempt(2), Duration::from_millis(750));
1324 assert_eq!(policy.delay_for_attempt(3), Duration::from_secs(1));
1325 assert_eq!(policy.delay_for_attempt(10), Duration::from_secs(1));
1326 }
1327
1328 #[test]
1331 fn subscribe_frame_matches_the_documented_contract() {
1332 let subscription =
1333 MarketSubscription::market_data(["btc-100k-2025"]).with_request_id("md-sub-1");
1334
1335 assert_eq!(
1336 subscription.frame(),
1337 json!({
1338 "subscribe": {
1339 "requestId": "md-sub-1",
1340 "subscriptionType": "SUBSCRIPTION_TYPE_MARKET_DATA",
1341 "marketSlugs": ["btc-100k-2025"],
1342 }
1343 })
1344 );
1345 }
1346
1347 #[test]
1348 fn subscribe_frame_carries_no_undocumented_fields() {
1349 let frame = MarketSubscription::trades(["btc-100k-2025"]).frame();
1350 let body = frame["subscribe"].as_object().expect("subscribe object");
1351
1352 let mut keys: Vec<&str> = body.keys().map(String::as_str).collect();
1355 keys.sort_unstable();
1356 assert_eq!(keys, ["marketSlugs", "requestId", "subscriptionType"]);
1357 }
1358
1359 #[test]
1360 fn private_subscribe_frame_omits_empty_market_slugs() {
1361 let frame = PrivateSubscription::orders().with_request_id("p-1").frame();
1362 assert_eq!(
1363 frame,
1364 json!({
1365 "subscribe": {
1366 "requestId": "p-1",
1367 "subscriptionType": "SUBSCRIPTION_TYPE_ORDER",
1368 }
1369 })
1370 );
1371 }
1372
1373 #[test]
1374 fn multiple_market_slugs_serialize_as_an_array() {
1375 let frame = MarketSubscription::market_data_lite(["a-market", "b-market"])
1376 .add_market_slug("c-market")
1377 .frame();
1378 assert_eq!(
1379 frame["subscribe"]["marketSlugs"],
1380 json!(["a-market", "b-market", "c-market"])
1381 );
1382 }
1383
1384 #[test]
1385 fn extras_are_only_added_when_asked_for() {
1386 let frame = MarketSubscription::market_data(["x"])
1387 .insert_extra("bookLevels", json!(2))
1388 .frame();
1389 assert_eq!(frame["subscribe"]["bookLevels"], 2);
1390 }
1391
1392 #[test]
1393 fn subscription_type_round_trips_through_the_wire_form() {
1394 for variant in [
1395 SubscriptionType::MarketData,
1396 SubscriptionType::MarketDataLite,
1397 SubscriptionType::Trade,
1398 SubscriptionType::Order,
1399 SubscriptionType::Position,
1400 SubscriptionType::AccountBalance,
1401 ] {
1402 assert_eq!(SubscriptionType::from_wire(variant.as_wire()), variant);
1403 assert!(variant.as_wire().starts_with("SUBSCRIPTION_TYPE_"));
1404 }
1405
1406 assert_eq!(
1407 SubscriptionType::from_wire("SUBSCRIPTION_TYPE_FUTURE"),
1408 SubscriptionType::Other("SUBSCRIPTION_TYPE_FUTURE".to_string())
1409 );
1410 }
1411
1412 #[test]
1413 fn custom_subscription_type_is_sent_verbatim() {
1414 let frame = MarketSubscription::custom("SUBSCRIPTION_TYPE_FUTURE")
1415 .with_market_slugs(["x"])
1416 .frame();
1417 assert_eq!(
1418 frame["subscribe"]["subscriptionType"],
1419 "SUBSCRIPTION_TYPE_FUTURE"
1420 );
1421 }
1422
1423 #[test]
1426 fn subscription_types_know_their_endpoint() {
1427 assert_eq!(
1428 SubscriptionType::Trade.endpoint(),
1429 Some(StreamEndpoint::Markets)
1430 );
1431 assert_eq!(
1432 SubscriptionType::AccountBalance.endpoint(),
1433 Some(StreamEndpoint::Private)
1434 );
1435 assert_eq!(SubscriptionType::Other("X".into()).endpoint(), None);
1437 }
1438
1439 #[test]
1440 fn a_private_type_is_rejected_on_the_markets_socket() {
1441 let smuggled = MarketSubscription::custom("SUBSCRIPTION_TYPE_ORDER");
1444 let err = smuggled
1445 .0
1446 .validate(StreamEndpoint::Markets)
1447 .expect_err("should be rejected");
1448 assert!(
1449 err.to_string().contains("private"),
1450 "error should name the right endpoint: {err}"
1451 );
1452 }
1453
1454 #[test]
1455 fn market_data_without_a_slug_is_rejected_before_connecting() {
1456 let err = MarketSubscription::market_data(Vec::<String>::new())
1457 .0
1458 .validate(StreamEndpoint::Markets)
1459 .expect_err("should be rejected");
1460 assert!(err.to_string().contains("market slug"), "got: {err}");
1461 }
1462
1463 #[test]
1464 fn private_subscriptions_need_no_slug() {
1465 assert!(PrivateSubscription::positions()
1466 .0
1467 .validate(StreamEndpoint::Private)
1468 .is_ok());
1469 }
1470
1471 #[test]
1474 fn parses_a_frame_keyed_by_subscription_type() {
1475 let message = parse_stream_message(json!({
1476 "requestId": "md-sub-1",
1477 "subscriptionType": "SUBSCRIPTION_TYPE_MARKET_DATA",
1478 "data": { "bids": [1, 2], "asks": [3, 4] }
1479 }))
1480 .expect("message");
1481
1482 assert_eq!(message.request_id.as_deref(), Some("md-sub-1"));
1483 match message.kind {
1484 StreamMessageKind::Data(StreamDataEvent::MarketData(payload)) => {
1485 assert_eq!(payload["bids"][0], 1);
1486 }
1487 other => panic!("unexpected event: {other:?}"),
1488 }
1489 }
1490
1491 #[test]
1492 fn parses_a_frame_wrapped_in_a_named_envelope() {
1493 let message = parse_stream_message(json!({
1494 "requestId": "md-sub-2",
1495 "marketDataLite": { "bid": "0.50", "ask": "0.55" }
1496 }))
1497 .expect("message");
1498
1499 assert_eq!(message.request_id.as_deref(), Some("md-sub-2"));
1500 match message.kind {
1501 StreamMessageKind::Data(StreamDataEvent::MarketDataLite(payload)) => {
1502 assert_eq!(payload["bid"], "0.50");
1503 }
1504 other => panic!("unexpected event: {other:?}"),
1505 }
1506 }
1507
1508 #[test]
1509 fn parses_order_snapshot_event() {
1510 let message = parse_stream_message(json!({
1511 "event": "order_snapshot",
1512 "requestId": "abc-123",
1513 "data": { "orders": [] }
1514 }))
1515 .expect("message");
1516
1517 assert_eq!(message.request_id.as_deref(), Some("abc-123"));
1518 assert!(matches!(
1519 message.kind,
1520 StreamMessageKind::Data(StreamDataEvent::OrderSnapshot(_))
1521 ));
1522 }
1523
1524 #[test]
1525 fn parses_account_balance_event() {
1526 let message = parse_stream_message(json!({
1527 "subscriptionType": "SUBSCRIPTION_TYPE_ACCOUNT_BALANCE",
1528 "data": { "currency": "USD", "balance": "1000.00" }
1529 }))
1530 .expect("message");
1531 assert!(
1532 matches!(
1533 message.kind,
1534 StreamMessageKind::Data(StreamDataEvent::BalanceUpdate(_))
1535 ),
1536 "expected BalanceUpdate, got {:?}",
1537 message.kind
1538 );
1539 }
1540
1541 #[test]
1542 fn parses_position_event() {
1543 let message = parse_stream_message(json!({
1544 "subscriptionType": "SUBSCRIPTION_TYPE_POSITION",
1545 "data": { "positions": [] }
1546 }))
1547 .expect("message");
1548 assert!(matches!(
1549 message.kind,
1550 StreamMessageKind::Data(StreamDataEvent::PositionUpdate(_))
1551 ));
1552 }
1553
1554 #[test]
1555 fn parses_trade_event() {
1556 let message = parse_stream_message(json!({
1557 "event": "trade",
1558 "data": { "price": "0.55", "size": "100" }
1559 }))
1560 .expect("message");
1561 assert!(matches!(
1562 message.kind,
1563 StreamMessageKind::Data(StreamDataEvent::Trade(_))
1564 ));
1565 }
1566
1567 #[test]
1568 fn parses_heartbeat_event() {
1569 let message = parse_stream_message(json!({ "event": "heartbeat" })).expect("message");
1570 assert!(matches!(
1571 message.kind,
1572 StreamMessageKind::Data(StreamDataEvent::Heartbeat)
1573 ));
1574 }
1575
1576 #[test]
1577 fn parses_subscription_ack() {
1578 let message = parse_stream_message(json!({
1579 "subscribed": { "requestId": "md-sub-1" }
1580 }))
1581 .expect("message");
1582 assert!(matches!(
1583 message.kind,
1584 StreamMessageKind::Control(StreamControlEvent::SubscriptionAck { .. })
1585 ));
1586 }
1587
1588 #[test]
1589 fn parses_server_error() {
1590 let message = parse_stream_message(json!({ "error": "invalid_message" })).expect("message");
1591 match message.kind {
1592 StreamMessageKind::Control(StreamControlEvent::Error(err)) => {
1593 assert!(err.contains("invalid_message"));
1594 }
1595 other => panic!("unexpected event: {other:?}"),
1596 }
1597 }
1598
1599 #[test]
1602 fn endpoints_have_the_documented_paths() {
1603 assert_eq!(
1604 StreamEndpoint::Markets.default_url(),
1605 "wss://api.polymarket.us/v1/ws/markets"
1606 );
1607 assert_eq!(
1608 StreamEndpoint::Private.default_url(),
1609 "wss://api.polymarket.us/v1/ws/private"
1610 );
1611 }
1612
1613 #[test]
1614 fn clients_default_to_their_own_endpoint() {
1615 assert_eq!(
1616 MarketStreamClient::new(None).base_url(),
1617 "wss://api.polymarket.us/v1/ws/markets"
1618 );
1619 }
1620
1621 #[test]
1622 fn a_host_only_base_url_gets_the_endpoint_path() {
1623 assert_eq!(
1624 normalize_stream_url(
1625 "https://staging.example.com".to_string(),
1626 StreamEndpoint::Private
1627 ),
1628 "wss://staging.example.com/v1/ws/private"
1629 );
1630 assert_eq!(
1631 normalize_stream_url("ws://127.0.0.1:8080".to_string(), StreamEndpoint::Markets),
1632 "ws://127.0.0.1:8080/v1/ws/markets"
1633 );
1634 }
1635
1636 #[test]
1637 fn an_explicit_path_is_left_alone() {
1638 assert_eq!(
1639 normalize_stream_url(
1640 "wss://custom.example/socket".to_string(),
1641 StreamEndpoint::Markets
1642 ),
1643 "wss://custom.example/socket"
1644 );
1645 }
1646}