feat(quantlib): add the analytics the finance-math layer was missing

Nine new modules, each with a tested implementation rather than a formula
in a markdown code block:

  var_backtest     Kupiec POF, Christoffersen independence + conditional
                   coverage, Basel traffic light. The zone cut reproduces the
                   1996 250-day table exactly.
  eventstudy       Estimation-window market model, AR/CAR/CAAR, Patell and
                   BMP. BMP is reported alongside Patell because events raise
                   volatility and Patell assumes they do not.
  factormodel      Cross-sectional style exposures (cap-weighted mean,
                   equal-weighted dispersion), factor returns, style drift.
                   Public definitions only -- this is not Barra and says so.
  multipletesting  Deflated Sharpe, CSCV/PBO, BH-FDR. Wired into the alpha
                   bench, where n_alphas_tested had been carried and only ever
                   displayed.
  crossvalidation  Purged + embargoed splits for overlapping labels, plus a
                   leakage detector so the off-by-one class of bug is
                   assertable rather than reviewable.
  valuation/       run_dcf (dual terminal value that cross-checks each method
                   against the other's implied multiple/growth), run_comps
                   (two-sided multiple exclusion), three-statement projection
                   with a hard balance assertion and an explicit revolver plug.
                   contracts.py holds the one shared rule: a missing input
                   makes a model not runnable and is never silently defaulted.
  governance/      Run manifest (prompt/skill/tool/version hashes) and a
                   hash-chained ledger whose rotation never deletes history.

Plus entities FX translation and panel ingest, private-markets PME and the
American waterfall with GP clawback, an ADV/participation cost model with
borrow, and a crypto L2 depth tool.

quantlib_call reaches all of it from every transport. The shell gate that
previously hid src/quantlib from the API and MCP paths is a fix for two
published advisories, so this routes around it instead of widening it: module
allowlist, __all__ only, file writers refused.
This commit is contained in:
Haozhe Wu committed 2026-08-06 17:05:33 +08:00
1 parent c103708b15
commit 11f97f9dfe
40 files changed
+20654 -6

No files matched your search

+416
View File
@@ -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,
)
+350
View File
@@ -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,
)
+834 -1
View File
@@ -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("<layout-inference>"), 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}: "
"'<your column>'}} 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,
)
+63
View File
@@ -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",
]
+738
View File
@@ -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:<hex>`` 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)
+566
View File
@@ -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:<hex>`` 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:<hex>`` 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:<hex>`` 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:<hex>`` 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:<hex>`` 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
+5
View File
@@ -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
+473
View File
@@ -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,
)
+495
View File
@@ -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,
)
+515
View File
@@ -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
File diff suppressed because it is too large. Load diff
+568
View File
@@ -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,
)
+132
View File
@@ -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).
+24
View File
@@ -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.
"""
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+160
View File
@@ -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
File diff suppressed because it is too large. Load diff
@@ -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))
+654
View File
@@ -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,
)
+507
View File
@@ -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,
)
+449
View File
@@ -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 "<truncated>"
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=<name> 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)
@@ -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
+313
View File
@@ -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]
+427
View File
@@ -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
)
+501
View File
@@ -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,
)
@@ -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
+70 -3
View File
@@ -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
+125 -1
View File
@@ -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)))
+332
View File
@@ -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
Whitespace-only changes.
@@ -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",
)
@@ -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 == ()
+998
View File
@@ -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
@@ -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)
+381
View File
@@ -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"}
+303
View File
@@ -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)
+774
View File
@@ -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)
+411
View File
@@ -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
+238
View File
@@ -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