1use ahash::{AHashMap, AHashSet};
21use nautilus_model::enums::PriceType;
22use rust_decimal::Decimal;
23use ustr::Ustr;
24
25pub fn get_exchange_rate(
41 from_currency: Ustr,
42 to_currency: Ustr,
43 price_type: PriceType,
44 quotes_bid: AHashMap<Ustr, Decimal>,
45 mut quotes_ask: AHashMap<Ustr, Decimal>,
46) -> anyhow::Result<Option<Decimal>> {
47 if from_currency == to_currency {
48 return Ok(Some(Decimal::ONE));
51 }
52
53 if quotes_bid.is_empty() || quotes_ask.is_empty() {
54 anyhow::bail!("Quote maps must not be empty");
55 }
56
57 if quotes_bid.len() != quotes_ask.len() {
58 anyhow::bail!("Quote maps must have equal lengths");
59 }
60
61 if !matches!(price_type, PriceType::Bid | PriceType::Ask | PriceType::Mid) {
64 anyhow::bail!("Invalid `price_type`, was '{price_type}'");
65 }
66
67 let mut graph: AHashMap<Ustr, Vec<(Ustr, Decimal)>> = AHashMap::new();
69
70 for (pair, bid) in quotes_bid {
71 let ask = quotes_ask
72 .remove(&pair)
73 .ok_or_else(|| anyhow::anyhow!("Missing ask quote for pair {pair}"))?;
74 let mut parts = pair.split('/');
75
76 let (Some(base), Some(quote), None) = (parts.next(), parts.next(), parts.next()) else {
77 log::warn!("Skipping invalid pair string: {pair}");
78 continue;
79 };
80
81 if bid <= Decimal::ZERO || ask <= Decimal::ZERO {
82 log::warn!("Skipping pair with non-positive bid or ask rate: {pair}");
84 continue;
85 }
86
87 let base = Ustr::from(base);
88 let quote = Ustr::from(quote);
89 let (forward_rate, reverse_rate) = directional_rates(bid, ask, price_type);
90
91 graph.entry(base).or_default().push((quote, forward_rate));
92 graph.entry(quote).or_default().push((base, reverse_rate));
93 }
94
95 for neighbors in graph.values_mut() {
98 neighbors.sort_unstable_by(|left, right| right.cmp(left));
99 }
100
101 let mut stack: Vec<(Ustr, Decimal)> = vec![(from_currency, Decimal::ONE)];
103 let mut visited: AHashSet<Ustr> = AHashSet::new();
104 visited.insert(from_currency);
105
106 while let Some((current, current_rate)) = stack.pop() {
107 if current == to_currency {
108 return Ok(Some(current_rate));
109 }
110
111 if let Some(neighbors) = graph.get(¤t) {
112 for (neighbor, rate) in neighbors {
113 if visited.insert(*neighbor) {
114 stack.push((*neighbor, current_rate * rate));
115 }
116 }
117 }
118 }
119
120 Ok(None)
122}
123
124fn directional_rates(bid: Decimal, ask: Decimal, price_type: PriceType) -> (Decimal, Decimal) {
125 match price_type {
126 PriceType::Bid => (bid, Decimal::ONE / ask),
127 PriceType::Ask => (ask, Decimal::ONE / bid),
128 PriceType::Mid => {
129 let mid = (bid + ask) / Decimal::TWO;
130 (mid, Decimal::ONE / mid)
131 }
132 _ => unreachable!("Price type was validated before graph construction"),
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use ahash::{AHashMap, RandomState};
139 use rstest::rstest;
140 use rust_decimal::Decimal;
141 use rust_decimal_macros::dec;
142 use ustr::Ustr;
143
144 use super::*;
145
146 fn setup_test_quotes() -> (AHashMap<Ustr, Decimal>, AHashMap<Ustr, Decimal>) {
147 let mut quotes_bid = AHashMap::new();
148 let mut quotes_ask = AHashMap::new();
149
150 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
152 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
153
154 quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
155 quotes_ask.insert(Ustr::from("GBP/USD"), dec!(1.3002));
156
157 quotes_bid.insert(Ustr::from("USD/JPY"), dec!(110.00));
158 quotes_ask.insert(Ustr::from("USD/JPY"), dec!(110.02));
159
160 quotes_bid.insert(Ustr::from("AUD/USD"), dec!(0.7500));
161 quotes_ask.insert(Ustr::from("AUD/USD"), dec!(0.7502));
162
163 (quotes_bid, quotes_ask)
164 }
165
166 #[rstest]
167 #[case("EURUSD")]
168 #[case("EUR/USD/JPY")]
169 #[case("EUR/USD/")]
170 #[case("EUR//USD")]
171 #[case("/EUR/USD")]
172 fn test_invalid_pair_string(#[case] pair: &str) {
173 let mut quotes_bid = AHashMap::new();
174 let mut quotes_ask = AHashMap::new();
175 quotes_bid.insert(Ustr::from(pair), dec!(2));
176 quotes_ask.insert(Ustr::from(pair), dec!(2));
177 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
179 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
180
181 let rate = get_exchange_rate(
182 Ustr::from("EUR"),
183 Ustr::from("USD"),
184 PriceType::Mid,
185 quotes_bid,
186 quotes_ask,
187 )
188 .unwrap();
189
190 assert_eq!(rate, Some(dec!(1.1001)));
191 }
192
193 #[rstest]
194 #[case("/USD", "", "USD")]
195 #[case("EUR/", "EUR", "")]
196 fn test_pair_with_empty_currency_field(
197 #[case] pair: &str,
198 #[case] from_currency: &str,
199 #[case] to_currency: &str,
200 ) {
201 let quotes_bid = AHashMap::from([(Ustr::from(pair), dec!(2))]);
202 let quotes_ask = AHashMap::from([(Ustr::from(pair), dec!(4))]);
203
204 let rate = get_exchange_rate(
205 Ustr::from(from_currency),
206 Ustr::from(to_currency),
207 PriceType::Mid,
208 quotes_bid,
209 quotes_ask,
210 )
211 .unwrap();
212
213 assert_eq!(rate, Some(dec!(3)));
214 }
215
216 #[rstest]
217 fn test_same_currency() {
218 let (quotes_bid, quotes_ask) = setup_test_quotes();
219 let rate = get_exchange_rate(
220 Ustr::from("USD"),
221 Ustr::from("USD"),
222 PriceType::Mid,
223 quotes_bid,
224 quotes_ask,
225 )
226 .unwrap();
227 assert_eq!(rate, Some(Decimal::ONE));
228 }
229
230 #[rstest(
231 price_type,
232 expected,
233 case(PriceType::Bid, dec!(1.1000)),
234 case(PriceType::Ask, dec!(1.1002)),
235 case(PriceType::Mid, dec!(1.1001))
236 )]
237 fn test_direct_pair(price_type: PriceType, expected: Decimal) {
238 let (quotes_bid, quotes_ask) = setup_test_quotes();
239
240 let rate = get_exchange_rate(
241 Ustr::from("EUR"),
242 Ustr::from("USD"),
243 price_type,
244 quotes_bid,
245 quotes_ask,
246 )
247 .unwrap();
248
249 let rate = rate.unwrap_or_else(|| panic!("Expected a conversion rate for {price_type}"));
250 assert_eq!(rate, expected);
251 }
252
253 #[rstest]
254 fn test_inverse_pair() {
255 let (quotes_bid, quotes_ask) = setup_test_quotes();
256
257 let rate_eur_usd = get_exchange_rate(
258 Ustr::from("EUR"),
259 Ustr::from("USD"),
260 PriceType::Mid,
261 quotes_bid.clone(),
262 quotes_ask.clone(),
263 )
264 .unwrap();
265 let rate_usd_eur = get_exchange_rate(
266 Ustr::from("USD"),
267 Ustr::from("EUR"),
268 PriceType::Mid,
269 quotes_bid,
270 quotes_ask,
271 )
272 .unwrap();
273
274 if let (Some(eur_usd), Some(usd_eur)) = (rate_eur_usd, rate_usd_eur) {
275 assert!((eur_usd * usd_eur - Decimal::ONE).abs() < dec!(0.0001));
277 } else {
278 panic!("Expected valid conversion rates for inverse conversion");
279 }
280 }
281
282 #[rstest(
283 price_type,
284 expected,
285 case(PriceType::Bid, Decimal::ONE / dec!(1.1002)),
286 case(PriceType::Ask, Decimal::ONE / dec!(1.1000)),
287 case(PriceType::Mid, Decimal::ONE / dec!(1.1001))
288 )]
289 fn test_inverse_pair_uses_opposite_spread_side(price_type: PriceType, expected: Decimal) {
290 let (quotes_bid, quotes_ask) = setup_test_quotes();
291
292 let rate = get_exchange_rate(
293 Ustr::from("USD"),
294 Ustr::from("EUR"),
295 price_type,
296 quotes_bid,
297 quotes_ask,
298 )
299 .unwrap();
300
301 assert_eq!(rate, Some(expected));
302 }
303
304 #[rstest]
305 fn test_indirect_route_is_deterministic_across_hash_seeds() {
306 let pairs = [
307 ("AAA/BBB", dec!(2)),
308 ("BBB/DDD", dec!(3)),
309 ("AAA/CCC", dec!(5)),
310 ("CCC/DDD", dec!(7)),
311 ];
312 let seeds = [(0, 0, 0, 0), (1, 2, 3, 4), (5, 6, 7, 8), (10, 20, 30, 40)];
313 let mut iteration_orders = Vec::new();
314
315 let rates = seeds.map(|(k0, k1, k2, k3)| {
316 let random_state = RandomState::with_seeds(k0, k1, k2, k3);
317 let mut quotes_bid = AHashMap::with_hasher(random_state.clone());
318 let mut quotes_ask = AHashMap::with_hasher(random_state);
319
320 for (pair, rate) in pairs {
321 quotes_bid.insert(Ustr::from(pair), rate);
322 quotes_ask.insert(Ustr::from(pair), rate);
323 }
324 iteration_orders.push(quotes_bid.keys().copied().collect::<Vec<_>>());
325
326 get_exchange_rate(
327 Ustr::from("AAA"),
328 Ustr::from("DDD"),
329 PriceType::Bid,
330 quotes_bid,
331 quotes_ask,
332 )
333 .unwrap()
334 });
335
336 assert!(
337 iteration_orders
338 .windows(2)
339 .any(|orders| orders[0] != orders[1]),
340 "Explicit hash seeds must produce different raw iteration orders",
341 );
342 assert_eq!(rates, [Some(dec!(6)); 4]);
343 }
344
345 #[rstest]
346 fn test_cross_pair_through_usd() {
347 let (quotes_bid, quotes_ask) = setup_test_quotes();
348 let rate = get_exchange_rate(
349 Ustr::from("EUR"),
350 Ustr::from("JPY"),
351 PriceType::Mid,
352 quotes_bid,
353 quotes_ask,
354 )
355 .unwrap();
356 let expected = dec!(1.1001) * dec!(110.01);
358
359 assert_eq!(rate, Some(expected));
360 }
361
362 #[rstest]
363 #[case(dec!(0))]
364 #[case(dec!(-1.1))]
365 fn test_non_positive_rate_is_skipped(#[case] rate: Decimal) {
366 let mut quotes_bid = AHashMap::new();
367 let mut quotes_ask = AHashMap::new();
368 quotes_bid.insert(Ustr::from("EUR/USD"), rate);
369 quotes_ask.insert(Ustr::from("EUR/USD"), rate);
370
371 let result = get_exchange_rate(
372 Ustr::from("EUR"),
373 Ustr::from("USD"),
374 PriceType::Mid,
375 quotes_bid,
376 quotes_ask,
377 );
378
379 assert_eq!(result.unwrap(), None);
380 }
381
382 #[rstest(
383 bid,
384 ask,
385 price_type,
386 case(dec!(1.1), Decimal::ZERO, PriceType::Bid),
387 case(Decimal::ZERO, dec!(1.1), PriceType::Ask)
388 )]
389 fn test_non_positive_opposite_side_is_skipped(
390 bid: Decimal,
391 ask: Decimal,
392 price_type: PriceType,
393 ) {
394 let mut quotes_bid = AHashMap::new();
395 let mut quotes_ask = AHashMap::new();
396 quotes_bid.insert(Ustr::from("EUR/USD"), bid);
397 quotes_ask.insert(Ustr::from("EUR/USD"), ask);
398
399 let result = get_exchange_rate(
400 Ustr::from("EUR"),
401 Ustr::from("USD"),
402 price_type,
403 quotes_bid,
404 quotes_ask,
405 );
406
407 assert_eq!(result.unwrap(), None);
408 }
409
410 #[rstest]
411 fn test_no_conversion_path() {
412 let mut quotes_bid = AHashMap::new();
413 let mut quotes_ask = AHashMap::new();
414
415 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
417 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
418
419 let rate = get_exchange_rate(
421 Ustr::from("EUR"),
422 Ustr::from("JPY"),
423 PriceType::Mid,
424 quotes_bid,
425 quotes_ask,
426 )
427 .unwrap();
428 assert_eq!(rate, None);
429 }
430
431 #[rstest]
432 fn test_empty_quotes() {
433 let quotes_bid: AHashMap<Ustr, Decimal> = AHashMap::new();
434 let quotes_ask: AHashMap<Ustr, Decimal> = AHashMap::new();
435 let result = get_exchange_rate(
436 Ustr::from("EUR"),
437 Ustr::from("USD"),
438 PriceType::Mid,
439 quotes_bid,
440 quotes_ask,
441 );
442 assert_eq!(
443 result.unwrap_err().to_string(),
444 "Quote maps must not be empty"
445 );
446 }
447
448 #[rstest]
449 fn test_unequal_quotes_length() {
450 let mut quotes_bid = AHashMap::new();
451 let mut quotes_ask = AHashMap::new();
452
453 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
454 quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
455 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
456 let result = get_exchange_rate(
459 Ustr::from("EUR"),
460 Ustr::from("USD"),
461 PriceType::Mid,
462 quotes_bid,
463 quotes_ask,
464 );
465 assert_eq!(
466 result.unwrap_err().to_string(),
467 "Quote maps must have equal lengths"
468 );
469 }
470
471 #[rstest]
472 fn test_equal_length_quotes_with_different_keys() {
473 let mut quotes_bid = AHashMap::new();
474 let mut quotes_ask = AHashMap::new();
475 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
476 quotes_ask.insert(Ustr::from("GBP/USD"), dec!(1.3002));
477
478 let result = get_exchange_rate(
479 Ustr::from("EUR"),
480 Ustr::from("USD"),
481 PriceType::Bid,
482 quotes_bid,
483 quotes_ask,
484 );
485
486 assert_eq!(
487 result.unwrap_err().to_string(),
488 "Missing ask quote for pair EUR/USD"
489 );
490 }
491
492 #[rstest]
493 fn test_invalid_price_type() {
494 let (quotes_bid, quotes_ask) = setup_test_quotes();
495 let result = get_exchange_rate(
497 Ustr::from("EUR"),
498 Ustr::from("USD"),
499 PriceType::Last,
500 quotes_bid,
501 quotes_ask,
502 );
503 assert_eq!(
504 result.unwrap_err().to_string(),
505 "Invalid `price_type`, was 'LAST'"
506 );
507 }
508
509 #[rstest]
510 fn test_same_currency_shortcut_precedes_all_validation() {
511 let result = get_exchange_rate(
514 Ustr::from("USD"),
515 Ustr::from("USD"),
516 PriceType::Last,
517 AHashMap::new(),
518 AHashMap::new(),
519 );
520
521 assert_eq!(result.unwrap(), Some(Decimal::ONE));
522 }
523
524 #[rstest]
525 fn test_quote_map_errors_precede_invalid_price_type() {
526 let empty = get_exchange_rate(
528 Ustr::from("EUR"),
529 Ustr::from("USD"),
530 PriceType::Last,
531 AHashMap::new(),
532 AHashMap::new(),
533 );
534
535 assert_eq!(
536 empty.unwrap_err().to_string(),
537 "Quote maps must not be empty"
538 );
539
540 let mut quotes_bid = AHashMap::new();
541 let mut quotes_ask = AHashMap::new();
542 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
543 quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
544 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
545
546 let unequal = get_exchange_rate(
547 Ustr::from("EUR"),
548 Ustr::from("USD"),
549 PriceType::Last,
550 quotes_bid,
551 quotes_ask,
552 );
553
554 assert_eq!(
555 unequal.unwrap_err().to_string(),
556 "Quote maps must have equal lengths"
557 );
558 }
559
560 #[rstest]
561 fn test_cycle_handling() {
562 let mut quotes_bid = AHashMap::new();
563 let mut quotes_ask = AHashMap::new();
564 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1));
566 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
567 quotes_bid.insert(Ustr::from("USD/EUR"), dec!(0.909));
568 quotes_ask.insert(Ustr::from("USD/EUR"), dec!(0.9091));
569
570 let rate = get_exchange_rate(
571 Ustr::from("EUR"),
572 Ustr::from("USD"),
573 PriceType::Mid,
574 quotes_bid,
575 quotes_ask,
576 )
577 .unwrap();
578
579 let expected = dec!(1.1001);
581 assert_eq!(rate, Some(expected));
582 }
583
584 #[rstest]
585 fn test_multiple_paths() {
586 let mut quotes_bid = AHashMap::new();
587 let mut quotes_ask = AHashMap::new();
588 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
590 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
591 quotes_bid.insert(Ustr::from("EUR/GBP"), dec!(0.8461));
593 quotes_ask.insert(Ustr::from("EUR/GBP"), dec!(0.8463));
594 quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
595 quotes_ask.insert(Ustr::from("GBP/USD"), dec!(1.3002));
596
597 let rate = get_exchange_rate(
598 Ustr::from("EUR"),
599 Ustr::from("USD"),
600 PriceType::Mid,
601 quotes_bid,
602 quotes_ask,
603 )
604 .unwrap();
605
606 let direct = dec!(1.1001);
608 let indirect = dec!(0.8462) * dec!(1.3001);
609 assert!((direct - indirect).abs() < dec!(0.0001));
610 assert!((rate.unwrap() - direct).abs() < dec!(0.0001));
611 }
612}