"""Method diagram: the live price-range forecaster (model B) next to the volatility-regime classifier, step by step, with how each was tested. Numbers are read from the deployed artifacts where they exist, so the figure matches what is live: btc_deployment_models/price_range/params.json and outputs/price_backtest_fixes.json. Usage: python scripts/make_method_diagram.py """ from __future__ import annotations import json from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.patches import FancyArrowPatch, FancyBboxPatch ROOT = Path(__file__).resolve().parents[1] SURFACE, INK, INK_2, GRID = "#fcfcfb", "#0b0b0b", "#52514e", "#e6e5e1" BLUE, ORANGE = "#2a78d6", "#eb6834" FILL = {BLUE: "#eef3fb", ORANGE: "#fdf0ea"} def main() -> None: p = json.loads((ROOT / "btc_deployment_models" / "price_range" / "params.json").read_text()) bt = json.loads((ROOT / "outputs" / "price_backtest_fixes.json").read_text())["walk_forward"] hw = p["hour_of_week_variance"] cov = bt["24h | + Fed/CPI calendar"]["coverage"] n = bt["24h | + Fed/CPI calendar"]["n"] q80 = p["quantiles"]["24"]["0.8"] left = [ ("1 Recent volatility", "Last 168 hourly BTC prices \u2192 \u03c3 = how much\nthe price typically moves per hour"), ("2 Weekly pattern", f"Each of the 168 hours of the week gets a factor:\nweekends {p['weekend_factor']:.2f}\u00d7, " f"range {min(hw):.2f}\u00d7 \u2013 {max(hw):.2f}\u00d7 normal"), ("3 Fed / CPI calendar", f"Release hour {p['k_release']:.1f}\u00d7 variance, next 2 hours " f"{p['k_after']:.2f}\u00d7\n(dates are published months ahead: no look-ahead)"), ("4 Combine over the horizon", "\u03c3\u2095 = \u03c3 \u00d7 \u221a(sum of the factors of the next h hours)"), ("5 Fat tails \u2192 range", f"price \u00d7 exp(\u00b1 q \u00d7 \u03c3\u2095), q learned from 2 years of\nreal outcomes " f"(80%: q = {q80:.2f} instead of the normal 1.28)"), ] vt = json.loads((ROOT / "btc_deployment_models" / "volatility" / "volatility_btc_24h_vaetransformer.json").read_text()) right = [ ("1 Recent market data", "Last 168 hours (+ 90 days for the reference level)\nof hourly price, volume, trades, high/low"), ("2 Features relative to normal", f"{len(vt['features'])} features per hour (volatility 6\u2013168h, ATR, volume \u2026),\n" "volatility ones divided by the 90-day median"), ("3 Question (target)", "Will the next 24h be MORE volatile than\nthe 90-day median? yes / no"), ("4 Model: VAE + Transformer", f"VAE encodes each hour, Transformer reads the {vt['sequence_length']}h\nsequence " f"\u2192 P(high); {vt['seeds']}-seed average, cut-off {vt['decision_threshold']:.2f}"), ("5 Answer", "\u201cHIGH\u201d or \u201cLOW\u201d volatility for the next 24h"), ] # Both examples are the live Space's own answers for the 17 Oct 2026 12:00 UTC candle. outputs = [ (BLUE, "Output: a price range", "live, from \\$81,520, next 24h:\n80% chance between \\$80,519 and \\$82,534"), (ORANGE, "Output: a yes/no regime", "live, next 24h: LOW volatility\n(probability of high: 0.31)"), ] tests = [ (BLUE, "Tested walk-forward 2023 \u2013 2026", f"Refit every quarter on the previous 2 years,\n{n:,} unseen forecasts per horizon\n\n" f"50 / 80 / 95% ranges held\n{cov['50%'] * 100:.1f} / {cov['80%'] * 100:.1f} / " f"{cov['95%'] * 100:.1f}% of the time (24h)\n" "3\u20137% better than plain volatility (1\u201324h)"), # Persistence on the same held-out span, recomputed from btc_1h.csv # (which ends 2026-09-27, a few days before the model's holdout does). (ORANGE, "Tested on unseen hours, Mar \u2013 Sep 2026", f"Last {vt['holdout_rows']:,} hours held out, trained on the {vt['trained_rows']:,} before\n\n" f"Accuracy {vt['holdout_accuracy'] * 100:.1f}% vs \u2248 64.5% for \u201csame as now\u201d\n" f"ROC-AUC {vt['holdout_auc_ensemble']:.2f}\n" "(earlier tree model, 5 walk-forward folds 2022\u201326:\n71.3% \u00b1 2.2 vs 65.6% persistence)"), ] plt.rcParams.update({"font.family": "DejaVu Sans", "text.color": INK}) fig = plt.figure(figsize=(14, 13.2), facecolor=SURFACE) ax = fig.add_axes([0, 0, 1, 1]) ax.set_xlim(0, 14); ax.set_ylim(0, 13.2); ax.axis("off") def box(x, y, w, h, title, body, color, strong=False): ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.02,rounding_size=0.12", facecolor=color if strong else FILL[color], edgecolor=color, linewidth=1.6 if strong else 1.1)) ink = "white" if strong else INK ax.text(x + 0.22, y + h - 0.2, title, fontsize=11, fontweight="bold", va="top", color=ink) ax.text(x + 0.22, y + h - 0.58, body, fontsize=9.6, va="top", color="white" if strong else INK_2, linespacing=1.4) def arrow(x, y0, y1, color): ax.add_patch(FancyArrowPatch((x, y0), (x, y1), arrowstyle="-|>", mutation_scale=13, color=color, linewidth=1.3)) ax.text(0.5, 12.85, "Two forecasts from the same idea: volatility clusters and is predictable", fontsize=15, fontweight="bold", va="top") ax.text(0.5, 12.38, "Price direction is a coin flip in every test we ran. How much the price " "will move is not, and both models below forecast that, in two different forms.", fontsize=9.8, color=INK_2, va="top") cols = [(0.5, BLUE, "A Price range (live: \u201cWhere will the price be?\u201d)", left), (7.2, ORANGE, "B Volatility regime (live: 24h volatility panel)", right)] w, h, gap, top = 6.3, 1.12, 0.32, 11.55 for x, color, head, steps in cols: ax.text(x, top + 0.42, head, fontsize=12, fontweight="bold", color=color, va="center") y = top for i, (title, body) in enumerate(steps): box(x, y - h, w, h, title, body, color) arrow(x + w / 2, y - h, y - h - gap, color) y -= h + gap k = 0 if color == BLUE else 1 oc, ot, ob = outputs[k] box(x, y - 1.05, w, 1.05, ot, ob, oc, strong=True) arrow(x + w / 2, y - 1.05, y - 1.05 - gap, color) y -= 1.05 + gap tc, tt, tb = tests[k] box(x, y - 2.15, w, 2.15, tt, tb, tc) fig.savefig(ROOT / "outputs" / "figures" / "fig14_method_diagram.png", dpi=150, facecolor=SURFACE) plt.close(fig) print("written: outputs/figures/fig14_method_diagram.png") if __name__ == "__main__": main()