1use ahash::{AHashMap, AHashSet};
21use nautilus_model::enums::PriceType;
22use rust_decimal::Decimal;
23use ustr::Ustr;
24
25pub fn get_exchange_rate(
40 from_currency: Ustr,
41 to_currency: Ustr,
42 price_type: PriceType,
43 quotes_bid: AHashMap<Ustr, Decimal>,
44 quotes_ask: AHashMap<Ustr, Decimal>,
45) -> anyhow::Result<Option<Decimal>> {
46 if from_currency == to_currency {
47 return Ok(Some(Decimal::ONE));
50 }
51
52 if quotes_bid.is_empty() || quotes_ask.is_empty() {
53 anyhow::bail!("Quote maps must not be empty");
54 }
55
56 if quotes_bid.len() != quotes_ask.len() {
57 anyhow::bail!("Quote maps must have equal lengths");
58 }
59
60 let effective_quotes: AHashMap<Ustr, Decimal> = match price_type {
62 PriceType::Bid => quotes_bid,
63 PriceType::Ask => quotes_ask,
64 PriceType::Mid => {
65 let mut mid_quotes = AHashMap::new();
66
67 for (pair, bid) in "es_bid {
68 let ask = quotes_ask
69 .get(pair)
70 .ok_or_else(|| anyhow::anyhow!("Missing ask quote for pair {pair}"))?;
71 mid_quotes.insert(*pair, (bid + ask) / Decimal::TWO);
72 }
73 mid_quotes
74 }
75 _ => anyhow::bail!("Invalid `price_type`, was '{price_type}'"),
76 };
77
78 let mut graph: AHashMap<Ustr, Vec<(Ustr, Decimal)>> = AHashMap::new();
80 for (pair, rate) in effective_quotes {
81 let parts: Vec<&str> = pair.split('/').collect();
82 if parts.len() != 2 {
83 log::warn!("Skipping invalid pair string: {pair}");
84 continue;
85 }
86
87 if rate <= Decimal::ZERO {
88 log::warn!("Skipping non-positive rate for pair {pair}");
90 continue;
91 }
92 let base = Ustr::from(parts[0]);
93 let quote = Ustr::from(parts[1]);
94
95 graph.entry(base).or_default().push((quote, rate));
96 graph
97 .entry(quote)
98 .or_default()
99 .push((base, Decimal::ONE / rate));
100 }
101
102 let mut stack: Vec<(Ustr, Decimal)> = vec![(from_currency, Decimal::ONE)];
104 let mut visited: AHashSet<Ustr> = AHashSet::new();
105 visited.insert(from_currency);
106
107 while let Some((current, current_rate)) = stack.pop() {
108 if current == to_currency {
109 return Ok(Some(current_rate));
110 }
111
112 if let Some(neighbors) = graph.get(¤t) {
113 for (neighbor, rate) in neighbors {
114 if visited.insert(*neighbor) {
115 stack.push((*neighbor, current_rate * rate));
116 }
117 }
118 }
119 }
120
121 Ok(None)
123}
124
125#[cfg(test)]
126mod tests {
127 use ahash::AHashMap;
128 use rstest::rstest;
129 use rust_decimal::Decimal;
130 use rust_decimal_macros::dec;
131 use ustr::Ustr;
132
133 use super::*;
134
135 fn setup_test_quotes() -> (AHashMap<Ustr, Decimal>, AHashMap<Ustr, Decimal>) {
136 let mut quotes_bid = AHashMap::new();
137 let mut quotes_ask = AHashMap::new();
138
139 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
141 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
142
143 quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
144 quotes_ask.insert(Ustr::from("GBP/USD"), dec!(1.3002));
145
146 quotes_bid.insert(Ustr::from("USD/JPY"), dec!(110.00));
147 quotes_ask.insert(Ustr::from("USD/JPY"), dec!(110.02));
148
149 quotes_bid.insert(Ustr::from("AUD/USD"), dec!(0.7500));
150 quotes_ask.insert(Ustr::from("AUD/USD"), dec!(0.7502));
151
152 (quotes_bid, quotes_ask)
153 }
154
155 #[rstest]
156 fn test_invalid_pair_string() {
157 let mut quotes_bid = AHashMap::new();
158 let mut quotes_ask = AHashMap::new();
159 quotes_bid.insert(Ustr::from("EURUSD"), dec!(1.1000));
161 quotes_ask.insert(Ustr::from("EURUSD"), dec!(1.1002));
162 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
164 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
165
166 let rate = get_exchange_rate(
167 Ustr::from("EUR"),
168 Ustr::from("USD"),
169 PriceType::Mid,
170 quotes_bid,
171 quotes_ask,
172 )
173 .unwrap();
174
175 assert_eq!(rate, Some(dec!(1.1001)));
176 }
177
178 #[rstest]
179 fn test_same_currency() {
180 let (quotes_bid, quotes_ask) = setup_test_quotes();
181 let rate = get_exchange_rate(
182 Ustr::from("USD"),
183 Ustr::from("USD"),
184 PriceType::Mid,
185 quotes_bid,
186 quotes_ask,
187 )
188 .unwrap();
189 assert_eq!(rate, Some(Decimal::ONE));
190 }
191
192 #[rstest(
193 price_type,
194 expected,
195 case(PriceType::Bid, dec!(1.1000)),
196 case(PriceType::Ask, dec!(1.1002)),
197 case(PriceType::Mid, dec!(1.1001))
198 )]
199 fn test_direct_pair(price_type: PriceType, expected: Decimal) {
200 let (quotes_bid, quotes_ask) = setup_test_quotes();
201
202 let rate = get_exchange_rate(
203 Ustr::from("EUR"),
204 Ustr::from("USD"),
205 price_type,
206 quotes_bid,
207 quotes_ask,
208 )
209 .unwrap();
210
211 let rate = rate.unwrap_or_else(|| panic!("Expected a conversion rate for {price_type}"));
212 assert_eq!(rate, expected);
213 }
214
215 #[rstest]
216 fn test_inverse_pair() {
217 let (quotes_bid, quotes_ask) = setup_test_quotes();
218
219 let rate_eur_usd = get_exchange_rate(
220 Ustr::from("EUR"),
221 Ustr::from("USD"),
222 PriceType::Mid,
223 quotes_bid.clone(),
224 quotes_ask.clone(),
225 )
226 .unwrap();
227 let rate_usd_eur = get_exchange_rate(
228 Ustr::from("USD"),
229 Ustr::from("EUR"),
230 PriceType::Mid,
231 quotes_bid,
232 quotes_ask,
233 )
234 .unwrap();
235
236 if let (Some(eur_usd), Some(usd_eur)) = (rate_eur_usd, rate_usd_eur) {
237 assert!((eur_usd * usd_eur - Decimal::ONE).abs() < dec!(0.0001));
239 } else {
240 panic!("Expected valid conversion rates for inverse conversion");
241 }
242 }
243
244 #[rstest]
245 fn test_cross_pair_through_usd() {
246 let (quotes_bid, quotes_ask) = setup_test_quotes();
247 let rate = get_exchange_rate(
248 Ustr::from("EUR"),
249 Ustr::from("JPY"),
250 PriceType::Mid,
251 quotes_bid,
252 quotes_ask,
253 )
254 .unwrap();
255 let expected = dec!(1.1001) * dec!(110.01);
257
258 assert_eq!(rate, Some(expected));
259 }
260
261 #[rstest]
262 #[case(dec!(0))]
263 #[case(dec!(-1.1))]
264 fn test_non_positive_rate_is_skipped(#[case] rate: Decimal) {
265 let mut quotes_bid = AHashMap::new();
266 let mut quotes_ask = AHashMap::new();
267 quotes_bid.insert(Ustr::from("EUR/USD"), rate);
268 quotes_ask.insert(Ustr::from("EUR/USD"), rate);
269
270 let result = get_exchange_rate(
271 Ustr::from("EUR"),
272 Ustr::from("USD"),
273 PriceType::Mid,
274 quotes_bid,
275 quotes_ask,
276 );
277
278 assert_eq!(result.unwrap(), None);
279 }
280
281 #[rstest]
282 fn test_no_conversion_path() {
283 let mut quotes_bid = AHashMap::new();
284 let mut quotes_ask = AHashMap::new();
285
286 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
288 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
289
290 let rate = get_exchange_rate(
292 Ustr::from("EUR"),
293 Ustr::from("JPY"),
294 PriceType::Mid,
295 quotes_bid,
296 quotes_ask,
297 )
298 .unwrap();
299 assert_eq!(rate, None);
300 }
301
302 #[rstest]
303 fn test_empty_quotes() {
304 let quotes_bid: AHashMap<Ustr, Decimal> = AHashMap::new();
305 let quotes_ask: AHashMap<Ustr, Decimal> = AHashMap::new();
306 let result = get_exchange_rate(
307 Ustr::from("EUR"),
308 Ustr::from("USD"),
309 PriceType::Mid,
310 quotes_bid,
311 quotes_ask,
312 );
313 assert!(result.is_err());
314 }
315
316 #[rstest]
317 fn test_unequal_quotes_length() {
318 let mut quotes_bid = AHashMap::new();
319 let mut quotes_ask = AHashMap::new();
320
321 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
322 quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
323 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
324 let result = get_exchange_rate(
327 Ustr::from("EUR"),
328 Ustr::from("USD"),
329 PriceType::Mid,
330 quotes_bid,
331 quotes_ask,
332 );
333 assert!(result.is_err());
334 }
335
336 #[rstest]
337 fn test_invalid_price_type() {
338 let (quotes_bid, quotes_ask) = setup_test_quotes();
339 let result = get_exchange_rate(
341 Ustr::from("EUR"),
342 Ustr::from("USD"),
343 PriceType::Last,
344 quotes_bid,
345 quotes_ask,
346 );
347 assert!(result.is_err());
348 }
349
350 #[rstest]
351 fn test_cycle_handling() {
352 let mut quotes_bid = AHashMap::new();
353 let mut quotes_ask = AHashMap::new();
354 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1));
356 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
357 quotes_bid.insert(Ustr::from("USD/EUR"), dec!(0.909));
358 quotes_ask.insert(Ustr::from("USD/EUR"), dec!(0.9091));
359
360 let rate = get_exchange_rate(
361 Ustr::from("EUR"),
362 Ustr::from("USD"),
363 PriceType::Mid,
364 quotes_bid,
365 quotes_ask,
366 )
367 .unwrap();
368
369 let expected = dec!(1.1001);
371 assert!((rate.unwrap() - expected).abs() < dec!(0.0001));
372 }
373
374 #[rstest]
375 fn test_multiple_paths() {
376 let mut quotes_bid = AHashMap::new();
377 let mut quotes_ask = AHashMap::new();
378 quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
380 quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
381 quotes_bid.insert(Ustr::from("EUR/GBP"), dec!(0.8461));
383 quotes_ask.insert(Ustr::from("EUR/GBP"), dec!(0.8463));
384 quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
385 quotes_ask.insert(Ustr::from("GBP/USD"), dec!(1.3002));
386
387 let rate = get_exchange_rate(
388 Ustr::from("EUR"),
389 Ustr::from("USD"),
390 PriceType::Mid,
391 quotes_bid,
392 quotes_ask,
393 )
394 .unwrap();
395
396 let direct = dec!(1.1001);
398 let indirect = dec!(0.8462) * dec!(1.3001);
399 assert!((direct - indirect).abs() < dec!(0.0001));
400 assert!((rate.unwrap() - direct).abs() < dec!(0.0001));
401 }
402}