File size: 7,977 Bytes
0c9cbf3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15836c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c9cbf3
 
 
 
 
 
 
 
 
 
 
 
 
15836c8
 
 
 
 
 
 
 
 
 
 
 
 
 
0c9cbf3
 
15836c8
 
 
 
 
 
 
 
0c9cbf3
15836c8
 
 
 
 
 
 
0c9cbf3
15836c8
 
0c9cbf3
 
15836c8
0c9cbf3
15836c8
 
0c9cbf3
15836c8
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Walk-forward validation engine — the trust backbone.

For a fixed config, roll the *same* ensemble forecast the dashboard makes across many
recent origins, hold out the truth each time, and measure how good (and how honest) the
forecast actually is on THIS ticker:

  * accuracy      : MAPE / MAE / DirAcc of the ensemble median
  * skill         : MASE and skill-vs-random-walk (does it beat "tomorrow = today"?)
  * calibration   : the 10-90% band's real coverage, and a multiplicative band-scale that
                    corrects it to a target coverage (split-conformal, CQR-style)
  * validity      : mean range_ratio of the fed windows (Kronos normalisation sanity)

Everything downstream (autoconfig, the trust card) is computed from this one pass so a
config is judged by evidence, not by guesswork.
"""
import time

import numpy as np

# predict_us lives one level up when imported as `us.validation`, or flat in the Space.
try:
    from us.predict_us import fetch_ohlcv, report_metrics, baseline_skill  # type: ignore
except Exception:  # flat context (Space) or run from inside us/
    from predict_us import fetch_ohlcv, report_metrics, baseline_skill  # type: ignore

_COLS = ["open", "high", "low", "close", "volume"]


def make_windows(n_bars: int, lookback: int, pred_len: int, n_windows: int) -> list:
    """End-indices (exclusive) for rolling origins, spread across the recent slice."""
    span = lookback + pred_len
    first = span + pred_len          # leave room for one extra step back
    last = n_bars
    if first >= last:
        first = span
    return [int(x) for x in np.linspace(first, last, n_windows, dtype=int)]


def _draw_paths(predictor, hist, fut_ts, pred_len, T, top_p, top_k, n_paths, seed):
    import torch
    paths = []
    x_df = hist[_COLS].reset_index(drop=True)
    x_ts = hist["timestamps"].reset_index(drop=True)
    for i in range(n_paths):
        torch.manual_seed(seed + i); np.random.seed(seed + i)
        pred_df = predictor.predict(
            df=x_df, x_timestamp=x_ts, y_timestamp=fut_ts, pred_len=pred_len,
            T=T, top_k=top_k, top_p=top_p, sample_count=1, verbose=False,
        )
        paths.append(pred_df["close"].values.astype(float))
    return np.stack(paths, axis=0)  # (n_paths, pred_len)


def _best_band_scale(median, lo, hi, truth, target, grid=None):
    """Multiplicative scale s so [median±s·halfwidth] hits `target` coverage (CQR-style)."""
    if grid is None:
        grid = np.round(np.arange(0.3, 6.001, 0.05), 3)
    best_s, best_cov, best_gap = 1.0, float("nan"), 1e9
    for s in grid:
        lo_s = median - s * (median - lo)
        hi_s = median + s * (hi - median)
        cov = float(np.mean((truth >= lo_s) & (truth <= hi_s)))
        gap = abs(cov - target)
        # prefer the tightest band whose coverage is closest to target
        if gap < best_gap - 1e-9 or (abs(gap - best_gap) <= 1e-9 and s < best_s):
            best_s, best_cov, best_gap = float(s), cov, gap
    return best_s, best_cov


def _pearson(a, b):
    a = np.asarray(a, float); b = np.asarray(b, float)
    if len(a) < 3 or np.std(a) < 1e-12 or np.std(b) < 1e-12:
        return float("nan")
    return float(np.corrcoef(a, b)[0, 1])


def compute_ic(records):
    """Information Coefficient: correlation of predicted vs realised step-returns, pooled
    across windows. This is how the Kronos paper judges skill (direction/ranking is what a
    generative price model can actually capture; absolute price level is near-random).
    Returns (IC=Pearson, RankIC=Spearman)."""
    pr, tr = [], []
    for w in records:
        if len(w["median"]) > 1:
            pr.append(np.diff(w["median"])); tr.append(np.diff(w["truth"]))
    if not pr:
        return float("nan"), float("nan")
    pr = np.concatenate(pr); tr = np.concatenate(tr)
    ic = _pearson(pr, tr)
    rank = lambda x: np.argsort(np.argsort(x)).astype(float)
    ric = _pearson(rank(pr), rank(tr))
    return ic, ric


def run_windows(predictor, df, lookback, pred_len, T=0.7, top_p=0.9, top_k=0,
                n_paths=10, windows=None, n_windows=6, seed=123):
    """Draw the ensemble at each rolling origin; return a per-window record list (raw
    arrays + point metrics). Explicit `windows` lets callers share identical origins
    across configs (needed for a fair nested inner/outer split)."""
    if windows is None:
        windows = make_windows(len(df), lookback, pred_len, n_windows)
    records = []
    for end in windows:
        hist = df.iloc[end - lookback - pred_len:end - pred_len]
        fut = df.iloc[end - pred_len:end]
        if len(hist) < lookback or len(fut) < pred_len:
            continue
        fut_ts = fut["timestamps"].reset_index(drop=True)
        paths = _draw_paths(predictor, hist, fut_ts, pred_len, T, top_p, top_k, n_paths, seed)
        median = np.median(paths, axis=0)
        lo = np.quantile(paths, 0.1, axis=0)
        hi = np.quantile(paths, 0.9, axis=0)
        truth = fut["close"].values.astype(float)
        hist_close = hist["close"].values.astype(float)
        wmin, wmax = float(hist_close.min()), float(hist_close.max())
        rec = {"median": median, "lo": lo, "hi": hi, "truth": truth,
               "range_ratio": wmax / wmin if wmin > 0 else float("inf")}
        rec.update(report_metrics(truth, median))
        rec.update(baseline_skill(hist_close, truth, median))
        records.append(rec)
    return records


def aggregate(records, target_coverage=0.8, band_scale=None) -> dict:
    """Summarise a set of window records. If band_scale is None, FIT the conformal scale
    to hit target coverage on these records (calibration set). If a band_scale is given,
    APPLY it and just measure the resulting coverage (test set) — this is how the nested
    outer set reports coverage using a scale fitted only on the inner set."""
    if not records:
        raise ValueError("No valid windows — not enough history for this lookback/pred_len.")
    keys = ["MAE", "RMSE", "MAPE%", "DirAcc%", "MASE", "skill_vs_rw"]
    agg = {k: float(np.nanmean([w[k] for w in records])) for k in keys}
    ic, ric = compute_ic(records)
    agg["IC"] = ic; agg["RankIC"] = ric

    med = np.concatenate([w["median"] for w in records])
    lo = np.concatenate([w["lo"] for w in records])
    hi = np.concatenate([w["hi"] for w in records])
    tr = np.concatenate([w["truth"] for w in records])
    cov_raw = float(np.mean((tr >= lo) & (tr <= hi)))
    if band_scale is None:
        bs, cov_cal = _best_band_scale(med, lo, hi, tr, target_coverage)
    else:
        bs = float(band_scale)
        lo_s = med - bs * (med - lo); hi_s = med + bs * (hi - med)
        cov_cal = float(np.mean((tr >= lo_s) & (tr <= hi_s)))
    rr = [w["range_ratio"] for w in records]
    return {
        "n_windows": len(records),
        "metrics": {k: (round(v, 4) if v == v else None) for k, v in agg.items()},
        "calibration": {"target_coverage": target_coverage,
                        "coverage_raw": round(cov_raw, 4),
                        "band_scale": round(float(bs), 3),
                        "coverage_calibrated": round(cov_cal, 4)},
        "validity": {"range_ratio_mean": round(float(np.mean(rr)), 3),
                     "range_ratio_max": round(float(np.max(rr)), 3)},
    }


def evaluate(predictor, df, lookback, pred_len, T=0.7, top_p=0.9, top_k=0,
             n_paths=10, n_windows=6, seed=123, target_coverage=0.8) -> dict:
    """Single-pass walk-forward for one config (non-nested; used for quick checks)."""
    t0 = time.time()
    records = run_windows(predictor, df, lookback, pred_len, T=T, top_p=top_p, top_k=top_k,
                          n_paths=n_paths, n_windows=n_windows, seed=seed)
    out = aggregate(records, target_coverage)
    out["config"] = {"lookback": lookback, "pred_len": pred_len, "T": T, "top_p": top_p,
                     "top_k": top_k, "n_paths": n_paths}
    out["secs"] = round(time.time() - t0, 1)
    return out