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#[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 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 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 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 pub fn as_tuple(&self) -> (AssetSymbol, AssetSymbol) {
65 (self.base.clone(), self.quote.clone())
66 }
67
68 pub fn format_with_separator(&self, separator: &str) -> String {
70 format!("{}{}{}", self.base, separator, self.quote)
71 }
72
73 pub fn to_pair_id(&self) -> String {
75 self.format_with_separator("/")
76 }
77
78 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 let normalized = pair_id.replace(['-', '_'], "/");
108
109 let parts: Vec<&str> = normalized.split('/').collect();
111
112 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 #[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 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 #[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)] #[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)] #[case("BTC", None)] #[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 #[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, "e_pair), expected);
237 }
238
239 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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]
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]
367 fn test_default() {
368 assert_eq!(
369 Pair::default(),
370 Pair {
371 base: "".to_string(),
372 quote: "".to_string()
373 }
374 );
375 }
376}