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