1use std::{fmt, str::FromStr};
12
13use crate::usdm::{
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 Ms250,
50 Ms500,
52}
53
54impl DepthSpeed {
55 pub const ALL: &'static [Self] = &[Self::Ms100, Self::Ms250, Self::Ms500];
57
58 fn suffix(self) -> &'static str {
60 match self {
61 Self::Ms100 => "@100ms",
62 Self::Ms250 => "",
63 Self::Ms500 => "@500ms",
64 }
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75#[non_exhaustive]
76pub enum StreamName {
77 AllTickers,
79 AllMarkPrices,
81 AggTrade(Symbol),
83 Kline(Symbol, Interval),
85 MarkPrice(Symbol),
87 Ticker(Symbol),
89 PartialDepth(Symbol, DepthLevels, DepthSpeed),
91 BookTicker(Symbol),
93}
94
95impl StreamName {
96 pub fn path(&self) -> StreamPath {
98 match self {
99 Self::PartialDepth(..) | Self::BookTicker(_) => StreamPath::Public,
100 Self::AllTickers
101 | Self::AllMarkPrices
102 | Self::AggTrade(_)
103 | Self::Kline(..)
104 | Self::MarkPrice(_)
105 | Self::Ticker(_) => StreamPath::Market,
106 }
107 }
108
109 pub fn symbol(&self) -> Option<&Symbol> {
111 match self {
112 Self::AllTickers | Self::AllMarkPrices => None,
113 Self::AggTrade(s)
114 | Self::Kline(s, _)
115 | Self::MarkPrice(s)
116 | Self::Ticker(s)
117 | Self::PartialDepth(s, ..)
118 | Self::BookTicker(s) => Some(s),
119 }
120 }
121}
122
123impl fmt::Display for StreamName {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 let lower = |s: &Symbol| s.as_str().to_ascii_lowercase();
126 match self {
127 Self::AllTickers => f.write_str("!ticker@arr"),
128 Self::AllMarkPrices => f.write_str("!markPrice@arr@1s"),
129 Self::AggTrade(s) => write!(f, "{}@aggTrade", lower(s)),
130 Self::Kline(s, interval) => write!(f, "{}@kline_{interval}", lower(s)),
131 Self::MarkPrice(s) => write!(f, "{}@markPrice@1s", lower(s)),
132 Self::Ticker(s) => write!(f, "{}@ticker", lower(s)),
133 Self::PartialDepth(s, levels, speed) => {
134 write!(f, "{}@depth{}{}", lower(s), levels.as_str(), speed.suffix())
135 }
136 Self::BookTicker(s) => write!(f, "{}@bookTicker", lower(s)),
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
143#[error("{0:?} is not a USDⓈ-M stream name this crate supports: StreamName builds every one, with its symbol in lowercase")]
144pub struct InvalidStreamName(pub String);
145
146impl polyoxide_venue::Classify for InvalidStreamName {
148 fn class(&self) -> polyoxide_venue::Class {
149 polyoxide_venue::Class::InvalidRequest
150 }
151}
152
153impl FromStr for StreamName {
154 type Err = InvalidStreamName;
155
156 fn from_str(name: &str) -> Result<Self, Self::Err> {
157 let invalid = || InvalidStreamName(name.to_owned());
158 match name {
159 "!ticker@arr" => return Ok(Self::AllTickers),
160 "!markPrice@arr@1s" => return Ok(Self::AllMarkPrices),
161 _ => {}
162 }
163 let (symbol, kind) = name.split_once('@').ok_or_else(invalid)?;
164 if symbol.chars().any(|c| c.is_ascii_uppercase()) {
167 return Err(invalid());
168 }
169 let symbol = Symbol::new(symbol).map_err(|_| invalid())?;
170 match kind {
171 "aggTrade" => Ok(Self::AggTrade(symbol)),
172 "markPrice@1s" => Ok(Self::MarkPrice(symbol)),
173 "ticker" => Ok(Self::Ticker(symbol)),
174 "bookTicker" => Ok(Self::BookTicker(symbol)),
175 _ => {
176 if let Some(interval) = kind.strip_prefix("kline_") {
177 let interval = interval.parse().map_err(|_| invalid())?;
178 return Ok(Self::Kline(symbol, interval));
179 }
180 let depth = kind.strip_prefix("depth").ok_or_else(invalid)?;
181 let (levels, speed) = DepthLevels::ALL
182 .iter()
183 .flat_map(|l| DepthSpeed::ALL.iter().map(move |s| (*l, *s)))
184 .find(|(l, s)| depth.strip_prefix(l.as_str()) == Some(s.suffix()))
185 .ok_or_else(invalid)?;
186 Ok(Self::PartialDepth(symbol, levels, speed))
187 }
188 }
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn every_kind(symbol: &str) -> Vec<StreamName> {
197 let s = Symbol::new(symbol).unwrap();
198 let mut names = vec![
199 StreamName::AllTickers,
200 StreamName::AllMarkPrices,
201 StreamName::AggTrade(s.clone()),
202 StreamName::MarkPrice(s.clone()),
203 StreamName::Ticker(s.clone()),
204 StreamName::BookTicker(s.clone()),
205 ];
206 names.extend(
207 Interval::ALL
208 .iter()
209 .map(|i| StreamName::Kline(s.clone(), *i)),
210 );
211 for levels in DepthLevels::ALL {
212 for speed in DepthSpeed::ALL {
213 names.push(StreamName::PartialDepth(s.clone(), *levels, *speed));
214 }
215 }
216 names
217 }
218
219 #[test]
220 fn every_name_round_trips_through_its_wire_spelling() {
221 for symbol in [
222 "BTCUSDT",
223 "BTCUSDT_261225",
224 "币安人生USDT",
225 "1000PEPEUSDT",
226 "ÉTÉUSDT",
227 ] {
228 for name in every_kind(symbol) {
229 let wire = name.to_string();
230 assert_eq!(wire.parse::<StreamName>(), Ok(name.clone()), "{wire}");
231 }
232 }
233 }
234
235 #[test]
236 fn names_are_spelled_as_the_wire_spells_them() {
237 let btc = Symbol::new("BTCUSDT").unwrap();
238 let chinese = Symbol::new("币安人生USDT").unwrap();
239 let cases = [
240 (StreamName::AllTickers, "!ticker@arr"),
241 (StreamName::AllMarkPrices, "!markPrice@arr@1s"),
242 (StreamName::AggTrade(btc.clone()), "btcusdt@aggTrade"),
243 (
244 StreamName::Kline(btc.clone(), Interval::Mo1),
245 "btcusdt@kline_1M",
246 ),
247 (StreamName::MarkPrice(chinese), "币安人生usdt@markPrice@1s"),
248 (StreamName::Ticker(btc.clone()), "btcusdt@ticker"),
249 (
250 StreamName::PartialDepth(btc.clone(), DepthLevels::Twenty, DepthSpeed::Ms100),
251 "btcusdt@depth20@100ms",
252 ),
253 (StreamName::BookTicker(btc), "btcusdt@bookTicker"),
254 ];
255 for (name, wire) in cases {
256 assert_eq!(name.to_string(), wire);
257 }
258 }
259
260 #[test]
261 fn every_depth_name_is_one_binance_delivers() {
262 let btc = Symbol::new("BTCUSDT").unwrap();
265 for (levels, speed, wire) in [
266 (DepthLevels::Five, DepthSpeed::Ms100, "btcusdt@depth5@100ms"),
267 (DepthLevels::Five, DepthSpeed::Ms250, "btcusdt@depth5"),
268 (DepthLevels::Five, DepthSpeed::Ms500, "btcusdt@depth5@500ms"),
269 (DepthLevels::Ten, DepthSpeed::Ms100, "btcusdt@depth10@100ms"),
270 (DepthLevels::Ten, DepthSpeed::Ms250, "btcusdt@depth10"),
271 (DepthLevels::Ten, DepthSpeed::Ms500, "btcusdt@depth10@500ms"),
272 (
273 DepthLevels::Twenty,
274 DepthSpeed::Ms100,
275 "btcusdt@depth20@100ms",
276 ),
277 (DepthLevels::Twenty, DepthSpeed::Ms250, "btcusdt@depth20"),
278 (
279 DepthLevels::Twenty,
280 DepthSpeed::Ms500,
281 "btcusdt@depth20@500ms",
282 ),
283 ] {
284 let name = StreamName::PartialDepth(btc.clone(), levels, speed);
285 assert_eq!(name.to_string(), wire);
286 assert_eq!(wire.parse::<StreamName>(), Ok(name));
287 }
288 }
289
290 #[test]
291 fn depth_and_book_ticker_ride_the_public_path_and_the_rest_the_market_path() {
292 for name in every_kind("BTCUSDT") {
293 let expected = match name {
294 StreamName::PartialDepth(..) | StreamName::BookTicker(_) => StreamPath::Public,
295 _ => StreamPath::Market,
296 };
297 assert_eq!(name.path(), expected, "{name}");
298 }
299 }
300
301 #[test]
302 fn a_name_this_crate_does_not_build_does_not_parse() {
303 for bad in [
304 "BTCUSDT@aggTrade",
305 "btcusdt@nonsense",
306 "btcusdt@depth7@100ms",
307 "btcusdt@depth5@250ms",
308 "btcusdt@depth50",
309 "btcusdt@kline_1s",
310 "btcusdt",
311 "!bookTicker",
312 "btc usdt@ticker",
313 ] {
314 assert_eq!(
315 bad.parse::<StreamName>(),
316 Err(InvalidStreamName(bad.to_owned())),
317 "{bad}"
318 );
319 }
320 }
321
322 #[test]
323 fn a_name_that_does_not_parse_is_an_invalid_request() {
324 use polyoxide_venue::{Class, Classify};
325
326 let err = "btcusdt".parse::<StreamName>().unwrap_err();
327 assert_eq!(err.class(), Class::InvalidRequest);
328 assert!(err.is_fault());
329 assert_eq!(err.retry_after(), None);
330 assert!(!err.is_retriable());
331 }
332}