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
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
153
crates/idx-core/src/analysis/report.rs
Normal file
153
crates/idx-core/src/analysis/report.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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!(
|
||||
|
|
|
|||
397
crates/idx-core/src/financial.rs
Normal file
397
crates/idx-core/src/financial.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
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