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
26 changes: 3 additions & 23 deletions src/investo/analysis/dcf.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,26 +54,6 @@ def _estimate_growth(ratios: Ratios, terminal_growth: float) -> float:
return _clamp(g, terminal_growth + 0.01, _GROWTH_CAP)


def _net_debt(fin: Financials, info: dict[str, Any]) -> float:
bal = F.latest(fin.balance_sheet)
total_debt = F.pick(bal, *F.TOTAL_DEBT)
cash = F.pick(bal, *F.CASH)
if total_debt is None:
total_debt = _f(info.get("totalDebt"))
if cash is None:
cash = _f(info.get("totalCash"))
total_debt = total_debt or 0.0
cash = cash or 0.0
return total_debt - cash


def _f(v: Any) -> float | None:
try:
return float(v) if v is not None else None
except (TypeError, ValueError):
return None


def compute_dcf(
symbol: str,
info: dict[str, Any] | None = None,
Expand Down Expand Up @@ -133,16 +113,16 @@ def compute_dcf(
pv_terminal = terminal_value / ((1 + r) ** n)
enterprise_value = pv_fcf + pv_terminal

net_debt = _net_debt(financials, info)
net_debt = F.net_debt(financials, info)
equity_value_stmt = enterprise_value - net_debt

result.enterprise_value = enterprise_value
result.equity_value = equity_value_stmt

# Convert equity value to trading currency for a per-share figure.
fx = data.fx_rate(stmt_ccy, price_ccy)
shares = _f(info.get("sharesOutstanding"))
price = _f(info.get("currentPrice") or info.get("regularMarketPrice"))
shares = F._f(info.get("sharesOutstanding"))
price = F._f(info.get("currentPrice") or info.get("regularMarketPrice"))
result.current_price = price

assumptions = [
Expand Down
29 changes: 28 additions & 1 deletion src/investo/analysis/finutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from __future__ import annotations

from ..models import FinancialPeriod
from typing import Any

from ..models import Financials, FinancialPeriod


def safe_div(numerator: float | None, denominator: float | None) -> float | None:
Expand Down Expand Up @@ -84,3 +86,28 @@ def yoy(newest_first: list[float | None]) -> float | None:
CAPEX = ("Capital Expenditure", "Capital Expenditure Reported")
DIVIDENDS_PAID = ("Cash Dividends Paid", "Common Stock Dividend Paid")
REPURCHASE = ("Repurchase Of Capital Stock", "Common Stock Payments")


def _f(v: Any) -> float | None:
"""Best-effort float coercion; None on failure."""
try:
return float(v) if v is not None else None
except (TypeError, ValueError):
return None


def net_debt(fin: Financials, info: dict[str, Any]) -> float:
"""Total debt minus cash in statement currency (0.0 fallbacks).

Reads the balance sheet first, then falls back to Yahoo ``info`` totals. A negative result
means the company holds more cash than debt (net cash). Single source of truth for both the
DCF equity bridge and the Ratios net-cash fields.
"""
bal = latest(fin.balance_sheet)
total_debt = pick(bal, *TOTAL_DEBT)
cash = pick(bal, *CASH)
if total_debt is None:
total_debt = _f(info.get("totalDebt"))
if cash is None:
cash = _f(info.get("totalCash"))
return (total_debt or 0.0) - (cash or 0.0)
14 changes: 14 additions & 0 deletions src/investo/analysis/ratios.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,4 +228,18 @@ def compute_ratios(

merged["ticker"] = symbol.upper()
merged["currency"] = info.get("financialCurrency") or info.get("currency")

# Net cash (cash - total debt) and its size relative to market cap. Both use the same
# net-debt source as the DCF equity bridge. Cash is in the statement currency while market
# cap is in the trading currency, so convert with the same FX the DCF uses (the Infosys
# USD-statements / INR-price case); leave the ratio None when FX or market cap is missing.
net_cash_stmt = -F.net_debt(financials, info)
stmt_ccy = info.get("financialCurrency") or info.get("currency")
price_ccy = info.get("currency") or stmt_ccy
fx = data.fx_rate(stmt_ccy, price_ccy)
mcap = info.get("marketCap")
merged["net_cash"] = net_cash_stmt
merged["net_cash_to_market_cap"] = (
(net_cash_stmt * fx) / mcap if (fx and mcap) else None
)
return Ratios(**merged)
92 changes: 76 additions & 16 deletions src/investo/analysis/scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

Weights (out of 100; ESG is an optional 11th that renormalizes the total):

Growth 15 | Profitability 15 | Cash Flow 10 | Debt 10 | Valuation 15
Moat 10 | Management 10 | Industry 5 | Innovation 5 | Risk 5 | (ESG 5, optional)
Growth 15 | Profitability 17 | Cash Flow 10 | Balance Sheet 11 | Valuation 10
Moat 12 | Management 10 | Industry 5 | Innovation 5 | Risk 5 | (ESG 5, optional)

Valuation is quality-aware: the acceptable multiple ceilings widen with the company's economics
(returns, margins, growth), and net cash lowers the effective equity multiple -- so a proven,
cash-rich compounder is not floored for trading at a premium.

Financial buckets are computed from ratios/DCF. Qualitative buckets (moat, management,
industry, innovation, risk) use transparent heuristics from the same data; the host LLM can
Expand All @@ -17,14 +21,16 @@

from ..models import DCFResult, Ratios, Score, ScoreBucket

# Bucket weights (core ten sum to 100).
# Bucket weights (core ten sum to 100). Leans on durable quality over absolute cheapness:
# Valuation is a lighter cross-check (10) while Profitability (17), Moat (12) and the net-cash-
# aware Balance Sheet (11) carry more.
WEIGHTS = {
"Growth": 15.0,
"Profitability": 15.0,
"Profitability": 17.0,
"Cash Flow": 10.0,
"Debt": 10.0,
"Valuation": 15.0,
"Competitive Moat": 10.0,
"Balance Sheet": 11.0,
"Valuation": 10.0,
"Competitive Moat": 12.0,
"Management": 10.0,
"Industry Outlook": 5.0,
"Innovation": 5.0,
Expand Down Expand Up @@ -61,6 +67,25 @@ def _pct(x: float | None) -> str:
return f"{x:.1%}" if x is not None else "n/a"


def _quality_factor(r: Ratios) -> float:
"""Business-quality read in [0, 1] from returns, margins and growth.

Four independent signals so the factor can't be gamed by leverage-inflated ROE (operating
margin) or a hollow top line (EPS CAGR blended into revenue growth). Each `_lin` clamps, so a
single sky-high metric can't dominate. Reusable across scorers; neutral 0.5 when data is absent.
"""
growth_blend = _avg([r.revenue_cagr_3y, r.eps_cagr_3y])
if growth_blend is None:
growth_blend = r.revenue_growth_yoy
q = _avg([
_lin(r.roe, 0.10, 0.30),
_lin(r.roce, 0.12, 0.35),
_lin(r.operating_margin, 0.05, 0.30),
_lin(growth_blend, 0.0, 0.20),
])
return q if q is not None else 0.5


# --------------------------------------------------------------------------------------
# Per-bucket scorers -> normalized [0,1]
# --------------------------------------------------------------------------------------
Expand Down Expand Up @@ -97,44 +122,79 @@ def score_cashflow(r: Ratios) -> tuple[float | None, str, dict]:


def score_debt(r: Ratios, sector: str | None = None) -> tuple[float | None, str, dict]:
"""Balance-sheet strength: leverage, liquidity and net-cash position (higher = stronger)."""
is_financial = sector in _FINANCIAL_SECTORS
parts = [
_lin(r.interest_coverage, 2.0, 12.0),
_lin(r.current_ratio, 1.0, 2.5),
]
if not is_financial: # leverage is structurally high for banks/NBFCs; don't penalize
parts.append(_inv(r.debt_to_equity, 0.0, 2.0))
# Signed net-cash term: net debt (-20% of m.cap) drags below the debt-free midpoint,
# a fortress net-cash pile (+20%) lifts it -- so leveraged / debt-free / net-cash names
# are separated. (Skipped for financials, where cash and debt are the business.)
parts.append(_lin(r.net_cash_to_market_cap, -0.20, 0.20))
n = _avg(parts)
de = "excluded (financial sector)" if is_financial else \
(f"{r.debt_to_equity:.2f}" if r.debt_to_equity is not None else "n/a")
cov = f"{r.interest_coverage:.1f}x" if r.interest_coverage is not None else "n/a"
rat = f"debt/equity {de}, interest coverage {cov}"
if not is_financial and r.net_cash_to_market_cap is not None:
label = "net cash" if r.net_cash_to_market_cap >= 0 else "net debt"
rat += f", {label} {_pct(abs(r.net_cash_to_market_cap))} of m.cap"
return n, rat, {"debt_to_equity": r.debt_to_equity, "interest_coverage": r.interest_coverage,
"current_ratio": r.current_ratio}
"current_ratio": r.current_ratio,
"net_cash_to_market_cap": r.net_cash_to_market_cap}


def score_valuation(r: Ratios, dcf: DCFResult | None = None) -> tuple[float | None, str, dict]:
q = _quality_factor(r)
# Ceilings widen *quadratically* with quality: average names keep today's ceilings (40 / 8 / 25);
# only exceptional quality unlocks real headroom -- so a proven compounder isn't floored for a
# premium multiple, while a low-quality expensive name still is.
qf = q * q
pe_hi = 40.0 + qf * 35.0
pb_hi = 8.0 + qf * 7.0
ev_hi = 25.0 + qf * 15.0
# Ex-cash equity multiples: net cash lowers the effective P/E and P/B (dampened so a cash-heavy
# balance sheet helps without a runaway boost; net debt raises them). EV/EBITDA is already
# cash-adjusted, so it's left alone to avoid double-counting.
ncm = max(-0.5, min(0.8, r.net_cash_to_market_cap or 0.0))
cash_factor = 1.0 - 0.5 * ncm
eff_pe = r.pe * cash_factor if r.pe is not None else None
eff_pb = r.pb * cash_factor if r.pb is not None else None
multiples = _avg([
_inv(r.pe, 8.0, 40.0),
_inv(r.pb, 1.0, 8.0),
_inv(r.ev_ebitda, 6.0, 25.0),
_inv(eff_pe, 8.0, pe_hi),
_inv(eff_pb, 1.0, pb_hi),
_inv(r.ev_ebitda, 6.0, ev_hi),
_inv(r.peg, 1.0, 3.0) if (r.peg is not None and r.peg > 0) else None,
])
mos = dcf.margin_of_safety if dcf else None
dcf_score = None
if mos is not None:
dcf_score = _lin(max(-1.0, min(0.7, mos)), -0.3, 0.4) # clamp: DCF unreliable for capex-heavy
# Multiples dominate (0.75); DCF is a secondary cross-check (0.25).
# Recentred so fair value (mos~0) scores ~0.5; clamp: DCF unreliable for capex-heavy names.
dcf_score = _lin(max(-1.0, min(0.7, mos)), -0.4, 0.4)
# Multiples dominate (0.70); DCF is an assumption-sensitive cross-check (0.30).
n: float | None
if multiples is not None and dcf_score is not None:
n = 0.75 * multiples + 0.25 * dcf_score
n = 0.70 * multiples + 0.30 * dcf_score
else:
n = multiples if multiples is not None else dcf_score
# Rationale: make the quality-aware assessment explicit rather than reading as a blind penalty.
pe = f"{r.pe:.1f}" if r.pe is not None else "n/a"
rat = f"P/E {pe}, P/B {r.pb:.1f}" if r.pb is not None else f"P/E {pe}"
premium = (r.pe is not None and r.pe > 40) or (r.pb is not None and r.pb > 8)
if premium and q >= 0.66:
rat += (" - premium multiples assessed relative to strong quality "
"(ROE/margins/growth), which justifies part of the premium")
if r.net_cash_to_market_cap is not None and r.net_cash_to_market_cap > 0.05:
rat += f", net cash {_pct(r.net_cash_to_market_cap)} of m.cap"
if mos is not None:
rat += f", DCF margin of safety {_pct(mos)}"
return n, rat, {"pe": r.pe, "pb": r.pb, "ev_ebitda": r.ev_ebitda, "dcf_margin_of_safety": mos}
return n, rat, {"pe": r.pe, "pb": r.pb, "ev_ebitda": r.ev_ebitda,
"quality_factor": round(q, 3),
"net_cash_to_market_cap": r.net_cash_to_market_cap,
"dcf_margin_of_safety": mos}


def score_moat(r: Ratios, market_share_proxy: float | None = None) -> tuple[float | None, str, dict]:
Expand Down Expand Up @@ -243,7 +303,7 @@ def compute_score(
("Growth", "computed", *score_growth(ratios)),
("Profitability", "computed", *score_profitability(ratios)),
("Cash Flow", "computed", *score_cashflow(ratios)),
("Debt", "computed", *score_debt(ratios, sector)),
("Balance Sheet", "computed", *score_debt(ratios, sector)),
("Valuation", "computed", *score_valuation(ratios, dcf)),
("Competitive Moat", "heuristic", *score_moat(ratios, market_share_proxy)),
("Management", "heuristic", *score_management(ratios, promoter_holding)),
Expand Down
2 changes: 2 additions & 0 deletions src/investo/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ class Ratios(_Base):
interest_coverage: float | None = None
current_ratio: float | None = None
quick_ratio: float | None = None
net_cash: float | None = None # cash - total debt, statement currency
net_cash_to_market_cap: float | None = None # FX-normalized, dimensionless (signed)

# Growth
revenue_growth_yoy: float | None = None
Expand Down
Loading
Loading