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, Serialize};
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 TRACKING_COUNTER: AtomicU64 = AtomicU64::new(1);
18
19// ---------------------------------------------------------------------------
20// Subscription channel enum
21// ---------------------------------------------------------------------------
22
23/// All known WebSocket subscription channels.
24///
25/// Pass a variant to the typed constructors on [`StreamSubscription`], or use
26/// [`SubscriptionChannel::as_str`] to get the wire-format channel name.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29#[non_exhaustive]
30pub enum SubscriptionChannel {
31    /// Initial snapshot of all open orders (private).
32    OrderSnapshot,
33    /// Real-time order lifecycle changes (private).
34    OrderUpdate,
35    /// Full order-book depth (public).
36    MarketData,
37    /// Best-bid/offer only (public).
38    MarketDataLite,
39    /// Initial snapshot of portfolio positions (private).
40    PositionSnapshot,
41    /// Real-time position changes (private).
42    PositionUpdate,
43    /// Initial snapshot of account balances (private).
44    BalanceSnapshot,
45    /// Real-time balance changes (private).
46    BalanceUpdate,
47    /// Trade execution feed (public).
48    Trade,
49    /// Server heartbeat — useful as an aliveness check.
50    Heartbeat,
51}
52
53impl SubscriptionChannel {
54    /// Returns the snake_case wire-format channel name.
55    pub fn as_str(self) -> &'static str {
56        match self {
57            Self::OrderSnapshot => "order_snapshot",
58            Self::OrderUpdate => "order_update",
59            Self::MarketData => "market_data",
60            Self::MarketDataLite => "market_data_lite",
61            Self::PositionSnapshot => "position_snapshot",
62            Self::PositionUpdate => "position_update",
63            Self::BalanceSnapshot => "balance_snapshot",
64            Self::BalanceUpdate => "balance_update",
65            Self::Trade => "trade",
66            Self::Heartbeat => "heartbeat",
67        }
68    }
69}
70
71impl std::fmt::Display for SubscriptionChannel {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.write_str(self.as_str())
74    }
75}
76
77// ---------------------------------------------------------------------------
78// Internal command sent from ManagedStream → StreamRunner
79// ---------------------------------------------------------------------------
80
81enum StreamCommand {
82    Subscribe(StreamSubscription),
83    Unsubscribe(String), // tracking_id
84}
85
86#[derive(Clone)]
87pub struct PolymarketUsStreamClient {
88    base_url: String,
89    auth: Option<UsAuth>,
90}
91
92impl PolymarketUsStreamClient {
93    pub fn new(base_url: impl Into<String>, auth: Option<UsAuth>) -> Self {
94        Self {
95            base_url: normalize_stream_url(base_url.into()),
96            auth,
97        }
98    }
99
100    pub fn from_gateway_base_url(
101        gateway_base_url: impl Into<String>,
102        auth: Option<UsAuth>,
103    ) -> Self {
104        let gateway_base_url = gateway_base_url.into();
105        Self::new(derive_stream_url(&gateway_base_url), auth)
106    }
107
108    pub fn base_url(&self) -> &str {
109        &self.base_url
110    }
111
112    pub async fn connect(
113        &self,
114        subscriptions: Vec<StreamSubscription>,
115    ) -> Result<ManagedStream, PolymarketUsError> {
116        self.connect_with_config(subscriptions, StreamConnectConfig::default())
117            .await
118    }
119
120    pub async fn connect_with_config(
121        &self,
122        subscriptions: Vec<StreamSubscription>,
123        config: StreamConnectConfig,
124    ) -> Result<ManagedStream, PolymarketUsError> {
125        if subscriptions.is_empty() {
126            return Err(PolymarketUsError::InvalidStreamConfig(
127                "at least one subscription is required".to_string(),
128            ));
129        }
130
131        let (tx, rx) = mpsc::channel(256);
132        let (cmd_tx, cmd_rx) = mpsc::channel(64);
133        let shutdown = Arc::new(StreamShutdown::new());
134        let base_url = self.base_url.clone();
135        let auth = self.auth.clone();
136        let shutdown_task = shutdown.clone();
137
138        tokio::spawn(async move {
139            let runner = StreamRunner {
140                base_url,
141                auth,
142                subscriptions,
143                config,
144                tx,
145                shutdown: shutdown_task,
146                cmd_rx,
147            };
148            runner.run().await;
149        });
150
151        Ok(ManagedStream {
152            receiver: rx,
153            shutdown,
154            cmd_tx,
155        })
156    }
157
158    pub async fn run<F, Fut>(
159        &self,
160        subscriptions: Vec<StreamSubscription>,
161        config: StreamConnectConfig,
162        mut on_message: F,
163    ) -> Result<(), PolymarketUsError>
164    where
165        F: FnMut(StreamMessage) -> Fut,
166        Fut: Future<Output = ()>,
167    {
168        let mut stream = self.connect_with_config(subscriptions, config).await?;
169        while let Some(message) = stream.next().await {
170            on_message(message).await;
171        }
172        Ok(())
173    }
174}
175
176pub struct ManagedStream {
177    receiver: mpsc::Receiver<StreamMessage>,
178    shutdown: Arc<StreamShutdown>,
179    cmd_tx: mpsc::Sender<StreamCommand>,
180}
181
182impl ManagedStream {
183    pub async fn next(&mut self) -> Option<StreamMessage> {
184        self.receiver.recv().await
185    }
186
187    pub fn shutdown(&self) {
188        self.shutdown.shutdown();
189    }
190
191    pub fn is_shutdown(&self) -> bool {
192        self.shutdown.is_shutdown()
193    }
194
195    /// Dynamically add a subscription to the live connection.
196    ///
197    /// The subscription frame is sent immediately over the existing WebSocket
198    /// and re-sent automatically after every reconnect.
199    pub async fn subscribe(&self, sub: StreamSubscription) -> Result<(), PolymarketUsError> {
200        self.cmd_tx
201            .send(StreamCommand::Subscribe(sub))
202            .await
203            .map_err(|_| PolymarketUsError::InvalidStreamConfig("stream is closed".to_string()))
204    }
205
206    /// Remove a subscription by its `tracking_id`.
207    ///
208    /// The subscription is removed from the reconnect list immediately.
209    /// An unsubscribe frame is also sent to the server over the live connection.
210    pub async fn unsubscribe(&self, tracking_id: &str) -> Result<(), PolymarketUsError> {
211        self.cmd_tx
212            .send(StreamCommand::Unsubscribe(tracking_id.to_string()))
213            .await
214            .map_err(|_| PolymarketUsError::InvalidStreamConfig("stream is closed".to_string()))
215    }
216}
217
218#[derive(Debug, Clone)]
219pub struct StreamConnectConfig {
220    pub tracking_id: String,
221    pub responses_debounced: bool,
222    pub reconnect: ReconnectConfig,
223
224    /// Tear down and reconnect if no frame arrives from the server within this
225    /// window. Defaults to 60 seconds; `None` disables the check.
226    ///
227    /// Without this, a TCP connection that dies silently (no FIN, no RST — the
228    /// common case behind NAT timeouts and load-balancer drops) leaves the
229    /// stream blocked forever and reconnect never fires. Subscribing to
230    /// [`StreamSubscription::heartbeat`] guarantees regular traffic to feed it.
231    pub idle_timeout: Option<Duration>,
232}
233
234impl Default for StreamConnectConfig {
235    fn default() -> Self {
236        Self {
237            tracking_id: next_tracking_id("session"),
238            responses_debounced: false,
239            reconnect: ReconnectConfig::default(),
240            idle_timeout: Some(Duration::from_secs(60)),
241        }
242    }
243}
244
245impl StreamConnectConfig {
246    pub fn with_tracking_id(mut self, tracking_id: impl Into<String>) -> Self {
247        self.tracking_id = tracking_id.into();
248        self
249    }
250
251    pub fn with_responses_debounced(mut self, responses_debounced: bool) -> Self {
252        self.responses_debounced = responses_debounced;
253        self
254    }
255
256    pub fn with_reconnect(mut self, reconnect: ReconnectConfig) -> Self {
257        self.reconnect = reconnect;
258        self
259    }
260
261    /// Set the idle timeout. Pass `None` to disable dead-connection detection.
262    pub fn with_idle_timeout(mut self, idle_timeout: Option<Duration>) -> Self {
263        self.idle_timeout = idle_timeout;
264        self
265    }
266}
267
268#[derive(Debug, Clone)]
269pub struct ReconnectConfig {
270    pub enabled: bool,
271    pub max_attempts: Option<usize>,
272    pub initial_delay: Duration,
273    pub max_delay: Duration,
274    pub multiplier: f64,
275}
276
277impl Default for ReconnectConfig {
278    fn default() -> Self {
279        Self {
280            enabled: true,
281            max_attempts: None,
282            initial_delay: Duration::from_millis(250),
283            max_delay: Duration::from_secs(10),
284            multiplier: 2.0,
285        }
286    }
287}
288
289impl ReconnectConfig {
290    pub fn disabled() -> Self {
291        Self {
292            enabled: false,
293            ..Self::default()
294        }
295    }
296
297    pub fn delay_for_attempt(&self, attempt: usize) -> Duration {
298        if attempt == 0 {
299            return self.initial_delay.min(self.max_delay);
300        }
301
302        let scaled = self
303            .initial_delay
304            .mul_f64(self.multiplier.powi(attempt.saturating_sub(1) as i32));
305        scaled.min(self.max_delay)
306    }
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
310#[serde(rename_all = "camelCase")]
311pub struct StreamSubscription {
312    pub channel: String,
313    pub tracking_id: String,
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub responses_debounced: Option<bool>,
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub symbol: Option<String>,
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub market_id: Option<String>,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub outcome: Option<String>,
322    #[serde(default, flatten)]
323    pub extra: Map<String, Value>,
324}
325
326impl StreamSubscription {
327    pub fn new(channel: impl Into<String>) -> Self {
328        Self {
329            channel: channel.into(),
330            tracking_id: next_tracking_id("sub"),
331            responses_debounced: None,
332            symbol: None,
333            market_id: None,
334            outcome: None,
335            extra: Map::new(),
336        }
337    }
338
339    /// Create a subscription for the given typed channel.
340    pub fn for_channel(channel: SubscriptionChannel) -> Self {
341        Self::new(channel.as_str())
342    }
343
344    // --- Market (public) ---
345
346    /// Full order-book depth updates for a market symbol.
347    pub fn market_data(symbol: impl Into<String>) -> Self {
348        let mut s = Self::new(SubscriptionChannel::MarketData.as_str());
349        s.symbol = Some(symbol.into());
350        s
351    }
352
353    /// Best-bid/offer updates for a market symbol (lightweight).
354    pub fn market_data_lite(symbol: impl Into<String>) -> Self {
355        let mut s = Self::new(SubscriptionChannel::MarketDataLite.as_str());
356        s.symbol = Some(symbol.into());
357        s
358    }
359
360    /// Trade executions for a market symbol.
361    pub fn trades(symbol: impl Into<String>) -> Self {
362        let mut s = Self::new(SubscriptionChannel::Trade.as_str());
363        s.symbol = Some(symbol.into());
364        s
365    }
366
367    /// Server heartbeat channel — useful for keepalive monitoring.
368    pub fn heartbeat() -> Self {
369        Self::new(SubscriptionChannel::Heartbeat.as_str())
370    }
371
372    // --- Private (authenticated) ---
373
374    /// Initial snapshot of all open orders for a symbol.
375    pub fn order_snapshot(symbol: impl Into<String>) -> Self {
376        let mut s = Self::new(SubscriptionChannel::OrderSnapshot.as_str());
377        s.symbol = Some(symbol.into());
378        s
379    }
380
381    /// Real-time order lifecycle events.
382    pub fn order_update() -> Self {
383        Self::new(SubscriptionChannel::OrderUpdate.as_str())
384    }
385
386    /// Initial snapshot of all portfolio positions.
387    pub fn position_snapshot() -> Self {
388        Self::new(SubscriptionChannel::PositionSnapshot.as_str())
389    }
390
391    /// Real-time position changes.
392    pub fn position_update() -> Self {
393        Self::new(SubscriptionChannel::PositionUpdate.as_str())
394    }
395
396    /// Initial snapshot of account balances.
397    pub fn balance_snapshot() -> Self {
398        Self::new(SubscriptionChannel::BalanceSnapshot.as_str())
399    }
400
401    /// Real-time balance changes.
402    pub fn balance_update() -> Self {
403        Self::new(SubscriptionChannel::BalanceUpdate.as_str())
404    }
405
406    // --- Builder methods ---
407
408    pub fn with_tracking_id(mut self, tracking_id: impl Into<String>) -> Self {
409        self.tracking_id = tracking_id.into();
410        self
411    }
412
413    pub fn with_responses_debounced(mut self, responses_debounced: bool) -> Self {
414        self.responses_debounced = Some(responses_debounced);
415        self
416    }
417
418    pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
419        self.symbol = Some(symbol.into());
420        self
421    }
422
423    pub fn with_market_id(mut self, market_id: impl Into<String>) -> Self {
424        self.market_id = Some(market_id.into());
425        self
426    }
427
428    pub fn with_outcome(mut self, outcome: impl Into<String>) -> Self {
429        self.outcome = Some(outcome.into());
430        self
431    }
432
433    pub fn insert_extra(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
434        self.extra.insert(key.into(), value.into());
435        self
436    }
437}
438
439#[derive(Debug, Clone)]
440pub struct StreamMessage {
441    pub tracking_id: Option<String>,
442    pub kind: StreamMessageKind,
443}
444
445#[derive(Debug, Clone)]
446#[non_exhaustive]
447pub enum StreamMessageKind {
448    Data(StreamDataEvent),
449    Control(StreamControlEvent),
450}
451
452#[derive(Debug, Clone)]
453#[non_exhaustive]
454pub enum StreamDataEvent {
455    /// Initial snapshot of all open orders (private channel).
456    OrderSnapshot(Value),
457    /// Real-time order lifecycle update (private channel).
458    OrderUpdate(Value),
459    /// Full order-book depth update.
460    MarketData(Value),
461    /// Best-bid/offer update (lightweight).
462    MarketDataLite(Value),
463    /// Order-book delta / incremental update.
464    OrderBookDelta(Value),
465    /// Initial snapshot of all portfolio positions (private channel).
466    PositionSnapshot(Value),
467    /// Real-time position change (private channel).
468    PositionUpdate(Value),
469    /// Initial snapshot of account balances (private channel).
470    BalanceSnapshot(Value),
471    /// Real-time balance change (private channel).
472    BalanceUpdate(Value),
473    /// Trade execution event.
474    Trade(Value),
475    /// Server heartbeat — no payload.
476    Heartbeat,
477    /// Any server event not yet modelled by this SDK.
478    Other { event_type: String, payload: Value },
479}
480
481#[derive(Debug, Clone)]
482#[non_exhaustive]
483pub enum StreamControlEvent {
484    Connected { session_tracking_id: String },
485    SubscriptionAck { event_type: String, payload: Value },
486    Reconnecting { attempt: usize, delay_ms: u64 },
487    Closed,
488    Error(String),
489}
490
491impl StreamMessage {
492    pub fn control(tracking_id: Option<String>, event: StreamControlEvent) -> Self {
493        Self {
494            tracking_id,
495            kind: StreamMessageKind::Control(event),
496        }
497    }
498
499    pub fn data(tracking_id: Option<String>, event: StreamDataEvent) -> Self {
500        Self {
501            tracking_id,
502            kind: StreamMessageKind::Data(event),
503        }
504    }
505}
506
507struct StreamRunner {
508    base_url: String,
509    auth: Option<UsAuth>,
510    subscriptions: Vec<StreamSubscription>,
511    config: StreamConnectConfig,
512    tx: mpsc::Sender<StreamMessage>,
513    shutdown: Arc<StreamShutdown>,
514    cmd_rx: mpsc::Receiver<StreamCommand>,
515}
516
517impl StreamRunner {
518    async fn run(mut self) {
519        let mut attempt = 0usize;
520
521        loop {
522            if self.shutdown.is_shutdown() || self.tx.is_closed() {
523                break;
524            }
525
526            match self.connect_and_consume().await {
527                Ok(()) => {
528                    if !self.config.reconnect.enabled {
529                        break;
530                    }
531                }
532                Err(err) => {
533                    if !self
534                        .emit(StreamMessage::control(
535                            Some(self.config.tracking_id.clone()),
536                            StreamControlEvent::Error(err.to_string()),
537                        ))
538                        .await
539                    {
540                        break;
541                    }
542                }
543            }
544
545            if !self.config.reconnect.enabled {
546                break;
547            }
548
549            attempt += 1;
550            if let Some(max_attempts) = self.config.reconnect.max_attempts {
551                if attempt > max_attempts {
552                    break;
553                }
554            }
555
556            let delay = self.config.reconnect.delay_for_attempt(attempt);
557            if !self
558                .emit(StreamMessage::control(
559                    Some(self.config.tracking_id.clone()),
560                    StreamControlEvent::Reconnecting {
561                        attempt,
562                        delay_ms: delay.as_millis() as u64,
563                    },
564                ))
565                .await
566            {
567                break;
568            }
569
570            let shutdown = Arc::clone(&self.shutdown);
571            tokio::select! {
572                _ = shutdown.notified() => break,
573                _ = tokio::time::sleep(delay) => {}
574            }
575        }
576
577        let _ = self
578            .emit(StreamMessage::control(
579                Some(self.config.tracking_id.clone()),
580                StreamControlEvent::Closed,
581            ))
582            .await;
583    }
584
585    async fn connect_and_consume(&mut self) -> Result<(), PolymarketUsError> {
586        let mut request = self
587            .base_url
588            .as_str()
589            .into_client_request()
590            .map_err(|err| {
591                PolymarketUsError::InvalidStreamConfig(format!(
592                    "invalid websocket URL {}: {err}",
593                    self.base_url
594                ))
595            })?;
596
597        if let Some(auth) = &self.auth {
598            let path = request
599                .uri()
600                .path_and_query()
601                .map(|path| path.as_str())
602                .unwrap_or("/");
603            for (name, value) in auth.signed_headers("GET", path) {
604                let header_value = HeaderValue::from_str(&value).map_err(|err| {
605                    PolymarketUsError::InvalidStreamConfig(format!(
606                        "invalid websocket auth header value for {name}: {err}"
607                    ))
608                })?;
609                request.headers_mut().insert(name, header_value);
610            }
611        }
612
613        let (mut websocket, _) = connect_async(request).await?;
614        let _ = self
615            .emit(StreamMessage::control(
616                Some(self.config.tracking_id.clone()),
617                StreamControlEvent::Connected {
618                    session_tracking_id: self.config.tracking_id.clone(),
619                },
620            ))
621            .await;
622
623        self.send_all_subscriptions(&mut websocket).await?;
624
625        // Clone the Arc so the future borrows it, not &mut self, allowing
626        // cmd_rx to be used in the same select! block.
627        let shutdown = Arc::clone(&self.shutdown);
628        let shutdown_wait = shutdown.notified();
629        tokio::pin!(shutdown_wait);
630
631        // Dead-connection detection. The sleep is armed unconditionally so the
632        // select! arm has something to poll, but is only ever selected when an
633        // idle timeout is configured; the fallback duration is never reached.
634        let idle_timeout = self.config.idle_timeout;
635        let idle_deadline =
636            tokio::time::sleep(idle_timeout.unwrap_or_else(|| Duration::from_secs(3600)));
637        tokio::pin!(idle_deadline);
638
639        loop {
640            tokio::select! {
641                _ = &mut shutdown_wait => {
642                    let _ = websocket.close(None).await;
643                    break;
644                }
645                _ = &mut idle_deadline, if idle_timeout.is_some() => {
646                    // Returning Err lets the outer run loop surface the reason
647                    // and then reconnect under the usual backoff policy.
648                    let _ = websocket.close(None).await;
649                    return Err(PolymarketUsError::StreamIdle(
650                        idle_timeout.expect("guarded by idle_timeout.is_some()"),
651                    ));
652                }
653                message = websocket.next() => {
654                    // Any frame — including a ping or pong — proves liveness.
655                    if let Some(timeout) = idle_timeout {
656                        idle_deadline.as_mut().reset(tokio::time::Instant::now() + timeout);
657                    }
658
659                    let Some(message) = message else {
660                        break;
661                    };
662
663                    match message {
664                        Ok(Message::Text(text)) => {
665                            self.handle_text(&text).await?;
666                        }
667                        Ok(Message::Binary(bytes)) => {
668                            let text = String::from_utf8(bytes.to_vec()).map_err(|err| {
669                                PolymarketUsError::InvalidStreamConfig(format!(
670                                    "received non-UTF8 websocket payload: {err}"
671                                ))
672                            })?;
673                            self.handle_text(&text).await?;
674                        }
675                        Ok(Message::Close(_)) => break,
676                        Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
677                        Ok(_) => {}
678                        Err(err) => return Err(err.into()),
679                    }
680                }
681                cmd = self.cmd_rx.recv() => {
682                    match cmd {
683                        Some(StreamCommand::Subscribe(sub)) => {
684                            self.send_subscription(&mut websocket, &sub).await?;
685                            self.subscriptions.push(sub);
686                        }
687                        Some(StreamCommand::Unsubscribe(tracking_id)) => {
688                            self.subscriptions.retain(|s| s.tracking_id != tracking_id);
689                            // Best-effort unsubscribe frame; server may not support it.
690                            let frame = serde_json::json!({
691                                "type": "unsubscribe",
692                                "trackingId": tracking_id,
693                            });
694                            let _ = websocket
695                                .send(Message::Text(frame.to_string().into()))
696                                .await;
697                        }
698                        None => break,
699                    }
700                }
701            }
702        }
703
704        Ok(())
705    }
706
707    async fn send_all_subscriptions(
708        &self,
709        websocket: &mut tokio_tungstenite::WebSocketStream<
710            tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
711        >,
712    ) -> Result<(), PolymarketUsError> {
713        for subscription in &self.subscriptions {
714            self.send_subscription(websocket, subscription).await?;
715        }
716        Ok(())
717    }
718
719    async fn send_subscription(
720        &self,
721        websocket: &mut tokio_tungstenite::WebSocketStream<
722            tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
723        >,
724        subscription: &StreamSubscription,
725    ) -> Result<(), PolymarketUsError> {
726        let mut prepared = subscription.clone();
727        if prepared.responses_debounced.is_none() {
728            prepared.responses_debounced = Some(self.config.responses_debounced);
729        }
730        let payload = serde_json::to_string(&prepared)?;
731        websocket.send(Message::Text(payload.into())).await?;
732        Ok(())
733    }
734
735    async fn handle_text(&self, text: &str) -> Result<(), PolymarketUsError> {
736        let json: Value = serde_json::from_str(text)?;
737        if let Some(message) = parse_stream_message(json) {
738            if !self.emit(message).await {
739                return Ok(());
740            }
741        }
742        Ok(())
743    }
744
745    async fn emit(&self, message: StreamMessage) -> bool {
746        self.tx.send(message).await.is_ok()
747    }
748}
749
750struct StreamShutdown {
751    requested: AtomicBool,
752    notify: Notify,
753}
754
755impl StreamShutdown {
756    fn new() -> Self {
757        Self {
758            requested: AtomicBool::new(false),
759            notify: Notify::new(),
760        }
761    }
762
763    fn shutdown(&self) {
764        if !self.requested.swap(true, Ordering::SeqCst) {
765            self.notify.notify_waiters();
766        }
767    }
768
769    fn is_shutdown(&self) -> bool {
770        self.requested.load(Ordering::SeqCst)
771    }
772
773    fn notified(&self) -> impl Future<Output = ()> + '_ {
774        self.notify.notified()
775    }
776}
777
778fn parse_stream_message(json: Value) -> Option<StreamMessage> {
779    match json {
780        Value::Object(map) => {
781            let tracking_id = extract_tracking_id(&map);
782            let event_type = extract_event_type(&map);
783            let payload = extract_payload(&map);
784
785            let kind = match event_type.as_str() {
786                // --- Order channels ---
787                "order_snapshot" | "orderSnapshot" => {
788                    StreamMessageKind::Data(StreamDataEvent::OrderSnapshot(payload))
789                }
790                "order_update" | "order_updates" | "orderUpdate" | "user_order" | "fill" => {
791                    StreamMessageKind::Data(StreamDataEvent::OrderUpdate(payload))
792                }
793                // --- Market channels ---
794                "market_data" | "marketData" => {
795                    StreamMessageKind::Data(StreamDataEvent::MarketData(payload))
796                }
797                "market_data_lite" | "marketDataLite" => {
798                    StreamMessageKind::Data(StreamDataEvent::MarketDataLite(payload))
799                }
800                "order_book_delta" | "orderbook_delta" | "book_delta" | "bookDelta" => {
801                    StreamMessageKind::Data(StreamDataEvent::OrderBookDelta(payload))
802                }
803                "trade" | "trades" => StreamMessageKind::Data(StreamDataEvent::Trade(payload)),
804                // --- Position channels ---
805                "position_snapshot" | "positionSnapshot" => {
806                    StreamMessageKind::Data(StreamDataEvent::PositionSnapshot(payload))
807                }
808                "position_update" | "positionUpdate" => {
809                    StreamMessageKind::Data(StreamDataEvent::PositionUpdate(payload))
810                }
811                // --- Balance channels ---
812                "balance_snapshot" | "balanceSnapshot" => {
813                    StreamMessageKind::Data(StreamDataEvent::BalanceSnapshot(payload))
814                }
815                "balance_update" | "balanceUpdate" => {
816                    StreamMessageKind::Data(StreamDataEvent::BalanceUpdate(payload))
817                }
818                // --- Keepalive ---
819                "heartbeat" | "ping" | "pong" => {
820                    StreamMessageKind::Data(StreamDataEvent::Heartbeat)
821                }
822                // --- Control ---
823                "subscription" | "subscribe" | "subscribed" | "ack" => {
824                    StreamMessageKind::Control(StreamControlEvent::SubscriptionAck {
825                        event_type: event_type.clone(),
826                        payload,
827                    })
828                }
829                "error" => {
830                    StreamMessageKind::Control(StreamControlEvent::Error(payload.to_string()))
831                }
832                _ => StreamMessageKind::Data(StreamDataEvent::Other {
833                    event_type: event_type.clone(),
834                    payload,
835                }),
836            };
837
838            Some(StreamMessage { tracking_id, kind })
839        }
840        other => Some(StreamMessage::data(
841            None,
842            StreamDataEvent::Other {
843                event_type: "unknown".to_string(),
844                payload: other,
845            },
846        )),
847    }
848}
849
850fn extract_tracking_id(map: &Map<String, Value>) -> Option<String> {
851    ["trackingId", "tracking_id", "trackingID", "id"]
852        .iter()
853        .find_map(|key| map.get(*key).and_then(Value::as_str).map(ToOwned::to_owned))
854}
855
856fn extract_event_type(map: &Map<String, Value>) -> String {
857    for key in ["event", "type", "channel", "name", "topic"] {
858        if let Some(value) = map.get(key).and_then(Value::as_str) {
859            return value.to_string();
860        }
861    }
862
863    if map.len() == 1 {
864        return map
865            .keys()
866            .next()
867            .cloned()
868            .unwrap_or_else(|| "unknown".to_string());
869    }
870
871    "unknown".to_string()
872}
873
874fn extract_payload(map: &Map<String, Value>) -> Value {
875    for key in ["data", "payload", "body", "message", "result"] {
876        if let Some(value) = map.get(key) {
877            return value.clone();
878        }
879    }
880
881    if map.len() == 1 {
882        return map.values().next().cloned().unwrap_or(Value::Null);
883    }
884
885    Value::Object(map.clone())
886}
887
888fn next_tracking_id(prefix: &str) -> String {
889    let ordinal = TRACKING_COUNTER.fetch_add(1, Ordering::Relaxed);
890    format!("{prefix}-{}-{ordinal}", unix_timestamp_millis())
891}
892
893fn normalize_stream_url(url: String) -> String {
894    let trimmed = url.trim_end_matches('/');
895    if trimmed.starts_with("ws://") || trimmed.starts_with("wss://") {
896        trimmed.to_string()
897    } else if let Some(rest) = trimmed.strip_prefix("https://") {
898        format!("wss://{rest}/ws")
899    } else if let Some(rest) = trimmed.strip_prefix("http://") {
900        format!("ws://{rest}/ws")
901    } else {
902        format!("wss://{trimmed}/ws")
903    }
904}
905
906/// Map a gateway base URL onto its WebSocket endpoint.
907///
908/// Identical to [`normalize_stream_url`]; retained as a named alias because it
909/// documents intent at the `from_gateway_base_url` call site.
910fn derive_stream_url(gateway_base_url: &str) -> String {
911    normalize_stream_url(gateway_base_url.to_string())
912}
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917    use serde_json::json;
918
919    #[test]
920    fn reconnect_delay_caps_at_max() {
921        let policy = ReconnectConfig {
922            enabled: true,
923            max_attempts: None,
924            initial_delay: Duration::from_millis(250),
925            max_delay: Duration::from_secs(1),
926            multiplier: 3.0,
927        };
928
929        assert_eq!(policy.delay_for_attempt(0), Duration::from_millis(250));
930        assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(250));
931        assert_eq!(policy.delay_for_attempt(2), Duration::from_millis(750));
932        assert_eq!(policy.delay_for_attempt(3), Duration::from_secs(1));
933        assert_eq!(policy.delay_for_attempt(10), Duration::from_secs(1));
934    }
935
936    #[test]
937    fn subscription_serializes_debounced_flag_and_tracking_id() {
938        let subscription = StreamSubscription::order_snapshot("ABC")
939            .with_tracking_id("tracking-1")
940            .with_responses_debounced(true)
941            .insert_extra("bookLevel", json!(2));
942
943        let json = serde_json::to_value(subscription).unwrap();
944        assert_eq!(json["channel"], "order_snapshot");
945        assert_eq!(json["trackingId"], "tracking-1");
946        assert_eq!(json["responsesDebounced"], true);
947        assert_eq!(json["symbol"], "ABC");
948        assert_eq!(json["bookLevel"], 2);
949    }
950
951    #[test]
952    fn parses_order_snapshot_event() {
953        let message = parse_stream_message(json!({
954            "event": "order_snapshot",
955            "trackingId": "abc-123",
956            "data": { "bids": [1, 2], "asks": [3, 4] }
957        }))
958        .expect("message");
959
960        assert_eq!(message.tracking_id.as_deref(), Some("abc-123"));
961        match message.kind {
962            StreamMessageKind::Data(StreamDataEvent::OrderSnapshot(payload)) => {
963                assert_eq!(payload["bids"][0], 1);
964                assert_eq!(payload["asks"][1], 4);
965            }
966            other => panic!("unexpected event: {other:?}"),
967        }
968    }
969
970    #[test]
971    fn parses_position_snapshot_event() {
972        let message = parse_stream_message(json!({
973            "event": "position_snapshot",
974            "data": { "positions": [] }
975        }))
976        .expect("message");
977        assert!(
978            matches!(
979                message.kind,
980                StreamMessageKind::Data(StreamDataEvent::PositionSnapshot(_))
981            ),
982            "expected PositionSnapshot"
983        );
984    }
985
986    #[test]
987    fn parses_balance_update_event() {
988        let message = parse_stream_message(json!({
989            "event": "balance_update",
990            "data": { "currency": "USD", "balance": "1000.00" }
991        }))
992        .expect("message");
993        assert!(
994            matches!(
995                message.kind,
996                StreamMessageKind::Data(StreamDataEvent::BalanceUpdate(_))
997            ),
998            "expected BalanceUpdate"
999        );
1000    }
1001
1002    #[test]
1003    fn parses_trade_event() {
1004        let message = parse_stream_message(json!({
1005            "event": "trade",
1006            "data": { "price": "0.55", "size": "100" }
1007        }))
1008        .expect("message");
1009        assert!(
1010            matches!(
1011                message.kind,
1012                StreamMessageKind::Data(StreamDataEvent::Trade(_))
1013            ),
1014            "expected Trade"
1015        );
1016    }
1017
1018    #[test]
1019    fn parses_heartbeat_event() {
1020        let message = parse_stream_message(json!({ "event": "heartbeat" })).expect("message");
1021        assert!(
1022            matches!(
1023                message.kind,
1024                StreamMessageKind::Data(StreamDataEvent::Heartbeat)
1025            ),
1026            "expected Heartbeat"
1027        );
1028    }
1029
1030    #[test]
1031    fn parses_market_data_lite_event() {
1032        let message = parse_stream_message(json!({
1033            "event": "market_data_lite",
1034            "data": { "bid": "0.50", "ask": "0.55" }
1035        }))
1036        .expect("message");
1037        assert!(
1038            matches!(
1039                message.kind,
1040                StreamMessageKind::Data(StreamDataEvent::MarketDataLite(_))
1041            ),
1042            "expected MarketDataLite"
1043        );
1044    }
1045
1046    #[test]
1047    fn subscription_channel_as_str() {
1048        assert_eq!(
1049            SubscriptionChannel::OrderSnapshot.as_str(),
1050            "order_snapshot"
1051        );
1052        assert_eq!(
1053            SubscriptionChannel::MarketDataLite.as_str(),
1054            "market_data_lite"
1055        );
1056        assert_eq!(
1057            SubscriptionChannel::PositionUpdate.as_str(),
1058            "position_update"
1059        );
1060        assert_eq!(
1061            SubscriptionChannel::BalanceSnapshot.as_str(),
1062            "balance_snapshot"
1063        );
1064        assert_eq!(SubscriptionChannel::Trade.as_str(), "trade");
1065        assert_eq!(SubscriptionChannel::Heartbeat.as_str(), "heartbeat");
1066    }
1067
1068    #[test]
1069    fn subscription_constructors_set_channel() {
1070        assert_eq!(StreamSubscription::market_data("X").channel, "market_data");
1071        assert_eq!(
1072            StreamSubscription::market_data_lite("X").channel,
1073            "market_data_lite"
1074        );
1075        assert_eq!(StreamSubscription::trades("X").channel, "trade");
1076        assert_eq!(StreamSubscription::heartbeat().channel, "heartbeat");
1077        assert_eq!(StreamSubscription::order_update().channel, "order_update");
1078        assert_eq!(
1079            StreamSubscription::position_snapshot().channel,
1080            "position_snapshot"
1081        );
1082        assert_eq!(
1083            StreamSubscription::position_update().channel,
1084            "position_update"
1085        );
1086        assert_eq!(
1087            StreamSubscription::balance_snapshot().channel,
1088            "balance_snapshot"
1089        );
1090        assert_eq!(
1091            StreamSubscription::balance_update().channel,
1092            "balance_update"
1093        );
1094    }
1095
1096    #[test]
1097    fn for_channel_constructor() {
1098        let sub = StreamSubscription::for_channel(SubscriptionChannel::BalanceUpdate);
1099        assert_eq!(sub.channel, "balance_update");
1100    }
1101
1102    #[test]
1103    fn derives_stream_url_from_gateway_base_url() {
1104        assert_eq!(
1105            derive_stream_url("https://gateway.polymarket.us"),
1106            "wss://gateway.polymarket.us/ws"
1107        );
1108        assert_eq!(
1109            normalize_stream_url("wss://custom.example/ws".to_string()),
1110            "wss://custom.example/ws"
1111        );
1112    }
1113}