bit-forecast-arena / scripts /seed_trackrecord.py
Bit-Trading-Company's picture
CI deploy local
8028640 verified
Raw
History Blame Contribute Delete
7.51 kB
"""Backfill the track record by forecasting through recent history, honestly.
The point of a backfill is to give a new model a track record without waiting
months for one. The danger is that a backfill is trivially easy to fake: run
the model over history it has already seen, or let it peek at the bar it is
predicting, and it will look extraordinary.
So this script forecasts *as of* each historical timestamp. The context is
sliced at that timestamp by `runtime.load_context`, and the adapter re-checks
the slice against `issued_ts` on every call -- a context bar dated after the
issue moment raises rather than producing a flattering number. Nothing here
opts out of that check; there is no fast path that skips it.
Every row written carries `backfilled: true`, and the UI badges those entries
wherever they appear. A backfilled record is evidence of a kind, but it is not
the same evidence as a forecast made before the outcome existed, and the
difference is on the page rather than in a footnote.
HF_TOKEN=$(cat ../../key.txt) python scripts/seed_trackrecord.py
python scripts/seed_trackrecord.py --dry-run --models chronos-bolt-tiny
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
from pathlib import Path
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src import config, runtime, trackrecord # noqa: E402
from src.store import ArenaStore, now_utc # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
log = logging.getLogger("backfill")
# How many forecasts to seed per (model, asset, timeframe).
TARGET_PER_SERIES = 60
# Space the issue points out so the windows do not all overlap: consecutive
# forecasts sharing 95% of their context would give 60 nearly identical
# observations and a coverage figure with far less evidence behind it than the
# count suggests.
STRIDE = {"1h": 12, "1d": 3}
def checkpoint_path(root: Path) -> Path:
return root / "backfill_checkpoint.json"
def load_checkpoint(root: Path) -> dict:
p = checkpoint_path(root)
if p.exists():
try:
return json.loads(p.read_text())
except Exception:
log.warning("unreadable checkpoint; starting fresh")
return {"done": {}}
def save_checkpoint(root: Path, state: dict) -> None:
checkpoint_path(root).parent.mkdir(parents=True, exist_ok=True)
checkpoint_path(root).write_text(json.dumps(state, indent=2, sort_keys=True))
def series_key(model: str, asset: str, tf: str) -> str:
return f"{model}|{asset}|{tf}"
def backfill_series(store: ArenaStore, model_slug: str, asset: str, tf: str,
registry: dict, target: int, horizon: int,
seed: int, dry_run: bool) -> int:
"""Issue `target` as-of forecasts, oldest first. Returns rows archived."""
prices = store.get_prices(asset, tf)
if not len(prices):
log.warning(" no prices for %s %s", asset, tf)
return 0
ts = pd.to_datetime(prices["ts"], utc=True).reset_index(drop=True)
stride = STRIDE.get(tf, 6)
# Only issue points whose horizon has already elapsed -- an unresolved
# backfill row would sit in the archive forever without ever scoring.
latest_resolvable = len(ts) - horizon - 1
earliest = config.DEFAULT_CONTEXT_BARS + 1
if latest_resolvable <= earliest:
log.warning(" not enough history for %s %s (%d bars)", asset, tf, len(ts))
return 0
points = list(range(latest_resolvable, earliest, -stride))[:target]
points.reverse()
written = 0
for i, idx in enumerate(points):
as_of = ts.iloc[idx]
try:
run = runtime.run_forecast(
store, model_slug, asset, tf, horizon=horizon,
n_samples=config.DEFAULT_N_SAMPLES, seed=seed, as_of=as_of,
registry=registry, archive=not dry_run, backfilled=True)
except runtime.ForecastUnavailable as e:
log.warning(" %s: %s", as_of.date(), e)
continue
written += run.archived_rows
if (i + 1) % 10 == 0:
log.info(" %d/%d issued (%s)", i + 1, len(points), as_of.date())
return written
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--models", nargs="*", default=None,
help="model slugs; default is every CPU-tier enrolled model")
ap.add_argument("--assets", nargs="*", default=list(config.SEED_ASSETS))
ap.add_argument("--timeframes", nargs="*", default=list(config.SEED_TIMEFRAMES))
ap.add_argument("--target", type=int, default=TARGET_PER_SERIES)
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--include-gpu", action="store_true",
help="also seed GPU-tier models (slow on CPU)")
ap.add_argument("--local-root", default=None)
args = ap.parse_args()
store = ArenaStore(local_root=args.local_root,
offline=not os.environ.get("HF_TOKEN"))
registry = store.get_registry()
models = registry.get("models", {})
if not models:
log.error("registry is empty; run scripts/bootstrap_registry.py first")
return 1
if args.models:
chosen = [m for m in args.models if m in models]
missing = set(args.models) - set(chosen)
if missing:
log.error("not enrolled: %s", ", ".join(sorted(missing)))
return 1
else:
chosen = [slug for slug, entry in sorted(models.items())
if args.include_gpu
or entry.get("capabilities", {}).get("hardware") == "cpu"]
root = Path(store.local_root)
checkpoint = load_checkpoint(root)
total = 0
for model_slug in chosen:
log.info("model %s", model_slug)
for asset in args.assets:
for tf in args.timeframes:
key = series_key(model_slug, asset, tf)
if checkpoint["done"].get(key):
log.info(" %s %s already done, skipping", asset, tf)
continue
horizon = config.DEFAULT_HORIZON.get(tf, 24)
log.info(" %s %s (h=%d, target=%d)", asset, tf, horizon, args.target)
written = backfill_series(store, model_slug, asset, tf, registry,
args.target, horizon, args.seed,
args.dry_run)
total += written
if not args.dry_run:
checkpoint["done"][key] = {
"rows": written, "ts": now_utc().isoformat()}
save_checkpoint(root, checkpoint)
# Commit per series rather than at the end: a run that dies
# halfway must not throw away hours of forecasting.
store.flush(f"arena: backfill {model_slug} {asset} {tf}")
log.info("archived %d forecast rows", total)
if not args.dry_run:
log.info("resolving")
for model_slug in chosen:
for asset in args.assets:
for tf in args.timeframes:
trackrecord.resolve(store, model_slug, asset, tf)
trackrecord.regenerate_standings(store)
store.flush("arena: resolve backfill and rebuild standings")
log.info("standings rebuilt")
return 0
if __name__ == "__main__":
raise SystemExit(main())