Skip to main content

polymarket_us/
stream.rs

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
19/// Host serving both WebSocket endpoints. Note this is the API host, not the
20/// gateway host used for public REST traffic.
21const DEFAULT_STREAM_HOST: &str = "wss://api.polymarket.us";
22
23type WebSocket =
24    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
25
26// ---------------------------------------------------------------------------
27// Endpoints
28// ---------------------------------------------------------------------------
29
30/// The two WebSocket surfaces exposed by the venue.
31///
32/// They are separate sockets on separate paths, so a single connection can
33/// never carry both market data and private account events. The SDK mirrors
34/// that split: see [`MarketStreamClient`] and [`PrivateStreamClient`].
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum StreamEndpoint {
37    /// Order books, trades, and best-bid/offer. Path `/v1/ws/markets`.
38    Markets,
39    /// Orders, positions, and account balances. Path `/v1/ws/private`.
40    Private,
41}
42
43impl StreamEndpoint {
44    /// The endpoint's path on the API host.
45    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    /// The full default URL for this endpoint.
53    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// ---------------------------------------------------------------------------
68// Subscription types
69// ---------------------------------------------------------------------------
70
71/// A value of the server's `subscriptionType` enum.
72///
73/// The wire format is the fully-qualified enum name — `SUBSCRIPTION_TYPE_TRADE`,
74/// not `trade`. Use [`SubscriptionType::as_wire`] to see the exact string that
75/// goes on the socket.
76#[derive(Debug, Clone, PartialEq, Eq, Hash)]
77#[non_exhaustive]
78pub enum SubscriptionType {
79    /// Full order-book depth. Markets endpoint.
80    MarketData,
81    /// Best-bid/offer only. Markets endpoint.
82    MarketDataLite,
83    /// Trade executions. Markets endpoint.
84    Trade,
85    /// Order lifecycle events. Private endpoint.
86    Order,
87    /// Position changes. Private endpoint.
88    Position,
89    /// Account balance changes. Private endpoint.
90    AccountBalance,
91    /// A subscription type this SDK does not model yet.
92    ///
93    /// The contained string is sent verbatim, so it must already be in wire
94    /// form (`SUBSCRIPTION_TYPE_...`). This exists so a newly documented type
95    /// can be used without waiting for a crate release.
96    Other(String),
97}
98
99impl SubscriptionType {
100    /// The exact string sent as `subscriptionType`.
101    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    /// Parse a wire string back into a variant, falling back to
114    /// [`SubscriptionType::Other`] for anything unrecognised.
115    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    /// Which socket carries this type, or `None` for an unmodelled type.
128    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    /// Whether this type needs at least one entry in `marketSlugs`.
137    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// ---------------------------------------------------------------------------
162// Subscriptions
163// ---------------------------------------------------------------------------
164
165/// The body of a `subscribe` request — serialized as the *inner* object, since
166/// the envelope is added when the frame is sent.
167///
168/// Only fields the server documents are emitted. `SUBSCRIPTION_TYPE_*` is
169/// protobuf-style naming and such servers commonly reject unknown fields
170/// outright, so nothing speculative is added here; `extra` carries only what a
171/// caller asked for explicitly.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct Subscription {
175    /// Correlates the server's acknowledgement, and identifies the
176    /// subscription when unsubscribing.
177    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    /// The frame as it goes on the socket, envelope included.
196    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
221/// Shared builders for both subscription newtypes.
222macro_rules! subscription_accessors {
223    ($ty:ident) => {
224        impl $ty {
225            /// The `requestId` sent with this subscription. Pass it to
226            /// `unsubscribe` to cancel.
227            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            /// Override the generated `requestId`.
240            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            /// Replace the market slugs this subscription covers.
246            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            /// Add one more market slug.
256            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            /// Add an arbitrary field to the `subscribe` object.
262            ///
263            /// Reach for this only when the server documents the field: the
264            /// endpoint rejects a frame it cannot parse, and an unknown field
265            /// may be enough to make it unparseable.
266            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            /// The exact JSON frame this subscription puts on the socket.
272            /// Useful when debugging a rejected subscription.
273            pub fn frame(&self) -> Value {
274                self.0.frame()
275            }
276        }
277    };
278}
279
280/// A subscription valid on the markets endpoint.
281#[derive(Debug, Clone)]
282pub struct MarketSubscription(Subscription);
283
284impl MarketSubscription {
285    /// Full order-book depth for one or more markets.
286    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    /// Best-bid/offer for one or more markets.
295    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    /// Trade executions for one or more markets.
304    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    /// A markets subscription type this SDK does not model yet. The string is
313    /// sent verbatim, so pass it in wire form (`SUBSCRIPTION_TYPE_...`).
314    ///
315    /// A string that names a type the SDK *does* know is resolved to that
316    /// variant, so endpoint validation still applies and this cannot be used
317    /// to smuggle a private subscription onto the markets socket.
318    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/// A subscription valid on the private endpoint.
326#[derive(Debug, Clone)]
327pub struct PrivateSubscription(Subscription);
328
329impl PrivateSubscription {
330    /// Order lifecycle events. Restrict to specific markets with
331    /// [`PrivateSubscription::with_market_slugs`].
332    pub fn orders() -> Self {
333        Self(Subscription::new(SubscriptionType::Order))
334    }
335
336    /// Position changes.
337    pub fn positions() -> Self {
338        Self(Subscription::new(SubscriptionType::Position))
339    }
340
341    /// Account balance changes.
342    pub fn account_balances() -> Self {
343        Self(Subscription::new(SubscriptionType::AccountBalance))
344    }
345
346    /// A private subscription type this SDK does not model yet. The string is
347    /// sent verbatim, so pass it in wire form (`SUBSCRIPTION_TYPE_...`).
348    ///
349    /// As with [`MarketSubscription::custom`], a known type string resolves to
350    /// its variant and stays subject to endpoint validation.
351    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// ---------------------------------------------------------------------------
362// Clients
363// ---------------------------------------------------------------------------
364
365/// Client for the market-data socket (`/v1/ws/markets`).
366#[derive(Clone)]
367pub struct MarketStreamClient {
368    base_url: String,
369    auth: Option<UsAuth>,
370}
371
372/// Client for the private account socket (`/v1/ws/private`).
373///
374/// Credentials are mandatory — the endpoint rejects an unauthenticated
375/// upgrade — so they are taken by value rather than as an `Option`.
376#[derive(Clone)]
377pub struct PrivateStreamClient {
378    base_url: String,
379    auth: UsAuth,
380}
381
382impl MarketStreamClient {
383    /// Connect to the default markets endpoint.
384    ///
385    /// Auth is optional here so the same client can be used before credentials
386    /// are configured, but note that the live endpoint has been observed to
387    /// reject unauthenticated upgrades with 401.
388    pub fn new(auth: Option<UsAuth>) -> Self {
389        Self {
390            base_url: StreamEndpoint::Markets.default_url(),
391            auth,
392        }
393    }
394
395    /// Point the client at a non-default host or path — a staging venue, or a
396    /// local server in tests.
397    ///
398    /// A URL with no path gets the endpoint's default path appended; a URL that
399    /// already has one is used as given.
400    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    /// Connect and drive a callback until the stream closes.
435    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    /// Connect to the default private endpoint.
455    pub fn new(auth: UsAuth) -> Self {
456        Self {
457            base_url: StreamEndpoint::Private.default_url(),
458            auth,
459        }
460    }
461
462    /// Point the client at a non-default host or path. See
463    /// [`MarketStreamClient::with_base_url`].
464    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    /// Connect and drive a callback until the stream closes.
499    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
560// ---------------------------------------------------------------------------
561// Stream handles
562// ---------------------------------------------------------------------------
563
564struct 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
581/// Shared surface of both handles. `subscribe` is defined per handle so the
582/// two sockets cannot be crossed.
583macro_rules! stream_handle {
584    ($ty:ident, $sub:ident, $endpoint:expr) => {
585        impl $ty {
586            /// Await the next message, or `None` once the stream has closed.
587            pub async fn next(&mut self) -> Option<StreamMessage> {
588                self.inner.receiver.recv().await
589            }
590
591            /// Which socket this handle is attached to.
592            pub fn endpoint(&self) -> StreamEndpoint {
593                $endpoint
594            }
595
596            /// Close the connection and stop reconnecting.
597            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            /// Add a subscription to the live connection.
606            ///
607            /// The frame is sent immediately and replayed after every
608            /// reconnect.
609            pub async fn subscribe(&self, subscription: $sub) -> Result<(), PolymarketUsError> {
610                self.inner.subscribe(subscription.0).await
611            }
612
613            /// Cancel a subscription by the `requestId` it was created with.
614            ///
615            /// The subscription is dropped from the reconnect list immediately
616            /// and an `unsubscribe` frame is sent over the live connection.
617            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
630/// A live connection to the markets endpoint.
631pub struct MarketStream {
632    inner: StreamHandle,
633}
634
635/// A live connection to the private endpoint.
636pub struct PrivateStream {
637    inner: StreamHandle,
638}
639
640stream_handle!(MarketStream, MarketSubscription, StreamEndpoint::Markets);
641stream_handle!(PrivateStream, PrivateSubscription, StreamEndpoint::Private);
642
643// ---------------------------------------------------------------------------
644// Configuration
645// ---------------------------------------------------------------------------
646
647enum StreamCommand {
648    Subscribe(Subscription),
649    Unsubscribe(String), // request_id
650}
651
652#[derive(Debug, Clone)]
653pub struct StreamConnectConfig {
654    /// Identifies this connection in [`StreamControlEvent`]s. Never sent to
655    /// the server — each subscription carries its own `requestId`.
656    pub session_id: String,
657    pub reconnect: ReconnectConfig,
658
659    /// Tear down and reconnect if no frame arrives from the server within this
660    /// window. Defaults to 60 seconds; `None` disables the check.
661    ///
662    /// Without this, a TCP connection that dies silently (no FIN, no RST — the
663    /// common case behind NAT timeouts and load-balancer drops) leaves the
664    /// stream blocked forever and reconnect never fires.
665    pub idle_timeout: Option<Duration>,
666
667    /// Send a WebSocket ping this often. Defaults to 20 seconds; `None`
668    /// disables it.
669    ///
670    /// The pong it draws counts as traffic for `idle_timeout`, so a market
671    /// that simply has nothing to report is not mistaken for a dead socket.
672    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    /// Set the idle timeout. Pass `None` to disable dead-connection detection.
698    pub fn with_idle_timeout(mut self, idle_timeout: Option<Duration>) -> Self {
699        self.idle_timeout = idle_timeout;
700        self
701    }
702
703    /// Set the keepalive ping interval. Pass `None` to stop sending pings.
704    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// ---------------------------------------------------------------------------
752// Messages
753// ---------------------------------------------------------------------------
754
755#[derive(Debug, Clone)]
756pub struct StreamMessage {
757    /// The `requestId` the server echoed, when it echoed one. Matches the
758    /// `request_id` of the subscription that produced this message.
759    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    /// Initial snapshot of all open orders (private endpoint).
774    OrderSnapshot(Value),
775    /// Order lifecycle update (private endpoint).
776    OrderUpdate(Value),
777    /// Full order-book depth update.
778    MarketData(Value),
779    /// Best-bid/offer update (lightweight).
780    MarketDataLite(Value),
781    /// Order-book delta / incremental update.
782    OrderBookDelta(Value),
783    /// Initial snapshot of all portfolio positions (private endpoint).
784    PositionSnapshot(Value),
785    /// Position change (private endpoint).
786    PositionUpdate(Value),
787    /// Initial snapshot of account balances (private endpoint).
788    BalanceSnapshot(Value),
789    /// Account balance change (private endpoint).
790    BalanceUpdate(Value),
791    /// Trade execution event.
792    Trade(Value),
793    /// Server heartbeat — no payload.
794    Heartbeat,
795    /// Any server event not yet modelled by this SDK.
796    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
825// ---------------------------------------------------------------------------
826// Runner
827// ---------------------------------------------------------------------------
828
829struct 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        // Clone the Arc so the future borrows it, not &mut self, allowing
948        // cmd_rx to be used in the same select! block.
949        let shutdown = Arc::clone(&self.shutdown);
950        let shutdown_wait = shutdown.notified();
951        tokio::pin!(shutdown_wait);
952
953        // Dead-connection detection. The sleep is armed unconditionally so the
954        // select! arm has something to poll, but is only ever selected when an
955        // idle timeout is configured; the fallback duration is never reached.
956        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        // Keepalive pings, armed the same way. The pong is what actually feeds
962        // the idle check on a market that has nothing to report.
963        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                    // Returning Err lets the outer run loop surface the reason
976                    // and then reconnect under the usual backoff policy.
977                    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                    // Any frame — including a ping or pong — proves liveness.
989                    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
1102// ---------------------------------------------------------------------------
1103// Inbound frame parsing
1104// ---------------------------------------------------------------------------
1105
1106/// Keys that describe a frame rather than carry its content. Used to spot the
1107/// payload key in an envelope like `{"requestId": "...", "marketData": {...}}`.
1108const 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 events ---
1137                "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 events ---
1145                "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 events ---
1156                "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 events ---
1163                "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                // --- Keepalive ---
1171                "heartbeat" | "ping" | "pong" => {
1172                    StreamMessageKind::Data(StreamDataEvent::Heartbeat)
1173                }
1174                // --- Control ---
1175                "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
1209/// Keys that wrap a payload without naming it, so a frame shaped like
1210/// `{"subscriptionType": "...", "data": {...}}` is identified by its type
1211/// rather than by the word "data".
1212const PAYLOAD_KEYS: &[&str] = &["data", "payload", "body", "message", "result"];
1213
1214/// The single key that isn't envelope metadata, if there is exactly one. This
1215/// is what identifies a frame shaped like `{"requestId": "x", "trade": {...}}`.
1216fn 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    // Only a key that actually names the event, never a generic wrapper.
1236    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
1243/// Fold the `SUBSCRIPTION_TYPE_MARKET_DATA` spelling onto the plain
1244/// `market_data` name the match arms use, so both forms land on one variant.
1245fn 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
1266// ---------------------------------------------------------------------------
1267// URLs and ids
1268// ---------------------------------------------------------------------------
1269
1270fn 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
1275/// Turn a caller-supplied base URL into a WebSocket URL for `endpoint`.
1276///
1277/// An `http(s)` scheme is upgraded to `ws(s)`. A URL with no path of its own
1278/// gets the endpoint's path appended; one that already has a path is trusted as
1279/// given, so a caller can point at a non-standard route.
1280fn 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    // --- Outbound wire format ---
1329
1330    #[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        // A protobuf-derived server rejects a frame with fields it does not
1353        // define, so the default frame must carry exactly these three.
1354        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    // --- Endpoint routing ---
1424
1425    #[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        // Unmodelled types are unroutable, so they are accepted on either.
1436        assert_eq!(SubscriptionType::Other("X".into()).endpoint(), None);
1437    }
1438
1439    #[test]
1440    fn a_private_type_is_rejected_on_the_markets_socket() {
1441        // Only reachable through `custom`, since the typed constructors keep
1442        // the two subscription families apart at compile time.
1443        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    // --- Inbound frame parsing ---
1472
1473    #[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    // --- URLs ---
1600
1601    #[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}