mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
Merge pull request #4 from 0xrsydn/feat/hybrid-provider
feat: hybrid provider with Yahoo history fallback
This commit is contained in:
commit
d49a8ed60e
4 changed files with 156 additions and 42 deletions
|
|
@ -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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok((resolved, Box::new(MockProvider::from_fixtures(resolved))));
|
||||||
}
|
}
|
||||||
match provider {
|
|
||||||
ProviderKind::Yahoo => Some(Box::new(yahoo::YahooProvider::new(verbose))),
|
match resolved {
|
||||||
ProviderKind::Msn => None,
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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)?
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
tests/cli.rs
39
tests/cli.rs
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue