1use std::str::FromStr;
2
3const STABLE_SUFFIXES: [&str; 4] = ["USDT", "USDC", "USD", "DAI"];
4
5#[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 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 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 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 pub fn as_tuple(&self) -> (String, String) {
60 (self.base.clone(), self.quote.clone())
61 }
62
63 pub fn format_with_separator(&self, separator: &str) -> String {
65 format!("{}{}{}", self.base, separator, self.quote)
66 }
67
68 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 let normalized = pair_id.replace(['-', '_'], "/");
92
93 let parts: Vec<&str> = normalized.split('/').collect();
95
96 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 #[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 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 #[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)] #[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)] #[case("BTC", None)] #[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 #[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, "e_pair), expected);
221 }
222
223 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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]
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]
351 fn test_default() {
352 assert_eq!(
353 Pair::default(),
354 Pair {
355 base: "".to_string(),
356 quote: "".to_string()
357 }
358 );
359 }
360}