generated from mathias/template-go-web
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c9bc1be31 | ||
|
|
308f71b566 | ||
|
|
6fb77f5263 | ||
|
|
8eb0358c01 | ||
|
|
2d412790bf | ||
|
|
8539ec3a85 | ||
|
|
c9b22db3b8 | ||
|
|
731673061e | ||
|
|
54b1bc216d | ||
|
|
fa6dcaae0a | ||
|
|
68bf8f15c5 | ||
|
|
65a58fcca2 |
@@ -0,0 +1,75 @@
|
|||||||
|
name: Autoresearch Loop
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
fixture:
|
||||||
|
description: 'Fixture name in fixtures/ (without .json)'
|
||||||
|
required: true
|
||||||
|
default: 'phase-a-toy'
|
||||||
|
rq_id:
|
||||||
|
description: 'Run ID — defaults to fixture name if blank'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
iters:
|
||||||
|
description: 'Max iterations'
|
||||||
|
required: false
|
||||||
|
default: '3'
|
||||||
|
model:
|
||||||
|
description: 'LiteLLM model override (leave blank for default berget/gemma4-31b)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run:
|
||||||
|
name: Autoresearch — ${{ inputs.fixture }}
|
||||||
|
runs-on: self-hosted
|
||||||
|
timeout-minutes: 90
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve run ID
|
||||||
|
id: vars
|
||||||
|
run: |
|
||||||
|
RQ_ID="${{ inputs.rq_id }}"
|
||||||
|
[ -z "$RQ_ID" ] && RQ_ID="${{ inputs.fixture }}"
|
||||||
|
echo "rq_id=$RQ_ID" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Clean stale run dir
|
||||||
|
run: rm -rf "runs/${{ steps.vars.outputs.rq_id }}"
|
||||||
|
|
||||||
|
- name: Set up Python venv
|
||||||
|
run: |
|
||||||
|
[ -d .venv ] || python3 -m venv .venv
|
||||||
|
# torch must come from the cu130 wheel index (koala Blackwell sm_120);
|
||||||
|
# requirements.txt deliberately excludes it. Install it first.
|
||||||
|
.venv/bin/pip install -q torch --index-url https://download.pytorch.org/whl/cu130
|
||||||
|
.venv/bin/pip install -q -r requirements.txt
|
||||||
|
|
||||||
|
- name: Scaffold run dir
|
||||||
|
run: |
|
||||||
|
.venv/bin/python scripts/autoresearch_start.py \
|
||||||
|
"fixtures/${{ inputs.fixture }}.json" \
|
||||||
|
"${{ steps.vars.outputs.rq_id }}"
|
||||||
|
|
||||||
|
- name: Run autoresearch loop
|
||||||
|
env:
|
||||||
|
LITELLM_KEY: ${{ secrets.LITELLM_KEY }}
|
||||||
|
LITELLM_BASE: ${{ secrets.LITELLM_BASE }}
|
||||||
|
NTFY_URL: ${{ secrets.NTFY_URL }}
|
||||||
|
run: |
|
||||||
|
ARGS="--run-dir runs/${{ steps.vars.outputs.rq_id }} --iters ${{ inputs.iters }}"
|
||||||
|
[ -n "${{ inputs.model }}" ] && ARGS="$ARGS --model ${{ inputs.model }}"
|
||||||
|
.venv/bin/python loop.py $ARGS
|
||||||
|
|
||||||
|
- name: Upload run artifacts
|
||||||
|
if: always()
|
||||||
|
uses: https://gitea.com/actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: run-${{ steps.vars.outputs.rq_id }}-${{ github.run_number }}
|
||||||
|
path: |
|
||||||
|
runs/${{ steps.vars.outputs.rq_id }}/STATUS.md
|
||||||
|
runs/${{ steps.vars.outputs.rq_id }}/metrics.json
|
||||||
|
runs/${{ steps.vars.outputs.rq_id }}/program.md
|
||||||
|
retention-days: 30
|
||||||
@@ -7,9 +7,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
env:
|
|
||||||
IMAGE: hostexecutor
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
name: Lint / Test / Vet
|
name: Lint / Test / Vet
|
||||||
@@ -31,85 +28,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Run checks
|
- name: Run checks
|
||||||
run: task check
|
run: task check
|
||||||
|
|
||||||
build:
|
|
||||||
name: Build & Import
|
|
||||||
needs: check
|
|
||||||
runs-on: self-hosted
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
outputs:
|
|
||||||
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Derive image tags
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
SHA=$(git rev-parse --short HEAD)
|
|
||||||
echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Build and push to local registry
|
|
||||||
run: |
|
|
||||||
REGISTRY="localhost:5000"
|
|
||||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
|
||||||
buildah build \
|
|
||||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
|
||||||
-t ${REF} \
|
|
||||||
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
|
|
||||||
.
|
|
||||||
buildah push --tls-verify=false ${REF}
|
|
||||||
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
|
|
||||||
echo "✓ Image pushed to ${REF}"
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Deploy via GitOps
|
|
||||||
needs: build
|
|
||||||
runs-on: self-hosted
|
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
|
||||||
steps:
|
|
||||||
- name: Update image tag in infra repo
|
|
||||||
env:
|
|
||||||
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
|
|
||||||
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
|
|
||||||
chmod 600 ~/.ssh/id_infra
|
|
||||||
ssh-keyscan -p 30022 10.0.1.20 >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_infra -o IdentitiesOnly=yes"
|
|
||||||
rm -rf /tmp/infra
|
|
||||||
git clone -b main ssh://git@10.0.1.20:30022/mathias/infra.git /tmp/infra
|
|
||||||
cd /tmp/infra
|
|
||||||
DEPLOYMENT="k3s/apps/hostexecutor/deployment.yaml"
|
|
||||||
sed -i "s|image: localhost:5000/hostexecutor:.*|image: localhost:5000/hostexecutor:${IMAGE_TAG}|" "$DEPLOYMENT"
|
|
||||||
grep -q "localhost:5000/hostexecutor:${IMAGE_TAG}" "$DEPLOYMENT" \
|
|
||||||
|| { echo "✗ image tag patch failed"; exit 1; }
|
|
||||||
if git diff --quiet "$DEPLOYMENT"; then
|
|
||||||
echo "ℹ image tag unchanged — skipping push"
|
|
||||||
else
|
|
||||||
git -c user.name="hostexecutor CI" \
|
|
||||||
-c user.email="ci@hostexecutor.local" \
|
|
||||||
commit -m "chore(deploy): hostexecutor → ${IMAGE_TAG}" "$DEPLOYMENT"
|
|
||||||
git push origin main
|
|
||||||
echo "✓ pushed to infra repo"
|
|
||||||
fi
|
|
||||||
shred -u ~/.ssh/id_infra
|
|
||||||
|
|
||||||
- name: Trigger Flux reconcile
|
|
||||||
run: |
|
|
||||||
kubectl -n flux-system annotate gitrepository flux-system \
|
|
||||||
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
|
|
||||||
kubectl -n flux-system annotate kustomization apps \
|
|
||||||
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
|
|
||||||
|
|
||||||
- name: Verify rollout
|
|
||||||
run: |
|
|
||||||
kubectl rollout status deployment/hostexecutor \
|
|
||||||
--namespace hostexecutor \
|
|
||||||
--timeout=120s \
|
|
||||||
|| {
|
|
||||||
kubectl get pods -n hostexecutor -o wide
|
|
||||||
kubectl get events -n hostexecutor --sort-by='.lastTimestamp' | tail -20
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|||||||
+17
@@ -34,3 +34,20 @@ bin/
|
|||||||
|
|
||||||
# downloaded + processed market data (track via DVC/MinIO, #10 — not git)
|
# downloaded + processed market data (track via DVC/MinIO, #10 — not git)
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# autoresearch run dirs (ephemeral; each scaffold rebuilds from fixtures/)
|
||||||
|
runs/
|
||||||
|
|
||||||
|
# ephemeral experiment outputs (generated by train.py / loop.py)
|
||||||
|
metrics.json
|
||||||
|
embeddings.json
|
||||||
|
HEARTBEAT
|
||||||
|
STATUS.md
|
||||||
|
|
||||||
|
# python caches
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# built Go binaries
|
||||||
|
eval
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ tasks:
|
|||||||
cmds: [.venv/bin/python scripts/fetch_multipair.py]
|
cmds: [.venv/bin/python scripts/fetch_multipair.py]
|
||||||
data:prepare:pair:
|
data:prepare:pair:
|
||||||
desc: "Build {PAIR}_hourly.parquet from data/raw/{PAIR}/ (e.g. PAIR=gbpusd)"
|
desc: "Build {PAIR}_hourly.parquet from data/raw/{PAIR}/ (e.g. PAIR=gbpusd)"
|
||||||
cmds: [PAIR={{.PAIR}} .venv/bin/python scripts/prepare_hourly.py {{.EXTRA_ARGS}}]
|
cmds: ["PAIR={{.PAIR}} .venv/bin/python scripts/prepare_hourly.py {{.EXTRA_ARGS}}"]
|
||||||
vars:
|
vars:
|
||||||
PAIR: '{{default "eurusd" .PAIR}}'
|
PAIR: '{{default "eurusd" .PAIR}}'
|
||||||
data:prepare:multipair:
|
data:prepare:multipair:
|
||||||
|
|||||||
@@ -83,6 +83,32 @@ func main() {
|
|||||||
fmt.Printf(`{"metric":"effective_rank","value":%.6f}`+"\n", er)
|
fmt.Printf(`{"metric":"effective_rank","value":%.6f}`+"\n", er)
|
||||||
log.Info("effective rank", "erank", fmt.Sprintf("%.2f", er))
|
log.Info("effective rank", "erank", fmt.Sprintf("%.2f", er))
|
||||||
|
|
||||||
|
case "var":
|
||||||
|
// Parametric 99% VaR breach rate from probe predictions vs actual realized vol.
|
||||||
|
// Requires train_embeddings (for no-leakage probe fit) and realized_vol (OOS).
|
||||||
|
if len(d.RealizedVol) == 0 {
|
||||||
|
log.Error("var requires realized_vol in embeddings.json")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
var predVol []float64
|
||||||
|
if len(d.TrainEmbeddings) > 0 {
|
||||||
|
trEmb, mu, sd := standardiseCompute(d.TrainEmbeddings)
|
||||||
|
oosEmb := applyStandardise(d.Embeddings, mu, sd)
|
||||||
|
predVol = eval.LinearProbePredict(trEmb, d.TrainRealizedVol, oosEmb, 1e-3)
|
||||||
|
} else {
|
||||||
|
oosEmb, mu, sd := standardiseCompute(d.Embeddings)
|
||||||
|
n70 := int(float64(len(oosEmb)) * 0.7)
|
||||||
|
oos70 := applyStandardise(d.Embeddings[n70:], mu, sd)
|
||||||
|
predVol = eval.LinearProbePredict(oosEmb[:n70], d.RealizedVol[:n70], oos70, 1e-3)
|
||||||
|
d.RealizedVol = d.RealizedVol[n70:]
|
||||||
|
}
|
||||||
|
const z99 = 2.326
|
||||||
|
breachRate, kupiecP := eval.VaRBreachRate(predVol, d.RealizedVol, z99)
|
||||||
|
fmt.Printf(`{"metric":"VaR_breach_rate_99_oos_regime_cond","value":%.6f,"kupiec_p":%.6f}`+"\n",
|
||||||
|
breachRate, kupiecP)
|
||||||
|
log.Info("VaR breach rate 99%", "breach_rate", fmt.Sprintf("%.4f", breachRate),
|
||||||
|
"kupiec_p", fmt.Sprintf("%.4f", kupiecP))
|
||||||
|
|
||||||
default:
|
default:
|
||||||
log.Error("unknown metric", "metric", *metric)
|
log.Error("unknown metric", "metric", *metric)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"strategic_question": "What is the highest-leverage path to a JEPA-based FX tail-risk system that beats a GARCH/EWMA baseline on out-of-sample VaR-breach calibration, given one GPU and a solo researcher?",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "phase-a-toy",
|
||||||
|
"status": "autoresearch-ready",
|
||||||
|
"question": "Improve the OOS linear-probe R² (val_vol_r2) of the HEPA encoder on EUR/USD daily realized vol. The encoder is a small causal transformer trained with VICReg. Vary one hyperparameter or architectural choice per iteration — model size, learning rate, window, patch length, depth, VICReg loss weights — to push val_vol_r2 as high as possible on the 2022-2023 OOS slice.",
|
||||||
|
"candidate_metric": "val_vol_r2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ Agent (on iguana/berget — NOT koala, whose GPU is reserved for train.py) reads
|
|||||||
program.md + train.py + STATUS.md, proposes ONE change to train.py, we run it,
|
program.md + train.py + STATUS.md, proposes ONE change to train.py, we run it,
|
||||||
keep if val_vol_r2 improved else git-revert. Appends per-iter record to STATUS.md.
|
keep if val_vol_r2 improved else git-revert. Appends per-iter record to STATUS.md.
|
||||||
|
|
||||||
LITELLM_KEY=xxx python loop.py [--iters N] [--model MODEL]
|
LITELLM_KEY=xxx python loop.py [--iters N] [--model MODEL] [--run-dir runs/rq-04]
|
||||||
|
|
||||||
Env:
|
Env:
|
||||||
LITELLM_KEY — LiteLLM master key (required)
|
LITELLM_KEY — LiteLLM master key (required)
|
||||||
@@ -12,6 +12,7 @@ Env:
|
|||||||
LOOP_MODEL — default berget/gemma4-31b (non-thinking; iguana/berget only)
|
LOOP_MODEL — default berget/gemma4-31b (non-thinking; iguana/berget only)
|
||||||
LOOP_ITERS — default 3
|
LOOP_ITERS — default 3
|
||||||
TRAIN_TIMEOUT — seconds per train.py run, default 120
|
TRAIN_TIMEOUT — seconds per train.py run, default 120
|
||||||
|
NTFY_URL — optional: POST crash/stall alerts here (e.g. ntfy.sh/<topic>)
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -29,9 +30,14 @@ LITELLM_KEY = os.environ.get("LITELLM_KEY", "")
|
|||||||
LOOP_MODEL = os.environ.get("LOOP_MODEL", "berget/gemma4-31b")
|
LOOP_MODEL = os.environ.get("LOOP_MODEL", "berget/gemma4-31b")
|
||||||
LOOP_ITERS = int(os.environ.get("LOOP_ITERS", "3"))
|
LOOP_ITERS = int(os.environ.get("LOOP_ITERS", "3"))
|
||||||
TRAIN_TIMEOUT = int(os.environ.get("TRAIN_TIMEOUT", "120"))
|
TRAIN_TIMEOUT = int(os.environ.get("TRAIN_TIMEOUT", "120"))
|
||||||
|
NTFY_URL = os.environ.get("NTFY_URL", "")
|
||||||
|
|
||||||
|
# Resolved by main() once --run-dir is parsed.
|
||||||
|
RUN_DIR = Path(".")
|
||||||
STATUS_MD = Path("STATUS.md")
|
STATUS_MD = Path("STATUS.md")
|
||||||
METRICS_JSON = Path("metrics.json")
|
METRICS_JSON = Path("metrics.json")
|
||||||
TRAIN_PY = Path("train.py")
|
TRAIN_PY = Path("train.py")
|
||||||
|
HEARTBEAT = Path("HEARTBEAT")
|
||||||
|
|
||||||
AGENT_SYSTEM = textwrap.dedent("""\
|
AGENT_SYSTEM = textwrap.dedent("""\
|
||||||
You are the autoresearch agent for jepa-fx-risk. Your job: propose ONE small,
|
You are the autoresearch agent for jepa-fx-risk. Your job: propose ONE small,
|
||||||
@@ -64,7 +70,7 @@ def gpu_snapshot() -> str:
|
|||||||
return "gpu=N/A"
|
return "gpu=N/A"
|
||||||
|
|
||||||
|
|
||||||
def read_metric() -> float | None:
|
def read_metric() -> "float | None":
|
||||||
if not METRICS_JSON.exists():
|
if not METRICS_JSON.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -73,14 +79,19 @@ def read_metric() -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def run_train() -> tuple[float | None, float, str]:
|
def run_train() -> "tuple[float | None, float, str]":
|
||||||
"""Run train.py. Returns (val_vol_r2 or None, wall_secs, stderr_tail)."""
|
"""Run train.py from project root with METRICS_OUT pointing into the run dir."""
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
gpu_before = gpu_snapshot()
|
env = dict(os.environ)
|
||||||
|
env["METRICS_OUT"] = str(METRICS_JSON.resolve())
|
||||||
|
# train.py is copied into the run dir, so sys.path[0] is that run dir — which
|
||||||
|
# has no scripts/. Put the project root (where loop.py + scripts/ live) on
|
||||||
|
# PYTHONPATH so train.py's `from scripts.var_breach import ...` resolves.
|
||||||
|
env["PYTHONPATH"] = str(Path(__file__).resolve().parent) + os.pathsep + env.get("PYTHONPATH", "")
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
[sys.executable, "train.py"],
|
[sys.executable, str(TRAIN_PY.resolve())],
|
||||||
capture_output=True, text=True, timeout=TRAIN_TIMEOUT,
|
capture_output=True, text=True, timeout=TRAIN_TIMEOUT, env=env,
|
||||||
)
|
)
|
||||||
elapsed = time.time() - t0
|
elapsed = time.time() - t0
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
@@ -91,10 +102,10 @@ def run_train() -> tuple[float | None, float, str]:
|
|||||||
return None, TRAIN_TIMEOUT, "TIMEOUT"
|
return None, TRAIN_TIMEOUT, "TIMEOUT"
|
||||||
|
|
||||||
|
|
||||||
def call_agent(iteration: int, best_so_far: float | None) -> str:
|
def call_agent(iteration: int, best_so_far: "float | None") -> str:
|
||||||
"""Ask the LLM agent to edit train.py. Returns new train.py content."""
|
"""Ask the LLM agent to edit train.py. Returns new train.py content."""
|
||||||
context = "\n\n".join([
|
context = "\n\n".join([
|
||||||
"# program.md\n" + read_file(Path("program.md")),
|
"# program.md\n" + read_file(RUN_DIR / "program.md"),
|
||||||
"# train.py (current)\n" + read_file(TRAIN_PY),
|
"# train.py (current)\n" + read_file(TRAIN_PY),
|
||||||
"# STATUS.md (history)\n" + read_file(STATUS_MD)[-2000:],
|
"# STATUS.md (history)\n" + read_file(STATUS_MD)[-2000:],
|
||||||
"# metrics.json (last run)\n" + read_file(METRICS_JSON),
|
"# metrics.json (last run)\n" + read_file(METRICS_JSON),
|
||||||
@@ -132,37 +143,96 @@ def append_status(line: str):
|
|||||||
f.write(line + "\n")
|
f.write(line + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def write_heartbeat(iteration: int, status: str = "alive"):
|
||||||
|
"""Update HEARTBEAT so watchdogs can detect stalls."""
|
||||||
|
HEARTBEAT.write_text("%s iter=%d ts=%.0f\n" % (status, iteration, time.time()))
|
||||||
|
|
||||||
|
|
||||||
|
def ntfy(msg: str):
|
||||||
|
"""POST an alert to NTFY_URL (best-effort; silently ignored on any error)."""
|
||||||
|
if not NTFY_URL:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
NTFY_URL, data=msg.encode(), method="POST",
|
||||||
|
headers={"Content-Type": "text/plain"},
|
||||||
|
)
|
||||||
|
urllib.request.urlopen(req, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
global RUN_DIR, STATUS_MD, METRICS_JSON, TRAIN_PY, HEARTBEAT
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--iters", type=int, default=LOOP_ITERS)
|
||||||
|
parser.add_argument("--model", default=LOOP_MODEL)
|
||||||
|
parser.add_argument(
|
||||||
|
"--run-dir", default=None,
|
||||||
|
help="run dir scaffolded by autoresearch_start.py; "
|
||||||
|
"STATUS.md, metrics.json, HEARTBEAT, and train.py live here",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
loop_iters = args.iters
|
||||||
|
loop_model = args.model
|
||||||
|
|
||||||
|
if args.run_dir:
|
||||||
|
RUN_DIR = Path(args.run_dir)
|
||||||
|
if not RUN_DIR.is_dir():
|
||||||
|
print("ERROR: run dir not found:", RUN_DIR); sys.exit(1)
|
||||||
|
|
||||||
|
STATUS_MD = RUN_DIR / "STATUS.md"
|
||||||
|
METRICS_JSON = RUN_DIR / "metrics.json"
|
||||||
|
TRAIN_PY = RUN_DIR / "train.py"
|
||||||
|
HEARTBEAT = RUN_DIR / "HEARTBEAT"
|
||||||
|
|
||||||
if not LITELLM_KEY:
|
if not LITELLM_KEY:
|
||||||
print("ERROR: set LITELLM_KEY"); sys.exit(1)
|
print("ERROR: set LITELLM_KEY"); sys.exit(1)
|
||||||
|
|
||||||
if not STATUS_MD.exists():
|
if not STATUS_MD.exists():
|
||||||
STATUS_MD.write_text("# Autoresearch STATUS\n\n| iter | val_vol_r2 | delta | action | secs | gpu | change |\n|------|-----------|-------|--------|------|-----|--------|\n")
|
STATUS_MD.write_text(
|
||||||
|
"# Autoresearch STATUS\n\n"
|
||||||
|
"| iter | val_vol_r2 | delta | action | secs | gpu | change |\n"
|
||||||
|
"|------|-----------|-------|--------|------|-----|--------|\n"
|
||||||
|
)
|
||||||
|
|
||||||
# establish baseline
|
|
||||||
baseline = read_metric()
|
baseline = read_metric()
|
||||||
if baseline is None:
|
if baseline is None:
|
||||||
print("No metrics.json — running train.py for baseline...")
|
print("No metrics.json — running train.py for baseline...")
|
||||||
m, secs, err = run_train()
|
m, secs, err = run_train()
|
||||||
if m is None:
|
if m is None:
|
||||||
print("Baseline run failed:", err); sys.exit(1)
|
msg = "Baseline run failed: " + err
|
||||||
|
print(msg)
|
||||||
|
ntfy("[jepa-fx-risk] loop CRASH — " + msg)
|
||||||
|
sys.exit(1)
|
||||||
baseline = m
|
baseline = m
|
||||||
print("Baseline: val_vol_r2 = %.4f (%.1fs)" % (baseline, secs))
|
print("Baseline: val_vol_r2 = %.4f (%.1fs)" % (baseline, secs))
|
||||||
|
|
||||||
best = baseline
|
best = baseline
|
||||||
print("Starting loop | model=%s | iters=%d | baseline=%.4f" % (LOOP_MODEL, LOOP_ITERS, best))
|
print("Starting loop | model=%s | iters=%d | baseline=%.4f" % (loop_model, loop_iters, best))
|
||||||
|
if args.run_dir:
|
||||||
|
print(" run-dir:", RUN_DIR)
|
||||||
|
|
||||||
for i in range(1, LOOP_ITERS + 1):
|
iter_index = 0
|
||||||
print("\n--- iter %d/%d ---" % (i, LOOP_ITERS))
|
try:
|
||||||
|
for i in range(1, loop_iters + 1):
|
||||||
|
iter_index = i
|
||||||
|
write_heartbeat(i, "agent-call")
|
||||||
|
print("\n--- iter %d/%d ---" % (i, loop_iters))
|
||||||
original = TRAIN_PY.read_text()
|
original = TRAIN_PY.read_text()
|
||||||
|
|
||||||
print(" calling agent (%s)..." % LOOP_MODEL)
|
print(" calling agent (%s)..." % loop_model)
|
||||||
t_agent = time.time()
|
t_agent = time.time()
|
||||||
try:
|
try:
|
||||||
new_code = call_agent(i, best)
|
new_code = call_agent(i, best)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(" agent call failed:", e)
|
msg = str(e)
|
||||||
append_status("| %d | ERR | — | agent-fail | — | — | %s |" % (i, str(e)[:60]))
|
print(" agent call failed:", msg)
|
||||||
|
append_status("| %d | ERR | — | agent-fail | — | — | %s |" % (i, msg[:60]))
|
||||||
|
write_heartbeat(i, "agent-fail")
|
||||||
|
ntfy("[jepa-fx-risk] iter %d agent FAIL — %s" % (i, msg[:80]))
|
||||||
continue
|
continue
|
||||||
agent_secs = time.time() - t_agent
|
agent_secs = time.time() - t_agent
|
||||||
print(" agent replied in %.1fs" % agent_secs)
|
print(" agent replied in %.1fs" % agent_secs)
|
||||||
@@ -174,6 +244,7 @@ def main():
|
|||||||
|
|
||||||
TRAIN_PY.write_text(new_code)
|
TRAIN_PY.write_text(new_code)
|
||||||
|
|
||||||
|
write_heartbeat(i, "training")
|
||||||
gpu = gpu_snapshot()
|
gpu = gpu_snapshot()
|
||||||
print(" running train.py [%s]..." % gpu)
|
print(" running train.py [%s]..." % gpu)
|
||||||
metric, secs, err = run_train()
|
metric, secs, err = run_train()
|
||||||
@@ -182,6 +253,8 @@ def main():
|
|||||||
print(" train.py FAILED — reverting. err:", err[:100])
|
print(" train.py FAILED — reverting. err:", err[:100])
|
||||||
revert_train(original)
|
revert_train(original)
|
||||||
append_status("| %d | FAIL | — | revert | %.0fs | %s | run error |" % (i, secs, gpu))
|
append_status("| %d | FAIL | — | revert | %.0fs | %s | run error |" % (i, secs, gpu))
|
||||||
|
write_heartbeat(i, "train-fail")
|
||||||
|
ntfy("[jepa-fx-risk] iter %d train FAIL — %s" % (i, err[:80]))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
delta = metric - best
|
delta = metric - best
|
||||||
@@ -195,10 +268,19 @@ def main():
|
|||||||
summary = "| %d | %.4f | %+.4f | %s | %.0fs | %s | iter%d |" % (
|
summary = "| %d | %.4f | %+.4f | %s | %.0fs | %s | iter%d |" % (
|
||||||
i, metric, delta, action, secs, gpu, i)
|
i, metric, delta, action, secs, gpu, i)
|
||||||
append_status(summary)
|
append_status(summary)
|
||||||
|
write_heartbeat(i, "done")
|
||||||
print(" val_vol_r2=%.4f delta=%+.4f action=%s [%.0fs]" % (metric, delta, action, secs))
|
print(" val_vol_r2=%.4f delta=%+.4f action=%s [%.0fs]" % (metric, delta, action, secs))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
msg = "loop CRASH at iter %d: %s" % (iter_index, e)
|
||||||
|
print("FATAL:", msg)
|
||||||
|
ntfy("[jepa-fx-risk] " + msg)
|
||||||
|
raise
|
||||||
|
|
||||||
print("\nDone. Best val_vol_r2 = %.4f (baseline was %.4f, delta %+.4f)" % (best, baseline, best - baseline))
|
print("\nDone. Best val_vol_r2 = %.4f (baseline was %.4f, delta %+.4f)" % (best, baseline, best - baseline))
|
||||||
print("STATUS.md updated.")
|
print("STATUS.md updated.")
|
||||||
|
write_heartbeat(loop_iters, "done")
|
||||||
|
ntfy("[jepa-fx-risk] loop done. best val_vol_r2=%.4f (delta %+.4f)" % (best, best - baseline))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"val_vol_r2": 0.3641397896593044,
|
|
||||||
"phase1_r2": 0.3908407688140869,
|
|
||||||
"n_test": 11641,
|
|
||||||
"knobs": {
|
|
||||||
"WINDOW": 120,
|
|
||||||
"PATCH_LEN": 24,
|
|
||||||
"D_MODEL": 128,
|
|
||||||
"DEPTH": 2,
|
|
||||||
"ALPHA": 0.1,
|
|
||||||
"DELTA_T_MAX": 3,
|
|
||||||
"EPOCHS": 300
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-1
@@ -5,4 +5,6 @@
|
|||||||
numpy>=2.0
|
numpy>=2.0
|
||||||
pandas>=2.2
|
pandas>=2.2
|
||||||
pyarrow>=16
|
pyarrow>=16
|
||||||
histdata>=1.3 # histdata.com downloader (handles the tk token politely)
|
histdata>=1.1 # histdata.com downloader (1.1 is newest on PyPI; 1.3 never existed)
|
||||||
|
hmmlearn>=0.3 # regime detector (prepare_regime.py, jepa-fx-risk#13)
|
||||||
|
scikit-learn>=1.4 # HMM dependency
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""autoresearch start — scaffold a run dir from an Autoresearch Council backlog leaf.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/autoresearch_start.py <backlog.json> <rq-id>
|
||||||
|
|
||||||
|
Reads the Council backlog JSON (from agentsquad autoresearch_pipe.py Stage-3 output),
|
||||||
|
finds the node by rq-id, validates it is autoresearch-ready (fail-closed), then
|
||||||
|
scaffolds runs/<rq-id>/ with:
|
||||||
|
|
||||||
|
program.md — hypothesis, single metric (stripped), agent search-space seam
|
||||||
|
run.json — provenance (strategic_question + council_node) + config
|
||||||
|
train.py — copy of project train.py (the loop edits this, keeps history clean)
|
||||||
|
|
||||||
|
Launch:
|
||||||
|
LITELLM_KEY=xxx python loop.py --run-dir runs/<rq-id>
|
||||||
|
|
||||||
|
Refs: jepa-fx-risk#11, agentsquad#44
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_backlog(path: str) -> dict:
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"error: backlog file not found: {path}", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def scaffold_run(
|
||||||
|
backlog_path_or_dict,
|
||||||
|
rq_id: str,
|
||||||
|
run_dir: Path,
|
||||||
|
train_py_src: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Scaffold a run dir. Raises SystemExit on any validation failure."""
|
||||||
|
if isinstance(backlog_path_or_dict, (str, Path)):
|
||||||
|
backlog = load_backlog(str(backlog_path_or_dict))
|
||||||
|
else:
|
||||||
|
backlog = backlog_path_or_dict
|
||||||
|
|
||||||
|
# Find node
|
||||||
|
nodes_by_id = {n["id"]: n for n in backlog.get("nodes", [])}
|
||||||
|
if rq_id not in nodes_by_id:
|
||||||
|
print(f"error: rq-id {rq_id!r} not found in backlog", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
node = nodes_by_id[rq_id]
|
||||||
|
|
||||||
|
# Fail-closed: only autoresearch-ready nodes may be scaffolded
|
||||||
|
status = node.get("status", "")
|
||||||
|
if status != "autoresearch-ready":
|
||||||
|
print(
|
||||||
|
f"error: {rq_id} has status {status!r}, not 'autoresearch-ready' — refusing to scaffold",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Guard against overwriting an existing run
|
||||||
|
if run_dir.exists():
|
||||||
|
print(
|
||||||
|
f"error: {run_dir} already exists — remove it first to re-scaffold",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
metric = (node.get("candidate_metric") or "").strip()
|
||||||
|
strategic_q = backlog.get("strategic_question", "")
|
||||||
|
council_node = node["id"]
|
||||||
|
generated_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
run_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
# --- program.md ---
|
||||||
|
program_md = f"""# program.md — {council_node}: {node.get("question", "")[:80]}
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
- strategic_question: {json.dumps(strategic_q)}
|
||||||
|
- council_node: {council_node} (autoresearch-ready; Autoresearch Council backlog)
|
||||||
|
- generated_at: {generated_at}
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
{node.get("question", "")}
|
||||||
|
|
||||||
|
## Single validation metric (optimise this, nothing else)
|
||||||
|
`{metric}` — see eval harness for the exact definition. Only this scalar drives
|
||||||
|
keep/revert decisions. Report alongside but do NOT optimise:
|
||||||
|
- Kupiec POF p-value (calibration sanity)
|
||||||
|
- val_vol_r2 (representation quality guard)
|
||||||
|
|
||||||
|
## What the agent MAY modify (the search space)
|
||||||
|
- Hyperparameters in train.py (model size, LR, window, patch_len, epochs, etc.)
|
||||||
|
- Conditioning mechanisms (e.g. JEPA_ENABLE_REGIME toggle)
|
||||||
|
- Loss function weights and architecture depth
|
||||||
|
|
||||||
|
## Frozen (do NOT touch — keeps the ablation clean)
|
||||||
|
- Data pipeline and splits (train ≤2021, OOS ≥2022, test 2024 held out)
|
||||||
|
- The metric definition and scoring code
|
||||||
|
- loop.py, scripts/, tests/
|
||||||
|
|
||||||
|
## Experiment loop (per Karpathy autoresearch)
|
||||||
|
Each iter (≤ time-box): apply ONE change to train.py → run → read
|
||||||
|
`{metric}` → keep if improved (and Kupiec p-value did not collapse), else revert.
|
||||||
|
Stop on: target reached, max iters, or K consecutive iters with no improvement.
|
||||||
|
"""
|
||||||
|
(run_dir / "program.md").write_text(program_md)
|
||||||
|
|
||||||
|
# --- run.json (provenance + config) ---
|
||||||
|
run_meta = {
|
||||||
|
"strategic_question": strategic_q,
|
||||||
|
"council_node": council_node,
|
||||||
|
"metric": metric,
|
||||||
|
"generated_at": generated_at,
|
||||||
|
"model_tier": "homelab",
|
||||||
|
"max_iters": 10,
|
||||||
|
"time_box_minutes": 5,
|
||||||
|
}
|
||||||
|
(run_dir / "run.json").write_text(json.dumps(run_meta, indent=2) + "\n")
|
||||||
|
|
||||||
|
# --- train.py (loop edits this copy; project root train.py is the template) ---
|
||||||
|
shutil.copy(train_py_src, run_dir / "train.py")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("usage: python scripts/autoresearch_start.py <backlog.json> <rq-id>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
backlog_path, rq_id = sys.argv[1], sys.argv[2]
|
||||||
|
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
run_dir = project_root / "runs" / rq_id
|
||||||
|
train_py_src = project_root / "train.py"
|
||||||
|
|
||||||
|
scaffold_run(backlog_path, rq_id, run_dir, train_py_src)
|
||||||
|
|
||||||
|
backlog = load_backlog(backlog_path)
|
||||||
|
nodes_by_id = {n["id"]: n for n in backlog.get("nodes", [])}
|
||||||
|
metric = (nodes_by_id[rq_id].get("candidate_metric") or "").strip()
|
||||||
|
|
||||||
|
print(f"✓ scaffolded {run_dir}")
|
||||||
|
print(f" node: {rq_id}")
|
||||||
|
print(f" metric: {metric}")
|
||||||
|
print()
|
||||||
|
print("launch:")
|
||||||
|
print(f" LITELLM_KEY=xxx python loop.py --run-dir runs/{rq_id}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""HMM regime detector — 3-state Gaussian HMM on realized_vol.
|
||||||
|
|
||||||
|
Fits on the FULL dataset (training + OOS) so the state sequence is globally
|
||||||
|
consistent across all periods. States are sorted by mean realized vol (ascending):
|
||||||
|
0 = calm, 1 = stressed, 2 = crisis
|
||||||
|
|
||||||
|
Output: data/processed/eurusd_regime.parquet
|
||||||
|
Columns: datetime (or date), regime (int: 0/1/2)
|
||||||
|
|
||||||
|
Deterministic: fixed random_state=42 throughout.
|
||||||
|
Cached: if the parquet already exists, it is not re-computed.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/prepare_regime.py [--hourly] [--daily] [--force]
|
||||||
|
|
||||||
|
jepa-fx-risk#13
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from hmmlearn import hmm
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).parent.parent / "data" / "processed"
|
||||||
|
HOURLY_PATH = DATA_DIR / "eurusd_hourly.parquet"
|
||||||
|
DAILY_PATH = DATA_DIR / "eurusd_daily.parquet"
|
||||||
|
OUTPUT_PATH = DATA_DIR / "eurusd_regime.parquet"
|
||||||
|
|
||||||
|
N_STATES = 3
|
||||||
|
RANDOM_STATE = 42
|
||||||
|
|
||||||
|
|
||||||
|
def fit_regime_hmm(realized_vol: np.ndarray, n_states: int = 3, random_state: int = 42) -> np.ndarray:
|
||||||
|
"""Fit a Gaussian HMM on realized_vol and return state labels (0=calm → n_states-1=crisis).
|
||||||
|
|
||||||
|
States are sorted by mean realized vol ascending so label 0 is always calm,
|
||||||
|
label n_states-1 is always crisis. This makes the labelling deterministic
|
||||||
|
across datasets with different vol levels.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
realized_vol: 1-D array of realized vol values
|
||||||
|
n_states: number of HMM hidden states (default 3)
|
||||||
|
random_state: random seed for reproducibility
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Integer label array of shape (len(realized_vol),), dtype int64
|
||||||
|
"""
|
||||||
|
X = realized_vol.reshape(-1, 1).astype(np.float64)
|
||||||
|
model = hmm.GaussianHMM(
|
||||||
|
n_components=n_states,
|
||||||
|
covariance_type="diag",
|
||||||
|
min_covar=1e-6,
|
||||||
|
n_iter=100,
|
||||||
|
random_state=random_state,
|
||||||
|
tol=1e-4,
|
||||||
|
)
|
||||||
|
model.fit(X)
|
||||||
|
raw_labels = model.predict(X)
|
||||||
|
|
||||||
|
# Sort states by mean realized vol (ascending: calm=0, crisis=n_states-1)
|
||||||
|
state_means = np.array([X[raw_labels == s].mean() if (raw_labels == s).any() else 0.0
|
||||||
|
for s in range(n_states)])
|
||||||
|
rank = np.argsort(state_means) # rank[0] = original state id of the calmest cluster
|
||||||
|
remap = np.empty(n_states, dtype=np.int64)
|
||||||
|
for new_label, old_label in enumerate(rank):
|
||||||
|
remap[old_label] = new_label
|
||||||
|
return remap[raw_labels].astype(np.int64)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_regime_df(parquet_path: str, freq: str = "hourly") -> pd.DataFrame:
|
||||||
|
"""Load parquet, fit HMM, return DataFrame with timestamp + regime columns.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parquet_path: path to input parquet (hourly or daily)
|
||||||
|
freq: "hourly" | "daily" — determines timestamp column name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataFrame with columns: (datetime|date), regime
|
||||||
|
"""
|
||||||
|
df = pd.read_parquet(parquet_path)
|
||||||
|
if freq == "hourly":
|
||||||
|
ts = pd.to_datetime(df["datetime"])
|
||||||
|
else:
|
||||||
|
ts = pd.to_datetime(df["date"])
|
||||||
|
|
||||||
|
rv = df["realized_vol"].to_numpy(np.float32)
|
||||||
|
labels = fit_regime_hmm(rv, n_states=N_STATES, random_state=RANDOM_STATE)
|
||||||
|
return pd.DataFrame({"datetime": ts.values, "regime": labels})
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Fit HMM regime detector")
|
||||||
|
parser.add_argument("--hourly", action="store_true", default=True,
|
||||||
|
help="use hourly parquet (default)")
|
||||||
|
parser.add_argument("--daily", action="store_true", default=False,
|
||||||
|
help="use daily parquet instead of hourly")
|
||||||
|
parser.add_argument("--force", action="store_true", default=False,
|
||||||
|
help="overwrite existing output")
|
||||||
|
parser.add_argument("--out", default=str(OUTPUT_PATH),
|
||||||
|
help="output parquet path")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
out_path = Path(args.out)
|
||||||
|
if out_path.exists() and not args.force:
|
||||||
|
print("regime parquet already exists:", out_path, "(use --force to recompute)")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.daily and DAILY_PATH.exists():
|
||||||
|
src, freq = str(DAILY_PATH), "daily"
|
||||||
|
elif HOURLY_PATH.exists():
|
||||||
|
src, freq = str(HOURLY_PATH), "hourly"
|
||||||
|
elif DAILY_PATH.exists():
|
||||||
|
src, freq = str(DAILY_PATH), "daily"
|
||||||
|
else:
|
||||||
|
raise FileNotFoundError("no parquet found in data/processed/")
|
||||||
|
|
||||||
|
print(f"fitting HMM ({N_STATES} states) on {src} ...")
|
||||||
|
df = prepare_regime_df(src, freq=freq)
|
||||||
|
|
||||||
|
counts = df["regime"].value_counts().sort_index()
|
||||||
|
print("regime distribution:")
|
||||||
|
for state, count in counts.items():
|
||||||
|
label = {0: "calm", 1: "stressed", 2: "crisis"}.get(state, f"state{state}")
|
||||||
|
print(f" {state} ({label}): {count} ({100*count/len(df):.1f}%)")
|
||||||
|
|
||||||
|
df.to_parquet(out_path, index=False)
|
||||||
|
print("wrote:", out_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Parametric 99% VaR breach rate + Kupiec POF p-value.
|
||||||
|
|
||||||
|
Used by train.py's LOCKED VaR EVAL BLOCK to write VaR_breach_rate_99_oos_regime_cond
|
||||||
|
to metrics.json so the autoresearch loop can optimise it.
|
||||||
|
|
||||||
|
jepa-fx-risk#12
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
# Canonical metric key — no surrounding whitespace, as required by the loop contract.
|
||||||
|
METRIC_KEY = "VaR_breach_rate_99_oos_regime_cond"
|
||||||
|
|
||||||
|
# Default normal 99th-percentile z-score.
|
||||||
|
Z99 = 2.326
|
||||||
|
|
||||||
|
|
||||||
|
def var_breach_rate(pred_vol, actual_vol, z99=Z99):
|
||||||
|
"""Compute VaR breach rate and Kupiec POF p-value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pred_vol: iterable of predicted conditional vol forecasts
|
||||||
|
actual_vol: iterable of actual realized vol (same length)
|
||||||
|
z99: 99th-percentile z-score (default 2.326)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(breach_rate, kupiec_p) where:
|
||||||
|
breach_rate — fraction of steps where actual_vol > pred_vol × z99
|
||||||
|
kupiec_p — Kupiec POF p-value (H0: true breach rate = 1%)
|
||||||
|
High p-value = well-calibrated; low = miscalibrated tail.
|
||||||
|
"""
|
||||||
|
pred_v = list(pred_vol)
|
||||||
|
act_v = list(actual_vol)
|
||||||
|
n = len(pred_v)
|
||||||
|
if n == 0 or n != len(act_v):
|
||||||
|
return 0.0, 1.0
|
||||||
|
|
||||||
|
n1 = sum(1 for p, a in zip(pred_v, act_v) if a > p * z99)
|
||||||
|
breach_rate = n1 / n
|
||||||
|
p = kupiec_pvalue(n, n1)
|
||||||
|
return breach_rate, p
|
||||||
|
|
||||||
|
|
||||||
|
def kupiec_pvalue(n, n1, p0=0.01):
|
||||||
|
"""Kupiec Proportion-of-Failures likelihood ratio test.
|
||||||
|
|
||||||
|
H0: true breach probability = p0.
|
||||||
|
Returns P(chi²(1) > LR) using the identity P(chi²(1)>x) = erfc(sqrt(x/2)).
|
||||||
|
Returns 1.0 for n=0 or LR<=0 (well-calibrated / over-conservative).
|
||||||
|
"""
|
||||||
|
if n == 0:
|
||||||
|
return 1.0
|
||||||
|
n0 = n - n1
|
||||||
|
phat = n1 / n
|
||||||
|
|
||||||
|
if n1 == 0:
|
||||||
|
# 0 × ln(0/p0) = 0 by convention; only n0 term contributes
|
||||||
|
lr = 2 * n0 * math.log((1 - phat) / (1 - p0))
|
||||||
|
elif n1 == n:
|
||||||
|
lr = 2 * n1 * math.log(phat / p0)
|
||||||
|
else:
|
||||||
|
lr = 2 * (n1 * math.log(phat / p0) + n0 * math.log((1 - phat) / (1 - p0)))
|
||||||
|
|
||||||
|
if lr <= 0:
|
||||||
|
return 1.0
|
||||||
|
# P(chi²(1) > LR) = erfc(sqrt(LR/2))
|
||||||
|
return math.erfc(math.sqrt(lr / 2))
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Tests for scripts/autoresearch_start.py — jepa-fx-risk#11 Phase A scaffold.
|
||||||
|
|
||||||
|
Success criterion: `autoresearch start <backlog.json> <rq-id>` scaffolds a
|
||||||
|
runnable run dir from a ready leaf; refuses non-ready nodes; strips
|
||||||
|
candidate_metric; records provenance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Load the module without executing main()
|
||||||
|
_SCRIPT = Path(__file__).parent.parent / "scripts" / "autoresearch_start.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _import():
|
||||||
|
spec = importlib.util.spec_from_file_location("autoresearch_start", _SCRIPT)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def mod():
|
||||||
|
return _import()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def backlog(tmp_path):
|
||||||
|
data = {
|
||||||
|
"strategic_question": "Test strategic question?",
|
||||||
|
"generated_at": "2026-06-27T00:00:00Z",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "rq-01",
|
||||||
|
"question": "Does X improve Y?",
|
||||||
|
"case_type": "autoresearch-loop",
|
||||||
|
"data": "obtainable",
|
||||||
|
"method": "adjacent",
|
||||||
|
"falsifiable": "yes",
|
||||||
|
"candidate_metric": " val_vol_r2", # leading space — bypass test
|
||||||
|
"depends_on": [],
|
||||||
|
"status": "autoresearch-ready",
|
||||||
|
"track": "autoresearch",
|
||||||
|
"converged": True,
|
||||||
|
"survived_review": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rq-02",
|
||||||
|
"question": "Not ready yet?",
|
||||||
|
"case_type": "empirical-study",
|
||||||
|
"data": "obtainable",
|
||||||
|
"method": "adjacent",
|
||||||
|
"falsifiable": "yes",
|
||||||
|
"candidate_metric": None,
|
||||||
|
"depends_on": [],
|
||||||
|
"status": "needs-metric",
|
||||||
|
"track": "study",
|
||||||
|
"converged": True,
|
||||||
|
"survived_review": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rq-03",
|
||||||
|
"question": "A spike.",
|
||||||
|
"case_type": "spike",
|
||||||
|
"data": "have",
|
||||||
|
"method": "yes-named",
|
||||||
|
"falsifiable": "yes",
|
||||||
|
"candidate_metric": None,
|
||||||
|
"depends_on": [],
|
||||||
|
"status": "spike-ready",
|
||||||
|
"track": "spike",
|
||||||
|
"converged": True,
|
||||||
|
"survived_review": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
p = tmp_path / "backlog.json"
|
||||||
|
p.write_text(json.dumps(data))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def fake_train_py(tmp_path):
|
||||||
|
"""Minimal train.py placeholder for scaffold tests."""
|
||||||
|
src = tmp_path / "train_template.py"
|
||||||
|
src.write_text("# train.py placeholder\n")
|
||||||
|
return src
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fail-closed: refuse non-autoresearch-ready nodes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRefuseNonReady:
|
||||||
|
def test_refuses_needs_metric(self, mod, backlog, fake_train_py, tmp_path):
|
||||||
|
run_dir = tmp_path / "runs" / "rq-02"
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
mod.scaffold_run(backlog, "rq-02", run_dir, fake_train_py)
|
||||||
|
assert exc.value.code != 0
|
||||||
|
|
||||||
|
def test_refuses_spike_ready(self, mod, backlog, fake_train_py, tmp_path):
|
||||||
|
run_dir = tmp_path / "runs" / "rq-03"
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
mod.scaffold_run(backlog, "rq-03", run_dir, fake_train_py)
|
||||||
|
assert exc.value.code != 0
|
||||||
|
|
||||||
|
def test_refuses_missing_rq_id(self, mod, backlog, fake_train_py, tmp_path):
|
||||||
|
run_dir = tmp_path / "runs" / "rq-99"
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
mod.scaffold_run(backlog, "rq-99", run_dir, fake_train_py)
|
||||||
|
assert exc.value.code != 0
|
||||||
|
|
||||||
|
def test_refuses_existing_run_dir(self, mod, backlog, fake_train_py, tmp_path):
|
||||||
|
run_dir = tmp_path / "runs" / "rq-01"
|
||||||
|
run_dir.mkdir(parents=True)
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
mod.scaffold_run(backlog, "rq-01", run_dir, fake_train_py)
|
||||||
|
assert exc.value.code != 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# scaffold structure: correct files created
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestScaffoldStructure:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _scaffold(self, mod, backlog, fake_train_py, tmp_path):
|
||||||
|
self.run_dir = tmp_path / "runs" / "rq-01"
|
||||||
|
mod.scaffold_run(backlog, "rq-01", self.run_dir, fake_train_py)
|
||||||
|
|
||||||
|
def test_run_dir_created(self):
|
||||||
|
assert self.run_dir.is_dir()
|
||||||
|
|
||||||
|
def test_program_md_created(self):
|
||||||
|
assert (self.run_dir / "program.md").exists()
|
||||||
|
|
||||||
|
def test_run_json_created(self):
|
||||||
|
assert (self.run_dir / "run.json").exists()
|
||||||
|
|
||||||
|
def test_train_py_copied(self):
|
||||||
|
assert (self.run_dir / "train.py").exists()
|
||||||
|
assert (self.run_dir / "train.py").read_text() == "# train.py placeholder\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# program.md content
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestProgramMd:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _scaffold(self, mod, backlog, fake_train_py, tmp_path):
|
||||||
|
self.run_dir = tmp_path / "runs" / "rq-01"
|
||||||
|
mod.scaffold_run(backlog, "rq-01", self.run_dir, fake_train_py)
|
||||||
|
self.content = (self.run_dir / "program.md").read_text()
|
||||||
|
|
||||||
|
def test_contains_hypothesis(self):
|
||||||
|
assert "Does X improve Y?" in self.content
|
||||||
|
|
||||||
|
def test_metric_key_stripped(self):
|
||||||
|
# candidate_metric had leading space " val_vol_r2" — must be stripped
|
||||||
|
assert "`val_vol_r2`" in self.content
|
||||||
|
assert "` val_vol_r2`" not in self.content
|
||||||
|
|
||||||
|
def test_contains_strategic_question(self):
|
||||||
|
assert "Test strategic question?" in self.content
|
||||||
|
|
||||||
|
def test_contains_council_node(self):
|
||||||
|
assert "rq-01" in self.content
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# run.json provenance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRunJson:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _scaffold(self, mod, backlog, fake_train_py, tmp_path):
|
||||||
|
self.run_dir = tmp_path / "runs" / "rq-01"
|
||||||
|
mod.scaffold_run(backlog, "rq-01", self.run_dir, fake_train_py)
|
||||||
|
self.run = json.loads((self.run_dir / "run.json").read_text())
|
||||||
|
|
||||||
|
def test_strategic_question_in_provenance(self):
|
||||||
|
assert self.run["strategic_question"] == "Test strategic question?"
|
||||||
|
|
||||||
|
def test_council_node_in_provenance(self):
|
||||||
|
assert self.run["council_node"] == "rq-01"
|
||||||
|
|
||||||
|
def test_metric_stripped_in_provenance(self):
|
||||||
|
assert self.run["metric"] == "val_vol_r2"
|
||||||
|
assert self.run["metric"] == self.run["metric"].strip()
|
||||||
|
|
||||||
|
def test_generated_at_present(self):
|
||||||
|
assert "generated_at" in self.run
|
||||||
|
|
||||||
|
def test_max_iters_present(self):
|
||||||
|
assert "max_iters" in self.run
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# load_backlog helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLoadBacklog:
|
||||||
|
def test_loads_json(self, mod, backlog):
|
||||||
|
data = mod.load_backlog(str(backlog))
|
||||||
|
assert data["strategic_question"] == "Test strategic question?"
|
||||||
|
assert len(data["nodes"]) == 3
|
||||||
|
|
||||||
|
def test_missing_file_raises(self, mod, tmp_path):
|
||||||
|
with pytest.raises((FileNotFoundError, SystemExit)):
|
||||||
|
mod.load_backlog(str(tmp_path / "nonexistent.json"))
|
||||||
@@ -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
|
||||||
@@ -36,6 +36,7 @@ PHASE1_JOINT = bool(int(_os.environ.get("JEPA_PHASE1_JOINT", 1)))
|
|||||||
PHASE1_JOINT_EPOCHS= int(_os.environ.get("JEPA_PHASE1_JOINT_EPOCHS", 30))
|
PHASE1_JOINT_EPOCHS= int(_os.environ.get("JEPA_PHASE1_JOINT_EPOCHS", 30))
|
||||||
PHASE1_ENCODER_LR = float(_os.environ.get("JEPA_PHASE1_ENCODER_LR", 3e-6))
|
PHASE1_ENCODER_LR = float(_os.environ.get("JEPA_PHASE1_ENCODER_LR", 3e-6))
|
||||||
USE_MULTIPAIR = bool(int(_os.environ.get("JEPA_USE_MULTIPAIR", 0)))
|
USE_MULTIPAIR = bool(int(_os.environ.get("JEPA_USE_MULTIPAIR", 0)))
|
||||||
|
JEPA_ENABLE_REGIME = bool(int(_os.environ.get("JEPA_ENABLE_REGIME", 0)))
|
||||||
SEED = int(_os.environ.get("JEPA_SEED", 0))
|
SEED = int(_os.environ.get("JEPA_SEED", 0))
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
|
|
||||||
@@ -171,6 +172,22 @@ def build():
|
|||||||
df["date"] = pd.to_datetime(df["date"])
|
df["date"] = pd.to_datetime(df["date"])
|
||||||
FEAT_COLS = ["ret", "realized_vol"]
|
FEAT_COLS = ["ret", "realized_vol"]
|
||||||
target_col = "realized_vol"
|
target_col = "realized_vol"
|
||||||
|
# ── REGIME CONDITIONING SEAM — agent may vary this mechanism ─────────────
|
||||||
|
# Baseline: concat regime flag as an additional feature channel (0=calm, 2=crisis).
|
||||||
|
# Agent may swap for FiLM conditioning, learned regime embedding, or gating.
|
||||||
|
_regime_path = "data/processed/eurusd_regime.parquet"
|
||||||
|
if JEPA_ENABLE_REGIME and os.path.exists(_regime_path):
|
||||||
|
_rdf = pd.read_parquet(_regime_path)
|
||||||
|
_ts_col = "datetime" if "datetime" in _rdf.columns else "date"
|
||||||
|
_rdf[_ts_col] = pd.to_datetime(_rdf[_ts_col])
|
||||||
|
df = df.copy()
|
||||||
|
df = df.merge(
|
||||||
|
_rdf.rename(columns={_ts_col: "date"})[["date", "regime"]],
|
||||||
|
on="date", how="left",
|
||||||
|
)
|
||||||
|
df["regime"] = df["regime"].fillna(0).astype(np.float32)
|
||||||
|
FEAT_COLS = list(FEAT_COLS) + ["regime"]
|
||||||
|
# ── END REGIME SEAM ───────────────────────────────────────────────────────
|
||||||
feats = df[FEAT_COLS].to_numpy(np.float32)
|
feats = df[FEAT_COLS].to_numpy(np.float32)
|
||||||
target = df[target_col].to_numpy(np.float32)
|
target = df[target_col].to_numpy(np.float32)
|
||||||
tr_idx = df.index[df["date"].dt.year <= 2021].tolist()
|
tr_idx = df.index[df["date"].dt.year <= 2021].tolist()
|
||||||
@@ -298,12 +315,22 @@ def main():
|
|||||||
phase1_r2 = float(1 - ((yte - pred_h) ** 2).sum() / ss_tot)
|
phase1_r2 = float(1 - ((yte - pred_h) ** 2).sum() / ss_tot)
|
||||||
print("phase1_r2 = %.4f (n_test=%d)" % (phase1_r2, len(yte)))
|
print("phase1_r2 = %.4f (n_test=%d)" % (phase1_r2, len(yte)))
|
||||||
|
|
||||||
|
# ── VaR EVAL BLOCK — do NOT edit (agent boundary) ───────────────────────
|
||||||
|
import sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))
|
||||||
|
from scripts.var_breach import var_breach_rate as _var_breach_rate, METRIC_KEY as _VAR_KEY
|
||||||
|
_var_rate, _kupiec_p = _var_breach_rate(pred_np.tolist(), yte.tolist())
|
||||||
|
print("%s=%.4f Kupiec_p=%.4f" % (_VAR_KEY, _var_rate, _kupiec_p))
|
||||||
|
# ── END VaR EVAL BLOCK ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_metrics_out = _os.environ.get("METRICS_OUT", "metrics.json")
|
||||||
json.dump({
|
json.dump({
|
||||||
"val_vol_r2": val_vol_r2, "phase1_r2": phase1_r2, "n_test": len(yte),
|
"val_vol_r2": val_vol_r2, "phase1_r2": phase1_r2, "n_test": len(yte),
|
||||||
|
_VAR_KEY: _var_rate, "kupiec_p": _kupiec_p,
|
||||||
"knobs": {"WINDOW": WINDOW, "PATCH_LEN": PATCH_LEN,
|
"knobs": {"WINDOW": WINDOW, "PATCH_LEN": PATCH_LEN,
|
||||||
"D_MODEL": D_MODEL, "DEPTH": DEPTH, "ALPHA": ALPHA,
|
"D_MODEL": D_MODEL, "DEPTH": DEPTH, "ALPHA": ALPHA,
|
||||||
"DELTA_T_MAX": DELTA_T_MAX, "EPOCHS": EPOCHS},
|
"DELTA_T_MAX": DELTA_T_MAX, "EPOCHS": EPOCHS},
|
||||||
}, open("metrics.json", "w"), indent=2)
|
}, open(_metrics_out, "w"), indent=2)
|
||||||
print("val_vol_r2 = %.4f (n_test=%d, dev=%s)" % (val_vol_r2, len(yte), dev))
|
print("val_vol_r2 = %.4f (n_test=%d, dev=%s)" % (val_vol_r2, len(yte), dev))
|
||||||
|
|
||||||
# ── EXPORT BLOCK — do NOT edit (agent boundary) ──────────────────────────
|
# ── EXPORT BLOCK — do NOT edit (agent boundary) ──────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user