mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 09:43:53 +00:00
feat(api): add MSN Finance as alternative data provider
Add MsnProvider implementing MarketDataProvider trait with quote, fundamentals, and history support. Includes provider-aware config (file/env/CLI), cache namespace isolation per provider, symbol ID mapping via embedded TSV, OHLCV resampling, and comprehensive unit + integration tests with MSN fixture data. Key changes: - MsnProvider with quote, key-ratios, and chart endpoints - ProviderKind enum (yahoo/msn) with config hierarchy support - Provider-namespaced cache buckets to prevent cross-provider poisoning - Provider-aware MockProvider loading correct fixtures per provider - Integration tests for config round-trip and cache isolation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3383fdd983
commit
2cdefeb7a6
11 changed files with 2099 additions and 36 deletions
|
|
@ -17,11 +17,15 @@ use crate::output::{
|
|||
render_growth, render_history, render_quotes, render_risk, render_technical, render_valuation,
|
||||
};
|
||||
|
||||
struct FundamentalCacheSpec<'a> {
|
||||
key: &'a str,
|
||||
struct FundamentalCacheSpec {
|
||||
bucket: String,
|
||||
ttl_secs: u64,
|
||||
}
|
||||
|
||||
fn cache_bucket(config: &IdxConfig, key: &str) -> String {
|
||||
format!("{}-{key}", config.provider.as_str())
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
#[command(about = "Stock data and analysis")]
|
||||
pub struct StocksCmd {
|
||||
|
|
@ -112,17 +116,18 @@ pub fn handle(
|
|||
|
||||
match &cmd.command {
|
||||
StocksSubcommand::Quote { symbols } => {
|
||||
let quote_bucket = cache_bucket(config, "quote");
|
||||
let mut quotes = Vec::new();
|
||||
for sym in symbols.iter().flat_map(|s| s.split(',')) {
|
||||
let resolved = crate::api::resolve_symbol(sym, &config.exchange);
|
||||
if !no_cache && let Some(q) = cache.get("quote", &resolved)? {
|
||||
if !no_cache && let Some(q) = cache.get("e_bucket, &resolved)? {
|
||||
quotes.push(q);
|
||||
continue;
|
||||
}
|
||||
if offline {
|
||||
let stale = cache
|
||||
.get_stale("quote", &resolved)?
|
||||
.ok_or_else(|| IdxError::CacheMiss(format!("quote/{resolved}")))?;
|
||||
.get_stale("e_bucket, &resolved)?
|
||||
.ok_or_else(|| IdxError::CacheMiss(format!("{quote_bucket}/{resolved}")))?;
|
||||
quotes.push(stale);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -130,12 +135,14 @@ pub fn handle(
|
|||
match provider.quote(&resolved) {
|
||||
Ok(q) => {
|
||||
if !no_cache {
|
||||
cache.put("quote", &resolved, &q, config.quote_ttl)?;
|
||||
cache.put("e_bucket, &resolved, &q, config.quote_ttl)?;
|
||||
}
|
||||
quotes.push(q);
|
||||
}
|
||||
Err(err) => {
|
||||
if !no_cache && let Some(stale) = cache.get_stale("quote", &resolved)? {
|
||||
if !no_cache
|
||||
&& let Some(stale) = cache.get_stale("e_bucket, &resolved)?
|
||||
{
|
||||
eprintln!(
|
||||
"warning: network failed, serving stale cache for {resolved}"
|
||||
);
|
||||
|
|
@ -153,21 +160,26 @@ pub fn handle(
|
|||
period,
|
||||
interval,
|
||||
} => {
|
||||
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());
|
||||
if !no_cache
|
||||
&& let Some(history) = cache
|
||||
.get::<Vec<crate::api::types::Ohlc>>("history", &format!("{resolved}-{key}"))?
|
||||
&& let Some(history) = cache.get::<Vec<crate::api::types::Ohlc>>(
|
||||
&history_bucket,
|
||||
&format!("{resolved}-{key}"),
|
||||
)?
|
||||
{
|
||||
return render_history(&resolved, &history, &config.output);
|
||||
}
|
||||
if offline {
|
||||
let stale = cache
|
||||
.get_stale::<Vec<crate::api::types::Ohlc>>(
|
||||
"history",
|
||||
&history_bucket,
|
||||
&format!("{resolved}-{key}"),
|
||||
)?
|
||||
.ok_or_else(|| IdxError::CacheMiss(format!("history/{resolved}-{key}")))?;
|
||||
.ok_or_else(|| {
|
||||
IdxError::CacheMiss(format!("{history_bucket}/{resolved}-{key}"))
|
||||
})?;
|
||||
return render_history(&resolved, &stale, &config.output);
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +187,7 @@ pub fn handle(
|
|||
Ok(history) => {
|
||||
if !no_cache {
|
||||
cache.put(
|
||||
"history",
|
||||
&history_bucket,
|
||||
&format!("{resolved}-{key}"),
|
||||
&history,
|
||||
config.quote_ttl,
|
||||
|
|
@ -186,7 +198,7 @@ pub fn handle(
|
|||
Err(err) => {
|
||||
if !no_cache
|
||||
&& let Some(stale) = cache.get_stale::<Vec<crate::api::types::Ohlc>>(
|
||||
"history",
|
||||
&history_bucket,
|
||||
&format!("{resolved}-{key}"),
|
||||
)?
|
||||
{
|
||||
|
|
@ -198,16 +210,17 @@ pub fn handle(
|
|||
}
|
||||
}
|
||||
StocksSubcommand::Technical { symbol } => {
|
||||
let technical_bucket = cache_bucket(config, "technical");
|
||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
||||
if !no_cache
|
||||
&& let Some(report) = cache.get::<TechnicalReport>("technical", &resolved)?
|
||||
&& let Some(report) = cache.get::<TechnicalReport>(&technical_bucket, &resolved)?
|
||||
{
|
||||
return render_technical(&report, &config.output, config.no_color);
|
||||
}
|
||||
if offline {
|
||||
let stale = cache
|
||||
.get_stale::<TechnicalReport>("technical", &resolved)?
|
||||
.ok_or_else(|| IdxError::CacheMiss(format!("technical/{resolved}")))?;
|
||||
.get_stale::<TechnicalReport>(&technical_bucket, &resolved)?
|
||||
.ok_or_else(|| IdxError::CacheMiss(format!("{technical_bucket}/{resolved}")))?;
|
||||
return render_technical(&stale, &config.output, config.no_color);
|
||||
}
|
||||
|
||||
|
|
@ -215,14 +228,14 @@ pub fn handle(
|
|||
Ok(history) => {
|
||||
let report = build_technical_report(&resolved, &history)?;
|
||||
if !no_cache {
|
||||
cache.put("technical", &resolved, &report, config.quote_ttl)?;
|
||||
cache.put(&technical_bucket, &resolved, &report, config.quote_ttl)?;
|
||||
}
|
||||
render_technical(&report, &config.output, config.no_color)
|
||||
}
|
||||
Err(err) => {
|
||||
if !no_cache
|
||||
&& let Some(stale) =
|
||||
cache.get_stale::<TechnicalReport>("technical", &resolved)?
|
||||
cache.get_stale::<TechnicalReport>(&technical_bucket, &resolved)?
|
||||
{
|
||||
eprintln!("warning: network failed, serving stale cache for {resolved}");
|
||||
return render_technical(&stale, &config.output, config.no_color);
|
||||
|
|
@ -238,7 +251,7 @@ pub fn handle(
|
|||
provider,
|
||||
&resolved,
|
||||
FundamentalCacheSpec {
|
||||
key: "growth",
|
||||
bucket: cache_bucket(config, "growth"),
|
||||
ttl_secs: config.fundamental_ttl,
|
||||
},
|
||||
offline,
|
||||
|
|
@ -254,7 +267,7 @@ pub fn handle(
|
|||
provider,
|
||||
&resolved,
|
||||
FundamentalCacheSpec {
|
||||
key: "valuation",
|
||||
bucket: cache_bucket(config, "valuation"),
|
||||
ttl_secs: config.fundamental_ttl,
|
||||
},
|
||||
offline,
|
||||
|
|
@ -270,7 +283,7 @@ pub fn handle(
|
|||
provider,
|
||||
&resolved,
|
||||
FundamentalCacheSpec {
|
||||
key: "risk",
|
||||
bucket: cache_bucket(config, "risk"),
|
||||
ttl_secs: config.fundamental_ttl,
|
||||
},
|
||||
offline,
|
||||
|
|
@ -286,7 +299,7 @@ pub fn handle(
|
|||
provider,
|
||||
&resolved,
|
||||
FundamentalCacheSpec {
|
||||
key: "fundamental",
|
||||
bucket: cache_bucket(config, "fundamental"),
|
||||
ttl_secs: config.fundamental_ttl,
|
||||
},
|
||||
offline,
|
||||
|
|
@ -306,7 +319,7 @@ pub fn handle(
|
|||
provider,
|
||||
&resolved,
|
||||
FundamentalCacheSpec {
|
||||
key: "fundamental",
|
||||
bucket: cache_bucket(config, "fundamental"),
|
||||
ttl_secs: config.fundamental_ttl,
|
||||
},
|
||||
offline,
|
||||
|
|
@ -336,7 +349,7 @@ fn fetch_fundamental_analysis_report<T, F>(
|
|||
cache: &Cache,
|
||||
provider: &dyn MarketDataProvider,
|
||||
resolved: &str,
|
||||
cache_spec: FundamentalCacheSpec<'_>,
|
||||
cache_spec: FundamentalCacheSpec,
|
||||
offline: bool,
|
||||
no_cache: bool,
|
||||
analyzer: F,
|
||||
|
|
@ -345,26 +358,26 @@ where
|
|||
T: Serialize + DeserializeOwned,
|
||||
F: FnOnce(&str, &Fundamentals) -> T,
|
||||
{
|
||||
if !no_cache && let Some(report) = cache.get::<T>(cache_spec.key, resolved)? {
|
||||
if !no_cache && let Some(report) = cache.get::<T>(&cache_spec.bucket, resolved)? {
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
if offline {
|
||||
return cache
|
||||
.get_stale::<T>(cache_spec.key, resolved)?
|
||||
.ok_or_else(|| IdxError::CacheMiss(format!("{}/{resolved}", cache_spec.key)));
|
||||
.get_stale::<T>(&cache_spec.bucket, resolved)?
|
||||
.ok_or_else(|| IdxError::CacheMiss(format!("{}/{resolved}", cache_spec.bucket)));
|
||||
}
|
||||
|
||||
match provider.fundamentals(resolved) {
|
||||
Ok(fundamentals) => {
|
||||
let report = analyzer(resolved, &fundamentals);
|
||||
if !no_cache {
|
||||
cache.put(cache_spec.key, resolved, &report, cache_spec.ttl_secs)?;
|
||||
cache.put(&cache_spec.bucket, resolved, &report, cache_spec.ttl_secs)?;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
Err(err) => {
|
||||
if !no_cache && let Some(stale) = cache.get_stale::<T>(cache_spec.key, resolved)? {
|
||||
if !no_cache && let Some(stale) = cache.get_stale::<T>(&cache_spec.bucket, resolved)? {
|
||||
eprintln!("warning: network failed, serving stale cache for {resolved}");
|
||||
return Ok(stale);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue