polyoxide_binance/spot/ws/mod.rs
1//! Spot market streams on `stream.binance.com`.
2//!
3//! Binance serves spot's streams on one combined path, `/stream`: every
4//! [`StreamName`] is a name on it, the bare [`SpotWs`] holds one connection,
5//! and the supervised tier ([`SpotWsBuilder`]) keeps that connection up,
6//! reconnecting, replaying its streams and replacing it before Binance's
7//! 24-hour cutoff.
8//!
9//! Binance acknowledges every `SUBSCRIBE` and enforces its connection rules by
10//! closing the connection, so both tiers enforce them before sending: at most
11//! [`MAX_STREAMS_PER_CONNECTION`] streams, [`MAX_NAMES_PER_REQUEST`] names per
12//! request, and one request per [`MIN_REQUEST_INTERVAL`]. The host closes a
13//! connection that receives more than five messages a second, requests, pings
14//! and pongs counted, and bans an address it disconnects repeatedly, so the
15//! pace is not a knob: a request every 250 ms was closed with 1008 "Too many
16//! requests" where one every 500 ms held. The measurements are in
17//! `docs/specs/binance/OBSERVED.md`.
18//!
19//! ```no_run
20//! use futures_util::StreamExt;
21//! use polyoxide_binance::spot::{types::Symbol, ws::{Event, SpotWsBuilder, StreamName}};
22//!
23//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
24//! let btc = Symbol::new("BTCUSDT")?;
25//! let mut feed = SpotWsBuilder::new()
26//! .streams([StreamName::AggTrade(btc.clone()), StreamName::BookTicker(btc)])
27//! .connect()
28//! .await?;
29//! while let Some(event) = feed.next().await {
30//! if let Event::Update(update) = event? {
31//! println!("{}", update.stream);
32//! }
33//! }
34//! # Ok(())
35//! # }
36//! ```
37
38// The test-only `scripted` below is a second `mod` item over `usdm::ws`'s scripted
39// server; the allow sits here, not on the item, because the test-bodies gate reads an
40// item's attributes as the gate of the file it declares.
41#![cfg_attr(test, allow(clippy::duplicate_mod))]
42
43pub mod client;
44pub mod error;
45pub mod event;
46#[cfg(any(test, feature = "test-server"))]
47#[doc(hidden)]
48pub mod fixtures;
49mod protocol;
50#[cfg(test)]
51#[path = "../../usdm/ws/scripted.rs"]
52mod scripted;
53pub mod stream;
54pub mod supervised;
55
56use std::{fmt, time::Duration};
57
58pub use client::SpotWs;
59pub use error::{SpotWsError, StopCause};
60pub use event::{
61 AggTradeEvent, AvgPriceEvent, BookTickerEvent, KlineBar, KlineEvent, MiniTickerEvent,
62 PartialDepthEvent, Payload, TickerEvent, TradeEvent, Update,
63};
64pub use stream::{DepthLevels, DepthSpeed, InvalidStreamName, StreamName};
65pub use supervised::{DisconnectReason, Event, MembershipHandle, SpotWsBuilder, SupervisedSpotWs};
66
67/// The production stream host. The client appends `/stream`.
68pub const SPOT_WS_BASE: &str = "wss://stream.binance.com:9443";
69
70/// Streams one connection may carry. Binance documents 1024 and 1024 were
71/// measured held (2026-11-02); the client never sends a 1025th.
72pub const MAX_STREAMS_PER_CONNECTION: usize = crate::shared::ws::MAX_STREAMS_PER_CONNECTION;
73
74/// Stream names per `SUBSCRIBE` or `UNSUBSCRIBE`. Binance documents no limit;
75/// one request of 1024 names (23,598 bytes) was acknowledged and held, as were
76/// 200 and 500 (2026-11-02), so a request carries as many names as a
77/// connection holds.
78pub const MAX_NAMES_PER_REQUEST: usize = 1024;
79
80/// The shortest gap between two requests on one connection. Binance allows 5
81/// incoming messages a second, pings, pongs and requests counted. A request
82/// every 250 ms, with a client ping and a pong, was closed about 16 s in with
83/// `1008 "Too many requests"` (at most 6 messages in a second); a request every
84/// 500 ms held for 45 s with two pings and two pongs (at most 4 in a second).
85pub const MIN_REQUEST_INTERVAL: Duration = Duration::from_millis(500);
86
87/// Which routed path a connection is on. Spot has one.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub enum StreamPath {
90 /// `/stream`: every spot market stream.
91 Stream,
92}
93
94impl StreamPath {
95 /// Every path.
96 pub const ALL: &'static [Self] = &[Self::Stream];
97
98 /// The path segment.
99 pub fn as_str(self) -> &'static str {
100 match self {
101 Self::Stream => "stream",
102 }
103 }
104
105 /// The combined-stream URL on `base`.
106 pub(crate) fn url(self, base: &str) -> String {
107 format!("{}/{}", base.trim_end_matches('/'), self.as_str())
108 }
109}
110
111impl fmt::Display for StreamPath {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 f.write_str(self.as_str())
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn spot_has_one_path_and_it_is_stream() {
123 assert_eq!(StreamPath::ALL, &[StreamPath::Stream]);
124 assert_eq!(StreamPath::Stream.as_str(), "stream");
125 assert_eq!(StreamPath::Stream.to_string(), "stream");
126 }
127
128 #[test]
129 fn the_url_appends_the_one_path_to_the_base() {
130 assert_eq!(
131 StreamPath::Stream.url(SPOT_WS_BASE),
132 "wss://stream.binance.com:9443/stream"
133 );
134 // A scripted server's base, with or without a trailing slash.
135 assert_eq!(
136 StreamPath::Stream.url("ws://127.0.0.1:4000"),
137 "ws://127.0.0.1:4000/stream"
138 );
139 assert_eq!(
140 StreamPath::Stream.url("ws://127.0.0.1:4000/"),
141 "ws://127.0.0.1:4000/stream"
142 );
143 }
144
145 #[test]
146 fn the_limits_are_the_measured_ones() {
147 // OBSERVED.md, `## Spot`: 1024 names in one request held; a request every
148 // 250 ms was closed with 1008, every 500 ms held for 45 s.
149 assert_eq!(MAX_STREAMS_PER_CONNECTION, 1024);
150 assert_eq!(MAX_NAMES_PER_REQUEST, 1024);
151 assert_eq!(MIN_REQUEST_INTERVAL, Duration::from_millis(500));
152 }
153}