pragma_common/pair/
mod.rs

1use std::str::FromStr;
2
3const STABLE_SUFFIXES: [&str; 4] = ["USDT", "USDC", "USD", "DAI"];
4
5/// A pair of assets, e.g. BTC/USD
6///
7/// This is a simple struct that holds the base and quote assets.
8/// It is used to represent a pair of assets in the system.
9/// Base and quote are always in UPPERCASE.
10#[derive(Default, Debug, Clone, Eq, PartialEq, Hash)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize,))]
12#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
13pub struct Pair {
14    pub base: String,
15    pub quote: String,
16}
17
18impl Pair {
19    /// Creates a routed pair from two pairs that share a common quote currency.
20    ///
21    /// e.g. "BTC/USD" and "ETH/USD" -> "BTC/ETH"
22    pub fn create_routed_pair(base_pair: &Self, quote_pair: &Self) -> Self {
23        Self {
24            base: base_pair.base.clone(),
25            quote: quote_pair.base.clone(),
26        }
27    }
28
29    /// Creates a new pair from base and quote currencies.
30    pub fn from_currencies(base: &str, quote: &str) -> Self {
31        Self {
32            base: base.to_uppercase(),
33            quote: quote.to_uppercase(),
34        }
35    }
36
37    /// Creates a pair from a stable pair string with or without delimiters
38    /// e.g. "BTCUSDT" -> BTC/USD, "ETH-USDC" -> ETH/USD, "`SOL_USDT`" -> SOL/USD
39    pub fn from_stable_pair(pair: &str) -> Option<Self> {
40        let pair = pair.to_uppercase();
41        let normalized = pair.replace(['-', '_', '/'], "");
42
43        for stable in STABLE_SUFFIXES {
44            if let Some(base) = normalized.strip_suffix(stable) {
45                return Some(Self {
46                    base: base.to_string(),
47                    quote: "USD".to_string(),
48                });
49            }
50        }
51        None
52    }
53
54    /// Get the base and quote as a tuple
55    pub fn as_tuple(&self) -> (String, String) {
56        (self.base.clone(), self.quote.clone())
57    }
58
59    /// Format pair with a custom separator
60    pub fn format_with_separator(&self, separator: &str) -> String {
61        format!("{}{}{}", self.base, separator, self.quote)
62    }
63
64    /// Get the pair ID in standard format without consuming self
65    pub fn to_pair_id(&self) -> String {
66        self.format_with_separator("/")
67    }
68}
69
70impl std::fmt::Display for Pair {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        write!(f, "{}/{}", self.base, self.quote)
73    }
74}
75
76impl From<Pair> for String {
77    fn from(pair: Pair) -> Self {
78        format!("{0}/{1}", pair.base, pair.quote)
79    }
80}
81
82impl From<&str> for Pair {
83    fn from(pair_id: &str) -> Self {
84        let normalized = pair_id.replace(['-', '_'], "/");
85        let parts: Vec<&str> = normalized.split('/').collect();
86        Self {
87            base: parts[0].trim().to_uppercase(),
88            quote: parts[1].trim().to_uppercase(),
89        }
90    }
91}
92
93impl From<String> for Pair {
94    fn from(pair_id: String) -> Self {
95        Self::from(pair_id.as_str())
96    }
97}
98
99impl FromStr for Pair {
100    type Err = ();
101
102    fn from_str(s: &str) -> Result<Self, Self::Err> {
103        Ok(Self::from(s))
104    }
105}
106
107impl From<(String, String)> for Pair {
108    fn from(pair: (String, String)) -> Self {
109        Self {
110            base: pair.0.to_uppercase(),
111            quote: pair.1.to_uppercase(),
112        }
113    }
114}
115
116#[macro_export]
117macro_rules! pair {
118    ($pair_str:expr) => {{
119        #[allow(dead_code)]
120        const fn validate_pair(s: &str) -> bool {
121            let mut count = 0;
122            let chars = s.as_bytes();
123            let mut i = 0;
124            while i < chars.len() {
125                if chars[i] == b'/' || chars[i] == b'-' || chars[i] == b'_' {
126                    count += 1;
127                }
128                i += 1;
129            }
130            count == 1
131        }
132        const _: () = {
133            assert!(
134                validate_pair($pair_str),
135                "Invalid pair format. Expected format: BASE/QUOTE, BASE-QUOTE, or BASE_QUOTE"
136            );
137        };
138        let normalized = $pair_str.replace('-', "/").replace('_', "/");
139        let parts: Vec<&str> = normalized.split('/').collect();
140        Pair {
141            base: parts[0].trim().to_uppercase(),
142            quote: parts[1].trim().to_uppercase(),
143        }
144    }};
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use rstest::rstest;
151
152    /// Test `from_stable_pair` with various inputs
153    #[rstest]
154    #[case("BTCUSDT", Some(Pair { base: "BTC".to_string(), quote: "USD".to_string() }))]
155    #[case("ETH-USDC", Some(Pair { base: "ETH".to_string(), quote: "USD".to_string() }))]
156    #[case("SOL_USDT", Some(Pair { base: "SOL".to_string(), quote: "USD".to_string() }))]
157    #[case("XRP/USD", Some(Pair { base: "XRP".to_string(), quote: "USD".to_string() }))]
158    #[case("BTC/ETH", None)] // No stable suffix
159    #[case("USDUSDT", Some(Pair { base: "USD".to_string(), quote: "USD".to_string() }))]
160    #[case("USDTUSD", Some(Pair { base: "USDT".to_string(), quote: "USD".to_string() }))]
161    #[case("btc_usdt", Some(Pair { base: "BTC".to_string(), quote: "USD".to_string() }))]
162    #[case("EthDai", Some(Pair { base: "ETH".to_string(), quote: "USD".to_string() }))]
163    #[case("", None)] // Empty string
164    #[case("BTC", None)] // No stable suffix
165    #[case("USDT", Some(Pair { base: "".to_string(), quote: "USD".to_string() }))]
166    fn test_from_stable_pair(#[case] input: &str, #[case] expected: Option<Pair>) {
167        assert_eq!(Pair::from_stable_pair(input), expected);
168    }
169
170    /// Test `create_routed_pair` with pairs sharing a common quote
171    #[rstest]
172    #[case(
173        Pair { base: "BTC".to_string(), quote: "USD".to_string() },
174        Pair { base: "ETH".to_string(), quote: "USD".to_string() },
175        Pair { base: "BTC".to_string(), quote: "ETH".to_string() }
176    )]
177    #[case(
178        Pair { base: "SOL".to_string(), quote: "USDT".to_string() },
179        Pair { base: "LUNA".to_string(), quote: "USDT".to_string() },
180        Pair { base: "SOL".to_string(), quote: "LUNA".to_string() }
181    )]
182    fn test_create_routed_pair(
183        #[case] base_pair: Pair,
184        #[case] quote_pair: Pair,
185        #[case] expected: Pair,
186    ) {
187        assert_eq!(Pair::create_routed_pair(&base_pair, &quote_pair), expected);
188    }
189
190    /// Test `from_currencies` with different case inputs
191    #[rstest]
192    #[case("btc", "usd", Pair { base: "BTC".to_string(), quote: "USD".to_string() })]
193    #[case("Eth", "Dai", Pair { base: "ETH".to_string(), quote: "DAI".to_string() })]
194    #[case("sol", "usdt", Pair { base: "SOL".to_string(), quote: "USDT".to_string() })]
195    fn test_from_currencies(#[case] base: &str, #[case] quote: &str, #[case] expected: Pair) {
196        assert_eq!(Pair::from_currencies(base, quote), expected);
197    }
198
199    /// Test `as_tuple` returns the correct tuple
200    #[rstest]
201    #[case(Pair { base: "BTC".to_string(), quote: "USD".to_string() }, ("BTC".to_string(), "USD".to_string()))]
202    #[case(Pair { base: "ETH".to_string(), quote: "USDT".to_string() }, ("ETH".to_string(), "USDT".to_string()))]
203    fn test_as_tuple(#[case] pair: Pair, #[case] expected: (String, String)) {
204        assert_eq!(pair.as_tuple(), expected);
205    }
206
207    /// Test `format_with_separator` with different separators
208    #[rstest]
209    #[case(Pair { base: "BTC".to_string(), quote: "USD".to_string() }, "/", "BTC/USD")]
210    #[case(Pair { base: "ETH".to_string(), quote: "USDT".to_string() }, "-", "ETH-USDT")]
211    #[case(Pair { base: "SOL".to_string(), quote: "USDC".to_string() }, "_", "SOL_USDC")]
212    fn test_format_with_separator(
213        #[case] pair: Pair,
214        #[case] separator: &str,
215        #[case] expected: &str,
216    ) {
217        assert_eq!(pair.format_with_separator(separator), expected);
218    }
219
220    /// Test `to_pair_id` uses the standard "/" separator
221    #[rstest]
222    #[case(Pair { base: "BTC".to_string(), quote: "USD".to_string() }, "BTC/USD")]
223    #[case(Pair { base: "ETH".to_string(), quote: "USDT".to_string() }, "ETH/USDT")]
224    fn test_to_pair_id(#[case] pair: Pair, #[case] expected: &str) {
225        assert_eq!(pair.to_pair_id(), expected);
226    }
227
228    /// Test `Display` implementation
229    #[rstest]
230    #[case(Pair { base: "BTC".to_string(), quote: "USD".to_string() }, "BTC/USD")]
231    #[case(Pair { base: "ETH".to_string(), quote: "USDT".to_string() }, "ETH/USDT")]
232    fn test_display(#[case] pair: Pair, #[case] expected: &str) {
233        assert_eq!(format!("{}", pair), expected);
234    }
235
236    /// Test `From<Pair> for String`
237    #[rstest]
238    #[case(Pair { base: "BTC".to_string(), quote: "USD".to_string() }, "BTC/USD")]
239    #[case(Pair { base: "ETH".to_string(), quote: "USDT".to_string() }, "ETH/USDT")]
240    fn test_from_pair_to_string(#[case] pair: Pair, #[case] expected: &str) {
241        let s: String = pair.into();
242        assert_eq!(s, expected);
243    }
244
245    /// Test `From<&str> for Pair` with different separators and whitespace
246    #[rstest]
247    #[case("BTC/USD", Pair { base: "BTC".to_string(), quote: "USD".to_string() })]
248    #[case("ETH-USDT", Pair { base: "ETH".to_string(), quote: "USDT".to_string() })]
249    #[case("SOL_USDC", Pair { base: "SOL".to_string(), quote: "USDC".to_string() })]
250    #[case(" btc / usd ", Pair { base: "BTC".to_string(), quote: "USD".to_string() })]
251    fn test_from_str_to_pair(#[case] input: &str, #[case] expected: Pair) {
252        let pair: Pair = input.into();
253        assert_eq!(pair, expected);
254    }
255
256    /// Test `From<String> for Pair`
257    #[rstest]
258    #[case("BTC/USD".to_string(), Pair { base: "BTC".to_string(), quote: "USD".to_string() })]
259    #[case("ETH-USDT".to_string(), Pair { base: "ETH".to_string(), quote: "USDT".to_string() })]
260    fn test_from_string_to_pair(#[case] input: String, #[case] expected: Pair) {
261        let pair: Pair = input.into();
262        assert_eq!(pair, expected);
263    }
264
265    /// Test `FromStr for Pair`
266    #[rstest]
267    #[case("BTC/USD", Pair { base: "BTC".to_string(), quote: "USD".to_string() })]
268    #[case("ETH-USDT", Pair { base: "ETH".to_string(), quote: "USDT".to_string() })]
269    fn test_fromstr(#[case] input: &str, #[case] expected: Pair) {
270        let pair: Pair = input.parse().unwrap();
271        assert_eq!(pair, expected);
272    }
273
274    /// Test `From<(String, String)> for Pair`
275    #[rstest]
276    #[case(("btc".to_string(), "usd".to_string()), Pair { base: "BTC".to_string(), quote: "USD".to_string() })]
277    #[case(("Eth".to_string(), "Dai".to_string()), Pair { base: "ETH".to_string(), quote: "DAI".to_string() })]
278    fn test_from_tuple(#[case] input: (String, String), #[case] expected: Pair) {
279        let pair: Pair = input.into();
280        assert_eq!(pair, expected);
281    }
282
283    /// Test the `pair!` macro with valid inputs
284    #[test]
285    fn test_pair_macro() {
286        assert_eq!(
287            pair!("BTC/USD"),
288            Pair {
289                base: "BTC".to_string(),
290                quote: "USD".to_string()
291            }
292        );
293        assert_eq!(
294            pair!("ETH-USDT"),
295            Pair {
296                base: "ETH".to_string(),
297                quote: "USDT".to_string()
298            }
299        );
300        assert_eq!(
301            pair!("SOL_USDC"),
302            Pair {
303                base: "SOL".to_string(),
304                quote: "USDC".to_string()
305            }
306        );
307        assert_eq!(
308            pair!(" btc / usd "),
309            Pair {
310                base: "BTC".to_string(),
311                quote: "USD".to_string()
312            }
313        );
314    }
315
316    /// Test the `Default` implementation
317    #[test]
318    fn test_default() {
319        assert_eq!(
320            Pair::default(),
321            Pair {
322                base: "".to_string(),
323                quote: "".to_string()
324            }
325        );
326    }
327}