refactor(core): move stocks fetching, caching, and analysis into idx-core

Phase 3b of docs/DESIGN.md. `stocks` handlers are now argument parsing,
one idx-core call, and rendering.

- Idx (client.rs) is the entry point: Idx::new(CoreConfig) with
  market(), financial(), and analysis(). Live lookups return
  Fetched<T> { data, source, fetched_at, stale } and take a CachePolicy
  (Default | Offline | Refresh, from the --offline/--no-cache flags).
- One cached_fetch replaces the CLI's three cache flows; they differed
  only in whether a fresh entry is read via get or get_offline when
  offline, which yields the same data and diagnostics. Report-building
  errors (technical) still skip the stale-cache fallback.
- market.rs: quotes, history (history_source is resolved first so the
  CLI's fallback note keeps its position), and a typed screener
  (ScreenFilter/ScreenRegion with the same error messages).
- financial.rs: profile, statements, earnings, sentiment, insights,
  news, with StatementFilter/EarningsFilter.
- analysis: TechnicalReport moves to analysis::report; Analysis offers
  technical, growth, valuation, risk, fundamental, and compare.
- CoreConfig::load() owns provider/exchange/TTL settings from
  config.toml and IDX_*; the CLI's IdxConfig adds output and color.
  With two different invalid settings at once, the reported one may
  differ from before; every single-error message is unchanged.
- Cache gains fresh/any/offline accessors returning fetch metadata.

Unit tests moved with their code (core 157 -> 168, CLI 17 -> 7). All
143 goldens unchanged; live A/B against main: 16/16 stocks identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hermes 2026-10-14 09:41:31 +00:00
commit 86cb1b4b7c
16 changed files with 1613 additions and 1301 deletions

View file

@ -1,3 +1,140 @@
pub mod fundamental;
pub mod report;
pub mod signals;
pub mod technical;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::api::history_provider;
use crate::api::types::{Fundamentals, Interval, Period};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::error::IdxError;
use crate::market::HistorySource;
use fundamental::{
FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental,
analyze_growth, analyze_risk, analyze_valuation,
};
use report::{TechnicalReport, build_technical_report};
/// Analysis reports. Reports are cached as computed, not their inputs.
pub struct Analysis<'a> {
pub(crate) idx: &'a Idx,
}
/// Comparison results; symbols that failed are listed with their error.
pub struct Comparison {
pub reports: Vec<Fetched<FundamentalReport>>,
pub failures: Vec<(String, IdxError)>,
}
impl Analysis<'_> {
/// Technical analysis over one year of daily history from `source`
/// (see `Market::history_source`).
pub fn technical(
&self,
symbol: &str,
source: HistorySource,
policy: CachePolicy,
) -> Result<Fetched<TechnicalReport>, IdxError> {
let idx = self.idx;
let (provider_kind, provider) =
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
let bucket = format!("{}-technical", provider_kind.as_str());
let resolved = idx.resolve(symbol)?;
cached_fetch(
&idx.cache,
CacheSpec {
bucket: &bucket,
key: &resolved,
ttl_secs: idx.config.quote_ttl,
subject: &resolved,
source: provider_kind.as_str(),
},
policy,
|| provider.history(&resolved, &Period::OneYear, &Interval::Day),
|history| build_technical_report(&resolved, &history),
)
}
pub fn growth(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<GrowthReport>, IdxError> {
self.fundamental_report(symbol, "growth", policy, |_, f| analyze_growth(f))
}
pub fn valuation(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<ValuationReport>, IdxError> {
self.fundamental_report(symbol, "valuation", policy, |_, f| analyze_valuation(f))
}
pub fn risk(&self, symbol: &str, policy: CachePolicy) -> Result<Fetched<RiskReport>, IdxError> {
self.fundamental_report(symbol, "risk", policy, |_, f| analyze_risk(f))
}
pub fn fundamental(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<FundamentalReport>, IdxError> {
self.fundamental_report(symbol, "fundamental", policy, analyze_fundamental)
}
/// Fundamental reports for each symbol. Fails only if every symbol fails
/// (with the last error) or a symbol cannot be resolved.
pub fn compare<S: AsRef<str>>(
&self,
symbols: &[S],
policy: CachePolicy,
) -> Result<Comparison, IdxError> {
let mut reports = Vec::new();
let mut failures = Vec::new();
for symbol in symbols {
let resolved = self.idx.resolve(symbol.as_ref())?;
match self.fundamental(&resolved, policy) {
Ok(report) => reports.push(report),
Err(err) => failures.push((resolved, err)),
}
}
if reports.is_empty() {
return Err(failures.pop().map(|(_, err)| err).unwrap_or_else(|| {
IdxError::CacheMiss("fundamental/no symbols could be compared".to_string())
}));
}
Ok(Comparison { reports, failures })
}
fn fundamental_report<T>(
&self,
symbol: &str,
bucket: &str,
policy: CachePolicy,
analyzer: impl FnOnce(&str, &Fundamentals) -> T,
) -> Result<Fetched<T>, IdxError>
where
T: Serialize + DeserializeOwned,
{
let idx = self.idx;
let resolved = idx.resolve(symbol)?;
let bucket = idx.bucket(bucket);
cached_fetch(
&idx.cache,
CacheSpec {
bucket: &bucket,
key: &resolved,
ttl_secs: idx.config.fundamental_ttl,
subject: &resolved,
source: idx.provider.kind().as_str(),
},
policy,
|| idx.provider.market().fundamentals(&resolved),
|fundamentals| Ok(analyzer(&resolved, &fundamentals)),
)
}
}