generated from mathias/template-go-web
#12 — VaR_breach_rate_99_oos_regime_cond metric: - internal/eval/var.go: VaRBreachRate() + kupiecPOF() + LinearProbePredict() (stdlib math only) - internal/eval/var_test.go: 8 golden tests (zero/all breach, perfect calibration, boundary) - cmd/eval/main.go: -metric var flag (no-leakage probe → VaR → Kupiec P) - scripts/var_breach.py: Python equivalent with METRIC_KEY constant (13 TDD tests) - train.py LOCKED VaR EVAL BLOCK: writes VaR_breach_rate_99_oos_regime_cond + kupiec_p to metrics.json - Fixed bug: train.py used bare 'os' before import; now uses module-level '_os' consistently #13 — HMM regime detector + JEPA conditioning seam: - scripts/prepare_regime.py: GaussianHMM (diag, 3-state) on realized_vol; states sorted by mean vol (0=calm, 1=stressed, 2=crisis); deterministic (random_state=42); outputs eurusd_regime.parquet - tests/test_regime.py: 11 TDD tests (dtype, states, determinism, vol sort, daily fallback) - train.py: JEPA_ENABLE_REGIME toggle + REGIME CONDITIONING SEAM (concat baseline, agent-editable) - requirements.txt: hmmlearn>=0.3, scikit-learn>=1.4 78 Python + all Go tests green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""Tests for scripts/prepare_regime.py — HMM regime detector (jepa-fx-risk#13).
|
||||
|
||||
TDD: tests first, implementation follows.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
_SCRIPT = Path(__file__).parent.parent / "scripts" / "prepare_regime.py"
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent / "data" / "processed"
|
||||
HOURLY = DATA_DIR / "eurusd_hourly.parquet"
|
||||
DAILY = DATA_DIR / "eurusd_daily.parquet"
|
||||
|
||||
|
||||
def _import():
|
||||
spec = importlib.util.spec_from_file_location("prepare_regime", _SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mod():
|
||||
return _import()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fit_regime_hmm — pure function (doesn't touch disk)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _synthetic_rv(seed=42, size=500):
|
||||
"""Noisy 3-regime vol series: calm→stressed→crisis→calm interleaved."""
|
||||
rng = np.random.default_rng(seed)
|
||||
low = np.abs(rng.normal(0.005, 0.001, size=size // 3))
|
||||
mid = np.abs(rng.normal(0.015, 0.003, size=size // 3))
|
||||
high = np.abs(rng.normal(0.04, 0.008, size=size - 2 * (size // 3)))
|
||||
return np.concatenate([low, mid, high])
|
||||
|
||||
|
||||
class TestFitRegimeHmm:
|
||||
def test_returns_integer_labels(self, mod):
|
||||
rv = _synthetic_rv(seed=0)
|
||||
labels = mod.fit_regime_hmm(rv, n_states=3, random_state=42)
|
||||
assert np.issubdtype(labels.dtype, np.integer), f"dtype={labels.dtype}"
|
||||
assert len(labels) == len(rv)
|
||||
|
||||
def test_states_are_0_1_2(self, mod):
|
||||
rv = _synthetic_rv(seed=1)
|
||||
labels = mod.fit_regime_hmm(rv, n_states=3, random_state=42)
|
||||
unique = set(labels.tolist())
|
||||
assert unique.issubset({0, 1, 2}), f"unexpected states: {unique}"
|
||||
|
||||
def test_deterministic(self, mod):
|
||||
rv = _synthetic_rv(seed=7)
|
||||
a = mod.fit_regime_hmm(rv, n_states=3, random_state=42)
|
||||
b = mod.fit_regime_hmm(rv, n_states=3, random_state=42)
|
||||
assert np.array_equal(a, b), "HMM not deterministic with same random_state"
|
||||
|
||||
def test_sorted_by_vol_asc(self, mod):
|
||||
# 3 clearly separated noisy clusters; state 0 should be calm, 2 should be crisis.
|
||||
rng = np.random.default_rng(42)
|
||||
n = 200
|
||||
low = np.abs(rng.normal(0.005, 0.001, n))
|
||||
mid = np.abs(rng.normal(0.015, 0.003, n))
|
||||
high = np.abs(rng.normal(0.05, 0.008, n))
|
||||
rv = np.concatenate([low, mid, high])
|
||||
labels = mod.fit_regime_hmm(rv, n_states=3, random_state=42)
|
||||
# Mean regime label in the high-vol section should exceed mean in the low-vol section.
|
||||
assert labels[2*n:].mean() > labels[:n].mean(), \
|
||||
"crisis section mean regime label should exceed calm section"
|
||||
# The calm section should not be labeled as crisis (2) dominantly.
|
||||
calm_modal = int(np.bincount(labels[:n]).argmax())
|
||||
assert calm_modal < 2, f"calm section mostly labeled {calm_modal}, expected 0 or 1"
|
||||
|
||||
def test_two_states(self, mod):
|
||||
rv = _synthetic_rv(seed=0)
|
||||
labels = mod.fit_regime_hmm(rv, n_states=2, random_state=42)
|
||||
unique = set(labels.tolist())
|
||||
assert unique.issubset({0, 1})
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prepare_regime_df — reads parquet, fits HMM, returns DataFrame
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPrepareRegimeDf:
|
||||
@pytest.mark.skipif(not HOURLY.exists(), reason="hourly parquet not available")
|
||||
def test_output_columns(self, mod):
|
||||
df = mod.prepare_regime_df(str(HOURLY), freq="hourly")
|
||||
assert "datetime" in df.columns
|
||||
assert "regime" in df.columns
|
||||
|
||||
@pytest.mark.skipif(not HOURLY.exists(), reason="hourly parquet not available")
|
||||
def test_regime_values(self, mod):
|
||||
df = mod.prepare_regime_df(str(HOURLY), freq="hourly")
|
||||
unique = set(df["regime"].tolist())
|
||||
assert unique.issubset({0, 1, 2}), f"unexpected regime values: {unique}"
|
||||
|
||||
@pytest.mark.skipif(not HOURLY.exists(), reason="hourly parquet not available")
|
||||
def test_no_nulls(self, mod):
|
||||
df = mod.prepare_regime_df(str(HOURLY), freq="hourly")
|
||||
assert df["regime"].isna().sum() == 0
|
||||
|
||||
@pytest.mark.skipif(not DAILY.exists(), reason="daily parquet not available")
|
||||
def test_daily_fallback(self, mod):
|
||||
df = mod.prepare_regime_df(str(DAILY), freq="daily")
|
||||
assert "regime" in df.columns
|
||||
assert set(df["regime"].tolist()).issubset({0, 1, 2})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: check that train.py REGIME SEAM exists and is togglable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTrainPyRegimeSeam:
|
||||
def test_enable_regime_env_var_documented(self):
|
||||
train_py = Path(__file__).parent.parent / "train.py"
|
||||
content = train_py.read_text()
|
||||
assert "JEPA_ENABLE_REGIME" in content, "JEPA_ENABLE_REGIME toggle not found in train.py"
|
||||
|
||||
def test_regime_seam_comment_present(self):
|
||||
train_py = Path(__file__).parent.parent / "train.py"
|
||||
content = train_py.read_text()
|
||||
assert "REGIME" in content and "seam" in content.lower(), \
|
||||
"agent-editable regime seam marker not found in train.py"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for scripts/var_breach.py — VaR breach rate + Kupiec POF (jepa-fx-risk#12).
|
||||
|
||||
Golden tests first: verify the math before wiring it into train.py.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_SCRIPT = Path(__file__).parent.parent / "scripts" / "var_breach.py"
|
||||
|
||||
|
||||
def _import():
|
||||
spec = importlib.util.spec_from_file_location("var_breach", _SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mod():
|
||||
return _import()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# var_breach_rate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVarBreachRate:
|
||||
def test_zero_breaches(self, mod):
|
||||
# 0.02 < 0.01×2.326=0.02326 → no breach
|
||||
rate, _ = mod.var_breach_rate([0.01, 0.01], [0.02, 0.02])
|
||||
assert rate == 0.0
|
||||
|
||||
def test_all_breach(self, mod):
|
||||
# 0.03 > 0.02326 → all breach
|
||||
rate, _ = mod.var_breach_rate([0.01, 0.01], [0.03, 0.03])
|
||||
assert rate == 1.0
|
||||
|
||||
def test_golden_two_of_ten(self, mod):
|
||||
pred = [0.01] * 10
|
||||
actual = [0.01] * 10
|
||||
actual[0] = 0.03 # breach
|
||||
actual[2] = 0.03 # breach
|
||||
rate, kupiec_p = mod.var_breach_rate(pred, actual)
|
||||
assert abs(rate - 0.2) < 1e-9, f"rate={rate}"
|
||||
assert kupiec_p < 0.05, f"kupiec_p={kupiec_p}" # strong reject
|
||||
|
||||
def test_perfect_calibration(self, mod):
|
||||
# n=100, 1 breach → p_hat=0.01=p0=0.01 → LR=0 → kupiec_p≈1
|
||||
pred = [0.01] * 100
|
||||
actual = [0.015] * 100
|
||||
actual[0] = 0.025 # 0.025 > 0.02326 → breach
|
||||
rate, kupiec_p = mod.var_breach_rate(pred, actual)
|
||||
assert abs(rate - 0.01) < 1e-9
|
||||
assert kupiec_p > 0.9, f"kupiec_p={kupiec_p}"
|
||||
|
||||
def test_boundary_at_var_is_not_breach(self, mod):
|
||||
# exactly at VaR_99 is NOT a breach (strict >)
|
||||
z99 = 2.326
|
||||
var = 0.01 * z99
|
||||
rate, _ = mod.var_breach_rate([0.01], [var], z99=z99)
|
||||
assert rate == 0.0
|
||||
|
||||
def test_empty_returns_zero_one(self, mod):
|
||||
rate, kupiec_p = mod.var_breach_rate([], [])
|
||||
assert rate == 0.0
|
||||
assert kupiec_p == 1.0
|
||||
|
||||
def test_metric_key_no_whitespace(self, mod):
|
||||
key = mod.METRIC_KEY
|
||||
assert key == key.strip(), f"metric key has surrounding whitespace: {key!r}"
|
||||
assert " " not in key, f"metric key contains space: {key!r}"
|
||||
|
||||
def test_metric_key_is_canonical(self, mod):
|
||||
assert mod.METRIC_KEY == "VaR_breach_rate_99_oos_regime_cond"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# kupiec_pvalue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKupiecPValue:
|
||||
def test_perfectly_calibrated(self, mod):
|
||||
# p_hat == p0 → LR=0 → p-value=1
|
||||
p = mod.kupiec_pvalue(100, 1, p0=0.01)
|
||||
assert p > 0.99, f"p={p}"
|
||||
|
||||
def test_strong_reject_high_breach(self, mod):
|
||||
# 20% breach when 1% expected → p << 0.05
|
||||
p = mod.kupiec_pvalue(100, 20, p0=0.01)
|
||||
assert p < 0.001, f"p={p}"
|
||||
|
||||
def test_zero_breaches_not_nan(self, mod):
|
||||
p = mod.kupiec_pvalue(100, 0, p0=0.01)
|
||||
assert not math.isnan(p)
|
||||
assert 0 <= p <= 1.0
|
||||
|
||||
def test_all_breaches_not_nan(self, mod):
|
||||
p = mod.kupiec_pvalue(10, 10, p0=0.01)
|
||||
assert not math.isnan(p)
|
||||
assert p < 0.001 # extremely unlikely
|
||||
|
||||
def test_zero_observations(self, mod):
|
||||
p = mod.kupiec_pvalue(0, 0)
|
||||
assert p == 1.0
|
||||
Reference in New Issue
Block a user