From 6d6d51a04f5e9b02f95efdc27e10da447b7d9854 Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:55:35 +0000 Subject: [PATCH] =?UTF-8?q?refactor(msn):=20remove=20HistoryProvider=20?= =?UTF-8?q?=E2=80=94=20Finance/Charts=20404s=20on=20all=20IDX=20stocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MSN Finance/Charts does not serve OHLCV data for XIDX stocks (returns 404). Following the FP principle of not exposing capabilities a provider cannot fulfil: - Remove impl HistoryProvider for MsnProvider entirely - Remove fetch_charts from MsnClient - Remove parse_chart_history, resample_history, trim_history_to_period from map.rs - Remove MsnChart, ChartSeries, RawChart from raw_types.rs - Remove parse_history_from_str, parse_close_only_history from parse.rs - Decouple HistoryProvider from MarketDataProvider trait bound - Add history_provider() factory: returns None for MSN, Some(Yahoo) for Yahoo - CLI gates History/Technical on history_provider(), fails fast for MSN - MSN mock returns Err(Unsupported); tests verify the behaviour explicitly --- src/api/mod.rs | 28 ++++-- src/api/msn/client.rs | 18 +--- src/api/msn/map.rs | 197 +------------------------------------- src/api/msn/mod.rs | 53 ++--------- src/api/msn/parse.rs | 199 +-------------------------------------- src/api/msn/raw_types.rs | 35 ------- src/cli/stocks.rs | 20 +++- tests/cli.rs | 25 +++-- 8 files changed, 71 insertions(+), 504 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 302c4dd..7996d99 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -26,8 +26,11 @@ pub trait FundamentalsProvider { fn fundamentals(&self, symbol: &str) -> Result; } -pub trait MarketDataProvider: QuoteProvider + FundamentalsProvider + HistoryProvider {} -impl MarketDataProvider for T where T: QuoteProvider + FundamentalsProvider + HistoryProvider {} +/// Core provider trait — quote + fundamentals only. +/// History is a separate capability (`HistoryProvider`) not all providers support +/// (e.g. MSN Finance/Charts returns 404 for IDX/XIDX stocks). +pub trait MarketDataProvider: QuoteProvider + FundamentalsProvider {} +impl MarketDataProvider for T where T: QuoteProvider + FundamentalsProvider {} #[allow(dead_code)] pub trait ProfileProvider { @@ -81,6 +84,18 @@ pub fn default_provider(provider: ProviderKind, verbose: bool) -> Box Option> { + if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() { + return Some(Box::new(MockProvider::from_fixtures(provider))); + } + match provider { + ProviderKind::Yahoo => Some(Box::new(yahoo::YahooProvider::new(verbose))), + ProviderKind::Msn => None, + } +} + pub struct MockProvider { quote: Result, fundamentals: Result, @@ -124,8 +139,6 @@ impl MockProvider { fn from_msn_fixtures() -> Self { let quote_raw = std::fs::read_to_string("tests/fixtures/msn_quote_bbca.json") .unwrap_or_else(|_| "[]".to_string()); - let history_raw = std::fs::read_to_string("tests/fixtures/msn_chart_bbca_3mo.json") - .unwrap_or_else(|_| "[]".to_string()); let fundamentals_raw = std::fs::read_to_string("tests/fixtures/msn_keyratios_bbca.json") .unwrap_or_else(|_| "[]".to_string()); @@ -133,9 +146,10 @@ impl MockProvider { .map_err(|e| IdxError::ParseError(e.to_string())); let fundamentals = msn::parse_fundamentals_from_str(&fundamentals_raw, Some("e_raw)) .map_err(|e| IdxError::ParseError(e.to_string())); - let history = - msn::parse_history_from_str(&crate::api::types::Period::ThreeMonths, &history_raw) - .map_err(|e| IdxError::ParseError(e.to_string())); + // MSN Finance/Charts returns 404 for IDX (XIDX) — history not supported + let history = Err(IdxError::Unsupported( + "MSN does not provide price history for IDX stocks. Use --provider yahoo.".into(), + )); Self { quote, diff --git a/src/api/msn/client.rs b/src/api/msn/client.rs index 811da3a..6823c79 100644 --- a/src/api/msn/client.rs +++ b/src/api/msn/client.rs @@ -6,9 +6,8 @@ use serde::de::DeserializeOwned; use crate::error::IdxError; use super::raw_types::{ - KeyRatios, MsnQuote, RawChart, RawEarningsResponse, RawEquity, RawFinancialStatement, - RawInsight, RawNewsFeed, RawScreenerResponse, RawSentiment, ScreenerFilter, ScreenerOrder, - ScreenerRequest, + KeyRatios, MsnQuote, RawEarningsResponse, RawEquity, RawFinancialStatement, RawInsight, + RawNewsFeed, RawScreenerResponse, RawSentiment, ScreenerFilter, ScreenerOrder, ScreenerRequest, }; use super::symbols::resolve_msn_id; @@ -182,19 +181,6 @@ impl MsnClient { self.get_json(&url, symbol, "earnings") } - pub(super) fn fetch_charts( - &self, - symbol: &str, - chart_type: &str, - ) -> Result, IdxError> { - let id = - resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?; - let url = format!( - "{MSN_ASSETS_BASE_URL}Finance/Charts?apikey={MSN_API_KEY}&ids={id}&chartType={chart_type}&wrapodata=false" - ); - self.get_json(&url, symbol, "charts") - } - pub(super) fn fetch_sentiment(&self, symbol: &str) -> Result, IdxError> { let id = resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?; diff --git a/src/api/msn/map.rs b/src/api/msn/map.rs index f93ba2a..7183385 100644 --- a/src/api/msn/map.rs +++ b/src/api/msn/map.rs @@ -1,17 +1,12 @@ -use std::collections::BTreeMap; - -use chrono::{Datelike, NaiveDate}; - use super::raw_types::{ - IndustryMetric, KeyRatios, MsnChart, MsnQuote, RawChart, RawEarningsData, RawEarningsResponse, - RawEquity, RawFinancialStatement, RawInsight, RawNewsFeed, RawScreenerResponse, RawSentiment, + IndustryMetric, KeyRatios, MsnQuote, RawEarningsData, RawEarningsResponse, RawEquity, + RawFinancialStatement, RawInsight, RawNewsFeed, RawScreenerResponse, RawSentiment, RawStatementSection, }; use super::symbols::{normalized_symbol, ticker_from_symbol}; use crate::api::types::{ - Bar, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, Fundamentals, - InsightData, InstrumentInfo, NewsItem, Officer, Ohlc, Period, Quote, SentimentData, - SentimentPeriod, StatementSection, + CompanyProfile, EarningsData, EarningsReport, FinancialStatements, Fundamentals, InsightData, + InstrumentInfo, NewsItem, Officer, Quote, SentimentData, SentimentPeriod, StatementSection, }; use crate::error::IdxError; @@ -109,94 +104,6 @@ pub(super) fn parse_fundamentals( }) } -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)) } @@ -264,16 +171,6 @@ fn sanitize_current_ratio(value: Option) -> Option { }) } -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 } @@ -378,57 +275,6 @@ pub(super) fn parse_earnings( }) } -pub(super) fn parse_chart_history( - _symbol: &str, - period: &Period, - raw: &[RawChart], -) -> Result, IdxError> { - let chart = raw - .first() - .ok_or_else(|| IdxError::ParseError("no chart data".into()))?; - - let mut grouped: BTreeMap = BTreeMap::new(); - for (idx, ts) in chart.series.time_stamps.iter().enumerate() { - let Some(date) = parse_chart_date(ts) else { - continue; - }; - - let close = chart.series.prices.get(idx).copied(); - let open = chart.series.open_prices.get(idx).copied().or(close); - let high = chart.series.prices_high.get(idx).copied().or(close); - let low = chart.series.prices_low.get(idx).copied().or(close); - let volume = chart.series.volumes.get(idx).copied().unwrap_or(0.0); - - let (Some(open), Some(high), Some(low), Some(close)) = (open, high, low, close) else { - continue; - }; - - grouped.insert( - date, - Bar { - 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), - }, - ); - } - - let mut out: Vec = grouped.into_values().collect(); - trim_history_to_period(period, &mut out); - if out.is_empty() { - return Err(IdxError::ParseError("no chart data".into())); - } - - if matches!(period, Period::FiveDays) { - Ok(resample_history(&out, ResampleInterval::Week)) - } else { - Ok(out) - } -} - pub(super) fn parse_sentiment( symbol: &str, raw: &[RawSentiment], @@ -673,38 +519,3 @@ fn collect_earnings( }); } } - -#[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 7c95531..3231ff8 100644 --- a/src/api/msn/mod.rs +++ b/src/api/msn/mod.rs @@ -5,23 +5,22 @@ mod raw_types; mod symbols; use crate::api::types::{ - Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, - NewsItem, Period, Quote, SentimentData, + CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, NewsItem, + Quote, SentimentData, }; use crate::api::{ - EarningsProvider, FinancialsProvider, FundamentalsProvider, HistoryProvider, InsightsProvider, - NewsProvider, ProfileProvider, QuoteProvider, SentimentProvider, + EarningsProvider, FinancialsProvider, FundamentalsProvider, InsightsProvider, NewsProvider, + ProfileProvider, QuoteProvider, SentimentProvider, }; use crate::error::IdxError; use client::MsnClient; use map::{ - parse_chart_history, parse_earnings, parse_financial_statements, parse_fundamentals, - parse_insights, parse_news, parse_profile, parse_quote, parse_screener_results, - parse_sentiment, + parse_earnings, parse_financial_statements, parse_fundamentals, parse_insights, parse_news, + parse_profile, parse_quote, parse_screener_results, parse_sentiment, }; -pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str}; +pub(crate) use parse::{parse_fundamentals_from_str, parse_quote_from_str}; pub struct MsnProvider { client: MsnClient, @@ -60,31 +59,6 @@ impl FundamentalsProvider for MsnProvider { } } -impl HistoryProvider for MsnProvider { - fn history( - &self, - symbol: &str, - period: &Period, - _interval: &Interval, - ) -> Result, IdxError> { - let chart_type = period_to_chart_type(period); - let raw = self.client.fetch_charts(symbol, chart_type).map_err(|e| { - // Finance/Charts returns 404 for IDX stocks — MSN doesn't provide - // OHLCV chart history for the Indonesian exchange (XIDX). - if matches!(e, IdxError::SymbolNotFound(_)) { - IdxError::Unsupported( - "MSN Finance/Charts does not provide OHLCV history for IDX (XIDX) stocks. \ - Use --provider yahoo for historical data." - .to_string(), - ) - } else { - e - } - })?; - parse_chart_history(symbol, period, &raw) - } -} - impl ProfileProvider for MsnProvider { fn profile(&self, symbol: &str) -> Result { let raw = self.client.fetch_equities(symbol)?; @@ -126,16 +100,3 @@ impl NewsProvider for MsnProvider { parse_news(&raw) } } - -fn period_to_chart_type(period: &Period) -> &'static str { - match period { - Period::OneDay => "1D", - Period::FiveDays => "1W", - Period::OneMonth => "1M", - Period::ThreeMonths => "3M", - Period::SixMonths => "6M", - Period::OneYear => "1Y", - Period::TwoYears => "3Y", - Period::FiveYears => "5Y", - } -} diff --git a/src/api/msn/parse.rs b/src/api/msn/parse.rs index d68228c..321c6d7 100644 --- a/src/api/msn/parse.rs +++ b/src/api/msn/parse.rs @@ -1,8 +1,6 @@ -use chrono::NaiveDate; - -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 super::map::{parse_fundamentals, parse_quote}; +use super::raw_types::{KeyRatios, MsnQuote}; +use crate::api::types::{Fundamentals, Quote}; use crate::error::IdxError; #[cfg_attr(not(test), allow(dead_code))] @@ -28,113 +26,11 @@ pub(crate) fn parse_fundamentals_from_str( } #[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(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)] -fn parse_close_only_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_close_only_history(period, &charts) -} - -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: std::collections::BTreeMap = - std::collections::BTreeMap::new(); - - for (idx, raw_ts) in timestamps.iter().enumerate() { - 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 { - continue; - }; - - grouped.insert( - date, - ClosePoint { - date, - close: close.round() as i64, - }, - ); - } - - let mut out: Vec = grouped.into_values().collect(); - trim_close_history_to_period(period, &mut out); - - if out.is_empty() { - return Err(IdxError::ProviderUnavailable); - } - - Ok(out) -} - -fn trim_close_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); -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ClosePoint { - date: NaiveDate, - close: i64, -} - #[cfg(test)] mod tests { - use super::{ - parse_close_only_history_from_str, parse_fundamentals_from_str, parse_history_from_str, - parse_quote_from_str, - }; - use crate::api::types::{Ohlc, Period}; + use super::{parse_fundamentals_from_str, parse_quote_from_str}; + use crate::api::types::Period; #[test] fn parses_quote_fixture_json() { @@ -164,89 +60,4 @@ mod tests { assert_eq!(fundamentals.earnings_growth, Some(0.121)); assert_eq!(fundamentals.market_cap, Some(1_215_200_000_000_000)); } - - #[test] - fn parses_history_fixture_json() { - let raw = std::fs::read_to_string("tests/fixtures/msn_chart_bbca_3mo.json") - .expect("history fixture exists"); - let history = parse_history_from_str(&Period::ThreeMonths, &raw).expect("history parsed"); - assert_eq!(history.len(), 6); - assert_eq!(history[0].date.to_string(), "2025-01-06"); - assert_eq!(history[0].open, 9800); - assert_eq!(history[0].close, 9875); - assert_eq!(history[5].close, 9940); - } - - #[test] - fn rejects_close_only_chart_series_for_public_history() { - let raw = r#"[ - { - "series": { - "prices": [7100.0, 7200.0], - "timeStamps": ["2026-03-03T17:00:00Z", "2026-03-04T17:00:00Z"] - } - } - ]"#; - let err = - parse_history_from_str(&Period::ThreeMonths, raw).expect_err("history should fail"); - assert_eq!( - err.to_string(), - "parse error: msn does not expose real OHLC/volume for this history range" - ); - } - - #[test] - fn parses_close_only_series_for_internal_use() { - let raw = r#"[ - { - "series": { - "prices": [7100.0, 7200.0], - "timeStamps": ["2026-03-03T17:00:00Z", "2026-03-04T17:00:00Z"] - } - } - ]"#; - let history = - parse_close_only_history_from_str(&Period::ThreeMonths, raw).expect("history parsed"); - assert_eq!(history.len(), 2); - assert_eq!(history[0].close, 7100); - assert_eq!(history[1].close, 7200); - } - - #[test] - fn resamples_history_to_weekly_bars() { - let rows = vec![ - Ohlc { - date: chrono::NaiveDate::from_ymd_opt(2025, 1, 6).expect("date"), - open: 100, - high: 110, - low: 90, - close: 105, - volume: 10, - }, - Ohlc { - date: chrono::NaiveDate::from_ymd_opt(2025, 1, 7).expect("date"), - open: 106, - high: 111, - low: 101, - close: 109, - volume: 11, - }, - Ohlc { - date: chrono::NaiveDate::from_ymd_opt(2025, 1, 13).expect("date"), - open: 110, - high: 115, - low: 108, - close: 114, - volume: 12, - }, - ]; - - 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); - } } diff --git a/src/api/msn/raw_types.rs b/src/api/msn/raw_types.rs index 2829883..889ca9b 100644 --- a/src/api/msn/raw_types.rs +++ b/src/api/msn/raw_types.rs @@ -76,41 +76,6 @@ pub(crate) struct IndustryMetric { pub(crate) price_to_book_ratio: Option, } -#[derive(Debug, Deserialize)] -pub(crate) struct MsnChart { - pub(crate) series: ChartSeries, -} - -pub(crate) type RawChart = MsnChart; - -#[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() - } -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(super) struct RawEquity { diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 952cd93..292903c 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -14,7 +14,7 @@ use crate::api::types::{ }; use crate::api::{ EarningsProvider, FinancialsProvider, InsightsProvider, MarketDataProvider, NewsProvider, - ProfileProvider, SentimentProvider, + ProfileProvider, SentimentProvider, history_provider, }; use crate::cache::Cache; use crate::config::IdxConfig; @@ -208,6 +208,13 @@ pub fn handle( period, interval, } => { + let hist_provider = history_provider(config.provider, false).ok_or_else(|| { + IdxError::Unsupported( + "MSN does not provide price history for IDX stocks. \ + Use --provider yahoo for historical data." + .into(), + ) + })?; let history_bucket = cache_bucket(config, "history"); let resolved = crate::api::resolve_symbol(symbol, &config.exchange); let key = format!("{}-{}", period.as_str(), interval.as_str()); @@ -231,7 +238,7 @@ pub fn handle( return render_history(&resolved, &stale, &config.output); } - match provider.history(&resolved, period, interval) { + match hist_provider.history(&resolved, period, interval) { Ok(history) => { if !no_cache { cache.put( @@ -258,6 +265,13 @@ pub fn handle( } } StocksSubcommand::Technical { symbol } => { + let hist_provider = history_provider(config.provider, false).ok_or_else(|| { + IdxError::Unsupported( + "MSN does not provide price history for IDX stocks. \ + Use --provider yahoo for technical analysis." + .into(), + ) + })?; let technical_bucket = cache_bucket(config, "technical"); let resolved = crate::api::resolve_symbol(symbol, &config.exchange); if !no_cache @@ -272,7 +286,7 @@ pub fn handle( return render_technical(&stale, &config.output, config.no_color); } - match provider.history(&resolved, &Period::OneYear, &Interval::Day) { + match hist_provider.history(&resolved, &Period::OneYear, &Interval::Day) { Ok(history) => { let report = build_technical_report(&resolved, &history)?; if !no_cache { diff --git a/tests/cli.rs b/tests/cli.rs index c6283d0..ccef2e3 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -107,27 +107,32 @@ fn technical_with_mock_provider_json_contains_fields() { } #[test] -fn msn_history_with_mock_returns_data() { - // MSN chart parsing works with fixture data; real IDX stocks 404 on Finance/Charts - // which surfaces as Unsupported (not the old blanket UNSUPPORTED message). - test_bin("msn-history-mock") +fn msn_history_returns_unsupported() { + // MSN Finance/Charts returns 404 for IDX (XIDX) stocks — history is not supported. + // history_provider() returns None for MSN, which surfaces as Unsupported error. + test_bin("msn-history-unsupported") .env("IDX_PROVIDER", "msn") .env("IDX_USE_MOCK_PROVIDER", "1") .args(["stocks", "history", "BBCA", "--period", "3mo"]) .assert() - .success(); + .failure() + .stderr(predicate::str::contains( + "MSN does not provide price history", + )); } #[test] -fn msn_technical_with_mock_returns_data() { - // Technical analysis works via MSN chart fixture data (mock); real IDX stocks - // return Unsupported from Finance/Charts (404 on XIDX). - test_bin("msn-technical-mock") +fn msn_technical_returns_unsupported() { + // Technical analysis requires history — also unsupported for MSN/IDX. + test_bin("msn-technical-unsupported") .env("IDX_PROVIDER", "msn") .env("IDX_USE_MOCK_PROVIDER", "1") .args(["-o", "json", "stocks", "technical", "BBCA"]) .assert() - .success(); + .failure() + .stderr(predicate::str::contains( + "MSN does not provide price history", + )); } #[test]