Skip to main content

limitless/ws/
channel.rs

1//! WebSocket types — channels, config, state, and subscription options.
2//!
3//! These model the Limitless Exchange WebSocket API's subscription channels
4//! and payloads without depending on any Socket.IO protocol.
5
6use crate::prelude::*;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10// ═══════════════════════════════════════════════════════════════════════════
11//  Connection state
12// ═══════════════════════════════════════════════════════════════════════════
13
14/// Tracks the lifecycle of a WebSocket connection.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum WebSocketState {
17    /// Not connected and not trying.
18    Disconnected,
19    /// Currently performing the initial handshake.
20    Connecting,
21    /// Connected and receiving events.
22    Connected,
23    /// Temporarily disconnected; attempting to re-establish.
24    Reconnecting,
25    /// Connection failed and will not be retried.
26    Error,
27}
28
29// ═══════════════════════════════════════════════════════════════════════════
30//  Subscription channels
31// ═══════════════════════════════════════════════════════════════════════════
32
33/// Identifies a WebSocket subscription target on the Limitless Exchange.
34///
35/// Variants prefixed with `Subscribe` / `Unsubscribe` represent client →
36/// server subscription requests. The non-prefixed variants are server-emitted
37/// event names used for dispatching incoming messages.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
39pub enum SubscriptionChannel {
40    // ── Server → Client event names ──
41    /// CLOB orderbook snapshots (`orderbook`).
42    Orderbook,
43    /// Public trade feed (`trades`).
44    Trades,
45    /// Order status updates (`orders`).
46    Orders,
47    /// Fill notifications (`fills`).
48    Fills,
49    /// Market statistics (`markets`).
50    Markets,
51    /// Aggregated price feed (`prices`).
52    Prices,
53    /// Portfolio position updates (`positions`).
54    Positions,
55    /// Blockchain transaction events (`transactions`).
56    Transactions,
57    /// OME + settlement lifecycle events (`orderEvent`).
58    OrderEvents,
59    /// Live sports data (`liveSports`).
60    LiveSports,
61    /// Live esports data (`liveEsports`).
62    LiveEsports,
63    /// Market creation / resolution events (`marketLifecycle`).
64    MarketLifecycle,
65
66    // ── Client → Server subscription requests ──
67    /// Subscribe to AMM prices + CLOB orderbook.
68    SubscribeMarketPrices,
69    /// Subscribe to portfolio position updates (requires auth).
70    SubscribePositions,
71    /// Subscribe to blockchain transaction events (requires auth).
72    SubscribeTransactions,
73    /// Subscribe to OME + settlement lifecycle events (requires auth).
74    SubscribeOrderEvents,
75    /// Subscribe to live sports data.
76    SubscribeLiveSports,
77    /// Subscribe to live esports data.
78    SubscribeLiveEsports,
79    /// Subscribe to market creation / resolution events.
80    SubscribeMarketLifecycle,
81    /// Unsubscribe from market lifecycle events.
82    UnsubscribeMarketLifecycle,
83}
84
85impl SubscriptionChannel {
86    /// Returns the wire-protocol string for this channel.
87    pub fn as_str(&self) -> &'static str {
88        match self {
89            Self::Orderbook => "orderbook",
90            Self::Trades => "trades",
91            Self::Orders => "orders",
92            Self::Fills => "fills",
93            Self::Markets => "markets",
94            Self::Prices => "prices",
95            Self::Positions => "positions",
96            Self::Transactions => "transactions",
97            Self::OrderEvents => "orderEvent",
98            Self::LiveSports => "liveSports",
99            Self::LiveEsports => "liveEsports",
100            Self::MarketLifecycle => "marketLifecycle",
101            Self::SubscribeMarketPrices => "subscribe_market_prices",
102            Self::SubscribePositions => "subscribe_positions",
103            Self::SubscribeTransactions => "subscribe_transactions",
104            Self::SubscribeOrderEvents => "subscribe_order_events",
105            Self::SubscribeLiveSports => "subscribe_live_sports",
106            Self::SubscribeLiveEsports => "subscribe_live_esports",
107            Self::SubscribeMarketLifecycle => "subscribe_market_lifecycle",
108            Self::UnsubscribeMarketLifecycle => "unsubscribe_market_lifecycle",
109        }
110    }
111}
112
113// ═══════════════════════════════════════════════════════════════════════════
114//  Subscription options
115// ═══════════════════════════════════════════════════════════════════════════
116
117/// Parameters supplied when subscribing to a channel.
118#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
119pub struct SubscriptionOptions {
120    /// A single market slug (for channels that accept one).
121    #[serde(
122        rename = "marketSlug",
123        skip_serializing_if = "Option::is_none",
124        default
125    )]
126    pub market_slug: Option<String>,
127
128    /// One or more market slugs (for multi-market subscriptions).
129    #[serde(rename = "marketSlugs", skip_serializing_if = "Vec::is_empty", default)]
130    pub market_slugs: Vec<String>,
131
132    /// A single on-chain market address.
133    #[serde(
134        rename = "marketAddress",
135        skip_serializing_if = "Option::is_none",
136        default
137    )]
138    pub market_address: Option<String>,
139
140    /// One or more on-chain market addresses.
141    #[serde(
142        rename = "marketAddresses",
143        skip_serializing_if = "Vec::is_empty",
144        default
145    )]
146    pub market_addresses: Vec<String>,
147
148    /// Arbitrary server-side filters (channel-dependent).
149    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
150    pub filters: BTreeMap<String, Value>,
151}
152
153// ═══════════════════════════════════════════════════════════════════════════
154//  WebSocket config
155// ═══════════════════════════════════════════════════════════════════════════
156
157/// Configuration for the Limitless WebSocket connection.
158#[derive(Clone, Debug)]
159pub struct WebSocketConfig {
160    /// The WebSocket endpoint URL.
161    pub url: String,
162    /// Optional API key / token ID for authenticated streams.
163    pub api_key: Option<String>,
164    /// Whether to automatically reconnect on disconnection.
165    pub auto_reconnect: bool,
166    /// Delay (in milliseconds) before each reconnection attempt.
167    pub reconnect_delay_ms: u64,
168    /// Maximum number of reconnection attempts (0 = unlimited).
169    pub max_reconnect_attempts: u32,
170    /// Connection and read timeout (in milliseconds).
171    pub timeout_ms: u64,
172}
173
174impl Default for WebSocketConfig {
175    fn default() -> Self {
176        Self {
177            url: "wss://ws.limitless.exchange/markets".to_string(),
178            api_key: std::env::var("LIMITLESS_API_KEY").ok(),
179            auto_reconnect: true,
180            reconnect_delay_ms: 1_000,
181            max_reconnect_attempts: 0,
182            timeout_ms: 10_000,
183        }
184    }
185}
186
187// ═══════════════════════════════════════════════════════════════════════════
188//  FlexFloat — handles string-encoded floats in WS payloads
189// ═══════════════════════════════════════════════════════════════════════════
190
191/// A flexible `f64` that deserializes from both JSON numbers and strings.
192///
193/// The Limitless WebSocket occasionally encodes numeric fields as strings
194/// (e.g., `"0.55"` instead of `0.55`). This wrapper handles both formats
195/// transparently.
196#[derive(Clone, Copy, Debug, PartialEq)]
197pub struct FlexFloat(pub f64);
198
199impl FlexFloat {
200    /// Extract the inner `f64`.
201    pub fn float64(self) -> f64 {
202        self.0
203    }
204}
205
206impl<'de> Deserialize<'de> for FlexFloat {
207    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
208    where
209        D: serde::Deserializer<'de>,
210    {
211        match Value::deserialize(deserializer)? {
212            Value::Number(n) => n
213                .as_f64()
214                .map(Self)
215                .ok_or_else(|| serde::de::Error::custom("expected f64-compatible number")),
216            Value::String(s) => s.parse::<f64>().map(Self).map_err(|err| {
217                serde::de::Error::custom(format!("cannot parse float '{s}': {err}"))
218            }),
219            other => Err(serde::de::Error::custom(format!(
220                "cannot deserialize FlexFloat from {other}"
221            ))),
222        }
223    }
224}
225
226impl Serialize for FlexFloat {
227    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
228    where
229        S: serde::Serializer,
230    {
231        serializer.serialize_f64(self.0)
232    }
233}
234
235// ═══════════════════════════════════════════════════════════════════════════
236//  WebSocket event payloads
237// ═══════════════════════════════════════════════════════════════════════════
238
239/// Generic WebSocket event — used as a fallback when the event type is
240/// not recognized by the typed dispatch.
241pub type WsEvent = Value;
242
243// ── Typed event dispatch ────────────────────────────────────────────────
244
245/// Typed WebSocket event — wraps all known server-emitted events.
246///
247/// Variants with named structs carry fully deserialized payloads. Variants
248/// that hold raw [`Value`] cover events whose schemas are not yet modelled
249/// (or are intentionally left as flexible key-value maps).
250#[derive(Clone, Debug, Serialize, Deserialize)]
251#[serde(untagged)]
252pub enum WsEventKind {
253    /// AMM price update (`newPriceData`).
254    NewPriceData(NewPriceData),
255    /// CLOB orderbook snapshot (`orderbookUpdate`).
256    OrderbookUpdate(OrderbookUpdate),
257    /// Oracle price data (`oraclePriceData`).
258    OraclePriceData(OraclePriceData),
259    /// Public trade event (`trades`).
260    TradeEvent(TradeEvent),
261    /// Order status update (`orders`).
262    OrderUpdate(OrderUpdate),
263    /// Fill notification (`fills`).
264    FillEvent(FillEvent),
265    /// Market statistics update (`markets`).
266    MarketUpdateEvent(MarketUpdateEvent),
267    /// On-chain transaction event (`transactions`).
268    TransactionEvent(TransactionEvent),
269    /// Market creation event (`marketCreated`).
270    MarketCreatedEvent(MarketCreatedEvent),
271    /// Market resolution event (`marketResolved`).
272    MarketResolvedEvent(MarketResolvedEvent),
273    /// Portfolio position update — raw payload (`positions`, requires auth).
274    Positions(Value),
275    /// OME state / settlement result — raw payload (`orderEvent`, requires auth).
276    OrderEvent(Value),
277    /// Live sports data — raw payload (`liveSports`).
278    LiveSports(Value),
279    /// Live esports data — raw payload (`liveEsports`).
280    LiveEsports(Value),
281    /// System notification (`system`).
282    System(SystemEvent),
283    /// Authentication confirmation — raw payload (`authenticated`).
284    Authenticated(Value),
285    /// Error notification — raw payload (`exception`).
286    Exception(Value),
287    /// Server error — raw payload (`error`). Emitted for e.g. "All requested markets are resolved".
288    Error(Value),
289    /// Unknown / unrecognized event with its raw payload.
290    Unknown(Value),
291}
292
293/// Map a server-emitted event name and its JSON payload to a typed [`WsEventKind`].
294///
295/// Returns `None` only when the payload fails to deserialize for a known
296/// event type. Unknown event names produce [`WsEventKind::Unknown`].
297pub fn deserialize_event(event: &str, payload: &Value) -> Option<WsEventKind> {
298    match event {
299        "newPriceData" => serde_json::from_value::<NewPriceData>(payload.clone())
300            .ok()
301            .map(WsEventKind::NewPriceData),
302        "orderbookUpdate" => serde_json::from_value::<OrderbookUpdate>(payload.clone())
303            .ok()
304            .map(WsEventKind::OrderbookUpdate),
305        "oraclePriceData" => serde_json::from_value::<OraclePriceData>(payload.clone())
306            .ok()
307            .map(WsEventKind::OraclePriceData),
308        "trades" => serde_json::from_value::<TradeEvent>(payload.clone())
309            .ok()
310            .map(WsEventKind::TradeEvent),
311        "orders" => serde_json::from_value::<OrderUpdate>(payload.clone())
312            .ok()
313            .map(WsEventKind::OrderUpdate),
314        "fills" => serde_json::from_value::<FillEvent>(payload.clone())
315            .ok()
316            .map(WsEventKind::FillEvent),
317        "markets" => serde_json::from_value::<MarketUpdateEvent>(payload.clone())
318            .ok()
319            .map(WsEventKind::MarketUpdateEvent),
320        "transactions" => serde_json::from_value::<TransactionEvent>(payload.clone())
321            .ok()
322            .map(WsEventKind::TransactionEvent),
323        "marketCreated" => serde_json::from_value::<MarketCreatedEvent>(payload.clone())
324            .ok()
325            .map(WsEventKind::MarketCreatedEvent),
326        "marketResolved" => serde_json::from_value::<MarketResolvedEvent>(payload.clone())
327            .ok()
328            .map(WsEventKind::MarketResolvedEvent),
329        "positions" => Some(WsEventKind::Positions(payload.clone())),
330        "orderEvent" => Some(WsEventKind::OrderEvent(payload.clone())),
331        "liveSports" => Some(WsEventKind::LiveSports(payload.clone())),
332        "liveEsports" => Some(WsEventKind::LiveEsports(payload.clone())),
333        "system" => serde_json::from_value::<SystemEvent>(payload.clone())
334            .ok()
335            .map(WsEventKind::System),
336        "authenticated" => Some(WsEventKind::Authenticated(payload.clone())),
337        "exception" => Some(WsEventKind::Exception(payload.clone())),
338        "error" => Some(WsEventKind::Error(payload.clone())),
339        _other => Some(WsEventKind::Unknown(payload.clone())),
340    }
341}
342
343// ── Orderbook ────────────────────────────────────────────────────────────
344
345/// A single level in the orderbook (bid or ask).
346#[derive(Clone, Debug, Serialize, Deserialize)]
347pub struct OrderbookLevel {
348    pub price: f64,
349    pub size: f64,
350}
351
352/// Full CLOB orderbook snapshot for a market.
353#[derive(Clone, Debug, Serialize, Deserialize)]
354pub struct OrderbookData {
355    pub bids: Vec<OrderbookLevel>,
356    pub asks: Vec<OrderbookLevel>,
357    #[serde(rename = "tokenId")]
358    pub token_id: String,
359    #[serde(rename = "adjustedMidpoint")]
360    pub adjusted_midpoint: f64,
361    #[serde(rename = "maxSpread")]
362    pub max_spread: FlexFloat,
363    #[serde(rename = "minSize")]
364    pub min_size: FlexFloat,
365}
366
367/// Server-emitted orderbook update event.
368#[derive(Clone, Debug, Serialize, Deserialize)]
369pub struct OrderbookUpdate {
370    #[serde(rename = "marketSlug")]
371    pub market_slug: String,
372    pub orderbook: OrderbookData,
373    pub timestamp: Value,
374}
375
376// ── Trades ───────────────────────────────────────────────────────────────
377
378/// A single public trade.
379#[derive(Clone, Debug, Serialize, Deserialize)]
380pub struct TradeEvent {
381    #[serde(rename = "marketSlug")]
382    pub market_slug: String,
383    pub side: String,
384    pub price: f64,
385    pub size: f64,
386    pub timestamp: f64,
387    #[serde(rename = "tradeId")]
388    pub trade_id: String,
389}
390
391// ── Orders ───────────────────────────────────────────────────────────────
392
393/// An order status update emitted by the server.
394#[derive(Clone, Debug, Serialize, Deserialize)]
395pub struct OrderUpdate {
396    #[serde(rename = "orderId")]
397    pub order_id: String,
398    #[serde(rename = "marketSlug")]
399    pub market_slug: String,
400    pub side: String,
401    #[serde(default)]
402    pub price: Option<f64>,
403    pub size: f64,
404    pub filled: f64,
405    pub status: String,
406    pub timestamp: f64,
407}
408
409// ── Fills ────────────────────────────────────────────────────────────────
410
411/// A fill (matched trade) notification.
412#[derive(Clone, Debug, Serialize, Deserialize)]
413pub struct FillEvent {
414    #[serde(rename = "orderId")]
415    pub order_id: String,
416    #[serde(rename = "marketSlug")]
417    pub market_slug: String,
418    pub side: String,
419    pub price: f64,
420    pub size: f64,
421    pub timestamp: f64,
422    #[serde(rename = "fillId")]
423    pub fill_id: String,
424}
425
426// ── Market stats ─────────────────────────────────────────────────────────
427
428/// Periodic market-level statistics update.
429#[derive(Clone, Debug, Serialize, Deserialize)]
430pub struct MarketUpdateEvent {
431    #[serde(rename = "marketSlug")]
432    pub market_slug: String,
433    #[serde(rename = "lastPrice", default)]
434    pub last_price: Option<f64>,
435    #[serde(rename = "volume24h", default)]
436    pub volume_24h: Option<f64>,
437    #[serde(rename = "priceChange24h", default)]
438    pub price_change_24h: Option<f64>,
439    pub timestamp: f64,
440}
441
442// ── AMM prices ───────────────────────────────────────────────────────────
443
444/// A per-market AMM price entry within a `NewPriceData` payload.
445#[derive(Clone, Debug, Serialize, Deserialize)]
446pub struct AmmPriceEntry {
447    #[serde(rename = "marketId")]
448    pub market_id: i32,
449    #[serde(rename = "marketAddress")]
450    pub market_address: String,
451    #[serde(rename = "yesPrice")]
452    pub yes_price: f64,
453    #[serde(rename = "noPrice")]
454    pub no_price: f64,
455}
456
457/// Server-emitted AMM price update (the `newPriceData` event).
458#[derive(Clone, Debug, Serialize, Deserialize)]
459pub struct NewPriceData {
460    #[serde(rename = "marketAddress")]
461    pub market_address: String,
462    #[serde(rename = "updatedPrices")]
463    pub updated_prices: Vec<AmmPriceEntry>,
464    #[serde(rename = "blockNumber")]
465    pub block_number: i64,
466    pub timestamp: Value,
467}
468
469// ── Oracle prices ────────────────────────────────────────────────────────
470
471/// Oracle price data for a market.
472#[derive(Clone, Debug, Serialize, Deserialize)]
473pub struct OraclePriceData {
474    #[serde(rename = "marketAddress", default)]
475    pub market_address: Option<String>,
476    #[serde(rename = "marketSlug")]
477    pub market_slug: String,
478    pub timestamp: i64,
479    pub value: f64,
480}
481
482// ── Transactions ─────────────────────────────────────────────────────────
483
484/// On-chain transaction event (deposit, withdrawal, trade settlement).
485#[derive(Clone, Debug, Serialize, Deserialize)]
486pub struct TransactionEvent {
487    #[serde(rename = "userId", default)]
488    pub user_id: Option<i32>,
489    #[serde(rename = "txHash", default)]
490    pub tx_hash: Option<String>,
491    pub status: String,
492    pub source: String,
493    pub timestamp: String,
494    #[serde(rename = "marketAddress", default)]
495    pub market_address: Option<String>,
496    #[serde(rename = "marketSlug", default)]
497    pub market_slug: Option<String>,
498    #[serde(rename = "tokenId", default)]
499    pub token_id: Option<String>,
500    #[serde(rename = "conditionId", default)]
501    pub condition_id: Option<String>,
502    #[serde(rename = "amountContracts", default)]
503    pub amount_contracts: Option<String>,
504    #[serde(rename = "amountCollateral", default)]
505    pub amount_collateral: Option<String>,
506    #[serde(default)]
507    pub price: Option<String>,
508    #[serde(default)]
509    pub side: Option<String>,
510}
511
512// ── Market lifecycle ─────────────────────────────────────────────────────
513
514/// Emitted when a new market is created and funded.
515#[derive(Clone, Debug, Serialize, Deserialize)]
516pub struct MarketCreatedEvent {
517    pub slug: String,
518    pub title: String,
519    #[serde(rename = "type")]
520    pub market_type: String,
521    #[serde(rename = "groupSlug", default)]
522    pub group_slug: Option<String>,
523    #[serde(rename = "categoryIds", default)]
524    pub category_ids: Vec<i32>,
525    #[serde(rename = "createdAt")]
526    pub created_at: String,
527}
528
529/// Emitted when a market is resolved with a winning outcome.
530#[derive(Clone, Debug, Serialize, Deserialize)]
531pub struct MarketResolvedEvent {
532    pub slug: String,
533    #[serde(rename = "type")]
534    pub market_type: String,
535    #[serde(rename = "winningOutcome")]
536    pub winning_outcome: String,
537    #[serde(rename = "winningIndex")]
538    pub winning_index: i32,
539    #[serde(rename = "resolutionDate")]
540    pub resolution_date: String,
541}
542
543/// System notification event (`system`).
544///
545/// Emitted by the server for subscription confirmations and other
546/// informational messages. The `markets` field is present when the
547/// message relates to specific market subscriptions.
548#[derive(Clone, Debug, Serialize, Deserialize)]
549pub struct SystemEvent {
550    #[serde(default)]
551    pub message: Option<String>,
552    #[serde(default)]
553    pub markets: Option<Vec<String>>,
554}
555
556// ═══════════════════════════════════════════════════════════════════════════
557//  Helpers
558// ═══════════════════════════════════════════════════════════════════════════
559
560/// Normalize `SubscriptionOptions` by copying the singular `market_slug` /
561/// `market_address` into the plural vecs when the plural vecs are empty.
562///
563/// This mirrors the reference SDK behaviour so that callers can supply
564/// either form.
565pub fn normalize_subscription_options(opts: SubscriptionOptions) -> SubscriptionOptions {
566    let mut opts = opts;
567    if opts.market_slugs.is_empty() {
568        if let Some(ref slug) = opts.market_slug {
569            opts.market_slugs = vec![slug.clone()];
570        }
571    }
572    if opts.market_addresses.is_empty() {
573        if let Some(ref addr) = opts.market_address {
574            opts.market_addresses = vec![addr.clone()];
575        }
576    }
577    opts
578}
579
580/// Build a deterministic key for a `(channel, options)` pair, suitable for
581/// tracking active subscriptions.
582pub fn subscription_key(channel: SubscriptionChannel, opts: &SubscriptionOptions) -> String {
583    let slugs = if opts.market_slugs.is_empty() {
584        String::new()
585    } else {
586        let mut sorted: Vec<&str> = opts.market_slugs.iter().map(String::as_str).collect();
587        sorted.sort_unstable();
588        sorted.join(",")
589    };
590
591    let addresses = if opts.market_addresses.is_empty() {
592        String::new()
593    } else {
594        let mut sorted: Vec<&str> = opts.market_addresses.iter().map(String::as_str).collect();
595        sorted.sort_unstable();
596        sorted.join(",")
597    };
598
599    format!("{}|{}|{}", channel.as_str(), slugs, addresses)
600}
601
602/// Attempt to recover a `SubscriptionChannel` from its wire-protocol string.
603pub fn channel_from_key(key: &str) -> Option<SubscriptionChannel> {
604    // The key format is "channel|slugs|addresses" — extract the channel part.
605    let channel_str = key.split('|').next().unwrap_or(key);
606    match channel_str {
607        "orderbook" => Some(SubscriptionChannel::Orderbook),
608        "trades" => Some(SubscriptionChannel::Trades),
609        "orders" => Some(SubscriptionChannel::Orders),
610        "fills" => Some(SubscriptionChannel::Fills),
611        "markets" => Some(SubscriptionChannel::Markets),
612        "prices" => Some(SubscriptionChannel::Prices),
613        "positions" => Some(SubscriptionChannel::Positions),
614        "transactions" => Some(SubscriptionChannel::Transactions),
615        "orderEvent" => Some(SubscriptionChannel::OrderEvents),
616        "liveSports" => Some(SubscriptionChannel::LiveSports),
617        "liveEsports" => Some(SubscriptionChannel::LiveEsports),
618        "marketLifecycle" => Some(SubscriptionChannel::MarketLifecycle),
619        "subscribe_market_prices" => Some(SubscriptionChannel::SubscribeMarketPrices),
620        "subscribe_positions" => Some(SubscriptionChannel::SubscribePositions),
621        "subscribe_transactions" => Some(SubscriptionChannel::SubscribeTransactions),
622        "subscribe_order_events" => Some(SubscriptionChannel::SubscribeOrderEvents),
623        "subscribe_live_sports" => Some(SubscriptionChannel::SubscribeLiveSports),
624        "subscribe_live_esports" => Some(SubscriptionChannel::SubscribeLiveEsports),
625        "subscribe_market_lifecycle" => Some(SubscriptionChannel::SubscribeMarketLifecycle),
626        "unsubscribe_market_lifecycle" => Some(SubscriptionChannel::UnsubscribeMarketLifecycle),
627        _ => None,
628    }
629}
630
631/// Returns `true` when the given channel requires API-key authentication.
632pub fn requires_websocket_auth(channel: SubscriptionChannel) -> bool {
633    matches!(
634        channel,
635        SubscriptionChannel::SubscribePositions
636            | SubscriptionChannel::SubscribeTransactions
637            | SubscriptionChannel::SubscribeOrderEvents
638    )
639}
640
641// ═══════════════════════════════════════════════════════════════════════════
642//  Tests
643// ═══════════════════════════════════════════════════════════════════════════
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    #[test]
650    fn subscription_key_is_order_independent() {
651        let opts_a = SubscriptionOptions {
652            market_slugs: vec!["btc-above-100k".into(), "eth-merge".into()],
653            ..Default::default()
654        };
655        let opts_b = SubscriptionOptions {
656            market_slugs: vec!["eth-merge".into(), "btc-above-100k".into()],
657            ..Default::default()
658        };
659        assert_eq!(
660            subscription_key(SubscriptionChannel::SubscribeMarketPrices, &opts_a),
661            subscription_key(SubscriptionChannel::SubscribeMarketPrices, &opts_b),
662        );
663    }
664
665    #[test]
666    fn normalize_copies_singular_into_plural() {
667        let opts = SubscriptionOptions {
668            market_slug: Some("test-slug".into()),
669            market_address: Some("0xdead".into()),
670            ..Default::default()
671        };
672        let normalized = normalize_subscription_options(opts);
673        assert_eq!(normalized.market_slugs, vec!["test-slug"]);
674        assert_eq!(normalized.market_addresses, vec!["0xdead"]);
675    }
676
677    #[test]
678    fn normalize_preserves_existing_plurals() {
679        let opts = SubscriptionOptions {
680            market_slugs: vec!["existing".into()],
681            ..Default::default()
682        };
683        let normalized = normalize_subscription_options(opts);
684        assert_eq!(normalized.market_slugs, vec!["existing"]);
685    }
686
687    #[test]
688    fn channel_from_key_roundtrips() {
689        for channel in &[
690            SubscriptionChannel::Orderbook,
691            SubscriptionChannel::Trades,
692            SubscriptionChannel::SubscribeMarketPrices,
693            SubscriptionChannel::SubscribePositions,
694            SubscriptionChannel::OrderEvents,
695            SubscriptionChannel::MarketLifecycle,
696        ] {
697            let key = subscription_key(*channel, &SubscriptionOptions::default());
698            let recovered = channel_from_key(&key);
699            assert_eq!(
700                recovered,
701                Some(*channel),
702                "round-trip failed for {channel:?}"
703            );
704        }
705    }
706
707    #[test]
708    fn requires_auth_returns_true_for_private_channels() {
709        assert!(requires_websocket_auth(
710            SubscriptionChannel::SubscribePositions
711        ));
712        assert!(requires_websocket_auth(
713            SubscriptionChannel::SubscribeTransactions
714        ));
715        assert!(requires_websocket_auth(
716            SubscriptionChannel::SubscribeOrderEvents
717        ));
718    }
719
720    #[test]
721    fn requires_auth_returns_false_for_public_channels() {
722        assert!(!requires_websocket_auth(
723            SubscriptionChannel::SubscribeMarketPrices
724        ));
725        assert!(!requires_websocket_auth(
726            SubscriptionChannel::SubscribeMarketLifecycle
727        ));
728    }
729
730    #[test]
731    fn flexfloat_parses_number_and_string() {
732        let from_number: FlexFloat = serde_json::from_str("0.55").unwrap();
733        assert!((from_number.float64() - 0.55).abs() < f64::EPSILON);
734
735        let from_string: FlexFloat = serde_json::from_str(r#""0.55""#).unwrap();
736        assert!((from_string.float64() - 0.55).abs() < f64::EPSILON);
737    }
738
739    #[test]
740    fn websocket_channel_inventory_includes_all_server_events() {
741        // Ensure every server-emitted event name has a corresponding variant.
742        let server_events = [
743            "orderbook",
744            "trades",
745            "orders",
746            "fills",
747            "markets",
748            "prices",
749            "positions",
750            "transactions",
751            "orderEvent",
752            "liveSports",
753            "liveEsports",
754            "marketLifecycle",
755        ];
756        for &event in &server_events {
757            assert!(
758                channel_from_key(event).is_some(),
759                "missing channel variant for server event '{event}'"
760            );
761        }
762    }
763}