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:
Ciphercat 2026-03-06 05:38:21 +00:00
commit 0d5b4b4755
10 changed files with 1459 additions and 32 deletions

468
src/analysis/fundamental.rs Normal file
View 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");
}
}

View file

@ -1,2 +1,3 @@
pub mod fundamental;
pub mod signals;
pub mod technical;

View file

@ -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", &quote_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,

View file

@ -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 {

View file

@ -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, &quote_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, &quote_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", &quote_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]

View file

@ -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)
}
}
}

View file

@ -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}"),

View file

@ -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};

View file

@ -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"])

82
tests/fixtures/quotesummary_bbca.json vendored Normal file
View file

@ -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
}
}