File size: 16,679 Bytes
8028640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279522c
8028640
 
 
279522c
 
 
 
 
 
 
 
 
 
 
 
 
 
8028640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ac20a54
279522c
ac20a54
 
 
 
 
279522c
ac20a54
 
 
 
 
 
8028640
 
 
 
 
279522c
8028640
 
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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
"""The adapter interface every forecasting model is reached through.

Three ideas carry most of the weight here.

**Capabilities are declared, not inferred.** An adapter says what kind of
output it produces, what hardware it needs, and how much context it can take.
The UI renders strictly from that declaration, so there is no place outside
this package where a model is special-cased by name. Adding a family is a new
module plus a registry entry, never an `if model == ...` in a renderer.

**Seeding is part of the interface.** `predict` takes a seed and is required to
be bit-reproducible under it. Some upstream models expose no seed argument at
all; those are wrapped so that their sampling is seeded anyway. A forecast that
cannot be reproduced cannot be audited, and an archive of unauditable forecasts
is not a track record.

**Contexts are validated, not trusted.** `check_context` runs on every call. It
is the structural guarantee behind the no-lookahead rule: a context bar dated
after the issue timestamp raises rather than quietly producing a forecast that
would look brilliant.
"""

from __future__ import annotations

import hashlib
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field

import numpy as np
import pandas as pd

from .. import config

log = logging.getLogger("arena.adapters")

OHLCV_COLUMNS = ("open", "high", "low", "close", "volume")

# Output kinds. `ohlcv_paths` models emit whole sampled candlestick paths and
# can drive the ghost-path and dispersion views; `quantile_line` models emit a
# band on the close and nothing else.
OUTPUT_QUANTILE_LINE = "quantile_line"
OUTPUT_OHLCV_PATHS = "ohlcv_paths"
OUTPUT_KINDS = (OUTPUT_QUANTILE_LINE, OUTPUT_OHLCV_PATHS)

HARDWARE_CPU = "cpu"
HARDWARE_GPU = "gpu"
HARDWARE_TIERS = (HARDWARE_CPU, HARDWARE_GPU)


class AdapterError(RuntimeError):
    """Anything an adapter refuses to do."""


class ModelNotAllowed(AdapterError):
    """The requested adapter family is not on the allow-list."""


class LookaheadError(AdapterError):
    """A context window reached past the moment the forecast is issued."""


class ContextError(AdapterError):
    """The context window is unusable -- gaps, NaNs, or too short."""


# --------------------------------------------------------------------------
# Capabilities
# --------------------------------------------------------------------------


@dataclass(frozen=True)
class Capabilities:
    """What a model can do, as the model itself declares it.

    `asset_generality` is a plain-language claim about training scope, not a
    quality score: "financial" means the model was pre-trained on market data,
    "general" means arbitrary time series. It drives one honest caveat in the
    UI and nothing else.
    """

    output: str
    hardware: str
    max_context: int
    asset_generality: str = "general"
    seedable_natively: bool = True

    def __post_init__(self):
        if self.output not in OUTPUT_KINDS:
            raise AdapterError(f"unknown output kind {self.output!r}")
        if self.hardware not in HARDWARE_TIERS:
            raise AdapterError(f"unknown hardware tier {self.hardware!r}")
        if self.max_context < 1:
            raise AdapterError("max_context must be positive")

    @property
    def emits_paths(self) -> bool:
        return self.output == OUTPUT_OHLCV_PATHS

    def as_dict(self) -> dict:
        return {
            "output": self.output,
            "hardware": self.hardware,
            "max_context": self.max_context,
            "asset_generality": self.asset_generality,
            "seedable_natively": self.seedable_natively,
        }


# --------------------------------------------------------------------------
# Result
# --------------------------------------------------------------------------


@dataclass
class ForecastResult:
    """One multi-step forecast.

    `quantiles` is (horizon, n_levels) over the *close* price, always present.
    `paths` is (n_samples, horizon, 5) of OHLCV and is present only for
    `ohlcv_paths` models -- the UI checks the capability, never this field, so
    that a model which merely happens to return paths cannot change how it is
    rendered.
    """

    quantiles: np.ndarray
    levels: tuple[float, ...]
    horizon: int
    context_len: int
    inference_version: str
    seed: int
    n_samples: int
    paths: np.ndarray | None = None

    def __post_init__(self):
        self.quantiles = np.asarray(self.quantiles, dtype="float64")
        if self.quantiles.shape != (self.horizon, len(self.levels)):
            raise AdapterError(
                f"quantiles shape {self.quantiles.shape} != "
                f"{(self.horizon, len(self.levels))}"
            )
        if not np.all(np.isfinite(self.quantiles)):
            raise AdapterError("forecast contains non-finite values")
        # Quantiles must not cross. Sorting along the level axis is the honest
        # repair for the tiny numerical inversions that sampling produces, and
        # it is what makes the coverage arithmetic downstream well-defined.
        self.quantiles = np.sort(self.quantiles, axis=1)
        if self.paths is not None:
            self.paths = np.asarray(self.paths, dtype="float64")
            if self.paths.ndim != 3 or self.paths.shape[1] != self.horizon:
                raise AdapterError(f"paths shape {self.paths.shape} is not (n, {self.horizon}, k)")

    def level_index(self, level: float) -> int:
        for i, lv in enumerate(self.levels):
            if abs(lv - level) < 1e-9:
                return i
        raise AdapterError(f"level {level} not in {self.levels}")

    def band(self, low: float = 0.1, high: float = 0.9):
        return (self.quantiles[:, self.level_index(low)],
                self.quantiles[:, self.level_index(high)])

    def median(self) -> np.ndarray:
        return self.quantiles[:, self.level_index(0.5)]

    def dispersion(self) -> np.ndarray:
        """Per-step spread, as a fraction of the median.

        Sampled-path standard deviation where paths exist, band half-width
        otherwise. Both are a width; they are not the same statistic, and the
        UI labels which one it is showing.
        """
        med = np.abs(self.median()) + 1e-12
        if self.paths is not None:
            close = self.paths[:, :, OHLCV_COLUMNS.index("close")]
            return close.std(axis=0) / med
        lo, hi = self.band()
        return (hi - lo) / 2.0 / med


# --------------------------------------------------------------------------
# Context validation
# --------------------------------------------------------------------------


def check_context(context: pd.DataFrame, issued_ts: pd.Timestamp | None = None,
                  min_len: int = 32) -> pd.DataFrame:
    """Validate a context window, or raise.

    This is the structural half of the no-lookahead guarantee. It is not a
    convention that callers are asked to honour: every adapter runs it on every
    call, so a forecast issued from data it should not have seen fails loudly
    at the point of use.
    """
    if not isinstance(context, pd.DataFrame):
        raise ContextError("context must be a DataFrame")

    missing = [c for c in OHLCV_COLUMNS if c not in context.columns]
    if missing:
        raise ContextError(f"context is missing columns {missing}")

    if len(context) < min_len:
        raise ContextError(f"context has {len(context)} bars, need at least {min_len}")

    if "ts" not in context.columns:
        raise ContextError("context must carry a 'ts' column")

    ts = pd.to_datetime(context["ts"], utc=True)
    if ts.isna().any():
        raise ContextError("context has unparseable timestamps")
    if not ts.is_monotonic_increasing:
        raise ContextError("context timestamps are not sorted ascending")
    if ts.duplicated().any():
        raise ContextError("context has duplicate timestamps")

    prices = context[list(OHLCV_COLUMNS)]
    if not np.isfinite(prices.to_numpy(dtype="float64")).all():
        raise ContextError("context contains NaN or infinite values")
    if (context[["open", "high", "low", "close"]].to_numpy(dtype="float64") <= 0).any():
        raise ContextError("context contains non-positive prices")

    # A hole in the cache means the model sees two bars as adjacent when they
    # are weeks apart, and produces a confident wrong answer from it.
    #
    # But "irregular spacing" is not the same as "missing data". An hourly
    # equity series closes for ~17 hours every night and ~65 hours every
    # weekend; those gaps are the market, not the cache. Judging them against
    # the modal bar rejected every SPY and NVDA hourly context outright.
    #
    # So the threshold adapts to the series' own gap distribution: a session
    # boundary recurs and therefore sits inside the 99th percentile, while a
    # genuinely absent period stands outside it.
    if len(ts) > 20:
        deltas = ts.diff().dropna()
        modal = deltas.mode()
        if len(modal):
            step = modal.iloc[0]
            p99 = deltas.quantile(0.99)

            # Any *single* gap far beyond the series' own worst regular one is
            # a hole, however few there are. This is the check that survives a
            # series where absence has become the pattern -- an adaptive
            # threshold alone would quietly normalise that.
            hard = max(step * 24, p99 * 5)
            worst = deltas.max()
            if worst > hard:
                raise ContextError(
                    f"context has a {worst} gap, far beyond its own session "
                    f"pattern; the cache is incomplete for this window"
                )

            # And an accumulation of smaller anomalies is a hole too.
            ceiling = max(step * 4, p99 * 1.5)
            bad = deltas[deltas > ceiling]
            if len(bad) > max(2, len(ts) // 50):
                raise ContextError(
                    f"context has {len(bad)} gaps beyond its own session pattern "
                    f"(> {ceiling}); the cache is incomplete for this window"
                )

    if issued_ts is not None:
        issued = pd.Timestamp(issued_ts)
        if issued.tzinfo is None:
            issued = issued.tz_localize("UTC")
        last = ts.iloc[-1]
        if last > issued:
            raise LookaheadError(
                f"context ends at {last.isoformat()}, after issued_ts "
                f"{issued.isoformat()}: a forecast may only see data at or "
                f"before the moment it is issued"
            )

    return context


# --------------------------------------------------------------------------
# Determinism
# --------------------------------------------------------------------------


def seed_everything(seed: int) -> None:
    """Pin every RNG an adapter might reach for.

    Seeding numpy alone is not enough: the sampling models draw through torch,
    and Kronos in particular calls `torch.multinomial` with no seed argument of
    its own. This is what makes "same inputs and seed produce bit-identical
    output" true rather than aspirational.
    """
    seed = int(seed) % (2 ** 31 - 1)
    np.random.seed(seed)
    try:
        import random

        random.seed(seed)
    except Exception:  # pragma: no cover
        pass
    try:
        import torch

        torch.manual_seed(seed)
        if torch.cuda.is_available():
            torch.cuda.manual_seed_all(seed)
    except ImportError:
        pass


# --------------------------------------------------------------------------
# The interface
# --------------------------------------------------------------------------


class ForecastAdapter(ABC):
    """Uniform interface over multi-step probabilistic forecasters."""

    family: str = "base"
    # Bumped when this adapter's own logic changes the numbers it produces.
    adapter_version: str = "1"

    def __init__(self, model_id: str, revision: str | None = None,
                 device: str | None = None):
        self.model_id = model_id
        self.revision = revision
        self._device = device
        self._model = None
        self._resolved_revision: str | None = None

    @property
    def device(self) -> str:
        """Resolved on first use, never at construction.

        On ZeroGPU, `torch.cuda.is_available()` raises unless it is called
        inside a `@spaces.GPU` function -- so probing the device eagerly made
        merely *constructing* an adapter fatal, including for CPU-tier models
        that never wanted a GPU. Deferring it means construction is free and
        the probe happens inside the GPU call, where it is legal.
        """
        if self._device is None:
            self._device = default_device()
        return self._device

    # -- interface --------------------------------------------------------

    @abstractmethod
    def load(self, model_id: str | None = None, revision: str | None = None) -> "ForecastAdapter":
        """Materialise the model. Idempotent."""

    @abstractmethod
    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:
        """Forecast `horizon` bars ahead from `context_ohlcv`.

        Must be bit-reproducible in `seed`.
        """

    @abstractmethod
    def capabilities(self) -> Capabilities:
        """What this model can do. The UI renders from this and nothing else."""

    # -- shared -----------------------------------------------------------

    def component_versions(self) -> dict[str, str]:
        """Every external thing whose version changes the output.

        Overridden by families that load a companion repo (Kronos ships its
        tokenizer separately) or depend on an inference package whose version
        moves the numbers.
        """
        return {"model": f"{self.model_id}@{self.resolved_revision}"}

    def inference_version(self) -> str:
        """Identity of everything that determines the output values.

        A short hash rather than the full component list, because it is written
        onto every archived row; the components themselves are recorded once in
        the registry so the hash can always be expanded.
        """
        parts = [config.INFERENCE_VERSION, self.family, self.adapter_version]
        parts += [f"{k}={v}" for k, v in sorted(self.component_versions().items())]
        digest = hashlib.sha256("|".join(parts).encode()).hexdigest()[:12]
        return f"{config.INFERENCE_VERSION}+{self.family}.{digest}"

    @property
    def resolved_revision(self) -> str:
        return self._resolved_revision or self.revision or "unpinned"

    def resolve_revision(self) -> str:
        """Pin the model to an immutable commit sha before any inference runs.

        A floating `main` would mean two forecasts a week apart are not
        comparable and neither is reproducible, which defeats the archive.
        """
        if self._resolved_revision:
            return self._resolved_revision
        from huggingface_hub import HfApi

        info = HfApi().model_info(self.model_id, revision=self.revision)
        self._resolved_revision = info.sha
        return self._resolved_revision

    # -- helpers for subclasses -------------------------------------------

    def _trim(self, context: pd.DataFrame) -> pd.DataFrame:
        """Cut a context down to what the model can actually attend over."""
        cap = self.capabilities().max_context
        return context.iloc[-cap:] if len(context) > cap else context

    @staticmethod
    def _quantiles_from_paths(paths_close: np.ndarray,
                              levels=config.QUANTILE_LEVELS) -> np.ndarray:
        """(n_samples, horizon) -> (horizon, n_levels)."""
        return np.quantile(paths_close, list(levels), axis=0).T


def default_device() -> str:
    """The device to run on, when the caller has not said.

    On ZeroGPU this never probes. `torch.cuda.is_available()` triggers a
    low-level CUDA init that ZeroGPU forbids outside a `@spaces.GPU` function,
    and it does not fail politely -- it takes the load down. Inside a GPU
    function the device is known to be cuda anyway, so `runtime` passes it
    explicitly and this is only the fallback for everywhere else.
    """
    try:
        import spaces  # noqa: F401 - presence is the signal

        return "cpu"
    except ImportError:
        pass
    try:
        import torch

        if torch.cuda.is_available():
            return "cuda"
    except Exception:
        pass
    return "cpu"