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///
40/// These conditions apply only to conversions between distinct currencies. Identical
41/// `from_currency` and `to_currency` return a rate of one without inspecting the quotes.
42pub 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        // When the source and target currencies are identical,
51        // no conversion is needed; return an exchange rate of one.
52        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    // Validated here, in the same position as the price-type match this replaced, so the
64    // identical-currency shortcut and the quote-map errors keep their original precedence.
65    if !matches!(price_type, PriceType::Bid | PriceType::Ask | PriceType::Mid) {
66        anyhow::bail!("Invalid `price_type`, was '{price_type}'");
67    }
68
69    // Construct a graph: each currency maps to its neighbors and corresponding conversion rate
70    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            // Both sides are required to build valid forward and reverse edges.
85            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    // Descending total order makes the smallest distinct neighbor the first branch popped from
98    // the LIFO stack. The rate only breaks ties between parallel edges.
99    for neighbors in graph.values_mut() {
100        neighbors.sort_unstable_by(|left, right| right.cmp(left));
101    }
102
103    // DFS: search for a conversion path from `from_currency` to `to_currency`
104    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(&current) {
114            for (neighbor, rate) in neighbors {
115                if visited.insert(*neighbor) {
116                    stack.push((*neighbor, current_rate * rate));
117                }
118            }
119        }
120    }
121
122    // No conversion path found
123    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        // Direct pairs
153        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        // Valid pair string
180        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            // Inverse-edge rounding makes the round-trip near one, not exactly one
278            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        // Expected rate: (EUR/USD mid) * (USD/JPY mid)
359        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        // Only one pair provided
418        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
419        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
420
421        // Attempt conversion from EUR to JPY should yield None
422        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        // Missing GBP/USD in ask quotes.
459
460        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        // Using an invalid price type variant (assume PriceType::Last is unsupported)
498        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        // The identical-currency shortcut runs before any quote or price-type validation, so an
514        // unsupported price type and empty quote maps still yield a rate of one.
515        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        // Empty maps are reported before an unsupported price type for distinct currencies.
529        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        // Create a cycle by including both EUR/USD and USD/EUR quotes
567        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        // The total adjacency ordering encounters the higher parallel rate first.
582        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        // Direct conversion
591        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
592        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
593        // Indirect path via GBP: EUR/GBP and GBP/USD
594        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        // Both paths should be consistent:
609        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}