nautilus_common/
xrate.rs

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