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