use ahash::{AHashMap, AHashSet};
use nautilus_model::enums::PriceType;
use rust_decimal::Decimal;
use ustr::Ustr;
pub fn get_exchange_rate(
from_currency: Ustr,
to_currency: Ustr,
price_type: PriceType,
quotes_bid: AHashMap<Ustr, Decimal>,
quotes_ask: AHashMap<Ustr, Decimal>,
) -> anyhow::Result<Option<Decimal>> {
if from_currency == to_currency {
return Ok(Some(Decimal::ONE));
}
if quotes_bid.is_empty() || quotes_ask.is_empty() {
anyhow::bail!("Quote maps must not be empty");
}
if quotes_bid.len() != quotes_ask.len() {
anyhow::bail!("Quote maps must have equal lengths");
}
let effective_quotes: AHashMap<Ustr, Decimal> = match price_type {
PriceType::Bid => quotes_bid,
PriceType::Ask => quotes_ask,
PriceType::Mid => {
let mut mid_quotes = AHashMap::new();
for (pair, bid) in "es_bid {
let ask = quotes_ask
.get(pair)
.ok_or_else(|| anyhow::anyhow!("Missing ask quote for pair {pair}"))?;
mid_quotes.insert(*pair, (bid + ask) / Decimal::TWO);
}
mid_quotes
}
_ => anyhow::bail!("Invalid `price_type`, was '{price_type}'"),
};
let mut graph: AHashMap<Ustr, Vec<(Ustr, Decimal)>> = AHashMap::new();
for (pair, rate) in effective_quotes {
let parts: Vec<&str> = pair.split('/').collect();
if parts.len() != 2 {
log::warn!("Skipping invalid pair string: {pair}");
continue;
}
if rate <= Decimal::ZERO {
log::warn!("Skipping non-positive rate for pair {pair}");
continue;
}
let base = Ustr::from(parts[0]);
let quote = Ustr::from(parts[1]);
graph.entry(base).or_default().push((quote, rate));
graph
.entry(quote)
.or_default()
.push((base, Decimal::ONE / rate));
}
let mut stack: Vec<(Ustr, Decimal)> = vec![(from_currency, Decimal::ONE)];
let mut visited: AHashSet<Ustr> = AHashSet::new();
visited.insert(from_currency);
while let Some((current, current_rate)) = stack.pop() {
if current == to_currency {
return Ok(Some(current_rate));
}
if let Some(neighbors) = graph.get(¤t) {
for (neighbor, rate) in neighbors {
if visited.insert(*neighbor) {
stack.push((*neighbor, current_rate * rate));
}
}
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use ahash::AHashMap;
use rstest::rstest;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use ustr::Ustr;
use super::*;
fn setup_test_quotes() -> (AHashMap<Ustr, Decimal>, AHashMap<Ustr, Decimal>) {
let mut quotes_bid = AHashMap::new();
let mut quotes_ask = AHashMap::new();
quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
quotes_ask.insert(Ustr::from("GBP/USD"), dec!(1.3002));
quotes_bid.insert(Ustr::from("USD/JPY"), dec!(110.00));
quotes_ask.insert(Ustr::from("USD/JPY"), dec!(110.02));
quotes_bid.insert(Ustr::from("AUD/USD"), dec!(0.7500));
quotes_ask.insert(Ustr::from("AUD/USD"), dec!(0.7502));
(quotes_bid, quotes_ask)
}
#[rstest]
fn test_invalid_pair_string() {
let mut quotes_bid = AHashMap::new();
let mut quotes_ask = AHashMap::new();
quotes_bid.insert(Ustr::from("EURUSD"), dec!(1.1000));
quotes_ask.insert(Ustr::from("EURUSD"), dec!(1.1002));
quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
let rate = get_exchange_rate(
Ustr::from("EUR"),
Ustr::from("USD"),
PriceType::Mid,
quotes_bid,
quotes_ask,
)
.unwrap();
assert_eq!(rate, Some(dec!(1.1001)));
}
#[rstest]
fn test_same_currency() {
let (quotes_bid, quotes_ask) = setup_test_quotes();
let rate = get_exchange_rate(
Ustr::from("USD"),
Ustr::from("USD"),
PriceType::Mid,
quotes_bid,
quotes_ask,
)
.unwrap();
assert_eq!(rate, Some(Decimal::ONE));
}
#[rstest(
price_type,
expected,
case(PriceType::Bid, dec!(1.1000)),
case(PriceType::Ask, dec!(1.1002)),
case(PriceType::Mid, dec!(1.1001))
)]
fn test_direct_pair(price_type: PriceType, expected: Decimal) {
let (quotes_bid, quotes_ask) = setup_test_quotes();
let rate = get_exchange_rate(
Ustr::from("EUR"),
Ustr::from("USD"),
price_type,
quotes_bid,
quotes_ask,
)
.unwrap();
let rate = rate.unwrap_or_else(|| panic!("Expected a conversion rate for {price_type}"));
assert_eq!(rate, expected);
}
#[rstest]
fn test_inverse_pair() {
let (quotes_bid, quotes_ask) = setup_test_quotes();
let rate_eur_usd = get_exchange_rate(
Ustr::from("EUR"),
Ustr::from("USD"),
PriceType::Mid,
quotes_bid.clone(),
quotes_ask.clone(),
)
.unwrap();
let rate_usd_eur = get_exchange_rate(
Ustr::from("USD"),
Ustr::from("EUR"),
PriceType::Mid,
quotes_bid,
quotes_ask,
)
.unwrap();
if let (Some(eur_usd), Some(usd_eur)) = (rate_eur_usd, rate_usd_eur) {
assert!((eur_usd * usd_eur - Decimal::ONE).abs() < dec!(0.0001));
} else {
panic!("Expected valid conversion rates for inverse conversion");
}
}
#[rstest]
fn test_cross_pair_through_usd() {
let (quotes_bid, quotes_ask) = setup_test_quotes();
let rate = get_exchange_rate(
Ustr::from("EUR"),
Ustr::from("JPY"),
PriceType::Mid,
quotes_bid,
quotes_ask,
)
.unwrap();
let expected = dec!(1.1001) * dec!(110.01);
assert_eq!(rate, Some(expected));
}
#[rstest]
#[case(dec!(0))]
#[case(dec!(-1.1))]
fn test_non_positive_rate_is_skipped(#[case] rate: Decimal) {
let mut quotes_bid = AHashMap::new();
let mut quotes_ask = AHashMap::new();
quotes_bid.insert(Ustr::from("EUR/USD"), rate);
quotes_ask.insert(Ustr::from("EUR/USD"), rate);
let result = get_exchange_rate(
Ustr::from("EUR"),
Ustr::from("USD"),
PriceType::Mid,
quotes_bid,
quotes_ask,
);
assert_eq!(result.unwrap(), None);
}
#[rstest]
fn test_no_conversion_path() {
let mut quotes_bid = AHashMap::new();
let mut quotes_ask = AHashMap::new();
quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
let rate = get_exchange_rate(
Ustr::from("EUR"),
Ustr::from("JPY"),
PriceType::Mid,
quotes_bid,
quotes_ask,
)
.unwrap();
assert_eq!(rate, None);
}
#[rstest]
fn test_empty_quotes() {
let quotes_bid: AHashMap<Ustr, Decimal> = AHashMap::new();
let quotes_ask: AHashMap<Ustr, Decimal> = AHashMap::new();
let result = get_exchange_rate(
Ustr::from("EUR"),
Ustr::from("USD"),
PriceType::Mid,
quotes_bid,
quotes_ask,
);
assert!(result.is_err());
}
#[rstest]
fn test_unequal_quotes_length() {
let mut quotes_bid = AHashMap::new();
let mut quotes_ask = AHashMap::new();
quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
let result = get_exchange_rate(
Ustr::from("EUR"),
Ustr::from("USD"),
PriceType::Mid,
quotes_bid,
quotes_ask,
);
assert!(result.is_err());
}
#[rstest]
fn test_invalid_price_type() {
let (quotes_bid, quotes_ask) = setup_test_quotes();
let result = get_exchange_rate(
Ustr::from("EUR"),
Ustr::from("USD"),
PriceType::Last,
quotes_bid,
quotes_ask,
);
assert!(result.is_err());
}
#[rstest]
fn test_cycle_handling() {
let mut quotes_bid = AHashMap::new();
let mut quotes_ask = AHashMap::new();
quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1));
quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
quotes_bid.insert(Ustr::from("USD/EUR"), dec!(0.909));
quotes_ask.insert(Ustr::from("USD/EUR"), dec!(0.9091));
let rate = get_exchange_rate(
Ustr::from("EUR"),
Ustr::from("USD"),
PriceType::Mid,
quotes_bid,
quotes_ask,
)
.unwrap();
let expected = dec!(1.1001);
assert!((rate.unwrap() - expected).abs() < dec!(0.0001));
}
#[rstest]
fn test_multiple_paths() {
let mut quotes_bid = AHashMap::new();
let mut quotes_ask = AHashMap::new();
quotes_bid.insert(Ustr::from("EUR/USD"), dec!(1.1000));
quotes_ask.insert(Ustr::from("EUR/USD"), dec!(1.1002));
quotes_bid.insert(Ustr::from("EUR/GBP"), dec!(0.8461));
quotes_ask.insert(Ustr::from("EUR/GBP"), dec!(0.8463));
quotes_bid.insert(Ustr::from("GBP/USD"), dec!(1.3000));
quotes_ask.insert(Ustr::from("GBP/USD"), dec!(1.3002));
let rate = get_exchange_rate(
Ustr::from("EUR"),
Ustr::from("USD"),
PriceType::Mid,
quotes_bid,
quotes_ask,
)
.unwrap();
let direct = dec!(1.1001);
let indirect = dec!(0.8462) * dec!(1.3001);
assert!((direct - indirect).abs() < dec!(0.0001));
assert!((rate.unwrap() - direct).abs() < dec!(0.0001));
}
}