use super::{PriceSource, user_agent};
use crate::cmd::price::{PriceRequest, PriceResponse};
use anyhow::{Context, Result};
use rust_decimal::Decimal;
use rustledger_core::NaiveDate;
use std::time::Duration;
#[derive(Debug)]
pub struct EcbSource {}
impl EcbSource {
pub const fn new(_timeout: Duration) -> Self {
Self {}
}
fn build_url(&self, currency: &str) -> String {
format!(
"https://data-api.ecb.europa.eu/service/data/EXR/D.{currency}.EUR.SP00.A?lastNObservations=1&format=jsondata"
)
}
}
impl EcbSource {
fn fetch_rate(&self, currency: &str) -> Result<(Decimal, NaiveDate)> {
let url = self.build_url(¤cy.to_uppercase());
let mut response = ureq::get(&url)
.header("User-Agent", user_agent())
.header("Accept", "application/json")
.call()
.with_context(|| format!("Failed to fetch ECB rate for {currency}"))?;
let json: serde_json::Value = response
.body_mut()
.read_json()
.with_context(|| format!("Failed to parse ECB response for {currency}"))?;
let datasets = json
.get("dataSets")
.and_then(serde_json::Value::as_array)
.and_then(|a| a.first())
.with_context(|| "Missing dataSets in ECB response")?;
let series = datasets
.get("series")
.and_then(serde_json::Value::as_object)
.and_then(|o| o.values().next())
.with_context(|| "Missing series in ECB response")?;
let observations = series
.get("observations")
.and_then(serde_json::Value::as_object)
.with_context(|| "Missing observations in ECB response")?;
let (obs_key, obs_value) = observations
.iter()
.next_back()
.with_context(|| "No observations in ECB response")?;
let rate_value = obs_value
.as_array()
.and_then(|a| a.first())
.with_context(|| "Invalid rate value in ECB response")?;
let rate = crate::cmd::price::price_decimal_from_json(rate_value)
.with_context(|| format!("Failed to parse rate: {rate_value}"))?;
let date = json
.get("structure")
.and_then(|s| s.get("dimensions"))
.and_then(|d| d.get("observation"))
.and_then(|o| o.as_array())
.and_then(|a| a.first())
.and_then(|t| t.get("values"))
.and_then(|v| v.as_array())
.and_then(|values| {
let idx: usize = obs_key.parse().unwrap_or(0);
values.get(idx)
})
.and_then(|v| v.get("id"))
.and_then(serde_json::Value::as_str)
.and_then(|s| s.parse::<NaiveDate>().ok())
.unwrap_or_else(|| jiff::Zoned::now().date());
Ok((rate, date))
}
}
impl PriceSource for EcbSource {
fn name(&self) -> &'static str {
"ecb"
}
fn description(&self) -> &'static str {
"European Central Bank - currency exchange rates"
}
fn fetch_price(&self, request: &PriceRequest) -> Result<PriceResponse> {
let ticker = request.ticker.to_uppercase();
let currency = request.currency.to_uppercase();
let date = request.date.unwrap_or_else(|| jiff::Zoned::now().date());
if ticker == "EUR" && currency == "EUR" {
return Ok(PriceResponse {
price: Decimal::ONE,
currency,
date,
source: self.name().to_string(),
});
}
if ticker == "EUR" {
let (rate, rate_date) = self.fetch_rate(¤cy)?;
return Ok(PriceResponse {
price: rate,
currency,
date: request.date.unwrap_or(rate_date),
source: self.name().to_string(),
});
}
if currency == "EUR" {
let (rate, rate_date) = self.fetch_rate(&ticker)?;
if rate.is_zero() {
anyhow::bail!("Cannot invert zero rate for {ticker}");
}
let inverted = Decimal::ONE / rate;
return Ok(PriceResponse {
price: inverted,
currency,
date: request.date.unwrap_or(rate_date),
source: self.name().to_string(),
});
}
let (ticker_rate, ticker_date) = self.fetch_rate(&ticker)?;
let (currency_rate, _) = self.fetch_rate(¤cy)?;
if ticker_rate.is_zero() {
anyhow::bail!("Cannot compute cross-rate: zero rate for {ticker}");
}
let cross_rate = currency_rate / ticker_rate;
Ok(PriceResponse {
price: cross_rate,
currency,
date: request.date.unwrap_or(ticker_date),
source: self.name().to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_url() {
let source = EcbSource::new(Duration::from_secs(30));
let url = source.build_url("USD");
assert!(url.contains("USD"));
assert!(url.contains("data-api.ecb.europa.eu"));
}
#[test]
fn test_source_metadata() {
let source = EcbSource::new(Duration::from_secs(30));
assert_eq!(source.name(), "ecb");
assert!(!source.requires_api_key());
}
#[test]
fn test_eur_to_eur_returns_one() {
let source = EcbSource::new(Duration::from_secs(30));
let request = PriceRequest::new("EUR", "EUR");
let response = source.fetch_price(&request).unwrap();
assert_eq!(response.price, Decimal::ONE);
assert_eq!(response.currency, "EUR");
}
}