mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
refactor(core): move stocks fetching, caching, and analysis into idx-core
Phase 3b of docs/DESIGN.md. `stocks` handlers are now argument parsing,
one idx-core call, and rendering.
- Idx (client.rs) is the entry point: Idx::new(CoreConfig) with
market(), financial(), and analysis(). Live lookups return
Fetched<T> { data, source, fetched_at, stale } and take a CachePolicy
(Default | Offline | Refresh, from the --offline/--no-cache flags).
- One cached_fetch replaces the CLI's three cache flows; they differed
only in whether a fresh entry is read via get or get_offline when
offline, which yields the same data and diagnostics. Report-building
errors (technical) still skip the stale-cache fallback.
- market.rs: quotes, history (history_source is resolved first so the
CLI's fallback note keeps its position), and a typed screener
(ScreenFilter/ScreenRegion with the same error messages).
- financial.rs: profile, statements, earnings, sentiment, insights,
news, with StatementFilter/EarningsFilter.
- analysis: TechnicalReport moves to analysis::report; Analysis offers
technical, growth, valuation, risk, fundamental, and compare.
- CoreConfig::load() owns provider/exchange/TTL settings from
config.toml and IDX_*; the CLI's IdxConfig adds output and color.
With two different invalid settings at once, the reported one may
differ from before; every single-error message is unchanged.
- Cache gains fresh/any/offline accessors returning fetch metadata.
Unit tests moved with their code (core 157 -> 168, CLI 17 -> 7). All
143 goldens unchanged; live A/B against main: 16/16 stocks identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e78d9fcecd
commit
86cb1b4b7c
16 changed files with 1613 additions and 1301 deletions
|
|
@ -1,3 +1,140 @@
|
|||
pub mod fundamental;
|
||||
pub mod report;
|
||||
pub mod signals;
|
||||
pub mod technical;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::api::history_provider;
|
||||
use crate::api::types::{Fundamentals, Interval, Period};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::error::IdxError;
|
||||
use crate::market::HistorySource;
|
||||
use fundamental::{
|
||||
FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental,
|
||||
analyze_growth, analyze_risk, analyze_valuation,
|
||||
};
|
||||
use report::{TechnicalReport, build_technical_report};
|
||||
|
||||
/// Analysis reports. Reports are cached as computed, not their inputs.
|
||||
pub struct Analysis<'a> {
|
||||
pub(crate) idx: &'a Idx,
|
||||
}
|
||||
|
||||
/// Comparison results; symbols that failed are listed with their error.
|
||||
pub struct Comparison {
|
||||
pub reports: Vec<Fetched<FundamentalReport>>,
|
||||
pub failures: Vec<(String, IdxError)>,
|
||||
}
|
||||
|
||||
impl Analysis<'_> {
|
||||
/// Technical analysis over one year of daily history from `source`
|
||||
/// (see `Market::history_source`).
|
||||
pub fn technical(
|
||||
&self,
|
||||
symbol: &str,
|
||||
source: HistorySource,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<TechnicalReport>, IdxError> {
|
||||
let idx = self.idx;
|
||||
let (provider_kind, provider) =
|
||||
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
|
||||
let bucket = format!("{}-technical", provider_kind.as_str());
|
||||
let resolved = idx.resolve(symbol)?;
|
||||
cached_fetch(
|
||||
&idx.cache,
|
||||
CacheSpec {
|
||||
bucket: &bucket,
|
||||
key: &resolved,
|
||||
ttl_secs: idx.config.quote_ttl,
|
||||
subject: &resolved,
|
||||
source: provider_kind.as_str(),
|
||||
},
|
||||
policy,
|
||||
|| provider.history(&resolved, &Period::OneYear, &Interval::Day),
|
||||
|history| build_technical_report(&resolved, &history),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn growth(
|
||||
&self,
|
||||
symbol: &str,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<GrowthReport>, IdxError> {
|
||||
self.fundamental_report(symbol, "growth", policy, |_, f| analyze_growth(f))
|
||||
}
|
||||
|
||||
pub fn valuation(
|
||||
&self,
|
||||
symbol: &str,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<ValuationReport>, IdxError> {
|
||||
self.fundamental_report(symbol, "valuation", policy, |_, f| analyze_valuation(f))
|
||||
}
|
||||
|
||||
pub fn risk(&self, symbol: &str, policy: CachePolicy) -> Result<Fetched<RiskReport>, IdxError> {
|
||||
self.fundamental_report(symbol, "risk", policy, |_, f| analyze_risk(f))
|
||||
}
|
||||
|
||||
pub fn fundamental(
|
||||
&self,
|
||||
symbol: &str,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<FundamentalReport>, IdxError> {
|
||||
self.fundamental_report(symbol, "fundamental", policy, analyze_fundamental)
|
||||
}
|
||||
|
||||
/// Fundamental reports for each symbol. Fails only if every symbol fails
|
||||
/// (with the last error) or a symbol cannot be resolved.
|
||||
pub fn compare<S: AsRef<str>>(
|
||||
&self,
|
||||
symbols: &[S],
|
||||
policy: CachePolicy,
|
||||
) -> Result<Comparison, IdxError> {
|
||||
let mut reports = Vec::new();
|
||||
let mut failures = Vec::new();
|
||||
for symbol in symbols {
|
||||
let resolved = self.idx.resolve(symbol.as_ref())?;
|
||||
match self.fundamental(&resolved, policy) {
|
||||
Ok(report) => reports.push(report),
|
||||
Err(err) => failures.push((resolved, err)),
|
||||
}
|
||||
}
|
||||
|
||||
if reports.is_empty() {
|
||||
return Err(failures.pop().map(|(_, err)| err).unwrap_or_else(|| {
|
||||
IdxError::CacheMiss("fundamental/no symbols could be compared".to_string())
|
||||
}));
|
||||
}
|
||||
Ok(Comparison { reports, failures })
|
||||
}
|
||||
|
||||
fn fundamental_report<T>(
|
||||
&self,
|
||||
symbol: &str,
|
||||
bucket: &str,
|
||||
policy: CachePolicy,
|
||||
analyzer: impl FnOnce(&str, &Fundamentals) -> T,
|
||||
) -> Result<Fetched<T>, IdxError>
|
||||
where
|
||||
T: Serialize + DeserializeOwned,
|
||||
{
|
||||
let idx = self.idx;
|
||||
let resolved = idx.resolve(symbol)?;
|
||||
let bucket = idx.bucket(bucket);
|
||||
cached_fetch(
|
||||
&idx.cache,
|
||||
CacheSpec {
|
||||
bucket: &bucket,
|
||||
key: &resolved,
|
||||
ttl_secs: idx.config.fundamental_ttl,
|
||||
subject: &resolved,
|
||||
source: idx.provider.kind().as_str(),
|
||||
},
|
||||
policy,
|
||||
|| idx.provider.market().fundamentals(&resolved),
|
||||
|fundamentals| Ok(analyzer(&resolved, &fundamentals)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
153
crates/idx-core/src/analysis/report.rs
Normal file
153
crates/idx-core/src/analysis/report.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
//! Technical analysis report built from daily history.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::signals::{self, Signal, TechnicalSignal};
|
||||
use super::technical;
|
||||
use crate::api::types::Ohlc;
|
||||
use crate::error::IdxError;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TechnicalReport {
|
||||
pub symbol: String,
|
||||
pub as_of: NaiveDate,
|
||||
pub current_price: i64,
|
||||
pub sma20: Option<f64>,
|
||||
pub sma50: Option<f64>,
|
||||
pub sma200: Option<f64>,
|
||||
pub rsi14: Option<f64>,
|
||||
pub macd: MacdSnapshot,
|
||||
pub volume: VolumeSnapshot,
|
||||
pub signals: TechnicalSignal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MacdSnapshot {
|
||||
pub line: Option<f64>,
|
||||
pub signal: Option<f64>,
|
||||
pub histogram: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VolumeSnapshot {
|
||||
pub current: u64,
|
||||
pub average20: Option<f64>,
|
||||
pub ratio20: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn build_technical_report(symbol: &str, history: &[Ohlc]) -> Result<TechnicalReport, IdxError> {
|
||||
let latest = history
|
||||
.last()
|
||||
.ok_or_else(|| IdxError::ParseError(format!("no history available for {symbol}")))?;
|
||||
let closes: Vec<f64> = history.iter().map(|item| item.close as f64).collect();
|
||||
let volumes: Vec<f64> = history.iter().map(|item| item.volume as f64).collect();
|
||||
|
||||
let sma20 = last_value(&technical::sma(&closes, 20));
|
||||
let sma50 = last_value(&technical::sma(&closes, 50));
|
||||
let sma200 = last_value(&technical::sma(&closes, 200));
|
||||
let rsi14 = last_value(&technical::rsi(&closes, 14));
|
||||
let macd = technical::macd(&closes, 12, 26, 9);
|
||||
let macd_line = last_value(&macd.macd_line);
|
||||
let signal_line = last_value(&macd.signal_line);
|
||||
let histogram = last_value(&macd.histogram);
|
||||
let previous_histogram = previous_value(&macd.histogram);
|
||||
let average_volume20 = average_last(&volumes, 20);
|
||||
let volume_ratio20 = technical::volume_ratio(&volumes, 20);
|
||||
|
||||
let rsi_signal = rsi14.map_or(Signal::Neutral, signals::interpret_rsi);
|
||||
let macd_signal = histogram
|
||||
.map(|value| signals::interpret_macd(value, previous_histogram))
|
||||
.unwrap_or(Signal::Neutral);
|
||||
let trend_signal = signals::interpret_trend(latest.close as f64, sma50, sma200);
|
||||
let overall = signals::overall_signal(rsi_signal, macd_signal, trend_signal);
|
||||
|
||||
Ok(TechnicalReport {
|
||||
symbol: symbol.to_string(),
|
||||
as_of: latest.date,
|
||||
current_price: latest.close,
|
||||
sma20,
|
||||
sma50,
|
||||
sma200,
|
||||
rsi14,
|
||||
macd: MacdSnapshot {
|
||||
line: macd_line,
|
||||
signal: signal_line,
|
||||
histogram,
|
||||
},
|
||||
volume: VolumeSnapshot {
|
||||
current: latest.volume,
|
||||
average20: average_volume20,
|
||||
ratio20: volume_ratio20,
|
||||
},
|
||||
signals: TechnicalSignal {
|
||||
rsi: rsi_signal,
|
||||
macd: macd_signal,
|
||||
trend: trend_signal,
|
||||
overall,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn last_value(values: &[Option<f64>]) -> Option<f64> {
|
||||
values.iter().rev().find_map(|value| *value)
|
||||
}
|
||||
|
||||
fn previous_value(values: &[Option<f64>]) -> Option<f64> {
|
||||
let mut seen_latest = false;
|
||||
for value in values.iter().rev() {
|
||||
if value.is_some() {
|
||||
if seen_latest {
|
||||
return *value;
|
||||
}
|
||||
seen_latest = true;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn average_last(values: &[f64], period: usize) -> Option<f64> {
|
||||
if period == 0 || values.len() < period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start = values.len() - period;
|
||||
Some(values[start..].iter().sum::<f64>() / period as f64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{Days, NaiveDate};
|
||||
|
||||
use super::build_technical_report;
|
||||
use crate::analysis::signals::Signal;
|
||||
use crate::api::types::Ohlc;
|
||||
|
||||
#[test]
|
||||
fn technical_report_uses_latest_values() {
|
||||
let start = NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date");
|
||||
let history: Vec<Ohlc> = (0..60)
|
||||
.map(|idx| Ohlc {
|
||||
date: start
|
||||
.checked_add_days(Days::new(idx as u64))
|
||||
.expect("valid offset"),
|
||||
open: 100 + idx as i64,
|
||||
high: 101 + idx as i64,
|
||||
low: 99 + idx as i64,
|
||||
close: 100 + idx as i64,
|
||||
volume: 1_000 + idx as u64 * 10,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let report = build_technical_report("BBCA.JK", &history).expect("report should build");
|
||||
|
||||
assert_eq!(report.symbol, "BBCA.JK");
|
||||
assert_eq!(report.current_price, 159);
|
||||
assert!(report.sma20.is_some());
|
||||
assert!(report.sma50.is_some());
|
||||
assert_eq!(report.sma200, None);
|
||||
assert!(report.rsi14.is_some());
|
||||
assert!(report.volume.ratio20.is_some());
|
||||
assert_eq!(report.signals.trend, Signal::Neutral);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue