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 parts: Vec<&str> = pair.split('/').collect();
75
76 if parts.len() != 2 {
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(parts[0]);
88 let quote = Ustr::from(parts[1]);
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 fn test_invalid_pair_string() {
168 let mut quotes_bid = AHashMap::new();
169 let mut quotes_ask = AHashMap::new();
170 quotes_bid.insert(Ustr::from("EURUSD"), dec!(1.1000));
172 quotes_ask.insert(Ustr::from("EURUSD"), dec!(1.1002));
173 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
175 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
176
177 let rate = get_exchange_rate(
178 Ustr::from("EUR"),
179 Ustr::from("USD"),
180 PriceType::Mid,
181 quotes_bid,
182 quotes_ask,
183 )
184 .unwrap();
185
186 assert_eq!(rate, Some(dec!(1.1001)));
187 }
188
189 #[rstest]
190 fn test_same_currency() {
191 let (quotes_bid, quotes_ask) = setup_test_quotes();
192 let rate = get_exchange_rate(
193 Ustr::from("USD"),
194 Ustr::from("USD"),
195 PriceType::Mid,
196 quotes_bid,
197 quotes_ask,
198 )
199 .unwrap();
200 assert_eq!(rate, Some(Decimal::ONE));
201 }
202
203 #[rstest(
204 price_type,
205 expected,
206 case(PriceType::Bid, dec!(1.1000)),
207 case(PriceType::Ask, dec!(1.1002)),
208 case(PriceType::Mid, dec!(1.1001))
209 )]
210 fn test_direct_pair(price_type: PriceType, expected: Decimal) {
211 let (quotes_bid, quotes_ask) = setup_test_quotes();
212
213 let rate = get_exchange_rate(
214 Ustr::from("EUR"),
215 Ustr::from("USD"),
216 price_type,
217 quotes_bid,
218 quotes_ask,
219 )
220 .unwrap();
221
222 let rate = rate.unwrap_or_else(|| panic!("Expected a conversion rate for {price_type}"));
223 assert_eq!(rate, expected);
224 }
225
226 #[rstest]
227 fn test_inverse_pair() {
228 let (quotes_bid, quotes_ask) = setup_test_quotes();
229
230 let rate_eur_usd = get_exchange_rate(
231 Ustr::from("EUR"),
232 Ustr::from("USD"),
233 PriceType::Mid,
234 quotes_bid.clone(),
235 quotes_ask.clone(),
236 )
237 .unwrap();
238 let rate_usd_eur = get_exchange_rate(
239 Ustr::from("USD"),
240 Ustr::from("EUR"),
241 PriceType::Mid,
242 quotes_bid,
243 quotes_ask,
244 )
245 .unwrap();
246
247 if let (Some(eur_usd), Some(usd_eur)) = (rate_eur_usd, rate_usd_eur) {
248 assert!((eur_usd * usd_eur - Decimal::ONE).abs() < dec!(0.0001));
250 } else {
251 panic!("Expected valid conversion rates for inverse conversion");
252 }
253 }
254
255 #[rstest(
256 price_type,
257 expected,
258 case(PriceType::Bid, Decimal::ONE / dec!(1.1002)),
259 case(PriceType::Ask, Decimal::ONE / dec!(1.1000)),
260 case(PriceType::Mid, Decimal::ONE / dec!(1.1001))
261 )]
262 fn test_inverse_pair_uses_opposite_spread_side(price_type: PriceType, expected: Decimal) {
263 let (quotes_bid, quotes_ask) = setup_test_quotes();
264
265 let rate = get_exchange_rate(
266 Ustr::from("USD"),
267 Ustr::from("EUR"),
268 price_type,
269 quotes_bid,
270 quotes_ask,
271 )
272 .unwrap();
273
274 assert_eq!(rate, Some(expected));
275 }
276
277 #[rstest]
278 fn test_indirect_route_is_deterministic_across_hash_seeds() {
279 let pairs = [
280 ("AAA/BBB", dec!(2)),
281 ("BBB/DDD", dec!(3)),
282 ("AAA/CCC", dec!(5)),
283 ("CCC/DDD", dec!(7)),
284 ];
285 let seeds = [(0, 0, 0, 0), (1, 2, 3, 4), (5, 6, 7, 8), (10, 20, 30, 40)];
286 let mut iteration_orders = Vec::new();
287
288 let rates = seeds.map(|(k0, k1, k2, k3)| {
289 let random_state = RandomState::with_seeds(k0, k1, k2, k3);
290 let mut quotes_bid = AHashMap::with_hasher(random_state.clone());
291 let mut quotes_ask = AHashMap::with_hasher(random_state);
292
293 for (pair, rate) in pairs {
294 quotes_bid.insert(Ustr::from(pair), rate);
295 quotes_ask.insert(Ustr::from(pair), rate);
296 }
297 iteration_orders.push(quotes_bid.keys().copied().collect::<Vec<_>>());
298
299 get_exchange_rate(
300 Ustr::from("AAA"),
301 Ustr::from("DDD"),
302 PriceType::Bid,
303 quotes_bid,
304 quotes_ask,
305 )
306 .unwrap()
307 });
308
309 assert!(
310 iteration_orders
311 .windows(2)
312 .any(|orders| orders[0] != orders[1]),
313 "Explicit hash seeds must produce different raw iteration orders",
314 );
315 assert_eq!(rates, [Some(dec!(6)); 4]);
316 }
317
318 #[rstest]
319 fn test_cross_pair_through_usd() {
320 let (quotes_bid, quotes_ask) = setup_test_quotes();
321 let rate = get_exchange_rate(
322 Ustr::from("EUR"),
323 Ustr::from("JPY"),
324 PriceType::Mid,
325 quotes_bid,
326 quotes_ask,
327 )
328 .unwrap();
329 let expected = dec!(1.1001) * dec!(110.01);
331
332 assert_eq!(rate, Some(expected));
333 }
334
335 #[rstest]
336 #[case(dec!(0))]
337 #[case(dec!(-1.1))]
338 fn test_non_positive_rate_is_skipped(#[case] rate: Decimal) {
339 let mut quotes_bid = AHashMap::new();
340 let mut quotes_ask = AHashMap::new();
341 quotes_bid.insert(Ustr::from("EUR/USD"), rate);
342 quotes_ask.insert(Ustr::from("EUR/USD"), rate);
343
344 let result = get_exchange_rate(
345 Ustr::from("EUR"),
346 Ustr::from("USD"),
347 PriceType::Mid,
348 quotes_bid,
349 quotes_ask,
350 );
351
352 assert_eq!(result.unwrap(), None);
353 }
354
355 #[rstest(
356 bid,
357 ask,
358 price_type,
359 case(dec!(1.1), Decimal::ZERO, PriceType::Bid),
360 case(Decimal::ZERO, dec!(1.1), PriceType::Ask)
361 )]
362 fn test_non_positive_opposite_side_is_skipped(
363 bid: Decimal,
364 ask: Decimal,
365 price_type: PriceType,
366 ) {
367 let mut quotes_bid = AHashMap::new();
368 let mut quotes_ask = AHashMap::new();
369 quotes_bid.insert(Ustr::from("EUR/USD"), bid);
370 quotes_ask.insert(Ustr::from("EUR/USD"), ask);
371
372 let result = get_exchange_rate(
373 Ustr::from("EUR"),
374 Ustr::from("USD"),
375 price_type,
376 quotes_bid,
377 quotes_ask,
378 );
379
380 assert_eq!(result.unwrap(), None);
381 }
382
383 #[rstest]
384 fn test_no_conversion_path() {
385 let mut quotes_bid = AHashMap::new();
386 let mut quotes_ask = AHashMap::new();
387
388 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
390 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
391
392 let rate = get_exchange_rate(
394 Ustr::from("EUR"),
395 Ustr::from("JPY"),
396 PriceType::Mid,
397 quotes_bid,
398 quotes_ask,
399 )
400 .unwrap();
401 assert_eq!(rate, None);
402 }
403
404 #[rstest]
405 fn test_empty_quotes() {
406 let quotes_bid: AHashMap<Ustr, Decimal> = AHashMap::new();
407 let quotes_ask: AHashMap<Ustr, Decimal> = AHashMap::new();
408 let result = get_exchange_rate(
409 Ustr::from("EUR"),
410 Ustr::from("USD"),
411 PriceType::Mid,
412 quotes_bid,
413 quotes_ask,
414 );
415 assert!(result.is_err());
416 }
417
418 #[rstest]
419 fn test_unequal_quotes_length() {
420 let mut quotes_bid = AHashMap::new();
421 let mut quotes_ask = AHashMap::new();
422
423 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
424 quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
425 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
426 let result = get_exchange_rate(
429 Ustr::from("EUR"),
430 Ustr::from("USD"),
431 PriceType::Mid,
432 quotes_bid,
433 quotes_ask,
434 );
435 assert!(result.is_err());
436 }
437
438 #[rstest]
439 fn test_equal_length_quotes_with_different_keys() {
440 let mut quotes_bid = AHashMap::new();
441 let mut quotes_ask = AHashMap::new();
442 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
443 quotes_ask.insert(Ustr::from("GBP/USD"), dec!(1.3002));
444
445 let result = get_exchange_rate(
446 Ustr::from("EUR"),
447 Ustr::from("USD"),
448 PriceType::Bid,
449 quotes_bid,
450 quotes_ask,
451 );
452
453 assert!(result.is_err());
454 }
455
456 #[rstest]
457 fn test_invalid_price_type() {
458 let (quotes_bid, quotes_ask) = setup_test_quotes();
459 let result = get_exchange_rate(
461 Ustr::from("EUR"),
462 Ustr::from("USD"),
463 PriceType::Last,
464 quotes_bid,
465 quotes_ask,
466 );
467 assert!(result.is_err());
468 }
469
470 #[rstest]
471 fn test_same_currency_shortcut_precedes_all_validation() {
472 let result = get_exchange_rate(
475 Ustr::from("USD"),
476 Ustr::from("USD"),
477 PriceType::Last,
478 AHashMap::new(),
479 AHashMap::new(),
480 );
481
482 assert_eq!(result.unwrap(), Some(Decimal::ONE));
483 }
484
485 #[rstest]
486 fn test_quote_map_errors_precede_invalid_price_type() {
487 let empty = get_exchange_rate(
489 Ustr::from("EUR"),
490 Ustr::from("USD"),
491 PriceType::Last,
492 AHashMap::new(),
493 AHashMap::new(),
494 );
495
496 assert_eq!(
497 empty.unwrap_err().to_string(),
498 "Quote maps must not be empty"
499 );
500
501 let mut quotes_bid = AHashMap::new();
502 let mut quotes_ask = AHashMap::new();
503 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
504 quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
505 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
506
507 let unequal = get_exchange_rate(
508 Ustr::from("EUR"),
509 Ustr::from("USD"),
510 PriceType::Last,
511 quotes_bid,
512 quotes_ask,
513 );
514
515 assert_eq!(
516 unequal.unwrap_err().to_string(),
517 "Quote maps must have equal lengths"
518 );
519 }
520
521 #[rstest]
522 fn test_cycle_handling() {
523 let mut quotes_bid = AHashMap::new();
524 let mut quotes_ask = AHashMap::new();
525 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1));
527 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
528 quotes_bid.insert(Ustr::from("USD/EUR"), dec!(0.909));
529 quotes_ask.insert(Ustr::from("USD/EUR"), dec!(0.9091));
530
531 let rate = get_exchange_rate(
532 Ustr::from("EUR"),
533 Ustr::from("USD"),
534 PriceType::Mid,
535 quotes_bid,
536 quotes_ask,
537 )
538 .unwrap();
539
540 let expected = dec!(1.1001);
542 assert_eq!(rate, Some(expected));
543 }
544
545 #[rstest]
546 fn test_multiple_paths() {
547 let mut quotes_bid = AHashMap::new();
548 let mut quotes_ask = AHashMap::new();
549 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
551 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
552 quotes_bid.insert(Ustr::from("EUR/GBP"), dec!(0.8461));
554 quotes_ask.insert(Ustr::from("EUR/GBP"), dec!(0.8463));
555 quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
556 quotes_ask.insert(Ustr::from("GBP/USD"), dec!(1.3002));
557
558 let rate = get_exchange_rate(
559 Ustr::from("EUR"),
560 Ustr::from("USD"),
561 PriceType::Mid,
562 quotes_bid,
563 quotes_ask,
564 )
565 .unwrap();
566
567 let direct = dec!(1.1001);
569 let indirect = dec!(0.8462) * dec!(1.3001);
570 assert!((direct - indirect).abs() < dec!(0.0001));
571 assert!((rate.unwrap() - direct).abs() < dec!(0.0001));
572 }
573}