Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions starters/quant-research-loop/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
data/
__pycache__/
*.pyc
quant-blotter-*.md
scoreboard.json
paper-account.json
research-ledger.json
1 change: 1 addition & 0 deletions starters/quant-research-loop/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions starters/quant-research-loop/DEPLOY-RAILWAY.md
Original file line number Diff line number Diff line change
@@ -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
```
15 changes: 15 additions & 0 deletions starters/quant-research-loop/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
1 change: 1 addition & 0 deletions starters/quant-research-loop/Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: python -m engine.service
17 changes: 17 additions & 0 deletions starters/quant-research-loop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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
Expand Down
36 changes: 31 additions & 5 deletions starters/quant-research-loop/engine/forward_paper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,16 +84,36 @@ 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:
return {data["strategy"]: data}
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)}")
Expand Down
151 changes: 151 additions & 0 deletions starters/quant-research-loop/engine/service.py
Original file line number Diff line number Diff line change
@@ -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 "<h1>Quant Forward Tracker</h1><p>Initializing — first check running…</p>"
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"<tr><td>{name}</td>"
f"<td style='color:{c};font-weight:600'>{s['status'].replace('_',' ')}</td>"
f"<td>{s['registered_as_of']}</td><td>{s['forward_days']}</td>"
f"<td>{eq}</td><td>{sh}</td><td>{dd}</td><td>{s.get('latest_data','—')}</td></tr>")
return f"""<!doctype html><meta charset=utf-8>
<title>Quant Forward Tracker</title>
<style>body{{font-family:ui-monospace,Menlo,monospace;background:#0d1117;color:#c9d4e2;
max-width:900px;margin:40px auto;padding:0 20px;line-height:1.6}}
h1{{color:#3ee8c5;font-size:20px}} table{{width:100%;border-collapse:collapse;font-size:13px}}
th,td{{text-align:left;padding:8px 10px;border-bottom:1px solid #20303f}}
th{{color:#7a879a;font-size:11px;text-transform:uppercase;letter-spacing:.06em}}
.meta{{color:#7a879a;font-size:12px}}</style>
<h1>◈ Quant Forward Tracker</h1>
<p class=meta>Last check: {board['checked_utc']} · interval {board['interval_seconds']}s ·
paper only · forward equity is the verdict</p>
<table><tr><th>strategy</th><th>status</th><th>registered</th><th>fwd days</th>
<th>equity</th><th>Sharpe</th><th>maxDD</th><th>latest data</th></tr>
{''.join(rows)}</table>
<p class=meta>Statuses: <b style='color:#3ee8c5'>within mandate</b> ·
<b style='color:#e5687a'>breached</b> · <b style='color:#7a879a'>awaiting data</b>.
Add a thesis in forward_paper.FROZEN_STRATEGIES and redeploy.</p>"""


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()
12 changes: 12 additions & 0 deletions starters/quant-research-loop/railway.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
3 changes: 3 additions & 0 deletions starters/quant-research-loop/requirements.txt
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions starters/quant-research-loop/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading