claude-skills/finance/skills/stock-analysis/scripts/ratios.py
2026-08-06 09:40:15 +05:30

2690 lines
127 KiB
Python

#!/usr/bin/env python3
"""ratios.py -- deterministic financial ratio engine for the stock-analysis skill.
Purpose
-------
Every analysis otherwise recomputes the same ratios by hand, which invites
arithmetic slips and silent inconsistency between sections of the same report.
This script takes raw financial line items for one company across several
periods and returns the full derived ratio set: growth, the margin ladder, the
return set, 3-step and 5-step DuPont, return on incremental invested capital,
leverage, liquidity, working capital, cash-flow quality, per-share figures, an
explicit enterprise-value bridge, valuation multiples, and automatic quality
warnings.
Python 3.8+. Standard library only. No network access, no third-party packages.
SECTOR GATE -- READ THIS FIRST
------------------------------
For **banks, NBFCs/HFCs, insurers and REITs/InvITs** most of the ratios below
are meaningless or actively misleading:
* Banks and NBFCs -- debt is raw material, not financing. Invested capital,
NOPAT, enterprise value, EV/EBITDA, net debt/EBITDA and ROIC are undefined.
A bank is *supposed* to run 8-15x assets/equity, so the DuPont equity
multiplier is not a warning, it is the business model. Use ROA and ROE read
together with CET1/CAR, NIM, cost-to-income, credit cost and RoRWA.
* Insurers -- new-business strain depresses reported ROE precisely when the
company is writing profitable growth. Use ROEV, VNB margin, combined ratio.
* REITs/InvITs -- assets are carried at fair value and the asset *is* the
business, so ROIC collapses toward the cap rate by construction. Use AFFO
yield, NOI yield on cost, cap rate vs cost of debt, LTV.
Pass ``--sector banks`` (or nbfc / insurance / reit / financials) and the script
prints this warning prominently and suppresses the enterprise-value multiples
for the sectors where EV has no meaning. Use the matching sector playbook in
``references/sectors/`` for the metric set that actually applies.
Usage
-----
python ratios.py inputs.json
python ratios.py inputs.json --json > ratios.json
python ratios.py inputs.json --sector banks
python ratios.py --example > inputs.json # emit the worked example below
python ratios.py --help
Input format
------------
A JSON object with:
``company``, ``currency``, ``basis``, ``sector`` -- free-text metadata.
``periods`` -- list of period objects, **ordered oldest first** (set
``"periods_order": "newest_first"`` if yours are reversed).
``market`` -- optional. Price / share count / EV-bridge items used for
valuation. Applies to the latest period.
Every numeric field is optional. Anything that cannot be computed from what was
supplied is reported as ``n/a`` with the reason, never guessed. Line items that
can be derived unambiguously from others (gross profit from revenue and COGS,
EBIT from EBITDA and depreciation, and so on) are derived and the derivation is
listed under INPUT INTEGRITY.
Recognised period fields (all optional, all in one consistent currency unit):
Income statement:
label, revenue, cogs, gross_profit, ebitda, depreciation, ebit,
other_income, interest, pbt, tax, cash_taxes_paid, minority_interest,
associate_profit, exceptional_items, pat
Balance sheet:
equity, minority_interest_bs, total_assets, goodwill, intangibles,
short_term_debt, long_term_debt, current_maturities, gross_debt,
lease_liabilities, rou_assets, cash, current_investments,
investments_in_associates, current_assets, current_liabilities,
inventory, receivables, payables, net_ppe, cwip, pension_deficit,
preference_capital, other_debt_like
Cash flow:
cfo, capex, acquisitions, dividends_paid, interest_paid, lease_expense,
preference_dividends
Shares:
shares_outstanding, diluted_shares, dividend_per_share
Recognised ``market`` fields:
price, as_of, market_cap, diluted_shares, surplus_cash, preference_capital,
minority_interest, pension_deficit, other_debt_like,
contingent_consideration, value_of_associates
Sign conventions: capex, acquisitions, dividends_paid, interest_paid and tax are
supplied as **positive outflows**. Revenue is net of indirect taxes (state the
basis in the report; peers must be on the same basis).
Worked example input
--------------------
A complete, runnable example (also available via ``--example``):
--- EXAMPLE INPUT (begin) ---
{
"company": "Example Manufacturing Ltd",
"currency": "INR crore",
"basis": "consolidated",
"sector": "manufacturing",
"source": "FY22-FY25 annual reports, consolidated; price from NSE 2026-07-21",
"periods_order": "oldest_first",
"periods": [
{
"label": "FY22",
"revenue": 4000, "cogs": 2400, "ebitda": 720, "depreciation": 180,
"other_income": 40, "interest": 90, "tax": 125, "minority_interest": 10,
"equity": 2100, "minority_interest_bs": 60, "total_assets": 3900,
"goodwill": 150, "intangibles": 90,
"short_term_debt": 250, "long_term_debt": 700, "current_maturities": 100,
"lease_liabilities": 180, "rou_assets": 170,
"cash": 260, "current_investments": 140, "investments_in_associates": 90,
"current_assets": 1750, "current_liabilities": 1100,
"inventory": 620, "receivables": 700, "payables": 560,
"net_ppe": 1500, "cwip": 120,
"cfo": 600, "capex": 260, "acquisitions": 0, "dividends_paid": 90,
"interest_paid": 88, "cash_taxes_paid": 120, "lease_expense": 60,
"shares_outstanding": 20.0, "diluted_shares": 20.05
},
{
"label": "FY23",
"revenue": 4560, "cogs": 2735, "ebitda": 830, "depreciation": 205,
"other_income": 45, "interest": 95, "tax": 148, "minority_interest": 12,
"equity": 2420, "minority_interest_bs": 68, "total_assets": 4350,
"goodwill": 150, "intangibles": 85,
"short_term_debt": 260, "long_term_debt": 720, "current_maturities": 110,
"lease_liabilities": 195, "rou_assets": 185,
"cash": 300, "current_investments": 150, "investments_in_associates": 95,
"current_assets": 1950, "current_liabilities": 1180,
"inventory": 700, "receivables": 820, "payables": 600,
"net_ppe": 1620, "cwip": 160,
"cfo": 640, "capex": 300, "acquisitions": 0, "dividends_paid": 100,
"interest_paid": 93, "cash_taxes_paid": 140, "lease_expense": 66,
"shares_outstanding": 20.0, "diluted_shares": 20.1
},
{
"label": "FY24",
"revenue": 5200, "cogs": 3120, "ebitda": 950, "depreciation": 235,
"other_income": 52, "interest": 102, "tax": 172, "minority_interest": 14,
"equity": 2790, "minority_interest_bs": 76, "total_assets": 4900,
"goodwill": 150, "intangibles": 80,
"short_term_debt": 280, "long_term_debt": 760, "current_maturities": 120,
"lease_liabilities": 210, "rou_assets": 200,
"cash": 330, "current_investments": 170, "investments_in_associates": 100,
"current_assets": 2200, "current_liabilities": 1290,
"inventory": 800, "receivables": 980, "payables": 640,
"net_ppe": 1780, "cwip": 210,
"cfo": 690, "capex": 340, "acquisitions": 0, "dividends_paid": 115,
"interest_paid": 100, "cash_taxes_paid": 165, "lease_expense": 72,
"shares_outstanding": 20.0, "diluted_shares": 20.15
},
{
"label": "FY25",
"revenue": 5950, "cogs": 3540, "ebitda": 1105, "depreciation": 268,
"other_income": 58, "interest": 110, "tax": 200, "minority_interest": 16,
"equity": 3230, "minority_interest_bs": 85, "total_assets": 5590,
"goodwill": 150, "intangibles": 75,
"short_term_debt": 300, "long_term_debt": 800, "current_maturities": 130,
"lease_liabilities": 225, "rou_assets": 215,
"cash": 380, "current_investments": 200, "investments_in_associates": 110,
"current_assets": 2560, "current_liabilities": 1420,
"inventory": 920, "receivables": 1220, "payables": 690,
"net_ppe": 1960, "cwip": 260,
"cfo": 760, "capex": 420, "acquisitions": 0, "dividends_paid": 130,
"interest_paid": 108, "cash_taxes_paid": 190, "lease_expense": 78,
"shares_outstanding": 20.0, "diluted_shares": 20.2
}
],
"market": {
"as_of": "2026-07-21",
"price": 620,
"diluted_shares": 20.2,
"pension_deficit": 30,
"preference_capital": 0,
"other_debt_like": 0,
"contingent_consideration": 0,
"value_of_associates": 110
}
}
--- EXAMPLE INPUT (end) ---
Running that example produces, among much else, a 14.2% revenue CAGR, an 18.6%
FY25 EBITDA margin, FY25 ROCE of 21.5% against ROIC of 16.1%, an EV of 13,404
built component by component, and two quality warnings: receivables compounding
at 20.3% against revenue at 14.2%, and a DSO that rises in every single year.
That pair is the classic early signature of channel stuffing or weakening
customers -- and it is invisible in the headline growth and margin figures,
which look excellent throughout.
Method notes that matter
------------------------
* Balance-sheet denominators use the **average** of opening and closing
balances wherever a prior period exists; the earliest period necessarily
uses closing balances and is marked as such.
* NOPAT uses a **normalised** tax rate (aggregate tax / aggregate PBT across
all supplied periods), not one year's effective rate. Override with
``--tax-rate``. India: a company that elected s.115BAA is not comparable to
its own pre-FY20 history; hold the rate constant for cross-cycle work.
* "OPM" is reported as the EBITDA margin (the Indian screener convention) and
separately as the EBIT margin. They are different numbers; the label is
always explicit here so the two are never mixed.
* Invested capital is computed by both the financing route and the operating
route and the two are reconciled. A gap means something is misclassified.
* ROIIC lags the capital denominator by one year, per the standard
construction, whenever enough periods are supplied.
* A silently wrong EV corrupts every EV multiple, so the bridge is printed
component by component, with every component that was *not* supplied listed
explicitly.
"""
from __future__ import annotations
import argparse
import json
import math
import sys
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
# --------------------------------------------------------------------------- #
# Types and constants
# --------------------------------------------------------------------------- #
Number = Optional[float]
# A computed cell is (value, reason_it_is_missing). Exactly one is not None.
Cell = Tuple[Number, Optional[str]]
DAYS_IN_YEAR = 365.0
#: Period fields the script understands. Anything else is reported as a typo
#: risk rather than silently ignored.
KNOWN_PERIOD_FIELDS = {
# metadata
"label", "years_from_prior", "note",
# income statement
"revenue", "cogs", "gross_profit", "ebitda", "depreciation", "ebit",
"other_income", "interest", "pbt", "tax", "cash_taxes_paid",
"minority_interest", "associate_profit", "exceptional_items", "pat",
# balance sheet
"equity", "minority_interest_bs", "total_assets", "goodwill", "intangibles",
"short_term_debt", "long_term_debt", "current_maturities", "gross_debt",
"lease_liabilities", "rou_assets", "cash", "current_investments",
"investments_in_associates", "current_assets", "current_liabilities",
"inventory", "receivables", "payables", "net_ppe", "cwip",
"pension_deficit", "preference_capital", "other_debt_like",
# cash flow
"cfo", "capex", "acquisitions", "dividends_paid", "interest_paid",
"lease_expense", "preference_dividends",
# shares
"shares_outstanding", "diluted_shares", "dividend_per_share",
}
KNOWN_MARKET_FIELDS = {
"price", "as_of", "market_cap", "diluted_shares", "shares_outstanding",
"surplus_cash", "preference_capital", "minority_interest",
"pension_deficit", "other_debt_like", "contingent_consideration",
"value_of_associates", "note",
}
#: Sectors where the standard ratio set is meaningless or inverted.
FINANCIAL_SECTOR_KEYS = {
"bank", "banks", "banking", "nbfc", "nbfcs", "hfc", "hfcs", "lender",
"financial", "financials", "finance", "insurance", "insurer", "insurers",
"life-insurance", "general-insurance", "reit", "reits", "invit", "invits",
"realestate-reit",
}
#: Subset of the above where enterprise value itself has no meaning, so every
#: EV multiple is suppressed rather than printed as a plausible-looking number.
EV_MEANINGLESS_SECTOR_KEYS = {
"bank", "banks", "banking", "nbfc", "nbfcs", "hfc", "hfcs", "lender",
"financial", "financials", "finance", "insurance", "insurer", "insurers",
"life-insurance", "general-insurance",
}
SECTOR_GATE_MESSAGE = {
"bank": (
"BANK / LENDER. Debt is raw material, not financing. Invested capital, "
"NOPAT, enterprise value, EV multiples, net debt/EBITDA, interest "
"coverage and ROIC are undefined for a bank; a bank is supposed to run "
"8-15x assets/equity, so the DuPont equity multiplier is the business "
"model and not a warning. Use ROA and ROE together with CET1/CAR, NIM, "
"cost-to-income, credit cost and RoRWA. See references/sectors/banks.md."
),
"nbfc": (
"NBFC / HFC. Leverage is the product. Use the lender form of DuPont: "
"ROA decomposed into NIM + fees - opex - credit cost, times the equity "
"multiplier, with leverage read against the regulatory ceiling. EV and "
"ROIC are meaningless. See references/sectors/nbfc.md."
),
"insurance": (
"INSURER. New-business strain depresses reported ROE precisely when the "
"company is writing profitable growth, and invested capital is not "
"meaningful against float. Life: ROEV, VNB margin, operating variances. "
"General: combined ratio, ROE ex-investment gains. "
"See references/sectors/insurance.md."
),
"reit": (
"REIT / InvIT. Assets are carried at fair value and the asset is the "
"business, so ROIC collapses toward the cap rate by construction and "
"earnings-based multiples are distorted by depreciation. Use AFFO "
"yield, NOI yield on cost, cap rate vs cost of debt and LTV. "
"See references/sectors/realestate-reit.md."
),
}
# --------------------------------------------------------------------------- #
# Small numeric helpers -- every one of these is explicit about failure
# --------------------------------------------------------------------------- #
def to_number(value: Any, where: str, key: str) -> Number:
"""Coerce an input value to float, or None if it is absent.
Empty strings, ``None`` and the strings "na"/"n/a"/"-" all mean "not
supplied". Anything else that is not numeric raises ValueError naming the
period and the key, because a silently dropped input is how a wrong number
reaches a report.
"""
if value is None:
return None
if isinstance(value, bool):
raise ValueError("%s: field '%s' is a boolean; expected a number" % (where, key))
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
text = value.strip().replace(",", "")
if text == "" or text.lower() in {"na", "n/a", "-", "nil", "none"}:
return None
try:
return float(text)
except ValueError:
raise ValueError(
"%s: field '%s' has non-numeric value %r" % (where, key, value)
)
raise ValueError("%s: field '%s' has unsupported type %s" % (where, key, type(value).__name__))
def divide(
numerator: Number,
denominator: Number,
num_label: str,
den_label: str,
scale: float = 1.0,
require_positive_denominator: bool = False,
) -> Cell:
"""Divide with explicit None / zero / negative-denominator handling.
Returns ``(value, None)`` on success and ``(None, reason)`` otherwise. The
reason names the missing or offending input so the caller can print exactly
why a metric could not be computed.
``require_positive_denominator`` is used for ratios that are not merely
unusual but *uninterpretable* when the base is negative -- ROE on negative
equity, ROIC on negative invested capital, a CAGR off a negative start.
"""
if numerator is None:
return None, "%s not available" % num_label
if denominator is None:
return None, "%s not available" % den_label
if denominator == 0:
return None, "%s is zero" % den_label
if require_positive_denominator and denominator < 0:
return None, "%s is negative (%s) - ratio is not meaningful" % (
den_label, format_number(denominator),
)
return numerator / denominator * scale, None
def subtract(a: Number, b: Number) -> Number:
"""Subtract, propagating None (a missing input must not become zero)."""
if a is None or b is None:
return None
return a - b
def add_optional(*values: Number) -> Number:
"""Sum values, returning None if every value is None; absent parts count 0.
Used only where a component genuinely defaults to zero when a company does
not have it (no leases, no minorities). Callers that must not default are
expected to use ``subtract``/direct access instead.
"""
present = [v for v in values if v is not None]
if not present:
return None
return float(sum(present))
def sum_strict(*values: Number) -> Number:
"""Sum, returning None if any component is missing."""
if any(v is None for v in values):
return None
return float(sum(v for v in values if v is not None))
def cagr(begin: Number, end: Number, years: float, label: str) -> Cell:
"""Compound annual growth rate in percent, with sign handling made explicit.
A CAGR from a negative or zero base is arithmetically undefined, and a CAGR
to a negative endpoint is meaningless; both are reported as such rather
than returned as a number.
"""
if begin is None or end is None:
return None, "%s not available for both endpoints" % label
if years <= 0:
return None, "period span is zero"
if begin <= 0:
return None, "%s starts at %s - CAGR from a non-positive base is undefined" % (
label, format_number(begin),
)
if end <= 0:
return None, "%s ends at %s - CAGR to a non-positive value is undefined" % (
label, format_number(end),
)
return ((end / begin) ** (1.0 / years) - 1.0) * 100.0, None
def yoy(prev: Number, cur: Number, label: str) -> Cell:
"""Year-on-year growth in percent, undefined off a non-positive base."""
if prev is None or cur is None:
return None, "%s not available for both periods" % label
if prev == 0:
return None, "prior-period %s is zero" % label
if prev < 0:
return None, "prior-period %s is negative (%s) - growth rate is not meaningful" % (
label, format_number(prev),
)
return (cur / prev - 1.0) * 100.0, None
def format_number(value: Number, decimals: int = 1) -> str:
"""Format an absolute figure with thousands separators."""
if value is None:
return "n/a"
return "{:,.{d}f}".format(value, d=decimals)
def format_cell(cell: Cell, unit: str) -> str:
"""Render a computed cell for the text table."""
value, _reason = cell
if value is None:
return "n/a"
if unit == "%":
return "{:,.1f}%".format(value)
if unit == "x":
return "{:,.2f}x".format(value)
if unit == "days":
return "{:,.0f}d".format(value)
if unit == "abs":
return format_number(value, 1)
if unit == "ps":
return "{:,.2f}".format(value)
if unit == "count":
return "{:,.2f}".format(value)
return "{:,.2f}".format(value)
# --------------------------------------------------------------------------- #
# Period model
# --------------------------------------------------------------------------- #
class Period:
"""One reporting period, with unambiguous line-item derivations applied.
Only derivations that cannot change the meaning of the number are performed
(gross profit from revenue less COGS, EBIT from EBITDA less depreciation,
and so on). Every derivation is recorded in ``self.derivations`` and printed
under INPUT INTEGRITY so a reader can see which figures came from the filing
and which the script inferred.
"""
def __init__(self, raw: Dict[str, Any], index: int) -> None:
"""Parse one raw period object and apply the safe derivations."""
self.index = index
self.label = str(raw.get("label") or "P%d" % (index + 1))
where = "period '%s'" % self.label
self.unknown_keys = sorted(k for k in raw if k not in KNOWN_PERIOD_FIELDS)
self.data: Dict[str, Number] = {}
for key, value in raw.items():
if key in {"label", "note"}:
continue
if key not in KNOWN_PERIOD_FIELDS:
continue
self.data[key] = to_number(value, where, key)
self.years_from_prior = self.data.pop("years_from_prior", None)
self.derivations: List[str] = []
self._derive()
# -- access ------------------------------------------------------------ #
def get(self, field: str) -> Number:
"""Return a line item, or None if it was neither supplied nor derived."""
return self.data.get(field)
def has(self, field: str) -> bool:
"""True when a line item is available."""
return self.data.get(field) is not None
def _set(self, field: str, value: Number, how: str) -> None:
"""Record a derived line item and how it was obtained."""
if value is None or self.data.get(field) is not None:
return
self.data[field] = value
self.derivations.append("%s: %s = %s" % (self.label, field, how))
# -- derivation -------------------------------------------------------- #
def _derive(self) -> None:
"""Fill in line items that follow unambiguously from those supplied."""
d = self.data
# Income statement ladder.
if d.get("gross_profit") is None and d.get("revenue") is not None and d.get("cogs") is not None:
self._set("gross_profit", d["revenue"] - d["cogs"], "revenue - COGS")
if d.get("cogs") is None and d.get("revenue") is not None and d.get("gross_profit") is not None:
self._set("cogs", d["revenue"] - d["gross_profit"], "revenue - gross profit")
if d.get("ebitda") is None and d.get("ebit") is not None and d.get("depreciation") is not None:
self._set("ebitda", d["ebit"] + d["depreciation"], "EBIT + depreciation & amortisation")
if d.get("ebit") is None and d.get("ebitda") is not None and d.get("depreciation") is not None:
self._set("ebit", d["ebitda"] - d["depreciation"], "EBITDA - depreciation & amortisation")
if d.get("depreciation") is None and d.get("ebitda") is not None and d.get("ebit") is not None:
self._set("depreciation", d["ebitda"] - d["ebit"], "EBITDA - EBIT")
# PBT only where the bridge is unambiguous: no exceptional items, and
# other income / interest both stated.
if (
d.get("pbt") is None
and d.get("ebit") is not None
and d.get("interest") is not None
and not d.get("exceptional_items")
):
other = d.get("other_income") or 0.0
assoc = d.get("associate_profit") or 0.0
self._set(
"pbt",
d["ebit"] + other + assoc - d["interest"],
"EBIT + other income + associate profit - interest (no exceptional items supplied)",
)
if d.get("pat") is None and d.get("pbt") is not None and d.get("tax") is not None:
minority = d.get("minority_interest") or 0.0
self._set(
"pat",
d["pbt"] - d["tax"] - minority,
"PBT - tax - minority interest",
)
if d.get("tax") is None and d.get("pbt") is not None and d.get("pat") is not None:
minority = d.get("minority_interest") or 0.0
self._set("tax", d["pbt"] - d["pat"] - minority, "PBT - PAT - minority interest")
# Debt aggregation.
if d.get("gross_debt") is None:
parts = [d.get("short_term_debt"), d.get("long_term_debt"), d.get("current_maturities")]
if any(p is not None for p in parts):
self._set(
"gross_debt",
float(sum(p for p in parts if p is not None)),
"sum of supplied debt components (short-term + long-term + current maturities)",
)
# Cash-flow derivations.
if d.get("capex") is not None and d.get("cfo") is not None:
self._set("fcf", d["cfo"] - d["capex"], "CFO - capex")
# -- composite balance-sheet quantities -------------------------------- #
def debt_including_leases(self) -> Number:
"""Gross debt plus capitalised lease liabilities (leases default to 0)."""
gross = self.get("gross_debt")
if gross is None:
return None
return gross + (self.get("lease_liabilities") or 0.0)
def liquid_assets(self) -> Number:
"""Cash and equivalents plus current investments / liquid funds.
Indian filings park surplus treasury in "current investments" (liquid
mutual funds) rather than in "cash and cash equivalents"; both lines
must be read or net debt is overstated.
"""
return add_optional(self.get("cash"), self.get("current_investments"))
def net_debt(self) -> Number:
"""Gross debt + leases - cash - current investments."""
debt = self.debt_including_leases()
if debt is None:
return None
liquid = self.liquid_assets()
if liquid is None:
return None
return debt - liquid
def net_working_capital(self) -> Number:
"""Operating working capital: current assets and liabilities, ex-finance.
Cash, current investments, short-term debt and current maturities are
removed so that a change in the treasury or the revolver does not read
as a change in operating working-capital intensity.
"""
ca, cl = self.get("current_assets"), self.get("current_liabilities")
if ca is None or cl is None:
return None
operating_ca = ca - (self.get("cash") or 0.0) - (self.get("current_investments") or 0.0)
operating_cl = cl - (self.get("short_term_debt") or 0.0) - (self.get("current_maturities") or 0.0)
return operating_ca - operating_cl
def invested_capital_financing(self) -> Cell:
"""Invested capital, financing route.
equity + minority interest + debt + leases - surplus cash and
non-operating investments. Returns the standard (value, reason) cell.
"""
equity = self.get("equity")
if equity is None:
return None, "shareholders' equity not available"
debt = self.debt_including_leases()
if debt is None:
return None, "gross debt not available"
liquid = self.liquid_assets()
if liquid is None:
return None, "cash / current investments not available"
value = (
equity
+ (self.get("minority_interest_bs") or 0.0)
+ debt
- liquid
- (self.get("investments_in_associates") or 0.0)
)
return value, None
def invested_capital_operating(self) -> Cell:
"""Invested capital, operating route.
net working capital + net PP&E + CWIP + right-of-use assets + goodwill
and acquired intangibles. Computed independently of the financing route
so the two can be reconciled; a material gap means something is
misclassified.
"""
nwc = self.net_working_capital()
if nwc is None:
return None, "current assets / current liabilities not available"
ppe = self.get("net_ppe")
if ppe is None:
return None, "net PP&E not available"
value = (
nwc
+ ppe
+ (self.get("cwip") or 0.0)
+ (self.get("rou_assets") or self.get("lease_liabilities") or 0.0)
+ (self.get("goodwill") or 0.0)
+ (self.get("intangibles") or 0.0)
)
return value, None
def capital_employed(self) -> Cell:
"""Capital employed for ROCE: total assets less current liabilities."""
ta, cl = self.get("total_assets"), self.get("current_liabilities")
if ta is not None and cl is not None:
return ta - cl, None
equity = self.get("equity")
debt = self.debt_including_leases()
if equity is not None and debt is not None:
return equity + (self.get("minority_interest_bs") or 0.0) + debt, None
return None, "total assets and current liabilities not available (nor equity + debt)"
def tangible_equity(self) -> Number:
"""Equity less goodwill and acquired intangibles."""
equity = self.get("equity")
if equity is None:
return None
return equity - (self.get("goodwill") or 0.0) - (self.get("intangibles") or 0.0)
def tangible_invested_capital(self) -> Cell:
"""Invested capital excluding goodwill and acquired intangibles.
Excluding goodwill measures how well the *operations* use capital;
including it measures capital allocation. Both are reported.
"""
base, reason = self.invested_capital_financing()
if base is None:
return None, reason
return base - (self.get("goodwill") or 0.0) - (self.get("intangibles") or 0.0), None
# --------------------------------------------------------------------------- #
# Output containers
# --------------------------------------------------------------------------- #
class Section:
"""A titled block of per-period rows plus whole-period scalar rows."""
def __init__(self, title: str, period_labels: Sequence[str], note: Optional[str] = None) -> None:
"""Create an empty section with a title, column labels and an optional note."""
self.title = title
self.period_labels = list(period_labels)
self.note = note
self.rows: List[Dict[str, Any]] = []
self.scalars: List[Dict[str, Any]] = []
self.suppressed: Optional[str] = None
def add(self, name: str, unit: str, cells: Sequence[Cell]) -> None:
"""Add a row with one cell per period."""
self.rows.append({"name": name, "unit": unit, "cells": list(cells)})
def add_scalar(self, name: str, unit: str, cell: Cell) -> None:
"""Add a single figure that spans the whole period set (a CAGR, ROIIC)."""
self.scalars.append({"name": name, "unit": unit, "cell": cell})
def suppress(self, reason: str) -> None:
"""Mark the whole section as not applicable, with the reason shown."""
self.suppressed = reason
class Warning_:
"""One triggered quality test, carrying the values that triggered it."""
def __init__(self, test: str, severity: str, detail: str, values: Dict[str, Any]) -> None:
"""Record a triggered test, its severity, the explanation and the inputs."""
self.test = test
self.severity = severity # "high" | "medium"
self.detail = detail
self.values = values
def to_dict(self) -> Dict[str, Any]:
"""Serialise for --json output."""
return {
"test": self.test,
"severity": self.severity,
"detail": self.detail,
"values": self.values,
}
# --------------------------------------------------------------------------- #
# The analysis engine
# --------------------------------------------------------------------------- #
class Analysis:
"""Computes the full ratio set from a parsed input document."""
def __init__(self, doc: Dict[str, Any], options: argparse.Namespace) -> None:
"""Validate the input document, build the period list and fix the tax rate.
Raises ValueError with a specific message on any structural problem, so
a malformed input fails loudly rather than producing a partial report.
"""
self.options = options
self.company = str(doc.get("company") or "(unnamed company)")
self.currency = str(doc.get("currency") or "units not stated")
self.basis = str(doc.get("basis") or "basis not stated (consolidated vs standalone!)")
self.source = doc.get("source")
self.sector_raw = (options.sector or doc.get("sector") or "").strip()
self.sector_key = self.sector_raw.lower().replace(" ", "-")
raw_periods = doc.get("periods")
if not isinstance(raw_periods, list) or not raw_periods:
raise ValueError("input must contain a non-empty 'periods' list")
if str(doc.get("periods_order", "oldest_first")).lower() in {"newest_first", "reverse", "desc"}:
raw_periods = list(reversed(raw_periods))
self.periods: List[Period] = [Period(p, i) for i, p in enumerate(raw_periods)]
self.n = len(self.periods)
self.labels = [p.label for p in self.periods]
market = doc.get("market") or {}
if not isinstance(market, dict):
raise ValueError("'market' must be an object")
self.market: Dict[str, Any] = {}
self.unknown_market_keys = sorted(k for k in market if k not in KNOWN_MARKET_FIELDS)
for key, value in market.items():
if key in {"as_of", "note"} or key not in KNOWN_MARKET_FIELDS:
self.market[key] = value
continue
self.market[key] = to_number(value, "market", key)
self.cautions: List[str] = []
self.method_notes: List[str] = []
self.sections: List[Section] = []
self.warnings: List[Warning_] = []
self.ev_bridge: Dict[str, Any] = {}
self.period_years = float(options.period_years)
self.normalised_tax_rate: Number = None
self.tax_rate_basis = ""
self._collect_input_cautions()
self._set_tax_rate()
# -- setup ------------------------------------------------------------- #
def _collect_input_cautions(self) -> None:
"""Record input-integrity issues: unknown keys, derivations, ordering."""
for period in self.periods:
for key in period.unknown_keys:
self.cautions.append(
"period '%s': unrecognised field '%s' was ignored - check for a typo"
% (period.label, key)
)
for key in self.unknown_market_keys:
self.cautions.append("market: unrecognised field '%s' was ignored" % key)
if self.n < 2:
self.cautions.append(
"only one period supplied: growth, CAGR, ROIIC, average balances and "
"trend-based quality tests cannot be computed"
)
if "consolidated" not in self.basis.lower() and "standalone" not in self.basis.lower():
self.cautions.append(
"'basis' does not state consolidated vs standalone - for any company with "
"subsidiaries these differ materially and mixing them invalidates every ratio"
)
def _set_tax_rate(self) -> None:
"""Establish the normalised tax rate used for NOPAT.
Aggregate tax over aggregate PBT across all supplied periods, which is
more stable than any single year's effective rate. ``--tax-rate``
overrides it; ``--use-cash-tax`` uses cash taxes paid instead of the
book charge.
"""
if self.options.tax_rate is not None:
self.normalised_tax_rate = float(self.options.tax_rate) / 100.0
self.tax_rate_basis = "supplied via --tax-rate"
self.method_notes.append(
"NOPAT uses a tax rate of %.1f%% supplied on the command line."
% (self.normalised_tax_rate * 100.0)
)
return
tax_field = "cash_taxes_paid" if self.options.use_cash_tax else "tax"
taxes, pbts = 0.0, 0.0
used = []
for period in self.periods:
tax, pbt = period.get(tax_field), period.get("pbt")
if tax is None or pbt is None or pbt <= 0:
continue
taxes += tax
pbts += pbt
used.append(period.label)
if pbts > 0:
self.normalised_tax_rate = taxes / pbts
self.tax_rate_basis = "aggregate %s / aggregate PBT over %s" % (
"cash taxes paid" if self.options.use_cash_tax else "tax charge",
", ".join(used),
)
self.method_notes.append(
"NOPAT uses a normalised tax rate of %.1f%% (%s), not any single year's "
"effective rate. India: an entity that elected s.115BAA is not comparable "
"to its own pre-FY20 history - hold the rate constant for cross-cycle work."
% (self.normalised_tax_rate * 100.0, self.tax_rate_basis)
)
else:
self.tax_rate_basis = "not derivable (tax and positive PBT not both available)"
self.cautions.append(
"normalised tax rate could not be derived, so NOPAT, ROIC, return on "
"tangible capital and ROIIC are unavailable; supply tax and PBT, or pass "
"--tax-rate"
)
# -- generic accessors ------------------------------------------------- #
def value(self, i: int, field: str) -> Number:
"""Line item for period i."""
return self.periods[i].get(field)
def average_balance(self, i: int, field: str) -> Cell:
"""Average of opening and closing balance for period i, where possible.
Falls back to the closing balance for the earliest period (or when the
prior year's figure is missing) and says so, because pairing a full
year of profit with a year-end balance after a mid-year acquisition is
one of the standard ways an ROCE series lies.
"""
current = self.value(i, field)
if current is None:
return None, "%s not available" % field.replace("_", " ")
if i == 0:
return current, None
prior = self.value(i - 1, field)
if prior is None:
return current, None
return (current + prior) / 2.0, None
def average_of(self, i: int, getter: Callable[[int], Cell]) -> Cell:
"""Average a computed balance (e.g. invested capital) over the period."""
current, reason = getter(i)
if current is None:
return None, reason
if i == 0:
return current, None
prior, _ = getter(i - 1)
if prior is None:
return current, None
return (current + prior) / 2.0, None
def ic_financing(self, i: int) -> Cell:
"""Invested capital (financing route) for period i."""
return self.periods[i].invested_capital_financing()
def ic_operating(self, i: int) -> Cell:
"""Invested capital (operating route) for period i."""
return self.periods[i].invested_capital_operating()
def capital_employed(self, i: int) -> Cell:
"""Capital employed for period i."""
return self.periods[i].capital_employed()
def tangible_ic(self, i: int) -> Cell:
"""Tangible invested capital for period i."""
return self.periods[i].tangible_invested_capital()
def nopat(self, i: int) -> Cell:
"""NOPAT = EBIT x (1 - normalised tax rate)."""
ebit = self.value(i, "ebit")
if ebit is None:
return None, "EBIT not available"
if self.normalised_tax_rate is None:
return None, "normalised tax rate not derivable (%s)" % self.tax_rate_basis
return ebit * (1.0 - self.normalised_tax_rate), None
def series(self, fn: Callable[[int], Cell]) -> List[Cell]:
"""Evaluate fn for every period, returning one cell per period."""
return [fn(i) for i in range(self.n)]
def blank_first(self) -> Cell:
"""The standard 'no prior period' cell for YoY-style rows."""
return None, "no prior period"
# ------------------------------------------------------------------ #
# Sections
# ------------------------------------------------------------------ #
def build_all(self) -> None:
"""Compute every section, the EV bridge and the quality warnings."""
self.method_notes.append(
"Balance-sheet denominators use the average of opening and closing balances "
"wherever a prior period exists; the earliest period necessarily uses closing "
"balances."
)
self.method_notes.append(
"Periods are assumed to be %.2f year(s) apart (change with --period-years)."
% self.period_years
)
self.sections.append(self.section_growth())
self.sections.append(self.section_margins())
self.sections.append(self.section_returns())
self.sections.append(self.section_dupont3())
self.sections.append(self.section_dupont5())
self.sections.append(self.section_incremental())
self.sections.append(self.section_leverage())
self.sections.append(self.section_liquidity())
self.sections.append(self.section_working_capital())
self.sections.append(self.section_cash_quality())
self.sections.append(self.section_per_share())
self.build_ev_bridge()
self.sections.append(self.section_valuation())
self.detect_quality_warnings()
# -- growth ------------------------------------------------------------ #
def section_growth(self) -> Section:
"""Year-on-year growth and full-period CAGRs for the top lines."""
section = Section("GROWTH", self.labels)
span = (self.n - 1) * self.period_years
for field, name in (
("revenue", "Revenue"),
("gross_profit", "Gross profit"),
("ebitda", "EBITDA"),
("ebit", "EBIT"),
("pat", "PAT"),
):
cells: List[Cell] = [self.blank_first()]
for i in range(1, self.n):
cells.append(yoy(self.value(i - 1, field), self.value(i, field), name.lower()))
section.add("%s YoY" % name, "%", cells)
for field, name in (
("revenue", "Revenue"),
("ebitda", "EBITDA"),
("pat", "PAT"),
("gross_profit", "Gross profit"),
):
if self.n < 2:
section.add_scalar("%s CAGR" % name, "%", (None, "needs at least two periods"))
continue
section.add_scalar(
"%s CAGR (%s -> %s, %.1fy)" % (name, self.labels[0], self.labels[-1], span),
"%",
cagr(self.value(0, field), self.value(self.n - 1, field), span, name.lower()),
)
# A 3-year CAGR alongside the full span, where the history allows it.
if self.n >= 4:
span3 = 3 * self.period_years
for field, name in (("revenue", "Revenue"), ("ebitda", "EBITDA"), ("pat", "PAT")):
section.add_scalar(
"%s CAGR (last %.1fy)" % (name, span3),
"%",
cagr(self.value(self.n - 4, field), self.value(self.n - 1, field), span3, name.lower()),
)
return section
# -- margins ----------------------------------------------------------- #
def section_margins(self) -> Section:
"""The full margin ladder, from gross margin down to net margin."""
section = Section(
"MARGIN LADDER",
self.labels,
note=(
"'OPM' in Indian screeners is the EBITDA margin; the EBIT margin is a "
"different number. Both are labelled explicitly here so they are never mixed."
),
)
rev = lambda i: self.value(i, "revenue") # noqa: E731 - local shorthand
section.add("Revenue", "abs", self.series(lambda i: (rev(i), "revenue not available")
if rev(i) is None else (rev(i), None)))
section.add("Gross margin", "%", self.series(
lambda i: divide(self.value(i, "gross_profit"), rev(i), "gross profit", "revenue", 100.0)))
section.add("EBITDA margin (OPM)", "%", self.series(
lambda i: divide(self.value(i, "ebitda"), rev(i), "EBITDA", "revenue", 100.0)))
section.add("EBIT margin", "%", self.series(
lambda i: divide(self.value(i, "ebit"), rev(i), "EBIT", "revenue", 100.0)))
section.add("PBT margin", "%", self.series(
lambda i: divide(self.value(i, "pbt"), rev(i), "PBT", "revenue", 100.0)))
section.add("Net (PAT) margin", "%", self.series(
lambda i: divide(self.value(i, "pat"), rev(i), "PAT", "revenue", 100.0)))
section.add("Depreciation % of sales", "%", self.series(
lambda i: divide(self.value(i, "depreciation"), rev(i), "depreciation", "revenue", 100.0)))
section.add("Interest % of sales", "%", self.series(
lambda i: divide(self.value(i, "interest"), rev(i), "interest", "revenue", 100.0)))
section.add("Effective tax rate", "%", self.series(
lambda i: divide(self.value(i, "tax"), self.value(i, "pbt"), "tax charge", "PBT", 100.0,
require_positive_denominator=True)))
section.add("Cash tax rate", "%", self.series(
lambda i: divide(self.value(i, "cash_taxes_paid"), self.value(i, "pbt"),
"cash taxes paid", "PBT", 100.0, require_positive_denominator=True)))
return section
# -- returns ----------------------------------------------------------- #
def section_returns(self) -> Section:
"""ROE, ROCE, ROIC, ROA and the tangible-capital variants."""
section = Section(
"RETURNS ON CAPITAL",
self.labels,
note=(
"Return on capital, not margin, is what compounds. Invested capital is "
"computed by both routes and reconciled; a material gap means something is "
"misclassified. ROIC excluding goodwill measures the operations, ROIC "
"including it measures capital allocation - both are shown."
),
)
section.add("NOPAT", "abs", self.series(self.nopat))
section.add("Invested capital (financing route)", "abs", self.series(self.ic_financing))
section.add("Invested capital (operating route)", "abs", self.series(self.ic_operating))
def reconciliation(i: int) -> Cell:
"""Financing-route minus operating-route invested capital."""
a, reason_a = self.ic_financing(i)
b, reason_b = self.ic_operating(i)
if a is None:
return None, reason_a
if b is None:
return None, reason_b
return a - b, None
section.add(" reconciliation gap (fin - op)", "abs", self.series(reconciliation))
section.add("Capital employed", "abs", self.series(self.capital_employed))
section.add("ROE", "%", self.series(
lambda i: divide(self.value(i, "pat"), self.average_balance(i, "equity")[0],
"PAT", "average shareholders' equity", 100.0,
require_positive_denominator=True)))
section.add("ROCE (EBIT / avg capital employed)", "%", self.series(
lambda i: divide(self.value(i, "ebit"), self.average_of(i, self.capital_employed)[0],
"EBIT", "average capital employed", 100.0,
require_positive_denominator=True)))
section.add("ROIC (NOPAT / avg invested capital)", "%", self.series(
lambda i: divide(self.nopat(i)[0], self.average_of(i, self.ic_financing)[0],
"NOPAT", "average invested capital", 100.0,
require_positive_denominator=True)))
section.add("ROIC ex-goodwill (tangible capital)", "%", self.series(
lambda i: divide(self.nopat(i)[0], self.average_of(i, self.tangible_ic)[0],
"NOPAT", "average tangible invested capital", 100.0,
require_positive_denominator=True)))
section.add("Return on tangible equity", "%", self.series(
lambda i: divide(self.value(i, "pat"),
self.average_of(i, lambda j: (self.periods[j].tangible_equity(),
"equity not available"))[0],
"PAT", "average tangible equity", 100.0,
require_positive_denominator=True)))
section.add("ROA (PAT / avg total assets)", "%", self.series(
lambda i: divide(self.value(i, "pat"), self.average_balance(i, "total_assets")[0],
"PAT", "average total assets", 100.0,
require_positive_denominator=True)))
section.add("NOPAT margin", "%", self.series(
lambda i: divide(self.nopat(i)[0], self.value(i, "revenue"), "NOPAT", "revenue", 100.0)))
section.add("Capital turnover (sales / avg IC)", "x", self.series(
lambda i: divide(self.value(i, "revenue"), self.average_of(i, self.ic_financing)[0],
"revenue", "average invested capital",
require_positive_denominator=True)))
section.add("Fixed-asset turnover (sales / avg net PP&E)", "x", self.series(
lambda i: divide(self.value(i, "revenue"), self.average_balance(i, "net_ppe")[0],
"revenue", "average net PP&E", require_positive_denominator=True)))
return section
# -- DuPont ------------------------------------------------------------ #
def section_dupont3(self) -> Section:
"""3-step DuPont: what kind of business is this."""
section = Section(
"DUPONT - 3 STEP (ROE = net margin x asset turnover x equity multiplier)",
self.labels,
note="Answers 'what kind of business is this'. Average balances are used, so "
"the product reconciles exactly to ROE.",
)
net_margin = self.series(
lambda i: divide(self.value(i, "pat"), self.value(i, "revenue"), "PAT", "revenue"))
asset_turnover = self.series(
lambda i: divide(self.value(i, "revenue"), self.average_balance(i, "total_assets")[0],
"revenue", "average total assets", require_positive_denominator=True))
equity_multiplier = self.series(
lambda i: divide(self.average_balance(i, "total_assets")[0],
self.average_balance(i, "equity")[0],
"average total assets", "average equity",
require_positive_denominator=True))
section.add("Net margin (PAT / sales)", "%", [
(c[0] * 100.0 if c[0] is not None else None, c[1]) for c in net_margin])
section.add("Asset turnover (sales / avg assets)", "x", asset_turnover)
section.add("Equity multiplier (avg assets / avg equity)", "x", equity_multiplier)
section.add("= ROE (product of the three)", "%", [
self._product([net_margin[i], asset_turnover[i], equity_multiplier[i]], 100.0)
for i in range(self.n)])
section.add("ROE (direct: PAT / avg equity)", "%", self.series(
lambda i: divide(self.value(i, "pat"), self.average_balance(i, "equity")[0],
"PAT", "average equity", 100.0, require_positive_denominator=True)))
return section
def section_dupont5(self) -> Section:
"""5-step DuPont: where the ROE is actually coming from."""
section = Section(
"DUPONT - 5 STEP (ROE = tax burden x interest burden x EBIT margin x asset turnover x leverage)",
self.labels,
note=(
"Separates operations-driven ROE (margin x turnover) from financing-driven "
"ROE (tax burden, interest burden, leverage). Two firms with the same ROE "
"and different decompositions are not comparable investments. Note that the "
"interest burden can exceed 1.0 where other income is large, since PBT then "
"sits above EBIT."
),
)
tax_burden = self.series(
lambda i: divide(self.value(i, "pat"), self.value(i, "pbt"), "PAT", "PBT"))
interest_burden = self.series(
lambda i: divide(self.value(i, "pbt"), self.value(i, "ebit"), "PBT", "EBIT"))
ebit_margin = self.series(
lambda i: divide(self.value(i, "ebit"), self.value(i, "revenue"), "EBIT", "revenue"))
asset_turnover = self.series(
lambda i: divide(self.value(i, "revenue"), self.average_balance(i, "total_assets")[0],
"revenue", "average total assets", require_positive_denominator=True))
leverage = self.series(
lambda i: divide(self.average_balance(i, "total_assets")[0],
self.average_balance(i, "equity")[0],
"average total assets", "average equity",
require_positive_denominator=True))
section.add("Tax burden (PAT / PBT)", "x", tax_burden)
section.add("Interest burden (PBT / EBIT)", "x", interest_burden)
section.add("EBIT margin (EBIT / sales)", "%", [
(c[0] * 100.0 if c[0] is not None else None, c[1]) for c in ebit_margin])
section.add("Asset turnover (sales / avg assets)", "x", asset_turnover)
section.add("Equity multiplier (avg assets / avg equity)", "x", leverage)
section.add("= ROE (product of the five)", "%", [
self._product([tax_burden[i], interest_burden[i], ebit_margin[i],
asset_turnover[i], leverage[i]], 100.0)
for i in range(self.n)])
if self.n >= 2:
section.add_scalar(
"ROE driver over the full period",
"text",
self._dupont_attribution(tax_burden, interest_burden, ebit_margin,
asset_turnover, leverage),
)
return section
@staticmethod
def _product(cells: Sequence[Cell], scale: float = 1.0) -> Cell:
"""Multiply cells, propagating the first missing-value reason."""
total = scale
for value, reason in cells:
if value is None:
return None, reason or "component not available"
total *= value
return total, None
def _dupont_attribution(self, *term_series: Sequence[Cell]) -> Cell:
"""Name the DuPont term that moved most between first and last period.
Uses the log-decomposition of a product: the term with the largest
absolute change in log-value contributed most to the change in ROE.
"""
names = ["tax burden", "interest burden", "EBIT margin", "asset turnover", "leverage"]
contributions = []
for name, cells in zip(names, term_series):
first, last = cells[0][0], cells[-1][0]
if first is None or last is None or first <= 0 or last <= 0:
continue
contributions.append((abs(math.log(last / first)), name, first, last))
if not contributions:
return None, "not enough positive terms in both endpoint periods"
contributions.sort(reverse=True)
_, name, first, last = contributions[0]
operations = {"EBIT margin", "asset turnover"}
character = "operations-driven" if name in operations else "financing/tax-driven"
return (
"largest move: %s, %.3f -> %.3f (%s)" % (name, first, last, character)
), None
# -- incremental returns ----------------------------------------------- #
def section_incremental(self) -> Section:
"""ROIIC, the reinvestment rate and the growth they jointly imply."""
section = Section(
"INCREMENTAL RETURNS",
self.labels,
note=(
"Average ROIC is history; ROIIC is the forecast. The capital denominator is "
"lagged one year because capital takes time to earn. Blended ROIC drifts "
"toward ROIIC as new capital dominates the base - which is why a company can "
"report record profits for years while every new rupee destroys value."
),
)
window = int(self.options.roiic_years)
nopats = [self.nopat(i)[0] for i in range(self.n)]
ics = [self.ic_financing(i)[0] for i in range(self.n)]
last = self.n - 1
roiic_cell: Cell = (None, "needs at least two periods")
if self.n < 2:
section.add_scalar("ROIIC", "%", roiic_cell)
else:
k = min(window, self.n - 1)
lagged = self.n >= k + 2
if lagged:
num_hi, num_lo = last, last - k
den_hi, den_lo = last - 1, last - k - 1
basis = "%d-year, capital lagged one year (%s..%s vs capital %s..%s)" % (
k, self.labels[num_lo], self.labels[num_hi],
self.labels[den_lo], self.labels[den_hi])
else:
num_hi, num_lo = last, last - k
den_hi, den_lo = last, last - k
basis = "%d-year, capital NOT lagged (only %d periods supplied)" % (k, self.n)
section.add_scalar("ROIIC basis", "text", (basis, None))
roiic_cell = self._roiic(nopats, ics, num_lo, num_hi, den_lo, den_hi)
section.add_scalar("ROIIC", "%", roiic_cell)
section.add_scalar(
" change in NOPAT", "abs",
(subtract(nopats[num_hi], nopats[num_lo]), "NOPAT not available for both endpoints")
if nopats[num_hi] is not None and nopats[num_lo] is not None
else (None, "NOPAT not available for both endpoints"))
section.add_scalar(
" change in invested capital", "abs",
(subtract(ics[den_hi], ics[den_lo]), None)
if ics[den_hi] is not None and ics[den_lo] is not None
else (None, "invested capital not available for both endpoints"))
section.add_scalar(
"ROIC at start of window", "%",
divide(nopats[num_lo], ics[num_lo], "opening NOPAT", "opening invested capital",
100.0, require_positive_denominator=True))
section.add_scalar(
"ROIC at end of window", "%",
divide(nopats[num_hi], ics[num_hi], "closing NOPAT", "closing invested capital",
100.0, require_positive_denominator=True))
# Reinvestment rate, per period.
def reinvestment(i: int) -> Cell:
"""(capex + acquisitions + change in NWC - depreciation) / NOPAT."""
capex = self.value(i, "capex")
if capex is None:
return None, "capex not available"
dep = self.value(i, "depreciation")
if dep is None:
return None, "depreciation not available"
nopat_value, reason = self.nopat(i)
if nopat_value is None:
return None, reason
if nopat_value <= 0:
return None, "NOPAT is not positive - reinvestment rate is not meaningful"
delta_nwc = 0.0
if i > 0:
cur = self.periods[i].net_working_capital()
prev = self.periods[i - 1].net_working_capital()
if cur is not None and prev is not None:
delta_nwc = cur - prev
spend = capex + (self.value(i, "acquisitions") or 0.0) + delta_nwc - dep
return spend / nopat_value * 100.0, None
section.add("Reinvestment rate (% of NOPAT)", "%", self.series(reinvestment))
section.add("Capex / depreciation", "x", self.series(
lambda i: divide(self.value(i, "capex"), self.value(i, "depreciation"),
"capex", "depreciation")))
if self.n >= 2:
roiic_value, roiic_reason = roiic_cell
reinvest_last, reinvest_reason = reinvestment(self.n - 1)
if roiic_value is None or reinvest_last is None:
section.add_scalar(
"Implied intrinsic growth (reinvestment x ROIIC)", "%",
(None, "needs both ROIIC and the latest reinvestment rate (%s)"
% (roiic_reason or reinvest_reason or "input missing")))
else:
section.add_scalar(
"Implied intrinsic growth (reinvestment x ROIIC)", "%",
(reinvest_last / 100.0 * roiic_value, None))
return section
@staticmethod
def _roiic(nopats: Sequence[Number], ics: Sequence[Number],
num_lo: int, num_hi: int, den_lo: int, den_hi: int) -> Cell:
"""Return on incremental invested capital over a window."""
if nopats[num_hi] is None or nopats[num_lo] is None:
return None, "NOPAT not available at both ends of the window"
if ics[den_hi] is None or ics[den_lo] is None:
return None, "invested capital not available at both ends of the window"
delta_nopat = nopats[num_hi] - nopats[num_lo]
delta_ic = ics[den_hi] - ics[den_lo]
if delta_ic == 0:
return None, "invested capital did not change over the window"
if delta_ic < 0:
return None, (
"invested capital fell by %s over the window - ROIIC is not meaningful "
"when the capital base shrinks (report the NOPAT change and the capital "
"release separately)" % format_number(abs(delta_ic))
)
return delta_nopat / delta_ic * 100.0, None
# -- leverage ---------------------------------------------------------- #
def section_leverage(self) -> Section:
"""Debt levels and the coverage ratios that decide refinancing terms."""
section = Section(
"LEVERAGE AND COVERAGE",
self.labels,
note=(
"Debt includes capitalised lease liabilities throughout. Net debt deducts "
"cash and current investments (Indian filings park surplus treasury in "
"current investments, not in cash). Every band is sector-dependent: 3x net "
"debt/EBITDA is prudent for a contracted utility and reckless for a mid-cap "
"with a 200-day cash cycle."
),
)
section.add("Gross debt (incl. leases)", "abs", self.series(
lambda i: (self.periods[i].debt_including_leases(), "gross debt not available")
if self.periods[i].debt_including_leases() is None
else (self.periods[i].debt_including_leases(), None)))
section.add("Net debt", "abs", self.series(
lambda i: (self.periods[i].net_debt(), None)
if self.periods[i].net_debt() is not None
else (None, "gross debt or cash / current investments not available")))
section.add("Debt / equity", "x", self.series(
lambda i: divide(self.periods[i].debt_including_leases(), self.value(i, "equity"),
"gross debt incl. leases", "shareholders' equity",
require_positive_denominator=True)))
section.add("Net debt / equity", "x", self.series(
lambda i: divide(self.periods[i].net_debt(), self.value(i, "equity"),
"net debt", "shareholders' equity",
require_positive_denominator=True)))
section.add("Net debt / EBITDA", "x", self.series(
lambda i: divide(self.periods[i].net_debt(), self.value(i, "ebitda"),
"net debt", "EBITDA", require_positive_denominator=True)))
section.add("Gross debt / EBITDA", "x", self.series(
lambda i: divide(self.periods[i].debt_including_leases(), self.value(i, "ebitda"),
"gross debt", "EBITDA", require_positive_denominator=True)))
def net_debt_over_ebitda_less_capex(i: int) -> Cell:
"""Leverage against the cash left after sustaining capital spend."""
ebitda, capex = self.value(i, "ebitda"), self.value(i, "capex")
if ebitda is None:
return None, "EBITDA not available"
if capex is None:
return None, "capex not available"
return divide(self.periods[i].net_debt(), ebitda - capex,
"net debt", "EBITDA less capex", require_positive_denominator=True)
section.add("Net debt / (EBITDA - capex)", "x", self.series(net_debt_over_ebitda_less_capex))
section.add("Interest coverage (EBIT / interest)", "x", self.series(
lambda i: divide(self.value(i, "ebit"), self.value(i, "interest"),
"EBIT", "interest expense", require_positive_denominator=True)))
section.add("EBITDA interest coverage", "x", self.series(
lambda i: divide(self.value(i, "ebitda"), self.value(i, "interest"),
"EBITDA", "interest expense", require_positive_denominator=True)))
def cash_interest_coverage(i: int) -> Cell:
"""(CFO before interest) / cash interest paid. Cash pays interest."""
cfo, paid = self.value(i, "cfo"), self.value(i, "interest_paid")
if cfo is None:
return None, "CFO not available"
if paid is None:
return None, "interest paid (cash) not available"
return divide(cfo + paid, paid, "CFO before interest", "cash interest paid",
require_positive_denominator=True)
section.add("Cash interest coverage", "x", self.series(cash_interest_coverage))
def fixed_charge_coverage(i: int) -> Cell:
"""(EBIT + lease expense) / (interest + lease expense + pref dividends).
The only fair coverage measure where leases and preference capital
are large -- retail, airlines, shipping, hotels.
"""
ebit, interest = self.value(i, "ebit"), self.value(i, "interest")
lease = self.value(i, "lease_expense")
if ebit is None:
return None, "EBIT not available"
if interest is None:
return None, "interest expense not available"
if lease is None:
return None, ("lease expense not available - without it this is just interest "
"coverage, which understates the fixed-charge burden of a "
"lease-heavy business")
pref = self.value(i, "preference_dividends") or 0.0
return divide(ebit + lease, interest + lease + pref,
"EBIT plus lease expense", "interest plus leases plus preference dividends",
require_positive_denominator=True)
section.add("Fixed-charge coverage", "x", self.series(fixed_charge_coverage))
return section
# -- liquidity --------------------------------------------------------- #
def section_liquidity(self) -> Section:
"""Current, quick and cash ratios."""
section = Section("LIQUIDITY", self.labels)
section.add("Current ratio", "x", self.series(
lambda i: divide(self.value(i, "current_assets"), self.value(i, "current_liabilities"),
"current assets", "current liabilities",
require_positive_denominator=True)))
def quick(i: int) -> Cell:
"""(Current assets - inventory) / current liabilities."""
ca, inv = self.value(i, "current_assets"), self.value(i, "inventory")
if ca is None:
return None, "current assets not available"
if inv is None:
return None, "inventory not available"
return divide(ca - inv, self.value(i, "current_liabilities"),
"current assets less inventory", "current liabilities",
require_positive_denominator=True)
section.add("Quick ratio", "x", self.series(quick))
section.add("Cash ratio", "x", self.series(
lambda i: divide(self.periods[i].liquid_assets(), self.value(i, "current_liabilities"),
"cash and current investments", "current liabilities",
require_positive_denominator=True)))
return section
# -- working capital --------------------------------------------------- #
def section_working_capital(self) -> Section:
"""DSO, DIO, DPO, the cash conversion cycle and working-capital intensity."""
section = Section(
"WORKING CAPITAL",
self.labels,
note=(
"Days are computed on average balances. A CCC that improves because DPO "
"jumped is supplier financing, and a CCC that improves because receivables "
"were factored is a balance-sheet transaction - neither is an operating gain, "
"and both reverse."
),
)
def dso(i: int) -> Cell:
"""Trade receivables / revenue x 365, on average balances."""
avg, reason = self.average_balance(i, "receivables")
if avg is None:
return None, reason
return divide(avg, self.value(i, "revenue"), "average receivables", "revenue",
DAYS_IN_YEAR, require_positive_denominator=True)
def dio(i: int) -> Cell:
"""Inventory / COGS x 365, on average balances."""
avg, reason = self.average_balance(i, "inventory")
if avg is None:
return None, reason
return divide(avg, self.value(i, "cogs"), "average inventory", "COGS",
DAYS_IN_YEAR, require_positive_denominator=True)
def dpo(i: int) -> Cell:
"""Trade payables / COGS x 365, on average balances."""
avg, reason = self.average_balance(i, "payables")
if avg is None:
return None, reason
return divide(avg, self.value(i, "cogs"), "average payables", "COGS",
DAYS_IN_YEAR, require_positive_denominator=True)
dso_cells = self.series(dso)
dio_cells = self.series(dio)
dpo_cells = self.series(dpo)
section.add("DSO (receivable days)", "days", dso_cells)
section.add("DIO (inventory days)", "days", dio_cells)
section.add("DPO (payable days)", "days", dpo_cells)
ccc_cells: List[Cell] = []
for i in range(self.n):
parts = [dso_cells[i], dio_cells[i], dpo_cells[i]]
missing = [c[1] for c in parts if c[0] is None]
if missing:
ccc_cells.append((None, "; ".join(sorted(set(m for m in missing if m)))))
else:
ccc_cells.append((dso_cells[i][0] + dio_cells[i][0] - dpo_cells[i][0], None))
section.add("Cash conversion cycle (DSO + DIO - DPO)", "days", ccc_cells)
section.add("Net working capital", "abs", self.series(
lambda i: (self.periods[i].net_working_capital(), None)
if self.periods[i].net_working_capital() is not None
else (None, "current assets / current liabilities not available")))
section.add("Net working capital / sales", "%", self.series(
lambda i: divide(self.periods[i].net_working_capital(), self.value(i, "revenue"),
"net working capital", "revenue", 100.0)))
def delta_nwc_over_delta_revenue(i: int) -> Cell:
"""Change in NWC divided by change in revenue: does growth eat cash?"""
if i == 0:
return None, "no prior period"
cur, prev = self.periods[i].net_working_capital(), self.periods[i - 1].net_working_capital()
if cur is None or prev is None:
return None, "net working capital not available for both periods"
rev_cur, rev_prev = self.value(i, "revenue"), self.value(i - 1, "revenue")
if rev_cur is None or rev_prev is None:
return None, "revenue not available for both periods"
if rev_cur == rev_prev:
return None, "revenue did not change"
return (cur - prev) / (rev_cur - rev_prev) * 100.0, None
section.add("Change in NWC / change in revenue", "%", self.series(delta_nwc_over_delta_revenue))
return section
# -- cash flow quality ------------------------------------------------- #
def section_cash_quality(self) -> Section:
"""Whether the reported profit turns into cash."""
section = Section(
"CASH FLOW QUALITY",
self.labels,
note=(
"The Sloan accrual ratio -- (net income - CFO) / average total assets -- is "
"one of the most robust anomalies in the literature: above roughly 10% is a "
"red flag for earnings reversal."
),
)
section.add("CFO", "abs", self.series(
lambda i: (self.value(i, "cfo"), None) if self.value(i, "cfo") is not None
else (None, "CFO not available")))
section.add("Capex", "abs", self.series(
lambda i: (self.value(i, "capex"), None) if self.value(i, "capex") is not None
else (None, "capex not available")))
section.add("Free cash flow (CFO - capex)", "abs", self.series(
lambda i: (self.value(i, "fcf"), None) if self.value(i, "fcf") is not None
else (None, "CFO or capex not available")))
section.add("OCF / EBITDA", "x", self.series(
lambda i: divide(self.value(i, "cfo"), self.value(i, "ebitda"), "CFO", "EBITDA",
require_positive_denominator=True)))
section.add("OCF / net profit", "x", self.series(
lambda i: divide(self.value(i, "cfo"), self.value(i, "pat"), "CFO", "PAT",
require_positive_denominator=True)))
section.add("FCF / net profit", "x", self.series(
lambda i: divide(self.value(i, "fcf"), self.value(i, "pat"), "FCF", "PAT",
require_positive_denominator=True)))
section.add("FCF margin", "%", self.series(
lambda i: divide(self.value(i, "fcf"), self.value(i, "revenue"), "FCF", "revenue", 100.0)))
section.add("FCF / EBITDA (cash conversion)", "%", self.series(
lambda i: divide(self.value(i, "fcf"), self.value(i, "ebitda"), "FCF", "EBITDA", 100.0,
require_positive_denominator=True)))
def accrual_ratio(i: int) -> Cell:
"""Sloan accrual ratio: (PAT - CFO) / average total assets."""
pat, cfo = self.value(i, "pat"), self.value(i, "cfo")
if pat is None:
return None, "PAT not available"
if cfo is None:
return None, "CFO not available"
avg, reason = self.average_balance(i, "total_assets")
if avg is None:
return None, reason
return divide(pat - cfo, avg, "PAT less CFO", "average total assets", 100.0,
require_positive_denominator=True)
section.add("Accrual ratio (Sloan)", "%", self.series(accrual_ratio))
section.add("Capex / sales", "%", self.series(
lambda i: divide(self.value(i, "capex"), self.value(i, "revenue"), "capex", "revenue", 100.0)))
section.add("Capex / depreciation", "x", self.series(
lambda i: divide(self.value(i, "capex"), self.value(i, "depreciation"),
"capex", "depreciation", require_positive_denominator=True)))
# Cumulative cash conversion over the whole period -- more informative
# than any single year, and the form the literature uses.
cfo_total = sum(p.get("cfo") for p in self.periods if p.get("cfo") is not None)
pat_total = sum(p.get("pat") for p in self.periods if p.get("pat") is not None)
have_all = all(p.has("cfo") and p.has("pat") for p in self.periods)
if have_all:
section.add_scalar("Cumulative CFO / cumulative PAT", "x",
divide(cfo_total, pat_total, "cumulative CFO", "cumulative PAT",
require_positive_denominator=True))
else:
section.add_scalar("Cumulative CFO / cumulative PAT", "x",
(None, "CFO and PAT are not available for every period"))
return section
# -- per share --------------------------------------------------------- #
def _diluted_shares(self, i: int) -> Cell:
"""Diluted share count for period i, falling back to the basic count."""
diluted = self.value(i, "diluted_shares")
if diluted is not None:
return diluted, None
basic = self.value(i, "shares_outstanding")
if basic is not None:
return basic, None
return None, "no share count supplied"
def section_per_share(self) -> Section:
"""Per-share figures on the diluted count."""
section = Section(
"PER SHARE",
self.labels,
note=(
"Computed on the diluted count where supplied (basic count otherwise). A "
"count lifted from the cover of a filing understates the claim on the "
"business wherever options, RSUs, warrants, CCPS or convertibles exist."
),
)
section.add("Diluted shares", "count", self.series(self._diluted_shares))
section.add("EPS (diluted)", "ps", self.series(
lambda i: divide(self.value(i, "pat"), self._diluted_shares(i)[0],
"PAT", "diluted share count", require_positive_denominator=True)))
section.add("Book value per share", "ps", self.series(
lambda i: divide(self.value(i, "equity"), self._diluted_shares(i)[0],
"shareholders' equity", "diluted share count",
require_positive_denominator=True)))
section.add("Tangible book value per share", "ps", self.series(
lambda i: divide(self.periods[i].tangible_equity(), self._diluted_shares(i)[0],
"tangible equity", "diluted share count",
require_positive_denominator=True)))
section.add("Revenue per share", "ps", self.series(
lambda i: divide(self.value(i, "revenue"), self._diluted_shares(i)[0],
"revenue", "diluted share count", require_positive_denominator=True)))
section.add("CFO per share", "ps", self.series(
lambda i: divide(self.value(i, "cfo"), self._diluted_shares(i)[0],
"CFO", "diluted share count", require_positive_denominator=True)))
section.add("FCF per share", "ps", self.series(
lambda i: divide(self.value(i, "fcf"), self._diluted_shares(i)[0],
"FCF", "diluted share count", require_positive_denominator=True)))
def dps(i: int) -> Cell:
"""Dividend per share, taken directly or from total dividends paid."""
direct = self.value(i, "dividend_per_share")
if direct is not None:
return direct, None
return divide(self.value(i, "dividends_paid"), self._diluted_shares(i)[0],
"dividends paid", "diluted share count",
require_positive_denominator=True)
section.add("Dividend per share", "ps", self.series(dps))
section.add("Dividend payout ratio", "%", self.series(
lambda i: divide(self.value(i, "dividends_paid"), self.value(i, "pat"),
"dividends paid", "PAT", 100.0, require_positive_denominator=True)))
return section
# -- enterprise value bridge ------------------------------------------- #
def build_ev_bridge(self) -> None:
"""Build the EV bridge component by component.
Most "cheap on EV/EBITDA" findings are arithmetic errors in EV, so every
component is shown, every component that was not supplied is named, and
the surplus-cash assumption is stated rather than buried.
"""
latest = self.periods[-1]
market = self.market
bridge: Dict[str, Any] = {
"as_of": market.get("as_of"),
"period": latest.label,
"components": [],
"not_supplied": [],
"assumptions": [],
"enterprise_value": None,
"market_cap": None,
"reason": None,
}
# 1. Fully diluted market capitalisation.
market_cap = market.get("market_cap")
cap_source = "supplied directly"
if market_cap is None:
price = market.get("price")
shares = market.get("diluted_shares")
if shares is None:
shares = market.get("shares_outstanding")
if shares is None:
shares = self._diluted_shares(self.n - 1)[0]
if price is not None and shares is not None:
market_cap = price * shares
cap_source = "price %s x diluted share count %s" % (
format_number(price, 2), format_number(shares, 2))
if market_cap is not None and market_cap <= 0:
bridge["reason"] = (
"market capitalisation computed as %s - a non-positive market cap means the "
"price or the share count is wrong; every multiple is suppressed rather than "
"printed" % format_number(market_cap)
)
market_cap = None
if market_cap is None:
bridge.setdefault("reason", None)
bridge["reason"] = bridge["reason"] or (
"no market capitalisation: supply market.market_cap, or market.price with a "
"diluted share count"
)
self.ev_bridge = bridge
return
bridge["market_cap"] = market_cap
bridge["components"].append({
"name": "Fully diluted market capitalisation",
"sign": "+", "value": market_cap, "source": cap_source, "supplied": True,
})
def component(name: str, sign: str, market_key: Optional[str],
period_field: Optional[str], note: str = "") -> Number:
"""Add one bridge component, defaulting to zero but recording the gap."""
value = market.get(market_key) if market_key else None
source = "market input"
if value is None and period_field:
value = latest.get(period_field)
source = "%s balance sheet" % latest.label
if value is None:
bridge["not_supplied"].append(name)
value = 0.0
source = "not supplied - treated as zero"
bridge["components"].append({
"name": name, "sign": sign, "value": value,
"source": source + ((" (%s)" % note) if note else ""),
"supplied": source != "not supplied - treated as zero",
})
return value
gross_debt = latest.get("gross_debt")
if gross_debt is None:
bridge["not_supplied"].append("Gross debt")
gross_debt = 0.0
bridge["components"].append({
"name": "Gross debt (short + long term + current maturities)",
"sign": "+", "value": 0.0,
"source": "not supplied - treated as zero", "supplied": False,
})
else:
bridge["components"].append({
"name": "Gross debt (short + long term + current maturities)",
"sign": "+", "value": gross_debt,
"source": "%s balance sheet" % latest.label, "supplied": True,
})
leases = component("Capitalised lease liabilities", "+", None, "lease_liabilities",
"IFRS 16 / Ind AS 116 / ASC 842")
preference = component("Preference shares / CCPS", "+", "preference_capital", "preference_capital")
minorities = component("Non-controlling (minority) interest", "+", "minority_interest",
"minority_interest_bs", "at book unless a market value was supplied")
pension = component("Net pension / gratuity deficit", "+", "pension_deficit", "pension_deficit",
"net of plan assets, tax-effected")
other_debt = component("Other debt-like items", "+", "other_debt_like", "other_debt_like",
"reverse factoring, ARO, earn-outs, related-party loans")
contingent = component("Contingent consideration / earn-outs", "+", "contingent_consideration", None)
# Surplus cash, with the operating-cash carve-out stated explicitly.
surplus = market.get("surplus_cash")
if surplus is None:
liquid = latest.liquid_assets()
if liquid is None:
bridge["not_supplied"].append("Surplus cash and equivalents")
surplus = 0.0
cash_source = "not supplied - treated as zero (EV is overstated)"
else:
pct = float(self.options.operating_cash_pct)
revenue = latest.get("revenue")
carve_out = 0.0
if pct > 0 and revenue is not None:
carve_out = revenue * pct / 100.0
bridge["assumptions"].append(
"operating cash requirement carved out at %.1f%% of revenue = %s; "
"only the remainder is treated as distributable surplus"
% (pct, format_number(carve_out))
)
elif pct > 0:
bridge["assumptions"].append(
"--operating-cash-pct was set but revenue is unavailable, so no "
"operating-cash carve-out could be applied"
)
else:
bridge["assumptions"].append(
"ALL cash and current investments treated as surplus (no operating-cash "
"carve-out). Use --operating-cash-pct to carve out operating cash "
"(typically 2-5% of revenue), and exclude trapped, escrow, margin and "
"customer-float balances, which are not distributable."
)
surplus = max(liquid - carve_out, 0.0)
cash_source = "cash + current investments less any carve-out"
bridge["components"].append({
"name": "Less: surplus cash and equivalents", "sign": "-",
"value": surplus, "source": cash_source,
"supplied": "not supplied" not in cash_source,
})
else:
bridge["components"].append({
"name": "Less: surplus cash and equivalents", "sign": "-",
"value": surplus, "source": "market input", "supplied": True,
})
associates = market.get("value_of_associates")
assoc_source = "market input (fair value)"
if associates is None:
associates = latest.get("investments_in_associates")
assoc_source = "%s balance sheet (BOOK value - fair value is preferable)" % latest.label
if associates is None:
bridge["not_supplied"].append("Value of associates / JVs / listed stakes")
associates = 0.0
assoc_source = "not supplied - treated as zero"
bridge["components"].append({
"name": "Less: value of non-consolidated stakes (associates, JVs)", "sign": "-",
"value": associates, "source": assoc_source,
"supplied": assoc_source != "not supplied - treated as zero",
})
enterprise_value = (
market_cap + gross_debt + leases + preference + minorities + pension
+ other_debt + contingent - surplus - associates
)
bridge["enterprise_value"] = enterprise_value
bridge["assumptions"].append(
"Pairing rule: minority interest is inside EV, so the earnings figure paired with "
"an EV multiple must be pre-minority (EBITDA and EBIT are). P/E and P/B pair with "
"the post-minority PAT and owners' equity."
)
if "Value of associates / JVs / listed stakes" not in bridge["not_supplied"] and associates:
bridge["assumptions"].append(
"Associates are deducted from EV, so any share of associate profit must also "
"be stripped out of the earnings figure - doing one without the other is the "
"commonest silent error in holdco and conglomerate analysis."
)
self.ev_bridge = bridge
# -- valuation --------------------------------------------------------- #
def section_valuation(self) -> Section:
"""Equity and enterprise multiples on the latest period."""
latest_index = self.n - 1
latest = self.periods[latest_index]
as_of = self.market.get("as_of")
title = "VALUATION (on %s%s)" % (latest.label, (", price as of %s" % as_of) if as_of else "")
section = Section(
title, [latest.label],
note=(
"Multiples are computed on the latest supplied period only. A multiple "
"without an as-of date and a stated earnings basis is not usable."
),
)
market_cap = self.ev_bridge.get("market_cap")
enterprise_value = self.ev_bridge.get("enterprise_value")
if market_cap is None:
section.suppress(self.ev_bridge.get("reason") or "no market data supplied")
return section
ev_suppressed_reason = None
if self.sector_key in EV_MEANINGLESS_SECTOR_KEYS:
ev_suppressed_reason = (
"enterprise value has no meaning for a bank, NBFC or insurer - debt is raw "
"material, not financing, so every EV multiple is suppressed. Use P/B, "
"P/ABV, ROE vs cost of equity, P/EV."
)
def ev_cell(numerator_name: str, denominator: Number, den_label: str) -> Cell:
"""EV multiple, suppressed entirely where EV is meaningless."""
if ev_suppressed_reason:
return None, ev_suppressed_reason
return divide(enterprise_value, denominator, numerator_name, den_label,
require_positive_denominator=True)
section.add("Market capitalisation", "abs", [(market_cap, None)])
section.add("Enterprise value", "abs", [
(None, ev_suppressed_reason) if ev_suppressed_reason else (enterprise_value, None)])
section.add("P/E (trailing)", "x", [
divide(market_cap, latest.get("pat"), "market cap", "PAT",
require_positive_denominator=True)])
section.add("P/B", "x", [
divide(market_cap, latest.get("equity"), "market cap", "shareholders' equity",
require_positive_denominator=True)])
section.add("P/B (tangible)", "x", [
divide(market_cap, latest.tangible_equity(), "market cap", "tangible equity",
require_positive_denominator=True)])
section.add("P/S", "x", [
divide(market_cap, latest.get("revenue"), "market cap", "revenue",
require_positive_denominator=True)])
section.add("EV/EBITDA", "x", [ev_cell("enterprise value", latest.get("ebitda"), "EBITDA")])
section.add("EV/EBIT", "x", [ev_cell("enterprise value", latest.get("ebit"), "EBIT")])
section.add("EV/Sales", "x", [ev_cell("enterprise value", latest.get("revenue"), "revenue")])
section.add("EV/CFO", "x", [ev_cell("enterprise value", latest.get("cfo"), "CFO")])
section.add("Earnings yield (PAT / market cap)", "%", [
divide(latest.get("pat"), market_cap, "PAT", "market cap", 100.0,
require_positive_denominator=True)])
section.add("FCF yield (FCF / market cap)", "%", [
divide(latest.get("fcf"), market_cap, "FCF", "market cap", 100.0,
require_positive_denominator=True)])
if ev_suppressed_reason:
section.add("FCF yield (FCF / EV)", "%", [(None, ev_suppressed_reason)])
section.add("EBIT / EV (earnings yield, enterprise)", "%", [(None, ev_suppressed_reason)])
else:
section.add("FCF yield (FCF / EV)", "%", [
divide(latest.get("fcf"), enterprise_value, "FCF", "enterprise value", 100.0,
require_positive_denominator=True)])
section.add("EBIT / EV (earnings yield, enterprise)", "%", [
divide(latest.get("ebit"), enterprise_value, "EBIT", "enterprise value", 100.0,
require_positive_denominator=True)])
section.add("Dividend yield", "%", [
divide(latest.get("dividends_paid"), market_cap, "dividends paid", "market cap", 100.0,
require_positive_denominator=True)])
return section
# ------------------------------------------------------------------ #
# Quality warnings
# ------------------------------------------------------------------ #
def detect_quality_warnings(self) -> None:
"""Run every automatic quality test and record the ones that trigger.
Each warning names the test and carries the values that triggered it, so
a reader can check the arithmetic rather than take the flag on trust.
"""
self._warn_cash_vs_profit()
self._warn_receivables_vs_sales()
self._warn_inventory_vs_sales()
self._warn_dso_trend()
self._warn_interest_cover()
self._warn_capex_vs_depreciation()
self._warn_negative_fcf_with_profit()
self._warn_tax_rate_anomalies()
self._warn_accrual_ratio()
self._warn_leverage()
def _add_warning(self, test: str, severity: str, detail: str, values: Dict[str, Any]) -> None:
"""Register a triggered warning."""
self.warnings.append(Warning_(test, severity, detail, values))
def _warn_cash_vs_profit(self) -> None:
"""OCF below net profit across multiple years."""
pairs = [(p.label, p.get("cfo"), p.get("pat")) for p in self.periods
if p.get("cfo") is not None and p.get("pat") is not None]
if len(pairs) < 2:
return
shortfalls = [(label, cfo, pat) for label, cfo, pat in pairs if pat > 0 and cfo < pat]
cfo_total = sum(cfo for _, cfo, _ in pairs)
pat_total = sum(pat for _, _, pat in pairs)
ratio = cfo_total / pat_total if pat_total > 0 else None
if len(shortfalls) >= 2:
severity = "high" if (ratio is not None and ratio < 0.8) else "medium"
self._add_warning(
"OCF below net profit in multiple years",
severity,
"Operating cash flow fell short of reported net profit in %d of %d periods%s. "
"Profit that does not become cash is either sitting in working capital or is "
"not real; bridge net income to CFO line by line before accepting the earnings."
% (len(shortfalls), len(pairs),
"" if ratio is None else "; cumulative CFO/PAT = %.2fx" % ratio),
{
"periods": [
{"period": label, "cfo": cfo, "pat": pat, "shortfall": pat - cfo}
for label, cfo, pat in shortfalls
],
"cumulative_cfo": cfo_total,
"cumulative_pat": pat_total,
"cumulative_cfo_over_pat": ratio,
},
)
elif ratio is not None and ratio < 0.8:
self._add_warning(
"Cumulative OCF well below cumulative net profit",
"medium",
"Cumulative CFO/PAT over the supplied history is %.2fx (below 0.80x), even "
"though few individual years show a shortfall." % ratio,
{"cumulative_cfo": cfo_total, "cumulative_pat": pat_total,
"cumulative_cfo_over_pat": ratio},
)
def _growth_gap_warning(self, field: str, label: str, threshold_pp: float,
detail_tail: str) -> None:
"""Shared test: a balance-sheet item compounding faster than sales."""
if self.n < 2:
return
span = (self.n - 1) * self.period_years
item_cagr, item_reason = cagr(self.value(0, field), self.value(self.n - 1, field), span, label)
rev_cagr, rev_reason = cagr(self.value(0, "revenue"), self.value(self.n - 1, "revenue"),
span, "revenue")
if item_cagr is None or rev_cagr is None:
return
gap = item_cagr - rev_cagr
if gap <= threshold_pp:
return
severity = "high" if gap > 2 * threshold_pp else "medium"
self._add_warning(
"%s growing faster than sales" % label.capitalize(),
severity,
"%s compounded at %.1f%% against revenue at %.1f%% over %s-%s, a gap of %.1f "
"percentage points. %s"
% (label.capitalize(), item_cagr, rev_cagr, self.labels[0], self.labels[-1],
gap, detail_tail),
{
"metric_cagr_pct": item_cagr,
"revenue_cagr_pct": rev_cagr,
"gap_pp": gap,
"opening": {"period": self.labels[0], label: self.value(0, field),
"revenue": self.value(0, "revenue")},
"closing": {"period": self.labels[-1], label: self.value(self.n - 1, field),
"revenue": self.value(self.n - 1, "revenue")},
},
)
def _warn_receivables_vs_sales(self) -> None:
"""Receivables growing materially faster than sales."""
self._growth_gap_warning(
"receivables", "receivables", self.options.growth_gap_pp,
"That is the classic signature of pulled-forward revenue, channel stuffing or "
"weakening customers. Read the Schedule III receivables ageing schedule and check "
"the over-6-month bucket before accepting the headline DSO.",
)
def _warn_inventory_vs_sales(self) -> None:
"""Inventory growing faster than sales."""
self._growth_gap_warning(
"inventory", "inventory", self.options.growth_gap_pp,
"Inventory built ahead of demand precedes write-downs. Check whether it is raw "
"material (input-cost positioning) or finished goods (unsold product).",
)
def _warn_dso_trend(self) -> None:
"""A DSO that rises year after year.
The earliest period's DSO is computed on a closing balance rather than
an average, so it is excluded from the trend: comparing a closing-basis
figure with average-basis figures manufactures a trend that is not there.
"""
dso_values: List[Tuple[str, float]] = []
first_comparable = 1 if self.n > 1 else 0
for i in range(first_comparable, self.n):
avg, _ = self.average_balance(i, "receivables")
revenue = self.value(i, "revenue")
if avg is None or revenue is None or revenue <= 0:
continue
dso_values.append((self.labels[i], avg / revenue * DAYS_IN_YEAR))
if len(dso_values) < 3:
return
rising_every_year = all(
dso_values[i][1] > dso_values[i - 1][1] for i in range(1, len(dso_values)))
first, last = dso_values[0][1], dso_values[-1][1]
pct_change = (last / first - 1.0) * 100.0 if first > 0 else None
material = pct_change is not None and pct_change > 15.0
if not (rising_every_year or material):
return
severity = "high" if (rising_every_year and material) else "medium"
self._add_warning(
"Rising DSO trend",
severity,
"Receivable days moved from %.0f (%s) to %.0f (%s)%s%s. Rising DSO is the "
"earliest sign of channel stuffing, weakening customers or aggressive revenue "
"recognition; distinguish it from a deliberate credit-terms change and check "
"whether any apparent improvement came from factoring."
% (first, dso_values[0][0], last, dso_values[-1][0],
"" if pct_change is None else ", %+.1f%%" % pct_change,
", rising in every single year" if rising_every_year else ""),
{"dso_by_period": [{"period": p, "dso_days": v} for p, v in dso_values],
"change_pct": pct_change, "rose_every_year": rising_every_year},
)
def _warn_interest_cover(self) -> None:
"""Interest coverage below the threshold."""
threshold = float(self.options.interest_cover_threshold)
breaches = []
for i in range(self.n):
ebit, interest = self.value(i, "ebit"), self.value(i, "interest")
if ebit is None or interest is None or interest <= 0:
continue
cover = ebit / interest
if cover < threshold:
breaches.append({"period": self.labels[i], "ebit": ebit,
"interest": interest, "coverage_x": cover})
if not breaches:
return
worst = min(b["coverage_x"] for b in breaches)
latest_breach = breaches[-1]["period"] == self.labels[-1]
severity = "high" if (worst < 1.5 or latest_breach) else "medium"
self._add_warning(
"Interest coverage below %.1fx" % threshold,
severity,
"EBIT/interest fell below %.1fx in %d period(s); the worst was %.2fx. Below "
"roughly 2x a modest earnings dip breaches covenants, and the covenant test is "
"usually run on a definition of EBITDA set in the credit agreement rather than "
"the reported one - read it." % (threshold, len(breaches), worst),
{"breaches": breaches, "threshold_x": threshold},
)
def _warn_capex_vs_depreciation(self) -> None:
"""Capex persistently below depreciation."""
ratios = []
for i in range(self.n):
capex, dep = self.value(i, "capex"), self.value(i, "depreciation")
if capex is None or dep is None or dep <= 0:
continue
ratios.append({"period": self.labels[i], "capex": capex,
"depreciation": dep, "capex_over_dep_x": capex / dep})
if len(ratios) < 2:
return
below = [r for r in ratios if r["capex_over_dep_x"] < 0.9]
if len(below) < 2 or len(below) < len(ratios) / 2.0:
return
self._add_warning(
"Capex persistently below depreciation",
"medium",
"Capex was below 90%% of depreciation in %d of %d periods. Either the asset base "
"is being run down (which flatters current margins and cash flow at the cost of "
"future capacity) or depreciation is overstated relative to true economic life - "
"and only one of those is good news. Check the useful-life assumptions and the "
"age of the gross block." % (len(below), len(ratios)),
{"periods": ratios, "below_threshold": [r["period"] for r in below]},
)
def _warn_negative_fcf_with_profit(self) -> None:
"""Negative free cash flow while reporting a profit."""
hits = []
for period in self.periods:
fcf, pat = period.get("fcf"), period.get("pat")
if fcf is None or pat is None:
continue
if fcf < 0 and pat > 0:
hits.append({"period": period.label, "fcf": fcf, "pat": pat,
"cfo": period.get("cfo"), "capex": period.get("capex")})
if not hits:
return
severity = "high" if len(hits) >= 2 else "medium"
self._add_warning(
"Negative FCF while reporting profit",
severity,
"Free cash flow was negative in %d period(s) despite positive reported profit: "
"%s. Separate growth capex from maintenance capex before concluding this is a "
"problem - a genuine build-out is negative by design - but a company that never "
"generates cash is funding its dividend from borrowings."
% (len(hits), ", ".join(h["period"] for h in hits)),
{"periods": hits},
)
def _warn_tax_rate_anomalies(self) -> None:
"""Effective tax rates that are implausible, volatile, or off statutory."""
rates = []
for i in range(self.n):
tax, pbt = self.value(i, "tax"), self.value(i, "pbt")
if tax is None or pbt is None or pbt <= 0:
continue
rates.append({"period": self.labels[i], "tax": tax, "pbt": pbt,
"effective_rate_pct": tax / pbt * 100.0})
if not rates:
return
values = [r["effective_rate_pct"] for r in rates]
issues = []
if min(values) < 10.0:
issues.append("an effective rate below 10%")
if max(values) > 50.0:
issues.append("an effective rate above 50%")
if len(values) >= 2 and (max(values) - min(values)) > 10.0:
issues.append("a swing of %.1f percentage points across the period"
% (max(values) - min(values)))
statutory = self.options.statutory_tax
if statutory is not None:
latest = values[-1]
if abs(latest - float(statutory)) > 10.0:
issues.append("the latest rate of %.1f%% is more than 10pp away from the "
"stated statutory rate of %.1f%%" % (latest, float(statutory)))
# Book vs cash tax divergence is the other half of this test.
cash_gap = None
for i in range(self.n):
book, cash, pbt = self.value(i, "tax"), self.value(i, "cash_taxes_paid"), self.value(i, "pbt")
if book is None or cash is None or pbt is None or pbt <= 0:
continue
gap = (book - cash) / pbt * 100.0
if abs(gap) > 8.0:
cash_gap = {"period": self.labels[i], "book_tax": book, "cash_tax": cash,
"gap_pp_of_pbt": gap}
issues.append("book tax and cash tax paid diverge by %.1fpp of PBT in %s"
% (gap, self.labels[i]))
break
if not issues:
return
self._add_warning(
"Effective tax rate anomaly",
"medium",
"Tax tests flagged: %s. A tax rate that moves without a regime change usually "
"signals one-off credits, MAT credit utilisation, an expiring tax holiday about "
"to reverse, or aggressive positions under dispute - each of which makes the "
"current post-tax earnings a poor guide to the next year's."
% "; ".join(issues),
{"effective_rates": rates, "statutory_rate_pct": statutory,
"book_vs_cash_tax": cash_gap},
)
def _warn_accrual_ratio(self) -> None:
"""Sloan accrual ratio above the red-flag threshold."""
hits = []
for i in range(self.n):
pat, cfo = self.value(i, "pat"), self.value(i, "cfo")
avg, _ = self.average_balance(i, "total_assets")
if pat is None or cfo is None or avg is None or avg <= 0:
continue
ratio = (pat - cfo) / avg * 100.0
if ratio > 10.0:
hits.append({"period": self.labels[i], "accrual_ratio_pct": ratio,
"pat": pat, "cfo": cfo, "avg_total_assets": avg})
if not hits:
return
self._add_warning(
"High accrual ratio (Sloan)",
"high" if len(hits) >= 2 else "medium",
"(Net income - CFO) / average total assets exceeded 10%% in %d period(s). High-"
"accrual firms systematically underperform; this is one of the most robust "
"anomalies in the accounting literature." % len(hits),
{"periods": hits, "threshold_pct": 10.0},
)
def _warn_leverage(self) -> None:
"""Net debt/EBITDA above a broadly stretched level in the latest period."""
latest = self.periods[-1]
net_debt, ebitda = latest.net_debt(), latest.get("ebitda")
if net_debt is None or ebitda is None or ebitda <= 0:
return
ratio = net_debt / ebitda
if ratio <= 3.5:
return
self._add_warning(
"Net debt / EBITDA above 3.5x",
"high" if ratio > 4.5 else "medium",
"Net debt/EBITDA is %.2fx in %s. That band is stretched outside utilities, REITs "
"and contracted infrastructure - check the maturity ladder, the covenant "
"definition of EBITDA, and whether leverage is being measured at a commodity or "
"cycle peak." % (ratio, latest.label),
{"period": latest.label, "net_debt": net_debt, "ebitda": ebitda,
"net_debt_over_ebitda_x": ratio},
)
# ------------------------------------------------------------------ #
# Sector gate
# ------------------------------------------------------------------ #
def sector_gate(self) -> Optional[str]:
"""Return the sector warning text when the sector is a financial one."""
key = self.sector_key
if key not in FINANCIAL_SECTOR_KEYS:
return None
if key in {"bank", "banks", "banking", "lender", "financial", "financials", "finance"}:
body = SECTOR_GATE_MESSAGE["bank"]
elif key in {"nbfc", "nbfcs", "hfc", "hfcs"}:
body = SECTOR_GATE_MESSAGE["nbfc"]
elif key in {"insurance", "insurer", "insurers", "life-insurance", "general-insurance"}:
body = SECTOR_GATE_MESSAGE["insurance"]
else:
body = SECTOR_GATE_MESSAGE["reit"]
return body
# ------------------------------------------------------------------ #
# Collecting what could not be computed
# ------------------------------------------------------------------ #
def not_computed(self) -> List[Dict[str, Any]]:
"""Aggregate every metric that could not be computed, with the reason.
"No prior period" is excluded: the first period of any series has no
year-on-year figure by construction, which is arithmetic rather than a
gap in the data.
"""
structural = {"no prior period"}
collected: Dict[Tuple[str, str, str], List[str]] = {}
for section in self.sections:
if section.suppressed:
collected.setdefault((section.title, "(whole section)", section.suppressed), [])
continue
for row in section.rows:
for idx, (value, reason) in enumerate(row["cells"]):
if value is not None or reason in structural:
continue
label = (section.period_labels[idx]
if idx < len(section.period_labels) else "?")
key = (section.title, row["name"], reason or "reason not recorded")
collected.setdefault(key, []).append(label)
for scalar in section.scalars:
value, reason = scalar["cell"]
if value is None:
key = (section.title, scalar["name"], reason or "reason not recorded")
collected.setdefault(key, [])
return [
{"section": section, "metric": metric, "reason": reason, "periods": periods}
for (section, metric, reason), periods in collected.items()
]
# --------------------------------------------------------------------------- #
# Rendering
# --------------------------------------------------------------------------- #
LABEL_WIDTH = 46
COL_WIDTH = 13
def wrap(text: str, width: int = 96, indent: str = " ") -> List[str]:
"""Wrap text to a width, returning indented lines (no textwrap import games)."""
words, lines, current = text.split(), [], ""
for word in words:
candidate = word if not current else current + " " + word
if len(candidate) + len(indent) > width:
lines.append(indent + current)
current = word
else:
current = candidate
if current:
lines.append(indent + current)
return lines
def render_section(section: Section) -> List[str]:
"""Render one section as fixed-width text."""
out: List[str] = []
out.append("")
out.append(section.title)
out.append("-" * max(len(section.title), 60))
if section.suppressed:
out.extend(wrap("NOT APPLICABLE: " + section.suppressed))
return out
if section.note:
out.extend(wrap("Note: " + section.note))
out.append("")
if section.rows:
header = " " * LABEL_WIDTH + "".join(
label.rjust(COL_WIDTH) for label in section.period_labels)
out.append(header)
for row in section.rows:
name = row["name"]
if len(name) > LABEL_WIDTH - 1:
name = name[: LABEL_WIDTH - 2] + "."
line = name.ljust(LABEL_WIDTH)
line += "".join(format_cell(cell, row["unit"]).rjust(COL_WIDTH)
for cell in row["cells"])
out.append(line)
for scalar in section.scalars:
value, _reason = scalar["cell"]
if scalar["unit"] == "text":
rendered = value if value is not None else "n/a"
else:
rendered = format_cell(scalar["cell"], scalar["unit"])
name = scalar["name"]
dots = "." * max(3, LABEL_WIDTH + 8 - len(name))
out.append("%s %s %s" % (name, dots, rendered))
return out
def render_text(analysis: Analysis) -> str:
"""Render the whole analysis as a readable report."""
out: List[str] = []
rule = "=" * 96
out.append(rule)
out.append("RATIO ANALYSIS - %s" % analysis.company)
out.append("Basis: %s | Currency/units: %s | Periods: %s"
% (analysis.basis, analysis.currency, ", ".join(analysis.labels)))
if analysis.sector_raw:
out.append("Sector: %s" % analysis.sector_raw)
if analysis.source:
out.append("Source: %s" % analysis.source)
out.append(rule)
gate = analysis.sector_gate()
if gate:
out.append("")
out.append("!" * 96)
out.append("SECTOR GATE - THE STANDARD RATIO SET DOES NOT APPLY HERE")
out.extend(wrap(gate))
if analysis.sector_key in EV_MEANINGLESS_SECTOR_KEYS:
tail = ("Ratios below are still computed where the arithmetic is defined, but "
"ROIC, invested capital, net debt/EBITDA and interest coverage must not "
"be quoted for this sector, and every EV multiple has been suppressed. "
"Use the sector playbook's metric set instead.")
else:
tail = ("Ratios below are still computed where the arithmetic is defined, but "
"ROIC, invested capital and the earnings-based multiples will mislead "
"for this sector. Use the sector playbook's metric set instead.")
out.extend(wrap(tail))
out.append("!" * 96)
# Input integrity.
derivations = [d for period in analysis.periods for d in period.derivations]
out.append("")
out.append("INPUT INTEGRITY")
out.append("-" * 60)
if derivations:
out.append(" Derived line items (not taken from the filing directly):")
for item in derivations:
out.append(" - " + item)
else:
out.append(" No line items had to be derived; everything came from the input.")
if analysis.cautions:
out.append(" Cautions:")
for caution in analysis.cautions:
out.extend(wrap("- " + caution, indent=" "))
out.append(" Normalised tax rate for NOPAT: %s (%s)" % (
"n/a" if analysis.normalised_tax_rate is None
else "%.1f%%" % (analysis.normalised_tax_rate * 100.0),
analysis.tax_rate_basis))
for section in analysis.sections:
if section.title.startswith("VALUATION"):
out.extend(render_ev_bridge(analysis))
out.extend(render_section(section))
# Quality warnings.
out.append("")
out.append("QUALITY WARNINGS")
out.append("-" * 60)
if not analysis.warnings:
out.append(" No automatic quality test triggered on the data supplied.")
out.extend(wrap(
"That is not a clean bill of health: these tests only see the line items given "
"to them. Related-party transactions, contingent liabilities, auditor changes, "
"pledging and segment disclosure are not testable from this input.",
indent=" "))
else:
for index, warning in enumerate(analysis.warnings, start=1):
out.append(" %d. [%s] %s" % (index, warning.severity.upper(), warning.test))
out.extend(wrap(warning.detail, indent=" "))
out.extend(wrap("triggering values: " + json.dumps(warning.values, default=str),
indent=" "))
out.append("")
# Not computed.
missing = analysis.not_computed()
out.append("")
out.append("NOT COMPUTED (and why)")
out.append("-" * 60)
if not missing:
out.append(" Every metric in every section was computable from the input supplied.")
else:
for item in sorted(missing, key=lambda m: (m["section"], m["metric"])):
scope = ("all periods" if len(item["periods"]) == len(analysis.labels)
else ", ".join(item["periods"]) if item["periods"] else "whole period")
out.extend(wrap("- %s / %s [%s]: %s"
% (item["section"].split(" ")[0], item["metric"], scope, item["reason"]),
indent=" "))
# Method notes.
out.append("")
out.append("METHOD NOTES")
out.append("-" * 60)
for note in analysis.method_notes:
out.extend(wrap("- " + note, indent=" "))
out.extend(wrap(
"- Every band implied by these tests is indicative only. Sector, market, rate cycle "
"and accounting regime move all of them; peer comparison and the company's own "
"multi-year record override any absolute threshold used here.", indent=" "))
out.extend(wrap(
"- This script computes; it does not judge. A number is not a conclusion until it is "
"set against the peer set and the company's own history.", indent=" "))
out.append("")
return "\n".join(out)
def render_ev_bridge(analysis: Analysis) -> List[str]:
"""Render the enterprise-value bridge as an explicit component table."""
bridge = analysis.ev_bridge
out: List[str] = []
out.append("")
out.append("ENTERPRISE VALUE BRIDGE (%s)" % bridge.get("period", ""))
out.append("-" * 60)
out.extend(wrap(
"Most 'cheap on EV/EBITDA' findings are arithmetic errors in EV, so the bridge is "
"built once, shown component by component, and reused in every enterprise multiple."))
out.append("")
if bridge.get("enterprise_value") is None:
out.extend(wrap("NOT BUILT: " + (bridge.get("reason") or "insufficient market data")))
return out
bridge_width = max(
[len(c["name"]) for c in bridge["components"]] + [len("ENTERPRISE VALUE")]) + 2
for component in bridge["components"]:
out.append(" %s %s %s [%s]" % (
component["sign"],
component["name"].ljust(bridge_width),
format_number(component["value"]).rjust(16),
component["source"],
))
out.append(" = %s %s" % (
"ENTERPRISE VALUE".ljust(bridge_width),
format_number(bridge["enterprise_value"]).rjust(16)))
if bridge["not_supplied"]:
out.append("")
out.extend(wrap(
"Components NOT supplied and therefore treated as zero: %s. Each one that exists "
"in reality makes the EV above too low, and every EV multiple correspondingly too "
"cheap." % ", ".join(bridge["not_supplied"])))
for assumption in bridge["assumptions"]:
out.extend(wrap("Assumption: " + assumption))
return out
def render_json(analysis: Analysis) -> str:
"""Serialise the whole analysis, including reasons for every missing value."""
payload: Dict[str, Any] = {
"company": analysis.company,
"basis": analysis.basis,
"currency": analysis.currency,
"sector": analysis.sector_raw or None,
"source": analysis.source,
"periods": analysis.labels,
"sector_gate_warning": analysis.sector_gate(),
"input_integrity": {
"derivations": [d for p in analysis.periods for d in p.derivations],
"cautions": analysis.cautions,
"normalised_tax_rate_pct": (None if analysis.normalised_tax_rate is None
else analysis.normalised_tax_rate * 100.0),
"normalised_tax_rate_basis": analysis.tax_rate_basis,
},
"sections": [],
"enterprise_value_bridge": analysis.ev_bridge,
"quality_warnings": [w.to_dict() for w in analysis.warnings],
"not_computed": analysis.not_computed(),
"method_notes": analysis.method_notes,
}
for section in analysis.sections:
block: Dict[str, Any] = {
"title": section.title,
"periods": section.period_labels,
"note": section.note,
"suppressed": section.suppressed,
"metrics": [],
"scalars": [],
}
for row in section.rows:
block["metrics"].append({
"name": row["name"],
"unit": row["unit"],
"values": [
{"period": (section.period_labels[i] if i < len(section.period_labels) else None),
"value": cell[0], "unavailable_because": cell[1]}
for i, cell in enumerate(row["cells"])
],
})
for scalar in section.scalars:
block["scalars"].append({
"name": scalar["name"],
"unit": scalar["unit"],
"value": scalar["cell"][0],
"unavailable_because": scalar["cell"][1],
})
payload["sections"].append(block)
return json.dumps(payload, indent=2, default=str)
# --------------------------------------------------------------------------- #
# Worked example plumbing
# --------------------------------------------------------------------------- #
EXAMPLE_BEGIN = "--- EXAMPLE INPUT (begin) ---"
EXAMPLE_END = "--- EXAMPLE INPUT (end) ---"
def example_input_text() -> str:
"""Return the worked example JSON embedded in this module's docstring.
Extracting it from the docstring rather than duplicating it guarantees the
documented example and the emitted example can never drift apart.
"""
doc = __doc__ or ""
try:
start = doc.index(EXAMPLE_BEGIN) + len(EXAMPLE_BEGIN)
end = doc.index(EXAMPLE_END)
except ValueError: # pragma: no cover - only if the docstring is edited badly
raise RuntimeError("worked example markers are missing from the module docstring")
text = doc[start:end].strip()
json.loads(text) # fail loudly if the documented example stopped being valid JSON
return text
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
"""Construct the command-line interface."""
parser = argparse.ArgumentParser(
prog="ratios.py",
description=(
"Compute the full derived ratio set (growth, margins, returns, DuPont, ROIIC, "
"leverage, liquidity, working capital, cash-flow quality, per-share, EV bridge "
"and valuation multiples) from a JSON file of raw financial line items, and "
"flag quality warnings automatically."
),
epilog=(
"For banks, NBFCs, insurers and REITs most of these ratios are meaningless: pass "
"--sector to get the gate warning, and use the sector playbook's metric set "
"instead. Run --example to print a complete worked input file."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("input", nargs="?", help="path to the JSON input file ('-' for stdin)")
parser.add_argument("--json", action="store_true", dest="as_json",
help="emit machine-readable JSON instead of the text table")
parser.add_argument("--example", action="store_true",
help="print the complete worked example input file and exit")
parser.add_argument("--sector", default=None,
help="sector key (e.g. banks, nbfc, insurance, reit, it-saas, auto); "
"financial sectors trigger the sector gate warning and suppress "
"EV multiples")
parser.add_argument("--tax-rate", type=float, default=None, metavar="PCT",
help="override the normalised tax rate used for NOPAT, in percent")
parser.add_argument("--use-cash-tax", action="store_true",
help="derive the normalised tax rate from cash taxes paid rather than "
"the book tax charge")
parser.add_argument("--statutory-tax", type=float, default=None, metavar="PCT",
help="statutory tax rate in percent, used only by the effective-tax-rate "
"anomaly test (India post-115BAA is about 25.2%%; US federal 21%%)")
parser.add_argument("--operating-cash-pct", type=float, default=0.0, metavar="PCT",
help="percent of revenue treated as operating cash and therefore NOT "
"deducted as surplus cash in the EV bridge (typically 2-5); "
"default 0, i.e. all cash treated as surplus")
parser.add_argument("--roiic-years", type=int, default=3, metavar="N",
help="window in years for return on incremental invested capital "
"(default 3; single-year ROIIC is noise)")
parser.add_argument("--period-years", type=float, default=1.0, metavar="Y",
help="years between consecutive periods, for CAGRs (default 1.0)")
parser.add_argument("--interest-cover-threshold", type=float, default=2.5, metavar="X",
help="interest coverage below this triggers a quality warning "
"(default 2.5x)")
parser.add_argument("--growth-gap-pp", type=float, default=4.0, metavar="PP",
help="percentage-point gap between receivables/inventory CAGR and "
"revenue CAGR that triggers a warning (default 4.0)")
return parser
def load_document(path: str) -> Dict[str, Any]:
"""Read and parse the input JSON file (or stdin when path is '-')."""
if path == "-":
text = sys.stdin.read()
else:
with open(path, "r", encoding="utf-8") as handle:
text = handle.read()
try:
doc = json.loads(text)
except json.JSONDecodeError as error:
raise ValueError("input is not valid JSON: %s" % error)
if not isinstance(doc, dict):
raise ValueError("input JSON must be an object at the top level")
return doc
def main(argv: Optional[Sequence[str]] = None) -> int:
"""Entry point. Returns a process exit code."""
parser = build_parser()
args = parser.parse_args(argv)
if args.example:
sys.stdout.write(example_input_text() + "\n")
return 0
if not args.input:
parser.error("an input JSON file is required (or use --example)")
try:
doc = load_document(args.input)
analysis = Analysis(doc, args)
analysis.build_all()
except (ValueError, OSError) as error:
sys.stderr.write("ratios.py: %s\n" % error)
return 2
if args.as_json:
sys.stdout.write(render_json(analysis) + "\n")
else:
sys.stdout.write(render_text(analysis) + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())