feat: hybrid provider with Yahoo history fallback

This commit is contained in:
Ciphercat 2026-03-06 23:04:30 +00:00
commit ed85428c90
4 changed files with 156 additions and 42 deletions

View file

@ -2,7 +2,7 @@ pub mod msn;
pub mod types; pub mod types;
pub mod yahoo; pub mod yahoo;
use crate::config::ProviderKind; use crate::config::{HistoryProviderKind, ProviderKind};
use crate::error::IdxError; use crate::error::IdxError;
use types::{ use types::{
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
@ -84,15 +84,41 @@ pub fn default_provider(provider: ProviderKind, verbose: bool) -> Box<dyn Market
} }
} }
/// Returns a history-capable provider, or `None` if the selected provider doesn't /// Resolves a history provider based on the selected market data provider and
/// support price history (e.g. MSN Finance/Charts returns 404 for IDX/XIDX stocks). /// history provider strategy.
pub fn history_provider(provider: ProviderKind, verbose: bool) -> Option<Box<dyn HistoryProvider>> { ///
/// `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<dyn HistoryProvider>), 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() { 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(),
));
} }
match provider { return Ok((resolved, Box::new(MockProvider::from_fixtures(resolved))));
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())); .map_err(|e| IdxError::ParseError(e.to_string()));
// MSN Finance/Charts returns 404 for IDX (XIDX) — history not supported // MSN Finance/Charts returns 404 for IDX (XIDX) — history not supported
let history = Err(IdxError::Unsupported( 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 { Self {

View file

@ -17,7 +17,7 @@ use crate::api::{
ProfileProvider, SentimentProvider, history_provider, ProfileProvider, SentimentProvider, history_provider,
}; };
use crate::cache::Cache; use crate::cache::Cache;
use crate::config::IdxConfig; use crate::config::{HistoryProviderKind, IdxConfig};
use crate::error::IdxError; use crate::error::IdxError;
use crate::output::{ use crate::output::{
MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_earnings, MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_earnings,
@ -63,6 +63,8 @@ pub enum StocksSubcommand {
period: Period, period: Period,
#[arg(long, value_enum, default_value_t = Interval::Day)] #[arg(long, value_enum, default_value_t = Interval::Day)]
interval: Interval, interval: Interval,
#[arg(long, value_enum)]
history_provider: Option<HistoryProviderKind>,
}, },
#[command( #[command(
about = "Run technical analysis on a stock", about = "Run technical analysis on a stock",
@ -71,6 +73,8 @@ pub enum StocksSubcommand {
Technical { Technical {
/// Single ticker symbol (e.g. BBCA). /// Single ticker symbol (e.g. BBCA).
symbol: String, symbol: String,
#[arg(long, value_enum)]
history_provider: Option<HistoryProviderKind>,
}, },
#[command( #[command(
about = "Run growth analysis on a stock", about = "Run growth analysis on a stock",
@ -207,15 +211,22 @@ pub fn handle(
symbol, symbol,
period, period,
interval, interval,
history_provider: history_provider_override,
} => { } => {
let hist_provider = history_provider(config.provider, false).ok_or_else(|| { let history_mode = history_provider_override.unwrap_or(config.history_provider);
IdxError::Unsupported( let (history_source, hist_provider) =
"MSN does not provide price history for IDX stocks. \ history_provider(config.provider, history_mode, false)?;
Use --provider yahoo for historical data." if matches!(history_mode, HistoryProviderKind::Auto)
.into(), && history_source != config.provider
) && !matches!(config.output, crate::output::OutputFormat::Json)
})?; {
let history_bucket = cache_bucket(config, "history"); 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 resolved = crate::api::resolve_symbol(symbol, &config.exchange);
let key = format!("{}-{}", period.as_str(), interval.as_str()); let key = format!("{}-{}", period.as_str(), interval.as_str());
if !no_cache if !no_cache
@ -264,15 +275,24 @@ pub fn handle(
} }
} }
} }
StocksSubcommand::Technical { symbol } => { StocksSubcommand::Technical {
let hist_provider = history_provider(config.provider, false).ok_or_else(|| { symbol,
IdxError::Unsupported( history_provider: history_provider_override,
"MSN does not provide price history for IDX stocks. \ } => {
Use --provider yahoo for technical analysis." let history_mode = history_provider_override.unwrap_or(config.history_provider);
.into(), let (history_source, hist_provider) =
) history_provider(config.provider, history_mode, false)?;
})?; if matches!(history_mode, HistoryProviderKind::Auto)
let technical_bucket = cache_bucket(config, "technical"); && 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); let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
if !no_cache if !no_cache
&& let Some(report) = cache.get::<TechnicalReport>(&technical_bucket, &resolved)? && let Some(report) = cache.get::<TechnicalReport>(&technical_bucket, &resolved)?

View file

@ -1,6 +1,7 @@
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use clap::ValueEnum;
use directories::ProjectDirs; use directories::ProjectDirs;
use serde::Deserialize; use serde::Deserialize;
@ -15,6 +16,15 @@ pub enum ProviderKind {
Msn, 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 { impl ProviderKind {
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
@ -36,9 +46,26 @@ impl ProviderKind {
} }
} }
impl HistoryProviderKind {
fn parse(value: &str) -> Result<Self, IdxError> {
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)] #[derive(Debug, Clone)]
pub struct IdxConfig { pub struct IdxConfig {
pub provider: ProviderKind, pub provider: ProviderKind,
pub history_provider: HistoryProviderKind,
pub exchange: String, pub exchange: String,
pub output: OutputFormat, pub output: OutputFormat,
pub no_color: bool, pub no_color: bool,
@ -55,6 +82,7 @@ struct FileConfig {
#[derive(Debug, Deserialize, Default)] #[derive(Debug, Deserialize, Default)]
struct FileGeneral { struct FileGeneral {
provider: Option<ProviderKind>, provider: Option<ProviderKind>,
history_provider: Option<HistoryProviderKind>,
exchange: Option<String>, exchange: Option<String>,
output: Option<OutputFormat>, output: Option<OutputFormat>,
color: Option<bool>, color: Option<bool>,
@ -69,7 +97,8 @@ struct FileCache {
impl Default for IdxConfig { impl Default for IdxConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
provider: ProviderKind::Yahoo, provider: ProviderKind::Msn,
history_provider: HistoryProviderKind::Auto,
exchange: "JK".to_string(), exchange: "JK".to_string(),
output: OutputFormat::Table, output: OutputFormat::Table,
no_color: false, no_color: false,
@ -86,6 +115,9 @@ impl IdxConfig {
if let Ok(provider) = std::env::var("IDX_PROVIDER") { if let Ok(provider) = std::env::var("IDX_PROVIDER") {
cfg.provider = ProviderKind::parse(&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") { if let Ok(exchange) = std::env::var("IDX_EXCHANGE") {
cfg.exchange = exchange; cfg.exchange = exchange;
} }
@ -134,6 +166,9 @@ impl IdxConfig {
if let Some(provider) = general.provider { if let Some(provider) = general.provider {
cfg.provider = provider; cfg.provider = provider;
} }
if let Some(history_provider) = general.history_provider {
cfg.history_provider = history_provider;
}
if let Some(exchange) = general.exchange { if let Some(exchange) = general.exchange {
cfg.exchange = exchange; cfg.exchange = exchange;
} }
@ -158,7 +193,7 @@ impl IdxConfig {
} }
pub fn default_config_toml() -> String { 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<PathBuf, IdxError> { pub fn config_path() -> Result<PathBuf, IdxError> {
@ -252,7 +287,8 @@ mod tests {
#[test] #[test]
fn default_values_are_sane() { fn default_values_are_sane() {
let cfg = IdxConfig::default(); 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.exchange, "JK");
assert_eq!(cfg.quote_ttl, 300); assert_eq!(cfg.quote_ttl, 300);
} }
@ -269,4 +305,21 @@ mod tests {
); );
assert!(super::ProviderKind::parse("unknown").is_err()); 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());
}
} }

View file

@ -107,28 +107,42 @@ fn technical_with_mock_provider_json_contains_fields() {
} }
#[test] #[test]
fn msn_history_returns_unsupported() { fn msn_history_auto_falls_back_to_yahoo() {
// MSN Finance/Charts returns 404 for IDX (XIDX) stocks — history is not supported. test_bin("msn-history-auto-fallback")
// history_provider() returns None for MSN, which surfaces as Unsupported error.
test_bin("msn-history-unsupported")
.env("IDX_PROVIDER", "msn") .env("IDX_PROVIDER", "msn")
.env("IDX_USE_MOCK_PROVIDER", "1") .env("IDX_USE_MOCK_PROVIDER", "1")
.args(["stocks", "history", "BBCA", "--period", "3mo"]) .args(["stocks", "history", "BBCA", "--period", "3mo"])
.assert() .assert()
.failure() .success()
.stderr(predicate::str::contains( .stdout(predicate::str::contains("DATE"));
"MSN does not provide price history",
));
} }
#[test] #[test]
fn msn_technical_returns_unsupported() { fn msn_technical_auto_falls_back_to_yahoo() {
// Technical analysis requires history — also unsupported for MSN/IDX. test_bin("msn-technical-auto-fallback")
test_bin("msn-technical-unsupported")
.env("IDX_PROVIDER", "msn") .env("IDX_PROVIDER", "msn")
.env("IDX_USE_MOCK_PROVIDER", "1") .env("IDX_USE_MOCK_PROVIDER", "1")
.args(["-o", "json", "stocks", "technical", "BBCA"]) .args(["-o", "json", "stocks", "technical", "BBCA"])
.assert() .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() .failure()
.stderr(predicate::str::contains( .stderr(predicate::str::contains(
"MSN does not provide price history", "MSN does not provide price history",
@ -225,7 +239,8 @@ fn config_init_creates_file() {
assert!(config_home.join("idx/config.toml").exists()); assert!(config_home.join("idx/config.toml").exists());
let raw = fs::read_to_string(config_home.join("idx/config.toml")).expect("read config"); 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] #[test]