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
55 if quotes_bid.len() != quotes_ask.len() {
56 anyhow::bail!("Quote maps must have equal lengths");
57 }
58
59 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 "es_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 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 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(¤t) {
103 for (neighbor, rate) in neighbors {
104 if visited.insert(*neighbor) {
105 stack.push((*neighbor, current_rate * rate));
106 }
107 }
108 }
109 }
110
111 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 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
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 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 quotes_bid.insert("EUR/USD".to_string(), 1.1000);
262 quotes_ask.insert("EUR/USD".to_string(), 1.1002);
263
264 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 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 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 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 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 quotes_bid.insert("EUR/USD".to_string(), 1.1000);
354 quotes_ask.insert("EUR/USD".to_string(), 1.1002);
355 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 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}