Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ All notable changes to Investo are documented here. The format follows
## [Unreleased]

### Added
- **Five new MCP tools** (23 → 28):
- `technical_snapshot` — price/momentum context (50/200-day moving averages + golden/death cross,
RSI(14) with Wilder's smoothing, annualized volatility, 1-year max drawdown, beta vs the market
index, 52-week position). Explicitly framed as *context, not a trading signal*. Backed by a new
cached `sources/yahoo.get_history`.
- `dcf_sensitivity` — intrinsic value across a discount-rate × terminal-growth grid plus the
break-even growth implied by today's price. Fetches statements **once** and reuses them across
all 25 grid cells (get_financials is uncached).
- `compare_companies` — head-to-head across 2–6 arbitrary tickers (answers "compare KPIT with Tata
Elxsi and Tata Tech" directly), not limited to a curated group; share is named set-relative, not
"market share".
- `peer_group_directory` — lists the curated peer groups and members, so a client can see how
companies are grouped and why.
- `export_report` — renders a full analysis to an HTML/PDF file (the **only** non-read-only tool;
its LLM-supplied path is sandboxed to the export directory).
- **PDF export** — `investo analyze --pdf [FILE]` renders the research note to PDF, with **no new
required dependency**. It shells out to a system Chrome/Edge/Chromium/Brave if one is installed
(the usual case), falls back to a Playwright-managed Chromium (`pip install 'investo[pdf]' &&
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ of Reliance?"*
| `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) |
| `technical_snapshot` | Price/momentum context: 50/200-DMA + golden/death cross, RSI, volatility, drawdown, beta, 52-week position (context, not a signal) |
| `dcf_sensitivity` | Intrinsic value across a discount-rate × terminal-growth grid + the growth implied by today's price |
| `compare_companies` | Head-to-head across 2–6 named tickers (not a curated group) |
| `peer_group_directory` | List the curated peer groups and their members |
| `export_report` | Render a full analysis to an HTML/PDF file (writes a file; path sandboxed) |
| `analyze_company` | Everything above bundled into one report (with a confidence/provenance evidence layer) |
| `get_sec_facts` | SEC EDGAR cross-check (US/ADR only) |

Expand Down
7 changes: 6 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@
{ "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" }
{ "name": "provider_status", "description": "Active data providers + disclosure" },
{ "name": "technical_snapshot", "description": "Price/momentum context: DMAs, RSI, volatility, drawdown, beta (not a signal)" },
{ "name": "dcf_sensitivity", "description": "Intrinsic value across a discount-rate x terminal-growth grid + implied break-even growth" },
{ "name": "compare_companies", "description": "Head-to-head across 2-6 named tickers (not a curated peer group)" },
{ "name": "peer_group_directory", "description": "List the curated peer groups and their members" },
{ "name": "export_report", "description": "Render a full analysis to an HTML or PDF file (writes a file)" }
],
"compatibility": {
"runtimes": { "python": ">=3.10" }
Expand Down
90 changes: 90 additions & 0 deletions src/investo/analysis/multi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Head-to-head comparison across an arbitrary set of tickers.

Unlike ``compare_peers``, which works from a curated group, this compares exactly the tickers the
caller names — answering "compare KPIT with Tata Elxsi and Tata Tech" directly. It reuses
``peers._peer_row`` (same currency normalisation) and the same concurrent fetch.

It deliberately does **not** compute a "market share": revenue share within a set the user invented
is not market share, and calling it that would mislead. The share it does report is named
``revenue_share_of_set`` in prose and is explicitly set-relative.
"""

from __future__ import annotations

from concurrent.futures import ThreadPoolExecutor

from ..models import MultiCompare, PeerRow, Provenance
from ..sources import data
from . import evidence as ev
from .peers import _peer_row


def compare_companies(symbols: list[str]) -> MultiCompare:
"""Compare 2-6 tickers side by side (already-resolved exchange tickers)."""
# De-duplicate, preserving the caller's order.
seen: set[str] = set()
ordered: list[str] = []
for s in symbols:
u = s.upper()
if u not in seen:
seen.add(u)
ordered.append(u)

if len(ordered) < 2:
return MultiCompare(tickers=ordered,
note="Need at least two distinct tickers to compare.")

base_ccy = data.get_info(ordered[0]).get("currency") or \
data.get_info(ordered[0]).get("financialCurrency")
with ThreadPoolExecutor(max_workers=8) as pool:
fetched = list(pool.map(lambda sym: _peer_row(sym, base_ccy), ordered))
rows: list[PeerRow] = [r for r in fetched if r is not None]

if not rows:
return MultiCompare(tickers=ordered, note="None of the tickers resolved to usable data.")

total_rev = sum(r.revenue_ttm for r in rows if r.revenue_ttm is not None)
if total_rev > 0:
for r in rows:
if r.revenue_ttm is not None:
r.market_share_proxy = r.revenue_ttm / total_rev # set-relative; see _summary

present = sum(1 for r in rows if r.revenue_ttm is not None or r.market_cap is not None)
return MultiCompare(
tickers=ordered,
rows=rows,
summary=_summary(rows),
note="Revenue & market cap normalized to the first ticker's trading currency. "
"Shares are within this set, not market share.",
evidence=ev.build_meta(
sources=[Provenance(source=ev.SRC_YAHOO, detail="peer fundamentals")],
present=present, expected=len(rows),
notes=[f"Comparing {len(rows)} of {len(ordered)} requested tickers."]),
)


def _summary(rows: list[PeerRow]) -> list[str]:
"""A few grounded leaders across the set — no subject, so no rank-of-self."""
out: list[str] = []

def leader(attr: str, label: str, fmt, reverse: bool = True) -> None:
vals = [(r, getattr(r, attr)) for r in rows if getattr(r, attr) is not None]
if not vals:
return
best = sorted(vals, key=lambda x: x[1], reverse=reverse)[0][0]
out.append(f"{label}: {best.name or best.ticker} ({fmt(getattr(best, attr))}).")

leader("market_cap", "Largest", lambda v: _money(v))
leader("net_margin", "Highest net margin", lambda v: f"{v:.1%}")
leader("revenue_growth_yoy", "Fastest growth", lambda v: f"{v:.1%}")
leader("pe", "Cheapest P/E", lambda v: f"{v:.1f}x", reverse=False)
leader("roe", "Best ROE", lambda v: f"{v:.1%}")
return out


def _money(value: float) -> str:
if abs(value) >= 1e9:
return f"{value / 1e9:,.1f}B"
if abs(value) >= 1e7:
return f"{value / 1e7:,.0f} Cr"
return f"{value:,.0f}"
19 changes: 19 additions & 0 deletions src/investo/analysis/peers.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,25 @@ def get_peers(symbol: str) -> tuple[list[str], dict | None]:
return res.peers, res.group


def peer_group_directory():
"""List the curated peer groups so a client can see how companies are grouped and why."""
from ..models import PeerGroupDirectory, PeerGroupInfo

groups = [
PeerGroupInfo(
key=key,
label=g.get("label", key),
outlook=g.get("outlook"),
industry_cagr=g.get("industry_cagr"),
updated_at=g.get("updated_at"),
member_count=len(g.get("members", [])),
members=list(g.get("members", [])),
)
for key, g in peer_groups().items()
]
return PeerGroupDirectory(groups=groups, count=len(groups))


def _bounded(value: float | None, lo: float, hi: float) -> float | None:
if value is None:
return None
Expand Down
119 changes: 119 additions & 0 deletions src/investo/analysis/sensitivity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""DCF sensitivity: intrinsic value across a discount-rate x terminal-growth grid.

A single DCF number hides how much it rests on two assumptions. This grid shows the spread, and
the implied break-even growth answers the more useful question: what future growth is the *market*
already paying for at today's price?

Performance note that shapes the whole module: ``yahoo.get_financials`` is **not** cached (only
info and FX are). A 5x5 grid calls ``compute_dcf`` 25 times, so we fetch info/financials/ratios
**once** and pass them into every call — otherwise one tool invocation becomes 25 multi-second
statement fetches on the analyze critical path. ``test_sensitivity`` asserts the single fetch.
"""

from __future__ import annotations

from ..config import CONFIG
from ..models import DcfSensitivity, Provenance
from ..sources import data
from . import evidence as ev
from .dcf import compute_dcf
from .ratios import compute_ratios

# Grid geometry: base +/- span, in steps. Kept small — the point is the shape, not a heat map.
_R_STEP = 0.015
_R_SPAN = 2 # -> 5 columns: base +/- 3.0pp
_G_STEP = 0.01
_G_SPAN = 2 # -> 5 rows: base +/- 2.0pp
_BISECT_ITERS = 40
_BISECT_HI = 0.50 # no company grows FCF > 50%/yr forever; cap the break-even search here


def dcf_sensitivity(symbol: str, market: str = "IN") -> DcfSensitivity:
symbol = symbol.upper()
# Fetch once; reuse across all 25 grid cells and the break-even bisection.
info = data.get_info(symbol)
financials = data.get_financials(symbol)
ratios = compute_ratios(symbol, info=info, financials=financials)

base = compute_dcf(symbol, info=info, financials=financials, ratios=ratios)
result = DcfSensitivity(
ticker=symbol, currency=base.currency, base=base, current_price=base.current_price,
)

if base.intrinsic_value_per_share is None:
result.note = base.note or "DCF not available, so no sensitivity grid."
result.evidence = ev.build_meta(
sources=[Provenance(source=ev.SRC_YAHOO, detail="statements")],
present=0, expected=1, reason="DCF not computable")
return result

r0 = base.discount_rate if base.discount_rate is not None else \
CONFIG.discount_rate_for_market(market)
g0 = base.terminal_growth if base.terminal_growth is not None else CONFIG.dcf_terminal_growth

rates = [round(r0 + (i - _R_SPAN) * _R_STEP, 4) for i in range(2 * _R_SPAN + 1)]
growths = [round(g0 + (j - _G_SPAN) * _G_STEP, 4) for j in range(2 * _G_SPAN + 1)]
result.discount_rates = rates
result.terminal_growths = growths

grid: list[list[float | None]] = []
for g in growths:
row: list[float | None] = []
for r in rates:
if r <= g:
row.append(None) # matches compute_dcf's own guard; undefined here
continue
cell = compute_dcf(symbol, info=info, financials=financials, ratios=ratios,
discount_rate=r, terminal_growth=g)
row.append(cell.intrinsic_value_per_share)
grid.append(row)
result.grid = grid

result.implied_breakeven_growth = _breakeven_growth(
symbol, info, financials, ratios, base, g0)

notes = ["Intrinsic value per share across discount rate (columns) x terminal growth (rows)."]
if result.implied_breakeven_growth is None:
notes.append("Break-even growth exceeds 50%/yr or is otherwise unreachable.")
result.evidence = ev.build_meta(
sources=[Provenance(source=ev.SRC_YAHOO, detail="statements"),
Provenance(source=ev.SRC_HEURISTIC, detail="two-stage DCF")],
present=1, expected=1, notes=notes,
)
return result


def _breakeven_growth(symbol, info, financials, ratios, base, g0) -> float | None:
"""The explicit-stage growth that makes intrinsic value equal today's price.

Intrinsic value is monotonic in growth, so bisect rather than solve — there is no clean
closed form for a two-stage-plus-Gordon model. Returns None if the price can't be reached
within a sane growth ceiling.
"""
price = base.current_price
if price is None or price <= 0:
return None

def intrinsic_at(g: float) -> float | None:
return compute_dcf(symbol, info=info, financials=financials, ratios=ratios,
growth_rate=g).intrinsic_value_per_share

lo, hi = g0 + 0.0001, _BISECT_HI
v_lo, v_hi = intrinsic_at(lo), intrinsic_at(hi)
if v_lo is None or v_hi is None:
return None
# The price must sit between the two ends for a root to exist in [lo, hi].
if not (min(v_lo, v_hi) <= price <= max(v_lo, v_hi)):
return None

ascending = v_hi >= v_lo
for _ in range(_BISECT_ITERS):
mid = (lo + hi) / 2
v = intrinsic_at(mid)
if v is None:
return None
if (v < price) == ascending:
lo = mid
else:
hi = mid
return round((lo + hi) / 2, 4)
Loading
Loading