crypto-forecasting-ai / scripts /strategy_backtest.py
chonthichar's picture
Claude Opus 5.5
Size the live price range with a HAR blend of day, week and month
9782da2
Raw History Blame Contribute Delete
10.2 kB
"""Can the volatility forecast help a BTC holder, without predicting direction?
Five ways to hold BTC, Jan 2023 - Sep 2026, decided once a day at 00:00 UTC
from information available then, with a 0.1% fee on every unit traded:
Buy and hold 100% BTC throughout
Weekly DCA cash invested in equal weekly slices (Mondays)
Volatility-managed exposure = min(1, target / forecast volatility); the
forecast is the past week's hourly volatility (model
B's core) and the target its trailing one-year median,
so a calm market is held fully and a wild one partly
(Moreira & Muir 2017, J. Finance)
+ Fed/CPI rule as above, halved on a day with a Fed decision or CPI
release, whose dates are published ahead
20-week trend 100% BTC when yesterday's close is above its 140-day
average, otherwise cash
The live VAE+Transformer regime model is NOT used: it was trained on data to
2026, so trading 2023 with it would use the future. The causal inputs above
are all known at the moment of each decision.
Metrics: total and annualised return, annualised volatility, Sharpe ratio
(daily, risk-free 0, x sqrt(365)), maximum drawdown, average exposure.
A block bootstrap (30-day blocks) gives a 95% interval for each strategy's
Sharpe minus buy-and-hold's.
Usage:
python scripts/strategy_backtest.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from news_topic_event_study import GRID, INK, INK_2, SURFACE # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.forecasting.price_range import releases # noqa: E402
START, FEE = "2023-01-01", 0.001
COLORS = {"Buy and hold": "#8b8a85", "Weekly DCA": "#c9a227", "Volatility-managed": "#2a78d6",
"+ Fed/CPI rule": "#0b3d91", "20-week trend": "#eb6834"}
def inputs() -> pd.DataFrame:
btc = pd.read_csv(ROOT / "outputs" / "volatility" / "btc_1h.csv", usecols=["timestamp", "close"])
btc["timestamp"] = pd.to_datetime(btc["timestamp"], utc=True)
hourly = btc.drop_duplicates("timestamp").set_index("timestamp")["close"].sort_index().asfreq("1h")
r = np.log(hourly).diff()
sigma = r.rolling(168, min_periods=150).std() # past week, hourly
day = pd.DataFrame({
"close": hourly.resample("1D").last(),
# value at the END of the previous day = what is known at 00:00 today
"sigma": sigma.resample("1D").last().shift(1),
})
day["target"] = day["sigma"].rolling(365, min_periods=180).median()
day["sma140"] = day["close"].rolling(140).mean().shift(1)
day["prev_close"] = day["close"].shift(1)
rel_days = {t.floor("D") for t, _ in releases()}
day["release_day"] = day.index.isin(list(rel_days))
day["ret"] = day["close"].pct_change()
return day[day.index >= START].dropna(subset=["ret", "sigma", "target", "sma140"])
def run(weights: pd.Series, ret: pd.Series) -> pd.Series:
"""Daily portfolio return: weight held over the day, fee on the change."""
turnover = weights.diff().abs().fillna(weights.iloc[0])
return weights * ret - FEE * turnover
def dca(day: pd.DataFrame) -> pd.Series:
"""Account value with cash fed into BTC in equal Monday slices."""
mondays = day.index[day.index.dayofweek == 0]
slice_ = 1.0 / len(mondays)
cash, coins, values = 1.0, 0.0, []
for t, row in day.iterrows():
if t in mondays:
coins += slice_ * (1 - FEE) / row["close"]
cash -= slice_
values.append(cash + coins * row["close"])
return pd.Series(values, index=day.index)
def metrics(value: pd.Series, exposure: float) -> dict:
daily = value.pct_change().dropna()
years = (value.index[-1] - value.index[0]).days / 365
peak = value.cummax()
return {
"total_return_pct": float((value.iloc[-1] / value.iloc[0] - 1) * 100),
"annual_return_pct": float(((value.iloc[-1] / value.iloc[0]) ** (1 / years) - 1) * 100),
"annual_volatility_pct": float(daily.std() * np.sqrt(365) * 100),
"sharpe": float(daily.mean() / daily.std() * np.sqrt(365)),
"max_drawdown_pct": float(((value / peak) - 1).min() * 100),
"average_exposure_pct": float(exposure * 100),
}
def sharpe_gap_ci(a: pd.Series, b: pd.Series, rng, block=30, n=2000):
"""95% interval for Sharpe(a) - Sharpe(b) by moving-block bootstrap."""
x = pd.concat([a, b], axis=1).dropna().to_numpy()
starts = np.arange(len(x) - block)
out = []
for _ in range(n):
idx = np.concatenate([np.arange(s, s + block) for s in rng.choice(starts, len(x) // block + 1)])[:len(x)]
s = x[idx]
sh = s.mean(0) / s.std(0) * np.sqrt(365)
out.append(sh[0] - sh[1])
return [float(np.quantile(out, 0.025)), float(np.quantile(out, 0.975))]
def main() -> None:
sys.stdout.reconfigure(encoding="utf-8")
rng = np.random.default_rng(0)
day = inputs()
ret = day["ret"]
vm = (day["target"] / day["sigma"]).clip(upper=1.0)
weights = {
"Buy and hold": pd.Series(1.0, index=day.index),
"Volatility-managed": vm,
"+ Fed/CPI rule": vm.where(~day["release_day"], vm * 0.5),
"20-week trend": (day["prev_close"] > day["sma140"]).astype(float),
}
values = {k: (1 + run(w, ret)).cumprod() for k, w in weights.items()}
values["Weekly DCA"] = dca(day)
exposure = {k: float(w.mean()) for k, w in weights.items()}
dca_btc = (values["Weekly DCA"] - (1 - np.minimum(1, np.cumsum(day.index.dayofweek == 0) /
(day.index.dayofweek == 0).sum()))) / values["Weekly DCA"]
exposure["Weekly DCA"] = float(dca_btc.mean())
order = ["Buy and hold", "Weekly DCA", "Volatility-managed", "+ Fed/CPI rule", "20-week trend"]
res = {k: metrics(values[k], exposure[k]) for k in order}
bh = values["Buy and hold"].pct_change()
for k in order[1:]:
res[k]["sharpe_minus_buy_hold_ci"] = sharpe_gap_ci(values[k].pct_change(), bh, rng)
print(f"{day.index[0].date()} -> {day.index[-1].date()}, {len(day)} days, fee {FEE:.1%} per trade\n")
print(f"{'strategy':20s} {'total':>8} {'per yr':>7} {'vol/yr':>7} {'Sharpe':>7} {'max DD':>8} {'in BTC':>7} Sharpe vs B&H (95%)")
for k in order:
r = res[k]
ci = r.get("sharpe_minus_buy_hold_ci")
print(f"{k:20s} {r['total_return_pct']:>+7.0f}% {r['annual_return_pct']:>+6.1f}% "
f"{r['annual_volatility_pct']:>6.1f}% {r['sharpe']:>7.2f} {r['max_drawdown_pct']:>+7.1f}% "
f"{r['average_exposure_pct']:>6.0f}% " + (f"[{ci[0]:+.2f}, {ci[1]:+.2f}]" if ci else ""))
(ROOT / "outputs" / "strategy_backtest.json").write_text(json.dumps({
"period": [str(day.index[0].date()), str(day.index[-1].date())], "fee_per_trade": FEE,
"decision": "daily at 00:00 UTC from information known then",
"results": res}, indent=2))
draw(values, res, order)
def draw(values, res, order) -> None:
plt.rcParams.update({"font.family": "DejaVu Sans", "text.color": INK, "axes.labelcolor": INK_2})
fig, (a, b) = plt.subplots(2, 1, figsize=(13.5, 8.6), facecolor=SURFACE, sharex=True,
gridspec_kw={"height_ratios": [1.6, 1]})
fig.subplots_adjust(left=0.07, right=0.8, top=0.86, bottom=0.07, hspace=0.12)
for ax in (a, b):
ax.set_facecolor(SURFACE)
for s in ("top", "right"):
ax.spines[s].set_visible(False)
for s in ("left", "bottom"):
ax.spines[s].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)
for k in order:
v = values[k]
w = 2.6 if k == "+ Fed/CPI rule" else 1.8
a.plot(v.index, v, color=COLORS[k], linewidth=w)
b.plot(v.index, (v / v.cummax() - 1) * 100, color=COLORS[k], linewidth=w * 0.75)
# End labels, pushed apart in log space so close finishes do not overlap.
ends = sorted(order, key=lambda k: values[k].iloc[-1], reverse=True)
placed = []
for k in ends:
y = values[k].iloc[-1]
if placed and np.log(placed[-1]) - np.log(y) < 0.2:
y = placed[-1] / np.exp(0.2)
placed.append(y)
r = res[k]
a.annotate(f"{k}\n{r['total_return_pct']:+.0f}%, Sharpe {r['sharpe']:.2f}, max DD {r['max_drawdown_pct']:.0f}%",
xy=(values[k].index[-1], values[k].iloc[-1]), xytext=(values[k].index[-1] + pd.Timedelta(days=25), y),
va="center", fontsize=8.3, color=INK, annotation_clip=False,
arrowprops=dict(arrowstyle="-", color=COLORS[k], linewidth=1))
a.set_yscale("log")
ticks = [1, 1.5, 2, 3, 4, 6]
a.set_yticks(ticks)
a.set_yticklabels([f"${t:g}" for t in ticks])
a.minorticks_off()
a.set_ylabel("value of $1 (log scale)", fontsize=8.5)
a.set_title("Growth of $1", loc="left", fontsize=11, fontweight="bold")
b.set_ylabel("% below previous peak", fontsize=8.5)
b.set_title("Drawdown: how far each strategy was below its own best", loc="left", fontsize=11,
fontweight="bold")
fig.suptitle("Using the volatility forecast to size a BTC position, Jan 2023 – Sep 2026",
x=0.07, ha="left", fontsize=14, fontweight="bold", y=0.975)
fig.text(0.07, 0.915, "Decided daily at 00:00 UTC from information known then; 0.1% fee per trade. "
"No strategy predicts direction: they change how much BTC is held, or when it is bought.",
fontsize=9.2, color=INK_2, ha="left")
fig.savefig(ROOT / "outputs" / "figures" / "fig18_strategy_backtest.png", dpi=150, facecolor=SURFACE)
plt.close(fig)
print("\nwritten: outputs/figures/fig18_strategy_backtest.png")
if __name__ == "__main__":
main()