mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
feat(fundamental): add fundamental analysis suite (growth, valuation, risk, compare)
- Add Fundamentals type and MarketDataProvider::fundamentals() trait method - Add Yahoo quoteSummary endpoint parser (/v10/finance/quoteSummary) - Add analysis/fundamental.rs with exact signal thresholds ported from idx-mcp: - GrowthReport (revenue/earnings growth with signals) - ValuationReport (PE, PB, ROE, margin, EV/EBITDA with signals) - RiskReport (D/E, current ratio, ROA with signals) - FundamentalReport (composite with overall health signal) - Wire CLI subcommands: stocks growth/valuation/risk/fundamental/compare - Add table + JSON rendering for all report types - Add mock fixture (quotesummary_bbca.json) - 41 tests passing (22 unit + 19 integration) - Known: Yahoo quoteSummary returns 401 from datacenter IPs (needs crumb auth)
This commit is contained in:
parent
3e5869af67
commit
0d5b4b4755
10 changed files with 1459 additions and 32 deletions
|
|
@ -1,16 +1,27 @@
|
|||
use clap::{Args, Subcommand};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
use crate::analysis::fundamental::{
|
||||
FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental,
|
||||
analyze_growth, analyze_risk, analyze_valuation,
|
||||
};
|
||||
use crate::analysis::signals::{self, Signal, TechnicalSignal};
|
||||
use crate::analysis::technical;
|
||||
use crate::api::MarketDataProvider;
|
||||
use crate::api::types::{Interval, Ohlc, Period};
|
||||
use crate::api::types::{Fundamentals, Interval, Ohlc, Period};
|
||||
use crate::cache::Cache;
|
||||
use crate::config::IdxConfig;
|
||||
use crate::error::IdxError;
|
||||
use crate::output::{
|
||||
MacdSnapshot, TechnicalReport, VolumeSnapshot, render_history, render_quotes, render_technical,
|
||||
MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_fundamental,
|
||||
render_growth, render_history, render_quotes, render_risk, render_technical, render_valuation,
|
||||
};
|
||||
|
||||
struct FundamentalCacheSpec<'a> {
|
||||
key: &'a str,
|
||||
ttl_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
#[command(about = "Stock data and analysis")]
|
||||
pub struct StocksCmd {
|
||||
|
|
@ -48,6 +59,46 @@ pub enum StocksSubcommand {
|
|||
/// Single ticker symbol (e.g. BBCA).
|
||||
symbol: String,
|
||||
},
|
||||
#[command(
|
||||
about = "Run growth analysis on a stock",
|
||||
after_help = "Examples:\n idx stocks growth BBCA\n idx -o json stocks growth BBCA"
|
||||
)]
|
||||
Growth {
|
||||
/// Single ticker symbol (e.g. BBCA).
|
||||
symbol: String,
|
||||
},
|
||||
#[command(
|
||||
about = "Run valuation analysis on a stock",
|
||||
after_help = "Examples:\n idx stocks valuation BBCA\n idx -o json stocks valuation BBCA"
|
||||
)]
|
||||
Valuation {
|
||||
/// Single ticker symbol (e.g. BBCA).
|
||||
symbol: String,
|
||||
},
|
||||
#[command(
|
||||
about = "Run risk analysis on a stock",
|
||||
after_help = "Examples:\n idx stocks risk BBCA\n idx -o json stocks risk BBCA"
|
||||
)]
|
||||
Risk {
|
||||
/// Single ticker symbol (e.g. BBCA).
|
||||
symbol: String,
|
||||
},
|
||||
#[command(
|
||||
about = "Run full fundamental analysis on a stock",
|
||||
after_help = "Examples:\n idx stocks fundamental BBCA\n idx -o json stocks fundamental BBCA"
|
||||
)]
|
||||
Fundamental {
|
||||
/// Single ticker symbol (e.g. BBCA).
|
||||
symbol: String,
|
||||
},
|
||||
#[command(
|
||||
about = "Compare fundamentals across stocks",
|
||||
after_help = "Examples:\n idx stocks compare BBCA BBRI BMRI\n idx stocks compare BBCA,BBRI,BMRI\n idx -o json stocks compare BBCA,BBRI"
|
||||
)]
|
||||
Compare {
|
||||
/// One or more symbols, comma-separated or space-separated.
|
||||
symbols: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn handle(
|
||||
|
|
@ -180,6 +231,145 @@ pub fn handle(
|
|||
}
|
||||
}
|
||||
}
|
||||
StocksSubcommand::Growth { symbol } => {
|
||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
||||
let report: GrowthReport = fetch_fundamental_analysis_report(
|
||||
&cache,
|
||||
provider,
|
||||
&resolved,
|
||||
FundamentalCacheSpec {
|
||||
key: "growth",
|
||||
ttl_secs: config.fundamental_ttl,
|
||||
},
|
||||
offline,
|
||||
no_cache,
|
||||
|_, fundamentals| analyze_growth(fundamentals),
|
||||
)?;
|
||||
render_growth(&resolved, &report, &config.output, config.no_color)
|
||||
}
|
||||
StocksSubcommand::Valuation { symbol } => {
|
||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
||||
let report: ValuationReport = fetch_fundamental_analysis_report(
|
||||
&cache,
|
||||
provider,
|
||||
&resolved,
|
||||
FundamentalCacheSpec {
|
||||
key: "valuation",
|
||||
ttl_secs: config.fundamental_ttl,
|
||||
},
|
||||
offline,
|
||||
no_cache,
|
||||
|_, fundamentals| analyze_valuation(fundamentals),
|
||||
)?;
|
||||
render_valuation(&resolved, &report, &config.output, config.no_color)
|
||||
}
|
||||
StocksSubcommand::Risk { symbol } => {
|
||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
||||
let report: RiskReport = fetch_fundamental_analysis_report(
|
||||
&cache,
|
||||
provider,
|
||||
&resolved,
|
||||
FundamentalCacheSpec {
|
||||
key: "risk",
|
||||
ttl_secs: config.fundamental_ttl,
|
||||
},
|
||||
offline,
|
||||
no_cache,
|
||||
|_, fundamentals| analyze_risk(fundamentals),
|
||||
)?;
|
||||
render_risk(&resolved, &report, &config.output, config.no_color)
|
||||
}
|
||||
StocksSubcommand::Fundamental { symbol } => {
|
||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
||||
let report: FundamentalReport = fetch_fundamental_analysis_report(
|
||||
&cache,
|
||||
provider,
|
||||
&resolved,
|
||||
FundamentalCacheSpec {
|
||||
key: "fundamental",
|
||||
ttl_secs: config.fundamental_ttl,
|
||||
},
|
||||
offline,
|
||||
no_cache,
|
||||
analyze_fundamental,
|
||||
)?;
|
||||
render_fundamental(&report, &config.output, config.no_color)
|
||||
}
|
||||
StocksSubcommand::Compare { symbols } => {
|
||||
let mut reports: Vec<FundamentalReport> = Vec::new();
|
||||
let mut last_error = None;
|
||||
|
||||
for sym in symbols.iter().flat_map(|s| s.split(',')) {
|
||||
let resolved = crate::api::resolve_symbol(sym, &config.exchange);
|
||||
match fetch_fundamental_analysis_report(
|
||||
&cache,
|
||||
provider,
|
||||
&resolved,
|
||||
FundamentalCacheSpec {
|
||||
key: "fundamental",
|
||||
ttl_secs: config.fundamental_ttl,
|
||||
},
|
||||
offline,
|
||||
no_cache,
|
||||
analyze_fundamental,
|
||||
) {
|
||||
Ok(report) => reports.push(report),
|
||||
Err(err) => {
|
||||
eprintln!("warning: failed to fetch fundamentals for {resolved}: {err}");
|
||||
last_error = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if reports.is_empty() {
|
||||
return Err(last_error.unwrap_or_else(|| {
|
||||
IdxError::CacheMiss("fundamental/no symbols could be compared".to_string())
|
||||
}));
|
||||
}
|
||||
|
||||
render_compare(&reports, &config.output, config.no_color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_fundamental_analysis_report<T, F>(
|
||||
cache: &Cache,
|
||||
provider: &dyn MarketDataProvider,
|
||||
resolved: &str,
|
||||
cache_spec: FundamentalCacheSpec<'_>,
|
||||
offline: bool,
|
||||
no_cache: bool,
|
||||
analyzer: F,
|
||||
) -> Result<T, IdxError>
|
||||
where
|
||||
T: Serialize + DeserializeOwned,
|
||||
F: FnOnce(&str, &Fundamentals) -> T,
|
||||
{
|
||||
if !no_cache && let Some(report) = cache.get::<T>(cache_spec.key, resolved)? {
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
if offline {
|
||||
return cache
|
||||
.get_stale::<T>(cache_spec.key, resolved)?
|
||||
.ok_or_else(|| IdxError::CacheMiss(format!("{}/{resolved}", cache_spec.key)));
|
||||
}
|
||||
|
||||
match provider.fundamentals(resolved) {
|
||||
Ok(fundamentals) => {
|
||||
let report = analyzer(resolved, &fundamentals);
|
||||
if !no_cache {
|
||||
cache.put(cache_spec.key, resolved, &report, cache_spec.ttl_secs)?;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
Err(err) => {
|
||||
if !no_cache && let Some(stale) = cache.get_stale::<T>(cache_spec.key, resolved)? {
|
||||
eprintln!("warning: network failed, serving stale cache for {resolved}");
|
||||
return Ok(stale);
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue