Download scripts/strategy_backtest.py from chonthichar/crypto-forecasting-ai: direct link, hf CLI and curl.
- Browser
- Download file 10.2 kB
-
https://huggingface.co/spaces/chonthichar/crypto-forecasting-ai/resolve/main/scripts/strategy_backtest.py
- Command line
-
hf download hf://spaces/chonthichar/crypto-forecasting-ai/scripts/strategy_backtest.py
-
curl -L -o strategy_backtest.py https://huggingface.co/spaces/chonthichar/crypto-forecasting-ai/resolve/main/scripts/strategy_backtest.py
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() | |