From 86cb1b4b7c1c2b50c9b815d12bb1ddf29ef58f7f Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 09:41:31 +0000 Subject: [PATCH] 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 { 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 --- crates/idx-cli/src/cli/stocks.rs | 1192 ++---------------------- crates/idx-cli/src/config.rs | 134 +-- crates/idx-cli/src/main.rs | 11 +- crates/idx-cli/src/output/mod.rs | 33 +- crates/idx-cli/src/output/table.rs | 2 +- crates/idx-core/src/analysis/mod.rs | 137 +++ crates/idx-core/src/analysis/report.rs | 153 +++ crates/idx-core/src/api/mod.rs | 21 +- crates/idx-core/src/cache.rs | 74 +- crates/idx-core/src/client.rs | 177 ++++ crates/idx-core/src/config.rs | 117 ++- crates/idx-core/src/financial.rs | 397 ++++++++ crates/idx-core/src/lib.rs | 5 + crates/idx-core/src/market.rs | 409 ++++++++ docs/ARCHITECTURE.md | 14 +- docs/DESIGN.md | 38 +- 16 files changed, 1613 insertions(+), 1301 deletions(-) create mode 100644 crates/idx-core/src/analysis/report.rs create mode 100644 crates/idx-core/src/client.rs create mode 100644 crates/idx-core/src/financial.rs create mode 100644 crates/idx-core/src/market.rs diff --git a/crates/idx-cli/src/cli/stocks.rs b/crates/idx-cli/src/cli/stocks.rs index 8aaae85..21f14ab 100644 --- a/crates/idx-cli/src/cli/stocks.rs +++ b/crates/idx-cli/src/cli/stocks.rs @@ -1,55 +1,18 @@ -use std::cmp::Ordering; use std::io::Read; -use clap::{Args, Subcommand, ValueEnum}; -use serde::{Serialize, de::DeserializeOwned}; +use clap::{Args, Subcommand}; -use crate::config::{HistoryProviderKind, IdxConfig, ProviderKind}; +use crate::config::{HistoryProviderKind, IdxConfig}; use crate::output::{ - MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_earnings, - render_financials, render_fundamental, render_growth, render_history, render_insights, - render_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment, - render_technical, render_valuation, + render_compare, render_earnings, render_financials, render_fundamental, render_growth, + render_history, render_insights, render_news, render_profile, render_quotes, render_risk, + render_screener, render_sentiment, render_technical, render_valuation, }; -use idx_core::analysis::fundamental::{ - FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental, - analyze_growth, analyze_risk, analyze_valuation, -}; -use idx_core::analysis::signals::{self, Signal, TechnicalSignal}; -use idx_core::analysis::technical; -use idx_core::api::types::{ - CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval, - NewsItem, Ohlc, Period, Quote, SentimentData, -}; -use idx_core::api::{MarketDataProvider, SelectedProvider, history_provider}; -use idx_core::cache::Cache; +use idx_core::api::types::{Interval, Period}; use idx_core::error::IdxError; -use idx_core::runtime; - -struct FundamentalCacheSpec { - bucket: String, - ttl_secs: u64, -} - -struct CacheFetchSpec<'a> { - bucket: &'a str, - key: &'a str, - ttl_secs: u64, - subject: &'a str, -} - -const SCREENER_FILTERS: &[&str] = &[ - "top-performers", - "worst-performers", - "high-dividend", - "low-pe", - "52w-high", - "52w-low", - "high-volume", - "large-cap", -]; - -const SCREENER_REGIONS: &[&str] = &["id", "us", "sg", "hk", "jp"]; +use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind}; +use idx_core::market::ScreenQuery; +use idx_core::{CachePolicy, Idx, runtime}; /// Splits comma-separated symbol arguments; `-` reads symbols from stdin, /// separated by whitespace or commas. @@ -79,22 +42,11 @@ fn expand_symbols(args: &[String]) -> Result, IdxError> { Ok(symbols) } -fn cache_bucket(provider: ProviderKind, key: &str) -> String { - format!("{}-{key}", provider.as_str()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -enum FinancialStatementKind { - Income, - Balance, - Cashflow, -} - #[derive(Debug, Clone, Args, Default)] pub struct FinancialsFilterArgs { /// Limit output to one or more statements. #[arg(long, value_enum, value_delimiter = ',', num_args = 1..)] - statement: Vec, + statement: Vec, } #[derive(Debug, Clone, Copy, Args, Default)] @@ -113,35 +65,17 @@ pub struct EarningsFilterArgs { quarterly: bool, } -impl EarningsFilterArgs { - 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, +impl From for EarningsFilter { + fn from(args: EarningsFilterArgs) -> Self { + Self { + forecast: args.forecast, + history: args.history, + annual: args.annual, + quarterly: args.quarterly, } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EarningsPeriodKind { - Annual, - Quarterly, - Unknown, -} - #[derive(Debug, Args)] #[command(about = "Stock data and analysis")] pub struct StocksCmd { @@ -269,1085 +203,131 @@ pub enum StocksSubcommand { pub fn handle( cmd: &StocksCmd, config: &IdxConfig, - provider: &SelectedProvider, offline: bool, no_cache: bool, - verbose: bool, ) -> Result<(), IdxError> { - if offline && no_cache { - return Err(IdxError::InvalidInput( - "cannot combine --offline with --no-cache".to_string(), - )); - } - - let cache = Cache::new()?; + let policy = CachePolicy::from_flags(offline, no_cache)?; + let idx = Idx::new(config.core.clone())?; + let format = &config.output; + let no_color = config.no_color; match &cmd.command { StocksSubcommand::Quote { symbols } => { - let quote_bucket = cache_bucket(provider.kind(), "quote"); - let mut quotes = Vec::new(); - for sym in expand_symbols(symbols)? { - let resolved = idx_core::api::resolve_symbol(&sym, &config.exchange)?; - if !no_cache && let Some(q) = cache.get("e_bucket, &resolved)? { - quotes.push(q); - continue; - } - if offline { - let stale = cache - .get_offline("e_bucket, &resolved)? - .ok_or_else(|| IdxError::CacheMiss(format!("{quote_bucket}/{resolved}")))?; - quotes.push(stale); - continue; - } - - match provider.market().quote(&resolved) { - Ok(q) => { - if !no_cache { - cache.put("e_bucket, &resolved, &q, config.quote_ttl)?; - } - quotes.push(q); - } - Err(err) => { - if !no_cache - && let Some(stale) = cache.get_stale("e_bucket, &resolved)? - { - runtime::warn(format!( - "network failed, serving stale cache for {resolved}" - )); - quotes.push(stale); - continue; - } - return Err(err); - } - } - } - render_quotes("es, &config.output, config.no_color) + let quotes = idx.market().quotes(&expand_symbols(symbols)?, policy)?; + let quotes: Vec<_> = quotes.into_iter().map(|q| q.data).collect(); + render_quotes("es, format, no_color) } StocksSubcommand::History { symbol, period, interval, - history_provider: history_provider_override, + history_provider, } => { - let history_mode = history_provider_override.unwrap_or(config.history_provider); - let (history_source, hist_provider) = - history_provider(provider.kind(), history_mode, verbose)?; - if matches!(history_mode, HistoryProviderKind::Auto) - && history_source != provider.kind() - && !config.output.is_json() - { - runtime::info(format!( - "history provider fallback active ({} -> {})", - provider.kind().as_str(), - history_source.as_str() - )); - } - let history_bucket = format!("{}-history", history_source.as_str()); - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let key = format!("{}-{}", period.as_str(), interval.as_str()); - if !no_cache - && let Some(history) = cache.get::>( - &history_bucket, - &format!("{resolved}-{key}"), - )? - { - return render_history(&resolved, &history, &config.output); - } - if offline { - let stale = cache - .get_offline::>( - &history_bucket, - &format!("{resolved}-{key}"), - )? - .ok_or_else(|| { - IdxError::CacheMiss(format!("{history_bucket}/{resolved}-{key}")) - })?; - return render_history(&resolved, &stale, &config.output); - } - - match hist_provider.history(&resolved, period, interval) { - Ok(history) => { - if !no_cache { - cache.put( - &history_bucket, - &format!("{resolved}-{key}"), - &history, - config.quote_ttl, - )?; - } - render_history(&resolved, &history, &config.output) - } - Err(err) => { - if !no_cache - && let Some(stale) = cache.get_stale::>( - &history_bucket, - &format!("{resolved}-{key}"), - )? - { - runtime::warn(format!( - "network failed, serving stale cache for {resolved}" - )); - return render_history(&resolved, &stale, &config.output); - } - Err(err) - } - } + let source = idx.market().history_source(*history_provider); + announce_history_fallback(&idx, source, config); + let history = idx + .market() + .history(symbol, period, interval, source, policy)?; + let resolved = idx_core::api::resolve_symbol(symbol, &config.core.exchange)?; + render_history(&resolved, &history.data, format) } StocksSubcommand::Technical { symbol, - history_provider: history_provider_override, + history_provider, } => { - let history_mode = history_provider_override.unwrap_or(config.history_provider); - let (history_source, hist_provider) = - history_provider(provider.kind(), history_mode, verbose)?; - if matches!(history_mode, HistoryProviderKind::Auto) - && history_source != provider.kind() - && !config.output.is_json() - { - runtime::info(format!( - "history provider fallback active ({} -> {})", - provider.kind().as_str(), - history_source.as_str() - )); - } - let technical_bucket = format!("{}-technical", history_source.as_str()); - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - if !no_cache - && let Some(report) = cache.get::(&technical_bucket, &resolved)? - { - return render_technical(&report, &config.output, config.no_color); - } - if offline { - let stale = cache - .get_offline::(&technical_bucket, &resolved)? - .ok_or_else(|| IdxError::CacheMiss(format!("{technical_bucket}/{resolved}")))?; - return render_technical(&stale, &config.output, config.no_color); - } - - match hist_provider.history(&resolved, &Period::OneYear, &Interval::Day) { - Ok(history) => { - let report = build_technical_report(&resolved, &history)?; - if !no_cache { - cache.put(&technical_bucket, &resolved, &report, config.quote_ttl)?; - } - render_technical(&report, &config.output, config.no_color) - } - Err(err) => { - if !no_cache - && let Some(stale) = - cache.get_stale::(&technical_bucket, &resolved)? - { - runtime::warn(format!( - "network failed, serving stale cache for {resolved}" - )); - return render_technical(&stale, &config.output, config.no_color); - } - Err(err) - } - } + let source = idx.market().history_source(*history_provider); + announce_history_fallback(&idx, source, config); + let report = idx.analysis().technical(symbol, source, policy)?; + render_technical(&report.data, format, no_color) } StocksSubcommand::Growth { symbol } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let report: GrowthReport = fetch_fundamental_analysis_report( - &cache, - provider.market(), - &resolved, - FundamentalCacheSpec { - bucket: cache_bucket(provider.kind(), "growth"), - ttl_secs: config.fundamental_ttl, - }, - offline, - no_cache, - |_, fundamentals| analyze_growth(fundamentals), - )?; - render_growth(&resolved, &report, &config.output, config.no_color) + let report = idx.analysis().growth(symbol, policy)?; + render_growth(&resolve(symbol, config)?, &report.data, format, no_color) } StocksSubcommand::Valuation { symbol } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let report: ValuationReport = fetch_fundamental_analysis_report( - &cache, - provider.market(), - &resolved, - FundamentalCacheSpec { - bucket: cache_bucket(provider.kind(), "valuation"), - ttl_secs: config.fundamental_ttl, - }, - offline, - no_cache, - |_, fundamentals| analyze_valuation(fundamentals), - )?; - render_valuation(&resolved, &report, &config.output, config.no_color) + let report = idx.analysis().valuation(symbol, policy)?; + render_valuation(&resolve(symbol, config)?, &report.data, format, no_color) } StocksSubcommand::Risk { symbol } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let report: RiskReport = fetch_fundamental_analysis_report( - &cache, - provider.market(), - &resolved, - FundamentalCacheSpec { - bucket: cache_bucket(provider.kind(), "risk"), - ttl_secs: config.fundamental_ttl, - }, - offline, - no_cache, - |_, fundamentals| analyze_risk(fundamentals), - )?; - render_risk(&resolved, &report, &config.output, config.no_color) + let report = idx.analysis().risk(symbol, policy)?; + render_risk(&resolve(symbol, config)?, &report.data, format, no_color) } StocksSubcommand::Fundamental { symbol } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let report: FundamentalReport = fetch_fundamental_analysis_report( - &cache, - provider.market(), - &resolved, - FundamentalCacheSpec { - bucket: cache_bucket(provider.kind(), "fundamental"), - ttl_secs: config.fundamental_ttl, - }, - offline, - no_cache, - analyze_fundamental, - )?; - render_fundamental(&report, &config.output, config.no_color) + let report = idx.analysis().fundamental(symbol, policy)?; + render_fundamental(&report.data, format, no_color) } StocksSubcommand::Profile { symbol } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(provider.kind(), "profile-v2"); - let profile_provider = provider.profile_provider(&resolved)?; - let profile: CompanyProfile = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &resolved, - ttl_secs: config.fundamental_ttl, - subject: &resolved, - }, - offline, - no_cache, - || profile_provider.profile(&resolved), - )?; - render_profile(&profile, &config.output) + let profile = idx.financial().profile(symbol, policy)?; + render_profile(&profile.data, format) } StocksSubcommand::Financials { symbol, filters } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(provider.kind(), "financials"); - let financials_provider = provider.financials_provider(&resolved)?; - let mut financials: FinancialStatements = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &resolved, - ttl_secs: config.fundamental_ttl, - subject: &resolved, - }, - offline, - no_cache, - || financials_provider.financials(&resolved), - )?; - if financials.instrument.symbol.trim().is_empty() - || !financials.instrument.symbol.contains('.') - { - financials.instrument.symbol = resolved.clone(); - } - let filtered = filter_financial_statements(&financials, filters); - render_financials(&filtered, &config.output) + let filter = StatementFilter { + statements: filters.statement.clone(), + }; + let financials = idx.financial().statements(symbol, &filter, policy)?; + render_financials(&financials.data, format) } StocksSubcommand::Earnings { symbol, filters } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(provider.kind(), "earnings"); - let earnings_provider = provider.earnings_provider(&resolved)?; - let mut earnings: EarningsReport = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &resolved, - ttl_secs: config.fundamental_ttl, - subject: &resolved, - }, - offline, - no_cache, - || earnings_provider.earnings(&resolved), - )?; - if earnings.symbol.is_empty() { - earnings.symbol = resolved.clone(); - } - let filtered = filter_earnings_report(&earnings, filters); - render_earnings(&filtered, &config.output) + let earnings = + idx.financial() + .earnings(symbol, EarningsFilter::from(*filters), policy)?; + render_earnings(&earnings.data, format) } StocksSubcommand::Sentiment { symbol } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(provider.kind(), "sentiment"); - let sentiment_provider = provider.sentiment_provider(&resolved)?; - let sentiment: SentimentData = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &resolved, - ttl_secs: config.fundamental_ttl, - subject: &resolved, - }, - offline, - no_cache, - || sentiment_provider.sentiment(&resolved), - )?; - render_sentiment(&sentiment, &config.output) + let sentiment = idx.financial().sentiment(symbol, policy)?; + render_sentiment(&sentiment.data, format) } StocksSubcommand::Insights { symbol } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(provider.kind(), "insights-v2"); - let insights_provider = provider.insights_provider(&resolved)?; - let mut insights: InsightData = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &resolved, - ttl_secs: config.fundamental_ttl, - subject: &resolved, - }, - offline, - no_cache, - || insights_provider.insights(&resolved), - )?; - if insights.symbol.is_empty() { - insights.symbol = resolved.clone(); - } - render_insights(&insights, &config.output) + let insights = idx.financial().insights(symbol, policy)?; + render_insights(&insights.data, format) } StocksSubcommand::News { symbol, limit } => { - let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?; - let bucket = cache_bucket(provider.kind(), "news"); - let key = format!("{resolved}-{limit}"); - let news_provider = provider.news_provider(&resolved)?; - let mut news: Vec = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &key, - ttl_secs: config.quote_ttl, - subject: &resolved, - }, - offline, - no_cache, - || news_provider.news(&resolved, *limit), - )?; - for item in &mut news { - if item.symbol.is_empty() { - item.symbol = resolved.clone(); - } - } - render_news(&news, &config.output) + let news = idx.financial().news(symbol, *limit, policy)?; + render_news(&news.data, format) } StocksSubcommand::Screen { filter, region, limit, } => { - let filter_key = screener_filter_key(filter)?; - let region_key = screener_region_key(region)?; - // 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 needs_full_fetch = matches!( - filter.as_str(), - "high-volume" | "large-cap" | "top-performers" | "worst-performers" - ); - let fetch_limit = (!needs_full_fetch).then_some(*limit); - let bucket = cache_bucket(provider.kind(), "screen"); - let key = match fetch_limit { - Some(limit) => format!("{filter}:{region}:{limit}"), - None => format!("{filter}:{region}:all"), + let query = ScreenQuery { + filter: filter.parse()?, + region: region.parse()?, + limit: *limit, }; - let screener_provider = provider.screener_provider("screen")?; - let mut quotes: Vec = fetch_with_cache( - &cache, - CacheFetchSpec { - bucket: &bucket, - key: &key, - ttl_secs: config.quote_ttl, - subject: "screen", - }, - offline, - no_cache, - || screener_provider.screener(filter_key, region_key, fetch_limit), - )?; - sort_screener_quotes(&mut quotes, filter); - quotes.truncate(*limit); - render_screener("es, &config.output, config.no_color) + let quotes = idx.market().screen(&query, policy)?; + render_screener("es.data, format, no_color) } StocksSubcommand::Compare { symbols } => { - let mut reports: Vec = Vec::new(); - let mut failures = Vec::new(); - - for sym in expand_symbols(symbols)? { - let resolved = idx_core::api::resolve_symbol(&sym, &config.exchange)?; - match fetch_fundamental_analysis_report( - &cache, - provider.market(), - &resolved, - FundamentalCacheSpec { - bucket: cache_bucket(provider.kind(), "fundamental"), - ttl_secs: config.fundamental_ttl, - }, - offline, - no_cache, - analyze_fundamental, - ) { - 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()) - })); - } - + let comparison = idx.analysis().compare(&expand_symbols(symbols)?, policy)?; // Partial success needs diagnostics; total failure must leave stderr // available for the single structured error emitted by the caller. - for (resolved, err) in failures { + for (resolved, err) in comparison.failures { runtime::warn(format!( "failed to fetch fundamentals for {resolved}: {err}" )); } - - render_compare(&reports, &config.output, config.no_color) + let reports: Vec<_> = comparison.reports.into_iter().map(|r| r.data).collect(); + render_compare(&reports, format, no_color) } } } -fn fetch_with_cache( - cache: &Cache, - cache_spec: CacheFetchSpec<'_>, - offline: bool, - no_cache: bool, - fetch_fn: F, -) -> Result -where - T: Serialize + DeserializeOwned, - F: FnOnce() -> Result, -{ - if !offline - && !no_cache - && let Some(cached) = cache.get::(cache_spec.bucket, cache_spec.key)? - { - return Ok(cached); - } - - if offline { - if no_cache { - return Err(IdxError::InvalidInput( - "cannot combine --offline with --no-cache".to_string(), - )); - } - - return cache - .get_offline::(cache_spec.bucket, cache_spec.key)? - .ok_or_else(|| { - IdxError::CacheMiss(format!("{}/{}", cache_spec.bucket, cache_spec.key)) - }); - } - - match fetch_fn() { - Ok(data) => { - if !no_cache { - cache.put( - cache_spec.bucket, - cache_spec.key, - &data, - cache_spec.ttl_secs, - )?; - } - Ok(data) - } - Err(err) => { - if !no_cache - && let Some(stale) = cache.get_stale::(cache_spec.bucket, cache_spec.key)? - { - runtime::warn(format!( - "network failed, serving stale cache for {}", - cache_spec.subject - )); - return Ok(stale); - } - Err(err) - } - } +fn resolve(symbol: &str, config: &IdxConfig) -> Result { + idx_core::api::resolve_symbol(symbol, &config.core.exchange) } -fn fetch_fundamental_analysis_report( - cache: &Cache, - provider: &dyn MarketDataProvider, - resolved: &str, - cache_spec: FundamentalCacheSpec, - offline: bool, - no_cache: bool, - analyzer: F, -) -> Result -where - T: Serialize + DeserializeOwned, - F: FnOnce(&str, &Fundamentals) -> T, -{ - if !no_cache && let Some(report) = cache.get::(&cache_spec.bucket, resolved)? { - return Ok(report); - } - - if offline { - return cache - .get_offline::(&cache_spec.bucket, resolved)? - .ok_or_else(|| IdxError::CacheMiss(format!("{}/{resolved}", cache_spec.bucket))); - } - - match provider.fundamentals(resolved) { - Ok(fundamentals) => { - let report = analyzer(resolved, &fundamentals); - if !no_cache { - cache.put(&cache_spec.bucket, resolved, &report, cache_spec.ttl_secs)?; - } - Ok(report) - } - Err(err) => { - if !no_cache && let Some(stale) = cache.get_stale::(&cache_spec.bucket, resolved)? { - runtime::warn(format!( - "network failed, serving stale cache for {resolved}" - )); - return Ok(stale); - } - Err(err) - } - } -} - -fn build_technical_report(symbol: &str, history: &[Ohlc]) -> Result { - let latest = history - .last() - .ok_or_else(|| IdxError::ParseError(format!("no history available for {symbol}")))?; - let closes: Vec = history.iter().map(|item| item.close as f64).collect(); - let volumes: Vec = 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]) -> Option { - values.iter().rev().find_map(|value| *value) -} - -fn previous_value(values: &[Option]) -> Option { - 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 { - if period == 0 || values.len() < period { - return None; - } - - let start = values.len() - period; - Some(values[start..].iter().sum::() / period as f64) -} - -fn filter_financial_statements( - financials: &FinancialStatements, - filters: &FinancialsFilterArgs, -) -> FinancialStatements { - let includes = |kind: FinancialStatementKind| { - filters.statement.is_empty() || filters.statement.contains(&kind) - }; - - FinancialStatements { - instrument: financials.instrument.clone(), - balance_sheet: includes(FinancialStatementKind::Balance) - .then(|| financials.balance_sheet.clone()) - .flatten(), - cash_flow: includes(FinancialStatementKind::Cashflow) - .then(|| financials.cash_flow.clone()) - .flatten(), - income_statement: includes(FinancialStatementKind::Income) - .then(|| financials.income_statement.clone()) - .flatten(), - } -} - -fn filter_earnings_report(report: &EarningsReport, filters: &EarningsFilterArgs) -> EarningsReport { - let filter_rows = |rows: &[idx_core::api::types::EarningsData]| { - rows.iter() - .filter(|row| filters.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 filters.include_forecast() { - filter_rows(&report.forecast) - } else { - Vec::new() - }, - history: if filters.include_history() { - filter_rows(&report.history) - } else { - Vec::new() - }, - } -} - -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 -} - -fn screener_filter_key(filter: &str) -> Result<&'static str, IdxError> { - match filter { - "top-performers" => Ok("st_list_topperfs"), - "worst-performers" => Ok("st_list_poorperfs"), - "high-dividend" => Ok("st_list_highdividend"), - "low-pe" => Ok("st_list_lowpe"), - "52w-high" => Ok("st_list_52wkhi"), - "52w-low" => Ok("st_list_52wklow"), - "high-volume" => Ok("st_list_topperfs"), - "large-cap" => Ok("st_list_topperfs"), - _ => Err(IdxError::InvalidInput(format!( - "invalid screener filter '{filter}' (expected one of: {})", - SCREENER_FILTERS.join(", ") - ))), - } -} - -fn screener_region_key(region: &str) -> Result<&'static str, IdxError> { - match region { - "id" => Ok("st_reg_id"), - "us" => Ok("st_reg_us"), - "sg" => Ok("st_reg_sg"), - "hk" => Ok("st_reg_hk"), - "jp" => Ok("st_reg_jp"), - _ => Err(IdxError::InvalidInput(format!( - "invalid screener region '{region}' (expected one of: {})", - SCREENER_REGIONS.join(", ") - ))), - } -} - -fn sort_screener_quotes(quotes: &mut [Quote], filter: &str) { - match filter { - "top-performers" => { - 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) - }); - } - "worst-performers" => { - 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) - }); - } - "52w-high" => { - 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) - }); - } - "52w-low" => { - 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) - }); - } - "high-volume" => { - quotes.sort_by_key(|quote| std::cmp::Reverse(quote.volume)); - } - "large-cap" => { - quotes.sort_by_key(|quote| std::cmp::Reverse(quote.market_cap.unwrap_or(0))); - } - _ => {} - } -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use chrono::{Days, NaiveDate}; - - use super::{ - EarningsFilterArgs, EarningsPeriodKind, FinancialStatementKind, FinancialsFilterArgs, - build_technical_report, classify_earnings_period, filter_earnings_report, - filter_financial_statements, - }; - use idx_core::analysis::signals::Signal; - use idx_core::api::types::{ - EarningsData, EarningsReport, FinancialStatements, InstrumentInfo, Ohlc, StatementSection, - }; - use idx_core::error::IdxError; - - #[test] - fn technical_report_uses_latest_values() { - let start = NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date"); - let history: Vec = (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); - } - - #[test] - fn sort_screener_top_performers_descending() { - use idx_core::api::types::Quote; - - let mut quotes = vec![ - Quote { - symbol: "A".into(), - price: 100, - change: Some(1), - change_pct: Some(1.0), - volume: Some(100), - market_cap: None, - week52_high: None, - week52_low: None, - week52_position: None, - range_signal: None, - prev_close: None, - avg_volume: None, - as_of: None, - }, - Quote { - symbol: "B".into(), - price: 200, - change: Some(10), - change_pct: Some(5.0), - volume: Some(200), - market_cap: None, - week52_high: None, - week52_low: None, - week52_position: None, - range_signal: None, - prev_close: None, - avg_volume: None, - as_of: None, - }, - Quote { - symbol: "C".into(), - price: 150, - change: Some(5), - change_pct: Some(3.0), - volume: Some(150), - market_cap: None, - week52_high: None, - week52_low: None, - week52_position: None, - range_signal: None, - prev_close: None, - avg_volume: None, - as_of: None, - }, - ]; - - super::sort_screener_quotes(&mut quotes, "top-performers"); - let pcts: Vec = 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() { - use idx_core::api::types::Quote; - - let mut quotes = vec![ - Quote { - symbol: "A".into(), - price: 100, - change: Some(1), - change_pct: Some(1.0), - volume: Some(100), - market_cap: None, - week52_high: None, - week52_low: None, - week52_position: None, - range_signal: None, - prev_close: None, - avg_volume: None, - as_of: None, - }, - Quote { - symbol: "B".into(), - price: 200, - change: Some(-10), - change_pct: Some(-5.0), - volume: Some(200), - market_cap: None, - week52_high: None, - week52_low: None, - week52_position: None, - range_signal: None, - prev_close: None, - avg_volume: None, - as_of: None, - }, - Quote { - symbol: "C".into(), - price: 150, - change: Some(-3), - change_pct: Some(-2.0), - volume: Some(150), - market_cap: None, - week52_high: None, - week52_low: None, - week52_position: None, - range_signal: None, - prev_close: None, - avg_volume: None, - as_of: None, - }, - ]; - - super::sort_screener_quotes(&mut quotes, "worst-performers"); - let pcts: Vec = 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)), - ]; - super::sort_screener_quotes(&mut quotes, "worst-performers"); - assert_eq!(quotes.last().unwrap().symbol, "U"); - super::sort_screener_quotes(&mut quotes, "top-performers"); - assert_eq!(quotes.last().unwrap().symbol, "U"); - } - - fn sample_quote(symbol: &str, change_pct: Option) -> idx_core::api::types::Quote { - idx_core::api::types::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 invalid_screener_filter_returns_error() { - let err = super::screener_filter_key("bogus").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 = super::screener_region_key("eu").expect_err("invalid region should fail"); - assert!(matches!(err, IdxError::InvalidInput(_))); - assert!(err.to_string().contains("invalid screener region")); - } - - #[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: Some(StatementSection { - values: BTreeMap::from([("assets".into(), 1.0)]), - currency: "IDR".into(), - report_date: "2026-01-01".into(), - end_date: "2025-12-31".into(), - }), - cash_flow: Some(StatementSection { - values: BTreeMap::from([("cash".into(), 2.0)]), - currency: "IDR".into(), - report_date: "2026-01-01".into(), - end_date: "2025-12-31".into(), - }), - income_statement: Some(StatementSection { - values: BTreeMap::from([("income".into(), 3.0)]), - currency: "IDR".into(), - report_date: "2026-01-01".into(), - end_date: "2025-12-31".into(), - }), - }; - - let filtered = filter_financial_statements( - &sample, - &FinancialsFilterArgs { - statement: vec![FinancialStatementKind::Cashflow], - }, - ); - - 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"); - } - - #[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![ - EarningsData { - eps_actual: None, - eps_forecast: Some(1_300.0), - eps_surprise: None, - eps_surprise_pct: None, - revenue_actual: None, - revenue_forecast: Some(110_000_000_000.0), - revenue_surprise: None, - earning_release_date: Some("2026-03-15".into()), - period_type: "2026".into(), - }, - EarningsData { - eps_actual: None, - eps_forecast: Some(330.0), - eps_surprise: None, - eps_surprise_pct: None, - revenue_actual: None, - revenue_forecast: Some(28_000_000_000.0), - revenue_surprise: None, - earning_release_date: Some("2026-04-20".into()), - period_type: "Q12026".into(), - }, - ], - history: vec![EarningsData { - eps_actual: Some(1_250.0), - eps_forecast: None, - eps_surprise: Some(20.0), - eps_surprise_pct: Some(1.6), - revenue_actual: Some(105_000_000_000.0), - revenue_forecast: None, - revenue_surprise: Some(500_000_000.0), - earning_release_date: Some("2025-03-15".into()), - period_type: "2025".into(), - }], - }; - - let filtered = filter_earnings_report( - &report, - &EarningsFilterArgs { - forecast: true, - history: false, - annual: true, - quarterly: false, - }, - ); - - 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); +/// Table mode notes when `auto` history falls back to another provider. +fn announce_history_fallback( + idx: &Idx, + source: idx_core::market::HistorySource, + config: &IdxConfig, +) { + if source.is_fallback && !config.output.is_json() { + runtime::info(format!( + "history provider fallback active ({} -> {})", + idx.config().provider.as_str(), + source.provider.as_str() + )); } } diff --git a/crates/idx-cli/src/config.rs b/crates/idx-cli/src/config.rs index c41c1d1..ba85b4c 100644 --- a/crates/idx-cli/src/config.rs +++ b/crates/idx-cli/src/config.rs @@ -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, - cache: Option, } #[derive(Debug, Deserialize, Default)] struct FileGeneral { - provider: Option, - history_provider: Option, - exchange: Option, output: Option, color: Option, } -#[derive(Debug, Deserialize, Default)] -struct FileCache { - quote_ttl: Option, - fundamental_ttl: Option, -} - -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 { - 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 { - 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; diff --git a/crates/idx-cli/src/main.rs b/crates/idx-cli/src/main.rs index 4c75dd0..91f75a1 100644 --- a/crates/idx-cli/src/main.rs +++ b/crates/idx-cli/src/main.rs @@ -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); } diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index 5c43bda..212a104 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -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, - pub sma50: Option, - pub sma200: Option, - pub rsi14: Option, - pub macd: MacdSnapshot, - pub volume: VolumeSnapshot, - pub signals: TechnicalSignal, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MacdSnapshot { - pub line: Option, - pub signal: Option, - pub histogram: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VolumeSnapshot { - pub current: u64, - pub average20: Option, - pub ratio20: Option, -} - pub fn render_quotes( quotes: &[Quote], format: &OutputFormat, diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index f17f8d5..cfa3a2e 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -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, diff --git a/crates/idx-core/src/analysis/mod.rs b/crates/idx-core/src/analysis/mod.rs index 612ff07..87c3aea 100644 --- a/crates/idx-core/src/analysis/mod.rs +++ b/crates/idx-core/src/analysis/mod.rs @@ -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>, + 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, 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, IdxError> { + self.fundamental_report(symbol, "growth", policy, |_, f| analyze_growth(f)) + } + + pub fn valuation( + &self, + symbol: &str, + policy: CachePolicy, + ) -> Result, IdxError> { + self.fundamental_report(symbol, "valuation", policy, |_, f| analyze_valuation(f)) + } + + pub fn risk(&self, symbol: &str, policy: CachePolicy) -> Result, IdxError> { + self.fundamental_report(symbol, "risk", policy, |_, f| analyze_risk(f)) + } + + pub fn fundamental( + &self, + symbol: &str, + policy: CachePolicy, + ) -> Result, 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>( + &self, + symbols: &[S], + policy: CachePolicy, + ) -> Result { + 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( + &self, + symbol: &str, + bucket: &str, + policy: CachePolicy, + analyzer: impl FnOnce(&str, &Fundamentals) -> T, + ) -> Result, 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)), + ) + } +} diff --git a/crates/idx-core/src/analysis/report.rs b/crates/idx-core/src/analysis/report.rs new file mode 100644 index 0000000..951f79c --- /dev/null +++ b/crates/idx-core/src/analysis/report.rs @@ -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, + pub sma50: Option, + pub sma200: Option, + pub rsi14: Option, + pub macd: MacdSnapshot, + pub volume: VolumeSnapshot, + pub signals: TechnicalSignal, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MacdSnapshot { + pub line: Option, + pub signal: Option, + pub histogram: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VolumeSnapshot { + pub current: u64, + pub average20: Option, + pub ratio20: Option, +} + +pub fn build_technical_report(symbol: &str, history: &[Ohlc]) -> Result { + let latest = history + .last() + .ok_or_else(|| IdxError::ParseError(format!("no history available for {symbol}")))?; + let closes: Vec = history.iter().map(|item| item.close as f64).collect(); + let volumes: Vec = 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]) -> Option { + values.iter().rev().find_map(|value| *value) +} + +fn previous_value(values: &[Option]) -> Option { + 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 { + if period == 0 || values.len() < period { + return None; + } + + let start = values.len() - period; + Some(values[start..].iter().sum::() / 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 = (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); + } +} diff --git a/crates/idx-core/src/api/mod.rs b/crates/idx-core/src/api/mod.rs index 624942e..258b122 100644 --- a/crates/idx-core/src/api/mod.rs +++ b/crates/idx-core/src/api/mod.rs @@ -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), 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) diff --git a/crates/idx-core/src/cache.rs b/crates/idx-core/src/cache.rs index 0d9ff43..3740eed 100644 --- a/crates/idx-core/src/cache.rs +++ b/crates/idx-core/src/cache.rs @@ -25,6 +25,14 @@ struct CacheEntry { data: T, } +/// A cache hit with its metadata. +#[derive(Debug)] +pub struct Cached { + pub data: T, + pub fetched_at: DateTime, + 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, IdxError> { - let Some(entry): Option> = 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( @@ -63,26 +64,57 @@ impl Cache { data_type: &str, symbol: &str, ) -> Result, IdxError> { - Ok(self.read_entry::(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( &self, data_type: &str, symbol: &str, ) -> Result, IdxError> { - let Some(entry) = self.read_entry::(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( + &self, + data_type: &str, + symbol: &str, + ) -> Result>, IdxError> { + Ok(self.any(data_type, symbol)?.filter(|hit| !hit.expired)) + } + + /// Any entry, expired or not. + pub fn any( + &self, + data_type: &str, + symbol: &str, + ) -> Result>, IdxError> { + Ok(self + .read_entry::(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( + &self, + data_type: &str, + symbol: &str, + ) -> Result>, IdxError> { + let hit = self.any::(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( @@ -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"); diff --git a/crates/idx-core/src/client.rs b/crates/idx-core/src/client.rs new file mode 100644 index 0000000..b73f49c --- /dev/null +++ b/crates/idx-core/src/client.rs @@ -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 { + 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 { + pub data: T, + /// Provider that produced the data (`msn` or `yahoo`). + pub source: &'static str, + pub fetched_at: DateTime, + /// True when an expired cache entry was served (offline, or after a + /// failed fetch). + pub stale: bool, +} + +impl Fetched { + pub fn map(self, f: impl FnOnce(T) -> U) -> Fetched { + 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 { + 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 { + 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( + cache: &Cache, + spec: CacheSpec<'_>, + policy: CachePolicy, + fetch: impl FnOnce() -> Result, + finish: impl FnOnce(R) -> Result, +) -> Result, IdxError> +where + T: Serialize + DeserializeOwned, +{ + let hit = |hit: crate::cache::Cached, 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::(spec.bucket, spec.key)? { + return Ok(hit(fresh, false)); + } + } + CachePolicy::Offline => { + return cache + .offline::(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::(spec.bucket, spec.key)? { + runtime::warn(format!( + "network failed, serving stale cache for {}", + spec.subject + )); + return Ok(hit(stale, true)); + } + Err(err) + } + } +} diff --git a/crates/idx-core/src/config.rs b/crates/idx-core/src/config.rs index 6852533..4f28071 100644 --- a/crates/idx-core/src/config.rs +++ b/crates/idx-core/src/config.rs @@ -74,7 +74,113 @@ impl HistoryProviderKind { } } -pub fn parse_env_ttl(name: &str) -> Result, 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, + /// 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, + cache: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct FileGeneral { + provider: Option, + history_provider: Option, + exchange: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct FileCache { + quote_ttl: Option, + fundamental_ttl: Option, +} + +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 { + 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 { + match &self.cache_dir { + Some(dir) => Ok(dir.clone()), + None => crate::cache::cache_dir(), + } + } +} + +fn parse_env_ttl(name: &str) -> Result, 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!( diff --git a/crates/idx-core/src/financial.rs b/crates/idx-core/src/financial.rs new file mode 100644 index 0000000..19c2fb4 --- /dev/null +++ b/crates/idx-core/src/financial.rs @@ -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, 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, 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, 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, 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, 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>, 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( + &self, + bucket: &str, + key: &str, + subject: &str, + ttl_secs: u64, + policy: CachePolicy, + fetch: impl FnOnce() -> Result, + ) -> Result, 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, +} + +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 { + 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) -> 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); + } +} diff --git a/crates/idx-core/src/lib.rs b/crates/idx-core/src/lib.rs index 39170cd..703911e 100644 --- a/crates/idx-core/src/lib.rs +++ b/crates/idx-core/src/lib.rs @@ -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}; diff --git a/crates/idx-core/src/market.rs b/crates/idx-core/src/market.rs new file mode 100644 index 0000000..2955f9c --- /dev/null +++ b/crates/idx-core/src/market.rs @@ -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>( + &self, + symbols: &[S], + policy: CachePolicy, + ) -> Result>, 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) -> 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>, 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>, 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::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::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) -> 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 = 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 = 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::() + .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::() + .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::().unwrap(), filter); + } + for region in ScreenRegion::ALL { + assert_eq!(region.as_str().parse::().unwrap(), region); + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7f6789e..38e2dd1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,16 +4,18 @@ The repo is a Cargo workspace with one shared version: -- `crates/idx-core` (library): providers (`api/`), `analysis/`, `cache`, config - file handling and provider selection (`config`), `ownership/` (SQLite, parsers, - discovery, snapshot sync), `error`, and `runtime` (quiet/info/warn). It owns the - shared test fixtures in `tests/fixtures/`. +- `crates/idx-core` (library): the `Idx` entry point with `market()`, + `financial()`, and `analysis()` (cache policy and `Fetched` in `client.rs`), + providers (`api/`), `analysis/`, `cache`, `config` (`CoreConfig`), + `ownership/` (SQLite, parsers, discovery, snapshot sync), `error`, and + `runtime` (quiet/info/warn). It owns the shared test fixtures in + `tests/fixtures/`; the fixture-backed mock provider needs the `testing` feature. - `crates/idx-cli` (binary `idx`): clap commands (`cli/`), output format and color resolution (`config.rs`, `IdxConfig`), and rendering (`output/`). Its `tests/` hold the CLI integration and golden output suites. -Some fetch/cache orchestration still lives in `crates/idx-cli/src/cli/` until -phase 3 of `docs/DESIGN.md` moves it into idx-core. +Ownership import/sync orchestration still lives in +`crates/idx-cli/src/cli/ownership.rs` until phase 3c of `docs/DESIGN.md`. ## Domain Map diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 497872f..b728fec 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -59,21 +59,25 @@ A single entry point holds configuration, providers, cache, and the DB path. Domain methods hang off it. ```rust -let idx = Idx::builder() - .config(CoreConfig::load()?) // provider, TTLs, paths; no output settings - .build()?; +let idx = Idx::new(CoreConfig::load()?)?; // provider, TTLs, cache dir; no output settings +let policy = CachePolicy::Default; // market -let quotes: Fetched> = idx.market().quotes(&["BBCA", "BBRI"], CachePolicy::Default)?; -let candles: Fetched> = idx.market().history("BBCA", Period::M3, Interval::D1, CachePolicy::Default)?; -let screen: Fetched> = idx.market().screen(ScreenQuery { filter, region, limit })?; +let quotes: Vec> = idx.market().quotes(&["BBCA", "BBRI"], policy)?; +let source = idx.market().history_source(None); // which provider serves history +let candles: Fetched> = + idx.market().history("BBCA", &Period::ThreeMonths, &Interval::Day, source, policy)?; +let screen: Fetched> = idx.market().screen(&ScreenQuery { filter, region, limit }, policy)?; // financial -let report: Fetched = idx.financial().statements("BBCA", StatementFilter::all())?; -let earnings: Fetched = idx.financial().earnings("BBCA", EarningsFilter::default())?; +let report: Fetched = + idx.financial().statements("BBCA", &StatementFilter::all(), policy)?; +let earnings: Fetched = + idx.financial().earnings("BBCA", EarningsFilter::default(), policy)?; // analysis (fetches its own inputs through the same cache) -let tech: Fetched = idx.analysis().technical("BBCA")?; +let tech: Fetched = idx.analysis().technical("BBCA", source, policy)?; +let compared: Comparison = idx.analysis().compare(&["BBCA", "BBRI"], policy)?; // investor (local DB) let holders: TickerOwnership = idx.investor().ticker("BBCA")?; @@ -86,14 +90,18 @@ Key decisions: - **Blocking API.** `ureq` and `rusqlite` are blocking, and so is the CLI. Async frontends (MCP, HTTP) call core through `spawn_blocking`. Revisit only if an HTTP API needs high concurrency. -- **`Fetched` envelope** for live data: `{ data, source, fetched_at, stale }`. - This replaces today's stderr-only stale-cache warning, which an agent or API - client cannot see. +- **`Fetched` envelope** for live data: `{ data, source, fetched_at, stale }`, + so agents and API clients can see freshness. idx-core still emits the + stale-cache warnings through `runtime` (which `set_quiet` silences) so CLI + stderr stays identical. - **`CachePolicy`**: `Default | Offline | Refresh` replaces the `--offline` / `--no-cache` flag handling now spread through `cli/stocks.rs`. -- **Explicit configuration.** Core reads no environment variables. `IDX_*` env - parsing, `config.toml` loading, and XDG resolution move to a `CoreConfig::load()` - helper that frontends call; tests construct `CoreConfig` directly. +- **Explicit configuration.** `CoreConfig::load()` is the one place idx-core reads + provider, exchange, and TTL settings from `config.toml` and `IDX_*`; other + frontends and tests construct `CoreConfig` directly (`cache_dir` overrides the + XDG default). Remaining environment hooks: XDG paths for the config file and + ownership DB, `IDX_CURL_IMPERSONATE_BIN`, the IDX discovery URL overrides, and + the `testing`-only mock variables. - **Mock provider behind a `testing` feature.** Done: `MockProvider` and the embedded MSN fixtures compile only with idx-core's `testing` feature, which idx-cli enables as a dev-dependency.