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"
  • {escape(x)}
  • " for x in t.pros) or "
  • " + cons = "".join(f"
  • {escape(x)}
  • " for x in t.cons) or "
  • " + summary = f"

    {escape(t.summary)}

    " if t.summary else "" + conf = _meter(t.confidence.score, t.confidence.tier) if t.confidence else "" + return _card( + "Investment thesis", summary + + f"

    Pros

    " + f"

    Cons

    ", + 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 = "MetricCompany" \ + "IndustryStanding" + 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"" if obs else "" + sig = "" + if sh.ownership_signal: + sig = f"" \ + f"{sh.ownership_signal}" + note = f"

    {escape(sh.note)}

    " if sh.note else "" + return _card(f"Shareholding ({escape(sh.source)})", + f"
    {chips}
    {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"
    {escape(d.name)}" + f"{share:.0%}
    " + f"
    {risks}
    ") + catalysts = "" + if g.catalysts: + items = "".join( + f"
  • {c.year or ''}{escape(c.event)}
  • " + for c in g.catalysts if c.event) + catalysts = f"

    Catalysts

    " + 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 = "MetricTrendHealth" \ + "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"" + 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"

    Missing: {escape(', '.join(em.missing_fields))}

    " + return _card("Analysis quality", f"
    {''.join(stats)}
    {missing}") + + +# -------------------------------------------------------------------------------------- +# Building blocks +# -------------------------------------------------------------------------------------- +def _card(title: str, body: str, *, aside: str = "") -> str: + aside_html = f"
    {aside}
    " if aside else "" + return (f"

    {title}

    {aside_html}
    " + f"
    {body}
    ") + + +def _tile(label: str, value: str, unit: str, sub: str | None, cls: str) -> str: + unit_html = f"{unit}" if unit else "" + sub_html = f"{escape(sub)}" if sub else "" + return (f"
    {escape(label)}" + f"{escape(value)}{unit_html}{sub_html}
    ") + + +def _bar(normalized: float) -> str: + pct = max(0, min(100, round(normalized * 100))) + return f"" + + +def _meter(score: float, tier: str) -> str: + pct = max(0, min(100, round(score * 100))) + return (f"
    confidence {pct}% · {escape(tier)}" + f"
    ") + + +def _mini_meter(score: float) -> str: + pct = max(0, min(100, round(score * 100))) + return f"" \ + f" {pct}%" + + +def _band_pill(percentile: float | None, better: bool | None) -> str: + if percentile is None: + return "" + if percentile >= 0.75: + label, cls = "top quartile", "good" + elif percentile >= 0.5: + label, cls = "above median", "good" + elif percentile >= 0.25: + label, cls = "below median", "warn" + else: + label, cls = "bottom quartile", "bad" + return f"{label}" + + +def _score_class(total: float) -> str: + if total >= 65: + return "good" + if total >= 45: + return "warn" + return "bad" + + +def _metric(name: str, value: float | None) -> str: + if value is None: + return "—" + if name in {"P/E", "P/B", "Debt/Equity"}: + return f"{value:.1f}" + return f"{value:.1%}" + + +def _money(value: float | None, currency: str | None) -> str: + if value is None: + return "—" + cur = (currency or "").upper() + if cur == "INR": + return f"₹{value / 1e7:,.0f} Cr" + if abs(value) >= 1e9: + return f"{value / 1e9:,.1f}B {cur}".strip() + return f"{value:,.0f} {cur}".strip() + + +def _num(value: float | None) -> str: + return f"{value:,.0f}" if value is not None else "—" + + +# -------------------------------------------------------------------------------------- +# Styles — token-based, theme-aware, self-contained +# -------------------------------------------------------------------------------------- +_CSS = """ +:root{ + --ground:#f5f7f6; --surface:#ffffff; --ink:#19222a; --muted:#5c6873; --hair:#e3e8e6; + --accent:#0e7c86; --good:#1a7f5a; --warn:#b07d19; --bad:#c0392b; + --good-bg:#e7f3ec; --warn-bg:#f6efdd; --bad-bg:#f7e7e4; --muted-bg:#eef1f0; + --serif:Iowan Old Style,"Palatino Linotype",Palatino,Georgia,serif; + --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; + --mono:ui-monospace,"SF Mono","Cascadia Code",Consolas,monospace; +} +@media (prefers-color-scheme:dark){:root{ + --ground:#0e1418; --surface:#161f26; --ink:#e7edea; --muted:#93a1a9; --hair:#26333b; + --accent:#42b7c1; --good:#4bbd8a; --warn:#d5a531; --bad:#e07a6c; + --good-bg:#12281f; --warn-bg:#2a2413; --bad-bg:#2c1a17; --muted-bg:#1b252c; +}} +:root[data-theme="light"]{ + --ground:#f5f7f6; --surface:#ffffff; --ink:#19222a; --muted:#5c6873; --hair:#e3e8e6; + --accent:#0e7c86; --good:#1a7f5a; --warn:#b07d19; --bad:#c0392b; + --good-bg:#e7f3ec; --warn-bg:#f6efdd; --bad-bg:#f7e7e4; --muted-bg:#eef1f0; +} +:root[data-theme="dark"]{ + --ground:#0e1418; --surface:#161f26; --ink:#e7edea; --muted:#93a1a9; --hair:#26333b; + --accent:#42b7c1; --good:#4bbd8a; --warn:#d5a531; --bad:#e07a6c; + --good-bg:#12281f; --warn-bg:#2a2413; --bad-bg:#2c1a17; --muted-bg:#1b252c; +} +*{box-sizing:border-box} +body{margin:0;background:var(--ground);color:var(--ink);font-family:var(--sans); + line-height:1.5;-webkit-font-smoothing:antialiased} +.page{max-width:900px;margin:0 auto;padding:32px 20px 48px;display:flex;flex-direction:column;gap:18px} +.num,.tval,.yr{font-variant-numeric:tabular-nums;font-family:var(--mono)} +h1,h3,h4{text-wrap:balance;margin:0} +.eyebrow{font-family:var(--sans);text-transform:uppercase;letter-spacing:.14em;font-size:11px; + font-weight:600;color:var(--accent)} +.masthead{border-bottom:2px solid var(--ink);padding-bottom:16px} +.mast-top{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap} +.masthead h1{font-family:var(--serif);font-size:34px;font-weight:600;line-height:1.1;margin:.3em 0 .1em} +.masthead .ticker{font-family:var(--mono);font-size:16px;color:var(--muted);font-weight:500} +.sub{color:var(--muted);margin:0 0 12px;font-size:14px} +.facts{display:flex;flex-wrap:wrap;gap:8px 20px} +.fact{display:flex;flex-direction:column;font-size:14px;font-variant-numeric:tabular-nums} +.fact em{font-style:normal;text-transform:uppercase;letter-spacing:.08em;font-size:10px;color:var(--muted)} +.verdict{font-size:13px} +.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px} +.tile{background:var(--surface);border:1px solid var(--hair);border-radius:10px;padding:12px 14px; + display:flex;flex-direction:column;gap:2px;border-top:3px solid var(--muted)} +.tile.good{border-top-color:var(--good)} .tile.warn{border-top-color:var(--warn)} +.tile.bad{border-top-color:var(--bad)} .tile.muted{border-top-color:var(--muted)} +.tlabel{text-transform:uppercase;letter-spacing:.09em;font-size:10px;color:var(--muted);font-weight:600} +.tval{font-size:26px;font-weight:600;line-height:1.1} +.tval .unit{font-size:13px;color:var(--muted);margin-left:1px} +.tsub{font-size:12px;color:var(--muted)} +.card{background:var(--surface);border:1px solid var(--hair);border-radius:12px;padding:18px 20px} +.card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px; + flex-wrap:wrap} +.card-head h3{font-family:var(--serif);font-size:18px;font-weight:600} +.card-body{font-size:14px} +.lead{font-size:15px;margin:0 0 12px;color:var(--ink)} +.proscons{display:grid;grid-template-columns:1fr 1fr;gap:18px} +.proscons h4{font-size:11px;text-transform:uppercase;letter-spacing:.1em;margin-bottom:6px} +.pros h4{color:var(--good)} .cons h4{color:var(--bad)} +.proscons ul{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:5px} +table.grid{width:100%;border-collapse:collapse;font-size:13.5px} +table.grid th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:.08em; + color:var(--muted);font-weight:600;padding:0 8px 6px;border-bottom:1px solid var(--hair)} +table.grid td{padding:7px 8px;border-bottom:1px solid var(--hair);vertical-align:middle} +table.grid tr:last-child td{border-bottom:none} +.num{text-align:right;white-space:nowrap} th.num{text-align:right} +.small{font-size:12px} .muted{color:var(--muted)} +.barcell{width:38%} +.track{display:inline-block;width:100%;min-width:60px;height:7px;background:var(--muted-bg); + border-radius:99px;overflow:hidden;vertical-align:middle} +.track>span{display:block;height:100%;background:var(--accent);border-radius:99px} +.track.mini{width:52px;min-width:52px} +.pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:11px;font-weight:600; + text-transform:capitalize;white-space:nowrap} +.pill.good{background:var(--good-bg);color:var(--good)} .pill.warn{background:var(--warn-bg);color:var(--warn)} +.pill.bad{background:var(--bad-bg);color:var(--bad)} .pill.muted{background:var(--muted-bg);color:var(--muted)} +.tag{display:inline-block;margin-left:8px;font-size:11px;color:var(--muted);border:1px solid var(--hair); + border-radius:99px;padding:1px 8px} +.chips{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px} +.chip{display:flex;flex-direction:column;background:var(--muted-bg);border-radius:8px;padding:6px 12px; + font-variant-numeric:tabular-nums;font-weight:600;font-family:var(--mono)} +.chip em{font-style:normal;font-family:var(--sans);text-transform:uppercase;letter-spacing:.07em; + font-size:9px;color:var(--muted);font-weight:600} +.obs,.flags{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:5px} +.flags{list-style:none;padding:0} +.flags li{display:flex;gap:10px;align-items:baseline} +.drivers{display:flex;flex-direction:column;gap:12px} +.driver .drow{display:flex;justify-content:space-between;font-weight:600;margin-bottom:4px} +.driver .track{height:9px} +.timeline{margin-top:16px} .timeline h4{font-size:11px;text-transform:uppercase;letter-spacing:.1em; + color:var(--muted);margin-bottom:6px} +.timeline ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px} +.timeline li{display:flex;gap:12px;align-items:baseline;font-size:13.5px; + border-left:2px solid var(--accent);padding-left:12px} +.timeline .yr{color:var(--accent);font-weight:600;min-width:44px} +.arrows{letter-spacing:2px} .arr.up{color:var(--good)} .arr.down{color:var(--bad)} .arr.flat{color:var(--muted)} +.meter{display:flex;flex-direction:column;gap:3px;min-width:150px} +.mlabel{font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted)} +.disclaimer{text-align:center;color:var(--muted);font-size:12px;margin-top:8px} +@media (max-width:560px){.proscons{grid-template-columns:1fr}.masthead h1{font-size:27px}} +@media print{body{background:#fff}.card,.tile{border-color:#ccc}} +""" diff --git a/src/investo/cli.py b/src/investo/cli.py index a931ea6..25c2a26 100644 --- a/src/investo/cli.py +++ b/src/investo/cli.py @@ -49,6 +49,26 @@ def _bar(normalized: float, width: int = 10) -> str: return "█" * filled + "·" * (width - filled) +_STATUS_GLYPH = {"pass": "✓", "warn": "⚠", "fail": "✗", "unknown": "—"} +_ARROW = {"up": "⬆", "flat": "➡", "down": "⬇"} + + +def _conf(confidence) -> str: + """Render a Confidence as e.g. '92% High'.""" + if confidence is None: + return "n/a" + 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.""" + if value is None: + return "n/a" + if name in {"P/E", "P/B", "Debt/Equity"}: + return f"{value:.1f}" + return f"{value:.1%}" + + # -------------------------------------------------------------------------------------- # Report renderer # -------------------------------------------------------------------------------------- @@ -69,6 +89,24 @@ def render_report(r: AnalysisReport) -> str: summary = p.business_summary out.append("\n" + (summary[:400] + ("…" if len(summary) > 400 else ""))) + # Investment thesis (lead with the synthesis) + th = r.thesis + if th: + title = f"INVESTMENT THESIS — {th.verdict}" if th.verdict else "INVESTMENT THESIS" + out.append(_rule(title)) + if th.summary: + out.append(f" {th.summary}") + if th.confidence: + out.append(f" Confidence: {_conf(th.confidence)}") + if th.pros: + out.append(" \033[1mPros\033[0m") + for pro in th.pros: + out.append(f" + {pro}") + if th.cons: + out.append(" \033[1mCons\033[0m") + for con in th.cons: + out.append(f" − {con}") + # Rating s = r.score if s: @@ -76,6 +114,82 @@ def render_report(r: AnalysisReport) -> str: for b in s.buckets: out.append(f" {b.name:18} {_bar(b.normalized)} {b.score:5.1f}/{b.weight:<4.0f} {b.rationale}") + # Relative to industry + rel = r.relative + if rel and rel.metrics: + out.append(_rule("RELATIVE TO INDUSTRY")) + 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}") + + # Buffett checklist + bf = r.buffett + if bf and bf.criteria: + head = f"BUFFETT CHECKLIST: {bf.weighted_score}/100" + if bf.verdict: + head += f" — {bf.verdict}" + out.append(_rule(head)) + for crit in bf.criteria: + glyph = _STATUS_GLYPH.get(crit.status, "—") + trend = f" [{crit.trend_verdict}]" if crit.trend_verdict else "" + out.append(f" {glyph} {crit.name:26} {_conf(crit.confidence):>9} {crit.reason}{trend}") + + # Shareholding pattern + sh = r.shareholding + if sh and sh.latest: + sig = f" — ownership: {sh.ownership_signal}" if sh.ownership_signal else "" + out.append(_rule(f"SHAREHOLDING ({sh.source}){sig}")) + lt = sh.latest + split = [(lbl, val) 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] + if split: + out.append(" " + " ".join(f"{lbl} {val:.1%}" for lbl, val in split)) + for o in sh.observations: + out.append(f" • {o}") + if sh.note: + out.append(f" ({sh.note})") + + # Growth engine (next 5 years) + g = r.growth_outlook + if g and g.drivers: + 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)" + out.append(_rule(f"GROWTH ENGINE — 5Y: {g.growth_signal or 'n/a'}{band}")) + if g.primary_engine: + out.append(f" Primary: {g.primary_engine}") + for drv in g.drivers: + share = f"{drv.contribution_pct:.0%}" if drv.contribution_pct is not None else " — " + risks = f" risks: {', '.join(drv.risks[:2])}" if drv.risks else "" + out.append(f" {drv.rank}. {drv.name:24} {share:>5}{risks}") + if g.catalysts: + cats = " ".join(f"{c.year}: {c.event}" for c in g.catalysts if c.event) + out.append(f" Catalysts: {cats}") + + # Fundamentals trend (health at a glance) + ft = r.fundamental_trend + if ft and ft.metrics: + out.append(_rule(f"FUNDAMENTALS TREND — overall: {ft.overall_health or 'n/a'}")) + for mt in ft.metrics: + arrows = "".join(_ARROW.get(step, "·") for step in mt.directions) + cagr = f"{mt.cagr:+.1%}" if mt.cagr is not None else "n/a" + out.append(f" {mt.name:12} {arrows:6} {mt.health or '':10} CAGR {cagr}") + + # Red flags + rf = r.red_flags + if rf: + out.append(_rule(f"RED FLAGS — risk level: {rf.risk_level}")) + if not rf.flags: + out.append(" ✓ No material red flags detected.") + for flag in rf.flags: + out.append(f" ⚠ [{flag.severity:8}] {flag.issue} — {flag.detail}") + # Valuation / DCF d = r.dcf ra = r.ratios @@ -150,6 +264,20 @@ def render_report(r: AnalysisReport) -> str: for w in r.warnings: out.append(f" ! {w}") + # Analysis quality footer — transparency for downstream judgement. + em = r.evidence + if em and em.confidence: + out.append(_rule("ANALYSIS QUALITY")) + parts = [f"Confidence {em.confidence.score:.0%} ({em.confidence.tier})"] + if em.data_coverage is not None: + parts.append(f"Coverage {em.data_coverage:.0%}") + parts.append(f"Sources {em.source_count}") + if em.as_of: + parts.append(f"As of {em.as_of}") + out.append(" " + " · ".join(parts)) + if em.missing_fields: + out.append(f" Missing: {', '.join(em.missing_fields)}") + out.append("\n\033[2mResearch only — not investment advice. Data: public sources (Yahoo Finance).\033[0m") return "\n".join(out) @@ -160,6 +288,12 @@ def render_report(r: AnalysisReport) -> str: def _cmd_analyze(args: argparse.Namespace) -> int: from .analysis.report import analyze report = analyze(args.query, args.market) + if getattr(args, "html", None): + from .analysis.report_html import render_html + with open(args.html, "w", encoding="utf-8") as fh: + fh.write(render_html(report)) + print(f"Wrote HTML report to {args.html}") + return 0 if args.json: print(json.dumps(report.model_dump(), indent=2, default=str)) else: @@ -209,6 +343,7 @@ def build_parser() -> argparse.ArgumentParser: pa = sub.add_parser("analyze", help="Full investment analysis") pa.add_argument("query", help="Company name or ticker") pa.add_argument("--json", action="store_true", help="Emit raw JSON") + pa.add_argument("--html", metavar="FILE", help="Write a self-contained HTML report to FILE") _add_market(pa) pa.set_defaults(func=_cmd_analyze)