File size: 7,082 Bytes
30b61f5 f9517e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | """Typed units and quantity types: what a number IS, not only how large it is.
A rate stored as a decimal (0.0335) can be a margin LEVEL of 3.35%, a margin CHANGE of 335 basis points or a
growth rate of 3.35%. The decimal is the same, the quantity is not. Every rate-like figure therefore carries:
* ``unit`` — the unit it was stated in: PERCENT, PERCENTAGE_POINTS, BASIS_POINTS, DECIMAL_FRACTION, MULTIPLE,
COUNT, USD, USD_MILLION, USD_BILLION, USD_PER_SHARE;
* ``quantity_type`` — what it measures: LEVEL, CHANGE, GROWTH_RATE, SPREAD, MARGIN_LEVEL, MARGIN_CHANGE, YIELD,
PROBABILITY.
Canonical values: rates, changes and spreads as decimals (335 bps → 0.0335, 3.35 pp → 0.0335, 3.35% → 0.0335);
money in USD. The canonical number may coincide; the unit and quantity type never collapse, and formatting is
driven by them, never inferred from the size of the stored number.
"""
from __future__ import annotations
# ── units ─────────────────────────────────────────────────────────────────────────────
DECIMAL_FRACTION = "DECIMAL_FRACTION"
PERCENT = "PERCENT"
PERCENTAGE_POINTS = "PERCENTAGE_POINTS"
BASIS_POINTS = "BASIS_POINTS"
MULTIPLE = "MULTIPLE"
COUNT = "COUNT"
USD = "USD"
USD_MILLION = "USD_MILLION"
USD_BILLION = "USD_BILLION"
USD_PER_SHARE = "USD_PER_SHARE"
UNITS = (DECIMAL_FRACTION, PERCENT, PERCENTAGE_POINTS, BASIS_POINTS, MULTIPLE, COUNT, USD, USD_MILLION, USD_BILLION,
USD_PER_SHARE)
RATE_UNITS = (DECIMAL_FRACTION, PERCENT, PERCENTAGE_POINTS, BASIS_POINTS)
MONEY_UNITS = (USD, USD_MILLION, USD_BILLION, USD_PER_SHARE)
# stated value × factor = canonical value (decimal for rates, USD for money)
TO_CANONICAL = {DECIMAL_FRACTION: 1.0, PERCENT: 0.01, PERCENTAGE_POINTS: 0.01, BASIS_POINTS: 0.0001, MULTIPLE: 1.0,
COUNT: 1.0, USD: 1.0, USD_MILLION: 1e6, USD_BILLION: 1e9, USD_PER_SHARE: 1.0}
# ── quantity types ────────────────────────────────────────────────────────────────────
LEVEL = "LEVEL"
CHANGE = "CHANGE"
GROWTH_RATE = "GROWTH_RATE"
SPREAD = "SPREAD"
MARGIN_LEVEL = "MARGIN_LEVEL"
MARGIN_CHANGE = "MARGIN_CHANGE"
YIELD = "YIELD"
PROBABILITY = "PROBABILITY"
QUANTITY_TYPES = (LEVEL, CHANGE, GROWTH_RATE, SPREAD, MARGIN_LEVEL, MARGIN_CHANGE, YIELD, PROBABILITY)
CHANGE_TYPES = (CHANGE, MARGIN_CHANGE, SPREAD)
# units a quantity type may be stated in
ALLOWED_UNITS = {
LEVEL: MONEY_UNITS + (COUNT, MULTIPLE),
GROWTH_RATE: (PERCENT, DECIMAL_FRACTION),
MARGIN_LEVEL: (PERCENT, DECIMAL_FRACTION),
MARGIN_CHANGE: (BASIS_POINTS, PERCENTAGE_POINTS),
CHANGE: (BASIS_POINTS, PERCENTAGE_POINTS) + MONEY_UNITS,
SPREAD: (BASIS_POINTS, PERCENTAGE_POINTS),
YIELD: (PERCENT, DECIMAL_FRACTION),
PROBABILITY: (PERCENT, DECIMAL_FRACTION),
}
# economic concepts for guidance and claims: a change or a growth rate is never stored under a level concept
CONCEPT_QUANTITY = {
"REVENUE": LEVEL, "REVENUE_GROWTH": GROWTH_RATE, "ORGANIC_REVENUE_GROWTH": GROWTH_RATE,
"OPERATING_MARGIN": MARGIN_LEVEL, "OPERATING_MARGIN_CHANGE": MARGIN_CHANGE,
"NET_MARGIN": MARGIN_LEVEL, "GROSS_MARGIN": MARGIN_LEVEL, "GROSS_MARGIN_CHANGE": MARGIN_CHANGE,
"EPS": LEVEL, "EPS_GROWTH": GROWTH_RATE, "OPERATING_INCOME": LEVEL, "OPERATING_EXPENSES": LEVEL,
"INTEREST_EXPENSE": LEVEL, "AMORTIZATION": LEVEL, "TAX_RATE": MARGIN_LEVEL, "FREE_CASH_FLOW": LEVEL,
"CAPEX": LEVEL, "SHARE_REPURCHASE": LEVEL, "DIVIDEND": LEVEL, "NET_INTEREST_INCOME": LEVEL,
"NET_INTEREST_MARGIN": MARGIN_LEVEL, "OTHER": None,
}
class QuantityError(ValueError):
"""A unit or quantity type that does not describe the figure."""
def to_canonical(value, unit):
if value is None:
return None
if unit not in TO_CANONICAL:
raise QuantityError(f"unknown unit {unit!r}")
return float(value) * TO_CANONICAL[unit]
def check(unit, quantity_type):
"""(ok, reason): is this unit a valid way to state this quantity type?"""
if unit not in UNITS:
return False, f"unknown unit {unit!r}"
if quantity_type not in QUANTITY_TYPES:
return False, f"unknown quantity type {quantity_type!r}"
if unit not in ALLOWED_UNITS[quantity_type]:
return False, f"{quantity_type} cannot be stated in {unit}"
return True, ""
def _num(x, dec):
s = f"{x:,.{dec}f}"
return s.rstrip("0").rstrip(".") if dec and "." in s else s
def format_value(value, unit, quantity_type=None, *, signed=False):
"""One canonical value, formatted from its unit and quantity type (never from its magnitude)."""
if value is None:
return None
v = float(value)
sg = "+" if (signed and v > 0) else ""
if unit == BASIS_POINTS:
return f"{sg}{_num(v * 10000.0, 0)} bps"
if unit == PERCENTAGE_POINTS:
return f"{sg}{_num(v * 100.0, 2)} percentage points"
if unit in (PERCENT, DECIMAL_FRACTION):
return f"{sg}{v * 100.0:,.1f}%"
if unit == MULTIPLE:
return f"{v:,.1f}x"
if unit == COUNT:
return f"{int(round(v)):,}"
if unit == USD_PER_SHARE:
return f"{sg}${v:,.2f}"
if unit in (USD, USD_MILLION, USD_BILLION):
a = abs(v)
s = "-" if v < 0 else sg
if a >= 1e12:
return f"{s}${a / 1e12:,.2f}T"
if a >= 1e9:
return f"{s}${a / 1e9:,.2f}B"
if a >= 1e6:
return f"{s}${a / 1e6:,.0f}M" if a >= 1e8 else f"{s}${a / 1e6:,.1f}M"
return f"{s}${a:,.0f}"
raise QuantityError(f"cannot format unit {unit!r}")
def format_range(low, high, unit, quantity_type=None):
lo, hi = format_value(low, unit, quantity_type), format_value(high, unit, quantity_type)
if lo is None and hi is None:
return None
if lo is None or hi is None or lo == hi:
return lo or hi
if unit == BASIS_POINTS:
return f"{_num(float(low) * 10000.0, 0)}–{_num(float(high) * 10000.0, 0)} bps"
if unit == PERCENTAGE_POINTS:
return f"{_num(float(low) * 100.0, 2)}–{_num(float(high) * 100.0, 2)} percentage points"
return f"{lo}–{hi}"
MAGNITUDE_BAND = (0.2, 5.0)
def magnitude_ok(value, low, high=None):
"""(ok, reason): is a threshold measured against a source of the same unit of the same order of magnitude?
A threshold of 3.6 bps against guidance of 335–360 bps is the same number in the wrong unit, not a judgement."""
refs = [abs(float(x)) for x in (low, high) if x is not None and float(x) != 0.0]
if value is None or not refs:
return True, ""
v = abs(float(value))
ratio = min((v / r for r in refs), key=lambda q: abs(q - 1.0) if q else float("inf")) if v else 0.0
lo, hi = MAGNITUDE_BAND
if lo <= ratio <= hi:
return True, ""
return False, f"threshold is {ratio:.3g}× its source value (outside {lo}–{hi}×): a unit slip, not a judgement"
|