"""Figures for the thesis, drawn from the measured results rather than retyped. Every figure reads a JSON written by the experiment that produced it, so a re-run updates the plots and no number is transcribed by hand. The design rule throughout: **a bar is never shown without its baseline.** Each chart draws the naive comparison as a line or a paired bar, because a figure showing seven architectures at 65-75% with no baseline tells a reader nothing about whether any of them works, and that is the failure this project documents in the literature. Usage: python scripts/make_thesis_figures.py """ from __future__ import annotations import json import sys from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt # noqa: E402 import numpy as np # noqa: E402 ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) OUT = ROOT / "outputs" / "figures" OUT.mkdir(parents=True, exist_ok=True) plt.rcParams.update({ "figure.dpi": 150, "savefig.dpi": 300, "savefig.bbox": "tight", "font.size": 9, "axes.titlesize": 10, "axes.labelsize": 9, "axes.spines.top": False, "axes.spines.right": False, "axes.grid": True, "grid.alpha": 0.25, "grid.linewidth": 0.5, }) MODEL = "#2f6f9f" BASE = "#b0b7bd" WARN = "#c8912f" GOOD = "#3f8f5f" def load(name: str): path = ROOT / "outputs" / name if not path.exists(): print(f" skipped: outputs/{name} not found") return None return json.loads(path.read_text(encoding="utf-8")) # ------------------------------------------------------------------ figure 1 def figure_target_screen(): """Margin over the binding baseline, for every target screened. Plotted as margin rather than accuracy on purpose: accuracy would rank a 94.71% target top when it loses to its own majority class. """ first, second = load("prediction_target_screen.json"), load("change_target_screen.json") if not first or not second: return rows = [] for source in (first, second): for asset, targets in source.items(): if asset.startswith("_"): continue for name, row in targets.items(): binding = max(row["majority"], row["persistence"]) rows.append((name, asset.replace("USDT", ""), row["accuracy"] - binding, row["roc_auc"])) names = sorted({r[0] for r in rows}, key=lambda n: np.mean([r[2] for r in rows if r[0] == n])) assets = ["BTC", "ETH", "ADA"] fig, ax = plt.subplots(figsize=(7.2, 5.4)) height = 0.26 for i, asset in enumerate(assets): values = [next((r[2] * 100 for r in rows if r[0] == n and r[1] == asset), np.nan) for n in names] ax.barh(np.arange(len(names)) + (i - 1) * height, values, height, label=asset, color=[MODEL, "#7aa7c7", "#aecbe0"][i]) ax.axvline(0, color="#333", linewidth=1) ax.set_yticks(range(len(names))) ax.set_yticklabels(names) ax.set_xlabel("accuracy minus the harder of (majority, persistence), percentage points") ax.set_title("Sixteen candidate targets, ranked by margin over baseline\n" "Zero is the line a target must clear to be worth modelling") ax.legend(title="asset", loc="lower right", frameon=False) fig.savefig(OUT / "fig1_target_screen.png") plt.close(fig) print(" fig1_target_screen.png") # ------------------------------------------------------------------ figure 2 def figure_ablation(): """The architecture ladder, all assets, naive rungs in grey. Drawn as margin over the majority class rather than accuracy: accuracy alone puts the naive rungs at a respectable-looking 50% and hides that they are the floor rather than a result. """ data = load("ablation_study.json") if not data: return assets = [a for a in ("BTCUSDT", "ETHUSDT", "ADAUSDT") if a in data] order = ["majority", "persistence", "logistic", "gradient boosting", "MLP", "CNN", "LSTM", "GRU", "Transformer", "CNN+Transformer", "VAE+Transformer", "CNN+VAE+Transformer"] names = [n for n in order if any(n in data[a]["architecture"] for a in assets)] fig, ax = plt.subplots(figsize=(8.8, 5.2)) width = 0.26 for i, asset in enumerate(assets): arch = data[asset]["architecture"] values = [arch[n]["margin"] * 100 if n in arch else np.nan for n in names] ax.bar(np.arange(len(names)) + (i - 1) * width, values, width, label=asset.replace("USDT", ""), color=[MODEL, "#7aa7c7", "#aecbe0"][i]) ax.axhline(0, color="#333", linewidth=1) # The band the non-linear architectures occupy. This is the figure's point: # nine of them sit inside one shaded strip, while the step up from the # naive rungs on the left is an order of magnitude larger. band = [data[a]["architecture"][n]["margin"] * 100 for a in assets for n in names if n in data[a]["architecture"] and n not in ("majority", "persistence", "logistic")] ax.axhspan(min(band), max(band), color=GOOD, alpha=0.10, zorder=0, label="nine non-linear architectures ({:.1f} to {:.1f} pts)".format( min(band), max(band))) ax.set_xticks(range(len(names))) ax.set_xticklabels(names, rotation=28, ha="right") ax.set_ylabel("margin over majority class, percentage points") ax.set_title("Twelve rungs, three assets, identical folds and features" "\nNine non-linear architectures share one band; the gap that " "matters is to the left of it") ax.legend(frameon=False, loc="lower right", fontsize=8) fig.savefig(OUT / "fig2_ablation_ladder.png") plt.close(fig) print(" fig2_ablation_ladder.png") # ------------------------------------------------------------------ figure 3 def figure_har(): """R-squared and QLIKE against HAR-RV. Two panels because they disagree. QLIKE is drawn with the axis inverted so that "higher is better" holds in both panels; a reader comparing them should not have to remember that one is a loss. """ data = load("har_benchmark.json") if not data: return keys, r2, ql, har_r2, har_ql = [], [], [], [], [] for asset, horizons in data.items(): for label, rows in horizons.items(): if "HAR-RV" not in rows: continue best = max((v for k, v in rows.items() if k != "HAR-RV"), key=lambda v: v["r2"]) cheapest = min((v for k, v in rows.items() if k != "HAR-RV"), key=lambda v: v["qlike"]) keys.append(f"{asset.replace('USDT','')} {label}") r2.append(best["r2"]); har_r2.append(rows["HAR-RV"]["r2"]) ql.append(cheapest["qlike"]); har_ql.append(rows["HAR-RV"]["qlike"]) x = np.arange(len(keys)) fig, (left, right) = plt.subplots(1, 2, figsize=(10, 4)) left.bar(x - 0.19, har_r2, 0.38, label="HAR-RV", color=BASE) left.bar(x + 0.19, r2, 0.38, label="best model here", color=MODEL) left.set_xticks(x); left.set_xticklabels(keys, rotation=30, ha="right") left.set_ylabel("R² (higher is better)") left.set_title("Explained variance") left.legend(frameon=False) right.bar(x - 0.19, har_ql, 0.38, label="HAR-RV", color=BASE) right.bar(x + 0.19, ql, 0.38, label="best model here", color=MODEL) right.set_xticks(x); right.set_xticklabels(keys, rotation=30, ha="right") right.set_ylabel("QLIKE (lower is better)") right.set_yscale("log") right.set_title("QLIKE, the loss this literature reports") right.legend(frameon=False) # the caption is derived, not asserted: whether the panels disagree is a # property of the data, and on some assets the same model wins both disagree = sum(1 for a, b in zip(r2, ql) if not np.isclose(a, b)) fig.suptitle("Against HAR-RV on identical folds, R² and the loss this " "literature actually reports", y=1.02) fig.savefig(OUT / "fig3_har_benchmark.png") plt.close(fig) print(" fig3_har_benchmark.png") # ------------------------------------------------------------------ figure 4 def figure_sentiment(): """Change in ROC-AUC from adding external data. Zero is the only story.""" data = load("sentiment_new_targets_screen.json") if not data: return labels, deltas = [], [] for asset, rows in data.items(): for key, row in rows.items(): if row["feature_set"] == "own 33" or row.get("delta_auc") is None: continue labels.append(f"{asset.replace('USDT','')} · {row['target']}\n{row['feature_set']}") deltas.append(row["delta_auc"]) order = np.argsort(deltas) labels = [labels[i] for i in order] deltas = [deltas[i] for i in order] fig, ax = plt.subplots(figsize=(7.2, max(3.5, 0.32 * len(labels)))) ax.barh(range(len(deltas)), deltas, color=[GOOD if d > 0.005 else BASE for d in deltas]) ax.axvline(0, color="#333", linewidth=1) ax.axvline(0.005, color=WARN, linestyle="--", linewidth=1, label="+0.005, the smallest change worth claiming") ax.set_yticks(range(len(labels))) ax.set_yticklabels(labels, fontsize=7) ax.set_xlabel("change in ROC-AUC from adding sentiment or on-chain data") ax.set_title("Historic data versus sentiment: every comparison\n" "Both sources lagged a full day, so nothing describes the hour it scores") ax.legend(frameon=False, loc="lower right") fig.savefig(OUT / "fig4_sentiment.png") plt.close(fig) print(" fig4_sentiment.png") # ------------------------------------------------------------------ figure 5 def figure_horizon(): """Margin by forecast horizon. 24h, which every deployed model used, is worst.""" data = load("horizon_spillover_screen.json") if not data: return fig, ax = plt.subplots(figsize=(6.4, 4)) for asset, rows in data.items(): own = [(v["horizon"], (v["accuracy"] - v["majority"]) * 100) for k, v in rows.items() if v["feature_set"] == "own 33"] own.sort() ax.plot([h for h, _ in own], [m for _, m in own], marker="o", label=asset.replace("USDT", "")) ax.axvline(24, color=WARN, linestyle="--", linewidth=1, label="24h, the horizon every deployed model inherited") ax.set_xscale("log") ax.set_xticks([1, 6, 12, 24, 48]) ax.get_xaxis().set_major_formatter(matplotlib.ticker.ScalarFormatter()) ax.set_xlabel("forecast horizon, hours") ax.set_ylabel("margin over majority class, percentage points") ax.set_title("Forecast horizon was never tested until now\n" "BTC and ETH peak at 12h; ADA degrades monotonically") ax.legend(frameon=False) fig.savefig(OUT / "fig5_horizon.png") plt.close(fig) print(" fig5_horizon.png") # ------------------------------------------------------------------ figure 6 def figure_direction(): """The negative result: direction against its baselines, measured three ways.""" labels = ["direction 1h", "direction 6h", "direction 24h", "triple-barrier 1%", "triple-barrier 2.5%", "triple-barrier 5%"] auc = [0.503, 0.474, 0.490, 0.4989, 0.5167, 0.5265] fig, ax = plt.subplots(figsize=(6.8, 3.8)) ax.bar(labels, auc, color=[WARN if a < 0.5 else BASE for a in auc], width=0.6) ax.axhline(0.5, color="#333", linestyle="--", linewidth=1.2, label="chance (0.50)") ax.axhline(0.8286, color=GOOD, linestyle=":", linewidth=1.4, label="volatility rises, same features (0.8286)") ax.set_ylim(0.4, 0.88) ax.set_ylabel("ROC-AUC") ax.set_title("Direction is not predictable from these features\n" "The dotted line is the positive control: same data, same pipeline") ax.legend(frameon=False, fontsize=8) plt.setp(ax.get_xticklabels(), rotation=20, ha="right") fig.savefig(OUT / "fig6_direction_negative.png") plt.close(fig) print(" fig6_direction_negative.png") def main() -> None: print(f"writing to {OUT.relative_to(ROOT)}/") for fn in (figure_target_screen, figure_ablation, figure_har, figure_sentiment, figure_horizon, figure_direction): try: fn() except Exception as exc: # noqa: BLE001 print(f" {fn.__name__} failed: {exc}") print("done") if __name__ == "__main__": main()