use std::collections::HashMap;
use async_trait::async_trait;
use chrono::{Duration, Utc};
use serde::Deserialize;
use crate::price::config::ProviderConfig;
use crate::price::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes, Quote};
const LOOKBACK_HOURS: i64 = 23;
const DATE_FMT: &str = "%Y-%m-%d %H:%M:%S";
#[derive(Debug, Deserialize)]
struct ElToqueResponse {
tasas: HashMap<String, Option<f64>>,
}
pub struct ElToqueProvider {
url: String,
token: String,
}
impl std::fmt::Debug for ElToqueProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ElToqueProvider")
.field("url", &self.url)
.field("token", &"<redacted>")
.finish()
}
}
impl ElToqueProvider {
pub fn new(cfg: &ProviderConfig) -> Result<Self, String> {
let token = cfg
.token
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
.ok_or_else(|| {
"price provider 'eltoque': enabled provider requires a `token` (Bearer API \
key) — set it or disable the provider (see docs/PRICE_PROVIDERS.md §7)"
.to_string()
})?;
Ok(Self {
url: cfg.url.trim_end_matches('/').to_string(),
token: token.to_string(),
})
}
pub(crate) fn parse(body: &str) -> Result<ProviderQuotes, ProviderError> {
let parsed: ElToqueResponse = serde_json::from_str(body)
.map_err(|e| ProviderError::Parse(format!("eltoque: {e}")))?;
let tasas = parsed.tasas;
let mut out = ProviderQuotes::new();
let cup_per_usd = match tasas.get("USD") {
Some(Some(v)) if v.is_finite() && *v > 0.0 => *v,
_ => return Ok(out),
};
out.insert(
"CUP".to_string(),
Quote::PerBase {
base: "USD".to_string(),
value: cup_per_usd,
},
);
if let Some(Some(cup_per_mlc)) = tasas.get("MLC") {
if cup_per_mlc.is_finite() && *cup_per_mlc > 0.0 {
let mlc_per_usd = cup_per_usd / cup_per_mlc;
if mlc_per_usd.is_finite() && mlc_per_usd > 0.0 {
out.insert(
"MLC".to_string(),
Quote::PerBase {
base: "USD".to_string(),
value: mlc_per_usd,
},
);
}
}
}
Ok(out)
}
}
#[async_trait]
impl PriceProvider for ElToqueProvider {
fn id(&self) -> ProviderId {
ProviderId::ElToque
}
async fn fetch(&self, http: &reqwest::Client) -> Result<ProviderQuotes, ProviderError> {
let url = format!("{}/v1/trmi", self.url);
let now = Utc::now();
let from = now - Duration::hours(LOOKBACK_HOURS);
let res = http
.get(&url)
.bearer_auth(&self.token)
.query(&[
("date_from", from.format(DATE_FMT).to_string()),
("date_to", now.format(DATE_FMT).to_string()),
])
.send()
.await
.map_err(|e| ProviderError::Http(format!("eltoque GET {url}: {e}")))?;
if !res.status().is_success() {
return Err(ProviderError::Http(format!(
"eltoque GET {url}: status {}",
res.status()
)));
}
let body = res
.text()
.await
.map_err(|e| ProviderError::Http(format!("eltoque read body: {e}")))?;
Self::parse(&body)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_PAYLOAD: &str = include_str!("../../../tests/fixtures/price/eltoque_trmi.json");
fn cfg(url: &str, token: Option<&str>) -> ProviderConfig {
ProviderConfig {
enabled: true,
url: url.into(),
fallback_urls: vec![],
api_key: None,
token: token.map(String::from),
only: Some(vec!["CUP".into(), "MLC".into()]),
except: None,
}
}
fn per_usd(q: &Quote) -> f64 {
match q {
Quote::PerBase { base, value } => {
assert_eq!(base, "USD", "El Toque must anchor on USD");
*value
}
Quote::PerBtc(_) => panic!("El Toque must emit PerBase, not PerBtc"),
}
}
#[test]
fn parses_sample_payload_into_cup_and_mlc_perbase() {
let quotes = ElToqueProvider::parse(SAMPLE_PAYLOAD).expect("fixture must parse");
assert_eq!(quotes.len(), 2, "exactly CUP and MLC are emitted");
assert!((per_usd("es["CUP"]) - 490.0).abs() < 1e-9);
assert!((per_usd("es["MLC"]) - 490.0 / 200.0).abs() < 1e-9);
let cup_per_btc = per_usd("es["CUP"]) * 50_000.0; let mlc_per_btc = per_usd("es["MLC"]) * 50_000.0;
assert!(
(cup_per_btc / mlc_per_btc - 200.0).abs() < 1e-6,
"1 MLC must price at 200 CUP, matching tasas"
);
}
#[test]
fn mlc_cross_math_is_cup_per_usd_over_cup_per_mlc() {
let body = r#"{"tasas":{"USD":400.0,"MLC":250.0,"ECU":420.0}}"#;
let quotes = ElToqueProvider::parse(body).unwrap();
assert!((per_usd("es["CUP"]) - 400.0).abs() < 1e-9);
assert!((per_usd("es["MLC"]) - 400.0 / 250.0).abs() < 1e-9);
assert!(!quotes.contains_key("EUR"));
assert!(!quotes.contains_key("ECU"));
}
#[test]
fn no_usd_anchor_emits_nothing() {
let body = r#"{"tasas":{"MLC":210.0,"ECU":500.0}}"#;
let quotes = ElToqueProvider::parse(body).unwrap();
assert!(quotes.is_empty(), "no tasas.USD → no resolvable quotes");
}
#[test]
fn non_positive_rates_are_dropped() {
let body = r#"{"tasas":{"USD":442.0,"MLC":0}}"#;
let quotes = ElToqueProvider::parse(body).unwrap();
assert_eq!(quotes.len(), 1);
assert!(quotes.contains_key("CUP"));
assert!(!quotes.contains_key("MLC"));
let body = r#"{"tasas":{"USD":0,"MLC":210.0}}"#;
assert!(ElToqueProvider::parse(body).unwrap().is_empty());
}
#[test]
fn parse_error_is_returned() {
assert!(matches!(
ElToqueProvider::parse("not json").unwrap_err(),
ProviderError::Parse(_)
));
}
#[test]
fn new_requires_a_token() {
assert!(ElToqueProvider::new(&cfg("https://tasas.eltoque.com", None)).is_err());
assert!(ElToqueProvider::new(&cfg("https://tasas.eltoque.com", Some(" "))).is_err());
assert!(ElToqueProvider::new(&cfg("https://tasas.eltoque.com", Some("tok"))).is_ok());
}
#[test]
fn debug_redacts_token() {
let p = ElToqueProvider::new(&cfg("https://tasas.eltoque.com", Some("super-secret-key")))
.unwrap();
let dbg = format!("{p:?}");
assert!(!dbg.contains("super-secret-key"), "token leaked: {dbg}");
assert!(dbg.contains("<redacted>"));
}
#[test]
fn new_strips_trailing_slash() {
let p = ElToqueProvider::new(&cfg("https://tasas.eltoque.com/", Some("tok"))).unwrap();
assert_eq!(p.url, "https://tasas.eltoque.com");
}
}