mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
feat: cache, schema
This commit is contained in:
parent
06e06975b8
commit
76656de1f6
1 changed files with 226 additions and 62 deletions
|
|
@ -33,6 +33,26 @@ struct FundamentalCacheSpec {
|
||||||
ttl_secs: u64,
|
ttl_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CacheFetchSpec<'a> {
|
||||||
|
bucket: &'a str,
|
||||||
|
key: &'a str,
|
||||||
|
ttl_secs: u64,
|
||||||
|
subject: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCREENER_FILTERS: &[&str] = &[
|
||||||
|
"top-performers",
|
||||||
|
"worst-performers",
|
||||||
|
"high-dividend",
|
||||||
|
"low-pe",
|
||||||
|
"52w-high",
|
||||||
|
"52w-low",
|
||||||
|
"high-volume",
|
||||||
|
"large-cap",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCREENER_REGIONS: &[&str] = &["id", "us", "sg", "hk", "jp"];
|
||||||
|
|
||||||
fn cache_bucket(config: &IdxConfig, key: &str) -> String {
|
fn cache_bucket(config: &IdxConfig, key: &str) -> String {
|
||||||
format!("{}-{key}", config.provider.as_str())
|
format!("{}-{key}", config.provider.as_str())
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +172,12 @@ pub fn handle(
|
||||||
offline: bool,
|
offline: bool,
|
||||||
no_cache: bool,
|
no_cache: bool,
|
||||||
) -> Result<(), IdxError> {
|
) -> Result<(), IdxError> {
|
||||||
|
if offline && no_cache {
|
||||||
|
return Err(IdxError::InvalidInput(
|
||||||
|
"cannot combine --offline with --no-cache".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let cache = Cache::new()?;
|
let cache = Cache::new()?;
|
||||||
|
|
||||||
match &cmd.command {
|
match &cmd.command {
|
||||||
|
|
@ -380,45 +406,111 @@ pub fn handle(
|
||||||
}
|
}
|
||||||
StocksSubcommand::Profile { symbol } => {
|
StocksSubcommand::Profile { symbol } => {
|
||||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
|
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
|
||||||
let profile: CompanyProfile = fetch_msn_only(&resolved, config.provider, || {
|
let bucket = cache_bucket(config, "profile-v2");
|
||||||
MsnProvider::new(false).profile(&resolved)
|
let profile: CompanyProfile = fetch_msn_with_cache(
|
||||||
})?;
|
&cache,
|
||||||
|
config.provider,
|
||||||
|
CacheFetchSpec {
|
||||||
|
bucket: &bucket,
|
||||||
|
key: &resolved,
|
||||||
|
ttl_secs: config.fundamental_ttl,
|
||||||
|
subject: &resolved,
|
||||||
|
},
|
||||||
|
offline,
|
||||||
|
no_cache,
|
||||||
|
|| MsnProvider::new(false).profile(&resolved),
|
||||||
|
)?;
|
||||||
render_profile(&profile, &config.output)
|
render_profile(&profile, &config.output)
|
||||||
}
|
}
|
||||||
StocksSubcommand::Financials { symbol } => {
|
StocksSubcommand::Financials { symbol } => {
|
||||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
|
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
|
||||||
let financials: FinancialStatements =
|
let bucket = cache_bucket(config, "financials");
|
||||||
fetch_msn_only(&resolved, config.provider, || {
|
let financials: FinancialStatements = fetch_msn_with_cache(
|
||||||
MsnProvider::new(false).financials(&resolved)
|
&cache,
|
||||||
})?;
|
config.provider,
|
||||||
|
CacheFetchSpec {
|
||||||
|
bucket: &bucket,
|
||||||
|
key: &resolved,
|
||||||
|
ttl_secs: config.fundamental_ttl,
|
||||||
|
subject: &resolved,
|
||||||
|
},
|
||||||
|
offline,
|
||||||
|
no_cache,
|
||||||
|
|| MsnProvider::new(false).financials(&resolved),
|
||||||
|
)?;
|
||||||
render_financials(&financials, &config.output)
|
render_financials(&financials, &config.output)
|
||||||
}
|
}
|
||||||
StocksSubcommand::Earnings { symbol } => {
|
StocksSubcommand::Earnings { symbol } => {
|
||||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
|
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
|
||||||
let earnings: EarningsReport = fetch_msn_only(&resolved, config.provider, || {
|
let bucket = cache_bucket(config, "earnings");
|
||||||
MsnProvider::new(false).earnings(&resolved)
|
let earnings: EarningsReport = fetch_msn_with_cache(
|
||||||
})?;
|
&cache,
|
||||||
|
config.provider,
|
||||||
|
CacheFetchSpec {
|
||||||
|
bucket: &bucket,
|
||||||
|
key: &resolved,
|
||||||
|
ttl_secs: config.fundamental_ttl,
|
||||||
|
subject: &resolved,
|
||||||
|
},
|
||||||
|
offline,
|
||||||
|
no_cache,
|
||||||
|
|| MsnProvider::new(false).earnings(&resolved),
|
||||||
|
)?;
|
||||||
render_earnings(&earnings, &config.output)
|
render_earnings(&earnings, &config.output)
|
||||||
}
|
}
|
||||||
StocksSubcommand::Sentiment { symbol } => {
|
StocksSubcommand::Sentiment { symbol } => {
|
||||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
|
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
|
||||||
let sentiment: SentimentData = fetch_msn_only(&resolved, config.provider, || {
|
let bucket = cache_bucket(config, "sentiment");
|
||||||
MsnProvider::new(false).sentiment(&resolved)
|
let sentiment: SentimentData = fetch_msn_with_cache(
|
||||||
})?;
|
&cache,
|
||||||
|
config.provider,
|
||||||
|
CacheFetchSpec {
|
||||||
|
bucket: &bucket,
|
||||||
|
key: &resolved,
|
||||||
|
ttl_secs: config.fundamental_ttl,
|
||||||
|
subject: &resolved,
|
||||||
|
},
|
||||||
|
offline,
|
||||||
|
no_cache,
|
||||||
|
|| MsnProvider::new(false).sentiment(&resolved),
|
||||||
|
)?;
|
||||||
render_sentiment(&sentiment, &config.output)
|
render_sentiment(&sentiment, &config.output)
|
||||||
}
|
}
|
||||||
StocksSubcommand::Insights { symbol } => {
|
StocksSubcommand::Insights { symbol } => {
|
||||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
|
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
|
||||||
let insights: InsightData = fetch_msn_only(&resolved, config.provider, || {
|
let bucket = cache_bucket(config, "insights-v2");
|
||||||
MsnProvider::new(false).insights(&resolved)
|
let insights: InsightData = fetch_msn_with_cache(
|
||||||
})?;
|
&cache,
|
||||||
|
config.provider,
|
||||||
|
CacheFetchSpec {
|
||||||
|
bucket: &bucket,
|
||||||
|
key: &resolved,
|
||||||
|
ttl_secs: config.fundamental_ttl,
|
||||||
|
subject: &resolved,
|
||||||
|
},
|
||||||
|
offline,
|
||||||
|
no_cache,
|
||||||
|
|| MsnProvider::new(false).insights(&resolved),
|
||||||
|
)?;
|
||||||
render_insights(&insights, &config.output)
|
render_insights(&insights, &config.output)
|
||||||
}
|
}
|
||||||
StocksSubcommand::News { symbol, limit } => {
|
StocksSubcommand::News { symbol, limit } => {
|
||||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
|
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?;
|
||||||
let news: Vec<NewsItem> = fetch_msn_only(&resolved, config.provider, || {
|
let bucket = cache_bucket(config, "news");
|
||||||
MsnProvider::new(false).news(&resolved, *limit)
|
let key = format!("{resolved}-{limit}");
|
||||||
})?;
|
let news: Vec<NewsItem> = fetch_msn_with_cache(
|
||||||
|
&cache,
|
||||||
|
config.provider,
|
||||||
|
CacheFetchSpec {
|
||||||
|
bucket: &bucket,
|
||||||
|
key: &key,
|
||||||
|
ttl_secs: config.quote_ttl,
|
||||||
|
subject: &resolved,
|
||||||
|
},
|
||||||
|
offline,
|
||||||
|
no_cache,
|
||||||
|
|| MsnProvider::new(false).news(&resolved, *limit),
|
||||||
|
)?;
|
||||||
render_news(&news, &config.output)
|
render_news(&news, &config.output)
|
||||||
}
|
}
|
||||||
StocksSubcommand::Screen {
|
StocksSubcommand::Screen {
|
||||||
|
|
@ -426,16 +518,27 @@ pub fn handle(
|
||||||
region,
|
region,
|
||||||
limit,
|
limit,
|
||||||
} => {
|
} => {
|
||||||
let msn = MsnProvider::new(false);
|
let filter_key = screener_filter_key(filter)?;
|
||||||
let filter_key = screener_filter_key(filter);
|
let region_key = screener_region_key(region)?;
|
||||||
let region_key = screener_region_key(region);
|
|
||||||
// For filters that fall back to topperfs, fetch all stocks so
|
// For filters that fall back to topperfs, fetch all stocks so
|
||||||
// client-side sorting picks the correct top N.
|
// client-side sorting picks the correct top N.
|
||||||
let needs_full_fetch = matches!(filter.as_str(), "high-volume" | "large-cap");
|
let needs_full_fetch = matches!(filter.as_str(), "high-volume" | "large-cap");
|
||||||
let fetch_limit = if needs_full_fetch { 500 } else { *limit };
|
let fetch_limit = if needs_full_fetch { 500 } else { *limit };
|
||||||
let mut quotes: Vec<Quote> = fetch_msn_only("screen", config.provider, || {
|
let bucket = cache_bucket(config, "screen");
|
||||||
msn.screener(filter_key, region_key, fetch_limit)
|
let key = format!("{filter}:{region}:{fetch_limit}");
|
||||||
})?;
|
let mut quotes: Vec<Quote> = fetch_msn_with_cache(
|
||||||
|
&cache,
|
||||||
|
config.provider,
|
||||||
|
CacheFetchSpec {
|
||||||
|
bucket: &bucket,
|
||||||
|
key: &key,
|
||||||
|
ttl_secs: config.quote_ttl,
|
||||||
|
subject: "screen",
|
||||||
|
},
|
||||||
|
offline,
|
||||||
|
no_cache,
|
||||||
|
|| MsnProvider::new(false).screener(filter_key, region_key, fetch_limit),
|
||||||
|
)?;
|
||||||
sort_screener_quotes(&mut quotes, filter);
|
sort_screener_quotes(&mut quotes, filter);
|
||||||
quotes.truncate(*limit);
|
quotes.truncate(*limit);
|
||||||
render_screener("es, &config.output, config.no_color)
|
render_screener("es, &config.output, config.no_color)
|
||||||
|
|
@ -477,12 +580,9 @@ pub fn handle(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)] // wired up once per-subcommand handlers are fully split
|
fn fetch_with_cache<T, F>(
|
||||||
pub(crate) fn fetch_with_cache<T, F>(
|
|
||||||
cache: &Cache,
|
cache: &Cache,
|
||||||
bucket: &str,
|
cache_spec: CacheFetchSpec<'_>,
|
||||||
key: &str,
|
|
||||||
ttl_secs: u64,
|
|
||||||
offline: bool,
|
offline: bool,
|
||||||
no_cache: bool,
|
no_cache: bool,
|
||||||
fetch_fn: F,
|
fetch_fn: F,
|
||||||
|
|
@ -491,24 +591,52 @@ where
|
||||||
T: Serialize + DeserializeOwned,
|
T: Serialize + DeserializeOwned,
|
||||||
F: FnOnce() -> Result<T, IdxError>,
|
F: FnOnce() -> Result<T, IdxError>,
|
||||||
{
|
{
|
||||||
if !no_cache
|
if !offline
|
||||||
&& !offline
|
&& !no_cache
|
||||||
&& let Some(cached) = cache.get::<T>(bucket, key)?
|
&& let Some(cached) = cache.get::<T>(cache_spec.bucket, cache_spec.key)?
|
||||||
{
|
{
|
||||||
return Ok(cached);
|
return Ok(cached);
|
||||||
}
|
}
|
||||||
|
|
||||||
if offline {
|
if offline {
|
||||||
|
if no_cache {
|
||||||
|
return Err(IdxError::InvalidInput(
|
||||||
|
"cannot combine --offline with --no-cache".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
return cache
|
return cache
|
||||||
.get_stale::<T>(bucket, key)?
|
.get_stale::<T>(cache_spec.bucket, cache_spec.key)?
|
||||||
.ok_or_else(|| IdxError::Offline("no cached data available".to_string()));
|
.ok_or_else(|| {
|
||||||
|
IdxError::CacheMiss(format!("{}/{}", cache_spec.bucket, cache_spec.key))
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = fetch_fn()?;
|
match fetch_fn() {
|
||||||
if !no_cache {
|
Ok(data) => {
|
||||||
let _ = cache.put(bucket, key, &data, ttl_secs);
|
if !no_cache {
|
||||||
|
cache.put(
|
||||||
|
cache_spec.bucket,
|
||||||
|
cache_spec.key,
|
||||||
|
&data,
|
||||||
|
cache_spec.ttl_secs,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
if !no_cache
|
||||||
|
&& let Some(stale) = cache.get_stale::<T>(cache_spec.bucket, cache_spec.key)?
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"warning: network failed, serving stale cache for {}",
|
||||||
|
cache_spec.subject
|
||||||
|
);
|
||||||
|
return Ok(stale);
|
||||||
|
}
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_fundamental_analysis_report<T, F>(
|
fn fetch_fundamental_analysis_report<T, F>(
|
||||||
|
|
@ -631,41 +759,62 @@ fn average_last(values: &[f64], period: usize) -> Option<f64> {
|
||||||
Some(values[start..].iter().sum::<f64>() / period as f64)
|
Some(values[start..].iter().sum::<f64>() / period as f64)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_msn_only<T>(
|
fn fetch_msn_with_cache<T, F>(
|
||||||
symbol: &str,
|
cache: &Cache,
|
||||||
provider: crate::config::ProviderKind,
|
provider: crate::config::ProviderKind,
|
||||||
f: impl FnOnce() -> Result<T, IdxError>,
|
cache_spec: CacheFetchSpec<'_>,
|
||||||
) -> Result<T, IdxError> {
|
offline: bool,
|
||||||
|
no_cache: bool,
|
||||||
|
fetch_fn: F,
|
||||||
|
) -> Result<T, IdxError>
|
||||||
|
where
|
||||||
|
T: Serialize + DeserializeOwned,
|
||||||
|
F: FnOnce() -> Result<T, IdxError>,
|
||||||
|
{
|
||||||
|
ensure_msn_provider(cache_spec.subject, provider)?;
|
||||||
|
fetch_with_cache(cache, cache_spec, offline, no_cache, fetch_fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_msn_provider(
|
||||||
|
subject: &str,
|
||||||
|
provider: crate::config::ProviderKind,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
if !matches!(provider, crate::config::ProviderKind::Msn) {
|
if !matches!(provider, crate::config::ProviderKind::Msn) {
|
||||||
return Err(IdxError::Unsupported(format!(
|
return Err(IdxError::Unsupported(format!(
|
||||||
"{symbol}: command requires --provider msn"
|
"{subject}: command requires --provider msn"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
f()
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn screener_filter_key(filter: &str) -> &'static str {
|
fn screener_filter_key(filter: &str) -> Result<&'static str, IdxError> {
|
||||||
match filter {
|
match filter {
|
||||||
"top-performers" => "st_list_topperfs",
|
"top-performers" => Ok("st_list_topperfs"),
|
||||||
"worst-performers" => "st_list_poorperfs",
|
"worst-performers" => Ok("st_list_poorperfs"),
|
||||||
"high-dividend" => "st_list_highdividend",
|
"high-dividend" => Ok("st_list_highdividend"),
|
||||||
"low-pe" => "st_list_lowpe",
|
"low-pe" => Ok("st_list_lowpe"),
|
||||||
"52w-high" => "st_list_52wkhi",
|
"52w-high" => Ok("st_list_52wkhi"),
|
||||||
"52w-low" => "st_list_52wklow",
|
"52w-low" => Ok("st_list_52wklow"),
|
||||||
"high-volume" => "st_list_topperfs",
|
"high-volume" => Ok("st_list_topperfs"),
|
||||||
"large-cap" => "st_list_topperfs",
|
"large-cap" => Ok("st_list_topperfs"),
|
||||||
_ => "st_list_topperfs",
|
_ => Err(IdxError::InvalidInput(format!(
|
||||||
|
"invalid screener filter '{filter}' (expected one of: {})",
|
||||||
|
SCREENER_FILTERS.join(", ")
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn screener_region_key(region: &str) -> &'static str {
|
fn screener_region_key(region: &str) -> Result<&'static str, IdxError> {
|
||||||
match region {
|
match region {
|
||||||
"id" => "st_reg_id",
|
"id" => Ok("st_reg_id"),
|
||||||
"us" => "st_reg_us",
|
"us" => Ok("st_reg_us"),
|
||||||
"sg" => "st_reg_sg",
|
"sg" => Ok("st_reg_sg"),
|
||||||
"hk" => "st_reg_hk",
|
"hk" => Ok("st_reg_hk"),
|
||||||
"jp" => "st_reg_jp",
|
"jp" => Ok("st_reg_jp"),
|
||||||
_ => "st_reg_id",
|
_ => Err(IdxError::InvalidInput(format!(
|
||||||
|
"invalid screener region '{region}' (expected one of: {})",
|
||||||
|
SCREENER_REGIONS.join(", ")
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -716,6 +865,7 @@ mod tests {
|
||||||
use super::build_technical_report;
|
use super::build_technical_report;
|
||||||
use crate::analysis::signals::Signal;
|
use crate::analysis::signals::Signal;
|
||||||
use crate::api::types::Ohlc;
|
use crate::api::types::Ohlc;
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn technical_report_uses_latest_values() {
|
fn technical_report_uses_latest_values() {
|
||||||
|
|
@ -852,4 +1002,18 @@ mod tests {
|
||||||
let pcts: Vec<f64> = quotes.iter().map(|q| q.change_pct).collect();
|
let pcts: Vec<f64> = quotes.iter().map(|q| q.change_pct).collect();
|
||||||
assert_eq!(pcts, vec![-5.0, -2.0, 1.0]);
|
assert_eq!(pcts, vec![-5.0, -2.0, 1.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_screener_filter_returns_error() {
|
||||||
|
let err = super::screener_filter_key("bogus").expect_err("invalid filter should fail");
|
||||||
|
assert!(matches!(err, IdxError::InvalidInput(_)));
|
||||||
|
assert!(err.to_string().contains("invalid screener filter"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_screener_region_returns_error() {
|
||||||
|
let err = super::screener_region_key("eu").expect_err("invalid region should fail");
|
||||||
|
assert!(matches!(err, IdxError::InvalidInput(_)));
|
||||||
|
assert!(err.to_string().contains("invalid screener region"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue