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 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            // 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(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    // 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    fn test_invalid_pair_string() {
168        let mut quotes_bid = AHashMap::new();
169        let mut quotes_ask = AHashMap::new();
170        // Invalid pair string (missing '/')
171        quotes_bid.insert(Ustr::from("EURUSD"), dec!(1.1000));
172        quotes_ask.insert(Ustr::from("EURUSD"), dec!(1.1002));
173        // Valid pair string
174        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            // Inverse-edge rounding makes the round-trip near one, not exactly one
249            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        // Expected rate: (EUR/USD mid) * (USD/JPY mid)
330        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        // Only one pair provided
389        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
390        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
391
392        // Attempt conversion from EUR to JPY should yield None
393        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        // Missing GBP/USD in ask quotes.
427
428        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        // Using an invalid price type variant (assume PriceType::Last is unsupported)
460        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        // The identical-currency shortcut runs before any quote or price-type validation, so an
473        // unsupported price type and empty quote maps still yield a rate of one.
474        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        // Empty maps are reported before an unsupported price type for distinct currencies.
488        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        // Create a cycle by including both EUR/USD and USD/EUR quotes
526        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        // The total adjacency ordering encounters the higher parallel rate first.
541        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        // Direct conversion
550        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
551        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
552        // Indirect path via GBP: EUR/GBP and GBP/USD
553        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        // Both paths should be consistent:
568        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}