mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
e78d9fcecd
commit
86cb1b4b7c
16 changed files with 1613 additions and 1301 deletions
177
crates/idx-core/src/client.rs
Normal file
177
crates/idx-core/src/client.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
//! The `Idx` entry point and the cache policy shared by live-data lookups.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::api::{SelectedProvider, default_provider};
|
||||
use crate::cache::Cache;
|
||||
use crate::config::CoreConfig;
|
||||
use crate::error::IdxError;
|
||||
use crate::runtime;
|
||||
|
||||
/// How a live-data lookup may use the on-disk cache.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum CachePolicy {
|
||||
/// Serve unexpired cache, otherwise fetch and store; on a fetch failure,
|
||||
/// fall back to an expired entry.
|
||||
#[default]
|
||||
Default,
|
||||
/// Never touch the network; serve any cached entry, expired or not.
|
||||
Offline,
|
||||
/// Always fetch; neither read nor write the cache.
|
||||
Refresh,
|
||||
}
|
||||
|
||||
impl CachePolicy {
|
||||
/// Maps the CLI's `--offline` / `--no-cache` flags, which are exclusive.
|
||||
pub fn from_flags(offline: bool, no_cache: bool) -> Result<Self, IdxError> {
|
||||
match (offline, no_cache) {
|
||||
(true, true) => Err(IdxError::InvalidInput(
|
||||
"cannot combine --offline with --no-cache".to_string(),
|
||||
)),
|
||||
(true, false) => Ok(Self::Offline),
|
||||
(false, true) => Ok(Self::Refresh),
|
||||
(false, false) => Ok(Self::Default),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live data with where it came from and how fresh it is.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Fetched<T> {
|
||||
pub data: T,
|
||||
/// Provider that produced the data (`msn` or `yahoo`).
|
||||
pub source: &'static str,
|
||||
pub fetched_at: DateTime<Utc>,
|
||||
/// True when an expired cache entry was served (offline, or after a
|
||||
/// failed fetch).
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
impl<T> Fetched<T> {
|
||||
pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Fetched<U> {
|
||||
Fetched {
|
||||
data: f(self.data),
|
||||
source: self.source,
|
||||
fetched_at: self.fetched_at,
|
||||
stale: self.stale,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry point for market, financial, and analysis data.
|
||||
pub struct Idx {
|
||||
pub(crate) config: CoreConfig,
|
||||
pub(crate) provider: SelectedProvider,
|
||||
pub(crate) cache: Cache,
|
||||
}
|
||||
|
||||
impl Idx {
|
||||
pub fn new(config: CoreConfig) -> Result<Self, IdxError> {
|
||||
let cache = Cache::at(config.cache_dir()?);
|
||||
let provider = default_provider(config.provider, config.verbose);
|
||||
Ok(Self {
|
||||
config,
|
||||
provider,
|
||||
cache,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &CoreConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub fn market(&self) -> crate::market::Market<'_> {
|
||||
crate::market::Market { idx: self }
|
||||
}
|
||||
|
||||
pub fn financial(&self) -> crate::financial::Financial<'_> {
|
||||
crate::financial::Financial { idx: self }
|
||||
}
|
||||
|
||||
pub fn analysis(&self) -> crate::analysis::Analysis<'_> {
|
||||
crate::analysis::Analysis { idx: self }
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(&self, symbol: &str) -> Result<String, IdxError> {
|
||||
crate::api::resolve_symbol(symbol, &self.config.exchange)
|
||||
}
|
||||
|
||||
pub(crate) fn bucket(&self, key: &str) -> String {
|
||||
format!("{}-{key}", self.provider.kind().as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct CacheSpec<'a> {
|
||||
pub bucket: &'a str,
|
||||
pub key: &'a str,
|
||||
pub ttl_secs: u64,
|
||||
/// Named in the stale-fallback warning.
|
||||
pub subject: &'a str,
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
/// Fetches through the cache. `finish` turns the raw fetch into the cached
|
||||
/// value; its errors propagate without a stale-cache fallback.
|
||||
pub(crate) fn cached_fetch<R, T>(
|
||||
cache: &Cache,
|
||||
spec: CacheSpec<'_>,
|
||||
policy: CachePolicy,
|
||||
fetch: impl FnOnce() -> Result<R, IdxError>,
|
||||
finish: impl FnOnce(R) -> Result<T, IdxError>,
|
||||
) -> Result<Fetched<T>, IdxError>
|
||||
where
|
||||
T: Serialize + DeserializeOwned,
|
||||
{
|
||||
let hit = |hit: crate::cache::Cached<T>, stale: bool| Fetched {
|
||||
data: hit.data,
|
||||
source: spec.source,
|
||||
fetched_at: hit.fetched_at,
|
||||
stale,
|
||||
};
|
||||
|
||||
match policy {
|
||||
CachePolicy::Default => {
|
||||
if let Some(fresh) = cache.fresh::<T>(spec.bucket, spec.key)? {
|
||||
return Ok(hit(fresh, false));
|
||||
}
|
||||
}
|
||||
CachePolicy::Offline => {
|
||||
return cache
|
||||
.offline::<T>(spec.bucket, spec.key)?
|
||||
.map(|cached| {
|
||||
let stale = cached.expired;
|
||||
hit(cached, stale)
|
||||
})
|
||||
.ok_or_else(|| IdxError::CacheMiss(format!("{}/{}", spec.bucket, spec.key)));
|
||||
}
|
||||
CachePolicy::Refresh => {}
|
||||
}
|
||||
|
||||
let use_cache = policy == CachePolicy::Default;
|
||||
match fetch() {
|
||||
Ok(raw) => {
|
||||
let data = finish(raw)?;
|
||||
if use_cache {
|
||||
cache.put(spec.bucket, spec.key, &data, spec.ttl_secs)?;
|
||||
}
|
||||
Ok(Fetched {
|
||||
data,
|
||||
source: spec.source,
|
||||
fetched_at: Utc::now(),
|
||||
stale: false,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
if use_cache && let Some(stale) = cache.any::<T>(spec.bucket, spec.key)? {
|
||||
runtime::warn(format!(
|
||||
"network failed, serving stale cache for {}",
|
||||
spec.subject
|
||||
));
|
||||
return Ok(hit(stale, true));
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue