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/// - `price_type` is equal to `Last` or `Mark` (cannot calculate from quotes).
36/// - `quotes_bid` or `quotes_ask` is empty.
37/// - `quotes_bid` and `quotes_ask` lengths are not equal.
38/// - The bid or ask side of a pair is missing.
39pub fn get_exchange_rate(
40    from_currency: Ustr,
41    to_currency: Ustr,
42    price_type: PriceType,
43    quotes_bid: AHashMap<Ustr, Decimal>,
44    quotes_ask: AHashMap<Ustr, Decimal>,
45) -> anyhow::Result<Option<Decimal>> {
46    if from_currency == to_currency {
47        // When the source and target currencies are identical,
48        // no conversion is needed; return an exchange rate of one.
49        return Ok(Some(Decimal::ONE));
50    }
51
52    if quotes_bid.is_empty() || quotes_ask.is_empty() {
53        anyhow::bail!("Quote maps must not be empty");
54    }
55
56    if quotes_bid.len() != quotes_ask.len() {
57        anyhow::bail!("Quote maps must have equal lengths");
58    }
59
60    // Build effective quotes based on the requested price type
61    let effective_quotes: AHashMap<Ustr, Decimal> = match price_type {
62        PriceType::Bid => quotes_bid,
63        PriceType::Ask => quotes_ask,
64        PriceType::Mid => {
65            let mut mid_quotes = AHashMap::new();
66
67            for (pair, bid) in &quotes_bid {
68                let ask = quotes_ask
69                    .get(pair)
70                    .ok_or_else(|| anyhow::anyhow!("Missing ask quote for pair {pair}"))?;
71                mid_quotes.insert(*pair, (bid + ask) / Decimal::TWO);
72            }
73            mid_quotes
74        }
75        _ => anyhow::bail!("Invalid `price_type`, was '{price_type}'"),
76    };
77
78    // Construct a graph: each currency maps to its neighbors and corresponding conversion rate
79    let mut graph: AHashMap<Ustr, Vec<(Ustr, Decimal)>> = AHashMap::new();
80    for (pair, rate) in effective_quotes {
81        let parts: Vec<&str> = pair.split('/').collect();
82        if parts.len() != 2 {
83            log::warn!("Skipping invalid pair string: {pair}");
84            continue;
85        }
86
87        if rate <= Decimal::ZERO {
88            // A non-positive quote would divide by zero on the inverse edge
89            log::warn!("Skipping non-positive rate for pair {pair}");
90            continue;
91        }
92        let base = Ustr::from(parts[0]);
93        let quote = Ustr::from(parts[1]);
94
95        graph.entry(base).or_default().push((quote, rate));
96        graph
97            .entry(quote)
98            .or_default()
99            .push((base, Decimal::ONE / rate));
100    }
101
102    // DFS: search for a conversion path from `from_currency` to `to_currency`
103    let mut stack: Vec<(Ustr, Decimal)> = vec![(from_currency, Decimal::ONE)];
104    let mut visited: AHashSet<Ustr> = AHashSet::new();
105    visited.insert(from_currency);
106
107    while let Some((current, current_rate)) = stack.pop() {
108        if current == to_currency {
109            return Ok(Some(current_rate));
110        }
111
112        if let Some(neighbors) = graph.get(&current) {
113            for (neighbor, rate) in neighbors {
114                if visited.insert(*neighbor) {
115                    stack.push((*neighbor, current_rate * rate));
116                }
117            }
118        }
119    }
120
121    // No conversion path found
122    Ok(None)
123}
124
125#[cfg(test)]
126mod tests {
127    use ahash::AHashMap;
128    use rstest::rstest;
129    use rust_decimal::Decimal;
130    use rust_decimal_macros::dec;
131    use ustr::Ustr;
132
133    use super::*;
134
135    fn setup_test_quotes() -> (AHashMap<Ustr, Decimal>, AHashMap<Ustr, Decimal>) {
136        let mut quotes_bid = AHashMap::new();
137        let mut quotes_ask = AHashMap::new();
138
139        // Direct pairs
140        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
141        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
142
143        quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
144        quotes_ask.insert(Ustr::from("GBP/USD"), dec!(1.3002));
145
146        quotes_bid.insert(Ustr::from("USD/JPY"), dec!(110.00));
147        quotes_ask.insert(Ustr::from("USD/JPY"), dec!(110.02));
148
149        quotes_bid.insert(Ustr::from("AUD/USD"), dec!(0.7500));
150        quotes_ask.insert(Ustr::from("AUD/USD"), dec!(0.7502));
151
152        (quotes_bid, quotes_ask)
153    }
154
155    #[rstest]
156    fn test_invalid_pair_string() {
157        let mut quotes_bid = AHashMap::new();
158        let mut quotes_ask = AHashMap::new();
159        // Invalid pair string (missing '/')
160        quotes_bid.insert(Ustr::from("EURUSD"), dec!(1.1000));
161        quotes_ask.insert(Ustr::from("EURUSD"), dec!(1.1002));
162        // Valid pair string
163        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
164        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
165
166        let rate = get_exchange_rate(
167            Ustr::from("EUR"),
168            Ustr::from("USD"),
169            PriceType::Mid,
170            quotes_bid,
171            quotes_ask,
172        )
173        .unwrap();
174
175        assert_eq!(rate, Some(dec!(1.1001)));
176    }
177
178    #[rstest]
179    fn test_same_currency() {
180        let (quotes_bid, quotes_ask) = setup_test_quotes();
181        let rate = get_exchange_rate(
182            Ustr::from("USD"),
183            Ustr::from("USD"),
184            PriceType::Mid,
185            quotes_bid,
186            quotes_ask,
187        )
188        .unwrap();
189        assert_eq!(rate, Some(Decimal::ONE));
190    }
191
192    #[rstest(
193        price_type,
194        expected,
195        case(PriceType::Bid, dec!(1.1000)),
196        case(PriceType::Ask, dec!(1.1002)),
197        case(PriceType::Mid, dec!(1.1001))
198    )]
199    fn test_direct_pair(price_type: PriceType, expected: Decimal) {
200        let (quotes_bid, quotes_ask) = setup_test_quotes();
201
202        let rate = get_exchange_rate(
203            Ustr::from("EUR"),
204            Ustr::from("USD"),
205            price_type,
206            quotes_bid,
207            quotes_ask,
208        )
209        .unwrap();
210
211        let rate = rate.unwrap_or_else(|| panic!("Expected a conversion rate for {price_type}"));
212        assert_eq!(rate, expected);
213    }
214
215    #[rstest]
216    fn test_inverse_pair() {
217        let (quotes_bid, quotes_ask) = setup_test_quotes();
218
219        let rate_eur_usd = get_exchange_rate(
220            Ustr::from("EUR"),
221            Ustr::from("USD"),
222            PriceType::Mid,
223            quotes_bid.clone(),
224            quotes_ask.clone(),
225        )
226        .unwrap();
227        let rate_usd_eur = get_exchange_rate(
228            Ustr::from("USD"),
229            Ustr::from("EUR"),
230            PriceType::Mid,
231            quotes_bid,
232            quotes_ask,
233        )
234        .unwrap();
235
236        if let (Some(eur_usd), Some(usd_eur)) = (rate_eur_usd, rate_usd_eur) {
237            // Inverse-edge rounding makes the round-trip near one, not exactly one
238            assert!((eur_usd * usd_eur - Decimal::ONE).abs() < dec!(0.0001));
239        } else {
240            panic!("Expected valid conversion rates for inverse conversion");
241        }
242    }
243
244    #[rstest]
245    fn test_cross_pair_through_usd() {
246        let (quotes_bid, quotes_ask) = setup_test_quotes();
247        let rate = get_exchange_rate(
248            Ustr::from("EUR"),
249            Ustr::from("JPY"),
250            PriceType::Mid,
251            quotes_bid,
252            quotes_ask,
253        )
254        .unwrap();
255        // Expected rate: (EUR/USD mid) * (USD/JPY mid)
256        let expected = dec!(1.1001) * dec!(110.01);
257
258        assert_eq!(rate, Some(expected));
259    }
260
261    #[rstest]
262    #[case(dec!(0))]
263    #[case(dec!(-1.1))]
264    fn test_non_positive_rate_is_skipped(#[case] rate: Decimal) {
265        let mut quotes_bid = AHashMap::new();
266        let mut quotes_ask = AHashMap::new();
267        quotes_bid.insert(Ustr::from("EUR/USD"), rate);
268        quotes_ask.insert(Ustr::from("EUR/USD"), rate);
269
270        let result = get_exchange_rate(
271            Ustr::from("EUR"),
272            Ustr::from("USD"),
273            PriceType::Mid,
274            quotes_bid,
275            quotes_ask,
276        );
277
278        assert_eq!(result.unwrap(), None);
279    }
280
281    #[rstest]
282    fn test_no_conversion_path() {
283        let mut quotes_bid = AHashMap::new();
284        let mut quotes_ask = AHashMap::new();
285
286        // Only one pair provided
287        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
288        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
289
290        // Attempt conversion from EUR to JPY should yield None
291        let rate = get_exchange_rate(
292            Ustr::from("EUR"),
293            Ustr::from("JPY"),
294            PriceType::Mid,
295            quotes_bid,
296            quotes_ask,
297        )
298        .unwrap();
299        assert_eq!(rate, None);
300    }
301
302    #[rstest]
303    fn test_empty_quotes() {
304        let quotes_bid: AHashMap<Ustr, Decimal> = AHashMap::new();
305        let quotes_ask: AHashMap<Ustr, Decimal> = AHashMap::new();
306        let result = get_exchange_rate(
307            Ustr::from("EUR"),
308            Ustr::from("USD"),
309            PriceType::Mid,
310            quotes_bid,
311            quotes_ask,
312        );
313        assert!(result.is_err());
314    }
315
316    #[rstest]
317    fn test_unequal_quotes_length() {
318        let mut quotes_bid = AHashMap::new();
319        let mut quotes_ask = AHashMap::new();
320
321        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
322        quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
323        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
324        // Missing GBP/USD in ask quotes.
325
326        let result = get_exchange_rate(
327            Ustr::from("EUR"),
328            Ustr::from("USD"),
329            PriceType::Mid,
330            quotes_bid,
331            quotes_ask,
332        );
333        assert!(result.is_err());
334    }
335
336    #[rstest]
337    fn test_invalid_price_type() {
338        let (quotes_bid, quotes_ask) = setup_test_quotes();
339        // Using an invalid price type variant (assume PriceType::Last is unsupported)
340        let result = get_exchange_rate(
341            Ustr::from("EUR"),
342            Ustr::from("USD"),
343            PriceType::Last,
344            quotes_bid,
345            quotes_ask,
346        );
347        assert!(result.is_err());
348    }
349
350    #[rstest]
351    fn test_cycle_handling() {
352        let mut quotes_bid = AHashMap::new();
353        let mut quotes_ask = AHashMap::new();
354        // Create a cycle by including both EUR/USD and USD/EUR quotes
355        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1));
356        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
357        quotes_bid.insert(Ustr::from("USD/EUR"), dec!(0.909));
358        quotes_ask.insert(Ustr::from("USD/EUR"), dec!(0.9091));
359
360        let rate = get_exchange_rate(
361            Ustr::from("EUR"),
362            Ustr::from("USD"),
363            PriceType::Mid,
364            quotes_bid,
365            quotes_ask,
366        )
367        .unwrap();
368
369        // Edge order is non-deterministic, so allow a small tolerance around the mid rate
370        let expected = dec!(1.1001);
371        assert!((rate.unwrap() - expected).abs() < dec!(0.0001));
372    }
373
374    #[rstest]
375    fn test_multiple_paths() {
376        let mut quotes_bid = AHashMap::new();
377        let mut quotes_ask = AHashMap::new();
378        // Direct conversion
379        quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
380        quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
381        // Indirect path via GBP: EUR/GBP and GBP/USD
382        quotes_bid.insert(Ustr::from("EUR/GBP"), dec!(0.8461));
383        quotes_ask.insert(Ustr::from("EUR/GBP"), dec!(0.8463));
384        quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
385        quotes_ask.insert(Ustr::from("GBP/USD"), dec!(1.3002));
386
387        let rate = get_exchange_rate(
388            Ustr::from("EUR"),
389            Ustr::from("USD"),
390            PriceType::Mid,
391            quotes_bid,
392            quotes_ask,
393        )
394        .unwrap();
395
396        // Both paths should be consistent:
397        let direct = dec!(1.1001);
398        let indirect = dec!(0.8462) * dec!(1.3001);
399        assert!((direct - indirect).abs() < dec!(0.0001));
400        assert!((rate.unwrap() - direct).abs() < dec!(0.0001));
401    }
402}