File size: 13,202 Bytes
204b9a4 0d49d25 204b9a4 0d49d25 204b9a4 0d49d25 204b9a4 0d49d25 204b9a4 0d49d25 204b9a4 c34558e 204b9a4 c34558e 204b9a4 0d49d25 204b9a4 0d49d25 204b9a4 c34558e 204b9a4 c34558e 204b9a4 c34558e 204b9a4 c34558e 204b9a4 c34558e 204b9a4 c34558e 204b9a4 c34558e 204b9a4 c34558e 204b9a4 | 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 | """Do the two live forecasts tell the same story? Regime vs price range, out of sample.
Both forecasts are about the size of the next day's move, not its direction:
* the VAE+Transformer regime model gives P(next 24h more volatile than the
trailing 90-day median), from 33 market features over the last week;
* the price-range model gives a 24h 80% interval, from HAR volatility, the
hour-of-week pattern and the Fed/CPI calendar.
They are built differently, so agreement is evidence that both track the same
underlying state. Both are the live versions with Deribit DVOL inputs. Period: 5 April -
25 September 2026, inside the regime model's hold-out (trained on data before
4 April 2026), with the range model refitted quarterly on the two years
before each quarter.
Usage:
python scripts/plot_regime_vs_range.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import torch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts"))
from app.forecasting.price_range import fit, forecast, hourly # noqa: E402
from news_topic_event_study import GRID, INK, INK_2, SURFACE # noqa: E402
from src.pipeline import VAETransformer # noqa: E402
from src.volatility import add_volatility_target, build_volatility_features # noqa: E402
# The live regime model (with DVOL) holds out everything from 4 Apr 2026 15:00.
START, END = pd.Timestamp("2026-04-05", tz="UTC"), pd.Timestamp("2026-09-25", tz="UTC")
VOL = ROOT / "btc_deployment_models" / "volatility"
BLUE, ORANGE, RED = "#2a78d6", "#eb6834", "#c24a12"
def regime_scores(frame: pd.DataFrame) -> pd.Series:
meta = json.loads((VOL / "volatility_btc_24h_vaetransformer.json").read_text())
ck = torch.load(VOL / meta["weights"], map_location="cpu", weights_only=False)
models = []
for state in ck["state_dicts"]:
m = VAETransformer(n_market=int(ck["n_market"]), n_sent=0, latent=int(ck.get("latent", 24)),
d_model=int(ck["d_model"]), nhead=int(ck.get("n_heads", 4)),
num_layers=int(ck.get("n_layers", 2)), dropout=float(ck.get("dropout", 0.15)))
m.load_state_dict(state, strict=True)
m.eval()
models.append(m)
X = (frame[meta["features"]].to_numpy(float) - np.array(meta["scaler_mean"])) / np.array(meta["scaler_scale"])
seq = int(meta["sequence_length"])
rows = np.where((frame["timestamp"] >= START) & (frame["timestamp"] <= END))[0]
rows = rows[rows >= seq - 1]
# contiguous, finite windows only, as the live predictor requires
stamps = frame["timestamp"].to_numpy()
ok = [(stamps[i] - stamps[i - seq + 1]) == np.timedelta64(seq - 1, "h")
and np.isfinite(X[i - seq + 1:i + 1]).all() for i in rows]
rows = rows[np.array(ok)]
out = []
with torch.no_grad():
for b in range(0, len(rows), 256):
idx = rows[b:b + 256]
win = torch.tensor(np.stack([X[i - seq + 1:i + 1] for i in idx]), dtype=torch.float32)
out.append(np.mean([torch.sigmoid(m(win)[0]).numpy() for m in models], axis=0))
return pd.Series(np.concatenate(out), index=frame["timestamp"].iloc[rows].to_numpy()), meta["decision_threshold"]
def range_forecasts(close: pd.Series, dvol: pd.Series) -> pd.DataFrame:
"""24h 80% range issued at every hour, refitted at each quarter start."""
rows = []
for q_start in pd.date_range(START, END, freq="QS", tz="UTC").union([START]):
q_end = min(q_start + pd.offsets.QuarterBegin(1, startingMonth=1), END)
params = fit(close.loc[:q_start], now=q_start, dvol=dvol.loc[:q_start])
for t in close.loc[q_start:q_end].index:
hist = close.loc[:t].dropna().iloc[-800:]
row = forecast(hist, params, dvol=dvol)["horizons"][2] # 24h; reads DVOL at t only
lo, hi = row["ranges"]["80"]
rows.append((t, lo, hi, float(hist.iloc[-1])))
f = pd.DataFrame(rows, columns=["t", "lo", "hi", "price"]).drop_duplicates("t").set_index("t")
f["width_pct"] = (f["hi"] / f["lo"] - 1) * 100
return f
CACHE = ROOT / "outputs" / "regime_vs_range_hourly.csv"
def main() -> None:
if "--redraw" in sys.argv and CACHE.exists():
# Wording-only changes: redraw from the hours computed last time.
both = pd.read_csv(CACHE, index_col=0)
both.index = pd.to_datetime(both.index, utc=True)
close = both.pop("close_series").dropna()
both = both.dropna(subset=["p_high"])
st = json.loads((ROOT / "outputs" / "regime_vs_range.json").read_text())
draw(both, close, st["decision_threshold"], st["range_80_held"], st["regime_accuracy"],
st["pearson_p_high_vs_width"], st["spearman_p_high_vs_width"],
st["median_width_pct"]["predicted_high"], st["median_width_pct"]["predicted_low"],
st["median_realised_abs_move_pct"]["predicted_high"],
st["median_realised_abs_move_pct"]["predicted_low"])
return
btc = pd.read_csv(ROOT / "outputs" / "volatility" / "btc_1h.csv")
btc["timestamp"] = pd.to_datetime(btc["timestamp"], utc=True)
btc = btc.drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True)
dv = pd.read_csv(ROOT / "outputs" / "volatility" / "btc_dvol_1h.csv")
dv["timestamp"] = pd.to_datetime(dv["timestamp"], utc=True)
dvol = dv.set_index("timestamp")["close"]
frame, _ = build_volatility_features(btc, dvol=dvol)
frame = add_volatility_target(frame, 24)
close = hourly(btc.set_index("timestamp")["close"])
p_high, cut = regime_scores(frame)
rng = range_forecasts(close, dvol)
truth = frame.set_index("timestamp")["target_volatility_regime"]
both = rng.join(p_high.rename("p_high"), how="inner").join(truth.rename("actual"), how="left")
both["price_24h_later"] = close.shift(-24).reindex(both.index)
both = both.dropna(subset=["p_high"])
scored = both.dropna(subset=["price_24h_later"])
inside = ((scored["price_24h_later"] >= scored["lo"]) & (scored["price_24h_later"] <= scored["hi"])).mean()
pred_high = both["p_high"] >= cut
corr = float(np.corrcoef(both["p_high"], both["width_pct"])[0, 1])
rank_corr = float(both[["p_high", "width_pct"]].rank().corr().iloc[0, 1])
w_hi, w_lo = both.loc[pred_high, "width_pct"].median(), both.loc[~pred_high, "width_pct"].median()
a = both.dropna(subset=["actual"])
acc = float(((a["p_high"] >= cut).astype(int) == a["actual"].astype(int)).mean())
move = (np.log(scored["price_24h_later"] / scored["price"]).abs() * 100)
mv_hi, mv_lo = move[scored["p_high"] >= cut].median(), move[scored["p_high"] < cut].median()
print(f"{len(both):,} hours; range held {inside:.1%}; regime accuracy {acc:.1%} (cut {cut:.2f})")
print(f"agreement: Pearson r {corr:.2f}, Spearman {rank_corr:.2f}; median 24h width "
f"{w_hi:.2f}% when HIGH vs {w_lo:.2f}% when LOW; realised |24h move| {mv_hi:.2f}% vs {mv_lo:.2f}%")
(ROOT / "outputs" / "regime_vs_range.json").write_text(json.dumps({
"period": [str(START.date()), str(END.date())], "hours": int(len(both)),
"range_80_held": float(inside), "regime_accuracy": acc, "decision_threshold": cut,
"pearson_p_high_vs_width": corr, "spearman_p_high_vs_width": rank_corr,
"median_width_pct": {"predicted_high": float(w_hi), "predicted_low": float(w_lo)},
"median_realised_abs_move_pct": {"predicted_high": float(mv_hi), "predicted_low": float(mv_lo)}},
indent=2))
span = close.loc[both.index[0]:both.index[-1] + pd.Timedelta(hours=24)]
both.join(span.rename("close_series"), how="outer").to_csv(CACHE)
draw(both, close, cut, inside, acc, corr, rank_corr, w_hi, w_lo, mv_hi, mv_lo)
def draw(both, close, cut, inside, acc, corr, rank_corr, w_hi, w_lo, mv_hi, mv_lo) -> None:
plt.rcParams.update({"font.family": "DejaVu Sans", "text.color": INK, "axes.labelcolor": INK_2})
fig = plt.figure(figsize=(15, 10.5), facecolor=SURFACE)
gs = fig.add_gridspec(3, 2, width_ratios=[3.3, 1], height_ratios=[1.5, 1, 1],
left=0.06, right=0.98, top=0.85, bottom=0.07, hspace=0.18, wspace=0.18)
a = fig.add_subplot(gs[0, 0])
b = fig.add_subplot(gs[1, 0], sharex=a)
c = fig.add_subplot(gs[2, 0], sharex=a)
s = fig.add_subplot(gs[:, 1])
for ax in (a, b, c, s):
ax.set_facecolor(SURFACE)
for sp in ("top", "right"):
ax.spines[sp].set_visible(False)
for sp in ("left", "bottom"):
ax.spines[sp].set_color(GRID)
ax.tick_params(colors=INK_2, labelsize=8.5, length=0)
ax.grid(color=GRID, linewidth=0.8, axis="y")
ax.set_axisbelow(True)
# actual HIGH hours, shaded on every time panel
actual = both["actual"].fillna(0).astype(bool)
edges = actual.astype(int).diff().fillna(0)
starts = list(both.index[edges == 1]) + ([both.index[0]] if actual.iloc[0] else [])
ends = list(both.index[edges == -1]) + ([both.index[-1]] if actual.iloc[-1] else [])
for ax in (a, b, c):
for s0, e0 in zip(sorted(starts), sorted(ends)):
ax.axvspan(s0, e0, color="#fbe3d6", linewidth=0, zorder=0)
# A: price and the 24h range, plotted at the hour it is ABOUT (issued 24h earlier)
target = both.index + pd.Timedelta(hours=24)
a.fill_between(target, both["lo"], both["hi"], color=BLUE, alpha=0.22, linewidth=0,
label="24h 80% range, forecast a day earlier")
window = close.loc[both.index[0] + pd.Timedelta(hours=24):both.index[-1] + pd.Timedelta(hours=24)]
a.plot(window.index, window, color=INK, linewidth=0.9, label="BTC price")
a.set_ylabel("BTC / USDT", fontsize=8.5)
a.yaxis.set_major_formatter(matplotlib.ticker.FuncFormatter(lambda v, _: f"${v / 1000:.0f}k"))
a.legend(loc="upper left", frameon=False, fontsize=8.5)
a.set_title(f"Prediction 1 · Where will the price be in 24 hours? The real price stayed inside "
f"the 80% range {inside:.0%} of the time",
loc="left", fontsize=11, fontweight="bold")
# B: range width
b.plot(both.index, both["width_pct"], color=BLUE, linewidth=1)
b.set_ylabel("24h 80% range width, %", fontsize=8.5)
b.set_title("Prediction 1 · How wide that 24-hour price range is", loc="left",
fontsize=11, fontweight="bold")
# C: regime probability
c.plot(both.index, both["p_high"], color=ORANGE, linewidth=1)
c.axhline(cut, color=INK, linewidth=1, linestyle=(0, (4, 2)))
c.text(both.index[0], cut + 0.03, f" above {cut:.2f} = volatile", fontsize=8, color=INK_2)
c.set_ylim(0, 1)
c.set_ylabel("probability next 24h\nis more volatile", fontsize=8.5)
c.set_title(f"Prediction 2 · Will the next 24 hours be more volatile than normal? "
f"Correct {acc:.0%} of the hours", loc="left",
fontsize=11, fontweight="bold")
c.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
for ax in (a, b):
ax.tick_params(labelbottom=False)
# side: do they agree?
hi_mask = both["p_high"] >= cut
data = [both.loc[~hi_mask, "width_pct"], both.loc[hi_mask, "width_pct"]]
bp = s.boxplot(data, positions=[0, 1], widths=0.55, patch_artist=True, showfliers=False,
medianprops={"color": INK, "linewidth": 1.6})
for patch, col in zip(bp["boxes"], ("#c4dcf6", "#f6c9b1")):
patch.set_facecolor(col)
patch.set_edgecolor(INK_2)
s.set_xticks([0, 1])
s.set_xticklabels([f"Prediction 2 says\ncalm ({(~hi_mask).sum():,} h)",
f"Prediction 2 says\nvolatile ({hi_mask.sum():,} h)"], fontsize=9)
s.set_ylabel("Prediction 1: 24h 80% range width, %", fontsize=8.5)
s.set_title("Do the two predictions agree?", loc="left", fontsize=11, fontweight="bold")
s.set_ylim(top=both["width_pct"].quantile(0.999) * 1.45)
s.text(0.5, 0.98, f"median width {w_lo:.1f}% vs {w_hi:.1f}%\nrank correlation {rank_corr:.2f}\n\n"
f"real 24h move afterwards:\n{mv_lo:.1f}% vs {mv_hi:.1f}% (median)",
transform=s.transAxes, ha="center", va="top", fontsize=9, color=INK)
fig.suptitle("The two predictions move together: when a volatile day is expected, "
"the price range widens", x=0.06, ha="left", fontsize=14, fontweight="bold", y=0.975)
fig.text(0.06, 0.905,
"5 April – 25 September 2026, every hour, on data neither prediction was trained on. "
"Prediction 1: the 24-hour price range. Prediction 2: calm or volatile next 24 hours.\n"
"Pale orange: hours whose next 24 hours actually turned out more volatile than normal.",
fontsize=9.2, color=INK_2, ha="left")
fig.savefig(ROOT / "outputs" / "figures" / "fig19_regime_vs_range.png", dpi=150, facecolor=SURFACE)
plt.close(fig)
print("written: outputs/figures/fig19_regime_vs_range.png")
if __name__ == "__main__":
main()
|