File size: 20,097 Bytes
46f1a78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27c0524
 
 
 
 
 
 
 
 
 
 
 
 
46f1a78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27c0524
 
 
 
 
 
 
 
 
389e1f7
27c0524
 
 
 
 
389e1f7
 
 
 
 
 
 
 
 
 
27c0524
 
389e1f7
 
27c0524
 
46f1a78
 
 
 
 
 
 
 
 
 
389e1f7
 
 
 
 
 
27c0524
 
 
 
 
 
 
 
 
 
389e1f7
27c0524
 
 
 
46f1a78
 
 
389e1f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
"""Forecast model adapters.

Adding a model means giving a model id and naming an adapter *family*. The
family list is fixed in `config.ALLOWED_ADAPTER_FAMILIES`; nothing here ever
imports, downloads or executes code chosen by a user. A user-supplied model id
is loaded only through an already-vetted family's loader, and `trust_remote_code`
is never enabled.

Every adapter reports an `inference_version` that pins the adapter's own logic
alongside the model revision, so a stored slice can always be traced back to
exactly what produced it.
"""

from __future__ import annotations

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

import numpy as np
import pandas as pd

from . import config

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

DEFAULT_QUANTILES = (0.1, 0.5, 0.9)


class AdapterError(RuntimeError):
    pass


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


@dataclass
class Forecast:
    """One horizon-1 forecast per input window."""

    q10: np.ndarray
    q50: np.ndarray
    q90: np.ndarray
    context_len: int

    def __post_init__(self):
        if not (len(self.q10) == len(self.q50) == len(self.q90)):
            raise AdapterError("quantile arrays have mismatched lengths")

    def as_frame(self, ts: pd.DatetimeIndex, inference_version: str) -> pd.DataFrame:
        if len(ts) != len(self.q50):
            raise AdapterError(
                f"timestamp count {len(ts)} != forecast count {len(self.q50)}"
            )
        # Quantiles must not cross; sorting is the honest repair for tiny
        # numerical inversions and makes the store's validator pass.
        stacked = np.sort(np.vstack([self.q10, self.q50, self.q90]), axis=0)
        return pd.DataFrame({
            "ts": ts,
            "q10": stacked[0], "q50": stacked[1], "q90": stacked[2],
            "context_len": self.context_len,
            "inference_version": inference_version,
        })


class ForecastAdapter(ABC):
    """Uniform interface over quantile forecasters."""

    family: str = "base"
    adapter_version: str = "1"

    def __init__(self, model_id: str, revision: str | None = None,
                 context_len: int = 512, device: str | None = None):
        self.model_id = model_id
        self.revision = revision
        self.context_len = context_len
        self.device = device or _default_device()
        self._model = None
        self._resolved_revision: str | None = None

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

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

    @abstractmethod
    def predict(self, context_windows: np.ndarray) -> Forecast:
        """`context_windows` is (n_windows, context_len). Returns horizon-1 quantiles."""

    def inference_version(self) -> str:
        """Identity of everything that determines the output values."""
        rev = self._resolved_revision or self.revision or "unpinned"
        payload = f"{self.family}|{self.adapter_version}|{self.model_id}|{rev}|{self.context_len}"
        digest = hashlib.sha256(payload.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"

    # -- shared helpers ---------------------------------------------------

    def resolve_revision(self) -> str:
        """Pin the model to an immutable commit sha before any inference runs."""
        if self._resolved_revision:
            return self._resolved_revision
        try:
            from huggingface_hub import HfApi

            info = HfApi().model_info(self.model_id, revision=self.revision)
            self._resolved_revision = info.sha
        except Exception as e:
            log.warning("could not resolve revision for %s: %s", self.model_id, e)
            self._resolved_revision = self.revision or "unpinned"
        return self._resolved_revision


def _default_device() -> str:
    try:
        import torch

        if torch.cuda.is_available():
            return "cuda"
        if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
            return "mps"
    except Exception:
        pass
    return "cpu"


def _release(device: str) -> None:
    """Free accelerator memory between inference chunks."""
    try:
        import torch

        if device == "cuda":
            torch.cuda.empty_cache()
        elif device == "mps" and hasattr(torch, "mps"):
            torch.mps.empty_cache()
    except Exception:
        pass


# --------------------------------------------------------------------------
# Chronos / Chronos-Bolt
# --------------------------------------------------------------------------


class ChronosAdapter(ForecastAdapter):
    """amazon/chronos-* and amazon/chronos-bolt-* quantile forecasters."""

    family = "chronos"
    adapter_version = "1"

    def load(self, model_id: str | None = None, revision: str | None = None):
        if model_id:
            self.model_id = model_id
        if revision:
            self.revision = revision
        if self._model is not None:
            return self

        self.resolve_revision()
        try:
            from chronos import BaseChronosPipeline
        except ImportError as e:
            raise AdapterError(
                "chronos-forecasting is not installed; add it to requirements.txt"
            ) from e

        import torch

        dtype = torch.float32 if self.device in ("cpu", "mps") else torch.bfloat16
        self._model = BaseChronosPipeline.from_pretrained(
            self.model_id,
            revision=self._resolved_revision if self._resolved_revision != "unpinned" else None,
            device_map=self.device,
            torch_dtype=dtype,
        )
        return self

    # Bolt emits quantiles directly and is happy with wide batches. The
    # original T5 Chronos *samples* forecast paths instead, so its peak memory
    # is batch x num_samples x context and a wide batch OOMs a GPU outright.
    # Chunking here rather than at the call site means every caller -- seed
    # script, ZeroGPU function, tests -- is protected without knowing which
    # variant it holds.
    BOLT_CHUNK = 256
    T5_CHUNK = 16
    T5_NUM_SAMPLES = 20
    CHRONOS2_CHUNK = 128

    @property
    def _is_bolt(self) -> bool:
        return "bolt" in (self.model_id or "").lower()

    @property
    def _is_chronos2(self) -> bool:
        """Chronos-2 (`amazon/chronos-2`).

        Detected from the id rather than from the loaded pipeline class so the
        chunk size is known before the weights are fetched.
        """
        name = (self.model_id or "").lower()
        return "chronos-2" in name or "chronos2" in name

    @property
    def chunk_size(self) -> int:
        if self._is_chronos2:
            return self.CHRONOS2_CHUNK
        return self.BOLT_CHUNK if self._is_bolt else self.T5_CHUNK

    def predict(self, context_windows: np.ndarray) -> Forecast:
        if self._model is None:
            self.load()
        import torch

        ctx = np.asarray(context_windows, dtype="float32")
        if ctx.ndim == 1:
            ctx = ctx[None, :]

        q_levels = list(DEFAULT_QUANTILES)
        # Only the original T5 Chronos samples paths; Bolt and Chronos-2 emit
        # quantiles directly.
        if self._is_bolt or self._is_chronos2:
            extra = {}
        else:
            extra = {"num_samples": self.T5_NUM_SAMPLES}
        step = max(1, self.chunk_size)
        parts = []

        for start in range(0, len(ctx), step):
            block = ctx[start:start + step]
            tensors = [torch.tensor(row) for row in block]
            with torch.inference_mode():
                quantiles, _mean = self._model.predict_quantiles(
                    tensors, prediction_length=1, quantile_levels=q_levels, **extra,
                )
            parts.append(self._to_array(quantiles))
            del quantiles, tensors
            _release(self.device)

        arr = np.vstack(parts)
        return Forecast(q10=arr[:, 0], q50=arr[:, 1], q90=arr[:, 2],
                        context_len=ctx.shape[1])

    @staticmethod
    def _to_array(quantiles) -> np.ndarray:
        """Normalise a `predict_quantiles` result to `(batch, n_quantiles)`.

        The two shapes differ and the difference is not cosmetic:

          Bolt / T5      one stacked tensor, `(batch, horizon, quantiles)`
          Chronos-2      a *list* of per-item tensors, each
                         `(n_variates, horizon, quantiles)`

        Calling `.float()` on the list raises `AttributeError`, which is
        exactly what `amazon/chronos-2` did before this existed. Horizon is
        always 1 here, and these are univariate price series, so the leading
        variate axis is taken at index 0.
        """
        if isinstance(quantiles, (list, tuple)):
            rows = []
            for item in quantiles:
                array = ChronosAdapter._as_numpy(item)
                # (n_variates, horizon, quantiles) -> (quantiles,)
                while array.ndim > 1:
                    array = array[0]
                rows.append(array)
            return np.vstack(rows)

        return ChronosAdapter._as_numpy(quantiles)[:, 0, :]

    @staticmethod
    def _as_numpy(value) -> np.ndarray:
        """Tensor -> ndarray, by duck typing rather than an isinstance check.

        Deliberately does not import torch: the test suite runs offline with no
        GPU stack installed, and a shape-normalising helper that cannot be
        tested without a 2GB dependency would not get tested.
        """
        if hasattr(value, "float") and hasattr(value, "cpu"):
            return np.asarray(value.float().cpu().numpy())
        if isinstance(value, np.ndarray):
            return value
        raise AdapterError(
            f"unexpected predict_quantiles result: {type(value).__name__}")


# --------------------------------------------------------------------------
# TimesFM
# --------------------------------------------------------------------------


class TimesFMAdapter(ForecastAdapter):
    """google/timesfm-* forecasters.

    TimesFM returns a fixed quantile grid; the 0.1/0.5/0.9 columns are selected
    from it rather than re-derived, so the stored numbers are the model's own.
    """

    family = "timesfm"
    adapter_version = "1"
    _QUANTILE_GRID = (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9)

    def load(self, model_id: str | None = None, revision: str | None = None):
        if model_id:
            self.model_id = model_id
        if revision:
            self.revision = revision
        if self._model is not None:
            return self

        self.resolve_revision()
        try:
            import timesfm
        except ImportError as e:
            raise AdapterError(
                "timesfm is not installed; add it to requirements.txt to enable "
                "this adapter family"
            ) from e

        backend = {"cuda": "gpu", "mps": "cpu"}.get(self.device, "cpu")
        self._model = timesfm.TimesFm(
            hparams=timesfm.TimesFmHparams(
                backend=backend, per_core_batch_size=32,
                context_len=self.context_len, horizon_len=1,
            ),
            checkpoint=timesfm.TimesFmCheckpoint(huggingface_repo_id=self.model_id),
        )
        return self

    def predict(self, context_windows: np.ndarray) -> Forecast:
        if self._model is None:
            self.load()

        ctx = np.asarray(context_windows, dtype="float32")
        if ctx.ndim == 1:
            ctx = ctx[None, :]

        _point, quantile_out = self._model.forecast(
            [row for row in ctx], freq=[0] * len(ctx)
        )
        arr = np.asarray(quantile_out)[:, 0, :]  # horizon 1
        grid = list(self._QUANTILE_GRID)
        # Column 0 of TimesFM's output is the mean; quantiles follow.
        offset = arr.shape[1] - len(grid)
        i10, i50, i90 = (grid.index(q) + offset for q in DEFAULT_QUANTILES)
        return Forecast(q10=arr[:, i10], q50=arr[:, i50], q90=arr[:, i90],
                        context_len=ctx.shape[1])


# --------------------------------------------------------------------------
# Naive baselines
# --------------------------------------------------------------------------


class BaselineAdapter(ForecastAdapter):
    """Classical forecasting baselines, exposed as first-class models.

    These exist so the leaderboard always carries a floor. A large pretrained
    forecaster that cannot beat "tomorrow looks like today" has not earned its
    inference cost, and burying that comparison would be the single easiest way
    to make this whole Space misleading.

    The method is selected by the model id: `baseline/naive`, `baseline/drift`,
    or `baseline/seasonal`. All three are deterministic and causal -- each uses
    only the trailing window, which ends at the bar being forecast.

    The interval is a Gaussian band around the point forecast, scaled by the
    window's own realised volatility, so coverage is comparable with a
    learned model's q10-q90 rather than arbitrarily wide.
    """

    family = "baseline"
    adapter_version = "1"
    Z90 = 1.2815515655446004  # normal 90th percentile

    def __init__(self, model_id: str, revision: str | None = None,
                 context_len: int = 128, device: str | None = None,
                 season: int = 7):
        super().__init__(model_id, revision, context_len, device)
        self.season = season
        self.method = (model_id.split("/")[-1] or "naive").lower()
        if self.method not in ("naive", "drift", "seasonal"):
            raise AdapterError(
                f"unknown baseline method {self.method!r}; "
                "use baseline/naive, baseline/drift or baseline/seasonal"
            )

    def load(self, model_id: str | None = None, revision: str | None = None):
        if model_id:
            self.model_id = model_id
            self.method = model_id.split("/")[-1].lower()
        # Baselines have no weights, so the "revision" is the adapter's own
        # version -- still pinned, still reproducible.
        self._resolved_revision = f"baseline-{self.adapter_version}"
        return self

    def resolve_revision(self) -> str:
        self._resolved_revision = f"baseline-{self.adapter_version}"
        return self._resolved_revision

    def predict(self, context_windows: np.ndarray) -> Forecast:
        ctx = np.asarray(context_windows, dtype="float64")
        if ctx.ndim == 1:
            ctx = ctx[None, :]
        last = ctx[:, -1]

        if self.method == "naive":
            point = last
        elif self.method == "drift":
            # Average per-step change across the window, added once.
            steps = ctx.shape[1] - 1
            point = last + (ctx[:, -1] - ctx[:, 0]) / max(steps, 1)
        else:  # seasonal
            k = min(self.season, ctx.shape[1])
            point = ctx[:, -k]

        # One-step-ahead uncertainty from the window's own step volatility.
        sigma = np.std(np.diff(ctx, axis=1), axis=1)
        sigma = np.where(np.isfinite(sigma) & (sigma > 0), sigma,
                         np.abs(last) * 1e-4)
        band = self.Z90 * sigma
        return Forecast(q10=point - band, q50=point, q90=point + band,
                        context_len=ctx.shape[1])


# --------------------------------------------------------------------------
# Placeholder (no GPU / no model available)
# --------------------------------------------------------------------------


class PlaceholderAdapter(ForecastAdapter):
    """Structurally identical synthetic output, permanently labelled.

    Exists so the UI has complete shape before every cell has real coverage.
    Its `inference_version` is the literal string `PLACEHOLDER`, which the store
    and the UI both key off to mark the data as not-real. It is deterministic:
    the same window always yields the same numbers.
    """

    family = "placeholder"
    adapter_version = "1"

    def load(self, model_id: str | None = None, revision: str | None = None):
        if model_id:
            self.model_id = model_id
        self._resolved_revision = "PLACEHOLDER"
        return self

    def inference_version(self) -> str:
        return config.PLACEHOLDER_VERSION

    def predict(self, context_windows: np.ndarray) -> Forecast:
        ctx = np.asarray(context_windows, dtype="float64")
        if ctx.ndim == 1:
            ctx = ctx[None, :]

        last = ctx[:, -1]
        # Deterministic pseudo-drift seeded by the window itself, plus a band
        # scaled to that window's realised volatility.
        seedvals = np.abs(np.sum(ctx[:, -8:], axis=1) * 1e6).astype("int64")
        drift = np.array([
            (np.random.default_rng(int(s) % (2**32)).normal(0.0, 1.0)) for s in seedvals
        ])
        vol = np.std(np.diff(ctx, axis=1), axis=1) / np.maximum(np.abs(last), 1e-9)
        vol = np.clip(vol, 1e-4, 0.2)

        q50 = last * (1.0 + 0.25 * drift * vol)
        band = last * vol * 1.28
        return Forecast(q10=q50 - band, q50=q50, q90=q50 + band,
                        context_len=ctx.shape[1])


# --------------------------------------------------------------------------
# Factory
# --------------------------------------------------------------------------

_FAMILIES: dict[str, type[ForecastAdapter]] = {
    "chronos": ChronosAdapter,
    "timesfm": TimesFMAdapter,
    "baseline": BaselineAdapter,
    "placeholder": PlaceholderAdapter,
}


def get_adapter(family: str, model_id: str, revision: str | None = None,
                context_len: int = 512, device: str | None = None) -> ForecastAdapter:
    """Build an adapter. Only allow-listed families are constructible."""
    fam = (family or "").strip().lower()
    if fam == "placeholder":
        return PlaceholderAdapter(model_id, revision, context_len, device)
    if fam not in config.ALLOWED_ADAPTER_FAMILIES:
        raise ModelNotAllowed(
            f"adapter family {family!r} is not allowed; pick one of "
            f"{list(config.ALLOWED_ADAPTER_FAMILIES)}"
        )
    return _FAMILIES[fam](model_id, revision, context_len, device)


def validate_model_id(model_id: str) -> str:
    """Reject anything that is not a plain `owner/name` Hub id."""
    mid = (model_id or "").strip()
    if not mid or mid.count("/") != 1:
        raise AdapterError(f"{model_id!r} is not a valid Hub model id (owner/name)")
    owner, name = mid.split("/")
    ok = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.")
    if not owner or not name or set(mid) - ok - {"/"}:
        raise AdapterError(f"{model_id!r} contains characters that are not allowed")
    if ".." in mid:
        raise AdapterError("model id may not contain '..'")
    return mid


# --------------------------------------------------------------------------
# Rolling-window construction (shared by the seed script and the GPU path)
# --------------------------------------------------------------------------


def build_windows(series: pd.Series, context_len: int, stride: int = 1):
    """Yield (timestamp, trailing window) pairs.

    The window for timestamp `t` ends at `t` inclusive, so the forecast stored
    at `t` used only data available at `t`. The engine then shifts it before any
    trade can act on it.
    """
    values = series.to_numpy(dtype="float64")
    index = series.index
    n = len(values)
    if n <= context_len:
        return [], np.empty((0, context_len))

    stamps, rows = [], []
    for i in range(context_len, n, stride):
        stamps.append(index[i])
        rows.append(values[i - context_len + 1: i + 1])
    if not rows:
        return [], np.empty((0, context_len))
    return pd.DatetimeIndex(stamps), np.vstack(rows)