Spaces:
Running
Running
feat(autotune): run_autotune orchestrator with heatmap output
Browse filesCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- autotune.py +93 -0
- tests/test_autotune.py +82 -0
autotune.py
CHANGED
|
@@ -5,6 +5,7 @@ from typing import Optional
|
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
import pandas as pd
|
|
|
|
| 8 |
|
| 9 |
_TUNING_DDL = """
|
| 10 |
CREATE TABLE IF NOT EXISTS tuning_results (
|
|
@@ -147,3 +148,95 @@ def backtest_core(predict_fn, fetch_fn,
|
|
| 147 |
"sharpe": sharpe,
|
| 148 |
"per_anchor": df,
|
| 149 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
import pandas as pd
|
| 8 |
+
import plotly.graph_objects as go
|
| 9 |
|
| 10 |
_TUNING_DDL = """
|
| 11 |
CREATE TABLE IF NOT EXISTS tuning_results (
|
|
|
|
| 148 |
"sharpe": sharpe,
|
| 149 |
"per_anchor": df,
|
| 150 |
}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
GRID_T = (0.5, 1.0, 1.5)
|
| 154 |
+
GRID_TOP_P = (0.7, 0.85, 0.95)
|
| 155 |
+
|
| 156 |
+
def run_autotune(*, predict_fn, fetch_fn, db_path: str,
|
| 157 |
+
symbol: str, interval: str,
|
| 158 |
+
start_date: str, end_date: str,
|
| 159 |
+
lookback: int, pred_len: int, stride: int,
|
| 160 |
+
sample_count: int, max_anchors: int):
|
| 161 |
+
params = dict(lookback=lookback, pred_len=pred_len, stride=stride,
|
| 162 |
+
sample_count=sample_count, max_anchors=max_anchors,
|
| 163 |
+
start_date=start_date, end_date=end_date)
|
| 164 |
+
results = []
|
| 165 |
+
for T in GRID_T:
|
| 166 |
+
for top_p in GRID_TOP_P:
|
| 167 |
+
try:
|
| 168 |
+
m = backtest_core(
|
| 169 |
+
predict_fn, fetch_fn,
|
| 170 |
+
symbol=symbol, interval=interval,
|
| 171 |
+
start_date=start_date, end_date=end_date,
|
| 172 |
+
lookback=lookback, pred_len=pred_len, stride=stride,
|
| 173 |
+
T=T, top_p=top_p,
|
| 174 |
+
sample_count=sample_count, max_anchors=max_anchors,
|
| 175 |
+
)
|
| 176 |
+
results.append({
|
| 177 |
+
"T": T, "top_p": top_p, "error": None,
|
| 178 |
+
"anchors": m["anchors"],
|
| 179 |
+
"mean_rmse": m["mean_rmse"],
|
| 180 |
+
"hit_rate": m["hit_rate"],
|
| 181 |
+
"total_return_pct": m["total_return_pct"],
|
| 182 |
+
"max_dd_pct": m["max_dd_pct"],
|
| 183 |
+
"sharpe": m["sharpe"],
|
| 184 |
+
})
|
| 185 |
+
except Exception as e:
|
| 186 |
+
results.append({
|
| 187 |
+
"T": T, "top_p": top_p, "error": str(e),
|
| 188 |
+
"anchors": 0, "mean_rmse": float("nan"),
|
| 189 |
+
"hit_rate": float("nan"),
|
| 190 |
+
"total_return_pct": float("nan"),
|
| 191 |
+
"max_dd_pct": float("nan"),
|
| 192 |
+
"sharpe": float("nan"),
|
| 193 |
+
})
|
| 194 |
+
|
| 195 |
+
valid = [r for r in results if r["error"] is None]
|
| 196 |
+
if not valid:
|
| 197 |
+
first_err = results[0]["error"]
|
| 198 |
+
raise RuntimeError(f"All grid cells failed: {first_err}")
|
| 199 |
+
|
| 200 |
+
best = max(valid, key=lambda r: r["total_return_pct"])
|
| 201 |
+
save_tuning(db_path, symbol, interval, best, results, params)
|
| 202 |
+
|
| 203 |
+
grid_df = pd.DataFrame(results)
|
| 204 |
+
z = np.full((len(GRID_T), len(GRID_TOP_P)), np.nan)
|
| 205 |
+
for i, T in enumerate(GRID_T):
|
| 206 |
+
for j, top_p in enumerate(GRID_TOP_P):
|
| 207 |
+
row = next(r for r in results if r["T"] == T and r["top_p"] == top_p)
|
| 208 |
+
if row["error"] is None:
|
| 209 |
+
z[i, j] = row["total_return_pct"]
|
| 210 |
+
|
| 211 |
+
fig = go.Figure(data=go.Heatmap(
|
| 212 |
+
z=z, x=[str(p) for p in GRID_TOP_P], y=[str(t) for t in GRID_T],
|
| 213 |
+
colorscale="RdYlGn", zmid=0.0, colorbar=dict(title="P&L %"),
|
| 214 |
+
))
|
| 215 |
+
fig.update_layout(
|
| 216 |
+
title=f"{symbol.upper()} {interval} — Auto-tune heatmap (P&L %)",
|
| 217 |
+
xaxis_title="top_p", yaxis_title="T",
|
| 218 |
+
template="plotly_dark", height=420,
|
| 219 |
+
margin=dict(l=20, r=20, t=60, b=20),
|
| 220 |
+
)
|
| 221 |
+
fig.add_annotation(
|
| 222 |
+
x=str(best["top_p"]), y=str(best["T"]),
|
| 223 |
+
text="★", showarrow=False, font=dict(size=24, color="black"),
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
cells_errored = sum(1 for r in results if r["error"] is not None)
|
| 227 |
+
summary = pd.DataFrame({
|
| 228 |
+
"Field": ["Symbol", "Interval", "Best T", "Best top_p",
|
| 229 |
+
"Best P&L %", "Best hit rate %", "Best mean RMSE",
|
| 230 |
+
"Anchors per cell", "Total cells", "Cells errored"],
|
| 231 |
+
"Value": [
|
| 232 |
+
symbol.upper(), interval,
|
| 233 |
+
f"{best['T']}", f"{best['top_p']}",
|
| 234 |
+
f"{best['total_return_pct']:+.3f}",
|
| 235 |
+
f"{best['hit_rate'] * 100.0:.2f}",
|
| 236 |
+
f"{best['mean_rmse']:.4f}",
|
| 237 |
+
f"{best['anchors']}", f"{len(results)}",
|
| 238 |
+
f"{cells_errored}",
|
| 239 |
+
],
|
| 240 |
+
})
|
| 241 |
+
|
| 242 |
+
return fig, summary, grid_df
|
tests/test_autotune.py
CHANGED
|
@@ -134,3 +134,85 @@ def test_backtest_core_raises_when_lookback_plus_predlen_too_big():
|
|
| 134 |
lookback=400, pred_len=200, stride=50,
|
| 135 |
T=1.0, top_p=0.9,
|
| 136 |
sample_count=1, max_anchors=5)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
lookback=400, pred_len=200, stride=50,
|
| 135 |
T=1.0, top_p=0.9,
|
| 136 |
sample_count=1, max_anchors=5)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
from unittest.mock import MagicMock
|
| 140 |
+
from autotune import run_autotune, GRID_T, GRID_TOP_P
|
| 141 |
+
|
| 142 |
+
def test_run_autotune_iterates_full_grid_and_picks_max(tmp_db, monkeypatch):
|
| 143 |
+
init_tuning_table(tmp_db)
|
| 144 |
+
calls = []
|
| 145 |
+
|
| 146 |
+
def fake_core(predict_fn, fetch_fn, *, symbol, interval, start_date, end_date,
|
| 147 |
+
lookback, pred_len, stride, T, top_p, sample_count, max_anchors):
|
| 148 |
+
calls.append((T, top_p))
|
| 149 |
+
# Make T=1.0, top_p=0.85 win clearly
|
| 150 |
+
score = 9.0 if (T == 1.0 and top_p == 0.85) else 1.0
|
| 151 |
+
return {
|
| 152 |
+
"anchors": 5, "mean_rmse": 1.0,
|
| 153 |
+
"hit_rate": 0.5,
|
| 154 |
+
"total_return_pct": score,
|
| 155 |
+
"max_dd_pct": -1.0, "sharpe": 0.5,
|
| 156 |
+
"per_anchor": pd.DataFrame(),
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
monkeypatch.setattr("autotune.backtest_core", fake_core)
|
| 160 |
+
fig, summary, grid = run_autotune(
|
| 161 |
+
predict_fn=MagicMock(), fetch_fn=MagicMock(),
|
| 162 |
+
db_path=tmp_db, symbol="SPY", interval="5min",
|
| 163 |
+
start_date="", end_date="",
|
| 164 |
+
lookback=200, pred_len=30, stride=30,
|
| 165 |
+
sample_count=1, max_anchors=5,
|
| 166 |
+
)
|
| 167 |
+
assert sorted(calls) == sorted([(t, p) for t in GRID_T for p in GRID_TOP_P])
|
| 168 |
+
assert len(grid) == 9
|
| 169 |
+
best_row = summary[summary["Field"] == "Best T"].iloc[0]["Value"]
|
| 170 |
+
assert float(best_row) == 1.0
|
| 171 |
+
|
| 172 |
+
persisted = load_tuning(tmp_db, "SPY", "5min")
|
| 173 |
+
assert persisted["best_T"] == 1.0
|
| 174 |
+
assert persisted["best_top_p"] == 0.85
|
| 175 |
+
assert persisted["best_pnl_pct"] == 9.0
|
| 176 |
+
|
| 177 |
+
def test_run_autotune_skips_failed_cells(tmp_db, monkeypatch):
|
| 178 |
+
init_tuning_table(tmp_db)
|
| 179 |
+
|
| 180 |
+
def flaky_core(predict_fn, fetch_fn, *, T, top_p, **kw):
|
| 181 |
+
if T == 0.5:
|
| 182 |
+
raise RuntimeError("fmp blew up")
|
| 183 |
+
return {"anchors": 5, "mean_rmse": 1.0, "hit_rate": 0.5,
|
| 184 |
+
"total_return_pct": 2.0 + top_p,
|
| 185 |
+
"max_dd_pct": -1.0, "sharpe": 0.5,
|
| 186 |
+
"per_anchor": pd.DataFrame()}
|
| 187 |
+
|
| 188 |
+
monkeypatch.setattr("autotune.backtest_core", flaky_core)
|
| 189 |
+
fig, summary, grid = run_autotune(
|
| 190 |
+
predict_fn=MagicMock(), fetch_fn=MagicMock(),
|
| 191 |
+
db_path=tmp_db, symbol="X", interval="5min",
|
| 192 |
+
start_date="", end_date="",
|
| 193 |
+
lookback=200, pred_len=30, stride=30,
|
| 194 |
+
sample_count=1, max_anchors=5,
|
| 195 |
+
)
|
| 196 |
+
errored = grid[grid["error"].notna()]
|
| 197 |
+
assert len(errored) == 3 # all T=0.5 rows
|
| 198 |
+
valid = grid[grid["error"].isna()]
|
| 199 |
+
assert len(valid) == 6
|
| 200 |
+
persisted = load_tuning(tmp_db, "X", "5min")
|
| 201 |
+
assert persisted["best_T"] != 0.5 # never the failing T
|
| 202 |
+
|
| 203 |
+
def test_run_autotune_raises_when_all_cells_fail(tmp_db, monkeypatch):
|
| 204 |
+
init_tuning_table(tmp_db)
|
| 205 |
+
def always_fail(*a, **kw):
|
| 206 |
+
raise RuntimeError("everything is broken")
|
| 207 |
+
monkeypatch.setattr("autotune.backtest_core", always_fail)
|
| 208 |
+
|
| 209 |
+
import pytest
|
| 210 |
+
with pytest.raises(RuntimeError, match="All grid cells failed"):
|
| 211 |
+
run_autotune(
|
| 212 |
+
predict_fn=MagicMock(), fetch_fn=MagicMock(),
|
| 213 |
+
db_path=tmp_db, symbol="X", interval="5min",
|
| 214 |
+
start_date="", end_date="",
|
| 215 |
+
lookback=200, pred_len=30, stride=30,
|
| 216 |
+
sample_count=1, max_anchors=5,
|
| 217 |
+
)
|
| 218 |
+
assert load_tuning(tmp_db, "X", "5min") is None
|