163 lines
5.8 KiB
Python
163 lines
5.8 KiB
Python
|
|
"""Non-regression tests for the optimiz-rs v2 public API.
|
||
|
|
|
||
|
|
Each test exercises one of the v2 primitives advertised in the README and
|
||
|
|
the public blog post and checks it against an analytic ground truth.
|
||
|
|
|
||
|
|
Run with: pytest tests/test_v2_api.py -v
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import math
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
import optimizr as opt
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 1. Risk measures -- historical VaR
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def test_historical_var_gaussian():
|
||
|
|
"""VaR_0.95 of N(0,1) losses is the 0.95-quantile ~= 1.6449."""
|
||
|
|
rng = np.random.default_rng(0)
|
||
|
|
losses = rng.standard_normal(200_000).tolist()
|
||
|
|
v95 = opt.historical_var_py(losses, 0.95)
|
||
|
|
assert math.isclose(v95, 1.6449, abs_tol=2e-2), f"got {v95}"
|
||
|
|
|
||
|
|
|
||
|
|
def test_historical_var_monotone_in_alpha():
|
||
|
|
rng = np.random.default_rng(1)
|
||
|
|
losses = rng.standard_normal(50_000).tolist()
|
||
|
|
v90 = opt.historical_var_py(losses, 0.90)
|
||
|
|
v95 = opt.historical_var_py(losses, 0.95)
|
||
|
|
v99 = opt.historical_var_py(losses, 0.99)
|
||
|
|
assert v90 < v95 < v99
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 2. Volterra -- fractional ODE (Caputo / Adams scheme)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def test_solve_fractional_ode_constant_rhs_matches_power_law():
|
||
|
|
"""For Caputo D^alpha h = c with h(0) = h0, the closed form is
|
||
|
|
h(t) = h0 + c * t^alpha / Gamma(alpha + 1)."""
|
||
|
|
alpha = 0.7
|
||
|
|
h0 = 1.0
|
||
|
|
c = 2.0
|
||
|
|
T = 1.0
|
||
|
|
out = opt.solve_fractional_ode(h0, alpha, T, 400, lambda t, h: c)
|
||
|
|
assert set(out.keys()) >= {"t_grid", "h"}
|
||
|
|
h_T = out["h"][-1]
|
||
|
|
expected = h0 + c * T ** alpha / math.gamma(alpha + 1.0)
|
||
|
|
assert math.isclose(h_T, expected, rel_tol=2e-2), f"got {h_T}, expected {expected}"
|
||
|
|
|
||
|
|
|
||
|
|
def test_solve_fractional_ode_zero_rhs_is_constant():
|
||
|
|
"""If the right-hand side is zero, the Caputo ODE preserves h0."""
|
||
|
|
out = opt.solve_fractional_ode(3.14, 0.5, 1.0, 200, lambda t, h: 0.0)
|
||
|
|
for hi in out["h"]:
|
||
|
|
assert math.isclose(hi, 3.14, abs_tol=1e-9)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 3. Volterra -- second-kind integral equation
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def test_solve_volterra_zero_kernel_returns_g():
|
||
|
|
"""If K(dt, y) = 0 the Volterra equation collapses to y(t) = g(t)."""
|
||
|
|
out = opt.solve_volterra(lambda t: t, lambda dt, y: 0.0, 1.0, 50)
|
||
|
|
grid = list(out["t_grid"])
|
||
|
|
y = list(out["y"])
|
||
|
|
assert len(grid) == len(y) == 51
|
||
|
|
for ti, yi in zip(grid, y):
|
||
|
|
assert math.isclose(yi, ti, abs_tol=1e-12)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 4. BSDE -- linear theta scheme with constant coefficients
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def test_linear_bsde_zero_coefficients_returns_terminal():
|
||
|
|
"""a = b = c = 0 reduces the BSDE to dY = -Z dW with terminal Y_T = K;
|
||
|
|
the unique solution is Y_t = K, Z_t = 0."""
|
||
|
|
res = opt.linear_bsde_constant_coeffs(
|
||
|
|
a_const=0.0, b_const=0.0, c_const=0.0,
|
||
|
|
terminal=2.5, n_steps=100, t_horizon=1.0, theta=0.5,
|
||
|
|
)
|
||
|
|
y = list(res["y"])
|
||
|
|
z = list(res["z"])
|
||
|
|
assert len(y) == 101 and len(z) == 100
|
||
|
|
for yi in y:
|
||
|
|
assert math.isclose(yi, 2.5, abs_tol=1e-9)
|
||
|
|
for zi in z:
|
||
|
|
assert math.isclose(zi, 0.0, abs_tol=1e-9)
|
||
|
|
|
||
|
|
|
||
|
|
def test_linear_bsde_pure_drift_grows_backward():
|
||
|
|
"""With a > 0, b = c = 0 and Y_T = 1 we get Y_t = exp(a (T - t))."""
|
||
|
|
a = 0.3
|
||
|
|
T = 1.0
|
||
|
|
res = opt.linear_bsde_constant_coeffs(
|
||
|
|
a_const=a, b_const=0.0, c_const=0.0,
|
||
|
|
terminal=1.0, n_steps=400, t_horizon=T, theta=0.5,
|
||
|
|
)
|
||
|
|
grid = list(res["time_grid"])
|
||
|
|
y = list(res["y"])
|
||
|
|
expected = [math.exp(a * (T - t)) for t in grid]
|
||
|
|
err = max(abs(yi - ei) for yi, ei in zip(y, expected))
|
||
|
|
assert err < 5e-3, f"max error {err}"
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 5. McKean-Vlasov -- mean-reverting toward the empirical mean
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def test_mean_reverting_mckean_vlasov_shapes_and_invariance():
|
||
|
|
"""Empirical mean is conserved in expectation by mean-reversion to it."""
|
||
|
|
n_part = 200
|
||
|
|
n_steps = 500
|
||
|
|
initial = np.linspace(-1.0, 1.0, n_part).tolist()
|
||
|
|
out = opt.mean_reverting_mckean_vlasov(
|
||
|
|
initial=initial, theta=1.0, sigma=0.0,
|
||
|
|
n_steps=n_steps, t_horizon=1.0, seed=42,
|
||
|
|
)
|
||
|
|
assert set(out.keys()) >= {"paths_flat", "n_steps", "n_particles", "time_grid"}
|
||
|
|
assert out["n_particles"] == n_part
|
||
|
|
assert out["n_steps"] == n_steps + 1
|
||
|
|
paths = np.array(out["paths_flat"]).reshape(n_steps + 1, n_part)
|
||
|
|
mean0 = float(np.mean(initial))
|
||
|
|
# With sigma = 0 and mean-reversion to the empirical mean,
|
||
|
|
# the cross-sectional mean must be preserved exactly.
|
||
|
|
assert math.isclose(float(np.mean(paths[-1])), mean0, abs_tol=1e-9)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 6. Module surface -- guard against accidental API removal
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
V2_PUBLIC_API = (
|
||
|
|
# v2.0 newcomers advertised in the blog post and README
|
||
|
|
"solve_fractional_ode",
|
||
|
|
"solve_volterra",
|
||
|
|
"linear_bsde_constant_coeffs",
|
||
|
|
"mean_reverting_mckean_vlasov",
|
||
|
|
"historical_var_py",
|
||
|
|
# v1.x primitives that must remain available (backward compat)
|
||
|
|
"differential_evolution",
|
||
|
|
"fit_hmm",
|
||
|
|
"viterbi_decode",
|
||
|
|
"mcmc_sample",
|
||
|
|
"grid_search",
|
||
|
|
"mutual_information",
|
||
|
|
"shannon_entropy",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("name", V2_PUBLIC_API)
|
||
|
|
def test_public_symbol_exposed(name):
|
||
|
|
assert hasattr(opt, name), f"optimizr.{name} is missing"
|
||
|
|
assert callable(getattr(opt, name)), f"optimizr.{name} is not callable"
|