Skip to main content

wickra_exchange_core/
symbol.rs

1//! The unified [`Symbol`] type.
2//!
3//! Every venue spells the same market differently — `BTCUSDT` (Binance),
4//! `BTC-USDT` (OKX), `XBT/USD` (Kraken). `Symbol` is the canonical, venue-neutral
5//! form: a base asset traded against a quote asset. Each exchange module maps
6//! `Symbol` to and from its own wire format; consumers only ever see the
7//! canonical form, which is what makes the API typed rather than stringly.
8
9use crate::error::{Error, Result};
10use serde::{Deserialize, Serialize};
11use std::fmt;
12use std::str::FromStr;
13
14/// A canonical, venue-neutral market: a base asset against a quote asset.
15///
16/// Assets are upper-cased on construction so `Symbol` comparisons and hashing
17/// are case-insensitive. The canonical text form is `BASE/QUOTE` (e.g.
18/// `BTC/USDT`); [`FromStr`] also accepts a `-` separator.
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct Symbol {
21    base: String,
22    quote: String,
23}
24
25impl Symbol {
26    /// Build a symbol from a base and quote asset. Both are upper-cased.
27    pub fn new(base: impl Into<String>, quote: impl Into<String>) -> Self {
28        Self {
29            base: base.into().to_ascii_uppercase(),
30            quote: quote.into().to_ascii_uppercase(),
31        }
32    }
33
34    /// The base asset (e.g. `BTC` in `BTC/USDT`).
35    #[must_use]
36    pub fn base(&self) -> &str {
37        &self.base
38    }
39
40    /// The quote asset (e.g. `USDT` in `BTC/USDT`).
41    #[must_use]
42    pub fn quote(&self) -> &str {
43        &self.quote
44    }
45
46    /// Render as a concatenated pair with no separator, e.g. `BTCUSDT`. This is
47    /// the most common venue wire form; modules that need a different spelling
48    /// (a separator, an alias such as `XBT`) map from the base/quote parts.
49    #[must_use]
50    pub fn to_concatenated(&self) -> String {
51        format!("{}{}", self.base, self.quote)
52    }
53}
54
55impl fmt::Display for Symbol {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        write!(f, "{}/{}", self.base, self.quote)
58    }
59}
60
61impl FromStr for Symbol {
62    type Err = Error;
63
64    /// Parse `BASE/QUOTE` or `BASE-QUOTE`. The separator is required because a
65    /// bare concatenation (`BTCUSDT`) cannot be split unambiguously without the
66    /// venue's asset list.
67    fn from_str(s: &str) -> Result<Self> {
68        let (base, quote) = s
69            .split_once(['/', '-'])
70            .ok_or_else(|| Error::InvalidSymbol(s.to_string()))?;
71        if base.is_empty() || quote.is_empty() {
72            return Err(Error::InvalidSymbol(s.to_string()));
73        }
74        Ok(Self::new(base, quote))
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn new_upper_cases_assets() {
84        let symbol = Symbol::new("btc", "usdt");
85        assert_eq!(symbol.base(), "BTC");
86        assert_eq!(symbol.quote(), "USDT");
87    }
88
89    #[test]
90    fn display_is_canonical_slash_form() {
91        assert_eq!(Symbol::new("BTC", "USDT").to_string(), "BTC/USDT");
92    }
93
94    #[test]
95    fn concatenated_form_has_no_separator() {
96        assert_eq!(Symbol::new("BTC", "USDT").to_concatenated(), "BTCUSDT");
97    }
98
99    #[test]
100    fn parses_slash_and_dash_separators() {
101        assert_eq!(
102            "BTC/USDT".parse::<Symbol>().unwrap(),
103            Symbol::new("BTC", "USDT")
104        );
105        assert_eq!(
106            "eth-usd".parse::<Symbol>().unwrap(),
107            Symbol::new("ETH", "USD")
108        );
109    }
110
111    #[test]
112    fn rejects_missing_or_empty_parts() {
113        assert!("BTCUSDT".parse::<Symbol>().is_err());
114        assert!("BTC/".parse::<Symbol>().is_err());
115        assert!("/USDT".parse::<Symbol>().is_err());
116    }
117
118    #[test]
119    fn round_trips_through_json() {
120        let symbol = Symbol::new("BTC", "USDT");
121        let json = serde_json::to_string(&symbol).unwrap();
122        assert_eq!(serde_json::from_str::<Symbol>(&json).unwrap(), symbol);
123    }
124}