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
468
src/analysis/fundamental.rs
Normal file
468
src/analysis/fundamental.rs
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::types::Fundamentals;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GrowthReport {
|
||||
pub revenue_growth: Option<f64>,
|
||||
pub earnings_growth: Option<f64>,
|
||||
pub revenue_growth_pct: Option<f64>,
|
||||
pub earnings_growth_pct: Option<f64>,
|
||||
pub revenue_signal: String,
|
||||
pub earnings_signal: String,
|
||||
pub overall_signal: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ValuationReport {
|
||||
pub pe_trailing: Option<f64>,
|
||||
pub pe_forward: Option<f64>,
|
||||
pub pb: Option<f64>,
|
||||
pub roe: Option<f64>,
|
||||
pub roe_pct: Option<f64>,
|
||||
pub net_margin: Option<f64>,
|
||||
pub net_margin_pct: Option<f64>,
|
||||
pub ev_ebitda: Option<f64>,
|
||||
pub pe_signal: String,
|
||||
pub pb_signal: String,
|
||||
pub roe_signal: String,
|
||||
pub margin_signal: String,
|
||||
pub ev_ebitda_signal: String,
|
||||
pub overall_signal: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RiskReport {
|
||||
pub debt_to_equity: Option<f64>,
|
||||
pub current_ratio: Option<f64>,
|
||||
pub roa: Option<f64>,
|
||||
pub roa_pct: Option<f64>,
|
||||
pub de_signal: String,
|
||||
pub current_ratio_signal: String,
|
||||
pub overall_signal: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FundamentalReport {
|
||||
pub symbol: String,
|
||||
pub growth: GrowthReport,
|
||||
pub valuation: ValuationReport,
|
||||
pub risk: RiskReport,
|
||||
pub overall_signal: String,
|
||||
}
|
||||
|
||||
pub fn analyze_growth(fundamentals: &Fundamentals) -> GrowthReport {
|
||||
let revenue_signal = growth_signal(fundamentals.revenue_growth);
|
||||
let earnings_signal = growth_signal(fundamentals.earnings_growth);
|
||||
let positive = ["strong", "moderate"];
|
||||
let negative = ["declining", "contracting"];
|
||||
|
||||
let overall_signal = if revenue_signal == "no data" && earnings_signal == "no data" {
|
||||
"no data"
|
||||
} else if positive.contains(&revenue_signal) && positive.contains(&earnings_signal) {
|
||||
"growing"
|
||||
} else if negative.contains(&revenue_signal) && negative.contains(&earnings_signal) {
|
||||
"shrinking"
|
||||
} else if revenue_signal == "no data" || earnings_signal == "no data" {
|
||||
let other = if earnings_signal == "no data" {
|
||||
revenue_signal
|
||||
} else {
|
||||
earnings_signal
|
||||
};
|
||||
if negative.contains(&other) {
|
||||
"mixed"
|
||||
} else {
|
||||
"incomplete data"
|
||||
}
|
||||
} else {
|
||||
"mixed"
|
||||
};
|
||||
|
||||
GrowthReport {
|
||||
revenue_growth: fundamentals.revenue_growth,
|
||||
earnings_growth: fundamentals.earnings_growth,
|
||||
revenue_growth_pct: ratio_pct(fundamentals.revenue_growth),
|
||||
earnings_growth_pct: ratio_pct(fundamentals.earnings_growth),
|
||||
revenue_signal: revenue_signal.to_string(),
|
||||
earnings_signal: earnings_signal.to_string(),
|
||||
overall_signal: overall_signal.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analyze_valuation(fundamentals: &Fundamentals) -> ValuationReport {
|
||||
let ev_ebitda = match (fundamentals.enterprise_value, fundamentals.ebitda) {
|
||||
(Some(ev), Some(ebitda)) if ebitda > 0 => Some(round2(ev as f64 / ebitda as f64)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let pe_signal = pe_signal(fundamentals.trailing_pe);
|
||||
let pb_signal = pb_signal(fundamentals.price_to_book);
|
||||
let ev_ebitda_signal = ev_ebitda_signal(ev_ebitda);
|
||||
let cheap = ["deep value", "undervalued"];
|
||||
let rich = ["premium", "expensive"];
|
||||
let price_signals: Vec<&str> = [pe_signal, pb_signal, ev_ebitda_signal]
|
||||
.into_iter()
|
||||
.filter(|signal| *signal != "no data")
|
||||
.collect();
|
||||
|
||||
let overall_signal = if price_signals.is_empty() {
|
||||
"no data"
|
||||
} else {
|
||||
let cheap_count = price_signals
|
||||
.iter()
|
||||
.filter(|signal| cheap.contains(signal))
|
||||
.count();
|
||||
let rich_count = price_signals
|
||||
.iter()
|
||||
.filter(|signal| rich.contains(signal))
|
||||
.count();
|
||||
|
||||
if cheap_count > price_signals.len() / 2 {
|
||||
"undervalued"
|
||||
} else if rich_count > price_signals.len() / 2 {
|
||||
"expensive"
|
||||
} else {
|
||||
"fairly valued"
|
||||
}
|
||||
};
|
||||
|
||||
ValuationReport {
|
||||
pe_trailing: fundamentals.trailing_pe,
|
||||
pe_forward: fundamentals.forward_pe,
|
||||
pb: fundamentals.price_to_book,
|
||||
roe: fundamentals.return_on_equity,
|
||||
roe_pct: ratio_pct(fundamentals.return_on_equity),
|
||||
net_margin: fundamentals.profit_margins,
|
||||
net_margin_pct: ratio_pct(fundamentals.profit_margins),
|
||||
ev_ebitda,
|
||||
pe_signal: pe_signal.to_string(),
|
||||
pb_signal: pb_signal.to_string(),
|
||||
roe_signal: roe_signal(fundamentals.return_on_equity).to_string(),
|
||||
margin_signal: margin_signal(fundamentals.profit_margins).to_string(),
|
||||
ev_ebitda_signal: ev_ebitda_signal.to_string(),
|
||||
overall_signal: overall_signal.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analyze_risk(fundamentals: &Fundamentals) -> RiskReport {
|
||||
let de_signal = de_signal(fundamentals.debt_to_equity);
|
||||
let current_ratio_signal = cr_signal(fundamentals.current_ratio);
|
||||
|
||||
let overall_signal = if de_signal == "no data" && current_ratio_signal == "no data" {
|
||||
"no data"
|
||||
} else if de_signal == "no data" || current_ratio_signal == "no data" {
|
||||
"incomplete data"
|
||||
} else if de_signal == "highly leveraged" || current_ratio_signal == "weak" {
|
||||
"high risk"
|
||||
} else if de_signal == "conservative" && matches!(current_ratio_signal, "strong" | "adequate") {
|
||||
"low risk"
|
||||
} else {
|
||||
"moderate risk"
|
||||
};
|
||||
|
||||
RiskReport {
|
||||
debt_to_equity: fundamentals.debt_to_equity,
|
||||
current_ratio: fundamentals.current_ratio,
|
||||
roa: fundamentals.return_on_assets,
|
||||
roa_pct: ratio_pct(fundamentals.return_on_assets),
|
||||
de_signal: de_signal.to_string(),
|
||||
current_ratio_signal: current_ratio_signal.to_string(),
|
||||
overall_signal: overall_signal.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analyze_fundamental(symbol: &str, fundamentals: &Fundamentals) -> FundamentalReport {
|
||||
let growth = analyze_growth(fundamentals);
|
||||
let valuation = analyze_valuation(fundamentals);
|
||||
let risk = analyze_risk(fundamentals);
|
||||
|
||||
let overall_signal = if growth.overall_signal == "growing"
|
||||
&& valuation.overall_signal != "expensive"
|
||||
&& risk.overall_signal != "high risk"
|
||||
{
|
||||
"healthy"
|
||||
} else if growth.overall_signal == "shrinking" && risk.overall_signal == "high risk" {
|
||||
"weak"
|
||||
} else if [
|
||||
&growth.overall_signal,
|
||||
&valuation.overall_signal,
|
||||
&risk.overall_signal,
|
||||
]
|
||||
.into_iter()
|
||||
.all(|signal| *signal == "no data")
|
||||
{
|
||||
"no data"
|
||||
} else {
|
||||
"mixed"
|
||||
};
|
||||
|
||||
FundamentalReport {
|
||||
symbol: symbol.to_string(),
|
||||
growth,
|
||||
valuation,
|
||||
risk,
|
||||
overall_signal: overall_signal.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn growth_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value >= 0.20 {
|
||||
"strong"
|
||||
} else if value >= 0.10 {
|
||||
"moderate"
|
||||
} else if value >= 0.0 {
|
||||
"slow"
|
||||
} else if value >= -0.10 {
|
||||
"declining"
|
||||
} else {
|
||||
"contracting"
|
||||
}
|
||||
}
|
||||
|
||||
fn pe_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value <= 0.0 {
|
||||
"no data"
|
||||
} else if value < 8.0 {
|
||||
"deep value"
|
||||
} else if value < 15.0 {
|
||||
"undervalued"
|
||||
} else if value < 25.0 {
|
||||
"fairly valued"
|
||||
} else if value < 40.0 {
|
||||
"premium"
|
||||
} else {
|
||||
"expensive"
|
||||
}
|
||||
}
|
||||
|
||||
fn pb_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value <= 0.0 {
|
||||
"no data"
|
||||
} else if value < 1.0 {
|
||||
"deep value"
|
||||
} else if value < 2.0 {
|
||||
"undervalued"
|
||||
} else if value < 4.0 {
|
||||
"fairly valued"
|
||||
} else {
|
||||
"expensive"
|
||||
}
|
||||
}
|
||||
|
||||
fn roe_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value >= 0.20 {
|
||||
"excellent"
|
||||
} else if value >= 0.15 {
|
||||
"strong"
|
||||
} else if value >= 0.10 {
|
||||
"adequate"
|
||||
} else if value >= 0.0 {
|
||||
"weak"
|
||||
} else {
|
||||
"negative"
|
||||
}
|
||||
}
|
||||
|
||||
fn margin_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value >= 0.20 {
|
||||
"excellent"
|
||||
} else if value >= 0.10 {
|
||||
"healthy"
|
||||
} else if value >= 0.05 {
|
||||
"adequate"
|
||||
} else if value >= 0.0 {
|
||||
"thin"
|
||||
} else {
|
||||
"negative"
|
||||
}
|
||||
}
|
||||
|
||||
fn ev_ebitda_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value <= 0.0 {
|
||||
"no data"
|
||||
} else if value < 8.0 {
|
||||
"undervalued"
|
||||
} else if value < 14.0 {
|
||||
"fairly valued"
|
||||
} else if value < 20.0 {
|
||||
"premium"
|
||||
} else {
|
||||
"expensive"
|
||||
}
|
||||
}
|
||||
|
||||
fn de_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value < 0.0 {
|
||||
"negative equity"
|
||||
} else if value < 50.0 {
|
||||
"conservative"
|
||||
} else if value < 100.0 {
|
||||
"moderate"
|
||||
} else if value < 200.0 {
|
||||
"leveraged"
|
||||
} else {
|
||||
"highly leveraged"
|
||||
}
|
||||
}
|
||||
|
||||
fn cr_signal(value: Option<f64>) -> &'static str {
|
||||
let Some(value) = value else {
|
||||
return "no data";
|
||||
};
|
||||
if value >= 2.0 {
|
||||
"strong"
|
||||
} else if value >= 1.5 {
|
||||
"adequate"
|
||||
} else if value >= 1.0 {
|
||||
"tight"
|
||||
} else {
|
||||
"weak"
|
||||
}
|
||||
}
|
||||
|
||||
fn ratio_pct(value: Option<f64>) -> Option<f64> {
|
||||
value.map(|value| round2(value * 100.0))
|
||||
}
|
||||
|
||||
fn round2(value: f64) -> f64 {
|
||||
(value * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
Fundamentals, analyze_fundamental, analyze_growth, analyze_risk, analyze_valuation,
|
||||
cr_signal, de_signal, ev_ebitda_signal, growth_signal, margin_signal, pb_signal, pe_signal,
|
||||
roe_signal,
|
||||
};
|
||||
|
||||
fn sample_fundamentals() -> Fundamentals {
|
||||
Fundamentals {
|
||||
trailing_pe: Some(12.5),
|
||||
forward_pe: Some(11.0),
|
||||
price_to_book: Some(1.8),
|
||||
return_on_equity: Some(0.18),
|
||||
profit_margins: Some(0.12),
|
||||
return_on_assets: Some(0.06),
|
||||
revenue_growth: Some(0.12),
|
||||
earnings_growth: Some(0.22),
|
||||
debt_to_equity: Some(40.0),
|
||||
current_ratio: Some(1.6),
|
||||
enterprise_value: Some(120),
|
||||
ebitda: Some(15),
|
||||
market_cap: Some(100),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growth_signal_thresholds_match_python() {
|
||||
assert_eq!(growth_signal(None), "no data");
|
||||
assert_eq!(growth_signal(Some(0.20)), "strong");
|
||||
assert_eq!(growth_signal(Some(0.10)), "moderate");
|
||||
assert_eq!(growth_signal(Some(0.0)), "slow");
|
||||
assert_eq!(growth_signal(Some(-0.10)), "declining");
|
||||
assert_eq!(growth_signal(Some(-0.11)), "contracting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valuation_signal_thresholds_match_python() {
|
||||
assert_eq!(pe_signal(None), "no data");
|
||||
assert_eq!(pe_signal(Some(7.9)), "deep value");
|
||||
assert_eq!(pe_signal(Some(14.9)), "undervalued");
|
||||
assert_eq!(pe_signal(Some(24.9)), "fairly valued");
|
||||
assert_eq!(pe_signal(Some(39.9)), "premium");
|
||||
assert_eq!(pe_signal(Some(40.0)), "expensive");
|
||||
|
||||
assert_eq!(pb_signal(Some(0.9)), "deep value");
|
||||
assert_eq!(pb_signal(Some(1.9)), "undervalued");
|
||||
assert_eq!(pb_signal(Some(3.9)), "fairly valued");
|
||||
assert_eq!(pb_signal(Some(4.0)), "expensive");
|
||||
|
||||
assert_eq!(roe_signal(Some(0.20)), "excellent");
|
||||
assert_eq!(roe_signal(Some(0.15)), "strong");
|
||||
assert_eq!(roe_signal(Some(0.10)), "adequate");
|
||||
assert_eq!(roe_signal(Some(0.0)), "weak");
|
||||
assert_eq!(roe_signal(Some(-0.01)), "negative");
|
||||
|
||||
assert_eq!(margin_signal(Some(0.20)), "excellent");
|
||||
assert_eq!(margin_signal(Some(0.10)), "healthy");
|
||||
assert_eq!(margin_signal(Some(0.05)), "adequate");
|
||||
assert_eq!(margin_signal(Some(0.0)), "thin");
|
||||
assert_eq!(margin_signal(Some(-0.01)), "negative");
|
||||
|
||||
assert_eq!(ev_ebitda_signal(Some(7.9)), "undervalued");
|
||||
assert_eq!(ev_ebitda_signal(Some(13.9)), "fairly valued");
|
||||
assert_eq!(ev_ebitda_signal(Some(19.9)), "premium");
|
||||
assert_eq!(ev_ebitda_signal(Some(20.0)), "expensive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_signal_thresholds_match_python() {
|
||||
assert_eq!(de_signal(None), "no data");
|
||||
assert_eq!(de_signal(Some(-1.0)), "negative equity");
|
||||
assert_eq!(de_signal(Some(49.9)), "conservative");
|
||||
assert_eq!(de_signal(Some(99.9)), "moderate");
|
||||
assert_eq!(de_signal(Some(199.9)), "leveraged");
|
||||
assert_eq!(de_signal(Some(200.0)), "highly leveraged");
|
||||
|
||||
assert_eq!(cr_signal(None), "no data");
|
||||
assert_eq!(cr_signal(Some(2.0)), "strong");
|
||||
assert_eq!(cr_signal(Some(1.5)), "adequate");
|
||||
assert_eq!(cr_signal(Some(1.0)), "tight");
|
||||
assert_eq!(cr_signal(Some(0.99)), "weak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_overalls_match_python_logic() {
|
||||
let fundamentals = sample_fundamentals();
|
||||
|
||||
let growth = analyze_growth(&fundamentals);
|
||||
assert_eq!(growth.overall_signal, "growing");
|
||||
|
||||
let valuation = analyze_valuation(&fundamentals);
|
||||
assert_eq!(valuation.overall_signal, "undervalued");
|
||||
assert_eq!(valuation.ev_ebitda, Some(8.0));
|
||||
|
||||
let risk = analyze_risk(&fundamentals);
|
||||
assert_eq!(risk.overall_signal, "low risk");
|
||||
|
||||
let fundamental = analyze_fundamental("BBCA.JK", &fundamentals);
|
||||
assert_eq!(fundamental.overall_signal, "healthy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_growth_and_high_risk_paths_match_python_logic() {
|
||||
let mut fundamentals = sample_fundamentals();
|
||||
fundamentals.revenue_growth = None;
|
||||
fundamentals.earnings_growth = Some(0.05);
|
||||
fundamentals.debt_to_equity = Some(250.0);
|
||||
fundamentals.current_ratio = Some(0.9);
|
||||
|
||||
let growth = analyze_growth(&fundamentals);
|
||||
assert_eq!(growth.overall_signal, "incomplete data");
|
||||
|
||||
let risk = analyze_risk(&fundamentals);
|
||||
assert_eq!(risk.overall_signal, "high risk");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod fundamental;
|
||||
pub mod signals;
|
||||
pub mod technical;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ pub mod types;
|
|||
pub mod yahoo;
|
||||
|
||||
use crate::error::IdxError;
|
||||
use types::{Interval, Ohlc, Period, Quote};
|
||||
use types::{Fundamentals, Interval, Ohlc, Period, Quote};
|
||||
|
||||
pub trait MarketDataProvider {
|
||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError>;
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||
fn history(
|
||||
&self,
|
||||
symbol: &str,
|
||||
|
|
@ -35,6 +36,7 @@ pub fn default_provider(verbose: bool) -> Box<dyn MarketDataProvider> {
|
|||
|
||||
pub struct MockProvider {
|
||||
quote: Result<Quote, IdxError>,
|
||||
fundamentals: Result<Fundamentals, IdxError>,
|
||||
history: Result<Vec<Ohlc>, IdxError>,
|
||||
}
|
||||
|
||||
|
|
@ -48,19 +50,28 @@ impl MockProvider {
|
|||
.unwrap_or_else(|_| "{}".to_string());
|
||||
let history_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_3mo.json")
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
let fundamentals_raw = std::fs::read_to_string("tests/fixtures/quotesummary_bbca.json")
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
let quote = yahoo::parse_quote_from_str("BBCA.JK", "e_raw)
|
||||
.map_err(|e| IdxError::ParseError(e.to_string()));
|
||||
let fundamentals = yahoo::parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw)
|
||||
.map_err(|e| IdxError::ParseError(e.to_string()));
|
||||
let history = yahoo::parse_history_from_str(&history_raw)
|
||||
.map_err(|e| IdxError::ParseError(e.to_string()));
|
||||
|
||||
Self { quote, history }
|
||||
Self {
|
||||
quote,
|
||||
fundamentals,
|
||||
history,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_error(err: IdxError) -> Self {
|
||||
Self {
|
||||
quote: Err(err),
|
||||
history: Err(IdxError::ProviderUnavailable),
|
||||
quote: Err(err.clone()),
|
||||
fundamentals: Err(err.clone()),
|
||||
history: Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,6 +83,10 @@ impl MarketDataProvider for MockProvider {
|
|||
Ok(q)
|
||||
}
|
||||
|
||||
fn fundamentals(&self, _symbol: &str) -> Result<Fundamentals, IdxError> {
|
||||
self.fundamentals.clone()
|
||||
}
|
||||
|
||||
fn history(
|
||||
&self,
|
||||
_symbol: &str,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,24 @@ pub struct Ohlc {
|
|||
pub volume: u64,
|
||||
}
|
||||
|
||||
/// Fundamental metrics normalized from Yahoo Finance `/v10/finance/quoteSummary`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Fundamentals {
|
||||
pub trailing_pe: Option<f64>,
|
||||
pub forward_pe: Option<f64>,
|
||||
pub price_to_book: Option<f64>,
|
||||
pub return_on_equity: Option<f64>,
|
||||
pub profit_margins: Option<f64>,
|
||||
pub return_on_assets: Option<f64>,
|
||||
pub revenue_growth: Option<f64>,
|
||||
pub earnings_growth: Option<f64>,
|
||||
pub debt_to_equity: Option<f64>,
|
||||
pub current_ratio: Option<f64>,
|
||||
pub enterprise_value: Option<i64>,
|
||||
pub ebitda: Option<i64>,
|
||||
pub market_cap: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum NumberLike {
|
||||
|
|
|
|||
246
src/api/yahoo.rs
246
src/api/yahoo.rs
|
|
@ -1,9 +1,10 @@
|
|||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::MarketDataProvider;
|
||||
use crate::api::types::{Interval, Ohlc, Period, Quote};
|
||||
use crate::api::types::{Fundamentals, Interval, Ohlc, Period, Quote};
|
||||
use crate::error::IdxError;
|
||||
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
||||
|
|
@ -34,6 +35,12 @@ impl YahooProvider {
|
|||
)
|
||||
}
|
||||
|
||||
fn quote_summary_url(symbol: &str) -> String {
|
||||
format!(
|
||||
"{BASE_URL}/v10/finance/quoteSummary/{symbol}?modules=defaultKeyStatistics,financialData,incomeStatementHistory"
|
||||
)
|
||||
}
|
||||
|
||||
fn fetch_chart(
|
||||
&self,
|
||||
symbol: &str,
|
||||
|
|
@ -51,7 +58,7 @@ impl YahooProvider {
|
|||
.read_json::<ChartResponse>()
|
||||
.map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
if let Some(err) = chart.chart.error.as_ref() {
|
||||
return Err(map_chart_error(symbol, err));
|
||||
return Err(map_yahoo_error(symbol, "chart", err));
|
||||
}
|
||||
return Ok(chart);
|
||||
}
|
||||
|
|
@ -69,6 +76,37 @@ impl YahooProvider {
|
|||
}
|
||||
Err(IdxError::RateLimited)
|
||||
}
|
||||
|
||||
fn fetch_quote_summary(&self, symbol: &str) -> Result<QuoteSummaryResponse, IdxError> {
|
||||
let mut wait = Duration::from_millis(250);
|
||||
for attempt in 0..3 {
|
||||
let url = Self::quote_summary_url(symbol);
|
||||
let response = self.agent.get(&url).header("User-Agent", USER_AGENT).call();
|
||||
match response {
|
||||
Ok(ok) => {
|
||||
let quote_summary = ok
|
||||
.into_body()
|
||||
.read_json::<QuoteSummaryResponse>()
|
||||
.map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
if let Some(err) = quote_summary.quote_summary.error.as_ref() {
|
||||
return Err(map_yahoo_error(symbol, "quoteSummary", err));
|
||||
}
|
||||
return Ok(quote_summary);
|
||||
}
|
||||
Err(ureq::Error::StatusCode(429)) => {
|
||||
if attempt < 2 {
|
||||
std::thread::sleep(wait + jitter());
|
||||
wait *= 2;
|
||||
}
|
||||
}
|
||||
Err(ureq::Error::StatusCode(404)) => {
|
||||
return Err(IdxError::SymbolNotFound(symbol.to_string()));
|
||||
}
|
||||
Err(e) => return Err(IdxError::Http(e.to_string())),
|
||||
}
|
||||
}
|
||||
Err(IdxError::RateLimited)
|
||||
}
|
||||
}
|
||||
|
||||
fn jitter() -> Duration {
|
||||
|
|
@ -81,12 +119,12 @@ fn round_price(value: f64) -> i64 {
|
|||
|
||||
// verbose behavior is configured on YahooProvider and threaded into history parsing.
|
||||
|
||||
fn map_chart_error(symbol: &str, err: &ChartError) -> IdxError {
|
||||
fn map_yahoo_error(symbol: &str, endpoint: &str, err: &ChartError) -> IdxError {
|
||||
if err.code.eq_ignore_ascii_case("Not Found") {
|
||||
return IdxError::SymbolNotFound(symbol.to_string());
|
||||
}
|
||||
IdxError::Http(format!(
|
||||
"yahoo chart error {}: {}",
|
||||
"yahoo {endpoint} error {}: {}",
|
||||
err.code, err.description
|
||||
))
|
||||
}
|
||||
|
|
@ -97,6 +135,11 @@ impl MarketDataProvider for YahooProvider {
|
|||
parse_quote(symbol, &chart)
|
||||
}
|
||||
|
||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError> {
|
||||
let quote_summary = self.fetch_quote_summary(symbol)?;
|
||||
parse_fundamentals(symbol, "e_summary)
|
||||
}
|
||||
|
||||
fn history(
|
||||
&self,
|
||||
symbol: &str,
|
||||
|
|
@ -112,14 +155,14 @@ pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result<Quote, Idx
|
|||
let chart: ChartResponse =
|
||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
if let Some(err) = chart.chart.error.as_ref() {
|
||||
return Err(map_chart_error(symbol, err));
|
||||
return Err(map_yahoo_error(symbol, "chart", err));
|
||||
}
|
||||
parse_quote(symbol, &chart)
|
||||
}
|
||||
|
||||
fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
||||
if let Some(err) = chart.chart.error.as_ref() {
|
||||
return Err(map_chart_error(symbol, err));
|
||||
return Err(map_yahoo_error(symbol, "chart", err));
|
||||
}
|
||||
|
||||
let result = chart
|
||||
|
|
@ -183,9 +226,21 @@ pub(crate) fn parse_history_from_str(raw: &str) -> Result<Vec<Ohlc>, IdxError> {
|
|||
parse_history_with_verbose(&chart, false)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_fundamentals_from_str(
|
||||
symbol: &str,
|
||||
raw: &str,
|
||||
) -> Result<Fundamentals, IdxError> {
|
||||
let quote_summary: QuoteSummaryResponse =
|
||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||
if let Some(err) = quote_summary.quote_summary.error.as_ref() {
|
||||
return Err(map_yahoo_error(symbol, "quoteSummary", err));
|
||||
}
|
||||
parse_fundamentals(symbol, "e_summary)
|
||||
}
|
||||
|
||||
fn parse_history_with_verbose(chart: &ChartResponse, verbose: bool) -> Result<Vec<Ohlc>, IdxError> {
|
||||
if let Some(err) = chart.chart.error.as_ref() {
|
||||
return Err(map_chart_error("unknown", err));
|
||||
return Err(map_yahoo_error("unknown", "chart", err));
|
||||
}
|
||||
|
||||
let result = chart
|
||||
|
|
@ -259,11 +314,85 @@ fn parse_history_with_verbose(chart: &ChartResponse, verbose: bool) -> Result<Ve
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_fundamentals(
|
||||
symbol: &str,
|
||||
quote_summary: &QuoteSummaryResponse,
|
||||
) -> Result<Fundamentals, IdxError> {
|
||||
if let Some(err) = quote_summary.quote_summary.error.as_ref() {
|
||||
return Err(map_yahoo_error(symbol, "quoteSummary", err));
|
||||
}
|
||||
|
||||
let result = quote_summary
|
||||
.quote_summary
|
||||
.result
|
||||
.as_ref()
|
||||
.and_then(|results| results.first())
|
||||
.ok_or(IdxError::ProviderUnavailable)?;
|
||||
|
||||
Ok(Fundamentals {
|
||||
trailing_pe: result
|
||||
.default_key_statistics
|
||||
.get_f64("trailingPE")
|
||||
.or_else(|| result.financial_data.get_f64("trailingPE")),
|
||||
forward_pe: result
|
||||
.default_key_statistics
|
||||
.get_f64("forwardPE")
|
||||
.or_else(|| result.financial_data.get_f64("forwardPE")),
|
||||
price_to_book: result
|
||||
.default_key_statistics
|
||||
.get_f64("priceToBook")
|
||||
.or_else(|| result.financial_data.get_f64("priceToBook")),
|
||||
return_on_equity: result.financial_data.get_f64("returnOnEquity"),
|
||||
profit_margins: result.financial_data.get_f64("profitMargins"),
|
||||
return_on_assets: result.financial_data.get_f64("returnOnAssets"),
|
||||
revenue_growth: result.financial_data.get_f64("revenueGrowth"),
|
||||
earnings_growth: result
|
||||
.default_key_statistics
|
||||
.get_f64("earningsGrowth")
|
||||
.or_else(|| result.financial_data.get_f64("earningsGrowth")),
|
||||
debt_to_equity: result.financial_data.get_f64("debtToEquity"),
|
||||
current_ratio: result.financial_data.get_f64("currentRatio"),
|
||||
enterprise_value: result
|
||||
.default_key_statistics
|
||||
.get_i64("enterpriseValue")
|
||||
.or_else(|| result.financial_data.get_i64("enterpriseValue")),
|
||||
ebitda: result
|
||||
.financial_data
|
||||
.get_i64("ebitda")
|
||||
.or_else(|| result.default_key_statistics.get_i64("ebitda")),
|
||||
market_cap: result
|
||||
.financial_data
|
||||
.get_u64("marketCap")
|
||||
.or_else(|| result.default_key_statistics.get_u64("marketCap")),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChartResponse {
|
||||
chart: ChartRoot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QuoteSummaryResponse {
|
||||
quote_summary: QuoteSummaryRoot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QuoteSummaryRoot {
|
||||
result: Option<Vec<QuoteSummaryResult>>,
|
||||
error: Option<ChartError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QuoteSummaryResult {
|
||||
#[serde(default)]
|
||||
default_key_statistics: QuoteSummarySection,
|
||||
#[serde(default)]
|
||||
financial_data: QuoteSummarySection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChartRoot {
|
||||
result: Option<Vec<ChartResult>>,
|
||||
|
|
@ -316,11 +445,98 @@ struct IndicatorQuote {
|
|||
volume: Option<Vec<Option<u64>>>,
|
||||
}
|
||||
|
||||
type QuoteSummarySection = HashMap<String, QuoteSummaryValue>;
|
||||
|
||||
trait QuoteSummarySectionExt {
|
||||
fn get_f64(&self, key: &str) -> Option<f64>;
|
||||
fn get_i64(&self, key: &str) -> Option<i64>;
|
||||
fn get_u64(&self, key: &str) -> Option<u64>;
|
||||
}
|
||||
|
||||
impl QuoteSummarySectionExt for QuoteSummarySection {
|
||||
fn get_f64(&self, key: &str) -> Option<f64> {
|
||||
self.get(key).and_then(QuoteSummaryValue::as_f64)
|
||||
}
|
||||
|
||||
fn get_i64(&self, key: &str) -> Option<i64> {
|
||||
self.get(key).and_then(QuoteSummaryValue::as_i64)
|
||||
}
|
||||
|
||||
fn get_u64(&self, key: &str) -> Option<u64> {
|
||||
self.get(key).and_then(QuoteSummaryValue::as_u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum QuoteSummaryValue {
|
||||
Wrapped { raw: Option<YahooNumber> },
|
||||
Direct(YahooNumber),
|
||||
}
|
||||
|
||||
impl QuoteSummaryValue {
|
||||
fn as_f64(&self) -> Option<f64> {
|
||||
match self {
|
||||
Self::Wrapped { raw } => raw.as_ref().map(YahooNumber::as_f64),
|
||||
Self::Direct(value) => Some(value.as_f64()),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_i64(&self) -> Option<i64> {
|
||||
match self {
|
||||
Self::Wrapped { raw } => raw.as_ref().and_then(YahooNumber::as_i64),
|
||||
Self::Direct(value) => value.as_i64(),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_u64(&self) -> Option<u64> {
|
||||
match self {
|
||||
Self::Wrapped { raw } => raw.as_ref().and_then(YahooNumber::as_u64),
|
||||
Self::Direct(value) => value.as_u64(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum YahooNumber {
|
||||
I64(i64),
|
||||
U64(u64),
|
||||
F64(f64),
|
||||
}
|
||||
|
||||
impl YahooNumber {
|
||||
fn as_f64(&self) -> f64 {
|
||||
match self {
|
||||
Self::I64(value) => *value as f64,
|
||||
Self::U64(value) => *value as f64,
|
||||
Self::F64(value) => *value,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_i64(&self) -> Option<i64> {
|
||||
match self {
|
||||
Self::I64(value) => Some(*value),
|
||||
Self::U64(value) => i64::try_from(*value).ok(),
|
||||
Self::F64(value) => Some(value.round() as i64),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_u64(&self) -> Option<u64> {
|
||||
match self {
|
||||
Self::I64(value) => u64::try_from(*value).ok(),
|
||||
Self::U64(value) => Some(*value),
|
||||
Self::F64(value) if value.is_sign_negative() => None,
|
||||
Self::F64(value) => Some(value.round() as u64),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ChartResponse, parse_history_from_str, parse_history_with_verbose, parse_quote,
|
||||
parse_quote_from_str,
|
||||
ChartResponse, parse_fundamentals_from_str, parse_history_from_str,
|
||||
parse_history_with_verbose, parse_quote, parse_quote_from_str,
|
||||
};
|
||||
|
||||
const SAMPLE: &str = r#"{
|
||||
|
|
@ -365,6 +581,8 @@ mod tests {
|
|||
std::fs::read_to_string("tests/fixtures/chart_bbca_1d.json").expect("fixture exists");
|
||||
let history_raw =
|
||||
std::fs::read_to_string("tests/fixtures/chart_bbca_3mo.json").expect("fixture exists");
|
||||
let fundamentals_raw = std::fs::read_to_string("tests/fixtures/quotesummary_bbca.json")
|
||||
.expect("fixture exists");
|
||||
|
||||
let quote = parse_quote_from_str("BBCA.JK", "e_raw).expect("fixture quote parsed");
|
||||
assert_eq!(quote.symbol, "BBCA.JK");
|
||||
|
|
@ -373,6 +591,16 @@ mod tests {
|
|||
|
||||
let history = parse_history_from_str(&history_raw).expect("fixture history parsed");
|
||||
assert!(!history.is_empty());
|
||||
|
||||
let fundamentals = parse_fundamentals_from_str("BBCA.JK", &fundamentals_raw)
|
||||
.expect("fixture fundamentals parsed");
|
||||
assert_eq!(fundamentals.trailing_pe, Some(25.4));
|
||||
assert_eq!(fundamentals.forward_pe, Some(23.1));
|
||||
assert_eq!(fundamentals.price_to_book, Some(4.6));
|
||||
assert_eq!(fundamentals.earnings_growth, Some(0.121));
|
||||
assert_eq!(fundamentals.enterprise_value, Some(1_245_000_000_000_000));
|
||||
assert_eq!(fundamentals.ebitda, Some(58_500_000_000_000));
|
||||
assert_eq!(fundamentals.market_cap, Some(1_215_200_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use chrono::NaiveDate;
|
|||
use clap::ValueEnum;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport};
|
||||
use crate::analysis::signals::TechnicalSignal;
|
||||
use crate::api::types::{Ohlc, Quote};
|
||||
use crate::error::IdxError;
|
||||
|
|
@ -78,6 +79,64 @@ pub fn render_technical(
|
|||
}
|
||||
}
|
||||
|
||||
pub fn render_growth(
|
||||
symbol: &str,
|
||||
report: &GrowthReport,
|
||||
format: &OutputFormat,
|
||||
no_color: bool,
|
||||
) -> Result<(), IdxError> {
|
||||
match format {
|
||||
OutputFormat::Table => table::print_growth(symbol, report, no_color),
|
||||
OutputFormat::Json => json::print_json(report),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_valuation(
|
||||
symbol: &str,
|
||||
report: &ValuationReport,
|
||||
format: &OutputFormat,
|
||||
no_color: bool,
|
||||
) -> Result<(), IdxError> {
|
||||
match format {
|
||||
OutputFormat::Table => table::print_valuation(symbol, report, no_color),
|
||||
OutputFormat::Json => json::print_json(report),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_risk(
|
||||
symbol: &str,
|
||||
report: &RiskReport,
|
||||
format: &OutputFormat,
|
||||
no_color: bool,
|
||||
) -> Result<(), IdxError> {
|
||||
match format {
|
||||
OutputFormat::Table => table::print_risk(symbol, report, no_color),
|
||||
OutputFormat::Json => json::print_json(report),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_fundamental(
|
||||
report: &FundamentalReport,
|
||||
format: &OutputFormat,
|
||||
no_color: bool,
|
||||
) -> Result<(), IdxError> {
|
||||
match format {
|
||||
OutputFormat::Table => table::print_fundamental(report, no_color),
|
||||
OutputFormat::Json => json::print_json(report),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_compare(
|
||||
reports: &[FundamentalReport],
|
||||
format: &OutputFormat,
|
||||
no_color: bool,
|
||||
) -> Result<(), IdxError> {
|
||||
match format {
|
||||
OutputFormat::Table => table::print_compare(reports, no_color),
|
||||
OutputFormat::Json => json::print_json(reports),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit_error(err: &IdxError, format: &OutputFormat) {
|
||||
match format {
|
||||
OutputFormat::Table => eprintln!("Error: {err}"),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
|
||||
use owo_colors::OwoColorize;
|
||||
|
||||
use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport};
|
||||
use crate::analysis::signals::Signal;
|
||||
use crate::api::types::{Ohlc, Quote};
|
||||
use crate::error::IdxError;
|
||||
|
|
@ -167,6 +168,223 @@ pub fn print_technical(report: &TechnicalReport, no_color: bool) -> Result<(), I
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn print_growth(symbol: &str, report: &GrowthReport, no_color: bool) -> Result<(), IdxError> {
|
||||
println!("{}", format!("Growth Analysis for {symbol}").bold());
|
||||
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec!["METRIC", "VALUE", "SIGNAL"]);
|
||||
|
||||
table.add_row(vec![
|
||||
Cell::new("Revenue Growth"),
|
||||
Cell::new(format_pct(report.revenue_growth_pct)),
|
||||
Cell::new(format_growth_signal(&report.revenue_signal, no_color)),
|
||||
]);
|
||||
table.add_row(vec![
|
||||
Cell::new("Earnings Growth"),
|
||||
Cell::new(format_pct(report.earnings_growth_pct)),
|
||||
Cell::new(format_growth_signal(&report.earnings_signal, no_color)),
|
||||
]);
|
||||
table.add_row(vec![
|
||||
Cell::new("Overall"),
|
||||
Cell::new("-"),
|
||||
Cell::new(format_growth_signal(&report.overall_signal, no_color)),
|
||||
]);
|
||||
|
||||
println!("{table}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn print_valuation(
|
||||
symbol: &str,
|
||||
report: &ValuationReport,
|
||||
no_color: bool,
|
||||
) -> Result<(), IdxError> {
|
||||
println!("{}", format!("Valuation Analysis for {symbol}").bold());
|
||||
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec!["METRIC", "VALUE", "SIGNAL"]);
|
||||
|
||||
table.add_row(vec![
|
||||
Cell::new("P/E (Trailing)"),
|
||||
Cell::new(format_opt_f64(report.pe_trailing, 2)),
|
||||
Cell::new(format_valuation_signal(&report.pe_signal, no_color)),
|
||||
]);
|
||||
table.add_row(vec![
|
||||
Cell::new("P/E (Forward)"),
|
||||
Cell::new(format_opt_f64(report.pe_forward, 2)),
|
||||
Cell::new("-"),
|
||||
]);
|
||||
table.add_row(vec![
|
||||
Cell::new("Price/Book"),
|
||||
Cell::new(format_opt_f64(report.pb, 2)),
|
||||
Cell::new(format_valuation_signal(&report.pb_signal, no_color)),
|
||||
]);
|
||||
table.add_row(vec![
|
||||
Cell::new("ROE"),
|
||||
Cell::new(format_pct(report.roe_pct)),
|
||||
Cell::new(format_valuation_signal(&report.roe_signal, no_color)),
|
||||
]);
|
||||
table.add_row(vec![
|
||||
Cell::new("Net Margin"),
|
||||
Cell::new(format_pct(report.net_margin_pct)),
|
||||
Cell::new(format_valuation_signal(&report.margin_signal, no_color)),
|
||||
]);
|
||||
table.add_row(vec![
|
||||
Cell::new("EV/EBITDA"),
|
||||
Cell::new(format_opt_f64(report.ev_ebitda, 2)),
|
||||
Cell::new(format_valuation_signal(&report.ev_ebitda_signal, no_color)),
|
||||
]);
|
||||
table.add_row(vec![
|
||||
Cell::new("Overall"),
|
||||
Cell::new("-"),
|
||||
Cell::new(format_valuation_signal(&report.overall_signal, no_color)),
|
||||
]);
|
||||
|
||||
println!("{table}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn print_risk(symbol: &str, report: &RiskReport, no_color: bool) -> Result<(), IdxError> {
|
||||
println!("{}", format!("Risk Analysis for {symbol}").bold());
|
||||
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec!["METRIC", "VALUE", "SIGNAL"]);
|
||||
|
||||
table.add_row(vec![
|
||||
Cell::new("Debt/Equity"),
|
||||
Cell::new(format_opt_f64(report.debt_to_equity, 2)),
|
||||
Cell::new(format_risk_signal(&report.de_signal, no_color)),
|
||||
]);
|
||||
table.add_row(vec![
|
||||
Cell::new("Current Ratio"),
|
||||
Cell::new(format_opt_f64(report.current_ratio, 2)),
|
||||
Cell::new(format_risk_signal(&report.current_ratio_signal, no_color)),
|
||||
]);
|
||||
table.add_row(vec![
|
||||
Cell::new("ROA"),
|
||||
Cell::new(format_pct(report.roa_pct)),
|
||||
Cell::new("-"),
|
||||
]);
|
||||
table.add_row(vec![
|
||||
Cell::new("Overall"),
|
||||
Cell::new("-"),
|
||||
Cell::new(format_risk_signal(&report.overall_signal, no_color)),
|
||||
]);
|
||||
|
||||
println!("{table}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn print_fundamental(report: &FundamentalReport, no_color: bool) -> Result<(), IdxError> {
|
||||
println!(
|
||||
"{}",
|
||||
format!("Fundamental Analysis for {}", report.symbol).bold()
|
||||
);
|
||||
println!();
|
||||
print_growth(&report.symbol, &report.growth, no_color)?;
|
||||
println!();
|
||||
print_valuation(&report.symbol, &report.valuation, no_color)?;
|
||||
println!();
|
||||
print_risk(&report.symbol, &report.risk, no_color)?;
|
||||
println!();
|
||||
println!(
|
||||
"{} {}",
|
||||
"Overall Signal:".bold(),
|
||||
format_growth_signal(&report.overall_signal, no_color)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn print_compare(reports: &[FundamentalReport], no_color: bool) -> Result<(), IdxError> {
|
||||
println!("{}", "Fundamental Comparison".bold());
|
||||
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic);
|
||||
|
||||
let mut header = vec![Cell::new("METRIC")];
|
||||
header.extend(reports.iter().map(|report| Cell::new(&report.symbol)));
|
||||
table.set_header(header);
|
||||
|
||||
add_compare_row(
|
||||
&mut table,
|
||||
"Symbol",
|
||||
reports
|
||||
.iter()
|
||||
.map(|report| report.symbol.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
add_compare_row(
|
||||
&mut table,
|
||||
"Overall",
|
||||
reports
|
||||
.iter()
|
||||
.map(|report| format_growth_signal(&report.overall_signal, no_color))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
add_compare_row(
|
||||
&mut table,
|
||||
"Growth",
|
||||
reports
|
||||
.iter()
|
||||
.map(|report| format_growth_signal(&report.growth.overall_signal, no_color))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
add_compare_row(
|
||||
&mut table,
|
||||
"Valuation",
|
||||
reports
|
||||
.iter()
|
||||
.map(|report| format_valuation_signal(&report.valuation.overall_signal, no_color))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
add_compare_row(
|
||||
&mut table,
|
||||
"Risk",
|
||||
reports
|
||||
.iter()
|
||||
.map(|report| format_risk_signal(&report.risk.overall_signal, no_color))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
add_compare_row(
|
||||
&mut table,
|
||||
"P/E",
|
||||
reports
|
||||
.iter()
|
||||
.map(|report| format_opt_f64(report.valuation.pe_trailing, 2))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
add_compare_row(
|
||||
&mut table,
|
||||
"ROE",
|
||||
reports
|
||||
.iter()
|
||||
.map(|report| format_pct(report.valuation.roe_pct))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
add_compare_row(
|
||||
&mut table,
|
||||
"Revenue Growth",
|
||||
reports
|
||||
.iter()
|
||||
.map(|report| format_pct(report.growth.revenue_growth_pct))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
println!("{table}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_idr_option(value: Option<f64>) -> String {
|
||||
value
|
||||
.map(|v| format_idr(v.round() as i64))
|
||||
|
|
@ -179,6 +397,16 @@ fn format_float(value: Option<f64>, precision: usize) -> String {
|
|||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn format_opt_f64(value: Option<f64>, precision: usize) -> String {
|
||||
format_float(value, precision)
|
||||
}
|
||||
|
||||
fn format_pct(value: Option<f64>) -> String {
|
||||
value
|
||||
.map(|v| format!("{v:+.2}%"))
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn format_signal(signal: Signal, no_color: bool, uppercase: bool) -> String {
|
||||
let label = if uppercase {
|
||||
signal_label_upper(signal)
|
||||
|
|
@ -236,6 +464,58 @@ fn format_volume_ratio(report: &TechnicalReport) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn format_growth_signal(signal: &str, no_color: bool) -> String {
|
||||
format_text_signal(
|
||||
signal,
|
||||
no_color,
|
||||
&["strong", "moderate", "growing", "healthy"],
|
||||
&["contracting", "declining", "shrinking", "weak"],
|
||||
)
|
||||
}
|
||||
|
||||
fn format_valuation_signal(signal: &str, no_color: bool) -> String {
|
||||
format_text_signal(
|
||||
signal,
|
||||
no_color,
|
||||
&["deep value", "undervalued", "excellent", "strong"],
|
||||
&["expensive", "negative"],
|
||||
)
|
||||
}
|
||||
|
||||
fn format_risk_signal(signal: &str, no_color: bool) -> String {
|
||||
format_text_signal(
|
||||
signal,
|
||||
no_color,
|
||||
&["conservative", "strong", "adequate", "low risk"],
|
||||
&["highly leveraged", "weak", "high risk", "negative equity"],
|
||||
)
|
||||
}
|
||||
|
||||
fn format_text_signal(
|
||||
signal: &str,
|
||||
no_color: bool,
|
||||
positive: &[&str],
|
||||
negative: &[&str],
|
||||
) -> String {
|
||||
if no_color {
|
||||
return signal.to_string();
|
||||
}
|
||||
|
||||
if positive.contains(&signal) {
|
||||
signal.green().to_string()
|
||||
} else if negative.contains(&signal) {
|
||||
signal.red().to_string()
|
||||
} else {
|
||||
signal.yellow().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn add_compare_row(table: &mut Table, label: &str, values: Vec<String>) {
|
||||
let mut row = vec![Cell::new(label)];
|
||||
row.extend(values.into_iter().map(Cell::new));
|
||||
table.add_row(row);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_idr, format_signal, format_u64};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue