1use ahash::{AHashMap, AHashSet};
21use nautilus_model::enums::PriceType;
22use ustr::Ustr;
23
24pub 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 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 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 "es_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 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 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(¤t) {
100 for (neighbor, rate) in neighbors {
101 if visited.insert(*neighbor) {
102 stack.push((*neighbor, current_rate * rate));
103 }
104 }
105 }
106 }
107
108 Ok(None)
110}
111
112#[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 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 quotes_bid.insert("EURUSD".to_string(), 1.1000);
149 quotes_ask.insert("EURUSD".to_string(), 1.1002);
150 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 if let (Some(eur_usd), Some(usd_eur)) = (rate_eur_usd, rate_usd_eur) {
225 assert!(eur_usd.mul_add(usd_eur, -1.0).abs() < 0.0001);
226 } else {
227 panic!("Expected valid conversion rates for inverse conversion");
228 }
229 }
230
231 #[rstest]
232 fn test_cross_pair_through_usd() {
233 let (quotes_bid, quotes_ask) = setup_test_quotes();
234 let rate = get_exchange_rate(
235 Ustr::from("EUR"),
236 Ustr::from("JPY"),
237 PriceType::Mid,
238 quotes_bid,
239 quotes_ask,
240 )
241 .unwrap();
242 let mid_eur_usd = f64::midpoint(1.1000, 1.1002);
244 let mid_usd_jpy = f64::midpoint(110.00, 110.02);
245 let expected = mid_eur_usd * mid_usd_jpy;
246 if let Some(val) = rate {
247 assert!((val - expected).abs() < 0.1);
248 } else {
249 panic!("Expected conversion rate through USD but got None");
250 }
251 }
252
253 #[rstest]
254 fn test_no_conversion_path() {
255 let mut quotes_bid = AHashMap::new();
256 let mut quotes_ask = AHashMap::new();
257
258 quotes_bid.insert("EUR/USD".to_string(), 1.1000);
260 quotes_ask.insert("EUR/USD".to_string(), 1.1002);
261
262 let rate = get_exchange_rate(
264 Ustr::from("EUR"),
265 Ustr::from("JPY"),
266 PriceType::Mid,
267 quotes_bid,
268 quotes_ask,
269 )
270 .unwrap();
271 assert_eq!(rate, None);
272 }
273
274 #[rstest]
275 fn test_empty_quotes() {
276 let quotes_bid: AHashMap<String, f64> = AHashMap::new();
277 let quotes_ask: AHashMap<String, f64> = AHashMap::new();
278 let result = get_exchange_rate(
279 Ustr::from("EUR"),
280 Ustr::from("USD"),
281 PriceType::Mid,
282 quotes_bid,
283 quotes_ask,
284 );
285 assert!(result.is_err());
286 }
287
288 #[rstest]
289 fn test_unequal_quotes_length() {
290 let mut quotes_bid = AHashMap::new();
291 let mut quotes_ask = AHashMap::new();
292
293 quotes_bid.insert("EUR/USD".to_string(), 1.1000);
294 quotes_bid.insert("GBP/USD".to_string(), 1.3000);
295 quotes_ask.insert("EUR/USD".to_string(), 1.1002);
296 let result = get_exchange_rate(
299 Ustr::from("EUR"),
300 Ustr::from("USD"),
301 PriceType::Mid,
302 quotes_bid,
303 quotes_ask,
304 );
305 assert!(result.is_err());
306 }
307
308 #[rstest]
309 fn test_invalid_price_type() {
310 let (quotes_bid, quotes_ask) = setup_test_quotes();
311 let result = get_exchange_rate(
313 Ustr::from("EUR"),
314 Ustr::from("USD"),
315 PriceType::Last,
316 quotes_bid,
317 quotes_ask,
318 );
319 assert!(result.is_err());
320 }
321
322 #[rstest]
323 fn test_cycle_handling() {
324 let mut quotes_bid = AHashMap::new();
325 let mut quotes_ask = AHashMap::new();
326 quotes_bid.insert("EUR/USD".to_string(), 1.1);
328 quotes_ask.insert("EUR/USD".to_string(), 1.1002);
329 quotes_bid.insert("USD/EUR".to_string(), 0.909);
330 quotes_ask.insert("USD/EUR".to_string(), 0.9091);
331
332 let rate = get_exchange_rate(
333 Ustr::from("EUR"),
334 Ustr::from("USD"),
335 PriceType::Mid,
336 quotes_bid,
337 quotes_ask,
338 )
339 .unwrap();
340
341 let expected = f64::midpoint(1.1, 1.1002);
343 assert!((rate.unwrap() - expected).abs() < 0.0001);
344 }
345
346 #[rstest]
347 fn test_multiple_paths() {
348 let mut quotes_bid = AHashMap::new();
349 let mut quotes_ask = AHashMap::new();
350 quotes_bid.insert("EUR/USD".to_string(), 1.1000);
352 quotes_ask.insert("EUR/USD".to_string(), 1.1002);
353 quotes_bid.insert("EUR/GBP".to_string(), 0.8461);
355 quotes_ask.insert("EUR/GBP".to_string(), 0.8463);
356 quotes_bid.insert("GBP/USD".to_string(), 1.3000);
357 quotes_ask.insert("GBP/USD".to_string(), 1.3002);
358
359 let rate = get_exchange_rate(
360 Ustr::from("EUR"),
361 Ustr::from("USD"),
362 PriceType::Mid,
363 quotes_bid,
364 quotes_ask,
365 )
366 .unwrap();
367
368 let direct: f64 = f64::midpoint(1.1000_f64, 1.1002_f64);
370 let indirect: f64 =
371 f64::midpoint(0.8461_f64, 0.8463_f64) * f64::midpoint(1.3000_f64, 1.3002_f64);
372 assert!((direct - indirect).abs() < 0.0001_f64);
373 assert!((rate.unwrap() - direct).abs() < 0.0001_f64);
374 }
375}