use super::{PriceSource, user_agent};
use crate::cmd::price::{PriceRequest, PriceResponse};
use anyhow::{Context, Result};
use rustledger_core::NaiveDate;
use std::env;
use std::time::Duration;
#[derive(Debug)]
pub struct QuandlSource {}
impl QuandlSource {
pub const fn new(_timeout: Duration) -> Self {
Self {}
}
fn get_api_key() -> Result<String> {
env::var("QUANDL_API_KEY").with_context(|| "QUANDL_API_KEY environment variable not set")
}
fn build_url(&self, dataset: &str, api_key: &str) -> String {
format!(
"https://data.nasdaq.com/api/v3/datasets/{dataset}/data.json?limit=1&api_key={api_key}"
)
}
fn parse_dataset(ticker: &str) -> (&str, &str) {
if let Some(pos) = ticker.find('/') {
(&ticker[..pos], &ticker[pos + 1..])
} else {
("WIKI", ticker)
}
}
}
impl PriceSource for QuandlSource {
fn name(&self) -> &'static str {
"quandl"
}
fn description(&self) -> &'static str {
"Nasdaq Data Link (Quandl) - financial datasets (requires API key)"
}
fn requires_api_key(&self) -> bool {
true
}
fn api_key_env_var(&self) -> Option<&'static str> {
Some("QUANDL_API_KEY")
}
fn fetch_price(&self, request: &PriceRequest) -> Result<PriceResponse> {
let api_key = Self::get_api_key()?;
let (database, dataset) = Self::parse_dataset(&request.ticker);
let full_dataset = format!("{database}/{dataset}");
let url = self.build_url(&full_dataset, &api_key);
let mut response = ureq::get(&url)
.header("User-Agent", user_agent())
.call()
.with_context(|| format!("Failed to fetch data for {}", request.ticker))?;
let json: serde_json::Value = response
.body_mut()
.read_json()
.with_context(|| "Failed to parse Quandl response")?;
if let Some(quandl_error) = json.get("quandl_error") {
let code = quandl_error
.get("code")
.and_then(serde_json::Value::as_str)
.unwrap_or("UNKNOWN");
let message = quandl_error
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("Unknown error");
anyhow::bail!("Quandl error {code}: {message}");
}
let dataset_data = json
.get("dataset_data")
.with_context(|| "Missing dataset_data in response")?;
let data = dataset_data
.get("data")
.and_then(serde_json::Value::as_array)
.and_then(|a| a.first())
.and_then(serde_json::Value::as_array)
.with_context(|| "No data available")?;
let column_names = dataset_data
.get("column_names")
.and_then(serde_json::Value::as_array)
.with_context(|| "Missing column names")?;
let date_idx = column_names
.iter()
.position(|c| {
c.as_str()
.is_some_and(|s| s.to_lowercase().contains("date"))
})
.unwrap_or(0);
let price_idx = column_names
.iter()
.position(|c| {
c.as_str().is_some_and(|s| {
let lower = s.to_lowercase();
lower.contains("close")
|| lower.contains("value")
|| lower.contains("price")
|| lower.contains("settle")
})
})
.with_context(|| "No price column found in dataset")?;
let date = data
.get(date_idx)
.and_then(serde_json::Value::as_str)
.and_then(|s| s.parse::<NaiveDate>().ok())
.unwrap_or_else(|| request.date.unwrap_or_else(|| jiff::Zoned::now().date()));
let price_value = data.get(price_idx).with_context(|| "Missing price value")?;
let price = crate::cmd::price::price_decimal_from_json(price_value)
.with_context(|| format!("Invalid price format: {price_value}"))?;
Ok(PriceResponse {
price,
currency: request.currency.clone(),
date,
source: self.name().to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_dataset() {
assert_eq!(QuandlSource::parse_dataset("WIKI/AAPL"), ("WIKI", "AAPL"));
assert_eq!(QuandlSource::parse_dataset("LBMA/GOLD"), ("LBMA", "GOLD"));
assert_eq!(QuandlSource::parse_dataset("AAPL"), ("WIKI", "AAPL"));
}
#[test]
fn test_build_url() {
let source = QuandlSource::new(Duration::from_secs(30));
let url = source.build_url("WIKI/AAPL", "demo");
assert!(url.contains("WIKI/AAPL"));
assert!(url.contains("data.nasdaq.com"));
}
#[test]
fn test_source_metadata() {
let source = QuandlSource::new(Duration::from_secs(30));
assert_eq!(source.name(), "quandl");
assert!(source.requires_api_key());
assert_eq!(source.api_key_env_var(), Some("QUANDL_API_KEY"));
}
}