use crate::analytics;
use crate::types::Bar;
use chrono::NaiveDate;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
Memory,
Live,
}
impl Backend {
pub fn from_env() -> Backend {
match std::env::var("MARKET_DATA_BACKEND").unwrap_or_default().to_lowercase().as_str() {
"live" => Backend::Live,
_ => Backend::Memory,
}
}
pub fn label(&self) -> &'static str {
match self { Backend::Memory => "memory", Backend::Live => "live" }
}
}
#[derive(Clone)]
pub struct LiveClient {
http: reqwest::Client,
}
impl Default for LiveClient {
fn default() -> Self {
Self::new()
}
}
impl LiveClient {
pub fn new() -> Self {
let http = reqwest::Client::builder()
.user_agent("mcp-market-data/1.1 (+https://github.com/zavora-ai/mcp-market-data)")
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("reqwest client");
LiveClient { http }
}
pub async fn quote(&self, symbol: &str) -> Result<(f64, NaiveDate), String> {
let json = self.chart(symbol, "5d").await?;
let meta = &json["chart"]["result"][0]["meta"];
let price = meta["regularMarketPrice"].as_f64()
.ok_or_else(|| format!("yahoo: no price for '{symbol}' (symbol may be unknown)"))?;
let ts = meta["regularMarketTime"].as_i64().unwrap_or(0);
let date = chrono::DateTime::from_timestamp(ts, 0).map(|d| d.date_naive()).unwrap_or_else(|| chrono::Utc::now().date_naive());
Ok((price, date))
}
pub async fn history(&self, symbol: &str, limit: Option<usize>) -> Result<Vec<Bar>, String> {
let json = self.chart(symbol, "1y").await?;
let result = &json["chart"]["result"][0];
let ts = result["timestamp"].as_array().ok_or_else(|| format!("yahoo: no history for '{symbol}'"))?;
let quote = &result["indicators"]["quote"][0];
let (opens, highs, lows, closes, vols) = ("e["open"], "e["high"], "e["low"], "e["close"], "e["volume"]);
let mut bars = Vec::new();
for (i, t) in ts.iter().enumerate() {
let Some(secs) = t.as_i64() else { continue };
let date = match chrono::DateTime::from_timestamp(secs, 0) { Some(d) => d.date_naive(), None => continue };
let (Some(open), Some(high), Some(low), Some(close)) = (
opens[i].as_f64(), highs[i].as_f64(), lows[i].as_f64(), closes[i].as_f64(),
) else { continue };
let volume = vols[i].as_f64().unwrap_or(0.0);
bars.push(Bar { instrument_id: symbol.to_string(), date, open, high, low, close, volume });
}
if bars.is_empty() { return Err(format!("yahoo: no parseable history rows for '{symbol}'")); }
bars.sort_by(|a, b| a.date.cmp(&b.date));
if let Some(n) = limit {
if n < bars.len() { bars = bars[bars.len() - n..].to_vec(); }
}
Ok(bars)
}
pub async fn analytics(&self, symbol: &str) -> Result<serde_json::Value, String> {
let bars = self.history(symbol, Some(252)).await?;
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
let mut v = analytics::summarize("symbol", symbol, symbol, &closes);
if let Some(obj) = v.as_object_mut() {
obj.insert("source".into(), serde_json::json!("yahoo-finance"));
}
Ok(v)
}
async fn chart(&self, symbol: &str, range: &str) -> Result<serde_json::Value, String> {
let s = map_symbol(symbol);
let url = format!("https://query1.finance.yahoo.com/v8/finance/chart/{s}?range={range}&interval=1d");
let body = self.get_text(&url).await?;
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| format!("yahoo: bad json: {e}"))?;
if json["chart"]["result"][0].is_null() {
let err = json["chart"]["error"]["description"].as_str().unwrap_or("unknown symbol or no data");
return Err(format!("yahoo: {err} for '{symbol}'"));
}
Ok(json)
}
pub async fn fx_convert(&self, amount: f64, from: &str, to: &str) -> Result<serde_json::Value, String> {
let from = from.to_uppercase();
let to = to.to_uppercase();
if from == to {
return Ok(serde_json::json!({"amount": amount, "from": from, "to": to, "rate": 1.0, "result": analytics::round4(amount), "source": "identity"}));
}
let url = format!("https://api.frankfurter.dev/v1/latest?base={from}&symbols={to}");
let body = self.get_text(&url).await?;
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| format!("frankfurter: bad json: {e}"))?;
let rate = json["rates"][&to].as_f64()
.ok_or_else(|| format!("frankfurter: no rate {from}->{to} (currencies must be ISO codes it supports)"))?;
let date = json["date"].as_str().unwrap_or("").to_string();
Ok(serde_json::json!({
"amount": amount, "from": from, "to": to,
"rate": analytics::round6(rate), "result": analytics::round4(amount * rate),
"as_of": date, "source": "frankfurter-ecb",
}))
}
async fn get_text(&self, url: &str) -> Result<String, String> {
let resp = self.http.get(url).send().await.map_err(|e| format!("request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("upstream {} for {url}", resp.status()));
}
resp.text().await.map_err(|e| format!("read body failed: {e}"))
}
}
fn map_symbol(symbol: &str) -> String {
let s = symbol.trim();
if s.contains('.') || s.contains('^') || s.contains('=') {
return s.to_uppercase();
}
s.to_uppercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn symbol_mapping() {
assert_eq!(map_symbol("aapl"), "AAPL");
assert_eq!(map_symbol("AAPL"), "AAPL");
assert_eq!(map_symbol("^gspc"), "^GSPC");
assert_eq!(map_symbol("vod.l"), "VOD.L");
}
#[test]
fn backend_from_env_defaults_memory() {
unsafe { std::env::remove_var("MARKET_DATA_BACKEND"); }
assert_eq!(Backend::from_env(), Backend::Memory);
}
}