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>
139 lines
4.1 KiB
Go
139 lines
4.1 KiB
Go
package eval_test
|
||
|
||
import (
|
||
"math"
|
||
"testing"
|
||
|
||
"gitea.d-ma.be/mathias/jepa-fx-risk/internal/eval"
|
||
)
|
||
|
||
// ── VaRBreachRate golden tests ──────────────────────────────────────────────
|
||
//
|
||
// VaR_99_t = predVol[t] × z99 (parametric 99% normal VaR)
|
||
// breach_t = actualVol[t] > VaR_99_t
|
||
// breachRate = mean(breach_t)
|
||
// kupiecP = Kupiec POF p-value (chi²(1) test, H0: breach rate = 1%)
|
||
|
||
func TestVaRBreachRate_ZeroBreaches(t *testing.T) {
|
||
// 0.02 < 0.01×2.326=0.02326 → no breaches
|
||
pred := []float64{0.01, 0.01, 0.01}
|
||
act := []float64{0.02, 0.02, 0.02}
|
||
rate, _ := eval.VaRBreachRate(pred, act, 2.326)
|
||
if rate != 0 {
|
||
t.Fatalf("want rate=0, got %.4f", rate)
|
||
}
|
||
}
|
||
|
||
func TestVaRBreachRate_AllBreach(t *testing.T) {
|
||
// 0.03 > 0.02326 → all breach
|
||
pred := []float64{0.01, 0.01}
|
||
act := []float64{0.03, 0.03}
|
||
rate, _ := eval.VaRBreachRate(pred, act, 2.326)
|
||
if math.Abs(rate-1.0) > 1e-9 {
|
||
t.Fatalf("want rate=1.0, got %.4f", rate)
|
||
}
|
||
}
|
||
|
||
func TestVaRBreachRate_Golden(t *testing.T) {
|
||
// n=10, 2 breaches at indices 0 and 2 → rate=0.2
|
||
// Kupiec: p_hat=0.2 vs p0=0.01 → strongly reject H0 (p < 0.05)
|
||
pred := make([]float64, 10)
|
||
act := make([]float64, 10)
|
||
for i := range pred {
|
||
pred[i] = 0.01
|
||
act[i] = 0.01 // no breach: 0.01 < 0.02326
|
||
}
|
||
act[0] = 0.03 // breach
|
||
act[2] = 0.03 // breach
|
||
|
||
rate, kupiecP := eval.VaRBreachRate(pred, act, 2.326)
|
||
|
||
if math.Abs(rate-0.2) > 1e-9 {
|
||
t.Fatalf("breach rate: want 0.2, got %.4f", rate)
|
||
}
|
||
if kupiecP > 0.05 {
|
||
t.Fatalf("kupiec p-value: want <0.05 (strong reject H0), got %.4f", kupiecP)
|
||
}
|
||
}
|
||
|
||
func TestVaRBreachRate_PerfectCalibration(t *testing.T) {
|
||
// n=100, exactly 1 breach → p_hat=0.01=p0 → LR=0 → kupiecP≈1.0
|
||
n := 100
|
||
pred := make([]float64, n)
|
||
act := make([]float64, n)
|
||
for i := range pred {
|
||
pred[i] = 0.01
|
||
act[i] = 0.015 // < 0.02326, no breach
|
||
}
|
||
act[0] = 0.025 // > 0.02326, breach
|
||
|
||
rate, kupiecP := eval.VaRBreachRate(pred, act, 2.326)
|
||
|
||
if math.Abs(rate-0.01) > 1e-9 {
|
||
t.Fatalf("breach rate: want 0.01, got %.4f", rate)
|
||
}
|
||
if kupiecP < 0.9 {
|
||
t.Fatalf("kupiec p-value: want ≈1.0 (well calibrated), got %.4f", kupiecP)
|
||
}
|
||
}
|
||
|
||
func TestVaRBreachRate_EmptyInput(t *testing.T) {
|
||
rate, kupiecP := eval.VaRBreachRate(nil, nil, 2.326)
|
||
if rate != 0 || kupiecP != 1 {
|
||
t.Fatalf("empty: want (0,1), got (%.4f,%.4f)", rate, kupiecP)
|
||
}
|
||
}
|
||
|
||
func TestVaRBreachRate_LenMismatch(t *testing.T) {
|
||
rate, kupiecP := eval.VaRBreachRate([]float64{0.01}, []float64{0.01, 0.02}, 2.326)
|
||
if rate != 0 || kupiecP != 1 {
|
||
t.Fatalf("mismatch: want (0,1), got (%.4f,%.4f)", rate, kupiecP)
|
||
}
|
||
}
|
||
|
||
func TestVaRBreachRate_Z99Default(t *testing.T) {
|
||
// z99=2.326 is the canonical value; test that boundary case works
|
||
// VaR = 0.01 × 2.326 = 0.02326
|
||
// actual = 0.02326 → NOT a breach (strict >)
|
||
pred := []float64{0.01}
|
||
act := []float64{0.02326}
|
||
rate, _ := eval.VaRBreachRate(pred, act, 2.326)
|
||
if rate != 0 {
|
||
t.Fatalf("boundary: exactly at VaR is not a breach; want rate=0, got %.4f", rate)
|
||
}
|
||
}
|
||
|
||
// ── LinearProbePredict ──────────────────────────────────────────────────────
|
||
|
||
func TestLinearProbePredict_PerfectLinear(t *testing.T) {
|
||
// y = x; predictions should match targets closely
|
||
n := 20
|
||
trainEmb := make([][]float64, n)
|
||
trainY := make([]float64, n)
|
||
testEmb := make([][]float64, 5)
|
||
testY := []float64{5, 10, 15, 20, 25}
|
||
for i := range trainEmb {
|
||
trainEmb[i] = []float64{float64(i)}
|
||
trainY[i] = float64(i)
|
||
}
|
||
for i := range testEmb {
|
||
testEmb[i] = []float64{testY[i]}
|
||
}
|
||
preds := eval.LinearProbePredict(trainEmb, trainY, testEmb, 1e-3)
|
||
if len(preds) != len(testEmb) {
|
||
t.Fatalf("len: want %d, got %d", len(testEmb), len(preds))
|
||
}
|
||
for i, p := range preds {
|
||
if math.Abs(p-testY[i]) > 1.0 {
|
||
t.Fatalf("pred[%d]: want ≈%.1f, got %.4f", i, testY[i], p)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestLinearProbePredict_EmptyTrain(t *testing.T) {
|
||
preds := eval.LinearProbePredict(nil, nil, [][]float64{{1.0}}, 1e-3)
|
||
if len(preds) != 0 {
|
||
t.Fatalf("empty train: want nil/empty preds, got len=%d", len(preds))
|
||
}
|
||
}
|