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"