Skip to main content

nautilus_common/
xrate.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Exchange rate calculations between currencies.
17//!
18//! An exchange rate is the value of one asset versus that of another.
19
20use ahash::{AHashMap, AHashSet};
21use nautilus_model::enums::PriceType;
22use rust_decimal::Decimal;
23use ustr::Ustr;
24
25/// Calculates the exchange rate between two currencies using provided bid and ask quotes.
26///
27/// This function builds a graph of direct conversion rates from the quotes and uses a DFS to
28/// accumulate the conversion rate along a valid conversion path. While a full Floyd-Warshall
29/// algorithm could compute all-pairs conversion rates, the DFS approach here provides a quick
30/// solution for a single conversion query.
31///
32/// # Errors
33///
34/// Returns an error if:
35/// - `quotes_bid` or `quotes_ask` is empty.
36/// - `quotes_bid` and `quotes_ask` lengths are not equal.
37/// - `price_type` is equal to `Last` or `Mark` (cannot calculate from quotes).
38/// - The bid or ask side of a pair is missing.
39/// - An exchange rate calculation exceeds the decimal range.
40///
41/// These conditions apply only to conversions between distinct currencies. Identical
42/// `from_currency` and `to_currency` return a rate of one without inspecting the quotes.
43pub 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        // When the source and target currencies are identical,
52        // no conversion is needed; return an exchange rate of one.
53        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    // Validated here, in the same position as the price-type match this replaced, so the
65    // identical-currency shortcut and the quote-map errors keep their original precedence.
66    if !matches!(price_type, PriceType::Bid | PriceType::Ask | PriceType::Mid) {
67        anyhow::bail!("Invalid `price_type`, was '{price_type}'");
68    }
69
70    // Construct a graph: each currency maps to its neighbors and corresponding conversion rate
71    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            // Both sides are required to build valid forward and reverse edges.
86            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    // Descending total order makes the smallest distinct neighbor the first branch popped from
99    // the LIFO stack. The rate only breaks ties between parallel edges.
100    for neighbors in graph.values_mut() {
101        neighbors.sort_unstable_by(|left, right| right.cmp(left));
102    }
103
104    // DFS: search for a conversion path from `from_currency` to `to_currency`
105    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(&current) {
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    // No conversion path found
130    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        // Direct pairs
177        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        // Valid pair string
204        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            // Inverse-edge rounding makes the round-trip near one, not exactly one
302            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        // Expected rate: (EUR/USD mid) * (USD/JPY mid)
382        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        // Only one pair provided
504        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
505        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
506
507        // Attempt conversion from EUR to JPY should yield None
508        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        // Missing GBP/USD in ask quotes.
545
546        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        // Using an invalid price type variant (assume PriceType::Last is unsupported)
584        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        // The identical-currency shortcut runs before any quote or price-type validation, so an
600        // unsupported price type and empty quote maps still yield a rate of one.
601        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        // Empty maps are reported before an unsupported price type for distinct currencies.
615        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        // Create a cycle by including both EUR/USD and USD/EUR quotes
653        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        // The total adjacency ordering encounters the higher parallel rate first.
668        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        // Direct conversion
677        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
678        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
679        // Indirect path via GBP: EUR/GBP and GBP/USD
680        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        // Both paths should be consistent:
695        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}