Skip to main content

polyoxide_binance/spot/ws/
stream.rs

1//! Stream names: the only way to name a spot market stream.
2//!
3//! Binance acknowledges every `SUBSCRIBE`, including a stream type that does
4//! not exist, an uppercase symbol, a spelling it does not serve (`@250ms`) and
5//! an unknown symbol, and none of those deliver anything. A name that will
6//! never deliver is indistinguishable from a quiet one, so names are built
7//! here, by construction, and never accepted as caller strings. The symbol is
8//! the one part this cannot check: `exchange().exchange_info()` lists the
9//! symbols that trade.
10
11use std::{fmt, str::FromStr};
12
13use crate::spot::{
14    types::{Interval, Symbol},
15    ws::StreamPath,
16};
17
18/// Levels per side of a partial depth stream.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum DepthLevels {
21    /// `depth5`.
22    Five,
23    /// `depth10`.
24    Ten,
25    /// `depth20`.
26    Twenty,
27}
28
29impl DepthLevels {
30    /// Every value, in order.
31    pub const ALL: &'static [Self] = &[Self::Five, Self::Ten, Self::Twenty];
32
33    fn as_str(self) -> &'static str {
34        match self {
35            Self::Five => "5",
36            Self::Ten => "10",
37            Self::Twenty => "20",
38        }
39    }
40}
41
42/// How often a partial depth stream pushes.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum DepthSpeed {
45    /// `@100ms`.
46    Ms100,
47    /// No suffix: every 1000 ms, Binance's default. An explicit `@250ms` or
48    /// `@500ms` is not a spot spelling.
49    Ms1000,
50}
51
52impl DepthSpeed {
53    /// Every value, in order.
54    pub const ALL: &'static [Self] = &[Self::Ms100, Self::Ms1000];
55
56    /// What follows the levels in a stream name.
57    fn suffix(self) -> &'static str {
58        match self {
59            Self::Ms100 => "@100ms",
60            Self::Ms1000 => "",
61        }
62    }
63}
64
65/// A spot market stream.
66///
67/// `Display` renders the wire name, with the symbol's ASCII letters
68/// lowercased (`btcusdt@aggTrade`, `币安人生usdt@miniTicker`), and `FromStr`
69/// parses an echoed name back; [`Symbol::new`] uppercases ASCII, so the two
70/// round-trip.
71#[derive(Debug, Clone, PartialEq, Eq, Hash)]
72#[non_exhaustive]
73pub enum StreamName {
74    /// `<s>@aggTrade`.
75    AggTrade(Symbol),
76    /// `<s>@trade`.
77    Trade(Symbol),
78    /// `<s>@kline_<interval>`.
79    Kline(Symbol, Interval),
80    /// `<s>@miniTicker`.
81    MiniTicker(Symbol),
82    /// `!miniTicker@arr`: the mini ticker of every symbol that changed.
83    AllMiniTickers,
84    /// `<s>@ticker`.
85    Ticker(Symbol),
86    /// `<s>@bookTicker`.
87    BookTicker(Symbol),
88    /// `<s>@depth<levels>`, or `<s>@depth<levels>@100ms`.
89    PartialDepth(Symbol, DepthLevels, DepthSpeed),
90    /// `<s>@avgPrice`.
91    AvgPrice(Symbol),
92}
93
94impl StreamName {
95    /// The path whose connection carries this stream: spot has one.
96    pub fn path(&self) -> StreamPath {
97        StreamPath::Stream
98    }
99
100    /// The symbol, for a single-symbol stream.
101    pub fn symbol(&self) -> Option<&Symbol> {
102        match self {
103            Self::AllMiniTickers => None,
104            Self::AggTrade(s)
105            | Self::Trade(s)
106            | Self::Kline(s, _)
107            | Self::MiniTicker(s)
108            | Self::Ticker(s)
109            | Self::BookTicker(s)
110            | Self::PartialDepth(s, ..)
111            | Self::AvgPrice(s) => Some(s),
112        }
113    }
114}
115
116impl fmt::Display for StreamName {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        let lower = |s: &Symbol| s.as_str().to_ascii_lowercase();
119        match self {
120            Self::AggTrade(s) => write!(f, "{}@aggTrade", lower(s)),
121            Self::Trade(s) => write!(f, "{}@trade", lower(s)),
122            Self::Kline(s, interval) => write!(f, "{}@kline_{interval}", lower(s)),
123            Self::MiniTicker(s) => write!(f, "{}@miniTicker", lower(s)),
124            Self::AllMiniTickers => f.write_str("!miniTicker@arr"),
125            Self::Ticker(s) => write!(f, "{}@ticker", lower(s)),
126            Self::BookTicker(s) => write!(f, "{}@bookTicker", lower(s)),
127            Self::PartialDepth(s, levels, speed) => {
128                write!(f, "{}@depth{}{}", lower(s), levels.as_str(), speed.suffix())
129            }
130            Self::AvgPrice(s) => write!(f, "{}@avgPrice", lower(s)),
131        }
132    }
133}
134
135/// A string that is not a stream name this crate builds.
136#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
137#[error("{0:?} is not a spot stream name this crate supports: StreamName builds every one, with its symbol in lowercase")]
138pub struct InvalidStreamName(pub String);
139
140/// An `InvalidRequest`: the caller named a stream this crate does not build.
141impl polyoxide_venue::Classify for InvalidStreamName {
142    fn class(&self) -> polyoxide_venue::Class {
143        polyoxide_venue::Class::InvalidRequest
144    }
145}
146
147impl FromStr for StreamName {
148    type Err = InvalidStreamName;
149
150    fn from_str(name: &str) -> Result<Self, Self::Err> {
151        let invalid = || InvalidStreamName(name.to_owned());
152        if name == "!miniTicker@arr" {
153            return Ok(Self::AllMiniTickers);
154        }
155        let (symbol, kind) = name.split_once('@').ok_or_else(invalid)?;
156        // A stream name spells its symbol in lowercase; one that does not was
157        // not built here and, on the wire, delivers nothing.
158        if symbol.chars().any(|c| c.is_ascii_uppercase()) {
159            return Err(invalid());
160        }
161        let symbol = Symbol::new(symbol).map_err(|_| invalid())?;
162        match kind {
163            "aggTrade" => Ok(Self::AggTrade(symbol)),
164            "trade" => Ok(Self::Trade(symbol)),
165            "miniTicker" => Ok(Self::MiniTicker(symbol)),
166            "ticker" => Ok(Self::Ticker(symbol)),
167            "bookTicker" => Ok(Self::BookTicker(symbol)),
168            "avgPrice" => Ok(Self::AvgPrice(symbol)),
169            _ => {
170                if let Some(interval) = kind.strip_prefix("kline_") {
171                    let interval = interval.parse().map_err(|_| invalid())?;
172                    return Ok(Self::Kline(symbol, interval));
173                }
174                let depth = kind.strip_prefix("depth").ok_or_else(invalid)?;
175                let (levels, speed) = DepthLevels::ALL
176                    .iter()
177                    .flat_map(|l| DepthSpeed::ALL.iter().map(move |s| (*l, *s)))
178                    .find(|(l, s)| depth.strip_prefix(l.as_str()) == Some(s.suffix()))
179                    .ok_or_else(invalid)?;
180                Ok(Self::PartialDepth(symbol, levels, speed))
181            }
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn every_kind(symbol: &str) -> Vec<StreamName> {
191        let s = Symbol::new(symbol).unwrap();
192        let mut names = vec![
193            StreamName::AllMiniTickers,
194            StreamName::AggTrade(s.clone()),
195            StreamName::Trade(s.clone()),
196            StreamName::MiniTicker(s.clone()),
197            StreamName::Ticker(s.clone()),
198            StreamName::BookTicker(s.clone()),
199            StreamName::AvgPrice(s.clone()),
200        ];
201        names.extend(
202            Interval::ALL
203                .iter()
204                .map(|i| StreamName::Kline(s.clone(), *i)),
205        );
206        for levels in DepthLevels::ALL {
207            for speed in DepthSpeed::ALL {
208                names.push(StreamName::PartialDepth(s.clone(), *levels, *speed));
209            }
210        }
211        names
212    }
213
214    #[test]
215    fn every_name_round_trips_through_its_wire_spelling() {
216        for symbol in [
217            "BTCUSDT",
218            "币安人生USDT",
219            "1000SATSUSDT",
220            "ÉTÉUSDT",
221            "BTCUSDT_261225",
222        ] {
223            for name in every_kind(symbol) {
224                let wire = name.to_string();
225                assert_eq!(wire.parse::<StreamName>(), Ok(name.clone()), "{wire}");
226            }
227        }
228    }
229
230    #[test]
231    fn names_are_spelled_as_the_wire_spells_them() {
232        let btc = Symbol::new("BTCUSDT").unwrap();
233        let chinese = Symbol::new("币安人生USDT").unwrap();
234        let cases = [
235            (StreamName::AggTrade(btc.clone()), "btcusdt@aggTrade"),
236            (StreamName::Trade(btc.clone()), "btcusdt@trade"),
237            (
238                StreamName::Kline(btc.clone(), Interval::Month1),
239                "btcusdt@kline_1M",
240            ),
241            (
242                StreamName::Kline(btc.clone(), Interval::Minute1),
243                "btcusdt@kline_1m",
244            ),
245            (
246                StreamName::Kline(btc.clone(), Interval::Second1),
247                "btcusdt@kline_1s",
248            ),
249            (StreamName::MiniTicker(chinese), "币安人生usdt@miniTicker"),
250            (StreamName::AllMiniTickers, "!miniTicker@arr"),
251            (StreamName::Ticker(btc.clone()), "btcusdt@ticker"),
252            (StreamName::BookTicker(btc.clone()), "btcusdt@bookTicker"),
253            (
254                StreamName::PartialDepth(btc.clone(), DepthLevels::Twenty, DepthSpeed::Ms100),
255                "btcusdt@depth20@100ms",
256            ),
257            (StreamName::AvgPrice(btc), "btcusdt@avgPrice"),
258        ];
259        for (name, wire) in cases {
260            assert_eq!(name.to_string(), wire);
261        }
262    }
263
264    #[test]
265    fn every_depth_name_is_one_binance_delivers() {
266        // Measured 2026-11-02 (OBSERVED.md, `### Frames`): each of these
267        // delivered frames at its cadence, the bare name every 1000 ms and the
268        // `@100ms` name every 100 ms.
269        let btc = Symbol::new("BTCUSDT").unwrap();
270        for (levels, speed, wire) in [
271            (DepthLevels::Five, DepthSpeed::Ms100, "btcusdt@depth5@100ms"),
272            (DepthLevels::Five, DepthSpeed::Ms1000, "btcusdt@depth5"),
273            (DepthLevels::Ten, DepthSpeed::Ms100, "btcusdt@depth10@100ms"),
274            (DepthLevels::Ten, DepthSpeed::Ms1000, "btcusdt@depth10"),
275            (
276                DepthLevels::Twenty,
277                DepthSpeed::Ms100,
278                "btcusdt@depth20@100ms",
279            ),
280            (DepthLevels::Twenty, DepthSpeed::Ms1000, "btcusdt@depth20"),
281        ] {
282            let name = StreamName::PartialDepth(btc.clone(), levels, speed);
283            assert_eq!(name.to_string(), wire);
284            assert_eq!(wire.parse::<StreamName>(), Ok(name));
285        }
286        assert_eq!(DepthLevels::ALL.len() * DepthSpeed::ALL.len(), 6);
287    }
288
289    #[test]
290    fn every_stream_rides_the_one_path() {
291        for name in every_kind("BTCUSDT") {
292            assert_eq!(name.path(), StreamPath::Stream, "{name}");
293        }
294    }
295
296    #[test]
297    fn only_the_array_stream_has_no_symbol() {
298        for name in every_kind("BTCUSDT") {
299            match name {
300                StreamName::AllMiniTickers => assert_eq!(name.symbol(), None),
301                _ => assert_eq!(name.symbol().map(Symbol::as_str), Some("BTCUSDT"), "{name}"),
302            }
303        }
304    }
305
306    #[test]
307    fn a_name_this_crate_does_not_build_does_not_parse() {
308        for bad in [
309            // Uppercase: acknowledged by Binance and delivers nothing.
310            "BTCUSDT@aggTrade",
311            "BTCUSDT@trade",
312            "btcUSDT@trade",
313            // A stream type that does not exist.
314            "btcusdt@nonsense",
315            // Depth: five levels, a speed spot does not serve, a level count it
316            // does not have.
317            "btcusdt@depth7@100ms",
318            "btcusdt@depth5@250ms",
319            "btcusdt@depth5@500ms",
320            "btcusdt@depth50",
321            "btcusdt@depth",
322            // An interval spot does not have.
323            "btcusdt@kline_2s",
324            "btcusdt@kline_",
325            // Streams of the other product and a retired one.
326            "btcusdt@markPrice@1s",
327            "!ticker@arr",
328            "!miniTicker@arr@1s",
329            "!bookTicker",
330            // Not a stream at all.
331            "btcusdt",
332            "",
333            "@trade",
334            "btc usdt@ticker",
335        ] {
336            assert_eq!(
337                bad.parse::<StreamName>(),
338                Err(InvalidStreamName(bad.to_owned())),
339                "{bad:?}"
340            );
341        }
342    }
343
344    #[test]
345    fn a_name_that_does_not_parse_is_an_invalid_request() {
346        use polyoxide_venue::{Class, Classify};
347
348        let err = "btcusdt".parse::<StreamName>().unwrap_err();
349        assert_eq!(err.class(), Class::InvalidRequest);
350        assert!(err.is_fault());
351        assert_eq!(err.retry_after(), None);
352        assert!(!err.is_retriable());
353    }
354
355    #[test]
356    fn every_captured_envelope_names_a_stream_this_crate_builds_and_echoes_it() {
357        use crate::spot::ws::fixtures;
358
359        let btc = Symbol::new("BTCUSDT").unwrap();
360        // Each fixture, with the name its capture subscribed to.
361        let expected = [
362            (fixtures::AGG_TRADE, "btcusdt@aggTrade"),
363            (fixtures::TRADE, "btcusdt@trade"),
364            (fixtures::KLINE, "btcusdt@kline_1m"),
365            (fixtures::MINI_TICKER, "btcusdt@miniTicker"),
366            (fixtures::ALL_MINI_TICKERS, "!miniTicker@arr"),
367            (fixtures::TICKER, "btcusdt@ticker"),
368            (fixtures::BOOK_TICKER, "btcusdt@bookTicker"),
369            (fixtures::PARTIAL_DEPTH, "btcusdt@depth5"),
370            (fixtures::PARTIAL_DEPTH_100MS, "btcusdt@depth20@100ms"),
371            (fixtures::AVG_PRICE, "btcusdt@avgPrice"),
372        ];
373        for (frame, wire) in expected {
374            let envelope: serde_json::Value = serde_json::from_str(frame).unwrap();
375            assert_eq!(envelope["stream"], wire);
376            let name: StreamName = wire.parse().unwrap();
377            assert_eq!(name.to_string(), wire);
378        }
379        let parsed: StreamName = "btcusdt@depth20@100ms".parse().unwrap();
380        assert_eq!(
381            parsed,
382            StreamName::PartialDepth(btc, DepthLevels::Twenty, DepthSpeed::Ms100)
383        );
384
385        // The non-ASCII symbol's name is echoed as sent, and parses back to the
386        // name that renders it.
387        let envelope: serde_json::Value =
388            serde_json::from_str(fixtures::NON_ASCII_MINI_TICKER).unwrap();
389        let echoed = envelope["stream"].as_str().unwrap();
390        assert!(!echoed.is_ascii(), "{echoed}");
391        let name: StreamName = echoed.parse().unwrap();
392        assert!(matches!(&name, StreamName::MiniTicker(s) if !s.as_str().is_ascii()));
393        assert_eq!(name.to_string(), echoed);
394
395        // And every file in `ALL` is an envelope whose name parses.
396        assert_eq!(fixtures::ALL.len(), 11);
397        for (stem, frame) in fixtures::ALL {
398            let envelope: serde_json::Value = serde_json::from_str(frame).unwrap();
399            let echoed = envelope["stream"].as_str().unwrap();
400            let name: StreamName = echoed.parse().unwrap_or_else(|e| panic!("{stem}: {e}"));
401            assert_eq!(name.to_string(), echoed, "{stem}");
402        }
403    }
404}