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