200 lines
7.6 KiB
Python
200 lines
7.6 KiB
Python
"""Run Bollinger Band mean-reversion backtest on ETH 2023+2024.
|
|
|
|
Preloads data once, then sweeps parameters in-memory for speed.
|
|
"""
|
|
import sys, time
|
|
sys.stdout.reconfigure(line_buffering=True)
|
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1]))
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
from pathlib import Path
|
|
from collections import defaultdict
|
|
|
|
from strategy.bb_backtest import BBConfig, run_bb_backtest
|
|
from strategy.data_loader import KlineSource, load_klines
|
|
from datetime import datetime, timezone
|
|
|
|
root = Path(__file__).resolve().parents[1]
|
|
src = KlineSource(db_path=root / "models" / "database.db", table_name="bitmart_eth_5m")
|
|
out_dir = root / "strategy" / "results"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
t0 = time.time()
|
|
|
|
# Preload data once
|
|
print("Loading data...")
|
|
df_23 = load_klines(src, datetime(2023,1,1,tzinfo=timezone.utc),
|
|
datetime(2023,12,31,23,59,tzinfo=timezone.utc))
|
|
df_24 = load_klines(src, datetime(2024,1,1,tzinfo=timezone.utc),
|
|
datetime(2024,12,31,23,59,tzinfo=timezone.utc))
|
|
data = {2023: df_23, 2024: df_24}
|
|
print(f"Loaded: 2023={len(df_23)} bars, 2024={len(df_24)} bars ({time.time()-t0:.1f}s)")
|
|
|
|
# ================================================================
|
|
# Sweep
|
|
# ================================================================
|
|
print("\n" + "=" * 120)
|
|
print(" Bollinger Band Mean-Reversion — ETH 5min | 1000U capital")
|
|
print(" touch upper BB -> short, touch lower BB -> long (flip)")
|
|
print("=" * 120)
|
|
|
|
results = []
|
|
|
|
def test(label, cfg):
|
|
"""Run on both years, print summary, store results."""
|
|
row = {"label": label, "cfg": cfg}
|
|
for year in [2023, 2024]:
|
|
r = run_bb_backtest(data[year], cfg)
|
|
d = r.daily_stats
|
|
pnl = d["pnl"].astype(float)
|
|
eq = d["equity"].astype(float)
|
|
dd = float((eq - eq.cummax()).min())
|
|
final = float(eq.iloc[-1])
|
|
nt = len(r.trades)
|
|
wr = sum(1 for t in r.trades if t.net_pnl > 0) / max(nt, 1) * 100
|
|
nf = r.total_fee - r.total_rebate
|
|
row[f"a{year}"] = float(pnl.mean())
|
|
row[f"d{year}"] = dd
|
|
row[f"r{year}"] = r
|
|
row[f"n{year}"] = nt
|
|
row[f"w{year}"] = wr
|
|
row[f"f{year}"] = nf
|
|
row[f"eq{year}"] = final
|
|
mn = min(row["a2023"], row["a2024"])
|
|
avg = (row["a2023"] + row["a2024"]) / 2
|
|
mark = " <<<" if mn >= 20 else (" **" if mn >= 10 else "")
|
|
print(f" {label:52s} 23:{row['a2023']:+6.1f} 24:{row['a2024']:+6.1f} "
|
|
f"avg:{avg:+5.1f} n23:{row['n2023']:3d} n24:{row['n2024']:3d} "
|
|
f"dd:{min(row['d2023'],row['d2024']):+7.0f}{mark}")
|
|
row["mn"] = mn; row["avg"] = avg
|
|
results.append(row)
|
|
|
|
# [1] BB period
|
|
print("\n[1] Period sweep")
|
|
for p in [10, 15, 20, 30, 40]:
|
|
test(f"BB({p},2.0) 80u 100x", BBConfig(bb_period=p, bb_std=2.0, margin_per_trade=80, leverage=100))
|
|
|
|
# [2] BB std
|
|
print("\n[2] Std sweep")
|
|
for s in [1.5, 1.8, 2.0, 2.5, 3.0]:
|
|
test(f"BB(20,{s}) 80u 100x", BBConfig(bb_period=20, bb_std=s, margin_per_trade=80, leverage=100))
|
|
|
|
# [3] Margin
|
|
print("\n[3] Margin sweep")
|
|
for m in [40, 60, 80, 100, 120]:
|
|
test(f"BB(20,2.0) {m}u 100x", BBConfig(bb_period=20, bb_std=2.0, margin_per_trade=m, leverage=100))
|
|
|
|
# [4] SL
|
|
print("\n[4] Stop-loss sweep")
|
|
for sl in [0.0, 0.01, 0.02, 0.03, 0.05]:
|
|
test(f"BB(20,2.0) 80u SL={sl:.0%}", BBConfig(bb_period=20, bb_std=2.0, margin_per_trade=80, leverage=100, stop_loss_pct=sl))
|
|
|
|
# [5] MDL
|
|
print("\n[5] Max daily loss")
|
|
for mdl in [50, 100, 150, 200]:
|
|
test(f"BB(20,2.0) 80u mdl={mdl}", BBConfig(bb_period=20, bb_std=2.0, margin_per_trade=80, leverage=100, max_daily_loss=mdl))
|
|
|
|
# [6] Combined fine-tune
|
|
print("\n[6] Fine-tune")
|
|
for p in [15, 20, 30]:
|
|
for s in [1.5, 2.0, 2.5]:
|
|
for m in [80, 100]:
|
|
test(f"BB({p},{s}) {m}u mdl=150",
|
|
BBConfig(bb_period=p, bb_std=s, margin_per_trade=m, leverage=100, max_daily_loss=150))
|
|
|
|
# ================================================================
|
|
# Ranking
|
|
# ================================================================
|
|
results.sort(key=lambda x: x["mn"], reverse=True)
|
|
print(f"\n{'='*120}")
|
|
print(f" TOP 10 — ranked by min(daily_avg_2023, daily_avg_2024)")
|
|
print(f"{'='*120}")
|
|
for i, r in enumerate(results[:10]):
|
|
print(f" {i+1:2d}. {r['label']:50s} 23:{r['a2023']:+6.1f} 24:{r['a2024']:+6.1f} "
|
|
f"min:{r['mn']:+6.1f} dd:{min(r['d2023'],r['d2024']):+7.0f} "
|
|
f"wr23:{r['w2023']:.0f}% wr24:{r['w2024']:.0f}%")
|
|
|
|
# ================================================================
|
|
# Detailed report for best
|
|
# ================================================================
|
|
best = results[0]
|
|
print(f"\n{'#'*70}")
|
|
print(f" BEST: {best['label']}")
|
|
print(f"{'#'*70}")
|
|
|
|
for year in [2023, 2024]:
|
|
r = best[f"r{year}"]
|
|
cfg = best["cfg"]
|
|
d = r.daily_stats
|
|
pnl = d["pnl"].astype(float)
|
|
eq = d["equity"].astype(float)
|
|
dd = (eq - eq.cummax()).min()
|
|
final = float(eq.iloc[-1])
|
|
nt = len(r.trades)
|
|
wr = sum(1 for t in r.trades if t.net_pnl > 0) / max(nt, 1)
|
|
nf = r.total_fee - r.total_rebate
|
|
|
|
loss_streak = max_ls = 0
|
|
for v in pnl.values:
|
|
if v < 0: loss_streak += 1; max_ls = max(max_ls, loss_streak)
|
|
else: loss_streak = 0
|
|
|
|
print(f"\n --- {year} ---")
|
|
print(f" Final equity : {final:,.2f} U ({final-cfg.initial_capital:+,.2f}, "
|
|
f"{(final-cfg.initial_capital)/cfg.initial_capital*100:+.1f}%)")
|
|
print(f" Max drawdown : {dd:,.2f} U")
|
|
print(f" Avg daily PnL : {pnl.mean():+,.2f} U")
|
|
print(f" Median daily PnL : {pnl.median():+,.2f} U")
|
|
print(f" Best/worst day : {pnl.max():+,.2f} / {pnl.min():+,.2f}")
|
|
print(f" Profitable days : {(pnl>0).sum()}/{len(pnl)} ({(pnl>0).mean():.1%})")
|
|
print(f" Days >= 20U : {(pnl>=20).sum()}")
|
|
print(f" Max loss streak : {max_ls} days")
|
|
print(f" Trades : {nt} (win rate {wr:.1%})")
|
|
print(f" Net fees : {nf:,.0f} U")
|
|
sharpe = pnl.mean() / max(pnl.std(), 1e-10) * np.sqrt(365)
|
|
print(f" Sharpe (annual) : {sharpe:.2f}")
|
|
|
|
# ================================================================
|
|
# Chart
|
|
# ================================================================
|
|
fig, axes = plt.subplots(3, 2, figsize=(18, 12),
|
|
gridspec_kw={"height_ratios": [3, 1.5, 1]})
|
|
|
|
for col, year in enumerate([2023, 2024]):
|
|
r = best[f"r{year}"]
|
|
cfg = best["cfg"]
|
|
d = r.daily_stats
|
|
eq = d["equity"].astype(float)
|
|
pnl = d["pnl"].astype(float)
|
|
dd = eq - eq.cummax()
|
|
|
|
axes[0, col].plot(eq.index, eq.values, linewidth=1.2, color="#1f77b4")
|
|
axes[0, col].axhline(cfg.initial_capital, color="gray", ls="--", lw=0.5)
|
|
axes[0, col].set_title(f"BB Strategy Equity — {year}\n"
|
|
f"BB({cfg.bb_period},{cfg.bb_std}) {cfg.margin_per_trade}u {cfg.leverage:.0f}x",
|
|
fontsize=11)
|
|
axes[0, col].set_ylabel("Equity (U)")
|
|
axes[0, col].grid(True, alpha=0.3)
|
|
|
|
colors = ["#2ca02c" if v >= 0 else "#d62728" for v in pnl.values]
|
|
axes[1, col].bar(pnl.index, pnl.values, color=colors, width=0.8)
|
|
axes[1, col].axhline(20, color="orange", ls="--", lw=1, label="20U target")
|
|
axes[1, col].axhline(0, color="gray", lw=0.5)
|
|
axes[1, col].set_ylabel("Daily PnL (U)")
|
|
axes[1, col].legend(fontsize=8)
|
|
axes[1, col].grid(True, alpha=0.3)
|
|
|
|
axes[2, col].fill_between(dd.index, dd.values, 0, color="#d62728", alpha=0.4)
|
|
axes[2, col].set_ylabel("Drawdown (U)")
|
|
axes[2, col].grid(True, alpha=0.3)
|
|
|
|
fig.tight_layout()
|
|
fig.savefig(out_dir / "bb_strategy_report.png", dpi=150)
|
|
plt.close(fig)
|
|
print(f"\nChart: {out_dir / 'bb_strategy_report.png'}")
|
|
print(f"Total time: {time.time()-t0:.0f}s")
|