From 3fc91c5aa81b13ec73fb3faa30f21d2650b2a93f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 12:54:06 +0000 Subject: [PATCH 1/2] feat(quant-research-loop): always-on Railway service for continuous thesis tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the forward loop into a hosted, always-running platform that checks prices on a schedule, scores every thesis, and persists the record — so you can iterate, add strategies, and keep tracking until something is truly profitable. - engine/service.py: stdlib web service + background scheduler. Runs the forward check every CHECK_INTERVAL_SECONDS, persists state/log/scoreboard to QUANT_DATA_DIR, serves a live status page (/), /scoreboard.json, and /health. Statuses per thesis: within_mandate / breached / awaiting_data. - forward_paper.py: QUANT_DATA_DIR env so the record persists to a Volume (not the repo); committed registration acts as a seed; auto_register_pending() write-once registers any new thesis on boot. - Dockerfile + railway.json + DEPLOY-RAILWAY.md: one-click-ish Railway deploy (mount a Volume at /data). Pure stdlib, no DB required. Extensibility: add a thesis to FROZEN_STRATEGIES, redeploy -> the service auto-registers it (write-once, stamped at the latest data date) and tracks it alongside the others. Registration stays write-once per name; revising a thesis means a NEW name with a NEW start date. Tests 38/38, repo gates pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UcE4n3gQdVXJtD2z3mBrZX --- starters/quant-research-loop/.dockerignore | 7 + starters/quant-research-loop/.gitignore | 1 + .../quant-research-loop/DEPLOY-RAILWAY.md | 56 +++++++ starters/quant-research-loop/Dockerfile | 15 ++ starters/quant-research-loop/README.md | 17 ++ .../engine/forward_paper.py | 36 ++++- .../quant-research-loop/engine/service.py | 151 ++++++++++++++++++ starters/quant-research-loop/railway.json | 12 ++ starters/quant-research-loop/test_engine.py | 20 +++ 9 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 starters/quant-research-loop/.dockerignore create mode 100644 starters/quant-research-loop/DEPLOY-RAILWAY.md create mode 100644 starters/quant-research-loop/Dockerfile create mode 100644 starters/quant-research-loop/engine/service.py create mode 100644 starters/quant-research-loop/railway.json diff --git a/starters/quant-research-loop/.dockerignore b/starters/quant-research-loop/.dockerignore new file mode 100644 index 0000000..610189e --- /dev/null +++ b/starters/quant-research-loop/.dockerignore @@ -0,0 +1,7 @@ +data/ +__pycache__/ +*.pyc +quant-blotter-*.md +scoreboard.json +paper-account.json +research-ledger.json diff --git a/starters/quant-research-loop/.gitignore b/starters/quant-research-loop/.gitignore index 3cd68f5..d445f66 100644 --- a/starters/quant-research-loop/.gitignore +++ b/starters/quant-research-loop/.gitignore @@ -3,6 +3,7 @@ paper-account.json quant-run-log.md quant-state.md quant-blotter-*.md +scoreboard.json research-ledger.json # forward-track record (quant-forward-state.md / quant-forward-log.md) IS committed # so the scheduled loop builds a durable P&L history in git diff --git a/starters/quant-research-loop/DEPLOY-RAILWAY.md b/starters/quant-research-loop/DEPLOY-RAILWAY.md new file mode 100644 index 0000000..b9f4cb1 --- /dev/null +++ b/starters/quant-research-loop/DEPLOY-RAILWAY.md @@ -0,0 +1,56 @@ +# Deploy the forward tracker on Railway + +An always-on service that checks prices on a schedule, marks the frozen strategies +to market, and persists a live P&L record + scoreboard. Pure stdlib, no database +required (though you can add one later). + +## What it is + +- `engine/service.py` — a web service (status page on `$PORT`) with a background + scheduler that runs `forward_paper.run(refresh=True)` every `CHECK_INTERVAL_SECONDS`. +- Persists to `QUANT_DATA_DIR` (mount a Railway **Volume** there so the record + survives redeploys): `forward-registration.json`, `quant-forward-state.md`, + `quant-forward-log.md`, `scoreboard.json`. +- Config lives in `railway.json` + `Dockerfile`. + +## One-time setup + +1. **New Railway project → Deploy from repo**, root set to `starters/quant-research-loop` + (or point the service's root directory there). Railway reads `railway.json` and + builds the `Dockerfile`. +2. **Add a Volume**, mount path `/data`. The Dockerfile already sets + `QUANT_DATA_DIR=/data`. +3. **Generate a domain** (Settings → Networking) to see the status page. Railway + injects `$PORT` automatically; the service binds it. +4. (Optional) Override `CHECK_INTERVAL_SECONDS` (default `86400` = daily). + +That's it. The service boots, auto-registers the frozen strategies (write-once), +and starts checking. Visit the domain for the scoreboard; `GET /scoreboard.json` +for the machine-readable version; `GET /health` for the healthcheck. + +## The data caveat (read this) + +The default price source is the free Coin Metrics community dataset, which can lag +by days–weeks. Forward rows only appear once it publishes bars **after** the +registration date. For same-day tracking, wire a real-time feed (Binance.US / +Coinbase) into `data.py` / `multi_data.py` — Railway has open internet, so exchange +APIs that are blocked in some sandboxes work there. + +## Adding a new thesis (the iterate loop) + +1. Add an entry to `FROZEN_STRATEGIES` in `engine/forward_paper.py` (single-asset + or a multi-asset `xsectional` config). +2. Redeploy. The service **auto-registers** it (write-once) stamped at the latest + data date, and starts tracking it alongside the others. +3. Watch the scoreboard. Each thesis shows: forward days, equity, Sharpe, drawdown, + and `within_mandate` / `breached` / `awaiting_data`. + +Registration is write-once per name — you never move a thesis's goalposts. To +revise a thesis, register a NEW name (a new hypothesis with a new start date). + +## Run it locally first + +```bash +QUANT_DATA_DIR=./data CHECK_INTERVAL_SECONDS=3600 PORT=8080 python -m engine.service +# → open http://localhost:8080 +``` diff --git a/starters/quant-research-loop/Dockerfile b/starters/quant-research-loop/Dockerfile new file mode 100644 index 0000000..6a71ff9 --- /dev/null +++ b/starters/quant-research-loop/Dockerfile @@ -0,0 +1,15 @@ +# Always-on forward-tracking service. Pure stdlib — no pip install needed. +FROM python:3.11-slim + +WORKDIR /app +COPY . /app + +# Persist the track record to a mounted Volume here (Railway: mount at /data). +ENV QUANT_DATA_DIR=/data \ + CHECK_INTERVAL_SECONDS=86400 \ + AUTO_REGISTER=1 +RUN mkdir -p /data + +# Railway injects $PORT; the service binds 0.0.0.0:$PORT (default 8080). +EXPOSE 8080 +CMD ["python", "-m", "engine.service"] diff --git a/starters/quant-research-loop/README.md b/starters/quant-research-loop/README.md index 85891e0..025a194 100644 --- a/starters/quant-research-loop/README.md +++ b/starters/quant-research-loop/README.md @@ -223,6 +223,8 @@ further searches halt and point you to forward-testing or new data. | `engine/walkforward.py` | Walk-forward K-of-N rolling out-of-sample validation | | `engine/quarantine.py` | Forward out-of-time test; the verdict that gates capital | | `engine/blotter.py` | Per-trade blotter — single-asset round-trips + per-coin basket contribution | +| `engine/forward_paper.py` | Frozen-strategy forward paper trade + registry (write-once) | +| `engine/service.py` | Always-on tracker service (Railway) — scheduler + scoreboard | | `engine/split.py` | Three-way train/validation/lockbox split | | `engine/ledger.py` | Trial counter + budget + write-once lockbox/forward ledger | | `engine/stats.py` | Overfitting-aware metrics (no numpy/scipy) | @@ -430,6 +432,21 @@ in-git track record. It self-suppresses until new price data exists. (In a netwo that blocks exchange APIs, point `data.py`/`multi_data.py` at Coin Metrics, as it does by default, or a reachable feed.) +**Always-on service (Railway).** For a hosted tracker that runs continuously and +scores every thesis, `engine/service.py` is a stdlib web service + scheduler: + +```bash +QUANT_DATA_DIR=./data CHECK_INTERVAL_SECONDS=3600 PORT=8080 python -m engine.service +# → http://localhost:8080 (live scoreboard) · /scoreboard.json · /health +``` + +Deploy on Railway with the bundled `Dockerfile` + `railway.json` (mount a Volume at +`/data` so the record survives redeploys) — see [DEPLOY-RAILWAY.md](DEPLOY-RAILWAY.md). +**Add a new thesis** by defining it in `FROZEN_STRATEGIES` and redeploying; the +service auto-registers it (write-once) and starts tracking it alongside the rest. +The scoreboard shows each thesis as `within_mandate` / `breached` / `awaiting_data` +— your running record of what's actually profitable. + ## What this does NOT do - **It does not place real orders.** `paper_broker.py` has no credentials. Going diff --git a/starters/quant-research-loop/engine/forward_paper.py b/starters/quant-research-loop/engine/forward_paper.py index 08f14e0..431b836 100644 --- a/starters/quant-research-loop/engine/forward_paper.py +++ b/starters/quant-research-loop/engine/forward_paper.py @@ -35,9 +35,15 @@ HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -REG_PATH = os.path.join(HERE, "forward-registration.json") -STATE_MD = os.path.join(HERE, "quant-forward-state.md") -LOG_MD = os.path.join(HERE, "quant-forward-log.md") +# Where the live record is persisted. Defaults to the repo dir (local runs); set +# QUANT_DATA_DIR to a persistent volume (e.g. /data on Railway) for an always-on +# service so the track record survives redeploys. +DATA_DIR = os.environ.get("QUANT_DATA_DIR", HERE) +os.makedirs(DATA_DIR, exist_ok=True) +SEED_REG = os.path.join(HERE, "forward-registration.json") # committed contract (seed) +REG_PATH = os.path.join(DATA_DIR, "forward-registration.json") +STATE_MD = os.path.join(DATA_DIR, "quant-forward-state.md") +LOG_MD = os.path.join(DATA_DIR, "quant-forward-log.md") PPY = 365 # The frozen, pre-registered strategies. Configs are a priori — NOT re-optimized @@ -78,9 +84,10 @@ def _date(ts: int) -> str: def _load_reg() -> dict: - if not os.path.exists(REG_PATH): + path = REG_PATH if os.path.exists(REG_PATH) else SEED_REG # fall back to committed seed + if not os.path.exists(path): return {} - with open(REG_PATH) as fh: + with open(path) as fh: data = json.load(fh) # migrate the old single flat format -> keyed dict if "strategy" in data and "registered_as_of" in data: @@ -88,6 +95,25 @@ def _load_reg() -> dict: return data +def default_as_of(name: str) -> str: + """Latest date available in a strategy's data — the honest 'now' to register from.""" + pairs = _forward_pairs(FROZEN_STRATEGIES[name]) + return _date(pairs[-1][0]) if pairs else "1970-01-01" + + +def auto_register_pending() -> list[str]: + """Register any FROZEN strategy not yet in the record (write-once), stamping the + latest data date. This is how you add a new thesis: define it in + FROZEN_STRATEGIES, redeploy, and it starts tracking from that point.""" + reg = _load_reg() + added = [] + for name in FROZEN_STRATEGIES: + if name not in reg: + register(name, default_as_of(name)) + added.append(name) + return added + + def register(name: str, as_of: str, force: bool = False) -> None: if name not in FROZEN_STRATEGIES: raise SystemExit(f"Unknown strategy '{name}'. Known: {list(FROZEN_STRATEGIES)}") diff --git a/starters/quant-research-loop/engine/service.py b/starters/quant-research-loop/engine/service.py new file mode 100644 index 0000000..1e5c16a --- /dev/null +++ b/starters/quant-research-loop/engine/service.py @@ -0,0 +1,151 @@ +"""Always-on forward-tracking service (Railway-ready, pure stdlib). + +Runs the frozen-strategy forward check on a schedule, persists the record and a +scoreboard to QUANT_DATA_DIR (mount a persistent Volume there), and serves a live +status page on $PORT. No dependencies. + +Env: + PORT HTTP port (Railway sets this automatically; default 8080) + QUANT_DATA_DIR where the record persists (mount a Volume here, e.g. /data) + CHECK_INTERVAL_SECONDS how often to check prices (default 86400 = daily) + AUTO_REGISTER "1" (default) auto-registers any new FROZEN thesis on boot + +Add a new thesis: define it in forward_paper.FROZEN_STRATEGIES, redeploy — the +service auto-registers it (write-once) and starts tracking it from that point. +""" +from __future__ import annotations + +import json +import os +import threading +import time +import traceback +from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from . import forward_paper as fp + + +PORT = int(os.environ.get("PORT", "8080")) +INTERVAL = int(os.environ.get("CHECK_INTERVAL_SECONDS", "86400")) +AUTO_REGISTER = os.environ.get("AUTO_REGISTER", "1") == "1" +SCORE_PATH = os.path.join(fp.DATA_DIR, "scoreboard.json") + + +def scoreboard_from_results(results: dict, checked_utc: str) -> dict: + """Pure: turn forward_paper.run() output into a persisted scoreboard.""" + board = {"checked_utc": checked_utc, "interval_seconds": INTERVAL, "strategies": {}} + for name, r in results.items(): + m = r["forward"] + n_days = m.get("n_days", 0) + status = "awaiting_data" if n_days == 0 else ("within_mandate" if r["within_mandate"] else "breached") + board["strategies"][name] = { + "status": status, + "kind": r.get("kind"), + "registered_as_of": r["registered_as_of"], + "mandate_max_drawdown": r["mandate_max_drawdown"], + "forward_days": n_days, + "equity_mult": m.get("equity_mult"), + "total_return": m.get("total_return"), + "sharpe": m.get("sharpe"), + "max_drawdown": m.get("max_drawdown"), + "latest_data": m.get("last_forward") or m.get("last_data"), + } + return board + + +def run_check() -> dict: + results = fp.run(refresh=True) + board = scoreboard_from_results(results, datetime.utcnow().strftime("%Y-%m-%d %H:%M:%SZ")) + tmp = SCORE_PATH + ".tmp" + with open(tmp, "w") as fh: + json.dump(board, fh, indent=2) + os.replace(tmp, SCORE_PATH) # atomic + return board + + +def _scheduler(): + while True: + try: + b = run_check() + print(f"[check] {b['checked_utc']} — " + + ", ".join(f"{n}:{s['status']}" for n, s in b["strategies"].items()), flush=True) + except Exception: + traceback.print_exc() + time.sleep(INTERVAL) + + +def _load_board() -> dict | None: + if os.path.exists(SCORE_PATH): + with open(SCORE_PATH) as fh: + return json.load(fh) + return None + + +def _render_html(board: dict | None) -> str: + if not board: + return "

Quant Forward Tracker

Initializing — first check running…

" + rows = [] + color = {"within_mandate": "#3ee8c5", "breached": "#e5687a", "awaiting_data": "#7a879a"} + for name, s in board["strategies"].items(): + c = color.get(s["status"], "#c9d4e2") + eq = f"{s['equity_mult']}x" if s.get("equity_mult") is not None else "—" + sh = s.get("sharpe") if s.get("sharpe") is not None else "—" + dd = f"{s['max_drawdown']:.0%}" if s.get("max_drawdown") is not None else "—" + rows.append( + f"{name}" + f"{s['status'].replace('_',' ')}" + f"{s['registered_as_of']}{s['forward_days']}" + f"{eq}{sh}{dd}{s.get('latest_data','—')}") + return f""" +Quant Forward Tracker + +

◈ Quant Forward Tracker

+

Last check: {board['checked_utc']} · interval {board['interval_seconds']}s · +paper only · forward equity is the verdict

+ + +{''.join(rows)}
strategystatusregisteredfwd daysequitySharpemaxDDlatest data
+

Statuses: within mandate · +breached · awaiting data. +Add a thesis in forward_paper.FROZEN_STRATEGIES and redeploy.

""" + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path.startswith("/health"): + self._send(200, "text/plain", b"ok") + elif self.path.startswith("/scoreboard.json"): + b = _load_board() or {} + self._send(200, "application/json", json.dumps(b, indent=2).encode()) + else: + self._send(200, "text/html; charset=utf-8", _render_html(_load_board()).encode()) + + def _send(self, code, ctype, body): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *a): # quiet default access logging + pass + + +def main(): + if AUTO_REGISTER: + added = fp.auto_register_pending() + if added: + print(f"[boot] auto-registered: {added}", flush=True) + threading.Thread(target=_scheduler, daemon=True).start() + print(f"[boot] serving on :{PORT} · data dir {fp.DATA_DIR} · interval {INTERVAL}s", flush=True) + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/starters/quant-research-loop/railway.json b/starters/quant-research-loop/railway.json new file mode 100644 index 0000000..e73f480 --- /dev/null +++ b/starters/quant-research-loop/railway.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "Dockerfile" + }, + "deploy": { + "startCommand": "python -m engine.service", + "restartPolicyType": "ALWAYS", + "healthcheckPath": "/health" + } +} diff --git a/starters/quant-research-loop/test_engine.py b/starters/quant-research-loop/test_engine.py index 512a6a2..c698152 100644 --- a/starters/quant-research-loop/test_engine.py +++ b/starters/quant-research-loop/test_engine.py @@ -444,6 +444,26 @@ def test_blotter_marks_open_trade(): assert trades[-1].status == "open" +def test_service_scoreboard_scoring(): + from engine import service + results = { + "regime-trend": {"registered_as_of": "2026-05-23", "mandate_max_drawdown": 0.40, + "kind": "single_asset", "within_mandate": False, + "forward": {"n_days": 0, "last_data": "2026-05-23"}}, + "good-one": {"registered_as_of": "2025-01-01", "mandate_max_drawdown": 0.40, + "kind": "xsectional", "within_mandate": True, + "forward": {"n_days": 300, "equity_mult": 1.4, "sharpe": 0.9, + "max_drawdown": 0.31, "last_forward": "2026-01-01"}}, + } + board = service.scoreboard_from_results(results, "2026-07-03 00:00:00Z") + assert board["strategies"]["regime-trend"]["status"] == "awaiting_data" + assert board["strategies"]["good-one"]["status"] == "within_mandate" + # a breached mandate is flagged + results["good-one"]["within_mandate"] = False + board = service.scoreboard_from_results(results, "t") + assert board["strategies"]["good-one"]["status"] == "breached" + + def _run_all(): fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] passed = 0 From 7452a7b59630242740c6ba08b9283dce4935fa87 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 16:21:48 +0000 Subject: [PATCH 2/2] fix(quant-research-loop): make Railway build work with the default builder The Railway deploy failed with 'Railpack could not determine how to build the app' because the merged commit predated the Dockerfile and Railpack (Railway's default builder) found no Python marker. Rather than depend on the Dockerfile being picked up (fragile: needs the right commit + builder setting + root dir), add the two signals the default builder needs: - requirements.txt: pure-stdlib marker (no deps) so Railpack/Nixpacks detects Python - Procfile: 'web: python -m engine.service' start command Now it builds natively under Railpack OR the Dockerfile. DEPLOY-RAILWAY.md updated: set QUANT_DATA_DIR=/data (required for persistence without the Dockerfile ENV) and mount a Volume there; redeploy latest main if a build predates these files. Tests 38/38, service boots via the Procfile command, repo gates pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UcE4n3gQdVXJtD2z3mBrZX --- .../quant-research-loop/DEPLOY-RAILWAY.md | 25 +++++++++++++------ starters/quant-research-loop/Procfile | 1 + starters/quant-research-loop/requirements.txt | 3 +++ 3 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 starters/quant-research-loop/Procfile create mode 100644 starters/quant-research-loop/requirements.txt diff --git a/starters/quant-research-loop/DEPLOY-RAILWAY.md b/starters/quant-research-loop/DEPLOY-RAILWAY.md index b9f4cb1..5a600c6 100644 --- a/starters/quant-research-loop/DEPLOY-RAILWAY.md +++ b/starters/quant-research-loop/DEPLOY-RAILWAY.md @@ -15,19 +15,28 @@ required (though you can add one later). ## One-time setup -1. **New Railway project → Deploy from repo**, root set to `starters/quant-research-loop` - (or point the service's root directory there). Railway reads `railway.json` and - builds the `Dockerfile`. -2. **Add a Volume**, mount path `/data`. The Dockerfile already sets - `QUANT_DATA_DIR=/data`. -3. **Generate a domain** (Settings → Networking) to see the status page. Railway - injects `$PORT` automatically; the service binds it. -4. (Optional) Override `CHECK_INTERVAL_SECONDS` (default `86400` = daily). +1. **New Railway project → Deploy from repo.** Set the service **Root Directory** to + `starters/quant-research-loop` (Settings → Source). The build works with either + builder — the default **Railpack/Nixpacks** picks it up via `requirements.txt` + (pure stdlib, no deps) + `Procfile` (`web: python -m engine.service`); or set the + builder to **Dockerfile** to use the bundled `Dockerfile`. No configuration + needed either way. +2. **Set variables** (Settings → Variables): + - `QUANT_DATA_DIR=/data` — **required for persistence** (without it the record + lives on the ephemeral filesystem and resets on redeploy). + - `CHECK_INTERVAL_SECONDS=86400` — optional (default daily). +3. **Add a Volume**, mount path `/data` (Settings → Volumes) — matches `QUANT_DATA_DIR`. +4. **Generate a domain** (Settings → Networking) to see the status page. Railway + injects `$PORT`; the service binds it automatically. That's it. The service boots, auto-registers the frozen strategies (write-once), and starts checking. Visit the domain for the scoreboard; `GET /scoreboard.json` for the machine-readable version; `GET /health` for the healthcheck. +> **If the build fails with "Railpack could not determine how to build the app,"** +> you're on a commit that predates `requirements.txt`/`Procfile`. Redeploy the +> latest `main` (Railway → Deployments → redeploy, or push a new commit). + ## The data caveat (read this) The default price source is the free Coin Metrics community dataset, which can lag diff --git a/starters/quant-research-loop/Procfile b/starters/quant-research-loop/Procfile new file mode 100644 index 0000000..3803e6a --- /dev/null +++ b/starters/quant-research-loop/Procfile @@ -0,0 +1 @@ +web: python -m engine.service diff --git a/starters/quant-research-loop/requirements.txt b/starters/quant-research-loop/requirements.txt new file mode 100644 index 0000000..5c46fbe --- /dev/null +++ b/starters/quant-research-loop/requirements.txt @@ -0,0 +1,3 @@ +# The quant-research-loop engine is PURE PYTHON STDLIB — no third-party packages. +# This file exists so Railway/Railpack/Nixpacks detects a Python app and builds it +# without a Dockerfile. Intentionally empty of dependencies.