1use std::{fmt, str::FromStr};
12
13use crate::spot::{
14 types::{Interval, Symbol},
15 ws::StreamPath,
16};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum DepthLevels {
21 Five,
23 Ten,
25 Twenty,
27}
28
29impl DepthLevels {
30 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum DepthSpeed {
45 Ms100,
47 Ms1000,
50}
51
52impl DepthSpeed {
53 pub const ALL: &'static [Self] = &[Self::Ms100, Self::Ms1000];
55
56 fn suffix(self) -> &'static str {
58 match self {
59 Self::Ms100 => "@100ms",
60 Self::Ms1000 => "",
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
72#[non_exhaustive]
73pub enum StreamName {
74 AggTrade(Symbol),
76 Trade(Symbol),
78 Kline(Symbol, Interval),
80 MiniTicker(Symbol),
82 AllMiniTickers,
84 Ticker(Symbol),
86 BookTicker(Symbol),
88 PartialDepth(Symbol, DepthLevels, DepthSpeed),
90 AvgPrice(Symbol),
92}
93
94impl StreamName {
95 pub fn path(&self) -> StreamPath {
97 StreamPath::Stream
98 }
99
100 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#[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
140impl 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 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 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 "BTCUSDT@aggTrade",
311 "BTCUSDT@trade",
312 "btcUSDT@trade",
313 "btcusdt@nonsense",
315 "btcusdt@depth7@100ms",
318 "btcusdt@depth5@250ms",
319 "btcusdt@depth5@500ms",
320 "btcusdt@depth50",
321 "btcusdt@depth",
322 "btcusdt@kline_2s",
324 "btcusdt@kline_",
325 "btcusdt@markPrice@1s",
327 "!ticker@arr",
328 "!miniTicker@arr@1s",
329 "!bookTicker",
330 "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 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 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 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}