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