Skip to main content

nautilus_polymarket/websocket/
handler.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! WebSocket message handler for the Polymarket CLOB API.
17
18use std::{
19    sync::{
20        Arc,
21        atomic::{AtomicBool, Ordering},
22    },
23    time::Duration,
24};
25
26use ahash::AHashMap;
27use nautilus_core::string::secret::SecretString;
28use nautilus_live::book::snapshot::SnapshotGate;
29use nautilus_network::{
30    RECONNECTED,
31    error::SendError,
32    websocket::{AuthTracker, SubscriptionState, WebSocketClient},
33};
34use serde_json::value::RawValue;
35use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; // tokio-import-ok
36use tokio_tungstenite::tungstenite::Message;
37use tokio_util::sync::CancellationToken;
38use ustr::Ustr;
39use zeroize::Zeroize;
40
41use super::{
42    client::{POLYMARKET_HEARTBEAT_PAYLOAD, POLYMARKET_HEARTBEAT_SECS, WsChannel},
43    messages::{
44        MarketInitialSubscribeRequest, MarketSubscribeRequest, MarketUnsubscribeRequest,
45        MarketWsMessage, PolymarketWsAuth, PolymarketWsMessage, UserSubscribeRequest,
46        UserWsMessage,
47    },
48};
49use crate::{common::credential::Credential, http::error::sanitize_error_text};
50
51const INITIAL_DUMP: bool = true;
52
53/// Commands sent from the outer client to the inner message handler.
54#[derive(Debug)]
55pub enum HandlerCommand {
56    /// Set the WebSocketClient for the handler to use.
57    SetClient(WebSocketClient),
58    /// Disconnect the WebSocket connection.
59    Disconnect,
60    /// Add asset IDs to the market-channel subscription set and send a subscribe message.
61    SubscribeMarket(Vec<String>),
62    /// Remove asset IDs from the subscription set and send an unsubscribe message.
63    UnsubscribeMarket(Vec<String>),
64    /// Cycle asset IDs (unsubscribe then subscribe) without changing desired
65    /// subscription state, forcing the venue to emit fresh snapshots.
66    CycleMarketSubscription {
67        asset_ids: Vec<String>,
68        cancel: CancellationToken,
69        responder: tokio::sync::oneshot::Sender<CycleMarketOutcome>,
70        gate: SnapshotGate,
71    },
72    /// Send the authenticated subscribe message on the user channel.
73    SubscribeUser,
74}
75
76/// Outcome of a recovery subscription cycle.
77#[derive(Clone, Debug)]
78pub enum CycleMarketOutcome {
79    /// Both legs wrote on one connection; a fresh snapshot should follow.
80    Completed,
81    /// The connection changed mid-cycle; reconnect replay owns restoration.
82    ConnectionChanged,
83    /// The attempt was abandoned; a written unsubscribe leg was restored.
84    Cancelled,
85    /// A genuine unsubscribe won; nothing was sent or restored.
86    NotDesired,
87    /// A wire write failed; a written unsubscribe leg was restored.
88    SendFailed(SendError),
89}
90
91pub(super) struct FeedHandler {
92    signal: Arc<AtomicBool>,
93    channel: WsChannel,
94    client: Option<WebSocketClient>,
95    cmd_rx: UnboundedReceiver<HandlerCommand>,
96    raw_rx: UnboundedReceiver<(u64, Message)>,
97    out_tx: UnboundedSender<PolymarketWsMessage>,
98    credential: Option<Credential>,
99    subscriptions: SubscriptionState,
100    discovery_subscribed: Arc<AtomicBool>,
101    initial_market_replay: Option<(Vec<String>, u64)>,
102    auth_tracker: AuthTracker,
103    // True once SubscribeUser has been explicitly requested by the caller
104    user_subscribed: bool,
105    market_heartbeat_next: Option<(tokio::time::Instant, u64)>,
106    // Assets awaiting first authoritative data, keyed by the connection that wrote the subscribe.
107    market_subscription_epochs: AHashMap<String, u64>,
108    // Overflow buffer for batched frames, drained before reading the next raw message
109    message_buffer: Vec<PolymarketWsMessage>,
110    // Whether to include `custom_feature_enabled: true` in the initial subscribe
111    subscribe_new_markets: bool,
112}
113
114impl FeedHandler {
115    #[expect(clippy::too_many_arguments)]
116    pub(super) fn new(
117        signal: Arc<AtomicBool>,
118        channel: WsChannel,
119        client: Option<WebSocketClient>,
120        cmd_rx: UnboundedReceiver<HandlerCommand>,
121        raw_rx: UnboundedReceiver<(u64, Message)>,
122        out_tx: UnboundedSender<PolymarketWsMessage>,
123        credential: Option<Credential>,
124        subscriptions: SubscriptionState,
125        discovery_subscribed: Arc<AtomicBool>,
126        initial_market_replay: Option<(Vec<String>, u64)>,
127        auth_tracker: AuthTracker,
128        user_subscribed: bool,
129        subscribe_new_markets: bool,
130    ) -> Self {
131        Self {
132            signal,
133            channel,
134            client,
135            cmd_rx,
136            raw_rx,
137            out_tx,
138            credential,
139            subscriptions,
140            discovery_subscribed,
141            initial_market_replay,
142            auth_tracker,
143            user_subscribed,
144            market_heartbeat_next: None,
145            market_subscription_epochs: AHashMap::new(),
146            message_buffer: Vec::new(),
147            subscribe_new_markets,
148        }
149    }
150
151    pub(super) fn send(&self, msg: PolymarketWsMessage) -> Result<(), String> {
152        self.out_tx
153            .send(msg)
154            .map_err(|e| format!("Failed to send message: {e}"))
155    }
156
157    pub(super) fn is_stopped(&self) -> bool {
158        self.signal.load(Ordering::Relaxed)
159    }
160
161    async fn send_subscribe_market(&mut self, asset_ids: &[String], connection_epoch: Option<u64>) {
162        if let Err(e) = self
163            .try_send_subscribe_market(asset_ids, connection_epoch)
164            .await
165        {
166            log::error!("Failed to send market subscribe: {e}");
167        }
168    }
169
170    async fn try_send_subscribe_market(
171        &mut self,
172        asset_ids: &[String],
173        connection_epoch: Option<u64>,
174    ) -> Result<(), SendError> {
175        let Some(ref client) = self.client else {
176            log::warn!("No client available for market subscribe");
177            return Err(SendError::Closed);
178        };
179
180        let connection_epoch = connection_epoch.unwrap_or_else(|| client.connection_epoch());
181
182        for id in asset_ids {
183            self.market_subscription_epochs.remove(id);
184            self.subscriptions.mark_subscribe(id);
185        }
186
187        let payload = if self.market_heartbeat_next.is_some() {
188            serde_json::to_string(&MarketSubscribeRequest {
189                assets_ids: asset_ids.to_vec(),
190                operation: "subscribe",
191                initial_dump: INITIAL_DUMP,
192                custom_feature_enabled: self.subscribe_new_markets,
193            })
194        } else {
195            serde_json::to_string(&MarketInitialSubscribeRequest {
196                assets_ids: asset_ids.to_vec(),
197                msg_type: "market",
198                initial_dump: INITIAL_DUMP,
199                custom_feature_enabled: self.subscribe_new_markets,
200            })
201        };
202
203        let payload = match payload {
204            Ok(payload) => payload,
205            Err(e) => {
206                for id in asset_ids {
207                    self.market_subscription_epochs.remove(id);
208                    self.subscriptions.mark_failure(id);
209                }
210
211                return Err(SendError::InvalidInput(e.to_string()));
212            }
213        };
214
215        match client
216            .send_text_on_connection(payload, None, connection_epoch)
217            .await
218        {
219            Ok(()) => {
220                for id in asset_ids {
221                    if self.market_subscription_pending(id) {
222                        self.market_subscription_epochs
223                            .insert(id.clone(), connection_epoch);
224                    }
225                }
226
227                if self.market_heartbeat_next.is_none() {
228                    self.schedule_market_heartbeat(connection_epoch);
229                }
230
231                Ok(())
232            }
233            Err(e) => {
234                for id in asset_ids {
235                    self.market_subscription_epochs.remove(id);
236                    self.subscriptions.mark_failure(id);
237                }
238
239                Err(e)
240            }
241        }
242    }
243
244    async fn send_unsubscribe_market(&self, asset_ids: &[String]) {
245        let Some(epoch) = self.client.as_ref().map(|client| client.connection_epoch()) else {
246            log::warn!("No client available for market unsubscribe");
247            return;
248        };
249
250        // Epoch-bound: a reconnect drops the send instead of buffering it onto
251        // the new connection, where the replayed subscription set no longer
252        // includes these assets.
253        match self.try_send_unsubscribe_market(asset_ids, epoch).await {
254            Ok(()) => {}
255            Err(SendError::ConnectionChanged) => {
256                log::debug!(
257                    "Dropped market unsubscribe during reconnect; replay excludes the assets"
258                );
259            }
260            Err(e) => {
261                log::error!("Failed to send market unsubscribe: {e}");
262            }
263        }
264    }
265
266    async fn try_send_unsubscribe_market(
267        &self,
268        asset_ids: &[String],
269        connection_epoch: u64,
270    ) -> Result<(), SendError> {
271        let Some(ref client) = self.client else {
272            return Err(SendError::Closed);
273        };
274
275        let req = MarketUnsubscribeRequest {
276            assets_ids: asset_ids.to_vec(),
277            operation: "unsubscribe",
278        };
279
280        let payload =
281            serde_json::to_string(&req).map_err(|e| SendError::InvalidInput(e.to_string()))?;
282        client
283            .send_text_on_connection(payload, None, connection_epoch)
284            .await
285    }
286
287    async fn cycle_market_subscription(
288        &mut self,
289        asset_ids: &[String],
290        cancel: &CancellationToken,
291        gate: &SnapshotGate,
292    ) -> CycleMarketOutcome {
293        if cancel.is_cancelled() {
294            return CycleMarketOutcome::Cancelled;
295        }
296
297        let Some(epoch) = self.client.as_ref().map(|client| client.connection_epoch()) else {
298            return CycleMarketOutcome::SendFailed(SendError::Closed);
299        };
300
301        if asset_ids
302            .iter()
303            .any(|id| !self.market_subscription_desired(id))
304        {
305            return CycleMarketOutcome::NotDesired;
306        }
307
308        // Leg 1: venue-side unsubscribe. Desired state is untouched: the pool
309        // retains ownership throughout the cycle.
310        match self.try_send_unsubscribe_market(asset_ids, epoch).await {
311            Ok(()) | Err(SendError::WriteTimeout) => {}
312            Err(SendError::ConnectionChanged) => {
313                return CycleMarketOutcome::ConnectionChanged;
314            }
315            Err(e) => return CycleMarketOutcome::SendFailed(e),
316        }
317
318        if cancel.is_cancelled() {
319            self.restore_market_subscription(asset_ids, epoch).await;
320            return CycleMarketOutcome::Cancelled;
321        }
322
323        if asset_ids
324            .iter()
325            .any(|id| !self.market_subscription_desired(id))
326        {
327            // A genuine unsubscribe won after the first leg: the venue state
328            // already matches desire, so no restore is needed.
329            return CycleMarketOutcome::NotDesired;
330        }
331
332        let current = self.client.as_ref().map(|client| client.connection_epoch());
333
334        if current != Some(epoch) {
335            // Reconnect replay owns restoration on the new connection.
336            return CycleMarketOutcome::ConnectionChanged;
337        }
338
339        // Leg 2: fresh subscribe on the same connection.
340        match self.try_send_subscribe_market(asset_ids, Some(epoch)).await {
341            Ok(()) => {
342                // Open before returning so the gate precedes every later raw
343                // read on this task; a queued fresh snapshot cannot miss it.
344                gate.open();
345                CycleMarketOutcome::Completed
346            }
347            Err(SendError::ConnectionChanged) => CycleMarketOutcome::ConnectionChanged,
348            Err(e) => {
349                self.restore_market_subscription(asset_ids, epoch).await;
350                CycleMarketOutcome::SendFailed(e)
351            }
352        }
353    }
354
355    async fn restore_market_subscription(&mut self, asset_ids: &[String], epoch: u64) {
356        // Best effort: on failure the next recovery attempt or reconnect
357        // replay owns restoration from the retained desired state.
358        if let Err(e) = self.try_send_subscribe_market(asset_ids, Some(epoch)).await {
359            log::warn!("Failed to restore market subscription after cycle abort: {e}");
360        }
361    }
362
363    fn market_subscription_desired(&self, asset_id: &str) -> bool {
364        self.subscriptions
365            .is_subscribed(&Ustr::from(asset_id), &Ustr::from(""))
366    }
367
368    async fn send_subscribe_user(&self) {
369        let Some(ref client) = self.client else {
370            log::warn!("No client available for user subscribe");
371            return;
372        };
373        let Some(cred) = &self.credential else {
374            log::error!("User channel subscribe requires credential");
375            return;
376        };
377
378        let mut req = UserSubscribeRequest {
379            auth: PolymarketWsAuth {
380                api_key: SecretString::from(cred.api_key_str()),
381                secret: cred.api_secret(),
382                passphrase: SecretString::from(cred.passphrase()),
383            },
384            msg_type: "user",
385        };
386
387        // Begin auth tracking; discard receiver, state is queried via is_authenticated()
388        drop(self.auth_tracker.begin());
389
390        let payload = serde_json::to_string(&req);
391        req.zeroize();
392
393        match payload {
394            Ok(payload) => {
395                // auth_tracker.succeed() is NOT called here; sending the request only
396                // confirms delivery to the server, not that the credentials were accepted.
397                // succeed() is called in next() when the server actually sends user-channel
398                // data, which is the real confirmation that authentication worked.
399                if let Err(e) = client.send_text(payload, None).await {
400                    self.auth_tracker.fail(e.to_string());
401                    log::error!("Failed to send user subscribe: {e}");
402                }
403            }
404            Err(e) => {
405                self.auth_tracker.fail(format!("Serialize error: {e}"));
406                log::error!("Failed to serialize user subscribe request: {e}");
407            }
408        }
409    }
410
411    async fn resubscribe_all(&mut self, connection_epoch: u64) {
412        match self.channel {
413            WsChannel::Market => {
414                let ids = self.subscriptions.reset_after_reconnect();
415                if ids.is_empty() && !self.discovery_subscribed.load(Ordering::Relaxed) {
416                    return;
417                }
418                log::info!(
419                    "Restoring market subscription state after reconnect: assets={}, discovery={}",
420                    ids.len(),
421                    self.discovery_subscribed.load(Ordering::Relaxed),
422                );
423                self.send_subscribe_market(&ids, Some(connection_epoch))
424                    .await;
425            }
426            WsChannel::User => {
427                if self.user_subscribed {
428                    log::info!("Re-authenticating user channel after reconnect");
429                    self.send_subscribe_user().await;
430                }
431            }
432        }
433    }
434
435    fn parse_messages(&self, text: &str) -> Vec<PolymarketWsMessage> {
436        // When `subscribe_new_markets` is enabled, Polymarket's WSS periodically
437        // sends the plain-text string "NO NEW ASSETS" as a heartbeat/ack.
438        if text == "NO NEW ASSETS" {
439            return vec![];
440        }
441
442        // Reply to the application-level `PING` heartbeat, which is not JSON
443        if text == "PONG" {
444            return vec![];
445        }
446
447        match self.channel {
448            WsChannel::Market => {
449                if let Ok(msgs) = serde_json::from_str::<Vec<&RawValue>>(text) {
450                    msgs.into_iter()
451                        .filter_map(|raw| match MarketWsMessage::parse(raw.get()) {
452                            Ok(msg) => Some(PolymarketWsMessage::Market(msg)),
453                            Err(e) => {
454                                log::warn!("Failed to parse market WS batch element: {e}");
455                                None
456                            }
457                        })
458                        .collect()
459                } else {
460                    match MarketWsMessage::parse(text) {
461                        Ok(msg) => vec![PolymarketWsMessage::Market(msg)],
462                        Err(e) => {
463                            log::warn!(
464                                "Failed to parse market WS message: {e}; payload={}",
465                                sanitize_error_text(text)
466                            );
467                            vec![]
468                        }
469                    }
470                }
471            }
472            WsChannel::User => {
473                if let Ok(msgs) = UserWsMessage::parse_batch(text) {
474                    msgs.into_iter().map(PolymarketWsMessage::User).collect()
475                } else {
476                    match UserWsMessage::parse(text) {
477                        Ok(msg) => vec![PolymarketWsMessage::User(msg)],
478                        Err(_) if UserWsMessage::has_unrecognized_event_type(text) => {
479                            log::debug!("Skipped user WS message with an unrecognized event_type");
480                            vec![]
481                        }
482                        Err(e) => {
483                            log::warn!(
484                                "Failed to parse user WS message: {e}; payload={}",
485                                sanitize_error_text(text)
486                            );
487                            vec![]
488                        }
489                    }
490                }
491            }
492        }
493    }
494
495    pub(super) async fn next(&mut self) -> Option<PolymarketWsMessage> {
496        if !self.message_buffer.is_empty() {
497            return Some(self.message_buffer.remove(0));
498        }
499
500        if let Some((asset_ids, connection_epoch)) = self.initial_market_replay.take() {
501            self.send_subscribe_market(&asset_ids, Some(connection_epoch))
502                .await;
503        }
504
505        loop {
506            let market_heartbeat_next = self.market_heartbeat_next;
507
508            tokio::select! {
509                connection_epoch = async move {
510                    if let Some((deadline, connection_epoch)) = market_heartbeat_next {
511                        tokio::time::sleep_until(deadline).await;
512                        connection_epoch
513                    } else {
514                        std::future::pending::<u64>().await
515                    }
516                } => {
517                    self.send_market_heartbeat(connection_epoch).await;
518                    self.schedule_market_heartbeat(connection_epoch);
519                }
520                Some(cmd) = self.cmd_rx.recv() => {
521                    match cmd {
522                        HandlerCommand::SetClient(client) => {
523                            log::debug!("Setting WebSocket client in handler");
524                            self.client = Some(client);
525                        }
526                        HandlerCommand::Disconnect => {
527                            log::debug!("Handler received disconnect command");
528
529                            if let Some(ref client) = self.client {
530                                client.disconnect().await;
531                            }
532                            self.signal.store(true, Ordering::SeqCst);
533                            return None;
534                        }
535                        HandlerCommand::SubscribeMarket(ids) => {
536                            if self.subscribe_new_markets && ids.is_empty() {
537                                self.discovery_subscribed.store(true, Ordering::Relaxed);
538                            }
539                            self.send_subscribe_market(&ids, None).await;
540                        }
541                        HandlerCommand::UnsubscribeMarket(ids) => {
542                            for id in &ids {
543                                self.market_subscription_epochs.remove(id);
544                                self.subscriptions.mark_unsubscribe(id);
545                            }
546                            self.send_unsubscribe_market(&ids).await;
547                            for id in &ids {
548                                self.subscriptions.confirm_unsubscribe(id);
549                            }
550                        }
551                        HandlerCommand::CycleMarketSubscription {
552                            asset_ids,
553                            cancel,
554                            responder,
555                            gate,
556                        } => {
557                            let outcome = self
558                                .cycle_market_subscription(&asset_ids, &cancel, &gate)
559                                .await;
560                            let _ = responder.send(outcome);
561                        }
562                        HandlerCommand::SubscribeUser => {
563                            self.user_subscribed = true;
564                            self.send_subscribe_user().await;
565                        }
566                    }
567                }
568                Some((connection_epoch, raw)) = self.raw_rx.recv() => {
569                    match raw {
570                        Message::Text(text) => {
571                            if text == RECONNECTED {
572                                self.market_heartbeat_next = None;
573                                self.resubscribe_all(connection_epoch).await;
574                                return Some(PolymarketWsMessage::Reconnected { shard_id: None });
575                            }
576                            let msgs = self.parse_messages(&text);
577                            if msgs.is_empty() {
578                                continue;
579                            }
580
581                            if self.channel == WsChannel::Market {
582                                self.confirm_market_subscriptions(connection_epoch, &msgs);
583                            } else {
584                                // Receiving any user-channel data confirms the server accepted the
585                                // credentials; mark auth as successful on the first delivery.
586                                self.auth_tracker.succeed();
587                            }
588                            // Buffer msgs[1..] so they are returned in order on subsequent
589                            // next() calls; returning first directly preserves 0,1,2,...,n order
590                            let mut iter = msgs.into_iter();
591                            let first = iter.next().unwrap();
592                            self.message_buffer.extend(iter);
593                            return Some(first);
594                        }
595                        Message::Ping(data) => {
596                            if let Some(ref client) = self.client
597                                && let Err(e) = client.send_pong(data.to_vec()).await
598                            {
599                                log::warn!("Failed to send pong: {e}");
600                            }
601                        }
602                        Message::Close(_) => {
603                            log::debug!("WebSocket close frame received");
604                            return None;
605                        }
606                        _ => {}
607                    }
608                }
609                else => return None,
610            }
611        }
612    }
613
614    fn schedule_market_heartbeat(&mut self, connection_epoch: u64) {
615        self.market_heartbeat_next = Some((
616            tokio::time::Instant::now() + Duration::from_secs(POLYMARKET_HEARTBEAT_SECS),
617            connection_epoch,
618        ));
619    }
620
621    async fn send_market_heartbeat(&self, connection_epoch: u64) {
622        let Some(ref client) = self.client else {
623            return;
624        };
625
626        if let Err(e) = client
627            .send_text_on_connection(
628                POLYMARKET_HEARTBEAT_PAYLOAD.to_string(),
629                None,
630                connection_epoch,
631            )
632            .await
633        {
634            log::debug!("Failed to send market heartbeat: {e}");
635        }
636    }
637
638    fn confirm_market_subscriptions(
639        &mut self,
640        connection_epoch: u64,
641        messages: &[PolymarketWsMessage],
642    ) {
643        for message in messages {
644            let asset_id = match message {
645                PolymarketWsMessage::Market(MarketWsMessage::Book(book)) => &book.asset_id,
646                PolymarketWsMessage::Market(MarketWsMessage::LastTradePrice(trade)) => {
647                    &trade.asset_id
648                }
649                _ => continue,
650            };
651
652            let was_sent_on_connection = self
653                .market_subscription_epochs
654                .get(asset_id.as_str())
655                .is_some_and(|epoch| *epoch == connection_epoch);
656
657            if was_sent_on_connection && self.market_subscription_pending(asset_id.as_str()) {
658                self.subscriptions.confirm_subscribe(asset_id.as_str());
659                self.market_subscription_epochs.remove(asset_id.as_str());
660            }
661        }
662    }
663
664    fn market_subscription_pending(&self, asset_id: &str) -> bool {
665        let channel_level = Ustr::from("");
666        let asset_id = Ustr::from(asset_id);
667        self.subscriptions
668            .pending_subscribe()
669            .get(&asset_id)
670            .is_some_and(|symbols| symbols.contains(&channel_level))
671    }
672}
673
674#[cfg(test)]
675mod tests {
676    use std::time::Duration;
677
678    use futures_util::{SinkExt, StreamExt};
679    use nautilus_common::testing::wait_until_async;
680    use nautilus_network::websocket::{TransportBackend, WebSocketConfig, channel_message_handler};
681    use parking_lot::Mutex;
682    use rstest::{fixture, rstest};
683    use serde_json::{Value, json};
684
685    use super::*;
686    use crate::common::enums::PolymarketOrderSide;
687
688    const MARKET_ASSET_ID: &str =
689        "71321045679252212594626385532706912750332728571942532289631379312455583992563";
690
691    #[fixture]
692    fn market_handler() -> FeedHandler {
693        feed_handler(WsChannel::Market)
694    }
695
696    #[fixture]
697    fn user_handler() -> FeedHandler {
698        feed_handler(WsChannel::User)
699    }
700
701    fn feed_handler(channel: WsChannel) -> FeedHandler {
702        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
703        let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
704        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
705
706        FeedHandler::new(
707            Arc::new(AtomicBool::new(false)),
708            channel,
709            None,
710            cmd_rx,
711            raw_rx,
712            out_tx,
713            None,
714            SubscriptionState::new(':'),
715            Arc::new(AtomicBool::new(false)),
716            None,
717            AuthTracker::new(),
718            false,
719            false,
720        )
721    }
722
723    async fn recording_server() -> (String, Arc<Mutex<Vec<String>>>) {
724        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
725            .await
726            .expect("bind recording server");
727        let addr = listener.local_addr().expect("recording server address");
728        let messages = Arc::new(Mutex::new(Vec::new()));
729        let received = Arc::clone(&messages);
730
731        tokio::spawn(async move {
732            let (stream, _) = listener.accept().await.expect("accept websocket client");
733            let mut socket = tokio_tungstenite::accept_async(stream)
734                .await
735                .expect("accept websocket handshake");
736
737            while let Some(message) = socket.next().await {
738                match message.expect("read websocket message") {
739                    Message::Text(text) => received.lock().push(text.to_string()),
740                    Message::Close(_) => break,
741                    _ => {}
742                }
743            }
744        });
745
746        (format!("ws://{addr}"), messages)
747    }
748
749    async fn recording_client(url: String) -> WebSocketClient {
750        let config = WebSocketConfig::builder()
751            .url(url)
752            .backend(TransportBackend::Tungstenite)
753            .build()
754            .expect("valid websocket config");
755        let (message_handler, _message_rx) = channel_message_handler();
756        WebSocketClient::builder()
757            .config(config)
758            .message_handler(message_handler)
759            .connect()
760            .await
761            .expect("connect websocket client")
762    }
763
764    fn market_handler_with(
765        client: WebSocketClient,
766    ) -> (
767        FeedHandler,
768        UnboundedSender<(u64, Message)>,
769        SubscriptionState,
770    ) {
771        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
772        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
773        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
774        let subscriptions = SubscriptionState::new(':');
775        let handler = FeedHandler::new(
776            Arc::new(AtomicBool::new(false)),
777            WsChannel::Market,
778            Some(client),
779            cmd_rx,
780            raw_rx,
781            out_tx,
782            None,
783            subscriptions.clone(),
784            Arc::new(AtomicBool::new(false)),
785            None,
786            AuthTracker::new(),
787            false,
788            false,
789        );
790
791        (handler, raw_tx, subscriptions)
792    }
793
794    fn market_handler_with_cmd_tx(
795        client: WebSocketClient,
796    ) -> (
797        FeedHandler,
798        tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
799        UnboundedSender<(u64, Message)>,
800        SubscriptionState,
801    ) {
802        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
803        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
804        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
805        let subscriptions = SubscriptionState::new(':');
806
807        let handler = FeedHandler::new(
808            Arc::new(AtomicBool::new(false)),
809            WsChannel::Market,
810            Some(client),
811            cmd_rx,
812            raw_rx,
813            out_tx,
814            None,
815            subscriptions.clone(),
816            Arc::new(AtomicBool::new(false)),
817            None,
818            AuthTracker::new(),
819            false,
820            false,
821        );
822
823        (handler, cmd_tx, raw_tx, subscriptions)
824    }
825
826    #[rstest]
827    #[tokio::test]
828    async fn initial_market_replay_recovers_on_current_connection_epoch() {
829        let (url, messages) = recording_server().await;
830        let client = recording_client(url).await;
831
832        let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
833        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
834        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
835        let mut handler = FeedHandler::new(
836            Arc::new(AtomicBool::new(false)),
837            WsChannel::Market,
838            Some(client),
839            cmd_rx,
840            raw_rx,
841            out_tx,
842            None,
843            SubscriptionState::new(':'),
844            Arc::new(AtomicBool::new(true)),
845            Some((vec![], 1)),
846            AuthTracker::new(),
847            false,
848            true,
849        );
850        raw_tx
851            .send((0, Message::Text(RECONNECTED.into())))
852            .expect("queue reconnect notification");
853
854        assert!(matches!(
855            handler.next().await,
856            Some(PolymarketWsMessage::Reconnected { .. }),
857        ));
858        handler
859            .client
860            .as_ref()
861            .expect("websocket client")
862            .send_text_on_connection("barrier".to_string(), None, 0)
863            .await
864            .expect("send barrier on current connection");
865
866        wait_until_async(
867            || {
868                let messages = Arc::clone(&messages);
869                async move { messages.lock().len() >= 2 }
870            },
871            Duration::from_secs(1),
872        )
873        .await;
874
875        {
876            let messages = messages.lock();
877            assert_eq!(messages.len(), 2);
878            assert_eq!(
879                serde_json::from_str::<Value>(&messages[0]).expect("valid subscribe payload"),
880                json!({
881                    "assets_ids": [],
882                    "type": "market",
883                    "initial_dump": true,
884                    "custom_feature_enabled": true,
885                }),
886            );
887            assert_eq!(messages[1], "barrier");
888        }
889
890        handler
891            .client
892            .as_ref()
893            .expect("websocket client")
894            .disconnect()
895            .await;
896    }
897
898    #[rstest]
899    #[tokio::test(start_paused = true)]
900    async fn market_text_heartbeat_follows_initial_subscription() {
901        let (url, messages) = recording_server().await;
902        let client = recording_client(url).await;
903        let connection_epoch = client.connection_epoch();
904        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
905        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
906        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
907        let mut handler = FeedHandler::new(
908            Arc::new(AtomicBool::new(false)),
909            WsChannel::Market,
910            Some(client),
911            cmd_rx,
912            raw_rx,
913            out_tx,
914            None,
915            SubscriptionState::new(':'),
916            Arc::new(AtomicBool::new(false)),
917            None,
918            AuthTracker::new(),
919            false,
920            false,
921        );
922
923        handler
924            .send_subscribe_market(&[MARKET_ASSET_ID.to_string()], None)
925            .await;
926        wait_for_recorded_messages(&messages, 1).await;
927
928        let task = tokio::spawn(async move {
929            let message = handler.next().await;
930            (handler, message)
931        });
932        tokio::task::yield_now().await;
933        tokio::time::advance(Duration::from_secs(POLYMARKET_HEARTBEAT_SECS)).await;
934        wait_for_recorded_messages(&messages, 2).await;
935
936        raw_tx
937            .send((connection_epoch, Message::Text(RECONNECTED.into())))
938            .expect("queue reconnect notification");
939        let (mut handler, message) = task.await.expect("join handler task");
940        assert!(matches!(
941            message,
942            Some(PolymarketWsMessage::Reconnected { .. })
943        ));
944        wait_for_recorded_messages(&messages, 3).await;
945
946        let task = tokio::spawn(async move {
947            let message = handler.next().await;
948            (handler, message)
949        });
950        tokio::task::yield_now().await;
951        tokio::time::advance(Duration::from_secs(POLYMARKET_HEARTBEAT_SECS)).await;
952        wait_for_recorded_messages(&messages, 4).await;
953
954        cmd_tx
955            .send(HandlerCommand::Disconnect)
956            .expect("queue disconnect");
957        let (_, message) = task.await.expect("join handler task");
958        assert!(message.is_none());
959
960        let messages = messages.lock().clone();
961        let expected_subscription = json!({
962            "assets_ids": [MARKET_ASSET_ID],
963            "type": "market",
964            "initial_dump": true,
965        });
966        assert_eq!(messages.len(), 4);
967        assert_eq!(
968            serde_json::from_str::<Value>(&messages[0]).expect("valid subscribe payload"),
969            expected_subscription,
970        );
971        assert_eq!(messages[1], POLYMARKET_HEARTBEAT_PAYLOAD);
972        assert_eq!(
973            serde_json::from_str::<Value>(&messages[2]).expect("valid replay payload"),
974            expected_subscription,
975        );
976        assert_eq!(messages[3], POLYMARKET_HEARTBEAT_PAYLOAD);
977    }
978
979    #[rstest]
980    #[tokio::test(start_paused = true)]
981    async fn incremental_market_subscription_preserves_heartbeat_deadline() {
982        let (url, messages) = recording_server().await;
983        let client = recording_client(url).await;
984        let connection_epoch = client.connection_epoch();
985        let (mut handler, _raw_tx, _) = market_handler_with(client);
986
987        handler
988            .try_send_subscribe_market(&[MARKET_ASSET_ID.to_string()], None)
989            .await
990            .expect("send initial subscription");
991        let heartbeat_next = Some((
992            tokio::time::Instant::now() + Duration::from_secs(POLYMARKET_HEARTBEAT_SECS),
993            connection_epoch,
994        ));
995        assert_eq!(handler.market_heartbeat_next, heartbeat_next);
996
997        tokio::time::advance(Duration::from_secs(POLYMARKET_HEARTBEAT_SECS / 2)).await;
998        handler
999            .try_send_subscribe_market(&["second-asset".to_string()], None)
1000            .await
1001            .expect("send incremental subscription");
1002        wait_for_recorded_messages(&messages, 2).await;
1003
1004        let messages = messages.lock().clone();
1005        assert_eq!(handler.market_heartbeat_next, heartbeat_next);
1006        assert_eq!(messages.len(), 2);
1007        assert_eq!(
1008            serde_json::from_str::<Value>(&messages[0]).expect("valid initial payload"),
1009            json!({
1010                "assets_ids": [MARKET_ASSET_ID],
1011                "type": "market",
1012                "initial_dump": true,
1013            }),
1014        );
1015        assert_eq!(
1016            serde_json::from_str::<Value>(&messages[1]).expect("valid incremental payload"),
1017            json!({
1018                "assets_ids": ["second-asset"],
1019                "operation": "subscribe",
1020                "initial_dump": true,
1021            }),
1022        );
1023    }
1024
1025    #[rstest]
1026    #[tokio::test]
1027    async fn market_heartbeat_stays_bound_to_subscribed_connection() {
1028        let (url, messages) = recording_server().await;
1029        let client = recording_client(url).await;
1030        let connection_epoch = client.connection_epoch();
1031        let connection_epoch_atomic = client.connection_epoch_atomic();
1032        let (mut handler, raw_tx, _) = market_handler_with(client);
1033
1034        handler
1035            .send_subscribe_market(&[MARKET_ASSET_ID.to_string()], None)
1036            .await;
1037        wait_for_recorded_messages(&messages, 1).await;
1038
1039        let replacement_epoch = connection_epoch + 1;
1040        connection_epoch_atomic.store(replacement_epoch, Ordering::Release);
1041        handler.send_market_heartbeat(connection_epoch).await;
1042        raw_tx
1043            .send((replacement_epoch, Message::Text(RECONNECTED.into())))
1044            .expect("queue reconnect notification");
1045
1046        assert!(matches!(
1047            handler.next().await,
1048            Some(PolymarketWsMessage::Reconnected { .. }),
1049        ));
1050        handler
1051            .client
1052            .as_ref()
1053            .expect("websocket client")
1054            .send_text_on_connection("barrier".to_string(), None, replacement_epoch)
1055            .await
1056            .expect("send barrier on replacement connection");
1057        wait_until_async(
1058            || {
1059                let messages = Arc::clone(&messages);
1060                async move {
1061                    messages
1062                        .lock()
1063                        .last()
1064                        .is_some_and(|message| message == "barrier")
1065                }
1066            },
1067            Duration::from_secs(1),
1068        )
1069        .await;
1070
1071        let messages = messages.lock().clone();
1072        let expected_subscription = json!({
1073            "assets_ids": [MARKET_ASSET_ID],
1074            "type": "market",
1075            "initial_dump": true,
1076        });
1077        assert_eq!(messages.len(), 3);
1078        assert_eq!(
1079            serde_json::from_str::<Value>(&messages[0]).expect("valid subscribe payload"),
1080            expected_subscription,
1081        );
1082        assert_eq!(
1083            serde_json::from_str::<Value>(&messages[1]).expect("valid replay payload"),
1084            expected_subscription,
1085        );
1086        assert_eq!(messages[2], "barrier");
1087
1088        handler
1089            .client
1090            .as_ref()
1091            .expect("websocket client")
1092            .disconnect()
1093            .await;
1094    }
1095
1096    async fn wait_for_recorded_messages(messages: &Arc<Mutex<Vec<String>>>, expected: usize) {
1097        wait_until_async(
1098            || {
1099                let messages = Arc::clone(messages);
1100                async move { messages.lock().len() == expected }
1101            },
1102            Duration::from_secs(1),
1103        )
1104        .await;
1105    }
1106
1107    #[rstest]
1108    #[case(include_str!("../../test_data/ws_market_book_msg.json"))]
1109    #[case(include_str!("../../test_data/ws_market_last_trade_msg.json"))]
1110    #[tokio::test]
1111    async fn market_subscription_confirms_from_first_book_or_trade(#[case] payload: &str) {
1112        let (url, messages) = recording_server().await;
1113        let client = recording_client(url).await;
1114        let (mut handler, raw_tx, subscriptions) = market_handler_with(client);
1115
1116        handler
1117            .send_subscribe_market(&[MARKET_ASSET_ID.to_string()], None)
1118            .await;
1119        wait_until_async(
1120            || {
1121                let messages = Arc::clone(&messages);
1122                async move { !messages.lock().is_empty() }
1123            },
1124            Duration::from_secs(1),
1125        )
1126        .await;
1127
1128        assert_eq!(
1129            subscriptions.pending_subscribe_topics(),
1130            vec![MARKET_ASSET_ID]
1131        );
1132        assert_eq!(subscriptions.len(), 0);
1133
1134        raw_tx
1135            .send((0, Message::Text(payload.into())))
1136            .expect("queue market data");
1137        assert!(matches!(
1138            handler.next().await,
1139            Some(PolymarketWsMessage::Market(_)),
1140        ));
1141
1142        assert!(subscriptions.pending_subscribe_topics().is_empty());
1143        assert_eq!(subscriptions.len(), 1);
1144
1145        handler
1146            .client
1147            .as_ref()
1148            .expect("websocket client")
1149            .disconnect()
1150            .await;
1151    }
1152
1153    #[rstest]
1154    fn unsolicited_market_data_does_not_create_subscription(mut market_handler: FeedHandler) {
1155        let messages =
1156            market_handler.parse_messages(include_str!("../../test_data/ws_market_book_msg.json"));
1157
1158        market_handler.confirm_market_subscriptions(0, &messages);
1159
1160        assert!(market_handler.subscriptions.is_empty());
1161    }
1162
1163    #[rstest]
1164    fn market_batch_confirms_trade_but_not_price_change(mut market_handler: FeedHandler) {
1165        let price_change_asset_id = "101";
1166        let trade_asset_id = "202";
1167        market_handler
1168            .subscriptions
1169            .mark_subscribe(price_change_asset_id);
1170        market_handler.subscriptions.mark_subscribe(trade_asset_id);
1171        market_handler
1172            .market_subscription_epochs
1173            .insert(price_change_asset_id.to_string(), 0);
1174        market_handler
1175            .market_subscription_epochs
1176            .insert(trade_asset_id.to_string(), 0);
1177        let messages = market_handler.parse_messages(include_str!(
1178            "../../test_data/ws_market_mixed_known_unknown.json"
1179        ));
1180
1181        market_handler.confirm_market_subscriptions(0, &messages);
1182
1183        assert_eq!(
1184            market_handler.subscriptions.pending_subscribe_topics(),
1185            vec![price_change_asset_id]
1186        );
1187        assert_eq!(market_handler.subscriptions.len(), 1);
1188    }
1189
1190    #[rstest]
1191    fn market_subscription_confirmation_requires_sent_current_epoch(
1192        mut market_handler: FeedHandler,
1193    ) {
1194        market_handler.subscriptions.mark_subscribe(MARKET_ASSET_ID);
1195        let messages =
1196            market_handler.parse_messages(include_str!("../../test_data/ws_market_book_msg.json"));
1197
1198        market_handler.confirm_market_subscriptions(0, &messages);
1199        assert_eq!(
1200            market_handler.subscriptions.pending_subscribe_topics(),
1201            vec![MARKET_ASSET_ID]
1202        );
1203        assert_eq!(market_handler.subscriptions.len(), 0);
1204
1205        market_handler
1206            .market_subscription_epochs
1207            .insert(MARKET_ASSET_ID.to_string(), 1);
1208        market_handler.confirm_market_subscriptions(0, &messages);
1209        assert_eq!(
1210            market_handler.subscriptions.pending_subscribe_topics(),
1211            vec![MARKET_ASSET_ID]
1212        );
1213        assert_eq!(market_handler.subscriptions.len(), 0);
1214
1215        market_handler.confirm_market_subscriptions(1, &messages);
1216        assert!(
1217            market_handler
1218                .subscriptions
1219                .pending_subscribe_topics()
1220                .is_empty()
1221        );
1222        assert_eq!(market_handler.subscriptions.len(), 1);
1223    }
1224
1225    #[rstest]
1226    #[tokio::test]
1227    async fn reconnect_replay_requires_current_connection_data() {
1228        let cancelled_asset_id = "cancelled-asset";
1229        let (url, _) = recording_server().await;
1230        let client = recording_client(url).await;
1231        let connection_epoch = client.connection_epoch();
1232        let (mut handler, raw_tx, subscriptions) = market_handler_with(client);
1233        subscriptions.mark_subscribe(MARKET_ASSET_ID);
1234        subscriptions.confirm_subscribe(MARKET_ASSET_ID);
1235        subscriptions.mark_subscribe(cancelled_asset_id);
1236        subscriptions.confirm_subscribe(cancelled_asset_id);
1237        subscriptions.mark_unsubscribe(cancelled_asset_id);
1238
1239        handler.resubscribe_all(connection_epoch).await;
1240
1241        assert_eq!(
1242            subscriptions.pending_subscribe_topics(),
1243            vec![MARKET_ASSET_ID]
1244        );
1245        assert!(subscriptions.pending_unsubscribe_topics().is_empty());
1246        assert_eq!(subscriptions.len(), 0);
1247
1248        raw_tx
1249            .send((
1250                connection_epoch,
1251                Message::Text(include_str!("../../test_data/ws_market_book_msg.json").into()),
1252            ))
1253            .expect("queue market data");
1254        assert!(matches!(
1255            handler.next().await,
1256            Some(PolymarketWsMessage::Market(_)),
1257        ));
1258
1259        assert!(subscriptions.pending_subscribe_topics().is_empty());
1260        assert_eq!(subscriptions.len(), 1);
1261
1262        handler
1263            .client
1264            .as_ref()
1265            .expect("websocket client")
1266            .disconnect()
1267            .await;
1268    }
1269
1270    #[rstest]
1271    #[tokio::test]
1272    async fn failed_market_subscribe_stays_pending_for_reconnect_replay() {
1273        let (url, _) = recording_server().await;
1274        let client = recording_client(url).await;
1275        client.disconnect().await;
1276        let (mut handler, raw_tx, subscriptions) = market_handler_with(client);
1277        subscriptions.mark_subscribe(MARKET_ASSET_ID);
1278        subscriptions.confirm_subscribe(MARKET_ASSET_ID);
1279
1280        assert!(subscriptions.pending_subscribe_topics().is_empty());
1281        assert_eq!(subscriptions.len(), 1);
1282
1283        handler
1284            .send_subscribe_market(&[MARKET_ASSET_ID.to_string()], None)
1285            .await;
1286
1287        assert_eq!(
1288            subscriptions.pending_subscribe_topics(),
1289            vec![MARKET_ASSET_ID]
1290        );
1291        assert_eq!(subscriptions.len(), 0);
1292
1293        raw_tx
1294            .send((
1295                0,
1296                Message::Text(include_str!("../../test_data/ws_market_book_msg.json").into()),
1297            ))
1298            .expect("queue stale market data");
1299        assert!(matches!(
1300            handler.next().await,
1301            Some(PolymarketWsMessage::Market(_)),
1302        ));
1303        assert_eq!(
1304            subscriptions.pending_subscribe_topics(),
1305            vec![MARKET_ASSET_ID]
1306        );
1307        assert_eq!(subscriptions.len(), 0);
1308
1309        let (replay_url, messages) = recording_server().await;
1310        let replay_client = recording_client(replay_url).await;
1311        let connection_epoch = replay_client.connection_epoch();
1312        handler.client = Some(replay_client);
1313        handler.resubscribe_all(connection_epoch).await;
1314        wait_until_async(
1315            || {
1316                let messages = Arc::clone(&messages);
1317                async move { !messages.lock().is_empty() }
1318            },
1319            Duration::from_secs(1),
1320        )
1321        .await;
1322
1323        {
1324            let messages = messages.lock();
1325            assert_eq!(messages.len(), 1);
1326            assert_eq!(
1327                serde_json::from_str::<Value>(&messages[0]).expect("valid subscribe payload"),
1328                json!({
1329                    "assets_ids": [MARKET_ASSET_ID],
1330                    "type": "market",
1331                    "initial_dump": true,
1332                }),
1333            );
1334        }
1335        assert_eq!(
1336            subscriptions.pending_subscribe_topics(),
1337            vec![MARKET_ASSET_ID]
1338        );
1339        assert_eq!(subscriptions.len(), 0);
1340
1341        handler
1342            .client
1343            .as_ref()
1344            .expect("websocket client")
1345            .disconnect()
1346            .await;
1347    }
1348
1349    #[rstest]
1350    fn test_parse_market_batch_skips_unknown_event(market_handler: FeedHandler) {
1351        let messages = market_handler.parse_messages(include_str!(
1352            "../../test_data/ws_market_mixed_known_unknown.json"
1353        ));
1354
1355        assert_eq!(messages.len(), 2);
1356
1357        let PolymarketWsMessage::Market(MarketWsMessage::PriceChange(quotes)) = &messages[0] else {
1358            panic!("Expected first message to be a price change");
1359        };
1360        assert_eq!(
1361            quotes.market,
1362            "0x1111111111111111111111111111111111111111111111111111111111111111"
1363        );
1364        assert_eq!(quotes.timestamp, "1700000000001");
1365        assert_eq!(quotes.price_changes.len(), 1);
1366
1367        let quote = &quotes.price_changes[0];
1368        assert_eq!(quote.asset_id, "101");
1369        assert_eq!(quote.price, "0.37");
1370        assert_eq!(quote.side, PolymarketOrderSide::Buy);
1371        assert_eq!(quote.size, "12.5");
1372        assert_eq!(quote.hash, "price-change-hash");
1373        assert_eq!(quote.best_bid.as_deref(), Some("0.36"));
1374        assert_eq!(quote.best_ask.as_deref(), Some("0.38"));
1375
1376        let PolymarketWsMessage::Market(MarketWsMessage::LastTradePrice(trade)) = &messages[1]
1377        else {
1378            panic!("Expected second message to be a last trade price");
1379        };
1380        assert_eq!(
1381            trade.market,
1382            "0x2222222222222222222222222222222222222222222222222222222222222222"
1383        );
1384        assert_eq!(trade.asset_id, "202");
1385        assert_eq!(trade.fee_rate_bps, "17");
1386        assert_eq!(trade.price, "0.63");
1387        assert_eq!(trade.side, PolymarketOrderSide::Sell);
1388        assert_eq!(trade.size, "4.25");
1389        assert_eq!(trade.timestamp, "1700000000003");
1390        assert_eq!(trade.transaction_hash.as_deref(), Some("0xtrade-hash"));
1391    }
1392
1393    #[rstest]
1394    fn test_parse_market_single_message(market_handler: FeedHandler) {
1395        let messages = market_handler.parse_messages(include_str!(
1396            "../../test_data/ws_market_last_trade_msg.json"
1397        ));
1398
1399        assert_eq!(messages.len(), 1);
1400
1401        let PolymarketWsMessage::Market(MarketWsMessage::LastTradePrice(trade)) = &messages[0]
1402        else {
1403            panic!("Expected a last trade price");
1404        };
1405        assert_eq!(
1406            trade.market,
1407            "0xdd22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917"
1408        );
1409        assert_eq!(
1410            trade.asset_id,
1411            "71321045679252212594626385532706912750332728571942532289631379312455583992563"
1412        );
1413        assert_eq!(trade.fee_rate_bps, "0");
1414        assert_eq!(trade.price, "0.51");
1415        assert_eq!(trade.side, PolymarketOrderSide::Buy);
1416        assert_eq!(trade.size, "25.0");
1417        assert_eq!(trade.timestamp, "1703875202000");
1418        assert!(trade.transaction_hash.is_none());
1419    }
1420
1421    #[rstest]
1422    fn test_parse_user_batch(user_handler: FeedHandler) {
1423        let messages =
1424            user_handler.parse_messages(include_str!("../../test_data/ws_user_batch_msg.json"));
1425        let actual: Vec<UserWsMessage> = messages
1426            .into_iter()
1427            .map(|message| match message {
1428                PolymarketWsMessage::User(message) => message,
1429                other => panic!("Expected user message, received {other:?}"),
1430            })
1431            .collect();
1432        let expected: Vec<UserWsMessage> =
1433            serde_json::from_str(include_str!("../../test_data/ws_user_batch_msg.json"))
1434                .expect("user batch fixture should deserialize");
1435
1436        assert_eq!(actual, expected);
1437    }
1438
1439    fn cycle_asset_ids() -> Vec<String> {
1440        vec![MARKET_ASSET_ID.to_string()]
1441    }
1442
1443    fn assert_desired(handler: &FeedHandler, subscriptions: &SubscriptionState) {
1444        assert!(
1445            handler.market_subscription_desired(MARKET_ASSET_ID),
1446            "cycle must preserve desired ownership"
1447        );
1448        assert!(
1449            subscriptions.pending_unsubscribe_topics().is_empty(),
1450            "cycle must never mark desired assets for unsubscribe"
1451        );
1452    }
1453
1454    /// Classifies client frames: `subscribe` covers both the initial
1455    /// `type: market` frame and later `operation` frames.
1456    fn frame_kind(frame: &str) -> &str {
1457        let value: Value = serde_json::from_str(frame).expect("client frame should be JSON");
1458        if value.get("operation").and_then(Value::as_str) == Some("unsubscribe") {
1459            return "unsubscribe";
1460        }
1461
1462        if value.get("operation").and_then(Value::as_str) == Some("subscribe")
1463            || value.get("type").and_then(Value::as_str) == Some("market")
1464        {
1465            return "subscribe";
1466        }
1467
1468        panic!("unexpected client frame: {frame}");
1469    }
1470
1471    fn frame_assets(frame: &str) -> Vec<String> {
1472        let value: Value = serde_json::from_str(frame).expect("client frame should be JSON");
1473        value
1474            .get("assets_ids")
1475            .and_then(Value::as_array)
1476            .expect("frame should carry assets_ids")
1477            .iter()
1478            .map(|id| {
1479                id.as_str()
1480                    .expect("asset id should be a string")
1481                    .to_string()
1482            })
1483            .collect()
1484    }
1485
1486    #[rstest]
1487    #[tokio::test]
1488    async fn cycle_market_subscription_resubscribes_without_changing_desired_state() {
1489        let (url, messages) = recording_server().await;
1490        let client = recording_client(url).await;
1491        let (mut handler, _raw_tx, subscriptions) = market_handler_with(client);
1492        subscriptions.mark_subscribe(MARKET_ASSET_ID);
1493
1494        let gate = SnapshotGate::default();
1495        gate.lock().close();
1496
1497        let outcome = handler
1498            .cycle_market_subscription(&cycle_asset_ids(), &CancellationToken::new(), &gate)
1499            .await;
1500        assert!(matches!(outcome, CycleMarketOutcome::Completed));
1501        assert!(!gate.lock().is_closed());
1502
1503        wait_for_recorded_messages(&messages, 2).await;
1504        let frames = messages.lock().clone();
1505        assert_eq!(frame_kind(&frames[0]), "unsubscribe");
1506        assert_eq!(frame_kind(&frames[1]), "subscribe");
1507
1508        for frame in &frames {
1509            assert_eq!(frame_assets(frame), cycle_asset_ids());
1510        }
1511
1512        assert_desired(&handler, &subscriptions);
1513
1514        handler
1515            .client
1516            .as_ref()
1517            .expect("websocket client")
1518            .disconnect()
1519            .await;
1520    }
1521
1522    #[rstest]
1523    #[tokio::test]
1524    async fn cycle_market_subscription_precancelled_sends_nothing() {
1525        let (url, messages) = recording_server().await;
1526        let client = recording_client(url).await;
1527        let (mut handler, _raw_tx, subscriptions) = market_handler_with(client);
1528        subscriptions.mark_subscribe(MARKET_ASSET_ID);
1529
1530        let cancel = CancellationToken::new();
1531        cancel.cancel();
1532        let outcome = handler
1533            .cycle_market_subscription(&cycle_asset_ids(), &cancel, &SnapshotGate::default())
1534            .await;
1535        assert!(matches!(outcome, CycleMarketOutcome::Cancelled));
1536
1537        tokio::time::sleep(Duration::from_millis(100)).await;
1538        assert!(messages.lock().is_empty());
1539        assert_desired(&handler, &subscriptions);
1540
1541        handler
1542            .client
1543            .as_ref()
1544            .expect("websocket client")
1545            .disconnect()
1546            .await;
1547    }
1548
1549    #[rstest]
1550    #[tokio::test]
1551    async fn cycle_market_subscription_without_client_fails_closed(
1552        mut market_handler: FeedHandler,
1553    ) {
1554        let subscriptions = market_handler.subscriptions.clone();
1555        subscriptions.mark_subscribe(MARKET_ASSET_ID);
1556        let outcome = market_handler
1557            .cycle_market_subscription(
1558                &cycle_asset_ids(),
1559                &CancellationToken::new(),
1560                &SnapshotGate::default(),
1561            )
1562            .await;
1563        assert!(matches!(
1564            outcome,
1565            CycleMarketOutcome::SendFailed(SendError::Closed)
1566        ));
1567        assert_desired(&market_handler, &subscriptions);
1568    }
1569
1570    #[rstest]
1571    #[tokio::test]
1572    async fn cycle_market_subscription_rejects_undesired_asset() {
1573        let (url, messages) = recording_server().await;
1574        let client = recording_client(url).await;
1575        let (mut handler, _raw_tx, _subscriptions) = market_handler_with(client);
1576
1577        let outcome = handler
1578            .cycle_market_subscription(
1579                &cycle_asset_ids(),
1580                &CancellationToken::new(),
1581                &SnapshotGate::default(),
1582            )
1583            .await;
1584        assert!(matches!(outcome, CycleMarketOutcome::NotDesired));
1585
1586        tokio::time::sleep(Duration::from_millis(100)).await;
1587        assert!(messages.lock().is_empty());
1588
1589        handler
1590            .client
1591            .as_ref()
1592            .expect("websocket client")
1593            .disconnect()
1594            .await;
1595    }
1596
1597    #[rstest]
1598    #[tokio::test]
1599    async fn cycle_market_subscription_after_disconnect_fails_send() {
1600        let (url, messages) = recording_server().await;
1601        let client = recording_client(url).await;
1602        let (mut handler, _raw_tx, subscriptions) = market_handler_with(client);
1603        subscriptions.mark_subscribe(MARKET_ASSET_ID);
1604
1605        handler
1606            .client
1607            .as_ref()
1608            .expect("websocket client")
1609            .disconnect()
1610            .await;
1611
1612        let outcome = handler
1613            .cycle_market_subscription(
1614                &cycle_asset_ids(),
1615                &CancellationToken::new(),
1616                &SnapshotGate::default(),
1617            )
1618            .await;
1619        assert!(matches!(
1620            outcome,
1621            CycleMarketOutcome::SendFailed(SendError::Closed)
1622        ));
1623
1624        // Leg 1 never wrote, so desired state is retained for retry/replay
1625        tokio::time::sleep(Duration::from_millis(100)).await;
1626        assert!(messages.lock().is_empty());
1627        assert_desired(&handler, &subscriptions);
1628    }
1629
1630    /// Scripted venue: a subscribe only yields a snapshot when the venue-side
1631    /// state flips from unsubscribed. This reproduces the live finding that a
1632    /// duplicate mid-connection subscribe receives no snapshot.
1633    async fn snapshot_gated_server(snapshot: &'static str) -> String {
1634        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1635            .await
1636            .expect("bind snapshot-gated server");
1637        let addr = listener.local_addr().expect("snapshot-gated address");
1638
1639        tokio::spawn(async move {
1640            let (stream, _) = listener.accept().await.expect("accept websocket client");
1641            let mut socket = tokio_tungstenite::accept_async(stream)
1642                .await
1643                .expect("accept websocket handshake");
1644            let mut subscribed = false;
1645
1646            while let Some(message) = socket.next().await {
1647                match message.expect("read websocket message") {
1648                    Message::Text(text) => {
1649                        let Ok(value) = serde_json::from_str::<Value>(&text) else {
1650                            continue;
1651                        };
1652
1653                        let operation = value.get("operation").and_then(Value::as_str);
1654                        let is_initial =
1655                            value.get("type").and_then(Value::as_str) == Some("market");
1656
1657                        if operation == Some("unsubscribe") {
1658                            subscribed = false;
1659                        } else if (operation == Some("subscribe") || is_initial) && !subscribed {
1660                            subscribed = true;
1661                            socket
1662                                .send(Message::Text(snapshot.into()))
1663                                .await
1664                                .expect("send gated snapshot");
1665                        }
1666                    }
1667                    Message::Close(_) => break,
1668                    _ => {}
1669                }
1670            }
1671        });
1672
1673        format!("ws://{addr}")
1674    }
1675
1676    async fn next_market_message(handler: &mut FeedHandler) -> Option<PolymarketWsMessage> {
1677        loop {
1678            match handler.next().await {
1679                Some(message @ PolymarketWsMessage::Market(_)) => return Some(message),
1680                Some(_) => {}
1681                None => return None,
1682            }
1683        }
1684    }
1685
1686    #[rstest]
1687    #[tokio::test]
1688    async fn cycle_recovers_snapshot_that_duplicate_subscribe_misses() {
1689        let snapshot = include_str!("../../test_data/ws_market_book_msg.json");
1690        let url = snapshot_gated_server(snapshot).await;
1691
1692        let config = WebSocketConfig::builder()
1693            .url(url)
1694            .backend(TransportBackend::Tungstenite)
1695            .build()
1696            .expect("valid websocket config");
1697        let (message_handler, mut message_rx) = channel_message_handler();
1698        let client = WebSocketClient::builder()
1699            .config(config)
1700            .message_handler(message_handler)
1701            .connect()
1702            .await
1703            .expect("connect websocket client");
1704        let epoch = client.connection_epoch_atomic();
1705        let (mut handler, cmd_tx, raw_tx, subscriptions) = market_handler_with_cmd_tx(client);
1706
1707        tokio::spawn(async move {
1708            while let Some(message) = message_rx.recv().await {
1709                let epoch = epoch.load(Ordering::SeqCst);
1710                if raw_tx.send((epoch, message)).is_err() {
1711                    break;
1712                }
1713            }
1714        });
1715
1716        cmd_tx
1717            .send(HandlerCommand::SubscribeMarket(cycle_asset_ids()))
1718            .expect("send initial subscribe");
1719        tokio::time::timeout(Duration::from_secs(5), next_market_message(&mut handler))
1720            .await
1721            .expect("initial subscribe should yield a snapshot")
1722            .expect("handler should stay open");
1723
1724        cmd_tx
1725            .send(HandlerCommand::SubscribeMarket(cycle_asset_ids()))
1726            .expect("send duplicate subscribe");
1727        assert!(
1728            tokio::time::timeout(
1729                Duration::from_millis(500),
1730                next_market_message(&mut handler)
1731            )
1732            .await
1733            .is_err(),
1734            "duplicate subscribe must receive no snapshot from the venue"
1735        );
1736
1737        let (responder, response) = tokio::sync::oneshot::channel();
1738        cmd_tx
1739            .send(HandlerCommand::CycleMarketSubscription {
1740                asset_ids: cycle_asset_ids(),
1741                cancel: CancellationToken::new(),
1742                responder,
1743                gate: SnapshotGate::default(),
1744            })
1745            .expect("send cycle command");
1746
1747        // The cycle runs inside next(), so pump the loop while awaiting the outcome
1748        tokio::pin!(response);
1749        let mut outcome = None;
1750        let mut snapshot_seen = false;
1751        tokio::time::timeout(Duration::from_secs(5), async {
1752            while outcome.is_none() || !snapshot_seen {
1753                tokio::select! {
1754                    result = &mut response, if outcome.is_none() => {
1755                        outcome = Some(result.expect("cycle responder should stay open"));
1756                    }
1757                    message = next_market_message(&mut handler), if !snapshot_seen => {
1758                        message.expect("handler should stay open");
1759                        snapshot_seen = true;
1760                    }
1761                }
1762            }
1763        })
1764        .await
1765        .expect("cycle should respond and yield a fresh snapshot");
1766
1767        assert!(matches!(outcome, Some(CycleMarketOutcome::Completed)));
1768
1769        assert_desired(&handler, &subscriptions);
1770
1771        handler
1772            .client
1773            .as_ref()
1774            .expect("websocket client")
1775            .disconnect()
1776            .await;
1777    }
1778
1779    #[rstest]
1780    #[tokio::test]
1781    async fn cycle_followed_by_queued_unsubscribe_honors_the_unsubscribe() {
1782        let (url, messages) = recording_server().await;
1783        let client = recording_client(url).await;
1784        let (mut handler, cmd_tx, _raw_tx, subscriptions) = market_handler_with_cmd_tx(client);
1785        subscriptions.mark_subscribe(MARKET_ASSET_ID);
1786
1787        // Commands serialize: the cycle completes first, then the unsubscribe wins
1788        let (responder, response) = tokio::sync::oneshot::channel();
1789        cmd_tx
1790            .send(HandlerCommand::CycleMarketSubscription {
1791                asset_ids: cycle_asset_ids(),
1792                cancel: CancellationToken::new(),
1793                responder,
1794                gate: SnapshotGate::default(),
1795            })
1796            .expect("queue cycle command");
1797
1798        cmd_tx
1799            .send(HandlerCommand::UnsubscribeMarket(cycle_asset_ids()))
1800            .expect("queue unsubscribe command");
1801
1802        tokio::pin!(response);
1803
1804        let outcome = tokio::time::timeout(Duration::from_secs(5), async {
1805            loop {
1806                tokio::select! {
1807                    biased;
1808                    result = &mut response => return result.expect("cycle responder open"),
1809                    _ = handler.next() => {}
1810                }
1811            }
1812        })
1813        .await
1814        .expect("cycle should respond");
1815
1816        assert!(matches!(outcome, CycleMarketOutcome::Completed));
1817
1818        tokio::time::timeout(Duration::from_secs(5), async {
1819            loop {
1820                if messages.lock().len() >= 3 {
1821                    break;
1822                }
1823
1824                tokio::select! {
1825                    _ = handler.next() => {}
1826                    () = tokio::time::sleep(Duration::from_millis(10)) => {}
1827                }
1828            }
1829        })
1830        .await
1831        .expect("queued unsubscribe should reach the wire");
1832
1833        let frames = messages.lock().clone();
1834        assert_eq!(frame_kind(&frames[0]), "unsubscribe");
1835        assert_eq!(frame_kind(&frames[1]), "subscribe");
1836        assert_eq!(frame_kind(&frames[2]), "unsubscribe");
1837
1838        assert!(!handler.market_subscription_desired(MARKET_ASSET_ID));
1839
1840        handler
1841            .client
1842            .as_ref()
1843            .expect("websocket client")
1844            .disconnect()
1845            .await;
1846    }
1847}