Merge pull request #1824 for October 14 maintenance

This commit is contained in:
Haozhe Wu committed 2026-10-14 22:16:51 +08:00
commit 767c16bba7
5 files changed
+129 -19

No files matched your search

+2 -2
View File
@@ -870,14 +870,14 @@ that is a bug, not a pattern.
| Module | What it covers |
|--------|----------------|
| `options` | Black-Scholes price + greeks, implied-volatility inversion |
| `fixedincome` | Bond math, Nelson-Siegel / Svensson curve fitting |
| `fixedincome` | Bond math, Nelson-Siegel / Svensson curve fitting; schedules, accrual, key-rate tenors and yield inversion reject non-finite inputs by name. |
| `credit` | Altman Z-score, Merton / KMV distance-to-default; CDS premium dates retain their configured frequency, with a final stub when maturity falls between coupons. |
| `timeseries` | Stationarity, cointegration, GARCH, bootstrap; OU fits use adjacent observed lag pairs; missing observations never become artificial one-step transitions. |
| `risk` · `var_backtest` | VaR / CVaR / EVT and their backtests |
| `attribution` | Brinson-Fachler decomposition |
| `performance` · `fundmath` | TWR / MWR / Modified Dietz; XIRR / MOIC / DPI / TVPI |
| `factormodel` · `eventstudy` | Factor regressions, event studies; portfolio style exposure reports incomplete factor rows as unmatched weight. |
| `multipletesting` · `crossvalidation` | Deflated significance, purged CV; purged walk-forward splits omit folds with no training observations left after purging. |
| `multipletesting` · `crossvalidation` | Deflated significance, purged CV; purged walk-forward splits omit folds with no training observations left after purging; Sharpe significance summaries reject non-finite inputs by name. |
| `impact` | Market-impact models |
| `volatility` | Heston (1993) stochastic-volatility pricing; Heston pricing requires finite inputs and a positive integration limit; invalid inputs return a domain error. |
| `portfolio` | Hierarchical Risk Parity allocation |
+29 -9
View File
@@ -173,10 +173,15 @@ def accrued_interest(
Accrued interest in the same currency units as ``face``.
Raises:
ValueError: If ``freq`` is not positive, if the coupon dates are not
ordered, if ``settlement`` falls outside the coupon period, or if
``day_count`` is not a recognised convention.
ValueError: If any numeric input is non-finite, if ``freq`` is not
positive, if the coupon dates are not ordered, if ``settlement``
falls outside the coupon period, or if ``day_count`` is not a
recognised convention.
"""
# Before the endpoint shortcuts: settling exactly on a coupon date returns
# a constant, which would otherwise launder a NaN face or rate into a
# clean-looking 0.0.
_require_finite(face=face, coupon_rate=coupon_rate, freq=freq)
if freq <= 0:
raise ValueError(f"freq must be positive, got {freq}")
if not last_coupon < next_coupon:
@@ -227,10 +232,13 @@ def bond_cashflows(
``periods / freq``.
Raises:
ValueError: If ``face`` is not positive, if ``n_periods`` is not a
positive integer, or if ``freq`` is not positive.
ValueError: If any numeric input is non-finite, if ``face`` is not
positive, if ``n_periods`` is not a positive integer, or if
``freq`` is not positive.
"""
_require_finite(face=face, coupon_rate=coupon_rate)
# freq too: with an infinite frequency every coupon collapses to zero and
# the schedule reprices to a plausible-looking par.
_require_finite(face=face, coupon_rate=coupon_rate, freq=freq)
if face <= 0:
raise ValueError(f"face must be positive, got {face}")
if isinstance(n_periods, bool) or not isinstance(n_periods, (int, np.integer)) or n_periods <= 0:
@@ -321,9 +329,13 @@ def ytm_solve(
Annual yield to maturity as a decimal.
Raises:
ValueError: If the price is not reachable inside ``bracket``.
ValueError: If ``price`` or a bracket bound is non-finite, or if the
price is not reachable inside ``bracket``.
"""
low, high = bracket
# NaN fails ``low >= high`` and every solver evaluation; name the input
# instead of reporting a bracket that "cannot reprice" it.
_require_finite(price=price, bracket_low=low, bracket_high=high)
if low >= high:
raise ValueError(f"bracket must be increasing, got {bracket}")
@@ -424,11 +436,15 @@ def key_rate_duration(
Dict mapping each key rate tenor (float) to its Key Rate Duration (float in years).
Raises:
ValueError: If key_rates is empty or not strictly increasing, or schedule/compounding invalid.
ValueError: If key_rates is empty, non-finite or not strictly
increasing, or schedule/compounding invalid.
"""
kr = np.asarray(key_rates, dtype=float)
if kr.size == 0:
raise ValueError("key_rates must contain at least one tenor")
# A NaN tenor is neither empty nor non-increasing, so it sailed past both
# checks and came back as a NaN key in the result dict.
_require_finite(key_rates=kr)
if (np.diff(kr) <= 0.0).any():
raise ValueError(f"key_rates must be strictly increasing, got {key_rates}")
@@ -528,10 +544,14 @@ def dv01(
positive number.
Raises:
ValueError: If ``face`` is zero, or the schedule/compounding is invalid.
ValueError: If ``face`` is zero, if ``par_amount`` is non-finite, or
the schedule/compounding is invalid.
"""
if face == 0:
raise ValueError("face must be non-zero")
# face itself is admitted by the schedule checks downstream; par_amount
# never reaches them, so a NaN position size would scale into a NaN hedge.
_require_finite(par_amount=par_amount)
d_mod = modified_duration(face, coupon_rate, ytm, n_periods, freq, compounding)
price = bond_price(face, coupon_rate, ytm, n_periods, freq, compounding)
return d_mod * (price / face) * 1e-4 * par_amount
+26 -8
View File
@@ -243,11 +243,24 @@ def probabilistic_sharpe_ratio(
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.
ValueError: If any summary input is non-finite, 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.
"""
# Every check below is a comparison, and a comparison with NaN is False:
# without this admission, a NaN benchmark returned NaN as "confidence" and
# an infinite one returned a clean 0.0 or 1.0.
for name, value in (
("observed_sharpe", observed_sharpe),
("n_observations", n_observations),
("benchmark_sharpe", benchmark_sharpe),
("skew", skew),
("kurtosis", kurtosis),
):
if not np.isfinite(value):
raise ValueError(f"{name} must be finite, got {value}")
if n_observations < MIN_OBSERVATIONS:
raise ValueError(
f"need at least {MIN_OBSERVATIONS} observations, got {n_observations}"
@@ -257,8 +270,6 @@ def probabilistic_sharpe_ratio(
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
@@ -300,9 +311,16 @@ def expected_maximum_sharpe(n_trials: int, trial_sharpe_std: float) -> float:
``trial_sharpe_std`` is zero.
Raises:
ValueError: If ``n_trials`` is below 1 or ``trial_sharpe_std`` is
negative.
ValueError: If either input is non-finite, if ``n_trials`` is below 1
or ``trial_sharpe_std`` is negative.
"""
# NaN fails every comparison below and norm.ppf(1 - 1/inf) is inf: the
# first surfaces as a NaN bar, the second as an unbeatably infinite one,
# and deflated_sharpe_ratio reports both as an ordinary pass/fail verdict.
if not np.isfinite(n_trials):
raise ValueError(f"n_trials must be finite, got {n_trials}")
if not np.isfinite(trial_sharpe_std):
raise ValueError(f"trial_sharpe_std must be finite, got {trial_sharpe_std}")
if n_trials < 1:
raise ValueError(f"n_trials must be at least 1, got {n_trials}")
if trial_sharpe_std < 0.0:
+41
View File
@@ -575,6 +575,20 @@ def test_fit_yield_curve_rejects_nonfinite_observations(bad):
(lambda bad: nelson_siegel(bad, 0.04, -0.01, 0.01, 2.0), "maturities"),
(lambda bad: nelson_siegel(5.0, 0.04, bad, 0.01, 2.0), "beta1"),
(lambda bad: svensson(5.0, 0.04, -0.01, 0.01, 0.01, 2.0, bad), "decay"),
# An infinite frequency priced a bullet bond at par: every coupon
# collapses to zero and each discount factor to one.
(lambda bad: bond_price(100.0, 0.05, 0.05, 10, freq=bad), "freq"),
(lambda bad: bond_cashflows(100.0, 0.05, 10, freq=bad), "freq"),
(lambda bad: macaulay_duration(100.0, 0.05, 0.05, 10, freq=bad), "freq"),
# NaN fails ``low >= high`` and every solver probe; the old error
# blamed the bracket for an unreachable price.
(lambda bad: ytm_solve(bad, 100.0, 0.05, 10), "price"),
(lambda bad: ytm_solve(95.0, 100.0, 0.05, 10, bracket=(bad, 10.0)), "bracket_low"),
(lambda bad: ytm_solve(95.0, 100.0, 0.05, 10, bracket=(-0.5, bad)), "bracket_high"),
# Neither empty nor non-increasing, so a NaN tenor passed both checks.
(lambda bad: key_rate_duration(100.0, 0.05, 0.05, 10, key_rates=[1.0, bad]), "key_rates"),
# par_amount never reaches the schedule checks downstream.
(lambda bad: dv01(100.0, 0.05, 0.05, 10, par_amount=bad), "par_amount"),
],
)
@pytest.mark.parametrize("bad", [np.nan, np.inf])
@@ -582,3 +596,30 @@ def test_a_non_finite_input_is_named_not_priced(call, name, bad):
"""Each range check is a comparison, and NaN fails none of them: it came out as a NaN price."""
with pytest.raises(ValueError, match=f"{name} must be finite"):
call(bad)
@pytest.mark.parametrize("field", ["face", "coupon_rate", "freq"])
@pytest.mark.parametrize("bad", [np.nan, np.inf, -np.inf])
@pytest.mark.parametrize(
"settlement", [dt.date(2026, 1, 1), dt.date(2026, 4, 1), dt.date(2026, 7, 1)]
)
def test_accrued_interest_rejects_non_finite_inputs(field, bad, settlement):
"""The on-coupon-date shortcuts return constants, so admission must run
before them or a NaN face laundered into a clean 0.0."""
params = dict(
face=100.0,
coupon_rate=0.05,
freq=2,
last_coupon=dt.date(2026, 1, 1),
settlement=settlement,
next_coupon=dt.date(2026, 7, 1),
)
params[field] = bad
with pytest.raises(ValueError, match=f"{field} must be finite"):
accrued_interest(**params)
def test_bond_price_with_infinite_frequency_is_not_par():
"""freq=inf used to price every bullet bond at exactly face."""
with pytest.raises(ValueError, match="freq must be finite"):
bond_price(100.0, 0.05, 0.04, 4, freq=np.inf)
@@ -340,3 +340,34 @@ def test_cscv_logits_and_pbo_agree():
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
@pytest.mark.parametrize(
"field",
["observed_sharpe", "n_observations", "benchmark_sharpe", "skew", "kurtosis"],
)
@pytest.mark.parametrize("bad", [np.nan, np.inf, -np.inf])
def test_psr_rejects_non_finite_summary_inputs(field, bad):
"""A NaN benchmark returned NaN as 'confidence'; an infinite one returned
a clean 0.0 or 1.0. Undefined inputs are named, not reported."""
params = dict(
observed_sharpe=0.1,
n_observations=500,
benchmark_sharpe=0.0,
skew=0.0,
kurtosis=3.0,
)
params[field] = bad
with pytest.raises(ValueError, match=f"{field} must be finite"):
probabilistic_sharpe_ratio(**params)
@pytest.mark.parametrize("field", ["n_trials", "trial_sharpe_std"])
@pytest.mark.parametrize("bad", [np.nan, np.inf, -np.inf])
def test_expected_maximum_sharpe_rejects_non_finite_inputs(field, bad):
"""NaN and inf sail through the comparisons below into a NaN or infinite
bar, which deflated_sharpe_ratio then reports as an ordinary verdict."""
params = dict(n_trials=100, trial_sharpe_std=0.1)
params[field] = bad
with pytest.raises(ValueError, match=f"{field} must be finite"):
expected_maximum_sharpe(**params)