Files
mathias 308f71b566
CD / Build & Import (push) Failing after 10s
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
fix(ci): Taskfile YAML syntax error + staticcheck tagged-switch (jepa-fx-risk#19)
Taskfile.yml line 46 had an unquoted Go-template `{{.VAR}}` inside a YAML
flow sequence (`cmds: [...]`) -- the literal braces broke YAML parsing
outright ("did not find expected ',' or ']'"), so `task check` (and thus
CI's push-triggered check job) failed before running a single command.
A manual `workflow_dispatch` re-run passed because the autoresearch
workflow never calls `task check` at all -- unrelated path, not an
env/secret difference as first suspected. Quoted the string.

Also fixed a staticcheck QF1002 in internal/eval/var.go: a boolean
switch comparing the same variable (n1) in every case is a tagged
switch in disguise -- converted to `switch n1 { case 0: ... case n: ...
}`.
2026-07-24 15:26:01 +02:00

108 lines
2.7 KiB
Go
Raw Permalink 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
}