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..5a600c6 --- /dev/null +++ b/starters/quant-research-loop/DEPLOY-RAILWAY.md @@ -0,0 +1,65 @@ +# 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.** 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 +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/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/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 "
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"| strategy | status | registered | fwd days | +equity | Sharpe | maxDD | latest data |
|---|