diff --git a/src/analysis/fundamental.rs b/src/analysis/fundamental.rs new file mode 100644 index 0000000..aec0e80 --- /dev/null +++ b/src/analysis/fundamental.rs @@ -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, + pub earnings_growth: Option, + pub revenue_growth_pct: Option, + pub earnings_growth_pct: Option, + pub revenue_signal: String, + pub earnings_signal: String, + pub overall_signal: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValuationReport { + pub pe_trailing: Option, + pub pe_forward: Option, + pub pb: Option, + pub roe: Option, + pub roe_pct: Option, + pub net_margin: Option, + pub net_margin_pct: Option, + pub ev_ebitda: Option, + 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, + pub current_ratio: Option, + pub roa: Option, + pub roa_pct: Option, + 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) -> &'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) -> &'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) -> &'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) -> &'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) -> &'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) -> &'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) -> &'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) -> &'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) -> Option { + 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"); + } +} diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs index 09c14c3..612ff07 100644 --- a/src/analysis/mod.rs +++ b/src/analysis/mod.rs @@ -1,2 +1,3 @@ +pub mod fundamental; pub mod signals; pub mod technical; diff --git a/src/api/mod.rs b/src/api/mod.rs index c6f67de..d786f60 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -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; + fn fundamentals(&self, symbol: &str) -> Result; fn history( &self, symbol: &str, @@ -35,6 +36,7 @@ pub fn default_provider(verbose: bool) -> Box { pub struct MockProvider { quote: Result, + fundamentals: Result, history: Result, 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 { + self.fundamentals.clone() + } + fn history( &self, _symbol: &str, diff --git a/src/api/types.rs b/src/api/types.rs index 134623a..c110793 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -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, + pub forward_pe: Option, + pub price_to_book: Option, + pub return_on_equity: Option, + pub profit_margins: Option, + pub return_on_assets: Option, + pub revenue_growth: Option, + pub earnings_growth: Option, + pub debt_to_equity: Option, + pub current_ratio: Option, + pub enterprise_value: Option, + pub ebitda: Option, + pub market_cap: Option, +} + #[derive(Debug, Deserialize)] #[serde(untagged)] enum NumberLike { diff --git a/src/api/yahoo.rs b/src/api/yahoo.rs index 57b4885..969815d 100644 --- a/src/api/yahoo.rs +++ b/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::() .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 { + 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::() + .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 { + 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 Result { 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, IdxError> { parse_history_with_verbose(&chart, false) } +pub(crate) fn parse_fundamentals_from_str( + symbol: &str, + raw: &str, +) -> Result { + 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, 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 Result { + 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>, + error: Option, +} + +#[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>, @@ -316,11 +445,98 @@ struct IndicatorQuote { volume: Option>>, } +type QuoteSummarySection = HashMap; + +trait QuoteSummarySectionExt { + fn get_f64(&self, key: &str) -> Option; + fn get_i64(&self, key: &str) -> Option; + fn get_u64(&self, key: &str) -> Option; +} + +impl QuoteSummarySectionExt for QuoteSummarySection { + fn get_f64(&self, key: &str) -> Option { + self.get(key).and_then(QuoteSummaryValue::as_f64) + } + + fn get_i64(&self, key: &str) -> Option { + self.get(key).and_then(QuoteSummaryValue::as_i64) + } + + fn get_u64(&self, key: &str) -> Option { + self.get(key).and_then(QuoteSummaryValue::as_u64) + } +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum QuoteSummaryValue { + Wrapped { raw: Option }, + Direct(YahooNumber), +} + +impl QuoteSummaryValue { + fn as_f64(&self) -> Option { + match self { + Self::Wrapped { raw } => raw.as_ref().map(YahooNumber::as_f64), + Self::Direct(value) => Some(value.as_f64()), + } + } + + fn as_i64(&self) -> Option { + match self { + Self::Wrapped { raw } => raw.as_ref().and_then(YahooNumber::as_i64), + Self::Direct(value) => value.as_i64(), + } + } + + fn as_u64(&self) -> Option { + 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 { + 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 { + 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] diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 3230425..3e27d57 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -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, + }, } 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 = 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( + 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.key, resolved)? { + return Ok(report); + } + + if offline { + return cache + .get_stale::(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::(cache_spec.key, resolved)? { + eprintln!("warning: network failed, serving stale cache for {resolved}"); + return Ok(stale); + } + Err(err) + } } } diff --git a/src/output/mod.rs b/src/output/mod.rs index 3b3e4b1..162bd9a 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -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}"), diff --git a/src/output/table.rs b/src/output/table.rs index 5641bec..4879b4f 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -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::>(), + ); + add_compare_row( + &mut table, + "Overall", + reports + .iter() + .map(|report| format_growth_signal(&report.overall_signal, no_color)) + .collect::>(), + ); + add_compare_row( + &mut table, + "Growth", + reports + .iter() + .map(|report| format_growth_signal(&report.growth.overall_signal, no_color)) + .collect::>(), + ); + add_compare_row( + &mut table, + "Valuation", + reports + .iter() + .map(|report| format_valuation_signal(&report.valuation.overall_signal, no_color)) + .collect::>(), + ); + add_compare_row( + &mut table, + "Risk", + reports + .iter() + .map(|report| format_risk_signal(&report.risk.overall_signal, no_color)) + .collect::>(), + ); + add_compare_row( + &mut table, + "P/E", + reports + .iter() + .map(|report| format_opt_f64(report.valuation.pe_trailing, 2)) + .collect::>(), + ); + add_compare_row( + &mut table, + "ROE", + reports + .iter() + .map(|report| format_pct(report.valuation.roe_pct)) + .collect::>(), + ); + add_compare_row( + &mut table, + "Revenue Growth", + reports + .iter() + .map(|report| format_pct(report.growth.revenue_growth_pct)) + .collect::>(), + ); + + println!("{table}"); + Ok(()) +} + fn format_idr_option(value: Option) -> String { value .map(|v| format_idr(v.round() as i64)) @@ -179,6 +397,16 @@ fn format_float(value: Option, precision: usize) -> String { .unwrap_or_else(|| "-".to_string()) } +fn format_opt_f64(value: Option, precision: usize) -> String { + format_float(value, precision) +} + +fn format_pct(value: Option) -> 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) { + 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}; diff --git a/tests/cli.rs b/tests/cli.rs index 510d4eb..72b7686 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,4 +1,5 @@ use std::fs; +use std::path::{Path, PathBuf}; use assert_cmd::Command; use predicates::prelude::*; @@ -7,21 +8,38 @@ fn bin() -> Command { Command::new(assert_cmd::cargo::cargo_bin!("idx-cli")) } -fn test_env_dir(name: &str) -> std::path::PathBuf { +fn test_env_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("idx-cli-it-{name}-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("create temp dir"); dir } +fn bin_with_root(root: &Path) -> Command { + let config_home = root.join("config"); + let cache_home = root.join("cache"); + fs::create_dir_all(&config_home).expect("create config dir"); + fs::create_dir_all(&cache_home).expect("create cache dir"); + + let mut cmd = bin(); + cmd.env("XDG_CONFIG_HOME", &config_home); + cmd.env("XDG_CACHE_HOME", &cache_home); + cmd +} + +fn test_bin(name: &str) -> Command { + let root = test_env_dir(name); + bin_with_root(&root) +} + #[test] fn help_works() { - bin().arg("--help").assert().success(); + test_bin("help").arg("--help").assert().success(); } #[test] fn version_prints_cargo_version() { - bin() + test_bin("version") .arg("version") .assert() .success() @@ -30,7 +48,7 @@ fn version_prints_cargo_version() { #[test] fn quote_table_with_mock_contains_expected_columns() { - bin() + test_bin("quote-table") .env("IDX_USE_MOCK_PROVIDER", "1") .env("IDX_CACHE_QUOTE_TTL", "0") .args(["stocks", "quote", "BBCA"]) @@ -43,7 +61,7 @@ fn quote_table_with_mock_contains_expected_columns() { #[test] fn quote_with_mock_provider_json() { - bin() + test_bin("quote-json") .env("IDX_USE_MOCK_PROVIDER", "1") .args(["-o", "json", "stocks", "quote", "BBCA"]) .assert() @@ -54,7 +72,7 @@ fn quote_with_mock_provider_json() { #[test] fn history_with_mock_provider_table_contains_columns() { - bin() + test_bin("history-table") .env("IDX_USE_MOCK_PROVIDER", "1") .args(["stocks", "history", "BBCA", "--period", "1mo"]) .assert() @@ -66,7 +84,7 @@ fn history_with_mock_provider_table_contains_columns() { #[test] fn technical_with_mock_provider_table_contains_expected_rows() { - bin() + test_bin("technical-table") .env("IDX_USE_MOCK_PROVIDER", "1") .args(["stocks", "technical", "BBCA"]) .assert() @@ -78,7 +96,7 @@ fn technical_with_mock_provider_table_contains_expected_rows() { #[test] fn technical_with_mock_provider_json_contains_fields() { - bin() + test_bin("technical-json") .env("IDX_USE_MOCK_PROVIDER", "1") .args(["-o", "json", "stocks", "technical", "BBCA"]) .assert() @@ -88,9 +106,77 @@ fn technical_with_mock_provider_json_contains_fields() { .stdout(predicate::str::contains("\"signals\"")); } +#[test] +fn growth_with_mock_provider_table_contains_expected_rows() { + test_bin("growth-table") + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["stocks", "growth", "BBCA"]) + .assert() + .success() + .stdout(predicate::str::contains("Growth Analysis")) + .stdout(predicate::str::contains("Revenue Growth")) + .stdout(predicate::str::contains("Overall")); +} + +#[test] +fn growth_with_mock_provider_json_contains_fields() { + test_bin("growth-json") + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["-o", "json", "stocks", "growth", "BBCA"]) + .assert() + .success() + .stdout(predicate::str::contains("\"revenue_growth\"")) + .stdout(predicate::str::contains("\"overall_signal\"")); +} + +#[test] +fn valuation_with_mock_provider_table_contains_expected_rows() { + test_bin("valuation-table") + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["stocks", "valuation", "BBCA"]) + .assert() + .success() + .stdout(predicate::str::contains("Valuation")) + .stdout(predicate::str::contains("P/E")) + .stdout(predicate::str::contains("Overall")); +} + +#[test] +fn risk_with_mock_provider_table_contains_expected_rows() { + test_bin("risk-table") + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["stocks", "risk", "BBCA"]) + .assert() + .success() + .stdout(predicate::str::contains("Risk")) + .stdout(predicate::str::contains("Debt/Equity")) + .stdout(predicate::str::contains("Overall")); +} + +#[test] +fn fundamental_with_mock_provider_table_contains_expected_rows() { + test_bin("fundamental-table") + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["stocks", "fundamental", "BBCA"]) + .assert() + .success() + .stdout(predicate::str::contains("Fundamental")) + .stdout(predicate::str::contains("Overall")); +} + +#[test] +fn compare_with_mock_provider_table_contains_resolved_symbol() { + test_bin("compare-table") + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["stocks", "compare", "BBCA,BBRI"]) + .assert() + .success() + .stdout(predicate::str::contains("BBCA.JK")); +} + #[test] fn config_path_prints_path() { - bin() + test_bin("config-path") .args(["config", "path"]) .assert() .success() @@ -102,7 +188,7 @@ fn config_init_creates_file() { let root = test_env_dir("config-init"); let config_home = root.join("cfg"); - bin() + bin_with_root(&root) .env("XDG_CONFIG_HOME", &config_home) .args(["config", "init"]) .assert() @@ -116,13 +202,13 @@ fn cache_info_and_clear_do_not_crash() { let root = test_env_dir("cache"); let cache_home = root.join("cache"); - bin() + bin_with_root(&root) .env("XDG_CACHE_HOME", &cache_home) .args(["cache", "info"]) .assert() .success(); - bin() + bin_with_root(&root) .env("XDG_CACHE_HOME", &cache_home) .args(["cache", "clear"]) .assert() @@ -134,7 +220,7 @@ fn serves_stale_cache_on_provider_failure_with_warning() { let root = test_env_dir("stale"); let cache_home = root.join("cache"); - bin() + bin_with_root(&root) .env("XDG_CACHE_HOME", &cache_home) .env("IDX_USE_MOCK_PROVIDER", "1") .env("IDX_CACHE_QUOTE_TTL", "0") @@ -142,7 +228,7 @@ fn serves_stale_cache_on_provider_failure_with_warning() { .assert() .success(); - bin() + bin_with_root(&root) .env("XDG_CACHE_HOME", &cache_home) .env("IDX_USE_MOCK_PROVIDER", "1") .env("IDX_CACHE_QUOTE_TTL", "0") @@ -158,7 +244,7 @@ fn technical_serves_stale_cache_on_provider_failure_with_warning() { let root = test_env_dir("technical-stale"); let cache_home = root.join("cache"); - bin() + bin_with_root(&root) .env("XDG_CACHE_HOME", &cache_home) .env("IDX_USE_MOCK_PROVIDER", "1") .env("IDX_CACHE_QUOTE_TTL", "0") @@ -166,7 +252,7 @@ fn technical_serves_stale_cache_on_provider_failure_with_warning() { .assert() .success(); - bin() + bin_with_root(&root) .env("XDG_CACHE_HOME", &cache_home) .env("IDX_USE_MOCK_PROVIDER", "1") .env("IDX_CACHE_QUOTE_TTL", "0") @@ -179,7 +265,7 @@ fn technical_serves_stale_cache_on_provider_failure_with_warning() { #[test] fn invalid_symbol_returns_non_zero() { - bin() + test_bin("invalid-symbol") .env("IDX_USE_MOCK_PROVIDER", "1") .env("IDX_MOCK_ERROR", "1") .args(["stocks", "quote", "INVALID"]) diff --git a/tests/fixtures/quotesummary_bbca.json b/tests/fixtures/quotesummary_bbca.json new file mode 100644 index 0000000..14f0a14 --- /dev/null +++ b/tests/fixtures/quotesummary_bbca.json @@ -0,0 +1,82 @@ +{ + "quoteSummary": { + "result": [ + { + "defaultKeyStatistics": { + "forwardPE": { + "raw": 23.1, + "fmt": "23.10" + }, + "priceToBook": { + "raw": 4.6, + "fmt": "4.60" + }, + "enterpriseValue": { + "raw": 1245000000000000, + "fmt": "1.25T" + }, + "trailingEps": { + "raw": 384.25, + "fmt": "384.25" + }, + "forwardEps": { + "raw": 410.18, + "fmt": "410.18" + } + }, + "financialData": { + "trailingPE": { + "raw": 25.4, + "fmt": "25.40" + }, + "marketCap": { + "raw": 1215200000000000, + "fmt": "1.22T" + }, + "currentPrice": { + "raw": 9875, + "fmt": "9,875.00" + }, + "returnOnEquity": { + "raw": 0.202, + "fmt": "20.20%" + }, + "returnOnAssets": { + "raw": 0.038, + "fmt": "3.80%" + }, + "profitMargins": { + "raw": 0.385, + "fmt": "38.50%" + }, + "revenueGrowth": { + "raw": 0.118, + "fmt": "11.80%" + }, + "earningsGrowth": { + "raw": 0.121, + "fmt": "12.10%" + }, + "debtToEquity": { + "raw": 18.5, + "fmt": "18.50" + }, + "currentRatio": { + "raw": 1.21, + "fmt": "1.21" + }, + "ebitda": { + "raw": 58500000000000, + "fmt": "58.50B" + }, + "totalRevenue": { + "raw": 147900000000000, + "fmt": "147.90B" + } + }, + "incomeStatementHistory": {} + } + ], + "error": null + } +}