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)]
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 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 quotes_bid.insert("EURUSD".to_string(), 1.1000);
146 quotes_ask.insert("EURUSD".to_string(), 1.1002);
147 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 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 quotes_bid.insert("EUR/USD".to_string(), 1.1000);
257 quotes_ask.insert("EUR/USD".to_string(), 1.1002);
258
259 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 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 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 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 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 quotes_bid.insert("EUR/USD".to_string(), 1.1000);
349 quotes_ask.insert("EUR/USD".to_string(), 1.1002);
350 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 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}