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
409
crates/idx-core/src/market.rs
Normal file
409
crates/idx-core/src/market.rs
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
//! Market data: quotes, price history, and the screener.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::api::types::{Interval, Ohlc, Period, Quote};
|
||||
use crate::api::{history_provider, history_source};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::config::{HistoryProviderKind, ProviderKind};
|
||||
use crate::error::IdxError;
|
||||
|
||||
pub struct Market<'a> {
|
||||
pub(crate) idx: &'a Idx,
|
||||
}
|
||||
|
||||
/// Which provider serves history, and whether that differs from the selected
|
||||
/// market provider because `auto` fell back (MSN history is price-only).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct HistorySource {
|
||||
pub provider: ProviderKind,
|
||||
pub mode: HistoryProviderKind,
|
||||
pub is_fallback: bool,
|
||||
}
|
||||
|
||||
impl Market<'_> {
|
||||
/// Quotes in input order; stops at the first symbol that fails.
|
||||
pub fn quotes<S: AsRef<str>>(
|
||||
&self,
|
||||
symbols: &[S],
|
||||
policy: CachePolicy,
|
||||
) -> Result<Vec<Fetched<Quote>>, IdxError> {
|
||||
let idx = self.idx;
|
||||
let bucket = idx.bucket("quote");
|
||||
let mut quotes = Vec::with_capacity(symbols.len());
|
||||
for symbol in symbols {
|
||||
let resolved = idx.resolve(symbol.as_ref())?;
|
||||
quotes.push(cached_fetch(
|
||||
&idx.cache,
|
||||
CacheSpec {
|
||||
bucket: &bucket,
|
||||
key: &resolved,
|
||||
ttl_secs: idx.config.quote_ttl,
|
||||
subject: &resolved,
|
||||
source: idx.provider.kind().as_str(),
|
||||
},
|
||||
policy,
|
||||
|| idx.provider.market().quote(&resolved),
|
||||
Ok,
|
||||
)?);
|
||||
}
|
||||
Ok(quotes)
|
||||
}
|
||||
|
||||
/// Resolves the history source for an optional `--history-provider`
|
||||
/// override, without fetching.
|
||||
pub fn history_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
|
||||
let mode = mode.unwrap_or(self.idx.config.history_provider);
|
||||
let selected = self.idx.provider.kind();
|
||||
let provider = history_source(selected, mode);
|
||||
HistorySource {
|
||||
provider,
|
||||
mode,
|
||||
is_fallback: mode == HistoryProviderKind::Auto && provider != selected,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn history(
|
||||
&self,
|
||||
symbol: &str,
|
||||
period: &Period,
|
||||
interval: &Interval,
|
||||
source: HistorySource,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<Vec<Ohlc>>, IdxError> {
|
||||
let idx = self.idx;
|
||||
let (provider_kind, provider) =
|
||||
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
|
||||
let bucket = format!("{}-history", provider_kind.as_str());
|
||||
let resolved = idx.resolve(symbol)?;
|
||||
let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str());
|
||||
cached_fetch(
|
||||
&idx.cache,
|
||||
CacheSpec {
|
||||
bucket: &bucket,
|
||||
key: &key,
|
||||
ttl_secs: idx.config.quote_ttl,
|
||||
subject: &resolved,
|
||||
source: provider_kind.as_str(),
|
||||
},
|
||||
policy,
|
||||
|| provider.history(&resolved, period, interval),
|
||||
Ok,
|
||||
)
|
||||
}
|
||||
|
||||
/// Screener results, ranked locally and truncated to `query.limit`.
|
||||
pub fn screen(
|
||||
&self,
|
||||
query: &ScreenQuery,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<Vec<Quote>>, IdxError> {
|
||||
let idx = self.idx;
|
||||
let ScreenQuery {
|
||||
filter,
|
||||
region,
|
||||
limit,
|
||||
} = *query;
|
||||
// Filters re-sorted client-side need the whole MSN list: MSN orders
|
||||
// by 1-year return, so truncating first and then sorting by day
|
||||
// change made `--limit 3` return different stocks than `--limit 25`.
|
||||
let fetch_limit = (!filter.needs_full_list()).then_some(limit);
|
||||
let bucket = idx.bucket("screen");
|
||||
let key = match fetch_limit {
|
||||
Some(limit) => format!("{filter}:{region}:{limit}"),
|
||||
None => format!("{filter}:{region}:all"),
|
||||
};
|
||||
let screener = idx.provider.screener_provider("screen")?;
|
||||
let mut fetched = cached_fetch(
|
||||
&idx.cache,
|
||||
CacheSpec {
|
||||
bucket: &bucket,
|
||||
key: &key,
|
||||
ttl_secs: idx.config.quote_ttl,
|
||||
subject: "screen",
|
||||
source: idx.provider.kind().as_str(),
|
||||
},
|
||||
policy,
|
||||
|| screener.screener(filter.msn_key(), region.msn_key(), fetch_limit),
|
||||
Ok,
|
||||
)?;
|
||||
sort_screener_quotes(&mut fetched.data, filter);
|
||||
fetched.data.truncate(limit);
|
||||
Ok(fetched)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ScreenQuery {
|
||||
pub filter: ScreenFilter,
|
||||
pub region: ScreenRegion,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScreenFilter {
|
||||
TopPerformers,
|
||||
WorstPerformers,
|
||||
HighDividend,
|
||||
LowPe,
|
||||
Week52High,
|
||||
Week52Low,
|
||||
HighVolume,
|
||||
LargeCap,
|
||||
}
|
||||
|
||||
impl ScreenFilter {
|
||||
pub const ALL: [Self; 8] = [
|
||||
Self::TopPerformers,
|
||||
Self::WorstPerformers,
|
||||
Self::HighDividend,
|
||||
Self::LowPe,
|
||||
Self::Week52High,
|
||||
Self::Week52Low,
|
||||
Self::HighVolume,
|
||||
Self::LargeCap,
|
||||
];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::TopPerformers => "top-performers",
|
||||
Self::WorstPerformers => "worst-performers",
|
||||
Self::HighDividend => "high-dividend",
|
||||
Self::LowPe => "low-pe",
|
||||
Self::Week52High => "52w-high",
|
||||
Self::Week52Low => "52w-low",
|
||||
Self::HighVolume => "high-volume",
|
||||
Self::LargeCap => "large-cap",
|
||||
}
|
||||
}
|
||||
|
||||
fn msn_key(self) -> &'static str {
|
||||
match self {
|
||||
Self::TopPerformers | Self::HighVolume | Self::LargeCap => "st_list_topperfs",
|
||||
Self::WorstPerformers => "st_list_poorperfs",
|
||||
Self::HighDividend => "st_list_highdividend",
|
||||
Self::LowPe => "st_list_lowpe",
|
||||
Self::Week52High => "st_list_52wkhi",
|
||||
Self::Week52Low => "st_list_52wklow",
|
||||
}
|
||||
}
|
||||
|
||||
/// Filters ranked locally need every candidate before `limit` applies.
|
||||
fn needs_full_list(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::HighVolume | Self::LargeCap | Self::TopPerformers | Self::WorstPerformers
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ScreenFilter {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ScreenFilter {
|
||||
type Err = IdxError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, IdxError> {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|filter| filter.as_str() == value)
|
||||
.ok_or_else(|| {
|
||||
let names: Vec<_> = Self::ALL.iter().map(|f| f.as_str()).collect();
|
||||
IdxError::InvalidInput(format!(
|
||||
"invalid screener filter '{value}' (expected one of: {})",
|
||||
names.join(", ")
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScreenRegion {
|
||||
Id,
|
||||
Us,
|
||||
Sg,
|
||||
Hk,
|
||||
Jp,
|
||||
}
|
||||
|
||||
impl ScreenRegion {
|
||||
pub const ALL: [Self; 5] = [Self::Id, Self::Us, Self::Sg, Self::Hk, Self::Jp];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Id => "id",
|
||||
Self::Us => "us",
|
||||
Self::Sg => "sg",
|
||||
Self::Hk => "hk",
|
||||
Self::Jp => "jp",
|
||||
}
|
||||
}
|
||||
|
||||
fn msn_key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Id => "st_reg_id",
|
||||
Self::Us => "st_reg_us",
|
||||
Self::Sg => "st_reg_sg",
|
||||
Self::Hk => "st_reg_hk",
|
||||
Self::Jp => "st_reg_jp",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ScreenRegion {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ScreenRegion {
|
||||
type Err = IdxError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, IdxError> {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|region| region.as_str() == value)
|
||||
.ok_or_else(|| {
|
||||
let names: Vec<_> = Self::ALL.iter().map(|r| r.as_str()).collect();
|
||||
IdxError::InvalidInput(format!(
|
||||
"invalid screener region '{value}' (expected one of: {})",
|
||||
names.join(", ")
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_screener_quotes(quotes: &mut [Quote], filter: ScreenFilter) {
|
||||
match filter {
|
||||
ScreenFilter::TopPerformers => {
|
||||
quotes.sort_by(|a, b| {
|
||||
let pa = a.change_pct.unwrap_or(f64::MIN);
|
||||
let pb = b.change_pct.unwrap_or(f64::MIN);
|
||||
pb.partial_cmp(&pa).unwrap_or(Ordering::Equal)
|
||||
});
|
||||
}
|
||||
ScreenFilter::WorstPerformers => {
|
||||
quotes.sort_by(|a, b| {
|
||||
let pa = a.change_pct.unwrap_or(f64::MAX);
|
||||
let pb = b.change_pct.unwrap_or(f64::MAX);
|
||||
pa.partial_cmp(&pb).unwrap_or(Ordering::Equal)
|
||||
});
|
||||
}
|
||||
ScreenFilter::Week52High => {
|
||||
quotes.sort_by(|a, b| {
|
||||
let pa = a.week52_position.unwrap_or(f64::MIN);
|
||||
let pb = b.week52_position.unwrap_or(f64::MIN);
|
||||
pb.partial_cmp(&pa).unwrap_or(Ordering::Equal)
|
||||
});
|
||||
}
|
||||
ScreenFilter::Week52Low => {
|
||||
quotes.sort_by(|a, b| {
|
||||
let pa = a.week52_position.unwrap_or(f64::MAX);
|
||||
let pb = b.week52_position.unwrap_or(f64::MAX);
|
||||
pa.partial_cmp(&pb).unwrap_or(Ordering::Equal)
|
||||
});
|
||||
}
|
||||
ScreenFilter::HighVolume => {
|
||||
quotes.sort_by_key(|quote| std::cmp::Reverse(quote.volume));
|
||||
}
|
||||
ScreenFilter::LargeCap => {
|
||||
quotes.sort_by_key(|quote| std::cmp::Reverse(quote.market_cap.unwrap_or(0)));
|
||||
}
|
||||
ScreenFilter::HighDividend | ScreenFilter::LowPe => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ScreenFilter, ScreenRegion, sort_screener_quotes};
|
||||
use crate::api::types::Quote;
|
||||
use crate::error::IdxError;
|
||||
|
||||
fn sample_quote(symbol: &str, change_pct: Option<f64>) -> Quote {
|
||||
Quote {
|
||||
symbol: symbol.into(),
|
||||
price: 100,
|
||||
change: None,
|
||||
change_pct,
|
||||
volume: None,
|
||||
market_cap: None,
|
||||
week52_high: None,
|
||||
week52_low: None,
|
||||
week52_position: None,
|
||||
range_signal: None,
|
||||
prev_close: None,
|
||||
avg_volume: None,
|
||||
as_of: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_screener_top_performers_descending() {
|
||||
let mut quotes = vec![
|
||||
sample_quote("A", Some(1.0)),
|
||||
sample_quote("B", Some(5.0)),
|
||||
sample_quote("C", Some(3.0)),
|
||||
];
|
||||
sort_screener_quotes(&mut quotes, ScreenFilter::TopPerformers);
|
||||
let pcts: Vec<f64> = quotes.iter().filter_map(|q| q.change_pct).collect();
|
||||
assert_eq!(pcts, vec![5.0, 3.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_screener_worst_performers_ascending() {
|
||||
let mut quotes = vec![
|
||||
sample_quote("A", Some(1.0)),
|
||||
sample_quote("B", Some(-5.0)),
|
||||
sample_quote("C", Some(-2.0)),
|
||||
];
|
||||
sort_screener_quotes(&mut quotes, ScreenFilter::WorstPerformers);
|
||||
let pcts: Vec<f64> = quotes.iter().filter_map(|q| q.change_pct).collect();
|
||||
assert_eq!(pcts, vec![-5.0, -2.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_screener_puts_unknown_change_last_both_ways() {
|
||||
let mut quotes = vec![
|
||||
sample_quote("U", None),
|
||||
sample_quote("A", Some(1.0)),
|
||||
sample_quote("B", Some(-5.0)),
|
||||
];
|
||||
sort_screener_quotes(&mut quotes, ScreenFilter::WorstPerformers);
|
||||
assert_eq!(quotes.last().unwrap().symbol, "U");
|
||||
sort_screener_quotes(&mut quotes, ScreenFilter::TopPerformers);
|
||||
assert_eq!(quotes.last().unwrap().symbol, "U");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_screener_filter_returns_error() {
|
||||
let err = "bogus"
|
||||
.parse::<ScreenFilter>()
|
||||
.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 = "eu"
|
||||
.parse::<ScreenRegion>()
|
||||
.expect_err("invalid region should fail");
|
||||
assert!(matches!(err, IdxError::InvalidInput(_)));
|
||||
assert!(err.to_string().contains("invalid screener region"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_names_round_trip() {
|
||||
for filter in ScreenFilter::ALL {
|
||||
assert_eq!(filter.as_str().parse::<ScreenFilter>().unwrap(), filter);
|
||||
}
|
||||
for region in ScreenRegion::ALL {
|
||||
assert_eq!(region.as_str().parse::<ScreenRegion>().unwrap(), region);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue