From f0664b0117356f35c241558299bb5322c97d63b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 18:10:48 +0000 Subject: [PATCH] feat(ui): Loss Chance % heuristic chip (PR 4e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 UX feature #2 of the trio (after recommendation-badge / PR 4d). Adds a deterministic 5-95% "Loss Chance" chip to every stock — answers "if I buy at the current price, what's my heuristic chance of loss?" Locked decisions (per phase-4-kickoff-checklist/PLAN.md §1): - Terminology: Option D — "Loss Chance %" + small italic "heuristic" qualifier on detail page; tooltip qualifier on ranking row - Display: after MoS bar on overview table + detail header card - 5-band color: outlined-light pattern per frontend-design-system Rule 2 (emerald-50/-50/slate-100/red-50/-50, NO `dark:` variants — lesson PR #70) - Schema additive: `loss_chance_pct: float | None` on StockSummary + StockDetail Calibration (locked 2026-05-14 post-simulation on live S&P 500): The PLAN's draft weights (baseline 50, mos_scale 0.4, dq=20, altman=15) yielded a universe-median LC of 64 with bands skewed Sell-side. Locked constants: BASELINE_PCT 40 (not 50) MOS_SCALE 0.35 (asymmetric caps) MOS_NEG_CAP 35 (positive MoS push-down) MOS_POS_CAP 20 (negative MoS push-up) COMPOSITE_SCALE 2.0 (each 10 composite-pts → 5 pp) DATA_QUALITY_PENALTY 15 (was 20) ALTMAN_PENALTY 12 (was 15) GOING_CONCERN_PENALTY 8 (was 10) SLOAN_NSI_PENALTY 3 (was 5, per flag) BENEISH_DECHOW_PENALTY 3 (was 5) Predicted distribution on commit eb861e12 production data: Universe median: 49 (target ~50 ✅) <25%: 7.6% (target ~10%) 25-40%: 20.9% (target ~20%) 40-60%: 43.8% (target ~30%) 60-80%: 26.3% (target ~25%) >80%: 1.4% (target ~15% — S&P 500 mostly fully-priced) Per-recommendation tier median: Bullish: 24 (target 10-25 ✅) Lean Bullish: 42 (target 25-40, slightly above) Neutral: 56 (target 40-60 ✅) Cautious: 62 (target 60-90 ✅) Files (+450 LOC): - compute/scoring/loss_chance.py (new) — pure heuristic combiner, ~13 exported constants for downstream tuning, [5, 95] honest clip - compute/output/schemas.py — `loss_chance_pct: float | None` on both StockSummary + StockDetail (additive, None for legacy data) - compute/main.py — wire derive_loss_chance() in per-ticker loop after derive_recommendation() - tests/test_scoring/test_loss_chance.py (new, 21 tests) — covers 5-band gradient, MoS asymmetric caps, clipping, flag additivity, input shape tolerance, tier-aligned expectations. Threshold-relative so future tuning doesn't break tests - frontend/lib/types.ts — mirror loss_chance_pct field - frontend/lib/schema-snapshot.json — regenerated - frontend/components/LossChanceBadge.tsx (new) — 5-band outlined-light badge, 3 sizes (xs/sm/md), optional italic "heuristic" qualifier for detail-page contexts, em-dash placeholder when MoS missing - frontend/components/RankingTable.tsx — new "Loss Chance" column after MoS on desktop table; new row after MoS-and-fair-price on mobile card - frontend/app/stock/[ticker]/page.tsx — new metric column in header card next to MoS, with showQualifier (small italic "heuristic" text) - .claude/skills/phase-4/loss-chance/PLAN.md — marked ✅ Implemented; documented calibration delta vs draft rubric No new third-party deps. No external API surface change beyond the single additive Pydantic / TypeScript field. Verification ladder: - ruff check . → All passed - python -m pytest tests/ -m "not network" → 772 passed (was 751 + 21 new) - python -m compute.output.schema_check → in sync - npx tsc --noEmit → clean - npx next build → clean (502 SSG pages) Tag target on merge: v1.1.0-rc2 per v1-to-v1-1-migration/PLAN.md sequencing. Next: PR 4f (price-chart-enhancements). https://claude.ai/code/session_015649aRyi2bvciQYZVNACd2 --- .claude/skills/phase-4/loss-chance/PLAN.md | 18 +- compute/main.py | 15 + compute/output/schemas.py | 2 + compute/scoring/loss_chance.py | 180 ++++++++++++ frontend/app/stock/[ticker]/page.tsx | 13 + frontend/components/LossChanceBadge.tsx | 119 ++++++++ frontend/components/RankingTable.tsx | 11 + frontend/lib/schema-snapshot.json | 10 + frontend/lib/types.ts | 6 + tests/test_scoring/test_loss_chance.py | 304 +++++++++++++++++++++ 10 files changed, 674 insertions(+), 4 deletions(-) create mode 100644 compute/scoring/loss_chance.py create mode 100644 frontend/components/LossChanceBadge.tsx create mode 100644 tests/test_scoring/test_loss_chance.py diff --git a/.claude/skills/phase-4/loss-chance/PLAN.md b/.claude/skills/phase-4/loss-chance/PLAN.md index 4fa35d264..4c7598daf 100644 --- a/.claude/skills/phase-4/loss-chance/PLAN.md +++ b/.claude/skills/phase-4/loss-chance/PLAN.md @@ -1,9 +1,19 @@ # Loss Chance % (Phase 4 planning stub) -**Status**: Planning. Not yet a loaded skill — promote to top-level -`.claude/skills/loss-chance/SKILL.md` when implementation begins. -Companion stub to `recommendation-badge/PLAN.md` and -`price-chart-enhancements/PLAN.md`. +**Status**: ✅ Implemented in PR 4e (`feat/phase-4e-loss-chance`). +Final calibration differs from the draft rubric in this PLAN — see +`compute/scoring/loss_chance.py` for the locked constants: + +- Baseline 40 (not 50 — universe MoS median is −25%, baseline 50 + pushed universe median to ~64) +- MoS scale 0.35 asymmetric (cap_neg=35 for undervalued push-down, + cap_pos=20 for overvalued push-up) +- Composite scale 2.0 (each 10 composite-points = 5 pp loss change) +- Flag penalties proportionally lower (dq=15, altman=12, gc=8) + +Predicted distribution on commit eb861e12 production data: +SB 7.6% / Buy 20.9% / Hold 43.8% / Sell-light 26.3% / Sell-strong 1.4% +Universe median 49 (target ~50 ✅). ## Spec (user request, 2026-05-14) diff --git a/compute/main.py b/compute/main.py index e5691dcc1..bdc397386 100644 --- a/compute/main.py +++ b/compute/main.py @@ -76,6 +76,7 @@ neutralize_pillar_scores, ) from compute.scoring.dechow_f import compute_dechow_f +from compute.scoring.loss_chance import derive_loss_chance from compute.scoring.pillars import TickerInputs, compute_all_pillars from compute.scoring.recommendation import derive_recommendation from compute.scoring.risk_overlay import compute_risk_flags @@ -984,6 +985,18 @@ def run_weekly_compute() -> int: mos_pct=ensemble.mos_pct if ensemble is not None else None, ) + # PR 4e — Loss Chance % heuristic (Option D locked: "Loss Chance %" + # label + small italic "heuristic" qualifier in the UI). Pure + # combiner over composite + risk_flags + valuation_warnings + MoS. + # Returns None when MoS unavailable (no ensemble) — frontend + # renders em-dash placeholder. See `compute/scoring/loss_chance.py`. + loss_chance_pct = derive_loss_chance( + composite_score=float(r["composite_score"]), + risk_flags=risk_flags.get(ticker, []), + valuation_warnings=valuation_warnings, + mos_pct=ensemble.mos_pct if ensemble is not None else None, + ) + summaries.append( StockSummary( rank=int(r["rank"]), @@ -999,6 +1012,7 @@ def run_weekly_compute() -> int: risk_flags=risk_flags.get(ticker, []), valuation_warnings=valuation_warnings, recommendation=recommendation, + loss_chance_pct=loss_chance_pct, entered_top5=ticker in entered, exited_top5=ticker in exited, ) @@ -1030,6 +1044,7 @@ def run_weekly_compute() -> int: beneish_m_score=beneish_result.m_score, dechow_f_score=dechow_result.f_score, recommendation=recommendation, + loss_chance_pct=loss_chance_pct, entered_top5=ticker in entered, exited_top5=ticker in exited, ) diff --git a/compute/output/schemas.py b/compute/output/schemas.py index 3aab9a48f..41ef2d5c3 100644 --- a/compute/output/schemas.py +++ b/compute/output/schemas.py @@ -69,6 +69,7 @@ class StockSummary(BaseModel): risk_flags: list[str] = Field(default_factory=list) valuation_warnings: list[str] = Field(default_factory=list) recommendation: Recommendation | None = None + loss_chance_pct: float | None = None entered_top5: bool = False exited_top5: bool = False @@ -150,5 +151,6 @@ class StockDetail(BaseModel): beneish_m_score: float | None = None dechow_f_score: float | None = None recommendation: Recommendation | None = None + loss_chance_pct: float | None = None entered_top5: bool = False exited_top5: bool = False diff --git a/compute/scoring/loss_chance.py b/compute/scoring/loss_chance.py new file mode 100644 index 000000000..52a82d258 --- /dev/null +++ b/compute/scoring/loss_chance.py @@ -0,0 +1,180 @@ +"""Loss Chance % heuristic derivation (PR 4e, Phase 4 UX trio §2). + +Combines the already-computed composite score, risk-overlay flags, +valuation warnings, and Margin of Safety into a single 5-95% chip that +answers "if I buy this at the current price, what's my heuristic chance +of loss?" **No new modeling, no ML, no backtest** — purely a tunable +linear combiner over existing outputs. + +Per the locked decision in `.claude/skills/phase-4/loss-chance/PLAN.md` ++ `phase-4-kickoff-checklist/PLAN.md` §1 (Option D, 2026-05-14): use +the literal "Loss Chance %" label + small italic "heuristic" +qualifier. Internal value is just a 0-100 number with [5, 95] clipping. + +Calibration (locked 2026-05-14 post-simulation against live S&P 500): +- Baseline 40 (NOT 50) — S&P 500 has median MoS −25% so a symmetric + 50% baseline pushes the universe median up to ~60-64. Baseline 40 + shifts the universe median to ~49, on target. +- Asymmetric MoS contribution: undervalued (positive MoS) gets a + stronger push DOWN (cap 35 pp) than overvalued gets push UP (cap 20 + pp). Captures the rare-deep-value vs common-moderate-overvalue + shape of the universe. +- Composite signal stronger (`/2.0` vs PLAN's `/5`) so top-composite + stocks land in the green band even when MoS is moderate. +- Flag penalties proportionally lower than PLAN draft (`dq` 20→15, + `altman` 15→12, `gc` 10→8) so the universe median doesn't drift + above 50 from flag stacking alone. + +The threshold knobs are exposed as module constants; downstream +re-tuning happens by editing them, not by rewriting the rubric. + +⚠️ This is a **heuristic**, not a backtested probability. The +LossChanceBadge component must render a small italic "heuristic" +qualifier next to the number. The global Disclaimer banner on every +page covers the legal posture. For a backtest-calibrated interval, +see Phase 5+ Triple-Barrier + Conformal Prediction work. +""" + +from __future__ import annotations + +# Baseline starts BELOW 50 because S&P 500 median MoS is negative +# (universe median MoS −25% in 2026-05-14 production data); a 50 +# baseline drifts universe median up to ~60. Baseline 40 + the +# MoS / composite shifters below land the universe median ≈ 49. +BASELINE_PCT: float = 40.0 + +# MoS contribution shape: +# - mos_signal = -mos_pct * MOS_SCALE +# - clipped to [-MOS_NEG_CAP, +MOS_POS_CAP] +# Positive MoS (stock undervalued) → mos_signal negative → push LC down. +# Negative MoS (overvalued) → mos_signal positive → push LC up. +# Asymmetric caps reflect that S&P 500's left tail (deep overvalue) +# is heavier than its right tail (deep undervalue) — we don't want +# every overvalued stock pinned at 95. +MOS_SCALE: float = 0.35 +MOS_NEG_CAP: float = 35.0 # max push DOWN from MoS (undervalued stocks) +MOS_POS_CAP: float = 20.0 # max push UP from MoS (overvalued stocks) + +# Composite contribution: (50 - composite) / COMPOSITE_SCALE. +# composite=70 → -10 pp; composite=30 → +10 pp. +COMPOSITE_SCALE: float = 2.0 + +# Risk-flag penalties — heaviest weight to broken-input vetoes since +# they invalidate the entire downstream signal stack. +DATA_QUALITY_PENALTY: float = 15.0 +ALTMAN_PENALTY: float = 12.0 +GOING_CONCERN_PENALTY: float = 8.0 +SLOAN_NSI_PENALTY: float = 3.0 # applied per flag (Sloan + NSI both fire = +6) + +# Valuation-warning penalties — lighter touch. +BENEISH_DECHOW_PENALTY: float = 3.0 # per flag +VALUE_TRAP_PENALTY: float = 2.0 +GOODWILL_HEAVY_PENALTY: float = 1.0 +STALE_FILING_SOFT_PENALTY: float = 2.0 +EXTREME_ESTIMATE_PER_FLAG: float = 1.0 # per `extreme_*_estimate` flag +EXTREME_ESTIMATE_CAP: float = 5.0 # max cumulative + +# Honest 5-95% clip — the chip surface should never claim 0% or 100% +# certainty because a heuristic combiner can't justify either. +MIN_PCT: float = 5.0 +MAX_PCT: float = 95.0 + + +def derive_loss_chance( + *, + composite_score: float | None, + risk_flags: list[str] | tuple[str, ...] | set[str] | frozenset[str] | None, + valuation_warnings: list[str] | tuple[str, ...] | set[str] | frozenset[str] | None, + mos_pct: float | None, +) -> float | None: + """Return a heuristic Loss Chance % in [MIN_PCT, MAX_PCT], or None + when MoS is missing (the dominant signal — without it we don't have + enough information to fake a probability). + + Parameters mirror the StockSummary / StockDetail fields rather than + taking the full Pydantic object — keeps the function pure-data and + trivially unit-testable. + + Returns + ------- + float | None + Loss Chance percentage, clipped to [MIN_PCT, MAX_PCT]. None + propagates when ``mos_pct`` is None — the badge component + renders an em-dash placeholder in that case. + + Notes + ----- + The function is **pure**: same inputs always yield the same + output. No reads from disk, env, or global mutable state. + """ + if mos_pct is None: + return None + + base = BASELINE_PCT + + # MoS contribution (asymmetric caps). + mos_signal = -mos_pct * MOS_SCALE + if mos_signal < 0: + mos_signal = max(-MOS_NEG_CAP, mos_signal) + else: + mos_signal = min(MOS_POS_CAP, mos_signal) + base += mos_signal + + # Composite contribution. + if composite_score is not None: + base += (50.0 - composite_score) / COMPOSITE_SCALE + + # Risk-flag penalties. + rf: set[str] = set(risk_flags or ()) + if "data_quality_input_corruption" in rf: + base += DATA_QUALITY_PENALTY + if "altman_distress" in rf: + base += ALTMAN_PENALTY + if "going_concern_disclosure" in rf: + base += GOING_CONCERN_PENALTY + if "sloan_accruals_top_decile" in rf: + base += SLOAN_NSI_PENALTY + if "net_issuance_top_decile" in rf: + base += SLOAN_NSI_PENALTY + + # Valuation-warning penalties. + vw: set[str] = set(valuation_warnings or ()) + if "beneish_high" in vw: + base += BENEISH_DECHOW_PENALTY + if "dechow_high" in vw: + base += BENEISH_DECHOW_PENALTY + if "value_trap_risk" in vw: + base += VALUE_TRAP_PENALTY + if "goodwill_heavy" in vw: + base += GOODWILL_HEAVY_PENALTY + if "stale_filing_soft" in vw: + base += STALE_FILING_SOFT_PENALTY + extreme_count = sum( + 1 for w in vw if w.startswith("extreme_") and w.endswith("_estimate") + ) + base += min(EXTREME_ESTIMATE_CAP, extreme_count * EXTREME_ESTIMATE_PER_FLAG) + + # Honest clip. + return max(MIN_PCT, min(MAX_PCT, base)) + + +__all__ = [ + "ALTMAN_PENALTY", + "BASELINE_PCT", + "BENEISH_DECHOW_PENALTY", + "COMPOSITE_SCALE", + "DATA_QUALITY_PENALTY", + "EXTREME_ESTIMATE_CAP", + "EXTREME_ESTIMATE_PER_FLAG", + "GOING_CONCERN_PENALTY", + "GOODWILL_HEAVY_PENALTY", + "MAX_PCT", + "MIN_PCT", + "MOS_NEG_CAP", + "MOS_POS_CAP", + "MOS_SCALE", + "SLOAN_NSI_PENALTY", + "STALE_FILING_SOFT_PENALTY", + "VALUE_TRAP_PENALTY", + "derive_loss_chance", +] diff --git a/frontend/app/stock/[ticker]/page.tsx b/frontend/app/stock/[ticker]/page.tsx index bd1fc3000..4bb526ccd 100644 --- a/frontend/app/stock/[ticker]/page.tsx +++ b/frontend/app/stock/[ticker]/page.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import FairPriceCard from '@/components/FairPriceCard'; import { FairPriceBarChart } from '@/components/FairPriceBarChart'; +import { LossChanceBadge } from '@/components/LossChanceBadge'; import { MoSCell } from '@/components/MoSCell'; import { PillarRadarChart } from '@/components/PillarRadarChart'; import { PriceHistoryChart } from '@/components/PriceHistoryChart'; @@ -122,6 +123,18 @@ export default function StockDetailPage({ +
+ + Loss chance + +
+ +
+
diff --git a/frontend/components/LossChanceBadge.tsx b/frontend/components/LossChanceBadge.tsx new file mode 100644 index 000000000..998a62c0e --- /dev/null +++ b/frontend/components/LossChanceBadge.tsx @@ -0,0 +1,119 @@ +'use client'; + +// Loss Chance % chip (PR 4e, Phase 4 UX trio §2). +// +// 5-band color gradient — outlined-light pattern matching SectorChip / +// RecommendationBadge / score-tier / MoS-bucket chips. NO `dark:` +// variants (the site is force-light via `globals.css :root +// color-scheme: light`; Tailwind dark: triggers on SYSTEM +// `prefers-color-scheme: dark` and would make text vanish — lesson +// from PR #70). See `.claude/skills/frontend-design-system/SKILL.md` +// Rules 2 + 4. +// +// Display: "NN%" with optional italic "heuristic" qualifier per +// Option D locked in `phase-4-kickoff-checklist/PLAN.md` §1. The +// global Disclaimer banner covers the legal posture — no per-badge +// popover required. + +type Band = { + max: number; // strictly less than `max` puts the value in this band + cls: string; // chip tone — outlined-light, light mode only + dot: string; // small leading colored dot + label: string; // accessibility / tooltip context +}; + +// Strong-end uses text-{tone}-900 (~10:1 contrast vs bg-50) for the +// high-stakes "low loss / high loss" ends; middle bands use -700 for +// the visual hierarchy ramp. +const BANDS: readonly Band[] = [ + { + max: 25, + cls: 'bg-emerald-50 text-emerald-900 ring-emerald-300', + dot: 'bg-emerald-700', + label: 'Low loss chance', + }, + { + max: 40, + cls: 'bg-emerald-50 text-emerald-700 ring-emerald-200', + dot: 'bg-emerald-500', + label: 'Moderate-low loss chance', + }, + { + max: 60, + cls: 'bg-slate-100 text-slate-700 ring-slate-300', + dot: 'bg-slate-500', + label: 'Neutral loss chance', + }, + { + max: 80, + cls: 'bg-red-50 text-red-700 ring-red-200', + dot: 'bg-red-500', + label: 'Moderate-high loss chance', + }, + { + max: 100, + cls: 'bg-red-50 text-red-900 ring-red-300', + dot: 'bg-red-600', + label: 'High loss chance', + }, +]; + +const SIZE_CLASSES: Record<'xs' | 'sm' | 'md', string> = { + xs: 'px-1.5 py-0 text-[10px]', + sm: 'px-2 py-0.5 text-xs', + md: 'px-2.5 py-0.5 text-sm', +}; + +function bandFor(pct: number): Band { + for (const b of BANDS) { + if (pct < b.max) return b; + } + // pct === 100 (impossible given clip, but safe fallback) + return BANDS[BANDS.length - 1]; +} + +export function LossChanceBadge({ + lossChancePct, + size = 'sm', + showQualifier = false, + className = '', +}: { + lossChancePct: number | null; + size?: 'xs' | 'sm' | 'md'; + // When `true` (detail-page contexts), renders a small italic + // "heuristic" suffix next to the percentage. Off by default for + // table rows where horizontal space is tight; the tooltip carries + // the qualifier instead. + showQualifier?: boolean; + className?: string; +}) { + // Missing MoS → em-dash placeholder. Matches existing MoSCell + // missing-input UI. + if (lossChancePct === null || lossChancePct === undefined) { + return ( + + — + + ); + } + const band = bandFor(lossChancePct); + const sizeCls = SIZE_CLASSES[size]; + const rounded = Math.round(lossChancePct); + return ( + + + ); +} diff --git a/frontend/components/RankingTable.tsx b/frontend/components/RankingTable.tsx index 6e3b0e051..aebf67afa 100644 --- a/frontend/components/RankingTable.tsx +++ b/frontend/components/RankingTable.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { useEffect, useMemo, useRef, useState } from 'react'; import { FilterDrawer } from '@/components/FilterDrawer'; +import { LossChanceBadge } from '@/components/LossChanceBadge'; import { MoSCell } from '@/components/MoSCell'; import { RECOMMENDATION_CHIP_DOTS, @@ -403,6 +404,9 @@ export default function RankingTable({ data }: { data: StockSummary[] }) { {headerCell('current_price', 'Price', 'text-right')} {headerCell('fair_price', 'Fair price', 'text-right')} {headerCell('margin_of_safety_pct', 'MoS', 'text-right')} + + Loss Chance + @@ -450,6 +454,9 @@ export default function RankingTable({ data }: { data: StockSummary[] }) { )} + + + ); })} @@ -513,6 +520,10 @@ export default function RankingTable({ data }: { data: StockSummary[] }) { )} +
+ Loss Chance + +
{mos.tooltip && {mos.tooltip}} diff --git a/frontend/lib/schema-snapshot.json b/frontend/lib/schema-snapshot.json index 00b0e2c0d..c04e6c86d 100644 --- a/frontend/lib/schema-snapshot.json +++ b/frontend/lib/schema-snapshot.json @@ -280,6 +280,11 @@ "required": false, "default": null }, + "loss_chance_pct": { + "type": "float | None", + "required": false, + "default": null + }, "market_cap": { "type": "float | None", "required": false, @@ -382,6 +387,11 @@ "required": false, "default": null }, + "loss_chance_pct": { + "type": "float | None", + "required": false, + "default": null + }, "margin_of_safety_pct": { "type": "float | None", "required": false, diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 93c9ae9ea..e5b55b06a 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -33,6 +33,11 @@ export type StockSummary = { risk_flags: string[]; valuation_warnings: string[]; recommendation: Recommendation | null; + // Loss Chance % heuristic chip — 5-95 clipped, null when MoS missing. + // See `compute/scoring/loss_chance.py` for the rubric. Display via + // `LossChanceBadge` with small italic "heuristic" qualifier (Option D + // locked per `phase-4-kickoff-checklist/PLAN.md` §1). + loss_chance_pct: number | null; entered_top5: boolean; exited_top5: boolean; }; @@ -187,6 +192,7 @@ export type StockDetail = { beneish_m_score: number | null; dechow_f_score: number | null; recommendation: Recommendation | null; + loss_chance_pct: number | null; entered_top5: boolean; exited_top5: boolean; }; diff --git a/tests/test_scoring/test_loss_chance.py b/tests/test_scoring/test_loss_chance.py new file mode 100644 index 000000000..5ede36fa0 --- /dev/null +++ b/tests/test_scoring/test_loss_chance.py @@ -0,0 +1,304 @@ +"""Tests for compute.scoring.loss_chance (PR 4e). + +Threshold-relative — uses the module's exposed constants so future +re-calibration doesn't silently break tests. Same pattern PR 4d +adopted after its first iteration broke 5 tests on calibration change. + +Coverage: +- 5-band gradient triggered by canonical inputs +- MoS=None returns None (dominant signal missing) +- Composite=None falls back gracefully +- Clipping floor (MIN_PCT) + ceiling (MAX_PCT) +- Flag penalties stack additively +- Tolerant input shapes (None, set, list, tuple, frozenset) +""" + +from __future__ import annotations + +import pytest + +from compute.scoring.loss_chance import ( + ALTMAN_PENALTY, + BASELINE_PCT, + DATA_QUALITY_PENALTY, + MAX_PCT, + MIN_PCT, + MOS_NEG_CAP, + MOS_POS_CAP, + MOS_SCALE, + derive_loss_chance, +) + +# -- Missing inputs ---------------------------------------------------------- + +def test_returns_none_when_mos_missing(): + """MoS is the dominant signal — without it we don't fake a probability.""" + assert ( + derive_loss_chance( + composite_score=70.0, + risk_flags=[], + valuation_warnings=[], + mos_pct=None, + ) + is None + ) + + +def test_composite_none_does_not_break(): + """Composite=None should skip the composite contribution but still + return a value (MoS alone suffices).""" + result = derive_loss_chance( + composite_score=None, + risk_flags=[], + valuation_warnings=[], + mos_pct=0.0, + ) + assert result is not None + assert MIN_PCT <= result <= MAX_PCT + + +# -- Band gradient ----------------------------------------------------------- + +def test_deep_undervalue_clean_lands_in_low_band(): + """Composite 80 + clean + MoS +50% should land in the <25 band.""" + result = derive_loss_chance( + composite_score=80.0, + risk_flags=[], + valuation_warnings=[], + mos_pct=50.0, + ) + assert result is not None + assert result < 25.0 + + +def test_neutral_inputs_land_near_baseline(): + """Composite 50 + clean + MoS 0% sits at baseline (no shifts).""" + result = derive_loss_chance( + composite_score=50.0, + risk_flags=[], + valuation_warnings=[], + mos_pct=0.0, + ) + assert result == BASELINE_PCT + + +def test_deep_overvalue_with_flags_lands_in_high_band(): + """Composite 30 + altman_distress + MoS −80% lands in the >80 band.""" + result = derive_loss_chance( + composite_score=30.0, + risk_flags=["altman_distress"], + valuation_warnings=[], + mos_pct=-80.0, + ) + assert result is not None + assert result > 80.0 + + +# -- Clipping ---------------------------------------------------------------- + +def test_clips_to_floor_on_extreme_undervalue(): + """Extreme positive MoS + top composite + no flags doesn't go below MIN_PCT.""" + result = derive_loss_chance( + composite_score=100.0, + risk_flags=[], + valuation_warnings=[], + mos_pct=500.0, # absurd MoS — should saturate via MOS_NEG_CAP + ) + assert result == MIN_PCT + + +def test_clips_to_ceiling_on_extreme_overvalue_with_all_flags(): + """Deep negative MoS + every flag stacked → ceiling at MAX_PCT.""" + result = derive_loss_chance( + composite_score=0.0, + risk_flags=[ + "data_quality_input_corruption", + "altman_distress", + "going_concern_disclosure", + "sloan_accruals_top_decile", + "net_issuance_top_decile", + ], + valuation_warnings=[ + "beneish_high", + "dechow_high", + "value_trap_risk", + "goodwill_heavy", + "stale_filing_soft", + "extreme_graham_estimate", + "extreme_rim_estimate", + "extreme_dcf_estimate", + "extreme_multiples_pe_estimate", + "extreme_multiples_pb_estimate", + ], + mos_pct=-500.0, + ) + assert result == MAX_PCT + + +# -- MoS scaling ------------------------------------------------------------- + +def test_positive_mos_pushes_lc_down(): + """Positive MoS (undervalued) reduces loss chance vs baseline.""" + neutral = derive_loss_chance( + composite_score=50.0, risk_flags=[], valuation_warnings=[], mos_pct=0.0 + ) + undervalued = derive_loss_chance( + composite_score=50.0, risk_flags=[], valuation_warnings=[], mos_pct=30.0 + ) + assert undervalued < neutral + + +def test_negative_mos_pushes_lc_up(): + """Negative MoS (overvalued) raises loss chance vs baseline.""" + neutral = derive_loss_chance( + composite_score=50.0, risk_flags=[], valuation_warnings=[], mos_pct=0.0 + ) + overvalued = derive_loss_chance( + composite_score=50.0, risk_flags=[], valuation_warnings=[], mos_pct=-30.0 + ) + assert overvalued > neutral + + +def test_mos_asymmetric_caps(): + """Positive MoS can push down up to MOS_NEG_CAP; negative MoS can push + up to MOS_POS_CAP. Verify both via threshold-relative math.""" + # Positive MoS large enough to saturate: contribution = +MOS_NEG_CAP + # pushed onto baseline = BASELINE_PCT - MOS_NEG_CAP. + pos_saturated = derive_loss_chance( + composite_score=50.0, + risk_flags=[], + valuation_warnings=[], + mos_pct=MOS_NEG_CAP / MOS_SCALE + 100.0, # well past saturation + ) + assert pos_saturated == max(MIN_PCT, BASELINE_PCT - MOS_NEG_CAP) + + # Negative MoS large enough to saturate the positive cap. + neg_saturated = derive_loss_chance( + composite_score=50.0, + risk_flags=[], + valuation_warnings=[], + mos_pct=-(MOS_POS_CAP / MOS_SCALE + 100.0), + ) + assert neg_saturated == min(MAX_PCT, BASELINE_PCT + MOS_POS_CAP) + + +# -- Flag penalty additivity ------------------------------------------------- + +def test_data_quality_flag_adds_dq_penalty(): + """Cleanly isolating one flag — penalty is exactly DATA_QUALITY_PENALTY.""" + clean = derive_loss_chance( + composite_score=50.0, risk_flags=[], valuation_warnings=[], mos_pct=0.0 + ) + flagged = derive_loss_chance( + composite_score=50.0, + risk_flags=["data_quality_input_corruption"], + valuation_warnings=[], + mos_pct=0.0, + ) + assert flagged == min(MAX_PCT, clean + DATA_QUALITY_PENALTY) + + +def test_altman_flag_adds_altman_penalty(): + clean = derive_loss_chance( + composite_score=50.0, risk_flags=[], valuation_warnings=[], mos_pct=0.0 + ) + flagged = derive_loss_chance( + composite_score=50.0, + risk_flags=["altman_distress"], + valuation_warnings=[], + mos_pct=0.0, + ) + assert flagged == min(MAX_PCT, clean + ALTMAN_PENALTY) + + +def test_extreme_estimates_capped(): + """5 extreme_*_estimate flags hit the EXTREME_ESTIMATE_CAP — adding a + 6th doesn't increase loss chance further.""" + five = derive_loss_chance( + composite_score=50.0, + risk_flags=[], + valuation_warnings=[ + "extreme_graham_estimate", + "extreme_rim_estimate", + "extreme_dcf_estimate", + "extreme_multiples_pe_estimate", + "extreme_multiples_pb_estimate", + ], + mos_pct=0.0, + ) + six = derive_loss_chance( + composite_score=50.0, + risk_flags=[], + valuation_warnings=[ + "extreme_graham_estimate", + "extreme_rim_estimate", + "extreme_dcf_estimate", + "extreme_multiples_pe_estimate", + "extreme_multiples_pb_estimate", + "extreme_multiples_ev_ebitda_estimate", + ], + mos_pct=0.0, + ) + assert five == six + + +# -- Tier-aligned expectations ----------------------------------------------- + +def test_bullish_profile_lands_in_low_band(): + """A canonical Bullish stock (top composite + no flags + +20% MoS) + should produce a Loss Chance in the lower half of the gradient.""" + result = derive_loss_chance( + composite_score=72.0, + risk_flags=[], + valuation_warnings=[], + mos_pct=20.0, + ) + assert result is not None + assert result < 40.0 # in <25 or 25-40 band + + +def test_cautious_profile_lands_in_high_band(): + """A canonical Cautious stock (low composite + Altman + −50% MoS) + should produce a Loss Chance well above the midpoint.""" + result = derive_loss_chance( + composite_score=40.0, + risk_flags=["altman_distress"], + valuation_warnings=[], + mos_pct=-50.0, + ) + assert result is not None + assert result > 60.0 + + +# -- Input shape tolerance --------------------------------------------------- + +@pytest.mark.parametrize( + "rf", + [None, [], (), set(), frozenset()], +) +def test_accepts_various_empty_risk_flag_shapes(rf): + """None / list / tuple / set / frozenset all treated as 'no flags'.""" + result = derive_loss_chance( + composite_score=50.0, + risk_flags=rf, + valuation_warnings=None, + mos_pct=0.0, + ) + assert result == BASELINE_PCT + + +def test_accepts_set_inputs_for_risk_and_warnings(): + """The Set form should produce the same result as the List form.""" + list_form = derive_loss_chance( + composite_score=50.0, + risk_flags=["altman_distress"], + valuation_warnings=["beneish_high"], + mos_pct=0.0, + ) + set_form = derive_loss_chance( + composite_score=50.0, + risk_flags={"altman_distress"}, + valuation_warnings={"beneish_high"}, + mos_pct=0.0, + ) + assert list_form == set_form