diff --git a/.env.example b/.env.example
index 6db6f16..29aebdc 100644
--- a/.env.example
+++ b/.env.example
@@ -32,3 +32,8 @@ INVESTO_LOG_LEVEL=WARNING
# Rate limiting (optional)
INVESTO_RATE_MIN_INTERVAL=0.0 # min seconds between Yahoo calls (0 = off)
INVESTO_AV_DAILY_CAP=25 # Alpha Vantage daily request cap (free tier), then falls back to Yahoo
+
+# India shareholding source: fetch NSE/BSE quarterly filings for the shareholding pattern.
+# Set to false to force the Yahoo insider/institutional fallback (e.g. offline). Optional.
+INVESTO_ENABLE_INDIA_HOLDINGS=true
+INVESTO_INDIA_HOLDINGS_MIN_INTERVAL=1.0 # polite gap between NSE/BSE calls
diff --git a/README.md b/README.md
index 3cd1b18..883a802 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,8 @@
**An AI investment-analysis agent you run from Claude or Cursor.**
+> ⚠️ **Research and education only — not investment advice.**
+
Give Investo a company name — Indian (NSE/BSE) or global — and it gathers public financial
data and produces a full analysis: what the company does, its financials & ratios, a
competitor comparison, DCF intrinsic value, economic moat, risks, management, recent news,
@@ -38,6 +40,20 @@ For any company, Investo supplies the evidence for:
8. **Economic moat** — brand / network / cost / scale / switching-cost signals.
9. **Risk analysis** — debt, currency, concentration, regulation, tech obsolescence.
10. **Rating out of 100** — a balanced 11-bucket score with per-bucket rationale.
+11. **Warren Buffett checklist** — a weighted 0–100 quality-fit score; each criterion (ROE, ROIC,
+ debt, owner earnings, margin of safety, management, moat) shows value vs threshold, a
+ pass/warn/fail with the *reason*, a confidence, and its multi-year trend.
+12. **Relative to industry** — key metrics vs the peer-set median with favourable-side percentiles.
+13. **Shareholding pattern** — promoter/FII/DII/public split + promoter pledge, with
+ quarter-over-quarter smart observations and an ownership signal (NSE/BSE filings; Yahoo fallback).
+14. **5-year growth engine** — the primary engine plus ranked drivers (estimated contribution %,
+ per-driver risks), a catalyst timeline, and a blended growth band.
+15. **Fundamentals trend, red-flags, and an investment thesis** — multi-year health at a glance,
+ automated deterioration warnings, and a synthesized pros/cons verdict.
+
+Every section carries a **confidence score, provenance and reasoning** (the evidence layer), so an
+AI agent — or you — can judge how far to trust each conclusion. A machine-readable `ai_signals`
+digest and a self-contained **HTML one-pager** (`--html`) are available too.
### Rating buckets (out of 100)
@@ -74,6 +90,7 @@ investo analyze "Infosys"
investo analyze "Reliance Industries"
investo analyze "Tata Motors"
investo analyze AAPL
+investo analyze "Reliance Industries" --html reliance.html # self-contained analyst one-pager
investo search "tata motors"
```
@@ -140,7 +157,15 @@ of Reliance?"*
| `moat_assessment` | Economic-moat signals + heuristic score |
| `risk_assessment` | Risk signals + heuristic score |
| `score_company` | 0–100 composite rating |
-| `analyze_company` | Everything above bundled into one report |
+| `buffett_checklist` | Warren-Buffett quality checklist: weighted 0–100 fit, per-criterion pass/warn/fail + reason, confidence & multi-year trend |
+| `relative_metrics` | Key metrics vs the peer-set median (industry proxy) with favourable-side percentiles |
+| `shareholding_pattern` | Promoter/FII/DII/public split + pledge, QoQ smart observations & ownership signal (NSE/BSE filings, Yahoo fallback) |
+| `growth_outlook` | 5-year growth engine: ranked drivers (contribution %, risks), catalyst timeline, blended growth band |
+| `fundamental_trend` | Multi-year revenue/profit/margin/EPS/ROE with per-year direction & health grade |
+| `red_flags` | Automated deterioration warnings + overall risk level |
+| `investment_thesis` | Synthesized pros/cons, quality grade, valuation stance & one-line verdict |
+| `ai_signals` | Compact machine-readable digest (thesis, quality, confidence, ownership/growth signals, risk, valuation) |
+| `analyze_company` | Everything above bundled into one report (with a confidence/provenance evidence layer) |
| `get_sec_facts` | SEC EDGAR cross-check (US/ADR only) |
---
@@ -156,6 +181,7 @@ All optional — set as environment variables (or in `.env`; see `.env.example`)
| `INVESTO_RATE_MIN_INTERVAL` | Min seconds between Yahoo calls | `0.0` |
| `INVESTO_AV_DAILY_CAP` | Alpha Vantage daily cap before Yahoo fallback | `25` |
| `INVESTO_SEC_CONTACT` | Contact for the SEC EDGAR User-Agent | repo URL |
+| `INVESTO_ENABLE_INDIA_HOLDINGS` | Fetch NSE/BSE shareholding filings (else Yahoo fallback) | `true` |
| `INVESTO_DEFAULT_MARKET` | `IN` or `US` | `IN` |
| `INVESTO_DCF_*` | DCF discount / terminal / years overrides | see `.env.example` |
diff --git a/manifest.json b/manifest.json
index 622cd51..f6e4c4d 100644
--- a/manifest.json
+++ b/manifest.json
@@ -64,6 +64,14 @@
{ "name": "moat_assessment", "description": "Economic-moat signals + score" },
{ "name": "risk_assessment", "description": "Risk signals + safety score" },
{ "name": "score_company", "description": "0-100 composite rating" },
+ { "name": "buffett_checklist", "description": "Weighted Buffett quality checklist (0-100) with reasons, confidence & trend" },
+ { "name": "relative_metrics", "description": "Metrics vs peer-set median + percentiles" },
+ { "name": "shareholding_pattern", "description": "Promoter/FII/DII/public split + pledge, QoQ observations & signal" },
+ { "name": "growth_outlook", "description": "5-year growth engine: ranked drivers, catalysts, blended band" },
+ { "name": "fundamental_trend", "description": "Multi-year revenue/profit/margin/EPS/ROE trend + health" },
+ { "name": "red_flags", "description": "Automated deterioration warnings + risk level" },
+ { "name": "investment_thesis", "description": "Pros/cons, quality grade, valuation stance & verdict" },
+ { "name": "ai_signals", "description": "Compact machine-readable analysis digest" },
{ "name": "get_sec_facts", "description": "SEC EDGAR cross-check (US/ADR)" },
{ "name": "provider_status", "description": "Active data providers + disclosure" }
],
diff --git a/src/investo/analysis/report.py b/src/investo/analysis/report.py
index a698d7f..17bc90f 100644
--- a/src/investo/analysis/report.py
+++ b/src/investo/analysis/report.py
@@ -17,24 +17,55 @@
from ..resolve import resolve
from ..sources import data
from ..sources.news import get_news
+from . import evidence as ev
+from .buffett import buffett_checklist
from .dcf import compute_dcf
+from .growth import growth_outlook
from .industry import get_industry_intelligence, industry_outlook
from .management import get_management
from .moat import moat_assessment
+from .ownership import shareholding_pattern
from .peers import compare_peers
from .ratios import compute_ratios
+from .redflags import detect_red_flags
+from .relative import relative_comparison
from .risk import risk_assessment
from .scoring import compute_score
+from .thesis import build_ai_signals, build_thesis
+from .trends import fundamental_trend
_log = logging.getLogger("investo.analysis.report")
_LLM_GUIDANCE = (
- "You are Investo. Using ONLY the structured evidence in this report (do not invent "
- "numbers), write: (1) what the company does and its sector/sub-domains; (2) a competitor "
- "comparison from `peers`; (3) a SWOT built from `swot_seeds`; (4) advantages and "
- "disadvantages from `signals`; (5) growth drivers from `growth_driver_hints`; (6) key "
- "risks from `risk`; then (7) present the rating `score.total`/100 with its bucket table "
- "and the DCF. Close with a one-line reminder that this is research, not investment advice."
+ "You are Investo. Produce a PROFESSIONAL, ANALYST-GRADE report in clean, well-formatted "
+ "Markdown using ONLY the structured evidence here — never invent numbers. Use headed "
+ "sections, tables, and ✓/⚠/✗ markers; keep it scannable. Order:\n"
+ "1. HEADER: name, ticker, price, market cap, 52w range.\n"
+ "2. INVESTMENT THESIS (lead with `thesis`): the one-line `verdict`, `summary`, then a "
+ "Pros vs Cons table from `thesis.pros`/`thesis.cons`. Show `thesis.quality` and "
+ "`thesis.valuation_stance`.\n"
+ "3. RATING: `score.total`/100 (`score.verdict`) with the bucket table.\n"
+ "4. RELATIVE TO INDUSTRY (`relative`): a table of company vs industry(median) + the "
+ "percentile band for each metric.\n"
+ "5. WARREN BUFFETT CHECKLIST (`buffett`): the weighted `weighted_score`/100 and `verdict`, "
+ "then a table of each criterion — status (✓ pass / ⚠ warn / ✗ fail / — unknown), the "
+ "`reason`, the `confidence.tier`, and the `trend_verdict` where present.\n"
+ "6. SHAREHOLDING (`shareholding`): the latest promoter/FII/DII/public split and pledge, the "
+ "quarter-over-quarter `observations`, and the `ownership_signal`; note the source (exchange "
+ "filing vs Yahoo snapshot).\n"
+ "7. GROWTH ENGINE — NEXT 5 YEARS (`growth_outlook`): the `primary_engine`, a ranked table of "
+ "`drivers` (name, ~contribution %, confidence, key risks), the `catalysts` timeline "
+ "(year → event), the blended 5y band and `growth_signal`.\n"
+ "8. FUNDAMENTALS TREND (`fundamental_trend`): a compact table per metric with the ⬆/➡/⬇ "
+ "`directions` and `health` grade, and the `overall_health`.\n"
+ "9. RED FLAGS (`red_flags`): the `risk_level` and each flag with its severity; say so "
+ "explicitly if none.\n"
+ "10. WHAT IT DOES, competitor comparison (`peers`), SWOT (`swot_seeds`), key risks (`risk`), "
+ "and the DCF (respect any low-confidence note).\n"
+ "11. ANALYSIS QUALITY FOOTER (`evidence`): overall confidence (score + tier), data coverage, "
+ "source count, latest data date (`as_of`), and any `missing_fields`.\n"
+ "Surface confidence and provenance wherever the evidence provides them so the reader can "
+ "judge reliability. Close with one line: research/education only, not investment advice."
)
@@ -86,6 +117,22 @@ def _build_growth_hints(ratios, industry, news) -> list[str]:
return hints
+def _growth_hints_from_outlook(growth, ratios, industry, news) -> list[str]:
+ """Prefer the ranked growth-engine drivers; fall back to the legacy hint builder."""
+ hints: list[str] = []
+ if growth is not None and growth.primary_engine:
+ hints.append(f"Primary engine: {growth.primary_engine}")
+ if growth is not None and growth.drivers:
+ for d in growth.drivers[:4]:
+ share = f" (~{d.contribution_pct:.0%})" if d.contribution_pct is not None else ""
+ hints.append(f"{d.name}{share}")
+ # Always include the news/CAGR-derived hints so nothing is lost.
+ for h in _build_growth_hints(ratios, industry, news):
+ if h not in hints:
+ hints.append(h)
+ return hints
+
+
ProgressFn = Callable[[int, int, str], None]
@@ -115,15 +162,17 @@ def analyze(query: str, market: str = "IN", progress: ProgressFn | None = None)
report_progress(1, 5, f"Fetching financials, peers & news for {symbol}")
# Run the independent, network-bound fetches concurrently (peers is itself parallel).
- with ThreadPoolExecutor(max_workers=4) as pool:
+ with ThreadPoolExecutor(max_workers=5) as pool:
f_financials = pool.submit(data.get_financials, symbol)
f_peers = pool.submit(compare_peers, symbol)
f_news = pool.submit(get_news, symbol, profile.name)
f_esg = pool.submit(data.get_esg_score, symbol)
+ f_shareholding = pool.submit(shareholding_pattern, symbol, info=info)
financials = f_financials.result()
peers = f_peers.result()
news = f_news.result()
esg = f_esg.result()
+ shareholding = f_shareholding.result()
report_progress(2, 5, "Computing ratios, DCF, moat & risk")
ratios = compute_ratios(symbol, info=info, financials=financials)
@@ -145,6 +194,35 @@ def analyze(query: str, market: str = "IN", progress: ProgressFn | None = None)
product_news=product_news, esg_total=esg,
)
+ report_progress(4, 5, "Buffett checklist, relative metrics, red flags & thesis")
+ # Analyst-grade evidence layer. Each reuses data already fetched above (no extra network),
+ # and each degrades gracefully to a mostly-empty result rather than raising.
+ relative = relative_comparison(symbol, peers, ratios)
+ buffett = buffett_checklist(
+ symbol, ratios=ratios, dcf=dcf, moat=moat, management=management,
+ financials=financials, info=info, sector=profile.sector,
+ )
+ growth = growth_outlook(
+ symbol, ratios=ratios, info=info, industry=industry, sector=profile.sector,
+ payout_ratio=management.dividend_payout_ratio,
+ )
+ trend = fundamental_trend(symbol, financials=financials)
+ red_flags = detect_red_flags(
+ symbol, ratios=ratios, financials=financials, info=info, shareholding=shareholding,
+ )
+ thesis = build_thesis(
+ symbol, score=score, ratios=ratios, buffett=buffett, red_flags=red_flags,
+ relative=relative, dcf=dcf, shareholding=shareholding, growth=growth,
+ )
+ ai_signals = build_ai_signals(
+ symbol, thesis=thesis, red_flags=red_flags, shareholding=shareholding, growth=growth,
+ )
+ overall_evidence = ev.aggregate(
+ [relative.evidence, buffett.evidence, growth.evidence, trend.evidence,
+ shareholding.evidence, red_flags.evidence, thesis.evidence],
+ notes=["Overall analysis quality blended across modules."],
+ )
+
signals = _build_signals(score)
report.profile = profile
report.ratios = ratios
@@ -156,9 +234,18 @@ def analyze(query: str, market: str = "IN", progress: ProgressFn | None = None)
report.moat = moat
report.risk = risk
report.score = score
+ report.relative = relative
+ report.buffett = buffett
+ report.growth_outlook = growth
+ report.fundamental_trend = trend
+ report.shareholding = shareholding
+ report.red_flags = red_flags
+ report.thesis = thesis
+ report.ai_signals = ai_signals
+ report.evidence = overall_evidence
report.signals = signals
report.swot_seeds = _build_swot(signals, industry, risk)
- report.growth_driver_hints = _build_growth_hints(ratios, industry, news)
+ report.growth_driver_hints = _growth_hints_from_outlook(growth, ratios, industry, news)
report.llm_guidance = _LLM_GUIDANCE
# Degraded-mode: the source returned essentially nothing (rate-limited / delisted / unsupported).
diff --git a/src/investo/analysis/report_html.py b/src/investo/analysis/report_html.py
new file mode 100644
index 0000000..8fed389
--- /dev/null
+++ b/src/investo/analysis/report_html.py
@@ -0,0 +1,462 @@
+"""Render an :class:`AnalysisReport` as a self-contained, theme-aware HTML one-pager.
+
+A single research-note style page: masthead, a KPI strip (rating, Buffett fit, ownership, risk,
+growth, valuation), then card sections for the thesis, relative-to-industry, Buffett checklist,
+shareholding, growth engine, fundamentals trend and red flags, closing with an analysis-quality
+footer. Confidence is shown as thin meter bars and statuses as semantic pills throughout.
+
+No external assets (CSP-safe): all CSS is inline and fonts are system stacks. ``render_html`` returns
+a full standalone document; pass ``standalone=False`` for a body fragment (e.g. an Artifact).
+"""
+
+from __future__ import annotations
+
+from html import escape
+
+from ..models import AnalysisReport
+
+# Semantic classes shared by pills/among statuses.
+_STATUS_CLASS = {"pass": "good", "warn": "warn", "fail": "bad", "unknown": "muted"}
+_RISK_CLASS = {"none": "good", "low": "good", "moderate": "warn", "high": "bad", "severe": "bad"}
+_SIGNAL_CLASS = {
+ "bullish": "good", "positive": "good", "neutral": "muted", "cautious": "warn", "bearish": "bad",
+ "strong": "good", "moderate": "warn", "weak": "bad",
+ "cheap": "good", "fair": "muted", "expensive": "warn",
+ "Excellent": "good", "Good": "good", "Fair": "muted", "Weak": "warn", "Poor": "bad",
+}
+_ARROW = {"up": "▲", "flat": "▬", "down": "▼"}
+
+
+def render_html(report: AnalysisReport, *, standalone: bool = True) -> str:
+ """Render the report to HTML. Full document unless ``standalone=False``."""
+ body = _body(report)
+ style = _CSS
+ if not standalone:
+ return f"\n{body}"
+ title = escape((report.profile.name if report.profile else None) or report.query)
+ return (
+ f"
"
+ f""
+ f"{title} — Investo analysis"
+ f"{body}"
+ )
+
+
+# --------------------------------------------------------------------------------------
+# Sections
+# --------------------------------------------------------------------------------------
+def _body(r: AnalysisReport) -> str:
+ parts = [_masthead(r), _kpis(r)]
+ parts += [
+ _thesis(r), _rating(r), _relative(r), _buffett(r), _shareholding(r),
+ _growth(r), _trend(r), _red_flags(r), _quality(r),
+ ]
+ inner = "\n".join(p for p in parts if p)
+ return f"{inner}"
+
+
+def _masthead(r: AnalysisReport) -> str:
+ p = r.profile
+ name = escape((p.name if p else None) or r.query)
+ ticker = escape(r.resolved.symbol if r.resolved else "")
+ sub = " · ".join(escape(x) for x in ((p.sector if p else None), (p.industry if p else None),
+ (p.exchange if p else None)) if x)
+ price = _money(p.current_price, p.currency) if p and p.current_price else ""
+ cap = _money(p.market_cap, p.currency) if p and p.market_cap else ""
+ facts = []
+ if price:
+ facts.append(f"Price{price}")
+ if cap:
+ facts.append(f"Market cap{cap}")
+ if p and p.fifty_two_week_low and p.fifty_two_week_high:
+ facts.append(f"52-wk{_num(p.fifty_two_week_low)}–"
+ f"{_num(p.fifty_two_week_high)}")
+ verdict = ""
+ if r.thesis and r.thesis.verdict:
+ cls = _SIGNAL_CLASS.get(r.thesis.quality or "", "muted")
+ verdict = f"
{escape(r.thesis.verdict)}
"
+ return (
+ f"
Investo "
+ f"equity research{verdict}
"
+ f"
{name} {ticker}
"
+ f"
{sub}
{''.join(facts)}
"
+ )
+
+
+def _kpis(r: AnalysisReport) -> str:
+ tiles = []
+ if r.score:
+ tiles.append(_tile("Rating", f"{r.score.total:.0f}", "/100", r.score.verdict,
+ _score_class(r.score.total)))
+ if r.buffett and r.buffett.weighted_score is not None:
+ tiles.append(_tile("Buffett fit", f"{r.buffett.weighted_score:.0f}", "/100",
+ r.buffett.verdict, _score_class(r.buffett.weighted_score)))
+ if r.shareholding and r.shareholding.ownership_signal:
+ own = r.shareholding.ownership_signal
+ tiles.append(_tile("Ownership", own.title(), "", "trend", _SIGNAL_CLASS.get(own, "muted")))
+ if r.growth_outlook and r.growth_outlook.growth_signal:
+ gs = r.growth_outlook.growth_signal
+ tiles.append(_tile("Growth 5Y", gs.title(), "", "engine", _SIGNAL_CLASS.get(gs, "muted")))
+ if r.red_flags:
+ rl = str(r.red_flags.risk_level)
+ tiles.append(_tile("Risk", rl.title(), "", f"{len(r.red_flags.flags)} flags",
+ _RISK_CLASS.get(rl, "muted")))
+ if r.thesis and r.thesis.valuation_stance:
+ vs = r.thesis.valuation_stance
+ tiles.append(_tile("Valuation", vs.title(), "", "vs value", _SIGNAL_CLASS.get(vs, "muted")))
+ if not tiles:
+ return ""
+ return f"{''.join(tiles)}"
+
+
+def _thesis(r: AnalysisReport) -> str:
+ t = r.thesis
+ if not t:
+ return ""
+ pros = "".join(f"
",
+ aside=conf)
+
+
+def _rating(r: AnalysisReport) -> str:
+ s = r.score
+ if not s:
+ return ""
+ rows = "".join(
+ f"
{escape(b.name)}
{_bar(b.normalized)}
"
+ f"
{b.score:.1f}/{b.weight:.0f}
"
+ f"
{escape(b.rationale or '')}
"
+ for b in s.buckets)
+ return _card(f"Rating — {s.total:.1f}/100 ({escape(s.verdict)})",
+ f"
{rows}
")
+
+
+def _relative(r: AnalysisReport) -> str:
+ rel = r.relative
+ 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"
{_band_pill(m.percentile, m.better)}
"
+ for m in rel.metrics)
+ head = "
Metric
Company
" \
+ "
Industry
Standing
"
+ return _card("Relative to industry", f"
{head}{rows}
")
+
+
+def _buffett(r: AnalysisReport) -> str:
+ b = r.buffett
+ if not b or not b.criteria:
+ return ""
+ rows = ""
+ for c in b.criteria:
+ pill = f"{c.status}"
+ trend = f"{escape(c.trend_verdict)}" if c.trend_verdict else ""
+ conf = _mini_meter(c.confidence.score) if c.confidence else ""
+ rows += (f"
{pill}
{escape(c.name)}{trend}
"
+ f"
{escape(c.reason or '')}
"
+ f"
{conf}
")
+ title = f"Warren Buffett checklist — {b.weighted_score:.0f}/100"
+ if b.verdict:
+ title += f" ({escape(b.verdict)})"
+ return _card(title, f"
{rows}
")
+
+
+def _shareholding(r: AnalysisReport) -> str:
+ sh = r.shareholding
+ if not sh or not sh.latest:
+ return ""
+ lt = sh.latest
+ chips = "".join(
+ f"{lbl}{val:.1%}"
+ for lbl, val in (("Promoter", lt.promoter), ("FII", lt.fii), ("DII", lt.dii),
+ ("Institutional", lt.institutional), ("Public", lt.public),
+ ("Pledge", lt.promoter_pledge)) if val is not None)
+ obs = "".join(f"
{escape(o)}
" for o in sh.observations)
+ obs_html = f"
{obs}
" if obs else ""
+ sig = ""
+ if sh.ownership_signal:
+ sig = f"" \
+ f"{sh.ownership_signal}"
+ note = f"
{obs_html}{note}", aside=sig)
+
+
+def _growth(r: AnalysisReport) -> str:
+ g = r.growth_outlook
+ if not g or not g.drivers:
+ return ""
+ engine = f"
{escape(g.primary_engine)}
" if g.primary_engine else ""
+ drivers = ""
+ for d in g.drivers:
+ share = d.contribution_pct or 0
+ risks = f"{escape(', '.join(d.risks[:2]))}" if d.risks else ""
+ drivers += (
+ f"
"
+ for c in g.catalysts if c.event)
+ catalysts = f"
Catalysts
{items}
"
+ band = ""
+ if g.blended_5y_low is not None and g.blended_5y_high is not None:
+ band = f"{g.blended_5y_low:.0%}–{g.blended_5y_high:.0%} blended"
+ sig = ""
+ if g.growth_signal:
+ sig = f"" \
+ f"{g.growth_signal}{band}"
+ return _card("Growth engine — next 5 years",
+ f"{engine}
{drivers}
{catalysts}", aside=sig)
+
+
+def _trend(r: AnalysisReport) -> str:
+ ft = r.fundamental_trend
+ if not ft or not ft.metrics:
+ return ""
+ rows = ""
+ for m in ft.metrics:
+ arrows = "".join(
+ f"{_ARROW.get(d, '·')}" for d in m.directions)
+ cagr = f"{m.cagr:+.1%}" if m.cagr is not None else "—"
+ cls = _SIGNAL_CLASS.get(m.health or "", "muted")
+ rows += (f"
{escape(m.name)}
{arrows}
"
+ f"
{escape(m.health or '')}
"
+ f"
{cagr}
")
+ head = "
Metric
Trend
Health
" \
+ "
CAGR
"
+ title = "Fundamentals trend"
+ if ft.overall_health:
+ title += f" — {escape(ft.overall_health)}"
+ return _card(title, f"
{head}{rows}
")
+
+
+def _red_flags(r: AnalysisReport) -> str:
+ rf = r.red_flags
+ if not rf:
+ return ""
+ if not rf.flags:
+ body = "
✓ No material red flags detected.
"
+ else:
+ items = "".join(
+ f"
{f.severity}"
+ f"{escape(f.issue)} — {escape(f.detail or '')}
"
+ for f in rf.flags)
+ body = f"
{items}
"
+ cls = _RISK_CLASS.get(str(rf.risk_level), "muted")
+ aside = f"risk: {rf.risk_level}"
+ return _card("Red flags", body, aside=aside)
+
+
+def _quality(r: AnalysisReport) -> str:
+ em = r.evidence
+ if not em or not em.confidence:
+ return ""
+ stats = [
+ f"Confidence{em.confidence.score:.0%} {em.confidence.tier}",
+ ]
+ if em.data_coverage is not None:
+ stats.append(f"Coverage{em.data_coverage:.0%}")
+ stats.append(f"Sources{em.source_count}")
+ if em.as_of:
+ stats.append(f"As of{escape(em.as_of)}")
+ missing = ""
+ if em.missing_fields:
+ missing = f"