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(feature = "utoipa", derive(utoipa::ToSchema))]
13pub struct Pair {
14 pub base: String,
15 pub quote: String,
16}
17
18impl Pair {
19 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 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 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 pub fn as_tuple(&self) -> (String, String) {
56 (self.base.clone(), self.quote.clone())
57 }
58
59 pub fn format_with_separator(&self, separator: &str) -> String {
61 format!("{}{}{}", self.base, separator, self.quote)
62 }
63
64 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 #[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)] #[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)] #[case("BTC", None)] #[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 #[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, "e_pair), expected);
188 }
189
190 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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]
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]
318 fn test_default() {
319 assert_eq!(
320 Pair::default(),
321 Pair {
322 base: "".to_string(),
323 quote: "".to_string()
324 }
325 );
326 }
327}