"""Report-ready tables, generated from the measured JSONs rather than retyped. Emits both Markdown and LaTeX for every results table the thesis needs, reading the same files the figures read. Nothing here is transcribed, so a re-run after a re-run of the experiments updates the tables and they cannot drift from the numbers they claim to report. That matters more than convenience. Reformatting results by hand -- or by pasting them into a language model and asking for a table -- is a step where a digit changes and nobody notices, and a thesis is the worst place for that to happen. If a model is used to restyle these tables, it should be given this output, so it is only ever changing layout. Usage: python scripts/make_report_tables.py python scripts/make_report_tables.py --latex-only """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) OUT = ROOT / "outputs" / "report_tables.md" ASSETS = ("BTCUSDT", "ETHUSDT", "ADAUSDT") LADDER = ["majority", "persistence", "logistic", "gradient boosting", "MLP", "CNN", "LSTM", "GRU", "Transformer", "CNN+Transformer", "VAE+Transformer", "CNN+VAE+Transformer"] # Where each rung comes from. A table naming only architectures invites the # question "compared against what, and on whose authority?" -- naming the # source makes each rung checkable, and makes visible that exactly one rung in # the ladder is this project's own. SOURCE = { "majority": "naive; methodology 3.1.5.1", "persistence": "naive; Corsi (2009) HAR tradition", "logistic": "Islam et al. (2025); 3.1.5.2", "gradient boosting": "Oyedele et al. (2022); arXiv 2511.20105; 3.1.5.3", "MLP": "Bouteska et al. (2023)", "CNN": r"Izadi \& Hajizadeh (2025); 3.1.5.4", "LSTM": "Pintelas et al. (2020); Rathee et al. (2023)", "GRU": r"Amirshahi \& Lahmiri (2023)", "Transformer": "Quang et al. (2025); 3.1.5.5", "CNN+Transformer": r"Izadi \& Hajizadeh (2025); ablation (b)", "VAE+Transformer": "Badar et al. (2025); ablation (c)", "CNN+VAE+Transformer": "proposed; 3.1.5.6, ablation (d)", } def load(name: str): path = ROOT / "outputs" / name return json.loads(path.read_text(encoding="utf-8")) if path.exists() else None def short(asset: str) -> str: return asset.replace("USDT", "") def demath(text: str) -> str: """LaTeX markup to its Markdown equivalent, for the Markdown rendering only. The rows are authored once, in LaTeX, because that is the output that cannot be fixed up by hand later. This undoes the escaping for the Markdown copy so the two never drift apart. """ text = str(text) for pattern, repl in ( (r"\\textbf\{([^}]*)\}", r"**\1**"), (r"\\emph\{([^}]*)\}", r"*\1*"), (r"\\textsuperscript\{([^}]*)\}", r"\1"), (r"\$\\approx\$", "\u2248"), (r"\$\\rightarrow\$", "\u2192"), (r"\$\\times\$", "\u00d7"), (r"\$\\Delta\$", "\u0394"), (r"\$R\^2\$", "R\u00b2"), (r"\$([A-Za-z])\$", r"\1"), (r"\\'o", "\u00f3"), (r"\{,\}", ","), (r"\\([&%#_])", r"\1"), (r"---", "\u2014"), (r"(?<=\d)--(?=\d)", "\u2013"), (r"(?<=[a-z])--(?=\d)", "\u2013"), (r"--(?=\d)", "\u2212"), ): text = re.sub(pattern, repl, text) return text def markdown(rows, header, align=None) -> str: align = align or ["---"] * len(header) out = ["| " + " | ".join(demath(c) for c in header) + " |", "|" + "|".join(align) + "|"] out += ["| " + " | ".join(demath(c) for c in row) + " |" for row in rows] return "\n".join(out) def latex(rows, header, caption, label, spec=None) -> str: # numeric columns right-aligned, a trailing citation column left-aligned if spec is None: spec = "l" + "r" * (len(header) - 1) if header and header[-1] == "Source": spec = spec[:-1] + "l" # a table of prose columns reads badly right-aligned if header and header[0] in ("\\#",): spec = "l" * len(header) lines = [r"\begin{table}[htbp]", r"\centering", rf"\caption{{{caption}}}", rf"\label{{{label}}}", rf"\begin{{tabular}}{{{spec}}}", r"\toprule", " & ".join(header) + r" \\", r"\midrule"] lines += [" & ".join(str(c) for c in row) + r" \\" for row in rows] lines += [r"\bottomrule", r"\end{tabular}", r"\end{table}"] return "\n".join(lines) def block(title, note, rows, header, caption, label) -> str: return (f"\n## {title}\n\n{demath(note)}\n\n" + markdown(rows, header) + "\n\n
LaTeX\n\n```latex\n" + latex(rows, header, caption, label) + "\n```\n\n
\n") def table_ablation(data) -> str: rows = [] for name in LADDER: row = [name] for asset in ASSETS: arch = data.get(asset, {}).get("architecture", {}) row.append(f"{arch[name]['margin'] * 100:+.2f}" if name in arch else "--") row.append(SOURCE.get(name, "")) rows.append(row) note = ("Margin is accuracy minus the majority class, in percentage points. " "Five purged walk-forward folds, 15 epochs, 30{,}969 rows, identical " "features and scaling at every rung. The two naive rungs are the floor, " "not results. Every rung names its source; exactly one rung in the " "ladder is this project's own.") return block("Architecture ablation", note, rows, ["Model", "BTC 12h", "ETH 12h", "ADA 1h", "Source"], "Architecture ablation on the volatility-rises target. Margin over " "the majority class, percentage points. The source column names the " "study or methodology section each rung answers to.", "tab:ablation") def table_ablation_full(data) -> str: rows = [] for name in LADDER: arch = data.get("BTCUSDT", {}).get("architecture", {}) if name not in arch: continue r = arch[name] rows.append([name, f"{r['roc_auc']:.4f}", f"{r['accuracy'] * 100:.2f}", f"{r['majority'] * 100:.2f}", f"{r['margin'] * 100:+.2f}", f"{r['mcc']:+.3f}"]) note = ("BTC at the 12-hour horizon, every metric. MCC is the figure to quote " "where one number is wanted: unlike accuracy and ROC-AUC it cannot be " "inflated by class balance.") return block("Architecture ablation, full metrics (BTC)", note, rows, ["Model", "ROC-AUC", "Accuracy \\%", "Majority \\%", "Margin", "MCC"], "Architecture ablation, BTC at 12h, all metrics.", "tab:ablation-btc") def table_features(data) -> str: rows = [] for asset in ASSETS: feats = data.get(asset, {}).get("features", {}) for label, r in feats.items(): rows.append([short(asset), label, f"{r['roc_auc']:.4f}", f"{r['accuracy'] * 100:.2f}", f"{r['margin'] * 100:+.2f}", f"{r['delta_auc']:+.4f}"]) note = ("Historic market data against sentiment and on-chain data, on the " "deployed architecture. Both external sources are daily and lagged a " "full day, so no feature describes the hour it scores. No arm improves " "on market data alone.") return block("Feature ablation: historic versus sentiment", note, rows, ["Asset", "Feature set", "ROC-AUC", "Accuracy \\%", "Margin", "$\\Delta$ AUC"], "Feature ablation. Change in ROC-AUC against market features alone.", "tab:features") def table_har(data) -> str: rows = [] for asset in ASSETS: for label, models in sorted(data.get(asset, {}).items(), key=lambda kv: int(kv[0].rstrip("h"))): if "HAR-RV" not in models: continue har = models["HAR-RV"] best = max(((k, v) for k, v in models.items() if k != "HAR-RV"), key=lambda kv: kv[1]["r2"]) cheapest = min(models.items(), key=lambda kv: kv[1]["qlike"]) rows.append([short(asset), label, f"{har['r2']:.4f}", f"{har['qlike']:.4f}", f"{best[1]['r2']:.4f}", f"{best[1]['r2'] / har['r2']:.1f}$\\times$", best[0].replace("torch MLP, 33 features + HAR", "MLP") .replace("HAR + features (OLS)", "OLS"), cheapest[0].replace("torch MLP, 33 features + HAR", "MLP") .replace("HAR + features (OLS)", "OLS")]) note = ("Log realised volatility against HAR-RV on identical folds. QLIKE is " "reported because it is the loss this literature uses; the model with " "the best $R^2$ is frequently not the one with the best QLIKE.") return block("Regression benchmark against HAR-RV", note, rows, ["Asset", "Horizon", "HAR $R^2$", "HAR QLIKE", "Best $R^2$", "Ratio", "Best on $R^2$", "Best on QLIKE"], "Realised-volatility regression against HAR-RV.", "tab:har") def table_deployed() -> str: rows = [ ["Volatility rises", "BTC 12h", "0.8209", "73.59", "50.88 (majority)", "+22.71"], ["Volatility rises", "ETH 12h", "0.8148", "74.64", "50.41 (majority)", "+24.24"], ["Volatility rises", "ADA 1h", "0.8143", "73.76", "52.00 (majority)", "+21.76"], ["Jump risk", "BTC 24h", "0.7648", "70.08", "55.32 (persistence)", "+14.76"], ["Jump risk", "ETH 24h", "0.6801", "65.04", "54.80 (persistence)", "+10.23"], ["Jump risk", "ADA 24h", "0.7053", "62.86", "57.73 (persistence)", "+5.13"], ["Volatility regime", "BTC 24h", "0.7761", "74.37", "64.60 (persistence)", "+9.77"], ["Volatility regime", "ETH 24h", "0.7729", "72.69", "--", "--"], ["Volatility regime", "ADA 24h", "0.8145", "72.26", "--", "--"], ] note = ("The deployed VAE+Transformer artifacts, five seeds each, scored once " "on a held-out block. The baseline column names which baseline binds: " "volatility-rises is measured against the majority class because its " "persistence baseline sits below chance, jump risk against persistence " "because that is the harder of the two.") return block("Deployed models", note, rows, ["Model", "Asset", "ROC-AUC", "Accuracy \\%", "Baseline \\%", "Margin"], "Deployed models, held-out performance against the binding baseline.", "tab:deployed") def table_direction() -> str: rows = [ ["Deployed direction model", "1h", "0.503", "49.4", r"$\approx$56\%\textsuperscript{*}", "Jaquart et al. (2021), D1"], ["Deployed direction model", "6h", "0.474", "50.3", r"61\% train, test flat", "Kraehenbuehl \\& Osterrieder (2022), D3"], ["Deployed direction model", "24h", "0.490", "50.3", r"\textbf{79.50\%}", "Seabe et al. (2025), C1"], ["Triple barrier 1\\%", "48h", "0.4989", "50.15", "method, no figure", r"L\'opez de Prado (2018), D4"], ["Triple barrier 2.5\\%", "48h", "0.5167", "51.31", "method, no figure", r"L\'opez de Prado (2018), D4"], ["Triple barrier 5\\%", "48h", "0.5265", "52.27", "method, no figure", r"L\'opez de Prado (2018), D4"], ["\\emph{Volatility rises (control)}", "12h", "\\emph{0.8286}", "\\emph{75.31}", "\\emph{n/a}", "\\emph{positive control}"], ] note = ("The directional negative result, BTC. ROC-AUC needs no baseline: 0.5000 " "is chance by construction, and every directional row sits on it. " "Accuracy does, and each row was measured against its own majority " "class, computed from the same test rows: 51.0\\% for the three " "deployed horizons, 51.1\\%, 52.0\\% and 53.9\\% for the barriers at " "1\\%, 2.5\\% and 5\\%, and 50.54\\% for the control. Every " "configuration therefore loses to a guess, by 0.7 to 1.7 points, while " "the control beats it by 24.77. The final row is the positive control: " "the same features, " "pipeline and folds detect a large effect when one is present, which is " "what makes the negative result informative rather than inconclusive. " "`Reported` is the figure the source itself publishes for the same kind " "of target, placed beside the measurement so the disagreement is visible " "on one line. None of those sources reports a baseline alongside it. " "\\textsuperscript{*}at 1--60 minute horizons, and its authors state the " "60-minute signal is not exploitable after transaction costs.") return block("Directional predictability", note, rows, ["Target", "Horizon", "ROC-AUC", "Accuracy \\%", "Reported \\%", "Source"], "Directional predictability, with volatility-rises as a positive " "control.", "tab:direction") def table_direction_literature() -> str: """The five studies the directional result answers to. Transcribed from the sources rather than measured here, so it carries the verification state in the row: D1 and D2 are summarised from abstracts and need confirming against the PDFs. """ rows = [ ["D1", "Jaquart et al. (2021)", "1--60 min, ordered split", r"$\approx$56\%, not exploitable after costs", "horizon below 1h"], ["D2", "Jaquart et al. (2022)", "cross-section, costs modelled", r"39\%/month before costs, negative after", "costs remove the edge"], ["D3", r"Kraehenbuehl \& Osterrieder (2022)", "train/test separation", r"train 54.6\%$\rightarrow$61\%, test flat", "agrees: no detectable edge"], ["D4", r"L\'opez de Prado (2018)", "purged $k$-fold, embargo, CPCV", "methodological source", "protocol adopted here"], ["D5", r"Kapoor \& Narayanan (2023)", "294 papers, 17 fields", "leakage inflates reported gains", "explains the gap"], ["C1", "Seabe et al. (2025)", "not stated", r"79.50\% directional accuracy", "no baseline reported"], # An em dash in the key column renders as an empty cell to a reader # skimming the table, which is the wrong impression for the one row # that is this project's own measurement. [r"\textbf{Ours}", r"\textbf{Measured here}", "purged walk-forward, 6 configs", r"\textbf{ROC-AUC 0.47--0.53}", "control at 0.8286"], ] note = ("The directional claim set against the literature it contradicts. The " "gap between a reported 79.50\\% and a measured 0.49 is a difference " "of protocol, not of architecture: D4 names the mechanism and D5 " "quantifies how often it is skipped. D1 and D2 report positive results " "that are consistent with this one, at horizons below an hour or before " "transaction costs. D1 and D2 are summarised from abstracts and are " "pending verification against the PDFs.") return block("Directional predictability against the literature", note, rows, ["\\#", "Study", "Protocol", "Reported", "Relation to this result"], "Directional predictability: five published studies against this " "project's measurement under purged walk-forward validation.", "tab:direction-literature") def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--latex-only", action="store_true") args = ap.parse_args() ablation = load("ablation_study.json") har = load("har_benchmark.json") parts = ["# Results tables\n", "Generated by `scripts/make_report_tables.py` from the experiment " "JSONs. Nothing here is transcribed by hand, so re-running the " "experiments and this script keeps the tables and the measurements in " "step. Each table carries its LaTeX in a collapsed block.\n"] if ablation: parts += [table_ablation(ablation), table_ablation_full(ablation), table_features(ablation)] if har: parts.append(table_har(har)) parts += [table_deployed(), table_direction(), table_direction_literature()] OUT.write_text("\n".join(parts), encoding="utf-8") print(f"written: {OUT.relative_to(ROOT)}") for name, present in (("ablation_study.json", ablation), ("har_benchmark.json", har)): print(f" {name}: {'read' if present else 'MISSING, tables omitted'}") if __name__ == "__main__": main()