Spaces:
Running on Zero
Running on Zero
File size: 9,414 Bytes
46f1a78 27c0524 46f1a78 27c0524 389e1f7 27c0524 46f1a78 27c0524 46f1a78 27c0524 46f1a78 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 | """Central configuration for the Backtest Lab.
Everything that a maintainer might want to tune -- repo ids, provider chains,
asset universe, rate limits, guardrails -- lives here as data, not code.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
# --------------------------------------------------------------------------
# Repos
# --------------------------------------------------------------------------
ORG = "The-Bit-Trading-Company"
# The shared signal store lives under the org (the company-branded data asset).
STORE_REPO = os.environ.get("BIT_STORE_REPO", f"{ORG}/bit-signal-store")
STORE_REPO_TYPE = "dataset"
# The Space itself. Gradio Spaces under an org require a paid Team/Enterprise
# plan, so the app is hosted under the owner's PRO personal namespace.
# See DECISIONS.md (D-001).
SPACE_REPO = os.environ.get("BIT_SPACE_REPO", "Bit-Trading-Company/bit-backtest-lab")
MANIFEST_PATH = "manifest.json"
MANIFEST_SCHEMA_VERSION = 1
# Bumped whenever a change to inference or storage semantics invalidates
# previously-written signal slices.
INFERENCE_VERSION = "1.0.0"
PLACEHOLDER_VERSION = "PLACEHOLDER"
# --------------------------------------------------------------------------
# Assets & timeframes
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Asset:
"""One tradable symbol, with the per-provider symbol spellings it needs."""
slug: str # canonical id used in store paths, e.g. "BTC-USD"
display: str
kind: str # "crypto" | "equity"
ccxt_symbol: str | None = None
yahoo_symbol: str | None = None
stooq_symbol: str | None = None
tiingo_symbol: str | None = None
ASSETS: dict[str, Asset] = {
a.slug: a
for a in [
Asset("BTC-USD", "Bitcoin", "crypto", ccxt_symbol="BTC/USDT", yahoo_symbol="BTC-USD"),
Asset("ETH-USD", "Ethereum", "crypto", ccxt_symbol="ETH/USDT", yahoo_symbol="ETH-USD"),
Asset("SOL-USD", "Solana", "crypto", ccxt_symbol="SOL/USDT", yahoo_symbol="SOL-USD"),
Asset("SPY", "S&P 500 ETF", "equity", yahoo_symbol="SPY",
stooq_symbol="spy.us", tiingo_symbol="SPY"),
Asset("QQQ", "Nasdaq 100 ETF", "equity", yahoo_symbol="QQQ",
stooq_symbol="qqq.us", tiingo_symbol="QQQ"),
Asset("NVDA", "NVIDIA", "equity", yahoo_symbol="NVDA",
stooq_symbol="nvda.us", tiingo_symbol="NVDA"),
]
}
@dataclass(frozen=True)
class Timeframe:
slug: str
pandas_freq: str
minutes: int
bars_per_year: float
ccxt_tf: str | None = None
yahoo_interval: str | None = None
# Provider-imposed history depth, in days. None = no practical limit.
# These are honest coverage boundaries, not errors (see data.py).
yahoo_max_days: int | None = None
TIMEFRAMES: dict[str, Timeframe] = {
t.slug: t
for t in [
Timeframe("1d", "D", 1440, 365.0, ccxt_tf="1d", yahoo_interval="1d"),
Timeframe("1h", "h", 60, 365.0 * 24, ccxt_tf="1h", yahoo_interval="1h",
yahoo_max_days=730),
Timeframe("15m", "15min", 15, 365.0 * 24 * 4, ccxt_tf="15m",
yahoo_interval="15m", yahoo_max_days=60),
]
}
# Equities only trade during market hours, so a calendar year holds far fewer
# bars than the wall-clock math above. Annualisation uses these instead.
EQUITY_BARS_PER_YEAR = {"1d": 252.0, "1h": 252.0 * 6.5, "15m": 252.0 * 26.0}
def bars_per_year(asset_slug: str, tf_slug: str) -> float:
"""Annualisation factor for Sharpe/CAGR, respecting market calendars."""
asset = ASSETS.get(asset_slug)
if asset is not None and asset.kind == "equity":
return EQUITY_BARS_PER_YEAR[tf_slug]
return TIMEFRAMES[tf_slug].bars_per_year
# --------------------------------------------------------------------------
# Provider chain (config, not code -- data.py walks these in order)
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class ProviderSpec:
name: str
kinds: tuple[str, ...]
# Minimum seconds between calls, and backoff schedule on failure.
min_interval_s: float = 0.25
max_retries: int = 4
backoff_base_s: float = 1.5
requires_env: str | None = None
PROVIDER_CHAIN: tuple[ProviderSpec, ...] = (
ProviderSpec("binance", ("crypto",), min_interval_s=0.10),
ProviderSpec("coinbase", ("crypto",), min_interval_s=0.35),
ProviderSpec("yfinance", ("equity",), min_interval_s=1.20),
ProviderSpec("stooq", ("equity",), min_interval_s=1.00),
ProviderSpec("tiingo", ("equity",), min_interval_s=0.60, requires_env="TIINGO_KEY"),
)
def providers_for(kind: str) -> list[ProviderSpec]:
"""Ordered, currently-usable providers for an asset kind."""
out = []
for p in PROVIDER_CHAIN:
if kind not in p.kinds:
continue
if p.requires_env and not os.environ.get(p.requires_env):
continue
out.append(p)
return out
# --------------------------------------------------------------------------
# Models
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class ModelSpec:
slug: str # store path segment
model_id: str # HF model id
family: str # adapter family
display: str
context_len: int = 512
quantile_levels: tuple[float, ...] = (0.1, 0.5, 0.9)
SEED_MODELS: dict[str, ModelSpec] = {
m.slug: m
for m in [
# Chronos-Bolt: the fast encoder-decoder family. All four sizes share one
# adapter, so comparing them isolates model capacity from everything else.
ModelSpec("chronos-bolt-tiny", "amazon/chronos-bolt-tiny", "chronos",
"Chronos-Bolt Tiny", context_len=512),
ModelSpec("chronos-bolt-mini", "amazon/chronos-bolt-mini", "chronos",
"Chronos-Bolt Mini", context_len=512),
ModelSpec("chronos-bolt-small", "amazon/chronos-bolt-small", "chronos",
"Chronos-Bolt Small", context_len=512),
ModelSpec("chronos-bolt-base", "amazon/chronos-bolt-base", "chronos",
"Chronos-Bolt Base", context_len=512),
# Original Chronos (T5-based, sampling rather than direct quantiles).
ModelSpec("chronos-t5-small", "amazon/chronos-t5-small", "chronos",
"Chronos T5 Small", context_len=512),
# Chronos-2. Loads through the same adapter and the already-pinned
# chronos-forecasting 2.3.1, but `predict_quantiles` returns a list of
# per-item tensors rather than one stacked tensor -- see
# `ChronosAdapter._to_array`. Seedable, so it costs GPU quota on the
# next seed run; drop it from SEEDABLE_MODELS if that is not wanted yet.
ModelSpec("chronos-2", "amazon/chronos-2", "chronos",
"Chronos-2", context_len=512),
# Naive baselines, deliberately first-class. A forecasting model that
# cannot beat "tomorrow looks like today" is not worth deploying, and
# the leaderboard should make that impossible to miss.
ModelSpec("baseline-naive", "baseline/naive", "baseline",
"Baseline 路 Random walk", context_len=128),
ModelSpec("baseline-drift", "baseline/drift", "baseline",
"Baseline 路 Drift", context_len=128),
ModelSpec("baseline-seasonal", "baseline/seasonal", "baseline",
"Baseline 路 Seasonal naive", context_len=128),
# Registered but unseeded: the timesfm package is heavy and optional.
ModelSpec("timesfm-2-500m", "google/timesfm-2.0-500m-pytorch", "timesfm",
"TimesFM 2.0 500M", context_len=512),
]
}
# Models the seed plan actually runs. TimesFM is excluded until its dependency
# is pinned in requirements.txt.
SEEDABLE_MODELS = tuple(k for k in SEED_MODELS if not k.startswith("timesfm"))
BASELINE_MODELS = tuple(k for k, v in SEED_MODELS.items() if v.family == "baseline")
def is_baseline(model_slug: str) -> bool:
return model_slug in BASELINE_MODELS
# Adapter families a user may pick from in the "Add model" flow. Restricting to
# a fixed set is what keeps arbitrary model code from ever being executed.
ALLOWED_ADAPTER_FAMILIES = ("chronos", "timesfm", "baseline")
# --------------------------------------------------------------------------
# Guardrails for user-funded coverage extension
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class ExtensionCaps:
max_days: dict[str, int] = field(
default_factory=lambda: {"1d": 730, "1h": 183, "15m": 62}
)
max_steps_per_run: int = 4000
smoke_test_steps: int = 100
CAPS = ExtensionCaps()
# --------------------------------------------------------------------------
# Backtest defaults
# --------------------------------------------------------------------------
DEFAULT_INIT_CASH = 10_000.0
DEFAULT_COMMISSION_BPS = 10.0 # per side
DEFAULT_SLIPPAGE_BPS = 5.0
DEFAULT_HOLDOUT_MONTHS = 6
# In-process LRU sizing for parquet slices (Phase 3 perf target: <2s runs).
PARQUET_CACHE_SIZE = 64
DISCLAIMER = (
"Backtested results are hypothetical, derived from historical data, and are "
"not indicative of future results. Nothing here is investment advice. "
"The Bit Trading Company is not a licensed investment adviser."
)
|