From ed85428c908f689d6ab2358f8c9489629ca5a8d2 Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:04:30 +0000 Subject: [PATCH] feat: hybrid provider with Yahoo history fallback --- src/api/mod.rs | 44 +++++++++++++++++++++++++++-------- src/cli/stocks.rs | 56 +++++++++++++++++++++++++++++--------------- src/config.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++--- tests/cli.rs | 39 +++++++++++++++++++++---------- 4 files changed, 156 insertions(+), 42 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 7996d99..fceac7a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2,7 +2,7 @@ pub mod msn; pub mod types; pub mod yahoo; -use crate::config::ProviderKind; +use crate::config::{HistoryProviderKind, ProviderKind}; use crate::error::IdxError; use types::{ Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, @@ -84,15 +84,41 @@ pub fn default_provider(provider: ProviderKind, verbose: bool) -> Box Option> { +/// Resolves a history provider based on the selected market data provider and +/// history provider strategy. +/// +/// `history_mode=auto` means: use the selected provider when it supports history, +/// otherwise transparently fallback to Yahoo. +pub fn history_provider( + provider: ProviderKind, + history_mode: HistoryProviderKind, + verbose: bool, +) -> Result<(ProviderKind, Box), IdxError> { + let resolved = match history_mode { + HistoryProviderKind::Yahoo => ProviderKind::Yahoo, + HistoryProviderKind::Msn => ProviderKind::Msn, + HistoryProviderKind::Auto => match provider { + ProviderKind::Yahoo => ProviderKind::Yahoo, + ProviderKind::Msn => ProviderKind::Yahoo, + }, + }; + if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() { - return Some(Box::new(MockProvider::from_fixtures(provider))); + if matches!(resolved, ProviderKind::Msn) { + return Err(IdxError::Unsupported( + "MSN does not provide price history for IDX stocks. Use --history-provider yahoo or auto." + .into(), + )); + } + return Ok((resolved, Box::new(MockProvider::from_fixtures(resolved)))); } - match provider { - ProviderKind::Yahoo => Some(Box::new(yahoo::YahooProvider::new(verbose))), - ProviderKind::Msn => None, + + match resolved { + ProviderKind::Yahoo => Ok((resolved, Box::new(yahoo::YahooProvider::new(verbose)))), + ProviderKind::Msn => Err(IdxError::Unsupported( + "MSN does not provide price history for IDX stocks. Use --history-provider yahoo or auto." + .into(), + )), } } @@ -148,7 +174,7 @@ impl MockProvider { .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(), + "MSN does not provide price history for IDX stocks. Use --history-provider yahoo or auto.".into(), )); Self { diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 292903c..1240ec2 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -17,7 +17,7 @@ use crate::api::{ ProfileProvider, SentimentProvider, history_provider, }; use crate::cache::Cache; -use crate::config::IdxConfig; +use crate::config::{HistoryProviderKind, IdxConfig}; use crate::error::IdxError; use crate::output::{ MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_earnings, @@ -63,6 +63,8 @@ pub enum StocksSubcommand { period: Period, #[arg(long, value_enum, default_value_t = Interval::Day)] interval: Interval, + #[arg(long, value_enum)] + history_provider: Option, }, #[command( about = "Run technical analysis on a stock", @@ -71,6 +73,8 @@ pub enum StocksSubcommand { Technical { /// Single ticker symbol (e.g. BBCA). symbol: String, + #[arg(long, value_enum)] + history_provider: Option, }, #[command( about = "Run growth analysis on a stock", @@ -207,15 +211,22 @@ pub fn handle( symbol, period, interval, + history_provider: history_provider_override, } => { - 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 history_mode = history_provider_override.unwrap_or(config.history_provider); + let (history_source, hist_provider) = + history_provider(config.provider, history_mode, false)?; + if matches!(history_mode, HistoryProviderKind::Auto) + && history_source != config.provider + && !matches!(config.output, crate::output::OutputFormat::Json) + { + eprintln!( + "info: history provider fallback active ({} -> {})", + config.provider.as_str(), + history_source.as_str() + ); + } + let history_bucket = format!("{}-history", history_source.as_str()); let resolved = crate::api::resolve_symbol(symbol, &config.exchange); let key = format!("{}-{}", period.as_str(), interval.as_str()); if !no_cache @@ -264,15 +275,24 @@ 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"); + StocksSubcommand::Technical { + symbol, + history_provider: history_provider_override, + } => { + let history_mode = history_provider_override.unwrap_or(config.history_provider); + let (history_source, hist_provider) = + history_provider(config.provider, history_mode, false)?; + if matches!(history_mode, HistoryProviderKind::Auto) + && history_source != config.provider + && !matches!(config.output, crate::output::OutputFormat::Json) + { + eprintln!( + "info: history provider fallback active ({} -> {})", + config.provider.as_str(), + history_source.as_str() + ); + } + let technical_bucket = format!("{}-technical", history_source.as_str()); let resolved = crate::api::resolve_symbol(symbol, &config.exchange); if !no_cache && let Some(report) = cache.get::(&technical_bucket, &resolved)? diff --git a/src/config.rs b/src/config.rs index c58f59c..6339d49 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use std::fs; use std::path::PathBuf; +use clap::ValueEnum; use directories::ProjectDirs; use serde::Deserialize; @@ -15,6 +16,15 @@ pub enum ProviderKind { Msn, } +#[derive(Debug, Clone, Copy, Deserialize, ValueEnum, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +#[value(rename_all = "lower")] +pub enum HistoryProviderKind { + Auto, + Yahoo, + Msn, +} + impl ProviderKind { pub fn as_str(self) -> &'static str { match self { @@ -36,9 +46,26 @@ impl ProviderKind { } } +impl HistoryProviderKind { + fn parse(value: &str) -> Result { + if value.eq_ignore_ascii_case("auto") { + Ok(Self::Auto) + } else if value.eq_ignore_ascii_case("yahoo") { + Ok(Self::Yahoo) + } else if value.eq_ignore_ascii_case("msn") { + Ok(Self::Msn) + } else { + Err(IdxError::ConfigError(format!( + "invalid history provider '{value}' (expected auto, yahoo, or msn)" + ))) + } + } +} + #[derive(Debug, Clone)] pub struct IdxConfig { pub provider: ProviderKind, + pub history_provider: HistoryProviderKind, pub exchange: String, pub output: OutputFormat, pub no_color: bool, @@ -55,6 +82,7 @@ struct FileConfig { #[derive(Debug, Deserialize, Default)] struct FileGeneral { provider: Option, + history_provider: Option, exchange: Option, output: Option, color: Option, @@ -69,7 +97,8 @@ struct FileCache { impl Default for IdxConfig { fn default() -> Self { Self { - provider: ProviderKind::Yahoo, + provider: ProviderKind::Msn, + history_provider: HistoryProviderKind::Auto, exchange: "JK".to_string(), output: OutputFormat::Table, no_color: false, @@ -86,6 +115,9 @@ impl IdxConfig { if let Ok(provider) = std::env::var("IDX_PROVIDER") { cfg.provider = ProviderKind::parse(&provider)?; } + if let Ok(history_provider) = std::env::var("IDX_HISTORY_PROVIDER") { + cfg.history_provider = HistoryProviderKind::parse(&history_provider)?; + } if let Ok(exchange) = std::env::var("IDX_EXCHANGE") { cfg.exchange = exchange; } @@ -134,6 +166,9 @@ impl IdxConfig { if let Some(provider) = general.provider { cfg.provider = provider; } + if let Some(history_provider) = general.history_provider { + cfg.history_provider = history_provider; + } if let Some(exchange) = general.exchange { cfg.exchange = exchange; } @@ -158,7 +193,7 @@ impl IdxConfig { } pub fn default_config_toml() -> String { - "[general]\nprovider = \"yahoo\"\nexchange = \"JK\"\noutput = \"table\"\ncolor = true\n\n[cache]\nquote_ttl = 300\nfundamental_ttl = 3600\n".to_string() + "[general]\nprovider = \"msn\"\nhistory_provider = \"auto\"\nexchange = \"JK\"\noutput = \"table\"\ncolor = true\n\n[cache]\nquote_ttl = 300\nfundamental_ttl = 3600\n".to_string() } pub fn config_path() -> Result { @@ -252,7 +287,8 @@ mod tests { #[test] fn default_values_are_sane() { let cfg = IdxConfig::default(); - assert_eq!(cfg.provider, super::ProviderKind::Yahoo); + assert_eq!(cfg.provider, super::ProviderKind::Msn); + assert_eq!(cfg.history_provider, super::HistoryProviderKind::Auto); assert_eq!(cfg.exchange, "JK"); assert_eq!(cfg.quote_ttl, 300); } @@ -269,4 +305,21 @@ mod tests { ); assert!(super::ProviderKind::parse("unknown").is_err()); } + + #[test] + fn parses_history_provider_values() { + assert_eq!( + super::HistoryProviderKind::parse("auto").expect("auto history provider"), + super::HistoryProviderKind::Auto + ); + assert_eq!( + super::HistoryProviderKind::parse("yahoo").expect("yahoo history provider"), + super::HistoryProviderKind::Yahoo + ); + assert_eq!( + super::HistoryProviderKind::parse("MSN").expect("msn history provider"), + super::HistoryProviderKind::Msn + ); + assert!(super::HistoryProviderKind::parse("unknown").is_err()); + } } diff --git a/tests/cli.rs b/tests/cli.rs index ccef2e3..a69ad3a 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -107,28 +107,42 @@ fn technical_with_mock_provider_json_contains_fields() { } #[test] -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") +fn msn_history_auto_falls_back_to_yahoo() { + test_bin("msn-history-auto-fallback") .env("IDX_PROVIDER", "msn") .env("IDX_USE_MOCK_PROVIDER", "1") .args(["stocks", "history", "BBCA", "--period", "3mo"]) .assert() - .failure() - .stderr(predicate::str::contains( - "MSN does not provide price history", - )); + .success() + .stdout(predicate::str::contains("DATE")); } #[test] -fn msn_technical_returns_unsupported() { - // Technical analysis requires history — also unsupported for MSN/IDX. - test_bin("msn-technical-unsupported") +fn msn_technical_auto_falls_back_to_yahoo() { + test_bin("msn-technical-auto-fallback") .env("IDX_PROVIDER", "msn") .env("IDX_USE_MOCK_PROVIDER", "1") .args(["-o", "json", "stocks", "technical", "BBCA"]) .assert() + .success() + .stdout(predicate::str::contains("\"symbol\"")); +} + +#[test] +fn explicit_msn_history_provider_returns_unsupported() { + test_bin("msn-history-explicit-unsupported") + .env("IDX_PROVIDER", "msn") + .env("IDX_USE_MOCK_PROVIDER", "1") + .args([ + "stocks", + "history", + "BBCA", + "--period", + "3mo", + "--history-provider", + "msn", + ]) + .assert() .failure() .stderr(predicate::str::contains( "MSN does not provide price history", @@ -225,7 +239,8 @@ fn config_init_creates_file() { assert!(config_home.join("idx/config.toml").exists()); let raw = fs::read_to_string(config_home.join("idx/config.toml")).expect("read config"); - assert!(raw.contains("provider = \"yahoo\"")); + assert!(raw.contains("provider = \"msn\"")); + assert!(raw.contains("history_provider = \"auto\"")); } #[test]