Files
jepa-fx-risk/internal/eval/var.go
T
mathias 73698814e8
CD / Lint / Test / Vet (push) Successful in 5s
style: apply gofmt
Estate-wide consolidation pass. golangci-lint v2 default set enables NO
formatter, so gofmt drift accumulated silently across the estate -- 8 repos
were carrying it, this one included. Same hole that let two files rot in
dma-cli until they were found by hand (dma-cli#12).

Mechanical and deterministic: `gofmt -w`, nothing hand-edited. Verified
before commit -- gofmt -l now reports 0, go build and go vet both pass.

Clears this repo to adopt the canonical .golangci.yml (infra
ci-templates/golangci.yml, infra#309 pattern) in a later wave.
2026-09-05 12:40:35 +02:00

109 lines
2.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 n1 {
case 0:
// 0 × ln(0/p0) = 0 by convention; only the n0 term contributes
lr = 2 * float64(n0) * math.Log((1-phat)/(1-p0))
case 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
}