"""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" # The newest archived forecast for every (model, asset, timeframe), so a # visitor who has not pressed anything still sees each model's most recent # work instead of an empty chart. One small file rather than a scan of the # year partitions, because this is read on the render path. LATEST_PATH = "arena/latest.parquet" def latest_paths_path(model_slug: str, asset: str, timeframe: str) -> str: """Sampled OHLCV paths for that latest forecast. Kept out of `latest.parquet` and out of the archive itself: paths are two orders of magnitude bigger than the quantiles they summarise, and only the most recent forecast of a path-emitting model ever needs to be redrawn. """ return f"arena/latest_paths/{model_slug}/{asset}/{timeframe}.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()