"""One canonical representation and one formatting path for every rate-like value. The defect this module exists to make impossible: the same canonical number, a model-implied 12-month return of 0.2375, rendered as "+23.8%" on the cover and "+0.2%" in the synthesis paragraph, because one call site multiplied by 100 before appending "%" and the other did not. Nothing in the code declared which convention a field used, so each renderer guessed. Rules ----- * Every rate-like field declares its storage unit in ``FIELD_UNITS``. The unit is never inferred from the variable name at a call site. * A rate-like value is rendered only through ``format_percent`` / ``Pct``. Appending a literal "%" to a bare float is forbidden and is caught by ``scan_source_for_adhoc_percent``. * ``parse_percent(format_percent(x)) ≈ x`` for every storage unit, which is what lets the final render audit read a displayed string back and compare it with the canonical value. """ from __future__ import annotations import re from dataclasses import dataclass # ── storage units ───────────────────────────────────────────────────────────────────── DECIMAL_FRACTION = "DECIMAL_FRACTION" # 0.2375 means 23.75% PERCENT_NUMBER = "PERCENT_NUMBER" # 23.75 means 23.75% BASIS_POINTS = "BASIS_POINTS" # 2375 means 23.75% RATIO = "RATIO" # 1.35 means 1.35x — NOT a percentage UNITS = (DECIMAL_FRACTION, PERCENT_NUMBER, BASIS_POINTS, RATIO) _TO_DECIMAL = {DECIMAL_FRACTION: 1.0, PERCENT_NUMBER: 0.01, BASIS_POINTS: 0.0001} class UnitError(ValueError): """A rate-like value was used without a declared, convertible unit.""" # ── the declared storage semantics of every rate-like field ─────────────────────────── # Adding a rate-like field without an entry here is a test failure, not a silent default. FIELD_UNITS = { # returns "expected_return": DECIMAL_FRACTION, "low_estimate": DECIMAL_FRACTION, "high_estimate": DECIMAL_FRACTION, "headline_return": DECIMAL_FRACTION, "model_implied_return": DECIMAL_FRACTION, "pm_return": DECIMAL_FRACTION, "scenario_ev": DECIMAL_FRACTION, "bear_return": DECIMAL_FRACTION, "neutral_return": DECIMAL_FRACTION, "bull_return": DECIMAL_FRACTION, "value_implied": DECIMAL_FRACTION, "upside": DECIMAL_FRACTION, "downside": DECIMAL_FRACTION, "discount_to_price": DECIMAL_FRACTION, "premium_to_price": DECIMAL_FRACTION, "return_3m": DECIMAL_FRACTION, "return_12m": DECIMAL_FRACTION, # growth "eps_growth": DECIMAL_FRACTION, "analyst_upside": DECIMAL_FRACTION, "margin_trend": DECIMAL_FRACTION, "rev_growth": DECIMAL_FRACTION, "revenue_growth": DECIMAL_FRACTION, "fcf_growth": DECIMAL_FRACTION, "terminal_growth": DECIMAL_FRACTION, "consensus_growth": DECIMAL_FRACTION, # margins "op_margin_current": DECIMAL_FRACTION, "op_margin_predicted": DECIMAL_FRACTION, "operating_margin": DECIMAL_FRACTION, "operating_margin_ttm": DECIMAL_FRACTION, "operating_margin_forward_12m": DECIMAL_FRACTION, "operating_margin_story_maturity": DECIMAL_FRACTION, "net_margin": DECIMAL_FRACTION, "net_margin_forward_implied": DECIMAL_FRACTION, "gross_margin": DECIMAL_FRACTION, "fcf_margin": DECIMAL_FRACTION, "margin_change": DECIMAL_FRACTION, # yields, rates "dividend_yield": DECIMAL_FRACTION, "fcf_yield": DECIMAL_FRACTION, "sales_yield": DECIMAL_FRACTION, "earnings_yield": DECIMAL_FRACTION, "risk_free_rate": DECIMAL_FRACTION, "equity_risk_premium": DECIMAL_FRACTION, "discount_rate": DECIMAL_FRACTION, "cost_of_equity": DECIMAL_FRACTION, "wacc": DECIMAL_FRACTION, "tax_rate": DECIMAL_FRACTION, "effective_tax_rate": DECIMAL_FRACTION, "roe": DECIMAL_FRACTION, "roic": DECIMAL_FRACTION, "nim": DECIMAL_FRACTION, "cet1": DECIMAL_FRACTION, "market_share": DECIMAL_FRACTION, # probabilities and confidence "p_bear": DECIMAL_FRACTION, "p_neutral": DECIMAL_FRACTION, "p_bull": DECIMAL_FRACTION, "probability": DECIMAL_FRACTION, "confidence": DECIMAL_FRACTION, "beat_rate": DECIMAL_FRACTION, "direction_accuracy": DECIMAL_FRACTION, "rank_correlation": RATIO, # a correlation is not a percentage # risk "volatility": DECIMAL_FRACTION, "drawdown": DECIMAL_FRACTION, "max_drawdown": DECIMAL_FRACTION, "tracking_error": DECIMAL_FRACTION, # surprise "avg_surprise": PERCENT_NUMBER, # providers report surprise as a percent number "surprise_pct": PERCENT_NUMBER, # spreads "spread_bps": BASIS_POINTS, "credit_spread_bps": BASIS_POINTS, # multiples — ratios, never rendered with a percent sign "pe_forward": RATIO, "pe_trailing": RATIO, "peg_forward": RATIO, "ev_ebitda": RATIO, "price_to_book": RATIO, "debt_to_equity": RATIO, } def unit_of(field, default=None): """The declared storage unit of a field. Raises rather than guessing when undeclared.""" u = FIELD_UNITS.get(field, default) if u is None: raise UnitError(f"field '{field}' has no declared storage unit — add it to FIELD_UNITS") return u def to_decimal(value, unit=DECIMAL_FRACTION): """Convert any declared rate unit to a decimal fraction.""" if value is None: return None if unit == RATIO: raise UnitError("a RATIO is not a percentage and has no decimal-fraction form") try: f = _TO_DECIMAL[unit] except KeyError: raise UnitError(f"unknown storage unit {unit!r}") from None return float(value) * f # ── the one percentage type ─────────────────────────────────────────────────────────── @dataclass(frozen=True) class Pct: """A rate, held as a decimal fraction whatever unit it arrived in.""" decimal: float @classmethod def of(cls, value, unit=DECIMAL_FRACTION): d = to_decimal(value, unit) return None if d is None else cls(float(d)) @classmethod def of_field(cls, field, value): return cls.of(value, unit_of(field)) def as_decimal(self): return self.decimal def as_percent_number(self): return self.decimal * 100.0 def as_basis_points(self): return self.decimal * 10000.0 def format_percent(self, decimals=1, sign=False): return format_percent(self.decimal, decimals=decimals, sign=sign) def format_basis_points(self, decimals=0): return f"{self.decimal * 10000.0:,.{decimals}f} bps" def format_ratio(self, decimals=4): return f"{self.decimal:.{decimals}f}" def __str__(self): return self.format_percent() def format_percent(value, decimals=1, sign=False, unit=DECIMAL_FRACTION, none="n/a"): """The only way a percentage becomes user-visible text. ``format_percent(0.2375, sign=True) == "+23.8%"``. Passing a percent number by mistake is not silently accepted: declare the unit instead — ``format_percent(23.75, unit=PERCENT_NUMBER)``. """ if isinstance(value, Pct): value, unit = value.decimal, DECIMAL_FRACTION if value is None: return none d = to_decimal(value, unit) * 100.0 if sign: # a value that rounds to zero prints without a misleading direction if round(d, decimals) == 0: return f"{0.0:.{decimals}f}%" return f"{d:+.{decimals}f}%" return f"{d:.{decimals}f}%" def format_pp(value, decimals=1, sign=True, unit=DECIMAL_FRACTION, none="n/a"): """A difference between two percentages, rendered in percentage points.""" if isinstance(value, Pct): value, unit = value.decimal, DECIMAL_FRACTION if value is None: return none d = to_decimal(value, unit) * 100.0 return f"{d:+.{decimals}f} pp" if sign else f"{d:.{decimals}f} pp" def format_ratio(value, decimals=2, suffix="x", none="n/a"): """A multiple. Never carries a percent sign.""" if value is None: return none return f"{float(value):.{decimals}f}{suffix}" _PCT_RE = re.compile(r"([+-]?\d{1,3}(?:,\d{3})*(?:\.\d+)?)\s*%") _BPS_RE = re.compile(r"([+-]?\d{1,3}(?:,\d{3})*(?:\.\d+)?)\s*bps", re.I) # ── deterministic ranges ────────────────────────────────────────────────────────────── @dataclass(frozen=True) class RangeClaim: """A numeric interval derived from registered endpoint claims, never invented in prose.""" lower: float upper: float semantic_type: str = DECIMAL_FRACTION claim_id: str | None = None lower_claim: str | None = None upper_claim: str | None = None label: str = "" @classmethod def from_endpoints(cls, a, b, semantic_type=DECIMAL_FRACTION, **kw): """Order-independent: the caller supplies two endpoint values, not a pre-sorted pair.""" if a is None or b is None: return None lo, hi = (a, b) if a <= b else (b, a) return cls(float(lo), float(hi), semantic_type, **kw) def magnitude(self): """The interval of absolute sizes — what "11–14% below price" states.""" a, b = abs(self.lower), abs(self.upper) return (min(a, b), max(a, b)) def format_percent_range(self, decimals=0, absolute=False): lo, hi = self.magnitude() if absolute else (self.lower, self.upper) u = self.semantic_type return f"{format_percent(lo, decimals, unit=u)}–{format_percent(hi, decimals, unit=u)}".replace("%–", "–") def format_currency_range(self, decimals=2, symbol="$"): lo, hi = (self.lower, self.upper) return f"{symbol}{lo:,.{decimals}f}–{symbol}{hi:,.{decimals}f}" def contains(self, value, tol=0.005): return value is not None and (self.lower - tol) <= value <= (self.upper + tol) _RANGE_PCT_RE = re.compile(r"(\d{1,3}(?:\.\d+)?)\s*[–-]\s*(\d{1,3}(?:\.\d+)?)\s*%") _RANGE_CUR_RE = re.compile(r"\$(\d[\d,]*(?:\.\d+)?)\s*[–-]\s*\$?(\d[\d,]*(?:\.\d+)?)") # ── guard against the defect returning ──────────────────────────────────────────────── _ADHOC = re.compile(r"""\{[^{}]*?:[+ ]?[,]?\.\d+f\}\s*%""") def scan_source_for_adhoc_percent(source, allow_marker="# pct-ok"): """Report f-string sites that append a literal '%' to a formatted float. Such a site re-implements ``format_percent`` and is where the unit convention silently diverges. A line may opt out with a trailing ``# pct-ok`` marker when the value is genuinely already a percent number from an external source. """ hits = [] for i, line in enumerate(source.splitlines(), 1): if allow_marker in line: continue if _ADHOC.search(line): hits.append((i, line.strip())) return hits