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"