From 828cab284e2640651c63c1e0c4ad34993eddc7ad Mon Sep 17 00:00:00 2001 From: yashvanthange Date: Wed, 15 Jul 2026 23:03:52 +0530 Subject: [PATCH] feat: honest peer resolution and relative-to-industry confidence Fix a section that reported "Not available ... Low confidence (0.37, 0% field coverage)" for any company outside a curated peer group -- KPIT and the entire automotive ER&D cohort were in none. With no peers, relative.py computed nothing and evidence.py then scored 0.80 x (0.4 + 0.6x0) = 0.32 plus a +0.05 cross-source agreement bonus awarded over zero rows, so the module claimed two sources corroborated each other while holding no data. That 0.37 leaked into the report-level and thesis-level aggregates as a plain mean. Zero coverage now collapses to 0.00 with a reason that says why, the agreement bonus is gated on there being something to agree on, and ev.aggregate blends by a coverage-weighted mean so an empty module carries no weight. The same arithmetic was scoring 0.37 for every unknown Buffett criterion; those reprice to 0.00 as well. Add an auto_erd peer group (KPITTECH, TATAELXSI, TATATECH, LTTS, CYIENT) plus auto_components, hospitals_diagnostics and capital_goods_defence, and a resolution ladder in peers.py: curated membership, then a keyword match on Yahoo's industry, then Finnhub, then none. The resulting PeerBasis travels on PeerComparison, RelativeComparison and IndustryIntelligence and discounts confidence via a new evidence.confidence(reliability_factor=...), so a guessed cohort can never read like a deliberate one and a rank among five hand-picked names can never reach the High tier. A matched group now reframes the industry narrative too -- KPIT's sub-domains become SDV, ADAS and EV powertrain rather than "IT services & outsourcing" -- while Yahoo's raw industry string is kept verbatim alongside, since hiding the disagreement would be worse than showing it. Extend the metric set to EV/EBITDA, ROA and P/S (7 -> 10), measuring coverage against the metrics the peer set can actually rank on so that adding a metric Indian peers rarely report does not silently mark every Indian company down. RelativeMetric now carries its unit, because both renderers inferred it from the metric name and would have printed EV/EBITDA as 3000%. Repair four dead tickers that each silently dropped a company out of its own peer table: TATAMOTORS.NS (superseded by the TMCV/TMPV demerger, both already listed), SPICEJET.NS (BSE only now), and LTIM.NS/AKZOINDIA.NS (unresolvable across .NS and .BO, removed rather than guessed at). No offline test can catch this, so scripts/validate_peers.py checks the file against the provider on demand. Ships per-group provenance and docs/confidence.md covering the formula's reasoning and its limits. --- CHANGELOG.md | 42 +++++++ README.md | 9 +- docs/confidence.md | 134 +++++++++++++++++++++ scripts/validate_peers.py | 58 +++++++++ src/investo/analysis/evidence.py | 94 ++++++++++++--- src/investo/analysis/industry.py | 60 ++++++---- src/investo/analysis/peers.py | 136 +++++++++++++++++---- src/investo/analysis/relative.py | 175 +++++++++++++++++++++------- src/investo/analysis/report_html.py | 17 +-- src/investo/cli.py | 30 +++-- src/investo/data/__init__.py | 14 ++- src/investo/data/peers.yaml | 99 +++++++++++++++- src/investo/models.py | 24 +++- tests/test_evidence.py | 83 +++++++++++++ tests/test_industry.py | 80 +++++++++++++ tests/test_peers.py | 106 ++++++++++++++++- tests/test_relative.py | 106 ++++++++++++++++- 17 files changed, 1135 insertions(+), 132 deletions(-) create mode 100644 docs/confidence.md create mode 100644 scripts/validate_peers.py create mode 100644 tests/test_industry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad2e48..a634ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,48 @@ All notable changes to Investo are documented here. The format follows ## [Unreleased] +### Fixed +- **Relative-to-industry reported 0.37 confidence over zero data.** A company in no curated peer + group (KPIT and the whole automotive ER&D cohort were in none) computed no metrics, then scored + `0.80 × (0.4 + 0.6×0) = 0.32` plus a `+0.05` **cross-source agreement bonus awarded over zero + rows** — a confident-looking number manufactured from nothing, which then leaked into the + report-level and thesis-level aggregates. Zero coverage now scores **0.00** with a reason that + says why. The same arithmetic was scoring 0.37 for every `unknown` Buffett criterion; that is + fixed too. +- **Dead tickers in `data/peers.yaml`**, each of which silently dropped a company out of its own + peer table: `TATAMOTORS.NS` (superseded by the `TMCV.NS`/`TMPV.NS` demerger, both already + listed), `SPICEJET.NS` (resolves on BSE only → `SPICEJET.BO`), plus `LTIM.NS` and `AKZOINDIA.NS` + removed as unresolvable across `.NS`/`.BO`. Added `scripts/validate_peers.py` to catch this + class of rot, which no offline test can. + +### Added +- **Automotive ER&D peer group** (`KPITTECH.NS`, `TATAELXSI.NS`, `TATATECH.NS`, `LTTS.NS`, + `CYIENT.NS`) — the market treats these as one cohort, and Yahoo's "Information Technology + Services" classification points the entire analysis at the wrong drivers, CAGR and risks. Plus + `auto_components`, `hospitals_diagnostics` and `capital_goods_defence`. +- **Peer-resolution ladder** (`peers.resolve_peer_group`): curated membership → keyword match on + Yahoo's industry/sector → Finnhub → none. The resulting `PeerBasis` travels on `PeerComparison`, + `RelativeComparison` and `IndustryIntelligence`, so a guessed cohort can never be presented with + the confidence of a deliberate one. +- **Three more relative metrics** — EV/EBITDA, ROA and P/S (7 → 10). Coverage is measured against + the metrics the peer set can actually rank on, so adding a metric Indian peers rarely report + doesn't silently mark every Indian company down. +- **A peer group can reframe the industry narrative**, not just its outlook and CAGR: KPIT's + sub-domains are now SDV, ADAS and EV powertrain rather than "IT services & outsourcing". Yahoo's + raw `industry` string is preserved alongside — it's a fact, and hiding the disagreement would be + worse than showing it. +- **`docs/confidence.md`** — worked examples, the reasoning behind each factor, and the known + limitations of the confidence model. +- `evidence.confidence(reliability_factor=…)` for module-specific discounts, and per-group + provenance (`version`, `updated_at`, `source`) in `peers.yaml`. + +### Changed +- **`ev.aggregate` blends modules by a coverage-weighted mean.** A module that found nothing now + carries zero weight rather than dragging the report down, and the evidence block says how many + modules came back empty. +- `RelativeMetric` carries a `unit` (`ratio`/`percent`); renderers no longer guess from the metric + name, which rendered any unrecognised ratio (EV/EBITDA, P/S) as a percentage. + ### Added - **Listed in the [Cursor Directory](https://cursor.directory)** with a one-click "Add to Cursor" install. The recommended config is now **`uvx --from git+…/Investo investo-mcp`** — it builds diff --git a/README.md b/README.md index 883a802..5df3639 100644 --- a/README.md +++ b/README.md @@ -211,8 +211,15 @@ variables (never logged). It is read-only and does not modify your system. - **Promoter/insider shareholding** for NSE/BSE has no clean free API — best-effort, often unavailable for Indian names. -- **Industry CAGR / market share** are curated/estimated (`data/*.yaml`), not live. +- **Industry CAGR / market share** are curated/estimated (`data/*.yaml`), not live. Each peer group + carries an `updated_at` so you can judge staleness rather than assume freshness. - **Peer lists** start curated for major Indian sectors and are extensible via `data/peers.yaml`. + A ticker in no group falls back to a keyword match on its Yahoo industry; that guess is reported + as `basis: sector-fallback` and scored below a curated group. After editing peers.yaml, run + `python scripts/validate_peers.py` — a dead ticker silently drops a company out of its own peer + table, and no offline test can catch it. +- **Confidence is about evidence quality, not about being right** — see [docs/confidence.md](docs/confidence.md) + for how it's computed and where it stops being trustworthy. - Sharp reporting discontinuities (e.g. a demerger) can distort growth; Investo flags a warning when it detects one, but read the note in context. diff --git a/docs/confidence.md b/docs/confidence.md new file mode 100644 index 0000000..72ef260 --- /dev/null +++ b/docs/confidence.md @@ -0,0 +1,134 @@ +# How Investo scores its own confidence + +Every analysis module reports how much to trust it. That number is **computed, never asserted** — +it is a transparent function of the evidence behind the module, not a vibe. The formula itself +lives in `src/investo/analysis/evidence.py`, which is the single source of truth; this document +explains *why* it is shaped the way it is, what the numbers mean in practice, and where it stops +being trustworthy. + +## The shape + +``` +confidence = source_reliability × coverage_factor × history_factor × reliability_factor + (+ 0.05 corroboration bonus) +``` + +| Factor | Asks | Neutral value | +|---|---|---| +| `source_reliability` | How authoritative is the data? An exchange filing (0.95) beats Yahoo (0.80) beats a curated estimate (0.70) beats a heuristic (0.50). Best source wins. | 0.60 (unknown) | +| `coverage_factor` | What fraction of the fields we expected did we actually get? | 1.0 (`coverage=None`) | +| `history_factor` | For trend checks, how many years back it goes. | 1.0 (`history_years=None`) | +| `reliability_factor` | How were the inputs *obtained*? Distinct from how good the source is. | 1.0 (`None`) | +| corroboration | Do two independent sources agree? | no bonus | + +Tiers: **High** ≥ 0.80, **Medium** ≥ 0.60, **Low** below. + +## Why zero coverage is special + +`coverage_factor` is `0.4 + 0.6 × coverage` — a deliberate softening so that a couple of missing +fields don't collapse an otherwise sound module. But that floor is **skipped entirely at zero**: + +```python +if coverage is None: coverage_factor = 1.0 # not applicable, no penalty +elif coverage <= 0.0: coverage_factor = 0.0 # nothing computed, no confidence +else: coverage_factor = 0.4 + 0.6 * coverage +``` + +This is not a rounding detail. It is the bug this design exists to prevent. + +Before the fix, a relative-to-industry comparison for a company with **no peer group at all** +computed nothing, and then reported: + +``` +0.80 (Yahoo) × (0.4 + 0.6×0.0) × 1.0 = 0.32, +0.05 corroboration -> 0.37 "Low" +reason: "source: Yahoo Finance, Curated (Investo); 0% field coverage; cross-source agreement" +``` + +Read that reason again. It claims two sources cross-checked each other, over zero rows of data. +The 0.37 was not a low-confidence answer — it was **a confident-looking number manufactured from +nothing**, and it then leaked into the report-level and thesis-level aggregates as if it were a +real measurement. A plausible number from no data is worse than a zero, because a zero is +obviously a zero and a 0.37 looks like an opinion. + +The same arithmetic was reporting 0.37 for every `unknown` Buffett criterion. + +For the same reason, the corroboration bonus is gated on coverage: two sources cannot agree on a +figure that was never computed. + +**The trap on the other side:** `build_meta(expected=0)` leaves coverage `None`, not `0.0` — so a +module that means "I computed nothing" must pass a non-zero `expected` with `present=0`. Passing +`expected=0` silently yields *full* confidence. See `relative._evidence`, where +`expected = len(applicable) or len(_METRIC_SPECS)` guards exactly this. + +## `reliability_factor`: how the inputs were obtained + +Source reliability answers "how good is Yahoo?". It cannot answer "did we compare this company to +the right peers?" — that is a separate axis, so it gets a separate factor. + +`relative.py` uses it to price the peer set: + +```python +_BASIS_RELIABILITY = {"curated": 0.90, "keyed": 0.80, "sector-fallback": 0.65, "none": 0.0} +peer_factor = 0.6 + 0.4 × min(1, n_peers / 4) +reliability_factor = _BASIS_RELIABILITY[basis] × peer_factor +``` + +Two judgements are encoded here: + +- **A guessed cohort must never read like a deliberate one.** A `sector-fallback` peer set — matched + by a keyword against Yahoo's industry string — is an educated guess. It is useful, and far better + than nothing, but it must cost confidence relative to a curated group somebody actually thought + about. +- **A thin set is a weak proxy.** Being "top quartile" against two peers means much less than + against six, whatever the basis. + +## Why curated caps at 0.90, not 1.0 + +So that the relative module **can never reach the High tier**, by construction. + +Even with a hand-picked peer group and every field present, a percentile from that comparison is a +rank *within five names*, not a market percentile. Being the best of five is a genuinely different +claim from being in the top quintile of the market, and the confidence should never let a reader +conflate them. The ceiling is the honest statement that this method has a limit no amount of data +quality can lift. + +## Worked examples + +| Scenario | Computation | Score | Tier | +|---|---|---|---| +| No peer group matched | `0.80 × 0.0 × 0.0`, no bonus | **0.00** | Low | +| Curated, 4 peers, 8/10 metrics | `0.80 × 0.88 × 0.90 + 0.05` | **0.68** | Medium | +| Curated, 4 peers, full coverage | `0.80 × 1.0 × 0.90 + 0.05` | **0.77** | Medium | +| Sector-fallback, 4 peers, full | `0.80 × 1.0 × 0.65 + 0.05` | **0.57** | Low | +| Curated but only 2 peers, full | `0.80 × 1.0 × (0.90 × 0.80) + 0.05` | **0.63** | Medium | +| NSE filing, full coverage, 8y history | `0.95 × 1.0 × 1.0 + 0.05` | **1.00** | High | + +## Aggregation + +`ev.aggregate` blends modules by a **coverage-weighted** mean: + +``` +score = Σ(confidence_i × w_i) / Σ(w_i), w_i = coverage_i, or 1.0 when coverage is None +``` + +A module that found nothing has zero coverage, so it carries zero weight: it neither drags the +report down nor props it up, with no special-casing. Point-in-time modules (no coverage to speak +of) weigh fully. + +**The honesty risk this creates**, stated plainly: if five of seven modules come back empty, the +report's confidence reflects only the two that ran, which overstates how much is actually known +about the company. `aggregate` therefore pushes a `"k of n modules found no data"` note into the +evidence block. A reader who ignores that note will over-trust the headline. + +## Known limitations + +- **`len(labels) >= 2` is a weak proxy for corroboration.** Yahoo (the figures) and Curated (the + peer list) are not two independent measurements of the same quantity, but they currently trigger + the agreement bonus. It is gated on coverage, so it can no longer fire over no data — but the + proxy itself deserves replacing with explicit per-figure corroboration. +- **Source weights are judgement, not measurement.** Nobody benchmarked Yahoo at 0.80. The ordering + (filings > statements > Yahoo > curated > heuristic) is defensible; the exact gaps are not. +- **Curated CAGR and outlook are Investo's own estimates**, not third-party forecasts, and they age. + Each peer group carries `updated_at` so a reader can judge staleness rather than assume freshness. +- **Confidence is about evidence quality, not about being right.** A high-confidence read of + complete, authoritative data can still be a bad investment call. diff --git a/scripts/validate_peers.py b/scripts/validate_peers.py new file mode 100644 index 0000000..59a6168 --- /dev/null +++ b/scripts/validate_peers.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +"""Check every ticker in ``data/peers.yaml`` actually resolves at the data provider. + +A dead ticker in a curated group fails *silently and badly*: the company simply drops out of +its own peer table, which is indistinguishable from "no peer data" — the exact symptom curated +groups exist to prevent. This is not catchable offline, so it cannot live in the pytest suite +(which is deliberately network-free); run it by hand after editing peers.yaml. + + python scripts/validate_peers.py # all groups + python scripts/validate_peers.py auto_erd # one group + +Exits non-zero if any ticker is unresolvable, so it can gate a data change in CI if wanted. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +from investo.data import peer_groups # noqa: E402 +from investo.sources import data # noqa: E402 + + +def main(argv: list[str]) -> int: + only = set(argv[1:]) + groups = peer_groups() + if only: + groups = {k: v for k, v in groups.items() if k in only} + if not groups: + print(f"No such group(s): {', '.join(sorted(only))}", file=sys.stderr) + return 2 + + dead: list[tuple[str, str]] = [] + for key, group in groups.items(): + print(f"\n{group.get('label', key)} ({key}, updated {group.get('updated_at', '?')})") + for ticker in group.get("members", []): + info = data.get_info(ticker) + name = info.get("longName") or info.get("shortName") + if name: + print(f" ok {ticker:16} {name[:44]}") + else: + print(f" DEAD {ticker:16} unresolvable at the provider") + dead.append((key, ticker)) + + print() + if dead: + print(f"{len(dead)} dead ticker(s) — a company missing from its own peer table:") + for key, ticker in dead: + print(f" {key}: {ticker}") + return 1 + print("All tickers resolve.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/src/investo/analysis/evidence.py b/src/investo/analysis/evidence.py index 47133c7..5652aaa 100644 --- a/src/investo/analysis/evidence.py +++ b/src/investo/analysis/evidence.py @@ -4,15 +4,23 @@ conclusions correctly. Confidence is **computed, never asserted**: it is a transparent function of three inputs, documented here and unit-tested. - confidence = source_reliability * coverage_factor * history_factor (+ agreement bonus) + confidence = source_reliability * coverage_factor * history_factor * reliability_factor + (+ agreement bonus) - *source reliability* — how authoritative the underlying data is (an exchange filing beats a scraped estimate). When several sources back a module we take the best. - *coverage_factor* — fraction of the fields a module expects that were actually present, - softened so a couple of gaps don't collapse the score (``0.4 + 0.6 * coverage``). + softened so a couple of gaps don't collapse the score (``0.4 + 0.6 * coverage``). **The + softening deliberately does not apply at zero**: a module that computed *nothing* has earned + no confidence at all, so ``coverage == 0`` collapses the score to 0 rather than resting on + the 0.4 floor. A plausible-looking number derived from no data is worse than a zero. - *history_factor* — for trend-based checks, how many years of history backed it (``0.5 + 0.5 * min(1, years / target)``); ``None`` for point-in-time checks (no penalty). -- *agreement bonus* — a small boost when two independent sources corroborate a figure. +- *reliability_factor* — an optional module-specific discount for *how the inputs were + obtained*, distinct from how good the source is. Used by ``relative`` to price a guessed + peer group below a curated one. ``None`` means no discount. +- *agreement bonus* — a small boost when two independent sources corroborate a figure. Gated + on coverage: with nothing computed, no two sources can agree on anything. The result is a ``Confidence`` with a 0-1 ``score``, a ``High/Medium/Low`` ``tier`` and a plain-language ``reason``. This module is the single source of truth reused everywhere, exactly @@ -57,6 +65,20 @@ _TIER_HIGH = 0.80 _TIER_MEDIUM = 0.60 +# Coverage shaping: `_COVERAGE_FLOOR + _COVERAGE_SPAN * coverage` for any coverage above zero, +# so a couple of gaps don't collapse the score. At exactly zero the floor does not apply. +_ZERO_COVERAGE = 0.0 +_COVERAGE_FLOOR = 0.4 +_COVERAGE_SPAN = 0.6 + +# History shaping: `_HISTORY_FLOOR + _HISTORY_SPAN * min(1, years / target)`. +_HISTORY_FLOOR = 0.5 +_HISTORY_SPAN = 0.5 + +# Boost applied when two independent sources corroborate a figure. +_CORROBORATION_BONUS = 0.05 +_CORROBORATION_MIN_SOURCES = 2 + def source_weight(source: str | None) -> float: """Reliability weight for a source label (case-insensitive; highest matching key wins).""" @@ -83,36 +105,57 @@ def confidence( history_years: int | None = None, target_years: int = 5, corroborated: bool = False, + reliability_factor: float | None = None, reason: str | None = None, ) -> Confidence: """Compute a :class:`Confidence` from source reliability, coverage and history depth. All inputs are optional; each missing input is treated neutrally (no penalty) so the formula degrades gracefully. See the module docstring for the exact factors. + + ``coverage=0.0`` is the one input treated harshly rather than neutrally: it means the module + computed nothing, so the result is 0 and carries no agreement bonus. """ labels = _source_labels(sources) best_source = max((source_weight(s) for s in labels), default=_DEFAULT_SOURCE_WEIGHT) - coverage_factor = 1.0 if coverage is None else 0.4 + 0.6 * _clamp01(coverage) + if coverage is None: + coverage_factor = 1.0 + elif coverage <= _ZERO_COVERAGE: + coverage_factor = 0.0 # nothing computed -> no confidence, floor deliberately skipped + else: + coverage_factor = _COVERAGE_FLOOR + _COVERAGE_SPAN * _clamp01(coverage) if history_years is None: history_factor = 1.0 else: - history_factor = 0.5 + 0.5 * min(1.0, history_years / max(target_years, 1)) - - score = best_source * coverage_factor * history_factor - if corroborated or len(labels) >= 2: - score = min(1.0, score + 0.05) # independent corroboration bonus + history_factor = _HISTORY_FLOOR + _HISTORY_SPAN * min( + 1.0, history_years / max(target_years, 1)) + + score = best_source * coverage_factor * history_factor * _clamp01( + 1.0 if reliability_factor is None else reliability_factor) + + # Two sources can only corroborate each other if there is something to agree on. + has_data = coverage is None or coverage > _ZERO_COVERAGE + corroborating = has_data and ( + corroborated or len(labels) >= _CORROBORATION_MIN_SOURCES) + if corroborating: + score = min(1.0, score + _CORROBORATION_BONUS) score = round(_clamp01(score), 3) return Confidence(score=score, tier=tier(score), reason=reason or _auto_reason( - labels, coverage, history_years, target_years, corroborated or len(labels) >= 2)) + labels, coverage, history_years, target_years, corroborating)) def aggregate(metas: list[EvidenceMeta | None], notes: list[str] | None = None) -> EvidenceMeta: - """Roll several modules' :class:`EvidenceMeta` into one report-level quality block.""" + """Roll several modules' :class:`EvidenceMeta` into one report-level quality block. + + Modules are blended by a **coverage-weighted** mean: a module that found no data carries no + weight, so it neither drags the report down nor props it up. Point-in-time modules (no + coverage) weigh fully, as before. When any module came back empty we say so in ``notes`` — + a report built on 2 of 7 modules should not read like a report built on 7. + """ present = [m for m in metas if m is not None] - conf_scores = [m.confidence.score for m in present if m.confidence] coverages = [m.data_coverage for m in present if m.data_coverage is not None] sources: list[Provenance] = [] seen: set[tuple[str, str | None]] = set() @@ -125,13 +168,26 @@ def aggregate(metas: list[EvidenceMeta | None], notes: list[str] | None = None) missing = sorted({f for m in present for f in m.missing_fields}) as_of = max((m.as_of for m in present if m.as_of), default=None) - score = round(sum(conf_scores) / len(conf_scores), 3) if conf_scores else 0.5 + # (confidence, weight) per module. A module that found nothing weighs nothing. + scored = [(m.confidence.score, 1.0 if m.data_coverage is None else m.data_coverage) + for m in present if m.confidence] + total_weight = sum(w for _, w in scored) + if total_weight > 0: + score = round(sum(s * w for s, w in scored) / total_weight, 3) + else: + score = 0.0 if scored else 0.5 # every module empty vs nothing to blend at all coverage = round(sum(coverages) / len(coverages), 3) if coverages else None - conf = Confidence(score=score, tier=tier(score), - reason=f"blended across {len(present)} module(s)") + + empty = sum(1 for _, w in scored if w <= 0) + all_notes = list(notes or []) + if empty: + all_notes.append(f"{empty} of {len(scored)} modules found no data and were not blended " + f"into this confidence.") + reason = f"coverage-weighted across {len(scored)} module(s)" + conf = Confidence(score=score, tier=tier(score), reason=reason) return EvidenceMeta( confidence=conf, data_coverage=coverage, sources=sources, source_count=len(sources), - missing_fields=missing, as_of=as_of, notes=list(notes or []), + missing_fields=missing, as_of=as_of, notes=all_notes, ) @@ -143,6 +199,7 @@ def build_meta( missing_fields: list[str] | None = None, history_years: int | None = None, target_years: int = 5, + reliability_factor: float | None = None, as_of: str | None = None, notes: list[str] | None = None, reason: str | None = None, @@ -151,6 +208,10 @@ def build_meta( ``present``/``expected`` give data coverage; ``missing_fields`` is surfaced verbatim so a caller can see exactly what was unavailable. + + Note that ``expected=0`` leaves coverage ``None`` (i.e. "not applicable", no penalty) rather + than zero. A caller that means "nothing was computed" must pass a non-zero ``expected`` with + ``present=0``, otherwise an empty module reads as a fully-covered one. """ provs = list(sources or []) coverage: float | None = None @@ -163,6 +224,7 @@ def build_meta( coverage=coverage, history_years=history_years, target_years=target_years, + reliability_factor=reliability_factor, reason=reason, ) latest = as_of or _latest_as_of(provs) diff --git a/src/investo/analysis/industry.py b/src/investo/analysis/industry.py index 2ec5740..5082fd6 100644 --- a/src/investo/analysis/industry.py +++ b/src/investo/analysis/industry.py @@ -1,7 +1,14 @@ """Industry intelligence: sub-domains, demand drivers, CAGR and risks for a company. -Combines the curated per-sector notes (``data/industry.yaml``) with the peer-group's more -specific outlook/CAGR (``data/peers.yaml``) when the company belongs to a curated group. +Yahoo's ``sector`` is broad — KPIT, Infosys and a data-centre REIT are all "Technology" — so the +per-sector notes in ``data/industry.yaml`` are only a starting point. When a company resolves to a +curated peer group, that group's framing wins: an automotive ER&D firm is described by SDV and +ADAS programmes, not by "IT services & outsourcing", because the group is the more specific and +more considered judgement. + +Yahoo's raw ``industry`` string is preserved verbatim alongside — it is a fact about how the +exchange classifies the company, and overwriting it would hide the disagreement rather than show +it. """ from __future__ import annotations @@ -9,7 +16,7 @@ from ..data import industry_notes from ..models import IndustryIntelligence from ..sources import data -from .peers import _group_for +from .peers import resolve_peer_group def get_industry_intelligence(symbol: str) -> IndustryIntelligence: @@ -18,31 +25,39 @@ def get_industry_intelligence(symbol: str) -> IndustryIntelligence: industry = info.get("industry") notes = industry_notes().get(sector or "", {}) - outlook = notes.get("outlook") - cagr = notes.get("industry_cagr") + res = resolve_peer_group(symbol, info) + group = res.group or {} - # Peer-group specifics override the broad sector note when available. - found = _group_for(symbol) - if found: - _, group = found - outlook = group.get("outlook", outlook) - cagr = group.get("industry_cagr", cagr) + # The peer group is the more specific judgement, so it wins field by field; the sector note + # fills whatever the group doesn't speak to. + def pick(key: str, default): + value = group.get(key) + return value if value else notes.get(key, default) result = IndustryIntelligence( ticker=symbol.upper(), sector=sector, industry=industry, - sub_domains=list(notes.get("sub_domains", [])), - demand_drivers=list(notes.get("demand_drivers", [])), - future_demand=notes.get("future_demand"), - industry_cagr=cagr, - risks=list(notes.get("risks", [])), - source="curated", + peer_group=res.label, + basis=res.basis, + sub_domains=list(pick("sub_domains", [])), + demand_drivers=list(pick("demand_drivers", [])), + future_demand=pick("future_demand", None), + industry_cagr=pick("industry_cagr", None), + as_of=group.get("updated_at"), + risks=list(pick("risks", [])), + source="curated" if (res.basis == "curated" or notes) else "unknown", ) - if not notes: + if res.basis == "sector-fallback": + result.note = ( + f"{symbol.upper()} is not in a curated peer group; framed as '{res.label}' by matching " + f"its Yahoo industry ('{industry}'). Indicative only." + ) + elif not notes and not group: result.note = ( - f"No curated intelligence for sector '{sector}'. Add it in data/industry.yaml; " - "the host LLM can also reason about the industry from the profile." + f"No curated intelligence for sector '{sector}'. Add it in data/industry.yaml or add " + "the ticker to a peer group in data/peers.yaml; the host LLM can also reason about " + "the industry from the profile." ) return result @@ -54,9 +69,8 @@ def industry_outlook(symbol: str) -> tuple[str | None, str | None]: notes = industry_notes().get(sector or "", {}) outlook = notes.get("outlook") cagr = notes.get("industry_cagr") - found = _group_for(symbol) - if found: - _, group = found + group = resolve_peer_group(symbol, info).group + if group: outlook = group.get("outlook", outlook) cagr = group.get("industry_cagr", cagr) return outlook, cagr diff --git a/src/investo/analysis/peers.py b/src/investo/analysis/peers.py index 5173ded..803ce62 100644 --- a/src/investo/analysis/peers.py +++ b/src/investo/analysis/peers.py @@ -1,24 +1,45 @@ """Competitor analysis: find sector peers and build a side-by-side comparison. -Peers come from the curated map (``data/peers.yaml``); when a ticker isn't in the map and a -Finnhub key is configured, we fall back to Finnhub's peer list. Revenue and market cap are -normalized to the queried company's trading currency so cross-listed peers (e.g. INFY in USD) -compare fairly against INR-reporting peers. +Peers are resolved by a ladder, best evidence first (see :func:`resolve_peer_group`): an exact +curated match, then a keyword match on the company's Yahoo industry/sector, then Finnhub if a key +is configured. The resulting :class:`PeerBasis` travels with the comparison so that everything +derived from it can be priced honestly — a guessed peer set must not be presented with the same +confidence as a deliberate one. + +Revenue and market cap are normalized to the queried company's trading currency so cross-listed +peers (e.g. INFY in USD) compare fairly against INR-reporting peers. """ from __future__ import annotations from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass from ..config import CONFIG from ..data import peer_groups -from ..models import PeerComparison, PeerRow +from ..models import PeerBasis, PeerComparison, PeerRow from ..sources import data _MAX_PEERS = 6 +@dataclass(frozen=True) +class PeerResolution: + """How a peer set was found, and what it is.""" + + basis: PeerBasis + key: str | None + group: dict | None + peers: list[str] + + @property + def label(self) -> str | None: + return self.group.get("label") if self.group else None + + def _group_for(symbol: str) -> tuple[str, dict] | None: + """Exact membership match. First-match-wins over peers.yaml order (deliberate — see the note + at the top of that file; some tickers are double-booked).""" sym = symbol.upper() for key, group in peer_groups().items(): members = [m.upper() for m in group.get("members", [])] @@ -27,21 +48,75 @@ def _group_for(symbol: str) -> tuple[str, dict] | None: return None -def get_peers(symbol: str) -> tuple[list[str], dict | None]: - """Return (peer_symbols, group_metadata) for a ticker.""" - found = _group_for(symbol) +def _group_by_keywords(info: dict) -> tuple[str, dict] | None: + """Match a group's ``keywords`` against Yahoo's industry, then its sector. + + Only reached for tickers in no curated group. Longest keyword wins so the most specific + match beats a generic one, with an alphabetical tie-break so the result never depends on + dict ordering — an unstable peer group would be worse than none. + """ + for field in ("industry", "sector"): + haystack = str(info.get(field) or "").lower() + if not haystack: + continue + hits = [ + (len(kw), key, group) + for key, group in peer_groups().items() + for kw in group.get("keywords", []) + if kw and kw.lower() in haystack + ] + if hits: + _, key, group = max(hits, key=lambda h: (h[0], _inverse(h[1]))) + return key, group + return None + + +def _inverse(key: str) -> tuple[int, ...]: + """Sort helper: makes `max` pick the alphabetically-first key on a length tie.""" + return tuple(-ord(c) for c in key) + + +def resolve_peer_group(symbol: str, info: dict | None = None) -> PeerResolution: + """Resolve ``symbol``'s peer set, best evidence first. + + 1. curated — an exact membership match in data/peers.yaml + 2. sector-fallback — a keyword match on Yahoo's industry/sector (an educated guess) + 3. keyed — Finnhub's peer list, when a key is configured + 4. none — no peers; callers must not pretend otherwise + """ + sym = symbol.upper() + + found = _group_for(sym) if found: - _, group = found - peers = [m for m in group.get("members", []) if m.upper() != symbol.upper()] - return peers, group + key, group = found + peers = [m for m in group.get("members", []) if m.upper() != sym] + return PeerResolution("curated", key, group, peers) + + info = data.get_info(symbol) if info is None else info + found = _group_by_keywords(info or {}) + if found: + key, group = found + peers = [m for m in group.get("members", []) if m.upper() != sym] + if peers: + return PeerResolution("sector-fallback", key, group, peers) - # Fallback: Finnhub peers (optional, needs key). if CONFIG.has_finnhub: from ..sources import keyed - peers = keyed.finnhub_peers(symbol) - if peers: - return [p for p in peers if p.upper() != symbol.upper()], None - return [], None + fh = [p for p in keyed.finnhub_peers(symbol) if p.upper() != sym] + if fh: + return PeerResolution("keyed", None, None, fh) + + return PeerResolution("none", None, None, []) + + +def get_peers(symbol: str) -> tuple[list[str], dict | None]: + """Return (peer_symbols, group_metadata) for a ticker. + + Kept for callers that don't care *how* the peers were found; prefer + :func:`resolve_peer_group`, which also tells you how much to trust them. + """ + res = resolve_peer_group(symbol) + return res.peers, res.group def _bounded(value: float | None, lo: float, hi: float) -> float | None: @@ -81,6 +156,11 @@ def f(key: str) -> float | None: pe=_bounded(f("trailingPE"), 0, 500), pb=_bounded(f("priceToBook"), 0, 100), roe=f("returnOnEquity"), + roa=f("returnOnAssets"), + # Bounded because Yahoo mixes currencies on cross-listed names: an INR enterprise value + # over USD EBITDA yields nonsense like 975x (see ratios.py). + ev_ebitda=_bounded(f("enterpriseToEbitda"), 0, 100), + price_to_sales=_bounded(f("priceToSalesTrailing12Months"), 0, 100), revenue_growth_yoy=f("revenueGrowth"), debt_to_equity=(d2e / 100.0) if d2e is not None else None, ) @@ -124,17 +204,26 @@ def _p(x: float | None) -> str: return f"{x:.1%}" if x is not None else "n/a" +_NO_PEERS_NOTE = { + "none": "No peer group matched — not by membership in data/peers.yaml, not by industry " + "keyword. Add the ticker to a group there, or set FINNHUB_API_KEY.", + "keyed": "Finnhub returned no usable peers for this ticker.", +} + + def compare_peers(symbol: str, max_peers: int = _MAX_PEERS) -> PeerComparison: """Build a peer comparison table for *symbol* (must be a resolved ticker).""" info = data.get_info(symbol) base_ccy = info.get("currency") or info.get("financialCurrency") sector = info.get("sector") - peers, group = get_peers(symbol) + res = resolve_peer_group(symbol, info) + peers = res.peers + group = res.group if not peers: return PeerComparison( - ticker=symbol.upper(), sector=sector, peers=[], - note="No curated peer group matched. Add one in data/peers.yaml or set FINNHUB_API_KEY.", + ticker=symbol.upper(), sector=sector, peers=[], basis=res.basis, + note=_NO_PEERS_NOTE.get(res.basis, _NO_PEERS_NOTE["none"]), ) symbols = [symbol.upper()] + [p.upper() for p in peers[:max_peers]] @@ -151,10 +240,17 @@ def compare_peers(symbol: str, max_peers: int = _MAX_PEERS) -> PeerComparison: r.market_share_proxy = r.revenue_ttm / total_rev label = group.get("label") if group else sector + note = "Revenue & market cap normalized to the subject's trading currency." + if res.basis == "sector-fallback": + note = (f"{symbol.upper()} is not in a curated peer group; matched to '{label}' by its " + f"industry. Treat the comparison as indicative. ") + note return PeerComparison( ticker=symbol.upper(), sector=label, peers=rows, summary=_summarize(symbol, rows), - note="Revenue & market cap normalized to the subject's trading currency.", + basis=res.basis, + peer_group_key=res.key, + peer_group_label=label, + note=note, ) diff --git a/src/investo/analysis/relative.py b/src/investo/analysis/relative.py index a9c5d21..200abfd 100644 --- a/src/investo/analysis/relative.py +++ b/src/investo/analysis/relative.py @@ -5,28 +5,59 @@ value against the **peer-set median** (an industry proxy) plus a **favourable-side percentile** so that a high percentile always means "good" regardless of whether higher or lower is better. -Honesty note: the peer set is small and curated, so the percentile is a rank *within that set*, not -a true market-wide percentile. That limitation is reflected in the section's confidence/notes. +Honesty notes, which the confidence reflects rather than merely mentions: + +- The peer set is small and curated, so a percentile is a rank *within that set*, not a true + market-wide percentile. This module therefore cannot reach the High confidence tier. +- A guessed peer set (``sector-fallback``) is priced below a deliberate one (``curated``), and a + two-name set below a six-name one, via ``reliability_factor``. +- With no peers there are no metrics, and the confidence is **zero** — not a plausible-looking + number derived from nothing. """ from __future__ import annotations from statistics import median -from ..models import PeerComparison, Ratios, RelativeComparison, RelativeMetric +from ..models import MetricUnit, PeerBasis, PeerComparison, Ratios, RelativeComparison, RelativeMetric from . import evidence as ev -# (display name, PeerRow attribute, higher-is-better) -_METRIC_SPECS: list[tuple[str, str, bool]] = [ - ("ROE", "roe", True), - ("Net margin", "net_margin", True), - ("Operating margin", "operating_margin", True), - ("Revenue growth", "revenue_growth_yoy", True), - ("P/E", "pe", False), - ("P/B", "pb", False), - ("Debt/Equity", "debt_to_equity", False), +# (display name, PeerRow attribute, higher-is-better, unit) +_METRIC_SPECS: list[tuple[str, str, bool, MetricUnit]] = [ + ("ROE", "roe", True, "percent"), + ("ROA", "roa", True, "percent"), + ("Net margin", "net_margin", True, "percent"), + ("Operating margin", "operating_margin", True, "percent"), + ("Revenue growth", "revenue_growth_yoy", True, "percent"), + ("P/E", "pe", False, "ratio"), + ("P/B", "pb", False, "ratio"), + ("P/S", "price_to_sales", False, "ratio"), + ("EV/EBITDA", "ev_ebitda", False, "ratio"), + ("Debt/Equity", "debt_to_equity", False, "ratio"), ] +# A metric needs at least this many peer values before its median means anything. +_MIN_PEERS_FOR_MEDIAN = 2 + +# How much to trust a peer set given how it was found. Curated caps below the High tier (0.80) +# on purpose: a rank among five hand-picked names is not a market percentile, however complete +# the data behind it. +_BASIS_RELIABILITY: dict[PeerBasis, float] = { + "curated": 0.90, + "keyed": 0.80, + "sector-fallback": 0.65, + "none": 0.0, +} + +# A small peer set is a weak proxy for an industry, independent of how it was found. +_PEER_FACTOR_FLOOR = 0.6 +_PEER_FACTOR_SPAN = 0.4 +_PEER_TARGET = 4 + +_BAND_TOP = 0.75 +_BAND_ABOVE = 0.5 +_BAND_BELOW = 0.25 + def relative_comparison( symbol: str, @@ -40,13 +71,21 @@ def relative_comparison( metrics: list[RelativeMetric] = [] summary: list[str] = [] - computed = 0 + applicable: list[str] = [] # metrics the peer set can actually rank on + missing: list[str] = [] # applicable, but we lack the company's own value + unavailable: list[str] = [] # the peer set has no data for these at all - for name, attr, higher_better in _METRIC_SPECS: - company = _subject_value(subject, ratios, attr) + for name, attr, higher_better, unit in _METRIC_SPECS: peer_vals = [v for v in (getattr(p, attr, None) for p in others) if v is not None] - if company is None or len(peer_vals) < 2: - continue # need the company's value and a couple of peers to be meaningful + if len(peer_vals) < _MIN_PEERS_FOR_MEDIAN: + unavailable.append(name) + continue + + applicable.append(name) + company = _subject_value(subject, ratios, attr) + if company is None: + missing.append(name) + continue ind = float(median(peer_vals)) pct = _favourable_percentile(company, peer_vals, higher_better) @@ -59,34 +98,85 @@ def relative_comparison( better=better, delta=round(company - ind, 6), higher_is_better=higher_better, + unit=unit, provenance=ev.Provenance(source=ev.SRC_YAHOO, detail="peer-set median"), )) - computed += 1 - summary.append(_phrase(name, company, ind, pct, higher_better)) + summary.append(_phrase(name, company, ind, pct, unit)) - total = len(_METRIC_SPECS) - meta = ev.build_meta( - sources=[ - ev.Provenance(source=ev.SRC_YAHOO, detail="peer fundamentals"), - ev.Provenance(source=ev.SRC_CURATED, detail="peer list"), - ], - present=computed, - expected=total, - missing_fields=[n for n, a, _ in _METRIC_SPECS - if not any(m.name == n for m in metrics)], - notes=[f"Percentiles are within a {len(others) + 1}-name peer set, not the whole market."], - ) - note = None if metrics else "Not enough peer data for a relative comparison." + label = peers.peer_group_label or peers.sector + meta = _evidence(peers.basis, label, len(others), metrics, applicable, missing, unavailable) return RelativeComparison( ticker=symbol, metrics=metrics, - peer_count=len(others) + 1, + peer_count=len(others) + 1 if others else 0, summary=summary, + basis=peers.basis, + peer_group_label=label, evidence=meta, - note=note, + note=None if metrics else _no_metrics_note(peers.basis), ) +# -------------------------------------------------------------------------------------- +# Evidence +# -------------------------------------------------------------------------------------- +def _evidence( + basis: PeerBasis, + label: str | None, + n_peers: int, + metrics: list[RelativeMetric], + applicable: list[str], + missing: list[str], + unavailable: list[str], +) -> ev.EvidenceMeta: + """Price this comparison honestly. + + Coverage is computed against the metrics the peer set can actually rank on, not against every + metric we know how to compute. Otherwise adding a metric that Indian peers rarely report would + silently mark every Indian company down — a confidence drop with no change in what we know. + """ + # `or len(_METRIC_SPECS)` is load-bearing: build_meta treats expected=0 as "not applicable" + # and would hand back full confidence for a module that computed nothing at all. + expected = len(applicable) or len(_METRIC_SPECS) + + notes: list[str] = [] + if metrics: + notes.append(f"Percentiles are a rank within a {n_peers}-peer {basis} set" + f"{f' ({label})' if label else ''}, not the whole market.") + # With no peers at all, every metric is trivially "unavailable" — the reason already says why, + # and listing all ten would bury it. + if unavailable and n_peers: + notes.append("No peer data reported for: " + ", ".join(unavailable) + ".") + + return ev.build_meta( + sources=[ + ev.Provenance(source=ev.SRC_YAHOO, detail="peer fundamentals"), + ev.Provenance(source=ev.SRC_CURATED, detail="peer list"), + ], + present=len(metrics), + expected=expected, + missing_fields=missing, + reliability_factor=_reliability(basis, n_peers), + notes=notes, + reason=None if metrics else _no_metrics_note(basis), + ) + + +def _reliability(basis: PeerBasis, n_peers: int) -> float: + """Discount for *how* the peer set was obtained and how thin it is.""" + if not n_peers: + return 0.0 + peer_factor = _PEER_FACTOR_FLOOR + _PEER_FACTOR_SPAN * min(1.0, n_peers / _PEER_TARGET) + return _BASIS_RELIABILITY.get(basis, 0.0) * peer_factor + + +def _no_metrics_note(basis: PeerBasis) -> str: + if basis == "none": + return "No peer metrics computed: no peer group matched this ticker." + return ("No peer metrics computed: the peer set reported too few comparable figures " + f"(basis: {basis}).") + + # -------------------------------------------------------------------------------------- # Internals # -------------------------------------------------------------------------------------- @@ -112,23 +202,20 @@ def _favourable_percentile(company: float, peer_vals: list[float], higher_better return wins / len(peer_vals) -def _phrase(name: str, company: float, industry: float, pct: float, higher_better: bool) -> str: - fmt = _fmt(name) - return f"{name} {fmt(company)} vs industry {fmt(industry)} ({_band(pct)})" +def _phrase(name: str, company: float, industry: float, pct: float, unit: MetricUnit) -> str: + return f"{name} {_fmt(company, unit)} vs industry {_fmt(industry, unit)} ({_band(pct)})" def _band(pct: float) -> str: """Qualitative percentile band (higher pct = better; robust for small peer sets).""" - if pct >= 0.75: + if pct >= _BAND_TOP: return "top quartile" - if pct >= 0.5: + if pct >= _BAND_ABOVE: return "above median" - if pct >= 0.25: + if pct >= _BAND_BELOW: return "below median" return "bottom quartile" -def _fmt(name: str): - if name in {"P/E", "P/B", "Debt/Equity"}: - return lambda x: f"{x:.1f}" - return lambda x: f"{x:.1%}" +def _fmt(value: float, unit: MetricUnit) -> str: + return f"{value:.1f}x" if unit == "ratio" else f"{value:.1%}" diff --git a/src/investo/analysis/report_html.py b/src/investo/analysis/report_html.py index 8fed389..512e999 100644 --- a/src/investo/analysis/report_html.py +++ b/src/investo/analysis/report_html.py @@ -143,13 +143,17 @@ def _relative(r: AnalysisReport) -> str: if not rel or not rel.metrics: return "" rows = "".join( - f"{escape(m.name)}{_metric(m.name, m.company)}" - f"{_metric(m.name, m.industry)}" + f"{escape(m.name)}{_metric(m.unit, m.company)}" + f"{_metric(m.unit, m.industry)}" f"{_band_pill(m.percentile, m.better)}" for m in rel.metrics) head = "MetricCompany" \ "IndustryStanding" - return _card("Relative to industry", f"{head}{rows}
") + title = "Relative to industry" + if rel.peer_group_label: + title += f" — {escape(rel.peer_group_label)}" + aside = f"{rel.peer_count - 1} peers · {escape(rel.basis)}" + return _card(title, f"{head}{rows}
", aside=aside) def _buffett(r: AnalysisReport) -> str: @@ -332,12 +336,11 @@ def _score_class(total: float) -> str: return "bad" -def _metric(name: str, value: float | None) -> str: +def _metric(unit: str, value: float | None) -> str: + """Format from the metric's declared unit; guessing by name mis-renders new ratios.""" if value is None: return "—" - if name in {"P/E", "P/B", "Debt/Equity"}: - return f"{value:.1f}" - return f"{value:.1%}" + return f"{value:.1f}x" if unit == "ratio" else f"{value:.1%}" def _money(value: float | None, currency: str | None) -> str: diff --git a/src/investo/cli.py b/src/investo/cli.py index 25c2a26..fa09542 100644 --- a/src/investo/cli.py +++ b/src/investo/cli.py @@ -44,6 +44,10 @@ def _rule(title: str) -> str: return f"\n\033[1m{title}\033[0m\n" + "-" * max(len(title), 40) +def _dim(text: str) -> str: + return f"\033[2m{text}\033[0m" + + def _bar(normalized: float, width: int = 10) -> str: filled = int(round(normalized * width)) return "█" * filled + "·" * (width - filled) @@ -60,13 +64,15 @@ def _conf(confidence) -> str: return f"{confidence.score:.0%} {confidence.tier}" -def _metric(name: str, value: float | None) -> str: - """Format a relative-comparison value: ratios as x.x, everything else as a percent.""" +def _metric(unit: str, value: float | None) -> str: + """Format a relative-comparison value from its declared unit. + + The unit travels on the metric rather than being inferred from its name — guessing by name + silently renders any unrecognised ratio (EV/EBITDA, P/S) as a percentage. + """ if value is None: return "n/a" - if name in {"P/E", "P/B", "Debt/Equity"}: - return f"{value:.1f}" - return f"{value:.1%}" + return f"{value:.1f}x" if unit == "ratio" else f"{value:.1%}" # -------------------------------------------------------------------------------------- @@ -117,14 +123,22 @@ def render_report(r: AnalysisReport) -> str: # Relative to industry rel = r.relative if rel and rel.metrics: - out.append(_rule("RELATIVE TO INDUSTRY")) + title = "RELATIVE TO INDUSTRY" + if rel.peer_group_label: + title += f" — {rel.peer_group_label.upper()}" + out.append(_rule(title)) out.append(f" {'Metric':18}{'Company':>10}{'Industry':>10} Standing") for m in rel.metrics: band = "top quartile" if (m.percentile or 0) >= 0.75 else \ "above median" if (m.percentile or 0) >= 0.5 else \ "below median" if (m.percentile or 0) >= 0.25 else "bottom quartile" - out.append(f" {m.name:18}{_metric(m.name, m.company):>10}" - f"{_metric(m.name, m.industry):>10} {band}") + out.append(f" {m.name:18}{_metric(m.unit, m.company):>10}" + f"{_metric(m.unit, m.industry):>10} {band}") + # Say how the peer set was found — a guessed cohort must not read like a curated one. + out.append(f" {_dim(f'{rel.peer_count - 1} peers, basis: {rel.basis}')}") + elif rel and rel.note: + out.append(_rule("RELATIVE TO INDUSTRY")) + out.append(f" {_dim(rel.note)}") # Buffett checklist bf = r.buffett diff --git a/src/investo/data/__init__.py b/src/investo/data/__init__.py index b21d78e..71660d9 100644 --- a/src/investo/data/__init__.py +++ b/src/investo/data/__init__.py @@ -22,8 +22,20 @@ def _load_yaml(filename: str) -> dict[str, Any]: return {} +@cache def peer_groups() -> dict[str, Any]: - return _load_yaml("peers.yaml").get("groups", {}) + """Curated peer groups, each merged with the file-level ``meta`` provenance defaults. + + Insertion order is preserved and load-bearing: ``analysis.peers._group_for`` is + first-match-wins, and a few tickers sit in more than one group on purpose. + """ + data = _load_yaml("peers.yaml") + meta = data.get("meta", {}) + groups = data.get("groups", {}) + if not isinstance(meta, dict) or not isinstance(groups, dict): + return {} + # A group's own version/updated_at wins over the file-level default. + return {key: {**meta, **group} for key, group in groups.items() if isinstance(group, dict)} def industry_notes() -> dict[str, Any]: diff --git a/src/investo/data/peers.yaml b/src/investo/data/peers.yaml index 074c294..a72455f 100644 --- a/src/investo/data/peers.yaml +++ b/src/investo/data/peers.yaml @@ -1,12 +1,39 @@ # Curated peer groups, India-first (NSE symbols). Extend freely. -# Each group: label, outlook (high|medium|low), industry_cagr (display string), members. -# A company is matched to a group by membership; peers = the other members. +# +# Each group: label, outlook (high|medium|low), industry_cagr (display string), members, and +# provenance (version, updated_at, source). A group may also carry `keywords` and narrative +# overrides (sub_domains, demand_drivers, future_demand, risks) — see below. +# +# Matching, in order (see analysis/peers.py::resolve_peer_group): +# 1. exact membership -> basis "curated" +# 2. `keywords` vs Yahoo's industry/sector, for tickers in no group -> basis "sector-fallback" +# Keep `keywords` NARROW. A loose keyword that drags an unrelated company into a group and then +# reports it at curated-grade confidence is worse than reporting no peers at all. +# +# ORDERING IS SIGNIFICANT: _group_for is first-match-wins over this file's order, and some +# tickers are deliberately double-booked (EICHERMOT.NS in auto_oem + two_wheelers; RELIANCE.NS in +# oil_gas_energy + telecom). The first listed group wins. APPEND new groups at the END — inserting +# near the top silently reframes existing companies. tests/test_peers.py pins this. +# +# industry_cagr values are Investo's own curated estimates, not third-party forecasts; they are +# rendered with their `updated_at` so a reader can judge staleness. + +# File-level provenance, merged into every group by data/__init__.py::peer_groups(). A group that +# is revised on its own should override `updated_at` (and bump `version`) locally. +meta: + version: 1 + updated_at: "2026-07" + source: Investo curated + groups: it_services: label: IT Services outlook: high industry_cagr: "~6-8% global IT services spend (est.)" - members: [TCS.NS, INFY.NS, WIPRO.NS, HCLTECH.NS, TECHM.NS, LTIM.NS, MPHASIS.NS, PERSISTENT.NS, COFORGE.NS] + keywords: [information technology services] + # LTIM.NS removed 2026-07: unresolvable at the provider across .NS and .BO (corporate + # action?). Re-add under its current symbol once confirmed — see scripts/validate_peers.py. + members: [TCS.NS, INFY.NS, WIPRO.NS, HCLTECH.NS, TECHM.NS, MPHASIS.NS, PERSISTENT.NS, COFORGE.NS] private_banks: label: Private Banks @@ -30,7 +57,9 @@ groups: label: Automobiles (OEM) outlook: medium industry_cagr: "~8-10% volume + EV shift (est.)" - members: [MARUTI.NS, M&M.NS, TATAMOTORS.NS, TMCV.NS, TMPV.NS, ASHOKLEY.NS, EICHERMOT.NS] + # TATAMOTORS.NS removed 2026-07: the demerger replaced it with TMCV.NS (commercial) and + # TMPV.NS (passenger), both already listed here. + members: [MARUTI.NS, M&M.NS, TMCV.NS, TMPV.NS, ASHOKLEY.NS, EICHERMOT.NS] two_wheelers: label: Two-Wheelers @@ -72,7 +101,9 @@ groups: label: Paints outlook: medium industry_cagr: "~9-11% (est.)" - members: [ASIANPAINT.NS, BERGEPAINT.NS, KANSAINER.NS, AKZOINDIA.NS] + # AKZOINDIA.NS removed 2026-07: unresolvable at the provider across .NS and .BO (corporate + # action?). Re-add under its current symbol once confirmed. + members: [ASIANPAINT.NS, BERGEPAINT.NS, KANSAINER.NS, INDIGOPNTS.NS] telecom: label: Telecom @@ -120,10 +151,66 @@ groups: label: Aviation outlook: medium industry_cagr: "~10-12% (traffic growth, est.)" - members: [INDIGO.NS, SPICEJET.NS] + # SpiceJet resolves on BSE only; its NSE line is gone from the provider. + members: [INDIGO.NS, SPICEJET.BO] us_bigtech: label: US Big Tech outlook: high industry_cagr: "~10-12% (cloud + AI, est.)" members: [AAPL, MSFT, GOOGL, AMZN, META, NVDA] + + # --- appended 2026-07; see the ordering note at the top of this file --- + + # Automotive ER&D is NOT generic IT services: these firms sell engineering hours against OEM + # R&D budgets, so they track the auto cycle and SDV/EV programme spend, not enterprise IT + # discretionary spend. The narrative overrides below reframe them accordingly. + auto_erd: + label: Automotive ER&D + outlook: high + industry_cagr: "~12-15% (SDV, EV & ADAS engineering spend; est.)" + keywords: [automotive engineering, engineering r&d, embedded software] + sub_domains: + - Software-defined vehicles (SDV) + - ADAS & autonomous driving + - EV powertrain & battery management + - Vehicle electronics & middleware + - Digital cockpit & infotainment + demand_drivers: + - OEM transition to software-defined vehicles + - EV platform re-architecture programmes + - Rising ADAS content per vehicle + - OEMs externalising non-differentiating R&D + - Regulatory safety & emissions mandates + future_demand: >- + Structural rather than cyclical: as vehicles become software-defined, OEMs are + externalising engineering they cannot staff in-house. The risk is programme timing, + not the direction of travel. + risks: + - OEM R&D budget cuts or programme deferrals + - Client concentration (a few large OEMs) + - Wage inflation and engineer attrition + - USD/EUR-INR currency swings + - Pace of EV adoption slowing + members: [KPITTECH.NS, TATAELXSI.NS, TATATECH.NS, LTTS.NS, CYIENT.NS] + + auto_components: + label: Auto Components + outlook: medium + industry_cagr: "~9-11% (content per vehicle + exports, est.)" + keywords: [auto parts, auto components] + members: [BOSCHLTD.NS, MOTHERSON.NS, BHARATFORG.NS, SONACOMS.NS, UNOMINDA.NS, ENDURANCE.NS, EXIDEIND.NS] + + hospitals_diagnostics: + label: Hospitals & Diagnostics + outlook: high + industry_cagr: "~12-14% (insurance penetration + capacity, est.)" + keywords: [medical care facilities, diagnostics & research] + members: [APOLLOHOSP.NS, MAXHEALTH.NS, FORTIS.NS, NH.NS, LALPATHLAB.NS, METROPOLIS.NS] + + capital_goods_defence: + label: Capital Goods & Defence + outlook: high + industry_cagr: "~13-16% (capex cycle + indigenisation, est.)" + keywords: [aerospace & defense, specialty industrial machinery] + members: [LT.NS, SIEMENS.NS, ABB.NS, BEL.NS, HAL.NS, BHEL.NS, CUMMINSIND.NS] diff --git a/src/investo/models.py b/src/investo/models.py index e20af1c..796c001 100644 --- a/src/investo/models.py +++ b/src/investo/models.py @@ -164,6 +164,11 @@ class Ratios(_Base): # -------------------------------------------------------------------------------------- # Peers # -------------------------------------------------------------------------------------- +# How a peer set was arrived at. Drives how much to trust anything derived from it: a curated +# group is a deliberate judgement, a sector fallback is an educated guess, "none" means no set. +PeerBasis = Literal["curated", "sector-fallback", "keyed", "none"] + + class PeerRow(_Base): ticker: str name: str | None = None @@ -174,6 +179,9 @@ class PeerRow(_Base): pe: float | None = None pb: float | None = None roe: float | None = None + roa: float | None = None + ev_ebitda: float | None = None + price_to_sales: float | None = None revenue_growth_yoy: float | None = None debt_to_equity: float | None = None market_share_proxy: float | None = None # revenue / sum(revenue) within peer set @@ -184,6 +192,10 @@ class PeerComparison(_Base): sector: str | None = None peers: list[PeerRow] = Field(default_factory=list) summary: list[str] = Field(default_factory=list) # grounded observations + basis: PeerBasis = "none" + peer_group_key: str | None = None + peer_group_label: str | None = None + evidence: EvidenceMeta | None = None note: str | None = None @@ -193,11 +205,14 @@ class PeerComparison(_Base): class IndustryIntelligence(_Base): ticker: str sector: str | None = None - industry: str | None = None + industry: str | None = None # Yahoo's raw label, kept verbatim — it is a fact, not a judgement + peer_group: str | None = None # Investo's framing, e.g. "Automotive ER&D" + basis: PeerBasis = "none" sub_domains: list[str] = Field(default_factory=list) demand_drivers: list[str] = Field(default_factory=list) future_demand: str | None = None industry_cagr: str | None = None # curated string e.g. "~10-12% (FY24-30, est.)" + as_of: str | None = None # when the curated framing was last reviewed risks: list[str] = Field(default_factory=list) source: Literal["curated", "keyed", "unknown"] = "curated" note: str | None = None @@ -315,6 +330,10 @@ class Score(_Base): # -------------------------------------------------------------------------------------- # Relative-to-industry comparison # -------------------------------------------------------------------------------------- +# How to render a metric's value. Carried on the metric so renderers stop guessing from its name. +MetricUnit = Literal["ratio", "percent"] + + class RelativeMetric(_Base): name: str company: float | None = None @@ -323,6 +342,7 @@ class RelativeMetric(_Base): better: bool | None = None # is the company on the favourable side of the median? delta: float | None = None # company - industry higher_is_better: bool = True + unit: MetricUnit = "percent" provenance: Provenance | None = None @@ -331,6 +351,8 @@ class RelativeComparison(_Base): metrics: list[RelativeMetric] = Field(default_factory=list) peer_count: int = 0 summary: list[str] = Field(default_factory=list) + basis: PeerBasis = "none" + peer_group_label: str | None = None evidence: EvidenceMeta | None = None note: str | None = None diff --git a/tests/test_evidence.py b/tests/test_evidence.py index 31d6d53..ff25e70 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -61,3 +61,86 @@ def test_aggregate_blends_modules(): def test_aggregate_empty_is_safe(): agg = ev.aggregate([None, EvidenceMeta()]) assert agg.confidence is not None + + +# -------------------------------------------------------------------------------------- +# Zero coverage means zero. The 0.4 coverage floor exists so a couple of gaps don't collapse +# a score; applying it to a module that computed *nothing* manufactured a plausible 0.37 out +# of no data at all. +# -------------------------------------------------------------------------------------- +def test_zero_coverage_earns_no_confidence(): + c = ev.confidence(sources=[Provenance(source=ev.SRC_YAHOO)], coverage=0.0) + assert c.score == 0.0 # was 0.4 * 0.80 = 0.32 + assert c.tier == "Low" + + +def test_zero_coverage_gets_no_corroboration_bonus(): + # Two sources cannot agree on a figure that was never computed. + c = ev.confidence( + sources=[Provenance(source=ev.SRC_YAHOO), Provenance(source=ev.SRC_CURATED)], + coverage=0.0) + assert c.score == 0.0 # was 0.32 + 0.05 = 0.37 + assert "cross-source agreement" not in (c.reason or "") + + +def test_partial_coverage_still_gets_the_softening_floor(): + # The floor must survive for real-but-incomplete data; only zero is special. + c = ev.confidence(sources=[Provenance(source=ev.SRC_YAHOO)], coverage=0.1) + assert c.score > 0.3 + + +def test_missing_coverage_is_still_neutral(): + # None means "not applicable", not "nothing found" — point-in-time modules must not be hit. + assert ev.confidence(sources=[Provenance(source=ev.SRC_YAHOO)], coverage=None).score == 0.80 + + +def test_reliability_factor_discounts_the_score(): + full = ev.confidence(sources=[Provenance(source=ev.SRC_YAHOO)], coverage=1.0) + half = ev.confidence(sources=[Provenance(source=ev.SRC_YAHOO)], coverage=1.0, + reliability_factor=0.5) + assert half.score == round(full.score * 0.5, 3) + + +def test_reliability_factor_defaults_to_no_discount(): + a = ev.confidence(sources=[Provenance(source=ev.SRC_YAHOO)], coverage=1.0) + b = ev.confidence(sources=[Provenance(source=ev.SRC_YAHOO)], coverage=1.0, + reliability_factor=None) + assert a.score == b.score + + +def test_build_meta_passes_reliability_through(): + meta = ev.build_meta(sources=[Provenance(source=ev.SRC_YAHOO)], present=2, expected=2, + reliability_factor=0.5) + assert meta.confidence.score < 0.5 + + +# -------------------------------------------------------------------------------------- +# Aggregation weights by coverage, so an empty module neither drags the report down nor +# props it up. +# -------------------------------------------------------------------------------------- +def test_aggregate_ignores_a_module_that_found_nothing(): + real = ev.build_meta(sources=[Provenance(source=ev.SRC_STATEMENTS)], present=2, expected=2) + empty = ev.build_meta(sources=[Provenance(source=ev.SRC_YAHOO)], present=0, expected=7) + agg = ev.aggregate([real, empty]) + assert agg.confidence.score == real.confidence.score # the empty module carried no weight + + +def test_aggregate_says_when_modules_came_back_empty(): + real = ev.build_meta(sources=[Provenance(source=ev.SRC_STATEMENTS)], present=2, expected=2) + empty = ev.build_meta(sources=[Provenance(source=ev.SRC_YAHOO)], present=0, expected=7) + agg = ev.aggregate([real, empty]) + # A report built on one of two modules must not read like a report built on two. + assert any("found no data" in n for n in agg.notes) + + +def test_aggregate_of_only_empty_modules_is_zero_not_a_default(): + empty = ev.build_meta(sources=[Provenance(source=ev.SRC_YAHOO)], present=0, expected=7) + agg = ev.aggregate([empty, empty]) + assert agg.confidence.score == 0.0 + + +def test_aggregate_weights_point_in_time_modules_fully(): + # coverage=None modules have no coverage to weight by; they must not be dropped. + pit = ev.build_meta(sources=[Provenance(source=ev.SRC_YAHOO)]) + agg = ev.aggregate([pit]) + assert agg.confidence.score == pit.confidence.score diff --git a/tests/test_industry.py b/tests/test_industry.py new file mode 100644 index 0000000..212dd05 --- /dev/null +++ b/tests/test_industry.py @@ -0,0 +1,80 @@ +"""Industry-framing tests (no network). + +Yahoo calls KPIT "Technology / Information Technology Services", which is true but useless: it +frames an automotive ER&D firm as an IT outsourcer and points the whole analysis at the wrong +demand drivers, the wrong CAGR and the wrong risks. +""" + +import pytest + +from investo.analysis.industry import get_industry_intelligence, industry_outlook +from investo.sources import data + + +@pytest.fixture(autouse=True) +def _no_network(monkeypatch): + monkeypatch.setattr(data, "get_info", lambda symbol: {}) + + +def _info(monkeypatch, **fields): + monkeypatch.setattr(data, "get_info", lambda symbol: dict(fields)) + + +def test_curated_group_reframes_yahoos_generic_classification(monkeypatch): + _info(monkeypatch, sector="Technology", industry="Information Technology Services") + intel = get_industry_intelligence("KPITTECH.NS") + + assert intel.peer_group == "Automotive ER&D" + assert intel.basis == "curated" + joined = " ".join(intel.sub_domains).lower() + assert "software-defined" in joined + assert "outsourcing" not in joined # the generic Technology framing must not win + + +def test_yahoos_raw_industry_string_is_preserved_not_overwritten(monkeypatch): + # It's a fact about how the exchange classifies the company. Hiding the disagreement + # would be worse than showing it. + _info(monkeypatch, sector="Technology", industry="Information Technology Services") + intel = get_industry_intelligence("KPITTECH.NS") + assert intel.industry == "Information Technology Services" + assert intel.sector == "Technology" + + +def test_group_drives_drivers_risks_and_cagr(monkeypatch): + _info(monkeypatch, sector="Technology", industry="Information Technology Services") + intel = get_industry_intelligence("KPITTECH.NS") + assert any("software-defined" in d.lower() for d in intel.demand_drivers) + assert any("oem" in r.lower() for r in intel.risks) + assert "SDV" in (intel.industry_cagr or "") + assert intel.as_of # curated framing is dated so a reader can judge staleness + + +def test_company_without_a_group_keeps_the_sector_framing(monkeypatch): + # TCS really is an IT services company; the sector note is right for it. + _info(monkeypatch, sector="Technology", industry="Information Technology Services") + intel = get_industry_intelligence("TCS.NS") + assert intel.peer_group == "IT Services" + assert any("IT services" in d for d in intel.sub_domains) + + +def test_sector_fallback_says_it_is_a_guess(monkeypatch): + _info(monkeypatch, sector="Consumer Cyclical", industry="Auto Parts") + intel = get_industry_intelligence("SOMENEWCO.NS") + assert intel.basis == "sector-fallback" + assert intel.peer_group == "Auto Components" + assert intel.note and "Indicative only" in intel.note + + +def test_unknown_sector_and_no_group_admits_it(monkeypatch): + _info(monkeypatch, sector="Llama Farming", industry="Llama Farming") + intel = get_industry_intelligence("LLAMA.NS") + assert intel.basis == "none" + assert intel.source == "unknown" + assert intel.note and "No curated intelligence" in intel.note + + +def test_industry_outlook_prefers_the_group_over_the_sector(monkeypatch): + _info(monkeypatch, sector="Technology", industry="Information Technology Services") + outlook, cagr = industry_outlook("KPITTECH.NS") + assert outlook == "high" + assert "SDV" in (cagr or "") # the auto_erd CAGR, not the generic Technology one diff --git a/tests/test_peers.py b/tests/test_peers.py index 71bb5e4..866dd34 100644 --- a/tests/test_peers.py +++ b/tests/test_peers.py @@ -1,7 +1,21 @@ -"""Peer-map tests (no network).""" +"""Peer-map and peer-resolution tests (no network).""" -from investo.analysis.peers import _group_for, get_peers +import pytest + +from investo.analysis.peers import _group_for, get_peers, resolve_peer_group from investo.data import industry_notes, peer_groups +from investo.sources import data + + +@pytest.fixture(autouse=True) +def _no_network(monkeypatch): + """Resolution falls back to Yahoo's industry/sector when a ticker is in no curated group. + Default that to empty so no test reaches the network unless it opts in via `_info`.""" + monkeypatch.setattr(data, "get_info", lambda symbol: {}) + + +def _info(monkeypatch, **fields): + monkeypatch.setattr(data, "get_info", lambda symbol: dict(fields)) def test_infosys_in_it_services_group(): @@ -33,3 +47,91 @@ def test_curated_data_loads(): for key, g in peer_groups().items(): assert g.get("members"), f"{key} has no members" assert g.get("outlook") in {"high", "medium", "low"}, f"{key} outlook invalid" + + +# -------------------------------------------------------------------------------------- +# The reported bug: KPIT and its ER&D cohort were in no group at all. +# -------------------------------------------------------------------------------------- +def test_kpit_resolves_to_automotive_erd_not_it_services(): + res = resolve_peer_group("KPITTECH.NS") + assert res.basis == "curated" + assert res.key == "auto_erd" + assert res.label == "Automotive ER&D" + upper = [p.upper() for p in res.peers] + assert "KPITTECH.NS" not in upper # self excluded + assert {"TATAELXSI.NS", "TATATECH.NS", "LTTS.NS", "CYIENT.NS"} == set(upper) + + +def test_erd_cohort_all_resolve_to_each_other(): + # The market treats these as one cohort; so must we. + for sym in ("TATAELXSI.NS", "TATATECH.NS", "LTTS.NS", "CYIENT.NS"): + assert resolve_peer_group(sym).key == "auto_erd" + + +# -------------------------------------------------------------------------------------- +# The resolution ladder +# -------------------------------------------------------------------------------------- +def test_keyword_fallback_finds_a_group_for_an_unlisted_ticker(monkeypatch): + _info(monkeypatch, industry="Auto Parts", sector="Consumer Cyclical") + res = resolve_peer_group("SOMENEWCO.NS") + assert res.basis == "sector-fallback" + assert res.key == "auto_components" + assert res.peers # a usable set, flagged as a guess + + +def test_keyword_fallback_prefers_industry_over_sector(monkeypatch): + # Sector alone is too coarse to frame a company; the finer industry label wins. + _info(monkeypatch, industry="Auto Parts", sector="Technology") + assert resolve_peer_group("SOMENEWCO.NS").key == "auto_components" + + +def test_no_keyword_match_reports_none_not_a_guess(monkeypatch): + _info(monkeypatch, industry="Llama Farming", sector="Agriculture") + res = resolve_peer_group("LLAMA.NS") + assert res.basis == "none" + assert res.peers == [] + assert res.group is None + + +def test_curated_membership_beats_keyword_match(monkeypatch): + # KPIT's Yahoo industry is generic IT; membership must win so it is never re-framed + # as an IT services company by the fallback. + _info(monkeypatch, industry="Information Technology Services", sector="Technology") + assert resolve_peer_group("KPITTECH.NS").key == "auto_erd" + + +def test_keyword_resolution_is_deterministic(monkeypatch): + _info(monkeypatch, industry="Auto Parts", sector="Consumer Cyclical") + keys = {resolve_peer_group("SOMENEWCO.NS").key for _ in range(5)} + assert len(keys) == 1 # an unstable peer group would be worse than none + + +# -------------------------------------------------------------------------------------- +# peers.yaml invariants +# -------------------------------------------------------------------------------------- +def test_first_match_wins_order_is_pinned(): + # Several tickers sit in more than one group on purpose; peers.yaml order decides which + # wins. New groups must be appended, never inserted, or these silently re-frame. + assert _group_for("RELIANCE.NS")[0] == "oil_gas_energy" # not telecom + assert _group_for("EICHERMOT.NS")[0] == "auto_oem" # not two_wheelers + + +def test_every_group_carries_provenance(): + for key, g in peer_groups().items(): + assert g.get("version"), f"{key} has no version" + assert g.get("updated_at"), f"{key} has no updated_at" + assert g.get("source"), f"{key} has no source" + + +def test_keywords_are_lowercase_for_case_insensitive_matching(): + for key, g in peer_groups().items(): + for kw in g.get("keywords", []): + assert kw == kw.lower(), f"{key} keyword {kw!r} is not lowercase" + + +def test_auto_erd_reframes_the_narrative(): + g = peer_groups()["auto_erd"] + joined = " ".join(g["sub_domains"]).lower() + assert "software-defined" in joined + assert "adas" in joined + assert "outsourcing" not in joined # this is not an IT services group diff --git a/tests/test_relative.py b/tests/test_relative.py index 183f8bd..fd3d7de 100644 --- a/tests/test_relative.py +++ b/tests/test_relative.py @@ -1,11 +1,11 @@ """Relative-to-industry tests (no network).""" -from investo.analysis.relative import relative_comparison +from investo.analysis.relative import _METRIC_SPECS, relative_comparison from investo.models import PeerComparison, PeerRow, Ratios -def _peers() -> PeerComparison: - return PeerComparison(ticker="SUB.NS", peers=[ +def _peers(basis="curated", label="Test Group") -> PeerComparison: + return PeerComparison(ticker="SUB.NS", basis=basis, peer_group_label=label, peers=[ PeerRow(ticker="SUB.NS", roe=0.30, net_margin=0.20, pe=25.0, pb=5.0, debt_to_equity=0.2, revenue_growth_yoy=0.20, operating_margin=0.25), PeerRow(ticker="A.NS", roe=0.10, net_margin=0.08, pe=10.0, pb=1.0, debt_to_equity=0.6, @@ -59,3 +59,103 @@ def test_insufficient_peers_returns_note(): rc = relative_comparison("SUB.NS", PeerComparison(ticker="SUB.NS", peers=[])) assert rc.metrics == [] assert rc.note is not None + + +# -------------------------------------------------------------------------------------- +# The reported bug. KPIT matched no peer group, so this module computed nothing — and then +# reported 0.37 confidence claiming "cross-source agreement" over zero rows. +# -------------------------------------------------------------------------------------- +def test_no_peer_group_reports_zero_confidence_not_thirty_seven(): + rc = relative_comparison("KPITTECH.NS", PeerComparison(ticker="KPITTECH.NS", peers=[], basis="none")) + assert rc.metrics == [] + assert rc.evidence.data_coverage == 0.0 + assert rc.evidence.confidence.score < 0.10 # was 0.37 + assert "cross-source agreement" not in (rc.evidence.confidence.reason or "") + assert "no peer group matched" in rc.evidence.confidence.reason + + +def test_no_peers_does_not_claim_a_one_name_peer_set(): + rc = relative_comparison("KPITTECH.NS", PeerComparison(ticker="KPITTECH.NS", peers=[], basis="none")) + assert rc.peer_count == 0 # not 1: the company alone is not a peer set + joined = " ".join(rc.evidence.notes) + assert "peer set" not in joined # no rank-within-set claim when there is no set + assert "1-peer" not in joined + + +# -------------------------------------------------------------------------------------- +# Basis drives confidence: a guessed peer set must never read like a deliberate one. +# -------------------------------------------------------------------------------------- +def test_sector_fallback_scores_strictly_below_curated_on_identical_data(): + curated = relative_comparison("SUB.NS", _peers(basis="curated")) + guessed = relative_comparison("SUB.NS", _peers(basis="sector-fallback")) + assert guessed.evidence.confidence.score < curated.evidence.confidence.score + assert guessed.basis == "sector-fallback" + + +def test_curated_never_reaches_the_high_tier(): + # A rank among a handful of hand-picked names is not a market percentile, however + # complete the underlying data. + rc = relative_comparison("SUB.NS", _peers()) + assert rc.evidence.confidence.score < 0.80 + assert rc.evidence.confidence.tier != "High" + + +def test_thin_peer_set_scores_below_a_full_one(): + thin = _peers() + thin.peers = thin.peers[:3] # subject + 2 peers, the bare minimum for a median + a = relative_comparison("SUB.NS", thin) + b = relative_comparison("SUB.NS", _peers()) + assert a.evidence.confidence.score < b.evidence.confidence.score + + +def test_peer_group_label_travels_to_the_comparison(): + rc = relative_comparison("SUB.NS", _peers(label="Automotive ER&D")) + assert rc.peer_group_label == "Automotive ER&D" + assert "Automotive ER&D" in " ".join(rc.evidence.notes) + + +# -------------------------------------------------------------------------------------- +# Coverage counts what the peer set can rank on, not every metric we know how to compute. +# -------------------------------------------------------------------------------------- +def test_metrics_peers_never_report_do_not_dent_coverage(): + # None of these peers report EV/EBITDA, P/S or ROA. That is a gap in the market data, not + # a gap in the company — so coverage must stay at 1.0 rather than falling to 7/10. + rc = relative_comparison("SUB.NS", _peers()) + assert rc.evidence.data_coverage == 1.0 + assert len(rc.metrics) == 7 + assert "EV/EBITDA" not in rc.evidence.missing_fields + assert "No peer data reported for" in " ".join(rc.evidence.notes) + + +def test_company_missing_a_metric_peers_have_does_dent_coverage(): + peers = _peers() + peers.peers[0].pe = None # the subject alone lacks P/E; peers have it + rc = relative_comparison("SUB.NS", peers) + assert "P/E" in rc.evidence.missing_fields + assert rc.evidence.data_coverage < 1.0 + + +def test_new_metrics_compute_when_peers_report_them(): + peers = _peers() + for row, ev_, ps, roa in ((peers.peers[0], 30.0, 5.5, 0.18), + (peers.peers[1], 12.0, 1.5, 0.06), + (peers.peers[2], 14.0, 1.8, 0.07), + (peers.peers[3], 16.0, 2.0, 0.08)): + row.ev_ebitda, row.price_to_sales, row.roa = ev_, ps, roa + rc = relative_comparison("SUB.NS", peers) + assert {"EV/EBITDA", "P/S", "ROA"} <= {m.name for m in rc.metrics} + assert len(rc.metrics) == 10 + # Expensive on EV/EBITDA -> unfavourable, since lower is better. + assert next(m for m in rc.metrics if m.name == "EV/EBITDA").better is False + # High ROA -> favourable. + assert next(m for m in rc.metrics if m.name == "ROA").better is True + + +def test_units_are_declared_so_renderers_need_not_guess_from_the_name(): + by_name = {name: unit for name, _, _, unit in _METRIC_SPECS} + assert by_name["EV/EBITDA"] == "ratio" # would render as 3000% if guessed by name + assert by_name["P/S"] == "ratio" + assert by_name["ROE"] == "percent" + rc = relative_comparison("SUB.NS", _peers()) + assert next(m for m in rc.metrics if m.name == "P/E").unit == "ratio" + assert next(m for m in rc.metrics if m.name == "ROE").unit == "percent"