Spaces:
Running on Zero
Running on Zero
| """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 | |
| # -------------------------------------------------------------------------- | |
| 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") | |
| 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 | |
| ) | |
| class Caps: | |
| """Guardrails on user-funded work.""" | |
| smoke_test_steps: int = 100 | |
| max_enrollments_per_session: int = 3 | |
| CAPS = Caps() | |