feat(eval): VaR breach rate metric (#12) + HMM regime detector (#13) — rq-04 prep
CD / Lint / Test / Vet (push) Failing after 2s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped

#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:
2026-06-27 10:35:10 +02:00
co-authored by Claude Sonnet 4.6
parent 65a58fcca2
commit 68bf8f15c5
9 changed files with 743 additions and 1 deletions
+107
View File
@@ -0,0 +1,107 @@
package eval
import "math"
// VaRBreachRate computes the parametric 99% VaR breach rate and Kupiec POF p-value.
//
// VaR_99_t = predVol[t] × z99 (z99 = 2.326 for 99% normal VaR)
// breach_t = actualVol[t] > VaR_99_t (strict inequality)
// breachRate = fraction of breaches over all steps
// kupiecP = Kupiec POF p-value: P(chi²(1) > LR) where LR is the likelihood ratio
// testing H0: true breach probability = 1%. High p = well-calibrated.
//
// Returns (0, 1) for empty or mismatched input.
func VaRBreachRate(predVol, actualVol []float64, z99 float64) (breachRate, kupiecP float64) {
n := len(predVol)
if n == 0 || n != len(actualVol) {
return 0, 1
}
var n1 int
for i := 0; i < n; i++ {
if actualVol[i] > predVol[i]*z99 {
n1++
}
}
breachRate = float64(n1) / float64(n)
kupiecP = kupiecPOF(n, n1, 0.01)
return
}
// kupiecPOF returns the Kupiec Proportion-of-Failures p-value.
// H0: true breach probability = p0 (e.g. 0.01 for 99% VaR).
// Returns 1.0 for edge cases (n=0, p_hat=p0).
func kupiecPOF(n, n1 int, p0 float64) float64 {
if n == 0 {
return 1.0
}
n0 := n - n1
phat := float64(n1) / float64(n)
var lr float64
switch {
case n1 == 0:
// 0 × ln(0/p0) = 0 by convention; only the n0 term contributes
lr = 2 * float64(n0) * math.Log((1-phat)/(1-p0))
case n1 == n:
// n0 term vanishes
lr = 2 * float64(n1) * math.Log(phat/p0)
default:
lr = 2 * (float64(n1)*math.Log(phat/p0) + float64(n0)*math.Log((1-phat)/(1-p0)))
}
if lr <= 0 {
return 1.0
}
// P(chi²(1) > LR) = erfc(sqrt(LR/2)) [chi²(1) = Z², Z~N(0,1)]
return math.Erfc(math.Sqrt(lr / 2))
}
// LinearProbePredict fits ridge regression on (trainEmb, trainY) and returns
// predictions for testEmb. Complements LinearProbeTrainTest when the caller
// needs the raw predictions (e.g. to compute VaR breach rate).
// Returns nil when trainEmb is empty.
func LinearProbePredict(trainEmb [][]float64, trainY []float64,
testEmb [][]float64, lambda float64) []float64 {
n := len(trainEmb)
if n == 0 || len(testEmb) == 0 {
return nil
}
d := len(trainEmb[0])
p := d + 1
A := make([][]float64, n)
for i, e := range trainEmb {
row := make([]float64, p)
copy(row, e)
row[d] = 1.0
A[i] = row
}
AtA := make([][]float64, p)
for i := range AtA {
AtA[i] = make([]float64, p)
}
Aty := make([]float64, p)
for i := 0; i < n; i++ {
for j := 0; j < p; j++ {
Aty[j] += A[i][j] * trainY[i]
for k := 0; k < p; k++ {
AtA[j][k] += A[i][j] * A[i][k]
}
}
}
for j := 0; j < p; j++ {
AtA[j][j] += lambda
}
w := solveCholesky(AtA, Aty)
preds := make([]float64, len(testEmb))
for i, e := range testEmb {
row := make([]float64, p)
copy(row, e)
row[d] = 1.0
preds[i] = dot(row, w)
}
return preds
}
+138
View File
@@ -0,0 +1,138 @@
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))
}
}