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/// For conversions between distinct currencies (an identical `from_currency` and `to_currency`
35/// returns a rate of one without inspecting the quotes), returns an error if:
36/// - `quotes_bid` or `quotes_ask` is empty.
37/// - `quotes_bid` and `quotes_ask` lengths are not equal.
38/// - `price_type` is equal to `Last` or `Mark` (cannot calculate from quotes).
39/// - The bid or ask side of a pair is missing.
40pub 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        // When the source and target currencies are identical,
49        // no conversion is needed; return an exchange rate of one.
50        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    // Validated here, in the same position as the price-type match this replaced, so the
62    // identical-currency shortcut and the quote-map errors keep their original precedence.
63    if !matches!(price_type, PriceType::Bid | PriceType::Ask | PriceType::Mid) {
64        anyhow::bail!("Invalid `price_type`, was '{price_type}'");
65    }
66
67    // Construct a graph: each currency maps to its neighbors and corresponding conversion rate
68    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 mut parts = pair.split('/');
75
76        let (Some(base), Some(quote), None) = (parts.next(), parts.next(), parts.next()) else {
77            log::warn!("Skipping invalid pair string: {pair}");
78            continue;
79        };
80
81        if bid <= Decimal::ZERO || ask <= Decimal::ZERO {
82            // Both sides are required to build valid forward and reverse edges.
83            log::warn!("Skipping pair with non-positive bid or ask rate: {pair}");
84            continue;
85        }
86
87        let base = Ustr::from(base);
88        let quote = Ustr::from(quote);
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    // Descending total order makes the smallest distinct neighbor the first branch popped from
96    // the LIFO stack. The rate only breaks ties between parallel edges.
97    for neighbors in graph.values_mut() {
98        neighbors.sort_unstable_by(|left, right| right.cmp(left));
99    }
100
101    // DFS: search for a conversion path from `from_currency` to `to_currency`
102    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(&current) {
112            for (neighbor, rate) in neighbors {
113                if visited.insert(*neighbor) {
114                    stack.push((*neighbor, current_rate * rate));
115                }
116            }
117        }
118    }
119
120    // No conversion path found
121    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        // Direct pairs
151        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    #[case("EURUSD")]
168    #[case("EUR/USD/JPY")]
169    #[case("EUR/USD/")]
170    #[case("EUR//USD")]
171    #[case("/EUR/USD")]
172    fn test_invalid_pair_string(#[case] pair: &str) {
173        let mut quotes_bid = AHashMap::new();
174        let mut quotes_ask = AHashMap::new();
175        quotes_bid.insert(Ustr::from(pair), dec!(2));
176        quotes_ask.insert(Ustr::from(pair), dec!(2));
177        // Valid pair string
178        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
179        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
180
181        let rate = get_exchange_rate(
182            Ustr::from("EUR"),
183            Ustr::from("USD"),
184            PriceType::Mid,
185            quotes_bid,
186            quotes_ask,
187        )
188        .unwrap();
189
190        assert_eq!(rate, Some(dec!(1.1001)));
191    }
192
193    #[rstest]
194    #[case("/USD", "", "USD")]
195    #[case("EUR/", "EUR", "")]
196    fn test_pair_with_empty_currency_field(
197        #[case] pair: &str,
198        #[case] from_currency: &str,
199        #[case] to_currency: &str,
200    ) {
201        let quotes_bid = AHashMap::from([(Ustr::from(pair), dec!(2))]);
202        let quotes_ask = AHashMap::from([(Ustr::from(pair), dec!(4))]);
203
204        let rate = get_exchange_rate(
205            Ustr::from(from_currency),
206            Ustr::from(to_currency),
207            PriceType::Mid,
208            quotes_bid,
209            quotes_ask,
210        )
211        .unwrap();
212
213        assert_eq!(rate, Some(dec!(3)));
214    }
215
216    #[rstest]
217    fn test_same_currency() {
218        let (quotes_bid, quotes_ask) = setup_test_quotes();
219        let rate = get_exchange_rate(
220            Ustr::from("USD"),
221            Ustr::from("USD"),
222            PriceType::Mid,
223            quotes_bid,
224            quotes_ask,
225        )
226        .unwrap();
227        assert_eq!(rate, Some(Decimal::ONE));
228    }
229
230    #[rstest(
231        price_type,
232        expected,
233        case(PriceType::Bid, dec!(1.1000)),
234        case(PriceType::Ask, dec!(1.1002)),
235        case(PriceType::Mid, dec!(1.1001))
236    )]
237    fn test_direct_pair(price_type: PriceType, expected: Decimal) {
238        let (quotes_bid, quotes_ask) = setup_test_quotes();
239
240        let rate = get_exchange_rate(
241            Ustr::from("EUR"),
242            Ustr::from("USD"),
243            price_type,
244            quotes_bid,
245            quotes_ask,
246        )
247        .unwrap();
248
249        let rate = rate.unwrap_or_else(|| panic!("Expected a conversion rate for {price_type}"));
250        assert_eq!(rate, expected);
251    }
252
253    #[rstest]
254    fn test_inverse_pair() {
255        let (quotes_bid, quotes_ask) = setup_test_quotes();
256
257        let rate_eur_usd = get_exchange_rate(
258            Ustr::from("EUR"),
259            Ustr::from("USD"),
260            PriceType::Mid,
261            quotes_bid.clone(),
262            quotes_ask.clone(),
263        )
264        .unwrap();
265        let rate_usd_eur = get_exchange_rate(
266            Ustr::from("USD"),
267            Ustr::from("EUR"),
268            PriceType::Mid,
269            quotes_bid,
270            quotes_ask,
271        )
272        .unwrap();
273
274        if let (Some(eur_usd), Some(usd_eur)) = (rate_eur_usd, rate_usd_eur) {
275            // Inverse-edge rounding makes the round-trip near one, not exactly one
276            assert!((eur_usd * usd_eur - Decimal::ONE).abs() < dec!(0.0001));
277        } else {
278            panic!("Expected valid conversion rates for inverse conversion");
279        }
280    }
281
282    #[rstest(
283        price_type,
284        expected,
285        case(PriceType::Bid, Decimal::ONE / dec!(1.1002)),
286        case(PriceType::Ask, Decimal::ONE / dec!(1.1000)),
287        case(PriceType::Mid, Decimal::ONE / dec!(1.1001))
288    )]
289    fn test_inverse_pair_uses_opposite_spread_side(price_type: PriceType, expected: Decimal) {
290        let (quotes_bid, quotes_ask) = setup_test_quotes();
291
292        let rate = get_exchange_rate(
293            Ustr::from("USD"),
294            Ustr::from("EUR"),
295            price_type,
296            quotes_bid,
297            quotes_ask,
298        )
299        .unwrap();
300
301        assert_eq!(rate, Some(expected));
302    }
303
304    #[rstest]
305    fn test_indirect_route_is_deterministic_across_hash_seeds() {
306        let pairs = [
307            ("AAA/BBB", dec!(2)),
308            ("BBB/DDD", dec!(3)),
309            ("AAA/CCC", dec!(5)),
310            ("CCC/DDD", dec!(7)),
311        ];
312        let seeds = [(0, 0, 0, 0), (1, 2, 3, 4), (5, 6, 7, 8), (10, 20, 30, 40)];
313        let mut iteration_orders = Vec::new();
314
315        let rates = seeds.map(|(k0, k1, k2, k3)| {
316            let random_state = RandomState::with_seeds(k0, k1, k2, k3);
317            let mut quotes_bid = AHashMap::with_hasher(random_state.clone());
318            let mut quotes_ask = AHashMap::with_hasher(random_state);
319
320            for (pair, rate) in pairs {
321                quotes_bid.insert(Ustr::from(pair), rate);
322                quotes_ask.insert(Ustr::from(pair), rate);
323            }
324            iteration_orders.push(quotes_bid.keys().copied().collect::<Vec<_>>());
325
326            get_exchange_rate(
327                Ustr::from("AAA"),
328                Ustr::from("DDD"),
329                PriceType::Bid,
330                quotes_bid,
331                quotes_ask,
332            )
333            .unwrap()
334        });
335
336        assert!(
337            iteration_orders
338                .windows(2)
339                .any(|orders| orders[0] != orders[1]),
340            "Explicit hash seeds must produce different raw iteration orders",
341        );
342        assert_eq!(rates, [Some(dec!(6)); 4]);
343    }
344
345    #[rstest]
346    fn test_cross_pair_through_usd() {
347        let (quotes_bid, quotes_ask) = setup_test_quotes();
348        let rate = get_exchange_rate(
349            Ustr::from("EUR"),
350            Ustr::from("JPY"),
351            PriceType::Mid,
352            quotes_bid,
353            quotes_ask,
354        )
355        .unwrap();
356        // Expected rate: (EUR/USD mid) * (USD/JPY mid)
357        let expected = dec!(1.1001) * dec!(110.01);
358
359        assert_eq!(rate, Some(expected));
360    }
361
362    #[rstest]
363    #[case(dec!(0))]
364    #[case(dec!(-1.1))]
365    fn test_non_positive_rate_is_skipped(#[case] rate: Decimal) {
366        let mut quotes_bid = AHashMap::new();
367        let mut quotes_ask = AHashMap::new();
368        quotes_bid.insert(Ustr::from("EUR/USD"), rate);
369        quotes_ask.insert(Ustr::from("EUR/USD"), rate);
370
371        let result = get_exchange_rate(
372            Ustr::from("EUR"),
373            Ustr::from("USD"),
374            PriceType::Mid,
375            quotes_bid,
376            quotes_ask,
377        );
378
379        assert_eq!(result.unwrap(), None);
380    }
381
382    #[rstest(
383        bid,
384        ask,
385        price_type,
386        case(dec!(1.1), Decimal::ZERO, PriceType::Bid),
387        case(Decimal::ZERO, dec!(1.1), PriceType::Ask)
388    )]
389    fn test_non_positive_opposite_side_is_skipped(
390        bid: Decimal,
391        ask: Decimal,
392        price_type: PriceType,
393    ) {
394        let mut quotes_bid = AHashMap::new();
395        let mut quotes_ask = AHashMap::new();
396        quotes_bid.insert(Ustr::from("EUR/USD"), bid);
397        quotes_ask.insert(Ustr::from("EUR/USD"), ask);
398
399        let result = get_exchange_rate(
400            Ustr::from("EUR"),
401            Ustr::from("USD"),
402            price_type,
403            quotes_bid,
404            quotes_ask,
405        );
406
407        assert_eq!(result.unwrap(), None);
408    }
409
410    #[rstest]
411    fn test_no_conversion_path() {
412        let mut quotes_bid = AHashMap::new();
413        let mut quotes_ask = AHashMap::new();
414
415        // Only one pair provided
416        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
417        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
418
419        // Attempt conversion from EUR to JPY should yield None
420        let rate = get_exchange_rate(
421            Ustr::from("EUR"),
422            Ustr::from("JPY"),
423            PriceType::Mid,
424            quotes_bid,
425            quotes_ask,
426        )
427        .unwrap();
428        assert_eq!(rate, None);
429    }
430
431    #[rstest]
432    fn test_empty_quotes() {
433        let quotes_bid: AHashMap<Ustr, Decimal> = AHashMap::new();
434        let quotes_ask: AHashMap<Ustr, Decimal> = AHashMap::new();
435        let result = get_exchange_rate(
436            Ustr::from("EUR"),
437            Ustr::from("USD"),
438            PriceType::Mid,
439            quotes_bid,
440            quotes_ask,
441        );
442        assert_eq!(
443            result.unwrap_err().to_string(),
444            "Quote maps must not be empty"
445        );
446    }
447
448    #[rstest]
449    fn test_unequal_quotes_length() {
450        let mut quotes_bid = AHashMap::new();
451        let mut quotes_ask = AHashMap::new();
452
453        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
454        quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
455        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
456        // Missing GBP/USD in ask quotes.
457
458        let result = get_exchange_rate(
459            Ustr::from("EUR"),
460            Ustr::from("USD"),
461            PriceType::Mid,
462            quotes_bid,
463            quotes_ask,
464        );
465        assert_eq!(
466            result.unwrap_err().to_string(),
467            "Quote maps must have equal lengths"
468        );
469    }
470
471    #[rstest]
472    fn test_equal_length_quotes_with_different_keys() {
473        let mut quotes_bid = AHashMap::new();
474        let mut quotes_ask = AHashMap::new();
475        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
476        quotes_ask.insert(Ustr::from("GBP/USD"), dec!(1.3002));
477
478        let result = get_exchange_rate(
479            Ustr::from("EUR"),
480            Ustr::from("USD"),
481            PriceType::Bid,
482            quotes_bid,
483            quotes_ask,
484        );
485
486        assert_eq!(
487            result.unwrap_err().to_string(),
488            "Missing ask quote for pair EUR/USD"
489        );
490    }
491
492    #[rstest]
493    fn test_invalid_price_type() {
494        let (quotes_bid, quotes_ask) = setup_test_quotes();
495        // Using an invalid price type variant (assume PriceType::Last is unsupported)
496        let result = get_exchange_rate(
497            Ustr::from("EUR"),
498            Ustr::from("USD"),
499            PriceType::Last,
500            quotes_bid,
501            quotes_ask,
502        );
503        assert_eq!(
504            result.unwrap_err().to_string(),
505            "Invalid `price_type`, was 'LAST'"
506        );
507    }
508
509    #[rstest]
510    fn test_same_currency_shortcut_precedes_all_validation() {
511        // The identical-currency shortcut runs before any quote or price-type validation, so an
512        // unsupported price type and empty quote maps still yield a rate of one.
513        let result = get_exchange_rate(
514            Ustr::from("USD"),
515            Ustr::from("USD"),
516            PriceType::Last,
517            AHashMap::new(),
518            AHashMap::new(),
519        );
520
521        assert_eq!(result.unwrap(), Some(Decimal::ONE));
522    }
523
524    #[rstest]
525    fn test_quote_map_errors_precede_invalid_price_type() {
526        // Empty maps are reported before an unsupported price type for distinct currencies.
527        let empty = get_exchange_rate(
528            Ustr::from("EUR"),
529            Ustr::from("USD"),
530            PriceType::Last,
531            AHashMap::new(),
532            AHashMap::new(),
533        );
534
535        assert_eq!(
536            empty.unwrap_err().to_string(),
537            "Quote maps must not be empty"
538        );
539
540        let mut quotes_bid = AHashMap::new();
541        let mut quotes_ask = AHashMap::new();
542        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
543        quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
544        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
545
546        let unequal = get_exchange_rate(
547            Ustr::from("EUR"),
548            Ustr::from("USD"),
549            PriceType::Last,
550            quotes_bid,
551            quotes_ask,
552        );
553
554        assert_eq!(
555            unequal.unwrap_err().to_string(),
556            "Quote maps must have equal lengths"
557        );
558    }
559
560    #[rstest]
561    fn test_cycle_handling() {
562        let mut quotes_bid = AHashMap::new();
563        let mut quotes_ask = AHashMap::new();
564        // Create a cycle by including both EUR/USD and USD/EUR quotes
565        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1));
566        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
567        quotes_bid.insert(Ustr::from("USD/EUR"), dec!(0.909));
568        quotes_ask.insert(Ustr::from("USD/EUR"), dec!(0.9091));
569
570        let rate = get_exchange_rate(
571            Ustr::from("EUR"),
572            Ustr::from("USD"),
573            PriceType::Mid,
574            quotes_bid,
575            quotes_ask,
576        )
577        .unwrap();
578
579        // The total adjacency ordering encounters the higher parallel rate first.
580        let expected = dec!(1.1001);
581        assert_eq!(rate, Some(expected));
582    }
583
584    #[rstest]
585    fn test_multiple_paths() {
586        let mut quotes_bid = AHashMap::new();
587        let mut quotes_ask = AHashMap::new();
588        // Direct conversion
589        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
590        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
591        // Indirect path via GBP: EUR/GBP and GBP/USD
592        quotes_bid.insert(Ustr::from("EUR/GBP"), dec!(0.8461));
593        quotes_ask.insert(Ustr::from("EUR/GBP"), dec!(0.8463));
594        quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
595        quotes_ask.insert(Ustr::from("GBP/USD"), dec!(1.3002));
596
597        let rate = get_exchange_rate(
598            Ustr::from("EUR"),
599            Ustr::from("USD"),
600            PriceType::Mid,
601            quotes_bid,
602            quotes_ask,
603        )
604        .unwrap();
605
606        // Both paths should be consistent:
607        let direct = dec!(1.1001);
608        let indirect = dec!(0.8462) * dec!(1.3001);
609        assert!((direct - indirect).abs() < dec!(0.0001));
610        assert!((rate.unwrap() - direct).abs() < dec!(0.0001));
611    }
612}