bit-forecast-arena / src /config.py
Bit-Trading-Company's picture
CI deploy local
8ac699f verified
Raw
History Blame Contribute Delete
7.14 kB
"""Configuration for the Forecast Arena.
Everything that names an external resource -- a repo, a model, an asset -- is
declared here, so that changing where the Arena reads or writes is one edit in
one file rather than a search across the app.
The store is the *organisation's* dataset, not the user account's. That is
where the validated price cache and the signal conventions already live, and
it is what `bit-backtest-lab` reads and writes. See docs/DECISIONS.md.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
# --------------------------------------------------------------------------
# Repos
# --------------------------------------------------------------------------
ORG = "The-Bit-Trading-Company"
USER = "Bit-Trading-Company"
# The signal store. `BIT_STORE_REPO` overrides it for tests and local runs.
STORE_REPO = os.environ.get("BIT_STORE_REPO", f"{ORG}/bit-signal-store")
STORE_REPO_TYPE = "dataset"
SPACE_REPO = f"{USER}/bit-forecast-arena"
# Bumped when a change to *this* app alters forecast values. It travels into
# every archived row through `inference_version()`, so a stored forecast can
# always be traced to the code that produced it.
INFERENCE_VERSION = "arena-1"
# --------------------------------------------------------------------------
# Store layout
# --------------------------------------------------------------------------
# Everything the Arena writes lives under `arena/`.
#
# The build spec named `signals/{model_slug}/...` for the forecast archive, but
# that tree is already occupied. `bit-backtest-lab` writes per-bar, horizon-1
# signals there -- columns `ts,q10,q50,q90,context_len,inference_version` -- and
# four of its model slugs (`chronos-bolt-{tiny,mini,small,base}`) are slugs the
# Arena also uses. Writing frozen multi-step forecasts to the same paths would
# have merged two incompatible schemas into one file and destroyed signals that
# cost real GPU time to produce.
#
# So the archive is namespaced instead. Same layout, same conventions, one
# level down. See docs/DECISIONS.md.
def signals_path(model_slug: str, asset: str, timeframe: str, year: int) -> str:
return f"arena/forecasts/{model_slug}/{asset}/{timeframe}/{year}.parquet"
def trackrecord_path(model_slug: str, asset: str, timeframe: str) -> str:
return f"arena/trackrecord/{model_slug}/{asset}/{timeframe}.parquet"
def prices_path(asset: str, timeframe: str, year: int) -> str:
return f"prices/{asset}/{timeframe}/{year}.parquet"
STANDINGS_PATH = "arena/standings.parquet"
# Precomputed Track Record panels: what the Space reads instead of the raw
# track record, which is megabytes across dozens of files.
PANELS_PATH = "arena/panels.json"
REGISTRY_PATH = "arena/registry.json"
# --------------------------------------------------------------------------
# Assets and timeframes
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Asset:
slug: str # store path segment, matches the existing price cache
display: str
asset_class: str # "crypto" | "equity"
ASSETS: dict[str, Asset] = {
a.slug: a
for a in [
Asset("BTC-USD", "BTC/USD", "crypto"),
Asset("ETH-USD", "ETH/USD", "crypto"),
Asset("SOL-USD", "SOL/USD", "crypto"),
Asset("SPY", "SPY", "equity"),
Asset("NVDA", "NVDA", "equity"),
Asset("QQQ", "QQQ", "equity"),
]
}
# The seed/backfill scope named in the build spec.
SEED_ASSETS = ("BTC-USD", "ETH-USD", "SOL-USD", "SPY", "NVDA")
@dataclass(frozen=True)
class Timeframe:
slug: str
display: str
minutes: int
TIMEFRAMES: dict[str, Timeframe] = {
t.slug: t
for t in [
Timeframe("1h", "1 hour", 60),
Timeframe("1d", "1 day", 1440),
]
}
SEED_TIMEFRAMES = ("1h", "1d")
# Default forecast horizons offered in the UI, per timeframe.
DEFAULT_HORIZON = {"1h": 24, "1d": 30}
MAX_HORIZON = {"1h": 168, "1d": 90}
# --------------------------------------------------------------------------
# Sampling and latency
# --------------------------------------------------------------------------
# Quantile levels stored for every forecast.
#
# These are chosen so that *every* family emits them natively. TimesFM returns
# a fixed 0.1-step grid, so 0.25/0.75 would have had to be interpolated for it
# while Chronos and Kronos produced theirs directly -- three models' bands
# would then not have been the same kind of number. Snapping to 0.2/0.8 keeps
# every stored quantile the model's own output.
#
# The pair either side of the median gives a second nominal band (60%) to
# check coverage against, which is what stops a model tuning itself to look
# calibrated at exactly one width.
QUANTILE_LEVELS = (0.1, 0.2, 0.5, 0.8, 0.9)
# How many bars of history a forecast actually conditions on.
#
# This is capped *below* several models' `max_context` on purpose. Kronos-mini
# will attend over 2048 bars and TimesFM over far more, but the cost is roughly
# linear in context and the accuracy gain past a few hundred bars is small: at
# 2048 bars Kronos-mini took 36.8s for a 24-step forecast, and at 512 it takes
# a fraction of that for a materially similar answer. The design says "Fetching
# 512 candles", and 512 is what the app fetches.
#
# A model whose `max_context` is *smaller* than this still gets trimmed to its
# own limit by `ForecastAdapter._trim`.
DEFAULT_CONTEXT_BARS = 512
DEFAULT_N_SAMPLES = 32
MAX_N_SAMPLES = 128
# How many sampled paths are ever sent to the browser as "ghosts". Sending all
# of them is a payload problem, not an information gain.
GHOST_PATHS = 12
# CPU-tier budget on `cpu-basic`, in seconds. A model that misses it is demoted
# to GPU tier in the registry rather than left to time out in front of a user.
CPU_BUDGET_COLD_S = 20.0
CPU_BUDGET_WARM_S = 8.0
# Per-session forecast cap for anonymous visitors.
ANON_SESSION_CAP = 12
# --------------------------------------------------------------------------
# Calibration grading
# --------------------------------------------------------------------------
# A grade is assigned on the absolute gap between empirical coverage of the
# 80% band (q10..q90) and its nominal 0.80. The thresholds are deliberately
# wide: with ~60 resolved forecasts the standard error on a coverage estimate
# is around 5 points, so anything tighter would be grading noise.
#
# `MIN_RESOLVED_FOR_GRADE` is the point below which no grade is shown at all --
# the UI renders the count instead. Refusing to grade is more honest than
# grading 3 observations.
# (low level, high level, nominal coverage) for each band the resolver scores.
BANDS = ((0.1, 0.9, 0.80), (0.2, 0.8, 0.60))
NOMINAL_COVERAGE = 0.80 # the band the headline grade is computed on
MIN_RESOLVED_FOR_GRADE = 20
GRADE_THRESHOLDS = (
("A", 0.05), # within 5 points of nominal
("B", 0.10),
("C", 0.20),
("D", 1.00), # anything worse
)
@dataclass(frozen=True)
class Caps:
"""Guardrails on user-funded work."""
smoke_test_steps: int = 100
max_enrollments_per_session: int = 3
CAPS = Caps()