Bit-Trading-Company's picture
CI deploy local
8028640 verified
Raw
History Blame Contribute Delete
8.88 kB
"""Naive baselines, deliberately first-class citizens of the Arena.
A foundation model that cannot out-forecast "tomorrow looks like today" is not
worth the GPU time, and a leaderboard that quietly omits that comparison is
flattering rather than useful. So the baselines are enrolled, archived and
graded on exactly the same footing as everything else, and they appear on the
standings table next to the models they embarrass.
They are also the only family that runs with no weights, no network and no
torch, which makes them the fixture the test suite reasons about: capability
gating, determinism and coverage arithmetic are all checked here first, where
the expected answer can be worked out by hand.
`random-walk` is the reference. Its band comes from the empirical distribution
of historical log returns, scaled by sqrt(h) -- which is the correct widening
for a driftless random walk and is why a well-calibrated foundation model
should be able to beat it on error but will struggle to beat it on coverage.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from .. import config
from .base import (OHLCV_COLUMNS, OUTPUT_OHLCV_PATHS, OUTPUT_QUANTILE_LINE,
AdapterError, Capabilities, ForecastAdapter,
ForecastResult, check_context)
# `kind` -> how the median path is built, and what the adapter emits.
BASELINE_MODELS = {
"baseline/random-walk": {
"kind": "naive",
"output": OUTPUT_QUANTILE_LINE,
"display": "Random walk",
},
"baseline/drift": {
"kind": "drift",
"output": OUTPUT_QUANTILE_LINE,
"display": "Drift",
},
# A bootstrap over historical candles, so there is at least one path-
# emitting reference to hold Kronos's ghost paths against.
"baseline/bootstrap": {
"kind": "bootstrap",
"output": OUTPUT_OHLCV_PATHS,
"display": "Block bootstrap",
},
}
# How much history the return distribution is estimated from.
LOOKBACK = 256
class BaselineAdapter(ForecastAdapter):
"""Closed-form and bootstrap baselines. No weights, no network."""
family = "baseline"
adapter_version = "1"
def __init__(self, model_id: str, revision: str | None = None,
device: str | None = None, hardware: str | None = None):
super().__init__(model_id, revision=revision, device=device)
spec = BASELINE_MODELS.get(model_id)
if spec is None:
raise AdapterError(
f"unknown baseline {model_id!r}; known: {sorted(BASELINE_MODELS)}")
self.kind = spec["kind"]
self._output = spec["output"]
def capabilities(self) -> Capabilities:
return Capabilities(
output=self._output,
hardware="cpu",
max_context=LOOKBACK,
asset_generality="general",
seedable_natively=True,
)
def component_versions(self) -> dict[str, str]:
# No revision to pin: the model *is* this file.
return {"model": self.model_id, "kind": self.kind, "lookback": str(LOOKBACK)}
def resolve_revision(self) -> str:
self._resolved_revision = "builtin"
return self._resolved_revision
def load(self, model_id: str | None = None, revision: str | None = None):
if model_id and model_id != self.model_id:
spec = BASELINE_MODELS.get(model_id)
if spec is None:
raise AdapterError(f"unknown baseline {model_id!r}")
self.model_id = model_id
self.kind = spec["kind"]
self._output = spec["output"]
self.resolve_revision()
self._model = self.kind
return self
def predict(self, context_ohlcv: pd.DataFrame, horizon: int,
n_samples: int = config.DEFAULT_N_SAMPLES, seed: int = 0,
issued_ts: pd.Timestamp | None = None) -> ForecastResult:
check_context(context_ohlcv, issued_ts=issued_ts)
if horizon < 1:
raise AdapterError("horizon must be at least 1")
if self._model is None:
self.load()
ctx = self._trim(context_ohlcv)
close = ctx["close"].to_numpy(dtype="float64")
last = float(close[-1])
log_returns = np.diff(np.log(close))
if len(log_returns) < 8:
raise AdapterError("need at least 9 bars to estimate a return distribution")
steps = np.arange(1, horizon + 1, dtype="float64")
# A local RNG, not the global one: a baseline must not be able to
# perturb the sampling of whatever model runs after it in a matchup.
rng = np.random.default_rng(int(seed))
if self.kind == "bootstrap":
paths = self._bootstrap_paths(ctx, horizon, n_samples, rng)
close_paths = paths[:, :, OHLCV_COLUMNS.index("close")]
quantiles = self._quantiles_from_paths(close_paths)
return ForecastResult(
quantiles=quantiles, levels=config.QUANTILE_LEVELS,
horizon=horizon, context_len=len(ctx),
inference_version=self.inference_version(),
seed=int(seed), n_samples=int(n_samples), paths=paths,
)
# Analytic band. The median is flat for `naive` and extends the mean
# historical return for `drift`; the width is the empirical return
# quantile scaled by sqrt(h), which is the random walk's own spread.
mu = float(log_returns.mean()) if self.kind == "drift" else 0.0
sigma = float(log_returns.std(ddof=1))
cols = []
for level in config.QUANTILE_LEVELS:
# Normal quantile via the error function's inverse, so scipy is not
# a dependency for the one family that has no dependencies.
z = _norm_ppf(level)
cols.append(last * np.exp(mu * steps + z * sigma * np.sqrt(steps)))
quantiles = np.stack(cols, axis=1)
return ForecastResult(
quantiles=quantiles, levels=config.QUANTILE_LEVELS,
horizon=horizon, context_len=len(ctx),
inference_version=self.inference_version(),
seed=int(seed), n_samples=int(n_samples), paths=None,
)
def _bootstrap_paths(self, ctx: pd.DataFrame, horizon: int,
n_samples: int, rng) -> np.ndarray:
"""Resample historical candles in blocks, chained off the last close.
Blocks rather than single bars, because independent draws destroy the
volatility clustering that makes a market path look like a market path.
"""
frame = ctx[list(OHLCV_COLUMNS)].to_numpy(dtype="float64")
close = frame[:, OHLCV_COLUMNS.index("close")]
log_returns = np.diff(np.log(close))
# Per-bar shape: how open/high/low/volume sat relative to that bar's
# close. Resampling the shape keeps the sampled candles plausible.
ratios = frame[1:, :4] / close[1:, None]
volumes = frame[1:, OHLCV_COLUMNS.index("volume")]
block = max(1, min(8, len(log_returns) // 4))
n = len(log_returns)
paths = np.empty((n_samples, horizon, len(OHLCV_COLUMNS)), dtype="float64")
for s in range(n_samples):
picked_r, picked_i = [], []
while len(picked_r) < horizon:
start = int(rng.integers(0, max(1, n - block)))
for j in range(start, min(start + block, n)):
picked_r.append(log_returns[j])
picked_i.append(j)
picked_r = np.asarray(picked_r[:horizon])
picked_i = np.asarray(picked_i[:horizon])
closes = float(close[-1]) * np.exp(np.cumsum(picked_r))
shape = ratios[picked_i]
paths[s, :, 0] = closes * shape[:, 0] # open
paths[s, :, 1] = closes * shape[:, 1] # high
paths[s, :, 2] = closes * shape[:, 2] # low
paths[s, :, 3] = closes # close
paths[s, :, 4] = volumes[picked_i]
# Keep the sampled candle self-consistent, same rule as Kronos.
body_hi = np.maximum(paths[:, :, 0], paths[:, :, 3])
body_lo = np.minimum(paths[:, :, 0], paths[:, :, 3])
paths[:, :, 1] = np.maximum(paths[:, :, 1], body_hi)
paths[:, :, 2] = np.minimum(paths[:, :, 2], body_lo)
return paths
def _norm_ppf(p: float) -> float:
"""Inverse standard normal CDF, via the inverse error function."""
from math import erf, sqrt
if not 0.0 < p < 1.0:
raise AdapterError(f"quantile level {p} out of range")
# Bisection: exact enough (1e-12) and keeps this file dependency-free.
lo, hi = -12.0, 12.0
for _ in range(200):
mid = (lo + hi) / 2.0
if 0.5 * (1.0 + erf(mid / sqrt(2.0))) < p:
lo = mid
else:
hi = mid
return (lo + hi) / 2.0