generated from mathias/template-go-web
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed85dc4a8c | ||
|
|
69784f59cf | ||
|
|
485fdaa9f9 | ||
|
|
df910e4336 | ||
|
|
e616575979 |
+97
-6
@@ -1,13 +1,104 @@
|
|||||||
# hostexecutor
|
# jepa-fx-risk
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
|
|
||||||
- **Name**: hostexecutor
|
- **Name**: jepa-fx-risk
|
||||||
- **Owner**: Mathias
|
- **Owner**: Mathias
|
||||||
- **Client**: personal
|
- **Client**: personal research
|
||||||
- **Repo**: gitea.d-ma.be/mathias/hostexecutor
|
- **Repo**: gitea.d-ma.be/mathias/jepa-fx-risk
|
||||||
- **Status**: active
|
- **Status**: active — Phase 0
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Research project: apply JEPA-based self-supervised representation learning to
|
||||||
|
FX risk management for a corporate bank with an internal global FX trading desk.
|
||||||
|
|
||||||
|
Primary tasks: FX volatility forecasting and VaR/CVaR estimation.
|
||||||
|
Target output: internal PoC for the trading desk.
|
||||||
|
|
||||||
|
## Architecture decision (see DECISIONS.md ADR-001)
|
||||||
|
|
||||||
|
**TS-JEPA + SIGReg** — TS-JEPA temporal patchwise architecture (Ennadir et al.,
|
||||||
|
arXiv:2509.25449) with EMA replaced by Sketched Isotropic Gaussian Regularization
|
||||||
|
(SIGReg, Balestriero & LeCun, arXiv:2511.08544). Single search axis: λ ∈ [0.01, 1.0].
|
||||||
|
|
||||||
|
Phase 2 hypothesis: MTS-JEPA multi-resolution objective (arXiv:2602.04643).
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
Go + Templ + HTMX + CDN Tailwind. See `~/dev/.context/AGENT.md` for cross-project conventions.
|
**Go** (`src/`): data pipeline (DUKASCopy fetch + hourly processing), evaluation
|
||||||
|
harness (silhouette, linear probe R², collapse diagnostic, Kupiec/Christoffersen),
|
||||||
|
results dashboard (Templ + HTMX + CDN Tailwind).
|
||||||
|
|
||||||
|
**Python** (`model/`): all training and embedding export only. PyTorch cu130
|
||||||
|
(Blackwell sm_120 compatible). No evaluation logic in Python.
|
||||||
|
|
||||||
|
**Infra**: koala (Arch Linux, Blackwell GPU 12 GB VRAM) for training.
|
||||||
|
iguana (Mac Studio M2 Ultra) + LiteLLM on piguard for autoresearch agent LLM.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
jepa-fx-risk/
|
||||||
|
├── src/ # Go — data pipeline + eval harness + dashboard
|
||||||
|
│ ├── data/ # DUKASCopy fetch, hourly processing, validation
|
||||||
|
│ └── eval/ # silhouette, linear probe, collapse, backtest
|
||||||
|
├── model/ # Python — training only
|
||||||
|
│ ├── train.py # TS-JEPA + SIGReg backbone (autoresearch edits this)
|
||||||
|
│ ├── prepare.py # LOCKED — data loading, tokenization, export
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── specs/ # Research specs (one per phase/experiment type)
|
||||||
|
├── experiments/ # Per-run outputs: embeddings, metrics.json, git tag
|
||||||
|
├── results/summaries/ # Human-readable outcome per experiment
|
||||||
|
├── program.md # Autoresearch agenda — researcher edits this
|
||||||
|
├── DECISIONS.md # Architecture Decision Records
|
||||||
|
└── Taskfile.yml # task data:fetch, task experiment:run, task eval:*
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase structure
|
||||||
|
|
||||||
|
- **Phase 0** (current): MAE baseline on EUR/USD hourly 2008–2022.
|
||||||
|
Gate: silhouette > 0.20, MAE > PCA, ±10% over 3 reruns.
|
||||||
|
- **Phase 1**: TS-JEPA + SIGReg autoresearch sweep, 50 experiments.
|
||||||
|
Gate: val_vol_r2 > GARCH baseline AND Kupiec p > 0.05.
|
||||||
|
- **Phase 2**: MTS-JEPA multi-resolution hypothesis.
|
||||||
|
- **Phase 3**: Internal bank tick/position data (future, out of current scope).
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
DUKASCopy hourly OHLCV, 10 G10 pairs, 2008–2022 train / 2023 val / 2024 test.
|
||||||
|
Features: log-return, log rolling-20-period HV, VIX (daily interpolated).
|
||||||
|
Weekend gaps handled explicitly. See ADR-002.
|
||||||
|
|
||||||
|
## Key conventions
|
||||||
|
|
||||||
|
- `prepare.py` is LOCKED — never modified by agents or autoresearch
|
||||||
|
- Evaluation metrics are always computed by the Go harness, never in Python
|
||||||
|
- Every experiment gets a git tag: `exp/YYYYMMDD-description`
|
||||||
|
- Null results are recorded explicitly in `results/summaries/` — do not iterate silently
|
||||||
|
- `program.md` is the only file the researcher edits to steer autoresearch
|
||||||
|
- CI runs `task check` (lint + vet + test) only — no GPU, no training
|
||||||
|
|
||||||
|
## Evaluation metrics
|
||||||
|
|
||||||
|
Primary (autoresearch optimizes): `val_vol_r2` — linear probe R² on 1-day
|
||||||
|
realized volatility from frozen embeddings, computed by Go harness.
|
||||||
|
|
||||||
|
Secondary (logged, not optimized): Kupiec p-value (VaR 99% backtest on EUR/USD).
|
||||||
|
Must co-move with val_vol_r2 — checked from experiment 1.
|
||||||
|
|
||||||
|
Diagnostics: silhouette score (regime clustering), PC1/HV correlation (collapse check).
|
||||||
|
|
||||||
|
## Benchmarks to beat (trading desk comparison)
|
||||||
|
|
||||||
|
- GARCH(1,1) — volatility forecasting baseline
|
||||||
|
- Historical Simulation VaR (250-day rolling) — Basel default
|
||||||
|
- EWMA RiskMetrics (λ=0.94)
|
||||||
|
|
||||||
|
## Agent guidance
|
||||||
|
|
||||||
|
Read `DECISIONS.md` before making architecture suggestions.
|
||||||
|
Do not modify `prepare.py` or `src/eval/`.
|
||||||
|
Do not suggest changing the Python/Go separation.
|
||||||
|
Training runs are always manual via `task experiment:run` — never triggered by CI.
|
||||||
|
When implementing, follow Go conventions in `.skills/go-patterns/SKILL.md`.
|
||||||
|
|||||||
@@ -28,3 +28,9 @@ go.work.sum
|
|||||||
# Project-specific
|
# Project-specific
|
||||||
bin/
|
bin/
|
||||||
*.templ.go
|
*.templ.go
|
||||||
|
|
||||||
|
# python venv (autoresearch loop)
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# downloaded + processed market data (track via DVC/MinIO, #10 — not git)
|
||||||
|
data/
|
||||||
|
|||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
# Architecture Decision Records
|
||||||
|
|
||||||
|
This file records significant technical and research decisions for `jepa-fx-risk`.
|
||||||
|
Each record is immutable once merged — append new records rather than editing old ones.
|
||||||
|
Format: ID · Date · Status · Context · Decision · Rationale · Consequences.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-001 · Architecture: TS-JEPA + SIGReg as Phase 1 backbone
|
||||||
|
|
||||||
|
**Date:** 2026-05-28
|
||||||
|
**Status:** Accepted
|
||||||
|
**Supersedes:** informal decision to use TS-JEPA standalone (pre-ADR)
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Four JEPA variants were evaluated for FX volatility forecasting and VaR/CVaR estimation:
|
||||||
|
|
||||||
|
| Variant | Origin | Key property |
|
||||||
|
|---|---|---|
|
||||||
|
| TS-JEPA | Ennadir et al., Sep 2025 | Time-series native; EMA collapse prevention |
|
||||||
|
| LeJEPA | Balestriero & LeCun, Nov 2025 | Proven optimal embeddings (isotropic Gaussian); SIGReg |
|
||||||
|
| MTS-JEPA | He et al., Feb 2026 | Multi-resolution + codebook; no public code |
|
||||||
|
| Var-JEPA | Multiple, Mar 2026 | ELBO-based UQ; no public code |
|
||||||
|
|
||||||
|
Key constraints: 12 GB VRAM (Blackwell, koala), hourly DUKASCopy data, internal PoC target,
|
||||||
|
autoresearch loop requires a clean single-scalar search space, trading desk requires an
|
||||||
|
explainable theoretical story.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Use **TS-JEPA architecture with SIGReg replacing EMA** as the Phase 1 backbone.
|
||||||
|
|
||||||
|
Concretely:
|
||||||
|
- Start from the TS-JEPA open-source implementation (arXiv:2509.25449, GitHub)
|
||||||
|
- Remove the EMA target-network mechanism
|
||||||
|
- Replace it with Sketched Isotropic Gaussian Regularization (SIGReg) from LeJEPA
|
||||||
|
(arXiv:2511.08544), controlled by a single λ hyperparameter
|
||||||
|
- Keep TS-JEPA's temporal patchwise masking and Transformer encoder unchanged
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
**Why not pure TS-JEPA:** EMA is a heuristic; λ interacts with EMA momentum and
|
||||||
|
learning rate, creating a three-way search space that is hard to navigate with autoresearch.
|
||||||
|
EMA also has no theoretical non-stationarity guarantee.
|
||||||
|
|
||||||
|
**Why not pure LeJEPA:** The reference implementation targets vision (multi-crop views).
|
||||||
|
Adapting it to temporal patchwise masking requires non-trivial surgery and moves away from
|
||||||
|
open code. TS-JEPA's masking is already the right inductive bias for time series.
|
||||||
|
|
||||||
|
**Why the hybrid:** SIGReg is architecture-agnostic — it operates on the embedding
|
||||||
|
distribution, not the encoder structure. Swapping EMA for SIGReg is a ~20-line change to
|
||||||
|
TS-JEPA's training loop. The result is:
|
||||||
|
- Time-series native (TS-JEPA masking + patch structure)
|
||||||
|
- Provably collapse-free without heuristics (SIGReg)
|
||||||
|
- Single search axis for autoresearch (λ ∈ [0.01, 1.0])
|
||||||
|
- Non-stationarity robustness proven formally (arXiv:2602.19373 extends LeJEPA
|
||||||
|
guarantees to non-stationary target distributions — directly relevant to FX)
|
||||||
|
- Explainable to a model validation team: "embeddings are provably optimal for
|
||||||
|
downstream prediction under distributional uncertainty"
|
||||||
|
|
||||||
|
**Why not MTS-JEPA or Var-JEPA now:** Both lack public code (as of May 2026).
|
||||||
|
MTS-JEPA's multi-resolution objective is the right next hypothesis (see ADR-003).
|
||||||
|
Var-JEPA's ELBO-based UQ is a compelling future direction for CVaR estimation.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Phase 0 (MAE baseline) is unaffected — it precedes the JEPA architecture choice
|
||||||
|
- Issue #3 (TS-JEPA reproduction) is still the right first step; SIGReg is added after
|
||||||
|
reproduction is confirmed
|
||||||
|
- The autoresearch `program.md` primary search axis is λ (SIGReg weight)
|
||||||
|
- Secondary axes: masking block size, patch stride, encoder depth
|
||||||
|
- `model/requirements.txt` must include the SIGReg implementation (≈20 lines,
|
||||||
|
can be vendored directly)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-002 · Data: DUKASCopy hourly G10 FX as primary training data
|
||||||
|
|
||||||
|
**Date:** 2026-05-28
|
||||||
|
**Status:** Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Data scale is the most dangerous assumption for any SSL/JEPA approach. Daily FX data
|
||||||
|
(~5,000 samples over 20 years) is insufficient for self-supervised pretraining.
|
||||||
|
Two alternatives were considered: daily public data (yfinance) vs. hourly tick data
|
||||||
|
(DUKASCopy, free, rate-limited HTTP API).
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Use **DUKASCopy hourly OHLCV** as the primary data source.
|
||||||
|
|
||||||
|
- 10 G10 pairs: EURUSD, GBPUSD, USDJPY, USDCHF, AUDUSD, NZDUSD, USDCAD,
|
||||||
|
EURGBP, EURJPY, GBPJPY
|
||||||
|
- Training window: 2008-01-01 – 2022-12-31 (~175,000 samples per pair)
|
||||||
|
- Validation window: 2023-01-01 – 2023-12-31 (~2,600 samples)
|
||||||
|
- Test window: 2024-01-01 – 2024-12-31 (held out, never seen during development)
|
||||||
|
- Features per bar: log-return, log rolling-20-period HV, VIX (daily interpolated)
|
||||||
|
- Weekend gaps handled explicitly — no interpolation across market close
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
Hourly data gives ~35× more samples than daily. This is the minimum threshold for
|
||||||
|
JEPA-style SSL to show a training signal within 10-minute autoresearch experiments.
|
||||||
|
DUKASCopy is free, reliable, and provides consistent tick-level source data back to 2003.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- The Go data pipeline (Issue #2) is the critical path for everything else
|
||||||
|
- Phase 0 MAE baseline trains on the same 2008-2022 window
|
||||||
|
- Daily data (yfinance) may still be used for VIX and rate differentials as auxiliary features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-003 · Research roadmap: Phase structure and JEPA variant progression
|
||||||
|
|
||||||
|
**Date:** 2026-05-28
|
||||||
|
**Status:** Accepted
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Three-phase research roadmap:
|
||||||
|
|
||||||
|
**Phase 0 — SSL feasibility gate (MAE baseline)**
|
||||||
|
Implement a 1D temporal MAE (not JEPA) on EUR/USD hourly 2008-2022.
|
||||||
|
Gate criteria: silhouette > 0.20 on 2023 held-out, MAE > PCA baseline, ±10% over 3 reruns.
|
||||||
|
Purpose: validate that the data and eval harness work before committing to JEPA complexity.
|
||||||
|
If gate fails: follow null result protocol in `specs/phase-0-ssl-feasibility.md`.
|
||||||
|
|
||||||
|
**Phase 1 — TS-JEPA + SIGReg autoresearch sweep**
|
||||||
|
Primary architecture per ADR-001.
|
||||||
|
Autoresearch loop: `program.md`-driven, 10-min experiments, 50-experiment budget.
|
||||||
|
Primary metric: `val_vol_r2` (linear probe R² on 1-day realized volatility).
|
||||||
|
Gate criteria: `val_vol_r2` > GARCH-implied baseline AND Kupiec p-value > 0.05 on
|
||||||
|
EUR/USD VaR 99%.
|
||||||
|
Kupiec is logged from experiment 1 to verify it co-moves with `val_vol_r2`.
|
||||||
|
|
||||||
|
**Phase 2 — MTS-JEPA multi-resolution hypothesis**
|
||||||
|
Introduce parallel multi-scale predictive pathways (1h, 8h, 24h context windows)
|
||||||
|
adapted from MTS-JEPA (arXiv:2602.04643).
|
||||||
|
Hypothesis: multi-scale representations improve regime detection (silhouette) and
|
||||||
|
reduce VaR exceedance clustering (Christoffersen test).
|
||||||
|
Prerequisite: Phase 1 gate passed AND MTS-JEPA code available or reproducible from paper.
|
||||||
|
Time-box: if MTS-JEPA code not available within 4 weeks of Phase 2 start, implement
|
||||||
|
multi-resolution masking from scratch using Phase 1 backbone as base.
|
||||||
|
|
||||||
|
**Phase 3 — Internal bank data (future)**
|
||||||
|
Replace DUKASCopy pipeline with internal tick feed adapter.
|
||||||
|
Fine-tune heads only; backbone frozen or lightly fine-tuned.
|
||||||
|
Out of scope for current PoC cycle.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Issue #5 (Phase 0 MAE) is the unblocked next executable step
|
||||||
|
- Phase 1 autoresearch is blocked until Phase 0 passes its gate
|
||||||
|
- Var-JEPA (ELBO-based UQ) is a named future hypothesis for CVaR estimation in Phase 2+
|
||||||
|
but not on the critical path
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-004 · Evaluation: Go harness + Python training separation
|
||||||
|
|
||||||
|
**Date:** 2026-05-28
|
||||||
|
**Status:** Accepted
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Hard separation between training (Python) and evaluation (Go):
|
||||||
|
|
||||||
|
- **Python** (`model/`): all training, embedding export, model checkpointing
|
||||||
|
- **Go** (`src/eval/`): all evaluation metrics — silhouette, linear probe R², collapse
|
||||||
|
diagnostic, Kupiec/Christoffersen backtests
|
||||||
|
- Interface: Python exports embedding matrices + labels to `experiments/RUNID/` as
|
||||||
|
`.npy` files; Go eval harness reads them and writes `metrics.json`
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
Go evaluation gives deterministic, fast, auditable metric computation with proper
|
||||||
|
unit tests. It decouples the experimental loop from the training framework, making
|
||||||
|
it possible to re-evaluate any past experiment without re-running training.
|
||||||
|
The Go layer also serves as the foundation for the eventual trading desk dashboard.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- All acceptance criteria in Issues #4 and #5 are specified in terms of Go eval outputs
|
||||||
|
- `val_vol_r2` (the autoresearch optimization metric) is computed by the Go harness,
|
||||||
|
not inside the Python training loop
|
||||||
|
- Python training loop calls `task eval:probe` as a subprocess after each experiment
|
||||||
|
to get the scalar fed back to autoresearch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-005 · Compute: Blackwell GPU on koala, PyTorch cu130
|
||||||
|
|
||||||
|
**Date:** 2026-05-28
|
||||||
|
**Status:** Accepted
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
All GPU training runs on koala (Arch Linux, Blackwell GPU, 12 GB VRAM).
|
||||||
|
PyTorch install: `pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu130`
|
||||||
|
(CUDA 13.0 wheel — required for sm_120 Blackwell support; stable as of May 2026).
|
||||||
|
Driver requirement: NVIDIA R570+, CUDA toolkit 12.8+.
|
||||||
|
|
||||||
|
Ollama on iguana (Mac Studio M2 Ultra) serves the autoresearch agent LLM via the
|
||||||
|
existing LiteLLM proxy on piguard. Agent calls never hit koala directly.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- `model/requirements.txt` must NOT pin torch to a cu124 or earlier wheel
|
||||||
|
- CI (Issue #7) must NOT run GPU tests — CPU-only for unit tests, GPU only via
|
||||||
|
`task experiment:run` on koala
|
||||||
|
- 12 GB VRAM is sufficient for <5M parameter models at batch=64; monitor if
|
||||||
|
autoresearch explores larger architectures
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Autoresearch STATUS
|
||||||
|
|
||||||
|
| iter | val_vol_r2 | delta | action | secs | gpu | change |
|
||||||
|
|------|-----------|-------|--------|------|-----|--------|
|
||||||
|
| 1 | 0.3749 | +0.0928 | KEEP | 2s | gpu=0% vram=10054/12227MiB temp=34°C | iter1 |
|
||||||
|
| 1 | 0.3011 | +0.0776 | KEEP | 2s | gpu=0% vram=10054/12227MiB temp=34°C | iter1 |
|
||||||
|
| 2 | 0.3032 | +0.0021 | KEEP | 2s | gpu=0% vram=10054/12227MiB temp=35°C | iter2 |
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""loop.py — Karpathy-style autoresearch loop for jepa-fx-risk.
|
||||||
|
|
||||||
|
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,
|
||||||
|
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]
|
||||||
|
|
||||||
|
Env:
|
||||||
|
LITELLM_KEY — LiteLLM master key (required)
|
||||||
|
LITELLM_BASE — default http://localhost:30401/v1
|
||||||
|
LOOP_MODEL — default berget/gemma4-31b (non-thinking; iguana/berget only)
|
||||||
|
LOOP_ITERS — default 3
|
||||||
|
TRAIN_TIMEOUT — seconds per train.py run, default 120
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
LITELLM_BASE = os.environ.get("LITELLM_BASE", "http://localhost:30401/v1")
|
||||||
|
LITELLM_KEY = os.environ.get("LITELLM_KEY", "")
|
||||||
|
LOOP_MODEL = os.environ.get("LOOP_MODEL", "berget/gemma4-31b")
|
||||||
|
LOOP_ITERS = int(os.environ.get("LOOP_ITERS", "3"))
|
||||||
|
TRAIN_TIMEOUT = int(os.environ.get("TRAIN_TIMEOUT", "120"))
|
||||||
|
STATUS_MD = Path("STATUS.md")
|
||||||
|
METRICS_JSON = Path("metrics.json")
|
||||||
|
TRAIN_PY = Path("train.py")
|
||||||
|
|
||||||
|
AGENT_SYSTEM = textwrap.dedent("""\
|
||||||
|
You are the autoresearch agent for jepa-fx-risk. Your job: propose ONE small,
|
||||||
|
targeted change to train.py to improve val_vol_r2 (OOS R² predicting 1-day
|
||||||
|
realized vol from frozen embeddings). Higher is better.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Return ONLY the full new content of train.py — nothing else, no explanation,
|
||||||
|
no markdown fence. Raw Python only.
|
||||||
|
- Change ONE thing at a time (one knob, one structural idea).
|
||||||
|
- Do NOT touch prepare_data.py, loop.py, or the data pipeline — only train.py.
|
||||||
|
- Do NOT add new data sources or new files.
|
||||||
|
- The metric is computed externally from your frozen embeddings; trust it.
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def read_file(p: Path) -> str:
|
||||||
|
return p.read_text() if p.exists() else ""
|
||||||
|
|
||||||
|
|
||||||
|
def gpu_snapshot() -> str:
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
["nvidia-smi", "--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu",
|
||||||
|
"--format=csv,noheader,nounits"], timeout=5, text=True
|
||||||
|
).strip()
|
||||||
|
util, mem_used, mem_total, temp = [x.strip() for x in out.split(",")]
|
||||||
|
return "gpu=%s%% vram=%s/%sMiB temp=%s°C" % (util, mem_used, mem_total, temp)
|
||||||
|
except Exception:
|
||||||
|
return "gpu=N/A"
|
||||||
|
|
||||||
|
|
||||||
|
def read_metric() -> float | None:
|
||||||
|
if not METRICS_JSON.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(json.loads(METRICS_JSON.read_text())["val_vol_r2"])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_train() -> tuple[float | None, float, str]:
|
||||||
|
"""Run train.py. Returns (val_vol_r2 or None, wall_secs, stderr_tail)."""
|
||||||
|
t0 = time.time()
|
||||||
|
gpu_before = gpu_snapshot()
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, "train.py"],
|
||||||
|
capture_output=True, text=True, timeout=TRAIN_TIMEOUT,
|
||||||
|
)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
if r.returncode != 0:
|
||||||
|
return None, elapsed, (r.stderr or r.stdout)[-300:]
|
||||||
|
metric = read_metric()
|
||||||
|
return metric, elapsed, ""
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return None, TRAIN_TIMEOUT, "TIMEOUT"
|
||||||
|
|
||||||
|
|
||||||
|
def call_agent(iteration: int, best_so_far: float | None) -> str:
|
||||||
|
"""Ask the LLM agent to edit train.py. Returns new train.py content."""
|
||||||
|
context = "\n\n".join([
|
||||||
|
"# program.md\n" + read_file(Path("program.md")),
|
||||||
|
"# train.py (current)\n" + read_file(TRAIN_PY),
|
||||||
|
"# STATUS.md (history)\n" + read_file(STATUS_MD)[-2000:],
|
||||||
|
"# metrics.json (last run)\n" + read_file(METRICS_JSON),
|
||||||
|
"Iteration %d. Best val_vol_r2 so far: %s. Improve it." % (
|
||||||
|
iteration, "%.4f" % best_so_far if best_so_far is not None else "none yet"
|
||||||
|
),
|
||||||
|
])
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": LOOP_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": AGENT_SYSTEM},
|
||||||
|
{"role": "user", "content": context},
|
||||||
|
],
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
LITELLM_BASE + "/chat/completions",
|
||||||
|
data=payload,
|
||||||
|
headers={"Authorization": "Bearer " + LITELLM_KEY,
|
||||||
|
"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
resp = urllib.request.urlopen(req, timeout=60)
|
||||||
|
data = json.load(resp)
|
||||||
|
return data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def revert_train(original_content: str):
|
||||||
|
TRAIN_PY.write_text(original_content)
|
||||||
|
|
||||||
|
|
||||||
|
def append_status(line: str):
|
||||||
|
with open(STATUS_MD, "a") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not LITELLM_KEY:
|
||||||
|
print("ERROR: set LITELLM_KEY"); sys.exit(1)
|
||||||
|
|
||||||
|
if not STATUS_MD.exists():
|
||||||
|
STATUS_MD.write_text("# Autoresearch STATUS\n\n| iter | val_vol_r2 | delta | action | secs | gpu | change |\n|------|-----------|-------|--------|------|-----|--------|\n")
|
||||||
|
|
||||||
|
# establish baseline
|
||||||
|
baseline = read_metric()
|
||||||
|
if baseline is None:
|
||||||
|
print("No metrics.json — running train.py for baseline...")
|
||||||
|
m, secs, err = run_train()
|
||||||
|
if m is None:
|
||||||
|
print("Baseline run failed:", err); sys.exit(1)
|
||||||
|
baseline = m
|
||||||
|
print("Baseline: val_vol_r2 = %.4f (%.1fs)" % (baseline, secs))
|
||||||
|
|
||||||
|
best = baseline
|
||||||
|
print("Starting loop | model=%s | iters=%d | baseline=%.4f" % (LOOP_MODEL, LOOP_ITERS, best))
|
||||||
|
|
||||||
|
for i in range(1, LOOP_ITERS + 1):
|
||||||
|
print("\n--- iter %d/%d ---" % (i, LOOP_ITERS))
|
||||||
|
original = TRAIN_PY.read_text()
|
||||||
|
|
||||||
|
print(" calling agent (%s)..." % LOOP_MODEL)
|
||||||
|
t_agent = time.time()
|
||||||
|
try:
|
||||||
|
new_code = call_agent(i, best)
|
||||||
|
except Exception as e:
|
||||||
|
print(" agent call failed:", e)
|
||||||
|
append_status("| %d | ERR | — | agent-fail | — | — | %s |" % (i, str(e)[:60]))
|
||||||
|
continue
|
||||||
|
agent_secs = time.time() - t_agent
|
||||||
|
print(" agent replied in %.1fs" % agent_secs)
|
||||||
|
|
||||||
|
# strip accidental markdown fences
|
||||||
|
if new_code.strip().startswith("```"):
|
||||||
|
lines = new_code.strip().splitlines()
|
||||||
|
new_code = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
|
||||||
|
|
||||||
|
TRAIN_PY.write_text(new_code)
|
||||||
|
|
||||||
|
gpu = gpu_snapshot()
|
||||||
|
print(" running train.py [%s]..." % gpu)
|
||||||
|
metric, secs, err = run_train()
|
||||||
|
|
||||||
|
if metric is None:
|
||||||
|
print(" train.py FAILED — reverting. err:", err[:100])
|
||||||
|
revert_train(original)
|
||||||
|
append_status("| %d | FAIL | — | revert | %.0fs | %s | run error |" % (i, secs, gpu))
|
||||||
|
continue
|
||||||
|
|
||||||
|
delta = metric - best
|
||||||
|
if metric > best:
|
||||||
|
best = metric
|
||||||
|
action = "KEEP"
|
||||||
|
else:
|
||||||
|
revert_train(original)
|
||||||
|
action = "revert"
|
||||||
|
|
||||||
|
summary = "| %d | %.4f | %+.4f | %s | %.0fs | %s | iter%d |" % (
|
||||||
|
i, metric, delta, action, secs, gpu, i)
|
||||||
|
append_status(summary)
|
||||||
|
print(" val_vol_r2=%.4f delta=%+.4f action=%s [%.0fs]" % (metric, delta, action, secs))
|
||||||
|
|
||||||
|
print("\nDone. Best val_vol_r2 = %.4f (baseline was %.4f, delta %+.4f)" % (best, baseline, best - baseline))
|
||||||
|
print("STATUS.md updated.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"val_vol_r2": 0.30321519081159654,
|
||||||
|
"n_test": 275,
|
||||||
|
"knobs": {
|
||||||
|
"WINDOW": 20,
|
||||||
|
"EMBED_DIM": 64,
|
||||||
|
"MASK_FRAC": 0.4,
|
||||||
|
"EPOCHS": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Python deps for the autoresearch loop (train.py + scripts). Install torch from
|
||||||
|
# the cu130 index FIRST (koala Blackwell sm_120, torch 2.12.1+cu130 verified):
|
||||||
|
# pip install torch --index-url https://download.pytorch.org/whl/cu130
|
||||||
|
# pip install -r requirements.txt
|
||||||
|
numpy>=2.0
|
||||||
|
pandas>=2.2
|
||||||
|
pyarrow>=16
|
||||||
|
histdata>=1.3 # histdata.com downloader (handles the tk token politely)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Phase-0 compute gate (brain wiki/jepa-fx/facts/autoresearch-integration-phase1):
|
||||||
|
PyTorch cu130 must see the koala Blackwell GPU and compute before any experiment.
|
||||||
|
|
||||||
|
python scripts/check_gpu.py # exits 0 if the GPU is usable, 1 otherwise
|
||||||
|
|
||||||
|
Note: koala shares this 12GB card with the llama-swap LLM stack. The autoresearch
|
||||||
|
agent should run on iguana/berget models so koala's GPU stays free for train.py.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import torch
|
||||||
|
|
||||||
|
print("torch", torch.__version__)
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
print("CUDA NOT AVAILABLE — gate BLOCKED")
|
||||||
|
sys.exit(1)
|
||||||
|
print("device:", torch.cuda.get_device_name(0))
|
||||||
|
print("capability: sm_%d%d" % torch.cuda.get_device_capability(0))
|
||||||
|
x = torch.randn(2000, 2000, device="cuda")
|
||||||
|
(x @ x).sum().item()
|
||||||
|
torch.cuda.synchronize()
|
||||||
|
print("GPU matmul OK — Phase-0 compute gate GREEN")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Fetch EUR/USD M1 bars from histdata.com (free, research use).
|
||||||
|
|
||||||
|
Polite: one request per year, spaced; past years query month=None. Uses the
|
||||||
|
maintained `histdata` package which handles histdata's anti-hotlink tk token.
|
||||||
|
Output: data/raw/DAT_ASCII_EURUSD_M1_<year>.zip
|
||||||
|
|
||||||
|
YEARS=2019,2020,2021 python scripts/fetch_data.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from histdata import download_hist_data
|
||||||
|
from histdata.api import Platform as P, TimeFrame as T
|
||||||
|
|
||||||
|
YEARS = [y.strip() for y in os.environ.get("YEARS", "2019,2020,2021").split(",")]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs("data/raw", exist_ok=True)
|
||||||
|
for yr in YEARS:
|
||||||
|
f = download_hist_data(
|
||||||
|
year=yr, month=None, pair="eurusd",
|
||||||
|
platform=P.GENERIC_ASCII, time_frame=T.ONE_MINUTE,
|
||||||
|
output_directory="data/raw",
|
||||||
|
)
|
||||||
|
print("fetched", yr, "->", f)
|
||||||
|
time.sleep(2) # be a good citizen
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""LOCKED data pipeline (toy) — agent must NOT edit (brain Phase-1 contract).
|
||||||
|
|
||||||
|
Parses histdata EUR/USD M1 zips → daily series with realized volatility (the
|
||||||
|
val_vol_r2 target = 1-day realized vol from intraday squared returns).
|
||||||
|
Output: data/processed/eurusd_daily.parquet [date, close, ret, realized_vol].
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
RAW = "data/raw"
|
||||||
|
OUT = "data/processed/eurusd_daily.parquet"
|
||||||
|
|
||||||
|
|
||||||
|
def load_m1() -> pd.DataFrame:
|
||||||
|
frames = []
|
||||||
|
for zp in sorted(glob.glob(os.path.join(RAW, "DAT_ASCII_EURUSD_M1_*.zip"))):
|
||||||
|
with zipfile.ZipFile(zp) as z:
|
||||||
|
csv = [n for n in z.namelist() if n.endswith(".csv")][0]
|
||||||
|
with z.open(csv) as f:
|
||||||
|
df = pd.read_csv(
|
||||||
|
f, sep=";", header=None,
|
||||||
|
names=["dt", "open", "high", "low", "close", "vol"],
|
||||||
|
)
|
||||||
|
df["ts"] = pd.to_datetime(df["dt"], format="%Y%m%d %H%M%S")
|
||||||
|
frames.append(df[["ts", "close"]])
|
||||||
|
out = pd.concat(frames).sort_values("ts").reset_index(drop=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
m1 = load_m1()
|
||||||
|
m1["r"] = np.log(m1["close"]).diff()
|
||||||
|
m1["day"] = m1["ts"].dt.normalize()
|
||||||
|
daily = m1.groupby("day").agg(
|
||||||
|
close=("close", "last"),
|
||||||
|
realized_vol=("r", lambda x: np.sqrt(np.nansum(x.values ** 2))),
|
||||||
|
n_min=("r", "count"),
|
||||||
|
).reset_index()
|
||||||
|
daily = daily[daily["n_min"] > 60] # drop thin days (holidays)
|
||||||
|
daily["ret"] = np.log(daily["close"]).diff()
|
||||||
|
daily = daily.dropna().reset_index(drop=True)
|
||||||
|
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||||
|
daily[["day", "close", "ret", "realized_vol"]].rename(columns={"day": "date"}).to_parquet(OUT)
|
||||||
|
print("rows:", len(daily), "| dates:", daily["day"].min().date(), "→", daily["day"].max().date())
|
||||||
|
# sanity: the COVID crash (March 2020) must show a realized-vol spike
|
||||||
|
rv = daily.set_index("day")["realized_vol"]
|
||||||
|
mar20 = rv["2020-03-01":"2020-03-31"].max()
|
||||||
|
typ = rv["2019-01-01":"2019-12-31"].median()
|
||||||
|
print("median 2019 RV: %.5f | max Mar-2020 RV: %.5f | spike x%.1f" % (typ, mar20, mar20 / typ))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""train.py — the ONLY file the autoresearch agent may edit (Phase-1 contract).
|
||||||
|
|
||||||
|
Toy slice: a tiny self-supervised encoder (masked reconstruction of windowed
|
||||||
|
daily [return, realized_vol]) → FROZEN → linear probe predicts NEXT-day realized
|
||||||
|
vol → val_vol_r2 = OOS R². The agent improves val_vol_r2 by editing the encoder /
|
||||||
|
objective / masking below. Writes metrics.json (the scalar the loop reads).
|
||||||
|
|
||||||
|
python train.py
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
|
||||||
|
# --- agent-tunable knobs ---
|
||||||
|
WINDOW = 20
|
||||||
|
EMBED_DIM = 64
|
||||||
|
MASK_FRAC = 0.40
|
||||||
|
EPOCHS = 200
|
||||||
|
LR = 1e-3
|
||||||
|
SEED = 0
|
||||||
|
# ---------------------------
|
||||||
|
|
||||||
|
torch.manual_seed(SEED)
|
||||||
|
np.random.seed(SEED)
|
||||||
|
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
df = pd.read_parquet("data/processed/eurusd_daily.parquet").reset_index(drop=True)
|
||||||
|
feats = df[["ret", "realized_vol"]].to_numpy(np.float32)
|
||||||
|
target = df["realized_vol"].to_numpy(np.float32) # predict NEXT-day RV
|
||||||
|
X, y = [], []
|
||||||
|
for t in range(WINDOW, len(df) - 1):
|
||||||
|
X.append(feats[t - WINDOW:t])
|
||||||
|
y.append(target[t + 1])
|
||||||
|
X = np.stack(X); y = np.array(y, np.float32)
|
||||||
|
n_tr = int(0.7 * len(X)) # time-ordered OOS split
|
||||||
|
mu, sd = X[:n_tr].mean((0, 1)), X[:n_tr].std((0, 1)) + 1e-8 # train-only stats
|
||||||
|
X = (X - mu) / sd
|
||||||
|
return (X[:n_tr], y[:n_tr]), (X[n_tr:], y[n_tr:])
|
||||||
|
|
||||||
|
|
||||||
|
class Encoder(nn.Module):
|
||||||
|
def __init__(self, win, emb):
|
||||||
|
super().__init__()
|
||||||
|
self.net = nn.Sequential(
|
||||||
|
nn.Flatten(),
|
||||||
|
nn.Linear(win * 2, 128),
|
||||||
|
nn.LayerNorm(128),
|
||||||
|
nn.GELU(),
|
||||||
|
nn.Linear(128, emb)
|
||||||
|
)
|
||||||
|
|
||||||
|
def forward(self, x):
|
||||||
|
return self.net(x)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
(Xtr, ytr), (Xte, yte) = build()
|
||||||
|
Xtr_t = torch.tensor(Xtr, device=dev)
|
||||||
|
enc = Encoder(WINDOW, EMBED_DIM).to(dev)
|
||||||
|
dec = nn.Sequential(nn.Linear(EMBED_DIM, 128), nn.GELU(), nn.Linear(128, WINDOW * 2)).to(dev)
|
||||||
|
opt = torch.optim.Adam(list(enc.parameters()) + list(dec.parameters()), lr=LR)
|
||||||
|
|
||||||
|
for _ in range(EPOCHS): # SSL: masked reconstruction of the window
|
||||||
|
mask = (torch.rand_like(Xtr_t) > MASK_FRAC).float()
|
||||||
|
rec = dec(enc((Xtr_t * mask)))
|
||||||
|
loss = (((rec - Xtr_t.flatten(1)) ** 2) * (1 - mask.flatten(1))).mean()
|
||||||
|
opt.zero_grad(); loss.backward(); opt.step()
|
||||||
|
|
||||||
|
enc.eval()
|
||||||
|
with torch.no_grad(): # FROZEN embeddings
|
||||||
|
Etr = enc(Xtr_t).cpu().numpy()
|
||||||
|
Ete = enc(torch.tensor(Xte, device=dev)).cpu().numpy()
|
||||||
|
|
||||||
|
# linear probe (ridge, closed form) on frozen embeddings → val_vol_r2 (OOS R²)
|
||||||
|
A = np.hstack([Etr, np.ones((len(Etr), 1))])
|
||||||
|
w = np.linalg.solve(A.T @ A + 1e-3 * np.eye(A.shape[1]), A.T @ ytr)
|
||||||
|
pred = np.hstack([Ete, np.ones((len(Ete), 1))]) @ w
|
||||||
|
ss_res = ((yte - pred) ** 2).sum()
|
||||||
|
ss_tot = ((yte - yte.mean()) ** 2).sum()
|
||||||
|
val_vol_r2 = float(1 - ss_res / ss_tot)
|
||||||
|
|
||||||
|
json.dump({"val_vol_r2": val_vol_r2, "n_test": len(yte),
|
||||||
|
"knobs": {"WINDOW": WINDOW, "EMBED_DIM": EMBED_DIM, "MASK_FRAC": MASK_FRAC, "EPOCHS": EPOCHS}},
|
||||||
|
open("metrics.json", "w"), indent=2)
|
||||||
|
print("val_vol_r2 = %.4f (n_test=%d, dev=%s)" % (val_vol_r2, len(yte), dev))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user