Skip to main content

scope/market/
pair.rs

1//! Trading pairs: one parser and one display format for every surface.
2//!
3//! Users write a pair as `BASE_QUOTE`, `BASE/QUOTE`, `BASE-QUOTE`, a bare
4//! `BASE`, or `BASEQUOTE` when the quote is a stablecoin
5//! or a fiat currency ([`FIAT_CURRENCIES`]). Venue responses use the same forms. [`Pair::parse`]
6//! reads all of them, and `Display` writes `BASE/QUOTE`.
7
8use crate::tokens::STABLECOIN_SYMBOLS;
9use std::fmt;
10
11/// Fiat quote currencies. With the stablecoins
12/// ([`STABLECOIN_SYMBOLS`]) they are the quotes that may end a joined symbol
13/// such as `OPUSDT`. A crypto quote (BTC, ETH, BNB) needs a separator, so
14/// `WETH` and `STETH` stay whole.
15pub const FIAT_CURRENCIES: [&str; 4] = ["USD", "EUR", "GBP", "TRY"];
16
17/// The pair a market request falls back to when none is given.
18pub const DEFAULT_PAIR: &str = "USDC";
19
20/// Stablecoins that end in a fiat code and are no venue's quote currency.
21/// The longest-suffix rule would read `SOLUSD` as SO/LUSD, so a joined symbol
22/// never ends in one of these. `TUSD` is a quote (Binance `BTCTUSD`).
23const NOT_JOINED_QUOTES: [&str; 2] = ["LUSD", "GUSD"];
24
25/// The quotes that may end a joined symbol: the stablecoins (less
26/// [`NOT_JOINED_QUOTES`]), then fiat.
27fn quote_currencies() -> impl Iterator<Item = &'static str> {
28    STABLECOIN_SYMBOLS
29        .into_iter()
30        .filter(|s| !NOT_JOINED_QUOTES.contains(s))
31        .chain(FIAT_CURRENCIES)
32}
33
34/// The fewest base characters left after a quote suffix is split off, so
35/// `TUSD` and `XUSDT` stay whole.
36const MIN_BASE_LEN: usize = 2;
37
38/// Separators accepted between base and quote.
39const SEPARATORS: [char; 3] = ['_', '/', '-'];
40
41/// A base asset and, when known, its quote asset. With no quote, a venue
42/// uses its default quote.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Pair {
45    /// Base asset symbol, as written (case is kept).
46    pub base: String,
47    /// Quote asset symbol, as written; `None` for a bare base.
48    pub quote: Option<String>,
49}
50
51impl Pair {
52    /// A pair of `base` with the venue's default quote. Use this for a token
53    /// symbol, which must not be split (`WETH` is not `W/ETH`).
54    pub fn base_only(base: &str) -> Self {
55        Self {
56            base: base.trim().to_string(),
57            quote: None,
58        }
59    }
60
61    /// Parses user input or a venue symbol. Accepts `key=value` input
62    /// (`pair=DAI_USDC`), a separated pair, a base followed by a known quote
63    /// currency, or a bare base.
64    pub fn parse(input: &str) -> Self {
65        let p = strip_key(input.trim());
66        if let Some((base, quote)) = split_on_separator(p).or_else(|| split_on_quote_suffix(p)) {
67            return Self {
68                base: base.to_string(),
69                quote: Some(quote.to_string()),
70            };
71        }
72        Self::base_only(p)
73    }
74}
75
76impl fmt::Display for Pair {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match &self.quote {
79            Some(quote) => write!(f, "{}/{}", self.base, quote),
80            None => f.write_str(&self.base),
81        }
82    }
83}
84
85/// `pair=USDC_USDT` -> `USDC_USDT`; other input is returned unchanged.
86fn strip_key(p: &str) -> &str {
87    match p.split_once('=') {
88        Some((key, value))
89            if !value.is_empty()
90                && key
91                    .chars()
92                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') =>
93        {
94            value
95        }
96        _ => p,
97    }
98}
99
100fn split_on_separator(p: &str) -> Option<(&str, &str)> {
101    SEPARATORS.iter().find_map(|&sep| {
102        p.split_once(sep)
103            .filter(|(base, quote)| !base.is_empty() && !quote.is_empty())
104    })
105}
106
107/// The longest quote currency that ends `p` decides (`FDUSD`, not `USD`). It
108/// splits only when at least [`MIN_BASE_LEN`] characters remain, so a quote
109/// on its own (`BUSD`, `FDUSD`) stays a base.
110fn split_on_quote_suffix(p: &str) -> Option<(&str, &str)> {
111    let upper = p.to_ascii_uppercase();
112    let quote = quote_currencies()
113        .filter(|quote| upper.ends_with(quote))
114        .max_by_key(|quote| quote.len())?;
115    let base_len = p.len() - quote.len();
116    (base_len >= MIN_BASE_LEN).then(|| p.split_at(base_len))
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn parts(input: &str) -> (String, Option<String>) {
124        let pair = Pair::parse(input);
125        (pair.base, pair.quote)
126    }
127
128    fn pair(base: &str, quote: Option<&str>) -> (String, Option<String>) {
129        (base.to_string(), quote.map(str::to_string))
130    }
131
132    #[test]
133    fn separated_forms_split_on_the_separator() {
134        assert_eq!(parts("USDT_PUSD"), pair("USDT", Some("PUSD")));
135        assert_eq!(parts("ETH/USDC"), pair("ETH", Some("USDC")));
136        assert_eq!(parts("BTC-USDT"), pair("BTC", Some("USDT")));
137        assert_eq!(parts("DAI_USDC"), pair("DAI", Some("USDC")));
138    }
139
140    #[test]
141    fn case_is_kept() {
142        assert_eq!(parts("dai_usdt"), pair("dai", Some("usdt")));
143        assert_eq!(parts("btcusdt"), pair("btc", Some("usdt")));
144    }
145
146    #[test]
147    fn concatenated_forms_split_on_a_known_quote() {
148        assert_eq!(parts("BTCUSDT"), pair("BTC", Some("USDT")));
149        assert_eq!(parts("ETHUSD"), pair("ETH", Some("USD")));
150        assert_eq!(parts("ETHUSDC"), pair("ETH", Some("USDC")));
151        assert_eq!(parts("USDCUSDT"), pair("USDC", Some("USDT")));
152        assert_eq!(parts("XBTEUR"), pair("XBT", Some("EUR")));
153    }
154
155    /// The audit's inputs: before one list, the CLI knew TRY but not GBP and
156    /// the venue display knew GBP but not TRY.
157    #[test]
158    fn gbp_and_try_parse_and_display() {
159        assert_eq!(parts("BTCGBP"), pair("BTC", Some("GBP")));
160        assert_eq!(parts("BTCTRY"), pair("BTC", Some("TRY")));
161        assert_eq!(Pair::parse("BTCGBP").to_string(), "BTC/GBP");
162        assert_eq!(Pair::parse("BTCTRY").to_string(), "BTC/TRY");
163        assert_eq!(Pair::parse("XBTGBP").to_string(), "XBT/GBP");
164    }
165
166    #[test]
167    fn a_bare_base_has_no_quote() {
168        assert_eq!(parts("ARB"), pair("ARB", None));
169        assert_eq!(parts("USDC"), pair("USDC", None));
170        assert_eq!(parts("USDT"), pair("USDT", None));
171        assert_eq!(parts("X"), pair("X", None));
172        assert_eq!(parts("XYZABC"), pair("XYZABC", None));
173        assert_eq!(parts(""), pair("", None));
174        assert_eq!(Pair::parse("USDT").to_string(), "USDT");
175        assert_eq!(Pair::parse("A").to_string(), "A");
176    }
177
178    #[test]
179    fn whitespace_and_key_value_input_are_accepted() {
180        assert_eq!(parts("  DAI_USDT  "), pair("DAI", Some("USDT")));
181        assert_eq!(parts("pair=USDT_PUSD"), pair("USDT", Some("PUSD")));
182        assert_eq!(parts("pair_symbol=USDT_PUSD"), pair("USDT", Some("PUSD")));
183    }
184
185    #[test]
186    fn venue_symbols_display_with_a_slash() {
187        assert_eq!(Pair::parse("BTC_USDT").to_string(), "BTC/USDT");
188        assert_eq!(Pair::parse("BTC-USDT").to_string(), "BTC/USDT");
189        assert_eq!(Pair::parse("BTCUSDT").to_string(), "BTC/USDT");
190        assert_eq!(Pair::parse("ETHUSD").to_string(), "ETH/USD");
191    }
192
193    #[test]
194    fn base_only_never_splits_a_token_symbol() {
195        assert_eq!(
196            Pair::base_only("WETH"),
197            Pair {
198                base: "WETH".into(),
199                quote: None
200            }
201        );
202    }
203
204    /// Concatenated input splits only on a stable or fiat quote, and only when
205    /// at least two base characters remain. Crypto quotes need a separator.
206    #[test]
207    fn concatenated_input_splits_only_on_a_stable_or_fiat_quote() {
208        assert_eq!(parts("OPUSDT"), pair("OP", Some("USDT")));
209        assert_eq!(parts("WBTCUSDT"), pair("WBTC", Some("USDT")));
210        assert_eq!(parts("ETHFDUSD"), pair("ETH", Some("FDUSD")));
211        assert_eq!(parts("BTCBUSD"), pair("BTC", Some("BUSD")));
212        assert_eq!(parts("ETHDAI"), pair("ETH", Some("DAI")));
213        assert_eq!(parts("WETH"), pair("WETH", None));
214        assert_eq!(parts("STETH"), pair("STETH", None));
215        assert_eq!(parts("BTCETH"), pair("BTCETH", None));
216        assert_eq!(parts("BUSD"), pair("BUSD", None));
217        assert_eq!(parts("FDUSD"), pair("FDUSD", None));
218        assert_eq!(parts("TUSD"), pair("TUSD", None));
219        assert_eq!(parts("XUSDT"), pair("XUSDT", None));
220    }
221
222    /// The quote list is the stablecoin list plus fiat, so every stablecoin
223    /// splits a joined symbol and every split stable quote counts as USD.
224    #[test]
225    fn every_stablecoin_is_a_quote_and_counts_as_usd() {
226        assert_eq!(parts("BTCTUSD"), pair("BTC", Some("TUSD")));
227        assert_eq!(parts("ETHUSDP"), pair("ETH", Some("USDP")));
228        for symbol in STABLECOIN_SYMBOLS
229            .into_iter()
230            .filter(|s| !NOT_JOINED_QUOTES.contains(s))
231        {
232            let quote = Pair::parse(&format!("BTC{symbol}")).quote;
233            assert_eq!(quote.as_deref(), Some(symbol), "{symbol}");
234        }
235        // The monitor counts a ticker's volume as USD when its quote is a
236        // stablecoin: BTC/FDUSD does.
237        let quote = Pair::parse("BTC/FDUSD").quote.unwrap();
238        assert!(crate::tokens::is_stablecoin_symbol(&quote));
239    }
240
241    /// `SOLUSD` is SOL/USD on Kraken, not SO/LUSD. LUSD and GUSD end in a
242    /// fiat code and are no venue's quote, so a joined symbol splits on the
243    /// fiat code. Separated, they are still quotes.
244    #[test]
245    fn a_joined_symbol_never_ends_in_a_non_quote_stablecoin() {
246        assert_eq!(parts("SOLUSD"), pair("SOL", Some("USD")));
247        assert_eq!(parts("DOGUSD"), pair("DOG", Some("USD")));
248        assert_eq!(parts("BTCLUSD"), pair("BTCL", Some("USD")));
249        assert_eq!(parts("SOL/LUSD"), pair("SOL", Some("LUSD")));
250        for symbol in NOT_JOINED_QUOTES {
251            assert!(crate::tokens::is_stablecoin_symbol(symbol), "{symbol}");
252        }
253    }
254
255    #[test]
256    fn a_separator_always_splits() {
257        assert_eq!(parts("BTC_ETH"), pair("BTC", Some("ETH")));
258        assert_eq!(parts("WETH/BTC"), pair("WETH", Some("BTC")));
259        assert_eq!(parts("X-USDT"), pair("X", Some("USDT")));
260    }
261}