fix(analysis): ratings need evidence; negative equity is high risk

From the full-repo Codex audit:
- The overall fundamental rating was "healthy" whenever growth was
  growing and valuation and risk were merely not "expensive" / not "high
  risk", so with no valuation or risk data at all (partial MSN
  fundamentals) it still read "healthy". It now needs valuation
  undervalued or fairly valued and risk low or moderate.
- Risk recognized only heavy leverage or a weak current ratio as high
  risk; negative equity fell through to "moderate risk", and a known
  adverse signal with the other metric missing became "incomplete data".
  Any known adverse signal now makes it high risk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-17 04:48:10 +00:00
commit 49b2d3aaba
2 changed files with 44 additions and 6 deletions

View file

@ -148,12 +148,16 @@ 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" {
// A known adverse signal decides on its own: negative equity or heavy
// leverage is high risk even when the current ratio is missing.
let overall_signal = if matches!(de_signal, "highly leveraged" | "negative equity")
|| current_ratio_signal == "weak"
{
"high risk"
} else 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 {
@ -176,9 +180,14 @@ pub fn analyze_fundamental(symbol: &str, fundamentals: &Fundamentals) -> Fundame
let valuation = analyze_valuation(fundamentals);
let risk = analyze_risk(fundamentals);
// "healthy" needs evidence on all three: missing valuation or risk data
// must not pass for "not expensive" or "not risky".
let overall_signal = if growth.overall_signal == "growing"
&& valuation.overall_signal != "expensive"
&& risk.overall_signal != "high risk"
&& matches!(
valuation.overall_signal.as_str(),
"undervalued" | "fairly valued"
)
&& matches!(risk.overall_signal.as_str(), "low risk" | "moderate risk")
{
"healthy"
} else if growth.overall_signal == "shrinking" && risk.overall_signal == "high risk" {
@ -375,6 +384,35 @@ mod tests {
}
}
#[test]
fn missing_valuation_and_risk_data_is_not_healthy() {
// Growth alone (12% revenue, 22% earnings) used to rate "healthy".
let fundamentals = Fundamentals {
revenue_growth: Some(0.12),
earnings_growth: Some(0.22),
..Fundamentals::default()
};
let report = analyze_fundamental("X.JK", &fundamentals);
assert_eq!(report.growth.overall_signal, "growing");
assert_eq!(report.overall_signal, "mixed");
}
#[test]
fn negative_equity_is_high_risk_and_not_healthy() {
let mut fundamentals = sample_fundamentals();
fundamentals.debt_to_equity = Some(-10.0);
assert_eq!(analyze_risk(&fundamentals).overall_signal, "high risk");
assert_eq!(
analyze_fundamental("X.JK", &fundamentals).overall_signal,
"mixed"
);
// Known heavy leverage decides even with the current ratio missing.
fundamentals.debt_to_equity = Some(250.0);
fundamentals.current_ratio = None;
assert_eq!(analyze_risk(&fundamentals).overall_signal, "high risk");
}
#[test]
fn growth_signal_thresholds_match_python() {
assert_eq!(growth_signal(None), "no data");

View file

@ -74,7 +74,7 @@ pub struct Ohlc {
}
/// Fundamental metrics normalized from Yahoo Finance `/v10/finance/quoteSummary`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Fundamentals {
pub trailing_pe: Option<f64>,
pub forward_pe: Option<f64>,