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

File diff suppressed because it is too large Load diff

View file

@ -9,125 +9,69 @@ use crate::cli::Cli;
use crate::output::{OutputFormat, OutputSetting};
use idx_core::error::IdxError;
/// CLI settings: core data-access settings plus presentation.
#[derive(Debug, Clone)]
pub struct IdxConfig {
pub provider: ProviderKind,
pub history_provider: HistoryProviderKind,
pub exchange: String,
pub core: CoreConfig,
pub output: OutputFormat,
pub no_color: bool,
pub quote_ttl: u64,
pub fundamental_ttl: u64,
}
#[derive(Debug, Deserialize, Default)]
struct FileConfig {
general: Option<FileGeneral>,
cache: Option<FileCache>,
}
#[derive(Debug, Deserialize, Default)]
struct FileGeneral {
provider: Option<ProviderKind>,
history_provider: Option<HistoryProviderKind>,
exchange: Option<String>,
output: Option<OutputSetting>,
color: Option<bool>,
}
#[derive(Debug, Deserialize, Default)]
struct FileCache {
quote_ttl: Option<u64>,
fundamental_ttl: Option<u64>,
}
impl Default for IdxConfig {
fn default() -> Self {
Self {
provider: ProviderKind::Msn,
history_provider: HistoryProviderKind::Auto,
exchange: "JK".to_string(),
output: OutputSetting::Auto.resolve(stdout_is_terminal()),
no_color: false,
quote_ttl: 300,
fundamental_ttl: 3600,
}
}
}
impl IdxConfig {
pub fn load_with_cli(cli: &Cli) -> Result<Self, IdxError> {
let mut cfg = Self::load()?;
let mut core = CoreConfig::load()?;
core.verbose = cli.verbose > 0;
let mut output = OutputSetting::Auto.resolve(stdout_is_terminal());
let mut no_color = false;
if let Ok(provider) = std::env::var("IDX_PROVIDER") {
cfg.provider = ProviderKind::parse(&provider)?;
}
if let Ok(history_provider) = std::env::var("IDX_HISTORY_PROVIDER") {
cfg.history_provider = HistoryProviderKind::parse(&history_provider)?;
}
if let Ok(exchange) = std::env::var("IDX_EXCHANGE") {
cfg.exchange = exchange;
}
if let Ok(output) = std::env::var("IDX_OUTPUT") {
let setting = OutputSetting::parse(&output).ok_or_else(|| {
IdxError::ConfigError(format!(
"invalid IDX_OUTPUT value: '{output}', expected 'auto', 'table', 'json', or 'jsonl'"
))
})?;
cfg.output = setting.resolve(stdout_is_terminal());
}
if let Ok(no_color) = std::env::var("IDX_NO_COLOR") {
cfg.no_color = no_color == "1" || no_color.eq_ignore_ascii_case("true");
}
if let Some(parsed) = parse_env_ttl("IDX_CACHE_QUOTE_TTL")? {
cfg.quote_ttl = parsed;
}
if let Some(parsed) = parse_env_ttl("IDX_CACHE_FUNDAMENTAL_TTL")? {
cfg.fundamental_ttl = parsed;
}
if let Some(output) = cli.output {
cfg.output = output.resolve(stdout_is_terminal());
}
cfg.no_color = cfg.no_color || cli.no_color || !stdout_is_terminal();
Ok(cfg)
}
pub fn load() -> Result<Self, IdxError> {
let mut cfg = Self::default();
let path = config_path()?;
if path.exists() {
let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?;
let parsed: FileConfig =
toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?;
if let Some(general) = parsed.general {
if let Some(provider) = general.provider {
cfg.provider = provider;
}
if let Some(history_provider) = general.history_provider {
cfg.history_provider = history_provider;
}
if let Some(exchange) = general.exchange {
cfg.exchange = exchange;
}
if let Some(output) = general.output {
cfg.output = output.resolve(stdout_is_terminal());
if let Some(setting) = general.output {
output = setting.resolve(stdout_is_terminal());
}
if let Some(color) = general.color {
cfg.no_color = !color;
}
}
if let Some(cache) = parsed.cache {
if let Some(v) = cache.quote_ttl {
cfg.quote_ttl = v;
}
if let Some(v) = cache.fundamental_ttl {
cfg.fundamental_ttl = v;
no_color = !color;
}
}
}
Ok(cfg)
if let Ok(value) = std::env::var("IDX_OUTPUT") {
let setting = OutputSetting::parse(&value).ok_or_else(|| {
IdxError::ConfigError(format!(
"invalid IDX_OUTPUT value: '{value}', expected 'auto', 'table', 'json', or 'jsonl'"
))
})?;
output = setting.resolve(stdout_is_terminal());
}
if let Ok(value) = std::env::var("IDX_NO_COLOR") {
no_color = value == "1" || value.eq_ignore_ascii_case("true");
}
if let Some(setting) = cli.output {
output = setting.resolve(stdout_is_terminal());
}
no_color = no_color || cli.no_color || !stdout_is_terminal();
Ok(Self {
core,
output,
no_color,
})
}
}
@ -135,20 +79,6 @@ pub fn stdout_is_terminal() -> bool {
std::io::stdout().is_terminal()
}
#[cfg(test)]
mod tests {
use super::IdxConfig;
#[test]
fn default_values_are_sane() {
let cfg = IdxConfig::default();
assert_eq!(cfg.provider, super::ProviderKind::Msn);
assert_eq!(cfg.history_provider, super::HistoryProviderKind::Auto);
assert_eq!(cfg.exchange, "JK");
assert_eq!(cfg.quote_ttl, 300);
}
}
#[cfg(test)]
mod output_values_tests {
use clap::ValueEnum;

View file

@ -11,7 +11,6 @@ use clap_complete::{generate, shells};
use crate::cli::{Cli, Commands};
use crate::config::IdxConfig;
use crate::output::{OutputFormat, OutputSetting, emit_error};
use idx_core::api::default_provider;
use idx_core::error::IdxError;
fn main() {
@ -53,15 +52,7 @@ fn run() -> Result<(), IdxError> {
}
}
Commands::Stocks(stocks) => {
let provider = default_provider(config.provider, cli.verbose > 0);
if let Err(err) = cli::stocks::handle(
stocks,
&config,
&provider,
cli.offline,
cli.no_cache,
cli.verbose > 0,
) {
if let Err(err) = cli::stocks::handle(stocks, &config, cli.offline, cli.no_cache) {
emit_error(&err, &config.output);
return Err(err);
}

View file

@ -4,14 +4,13 @@ pub mod table;
use std::io::IsTerminal;
use chrono::NaiveDate;
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use serde::Serialize;
use idx_core::analysis::fundamental::{
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
};
use idx_core::analysis::signals::TechnicalSignal;
use idx_core::analysis::report::TechnicalReport;
use idx_core::api::types::{
CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote,
SentimentData,
@ -62,34 +61,6 @@ impl OutputFormat {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TechnicalReport {
pub symbol: String,
pub as_of: NaiveDate,
pub current_price: i64,
pub sma20: Option<f64>,
pub sma50: Option<f64>,
pub sma200: Option<f64>,
pub rsi14: Option<f64>,
pub macd: MacdSnapshot,
pub volume: VolumeSnapshot,
pub signals: TechnicalSignal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MacdSnapshot {
pub line: Option<f64>,
pub signal: Option<f64>,
pub histogram: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeSnapshot {
pub current: u64,
pub average20: Option<f64>,
pub ratio20: Option<f64>,
}
pub fn render_quotes(
quotes: &[Quote],
format: &OutputFormat,

View file

@ -1,10 +1,10 @@
use crate::output::style::Paint;
use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
use crate::output::TechnicalReport;
use idx_core::analysis::fundamental::{
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
};
use idx_core::analysis::report::TechnicalReport;
use idx_core::analysis::signals::Signal;
use idx_core::api::types::{
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc,

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)),
)
}
}

View file

@ -0,0 +1,153 @@
//! Technical analysis report built from daily history.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use super::signals::{self, Signal, TechnicalSignal};
use super::technical;
use crate::api::types::Ohlc;
use crate::error::IdxError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TechnicalReport {
pub symbol: String,
pub as_of: NaiveDate,
pub current_price: i64,
pub sma20: Option<f64>,
pub sma50: Option<f64>,
pub sma200: Option<f64>,
pub rsi14: Option<f64>,
pub macd: MacdSnapshot,
pub volume: VolumeSnapshot,
pub signals: TechnicalSignal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MacdSnapshot {
pub line: Option<f64>,
pub signal: Option<f64>,
pub histogram: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeSnapshot {
pub current: u64,
pub average20: Option<f64>,
pub ratio20: Option<f64>,
}
pub fn build_technical_report(symbol: &str, history: &[Ohlc]) -> Result<TechnicalReport, IdxError> {
let latest = history
.last()
.ok_or_else(|| IdxError::ParseError(format!("no history available for {symbol}")))?;
let closes: Vec<f64> = history.iter().map(|item| item.close as f64).collect();
let volumes: Vec<f64> = history.iter().map(|item| item.volume as f64).collect();
let sma20 = last_value(&technical::sma(&closes, 20));
let sma50 = last_value(&technical::sma(&closes, 50));
let sma200 = last_value(&technical::sma(&closes, 200));
let rsi14 = last_value(&technical::rsi(&closes, 14));
let macd = technical::macd(&closes, 12, 26, 9);
let macd_line = last_value(&macd.macd_line);
let signal_line = last_value(&macd.signal_line);
let histogram = last_value(&macd.histogram);
let previous_histogram = previous_value(&macd.histogram);
let average_volume20 = average_last(&volumes, 20);
let volume_ratio20 = technical::volume_ratio(&volumes, 20);
let rsi_signal = rsi14.map_or(Signal::Neutral, signals::interpret_rsi);
let macd_signal = histogram
.map(|value| signals::interpret_macd(value, previous_histogram))
.unwrap_or(Signal::Neutral);
let trend_signal = signals::interpret_trend(latest.close as f64, sma50, sma200);
let overall = signals::overall_signal(rsi_signal, macd_signal, trend_signal);
Ok(TechnicalReport {
symbol: symbol.to_string(),
as_of: latest.date,
current_price: latest.close,
sma20,
sma50,
sma200,
rsi14,
macd: MacdSnapshot {
line: macd_line,
signal: signal_line,
histogram,
},
volume: VolumeSnapshot {
current: latest.volume,
average20: average_volume20,
ratio20: volume_ratio20,
},
signals: TechnicalSignal {
rsi: rsi_signal,
macd: macd_signal,
trend: trend_signal,
overall,
},
})
}
fn last_value(values: &[Option<f64>]) -> Option<f64> {
values.iter().rev().find_map(|value| *value)
}
fn previous_value(values: &[Option<f64>]) -> Option<f64> {
let mut seen_latest = false;
for value in values.iter().rev() {
if value.is_some() {
if seen_latest {
return *value;
}
seen_latest = true;
}
}
None
}
fn average_last(values: &[f64], period: usize) -> Option<f64> {
if period == 0 || values.len() < period {
return None;
}
let start = values.len() - period;
Some(values[start..].iter().sum::<f64>() / period as f64)
}
#[cfg(test)]
mod tests {
use chrono::{Days, NaiveDate};
use super::build_technical_report;
use crate::analysis::signals::Signal;
use crate::api::types::Ohlc;
#[test]
fn technical_report_uses_latest_values() {
let start = NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date");
let history: Vec<Ohlc> = (0..60)
.map(|idx| Ohlc {
date: start
.checked_add_days(Days::new(idx as u64))
.expect("valid offset"),
open: 100 + idx as i64,
high: 101 + idx as i64,
low: 99 + idx as i64,
close: 100 + idx as i64,
volume: 1_000 + idx as u64 * 10,
})
.collect();
let report = build_technical_report("BBCA.JK", &history).expect("report should build");
assert_eq!(report.symbol, "BBCA.JK");
assert_eq!(report.current_price, 159);
assert!(report.sma20.is_some());
assert!(report.sma50.is_some());
assert_eq!(report.sma200, None);
assert!(report.rsi14.is_some());
assert!(report.volume.ratio20.is_some());
assert_eq!(report.signals.trend, Signal::Neutral);
}
}

View file

@ -245,6 +245,18 @@ fn msn_capability_error(subject: &str) -> IdxError {
IdxError::Unsupported(format!("{subject}: command requires --provider msn"))
}
/// The provider that serves history for `provider` under `history_mode`.
pub fn history_source(provider: ProviderKind, history_mode: HistoryProviderKind) -> ProviderKind {
match history_mode {
HistoryProviderKind::Yahoo => ProviderKind::Yahoo,
HistoryProviderKind::Msn => ProviderKind::Msn,
HistoryProviderKind::Auto => match provider {
ProviderKind::Yahoo => ProviderKind::Yahoo,
ProviderKind::Msn => ProviderKind::Yahoo,
},
}
}
/// Resolves a history provider based on the selected market data provider and
/// history provider strategy.
///
@ -255,14 +267,7 @@ pub fn history_provider(
history_mode: HistoryProviderKind,
verbose: bool,
) -> Result<(ProviderKind, Box<dyn HistoryProvider>), IdxError> {
let resolved = match history_mode {
HistoryProviderKind::Yahoo => ProviderKind::Yahoo,
HistoryProviderKind::Msn => ProviderKind::Msn,
HistoryProviderKind::Auto => match provider {
ProviderKind::Yahoo => ProviderKind::Yahoo,
ProviderKind::Msn => ProviderKind::Yahoo,
},
};
let resolved = history_source(provider, history_mode);
if mock_enabled()
&& let Some(mock) = mock_history(resolved, verbose)

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");

View 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)
}
}
}

View file

@ -74,7 +74,113 @@ impl HistoryProviderKind {
}
}
pub fn parse_env_ttl(name: &str) -> Result<Option<u64>, IdxError> {
/// Settings for data access, shared by every frontend. Presentation settings
/// (output format, color) are not part of it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreConfig {
pub provider: ProviderKind,
pub history_provider: HistoryProviderKind,
pub exchange: String,
pub quote_ttl: u64,
pub fundamental_ttl: u64,
/// Cache root; `None` resolves `$XDG_CACHE_HOME/idx` or the platform default.
pub cache_dir: Option<PathBuf>,
/// Provider diagnostics on stderr.
pub verbose: bool,
}
impl Default for CoreConfig {
fn default() -> Self {
Self {
provider: ProviderKind::Msn,
history_provider: HistoryProviderKind::Auto,
exchange: "JK".to_string(),
quote_ttl: 300,
fundamental_ttl: 3600,
cache_dir: None,
verbose: false,
}
}
}
#[derive(Debug, Deserialize, Default)]
struct FileConfig {
general: Option<FileGeneral>,
cache: Option<FileCache>,
}
#[derive(Debug, Deserialize, Default)]
struct FileGeneral {
provider: Option<ProviderKind>,
history_provider: Option<HistoryProviderKind>,
exchange: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct FileCache {
quote_ttl: Option<u64>,
fundamental_ttl: Option<u64>,
}
impl CoreConfig {
/// Defaults, then `config.toml`, then `IDX_PROVIDER`, `IDX_HISTORY_PROVIDER`,
/// `IDX_EXCHANGE`, `IDX_CACHE_QUOTE_TTL`, and `IDX_CACHE_FUNDAMENTAL_TTL`.
/// This is the only place idx-core reads those settings from the environment.
pub fn load() -> Result<Self, IdxError> {
let mut cfg = Self::default();
let path = config_path()?;
if path.exists() {
let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?;
let parsed: FileConfig =
toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?;
if let Some(general) = parsed.general {
if let Some(provider) = general.provider {
cfg.provider = provider;
}
if let Some(history_provider) = general.history_provider {
cfg.history_provider = history_provider;
}
if let Some(exchange) = general.exchange {
cfg.exchange = exchange;
}
}
if let Some(cache) = parsed.cache {
if let Some(v) = cache.quote_ttl {
cfg.quote_ttl = v;
}
if let Some(v) = cache.fundamental_ttl {
cfg.fundamental_ttl = v;
}
}
}
if let Ok(provider) = std::env::var("IDX_PROVIDER") {
cfg.provider = ProviderKind::parse(&provider)?;
}
if let Ok(history_provider) = std::env::var("IDX_HISTORY_PROVIDER") {
cfg.history_provider = HistoryProviderKind::parse(&history_provider)?;
}
if let Ok(exchange) = std::env::var("IDX_EXCHANGE") {
cfg.exchange = exchange;
}
if let Some(parsed) = parse_env_ttl("IDX_CACHE_QUOTE_TTL")? {
cfg.quote_ttl = parsed;
}
if let Some(parsed) = parse_env_ttl("IDX_CACHE_FUNDAMENTAL_TTL")? {
cfg.fundamental_ttl = parsed;
}
Ok(cfg)
}
pub fn cache_dir(&self) -> Result<PathBuf, IdxError> {
match &self.cache_dir {
Some(dir) => Ok(dir.clone()),
None => crate::cache::cache_dir(),
}
}
}
fn parse_env_ttl(name: &str) -> Result<Option<u64>, IdxError> {
let Ok(raw) = std::env::var(name) else {
return Ok(None);
};
@ -258,6 +364,15 @@ pub fn set_config_value(key: &str, value: &str) -> Result<(), IdxError> {
#[cfg(test)]
mod tests {
#[test]
fn default_values_are_sane() {
let cfg = super::CoreConfig::default();
assert_eq!(cfg.provider, super::ProviderKind::Msn);
assert_eq!(cfg.history_provider, super::HistoryProviderKind::Auto);
assert_eq!(cfg.exchange, "JK");
assert_eq!(cfg.quote_ttl, 300);
}
#[test]
fn parses_provider_values() {
assert_eq!(

View file

@ -0,0 +1,397 @@
//! Company financial data: profile, statements, earnings, sentiment,
//! insights, and news. These require the MSN provider.
use crate::api::types::{
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem,
SentimentData,
};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::error::IdxError;
pub struct Financial<'a> {
pub(crate) idx: &'a Idx,
}
impl Financial<'_> {
pub fn profile(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<CompanyProfile>, IdxError> {
let resolved = self.idx.resolve(symbol)?;
let provider = self.idx.provider.profile_provider(&resolved)?;
self.fetch(
"profile-v2",
&resolved,
&resolved,
self.ttl(),
policy,
|| provider.profile(&resolved),
)
}
pub fn statements(
&self,
symbol: &str,
filter: &StatementFilter,
policy: CachePolicy,
) -> Result<Fetched<FinancialStatements>, IdxError> {
let resolved = self.idx.resolve(symbol)?;
let provider = self.idx.provider.financials_provider(&resolved)?;
let fetched = self.fetch(
"financials",
&resolved,
&resolved,
self.ttl(),
policy,
|| provider.financials(&resolved),
)?;
Ok(fetched.map(|mut financials| {
if financials.instrument.symbol.trim().is_empty()
|| !financials.instrument.symbol.contains('.')
{
financials.instrument.symbol = resolved.clone();
}
filter.apply(&financials)
}))
}
pub fn earnings(
&self,
symbol: &str,
filter: EarningsFilter,
policy: CachePolicy,
) -> Result<Fetched<EarningsReport>, IdxError> {
let resolved = self.idx.resolve(symbol)?;
let provider = self.idx.provider.earnings_provider(&resolved)?;
let fetched = self.fetch("earnings", &resolved, &resolved, self.ttl(), policy, || {
provider.earnings(&resolved)
})?;
Ok(fetched.map(|mut earnings| {
if earnings.symbol.is_empty() {
earnings.symbol = resolved.clone();
}
filter.apply(&earnings)
}))
}
pub fn sentiment(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<SentimentData>, IdxError> {
let resolved = self.idx.resolve(symbol)?;
let provider = self.idx.provider.sentiment_provider(&resolved)?;
self.fetch(
"sentiment",
&resolved,
&resolved,
self.ttl(),
policy,
|| provider.sentiment(&resolved),
)
}
pub fn insights(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<InsightData>, IdxError> {
let resolved = self.idx.resolve(symbol)?;
let provider = self.idx.provider.insights_provider(&resolved)?;
let fetched = self.fetch(
"insights-v2",
&resolved,
&resolved,
self.ttl(),
policy,
|| provider.insights(&resolved),
)?;
Ok(fetched.map(|mut insights| {
if insights.symbol.is_empty() {
insights.symbol = resolved.clone();
}
insights
}))
}
pub fn news(
&self,
symbol: &str,
limit: usize,
policy: CachePolicy,
) -> Result<Fetched<Vec<NewsItem>>, IdxError> {
let resolved = self.idx.resolve(symbol)?;
let key = format!("{resolved}-{limit}");
let provider = self.idx.provider.news_provider(&resolved)?;
let ttl = self.idx.config.quote_ttl;
let fetched = self.fetch("news", &key, &resolved, ttl, policy, || {
provider.news(&resolved, limit)
})?;
Ok(fetched.map(|mut news| {
for item in &mut news {
if item.symbol.is_empty() {
item.symbol = resolved.clone();
}
}
news
}))
}
fn ttl(&self) -> u64 {
self.idx.config.fundamental_ttl
}
fn fetch<T>(
&self,
bucket: &str,
key: &str,
subject: &str,
ttl_secs: u64,
policy: CachePolicy,
fetch: impl FnOnce() -> Result<T, IdxError>,
) -> Result<Fetched<T>, IdxError>
where
T: serde::Serialize + serde::de::DeserializeOwned,
{
let bucket = self.idx.bucket(bucket);
cached_fetch(
&self.idx.cache,
CacheSpec {
bucket: &bucket,
key,
ttl_secs,
subject,
source: self.idx.provider.kind().as_str(),
},
policy,
fetch,
Ok,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
pub enum StatementKind {
Income,
Balance,
Cashflow,
}
/// Statements to keep; empty keeps all. Excluded sections become `None`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StatementFilter {
pub statements: Vec<StatementKind>,
}
impl StatementFilter {
pub fn all() -> Self {
Self::default()
}
fn includes(&self, kind: StatementKind) -> bool {
self.statements.is_empty() || self.statements.contains(&kind)
}
pub fn apply(&self, financials: &FinancialStatements) -> FinancialStatements {
FinancialStatements {
instrument: financials.instrument.clone(),
balance_sheet: self
.includes(StatementKind::Balance)
.then(|| financials.balance_sheet.clone())
.flatten(),
cash_flow: self
.includes(StatementKind::Cashflow)
.then(|| financials.cash_flow.clone())
.flatten(),
income_statement: self
.includes(StatementKind::Income)
.then(|| financials.income_statement.clone())
.flatten(),
}
}
}
/// Earnings scope. With neither `forecast` nor `history` set both are kept;
/// with neither `annual` nor `quarterly` set every period is kept.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct EarningsFilter {
pub forecast: bool,
pub history: bool,
pub annual: bool,
pub quarterly: bool,
}
impl EarningsFilter {
fn include_forecast(self) -> bool {
self.forecast || !self.history
}
fn include_history(self) -> bool {
self.history || !self.forecast
}
fn includes_period(self, period_type: &str) -> bool {
if !self.annual && !self.quarterly {
return true;
}
match classify_earnings_period(period_type) {
EarningsPeriodKind::Annual => self.annual || !self.quarterly,
EarningsPeriodKind::Quarterly => self.quarterly || !self.annual,
EarningsPeriodKind::Unknown => false,
}
}
pub fn apply(self, report: &EarningsReport) -> EarningsReport {
let filter_rows = |rows: &[EarningsData]| {
rows.iter()
.filter(|row| self.includes_period(&row.period_type))
.cloned()
.collect()
};
EarningsReport {
symbol: report.symbol.clone(),
eps_last_year: report.eps_last_year,
revenue_last_year: report.revenue_last_year,
forecast: if self.include_forecast() {
filter_rows(&report.forecast)
} else {
Vec::new()
},
history: if self.include_history() {
filter_rows(&report.history)
} else {
Vec::new()
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EarningsPeriodKind {
Annual,
Quarterly,
Unknown,
}
fn classify_earnings_period(period_type: &str) -> EarningsPeriodKind {
let trimmed = period_type.trim();
if trimmed.is_empty() {
return EarningsPeriodKind::Unknown;
}
let upper = trimmed.to_ascii_uppercase();
if upper.starts_with('Q') {
return EarningsPeriodKind::Quarterly;
}
if upper.starts_with("FY") || (upper.len() == 4 && upper.chars().all(|ch| ch.is_ascii_digit()))
{
return EarningsPeriodKind::Annual;
}
EarningsPeriodKind::Unknown
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{
EarningsFilter, EarningsPeriodKind, StatementFilter, StatementKind,
classify_earnings_period,
};
use crate::api::types::{
EarningsData, EarningsReport, FinancialStatements, InstrumentInfo, StatementSection,
};
fn section(key: &str, value: f64) -> Option<StatementSection> {
Some(StatementSection {
values: BTreeMap::from([(key.into(), value)]),
currency: "IDR".into(),
report_date: "2026-01-01".into(),
end_date: "2025-12-31".into(),
})
}
#[test]
fn filters_financial_statements_to_requested_sections() {
let sample = FinancialStatements {
instrument: InstrumentInfo {
id: "123".into(),
symbol: "BBCA.JK".into(),
name: "BCA".into(),
},
balance_sheet: section("assets", 1.0),
cash_flow: section("cash", 2.0),
income_statement: section("income", 3.0),
};
let filtered = StatementFilter {
statements: vec![StatementKind::Cashflow],
}
.apply(&sample);
assert!(filtered.cash_flow.is_some());
assert!(filtered.income_statement.is_none());
assert!(filtered.balance_sheet.is_none());
assert_eq!(filtered.instrument.symbol, "BBCA.JK");
}
fn earnings_row(period_type: &str, eps_forecast: Option<f64>) -> EarningsData {
EarningsData {
eps_actual: None,
eps_forecast,
eps_surprise: None,
eps_surprise_pct: None,
revenue_actual: None,
revenue_forecast: None,
revenue_surprise: None,
earning_release_date: None,
period_type: period_type.into(),
}
}
#[test]
fn filters_earnings_by_scope_and_period() {
let report = EarningsReport {
symbol: "BBCA.JK".into(),
eps_last_year: 1_200.0,
revenue_last_year: 100_000_000_000.0,
forecast: vec![
earnings_row("2026", Some(1_300.0)),
earnings_row("Q12026", Some(330.0)),
],
history: vec![earnings_row("2025", None)],
};
let filtered = EarningsFilter {
forecast: true,
history: false,
annual: true,
quarterly: false,
}
.apply(&report);
assert_eq!(filtered.symbol, "BBCA.JK");
assert!(filtered.history.is_empty());
assert_eq!(filtered.forecast.len(), 1);
assert_eq!(filtered.forecast[0].period_type, "2026");
}
#[test]
fn classifies_earnings_periods() {
assert_eq!(classify_earnings_period("2025"), EarningsPeriodKind::Annual);
assert_eq!(
classify_earnings_period("FY2026"),
EarningsPeriodKind::Annual
);
assert_eq!(
classify_earnings_period("Q12026"),
EarningsPeriodKind::Quarterly
);
assert_eq!(classify_earnings_period(""), EarningsPeriodKind::Unknown);
}
}

View file

@ -5,12 +5,17 @@
pub mod analysis;
pub mod api;
pub mod cache;
mod client;
pub mod config;
pub mod curl_impersonate;
pub mod error;
pub mod financial;
pub mod market;
#[cfg(feature = "ownership")]
pub mod ownership;
pub use client::{CachePolicy, Fetched, Idx};
pub mod runtime {
use std::fmt::Display;
use std::sync::atomic::{AtomicBool, Ordering};

View 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);
}
}
}