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 (
+
+
+ {rounded}%
+ {showQualifier && (
+ heuristic
+ )}
+
+ );
+}
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