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"