From 3998e38ffc71575bf9cdd5fe6d7b91dd0074972c Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:17:50 +0000 Subject: [PATCH] refactor: schema-driven architecture, capability traits, hardened error paths - Split parse.rs into raw_types.rs (serde structs) + map.rs (pure transforms) for MSN and Yahoo - Replace Yahoo fundamentals dynamic HashMap with typed structs (SummaryDetail, DefaultKeyStatistics, etc.) - Introduce capability-based provider traits: QuoteProvider, FundamentalsProvider, HistoryProvider - Add future MSN capability traits: ProfileProvider, EarningsProvider, FinancialsProvider, SentimentProvider, InsightsProvider, NewsProvider (all dead_code until wired to CLI) - Add shared domain types in src/api/types.rs (CompanyProfile, EarningsReport, FinancialStatements, SentimentData, InsightData, NewsItem) - Harden error propagation: Yahoo cookie auth, MSN partial fundamentals, history symbol context, cache clear failures - Strict config parsing: invalid IDX_OUTPUT returns ConfigError instead of silent fallback - Cache schema version enforcement: version mismatch treated as cache miss - Extract fetch_with_cache() helper in cli/stocks.rs - Add MSN retry/backoff parity with Yahoo client - Standardize Option policy through parse/map layers - All 56 tests passing, clippy clean --- src/api/mod.rs | 62 ++++- src/api/msn/client.rs | 59 ++-- src/api/msn/map.rs | 316 +++++++++++++++++++++ src/api/msn/mod.rs | 36 +-- src/api/msn/parse.rs | 557 +++---------------------------------- src/api/msn/raw_types.rs | 130 +++++++++ src/api/types.rs | 119 ++++++++ src/api/yahoo/client.rs | 15 +- src/api/yahoo/map.rs | 209 ++++++++++++++ src/api/yahoo/mod.rs | 19 +- src/api/yahoo/parse.rs | 391 +------------------------- src/api/yahoo/raw_types.rs | 156 +++++++++++ src/cache.rs | 33 ++- src/cli/cache.rs | 5 +- src/cli/stocks.rs | 34 +++ src/config.rs | 7 +- src/error.rs | 8 + 17 files changed, 1186 insertions(+), 970 deletions(-) create mode 100644 src/api/msn/map.rs create mode 100644 src/api/msn/raw_types.rs create mode 100644 src/api/yahoo/map.rs create mode 100644 src/api/yahoo/raw_types.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index ca18758..302c4dd 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -4,17 +4,59 @@ pub mod yahoo; use crate::config::ProviderKind; use crate::error::IdxError; -use types::{Fundamentals, Interval, Ohlc, Period, Quote}; +use types::{ + Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, + NewsItem, Period, Quote, SentimentData, +}; -pub trait MarketDataProvider { +pub trait QuoteProvider { fn quote(&self, symbol: &str) -> Result; - fn fundamentals(&self, symbol: &str) -> Result; +} + +pub trait HistoryProvider { fn history( &self, symbol: &str, period: &Period, interval: &Interval, - ) -> Result, IdxError>; + ) -> Result, IdxError>; +} + +pub trait FundamentalsProvider { + fn fundamentals(&self, symbol: &str) -> Result; +} + +pub trait MarketDataProvider: QuoteProvider + FundamentalsProvider + HistoryProvider {} +impl MarketDataProvider for T where T: QuoteProvider + FundamentalsProvider + HistoryProvider {} + +#[allow(dead_code)] +pub trait ProfileProvider { + fn profile(&self, symbol: &str) -> Result; +} + +#[allow(dead_code)] +pub trait EarningsProvider { + fn earnings(&self, symbol: &str) -> Result; +} + +#[allow(dead_code)] +pub trait FinancialsProvider { + fn financials(&self, symbol: &str) -> Result; +} + +#[allow(dead_code)] +pub trait SentimentProvider { + fn sentiment(&self, symbol: &str) -> Result; +} + +#[allow(dead_code)] +pub trait InsightsProvider { + fn insights(&self, symbol: &str) -> Result; +} + +#[allow(dead_code)] +pub trait NewsProvider { + fn news(&self, symbol: &str, limit: usize) -> Result, IdxError>; } pub fn resolve_symbol(symbol: &str, exchange: &str) -> String { @@ -42,7 +84,7 @@ pub fn default_provider(provider: ProviderKind, verbose: bool) -> Box, fundamentals: Result, - history: Result, IdxError>, + history: Result, IdxError>, } impl MockProvider { @@ -69,7 +111,7 @@ impl MockProvider { .map_err(|e| IdxError::ParseError(e.to_string())); let fundamentals = yahoo::parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw) .map_err(|e| IdxError::ParseError(e.to_string())); - let history = yahoo::parse_history_from_str(&history_raw) + let history = yahoo::parse_history_from_str("BBCA.JK", &history_raw) .map_err(|e| IdxError::ParseError(e.to_string())); Self { @@ -111,23 +153,27 @@ impl MockProvider { } } -impl MarketDataProvider for MockProvider { +impl QuoteProvider for MockProvider { fn quote(&self, symbol: &str) -> Result { let mut q = self.quote.clone()?; q.symbol = symbol.to_string(); Ok(q) } +} +impl FundamentalsProvider for MockProvider { fn fundamentals(&self, _symbol: &str) -> Result { self.fundamentals.clone() } +} +impl HistoryProvider for MockProvider { fn history( &self, _symbol: &str, _period: &Period, _interval: &Interval, - ) -> Result, IdxError> { + ) -> Result, IdxError> { self.history.clone() } } diff --git a/src/api/msn/client.rs b/src/api/msn/client.rs index 659170b..a62ea8f 100644 --- a/src/api/msn/client.rs +++ b/src/api/msn/client.rs @@ -4,7 +4,7 @@ use serde::de::DeserializeOwned; use crate::error::IdxError; -use super::parse::{KeyRatios, MsnQuote}; +use super::raw_types::{KeyRatios, MsnQuote}; use super::symbols::resolve_msn_id; const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; @@ -34,25 +34,48 @@ impl MsnClient { symbol: &str, endpoint: &str, ) -> Result { - let response = self - .agent - .get(url) - .header("User-Agent", USER_AGENT) - .header("Accept", "application/json") - .header("Accept-Language", "en-US,en;q=0.9,id;q=0.8") - .header("Origin", "https://www.msn.com") - .header("Referer", "https://www.msn.com/") - .call(); + let mut wait = Duration::from_millis(500); + for attempt in 0..3 { + let response = self + .agent + .get(url) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json") + .header("Accept-Language", "en-US,en;q=0.9,id;q=0.8") + .header("Origin", "https://www.msn.com") + .header("Referer", "https://www.msn.com/") + .call(); - match response { - Ok(ok) => ok - .into_body() - .read_json::() - .map_err(|e| IdxError::ParseError(format!("msn {endpoint}: {e}"))), - Err(ureq::Error::StatusCode(404)) => Err(IdxError::SymbolNotFound(symbol.to_string())), - Err(ureq::Error::StatusCode(429)) => Err(IdxError::RateLimited), - Err(err) => Err(IdxError::Http(format!("msn {endpoint}: {err}"))), + match response { + Ok(ok) => { + return ok + .into_body() + .read_json::() + .map_err(|e| IdxError::ParseError(format!("msn {endpoint}: {e}"))); + } + Err(ureq::Error::StatusCode(404)) => { + return Err(IdxError::SymbolNotFound(symbol.to_string())); + } + Err(ureq::Error::StatusCode(429)) => { + if attempt < 2 { + std::thread::sleep(wait); + wait *= 2; + continue; + } + return Err(IdxError::RateLimited); + } + Err(ureq::Error::StatusCode(code)) if code >= 500 => { + if attempt < 2 { + std::thread::sleep(wait); + wait *= 2; + continue; + } + return Err(IdxError::Http(format!("msn {endpoint}: status {code}"))); + } + Err(err) => return Err(IdxError::Http(format!("msn {endpoint}: {err}"))), + } } + Err(IdxError::RateLimited) } pub(super) fn fetch_quotes(&self, symbol: &str) -> Result, IdxError> { diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs new file mode 100644 index 0000000..337019f --- /dev/null +++ b/src/api/msn/map.rs @@ -0,0 +1,316 @@ +use std::collections::BTreeMap; + +use chrono::{Datelike, NaiveDate}; + +use super::raw_types::{IndustryMetric, KeyRatios, MsnChart, MsnQuote}; +use super::symbols::{normalized_symbol, ticker_from_symbol}; +use crate::api::types::{Fundamentals, Ohlc, Period, Quote}; +use crate::error::IdxError; + +pub(super) fn parse_quote(symbol: &str, quotes: &[MsnQuote]) -> Result { + let quote = quotes.first().ok_or(IdxError::ProviderUnavailable)?; + let raw_price = quote + .price + .ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?; + let prev_close = quote.price_previous_close.map(round_price); + let price = round_price(raw_price); + let change = prev_close + .map(|previous| price - previous) + .or_else(|| quote.price_change.map(round_price)) + .unwrap_or(0); + + let ticker = quote + .symbol + .as_deref() + .and_then(ticker_from_symbol) + .unwrap_or_else(|| ticker_from_symbol(symbol).unwrap_or_default()); + + let (week52_position, range_signal) = match (quote.price_52w_low, quote.price_52w_high) { + (Some(low), Some(high)) if high > low => { + let position = (raw_price - low) / (high - low); + let signal = if position > 0.66 { + Some("upper".to_string()) + } else if position < 0.33 { + Some("lower".to_string()) + } else { + Some("middle".to_string()) + }; + (Some(position), signal) + } + _ => (None, None), + }; + + Ok(Quote { + symbol: normalized_symbol(symbol, &ticker), + price, + change, + change_pct: quote.price_change_percent.unwrap_or(0.0), + volume: round_u64(quote.accumulated_volume).unwrap_or(0), + market_cap: round_u64(quote.market_cap), + week52_high: quote.price_52w_high.map(round_price), + week52_low: quote.price_52w_low.map(round_price), + week52_position, + range_signal, + prev_close, + avg_volume: round_u64(quote.average_volume), + }) +} + +pub(super) fn parse_fundamentals( + ratios: &[KeyRatios], + quote: Option<&MsnQuote>, +) -> Result { + let ratios = ratios.first().ok_or(IdxError::ProviderUnavailable)?; + let metrics = if ratios.company_metrics.is_empty() { + &ratios.industry_metrics + } else { + &ratios.company_metrics + }; + if preferred_metric(metrics).is_none() { + return Err(IdxError::ProviderUnavailable); + } + + Ok(Fundamentals { + trailing_pe: best_metric_value(metrics, |metric| metric.price_to_earnings_ratio), + forward_pe: best_metric_value(metrics, |metric| metric.forward_price_to_eps), + price_to_book: best_metric_value(metrics, |metric| metric.price_to_book_ratio), + return_on_equity: best_metric_value(metrics, |metric| normalize_percentish(metric.roe)), + profit_margins: best_metric_value(metrics, |metric| { + normalize_percentish(metric.profit_margin.or(metric.net_margin)) + }), + return_on_assets: best_metric_value(metrics, |metric| { + normalize_percentish(metric.roa_ttm.or(metric.return_on_asset_current)) + }), + revenue_growth: best_metric_value(metrics, |metric| { + normalize_percentish(metric.revenue_ytd_ytd.or(metric.revenue_growth_rate)) + }), + earnings_growth: best_metric_value(metrics, |metric| { + normalize_percentish( + metric + .net_income_ytd_ytd_growth_rate + .or(metric.earnings_growth_rate), + ) + }), + debt_to_equity: best_metric_value(metrics, |metric| metric.debt_to_equity_ratio), + current_ratio: best_metric_value(metrics, |metric| { + sanitize_current_ratio(metric.current_ratio) + }), + enterprise_value: None, + ebitda: None, + market_cap: quote.and_then(|item| round_u64(item.market_cap)), + }) +} + +pub(super) fn parse_history(period: &Period, charts: &[MsnChart]) -> Result, IdxError> { + parse_history_with_drop_count(period, charts).map(|v| v.0) +} + +pub(super) fn parse_history_with_drop_count( + period: &Period, + charts: &[MsnChart], +) -> Result<(Vec, usize), IdxError> { + let chart = charts.first().ok_or(IdxError::ProviderUnavailable)?; + + if !chart.series.has_real_ohlcv() { + return Err(IdxError::ParseError( + "msn does not expose real OHLC/volume for this history range".to_string(), + )); + } + + let timestamps = &chart.series.time_stamps; + + let mut grouped: BTreeMap = BTreeMap::new(); + let mut dropped = 0usize; + + for (idx, raw_ts) in timestamps.iter().enumerate() { + let Some(date) = parse_chart_date(raw_ts) else { + dropped += 1; + continue; + }; + let point = ( + chart.series.open_prices.get(idx).copied(), + chart.series.prices_high.get(idx).copied(), + chart.series.prices_low.get(idx).copied(), + chart.series.prices.get(idx).copied(), + chart.series.volumes.get(idx).copied(), + ); + + let (Some(open), Some(high), Some(low), Some(close), Some(volume)) = point else { + dropped += 1; + continue; + }; + + let candle = Ohlc { + date, + open: round_price(open), + high: round_price(high), + low: round_price(low), + close: round_price(close), + volume: round_u64(Some(volume)).unwrap_or(0), + }; + + grouped + .entry(date) + .and_modify(|existing| { + existing.high = existing.high.max(candle.high); + existing.low = existing.low.min(candle.low); + existing.close = candle.close; + existing.volume = existing.volume.saturating_add(candle.volume); + }) + .or_insert(candle); + } + + let mut out: Vec = grouped.into_values().collect(); + trim_history_to_period(period, &mut out); + + if out.is_empty() { + return Err(IdxError::ProviderUnavailable); + } + + Ok((out, dropped)) +} + +fn trim_history_to_period(period: &Period, rows: &mut Vec) { + let days: i64 = match period { + Period::OneDay => return, + Period::FiveDays => 5, + Period::OneMonth => 31, + Period::ThreeMonths => 92, + Period::SixMonths => 183, + Period::OneYear => 366, + Period::TwoYears => 731, + Period::FiveYears => 1826, + }; + + let Some(last_date) = rows.last().map(|item| item.date) else { + return; + }; + let cutoff = last_date - chrono::Duration::days(days.saturating_sub(1)); + rows.retain(|item| item.date >= cutoff); +} + +fn preferred_metric(metrics: &[IndustryMetric]) -> Option<&IndustryMetric> { + metrics.iter().max_by_key(|metric| metric_rank(metric)) +} + +fn best_metric_value( + metrics: &[IndustryMetric], + extractor: impl Fn(&IndustryMetric) -> Option, +) -> Option { + metrics + .iter() + .filter_map(|metric| extractor(metric).map(|value| (metric_rank(metric), value))) + .max_by_key(|(rank, _)| *rank) + .map(|(_, value)| value) +} + +fn metric_rank(metric: &IndustryMetric) -> (i32, i32) { + ( + metric + .year + .as_deref() + .and_then(|year| year.parse::().ok()) + .unwrap_or(i32::MIN), + metric_period_priority(metric.fiscal_period_type.as_deref()), + ) +} + +fn metric_period_priority(period: Option<&str>) -> i32 { + match period.map(|value| value.trim()) { + Some(value) if value.eq_ignore_ascii_case("TTM") => 7, + Some(value) + if value.eq_ignore_ascii_case("ANNUAL") + || value.eq_ignore_ascii_case("FY") + || value.eq_ignore_ascii_case("YEAR") => + { + 6 + } + Some(value) if value.eq_ignore_ascii_case("Q4") => 5, + Some(value) if value.eq_ignore_ascii_case("Q3") => 4, + Some(value) if value.eq_ignore_ascii_case("Q2") => 3, + Some(value) if value.eq_ignore_ascii_case("Q1") => 2, + Some(value) if value.eq_ignore_ascii_case("NTM") => 1, + _ => 0, + } +} + +fn normalize_percentish(value: Option) -> Option { + value.and_then(|number| { + if !number.is_finite() { + None + } else if number.abs() > 1.0 { + Some(number / 100.0) + } else { + Some(number) + } + }) +} + +fn sanitize_current_ratio(value: Option) -> Option { + value.and_then(|number| { + if !number.is_finite() || number < 0.01 { + None + } else { + Some(number) + } + }) +} + +fn parse_chart_date(raw: &str) -> Option { + if let Ok(date) = chrono::DateTime::parse_from_rfc3339(raw) { + return Some(date.date_naive()); + } + if let Ok(timestamp) = raw.parse::() { + return chrono::DateTime::from_timestamp(timestamp, 0).map(|dt| dt.date_naive()); + } + NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok() +} + +fn round_price(value: f64) -> i64 { + value.round() as i64 +} + +fn round_u64(value: Option) -> Option { + value.and_then(|number| { + if !number.is_finite() || number.is_sign_negative() { + None + } else { + Some(number.round() as u64) + } + }) +} + +#[allow(dead_code)] +#[derive(Clone, Copy)] +pub(super) enum ResampleInterval { + Week, + Month, +} + +#[allow(dead_code)] +pub(super) fn resample_history(rows: &[Ohlc], interval: ResampleInterval) -> Vec { + let mut grouped: BTreeMap<(i32, u32), Ohlc> = BTreeMap::new(); + + for row in rows { + let key = match interval { + ResampleInterval::Week => { + let iso = row.date.iso_week(); + (iso.year(), iso.week()) + } + ResampleInterval::Month => (row.date.year(), row.date.month()), + }; + + grouped + .entry(key) + .and_modify(|existing| { + existing.high = existing.high.max(row.high); + existing.low = existing.low.min(row.low); + existing.close = row.close; + existing.volume = existing.volume.saturating_add(row.volume); + existing.date = row.date; + }) + .or_insert_with(|| row.clone()); + } + + grouped.into_values().collect() +} diff --git a/src/api/msn/mod.rs b/src/api/msn/mod.rs index 4be68f4..0daf6cb 100644 --- a/src/api/msn/mod.rs +++ b/src/api/msn/mod.rs @@ -1,13 +1,15 @@ mod client; +mod map; mod parse; +mod raw_types; mod symbols; -use crate::api::MarketDataProvider; -use crate::api::types::{Fundamentals, Interval, Ohlc, Period, Quote}; +use crate::api::types::{Bar, Fundamentals, Interval, Period, Quote}; +use crate::api::{FundamentalsProvider, HistoryProvider, QuoteProvider}; use crate::error::IdxError; use client::MsnClient; -use parse::{parse_fundamentals, parse_quote}; +use map::{parse_fundamentals, parse_quote}; pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str}; @@ -15,46 +17,38 @@ const HISTORY_UNSUPPORTED_REASON: &str = "MSN provider does not currently suppor pub struct MsnProvider { client: MsnClient, - verbose: bool, } impl MsnProvider { - pub fn new(verbose: bool) -> Self { + pub fn new(_verbose: bool) -> Self { Self { client: MsnClient::new(), - verbose, } } } -impl MarketDataProvider for MsnProvider { +impl QuoteProvider for MsnProvider { fn quote(&self, symbol: &str) -> Result { let quotes = self.client.fetch_quotes(symbol)?; parse_quote(symbol, "es) } +} +impl FundamentalsProvider for MsnProvider { fn fundamentals(&self, symbol: &str) -> Result { let ratios = self.client.fetch_key_ratios(symbol)?; - let quote = self - .client - .fetch_quotes(symbol) - .map_err(|e| { - if self.verbose { - eprintln!("warning: quote fetch for fundamentals failed: {e}"); - } - e - }) - .ok() - .and_then(|quotes| quotes.into_iter().next()); - parse_fundamentals(&ratios, quote.as_ref()) + let quote = self.client.fetch_quotes(symbol)?; + parse_fundamentals(&ratios, quote.first()) } +} +impl HistoryProvider for MsnProvider { fn history( &self, _symbol: &str, _period: &Period, _interval: &Interval, - ) -> Result, IdxError> { + ) -> Result, IdxError> { Err(IdxError::Unsupported( HISTORY_UNSUPPORTED_REASON.to_string(), )) @@ -64,7 +58,7 @@ impl MarketDataProvider for MsnProvider { #[cfg(test)] mod tests { use super::MsnProvider; - use crate::api::MarketDataProvider; + use crate::api::HistoryProvider; use crate::api::types::{Interval, Period}; use crate::error::IdxError; diff --git a/src/api/msn/parse.rs b/src/api/msn/parse.rs index b73b44f..d68228c 100644 --- a/src/api/msn/parse.rs +++ b/src/api/msn/parse.rs @@ -1,10 +1,7 @@ -use std::collections::BTreeMap; +use chrono::NaiveDate; -use chrono::{Datelike, NaiveDate}; -use serde::de::Error as _; -use serde::{Deserialize, Deserializer}; - -use super::symbols::{normalized_symbol, ticker_from_symbol}; +use super::map::{parse_fundamentals, parse_history, parse_history_with_drop_count, parse_quote}; +use super::raw_types::{KeyRatios, MsnChart, MsnQuote}; use crate::api::types::{Fundamentals, Ohlc, Period, Quote}; use crate::error::IdxError; @@ -15,55 +12,6 @@ pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result Result { - let quote = quotes.first().ok_or(IdxError::ProviderUnavailable)?; - let raw_price = quote - .price - .ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?; - let prev_close = quote.price_previous_close.map(round_price); - let price = round_price(raw_price); - let change = prev_close - .map(|previous| price - previous) - .or_else(|| quote.price_change.map(round_price)) - .unwrap_or(0); - - let ticker = quote - .symbol - .as_deref() - .and_then(ticker_from_symbol) - .unwrap_or_else(|| ticker_from_symbol(symbol).unwrap_or_default()); - - let (week52_position, range_signal) = match (quote.price_52w_low, quote.price_52w_high) { - (Some(low), Some(high)) if high > low => { - let position = (raw_price - low) / (high - low); - let signal = if position > 0.66 { - Some("upper".to_string()) - } else if position < 0.33 { - Some("lower".to_string()) - } else { - Some("middle".to_string()) - }; - (Some(position), signal) - } - _ => (None, None), - }; - - Ok(Quote { - symbol: normalized_symbol(symbol, &ticker), - price, - change, - change_pct: quote.price_change_percent.unwrap_or(0.0), - volume: round_u64(quote.accumulated_volume).unwrap_or(0), - market_cap: round_u64(quote.market_cap), - week52_high: quote.price_52w_high.map(round_price), - week52_low: quote.price_52w_low.map(round_price), - week52_position, - range_signal, - prev_close, - avg_volume: round_u64(quote.average_volume), - }) -} - #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn parse_fundamentals_from_str( raw: &str, @@ -79,56 +27,24 @@ pub(crate) fn parse_fundamentals_from_str( parse_fundamentals(&ratios, quote.as_ref()) } -pub(super) fn parse_fundamentals( - ratios: &[KeyRatios], - quote: Option<&MsnQuote>, -) -> Result { - let ratios = ratios.first().ok_or(IdxError::ProviderUnavailable)?; - let metrics = if ratios.company_metrics.is_empty() { - &ratios.industry_metrics - } else { - &ratios.company_metrics - }; - if preferred_metric(metrics).is_none() { - return Err(IdxError::ProviderUnavailable); - } - - Ok(Fundamentals { - trailing_pe: best_metric_value(metrics, |metric| metric.price_to_earnings_ratio), - forward_pe: best_metric_value(metrics, |metric| metric.forward_price_to_eps), - price_to_book: best_metric_value(metrics, |metric| metric.price_to_book_ratio), - return_on_equity: best_metric_value(metrics, |metric| normalize_percentish(metric.roe)), - profit_margins: best_metric_value(metrics, |metric| { - normalize_percentish(metric.profit_margin.or(metric.net_margin)) - }), - return_on_assets: best_metric_value(metrics, |metric| { - normalize_percentish(metric.roa_ttm.or(metric.return_on_asset_current)) - }), - revenue_growth: best_metric_value(metrics, |metric| { - normalize_percentish(metric.revenue_ytd_ytd.or(metric.revenue_growth_rate)) - }), - earnings_growth: best_metric_value(metrics, |metric| { - normalize_percentish( - metric - .net_income_ytd_ytd_growth_rate - .or(metric.earnings_growth_rate), - ) - }), - debt_to_equity: best_metric_value(metrics, |metric| metric.debt_to_equity_ratio), - current_ratio: best_metric_value(metrics, |metric| { - sanitize_current_ratio(metric.current_ratio) - }), - enterprise_value: None, - ebitda: None, - market_cap: quote.and_then(|item| round_u64(item.market_cap)), - }) -} - #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn parse_history_from_str(period: &Period, raw: &str) -> Result, IdxError> { let charts: Vec = serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; - parse_history_with_verbose(period, &charts, false) + parse_history(period, &charts) +} + +#[allow(dead_code)] // retained for verbose history path, wired once MSN charts are exposed +pub(super) fn parse_history_with_verbose( + period: &Period, + charts: &[MsnChart], + verbose: bool, +) -> Result, IdxError> { + let (history, dropped) = parse_history_with_drop_count(period, charts)?; + if dropped > 0 && verbose { + eprintln!("warning: dropped {dropped} OHLC row(s) from MSN response due to missing fields"); + } + Ok(history) } #[allow(dead_code)] @@ -141,86 +57,27 @@ fn parse_close_only_history_from_str( parse_close_only_history(period, &charts) } -pub(super) fn parse_history_with_verbose( - period: &Period, - charts: &[MsnChart], - verbose: bool, -) -> Result, IdxError> { - let chart = charts.first().ok_or(IdxError::ProviderUnavailable)?; - - if !chart.series.has_real_ohlcv() { - return Err(IdxError::ParseError( - "msn does not expose real OHLC/volume for this history range".to_string(), - )); - } - - let timestamps = &chart.series.time_stamps; - - let mut grouped: BTreeMap = BTreeMap::new(); - let mut dropped = 0usize; - - for (idx, raw_ts) in timestamps.iter().enumerate() { - let Some(date) = parse_chart_date(raw_ts) else { - dropped += 1; - continue; - }; - let point = ( - chart.series.open_prices.get(idx).copied(), - chart.series.prices_high.get(idx).copied(), - chart.series.prices_low.get(idx).copied(), - chart.series.prices.get(idx).copied(), - chart.series.volumes.get(idx).copied(), - ); - - let (Some(open), Some(high), Some(low), Some(close), Some(volume)) = point else { - dropped += 1; - continue; - }; - - let candle = Ohlc { - date, - open: round_price(open), - high: round_price(high), - low: round_price(low), - close: round_price(close), - volume: round_u64(Some(volume)).unwrap_or(0), - }; - - grouped - .entry(date) - .and_modify(|existing| { - existing.high = existing.high.max(candle.high); - existing.low = existing.low.min(candle.low); - existing.close = candle.close; - existing.volume = existing.volume.saturating_add(candle.volume); - }) - .or_insert(candle); - } - - let mut out: Vec = grouped.into_values().collect(); - trim_history_to_period(period, &mut out); - - if dropped > 0 && verbose { - eprintln!("warning: dropped {dropped} OHLC row(s) from MSN response due to missing fields"); - } - - if out.is_empty() { - return Err(IdxError::ProviderUnavailable); - } - - Ok(out) -} - fn parse_close_only_history( period: &Period, charts: &[MsnChart], ) -> Result, IdxError> { let chart = charts.first().ok_or(IdxError::ProviderUnavailable)?; let timestamps = &chart.series.time_stamps; - let mut grouped: BTreeMap = BTreeMap::new(); + let mut grouped: std::collections::BTreeMap = + std::collections::BTreeMap::new(); for (idx, raw_ts) in timestamps.iter().enumerate() { - let Some(date) = parse_chart_date(raw_ts) else { + let Some(date) = chrono::DateTime::parse_from_rfc3339(raw_ts) + .map(|d| d.date_naive()) + .ok() + .or_else(|| { + raw_ts + .parse::() + .ok() + .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0).map(|d| d.date_naive())) + }) + .or_else(|| NaiveDate::parse_from_str(raw_ts, "%Y-%m-%d").ok()) + else { continue; }; let Some(close) = chart.series.prices.get(idx).copied() else { @@ -231,7 +88,7 @@ fn parse_close_only_history( date, ClosePoint { date, - close: round_price(close), + close: close.round() as i64, }, ); } @@ -246,25 +103,6 @@ fn parse_close_only_history( Ok(out) } -fn trim_history_to_period(period: &Period, rows: &mut Vec) { - let days: i64 = match period { - Period::OneDay => return, - Period::FiveDays => 5, - Period::OneMonth => 31, - Period::ThreeMonths => 92, - Period::SixMonths => 183, - Period::OneYear => 366, - Period::TwoYears => 731, - Period::FiveYears => 1826, - }; - - let Some(last_date) = rows.last().map(|item| item.date) else { - return; - }; - let cutoff = last_date - chrono::Duration::days(days.saturating_sub(1)); - rows.retain(|item| item.date >= cutoff); -} - fn trim_close_history_to_period(period: &Period, rows: &mut Vec) { let days: i64 = match period { Period::OneDay => return, @@ -284,260 +122,6 @@ fn trim_close_history_to_period(period: &Period, rows: &mut Vec) { rows.retain(|item| item.date >= cutoff); } -fn preferred_metric(metrics: &[IndustryMetric]) -> Option<&IndustryMetric> { - metrics.iter().max_by_key(|metric| metric_rank(metric)) -} - -fn best_metric_value( - metrics: &[IndustryMetric], - extractor: impl Fn(&IndustryMetric) -> Option, -) -> Option { - metrics - .iter() - .filter_map(|metric| extractor(metric).map(|value| (metric_rank(metric), value))) - .max_by_key(|(rank, _)| *rank) - .map(|(_, value)| value) -} - -fn metric_rank(metric: &IndustryMetric) -> (i32, i32) { - ( - metric - .year - .as_deref() - .and_then(|year| year.parse::().ok()) - .unwrap_or(i32::MIN), - metric_period_priority(metric.fiscal_period_type.as_deref()), - ) -} - -fn metric_period_priority(period: Option<&str>) -> i32 { - match period.map(|value| value.trim()) { - Some(value) if value.eq_ignore_ascii_case("TTM") => 7, - Some(value) - if value.eq_ignore_ascii_case("ANNUAL") - || value.eq_ignore_ascii_case("FY") - || value.eq_ignore_ascii_case("YEAR") => - { - 6 - } - Some(value) if value.eq_ignore_ascii_case("Q4") => 5, - Some(value) if value.eq_ignore_ascii_case("Q3") => 4, - Some(value) if value.eq_ignore_ascii_case("Q2") => 3, - Some(value) if value.eq_ignore_ascii_case("Q1") => 2, - Some(value) if value.eq_ignore_ascii_case("NTM") => 1, - _ => 0, - } -} - -fn normalize_percentish(value: Option) -> Option { - value.and_then(|number| { - if !number.is_finite() { - None - } else if number.abs() > 1.0 { - Some(number / 100.0) - } else { - Some(number) - } - }) -} - -fn sanitize_current_ratio(value: Option) -> Option { - value.and_then(|number| { - if !number.is_finite() || number < 0.01 { - None - } else { - Some(number) - } - }) -} - -#[allow(dead_code)] -#[derive(Clone, Copy)] -pub(super) enum ResampleInterval { - Week, - Month, -} - -#[allow(dead_code)] -pub(super) fn resample_history(rows: &[Ohlc], interval: ResampleInterval) -> Vec { - let mut grouped: BTreeMap<(i32, u32), Ohlc> = BTreeMap::new(); - - for row in rows { - let key = match interval { - ResampleInterval::Week => { - let iso = row.date.iso_week(); - (iso.year(), iso.week()) - } - ResampleInterval::Month => (row.date.year(), row.date.month()), - }; - - grouped - .entry(key) - .and_modify(|existing| { - existing.high = existing.high.max(row.high); - existing.low = existing.low.min(row.low); - existing.close = row.close; - existing.volume = existing.volume.saturating_add(row.volume); - existing.date = row.date; - }) - .or_insert_with(|| row.clone()); - } - - grouped.into_values().collect() -} - -fn parse_chart_date(raw: &str) -> Option { - if let Ok(date) = chrono::DateTime::parse_from_rfc3339(raw) { - return Some(date.date_naive()); - } - if let Ok(timestamp) = raw.parse::() { - return chrono::DateTime::from_timestamp(timestamp, 0).map(|dt| dt.date_naive()); - } - NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok() -} - -fn round_price(value: f64) -> i64 { - value.round() as i64 -} - -fn round_u64(value: Option) -> Option { - value.and_then(|number| { - if !number.is_finite() || number.is_sign_negative() { - None - } else { - Some(number.round() as u64) - } - }) -} - -fn de_opt_f64_lenient<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - #[derive(Deserialize)] - #[serde(untagged)] - enum NumberLike { - F64(f64), - String(String), - } - - let value = Option::::deserialize(deserializer)?; - match value { - Some(NumberLike::F64(number)) if number.is_finite() => Ok(Some(number)), - Some(NumberLike::F64(_)) => Ok(None), - Some(NumberLike::String(raw)) => { - let trimmed = raw.trim(); - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("nan") { - Ok(None) - } else { - trimmed.parse::().map(Some).map_err(D::Error::custom) - } - } - None => Ok(None), - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct MsnQuote { - #[serde(default)] - symbol: Option, - price: Option, - #[serde(default)] - price_change: Option, - #[serde(default)] - price_change_percent: Option, - #[serde(default)] - price_previous_close: Option, - #[serde(default, rename = "price52wHigh")] - price_52w_high: Option, - #[serde(default, rename = "price52wLow")] - price_52w_low: Option, - #[serde(default)] - accumulated_volume: Option, - #[serde(default)] - average_volume: Option, - #[serde(default)] - market_cap: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct KeyRatios { - #[serde(default)] - industry_metrics: Vec, - #[serde(default)] - company_metrics: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct IndustryMetric { - year: Option, - fiscal_period_type: Option, - #[serde(default)] - revenue_growth_rate: Option, - #[serde(default)] - earnings_growth_rate: Option, - #[serde(default, rename = "netIncomeYTDYTDGrowthRate")] - net_income_ytd_ytd_growth_rate: Option, - #[serde(default, rename = "revenueYTDYTD")] - revenue_ytd_ytd: Option, - #[serde(default)] - net_margin: Option, - #[serde(default)] - profit_margin: Option, - #[serde(default)] - roe: Option, - #[serde(default, rename = "roaTTM")] - roa_ttm: Option, - #[serde(default)] - return_on_asset_current: Option, - #[serde(default)] - debt_to_equity_ratio: Option, - #[serde(default, deserialize_with = "de_opt_f64_lenient")] - current_ratio: Option, - #[serde(default)] - price_to_earnings_ratio: Option, - #[serde(default, rename = "forwardPriceToEPS")] - forward_price_to_eps: Option, - #[serde(default)] - price_to_book_ratio: Option, -} - -#[derive(Debug, Deserialize)] -pub(crate) struct MsnChart { - series: ChartSeries, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ChartSeries { - #[serde(default)] - time_stamps: Vec, - #[serde(default)] - prices: Vec, - #[serde(default)] - open_prices: Vec, - #[serde(default)] - prices_high: Vec, - #[serde(default)] - prices_low: Vec, - #[serde(default)] - volumes: Vec, -} - -impl ChartSeries { - fn has_real_ohlcv(&self) -> bool { - !self.time_stamps.is_empty() - && self.open_prices.len() == self.time_stamps.len() - && self.prices_high.len() == self.time_stamps.len() - && self.prices_low.len() == self.time_stamps.len() - && self.prices.len() == self.time_stamps.len() - && self.volumes.len() == self.time_stamps.len() - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ClosePoint { date: NaiveDate, @@ -547,8 +131,8 @@ struct ClosePoint { #[cfg(test)] mod tests { use super::{ - ResampleInterval, parse_close_only_history_from_str, parse_fundamentals_from_str, - parse_history_from_str, parse_quote_from_str, resample_history, + parse_close_only_history_from_str, parse_fundamentals_from_str, parse_history_from_str, + parse_quote_from_str, }; use crate::api::types::{Ohlc, Period}; @@ -657,83 +241,12 @@ mod tests { }, ]; - let weekly = resample_history(&rows, ResampleInterval::Week); + let weekly = + super::super::map::resample_history(&rows, super::super::map::ResampleInterval::Week); assert_eq!(weekly.len(), 2); assert_eq!(weekly[0].open, 100); assert_eq!(weekly[0].close, 109); assert_eq!(weekly[0].volume, 21); assert_eq!(weekly[1].close, 114); } - - #[test] - fn normalizes_live_style_percent_metrics() { - let raw = r#"[ - { - "industryMetrics": [ - { - "year": "2025", - "fiscalPeriodType": "Q1", - "revenueGrowthRate": 9.584679119559473, - "earningsGrowthRate": 28.793562408178182, - "netMargin": 35.05868669243578, - "roe": 16.27117054525313, - "returnOnAssetCurrent": 2.5707368150889867, - "debtToEquityRatio": 32.80253090283387, - "currentRatio": 9.38775908812586E-06, - "priceToEarningsRatio": 21.331183408517173, - "priceToBookRatio": 3.0625539678152234 - }, - { - "year": "2025", - "fiscalPeriodType": "TTM", - "revenueYTDYTD": 0.0481563350951302, - "netIncomeYTDYTDGrowthRate": 0.0492553610240516, - "profitMargin": 0.504190105842766, - "roe": 0.211493, - "roaTTM": 3.7919, - "priceToEarningsRatio": 17.296683642049683, - "priceToSalesRatio": 7.6652108104296985, - "priceToBookRatio": 3.107795874896335 - }, - { - "year": "2025", - "fiscalPeriodType": "NTM", - "forwardPriceToEPS": 14.723 - } - ], - "companyMetrics": [ - { - "year": "2025", - "fiscalPeriodType": "TTM", - "revenueYTDYTD": 0.0481563350951302, - "netIncomeYTDYTDGrowthRate": 0.0492553610240516, - "profitMargin": 0.504190105842766, - "roe": 0.211493, - "roaTTM": 3.7919, - "priceToEarningsRatio": 17.296683642049683, - "priceToBookRatio": 3.107795874896335 - }, - { - "year": "2025", - "fiscalPeriodType": "NTM", - "forwardPriceToEPS": 14.723 - } - ] - } - ]"#; - let quote_raw = r#"[{"symbol":"BBCA","marketCap":866500400000000.0}]"#; - - let fundamentals = - parse_fundamentals_from_str(raw, Some(quote_raw)).expect("fundamentals parsed"); - assert_eq!(fundamentals.trailing_pe, Some(17.296683642049683)); - assert_eq!(fundamentals.forward_pe, Some(14.723)); - assert_eq!(fundamentals.price_to_book, Some(3.107795874896335)); - assert_eq!(fundamentals.return_on_equity, Some(0.211493)); - assert_eq!(fundamentals.profit_margins, Some(0.504190105842766)); - assert_eq!(fundamentals.return_on_assets, Some(0.037919)); - assert_eq!(fundamentals.revenue_growth, Some(0.0481563350951302)); - assert_eq!(fundamentals.earnings_growth, Some(0.0492553610240516)); - assert_eq!(fundamentals.debt_to_equity, None); - assert_eq!(fundamentals.current_ratio, None); - } } diff --git a/src/api/msn/raw_types.rs b/src/api/msn/raw_types.rs new file mode 100644 index 0000000..f12e23c --- /dev/null +++ b/src/api/msn/raw_types.rs @@ -0,0 +1,130 @@ +use serde::de::Error as _; +use serde::{Deserialize, Deserializer}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MsnQuote { + #[serde(default)] + pub(crate) symbol: Option, + pub(crate) price: Option, + #[serde(default)] + pub(crate) price_change: Option, + #[serde(default)] + pub(crate) price_change_percent: Option, + #[serde(default)] + pub(crate) price_previous_close: Option, + #[serde(default, rename = "price52wHigh")] + pub(crate) price_52w_high: Option, + #[serde(default, rename = "price52wLow")] + pub(crate) price_52w_low: Option, + #[serde(default)] + pub(crate) accumulated_volume: Option, + #[serde(default)] + pub(crate) average_volume: Option, + #[serde(default)] + pub(crate) market_cap: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct KeyRatios { + #[serde(default)] + pub(crate) industry_metrics: Vec, + #[serde(default)] + pub(crate) company_metrics: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IndustryMetric { + pub(crate) year: Option, + pub(crate) fiscal_period_type: Option, + #[serde(default)] + pub(crate) revenue_growth_rate: Option, + #[serde(default)] + pub(crate) earnings_growth_rate: Option, + #[serde(default, rename = "netIncomeYTDYTDGrowthRate")] + pub(crate) net_income_ytd_ytd_growth_rate: Option, + #[serde(default, rename = "revenueYTDYTD")] + pub(crate) revenue_ytd_ytd: Option, + #[serde(default)] + pub(crate) net_margin: Option, + #[serde(default)] + pub(crate) profit_margin: Option, + #[serde(default)] + pub(crate) roe: Option, + #[serde(default, rename = "roaTTM")] + pub(crate) roa_ttm: Option, + #[serde(default)] + pub(crate) return_on_asset_current: Option, + #[serde(default)] + pub(crate) debt_to_equity_ratio: Option, + #[serde(default, deserialize_with = "de_opt_f64_lenient")] + pub(crate) current_ratio: Option, + #[serde(default)] + pub(crate) price_to_earnings_ratio: Option, + #[serde(default, rename = "forwardPriceToEPS")] + pub(crate) forward_price_to_eps: Option, + #[serde(default)] + pub(crate) price_to_book_ratio: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct MsnChart { + pub(crate) series: ChartSeries, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChartSeries { + #[serde(default)] + pub(crate) time_stamps: Vec, + #[serde(default)] + pub(crate) prices: Vec, + #[serde(default)] + pub(crate) open_prices: Vec, + #[serde(default)] + pub(crate) prices_high: Vec, + #[serde(default)] + pub(crate) prices_low: Vec, + #[serde(default)] + pub(crate) volumes: Vec, +} + +impl ChartSeries { + pub(crate) fn has_real_ohlcv(&self) -> bool { + !self.time_stamps.is_empty() + && self.open_prices.len() == self.time_stamps.len() + && self.prices_high.len() == self.time_stamps.len() + && self.prices_low.len() == self.time_stamps.len() + && self.prices.len() == self.time_stamps.len() + && self.volumes.len() == self.time_stamps.len() + } +} + +fn de_opt_f64_lenient<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum NumberLike { + F64(f64), + String(String), + } + + let value = Option::::deserialize(deserializer)?; + match value { + Some(NumberLike::F64(number)) if number.is_finite() => Ok(Some(number)), + Some(NumberLike::F64(_)) => Ok(None), + Some(NumberLike::String(raw)) => { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("nan") { + Ok(None) + } else { + trimmed.parse::().map(Some).map_err(D::Error::custom) + } + } + None => Ok(None), + } +} diff --git a/src/api/types.rs b/src/api/types.rs index c110793..90d8682 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -82,6 +82,125 @@ pub struct Fundamentals { pub market_cap: Option, } +pub type Bar = Ohlc; + +// Forward-looking types for planned MSN endpoints — used once capability traits are wired to CLI. +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompanyProfile { + pub id: String, + pub symbol: String, + pub short_name: String, + pub long_name: String, + pub description: String, + pub sector: String, + pub industry: String, + pub website: String, + pub employees: i64, + pub address: String, + pub city: String, + pub country: String, + pub phone: String, + pub officers: Vec, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Officer { + pub name: String, + pub title: String, + pub age: Option, + pub year_born: Option, + pub total_pay: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FinancialStatements { + pub instrument: InstrumentInfo, + pub balance_sheet: Option, + pub cash_flow: Option, + pub income_statement: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstrumentInfo { + pub id: String, + pub symbol: String, + pub name: String, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatementSection { + pub values: std::collections::HashMap, + pub currency: String, + pub report_date: String, + pub end_date: String, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EarningsReport { + pub eps_last_year: f64, + pub revenue_last_year: f64, + pub forecast: Vec, + pub history: Vec, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EarningsData { + pub eps_actual: Option, + pub eps_forecast: Option, + pub eps_surprise: Option, + pub eps_surprise_pct: Option, + pub revenue_actual: Option, + pub revenue_forecast: Option, + pub revenue_surprise: Option, + pub earning_release_date: Option, + pub period_type: String, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SentimentData { + pub symbol: String, + pub statistics: Vec, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SentimentPeriod { + pub time_range: String, + pub bullish: i32, + pub bearish: i32, + pub neutral: i32, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InsightData { + pub id: String, + pub summary: String, + pub highlights: Vec, + pub risks: Vec, + pub last_updated: String, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewsItem { + pub id: String, + pub title: String, + pub url: String, + pub description: String, + pub provider: String, + pub published_at: String, + pub read_time_min: Option, +} + #[derive(Debug, Deserialize)] #[serde(untagged)] enum NumberLike { diff --git a/src/api/yahoo/client.rs b/src/api/yahoo/client.rs index ece144a..b320f30 100644 --- a/src/api/yahoo/client.rs +++ b/src/api/yahoo/client.rs @@ -6,7 +6,7 @@ use std::time::Duration; use crate::api::types::{Interval, Period}; use crate::error::IdxError; -use super::parse::{ChartResponse, QuoteSummaryResponse}; +use super::raw_types::{ChartResponse, QuoteSummaryResponse}; const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; const BASE_URL: &str = "https://query2.finance.yahoo.com"; @@ -42,7 +42,7 @@ impl YahooClient { fn quote_summary_url(symbol: &str, crumb: &str) -> String { format!( - "{BASE_URL}/v10/finance/quoteSummary/{symbol}?modules=defaultKeyStatistics,financialData,incomeStatementHistory&crumb={crumb}" + "{BASE_URL}/v10/finance/quoteSummary/{symbol}?modules=summaryDetail,defaultKeyStatistics,financialData,assetProfile,incomeStatementHistory&crumb={crumb}" ) } @@ -262,8 +262,15 @@ impl YahooClient { ) -> Result { for auth_attempt in 0..2 { let crumb = self.get_or_init_crumb()?; - let cookie_header = - Self::cookie_header_from_jar(&Self::cookie_jar_path()).unwrap_or_default(); + let cookie_header = match Self::cookie_header_from_jar(&Self::cookie_jar_path()) { + Ok(header) => header, + Err(err) => { + eprintln!("warning: failed to parse Yahoo cookie jar: {err}"); + return Err(IdxError::AuthError(format!( + "failed to parse Yahoo cookies: {err}" + ))); + } + }; let url = Self::quote_summary_url(symbol, &crumb); let mut wait = Duration::from_millis(250); diff --git a/src/api/yahoo/map.rs b/src/api/yahoo/map.rs new file mode 100644 index 0000000..aef8aed --- /dev/null +++ b/src/api/yahoo/map.rs @@ -0,0 +1,209 @@ +use crate::api::types::{Fundamentals, Ohlc, Quote}; +use crate::error::IdxError; + +use super::raw_types::{ChartError, ChartResponse, QuoteSummaryResponse}; + +pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result { + if let Some(err) = chart.chart.error.as_ref() { + return Err(map_yahoo_error(symbol, "chart", err)); + } + + let result = chart + .chart + .result + .as_ref() + .and_then(|r| r.first()) + .ok_or(IdxError::ProviderUnavailable)?; + let meta = result.meta.as_ref().ok_or(IdxError::ProviderUnavailable)?; + let raw_price = meta + .regular_market_price + .ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?; + let raw_prev_close = meta.previous_close.or(meta.chart_previous_close); + + let price = round_price(raw_price); + let prev_close = raw_prev_close.map(round_price); + let change = prev_close.map_or(0, |p| price - p); + let change_pct = raw_prev_close.map_or(0.0, |p| { + if p != 0.0 { + ((raw_price - p) / p) * 100.0 + } else { + 0.0 + } + }); + + let (week52_position, range_signal) = match (meta.fifty_two_week_low, meta.fifty_two_week_high) + { + (Some(low), Some(high)) if high > low => { + let pos = (raw_price - low) / (high - low); + let signal = if pos > 0.66 { + "upper" + } else if pos < 0.33 { + "lower" + } else { + "middle" + }; + (Some(pos), Some(signal.to_string())) + } + _ => (None, None), + }; + + Ok(Quote { + symbol: meta.symbol.clone().unwrap_or_else(|| symbol.to_string()), + price, + change, + change_pct, + volume: meta.regular_market_volume.unwrap_or(0), + market_cap: meta.market_cap, + week52_high: meta.fifty_two_week_high.map(round_price), + week52_low: meta.fifty_two_week_low.map(round_price), + week52_position, + range_signal, + prev_close, + avg_volume: meta.average_daily_volume_3month, + }) +} + +pub(super) fn parse_history( + symbol: &str, + chart: &ChartResponse, +) -> Result<(Vec, usize), IdxError> { + if let Some(err) = chart.chart.error.as_ref() { + return Err(map_yahoo_error(symbol, "chart", err)); + } + + let result = chart + .chart + .result + .as_ref() + .and_then(|r| r.first()) + .ok_or(IdxError::ProviderUnavailable)?; + let timestamps = result + .timestamp + .as_ref() + .ok_or(IdxError::ProviderUnavailable)?; + let quote = result + .indicators + .as_ref() + .and_then(|i| i.quote.as_ref()) + .and_then(|q| q.first()) + .ok_or(IdxError::ProviderUnavailable)?; + + let mut out = Vec::new(); + let mut dropped = 0usize; + for (i, ts) in timestamps.iter().enumerate() { + let open = quote + .open + .as_ref() + .and_then(|v| v.get(i).copied().flatten()) + .map(round_price); + let high = quote + .high + .as_ref() + .and_then(|v| v.get(i).copied().flatten()) + .map(round_price); + let low = quote + .low + .as_ref() + .and_then(|v| v.get(i).copied().flatten()) + .map(round_price); + let close = quote + .close + .as_ref() + .and_then(|v| v.get(i).copied().flatten()) + .map(round_price); + let volume = quote + .volume + .as_ref() + .and_then(|v| v.get(i).copied().flatten()); + + if let (Some(open), Some(high), Some(low), Some(close), Some(volume)) = + (open, high, low, close, volume) + && let Some(dt) = chrono::DateTime::from_timestamp(*ts, 0) + { + out.push(Ohlc { + date: dt.date_naive(), + open, + high, + low, + close, + volume, + }); + } else { + dropped += 1; + } + } + + Ok((out, dropped)) +} + +pub(super) fn parse_fundamentals( + symbol: &str, + quote_summary: &QuoteSummaryResponse, +) -> Result { + if let Some(err) = quote_summary.quote_summary.error.as_ref() { + return Err(map_yahoo_error(symbol, "quoteSummary", err)); + } + + let result = quote_summary + .quote_summary + .result + .as_ref() + .and_then(|results| results.first()) + .ok_or(IdxError::ProviderUnavailable)?; + + let stats = result.default_key_statistics.as_ref(); + let fin = result.financial_data.as_ref(); + let summary = result.summary_detail.as_ref(); + + Ok(Fundamentals { + trailing_pe: stats + .and_then(|s| s.trailing_pe.as_ref().and_then(|v| v.raw)) + .or_else(|| fin.and_then(|f| f.trailing_pe.as_ref().and_then(|v| v.raw))) + .or_else(|| summary.and_then(|s| s.trailing_pe.as_ref().and_then(|v| v.raw))), + forward_pe: stats + .and_then(|s| s.forward_pe.as_ref().and_then(|v| v.raw)) + .or_else(|| fin.and_then(|f| f.forward_pe.as_ref().and_then(|v| v.raw))) + .or_else(|| summary.and_then(|s| s.forward_pe.as_ref().and_then(|v| v.raw))), + price_to_book: stats + .and_then(|s| s.price_to_book.as_ref().and_then(|v| v.raw)) + .or_else(|| fin.and_then(|f| f.price_to_book.as_ref().and_then(|v| v.raw))) + .or_else(|| summary.and_then(|s| s.price_to_book.as_ref().and_then(|v| v.raw))), + return_on_equity: fin.and_then(|f| f.return_on_equity.as_ref().and_then(|v| v.raw)), + profit_margins: fin.and_then(|f| f.profit_margins.as_ref().and_then(|v| v.raw)), + return_on_assets: fin.and_then(|f| f.return_on_assets.as_ref().and_then(|v| v.raw)), + revenue_growth: fin.and_then(|f| f.revenue_growth.as_ref().and_then(|v| v.raw)), + earnings_growth: stats + .and_then(|s| s.earnings_growth.as_ref().and_then(|v| v.raw)) + .or_else(|| fin.and_then(|f| f.earnings_growth.as_ref().and_then(|v| v.raw))), + debt_to_equity: fin.and_then(|f| f.debt_to_equity.as_ref().and_then(|v| v.raw)), + current_ratio: fin.and_then(|f| f.current_ratio.as_ref().and_then(|v| v.raw)), + enterprise_value: stats + .and_then(|s| s.enterprise_value.as_ref().and_then(|v| v.raw)) + .or_else(|| fin.and_then(|f| f.enterprise_value.as_ref().and_then(|v| v.raw))), + ebitda: fin + .and_then(|f| f.ebitda.as_ref().and_then(|v| v.raw)) + .or_else(|| stats.and_then(|s| s.ebitda.as_ref().and_then(|v| v.raw))), + market_cap: fin + .and_then(|f| f.market_cap.as_ref().and_then(|v| v.raw)) + .or_else(|| stats.and_then(|s| s.market_cap.as_ref().and_then(|v| v.raw))) + .or_else(|| { + summary + .and_then(|s| s.market_cap.as_ref().and_then(|v| v.raw)) + .map(|n| n.round() as u64) + }), + }) +} + +fn round_price(value: f64) -> i64 { + value.round() as i64 +} + +pub(super) fn map_yahoo_error(symbol: &str, endpoint: &str, err: &ChartError) -> IdxError { + if err.code.eq_ignore_ascii_case("Not Found") { + return IdxError::SymbolNotFound(symbol.to_string()); + } + IdxError::Http(format!( + "yahoo {endpoint} error {}: {}", + err.code, err.description + )) +} diff --git a/src/api/yahoo/mod.rs b/src/api/yahoo/mod.rs index e87ecfe..a3f7f3b 100644 --- a/src/api/yahoo/mod.rs +++ b/src/api/yahoo/mod.rs @@ -1,12 +1,15 @@ mod client; +mod map; mod parse; +mod raw_types; -use crate::api::MarketDataProvider; -use crate::api::types::{Fundamentals, Interval, Ohlc, Period, Quote}; +use crate::api::types::{Bar, Fundamentals, Interval, Period, Quote}; +use crate::api::{FundamentalsProvider, HistoryProvider, QuoteProvider}; use crate::error::IdxError; use client::YahooClient; -use parse::{parse_fundamentals, parse_history_with_verbose, parse_quote}; +use map::{parse_fundamentals, parse_quote}; +use parse::parse_history_with_verbose; pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str}; @@ -24,26 +27,30 @@ impl YahooProvider { } } -impl MarketDataProvider for YahooProvider { +impl QuoteProvider for YahooProvider { fn quote(&self, symbol: &str) -> Result { let chart = self .client .fetch_chart(symbol, &Period::OneDay, &Interval::Day)?; parse_quote(symbol, &chart) } +} +impl FundamentalsProvider for YahooProvider { fn fundamentals(&self, symbol: &str) -> Result { let quote_summary = self.client.fetch_quote_summary(symbol)?; parse_fundamentals(symbol, "e_summary) } +} +impl HistoryProvider for YahooProvider { fn history( &self, symbol: &str, period: &Period, interval: &Interval, - ) -> Result, IdxError> { + ) -> Result, IdxError> { let chart = self.client.fetch_chart(symbol, period, interval)?; - parse_history_with_verbose(&chart, self.verbose) + parse_history_with_verbose(symbol, &chart, self.verbose) } } diff --git a/src/api/yahoo/parse.rs b/src/api/yahoo/parse.rs index 6907190..deafa55 100644 --- a/src/api/yahoo/parse.rs +++ b/src/api/yahoo/parse.rs @@ -1,83 +1,19 @@ -use std::collections::HashMap; - -use serde::Deserialize; - use crate::api::types::{Fundamentals, Ohlc, Quote}; use crate::error::IdxError; +use super::map::{parse_fundamentals, parse_history, parse_quote}; +use super::raw_types::{ChartResponse, QuoteSummaryResponse}; + pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result { let chart: ChartResponse = serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; - if let Some(err) = chart.chart.error.as_ref() { - return Err(map_yahoo_error(symbol, "chart", err)); - } parse_quote(symbol, &chart) } -pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result { - if let Some(err) = chart.chart.error.as_ref() { - return Err(map_yahoo_error(symbol, "chart", err)); - } - - let result = chart - .chart - .result - .as_ref() - .and_then(|r| r.first()) - .ok_or(IdxError::ProviderUnavailable)?; - let meta = result.meta.as_ref().ok_or(IdxError::ProviderUnavailable)?; - let raw_price = meta - .regular_market_price - .ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?; - let raw_prev_close = meta.previous_close.or(meta.chart_previous_close); - - let price = round_price(raw_price); - let prev_close = raw_prev_close.map(round_price); - let change = prev_close.map_or(0, |p| price - p); - let change_pct = raw_prev_close.map_or(0.0, |p| { - if p != 0.0 { - ((raw_price - p) / p) * 100.0 - } else { - 0.0 - } - }); - - let (week52_position, range_signal) = match (meta.fifty_two_week_low, meta.fifty_two_week_high) - { - (Some(low), Some(high)) if high > low => { - let pos = (raw_price - low) / (high - low); - let signal = if pos > 0.66 { - "upper" - } else if pos < 0.33 { - "lower" - } else { - "middle" - }; - (Some(pos), Some(signal.to_string())) - } - _ => (None, None), - }; - - Ok(Quote { - symbol: meta.symbol.clone().unwrap_or_else(|| symbol.to_string()), - price, - change, - change_pct, - volume: meta.regular_market_volume.unwrap_or(0), - market_cap: meta.market_cap, - week52_high: meta.fifty_two_week_high.map(round_price), - week52_low: meta.fifty_two_week_low.map(round_price), - week52_position, - range_signal, - prev_close, - avg_volume: meta.average_daily_volume_3month, - }) -} - -pub(crate) fn parse_history_from_str(raw: &str) -> Result, IdxError> { +pub(crate) fn parse_history_from_str(symbol: &str, raw: &str) -> Result, IdxError> { let chart: ChartResponse = serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; - parse_history_with_verbose(&chart, false) + parse_history_with_verbose(symbol, &chart, false) } pub(crate) fn parse_fundamentals_from_str( @@ -86,327 +22,21 @@ pub(crate) fn parse_fundamentals_from_str( ) -> Result { let quote_summary: QuoteSummaryResponse = serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; - if let Some(err) = quote_summary.quote_summary.error.as_ref() { - return Err(map_yahoo_error(symbol, "quoteSummary", err)); - } parse_fundamentals(symbol, "e_summary) } pub(super) fn parse_history_with_verbose( + symbol: &str, chart: &ChartResponse, verbose: bool, ) -> Result, IdxError> { - if let Some(err) = chart.chart.error.as_ref() { - return Err(map_yahoo_error("unknown", "chart", err)); - } - - let result = chart - .chart - .result - .as_ref() - .and_then(|r| r.first()) - .ok_or(IdxError::ProviderUnavailable)?; - let timestamps = result - .timestamp - .as_ref() - .ok_or(IdxError::ProviderUnavailable)?; - let quote = result - .indicators - .as_ref() - .and_then(|i| i.quote.as_ref()) - .and_then(|q| q.first()) - .ok_or(IdxError::ProviderUnavailable)?; - - let mut out = Vec::new(); - let mut dropped = 0usize; - for (i, ts) in timestamps.iter().enumerate() { - let open = quote - .open - .as_ref() - .and_then(|v| v.get(i).copied().flatten()) - .map(round_price); - let high = quote - .high - .as_ref() - .and_then(|v| v.get(i).copied().flatten()) - .map(round_price); - let low = quote - .low - .as_ref() - .and_then(|v| v.get(i).copied().flatten()) - .map(round_price); - let close = quote - .close - .as_ref() - .and_then(|v| v.get(i).copied().flatten()) - .map(round_price); - let volume = quote - .volume - .as_ref() - .and_then(|v| v.get(i).copied().flatten()); - - if let (Some(open), Some(high), Some(low), Some(close), Some(volume)) = - (open, high, low, close, volume) - && let Some(dt) = chrono::DateTime::from_timestamp(*ts, 0) - { - out.push(Ohlc { - date: dt.date_naive(), - open, - high, - low, - close, - volume, - }); - } else { - dropped += 1; - } - } - + let (history, dropped) = parse_history(symbol, chart)?; if dropped > 0 && verbose { eprintln!( "warning: dropped {dropped} OHLC row(s) from Yahoo response due to missing fields" ); } - - Ok(out) -} - -pub(super) fn parse_fundamentals( - symbol: &str, - quote_summary: &QuoteSummaryResponse, -) -> Result { - if let Some(err) = quote_summary.quote_summary.error.as_ref() { - return Err(map_yahoo_error(symbol, "quoteSummary", err)); - } - - let result = quote_summary - .quote_summary - .result - .as_ref() - .and_then(|results| results.first()) - .ok_or(IdxError::ProviderUnavailable)?; - - Ok(Fundamentals { - trailing_pe: result - .default_key_statistics - .get_f64("trailingPE") - .or_else(|| result.financial_data.get_f64("trailingPE")), - forward_pe: result - .default_key_statistics - .get_f64("forwardPE") - .or_else(|| result.financial_data.get_f64("forwardPE")), - price_to_book: result - .default_key_statistics - .get_f64("priceToBook") - .or_else(|| result.financial_data.get_f64("priceToBook")), - return_on_equity: result.financial_data.get_f64("returnOnEquity"), - profit_margins: result.financial_data.get_f64("profitMargins"), - return_on_assets: result.financial_data.get_f64("returnOnAssets"), - revenue_growth: result.financial_data.get_f64("revenueGrowth"), - earnings_growth: result - .default_key_statistics - .get_f64("earningsGrowth") - .or_else(|| result.financial_data.get_f64("earningsGrowth")), - debt_to_equity: result.financial_data.get_f64("debtToEquity"), - current_ratio: result.financial_data.get_f64("currentRatio"), - enterprise_value: result - .default_key_statistics - .get_i64("enterpriseValue") - .or_else(|| result.financial_data.get_i64("enterpriseValue")), - ebitda: result - .financial_data - .get_i64("ebitda") - .or_else(|| result.default_key_statistics.get_i64("ebitda")), - market_cap: result - .financial_data - .get_u64("marketCap") - .or_else(|| result.default_key_statistics.get_u64("marketCap")), - }) -} - -fn round_price(value: f64) -> i64 { - value.round() as i64 -} - -pub(super) fn map_yahoo_error(symbol: &str, endpoint: &str, err: &ChartError) -> IdxError { - if err.code.eq_ignore_ascii_case("Not Found") { - return IdxError::SymbolNotFound(symbol.to_string()); - } - IdxError::Http(format!( - "yahoo {endpoint} error {}: {}", - err.code, err.description - )) -} - -#[derive(Debug, Deserialize)] -pub(super) struct ChartResponse { - chart: ChartRoot, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct QuoteSummaryResponse { - quote_summary: QuoteSummaryRoot, -} - -#[derive(Debug, Deserialize)] -pub(super) struct QuoteSummaryRoot { - result: Option>, - error: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct QuoteSummaryResult { - #[serde(default)] - default_key_statistics: QuoteSummarySection, - #[serde(default)] - financial_data: QuoteSummarySection, -} - -#[derive(Debug, Deserialize)] -pub(super) struct ChartRoot { - result: Option>, - error: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct ChartError { - code: String, - description: String, -} - -#[derive(Debug, Deserialize)] -pub(super) struct ChartResult { - meta: Option, - timestamp: Option>, - indicators: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -#[allow(dead_code)] -pub(super) struct ChartMeta { - symbol: Option, - regular_market_price: Option, - previous_close: Option, - chart_previous_close: Option, - regular_market_volume: Option, - regular_market_day_high: Option, - regular_market_day_low: Option, - market_cap: Option, - fifty_two_week_high: Option, - fifty_two_week_low: Option, - #[serde(rename = "averageDailyVolume3Month")] - average_daily_volume_3month: Option, -} - -#[derive(Debug, Deserialize)] -pub(super) struct Indicators { - quote: Option>, -} - -#[derive(Debug, Deserialize)] -pub(super) struct IndicatorQuote { - open: Option>>, - high: Option>>, - low: Option>>, - close: Option>>, - volume: Option>>, -} - -type QuoteSummarySection = HashMap; - -trait QuoteSummarySectionExt { - fn get_f64(&self, key: &str) -> Option; - fn get_i64(&self, key: &str) -> Option; - fn get_u64(&self, key: &str) -> Option; -} - -impl QuoteSummarySectionExt for QuoteSummarySection { - fn get_f64(&self, key: &str) -> Option { - self.get(key).and_then(QuoteSummaryValue::as_f64) - } - - fn get_i64(&self, key: &str) -> Option { - self.get(key).and_then(QuoteSummaryValue::as_i64) - } - - fn get_u64(&self, key: &str) -> Option { - self.get(key).and_then(QuoteSummaryValue::as_u64) - } -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -#[allow(dead_code)] -enum QuoteSummaryValue { - Wrapped { raw: Option }, - Direct(YahooNumber), - // Catch-all for empty objects {}, null, strings, booleans; return None for numeric extractions. - Unknown(serde_json::Value), -} - -impl QuoteSummaryValue { - fn as_f64(&self) -> Option { - match self { - Self::Wrapped { raw } => raw.as_ref().map(YahooNumber::as_f64), - Self::Direct(value) => Some(value.as_f64()), - Self::Unknown(_) => None, - } - } - - fn as_i64(&self) -> Option { - match self { - Self::Wrapped { raw } => raw.as_ref().and_then(YahooNumber::as_i64), - Self::Direct(value) => value.as_i64(), - Self::Unknown(_) => None, - } - } - - fn as_u64(&self) -> Option { - match self { - Self::Wrapped { raw } => raw.as_ref().and_then(YahooNumber::as_u64), - Self::Direct(value) => value.as_u64(), - Self::Unknown(_) => None, - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum YahooNumber { - I64(i64), - U64(u64), - F64(f64), -} - -impl YahooNumber { - fn as_f64(&self) -> f64 { - match self { - Self::I64(value) => *value as f64, - Self::U64(value) => *value as f64, - Self::F64(value) => *value, - } - } - - fn as_i64(&self) -> Option { - match self { - Self::I64(value) => Some(*value), - Self::U64(value) => i64::try_from(*value).ok(), - Self::F64(value) => Some(value.round() as i64), - } - } - - fn as_u64(&self) -> Option { - match self { - Self::I64(value) => u64::try_from(*value).ok(), - Self::U64(value) => Some(*value), - Self::F64(value) if value.is_sign_negative() => None, - Self::F64(value) => Some(value.round() as u64), - } - } + Ok(history) } #[cfg(test)] @@ -447,7 +77,7 @@ mod tests { let quote = parse_quote("BBCA.JK", &chart).expect("quote parsed"); assert_eq!(quote.symbol, "BBCA.JK"); assert_eq!(quote.price, 9875); - let history = parse_history_with_verbose(&chart, false).expect("history parsed"); + let history = parse_history_with_verbose("BBCA.JK", &chart, false).expect("history parsed"); assert_eq!(history.len(), 2); assert_eq!(history[0].close, 9875); } @@ -466,7 +96,8 @@ mod tests { assert_eq!(quote.market_cap, Some(1_215_200_000_000_000)); assert_eq!(quote.avg_volume, Some(10_000_000)); - let history = parse_history_from_str(&history_raw).expect("fixture history parsed"); + let history = + parse_history_from_str("BBCA.JK", &history_raw).expect("fixture history parsed"); assert!(!history.is_empty()); let fundamentals = parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw) diff --git a/src/api/yahoo/raw_types.rs b/src/api/yahoo/raw_types.rs new file mode 100644 index 0000000..5fe70f8 --- /dev/null +++ b/src/api/yahoo/raw_types.rs @@ -0,0 +1,156 @@ +// Raw serde structs for Yahoo API responses. Fields not yet consumed by map.rs are +// retained for future fundamentals expansion; suppress dead_code for forward-compat. +#![allow(dead_code)] + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub(super) struct ChartResponse { + pub(super) chart: ChartRoot, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct QuoteSummaryResponse { + pub(super) quote_summary: QuoteSummaryRoot, +} + +#[derive(Debug, Deserialize)] +pub(super) struct QuoteSummaryRoot { + pub(super) result: Option>, + pub(super) error: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct QuoteSummaryResult { + #[serde(default)] + pub(super) summary_detail: Option, + #[serde(default)] + pub(super) default_key_statistics: Option, + #[serde(default)] + pub(super) financial_data: Option, + #[serde(default)] + pub(super) asset_profile: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ChartRoot { + pub(super) result: Option>, + pub(super) error: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ChartError { + pub(super) code: String, + pub(super) description: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ChartResult { + pub(super) meta: Option, + pub(super) timestamp: Option>, + pub(super) indicators: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub(super) struct ChartMeta { + pub(super) symbol: Option, + pub(super) regular_market_price: Option, + pub(super) previous_close: Option, + pub(super) chart_previous_close: Option, + pub(super) regular_market_volume: Option, + pub(super) regular_market_day_high: Option, + pub(super) regular_market_day_low: Option, + pub(super) market_cap: Option, + pub(super) fifty_two_week_high: Option, + pub(super) fifty_two_week_low: Option, + #[serde(rename = "averageDailyVolume3Month")] + pub(super) average_daily_volume_3month: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Indicators { + pub(super) quote: Option>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct IndicatorQuote { + pub(super) open: Option>>, + pub(super) high: Option>>, + pub(super) low: Option>>, + pub(super) close: Option>>, + pub(super) volume: Option>>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SummaryDetail { + #[serde(rename = "trailingPE")] + pub trailing_pe: Option, + #[serde(rename = "forwardPE")] + pub forward_pe: Option, + pub price_to_book: Option, + pub dividend_yield: Option, + pub market_cap: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DefaultKeyStatistics { + #[serde(rename = "trailingPE")] + pub trailing_pe: Option, + #[serde(rename = "forwardPE")] + pub forward_pe: Option, + pub price_to_book: Option, + pub earnings_growth: Option, + pub enterprise_value: Option, + pub ebitda: Option, + pub market_cap: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FinancialData { + #[serde(rename = "trailingPE")] + pub trailing_pe: Option, + #[serde(rename = "forwardPE")] + pub forward_pe: Option, + pub price_to_book: Option, + pub return_on_equity: Option, + pub profit_margins: Option, + pub return_on_assets: Option, + pub revenue_growth: Option, + pub earnings_growth: Option, + pub debt_to_equity: Option, + pub current_ratio: Option, + pub enterprise_value: Option, + pub ebitda: Option, + pub market_cap: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetProfile { + pub sector: Option, + pub industry: Option, + pub long_business_summary: Option, +} + +#[derive(Debug, Deserialize)] +pub struct FloatValue { + pub raw: Option, +} + +#[derive(Debug, Deserialize)] +pub struct IntValue { + pub raw: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UIntValue { + pub raw: Option, +} diff --git a/src/cache.rs b/src/cache.rs index 046cc04..59432bf 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::error::IdxError; -const SCHEMA_VERSION: u32 = 1; +const CURRENT_SCHEMA_VERSION: u32 = 1; #[derive(Debug, Clone)] pub struct Cache { @@ -83,7 +83,7 @@ impl Cache { let entry = CacheEntry { fetched_at: Utc::now(), ttl_secs, - schema_version: SCHEMA_VERSION, + schema_version: CURRENT_SCHEMA_VERSION, data, }; let raw = serde_json::to_string_pretty(&entry) @@ -124,17 +124,21 @@ impl Cache { }) } - pub fn clear(&self) -> Result { + pub fn clear(&self) -> Result<(usize, Vec), IdxError> { if !self.root.exists() { - return Ok(0); + return Ok((0, Vec::new())); } let mut removed = 0usize; + let mut failed = Vec::new(); self.walk(&self.root, &mut |p| { - if p.is_file() && fs::remove_file(p).is_ok() { - removed += 1; + if p.is_file() { + match fs::remove_file(p) { + Ok(_) => removed += 1, + Err(_) => failed.push(p.to_path_buf()), + } } })?; - Ok(removed) + Ok((removed, failed)) } fn walk(&self, dir: &Path, f: &mut F) -> Result<(), IdxError> { @@ -159,8 +163,19 @@ impl Cache { if !path.exists() { return Ok(None); } - let raw = fs::read_to_string(path).map_err(|e| IdxError::Io(e.to_string()))?; - let entry = serde_json::from_str(&raw).map_err(|e| IdxError::ParseError(e.to_string()))?; + let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?; + let entry: CacheEntry = + serde_json::from_str(&raw).map_err(|e| IdxError::ParseError(e.to_string()))?; + if entry.schema_version != CURRENT_SCHEMA_VERSION { + eprintln!( + "debug: cache schema mismatch for {} (got {}, expected {})", + path.display(), + entry.schema_version, + CURRENT_SCHEMA_VERSION + ); + let _ = fs::remove_file(&path); + return Ok(None); + } Ok(Some(entry)) } diff --git a/src/cli/cache.rs b/src/cli/cache.rs index 2717162..6181727 100644 --- a/src/cli/cache.rs +++ b/src/cli/cache.rs @@ -40,8 +40,11 @@ pub fn handle(cmd: &CacheCmd) -> Result<(), IdxError> { ); } CacheSubcommand::Clear => { - let removed = cache.clear()?; + let (removed, failed) = cache.clear()?; println!("cleared {removed} files"); + if !failed.is_empty() { + eprintln!("warning: failed to remove {} file(s)", failed.len()); + } } } Ok(()) diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 9f1af5c..0ba8540 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -345,6 +345,40 @@ pub fn handle( } } +#[allow(dead_code)] // wired up once per-subcommand handlers are fully split +pub(crate) fn fetch_with_cache( + cache: &Cache, + bucket: &str, + key: &str, + ttl_secs: u64, + offline: bool, + no_cache: bool, + fetch_fn: F, +) -> Result +where + T: Serialize + DeserializeOwned, + F: FnOnce() -> Result, +{ + if !no_cache + && !offline + && let Some(cached) = cache.get::(bucket, key)? + { + return Ok(cached); + } + + if offline { + return cache + .get_stale::(bucket, key)? + .ok_or_else(|| IdxError::Offline("no cached data available".to_string())); + } + + let data = fetch_fn()?; + if !no_cache { + let _ = cache.put(bucket, key, &data, ttl_secs); + } + Ok(data) +} + fn fetch_fundamental_analysis_report( cache: &Cache, provider: &dyn MarketDataProvider, diff --git a/src/config.rs b/src/config.rs index ef1f63d..c58f59c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -92,8 +92,13 @@ impl IdxConfig { if let Ok(output) = std::env::var("IDX_OUTPUT") { cfg.output = if output.eq_ignore_ascii_case("json") { OutputFormat::Json - } else { + } else if output.eq_ignore_ascii_case("table") { OutputFormat::Table + } else { + return Err(IdxError::ConfigError(format!( + "invalid IDX_OUTPUT value: '{}', expected 'json' or 'table'", + output + ))); }; } if let Ok(no_color) = std::env::var("IDX_NO_COLOR") { diff --git a/src/error.rs b/src/error.rs index e23c960..c659aa8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,12 +16,16 @@ pub enum IdxError { ParseError(String), #[error("cache miss: {0}")] CacheMiss(String), + #[error("offline: {0}")] + Offline(String), #[error("config error: {0}")] ConfigError(String), #[error("io error: {0}")] Io(String), #[error("http error: {0}")] Http(String), + #[error("auth error: {0}")] + AuthError(String), } #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] @@ -32,9 +36,11 @@ pub enum ErrorCode { Unsupported, ParseError, CacheMiss, + Offline, ConfigError, Io, Http, + AuthError, } impl IdxError { @@ -46,9 +52,11 @@ impl IdxError { Self::Unsupported(_) => ErrorCode::Unsupported, Self::ParseError(_) => ErrorCode::ParseError, Self::CacheMiss(_) => ErrorCode::CacheMiss, + Self::Offline(_) => ErrorCode::Offline, Self::ConfigError(_) => ErrorCode::ConfigError, Self::Io(_) => ErrorCode::Io, Self::Http(_) => ErrorCode::Http, + Self::AuthError(_) => ErrorCode::AuthError, } }