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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29#[non_exhaustive]
30pub enum SubscriptionChannel {
31 OrderSnapshot,
33 OrderUpdate,
35 MarketData,
37 MarketDataLite,
39 PositionSnapshot,
41 PositionUpdate,
43 BalanceSnapshot,
45 BalanceUpdate,
47 Trade,
49 Heartbeat,
51}
52
53impl SubscriptionChannel {
54 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
77enum StreamCommand {
82 Subscribe(StreamSubscription),
83 Unsubscribe(String), }
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 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 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 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 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 pub fn for_channel(channel: SubscriptionChannel) -> Self {
341 Self::new(channel.as_str())
342 }
343
344 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 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 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 pub fn heartbeat() -> Self {
369 Self::new(SubscriptionChannel::Heartbeat.as_str())
370 }
371
372 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 pub fn order_update() -> Self {
383 Self::new(SubscriptionChannel::OrderUpdate.as_str())
384 }
385
386 pub fn position_snapshot() -> Self {
388 Self::new(SubscriptionChannel::PositionSnapshot.as_str())
389 }
390
391 pub fn position_update() -> Self {
393 Self::new(SubscriptionChannel::PositionUpdate.as_str())
394 }
395
396 pub fn balance_snapshot() -> Self {
398 Self::new(SubscriptionChannel::BalanceSnapshot.as_str())
399 }
400
401 pub fn balance_update() -> Self {
403 Self::new(SubscriptionChannel::BalanceUpdate.as_str())
404 }
405
406 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 OrderSnapshot(Value),
457 OrderUpdate(Value),
459 MarketData(Value),
461 MarketDataLite(Value),
463 OrderBookDelta(Value),
465 PositionSnapshot(Value),
467 PositionUpdate(Value),
469 BalanceSnapshot(Value),
471 BalanceUpdate(Value),
473 Trade(Value),
475 Heartbeat,
477 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 let shutdown = Arc::clone(&self.shutdown);
628 let shutdown_wait = shutdown.notified();
629 tokio::pin!(shutdown_wait);
630
631 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 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 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 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_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_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_snapshot" | "positionSnapshot" => {
806 StreamMessageKind::Data(StreamDataEvent::PositionSnapshot(payload))
807 }
808 "position_update" | "positionUpdate" => {
809 StreamMessageKind::Data(StreamDataEvent::PositionUpdate(payload))
810 }
811 "balance_snapshot" | "balanceSnapshot" => {
813 StreamMessageKind::Data(StreamDataEvent::BalanceSnapshot(payload))
814 }
815 "balance_update" | "balanceUpdate" => {
816 StreamMessageKind::Data(StreamDataEvent::BalanceUpdate(payload))
817 }
818 "heartbeat" | "ping" | "pong" => {
820 StreamMessageKind::Data(StreamDataEvent::Heartbeat)
821 }
822 "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
906fn 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}