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

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,