use std::{fmt, str::FromStr};
use crate::usdm::{
types::{Interval, Symbol},
ws::StreamPath,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DepthLevels {
Five,
Ten,
Twenty,
}
impl DepthLevels {
pub const ALL: &'static [Self] = &[Self::Five, Self::Ten, Self::Twenty];
fn as_str(self) -> &'static str {
match self {
Self::Five => "5",
Self::Ten => "10",
Self::Twenty => "20",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DepthSpeed {
Ms100,
Ms250,
Ms500,
}
impl DepthSpeed {
pub const ALL: &'static [Self] = &[Self::Ms100, Self::Ms250, Self::Ms500];
fn suffix(self) -> &'static str {
match self {
Self::Ms100 => "@100ms",
Self::Ms250 => "",
Self::Ms500 => "@500ms",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum StreamName {
AllTickers,
AllMarkPrices,
AggTrade(Symbol),
Kline(Symbol, Interval),
MarkPrice(Symbol),
Ticker(Symbol),
PartialDepth(Symbol, DepthLevels, DepthSpeed),
BookTicker(Symbol),
}
impl StreamName {
pub fn path(&self) -> StreamPath {
match self {
Self::PartialDepth(..) | Self::BookTicker(_) => StreamPath::Public,
Self::AllTickers
| Self::AllMarkPrices
| Self::AggTrade(_)
| Self::Kline(..)
| Self::MarkPrice(_)
| Self::Ticker(_) => StreamPath::Market,
}
}
pub fn symbol(&self) -> Option<&Symbol> {
match self {
Self::AllTickers | Self::AllMarkPrices => None,
Self::AggTrade(s)
| Self::Kline(s, _)
| Self::MarkPrice(s)
| Self::Ticker(s)
| Self::PartialDepth(s, ..)
| Self::BookTicker(s) => Some(s),
}
}
}
impl fmt::Display for StreamName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let lower = |s: &Symbol| s.as_str().to_ascii_lowercase();
match self {
Self::AllTickers => f.write_str("!ticker@arr"),
Self::AllMarkPrices => f.write_str("!markPrice@arr@1s"),
Self::AggTrade(s) => write!(f, "{}@aggTrade", lower(s)),
Self::Kline(s, interval) => write!(f, "{}@kline_{interval}", lower(s)),
Self::MarkPrice(s) => write!(f, "{}@markPrice@1s", lower(s)),
Self::Ticker(s) => write!(f, "{}@ticker", lower(s)),
Self::PartialDepth(s, levels, speed) => {
write!(f, "{}@depth{}{}", lower(s), levels.as_str(), speed.suffix())
}
Self::BookTicker(s) => write!(f, "{}@bookTicker", lower(s)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0:?} is not a USDⓈ-M stream name this crate supports: StreamName builds every one, with its symbol in lowercase")]
pub struct InvalidStreamName(pub String);
impl FromStr for StreamName {
type Err = InvalidStreamName;
fn from_str(name: &str) -> Result<Self, Self::Err> {
let invalid = || InvalidStreamName(name.to_owned());
match name {
"!ticker@arr" => return Ok(Self::AllTickers),
"!markPrice@arr@1s" => return Ok(Self::AllMarkPrices),
_ => {}
}
let (symbol, kind) = name.split_once('@').ok_or_else(invalid)?;
if symbol.chars().any(|c| c.is_ascii_uppercase()) {
return Err(invalid());
}
let symbol = Symbol::new(symbol).map_err(|_| invalid())?;
match kind {
"aggTrade" => Ok(Self::AggTrade(symbol)),
"markPrice@1s" => Ok(Self::MarkPrice(symbol)),
"ticker" => Ok(Self::Ticker(symbol)),
"bookTicker" => Ok(Self::BookTicker(symbol)),
_ => {
if let Some(interval) = kind.strip_prefix("kline_") {
let interval = interval.parse().map_err(|_| invalid())?;
return Ok(Self::Kline(symbol, interval));
}
let depth = kind.strip_prefix("depth").ok_or_else(invalid)?;
let (levels, speed) = DepthLevels::ALL
.iter()
.flat_map(|l| DepthSpeed::ALL.iter().map(move |s| (*l, *s)))
.find(|(l, s)| depth.strip_prefix(l.as_str()) == Some(s.suffix()))
.ok_or_else(invalid)?;
Ok(Self::PartialDepth(symbol, levels, speed))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn every_kind(symbol: &str) -> Vec<StreamName> {
let s = Symbol::new(symbol).unwrap();
let mut names = vec![
StreamName::AllTickers,
StreamName::AllMarkPrices,
StreamName::AggTrade(s.clone()),
StreamName::MarkPrice(s.clone()),
StreamName::Ticker(s.clone()),
StreamName::BookTicker(s.clone()),
];
names.extend(
Interval::ALL
.iter()
.map(|i| StreamName::Kline(s.clone(), *i)),
);
for levels in DepthLevels::ALL {
for speed in DepthSpeed::ALL {
names.push(StreamName::PartialDepth(s.clone(), *levels, *speed));
}
}
names
}
#[test]
fn every_name_round_trips_through_its_wire_spelling() {
for symbol in [
"BTCUSDT",
"BTCUSDT_261225",
"币安人生USDT",
"1000PEPEUSDT",
"ÉTÉUSDT",
] {
for name in every_kind(symbol) {
let wire = name.to_string();
assert_eq!(wire.parse::<StreamName>(), Ok(name.clone()), "{wire}");
}
}
}
#[test]
fn names_are_spelled_as_the_wire_spells_them() {
let btc = Symbol::new("BTCUSDT").unwrap();
let chinese = Symbol::new("币安人生USDT").unwrap();
let cases = [
(StreamName::AllTickers, "!ticker@arr"),
(StreamName::AllMarkPrices, "!markPrice@arr@1s"),
(StreamName::AggTrade(btc.clone()), "btcusdt@aggTrade"),
(
StreamName::Kline(btc.clone(), Interval::Mo1),
"btcusdt@kline_1M",
),
(StreamName::MarkPrice(chinese), "币安人生usdt@markPrice@1s"),
(StreamName::Ticker(btc.clone()), "btcusdt@ticker"),
(
StreamName::PartialDepth(btc.clone(), DepthLevels::Twenty, DepthSpeed::Ms100),
"btcusdt@depth20@100ms",
),
(StreamName::BookTicker(btc), "btcusdt@bookTicker"),
];
for (name, wire) in cases {
assert_eq!(name.to_string(), wire);
}
}
#[test]
fn every_depth_name_is_one_binance_delivers() {
let btc = Symbol::new("BTCUSDT").unwrap();
for (levels, speed, wire) in [
(DepthLevels::Five, DepthSpeed::Ms100, "btcusdt@depth5@100ms"),
(DepthLevels::Five, DepthSpeed::Ms250, "btcusdt@depth5"),
(DepthLevels::Five, DepthSpeed::Ms500, "btcusdt@depth5@500ms"),
(DepthLevels::Ten, DepthSpeed::Ms100, "btcusdt@depth10@100ms"),
(DepthLevels::Ten, DepthSpeed::Ms250, "btcusdt@depth10"),
(DepthLevels::Ten, DepthSpeed::Ms500, "btcusdt@depth10@500ms"),
(
DepthLevels::Twenty,
DepthSpeed::Ms100,
"btcusdt@depth20@100ms",
),
(DepthLevels::Twenty, DepthSpeed::Ms250, "btcusdt@depth20"),
(
DepthLevels::Twenty,
DepthSpeed::Ms500,
"btcusdt@depth20@500ms",
),
] {
let name = StreamName::PartialDepth(btc.clone(), levels, speed);
assert_eq!(name.to_string(), wire);
assert_eq!(wire.parse::<StreamName>(), Ok(name));
}
}
#[test]
fn depth_and_book_ticker_ride_the_public_path_and_the_rest_the_market_path() {
for name in every_kind("BTCUSDT") {
let expected = match name {
StreamName::PartialDepth(..) | StreamName::BookTicker(_) => StreamPath::Public,
_ => StreamPath::Market,
};
assert_eq!(name.path(), expected, "{name}");
}
}
#[test]
fn a_name_this_crate_does_not_build_does_not_parse() {
for bad in [
"BTCUSDT@aggTrade",
"btcusdt@nonsense",
"btcusdt@depth7@100ms",
"btcusdt@depth5@250ms",
"btcusdt@depth50",
"btcusdt@kline_1s",
"btcusdt",
"!bookTicker",
"btc usdt@ticker",
] {
assert_eq!(
bad.parse::<StreamName>(),
Err(InvalidStreamName(bad.to_owned())),
"{bad}"
);
}
}
}