22 Commits
Author SHA1 Message Date
mathias 6fb77f5263 fix(ci): drop empty LOOP_MODEL env that blanked the model → LLM 400
CD / Lint / Test / Vet (push) Failing after 3s
CD / Deploy via GitOps (push) Has been skipped
CD / Build & Import (push) Has been skipped
`LOOP_MODEL: ${{ inputs.model }}` sets the env var to "" when no model input is
given. An env var set-but-empty is NOT unset, so it overrode loop.py's
berget/gemma4-31b default, and every agent call POSTed "model":"" → HTTP 400
Bad Request. The loop ran but optimized nothing (best == baseline, delta 0).

Real overrides already flow through the conditional `--model` arg, so this env
line was redundant as well as harmful. Remove it; loop.py's default applies.
2026-06-29 19:34:57 +00:00
mathias 8eb0358c01 fix(loop): put project root on PYTHONPATH for train.py subprocess
CD / Lint / Test / Vet (push) Failing after 2s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
train.py is copied into runs/<rq-id>/ by autoresearch_start.py, so when loop.py
executes it, sys.path[0] is the run dir — which has no scripts/. train.py's
frozen VaR-eval block does `from scripts.var_breach import ...`, which then
fails with ModuleNotFoundError: No module named 'scripts' on every loop run
(CI and the documented manual launch alike).

Fix in the harness, not the frozen train.py/scripts boundary: prepend the
project root (loop.py's own dir, where scripts/ lives) to the subprocess
PYTHONPATH. Verified red→green locally: the import fails without it and
resolves with it (scripts/ is an implicit namespace package, no __init__.py).
2026-06-29 21:32:25 +02:00
mathias 2d412790bf chore(ci): commit 59KB toy eurusd_daily.parquet as CI data fixture
CD / Lint / Test / Vet (push) Failing after 3s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
The Autoresearch Loop workflow (phase-a-toy) runs train.py, which reads
data/processed/eurusd_daily.parquet. data/ is gitignored (DVC/MinIO policy, #10),
but CI checks out a fresh tree with no data, so the baseline run died with
FileNotFoundError. Force-add this single 59KB derived fixture (public EUR/USD
daily realized vol, 2019-2023, 1560 rows) so the toy loop is hermetic and fast.

This is a scoped exception to #10 for a CI fixture only — large/production
datasets still stay out of git per that policy.
2026-06-29 21:20:41 +02:00
mathias 8539ec3a85 fix(ci): install torch from cu130 index in venv step
CD / Lint / Test / Vet (push) Failing after 2s
CD / Deploy via GitOps (push) Has been skipped
CD / Build & Import (push) Has been skipped
train.py imports torch but the venv step only installed requirements.txt,
which deliberately excludes torch (must come from the cu130 wheel index for
koala's Blackwell sm_120, per the requirements.txt header). Baseline run died
with ModuleNotFoundError: No module named 'torch'. Add the documented install.
2026-06-29 19:14:51 +00:00
mathias c9b22db3b8 fix(ci): pin upload-artifact to @v3 — v4 artifact protocol unsupported on Gitea
CD / Lint / Test / Vet (push) Failing after 2s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
Gitea's act_runner does not implement the @actions/artifact v2.0.0+ backend, so
upload-artifact@v4 fails the job with GHESNotSupportedError even when the loop
succeeds. v3 is the newest tag the Gitea runner supports.
2026-06-29 18:43:12 +00:00
mathias 731673061e fix(deps): histdata>=1.3 is unsatisfiable, pin to >=1.1 (newest on PyPI)
CD / Lint / Test / Vet (push) Failing after 3s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
The Autoresearch Loop workflow failed at "Set up Python venv" on every run:
  ERROR: Could not find a version that satisfies the requirement histdata>=1.3
          (from versions: 1.0, 1.1)
histdata never published 1.3 — 1.1 is the newest release. Lower the floor so
pip can resolve and the loop can actually start.
2026-06-29 18:41:07 +00:00
mathiasandClaude Sonnet 4.6 54b1bc216d feat(ci): Gitea Actions workflow for autoresearch loop (Phase A automation)
CD / Lint / Test / Vet (push) Failing after 3s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
workflow_dispatch with inputs: fixture, rq_id, iters, model override.
- Scaffolds run dir from fixtures/<fixture>.json via autoresearch_start.py
- Runs loop.py with LITELLM_KEY/LITELLM_BASE/NTFY_URL secrets
- Uploads STATUS.md + metrics.json + program.md as artifacts (30-day retention)
- Cleans stale run dir at start; venv created/reused per runner workspace
- timeout-minutes: 90 to cover multi-iter runs

Phase B (k8s Job + GPU isolation) tracked in jepa-fx-risk#15.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 17:20:44 +02:00
mathiasandClaude Sonnet 4.6 fa6dcaae0a chore: fixture backlog for Phase A toy run + gitignore cleanup
CD / Lint / Test / Vet (push) Failing after 3s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
- fixtures/phase-a-toy.json — scaffold source for `autoresearch start`
  Phase A live run: val_vol_r2 metric, daily EUR/USD, HEPA encoder toy
- .gitignore: add runs/ (ephemeral run dirs), metrics.json, embeddings.json,
  HEARTBEAT, STATUS.md, pyc/__pycache__/.pytest_cache, eval binary
- untrack metrics.json (was a stale committed sample, now gitignored)

Launch on koala:
  python scripts/autoresearch_start.py fixtures/phase-a-toy.json phase-a-toy
  op run -- env LITELLM_KEY="$LITELLM_KEY" \
    python loop.py --run-dir runs/phase-a-toy --iters 3

Closes the last gate on jepa-fx-risk#11 Phase A (code shipped v1.4.0;
live run pending).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 17:03:55 +02:00
mathiasandClaude Sonnet 4.6 68bf8f15c5 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>
2026-06-27 10:35:10 +02:00
mathiasandClaude Sonnet 4.6 65a58fcca2 feat(loop): add --run-dir isolation, heartbeat, ntfy-on-crash; scaffold start command
CD / Lint / Test / Vet (push) Failing after 1s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
Closes jepa-fx-risk#11 Phase A.

- scripts/autoresearch_start.py: scaffold runs/<rq-id>/ from Council backlog leaf;
  fail-closed on non-autoresearch-ready; strips candidate_metric; writes program.md
  + run.json (provenance) + train.py copy. 19 TDD tests.
- loop.py: --run-dir flag redirects STATUS.md / metrics.json / HEARTBEAT / train.py
  into the run dir; METRICS_OUT env var passed to train subprocess so it writes
  metrics.json to the run dir; heartbeat file written each iter phase; ntfy-on-crash
  via NTFY_URL env var (best-effort).
- train.py: METRICS_OUT env var overrides metrics.json path (default unchanged).

Launch: LITELLM_KEY=xxx python loop.py --run-dir runs/rq-04

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 10:19:17 +02:00
mathiasandClaude Sonnet 4.6 d4b67943fb feat(multipair): lock best config + train:multipair Taskfile target
CD / Lint / Test / Vet (push) Failing after 2s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
Best: USE_MULTIPAIR=1 D_MODEL=256 WINDOW=120 → phase1_r2=0.4377, val_vol_r2=0.3695
WINDOW sweep (multipair D=256): W=60→0.4009, W=120→0.4377, W=240→0.4277
D_MODEL=128 undercapacity for 10ch (val_vol_r2=0.1013); D=256 restores probe quality.
Single-pair default knobs unchanged (D=128 still optimal there).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:08:25 +02:00
mathiasandClaude Sonnet 4.6 bd8962f997 feat(multipair): G10 multi-pair pipeline + USE_MULTIPAIR knob (Option C)
- prepare_hourly.py: parameterize PAIR env var; OUT_DEFAULT per-pair; load_m1_from_zips(pair=)
- prepare_multipair.py: inner-join 5-pair hourly parquets on datetime → wide parquet
  cols: datetime, {pair}_ret, {pair}_rv × n_pairs; eurusd_rv = target
- fetch_multipair.py: download GBPUSD/USDJPY/USDCHF/AUDUSD M1 2008-2023 from histdata
- train.py: USE_MULTIPAIR knob (JEPA_USE_MULTIPAIR=1); build() reads multipair parquet
  with n_channels = n_pairs × 2; target = eurusd_rv
- Taskfile: data:fetch:multipair, data:prepare:pair, data:prepare:multipair, data:test updated
- 7 new tests in test_multipair.py; 34/35 pass (1 SKIP until multipair parquet built)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:00:57 +02:00
mathiasandClaude Sonnet 4.6 b2bc01ba9e feat(phase1): warm-start joint encoder fine-tuning (Option B)
CD / Lint / Test / Vet (push) Successful in 3s
CD / Build & Import (push) Failing after 7s
CD / Deploy via GitOps (push) Has been skipped
Two-phase phase-1:
  1a. Frozen warmup: head trains on pre-computed embeddings for PHASE1_EPOCHS=200
  1b. Joint fine-tune: encoder + head for PHASE1_JOINT_EPOCHS=30 at PHASE1_ENCODER_LR=3e-6

Key design decisions:
- Warm start prevents catastrophic forgetting (PHASE1_JOINT=1 cold-start → -32 R²)
- Normalize live encoder output with FROZEN stats (mu_e/sd_e) so head sees same
  embedding distribution it was warmed up on
- head LR reduced 10× in joint phase to prevent head from racing ahead

HPO sweep: 30ep@3e-6=0.3962, 30ep@1e-5=0.3930, 50ep@3e-6=0.3923
Baseline (frozen): 0.3908. New best: phase1_r2=0.3962 (+0.0054 OOS).

New knobs: JEPA_PHASE1_JOINT (default 1), JEPA_PHASE1_JOINT_EPOCHS (default 30),
JEPA_PHASE1_ENCODER_LR (default 3e-6). 4 new tests (tests 15-18). 28/28 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:25:09 +02:00
mathiasandClaude Sonnet 4.6 de19bfeada fix(features): revert to 2-channel default; OHLCV features redundant
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Failing after 7s
CD / Deploy via GitOps (push) Has been skipped
HPO finding: hl_range≈realized_vol, ret_intrabar≈ret — correlation kills signal.
4ch D=128: 0.3503, 4ch D=256: 0.3807, 2ch D=128 baseline: 0.3908 (winner).
Parquet keeps hl_range+ret_intrabar; comment in build() documents the attempt.
test_build_uses_4_channels → test_build_uses_2_channels (tracks current default).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:14:01 +02:00
mathiasandClaude Sonnet 4.6 caccd1aa7b feat(features): add hl_range + ret_intrabar OHLCV features (4-channel input)
- prepare_hourly.py: keep O/H/L columns from M1 zips; compute per-hour
  hl_range=log(H/L) and ret_intrabar=log(close/open); backward-compat
  (falls back to 4-col output only when O/H/L present in input)
- train.py build(): auto-detect extra features from parquet columns
  (FEAT_COLS = [ret, realized_vol] + [hl_range, ret_intrabar] if present)
- 5 new tests (9 total in test_prepare_hourly); 24/24 pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:11:59 +02:00
mathiasandClaude Sonnet 4.6 e635a641a4 chore: autoresearch agent STATUS.md iterations (iter1-4 reverted — no improvement)
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Failing after 7s
CD / Deploy via GitOps (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:07:15 +02:00
mathiasandClaude Sonnet 4.6 48c7e3bd02 chore: update metrics.json to canonical WINDOW=120 run (phase1_r2=0.3908)
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Failing after 7s
CD / Deploy via GitOps (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:43:13 +02:00
mathiasandClaude Sonnet 4.6 3aded95ef1 feat(hpo): sweep results + update WINDOW default to 120
HPO sweep (18 configs, D_MODEL×DEPTH×WINDOW grid):
  Best: D_MODEL=128 DEPTH=2 WINDOW=120 → phase1_r2=0.3908
  Worst: D_MODEL=64 (all configs) → max phase1_r2=0.3653

Key findings:
- WINDOW=120 (5 days) > 240 > 480 — FX vol prediction is local, not regime-scale
- DEPTH=4 doesn't improve over DEPTH=2 — 2 causal layers sufficient
- D_MODEL=64 undercapacity; 128 and 256 comparable

Updated WINDOW default: 240 → 120 (HPO winner).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:42:50 +02:00
mathiasandClaude Sonnet 4.6 e739f84afd feat(hpo): env-var knob overrides + sweep script (18 configs)
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Failing after 8s
CD / Deploy via GitOps (push) Has been skipped
- train.py knobs all readable from JEPA_* env vars (JEPA_WINDOW, JEPA_D_MODEL,
  JEPA_DEPTH, etc.) so hpo_sweep.py can override without touching source
- scripts/hpo_sweep.py: 3×2×3 grid over D_MODEL × DEPTH × WINDOW,
  logs to results/hpo/hpo_results.jsonl with leaderboard at end
- 3 new tests: env override correctness, configs() schema validation
- 19/19 tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:37:27 +02:00
mathiasandClaude Sonnet 4.6 d282571c96 feat(phase1): MLP supervised head on frozen HEPA embeddings
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Failing after 7s
CD / Deploy via GitOps (push) Has been skipped
SupervisedHead: Linear(D→D/2)→GELU→Linear(D/2→1), trained on standardised
targets with proper epoch iteration (not random 200 batches) + weight_decay=1e-4.
Root cause of earlier -803 R²: unstandardised targets + ~1.2 effective passes.

Results on 2008-2023 hourly OOS (n=11,641):
  val_vol_r2 (linear probe): 0.3585
  phase1_r2  (MLP head):     0.3737  (+0.015 over probe)

New knobs: PHASE1_EPOCHS=200, PHASE1_LR=1e-3. 16/16 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:33:28 +02:00
mathiasandClaude Sonnet 4.6 1a17a4c88e fix(eval): export block uses next-period RV target (t+1) to match Python probe
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Failing after 7s
CD / Deploy via GitOps (push) Has been skipped
Go harness reported 0.42 vs Python 0.36 because export used realized_vol[t]
(current) while Python probe used realized_vol[t+1] (next-period). Fix adds
t+1 < len(df2) guard and uses iloc[t+1] as target. Go now matches Python: 0.3585.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:11:04 +02:00
mathiasandClaude Sonnet 4.6 fa6d6c634a fix(train): mini-batch training to avoid GPU OOM on hourly dataset
CD / Build & Import (push) Failing after 7s
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 4s
BATCH_SIZE=512 per step; batched embed() at eval + export time.
78k hourly windows can't fit in GPU in one shot (was fine at 877 daily).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:14:29 +02:00
27 changed files with 2157 additions and 118 deletions
+75
View File
@@ -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
+17
View File
@@ -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
+4
View File
@@ -16,3 +16,7 @@
| 3 | -0.0716 | +0.0487 | KEEP | 4s | gpu=0% vram=10054/12227MiB temp=36°C | iter3 | | 3 | -0.0716 | +0.0487 | KEEP | 4s | gpu=0% vram=10054/12227MiB temp=36°C | iter3 |
| 4 | 0.0590 | +0.1306 | KEEP | 5s | gpu=0% vram=10054/12227MiB temp=36°C | iter4 | | 4 | 0.0590 | +0.1306 | KEEP | 5s | gpu=0% vram=10054/12227MiB temp=36°C | iter4 |
| 5 | 0.0599 | +0.0009 | KEEP | 5s | gpu=0% vram=10054/12227MiB temp=37°C | iter5 | | 5 | 0.0599 | +0.0009 | KEEP | 5s | gpu=0% vram=10054/12227MiB temp=37°C | iter5 |
| 1 | 0.0563 | -0.0036 | revert | 5s | gpu=0% vram=10054/12227MiB temp=35°C | iter1 |
| 2 | 0.0577 | -0.0022 | revert | 5s | gpu=0% vram=10054/12227MiB temp=36°C | iter2 |
| 3 | 0.0563 | -0.0036 | revert | 5s | gpu=0% vram=10054/12227MiB temp=36°C | iter3 |
| 4 | -0.1613 | -0.2212 | revert | 5s | gpu=0% vram=10054/12227MiB temp=37°C | iter4 |
+16 -1
View File
@@ -34,9 +34,24 @@ tasks:
data:prepare:all: data:prepare:all:
desc: "Build both daily and hourly parquets" desc: "Build both daily and hourly parquets"
deps: [data:prepare:daily, data:prepare:hourly] deps: [data:prepare:daily, data:prepare:hourly]
train:multipair:
desc: "Train 5-pair G10 HEPA (D=256, best config, phase1_r2≈0.44)"
cmds: [JEPA_USE_MULTIPAIR=1 JEPA_D_MODEL=256 .venv/bin/python train.py]
data:fetch:multipair:
desc: "Download G10 M1 data (GBPUSD/USDJPY/USDCHF/AUDUSD) 2008-2023 from histdata"
cmds: [.venv/bin/python scripts/fetch_multipair.py]
data:prepare:pair:
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}}]
vars:
PAIR: '{{default "eurusd" .PAIR}}'
data:prepare:multipair:
desc: "Merge 5-pair hourly parquets into eurusd_multipair.parquet"
cmds: [.venv/bin/python scripts/prepare_multipair.py]
data:test: data:test:
desc: "Run Python data pipeline tests" desc: "Run Python data pipeline tests"
cmds: [.venv/bin/python -m pytest tests/test_prepare_hourly.py tests/test_hepa.py -v] cmds: [.venv/bin/python -m pytest tests/test_prepare_hourly.py tests/test_hepa.py tests/test_multipair.py -v]
eval:probe: eval:probe:
desc: "Run linear-probe (val_vol_r2) on embeddings from metrics.json" desc: "Run linear-probe (val_vol_r2) on embeddings from metrics.json"
+26
View File
@@ -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.
+11
View File
@@ -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"
}
]
}
+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))
}
}
+100 -18
View File
@@ -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__":
-14
View File
@@ -1,14 +0,0 @@
{
"val_vol_r2": 0.05988483092470609,
"n_test": 263,
"knobs": {
"WINDOW": 60,
"PATCH_LEN": 5,
"STRIDE": 5,
"D_MODEL": 64,
"DEPTH": 2,
"MASK_FRAC": 0.5,
"SIGREG_LAM": 0.01,
"EPOCHS": 300
}
}
+3 -1
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
{"config": {"JEPA_D_MODEL": 64, "JEPA_DEPTH": 2, "JEPA_WINDOW": 120}, "val_vol_r2": 0.302411480667525, "phase1_r2": 0.35809940099716187, "stdout_last": "val_vol_r2 = 0.3024 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:37:37.028406"}
{"config": {"JEPA_D_MODEL": 64, "JEPA_DEPTH": 2, "JEPA_WINDOW": 240}, "val_vol_r2": 0.29654798431244755, "phase1_r2": 0.35618388652801514, "stdout_last": "val_vol_r2 = 0.2965 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:37:49.822762"}
{"config": {"JEPA_D_MODEL": 64, "JEPA_DEPTH": 2, "JEPA_WINDOW": 480}, "val_vol_r2": 0.31050360040290237, "phase1_r2": 0.36530405282974243, "stdout_last": "val_vol_r2 = 0.3105 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:38:02.966176"}
{"config": {"JEPA_D_MODEL": 64, "JEPA_DEPTH": 4, "JEPA_WINDOW": 120}, "val_vol_r2": 0.2925057399716364, "phase1_r2": 0.3467639684677124, "stdout_last": "val_vol_r2 = 0.2925 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:38:16.195552"}
{"config": {"JEPA_D_MODEL": 64, "JEPA_DEPTH": 4, "JEPA_WINDOW": 240}, "val_vol_r2": 0.29334667623516786, "phase1_r2": 0.35872191190719604, "stdout_last": "val_vol_r2 = 0.2933 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:38:31.372602"}
{"config": {"JEPA_D_MODEL": 64, "JEPA_DEPTH": 4, "JEPA_WINDOW": 480}, "val_vol_r2": 0.3123527205416422, "phase1_r2": 0.3572431206703186, "stdout_last": "val_vol_r2 = 0.3124 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:38:46.672203"}
{"config": {"JEPA_D_MODEL": 128, "JEPA_DEPTH": 2, "JEPA_WINDOW": 120}, "val_vol_r2": 0.3641397896593044, "phase1_r2": 0.3908407688140869, "stdout_last": "val_vol_r2 = 0.3641 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:39:00.793878"}
{"config": {"JEPA_D_MODEL": 128, "JEPA_DEPTH": 2, "JEPA_WINDOW": 240}, "val_vol_r2": 0.35845865364171503, "phase1_r2": 0.3737195134162903, "stdout_last": "val_vol_r2 = 0.3585 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:39:13.321428"}
{"config": {"JEPA_D_MODEL": 128, "JEPA_DEPTH": 2, "JEPA_WINDOW": 480}, "val_vol_r2": 0.35310115657814645, "phase1_r2": 0.35306859016418457, "stdout_last": "val_vol_r2 = 0.3531 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:39:26.402249"}
{"config": {"JEPA_D_MODEL": 128, "JEPA_DEPTH": 4, "JEPA_WINDOW": 120}, "val_vol_r2": 0.3655629727960601, "phase1_r2": 0.371029257774353, "stdout_last": "val_vol_r2 = 0.3656 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:39:39.786248"}
{"config": {"JEPA_D_MODEL": 128, "JEPA_DEPTH": 4, "JEPA_WINDOW": 240}, "val_vol_r2": 0.36109622605593217, "phase1_r2": 0.3666273355484009, "stdout_last": "val_vol_r2 = 0.3611 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:39:53.194111"}
{"config": {"JEPA_D_MODEL": 128, "JEPA_DEPTH": 4, "JEPA_WINDOW": 480}, "val_vol_r2": 0.362228341965093, "phase1_r2": 0.3590735197067261, "stdout_last": "val_vol_r2 = 0.3622 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:40:06.991680"}
{"config": {"JEPA_D_MODEL": 256, "JEPA_DEPTH": 2, "JEPA_WINDOW": 120}, "val_vol_r2": 0.3749483295047378, "phase1_r2": 0.3801569938659668, "stdout_last": "val_vol_r2 = 0.3749 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:40:21.512210"}
{"config": {"JEPA_D_MODEL": 256, "JEPA_DEPTH": 2, "JEPA_WINDOW": 240}, "val_vol_r2": 0.3765593861479334, "phase1_r2": 0.38416117429733276, "stdout_last": "val_vol_r2 = 0.3766 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:40:34.959919"}
{"config": {"JEPA_D_MODEL": 256, "JEPA_DEPTH": 2, "JEPA_WINDOW": 480}, "val_vol_r2": 0.3653399117639956, "phase1_r2": 0.3685130476951599, "stdout_last": "val_vol_r2 = 0.3653 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:40:48.806135"}
{"config": {"JEPA_D_MODEL": 256, "JEPA_DEPTH": 4, "JEPA_WINDOW": 120}, "val_vol_r2": 0.375961424966925, "phase1_r2": 0.37861257791519165, "stdout_last": "val_vol_r2 = 0.3760 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:41:04.056939"}
{"config": {"JEPA_D_MODEL": 256, "JEPA_DEPTH": 4, "JEPA_WINDOW": 240}, "val_vol_r2": 0.37841726893098504, "phase1_r2": 0.3781360387802124, "stdout_last": "val_vol_r2 = 0.3784 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:41:18.693263"}
{"config": {"JEPA_D_MODEL": 256, "JEPA_DEPTH": 4, "JEPA_WINDOW": 480}, "val_vol_r2": 0.37118530199441635, "phase1_r2": 0.3651617765426636, "stdout_last": "val_vol_r2 = 0.3712 (n_test=11641, dev=cuda)", "ts": "2026-06-26T10:41:34.796157"}
+155
View File
@@ -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()
+48
View File
@@ -0,0 +1,48 @@
"""Fetch G10 FX M1 data from histdata.com for all pairs except EURUSD (already fetched).
Each pair's zips go into data/raw/{pair}/ to avoid collisions.
Output: data/raw/gbpusd/DAT_ASCII_GBPUSD_M1_YYYY.zip etc.
python scripts/fetch_multipair.py
PAIRS=gbpusd,usdjpy YEARS=2020,2021 python scripts/fetch_multipair.py
"""
import os
import time
from histdata import download_hist_data
from histdata.api import Platform as P, TimeFrame as T
PAIRS_DEFAULT = ["gbpusd", "usdjpy", "usdchf", "audusd"]
YEARS_DEFAULT = list(range(2008, 2024))
def main():
pairs_env = os.environ.get("PAIRS", "")
pairs = [p.strip() for p in pairs_env.split(",")] if pairs_env else PAIRS_DEFAULT
years_env = os.environ.get("YEARS", "")
years = [int(y.strip()) for y in years_env.split(",")] if years_env else YEARS_DEFAULT
for pair in pairs:
out_dir = f"data/raw/{pair}"
os.makedirs(out_dir, exist_ok=True)
print(f"\n=== {pair.upper()} ===")
for yr in years:
out_path = os.path.join(out_dir, f"DAT_ASCII_{pair.upper()}_M1_{yr}.zip")
if os.path.exists(out_path):
print(f" {yr} already present, skip")
continue
try:
f = download_hist_data(
year=str(yr), month=None, pair=pair,
platform=P.GENERIC_ASCII, time_frame=T.ONE_MINUTE,
output_directory=out_dir,
)
print(f" fetched {yr}{f}")
except Exception as e:
print(f" {yr} FAILED: {e}")
time.sleep(2)
if __name__ == "__main__":
main()
+97
View File
@@ -0,0 +1,97 @@
"""HPO sweep for jepa-fx-risk HEPA backbone.
Runs train.py with different JEPA_* env overrides, logs results to
results/hpo/hpo_results.jsonl. Each config writes its metrics.json then
the result is appended to the JSONL.
Usage:
python scripts/hpo_sweep.py
python scripts/hpo_sweep.py --dry-run # print configs, don't train
"""
import argparse
import json
import os
import subprocess
import sys
from datetime import datetime
from itertools import product
from pathlib import Path
# ── Search space ──────────────────────────────────────────────────────────────
SEARCH_SPACE = {
"JEPA_D_MODEL": [64, 128, 256],
"JEPA_DEPTH": [2, 4],
"JEPA_WINDOW": [120, 240, 480],
}
# Fixed: PATCH_LEN=24 (1-day patches), N_HEADS=4, EPOCHS=300, PHASE1_EPOCHS=200
PYTHON = str(Path(sys.executable))
OUT_DIR = Path("results/hpo")
def configs():
"""Yield all configs as dicts of JEPA_* env overrides."""
keys = list(SEARCH_SPACE.keys())
for vals in product(*SEARCH_SPACE.values()):
yield dict(zip(keys, vals))
def run_config(cfg: dict, metrics_path: str = "metrics.json") -> dict:
env = {**os.environ, **{k: str(v) for k, v in cfg.items()}}
result = subprocess.run(
[PYTHON, "train.py"],
env=env,
capture_output=True,
text=True,
)
if result.returncode != 0:
return {"config": cfg, "error": result.stderr[-500:]}
stdout_last = result.stdout.strip().split("\n")[-1]
with open(metrics_path) as f:
m = json.load(f)
return {
"config": cfg,
"val_vol_r2": m.get("val_vol_r2"),
"phase1_r2": m.get("phase1_r2"),
"stdout_last": stdout_last,
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
OUT_DIR.mkdir(parents=True, exist_ok=True)
out_file = OUT_DIR / "hpo_results.jsonl"
all_cfgs = list(configs())
print(f"HPO sweep: {len(all_cfgs)} configs")
for i, cfg in enumerate(all_cfgs):
label = " ".join(f"{k.replace('JEPA_','')}={v}" for k, v in cfg.items())
print(f"\n[{i+1}/{len(all_cfgs)}] {label}")
if args.dry_run:
continue
ts = datetime.utcnow().isoformat()
row = run_config(cfg)
row["ts"] = ts
with open(out_file, "a") as f:
f.write(json.dumps(row) + "\n")
if "error" in row:
print(f" ERROR: {row['error'][:200]}")
else:
print(f" val_vol_r2={row['val_vol_r2']:.4f} phase1_r2={row['phase1_r2']:.4f}")
if not args.dry_run:
# Print leaderboard
rows = [json.loads(l) for l in open(out_file) if l.strip()]
rows = [r for r in rows if "error" not in r]
rows.sort(key=lambda r: r.get("phase1_r2", -999), reverse=True)
print("\n── Leaderboard (by phase1_r2) ─────────────────────────")
for r in rows[:5]:
cfg_str = " ".join(f"{k.replace('JEPA_','')}={v}" for k,v in r["config"].items())
print(f" {r['phase1_r2']:.4f} {cfg_str}")
if __name__ == "__main__":
main()
+30 -10
View File
@@ -18,8 +18,9 @@ import zipfile
import numpy as np import numpy as np
import pandas as pd import pandas as pd
PAIR = os.environ.get("PAIR", "EURUSD").upper()
RAW_DEFAULT = "data/raw" RAW_DEFAULT = "data/raw"
OUT_DEFAULT = "data/processed/eurusd_hourly.parquet" OUT_DEFAULT = f"data/processed/{PAIR.lower()}_hourly.parquet"
MIN_BARS = 30 # drop hours thinner than this (holidays, DST boundary artefacts) MIN_BARS = 30 # drop hours thinner than this (holidays, DST boundary artefacts)
@@ -29,32 +30,51 @@ def resample_to_hourly(m1: pd.DataFrame) -> pd.DataFrame:
"""Aggregate M1 DataFrame to hourly bars. """Aggregate M1 DataFrame to hourly bars.
Args: Args:
m1: DataFrame with columns ['ts' (datetime), 'close' (float)] m1: DataFrame with columns ['ts', 'open', 'high', 'low', 'close']
('open'/'high'/'low' optional — omit for close-only data).
Returns: Returns:
DataFrame with columns ['datetime', 'close', 'ret', 'realized_vol'] DataFrame with columns ['datetime', 'close', 'ret', 'realized_vol',
sorted by datetime; hours with fewer than MIN_BARS M1 ticks dropped. 'hl_range', 'ret_intrabar'] sorted by datetime.
Hours with fewer than MIN_BARS M1 ticks are dropped.
""" """
m1 = m1.sort_values("ts").copy() m1 = m1.sort_values("ts").copy()
m1["log_r"] = np.log(m1["close"]).diff() m1["log_r"] = np.log(m1["close"]).diff()
m1["hour"] = m1["ts"].dt.floor("h") m1["hour"] = m1["ts"].dt.floor("h")
agg = m1.groupby("hour").agg( has_ohlc = all(c in m1.columns for c in ("open", "high", "low"))
agg_dict = dict(
close = ("close", "last"), close = ("close", "last"),
realized_vol = ("log_r", lambda x: np.sqrt(np.nansum(x.values ** 2))), realized_vol = ("log_r", lambda x: np.sqrt(np.nansum(x.values ** 2))),
n_bars = ("log_r", "count"), n_bars = ("log_r", "count"),
).reset_index() )
if has_ohlc:
agg_dict["high"] = ("high", "max")
agg_dict["low"] = ("low", "min")
agg_dict["open_"] = ("open", "first")
agg = m1.groupby("hour").agg(**agg_dict).reset_index()
agg = agg[agg["n_bars"] >= MIN_BARS].copy() agg = agg[agg["n_bars"] >= MIN_BARS].copy()
agg["ret"] = np.log(agg["close"]).diff() agg["ret"] = np.log(agg["close"]).diff()
agg = agg.dropna(subset=["ret"]).reset_index(drop=True) agg = agg.dropna(subset=["ret"]).reset_index(drop=True)
agg = agg.rename(columns={"hour": "datetime"}) agg = agg.rename(columns={"hour": "datetime"})
return agg[["datetime", "close", "ret", "realized_vol"]]
if has_ohlc:
agg["hl_range"] = np.log(agg["high"] / agg["low"])
agg["ret_intrabar"]= np.log(agg["close"] / agg["open_"])
cols = ["datetime", "close", "ret", "realized_vol", "hl_range", "ret_intrabar"]
else:
cols = ["datetime", "close", "ret", "realized_vol"]
return agg[cols]
def load_m1_from_zips(raw_dir: str) -> pd.DataFrame: def load_m1_from_zips(raw_dir: str, pair: str = None) -> pd.DataFrame:
"""Load and concatenate all M1 zips from raw_dir (histdata format).""" """Load and concatenate all M1 zips from raw_dir (histdata format)."""
pattern = os.path.join(raw_dir, "DAT_ASCII_EURUSD_M1_*.zip") p = (pair or PAIR).upper()
pattern = os.path.join(raw_dir, f"DAT_ASCII_{p}_M1_*.zip")
zips = sorted(glob.glob(pattern)) zips = sorted(glob.glob(pattern))
if not zips: if not zips:
raise FileNotFoundError(f"No M1 zips found at {pattern}") raise FileNotFoundError(f"No M1 zips found at {pattern}")
@@ -68,7 +88,7 @@ def load_m1_from_zips(raw_dir: str) -> pd.DataFrame:
names=["dt", "open", "high", "low", "close", "vol"], names=["dt", "open", "high", "low", "close", "vol"],
) )
df["ts"] = pd.to_datetime(df["dt"], format="%Y%m%d %H%M%S") df["ts"] = pd.to_datetime(df["dt"], format="%Y%m%d %H%M%S")
frames.append(df[["ts", "close"]]) frames.append(df[["ts", "open", "high", "low", "close"]])
print(f" loaded {os.path.basename(zp)}: {len(df):,} rows") print(f" loaded {os.path.basename(zp)}: {len(df):,} rows")
return pd.concat(frames).sort_values("ts").reset_index(drop=True) return pd.concat(frames).sort_values("ts").reset_index(drop=True)
+72
View File
@@ -0,0 +1,72 @@
"""Merge per-pair hourly parquets into a single wide multipair parquet.
Each pair contributes two features: {pair}_ret and {pair}_rv (realized vol).
The merge is an INNER JOIN on datetime — only hours present in ALL pairs are kept.
The target for train.py remains eurusd_rv.
Output: data/processed/eurusd_multipair.parquet
python scripts/prepare_multipair.py
PROCESSED=data/processed python scripts/prepare_multipair.py
"""
import os
import pandas as pd
PAIRS = ["eurusd", "gbpusd", "usdjpy", "usdchf", "audusd"]
PROCESSED_DEFAULT = "data/processed"
OUT_DEFAULT = "data/processed/eurusd_multipair.parquet"
def merge_pair_parquets(pair_dfs: dict) -> pd.DataFrame:
"""Inner-join hourly DataFrames from multiple pairs on datetime.
Args:
pair_dfs: dict mapping pair name (e.g. "eurusd") to hourly DataFrame
with columns [datetime, close, ret, realized_vol, ...].
Returns:
Wide DataFrame with columns:
datetime, {pair}_ret, {pair}_rv for each pair.
"""
merged = None
for pair, df in pair_dfs.items():
sub = df[["datetime", "ret", "realized_vol"]].copy()
sub = sub.rename(columns={"ret": f"{pair}_ret", "realized_vol": f"{pair}_rv"})
sub = sub.set_index("datetime")
if merged is None:
merged = sub
else:
merged = merged.join(sub, how="inner")
return merged.reset_index()
def build_multipair_parquet(
processed_dir: str = PROCESSED_DEFAULT,
out_path: str = OUT_DEFAULT,
pairs: list = None,
) -> None:
if pairs is None:
pairs = PAIRS
pair_dfs = {}
for pair in pairs:
path = os.path.join(processed_dir, f"{pair}_hourly.parquet")
if not os.path.exists(path):
raise FileNotFoundError(
f"{pair}_hourly.parquet not found at {path} — run prepare_hourly.py for this pair first"
)
df = pd.read_parquet(path)
pair_dfs[pair] = df
merged = merge_pair_parquets(pair_dfs)
merged.to_parquet(out_path, index=False)
n_pairs = len(pairs)
n_ch = n_pairs * 2
print(f"Multipair parquet: {len(merged):,} rows × {n_ch} feature channels ({n_pairs} pairs)")
print(f"Date range: {merged['datetime'].min()}{merged['datetime'].max()}")
print(f"Written: {out_path}")
if __name__ == "__main__":
processed_dir = os.environ.get("PROCESSED", PROCESSED_DEFAULT)
build_multipair_parquet(processed_dir=processed_dir)
+134
View File
@@ -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()
+67
View File
@@ -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))
+215
View File
@@ -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"))
+169 -4
View File
@@ -1,9 +1,10 @@
"""Failing tests for HEPA backbone in train.py. """Failing tests for HEPA backbone + Phase-1 supervised head + HPO in train.py.
Run: cd ~/dev/AI/jepa-fx-risk && .venv/bin/python -m pytest tests/test_hepa.py -v Run: cd ~/dev/AI/jepa-fx-risk && .venv/bin/python -m pytest tests/test_hepa.py -v
These tests define what the new backbone must satisfy BEFORE implementation. These tests define what the backbone and head must satisfy BEFORE implementation.
""" """
import math import math
import os
import torch import torch
import torch.nn as nn import torch.nn as nn
import pytest import pytest
@@ -12,11 +13,24 @@ import pytest
# They will fail until train.py implements: CausalEncoder, HorizonPredictor, vicreg_loss # They will fail until train.py implements: CausalEncoder, HorizonPredictor, vicreg_loss
def _import(): def _import(env_overrides=None):
import importlib.util, sys import importlib.util, sys
spec = importlib.util.spec_from_file_location("train", "train.py") saved = {}
if env_overrides:
for k, v in env_overrides.items():
saved[k] = os.environ.get(k)
os.environ[k] = str(v)
# Force fresh module load (env vars must be read at import time)
name = f"train_{id(env_overrides)}"
spec = importlib.util.spec_from_file_location(name, "train.py")
mod = importlib.util.module_from_spec(spec) mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) spec.loader.exec_module(mod)
if env_overrides:
for k, orig in saved.items():
if orig is None:
os.environ.pop(k, None)
else:
os.environ[k] = orig
return mod return mod
@@ -110,3 +124,154 @@ def test_build_hourly_more_windows(train_mod):
(Xtr, _), _ = train_mod.build() (Xtr, _), _ = train_mod.build()
# Daily had ~877 train windows; hourly with 2008-2021 should have > 50,000 # Daily had ~877 train windows; hourly with 2008-2021 should have > 50,000
assert len(Xtr) > 10_000, f"expected >10k hourly train windows, got {len(Xtr)}" assert len(Xtr) > 10_000, f"expected >10k hourly train windows, got {len(Xtr)}"
# ── Phase-1: supervised head ──────────────────────────────────────────────────
# 8. SupervisedHead exists and maps (B, D) → (B,)
def test_supervised_head_shape(train_mod):
D = 128
head = train_mod.SupervisedHead(D)
x = torch.randn(16, D)
out = head(x)
assert out.shape == (16,), f"expected (16,), got {out.shape}"
# 9. SupervisedHead gradient flows (not frozen)
def test_supervised_head_backward(train_mod):
head = train_mod.SupervisedHead(64)
x = torch.randn(8, 64)
loss = head(x).mean()
loss.backward()
for name, p in head.named_parameters():
assert p.grad is not None, f"no grad on {name}"
# 10. Phase-1 beats linear on nonlinear synthetic signal
def test_phase1_beats_linear_on_nonlinear(train_mod):
"""MLP head should outperform ridge regression on data with nonlinear structure."""
import numpy as np
torch.manual_seed(0); np.random.seed(0)
N, D = 1000, 32
# target = |h|² (quadratic — linear can't fit well)
Etr = np.random.randn(N, D).astype(np.float32)
ytr = (Etr ** 2).sum(axis=1)
Ete = np.random.randn(200, D).astype(np.float32)
yte = (Ete ** 2).sum(axis=1)
# Ridge baseline
A = np.hstack([Etr, np.ones((N, 1))])
w = np.linalg.solve(A.T @ A + 1e-3 * np.eye(A.shape[1]), A.T @ ytr)
pred_lin = np.hstack([Ete, np.ones((200, 1))]) @ w
r2_lin = float(1 - ((yte - pred_lin) ** 2).sum() / ((yte - yte.mean()) ** 2).sum())
# MLP head
head = train_mod.SupervisedHead(D)
opt = torch.optim.Adam(head.parameters(), lr=1e-2)
Xtr_t = torch.tensor(Etr); ytr_t = torch.tensor(ytr)
for _ in range(300):
loss = nn.functional.mse_loss(head(Xtr_t), ytr_t)
opt.zero_grad(); loss.backward(); opt.step()
head.eval()
with torch.no_grad():
pred_mlp = head(torch.tensor(Ete)).numpy()
r2_mlp = float(1 - ((yte - pred_mlp) ** 2).sum() / ((yte - yte.mean()) ** 2).sum())
assert r2_mlp > r2_lin + 0.05, (
f"MLP R²={r2_mlp:.3f} should beat ridge R²={r2_lin:.3f} by >0.05 on quadratic target"
)
# 11. main() returns phase1_r2 in metrics.json (integration — needs real data)
def test_metrics_json_has_phase1_r2(train_mod):
import json
if not os.path.exists("metrics.json"):
pytest.skip("metrics.json not present — run train.py first")
with open("metrics.json") as f:
m = json.load(f)
assert "phase1_r2" in m, f"phase1_r2 missing from metrics.json: {list(m.keys())}"
assert m["phase1_r2"] > m["val_vol_r2"], (
f"MLP head phase1_r2={m['phase1_r2']:.4f} should beat linear probe "
f"val_vol_r2={m['val_vol_r2']:.4f}"
)
# ── HPO: env-var knob overrides ───────────────────────────────────────────────
# 12. JEPA_WINDOW env var overrides WINDOW at import time
def test_env_override_window():
mod = _import({"JEPA_WINDOW": "48"})
assert mod.WINDOW == 48, f"expected WINDOW=48, got {mod.WINDOW}"
# 13. JEPA_D_MODEL and JEPA_DEPTH env vars work
def test_env_override_d_model_depth():
mod = _import({"JEPA_D_MODEL": "64", "JEPA_DEPTH": "4"})
assert mod.D_MODEL == 64, f"expected D_MODEL=64, got {mod.D_MODEL}"
assert mod.DEPTH == 4, f"expected DEPTH=4, got {mod.DEPTH}"
# 14. hpo_sweep.py exists and generates correct config list
def test_hpo_sweep_configs():
import importlib.util
sweep_path = "scripts/hpo_sweep.py"
if not os.path.exists(sweep_path):
pytest.fail(f"{sweep_path} not found — implement it")
spec = importlib.util.spec_from_file_location("hpo_sweep", sweep_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
cfgs = list(mod.configs())
assert len(cfgs) > 0, "configs() returned empty list"
# Every config must have at least D_MODEL, DEPTH, WINDOW keys
required = {"JEPA_D_MODEL", "JEPA_DEPTH", "JEPA_WINDOW"}
for cfg in cfgs:
assert required.issubset(cfg.keys()), f"config missing required keys: {cfg}"
# ── Option B: joint encoder fine-tuning in phase-1 ───────────────────────────
# 15. PHASE1_JOINT and PHASE1_ENCODER_LR knobs exist at module level
def test_joint_phase1_knobs():
mod = _import({"JEPA_PHASE1_JOINT": "1", "JEPA_PHASE1_ENCODER_LR": "1e-5"})
assert hasattr(mod, "PHASE1_JOINT"), "PHASE1_JOINT knob missing from train.py"
assert hasattr(mod, "PHASE1_ENCODER_LR"), "PHASE1_ENCODER_LR knob missing from train.py"
assert mod.PHASE1_JOINT is True
assert abs(mod.PHASE1_ENCODER_LR - 1e-5) < 1e-12
# 16. PHASE1_JOINT defaults to True (joint mode on by default)
def test_joint_phase1_default_on():
mod = _import()
assert hasattr(mod, "PHASE1_JOINT"), "PHASE1_JOINT knob missing"
assert mod.PHASE1_JOINT is True, f"PHASE1_JOINT default should be True, got {mod.PHASE1_JOINT}"
# 17. JEPA_PHASE1_JOINT=0 disables joint (env override works)
def test_joint_phase1_can_disable():
mod = _import({"JEPA_PHASE1_JOINT": "0"})
assert mod.PHASE1_JOINT is False, f"expected False, got {mod.PHASE1_JOINT}"
# 18. Encoder receives non-zero gradients when joint-training with the head
def test_joint_encoder_grad_flows(train_mod):
"""Gradient must flow into encoder when using two-param-group joint optimizer."""
import torch.nn.functional as F
enc = train_mod.CausalEncoder(n_channels=2, patch_len=8, d_model=16, n_heads=2, depth=1)
head = train_mod.SupervisedHead(16)
enc.train(); head.train()
opt = torch.optim.Adam([
{"params": head.parameters(), "lr": 1e-3},
{"params": enc.parameters(), "lr": 1e-5},
], weight_decay=1e-4)
# Tiny batch: 4 windows of length 16 (= 2 patches of patch_len=8)
X = torch.randn(4, 16, 2)
y = torch.randn(4)
tokens = enc(X) # (4, 2, 16)
h = tokens[:, -1, :] # (4, 16) — last token
pred = head(h)
loss = F.mse_loss(pred, y)
loss.backward()
enc_grads = [p.grad for p in enc.parameters() if p.grad is not None]
assert len(enc_grads) > 0, "no encoder params received gradients"
assert any(g.abs().max().item() > 0 for g in enc_grads), "all encoder grads are zero"
+126
View File
@@ -0,0 +1,126 @@
"""Tests for multi-pair G10 pipeline (Option C).
Tests the prepare_multipair.py merge logic and train.py multipair build().
Run: cd ~/dev/AI/jepa-fx-risk && .venv/bin/python -m pytest tests/test_multipair.py -v
"""
import importlib.util
import numpy as np
import pandas as pd
import pytest
import os
def _import_mp():
spec = importlib.util.spec_from_file_location("prepare_multipair", "scripts/prepare_multipair.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="module")
def mp():
return _import_mp()
def _pair_df(start: str, n_hours: int, seed: int) -> pd.DataFrame:
"""Synthetic single-pair hourly parquet (same schema as prepare_hourly output)."""
rng = np.random.default_rng(seed)
dts = pd.date_range(start, periods=n_hours, freq="h")
closes = 1.1 + np.cumsum(rng.normal(0, 0.001, n_hours))
return pd.DataFrame({
"datetime": dts,
"close": closes,
"ret": rng.normal(0, 0.001, n_hours),
"realized_vol": np.abs(rng.normal(0.0005, 0.0001, n_hours)),
})
# 1. merge_pair_parquets returns inner join on datetime
def test_merge_inner_join(mp):
eur = _pair_df("2020-01-01 00:00", 100, seed=1) # t0 to t0+99h
gbp = _pair_df("2020-01-01 20:00", 60, seed=2) # t0+20 to t0+79h → 60 common
result = mp.merge_pair_parquets({"eurusd": eur, "gbpusd": gbp})
assert len(result) == 60, f"expected 60 (inner join), got {len(result)}"
# 2. merge_pair_parquets prefixes columns with pair name
def test_merge_column_prefixes(mp):
eur = _pair_df("2020-01-01 00:00", 50, seed=1)
gbp = _pair_df("2020-01-01 00:00", 50, seed=2)
result = mp.merge_pair_parquets({"eurusd": eur, "gbpusd": gbp})
assert "datetime" in result.columns, "datetime column missing"
assert "eurusd_ret" in result.columns
assert "eurusd_rv" in result.columns
assert "gbpusd_ret" in result.columns
assert "gbpusd_rv" in result.columns
# raw pair columns should not leak through unprefixed
assert "ret" not in result.columns
assert "realized_vol" not in result.columns
# 3. No NaN in merged output
def test_merge_no_nan(mp):
eur = _pair_df("2020-01-01 00:00", 50, seed=1)
gbp = _pair_df("2020-01-01 00:00", 50, seed=2)
result = mp.merge_pair_parquets({"eurusd": eur, "gbpusd": gbp})
nan_count = result.isnull().sum().sum()
assert nan_count == 0, f"{nan_count} NaN values in merged output"
# 4. PAIRS constant is a non-empty list starting with eurusd
def test_pairs_constant(mp):
assert hasattr(mp, "PAIRS"), "PAIRS constant missing from prepare_multipair.py"
assert len(mp.PAIRS) >= 2, "PAIRS must have at least 2 pairs"
assert mp.PAIRS[0] == "eurusd", "first pair must be eurusd (target pair)"
# 5. merge target column is eurusd_rv (for build() target selection)
def test_merge_has_eurusd_rv_as_target(mp):
eur = _pair_df("2020-01-01 00:00", 50, seed=1)
gbp = _pair_df("2020-01-01 00:00", 50, seed=2)
result = mp.merge_pair_parquets({"eurusd": eur, "gbpusd": gbp})
assert "eurusd_rv" in result.columns, "eurusd_rv (target) missing from merged output"
assert (result["eurusd_rv"] > 0).all(), "eurusd_rv should be positive"
# 6. train.py recognises JEPA_USE_MULTIPAIR env var
def test_use_multipair_knob():
import importlib.util as ilu
spec = ilu.spec_from_file_location(f"train_mp_{id(None)}", "train.py")
mod = ilu.module_from_spec(spec)
saved = os.environ.get("JEPA_USE_MULTIPAIR")
os.environ["JEPA_USE_MULTIPAIR"] = "1"
try:
spec.loader.exec_module(mod)
finally:
if saved is None:
os.environ.pop("JEPA_USE_MULTIPAIR", None)
else:
os.environ["JEPA_USE_MULTIPAIR"] = saved
assert hasattr(mod, "USE_MULTIPAIR"), "USE_MULTIPAIR knob missing from train.py"
assert mod.USE_MULTIPAIR is True
# 7. build() uses n_pairs*2 channels when multipair parquet present
def test_build_uses_multipair_channels():
import importlib.util as ilu
multipair_path = "data/processed/eurusd_multipair.parquet"
if not os.path.exists(multipair_path):
pytest.skip("eurusd_multipair.parquet not present — run data:prepare:multipair first")
saved = os.environ.get("JEPA_USE_MULTIPAIR")
os.environ["JEPA_USE_MULTIPAIR"] = "1"
try:
spec = ilu.spec_from_file_location(f"train_mp2_{id(None)}", "train.py")
mod = ilu.module_from_spec(spec)
spec.loader.exec_module(mod)
(Xtr, _), _ = mod.build()
finally:
if saved is None:
os.environ.pop("JEPA_USE_MULTIPAIR", None)
else:
os.environ["JEPA_USE_MULTIPAIR"] = saved
mp = _import_mp()
expected_ch = len(mp.PAIRS) * 2
assert Xtr.shape[2] == expected_ch, (
f"expected {expected_ch} channels (n_pairs={len(mp.PAIRS)}×2), got {Xtr.shape[2]}"
)
+83 -2
View File
@@ -102,9 +102,7 @@ def test_thin_hours_dropped(ph):
# 5. Output parquet path and schema (integration — reads actual M1 zips if present) # 5. Output parquet path and schema (integration — reads actual M1 zips if present)
def test_output_schema_from_zips(ph, tmp_path): def test_output_schema_from_zips(ph, tmp_path):
# Build a minimal fake zip structure
import zipfile, io import zipfile, io
# synthetic M1 CSV (histdata format: YYYYMMDD HHMMSS;O;H;L;C;V)
rows = [] rows = []
for h in range(24): for h in range(24):
for m in range(60): for m in range(60):
@@ -124,3 +122,86 @@ def test_output_schema_from_zips(ph, tmp_path):
df = pd.read_parquet(out_path) df = pd.read_parquet(out_path)
assert set(["datetime", "close", "ret", "realized_vol"]).issubset(df.columns) assert set(["datetime", "close", "ret", "realized_vol"]).issubset(df.columns)
assert len(df) > 0 assert len(df) > 0
# ── New OHLCV-derived features ────────────────────────────────────────────────
def _make_m1_ohlcv(n_hours: int = 4, price: float = 1.1) -> pd.DataFrame:
"""Synthetic M1 with distinct O, H, L, C so hl_range and ret_intrabar are nonzero."""
rng = np.random.default_rng(7)
ts = pd.date_range("2020-01-06 00:00", periods=n_hours * 60, freq="min")
closes = price + np.cumsum(rng.normal(0, 0.0002, len(ts)))
highs = closes + rng.uniform(0.0001, 0.0005, len(ts))
lows = closes - rng.uniform(0.0001, 0.0005, len(ts))
opens = np.roll(closes, 1); opens[0] = price
return pd.DataFrame({"ts": ts, "open": opens, "high": highs, "low": lows, "close": closes})
# 6. resample_to_hourly produces hl_range column
def test_hourly_has_hl_range(ph):
m1 = _make_m1_ohlcv()
hourly = ph.resample_to_hourly(m1)
assert "hl_range" in hourly.columns, f"missing hl_range; cols={hourly.columns.tolist()}"
assert (hourly["hl_range"] > 0).all(), "hl_range should be positive"
# 7. resample_to_hourly produces ret_intrabar column
def test_hourly_has_ret_intrabar(ph):
m1 = _make_m1_ohlcv()
hourly = ph.resample_to_hourly(m1)
assert "ret_intrabar" in hourly.columns, f"missing ret_intrabar; cols={hourly.columns.tolist()}"
# 8. hl_range = log(hourly_high / hourly_low)
def test_hl_range_formula(ph):
# Two hours; second has known H=1.105, L=1.095
ts0 = pd.date_range("2020-01-06 00:00", periods=60, freq="min")
ts1 = pd.date_range("2020-01-06 01:00", periods=60, freq="min")
closes = np.full(120, 1.1)
highs = np.full(120, 1.1)
lows = np.full(120, 1.1)
# second hour: known spread
highs[60:] = 1.105
lows[60:] = 1.095
m1 = pd.DataFrame({
"ts": np.concatenate([ts0, ts1]),
"open": closes, "high": highs, "low": lows, "close": closes,
})
hourly = ph.resample_to_hourly(m1)
assert len(hourly) >= 1
hl = hourly.iloc[-1]["hl_range"]
expected = float(np.log(1.105 / 1.095))
assert abs(hl - expected) < 1e-6, f"hl_range={hl:.8f}, expected={expected:.8f}"
# 9. ret_intrabar = log(hourly_last_close / hourly_first_open)
def test_ret_intrabar_formula(ph):
ts0 = pd.date_range("2020-01-06 00:00", periods=60, freq="min")
ts1 = pd.date_range("2020-01-06 01:00", periods=60, freq="min")
closes = np.full(120, 1.1)
opens = np.full(120, 1.1)
# second hour: open=1.09, close=1.11
opens[60] = 1.09
closes[119] = 1.11
m1 = pd.DataFrame({
"ts": np.concatenate([ts0, ts1]),
"open": opens, "high": closes + 0.001, "low": closes - 0.001, "close": closes,
})
hourly = ph.resample_to_hourly(m1)
assert len(hourly) >= 1
rib = hourly.iloc[-1]["ret_intrabar"]
expected = float(np.log(1.11 / 1.09))
assert abs(rib - expected) < 1e-6, f"ret_intrabar={rib:.8f}, expected={expected:.8f}"
# 10. build() in train.py uses 2 feature channels (HPO: hl_range/ret_intrabar redundant)
def test_build_uses_2_channels(tmp_path):
import importlib.util, os
hourly_path = "data/processed/eurusd_hourly.parquet"
if not os.path.exists(hourly_path):
pytest.skip("eurusd_hourly.parquet not present")
spec = importlib.util.spec_from_file_location("train_2ch", "train.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
(Xtr, _), _ = mod.build()
assert Xtr.shape[2] == 2, f"expected 2 channels, got {Xtr.shape[2]}"
+134
View File
@@ -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"
+108
View File
@@ -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
+163 -27
View File
@@ -17,18 +17,27 @@ import torch
import torch.nn as nn import torch.nn as nn
import torch.nn.functional as F import torch.nn.functional as F
# --- agent-tunable knobs --- # --- agent-tunable knobs (all overridable via JEPA_* env vars for HPO) ---
USE_HOURLY = True # prefer eurusd_hourly.parquet when available import os as _os
WINDOW = 240 # hourly: 10 trading days; if USE_HOURLY=False reset to 60 USE_HOURLY = True
PATCH_LEN = 24 # hourly: 1-day patches (10 tokens); if USE_HOURLY=False reset to 10 WINDOW = int(_os.environ.get("JEPA_WINDOW", 120)) # HPO winner: 5-day context
D_MODEL = 128 PATCH_LEN = int(_os.environ.get("JEPA_PATCH_LEN", 24))
DEPTH = 2 D_MODEL = int(_os.environ.get("JEPA_D_MODEL", 128))
N_HEADS = 4 DEPTH = int(_os.environ.get("JEPA_DEPTH", 2))
ALPHA = 0.1 # VICReg mixing weight (fixed at 0.1 in HEPA paper) N_HEADS = int(_os.environ.get("JEPA_N_HEADS", 4))
DELTA_T_MAX = 3 # max prediction horizon in patches (1..min(DELTA_T_MAX, N-1-c)) ALPHA = float(_os.environ.get("JEPA_ALPHA", 0.1))
EPOCHS = 300 DELTA_T_MAX = int(_os.environ.get("JEPA_DELTA_T_MAX", 3))
LR = 3e-4 BATCH_SIZE = int(_os.environ.get("JEPA_BATCH_SIZE", 512))
SEED = 0 EPOCHS = int(_os.environ.get("JEPA_EPOCHS", 300))
LR = float(_os.environ.get("JEPA_LR", 3e-4))
PHASE1_EPOCHS = int(_os.environ.get("JEPA_PHASE1_EPOCHS", 200))
PHASE1_LR = float(_os.environ.get("JEPA_PHASE1_LR", 1e-3))
PHASE1_JOINT = bool(int(_os.environ.get("JEPA_PHASE1_JOINT", 1)))
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))
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))
# --------------------------- # ---------------------------
torch.manual_seed(SEED) torch.manual_seed(SEED)
@@ -118,6 +127,21 @@ class HorizonPredictor(nn.Module):
return self.net(torch.cat([h, dt], dim=-1)) return self.net(torch.cat([h, dt], dim=-1))
# ── Phase-1 supervised head ──────────────────────────────────────────────────
class SupervisedHead(nn.Module):
"""Small MLP trained on frozen HEPA embeddings to predict next-period realized vol."""
def __init__(self, d_model: int):
super().__init__()
self.net = nn.Sequential(
nn.Linear(d_model, d_model // 2), nn.GELU(),
nn.Linear(d_model // 2, 1),
)
def forward(self, h: torch.Tensor) -> torch.Tensor:
return self.net(h).squeeze(-1)
# ── Data ───────────────────────────────────────────────────────────────────── # ── Data ─────────────────────────────────────────────────────────────────────
def build(): def build():
@@ -127,16 +151,45 @@ def build():
falls back to eurusd_daily.parquet otherwise. falls back to eurusd_daily.parquet otherwise.
""" """
import os import os
multipair_path = "data/processed/eurusd_multipair.parquet"
hourly_path = "data/processed/eurusd_hourly.parquet" hourly_path = "data/processed/eurusd_hourly.parquet"
daily_path = "data/processed/eurusd_daily.parquet" daily_path = "data/processed/eurusd_daily.parquet"
if USE_HOURLY and os.path.exists(hourly_path): if USE_MULTIPAIR and os.path.exists(multipair_path):
df = pd.read_parquet(multipair_path).reset_index(drop=True)
df["date"] = pd.to_datetime(df["datetime"])
# All {pair}_ret + {pair}_rv columns as features; eurusd_rv as target
feat_cols = [c for c in df.columns if c.endswith("_ret") or c.endswith("_rv")]
FEAT_COLS = feat_cols
target_col = "eurusd_rv"
elif USE_HOURLY and os.path.exists(hourly_path):
df = pd.read_parquet(hourly_path).reset_index(drop=True) df = pd.read_parquet(hourly_path).reset_index(drop=True)
df["date"] = pd.to_datetime(df["datetime"]) df["date"] = pd.to_datetime(df["datetime"])
# 2-channel default (HPO: adding hl_range+ret_intrabar hurt — correlated with base feats)
FEAT_COLS = ["ret", "realized_vol"]
target_col = "realized_vol"
else: else:
df = pd.read_parquet(daily_path).reset_index(drop=True) df = pd.read_parquet(daily_path).reset_index(drop=True)
df["date"] = pd.to_datetime(df["date"]) df["date"] = pd.to_datetime(df["date"])
feats = df[["ret", "realized_vol"]].to_numpy(np.float32) FEAT_COLS = ["ret", "realized_vol"]
target = df["realized_vol"].to_numpy(np.float32) 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)
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()
te_idx = df.index[df["date"].dt.year >= 2022].tolist() te_idx = df.index[df["date"].dt.year >= 2022].tolist()
mu = feats[:tr_idx[-1]+1].mean(0) mu = feats[:tr_idx[-1]+1].mean(0)
@@ -157,29 +210,37 @@ def main():
(Xtr, ytr), (Xte, yte) = build() (Xtr, ytr), (Xte, yte) = build()
n_feats = Xtr.shape[2] n_feats = Xtr.shape[2]
n_patches = WINDOW // PATCH_LEN n_patches = WINDOW // PATCH_LEN
Xtr_t = torch.tensor(Xtr, device=dev) N_tr = len(Xtr)
bs = min(BATCH_SIZE, N_tr)
enc = CausalEncoder(n_feats, PATCH_LEN, D_MODEL, N_HEADS, DEPTH).to(dev) enc = CausalEncoder(n_feats, PATCH_LEN, D_MODEL, N_HEADS, DEPTH).to(dev)
pred = HorizonPredictor(D_MODEL).to(dev) pred = HorizonPredictor(D_MODEL).to(dev)
opt = torch.optim.AdamW(list(enc.parameters()) + list(pred.parameters()), lr=LR) opt = torch.optim.AdamW(list(enc.parameters()) + list(pred.parameters()), lr=LR)
for ep in range(EPOCHS): for ep in range(EPOCHS):
# Sample random context position and horizon; Δt log-biased toward short # Random mini-batch (avoids OOM on large hourly dataset)
idx_b = torch.randperm(N_tr)[:bs]
Xb = torch.tensor(Xtr[idx_b.numpy()], device=dev)
# Sample random context position and horizon
c = torch.randint(0, n_patches - 1, ()).item() c = torch.randint(0, n_patches - 1, ()).item()
dt = torch.randint(1, max(2, min(DELTA_T_MAX, n_patches - 1 - c) + 1), ()).item() dt = torch.randint(1, max(2, min(DELTA_T_MAX, n_patches - 1 - c) + 1), ()).item()
tokens = enc(Xtr_t) # (B, N, D) tokens = enc(Xb) # (bs, N, D)
h_ctx = tokens[:, c, :] # context embedding h_ctx = tokens[:, c, :] # context embedding
h_tgt = tokens[:, c + dt, :] # target embedding (joint training) h_tgt = tokens[:, c + dt, :] # target embedding (joint training)
h_hat = pred(h_ctx, torch.full((len(Xtr),), float(dt), device=dev)) h_hat = pred(h_ctx, torch.full((bs,), float(dt), device=dev))
loss = vicreg_loss(h_hat, h_tgt, alpha=ALPHA) loss = vicreg_loss(h_hat, h_tgt, alpha=ALPHA)
opt.zero_grad(); loss.backward(); opt.step() opt.zero_grad(); loss.backward(); opt.step()
enc.eval() enc.eval()
with torch.no_grad(): with torch.no_grad():
def embed(X_np): def embed(X_np):
t = torch.tensor(X_np, device=dev) chunks = []
return enc(t)[:, -1, :].cpu().numpy() # last token = full-context summary for i in range(0, len(X_np), bs):
t = torch.tensor(X_np[i:i+bs], device=dev)
chunks.append(enc(t)[:, -1, :].cpu().numpy())
return np.concatenate(chunks, axis=0)
Etr = embed(Xtr) Etr = embed(Xtr)
Ete = embed(Xte) Ete = embed(Xte)
@@ -195,12 +256,81 @@ def main():
ss_tot = ((yte - yte.mean()) ** 2).sum() ss_tot = ((yte - yte.mean()) ** 2).sum()
val_vol_r2 = float(1 - ss_res / ss_tot) val_vol_r2 = float(1 - ss_res / ss_tot)
# Phase-1: MLP supervised head — joint or frozen-encoder path
ytr_mu = float(ytr.mean()); ytr_sd = float(ytr.std()) + 1e-8
ytr_z = (ytr - ytr_mu) / ytr_sd
head = SupervisedHead(D_MODEL).to(dev)
p1_bs = min(BATCH_SIZE, len(Etr_n))
# Shared tensors for the frozen-head warmup (used by both paths)
Etr_t = torch.tensor(Etr_n, device=dev)
ytr_z_t = torch.tensor(ytr_z, device=dev)
Ete_t = torch.tensor(Ete_n, device=dev)
N_tr_h = len(Etr_t)
# Phase 1a: warm up head on frozen embeddings (both paths run this)
head_opt = torch.optim.Adam(head.parameters(), lr=PHASE1_LR, weight_decay=1e-4)
for _ in range(PHASE1_EPOCHS):
perm = torch.randperm(N_tr_h, device=dev)
for start in range(0, N_tr_h, p1_bs):
idx_h = perm[start:start + p1_bs]
loss_h = F.mse_loss(head(Etr_t[idx_h]), ytr_z_t[idx_h])
head_opt.zero_grad(); loss_h.backward(); head_opt.step()
if PHASE1_JOINT:
# Phase 1b: short joint fine-tuning — encoder nudged with tiny LR.
# Normalize live encoder output with FROZEN stats (mu_e, sd_e) so the
# head sees the same embedding distribution it was warmed up on.
enc.train()
mu_e_t = torch.tensor(mu_e, device=dev)
sd_e_t = torch.tensor(sd_e, device=dev)
Xtr_t = torch.tensor(Xtr, device=dev)
joint_opt = torch.optim.Adam([
{"params": head.parameters(), "lr": PHASE1_LR * 0.1},
{"params": enc.parameters(), "lr": PHASE1_ENCODER_LR},
], weight_decay=1e-4)
for _ in range(PHASE1_JOINT_EPOCHS):
perm = torch.randperm(len(Xtr_t), device=dev)
for start in range(0, len(Xtr_t), p1_bs):
idx_j = perm[start:start + p1_bs]
h_raw = enc(Xtr_t[idx_j])[:, -1, :]
h_n = (h_raw - mu_e_t) / sd_e_t # frozen-stats normalisation
loss_j = F.mse_loss(head(h_n), ytr_z_t[idx_j])
joint_opt.zero_grad(); loss_j.backward(); joint_opt.step()
enc.eval()
# Re-extract test embeddings with fine-tuned encoder, same normalisation
with torch.no_grad():
chunks = []
for i in range(0, len(Xte), p1_bs):
t = torch.tensor(Xte[i:i+p1_bs], device=dev)
h = enc(t)[:, -1, :]
chunks.append(((h - mu_e_t) / sd_e_t).cpu().numpy())
Ete_t = torch.tensor(np.concatenate(chunks), device=dev)
head.eval()
with torch.no_grad():
pred_h_z = head(Ete_t).cpu().numpy()
pred_h = pred_h_z * ytr_sd + ytr_mu # de-standardise
phase1_r2 = float(1 - ((yte - pred_h) ** 2).sum() / ss_tot)
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, "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) ──────────────────────────
@@ -216,21 +346,27 @@ def main():
df2 = pd.read_parquet(daily_path2).reset_index(drop=True) df2 = pd.read_parquet(daily_path2).reset_index(drop=True)
df2["date"] = pd.to_datetime(df2["date"]) df2["date"] = pd.to_datetime(df2["date"])
tr_mask = df2["date"].dt.year <= 2021 tr_mask = df2["date"].dt.year <= 2021
feats2 = df2[["ret", "realized_vol"]].to_numpy(np.float32) base2 = ["ret", "realized_vol"]
extra2 = [c for c in ["hl_range", "ret_intrabar"] if c in df2.columns]
feats2 = df2[base2 + extra2].to_numpy(np.float32)
mu2 = feats2[tr_mask].mean(0); sd2 = feats2[tr_mask].std(0) + 1e-8 mu2 = feats2[tr_mask].mean(0); sd2 = feats2[tr_mask].std(0) + 1e-8
fn2 = (feats2 - mu2) / sd2 fn2 = (feats2 - mu2) / sd2
def _export_windows(year_mask): def _export_windows(year_mask):
idx = df2.index[year_mask].tolist() idx = df2.index[year_mask].tolist()
Xs, dates, rvs = [], [], [] Xs, dates, rvs = [], [], []
for t in idx: for t in idx:
if t - WINDOW >= 0: if t - WINDOW >= 0 and t + 1 < len(df2):
Xs.append(fn2[t - WINDOW:t]) Xs.append(fn2[t - WINDOW:t])
dates.append(str(df2["date"].iloc[t].date())) dates.append(str(df2["date"].iloc[t].date()))
rvs.append(float(df2["realized_vol"].iloc[t])) rvs.append(float(df2["realized_vol"].iloc[t + 1]))
if not Xs: if not Xs:
return [], [], [] return [], [], []
Xa = np.stack(Xs)
chunks = []
with torch.no_grad(): with torch.no_grad():
E = enc(torch.tensor(np.stack(Xs), device=dev))[:, -1, :].cpu().numpy().tolist() for i in range(0, len(Xa), bs):
chunks.append(enc(torch.tensor(Xa[i:i+bs], device=dev))[:, -1, :].cpu().numpy())
E = np.concatenate(chunks, axis=0).tolist()
return E, dates, rvs return E, dates, rvs
Etr2, dates_tr, rv_tr = _export_windows(tr_mask) Etr2, dates_tr, rv_tr = _export_windows(tr_mask)
Eoos, dates_oos, rv_oos = _export_windows(df2["date"].dt.year >= 2022) Eoos, dates_oos, rv_oos = _export_windows(df2["date"].dt.year >= 2022)