diff --git a/agent/backtest/factor_costs.py b/agent/backtest/factor_costs.py new file mode 100644 index 00000000..085ba2e6 --- /dev/null +++ b/agent/backtest/factor_costs.py @@ -0,0 +1,416 @@ +"""Trading-cost model for factor backtests: ADV caps, impact, borrow. + +A long-short factor backtest that rebalances daily on a 500-name universe is +implicitly claiming it can trade every name, in any size, at the close, for a +flat fee. Three things break that claim, and this module prices all three: + +**Capacity.** You cannot buy more of a stock in a day than the market trades. +The standard bound is a participation rate -- a fraction of average daily volume +you are willing to be. Beyond it you are the market, not a participant in it. +When a target weight needs more than the cap allows, the position moves *part of +the way* and the shortfall carries into the next period. It is not silently +filled, and it is not silently dropped: both would flatter the backtest, in +opposite directions. + +**Impact.** The part you do trade moves the price against you, and the amount is +not linear in size. :mod:`src.quantlib.impact` already owns those models -- +this module calls them rather than carrying a second copy. + +**Borrow.** The short leg is not free. Someone lends you the stock and charges +for it, and for hard-to-borrow names that fee can exceed the alpha. A long-short +factor reported without borrow cost is reporting a portfolio nobody could have +held. + +WHY THIS IS NOT IN THE ENGINE +----------------------------- +``backtest/engines/base.py`` prices costs per *fill*, which is the right model +when there is an order book and a fill sequence. A factor bench has neither: it +has a weight vector per rebalance date. Forcing one abstraction onto the other +would mean inventing fills that never happened. This module therefore works in +weight space, and the two never share a code path. + +SIGN AND UNIT CONVENTIONS +------------------------- +* Weights are fractions of capital; a short is negative. +* Every cost this module returns is a POSITIVE drag, to be subtracted from a + gross return. A function that returned a signed adjustment would eventually be + added somewhere by mistake. +* ``adv`` is in the same currency as ``capital``, not in shares. Mixing those is + the classic error here, so the argument names say ``_value`` and the + docstrings repeat it. +* Rates named ``annual_*`` are annualised decimals; the periods-per-year + argument converts them, and it has no default because a daily and a monthly + bench would silently differ by 21x. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import numpy as np +import pandas as pd + +from src.quantlib.impact import DEFAULT_SLIPPAGE_BPS, linear_impact, sqrt_impact + +__all__ = [ + "DEFAULT_MAX_PARTICIPATION", + "IMPACT_MODELS", + "MARKET_BORROW_RATES", + "CapacityResult", + "PeriodCost", + "apply_adv_capacity", + "borrow_cost", + "rebalance_cost", +] + +#: Fraction of a name's average daily value a strategy is allowed to be. 10% is +#: the conventional institutional ceiling; above it the participation itself +#: starts to define the price and the impact models stop being descriptive. +DEFAULT_MAX_PARTICIPATION: float = 0.10 + +#: Impact models callable by name. Both come from ``src.quantlib.impact``; this +#: module owns no impact formula of its own. +IMPACT_MODELS: tuple[str, ...] = ("fixed", "linear", "sqrt") + +#: Indicative annual stock-borrow rates for a general-collateral name, by +#: market. These are STARTING POINTS for a caller who has no better number, not +#: measurements: a hard-to-borrow name can cost 20% or more, and nothing in this +#: repository knows which names those are. Pass your own rate when you have one. +MARKET_BORROW_RATES: dict[str, float] = { + "US": 0.0030, + "CN": 0.0850, + "HK": 0.0100, + "JP": 0.0050, +} + + +@dataclass(frozen=True) +class CapacityResult: + """Weights actually achievable under an ADV participation cap. + + Attributes: + achieved_weights: Weights after capping, indexed by symbol. + requested_weights: The weights that were asked for. + unfilled_weights: ``requested - achieved`` per symbol. Non-zero means + the strategy wanted a position the market could not give it that + period; carry this into the next rebalance rather than discarding it. + capped_symbols: Symbols whose trade hit the cap. + participation: Realised participation per symbol, as a fraction of ADV. + max_participation: The cap that was applied. + """ + + achieved_weights: pd.Series + requested_weights: pd.Series + unfilled_weights: pd.Series + capped_symbols: tuple[str, ...] + participation: pd.Series + max_participation: float + + +@dataclass(frozen=True) +class PeriodCost: + """All-in trading drag for one rebalance period. + + Attributes: + impact_cost: Drag from market impact and spread, as a fraction of + capital. Positive. + borrow_cost: Drag from borrowing the short leg, as a fraction of + capital. Positive. + total_cost: ``impact_cost + borrow_cost``. Positive. + turnover: One-way turnover actually executed, as a fraction of capital. + unfilled_turnover: Turnover the ADV cap prevented. + capped_symbols: Symbols that hit the cap. + impact_model: Which model priced the impact. + """ + + impact_cost: float + borrow_cost: float + total_cost: float + turnover: float + unfilled_turnover: float + capped_symbols: tuple[str, ...] + impact_model: str + + +def _as_aligned( + target: pd.Series, current: pd.Series +) -> tuple[pd.Series, pd.Series, pd.Index]: + """Put two weight vectors on one index, filling absent names with zero. + + A name in the target but not the current book is a new position; a name in + the current book but not the target is a full exit. Both are trades, so + neither may be dropped by an inner join. + + Args: + target: Desired weights. + current: Weights held before the rebalance. + + Returns: + Tuple of ``(target, current, index)`` on the union index. + """ + index = target.index.union(current.index) + return ( + target.reindex(index).fillna(0.0).astype(float), + current.reindex(index).fillna(0.0).astype(float), + index, + ) + + +def apply_adv_capacity( + target_weights: pd.Series, + current_weights: pd.Series, + adv_value: pd.Series, + capital: float, + max_participation: float = DEFAULT_MAX_PARTICIPATION, +) -> CapacityResult: + """Cap each name's trade at a fraction of its average daily traded value. + + Args: + target_weights: Desired weights by symbol, shorts negative. + current_weights: Weights held before this rebalance. + adv_value: Average daily traded VALUE by symbol, in the same currency as + ``capital`` -- not a share count. + capital: Portfolio capital in that currency. + max_participation: Largest fraction of a name's ADV the strategy may be. + + Returns: + A :class:`CapacityResult`. A symbol with no ADV entry, or a non-positive + one, is treated as untradeable this period: its weight stays where it + was and the whole requested change lands in ``unfilled_weights``. That + is deliberately the conservative reading -- assuming a name with unknown + volume is infinitely liquid is how a backtest acquires capacity it never + had. + + Raises: + ValueError: If ``capital`` is not positive or ``max_participation`` is + outside ``(0, 1]``. + """ + if not capital > 0: + raise ValueError(f"capital must be positive, got {capital}") + if not 0.0 < max_participation <= 1.0: + raise ValueError( + f"max_participation must be in (0, 1], got {max_participation}" + ) + + target, current, index = _as_aligned(target_weights, current_weights) + adv = pd.Series(adv_value, dtype=float).reindex(index) + + requested_change = target - current + # Largest weight change a name can absorb: participation x ADV, expressed + # as a fraction of capital. + capacity = (adv * max_participation / capital).fillna(0.0) + capacity[~np.isfinite(capacity)] = 0.0 + capacity[capacity < 0.0] = 0.0 + + allowed_change = requested_change.clip(lower=-capacity, upper=capacity) + achieved = current + allowed_change + unfilled = target - achieved + + capped = tuple( + str(sym) + for sym in index + if abs(requested_change[sym]) > capacity[sym] + 1e-15 + and abs(requested_change[sym]) > 0.0 + ) + with np.errstate(divide="ignore", invalid="ignore"): + participation = (allowed_change.abs() * capital / adv).replace( + [np.inf, -np.inf], np.nan + ) + + return CapacityResult( + achieved_weights=achieved, + requested_weights=target, + unfilled_weights=unfilled, + capped_symbols=capped, + participation=participation, + max_participation=max_participation, + ) + + +def borrow_cost( + weights: pd.Series, + periods_per_year: int, + annual_rate: float | pd.Series | None = None, + market: str | None = None, +) -> float: + """Cost of borrowing the short leg for one period. + + Only negative weights are charged: a long position borrows nothing. The + charge is the absolute short exposure times the pro-rated annual fee. + + Args: + weights: Portfolio weights, shorts negative. + periods_per_year: Rebalance periods in a year -- 252 for daily, 12 for + monthly. No default: a daily and a monthly bench charging the same + per-period rate differ by 21x, and nothing downstream would notice. + annual_rate: Annual borrow fee as a decimal, either one rate for the + whole book or a per-symbol Series. Takes precedence over ``market``. + market: Key into :data:`MARKET_BORROW_RATES` when no explicit rate is + supplied. Those values are indicative general-collateral rates, not + measurements -- see the constant's own note. + + Returns: + Positive cost as a fraction of capital. + + Raises: + ValueError: If ``periods_per_year`` is not positive, if neither + ``annual_rate`` nor ``market`` is supplied, if ``market`` is + unknown, or if any rate is negative. + """ + if periods_per_year <= 0: + raise ValueError(f"periods_per_year must be positive, got {periods_per_year}") + + if annual_rate is None: + if market is None: + raise ValueError( + "supply annual_rate, or market to fall back on an indicative " + f"general-collateral rate from {sorted(MARKET_BORROW_RATES)}" + ) + if market not in MARKET_BORROW_RATES: + raise ValueError( + f"unknown market {market!r}; known: {sorted(MARKET_BORROW_RATES)}" + ) + annual_rate = MARKET_BORROW_RATES[market] + + shorts = pd.Series(weights, dtype=float) + shorts = -shorts[shorts < 0.0] + if shorts.empty: + return 0.0 + + if isinstance(annual_rate, pd.Series): + rates = annual_rate.reindex(shorts.index) + if rates.isna().any(): + missing = sorted(str(s) for s in rates.index[rates.isna()]) + raise ValueError( + f"annual_rate is missing a rate for shorted symbol(s): {missing}" + ) + else: + rates = pd.Series(float(annual_rate), index=shorts.index) + + if (rates < 0.0).any(): + raise ValueError("borrow rates must be non-negative") + + return float((shorts * rates).sum() / periods_per_year) + + +def rebalance_cost( + target_weights: pd.Series, + current_weights: pd.Series, + capital: float, + periods_per_year: int, + adv_value: pd.Series | None = None, + max_participation: float = DEFAULT_MAX_PARTICIPATION, + impact_model: str = "sqrt", + slippage_bps: float = DEFAULT_SLIPPAGE_BPS, + impact_coefficient: float = 0.1, + volatility: float | pd.Series | None = None, + borrow_annual_rate: float | pd.Series | None = None, + borrow_market: str | None = None, +) -> tuple[PeriodCost, CapacityResult | None]: + """Price one rebalance: capacity, then impact, then borrow. + + Args: + target_weights: Desired weights, shorts negative. + current_weights: Weights held before the rebalance. + capital: Portfolio capital. + periods_per_year: Rebalance periods in a year, for the borrow charge. + adv_value: Average daily traded VALUE by symbol. When None, no capacity + cap is applied and ``capped_symbols`` comes back empty -- which is + an assumption of infinite liquidity and should be stated wherever + the result is reported. + max_participation: Participation ceiling for the capacity cap. + impact_model: One of :data:`IMPACT_MODELS`. + slippage_bps: Half-spread plus fees, in basis points, charged on every + unit of turnover regardless of model. + impact_coefficient: Coefficient handed to the linear or sqrt model. + volatility: Per-period volatility, required by the ``sqrt`` model. + borrow_annual_rate: Annual borrow fee, decimal. + borrow_market: Fallback market key when no explicit rate is given. + + Returns: + Tuple of the :class:`PeriodCost` and the :class:`CapacityResult` (None + when no ``adv_value`` was supplied). + + Raises: + ValueError: If ``impact_model`` is unknown, if the ``sqrt`` model is + chosen without ``volatility``, or from the functions this delegates + to. + """ + if impact_model not in IMPACT_MODELS: + raise ValueError( + f"impact_model must be one of {IMPACT_MODELS}, got {impact_model!r}" + ) + + target, current, index = _as_aligned(target_weights, current_weights) + + capacity: CapacityResult | None = None + if adv_value is not None: + capacity = apply_adv_capacity( + target, current, adv_value, capital, max_participation + ) + achieved = capacity.achieved_weights + unfilled_turnover = float(capacity.unfilled_weights.abs().sum()) + capped = capacity.capped_symbols + else: + achieved = target + unfilled_turnover = 0.0 + capped = () + + traded = (achieved - current).abs() + turnover = float(traded.sum()) + + # Spread and fees are charged on every unit of turnover under every model. + spread_cost = turnover * slippage_bps / 10_000.0 + + if impact_model == "fixed" or turnover == 0.0: + impact = 0.0 + elif impact_model == "linear": + participation = ( + capacity.participation.reindex(index).fillna(0.0) + if capacity is not None + else pd.Series(0.0, index=index) + ) + # linear_impact prices a fill; here it prices the weighted average + # participation, and the drag scales with the traded fraction. + impact = float((traded * participation * impact_coefficient).sum()) + else: # sqrt + if volatility is None: + raise ValueError( + "the sqrt impact model needs a volatility; supply one or choose " + "impact_model='linear' or 'fixed'" + ) + vol = ( + pd.Series(volatility, dtype=float).reindex(index).fillna(0.0) + if isinstance(volatility, pd.Series) + else pd.Series(float(volatility), index=index) + ) + participation = ( + capacity.participation.reindex(index).fillna(0.0) + if capacity is not None + else pd.Series(0.0, index=index) + ) + impact = float( + (traded * impact_coefficient * vol * np.sqrt(participation.clip(lower=0.0))).sum() + ) + + impact_cost = spread_cost + impact + borrow = borrow_cost( + achieved, + periods_per_year=periods_per_year, + annual_rate=borrow_annual_rate, + market=borrow_market, + ) if (borrow_annual_rate is not None or borrow_market is not None) else 0.0 + + return ( + PeriodCost( + impact_cost=impact_cost, + borrow_cost=borrow, + total_cost=impact_cost + borrow, + turnover=turnover, + unfilled_turnover=unfilled_turnover, + capped_symbols=capped, + impact_model=impact_model, + ), + capacity, + ) diff --git a/agent/src/entities/cashflow.py b/agent/src/entities/cashflow.py index b8259eba..5fb168a0 100644 --- a/agent/src/entities/cashflow.py +++ b/agent/src/entities/cashflow.py @@ -50,9 +50,13 @@ __all__ = [ "CashFlow", "CashFlowSeries", "CurrencyMismatchError", + "FxRate", + "FxRateTable", "KIND_DIRECTION", + "MissingExchangeRateError", "VALUATION_KINDS", "normalize_kind", + "translate_cashflows", ] #: Canonical kinds whose direction is definitional, mapped to the required sign @@ -416,3 +420,349 @@ class CashFlowSeries: if include_valuations or not flow.is_valuation ) ) + + +# ───────────────────────────────────────────────────────────────────────── +# FX translation: turn several single-currency flows into one pre_translated +# series, using each flow's OWN settlement-date rate (never a period-end +# rate -- see ``translate_cashflows``). +# ───────────────────────────────────────────────────────────────────────── + + +class MissingExchangeRateError(ValueError): + """Raised when a cash flow's settlement date has no usable exchange rate. + + Forward-filling silently across a rate gap is exactly the failure mode + this module refuses by default: a large flow that happens to land inside + the gap would be translated with the wrong rate, and nothing downstream + could tell. The explicit, opt-in alternative is + ``FxRateTable.get_rate(..., allow_stale=True)``. + """ + + +@dataclass(frozen=True) +class FxRate: + """One exchange-rate quote, in the standard FX BASE/QUOTE convention. + + Naming both currencies explicitly -- instead of accepting a bare + ``rate`` float and a docstring nobody reads -- is the defense against the + single most common bug in FX-translation code: silently applying the + reciprocal rate. ``EURUSD = 1.08`` means ``base_currency="EUR"``, + ``quote_currency="USD"``, ``rate=1.08``: 1 EUR converts to 1.08 USD, + never the other way around. + + Attributes: + base_currency: The currency being priced -- in a translation, the + cash flow's own currency. Normalized to uppercase. + quote_currency: The currency the rate is expressed in -- in a + translation, the reporting currency. Normalized to uppercase. + date: The date this quote applies to. + rate: Units of ``quote_currency`` per 1 unit of ``base_currency``, so + ``amount_in_quote = amount_in_base * rate``. Must be finite and + strictly positive: a zero or negative rate would silently zero + out, or flip the sign of, every flow translated with it. + """ + + base_currency: str + quote_currency: str + date: date + rate: float + + def __post_init__(self) -> None: + """Normalize the currencies and date, and validate the rate. + + Raises: + ValueError: If either currency code is invalid, the date is + unsupported, or the rate is not finite and strictly positive. + """ + object.__setattr__( + self, + "base_currency", + normalize_currency(self.base_currency, field_name="base_currency"), + ) + object.__setattr__( + self, + "quote_currency", + normalize_currency(self.quote_currency, field_name="quote_currency"), + ) + object.__setattr__(self, "date", normalize_date(self.date)) + try: + rate = float(self.rate) + except (TypeError, ValueError) as exc: + raise ValueError(f"rate must be numeric, got {self.rate!r}") from exc + if not math.isfinite(rate) or rate <= 0: + raise ValueError( + f"rate must be finite and strictly positive -- it is " + f"quote-per-base units -- got {rate!r}; a zero or negative " + "rate would silently zero out or flip the sign of every flow " + "translated with it" + ) + object.__setattr__(self, "rate", rate) + + +@dataclass(frozen=True) +class FxRateTable: + """A lookup of FX rates, every entry quoted against one fixed currency. + + All entries must share the same ``quote_currency``: a table that mixed + quote currencies would make "translate to X" ambiguous, so that is + rejected at construction rather than left to whichever rate happens to be + looked up first. + + Attributes: + quote_currency: The single reporting currency every rate quotes + against. Normalized to uppercase. + rates: Mapping of ``(base_currency, date) -> FxRate``, keyed by the + already-normalized currency code and date. + """ + + quote_currency: str + rates: Mapping[tuple[str, date], FxRate] = field(default_factory=dict) + + def __post_init__(self) -> None: + """Validate the quote currency and every entry's consistency. + + Raises: + ValueError: If ``quote_currency`` is invalid, a value is not an + ``FxRate``, an entry quotes against a different currency than + the table's, or an entry is stored under a key that does not + match its own ``(base_currency, date)``. + """ + quote = normalize_currency(self.quote_currency, field_name="quote_currency") + rates = dict(self.rates) + for key, entry in rates.items(): + if not isinstance(entry, FxRate): + raise ValueError( + f"FxRateTable entries must be FxRate, got {type(entry).__name__}" + ) + if entry.quote_currency != quote: + raise ValueError( + f"FxRate {entry.base_currency}/{entry.quote_currency}@" + f"{entry.date} does not quote against this table's " + f"currency {quote!r}; a table may only hold rates quoted " + "against one reporting currency" + ) + expected_key = (entry.base_currency, entry.date) + if key != expected_key: + raise ValueError( + f"FxRate stored under key {key!r} does not match its own " + f"(base_currency, date) = {expected_key!r}" + ) + object.__setattr__(self, "quote_currency", quote) + object.__setattr__(self, "rates", MappingProxyType(rates)) + + @classmethod + def from_rates(cls, rates: Iterable[FxRate], *, quote_currency: str) -> "FxRateTable": + """Build a table from a sequence of individual quotes. + + Args: + rates: The quotes, all against ``quote_currency``. + quote_currency: The reporting currency of the table. + + Returns: + A new ``FxRateTable`` keyed by ``(base_currency, date)``. + + Raises: + ValueError: If a member is not an ``FxRate``, two quotes collide + on the same ``(base_currency, date)`` with different rates, + or a quote's ``quote_currency`` disagrees with the table's. + """ + indexed: dict[tuple[str, date], FxRate] = {} + for entry in rates: + if not isinstance(entry, FxRate): + raise ValueError(f"rates must contain FxRate, got {type(entry).__name__}") + key = (entry.base_currency, entry.date) + if key in indexed and indexed[key].rate != entry.rate: + raise ValueError( + f"conflicting rates for {key}: {indexed[key].rate!r} vs " + f"{entry.rate!r}" + ) + indexed[key] = entry + return cls(quote_currency=quote_currency, rates=indexed) + + @classmethod + def from_mapping( + cls, + mapping: Mapping[tuple[str, "date | datetime | str"], float], + *, + quote_currency: str, + ) -> "FxRateTable": + """Build a table from ``{(base_currency, date): rate}`` pairs. + + Args: + mapping: Each value is units of ``quote_currency`` per 1 unit of + the key's currency, exactly as ``FxRate.rate`` defines it. + quote_currency: The reporting currency of the table. + + Returns: + A new ``FxRateTable``. + + Raises: + ValueError: If a currency code or date in a key is invalid, or a + rate is not finite and strictly positive. + """ + rows = [ + FxRate(base_currency=base, quote_currency=quote_currency, date=day, rate=rate) + for (base, day), rate in mapping.items() + ] + return cls.from_rates(rows, quote_currency=quote_currency) + + def get_rate( + self, + base_currency: str, + on_date: date, + *, + allow_stale: bool = False, + max_staleness_days: int | None = None, + ) -> tuple[FxRate, bool]: + """Look up the rate to translate ``base_currency`` on ``on_date``. + + Only a same-day or an earlier quote is ever used: translating a flow + with a rate quoted *after* it settled would leak future information + into the past, which is worse than an outright missing rate. + + Args: + base_currency: Currency being priced. + on_date: The flow's own settlement date -- never a series end + date. Applying one period-end rate to every flow is the + classic mistake this table exists to prevent. + allow_stale: When ``on_date`` has no exact quote, search backward + for the most recent earlier one instead of raising. Off by + default: reusing a stale rate without being asked is the + silent forward-fill-through-a-gap failure mode this module + refuses by default. + max_staleness_days: With ``allow_stale=True``, how many days + before ``on_date`` a reused quote may be. ``None`` means an + unbounded backward search. + + Returns: + A tuple of the ``FxRate`` used and whether it was stale (its + ``date`` is earlier than ``on_date``). + + Raises: + MissingExchangeRateError: If there is no exact same-day quote and + either ``allow_stale`` is False, or no earlier quote exists + (within ``max_staleness_days`` when given). + """ + base = normalize_currency(base_currency, field_name="base_currency") + day = normalize_date(on_date) + + exact = self.rates.get((base, day)) + if exact is not None: + return exact, False + + if not allow_stale: + raise MissingExchangeRateError( + f"no {base}/{self.quote_currency} rate for {day}; pass " + "allow_stale=True to reuse the most recent earlier rate " + "instead -- stale reuse is flagged on the translated flow, " + "never applied silently" + ) + + candidates = [ + entry + for (entry_base, entry_date), entry in self.rates.items() + if entry_base == base and entry_date <= day + ] + if max_staleness_days is not None: + candidates = [ + entry + for entry in candidates + if (day - entry.date).days <= max_staleness_days + ] + if not candidates: + bound = ( + f" within {max_staleness_days} days" if max_staleness_days is not None else "" + ) + raise MissingExchangeRateError( + f"no {base}/{self.quote_currency} rate on or before {day}{bound}" + ) + best = max(candidates, key=lambda entry: entry.date) + return best, True + + +def translate_cashflows( + flows: CashFlowSeries | Iterable[CashFlow], + rate_table: FxRateTable, + *, + allow_stale_rates: bool = False, + max_staleness_days: int | None = None, +) -> CashFlowSeries: + """Convert flows in one or more currencies into ``rate_table``'s currency. + + Each flow is converted using the FX rate quoted on **that flow's own + settlement date** -- never a single series-level or period-end rate. + Applying one end-of-period rate to every flow is the most common + real-world FX-translation bug: it looks correct on the aggregate total + while silently mispricing every individual flow that did not happen to + settle on that date. + + Translation is a lossy operation -- once flows are summed, the + original-currency detail cannot be recovered from the total alone -- so + every translated flow keeps its original amount, currency, and the exact + rate and rate date used, recorded in + ``metadata["fx_original_amount"]``, ``metadata["fx_original_currency"]``, + ``metadata["fx_rate"]``, ``metadata["fx_rate_date"]``, and + ``metadata["fx_rate_is_stale"]``. + + Args: + flows: The source flows, in any mix of currencies. + rate_table: Rates to translate with; see ``FxRateTable``. Its + ``quote_currency`` becomes the returned series' currency. + allow_stale_rates: Forwarded to ``FxRateTable.get_rate``. Off by + default, so a settlement date with no exact quote raises rather + than silently reusing an older one. + max_staleness_days: Forwarded to ``FxRateTable.get_rate``. + + Returns: + A ``CashFlowSeries`` with ``pre_translated=True`` and + ``currency=rate_table.quote_currency``. + + Raises: + MissingExchangeRateError: If a flow's currency has no usable rate for + its settlement date; see ``allow_stale_rates``. + ValueError: If ``flows`` contains a member that is not a ``CashFlow``. + """ + translated: list[CashFlow] = [] + for flow in flows: + if not isinstance(flow, CashFlow): + raise ValueError(f"flows must contain CashFlow, got {type(flow).__name__}") + + if flow.currency == rate_table.quote_currency: + converted_amount = flow.amount + rate_used = 1.0 + rate_date = flow.date + is_stale = False + else: + fx_rate, is_stale = rate_table.get_rate( + flow.currency, + flow.date, + allow_stale=allow_stale_rates, + max_staleness_days=max_staleness_days, + ) + converted_amount = flow.amount * fx_rate.rate + rate_used = fx_rate.rate + rate_date = fx_rate.date + + metadata = dict(flow.metadata) + metadata["fx_original_amount"] = flow.amount + metadata["fx_original_currency"] = flow.currency + metadata["fx_rate"] = rate_used + metadata["fx_rate_date"] = rate_date + metadata["fx_rate_is_stale"] = is_stale + + translated.append( + CashFlow( + date=flow.date, + amount=converted_amount, + kind=flow.kind, + currency=rate_table.quote_currency, + metadata=metadata, + ) + ) + + return CashFlowSeries( + flows=tuple(translated), + currency=rate_table.quote_currency, + pre_translated=True, + ) diff --git a/agent/src/entities/ingest.py b/agent/src/entities/ingest.py index ae3eda7a..683f3b57 100644 --- a/agent/src/entities/ingest.py +++ b/agent/src/entities/ingest.py @@ -23,16 +23,28 @@ from __future__ import annotations import csv import logging +import math +from dataclasses import dataclass, field from datetime import date, datetime from pathlib import Path +from types import MappingProxyType from typing import Any, Iterable, Mapping, Sequence from src.entities.cashflow import CashFlow, CashFlowSeries +from src.entities.cashflow import normalize_kind from src.entities.models import normalize_date +from src.entities.models import normalize_currency logger = logging.getLogger(__name__) -__all__ = ["CashFlowIngestError", "load_cashflows"] +__all__ = [ + "CashFlowIngestError", + "EntityPanel", + "PanelIngestError", + "PanelObservation", + "load_cashflows", + "load_panel", +] #: Canonical field -> the column names accepted for it without an explicit #: mapping. Matching is case-insensitive and ignores spaces and underscores. @@ -472,3 +484,824 @@ def load_cashflows( currency=currency, pre_translated=pre_translated, ) + + +# ───────────────────────────────────────────────────────────────────────── +# Entity x date x metric panels: the non-bar counterpart to load_cashflows +# for data that is not a signed cash movement (a NAV series, an exposure or +# headcount table, any per-entity time series a bar engine cannot hold). +# This path never touches ``backtest/loaders`` or ``backtest/runner`` -- see +# the module docstring and the ``src.entities`` package note on staying +# parallel to the bar engines. +# ───────────────────────────────────────────────────────────────────────── + + +class PanelIngestError(ValueError): + """Raised when an entity x date x metric panel file cannot be read. + + Mirrors ``CashFlowIngestError``'s contract: never return ``None`` or a + silently-empty result to mean "could not read this"; name the file, the + row or column, and the fix. + """ + + +#: Canonical panel field -> column-name aliases accepted without an explicit +#: mapping. Reuses ``DEFAULT_COLUMN_ALIASES`` wherever the concept already +#: exists there (``date``, ``currency``, and ``value``/"amount"), so date and +#: currency columns are recognised by the exact same names as in +#: ``load_cashflows``. +_PANEL_ENTITY_ALIASES: tuple[str, ...] = ( + "entity", + "entityid", + "id", + "ticker", + "symbol", + "instrument", + "instrumentid", +) +_PANEL_METRIC_ALIASES: tuple[str, ...] = ("metric", "field", "measure", "item") +_PANEL_UNIT_ALIASES: tuple[str, ...] = ("unit", "units", "uom") + +_PANEL_COLUMN_ALIASES: Mapping[str, tuple[str, ...]] = { + "entity": _PANEL_ENTITY_ALIASES, + "date": DEFAULT_COLUMN_ALIASES["date"], + "metric": _PANEL_METRIC_ALIASES, + "value": DEFAULT_COLUMN_ALIASES["amount"], + "currency": DEFAULT_COLUMN_ALIASES["currency"], + "unit": _PANEL_UNIT_ALIASES, +} + +#: Layouts ``load_panel`` recognises; see its docstring for each shape. +_PANEL_LAYOUTS: tuple[str, ...] = ("long", "wide_dates_rows", "wide_entities_rows") + + +@dataclass(frozen=True) +class PanelObservation: + """One (entity, date, metric) observation in a non-bar data panel. + + The generic counterpart to ``CashFlow``: instead of a signed settled + amount it carries an arbitrary metric value (a NAV level, an exposure, a + headcount) for a named entity on a date. It has no ``open``/``high``/ + ``low``/``close`` fields and is not shaped like an OHLC bar, so it cannot + be mistaken for one by a bar engine. + + Attributes: + entity_id: Identifier of the entity or instrument this value belongs + to, e.g. a fund id or a ticker. + date: Observation date. + metric: Canonical metric label, e.g. ``"nav"`` or ``"exposure"``. + Normalized via ``normalize_kind`` so ``"NAV"``/``"Nav"``/``"nav"`` + are recognised as the same metric. + value: The observed numeric value. + currency: Required currency code for the value, normalized to + uppercase. + unit: Required free-form unit label, e.g. ``"USD"``, ``"shares"``, + ``"pct"``, ``"headcount"``. An exposure or NAV figure with no + unit label is exactly the kind of ambiguous number this loader + refuses to produce. + metadata: Unmapped source columns, preserved verbatim. + """ + + entity_id: str + date: date + metric: str + value: float + currency: str + unit: str + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + """Normalize every field and validate the required ones are present. + + Raises: + PanelIngestError: If ``entity_id``, ``currency``, or ``unit`` is + blank, or ``value`` is not a finite number. + ValueError: If ``date`` is unsupported or ``metadata`` is not a + mapping. + """ + entity = self.entity_id.strip() if isinstance(self.entity_id, str) else "" + if not entity: + raise PanelIngestError("entity_id is required and cannot be empty") + object.__setattr__(self, "entity_id", entity) + object.__setattr__(self, "date", normalize_date(self.date)) + object.__setattr__(self, "metric", normalize_kind(self.metric)) + + try: + value = float(self.value) + except (TypeError, ValueError) as exc: + raise PanelIngestError(f"value must be numeric, got {self.value!r}") from exc + if not math.isfinite(value): + raise PanelIngestError( + f"value must be a finite number, got {self.value!r}; a missing " + "value must be fixed at the source, not carried as NaN" + ) + object.__setattr__(self, "value", value) + + currency = self.currency.strip() if isinstance(self.currency, str) else "" + if not currency: + raise PanelIngestError("currency is required and cannot be empty") + object.__setattr__(self, "currency", normalize_currency(currency)) + + unit = self.unit.strip() if isinstance(self.unit, str) else "" + if not unit: + raise PanelIngestError("unit is required and cannot be empty") + object.__setattr__(self, "unit", unit) + + if not isinstance(self.metadata, Mapping): + raise ValueError( + f"metadata must be a mapping, got {type(self.metadata).__name__}" + ) + object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata))) + + +@dataclass(frozen=True) +class EntityPanel: + """An immutable, ordered collection of ``PanelObservation``. + + The panel counterpart to ``CashFlowSeries``, on the same non-bar + ingestion path. Deliberately not a ``pandas.DataFrame`` shaped like an + OHLC bar frame -- nothing in this class or ``load_panel`` reaches into + ``backtest/loaders`` or ``backtest/runner``. + + Attributes: + observations: The observations, ordered by ``(entity_id, date, + metric)``. + """ + + observations: tuple[PanelObservation, ...] = () + + def __post_init__(self) -> None: + """Validate membership and impose a stable (entity, date) order. + + Raises: + PanelIngestError: If a member is not a ``PanelObservation``. + """ + obs = tuple(self.observations) + for item in obs: + if not isinstance(item, PanelObservation): + raise PanelIngestError( + f"EntityPanel members must be PanelObservation, got " + f"{type(item).__name__}" + ) + object.__setattr__( + self, + "observations", + tuple(sorted(obs, key=lambda o: (o.entity_id, o.date, o.metric))), + ) + + def __iter__(self) -> Iterable[PanelObservation]: + """Iterate the observations in ``(entity_id, date, metric)`` order. + + Returns: + An iterator over ``PanelObservation``. + """ + return iter(self.observations) + + def __len__(self) -> int: + """Return the number of observations. + + Returns: + Count of observations, zero for an empty panel. + """ + return len(self.observations) + + @property + def is_empty(self) -> bool: + """Whether the panel holds no observations. + + Returns: + True when there are zero observations. + """ + return not self.observations + + def entities(self) -> tuple[str, ...]: + """Return the distinct entity ids present, sorted. + + Returns: + Tuple of entity ids; empty for an empty panel. + """ + return tuple(sorted({o.entity_id for o in self.observations})) + + def metrics(self) -> tuple[str, ...]: + """Return the distinct metric labels present, sorted. + + Returns: + Tuple of canonical metric labels; empty for an empty panel. + """ + return tuple(sorted({o.metric for o in self.observations})) + + +def _resolve_panel_long_columns( + header: Sequence[str], + columns: Mapping[str, str] | None, + path: Path, +) -> dict[str, str]: + """Map canonical panel fields onto a long-layout file's column headers. + + Mirrors ``_resolve_columns``'s matching strategy (case-insensitive alias + lookup via ``_canonical``, with explicit overrides taking precedence) for + the panel's own field set. ``_resolve_columns`` itself is hard-wired to + ``DEFAULT_COLUMN_ALIASES`` and cash-flow's ``date``/``amount``/``kind``/ + ``currency`` fields, so it cannot be reused directly for ``entity`` and + ``metric``. + + Args: + header: Column names as they appear in the file. + columns: Explicit overrides, canonical field -> file column name. + path: File path, used only for error messages. + + Returns: + Mapping of canonical field name to the file's column name, containing + only fields that were actually found. + + Raises: + PanelIngestError: If an explicit override names a column the file + does not contain, or overrides an unknown field. + """ + resolved: dict[str, str] = {} + lookup = {_canonical(col): col for col in header} + + if columns: + for field_name, source_name in columns.items(): + if field_name not in _PANEL_COLUMN_ALIASES: + known = ", ".join(sorted(_PANEL_COLUMN_ALIASES)) + raise PanelIngestError( + f"{path}: unknown column mapping {field_name!r}; " + f"mappable fields are: {known}" + ) + if source_name not in header: + raise PanelIngestError( + f"{path}: mapped column {source_name!r} for field " + f"{field_name!r} is not in the file. Columns present: " + f"{', '.join(header)}" + ) + resolved[field_name] = source_name + + for field_name, aliases in _PANEL_COLUMN_ALIASES.items(): + if field_name in resolved: + continue + for alias in aliases: + if alias in lookup: + resolved[field_name] = lookup[alias] + break + + return resolved + + +def _looks_like_date(text: str, date_format: str | None) -> bool: + """Best-effort probe for whether a header cell parses as a date. + + Used only during layout inference, by reusing ``_parse_date`` itself + rather than a second date-parsing routine. A parse failure here means + "not a date" for inference purposes, not an error to surface. + + Args: + text: Candidate header cell. + date_format: ``strptime`` format to try, or ``None`` for ISO-8601. + + Returns: + True if the text parses as a date under the given format. + """ + try: + _parse_date(text, date_format, Path(""), 0) + return True + except CashFlowIngestError: + return False + + +def _infer_panel_layout(header: Sequence[str], date_format: str | None, path: Path) -> str: + """Infer a panel file's layout from its header, or raise if ambiguous. + + A header naming entity/date/metric/value columns is read as ``"long"``. + Otherwise, a header whose first column is date-like and whose remaining + columns are not all dates is read as ``"wide_dates_rows"``; a header + whose first column is entity-like and whose remaining columns are all + parseable dates is read as ``"wide_entities_rows"``. A header naming a + ``metric`` column is never read as wide, since a wide table has nowhere + to put a metric column. Any header that does not unambiguously match one + shape raises rather than guessing, since silently picking the wrong + layout mislabels every value with the wrong entity or date. + + Args: + header: Column names as they appear in the file. + date_format: ``strptime`` format used to probe header cells for + ``wide_entities_rows``, or ``None`` to require ISO-8601 there. + path: File path, used only for error messages. + + Returns: + One of ``_PANEL_LAYOUTS``. + + Raises: + PanelIngestError: If the header matches none of the recognised + shapes. + """ + date_aliases = {_canonical(a) for a in DEFAULT_COLUMN_ALIASES["date"]} + entity_aliases = {_canonical(a) for a in _PANEL_ENTITY_ALIASES} + metric_aliases = {_canonical(a) for a in _PANEL_METRIC_ALIASES} + value_aliases = {_canonical(a) for a in DEFAULT_COLUMN_ALIASES["amount"]} + + lookup = {_canonical(col) for col in header} + has_entity = bool(lookup & entity_aliases) + has_date = bool(lookup & date_aliases) + has_metric = bool(lookup & metric_aliases) + has_value = bool(lookup & value_aliases) + if has_entity and has_date and has_metric and has_value: + return "long" + + first = _canonical(header[0]) + rest = list(header[1:]) + first_is_date_like = first in date_aliases + first_is_entity_like = first in entity_aliases + rest_are_dates = bool(rest) and all(_looks_like_date(col, date_format) for col in rest) + + if first_is_date_like and rest and not rest_are_dates and not has_metric: + return "wide_dates_rows" + if first_is_entity_like and rest and rest_are_dates and not has_metric: + return "wide_entities_rows" + + raise PanelIngestError( + f"{path}: could not infer a panel layout from the header " + f"({', '.join(header)}); pass layout='long', layout='wide_dates_rows', " + "or layout='wide_entities_rows' explicitly" + ) + + +def _parse_panel_amount( + raw: str, path: Path, row_number: int, decimal_separator: str | None +) -> float: + """Parse a panel value cell, reusing ``_parse_amount``. + + Args: + raw: Raw cell text. + path: File path, used only for error messages. + row_number: 1-based data row number, used only for error messages. + decimal_separator: Declared decimal separator, or ``None`` to infer. + + Returns: + The parsed float. + + Raises: + PanelIngestError: If the cell is blank, not numeric, or ambiguously + grouped -- normalizes ``_parse_amount``'s ``CashFlowIngestError`` + so every error ``load_panel`` raises shares one type. + """ + try: + return _parse_amount(raw, path, row_number, decimal_separator) + except CashFlowIngestError as exc: + raise PanelIngestError(str(exc)) from exc + + +def _parse_panel_date( + raw: str, date_format: str | None, path: Path, row_number: int +) -> date: + """Parse a panel date cell, reusing ``_parse_date``. + + Args: + raw: Raw cell text. + date_format: ``strptime`` format, or ``None`` to require ISO-8601. + path: File path, used only for error messages. + row_number: 1-based data row number, used only for error messages. + + Returns: + The parsed ``datetime.date``. + + Raises: + PanelIngestError: If the cell is blank or does not parse -- + normalizes ``_parse_date``'s ``CashFlowIngestError`` so every + error ``load_panel`` raises shares one type. + """ + try: + return _parse_date(raw, date_format, path, row_number) + except CashFlowIngestError as exc: + raise PanelIngestError(str(exc)) from exc + + +def _load_panel_long( + path: Path, + header: list[str], + rows: list[dict[str, str]], + *, + columns: Mapping[str, str] | None, + entity: str | None, + metric: str | None, + currency: str | None, + unit: str | None, + date_format: str | None, + decimal_separator: str | None, + metadata_columns: Iterable[str] | None, +) -> "EntityPanel": + """Parse a long-layout (entity, date, metric, value) panel file. + + Args: + path: File path, used only for error messages. + header: Column names as they appear in the file. + rows: Row dicts as read by ``_read_rows``. + columns: Explicit column-name overrides; see ``load_panel``. + entity: Fallback entity id for rows with no entity column. + metric: Fallback metric label for rows with no metric column. + currency: Fallback currency for rows with no currency column. + unit: Fallback unit for rows with no unit column. + date_format: ``strptime`` format for the date column. + decimal_separator: Declared decimal separator, or ``None`` to infer. + metadata_columns: Restrict preserved metadata to these columns. + + Returns: + An ``EntityPanel``. + + Raises: + PanelIngestError: If a required column/argument is missing or a row + fails to parse. + """ + resolved = _resolve_panel_long_columns(header, columns, path) + + for required in ("date", "value"): + if required not in resolved: + raise PanelIngestError( + f"{path}: required column {required!r} not found. Columns " + f"present: {', '.join(header)}. Pass columns={{{required!r}: " + "''}} to map it." + ) + + entity_column = resolved.get("entity") + if entity_column is None and entity is None: + raise PanelIngestError( + f"{path}: no entity column found and no entity=... given. " + f"Columns present: {', '.join(header)}." + ) + + metric_column = resolved.get("metric") + if metric_column is None and metric is None: + raise PanelIngestError( + f"{path}: no metric column found and no metric=... given. " + f"Columns present: {', '.join(header)}." + ) + + currency_column = resolved.get("currency") + if currency_column is None and currency is None: + raise PanelIngestError( + f"{path}: no currency column found and no currency=... given. " + "Currency is required; it is never defaulted, because an " + "unlabelled figure silently corrupts every downstream use." + ) + + unit_column = resolved.get("unit") + if unit_column is None and unit is None: + raise PanelIngestError( + f"{path}: no unit column found and no unit=... given. Unit is " + "required; it is never defaulted, because an unlabelled figure " + "silently corrupts every downstream use." + ) + + mapped_columns = set(resolved.values()) + if metadata_columns is None: + extra_columns = [col for col in header if col not in mapped_columns] + else: + wanted = set(metadata_columns) + missing = wanted - set(header) + if missing: + raise PanelIngestError( + f"{path}: metadata_columns not in file: {', '.join(sorted(missing))}" + ) + extra_columns = [col for col in header if col in wanted] + + observations: list[PanelObservation] = [] + for offset, row in enumerate(rows): + row_number = offset + 1 + if all(not (value or "").strip() for value in row.values()): + continue # trailing blank line + + row_entity = entity + if entity_column is not None: + raw_entity = (row.get(entity_column) or "").strip() + if raw_entity: + row_entity = raw_entity + if not row_entity: + raise PanelIngestError( + f"{path} row {row_number}: entity is blank and no entity=... " + "fallback was given" + ) + + row_metric = metric + if metric_column is not None: + raw_metric = (row.get(metric_column) or "").strip() + if raw_metric: + row_metric = raw_metric + if not row_metric: + raise PanelIngestError( + f"{path} row {row_number}: metric is blank and no metric=... " + "fallback was given" + ) + + row_currency = currency + if currency_column is not None: + raw_currency = (row.get(currency_column) or "").strip() + if raw_currency: + row_currency = raw_currency + if not row_currency: + raise PanelIngestError( + f"{path} row {row_number}: currency is blank and no " + "currency=... fallback was given" + ) + + row_unit = unit + if unit_column is not None: + raw_unit = (row.get(unit_column) or "").strip() + if raw_unit: + row_unit = raw_unit + if not row_unit: + raise PanelIngestError( + f"{path} row {row_number}: unit is blank and no unit=... " + "fallback was given" + ) + + value = _parse_panel_amount( + row.get(resolved["value"], ""), path, row_number, decimal_separator + ) + + metadata: dict[str, Any] = { + col: row[col] for col in extra_columns if (row.get(col) or "").strip() + } + metadata["source_file"] = str(path) + metadata["source_row"] = row_number + + try: + observations.append( + PanelObservation( + entity_id=row_entity, + date=_parse_panel_date( + row.get(resolved["date"], ""), date_format, path, row_number + ), + metric=row_metric, + value=value, + currency=row_currency, + unit=row_unit, + metadata=metadata, + ) + ) + except PanelIngestError: + raise + except ValueError as exc: + raise PanelIngestError(f"{path} row {row_number}: {exc}") from exc + + logger.info("loaded %d panel observations from %s (layout=long)", len(observations), path) + return EntityPanel(observations=tuple(observations)) + + +def _load_panel_wide( + path: Path, + header: list[str], + rows: list[dict[str, str]], + *, + layout: str, + metric: str | None, + currency: str | None, + unit: str | None, + date_format: str | None, + decimal_separator: str | None, +) -> "EntityPanel": + """Parse a wide-layout panel file. + + ``layout="wide_dates_rows"``: the first column is the observation date + and every other column is an entity id. ``layout="wide_entities_rows"``: + the first column is the entity id and every other column header is + itself a date. A blank cell is treated as "no observation for that + entity/date", not as zero or an error -- a wide grid legitimately has + holes, and fabricating a value for one would be worse than omitting it. + + Args: + path: File path, used only for error messages. + header: Column names as they appear in the file. + rows: Row dicts as read by ``_read_rows``. + layout: ``"wide_dates_rows"`` or ``"wide_entities_rows"``. + metric: The single metric label every cell in this file represents. + currency: The currency for every value in this file. + unit: The unit for every value in this file. + date_format: ``strptime`` format for date cells/headers. + decimal_separator: Declared decimal separator, or ``None`` to infer. + + Returns: + An ``EntityPanel``. + + Raises: + PanelIngestError: If ``metric``/``currency``/``unit`` is missing, the + file has fewer than two columns, a column header in + ``wide_entities_rows`` is not a usable date, or a cell fails to + parse. + """ + if metric is None: + raise PanelIngestError( + f"{path}: layout={layout!r} needs metric=... -- a wide table has " + "nowhere to put a metric label; every cell already shares its " + "row's and column's identity" + ) + if currency is None: + raise PanelIngestError( + f"{path}: layout={layout!r} needs currency=... -- a wide table " + "has no currency column, and currency is never defaulted" + ) + if unit is None: + raise PanelIngestError( + f"{path}: layout={layout!r} needs unit=... -- a wide table has " + "no unit column, and unit is never defaulted" + ) + if len(header) < 2: + raise PanelIngestError( + f"{path}: layout={layout!r} needs at least one column besides " + f"the first (row-label) column. Columns present: {', '.join(header)}" + ) + + row_label_column = header[0] + series_columns = list(header[1:]) + + column_dates: dict[str, date] = {} + if layout == "wide_entities_rows": + for column_name in series_columns: + try: + column_dates[column_name] = _parse_panel_date( + column_name, date_format, path, row_number=0 + ) + except PanelIngestError as exc: + raise PanelIngestError( + f"{path}: column header {column_name!r} is not a usable " + f"date ({exc}); wide_entities_rows requires every column " + "after the first to be a date, or pass layout='long' " + "with columns=... instead" + ) from exc + + observations: list[PanelObservation] = [] + for offset, row in enumerate(rows): + row_number = offset + 1 + if all(not (value or "").strip() for value in row.values()): + continue # trailing blank line + + row_label_raw = (row.get(row_label_column) or "").strip() + if not row_label_raw: + raise PanelIngestError(f"{path} row {row_number}: {row_label_column!r} is blank") + + row_date = ( + _parse_panel_date(row_label_raw, date_format, path, row_number) + if layout == "wide_dates_rows" + else None + ) + row_entity = row_label_raw if layout == "wide_entities_rows" else None + + for column_name in series_columns: + raw_value = row.get(column_name, "") + if not (raw_value or "").strip(): + continue # hole in the grid: no observation, not zero + + value = _parse_panel_amount(raw_value, path, row_number, decimal_separator) + entity_id = column_name.strip() if layout == "wide_dates_rows" else row_entity + obs_date = row_date if layout == "wide_dates_rows" else column_dates[column_name] + + metadata = {"source_file": str(path), "source_row": row_number} + try: + observations.append( + PanelObservation( + entity_id=entity_id, + date=obs_date, + metric=metric, + value=value, + currency=currency, + unit=unit, + metadata=metadata, + ) + ) + except PanelIngestError: + raise + except ValueError as exc: + raise PanelIngestError(f"{path} row {row_number}: {exc}") from exc + + logger.info( + "loaded %d panel observations from %s (layout=%s)", len(observations), path, layout + ) + return EntityPanel(observations=tuple(observations)) + + +def load_panel( + path: str | Path, + *, + layout: str | None = None, + columns: Mapping[str, str] | None = None, + entity: str | None = None, + metric: str | None = None, + currency: str | None = None, + unit: str | None = None, + date_format: str | None = None, + decimal_separator: str | None = None, + delimiter: str | None = None, + encoding: str = "utf-8", + metadata_columns: Iterable[str] | None = None, +) -> EntityPanel: + """Load an entity x date x metric panel file into an ``EntityPanel``. + + The ingest module's generic counterpart to ``load_cashflows``, for data + that is not a signed cash movement -- a NAV series, an exposure or + headcount table, any per-entity time series a bar engine cannot + represent. It shares every parsing rule with ``load_cashflows``: dates + must be ISO-8601 or given an explicit ``date_format``, numeric grouping + that is genuinely ambiguous must be resolved with ``decimal_separator``, + and a required field that is missing raises rather than returning + ``None`` or a silently empty result. + + Three layouts are supported and never guessed silently: + + * ``"long"``: one row per (entity, date, metric[, currency, unit]) + observation, with a column for each. + * ``"wide_dates_rows"``: one row per date, one column per entity, every + value sharing a single ``metric`` (required via the ``metric`` + argument, since a wide table has nowhere to put a metric label). + * ``"wide_entities_rows"``: one row per entity, one column per date, + values sharing a single ``metric`` in the same way. + + When ``layout`` is omitted, it is inferred from the header -- see + ``_infer_panel_layout``. Layout inference looks only at the raw header, + not at ``columns=...``; a file with exotic column names should pass + ``layout="long"`` explicitly together with ``columns=...`` rather than + rely on inference. + + A wide layout's blank cell means "no observation for that entity/date" + and is skipped, not treated as zero: a grid legitimately has holes. + + Args: + path: Path to the file. ``.csv``, ``.tsv``, and ``.txt`` are + recognised; any other suffix requires an explicit ``delimiter``. + layout: ``"long"``, ``"wide_dates_rows"``, or ``"wide_entities_rows"``. + ``None`` to infer from the header. + columns: Explicit mapping of canonical field to file column name for + the ``"long"`` layout. Canonical fields are ``entity``, ``date``, + ``metric``, ``value``, ``currency``, ``unit``. + entity: Fallback entity id for ``"long"`` rows with no entity column. + Not used by the wide layouts, where each row/column already + names its own entity. + metric: Fallback metric label for ``"long"`` rows with no metric + column; required for both wide layouts. + currency: Currency for every row. Required when the file has no + currency column (``"long"``) or always (wide layouts). + unit: Unit label for every row. Required when the file has no unit + column (``"long"``) or always (wide layouts). + date_format: ``strptime`` format for date cells -- the date column in + ``"long"``/``"wide_dates_rows"``, or the date-valued column + headers in ``"wide_entities_rows"``. Omit only when the dates are + ISO-8601. + decimal_separator: ``"."`` or ``","``; needed only for ambiguous + grouped numbers such as a bare ``"1,234"``. + delimiter: Field delimiter; inferred from the suffix when omitted. + encoding: Text encoding of the file. + metadata_columns: Restrict preserved metadata to these ``"long"`` + columns. By default every unmapped column is preserved. Not + applicable to wide layouts, which have no unmapped columns. + + Returns: + An ``EntityPanel`` ordered by ``(entity_id, date, metric)``. A + ``"long"`` file with a header but no data rows yields an empty panel, + matching ``load_cashflows``. + + Raises: + PanelIngestError: If the file is missing, unreadable, or has no + header row; the layout cannot be determined or is invalid; a + required column or argument is absent; or any cell fails to + parse. + ValueError: If ``decimal_separator`` is neither ``"."`` nor ``","``. + """ + if decimal_separator is not None and decimal_separator not in _DECIMAL_SEPARATORS: + raise ValueError( + f"decimal_separator must be one of {_DECIMAL_SEPARATORS}, " + f"got {decimal_separator!r}" + ) + if layout is not None and layout not in _PANEL_LAYOUTS: + raise PanelIngestError(f"layout must be one of {_PANEL_LAYOUTS}, got {layout!r}") + + path = Path(path).expanduser() + try: + header, rows = _read_rows(path, delimiter, encoding) + except CashFlowIngestError as exc: + raise PanelIngestError(str(exc)) from exc + + resolved_layout = layout or _infer_panel_layout(header, date_format, path) + + if resolved_layout == "long": + return _load_panel_long( + path, + header, + rows, + columns=columns, + entity=entity, + metric=metric, + currency=currency, + unit=unit, + date_format=date_format, + decimal_separator=decimal_separator, + metadata_columns=metadata_columns, + ) + return _load_panel_wide( + path, + header, + rows, + layout=resolved_layout, + metric=metric, + currency=currency, + unit=unit, + date_format=date_format, + decimal_separator=decimal_separator, + ) diff --git a/agent/src/governance/__init__.py b/agent/src/governance/__init__.py new file mode 100644 index 00000000..8bb96600 --- /dev/null +++ b/agent/src/governance/__init__.py @@ -0,0 +1,63 @@ +"""Governance primitives: run manifests + a tamper-evident hash-chained ledger. + +Two independent pieces, both standalone (no dependency on the protected +``src/agent/`` tree, no wall-clock reads): + +- ``manifest``: :func:`build_run_manifest` fingerprints a run's methodology + composition (system prompt hash, injected-skill content hashes, tool + registry, key package versions) into one deterministic ``manifest_hash``, + and :func:`diff_manifests` names exactly what changed between two runs. +- ``ledger``: :func:`append_record` / :func:`verify_chain` implement a + generic hash-chained, fsynced, append-only JSONL ledger. ``src/live/audit.py`` + wires this in as an opt-in ``chain=True`` mode on its existing + ``write_live_action`` — see that module's docstring for the integration. + +Neither module writes into a real run directory or the live-action ledger +by itself; callers supply paths/composition data explicitly. +""" + +from src.governance.ledger import ( + EXPORT_FORMAT, + GENESIS_PREV_HASH, + ChainBreak, + ChainVerificationResult, + LedgerCorruptionError, + append_record, + build_export, + compute_record_hash, + export_chain_to_file, + verify_chain, + verify_export, +) +from src.governance.manifest import ( + ManifestDiff, + RunManifest, + SkillRecord, + ToolRegistrySnapshot, + build_run_manifest, + collect_key_package_versions, + diff_manifests, +) + +__all__ = [ + # ledger + "EXPORT_FORMAT", + "GENESIS_PREV_HASH", + "ChainBreak", + "ChainVerificationResult", + "LedgerCorruptionError", + "append_record", + "build_export", + "compute_record_hash", + "export_chain_to_file", + "verify_chain", + "verify_export", + # manifest + "ManifestDiff", + "RunManifest", + "SkillRecord", + "ToolRegistrySnapshot", + "build_run_manifest", + "collect_key_package_versions", + "diff_manifests", +] diff --git a/agent/src/governance/ledger.py b/agent/src/governance/ledger.py new file mode 100644 index 00000000..e24e0945 --- /dev/null +++ b/agent/src/governance/ledger.py @@ -0,0 +1,738 @@ +"""Hash-chained, fsynced, append-only JSONL ledger. + +A generic, domain-agnostic primitive for governance/compliance records: each +record embeds ``seq`` (1-based position) and ``prev_record_hash`` (the +previous record's own hash, or :data:`GENESIS_PREV_HASH` for the first +record), then commits to both plus its own payload in ``record_hash``. +Because every record's hash folds in its predecessor's hash, editing or +deleting ANY earlier record breaks the hash of every record chained after +it — that propagation, not a per-line checksum, is what makes tampering +detectable (:func:`verify_chain`). + +**Why this exists.** ``src/live/audit.py`` (SPEC.md Consent §5) is the +compliance-grade ledger of every live-money action, but its original write +path was a bare ``open(path, "a").write(line)`` — no fsync (a host crash +could lose the last record while the diagnostic per-run trace, which +already fsyncs every write, would not), no sequencing, no prior-record hash, +and nothing to detect if a line were edited or deleted after the fact. This +module supplies the missing piece; ``src/live/audit.py`` wires it in as an +opt-in ``chain=True`` mode on ``write_live_action`` (see that module for +why it is opt-in and why it writes to a *separate* file rather than +retrofitting the pre-existing plain ``audit.jsonl``). + +**Durability.** :func:`append_record` fsyncs the ledger file after every +write by default (mirroring ``src.agent.trace.TraceWriter.write``), and +fsyncs the parent directory the first time the ledger file is created (same +reasoning as ``TraceWriter.__init__``: the directory ENTRY for a brand new +file is itself not durable until the directory is fsynced). + +**Concurrency.** A POSIX ``flock`` (``msvcrt`` byte-range lock on Windows) +is held across the read-tail + append critical section so two writers +cannot compute conflicting ``seq``/``prev_record_hash`` values for the same +next slot. This is a liveness optimization against accidental forks, not +the source of the tamper-evidence guarantee — :func:`verify_chain` would +still catch a fork or a corrupted tail even if the lock were bypassed. + +**Refuse-to-extend-a-broken-chain.** :func:`append_record` verifies the +ENTIRE existing chain (not just its last line) before appending, and raises +:class:`LedgerCorruptionError` rather than silently building a valid-looking +suffix on top of an already-tampered history. This is O(n) per append, +trading write latency for the strongest available guarantee; the live-action +ledger this backs is low-volume by construction (one record per +order/mandate/breach/halt event, not per market tick — the same volume +argument ``TraceWriter`` makes for "one fsync per record is acceptable"), so +this is judged an acceptable cost rather than a premature optimization +target. A future change under real volume pressure could memoize the tail +state in a small companion file; deliberately not built here without a +demonstrated need for it. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, BinaryIO, Iterable, Iterator, Mapping + +try: # POSIX advisory lock (Linux/macOS). + import fcntl +except ImportError: # pragma: no cover - Windows + fcntl = None # type: ignore[assignment] + +try: # Windows advisory byte-range lock. + import msvcrt +except ImportError: # pragma: no cover - POSIX + msvcrt = None # type: ignore[assignment] + +logger = logging.getLogger(__name__) + +__all__ = [ + "GENESIS_PREV_HASH", + "EXPORT_FORMAT", + "ChainBreak", + "ChainVerificationResult", + "LedgerCorruptionError", + "compute_record_hash", + "append_record", + "verify_chain", + "build_export", + "export_chain_to_file", + "verify_export", + "DEFAULT_ROTATE_BYTES", + "archive_segments", + "rotate_if_needed", + "verify_chain_with_archives", +] + +#: Sentinel ``prev_record_hash`` for the first record in a chain — there is +#: no predecessor to reference. +GENESIS_PREV_HASH = "sha256:genesis" + +#: Reserved top-level keys written by :func:`append_record`. A caller +#: payload must not set any of these itself. +_CHAIN_FIELDS = frozenset({"seq", "prev_record_hash", "record_hash"}) + +#: Export envelope format tag (bump on any breaking export-shape change). +EXPORT_FORMAT = "vibe-trading-governance-ledger-export/v1" + +_fsync_warned = False + + +def _warn_fsync_failure(exc: OSError, target: Any) -> None: + """Log the first fsync failure for this process, then stay quiet. + + Mirrors ``src.agent.trace.TraceWriter._warn_fsync_failure``: on an + unsupported filesystem, durability degrades to flush-only rather than + failing the caller (the ledger write already succeeded at the OS-cache + level; refusing to record a compliance event because fsync is + unsupported would be worse than a degraded durability guarantee). + """ + global _fsync_warned + if _fsync_warned: + return + _fsync_warned = True + logger.warning( + "governance ledger fsync failed on %s (%s); durability degraded to flush-only", + target, + exc, + ) + + +def _canonical_json(obj: Any) -> str: + """Serialize ``obj`` deterministically for hashing (sorted keys, compact).""" + return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True, default=str) + + +def _sha256_hex(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def compute_record_hash(seq: int, prev_record_hash: str, payload: Mapping[str, Any]) -> str: + """Hash one ledger record so it commits to its position and predecessor. + + Args: + seq: This record's 1-based position in the chain. + prev_record_hash: The previous record's own ``record_hash``, or + :data:`GENESIS_PREV_HASH` for the first record. + payload: The record body (everything except the three chain fields). + + Returns: + ``sha256:`` over the canonical JSON of ``{seq, prev_record_hash, + payload}``. + """ + body = _canonical_json( + {"seq": seq, "prev_record_hash": prev_record_hash, "payload": payload} + ) + return f"sha256:{_sha256_hex(body)}" + + +@dataclass(frozen=True) +class ChainBreak: + """Where and why :func:`verify_chain` / :func:`verify_export` stopped trusting the chain. + + Attributes: + index: 0-based line index (within the source lines/records) where + the break was detected. + seq: The record's claimed ``seq`` value, when parseable (``None`` + for a line that failed to parse as JSON at all). + reason: One of ``"malformed_json"``, ``"missing_chain_fields"``, + ``"seq_gap"``, ``"prev_hash_mismatch"``, ``"record_hash_mismatch"``, + ``"export_hash_mismatch"``. + detail: Human-readable detail (expected vs. found). + """ + + index: int + seq: int | None + reason: str + detail: str + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-serializable representation.""" + return {"index": self.index, "seq": self.seq, "reason": self.reason, "detail": self.detail} + + +@dataclass(frozen=True) +class ChainVerificationResult: + """Outcome of walking a chain end to end. + + Attributes: + ok: ``True`` iff every record from the first to the last verified + cleanly. + record_count: Number of records verified before ``first_break`` (or + the total record count when ``ok`` is ``True``). + first_break: The first (earliest) :class:`ChainBreak` encountered, or + ``None`` when ``ok`` is ``True``. + """ + + ok: bool + record_count: int + first_break: ChainBreak | None + + @property + def broken(self) -> bool: + """Convenience negation of :attr:`ok`.""" + return not self.ok + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-serializable representation.""" + return { + "ok": self.ok, + "record_count": self.record_count, + "first_break": None if self.first_break is None else self.first_break.to_dict(), + } + + +class LedgerCorruptionError(RuntimeError): + """Raised when :func:`append_record` finds the existing chain already broken. + + Refusing to extend a corrupted chain — rather than silently building a + valid-looking suffix on top of it — is the point: a chain that "heals" + itself after a break would defeat the entire tamper-evidence guarantee. + """ + + def __init__(self, chain_break: ChainBreak) -> None: + super().__init__( + f"ledger chain already broken at index={chain_break.index} " + f"seq={chain_break.seq} reason={chain_break.reason}: {chain_break.detail}" + ) + self.chain_break = chain_break + + +def _iter_parsed_lines(lines: Iterable[str]) -> Iterator[tuple[int, dict[str, Any] | None, str | None]]: + """Yield ``(index, parsed_record_or_None, parse_error_or_None)`` for non-blank lines. + + Stops (does not yield further lines) after the first unparsable line — + content past a corrupted line is not trustworthy context for chain + verification. + """ + for index, raw in enumerate(lines): + line = raw.strip() + if not line: + continue + try: + yield index, json.loads(line), None + except json.JSONDecodeError as exc: + yield index, None, str(exc) + return + + +def _walk_chain( + record_source: Iterable[tuple[int, dict[str, Any] | None, str | None]], + start_seq: int = 1, + start_prev_hash: str = GENESIS_PREV_HASH, +) -> tuple[ChainVerificationResult, int, str]: + """Walk parsed records once, verifying the chain invariant at each step. + + Args: + record_source: Parsed records to walk. + start_seq: Sequence number the first record must carry. Defaults to 1 + (a chain that starts at genesis). A ledger whose earlier segments + have been sealed by :func:`rotate_if_needed` starts partway through + the chain instead, and passing the continuation here is what stops + a rotated file from looking like a corrupt one. + start_prev_hash: ``prev_record_hash`` the first record must carry. + + Returns: + ``(result, last_valid_seq, last_valid_record_hash)``. When the + source is empty, these are ``start_seq - 1`` and ``start_prev_hash``. + """ + expected_prev = start_prev_hash + expected_seq = start_seq + count = 0 + + def _break(index: int, seq: int | None, reason: str, detail: str) -> ChainVerificationResult: + return ChainVerificationResult( + ok=False, record_count=count, first_break=ChainBreak(index, seq, reason, detail) + ) + + for index, record, parse_error in record_source: + if parse_error is not None: + return _break(index, None, "malformed_json", parse_error), expected_seq - 1, expected_prev + + assert record is not None # parse_error is None iff record is not None + missing = sorted(_CHAIN_FIELDS - record.keys()) + if missing: + return ( + _break(index, record.get("seq"), "missing_chain_fields", f"missing {missing}"), + expected_seq - 1, + expected_prev, + ) + + seq = record["seq"] + prev_hash = record["prev_record_hash"] + record_hash = record["record_hash"] + + if seq != expected_seq: + detail = f"expected seq={expected_seq}, found {seq!r}" + return _break(index, seq, "seq_gap", detail), expected_seq - 1, expected_prev + + if prev_hash != expected_prev: + detail = f"expected prev_record_hash={expected_prev}, found {prev_hash!r}" + return _break(index, seq, "prev_hash_mismatch", detail), expected_seq - 1, expected_prev + + payload = {k: v for k, v in record.items() if k not in _CHAIN_FIELDS} + recomputed = compute_record_hash(seq, prev_hash, payload) + if recomputed != record_hash: + detail = f"stored record_hash={record_hash!r}, recomputed={recomputed!r}" + return _break(index, seq, "record_hash_mismatch", detail), expected_seq - 1, expected_prev + + expected_prev = record_hash + expected_seq += 1 + count += 1 + + return ChainVerificationResult(ok=True, record_count=count, first_break=None), expected_seq - 1, expected_prev + + +def verify_chain(path: Path) -> ChainVerificationResult: + """Walk an on-disk ledger end to end, returning where (if anywhere) it broke. + + Args: + path: Ledger JSONL file path. A missing file verifies as an empty, + intact (``ok=True``, ``record_count=0``) chain. + + Returns: + A :class:`ChainVerificationResult` pinpointing the first break, if any. + """ + if not path.exists(): + result, _, _ = _walk_chain(iter(())) + return result + with path.open("r", encoding="utf-8") as handle: + result, _, _ = _walk_chain(_iter_parsed_lines(handle)) + return result + + +def _lock_exclusive(handle: BinaryIO) -> None: + """Acquire a blocking, cross-process exclusive advisory lock on ``handle``.""" + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + return + if msvcrt is not None: # pragma: no cover - exercised on Windows CI + handle.seek(0, os.SEEK_END) + if handle.tell() == 0: + handle.write(b"\0") + handle.flush() + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + return + # No supported lock backend: proceed unlocked (single-writer assumption). + # verify_chain still catches a corrupted/forked chain either way. + + +def _unlock(handle: BinaryIO) -> None: + """Release the lock acquired by :func:`_lock_exclusive`.""" + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + return + if msvcrt is not None: # pragma: no cover - exercised on Windows CI + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + + +def _fsync_dir(directory: Path) -> None: + """Fsync a directory so a new file's directory entry survives a crash.""" + try: + dir_fd = os.open(directory, os.O_RDONLY) + except OSError: + return + try: + os.fsync(dir_fd) + except OSError as exc: + _warn_fsync_failure(exc, directory) + finally: + os.close(dir_fd) + + +def append_record( + path: Path, + payload: Mapping[str, Any], + *, + fsync: bool = True, + dir_mode: int = 0o700, +) -> dict[str, Any]: + """Append one hash-chained record to a JSONL ledger, durably. + + Reads and verifies the ledger's ENTIRE existing chain under an exclusive + advisory lock (see module docstring for the O(n)-per-append rationale), + computes this record's ``seq``/``prev_record_hash``/``record_hash``, + appends it as one JSON line, flushes, and (by default) fsyncs before + releasing the lock. + + Args: + path: Ledger JSONL file path. Parent directory is created (mode + ``dir_mode``) on first write; the directory entry is itself + fsynced the first time the file is created, unless ``fsync`` is + ``False``. + payload: This record's body. Must NOT contain the reserved keys + ``seq`` / ``prev_record_hash`` / ``record_hash`` — those are + chain metadata computed here, not caller-supplied. + fsync: Fsync the ledger file after this write, AND fsync the parent + directory the first time the file is created (default ``True``, + matching ``TraceWriter``'s per-record durability guarantee — see + module docstring for why the compliance ledger did not have this + before). ``False`` issues no fsync syscalls at all. + dir_mode: Permission bits for the parent directory when created. + + Returns: + The full record as written: ``payload`` merged with ``seq``, + ``prev_record_hash``, ``record_hash``. + + Raises: + ValueError: ``payload`` sets a reserved chain-field key. + LedgerCorruptionError: The ledger's existing chain is already broken + (a prior record was edited or deleted) — the append is refused + rather than silently extended on top of unknown history. + """ + reserved_used = _CHAIN_FIELDS & payload.keys() + if reserved_used: + raise ValueError(f"payload must not set reserved chain fields: {sorted(reserved_used)}") + + path.parent.mkdir(parents=True, exist_ok=True, mode=dir_mode) + created = not path.exists() + + handle = open(path, "a+b") + try: + _lock_exclusive(handle) + try: + handle.seek(0) + existing_text = handle.read().decode("utf-8") + # Sealed segments come first: after rotate_if_needed the active + # file starts partway through the chain, and walking it as if it + # began at genesis would report every rotated ledger as corrupt. + # Continuing from the newest segment is also what makes deleting a + # whole segment leave a detectable seam. + start_seq, start_prev = 1, GENESIS_PREV_HASH + segments = archive_segments(path) + if segments: + tail = _read_raw_records(segments[-1]) + if tail: + start_seq = int(tail[-1]["seq"]) + 1 + start_prev = str(tail[-1]["record_hash"]) + + verification, last_seq, last_hash = _walk_chain( + _iter_parsed_lines(existing_text.splitlines()), + start_seq=start_seq, + start_prev_hash=start_prev, + ) + if not verification.ok: + assert verification.first_break is not None + raise LedgerCorruptionError(verification.first_break) + + seq = last_seq + 1 + record_hash = compute_record_hash(seq, last_hash, payload) + full_record: dict[str, Any] = { + **payload, + "seq": seq, + "prev_record_hash": last_hash, + "record_hash": record_hash, + } + line = (json.dumps(full_record, ensure_ascii=False) + "\n").encode("utf-8") + + handle.seek(0, os.SEEK_END) + handle.write(line) + handle.flush() + if fsync: + try: + os.fsync(handle.fileno()) + except OSError as exc: + _warn_fsync_failure(exc, path) + finally: + _unlock(handle) + finally: + handle.close() + + if created and fsync: + _fsync_dir(path.parent) + + return full_record + + +def _read_raw_records(path: Path) -> list[dict[str, Any]]: + """Read every parseable JSON line from ``path`` in order. + + Used by :func:`build_export` after :func:`verify_chain` has already + established (or reported) chain integrity — this is a plain re-read, not + itself a second verification pass. + """ + if not path.exists(): + return [] + records: list[dict[str, Any]] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped: + continue + try: + records.append(json.loads(stripped)) + except json.JSONDecodeError: + # A malformed line is already surfaced by verify_chain's + # "malformed_json" break; stop collecting records past it so the + # export does not silently paper over the corruption. + break + return records + + +def build_export(path: Path) -> dict[str, Any]: + """Build a self-contained, offline-verifiable export of a chained ledger. + + The export embeds every parsed record plus the verification outcome + computed while building it, and a hash over the envelope itself + (``export_hash``) so a COPY of this dict — with no access to the + original ledger file or path — can be independently re-verified end to + end via :func:`verify_export`. + + Args: + path: Ledger JSONL file path. + + Returns: + A JSON-serializable export dict. + """ + verification = verify_chain(path) + records = _read_raw_records(path) + envelope = {"format": EXPORT_FORMAT, "source_path": str(path), "records": records} + export_hash = f"sha256:{_sha256_hex(_canonical_json(envelope))}" + return { + "format": EXPORT_FORMAT, + "source_path": str(path), + "record_count": len(records), + "records": records, + "verification": verification.to_dict(), + "export_hash": export_hash, + } + + +def export_chain_to_file(path: Path, dest: Path) -> Path: + """Write :func:`build_export`'s output to ``dest`` as pretty JSON. + + Args: + path: Source ledger JSONL file path. + dest: Destination file path for the export. Parent directory is + created if needed. + + Returns: + ``dest``, for chaining. + """ + export = build_export(path) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text( + json.dumps(export, sort_keys=True, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return dest + + +def verify_export(export: Mapping[str, Any] | str | Path) -> ChainVerificationResult: + """Re-verify a chain purely from an export — no source ledger file needed. + + First checks the export envelope itself (``export_hash``, recomputed the + same way :func:`build_export` computed it) so a tampered EXPORT is + caught even if the embedded records happen to still chain cleanly among + themselves; only then walks the embedded records with the same + verification routine :func:`verify_chain` uses. + + Args: + export: A dict from :func:`build_export`, or a JSON string / file + path to one. + + Returns: + A :class:`ChainVerificationResult`. An envelope-level tamper reports + ``first_break.reason == "export_hash_mismatch"`` at ``index=-1``. + """ + if isinstance(export, Path): + data: Mapping[str, Any] = json.loads(export.read_text(encoding="utf-8")) + elif isinstance(export, str): + data = json.loads(export) + else: + data = export + + records = list(data["records"]) + envelope = { + "format": data.get("format", EXPORT_FORMAT), + "source_path": data.get("source_path", ""), + "records": records, + } + expected_export_hash = f"sha256:{_sha256_hex(_canonical_json(envelope))}" + if expected_export_hash != data.get("export_hash"): + detail = f"expected export_hash={expected_export_hash!r}, found {data.get('export_hash')!r}" + return ChainVerificationResult( + ok=False, + record_count=0, + first_break=ChainBreak(index=-1, seq=None, reason="export_hash_mismatch", detail=detail), + ) + + source = ((index, record, None) for index, record in enumerate(records)) + result, _, _ = _walk_chain(source) + return result + + +# --------------------------------------------------------------------------- +# Retention +# --------------------------------------------------------------------------- + +#: Size at which :func:`rotate_if_needed` seals the active ledger. 64 MiB keeps +#: the O(n) verify-before-append (see the module docstring) bounded without +#: producing a directory full of tiny segments. +DEFAULT_ROTATE_BYTES: int = 64 * 1024 * 1024 + +#: Suffix given to a sealed segment: ``audit_chain.0001.jsonl``. +ARCHIVE_SUFFIX_WIDTH: int = 4 + + +def archive_segments(path: Path) -> list[Path]: + """Sealed segments for a ledger, oldest first. + + Args: + path: Active ledger path, e.g. ``.../audit_chain.jsonl``. + + Returns: + Sorted archive paths. Sorting is lexicographic over a zero-padded + counter, which is why the counter is padded. + """ + return sorted(path.parent.glob(f"{path.stem}.[0-9]" + "[0-9]" * (ARCHIVE_SUFFIX_WIDTH - 1) + path.suffix)) + + +def rotate_if_needed( + path: Path, + max_bytes: int = DEFAULT_ROTATE_BYTES, + *, + fsync: bool = True, +) -> Path | None: + """Seal the active ledger into an archive segment when it grows too large. + + **Nothing is ever deleted.** This project is not a regulated entity and has + no statutory retention period to satisfy, and for an audit log the safe + default in the absence of a policy is to keep everything: deleting an audit + record is itself an auditable event, and a retention window invented by a + library would silently destroy evidence nobody chose to destroy. Rotation + here bounds the *active file*, not the history. + + The chain survives rotation. The sealed segment keeps its records unchanged, + and the next record appended to the fresh active file continues from the + sealed segment's final ``record_hash`` -- so a deletion of an entire segment + is as detectable as an edit within one. Use + :func:`verify_chain_with_archives` to check the whole history. + + Args: + path: Active ledger path. + max_bytes: Size at or above which the active file is sealed. + fsync: Whether to fsync the directory after the rename. + + Returns: + The archive path when a rotation happened, else None. + + Raises: + ValueError: If ``max_bytes`` is not positive. + LedgerCorruptionError: If the active chain is broken -- a corrupt + ledger is sealed by nobody; fix or quarantine it deliberately. + """ + if max_bytes <= 0: + raise ValueError(f"max_bytes must be positive, got {max_bytes}") + if not path.exists() or path.stat().st_size < max_bytes: + return None + + result = verify_chain(path) + if not result.ok: + raise LedgerCorruptionError(result.first_break) + + counter = len(archive_segments(path)) + 1 + archive = path.with_name(f"{path.stem}.{counter:0{ARCHIVE_SUFFIX_WIDTH}d}{path.suffix}") + path.rename(archive) + if fsync: + _fsync_dir(path.parent) + return archive + + +def verify_chain_with_archives(path: Path) -> ChainVerificationResult: + """Verify a ledger's whole history, sealed segments included. + + Walks the archives oldest-first and then the active file, checking that each + segment's first record continues from the previous segment's last + ``record_hash``. A segment deleted wholesale therefore shows up as a break + at the seam, which is exactly what plain per-file verification would miss. + + Args: + path: Active ledger path. + + Returns: + A :class:`ChainVerificationResult` over the concatenated history. The + ``index`` of a break is its position in that concatenation, not in any + one file. + """ + records: list[dict[str, Any]] = [] + for segment in [*archive_segments(path), path]: + if not segment.exists(): + continue + for line in segment.read_text(encoding="utf-8").splitlines(): + if line.strip(): + records.append(json.loads(line)) + + if not records: + return ChainVerificationResult(ok=True, record_count=0, first_break=None) + + prev_hash = GENESIS_PREV_HASH + for index, record in enumerate(records): + expected_seq = index + 1 + if record.get("seq") != expected_seq: + return ChainVerificationResult( + ok=False, + record_count=len(records), + first_break=ChainBreak( + index=index, + seq=record.get("seq"), + reason="seq_gap", + detail=f"expected seq={expected_seq}, found {record.get('seq')!r}", + ), + ) + if record.get("prev_record_hash") != prev_hash: + return ChainVerificationResult( + ok=False, + record_count=len(records), + first_break=ChainBreak( + index=index, + seq=record.get("seq"), + reason="prev_hash_mismatch", + detail=( + f"expected prev_record_hash={prev_hash!r}, " + f"found {record.get('prev_record_hash')!r}" + ), + ), + ) + payload = { + k: v for k, v in record.items() + if k not in ("seq", "prev_record_hash", "record_hash") + } + recomputed = compute_record_hash(record["seq"], record["prev_record_hash"], payload) + if recomputed != record.get("record_hash"): + return ChainVerificationResult( + ok=False, + record_count=len(records), + first_break=ChainBreak( + index=index, + seq=record.get("seq"), + reason="record_hash_mismatch", + detail=f"stored {record.get('record_hash')!r}, recomputed {recomputed!r}", + ), + ) + prev_hash = record["record_hash"] + + return ChainVerificationResult(ok=True, record_count=len(records), first_break=None) diff --git a/agent/src/governance/manifest.py b/agent/src/governance/manifest.py new file mode 100644 index 00000000..15138435 --- /dev/null +++ b/agent/src/governance/manifest.py @@ -0,0 +1,566 @@ +"""Run manifest: a content-addressed fingerprint of "what produced this run". + +A :class:`RunManifest` answers "under what methodology was that number +produced" without carrying the methodology's raw, possibly sensitive +content: the system prompt is stored only as a hash, each injected skill is +stored as ``(name, content_hash)`` so a later diff can name exactly which +skill's body changed, the tool registry is stored as its sorted name list +plus a hash, and a curated set of package versions pins the numeric/runtime +environment. Composition in, one deterministic hash out. + +**Hash scope is the composition, not the instance.** ``timestamp`` and +``run_id`` are recorded on the manifest for provenance but are deliberately +EXCLUDED from ``manifest_hash``. Two runs on different days under the exact +same system prompt / skills / tools / package versions must hash identically +— that repeatability is the entire point (it lets you ask "did methodology +change between run A and run B", not just "did time pass between them"). If +timestamp were hashed, the manifest hash would never repeat and +:func:`diff_manifests` would report a spurious change on every single call. + +**No hidden clock.** Per the governance task's hard requirement, this module +never calls ``datetime.now()`` / ``time.time()``. The caller (whoever builds +the manifest at the moment a run starts) supplies ``timestamp`` explicitly. + +**This module is intentionally standalone.** It does not import +``src.agent.*`` (protected tree) or reach into a live tool registry / skills +loader itself — it accepts already-extracted composition data (raw system +prompt text to hash, a name->content mapping for skills, an iterable of tool +names, a package-version mapping) so it can be unit tested and reused +without any run-time wiring. Wiring this into the actual agent loop is +explicitly OUT OF SCOPE for this module (the loop lives under the protected +``src/agent/`` tree) — see "Mount points" below for where a future change +would plug in. + +Mount points for a future integration (NOT implemented here):: + + 1. System prompt: wherever ``src/agent/context.py`` (or + ``src/agent/loop.py``) finalizes the system prompt string for a run, + pass that raw string as ``system_prompt=`` to + :func:`build_run_manifest`. Only the hash is ever persisted. + + 2. Skills actually injected: the same context-building step (and/or + ``src/tools/load_skill_tool.py`` for skills pulled in mid-run via the + ``load_skill`` tool) knows which :class:`src.agent.skills.Skill` + objects were injected this turn. Pass + ``{skill.name: skill.body for skill in injected}`` as ``skills=``. + + 3. Tool registry composition: ``src.tools.build_registry(...)`` (this + one lives under the OPEN ``src/tools/`` tree, not protected) already + exposes ``ToolRegistry.tool_names`` — see + ``agent/src/agent/tools.py:87`` and the project CLAUDE.md line + "measured off ``build_registry().tool_names`` on a default checkout". + Pass that list as ``tool_names=``. + + 4. Package versions: call :func:`collect_key_package_versions` once per + process/run start and pass the result as ``package_versions=``. + + 5. Timestamp: the run/session start time the loop already tracks (e.g. + a ``started_at`` captured when the run begins) should be passed as + ``timestamp=``; this module must never generate it itself. + + 6. Persistence: analogous to how ``src.agent.trace.TraceWriter`` writes + ``trace.jsonl`` durably next to a run, a future change could persist + ``RunManifest.to_json()`` as ``manifest.json`` in the same run/session + directory (fsync'd the same way trace records are). Not implemented + here — that directory-resolution logic lives in the protected + ``src/agent/`` tree. +""" + +from __future__ import annotations + +import hashlib +import json +import platform +from dataclasses import dataclass, field +from typing import Any, Iterable, Mapping, Sequence + +__all__ = [ + "SkillRecord", + "ToolRegistrySnapshot", + "RunManifest", + "ManifestDiff", + "build_run_manifest", + "diff_manifests", + "collect_key_package_versions", +] + +#: Curated distributions whose version can shift a computed number or change +#: available functionality. Rationale per package: +#: - "vibe-trading-ai": pins the code release itself. +#: - "numpy" / "pandas" / "scipy": the numeric kernel every metric, +#: optimizer, and quantlib formula (agent/src/quantlib/) runs on top of — +#: a minor version bump in any of them can silently shift a computed +#: number (e.g. a different BLAS path, a changed default ddof). +#: - "langchain" / "langchain-core": fix the tool-calling / message +#: contract the ReAct loop (agent/src/agent/loop.py) depends on. +#: - "statsmodels" / "arch": back the optional econometrics surface +#: (stationarity/cointegration/GARCH in agent/src/quantlib/) gated +#: behind the "stats" extra; recorded as ``None`` when not installed, +#: which is itself methodology-relevant (those functions were simply +#: unavailable for this run). +_KEY_PACKAGES: tuple[str, ...] = ( + "vibe-trading-ai", + "langchain", + "langchain-core", + "numpy", + "pandas", + "scipy", + "statsmodels", + "arch", +) + + +def _canonical_json(obj: Any) -> str: + """Serialize ``obj`` deterministically for hashing (sorted keys, compact).""" + return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _sha256_hex(text: str) -> str: + """Return the hex SHA-256 digest of ``text``.""" + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _hash_prefixed(text: str) -> str: + """Return a self-describing ``sha256:`` digest of ``text``.""" + return f"sha256:{_sha256_hex(text)}" + + +@dataclass(frozen=True) +class SkillRecord: + """One injected skill's identity + content fingerprint. + + Only the hash is stored, never the body — a skill can carry arbitrary + (possibly long, possibly sensitive-in-context) text, and the manifest is + meant to be small and safe to persist/share. + + Attributes: + name: Skill name (``Skill.name`` in ``src.agent.skills``). + content_hash: ``sha256:`` of the exact body text that was + injected for this run. + """ + + name: str + content_hash: str + + @classmethod + def from_content(cls, name: str, content: str) -> "SkillRecord": + """Build a record by hashing ``content`` now. + + Args: + name: Skill name. + content: Full skill body text as actually injected. + + Returns: + A new :class:`SkillRecord`. + """ + return cls(name=name, content_hash=_hash_prefixed(content)) + + +@dataclass(frozen=True) +class ToolRegistrySnapshot: + """The tool registry's composition for a run: sorted names + one hash. + + Attributes: + tool_names: Deduplicated tool names, sorted for determinism. + tools_hash: ``sha256:`` over the canonical JSON of + ``tool_names`` — changes if any tool is added, removed, or + renamed. + """ + + tool_names: tuple[str, ...] + tools_hash: str + + @classmethod + def from_names(cls, tool_names: Iterable[str]) -> "ToolRegistrySnapshot": + """Build a snapshot from an (unordered, possibly duplicated) iterable. + + Args: + tool_names: Tool names, e.g. ``ToolRegistry.tool_names``. + + Returns: + A new :class:`ToolRegistrySnapshot` with names sorted. + """ + names = tuple(sorted(set(tool_names))) + digest = _hash_prefixed(_canonical_json(list(names))) + return cls(tool_names=names, tools_hash=digest) + + +def _pairs(mapping: Mapping[str, Any] | None) -> tuple[tuple[str, Any], ...]: + """Return ``mapping`` as a sorted, hashable tuple of ``(key, value)`` pairs.""" + return tuple(sorted((mapping or {}).items())) + + +def _compute_manifest_hash( + *, + system_prompt_hash: str, + skills: tuple[SkillRecord, ...], + tools: ToolRegistrySnapshot, + package_versions: tuple[tuple[str, Any], ...], + extra: tuple[tuple[str, Any], ...], +) -> str: + """Hash the run composition (NOT ``run_id``/``timestamp`` — see module docstring). + + ``tools.tools_hash`` alone (rather than re-embedding the full name list) + is sufficient input here: it is itself a deterministic hash of the + sorted tool-name list, so any tool addition/removal/rename already + changes ``tools_hash`` and therefore this hash too. + """ + payload = { + "system_prompt_hash": system_prompt_hash, + "skills": [[record.name, record.content_hash] for record in skills], + "tools_hash": tools.tools_hash, + "package_versions": list(package_versions), + "extra": list(extra), + } + return _hash_prefixed(_canonical_json(payload)) + + +@dataclass(frozen=True) +class RunManifest: + """Immutable fingerprint of one run's methodology composition. + + Attributes: + run_id: Caller-supplied run/session identifier. NOT part of + ``manifest_hash``. + timestamp: Caller-supplied ISO-8601 (or otherwise caller-defined) + timestamp string. NOT part of ``manifest_hash`` — see module + docstring for why. + system_prompt_hash: ``sha256:`` of the exact system prompt text. + skills: Sorted-by-name tuple of :class:`SkillRecord`. + tools: :class:`ToolRegistrySnapshot` of the tool registry. + package_versions: Sorted ``(name, version_or_None)`` pairs. + extra: Sorted ``(key, value)`` pairs for caller-defined composition + dimensions not covered above (e.g. provider/model name). Small, + non-sensitive strings only — unlike the system prompt/skills, + these are stored raw, not hashed, because they are meant to be + directly readable in a diff. + manifest_hash: ``sha256:`` over everything above except + ``run_id``/``timestamp``. Two manifests with identical + composition always produce the identical ``manifest_hash``. + """ + + run_id: str + timestamp: str + system_prompt_hash: str + skills: tuple[SkillRecord, ...] + tools: ToolRegistrySnapshot + package_versions: tuple[tuple[str, Any], ...] + extra: tuple[tuple[str, Any], ...] + manifest_hash: str + + def verify_hash(self) -> bool: + """Recompute ``manifest_hash`` from the other fields and compare. + + Useful after deserializing a persisted manifest (``from_dict`` / + ``from_json`` trust the stored hash at load time; this method is the + integrity check that actually re-derives it). + + Returns: + ``True`` when the stored ``manifest_hash`` matches a fresh + recomputation, ``False`` if the manifest was hand-edited/corrupted. + """ + expected = _compute_manifest_hash( + system_prompt_hash=self.system_prompt_hash, + skills=self.skills, + tools=self.tools, + package_versions=self.package_versions, + extra=self.extra, + ) + return expected == self.manifest_hash + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-serializable representation. + + Returns: + A plain dict; round-trips through :meth:`from_dict`. + """ + return { + "run_id": self.run_id, + "timestamp": self.timestamp, + "system_prompt_hash": self.system_prompt_hash, + "skills": [ + {"name": record.name, "content_hash": record.content_hash} + for record in self.skills + ], + "tools": { + "tool_names": list(self.tools.tool_names), + "tools_hash": self.tools.tools_hash, + }, + "package_versions": dict(self.package_versions), + "extra": dict(self.extra), + "manifest_hash": self.manifest_hash, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "RunManifest": + """Reconstruct a manifest from :meth:`to_dict` output. + + The stored ``manifest_hash`` is trusted as-is (not recomputed) — + call :meth:`verify_hash` afterward to check it was not tampered with. + + Args: + data: A mapping produced by :meth:`to_dict` (or an equivalent + deserialized JSON object). + + Returns: + A new :class:`RunManifest`. + """ + skills = tuple( + sorted( + ( + SkillRecord(name=item["name"], content_hash=item["content_hash"]) + for item in data["skills"] + ), + key=lambda record: record.name, + ) + ) + tools = ToolRegistrySnapshot( + tool_names=tuple(data["tools"]["tool_names"]), + tools_hash=data["tools"]["tools_hash"], + ) + return cls( + run_id=data["run_id"], + timestamp=data["timestamp"], + system_prompt_hash=data["system_prompt_hash"], + skills=skills, + tools=tools, + package_versions=_pairs(data.get("package_versions")), + extra=_pairs(data.get("extra")), + manifest_hash=data["manifest_hash"], + ) + + def to_json(self, *, indent: int | None = None) -> str: + """Serialize to a JSON string. + + Args: + indent: Optional pretty-print indent; compact when ``None``. + + Returns: + JSON text; round-trips through :meth:`from_json`. + """ + return json.dumps(self.to_dict(), sort_keys=True, ensure_ascii=False, indent=indent) + + @classmethod + def from_json(cls, text: str) -> "RunManifest": + """Deserialize from :meth:`to_json` output. + + Args: + text: JSON text. + + Returns: + A new :class:`RunManifest`. + """ + return cls.from_dict(json.loads(text)) + + +def build_run_manifest( + *, + run_id: str, + timestamp: str, + system_prompt: str, + skills: Mapping[str, str] | Sequence[SkillRecord] = (), + tool_names: Iterable[str] = (), + package_versions: Mapping[str, Any] | None = None, + extra: Mapping[str, Any] | None = None, +) -> RunManifest: + """Build a :class:`RunManifest` from raw run composition data. + + Args: + run_id: Run/session identifier (provenance only, not hashed). + timestamp: Caller-supplied timestamp string (provenance only, not + hashed). This function never reads the wall clock itself. + system_prompt: Full system prompt text; only its hash is kept. + skills: Either a ``{name: content}`` mapping (the common case — the + caller already has the injected skills' full bodies) or a + pre-built sequence of :class:`SkillRecord`. + tool_names: Tool registry names, e.g. ``ToolRegistry.tool_names``. + package_versions: ``{package: version_or_None}``; see + :func:`collect_key_package_versions` for a ready-made curated + collector. + extra: Small caller-defined composition dimensions (e.g. + ``{"provider": "openrouter", "model": "deepseek/deepseek-v3.2"}``). + + Returns: + A new :class:`RunManifest` with ``manifest_hash`` computed over the + composition (excluding ``run_id``/``timestamp``). + + Raises: + ValueError: ``run_id``/``timestamp`` is empty, or ``skills`` contains + two records with the same name. + """ + if not run_id or not run_id.strip(): + raise ValueError("run_id must not be empty") + if not timestamp or not timestamp.strip(): + raise ValueError("timestamp must not be empty") + + if isinstance(skills, Mapping): + skill_records = tuple( + sorted( + (SkillRecord.from_content(name, content) for name, content in skills.items()), + key=lambda record: record.name, + ) + ) + else: + skill_records = tuple(sorted(skills, key=lambda record: record.name)) + names = [record.name for record in skill_records] + if len(names) != len(set(names)): + raise ValueError(f"duplicate skill names in manifest input: {names}") + + tools_snapshot = ToolRegistrySnapshot.from_names(tool_names) + pv_pairs = _pairs(package_versions) + extra_pairs = _pairs(extra) + + manifest_hash = _compute_manifest_hash( + system_prompt_hash=_hash_prefixed(system_prompt), + skills=skill_records, + tools=tools_snapshot, + package_versions=pv_pairs, + extra=extra_pairs, + ) + + return RunManifest( + run_id=run_id, + timestamp=timestamp, + system_prompt_hash=_hash_prefixed(system_prompt), + skills=skill_records, + tools=tools_snapshot, + package_versions=pv_pairs, + extra=extra_pairs, + manifest_hash=manifest_hash, + ) + + +@dataclass(frozen=True) +class ManifestDiff: + """What changed between two :class:`RunManifest` instances. + + Attributes: + system_prompt_changed: Whether ``system_prompt_hash`` differs. + skills_added: Skill names present in ``b`` but not ``a``. + skills_removed: Skill names present in ``a`` but not ``b``. + skills_changed: Skill names present in both whose ``content_hash`` + differs — the direct answer to "which skill's content changed". + tools_added: Tool names present in ``b`` but not ``a``. + tools_removed: Tool names present in ``a`` but not ``b``. + package_version_changes: ``(package, old_version, new_version)`` + triples for every package whose recorded version differs + (``None`` on either side means added/removed/not-installed). + extra_changes: Same shape as ``package_version_changes`` for the + ``extra`` composition dimensions. + manifest_hash_changed: Whether the overall ``manifest_hash`` differs. + """ + + system_prompt_changed: bool + skills_added: tuple[str, ...] + skills_removed: tuple[str, ...] + skills_changed: tuple[str, ...] + tools_added: tuple[str, ...] + tools_removed: tuple[str, ...] + package_version_changes: tuple[tuple[str, Any, Any], ...] + extra_changes: tuple[tuple[str, Any, Any], ...] + manifest_hash_changed: bool + + @property + def unchanged(self) -> bool: + """Whether nothing at all differs between the two manifests.""" + return not ( + self.system_prompt_changed + or self.skills_added + or self.skills_removed + or self.skills_changed + or self.tools_added + or self.tools_removed + or self.package_version_changes + or self.extra_changes + ) + + +def diff_manifests(a: RunManifest, b: RunManifest) -> ManifestDiff: + """Diff two manifests, naming exactly what changed. + + Args: + a: The earlier (or "baseline") manifest. + b: The later (or "candidate") manifest. + + Returns: + A :class:`ManifestDiff` describing every difference. + """ + a_skills = {record.name: record.content_hash for record in a.skills} + b_skills = {record.name: record.content_hash for record in b.skills} + skills_added = tuple(sorted(set(b_skills) - set(a_skills))) + skills_removed = tuple(sorted(set(a_skills) - set(b_skills))) + skills_changed = tuple( + sorted( + name + for name in set(a_skills) & set(b_skills) + if a_skills[name] != b_skills[name] + ) + ) + + a_tools = set(a.tools.tool_names) + b_tools = set(b.tools.tool_names) + tools_added = tuple(sorted(b_tools - a_tools)) + tools_removed = tuple(sorted(a_tools - b_tools)) + + a_pv = dict(a.package_versions) + b_pv = dict(b.package_versions) + pv_changes = tuple( + (key, a_pv.get(key), b_pv.get(key)) + for key in sorted(set(a_pv) | set(b_pv)) + if a_pv.get(key) != b_pv.get(key) + ) + + a_extra = dict(a.extra) + b_extra = dict(b.extra) + extra_changes = tuple( + (key, a_extra.get(key), b_extra.get(key)) + for key in sorted(set(a_extra) | set(b_extra)) + if a_extra.get(key) != b_extra.get(key) + ) + + return ManifestDiff( + system_prompt_changed=a.system_prompt_hash != b.system_prompt_hash, + skills_added=skills_added, + skills_removed=skills_removed, + skills_changed=skills_changed, + tools_added=tools_added, + tools_removed=tools_removed, + package_version_changes=pv_changes, + extra_changes=extra_changes, + manifest_hash_changed=a.manifest_hash != b.manifest_hash, + ) + + +def collect_key_package_versions( + extra_packages: Iterable[str] = (), +) -> dict[str, str | None]: + """Look up installed versions for the curated methodology-relevant set. + + See :data:`_KEY_PACKAGES` for the selection and per-package rationale. + Also records the running interpreter's version under the ``"python"`` + key, since numeric/floating-point behavior is not guaranteed identical + across CPython versions either. + + This performs filesystem/package-metadata lookups but never reads the + wall clock, so it is safe to call from a hash-composition path. + + Args: + extra_packages: Additional distribution names to look up alongside + the curated list (e.g. a provider SDK selected at runtime, such + as ``langchain-anthropic`` or ``langchain-deepseek``). + + Returns: + ``{distribution_name: version}``, with ``None`` when a distribution + is not installed (itself methodology-relevant: e.g. ``arch`` absent + means GARCH-backed quantlib functions were unavailable this run). + """ + from importlib.metadata import PackageNotFoundError, version + + names = tuple(dict.fromkeys((*_KEY_PACKAGES, *extra_packages))) + result: dict[str, str | None] = {"python": platform.python_version()} + for name in names: + try: + result[name] = version(name) + except PackageNotFoundError: + result[name] = None + return result diff --git a/agent/src/quantlib/__init__.py b/agent/src/quantlib/__init__.py index fa7e1e30..a025b931 100644 --- a/agent/src/quantlib/__init__.py +++ b/agent/src/quantlib/__init__.py @@ -18,6 +18,11 @@ Submodules: credit Altman Z, Merton/KMV, spread analytics timeseries Stationarity, cointegration, GARCH, bootstrap risk VaR/CVaR, drawdown, Monte Carlo, EVT tail fitting + var_backtest Kupiec/Christoffersen coverage tests and the Basel traffic light + multipletesting Deflated Sharpe, CSCV/PBO and BH-FDR search corrections + crossvalidation Purged + embargoed splits for overlapping financial labels + eventstudy Abnormal returns around dated events (CAR/CAAR, Patell, BMP) + factormodel Cross-sectional style exposures, factor returns, style drift attribution Brinson-Fachler allocation/selection/interaction impact Market-impact and slippage models fundmath Private-markets fund maths on the irregular cash-flow spine diff --git a/agent/src/quantlib/crossvalidation.py b/agent/src/quantlib/crossvalidation.py new file mode 100644 index 00000000..9394db07 --- /dev/null +++ b/agent/src/quantlib/crossvalidation.py @@ -0,0 +1,473 @@ +"""Purged and embargoed cross-validation for overlapping financial labels. + +Plain K-fold assumes observations are independent. Financial labels are not: a +label formed at time ``t`` from the next 20 days of returns overlaps the labels +of the following 19 observations. Split such a sample naively and the training +set contains observations whose outcome window covers the test period. The model +is then evaluated on information it already saw, and the out-of-sample score is +not out of sample. + +Two corrections, both from Lopez de Prado's treatment, and they fix different +leaks: + +**Purging** removes from the training set any observation whose label interval +overlaps the test interval. This is the leak above, and it is *symmetric*: an +overlap matters whether the training observation starts before or after the test +window opens. + +**Embargo** additionally removes training observations that begin shortly after +the test set ends. Purging alone does not catch this, because those labels do +not overlap -- they are merely serially correlated with the test period through +autocorrelated features and slow-moving state. The embargo is a blunt instrument +by design; its size is a judgement about how fast the series forgets. + +THE OFF-BY-ONE IS THE WHOLE POINT +--------------------------------- +The single most common implementation bug in this area is a boundary that leaks +exactly one bar: taking the test block as ``[start, end]`` and the training +block as everything outside ``[start, end)``, so the bar at ``end`` sits in both. +:func:`detect_boundary_leakage` exists to make that class of error assertable +rather than reviewable, and every splitter here is checked with it. + +INTERVALS ARE CLOSED ON BOTH ENDS +--------------------------------- +A label spanning ``[t0, t1]`` includes both endpoints. Two labels touching at a +single instant therefore *do* overlap. This is the conservative reading and it +is stated here because the alternative silently keeps one bar of contamination +at every boundary. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from dataclasses import dataclass + +import numpy as np +import pandas as pd + +__all__ = [ + "DEFAULT_EMBARGO_FRACTION", + "MIN_FOLDS", + "LeakageReport", + "Split", + "combinatorial_purged_splits", + "detect_boundary_leakage", + "purged_kfold_splits", + "purged_walk_forward_splits", +] + +#: Fraction of the total sample embargoed after each test block. 0.01 is the +#: published default; it is a judgement about serial correlation, not a constant +#: of nature, and a strongly autocorrelated feature set deserves more. +DEFAULT_EMBARGO_FRACTION: float = 0.01 + +#: Fewest folds that leave anything to train on. +MIN_FOLDS: int = 2 + + +@dataclass(frozen=True) +class Split: + """One train/test partition of the sample. + + Attributes: + train: Positional indices of the training observations, ascending. + test: Positional indices of the test observations, ascending. + purged: How many observations were removed from training for overlapping + the test interval. + embargoed: How many were removed for falling inside the embargo window. + test_bounds: ``(first, last)`` positional index of the test block. For a + combinatorial split with several blocks this is the outer hull. + """ + + train: np.ndarray + test: np.ndarray + purged: int + embargoed: int + test_bounds: tuple[int, int] + + +@dataclass(frozen=True) +class LeakageReport: + """Result of auditing a split for contamination. + + Attributes: + overlapping: Positional indices of training observations whose label + interval overlaps the test interval. Non-empty means purging failed. + shared: Indices present in both the training and the test set. Non-empty + means an off-by-one at the boundary. + embargo_violations: Training indices that start inside the embargo + window after the test block. + clean: True when all three are empty. + """ + + overlapping: np.ndarray + shared: np.ndarray + embargo_violations: np.ndarray + clean: bool + + +def _as_label_spans( + label_end_times: pd.Series | Sequence[int] | np.ndarray, + n_samples: int | None = None, +) -> np.ndarray: + """Normalise label end times into positional spans. + + Args: + label_end_times: Either a pandas Series whose index is the label start + time and whose values are the label end time, or a positional array + where element ``i`` is the last positional index observation ``i``'s + label depends on. + n_samples: Expected number of samples, checked when supplied. + + Returns: + Integer array ``ends`` where ``ends[i]`` is the last positional index + that observation ``i``'s label covers. Always at least ``i``. + + Raises: + ValueError: If the input is empty, not 1-D, holds a non-finite value, or + declares a label ending before it starts. + """ + if isinstance(label_end_times, pd.Series): + if label_end_times.empty: + raise ValueError("label_end_times is empty") + starts = label_end_times.index + ends = label_end_times.to_numpy() + # searchsorted on the start index converts label end *times* into label + # end *positions*; 'left' then clipping keeps a label that ends between + # two observations attached to the earlier one. + positions = np.searchsorted(starts, ends, side="left") + positions = np.clip(positions, np.arange(len(starts)), len(starts) - 1) + span_ends = positions.astype(int) + else: + span_ends = np.asarray(label_end_times, dtype=float) + if span_ends.ndim != 1: + raise ValueError(f"label_end_times must be 1-D, got shape {span_ends.shape}") + if span_ends.size == 0: + raise ValueError("label_end_times is empty") + if not np.isfinite(span_ends).all(): + raise ValueError("label_end_times holds a non-finite value") + span_ends = span_ends.astype(int) + if (span_ends < np.arange(span_ends.size)).any(): + raise ValueError( + "a label cannot end before the observation it belongs to starts" + ) + + if n_samples is not None and span_ends.size != n_samples: + raise ValueError( + f"label_end_times has {span_ends.size} entries but the sample has {n_samples}" + ) + return span_ends + + +def _apply_purge_and_embargo( + label_ends: np.ndarray, + test_mask: np.ndarray, + embargo_size: int, +) -> tuple[np.ndarray, int, int]: + """Build a training mask that is purged of overlap and embargoed after. + + Args: + label_ends: Last positional index each observation's label covers. + test_mask: Boolean mask of the test observations. + embargo_size: Observations embargoed immediately after each test block. + + Returns: + Tuple of ``(train_indices, purged_count, embargoed_count)``. + """ + n = label_ends.size + starts = np.arange(n) + test_positions = np.flatnonzero(test_mask) + first_test, last_test = int(test_positions[0]), int(test_positions[-1]) + + # The test set's own footprint runs from its first observation to the last + # index any of its labels reaches -- not merely to its last row. + test_span_end = int(max(last_test, label_ends[test_positions].max())) + + candidate = ~test_mask + # Closed intervals on both ends: [start_i, end_i] and [first_test, test_span_end] + # overlap when start_i <= test_span_end and end_i >= first_test. + overlaps = (starts <= test_span_end) & (label_ends >= first_test) + purged_mask = candidate & overlaps + candidate_after_purge = candidate & ~overlaps + + if embargo_size > 0: + embargo_end = min(test_span_end + embargo_size, n - 1) + in_embargo = (starts > test_span_end) & (starts <= embargo_end) + else: + in_embargo = np.zeros(n, dtype=bool) + + embargo_mask = candidate_after_purge & in_embargo + train_mask = candidate_after_purge & ~in_embargo + + return ( + np.flatnonzero(train_mask), + int(purged_mask.sum()), + int(embargo_mask.sum()), + ) + + +def purged_kfold_splits( + n_samples: int, + label_end_times: pd.Series | Sequence[int] | np.ndarray | None = None, + n_folds: int = 5, + embargo_fraction: float = DEFAULT_EMBARGO_FRACTION, +) -> Iterator[Split]: + """K contiguous test blocks, each with training purged and embargoed. + + Folds are contiguous in time rather than shuffled: shuffling destroys the + very ordering purging exists to respect. + + Args: + n_samples: Number of observations. + label_end_times: Where each label's outcome window ends. When None, each + label is assumed to resolve within its own bar, so purging removes + only the boundary and the embargo does the remaining work. + n_folds: Number of folds, at least :data:`MIN_FOLDS`. + embargo_fraction: Fraction of the sample embargoed after each test block. + + Yields: + One :class:`Split` per fold, in chronological order of the test block. + + Raises: + ValueError: If ``n_folds`` is below :data:`MIN_FOLDS`, exceeds the + sample size, if ``embargo_fraction`` is negative or at least 1, or + if ``label_end_times`` does not match the sample. + """ + if n_folds < MIN_FOLDS: + raise ValueError(f"n_folds must be at least {MIN_FOLDS}, got {n_folds}") + if n_samples < n_folds: + raise ValueError(f"{n_samples} samples cannot make {n_folds} folds") + if not 0.0 <= embargo_fraction < 1.0: + raise ValueError( + f"embargo_fraction must be in [0, 1), got {embargo_fraction}" + ) + + if label_end_times is None: + label_ends = np.arange(n_samples) + else: + label_ends = _as_label_spans(label_end_times, n_samples) + + embargo_size = int(round(n_samples * embargo_fraction)) + boundaries = np.linspace(0, n_samples, n_folds + 1).astype(int) + + for fold in range(n_folds): + start, stop = int(boundaries[fold]), int(boundaries[fold + 1]) + if stop <= start: + continue + test_mask = np.zeros(n_samples, dtype=bool) + test_mask[start:stop] = True + + train, purged, embargoed = _apply_purge_and_embargo( + label_ends, test_mask, embargo_size + ) + yield Split( + train=train, + test=np.arange(start, stop), + purged=purged, + embargoed=embargoed, + test_bounds=(start, stop - 1), + ) + + +def purged_walk_forward_splits( + n_samples: int, + label_end_times: pd.Series | Sequence[int] | np.ndarray | None = None, + n_folds: int = 5, + embargo_fraction: float = DEFAULT_EMBARGO_FRACTION, + expanding: bool = True, +) -> Iterator[Split]: + """Walk-forward splits that only ever train on the past. + + Purged K-fold trains on data from *after* the test block as well, which is + correct for estimating generalisation but wrong for simulating a strategy + that had to be run in real time. This variant keeps training strictly before + the test block, and still purges the boundary, because the last training + label can easily reach into the test window. + + Args: + n_samples: Number of observations. + label_end_times: Where each label's outcome window ends. + n_folds: Number of test blocks; the first block of the sample is used + only for training. + embargo_fraction: Retained for signature symmetry with + :func:`purged_kfold_splits`. An embargo *after* the test block is + vacuous here because nothing after the test block is ever trained + on, so this only affects the reported ``embargoed`` count. + expanding: True for an expanding training window (all history so far), + False for a rolling window the size of one fold. + + Yields: + One :class:`Split` per test block, chronologically. A block whose + training set would be empty is skipped. + + Raises: + ValueError: Same conditions as :func:`purged_kfold_splits`. + """ + if n_folds < MIN_FOLDS: + raise ValueError(f"n_folds must be at least {MIN_FOLDS}, got {n_folds}") + if n_samples < n_folds: + raise ValueError(f"{n_samples} samples cannot make {n_folds} folds") + if not 0.0 <= embargo_fraction < 1.0: + raise ValueError(f"embargo_fraction must be in [0, 1), got {embargo_fraction}") + + if label_end_times is None: + label_ends = np.arange(n_samples) + else: + label_ends = _as_label_spans(label_end_times, n_samples) + + boundaries = np.linspace(0, n_samples, n_folds + 1).astype(int) + + for fold in range(1, n_folds): + start, stop = int(boundaries[fold]), int(boundaries[fold + 1]) + if stop <= start: + continue + + train_start = 0 if expanding else int(boundaries[fold - 1]) + candidate = np.arange(train_start, start) + if candidate.size == 0: + continue + + # Purge: a past label whose outcome window reaches into the test block + # has already seen the test period. + reaches_in = label_ends[candidate] >= start + train = candidate[~reaches_in] + yield Split( + train=train, + test=np.arange(start, stop), + purged=int(reaches_in.sum()), + embargoed=0, + test_bounds=(start, stop - 1), + ) + + +def combinatorial_purged_splits( + n_samples: int, + label_end_times: pd.Series | Sequence[int] | np.ndarray | None = None, + n_groups: int = 6, + n_test_groups: int = 2, + embargo_fraction: float = DEFAULT_EMBARGO_FRACTION, +) -> Iterator[Split]: + """Every combination of ``n_test_groups`` blocks held out at once. + + A single K-fold gives one estimate of generalisation per fold; combinatorial + splits give ``C(n_groups, n_test_groups)`` of them, from which a distribution + of out-of-sample performance can be built rather than a point estimate. This + is the sampler behind CSCV-style overfitting diagnostics. + + Args: + n_samples: Number of observations. + label_end_times: Where each label's outcome window ends. + n_groups: Contiguous blocks the sample is divided into. + n_test_groups: Blocks held out per split. + embargo_fraction: Fraction embargoed after each held-out region. + + Yields: + One :class:`Split` per combination, with ``test`` holding the union of + the held-out blocks. + + Raises: + ValueError: If ``n_test_groups`` is not in ``[1, n_groups - 1]``, if + ``n_groups`` exceeds the sample size, or if ``embargo_fraction`` is + out of range. + """ + from itertools import combinations + + if n_groups > n_samples: + raise ValueError(f"{n_samples} samples cannot make {n_groups} groups") + if not 1 <= n_test_groups < n_groups: + raise ValueError( + f"n_test_groups must be in [1, {n_groups - 1}], got {n_test_groups}" + ) + if not 0.0 <= embargo_fraction < 1.0: + raise ValueError(f"embargo_fraction must be in [0, 1), got {embargo_fraction}") + + if label_end_times is None: + label_ends = np.arange(n_samples) + else: + label_ends = _as_label_spans(label_end_times, n_samples) + + embargo_size = int(round(n_samples * embargo_fraction)) + boundaries = np.linspace(0, n_samples, n_groups + 1).astype(int) + blocks = [ + np.arange(int(boundaries[i]), int(boundaries[i + 1])) for i in range(n_groups) + ] + + for chosen in combinations(range(n_groups), n_test_groups): + test = np.concatenate([blocks[i] for i in chosen]) + if test.size == 0: + continue + test_mask = np.zeros(n_samples, dtype=bool) + test_mask[test] = True + + train, purged, embargoed = _apply_purge_and_embargo( + label_ends, test_mask, embargo_size + ) + yield Split( + train=train, + test=np.sort(test), + purged=purged, + embargoed=embargoed, + test_bounds=(int(test.min()), int(test.max())), + ) + + +def detect_boundary_leakage( + split: Split, + label_end_times: pd.Series | Sequence[int] | np.ndarray | None = None, + n_samples: int | None = None, + embargo_size: int = 0, +) -> LeakageReport: + """Audit a split for the three ways training can see the test period. + + This is the assertion that makes the off-by-one class of bug testable. Point + it at any splitter -- including one from another library -- and it reports + contamination rather than requiring the boundary arithmetic to be reviewed + by eye. + + Args: + split: The split to audit. + label_end_times: Where each label's outcome window ends. When None, each + label is assumed to resolve within its own bar and only the shared + index check is meaningful. + n_samples: Total sample size, needed only when ``label_end_times`` is a + positional array of a different length. + embargo_size: Observations that should have been embargoed after the + test block, for checking the embargo was actually applied. + + Returns: + A :class:`LeakageReport`. + + Raises: + ValueError: If ``label_end_times`` cannot be normalised. + """ + shared = np.intersect1d(split.train, split.test) + + if label_end_times is None: + overlapping = np.array([], dtype=int) + else: + label_ends = _as_label_spans(label_end_times, n_samples) + first_test = int(split.test.min()) + test_span_end = int(max(split.test.max(), label_ends[split.test].max())) + train_starts = split.train + train_ends = label_ends[split.train] + mask = (train_starts <= test_span_end) & (train_ends >= first_test) + overlapping = train_starts[mask] + + if embargo_size > 0: + if label_end_times is None: + span_end = int(split.test.max()) + else: + label_ends = _as_label_spans(label_end_times, n_samples) + span_end = int(max(split.test.max(), label_ends[split.test].max())) + violations = split.train[ + (split.train > span_end) & (split.train <= span_end + embargo_size) + ] + else: + violations = np.array([], dtype=int) + + return LeakageReport( + overlapping=overlapping, + shared=shared, + embargo_violations=violations, + clean=overlapping.size == 0 and shared.size == 0 and violations.size == 0, + ) diff --git a/agent/src/quantlib/eventstudy.py b/agent/src/quantlib/eventstudy.py new file mode 100644 index 00000000..fa0b4ba5 --- /dev/null +++ b/agent/src/quantlib/eventstudy.py @@ -0,0 +1,495 @@ +"""Event study: abnormal returns around a dated corporate or macro event. + +The question this answers is "did the market react to the event, beyond what it +would have done anyway", and the whole method is the *beyond* clause. A raw +return around an earnings date is not evidence of anything: the index moved too. +So a normal-return model is fitted on a window that ends before the event, and +what the model fails to explain during the event window is the abnormal return. + + AR_{i,t} = R_{i,t} - E[R_{i,t}] (abnormal return, one day) + CAR_i = sum over the event window (one event's cumulative reaction) + CAAR = mean CAR across events (the sample's average reaction) + +THE ESTIMATION WINDOW MUST NOT TOUCH THE EVENT WINDOW +----------------------------------------------------- +If the event's own returns are inside the sample the model was fitted on, the +model has partly learned the event, the residual shrinks toward zero, and the +study under-reports its own finding. :data:`DEFAULT_ESTIMATION_GAP` puts rows +between the two windows, and an overlap is an error rather than a warning. + +DAYS ARE ROWS, NOT CALENDAR DAYS +-------------------------------- +Relative day ``-1`` means "the row before the event row" in the supplied return +frame. With daily bars that is the previous trading day, which is what event +studies mean by ``t-1``. A frame with gaps, or with a different bar size, shifts +that meaning; the caller owns the frame's spacing. + +WHY THREE TEST STATISTICS +------------------------- +``t_statistic`` + Plain cross-sectional t-test on the CARs. Assumes the events are independent + and equally variable. Fine for a quick read, wrong when a few volatile names + dominate the sample. + +``patell_z`` + Standardises each daily abnormal return by the standard error the estimation + window implies for it, including the prediction-error term that accounts for + the market return on that day sitting far from its estimation-window mean. + Corrects for cross-sectional differences in volatility, but still assumes + event-window variance equals estimation-window variance. + +``bmp_z`` + Boehmer-Musumeci-Poulsen. Standardises like Patell, then takes the + cross-sectional variance *of the standardised values*. This is the one that + survives event-induced variance -- events routinely raise volatility, which + inflates Patell's rejection rate. When the three disagree, BMP is the one to + report. + +None of the three fixes cross-sectional correlation from events that share a +date; clustered event dates need a calendar-time portfolio instead, and this +module says so via :attr:`EventStudyResult.shared_event_dates` rather than +silently reporting an over-confident z. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import numpy as np +import pandas as pd +from scipy.stats import norm, t as student_t + +__all__ = [ + "DEFAULT_ESTIMATION_GAP", + "DEFAULT_ESTIMATION_WINDOW", + "DEFAULT_EVENT_WINDOW", + "MIN_ESTIMATION_OBSERVATIONS", + "NORMAL_RETURN_MODELS", + "EventOutcome", + "EventStudyResult", + "MarketModelFit", + "estimate_market_model", + "event_study", +] + +#: Rows placed between the end of the estimation window and the start of the +#: event window, so no event-window return can inform the model fitted on it. +DEFAULT_ESTIMATION_GAP: int = 10 + +#: Rows used to fit the normal-return model. +DEFAULT_ESTIMATION_WINDOW: int = 120 + +#: Event window in relative rows, inclusive on both ends. +DEFAULT_EVENT_WINDOW: tuple[int, int] = (-5, 5) + +#: Fewest estimation observations that still leave the residual variance and the +#: Patell correction meaningful. Below this an event is dropped, not fitted. +MIN_ESTIMATION_OBSERVATIONS: int = 30 + +#: Normal-return models understood by :func:`event_study`. +#: +#: ``market`` OLS of asset on market: E[R] = alpha + beta * R_m. +#: ``market_adjusted`` beta fixed at 1, alpha at 0: E[R] = R_m. No estimation +#: window is needed for the mean, but one is still required +#: for the residual variance the tests are scaled by. +#: ``mean_adjusted`` E[R] = mean of the estimation window. Ignores the market. +NORMAL_RETURN_MODELS: tuple[str, ...] = ("market", "market_adjusted", "mean_adjusted") + + +@dataclass(frozen=True) +class MarketModelFit: + """Normal-return model fitted on one asset's estimation window. + + Attributes: + alpha: Intercept. Zero by construction for the non-OLS models. + beta: Market sensitivity. One for ``market_adjusted``, zero for + ``mean_adjusted``. + residual_std: Standard deviation of the estimation-window residuals, + with the model's degrees of freedom removed. + observations: Estimation-window rows actually used. + market_mean: Mean market return over the estimation window, needed by + the Patell prediction-error correction. + market_sum_squares: Sum of squared market deviations over the estimation + window, likewise. + model: Which entry of :data:`NORMAL_RETURN_MODELS` was fitted. + """ + + alpha: float + beta: float + residual_std: float + observations: int + market_mean: float + market_sum_squares: float + model: str + + +@dataclass(frozen=True) +class EventOutcome: + """One event's abnormal returns and cumulative reaction. + + Attributes: + symbol: Asset the event belongs to. + event_date: Index label the event was anchored on. + abnormal_returns: Abnormal return per relative day, indexed by relative + day, ordered from the window's first day to its last. + car: Cumulative abnormal return over the whole event window. + car_std_error: Standard error of ``car`` implied by the estimation + window, including the Patell prediction-error term. + standardised_car: ``car / car_std_error``. This is the quantity BMP + takes a cross-sectional variance of. + fit: The normal-return model behind these numbers. + """ + + symbol: str + event_date: object + abnormal_returns: pd.Series + car: float + car_std_error: float + standardised_car: float + fit: MarketModelFit + + +@dataclass(frozen=True) +class EventStudyResult: + """Aggregate reaction across every event that could be measured. + + Attributes: + event_window: The ``(start, end)`` relative-row window, inclusive. + events: Per-event outcomes, in input order, for events that survived. + dropped: ``(symbol, event_date, reason)`` for each event that could not + be measured. Never silently omitted -- a study over 40 events of + which 18 were dropped is a different study. + aar: Average abnormal return per relative day. + caar: Cumulative average abnormal return per relative day, the running + sum of ``aar`` across the window. + caar_total: ``caar`` at the window's last day, the headline number. + t_statistic: Cross-sectional t-test on the CARs. + t_p_value: Two-sided p-value of ``t_statistic``. + patell_z: Patell standardised-residual z. + patell_p_value: Two-sided p-value of ``patell_z``. + bmp_z: Boehmer-Musumeci-Poulsen standardised cross-sectional z. + bmp_p_value: Two-sided p-value of ``bmp_z``. + shared_event_dates: Event dates carried by more than one event. Non-empty + means the events overlap in calendar time, the independence every + statistic here assumes is violated, and the p-values are optimistic. + """ + + event_window: tuple[int, int] + events: tuple[EventOutcome, ...] + dropped: tuple[tuple[str, object, str], ...] + aar: pd.Series + caar: pd.Series + caar_total: float + t_statistic: float + t_p_value: float + patell_z: float + patell_p_value: float + bmp_z: float + bmp_p_value: float + shared_event_dates: tuple[object, ...] + + +def estimate_market_model( + asset_returns: pd.Series | np.ndarray | Sequence[float], + market_returns: pd.Series | np.ndarray | Sequence[float], + model: str = "market", +) -> MarketModelFit: + """Fit a normal-return model on an estimation window. + + Args: + asset_returns: Asset returns over the estimation window. + market_returns: Market returns over the same window, same length and + same order. + model: One of :data:`NORMAL_RETURN_MODELS`. + + Returns: + A :class:`MarketModelFit`. + + Raises: + ValueError: If ``model`` is unknown, the two series differ in length, + fewer than :data:`MIN_ESTIMATION_OBSERVATIONS` finite pairs survive, + or the market series has no variation at all under the ``market`` + model (beta would be undefined). + """ + if model not in NORMAL_RETURN_MODELS: + raise ValueError( + f"model must be one of {NORMAL_RETURN_MODELS}, got {model!r}" + ) + + asset = np.asarray(asset_returns, dtype=float).ravel() + market = np.asarray(market_returns, dtype=float).ravel() + if asset.size != market.size: + raise ValueError( + f"asset and market must be the same length, got {asset.size} and {market.size}" + ) + + keep = np.isfinite(asset) & np.isfinite(market) + asset, market = asset[keep], market[keep] + n = asset.size + if n < MIN_ESTIMATION_OBSERVATIONS: + raise ValueError( + f"estimation window needs at least {MIN_ESTIMATION_OBSERVATIONS} " + f"finite observations, got {n}" + ) + + market_mean = float(market.mean()) + market_sum_squares = float(np.sum((market - market_mean) ** 2)) + + if model == "market": + if market_sum_squares <= 0.0: + raise ValueError( + "market returns are constant over the estimation window, so beta " + "is not identified; use model='mean_adjusted'" + ) + beta = float(np.sum((market - market_mean) * (asset - asset.mean())) / market_sum_squares) + alpha = float(asset.mean() - beta * market_mean) + residuals = asset - (alpha + beta * market) + dof = n - 2 + elif model == "market_adjusted": + alpha, beta = 0.0, 1.0 + residuals = asset - market + dof = n + else: # mean_adjusted + alpha, beta = float(asset.mean()), 0.0 + residuals = asset - alpha + dof = n - 1 + + residual_std = float(np.sqrt(np.sum(residuals**2) / dof)) if dof > 0 else float("nan") + + return MarketModelFit( + alpha=alpha, + beta=beta, + residual_std=residual_std, + observations=n, + market_mean=market_mean, + market_sum_squares=market_sum_squares, + model=model, + ) + + +def _patell_scale(fit: MarketModelFit, market_window: np.ndarray) -> np.ndarray: + """Per-day standard error of an abnormal return, prediction error included. + + A forecast made far from the estimation window's average market return is + less certain than one made near it, and Patell's correction is exactly that + term. Dropping it understates the standard error and overstates significance. + + Args: + fit: The fitted normal-return model. + market_window: Market returns over the event window. + + Returns: + Array of standard errors, one per event-window day. + """ + if fit.model == "market" and fit.market_sum_squares > 0.0: + prediction_error = ( + 1.0 + + 1.0 / fit.observations + + (market_window - fit.market_mean) ** 2 / fit.market_sum_squares + ) + elif fit.model == "mean_adjusted": + prediction_error = np.full(market_window.shape, 1.0 + 1.0 / fit.observations) + else: # market_adjusted has no estimated parameter to project + prediction_error = np.ones(market_window.shape) + return fit.residual_std * np.sqrt(prediction_error) + + +def event_study( + returns: pd.DataFrame, + market_returns: pd.Series, + events: Sequence[tuple[str, object]], + event_window: tuple[int, int] = DEFAULT_EVENT_WINDOW, + estimation_window: int = DEFAULT_ESTIMATION_WINDOW, + estimation_gap: int = DEFAULT_ESTIMATION_GAP, + model: str = "market", +) -> EventStudyResult: + """Measure the abnormal return around a set of dated events. + + Args: + returns: Simple returns, rows indexed by date, one column per symbol. + market_returns: Market returns on the same index. Extra index labels are + allowed; missing ones are not. + events: ``(symbol, event_date)`` pairs. ``event_date`` must be present in + the frame's index, or the nearest earlier row is used and the event + is reported with the row it snapped to. + event_window: Inclusive ``(start, end)`` in relative rows, e.g. + ``(-5, 5)``. ``start`` may be positive if the window opens after the + event, but must not exceed ``end``. + estimation_window: Rows used to fit the normal-return model. + estimation_gap: Rows left between the estimation window and the event + window so the model cannot see the event. + model: One of :data:`NORMAL_RETURN_MODELS`. + + Returns: + An :class:`EventStudyResult`. Events that cannot be measured -- unknown + symbol, event date before the frame starts, not enough estimation rows, + an all-NaN window -- appear in ``dropped`` with a reason instead of + being silently skipped. + + Raises: + ValueError: If the window bounds are inconsistent, ``estimation_gap`` is + negative, ``model`` is unknown, the market series does not cover the + frame's index, or no event at all could be measured. + """ + start, end = event_window + if start > end: + raise ValueError(f"event_window start must be <= end, got {event_window}") + if estimation_gap < 0: + raise ValueError(f"estimation_gap must be >= 0, got {estimation_gap}") + if estimation_window < MIN_ESTIMATION_OBSERVATIONS: + raise ValueError( + f"estimation_window must be at least {MIN_ESTIMATION_OBSERVATIONS}, " + f"got {estimation_window}" + ) + if model not in NORMAL_RETURN_MODELS: + raise ValueError(f"model must be one of {NORMAL_RETURN_MODELS}, got {model!r}") + if not events: + raise ValueError("events is empty") + + index = returns.index + missing_market = index.difference(market_returns.index) + if len(missing_market): + raise ValueError( + f"market_returns is missing {len(missing_market)} label(s) present in " + "returns; align them before calling" + ) + market_aligned = market_returns.reindex(index) + + relative_days = list(range(start, end + 1)) + window_len = len(relative_days) + + outcomes: list[EventOutcome] = [] + dropped: list[tuple[str, object, str]] = [] + + for symbol, event_date in events: + if symbol not in returns.columns: + dropped.append((symbol, event_date, "symbol not in returns frame")) + continue + + position = index.searchsorted(event_date) + if position >= len(index): + dropped.append((symbol, event_date, "event date is after the frame ends")) + continue + + event_start = position + start + event_end = position + end + estimation_end = position + start - estimation_gap + estimation_start = estimation_end - estimation_window + + if estimation_start < 0: + dropped.append( + (symbol, event_date, "not enough history before the estimation window") + ) + continue + if event_end >= len(index): + dropped.append((symbol, event_date, "event window runs past the frame end")) + continue + + asset_estimation = returns[symbol].iloc[estimation_start:estimation_end] + market_estimation = market_aligned.iloc[estimation_start:estimation_end] + try: + fit = estimate_market_model(asset_estimation, market_estimation, model=model) + except ValueError as exc: + dropped.append((symbol, event_date, str(exc))) + continue + + asset_window = returns[symbol].iloc[event_start : event_end + 1].to_numpy(dtype=float) + market_window = market_aligned.iloc[event_start : event_end + 1].to_numpy(dtype=float) + if not np.isfinite(asset_window).all() or not np.isfinite(market_window).all(): + dropped.append((symbol, event_date, "event window holds a non-finite return")) + continue + + expected = fit.alpha + fit.beta * market_window + abnormal = asset_window - expected + car = float(abnormal.sum()) + + daily_se = _patell_scale(fit, market_window) + car_se = float(np.sqrt(np.sum(daily_se**2))) + standardised = car / car_se if car_se > 0 else float("nan") + + outcomes.append( + EventOutcome( + symbol=symbol, + event_date=index[position], + abnormal_returns=pd.Series(abnormal, index=relative_days, name=symbol), + car=car, + car_std_error=car_se, + standardised_car=standardised, + fit=fit, + ) + ) + + if not outcomes: + raise ValueError( + "no event could be measured; reasons: " + + "; ".join(f"{s}@{d}: {r}" for s, d, r in dropped) + ) + + n_events = len(outcomes) + ar_matrix = np.vstack([o.abnormal_returns.to_numpy() for o in outcomes]) + aar = pd.Series(ar_matrix.mean(axis=0), index=relative_days, name="aar") + caar = pd.Series(np.cumsum(aar.to_numpy()), index=relative_days, name="caar") + + cars = np.array([o.car for o in outcomes]) + if n_events > 1: + car_sd = float(cars.std(ddof=1)) + t_stat = float(cars.mean() / (car_sd / np.sqrt(n_events))) if car_sd > 0 else float("nan") + t_p = float(2 * student_t.sf(abs(t_stat), df=n_events - 1)) if np.isfinite(t_stat) else float("nan") + else: + t_stat, t_p = float("nan"), float("nan") + + standardised = np.array([o.standardised_car for o in outcomes]) + finite_scar = standardised[np.isfinite(standardised)] + + # Patell: the standardised CARs are unit-variance under the null, up to the + # t-distribution correction for having estimated the residual variance. + if finite_scar.size: + corrections = np.array( + [ + (o.fit.observations - 2) / (o.fit.observations - 4) + if o.fit.observations > 4 + else np.nan + for o in outcomes + if np.isfinite(o.standardised_car) + ] + ) + variance = float(np.nansum(corrections)) + patell_z = float(finite_scar.sum() / np.sqrt(variance)) if variance > 0 else float("nan") + else: + patell_z = float("nan") + patell_p = float(2 * norm.sf(abs(patell_z))) if np.isfinite(patell_z) else float("nan") + + # BMP: cross-sectional variance OF the standardised values, which is what + # makes it robust to the event itself having raised volatility. + if finite_scar.size > 1: + scar_sd = float(finite_scar.std(ddof=1)) + bmp_z = ( + float(finite_scar.mean() / (scar_sd / np.sqrt(finite_scar.size))) + if scar_sd > 0 + else float("nan") + ) + else: + bmp_z = float("nan") + bmp_p = float(2 * norm.sf(abs(bmp_z))) if np.isfinite(bmp_z) else float("nan") + + seen: dict[object, int] = {} + for outcome in outcomes: + seen[outcome.event_date] = seen.get(outcome.event_date, 0) + 1 + shared = tuple(sorted((d for d, c in seen.items() if c > 1), key=str)) + + return EventStudyResult( + event_window=(start, end), + events=tuple(outcomes), + dropped=tuple(dropped), + aar=aar, + caar=caar, + caar_total=float(caar.iloc[-1]), + t_statistic=t_stat, + t_p_value=t_p, + patell_z=patell_z, + patell_p_value=patell_p, + bmp_z=bmp_z, + bmp_p_value=bmp_p, + shared_event_dates=shared, + ) diff --git a/agent/src/quantlib/factormodel.py b/agent/src/quantlib/factormodel.py new file mode 100644 index 00000000..66b11d1b --- /dev/null +++ b/agent/src/quantlib/factormodel.py @@ -0,0 +1,515 @@ +"""Cross-sectional style factor model: exposures, factor returns, drift. + +This is the machinery behind "what is this portfolio actually betting on" -- +whether a fund's outperformance came from a persistent tilt toward small, cheap, +high-momentum names, or from something the tilts do not explain. + +IT IS NOT BARRA +--------------- +Barra is a licensed commercial product from MSCI: their factor definitions, +their estimation universe, their covariance matrix. None of that is here and +none of it can be reproduced from public data. What is here is a +*Barra-style* cross-sectional model built from definitions stated in +:data:`STYLE_FACTOR_DEFINITIONS`, which are public-domain academic +constructions. Report the output as a style exposure from a named model. Never +present a number from this module as a Barra exposure -- they will not agree, +because they are not measuring the same thing. + +THE STANDARDISATION IS THE MODEL +-------------------------------- +An exposure is a cross-sectional z-score, so "value = 1.2" means *1.2 standard +deviations cheaper than the universe on this date*, not any absolute cheapness. +Three details make that number mean what it claims: + +1. **Winsorise before standardising.** One delisting-bound name with a book/price + of 40 will otherwise move the mean and inflate the standard deviation enough + to compress every other name toward zero. +2. **Cap-weighted mean, equal-weighted standard deviation.** The mean is + cap-weighted so that the *market portfolio* has zero exposure by construction + -- which is what makes a portfolio's exposure readable as an active tilt away + from the market rather than a level. The standard deviation is equal-weighted + so a handful of megacaps cannot set the scale for the whole cross-section. + This asymmetry is deliberate and is the convention commercial risk models use. +3. **A missing characteristic becomes exposure zero, i.e. the market average**, + and the count of filled cells is reported. Dropping the name instead would + silently change the universe between factors; imputing a nonzero value would + invent a tilt. + +FACTOR RETURNS COME FROM A CROSS-SECTIONAL REGRESSION +----------------------------------------------------- +For each date, regress that date's asset returns on the previous date's +exposures. The coefficients are the factor returns: what one unit of exposure +paid on that day. Weighting by square-root market cap is the standard choice -- +it treats the regression as heteroskedastic in the way small caps actually are, +without letting megacaps dominate as full-cap weighting would. + +The intercept is the market factor. It must be present: dropping it forces every +factor to absorb the market's return, and the "value factor" then reports the +market's direction. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +import numpy as np +import pandas as pd + +__all__ = [ + "DEFAULT_WINSORISE", + "MARKET_FACTOR", + "MIN_CROSS_SECTION", + "STYLE_FACTOR_DEFINITIONS", + "FactorReturnFit", + "StyleDrift", + "standardise_exposures", + "build_style_exposures", + "cross_sectional_factor_returns", + "portfolio_style_exposure", + "style_drift", + "factor_return_attribution", +] + +#: Fraction trimmed from each tail before standardising. +DEFAULT_WINSORISE: float = 0.025 + +#: Name of the regression intercept, which is the market factor. +MARKET_FACTOR: str = "market" + +#: Fewest assets a cross-section needs before a z-score or a regression on it +#: means anything. Below this the standard deviation is noise. +MIN_CROSS_SECTION: int = 10 + +#: Style factors and the raw characteristics each is built from. +#: +#: Each entry maps a factor name to ``{characteristic: sign}``. The +#: characteristic names are what :func:`build_style_exposures` expects as +#: columns; ``sign`` is ``+1`` when a larger raw value means a larger exposure +#: and ``-1`` when it means a smaller one. Every characteristic is standardised +#: on its own before the signed average, so factors built from several inputs +#: are not dominated by whichever input happens to have the widest spread. +#: +#: These are public academic definitions, restated here so that a reader can +#: check what a reported exposure actually measured. They are deliberately +#: simple: a defensible published construction beats an unverifiable proprietary +#: one when the alternative is having no model at all. +STYLE_FACTOR_DEFINITIONS: Mapping[str, Mapping[str, int]] = { + # Fama-French SMB direction: small minus big, so smaller cap = larger exposure. + "size": {"log_market_cap": -1}, + # HML: book-to-price and earnings-to-price, both larger when cheaper. + "value": {"book_to_price": 1, "earnings_to_price": 1}, + "growth": {"sales_growth": 1, "earnings_growth": 1}, + # Jegadeesh-Titman: 12-month return skipping the most recent month, because + # the skipped month carries short-term reversal, not momentum. + "momentum": {"return_12m_ex_1m": 1}, + "quality": {"return_on_equity": 1, "gross_profitability": 1}, + # Low-volatility anomaly: less volatile = larger exposure. + "low_volatility": {"realised_volatility": -1}, + "leverage": {"debt_to_equity": 1}, + # Amihud illiquidity inverted, so larger exposure = more liquid. + "liquidity": {"turnover": 1}, +} + + +@dataclass(frozen=True) +class FactorReturnFit: + """One date's cross-sectional regression of returns on exposures. + + Attributes: + date: Label of the return date. + factor_returns: Estimated return per unit of exposure, indexed by factor + name. Includes :data:`MARKET_FACTOR` as the intercept. + t_statistics: Coefficient t-statistics on the same index. + residuals: Per-asset specific return, indexed by asset. + r_squared: Fraction of the cross-sectional return variance explained. + observations: Assets in the regression. + """ + + date: object + factor_returns: pd.Series + t_statistics: pd.Series + residuals: pd.Series + r_squared: float + observations: int + + +@dataclass(frozen=True) +class StyleDrift: + """How much a portfolio's exposures moved over the observed history. + + Attributes: + mean_exposure: Average exposure per factor across the history. + std_exposure: Standard deviation of exposure per factor -- the drift + measure itself. + first_exposure: Exposure at the first observed date. + last_exposure: Exposure at the last observed date. + total_change: ``last_exposure - first_exposure``. + max_abs_change: Largest absolute period-on-period move per factor. A + fund can end where it started having gone somewhere else entirely, + and this is the column that shows it. + """ + + mean_exposure: pd.Series + std_exposure: pd.Series + first_exposure: pd.Series + last_exposure: pd.Series + total_change: pd.Series + max_abs_change: pd.Series + + +def standardise_exposures( + values: pd.Series, + market_caps: pd.Series | None = None, + winsorise: float = DEFAULT_WINSORISE, +) -> pd.Series: + """Turn a raw characteristic into a cross-sectional exposure. + + Winsorises both tails, then subtracts a cap-weighted mean and divides by an + equal-weighted standard deviation. See the module docstring for why those + two weightings differ. + + Args: + values: Raw characteristic, indexed by asset. NaN entries survive as NaN + and are the caller's to fill (:func:`build_style_exposures` fills + them with zero and counts them). + market_caps: Market capitalisation on the same index, used for the mean. + When None, the mean is equal-weighted. + winsorise: Fraction trimmed from each tail, in ``[0, 0.5)``. + + Returns: + Standardised exposures on the input index. + + Raises: + ValueError: If ``winsorise`` is outside ``[0, 0.5)``, if fewer than + :data:`MIN_CROSS_SECTION` finite values are present, or if + ``market_caps`` does not cover the same index. + """ + if not 0.0 <= winsorise < 0.5: + raise ValueError(f"winsorise must be in [0, 0.5), got {winsorise}") + + series = pd.Series(values, dtype=float) + finite = series.dropna() + if finite.size < MIN_CROSS_SECTION: + raise ValueError( + f"a cross-section needs at least {MIN_CROSS_SECTION} finite values to " + f"standardise, got {finite.size}" + ) + + if winsorise > 0: + lower, upper = finite.quantile(winsorise), finite.quantile(1.0 - winsorise) + clipped = series.clip(lower=lower, upper=upper) + else: + clipped = series + + if market_caps is None: + centre = float(clipped.dropna().mean()) + else: + caps = pd.Series(market_caps, dtype=float) + missing = series.index.difference(caps.index) + if len(missing): + raise ValueError( + f"market_caps is missing {len(missing)} asset(s) present in values" + ) + aligned_caps = caps.reindex(clipped.index) + usable = clipped.notna() & aligned_caps.notna() & (aligned_caps > 0) + if not usable.any(): + raise ValueError("no asset has both a finite value and a positive market cap") + weights = aligned_caps[usable] + centre = float((clipped[usable] * weights).sum() / weights.sum()) + + spread = float(clipped.dropna().std(ddof=1)) + if not np.isfinite(spread) or spread <= 0.0: + raise ValueError( + "the characteristic has no cross-sectional variation, so a z-score " + "would divide by zero" + ) + return (clipped - centre) / spread + + +def build_style_exposures( + characteristics: pd.DataFrame, + market_caps: pd.Series | None = None, + definitions: Mapping[str, Mapping[str, int]] = STYLE_FACTOR_DEFINITIONS, + winsorise: float = DEFAULT_WINSORISE, +) -> tuple[pd.DataFrame, dict[str, int]]: + """Assemble a style exposure matrix from raw characteristics. + + Args: + characteristics: Raw values, rows indexed by asset, one column per + characteristic named in ``definitions``. Columns a definition asks + for but the frame does not carry cause that factor to be skipped, + reported through the returned fill counts. + market_caps: Market capitalisation by asset, for the cap-weighted mean. + definitions: Factor construction map; defaults to + :data:`STYLE_FACTOR_DEFINITIONS`. + winsorise: Fraction trimmed from each tail before standardising. + + Returns: + Tuple of ``(exposures, filled)``. ``exposures`` has one row per asset + and one column per constructible factor. ``filled`` maps each factor to + the number of assets whose exposure was imputed as zero because the + underlying characteristic was missing -- a factor with most of its cells + filled is not a measurement and the caller must be able to see that. + + Raises: + ValueError: If ``characteristics`` is empty, or if no factor at all can + be built from the columns supplied. + """ + if characteristics.empty: + raise ValueError("characteristics frame is empty") + + columns: dict[str, pd.Series] = {} + filled: dict[str, int] = {} + + for factor, recipe in definitions.items(): + available = {c: s for c, s in recipe.items() if c in characteristics.columns} + if not available: + continue + + parts = [] + for characteristic, sign in available.items(): + raw = characteristics[characteristic] + try: + standardised = standardise_exposures( + raw, market_caps=market_caps, winsorise=winsorise + ) + except ValueError: + # A single unusable characteristic must not take the whole factor + # down when the factor has other inputs. + continue + parts.append(standardised * sign) + + if not parts: + continue + + combined = pd.concat(parts, axis=1).mean(axis=1) + filled[factor] = int(combined.isna().sum()) + columns[factor] = combined.fillna(0.0) + + if not columns: + raise ValueError( + "no factor could be built; characteristics carries none of the " + f"columns any definition needs: {sorted(characteristics.columns)}" + ) + return pd.DataFrame(columns), filled + + +def cross_sectional_factor_returns( + returns: pd.Series, + exposures: pd.DataFrame, + market_caps: pd.Series | None = None, + date: object = None, +) -> FactorReturnFit: + """Regress one date's asset returns on their exposures. + + The coefficients are that date's factor returns. Weighting is by square-root + market cap when caps are supplied, which is the standard choice: it respects + that small-cap residuals are noisier without letting megacaps set the fit. + + Args: + returns: Asset returns for the date being explained, indexed by asset. + exposures: Exposure matrix from the *previous* date, rows indexed by + asset. Using the same date's exposures would be a look-ahead: the + characteristic and the return would share information. + market_caps: Market capitalisation by asset for the regression weights. + When None the regression is unweighted. + date: Label recorded on the result. Purely informational. + + Returns: + A :class:`FactorReturnFit`. + + Raises: + ValueError: If fewer than :data:`MIN_CROSS_SECTION` assets are common to + the inputs, if the design matrix has more columns than rows, or if + the exposures are perfectly collinear. + """ + common = returns.dropna().index.intersection(exposures.dropna(how="any").index) + if len(common) < MIN_CROSS_SECTION: + raise ValueError( + f"cross-sectional regression needs at least {MIN_CROSS_SECTION} assets " + f"with both a return and full exposures, got {len(common)}" + ) + + y = returns.loc[common].to_numpy(dtype=float) + factor_names = list(exposures.columns) + design = np.column_stack( + [np.ones(len(common)), exposures.loc[common].to_numpy(dtype=float)] + ) + names = [MARKET_FACTOR, *factor_names] + + if design.shape[1] > design.shape[0]: + raise ValueError( + f"{design.shape[1]} regressors but only {design.shape[0]} assets; " + "the fit would be exactly determined and meaningless" + ) + + if market_caps is None: + weights = np.ones(len(common)) + else: + caps = pd.Series(market_caps, dtype=float).reindex(common) + if caps.isna().any() or (caps <= 0).any(): + raise ValueError( + "market_caps must be positive and defined for every asset in the " + "regression" + ) + weights = np.sqrt(caps.to_numpy(dtype=float)) + + sqrt_w = np.sqrt(weights) + design_w = design * sqrt_w[:, None] + y_w = y * sqrt_w + + rank = np.linalg.matrix_rank(design_w) + if rank < design_w.shape[1]: + raise ValueError( + "the exposure matrix is collinear with the market factor or with " + "itself, so the coefficients are not identified" + ) + + coefficients, *_ = np.linalg.lstsq(design_w, y_w, rcond=None) + fitted = design @ coefficients + residuals = y - fitted + + dof = len(common) - design.shape[1] + weighted_residuals = y_w - design_w @ coefficients + sigma_squared = float(weighted_residuals @ weighted_residuals / dof) if dof > 0 else np.nan + try: + covariance = sigma_squared * np.linalg.inv(design_w.T @ design_w) + standard_errors = np.sqrt(np.diag(covariance)) + except np.linalg.LinAlgError: # pragma: no cover - guarded by the rank check + standard_errors = np.full(design.shape[1], np.nan) + + with np.errstate(divide="ignore", invalid="ignore"): + t_stats = np.where(standard_errors > 0, coefficients / standard_errors, np.nan) + + total_variance = float(np.sum((y - y.mean()) ** 2)) + r_squared = ( + 1.0 - float(residuals @ residuals) / total_variance if total_variance > 0 else np.nan + ) + + return FactorReturnFit( + date=date, + factor_returns=pd.Series(coefficients, index=names, name="factor_return"), + t_statistics=pd.Series(t_stats, index=names, name="t_statistic"), + residuals=pd.Series(residuals, index=common, name="specific_return"), + r_squared=r_squared, + observations=len(common), + ) + + +def portfolio_style_exposure( + holdings: pd.Series, + exposures: pd.DataFrame, + benchmark: pd.Series | None = None, +) -> pd.Series: + """Aggregate asset exposures into a portfolio exposure. + + Args: + holdings: Portfolio weights by asset. Need not sum to 1; the weights are + used as supplied, so a book that is 60% invested reports a 60%-scaled + exposure, which is the honest reading. + exposures: Exposure matrix, rows indexed by asset. + benchmark: Benchmark weights by asset. When supplied, the result is the + ACTIVE exposure (portfolio minus benchmark), which is what an + attribution conversation is about. + + Returns: + Exposure per factor. Assets held but absent from ``exposures`` are + reported through the ``unmatched_weight`` entry rather than dropped, so + a portfolio half of whose weight had no exposure data cannot read as a + clean measurement. + + Raises: + ValueError: If ``holdings`` is empty or ``exposures`` has no columns. + """ + weights = pd.Series(holdings, dtype=float).dropna() + if weights.empty: + raise ValueError("holdings is empty") + if exposures.shape[1] == 0: + raise ValueError("exposures has no factor columns") + + matched = weights.index.intersection(exposures.index) + unmatched_weight = float(weights.drop(matched).abs().sum()) + result = exposures.loc[matched].mul(weights.loc[matched], axis=0).sum() + + if benchmark is not None: + bench = pd.Series(benchmark, dtype=float).dropna() + bench_matched = bench.index.intersection(exposures.index) + unmatched_weight += float(bench.drop(bench_matched).abs().sum()) + result = result - exposures.loc[bench_matched].mul( + bench.loc[bench_matched], axis=0 + ).sum() + + result["unmatched_weight"] = unmatched_weight + return result + + +def style_drift(exposure_history: pd.DataFrame) -> StyleDrift: + """Summarise how a portfolio's exposures moved over time. + + Args: + exposure_history: Rows indexed by date in chronological order, one + column per factor, each cell a portfolio-level exposure from + :func:`portfolio_style_exposure`. + + Returns: + A :class:`StyleDrift`. + + Raises: + ValueError: If fewer than two dates are supplied -- drift is a change, + and one observation cannot express one. + """ + if exposure_history.shape[0] < 2: + raise ValueError( + f"style drift needs at least 2 dates, got {exposure_history.shape[0]}" + ) + + frame = exposure_history.drop(columns=["unmatched_weight"], errors="ignore") + return StyleDrift( + mean_exposure=frame.mean(), + std_exposure=frame.std(ddof=1), + first_exposure=frame.iloc[0], + last_exposure=frame.iloc[-1], + total_change=frame.iloc[-1] - frame.iloc[0], + max_abs_change=frame.diff().abs().max(), + ) + + +def factor_return_attribution( + portfolio_exposures: pd.Series, + factor_returns: pd.Series, + portfolio_return: float, +) -> pd.Series: + """Split a portfolio return into factor contributions plus a specific residual. + + Args: + portfolio_exposures: Portfolio exposure per factor. + factor_returns: Factor return per factor over the same period. + portfolio_return: The realised portfolio return being explained. + + Returns: + Contribution per factor (exposure times factor return), plus a + ``specific`` entry holding the unexplained remainder and a ``total`` + entry equal to ``portfolio_return``. The parts sum to the total by + construction: the residual is defined as what is left, never estimated + separately, so no reconciliation gap can appear. + + Raises: + ValueError: If the two inputs share no factor. + """ + exposures = pd.Series(portfolio_exposures, dtype=float).drop( + labels=["unmatched_weight"], errors="ignore" + ) + returns = pd.Series(factor_returns, dtype=float) + shared = exposures.index.intersection(returns.index) + if shared.empty: + raise ValueError( + "portfolio_exposures and factor_returns share no factor; " + f"exposures={sorted(exposures.index)} returns={sorted(returns.index)}" + ) + + contributions = exposures.loc[shared] * returns.loc[shared] + explained = float(contributions.sum()) + contributions["specific"] = portfolio_return - explained + contributions["total"] = portfolio_return + return contributions diff --git a/agent/src/quantlib/fundmath.py b/agent/src/quantlib/fundmath.py index a3b1b3b0..64597a08 100644 --- a/agent/src/quantlib/fundmath.py +++ b/agent/src/quantlib/fundmath.py @@ -32,10 +32,12 @@ from __future__ import annotations import datetime as _dt import math -from collections.abc import Iterable, Sequence +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass +from types import MappingProxyType import numpy as np +import pandas as pd from scipy.optimize import brentq, newton from src.entities.cashflow import CashFlow, CashFlowSeries @@ -75,6 +77,14 @@ __all__ = [ "preferred_return_amount", "waterfall_split", "european_waterfall", + "PMEPlusResult", + "ks_pme", + "pme_plus", + "direct_alpha", + "AmericanWaterfallResult", + "american_waterfall", + "ClawbackResult", + "gp_clawback", ] #: Kinds treated as capital going into the fund. A parameter everywhere it is @@ -1296,3 +1306,1026 @@ def european_waterfall( carry_rate=carry_rate, catch_up_rate=catch_up_rate, ) + + +# --------------------------------------------------------------------------- +# Public market equivalent (PME) +# --------------------------------------------------------------------------- +# +# All three functions below answer the same question -- "did this fund beat a +# public index?" -- from the same two inputs (a CashFlowSeries and a public +# index level series), but they are not interchangeable and answer it in +# different units: +# +# ks_pme a multiple. >1 means the fund beat the index; the number +# itself is not a rate and cannot be annualized. +# pme_plus an IRR, but one that is only meaningful *relative to the +# fund's own realised IRR* -- it is the rate an index-tracking +# portfolio would have posted had it been forced to end at the +# fund's actual NAV. Comparing it to another fund's PME+ IRR +# is not valid; comparing it to *this fund's own* xirr() is. +# direct_alpha an annualized excess return, directly interpretable on its +# own: positive means the fund beat the index by that many +# points a year, negative means it lagged. This is the only +# one of the three that is a standalone number. +# +# All three require the public index to have a level on every date they touch +# and refuse to forward-fill a gap, because a cash flow landing inside a +# missing stretch would otherwise be discounted against a silently wrong +# price. Terminal NAV is handled the same way fund_multiples() already +# handles it -- via residual_value(), i.e. only the single latest valuation +# mark counts, per the existing VALUATION_POLICIES convention. direct_alpha is +# the one exception: it hands every flow to xirr() unchanged and lets the +# caller's own valuations= policy decide, exactly like npv()/xirr() already +# do, so it is the most flexible of the three but also the one that needs the +# index to cover every mark in the series, not only the terminal one. + + +def _index_levels_by_date(index_levels: pd.Series) -> dict[_dt.date, float]: + """Build a date -> level lookup table from a public index series. + + Args: + index_levels: Public-market index levels, a ``pandas.Series`` whose + index is date-like (``DatetimeIndex``, or labels accepted by + :func:`~src.entities.models.normalize_date`) and whose values are + the index's level on each date (a price or a total-return index + value, not a return). + + Returns: + Mapping from ``datetime.date`` to the index level on that date. + + Raises: + TypeError: If ``index_levels`` is not a ``pandas.Series``. + ValueError: If it is empty, a level is not finite and positive, or two + entries normalize to the same date (an ambiguous lookup, refused + rather than guessed at by taking the first or last). + """ + if not isinstance(index_levels, pd.Series): + raise TypeError( + "index_levels must be a pandas Series of index levels indexed by " + f"date, got {type(index_levels).__name__}" + ) + if index_levels.empty: + raise ValueError( + "index_levels is empty; a public market equivalent needs a " + "benchmark to compare against" + ) + lookup: dict[_dt.date, float] = {} + for raw_date, raw_level in index_levels.items(): + day = normalize_date(raw_date, field_name="index_levels date") + if day in lookup: + raise ValueError( + f"index_levels has more than one entry for {day}; resolve the " + "duplicate before calling, rather than have this function " + "guess which one is authoritative" + ) + level = float(raw_level) + if not math.isfinite(level) or level <= 0.0: + raise ValueError( + f"index level on {day} is {raw_level!r}; index levels must be " + "finite and positive to serve as a growth-factor denominator" + ) + lookup[day] = level + return lookup + + +def _index_level_at( + lookup: Mapping[_dt.date, float], day: _dt.date, *, flow_description: str +) -> float: + """Look up one date in an index lookup table, or fail loudly. + + Args: + lookup: Table built by :func:`_index_levels_by_date`. + day: Date to look up. + flow_description: Human-readable description of what needed this + date, quoted in the error message. + + Returns: + The index level on ``day``. + + Raises: + ValueError: If ``day`` has no entry. Forward-filling across the gap + is deliberately not attempted: a flow that lands inside a missing + stretch would then be discounted against a price that was never + actually observed on that date. + """ + try: + return lookup[day] + except KeyError as exc: + raise ValueError( + f"index_levels has no entry for {day} (needed for {flow_description}); " + "forward-filling across the gap is not done automatically, because " + "it would price a flow that lands inside a missing stretch against " + "a level that was never actually observed on that date. Supply an " + "index level for every cash-flow date this calculation touches." + ) from exc + + +def _terminal_mark(series: CashFlowSeries) -> CashFlow | None: + """The most recent valuation record in a series, if any. + + Selects the same record :func:`residual_value` would report the amount + of, but also returns the record itself so its date is available -- which + :func:`residual_value` has no reason to expose, but the PME functions + need in order to look up an index level for it. + + Args: + series: The cash flows. + + Returns: + The latest ``CashFlow`` with ``is_valuation`` True, date-ordered same + as the series; ``None`` if the series carries no valuation record. + """ + marks = [flow for flow in series if flow.is_valuation] + return marks[-1] if marks else None + + +def _default_as_of(series: CashFlowSeries) -> _dt.date: + """Default measurement date for a PME calculation. + + Uses the terminal valuation mark's own date when the series carries one, + because that mark is already "as of" some date, and growing it further + with the index would assume the market kept the index's pace past the + point somebody actually priced the fund. Falls back to the latest flow + date when there is no mark at all -- the same rule + :func:`preferred_return_amount` already uses for its own ``as_of=None``. + + Args: + series: The cash flows. + + Returns: + The date to measure at when the caller does not name one explicitly. + + Raises: + ValueError: If the series is empty. + """ + if series.is_empty: + raise ValueError("as_of is required for an empty series") + mark = _terminal_mark(series) + return mark.date if mark is not None else max(series.dates()) + + +def ks_pme( + series: CashFlowSeries, + index_levels: pd.Series, + *, + as_of: _dt.date | _dt.datetime | str | None = None, + contribution_kinds: Iterable[str] = CONTRIBUTION_KINDS, + distribution_kinds: Iterable[str] = DISTRIBUTION_KINDS, +) -> float: + """Kaplan-Schoar public market equivalent. + + Every contribution and distribution is grown to a single common date + (``as_of``) using the *public index's* realised growth between the flow's + own date and ``as_of`` -- i.e. each flow is asking "what would this dollar + be worth today if it had instead been invested in the index on this + date". Terminal NAV, via :func:`residual_value` (only the latest mark + counts, matching how :func:`fund_multiples` already treats it), is added + to the distributions side unchanged in kind but likewise grown from its + own mark date to ``as_of``:: + + KS-PME = (FV(distributions) + FV(NAV)) / FV(contributions) + + A result above 1 means the fund returned more than the index would have + over the same capital deployment schedule; below 1 means it returned + less. KS-PME is a multiple, not a rate -- it says nothing about *how + fast* the outperformance happened, which is what :func:`direct_alpha` + is for. + + Args: + series: The fund's cash flows. + index_levels: Public index levels. Must have an entry for every + contribution date, every distribution date, the terminal + valuation's date (if any), and ``as_of`` itself. + as_of: Common date to grow every flow to. ``None`` uses + :func:`_default_as_of` (the terminal mark's date, or the latest + flow date). + contribution_kinds: Kind labels counted as capital in. + distribution_kinds: Kind labels counted as capital out. + + Returns: + The KS-PME multiple. + + Raises: + TypeError: If ``series`` is not a ``CashFlowSeries``, or + ``index_levels`` is not a ``pandas.Series``. + ValueError: If ``index_levels`` is empty or has a non-positive level, + if it lacks an entry for a date this calculation needs, if no + contributions were found (or their future value is zero -- KS-PME + has nothing to divide by), or if a contribution/distribution's + sign contradicts the holder-perspective convention. + """ + _require_series(series) + lookup = _index_levels_by_date(index_levels) + measurement = ( + normalize_date(as_of, field_name="as_of") + if as_of is not None + else _default_as_of(series) + ) + index_as_of = _index_level_at(lookup, measurement, flow_description="as_of") + + contributions = tuple(series.filter(kind=contribution_kinds)) + fv_contributions = 0.0 + for flow in contributions: + if flow.amount > 0.0: + raise ValueError( + f"contribution on {flow.date} has a positive amount " + f"{flow.amount!r}; under the holder-perspective sign " + "convention capital going into a fund is negative" + ) + index_then = _index_level_at( + lookup, flow.date, flow_description=f"contribution on {flow.date}" + ) + fv_contributions += (-flow.amount) * (index_as_of / index_then) + if fv_contributions <= 0.0: + raise ValueError( + "no contributions were found (or their future value at as_of is " + "zero); KS-PME is undefined with nothing paid in" + ) + + distributions = tuple(series.filter(kind=distribution_kinds)) + fv_distributions = 0.0 + for flow in distributions: + if flow.amount < 0.0: + raise ValueError( + f"distribution on {flow.date} has a negative amount " + f"{flow.amount!r}; under the holder-perspective sign " + "convention capital coming out of a fund is positive" + ) + index_then = _index_level_at( + lookup, flow.date, flow_description=f"distribution on {flow.date}" + ) + fv_distributions += flow.amount * (index_as_of / index_then) + + nav_amount = residual_value(series) + fv_nav = 0.0 + if nav_amount > 0.0: + terminal_mark = _terminal_mark(series) + assert terminal_mark is not None # residual_value > 0 implies a mark exists + index_then = _index_level_at( + lookup, terminal_mark.date, flow_description="terminal valuation" + ) + fv_nav = nav_amount * (index_as_of / index_then) + + return (fv_distributions + fv_nav) / fv_contributions + + +@dataclass(frozen=True) +class PMEPlusResult: + """Outcome of a Kaplan-Schoar PME+ (Rouvinez) benchmark. + + Attributes: + scaling_factor: The multiplier applied to every actual distribution + so that an index-tracking portfolio -- built from the fund's + actual, unscaled contributions and these scaled distributions -- + ends at exactly the fund's actual terminal NAV. Below 1 means the + fund's real distributions were larger than an index-tracking + portfolio would have needed to reach the same ending NAV (the + fund outran the index); above 1 means the opposite. + irr: The money-weighted return of the fund's actual contributions, + the *scaled* distributions, and the actual terminal NAV. This + rate is only meaningful next to the fund's own realised + :func:`xirr` -- a lower PME+ IRR than the fund's own IRR means + the fund beat the index; a higher one means it lagged. It is not + comparable across two different funds. + """ + + scaling_factor: float + irr: float + + +def pme_plus( + series: CashFlowSeries, + index_levels: pd.Series, + *, + as_of: _dt.date | _dt.datetime | str | None = None, + days_per_year: float = DEFAULT_DAYS_PER_YEAR, + guess: float = 0.1, + rate_bounds: tuple[float, float] = DEFAULT_RATE_BOUNDS, + grid_points: int = DEFAULT_GRID_POINTS, + residual_tolerance: float = _DEFAULT_RESIDUAL_TOLERANCE, + contribution_kinds: Iterable[str] = CONTRIBUTION_KINDS, + distribution_kinds: Iterable[str] = DISTRIBUTION_KINDS, +) -> PMEPlusResult: + """Kaplan-Schoar "PME+" (Rouvinez) -- a comparable IRR, not a multiple. + + KS-PME reports a multiple, which cannot be annualized and cannot be + compared to the fund's own IRR. PME+ instead solves for a single scaling + factor ``lambda`` applied to every actual distribution such that an + index-replicating portfolio -- fed the fund's real, unscaled + contributions and the scaled distributions -- ends up with exactly the + fund's real terminal NAV:: + + FV(contributions) = lambda * FV(distributions) + FV(NAV) + lambda = (FV(contributions) - FV(NAV)) / FV(distributions) + + where every FV(...) grows a flow from its own date to ``as_of`` using the + public index, the same growth :func:`ks_pme` uses (and NAV is read via + :func:`residual_value`, so only the latest mark counts). The scaled + distributions, unchanged contributions and unchanged terminal NAV are + then handed to :func:`xirr` to solve a rate directly comparable to the + fund's own realised IRR -- see :class:`PMEPlusResult`. + + Only flows matching ``contribution_kinds`` or ``distribution_kinds`` + participate, plus the terminal valuation mark; a flow of some other kind + (an out-of-band fee, say) is invisible to this calculation exactly as it + already is to :func:`dpi`/:func:`tvpi` unless the caller widens the kind + set. + + Args: + series: The fund's cash flows. + index_levels: Public index levels. Must have an entry for every + contribution date, every distribution date, the terminal + valuation's date (if any), and ``as_of`` itself. + as_of: Common date to grow every flow to. ``None`` uses + :func:`_default_as_of`. + days_per_year: Day basis passed to the final :func:`xirr` call. + guess: Newton fallback starting rate passed to :func:`xirr`. + rate_bounds: Rate window passed to :func:`xirr`. + grid_points: Grid resolution passed to :func:`xirr`. + residual_tolerance: Root tolerance passed to :func:`xirr`. + contribution_kinds: Kind labels counted as capital in. + distribution_kinds: Kind labels counted as capital out. + + Returns: + A :class:`PMEPlusResult` with the scaling factor and the comparable + IRR. + + Raises: + TypeError: If ``series`` is not a ``CashFlowSeries``, or + ``index_levels`` is not a ``pandas.Series``. + ValueError: If ``index_levels`` lacks a needed date, if there are no + contributions or their future value is zero, if there are no + distributions or their future value is zero (the scaling factor + would divide by zero), if a sign contradicts the + holder-perspective convention, or if the resulting scaled series + has no usable IRR (see :func:`xirr`). + """ + _require_series(series) + lookup = _index_levels_by_date(index_levels) + measurement = ( + normalize_date(as_of, field_name="as_of") + if as_of is not None + else _default_as_of(series) + ) + index_as_of = _index_level_at(lookup, measurement, flow_description="as_of") + + contributions = tuple(series.filter(kind=contribution_kinds)) + if not contributions: + raise ValueError( + "no contributions were found; PME+ is undefined with nothing " + "paid in" + ) + fv_contributions = 0.0 + for flow in contributions: + if flow.amount > 0.0: + raise ValueError( + f"contribution on {flow.date} has a positive amount " + f"{flow.amount!r}; under the holder-perspective sign " + "convention capital going into a fund is negative" + ) + index_then = _index_level_at( + lookup, flow.date, flow_description=f"contribution on {flow.date}" + ) + fv_contributions += (-flow.amount) * (index_as_of / index_then) + if fv_contributions <= 0.0: + raise ValueError( + "contributions have zero future value at as_of; PME+ is " + "undefined with nothing paid in" + ) + + distributions = tuple(series.filter(kind=distribution_kinds)) + if not distributions: + raise ValueError( + "no distributions were found; PME+ solves a scaling factor for " + "the distributions, and there is nothing to scale" + ) + fv_distributions = 0.0 + for flow in distributions: + if flow.amount < 0.0: + raise ValueError( + f"distribution on {flow.date} has a negative amount " + f"{flow.amount!r}; under the holder-perspective sign " + "convention capital coming out of a fund is positive" + ) + index_then = _index_level_at( + lookup, flow.date, flow_description=f"distribution on {flow.date}" + ) + fv_distributions += flow.amount * (index_as_of / index_then) + if fv_distributions <= 0.0: + raise ValueError( + "distributions have zero future value at as_of; PME+ cannot " + "solve a scaling factor by dividing by zero" + ) + + nav_amount = residual_value(series) + terminal_mark = _terminal_mark(series) + fv_nav = 0.0 + if nav_amount > 0.0: + assert terminal_mark is not None + index_then = _index_level_at( + lookup, terminal_mark.date, flow_description="terminal valuation" + ) + fv_nav = nav_amount * (index_as_of / index_then) + + scaling_factor = (fv_contributions - fv_nav) / fv_distributions + + scaled_flows: list[CashFlow] = [ + CashFlow( + date=flow.date, + amount=flow.amount, + kind=flow.kind, + currency=flow.currency, + metadata=flow.metadata, + ) + for flow in contributions + ] + scaled_flows.extend( + CashFlow( + date=flow.date, + amount=flow.amount * scaling_factor, + kind=flow.kind, + currency=flow.currency, + metadata=flow.metadata, + ) + for flow in distributions + ) + if terminal_mark is not None: + scaled_flows.append(terminal_mark) + scaled_series = CashFlowSeries( + tuple(scaled_flows), currency=series.currency, pre_translated=series.pre_translated + ) + irr = xirr( + scaled_series, + days_per_year=days_per_year, + guess=guess, + rate_bounds=rate_bounds, + grid_points=grid_points, + residual_tolerance=residual_tolerance, + ) + return PMEPlusResult(scaling_factor=scaling_factor, irr=irr) + + +def direct_alpha( + series: CashFlowSeries, + index_levels: pd.Series, + *, + valuations: str = DEFAULT_VALUATION_POLICY, + days_per_year: float = DEFAULT_DAYS_PER_YEAR, + guess: float = 0.1, + rate_bounds: tuple[float, float] = DEFAULT_RATE_BOUNDS, + grid_points: int = DEFAULT_GRID_POINTS, + residual_tolerance: float = _DEFAULT_RESIDUAL_TOLERANCE, +) -> float: + """Direct Alpha -- an annualized excess return over a public index. + + Every flow, of every kind (contribution, distribution or valuation + mark), is deflated by the public index level on its own date and left at + its own date:: + + CF'_i = CF_i / I(t_i) + + and :func:`xirr` is then run on the deflated series unchanged, valuation + policy and all. Direct Alpha is the rate that solves this. + + Why this is "alpha" and not just a rescaled IRR: write the index level as + ``I(t) = I(t0) * (1+g)**years`` for a constant realised index CAGR ``g`` + between two flow dates. Substituting into the NPV equation the deflated + series satisfies shows the deflator and the discount rate combine + multiplicatively -- solving ``sum CF_i / I(t_i) / (1+alpha)**t_i == 0`` is + algebraically the same as solving ``sum CF_i / (1+alpha)**t_i / (1+g)**t_i + == 0``, i.e. ``sum CF_i / ((1+alpha)(1+g))**t_i == 0``. The rate that + zeroes *that* equation is, by definition, the fund's own plain + :func:`xirr`, call it ``rho``. So ``(1+alpha)(1+g) = (1+rho)``, i.e. + ``alpha = (1+rho)/(1+g) - 1`` -- the geometric excess of the fund's own + realised IRR over the index's realised CAGR. Using actual index *levels* + rather than one flat CAGR is what lets this hold even when the index's + growth rate varies over the fund's life, and it is also why, unlike + :func:`ks_pme` and :func:`pme_plus`, this function takes no ``as_of`` -- + the reference date cancels out of the equation algebraically, so the + result cannot depend on one. + + Positive means the fund beat the index by that many annualized points; + negative means it lagged. Unlike PME+'s IRR, this number needs no + companion fund IRR to be interpreted -- it already is the excess. + + Args: + series: The fund's cash flows. + index_levels: Public index levels. Must have an entry for every flow + in ``series``, including every intermediate valuation mark -- + not only the one ``valuations`` ultimately keeps, because the + deflation happens before the policy is applied. + valuations: One of :data:`VALUATION_POLICIES`, passed through to the + final :func:`xirr` call unchanged. + days_per_year: Day basis passed to :func:`xirr`. + guess: Newton fallback starting rate passed to :func:`xirr`. + rate_bounds: Rate window passed to :func:`xirr`. + grid_points: Grid resolution passed to :func:`xirr`. + residual_tolerance: Root tolerance passed to :func:`xirr`. + + Returns: + The annualized excess return as a decimal (``0.05`` is 5 points of + annual alpha over the index). + + Raises: + TypeError: If ``series`` is not a ``CashFlowSeries``, or + ``index_levels`` is not a ``pandas.Series``. + ValueError: If ``index_levels`` lacks an entry for any flow's date, + or if the deflated series has no usable IRR -- see :func:`xirr` + for the full list (no sign change, too few flows, an unknown + policy, and so on). + """ + _require_series(series) + lookup = _index_levels_by_date(index_levels) + scaled_flows = [] + for flow in series: + index_then = _index_level_at( + lookup, flow.date, flow_description=f"{flow.kind} on {flow.date}" + ) + scaled_flows.append( + CashFlow( + date=flow.date, + amount=flow.amount / index_then, + kind=flow.kind, + currency=flow.currency, + metadata=flow.metadata, + ) + ) + scaled_series = CashFlowSeries( + tuple(scaled_flows), currency=series.currency, pre_translated=series.pre_translated + ) + return xirr( + scaled_series, + valuations=valuations, + days_per_year=days_per_year, + guess=guess, + rate_bounds=rate_bounds, + grid_points=grid_points, + residual_tolerance=residual_tolerance, + ) + + +# --------------------------------------------------------------------------- +# American (deal-by-deal) waterfall + GP clawback +# --------------------------------------------------------------------------- +# +# european_waterfall() above is whole-of-fund: every dollar of contributed +# capital and the whole preferred return must be paid back before the GP sees +# any carry. That structurally protects LPs from the failure mode below, +# which is exactly why a European fund essentially never triggers a +# clawback -- see gp_clawback()'s docstring for the one-line reason it is +# still not literally impossible. +# +# An American (deal-by-deal) waterfall pays carry per investment as it exits, +# without waiting for the rest of the fund. That is what makes "distribute +# early, lose money later" representable: the GP can collect carry on a +# profitable exit in year 2 while a different investment in the same fund +# loses money in year 5, and there is no mechanism inside the deal-by-deal +# structure itself that hands that carry back. A clawback provision is the +# LPA's fix -- a true-up at fund termination that compares carry actually +# received against what a whole-of-fund calculation says the GP earned once +# every deal is counted, and requires the GP to return the difference. + + +def _validate_deals(deals: Mapping[str, CashFlowSeries]) -> Mapping[str, CashFlowSeries]: + """Check a deal-by-deal input before any arithmetic touches it. + + Args: + deals: Deal-id -> that deal's own cash flows. + + Returns: + The same mapping, unchanged. + + Raises: + TypeError: If ``deals`` is not a mapping, or a value is not a + ``CashFlowSeries``. + ValueError: If ``deals`` is empty, or the deals span more than one + currency. + """ + if not isinstance(deals, Mapping): + raise TypeError( + f"deals must be a mapping of deal_id -> CashFlowSeries, got " + f"{type(deals).__name__}" + ) + if not deals: + raise ValueError( + "deals is empty; a deal-by-deal waterfall needs at least one deal" + ) + currencies: set[str] = set() + for deal_id, series in deals.items(): + _require_series(series, name=f"deals[{deal_id!r}]") + if series.currency is not None: + currencies.add(series.currency) + if len(currencies) > 1: + raise ValueError( + f"deals span multiple currencies ({', '.join(sorted(currencies))}); " + "convert them to one reporting currency before pooling -- the same " + "discipline CashFlowSeries itself enforces within a single series" + ) + return deals + + +def _pool_deals(deals: Mapping[str, CashFlowSeries]) -> CashFlowSeries: + """Combine every deal's flows into one whole-of-fund series. + + Args: + deals: Deal-id -> that deal's cash flows. Assumed already validated + by :func:`_validate_deals` (non-empty, one currency). + + Returns: + A single ``CashFlowSeries`` holding every flow from every deal. + """ + all_flows: list[CashFlow] = [] + for series in deals.values(): + all_flows.extend(series) + currency = next(iter(deals.values())).currency + return CashFlowSeries(tuple(all_flows), currency=currency) + + +@dataclass(frozen=True) +class AmericanWaterfallResult: + """Deal-by-deal waterfall outcome, aggregated across every investment. + + Attributes: + deals: Each deal's own :class:`WaterfallResult`, keyed by the same + deal id the caller supplied to :func:`american_waterfall`. + lp_total: Sum of every deal's ``lp_total``. + gp_total: Sum of every deal's ``gp_total`` -- the GP's total carry + actually paid out across every deal, i.e. carry *received*. + distributable: Sum of every deal's ``distributable`` amount. + """ + + deals: Mapping[str, WaterfallResult] + lp_total: float + gp_total: float + distributable: float + + def __post_init__(self) -> None: + """Check that the per-deal results sum to the reported totals. + + Raises: + ValueError: If ``deals`` is empty, or a reported total does not + match the sum of the per-deal results. + """ + if not self.deals: + raise ValueError( + "an American waterfall result must hold at least one deal" + ) + checks = ( + ("lp_total", sum(r.lp_total for r in self.deals.values()), self.lp_total), + ("gp_total", sum(r.gp_total for r in self.deals.values()), self.gp_total), + ( + "distributable", + sum(r.distributable for r in self.deals.values()), + self.distributable, + ), + ) + for label, computed, expected in checks: + if not math.isclose( + computed, expected, rel_tol=_ALLOCATION_TOLERANCE, abs_tol=_ALLOCATION_TOLERANCE + ): + raise ValueError( + f"American waterfall result does not reconcile on {label}: " + f"deals give {computed!r}, expected {expected!r}" + ) + + +def american_waterfall( + deals: Mapping[str, CashFlowSeries], + *, + preferred_rate: float, + carry_rate: float, + catch_up_rate: float = 1.0, + as_of: Mapping[str, _dt.date | _dt.datetime | str] | None = None, + compounding: str = DEFAULT_PREFERRED_COMPOUNDING, + days_per_year: float = DEFAULT_DAYS_PER_YEAR, + include_residual: bool = False, + contribution_kinds: Iterable[str] = CONTRIBUTION_KINDS, + distribution_kinds: Iterable[str] = DISTRIBUTION_KINDS, +) -> AmericanWaterfallResult: + """Run a deal-by-deal (American) waterfall. + + Each deal is its own miniature fund: its own contributed capital, its own + preferred return accrued from its own contribution dates, and its own + return-of-capital / preferred / catch-up / carry-split tiers, computed by + calling :func:`european_waterfall` once per deal with that deal's own + series. Nothing about the tier mechanics is reimplemented here -- a + single-deal call to this function reproduces :func:`european_waterfall` + on that same series exactly. What makes the result "American" is that + carry crystallizes independently on each deal, at that deal's own exit, + with no visibility into how the fund's other deals are doing -- which is + also exactly the property that makes a clawback representable at all; see + :func:`gp_clawback`. + + The GP catch-up tier is unchanged from :func:`waterfall_split`: it lets + the GP catch up *to* its target carry percentage on that deal's own + profit, not an extra bonus on top of it. + + Args: + deals: Deal-id -> that deal's own cash flows. Every deal must share + one currency. + preferred_rate: Annual hurdle rate as a decimal, applied identically + to every deal. + carry_rate: GP share of profit above the hurdle, in ``[0, 1)``, + applied identically to every deal. + catch_up_rate: GP share of each catch-up dollar; ``0.0`` for no + catch-up. + as_of: Per-deal override for the preferred-return measurement date, + as a mapping from deal id to a date. A deal absent from the + mapping (or ``as_of=None`` entirely) uses that deal's own latest + flow date -- the natural exit-date accrual for a deal that has + actually closed. + compounding: One of :data:`PREFERRED_COMPOUNDING`, applied to every + deal. + days_per_year: Day basis for the preferred accrual. + include_residual: Passed through to each deal's + :func:`european_waterfall` call; see that function. + contribution_kinds: Kind labels counted as capital in. + distribution_kinds: Kind labels counted as capital out. + + Returns: + An :class:`AmericanWaterfallResult` aggregating every deal. + + Raises: + TypeError: If ``deals`` is not a mapping, or a value is not a + ``CashFlowSeries``. + ValueError: If ``deals`` is empty, spans more than one currency, or + any deal raises the errors :func:`european_waterfall` already + documents (bad rate, contribution after ``as_of``, sign- + convention violation, and so on). + """ + deals = _validate_deals(deals) + per_deal: dict[str, WaterfallResult] = {} + for deal_id, series in deals.items(): + deal_as_of = as_of.get(deal_id) if isinstance(as_of, Mapping) else None + per_deal[deal_id] = european_waterfall( + series, + preferred_rate=preferred_rate, + carry_rate=carry_rate, + catch_up_rate=catch_up_rate, + as_of=deal_as_of, + compounding=compounding, + days_per_year=days_per_year, + include_residual=include_residual, + contribution_kinds=contribution_kinds, + distribution_kinds=distribution_kinds, + ) + return AmericanWaterfallResult( + deals=MappingProxyType(dict(per_deal)), + lp_total=sum(result.lp_total for result in per_deal.values()), + gp_total=sum(result.gp_total for result in per_deal.values()), + distributable=sum(result.distributable for result in per_deal.values()), + ) + + +def _pooled_entitlement( + deals: Mapping[str, CashFlowSeries], + *, + preferred_rate: float, + carry_rate: float, + catch_up_rate: float, + as_of: _dt.date | _dt.datetime | str | None, + compounding: str, + days_per_year: float, + include_residual: bool, + contribution_kinds: Iterable[str], + distribution_kinds: Iterable[str], +) -> WaterfallResult: + """The whole-of-fund (European) split every deal's cash would have earned. + + This is the entitlement side of a clawback test: pool every deal's + contributions and distributions, accrue one preferred return over the + pooled contribution schedule, and run one :func:`waterfall_split`. + + Residual value is summed *per deal* -- via :func:`residual_value` called + once on each deal's own series -- rather than read off the pooled series + directly. :func:`residual_value` keeps only the single latest valuation + mark in a series, which is correct for one fund's own evolving NAV but + wrong for a pool of several deals' independent marks: reading it off the + pooled series would keep whichever deal happens to carry the latest-dated + mark and silently drop every other still-open deal's NAV. + + Args: + deals: Deal-id -> that deal's cash flows. Assumed already validated. + preferred_rate: Annual hurdle rate as a decimal. + carry_rate: GP share of profit above the hurdle. + catch_up_rate: GP share of each catch-up dollar. + as_of: Measurement date for the pooled preferred accrual. ``None`` + uses the latest date anywhere across every deal. + compounding: One of :data:`PREFERRED_COMPOUNDING`. + days_per_year: Day basis for the preferred accrual. + include_residual: Whether to add each still-open deal's own residual + value to the pooled distributable amount. + contribution_kinds: Kind labels counted as capital in. + distribution_kinds: Kind labels counted as capital out. + + Returns: + The pooled :class:`WaterfallResult`. + """ + pooled = _pool_deals(deals) + contributed = paid_in_capital(pooled, contribution_kinds=contribution_kinds) + distributed = distributed_capital(pooled, distribution_kinds=distribution_kinds) + residual = ( + sum(residual_value(series) for series in deals.values()) + if include_residual + else 0.0 + ) + preferred = preferred_return_amount( + pooled, + rate=preferred_rate, + as_of=as_of, + compounding=compounding, + days_per_year=days_per_year, + contribution_kinds=contribution_kinds, + ) + return waterfall_split( + distributed + residual, + contributed, + preferred, + carry_rate=carry_rate, + catch_up_rate=catch_up_rate, + ) + + +@dataclass(frozen=True) +class ClawbackResult: + """Outcome of a GP clawback test at fund termination. + + Attributes: + carry_received: Carry the GP actually collected -- the sum of every + deal's own realised carry under the deal-by-deal structure + (:func:`american_waterfall`'s ``gp_total``, with + ``include_residual=False`` always, because unrealised NAV is not + carry anyone has been paid). + carry_entitled: Carry the GP is entitled to once every deal's cash + flows are pooled and run through one whole-of-fund waterfall -- + the number a European structure would actually have paid; see + :func:`_pooled_entitlement`. + clawback_amount: ``max(0, carry_received - carry_entitled)``, scaled + down for tax already paid when ``tax_adjusted`` is True. + tax_adjusted: Whether ``clawback_amount`` reflects a net-of-tax cap. + gp_tax_rate: The rate used when ``tax_adjusted`` is True, else + ``None``. + """ + + carry_received: float + carry_entitled: float + clawback_amount: float + tax_adjusted: bool + gp_tax_rate: float | None + + def __post_init__(self) -> None: + """Check the reported figures are internally consistent. + + Raises: + ValueError: If any dollar figure is negative, or if + ``tax_adjusted`` and ``gp_tax_rate`` disagree about whether a + tax adjustment was applied. + """ + for label, value in ( + ("carry_received", self.carry_received), + ("carry_entitled", self.carry_entitled), + ("clawback_amount", self.clawback_amount), + ): + if value < -_ALLOCATION_TOLERANCE: + raise ValueError(f"{label} cannot be negative, got {value!r}") + if self.tax_adjusted and self.gp_tax_rate is None: + raise ValueError("tax_adjusted is True but gp_tax_rate is None") + if not self.tax_adjusted and self.gp_tax_rate is not None: + raise ValueError( + f"gp_tax_rate={self.gp_tax_rate!r} is set but tax_adjusted is False" + ) + + +def gp_clawback( + deals: Mapping[str, CashFlowSeries], + *, + preferred_rate: float, + carry_rate: float, + catch_up_rate: float = 1.0, + termination_date: _dt.date | _dt.datetime | str | None = None, + compounding: str = DEFAULT_PREFERRED_COMPOUNDING, + days_per_year: float = DEFAULT_DAYS_PER_YEAR, + include_unrealized_in_entitlement: bool = False, + contribution_kinds: Iterable[str] = CONTRIBUTION_KINDS, + distribution_kinds: Iterable[str] = DISTRIBUTION_KINDS, + gp_tax_rate: float | None = None, +) -> ClawbackResult: + """Test whether a deal-by-deal GP owes money back at fund termination. + + Compares carry actually received (summed deal by deal, each deal's carry + crystallized at that deal's own exit -- see :func:`american_waterfall`) + against carry the GP is entitled to once every deal is pooled into one + whole-of-fund waterfall (see :func:`_pooled_entitlement`). The GP owes + back the positive difference, if any. + + This is a genuinely different number from zero specifically *because* + the two sides are computed two different ways: "received" is a sum of + independent per-deal waterfalls, each blind to how the fund's other + deals perform, while "entitled" is one waterfall over the pooled total. + A European (whole-of-fund) structure never has this gap in the first + place, because there carry is only ever paid once, off the same pooled + totals used here as "entitled" -- so "received" and "entitled" are the + same computation by construction and a clawback is structurally + (near-)impossible. Running :func:`european_waterfall` once on the pooled + series and comparing its ``gp_total`` to itself demonstrates this + directly; see the test suite. + + Tax treatment of the clawback amount is deliberately not defaulted to a + net-of-tax figure, even though many LPAs contractually cap the GP's + payback obligation at what remains after the tax already paid on the + excess carry. Computing that net figure needs the GP's actual effective + tax rate on the carry, which this module has no way to know -- assuming + one (a US corporate rate, a "typical" GP rate, anything) would be exactly + the kind of invented number this codebase's evidence-first standard + forbids. The default is therefore the gross, pre-tax clawback; passing + ``gp_tax_rate`` opts into the net-of-tax figure the LPA actually specifies + for this fund. + + Args: + deals: Deal-id -> that deal's own cash flows. Every deal must share + one currency. + preferred_rate: Annual hurdle rate as a decimal, applied identically + to every deal and to the pooled entitlement calculation. + carry_rate: GP share of profit above the hurdle, in ``[0, 1)``. + catch_up_rate: GP share of each catch-up dollar; ``0.0`` for no + catch-up. + termination_date: Measurement date for the pooled entitlement's + preferred accrual. ``None`` uses the latest date anywhere across + every deal -- the natural "fund winds up now" default. This does + not affect the "received" side, which always uses each deal's own + historical exit date, because carry already paid is a fact, not a + modelling choice. + compounding: One of :data:`PREFERRED_COMPOUNDING`. + days_per_year: Day basis for the preferred accrual. + include_unrealized_in_entitlement: Whether the pooled entitlement + calculation adds each still-open deal's current NAV to the + distributable amount -- an "if the fund wound up today" stress + test. The "received" side never includes unrealised NAV, because + unpaid carry cannot have been received. + contribution_kinds: Kind labels counted as capital in. + distribution_kinds: Kind labels counted as capital out. + gp_tax_rate: If given, scales ``clawback_amount`` down to the + net-of-tax figure by multiplying the gross clawback by + ``(1 - gp_tax_rate)``. Must be in ``[0, 1)``. ``None`` (the + default) reports the gross, pre-tax clawback -- see above for why + that is the default rather than an assumed tax rate. + + Returns: + A :class:`ClawbackResult`. + + Raises: + TypeError: If ``deals`` is not a mapping, or a value is not a + ``CashFlowSeries``. + ValueError: If ``deals`` is empty or spans more than one currency, if + ``gp_tax_rate`` is outside ``[0, 1)``, or if a deal raises any + error :func:`european_waterfall` already documents. + """ + deals = _validate_deals(deals) + + received = american_waterfall( + deals, + preferred_rate=preferred_rate, + carry_rate=carry_rate, + catch_up_rate=catch_up_rate, + as_of=None, + compounding=compounding, + days_per_year=days_per_year, + include_residual=False, + contribution_kinds=contribution_kinds, + distribution_kinds=distribution_kinds, + ) + carry_received = received.gp_total + + entitled = _pooled_entitlement( + deals, + preferred_rate=preferred_rate, + carry_rate=carry_rate, + catch_up_rate=catch_up_rate, + as_of=termination_date, + compounding=compounding, + days_per_year=days_per_year, + include_residual=include_unrealized_in_entitlement, + contribution_kinds=contribution_kinds, + distribution_kinds=distribution_kinds, + ) + carry_entitled = entitled.gp_total + + gross_clawback = max(0.0, carry_received - carry_entitled) + if gp_tax_rate is None: + return ClawbackResult( + carry_received=carry_received, + carry_entitled=carry_entitled, + clawback_amount=gross_clawback, + tax_adjusted=False, + gp_tax_rate=None, + ) + if not 0.0 <= gp_tax_rate < 1.0: + raise ValueError(f"gp_tax_rate must be in [0, 1), got {gp_tax_rate!r}") + return ClawbackResult( + carry_received=carry_received, + carry_entitled=carry_entitled, + clawback_amount=gross_clawback * (1.0 - gp_tax_rate), + tax_adjusted=True, + gp_tax_rate=gp_tax_rate, + ) diff --git a/agent/src/quantlib/multipletesting.py b/agent/src/quantlib/multipletesting.py new file mode 100644 index 00000000..d76ffed1 --- /dev/null +++ b/agent/src/quantlib/multipletesting.py @@ -0,0 +1,568 @@ +"""Multiple-testing control: how much of a backtest's edge is search luck? + +Run 462 factors over one history, keep the best Sharpe, and you have not +measured an edge -- you have measured the maximum of 462 draws. Under a null +where every factor is worthless, that maximum is comfortably positive, and it +grows with how many you tried. Reporting it without saying how many were tried +is the single most common way a research process fools itself. + +This module supplies the three corrections, in increasing order of what they +demand of the caller: + +``probabilistic_sharpe_ratio`` + The confidence that one strategy's true Sharpe exceeds a benchmark, given + the sample length and the return distribution's skew and kurtosis. No trial + count involved. This is the building block. + +``deflated_sharpe_ratio`` + The same statement, but with the benchmark raised to the Sharpe you would + expect the *best of N trials* to reach by luck alone. Needs the number of + trials and the dispersion of their Sharpes. This is the number to publish + beside any "best factor" claim. + +``probability_of_backtest_overfitting`` + CSCV. Needs the full return matrix of every trial, not just summary + statistics, and answers a different question: across many in-sample / + out-of-sample splits, how often does the in-sample winner land below the + out-of-sample median? A PBO above 0.5 means the selection procedure itself + is anti-predictive. + +``benjamini_hochberg`` + When the question is "which of these N are real" rather than "is the best + one real", control the false discovery rate instead of deflating a maximum. + +TWO CONVENTIONS THAT SILENTLY BREAK THESE FORMULAS +-------------------------------------------------- +**Sharpe must be per-observation, not annualised.** Every formula here pairs the +Sharpe with the observation count ``T``. Feeding an annualised Sharpe with a +daily ``T`` inflates the statistic by roughly sqrt(252) and turns a coin flip +into a certainty. :func:`probabilistic_sharpe_ratio` cannot detect this, so it +is stated here and repeated in each docstring. + +**Kurtosis is non-excess.** The Bailey-Lopez de Prado derivation uses the fourth +standardised moment, which is **3** for a Gaussian, not 0. ``scipy.stats.kurtosis`` +returns the *excess* form by default and ``pandas.Series.kurt`` likewise. Passing +excess kurtosis makes the variance term ``1 - skew*SR + (gamma4-1)/4 * SR^2`` +too small and the resulting confidence too high. :data:`GAUSSIAN_KURTOSIS` is +provided as the default so the common case is right without thinking about it. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import dataclass +from itertools import combinations + +import numpy as np +import pandas as pd +from scipy.stats import norm + +__all__ = [ + "DEFAULT_CSCV_SPLITS", + "EULER_MASCHERONI", + "GAUSSIAN_KURTOSIS", + "MIN_OBSERVATIONS", + "CSCVResult", + "DeflatedSharpeResult", + "FDRResult", + "benjamini_hochberg", + "deflated_sharpe_ratio", + "expected_maximum_sharpe", + "probabilistic_sharpe_ratio", + "probability_of_backtest_overfitting", + "sharpe_ratio", +] + +#: Euler-Mascheroni constant, from the expected-maximum-of-N-Gaussians result. +EULER_MASCHERONI: float = 0.5772156649015329 + +#: Fourth standardised moment of a Gaussian. NOT zero -- see the module +#: docstring. The default wherever a kurtosis argument is optional. +GAUSSIAN_KURTOSIS: float = 3.0 + +#: Subsets the CSCV procedure splits the sample into. Must be even; 16 gives +#: C(16,8) = 12,870 splits, which is the published choice and runs in seconds. +DEFAULT_CSCV_SPLITS: int = 16 + +#: Fewest return observations for which these statistics mean anything. The +#: ``sqrt(T - 1)`` scaling is not the binding constraint -- the skew and +#: kurtosis estimates are, and they are hopeless on a short sample. +MIN_OBSERVATIONS: int = 30 + + +@dataclass(frozen=True) +class DeflatedSharpeResult: + """Outcome of deflating an observed Sharpe for the number of trials. + + Attributes: + observed_sharpe: The Sharpe that was actually observed, per-observation. + expected_maximum_sharpe: The Sharpe the best of ``n_trials`` would be + expected to reach under a null where none of them has any edge. + This is the bar the observed Sharpe has to clear. + deflated_sharpe_ratio: Probability in ``[0, 1]`` that the true Sharpe + exceeds ``expected_maximum_sharpe``. Read it as a confidence: 0.95 + means the result survives the search correction at 95%. + n_trials: How many strategies were searched over. + n_observations: Length of the return sample. + trial_sharpe_std: Dispersion of the trial Sharpes that set the bar. + skew: Return skewness used. + kurtosis: Non-excess kurtosis used. + survives: True when ``deflated_sharpe_ratio`` exceeds ``confidence``. + confidence: Threshold ``survives`` was judged at. + """ + + observed_sharpe: float + expected_maximum_sharpe: float + deflated_sharpe_ratio: float + n_trials: int + n_observations: int + trial_sharpe_std: float + skew: float + kurtosis: float + survives: bool + confidence: float + + +@dataclass(frozen=True) +class FDRResult: + """Benjamini-Hochberg false-discovery-rate control over many hypotheses. + + Attributes: + rejected: Boolean array, True where the null is rejected. + adjusted_p_values: BH-adjusted p-values, monotone non-decreasing in the + sorted order and clipped at 1. + n_rejected: Count of rejections. + threshold: Largest raw p-value that was rejected, or 0.0 if none were. + fdr: The false-discovery rate the procedure controlled at. + """ + + rejected: np.ndarray + adjusted_p_values: np.ndarray + n_rejected: int + threshold: float + fdr: float + + +@dataclass(frozen=True) +class CSCVResult: + """Combinatorially-symmetric cross-validation overfitting diagnostic. + + Attributes: + pbo: Probability of backtest overfitting -- the fraction of splits in + which the in-sample best strategy landed below the out-of-sample + median. Above 0.5 the selection rule is worse than picking at random. + logits: Logit of the out-of-sample relative rank, one per split. + n_splits: Number of in-sample/out-of-sample combinations evaluated. + n_strategies: Strategies compared. + n_observations: Rows actually used. + dropped_observations: Rows trimmed from the end so the sample divides + evenly into subsets. Reported, never silent. + in_sample_sharpes: In-sample Sharpe of the selected strategy per split. + out_of_sample_sharpes: Out-of-sample Sharpe of that same strategy. + performance_degradation: Slope of out-of-sample on in-sample Sharpe + across splits. A negative slope means better in-sample selection + produced worse out-of-sample results, which is overfitting in its + purest observable form. + """ + + pbo: float + logits: np.ndarray + n_splits: int + n_strategies: int + n_observations: int + dropped_observations: int + in_sample_sharpes: np.ndarray + out_of_sample_sharpes: np.ndarray + performance_degradation: float + + +def sharpe_ratio( + returns: pd.Series | np.ndarray | Sequence[float], + risk_free: float = 0.0, + ddof: int = 1, +) -> float: + """Per-observation Sharpe ratio. + + Deliberately NOT annualised: every other function in this module pairs the + Sharpe with an observation count, and mixing an annualised numerator with a + per-period count is the error the module docstring warns about. + + Args: + returns: Return observations. + risk_free: Risk-free rate per observation, in the same units. + ddof: Delta degrees of freedom for the standard deviation. Defaults to 1 + to match ``src.quantlib.timeseries.bootstrap_sharpe``; pass 0 to + match a population-convention implementation. The two differ by + ``sqrt(T/(T-1))``, which is immaterial at T=1000 and is not at T=30. + + Returns: + ``mean(excess) / std(excess)``, or ``nan`` when the excess returns have + no dispersion. + + Raises: + ValueError: If fewer than two finite observations are supplied. + """ + values = np.asarray(returns, dtype=float).ravel() + values = values[np.isfinite(values)] + if values.size < 2: + raise ValueError(f"sharpe_ratio needs at least 2 observations, got {values.size}") + excess = values - risk_free + spread = float(excess.std(ddof=ddof)) + if not np.isfinite(spread) or spread <= 0.0: + return float("nan") + return float(excess.mean() / spread) + + +def probabilistic_sharpe_ratio( + observed_sharpe: float, + n_observations: int, + benchmark_sharpe: float = 0.0, + skew: float = 0.0, + kurtosis: float = GAUSSIAN_KURTOSIS, +) -> float: + """Confidence that a strategy's true Sharpe exceeds a benchmark. + + ``PSR = Z[ (SR - SR*) * sqrt(T - 1) / sqrt(1 - g3*SR + (g4 - 1)/4 * SR^2) ]`` + + The denominator is why this is not just a t-test: negative skew and fat + tails both make a given Sharpe less trustworthy, and both are routine in + strategy returns. + + Args: + observed_sharpe: Observed Sharpe, **per-observation** and matched to + ``n_observations``. An annualised Sharpe here inflates the result. + n_observations: Number of return observations behind the Sharpe. + benchmark_sharpe: Sharpe to beat, per-observation. Zero asks "is this + better than nothing"; :func:`deflated_sharpe_ratio` raises it to the + expected best-of-N instead. + skew: Third standardised moment of the returns. + kurtosis: Fourth standardised moment, **non-excess** (3 for Gaussian). + + Returns: + Probability in ``[0, 1]``. + + Raises: + ValueError: If ``n_observations`` is below :data:`MIN_OBSERVATIONS`, if + ``kurtosis`` is below 1 (impossible for any distribution), or if the + variance term is non-positive, which means the supplied moments are + mutually inconsistent rather than merely extreme. + """ + if n_observations < MIN_OBSERVATIONS: + raise ValueError( + f"need at least {MIN_OBSERVATIONS} observations, got {n_observations}" + ) + if kurtosis < 1.0: + raise ValueError( + f"kurtosis must be the non-excess fourth moment (>= 1, 3 for a " + f"Gaussian), got {kurtosis}. Excess kurtosis was probably passed." + ) + if not np.isfinite(observed_sharpe): + raise ValueError(f"observed_sharpe must be finite, got {observed_sharpe}") + + variance_term = ( + 1.0 - skew * observed_sharpe + (kurtosis - 1.0) / 4.0 * observed_sharpe**2 + ) + if variance_term <= 0.0: + raise ValueError( + f"the Sharpe variance term is {variance_term:.4f}, which is not " + "positive; the supplied skew and kurtosis are mutually inconsistent " + "with this Sharpe" + ) + + statistic = ( + (observed_sharpe - benchmark_sharpe) + * math.sqrt(n_observations - 1) + / math.sqrt(variance_term) + ) + return float(norm.cdf(statistic)) + + +def expected_maximum_sharpe(n_trials: int, trial_sharpe_std: float) -> float: + """Sharpe the best of ``n_trials`` reaches by luck alone. + + From the expected maximum of ``N`` independent Gaussians:: + + E[max] ~ std * [ (1 - g) * Z^-1(1 - 1/N) + g * Z^-1(1 - 1/(N*e)) ] + + with ``g`` the Euler-Mascheroni constant. This is the bar an observed Sharpe + has to clear before "we tried 462 factors and this was the best" says + anything at all. + + Args: + n_trials: Number of strategies searched over. Must be at least 2 -- with + one trial there is no maximum to correct for and the answer is 0. + trial_sharpe_std: Standard deviation of the Sharpes across those trials, + per-observation. + + Returns: + The expected maximum Sharpe under the null, per-observation. Zero when + ``trial_sharpe_std`` is zero. + + Raises: + ValueError: If ``n_trials`` is below 1 or ``trial_sharpe_std`` is + negative. + """ + if n_trials < 1: + raise ValueError(f"n_trials must be at least 1, got {n_trials}") + if trial_sharpe_std < 0.0: + raise ValueError(f"trial_sharpe_std must be >= 0, got {trial_sharpe_std}") + if n_trials == 1 or trial_sharpe_std == 0.0: + return 0.0 + + quantile_a = norm.ppf(1.0 - 1.0 / n_trials) + quantile_b = norm.ppf(1.0 - 1.0 / (n_trials * math.e)) + return float( + trial_sharpe_std + * ((1.0 - EULER_MASCHERONI) * quantile_a + EULER_MASCHERONI * quantile_b) + ) + + +def deflated_sharpe_ratio( + observed_sharpe: float, + n_trials: int, + n_observations: int, + trial_sharpe_std: float, + skew: float = 0.0, + kurtosis: float = GAUSSIAN_KURTOSIS, + confidence: float = 0.95, +) -> DeflatedSharpeResult: + """Deflate an observed Sharpe by the number of strategies searched. + + Args: + observed_sharpe: Best observed Sharpe, per-observation. + n_trials: How many strategies were searched. This is the number a + research process usually fails to record; without it no correction + is possible and the reported Sharpe is not interpretable. + n_observations: Length of the return sample. + trial_sharpe_std: Standard deviation of the Sharpes across the trials. + skew: Third standardised moment of the winning strategy's returns. + kurtosis: Fourth standardised moment, non-excess. + confidence: Level at which ``survives`` is decided. + + Returns: + A :class:`DeflatedSharpeResult`. + + Raises: + ValueError: Propagated from :func:`expected_maximum_sharpe` and + :func:`probabilistic_sharpe_ratio`. + """ + if not 0.0 < confidence < 1.0: + raise ValueError(f"confidence must be in (0, 1), got {confidence}") + + bar = expected_maximum_sharpe(n_trials, trial_sharpe_std) + dsr = probabilistic_sharpe_ratio( + observed_sharpe=observed_sharpe, + n_observations=n_observations, + benchmark_sharpe=bar, + skew=skew, + kurtosis=kurtosis, + ) + return DeflatedSharpeResult( + observed_sharpe=observed_sharpe, + expected_maximum_sharpe=bar, + deflated_sharpe_ratio=dsr, + n_trials=n_trials, + n_observations=n_observations, + trial_sharpe_std=trial_sharpe_std, + skew=skew, + kurtosis=kurtosis, + survives=dsr > confidence, + confidence=confidence, + ) + + +def benjamini_hochberg( + p_values: Sequence[float] | np.ndarray, + fdr: float = 0.05, +) -> FDRResult: + """Control the false discovery rate across many simultaneous tests. + + Bonferroni controls the probability of *any* false positive and is far too + strict when hundreds of factors are screened; BH instead bounds the expected + *fraction* of rejections that are false, which is the quantity a research + pipeline actually cares about. + + Args: + p_values: Raw p-values, one per hypothesis. Must all lie in ``[0, 1]``. + fdr: False discovery rate to control at. + + Returns: + An :class:`FDRResult` whose ``rejected`` and ``adjusted_p_values`` are in + the caller's original order. + + Raises: + ValueError: If ``p_values`` is empty, holds a value outside ``[0, 1]`` + or a non-finite value, or if ``fdr`` is not in ``(0, 1)``. + """ + if not 0.0 < fdr < 1.0: + raise ValueError(f"fdr must be in (0, 1), got {fdr}") + + values = np.asarray(p_values, dtype=float).ravel() + if values.size == 0: + raise ValueError("p_values is empty") + if not np.isfinite(values).all(): + raise ValueError("p_values holds a non-finite value") + if ((values < 0.0) | (values > 1.0)).any(): + raise ValueError("every p-value must lie in [0, 1]") + + n = values.size + order = np.argsort(values, kind="stable") + sorted_p = values[order] + ranks = np.arange(1, n + 1) + + # Step-up: the largest rank whose p-value clears its own threshold, and + # everything below it, is rejected. + below = sorted_p <= (ranks / n) * fdr + if below.any(): + cutoff_rank = int(ranks[below].max()) + threshold = float(sorted_p[cutoff_rank - 1]) + else: + cutoff_rank = 0 + threshold = 0.0 + + rejected_sorted = ranks <= cutoff_rank + + # Adjusted p-values are the running minimum from the largest rank down, so + # they cannot decrease as the raw p-value increases. + adjusted_sorted = np.minimum.accumulate((n / ranks * sorted_p)[::-1])[::-1] + adjusted_sorted = np.clip(adjusted_sorted, 0.0, 1.0) + + rejected = np.empty(n, dtype=bool) + adjusted = np.empty(n, dtype=float) + rejected[order] = rejected_sorted + adjusted[order] = adjusted_sorted + + return FDRResult( + rejected=rejected, + adjusted_p_values=adjusted, + n_rejected=int(rejected.sum()), + threshold=threshold, + fdr=fdr, + ) + + +def probability_of_backtest_overfitting( + performance: pd.DataFrame | np.ndarray, + n_splits: int = DEFAULT_CSCV_SPLITS, + ddof: int = 1, +) -> CSCVResult: + """Combinatorially-symmetric cross-validation (CSCV) overfitting diagnostic. + + Splits the sample into ``n_splits`` contiguous subsets, then for every way of + choosing half of them as in-sample, picks the strategy with the best + in-sample Sharpe and records where it ranks out-of-sample. If selection + carried information, the in-sample winner would tend to rank high + out-of-sample; if the process is fitting noise, it lands anywhere, and below + the median about half the time. + + Args: + performance: Returns per strategy, rows as observations and columns as + strategies. A DataFrame's column labels are preserved only for the + caller's convenience; this function returns no per-strategy output. + n_splits: Number of subsets. Must be even and at least 4; the number of + combinations is ``C(n_splits, n_splits/2)``, so 16 gives 12,870. + ddof: Delta degrees of freedom for the Sharpe standard deviation, + forwarded to :func:`sharpe_ratio`. + + Returns: + A :class:`CSCVResult`. + + Raises: + ValueError: If ``n_splits`` is odd or below 4, if fewer than 2 + strategies are supplied (a rank needs competitors), if the sample + cannot give each subset at least 2 rows, or if every strategy has + zero variance so no Sharpe is defined. + """ + if n_splits < 4 or n_splits % 2 != 0: + raise ValueError(f"n_splits must be an even number >= 4, got {n_splits}") + + frame = pd.DataFrame(performance) + matrix = frame.to_numpy(dtype=float) + if matrix.ndim != 2: + raise ValueError(f"performance must be 2-D, got shape {matrix.shape}") + + n_rows, n_strategies = matrix.shape + if n_strategies < 2: + raise ValueError( + f"CSCV ranks strategies against each other and needs at least 2, " + f"got {n_strategies}" + ) + + subset_size = n_rows // n_splits + if subset_size < 2: + raise ValueError( + f"{n_rows} rows split {n_splits} ways gives {subset_size} row(s) per " + "subset; each subset needs at least 2 for a Sharpe" + ) + + used_rows = subset_size * n_splits + dropped = n_rows - used_rows + trimmed = matrix[:used_rows] + subsets = [ + trimmed[i * subset_size : (i + 1) * subset_size] for i in range(n_splits) + ] + + logits: list[float] = [] + in_sample_sharpes: list[float] = [] + out_sample_sharpes: list[float] = [] + all_indices = set(range(n_splits)) + + for chosen in combinations(range(n_splits), n_splits // 2): + rest = sorted(all_indices - set(chosen)) + in_sample = np.vstack([subsets[i] for i in chosen]) + out_sample = np.vstack([subsets[i] for i in rest]) + + in_scores = np.array( + [sharpe_ratio(in_sample[:, j], ddof=ddof) for j in range(n_strategies)] + ) + if not np.isfinite(in_scores).any(): + continue + best = int(np.nanargmax(np.where(np.isfinite(in_scores), in_scores, -np.inf))) + + out_scores = np.array( + [sharpe_ratio(out_sample[:, j], ddof=ddof) for j in range(n_strategies)] + ) + finite_out = np.isfinite(out_scores) + if not finite_out[best] or finite_out.sum() < 2: + continue + + # Relative rank of the selected strategy among all strategies OOS, on + # (0, 1). Ties are resolved by average rank so a plateau does not push + # the logit to an endpoint. + ranked = pd.Series(np.where(finite_out, out_scores, np.nan)).rank( + method="average" + ) + omega = float(ranked.iloc[best] / (finite_out.sum() + 1)) + omega = min(max(omega, 1e-12), 1.0 - 1e-12) + + logits.append(math.log(omega / (1.0 - omega))) + in_sample_sharpes.append(float(in_scores[best])) + out_sample_sharpes.append(float(out_scores[best])) + + if not logits: + raise ValueError( + "no split produced a usable Sharpe; every strategy may have zero " + "variance within the subsets" + ) + + logit_array = np.array(logits) + in_array = np.array(in_sample_sharpes) + out_array = np.array(out_sample_sharpes) + + if in_array.size >= 2 and float(in_array.std()) > 0: + degradation = float(np.polyfit(in_array, out_array, 1)[0]) + else: + degradation = float("nan") + + return CSCVResult( + pbo=float((logit_array <= 0).mean()), + logits=logit_array, + n_splits=len(logits), + n_strategies=n_strategies, + n_observations=used_rows, + dropped_observations=dropped, + in_sample_sharpes=in_array, + out_of_sample_sharpes=out_array, + performance_degradation=degradation, + ) diff --git a/agent/src/quantlib/timeseries.py b/agent/src/quantlib/timeseries.py index 441a2901..822265ec 100644 --- a/agent/src/quantlib/timeseries.py +++ b/agent/src/quantlib/timeseries.py @@ -46,6 +46,7 @@ __all__ = [ "compute_half_life", "granger_test", "fit_garch", + "fit_markov_regime", "heteroscedasticity_test", "autocorrelation_test", "vif_test", @@ -383,6 +384,137 @@ def fit_garch(returns: pd.Series, horizon: int = 5) -> dict: } +def fit_markov_regime( + returns: pd.Series, + n_regimes: int = 2, + switching_variance: bool = True, +) -> dict: + """Fit a Markov regime-switching model and label the current regime. + + A GARCH model says how volatile tomorrow is. This says *which state the + market is in* and how likely it is to stay there, which is the question a + regime section of a risk report is actually asking. The states are latent: + they are inferred from the return series, never labelled by hand. + + Two implementation choices matter and neither is cosmetic. + + **Regimes are relabelled by volatility, ascending.** The EM fit returns + states in an arbitrary order, so the same data can come back as "regime 0 is + calm" on one run and "regime 0 is turbulent" on the next. That is the + classic label-switching problem, and left alone it makes every downstream + number non-reproducible. Here regime 0 is always the calmest and the last is + always the most turbulent, with the transition matrix and the smoothed + probabilities permuted to match. + + **The transition matrix is returned row-stochastic**: ``transition[i][j]`` is + ``P(regime j tomorrow | regime i today)`` and every row sums to 1. + ``statsmodels`` returns the transpose of this, and reading it the wrong way + round silently swaps the two regimes' persistence. + + Args: + returns: Return series as *fractions* (0.01 = 1%). Scaled to percent + internally for optimiser stability and converted back on the way + out, so every reported mean and volatility is a fraction again. + n_regimes: Number of latent states, at least 2. Three is occasionally + defensible (calm / normal / crisis); beyond that the states stop + being interpretable and the fit stops being stable. + switching_variance: Let volatility differ across regimes. Leaving this + False fits regimes that differ only in mean return, which for asset + returns is nearly always the wrong model -- regimes in markets are a + volatility phenomenon first. + + Returns: + Dict with keys ``n_regimes`` (int), ``regime_means`` and ``regime_vols`` + (numpy arrays, ascending volatility, daily fractions), + ``transition_matrix`` (2-D numpy array, row-stochastic), + ``expected_durations`` (numpy array, ``1 / (1 - p_ii)`` in periods), + ``smoothed_probabilities`` (DataFrame on the input index, one column per + regime), ``current_regime`` (int) and ``current_regime_probability`` + (float) for the last observation, ``converged`` (bool), and ``llf``, + ``aic``, ``bic`` (float). + + Raises: + ImportError: If ``statsmodels`` is not installed. + ValueError: If ``n_regimes`` is below 2, or fewer than 50 finite + observations survive -- an EM fit on a shorter series produces + regimes that are numerically fine and substantively meaningless. + """ + markov = _require( + "statsmodels.tsa.regime_switching.markov_regression", + "statsmodels", + "fit_markov_regime", + ) + if n_regimes < 2: + raise ValueError(f"n_regimes must be at least 2, got {n_regimes}") + + series = pd.Series(returns, dtype=float).dropna() + if series.size < 50: + raise ValueError( + f"fit_markov_regime needs at least 50 finite observations, got {series.size}" + ) + + # Passed as a Series, not an array: statsmodels only names the fitted + # parameters (``const[k]``, ``sigma2[k]``) when the input is pandas, and + # positional unpacking of that vector would silently break if the package + # ever reorders it. + model = markov.MarkovRegression( + series * 100, + k_regimes=n_regimes, + trend="c", + switching_variance=switching_variance, + ) + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + result = model.fit() + + means = np.array( + [float(result.params[f"const[{k}]"]) / 100 for k in range(n_regimes)] + ) + if switching_variance: + variances = np.array([float(result.params[f"sigma2[{k}]"]) for k in range(n_regimes)]) + else: + variances = np.full(n_regimes, float(result.params["sigma2"])) + vols = np.sqrt(np.maximum(variances, 0.0)) / 100 + + # Canonical order: calmest regime first. Everything below is permuted to it. + order = np.argsort(vols, kind="stable") + + # statsmodels gives P(to i | from j); transpose to the conventional + # P(from i | to j) reading, then permute rows and columns together. + raw_transition = np.asarray(result.regime_transition, dtype=float) + if raw_transition.ndim == 3: + raw_transition = raw_transition[:, :, 0] + transition = raw_transition.T[np.ix_(order, order)] + + diagonal = np.clip(np.diag(transition), None, 1.0 - 1e-12) + expected_durations = 1.0 / (1.0 - diagonal) + + smoothed = np.asarray(result.smoothed_marginal_probabilities, dtype=float) + if smoothed.ndim == 1: + smoothed = smoothed.reshape(-1, 1) + smoothed = smoothed[:, order] + smoothed_frame = pd.DataFrame( + smoothed, index=series.index, columns=[f"regime_{k}" for k in range(n_regimes)] + ) + + last_row = smoothed[-1] + current_regime = int(np.argmax(last_row)) + + return { + "n_regimes": n_regimes, + "regime_means": means[order], + "regime_vols": vols[order], + "transition_matrix": transition, + "expected_durations": expected_durations, + "smoothed_probabilities": smoothed_frame, + "current_regime": current_regime, + "current_regime_probability": float(last_row[current_regime]), + "converged": bool(result.mle_retvals.get("converged", False)), + "llf": float(result.llf), + "aic": float(result.aic), + "bic": float(result.bic), + } + + def heteroscedasticity_test(model_result: Any, significance: float = 0.05) -> dict: """Test regression residuals for heteroskedasticity (White and Breusch-Pagan). diff --git a/agent/src/quantlib/valuation/__init__.py b/agent/src/quantlib/valuation/__init__.py new file mode 100644 index 00000000..fbecaaea --- /dev/null +++ b/agent/src/quantlib/valuation/__init__.py @@ -0,0 +1,24 @@ +"""Valuation models: DCF, comparable companies, three-statement projection. + +The audit that motivated this package found no executable DCF, WACC, terminal +value or comps engine anywhere in the repository -- only markdown describing +them. These modules are the executable form. + +Import submodules directly, so a missing optional dependency in one cannot +break the others:: + + from src.quantlib.valuation.dcf import run_dcf + from src.quantlib.valuation.comps import run_comps + +Submodules: + contracts Shared input discipline -- missing inputs stop the model + dcf FCFF bridge, WACC build, dual terminal value, sensitivity grid + comps Peer set, calendarisation, EV bridge, multiple matrix + threestatement Linked P&L / cash flow / balance sheet with a hard balance check + +The one rule every module here obeys is in :mod:`contracts`: a missing input +makes a model NOT RUNNABLE and is never silently defaulted. A valuation built +on quietly-chosen constants is indistinguishable, downstream, from one built on +filed statements -- and that is exactly the failure this package exists to make +impossible. +""" diff --git a/agent/src/quantlib/valuation/artifact.py b/agent/src/quantlib/valuation/artifact.py new file mode 100644 index 00000000..772a5d8e --- /dev/null +++ b/agent/src/quantlib/valuation/artifact.py @@ -0,0 +1,1615 @@ +"""Versioned model artifacts for DCF / comps / three-statement, plus export. + +:mod:`dcf`, :mod:`comps` and :mod:`threestatement` each answer "what is this +worth" for one run. None of them answer three questions every real valuation +practice needs answered on top of that: *which* inputs produced this number, +*can I tell if someone changed one*, and *can I hand this to someone who was +not in the room*. This module exists only for those three questions -- it does +not recompute anything the three model modules already compute, and it never +imports anything from them that isn't already public (:class:`DCFResult`, +:class:`CompsResult`, :class:`ThreeStatementProjection` and their nested +dataclasses, plus :class:`~.contracts.Assumption`). + +THE ARTIFACT +------------ +:func:`build_dcf_artifact`, :func:`build_comps_artifact` and +:func:`build_three_statement_artifact` each wrap one completed model run (the +raw inputs the caller fed the model, plus the call-level knobs such as +``discounting_convention`` that also determine the output, plus the model's +own result object) into one :class:`ModelArtifact`. Four things happen on the +way in: + +1. **Input hash.** Every input -- the raw mapping/dataclasses the caller + passed AND the call-level configuration knobs, because a knob like + ``discounting_convention`` changes the output just as much as a number in + ``inputs`` does -- is walked into a flat set of ``(dotted.path, value)`` + leaves (:func:`_flatten`), each leaf's number canonicalised + (:func:`_normalize_number`: fixed 12-significant-digit scientific + notation, ``-0.0`` collapsed to ``0.0``, ``int``/``float`` treated as the + same economic value), then hashed with ``sha256`` over a + ``sort_keys=True`` JSON encoding so the result depends on the *content* of + the inputs and nothing about how they were typed in (dict key order, + ``int`` vs ``float``, ``-0.0`` vs ``0.0``). Change one number anywhere in + the input tree and the hash changes; two calls with the same inputs in a + different key order produce the identical hash. See :func:`compute_input_hash`. +2. **Model version.** :func:`_module_version` hashes the producing module's + own source file together with :mod:`.contracts`'s (every model in this + package depends on ``contracts.py``'s input discipline, so a change there + is a change to what produced the artifact even if the model file's own + text is untouched). This is content-addressed on purpose: there is no + ``__version__`` anywhere in :mod:`src.quantlib` to defer to (checked before + writing this module), and a hand-maintained version string goes stale the + first time someone edits the model and forgets to bump it. A content hash + cannot go stale. +3. **Assumptions.** The same flatten pass that builds the input hash also + walks the *result* object (not the raw inputs -- see + :func:`build_dcf_artifact`'s docstring for why) collecting every + :class:`~.contracts.Assumption` it finds, with the dotted path it was found + at. Only :mod:`dcf` requires callers to pass :class:`Assumption` objects + (``terminal_growth``, ``exit_multiple``); :mod:`comps` and + :mod:`threestatement` take bare numbers for everything and therefore + produce artifacts with an empty ``assumptions`` tuple. That is not a bug in + this module -- it is an accurate report of what those two models do and do + not ask the caller to justify, and every export below says so explicitly + rather than leaving the section blank. +4. **Timestamp, supplied not invented.** ``generated_at`` is a required + keyword argument of ``datetime`` type with no default anywhere in this + file. This module never calls ``datetime.now()`` or ``time.time()`` -- + omitting the argument is a ``TypeError`` from Python's own argument + binding, and passing a naive (non-timezone-aware) datetime is a + ``ValueError`` from :func:`_require_generated_at`. A caller who wants a + real-time artifact passes ``datetime.now(timezone.utc)`` themselves; this + module has no opinion on "now" and cannot silently manufacture one, which + is exactly what makes ``input_hash`` and every export below reproducible + in a test. + +DIFFING +------- +:func:`diff_artifacts` compares two artifacts of the same ``model_name`` by +set-comparing their flattened input and output leaf paths: paths present in +both but changed get a :class:`FieldChange` (with a numeric ``delta`` when +both sides are numbers), paths only on one side are reported separately as +additions/removals. This is the only way to answer "did the OUTPUT move +because an INPUT moved, or because the model code changed" -- which is why +``input_hash`` is computed from inputs and configuration only, never from +``model_version`` or ``generated_at``: two artifacts built from identical +inputs on different days, or by different code versions, must still hash +identically on the input side, or that comparison is not possible. + +EXPORTS DO NOT HIDE THE UGLY PARTS +----------------------------------- +Every ``export_*`` function writes an "Assumptions & Data Quality" sheet (xlsx) +or a "Data Quality Notes" section (pptx) carrying, verbatim: every +:class:`~.contracts.Assumption` with its ``basis``, the ``input_hash`` and +``model_version`` that produced the run, and ``ModelArtifact.excluded`` -- +which for :mod:`comps` includes every peer dropped from a multiple +(:class:`~.comps.ExcludedMultiple`) and every EV-bridge component a peer or +the target did not supply (:class:`~.comps.EVBridgeResult.omitted_components`), +and for :mod:`threestatement` includes every period where the revolver had to +draw or repay (the model's one declared plug) plus the convergence diagnostics +:class:`~.threestatement.PeriodResult` already carries. An export that only +shows the numbers that make the model look clean is not an export of the +model -- it is a different, dishonest document that happens to share some +numbers with it. + +WHAT THIS MODULE DOES NOT DO +------------------------------ +It does not validate valuation inputs (that is :mod:`.contracts`'s job, +already done by the time a result object exists) and it does not recompute a +sensitivity grid on the caller's behalf -- :func:`export_dcf_workbook` +requires the caller to pass one (e.g. from +:func:`src.quantlib.valuation.dcf.sensitivity_grid`) rather than inventing a +WACC/growth sweep range, because a sweep range is itself a judgement call +this package does not make silently for the same reason +:mod:`.contracts` refuses to default a terminal growth rate. +""" + +from __future__ import annotations + +import dataclasses +import hashlib +import json +import math +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from types import MappingProxyType, ModuleType +from typing import Any + +import numpy as np +import pandas as pd +from openpyxl import Workbook +from openpyxl.styles import Font +from openpyxl.worksheet.worksheet import Worksheet +from pptx import Presentation +from pptx.text.text import TextFrame +from pptx.util import Inches, Pt + +from src.quantlib.valuation import comps as _comps_module +from src.quantlib.valuation import contracts as _contracts_module +from src.quantlib.valuation import dcf as _dcf_module +from src.quantlib.valuation import threestatement as _threestatement_module +from src.quantlib.valuation.comps import ( + MULTIPLE_NAMES, + CompsResult, + PeerCompany, + TargetCompany, +) +from src.quantlib.valuation.contracts import Assumption +from src.quantlib.valuation.dcf import DEFAULT_LONG_RUN_GDP_GROWTH_CEILING, DCFResult +from src.quantlib.valuation.threestatement import ( + BALANCE_TOLERANCE_REL, + CIRCULARITY_TOLERANCE_REL, + MAX_CIRCULARITY_ITERATIONS, + ThreeStatementProjection, +) + +__all__ = [ + "AssumptionRecord", + "ModelArtifact", + "FieldChange", + "ArtifactDiff", + "compute_input_hash", + "build_dcf_artifact", + "build_comps_artifact", + "build_three_statement_artifact", + "diff_artifacts", + "export_dcf_workbook", + "export_comps_workbook", + "export_three_statement_workbook", + "export_summary_deck", +] + +#: Names this module knows how to build an artifact for. Any other +#: ``model_name`` on a :class:`ModelArtifact` is a construction bug, not a +#: valid but-unsupported case -- see :meth:`ModelArtifact.__post_init__`. +_MODEL_NAMES: tuple[str, ...] = ("dcf", "comps", "three_statement") + +#: This module's own artifact-schema version. Bumped only if the normalization +#: scheme in :func:`_flatten` / :func:`_normalize_number` changes in a way +#: that would make an old and a new hash falsely comparable -- folded into +#: every input hash via :data:`_HASH_SCHEMA` so such a change is never silent. +_SCHEMA_VERSION = "artifact-v1" +_HASH_SCHEMA = "vt-valuation-input-hash-v1" + + +# --------------------------------------------------------------------------- +# Core types +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class AssumptionRecord: + """One :class:`~.contracts.Assumption` found inside a result object, located. + + Attributes: + path: Dotted/bracket path within the result object where this + assumption was found, e.g. ``"$.terminal_value.terminal_growth"``. + assumption: The :class:`~.contracts.Assumption` itself -- name, value, + basis and optional source, unmodified. + """ + + path: str + assumption: Assumption + + +@dataclass(frozen=True) +class ModelArtifact: + """A reproducible, diffable snapshot of one completed valuation run. + + Attributes: + model_name: One of :data:`_MODEL_NAMES` -- ``"dcf"``, ``"comps"`` or + ``"three_statement"``. + model_version: Content-addressed tag for the code that produced + ``result`` -- see :func:`_module_version`. + schema_version: This artifact schema's own version, :data:`_SCHEMA_VERSION`. + generated_at: Caller-supplied, timezone-aware timestamp of the run. + Never computed inside this module -- see the module docstring. + input_hash: ``sha256`` of the canonicalised inputs and call-level + configuration that produced ``result``. Does NOT depend on + ``generated_at`` or ``model_version``, by construction -- see + :func:`diff_artifacts`. + inputs: Flat ``{dotted.path: value}`` snapshot of every input leaf + (readable Python scalars, not the canonical hash strings), for + display and diffing. + outputs: Flat ``{dotted.path: value}`` snapshot of every leaf in + ``result``, same convention as ``inputs``. + assumptions: Every :class:`AssumptionRecord` found inside ``result``. + Empty for ``comps`` and ``three_statement`` -- neither model's + required inputs go through :class:`~.contracts.Assumption`; see + the module docstring. + result: The original typed result object (:class:`DCFResult`, + :class:`CompsResult` or :class:`ThreeStatementProjection`), + unmodified, for exporters that need typed access. + excluded: Human-readable notes on everything the model dropped, + omitted, or flagged rather than silently absorbed -- excluded + comps, omitted EV-bridge components, terminal growth above the + GDP-growth reference ceiling, revolver draws/repayments. Always + present (an empty tuple IS the report "nothing was excluded"), + and always written into every export -- see the module docstring. + warnings: Warning strings the underlying result itself carries + (currently only :attr:`CompsResult.warnings`); ``()`` for models + that carry none. + """ + + model_name: str + model_version: str + schema_version: str + generated_at: datetime + input_hash: str + inputs: Mapping[str, Any] + outputs: Mapping[str, Any] + assumptions: tuple[AssumptionRecord, ...] + result: Any + excluded: tuple[str, ...] + warnings: tuple[str, ...] + + def __post_init__(self) -> None: + """Check the artifact's own invariants. + + Raises: + ValueError: If ``model_name`` is not one of :data:`_MODEL_NAMES`, + or ``input_hash`` is not a 64-character hex sha256 digest. + """ + if self.model_name not in _MODEL_NAMES: + raise ValueError( + f"ModelArtifact.model_name must be one of {_MODEL_NAMES}, " + f"got {self.model_name!r}" + ) + if len(self.input_hash) != 64 or any( + c not in "0123456789abcdef" for c in self.input_hash + ): + raise ValueError( + f"ModelArtifact.input_hash must be a 64-character sha256 hex " + f"digest, got {self.input_hash!r}" + ) + + +@dataclass(frozen=True) +class FieldChange: + """One field that differs between two artifacts' inputs or outputs. + + Attributes: + path: The dotted/bracket path that changed. + old: The baseline artifact's value at ``path``. + new: The updated artifact's value at ``path``. + delta: ``new - old`` when both are numbers (and neither is a + ``bool``); ``None`` otherwise (e.g. a string or boolean field, or + a field that changed type). + """ + + path: str + old: Any + new: Any + delta: float | None + + +@dataclass(frozen=True) +class ArtifactDiff: + """The difference between two :class:`ModelArtifact` instances of the same model. + + Attributes: + model_name: The shared ``model_name`` of both artifacts. + baseline_generated_at: ``baseline.generated_at``. + updated_generated_at: ``updated.generated_at``. + baseline_input_hash: ``baseline.input_hash``. + updated_input_hash: ``updated.input_hash``. + input_hash_changed: ``baseline_input_hash != updated_input_hash``. + baseline_model_version: ``baseline.model_version``. + updated_model_version: ``updated.model_version``. + model_version_changed: ``baseline_model_version != updated_model_version``. + changed_inputs: One :class:`FieldChange` per input path present on + both artifacts whose value differs. + added_input_paths: Input paths present on ``updated`` only. + removed_input_paths: Input paths present on ``baseline`` only. + changed_outputs: One :class:`FieldChange` per output path present on + both artifacts whose value differs. + added_output_paths: Output paths present on ``updated`` only. + removed_output_paths: Output paths present on ``baseline`` only. + """ + + model_name: str + baseline_generated_at: datetime + updated_generated_at: datetime + baseline_input_hash: str + updated_input_hash: str + input_hash_changed: bool + baseline_model_version: str + updated_model_version: str + model_version_changed: bool + changed_inputs: tuple[FieldChange, ...] + added_input_paths: tuple[str, ...] + removed_input_paths: tuple[str, ...] + changed_outputs: tuple[FieldChange, ...] + added_output_paths: tuple[str, ...] + removed_output_paths: tuple[str, ...] + + +# --------------------------------------------------------------------------- +# Timestamp discipline -- no hidden clock anywhere in this module +# --------------------------------------------------------------------------- + + +def _require_generated_at(value: datetime, model: str) -> datetime: + """Check that a caller-supplied timestamp is usable, and only that. + + This function never reads a clock. Its only job is to reject a call that + did not supply one properly -- see the module docstring. + + Args: + value: The candidate ``generated_at``. + model: Name of the calling builder, for the error message. + + Returns: + ``value`` unchanged. + + Raises: + TypeError: If ``value`` is not a ``datetime.datetime``. + ValueError: If ``value`` is timezone-naive. + """ + if not isinstance(value, datetime): + raise TypeError( + f"{model}: generated_at must be a datetime.datetime supplied by the " + f"caller, got {type(value).__name__}. This module never calls " + "datetime.now() -- pass datetime.now(timezone.utc) yourself." + ) + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError( + f"{model}: generated_at must be timezone-aware (e.g. " + "datetime.now(timezone.utc)), got a naive datetime " + f"{value!r} -- an artifact's timestamp must be unambiguous." + ) + return value + + +# --------------------------------------------------------------------------- +# Canonical normalization: numbers, then the recursive flatten/hash engine +# --------------------------------------------------------------------------- + + +def _normalize_number(value: float) -> str: + """Canonicalise one number for hashing. + + Fixed 12-significant-digit scientific notation, independent of magnitude, + so ``int`` and ``float`` inputs representing the same economic value hash + identically, and ``-0.0`` collapses to ``0.0``. 12 significant digits is + far beyond the precision any input in this package carries, so a genuine + input change always moves the hash while floating-point representation + noise never does. + + Args: + value: An ``int`` or ``float`` (already unwrapped from any numpy + scalar by the caller). + + Returns: + A canonical string form -- ``"NaN"`` / ``"Infinity"`` / ``"-Infinity"`` + for non-finite values, else fixed-precision scientific notation. + """ + numeric = float(value) + if math.isnan(numeric): + return "NaN" + if math.isinf(numeric): + return "Infinity" if numeric > 0 else "-Infinity" + if numeric == 0.0: + numeric = 0.0 # collapse -0.0 + return f"{numeric:.12e}" + + +def _flatten( + value: Any, + path: str, + hash_leaves: dict[str, str], + readable_leaves: dict[str, Any], + assumptions: list[AssumptionRecord] | None, +) -> None: + """Recursively walk a value into flat ``path -> leaf`` dictionaries. + + Handles the exact shapes that appear in this package's inputs and result + objects: :class:`~.contracts.Assumption`, ``None``, ``bool``, ``int``/ + ``float`` (incl. numpy scalars), ``str``, any ``Mapping`` (incl. + ``MappingProxyType``), ``numpy.ndarray``/``pandas.Series``, ``list``/ + ``tuple``, and any frozen dataclass (every result type in :mod:`dcf`, + :mod:`comps` and :mod:`threestatement` is one). Anything else is refused + rather than silently stringified, because a silent ``str(obj)`` fallback + could make two different objects hash identically. + + Args: + value: The (sub)value to flatten. + path: The dotted/bracket path ``value`` was reached at. Callers + should start with ``"$"``. + hash_leaves: Mutated in place: ``path -> canonical string`` for + hashing. + readable_leaves: Mutated in place: ``path -> plain Python scalar`` + for display/diffing. + assumptions: If not ``None``, mutated in place: every + :class:`~.contracts.Assumption` encountered is appended as an + :class:`AssumptionRecord`. Pass ``None`` when flattening raw + inputs (assumptions are recorded from the RESULT only -- see the + module docstring). + + Raises: + TypeError: If ``value`` is of a type this function does not know how + to normalize. + """ + if isinstance(value, np.generic): + value = value.item() + + if isinstance(value, Assumption): + if assumptions is not None: + assumptions.append(AssumptionRecord(path=path, assumption=value)) + _flatten(value.name, f"{path}.name", hash_leaves, readable_leaves, assumptions) + _flatten(value.value, f"{path}.value", hash_leaves, readable_leaves, assumptions) + _flatten(value.basis, f"{path}.basis", hash_leaves, readable_leaves, assumptions) + _flatten(value.source, f"{path}.source", hash_leaves, readable_leaves, assumptions) + return + + if value is None: + hash_leaves[path] = "null" + readable_leaves[path] = None + return + + if isinstance(value, bool): + hash_leaves[path] = "true" if value else "false" + readable_leaves[path] = value + return + + if isinstance(value, (int, float)): + hash_leaves[path] = _normalize_number(value) + readable_leaves[path] = float(value) + return + + if isinstance(value, str): + hash_leaves[path] = json.dumps(value, ensure_ascii=False) + readable_leaves[path] = value + return + + if isinstance(value, Mapping): + for key in sorted(value.keys(), key=str): + _flatten(value[key], f"{path}.{key}", hash_leaves, readable_leaves, assumptions) + return + + if isinstance(value, np.ndarray): + value = value.tolist() + elif isinstance(value, pd.Series): + value = value.tolist() + + if isinstance(value, (list, tuple)): + for index, item in enumerate(value): + _flatten(item, f"{path}[{index}]", hash_leaves, readable_leaves, assumptions) + return + + if dataclasses.is_dataclass(value) and not isinstance(value, type): + for field in dataclasses.fields(value): + _flatten( + getattr(value, field.name), + f"{path}.{field.name}", + hash_leaves, + readable_leaves, + assumptions, + ) + return + + raise TypeError( + f"artifact: cannot normalize a value of type {type(value).__name__!r} at " + f"path {path!r}; extend _flatten or pass a plain mapping / sequence / " + "dataclass / Assumption / scalar" + ) + + +def _hash_leaves(hash_leaves: Mapping[str, str]) -> str: + """Hash a completed ``path -> canonical string`` leaf set. + + Args: + hash_leaves: Output of one or more :func:`_flatten` calls. + + Returns: + A 64-character sha256 hex digest. + """ + canonical = json.dumps( + {"schema": _HASH_SCHEMA, "leaves": dict(hash_leaves)}, + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def compute_input_hash(payload: Any) -> str: + """Hash an arbitrary payload the same way ``build_*_artifact`` hashes inputs. + + Public mainly so the normalization scheme (:func:`_flatten` / + :func:`_normalize_number`) can be tested directly, independent of any one + model's builder. + + Args: + payload: Any value :func:`_flatten` can walk -- typically a mapping + combining call-level config with the model's raw inputs. + + Returns: + A 64-character sha256 hex digest. Identical for two payloads that are + the same after normalization (dict key order, ``int``/``float``, + ``-0.0``/``0.0`` do not matter); different if any leaf's value + genuinely differs. + """ + hash_leaves: dict[str, str] = {} + readable_leaves: dict[str, Any] = {} + _flatten(payload, "$", hash_leaves, readable_leaves, None) + return _hash_leaves(hash_leaves) + + +def _module_version(module: ModuleType) -> str: + """Build a content-addressed version tag for the module that produced a result. + + Hashes ``module``'s own source together with :mod:`.contracts`'s, since + every model in this package depends on ``contracts.py``'s input + discipline -- a change there changes what produced an artifact even if + ``module``'s own text is untouched. Content-addressed rather than a + hand-maintained string because no ``__version__`` exists anywhere in + :mod:`src.quantlib` to defer to, and a hand-maintained tag goes stale + exactly when it matters most (a formula changes but the tag does not). + + Args: + module: The model module (:mod:`dcf`, :mod:`comps` or + :mod:`threestatement`). + + Returns: + ``"@sha256:<16 hex chars>"``. + """ + own_source = Path(module.__file__).read_bytes() + shared_source = Path(_contracts_module.__file__).read_bytes() + digest = hashlib.sha256(own_source + b"\x00" + shared_source).hexdigest()[:16] + return f"{module.__name__}@sha256:{digest}" + + +# --------------------------------------------------------------------------- +# Gap/exclusion extraction -- the "ugly parts" every export must carry +# --------------------------------------------------------------------------- + + +def _dcf_gaps(result: DCFResult) -> tuple[str, ...]: + """Collect DCF-specific notes: GDP-ceiling flag and the non-chosen terminal method. + + Args: + result: A completed :class:`DCFResult`. + + Returns: + Human-readable notes, possibly empty. + """ + notes: list[str] = [] + tv = result.terminal_value + if tv.growth_exceeds_gdp_ceiling: + notes.append( + f"terminal_growth={tv.terminal_growth.value!r} exceeds the " + f"gdp_growth_ceiling={tv.gdp_growth_ceiling!r} (a flag, not a " + "rejection) -- review whether a perpetuity growth rate this high " + "is defensible for this business." + ) + other_method = ( + "exit_multiple" if result.terminal_value_method == "perpetuity_growth" else "perpetuity_growth" + ) + notes.append( + f"terminal_value_method={result.terminal_value_method!r} was discounted " + f"into enterprise_value; the other method ({other_method!r}) was still " + f"computed (perpetuity_terminal_value={tv.perpetuity_terminal_value!r}, " + f"exit_terminal_value={tv.exit_terminal_value!r}, " + f"implied_ev_ebitda_multiple={tv.implied_ev_ebitda_multiple!r}, " + f"implied_perpetuity_growth={tv.implied_perpetuity_growth!r}) and is not " + "silently dropped." + ) + return tuple(notes) + + +def _comps_gaps(result: CompsResult, target: TargetCompany) -> tuple[str, ...]: + """Collect comps-specific notes: excluded multiples and omitted EV-bridge components. + + Args: + result: A completed :class:`CompsResult`. + target: The target company the run was built for -- needed because + the target's own EV-bridge omissions (used inside + ``equity_value_from_enterprise_value`` while walking each + EV-multiple's implied value back to equity) are not retained on + ``CompsResult`` itself. + + Returns: + Human-readable notes, possibly empty. + """ + notes: list[str] = [] + for peer in result.peer_multiples: + if peer.ev_bridge.omitted_components: + notes.append( + f"peer {peer.name!r}: EV bridge omitted " + f"{', '.join(peer.ev_bridge.omitted_components)} (not supplied -- " + "treated as unknown, not zero)." + ) + for excl in peer.exclusions: + notes.append( + f"peer {peer.name!r}: {excl.multiple_name} excluded -- denominator " + f"{excl.denominator_value!r} is not positive." + ) + target_omitted = tuple( + name + for name, value in ( + ("minority_interest", target.minority_interest), + ("preferred_stock", target.preferred_stock), + ("investments_in_associates", target.investments_in_associates), + ) + if value is None + ) + if target_omitted: + notes.append( + f"target {target.name!r}: EV bridge omitted {', '.join(target_omitted)} " + "when walking an implied enterprise value back to implied equity value." + ) + return tuple(notes) + + +def _three_statement_gaps(result: ThreeStatementProjection) -> tuple[str, ...]: + """Collect three-statement notes: non-convergence and revolver plug activity. + + A successfully-returned :class:`ThreeStatementProjection` cannot actually + contain a non-converged period -- :func:`~.threestatement.project_three_statement` + raises :class:`~.threestatement.ConvergenceError` before one is + constructed. The check below is kept anyway (mirroring + :mod:`threestatement`'s own defensive re-checks) so that if this invariant + is ever loosened upstream, an artifact built from the result still + surfaces it rather than silently reporting ``converged=True``. + + Args: + result: A completed :class:`ThreeStatementProjection`. + + Returns: + Human-readable notes, possibly empty. + """ + notes: list[str] = [] + for period in result.periods: + if not period.converged: + notes.append( + f"period {period.period}: interest/revolver circularity DID NOT " + f"CONVERGE after {period.iterations} iteration(s) -- treat this " + "period's financing line items as unresolved." + ) + cfs = period.cash_flow_statement + if cfs.revolver_draw > 0.0: + notes.append( + f"period {period.period}: revolver drew {cfs.revolver_draw!r} to " + "hold cash at minimum_cash -- financing is not organic." + ) + if cfs.revolver_repayment > 0.0: + notes.append( + f"period {period.period}: revolver repaid {cfs.revolver_repayment!r} " + "from surplus cash." + ) + return tuple(notes) + + +# --------------------------------------------------------------------------- +# Builders +# --------------------------------------------------------------------------- + + +def build_dcf_artifact( + *, + inputs: Mapping[str, Any], + result: DCFResult, + generated_at: datetime, + capital_structure_basis: str, + discounting_convention: str, + terminal_value_method: str, + gdp_growth_ceiling: float = DEFAULT_LONG_RUN_GDP_GROWTH_CEILING, +) -> ModelArtifact: + """Wrap a completed :func:`~.dcf.run_dcf` call into a versioned artifact. + + ``capital_structure_basis``, ``discounting_convention``, + ``terminal_value_method`` and ``gdp_growth_ceiling`` are hashed alongside + ``inputs`` because each one changes ``result`` just as much as a number in + ``inputs`` would -- an artifact whose hash ignored them could report + "inputs unchanged" for two runs that priced the same cash flows two + different ways. + + Assumptions are collected from ``result`` (via ``result.terminal_value``), + not from ``inputs`` directly -- :func:`~.dcf.run_dcf` passes the same + :class:`~.contracts.Assumption` objects through unchanged, so walking the + result finds them without walking the raw inputs a second time. + + Args: + inputs: The exact mapping passed as ``run_dcf(inputs, ...)``. + result: The :class:`DCFResult` that call produced. + generated_at: Timezone-aware timestamp of the run, supplied by the + caller. See :func:`_require_generated_at`. + capital_structure_basis: The ``capital_structure_basis`` the call used. + discounting_convention: The ``discounting_convention`` the call used. + terminal_value_method: The ``terminal_value_method`` the call used. + gdp_growth_ceiling: The ``gdp_growth_ceiling`` the call used. + + Returns: + A ``model_name="dcf"`` :class:`ModelArtifact`. + + Raises: + TypeError: If ``generated_at`` is not a ``datetime``, or ``result`` + is not a :class:`DCFResult`. + ValueError: If ``generated_at`` is timezone-naive. + """ + generated_at = _require_generated_at(generated_at, "build_dcf_artifact") + if not isinstance(result, DCFResult): + raise TypeError( + f"build_dcf_artifact: result must be a DCFResult, got {type(result).__name__}" + ) + + canonical_inputs = { + "config": { + "capital_structure_basis": capital_structure_basis, + "discounting_convention": discounting_convention, + "terminal_value_method": terminal_value_method, + "gdp_growth_ceiling": gdp_growth_ceiling, + }, + "model_inputs": dict(inputs), + } + hash_leaves: dict[str, str] = {} + readable_inputs: dict[str, Any] = {} + _flatten(canonical_inputs, "$", hash_leaves, readable_inputs, None) + input_hash = _hash_leaves(hash_leaves) + + readable_outputs: dict[str, Any] = {} + assumptions: list[AssumptionRecord] = [] + _flatten(result, "$", {}, readable_outputs, assumptions) + + return ModelArtifact( + model_name="dcf", + model_version=_module_version(_dcf_module), + schema_version=_SCHEMA_VERSION, + generated_at=generated_at, + input_hash=input_hash, + inputs=MappingProxyType(readable_inputs), + outputs=MappingProxyType(readable_outputs), + assumptions=tuple(assumptions), + result=result, + excluded=_dcf_gaps(result), + warnings=(), + ) + + +def build_comps_artifact( + *, + target: TargetCompany, + peers: Sequence[PeerCompany], + calendarisation_policy: str, + result: CompsResult, + generated_at: datetime, +) -> ModelArtifact: + """Wrap a completed :func:`~.comps.run_comps` call into a versioned artifact. + + :mod:`comps` takes no :class:`~.contracts.Assumption` objects anywhere in + its required inputs -- every field on :class:`TargetCompany` and + :class:`PeerCompany` is a bare number or a + :class:`~.comps.FlowMetricPeriods`. The resulting artifact's + ``assumptions`` is therefore always ``()``; that is a fact about the + model, not a gap in this builder, and every export says so explicitly + rather than leaving the section blank. + + Args: + target: The :class:`TargetCompany` passed to ``run_comps``. + peers: The peer sequence passed to ``run_comps``. + calendarisation_policy: The ``calendarisation_policy`` the call used. + result: The :class:`CompsResult` that call produced. + generated_at: Timezone-aware timestamp of the run, supplied by the + caller. + + Returns: + A ``model_name="comps"`` :class:`ModelArtifact`. + + Raises: + TypeError: If ``generated_at`` is not a ``datetime``, or ``result`` + is not a :class:`CompsResult`. + ValueError: If ``generated_at`` is timezone-naive. + """ + generated_at = _require_generated_at(generated_at, "build_comps_artifact") + if not isinstance(result, CompsResult): + raise TypeError( + f"build_comps_artifact: result must be a CompsResult, got {type(result).__name__}" + ) + + canonical_inputs = { + "config": {"calendarisation_policy": calendarisation_policy}, + "target": target, + "peers": list(peers), + } + hash_leaves: dict[str, str] = {} + readable_inputs: dict[str, Any] = {} + _flatten(canonical_inputs, "$", hash_leaves, readable_inputs, None) + input_hash = _hash_leaves(hash_leaves) + + readable_outputs: dict[str, Any] = {} + assumptions: list[AssumptionRecord] = [] + _flatten(result, "$", {}, readable_outputs, assumptions) + + return ModelArtifact( + model_name="comps", + model_version=_module_version(_comps_module), + schema_version=_SCHEMA_VERSION, + generated_at=generated_at, + input_hash=input_hash, + inputs=MappingProxyType(readable_inputs), + outputs=MappingProxyType(readable_outputs), + assumptions=tuple(assumptions), + result=result, + excluded=_comps_gaps(result, target), + warnings=result.warnings, + ) + + +def build_three_statement_artifact( + *, + opening: Mapping[str, float], + drivers: Mapping[str, Sequence[float]], + result: ThreeStatementProjection, + generated_at: datetime, + circularity_tolerance: float = CIRCULARITY_TOLERANCE_REL, + max_circularity_iterations: int = MAX_CIRCULARITY_ITERATIONS, + balance_tolerance: float = BALANCE_TOLERANCE_REL, +) -> ModelArtifact: + """Wrap a completed :func:`~.threestatement.project_three_statement` call into an artifact. + + The three tolerance/iteration-budget parameters default to the same + constants :func:`~.threestatement.project_three_statement` itself + defaults to -- these are numerical-solver knobs, not valuation opinions, + so mirroring the model's own defaults keeps the hash consistent for a + caller who did not override them, without this module inventing anything + :mod:`.contracts` would refuse (a terminal growth rate or similar + price-determining assumption is never defaulted anywhere in this file). + + Like :mod:`comps`, :mod:`threestatement` takes no + :class:`~.contracts.Assumption` objects -- every driver is a bare number. + ``assumptions`` on the returned artifact is therefore always ``()``. + + Args: + opening: The exact mapping passed as ``project_three_statement(opening, ...)``. + drivers: The exact mapping passed as the ``drivers`` argument. + result: The :class:`ThreeStatementProjection` that call produced. + generated_at: Timezone-aware timestamp of the run, supplied by the + caller. + circularity_tolerance: The ``circularity_tolerance`` the call used. + max_circularity_iterations: The ``max_circularity_iterations`` the + call used. + balance_tolerance: The ``balance_tolerance`` the call used. + + Returns: + A ``model_name="three_statement"`` :class:`ModelArtifact`. + + Raises: + TypeError: If ``generated_at`` is not a ``datetime``, or ``result`` + is not a :class:`ThreeStatementProjection`. + ValueError: If ``generated_at`` is timezone-naive. + """ + generated_at = _require_generated_at(generated_at, "build_three_statement_artifact") + if not isinstance(result, ThreeStatementProjection): + raise TypeError( + "build_three_statement_artifact: result must be a " + f"ThreeStatementProjection, got {type(result).__name__}" + ) + + canonical_inputs = { + "config": { + "circularity_tolerance": circularity_tolerance, + "max_circularity_iterations": max_circularity_iterations, + "balance_tolerance": balance_tolerance, + }, + "opening": dict(opening), + "drivers": {key: list(value) for key, value in drivers.items()}, + } + hash_leaves: dict[str, str] = {} + readable_inputs: dict[str, Any] = {} + _flatten(canonical_inputs, "$", hash_leaves, readable_inputs, None) + input_hash = _hash_leaves(hash_leaves) + + readable_outputs: dict[str, Any] = {} + assumptions: list[AssumptionRecord] = [] + _flatten(result, "$", {}, readable_outputs, assumptions) + + return ModelArtifact( + model_name="three_statement", + model_version=_module_version(_threestatement_module), + schema_version=_SCHEMA_VERSION, + generated_at=generated_at, + input_hash=input_hash, + inputs=MappingProxyType(readable_inputs), + outputs=MappingProxyType(readable_outputs), + assumptions=tuple(assumptions), + result=result, + excluded=_three_statement_gaps(result), + warnings=(), + ) + + +# --------------------------------------------------------------------------- +# Diff +# --------------------------------------------------------------------------- + + +def _diff_leaves( + baseline: Mapping[str, Any], updated: Mapping[str, Any] +) -> tuple[tuple[FieldChange, ...], tuple[str, ...], tuple[str, ...]]: + """Diff two flat ``path -> value`` snapshots. + + Args: + baseline: The "before" flat snapshot. + updated: The "after" flat snapshot. + + Returns: + ``(changed, added_paths, removed_paths)`` -- ``changed`` covers only + paths present on both sides whose value differs; ``added_paths`` / + ``removed_paths`` are sorted tuples of paths present on one side only. + """ + baseline_keys = set(baseline.keys()) + updated_keys = set(updated.keys()) + changed: list[FieldChange] = [] + for path in sorted(baseline_keys & updated_keys): + old, new = baseline[path], updated[path] + if old == new: + continue + delta = None + if ( + isinstance(old, (int, float)) + and not isinstance(old, bool) + and isinstance(new, (int, float)) + and not isinstance(new, bool) + ): + delta = float(new) - float(old) + changed.append(FieldChange(path=path, old=old, new=new, delta=delta)) + added = tuple(sorted(updated_keys - baseline_keys)) + removed = tuple(sorted(baseline_keys - updated_keys)) + return tuple(changed), added, removed + + +def diff_artifacts(baseline: ModelArtifact, updated: ModelArtifact) -> ArtifactDiff: + """Diff two artifacts of the same model: which fields changed, by how much. + + Args: + baseline: The "before" artifact. + updated: The "after" artifact. Must share ``baseline.model_name``. + + Returns: + An :class:`ArtifactDiff` naming every changed/added/removed input and + output path. + + Raises: + ValueError: If ``baseline.model_name != updated.model_name`` -- the + two artifacts would have no comparable field paths. + """ + if baseline.model_name != updated.model_name: + raise ValueError( + f"diff_artifacts: cannot diff a {baseline.model_name!r} artifact " + f"against a {updated.model_name!r} artifact -- they have no " + "comparable field paths" + ) + changed_inputs, added_inputs, removed_inputs = _diff_leaves(baseline.inputs, updated.inputs) + changed_outputs, added_outputs, removed_outputs = _diff_leaves(baseline.outputs, updated.outputs) + return ArtifactDiff( + model_name=baseline.model_name, + baseline_generated_at=baseline.generated_at, + updated_generated_at=updated.generated_at, + baseline_input_hash=baseline.input_hash, + updated_input_hash=updated.input_hash, + input_hash_changed=baseline.input_hash != updated.input_hash, + baseline_model_version=baseline.model_version, + updated_model_version=updated.model_version, + model_version_changed=baseline.model_version != updated.model_version, + changed_inputs=changed_inputs, + added_input_paths=added_inputs, + removed_input_paths=removed_inputs, + changed_outputs=changed_outputs, + added_output_paths=added_outputs, + removed_output_paths=removed_outputs, + ) + + +# --------------------------------------------------------------------------- +# xlsx export -- shared helpers +# --------------------------------------------------------------------------- + + +def _bold_row(ws: Worksheet, row: int = 1) -> None: + """Bold every cell in one worksheet row (typically a header row).""" + for cell in ws[row]: + cell.font = Font(bold=True) + + +def _write_assumptions_and_gaps_sheet(wb: Workbook, artifact: ModelArtifact) -> None: + """Write the "Assumptions & Data Quality" sheet shared by every xlsx export. + + Layout (rows, 1-indexed): + 1: ``["Provenance"]`` + 2-6: ``model_name`` / ``model_version`` / ``schema_version`` / + ``generated_at`` / ``input_hash``, each as ``[field, value]`` + 7: blank + 8: ``["Assumptions", "name", "value", "basis", "source"]`` header + 9..: one row per :class:`AssumptionRecord`, or a single + ``"(none) -- ..."`` row if there are none + blank, ``["Warnings"]``, one row per warning (or ``"(none)"``) + blank, ``["Excluded / Omitted / Data-quality notes"]``, one row per + note in ``artifact.excluded`` (or ``"(none)"``) + + Args: + wb: The workbook to add the sheet to. + artifact: The artifact whose provenance/assumptions/gaps to write. + """ + ws = wb.create_sheet("Assumptions & Data Quality") + ws.append(["Provenance"]) + ws.append(["model_name", artifact.model_name]) + ws.append(["model_version", artifact.model_version]) + ws.append(["schema_version", artifact.schema_version]) + ws.append(["generated_at", artifact.generated_at.isoformat()]) + ws.append(["input_hash", artifact.input_hash]) + ws.append([]) + ws.append(["Assumptions", "name", "value", "basis", "source"]) + if artifact.assumptions: + for record in artifact.assumptions: + a = record.assumption + ws.append([record.path, a.name, a.value, a.basis, a.source or ""]) + else: + ws.append(["(none) -- this model requires no caller-supplied Assumption objects"]) + ws.append([]) + ws.append(["Warnings"]) + for warning in artifact.warnings: + ws.append([warning]) + if not artifact.warnings: + ws.append(["(none)"]) + ws.append([]) + ws.append(["Excluded / Omitted / Data-quality notes"]) + for note in artifact.excluded: + ws.append([note]) + if not artifact.excluded: + ws.append(["(none)"]) + + _bold_row(ws, 1) + _bold_row(ws, 8) + + +# --------------------------------------------------------------------------- +# xlsx export -- DCF +# --------------------------------------------------------------------------- + +_FCFF_HEADERS: tuple[str, ...] = ( + "Year", "EBIT", "Tax Rate", "NOPAT", "D&A", "CapEx", "Delta NWC", + "FCFF", "Discount Factor", "Present Value", +) + +_WACC_FIELDS: tuple[str, ...] = ( + "risk_free_rate", "beta", "equity_risk_premium", "size_premium", + "country_risk_premium", "cost_of_equity", "pretax_cost_of_debt", "tax_rate", + "cost_of_debt_after_tax", "capital_structure_basis", "equity_weight", + "debt_weight", "wacc", +) + + +def _write_fcff_bridge_sheet(wb: Workbook, result: DCFResult) -> None: + """Write the "FCFF Bridge" sheet: one row per year plus a PV total row.""" + ws = wb.create_sheet("FCFF Bridge") + ws.append(list(_FCFF_HEADERS)) + for year, discounted in zip(result.fcff_bridge, result.discounted_fcff, strict=True): + ws.append( + [ + year.year, year.ebit, year.tax_rate, year.nopat, + year.depreciation_amortization, year.capex, year.delta_nwc, year.fcff, + discounted.discount_factor, discounted.present_value, + ] + ) + ws.append( + ["Total", None, None, None, None, None, None, None, + "PV of explicit FCFF", result.pv_of_explicit_fcff] + ) + _bold_row(ws) + + +def _write_wacc_sheet(wb: Workbook, result: DCFResult) -> None: + """Write the "WACC Build" sheet as ``field -> value`` rows.""" + ws = wb.create_sheet("WACC Build") + ws.append(["Field", "Value"]) + for field in _WACC_FIELDS: + ws.append([field, getattr(result.wacc_build, field)]) + _bold_row(ws) + + +def _write_terminal_value_sheet(wb: Workbook, result: DCFResult) -> None: + """Write the "Terminal Value" sheet: both estimates, cross-checks, the full bridge.""" + ws = wb.create_sheet("Terminal Value") + ws.append(["Field", "Value"]) + tv = result.terminal_value + ndb = result.net_debt_bridge + rows: tuple[tuple[str, Any], ...] = ( + ("wacc_rate", tv.wacc_rate), + ("terminal_growth.value", tv.terminal_growth.value), + ("terminal_growth.basis", tv.terminal_growth.basis), + ("terminal_growth.source", tv.terminal_growth.source or ""), + ("perpetuity_terminal_value", tv.perpetuity_terminal_value), + ("terminal_year_ebitda", tv.terminal_year_ebitda), + ("exit_multiple.value", tv.exit_multiple.value), + ("exit_multiple.basis", tv.exit_multiple.basis), + ("exit_multiple.source", tv.exit_multiple.source or ""), + ("exit_terminal_value", tv.exit_terminal_value), + ("implied_ev_ebitda_multiple", tv.implied_ev_ebitda_multiple), + ("implied_perpetuity_growth", tv.implied_perpetuity_growth), + ("gdp_growth_ceiling", tv.gdp_growth_ceiling), + ("growth_exceeds_gdp_ceiling", tv.growth_exceeds_gdp_ceiling), + ("terminal_value_method (chosen)", result.terminal_value_method), + ("discounting_convention", result.discounting_convention), + ("terminal_discount_factor", result.terminal_discount_factor), + ("pv_of_explicit_fcff", result.pv_of_explicit_fcff), + ("pv_of_terminal_value", result.pv_of_terminal_value), + ("enterprise_value", result.enterprise_value), + ("net_debt_bridge.total_debt", ndb.total_debt), + ("net_debt_bridge.cash_and_equivalents", ndb.cash_and_equivalents), + ("net_debt_bridge.minority_interest", ndb.minority_interest), + ("net_debt_bridge.preferred_equity", ndb.preferred_equity), + ("net_debt_bridge.associate_investments", ndb.associate_investments), + ("equity_value", result.equity_value), + ("net_debt_bridge.diluted_shares", ndb.diluted_shares), + ("value_per_share", result.value_per_share), + ) + for name, value in rows: + ws.append([name, value]) + _bold_row(ws) + + +def _write_sensitivity_sheet(wb: Workbook, grid: pd.DataFrame) -> None: + """Write the "Sensitivity" sheet from a wacc x growth value-per-share grid.""" + ws = wb.create_sheet("Sensitivity") + header_label = f"{grid.index.name} \\ {grid.columns.name}" + ws.append([header_label] + [float(col) for col in grid.columns]) + for wacc_value, row in grid.iterrows(): + cells = [None if pd.isna(v) else float(v) for v in row] + ws.append([float(wacc_value)] + cells) + _bold_row(ws) + + +def export_dcf_workbook( + artifact: ModelArtifact, + path: str | Path, + *, + sensitivity_grid: pd.DataFrame, +) -> Path: + """Export a DCF artifact to a 5-sheet workbook. + + Sheets, in order: "FCFF Bridge", "WACC Build", "Terminal Value" (both + terminal-value estimates cross-checked, and the full net-debt bridge to + value per share), "Sensitivity" (the caller-supplied grid), "Assumptions + & Data Quality" (see :func:`_write_assumptions_and_gaps_sheet`). + + Args: + artifact: A ``model_name="dcf"`` artifact from :func:`build_dcf_artifact`. + path: Output ``.xlsx`` path. Parent directories are created if absent. + sensitivity_grid: A wacc x growth value-per-share grid, e.g. from + :func:`src.quantlib.valuation.dcf.sensitivity_grid`. Required -- + this function does not choose a WACC/growth sweep range on the + caller's behalf; see the module docstring. + + Returns: + The written path. + + Raises: + TypeError: If ``artifact.model_name != "dcf"``. + """ + if artifact.model_name != "dcf": + raise TypeError( + f"export_dcf_workbook: expected a 'dcf' artifact, got {artifact.model_name!r}" + ) + result: DCFResult = artifact.result + + wb = Workbook() + wb.remove(wb.active) + _write_fcff_bridge_sheet(wb, result) + _write_wacc_sheet(wb, result) + _write_terminal_value_sheet(wb, result) + _write_sensitivity_sheet(wb, sensitivity_grid) + _write_assumptions_and_gaps_sheet(wb, artifact) + + out_path = Path(path) + out_path.parent.mkdir(parents=True, exist_ok=True) + wb.save(str(out_path)) + return out_path + + +# --------------------------------------------------------------------------- +# xlsx export -- comps +# --------------------------------------------------------------------------- + +_PEER_HEADERS: tuple[str, ...] = ( + "name", "equity_value", "total_debt", "cash_and_equivalents", "enterprise_value", + "omitted_ev_bridge_components", "ebitda", "ebit", "revenue", "diluted_eps", + "ev_ebitda", "ev_ebit", "ev_sales", "pe", "pb", +) + + +def _write_peer_detail_sheet(wb: Workbook, result: CompsResult) -> None: + """Write the "Peer Detail" sheet: one row per peer's bridge, metrics and multiples.""" + ws = wb.create_sheet("Peer Detail") + ws.append(list(_PEER_HEADERS)) + for peer in result.peer_multiples: + ws.append( + [ + peer.name, + peer.ev_bridge.equity_value, + peer.ev_bridge.total_debt, + peer.ev_bridge.cash_and_equivalents, + peer.ev_bridge.enterprise_value, + ", ".join(peer.ev_bridge.omitted_components) or "(none)", + peer.calendarised_ebitda.value, + peer.calendarised_ebit.value, + peer.calendarised_revenue.value, + peer.calendarised_diluted_eps.value, + peer.ev_ebitda if peer.ev_ebitda is not None else "excluded", + peer.ev_ebit if peer.ev_ebit is not None else "excluded", + peer.ev_sales if peer.ev_sales is not None else "excluded", + peer.pe if peer.pe is not None else "excluded", + peer.pb if peer.pb is not None else "excluded", + ] + ) + _bold_row(ws) + + +def _write_multiple_matrix_sheet(wb: Workbook, result: CompsResult) -> None: + """Write the "Multiple Matrix" sheet: cross-sectional stats plus excluded-peer detail.""" + ws = wb.create_sheet("Multiple Matrix") + ws.append(["multiple", "min", "p25", "median", "p75", "max", "n_included", "n_excluded", "warning"]) + for name in MULTIPLE_NAMES: + dist = result.distributions[name] + ws.append( + [ + name, dist.minimum, dist.p25, dist.median, dist.p75, dist.maximum, + len(dist.included), len(dist.excluded), dist.warning or "", + ] + ) + ws.append([]) + ws.append(["Excluded detail: peer", "multiple", "denominator_value"]) + for peer in result.peer_multiples: + for excl in peer.exclusions: + ws.append([excl.peer_name, excl.multiple_name, excl.denominator_value]) + _bold_row(ws, 1) + + +_QUANTILE_LABELS: tuple[str, ...] = ("minimum", "p25", "median", "p75", "maximum") + + +def _write_implied_valuation_sheet(wb: Workbook, result: CompsResult) -> None: + """Write the "Implied Valuation" sheet: target metric x peer quantiles, EV and equity.""" + ws = wb.create_sheet("Implied Valuation") + ws.append( + [ + "multiple", "is_ev_multiple", "target_metric_value", + "implied_ev_min", "implied_ev_p25", "implied_ev_median", "implied_ev_p75", "implied_ev_max", + "implied_equity_min", "implied_equity_p25", "implied_equity_median", + "implied_equity_p75", "implied_equity_max", "warning", + ] + ) + for name in MULTIPLE_NAMES: + iv = result.implied_valuations[name] + ev_map = iv.implied_ev_by_quantile + eq_map = iv.implied_equity_value_by_quantile + ev_cells = [ev_map.get(label) if ev_map else None for label in _QUANTILE_LABELS] + eq_cells = [eq_map.get(label) if eq_map else None for label in _QUANTILE_LABELS] + ws.append([name, iv.is_ev_multiple, iv.target_metric_value, *ev_cells, *eq_cells, iv.warning or ""]) + _bold_row(ws) + + +def export_comps_workbook(artifact: ModelArtifact, path: str | Path) -> Path: + """Export a comps artifact to a 4-sheet workbook. + + Sheets, in order: "Peer Detail", "Multiple Matrix" (including the + excluded-peer detail block), "Implied Valuation", "Assumptions & Data + Quality" (see :func:`_write_assumptions_and_gaps_sheet` -- for comps this + sheet's assumptions block always reads "(none)"; see + :func:`build_comps_artifact`). + + Args: + artifact: A ``model_name="comps"`` artifact from :func:`build_comps_artifact`. + path: Output ``.xlsx`` path. Parent directories are created if absent. + + Returns: + The written path. + + Raises: + TypeError: If ``artifact.model_name != "comps"``. + """ + if artifact.model_name != "comps": + raise TypeError( + f"export_comps_workbook: expected a 'comps' artifact, got {artifact.model_name!r}" + ) + result: CompsResult = artifact.result + + wb = Workbook() + wb.remove(wb.active) + _write_peer_detail_sheet(wb, result) + _write_multiple_matrix_sheet(wb, result) + _write_implied_valuation_sheet(wb, result) + _write_assumptions_and_gaps_sheet(wb, artifact) + + out_path = Path(path) + out_path.parent.mkdir(parents=True, exist_ok=True) + wb.save(str(out_path)) + return out_path + + +# --------------------------------------------------------------------------- +# xlsx export -- three-statement +# --------------------------------------------------------------------------- + +_INCOME_HEADERS: tuple[str, ...] = ( + "period", "revenue", "gross_profit", "opex", "ebitda", + "depreciation_amortization", "ebit", "interest_expense", "pretax_income", + "tax_expense", "net_income", +) + +_CASHFLOW_HEADERS: tuple[str, ...] = ( + "period", "net_income", "depreciation_amortization", + "change_in_net_working_capital", "capex", "dividends_paid", "revolver_draw", + "revolver_repayment", "financing_activities", "net_change_in_cash", + "cash_flow_from_operations", "free_cash_flow", "converged", "iterations", +) + +_BALANCE_HEADERS: tuple[str, ...] = ( + "period", "cash", "net_working_capital", "ppe", "revolver_balance", + "paid_in_capital", "retained_earnings", "total_assets", "total_liabilities", + "total_equity", "residual", +) + + +def _write_income_statement_sheet(wb: Workbook, projection: ThreeStatementProjection) -> None: + """Write the "Income Statement" sheet: one row per projected period.""" + ws = wb.create_sheet("Income Statement") + ws.append(list(_INCOME_HEADERS)) + for period in projection.periods: + s = period.income_statement + ws.append( + [ + s.period, s.revenue, s.gross_profit, s.opex, s.ebitda, + s.depreciation_amortization, s.ebit, s.interest_expense, + s.pretax_income, s.tax_expense, s.net_income, + ] + ) + _bold_row(ws) + + +def _write_cash_flow_sheet(wb: Workbook, projection: ThreeStatementProjection) -> None: + """Write the "Cash Flow Statement" sheet, incl. the convergence diagnostics.""" + ws = wb.create_sheet("Cash Flow Statement") + ws.append(list(_CASHFLOW_HEADERS)) + for period in projection.periods: + c = period.cash_flow_statement + ws.append( + [ + c.period, c.net_income, c.depreciation_amortization, + c.change_in_net_working_capital, c.capex, c.dividends_paid, + c.revolver_draw, c.revolver_repayment, c.financing_activities, + c.net_change_in_cash, c.cash_flow_from_operations, c.free_cash_flow, + period.converged, period.iterations, + ] + ) + _bold_row(ws) + + +def _write_balance_sheet_sheet(wb: Workbook, projection: ThreeStatementProjection) -> None: + """Write the "Balance Sheet" sheet, opening plus every period, with the balance residual.""" + ws = wb.create_sheet("Balance Sheet") + ws.append(list(_BALANCE_HEADERS)) + all_sheets = (projection.opening_balance_sheet, *(p.balance_sheet for p in projection.periods)) + for bs in all_sheets: + residual = bs.total_assets - bs.total_liabilities - bs.total_equity + ws.append( + [ + bs.period, bs.cash, bs.net_working_capital, bs.ppe, bs.revolver_balance, + bs.paid_in_capital, bs.retained_earnings, bs.total_assets, + bs.total_liabilities, bs.total_equity, residual, + ] + ) + _bold_row(ws) + + +def export_three_statement_workbook(artifact: ModelArtifact, path: str | Path) -> Path: + """Export a three-statement artifact to a 4-sheet workbook. + + Sheets, in order: "Income Statement", "Cash Flow Statement" (including + the per-period ``converged``/``iterations`` circularity diagnostics), + "Balance Sheet" (including a ``residual`` column, opening balance sheet + plus every period), "Assumptions & Data Quality" (see + :func:`_write_assumptions_and_gaps_sheet` -- for three-statement this + sheet's assumptions block always reads "(none)"; see + :func:`build_three_statement_artifact`). + + Args: + artifact: A ``model_name="three_statement"`` artifact from + :func:`build_three_statement_artifact`. + path: Output ``.xlsx`` path. Parent directories are created if absent. + + Returns: + The written path. + + Raises: + TypeError: If ``artifact.model_name != "three_statement"``. + """ + if artifact.model_name != "three_statement": + raise TypeError( + "export_three_statement_workbook: expected a 'three_statement' " + f"artifact, got {artifact.model_name!r}" + ) + projection: ThreeStatementProjection = artifact.result + + wb = Workbook() + wb.remove(wb.active) + _write_income_statement_sheet(wb, projection) + _write_cash_flow_sheet(wb, projection) + _write_balance_sheet_sheet(wb, projection) + _write_assumptions_and_gaps_sheet(wb, artifact) + + out_path = Path(path) + out_path.parent.mkdir(parents=True, exist_ok=True) + wb.save(str(out_path)) + return out_path + + +# --------------------------------------------------------------------------- +# pptx export -- one-slide summary, any model +# --------------------------------------------------------------------------- + + +def _dcf_headline(result: DCFResult) -> list[str]: + """Return the DCF valuation-conclusion bullet lines.""" + return [ + f"Enterprise value: {result.enterprise_value:,.2f}", + f"Equity value: {result.equity_value:,.2f}", + f"Value per share: {result.value_per_share:,.4f}", + f"WACC: {result.wacc_build.wacc:.4%}", + f"Terminal value method used: {result.terminal_value_method}", + ] + + +def _dcf_sensitivity_lines(grid: pd.DataFrame | None) -> list[str]: + """Return the DCF sensitivity bullet lines from a caller-supplied grid, if any.""" + if grid is None: + return ["Sensitivity grid not supplied to this export."] + values = grid.to_numpy(dtype=float) + finite = values[np.isfinite(values)] + if finite.size == 0: + return [ + "Sensitivity grid supplied but every cell is undefined (growth >= " + "WACC across the entire swept range)." + ] + return [ + f"Value-per-share range across the sensitivity grid: " + f"{finite.min():,.4f} to {finite.max():,.4f} " + f"({finite.size} of {values.size} cell(s) defined)" + ] + + +def _comps_headline(result: CompsResult) -> list[str]: + """Return the comps valuation-conclusion bullet lines.""" + lines = [ + f"Target: {result.target_name}", + f"Calendarisation policy: {result.calendarisation_policy}", + f"EPS basis: {result.eps_basis}", + ] + for name in MULTIPLE_NAMES: + iv = result.implied_valuations[name] + if iv.implied_equity_value_by_quantile is None: + lines.append(f"{name}: no implied valuation (distribution empty)") + else: + median = iv.implied_equity_value_by_quantile["median"] + lines.append(f"{name}: implied equity value at the median peer multiple = {median:,.2f}") + return lines + + +def _comps_sensitivity_lines(result: CompsResult) -> list[str]: + """Return the comps sensitivity bullet lines: each multiple's peer spread.""" + lines: list[str] = [] + for name in MULTIPLE_NAMES: + dist = result.distributions[name] + if dist.median is None: + lines.append(f"{name}: no usable distribution ({dist.warning})") + continue + lines.append( + f"{name}: peer multiple min {dist.minimum:.2f} / median {dist.median:.2f} / " + f"max {dist.maximum:.2f}" + ) + return lines + + +def _three_statement_headline(result: ThreeStatementProjection) -> list[str]: + """Return the three-statement conclusion bullet lines: the final projected period.""" + last = result.periods[-1] + return [ + f"Projection horizon: {len(result.periods)} period(s)", + f"Final period revenue: {last.income_statement.revenue:,.2f}", + f"Final period net income: {last.income_statement.net_income:,.2f}", + f"Final period ending cash: {last.balance_sheet.cash:,.2f}", + f"Final period ending revolver balance: {last.balance_sheet.revolver_balance:,.2f}", + ] + + +def _assumption_lines(artifact: ModelArtifact) -> list[str]: + """Return the "Key Assumptions" bullet lines, or an honest empty-state line.""" + if not artifact.assumptions: + return ["No caller-supplied Assumption objects were required by this model."] + return [ + f"{record.assumption.name} = {record.assumption.value!r} ({record.assumption.basis})" + for record in artifact.assumptions + ] + + +def _gap_lines(artifact: ModelArtifact) -> list[str]: + """Return the "Data Quality Notes" bullet lines, or an honest empty-state line.""" + lines = list(artifact.excluded) + list(artifact.warnings) + return lines or ["No exclusions, omissions, or warnings were recorded for this run."] + + +def _write_deck_section(text_frame: TextFrame, heading: str, lines: Sequence[str], *, first: bool = False) -> None: + """Append one heading paragraph plus one bulleted paragraph per line to a text frame.""" + paragraph = text_frame.paragraphs[0] if first else text_frame.add_paragraph() + paragraph.text = heading + paragraph.font.bold = True + paragraph.font.size = Pt(15) + for line in lines: + bullet = text_frame.add_paragraph() + bullet.text = f"• {line}" + bullet.font.size = Pt(11) + bullet.level = 1 + + +def export_summary_deck( + artifact: ModelArtifact, + path: str | Path, + *, + sensitivity_grid: pd.DataFrame | None = None, +) -> Path: + """Export a one-slide valuation summary: conclusion, assumptions, sensitivity, gaps. + + Works for any ``model_name``. The same provenance footer required on the + xlsx exports (input hash, model version, generated-at) is written here + too -- a slide making an unsourced claim is the same failure as an + unsourced spreadsheet cell. + + Args: + artifact: Any :class:`ModelArtifact` (``dcf``, ``comps`` or + ``three_statement``). + path: Output ``.pptx`` path. Parent directories are created if absent. + sensitivity_grid: For a ``dcf`` artifact only, the wacc x growth + value-per-share grid (e.g. from + :func:`src.quantlib.valuation.dcf.sensitivity_grid`). If omitted + on a ``dcf`` artifact, the slide says so explicitly rather than + guessing a range. Ignored for ``comps``/``three_statement``. + + Returns: + The written path. + """ + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + + title_box = slide.shapes.add_textbox(Inches(0.4), Inches(0.3), Inches(9.2), Inches(0.6)) + title_box.text_frame.text = f"{artifact.model_name.upper()} Valuation Summary" + + if artifact.model_name == "dcf": + headline = _dcf_headline(artifact.result) + sensitivity = _dcf_sensitivity_lines(sensitivity_grid) + elif artifact.model_name == "comps": + headline = _comps_headline(artifact.result) + sensitivity = _comps_sensitivity_lines(artifact.result) + else: + headline = _three_statement_headline(artifact.result) + sensitivity = ["Sensitivity analysis is not defined for a three-statement projection."] + + body = slide.shapes.add_textbox(Inches(0.4), Inches(1.0), Inches(9.2), Inches(5.7)) + text_frame = body.text_frame + text_frame.word_wrap = True + _write_deck_section(text_frame, "Valuation Conclusion", headline, first=True) + _write_deck_section(text_frame, "Key Assumptions", _assumption_lines(artifact)) + _write_deck_section(text_frame, "Sensitivity", sensitivity) + _write_deck_section(text_frame, "Data Quality Notes", _gap_lines(artifact)) + + footer = slide.shapes.add_textbox(Inches(0.4), Inches(6.9), Inches(9.2), Inches(0.5)) + footer.text_frame.text = ( + f"input_hash={artifact.input_hash} | model_version={artifact.model_version} | " + f"schema_version={artifact.schema_version} | generated_at={artifact.generated_at.isoformat()}" + ) + + out_path = Path(path) + out_path.parent.mkdir(parents=True, exist_ok=True) + prs.save(str(out_path)) + return out_path diff --git a/agent/src/quantlib/valuation/comps.py b/agent/src/quantlib/valuation/comps.py new file mode 100644 index 00000000..162542f2 --- /dev/null +++ b/agent/src/quantlib/valuation/comps.py @@ -0,0 +1,1177 @@ +"""Comparable-companies (comps) valuation: EV bridge, calendarisation, multiples. + +A comps valuation answers "what is this company worth, given what the market +pays for similar companies" -- by building a multiple (EV/EBITDA, P/E, ...) for +each peer and applying the peer set's distribution to the target's own +financials. Two mistakes make that answer wrong before a single multiple is +even computed, and this module exists to make both of them structurally hard +to commit silently. + +1. THE EV BRIDGE + ----------------- + Enterprise value is not market cap. It is the price a buyer of the WHOLE + operating business would pay, and every EV multiple's numerator must + represent that same whole-business claim or it is not comparable to the + denominator (EBITDA/EBIT/Sales), which is also a whole-business number: + + EV = market_cap + total_debt - cash_and_equivalents + + minority_interest + preferred_stock - investments_in_associates + + Each sign is explained where it is applied (:func:`enterprise_value`), but + the shape is: things that are a SENIOR or OUTSIDE claim on the operating + business are ADDED (debt, minority interest, preferred); things that are + NOT part of what the operating multiple prices are SUBTRACTED (cash sits + outside operations, an equity-method stake's earnings sit below the + operating metrics the multiple's denominator uses). Minority interest and + preferred stock and associate investments are commonly undisclosed or + genuinely zero -- this module refuses to guess which, and reports every + omitted component by name (see ``omitted_components`` on + :class:`EVBridgeResult`) rather than silently treating "not supplied" as + "zero". An EV bridge missing minority interest is systematically biased + LOW, exactly the failure mode the caller must be told about. + +2. FISCAL-CALENDAR ALIGNMENT (CALENDARISATION) + --------------------------------------------- + A peer with a June fiscal year end and a peer with a December fiscal year + end do not report over the same twelve months. Comparing their raw + "latest fiscal year" multiples mixes, e.g., one company's Jul-2024..Jun-2025 + performance against another's Jan-2025..Dec-2025 -- six months of look-ahead + or look-behind bias baked into a number that LOOKS like an apples-to-apples + ratio. Two conventions fix this, and this module implements both, but a + single :func:`run_comps` call uses exactly ONE for every peer and the + target: + + - ``"ltm"`` (last twelve months): ``last_full_fiscal_year + current_ytd - + prior_year_ytd``. This is always trailing twelve months ending at + whatever "as of" date the YTD figures were cut at, REGARDLESS of fiscal + year end -- it needs no month-weighting because the subtraction of + ``prior_year_ytd`` already cancels out the seasonal stub. + - ``"calendar_year"``: blends the fiscal year ending IN the target calendar + year with the one ending in the NEXT calendar year, weighted by what + fraction of the calendar year each covers, under the explicit + simplifying assumption that a fiscal year's flow is spread evenly across + its twelve months (a company disclosing quarterly seasonality could + calendarise more precisely than this module does; this module's + assumption is documented here so nobody mistakes the output for a + seasonality-aware estimate). + + Mixing the two within one run is refused structurally: ``run_comps`` takes + one ``calendarisation_policy`` for the whole call, and a peer whose data + does not carry the fields that policy needs is NOT RUNNABLE (see + :mod:`.contracts`) rather than silently computed from a partial fit. + +3. THE MULTIPLE MATRIX + --------------------- + A multiple whose denominator is at or below zero is not a small number or + a large negative number -- it is not a number at all in the sense a + valuation multiple needs (a company with negative EBITDA is not "worth + negative EBITDA times anything"). Every multiple in this module is dropped + for a peer whenever its OWN denominator is non-positive, and the drop is + reported by name (:class:`ExcludedMultiple`), not silently absorbed into a + distribution that would otherwise look like ordinary peer dispersion. Note + this rule is stated on the DENOMINATOR only, matching the task that + motivated this module; a peer with a non-positive EV but a positive + denominator (a net-cash-rich, low-multiple company) still produces a + negative multiple and is NOT excluded by this rule -- see the note on + :func:`_compute_multiple`. + + Every multiple's cross-sectional distribution is reported as min / p25 / + median / p75 / max. The MEDIAN, not the mean, is this module's headline + statistic and the one used to build the target's implied valuation: a + comps set is small by construction (rarely more than a handful of true + peers), and a single outlier peer can drag a mean far off-center while + barely moving a median. Below :data:`MIN_ROBUST_COMPS` peers, even the + median is flagged with an explicit warning -- a "median" of one or two + points is just those points, not a statistic. + +Import this module directly (``from src.quantlib.valuation.comps import +run_comps``); see :mod:`src.quantlib.valuation` for why submodules are kept +independently importable. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType + +import numpy as np + +from src.quantlib.valuation.contracts import ( + MissingInputError, + ValuationError, + require_inputs, + require_positive, +) + +__all__ = [ + "CALENDARISATION_POLICIES", + "EPS_BASES", + "MULTIPLE_NAMES", + "MIN_ROBUST_COMPS", + "FlowMetricPeriods", + "CalendarisedMetric", + "EVBridgeResult", + "PeerCompany", + "TargetCompany", + "ExcludedMultiple", + "PeerMultiples", + "MultipleDistribution", + "ImpliedValuation", + "CompsResult", + "enterprise_value", + "equity_value_from_enterprise_value", + "calendarise_metric", + "peer_multiple_set", + "multiple_distribution", + "implied_valuation", + "run_comps", +] + +#: The two fiscal-calendar alignment conventions this module implements. A +#: single `run_comps` call uses exactly one for every peer and the target -- +#: see the module docstring's section 2 for why mixing them is refused. +CALENDARISATION_POLICIES: tuple[str, ...] = ("ltm", "calendar_year") + +#: EPS conventions a caller may declare. Not a preference this module has an +#: opinion on -- it exists purely so the basis is an explicit, recorded fact +#: rather than an assumption baked silently into whatever EPS number arrived. +EPS_BASES: tuple[str, ...] = ("gaap", "adjusted") + +#: The five multiples this module computes, in the fixed order used for +#: reporting. The first three price Enterprise Value; the last two price +#: equity value directly and never pass through the EV bridge. +MULTIPLE_NAMES: tuple[str, ...] = ("ev_ebitda", "ev_ebit", "ev_sales", "pe", "pb") + +#: Which of `MULTIPLE_NAMES` have EV (not equity) as their numerator. +_EV_MULTIPLES: frozenset[str] = frozenset({"ev_ebitda", "ev_ebit", "ev_sales"}) + +#: Fewest peers a cross-sectional median needs before it is a statistic +#: rather than an arbitrary pick from 1-2 data points. Below this the +#: distribution is still computed (it is not "wrong"), but every result +#: carries an explicit warning saying so. +MIN_ROBUST_COMPS: int = 3 + +#: Names of the EV bridge's optional line items, in the order they appear in +#: the formula. A caller who does not supply one of these is NOT asserting it +#: is zero -- see `EVBridgeResult.omitted_components`. +_EV_BRIDGE_OPTIONAL_FIELDS: tuple[str, ...] = ( + "minority_interest", + "preferred_stock", + "investments_in_associates", +) + + +@dataclass(frozen=True) +class FlowMetricPeriods: + """Raw fiscal-period figures for one flow metric (EBITDA, EBIT, Sales, ...). + + Which fields beyond `last_full_fiscal_year` are required depends on which + calendarisation policy a run uses -- both need not be populated at once; + see :func:`calendarise_metric`. + + Attributes: + fiscal_year_end_month: Month (1-12) the company's fiscal year ends in. + last_full_fiscal_year: Value for the most recently COMPLETED fiscal + year. Used by both policies: it is the anchor of the LTM formula, + and the "fiscal year ending in the earlier calendar year" leg of + the calendar-year blend. + current_year_to_date: Value for the stub period since the CURRENT + fiscal year began, through the analysis "as of" date. Required + for the `"ltm"` policy. + prior_year_to_date: Value for the SAME stub period one year earlier + -- same start month, same length. Required for the `"ltm"` + policy. + next_full_fiscal_year: Value for the fiscal year immediately AFTER + `last_full_fiscal_year` (ending one calendar year later). + Required for the `"calendar_year"` policy whenever + `fiscal_year_end_month != 12` -- a December fiscal year end needs + no second year, because month-weighting already resolves to 100% + of `last_full_fiscal_year`. + + Raises: + ValuationError: If `fiscal_year_end_month` is not in 1..12. + """ + + fiscal_year_end_month: int + last_full_fiscal_year: float + current_year_to_date: float | None = None + prior_year_to_date: float | None = None + next_full_fiscal_year: float | None = None + + def __post_init__(self) -> None: + if not 1 <= int(self.fiscal_year_end_month) <= 12: + raise ValuationError( + "comps.FlowMetricPeriods: fiscal_year_end_month must be 1-12, " + f"got {self.fiscal_year_end_month!r}" + ) + + +@dataclass(frozen=True) +class CalendarisedMetric: + """One flow metric after fiscal-calendar alignment. + + Attributes: + policy: Which convention produced `value` -- `"ltm"` or + `"calendar_year"`. + value: The aligned metric value. + fiscal_year_end_month: Carried through from the input, for audit. + weights: For `"calendar_year"`, the `(early_fy_weight, + late_fy_weight)` used to blend the two fiscal years. `None` for + `"ltm"`, which has no month-weighting step. + """ + + policy: str + value: float + fiscal_year_end_month: int + weights: tuple[float, float] | None + + +def calendarise_metric( + periods: FlowMetricPeriods, + policy: str, + *, + metric_name: str, + company_name: str, +) -> CalendarisedMetric: + """Align one company's flow metric onto a comparable twelve-month footing. + + See the module docstring, section 2, for why this step exists and why + the two policies are not interchangeable within one comps run. + + Args: + periods: The company's raw fiscal-period figures for this metric. + policy: `"ltm"` or `"calendar_year"`. + metric_name: Name of the metric being aligned (e.g. `"ebitda"`), used + only for error messages. + company_name: Name of the company being aligned, used only for error + messages. + + Returns: + The aligned :class:`CalendarisedMetric`. + + Raises: + ValuationError: If `policy` is not one of `CALENDARISATION_POLICIES`. + MissingInputError: If `periods` lacks a field this policy needs. + """ + if policy not in CALENDARISATION_POLICIES: + raise ValuationError( + f"comps.calendarise_metric: unknown policy {policy!r}, must be " + f"one of {CALENDARISATION_POLICIES}" + ) + model = f"comps.calendarise_metric[{company_name}.{metric_name}:{policy}]" + + if policy == "ltm": + require_inputs( + { + "last_full_fiscal_year": periods.last_full_fiscal_year, + "current_year_to_date": periods.current_year_to_date, + "prior_year_to_date": periods.prior_year_to_date, + }, + ("last_full_fiscal_year", "current_year_to_date", "prior_year_to_date"), + model, + ) + value = ( + periods.last_full_fiscal_year + + periods.current_year_to_date + - periods.prior_year_to_date + ) + return CalendarisedMetric( + policy="ltm", + value=value, + fiscal_year_end_month=periods.fiscal_year_end_month, + weights=None, + ) + + # policy == "calendar_year" + month = periods.fiscal_year_end_month + if month == 12: + require_inputs( + {"last_full_fiscal_year": periods.last_full_fiscal_year}, + ("last_full_fiscal_year",), + model, + ) + return CalendarisedMetric( + policy="calendar_year", + value=periods.last_full_fiscal_year, + fiscal_year_end_month=month, + weights=(1.0, 0.0), + ) + + require_inputs( + { + "last_full_fiscal_year": periods.last_full_fiscal_year, + "next_full_fiscal_year": periods.next_full_fiscal_year, + }, + ("last_full_fiscal_year", "next_full_fiscal_year"), + model, + ) + weight_early = month / 12.0 + weight_late = 1.0 - weight_early + value = weight_early * periods.last_full_fiscal_year + weight_late * periods.next_full_fiscal_year + return CalendarisedMetric( + policy="calendar_year", + value=value, + fiscal_year_end_month=month, + weights=(weight_early, weight_late), + ) + + +@dataclass(frozen=True) +class EVBridgeResult: + """Enterprise-value bridge for one company, walked in one direction. + + Attributes: + direction: `"equity_to_ev"` (a known market cap produces EV) or + `"ev_to_equity"` (a known EV, e.g. one implied by a peer + multiple, produces an implied equity value). Lets a report tell + at a glance which side of the bridge was the input. + equity_value: The equity-side value -- `market_cap` on the forward + direction, the implied equity value on the reverse direction. + enterprise_value: The enterprise-side value -- computed on the + forward direction, supplied as an input on the reverse direction. + total_debt: Debt component used. + cash_and_equivalents: Cash component used. + minority_interest: Component used, or `None` if not supplied. + preferred_stock: Component used, or `None` if not supplied. + investments_in_associates: Component used, or `None` if not supplied. + omitted_components: Names, among `minority_interest`, + `preferred_stock`, `investments_in_associates`, that were NOT + supplied. Non-empty means the bridge value excludes their + (unknown) contribution and is biased in the direction of the + missing sign -- e.g. an omitted `minority_interest` means + `enterprise_value` is understated relative to the true bridge. + """ + + direction: str + equity_value: float + enterprise_value: float + total_debt: float + cash_and_equivalents: float + minority_interest: float | None + preferred_stock: float | None + investments_in_associates: float | None + omitted_components: tuple[str, ...] + + +def _bridge_delta( + total_debt: float, + cash_and_equivalents: float, + minority_interest: float | None, + preferred_stock: float | None, + investments_in_associates: float | None, +) -> tuple[float, tuple[str, ...]]: + """Compute the EV-minus-equity-value delta and which optional items were omitted. + + The delta is added to equity value to reach EV, and subtracted from EV to + reach equity value -- the two public bridge functions differ only in + which side is the known input. + + Returns: + `(delta, omitted_components)`. + """ + omitted: list[str] = [] + total = total_debt - cash_and_equivalents + for name, value in ( + ("minority_interest", minority_interest), + ("preferred_stock", preferred_stock), + ("investments_in_associates", investments_in_associates), + ): + if value is None: + omitted.append(name) + continue + sign = -1.0 if name == "investments_in_associates" else 1.0 + total += sign * value + return total, tuple(omitted) + + +def enterprise_value( + *, + market_cap: float, + total_debt: float, + cash_and_equivalents: float, + minority_interest: float | None = None, + preferred_stock: float | None = None, + investments_in_associates: float | None = None, +) -> EVBridgeResult: + """Bridge a known equity market value to enterprise value. + + EV = market_cap + total_debt - cash_and_equivalents + + minority_interest + preferred_stock - investments_in_associates + + Sign of each term, in the direction FROM equity value TO enterprise value: + + - `total_debt` (+): an acquirer of the whole enterprise assumes its debt + on top of paying for the equity -- debtholders are repaid ahead of + equity, so their claim is an ADDITIONAL cost layered on market cap. + - `cash_and_equivalents` (-): cash on the balance sheet is not part of + the OPERATING business the multiple's denominator (EBITDA/EBIT/Sales) + measures -- it funds part of the purchase price (an acquirer nets it + against assumed debt, or simply pockets it), so it is subtracted back + out of what the operating multiple is pricing. + - `minority_interest` (+): a majority-owned, consolidated subsidiary + contributes 100% of its EBITDA/EBIT/revenue to the parent's + consolidated figures (consolidation accounting backs the minority + holders' share out only at the net-income line, not the operating + lines). EV must therefore also represent 100% of that subsidiary's + value -- including the minority claim -- or EV and the operating + metric price different slices of the same business. + - `preferred_stock` (+): preferred shares rank senior to common equity + but junior to debt. `market_cap` prices only the common shares, so the + preferred claim is an additional cost an acquirer of the whole + enterprise must also cover, exactly like debt. + - `investments_in_associates` (-): an equity-method stake (typically + 20-50% owned, not consolidated) contributes only a single "equity in + earnings" line BELOW the operating metrics -- its own revenue/EBITDA/ + EBIT are NOT included in the consolidated numbers the multiple's + denominator uses. Its value is therefore not part of what the + operating multiple prices, and is backed out of EV the same way cash + is. + + `minority_interest`, `preferred_stock` and `investments_in_associates` + are genuinely optional -- many companies have none of one or more of + them. Passing `None` (the default) means "not supplied", NOT "zero": the + bridge still computes (using no contribution from that term), but the + omission is named in `EVBridgeResult.omitted_components` so nobody reads + the result as a complete bridge when it is not. Pass `0.0` explicitly to + assert a confirmed-zero line item. + + Args: + market_cap: Equity market value (share price x shares outstanding). + total_debt: Interest-bearing debt, all maturities. + cash_and_equivalents: Cash and short-term investments. + minority_interest: Book or fair value of minority (non-controlling) + interests in consolidated subsidiaries, or `None` if not + supplied. + preferred_stock: Value of preferred equity outstanding, or `None` if + not supplied. + investments_in_associates: Value of equity-method (non-consolidated) + investments, or `None` if not supplied. + + Returns: + The :class:`EVBridgeResult`, `direction="equity_to_ev"`. + + Raises: + MissingInputError: If `market_cap`, `total_debt` or + `cash_and_equivalents` is absent. + """ + require_inputs( + { + "market_cap": market_cap, + "total_debt": total_debt, + "cash_and_equivalents": cash_and_equivalents, + }, + ("market_cap", "total_debt", "cash_and_equivalents"), + "comps.enterprise_value", + ) + delta, omitted = _bridge_delta( + total_debt, cash_and_equivalents, minority_interest, preferred_stock, investments_in_associates + ) + ev = float(market_cap) + delta + return EVBridgeResult( + direction="equity_to_ev", + equity_value=float(market_cap), + enterprise_value=ev, + total_debt=float(total_debt), + cash_and_equivalents=float(cash_and_equivalents), + minority_interest=minority_interest, + preferred_stock=preferred_stock, + investments_in_associates=investments_in_associates, + omitted_components=omitted, + ) + + +def equity_value_from_enterprise_value( + *, + enterprise_value: float, + total_debt: float, + cash_and_equivalents: float, + minority_interest: float | None = None, + preferred_stock: float | None = None, + investments_in_associates: float | None = None, +) -> EVBridgeResult: + """Walk the EV bridge backward: a known EV to an implied equity value. + + This is the exact inverse of :func:`enterprise_value` -- same signs, same + omission handling -- used to turn a target's IMPLIED enterprise value + (peer multiple x target metric) into an implied equity value, using the + TARGET's own capital structure: + + equity_value = enterprise_value - total_debt + cash_and_equivalents + - minority_interest - preferred_stock + + investments_in_associates + + Args: + enterprise_value: A known or implied enterprise value. + total_debt: Target's interest-bearing debt. + cash_and_equivalents: Target's cash and short-term investments. + minority_interest: Target's minority interest, or `None` if not + supplied. + preferred_stock: Target's preferred equity, or `None` if not + supplied. + investments_in_associates: Target's equity-method investments, or + `None` if not supplied. + + Returns: + The :class:`EVBridgeResult`, `direction="ev_to_equity"`. + + Raises: + MissingInputError: If `enterprise_value`, `total_debt` or + `cash_and_equivalents` is absent. + """ + require_inputs( + { + "enterprise_value": enterprise_value, + "total_debt": total_debt, + "cash_and_equivalents": cash_and_equivalents, + }, + ("enterprise_value", "total_debt", "cash_and_equivalents"), + "comps.equity_value_from_enterprise_value", + ) + delta, omitted = _bridge_delta( + total_debt, cash_and_equivalents, minority_interest, preferred_stock, investments_in_associates + ) + equity = float(enterprise_value) - delta + return EVBridgeResult( + direction="ev_to_equity", + equity_value=equity, + enterprise_value=float(enterprise_value), + total_debt=float(total_debt), + cash_and_equivalents=float(cash_and_equivalents), + minority_interest=minority_interest, + preferred_stock=preferred_stock, + investments_in_associates=investments_in_associates, + omitted_components=omitted, + ) + + +def _require_name(name: str, model: str) -> str: + """Reject a blank company name -- every report keys off it.""" + if not isinstance(name, str) or not name.strip(): + raise ValuationError(f"{model}: name is required and cannot be blank, got {name!r}") + return name + + +def _require_eps_basis(eps_basis: str, model: str) -> str: + """Reject an EPS basis that is not one of the two this module recognises.""" + if eps_basis not in EPS_BASES: + raise ValuationError( + f"{model}: eps_basis must be one of {EPS_BASES}, got {eps_basis!r}" + ) + return eps_basis + + +@dataclass(frozen=True) +class PeerCompany: + """One comparable company's raw inputs for the EV bridge and multiple matrix. + + Attributes: + name: Identifier (ticker or short name), used in every report and + exclusion message for this peer. + market_cap: Equity market value. + total_debt: Interest-bearing debt. + cash_and_equivalents: Cash and short-term investments. + ebitda: Fiscal-period figures for EBITDA. + ebit: Fiscal-period figures for EBIT. + revenue: Fiscal-period figures for revenue/sales. + diluted_eps: Fiscal-period figures for diluted EPS. + price_per_share: Current share price, paired with `diluted_eps` to + form this peer's own P/E. + book_value_of_equity: Latest reported book value of common equity (a + balance-sheet snapshot, not a flow -- it is used as-is, never + calendarised). + eps_basis: `"gaap"` or `"adjusted"` -- must match every other peer's + and the target's basis within one `run_comps` call (see + `run_comps`). + minority_interest: Optional EV-bridge component; `None` means not + supplied (see `enterprise_value`). + preferred_stock: Optional EV-bridge component; `None` means not + supplied. + investments_in_associates: Optional EV-bridge component; `None` + means not supplied. + """ + + name: str + market_cap: float + total_debt: float + cash_and_equivalents: float + ebitda: FlowMetricPeriods + ebit: FlowMetricPeriods + revenue: FlowMetricPeriods + diluted_eps: FlowMetricPeriods + price_per_share: float + book_value_of_equity: float + eps_basis: str + minority_interest: float | None = None + preferred_stock: float | None = None + investments_in_associates: float | None = None + + def __post_init__(self) -> None: + _require_name(self.name, "comps.PeerCompany") + _require_eps_basis(self.eps_basis, "comps.PeerCompany") + + +@dataclass(frozen=True) +class TargetCompany: + """The company being valued: its own fundamentals and capital structure. + + A target does NOT carry `market_cap` or `price_per_share` -- those are + what a comps run estimates, not an input to it. It DOES carry its own + capital structure (`total_debt`, `cash_and_equivalents`, and the three + optional EV-bridge items), because those are needed to walk an EV + multiple's implied enterprise value back down to an implied equity value + via `equity_value_from_enterprise_value`. + + Attributes: + name: Identifier, used in every report for this run. + total_debt: Target's interest-bearing debt. + cash_and_equivalents: Target's cash and short-term investments. + ebitda: Target's fiscal-period figures for EBITDA. + ebit: Target's fiscal-period figures for EBIT. + revenue: Target's fiscal-period figures for revenue/sales. + diluted_eps: Target's fiscal-period figures for diluted EPS. + diluted_shares_outstanding: Target's diluted share count, used with + the calendarised `diluted_eps` to recover an aggregate + "net income to diluted common" figure so a peer P/E (a per-share + ratio) can be applied to produce an aggregate implied equity + value rather than a per-share price. + book_value_of_equity: Target's latest reported book value of common + equity. + eps_basis: `"gaap"` or `"adjusted"` -- must match every peer's basis. + minority_interest: Optional EV-bridge component; `None` means not + supplied. + preferred_stock: Optional EV-bridge component; `None` means not + supplied. + investments_in_associates: Optional EV-bridge component; `None` + means not supplied. + + Raises: + ValuationError: If `diluted_shares_outstanding` is not a finite + positive number, or `eps_basis` is not recognised, or `name` is + blank. + """ + + name: str + total_debt: float + cash_and_equivalents: float + ebitda: FlowMetricPeriods + ebit: FlowMetricPeriods + revenue: FlowMetricPeriods + diluted_eps: FlowMetricPeriods + diluted_shares_outstanding: float + book_value_of_equity: float + eps_basis: str + minority_interest: float | None = None + preferred_stock: float | None = None + investments_in_associates: float | None = None + + def __post_init__(self) -> None: + _require_name(self.name, "comps.TargetCompany") + _require_eps_basis(self.eps_basis, "comps.TargetCompany") + require_positive(self.diluted_shares_outstanding, "diluted_shares_outstanding", "comps.TargetCompany") + + +@dataclass(frozen=True) +class ExcludedMultiple: + """One peer dropped from one multiple's statistics. + + Attributes: + peer_name: Which comp was dropped. + multiple_name: Which multiple (one of `MULTIPLE_NAMES`). + denominator_value: The denominator at the time of exclusion. + numerator_value: The numerator at the time of exclusion. + reason: ``"non_positive_denominator"`` or ``"non_positive_numerator"``. + """ + + peer_name: str + multiple_name: str + denominator_value: float + numerator_value: float = 0.0 + reason: str = "non_positive_denominator" + + +def _compute_multiple( + numerator: float, denominator: float, *, peer_name: str, multiple_name: str +) -> tuple[float | None, ExcludedMultiple | None]: + """Compute one peer's multiple, or explain why it is excluded. + + **Both sides are checked, and that is deliberate.** + + A non-positive DENOMINATOR (a loss-making peer's EBITDA) makes the ratio + not a valuation multiple at all. So does a non-positive NUMERATOR: a + net-cash-rich company can have a negative enterprise value while its EBITDA + is perfectly healthy, and ``EV/EBITDA = -3.2x`` is not a cheap company, it + is a company the multiple cannot describe. Letting either through drags the + median toward a number no peer actually trades at. + + An earlier version guarded the denominator only and documented the gap as + "a one-sided guarantee inherited from the spec". A guarantee a reader has to + read the source to discover is not one; both sides are now excluded and + ``ExcludedMultiple.reason`` says which side triggered it. + + Args: + numerator: Multiple numerator (EV, price, market cap). + denominator: Multiple denominator (EBITDA, EBIT, revenue, EPS, book). + peer_name: For the exclusion record. + multiple_name: For the exclusion record. + + Returns: + ``(value, None)`` when both sides are positive, else + ``(None, ExcludedMultiple(...))`` naming the side that failed. + """ + if denominator <= 0.0: + return None, ExcludedMultiple( + peer_name=peer_name, + multiple_name=multiple_name, + denominator_value=denominator, + numerator_value=numerator, + reason="non_positive_denominator", + ) + if numerator <= 0.0: + return None, ExcludedMultiple( + peer_name=peer_name, + multiple_name=multiple_name, + denominator_value=denominator, + numerator_value=numerator, + reason="non_positive_numerator", + ) + return numerator / denominator, None + + +@dataclass(frozen=True) +class PeerMultiples: + """One peer's EV bridge, calendarised metrics, and full multiple set. + + Attributes: + name: The peer's name. + ev_bridge: This peer's `EVBridgeResult`. + calendarised_ebitda: EBITDA after fiscal-calendar alignment. + calendarised_ebit: EBIT after fiscal-calendar alignment. + calendarised_revenue: Revenue after fiscal-calendar alignment. + calendarised_diluted_eps: Diluted EPS after fiscal-calendar alignment. + ev_ebitda: This peer's EV/EBITDA, or `None` if excluded. + ev_ebit: This peer's EV/EBIT, or `None` if excluded. + ev_sales: This peer's EV/Sales, or `None` if excluded. + pe: This peer's P/E, or `None` if excluded. + pb: This peer's P/B, or `None` if excluded. + exclusions: Every multiple excluded for this peer, and why. + """ + + name: str + ev_bridge: EVBridgeResult + calendarised_ebitda: CalendarisedMetric + calendarised_ebit: CalendarisedMetric + calendarised_revenue: CalendarisedMetric + calendarised_diluted_eps: CalendarisedMetric + ev_ebitda: float | None + ev_ebit: float | None + ev_sales: float | None + pe: float | None + pb: float | None + exclusions: tuple[ExcludedMultiple, ...] + + +def peer_multiple_set(peer: PeerCompany, policy: str) -> PeerMultiples: + """Build one peer's EV bridge, calendarised metrics and full multiple set. + + Args: + peer: The peer's raw inputs. + policy: `"ltm"` or `"calendar_year"` -- see `calendarise_metric`. + + Returns: + The peer's :class:`PeerMultiples`. + + Raises: + MissingInputError: If `peer`'s data lacks a field the chosen policy + needs. + """ + ev_bridge = enterprise_value( + market_cap=peer.market_cap, + total_debt=peer.total_debt, + cash_and_equivalents=peer.cash_and_equivalents, + minority_interest=peer.minority_interest, + preferred_stock=peer.preferred_stock, + investments_in_associates=peer.investments_in_associates, + ) + ebitda = calendarise_metric(peer.ebitda, policy, metric_name="ebitda", company_name=peer.name) + ebit = calendarise_metric(peer.ebit, policy, metric_name="ebit", company_name=peer.name) + revenue = calendarise_metric(peer.revenue, policy, metric_name="revenue", company_name=peer.name) + eps = calendarise_metric(peer.diluted_eps, policy, metric_name="diluted_eps", company_name=peer.name) + + exclusions: list[ExcludedMultiple] = [] + values: dict[str, float | None] = {} + for multiple_name, numerator, denominator in ( + ("ev_ebitda", ev_bridge.enterprise_value, ebitda.value), + ("ev_ebit", ev_bridge.enterprise_value, ebit.value), + ("ev_sales", ev_bridge.enterprise_value, revenue.value), + ("pe", peer.price_per_share, eps.value), + ("pb", peer.market_cap, peer.book_value_of_equity), + ): + value, excluded = _compute_multiple( + numerator, denominator, peer_name=peer.name, multiple_name=multiple_name + ) + values[multiple_name] = value + if excluded is not None: + exclusions.append(excluded) + + return PeerMultiples( + name=peer.name, + ev_bridge=ev_bridge, + calendarised_ebitda=ebitda, + calendarised_ebit=ebit, + calendarised_revenue=revenue, + calendarised_diluted_eps=eps, + ev_ebitda=values["ev_ebitda"], + ev_ebit=values["ev_ebit"], + ev_sales=values["ev_sales"], + pe=values["pe"], + pb=values["pb"], + exclusions=tuple(exclusions), + ) + + +@dataclass(frozen=True) +class MultipleDistribution: + """Cross-sectional distribution of one multiple across the included peers. + + Attributes: + name: Which multiple (one of `MULTIPLE_NAMES`). + included: `{peer_name: value}` for peers whose denominator was + positive. + excluded: Peers dropped from this multiple, and why. An empty tuple + IS the report "nobody was excluded" -- it is always present, not + omitted when empty. + minimum: Smallest included value, or `None` if `included` is empty. + p25: 25th percentile (linear interpolation), or `None`. + median: 50th percentile, or `None`. The headline statistic this + module uses for implied valuation -- see module docstring, + section 3, for why median rather than mean. + p75: 75th percentile, or `None`. + maximum: Largest included value, or `None`. + warning: Set when `included` has fewer than `MIN_ROBUST_COMPS` + peers (including zero), explaining why the statistics above are + not, or cannot be, robust. `None` when the sample is adequate. + """ + + name: str + included: Mapping[str, float] + excluded: tuple[ExcludedMultiple, ...] + minimum: float | None + p25: float | None + median: float | None + p75: float | None + maximum: float | None + warning: str | None + + +def multiple_distribution(name: str, peer_multiples: Sequence[PeerMultiples]) -> MultipleDistribution: + """Build one multiple's cross-sectional distribution across a peer set. + + Args: + name: Which multiple, one of `MULTIPLE_NAMES`. + peer_multiples: Every peer's already-computed `PeerMultiples`. + + Returns: + The :class:`MultipleDistribution` for `name`. + """ + included: dict[str, float] = {} + excluded: list[ExcludedMultiple] = [] + for peer in peer_multiples: + value = getattr(peer, name) + if value is None: + excluded.extend(exc for exc in peer.exclusions if exc.multiple_name == name) + else: + included[peer.name] = value + + n = len(included) + if n == 0: + warning = ( + f"{name}: all {len(peer_multiples)} peer(s) were excluded (non-positive " + "denominator) -- no min/p25/median/p75/max can be computed" + ) + return MultipleDistribution( + name=name, + included=MappingProxyType(included), + excluded=tuple(excluded), + minimum=None, + p25=None, + median=None, + p75=None, + maximum=None, + warning=warning, + ) + + values = np.asarray(sorted(included.values()), dtype=float) + warning = None + if n < MIN_ROBUST_COMPS: + warning = ( + f"{name}: only {n} peer(s) contribute to this distribution (fewer than " + f"{MIN_ROBUST_COMPS}) -- a median of {n} point(s) is just the input(s) " + "themselves, not a robust cross-sectional statistic" + ) + + return MultipleDistribution( + name=name, + included=MappingProxyType(included), + excluded=tuple(excluded), + minimum=float(values[0]), + p25=float(np.percentile(values, 25)), + median=float(np.percentile(values, 50)), + p75=float(np.percentile(values, 75)), + maximum=float(values[-1]), + warning=warning, + ) + + +@dataclass(frozen=True) +class ImpliedValuation: + """The target's implied value range from one multiple's peer distribution. + + Attributes: + multiple_name: Which multiple (one of `MULTIPLE_NAMES`). + is_ev_multiple: Whether this multiple's numerator is enterprise + value (`ev_ebitda`/`ev_ebit`/`ev_sales`) rather than equity value + (`pe`/`pb`). + target_metric_value: The target's own metric multiplied against the + peer distribution -- calendarised EBITDA/EBIT/Revenue for the EV + multiples, book value of equity for `pb`, and calendarised + diluted EPS x diluted shares outstanding (an aggregate + "net income to diluted common" figure) for `pe`. + implied_ev_by_quantile: `{"minimum"/"p25"/"median"/"p75"/"maximum": + implied enterprise value}`, one entry per quantile of the peer + distribution x `target_metric_value`. `None` for `pe`/`pb`, + which imply equity value directly and never pass through an EV + bridge. Also `None` when the source distribution had zero + included peers. + implied_equity_value_by_quantile: Same quantile keys, implied equity + value. For the EV multiples this is `implied_ev_by_quantile` + walked back down through the TARGET's own EV bridge (see + `equity_value_from_enterprise_value`); for `pe`/`pb` it is + `target_metric_value * multiple` directly. `None` when the + source distribution had zero included peers. + warning: Propagated from the underlying `MultipleDistribution`. + """ + + multiple_name: str + is_ev_multiple: bool + target_metric_value: float + implied_ev_by_quantile: Mapping[str, float] | None + implied_equity_value_by_quantile: Mapping[str, float] | None + warning: str | None + + +_QUANTILE_FIELDS: tuple[str, ...] = ("minimum", "p25", "median", "p75", "maximum") + + +def implied_valuation( + multiple_name: str, + distribution: MultipleDistribution, + target: TargetCompany, + calendarised_target: Mapping[str, CalendarisedMetric], +) -> ImpliedValuation: + """Project one multiple's peer distribution onto the target's own metric. + + Args: + multiple_name: Which multiple, one of `MULTIPLE_NAMES`. + distribution: That multiple's peer `MultipleDistribution`. + target: The target company (for capital structure and share count). + calendarised_target: `{"ebitda"/"ebit"/"revenue"/"diluted_eps": + CalendarisedMetric}` -- the target's own metrics, aligned with + the same policy as the peer set. + + Returns: + The target's :class:`ImpliedValuation` for `multiple_name`. + """ + is_ev = multiple_name in _EV_MULTIPLES + + if multiple_name == "pb": + target_metric_value = target.book_value_of_equity + elif multiple_name == "pe": + target_metric_value = ( + calendarised_target["diluted_eps"].value * target.diluted_shares_outstanding + ) + else: + source_key = {"ev_ebitda": "ebitda", "ev_ebit": "ebit", "ev_sales": "revenue"}[multiple_name] + target_metric_value = calendarised_target[source_key].value + + if distribution.median is None: + return ImpliedValuation( + multiple_name=multiple_name, + is_ev_multiple=is_ev, + target_metric_value=target_metric_value, + implied_ev_by_quantile=None, + implied_equity_value_by_quantile=None, + warning=distribution.warning, + ) + + quantiles = {field: getattr(distribution, field) for field in _QUANTILE_FIELDS} + + if is_ev: + implied_ev = {label: multiple * target_metric_value for label, multiple in quantiles.items()} + implied_equity = { + label: equity_value_from_enterprise_value( + enterprise_value=ev, + total_debt=target.total_debt, + cash_and_equivalents=target.cash_and_equivalents, + minority_interest=target.minority_interest, + preferred_stock=target.preferred_stock, + investments_in_associates=target.investments_in_associates, + ).equity_value + for label, ev in implied_ev.items() + } + return ImpliedValuation( + multiple_name=multiple_name, + is_ev_multiple=True, + target_metric_value=target_metric_value, + implied_ev_by_quantile=MappingProxyType(implied_ev), + implied_equity_value_by_quantile=MappingProxyType(implied_equity), + warning=distribution.warning, + ) + + implied_equity = {label: multiple * target_metric_value for label, multiple in quantiles.items()} + return ImpliedValuation( + multiple_name=multiple_name, + is_ev_multiple=False, + target_metric_value=target_metric_value, + implied_ev_by_quantile=None, + implied_equity_value_by_quantile=MappingProxyType(implied_equity), + warning=distribution.warning, + ) + + +@dataclass(frozen=True) +class CompsResult: + """Full output of one comparable-companies run: one target against N peers. + + Attributes: + target_name: The target company's name. + calendarisation_policy: `"ltm"` or `"calendar_year"` -- the ONE + convention used for every peer and the target in this run (see + module docstring, section 2). + eps_basis: `"gaap"` or `"adjusted"` -- the ONE EPS convention shared + by every peer's P/E and the target's implied P/E valuation. + peer_multiples: Every peer's `PeerMultiples`, in the order supplied. + distributions: `{multiple_name: MultipleDistribution}` for all five + multiples in `MULTIPLE_NAMES`. + implied_valuations: `{multiple_name: ImpliedValuation}` for all five + multiples. + warnings: Every small-sample / zero-peer warning raised anywhere in + this run, so a caller reading only `warnings` still sees them + without walking every distribution. + """ + + target_name: str + calendarisation_policy: str + eps_basis: str + peer_multiples: tuple[PeerMultiples, ...] + distributions: Mapping[str, MultipleDistribution] + implied_valuations: Mapping[str, ImpliedValuation] + warnings: tuple[str, ...] + + +def run_comps( + target: TargetCompany, + peers: Sequence[PeerCompany], + *, + calendarisation_policy: str, +) -> CompsResult: + """Run a full comparable-companies valuation: EV bridge, multiples, implied range. + + Args: + target: The company being valued. + peers: The comparable set. Must be non-empty -- see `Raises`. Fewer + than `MIN_ROBUST_COMPS` peers still runs, but every distribution + (and the top-level `warnings`) carries an explicit small-sample + warning. + calendarisation_policy: `"ltm"` or `"calendar_year"`, applied to + every peer and the target -- see module docstring, section 2. + + Returns: + The full :class:`CompsResult`. + + Raises: + MissingInputError: If `peers` is empty -- with no comparable + companies at all there is nothing to build a multiple from, so + the run is not runnable, the same as any other missing required + input (see :mod:`.contracts`). This is distinct from the + boundary case where peers were supplied but every one of them + was later excluded from a given multiple by its own non-positive + denominator -- that case returns a normal `CompsResult` whose + `distributions[name]` is empty and carries a warning, because + the peers themselves were not missing, only their multiples were + not computable. + ValuationError: If `calendarisation_policy` is not recognised, if + two peers share a name, or if peers/target do not all declare + the same `eps_basis` (mixing GAAP and adjusted EPS across the + comp set would skew the P/E distribution by whatever one-time + items the adjustment removes, the same kind of silent distortion + the calendarisation-policy rule exists to prevent). + MissingInputError: (propagated from `calendarise_metric`) if any + peer's or the target's fiscal-period data lacks a field + `calendarisation_policy` needs. + """ + if calendarisation_policy not in CALENDARISATION_POLICIES: + raise ValuationError( + f"comps.run_comps: unknown calendarisation_policy {calendarisation_policy!r}, " + f"must be one of {CALENDARISATION_POLICIES}" + ) + if len(peers) == 0: + raise MissingInputError(("peers",), "comps.run_comps") + + names = [peer.name for peer in peers] + if len(set(names)) != len(names): + raise ValuationError(f"comps.run_comps: duplicate peer names in {names}") + + all_bases = {peer.eps_basis for peer in peers} | {target.eps_basis} + if len(all_bases) > 1: + raise ValuationError( + "comps.run_comps: mixed eps_basis across the comp set " + f"{sorted(all_bases)} -- every peer and the target must declare the " + "same EPS basis, or the P/E distribution mixes GAAP and adjusted " + "earnings" + ) + + peer_multiples = tuple(peer_multiple_set(peer, calendarisation_policy) for peer in peers) + distributions = { + name: multiple_distribution(name, peer_multiples) for name in MULTIPLE_NAMES + } + + calendarised_target = { + "ebitda": calendarise_metric( + target.ebitda, calendarisation_policy, metric_name="ebitda", company_name=target.name + ), + "ebit": calendarise_metric( + target.ebit, calendarisation_policy, metric_name="ebit", company_name=target.name + ), + "revenue": calendarise_metric( + target.revenue, calendarisation_policy, metric_name="revenue", company_name=target.name + ), + "diluted_eps": calendarise_metric( + target.diluted_eps, calendarisation_policy, metric_name="diluted_eps", company_name=target.name + ), + } + + implied = { + name: implied_valuation(name, distributions[name], target, calendarised_target) + for name in MULTIPLE_NAMES + } + + warnings = tuple( + distributions[name].warning for name in MULTIPLE_NAMES if distributions[name].warning is not None + ) + if len(peers) < MIN_ROBUST_COMPS: + warnings = ( + f"comps.run_comps: only {len(peers)} peer(s) supplied (fewer than " + f"{MIN_ROBUST_COMPS}) -- every multiple in this run is built from a " + "sample too small for its median to be a robust statistic", + ) + warnings + + return CompsResult( + target_name=target.name, + calendarisation_policy=calendarisation_policy, + eps_basis=target.eps_basis, + peer_multiples=peer_multiples, + distributions=MappingProxyType(distributions), + implied_valuations=MappingProxyType(implied), + warnings=warnings, + ) diff --git a/agent/src/quantlib/valuation/contracts.py b/agent/src/quantlib/valuation/contracts.py new file mode 100644 index 00000000..c6f5726b --- /dev/null +++ b/agent/src/quantlib/valuation/contracts.py @@ -0,0 +1,160 @@ +"""Shared input discipline for every valuation model in this package. + +The modules here -- DCF, comps, three-statement -- deliberately own their own +result types and share almost nothing. What they DO share is the one rule that +decides whether a valuation is a measurement or a story: + + **A missing input makes the model NOT RUNNABLE. It never becomes an + assumption chosen by the code.** + +A discounted cash flow whose tax rate was quietly defaulted to 21%, or whose +terminal growth was quietly defaulted to 2.5%, produces a per-share number that +looks exactly like one built from filed statements. Nothing downstream can tell +them apart, and the reader has no way to know which assumptions were theirs. +:func:`require_inputs` and :class:`MissingInputError` exist so that outcome is +unreachable: the model stops and names every field it lacked. + +An assumption that the CALLER makes is a different thing and is welcome -- but +it arrives as an :class:`Assumption` carrying its own justification, and it +shows up in the result so it can be argued with. + +WHY NOT DEFAULTS +---------------- +Every default in a valuation model is an opinion wearing a constant's clothes. +A 2.5% terminal growth rate is a view on long-run GDP; a 21% tax rate is a view +on jurisdiction and on permanence of current law. Those views may well be right, +but they belong to whoever signs the valuation, not to this package. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from typing import Any + +from src.entities.models import normalize_currency + +__all__ = [ + "Assumption", + "MissingInputError", + "ValuationError", + "normalize_currency", + "require_inputs", + "require_positive", +] + + +class ValuationError(ValueError): + """Base class for every refusal a valuation model makes.""" + + +class MissingInputError(ValuationError): + """Raised when a model cannot run because inputs are absent. + + Carries the full list rather than the first one found, so a caller + assembling data from filings learns everything still needed in one round + trip instead of one field per attempt. + + Attributes: + missing: Names of the absent fields, in the order they were required. + model: Name of the model that refused. + """ + + def __init__(self, missing: Iterable[str], model: str) -> None: + self.missing = tuple(missing) + self.model = model + joined = ", ".join(self.missing) + super().__init__( + f"{model} is not runnable: missing {len(self.missing)} required " + f"input(s): {joined}. Supply them or report the model as not " + f"runnable -- do not substitute an assumed value." + ) + + +@dataclass(frozen=True) +class Assumption: + """A caller-supplied judgement, carried through to the result. + + Attributes: + name: What is being assumed, e.g. ``"terminal_growth"``. + value: The assumed value. + basis: Why this value. Free text, but required and non-empty: an + assumption whose justification nobody wrote down cannot be reviewed, + and an unreviewable assumption in a valuation is indistinguishable + from a guess. + source: Optional pointer to where the basis came from -- a filing, a + data tool call, a named analyst view. + + Raises: + ValueError: If ``name`` or ``basis`` is empty or blank. + """ + + name: str + value: Any + basis: str + source: str | None = None + + def __post_init__(self) -> None: + if not str(self.name).strip(): + raise ValueError("an assumption must be named") + if not str(self.basis).strip(): + raise ValueError( + f"assumption {self.name!r} has no basis; state why this value was " + "chosen, or do not make the assumption" + ) + + +def require_inputs( + supplied: Mapping[str, Any], + required: Iterable[str], + model: str, +) -> None: + """Refuse to run unless every required input is present and usable. + + A key that is absent, ``None``, or an empty string counts as missing. Zero + and ``False`` do NOT: they are legitimate values for a line item, and + treating them as missing is the mirror-image bug of defaulting. + + Args: + supplied: The inputs the caller provided. + required: Field names the model cannot run without. + model: Name used in the error message. + + Raises: + MissingInputError: If any required field is absent or unusable, naming + all of them. + """ + missing = [ + field + for field in required + if field not in supplied + or supplied[field] is None + or (isinstance(supplied[field], str) and not supplied[field].strip()) + ] + if missing: + raise MissingInputError(missing, model) + + +def require_positive(value: float, name: str, model: str) -> float: + """Check a value that is meaningless at or below zero. + + Args: + value: The value to check, e.g. a share count or a discount rate. + name: Field name for the error message. + model: Model name for the error message. + + Returns: + ``value`` as a float. + + Raises: + ValuationError: If the value is not a finite number greater than zero. + """ + try: + numeric = float(value) + except (TypeError, ValueError) as exc: + raise ValuationError(f"{model}: {name} must be a number, got {value!r}") from exc + if not numeric > 0.0 or numeric != numeric or numeric in (float("inf"), float("-inf")): + raise ValuationError( + f"{model}: {name} must be a finite positive number, got {numeric}" + ) + return numeric diff --git a/agent/src/quantlib/valuation/dcf.py b/agent/src/quantlib/valuation/dcf.py new file mode 100644 index 00000000..5aaa9b6f --- /dev/null +++ b/agent/src/quantlib/valuation/dcf.py @@ -0,0 +1,1345 @@ +"""Discounted cash flow valuation: WACC, the FCFF bridge, dual terminal value, +discounting, and the bridge from enterprise value to value per share. + +This module is the executable form of the DCF walkthrough that used to live as +markdown in valuation write-ups -- a WACC formula, an FCFF formula, a Gordon +growth terminal value, all typed out fresh (and inconsistently) on every run. +Several design choices are deliberate and are exactly the places a hand-rolled +DCF usually goes wrong silently. + +**No valuation parameter defaults to anything.** This follows +:mod:`src.quantlib.valuation.contracts` to the letter: the risk-free rate, +beta, the equity risk premium, the cost of debt, the tax rate, the terminal +growth rate, the exit multiple and the diluted share count are all required +arguments with no fallback value anywhere in this file. :func:`run_dcf` takes +them as a flat mapping and refuses to run with :class:`~..contracts.MissingInputError` +if any is absent, so a caller assembling a model from filings learns exactly +what is still missing rather than getting a number built on a guess. + +**Terminal growth and the exit multiple are Assumptions, not bare floats.** +``contracts.py``'s own docstring names "terminal growth quietly defaulted to +2.5%" as the canonical example of a valuation opinion wearing a constant's +clothes. Both terminal-value drivers here must therefore arrive as +:class:`~..contracts.Assumption` objects carrying a ``basis`` -- the model +refuses a bare number for either one, and the justification travels through to +:class:`TerminalValueResult` so it can be checked, not just the output. + +**Terminal value is always computed two ways, and the two are cross-checked.** +:func:`terminal_value` never runs the perpetuity-growth method alone: it also +runs the exit-multiple method and reports each one's implied version of the +other's driver -- the perpetuity value's implied EV/EBITDA multiple, and the +exit value's implied perpetuity growth rate. This is the only mechanism that +catches a terminal assumption that is arithmetically fine but economically +detached from reality (a growth rate that implies a 35x EBITDA multiple is +wrong even though the Gordon growth formula was applied correctly). ``growth +>= WACC`` is refused outright -- the terminal value is undefined there, not +merely large -- while growth above a long-run nominal GDP reference is only +flagged, because that is a judgment call the model has no standing to veto. + +**``delta_nwc`` sign convention.** An *increase* in net working capital is a +*use* of cash: ``delta_nwc > 0`` means working capital grew period over +period and is subtracted from FCFF; a working-capital release +(``delta_nwc < 0``) adds cash back. This is the ordinary balance-sheet +convention, stated explicitly because the alternative reading (delta_nwc as +"cash contribution") is exactly as common in the wild and the two disagree on +every sign. + +**Discounting convention is exact, not approximate, at the enterprise-value +level.** Mid-year discounting multiplies every present value by exactly +``(1 + WACC) ** 0.5`` relative to year-end discounting, for one reason: this +module discounts the terminal value using the *same* time index as the final +explicit forecast year (``t = n - 0.5`` under mid-year, ``t = n`` under +year-end), so every dollar in the model -- explicit FCFF or terminal value -- +is scaled by the identical constant when the convention flips. That makes the +mid-year/year-end enterprise-value ratio equal to ``sqrt(1 + WACC)`` exactly, +not approximately, which is a strong assertion the test suite checks bit for +bit. The equality does *not* extend to equity value or value per share, since +the net-debt bridge below enterprise value is a fixed balance-sheet fact that +does not scale with the discounting convention. + +**Equity bridge sign convention.** Every line between enterprise value and +equity value is supplied as a **non-negative magnitude**; the formula itself +applies the sign:: + + equity_value = EV - total_debt + cash_and_equivalents - minority_interest + - preferred_equity + associate_investments + +Passing a negative magnitude is refused rather than silently flipping the +formula's intent, because that is the single most common sign error in a +hand-built bridge. + +**Capital-structure basis is a required, explicit argument.** WACC weights can +come from *current* market values (``E / (D + E)``, ``D / (D + E)`` off +today's market value of equity and debt) or from a *target* structure the +analyst believes the firm is moving toward. The two are not interchangeable: +current weights are only the theoretically correct WACC weights if the firm +intends to hold that mix going forward, and a firm expected to lever up or +delever needs target weights instead (Copeland/Koller enterprise-DCF +convention). Nothing here picks one silently -- see +``capital_structure_basis`` on :func:`wacc` and :func:`run_dcf`. +""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +import numpy as np +import pandas as pd + +from src.quantlib.valuation.contracts import ( + Assumption, + MissingInputError, + ValuationError, + require_inputs, + require_positive, +) + +__all__ = [ + "CAPITAL_STRUCTURE_BASES", + "DISCOUNTING_CONVENTIONS", + "TERMINAL_VALUE_METHODS", + "DEFAULT_LONG_RUN_GDP_GROWTH_CEILING", + "DCF_BASE_REQUIRED_FIELDS", + "DCF_CURRENT_STRUCTURE_FIELDS", + "DCF_TARGET_STRUCTURE_FIELDS", + "WACCResult", + "FCFFYear", + "TerminalValueResult", + "DiscountedCashFlow", + "NetDebtBridgeResult", + "DCFResult", + "wacc", + "fcff_bridge", + "terminal_value", + "discount_fcff", + "equity_bridge", + "run_dcf", + "sensitivity_grid", +] + +#: How WACC's capital-structure weights are sourced. ``"current"`` derives +#: ``E / (D + E)`` and ``D / (D + E)`` from today's market values; ``"target"`` +#: takes the weights directly as the analyst's view of the structure the firm +#: is moving toward. See the module docstring for why the two are not +#: interchangeable. +CAPITAL_STRUCTURE_BASES: tuple[str, ...] = ("current", "target") + +#: Timing convention for discounting a cash flow that arrives sometime during +#: year ``t``. ``"mid_year"`` assumes it lands on average at ``t - 0.5``; +#: ``"year_end"`` assumes it lands at ``t``. +DISCOUNTING_CONVENTIONS: tuple[str, ...] = ("mid_year", "year_end") + +#: Which terminal-value estimate feeds the enterprise-value bridge. Both +#: methods are always computed and cross-checked (see :func:`terminal_value`); +#: this only selects which one is discounted into ``DCFResult.enterprise_value``. +TERMINAL_VALUE_METHODS: tuple[str, ...] = ("perpetuity_growth", "exit_multiple") + +#: Reference ceiling used only to FLAG a perpetuity growth assumption, never to +#: block or alter one -- a terminal growth rate above long-run nominal GDP +#: growth is not mathematically wrong, only worth a second look. This is a +#: diagnostic threshold, not a valuation input: it never appears in any formula +#: that produces a price. 4% approximates long-run US real GDP growth +#: (~2%) plus a long-run inflation target (~2%); callers valuing a business +#: against a different economy should pass their own ``gdp_growth_ceiling``. +DEFAULT_LONG_RUN_GDP_GROWTH_CEILING: float = 0.04 + +#: Flat input-mapping keys :func:`run_dcf` requires regardless of +#: ``capital_structure_basis``. +DCF_BASE_REQUIRED_FIELDS: tuple[str, ...] = ( + "risk_free_rate", + "beta", + "equity_risk_premium", + "pretax_cost_of_debt", + "tax_rate", + "ebit", + "depreciation_amortization", + "capex", + "delta_nwc", + "terminal_growth", + "exit_multiple", + "total_debt", + "cash_and_equivalents", + "minority_interest", + "preferred_equity", + "associate_investments", + "diluted_shares", +) + +#: Additional keys :func:`run_dcf` requires when ``capital_structure_basis="current"``. +DCF_CURRENT_STRUCTURE_FIELDS: tuple[str, ...] = ( + "market_value_of_equity", + "market_value_of_debt", +) + +#: Additional keys :func:`run_dcf` requires when ``capital_structure_basis="target"``. +DCF_TARGET_STRUCTURE_FIELDS: tuple[str, ...] = ( + "target_equity_weight", + "target_debt_weight", +) + +#: Slack allowed when checking that capital-structure weights sum to 1, and +#: when re-checking a dataclass's own internal arithmetic in ``__post_init__``. +_RECONCILIATION_TOLERANCE: float = 1e-6 + + +# --------------------------------------------------------------------------- +# small private helpers +# --------------------------------------------------------------------------- + + +def _require_assumption(value: Any, expected_name: str, model: str) -> Assumption: + """Check that a terminal-value driver arrived as a justified Assumption. + + Args: + value: Candidate supplied by the caller. + expected_name: The :attr:`Assumption.name` this slot expects. + model: Name used in the error message. + + Returns: + ``value``, unchanged. + + Raises: + ValuationError: If ``value`` is not an :class:`Assumption`, or if its + ``name`` does not match ``expected_name`` (which usually means two + assumptions were passed under swapped keys). + """ + if not isinstance(value, Assumption): + raise ValuationError( + f"{model}: {expected_name!r} must be supplied as an Assumption(" + f"name=..., value=..., basis=...) carrying its justification, not " + f"a bare {type(value).__name__}. A terminal growth rate or exit " + "multiple chosen without a stated reason is exactly the kind of " + "silent default this package refuses to make on your behalf." + ) + if value.name != expected_name: + raise ValuationError( + f"{model}: expected an Assumption named {expected_name!r}, got " + f"{value.name!r}; check that assumptions were not passed under " + "swapped keys." + ) + return value + + +def _require_nonnegative(value: float, name: str, model: str) -> float: + """Check a value that is a signed formula's magnitude, so must be >= 0. + + Args: + value: The candidate magnitude. + name: Field name for the error message. + model: Model name for the error message. + + Returns: + ``value`` as a float. + + Raises: + ValuationError: If the value is negative or not a finite number. A + negative magnitude here would silently flip the sign the bridge + formula already applies. + """ + try: + numeric = float(value) + except (TypeError, ValueError) as exc: + raise ValuationError(f"{model}: {name} must be a number, got {value!r}") from exc + if not math.isfinite(numeric) or numeric < 0.0: + raise ValuationError( + f"{model}: {name} must be supplied as a non-negative magnitude " + f"(its sign is applied by the bridge formula), got {numeric!r}" + ) + return numeric + + +def _validate_weights(equity_weight: float, debt_weight: float, *, model: str) -> None: + """Check that a pair of capital-structure weights is usable. + + Args: + equity_weight: Proposed ``E / (D + E)``. + debt_weight: Proposed ``D / (D + E)``. + model: Model name for the error message. + + Raises: + ValuationError: If either weight is negative, or if they do not sum to + 1 within :data:`_RECONCILIATION_TOLERANCE`. + """ + if equity_weight < 0.0 or debt_weight < 0.0: + raise ValuationError( + f"{model}: capital-structure weights must be non-negative, got " + f"equity_weight={equity_weight!r} debt_weight={debt_weight!r}" + ) + total = equity_weight + debt_weight + if abs(total - 1.0) > _RECONCILIATION_TOLERANCE: + raise ValuationError( + f"{model}: capital-structure weights must sum to 1 (within " + f"{_RECONCILIATION_TOLERANCE:g}), got equity_weight={equity_weight!r} " + f"+ debt_weight={debt_weight!r} = {total!r}" + ) + + +# --------------------------------------------------------------------------- +# WACC +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class WACCResult: + """The WACC build, with every component that fed it kept visible. + + Attributes: + risk_free_rate: ``rf`` used in CAPM. + beta: Levered beta used in CAPM. + equity_risk_premium: ``ERP`` used in CAPM. + size_premium: Additive small-cap premium, ``0.0`` if not supplied. + country_risk_premium: Additive sovereign/country premium, ``0.0`` if + not supplied. + cost_of_equity: ``Re = rf + beta * ERP + size_premium + country_risk_premium``. + pretax_cost_of_debt: ``Rd`` before the tax shield. + tax_rate: Marginal tax rate applied to the debt tax shield (and, in + :func:`fcff_bridge`, to EBIT -- the same statutory rate drives both). + cost_of_debt_after_tax: ``Rd * (1 - tax_rate)``. + capital_structure_basis: ``"current"`` or ``"target"``; see the module + docstring for the difference. + equity_weight: ``E / (D + E)``. + debt_weight: ``D / (D + E)``. + wacc: The blended discount rate, + ``equity_weight * cost_of_equity + debt_weight * cost_of_debt_after_tax``. + """ + + risk_free_rate: float + beta: float + equity_risk_premium: float + size_premium: float + country_risk_premium: float + cost_of_equity: float + pretax_cost_of_debt: float + tax_rate: float + cost_of_debt_after_tax: float + capital_structure_basis: str + equity_weight: float + debt_weight: float + wacc: float + + def __post_init__(self) -> None: + """Re-check the blend arithmetic and the weight identity. + + Raises: + ValueError: If the weights do not sum to 1, or ``wacc`` is not the + stated weighted blend of the two costs of capital. + """ + _validate_weights(self.equity_weight, self.debt_weight, model="WACCResult") + expected = ( + self.equity_weight * self.cost_of_equity + + self.debt_weight * self.cost_of_debt_after_tax + ) + if not math.isclose( + self.wacc, expected, rel_tol=_RECONCILIATION_TOLERANCE, abs_tol=1e-12 + ): + raise ValueError( + f"WACCResult.wacc={self.wacc!r} does not match the weighted " + f"blend {expected!r} of cost_of_equity and cost_of_debt_after_tax" + ) + + +def wacc( + *, + risk_free_rate: float, + beta: float, + equity_risk_premium: float, + pretax_cost_of_debt: float, + tax_rate: float, + capital_structure_basis: str, + market_value_of_equity: float | None = None, + market_value_of_debt: float | None = None, + target_equity_weight: float | None = None, + target_debt_weight: float | None = None, + size_premium: float = 0.0, + country_risk_premium: float = 0.0, +) -> WACCResult: + """Build the weighted average cost of capital. + + ``Re`` is CAPM plus two optional additive premiums; ``Rd`` is the pretax + cost of debt shielded by the tax rate; the two are blended by + capital-structure weights sourced per ``capital_structure_basis``. + + Args: + risk_free_rate: ``rf``, as a decimal. + beta: Levered equity beta. + equity_risk_premium: ``ERP``, as a decimal. + pretax_cost_of_debt: ``Rd`` before the tax shield, as a decimal. + tax_rate: Marginal tax rate in ``[0, 1]``. + capital_structure_basis: One of :data:`CAPITAL_STRUCTURE_BASES`. + ``"current"`` derives weights from today's market values; + ``"target"`` takes the weights directly. Required, no default -- + see the module docstring for why the two are not interchangeable. + market_value_of_equity: Market value of equity. Required (and only + used) when ``capital_structure_basis="current"``. + market_value_of_debt: Market value of debt. Required (and only used) + when ``capital_structure_basis="current"``. + target_equity_weight: Target ``E / (D + E)``. Required (and only used) + when ``capital_structure_basis="target"``. + target_debt_weight: Target ``D / (D + E)``. Required (and only used) + when ``capital_structure_basis="target"``. + size_premium: Additive small-cap premium on cost of equity. Optional -- + absence means "not applying one", which is a structural choice, not + a guessed magnitude standing in for an unknown rate. + country_risk_premium: Additive sovereign/country premium on cost of + equity. Same optionality rationale as ``size_premium``. + + Returns: + A :class:`WACCResult` with every intermediate figure visible. + + Raises: + MissingInputError: If the market-value or target-weight pair matching + ``capital_structure_basis`` is not fully supplied. + ValuationError: If ``capital_structure_basis`` is unrecognised, if + ``tax_rate`` is outside ``[0, 1]``, if a market value is negative, + if the market value of equity plus debt is zero (weights + undefined), or if the resulting weights are negative or do not sum + to 1. + """ + if capital_structure_basis not in CAPITAL_STRUCTURE_BASES: + raise ValuationError( + f"wacc: capital_structure_basis must be one of " + f"{CAPITAL_STRUCTURE_BASES}, got {capital_structure_basis!r}" + ) + if not 0.0 <= tax_rate <= 1.0: + raise ValuationError(f"wacc: tax_rate must be within [0, 1], got {tax_rate!r}") + + if capital_structure_basis == "current": + missing = [ + name + for name, value in ( + ("market_value_of_equity", market_value_of_equity), + ("market_value_of_debt", market_value_of_debt), + ) + if value is None + ] + if missing: + raise MissingInputError(missing, "wacc") + equity_mv = float(market_value_of_equity) # type: ignore[arg-type] + debt_mv = float(market_value_of_debt) # type: ignore[arg-type] + if equity_mv < 0.0 or debt_mv < 0.0: + raise ValuationError( + f"wacc: market values must be non-negative, got " + f"market_value_of_equity={equity_mv!r} market_value_of_debt={debt_mv!r}" + ) + total_mv = equity_mv + debt_mv + if total_mv <= 0.0: + raise ValuationError( + "wacc: market value of equity plus debt is zero (D + E = 0); " + "capital-structure weights are undefined" + ) + equity_weight = equity_mv / total_mv + debt_weight = debt_mv / total_mv + else: + missing = [ + name + for name, value in ( + ("target_equity_weight", target_equity_weight), + ("target_debt_weight", target_debt_weight), + ) + if value is None + ] + if missing: + raise MissingInputError(missing, "wacc") + equity_weight = float(target_equity_weight) # type: ignore[arg-type] + debt_weight = float(target_debt_weight) # type: ignore[arg-type] + + _validate_weights(equity_weight, debt_weight, model="wacc") + + cost_of_equity = ( + risk_free_rate + beta * equity_risk_premium + size_premium + country_risk_premium + ) + cost_of_debt_after_tax = pretax_cost_of_debt * (1.0 - tax_rate) + blended = equity_weight * cost_of_equity + debt_weight * cost_of_debt_after_tax + + return WACCResult( + risk_free_rate=float(risk_free_rate), + beta=float(beta), + equity_risk_premium=float(equity_risk_premium), + size_premium=float(size_premium), + country_risk_premium=float(country_risk_premium), + cost_of_equity=cost_of_equity, + pretax_cost_of_debt=float(pretax_cost_of_debt), + tax_rate=float(tax_rate), + cost_of_debt_after_tax=cost_of_debt_after_tax, + capital_structure_basis=capital_structure_basis, + equity_weight=equity_weight, + debt_weight=debt_weight, + wacc=blended, + ) + + +# --------------------------------------------------------------------------- +# FCFF bridge +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class FCFFYear: + """One projection year's bridge from EBIT to free cash flow to the firm. + + ``FCFF = NOPAT + depreciation_amortization - capex - delta_nwc``. Every + step is a field so a reader can see which one consumed the cash, not just + the final number. + + Attributes: + year: 1-indexed projection year (1 is the first forecast year). + ebit: Earnings before interest and tax. + tax_rate: Marginal tax rate applied to EBIT. + nopat: ``ebit * (1 - tax_rate)``. + depreciation_amortization: Non-cash D&A added back. + capex: Capital expenditure, a positive magnitude representing a cash + outflow. + delta_nwc: Change in net working capital. **Positive means working + capital increased and cash was used**; negative means working + capital was released and cash was returned. See the module + docstring. + fcff: ``nopat + depreciation_amortization - capex - delta_nwc``. + """ + + year: int + ebit: float + tax_rate: float + nopat: float + depreciation_amortization: float + capex: float + delta_nwc: float + fcff: float + + def __post_init__(self) -> None: + """Re-check the NOPAT and FCFF arithmetic. + + Raises: + ValueError: If ``nopat`` or ``fcff`` do not match their formulas, + or ``year`` is not a positive integer. + """ + if self.year < 1: + raise ValueError(f"FCFFYear.year must be >= 1, got {self.year!r}") + expected_nopat = self.ebit * (1.0 - self.tax_rate) + if not math.isclose( + self.nopat, expected_nopat, rel_tol=_RECONCILIATION_TOLERANCE, abs_tol=1e-9 + ): + raise ValueError( + f"FCFFYear(year={self.year}).nopat={self.nopat!r} does not match " + f"ebit * (1 - tax_rate) = {expected_nopat!r}" + ) + expected_fcff = ( + self.nopat + self.depreciation_amortization - self.capex - self.delta_nwc + ) + if not math.isclose( + self.fcff, expected_fcff, rel_tol=_RECONCILIATION_TOLERANCE, abs_tol=1e-9 + ): + raise ValueError( + f"FCFFYear(year={self.year}).fcff={self.fcff!r} does not match the " + f"bridge total {expected_fcff!r}" + ) + + +def fcff_bridge( + *, + ebit: Sequence[float], + tax_rate: float, + depreciation_amortization: Sequence[float], + capex: Sequence[float], + delta_nwc: Sequence[float], +) -> tuple[FCFFYear, ...]: + """Build the EBIT -> FCFF bridge for every projection year. + + Args: + ebit: EBIT forecast, one entry per projection year, oldest first. + tax_rate: Marginal tax rate in ``[0, 1]``, applied to every year. + depreciation_amortization: D&A forecast, same length as ``ebit``. + capex: Capital expenditure forecast (positive = cash outflow), same + length as ``ebit``. + delta_nwc: Net-working-capital change forecast (positive = cash + outflow; see the module docstring), same length as ``ebit``. + + Returns: + One :class:`FCFFYear` per projection year, in order. + + Raises: + ValuationError: If ``tax_rate`` is outside ``[0, 1]``, if ``ebit`` is + empty, or if the other three forecasts are not the same length as + ``ebit``. + """ + if not 0.0 <= tax_rate <= 1.0: + raise ValuationError(f"fcff_bridge: tax_rate must be within [0, 1], got {tax_rate!r}") + + ebit_list = list(ebit) + horizon = len(ebit_list) + if horizon == 0: + raise ValuationError( + "fcff_bridge: ebit forecast is empty; at least one projection year " + "is required" + ) + forecasts = { + "depreciation_amortization": list(depreciation_amortization), + "capex": list(capex), + "delta_nwc": list(delta_nwc), + } + for name, values in forecasts.items(): + if len(values) != horizon: + raise ValuationError( + f"fcff_bridge: {name} has {len(values)} year(s), expected " + f"{horizon} to match ebit" + ) + + years = [] + for index in range(horizon): + ebit_value = float(ebit_list[index]) + da_value = float(forecasts["depreciation_amortization"][index]) + capex_value = float(forecasts["capex"][index]) + delta_nwc_value = float(forecasts["delta_nwc"][index]) + nopat = ebit_value * (1.0 - tax_rate) + fcff_value = nopat + da_value - capex_value - delta_nwc_value + years.append( + FCFFYear( + year=index + 1, + ebit=ebit_value, + tax_rate=tax_rate, + nopat=nopat, + depreciation_amortization=da_value, + capex=capex_value, + delta_nwc=delta_nwc_value, + fcff=fcff_value, + ) + ) + return tuple(years) + + +# --------------------------------------------------------------------------- +# Dual terminal value +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class TerminalValueResult: + """Both terminal-value estimates, each cross-checked against the other. + + Attributes: + wacc_rate: Discount rate used by the perpetuity-growth method. + terminal_growth: The :class:`Assumption` behind the perpetuity-growth + method, carrying its ``basis``. + perpetuity_terminal_value: ``FCFF_{n+1} / (wacc_rate - g)`` where + ``FCFF_{n+1} = FCFF_n * (1 + g)``. + terminal_year_ebitda: ``EBITDA`` of the final explicit forecast year, + used by the exit-multiple method. + exit_multiple: The :class:`Assumption` behind the exit-multiple + method, carrying its ``basis``. + exit_terminal_value: ``terminal_year_ebitda * exit_multiple.value``. + implied_ev_ebitda_multiple: What EV/EBITDA multiple + ``perpetuity_terminal_value`` implies -- the perpetuity method's + result, restated in the exit method's units, so a reader can judge + whether the growth assumption is realistic on its face. + implied_perpetuity_growth: What perpetuity growth rate + ``exit_terminal_value`` implies -- the exit method's result, + restated in the perpetuity method's units. + gdp_growth_ceiling: Reference ceiling used for ``growth_exceeds_gdp_ceiling``. + growth_exceeds_gdp_ceiling: ``True`` if ``terminal_growth.value`` is + above ``gdp_growth_ceiling``. A flag, never an error -- see the + module docstring. + """ + + wacc_rate: float + terminal_growth: Assumption + perpetuity_terminal_value: float + terminal_year_ebitda: float + exit_multiple: Assumption + exit_terminal_value: float + implied_ev_ebitda_multiple: float + implied_perpetuity_growth: float + gdp_growth_ceiling: float + growth_exceeds_gdp_ceiling: bool + + def __post_init__(self) -> None: + """Re-check that the stored growth rate is still below WACC. + + Raises: + ValueError: If ``terminal_growth.value >= wacc_rate``. This should + already have been refused by :func:`terminal_value` before + construction; this is a second, independent check. + """ + if float(self.terminal_growth.value) >= self.wacc_rate: + raise ValueError( + f"TerminalValueResult.terminal_growth.value=" + f"{self.terminal_growth.value!r} is not below wacc_rate=" + f"{self.wacc_rate!r}" + ) + + +def terminal_value( + *, + final_year_fcff: float, + terminal_year_ebitda: float, + wacc_rate: float, + terminal_growth: Assumption, + exit_multiple: Assumption, + gdp_growth_ceiling: float = DEFAULT_LONG_RUN_GDP_GROWTH_CEILING, +) -> TerminalValueResult: + """Compute both terminal-value estimates and cross-validate them. + + Args: + final_year_fcff: ``FCFF_n``, the last explicit forecast year's free + cash flow to the firm. + terminal_year_ebitda: ``EBITDA`` of the final explicit forecast year. + wacc_rate: The discount rate, as a decimal. + terminal_growth: An :class:`Assumption` naming the perpetuity growth + rate ``g`` and its basis. + exit_multiple: An :class:`Assumption` naming the EV/EBITDA exit + multiple and its basis. + gdp_growth_ceiling: Reference ceiling used only to flag + ``terminal_growth`` -- see :data:`DEFAULT_LONG_RUN_GDP_GROWTH_CEILING`. + + Returns: + A :class:`TerminalValueResult` carrying both terminal values and both + cross-checks. + + Raises: + ValuationError: If either argument is not the matching + :class:`Assumption`; if ``wacc_rate <= -1``; if + ``terminal_growth.value >= wacc_rate`` (the perpetuity terminal + value is negative or infinite there, not merely large); if + ``terminal_year_ebitda <= 0`` (an EV/EBITDA multiple is undefined); + if ``exit_multiple.value <= 0``; or if the exit terminal value + happens to equal ``-final_year_fcff`` exactly, which makes the + implied-growth reverse-solve's denominator zero. + """ + _require_assumption(terminal_growth, "terminal_growth", "terminal_value") + _require_assumption(exit_multiple, "exit_multiple", "terminal_value") + + if wacc_rate <= -1.0: + raise ValuationError( + f"terminal_value: wacc_rate must exceed -1, got {wacc_rate!r}" + ) + growth = float(terminal_growth.value) + if growth >= wacc_rate: + raise ValuationError( + f"terminal_value: terminal_growth ({growth!r}) must be strictly " + f"less than WACC ({wacc_rate!r}); at or above WACC the Gordon-growth " + "terminal value is negative or infinite, not merely a large number. " + "Lower the growth assumption or check the WACC build." + ) + if terminal_year_ebitda <= 0.0: + raise ValuationError( + f"terminal_value: terminal_year_ebitda must be positive to derive " + f"an implied EV/EBITDA multiple, got {terminal_year_ebitda!r}" + ) + multiple = float(exit_multiple.value) + if multiple <= 0.0: + raise ValuationError( + f"terminal_value: exit_multiple.value must be positive, got {multiple!r}" + ) + + next_year_fcff = final_year_fcff * (1.0 + growth) + perpetuity_tv = next_year_fcff / (wacc_rate - growth) + exit_tv = terminal_year_ebitda * multiple + + implied_ev_ebitda_multiple = perpetuity_tv / terminal_year_ebitda + + denominator = exit_tv + final_year_fcff + if denominator == 0.0: + raise ValuationError( + "terminal_value: cannot solve the exit multiple's implied perpetuity " + "growth because exit_terminal_value and final_year_fcff are equal " + "and opposite, making the reverse-solve denominator zero" + ) + implied_perpetuity_growth = (exit_tv * wacc_rate - final_year_fcff) / denominator + + return TerminalValueResult( + wacc_rate=float(wacc_rate), + terminal_growth=terminal_growth, + perpetuity_terminal_value=perpetuity_tv, + terminal_year_ebitda=float(terminal_year_ebitda), + exit_multiple=exit_multiple, + exit_terminal_value=exit_tv, + implied_ev_ebitda_multiple=implied_ev_ebitda_multiple, + implied_perpetuity_growth=implied_perpetuity_growth, + gdp_growth_ceiling=float(gdp_growth_ceiling), + growth_exceeds_gdp_ceiling=growth > gdp_growth_ceiling, + ) + + +# --------------------------------------------------------------------------- +# Discounting +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class DiscountedCashFlow: + """One projection year's FCFF, discounted to present value. + + Attributes: + year: 1-indexed projection year, matching :attr:`FCFFYear.year`. + fcff: The undiscounted free cash flow to the firm for this year. + wacc_rate: Discount rate used. + time_period: The exponent ``t`` actually used -- + ``year - 0.5`` under ``"mid_year"``, ``year`` under ``"year_end"``. + discount_factor: ``(1 + wacc_rate) ** -time_period``. + present_value: ``fcff * discount_factor``. + """ + + year: int + fcff: float + wacc_rate: float + time_period: float + discount_factor: float + present_value: float + + def __post_init__(self) -> None: + """Re-check the discount factor and present-value arithmetic. + + Raises: + ValueError: If ``discount_factor`` or ``present_value`` do not + match their formulas. + """ + expected_factor = (1.0 + self.wacc_rate) ** (-self.time_period) + if not math.isclose( + self.discount_factor, expected_factor, rel_tol=1e-9, abs_tol=1e-12 + ): + raise ValueError( + f"DiscountedCashFlow(year={self.year}).discount_factor=" + f"{self.discount_factor!r} does not match " + f"(1 + wacc_rate) ** -time_period = {expected_factor!r}" + ) + expected_pv = self.fcff * self.discount_factor + if not math.isclose(self.present_value, expected_pv, rel_tol=1e-9, abs_tol=1e-9): + raise ValueError( + f"DiscountedCashFlow(year={self.year}).present_value=" + f"{self.present_value!r} does not match fcff * discount_factor " + f"= {expected_pv!r}" + ) + + +def discount_fcff( + fcff_years: Sequence[FCFFYear], + *, + wacc_rate: float, + discounting_convention: str, +) -> tuple[DiscountedCashFlow, ...]: + """Discount every projection year's FCFF to present value. + + Args: + fcff_years: The FCFF bridge, e.g. from :func:`fcff_bridge`. + wacc_rate: Discount rate, as a decimal, strictly greater than -1. + discounting_convention: One of :data:`DISCOUNTING_CONVENTIONS`. + Required, no default -- see the module docstring for why mid-year + and year-end are not interchangeable. + + Returns: + One :class:`DiscountedCashFlow` per input year, in the same order. + + Raises: + ValuationError: If ``discounting_convention`` is unrecognised, if + ``fcff_years`` is empty, or if ``wacc_rate <= -1``. + """ + if discounting_convention not in DISCOUNTING_CONVENTIONS: + raise ValuationError( + f"discount_fcff: discounting_convention must be one of " + f"{DISCOUNTING_CONVENTIONS}, got {discounting_convention!r}" + ) + if not fcff_years: + raise ValuationError("discount_fcff: fcff_years is empty") + if wacc_rate <= -1.0: + raise ValuationError(f"discount_fcff: wacc_rate must exceed -1, got {wacc_rate!r}") + + results = [] + for entry in fcff_years: + time_period = ( + entry.year - 0.5 if discounting_convention == "mid_year" else float(entry.year) + ) + discount_factor_value = (1.0 + wacc_rate) ** (-time_period) + results.append( + DiscountedCashFlow( + year=entry.year, + fcff=entry.fcff, + wacc_rate=float(wacc_rate), + time_period=time_period, + discount_factor=discount_factor_value, + present_value=entry.fcff * discount_factor_value, + ) + ) + return tuple(results) + + +# --------------------------------------------------------------------------- +# Net-debt bridge to value per share +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class NetDebtBridgeResult: + """The bridge from enterprise value to value per share. + + Every balance-sheet item is a non-negative magnitude; the sign each one + carries in the formula is fixed and documented here, not inferred from the + number's own sign. + + Attributes: + enterprise_value: EV entering the bridge. + total_debt: Subtracted -- debt is a claim ahead of equity. + cash_and_equivalents: Added -- cash is equity value EV does not price. + minority_interest: Subtracted -- non-controlling interests' claim on + consolidated value. + preferred_equity: Subtracted -- senior to common equity. + associate_investments: Added -- equity-method investments EV does not + capture (their earnings are typically excluded from EBIT/EBITDA). + equity_value: ``enterprise_value - total_debt + cash_and_equivalents - + minority_interest - preferred_equity + associate_investments``. + diluted_shares: Fully diluted share count. + value_per_share: ``equity_value / diluted_shares``. + """ + + enterprise_value: float + total_debt: float + cash_and_equivalents: float + minority_interest: float + preferred_equity: float + associate_investments: float + equity_value: float + diluted_shares: float + value_per_share: float + + def __post_init__(self) -> None: + """Re-check the bridge and per-share arithmetic. + + Raises: + ValueError: If ``equity_value`` or ``value_per_share`` do not + match their formulas. + """ + expected_equity = ( + self.enterprise_value + - self.total_debt + + self.cash_and_equivalents + - self.minority_interest + - self.preferred_equity + + self.associate_investments + ) + if not math.isclose( + self.equity_value, expected_equity, rel_tol=_RECONCILIATION_TOLERANCE, abs_tol=1e-6 + ): + raise ValueError( + f"NetDebtBridgeResult.equity_value={self.equity_value!r} does not " + f"match the bridge total {expected_equity!r}" + ) + expected_per_share = self.equity_value / self.diluted_shares + if not math.isclose( + self.value_per_share, + expected_per_share, + rel_tol=_RECONCILIATION_TOLERANCE, + abs_tol=1e-9, + ): + raise ValueError( + f"NetDebtBridgeResult.value_per_share={self.value_per_share!r} " + f"does not match equity_value / diluted_shares = {expected_per_share!r}" + ) + + +def equity_bridge( + *, + enterprise_value: float, + total_debt: float, + cash_and_equivalents: float, + minority_interest: float, + preferred_equity: float, + associate_investments: float, + diluted_shares: float, +) -> NetDebtBridgeResult: + """Bridge enterprise value to equity value and value per share. + + Args: + enterprise_value: EV entering the bridge. + total_debt: Non-negative magnitude of total debt; subtracted. + cash_and_equivalents: Non-negative magnitude of cash and equivalents; + added. + minority_interest: Non-negative magnitude of non-controlling + interests; subtracted. + preferred_equity: Non-negative magnitude of preferred equity; + subtracted. + associate_investments: Non-negative magnitude of equity-method + investments in associates/joint ventures; added. + diluted_shares: Fully diluted share count; must be strictly positive. + + Returns: + A :class:`NetDebtBridgeResult`. + + Raises: + ValuationError: If any of the five balance-sheet items is negative or + not finite, or if ``diluted_shares`` is not a finite positive number. + """ + debt = _require_nonnegative(total_debt, "total_debt", "equity_bridge") + cash = _require_nonnegative(cash_and_equivalents, "cash_and_equivalents", "equity_bridge") + minority = _require_nonnegative(minority_interest, "minority_interest", "equity_bridge") + preferred = _require_nonnegative(preferred_equity, "preferred_equity", "equity_bridge") + associates = _require_nonnegative( + associate_investments, "associate_investments", "equity_bridge" + ) + shares = require_positive(diluted_shares, "diluted_shares", "equity_bridge") + + equity_value = enterprise_value - debt + cash - minority - preferred + associates + value_per_share = equity_value / shares + + return NetDebtBridgeResult( + enterprise_value=float(enterprise_value), + total_debt=debt, + cash_and_equivalents=cash, + minority_interest=minority, + preferred_equity=preferred, + associate_investments=associates, + equity_value=equity_value, + diluted_shares=shares, + value_per_share=value_per_share, + ) + + +# --------------------------------------------------------------------------- +# Sensitivity grid +# --------------------------------------------------------------------------- + + +def sensitivity_grid( + fcff_years: Sequence[FCFFYear], + *, + wacc_values: Sequence[float], + growth_values: Sequence[float], + discounting_convention: str, + total_debt: float, + cash_and_equivalents: float, + minority_interest: float, + preferred_equity: float, + associate_investments: float, + diluted_shares: float, +) -> pd.DataFrame: + """Value-per-share sensitivity to WACC and perpetuity terminal growth. + + Every cell re-runs the full model (discount the explicit FCFFs at that + WACC, build the perpetuity-growth terminal value at that WACC and growth, + discount it at the same timing as the final explicit year, then bridge + through the same fixed balance-sheet items) rather than perturbing the + published enterprise value, so the grid is internally consistent at every + point. Only the perpetuity-growth method is swept -- the exit-multiple + method has no growth axis. + + Args: + fcff_years: The FCFF bridge, e.g. from :func:`fcff_bridge`. WACC- and + growth-independent, so it is reused unchanged at every cell. + wacc_values: WACC values forming the grid's rows, each strictly + greater than -1. + growth_values: Perpetuity growth values forming the grid's columns. + discounting_convention: One of :data:`DISCOUNTING_CONVENTIONS`. + total_debt: Non-negative magnitude; subtracted at every cell. + cash_and_equivalents: Non-negative magnitude; added at every cell. + minority_interest: Non-negative magnitude; subtracted at every cell. + preferred_equity: Non-negative magnitude; subtracted at every cell. + associate_investments: Non-negative magnitude; added at every cell. + diluted_shares: Fully diluted share count; must be strictly positive. + + Returns: + A ``pandas.DataFrame`` indexed by ``wacc_values`` (index name + ``"wacc"``) with columns ``growth_values`` (columns name + ``"terminal_growth"``). Cells where ``growth >= wacc`` are ``NaN`` + rather than a large number, because the perpetuity terminal value is + undefined there, not merely large -- see the module docstring. + + Raises: + ValuationError: If ``fcff_years``, ``wacc_values`` or + ``growth_values`` is empty, if ``discounting_convention`` is + unrecognised, if any ``wacc_values`` entry is at or below -1, or if + any balance-sheet item is negative or ``diluted_shares`` is not + positive. + """ + if discounting_convention not in DISCOUNTING_CONVENTIONS: + raise ValuationError( + f"sensitivity_grid: discounting_convention must be one of " + f"{DISCOUNTING_CONVENTIONS}, got {discounting_convention!r}" + ) + if not fcff_years: + raise ValuationError("sensitivity_grid: fcff_years is empty") + wacc_list = list(wacc_values) + growth_list = list(growth_values) + if not wacc_list: + raise ValuationError("sensitivity_grid: wacc_values is empty") + if not growth_list: + raise ValuationError("sensitivity_grid: growth_values is empty") + + debt = _require_nonnegative(total_debt, "total_debt", "sensitivity_grid") + cash = _require_nonnegative(cash_and_equivalents, "cash_and_equivalents", "sensitivity_grid") + minority = _require_nonnegative(minority_interest, "minority_interest", "sensitivity_grid") + preferred = _require_nonnegative(preferred_equity, "preferred_equity", "sensitivity_grid") + associates = _require_nonnegative( + associate_investments, "associate_investments", "sensitivity_grid" + ) + shares = require_positive(diluted_shares, "diluted_shares", "sensitivity_grid") + + final_fcff = fcff_years[-1].fcff + grid = np.full((len(wacc_list), len(growth_list)), np.nan, dtype=float) + + for row, wacc_value in enumerate(wacc_list): + if wacc_value <= -1.0: + raise ValuationError( + f"sensitivity_grid: wacc_values must exceed -1, got {wacc_value!r}" + ) + discounted = discount_fcff( + fcff_years, wacc_rate=wacc_value, discounting_convention=discounting_convention + ) + pv_explicit = sum(entry.present_value for entry in discounted) + terminal_factor = discounted[-1].discount_factor + for col, growth_value in enumerate(growth_list): + if growth_value >= wacc_value: + continue # left as NaN: the perpetuity terminal value is undefined here + tv = final_fcff * (1.0 + growth_value) / (wacc_value - growth_value) + enterprise_value = pv_explicit + tv * terminal_factor + equity_value = ( + enterprise_value - debt + cash - minority - preferred + associates + ) + grid[row, col] = equity_value / shares + + return pd.DataFrame( + grid, + index=pd.Index(wacc_list, name="wacc"), + columns=pd.Index(growth_list, name="terminal_growth"), + ) + + +# --------------------------------------------------------------------------- +# Top-level orchestration +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class DCFResult: + """The full DCF: every bridge, both terminal values, and the final price. + + Attributes: + wacc_build: The WACC build, with every component visible. + fcff_bridge: One :class:`FCFFYear` per projection year. + discounted_fcff: One :class:`DiscountedCashFlow` per projection year. + pv_of_explicit_fcff: Sum of ``discounted_fcff[*].present_value``. + terminal_value: Both terminal-value estimates and their cross-checks. + terminal_value_method: Which of :data:`TERMINAL_VALUE_METHODS` was + discounted into ``enterprise_value``. The other method's estimate + is still fully computed and visible on ``terminal_value``. + discounting_convention: Which of :data:`DISCOUNTING_CONVENTIONS` was used. + terminal_discount_factor: Discount factor applied to the chosen + terminal value -- the same factor used for the final explicit + year, per the module docstring. + pv_of_terminal_value: The chosen terminal value, discounted. + enterprise_value: ``pv_of_explicit_fcff + pv_of_terminal_value``. + net_debt_bridge: The full enterprise-value-to-equity-value bridge. + equity_value: ``net_debt_bridge.equity_value``. + value_per_share: ``net_debt_bridge.value_per_share``. + """ + + wacc_build: WACCResult + fcff_bridge: tuple[FCFFYear, ...] + discounted_fcff: tuple[DiscountedCashFlow, ...] + pv_of_explicit_fcff: float + terminal_value: TerminalValueResult + terminal_value_method: str + discounting_convention: str + terminal_discount_factor: float + pv_of_terminal_value: float + enterprise_value: float + net_debt_bridge: NetDebtBridgeResult + equity_value: float + value_per_share: float + + def __post_init__(self) -> None: + """Re-check that the top-level rollups match their components. + + Raises: + ValueError: If ``enterprise_value``, ``equity_value`` or + ``value_per_share`` do not match the components they are + built from. + """ + expected_pv_explicit = sum(entry.present_value for entry in self.discounted_fcff) + if not math.isclose( + self.pv_of_explicit_fcff, expected_pv_explicit, rel_tol=1e-9, abs_tol=1e-6 + ): + raise ValueError( + f"DCFResult.pv_of_explicit_fcff={self.pv_of_explicit_fcff!r} does " + f"not match sum(discounted_fcff.present_value)={expected_pv_explicit!r}" + ) + expected_ev = self.pv_of_explicit_fcff + self.pv_of_terminal_value + if not math.isclose(self.enterprise_value, expected_ev, rel_tol=1e-9, abs_tol=1e-6): + raise ValueError( + f"DCFResult.enterprise_value={self.enterprise_value!r} does not " + f"match pv_of_explicit_fcff + pv_of_terminal_value = {expected_ev!r}" + ) + if not math.isclose( + self.enterprise_value, self.net_debt_bridge.enterprise_value, rel_tol=1e-9, abs_tol=1e-6 + ): + raise ValueError( + f"DCFResult.enterprise_value={self.enterprise_value!r} does not " + f"match net_debt_bridge.enterprise_value=" + f"{self.net_debt_bridge.enterprise_value!r}" + ) + if self.equity_value != self.net_debt_bridge.equity_value: + raise ValueError( + f"DCFResult.equity_value={self.equity_value!r} does not match " + f"net_debt_bridge.equity_value={self.net_debt_bridge.equity_value!r}" + ) + if self.value_per_share != self.net_debt_bridge.value_per_share: + raise ValueError( + f"DCFResult.value_per_share={self.value_per_share!r} does not " + f"match net_debt_bridge.value_per_share=" + f"{self.net_debt_bridge.value_per_share!r}" + ) + + +def run_dcf( + inputs: Mapping[str, Any], + *, + capital_structure_basis: str, + discounting_convention: str, + terminal_value_method: str, + gdp_growth_ceiling: float = DEFAULT_LONG_RUN_GDP_GROWTH_CEILING, +) -> DCFResult: + """Run a complete DCF from a flat mapping of named inputs. + + This is the entry point :mod:`contracts` was written for: every required + field is named, checked for presence up front, and the model refuses to + run rather than substitute anything for one that is missing. + + Args: + inputs: Flat mapping of raw inputs. Required keys are + :data:`DCF_BASE_REQUIRED_FIELDS` plus + :data:`DCF_CURRENT_STRUCTURE_FIELDS` (if + ``capital_structure_basis="current"``) or + :data:`DCF_TARGET_STRUCTURE_FIELDS` (if ``"target"``). + ``inputs["terminal_growth"]`` and ``inputs["exit_multiple"]`` must + each be an :class:`Assumption` (named ``"terminal_growth"`` / + ``"exit_multiple"`` respectively), not a bare number. Optional + keys ``"size_premium"`` and ``"country_risk_premium"`` default to + ``0.0`` when absent (an explicit "not applying one", not a guessed + magnitude). ``"ebit"``, ``"depreciation_amortization"``, + ``"capex"`` and ``"delta_nwc"`` are equal-length sequences, one + entry per projection year. + capital_structure_basis: One of :data:`CAPITAL_STRUCTURE_BASES`. + Required, no default. + discounting_convention: One of :data:`DISCOUNTING_CONVENTIONS`. + Required, no default. + terminal_value_method: One of :data:`TERMINAL_VALUE_METHODS`, selecting + which terminal-value estimate is discounted into + ``enterprise_value``. Required, no default. Both estimates are + always computed and cross-checked regardless of which is chosen -- + see ``DCFResult.terminal_value``. + gdp_growth_ceiling: Reference ceiling used only to flag (never block) + ``terminal_growth`` -- see :data:`DEFAULT_LONG_RUN_GDP_GROWTH_CEILING`. + + Returns: + A :class:`DCFResult` with every intermediate figure visible. + + Raises: + MissingInputError: If any required key is absent from ``inputs``. + ValuationError: If ``capital_structure_basis``, + ``discounting_convention`` or ``terminal_value_method`` is + unrecognised; if ``terminal_growth`` or ``exit_multiple`` is not + the matching :class:`Assumption`; if ``terminal_growth.value >=`` + the built WACC; if any capital-structure, FCFF-bridge or + balance-sheet input fails its own guard (see :func:`wacc`, + :func:`fcff_bridge`, :func:`terminal_value`, :func:`equity_bridge`). + """ + if capital_structure_basis not in CAPITAL_STRUCTURE_BASES: + raise ValuationError( + f"run_dcf: capital_structure_basis must be one of " + f"{CAPITAL_STRUCTURE_BASES}, got {capital_structure_basis!r}" + ) + if discounting_convention not in DISCOUNTING_CONVENTIONS: + raise ValuationError( + f"run_dcf: discounting_convention must be one of " + f"{DISCOUNTING_CONVENTIONS}, got {discounting_convention!r}" + ) + if terminal_value_method not in TERMINAL_VALUE_METHODS: + raise ValuationError( + f"run_dcf: terminal_value_method must be one of " + f"{TERMINAL_VALUE_METHODS}, got {terminal_value_method!r}" + ) + + structure_fields = ( + DCF_CURRENT_STRUCTURE_FIELDS + if capital_structure_basis == "current" + else DCF_TARGET_STRUCTURE_FIELDS + ) + require_inputs(inputs, (*DCF_BASE_REQUIRED_FIELDS, *structure_fields), model="run_dcf") + + terminal_growth = _require_assumption(inputs["terminal_growth"], "terminal_growth", "run_dcf") + exit_multiple = _require_assumption(inputs["exit_multiple"], "exit_multiple", "run_dcf") + + wacc_build = wacc( + risk_free_rate=inputs["risk_free_rate"], + beta=inputs["beta"], + equity_risk_premium=inputs["equity_risk_premium"], + pretax_cost_of_debt=inputs["pretax_cost_of_debt"], + tax_rate=inputs["tax_rate"], + capital_structure_basis=capital_structure_basis, + market_value_of_equity=inputs.get("market_value_of_equity"), + market_value_of_debt=inputs.get("market_value_of_debt"), + target_equity_weight=inputs.get("target_equity_weight"), + target_debt_weight=inputs.get("target_debt_weight"), + size_premium=inputs.get("size_premium", 0.0), + country_risk_premium=inputs.get("country_risk_premium", 0.0), + ) + + bridge = fcff_bridge( + ebit=inputs["ebit"], + tax_rate=inputs["tax_rate"], + depreciation_amortization=inputs["depreciation_amortization"], + capex=inputs["capex"], + delta_nwc=inputs["delta_nwc"], + ) + final_year = bridge[-1] + terminal_year_ebitda = final_year.ebit + final_year.depreciation_amortization + + tv = terminal_value( + final_year_fcff=final_year.fcff, + terminal_year_ebitda=terminal_year_ebitda, + wacc_rate=wacc_build.wacc, + terminal_growth=terminal_growth, + exit_multiple=exit_multiple, + gdp_growth_ceiling=gdp_growth_ceiling, + ) + + discounted = discount_fcff( + bridge, wacc_rate=wacc_build.wacc, discounting_convention=discounting_convention + ) + pv_explicit = sum(entry.present_value for entry in discounted) + + chosen_tv = ( + tv.perpetuity_terminal_value + if terminal_value_method == "perpetuity_growth" + else tv.exit_terminal_value + ) + # Discounted at the same timing as the final explicit year -- see the + # module docstring for why this makes the mid-year/year-end enterprise + # value ratio exactly sqrt(1 + WACC). + terminal_discount_factor = discounted[-1].discount_factor + pv_of_terminal_value = chosen_tv * terminal_discount_factor + enterprise_value = pv_explicit + pv_of_terminal_value + + net_debt = equity_bridge( + enterprise_value=enterprise_value, + total_debt=inputs["total_debt"], + cash_and_equivalents=inputs["cash_and_equivalents"], + minority_interest=inputs["minority_interest"], + preferred_equity=inputs["preferred_equity"], + associate_investments=inputs["associate_investments"], + diluted_shares=inputs["diluted_shares"], + ) + + return DCFResult( + wacc_build=wacc_build, + fcff_bridge=bridge, + discounted_fcff=discounted, + pv_of_explicit_fcff=pv_explicit, + terminal_value=tv, + terminal_value_method=terminal_value_method, + discounting_convention=discounting_convention, + terminal_discount_factor=terminal_discount_factor, + pv_of_terminal_value=pv_of_terminal_value, + enterprise_value=enterprise_value, + net_debt_bridge=net_debt, + equity_value=net_debt.equity_value, + value_per_share=net_debt.value_per_share, + ) diff --git a/agent/src/quantlib/valuation/threestatement.py b/agent/src/quantlib/valuation/threestatement.py new file mode 100644 index 00000000..b7626d79 --- /dev/null +++ b/agent/src/quantlib/valuation/threestatement.py @@ -0,0 +1,727 @@ +"""Linked three-statement projection: P&L, cash flow and balance sheet, one model. + +A three-statement model is only as good as its linkage. It is trivial to build +three spreadsheets that each look plausible in isolation and disagree the moment +you check whether assets equal liabilities plus equity -- that disagreement is +usually smoothed over with a hidden "plug" cell nobody labelled. This module +makes that failure mode structurally unreachable: + +* Every line on the balance sheet is *derived* from the income statement and + cash flow statement of the same period, never entered independently. Cash + rolls forward by the cash flow statement's net change; retained earnings + rolls forward by net income less dividends; PP&E rolls forward by capex less + depreciation. There is nothing left to plug. +* :func:`check_balance_sheet` asserts ``assets == liabilities + equity`` after + the opening balance sheet and after every projected period, and raises + :class:`BalanceSheetError` naming the exact residual if it does not. Nothing + catches or downgrades that exception -- an unbalanced period is not a + projection, it is a bug. +* The one place this model deliberately keeps a balancing account is the + **revolving credit facility**: if cash flow before financing would push cash + below ``minimum_cash``, the revolver draws exactly the shortfall; if there is + excess cash and an outstanding balance, the revolver sweeps exactly the + excess (capped at what is owed). This is a plug in the sense that it exists + to make the cash line come out right -- but it is *declared*, reported as its + own field (:attr:`BalanceSheet.revolver_balance`, plus + :attr:`CashFlowStatement.revolver_draw` / ``revolver_repayment``) and governed + by an explicit, inspectable policy (``minimum_cash``). A silent plug hides + what moved and why; this one names it on every period. + +CIRCULARITY +----------- +Interest expense is charged on the *average* of the beginning and ending +revolver balance, which is standard practice (it does not reward or penalise a +company for exactly when in the period it drew or repaid). But the ending +balance depends on net income (through the cash available to repay or the +shortfall to cover), and net income depends on interest expense -- so a +period's own ending debt balance is defined in terms of itself. This module +resolves that with fixed-point iteration (see :func:`_project_period`): guess +an ending balance, price interest off it, run the statements through to a new +ending balance, and repeat until the guess stops moving. Every +:class:`PeriodResult` reports ``converged`` and ``iterations`` so a caller can +see the solve actually happened rather than trusting it silently. A period that +fails to converge within :data:`MAX_CIRCULARITY_ITERATIONS` raises +:class:`ConvergenceError` instead of returning the last guess dressed up as an +answer. + +SCOPE, STATED PLAINLY +---------------------- +* Dividends are paid only out of positive net income (``max(net_income, 0) * + dividend_payout_ratio``); a loss-making period pays no dividend and receives + no offsetting "negative dividend". The payout ratio itself is still a + required, caller-supplied input -- this is a rule about how it applies, not a + default value for it. +* Net working capital is carried as one net line (its ratio to revenue is a + required driver); this model does not decompose it into receivables, + inventory and payables. A negative NWC balance is valid and simply reduces + total assets, which is mathematically identical to carrying the same net + liability on the other side of the ledger. +* Paid-in capital never moves: this model has no share-issuance or buyback + driver, so the only equity account that changes is retained earnings. +* Depreciation and interest rate are required per-period inputs, not derived + from a ratio driver -- see :data:`DRIVER_REQUIRED_FIELDS`. A model that + invented a depreciation-to-revenue ratio nobody asked for would be exactly + the kind of silently-chosen assumption :mod:`.contracts` exists to prevent. + +Every required input across the opening balance sheet and every driver is +checked with :func:`~src.quantlib.valuation.contracts.require_inputs` before +any arithmetic runs -- a missing field stops the model, never gets a default. +""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +from src.quantlib.valuation.contracts import ( + MissingInputError, # noqa: F401 -- re-exported for callers that catch it here + ValuationError, + require_inputs, + require_positive, +) + +__all__ = [ + "MODEL_NAME", + "BALANCE_TOLERANCE_REL", + "CIRCULARITY_TOLERANCE_REL", + "MAX_CIRCULARITY_ITERATIONS", + "OPENING_REQUIRED_FIELDS", + "DRIVER_REQUIRED_FIELDS", + "BalanceSheetError", + "ConvergenceError", + "IncomeStatement", + "CashFlowStatement", + "BalanceSheet", + "PeriodResult", + "ThreeStatementProjection", + "check_balance_sheet", + "project_three_statement", +] + +#: Name used in every error message raised by this module. +MODEL_NAME = "three_statement_projection" + +#: How far ``assets - (liabilities + equity)`` may drift, as a fraction of total +#: assets, before a balance sheet is refused. Scaled by assets (not an absolute +#: dollar amount) so the same tolerance is equally strict for a $10k projection +#: and a $10bn one. 1e-9 is far looser than the ~1e-12..1e-10 floating-point +#: noise a correctly-linked model accumulates over a handful of chained +#: arithmetic operations, and far tighter than any real accounting break, so it +#: catches genuine plugs without ever flagging rounding. +BALANCE_TOLERANCE_REL: float = 1e-9 + +#: Fixed-point convergence tolerance for the interest/revolver circularity, +#: expressed relative to the ending debt balance being solved for. Set an order +#: of magnitude tighter than BALANCE_TOLERANCE_REL because the balance-sheet +#: check is downstream of this solve: a loosely-converged debt balance would +#: still make the accounting identity hold exactly (it holds for any +#: self-consistent snapshot, converged or not -- see the module docstring), but +#: it would report a period whose interest was not actually priced off its own +#: ending balance. The tightness here is about answering the right economic +#: question, not about balancing. +CIRCULARITY_TOLERANCE_REL: float = 1e-10 + +#: Iteration budget for the revolver/interest fixed point. Realistic financing +#: rates put the loop's contraction factor -- roughly +#: ``(1 - payout) * (1 - tax) * interest_rate / 2`` from averaging the two debt +#: balances -- well under 0.5 per period, which reaches CIRCULARITY_TOLERANCE_REL +#: in well under 10 passes. 100 leaves an order of magnitude of headroom for +#: unusually high-yield financing before a genuinely non-convergent (contraction +#: factor >= 1) input is refused instead of silently truncated. +MAX_CIRCULARITY_ITERATIONS: int = 100 + +#: Required keys of the ``opening`` mapping passed to :func:`project_three_statement`. +OPENING_REQUIRED_FIELDS: tuple[str, ...] = ( + "revenue", + "cash", + "net_working_capital", + "ppe", + "revolver_balance", + "paid_in_capital", + "retained_earnings", +) + +#: Required keys of the ``drivers`` mapping passed to :func:`project_three_statement`. +#: Every value must be a sequence of the same length -- one entry per projected +#: period. ``depreciation_amortization``, ``interest_rate`` and ``minimum_cash`` +#: are required alongside the seven ratio/rate drivers because the linkage the +#: model exists to enforce (D&A into the PP&E rollforward, interest into the +#: revolver circularity, minimum_cash into the revolver policy) has no +#: economically defensible ratio to default them from; see the module docstring. +DRIVER_REQUIRED_FIELDS: tuple[str, ...] = ( + "revenue_growth", + "gross_margin", + "opex_pct_revenue", + "capex_pct_revenue", + "nwc_pct_revenue", + "tax_rate", + "dividend_payout_ratio", + "depreciation_amortization", + "interest_rate", + "minimum_cash", +) + + +class BalanceSheetError(ValuationError): + """Raised when a balance sheet fails ``assets == liabilities + equity``. + + Attributes: + period: The period index that failed (0 is the opening balance sheet). + assets: Total assets computed for that period. + liabilities: Total liabilities computed for that period. + equity: Total equity computed for that period. + residual: ``assets - liabilities - equity``. Non-zero is the failure. + tolerance: The relative tolerance that was exceeded. + """ + + def __init__(self, period: int, assets: float, liabilities: float, equity: float, tolerance: float) -> None: + self.period = period + self.assets = assets + self.liabilities = liabilities + self.equity = equity + self.residual = assets - liabilities - equity + self.tolerance = tolerance + super().__init__( + f"{MODEL_NAME}: period {period} balance sheet does not balance -- " + f"assets {assets!r} != liabilities {liabilities!r} + equity {equity!r} " + f"(residual {self.residual!r}, tolerance {tolerance!r} of assets). " + "This model derives every balance-sheet line from the income " + "statement and cash flow statement; a non-zero residual means an " + "input or a rollforward was overridden by hand, not that the model " + "chose to plug it." + ) + + +class ConvergenceError(ValuationError): + """Raised when the interest/revolver circularity fails to converge. + + Attributes: + period: The period whose fixed-point solve failed. + iterations: How many iterations were attempted (equals the budget). + last_delta: The absolute change in the ending debt balance on the final + iteration -- still larger than ``tolerance`` allowed. + tolerance: The relative convergence tolerance that was not met. + """ + + def __init__(self, period: int, iterations: int, last_delta: float, tolerance: float) -> None: + self.period = period + self.iterations = iterations + self.last_delta = last_delta + self.tolerance = tolerance + super().__init__( + f"{MODEL_NAME}: period {period} interest/revolver circularity did not " + f"converge in {iterations} iteration(s) (last change in ending debt " + f"{last_delta!r}, tolerance {tolerance!r}). Refusing to return the " + "last iteration's numbers as if they were a solved period -- widen " + "max_circularity_iterations only after confirming the loop is " + "actually contracting, not just slow." + ) + + +@dataclass(frozen=True) +class IncomeStatement: + """One period's income statement, linked end to end. + + The chain is ``revenue -> gross_profit -> ebitda -> (less D&A) -> ebit -> + (less interest) -> pretax_income -> (less tax) -> net_income``. + + Attributes: + period: 1-indexed projection period. + revenue: Period revenue. + gross_profit: ``revenue * gross_margin``. + opex: Operating expense, ``revenue * opex_pct_revenue``. + ebitda: ``gross_profit - opex``. + depreciation_amortization: D&A for the period (a required input, not a + derived ratio -- see the module docstring). + ebit: ``ebitda - depreciation_amortization``. + interest_expense: Interest on the average revolver balance for the + period; the output of the circularity solve. + pretax_income: ``ebit - interest_expense``. + tax_expense: ``pretax_income * tax_rate``. Negative when pretax income + is negative -- this model does not model a tax-loss carryforward, + so a loss period shows a tax benefit at the same rate. + net_income: ``pretax_income - tax_expense``. + """ + + period: int + revenue: float + gross_profit: float + opex: float + ebitda: float + depreciation_amortization: float + ebit: float + interest_expense: float + pretax_income: float + tax_expense: float + net_income: float + + @property + def cogs(self) -> float: + """Return cost of goods sold, implied by revenue and gross profit. + + Returns: + ``revenue - gross_profit``. + """ + return self.revenue - self.gross_profit + + +@dataclass(frozen=True) +class CashFlowStatement: + """One period's cash flow statement, bridging net income to the cash change. + + Implements ``net_income + D&A - change_in_net_working_capital - capex + + financing_activities = net_change_in_cash`` exactly. + + Attributes: + period: 1-indexed projection period. + net_income: Copied from the period's :class:`IncomeStatement`. + depreciation_amortization: Added back as a non-cash expense. + change_in_net_working_capital: ``nwc_end - nwc_begin``. A positive + value (NWC grew) is a use of cash and is subtracted. + capex: Capital expenditure, a use of cash. + dividends_paid: Cash dividends for the period (zero in a loss period; + see the module docstring). + revolver_draw: Cash drawn on the revolver this period (>= 0). This is + the explicit, reported plug that keeps cash from falling below + ``minimum_cash``. + revolver_repayment: Cash used to pay down the revolver this period + (>= 0), capped at the balance outstanding. + financing_activities: ``revolver_draw - revolver_repayment - + dividends_paid``. + net_change_in_cash: The full bridge total; matches + ``BalanceSheet.cash - previous BalanceSheet.cash`` exactly. + """ + + period: int + net_income: float + depreciation_amortization: float + change_in_net_working_capital: float + capex: float + dividends_paid: float + revolver_draw: float + revolver_repayment: float + financing_activities: float + net_change_in_cash: float + + @property + def cash_flow_from_operations(self) -> float: + """Return operating cash flow, before investing and financing. + + Returns: + ``net_income + depreciation_amortization - change_in_net_working_capital``. + """ + return self.net_income + self.depreciation_amortization - self.change_in_net_working_capital + + @property + def free_cash_flow(self) -> float: + """Return free cash flow, before financing activities. + + Returns: + :attr:`cash_flow_from_operations` minus :attr:`capex`. + """ + return self.cash_flow_from_operations - self.capex + + +@dataclass(frozen=True) +class BalanceSheet: + """One period's balance sheet. Every field is a rollforward, never an input. + + ``period=0`` is the opening balance sheet supplied by the caller; every + other period is derived: cash rolls forward by the cash flow statement's + net change, PP&E by capex less D&A, the revolver by its draws and + repayments, and retained earnings by net income less dividends. Paid-in + capital never moves (see the module docstring). + + Attributes: + period: 0 for the opening balance sheet, 1-indexed thereafter. + cash: Ending cash balance. + net_working_capital: Ending net working capital (a single net line; + see the module docstring). + ppe: Ending net property, plant and equipment. + revolver_balance: Ending balance on the revolving credit facility -- + the model's one explicit, reported plug. + paid_in_capital: Contributed capital. Constant across every period. + retained_earnings: Ending retained earnings; may be negative. + """ + + period: int + cash: float + net_working_capital: float + ppe: float + revolver_balance: float + paid_in_capital: float + retained_earnings: float + + @property + def total_assets(self) -> float: + """Return total assets. + + Returns: + ``cash + net_working_capital + ppe``. + """ + return self.cash + self.net_working_capital + self.ppe + + @property + def total_liabilities(self) -> float: + """Return total liabilities. + + Returns: + ``revolver_balance`` -- the only liability this model carries. + """ + return self.revolver_balance + + @property + def total_equity(self) -> float: + """Return total equity. + + Returns: + ``paid_in_capital + retained_earnings``. + """ + return self.paid_in_capital + self.retained_earnings + + +@dataclass(frozen=True) +class PeriodResult: + """One projected period's linked statements plus circularity diagnostics. + + Attributes: + period: 1-indexed projection period. + income_statement: The period's :class:`IncomeStatement`. + cash_flow_statement: The period's :class:`CashFlowStatement`. + balance_sheet: The period's :class:`BalanceSheet`, already checked by + :func:`check_balance_sheet` before this object is returned. + converged: Whether the interest/revolver fixed point converged. Always + ``True`` on a returned :class:`PeriodResult` -- a non-convergent + period raises :class:`ConvergenceError` instead of being returned; + the field is kept on the result so a caller inspecting a completed + projection does not have to take convergence on faith. + iterations: Number of fixed-point iterations the circularity solve + took. ``1`` means the initial guess (no revolver movement) was + already the fixed point -- for example, a period with zero + beginning debt and no shortfall. + """ + + period: int + income_statement: IncomeStatement + cash_flow_statement: CashFlowStatement + balance_sheet: BalanceSheet + converged: bool + iterations: int + + +@dataclass(frozen=True) +class ThreeStatementProjection: + """A full multi-period projection: the opening balance sheet plus every period. + + Attributes: + opening_balance_sheet: The caller-supplied opening balance sheet + (``period=0``), already checked by :func:`check_balance_sheet`. + periods: Projected periods in chronological order, 1-indexed. + """ + + opening_balance_sheet: BalanceSheet + periods: tuple[PeriodResult, ...] + + +def check_balance_sheet(balance_sheet: BalanceSheet, tolerance: float = BALANCE_TOLERANCE_REL) -> None: + """Assert that a balance sheet balances, or raise naming the residual. + + Args: + balance_sheet: The balance sheet to check. + tolerance: Largest tolerated ``|residual| / max(total_assets, 1.0)``. + Defaults to :data:`BALANCE_TOLERANCE_REL`. + + Raises: + BalanceSheetError: If ``assets != liabilities + equity`` beyond + ``tolerance``. The exception carries the exact residual. + """ + assets = balance_sheet.total_assets + liabilities = balance_sheet.total_liabilities + equity = balance_sheet.total_equity + residual = assets - liabilities - equity + scale = max(abs(assets), 1.0) + if abs(residual) > tolerance * scale: + raise BalanceSheetError(balance_sheet.period, assets, liabilities, equity, tolerance) + + +def _resolve_period_count(drivers: Mapping[str, Sequence[float]]) -> int: + """Validate every driver sequence shares one non-zero length and return it. + + Args: + drivers: The driver mapping already checked by + :func:`~src.quantlib.valuation.contracts.require_inputs`. + + Returns: + The number of periods to project. + + Raises: + ValuationError: If the driver sequences disagree in length, or all are + empty. + """ + lengths = {field: len(drivers[field]) for field in DRIVER_REQUIRED_FIELDS} + distinct = set(lengths.values()) + if len(distinct) > 1: + detail = ", ".join(f"{field}={n}" for field, n in lengths.items()) + raise ValuationError( + f"{MODEL_NAME}: every driver sequence must share one length (one entry " + f"per projected period); got {detail}" + ) + periods = distinct.pop() + if periods < 1: + raise ValuationError( + f"{MODEL_NAME}: driver sequences are empty; at least one projection " + "period is required" + ) + return periods + + +def _project_period( + period: int, + prior_bs: BalanceSheet, + prior_revenue: float, + revenue_growth: float, + gross_margin: float, + opex_pct_revenue: float, + capex_pct_revenue: float, + nwc_pct_revenue: float, + tax_rate: float, + dividend_payout_ratio: float, + depreciation_amortization: float, + interest_rate: float, + minimum_cash: float, + circularity_tolerance: float, + max_circularity_iterations: int, +) -> PeriodResult: + """Project one period, solving the interest/revolver circularity by iteration. + + See the module docstring for the fixed-point scheme: the ending revolver + balance is guessed, interest is priced on the average of the beginning and + guessed ending balance, the statements are run through to a freshly + computed ending balance, and the guess is replaced by that value until it + stops moving. + + Args: + period: 1-indexed period being projected. + prior_bs: The previous period's (or the opening) balance sheet. + prior_revenue: The previous period's (or the opening) revenue. + revenue_growth: This period's revenue growth rate. + gross_margin: This period's gross margin. + opex_pct_revenue: This period's operating expense as a fraction of revenue. + capex_pct_revenue: This period's capex as a fraction of revenue. + nwc_pct_revenue: This period's net working capital as a fraction of revenue. + tax_rate: This period's tax rate. + dividend_payout_ratio: This period's dividend payout ratio (applied to + positive net income only). + depreciation_amortization: This period's D&A, in currency units. + interest_rate: This period's interest rate on the average revolver balance. + minimum_cash: This period's minimum cash the revolver policy targets. + circularity_tolerance: Relative convergence tolerance on the ending + revolver balance. + max_circularity_iterations: Iteration budget before refusing to answer. + + Returns: + The period's :class:`PeriodResult`, with a balance sheet that has + already passed :func:`check_balance_sheet`. + + Raises: + ConvergenceError: If the fixed point does not converge within + ``max_circularity_iterations``. + BalanceSheetError: If the resulting balance sheet does not balance + (this should be unreachable given the rollforwards below, and its + presence here is a deliberate defensive check, not the primary + guard -- see the module docstring). + """ + revenue = prior_revenue * (1.0 + revenue_growth) + gross_profit = revenue * gross_margin + opex = revenue * opex_pct_revenue + ebitda = gross_profit - opex + ebit = ebitda - depreciation_amortization + capex = revenue * capex_pct_revenue + nwc_end = revenue * nwc_pct_revenue + change_in_nwc = nwc_end - prior_bs.net_working_capital + + debt_begin = prior_bs.revolver_balance + debt_end_guess = debt_begin + + interest_expense = pretax_income = tax_expense = net_income = 0.0 + dividends_paid = revolver_draw = revolver_repayment = 0.0 + debt_end_actual = debt_end_guess + cash_end = prior_bs.cash + delta = float("inf") + converged = False + iterations = 0 + + for iterations in range(1, max_circularity_iterations + 1): + avg_debt = (debt_begin + debt_end_guess) / 2.0 + interest_expense = interest_rate * avg_debt + pretax_income = ebit - interest_expense + tax_expense = pretax_income * tax_rate + net_income = pretax_income - tax_expense + dividends_paid = max(net_income, 0.0) * dividend_payout_ratio + + cash_flow_before_financing = net_income + depreciation_amortization - change_in_nwc - capex - dividends_paid + cash_before_revolver = prior_bs.cash + cash_flow_before_financing + + if cash_before_revolver >= minimum_cash: + surplus = cash_before_revolver - minimum_cash + revolver_repayment = min(surplus, debt_begin) + revolver_draw = 0.0 + else: + revolver_draw = minimum_cash - cash_before_revolver + revolver_repayment = 0.0 + + debt_end_actual = debt_begin + revolver_draw - revolver_repayment + cash_end = cash_before_revolver + revolver_draw - revolver_repayment + + scale = max(abs(debt_end_actual), 1.0) + delta = abs(debt_end_actual - debt_end_guess) + if delta <= circularity_tolerance * scale: + converged = True + break + debt_end_guess = debt_end_actual + + if not converged: + raise ConvergenceError(period, iterations, delta, circularity_tolerance) + + financing_activities = revolver_draw - revolver_repayment - dividends_paid + net_change_in_cash = net_income + depreciation_amortization - change_in_nwc - capex + financing_activities + + income_statement = IncomeStatement( + period=period, + revenue=revenue, + gross_profit=gross_profit, + opex=opex, + ebitda=ebitda, + depreciation_amortization=depreciation_amortization, + ebit=ebit, + interest_expense=interest_expense, + pretax_income=pretax_income, + tax_expense=tax_expense, + net_income=net_income, + ) + cash_flow_statement = CashFlowStatement( + period=period, + net_income=net_income, + depreciation_amortization=depreciation_amortization, + change_in_net_working_capital=change_in_nwc, + capex=capex, + dividends_paid=dividends_paid, + revolver_draw=revolver_draw, + revolver_repayment=revolver_repayment, + financing_activities=financing_activities, + net_change_in_cash=net_change_in_cash, + ) + balance_sheet = BalanceSheet( + period=period, + cash=cash_end, + net_working_capital=nwc_end, + ppe=prior_bs.ppe + capex - depreciation_amortization, + revolver_balance=debt_end_actual, + paid_in_capital=prior_bs.paid_in_capital, + retained_earnings=prior_bs.retained_earnings + net_income - dividends_paid, + ) + check_balance_sheet(balance_sheet) + + return PeriodResult( + period=period, + income_statement=income_statement, + cash_flow_statement=cash_flow_statement, + balance_sheet=balance_sheet, + converged=converged, + iterations=iterations, + ) + + +def project_three_statement( + opening: Mapping[str, float], + drivers: Mapping[str, Sequence[float]], + circularity_tolerance: float = CIRCULARITY_TOLERANCE_REL, + max_circularity_iterations: int = MAX_CIRCULARITY_ITERATIONS, + balance_tolerance: float = BALANCE_TOLERANCE_REL, +) -> ThreeStatementProjection: + """Project linked income, cash flow and balance-sheet statements forward. + + Args: + opening: The opening balance sheet plus trailing revenue. Must contain + every field in :data:`OPENING_REQUIRED_FIELDS`. + drivers: Per-period driver sequences. Must contain every field in + :data:`DRIVER_REQUIRED_FIELDS`, each a sequence of the same length + (one entry per projected period, chronological order). + circularity_tolerance: Relative convergence tolerance for the + interest/revolver fixed point. Defaults to + :data:`CIRCULARITY_TOLERANCE_REL`. + max_circularity_iterations: Iteration budget for that fixed point. + Defaults to :data:`MAX_CIRCULARITY_ITERATIONS`. + balance_tolerance: Relative tolerance for the hard balance-sheet check. + Defaults to :data:`BALANCE_TOLERANCE_REL`. + + Returns: + A :class:`ThreeStatementProjection` whose opening and every projected + balance sheet has passed :func:`check_balance_sheet`, and whose every + :class:`PeriodResult` converged. + + Raises: + MissingInputError: If ``opening`` or ``drivers`` is missing a required + field. + ValuationError: If the driver sequences disagree in length, are empty, + or if ``circularity_tolerance`` / ``balance_tolerance`` / + ``max_circularity_iterations`` are not usable. + BalanceSheetError: If the opening balance sheet, or any projected + period's balance sheet, does not balance. + ConvergenceError: If any period's interest/revolver circularity fails + to converge within ``max_circularity_iterations``. + """ + require_inputs(opening, OPENING_REQUIRED_FIELDS, MODEL_NAME) + require_inputs(drivers, DRIVER_REQUIRED_FIELDS, MODEL_NAME) + circularity_tolerance = require_positive(circularity_tolerance, "circularity_tolerance", MODEL_NAME) + balance_tolerance = require_positive(balance_tolerance, "balance_tolerance", MODEL_NAME) + if max_circularity_iterations < 1: + raise ValuationError( + f"{MODEL_NAME}: max_circularity_iterations must be >= 1, got " + f"{max_circularity_iterations!r}" + ) + + periods = _resolve_period_count(drivers) + + opening_balance_sheet = BalanceSheet( + period=0, + cash=float(opening["cash"]), + net_working_capital=float(opening["net_working_capital"]), + ppe=float(opening["ppe"]), + revolver_balance=float(opening["revolver_balance"]), + paid_in_capital=float(opening["paid_in_capital"]), + retained_earnings=float(opening["retained_earnings"]), + ) + check_balance_sheet(opening_balance_sheet, balance_tolerance) + + prior_bs = opening_balance_sheet + prior_revenue = float(opening["revenue"]) + results: list[PeriodResult] = [] + for index in range(periods): + result = _project_period( + period=index + 1, + prior_bs=prior_bs, + prior_revenue=prior_revenue, + revenue_growth=float(drivers["revenue_growth"][index]), + gross_margin=float(drivers["gross_margin"][index]), + opex_pct_revenue=float(drivers["opex_pct_revenue"][index]), + capex_pct_revenue=float(drivers["capex_pct_revenue"][index]), + nwc_pct_revenue=float(drivers["nwc_pct_revenue"][index]), + tax_rate=float(drivers["tax_rate"][index]), + dividend_payout_ratio=float(drivers["dividend_payout_ratio"][index]), + depreciation_amortization=float(drivers["depreciation_amortization"][index]), + interest_rate=float(drivers["interest_rate"][index]), + minimum_cash=float(drivers["minimum_cash"][index]), + circularity_tolerance=circularity_tolerance, + max_circularity_iterations=max_circularity_iterations, + ) + # Defensive re-check with the caller's tolerance: _project_period already + # checked with the module default, but a caller-widened balance_tolerance + # must not be allowed to let a period through unchecked at its own bar. + check_balance_sheet(result.balance_sheet, balance_tolerance) + results.append(result) + prior_bs = result.balance_sheet + prior_revenue = result.income_statement.revenue + + return ThreeStatementProjection(opening_balance_sheet=opening_balance_sheet, periods=tuple(results)) diff --git a/agent/src/quantlib/var_backtest.py b/agent/src/quantlib/var_backtest.py new file mode 100644 index 00000000..30007e1e --- /dev/null +++ b/agent/src/quantlib/var_backtest.py @@ -0,0 +1,654 @@ +"""VaR model backtesting: does the model's stated confidence level hold up? + +``src.quantlib.risk`` *produces* VaR numbers. This module *validates* them, which +is a separate job with a separate consumer: a risk function has to demonstrate +that a 99% VaR was breached on roughly 1% of days, and that the breaches did not +arrive in clusters. A VaR that is right on average and wrong in bursts is the one +that ends a firm, because the losses land consecutively while the model still +reports a passing exception count. + +Three tests, deliberately kept separate rather than collapsed into one verdict: + +``kupiec_pof`` + Unconditional coverage. Are there the right *number* of breaches? Blind to + when they happened -- a model breached ten days running and then never again + passes this test. + +``christoffersen_independence`` + Independence. Does a breach today predict a breach tomorrow? Blind to the + level -- a model breached on 30% of days but evenly scattered passes this + test. + +``christoffersen_conditional_coverage`` + Both at once: ``LR_cc = LR_uc + LR_ind``, the sum being exact because the + two restrictions are nested. Reported alongside its components, never + instead of them, because a rejection tells you nothing about which half + failed. + +``basel_traffic_light`` is the supervisory overlay: the zone follows from the +cumulative binomial probability of the observed exception count, which is the +general form of the rule. The 1996 Basel scaling-factor table, by contrast, is +tabulated for a **250-day window at 99%** and is only reported when the sample +actually matches that; applying those multipliers to a 60-day sample would be a +category error dressed as a regulatory figure. See +:data:`BASEL_SCALING_FACTORS`. + +WHAT COUNTS AS A BREACH +----------------------- +``return < -var``. The sign convention is inherited unchanged from +:mod:`src.quantlib.risk`: **a loss is a positive number**, so a 99% VaR of +``0.028`` means "a 2.8% loss", and a day returning ``-0.031`` breaches it while a +day returning ``-0.019`` does not. A VaR series that arrives negative is not +silently flipped -- see :func:`violation_indicator`. + +ALIGNMENT IS NOT ASSUMED +------------------------ +When both the returns and the VaR arrive as pandas Series, they are joined on +their index and any non-overlapping observation is an **error**, not a silent +drop. An off-by-one join between a return series and the VaR that was supposed +to have been forecast for it produces a plausible-looking exception count from +comparing each day against the wrong day's forecast, and nothing downstream can +detect it. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import numpy as np +import pandas as pd +from scipy.special import xlogy +from scipy.stats import binom, chi2 + +__all__ = [ + "BASEL_GREEN_MAX_CDF", + "BASEL_RED_MIN_CDF", + "BASEL_SCALING_FACTORS", + "BASEL_WINDOW", + "BASEL_CONFIDENCE", + "ConditionalCoverageResult", + "IndependenceResult", + "KupiecResult", + "TrafficLightResult", + "VarBacktestReport", + "violation_indicator", + "kupiec_pof", + "christoffersen_independence", + "christoffersen_conditional_coverage", + "basel_traffic_light", + "var_backtest", +] + +#: Cumulative binomial probability at or above which the Basel zone leaves green. +BASEL_GREEN_MAX_CDF: float = 0.95 + +#: Cumulative binomial probability at or above which the Basel zone becomes red. +BASEL_RED_MIN_CDF: float = 0.9999 + +#: Observation count the Basel scaling-factor table is tabulated for. +BASEL_WINDOW: int = 250 + +#: VaR confidence level the Basel scaling-factor table is tabulated for. +BASEL_CONFIDENCE: float = 0.99 + +#: Basel (1996) capital scaling factors by exception count, for a 250-day +#: backtest of a 99% VaR. Counts of 10 or more sit in the red zone at 4.00. +#: Keyed by exception count; only defined over the yellow zone and its +#: boundaries, because green is flat at 3.00 and red is flat at 4.00. +BASEL_SCALING_FACTORS: dict[int, float] = { + 0: 3.00, 1: 3.00, 2: 3.00, 3: 3.00, 4: 3.00, + 5: 3.40, 6: 3.50, 7: 3.65, 8: 3.75, 9: 3.85, +} + +#: Scaling factor applied to every red-zone exception count. +BASEL_RED_SCALING_FACTOR: float = 4.00 + + +@dataclass(frozen=True) +class KupiecResult: + """Outcome of the Kupiec proportion-of-failures test. + + Attributes: + observations: Number of paired observations tested. + violations: Number of days the loss exceeded VaR. + expected_violations: ``observations * (1 - confidence)``. + violation_rate: Realised breach frequency, ``violations / observations``. + expected_rate: ``1 - confidence``, the rate the model claims. + statistic: Likelihood-ratio statistic ``LR_uc``, chi-square with 1 df. + p_value: Probability of a statistic this large if the model is correct. + rejected: True when ``p_value < significance``. + significance: Significance level the rejection was judged at. + """ + + observations: int + violations: int + expected_violations: float + violation_rate: float + expected_rate: float + statistic: float + p_value: float + rejected: bool + significance: float + + +@dataclass(frozen=True) +class IndependenceResult: + """Outcome of the Christoffersen independence test. + + Attributes: + transitions: Counts ``(n00, n01, n10, n11)`` of consecutive state pairs, + where state 1 is a breach day. + prob_breach_after_calm: Estimated P(breach tomorrow | no breach today). + prob_breach_after_breach: Estimated P(breach tomorrow | breach today). + statistic: Likelihood-ratio statistic ``LR_ind``, chi-square with 1 df. + p_value: Probability of a statistic this large under independence. + rejected: True when ``p_value < significance``. + significance: Significance level the rejection was judged at. + identified: False when the sample cannot distinguish the two transition + probabilities at all -- no breach occurred, or every breach sits at + the very end of the sample. The statistic is then 0 by construction + and the test carries no evidence either way. + """ + + transitions: tuple[int, int, int, int] + prob_breach_after_calm: float + prob_breach_after_breach: float + statistic: float + p_value: float + rejected: bool + significance: float + identified: bool + + +@dataclass(frozen=True) +class ConditionalCoverageResult: + """Outcome of the joint Christoffersen conditional-coverage test. + + Attributes: + statistic: ``LR_cc = LR_uc + LR_ind``, chi-square with 2 df. + p_value: Probability of a statistic this large if the model is correct. + rejected: True when ``p_value < significance``. + significance: Significance level the rejection was judged at. + kupiec: The unconditional-coverage component. + independence: The independence component. + """ + + statistic: float + p_value: float + rejected: bool + significance: float + kupiec: KupiecResult + independence: IndependenceResult + + +@dataclass(frozen=True) +class TrafficLightResult: + """Basel supervisory zone for an observed exception count. + + Attributes: + zone: ``"green"``, ``"yellow"`` or ``"red"``. + violations: Observed exception count. + observations: Number of paired observations tested. + cumulative_probability: ``P(X <= violations)`` under the model's own + claimed breach probability. This is what the zone is cut on. + scaling_factor: Basel capital multiplier, or None when the sample does + not match the tabulated basis (see ``scaling_factor_basis``). + scaling_factor_basis: Why ``scaling_factor`` is or is not populated. + """ + + zone: str + violations: int + observations: int + cumulative_probability: float + scaling_factor: float | None + scaling_factor_basis: str + + +@dataclass(frozen=True) +class VarBacktestReport: + """Every test in this module, run over one aligned return/VaR pair. + + Attributes: + confidence: VaR confidence level tested. + observations: Number of paired observations after alignment. + violations: Number of breaches. + dropped_observations: Pairs discarded because either side was non-finite. + kupiec: Unconditional-coverage result. + independence: Independence result. + conditional_coverage: Joint result. + traffic_light: Basel zone. + breach_dates: Index labels of the breaches when the inputs carried an + index, else None. A risk committee reads the dates, not the count. + """ + + confidence: float + observations: int + violations: int + dropped_observations: int + kupiec: KupiecResult + independence: IndependenceResult + conditional_coverage: ConditionalCoverageResult + traffic_light: TrafficLightResult + breach_dates: tuple[object, ...] | None + + +def _align( + returns: pd.Series | np.ndarray | Sequence[float], + var: pd.Series | np.ndarray | Sequence[float] | float, +) -> tuple[np.ndarray, np.ndarray, pd.Index | None, int]: + """Pair each return with the VaR forecast that was made for it. + + Args: + returns: Realised returns, signed (a loss is negative). + var: VaR forecasts as positive loss magnitudes, either one per return or + a single scalar applied to every return. + + Returns: + Tuple of ``(returns, var, index, dropped)``: two equal-length finite + float arrays, the surviving index when the inputs carried one, and the + number of pairs dropped for holding a non-finite value. + + Raises: + ValueError: If either input is not 1-D, if two indexed Series do not + cover exactly the same labels, if the lengths differ, or if no + finite pair survives. + """ + ret_index = returns.index if isinstance(returns, pd.Series) else None + var_index = var.index if isinstance(var, pd.Series) else None + + if ret_index is not None and var_index is not None: + if not ret_index.equals(var_index): + only_ret = ret_index.difference(var_index) + only_var = var_index.difference(ret_index) + raise ValueError( + "returns and var must cover exactly the same labels; " + f"{len(only_ret)} label(s) only in returns and " + f"{len(only_var)} only in var. Align them explicitly -- a " + "partial join silently compares each day against another day's " + "forecast." + ) + + ret_values = np.asarray(returns, dtype=float) + if ret_values.ndim > 1: + raise ValueError(f"returns must be 1-D, got shape {ret_values.shape}") + ret_values = ret_values.ravel() + + var_values = np.asarray(var, dtype=float) + if var_values.ndim == 0: + var_values = np.full(ret_values.shape, float(var_values)) + else: + if var_values.ndim > 1: + raise ValueError(f"var must be 1-D or scalar, got shape {var_values.shape}") + var_values = var_values.ravel() + + if ret_values.size != var_values.size: + raise ValueError( + f"returns and var must be the same length, got {ret_values.size} " + f"and {var_values.size}" + ) + if ret_values.size == 0: + raise ValueError("returns is empty") + + keep = np.isfinite(ret_values) & np.isfinite(var_values) + dropped = int((~keep).sum()) + if not keep.any(): + raise ValueError("no observation has a finite return and a finite var") + + index = ret_index if ret_index is not None else var_index + kept_index = index[keep] if index is not None else None + return ret_values[keep], var_values[keep], kept_index, dropped + + +def violation_indicator( + returns: pd.Series | np.ndarray | Sequence[float], + var: pd.Series | np.ndarray | Sequence[float] | float, +) -> np.ndarray: + """Flag the days on which the realised loss exceeded the VaR forecast. + + Args: + returns: Realised returns, signed. A 3% loss is ``-0.03``. + var: VaR forecasts as positive loss magnitudes, one per return or a + single scalar. A negative entry is not flipped: it is taken at face + value, meaning "the model expects a gain even in the tail", which is + almost always a caller-side sign error and shows up here as an + implausible breach count rather than as a silently corrected input. + + Returns: + Boolean array, True where ``return < -var``. Equality is not a breach: + a loss exactly equal to VaR is the level the model promised, not an + excess of it. + + Raises: + ValueError: If the inputs cannot be aligned (see :func:`_align`). + """ + ret_values, var_values, _, _ = _align(returns, var) + return ret_values < -var_values + + +def kupiec_pof( + violations: int, + observations: int, + confidence: float = 0.99, + significance: float = 0.05, +) -> KupiecResult: + """Test whether the breach *count* matches the model's claimed level. + + The Kupiec proportion-of-failures test compares the realised breach rate + against ``1 - confidence`` by likelihood ratio:: + + LR_uc = -2 * [ (n-x)*ln(1-p) + x*ln(p) + - (n-x)*ln(1-x/n) - x*ln(x/n) ] + + distributed chi-square with 1 degree of freedom. The ``0 * ln(0) = 0`` + convention makes the zero-breach and all-breach cases well defined rather + than special. + + Args: + violations: Number of days the loss exceeded VaR. + observations: Number of days tested. + confidence: VaR confidence level the model claims, e.g. 0.99. + significance: Level at which ``rejected`` is decided. + + Returns: + A :class:`KupiecResult`. + + Raises: + ValueError: If ``observations`` is not positive, if ``violations`` is + negative or exceeds ``observations``, or if either probability is + not strictly between 0 and 1. + """ + if observations <= 0: + raise ValueError(f"observations must be > 0, got {observations}") + if not 0 <= violations <= observations: + raise ValueError( + f"violations must be in [0, {observations}], got {violations}" + ) + if not 0.0 < confidence < 1.0: + raise ValueError(f"confidence must be in (0, 1), got {confidence}") + if not 0.0 < significance < 1.0: + raise ValueError(f"significance must be in (0, 1), got {significance}") + + expected_rate = 1.0 - confidence + observed_rate = violations / observations + calm = observations - violations + + restricted = xlogy(calm, 1.0 - expected_rate) + xlogy(violations, expected_rate) + unrestricted = xlogy(calm, 1.0 - observed_rate) + xlogy(violations, observed_rate) + statistic = float(max(-2.0 * (restricted - unrestricted), 0.0)) + p_value = float(chi2.sf(statistic, df=1)) + + return KupiecResult( + observations=observations, + violations=violations, + expected_violations=observations * expected_rate, + violation_rate=observed_rate, + expected_rate=expected_rate, + statistic=statistic, + p_value=p_value, + rejected=p_value < significance, + significance=significance, + ) + + +def christoffersen_independence( + breaches: Sequence[bool] | np.ndarray, + significance: float = 0.05, +) -> IndependenceResult: + """Test whether breaches cluster in time. + + Fits a first-order Markov chain to the breach sequence and tests the + restriction that the two transition probabilities are equal:: + + LR_ind = -2 * [ (n00+n10)*ln(1-pi) + (n01+n11)*ln(pi) + - n00*ln(1-pi01) - n01*ln(pi01) + - n10*ln(1-pi11) - n11*ln(pi11) ] + + distributed chi-square with 1 degree of freedom. This is the test that + catches a model whose exception count looks fine because all ten breaches + landed in the same fortnight. + + Args: + breaches: Boolean sequence in chronological order, True on breach days. + significance: Level at which ``rejected`` is decided. + + Returns: + An :class:`IndependenceResult`. When the sample holds no breach, or + holds breaches only in its final position, the transition probabilities + are not separately identified; the statistic is then 0 with + ``identified=False`` rather than an arbitrary number. + + Raises: + ValueError: If ``breaches`` holds fewer than two observations or is not + 1-D, or if ``significance`` is not strictly between 0 and 1. + """ + if not 0.0 < significance < 1.0: + raise ValueError(f"significance must be in (0, 1), got {significance}") + + flags = np.asarray(breaches) + if flags.ndim != 1: + raise ValueError(f"breaches must be 1-D, got shape {flags.shape}") + if flags.size < 2: + raise ValueError( + f"breaches needs at least 2 observations to hold a transition, got {flags.size}" + ) + flags = flags.astype(bool) + + prev, curr = flags[:-1], flags[1:] + n00 = int(np.sum(~prev & ~curr)) + n01 = int(np.sum(~prev & curr)) + n10 = int(np.sum(prev & ~curr)) + n11 = int(np.sum(prev & curr)) + + from_calm = n00 + n01 + from_breach = n10 + n11 + total = from_calm + from_breach + + pi01 = n01 / from_calm if from_calm else 0.0 + pi11 = n11 / from_breach if from_breach else 0.0 + pi = (n01 + n11) / total if total else 0.0 + + # Not identified when no day follows a breach: pi11 has no sample behind it, + # so the restriction it is supposed to relax cannot be tested. + identified = from_breach > 0 and from_calm > 0 + + if not identified: + statistic = 0.0 + else: + restricted = xlogy(n00 + n10, 1.0 - pi) + xlogy(n01 + n11, pi) + unrestricted = ( + xlogy(n00, 1.0 - pi01) + + xlogy(n01, pi01) + + xlogy(n10, 1.0 - pi11) + + xlogy(n11, pi11) + ) + statistic = float(max(-2.0 * (restricted - unrestricted), 0.0)) + + p_value = float(chi2.sf(statistic, df=1)) + return IndependenceResult( + transitions=(n00, n01, n10, n11), + prob_breach_after_calm=pi01, + prob_breach_after_breach=pi11, + statistic=statistic, + p_value=p_value, + rejected=identified and p_value < significance, + significance=significance, + identified=identified, + ) + + +def christoffersen_conditional_coverage( + breaches: Sequence[bool] | np.ndarray, + confidence: float = 0.99, + significance: float = 0.05, +) -> ConditionalCoverageResult: + """Test coverage and independence jointly. + + ``LR_cc = LR_uc + LR_ind`` with 2 degrees of freedom. The components are + returned alongside it because the joint statistic alone cannot say whether + the model failed on level, on timing, or on both -- and the remedies differ. + + Args: + breaches: Boolean sequence in chronological order, True on breach days. + confidence: VaR confidence level the model claims. + significance: Level at which each ``rejected`` flag is decided. + + Returns: + A :class:`ConditionalCoverageResult`. + + Raises: + ValueError: Propagated from the component tests. + """ + flags = np.asarray(breaches) + if flags.ndim != 1: + raise ValueError(f"breaches must be 1-D, got shape {flags.shape}") + flags = flags.astype(bool) + + kupiec = kupiec_pof( + violations=int(flags.sum()), + observations=int(flags.size), + confidence=confidence, + significance=significance, + ) + independence = christoffersen_independence(flags, significance=significance) + + statistic = kupiec.statistic + independence.statistic + p_value = float(chi2.sf(statistic, df=2)) + return ConditionalCoverageResult( + statistic=statistic, + p_value=p_value, + rejected=p_value < significance, + significance=significance, + kupiec=kupiec, + independence=independence, + ) + + +def basel_traffic_light( + violations: int, + observations: int, + confidence: float = BASEL_CONFIDENCE, +) -> TrafficLightResult: + """Place an exception count in the Basel supervisory zone. + + The zone is cut on the cumulative binomial probability of the observed + count under the model's own claimed breach probability -- green below + :data:`BASEL_GREEN_MAX_CDF`, red at or above :data:`BASEL_RED_MIN_CDF`, + yellow between. That is the general form of the rule and it holds for any + sample size. + + The capital scaling factor is a different matter. :data:`BASEL_SCALING_FACTORS` + is tabulated for a 250-day backtest of a 99% VaR, and is reported only when + the sample matches that basis. On any other sample the zone is still + meaningful but the multiplier is not, so it comes back None rather than + borrowed from a table that was never fitted to it. + + Args: + violations: Observed exception count. + observations: Number of days tested. + confidence: VaR confidence level the model claims. + + Returns: + A :class:`TrafficLightResult`. + + Raises: + ValueError: If ``observations`` is not positive, if ``violations`` is + outside ``[0, observations]``, or if ``confidence`` is not strictly + between 0 and 1. + """ + if observations <= 0: + raise ValueError(f"observations must be > 0, got {observations}") + if not 0 <= violations <= observations: + raise ValueError( + f"violations must be in [0, {observations}], got {violations}" + ) + if not 0.0 < confidence < 1.0: + raise ValueError(f"confidence must be in (0, 1), got {confidence}") + + breach_prob = 1.0 - confidence + cdf = float(binom.cdf(violations, observations, breach_prob)) + + if cdf < BASEL_GREEN_MAX_CDF: + zone = "green" + elif cdf < BASEL_RED_MIN_CDF: + zone = "yellow" + else: + zone = "red" + + on_basis = observations == BASEL_WINDOW and confidence == BASEL_CONFIDENCE + if not on_basis: + scaling_factor = None + basis = ( + f"not reported: the Basel table is tabulated for {BASEL_WINDOW} " + f"observations at {BASEL_CONFIDENCE:.0%} confidence, this sample is " + f"{observations} at {confidence:.0%}" + ) + else: + scaling_factor = BASEL_SCALING_FACTORS.get(violations, BASEL_RED_SCALING_FACTOR) + basis = ( + f"Basel (1996) table for {BASEL_WINDOW} observations at " + f"{BASEL_CONFIDENCE:.0%} confidence" + ) + + return TrafficLightResult( + zone=zone, + violations=violations, + observations=observations, + cumulative_probability=cdf, + scaling_factor=scaling_factor, + scaling_factor_basis=basis, + ) + + +def var_backtest( + returns: pd.Series | np.ndarray | Sequence[float], + var: pd.Series | np.ndarray | Sequence[float] | float, + confidence: float = 0.99, + significance: float = 0.05, +) -> VarBacktestReport: + """Run every test in this module over one return/VaR pair. + + Args: + returns: Realised returns, signed, in chronological order. + var: VaR forecasts as positive loss magnitudes -- one per return, or a + scalar for a constant-VaR model. When both sides are indexed Series + the labels must match exactly. + confidence: VaR confidence level the model claims, e.g. 0.99. + significance: Level at which each ``rejected`` flag is decided. + + Returns: + A :class:`VarBacktestReport` carrying the Kupiec, independence, joint + and Basel results plus the breach dates when an index was supplied. + + Raises: + ValueError: If the inputs cannot be aligned, if fewer than two finite + pairs survive, or if either probability is out of range. + """ + ret_values, var_values, index, dropped = _align(returns, var) + if ret_values.size < 2: + raise ValueError( + f"var_backtest needs at least 2 aligned observations, got {ret_values.size}" + ) + + breaches = ret_values < -var_values + conditional = christoffersen_conditional_coverage( + breaches, confidence=confidence, significance=significance + ) + traffic = basel_traffic_light( + violations=int(breaches.sum()), + observations=int(breaches.size), + confidence=confidence, + ) + breach_dates = tuple(index[breaches]) if index is not None else None + + return VarBacktestReport( + confidence=confidence, + observations=int(breaches.size), + violations=int(breaches.sum()), + dropped_observations=dropped, + kupiec=conditional.kupiec, + independence=conditional.independence, + conditional_coverage=conditional, + traffic_light=traffic, + breach_dates=breach_dates, + ) diff --git a/agent/src/tools/orderbook_depth_tool.py b/agent/src/tools/orderbook_depth_tool.py new file mode 100644 index 00000000..1112066c --- /dev/null +++ b/agent/src/tools/orderbook_depth_tool.py @@ -0,0 +1,507 @@ +"""Read-only crypto L2 order-book depth tool (OKX / Binance spot, via ccxt). + +Built to close a documented gap: ``src/swarm/presets/statistical_arbitrage_desk.yaml`` +tells its microstructure analyst "There is no order-book depth or quote feed +here" because that is true for the equity data sources this project has for +free — but it is *not* true for crypto. OKX and Binance both publish full L2 +order-book snapshots over public, no-auth REST, and this project already +depends on ``ccxt>=4.0.0`` (see ``pyproject.toml``) and already talks to both +venues for OHLCV (``backtest/loaders/okx.py``, ``backtest/loaders/binance_loader.py``). +This tool reuses that same connectivity — ccxt's unified ``fetch_order_book`` +— instead of hand-rolling per-venue REST parsing. + +Symbol convention matches the rest of this repo (see CLAUDE.md: "OKX pairs +use ``BTC-USDT`` format, hyphen, uppercase") and ``ccxt_loader._parse_ccxt_symbol``: +the caller passes ``BASE-QUOTE`` (e.g. ``BTC-USDT``), which is upper-cased and +translated to ccxt's ``BASE/QUOTE`` form before the request. Perpetuals +(``-PERP``) are out of scope here — this tool is spot-book depth only. + +What ccxt's ``fetch_order_book`` actually returns (read from the installed +``ccxt==4.5.54`` source, not assumed): ``parse_order_book`` always returns +``{"symbol", "bids", "asks", "timestamp", "datetime", "nonce"}`` where +``bids`` is sorted descending by price and ``asks`` ascending — i.e. index 0 +on each side is always the best price, by construction of +``Exchange.parse_order_book``. This module still re-sorts defensively before +using either side (cheap, and removes any reliance on that upstream +invariant holding for every exchange/version). ``timestamp`` is venue +end-to-end epoch milliseconds when the venue's payload carries one — for +Binance *spot* depth (``GET /api/v3/depth``) it does not: ccxt's own +``fetch_order_book`` reads a ``"T"`` field that only appears in the +futures/options response shapes (see ``ccxt.binance.fetch_order_book`` +docstring examples), so ``timestamp`` comes back ``None`` for Binance spot. +When that happens this tool falls back to the local fetch time and marks +``timestamp_source`` accordingly, rather than silently passing a null-dated +snapshot to the caller (an order book is only valid for seconds — the reader +must be able to tell how fresh it is either way). + +The network call is isolated in :func:`_fetch_raw_book` for exactly one +reason: tests monkeypatch that single function and never touch the network, +same pattern as ``tests/test_prediction_market_tool.py`` monkeypatching +``throttled_get_json`` on its own tool module. + +Domain decisions worth being explicit about (also covered in the tests): + +* **Units.** Every ``amount``/``amount_base`` is a quantity of the pair's + BASE asset. Every ``*_notional_quote`` / ``notional_quote`` / price is in + the pair's QUOTE asset — USDT for ``BTC-USDT``, but BTC for ``ETH-BTC``. + These are never averaged or added together; the ``units`` block on every + response spells this out so a caller cannot mix them by accident. +* **Mid price** is always ``(best_bid + best_ask) / 2``. If either side of + the book is empty this tool refuses to compute a mid price (or anything + downstream of it) rather than guessing — see the empty-book handling below. +* **Crossed book** (``best_bid >= best_ask``) is treated as a data error and + rejected outright. A negative or zero spread is never returned. +* **Impact-cost sizing.** "A market order for $10,000 notional" is sized + identically for both directions: ``base_qty_target = notional_quote / + mid_price``, computed once from the pre-trade mid. The buy side then walks + the (fully fetched, not just the displayed) ask ladder consuming that many + base units; the sell side walks the bid ladder the same way. This keeps + buy and sell directly comparable (same order size) instead of the sell + side chasing a moving quote-proceeds target, which would make the two + sides answer different questions. +* **Partial fills are always reported, never disguised as full fills.** If + the fetched ladder (``_BOOK_FETCH_LIMIT`` levels — see below) cannot + satisfy ``base_qty_target``, the response carries ``fully_filled: false``, + the actual ``filled_base_qty`` / ``filled_notional_quote`` (which will be + less than requested), and an explanatory ``note``. The average price and + slippage are still computed over whatever *was* filled — a partial number + is more honest than no number, as long as it is labelled partial. +* **Fetch depth vs. requested depth are different knobs.** ``levels`` + (1..``_MAX_LEVELS``) controls how many rows are echoed back in ``depth`` + and how many are used for the imbalance ratio. Impact cost always walks + the full ``_BOOK_FETCH_LIMIT``-level ladder pulled from the exchange + (100 levels — a valid discrete Binance depth tier and well under OKX's + 400-row default-endpoint cap), regardless of ``levels``, since a $10k + order can easily need more rows than a human wants echoed back. +""" + +from __future__ import annotations + +import json +import logging +import math +from datetime import datetime, timezone +from typing import Any + +from backtest.loaders.ccxt_loader import _CCXT_TIMEOUT_MS, _ccxt_proxy_config +from src.agent.tools import BaseTool + +logger = logging.getLogger(__name__) + +_EXCHANGES = ("okx", "binance") +_DEFAULT_EXCHANGE = "okx" + +_DEFAULT_LEVELS = 10 +_MAX_LEVELS = 50 + +# Rows pulled from the exchange per call. Fixed (not caller-configurable): +# impact-cost needs headroom beyond whatever `levels` the caller wants +# echoed back, and 100 is a valid discrete Binance order-book depth tier +# (5/10/20/50/100/500/1000/5000) while sitting comfortably under OKX's +# default-endpoint 400-row cap — one request works cleanly against both. +_BOOK_FETCH_LIMIT = 100 + +_DEFAULT_NOTIONAL_QUOTE = 10_000.0 + +_UNITS = { + "amount_base": "quantity of the pair's BASE asset (e.g. BTC in BTC-USDT) — never a currency value", + "price": "quote-asset price of 1 unit of base (e.g. USDT per BTC in BTC-USDT)", + "notional_quote / *_notional_quote / filled_notional_quote": ( + "value denominated in the pair's QUOTE asset (USDT for BTC-USDT, but " + "BTC for ETH-BTC — read the quote leg off the symbol, do not assume USD)" + ), + "relative_bps / slippage_bps": "basis points (1 bps = 0.01%), relative to mid_price at fetch time", + "normalized_imbalance": ( + "(bid_notional_quote - ask_notional_quote) / (bid_notional_quote + ask_notional_quote), " + "in [-1, 1]. Positive = more resting buy-side notional (bid-heavy), negative = ask-heavy" + ), +} + + +def _fetch_raw_book(exchange_id: str, ccxt_symbol: str, limit: int) -> dict[str, Any]: + """Fetch one L2 order-book snapshot via ccxt's public REST API. + + Isolated as its own function so tests can monkeypatch exactly this name + and never open a socket — see the module docstring. + + Args: + exchange_id: ``"okx"`` or ``"binance"`` (a ccxt exchange id). + ccxt_symbol: Unified ccxt symbol, e.g. ``"BTC/USDT"``. + limit: Number of levels per side requested from the venue. + + Returns: + A ccxt ``OrderBook`` dict: ``{"symbol", "bids", "asks", "timestamp", + "datetime", "nonce"}``. + + Raises: + Exception: Whatever ccxt raises for a network, symbol, or venue error. + """ + import ccxt + + exchange_cls = getattr(ccxt, exchange_id) + config: dict[str, Any] = {"enableRateLimit": True, "timeout": _CCXT_TIMEOUT_MS} + proxies = _ccxt_proxy_config() + if proxies: + config["proxies"] = proxies + exchange = exchange_cls(config) + return exchange.fetch_order_book(ccxt_symbol, limit) + + +def _error(message: str) -> str: + """Build the failure envelope as a JSON string.""" + return json.dumps({"ok": False, "error": message}, ensure_ascii=False) + + +def _parse_symbol(raw: Any) -> tuple[str, str] | None: + """Validate + normalize a ``BASE-QUOTE`` symbol into ``(display, ccxt)`` forms. + + Args: + raw: Caller-supplied symbol, e.g. ``"btc-usdt"``. + + Returns: + ``(normalized_hyphen_symbol, ccxt_slash_symbol)``, or ``None`` when + ``raw`` is not a well-formed ``BASE-QUOTE`` string. + """ + if not isinstance(raw, str): + return None + normalized = raw.strip().upper() + parts = normalized.split("-") + if len(parts) != 2 or not parts[0] or not parts[1]: + return None + if not parts[0].isalnum() or not parts[1].isalnum(): + return None + return normalized, f"{parts[0]}/{parts[1]}" + + +def _coerce_int(value: Any) -> int | None: + """Coerce a parameter to int, accepting the JSON shapes an LLM tends to send.""" + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) if value.is_integer() else None + if isinstance(value, str): + try: + number = float(value.strip()) + except ValueError: + return None + return int(number) if number.is_integer() else None + return None + + +def _coerce_float(value: Any) -> float | None: + """Coerce a parameter to a finite float.""" + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + number = float(value) + elif isinstance(value, str): + try: + number = float(value.strip()) + except ValueError: + return None + else: + return None + return number if math.isfinite(number) else None + + +def _clean_levels(raw: Any) -> list[tuple[float, float]]: + """Normalize a ccxt bids/asks array into sorted-by-caller ``(price, amount)`` pairs. + + Rows with a non-positive or non-finite price/amount are dropped rather + than propagated into downstream arithmetic (a zero-amount row would + silently no-op through the impact walk; a negative one would corrupt it). + + Args: + raw: The ``bids`` or ``asks`` value from a ccxt order-book dict. + + Returns: + A list of ``(price, amount)`` float pairs, in whatever order ``raw`` + had them (the caller sorts). + """ + out: list[tuple[float, float]] = [] + if not isinstance(raw, list): + return out + for row in raw: + if not isinstance(row, (list, tuple)) or len(row) < 2: + continue + try: + price = float(row[0]) + amount = float(row[1]) + except (TypeError, ValueError): + continue + if not math.isfinite(price) or not math.isfinite(amount): + continue + if price <= 0 or amount <= 0: + continue + out.append((price, amount)) + return out + + +def _walk_book(levels: list[tuple[float, float]], base_qty_target: float) -> dict[str, Any]: + """Consume ``levels`` (best price first) until ``base_qty_target`` base units are filled. + + Args: + levels: ``(price, amount)`` pairs in walk order (best price first); + the caller is responsible for that ordering. + base_qty_target: Base-asset quantity the simulated market order wants + to fill. + + Returns: + ``{"filled_base_qty", "filled_notional_quote", "avg_price", + "levels_used", "fully_filled", "unfilled_base_qty"}``. ``avg_price`` + is ``None`` only when nothing at all could be filled (e.g. the side + was empty, though callers already reject that case upstream). + """ + remaining = base_qty_target + base_filled = 0.0 + quote_filled = 0.0 + levels_used = 0 + for price, amount in levels: + if remaining <= 0: + break + take = min(amount, remaining) + base_filled += take + quote_filled += take * price + remaining -= take + levels_used += 1 + # Tolerance absorbs float noise from the running subtraction above; it is + # not a relaxed pass/fail threshold on the caller's requested size. + fully_filled = remaining <= 1e-9 + return { + "filled_base_qty": base_filled, + "filled_notional_quote": quote_filled, + "avg_price": (quote_filled / base_filled) if base_filled > 0 else None, + "levels_used": levels_used, + "fully_filled": fully_filled, + "unfilled_base_qty": max(remaining, 0.0), + } + + +class OrderBookDepthTool(BaseTool): + """Read-only crypto L2 order-book depth: raw levels, spread, imbalance, impact cost.""" + + name = "orderbook_depth" + repeatable = True + is_readonly = True + description = ( + "READ-ONLY crypto L2 order-book snapshot from OKX or Binance spot " + "public REST (via ccxt) — the depth/quote feed equity sources here " + "cannot provide. Returns: (1) raw bid/ask levels (price + base-asset " + "amount, up to 'levels' per side); (2) bid-ask spread, absolute and " + "relative in bps; (3) depth imbalance within 'levels' — " + "bid-notional/ask-notional ratio and a normalized (B-A)/(B+A) score " + "in [-1, 1]; (4) impact cost — for a simulated market order sized at " + "'notional_quote' (in the pair's quote asset, e.g. USDT for " + "BTC-USDT), the volume-weighted average fill price and slippage in " + "bps vs. mid price, computed BOTH for a buy (walking asks) and a " + "sell (walking bids). A snapshot is only valid for seconds — every " + "response carries a timestamp and 'exchange'. A quote-side that " + "can't fill the full requested notional reports 'fully_filled: " + "false' with the actually-filled amount, never a fabricated full " + "fill. An empty book, a one-sided-empty book, or a crossed book " + "(best_bid >= best_ask, a data error) is refused with an explicit " + "error rather than a guessed number. Spot only, no perpetuals. " + 'Example: {"symbol": "BTC-USDT", "exchange": "okx", "levels": 10, ' + '"notional_quote": 10000}.' + ) + parameters = { + "type": "object", + "properties": { + "symbol": { + "type": "string", + "description": ( + "BASE-QUOTE pair, hyphenated and uppercase (OKX-style), " + "e.g. 'BTC-USDT' or 'ETH-BTC'. Required." + ), + }, + "exchange": { + "type": "string", + "enum": list(_EXCHANGES), + "description": "Which venue's public order book to read.", + "default": _DEFAULT_EXCHANGE, + }, + "levels": { + "type": "integer", + "description": ( + f"Number of price levels per side to return in 'depth' " + f"and to use for the imbalance calculation (1-{_MAX_LEVELS})." + ), + "default": _DEFAULT_LEVELS, + }, + "notional_quote": { + "type": "number", + "description": ( + "Simulated market-order size, in the pair's QUOTE asset " + "(USDT for BTC-USDT), used for the impact-cost " + "calculation. Must be positive." + ), + "default": _DEFAULT_NOTIONAL_QUOTE, + }, + }, + "required": ["symbol"], + } + + def execute(self, **kwargs: Any) -> str: + """Fetch one order-book snapshot and return the depth/spread/imbalance/impact envelope. + + Args: + **kwargs: ``symbol`` (required), plus optional ``exchange``, + ``levels``, ``notional_quote``. + + Returns: + A JSON string. On success: ``{"ok": true, "source": "ccxt", + "market": "crypto", "units": {...}, "data": {...}}``. On failure + (bad params, empty/one-sided/crossed book, or a request error): + ``{"ok": false, "error": str}``. + """ + parsed = _parse_symbol(kwargs.get("symbol")) + if parsed is None: + return _error( + "symbol is required and must be BASE-QUOTE, hyphenated and " + "uppercase, e.g. 'BTC-USDT' (got " + f"{kwargs.get('symbol')!r})" + ) + display_symbol, ccxt_symbol = parsed + + exchange_id = kwargs.get("exchange", _DEFAULT_EXCHANGE) + if exchange_id not in _EXCHANGES: + return _error(f"exchange must be one of {list(_EXCHANGES)}, got {exchange_id!r}") + + levels_raw = kwargs.get("levels", _DEFAULT_LEVELS) + levels = _coerce_int(levels_raw) + if levels is None or levels < 1 or levels > _MAX_LEVELS: + return _error( + f"levels must be an integer between 1 and {_MAX_LEVELS} " + f"(got {levels_raw!r})" + ) + + notional_raw = kwargs.get("notional_quote", _DEFAULT_NOTIONAL_QUOTE) + notional_quote = _coerce_float(notional_raw) + if notional_quote is None or notional_quote <= 0: + return _error(f"notional_quote must be a positive number (got {notional_raw!r})") + + try: + raw_book = _fetch_raw_book(exchange_id, ccxt_symbol, _BOOK_FETCH_LIMIT) + except Exception as exc: # noqa: BLE001 - surface as error envelope + return _error(f"{exchange_id} order book request failed for {display_symbol}: {exc}") + + if not isinstance(raw_book, dict): + return _error(f"unexpected order book payload shape from {exchange_id}") + + bids = sorted(_clean_levels(raw_book.get("bids")), key=lambda lvl: lvl[0], reverse=True) + asks = sorted(_clean_levels(raw_book.get("asks")), key=lambda lvl: lvl[0]) + + if not bids and not asks: + return _error(f"order book for {display_symbol} on {exchange_id} is empty (no bids or asks)") + if not bids: + return _error( + f"order book for {display_symbol} on {exchange_id} has no bids — " + "refusing to compute mid price / spread from a one-sided book" + ) + if not asks: + return _error( + f"order book for {display_symbol} on {exchange_id} has no asks — " + "refusing to compute mid price / spread from a one-sided book" + ) + + best_bid_price, best_bid_qty = bids[0] + best_ask_price, best_ask_qty = asks[0] + if best_bid_price >= best_ask_price: + return _error( + f"crossed book for {display_symbol} on {exchange_id}: " + f"best_bid {best_bid_price} >= best_ask {best_ask_price}. " + "This is a data error — refusing to compute a spread from it." + ) + + mid_price = (best_bid_price + best_ask_price) / 2.0 + spread_abs = best_ask_price - best_bid_price + spread_bps = (spread_abs / mid_price) * 10_000.0 + + timestamp_ms = raw_book.get("timestamp") + if isinstance(timestamp_ms, (int, float)) and not isinstance(timestamp_ms, bool): + timestamp = ( + datetime.fromtimestamp(timestamp_ms / 1000.0, tz=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) + timestamp_source = "exchange" + else: + # Binance spot depth carries no server timestamp field at all + # (ccxt's parser only finds one in the futures/options response + # shapes) — fetch time is the honest fallback, explicitly labelled + # so a caller never mistakes it for a venue-stamped time. + timestamp = datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z") + timestamp_source = "local_fetch_time" + + depth_bids = bids[:levels] + depth_asks = asks[:levels] + bid_notional = sum(p * a for p, a in depth_bids) + ask_notional = sum(p * a for p, a in depth_asks) + total_notional = bid_notional + ask_notional + normalized_imbalance = ( + (bid_notional - ask_notional) / total_notional if total_notional > 0 else 0.0 + ) + + base_qty_target = notional_quote / mid_price + buy = _walk_book(asks, base_qty_target) + buy["notional_quote_requested"] = notional_quote + buy["slippage_bps"] = ( + ((buy["avg_price"] - mid_price) / mid_price) * 10_000.0 + if buy["avg_price"] is not None + else None + ) + sell = _walk_book(bids, base_qty_target) + sell["notional_quote_requested"] = notional_quote + sell["slippage_bps"] = ( + ((mid_price - sell["avg_price"]) / mid_price) * 10_000.0 + if sell["avg_price"] is not None + else None + ) + for side_name, side in (("buy", buy), ("sell", sell)): + if not side["fully_filled"]: + side["note"] = ( + f"could not fill the full {notional_quote} notional within the " + f"{len(asks) if side_name == 'buy' else len(bids)} " + f"{'ask' if side_name == 'buy' else 'bid'} level(s) fetched " + f"(exchange fetch limit={_BOOK_FETCH_LIMIT}); filled_notional_quote " + "and avg_price reflect only the partial fill actually achieved. " + "This may mean genuinely thin liquidity, or just that the book " + "goes deeper than this snapshot's fetched window." + ) + + data = { + "exchange": exchange_id, + "symbol": display_symbol, + "ccxt_symbol": ccxt_symbol, + "timestamp": timestamp, + "timestamp_source": timestamp_source, + "best_bid": {"price": best_bid_price, "amount_base": best_bid_qty}, + "best_ask": {"price": best_ask_price, "amount_base": best_ask_qty}, + "mid_price": mid_price, + "spread": {"absolute_quote": spread_abs, "relative_bps": spread_bps}, + "depth": { + "levels": levels, + "bids": [{"price": p, "amount_base": a} for p, a in depth_bids], + "asks": [{"price": p, "amount_base": a} for p, a in depth_asks], + }, + "imbalance": { + "levels": levels, + "bid_notional_quote": bid_notional, + "ask_notional_quote": ask_notional, + "ratio_bid_over_ask": (bid_notional / ask_notional) if ask_notional > 0 else None, + "normalized_imbalance": normalized_imbalance, + }, + "impact_cost": { + "notional_quote": notional_quote, + "base_qty_target": base_qty_target, + "buy": buy, + "sell": sell, + }, + "levels_available": {"bids": len(bids), "asks": len(asks)}, + } + return json.dumps( + {"ok": True, "source": "ccxt", "market": "crypto", "units": _UNITS, "data": data}, + ensure_ascii=False, + ) diff --git a/agent/src/tools/quantlib_tool.py b/agent/src/tools/quantlib_tool.py new file mode 100644 index 00000000..cdcff363 --- /dev/null +++ b/agent/src/tools/quantlib_tool.py @@ -0,0 +1,449 @@ +"""Reach the finance-math layer from every transport, without a shell. + +``src/quantlib`` holds ~250 tested functions, and until now the only way an +agent could run them was ``bash`` plus an ``import``. That gate is off by +default on the API and MCP transports, and correctly so: process-control tools +are an RCE surface regardless of transport, and force-enabling them on stdio was +the subject of two published advisories (see +``mcp_server._resolve_include_shell_tools``). So on Web, API and MCP the maths +layer was simply unreachable. + +Widening the shell gate to fix that would trade a real security property for a +convenience. This tool takes the other route: one stable contract that can call +any *public* function of an allowlisted quantlib module, and nothing else. + +WHY THIS IS NOT ARBITRARY CODE EXECUTION +---------------------------------------- +Three structural limits, in order of what they rule out: + +1. **Module allowlist.** Only the modules in :data:`ALLOWED_MODULES` are + reachable. There is no path from a caller-supplied string to an arbitrary + import -- the string is looked up in a dict, never passed to ``importlib``. +2. **``__all__`` only.** Within a module, only names the module itself exports + are callable. A private helper, a re-exported third-party symbol, or a + dunder is not reachable. +3. **No file-writing entry points.** Names matching :data:`_WRITER_PREFIXES` are + refused even when exported, because they take a destination path. Everything + left is pure computation: no filesystem, no network, no process. Use + ``write_file`` if a file is what you want. + +WHAT THIS DOES NOT DO +--------------------- +It does not fetch data. Every argument comes from the caller, which usually +means a data tool ran first. A function that needs a price history needs you to +have retrieved that history. + +PASSING PANDAS OBJECTS OVER JSON +-------------------------------- +Several functions need a real indexed ``Series`` or ``DataFrame``, and a bare +JSON list loses the index -- which for an event study or a cash-flow series is +the part that carries the meaning. Two envelopes are therefore understood +anywhere in ``kwargs``:: + + {"__series__": {"index": [...], "values": [...]}} + {"__dataframe__": {"index": [...], "columns": [...], "data": [[...], ...]}} + +A plain list stays a plain list, because most functions accept a sequence and +coercing every list into a Series would break the ones that do not. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import json +import math +from enum import Enum +from typing import Any + +from src.agent.tools import BaseTool + +__all__ = ["ALLOWED_MODULES", "QuantlibCallTool"] + +#: Short name -> dotted module path. A caller names the short form; nothing a +#: caller sends is ever handed to ``importlib``. +ALLOWED_MODULES: dict[str, str] = { + "options": "src.quantlib.options", + "fixedincome": "src.quantlib.fixedincome", + "credit": "src.quantlib.credit", + "timeseries": "src.quantlib.timeseries", + "risk": "src.quantlib.risk", + "var_backtest": "src.quantlib.var_backtest", + "attribution": "src.quantlib.attribution", + "impact": "src.quantlib.impact", + "fundmath": "src.quantlib.fundmath", + "performance": "src.quantlib.performance", + "eventstudy": "src.quantlib.eventstudy", + "factormodel": "src.quantlib.factormodel", + "multipletesting": "src.quantlib.multipletesting", + "crossvalidation": "src.quantlib.crossvalidation", + "valuation.dcf": "src.quantlib.valuation.dcf", + "valuation.comps": "src.quantlib.valuation.comps", + "valuation.threestatement": "src.quantlib.valuation.threestatement", + "valuation.artifact": "src.quantlib.valuation.artifact", + "valuation.contracts": "src.quantlib.valuation.contracts", +} + +#: Exported names refused because they write to a caller-supplied path. This +#: tool stays pure-compute; file creation belongs to ``write_file``. +_WRITER_PREFIXES: tuple[str, ...] = ("export_",) + +#: Largest number of leaf values serialized back to the caller. A factor panel +#: or a full CSCV logit vector can be enormous, and silently returning 200k +#: numbers into a context window helps nobody. +_MAX_RESULT_LEAVES = 5000 + + +def _import_module(short_name: str) -> Any: + """Import an allowlisted module by its short name. + + Args: + short_name: Key of :data:`ALLOWED_MODULES`. + + Returns: + The imported module. + + Raises: + KeyError: If the name is not allowlisted. + """ + import importlib + + return importlib.import_module(ALLOWED_MODULES[short_name]) + + +def _public_names(module: Any) -> list[str]: + """Callable public names of a module, writers excluded. + + Args: + module: An imported quantlib module. + + Returns: + Sorted names from ``__all__`` that are callable and not file writers. + """ + exported = getattr(module, "__all__", []) + out = [] + for name in exported: + if name.startswith(_WRITER_PREFIXES): + continue + obj = getattr(module, name, None) + if callable(obj): + out.append(name) + return sorted(out) + + +def _decode(value: Any) -> Any: + """Turn JSON envelopes into the pandas objects some functions require. + + Args: + value: A decoded-JSON value, possibly a ``__series__`` or + ``__dataframe__`` envelope, possibly nested inside a container. + + Returns: + The value with any envelope replaced by the pandas object it describes. + + Raises: + ValueError: If an envelope is malformed. + """ + import pandas as pd + + if isinstance(value, dict): + if "__series__" in value: + spec = value["__series__"] + if not isinstance(spec, dict) or "values" not in spec: + raise ValueError("__series__ needs a 'values' list") + return pd.Series(spec["values"], index=spec.get("index")) + if "__dataframe__" in value: + spec = value["__dataframe__"] + if not isinstance(spec, dict) or "data" not in spec: + raise ValueError("__dataframe__ needs a 'data' list of rows") + return pd.DataFrame( + spec["data"], index=spec.get("index"), columns=spec.get("columns") + ) + return {k: _decode(v) for k, v in value.items()} + if isinstance(value, list): + return [_decode(v) for v in value] + return value + + +class _Budget: + """Mutable leaf counter shared across one serialization pass.""" + + def __init__(self) -> None: + self.leaves = 0 + self.truncated = False + + +def _encode(value: Any, budget: _Budget) -> Any: + """Serialize a quantlib result into JSON-safe data. + + Dataclasses become dicts, enums become their value, numpy and pandas objects + become plain containers, and non-finite floats become explicit strings so a + NaN cannot arrive at the caller as ``null`` and read as "absent". + + Args: + value: Anything a quantlib function returns. + budget: Shared leaf counter; once exhausted, containers are truncated + and the fact is reported rather than hidden. + + Returns: + A JSON-serializable structure. + """ + import numpy as np + import pandas as pd + + if budget.leaves >= _MAX_RESULT_LEAVES: + budget.truncated = True + return "" + + if value is None or isinstance(value, (bool, int, str)): + budget.leaves += 1 + return value + if isinstance(value, float): + budget.leaves += 1 + if math.isnan(value): + return "NaN" + if math.isinf(value): + return "Infinity" if value > 0 else "-Infinity" + return value + if isinstance(value, Enum): + budget.leaves += 1 + return value.value + if isinstance(value, (np.integer, np.floating, np.bool_)): + return _encode(value.item(), budget) + if isinstance(value, np.ndarray): + return [_encode(v, budget) for v in value.tolist()] + if isinstance(value, pd.Series): + return { + "__series__": { + "index": [_encode(i, budget) for i in value.index.tolist()], + "values": [_encode(v, budget) for v in value.tolist()], + } + } + if isinstance(value, pd.DataFrame): + return { + "__dataframe__": { + "index": [_encode(i, budget) for i in value.index.tolist()], + "columns": [str(c) for c in value.columns], + "data": [[_encode(v, budget) for v in row] for row in value.to_numpy().tolist()], + } + } + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return { + f.name: _encode(getattr(value, f.name), budget) + for f in dataclasses.fields(value) + } + if isinstance(value, dict): + return {str(k): _encode(v, budget) for k, v in value.items()} + if isinstance(value, (list, tuple, set, frozenset)): + return [_encode(v, budget) for v in value] + + budget.leaves += 1 + return str(value) + + +class QuantlibCallTool(BaseTool): + """Call any public function of the tested finance-math layer.""" + + name = "quantlib_call" + description = ( + "Run a function from the tested finance-math library (src/quantlib): " + "Black-Scholes and implied vol, bond math and curve fitting, Altman Z " + "and Merton/KMV, stationarity/cointegration/GARCH/regime switching, " + "VaR/CVaR/EVT and VaR backtesting (Kupiec/Christoffersen/Basel), " + "Brinson-Fachler attribution, market impact, fund maths " + "(XIRR/MOIC/DPI/TVPI/PME/waterfalls), TWR/Dietz/MWR, event studies " + "(CAR/CAAR/Patell/BMP), style factor models, deflated Sharpe and PBO, " + "purged cross-validation, and the valuation engine (DCF / comps / " + "three-statement). Read-only and pure-compute: it fetches no data and " + "writes no files. Start with action='list' to see modules, then " + "action='list' with a module to see its functions, then " + "action='describe' for a signature. " + 'Example: quantlib_call(action="call", module="risk", ' + 'function="historical_var", kwargs={"returns": [0.01, -0.02], "confidence": 0.95}).' + ) + is_readonly = True + repeatable = True + parameters = { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "describe", "call"], + "description": ( + "'list' enumerates modules (or a module's functions when " + "'module' is given); 'describe' returns one function's " + "signature and docstring; 'call' runs it." + ), + "default": "list", + }, + "module": { + "type": "string", + "description": ( + "Short module name, e.g. 'risk', 'options', 'valuation.dcf'. " + "Required for 'describe' and 'call'." + ), + }, + "function": { + "type": "string", + "description": "Public function name. Required for 'describe' and 'call'.", + }, + "kwargs": { + "type": "object", + "description": ( + "Keyword arguments. Pass an indexed pandas Series as " + '{"__series__": {"index": [...], "values": [...]}} and a ' + 'DataFrame as {"__dataframe__": {"index": [...], ' + '"columns": [...], "data": [[...]]}}; a plain list stays a list.' + ), + "default": {}, + }, + }, + "required": ["action"], + } + + def execute(self, **kwargs: Any) -> str: + """Dispatch a list / describe / call request. + + Args: + **kwargs: See :attr:`parameters`. + + Returns: + A JSON envelope. Every failure path returns ``ok=false`` with an + actionable ``error`` rather than raising, matching the other + read-only tools. + """ + action = str(kwargs.get("action") or "list").strip().lower() + module_name = kwargs.get("module") + function_name = kwargs.get("function") + + try: + if action == "list": + return self._list(module_name) + if action not in ("describe", "call"): + return self._error(f"unknown action {action!r}; use list, describe or call") + if not module_name or not function_name: + return self._error(f"action='{action}' needs both 'module' and 'function'") + + resolved = self._resolve(str(module_name), str(function_name)) + if isinstance(resolved, str): + return resolved + func = resolved + + if action == "describe": + return self._describe(str(module_name), str(function_name), func) + return self._call(str(module_name), str(function_name), func, kwargs.get("kwargs") or {}) + except Exception as exc: # noqa: BLE001 - envelope, never raise to the loop + return self._error(f"{type(exc).__name__}: {exc}") + + def _resolve(self, module_name: str, function_name: str) -> Any: + """Look up a callable, or return an error envelope string. + + Args: + module_name: Short module name. + function_name: Public function name. + + Returns: + The callable, or a JSON error envelope string. + """ + if module_name not in ALLOWED_MODULES: + return self._error( + f"unknown module {module_name!r}; available: {sorted(ALLOWED_MODULES)}" + ) + module = _import_module(module_name) + allowed = _public_names(module) + if function_name not in allowed: + if function_name.startswith(_WRITER_PREFIXES): + return self._error( + f"{function_name!r} writes to a path and is not callable here; " + "this tool is pure-compute" + ) + return self._error( + f"{function_name!r} is not a public function of {module_name!r}; " + f"available: {allowed}" + ) + return getattr(module, function_name) + + def _list(self, module_name: Any) -> str: + """List modules, or one module's public functions.""" + if not module_name: + return json.dumps( + { + "ok": True, + "modules": sorted(ALLOWED_MODULES), + "hint": "call again with module= to list its functions", + }, + ensure_ascii=False, + ) + name = str(module_name) + if name not in ALLOWED_MODULES: + return self._error(f"unknown module {name!r}; available: {sorted(ALLOWED_MODULES)}") + module = _import_module(name) + functions = [] + for fn_name in _public_names(module): + fn = getattr(module, fn_name) + doc = (inspect.getdoc(fn) or "").strip().splitlines() + functions.append({"name": fn_name, "summary": doc[0] if doc else ""}) + return json.dumps( + {"ok": True, "module": name, "functions": functions}, ensure_ascii=False + ) + + def _describe(self, module_name: str, function_name: str, func: Any) -> str: + """Return one function's signature and docstring.""" + try: + signature = str(inspect.signature(func)) + except (TypeError, ValueError): + signature = "(signature unavailable)" + return json.dumps( + { + "ok": True, + "module": module_name, + "function": function_name, + "signature": signature, + "doc": inspect.getdoc(func) or "", + }, + ensure_ascii=False, + ) + + def _call(self, module_name: str, function_name: str, func: Any, raw_kwargs: Any) -> str: + """Run the function and serialize its result.""" + if not isinstance(raw_kwargs, dict): + return self._error("'kwargs' must be an object") + try: + decoded = {str(k): _decode(v) for k, v in raw_kwargs.items()} + except ValueError as exc: + return self._error(f"bad argument envelope: {exc}") + + try: + result = func(**decoded) + except TypeError as exc: + # Almost always a wrong/missing argument; hand back the signature so + # the next attempt is informed rather than another guess. + try: + signature = str(inspect.signature(func)) + except (TypeError, ValueError): + signature = "(signature unavailable)" + return self._error(f"{exc}", signature=signature) + except Exception as exc: # noqa: BLE001 + # A domain refusal (bad confidence level, missing input, no sign + # change) is information, not a crash: surface it verbatim. + return self._error(f"{type(exc).__name__}: {exc}") + + budget = _Budget() + payload = { + "ok": True, + "module": module_name, + "function": function_name, + "result": _encode(result, budget), + } + if budget.truncated: + payload["truncated"] = True + payload["truncation_note"] = ( + f"result exceeded {_MAX_RESULT_LEAVES} values and was truncated; " + "narrow the inputs or request a summary statistic instead" + ) + return json.dumps(payload, ensure_ascii=False) + + @staticmethod + def _error(message: str, **extra: Any) -> str: + """Build a uniform failure envelope.""" + return json.dumps({"ok": False, "error": message, **extra}, ensure_ascii=False) diff --git a/agent/tests/quantlib/test_crossvalidation.py b/agent/tests/quantlib/test_crossvalidation.py new file mode 100644 index 00000000..da1f0f46 --- /dev/null +++ b/agent/tests/quantlib/test_crossvalidation.py @@ -0,0 +1,282 @@ +"""Tests for src.quantlib.crossvalidation. + +The central test is the one that shows a NAIVE split leaks and the purged split +does not, over identical data. Asserting only that the purged splitter is clean +would pass just as well if purging were a no-op. +""" + +import math + +import numpy as np +import pandas as pd +import pytest + +from src.quantlib.crossvalidation import ( + DEFAULT_EMBARGO_FRACTION, + MIN_FOLDS, + Split, + combinatorial_purged_splits, + detect_boundary_leakage, + purged_kfold_splits, + purged_walk_forward_splits, +) + + +def _overlapping_labels(n=1000, horizon=20): + """Label i resolves `horizon` bars later, so labels overlap heavily.""" + return np.minimum(np.arange(n) + horizon, n - 1) + + +# --- the leak exists, and purging is what removes it --- + + +def test_a_naive_split_leaks_and_the_purged_split_does_not(): + n, horizon = 1000, 20 + labels = _overlapping_labels(n, horizon) + test = np.arange(400, 600) + + naive = Split( + train=np.setdiff1d(np.arange(n), test), + test=test, + purged=0, + embargoed=0, + test_bounds=(400, 599), + ) + naive_report = detect_boundary_leakage(naive, labels, n_samples=n) + assert not naive_report.clean + # Exactly the observations whose 20-bar label window reaches into the test + # block: starts 380..399 before it, plus everything the test's own labels + # cover after it. + assert naive_report.overlapping.size > 0 + + purged = next( + s for s in purged_kfold_splits(n, labels, n_folds=5, embargo_fraction=0.0) + if s.test_bounds == (400, 599) + ) + assert detect_boundary_leakage(purged, labels, n_samples=n).clean + assert purged.purged > 0 + + +def test_purging_removes_exactly_the_overlapping_observations(): + n, horizon = 200, 10 + labels = _overlapping_labels(n, horizon) + splits = list(purged_kfold_splits(n, labels, n_folds=4, embargo_fraction=0.0)) + fold = splits[1] # test block 50..99 + assert fold.test_bounds == (50, 99) + + first_test = 50 + test_span_end = max(99, int(labels[np.arange(50, 100)].max())) + expected_removed = { + i + for i in range(n) + if i not in range(50, 100) and i <= test_span_end and labels[i] >= first_test + } + assert set(np.setdiff1d(np.arange(n), np.union1d(fold.train, fold.test))) == expected_removed + assert fold.purged == len(expected_removed) + + +def test_no_observation_is_in_both_train_and_test(): + n = 500 + labels = _overlapping_labels(n, 15) + for split in purged_kfold_splits(n, labels, n_folds=5): + assert np.intersect1d(split.train, split.test).size == 0 + + +def test_labels_touching_at_a_single_instant_count_as_overlapping(): + # Closed intervals on both ends. Observation 39's label ends exactly at 40, + # the first test bar, so it must be purged. + n = 100 + labels = np.arange(n) + 1 + labels[39] = 40 + split = next( + s for s in purged_kfold_splits(n, labels, n_folds=5, embargo_fraction=0.0) + if s.test_bounds[0] == 40 + ) + assert 39 not in split.train + + +# --- embargo --- + + +def test_embargo_removes_observations_after_the_test_block(): + n = 1000 + labels = np.arange(n) # labels resolve same-bar, so purging alone does nothing + embargo_fraction = 0.02 + embargo_size = int(round(n * embargo_fraction)) + + split = next( + s for s in purged_kfold_splits(n, labels, n_folds=5, embargo_fraction=embargo_fraction) + if s.test_bounds == (200, 399) + ) + assert split.embargoed == embargo_size + # Nothing in (399, 399 + embargo] survives in training. + assert not ((split.train > 399) & (split.train <= 399 + embargo_size)).any() + # But training resumes immediately after the embargo. + assert (split.train > 399 + embargo_size).any() + + +def test_zero_embargo_keeps_the_bar_right_after_the_test_block(): + n = 500 + labels = np.arange(n) + split = next( + s for s in purged_kfold_splits(n, labels, n_folds=5, embargo_fraction=0.0) + if s.test_bounds == (100, 199) + ) + assert split.embargoed == 0 + assert 200 in split.train + + +def test_detect_boundary_leakage_flags_a_missing_embargo(): + n = 300 + labels = np.arange(n) + test = np.arange(100, 200) + unembargoed = Split( + train=np.setdiff1d(np.arange(n), test), + test=test, + purged=0, + embargoed=0, + test_bounds=(100, 199), + ) + report = detect_boundary_leakage(unembargoed, labels, n_samples=n, embargo_size=10) + assert report.embargo_violations.size == 10 + assert not report.clean + + +def test_default_embargo_fraction_is_small_but_nonzero(): + assert 0.0 < DEFAULT_EMBARGO_FRACTION < 0.1 + + +# --- walk-forward --- + + +def test_walk_forward_never_trains_on_the_future(): + n = 1000 + labels = _overlapping_labels(n, 20) + for split in purged_walk_forward_splits(n, labels, n_folds=5): + assert split.train.max() < split.test.min() + + +def test_walk_forward_purges_past_labels_that_reach_into_the_test_block(): + n, horizon = 500, 25 + labels = _overlapping_labels(n, horizon) + split = next(purged_walk_forward_splits(n, labels, n_folds=5)) + start = split.test.min() + # Every retained training observation's label resolves strictly before the + # test block opens. + assert (labels[split.train] < start).all() + assert split.purged > 0 + + +def test_walk_forward_expanding_grows_and_rolling_does_not(): + n = 1000 + labels = np.arange(n) + expanding = [s.train.size for s in purged_walk_forward_splits(n, labels, n_folds=5)] + rolling = [ + s.train.size for s in purged_walk_forward_splits(n, labels, n_folds=5, expanding=False) + ] + assert expanding == sorted(expanding) + assert expanding[-1] > expanding[0] + assert max(rolling) - min(rolling) <= 1 + + +def test_walk_forward_splits_are_leak_free(): + n = 800 + labels = _overlapping_labels(n, 30) + for split in purged_walk_forward_splits(n, labels, n_folds=6): + assert detect_boundary_leakage(split, labels, n_samples=n).clean + + +# --- combinatorial --- + + +def test_combinatorial_yields_every_combination(): + n = 600 + labels = np.arange(n) + splits = list(combinatorial_purged_splits(n, labels, n_groups=6, n_test_groups=2)) + assert len(splits) == math.comb(6, 2) + + +def test_combinatorial_test_blocks_are_disjoint_from_training(): + n = 600 + labels = _overlapping_labels(n, 12) + for split in combinatorial_purged_splits(n, labels, n_groups=6, n_test_groups=2): + assert np.intersect1d(split.train, split.test).size == 0 + assert detect_boundary_leakage(split, labels, n_samples=n).clean + + +def test_combinatorial_holds_out_more_than_one_block_at_a_time(): + n = 600 + splits = list(combinatorial_purged_splits(n, np.arange(n), n_groups=6, n_test_groups=3)) + for split in splits: + assert split.test.size >= 3 * (n // 6) - 3 + + +# --- label end times as a pandas Series of timestamps --- + + +def test_label_end_times_accepts_a_timestamp_series(): + index = pd.date_range("2024-01-01", periods=200, freq="B") + # Each label resolves 5 business days later. + ends = pd.Series(index.to_series().shift(-5).bfill().values, index=index) + splits = list(purged_kfold_splits(len(index), ends, n_folds=4, embargo_fraction=0.0)) + assert len(splits) == 4 + for split in splits: + assert np.intersect1d(split.train, split.test).size == 0 + assert split.purged >= 0 + + +def test_a_label_ending_before_it_starts_is_rejected(): + bad = np.arange(100) - 5 + with pytest.raises(ValueError, match="cannot end before"): + purged_kfold_splits(100, bad, n_folds=4).__next__() + + +# --- fold accounting and validation --- + + +def test_every_observation_is_tested_exactly_once_across_kfold(): + n = 1000 + tested = np.concatenate([s.test for s in purged_kfold_splits(n, np.arange(n), n_folds=5)]) + assert np.array_equal(np.sort(tested), np.arange(n)) + + +def test_folds_come_back_in_chronological_order(): + n = 500 + bounds = [s.test_bounds for s in purged_kfold_splits(n, np.arange(n), n_folds=5)] + assert bounds == sorted(bounds) + + +@pytest.mark.parametrize("n_folds", [0, 1, MIN_FOLDS - 1]) +def test_too_few_folds_rejected(n_folds): + with pytest.raises(ValueError, match="at least"): + list(purged_kfold_splits(100, np.arange(100), n_folds=n_folds)) + + +def test_more_folds_than_samples_rejected(): + with pytest.raises(ValueError, match="cannot make"): + list(purged_kfold_splits(3, np.arange(3), n_folds=5)) + + +@pytest.mark.parametrize("fraction", [-0.01, 1.0, 2.0]) +def test_bad_embargo_fraction_rejected(fraction): + with pytest.raises(ValueError, match="embargo_fraction"): + list(purged_kfold_splits(100, np.arange(100), n_folds=4, embargo_fraction=fraction)) + + +def test_mismatched_label_length_rejected(): + with pytest.raises(ValueError, match="entries but the sample has"): + list(purged_kfold_splits(100, np.arange(50), n_folds=4)) + + +@pytest.mark.parametrize("n_test_groups", [0, 6, 7]) +def test_bad_combinatorial_group_count_rejected(n_test_groups): + with pytest.raises(ValueError, match="n_test_groups"): + list(combinatorial_purged_splits(600, np.arange(600), n_groups=6, n_test_groups=n_test_groups)) + + +def test_purge_counts_are_reported_not_hidden(): + n = 1000 + labels = _overlapping_labels(n, 25) + for split in purged_kfold_splits(n, labels, n_folds=5): + removed = n - split.train.size - split.test.size + assert split.purged + split.embargoed == removed diff --git a/agent/tests/quantlib/test_eventstudy.py b/agent/tests/quantlib/test_eventstudy.py new file mode 100644 index 00000000..e98c4125 --- /dev/null +++ b/agent/tests/quantlib/test_eventstudy.py @@ -0,0 +1,313 @@ +"""Tests for src.quantlib.eventstudy. + +Two-sided throughout: a sample with no event effect must NOT be called +significant, and a sample with an injected effect must be. +""" + +import numpy as np +import pandas as pd +import pytest + +from src.quantlib.eventstudy import ( + DEFAULT_ESTIMATION_GAP, + MIN_ESTIMATION_OBSERVATIONS, + estimate_market_model, + event_study, +) + + +def _panel(n_days=400, n_symbols=30, seed=0, beta=1.2, alpha=0.0002, vol=0.012): + """Build a return frame generated by a known market model.""" + rng = np.random.default_rng(seed) + index = pd.date_range("2020-01-01", periods=n_days, freq="B") + market = pd.Series(rng.normal(0.0003, 0.009, n_days), index=index, name="market") + symbols = [f"S{i:02d}" for i in range(n_symbols)] + data = { + s: alpha + beta * market.to_numpy() + rng.normal(0.0, vol, n_days) + for s in symbols + } + return pd.DataFrame(data, index=index), market + + +# --- market model fitting --- + + +def test_market_model_recovers_known_alpha_and_beta(): + rng = np.random.default_rng(5) + market = rng.normal(0.0, 0.01, 500) + asset = 0.0005 + 1.4 * market + rng.normal(0.0, 0.002, 500) + fit = estimate_market_model(asset, market) + assert fit.beta == pytest.approx(1.4, abs=0.03) + assert fit.alpha == pytest.approx(0.0005, abs=0.0003) + assert fit.residual_std == pytest.approx(0.002, rel=0.15) + assert fit.observations == 500 + + +def test_market_adjusted_model_fixes_beta_at_one(): + rng = np.random.default_rng(6) + market = rng.normal(0.0, 0.01, 200) + asset = market + rng.normal(0.0, 0.001, 200) + fit = estimate_market_model(asset, market, model="market_adjusted") + assert fit.alpha == 0.0 + assert fit.beta == 1.0 + + +def test_mean_adjusted_model_ignores_the_market(): + rng = np.random.default_rng(7) + market = rng.normal(0.0, 0.01, 200) + asset = 0.001 + rng.normal(0.0, 0.003, 200) + fit = estimate_market_model(asset, market, model="mean_adjusted") + assert fit.beta == 0.0 + assert fit.alpha == pytest.approx(asset.mean()) + + +def test_short_estimation_window_is_an_error(): + with pytest.raises(ValueError, match="at least"): + estimate_market_model(np.zeros(10), np.zeros(10)) + + +def test_constant_market_leaves_beta_unidentified(): + rng = np.random.default_rng(8) + with pytest.raises(ValueError, match="not identified"): + estimate_market_model(rng.normal(0, 0.01, 100), np.zeros(100)) + + +def test_unknown_model_rejected(): + with pytest.raises(ValueError, match="model must be one of"): + estimate_market_model(np.zeros(100), np.zeros(100), model="cape") + + +# --- the null: no effect must not be called significant --- + + +def test_no_effect_sample_is_not_significant(): + returns, market = _panel(seed=101) + events = [(s, returns.index[250]) for s in returns.columns] + result = event_study(returns, market, events, event_window=(-2, 2)) + + assert abs(result.caar_total) < 0.01 + assert result.bmp_p_value > 0.05 + assert result.t_p_value > 0.05 + + +# --- an injected effect must be found, at the right size --- + + +def test_injected_event_day_jump_is_recovered_in_caar(): + returns, market = _panel(seed=202) + event_row = 250 + jump = 0.05 + returns = returns.copy() + returns.iloc[event_row] += jump + + events = [(s, returns.index[event_row]) for s in returns.columns] + result = event_study(returns, market, events, event_window=(-2, 2)) + + assert result.caar_total == pytest.approx(jump, abs=0.01) + assert result.aar.loc[0] == pytest.approx(jump, abs=0.01) + # Days either side of the event carry no injected effect. + assert abs(result.aar.loc[-1]) < 0.01 + assert abs(result.aar.loc[1]) < 0.01 + assert result.bmp_p_value < 0.01 + assert result.patell_p_value < 0.01 + assert result.t_p_value < 0.01 + + +def test_car_is_exactly_the_sum_of_the_abnormal_returns(): + returns, market = _panel(seed=303) + events = [(s, returns.index[250]) for s in returns.columns[:5]] + result = event_study(returns, market, events, event_window=(-3, 3)) + for outcome in result.events: + assert outcome.car == pytest.approx(float(outcome.abnormal_returns.sum())) + + +def test_caar_is_the_running_sum_of_aar(): + returns, market = _panel(seed=304) + events = [(s, returns.index[250]) for s in returns.columns] + result = event_study(returns, market, events, event_window=(-4, 4)) + assert result.caar.to_numpy() == pytest.approx(np.cumsum(result.aar.to_numpy())) + assert result.caar_total == pytest.approx(result.caar.iloc[-1]) + + +# --- the estimation window must not see the event --- + + +def test_estimation_window_excludes_the_event_window(): + returns, market = _panel(seed=404) + event_row = 250 + contaminated = returns.copy() + # A violent, sustained move confined to the event window. If the estimation + # window overlapped it, beta would be dragged and the abnormal return would + # shrink toward zero -- the study would under-report its own finding. + contaminated.iloc[event_row - 5 : event_row + 6] += 0.25 + + events = [(s, returns.index[event_row]) for s in returns.columns] + clean = event_study(returns, market, events, event_window=(-5, 5)) + dirty = event_study(contaminated, market, events, event_window=(-5, 5)) + + # The fitted betas are unchanged: the estimation window never saw the shock. + for a, b in zip(clean.events, dirty.events): + assert a.fit.beta == pytest.approx(b.fit.beta, rel=1e-12) + # And the whole injected move shows up as abnormal return: 11 days x 0.25. + assert dirty.caar_total == pytest.approx(clean.caar_total + 11 * 0.25, abs=0.02) + + +def test_estimation_gap_is_respected(): + returns, market = _panel(seed=405) + event_row = 250 + contaminated = returns.copy() + # Poison only the gap rows, which sit between the estimation and event + # windows and must be used by neither. + gap_start = event_row - 5 - DEFAULT_ESTIMATION_GAP + contaminated.iloc[gap_start : event_row - 5] += 0.30 + + events = [(s, returns.index[event_row]) for s in returns.columns] + clean = event_study(returns, market, events, event_window=(-5, 5)) + dirty = event_study(contaminated, market, events, event_window=(-5, 5)) + + for a, b in zip(clean.events, dirty.events): + assert a.fit.beta == pytest.approx(b.fit.beta, rel=1e-12) + assert a.car == pytest.approx(b.car, rel=1e-12) + + +# --- BMP vs Patell under event-induced variance --- + + +def test_bmp_survives_event_induced_variance_where_patell_over_rejects(): + # The null is true: no mean effect. But the event window is three times as + # volatile as the estimation window, which is what real events do. Patell + # assumes event-window variance equals estimation-window variance and + # therefore rejects far too often; BMP takes the cross-sectional variance of + # the standardised values and holds its size. + rng = np.random.default_rng(909) + patell_rejections = 0 + bmp_rejections = 0 + trials = 60 + + for trial in range(trials): + n_days, n_symbols = 320, 40 + index = pd.date_range("2020-01-01", periods=n_days, freq="B") + market = pd.Series(rng.normal(0.0, 0.008, n_days), index=index) + event_row = 250 + data = {} + for i in range(n_symbols): + noise = rng.normal(0.0, 0.01, n_days) + noise[event_row - 2 : event_row + 3] *= 3.0 # variance, not mean + data[f"S{i:02d}"] = 1.0 * market.to_numpy() + noise + frame = pd.DataFrame(data, index=index) + events = [(s, index[event_row]) for s in frame.columns] + result = event_study(frame, market, events, event_window=(-2, 2)) + patell_rejections += result.patell_p_value < 0.05 + bmp_rejections += result.bmp_p_value < 0.05 + + patell_rate = patell_rejections / trials + bmp_rate = bmp_rejections / trials + assert bmp_rate <= 0.15, f"BMP should hold its size, got {bmp_rate:.2%}" + assert patell_rate > bmp_rate, ( + f"Patell should over-reject under event-induced variance " + f"(patell {patell_rate:.2%} vs bmp {bmp_rate:.2%})" + ) + + +# --- dropped events are reported, never silently skipped --- + + +def test_unknown_symbol_is_dropped_with_a_reason(): + returns, market = _panel(seed=505) + events = [("S00", returns.index[250]), ("NOPE", returns.index[250])] + result = event_study(returns, market, events) + assert len(result.events) == 1 + assert result.dropped == (("NOPE", returns.index[250], "symbol not in returns frame"),) + + +def test_event_too_early_for_an_estimation_window_is_dropped(): + returns, market = _panel(seed=506) + events = [("S00", returns.index[5])] + with pytest.raises(ValueError, match="no event could be measured"): + event_study(returns, market, events) + + +def test_event_window_running_past_the_frame_is_dropped(): + returns, market = _panel(seed=507) + events = [("S00", returns.index[250]), ("S01", returns.index[-2])] + result = event_study(returns, market, events, event_window=(-1, 5)) + reasons = [r for _, _, r in result.dropped] + assert reasons == ["event window runs past the frame end"] + + +def test_non_finite_event_window_is_dropped(): + returns, market = _panel(seed=508) + returns = returns.copy() + returns.iloc[250, returns.columns.get_loc("S00")] = np.nan + events = [("S00", returns.index[250]), ("S01", returns.index[250])] + result = event_study(returns, market, events, event_window=(-1, 1)) + assert [s for s, _, _ in result.dropped] == ["S00"] + assert [o.symbol for o in result.events] == ["S01"] + + +# --- clustering disclosure --- + + +def test_shared_event_dates_are_flagged(): + returns, market = _panel(seed=606) + same_day = returns.index[250] + events = [(s, same_day) for s in returns.columns[:4]] + result = event_study(returns, market, events) + assert result.shared_event_dates == (same_day,) + + +def test_distinct_event_dates_are_not_flagged(): + returns, market = _panel(seed=607) + events = [ + ("S00", returns.index[240]), + ("S01", returns.index[250]), + ("S02", returns.index[260]), + ] + result = event_study(returns, market, events) + assert result.shared_event_dates == () + + +# --- input validation --- + + +def test_inverted_event_window_rejected(): + returns, market = _panel(seed=707) + with pytest.raises(ValueError, match="start must be <= end"): + event_study(returns, market, [("S00", returns.index[250])], event_window=(3, -3)) + + +def test_negative_estimation_gap_rejected(): + returns, market = _panel(seed=708) + with pytest.raises(ValueError, match="estimation_gap must be >= 0"): + event_study(returns, market, [("S00", returns.index[250])], estimation_gap=-1) + + +def test_too_short_estimation_window_rejected(): + returns, market = _panel(seed=709) + with pytest.raises(ValueError, match="estimation_window must be at least"): + event_study( + returns, + market, + [("S00", returns.index[250])], + estimation_window=MIN_ESTIMATION_OBSERVATIONS - 1, + ) + + +def test_empty_events_rejected(): + returns, market = _panel(seed=710) + with pytest.raises(ValueError, match="events is empty"): + event_study(returns, market, []) + + +def test_market_missing_labels_is_an_error(): + returns, market = _panel(seed=711) + with pytest.raises(ValueError, match="missing"): + event_study(returns, market.iloc[10:], [("S00", returns.index[250])]) + + +def test_post_event_only_window_is_allowed(): + returns, market = _panel(seed=712) + events = [(s, returns.index[250]) for s in returns.columns] + result = event_study(returns, market, events, event_window=(1, 5)) + assert result.event_window == (1, 5) + assert list(result.aar.index) == [1, 2, 3, 4, 5] diff --git a/agent/tests/quantlib/test_factormodel.py b/agent/tests/quantlib/test_factormodel.py new file mode 100644 index 00000000..aaf1450f --- /dev/null +++ b/agent/tests/quantlib/test_factormodel.py @@ -0,0 +1,427 @@ +"""Tests for src.quantlib.factormodel. + +The recoverability tests build a cross-section from known factor returns and +check the regression gets them back. The standardisation tests pin the two +conventions that make an exposure mean what it claims: cap-weighted centring and +equal-weighted scaling. +""" + +import numpy as np +import pandas as pd +import pytest + +from src.quantlib.factormodel import ( + MARKET_FACTOR, + MIN_CROSS_SECTION, + STYLE_FACTOR_DEFINITIONS, + build_style_exposures, + cross_sectional_factor_returns, + factor_return_attribution, + portfolio_style_exposure, + standardise_exposures, + style_drift, +) + + +def _assets(n=200): + return [f"A{i:03d}" for i in range(n)] + + +# --- standardisation --- + + +def test_standardised_exposure_has_unit_equal_weighted_dispersion(): + rng = np.random.default_rng(1) + values = pd.Series(rng.normal(5.0, 2.0, 500), index=_assets(500)) + exposures = standardise_exposures(values, winsorise=0.0) + + assert exposures.mean() == pytest.approx(0.0, abs=1e-12) + assert exposures.std(ddof=1) == pytest.approx(1.0, rel=1e-12) + + +def test_cap_weighted_centring_puts_the_market_portfolio_at_zero(): + # This is the property that makes a portfolio exposure readable as a tilt + # away from the market rather than an absolute level. + rng = np.random.default_rng(2) + index = _assets(300) + values = pd.Series(rng.normal(0.0, 1.0, 300), index=index) + caps = pd.Series(rng.lognormal(10, 1.5, 300), index=index) + + exposures = standardise_exposures(values, market_caps=caps, winsorise=0.0) + market_weights = caps / caps.sum() + + assert float((exposures * market_weights).sum()) == pytest.approx(0.0, abs=1e-12) + + +def test_equal_weighted_scaling_is_not_cap_weighted(): + # The asymmetry is deliberate: a few megacaps must not set the scale. + rng = np.random.default_rng(3) + index = _assets(300) + values = pd.Series(rng.normal(0.0, 1.0, 300), index=index) + caps = pd.Series(np.r_[np.full(5, 1e12), np.full(295, 1e8)], index=index) + + exposures = standardise_exposures(values, market_caps=caps, winsorise=0.0) + assert exposures.std(ddof=1) == pytest.approx(1.0, rel=1e-12) + + +def test_winsorising_stops_one_outlier_compressing_the_cross_section(): + index = _assets(200) + clean = pd.Series(np.linspace(-1, 1, 200), index=index) + poisoned = clean.copy() + poisoned.iloc[0] = 500.0 + + without = standardise_exposures(poisoned, winsorise=0.0) + with_winsor = standardise_exposures(poisoned, winsorise=0.025) + + # Without winsorising the outlier eats the scale and everything else + # collapses toward zero. + assert without.drop(index[0]).abs().max() < 0.1 + assert with_winsor.drop(index[0]).abs().max() > 1.0 + + +def test_standardise_rejects_a_constant_characteristic(): + with pytest.raises(ValueError, match="no cross-sectional variation"): + standardise_exposures(pd.Series(np.full(50, 3.0), index=_assets(50))) + + +def test_standardise_rejects_a_cross_section_too_small_to_scale(): + small = pd.Series(np.arange(float(MIN_CROSS_SECTION - 1)), index=_assets(MIN_CROSS_SECTION - 1)) + with pytest.raises(ValueError, match="at least"): + standardise_exposures(small) + + +def test_standardise_rejects_bad_winsorise_fraction(): + values = pd.Series(np.arange(50.0), index=_assets(50)) + for bad in (-0.01, 0.5, 0.9): + with pytest.raises(ValueError, match="winsorise must be"): + standardise_exposures(values, winsorise=bad) + + +def test_standardise_rejects_market_caps_that_do_not_cover_the_universe(): + index = _assets(50) + values = pd.Series(np.arange(50.0), index=index) + caps = pd.Series(np.arange(1.0, 41.0), index=index[:40]) + with pytest.raises(ValueError, match="missing"): + standardise_exposures(values, market_caps=caps) + + +# --- exposure assembly --- + + +def test_build_style_exposures_uses_the_documented_signs(): + rng = np.random.default_rng(4) + index = _assets(200) + characteristics = pd.DataFrame( + { + "log_market_cap": rng.normal(0, 1, 200), + "book_to_price": rng.normal(0, 1, 200), + }, + index=index, + ) + exposures, _ = build_style_exposures(characteristics) + + # size is defined as -1 * log_market_cap: small caps get the large exposure. + assert exposures["size"].corr(characteristics["log_market_cap"]) < -0.9 + # value is +1 * book_to_price: cheap names get the large exposure. + assert exposures["value"].corr(characteristics["book_to_price"]) > 0.9 + + +def test_multi_input_factor_averages_its_standardised_parts(): + rng = np.random.default_rng(5) + index = _assets(200) + # earnings_to_price has a hugely wider raw spread than book_to_price. If the + # factor averaged the RAW inputs it would be an earnings-yield factor with a + # book-to-price rounding error attached. + characteristics = pd.DataFrame( + { + "book_to_price": rng.normal(0, 0.01, 200), + "earnings_to_price": rng.normal(0, 100.0, 200), + }, + index=index, + ) + exposures, _ = build_style_exposures(characteristics) + + b = standardise_exposures(characteristics["book_to_price"]) + e = standardise_exposures(characteristics["earnings_to_price"]) + assert exposures["value"].corr(b) == pytest.approx(exposures["value"].corr(e), abs=0.15) + + +def test_missing_characteristic_cells_become_zero_and_are_counted(): + rng = np.random.default_rng(6) + index = _assets(100) + values = pd.Series(rng.normal(0, 1, 100), index=index) + values.iloc[:20] = np.nan + exposures, filled = build_style_exposures(pd.DataFrame({"book_to_price": values})) + + assert filled["value"] == 20 + assert (exposures["value"].iloc[:20] == 0.0).all() + + +def test_factors_with_no_supplied_characteristic_are_skipped_not_faked(): + rng = np.random.default_rng(7) + frame = pd.DataFrame({"book_to_price": rng.normal(0, 1, 100)}, index=_assets(100)) + exposures, _ = build_style_exposures(frame) + + assert list(exposures.columns) == ["value"] + assert "momentum" not in exposures.columns + + +def test_build_rejects_a_frame_with_no_usable_characteristic(): + frame = pd.DataFrame({"unrelated_column": np.arange(100.0)}, index=_assets(100)) + with pytest.raises(ValueError, match="no factor could be built"): + build_style_exposures(frame) + + +def test_build_rejects_an_empty_frame(): + with pytest.raises(ValueError, match="empty"): + build_style_exposures(pd.DataFrame()) + + +def test_every_documented_factor_declares_at_least_one_characteristic(): + for factor, recipe in STYLE_FACTOR_DEFINITIONS.items(): + assert recipe, f"{factor} has no characteristics" + assert all(sign in (1, -1) for sign in recipe.values()), factor + + +# --- cross-sectional factor returns --- + + +def test_factor_returns_recover_the_generating_coefficients(): + rng = np.random.default_rng(8) + index = _assets(400) + exposures = pd.DataFrame( + { + "value": rng.normal(0, 1, 400), + "momentum": rng.normal(0, 1, 400), + "size": rng.normal(0, 1, 400), + }, + index=index, + ) + true_market, true_value, true_momentum, true_size = 0.004, 0.002, -0.003, 0.0015 + returns = pd.Series( + true_market + + true_value * exposures["value"] + + true_momentum * exposures["momentum"] + + true_size * exposures["size"] + + rng.normal(0, 0.002, 400), + index=index, + ) + + fit = cross_sectional_factor_returns(returns, exposures) + + assert fit.factor_returns[MARKET_FACTOR] == pytest.approx(true_market, abs=0.0004) + assert fit.factor_returns["value"] == pytest.approx(true_value, abs=0.0004) + assert fit.factor_returns["momentum"] == pytest.approx(true_momentum, abs=0.0004) + assert fit.factor_returns["size"] == pytest.approx(true_size, abs=0.0004) + assert fit.r_squared > 0.7 + assert fit.observations == 400 + + +def test_market_factor_is_always_present_as_the_intercept(): + rng = np.random.default_rng(9) + index = _assets(100) + exposures = pd.DataFrame({"value": rng.normal(0, 1, 100)}, index=index) + # Everything returns +2% regardless of exposure: that is a market move and + # it must land on the market factor, not on value. + returns = pd.Series(np.full(100, 0.02), index=index) + + fit = cross_sectional_factor_returns(returns, exposures) + + assert fit.factor_returns[MARKET_FACTOR] == pytest.approx(0.02, abs=1e-9) + assert fit.factor_returns["value"] == pytest.approx(0.0, abs=1e-9) + + +def test_residuals_are_the_specific_returns_and_sum_with_the_fit(): + rng = np.random.default_rng(10) + index = _assets(150) + exposures = pd.DataFrame({"value": rng.normal(0, 1, 150)}, index=index) + returns = pd.Series(0.001 + 0.002 * exposures["value"] + rng.normal(0, 0.001, 150), index=index) + + fit = cross_sectional_factor_returns(returns, exposures) + reconstructed = ( + fit.factor_returns[MARKET_FACTOR] + + fit.factor_returns["value"] * exposures["value"] + + fit.residuals + ) + assert reconstructed.to_numpy() == pytest.approx(returns.to_numpy(), abs=1e-12) + + +def test_cap_weighting_changes_the_fit_toward_the_larger_names(): + rng = np.random.default_rng(11) + index = _assets(200) + exposures = pd.DataFrame({"value": rng.normal(0, 1, 200)}, index=index) + returns = pd.Series(rng.normal(0, 0.01, 200), index=index) + # Big names all get an extra +5% that the small ones do not. + caps = pd.Series(np.r_[np.full(20, 1e12), np.full(180, 1e8)], index=index) + returns.iloc[:20] += 0.05 + + unweighted = cross_sectional_factor_returns(returns, exposures) + weighted = cross_sectional_factor_returns(returns, exposures, market_caps=caps) + + assert weighted.factor_returns[MARKET_FACTOR] > unweighted.factor_returns[MARKET_FACTOR] + + +def test_collinear_exposures_are_rejected_not_silently_pseudo_inverted(): + rng = np.random.default_rng(12) + index = _assets(100) + base = rng.normal(0, 1, 100) + exposures = pd.DataFrame({"a": base, "b": base * 2.0}, index=index) + returns = pd.Series(rng.normal(0, 0.01, 100), index=index) + + with pytest.raises(ValueError, match="collinear"): + cross_sectional_factor_returns(returns, exposures) + + +def test_constant_exposure_column_is_collinear_with_the_market(): + index = _assets(100) + exposures = pd.DataFrame({"dud": np.ones(100)}, index=index) + returns = pd.Series(np.random.default_rng(13).normal(0, 0.01, 100), index=index) + + with pytest.raises(ValueError, match="collinear"): + cross_sectional_factor_returns(returns, exposures) + + +def test_too_few_assets_rejected(): + index = _assets(5) + exposures = pd.DataFrame({"value": np.arange(5.0)}, index=index) + returns = pd.Series(np.arange(5.0) / 100, index=index) + + with pytest.raises(ValueError, match="at least"): + cross_sectional_factor_returns(returns, exposures) + + +def test_non_positive_market_caps_rejected(): + rng = np.random.default_rng(14) + index = _assets(100) + exposures = pd.DataFrame({"value": rng.normal(0, 1, 100)}, index=index) + returns = pd.Series(rng.normal(0, 0.01, 100), index=index) + caps = pd.Series(np.r_[np.zeros(1), np.full(99, 1e9)], index=index) + + with pytest.raises(ValueError, match="must be positive"): + cross_sectional_factor_returns(returns, exposures, market_caps=caps) + + +# --- portfolio exposure --- + + +def test_portfolio_exposure_is_the_weighted_sum(): + index = _assets(4) + exposures = pd.DataFrame({"value": [1.0, -1.0, 2.0, 0.0]}, index=index) + holdings = pd.Series([0.25, 0.25, 0.25, 0.25], index=index) + + result = portfolio_style_exposure(holdings, exposures) + assert result["value"] == pytest.approx(0.5) + assert result["unmatched_weight"] == 0.0 + + +def test_active_exposure_subtracts_the_benchmark(): + index = _assets(4) + exposures = pd.DataFrame({"value": [1.0, 1.0, -1.0, -1.0]}, index=index) + holdings = pd.Series([0.5, 0.5, 0.0, 0.0], index=index) + benchmark = pd.Series([0.25, 0.25, 0.25, 0.25], index=index) + + active = portfolio_style_exposure(holdings, exposures, benchmark=benchmark) + assert active["value"] == pytest.approx(1.0) + + +def test_holdings_without_exposure_data_are_disclosed_not_dropped(): + exposures = pd.DataFrame({"value": [1.0, 1.0]}, index=["A000", "A001"]) + holdings = pd.Series([0.3, 0.3, 0.4], index=["A000", "A001", "UNKNOWN"]) + + result = portfolio_style_exposure(holdings, exposures) + assert result["value"] == pytest.approx(0.6) + assert result["unmatched_weight"] == pytest.approx(0.4) + + +def test_partially_invested_book_reports_a_scaled_exposure(): + exposures = pd.DataFrame({"value": [1.0, 1.0]}, index=["A000", "A001"]) + half_invested = pd.Series([0.3, 0.3], index=["A000", "A001"]) + assert portfolio_style_exposure(half_invested, exposures)["value"] == pytest.approx(0.6) + + +def test_empty_holdings_rejected(): + exposures = pd.DataFrame({"value": [1.0]}, index=["A000"]) + with pytest.raises(ValueError, match="holdings is empty"): + portfolio_style_exposure(pd.Series(dtype=float), exposures) + + +# --- style drift --- + + +def test_style_drift_measures_movement_not_endpoints(): + # A fund that ends where it started but went somewhere else in between has + # drifted, and total_change alone would say it had not. + history = pd.DataFrame( + {"value": [0.0, 1.5, -1.5, 0.0], "size": [0.2, 0.2, 0.2, 0.2]}, + index=pd.date_range("2024-01-31", periods=4, freq="ME"), + ) + drift = style_drift(history) + + assert drift.total_change["value"] == pytest.approx(0.0) + assert drift.max_abs_change["value"] == pytest.approx(3.0) + assert drift.std_exposure["value"] > drift.std_exposure["size"] + assert drift.std_exposure["size"] == pytest.approx(0.0) + + +def test_style_drift_reports_first_and_last(): + history = pd.DataFrame( + {"momentum": [-1.0, 0.0, 1.0]}, + index=pd.date_range("2024-01-31", periods=3, freq="ME"), + ) + drift = style_drift(history) + assert drift.first_exposure["momentum"] == -1.0 + assert drift.last_exposure["momentum"] == 1.0 + assert drift.total_change["momentum"] == pytest.approx(2.0) + + +def test_style_drift_ignores_the_unmatched_weight_column(): + history = pd.DataFrame( + {"value": [0.5, 0.7], "unmatched_weight": [0.1, 0.2]}, + index=pd.date_range("2024-01-31", periods=2, freq="ME"), + ) + drift = style_drift(history) + assert "unmatched_weight" not in drift.mean_exposure.index + + +def test_style_drift_needs_two_dates(): + history = pd.DataFrame({"value": [0.5]}, index=pd.date_range("2024-01-31", periods=1)) + with pytest.raises(ValueError, match="at least 2 dates"): + style_drift(history) + + +# --- attribution --- + + +def test_attribution_parts_sum_to_the_portfolio_return_exactly(): + exposures = pd.Series({"value": 0.5, "momentum": -0.3, "unmatched_weight": 0.1}) + factor_returns = pd.Series({"value": 0.02, "momentum": 0.01, MARKET_FACTOR: 0.005}) + + result = factor_return_attribution(exposures, factor_returns, portfolio_return=0.015) + + parts = result.drop(labels=["total"]) + assert float(parts.sum()) == pytest.approx(0.015, abs=1e-15) + assert result["total"] == pytest.approx(0.015) + assert result["value"] == pytest.approx(0.5 * 0.02) + assert result["momentum"] == pytest.approx(-0.3 * 0.01) + + +def test_attribution_residual_absorbs_whatever_the_factors_miss(): + exposures = pd.Series({"value": 1.0}) + factor_returns = pd.Series({"value": 0.01}) + result = factor_return_attribution(exposures, factor_returns, portfolio_return=0.05) + assert result["specific"] == pytest.approx(0.04) + + +def test_attribution_ignores_the_unmatched_weight_entry(): + exposures = pd.Series({"value": 1.0, "unmatched_weight": 0.9}) + factor_returns = pd.Series({"value": 0.01, "unmatched_weight": 99.0}) + result = factor_return_attribution(exposures, factor_returns, portfolio_return=0.02) + assert "unmatched_weight" not in result.index + assert result["specific"] == pytest.approx(0.01) + + +def test_attribution_rejects_disjoint_factor_sets(): + with pytest.raises(ValueError, match="share no factor"): + factor_return_attribution( + pd.Series({"value": 1.0}), pd.Series({"momentum": 0.01}), portfolio_return=0.0 + ) diff --git a/agent/tests/quantlib/test_fundmath.py b/agent/tests/quantlib/test_fundmath.py index 7c15510d..64e658d4 100644 --- a/agent/tests/quantlib/test_fundmath.py +++ b/agent/tests/quantlib/test_fundmath.py @@ -16,6 +16,7 @@ test agree with itself. import datetime as dt import numpy as np +import pandas as pd import pytest from src.entities.cashflow import CashFlow, CashFlowSeries @@ -25,19 +26,27 @@ from src.quantlib.fundmath import ( TIER_CATCH_UP, TIER_PREFERRED_RETURN, TIER_RETURN_OF_CAPITAL, + AmericanWaterfallResult, + ClawbackResult, FundMultiples, NoSignChangeError, + PMEPlusResult, WaterfallResult, WaterfallTier, XIRRConvergenceError, XIRRError, + american_waterfall, + direct_alpha, distributed_capital, dpi, european_waterfall, fund_multiples, + gp_clawback, + ks_pme, moic, npv, paid_in_capital, + pme_plus, preferred_return_amount, residual_value, rvpi, @@ -724,3 +733,495 @@ def test_the_series_waterfall_distributes_realised_cash_by_default(): def test_the_series_waterfall_refuses_a_bare_list(): with pytest.raises(TypeError, match="CashFlowSeries"): european_waterfall([-100.0, 150.0], preferred_rate=0.08, carry_rate=0.2) + + +# -------------------------------------------------------------------------- +# PME — shared fixtures +# -------------------------------------------------------------------------- +# +# One index, used everywhere below: 100 on 2019-01-01, 150 on 2020-01-01 (a +# 50% index return over exactly one year -- 2019 is not a leap year, so the +# span is exactly 365 days and every hand-computed rate below is a clean +# fraction under the module's own days_per_year=365 default). + +INDEX = pd.Series({dt.date(2019, 1, 1): 100.0, dt.date(2020, 1, 1): 150.0}) + + +# -------------------------------------------------------------------------- +# ks_pme +# -------------------------------------------------------------------------- + + +def test_ks_pme_of_a_fund_that_beat_the_index(): + """-1000 in, +2000 out, one year later, index +50% over the same year. + + FV(contributions) = 1000 * 150/100 = 1500 + FV(distributions) = 2000 * 150/150 = 2000 + KS-PME = 2000 / 1500 = 4/3 + """ + series = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 2000.0, "distribution")] + ) + assert ks_pme(series, INDEX) == pytest.approx(4.0 / 3.0, abs=1e-12) + + +def test_ks_pme_of_a_fund_that_lagged_the_index(): + """-1000 in, +900 out: FV(contrib)=1500, FV(dist)=900, KS-PME = 0.6.""" + series = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 900.0, "distribution")] + ) + assert ks_pme(series, INDEX) == pytest.approx(0.6, abs=1e-12) + + +def test_ks_pme_double_sided_guard(): + """Outperformance -> KS-PME > 1; underperformance -> KS-PME < 1.""" + winner = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 2000.0, "distribution")] + ) + loser = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 900.0, "distribution")] + ) + assert ks_pme(winner, INDEX) > 1.0 + assert ks_pme(loser, INDEX) < 1.0 + + +def test_ks_pme_includes_terminal_nav_via_residual_value(): + """-1000 in, +600 realised, +700 NAV, all valued as of 2020-01-01. + + FV(contrib) = 1500, FV(dist) = 600, FV(NAV) = 700 (factor 1, same date). + KS-PME = (600 + 700) / 1500 = 13/15. + """ + series = make_series( + [ + ("2019-01-01", -1000.0, "capital_call"), + ("2020-01-01", 600.0, "distribution"), + ("2020-01-01", 700.0, "nav"), + ] + ) + assert ks_pme(series, INDEX) == pytest.approx(1300.0 / 1500.0, abs=1e-12) + + +def test_ks_pme_only_the_latest_mark_counts_matching_residual_value(): + """Two marks, an early stale one and the terminal one; only the terminal counts.""" + with_stale_mark = make_series( + [ + ("2019-01-01", -1000.0, "capital_call"), + ("2019-06-01", 5000.0, "nav"), # stale, must be ignored + ("2020-01-01", 600.0, "distribution"), + ("2020-01-01", 700.0, "nav"), + ] + ) + index_with_extra_date = pd.Series( + {dt.date(2019, 1, 1): 100.0, dt.date(2019, 6, 1): 120.0, dt.date(2020, 1, 1): 150.0} + ) + assert ks_pme(with_stale_mark, index_with_extra_date) == pytest.approx( + 1300.0 / 1500.0, abs=1e-12 + ) + + +def test_ks_pme_refuses_no_contributions(): + series = make_series([("2020-01-01", 100.0, "distribution")]) + with pytest.raises(ValueError, match="no contributions"): + ks_pme(series, INDEX) + + +def test_ks_pme_refuses_a_missing_index_date(): + series = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 2000.0, "distribution")] + ) + sparse_index = pd.Series({dt.date(2020, 1, 1): 150.0}) # missing 2019-01-01 + with pytest.raises(ValueError, match="no entry for 2019-01-01"): + ks_pme(series, sparse_index) + + +def test_ks_pme_refuses_a_bare_list(): + with pytest.raises(TypeError, match="CashFlowSeries"): + ks_pme([-1000.0, 2000.0], INDEX) + + +def test_ks_pme_refuses_a_non_series_index(): + series = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 2000.0, "distribution")] + ) + with pytest.raises(TypeError, match="pandas Series"): + ks_pme(series, {dt.date(2019, 1, 1): 100.0}) + + +def test_ks_pme_refuses_an_empty_index(): + series = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 2000.0, "distribution")] + ) + with pytest.raises(ValueError, match="empty"): + ks_pme(series, pd.Series(dtype=float)) + + +# -------------------------------------------------------------------------- +# pme_plus +# -------------------------------------------------------------------------- + + +def test_pme_plus_scales_distributions_to_match_actual_nav(): + """No NAV (fully realised): lambda = FV(contrib)/FV(dist) = 1500/2000 = 0.75. + + Scaled series: -1000 @ 2019-01-01, +1500 @ 2020-01-01 -> IRR = 0.5. + """ + series = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 2000.0, "distribution")] + ) + result = pme_plus(series, INDEX) + assert isinstance(result, PMEPlusResult) + assert result.scaling_factor == pytest.approx(0.75, abs=1e-12) + assert result.irr == pytest.approx(0.5, abs=1e-9) + # Fund's own realised IRR (100%) exceeds the PME+ IRR (50%): outperformance. + assert xirr(series) > result.irr + + +def test_pme_plus_with_a_terminal_nav(): + """+600 realised, +700 NAV, both dated 2020-01-01. + + lambda = (FV(contrib) - FV(NAV)) / FV(dist) = (1500 - 700) / 600 = 4/3. + Scaled distribution = 600 * 4/3 = 800; scaled series -1000, +800, +700 (nav) + at the same date collapse to -1000 + 1500 one year later -> IRR = 0.5. + """ + series = make_series( + [ + ("2019-01-01", -1000.0, "capital_call"), + ("2020-01-01", 600.0, "distribution"), + ("2020-01-01", 700.0, "nav"), + ] + ) + result = pme_plus(series, INDEX) + assert result.scaling_factor == pytest.approx(4.0 / 3.0, abs=1e-12) + assert result.irr == pytest.approx(0.5, abs=1e-9) + # Fund's own realised IRR (30%) is below the PME+ IRR (50%): underperformance, + # consistent with this same series' KS-PME of 13/15 < 1. + assert xirr(series) < result.irr + + +def test_pme_plus_refuses_no_distributions(): + series = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 1200.0, "nav")] + ) + with pytest.raises(ValueError, match="no distributions"): + pme_plus(series, INDEX) + + +def test_pme_plus_refuses_no_contributions(): + series = make_series([("2020-01-01", 100.0, "distribution")]) + with pytest.raises(ValueError, match="no contributions"): + pme_plus(series, INDEX) + + +def test_pme_plus_refuses_a_missing_index_date(): + series = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 2000.0, "distribution")] + ) + sparse_index = pd.Series({dt.date(2019, 1, 1): 100.0}) # missing 2020-01-01 + with pytest.raises(ValueError, match="no entry for 2020-01-01"): + pme_plus(series, sparse_index) + + +# -------------------------------------------------------------------------- +# direct_alpha +# -------------------------------------------------------------------------- + + +def test_direct_alpha_of_a_fund_that_beat_the_index(): + """Deflated series: -1000/100=-10 @ t0, 2000/150=13.3333 @ t0+1y. + + IRR of (-10, +40/3 one year later) solves 1+r = (40/3)/10 = 4/3 -> r = 1/3. + """ + series = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 2000.0, "distribution")] + ) + assert direct_alpha(series, INDEX) == pytest.approx(1.0 / 3.0, abs=1e-9) + + +def test_direct_alpha_of_a_fund_that_lagged_the_index(): + """Deflated series: -10 @ t0, 900/150=6 @ t0+1y -> 1+r=0.6 -> r=-0.4.""" + series = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 900.0, "distribution")] + ) + assert direct_alpha(series, INDEX) == pytest.approx(-0.4, abs=1e-9) + + +def test_direct_alpha_double_sided_guard(): + winner = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 2000.0, "distribution")] + ) + loser = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 900.0, "distribution")] + ) + assert direct_alpha(winner, INDEX) > 0.0 + assert direct_alpha(loser, INDEX) < 0.0 + + +def test_direct_alpha_agrees_with_ks_pme_and_pme_plus_on_direction(): + """All three metrics must agree on which of two funds outperformed.""" + winner = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 2000.0, "distribution")] + ) + loser = make_series( + [("2019-01-01", -1000.0, "capital_call"), ("2020-01-01", 900.0, "distribution")] + ) + assert ks_pme(winner, INDEX) > 1.0 and direct_alpha(winner, INDEX) > 0.0 + assert xirr(winner) > pme_plus(winner, INDEX).irr + assert ks_pme(loser, INDEX) < 1.0 and direct_alpha(loser, INDEX) < 0.0 + assert xirr(loser) < pme_plus(loser, INDEX).irr + + +def test_direct_alpha_needs_the_index_to_cover_every_mark_not_only_the_terminal_one(): + """Unlike ks_pme/pme_plus, direct_alpha deflates every flow before the + valuation policy runs, so even a mark that "terminal" will later drop + still needs an index entry. + """ + series = make_series( + [ + ("2019-01-01", -1000.0, "capital_call"), + ("2019-06-01", 5000.0, "nav"), # stale mark, dropped by the default policy + ("2020-01-01", 600.0, "distribution"), + ("2020-01-01", 700.0, "nav"), + ] + ) + # INDEX has no entry for 2019-06-01, the stale mark's date. + with pytest.raises(ValueError, match="no entry for 2019-06-01"): + direct_alpha(series, INDEX) + + +def test_direct_alpha_on_an_all_valuation_series_raises(): + """No contributions, no distributions -- only two NAV marks. + + The default "terminal" valuation policy collapses this to one flow, which + xirr() already refuses; direct_alpha inherits that refusal unchanged. + """ + series = make_series([("2019-01-01", 100.0, "nav"), ("2020-01-01", 120.0, "nav")]) + with pytest.raises(ValueError, match="at least two flows"): + direct_alpha(series, INDEX) + + +def test_direct_alpha_refuses_a_bare_list(): + with pytest.raises(TypeError, match="CashFlowSeries"): + direct_alpha([-1000.0, 2000.0], INDEX) + + +# -------------------------------------------------------------------------- +# american_waterfall +# -------------------------------------------------------------------------- + + +def test_a_single_deal_american_waterfall_matches_european_waterfall(): + """A one-deal American waterfall must reduce exactly to a European one.""" + series = make_series( + [("2020-01-01", -100.0, "capital_call"), ("2022-01-01", 150.0, "distribution")] + ) + american = american_waterfall( + {"only": series}, + preferred_rate=0.08, + carry_rate=0.20, + catch_up_rate=1.0, + compounding="simple", + ) + european = european_waterfall( + series, preferred_rate=0.08, carry_rate=0.20, catch_up_rate=1.0, compounding="simple" + ) + assert american.gp_total == pytest.approx(european.gp_total) + assert american.lp_total == pytest.approx(european.lp_total) + assert american.distributable == pytest.approx(european.distributable) + assert american.deals["only"].gp_total == pytest.approx(european.gp_total) + + +def test_american_waterfall_pays_carry_deal_by_deal_hand_computed(): + """Two deals, 0% pref (so preferred/catch-up tiers vanish), 20% carry. + + Deal A: 100 in, 250 out -> profit 150 -> GP 30, LP 220. + Deal B: 100 in, 20 out -> a loss, no profit -> GP 0, LP 20. + Aggregate: GP 30, LP 240, distributable 270. + """ + deals = { + "A": make_series( + [("2020-01-01", -100.0, "capital_call"), ("2020-06-01", 250.0, "distribution")] + ), + "B": make_series( + [("2020-01-01", -100.0, "capital_call"), ("2021-01-01", 20.0, "distribution")] + ), + } + result = american_waterfall(deals, preferred_rate=0.0, carry_rate=0.20, catch_up_rate=1.0) + assert isinstance(result, AmericanWaterfallResult) + assert result.deals["A"].gp_total == pytest.approx(30.0) + assert result.deals["A"].lp_total == pytest.approx(220.0) + assert result.deals["B"].gp_total == pytest.approx(0.0) + assert result.deals["B"].lp_total == pytest.approx(20.0) + assert result.gp_total == pytest.approx(30.0) + assert result.lp_total == pytest.approx(240.0) + assert result.distributable == pytest.approx(270.0) + + +def test_american_waterfall_catch_up_is_reused_unchanged_from_waterfall_split(): + """Deal-level catch-up must match the existing worked example exactly.""" + series = make_series( + [("2018-01-01", -100.0, "capital_call"), ("2020-01-01", 200.0, "distribution")] + ) + result = american_waterfall( + {"deal": series}, preferred_rate=0.08, carry_rate=0.20, catch_up_rate=0.50 + ) + deal_result = result.deals["deal"] + assert deal_result.gp_total == pytest.approx(20.0) + assert deal_result.gp_profit_share == pytest.approx(0.20) + + +def test_american_waterfall_refuses_empty_deals(): + with pytest.raises(ValueError, match="deals is empty"): + american_waterfall({}, preferred_rate=0.08, carry_rate=0.20) + + +def test_american_waterfall_refuses_a_non_mapping(): + with pytest.raises(TypeError, match="mapping"): + american_waterfall([("only", None)], preferred_rate=0.08, carry_rate=0.20) + + +def test_american_waterfall_refuses_mixed_currencies(): + deals = { + "A": make_series( + [("2020-01-01", -100.0, "capital_call"), ("2021-01-01", 150.0, "distribution")], + currency="USD", + ), + "B": make_series( + [("2020-01-01", -100.0, "capital_call"), ("2021-01-01", 150.0, "distribution")], + currency="EUR", + ), + } + with pytest.raises(ValueError, match="multiple currencies"): + american_waterfall(deals, preferred_rate=0.08, carry_rate=0.20) + + +# -------------------------------------------------------------------------- +# gp_clawback — the headline test: same cash flows, European=0, American>0 +# -------------------------------------------------------------------------- + + +def test_european_never_claws_back_but_american_does_on_the_same_cash_flows(): + """The single most important test in this file. + + Same two deals as the hand-computed American test above (0% pref, 20% + carry, 100% catch-up): + + Deal A: 100 in, 250 out (day ~150) -> profit 150 -> GP carry 30 + Deal B: 100 in, 20 out (later) -> a loss -> GP carry 0 + + American (deal-by-deal): carry_received = 30 (paid the moment deal A + exits, with no visibility into deal B's eventual loss). + + Pooled/European (whole-of-fund): contributed 200, distributed 270, + profit 70, entitled GP carry = 0.20 * 70 = 14. A European structure only + ever pays carry once, off exactly this pooled number, so its "received" + and "entitled" are the same 14 by construction -- clawback is always 0. + + American clawback = max(0, 30 - 14) = 16 > 0: the GP was paid 30 on deal + A's early profit but only 14 was ever earned once deal B's loss is + counted, so 16 must come back. + """ + deals = { + "A": make_series( + [("2020-01-01", -100.0, "capital_call"), ("2020-06-01", 250.0, "distribution")] + ), + "B": make_series( + [("2020-01-01", -100.0, "capital_call"), ("2021-01-01", 20.0, "distribution")] + ), + } + + american = gp_clawback(deals, preferred_rate=0.0, carry_rate=0.20, catch_up_rate=1.0) + assert isinstance(american, ClawbackResult) + assert american.carry_received == pytest.approx(30.0) + assert american.carry_entitled == pytest.approx(14.0) + assert american.clawback_amount == pytest.approx(16.0) + assert american.clawback_amount > 0.0 + + pooled = make_series( + [ + ("2020-01-01", -100.0, "capital_call"), + ("2020-06-01", 250.0, "distribution"), + ("2020-01-01", -100.0, "capital_call"), + ("2021-01-01", 20.0, "distribution"), + ] + ) + european = european_waterfall(pooled, preferred_rate=0.0, carry_rate=0.20, catch_up_rate=1.0) + assert european.gp_total == pytest.approx(14.0) + # European pays once off the pooled totals: "received" and "entitled" are + # the same number by construction, so the clawback is exactly zero. + european_carry_received = european.gp_total + european_carry_entitled = european.gp_total + european_clawback = max(0.0, european_carry_received - european_carry_entitled) + assert european_clawback == 0.0 + + +def test_gp_clawback_tax_adjustment_scales_down_the_gross_amount(): + """Same scenario, 30% GP tax rate: net clawback = 16 * (1 - 0.30) = 11.2.""" + deals = { + "A": make_series( + [("2020-01-01", -100.0, "capital_call"), ("2020-06-01", 250.0, "distribution")] + ), + "B": make_series( + [("2020-01-01", -100.0, "capital_call"), ("2021-01-01", 20.0, "distribution")] + ), + } + gross = gp_clawback(deals, preferred_rate=0.0, carry_rate=0.20, catch_up_rate=1.0) + net = gp_clawback( + deals, preferred_rate=0.0, carry_rate=0.20, catch_up_rate=1.0, gp_tax_rate=0.30 + ) + assert gross.tax_adjusted is False + assert gross.gp_tax_rate is None + assert net.tax_adjusted is True + assert net.gp_tax_rate == pytest.approx(0.30) + assert net.clawback_amount == pytest.approx(11.2) + assert net.clawback_amount == pytest.approx(gross.clawback_amount * 0.70) + # The received/entitled figures themselves are unaffected by tax. + assert net.carry_received == pytest.approx(gross.carry_received) + assert net.carry_entitled == pytest.approx(gross.carry_entitled) + + +def test_gp_clawback_is_zero_when_no_deal_outpaced_the_pooled_entitlement(): + """A single profitable deal: deal-by-deal and pooled totals coincide.""" + deals = { + "only": make_series( + [("2020-01-01", -100.0, "capital_call"), ("2021-01-01", 150.0, "distribution")] + ) + } + result = gp_clawback(deals, preferred_rate=0.0, carry_rate=0.20, catch_up_rate=1.0) + assert result.carry_received == pytest.approx(result.carry_entitled) + assert result.clawback_amount == 0.0 + + +def test_gp_clawback_refuses_out_of_range_tax_rate(): + deals = { + "only": make_series( + [("2020-01-01", -100.0, "capital_call"), ("2021-01-01", 150.0, "distribution")] + ) + } + with pytest.raises(ValueError, match="gp_tax_rate"): + gp_clawback(deals, preferred_rate=0.08, carry_rate=0.20, gp_tax_rate=1.0) + with pytest.raises(ValueError, match="gp_tax_rate"): + gp_clawback(deals, preferred_rate=0.08, carry_rate=0.20, gp_tax_rate=-0.1) + + +def test_gp_clawback_refuses_empty_deals(): + with pytest.raises(ValueError, match="deals is empty"): + gp_clawback({}, preferred_rate=0.08, carry_rate=0.20) + + +def test_clawback_result_rejects_an_inconsistent_tax_flag(): + with pytest.raises(ValueError, match="tax_adjusted"): + ClawbackResult( + carry_received=10.0, + carry_entitled=5.0, + clawback_amount=5.0, + tax_adjusted=True, + gp_tax_rate=None, + ) + with pytest.raises(ValueError, match="tax_adjusted"): + ClawbackResult( + carry_received=10.0, + carry_entitled=5.0, + clawback_amount=5.0, + tax_adjusted=False, + gp_tax_rate=0.3, + ) diff --git a/agent/tests/quantlib/test_multipletesting.py b/agent/tests/quantlib/test_multipletesting.py new file mode 100644 index 00000000..fecdf65c --- /dev/null +++ b/agent/tests/quantlib/test_multipletesting.py @@ -0,0 +1,342 @@ +"""Tests for src.quantlib.multipletesting. + +The load-bearing tests here are the ones that show a correction actually bites: +a Sharpe that passes uncorrected and fails once the trial count is admitted, and +a pure-noise strategy set whose PBO comes out near or above one half. +""" + +import math + +import numpy as np +import pandas as pd +import pytest +from scipy.stats import norm + +from src.quantlib.multipletesting import ( + DEFAULT_CSCV_SPLITS, + EULER_MASCHERONI, + GAUSSIAN_KURTOSIS, + MIN_OBSERVATIONS, + benjamini_hochberg, + deflated_sharpe_ratio, + expected_maximum_sharpe, + probabilistic_sharpe_ratio, + probability_of_backtest_overfitting, + sharpe_ratio, +) + + +# --- sharpe helper --- + + +def test_sharpe_is_per_observation_not_annualised(): + rng = np.random.default_rng(0) + daily = rng.normal(0.0005, 0.01, 5000) + sr = sharpe_ratio(daily) + assert sr == pytest.approx(0.05, abs=0.02) + # An annualised reading would be ~sqrt(252) larger. It is not. + assert sr < 0.5 + + +def test_sharpe_ddof_is_exposed_because_the_repo_has_two_conventions(): + values = np.array([0.01, -0.005, 0.02, 0.0, 0.015]) + ratio = sharpe_ratio(values, ddof=1) / sharpe_ratio(values, ddof=0) + assert ratio == pytest.approx(math.sqrt((len(values) - 1) / len(values))) + + +def test_sharpe_is_nan_without_dispersion(): + assert math.isnan(sharpe_ratio(np.full(50, 0.01))) + + +def test_sharpe_needs_two_observations(): + with pytest.raises(ValueError, match="at least 2"): + sharpe_ratio([0.01]) + + +# --- probabilistic Sharpe --- + + +def test_psr_matches_the_closed_form_under_gaussian_moments(): + sr, n = 0.10, 500 + expected = norm.cdf(sr * math.sqrt(n - 1) / math.sqrt(1 + 0.5 * sr**2)) + assert probabilistic_sharpe_ratio(sr, n) == pytest.approx(expected, rel=1e-12) + + +def test_psr_rises_with_sample_length(): + a = probabilistic_sharpe_ratio(0.05, 100) + b = probabilistic_sharpe_ratio(0.05, 1000) + assert b > a + + +def test_negative_skew_and_fat_tails_both_reduce_confidence(): + base = probabilistic_sharpe_ratio(0.10, 500, skew=0.0, kurtosis=GAUSSIAN_KURTOSIS) + skewed = probabilistic_sharpe_ratio(0.10, 500, skew=-1.5, kurtosis=GAUSSIAN_KURTOSIS) + fat = probabilistic_sharpe_ratio(0.10, 500, skew=0.0, kurtosis=12.0) + + assert skewed < base + assert fat < base + + +def test_excess_kurtosis_passed_by_mistake_is_rejected_not_absorbed(): + # scipy.stats.kurtosis and pandas .kurt both return EXCESS kurtosis, which + # is 0 for a Gaussian. Silently accepting 0 would shrink the variance term + # and overstate confidence, so it must be an error. + with pytest.raises(ValueError, match="non-excess"): + probabilistic_sharpe_ratio(0.10, 500, kurtosis=0.0) + + +def test_psr_rejects_a_sample_too_short_for_its_own_moments(): + with pytest.raises(ValueError, match="at least"): + probabilistic_sharpe_ratio(0.10, MIN_OBSERVATIONS - 1) + + +def test_psr_rejects_mutually_inconsistent_moments(): + with pytest.raises(ValueError, match="not\\s+positive|mutually inconsistent"): + probabilistic_sharpe_ratio(2.0, 500, skew=5.0, kurtosis=1.0) + + +# --- expected maximum Sharpe --- + + +def test_expected_maximum_grows_with_the_number_of_trials(): + bars = [expected_maximum_sharpe(n, 0.1) for n in (2, 10, 100, 1000)] + assert bars == sorted(bars) + assert all(b > 0 for b in bars) + + +def test_expected_maximum_matches_the_closed_form(): + n, std = 462, 0.08 + expected = std * ( + (1 - EULER_MASCHERONI) * norm.ppf(1 - 1 / n) + + EULER_MASCHERONI * norm.ppf(1 - 1 / (n * math.e)) + ) + assert expected_maximum_sharpe(n, std) == pytest.approx(expected, rel=1e-12) + + +def test_expected_maximum_is_zero_without_a_search(): + assert expected_maximum_sharpe(1, 0.1) == 0.0 + assert expected_maximum_sharpe(500, 0.0) == 0.0 + + +def test_expected_maximum_scales_linearly_in_trial_dispersion(): + assert expected_maximum_sharpe(100, 0.2) == pytest.approx( + 2 * expected_maximum_sharpe(100, 0.1) + ) + + +def test_expected_maximum_rejects_impossible_inputs(): + with pytest.raises(ValueError, match="n_trials"): + expected_maximum_sharpe(0, 0.1) + with pytest.raises(ValueError, match="trial_sharpe_std"): + expected_maximum_sharpe(10, -0.1) + + +# --- deflated Sharpe: the correction has to actually bite --- + + +def test_a_sharpe_that_passes_uncorrected_fails_once_the_search_is_admitted(): + # This is the whole point of the module. Same observed Sharpe, same sample: + # one trial and it is convincing, 462 trials and it is not. + observed, n_obs, dispersion = 0.09, 1000, 0.06 + + undeflated = probabilistic_sharpe_ratio(observed, n_obs) + assert undeflated > 0.99 + + deflated = deflated_sharpe_ratio( + observed_sharpe=observed, + n_trials=462, + n_observations=n_obs, + trial_sharpe_std=dispersion, + ) + assert deflated.expected_maximum_sharpe > 0.15 + assert deflated.deflated_sharpe_ratio < 0.05 + assert not deflated.survives + + +def test_a_genuinely_strong_result_still_survives_the_correction(): + # Two-sided: the correction must not reject everything, or it is useless. + strong = deflated_sharpe_ratio( + observed_sharpe=0.30, + n_trials=462, + n_observations=2000, + trial_sharpe_std=0.06, + ) + assert strong.survives + assert strong.deflated_sharpe_ratio > 0.95 + + +def test_deflation_bar_is_exactly_the_expected_maximum(): + result = deflated_sharpe_ratio(0.2, n_trials=100, n_observations=500, trial_sharpe_std=0.05) + assert result.expected_maximum_sharpe == pytest.approx( + expected_maximum_sharpe(100, 0.05) + ) + assert result.deflated_sharpe_ratio == pytest.approx( + probabilistic_sharpe_ratio(0.2, 500, benchmark_sharpe=result.expected_maximum_sharpe) + ) + + +def test_more_trials_monotonically_lowers_the_deflated_ratio(): + values = [ + deflated_sharpe_ratio(0.15, n, 1000, 0.05).deflated_sharpe_ratio + for n in (1, 10, 100, 1000) + ] + assert values == sorted(values, reverse=True) + + +def test_deflated_result_carries_the_inputs_it_was_judged_on(): + result = deflated_sharpe_ratio(0.15, 50, 800, 0.04, skew=-0.4, kurtosis=6.0) + assert result.n_trials == 50 + assert result.n_observations == 800 + assert result.skew == -0.4 + assert result.kurtosis == 6.0 + assert result.confidence == 0.95 + + +def test_deflated_rejects_a_bad_confidence(): + with pytest.raises(ValueError, match="confidence"): + deflated_sharpe_ratio(0.1, 10, 500, 0.05, confidence=1.5) + + +# --- Benjamini-Hochberg --- + + +def test_bh_rejects_nothing_when_every_null_is_true(): + rng = np.random.default_rng(1) + p_values = rng.uniform(0, 1, 1000) # uniform under the null + result = benjamini_hochberg(p_values, fdr=0.05) + assert result.n_rejected <= 50 # expected false discoveries bounded by fdr + + +def test_bh_finds_the_planted_signals(): + rng = np.random.default_rng(2) + p_values = np.r_[rng.uniform(0, 1e-6, 20), rng.uniform(0, 1, 480)] + result = benjamini_hochberg(p_values, fdr=0.05) + assert result.n_rejected >= 20 + assert result.rejected[:20].all() + + +def test_bh_is_less_strict_than_bonferroni(): + # Fifty p-values just under 0.001 with n=1000: Bonferroni (0.05/1000 = + # 5e-5) rejects none of them, BH rejects them all. + p_values = np.r_[np.full(50, 9e-4), np.full(950, 0.5)] + result = benjamini_hochberg(p_values, fdr=0.05) + assert (p_values[:50] > 0.05 / 1000).all() + assert result.n_rejected == 50 + + +def test_bh_adjusted_p_values_are_monotone_in_the_raw_ones(): + rng = np.random.default_rng(3) + p_values = rng.uniform(0, 1, 200) + result = benjamini_hochberg(p_values) + order = np.argsort(p_values) + adjusted_sorted = result.adjusted_p_values[order] + assert np.all(np.diff(adjusted_sorted) >= -1e-15) + assert (result.adjusted_p_values <= 1.0).all() + assert (result.adjusted_p_values >= 0.0).all() + + +def test_bh_results_come_back_in_the_callers_order(): + p_values = np.array([0.9, 1e-8, 0.4, 2e-8]) + result = benjamini_hochberg(p_values, fdr=0.05) + assert result.rejected.tolist() == [False, True, False, True] + + +def test_bh_rejection_set_is_exactly_the_step_up_rule(): + p_values = np.array([0.001, 0.008, 0.039, 0.041, 0.042, 0.6]) + n, fdr = len(p_values), 0.05 + ranks = np.arange(1, n + 1) + expected_cutoff = int(ranks[np.sort(p_values) <= ranks / n * fdr].max()) + result = benjamini_hochberg(p_values, fdr=fdr) + assert result.n_rejected == expected_cutoff + + +@pytest.mark.parametrize("bad", [[], [0.5, 1.5], [0.5, -0.1], [0.5, np.nan]]) +def test_bh_rejects_impossible_p_values(bad): + with pytest.raises(ValueError): + benjamini_hochberg(bad) + + +def test_bh_rejects_a_bad_fdr(): + with pytest.raises(ValueError, match="fdr"): + benjamini_hochberg([0.01, 0.02], fdr=0.0) + + +# --- CSCV / PBO --- + + +def test_pure_noise_strategies_give_a_pbo_around_one_half(): + # No strategy has any edge, so in-sample selection carries no information + # and the winner lands either side of the OOS median about equally often. + rng = np.random.default_rng(4) + noise = pd.DataFrame(rng.normal(0.0, 0.01, (960, 12))) + result = probability_of_backtest_overfitting(noise, n_splits=8) + + assert 0.3 < result.pbo < 0.75 + assert result.n_strategies == 12 + + +def test_a_genuinely_superior_strategy_drives_pbo_down(): + # One strategy really is better in every period, so the in-sample winner is + # the same strategy out-of-sample and PBO collapses. + rng = np.random.default_rng(5) + data = rng.normal(0.0, 0.01, (960, 12)) + data[:, 3] += 0.004 # a large, persistent edge + result = probability_of_backtest_overfitting(pd.DataFrame(data), n_splits=8) + + assert result.pbo < 0.05 + + +def test_performance_degradation_is_negative_when_selection_fits_noise(): + rng = np.random.default_rng(6) + noise = pd.DataFrame(rng.normal(0.0, 0.01, (960, 15))) + result = probability_of_backtest_overfitting(noise, n_splits=8) + assert result.performance_degradation < 0 + + +def test_cscv_reports_rows_it_trimmed_rather_than_dropping_them_silently(): + rng = np.random.default_rng(7) + # 1000 rows over 8 subsets leaves a remainder of 0; 1005 leaves 5. + data = pd.DataFrame(rng.normal(0, 0.01, (1005, 5))) + result = probability_of_backtest_overfitting(data, n_splits=8) + assert result.dropped_observations == 1005 - (1005 // 8) * 8 + assert result.n_observations == (1005 // 8) * 8 + + +def test_cscv_split_count_is_the_full_combination_set(): + rng = np.random.default_rng(8) + data = pd.DataFrame(rng.normal(0, 0.01, (480, 4))) + result = probability_of_backtest_overfitting(data, n_splits=6) + assert result.n_splits == math.comb(6, 3) + + +def test_cscv_default_split_count_is_even_and_usable(): + assert DEFAULT_CSCV_SPLITS % 2 == 0 + assert DEFAULT_CSCV_SPLITS >= 4 + + +def test_cscv_rejects_an_odd_split_count(): + rng = np.random.default_rng(9) + with pytest.raises(ValueError, match="even"): + probability_of_backtest_overfitting(pd.DataFrame(rng.normal(0, 1, (200, 3))), n_splits=7) + + +def test_cscv_needs_competing_strategies(): + rng = np.random.default_rng(10) + with pytest.raises(ValueError, match="at least 2"): + probability_of_backtest_overfitting(pd.DataFrame(rng.normal(0, 1, (200, 1)))) + + +def test_cscv_rejects_a_sample_too_short_for_its_subsets(): + rng = np.random.default_rng(11) + with pytest.raises(ValueError, match="at least 2"): + probability_of_backtest_overfitting( + pd.DataFrame(rng.normal(0, 1, (10, 3))), n_splits=8 + ) + + +def test_cscv_logits_and_pbo_agree(): + rng = np.random.default_rng(12) + data = pd.DataFrame(rng.normal(0, 0.01, (480, 6))) + result = probability_of_backtest_overfitting(data, n_splits=6) + assert result.pbo == pytest.approx((result.logits <= 0).mean()) + assert result.logits.size == result.n_splits diff --git a/agent/tests/quantlib/test_options.py b/agent/tests/quantlib/test_options.py index 2b030ef6..af6a2229 100644 --- a/agent/tests/quantlib/test_options.py +++ b/agent/tests/quantlib/test_options.py @@ -18,7 +18,12 @@ import math import pytest -from src.quantlib.options import bs_greeks, bs_price, implied_volatility +from src.quantlib.options import ( + bs_greeks, + bs_price, + implied_volatility, + normalise_option_type, +) # Hull, Example 15.6: a six-month European option on a non-dividend stock. _HULL_15_6 = {"S": 42.0, "K": 40.0, "T": 0.5, "r": 0.10, "sigma": 0.20} @@ -332,7 +337,10 @@ class TestEdgeCases: expected = bs_price(100.0, 100.0, 1.0, 0.05, 0.2, alias.strip().lower()) assert bs_price(100.0, 100.0, 1.0, 0.05, 0.2, alias) == expected - @pytest.mark.parametrize("bad", ["c", "straddle", ""]) + # "c" used to appear here as an unknown type. It is now an accepted alias + # (see _CALL_ALIASES), so the cases below are genuine typos and non-types -- + # the values for which guessing is unsafe. + @pytest.mark.parametrize("bad", ["cal", "straddle", "", "1", "long"]) def test_unknown_option_type_raises(self, bad: str) -> None: with pytest.raises(ValueError, match="option_type"): bs_price(100.0, 100.0, 1.0, 0.05, 0.2, bad) @@ -387,5 +395,64 @@ class TestEngineFoldsOptionTypeTheSameWay: def test_unknown_type_is_rejected_at_construction(self) -> None: from backtest.engines.options_portfolio import OptionPosition + # A genuine typo, not "c" -- that is an accepted alias now. with pytest.raises(ValueError, match="option_type"): - OptionPosition("c", 100.0, "2026-01-01", 1, 5.0, "2025-10-01", "X") + OptionPosition("cal", 100.0, "2026-01-01", 1, 5.0, "2025-10-01", "X") + + def test_an_aliased_type_is_accepted_at_construction(self) -> None: + from backtest.engines.options_portfolio import OptionPosition + + # The engine folds through the same rule, so a config typed "C" builds + # a position that prices AND settles as a call. + pos = OptionPosition("C", 100.0, "2026-01-01", 1, 5.0, "2025-10-01", "X") + assert pos.option_type == "call" + + +# --- option-type aliases: compatibility without reintroducing the defaulting bug --- + + +@pytest.mark.parametrize( + "raw", ["call", "Call", "CALL", " call ", "calls", "C", "c", "看涨", "认购"] +) +def test_unambiguous_call_spellings_are_accepted(raw): + assert normalise_option_type(raw) == "call" + + +@pytest.mark.parametrize( + "raw", ["put", "Put", "PUT", " put ", "puts", "P", "p", "看跌", "认沽"] +) +def test_unambiguous_put_spellings_are_accepted(raw): + assert normalise_option_type(raw) == "put" + + +@pytest.mark.parametrize("raw", ["cal", "kall", "pu", "", " ", "option", "1", "long"]) +def test_a_typo_still_raises_rather_than_being_guessed_at(raw): + # This is the half of the contract that must NOT be relaxed. Defaulting an + # unrecognised type to put is what settled ten in-the-money call contracts + # at zero. + with pytest.raises(ValueError, match="unrecognised option_type"): + normalise_option_type(raw) + + +def test_the_error_message_lists_what_is_accepted(): + with pytest.raises(ValueError) as excinfo: + normalise_option_type("cal") + message = str(excinfo.value) + assert "calls" in message and "puts" in message + + +def test_aliases_price_identically_to_the_canonical_spelling(): + canonical = bs_price(100, 100, 1.0, 0.05, 0.2, "call") + for alias in ("C", "CALLS", " 认购 "): + assert bs_price(100, 100, 1.0, 0.05, 0.2, alias) == pytest.approx(canonical) + + +def test_an_aliased_leg_prices_and_settles_as_the_same_side(): + # The original defect in one assertion: whatever the leg was typed as, the + # side used to price it must be the side used to settle it. + for alias, expected in (("C", "call"), ("认沽", "put"), ("Puts", "put")): + folded = normalise_option_type(alias) + assert folded == expected + deep_itm = bs_price(200, 100, 0.01, 0.0, 0.2, alias) if folded == "call" else \ + bs_price(50, 100, 0.01, 0.0, 0.2, alias) + assert deep_itm > 45.0 diff --git a/agent/tests/quantlib/test_timeseries.py b/agent/tests/quantlib/test_timeseries.py index 3e31a5c9..88c6c94f 100644 --- a/agent/tests/quantlib/test_timeseries.py +++ b/agent/tests/quantlib/test_timeseries.py @@ -21,6 +21,7 @@ from src.quantlib.timeseries import ( compute_half_life, find_hedge_ratio, fit_garch, + fit_markov_regime, granger_test, heteroscedasticity_test, vif_test, @@ -659,6 +660,129 @@ def test_importing_the_module_does_not_require_the_optional_backends(): module = importlib.import_module("src.quantlib.timeseries") - assert len(module.__all__) == 11 + assert len(module.__all__) == 12 assert "statsmodels" not in module.__dict__ assert "arch" not in module.__dict__ + + +# --- Markov regime switching --- + + +def _two_regime_series(seed=3, calm_len=200, turbulent_len=50, cycles=3, + calm_vol=0.005, turbulent_vol=0.03, start_calm=True): + """Build a series alternating between a calm and a turbulent regime.""" + rng = np.random.default_rng(seed) + segments = [] + truth = [] + for cycle in range(cycles * 2): + calm = (cycle % 2 == 0) == start_calm + length = calm_len if calm else turbulent_len + vol = calm_vol if calm else turbulent_vol + segments.append(rng.normal(0.0, vol, length)) + truth += [0 if calm else 1] * length + return pd.Series(np.concatenate(segments)), np.array(truth) + + +def test_markov_regime_recovers_known_volatilities(): + series, _ = _two_regime_series(seed=3) + result = fit_markov_regime(series) + + assert result["converged"] + assert result["regime_vols"][0] == pytest.approx(0.005, rel=0.15) + assert result["regime_vols"][1] == pytest.approx(0.030, rel=0.15) + + +def test_markov_regimes_are_ordered_calmest_first_regardless_of_fit_order(): + # Label switching is the classic reproducibility trap: the EM fit returns + # states in an arbitrary order. Starting the series in the turbulent regime + # instead of the calm one must not change which index means "calm". + calm_first, _ = _two_regime_series(seed=11, start_calm=True) + turbulent_first, _ = _two_regime_series(seed=11, start_calm=False) + + a = fit_markov_regime(calm_first) + b = fit_markov_regime(turbulent_first) + + for result in (a, b): + vols = result["regime_vols"] + assert np.all(np.diff(vols) >= 0), f"regimes not ascending in vol: {vols}" + assert vols[0] < vols[1] + + +def test_markov_transition_matrix_is_row_stochastic(): + series, _ = _two_regime_series(seed=5) + transition = fit_markov_regime(series)["transition_matrix"] + + assert transition.shape == (2, 2) + assert transition.sum(axis=1) == pytest.approx(np.ones(2)) + assert np.all(transition >= 0.0) + + +def test_markov_transition_rows_read_from_today_to_tomorrow(): + # The calm regime here lasts 200 periods and the turbulent one 50, so the + # calm row's diagonal must be the larger of the two. Reading the matrix + # transposed would swap these and silently mislabel which state is sticky. + series, _ = _two_regime_series(seed=7, calm_len=200, turbulent_len=50) + result = fit_markov_regime(series) + transition = result["transition_matrix"] + + assert transition[0][0] > transition[1][1] + assert result["expected_durations"][0] > result["expected_durations"][1] + + +def test_markov_expected_durations_track_the_true_segment_lengths(): + series, _ = _two_regime_series(seed=13, calm_len=180, turbulent_len=60) + durations = fit_markov_regime(series)["expected_durations"] + + assert durations[0] == pytest.approx(180, rel=0.5) + assert durations[1] == pytest.approx(60, rel=0.5) + + +def test_markov_identifies_the_regime_the_series_ends_in(): + # Ends in a turbulent segment. + series, truth = _two_regime_series(seed=17, start_calm=True) + result = fit_markov_regime(series) + + assert result["current_regime"] == int(truth[-1]) + assert result["current_regime_probability"] > 0.8 + + +def test_markov_smoothed_probabilities_are_a_distribution_on_the_input_index(): + series, _ = _two_regime_series(seed=19) + result = fit_markov_regime(series) + smoothed = result["smoothed_probabilities"] + + assert list(smoothed.index) == list(series.index) + assert list(smoothed.columns) == ["regime_0", "regime_1"] + assert smoothed.sum(axis=1).to_numpy() == pytest.approx(np.ones(len(series)), abs=1e-8) + + +def test_markov_smoothed_probabilities_track_the_true_state(): + series, truth = _two_regime_series(seed=23) + smoothed = fit_markov_regime(series)["smoothed_probabilities"] + inferred = smoothed.to_numpy().argmax(axis=1) + + # Not every boundary period is classifiable, but the bulk must be. + assert (inferred == truth).mean() > 0.9 + + +def test_markov_rejects_a_single_regime(): + series, _ = _two_regime_series(seed=29) + with pytest.raises(ValueError, match="at least 2"): + fit_markov_regime(series, n_regimes=1) + + +def test_markov_rejects_a_series_too_short_to_mean_anything(): + with pytest.raises(ValueError, match="at least 50"): + fit_markov_regime(pd.Series(np.random.default_rng(31).normal(0, 0.01, 40))) + + +def test_markov_raises_an_actionable_error_when_statsmodels_is_absent(): + try: + import statsmodels # noqa: F401 + except ImportError: + pass + else: + pytest.skip("'statsmodels' is installed, so the missing-dependency path cannot be exercised") + + with pytest.raises(ImportError, match="pip install"): + fit_markov_regime(pd.Series(np.random.default_rng(37).normal(0, 0.01, 100))) diff --git a/agent/tests/quantlib/test_var_backtest.py b/agent/tests/quantlib/test_var_backtest.py new file mode 100644 index 00000000..4d101dc1 --- /dev/null +++ b/agent/tests/quantlib/test_var_backtest.py @@ -0,0 +1,332 @@ +"""Tests for src.quantlib.var_backtest. + +Every guard here is two-sided: a well-calibrated model must NOT be rejected and +a miscalibrated one MUST be. A one-sided guard can always be satisfied by making +the test weaker, which is exactly the failure mode these tests exist to prevent. +""" + +import numpy as np +import pandas as pd +import pytest +from scipy.stats import chi2 + +from src.quantlib.var_backtest import ( + BASEL_SCALING_FACTORS, + basel_traffic_light, + christoffersen_conditional_coverage, + christoffersen_independence, + kupiec_pof, + var_backtest, + violation_indicator, +) + + +# --- breach definition and sign convention --- + + +def test_breach_is_loss_strictly_exceeding_var(): + returns = np.array([-0.031, -0.028, -0.019, 0.05]) + breaches = violation_indicator(returns, 0.028) + # -0.031 breaches; -0.028 exactly equals the promised level and does not. + assert breaches.tolist() == [True, False, False, False] + + +def test_var_is_a_positive_loss_magnitude_not_a_signed_return(): + # A caller who passes a negative VaR gets an implausible breach count, not a + # silently corrected input. + returns = np.array([-0.01, -0.02, -0.03]) + assert violation_indicator(returns, 0.05).sum() == 0 + assert violation_indicator(returns, -0.05).sum() == 3 + + +def test_scalar_var_broadcasts_over_returns(): + returns = np.array([-0.05, 0.01, -0.06]) + assert violation_indicator(returns, 0.04).tolist() == [True, False, True] + + +# --- alignment --- + + +def test_mismatched_series_labels_are_an_error_not_a_partial_join(): + idx_a = pd.date_range("2024-01-01", periods=5, freq="D") + idx_b = pd.date_range("2024-01-02", periods=5, freq="D") + returns = pd.Series(np.full(5, -0.01), index=idx_a) + var = pd.Series(np.full(5, 0.02), index=idx_b) + with pytest.raises(ValueError, match="exactly the same labels"): + violation_indicator(returns, var) + + +def test_length_mismatch_rejected(): + with pytest.raises(ValueError, match="same length"): + violation_indicator(np.zeros(5), np.zeros(4)) + + +def test_non_finite_pairs_are_dropped_and_counted(): + returns = pd.Series([-0.05, np.nan, -0.06, 0.01]) + var = pd.Series([0.02, 0.02, np.nan, 0.02]) + report = var_backtest(returns, var, confidence=0.95) + assert report.observations == 2 + assert report.dropped_observations == 2 + + +def test_all_non_finite_is_an_error(): + with pytest.raises(ValueError, match="no observation"): + violation_indicator([np.nan, np.nan], 0.02) + + +# --- Kupiec unconditional coverage --- + + +def test_kupiec_perfect_calibration_gives_zero_statistic(): + result = kupiec_pof(violations=10, observations=1000, confidence=0.99) + assert result.statistic == pytest.approx(0.0, abs=1e-12) + assert result.p_value == pytest.approx(1.0) + assert not result.rejected + + +def test_kupiec_rejects_a_model_breached_far_too_often(): + # 50 breaches where 10 were promised. + result = kupiec_pof(violations=50, observations=1000, confidence=0.99) + assert result.rejected + assert result.violation_rate == pytest.approx(0.05) + assert result.expected_violations == pytest.approx(10.0) + + +def test_kupiec_rejects_a_model_that_never_breaches_when_it_should(): + # Zero breaches over 1000 days at 99% is a far too conservative model, and + # over-conservatism is a real finding: it means capital is over-held. + result = kupiec_pof(violations=0, observations=1000, confidence=0.99) + assert result.rejected + assert np.isfinite(result.statistic) + + +def test_kupiec_matches_the_closed_form_likelihood_ratio(): + n, x, p = 250, 7, 0.01 + pi = x / n + expected = -2.0 * ( + (n - x) * np.log(1 - p) + + x * np.log(p) + - (n - x) * np.log(1 - pi) + - x * np.log(pi) + ) + result = kupiec_pof(violations=x, observations=n, confidence=1 - p) + assert result.statistic == pytest.approx(expected, rel=1e-12) + assert result.p_value == pytest.approx(chi2.sf(expected, df=1), rel=1e-12) + + +def test_kupiec_zero_and_full_breach_counts_are_finite(): + assert np.isfinite(kupiec_pof(0, 100, 0.99).statistic) + assert np.isfinite(kupiec_pof(100, 100, 0.99).statistic) + + +@pytest.mark.parametrize( + "violations, observations, confidence", + [(-1, 100, 0.99), (101, 100, 0.99), (5, 0, 0.99), (5, 100, 1.0), (5, 100, 0.0)], +) +def test_kupiec_rejects_impossible_inputs(violations, observations, confidence): + with pytest.raises(ValueError): + kupiec_pof(violations, observations, confidence) + + +# --- Christoffersen independence --- + + +def test_independence_not_rejected_when_breaches_are_independent(): + # The true null is Bernoulli draws, not a tidy pattern. + rng = np.random.default_rng(31) + breaches = rng.random(2000) < 0.05 + result = christoffersen_independence(breaches) + assert result.identified + assert not result.rejected + + +def test_perfect_periodicity_is_dependence_and_is_rejected(): + # Every 10th day, exactly. The breach count is right and nothing clusters, + # but a breach still predicts tomorrow perfectly: it says "not tomorrow". + # Anti-clustering is dependence, and the test is supposed to catch it. + breaches = np.zeros(500, dtype=bool) + breaches[::10] = True + result = christoffersen_independence(breaches) + assert result.identified + assert result.rejected + assert result.prob_breach_after_breach == 0.0 + assert result.prob_breach_after_calm > 0.0 + + +def test_independence_rejected_when_breaches_cluster(): + # Same breach count as the scattered case, all consecutive. + breaches = np.zeros(500, dtype=bool) + breaches[100:150] = True + result = christoffersen_independence(breaches) + assert result.identified + assert result.rejected + assert result.prob_breach_after_breach > result.prob_breach_after_calm + + +def test_clustering_is_invisible_to_kupiec_which_is_why_both_tests_exist(): + rng = np.random.default_rng(41) + scattered = np.zeros(500, dtype=bool) + scattered[rng.choice(500, size=50, replace=False)] = True + clustered = np.zeros(500, dtype=bool) + clustered[100:150] = True + + # Identical counts, so Kupiec cannot tell them apart at all. + k_scattered = kupiec_pof(int(scattered.sum()), scattered.size, 0.90) + k_clustered = kupiec_pof(int(clustered.sum()), clustered.size, 0.90) + assert k_scattered.statistic == pytest.approx(k_clustered.statistic) + + # Independence separates them. + assert not christoffersen_independence(scattered).rejected + assert christoffersen_independence(clustered).rejected + + +def test_independence_is_unidentified_with_no_breaches(): + result = christoffersen_independence(np.zeros(100, dtype=bool)) + assert not result.identified + assert result.statistic == 0.0 + assert not result.rejected + + +def test_independence_is_unidentified_when_the_only_breach_is_last(): + breaches = np.zeros(50, dtype=bool) + breaches[-1] = True + result = christoffersen_independence(breaches) + assert not result.identified + assert result.statistic == 0.0 + + +def test_independence_transition_counts_are_exact(): + breaches = np.array([False, True, True, False, False], dtype=bool) + result = christoffersen_independence(breaches) + # transitions: F->T, T->T, T->F, F->F + assert result.transitions == (1, 1, 1, 1) + + +def test_independence_needs_two_observations(): + with pytest.raises(ValueError, match="at least 2"): + christoffersen_independence(np.array([True])) + + +# --- conditional coverage --- + + +def test_conditional_coverage_is_exactly_the_sum_of_its_components(): + rng = np.random.default_rng(20260806) + breaches = rng.random(600) < 0.03 + result = christoffersen_conditional_coverage(breaches, confidence=0.99) + assert result.statistic == pytest.approx( + result.kupiec.statistic + result.independence.statistic, rel=1e-12 + ) + assert result.p_value == pytest.approx(chi2.sf(result.statistic, df=2), rel=1e-12) + + +def test_conditional_coverage_passes_a_correctly_calibrated_model(): + rng = np.random.default_rng(7) + breaches = rng.random(5000) < 0.01 + result = christoffersen_conditional_coverage(breaches, confidence=0.99) + assert not result.rejected + + +def test_conditional_coverage_catches_a_model_that_only_fails_on_timing(): + # Correct 10% breach rate, but delivered in one block. + breaches = np.zeros(500, dtype=bool) + breaches[200:250] = True + result = christoffersen_conditional_coverage(breaches, confidence=0.90) + assert not result.kupiec.rejected # level is right + assert result.independence.rejected # timing is not + assert result.rejected # joint test catches it + + +# --- Basel traffic light --- + + +@pytest.mark.parametrize( + "violations, zone", + [(0, "green"), (4, "green"), (5, "yellow"), (9, "yellow"), (10, "red"), (25, "red")], +) +def test_basel_zones_reproduce_the_official_250_day_table(violations, zone): + result = basel_traffic_light(violations, observations=250, confidence=0.99) + assert result.zone == zone + + +@pytest.mark.parametrize("violations, factor", sorted(BASEL_SCALING_FACTORS.items())) +def test_basel_scaling_factors_match_the_1996_table(violations, factor): + result = basel_traffic_light(violations, observations=250, confidence=0.99) + assert result.scaling_factor == factor + + +def test_basel_red_zone_scaling_factor_is_flat_at_four(): + for violations in (10, 15, 30): + assert basel_traffic_light(violations, 250, 0.99).scaling_factor == 4.00 + + +def test_basel_scaling_factor_withheld_off_the_tabulated_basis(): + # The zone still means something at 60 days; the multiplier does not. + result = basel_traffic_light(violations=3, observations=60, confidence=0.99) + assert result.zone in {"green", "yellow", "red"} + assert result.scaling_factor is None + assert "250" in result.scaling_factor_basis + + wrong_confidence = basel_traffic_light(violations=3, observations=250, confidence=0.95) + assert wrong_confidence.scaling_factor is None + + +def test_basel_cumulative_probability_is_reported_so_the_cut_is_auditable(): + result = basel_traffic_light(violations=5, observations=250, confidence=0.99) + assert 0.95 <= result.cumulative_probability < 0.9999 + + +# --- end-to-end report --- + + +def test_var_backtest_end_to_end_on_a_calibrated_model(): + rng = np.random.default_rng(11) + n = 2000 + returns = pd.Series( + rng.normal(0.0, 0.01, n), index=pd.date_range("2018-01-01", periods=n, freq="B") + ) + # A correctly specified parametric VaR for this generating process. + var = 0.01 * 2.3263478740408408 # z_{0.99} + report = var_backtest(returns, var, confidence=0.99) + + assert report.observations == n + assert not report.conditional_coverage.rejected + assert report.traffic_light.zone == "green" + assert report.breach_dates is not None + assert len(report.breach_dates) == report.violations + + +def test_var_backtest_end_to_end_on_an_understated_model(): + rng = np.random.default_rng(12) + n = 1000 + returns = pd.Series(rng.normal(0.0, 0.02, n)) + # VaR built for half the true volatility: breaches far too often. + report = var_backtest(returns, 0.01 * 2.3263478740408408, confidence=0.99) + + assert report.kupiec.rejected + assert report.conditional_coverage.rejected + assert report.traffic_light.zone == "red" + + +def test_var_backtest_breach_dates_are_the_actual_breach_labels(): + idx = pd.date_range("2024-01-01", periods=5, freq="D") + returns = pd.Series([-0.10, 0.01, -0.20, 0.02, -0.005], index=idx) + report = var_backtest(returns, 0.05, confidence=0.95) + assert report.breach_dates == (idx[0], idx[2]) + + +def test_var_backtest_without_an_index_reports_no_dates(): + report = var_backtest(np.array([-0.10, 0.01, -0.20, 0.02]), 0.05, confidence=0.95) + assert report.breach_dates is None + + +def test_var_backtest_needs_two_observations(): + with pytest.raises(ValueError, match="at least 2"): + var_backtest(np.array([-0.05]), 0.02) + + +def test_time_varying_var_series_is_compared_day_by_day(): + returns = pd.Series([-0.03, -0.03, -0.03]) + var = pd.Series([0.02, 0.05, 0.02]) + report = var_backtest(returns, var, confidence=0.95) + assert report.violations == 2 diff --git a/agent/tests/quantlib/valuation/__init__.py b/agent/tests/quantlib/valuation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/agent/tests/quantlib/valuation/test_artifact.py b/agent/tests/quantlib/valuation/test_artifact.py new file mode 100644 index 00000000..87c18d67 --- /dev/null +++ b/agent/tests/quantlib/valuation/test_artifact.py @@ -0,0 +1,932 @@ +"""Tests for src.quantlib.valuation.artifact. + +Structured around the six things the artifact module promises: + +1. The input hash is stable under dict-key reordering and int/float/``-0.0`` + representation noise, and changes whenever any single input number changes + (:class:`TestInputHashStability`). +2. The module never reads a clock -- checked both by grepping its own source + (no ``datetime.now``/``time.time``) and by proving omission/naivety of + ``generated_at`` raises rather than silently defaulting + (:class:`TestNoHiddenClock`). +3. Every :class:`~src.quantlib.valuation.contracts.Assumption` in a result is + captured with its ``basis``, and models that take no ``Assumption`` + objects (comps, three-statement) honestly report an empty tuple rather + than fabricating one (:class:`TestAssumptionCapture`). +4. Two artifacts diff to the exact changed field and its numeric delta + (:class:`TestDiff`). +5. Every xlsx export round-trips through ``openpyxl`` back to the values on + the result object -- not just "the file exists" -- and the excluded/ + omitted/non-converged material is actually present in the workbook + (:class:`TestDCFWorkbookExport`, :class:`TestCompsWorkbookExport`, + :class:`TestThreeStatementWorkbookExport`). +6. The pptx summary carries the same input hash, assumptions and gap notes + (:class:`TestSummaryDeckExport`). +""" + +from __future__ import annotations + +import ast +import inspect +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import openpyxl +import pandas as pd +import pytest +from pptx import Presentation + +from src.quantlib.valuation import artifact as artifact_module +from src.quantlib.valuation.artifact import ( + AssumptionRecord, + ModelArtifact, + build_comps_artifact, + build_dcf_artifact, + build_three_statement_artifact, + compute_input_hash, + diff_artifacts, + export_comps_workbook, + export_dcf_workbook, + export_summary_deck, + export_three_statement_workbook, +) +from src.quantlib.valuation.comps import FlowMetricPeriods, PeerCompany, TargetCompany, run_comps +from src.quantlib.valuation.contracts import Assumption +from src.quantlib.valuation.dcf import run_dcf, sensitivity_grid +from src.quantlib.valuation.threestatement import project_three_statement + +UTC = timezone.utc +FIXED_TIME = datetime(2026, 1, 15, 12, 0, 0, tzinfo=UTC) + + +# --------------------------------------------------------------------------- +# Shared fixtures (self-contained -- not imported from the sibling test files) +# --------------------------------------------------------------------------- + + +def _dcf_inputs() -> dict: + """A small, valid ``run_dcf`` inputs mapping (target capital structure).""" + return { + "risk_free_rate": 0.04, + "beta": 1.2, + "equity_risk_premium": 0.05, + "size_premium": 0.01, + "country_risk_premium": 0.0, + "pretax_cost_of_debt": 0.06, + "tax_rate": 0.25, + "ebit": [100.0, 105.0, 110.0], + "depreciation_amortization": [20.0, 21.0, 22.0], + "capex": [30.0, 28.0, 26.0], + "delta_nwc": [5.0, 4.0, 3.0], + "terminal_growth": Assumption( + name="terminal_growth", + value=0.03, + basis="Long-run nominal GDP proxy for a mature-market perpetuity", + source="analyst view", + ), + "exit_multiple": Assumption( + name="exit_multiple", + value=8.0, + basis="Median EV/EBITDA of the comparable peer set", + ), + "total_debt": 200.0, + "cash_and_equivalents": 50.0, + "minority_interest": 10.0, + "preferred_equity": 15.0, + "associate_investments": 5.0, + "diluted_shares": 100.0, + "target_equity_weight": 0.7, + "target_debt_weight": 0.3, + } + + +def _run_dcf_default(inputs: dict | None = None): + return run_dcf( + inputs if inputs is not None else _dcf_inputs(), + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + + +def _build_dcf_artifact(inputs: dict | None = None, *, generated_at: datetime = FIXED_TIME): + inputs = inputs if inputs is not None else _dcf_inputs() + result = _run_dcf_default(inputs) + return build_dcf_artifact( + inputs=inputs, + result=result, + generated_at=generated_at, + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + + +def _flow(last_full: float, *, ytd: float, prior_ytd: float, month: int = 12) -> FlowMetricPeriods: + return FlowMetricPeriods( + fiscal_year_end_month=month, + last_full_fiscal_year=last_full, + current_year_to_date=ytd, + prior_year_to_date=prior_ytd, + ) + + +def _comps_peers(*, include_excluded_peer: bool = True) -> list[PeerCompany]: + peers = [ + PeerCompany( + name="PEER_A", + market_cap=1000.0, + total_debt=200.0, + cash_and_equivalents=100.0, + ebitda=_flow(150.0, ytd=80.0, prior_ytd=70.0), + ebit=_flow(100.0, ytd=55.0, prior_ytd=50.0), + revenue=_flow(900.0, ytd=480.0, prior_ytd=420.0), + diluted_eps=_flow(5.0, ytd=2.6, prior_ytd=2.2), + price_per_share=54.0, + book_value_of_equity=500.0, + eps_basis="gaap", + minority_interest=None, + preferred_stock=None, + investments_in_associates=None, + ), + PeerCompany( + name="PEER_B", + market_cap=1200.0, + total_debt=250.0, + cash_and_equivalents=120.0, + ebitda=_flow(160.0, ytd=85.0, prior_ytd=75.0), + ebit=_flow(110.0, ytd=58.0, prior_ytd=52.0), + revenue=_flow(950.0, ytd=500.0, prior_ytd=440.0), + diluted_eps=_flow(5.5, ytd=2.8, prior_ytd=2.4), + price_per_share=60.0, + book_value_of_equity=550.0, + eps_basis="gaap", + ), + ] + if include_excluded_peer: + # Negative LTM EBITDA -> excluded from ev_ebitda's distribution. + peers.append( + PeerCompany( + name="PEER_LOSSMAKER", + market_cap=300.0, + total_debt=400.0, + cash_and_equivalents=20.0, + ebitda=_flow(-50.0, ytd=-30.0, prior_ytd=-10.0), + ebit=_flow(-60.0, ytd=-35.0, prior_ytd=-15.0), + revenue=_flow(500.0, ytd=260.0, prior_ytd=240.0), + diluted_eps=_flow(-1.0, ytd=-0.5, prior_ytd=-0.2), + price_per_share=8.0, + book_value_of_equity=90.0, + eps_basis="gaap", + ) + ) + return peers + + +def _comps_target(*, omit_components: bool = True) -> TargetCompany: + kwargs = dict( + name="TARGET", + total_debt=150.0, + cash_and_equivalents=60.0, + ebitda=_flow(140.0, ytd=75.0, prior_ytd=65.0), + ebit=_flow(95.0, ytd=52.0, prior_ytd=45.0), + revenue=_flow(850.0, ytd=460.0, prior_ytd=400.0), + diluted_eps=_flow(4.5, ytd=2.4, prior_ytd=2.0), + diluted_shares_outstanding=100.0, + book_value_of_equity=420.0, + eps_basis="gaap", + ) + if not omit_components: + kwargs.update(minority_interest=0.0, preferred_stock=0.0, investments_in_associates=0.0) + return TargetCompany(**kwargs) + + +def _build_comps_artifact(*, generated_at: datetime = FIXED_TIME): + target = _comps_target() + peers = _comps_peers() + result = run_comps(target, peers, calendarisation_policy="ltm") + art = build_comps_artifact( + target=target, + peers=peers, + calendarisation_policy="ltm", + result=result, + generated_at=generated_at, + ) + return target, peers, result, art + + +def _three_statement_opening() -> dict: + return { + "revenue": 1_000_000.0, + "cash": 200_000.0, + "net_working_capital": 150_000.0, + "ppe": 800_000.0, + "revolver_balance": 300_000.0, + "paid_in_capital": 500_000.0, + "retained_earnings": 350_000.0, + } + + +def _three_statement_drivers() -> dict: + # minimum_cash deliberately high in period 1 to force a revolver draw -- + # exercises the "declared plug" path the artifact must surface. + return { + "revenue_growth": [0.10, 0.08], + "gross_margin": [0.40, 0.42], + "opex_pct_revenue": [0.20, 0.19], + "capex_pct_revenue": [0.05, 0.05], + "nwc_pct_revenue": [0.15, 0.15], + "tax_rate": [0.25, 0.25], + "dividend_payout_ratio": [0.30, 0.30], + "depreciation_amortization": [60_000.0, 65_000.0], + "interest_rate": [0.06, 0.06], + "minimum_cash": [400_000.0, 100_000.0], + } + + +def _build_three_statement_artifact(*, generated_at: datetime = FIXED_TIME): + opening = _three_statement_opening() + drivers = _three_statement_drivers() + result = project_three_statement(opening, drivers) + art = build_three_statement_artifact( + opening=opening, drivers=drivers, result=result, generated_at=generated_at + ) + return opening, drivers, result, art + + +# --------------------------------------------------------------------------- +# 1. Input hash stability (both sides required by the task) +# --------------------------------------------------------------------------- + + +class TestInputHashStability: + def test_same_payload_hashes_identically_twice(self): + payload = {"a": 1.0, "b": {"c": 2.0, "d": [1, 2, 3]}} + assert compute_input_hash(payload) == compute_input_hash(payload) + + def test_hash_independent_of_dict_key_order(self): + forward = {"total_debt": 200.0, "cash": 50.0, "tax_rate": 0.25} + reversed_ = {"tax_rate": 0.25, "cash": 50.0, "total_debt": 200.0} + assert compute_input_hash(forward) == compute_input_hash(reversed_) + + def test_hash_independent_of_nested_dict_key_order(self): + forward = {"outer": {"x": 1.0, "y": 2.0}, "z": 3.0} + reordered = {"z": 3.0, "outer": {"y": 2.0, "x": 1.0}} + assert compute_input_hash(forward) == compute_input_hash(reordered) + + def test_hash_treats_int_and_float_as_the_same_value(self): + assert compute_input_hash({"shares": 100}) == compute_input_hash({"shares": 100.0}) + + def test_hash_collapses_negative_zero(self): + assert compute_input_hash({"x": -0.0}) == compute_input_hash({"x": 0.0}) + + @pytest.mark.parametrize( + ("field", "changed_value"), + [ + ("total_debt", 201.0), + ("cash_and_equivalents", 51.0), + ("tax_rate", 0.26), + ("risk_free_rate", 0.041), + ("diluted_shares", 101.0), + ], + ) + def test_hash_changes_when_a_single_scalar_input_changes(self, field, changed_value): + baseline = _dcf_inputs() + changed = _dcf_inputs() + changed[field] = changed_value + assert compute_input_hash(baseline) != compute_input_hash(changed) + + def test_hash_changes_when_a_sequence_entry_changes(self): + baseline = _dcf_inputs() + changed = _dcf_inputs() + changed["ebit"] = [100.0, 105.0, 111.0] # last year bumped by 1.0 + assert compute_input_hash(baseline) != compute_input_hash(changed) + + def test_hash_changes_when_an_assumption_value_changes(self): + baseline = _dcf_inputs() + changed = _dcf_inputs() + changed["terminal_growth"] = Assumption( + name="terminal_growth", value=0.031, basis=baseline["terminal_growth"].basis + ) + assert compute_input_hash(baseline) != compute_input_hash(changed) + + def test_hash_changes_when_only_an_assumption_basis_changes(self): + baseline = _dcf_inputs() + changed = _dcf_inputs() + changed["terminal_growth"] = Assumption( + name="terminal_growth", value=0.03, basis="A completely different justification" + ) + assert compute_input_hash(baseline) != compute_input_hash(changed) + + def test_dcf_artifact_input_hash_independent_of_generated_at(self): + """Same run, two different timestamps -> identical input_hash.""" + inputs = _dcf_inputs() + result = _run_dcf_default(inputs) + art_1 = build_dcf_artifact( + inputs=inputs, result=result, generated_at=FIXED_TIME, + capital_structure_basis="target", discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + art_2 = build_dcf_artifact( + inputs=inputs, result=result, generated_at=FIXED_TIME + timedelta(days=30), + capital_structure_basis="target", discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + assert art_1.input_hash == art_2.input_hash + assert art_1.generated_at != art_2.generated_at + assert art_1.model_version == art_2.model_version + + def test_dcf_artifact_input_hash_changes_when_a_config_knob_changes(self): + """discounting_convention is not in `inputs` but materially changes the model.""" + inputs = _dcf_inputs() + result_mid_year = run_dcf( + inputs, capital_structure_basis="target", discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + result_year_end = run_dcf( + inputs, capital_structure_basis="target", discounting_convention="year_end", + terminal_value_method="perpetuity_growth", + ) + art_mid = build_dcf_artifact( + inputs=inputs, result=result_mid_year, generated_at=FIXED_TIME, + capital_structure_basis="target", discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + art_year_end = build_dcf_artifact( + inputs=inputs, result=result_year_end, generated_at=FIXED_TIME, + capital_structure_basis="target", discounting_convention="year_end", + terminal_value_method="perpetuity_growth", + ) + assert art_mid.input_hash != art_year_end.input_hash + + def test_comps_and_three_statement_hashes_also_move_on_one_number(self): + target = _comps_target() + peers = _comps_peers() + result = run_comps(target, peers, calendarisation_policy="ltm") + baseline = build_comps_artifact( + target=target, peers=peers, calendarisation_policy="ltm", + result=result, generated_at=FIXED_TIME, + ) + bumped_target = _comps_target() + bumped_peers = _comps_peers() + bumped_target = TargetCompany( + **{**bumped_target.__dict__, "total_debt": bumped_target.total_debt + 1.0} + ) + bumped_result = run_comps(bumped_target, bumped_peers, calendarisation_policy="ltm") + updated = build_comps_artifact( + target=bumped_target, peers=bumped_peers, calendarisation_policy="ltm", + result=bumped_result, generated_at=FIXED_TIME, + ) + assert baseline.input_hash != updated.input_hash + + +# --------------------------------------------------------------------------- +# 2. No hidden clock +# --------------------------------------------------------------------------- + + +class TestNoHiddenClock: + def test_source_never_calls_datetime_now_or_time_time(self): + """AST-based, not substring-based: the module docstring legitimately + + *mentions* ``datetime.now()`` in prose while explaining why it is + never called, so a naive substring grep would false-positive on the + documentation itself. This walks the actual parsed call sites. + """ + tree = ast.parse(inspect.getsource(artifact_module)) + offending_calls = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + attr = node.func.attr + base = node.func.value + base_name = base.id if isinstance(base, ast.Name) else None + if (base_name == "datetime" and attr == "now") or (base_name == "time" and attr == "time"): + offending_calls.append(f"{base_name}.{attr}() at line {node.lineno}") + assert offending_calls == [] + + def test_missing_generated_at_is_a_typeerror_from_python_argument_binding(self): + inputs = _dcf_inputs() + result = _run_dcf_default(inputs) + with pytest.raises(TypeError): + build_dcf_artifact( # type: ignore[call-arg] + inputs=inputs, result=result, + capital_structure_basis="target", discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + + def test_non_datetime_generated_at_raises_typeerror(self): + inputs = _dcf_inputs() + result = _run_dcf_default(inputs) + with pytest.raises(TypeError): + build_dcf_artifact( + inputs=inputs, result=result, generated_at="2026-01-15", # type: ignore[arg-type] + capital_structure_basis="target", discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + + def test_naive_generated_at_raises_valueerror(self): + inputs = _dcf_inputs() + result = _run_dcf_default(inputs) + with pytest.raises(ValueError): + build_dcf_artifact( + inputs=inputs, result=result, generated_at=datetime(2026, 1, 15), + capital_structure_basis="target", discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + + def test_tz_aware_generated_at_is_accepted_and_preserved(self): + art = _build_dcf_artifact(generated_at=FIXED_TIME) + assert art.generated_at == FIXED_TIME + + +# --------------------------------------------------------------------------- +# 3. Assumption capture +# --------------------------------------------------------------------------- + + +class TestAssumptionCapture: + def test_dcf_artifact_captures_both_assumptions_with_basis(self): + art = _build_dcf_artifact() + assert len(art.assumptions) == 2 + names = {r.assumption.name for r in art.assumptions} + assert names == {"terminal_growth", "exit_multiple"} + for record in art.assumptions: + assert isinstance(record, AssumptionRecord) + assert record.assumption.basis.strip() != "" + assert record.path.startswith("$.terminal_value.") + + def test_dcf_artifact_assumption_values_match_the_result(self): + art = _build_dcf_artifact() + by_name = {r.assumption.name: r.assumption for r in art.assumptions} + assert by_name["terminal_growth"].value == pytest.approx(0.03) + assert by_name["exit_multiple"].value == pytest.approx(8.0) + assert by_name["terminal_growth"].source == "analyst view" + assert by_name["exit_multiple"].source is None + + def test_comps_artifact_has_no_assumptions(self): + """comps.py takes no Assumption objects -- this must be an honest empty tuple.""" + _, _, _, art = _build_comps_artifact() + assert art.assumptions == () + + def test_three_statement_artifact_has_no_assumptions(self): + """threestatement.py takes no Assumption objects either.""" + _, _, _, art = _build_three_statement_artifact() + assert art.assumptions == () + + +# --------------------------------------------------------------------------- +# 3b. Exclusions / omissions surfaced on the artifact itself +# --------------------------------------------------------------------------- + + +class TestGapExtraction: + def test_comps_artifact_records_excluded_peer_and_omitted_components(self): + _, _, result, art = _build_comps_artifact() + joined = " | ".join(art.excluded) + assert "PEER_LOSSMAKER" in joined + assert "ev_ebitda" in joined + assert "PEER_A" in joined # PEER_A omits all three optional EV-bridge items + assert "minority_interest" in joined + assert "target" in joined.lower() or "TARGET" in joined + + def test_three_statement_artifact_records_revolver_draw(self): + _, _, result, art = _build_three_statement_artifact() + joined = " | ".join(art.excluded) + assert "revolver drew" in joined + + def test_dcf_artifact_always_notes_the_non_chosen_terminal_method(self): + art = _build_dcf_artifact() + joined = " | ".join(art.excluded) + assert "exit_multiple" in joined + assert "terminal_value_method" in joined + + +# --------------------------------------------------------------------------- +# 4. Diff +# --------------------------------------------------------------------------- + + +class TestDiff: + def test_diff_across_model_names_raises(self): + dcf_art = _build_dcf_artifact() + _, _, _, comps_art = _build_comps_artifact() + with pytest.raises(ValueError): + diff_artifacts(dcf_art, comps_art) + + def test_diff_precisely_reports_a_single_changed_input_and_its_delta(self): + baseline_inputs = _dcf_inputs() + baseline_art = _build_dcf_artifact(baseline_inputs) + + updated_inputs = _dcf_inputs() + updated_inputs["total_debt"] = 250.0 + updated_art = _build_dcf_artifact(updated_inputs) + + diff = diff_artifacts(baseline_art, updated_art) + assert diff.input_hash_changed is True + assert diff.model_version_changed is False + + by_path = {c.path: c for c in diff.changed_inputs} + assert "$.model_inputs.total_debt" in by_path + change = by_path["$.model_inputs.total_debt"] + assert change.old == pytest.approx(200.0) + assert change.new == pytest.approx(250.0) + assert change.delta == pytest.approx(50.0) + + # Nothing else in the raw inputs should have moved. + other_paths = [p for p in by_path if p != "$.model_inputs.total_debt"] + assert other_paths == [] + + def test_diff_reports_only_the_outputs_actually_affected_by_total_debt(self): + baseline_inputs = _dcf_inputs() + baseline_art = _build_dcf_artifact(baseline_inputs) + + updated_inputs = _dcf_inputs() + updated_inputs["total_debt"] = 250.0 + updated_art = _build_dcf_artifact(updated_inputs) + + diff = diff_artifacts(baseline_art, updated_art) + changed_paths = {c.path for c in diff.changed_outputs} + + # total_debt flows only through the net-debt bridge to equity value / + # value per share -- enterprise value and WACC must be untouched. + assert "$.net_debt_bridge.total_debt" in changed_paths + assert "$.equity_value" in changed_paths + assert "$.value_per_share" in changed_paths + assert "$.net_debt_bridge.equity_value" in changed_paths + assert "$.net_debt_bridge.value_per_share" in changed_paths + assert "$.enterprise_value" not in changed_paths + assert "$.wacc_build.wacc" not in changed_paths + + by_path = {c.path: c for c in diff.changed_outputs} + expected_delta = -50.0 # equity_value = EV - total_debt + ... ; +50 debt -> -50 equity + assert by_path["$.equity_value"].delta == pytest.approx(expected_delta) + assert by_path["$.net_debt_bridge.equity_value"].delta == pytest.approx(expected_delta) + + def test_diff_of_identical_artifacts_has_no_changes(self): + inputs = _dcf_inputs() + art_1 = _build_dcf_artifact(inputs, generated_at=FIXED_TIME) + art_2 = _build_dcf_artifact(inputs, generated_at=FIXED_TIME + timedelta(hours=1)) + diff = diff_artifacts(art_1, art_2) + assert diff.changed_inputs == () + assert diff.changed_outputs == () + assert diff.input_hash_changed is False + + +# --------------------------------------------------------------------------- +# 5. xlsx exports +# --------------------------------------------------------------------------- + + +class TestDCFWorkbookExport: + @pytest.fixture() + def workbook_path(self, tmp_path: Path) -> Path: + inputs = _dcf_inputs() + result = _run_dcf_default(inputs) + art = build_dcf_artifact( + inputs=inputs, result=result, generated_at=FIXED_TIME, + capital_structure_basis="target", discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + grid = sensitivity_grid( + result.fcff_bridge, + wacc_values=[0.08, 0.0905, 0.10], + growth_values=[0.02, 0.03, 0.04], + discounting_convention="mid_year", + total_debt=200.0, cash_and_equivalents=50.0, minority_interest=10.0, + preferred_equity=15.0, associate_investments=5.0, diluted_shares=100.0, + ) + out = tmp_path / "dcf.xlsx" + export_dcf_workbook(art, out, sensitivity_grid=grid) + return out, art, result, grid + + def test_all_five_sheets_present(self, workbook_path): + out, *_ = workbook_path + wb = openpyxl.load_workbook(out) + assert set(wb.sheetnames) == { + "FCFF Bridge", "WACC Build", "Terminal Value", "Sensitivity", + "Assumptions & Data Quality", + } + + def test_fcff_bridge_cells_match_result(self, workbook_path): + out, _art, result, _grid = workbook_path + wb = openpyxl.load_workbook(out) + ws = wb["FCFF Bridge"] + assert ws["A1"].value == "Year" + for i, year in enumerate(result.fcff_bridge): + row = i + 2 + assert ws.cell(row=row, column=1).value == year.year + assert ws.cell(row=row, column=2).value == pytest.approx(year.ebit) + assert ws.cell(row=row, column=8).value == pytest.approx(year.fcff) + total_row = len(result.fcff_bridge) + 2 + assert ws.cell(row=total_row, column=10).value == pytest.approx(result.pv_of_explicit_fcff) + + def test_wacc_sheet_cells_match_result(self, workbook_path): + out, _art, result, _grid = workbook_path + wb = openpyxl.load_workbook(out) + ws = wb["WACC Build"] + values = {row[0].value: row[1].value for row in ws.iter_rows(min_row=2)} + assert values["wacc"] == pytest.approx(result.wacc_build.wacc) + assert values["cost_of_equity"] == pytest.approx(result.wacc_build.cost_of_equity) + assert values["equity_weight"] == pytest.approx(0.7) + + def test_terminal_value_sheet_cells_match_result(self, workbook_path): + out, _art, result, _grid = workbook_path + wb = openpyxl.load_workbook(out) + ws = wb["Terminal Value"] + values = {row[0].value: row[1].value for row in ws.iter_rows(min_row=2)} + assert values["value_per_share"] == pytest.approx(result.value_per_share) + assert values["enterprise_value"] == pytest.approx(result.enterprise_value) + assert values["exit_multiple.basis"] == result.terminal_value.exit_multiple.basis + assert values["growth_exceeds_gdp_ceiling"] == result.terminal_value.growth_exceeds_gdp_ceiling + + def test_sensitivity_sheet_matches_grid(self, workbook_path): + out, _art, _result, grid = workbook_path + wb = openpyxl.load_workbook(out) + ws = wb["Sensitivity"] + # Header row: label + growth columns. + assert ws.cell(row=1, column=1).value == "wacc \\ terminal_growth" + for col_idx, growth in enumerate(grid.columns, start=2): + assert ws.cell(row=1, column=col_idx).value == pytest.approx(float(growth)) + for row_idx, wacc_value in enumerate(grid.index, start=2): + assert ws.cell(row=row_idx, column=1).value == pytest.approx(float(wacc_value)) + for col_idx, growth in enumerate(grid.columns, start=2): + expected = grid.loc[wacc_value, growth] + cell_value = ws.cell(row=row_idx, column=col_idx).value + if pd.isna(expected): + assert cell_value is None + else: + assert cell_value == pytest.approx(float(expected)) + + def test_assumptions_and_gaps_sheet_has_hash_and_both_assumptions(self, workbook_path): + out, art, _result, _grid = workbook_path + wb = openpyxl.load_workbook(out) + ws = wb["Assumptions & Data Quality"] + all_values = [cell.value for row in ws.iter_rows() for cell in row if cell.value is not None] + assert art.input_hash in all_values + assert art.model_version in all_values + assert "terminal_growth" in all_values + assert "exit_multiple" in all_values + joined_text = " | ".join(str(v) for v in all_values) + assert "Long-run nominal GDP proxy" in joined_text + + +class TestCompsWorkbookExport: + @pytest.fixture() + def workbook_path(self, tmp_path: Path): + target, peers, result, art = _build_comps_artifact() + out = tmp_path / "comps.xlsx" + export_comps_workbook(art, out) + return out, target, peers, result, art + + def test_all_four_sheets_present(self, workbook_path): + out, *_ = workbook_path + wb = openpyxl.load_workbook(out) + assert set(wb.sheetnames) == { + "Peer Detail", "Multiple Matrix", "Implied Valuation", "Assumptions & Data Quality", + } + + def test_peer_detail_cells_match_result(self, workbook_path): + out, _target, _peers, result, _art = workbook_path + wb = openpyxl.load_workbook(out) + ws = wb["Peer Detail"] + header = [c.value for c in ws[1]] + rows_by_name = { + row[header.index("name")].value: row for row in ws.iter_rows(min_row=2) + } + peer_a = next(p for p in result.peer_multiples if p.name == "PEER_A") + row = rows_by_name["PEER_A"] + assert row[header.index("enterprise_value")].value == pytest.approx(peer_a.ev_bridge.enterprise_value) + assert row[header.index("ev_ebitda")].value == pytest.approx(peer_a.ev_ebitda) + lossmaker_row = rows_by_name["PEER_LOSSMAKER"] + assert lossmaker_row[header.index("ev_ebitda")].value == "excluded" + + def test_multiple_matrix_includes_excluded_detail(self, workbook_path): + out, _target, _peers, result, _art = workbook_path + wb = openpyxl.load_workbook(out) + ws = wb["Multiple Matrix"] + rows = list(ws.iter_rows(values_only=True)) + header_idx = next(i for i, r in enumerate(rows) if r and r[0] == "multiple") + by_multiple = {r[0]: r for r in rows[header_idx + 1 : header_idx + 1 + len(list(result.distributions))]} + ev_ebitda_row = by_multiple["ev_ebitda"] + dist = result.distributions["ev_ebitda"] + assert ev_ebitda_row[3] == pytest.approx(dist.median) # median column + assert ev_ebitda_row[6] == len(dist.included) + assert ev_ebitda_row[7] == len(dist.excluded) + + excluded_detail_rows = [r for r in rows if r and r[0] == "PEER_LOSSMAKER"] + assert excluded_detail_rows, "excluded peer must appear in the Excluded detail block" + assert any(r[1] == "ev_ebitda" for r in excluded_detail_rows) + + def test_implied_valuation_cells_match_result(self, workbook_path): + out, _target, _peers, result, _art = workbook_path + wb = openpyxl.load_workbook(out) + ws = wb["Implied Valuation"] + rows = {r[0]: r for r in ws.iter_rows(min_row=2, values_only=True)} + ev_ebitda_row = rows["ev_ebitda"] + iv = result.implied_valuations["ev_ebitda"] + assert ev_ebitda_row[2] == pytest.approx(iv.target_metric_value) + # Columns: multiple(0), is_ev_multiple(1), target_metric_value(2), + # then implied_ev min/p25/median/p75/max at indices 3..7. + assert ev_ebitda_row[5] == pytest.approx(iv.implied_ev_by_quantile["median"]) + + def test_assumptions_sheet_honestly_reports_no_assumptions(self, workbook_path): + out, _target, _peers, result, art = workbook_path + wb = openpyxl.load_workbook(out) + ws = wb["Assumptions & Data Quality"] + all_values = [cell.value for row in ws.iter_rows() for cell in row if cell.value is not None] + assert art.input_hash in all_values + joined = " | ".join(str(v) for v in all_values) + assert "no caller-supplied Assumption objects" in joined + assert "PEER_LOSSMAKER" in joined + assert "minority_interest" in joined + + +class TestThreeStatementWorkbookExport: + @pytest.fixture() + def workbook_path(self, tmp_path: Path): + opening, drivers, result, art = _build_three_statement_artifact() + out = tmp_path / "three_statement.xlsx" + export_three_statement_workbook(art, out) + return out, opening, drivers, result, art + + def test_all_four_sheets_present(self, workbook_path): + out, *_ = workbook_path + wb = openpyxl.load_workbook(out) + assert set(wb.sheetnames) == { + "Income Statement", "Cash Flow Statement", "Balance Sheet", "Assumptions & Data Quality", + } + + def test_income_statement_cells_match_result(self, workbook_path): + out, _opening, _drivers, result, _art = workbook_path + wb = openpyxl.load_workbook(out) + ws = wb["Income Statement"] + for i, period in enumerate(result.periods): + row = i + 2 + s = period.income_statement + assert ws.cell(row=row, column=1).value == s.period + assert ws.cell(row=row, column=2).value == pytest.approx(s.revenue) + assert ws.cell(row=row, column=11).value == pytest.approx(s.net_income) + + def test_cash_flow_sheet_surfaces_revolver_draw_and_convergence(self, workbook_path): + out, _opening, _drivers, result, _art = workbook_path + wb = openpyxl.load_workbook(out) + ws = wb["Cash Flow Statement"] + header = [c.value for c in ws[1]] + draw_col = header.index("revolver_draw") + 1 + converged_col = header.index("converged") + 1 + iterations_col = header.index("iterations") + 1 + for i, period in enumerate(result.periods): + row = i + 2 + c = period.cash_flow_statement + assert ws.cell(row=row, column=draw_col).value == pytest.approx(c.revolver_draw) + assert ws.cell(row=row, column=converged_col).value == period.converged + assert ws.cell(row=row, column=iterations_col).value == period.iterations + # Period 1's minimum_cash (400,000) forces a draw -- must be > 0 and present. + assert ws.cell(row=2, column=draw_col).value > 0.0 + + def test_balance_sheet_rows_balance_and_match_result(self, workbook_path): + out, _opening, _drivers, result, _art = workbook_path + wb = openpyxl.load_workbook(out) + ws = wb["Balance Sheet"] + rows = list(ws.iter_rows(min_row=2, values_only=True)) + assert rows[0][0] == 0 # opening balance sheet first + assert rows[0][1] == pytest.approx(result.opening_balance_sheet.cash) + for row in rows: + residual = row[-1] + assert residual == pytest.approx(0.0, abs=1e-6) + for i, period in enumerate(result.periods): + row = rows[i + 1] + assert row[1] == pytest.approx(period.balance_sheet.cash) + assert row[4] == pytest.approx(period.balance_sheet.revolver_balance) + + def test_assumptions_sheet_reports_no_assumptions_and_revolver_note(self, workbook_path): + out, _opening, _drivers, _result, art = workbook_path + wb = openpyxl.load_workbook(out) + ws = wb["Assumptions & Data Quality"] + all_values = [cell.value for row in ws.iter_rows() for cell in row if cell.value is not None] + assert art.input_hash in all_values + joined = " | ".join(str(v) for v in all_values) + assert "no caller-supplied Assumption objects" in joined + assert "revolver drew" in joined + + +# --------------------------------------------------------------------------- +# 6. pptx summary export +# --------------------------------------------------------------------------- + + +def _all_text(prs: Presentation) -> str: + chunks = [] + for slide in prs.slides: + for shape in slide.shapes: + if shape.has_text_frame: + chunks.append(shape.text_frame.text) + return "\n".join(chunks) + + +class TestSummaryDeckExport: + def test_dcf_deck_contains_value_per_share_hash_and_assumptions(self, tmp_path): + inputs = _dcf_inputs() + result = _run_dcf_default(inputs) + art = build_dcf_artifact( + inputs=inputs, result=result, generated_at=FIXED_TIME, + capital_structure_basis="target", discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + grid = sensitivity_grid( + result.fcff_bridge, wacc_values=[0.08, 0.0905, 0.10], growth_values=[0.02, 0.03], + discounting_convention="mid_year", total_debt=200.0, cash_and_equivalents=50.0, + minority_interest=10.0, preferred_equity=15.0, associate_investments=5.0, + diluted_shares=100.0, + ) + out = tmp_path / "dcf_summary.pptx" + export_summary_deck(art, out, sensitivity_grid=grid) + + prs = Presentation(out) + assert len(prs.slides) == 1 + text = _all_text(prs) + assert f"{result.value_per_share:,.4f}" in text + assert art.input_hash in text + assert "terminal_growth" in text + assert "exit_multiple" in text + assert "Sensitivity grid not supplied" not in text + + def test_dcf_deck_without_grid_says_so_explicitly(self, tmp_path): + art = _build_dcf_artifact() + out = tmp_path / "dcf_summary_no_grid.pptx" + export_summary_deck(art, out) + prs = Presentation(out) + text = _all_text(prs) + assert "Sensitivity grid not supplied" in text + + def test_comps_deck_reports_no_assumptions_and_exclusions(self, tmp_path): + _, _, result, art = _build_comps_artifact() + out = tmp_path / "comps_summary.pptx" + export_summary_deck(art, out) + prs = Presentation(out) + text = _all_text(prs) + assert "No caller-supplied Assumption objects" in text + assert "PEER_LOSSMAKER" in text + assert art.input_hash in text + + def test_three_statement_deck_reports_final_period_and_gaps(self, tmp_path): + _, _, result, art = _build_three_statement_artifact() + out = tmp_path / "three_statement_summary.pptx" + export_summary_deck(art, out) + prs = Presentation(out) + text = _all_text(prs) + last = result.periods[-1] + assert f"{last.income_statement.net_income:,.2f}" in text + assert "not defined for a three-statement projection" in text + assert "revolver drew" in text + + +# --------------------------------------------------------------------------- +# Construction guards +# --------------------------------------------------------------------------- + + +class TestModelArtifactConstruction: + def test_rejects_unknown_model_name(self): + art = _build_dcf_artifact() + with pytest.raises(ValueError): + ModelArtifact( + model_name="not_a_real_model", + model_version=art.model_version, + schema_version=art.schema_version, + generated_at=art.generated_at, + input_hash=art.input_hash, + inputs=art.inputs, + outputs=art.outputs, + assumptions=art.assumptions, + result=art.result, + excluded=art.excluded, + warnings=art.warnings, + ) + + def test_rejects_malformed_input_hash(self): + art = _build_dcf_artifact() + with pytest.raises(ValueError): + ModelArtifact( + model_name=art.model_name, + model_version=art.model_version, + schema_version=art.schema_version, + generated_at=art.generated_at, + input_hash="not-a-hash", + inputs=art.inputs, + outputs=art.outputs, + assumptions=art.assumptions, + result=art.result, + excluded=art.excluded, + warnings=art.warnings, + ) + + def test_result_type_mismatch_raises(self): + inputs = _dcf_inputs() + with pytest.raises(TypeError): + build_dcf_artifact( + inputs=inputs, result="not a DCFResult", generated_at=FIXED_TIME, # type: ignore[arg-type] + capital_structure_basis="target", discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) diff --git a/agent/tests/quantlib/valuation/test_comps.py b/agent/tests/quantlib/valuation/test_comps.py new file mode 100644 index 00000000..a3e60a48 --- /dev/null +++ b/agent/tests/quantlib/valuation/test_comps.py @@ -0,0 +1,778 @@ +"""Tests for src.quantlib.valuation.comps. + +The centerpiece is `test_full_hand_computed_example`: four peers and one +target, worked out with LITERAL ARITHMETIC EXPRESSIONS (e.g. ``1130 / 160``, +never a pre-rounded decimal typed by hand) so Python evaluates the same +formula the module's docstring states, independently of the module's own +code path. Percentiles are cross-checked against `_lin_percentile`, a ~10 +line reimplementation of numpy's default linear-interpolation quantile, +written from the textbook formula rather than imported from the module under +test -- this is the repo's convention (see `test_attribution.py`) for how a +"hand computed" fixture stays hand computed instead of silently re-deriving +its own answer from the implementation. +""" + +from __future__ import annotations + +import pytest + +from src.quantlib.valuation.comps import ( + CALENDARISATION_POLICIES, + EPS_BASES, + MIN_ROBUST_COMPS, + MULTIPLE_NAMES, + CompsResult, + EVBridgeResult, + ExcludedMultiple, + FlowMetricPeriods, + PeerCompany, + TargetCompany, + calendarise_metric, + enterprise_value, + equity_value_from_enterprise_value, + run_comps, +) +from src.quantlib.valuation.contracts import MissingInputError, ValuationError + +EXACT = 1e-9 + + +def _lin_percentile(values: list[float], q: float) -> float: + """Independent reimplementation of numpy's default ("linear") percentile. + + Textbook formula: sort, take fractional index ``q * (n - 1)``, linearly + interpolate between its floor and ceiling. Written from scratch (not + imported from numpy or from the module under test) so the big fixture + below is checked against ground truth, not against its own algorithm. + """ + ordered = sorted(values) + n = len(ordered) + if n == 1: + return ordered[0] + idx = q * (n - 1) + lo = int(idx) + hi = min(lo + 1, n - 1) + frac = idx - lo + return ordered[lo] + frac * (ordered[hi] - ordered[lo]) + + +def _flow( + last_full: float, + *, + ytd: float | None = None, + prior_ytd: float | None = None, + next_fy: float | None = None, + month: int = 12, +) -> FlowMetricPeriods: + """Small constructor to keep the isolated unit tests below readable.""" + return FlowMetricPeriods( + fiscal_year_end_month=month, + last_full_fiscal_year=last_full, + current_year_to_date=ytd, + prior_year_to_date=prior_ytd, + next_full_fiscal_year=next_fy, + ) + + +def _baseline_peer(name: str = "PEER", **overrides: object) -> PeerCompany: + """A self-consistent, LTM-ready peer for tests that are not about the + big hand-computed fixture (EV bridge signs, exclusion, warnings, ...). + """ + fields: dict[str, object] = dict( + name=name, + market_cap=1000.0, + total_debt=200.0, + cash_and_equivalents=100.0, + ebitda=_flow(150.0, ytd=80.0, prior_ytd=70.0), # LTM = 160 + ebit=_flow(100.0, ytd=55.0, prior_ytd=50.0), # LTM = 105 + revenue=_flow(900.0, ytd=480.0, prior_ytd=420.0), # LTM = 960 + diluted_eps=_flow(5.0, ytd=2.6, prior_ytd=2.2), # LTM = 5.4 + price_per_share=54.0, + book_value_of_equity=500.0, + eps_basis="gaap", + ) + fields.update(overrides) + return PeerCompany(**fields) # type: ignore[arg-type] + + +def _baseline_target(name: str = "TARGET", **overrides: object) -> TargetCompany: + fields: dict[str, object] = dict( + name=name, + total_debt=150.0, + cash_and_equivalents=60.0, + ebitda=_flow(140.0, ytd=75.0, prior_ytd=65.0), # LTM = 150 + ebit=_flow(95.0, ytd=52.0, prior_ytd=45.0), # LTM = 102 + revenue=_flow(850.0, ytd=460.0, prior_ytd=400.0), # LTM = 910 + diluted_eps=_flow(4.5, ytd=2.4, prior_ytd=2.0), # LTM = 4.9 + diluted_shares_outstanding=100.0, + book_value_of_equity=420.0, + eps_basis="gaap", + ) + fields.update(overrides) + return TargetCompany(**fields) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# EV bridge: one test per sign. +# --------------------------------------------------------------------------- + + +def test_total_debt_increases_enterprise_value(): + """Debt is assumed by an acquirer of the whole enterprise -- EV rises.""" + base = enterprise_value(market_cap=1000.0, total_debt=200.0, cash_and_equivalents=100.0) + higher_debt = enterprise_value(market_cap=1000.0, total_debt=250.0, cash_and_equivalents=100.0) + assert higher_debt.enterprise_value == pytest.approx(base.enterprise_value + 50.0, abs=EXACT) + + +def test_cash_decreases_enterprise_value(): + """Cash funds part of the purchase price -- EV falls as cash rises.""" + base = enterprise_value(market_cap=1000.0, total_debt=200.0, cash_and_equivalents=100.0) + more_cash = enterprise_value(market_cap=1000.0, total_debt=200.0, cash_and_equivalents=150.0) + assert more_cash.enterprise_value == pytest.approx(base.enterprise_value - 50.0, abs=EXACT) + + +def test_minority_interest_increases_enterprise_value(): + """Consolidated subsidiaries carry 100% of EBITDA into the parent's + figures -- the minority holders' claim on that 100% must be in EV too.""" + base = enterprise_value(market_cap=1000.0, total_debt=200.0, cash_and_equivalents=100.0) + with_mi = enterprise_value( + market_cap=1000.0, total_debt=200.0, cash_and_equivalents=100.0, minority_interest=50.0 + ) + assert with_mi.enterprise_value == pytest.approx(base.enterprise_value + 50.0, abs=EXACT) + assert "minority_interest" in base.omitted_components + assert "minority_interest" not in with_mi.omitted_components + + +def test_preferred_stock_increases_enterprise_value(): + """Preferred ranks senior to common -- market cap alone underprices the + whole enterprise by the preferred claim.""" + base = enterprise_value(market_cap=1000.0, total_debt=200.0, cash_and_equivalents=100.0) + with_pref = enterprise_value( + market_cap=1000.0, total_debt=200.0, cash_and_equivalents=100.0, preferred_stock=30.0 + ) + assert with_pref.enterprise_value == pytest.approx(base.enterprise_value + 30.0, abs=EXACT) + + +def test_investments_in_associates_decreases_enterprise_value(): + """An equity-method stake's revenue/EBITDA never entered the consolidated + operating metrics -- its value must come back out of EV.""" + base = enterprise_value(market_cap=1000.0, total_debt=200.0, cash_and_equivalents=100.0) + with_assoc = enterprise_value( + market_cap=1000.0, total_debt=200.0, cash_and_equivalents=100.0, investments_in_associates=40.0 + ) + assert with_assoc.enterprise_value == pytest.approx(base.enterprise_value - 40.0, abs=EXACT) + + +def test_omitted_components_are_not_silently_zeroed(): + """An unsupplied optional item is flagged by name, distinct from an + explicit confirmed zero.""" + omitted = enterprise_value(market_cap=1000.0, total_debt=200.0, cash_and_equivalents=100.0) + assert set(omitted.omitted_components) == { + "minority_interest", + "preferred_stock", + "investments_in_associates", + } + + explicit_zero = enterprise_value( + market_cap=1000.0, + total_debt=200.0, + cash_and_equivalents=100.0, + minority_interest=0.0, + preferred_stock=0.0, + investments_in_associates=0.0, + ) + assert explicit_zero.omitted_components == () + # Numerically identical to the fully-omitted case (0 contributes nothing + # either way) -- the DIFFERENCE this module guarantees is in the report, + # not in a different number. + assert explicit_zero.enterprise_value == pytest.approx(omitted.enterprise_value, abs=EXACT) + + +def test_enterprise_value_round_trips_through_equity_value_and_back(): + """`equity_value_from_enterprise_value` is the exact inverse bridge.""" + forward = enterprise_value( + market_cap=1000.0, + total_debt=200.0, + cash_and_equivalents=100.0, + minority_interest=50.0, + preferred_stock=30.0, + investments_in_associates=40.0, + ) + backward = equity_value_from_enterprise_value( + enterprise_value=forward.enterprise_value, + total_debt=200.0, + cash_and_equivalents=100.0, + minority_interest=50.0, + preferred_stock=30.0, + investments_in_associates=40.0, + ) + assert backward.equity_value == pytest.approx(1000.0, abs=EXACT) + assert backward.direction == "ev_to_equity" + assert forward.direction == "equity_to_ev" + assert isinstance(forward, EVBridgeResult) + assert isinstance(backward, EVBridgeResult) + + +def test_enterprise_value_missing_required_input_raises(): + with pytest.raises(MissingInputError) as excinfo: + enterprise_value(market_cap=1000.0, total_debt=200.0, cash_and_equivalents=None) # type: ignore[arg-type] + assert "cash_and_equivalents" in excinfo.value.missing + + +# --------------------------------------------------------------------------- +# Calendarisation: LTM vs calendar-year, and why alignment matters. +# --------------------------------------------------------------------------- + + +def test_ltm_formula_is_last_full_fy_plus_current_ytd_minus_prior_ytd(): + periods = _flow(150.0, ytd=80.0, prior_ytd=70.0) + result = calendarise_metric(periods, "ltm", metric_name="ebitda", company_name="X") + assert result.value == pytest.approx(150.0 + 80.0 - 70.0, abs=EXACT) + assert result.weights is None + + +def test_ltm_policy_is_invariant_to_fiscal_year_end_month(): + """LTM needs no month-weighting: it always trails 12 months from the + 'as of' date the YTD figures were cut at, whatever the fiscal calendar.""" + june_fye = _flow(150.0, ytd=80.0, prior_ytd=70.0, month=6) + march_fye = _flow(150.0, ytd=80.0, prior_ytd=70.0, month=3) + a = calendarise_metric(june_fye, "ltm", metric_name="ebitda", company_name="X") + b = calendarise_metric(march_fye, "ltm", metric_name="ebitda", company_name="Y") + assert a.value == pytest.approx(b.value, abs=EXACT) + + +def test_calendar_year_december_fye_uses_full_weight_on_last_full_year(): + periods = _flow(1200.0, month=12) + result = calendarise_metric(periods, "calendar_year", metric_name="revenue", company_name="X") + assert result.value == pytest.approx(1200.0, abs=EXACT) + assert result.weights == pytest.approx((1.0, 0.0), abs=EXACT) + + +def test_fiscal_calendar_alignment_changes_the_comparison(): + """Requirement 4: a June-FYE company and a December-FYE company give a + materially different conclusion unaligned vs aligned to calendar year. + + Company X: December FYE, last full fiscal year (= calendar year) revenue + 1200. + Company Y: June FYE. Fiscal year ending June (the 'early' half of the + blend) = 900; fiscal year ending the following June (the 'late' half) = + 1100. Calendar year revenue, assuming even monthly spread within each + fiscal year (this module's documented convention): the first six months + of the calendar year come from the FY ending in June of that year, the + last six months from the FY ending the following June: + + calendar_year(Y) = 0.5 * 900 + 0.5 * 1100 = 1000 + """ + x = _flow(1200.0, month=12) + y = _flow(900.0, next_fy=1100.0, month=6) + + x_aligned = calendarise_metric(x, "calendar_year", metric_name="revenue", company_name="X") + y_aligned = calendarise_metric(y, "calendar_year", metric_name="revenue", company_name="Y") + + assert y_aligned.value == pytest.approx(0.5 * 900.0 + 0.5 * 1100.0, abs=EXACT) + assert y_aligned.weights == pytest.approx((0.5, 0.5), abs=EXACT) + + # Unaligned (naive) comparison: Y's raw 'last full fiscal year' (a period + # that ended six months before X's) against X's calendar-year figure. + naive_ratio = y.last_full_fiscal_year / x.last_full_fiscal_year # 900 / 1200 = 0.75 + aligned_ratio = y_aligned.value / x_aligned.value # 1000 / 1200 = 0.8333... + + assert naive_ratio == pytest.approx(0.75, abs=EXACT) + assert aligned_ratio == pytest.approx(1000.0 / 1200.0, abs=EXACT) + # The two conclusions are materially different -- alignment is not a + # rounding nicety here, it changes which company looks bigger relative + # to the other by several points. + assert abs(aligned_ratio - naive_ratio) > 0.05 + + +def test_calendar_year_policy_missing_next_fiscal_year_raises(): + """A non-December FYE company that only supplied LTM-shaped fields + (no `next_full_fiscal_year`) cannot be calendar-year aligned -- this is + the 'do not mix conventions' rule enforced as MissingInputError rather + than a silently partial calendarisation.""" + ltm_only = _flow(900.0, ytd=500.0, prior_ytd=430.0, month=6) + with pytest.raises(MissingInputError) as excinfo: + calendarise_metric(ltm_only, "calendar_year", metric_name="revenue", company_name="Y") + assert "next_full_fiscal_year" in excinfo.value.missing + + +def test_ltm_policy_missing_ytd_fields_raises(): + calendar_year_only = _flow(900.0, next_fy=1100.0, month=6) + with pytest.raises(MissingInputError) as excinfo: + calendarise_metric(calendar_year_only, "ltm", metric_name="revenue", company_name="Y") + assert "current_year_to_date" in excinfo.value.missing + assert "prior_year_to_date" in excinfo.value.missing + + +def test_unknown_calendarisation_policy_raises(): + with pytest.raises(ValuationError): + calendarise_metric(_flow(100.0), "quarterly", metric_name="revenue", company_name="X") + + +def test_fiscal_year_end_month_out_of_range_raises(): + with pytest.raises(ValuationError): + FlowMetricPeriods(fiscal_year_end_month=13, last_full_fiscal_year=100.0) + + +# --------------------------------------------------------------------------- +# Multiple exclusion: negative/zero denominators are dropped, not computed. +# --------------------------------------------------------------------------- + + +def test_negative_ebitda_peer_is_excluded_from_ev_ebitda_and_reported(): + negative_ebitda_peer = _baseline_peer( + name="LOSSCO", ebitda=_flow(-30.0, ytd=-10.0, prior_ytd=5.0) # LTM = -45 + ) + healthy_peer = _baseline_peer(name="HEALTHY") + target = _baseline_target() + + result = run_comps(target, [negative_ebitda_peer, healthy_peer], calendarisation_policy="ltm") + + ev_ebitda_dist = result.distributions["ev_ebitda"] + assert "LOSSCO" not in ev_ebitda_dist.included + assert "HEALTHY" in ev_ebitda_dist.included + assert len(ev_ebitda_dist.excluded) == 1 + excluded = ev_ebitda_dist.excluded[0] + assert isinstance(excluded, ExcludedMultiple) + assert excluded.peer_name == "LOSSCO" + assert excluded.multiple_name == "ev_ebitda" + assert excluded.denominator_value == pytest.approx(-45.0, abs=EXACT) + + # The excluded peer's own multiple is None, not a negative number. + peer_multiples_by_name = {pm.name: pm for pm in result.peer_multiples} + assert peer_multiples_by_name["LOSSCO"].ev_ebitda is None + + # With only one included peer, the median IS that peer's own multiple -- + # LOSSCO's -45x never entered the calculation. + assert ev_ebitda_dist.median == pytest.approx( + peer_multiples_by_name["HEALTHY"].ev_ebitda, abs=EXACT + ) + + +def test_zero_ebitda_denominator_is_also_excluded(): + zero_ebitda_peer = _baseline_peer(name="FLAT", ebitda=_flow(0.0, ytd=0.0, prior_ytd=0.0)) + healthy_peer = _baseline_peer(name="HEALTHY") + result = run_comps(_baseline_target(), [zero_ebitda_peer, healthy_peer], calendarisation_policy="ltm") + assert "FLAT" not in result.distributions["ev_ebitda"].included + + +def test_all_peers_excluded_from_a_multiple_yields_no_statistics_and_a_warning(): + """Boundary: every peer excluded from one multiple. The run still + succeeds; that multiple's distribution and implied valuation are simply + empty/None, with a warning explaining why.""" + peer_1 = _baseline_peer(name="A", ebitda=_flow(-10.0, ytd=-5.0, prior_ytd=2.0)) + peer_2 = _baseline_peer(name="B", ebitda=_flow(-20.0, ytd=-8.0, prior_ytd=1.0)) + result = run_comps(_baseline_target(), [peer_1, peer_2], calendarisation_policy="ltm") + + dist = result.distributions["ev_ebitda"] + assert dist.included == {} + assert len(dist.excluded) == 2 + assert dist.minimum is None + assert dist.median is None + assert dist.warning is not None and "excluded" in dist.warning + + implied = result.implied_valuations["ev_ebitda"] + assert implied.implied_ev_by_quantile is None + assert implied.implied_equity_value_by_quantile is None + assert implied.warning == dist.warning + assert dist.warning in result.warnings + + +# --------------------------------------------------------------------------- +# Small-sample warning. +# --------------------------------------------------------------------------- + + +def test_fewer_than_min_robust_comps_peers_triggers_warning(): + peers = [_baseline_peer(name="A"), _baseline_peer(name="B")] + assert len(peers) < MIN_ROBUST_COMPS + result = run_comps(_baseline_target(), peers, calendarisation_policy="ltm") + assert any("only 2 peer(s) supplied" in w for w in result.warnings) + for name in MULTIPLE_NAMES: + assert result.distributions[name].warning is not None + + +def test_three_or_more_peers_does_not_trigger_the_sample_size_warning(): + peers = [_baseline_peer(name=n) for n in ("A", "B", "C")] + result = run_comps(_baseline_target(), peers, calendarisation_policy="ltm") + assert not any("peer(s) supplied" in w for w in result.warnings) + + +# --------------------------------------------------------------------------- +# Structural validation. +# --------------------------------------------------------------------------- + + +def test_empty_peer_list_raises_missing_input_error(): + with pytest.raises(MissingInputError) as excinfo: + run_comps(_baseline_target(), [], calendarisation_policy="ltm") + assert "peers" in excinfo.value.missing + + +def test_duplicate_peer_names_raise(): + peers = [_baseline_peer(name="DUP"), _baseline_peer(name="DUP")] + with pytest.raises(ValuationError): + run_comps(_baseline_target(), peers, calendarisation_policy="ltm") + + +def test_mixed_eps_basis_across_peers_raises(): + peers = [_baseline_peer(name="A", eps_basis="gaap"), _baseline_peer(name="B", eps_basis="adjusted")] + with pytest.raises(ValuationError): + run_comps(_baseline_target(), peers, calendarisation_policy="ltm") + + +def test_target_eps_basis_must_match_peers(): + peers = [_baseline_peer(name="A", eps_basis="gaap"), _baseline_peer(name="B", eps_basis="gaap")] + target = _baseline_target(eps_basis="adjusted") + with pytest.raises(ValuationError): + run_comps(target, peers, calendarisation_policy="ltm") + + +def test_unknown_eps_basis_rejected_at_construction(): + with pytest.raises(ValuationError): + _baseline_peer(name="A", eps_basis="non_gaap") + + +def test_unknown_calendarisation_policy_in_run_comps_raises(): + peers = [_baseline_peer(name="A"), _baseline_peer(name="B"), _baseline_peer(name="C")] + with pytest.raises(ValuationError): + run_comps(_baseline_target(), peers, calendarisation_policy="quarterly") + + +def test_blank_company_name_rejected(): + with pytest.raises(ValuationError): + _baseline_peer(name=" ") + + +def test_non_positive_diluted_shares_outstanding_rejected(): + with pytest.raises(ValuationError): + _baseline_target(diluted_shares_outstanding=0.0) + with pytest.raises(ValuationError): + _baseline_target(diluted_shares_outstanding=-10.0) + + +def test_result_records_which_calendarisation_policy_and_eps_basis_were_used(): + peers = [_baseline_peer(name="A"), _baseline_peer(name="B"), _baseline_peer(name="C")] + result = run_comps(_baseline_target(), peers, calendarisation_policy="calendar_year") + assert result.calendarisation_policy == "calendar_year" + assert result.eps_basis == "gaap" + assert result.calendarisation_policy in CALENDARISATION_POLICIES + assert result.eps_basis in EPS_BASES + assert isinstance(result, CompsResult) + + +# --------------------------------------------------------------------------- +# Boundary cases named explicitly in the spec. +# --------------------------------------------------------------------------- + + +def test_zero_market_cap_peer_is_excluded_and_reported(): + """A zero market cap is not a cheap company, it is missing data. + + Under the two-sided rule (2026-08-06) a non-positive NUMERATOR is excluded + just like a non-positive denominator: a 0.0x P/B dragged into a median + distorts it exactly as much as a negative multiple would. The exclusion is + reported with reason='non_positive_numerator' rather than silently dropped. + """ + zero_cap_peer = _baseline_peer(name="ZERO", market_cap=0.0) + result = run_comps( + _baseline_target(), [zero_cap_peer, _baseline_peer(name="B"), _baseline_peer(name="C")], + calendarisation_policy="ltm", + ) + peer_multiples_by_name = {pm.name: pm for pm in result.peer_multiples} + assert peer_multiples_by_name["ZERO"].pb is None + assert "ZERO" not in result.distributions["pb"].included + + reasons = { + (e.peer_name, e.multiple_name): e.reason + for e in peer_multiples_by_name["ZERO"].exclusions + } + assert reasons[("ZERO", "pb")] == "non_positive_numerator" + + +def test_a_net_cash_peer_with_negative_ev_is_excluded_not_averaged_in(): + """The gap the old one-sided rule left open. + + A net-cash-rich peer can carry a negative enterprise value with perfectly + healthy EBITDA. EV/EBITDA = -3.2x is not a valuation multiple, and letting + it into the median pulls the whole comparable range toward a number no peer + trades at. + """ + # Cash far exceeding market cap plus debt drives EV negative. + net_cash_peer = _baseline_peer(name="NETCASH", market_cap=100.0, cash_and_equivalents=10_000.0) + result = run_comps( + _baseline_target(), + [net_cash_peer, _baseline_peer(name="B"), _baseline_peer(name="C")], + calendarisation_policy="ltm", + ) + by_name = {pm.name: pm for pm in result.peer_multiples} + assert by_name["NETCASH"].ev_bridge.enterprise_value < 0 + + assert by_name["NETCASH"].ev_ebitda is None + assert "NETCASH" not in result.distributions["ev_ebitda"].included + assert result.distributions["ev_ebitda"].median > 0 + + +def test_target_negative_metric_produces_negative_implied_value_without_raising(): + """A target with negative LTM EPS produces a negative implied equity + value via P/E -- economically odd, but the module does not fabricate an + exclusion rule the spec does not state; it reports what falls out.""" + peers = [_baseline_peer(name="A"), _baseline_peer(name="B"), _baseline_peer(name="C")] + losing_target = _baseline_target(diluted_eps=_flow(-2.0, ytd=-1.0, prior_ytd=0.5)) # LTM = -3.5 + result = run_comps(losing_target, peers, calendarisation_policy="ltm") + + implied = result.implied_valuations["pe"] + assert implied.target_metric_value == pytest.approx(-3.5 * 100.0, abs=EXACT) + assert implied.implied_equity_value_by_quantile["median"] == pytest.approx( + result.distributions["pe"].median * implied.target_metric_value, abs=EXACT + ) + assert implied.implied_equity_value_by_quantile["median"] < 0.0 + + +# --------------------------------------------------------------------------- +# The full hand-computed example. +# --------------------------------------------------------------------------- + + +def test_full_hand_computed_example(): + """Four peers, one target, LTM policy. Every EV, every calendarised + metric, every multiple, every distribution quantile and every implied + valuation is asserted against a literal arithmetic expression built from + the formulas stated in the module docstring -- never a value read back + from the module under test. + """ + # --- Peer AAA ----------------------------------------------------- + ev_a = 1000.0 + 200.0 - 100.0 + 50.0 + 0.0 - 20.0 # market_cap + debt - cash + MI + pref - assoc + ebitda_a = 150.0 + 80.0 - 70.0 + ebit_a = 100.0 + 55.0 - 50.0 + revenue_a = 900.0 + 480.0 - 420.0 + eps_a = 5.0 + 2.6 - 2.2 + peer_a = PeerCompany( + name="AAA", + market_cap=1000.0, + total_debt=200.0, + cash_and_equivalents=100.0, + minority_interest=50.0, + preferred_stock=0.0, + investments_in_associates=20.0, + ebitda=_flow(150.0, ytd=80.0, prior_ytd=70.0), + ebit=_flow(100.0, ytd=55.0, prior_ytd=50.0), + revenue=_flow(900.0, ytd=480.0, prior_ytd=420.0), + diluted_eps=_flow(5.0, ytd=2.6, prior_ytd=2.2), + price_per_share=55.0, + book_value_of_equity=520.0, + eps_basis="gaap", + ) + + # --- Peer BBB ----------------------------------------------------- + ev_b = 1500.0 + 300.0 - 150.0 + 0.0 + 0.0 - 0.0 + ebitda_b = 200.0 + 110.0 - 90.0 + ebit_b = 140.0 + 75.0 - 65.0 + revenue_b = 1200.0 + 650.0 - 560.0 + eps_b = 6.0 + 3.2 - 2.8 + peer_b = PeerCompany( + name="BBB", + market_cap=1500.0, + total_debt=300.0, + cash_and_equivalents=150.0, + minority_interest=0.0, + preferred_stock=0.0, + investments_in_associates=0.0, + ebitda=_flow(200.0, ytd=110.0, prior_ytd=90.0), + ebit=_flow(140.0, ytd=75.0, prior_ytd=65.0), + revenue=_flow(1200.0, ytd=650.0, prior_ytd=560.0), + diluted_eps=_flow(6.0, ytd=3.2, prior_ytd=2.8), + price_per_share=67.0, + book_value_of_equity=740.0, + eps_basis="gaap", + ) + + # --- Peer CCC (negative LTM EBITDA -> excluded from ev_ebitda only) - + ev_c = 800.0 + 250.0 - 50.0 + ebitda_c = -30.0 + (-10.0) - 5.0 # = -45, negative + ebit_c = 90.0 + 48.0 - 40.0 + revenue_c = 700.0 + 380.0 - 320.0 + eps_c = 4.0 + 2.0 - 1.6 + peer_c = PeerCompany( + name="CCC", + market_cap=800.0, + total_debt=250.0, + cash_and_equivalents=50.0, + minority_interest=0.0, + preferred_stock=0.0, + investments_in_associates=0.0, + ebitda=_flow(-30.0, ytd=-10.0, prior_ytd=5.0), + ebit=_flow(90.0, ytd=48.0, prior_ytd=40.0), + revenue=_flow(700.0, ytd=380.0, prior_ytd=320.0), + diluted_eps=_flow(4.0, ytd=2.0, prior_ytd=1.6), + price_per_share=42.0, + book_value_of_equity=410.0, + eps_basis="gaap", + ) + assert ebitda_c < 0.0 + + # --- Peer DDD ------------------------------------------------------- + ev_d = 1200.0 + 180.0 - 80.0 + ebitda_d = 170.0 + 90.0 - 75.0 + ebit_d = 120.0 + 65.0 - 55.0 + revenue_d = 1000.0 + 540.0 - 460.0 + eps_d = 5.5 + 2.9 - 2.4 + peer_d = PeerCompany( + name="DDD", + market_cap=1200.0, + total_debt=180.0, + cash_and_equivalents=80.0, + minority_interest=0.0, + preferred_stock=0.0, + investments_in_associates=0.0, + ebitda=_flow(170.0, ytd=90.0, prior_ytd=75.0), + ebit=_flow(120.0, ytd=65.0, prior_ytd=55.0), + revenue=_flow(1000.0, ytd=540.0, prior_ytd=460.0), + diluted_eps=_flow(5.5, ytd=2.9, prior_ytd=2.4), + price_per_share=58.0, + book_value_of_equity=590.0, + eps_basis="gaap", + ) + + # --- Target TGT ------------------------------------------------------- + ebitda_t = 140.0 + 75.0 - 65.0 + ebit_t = 95.0 + 52.0 - 45.0 + revenue_t = 850.0 + 460.0 - 400.0 + eps_t = 4.5 + 2.4 - 2.0 + shares_t = 100.0 + net_income_t = eps_t * shares_t + bridge_delta_t = 150.0 - 60.0 + 10.0 + 0.0 - 5.0 # debt - cash + MI + pref - assoc + target = TargetCompany( + name="TGT", + total_debt=150.0, + cash_and_equivalents=60.0, + minority_interest=10.0, + preferred_stock=0.0, + investments_in_associates=5.0, + ebitda=_flow(140.0, ytd=75.0, prior_ytd=65.0), + ebit=_flow(95.0, ytd=52.0, prior_ytd=45.0), + revenue=_flow(850.0, ytd=460.0, prior_ytd=400.0), + diluted_eps=_flow(4.5, ytd=2.4, prior_ytd=2.0), + diluted_shares_outstanding=shares_t, + book_value_of_equity=420.0, + eps_basis="gaap", + ) + + result = run_comps(target, [peer_a, peer_b, peer_c, peer_d], calendarisation_policy="ltm") + assert result.calendarisation_policy == "ltm" + assert len(result.peer_multiples) == 4 + + # --- Per-peer EV bridge and calendarised metrics -------------------- + by_name = {pm.name: pm for pm in result.peer_multiples} + assert by_name["AAA"].ev_bridge.enterprise_value == pytest.approx(ev_a, abs=EXACT) + assert by_name["BBB"].ev_bridge.enterprise_value == pytest.approx(ev_b, abs=EXACT) + assert by_name["CCC"].ev_bridge.enterprise_value == pytest.approx(ev_c, abs=EXACT) + assert by_name["DDD"].ev_bridge.enterprise_value == pytest.approx(ev_d, abs=EXACT) + + assert by_name["AAA"].calendarised_ebitda.value == pytest.approx(ebitda_a, abs=EXACT) + assert by_name["AAA"].calendarised_ebit.value == pytest.approx(ebit_a, abs=EXACT) + assert by_name["AAA"].calendarised_revenue.value == pytest.approx(revenue_a, abs=EXACT) + assert by_name["AAA"].calendarised_diluted_eps.value == pytest.approx(eps_a, abs=EXACT) + + # --- Per-peer multiples ------------------------------------------------ + assert by_name["AAA"].ev_ebitda == pytest.approx(ev_a / ebitda_a, abs=EXACT) + assert by_name["AAA"].ev_ebit == pytest.approx(ev_a / ebit_a, abs=EXACT) + assert by_name["AAA"].ev_sales == pytest.approx(ev_a / revenue_a, abs=EXACT) + assert by_name["AAA"].pe == pytest.approx(55.0 / eps_a, abs=EXACT) + assert by_name["AAA"].pb == pytest.approx(1000.0 / 520.0, abs=EXACT) + + assert by_name["BBB"].ev_ebitda == pytest.approx(ev_b / ebitda_b, abs=EXACT) + assert by_name["BBB"].ev_ebit == pytest.approx(ev_b / ebit_b, abs=EXACT) + assert by_name["BBB"].ev_sales == pytest.approx(ev_b / revenue_b, abs=EXACT) + assert by_name["BBB"].pe == pytest.approx(67.0 / eps_b, abs=EXACT) + assert by_name["BBB"].pb == pytest.approx(1500.0 / 740.0, abs=EXACT) + + # CCC: EBITDA negative -> ev_ebitda excluded (None); everything else computed. + assert by_name["CCC"].ev_ebitda is None + assert len(by_name["CCC"].exclusions) == 1 + assert by_name["CCC"].exclusions[0].multiple_name == "ev_ebitda" + assert by_name["CCC"].exclusions[0].denominator_value == pytest.approx(ebitda_c, abs=EXACT) + assert by_name["CCC"].ev_ebit == pytest.approx(ev_c / ebit_c, abs=EXACT) + assert by_name["CCC"].ev_sales == pytest.approx(ev_c / revenue_c, abs=EXACT) + assert by_name["CCC"].pe == pytest.approx(42.0 / eps_c, abs=EXACT) + assert by_name["CCC"].pb == pytest.approx(800.0 / 410.0, abs=EXACT) + + assert by_name["DDD"].ev_ebitda == pytest.approx(ev_d / ebitda_d, abs=EXACT) + assert by_name["DDD"].ev_ebit == pytest.approx(ev_d / ebit_d, abs=EXACT) + assert by_name["DDD"].ev_sales == pytest.approx(ev_d / revenue_d, abs=EXACT) + assert by_name["DDD"].pe == pytest.approx(58.0 / eps_d, abs=EXACT) + assert by_name["DDD"].pb == pytest.approx(1200.0 / 590.0, abs=EXACT) + + # --- Distributions ------------------------------------------------- + ev_ebitda_values = [ev_a / ebitda_a, ev_b / ebitda_b, ev_d / ebitda_d] # CCC excluded + ev_ebit_values = [ev_a / ebit_a, ev_b / ebit_b, ev_c / ebit_c, ev_d / ebit_d] + ev_sales_values = [ev_a / revenue_a, ev_b / revenue_b, ev_c / revenue_c, ev_d / revenue_d] + pe_values = [55.0 / eps_a, 67.0 / eps_b, 42.0 / eps_c, 58.0 / eps_d] + pb_values = [1000.0 / 520.0, 1500.0 / 740.0, 800.0 / 410.0, 1200.0 / 590.0] + + expected_by_multiple = { + "ev_ebitda": ev_ebitda_values, + "ev_ebit": ev_ebit_values, + "ev_sales": ev_sales_values, + "pe": pe_values, + "pb": pb_values, + } + + for name, expected_values in expected_by_multiple.items(): + dist = result.distributions[name] + assert len(dist.included) == len(expected_values) + assert dist.minimum == pytest.approx(min(expected_values), abs=EXACT) + assert dist.maximum == pytest.approx(max(expected_values), abs=EXACT) + assert dist.p25 == pytest.approx(_lin_percentile(expected_values, 0.25), abs=EXACT) + assert dist.median == pytest.approx(_lin_percentile(expected_values, 0.50), abs=EXACT) + assert dist.p75 == pytest.approx(_lin_percentile(expected_values, 0.75), abs=EXACT) + + assert result.distributions["ev_ebitda"].median == pytest.approx(ev_a / ebitda_a, abs=EXACT) + assert len(result.distributions["ev_ebitda"].excluded) == 1 + assert result.distributions["ev_ebitda"].warning is None # 3 included, >= MIN_ROBUST_COMPS + + # --- Implied valuations --------------------------------------------- + for name in ("ev_ebitda", "ev_ebit", "ev_sales"): + target_metric = {"ev_ebitda": ebitda_t, "ev_ebit": ebit_t, "ev_sales": revenue_t}[name] + expected_values = expected_by_multiple[name] + implied = result.implied_valuations[name] + assert implied.target_metric_value == pytest.approx(target_metric, abs=EXACT) + assert implied.is_ev_multiple is True + + for label, q in (("minimum", 0.0), ("p25", 0.25), ("median", 0.5), ("p75", 0.75), ("maximum", 1.0)): + multiple = ( + min(expected_values) + if label == "minimum" + else max(expected_values) + if label == "maximum" + else _lin_percentile(expected_values, q) + ) + expected_ev = multiple * target_metric + assert implied.implied_ev_by_quantile[label] == pytest.approx(expected_ev, abs=EXACT) + expected_equity = expected_ev - bridge_delta_t + assert implied.implied_equity_value_by_quantile[label] == pytest.approx( + expected_equity, abs=EXACT + ) + + # pe: target metric is calendarised diluted EPS * diluted shares outstanding. + pe_implied = result.implied_valuations["pe"] + assert pe_implied.is_ev_multiple is False + assert pe_implied.target_metric_value == pytest.approx(net_income_t, abs=EXACT) + assert pe_implied.implied_ev_by_quantile is None + expected_pe_median = _lin_percentile(pe_values, 0.5) * net_income_t + assert pe_implied.implied_equity_value_by_quantile["median"] == pytest.approx( + expected_pe_median, abs=EXACT + ) + + # pb: target metric is book value of equity directly. + pb_implied = result.implied_valuations["pb"] + assert pb_implied.is_ev_multiple is False + assert pb_implied.target_metric_value == pytest.approx(420.0, abs=EXACT) + expected_pb_median = _lin_percentile(pb_values, 0.5) * 420.0 + assert pb_implied.implied_equity_value_by_quantile["median"] == pytest.approx( + expected_pb_median, abs=EXACT + ) + + # No small-sample warning: 4 peers >= MIN_ROBUST_COMPS, and CCC's single + # exclusion still leaves 3 included for ev_ebitda. + assert result.warnings == () diff --git a/agent/tests/quantlib/valuation/test_dcf.py b/agent/tests/quantlib/valuation/test_dcf.py new file mode 100644 index 00000000..31597cbf --- /dev/null +++ b/agent/tests/quantlib/valuation/test_dcf.py @@ -0,0 +1,998 @@ +"""Tests for src.quantlib.valuation.dcf. + +Two rules the assertions here follow, matching the rest of ``tests/quantlib``. + +*The main worked example is computed by hand, not by calling the module.* +:func:`test_full_worked_example_matches_hand_calculation` builds one clean +input set, and every expected intermediate (WACC, each year's NOPAT/FCFF, +both terminal values, both discount conventions, enterprise value, equity +value, value per share) is written out as a plain arithmetic expression in +this file using the same numbers -- not derived by calling ``dcf.py``. A wrong +implementation therefore cannot make the test agree with itself. + +*Every input this package refuses to default is tested for refusal.* The +missing-input tests are parametrized over every key :data:`DCF_BASE_REQUIRED_FIELDS` +and the structure-basis fields name, not just the four the task called out by +name, and a second block of tests calls the lower-level builders directly with +a required keyword omitted to show that no alternate entry point can slip a +number out when an input is missing. +""" + +import math + +import numpy as np +import pandas as pd +import pytest + +from src.quantlib.valuation.contracts import Assumption, MissingInputError, ValuationError +from src.quantlib.valuation.dcf import ( + DCF_BASE_REQUIRED_FIELDS, + DCF_CURRENT_STRUCTURE_FIELDS, + DCF_TARGET_STRUCTURE_FIELDS, + DEFAULT_LONG_RUN_GDP_GROWTH_CEILING, + DCFResult, + DiscountedCashFlow, + FCFFYear, + NetDebtBridgeResult, + TerminalValueResult, + WACCResult, + discount_fcff, + equity_bridge, + fcff_bridge, + run_dcf, + sensitivity_grid, + terminal_value, + wacc, +) + +# --------------------------------------------------------------------------- +# The worked example +# --------------------------------------------------------------------------- +# +# WACC: +# cost_of_equity = 0.04 + 1.2*0.05 + 0.01 + 0.00 = 0.11 +# cost_of_debt_after_tax = 0.06*(1-0.25) = 0.045 +# WACC = 0.7*0.11 + 0.3*0.045 = 0.0905 +# +# FCFF bridge (tax_rate=0.25 every year): +# Y1: NOPAT=100*0.75=75.00 FCFF=75.00+20-30-5 = 60.00 +# Y2: NOPAT=105*0.75=78.75 FCFF=78.75+21-28-4 = 67.75 +# Y3: NOPAT=110*0.75=82.50 FCFF=82.50+22-26-3 = 75.50 +# +# Terminal year EBITDA = EBIT_3 + D&A_3 = 110 + 22 = 132.0 +# +# Terminal value (g=0.03, exit_multiple=8.0x): +# FCFF_4 = 75.50*1.03 = 77.765 +# perpetuity_TV = 77.765/(0.0905-0.03) = 1285.3719008264463 +# exit_TV = 132.0*8.0 = 1056.0 +# implied_multiple= 1285.3719.../132 = 9.737665915351867 +# implied_growth = (1056*0.0905-75.5)/(1056+75.5) = 0.017735749005744584 +# +# Mid-year discounting (t = 0.5, 1.5, 2.5): +# pv_explicit = 177.7471630647021 +# terminal_discount_factor = (1.0905)**-2.5 = 0.805259440904553 +# pv_of_terminal_value (perpetuity) = 1035.0578582139267 +# enterprise_value = 1212.8050212786288 +# +# Year-end discounting (t = 1, 2, 3): +# pv_explicit = 170.2118699631167 +# terminal_discount_factor = (1.0905)**-3 = 0.7711218163967243 +# pv_of_terminal_value (perpetuity) = 991.1783149105994 +# enterprise_value = 1161.3901848737162 +# +# Equity bridge (mid-year, perpetuity method): +# equity_value = 1212.8050212786288 - 200 + 50 - 10 - 15 + 5 = 1042.8050212786288 +# value_per_share = 1042.8050212786288 / 100 = 10.428050212786289 + + +def base_inputs(*, structure: str = "target") -> dict: + """Fresh copy of the worked example's ``run_dcf`` inputs mapping. + + Args: + structure: ``"target"`` supplies ``target_equity_weight``/ + ``target_debt_weight`` (0.7/0.3); ``"current"`` supplies market + values (700/300) that resolve to the identical weights. + """ + inputs = { + "risk_free_rate": 0.04, + "beta": 1.2, + "equity_risk_premium": 0.05, + "size_premium": 0.01, + "country_risk_premium": 0.0, + "pretax_cost_of_debt": 0.06, + "tax_rate": 0.25, + "ebit": [100.0, 105.0, 110.0], + "depreciation_amortization": [20.0, 21.0, 22.0], + "capex": [30.0, 28.0, 26.0], + "delta_nwc": [5.0, 4.0, 3.0], + "terminal_growth": Assumption( + name="terminal_growth", + value=0.03, + basis="Long-run nominal GDP proxy for a mature-market perpetuity", + ), + "exit_multiple": Assumption( + name="exit_multiple", + value=8.0, + basis="Median EV/EBITDA of the comparable peer set", + ), + "total_debt": 200.0, + "cash_and_equivalents": 50.0, + "minority_interest": 10.0, + "preferred_equity": 15.0, + "associate_investments": 5.0, + "diluted_shares": 100.0, + } + if structure == "target": + inputs["target_equity_weight"] = 0.7 + inputs["target_debt_weight"] = 0.3 + else: + inputs["market_value_of_equity"] = 700.0 + inputs["market_value_of_debt"] = 300.0 + return inputs + + +# --------------------------------------------------------------------------- +# 1. The full worked example, asserted to 1e-9 +# --------------------------------------------------------------------------- + + +def test_full_worked_example_matches_hand_calculation(): + result = run_dcf( + base_inputs(), + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + + assert isinstance(result, DCFResult) + + # WACC + assert result.wacc_build.cost_of_equity == pytest.approx(0.11, abs=1e-9) + assert result.wacc_build.cost_of_debt_after_tax == pytest.approx(0.045, abs=1e-9) + assert result.wacc_build.wacc == pytest.approx(0.0905, abs=1e-9) + + # FCFF bridge + expected_nopat = [75.0, 78.75, 82.5] + expected_fcff = [60.0, 67.75, 75.5] + for i, year in enumerate(result.fcff_bridge): + assert year.year == i + 1 + assert year.nopat == pytest.approx(expected_nopat[i], abs=1e-9) + assert year.fcff == pytest.approx(expected_fcff[i], abs=1e-9) + + # Terminal value + tv = result.terminal_value + assert tv.terminal_year_ebitda == pytest.approx(132.0, abs=1e-9) + assert tv.perpetuity_terminal_value == pytest.approx(1285.3719008264463, abs=1e-6) + assert tv.exit_terminal_value == pytest.approx(1056.0, abs=1e-9) + assert tv.implied_ev_ebitda_multiple == pytest.approx(9.737665915351867, abs=1e-6) + assert tv.implied_perpetuity_growth == pytest.approx(0.017735749005744584, abs=1e-9) + + # Discounting + enterprise value (mid-year) + assert result.pv_of_explicit_fcff == pytest.approx(177.7471630647021, abs=1e-6) + assert result.terminal_discount_factor == pytest.approx(0.805259440904553, abs=1e-9) + assert result.pv_of_terminal_value == pytest.approx(1035.0578582139267, abs=1e-6) + assert result.enterprise_value == pytest.approx(1212.8050212786288, abs=1e-6) + + # Equity bridge + per share + assert result.equity_value == pytest.approx(1042.8050212786288, abs=1e-6) + assert result.value_per_share == pytest.approx(10.428050212786289, abs=1e-6) + assert result.net_debt_bridge.diluted_shares == 100.0 + + +def test_worked_example_current_basis_matches_target_basis(): + """700/300 market values resolve to the same 0.7/0.3 weights as target.""" + target = run_dcf( + base_inputs(structure="target"), + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + current = run_dcf( + base_inputs(structure="current"), + capital_structure_basis="current", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + assert current.wacc_build.wacc == pytest.approx(target.wacc_build.wacc, abs=1e-12) + assert current.value_per_share == pytest.approx(target.value_per_share, abs=1e-9) + + +def test_terminal_value_method_switch_changes_enterprise_value(): + perpetuity = run_dcf( + base_inputs(), + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + exit_method = run_dcf( + base_inputs(), + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="exit_multiple", + ) + # Both terminal values are always computed regardless of which drives EV. + assert perpetuity.terminal_value.exit_terminal_value == pytest.approx(1056.0, abs=1e-9) + assert exit_method.terminal_value.perpetuity_terminal_value == pytest.approx( + 1285.3719008264463, abs=1e-6 + ) + assert perpetuity.enterprise_value != pytest.approx(exit_method.enterprise_value) + + +# --------------------------------------------------------------------------- +# 2. Missing inputs are not runnable +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("missing_key", DCF_BASE_REQUIRED_FIELDS) +def test_run_dcf_refuses_each_missing_base_field(missing_key): + inputs = base_inputs() + del inputs[missing_key] + with pytest.raises(MissingInputError) as excinfo: + run_dcf( + inputs, + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + assert missing_key in excinfo.value.missing + assert excinfo.value.model == "run_dcf" + + +@pytest.mark.parametrize("missing_key", DCF_TARGET_STRUCTURE_FIELDS) +def test_run_dcf_refuses_each_missing_target_structure_field(missing_key): + inputs = base_inputs(structure="target") + del inputs[missing_key] + with pytest.raises(MissingInputError) as excinfo: + run_dcf( + inputs, + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + assert missing_key in excinfo.value.missing + + +@pytest.mark.parametrize("missing_key", DCF_CURRENT_STRUCTURE_FIELDS) +def test_run_dcf_refuses_each_missing_current_structure_field(missing_key): + inputs = base_inputs(structure="current") + del inputs[missing_key] + with pytest.raises(MissingInputError) as excinfo: + run_dcf( + inputs, + capital_structure_basis="current", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + assert missing_key in excinfo.value.missing + + +@pytest.mark.parametrize( + "missing_key", ["tax_rate", "beta", "equity_risk_premium", "diluted_shares"] +) +def test_run_dcf_names_the_four_headline_fields_explicitly(missing_key): + """The task's own four examples, kept as a standalone named check.""" + inputs = base_inputs() + del inputs[missing_key] + with pytest.raises(MissingInputError) as excinfo: + run_dcf( + inputs, + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + assert excinfo.value.missing == (missing_key,) + + +def test_no_path_returns_a_number_when_a_required_field_is_missing(): + """Neither the flat-mapping entry point nor any lower-level builder can + silently substitute a value for a missing required field.""" + # run_dcf: MissingInputError, never a DCFResult. + inputs = base_inputs() + del inputs["tax_rate"] + with pytest.raises(MissingInputError): + run_dcf( + inputs, + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + + # wacc(): a required keyword-only argument with no default cannot be + # omitted -- Python itself refuses the call before any arithmetic runs. + with pytest.raises(TypeError): + wacc( # missing tax_rate + risk_free_rate=0.04, + beta=1.2, + equity_risk_premium=0.05, + pretax_cost_of_debt=0.06, + capital_structure_basis="target", + target_equity_weight=0.7, + target_debt_weight=0.3, + ) + + # wacc()'s conditional structure inputs: present basis, absent pair. + with pytest.raises(MissingInputError) as excinfo: + wacc( + risk_free_rate=0.04, + beta=1.2, + equity_risk_premium=0.05, + pretax_cost_of_debt=0.06, + tax_rate=0.25, + capital_structure_basis="current", + ) + assert set(excinfo.value.missing) == {"market_value_of_equity", "market_value_of_debt"} + + # fcff_bridge(): missing capex. + with pytest.raises(TypeError): + fcff_bridge( + ebit=[100.0], + tax_rate=0.25, + depreciation_amortization=[20.0], + delta_nwc=[5.0], + ) + + # terminal_value(): missing exit_multiple. + with pytest.raises(TypeError): + terminal_value( + final_year_fcff=75.5, + terminal_year_ebitda=132.0, + wacc_rate=0.0905, + terminal_growth=Assumption("terminal_growth", 0.03, "test"), + ) + + # discount_fcff(): missing wacc_rate. + bridge = fcff_bridge( + ebit=[100.0], + tax_rate=0.25, + depreciation_amortization=[20.0], + capex=[30.0], + delta_nwc=[5.0], + ) + with pytest.raises(TypeError): + discount_fcff(bridge, discounting_convention="mid_year") + + # equity_bridge(): missing diluted_shares. + with pytest.raises(TypeError): + equity_bridge( + enterprise_value=1000.0, + total_debt=200.0, + cash_and_equivalents=50.0, + minority_interest=10.0, + preferred_equity=15.0, + associate_investments=5.0, + ) + + +def test_run_dcf_refuses_bare_float_terminal_growth(): + inputs = base_inputs() + inputs["terminal_growth"] = 0.03 # bare float, not an Assumption + with pytest.raises(ValuationError): + run_dcf( + inputs, + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + + +def test_run_dcf_refuses_bare_float_exit_multiple(): + inputs = base_inputs() + inputs["exit_multiple"] = 8.0 + with pytest.raises(ValuationError): + run_dcf( + inputs, + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + + +def test_run_dcf_refuses_assumption_with_wrong_name(): + inputs = base_inputs() + inputs["terminal_growth"] = Assumption(name="exit_multiple", value=0.03, basis="mislabelled") + with pytest.raises(ValuationError): + run_dcf( + inputs, + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + + +# --------------------------------------------------------------------------- +# 3. Dual terminal value + cross-validation +# --------------------------------------------------------------------------- + + +def test_implied_ev_ebitda_multiple_flags_an_unrealistic_growth_assumption(): + """g pushed close to WACC implies an absurd EV/EBITDA multiple, even + though the Gordon-growth arithmetic itself is perfectly correct.""" + result = terminal_value( + final_year_fcff=75.5, + terminal_year_ebitda=132.0, + wacc_rate=0.0905, + terminal_growth=Assumption("terminal_growth", 0.089, "deliberately aggressive"), + exit_multiple=Assumption("exit_multiple", 8.0, "median comp"), + ) + assert result.implied_ev_ebitda_multiple == pytest.approx(415.25, abs=0.01) + assert result.implied_ev_ebitda_multiple > 50.0 # an unmistakably absurd multiple + + +def test_implied_perpetuity_growth_flags_an_unrealistic_exit_multiple(): + """A rich exit multiple implies a perpetuity growth rate far above the + stated 3% growth assumption, surfacing the disagreement between methods.""" + result = terminal_value( + final_year_fcff=75.5, + terminal_year_ebitda=132.0, + wacc_rate=0.0905, + terminal_growth=Assumption("terminal_growth", 0.03, "base case"), + exit_multiple=Assumption("exit_multiple", 40.0, "deliberately rich"), + ) + assert result.implied_perpetuity_growth == pytest.approx(0.0751265054616749, abs=1e-9) + assert result.implied_perpetuity_growth > 0.06 # far above the stated 3% base case + + +def test_implied_perpetuity_growth_is_not_blocked_by_the_g_vs_wacc_guard(): + """The g>=WACC guard applies to the caller's stated terminal_growth + Assumption, not to the reverse-engineered implied growth -- an implied + growth is a diagnostic, and blocking it would hide exactly the + unrealistic assumption the cross-check exists to surface.""" + result = terminal_value( + final_year_fcff=75.5, + terminal_year_ebitda=132.0, + wacc_rate=0.0905, + terminal_growth=Assumption("terminal_growth", 0.03, "base case"), + exit_multiple=Assumption("exit_multiple", 60.0, "extreme"), + ) + # No exception raised even though the implied growth sits close to WACC. + assert result.implied_perpetuity_growth == pytest.approx(0.08020261397035833, abs=1e-9) + + +def test_terminal_growth_at_or_above_wacc_raises(): + with pytest.raises(ValuationError): + terminal_value( + final_year_fcff=75.5, + terminal_year_ebitda=132.0, + wacc_rate=0.0905, + terminal_growth=Assumption("terminal_growth", 0.0905, "equal to WACC"), + exit_multiple=Assumption("exit_multiple", 8.0, "median comp"), + ) + with pytest.raises(ValuationError): + terminal_value( + final_year_fcff=75.5, + terminal_year_ebitda=132.0, + wacc_rate=0.0905, + terminal_growth=Assumption("terminal_growth", 0.10, "above WACC"), + exit_multiple=Assumption("exit_multiple", 8.0, "median comp"), + ) + + +def test_terminal_value_blows_up_near_wacc_without_silent_truncation(): + """As g approaches WACC the terminal value must explode, not be capped + or silently truncated to a merely-large number.""" + near_far = terminal_value( + final_year_fcff=75.5, + terminal_year_ebitda=132.0, + wacc_rate=0.0905, + terminal_growth=Assumption("terminal_growth", 0.03, "base case"), + exit_multiple=Assumption("exit_multiple", 8.0, "median comp"), + ) + near_close = terminal_value( + final_year_fcff=75.5, + terminal_year_ebitda=132.0, + wacc_rate=0.0905, + terminal_growth=Assumption("terminal_growth", 0.0905 - 1e-6, "pathologically close to WACC"), + exit_multiple=Assumption("exit_multiple", 8.0, "median comp"), + ) + assert near_close.perpetuity_terminal_value > 1_000_000 + assert near_close.perpetuity_terminal_value > 100 * near_far.perpetuity_terminal_value + + +def test_growth_exceeds_gdp_ceiling_is_a_flag_not_an_error(): + below = terminal_value( + final_year_fcff=75.5, + terminal_year_ebitda=132.0, + wacc_rate=0.0905, + terminal_growth=Assumption("terminal_growth", 0.03, "below the 4% ceiling"), + exit_multiple=Assumption("exit_multiple", 8.0, "median comp"), + ) + assert below.growth_exceeds_gdp_ceiling is False + assert below.gdp_growth_ceiling == DEFAULT_LONG_RUN_GDP_GROWTH_CEILING + + above = terminal_value( + final_year_fcff=75.5, + terminal_year_ebitda=132.0, + wacc_rate=0.0905, + terminal_growth=Assumption("terminal_growth", 0.05, "above the 4% ceiling"), + exit_multiple=Assumption("exit_multiple", 8.0, "median comp"), + ) + # No error raised despite exceeding the ceiling -- a judgment call, not a refusal. + assert above.growth_exceeds_gdp_ceiling is True + assert above.perpetuity_terminal_value > 0.0 + + custom_ceiling = terminal_value( + final_year_fcff=75.5, + terminal_year_ebitda=132.0, + wacc_rate=0.0905, + terminal_growth=Assumption("terminal_growth", 0.03, "below default ceiling"), + exit_multiple=Assumption("exit_multiple", 8.0, "median comp"), + gdp_growth_ceiling=0.02, + ) + assert custom_ceiling.growth_exceeds_gdp_ceiling is True # 0.03 > custom 0.02 ceiling + + +# --------------------------------------------------------------------------- +# 4. Mid-year vs year-end discounting +# --------------------------------------------------------------------------- + + +def test_mid_year_discounting_is_higher_by_exactly_sqrt_1_plus_wacc(): + bridge = fcff_bridge( + ebit=[100.0, 105.0, 110.0], + tax_rate=0.25, + depreciation_amortization=[20.0, 21.0, 22.0], + capex=[30.0, 28.0, 26.0], + delta_nwc=[5.0, 4.0, 3.0], + ) + wacc_rate = 0.0905 + growth = Assumption("terminal_growth", 0.03, "base case") + multiple = Assumption("exit_multiple", 8.0, "median comp") + tv = terminal_value( + final_year_fcff=bridge[-1].fcff, + terminal_year_ebitda=bridge[-1].ebit + bridge[-1].depreciation_amortization, + wacc_rate=wacc_rate, + terminal_growth=growth, + exit_multiple=multiple, + ) + + def enterprise_value(convention: str) -> float: + discounted = discount_fcff(bridge, wacc_rate=wacc_rate, discounting_convention=convention) + pv_explicit = sum(entry.present_value for entry in discounted) + pv_terminal = tv.perpetuity_terminal_value * discounted[-1].discount_factor + return pv_explicit + pv_terminal + + ev_mid_year = enterprise_value("mid_year") + ev_year_end = enterprise_value("year_end") + + assert ev_mid_year > ev_year_end + ratio = ev_mid_year / ev_year_end + assert ratio == pytest.approx(math.sqrt(1.0 + wacc_rate), rel=1e-9) + + +def test_discount_fcff_requires_a_recognised_convention(): + bridge = fcff_bridge( + ebit=[100.0], + tax_rate=0.25, + depreciation_amortization=[20.0], + capex=[30.0], + delta_nwc=[5.0], + ) + with pytest.raises(ValuationError): + discount_fcff(bridge, wacc_rate=0.09, discounting_convention="quarterly") + + +# --------------------------------------------------------------------------- +# 5. Sensitivity grid +# --------------------------------------------------------------------------- + + +def test_sensitivity_grid_nan_where_growth_at_or_above_wacc(): + bridge = fcff_bridge( + ebit=[100.0, 105.0, 110.0], + tax_rate=0.25, + depreciation_amortization=[20.0, 21.0, 22.0], + capex=[30.0, 28.0, 26.0], + delta_nwc=[5.0, 4.0, 3.0], + ) + grid = sensitivity_grid( + bridge, + wacc_values=[0.07, 0.08, 0.09, 0.10, 0.11], + growth_values=[0.00, 0.02, 0.04, 0.08, 0.095], + discounting_convention="mid_year", + total_debt=200.0, + cash_and_equivalents=50.0, + minority_interest=10.0, + preferred_equity=15.0, + associate_investments=5.0, + diluted_shares=100.0, + ) + assert isinstance(grid, pd.DataFrame) + assert grid.index.name == "wacc" + assert grid.columns.name == "terminal_growth" + + # g=0.095 >= every wacc value except 0.10 and 0.11 -> NaN there. + assert math.isnan(grid.loc[0.07, 0.095]) + assert math.isnan(grid.loc[0.08, 0.095]) + assert math.isnan(grid.loc[0.09, 0.095]) + assert not math.isnan(grid.loc[0.10, 0.095]) + assert not math.isnan(grid.loc[0.11, 0.095]) + + # g=0.08 == wacc=0.08 -> NaN at the boundary too. + assert math.isnan(grid.loc[0.08, 0.08]) + assert math.isnan(grid.loc[0.07, 0.08]) # 0.08 > 0.07 + assert not math.isnan(grid.loc[0.09, 0.08]) + + +def test_sensitivity_grid_matches_hand_calculation_at_one_cell(): + bridge = fcff_bridge( + ebit=[100.0, 105.0, 110.0], + tax_rate=0.25, + depreciation_amortization=[20.0, 21.0, 22.0], + capex=[30.0, 28.0, 26.0], + delta_nwc=[5.0, 4.0, 3.0], + ) + grid = sensitivity_grid( + bridge, + wacc_values=[0.10], + growth_values=[0.03], + discounting_convention="mid_year", + total_debt=200.0, + cash_and_equivalents=50.0, + minority_interest=10.0, + preferred_equity=15.0, + associate_investments=5.0, + diluted_shares=100.0, + ) + # Hand-computed via the same formula, independently of sensitivity_grid: + # pv_explicit=175.42529663702476, tv=1110.9285714285713, + # ev=1050.8210257123903, equity=880.8210257123903, vps=8.808210257123903 + assert grid.loc[0.10, 0.03] == pytest.approx(8.808210257123903, abs=1e-6) + + +def test_sensitivity_grid_monotonic_decreasing_in_wacc_increasing_in_growth(): + bridge = fcff_bridge( + ebit=[100.0, 105.0, 110.0], + tax_rate=0.25, + depreciation_amortization=[20.0, 21.0, 22.0], + capex=[30.0, 28.0, 26.0], + delta_nwc=[5.0, 4.0, 3.0], + ) + wacc_values = [0.07, 0.08, 0.09, 0.10, 0.11] + growth_values = [0.00, 0.01, 0.02, 0.03, 0.04] + grid = sensitivity_grid( + bridge, + wacc_values=wacc_values, + growth_values=growth_values, + discounting_convention="mid_year", + total_debt=200.0, + cash_and_equivalents=50.0, + minority_interest=10.0, + preferred_equity=15.0, + associate_investments=5.0, + diluted_shares=100.0, + ) + values = grid.to_numpy() + assert not np.isnan(values).any() # every g here is below every wacc + + # Strictly decreasing down each column (increasing WACC). + for col in range(values.shape[1]): + column = values[:, col] + assert np.all(np.diff(column) < 0.0) + + # Strictly increasing across each row (increasing growth). + for row in range(values.shape[0]): + line = values[row, :] + assert np.all(np.diff(line) > 0.0) + + +def test_sensitivity_grid_requires_nonempty_axes_and_positive_shares(): + bridge = fcff_bridge( + ebit=[100.0], + tax_rate=0.25, + depreciation_amortization=[20.0], + capex=[30.0], + delta_nwc=[5.0], + ) + with pytest.raises(ValuationError): + sensitivity_grid( + bridge, + wacc_values=[], + growth_values=[0.03], + discounting_convention="mid_year", + total_debt=0.0, + cash_and_equivalents=0.0, + minority_interest=0.0, + preferred_equity=0.0, + associate_investments=0.0, + diluted_shares=100.0, + ) + with pytest.raises(ValuationError): + sensitivity_grid( + bridge, + wacc_values=[0.09], + growth_values=[0.03], + discounting_convention="mid_year", + total_debt=0.0, + cash_and_equivalents=0.0, + minority_interest=0.0, + preferred_equity=0.0, + associate_investments=0.0, + diluted_shares=0.0, # not positive + ) + + +# --------------------------------------------------------------------------- +# 6. Net-debt bridge: one sign-direction test per line item +# --------------------------------------------------------------------------- + + +def _bridge(**overrides) -> NetDebtBridgeResult: + base = dict( + enterprise_value=1000.0, + total_debt=0.0, + cash_and_equivalents=0.0, + minority_interest=0.0, + preferred_equity=0.0, + associate_investments=0.0, + diluted_shares=100.0, + ) + base.update(overrides) + return equity_bridge(**base) + + +def test_total_debt_reduces_equity_value(): + baseline = _bridge() + with_debt = _bridge(total_debt=120.0) + assert with_debt.equity_value == pytest.approx(baseline.equity_value - 120.0, abs=1e-9) + + +def test_cash_and_equivalents_increases_equity_value(): + baseline = _bridge() + with_cash = _bridge(cash_and_equivalents=80.0) + assert with_cash.equity_value == pytest.approx(baseline.equity_value + 80.0, abs=1e-9) + + +def test_minority_interest_reduces_equity_value(): + baseline = _bridge() + with_minority = _bridge(minority_interest=40.0) + assert with_minority.equity_value == pytest.approx(baseline.equity_value - 40.0, abs=1e-9) + + +def test_preferred_equity_reduces_equity_value(): + baseline = _bridge() + with_preferred = _bridge(preferred_equity=25.0) + assert with_preferred.equity_value == pytest.approx(baseline.equity_value - 25.0, abs=1e-9) + + +def test_associate_investments_increases_equity_value(): + baseline = _bridge() + with_associates = _bridge(associate_investments=15.0) + assert with_associates.equity_value == pytest.approx( + baseline.equity_value + 15.0, abs=1e-9 + ) + + +def test_equity_bridge_refuses_negative_magnitudes(): + for field in ( + "total_debt", + "cash_and_equivalents", + "minority_interest", + "preferred_equity", + "associate_investments", + ): + with pytest.raises(ValuationError): + _bridge(**{field: -1.0}) + + +def test_equity_bridge_requires_positive_diluted_shares(): + with pytest.raises(ValuationError): + _bridge(diluted_shares=0.0) + with pytest.raises(ValuationError): + _bridge(diluted_shares=-50.0) + + +# --------------------------------------------------------------------------- +# 7. WACC guards +# --------------------------------------------------------------------------- + + +def test_wacc_current_basis_zero_market_value_raises(): + with pytest.raises(ValuationError): + wacc( + risk_free_rate=0.04, + beta=1.2, + equity_risk_premium=0.05, + pretax_cost_of_debt=0.06, + tax_rate=0.25, + capital_structure_basis="current", + market_value_of_equity=0.0, + market_value_of_debt=0.0, + ) + + +def test_wacc_target_weights_must_sum_to_one(): + with pytest.raises(ValuationError): + wacc( + risk_free_rate=0.04, + beta=1.2, + equity_risk_premium=0.05, + pretax_cost_of_debt=0.06, + tax_rate=0.25, + capital_structure_basis="target", + target_equity_weight=0.7, + target_debt_weight=0.4, # sums to 1.1 + ) + + +def test_wacc_target_weights_must_be_nonnegative(): + with pytest.raises(ValuationError): + wacc( + risk_free_rate=0.04, + beta=1.2, + equity_risk_premium=0.05, + pretax_cost_of_debt=0.06, + tax_rate=0.25, + capital_structure_basis="target", + target_equity_weight=1.1, + target_debt_weight=-0.1, + ) + + +def test_wacc_rejects_unknown_capital_structure_basis(): + with pytest.raises(ValuationError): + wacc( + risk_free_rate=0.04, + beta=1.2, + equity_risk_premium=0.05, + pretax_cost_of_debt=0.06, + tax_rate=0.25, + capital_structure_basis="hybrid", + ) + + +def test_wacc_rejects_tax_rate_outside_unit_interval(): + with pytest.raises(ValuationError): + wacc( + risk_free_rate=0.04, + beta=1.2, + equity_risk_premium=0.05, + pretax_cost_of_debt=0.06, + tax_rate=1.25, + capital_structure_basis="target", + target_equity_weight=0.7, + target_debt_weight=0.3, + ) + + +def test_wacc_optional_premiums_default_to_zero_when_absent(): + result = wacc( + risk_free_rate=0.04, + beta=1.2, + equity_risk_premium=0.05, + pretax_cost_of_debt=0.06, + tax_rate=0.25, + capital_structure_basis="target", + target_equity_weight=0.7, + target_debt_weight=0.3, + ) + assert result.size_premium == 0.0 + assert result.country_risk_premium == 0.0 + assert result.cost_of_equity == pytest.approx(0.04 + 1.2 * 0.05, abs=1e-9) + + +# --------------------------------------------------------------------------- +# 8. FCFF bridge: delta_nwc sign convention + shape guards +# --------------------------------------------------------------------------- + + +def test_delta_nwc_increase_is_a_cash_outflow(): + baseline = fcff_bridge( + ebit=[100.0], tax_rate=0.25, depreciation_amortization=[20.0], capex=[30.0], + delta_nwc=[0.0], + )[0] + nwc_increase = fcff_bridge( + ebit=[100.0], tax_rate=0.25, depreciation_amortization=[20.0], capex=[30.0], + delta_nwc=[5.0], + )[0] + nwc_release = fcff_bridge( + ebit=[100.0], tax_rate=0.25, depreciation_amortization=[20.0], capex=[30.0], + delta_nwc=[-5.0], + )[0] + assert nwc_increase.fcff == pytest.approx(baseline.fcff - 5.0, abs=1e-9) + assert nwc_release.fcff == pytest.approx(baseline.fcff + 5.0, abs=1e-9) + + +def test_fcff_bridge_rejects_empty_forecast(): + with pytest.raises(ValuationError): + fcff_bridge( + ebit=[], tax_rate=0.25, depreciation_amortization=[], capex=[], delta_nwc=[] + ) + + +def test_fcff_bridge_rejects_mismatched_lengths(): + with pytest.raises(ValuationError): + fcff_bridge( + ebit=[100.0, 105.0], + tax_rate=0.25, + depreciation_amortization=[20.0], # wrong length + capex=[30.0, 28.0], + delta_nwc=[5.0, 4.0], + ) + + +def test_fcff_bridge_rejects_tax_rate_outside_unit_interval(): + with pytest.raises(ValuationError): + fcff_bridge( + ebit=[100.0], tax_rate=-0.1, depreciation_amortization=[20.0], capex=[30.0], + delta_nwc=[5.0], + ) + + +# --------------------------------------------------------------------------- +# 9. run_dcf: methodology switches are required and validated +# --------------------------------------------------------------------------- + + +def test_run_dcf_rejects_unknown_capital_structure_basis(): + with pytest.raises(ValuationError): + run_dcf( + base_inputs(), + capital_structure_basis="hybrid", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + + +def test_run_dcf_rejects_unknown_discounting_convention(): + with pytest.raises(ValuationError): + run_dcf( + base_inputs(), + capital_structure_basis="target", + discounting_convention="quarterly", + terminal_value_method="perpetuity_growth", + ) + + +def test_run_dcf_rejects_unknown_terminal_value_method(): + with pytest.raises(ValuationError): + run_dcf( + base_inputs(), + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="dividend_discount", + ) + + +def test_run_dcf_requires_explicit_methodology_arguments(): + """capital_structure_basis, discounting_convention and terminal_value_method + have no defaults -- omitting any of them is a TypeError, not a silently + chosen convention.""" + with pytest.raises(TypeError): + run_dcf( + base_inputs(), + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + with pytest.raises(TypeError): + run_dcf( + base_inputs(), + capital_structure_basis="target", + terminal_value_method="perpetuity_growth", + ) + with pytest.raises(TypeError): + run_dcf( + base_inputs(), + capital_structure_basis="target", + discounting_convention="mid_year", + ) + + +# --------------------------------------------------------------------------- +# 10. Dataclass repr sanity (result shape stays visible, not collapsed) +# --------------------------------------------------------------------------- + + +def test_dcf_result_exposes_every_intermediate_object(): + result = run_dcf( + base_inputs(), + capital_structure_basis="target", + discounting_convention="mid_year", + terminal_value_method="perpetuity_growth", + ) + assert isinstance(result.wacc_build, WACCResult) + assert all(isinstance(year, FCFFYear) for year in result.fcff_bridge) + assert all(isinstance(entry, DiscountedCashFlow) for entry in result.discounted_fcff) + assert isinstance(result.terminal_value, TerminalValueResult) + assert isinstance(result.net_debt_bridge, NetDebtBridgeResult) + assert len(result.fcff_bridge) == 3 + assert len(result.discounted_fcff) == 3 diff --git a/agent/tests/quantlib/valuation/test_threestatement.py b/agent/tests/quantlib/valuation/test_threestatement.py new file mode 100644 index 00000000..0de532e0 --- /dev/null +++ b/agent/tests/quantlib/valuation/test_threestatement.py @@ -0,0 +1,600 @@ +"""Tests for the linked three-statement projection. + +The flagship test (`test_three_period_projection_matches_hand_computation`) is +the important one: it builds a three-period scenario where the revolver never +moves (`minimum_cash` is set, period by period, to exactly the cash the model +would land on with no draw or repayment), which removes the interest/revolver +circularity from the arithmetic entirely and leaves every income-statement, +cash-flow-statement and balance-sheet line as closed-form arithmetic on the +driver inputs. Every expected number below is that closed-form arithmetic, +written out the same way the model itself derives it (revenue -> gross profit +-> EBITDA -> D&A -> EBIT -> interest -> pretax -> tax -> net income, then the +cash bridge, then the balance-sheet rollforwards) -- not copied from a prior +run of the implementation. A regression in the linkage cannot be absorbed by +reusing the implementation's own reasoning. + +The remaining tests each isolate one requirement that the flagship test does +not (and structurally cannot, since it is built to avoid the circularity) +exercise: the hard balance check actually raising on a genuinely unbalanced +input (not just staying quiet on a balanced one), multi-iteration convergence, +genuine divergence, the explicit revolver plug, missing-input refusal, and the +three named boundary conditions. +""" + +from __future__ import annotations + +import pytest + +from src.quantlib.valuation.contracts import MissingInputError +from src.quantlib.valuation.threestatement import ( + MAX_CIRCULARITY_ITERATIONS, + BalanceSheet, + BalanceSheetError, + ConvergenceError, + check_balance_sheet, + project_three_statement, +) + +TOL = 1e-9 + + +def _assert_balances(bs: BalanceSheet) -> None: + """Independently recompute assets vs. liabilities + equity and assert equality. + + Args: + bs: The balance sheet to check. + """ + assets = bs.cash + bs.net_working_capital + bs.ppe + liab_plus_equity = bs.revolver_balance + bs.paid_in_capital + bs.retained_earnings + assert assets == pytest.approx(liab_plus_equity, abs=TOL) + + +# -------------------------------------------------------------------------- +# Flagship: three-period hand-computed projection, flat (non-circular) debt. +# -------------------------------------------------------------------------- + +# Opening balance sheet. Assets = 200,000 + 150,000 + 800,000 = 1,150,000. +# Liabilities = 300,000. Equity must then be 850,000 = paid_in + RE, so +# RE_0 = 850,000 - 500,000 = 350,000. +CASH_0 = 200_000.0 +NWC_0 = 150_000.0 +PPE_0 = 800_000.0 +DEBT_0 = 300_000.0 +PAID_IN = 500_000.0 +RE_0 = 350_000.0 +REVENUE_0 = 1_000_000.0 +assert CASH_0 + NWC_0 + PPE_0 == pytest.approx(DEBT_0 + PAID_IN + RE_0) + +OPENING = { + "revenue": REVENUE_0, + "cash": CASH_0, + "net_working_capital": NWC_0, + "ppe": PPE_0, + "revolver_balance": DEBT_0, + "paid_in_capital": PAID_IN, + "retained_earnings": RE_0, +} + +# Drivers, period by period. interest_rate is 6% flat and the revolver never +# draws or repays (minimum_cash is set below to the exact break-even cash), so +# interest_1 = interest_2 = interest_3 = 0.06 * 300,000 = 18,000 throughout. +REVENUE_GROWTH = [0.10, 0.08, 0.05] +GROSS_MARGIN = [0.40, 0.42, 0.42] +OPEX_PCT = [0.20, 0.19, 0.18] +CAPEX_PCT = [0.05, 0.05, 0.04] +NWC_PCT = [0.15, 0.15, 0.14] +TAX_RATE = [0.25, 0.25, 0.25] +PAYOUT = [0.30, 0.30, 0.30] +DA = [60_000.0, 65_000.0, 68_000.0] +INTEREST_RATE = [0.06, 0.06, 0.06] + +# --- Period 1 --- +REVENUE_1 = REVENUE_0 * (1.0 + REVENUE_GROWTH[0]) # 1,100,000 +GROSS_PROFIT_1 = REVENUE_1 * GROSS_MARGIN[0] # 440,000 +OPEX_1 = REVENUE_1 * OPEX_PCT[0] # 220,000 +EBITDA_1 = GROSS_PROFIT_1 - OPEX_1 # 220,000 +EBIT_1 = EBITDA_1 - DA[0] # 160,000 +INTEREST_1 = INTEREST_RATE[0] * DEBT_0 # flat debt -> avg == 300,000; 18,000 +PRETAX_1 = EBIT_1 - INTEREST_1 # 142,000 +TAX_1 = PRETAX_1 * TAX_RATE[0] # 35,500 +NI_1 = PRETAX_1 - TAX_1 # 106,500 +CAPEX_1 = REVENUE_1 * CAPEX_PCT[0] # 55,000 +NWC_END_1 = REVENUE_1 * NWC_PCT[0] # 165,000 +D_NWC_1 = NWC_END_1 - NWC_0 # 15,000 +DIV_1 = max(NI_1, 0.0) * PAYOUT[0] # 31,950 +CFBF_1 = NI_1 + DA[0] - D_NWC_1 - CAPEX_1 - DIV_1 # 64,550 +CASH_1 = CASH_0 + CFBF_1 # 264,550 -- also this period's minimum_cash (no draw/repay) +PPE_1 = PPE_0 + CAPEX_1 - DA[0] # 795,000 +RE_1 = RE_0 + NI_1 - DIV_1 # 424,550 + +# --- Period 2 --- +REVENUE_2 = REVENUE_1 * (1.0 + REVENUE_GROWTH[1]) # 1,188,000 +GROSS_PROFIT_2 = REVENUE_2 * GROSS_MARGIN[1] # 498,960 +OPEX_2 = REVENUE_2 * OPEX_PCT[1] # 225,720 +EBITDA_2 = GROSS_PROFIT_2 - OPEX_2 # 273,240 +EBIT_2 = EBITDA_2 - DA[1] # 208,240 +INTEREST_2 = INTEREST_RATE[1] * DEBT_0 # 18,000 +PRETAX_2 = EBIT_2 - INTEREST_2 # 190,240 +TAX_2 = PRETAX_2 * TAX_RATE[1] # 47,560 +NI_2 = PRETAX_2 - TAX_2 # 142,680 +CAPEX_2 = REVENUE_2 * CAPEX_PCT[1] # 59,400 +NWC_END_2 = REVENUE_2 * NWC_PCT[1] # 178,200 +D_NWC_2 = NWC_END_2 - NWC_END_1 # 13,200 +DIV_2 = max(NI_2, 0.0) * PAYOUT[1] # 42,804 +CFBF_2 = NI_2 + DA[1] - D_NWC_2 - CAPEX_2 - DIV_2 # 92,276 +CASH_2 = CASH_1 + CFBF_2 # 356,826 +PPE_2 = PPE_1 + CAPEX_2 - DA[1] # 789,400 +RE_2 = RE_1 + NI_2 - DIV_2 # 524,426 + +# --- Period 3 --- +REVENUE_3 = REVENUE_2 * (1.0 + REVENUE_GROWTH[2]) # 1,247,400 +GROSS_PROFIT_3 = REVENUE_3 * GROSS_MARGIN[2] # 523,908 +OPEX_3 = REVENUE_3 * OPEX_PCT[2] # 224,532 +EBITDA_3 = GROSS_PROFIT_3 - OPEX_3 # 299,376 +EBIT_3 = EBITDA_3 - DA[2] # 231,376 +INTEREST_3 = INTEREST_RATE[2] * DEBT_0 # 18,000 +PRETAX_3 = EBIT_3 - INTEREST_3 # 213,376 +TAX_3 = PRETAX_3 * TAX_RATE[2] # 53,344 +NI_3 = PRETAX_3 - TAX_3 # 160,032 +CAPEX_3 = REVENUE_3 * CAPEX_PCT[2] # 49,896 +NWC_END_3 = REVENUE_3 * NWC_PCT[2] # 174,636 +D_NWC_3 = NWC_END_3 - NWC_END_2 # -3,564 (NWC shrinks -- a source of cash) +DIV_3 = max(NI_3, 0.0) * PAYOUT[2] # 48,009.6 +CFBF_3 = NI_3 + DA[2] - D_NWC_3 - CAPEX_3 - DIV_3 # 133,690.4 +CASH_3 = CASH_2 + CFBF_3 # 490,516.4 +PPE_3 = PPE_2 + CAPEX_3 - DA[2] # 771,296 +RE_3 = RE_2 + NI_3 - DIV_3 # 636,448.4 + +DRIVERS = { + "revenue_growth": REVENUE_GROWTH, + "gross_margin": GROSS_MARGIN, + "opex_pct_revenue": OPEX_PCT, + "capex_pct_revenue": CAPEX_PCT, + "nwc_pct_revenue": NWC_PCT, + "tax_rate": TAX_RATE, + "dividend_payout_ratio": PAYOUT, + "depreciation_amortization": DA, + "interest_rate": INTEREST_RATE, + # Set to the exact break-even cash each period, so the revolver never + # draws or repays and debt stays flat at 300,000 -- see module docstring. + "minimum_cash": [CASH_1, CASH_2, CASH_3], +} + + +def test_three_period_projection_matches_hand_computation(): + """Every IS/CF/BS line, all three periods, matches the arithmetic above exactly.""" + result = project_three_statement(OPENING, DRIVERS) + assert len(result.periods) == 3 + + p1, p2, p3 = result.periods + + # --- Period 1 income statement --- + assert p1.income_statement.revenue == pytest.approx(REVENUE_1, abs=TOL) + assert p1.income_statement.gross_profit == pytest.approx(GROSS_PROFIT_1, abs=TOL) + assert p1.income_statement.opex == pytest.approx(OPEX_1, abs=TOL) + assert p1.income_statement.ebitda == pytest.approx(EBITDA_1, abs=TOL) + assert p1.income_statement.depreciation_amortization == pytest.approx(DA[0], abs=TOL) + assert p1.income_statement.ebit == pytest.approx(EBIT_1, abs=TOL) + assert p1.income_statement.interest_expense == pytest.approx(INTEREST_1, abs=TOL) + assert p1.income_statement.pretax_income == pytest.approx(PRETAX_1, abs=TOL) + assert p1.income_statement.tax_expense == pytest.approx(TAX_1, abs=TOL) + assert p1.income_statement.net_income == pytest.approx(NI_1, abs=TOL) + assert p1.income_statement.cogs == pytest.approx(REVENUE_1 - GROSS_PROFIT_1, abs=TOL) + + # --- Period 1 cash flow statement --- + assert p1.cash_flow_statement.net_income == pytest.approx(NI_1, abs=TOL) + assert p1.cash_flow_statement.depreciation_amortization == pytest.approx(DA[0], abs=TOL) + assert p1.cash_flow_statement.change_in_net_working_capital == pytest.approx(D_NWC_1, abs=TOL) + assert p1.cash_flow_statement.capex == pytest.approx(CAPEX_1, abs=TOL) + assert p1.cash_flow_statement.dividends_paid == pytest.approx(DIV_1, abs=TOL) + assert p1.cash_flow_statement.revolver_draw == pytest.approx(0.0, abs=TOL) + assert p1.cash_flow_statement.revolver_repayment == pytest.approx(0.0, abs=TOL) + assert p1.cash_flow_statement.financing_activities == pytest.approx(-DIV_1, abs=TOL) + assert p1.cash_flow_statement.net_change_in_cash == pytest.approx(CFBF_1, abs=TOL) + + # --- Period 1 balance sheet --- + assert p1.balance_sheet.cash == pytest.approx(CASH_1, abs=TOL) + assert p1.balance_sheet.net_working_capital == pytest.approx(NWC_END_1, abs=TOL) + assert p1.balance_sheet.ppe == pytest.approx(PPE_1, abs=TOL) + assert p1.balance_sheet.revolver_balance == pytest.approx(DEBT_0, abs=TOL) + assert p1.balance_sheet.paid_in_capital == pytest.approx(PAID_IN, abs=TOL) + assert p1.balance_sheet.retained_earnings == pytest.approx(RE_1, abs=TOL) + assert p1.converged is True + _assert_balances(p1.balance_sheet) + + # --- Period 2 income statement --- + assert p2.income_statement.revenue == pytest.approx(REVENUE_2, abs=TOL) + assert p2.income_statement.gross_profit == pytest.approx(GROSS_PROFIT_2, abs=TOL) + assert p2.income_statement.opex == pytest.approx(OPEX_2, abs=TOL) + assert p2.income_statement.ebitda == pytest.approx(EBITDA_2, abs=TOL) + assert p2.income_statement.depreciation_amortization == pytest.approx(DA[1], abs=TOL) + assert p2.income_statement.ebit == pytest.approx(EBIT_2, abs=TOL) + assert p2.income_statement.interest_expense == pytest.approx(INTEREST_2, abs=TOL) + assert p2.income_statement.pretax_income == pytest.approx(PRETAX_2, abs=TOL) + assert p2.income_statement.tax_expense == pytest.approx(TAX_2, abs=TOL) + assert p2.income_statement.net_income == pytest.approx(NI_2, abs=TOL) + + # --- Period 2 cash flow statement --- + assert p2.cash_flow_statement.change_in_net_working_capital == pytest.approx(D_NWC_2, abs=TOL) + assert p2.cash_flow_statement.capex == pytest.approx(CAPEX_2, abs=TOL) + assert p2.cash_flow_statement.dividends_paid == pytest.approx(DIV_2, abs=TOL) + assert p2.cash_flow_statement.revolver_draw == pytest.approx(0.0, abs=TOL) + assert p2.cash_flow_statement.revolver_repayment == pytest.approx(0.0, abs=TOL) + assert p2.cash_flow_statement.financing_activities == pytest.approx(-DIV_2, abs=TOL) + assert p2.cash_flow_statement.net_change_in_cash == pytest.approx(CFBF_2, abs=TOL) + + # --- Period 2 balance sheet --- + assert p2.balance_sheet.cash == pytest.approx(CASH_2, abs=TOL) + assert p2.balance_sheet.net_working_capital == pytest.approx(NWC_END_2, abs=TOL) + assert p2.balance_sheet.ppe == pytest.approx(PPE_2, abs=TOL) + assert p2.balance_sheet.revolver_balance == pytest.approx(DEBT_0, abs=TOL) + assert p2.balance_sheet.retained_earnings == pytest.approx(RE_2, abs=TOL) + assert p2.converged is True + _assert_balances(p2.balance_sheet) + + # --- Period 3 income statement --- + assert p3.income_statement.revenue == pytest.approx(REVENUE_3, abs=TOL) + assert p3.income_statement.gross_profit == pytest.approx(GROSS_PROFIT_3, abs=TOL) + assert p3.income_statement.opex == pytest.approx(OPEX_3, abs=TOL) + assert p3.income_statement.ebitda == pytest.approx(EBITDA_3, abs=TOL) + assert p3.income_statement.depreciation_amortization == pytest.approx(DA[2], abs=TOL) + assert p3.income_statement.ebit == pytest.approx(EBIT_3, abs=TOL) + assert p3.income_statement.interest_expense == pytest.approx(INTEREST_3, abs=TOL) + assert p3.income_statement.pretax_income == pytest.approx(PRETAX_3, abs=TOL) + assert p3.income_statement.tax_expense == pytest.approx(TAX_3, abs=TOL) + assert p3.income_statement.net_income == pytest.approx(NI_3, abs=TOL) + + # --- Period 3 cash flow statement (note the NWC *source* of cash) --- + assert p3.cash_flow_statement.change_in_net_working_capital == pytest.approx(D_NWC_3, abs=TOL) + assert D_NWC_3 < 0.0 + assert p3.cash_flow_statement.capex == pytest.approx(CAPEX_3, abs=TOL) + assert p3.cash_flow_statement.dividends_paid == pytest.approx(DIV_3, abs=TOL) + assert p3.cash_flow_statement.revolver_draw == pytest.approx(0.0, abs=TOL) + assert p3.cash_flow_statement.revolver_repayment == pytest.approx(0.0, abs=TOL) + assert p3.cash_flow_statement.financing_activities == pytest.approx(-DIV_3, abs=TOL) + assert p3.cash_flow_statement.net_change_in_cash == pytest.approx(CFBF_3, abs=TOL) + + # --- Period 3 balance sheet --- + assert p3.balance_sheet.cash == pytest.approx(CASH_3, abs=TOL) + assert p3.balance_sheet.net_working_capital == pytest.approx(NWC_END_3, abs=TOL) + assert p3.balance_sheet.ppe == pytest.approx(PPE_3, abs=TOL) + assert p3.balance_sheet.revolver_balance == pytest.approx(DEBT_0, abs=TOL) + assert p3.balance_sheet.paid_in_capital == pytest.approx(PAID_IN, abs=TOL) + assert p3.balance_sheet.retained_earnings == pytest.approx(RE_3, abs=TOL) + assert p3.converged is True + _assert_balances(p3.balance_sheet) + + # All three periods balance -- assets == liabilities + equity, exactly. + for period_result in result.periods: + bs = period_result.balance_sheet + assert bs.total_assets == pytest.approx(bs.total_liabilities + bs.total_equity, abs=TOL) + + +# -------------------------------------------------------------------------- +# Hard balance check: must raise on a genuinely unbalanced input, not just +# stay quiet on a balanced one. +# -------------------------------------------------------------------------- + + +def test_check_balance_sheet_raises_on_broken_retained_earnings(): + """A balance sheet whose retained-earnings rollforward was hand-broken must raise. + + Assets = 1,000 + 200 + 3,000 = 4,200. Liabilities + equity = + 1,000 + 2,000 + 1,000 = 4,000. The 200 gap is a deliberately wrong + retained_earnings (it should have been 1,200 to balance). + """ + broken = BalanceSheet( + period=1, + cash=1_000.0, + net_working_capital=200.0, + ppe=3_000.0, + revolver_balance=1_000.0, + paid_in_capital=2_000.0, + retained_earnings=1_000.0, # should be 1,200.0 to balance + ) + with pytest.raises(BalanceSheetError) as excinfo: + check_balance_sheet(broken) + assert excinfo.value.residual == pytest.approx(200.0, abs=TOL) + assert "200" in str(excinfo.value) + assert excinfo.value.period == 1 + + +def test_check_balance_sheet_does_not_raise_when_balanced(): + """The same construction, with the correct retained earnings, must not raise.""" + balanced = BalanceSheet( + period=1, + cash=1_000.0, + net_working_capital=200.0, + ppe=3_000.0, + revolver_balance=1_000.0, + paid_in_capital=2_000.0, + retained_earnings=1_200.0, + ) + check_balance_sheet(balanced) # must not raise + + +def test_project_three_statement_raises_on_broken_opening_balance_sheet(): + """An opening balance sheet that does not balance must refuse to project at all. + + Reuses the flagship OPENING dict but bumps retained_earnings by 1.0, which + breaks the identity by exactly 1.0 (residual = -1.0, since equity is now + 1.0 too large). + """ + broken_opening = dict(OPENING) + broken_opening["retained_earnings"] = OPENING["retained_earnings"] + 1.0 + with pytest.raises(BalanceSheetError) as excinfo: + project_three_statement(broken_opening, DRIVERS) + assert excinfo.value.period == 0 + assert excinfo.value.residual == pytest.approx(-1.0, abs=TOL) + + +# -------------------------------------------------------------------------- +# Interest/revolver circularity: convergent (multi-iteration) and divergent. +# -------------------------------------------------------------------------- + +_CIRC_OPENING = { + "revenue": 1_000_000.0, + "cash": 50_000.0, + "net_working_capital": 100_000.0, + "ppe": 500_000.0, + "revolver_balance": 200_000.0, + "paid_in_capital": 300_000.0, + "retained_earnings": 150_000.0, +} +_CIRC_DRIVERS = { + "revenue_growth": [0.05], + "gross_margin": [0.35], + "opex_pct_revenue": [0.20], + "capex_pct_revenue": [0.06], + "nwc_pct_revenue": [0.12], + "tax_rate": [0.25], + "dividend_payout_ratio": [0.20], + "depreciation_amortization": [40_000.0], + "interest_rate": [0.20], # 20% -- forces a real draw, hence real circularity + "minimum_cash": [300_000.0], # well above the natural cash balance -> shortfall +} + + +def test_circularity_converges_in_more_than_one_iteration(): + """A real cash shortfall forces the interest/revolver fixed point to iterate. + + The initial guess (no revolver movement) is not the fixed point here -- + minimum_cash is set far above the cash the company would otherwise land + on, so the first pass computes a large draw, which changes the average + debt balance, which changes interest, which changes net income and hence + the draw again. The solve must still converge and say so. + """ + result = project_three_statement(_CIRC_OPENING, _CIRC_DRIVERS) + period = result.periods[0] + assert period.converged is True + assert period.iterations > 1 + assert period.cash_flow_statement.revolver_draw > 0.0 + _assert_balances(period.balance_sheet) + + +def test_divergent_circularity_raises_convergence_error(): + """A loop gain above 1 makes the fixed point explode; the model must refuse to answer. + + With dividend_payout_ratio=0 and tax_rate=0, the fixed-point gain on the + ending debt balance is ``interest_rate / 2`` (see the module docstring's + derivation). At interest_rate=5.0 (500%) that gain is 2.5, comfortably + above 1: each iteration's debt guess moves *further* from the last, not + closer, so no finite iteration budget converges it. + """ + diverging_opening = dict(_CIRC_OPENING) + diverging_drivers = dict(_CIRC_DRIVERS) + diverging_drivers["interest_rate"] = [5.0] + diverging_drivers["dividend_payout_ratio"] = [0.0] + diverging_drivers["tax_rate"] = [0.0] + diverging_drivers["minimum_cash"] = [10_000_000.0] # stays in the shortfall regime throughout + + with pytest.raises(ConvergenceError) as excinfo: + project_three_statement(diverging_opening, diverging_drivers) + assert excinfo.value.iterations == MAX_CIRCULARITY_ITERATIONS + assert excinfo.value.period == 1 + # A genuinely diverging (not just slowly converging) loop leaves the last + # step's change many orders of magnitude larger than the tolerance. + assert excinfo.value.last_delta > 1e30 + + +# -------------------------------------------------------------------------- +# Explicit plug: the revolver balance is a visible, reported field, and the +# balance sheet still balances exactly once it has been applied. +# -------------------------------------------------------------------------- + + +def test_revolver_plug_is_explicit_and_balance_sheet_still_balances(): + """The revolver draw and the resulting balance are visible result fields.""" + result = project_three_statement(_CIRC_OPENING, _CIRC_DRIVERS) + period = result.periods[0] + + # The plug is not hidden inside a residual -- it is a named field on both + # the cash flow statement (the movement) and the balance sheet (the level). + assert period.cash_flow_statement.revolver_draw > 0.0 + assert period.cash_flow_statement.revolver_repayment == 0.0 + expected_balance = _CIRC_OPENING["revolver_balance"] + period.cash_flow_statement.revolver_draw + assert period.balance_sheet.revolver_balance == pytest.approx(expected_balance, abs=TOL) + + # Cash was brought exactly up to the minimum_cash floor by the draw. + assert period.balance_sheet.cash == pytest.approx(_CIRC_DRIVERS["minimum_cash"][0], abs=TOL) + + _assert_balances(period.balance_sheet) + + +# -------------------------------------------------------------------------- +# Missing inputs: no defaults, ever. +# -------------------------------------------------------------------------- + + +def test_missing_opening_field_raises_missing_input_error(): + """Dropping a required opening-balance field refuses to run, naming the field.""" + incomplete_opening = dict(OPENING) + del incomplete_opening["retained_earnings"] + with pytest.raises(MissingInputError) as excinfo: + project_three_statement(incomplete_opening, DRIVERS) + assert excinfo.value.missing == ("retained_earnings",) + + +def test_missing_driver_field_raises_missing_input_error(): + """Dropping a required driver sequence refuses to run, naming the field. + + ``tax_rate`` is deliberately chosen: it is downstream of every other line + in the income statement, so if the model silently defaulted it, the + resulting net income would look plausible rather than obviously wrong. + """ + incomplete_drivers = dict(DRIVERS) + del incomplete_drivers["tax_rate"] + with pytest.raises(MissingInputError) as excinfo: + project_three_statement(OPENING, incomplete_drivers) + assert excinfo.value.missing == ("tax_rate",) + + +def test_missing_multiple_fields_are_all_named_at_once(): + """MissingInputError reports every missing field in one pass, not just the first.""" + incomplete_drivers = dict(DRIVERS) + del incomplete_drivers["tax_rate"] + del incomplete_drivers["interest_rate"] + with pytest.raises(MissingInputError) as excinfo: + project_three_statement(OPENING, incomplete_drivers) + assert set(excinfo.value.missing) == {"tax_rate", "interest_rate"} + + +# -------------------------------------------------------------------------- +# Structural validation beyond missingness: mismatched / empty driver lengths. +# -------------------------------------------------------------------------- + + +def test_mismatched_driver_lengths_raise(): + """Driver sequences of different lengths cannot be zipped into periods safely.""" + mismatched = dict(DRIVERS) + mismatched["tax_rate"] = [0.25, 0.25] # length 2 vs. 3 everywhere else + with pytest.raises(Exception, match=r"(?i)length"): + project_three_statement(OPENING, mismatched) + + +def test_empty_driver_sequences_raise(): + """Zero-length driver sequences mean zero periods, which is not a projection.""" + empty_drivers = {field: [] for field in DRIVERS} + with pytest.raises(Exception, match=r"(?i)period"): + project_three_statement(OPENING, empty_drivers) + + +# -------------------------------------------------------------------------- +# Boundary conditions. +# -------------------------------------------------------------------------- + + +def test_zero_revenue_boundary(): + """A pre-revenue company still produces a linked, balanced projection. + + With revenue pinned at zero, every revenue-scaled line (gross profit, + opex, capex, NWC) is zero too, but D&A is a direct dollar input and keeps + accruing, so EBIT, pretax income and net income are all driven purely + negative by D&A -- and the model must not divide by revenue anywhere to + get there. + """ + opening = { + "revenue": 0.0, + "cash": 100_000.0, + "net_working_capital": 0.0, + "ppe": 200_000.0, + "revolver_balance": 0.0, + "paid_in_capital": 250_000.0, + "retained_earnings": 50_000.0, + } + drivers = { + "revenue_growth": [0.0, 0.0], + "gross_margin": [0.40, 0.40], + "opex_pct_revenue": [0.20, 0.20], + "capex_pct_revenue": [0.0, 0.0], + "nwc_pct_revenue": [0.10, 0.10], + "tax_rate": [0.25, 0.25], + "dividend_payout_ratio": [0.5, 0.5], + "depreciation_amortization": [10_000.0, 10_000.0], + "interest_rate": [0.05, 0.05], + "minimum_cash": [0.0, 0.0], + } + result = project_three_statement(opening, drivers) + for period in result.periods: + assert period.income_statement.revenue == 0.0 + assert period.income_statement.gross_profit == 0.0 + assert period.income_statement.opex == 0.0 + assert period.income_statement.ebitda == 0.0 + assert period.income_statement.ebit == pytest.approx(-10_000.0, abs=TOL) + assert period.income_statement.net_income == pytest.approx(-7_500.0, abs=TOL) + assert period.cash_flow_statement.capex == 0.0 + assert period.balance_sheet.net_working_capital == 0.0 + _assert_balances(period.balance_sheet) + + +def test_negative_net_income_drives_retained_earnings_negative(): + """A sustained loss must be allowed to push retained earnings below zero. + + Opex is set to 50% of revenue against a 10% gross margin, guaranteeing a + large operating loss; the payout clamp means loss periods pay no dividend + (max(net_income, 0) * payout == 0), so the loss flows straight through to + retained earnings. + """ + opening = { + "revenue": 100_000.0, + "cash": 50_000.0, + "net_working_capital": 10_000.0, + "ppe": 100_000.0, + "revolver_balance": 0.0, + "paid_in_capital": 155_000.0, + "retained_earnings": 5_000.0, + } + drivers = { + "revenue_growth": [0.0, 0.0], + "gross_margin": [0.10, 0.10], + "opex_pct_revenue": [0.50, 0.50], + "capex_pct_revenue": [0.0, 0.0], + "nwc_pct_revenue": [0.10, 0.10], + "tax_rate": [0.25, 0.25], + "dividend_payout_ratio": [0.3, 0.3], + "depreciation_amortization": [5_000.0, 5_000.0], + "interest_rate": [0.05, 0.05], + "minimum_cash": [0.0, 0.0], + } + result = project_three_statement(opening, drivers) + period1 = result.periods[0] + assert period1.income_statement.net_income < 0.0 + assert period1.cash_flow_statement.dividends_paid == 0.0 + assert period1.balance_sheet.retained_earnings < 0.0 + _assert_balances(period1.balance_sheet) + + period2 = result.periods[1] + assert period2.balance_sheet.retained_earnings < period1.balance_sheet.retained_earnings + _assert_balances(period2.balance_sheet) + + +def test_capex_exceeding_depreciation_grows_ppe(): + """When capex outpaces D&A, net PP&E must increase by exactly the difference.""" + opening = { + "revenue": 100_000.0, + "cash": 100_000.0, + "net_working_capital": 10_000.0, + "ppe": 50_000.0, + "revolver_balance": 0.0, + "paid_in_capital": 100_000.0, + "retained_earnings": 60_000.0, + } + drivers = { + "revenue_growth": [0.0], + "gross_margin": [0.40], + "opex_pct_revenue": [0.20], + "capex_pct_revenue": [0.10], # capex = 10,000 + "nwc_pct_revenue": [0.10], + "tax_rate": [0.25], + "dividend_payout_ratio": [0.0], + "depreciation_amortization": [2_000.0], # da = 2,000 << capex + "interest_rate": [0.0], + "minimum_cash": [0.0], + } + result = project_three_statement(opening, drivers) + period = result.periods[0] + assert period.cash_flow_statement.capex == pytest.approx(10_000.0, abs=TOL) + assert period.income_statement.depreciation_amortization == pytest.approx(2_000.0, abs=TOL) + assert period.balance_sheet.ppe == pytest.approx(opening["ppe"] + 10_000.0 - 2_000.0, abs=TOL) + assert period.balance_sheet.ppe > opening["ppe"] + _assert_balances(period.balance_sheet) diff --git a/agent/tests/test_entities_spine.py b/agent/tests/test_entities_spine.py index 46e291be..27e26fc2 100644 --- a/agent/tests/test_entities_spine.py +++ b/agent/tests/test_entities_spine.py @@ -14,6 +14,8 @@ from src.entities.cashflow import ( normalize_kind, ) from src.entities.ingest import CashFlowIngestError, load_cashflows +from src.entities.cashflow import FxRate, FxRateTable, MissingExchangeRateError, translate_cashflows +from src.entities.ingest import EntityPanel, PanelIngestError, PanelObservation, load_panel from src.entities.models import ( Bond, Entity, @@ -671,3 +673,382 @@ def test_series_is_not_a_dataframe_and_exposes_no_bar_fields(): series = CashFlowSeries(flows=(_flow(1, 100.0, kind="nav"),)) assert not hasattr(series, "close") assert not hasattr(series[0], "close") + + +# --------------------------------------------------------------------------- +# FX translation +# --------------------------------------------------------------------------- + + +def test_translate_cashflows_hand_calculated_multi_currency(): + """Two flows, different currency and date, checked against a hand calc.""" + flow_eur = CashFlow( + date=date(2024, 1, 10), amount=1000.0, kind="distribution", currency="EUR" + ) + flow_jpy = CashFlow( + date=date(2024, 2, 15), amount=-2000.0, kind="capital_call", currency="JPY" + ) + table = FxRateTable.from_rates( + [ + FxRate(base_currency="EUR", quote_currency="USD", date=date(2024, 1, 10), rate=1.10), + FxRate(base_currency="JPY", quote_currency="USD", date=date(2024, 2, 15), rate=0.0067), + ], + quote_currency="USD", + ) + series = translate_cashflows([flow_eur, flow_jpy], table) + assert series.currency == "USD" + assert series.pre_translated is True + # Hand calc: 1000 EUR * 1.10 = 1100.0 USD; -2000 JPY * 0.0067 = -13.4 USD + amounts = {f.metadata["fx_original_currency"]: f.amount for f in series} + assert amounts["EUR"] == pytest.approx(1100.0, abs=1e-9) + assert amounts["JPY"] == pytest.approx(-13.4, abs=1e-9) + assert series.total() == pytest.approx(1100.0 - 13.4, abs=1e-9) + + +def test_translate_cashflows_uses_each_flows_own_settlement_date_rate_not_period_end(): + """A period-end rate would silently give 200.0 instead of the correct 100.0.""" + table = FxRateTable.from_rates( + [ + FxRate(base_currency="EUR", quote_currency="USD", date=date(2024, 1, 1), rate=1.0), + FxRate(base_currency="EUR", quote_currency="USD", date=date(2024, 1, 10), rate=2.0), + ], + quote_currency="USD", + ) + early_flow = CashFlow(date=date(2024, 1, 1), amount=100.0, kind="coupon", currency="EUR") + series = translate_cashflows([early_flow], table) + assert series[0].amount == pytest.approx(100.0, abs=1e-9) + assert series[0].amount != pytest.approx(200.0, abs=1e-9) + assert series[0].metadata["fx_rate_date"] == date(2024, 1, 1) + + +def test_fx_rate_table_rejects_entry_quoted_against_the_wrong_currency(): + """A reversed-direction quote (base/quote swapped) cannot enter a table + declared for the other currency -- caught at construction, not silently + used in the wrong direction. + """ + reversed_quote = FxRate( + base_currency="USD", quote_currency="EUR", date=date(2024, 1, 10), rate=0.91 + ) + with pytest.raises(ValueError, match="does not quote against"): + FxRateTable(quote_currency="USD", rates={("EUR", date(2024, 1, 10)): reversed_quote}) + + +def test_translate_cashflows_reversed_rate_produces_a_grossly_wrong_number(): + """A confused caller who uses the reciprocal (150) instead of the correct + quote-per-base rate (1/150) does not get a subtly-off number -- they get + one nobody could mistake for correct. + """ + flow = CashFlow(date=date(2024, 1, 1), amount=1_000_000.0, kind="distribution", currency="JPY") + + correct_rate = 1 / 150 # 1 JPY = 1/150 USD + correct_table = FxRateTable.from_rates( + [FxRate(base_currency="JPY", quote_currency="USD", date=date(2024, 1, 1), rate=correct_rate)], + quote_currency="USD", + ) + correct_amount = translate_cashflows([flow], correct_table)[0].amount + assert correct_amount == pytest.approx(1_000_000.0 / 150, abs=1e-6) + + reversed_table = FxRateTable.from_rates( + [FxRate(base_currency="JPY", quote_currency="USD", date=date(2024, 1, 1), rate=150.0)], + quote_currency="USD", + ) + reversed_amount = translate_cashflows([flow], reversed_table)[0].amount + assert reversed_amount == pytest.approx(1_000_000.0 * 150, abs=1e-6) + assert reversed_amount / correct_amount > 20_000 # off by 22,500x + + +def test_translate_cashflows_missing_rate_raises_explicit_error(): + flow = CashFlow(date=date(2024, 1, 10), amount=1000.0, kind="distribution", currency="EUR") + table = FxRateTable(quote_currency="USD") + with pytest.raises(MissingExchangeRateError, match="no EUR/USD rate"): + translate_cashflows([flow], table) + + +def test_translate_cashflows_allow_stale_rates_flags_the_reused_quote(): + table = FxRateTable.from_rates( + [FxRate(base_currency="EUR", quote_currency="USD", date=date(2024, 1, 1), rate=1.1)], + quote_currency="USD", + ) + flow = CashFlow(date=date(2024, 1, 20), amount=100.0, kind="coupon", currency="EUR") + with pytest.raises(MissingExchangeRateError): + translate_cashflows([flow], table) # off by default + + series = translate_cashflows([flow], table, allow_stale_rates=True) + result = series[0] + assert result.amount == pytest.approx(110.0, abs=1e-9) + assert result.metadata["fx_rate_is_stale"] is True + assert result.metadata["fx_rate_date"] == date(2024, 1, 1) + + +def test_translate_cashflows_max_staleness_days_bounds_the_stale_search(): + table = FxRateTable.from_rates( + [FxRate(base_currency="EUR", quote_currency="USD", date=date(2024, 1, 1), rate=1.1)], + quote_currency="USD", + ) + flow = CashFlow(date=date(2024, 1, 20), amount=100.0, kind="coupon", currency="EUR") + with pytest.raises(MissingExchangeRateError, match="within 5 days"): + translate_cashflows([flow], table, allow_stale_rates=True, max_staleness_days=5) + + series = translate_cashflows([flow], table, allow_stale_rates=True, max_staleness_days=30) + assert series[0].metadata["fx_rate_is_stale"] is True + + +def test_fx_rate_rejects_non_positive_rate(): + with pytest.raises(ValueError, match="strictly positive"): + FxRate(base_currency="EUR", quote_currency="USD", date=date(2024, 1, 1), rate=0.0) + with pytest.raises(ValueError, match="strictly positive"): + FxRate(base_currency="EUR", quote_currency="USD", date=date(2024, 1, 1), rate=-1.5) + + +def test_fx_rate_table_from_mapping_builds_a_table(): + table = FxRateTable.from_mapping( + {("EUR", date(2024, 1, 1)): 1.1, ("JPY", date(2024, 1, 1)): 0.0067}, + quote_currency="USD", + ) + rate, is_stale = table.get_rate("eur", date(2024, 1, 1)) + assert rate.rate == pytest.approx(1.1) + assert is_stale is False + + +def test_translate_cashflows_preserves_original_amount_currency_rate_and_date(): + flow = CashFlow( + date=date(2024, 1, 10), + amount=1000.0, + kind="distribution", + currency="EUR", + metadata={"note": "Q1 distribution"}, + ) + table = FxRateTable.from_rates( + [FxRate(base_currency="EUR", quote_currency="USD", date=date(2024, 1, 10), rate=1.1)], + quote_currency="USD", + ) + translated = translate_cashflows([flow], table)[0] + assert translated.metadata["fx_original_amount"] == 1000.0 + assert translated.metadata["fx_original_currency"] == "EUR" + assert translated.metadata["fx_rate"] == pytest.approx(1.1) + assert translated.metadata["fx_rate_date"] == date(2024, 1, 10) + assert translated.metadata["fx_rate_is_stale"] is False + assert translated.metadata["note"] == "Q1 distribution" # original metadata kept too + + +def test_translate_cashflows_same_currency_flow_passes_through_unchanged(): + flow = CashFlow(date=date(2024, 1, 10), amount=250.0, kind="coupon", currency="USD") + table = FxRateTable(quote_currency="USD") + translated = translate_cashflows([flow], table)[0] + assert translated.amount == pytest.approx(250.0) + assert translated.metadata["fx_rate"] == 1.0 + assert translated.metadata["fx_rate_is_stale"] is False + + +# --------------------------------------------------------------------------- +# Panel ingestion (load_panel) +# --------------------------------------------------------------------------- + + +def test_load_panel_long_layout_reads_a_fixture_csv(tmp_path): + path = _write_csv( + tmp_path, + "panel.csv", + "entity,date,metric,value,currency,unit\n" + "FUNDA,2024-01-01,nav,100.5,USD,USD\n" + "FUNDA,2024-02-01,nav,101.2,USD,USD\n" + "FUNDB,2024-01-01,nav,50.0,EUR,EUR\n", + ) + panel = load_panel(path) + assert len(panel) == 3 + assert panel.entities() == ("FUNDA", "FUNDB") + assert panel.metrics() == ("nav",) + observations = list(panel) + assert observations[0].entity_id == "FUNDA" + assert observations[0].date == date(2024, 1, 1) + assert observations[0].value == pytest.approx(100.5) + assert observations[0].currency == "USD" + assert observations[0].unit == "USD" + + +def test_load_panel_infers_wide_dates_rows_layout_and_skips_holes(tmp_path): + path = _write_csv( + tmp_path, + "wide.csv", + "date,FUNDA,FUNDB\n2024-01-01,100.5,50.0\n2024-02-01,101.2,\n", + ) + panel = load_panel(path, metric="nav", currency="USD", unit="USD") + assert len(panel) == 3 # the blank FUNDB@2024-02-01 cell produced no observation + assert panel.entities() == ("FUNDA", "FUNDB") + values = {(o.entity_id, o.date): o.value for o in panel} + assert values[("FUNDA", date(2024, 1, 1))] == pytest.approx(100.5) + assert ("FUNDB", date(2024, 2, 1)) not in values + + +def test_load_panel_infers_wide_entities_rows_layout(tmp_path): + path = _write_csv( + tmp_path, + "wide.csv", + "entity,2024-01-01,2024-02-01\nFUNDA,100.5,101.2\nFUNDB,50.0,\n", + ) + panel = load_panel(path, metric="nav", currency="USD", unit="USD") + assert len(panel) == 3 + values = {(o.entity_id, o.date): o.value for o in panel} + assert values[("FUNDA", date(2024, 2, 1))] == pytest.approx(101.2) + assert ("FUNDB", date(2024, 2, 1)) not in values + + +def test_load_panel_missing_value_column_raises_not_none(tmp_path): + path = _write_csv( + tmp_path, "panel.csv", "entity,date,metric,currency,unit\nFUNDA,2024-01-01,nav,USD,USD\n" + ) + with pytest.raises(PanelIngestError, match="value"): + load_panel(path) + + +def test_load_panel_missing_currency_is_an_error_not_a_default(tmp_path): + path = _write_csv( + tmp_path, "panel.csv", "entity,date,metric,value,unit\nFUNDA,2024-01-01,nav,100,USD\n" + ) + with pytest.raises(PanelIngestError, match="currency"): + load_panel(path) + + +def test_load_panel_missing_unit_is_an_error_not_a_default(tmp_path): + path = _write_csv( + tmp_path, "panel.csv", "entity,date,metric,value,currency\nFUNDA,2024-01-01,nav,100,USD\n" + ) + with pytest.raises(PanelIngestError, match="unit"): + load_panel(path) + + +def test_load_panel_ambiguous_header_raises_and_explicit_layout_resolves_it(tmp_path): + """A header that matches no recognised shape must not be silently guessed.""" + path = _write_csv(tmp_path, "panel.csv", "foo,bar,baz\nFUNDA,2024-01-01,100\n") + with pytest.raises(PanelIngestError, match="could not infer a panel layout"): + load_panel(path) + + panel = load_panel( + path, + layout="long", + columns={"entity": "foo", "date": "bar", "value": "baz"}, + metric="nav", + currency="USD", + unit="USD", + ) + assert len(panel) == 1 + obs = list(panel)[0] + assert obs.entity_id == "FUNDA" + assert obs.value == pytest.approx(100.0) + + +def test_load_panel_empty_file_raises_not_none(tmp_path): + path = _write_csv(tmp_path, "empty.csv", "") + with pytest.raises(PanelIngestError, match="no header row"): + load_panel(path) + + +def test_load_panel_header_only_long_file_yields_an_empty_panel(tmp_path): + path = _write_csv(tmp_path, "panel.csv", "entity,date,metric,value,currency,unit\n") + panel = load_panel(path) + assert panel.is_empty + + +def test_load_panel_wide_layout_requires_metric_currency_and_unit(tmp_path): + path = _write_csv(tmp_path, "wide.csv", "date,FUNDA,FUNDB\n2024-01-01,100.5,50.0\n") + with pytest.raises(PanelIngestError, match="metric"): + load_panel(path, currency="USD", unit="USD") + with pytest.raises(PanelIngestError, match="currency"): + load_panel(path, metric="nav", unit="USD") + with pytest.raises(PanelIngestError, match="unit"): + load_panel(path, metric="nav", currency="USD") + + +def test_load_panel_wide_entities_rows_bad_date_header_raises(tmp_path): + path = _write_csv( + tmp_path, "wide.csv", "entity,2024-01-01,not-a-date\nFUNDA,100.5,101.2\n" + ) + with pytest.raises(PanelIngestError, match="not a usable date"): + load_panel(path, layout="wide_entities_rows", metric="nav", currency="USD", unit="USD") + + +def test_load_panel_explicit_column_mapping(tmp_path): + path = _write_csv( + tmp_path, + "panel.csv", + "Fund,AsOf,Field,Net,CCY,UOM\nFUNDA,2024-01-01,nav,100.5,USD,USD\n", + ) + panel = load_panel( + path, + layout="long", + columns={ + "entity": "Fund", + "date": "AsOf", + "metric": "Field", + "value": "Net", + "currency": "CCY", + "unit": "UOM", + }, + ) + assert len(panel) == 1 + assert list(panel)[0].value == pytest.approx(100.5) + + +def test_load_panel_metric_is_normalized(tmp_path): + path = _write_csv( + tmp_path, + "panel.csv", + "entity,date,metric,value,currency,unit\nFUNDA,2024-01-01,NAV,100,USD,USD\n", + ) + panel = load_panel(path) + assert list(panel)[0].metric == "nav" + + +def test_entity_panel_is_immutable_and_ordered(tmp_path): + path = _write_csv( + tmp_path, + "panel.csv", + "entity,date,metric,value,currency,unit\n" + "FUNDB,2024-02-01,nav,1,USD,USD\n" + "FUNDA,2024-01-01,nav,2,USD,USD\n", + ) + panel = load_panel(path) + entities_in_order = [o.entity_id for o in panel] + assert entities_in_order == ["FUNDA", "FUNDB"] + with pytest.raises(dataclasses.FrozenInstanceError): + panel.observations = () + + +def test_load_panel_rejects_an_invalid_decimal_separator(tmp_path): + path = _write_csv( + tmp_path, + "panel.csv", + "entity,date,metric,value,currency,unit\nFUNDA,2024-01-01,nav,100,USD,USD\n", + ) + with pytest.raises(ValueError, match="decimal_separator must be"): + load_panel(path, decimal_separator=";") + + +def test_load_panel_rejects_an_invalid_layout_value(tmp_path): + path = _write_csv( + tmp_path, + "panel.csv", + "entity,date,metric,value,currency,unit\nFUNDA,2024-01-01,nav,100,USD,USD\n", + ) + with pytest.raises(PanelIngestError, match="layout must be one of"): + load_panel(path, layout="sideways") + + +def test_panel_observation_is_not_a_bar_and_exposes_no_ohlc_fields(): + obs = PanelObservation( + entity_id="FUNDA", date=date(2024, 1, 1), metric="nav", value=100.0, + currency="USD", unit="USD", + ) + assert not hasattr(obs, "close") + assert not hasattr(obs, "open") + + +def test_panel_path_does_not_touch_the_bar_price_panel_gate(): + """Same architectural guarantee as CashFlowSeries: the panel ingest path + must never widen what a bar engine will accept as a price column. + """ + from backtest.runner import _PRICE_PANEL_COLUMNS, _VALID_INTERVALS + + assert _PRICE_PANEL_COLUMNS == ("open", "high", "low", "close", "volume", "vwap", "amount") + assert "nav" not in _PRICE_PANEL_COLUMNS + assert _VALID_INTERVALS == {"1m", "5m", "15m", "30m", "1H", "4H", "1D"} diff --git a/agent/tests/test_factor_costs.py b/agent/tests/test_factor_costs.py new file mode 100644 index 00000000..651a2e02 --- /dev/null +++ b/agent/tests/test_factor_costs.py @@ -0,0 +1,303 @@ +"""Tests for backtest.factor_costs. + +The load-bearing test is the one showing a high-turnover factor is eroded and a +low-turnover one is not. A cost model that charges everyone equally, or charges +nobody, would pass a suite that only checked "cost >= 0". +""" + +import numpy as np +import pandas as pd +import pytest + +from backtest.factor_costs import ( + DEFAULT_MAX_PARTICIPATION, + MARKET_BORROW_RATES, + apply_adv_capacity, + borrow_cost, + rebalance_cost, +) + + +def _w(**kwargs): + return pd.Series(kwargs, dtype=float) + + +# --- ADV capacity --- + + +def test_a_trade_within_capacity_fills_completely(): + target = _w(A=0.10, B=-0.10) + current = _w(A=0.0, B=0.0) + adv = _w(A=1e9, B=1e9) + result = apply_adv_capacity(target, current, adv, capital=1e6) + + assert result.achieved_weights["A"] == pytest.approx(0.10) + assert result.achieved_weights["B"] == pytest.approx(-0.10) + assert result.unfilled_weights.abs().sum() == pytest.approx(0.0) + assert result.capped_symbols == () + + +def test_an_oversized_trade_moves_part_way_and_reports_the_shortfall(): + # ADV 1,000,000 at 10% participation = 100,000 tradeable; capital 1,000,000 + # so the largest weight change is 0.10. Asking for 0.30 gets 0.10. + target = _w(A=0.30) + current = _w(A=0.0) + adv = _w(A=1_000_000.0) + result = apply_adv_capacity(target, current, adv, capital=1_000_000.0) + + assert result.achieved_weights["A"] == pytest.approx(0.10) + assert result.unfilled_weights["A"] == pytest.approx(0.20) + assert result.capped_symbols == ("A",) + assert result.participation["A"] == pytest.approx(DEFAULT_MAX_PARTICIPATION) + + +def test_the_shortfall_is_neither_silently_filled_nor_silently_dropped(): + target = _w(A=0.30) + current = _w(A=0.0) + adv = _w(A=1_000_000.0) + result = apply_adv_capacity(target, current, adv, capital=1_000_000.0) + + # Not filled: achieved is short of target. + assert result.achieved_weights["A"] < result.requested_weights["A"] + # Not dropped: the difference is reported and reconciles exactly. + assert ( + result.achieved_weights["A"] + result.unfilled_weights["A"] + == pytest.approx(result.requested_weights["A"]) + ) + + +def test_a_name_with_unknown_volume_is_untradeable_not_infinitely_liquid(): + target = _w(A=0.20, B=0.20) + current = _w(A=0.0, B=0.0) + adv = _w(A=1e12) # B has no ADV at all + result = apply_adv_capacity(target, current, adv, capital=1e6) + + assert result.achieved_weights["A"] == pytest.approx(0.20) + assert result.achieved_weights["B"] == pytest.approx(0.0) + assert result.unfilled_weights["B"] == pytest.approx(0.20) + + +def test_zero_and_negative_adv_are_treated_as_untradeable(): + for bad in (0.0, -5.0): + result = apply_adv_capacity(_w(A=0.1), _w(A=0.0), _w(A=bad), capital=1e6) + assert result.achieved_weights["A"] == pytest.approx(0.0) + + +def test_capacity_caps_exits_as_well_as_entries(): + # Getting out is a trade too, and an illiquid name traps you. + target = _w(A=0.0) + current = _w(A=0.30) + adv = _w(A=1_000_000.0) + result = apply_adv_capacity(target, current, adv, capital=1_000_000.0) + + assert result.achieved_weights["A"] == pytest.approx(0.20) + assert result.capped_symbols == ("A",) + + +def test_names_only_in_one_vector_are_still_trades(): + result = apply_adv_capacity( + _w(NEW=0.1), _w(OLD=0.1), _w(NEW=1e12, OLD=1e12), capital=1e6 + ) + assert result.achieved_weights["NEW"] == pytest.approx(0.1) + assert result.achieved_weights["OLD"] == pytest.approx(0.0) + + +def test_higher_participation_allows_more_to_trade(): + args = (_w(A=0.30), _w(A=0.0), _w(A=1_000_000.0)) + tight = apply_adv_capacity(*args, capital=1_000_000.0, max_participation=0.05) + loose = apply_adv_capacity(*args, capital=1_000_000.0, max_participation=0.20) + assert loose.achieved_weights["A"] > tight.achieved_weights["A"] + + +@pytest.mark.parametrize("participation", [0.0, -0.1, 1.5]) +def test_bad_participation_rejected(participation): + with pytest.raises(ValueError, match="max_participation"): + apply_adv_capacity( + _w(A=0.1), _w(A=0.0), _w(A=1e9), capital=1e6, max_participation=participation + ) + + +def test_non_positive_capital_rejected(): + with pytest.raises(ValueError, match="capital"): + apply_adv_capacity(_w(A=0.1), _w(A=0.0), _w(A=1e9), capital=0.0) + + +# --- borrow --- + + +def test_only_the_short_leg_is_charged(): + longs_only = borrow_cost(_w(A=0.5, B=0.5), periods_per_year=252, annual_rate=0.03) + assert longs_only == 0.0 + + with_short = borrow_cost(_w(A=0.5, B=-0.5), periods_per_year=252, annual_rate=0.03) + assert with_short == pytest.approx(0.5 * 0.03 / 252) + + +def test_borrow_scales_with_short_exposure(): + small = borrow_cost(_w(A=-0.1), periods_per_year=252, annual_rate=0.05) + large = borrow_cost(_w(A=-0.4), periods_per_year=252, annual_rate=0.05) + assert large == pytest.approx(4 * small) + + +def test_periods_per_year_has_no_default_and_changes_the_charge(): + daily = borrow_cost(_w(A=-1.0), periods_per_year=252, annual_rate=0.05) + monthly = borrow_cost(_w(A=-1.0), periods_per_year=12, annual_rate=0.05) + assert monthly == pytest.approx(21 * daily) + with pytest.raises(TypeError): + borrow_cost(_w(A=-1.0), annual_rate=0.05) # type: ignore[call-arg] + + +def test_per_symbol_rates_are_honoured(): + cost = borrow_cost( + _w(EASY=-0.5, HARD=-0.5), + periods_per_year=252, + annual_rate=pd.Series({"EASY": 0.003, "HARD": 0.20}), + ) + assert cost == pytest.approx((0.5 * 0.003 + 0.5 * 0.20) / 252) + + +def test_a_shorted_symbol_with_no_rate_is_an_error_not_a_free_borrow(): + with pytest.raises(ValueError, match="missing a rate"): + borrow_cost( + _w(A=-0.5, B=-0.5), + periods_per_year=252, + annual_rate=pd.Series({"A": 0.01}), + ) + + +def test_market_fallback_uses_the_indicative_table(): + cost = borrow_cost(_w(A=-1.0), periods_per_year=252, market="CN") + assert cost == pytest.approx(MARKET_BORROW_RATES["CN"] / 252) + + +def test_a_shorting_book_with_no_rate_source_at_all_is_an_error(): + with pytest.raises(ValueError, match="supply annual_rate"): + borrow_cost(_w(A=-0.5), periods_per_year=252) + + +def test_unknown_market_rejected(): + with pytest.raises(ValueError, match="unknown market"): + borrow_cost(_w(A=-0.5), periods_per_year=252, market="MARS") + + +def test_negative_borrow_rate_rejected(): + with pytest.raises(ValueError, match="non-negative"): + borrow_cost(_w(A=-0.5), periods_per_year=252, annual_rate=-0.01) + + +# --- the whole point: turnover has to hurt --- + + +def test_a_high_turnover_factor_is_eroded_far_more_than_a_low_turnover_one(): + universe = [f"S{i:02d}" for i in range(20)] + adv = pd.Series(1e12, index=universe) # capacity is not the binding constraint + flat = pd.Series(0.0, index=universe) + + # Low turnover: the book barely moves. + low_target = flat.copy() + low_target.iloc[:10] = 0.10 + low_cost, _ = rebalance_cost( + low_target, low_target * 0.98, capital=1e8, periods_per_year=252, + adv_value=adv, impact_model="fixed", + ) + + # High turnover: the book flips entirely. + high_prev = flat.copy() + high_prev.iloc[:10] = 0.10 + high_target = flat.copy() + high_target.iloc[10:] = 0.10 + high_cost, _ = rebalance_cost( + high_target, high_prev, capital=1e8, periods_per_year=252, + adv_value=adv, impact_model="fixed", + ) + + assert high_cost.turnover > 20 * low_cost.turnover + assert high_cost.total_cost > 20 * low_cost.total_cost + + +def test_cost_is_a_positive_drag_never_a_signed_adjustment(): + universe = ["A", "B"] + result, _ = rebalance_cost( + pd.Series([0.5, -0.5], index=universe), + pd.Series([0.0, 0.0], index=universe), + capital=1e8, + periods_per_year=252, + adv_value=pd.Series(1e12, index=universe), + impact_model="fixed", + borrow_annual_rate=0.03, + ) + assert result.impact_cost > 0 + assert result.borrow_cost > 0 + assert result.total_cost == pytest.approx(result.impact_cost + result.borrow_cost) + + +def test_capacity_limits_show_up_as_unfilled_turnover(): + result, capacity = rebalance_cost( + _w(A=0.50), + _w(A=0.0), + capital=1_000_000.0, + periods_per_year=252, + adv_value=_w(A=1_000_000.0), + impact_model="fixed", + ) + assert result.turnover == pytest.approx(0.10) + assert result.unfilled_turnover == pytest.approx(0.40) + assert result.capped_symbols == ("A",) + assert capacity is not None + + +def test_no_adv_means_no_cap_and_that_is_an_assumption_of_infinite_liquidity(): + result, capacity = rebalance_cost( + _w(A=0.50), _w(A=0.0), capital=1_000.0, periods_per_year=252, + impact_model="fixed", + ) + assert capacity is None + assert result.unfilled_turnover == 0.0 + assert result.capped_symbols == () + + +def test_sqrt_model_needs_a_volatility(): + with pytest.raises(ValueError, match="needs a volatility"): + rebalance_cost( + _w(A=0.1), _w(A=0.0), capital=1e6, periods_per_year=252, + adv_value=_w(A=1e9), impact_model="sqrt", + ) + + +def test_sqrt_impact_grows_with_participation(): + args = dict(capital=1e8, periods_per_year=252, impact_model="sqrt", volatility=0.02) + thin, _ = rebalance_cost(_w(A=0.05), _w(A=0.0), adv_value=_w(A=1e8), **args) + thick, _ = rebalance_cost(_w(A=0.05), _w(A=0.0), adv_value=_w(A=1e12), **args) + # The same trade against a thinner book is a larger share of volume, so it + # costs more. + assert thin.impact_cost > thick.impact_cost + + +def test_unknown_impact_model_rejected(): + with pytest.raises(ValueError, match="impact_model"): + rebalance_cost( + _w(A=0.1), _w(A=0.0), capital=1e6, periods_per_year=252, + impact_model="magic", + ) + + +def test_zero_turnover_costs_nothing_but_borrow(): + held = _w(A=0.5, B=-0.5) + result, _ = rebalance_cost( + held, held, capital=1e8, periods_per_year=252, + adv_value=_w(A=1e12, B=1e12), impact_model="fixed", + borrow_annual_rate=0.03, + ) + assert result.turnover == pytest.approx(0.0) + assert result.impact_cost == pytest.approx(0.0) + assert result.borrow_cost > 0 + + +def test_borrow_is_charged_on_the_achieved_book_not_the_requested_one(): + # The cap stops the short from being fully established, so the borrow charge + # must be on what was actually shorted. + result, _ = rebalance_cost( + _w(A=-0.50), _w(A=0.0), capital=1_000_000.0, periods_per_year=252, + adv_value=_w(A=1_000_000.0), impact_model="fixed", borrow_annual_rate=0.10, + ) + assert result.borrow_cost == pytest.approx(0.10 * 0.10 / 252) diff --git a/agent/tests/test_governance.py b/agent/tests/test_governance.py new file mode 100644 index 00000000..1474705b --- /dev/null +++ b/agent/tests/test_governance.py @@ -0,0 +1,774 @@ +"""Tests for src/governance/ (run manifest + hash-chained ledger). + +Focus: manifest hashing is deterministic over composition and sensitive to +every composition dimension (system prompt / skills / tools / package +versions / extra) while being INSENSITIVE to run_id/timestamp; the ledger's +tamper detection is proven with negative (tampered/deleted) fixtures, not +just a happy-path pass; fsync is actually invoked on the write path; and the +pre-existing src/live/audit.py public contract keeps working unchanged. +""" + +from __future__ import annotations + +import ast +import inspect +import json +from pathlib import Path + +import pytest + +from src.governance import ledger as ledger_mod +from src.governance import manifest as manifest_mod +from src.governance.ledger import ( + ChainVerificationResult, + LedgerCorruptionError, + append_record, + build_export, + verify_chain, + verify_export, +) +from src.governance.manifest import ( + RunManifest, + build_run_manifest, + collect_key_package_versions, + diff_manifests, +) +from src.live import audit +from src.live import paths as live_paths +from src.live.audit import LiveActionEvent, write_live_action + + +# --------------------------------------------------------------------------- +# No hidden clock (source-level assertion, both new modules). +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("module", [manifest_mod, ledger_mod]) +def test_no_hidden_clock_in_source(module) -> None: + """Neither governance module may call datetime.now()/time.time() itself. + + Timestamps must always be caller-supplied (manifest) or simply absent + (ledger has no timestamp concept at all). A source-level AST walk is + used rather than a substring grep so a comment/docstring mentioning + "time.time()" cannot cause a false failure and, more importantly, an + actual call cannot hide behind reformatting. + """ + source = inspect.getsource(module) + tree = ast.parse(source) + offending: list[str] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + dotted = None + if isinstance(func, ast.Attribute): + if isinstance(func.value, ast.Name): + dotted = f"{func.value.id}.{func.attr}" + elif isinstance(func, ast.Name): + dotted = func.id + if dotted in {"datetime.now", "time.time", "now"}: + offending.append(dotted) + assert offending == [], f"{module.__name__} calls a hidden clock: {offending}" + + +# --------------------------------------------------------------------------- +# manifest.py +# --------------------------------------------------------------------------- + + +def _base_manifest_kwargs() -> dict: + return dict( + run_id="run-1", + timestamp="2026-08-06T00:00:00.000+00:00", + system_prompt="You are a trading research agent.", + skills={ + "tushare": "tushare skill body v1", + "risk-committee": "risk committee lens body v1", + }, + tool_names=["get_market_data", "backtest", "cashflow_performance"], + package_versions={"numpy": "2.4.4", "pandas": "2.3.3"}, + extra={"provider": "openrouter", "model": "deepseek/deepseek-v3.2"}, + ) + + +def test_manifest_hash_deterministic_for_same_composition() -> None: + """Building twice from identical composition yields the identical hash.""" + m1 = build_run_manifest(**_base_manifest_kwargs()) + m2 = build_run_manifest(**_base_manifest_kwargs()) + assert m1.manifest_hash == m2.manifest_hash + assert m1.manifest_hash.startswith("sha256:") + + +def test_manifest_hash_ignores_run_id_and_timestamp() -> None: + """Same composition, different run_id/timestamp -> SAME manifest_hash. + + This locks in the documented design choice: manifest_hash fingerprints + the METHODOLOGY, not the particular execution instance, so re-running + identical methodology on a different day is recognizable as "unchanged". + """ + kwargs_a = _base_manifest_kwargs() + kwargs_b = {**_base_manifest_kwargs(), "run_id": "run-2", "timestamp": "2099-01-01T00:00:00.000+00:00"} + m1 = build_run_manifest(**kwargs_a) + m2 = build_run_manifest(**kwargs_b) + assert m1.manifest_hash == m2.manifest_hash + assert m1.run_id != m2.run_id + assert m1.timestamp != m2.timestamp + + +def test_manifest_hash_changes_when_a_skill_content_changes() -> None: + """Editing one skill's body changes manifest_hash and is named by diff.""" + baseline = build_run_manifest(**_base_manifest_kwargs()) + + kwargs = _base_manifest_kwargs() + kwargs["skills"] = {**kwargs["skills"], "tushare": "tushare skill body v2 (edited)"} + changed = build_run_manifest(**kwargs) + + assert changed.manifest_hash != baseline.manifest_hash + diff = diff_manifests(baseline, changed) + assert diff.skills_changed == ("tushare",) + assert diff.skills_added == () + assert diff.skills_removed == () + assert diff.manifest_hash_changed is True + assert diff.unchanged is False + + +def test_manifest_hash_changes_when_system_prompt_changes() -> None: + baseline = build_run_manifest(**_base_manifest_kwargs()) + kwargs = _base_manifest_kwargs() + kwargs["system_prompt"] = "You are a trading research agent. (v2)" + changed = build_run_manifest(**kwargs) + + assert changed.manifest_hash != baseline.manifest_hash + assert changed.system_prompt_hash != baseline.system_prompt_hash + diff = diff_manifests(baseline, changed) + assert diff.system_prompt_changed is True + + +def test_manifest_hash_changes_when_tool_registry_changes() -> None: + baseline = build_run_manifest(**_base_manifest_kwargs()) + kwargs = _base_manifest_kwargs() + kwargs["tool_names"] = [*kwargs["tool_names"], "options_payoff"] + changed = build_run_manifest(**kwargs) + + assert changed.manifest_hash != baseline.manifest_hash + diff = diff_manifests(baseline, changed) + assert diff.tools_added == ("options_payoff",) + assert diff.tools_removed == () + + +def test_manifest_hash_changes_when_package_version_changes() -> None: + baseline = build_run_manifest(**_base_manifest_kwargs()) + kwargs = _base_manifest_kwargs() + kwargs["package_versions"] = {**kwargs["package_versions"], "numpy": "2.5.0"} + changed = build_run_manifest(**kwargs) + + assert changed.manifest_hash != baseline.manifest_hash + diff = diff_manifests(baseline, changed) + assert diff.package_version_changes == (("numpy", "2.4.4", "2.5.0"),) + + +def test_manifest_hash_changes_when_extra_changes() -> None: + baseline = build_run_manifest(**_base_manifest_kwargs()) + kwargs = _base_manifest_kwargs() + kwargs["extra"] = {**kwargs["extra"], "model": "deepseek/deepseek-v3.3"} + changed = build_run_manifest(**kwargs) + + assert changed.manifest_hash != baseline.manifest_hash + diff = diff_manifests(baseline, changed) + assert diff.extra_changes == (("model", "deepseek/deepseek-v3.2", "deepseek/deepseek-v3.3"),) + + +def test_manifest_diff_reports_no_changes_for_identical_composition() -> None: + a = build_run_manifest(**_base_manifest_kwargs()) + b = build_run_manifest(**{**_base_manifest_kwargs(), "run_id": "other-run"}) + diff = diff_manifests(a, b) + assert diff.unchanged is True + assert diff.manifest_hash_changed is False + + +def test_manifest_serialization_round_trip() -> None: + original = build_run_manifest(**_base_manifest_kwargs()) + + via_dict = RunManifest.from_dict(original.to_dict()) + assert via_dict == original + assert via_dict.verify_hash() is True + + via_json = RunManifest.from_json(original.to_json()) + assert via_json == original + assert via_json.verify_hash() is True + + +def test_manifest_verify_hash_detects_tampering_after_deserialize() -> None: + original = build_run_manifest(**_base_manifest_kwargs()) + data = original.to_dict() + # Tamper: swap in a different skill hash without recomputing manifest_hash. + data["skills"][0]["content_hash"] = "sha256:" + "0" * 64 + tampered = RunManifest.from_dict(data) + assert tampered.verify_hash() is False + + +def test_manifest_rejects_duplicate_skill_names_in_sequence_form() -> None: + from src.governance.manifest import SkillRecord + + with pytest.raises(ValueError): + build_run_manifest( + run_id="r1", + timestamp="t1", + system_prompt="p", + skills=[ + SkillRecord.from_content("dup", "a"), + SkillRecord.from_content("dup", "b"), + ], + ) + + +def test_manifest_rejects_empty_run_id_or_timestamp() -> None: + kwargs = _base_manifest_kwargs() + with pytest.raises(ValueError): + build_run_manifest(**{**kwargs, "run_id": ""}) + with pytest.raises(ValueError): + build_run_manifest(**{**kwargs, "timestamp": " "}) + + +def test_collect_key_package_versions_reflects_real_environment() -> None: + """Sanity check against the actual installed environment (no mocking). + + vibe-trading-ai is installed editable in this checkout (pyproject.toml + pins 0.1.13 at the time this test was written); numpy is a hard + dependency so it must resolve; arch is an optional 'stats' extra that is + NOT installed in this environment, proving the None-when-absent path is + real, not merely a documented intent. + """ + versions = collect_key_package_versions() + assert versions["numpy"] is not None + assert versions["vibe-trading-ai"] is not None + assert "python" in versions and versions["python"] + # Not asserting a specific arch install state (that is an environment + # fact, not a manifest.py contract) -- but the key must be present. + assert "arch" in versions + + +# --------------------------------------------------------------------------- +# ledger.py — happy path +# --------------------------------------------------------------------------- + + +def test_append_record_chains_sequential_records(tmp_path: Path) -> None: + path = tmp_path / "chain.jsonl" + records = [append_record(path, {"n": i}) for i in range(5)] + + assert [r["seq"] for r in records] == [1, 2, 3, 4, 5] + assert records[0]["prev_record_hash"] == ledger_mod.GENESIS_PREV_HASH + for earlier, later in zip(records, records[1:]): + assert later["prev_record_hash"] == earlier["record_hash"] + + result = verify_chain(path) + assert result.ok is True + assert result.record_count == 5 + assert result.first_break is None + + +def test_append_record_rejects_reserved_payload_keys(tmp_path: Path) -> None: + path = tmp_path / "chain.jsonl" + with pytest.raises(ValueError): + append_record(path, {"seq": 99, "note": "hijack"}) + + +def test_verify_chain_on_missing_file_is_trivially_ok(tmp_path: Path) -> None: + result = verify_chain(tmp_path / "does_not_exist.jsonl") + assert result == ChainVerificationResult(ok=True, record_count=0, first_break=None) + + +# --------------------------------------------------------------------------- +# ledger.py — tamper detection (negative-case driven, per task requirement) +# --------------------------------------------------------------------------- + + +def _write_five_records(path: Path) -> list[dict]: + return [append_record(path, {"label": f"record-{i}", "amount": i * 10}) for i in range(5)] + + +def test_tamper_editing_one_record_content_is_pinpointed(tmp_path: Path) -> None: + """The core negative case: hand-edit ONE record's payload, leave its + stored seq/prev_record_hash/record_hash untouched, and assert + verify_chain names exactly that record -- not merely "broken somewhere". + """ + path = tmp_path / "chain.jsonl" + _write_five_records(path) + + lines = path.read_text(encoding="utf-8").splitlines() + assert len(lines) == 5 + tampered_index = 2 # the 3rd record (seq=3), 0-based line index 2 + record = json.loads(lines[tampered_index]) + assert record["seq"] == 3 + original_amount = record["amount"] + record["amount"] = original_amount + 999 # tamper the payload only + lines[tampered_index] = json.dumps(record, ensure_ascii=False) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + result = verify_chain(path) + assert result.ok is False + assert result.first_break is not None + assert result.first_break.index == tampered_index + assert result.first_break.seq == 3 + assert result.first_break.reason == "record_hash_mismatch" + # Records before the tamper are still counted as verified. + assert result.record_count == 2 + + +def test_tamper_deleting_a_middle_record_is_detected(tmp_path: Path) -> None: + """Removing a whole line (not just editing content) must also be caught.""" + path = tmp_path / "chain.jsonl" + _write_five_records(path) + + lines = path.read_text(encoding="utf-8").splitlines() + assert len(lines) == 5 + deleted_index = 1 # remove the 2nd record (seq=2) + del lines[deleted_index] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + result = verify_chain(path) + assert result.ok is False + assert result.first_break is not None + # After deleting seq=2, the next physical line is the original seq=3 + # record, which the walk encounters while still expecting seq=2. + assert result.first_break.index == deleted_index + assert result.first_break.seq == 3 + assert result.first_break.reason == "seq_gap" + assert result.record_count == 1 + + +def test_tamper_that_also_fixes_its_own_hash_is_still_caught_downstream(tmp_path: Path) -> None: + """A more sophisticated tamper: edit a record's payload AND recompute + that record's own record_hash so it is internally self-consistent. The + chain must still catch this -- not at the tampered record (which now + looks locally valid) but at the very next record, whose stored + prev_record_hash still points at the ORIGINAL (pre-tamper) hash. + """ + path = tmp_path / "chain.jsonl" + _write_five_records(path) + + lines = path.read_text(encoding="utf-8").splitlines() + tampered_index = 2 # seq=3 + record = json.loads(lines[tampered_index]) + record["amount"] = -1 + payload = {k: v for k, v in record.items() if k not in {"seq", "prev_record_hash", "record_hash"}} + record["record_hash"] = ledger_mod.compute_record_hash( + record["seq"], record["prev_record_hash"], payload + ) + lines[tampered_index] = json.dumps(record, ensure_ascii=False) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + result = verify_chain(path) + assert result.ok is False + assert result.first_break is not None + assert result.first_break.index == tampered_index + 1 # the NEXT record, seq=4 + assert result.first_break.seq == 4 + assert result.first_break.reason == "prev_hash_mismatch" + # The tampered record itself (seq=3) still counts as "verified" in + # isolation -- proving the propagation, not a local check, is what + # catches this class of tamper. + assert result.record_count == 3 + + +def test_only_asserting_untampered_pass_would_be_insufficient_so_also_assert_ok_baseline( + tmp_path: Path, +) -> None: + """Companion positive control for the negative tests above: confirms the + SAME 5-record fixture verifies clean before any tampering, so the + negative tests above are proven to detect the tamper itself and not + some unrelated fixture bug. + """ + path = tmp_path / "chain.jsonl" + _write_five_records(path) + assert verify_chain(path) == ChainVerificationResult(ok=True, record_count=5, first_break=None) + + +def test_append_record_refuses_to_extend_an_already_broken_chain(tmp_path: Path) -> None: + path = tmp_path / "chain.jsonl" + _write_five_records(path) + lines = path.read_text(encoding="utf-8").splitlines() + record = json.loads(lines[2]) + record["amount"] = 999999 + lines[2] = json.dumps(record, ensure_ascii=False) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + with pytest.raises(LedgerCorruptionError) as excinfo: + append_record(path, {"label": "record-5-attempt"}) + assert excinfo.value.chain_break.index == 2 + assert excinfo.value.chain_break.reason == "record_hash_mismatch" + + # And the file was NOT extended with a 6th record. + assert len(path.read_text(encoding="utf-8").splitlines()) == 5 + + +# --------------------------------------------------------------------------- +# ledger.py — fsync +# --------------------------------------------------------------------------- + + +def test_append_record_calls_os_fsync(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import os as os_mod + + calls: list[int] = [] + real_fsync = os_mod.fsync + + def counting_fsync(fd: int) -> None: + calls.append(fd) + real_fsync(fd) + + monkeypatch.setattr(os_mod, "fsync", counting_fsync) + + path = tmp_path / "chain.jsonl" + append_record(path, {"n": 1}) + # At least one fsync for the record write itself (plus one for the + # parent directory entry, since this is the first write to a new file). + assert len(calls) >= 1 + + +def test_append_record_skips_fsync_when_disabled(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import os as os_mod + + calls: list[int] = [] + monkeypatch.setattr(os_mod, "fsync", lambda fd: calls.append(fd)) + + path = tmp_path / "chain.jsonl" + append_record(path, {"n": 1}, fsync=False) + assert calls == [] + + +# --------------------------------------------------------------------------- +# ledger.py — export / offline verification +# --------------------------------------------------------------------------- + + +def test_export_is_self_contained_and_offline_verifiable(tmp_path: Path) -> None: + path = tmp_path / "chain.jsonl" + _write_five_records(path) + + export = build_export(path) + assert export["record_count"] == 5 + assert export["verification"]["ok"] is True + + # Delete the SOURCE ledger entirely -- verify_export must not need it. + path.unlink() + result = verify_export(export) + assert result.ok is True + assert result.record_count == 5 + + +def test_export_to_file_round_trips(tmp_path: Path) -> None: + from src.governance.ledger import export_chain_to_file + + path = tmp_path / "chain.jsonl" + _write_five_records(path) + dest = tmp_path / "export.json" + export_chain_to_file(path, dest) + + loaded = json.loads(dest.read_text(encoding="utf-8")) + result = verify_export(loaded) + assert result.ok is True + result_from_path = verify_export(dest) + assert result_from_path.ok is True + + +def test_verify_export_detects_tampered_embedded_record(tmp_path: Path) -> None: + path = tmp_path / "chain.jsonl" + _write_five_records(path) + export = build_export(path) + + export["records"][2]["amount"] = -1 # tamper without fixing export_hash + result = verify_export(export) + assert result.ok is False + assert result.first_break.reason == "export_hash_mismatch" + + +def test_verify_export_detects_tampered_envelope_hash_directly(tmp_path: Path) -> None: + path = tmp_path / "chain.jsonl" + _write_five_records(path) + export = build_export(path) + + export["export_hash"] = "sha256:" + "f" * 64 + result = verify_export(export) + assert result.ok is False + assert result.first_break.reason == "export_hash_mismatch" + assert result.first_break.index == -1 + + +# --------------------------------------------------------------------------- +# src/live/audit.py — backward compatibility + new capabilities +# --------------------------------------------------------------------------- + + +@pytest.fixture +def live_runtime(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point the live runtime root at an isolated tmp dir (mirrors test_audit_redact.py).""" + monkeypatch.setattr(live_paths, "get_runtime_root", lambda: tmp_path) + return tmp_path + + +def _read_jsonl(path: Path) -> list[dict]: + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def test_default_write_live_action_shape_is_unchanged(live_runtime: Path) -> None: + """Backward compat under the 2026-08-06 default flip. + + The chain is now written on every call, but the classic ledger line and the + RETURN VALUE must stay byte-identical to what callers already depend on -- + the three chain fields belong to the chain file alone. + """ + rec = write_live_action( + LiveActionEvent(kind="order_placed", session_id="s1", outcome="accepted", server="robinhood") + ) + assert "seq" not in rec + assert "prev_record_hash" not in rec + assert "record_hash" not in rec + + written = _read_jsonl(audit.audit_ledger_path()) + assert len(written) == 1 + assert written[0] == rec + assert "seq" not in written[0] + + # And the chained copy exists alongside it, carrying the same payload. + chained = _read_jsonl(audit.audit_chain_ledger_path()) + assert len(chained) == 1 + assert chained[0]["seq"] == 1 + assert {k: v for k, v in chained[0].items() + if k not in ("seq", "prev_record_hash", "record_hash")} == rec + + +def test_chain_false_writes_only_the_classic_ledger(live_runtime: Path) -> None: + """The opt-out still exists and still means what it says.""" + write_live_action( + LiveActionEvent(kind="order_placed", session_id="s1", outcome="accepted", server="robinhood"), + chain=False, + ) + assert len(_read_jsonl(audit.audit_ledger_path())) == 1 + assert not audit.audit_chain_ledger_path().exists() + + +def test_default_write_live_action_now_fsyncs(live_runtime: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The durability fix: even the CLASSIC (chain=False) path must fsync now, + closing the gap with src.agent.trace.TraceWriter noted in the audit.py + docstring update.""" + import os as os_mod + + calls: list[int] = [] + real_fsync = os_mod.fsync + + def counting_fsync(fd: int) -> None: + calls.append(fd) + real_fsync(fd) + + monkeypatch.setattr(os_mod, "fsync", counting_fsync) + + write_live_action( + LiveActionEvent(kind="order_placed", session_id="s1", outcome="accepted", server="robinhood") + ) + assert len(calls) >= 1 + + +def test_chain_true_writes_to_dedicated_chain_ledger(live_runtime: Path) -> None: + for i in range(3): + write_live_action( + LiveActionEvent( + kind="order_placed", + session_id="s1", + outcome="accepted", + server="robinhood", + intent_normalized=f"order {i}", + ), + chain=True, + ) + + # Both ledgers are written: the classic one keeps every existing reader + # working, the chained one adds tamper-evidence. + assert len(_read_jsonl(audit.audit_ledger_path())) == 3 + + chained = _read_jsonl(audit.audit_chain_ledger_path()) + assert len(chained) == 3 + assert [r["seq"] for r in chained] == [1, 2, 3] + result = verify_chain(audit.audit_chain_ledger_path()) + assert result.ok is True + assert result.record_count == 3 + + +def test_chain_true_fans_the_same_augmented_record_to_all_sinks(live_runtime: Path) -> None: + trace_entries: list[dict] = [] + captured: list[tuple] = [] + + rec = write_live_action( + LiveActionEvent( + kind="order_placed", + session_id="s1", + outcome="accepted", + server="robinhood", + broker_request={"symbol": "NVDA", "token": "leak-me"}, + ), + chain=True, + trace_writer=type("T", (), {"write": lambda self, e: trace_entries.append(e)})(), + event_callback=lambda name, payload: captured.append((name, payload)), + ) + + # The chain fields live in the chain FILE only. The return value, the + # trace entry and the surface payload stay the classic record, so nothing + # that already parses those three breaks. + assert "seq" not in rec + assert rec["broker_request"]["token"] == "[redacted]" + + ledger_rec = _read_jsonl(audit.audit_chain_ledger_path())[0] + assert ledger_rec["seq"] == 1 + assert {k: v for k, v in ledger_rec.items() + if k not in ("seq", "prev_record_hash", "record_hash")} == rec + + assert trace_entries[0]["type"] == "live_action" + assert "seq" not in trace_entries[0] + # Redaction still happens BEFORE any sink, including the chained one. + assert trace_entries[0]["broker_request"]["token"] == "[redacted]" + assert ledger_rec["broker_request"]["token"] == "[redacted]" + + assert captured[0] == ("live.action", rec) + + +def test_chain_true_refuses_to_extend_a_tampered_chain_ledger(live_runtime: Path) -> None: + for i in range(3): + write_live_action( + LiveActionEvent(kind="order_placed", session_id="s1", outcome="accepted", server="robinhood"), + chain=True, + ) + + chain_path = audit.audit_chain_ledger_path() + lines = chain_path.read_text(encoding="utf-8").splitlines() + record = json.loads(lines[1]) + record["session_id"] = "hijacked" + lines[1] = json.dumps(record, ensure_ascii=False) + chain_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + # A corrupt chain must NOT take down the order path. The compliance record + # still lands in the classic ledger (nothing is lost), the failure is + # logged, and the break stays detectable -- which is the whole justification + # for swallowing it rather than raising into an in-flight order. + before = len(_read_jsonl(audit.audit_ledger_path())) + returned = write_live_action( + LiveActionEvent(kind="order_placed", session_id="s1", outcome="accepted", server="robinhood"), + chain=True, + ) + after = _read_jsonl(audit.audit_ledger_path()) + assert len(after) == before + 1, "the classic record must survive a chain failure" + assert after[-1] == returned + + # And the tamper is still reported, at the record that was edited. + result = verify_chain(chain_path) + assert result.ok is False + assert result.first_break.index == 1 + + # append_record itself still refuses -- the guard is intact, it is just no + # longer allowed to propagate into the order path. + with pytest.raises(LedgerCorruptionError): + append_record(chain_path, {"action": "direct"}) + + +# --- retention: rotate, never delete --- + + +def test_rotation_seals_a_segment_and_keeps_the_active_file_small(tmp_path): + from src.governance.ledger import archive_segments, rotate_if_needed + + led = tmp_path / "audit_chain.jsonl" + for i in range(5): + append_record(led, {"action": "order", "n": i}) + + archive = rotate_if_needed(led, max_bytes=100) + + assert archive is not None + assert archive_segments(led) == [archive] + assert not led.exists() # sealed; the next append starts a fresh active file + + +def test_rotation_does_not_delete_anything(tmp_path): + # The whole retention policy in one assertion: history is never destroyed. + from src.governance.ledger import rotate_if_needed + + led = tmp_path / "audit_chain.jsonl" + for i in range(5): + append_record(led, {"action": "order", "n": i}) + before = led.read_text() + + archive = rotate_if_needed(led, max_bytes=100) + assert archive.read_text() == before + + +def test_the_chain_continues_across_a_rotation(tmp_path): + from src.governance.ledger import rotate_if_needed, verify_chain_with_archives + + led = tmp_path / "audit_chain.jsonl" + for i in range(5): + append_record(led, {"action": "order", "n": i}) + rotate_if_needed(led, max_bytes=100) + for i in range(5, 9): + append_record(led, {"action": "order", "n": i}) + + # Not a fresh chain: the active file picks up at seq 6. + seqs = [json.loads(line)["seq"] for line in led.read_text().splitlines()] + assert seqs == [6, 7, 8, 9] + + result = verify_chain_with_archives(led) + assert result.ok is True + assert result.record_count == 9 + + +def test_deleting_a_whole_sealed_segment_is_detected(tmp_path): + # This is what rotation would have cost us if the chain restarted: a + # segment could be removed wholesale and each remaining file would still + # verify on its own. + from src.governance.ledger import rotate_if_needed, verify_chain_with_archives + + led = tmp_path / "audit_chain.jsonl" + for i in range(5): + append_record(led, {"action": "order", "n": i}) + archive = rotate_if_needed(led, max_bytes=100) + for i in range(5, 9): + append_record(led, {"action": "order", "n": i}) + + archive.unlink() + + result = verify_chain_with_archives(led) + assert result.ok is False + assert result.first_break.reason == "seq_gap" + + +def test_rotation_refuses_to_seal_a_corrupt_ledger(tmp_path): + from src.governance.ledger import rotate_if_needed + + led = tmp_path / "audit_chain.jsonl" + for i in range(5): + append_record(led, {"action": "order", "n": i}) + lines = led.read_text().splitlines() + record = json.loads(lines[2]) + record["n"] = 999 + lines[2] = json.dumps(record) + led.write_text("\n".join(lines) + "\n") + + with pytest.raises(LedgerCorruptionError): + rotate_if_needed(led, max_bytes=100) + + +def test_rotation_is_a_no_op_below_the_threshold(tmp_path): + from src.governance.ledger import rotate_if_needed + + led = tmp_path / "audit_chain.jsonl" + append_record(led, {"action": "order"}) + assert rotate_if_needed(led, max_bytes=10_000_000) is None + assert led.exists() + + +def test_rotation_rejects_a_non_positive_threshold(tmp_path): + from src.governance.ledger import rotate_if_needed + + led = tmp_path / "audit_chain.jsonl" + append_record(led, {"action": "order"}) + with pytest.raises(ValueError, match="max_bytes"): + rotate_if_needed(led, max_bytes=0) diff --git a/agent/tests/test_orderbook_depth_tool.py b/agent/tests/test_orderbook_depth_tool.py new file mode 100644 index 00000000..b451d590 --- /dev/null +++ b/agent/tests/test_orderbook_depth_tool.py @@ -0,0 +1,411 @@ +"""Tests for orderbook_depth_tool: validation, spread/imbalance/impact math, edge cases. + +No test ever reaches a live exchange. The tool isolates the network call in +its own module-level function, ``_fetch_raw_book`` — see the tool's module +docstring — and every test here monkeypatches exactly that name on the +``orderbook_depth_tool`` module, mirroring the pattern already used in +``tests/test_prediction_market_tool.py`` (monkeypatch ``throttled_get_json`` +on its own tool module rather than mocking ``requests`` globally). + +Fixture books use small integer prices/quantities specifically so the impact +walk's expected outputs can be hand-computed with exact arithmetic (verified +independently with ``decimal.Decimal`` outside the test, then hardcoded as +literals below) and asserted to 1e-9 rather than merely "looks plausible". +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from src.tools import orderbook_depth_tool +from src.tools.orderbook_depth_tool import OrderBookDepthTool + + +def _raw_book( + bids: list[list[float]], + asks: list[list[float]], + *, + timestamp: float | None = None, +) -> dict[str, Any]: + """Build a ccxt-shaped OrderBook dict, as ``fetch_order_book`` returns it.""" + return { + "symbol": "BTC/USDT", + "bids": bids, + "asks": asks, + "timestamp": timestamp, + "datetime": None, + "nonce": None, + } + + +def _install(monkeypatch: pytest.MonkeyPatch, book: dict[str, Any]) -> list[tuple[str, str, int]]: + """Monkeypatch ``_fetch_raw_book`` to return ``book`` and record call args.""" + calls: list[tuple[str, str, int]] = [] + + def fake_fetch(exchange_id: str, ccxt_symbol: str, limit: int) -> dict[str, Any]: + calls.append((exchange_id, ccxt_symbol, limit)) + return book + + monkeypatch.setattr(orderbook_depth_tool, "_fetch_raw_book", fake_fetch) + return calls + + +def _run(**kwargs: Any) -> dict[str, Any]: + return json.loads(OrderBookDepthTool().execute(**kwargs)) + + +# --------------------------------------------------------------------------- +# The four-level book used for the impact-cost hand-calc tests. +# +# asks (ascending): 100@1, 101@1, 102@1, 103@1 -> total ask depth = 4 base +# bids (descending): 99@1, 98@1, 97@1, 96@1 -> total bid depth = 4 base +# mid = (100 + 99) / 2 = 99.5 +# +# Every expected number below was computed independently with +# decimal.Decimal (not by calling the tool) and is asserted to 1e-9: +# +# Case A (notional=199 -> base_qty_target=199/99.5=2.0 exactly, a clean +# 2-level fill on both sides): +# BUY: take 1@100 + 1@101 = 201 quote for 2.0 base -> avg=100.5 +# slippage_bps = (100.5-99.5)/99.5*10000 = 100.502512562814070... +# SELL: take 1@99 + 1@98 = 197 quote for 2.0 base -> avg=98.5 +# slippage_bps = (99.5-98.5)/99.5*10000 = 100.502512562814070... +# +# Case B (base_qty_target=2.5 -> notional=248.75, a partial 3rd-level fill): +# BUY: 1@100 + 1@101 + 0.5@102 = 100+101+51 = 252 quote for 2.5 base +# avg=100.8, slippage_bps=(100.8-99.5)/99.5*10000=130.653266331658... +# +# Case C (base_qty_target=10, exceeds the entire 4-base ask ladder): +# BUY: all 4 asks = 100+101+102+103=406 quote for 4.0 base, avg=101.5 +# fully_filled=False, unfilled_base_qty=6.0 +# slippage_bps=(101.5-99.5)/99.5*10000=201.005025125628140... +# --------------------------------------------------------------------------- +_ASKS = [[100, 1], [101, 1], [102, 1], [103, 1]] +_BIDS = [[99, 1], [98, 1], [97, 1], [96, 1]] + + +def test_impact_cost_exact_two_level_fill_buy_and_sell(monkeypatch: pytest.MonkeyPatch) -> None: + """Case A: notional lands exactly on a level boundary on both sides.""" + _install(monkeypatch, _raw_book(_BIDS, _ASKS)) + result = _run(symbol="BTC-USDT", notional_quote=199, levels=4) + + assert result["ok"] is True + impact = result["data"]["impact_cost"] + assert impact["base_qty_target"] == pytest.approx(2.0, abs=1e-9) + + buy = impact["buy"] + assert buy["fully_filled"] is True + assert buy["filled_base_qty"] == pytest.approx(2.0, abs=1e-9) + assert buy["filled_notional_quote"] == pytest.approx(201.0, abs=1e-9) + assert buy["avg_price"] == pytest.approx(100.5, abs=1e-9) + assert buy["slippage_bps"] == pytest.approx(100.502512562814070, abs=1e-9) + assert buy["levels_used"] == 2 + assert "note" not in buy + + sell = impact["sell"] + assert sell["fully_filled"] is True + assert sell["filled_base_qty"] == pytest.approx(2.0, abs=1e-9) + assert sell["filled_notional_quote"] == pytest.approx(197.0, abs=1e-9) + assert sell["avg_price"] == pytest.approx(98.5, abs=1e-9) + assert sell["slippage_bps"] == pytest.approx(100.502512562814070, abs=1e-9) + assert sell["levels_used"] == 2 + assert "note" not in sell + + +def test_impact_cost_partial_level_fill(monkeypatch: pytest.MonkeyPatch) -> None: + """Case B: the target lands mid-level, exercising fractional level consumption.""" + _install(monkeypatch, _raw_book(_BIDS, _ASKS)) + result = _run(symbol="BTC-USDT", notional_quote=248.75, levels=4) + + buy = result["data"]["impact_cost"]["buy"] + assert buy["fully_filled"] is True + assert buy["filled_base_qty"] == pytest.approx(2.5, abs=1e-9) + assert buy["filled_notional_quote"] == pytest.approx(252.0, abs=1e-9) + assert buy["avg_price"] == pytest.approx(100.8, abs=1e-9) + assert buy["slippage_bps"] == pytest.approx(130.653266331658291, abs=1e-9) + assert buy["levels_used"] == 3 + + +def test_impact_cost_exceeds_full_book_depth_reports_partial_fill( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Case C: requested notional cannot be filled by the whole fetched ladder. + + This is the "does not fabricate a full fill" requirement: filled_notional + must reflect only what the book actually had, fully_filled must be False, + and a note must explain why. + """ + _install(monkeypatch, _raw_book(_BIDS, _ASKS)) + # base_qty_target = notional/mid = 995/99.5 = 10.0, but total ask depth is 4. + result = _run(symbol="BTC-USDT", notional_quote=995, levels=4) + + impact = result["data"]["impact_cost"] + assert impact["base_qty_target"] == pytest.approx(10.0, abs=1e-9) + + buy = impact["buy"] + assert buy["fully_filled"] is False + assert buy["filled_base_qty"] == pytest.approx(4.0, abs=1e-9) + assert buy["filled_notional_quote"] == pytest.approx(406.0, abs=1e-9) + assert buy["avg_price"] == pytest.approx(101.5, abs=1e-9) + assert buy["unfilled_base_qty"] == pytest.approx(6.0, abs=1e-9) + assert buy["slippage_bps"] == pytest.approx(201.005025125628140, abs=1e-9) + assert buy["levels_used"] == 4 + assert "note" in buy + assert "995" in buy["note"] + + # filled_notional_quote (406) must never be conflated with the requested + # notional_quote_requested (995) — a caller reading only one field must + # still be able to tell the order was not fully filled. + assert buy["notional_quote_requested"] == pytest.approx(995.0, abs=1e-9) + assert buy["filled_notional_quote"] < buy["notional_quote_requested"] + + +def test_spread_and_mid_price(monkeypatch: pytest.MonkeyPatch) -> None: + _install(monkeypatch, _raw_book(_BIDS, _ASKS)) + result = _run(symbol="BTC-USDT") + + data = result["data"] + assert data["mid_price"] == pytest.approx(99.5, abs=1e-9) + assert data["best_bid"] == {"price": 99, "amount_base": 1} + assert data["best_ask"] == {"price": 100, "amount_base": 1} + assert data["spread"]["absolute_quote"] == pytest.approx(1.0, abs=1e-9) + # 1 / 99.5 * 10000 + assert data["spread"]["relative_bps"] == pytest.approx(100.502512562814070, abs=1e-9) + + +# --------------------------------------------------------------------------- +# Depth imbalance: double-sided guard (balanced -> ~0, thick one side -> big +# and correctly signed), plus a mutation-style check that swapping which side +# is thick flips the sign. +# --------------------------------------------------------------------------- + + +def test_imbalance_balanced_book_is_near_zero(monkeypatch: pytest.MonkeyPatch) -> None: + """99*101 == 101*99, so bid and ask notional are exactly equal -> imbalance == 0.""" + _install(monkeypatch, _raw_book([[99, 101]], [[101, 99]])) + result = _run(symbol="BTC-USDT", levels=1) + + imbalance = result["data"]["imbalance"] + assert imbalance["bid_notional_quote"] == pytest.approx(9999.0, abs=1e-9) + assert imbalance["ask_notional_quote"] == pytest.approx(9999.0, abs=1e-9) + assert imbalance["normalized_imbalance"] == pytest.approx(0.0, abs=1e-9) + assert imbalance["ratio_bid_over_ask"] == pytest.approx(1.0, abs=1e-9) + + +def test_imbalance_bid_heavy_book_is_large_and_positive(monkeypatch: pytest.MonkeyPatch) -> None: + _install( + monkeypatch, + _raw_book([[99, 1000], [98, 1000]], [[101, 1], [102, 1]]), + ) + result = _run(symbol="BTC-USDT", levels=2) + + imbalance = result["data"]["imbalance"] + # bid_notional = 99*1000+98*1000 = 197000; ask_notional = 101+102 = 203 + assert imbalance["bid_notional_quote"] == pytest.approx(197_000.0, abs=1e-9) + assert imbalance["ask_notional_quote"] == pytest.approx(203.0, abs=1e-9) + assert imbalance["normalized_imbalance"] > 0.99 + assert imbalance["normalized_imbalance"] <= 1.0 + + +def test_imbalance_ask_heavy_book_is_large_and_negative(monkeypatch: pytest.MonkeyPatch) -> None: + """Mutation of the bid-heavy case: swap which side is thick and the sign must flip.""" + _install( + monkeypatch, + _raw_book([[99, 1], [98, 1]], [[101, 1000], [102, 1000]]), + ) + result = _run(symbol="BTC-USDT", levels=2) + + imbalance = result["data"]["imbalance"] + assert imbalance["normalized_imbalance"] < -0.99 + assert imbalance["normalized_imbalance"] >= -1.0 + + +# --------------------------------------------------------------------------- +# Timestamp handling: exchange-supplied vs. local-fallback. +# --------------------------------------------------------------------------- + + +def test_timestamp_from_exchange(monkeypatch: pytest.MonkeyPatch) -> None: + _install(monkeypatch, _raw_book(_BIDS, _ASKS, timestamp=1_700_000_000_000)) + result = _run(symbol="BTC-USDT") + + data = result["data"] + assert data["timestamp"] == "2023-11-14T22:13:20Z" + assert data["timestamp_source"] == "exchange" + + +def test_timestamp_falls_back_to_local_fetch_time_when_venue_has_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Binance spot depth carries no server timestamp field (see tool docstring).""" + _install(monkeypatch, _raw_book(_BIDS, _ASKS, timestamp=None)) + result = _run(symbol="BTC-USDT") + + data = result["data"] + assert data["timestamp_source"] == "local_fetch_time" + assert data["timestamp"].endswith("Z") + + +def test_exchange_and_symbol_are_always_present(monkeypatch: pytest.MonkeyPatch) -> None: + _install(monkeypatch, _raw_book(_BIDS, _ASKS)) + result = _run(symbol="btc-usdt", exchange="binance") + + data = result["data"] + assert data["exchange"] == "binance" + assert data["symbol"] == "BTC-USDT" + assert data["ccxt_symbol"] == "BTC/USDT" + + +# --------------------------------------------------------------------------- +# Boundary / error handling — every case must be an explicit `ok: false` +# error, never a plausible-looking number. +# --------------------------------------------------------------------------- + + +def test_empty_book_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + _install(monkeypatch, _raw_book([], [])) + result = _run(symbol="BTC-USDT") + + assert result["ok"] is False + assert "empty" in result["error"].lower() + + +def test_one_sided_book_no_bids_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + _install(monkeypatch, _raw_book([], _ASKS)) + result = _run(symbol="BTC-USDT") + + assert result["ok"] is False + assert "no bids" in result["error"].lower() + + +def test_one_sided_book_no_asks_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + _install(monkeypatch, _raw_book(_BIDS, [])) + result = _run(symbol="BTC-USDT") + + assert result["ok"] is False + assert "no asks" in result["error"].lower() + + +def test_crossed_book_is_rejected_not_computed_as_negative_spread( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """best_bid (101) >= best_ask (100): a data error, must refuse outright.""" + _install(monkeypatch, _raw_book([[101, 1]], [[100, 1]])) + result = _run(symbol="BTC-USDT") + + assert result["ok"] is False + assert "crossed" in result["error"].lower() + + +def test_crossed_book_equal_prices_is_also_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + """best_bid == best_ask is degenerate (zero/negative spread), not a valid quote.""" + _install(monkeypatch, _raw_book([[100, 1]], [[100, 1]])) + result = _run(symbol="BTC-USDT") + + assert result["ok"] is False + assert "crossed" in result["error"].lower() + + +def test_notional_exceeding_full_depth_on_both_sides_reports_partial_both_ways( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Symmetric to the buy-side depth-exceeded case, but exercised on sell too.""" + _install(monkeypatch, _raw_book(_BIDS, _ASKS)) + result = _run(symbol="BTC-USDT", notional_quote=995) # base_qty_target = 10 on both sides + + sell = result["data"]["impact_cost"]["sell"] + assert sell["fully_filled"] is False + assert sell["filled_base_qty"] == pytest.approx(4.0, abs=1e-9) + assert sell["unfilled_base_qty"] == pytest.approx(6.0, abs=1e-9) + assert "note" in sell + + +@pytest.mark.parametrize("levels", [51, 1000, -1, 0]) +def test_levels_out_of_bounds_is_rejected(monkeypatch: pytest.MonkeyPatch, levels: int) -> None: + """N over the cap (or below 1) must be an explicit error, never a silent clamp.""" + _install(monkeypatch, _raw_book(_BIDS, _ASKS)) + result = _run(symbol="BTC-USDT", levels=levels) + + assert result["ok"] is False + assert "levels" in result["error"].lower() + + +def test_levels_at_the_cap_is_accepted(monkeypatch: pytest.MonkeyPatch) -> None: + """50 is the documented maximum and must not be rejected.""" + _install(monkeypatch, _raw_book(_BIDS, _ASKS)) + result = _run(symbol="BTC-USDT", levels=50) + + assert result["ok"] is True + assert result["data"]["depth"]["levels"] == 50 + # Only 4 levels actually exist on each side; the response must not + # fabricate padding rows to reach the requested count. + assert len(result["data"]["depth"]["bids"]) == 4 + assert len(result["data"]["depth"]["asks"]) == 4 + + +@pytest.mark.parametrize("notional", [0, -100, "not-a-number", None]) +def test_invalid_notional_is_rejected(monkeypatch: pytest.MonkeyPatch, notional: Any) -> None: + _install(monkeypatch, _raw_book(_BIDS, _ASKS)) + result = _run(symbol="BTC-USDT", notional_quote=notional) + + assert result["ok"] is False + assert "notional" in result["error"].lower() + + +def test_invalid_exchange_is_rejected_even_bypassing_the_schema_enum( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The JSON-schema enum constrains a well-behaved LLM caller; execute() + must not trust that and re-validates at runtime.""" + _install(monkeypatch, _raw_book(_BIDS, _ASKS)) + result = _run(symbol="BTC-USDT", exchange="kraken") + + assert result["ok"] is False + assert "exchange" in result["error"].lower() + + +@pytest.mark.parametrize("symbol", ["BTCUSDT", "BTC-", "-USDT", "BTC-USDT-PERP", "", None, 123]) +def test_malformed_symbol_is_rejected(monkeypatch: pytest.MonkeyPatch, symbol: Any) -> None: + _install(monkeypatch, _raw_book(_BIDS, _ASKS)) + result = _run(symbol=symbol) + + assert result["ok"] is False + assert "symbol" in result["error"].lower() + + +def test_fetch_failure_is_surfaced_as_error_envelope(monkeypatch: pytest.MonkeyPatch) -> None: + def raising_fetch(exchange_id: str, ccxt_symbol: str, limit: int) -> dict[str, Any]: + raise RuntimeError("simulated network failure") + + monkeypatch.setattr(orderbook_depth_tool, "_fetch_raw_book", raising_fetch) + result = _run(symbol="BTC-USDT") + + assert result["ok"] is False + assert "simulated network failure" in result["error"] + + +def test_network_is_never_touched(monkeypatch: pytest.MonkeyPatch) -> None: + """Guard against a future edit silently reintroducing a real network path.""" + calls = _install(monkeypatch, _raw_book(_BIDS, _ASKS)) + _run(symbol="BTC-USDT", exchange="okx", levels=5, notional_quote=1234) + + assert calls == [("okx", "BTC/USDT", orderbook_depth_tool._BOOK_FETCH_LIMIT)] + + +def test_units_block_distinguishes_base_and_quote(monkeypatch: pytest.MonkeyPatch) -> None: + _install(monkeypatch, _raw_book(_BIDS, _ASKS)) + result = _run(symbol="BTC-USDT") + + units = result["units"] + assert "amount_base" in units + assert "notional_quote / *_notional_quote / filled_notional_quote" in units + + +def test_tool_metadata_is_readonly_and_repeatable() -> None: + tool = OrderBookDepthTool() + assert tool.name == "orderbook_depth" + assert tool.is_readonly is True + assert tool.repeatable is True diff --git a/agent/tests/test_quantlib_tool.py b/agent/tests/test_quantlib_tool.py new file mode 100644 index 00000000..5c9fb632 --- /dev/null +++ b/agent/tests/test_quantlib_tool.py @@ -0,0 +1,238 @@ +"""Tests for the quantlib_call tool. + +The load-bearing tests are the three structural limits. A tool that can reach +arbitrary modules, private helpers, or file writers would be a shell by another +name, which is precisely what this tool exists to avoid. +""" + +from __future__ import annotations + +import json + +import pytest + +from src.tools import build_registry +from src.tools.quantlib_tool import ALLOWED_MODULES, QuantlibCallTool + + +@pytest.fixture +def tool(): + return QuantlibCallTool() + + +def call(tool, **kwargs): + return json.loads(tool.execute(**kwargs)) + + +# --- structural limits: this must not be a shell --- + + +def test_a_module_outside_the_allowlist_is_unreachable(tool): + for module in ("os", "subprocess", "builtins", "src.agent.loop", "sys"): + result = call(tool, action="call", module=module, function="system", kwargs={}) + assert result["ok"] is False + assert "unknown module" in result["error"] + + +def test_the_module_string_never_reaches_importlib(tool): + # A dotted path that WOULD import if passed through is still refused, + # because resolution is a dict lookup and not an import. + result = call(tool, action="call", module="src.quantlib.risk", function="historical_var", kwargs={}) + assert result["ok"] is False + assert "unknown module" in result["error"] + + +def test_private_helpers_are_not_callable(tool): + for name in ("_clean_returns", "_validate_confidence", "_tail_index"): + result = call(tool, action="call", module="risk", function=name, kwargs={}) + assert result["ok"] is False + assert "not a public function" in result["error"] + + +def test_dunders_are_not_callable(tool): + for name in ("__builtins__", "__loader__", "__class__"): + result = call(tool, action="call", module="risk", function=name, kwargs={}) + assert result["ok"] is False + + +def test_file_writing_exporters_are_refused_with_a_reason(tool): + result = call(tool, action="call", module="valuation.artifact", + function="export_dcf_workbook", kwargs={}) + assert result["ok"] is False + assert "writes to a path" in result["error"] + + +def test_exporters_are_absent_from_the_listing_too(tool): + listed = call(tool, action="list", module="valuation.artifact") + names = {f["name"] for f in listed["functions"]} + assert not any(n.startswith("export_") for n in names) + + +def test_every_allowlisted_module_actually_imports(tool): + for short in ALLOWED_MODULES: + result = call(tool, action="list", module=short) + assert result["ok"] is True, f"{short} failed to list: {result}" + + +# --- discovery --- + + +def test_list_without_a_module_returns_the_module_set(tool): + result = call(tool, action="list") + assert result["ok"] is True + assert set(result["modules"]) == set(ALLOWED_MODULES) + + +def test_list_a_module_returns_functions_with_summaries(tool): + result = call(tool, action="list", module="risk") + names = {f["name"] for f in result["functions"]} + assert {"historical_var", "historical_cvar", "max_drawdown_analysis"} <= names + assert all(isinstance(f["summary"], str) for f in result["functions"]) + + +def test_describe_returns_a_signature_and_docstring(tool): + result = call(tool, action="describe", module="options", function="bs_price") + assert result["ok"] is True + assert "S" in result["signature"] and "sigma" in result["signature"] + assert "Black-Scholes" in result["doc"] + + +def test_describe_and_call_require_both_module_and_function(tool): + for action in ("describe", "call"): + assert call(tool, action=action, module="risk")["ok"] is False + assert call(tool, action=action, function="historical_var")["ok"] is False + + +def test_unknown_action_rejected(tool): + assert call(tool, action="exec")["ok"] is False + + +# --- computation matches the library --- + + +def test_bs_price_matches_the_textbook_value(tool): + result = call(tool, action="call", module="options", function="bs_price", + kwargs={"S": 100, "K": 100, "T": 1.0, "r": 0.05, "sigma": 0.2, + "option_type": "call"}) + assert result["result"] == pytest.approx(10.4506, abs=1e-4) + + +def test_result_matches_a_direct_import(tool): + from src.quantlib.risk import historical_var + + returns = [0.01, -0.02, -0.05, 0.03, -0.01, 0.02, -0.03, 0.04, -0.06, 0.01] + through_tool = call(tool, action="call", module="risk", function="historical_var", + kwargs={"returns": returns, "confidence": 0.9})["result"] + assert through_tool == pytest.approx(historical_var(returns, confidence=0.9)) + + +def test_a_dataclass_result_serializes_field_by_field(tool): + result = call(tool, action="call", module="var_backtest", function="kupiec_pof", + kwargs={"violations": 10, "observations": 1000, "confidence": 0.99}) + assert result["ok"] is True + assert result["result"]["violations"] == 10 + assert result["result"]["p_value"] == pytest.approx(1.0) + assert result["result"]["rejected"] is False + + +# --- pandas envelopes --- + + +def test_a_series_envelope_reaches_the_function_with_its_index(tool): + result = call( + tool, action="call", module="timeseries", function="compute_half_life", + kwargs={"spread": {"__series__": { + "index": list(range(200)), + "values": [((-1) ** i) * (0.9 ** i) for i in range(200)], + }}}, + ) + assert result["ok"] is True + assert isinstance(result["result"], (int, float)) + + +def test_a_dataframe_envelope_is_decoded(tool): + result = call( + tool, action="call", module="factormodel", function="portfolio_style_exposure", + kwargs={ + "holdings": {"__series__": {"index": ["A", "B"], "values": [0.5, 0.5]}}, + "exposures": {"__dataframe__": { + "index": ["A", "B"], "columns": ["value"], "data": [[1.0], [-1.0]]}}, + }, + ) + assert result["ok"] is True + assert result["result"]["__series__"]["values"][0] == pytest.approx(0.0) + + +def test_a_plain_list_stays_a_list(tool): + # Most functions take a sequence; coercing every list into a Series would + # break the ones that do not. + result = call(tool, action="call", module="risk", function="historical_cvar", + kwargs={"returns": [0.01, -0.02, -0.05, 0.03, -0.01, + 0.02, -0.03, 0.04, -0.06, 0.01]}) + assert result["ok"] is True + + +def test_a_malformed_envelope_is_reported_not_crashed(tool): + result = call(tool, action="call", module="risk", function="historical_var", + kwargs={"returns": {"__series__": {"index": [1, 2]}}}) + assert result["ok"] is False + assert "envelope" in result["error"] + + +# --- error surfaces are informative, never raised --- + + +def test_a_wrong_argument_returns_the_signature_to_fix_it_with(tool): + result = call(tool, action="call", module="options", function="bs_price", + kwargs={"S": 100}) + assert result["ok"] is False + assert "signature" in result + + +def test_a_domain_refusal_is_surfaced_verbatim(tool): + # The library refuses a confidence outside (0,1); that refusal is the + # answer, and must reach the caller instead of becoming a crash. + result = call(tool, action="call", module="risk", function="historical_var", + kwargs={"returns": [0.01, -0.02, 0.03], "confidence": 1.5}) + assert result["ok"] is False + assert "confidence" in result["error"] + + +def test_kwargs_must_be_an_object(tool): + result = call(tool, action="call", module="risk", function="historical_var", kwargs=[1, 2]) + assert result["ok"] is False + + +def test_execute_never_raises_whatever_it_is_given(tool): + for bad in ( + {"action": "call", "module": None, "function": None}, + {"action": None}, + {"action": "call", "module": "risk", "function": "historical_var", "kwargs": None}, + ): + assert json.loads(tool.execute(**bad))["ok"] in (True, False) + + +# --- serialization conventions --- + + +def test_non_finite_floats_are_explicit_strings_not_null(tool): + # A NaN arriving as JSON null reads as "absent", which is a different claim. + result = call(tool, action="call", module="options", function="implied_volatility", + kwargs={"market_price": 1e-9, "S": 100, "K": 200, "T": 0.01, + "r": 0.05, "option_type": "call"}) + if result["ok"]: + assert result["result"] in ("NaN", "Infinity", "-Infinity") or isinstance( + result["result"], (int, float) + ) + + +def test_registry_exposes_the_tool_without_shell_tools_enabled(): + # The whole point: reachable on a registry built the way the API and MCP + # transports build it, with shell tools off. + registry = build_registry(include_shell_tools=False) + assert "quantlib_call" in registry.tool_names + assert "bash" not in registry.tool_names + + +def test_the_tool_is_read_only(tool): + assert tool.is_readonly is True