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

@ -25,6 +25,14 @@ struct CacheEntry<T> {
data: T,
}
/// A cache hit with its metadata.
#[derive(Debug)]
pub struct Cached<T> {
pub data: T,
pub fetched_at: DateTime<Utc>,
pub expired: bool,
}
#[derive(Debug)]
pub struct CacheInfo {
pub path: PathBuf,
@ -39,8 +47,7 @@ impl Cache {
Ok(Self { root: cache_dir()? })
}
#[cfg(test)]
pub fn with_root(root: PathBuf) -> Self {
pub fn at(root: PathBuf) -> Self {
Self { root }
}
@ -49,13 +56,7 @@ impl Cache {
data_type: &str,
symbol: &str,
) -> Result<Option<T>, IdxError> {
let Some(entry): Option<CacheEntry<T>> = self.read_entry(data_type, symbol)? else {
return Ok(None);
};
if is_expired(entry.fetched_at, entry.ttl_secs) {
return Ok(None);
}
Ok(Some(entry.data))
Ok(self.fresh(data_type, symbol)?.map(|hit| hit.data))
}
pub fn get_stale<T: DeserializeOwned>(
@ -63,26 +64,57 @@ impl Cache {
data_type: &str,
symbol: &str,
) -> Result<Option<T>, IdxError> {
Ok(self.read_entry::<T>(data_type, symbol)?.map(|e| e.data))
Ok(self.any(data_type, symbol)?.map(|hit| hit.data))
}
/// Like [`Cache::get_stale`], but warns when the entry served is past its TTL.
/// Used by `--offline`, which otherwise serves arbitrarily old data silently.
pub fn get_offline<T: DeserializeOwned>(
&self,
data_type: &str,
symbol: &str,
) -> Result<Option<T>, IdxError> {
let Some(entry) = self.read_entry::<T>(data_type, symbol)? else {
return Ok(None);
};
if is_expired(entry.fetched_at, entry.ttl_secs) {
Ok(self.offline(data_type, symbol)?.map(|hit| hit.data))
}
/// An unexpired entry.
pub fn fresh<T: DeserializeOwned>(
&self,
data_type: &str,
symbol: &str,
) -> Result<Option<Cached<T>>, IdxError> {
Ok(self.any(data_type, symbol)?.filter(|hit| !hit.expired))
}
/// Any entry, expired or not.
pub fn any<T: DeserializeOwned>(
&self,
data_type: &str,
symbol: &str,
) -> Result<Option<Cached<T>>, IdxError> {
Ok(self
.read_entry::<T>(data_type, symbol)?
.map(|entry| Cached {
expired: is_expired(entry.fetched_at, entry.ttl_secs),
fetched_at: entry.fetched_at,
data: entry.data,
}))
}
/// Any entry, warning on stderr when serving an expired one offline.
pub fn offline<T: DeserializeOwned>(
&self,
data_type: &str,
symbol: &str,
) -> Result<Option<Cached<T>>, IdxError> {
let hit = self.any::<T>(data_type, symbol)?;
if let Some(hit) = &hit
&& hit.expired
{
runtime::warn(format!(
"offline: serving expired cache for {symbol} (fetched {})",
entry.fetched_at.to_rfc3339()
hit.fetched_at.to_rfc3339()
));
}
Ok(Some(entry.data))
Ok(hit)
}
pub fn put<T: Serialize>(
@ -264,7 +296,7 @@ mod tests {
#[test]
fn write_read_expire_and_stale() {
let root = tmp();
let cache = Cache::with_root(root.clone());
let cache = Cache::at(root.clone());
cache
.put("quote", "BBCA.JK", &T { v: 7 }, 300)
@ -295,7 +327,7 @@ mod tests {
#[test]
fn corrupted_cache_entry_returns_none_and_deletes_file() {
let root = tmp();
let cache = Cache::with_root(root.clone());
let cache = Cache::at(root.clone());
// Write invalid JSON to a cache file
let path = root.join("quote/CORRUPT.JK.json");
@ -319,7 +351,7 @@ mod tests {
#[test]
fn corrupted_cache_entry_get_stale_returns_none_and_deletes_file() {
let root = tmp();
let cache = Cache::with_root(root.clone());
let cache = Cache::at(root.clone());
// Write invalid JSON to a cache file
let path = root.join("quote/STALE_CORRUPT.JK.json");