Skip to main content

pragma_common/pair/
mod.rs

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