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:
1 parent
c103708b15
commit
11f97f9dfe
40 files changed
+20654
-6
No files matched your search
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
+1034
-1
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
)
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)))
|
||||
@@ -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 == ()
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user