382 lines
12 KiB
Python
382 lines
12 KiB
Python
"""
|
|
Practical upgraded BB backtest on 5m ETH data (2020-2025).
|
|
|
|
Execution model (conservative):
|
|
1) BB uses closed bars only (shift by 1 bar).
|
|
2) Signal on bar i, execution at bar i+1 open.
|
|
3) Fee = 0.05% each side, rebate = 90% next day at UTC 00:00 (UTC+8 08:00).
|
|
4) Daily loss stop, liquidation, and slippage are enabled.
|
|
|
|
Compares:
|
|
- Baseline: BB(30, 3.0), 1x, no trend filter.
|
|
- Practical: BB(30, 3.2), 1x, EMA trend filter, BB width filter, cooldown.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from strategy.data_loader import load_klines
|
|
|
|
|
|
@dataclass
|
|
class PracticalConfig:
|
|
bb_period: int = 30
|
|
bb_std: float = 3.2
|
|
leverage: float = 1.0
|
|
initial_capital: float = 200.0
|
|
margin_pct: float = 0.01
|
|
fee_rate: float = 0.0005
|
|
rebate_pct: float = 0.90
|
|
rebate_hour_utc: int = 0
|
|
slippage_pct: float = 0.0005
|
|
liq_enabled: bool = True
|
|
maint_margin_rate: float = 0.005
|
|
max_daily_loss: float = 50.0
|
|
|
|
# Practical risk controls
|
|
trend_ema_period: int = 288 # 288 * 5m = 24h EMA
|
|
min_bandwidth: float = 0.01 # (upper-lower)/mid minimum
|
|
cooldown_bars: int = 6 # 6 * 5m = 30 minutes
|
|
|
|
|
|
def run_practical_backtest(df: pd.DataFrame, cfg: PracticalConfig):
|
|
arr_open = df["open"].to_numpy(dtype=float)
|
|
arr_high = df["high"].to_numpy(dtype=float)
|
|
arr_low = df["low"].to_numpy(dtype=float)
|
|
arr_close = df["close"].to_numpy(dtype=float)
|
|
idx = df.index
|
|
n = len(df)
|
|
|
|
close_s = df["close"].astype(float)
|
|
mid = close_s.rolling(cfg.bb_period).mean().shift(1)
|
|
std = close_s.rolling(cfg.bb_period).std(ddof=0).shift(1)
|
|
upper_s = mid + cfg.bb_std * std
|
|
lower_s = mid - cfg.bb_std * std
|
|
bandwidth_s = (upper_s - lower_s) / mid
|
|
|
|
upper = upper_s.to_numpy(dtype=float)
|
|
lower = lower_s.to_numpy(dtype=float)
|
|
bandwidth = bandwidth_s.to_numpy(dtype=float)
|
|
|
|
if cfg.trend_ema_period > 0:
|
|
trend_ema = close_s.ewm(span=cfg.trend_ema_period, adjust=False).mean().shift(1).to_numpy(dtype=float)
|
|
else:
|
|
trend_ema = np.full(n, np.nan)
|
|
|
|
balance = cfg.initial_capital
|
|
position = 0 # +1 long, -1 short
|
|
entry_price = 0.0
|
|
entry_qty = 0.0
|
|
entry_margin = 0.0
|
|
|
|
trades = 0
|
|
win_trades = 0
|
|
liq_count = 0
|
|
total_fee = 0.0
|
|
total_rebate = 0.0
|
|
|
|
current_day = None
|
|
day_pnl = 0.0
|
|
day_stopped = False
|
|
rebate_applied_today = False
|
|
next_trade_bar = 0
|
|
day_fees = {}
|
|
|
|
equity_arr = np.full(n, np.nan)
|
|
position_arr = np.zeros(n)
|
|
|
|
def unrealised(price: float) -> float:
|
|
if position == 0:
|
|
return 0.0
|
|
if position == 1:
|
|
return entry_qty * (price - entry_price)
|
|
return entry_qty * (entry_price - price)
|
|
|
|
def apply_open_slippage(side: int, price: float) -> float:
|
|
return price * (1 + cfg.slippage_pct) if side == 1 else price * (1 - cfg.slippage_pct)
|
|
|
|
def apply_close_slippage(side: int, price: float) -> float:
|
|
return price * (1 - cfg.slippage_pct) if side == 1 else price * (1 + cfg.slippage_pct)
|
|
|
|
def add_fee(ts: pd.Timestamp, fee: float):
|
|
nonlocal total_fee
|
|
total_fee += fee
|
|
d = ts.date()
|
|
day_fees[d] = day_fees.get(d, 0.0) + fee
|
|
|
|
def close_position(exec_price: float, exec_idx: int):
|
|
nonlocal balance, position, entry_price, entry_qty, entry_margin
|
|
nonlocal trades, win_trades, day_pnl
|
|
if position == 0:
|
|
return
|
|
|
|
px = apply_close_slippage(position, exec_price)
|
|
gross = entry_qty * (px - entry_price) if position == 1 else entry_qty * (entry_price - px)
|
|
notional = entry_qty * px
|
|
fee = notional * cfg.fee_rate
|
|
net = gross - fee
|
|
|
|
balance += net
|
|
add_fee(idx[exec_idx], fee)
|
|
day_pnl += net
|
|
trades += 1
|
|
if net > 0:
|
|
win_trades += 1
|
|
|
|
position = 0
|
|
entry_price = 0.0
|
|
entry_qty = 0.0
|
|
entry_margin = 0.0
|
|
|
|
def open_position(side: int, exec_price: float, exec_idx: int):
|
|
nonlocal balance, position, entry_price, entry_qty, entry_margin
|
|
nonlocal day_pnl
|
|
px = apply_open_slippage(side, exec_price)
|
|
eq = balance + unrealised(px)
|
|
margin = min(eq * cfg.margin_pct, balance * 0.95)
|
|
if margin <= 0:
|
|
return
|
|
|
|
notional = margin * cfg.leverage
|
|
qty = notional / px
|
|
fee = notional * cfg.fee_rate
|
|
|
|
balance -= fee
|
|
add_fee(idx[exec_idx], fee)
|
|
day_pnl -= fee
|
|
|
|
position = side
|
|
entry_price = px
|
|
entry_qty = qty
|
|
entry_margin = margin
|
|
|
|
for i in range(n - 1):
|
|
ts = idx[i]
|
|
bar_day = ts.date()
|
|
|
|
if bar_day != current_day:
|
|
current_day = bar_day
|
|
day_pnl = 0.0
|
|
day_stopped = False
|
|
rebate_applied_today = False
|
|
|
|
# Fee rebate settlement (next day 08:00 UTC+8 = UTC 00:00)
|
|
if (not rebate_applied_today) and ts.hour >= cfg.rebate_hour_utc:
|
|
prev_day = bar_day - pd.Timedelta(days=1)
|
|
prev_fee = day_fees.get(prev_day, 0.0)
|
|
if prev_fee > 0:
|
|
rebate = prev_fee * cfg.rebate_pct
|
|
balance += rebate
|
|
total_rebate += rebate
|
|
rebate_applied_today = True
|
|
|
|
# Daily loss stop
|
|
if not day_stopped and (day_pnl + unrealised(arr_close[i]) <= -cfg.max_daily_loss):
|
|
close_position(arr_open[i + 1], i + 1)
|
|
day_stopped = True
|
|
|
|
# Liquidation check
|
|
if cfg.liq_enabled and position != 0 and entry_margin > 0:
|
|
liq_threshold = 1.0 / cfg.leverage * (1 - cfg.maint_margin_rate)
|
|
if position == 1:
|
|
liq_price = entry_price * (1 - liq_threshold)
|
|
if arr_low[i] <= liq_price:
|
|
balance -= entry_margin
|
|
day_pnl -= entry_margin
|
|
position = 0
|
|
entry_price = 0.0
|
|
entry_qty = 0.0
|
|
entry_margin = 0.0
|
|
trades += 1
|
|
liq_count += 1
|
|
else:
|
|
liq_price = entry_price * (1 + liq_threshold)
|
|
if arr_high[i] >= liq_price:
|
|
balance -= entry_margin
|
|
day_pnl -= entry_margin
|
|
position = 0
|
|
entry_price = 0.0
|
|
entry_qty = 0.0
|
|
entry_margin = 0.0
|
|
trades += 1
|
|
liq_count += 1
|
|
|
|
if (
|
|
day_stopped
|
|
or i < next_trade_bar
|
|
or np.isnan(upper[i])
|
|
or np.isnan(lower[i])
|
|
or np.isnan(bandwidth[i])
|
|
):
|
|
equity_arr[i] = balance + unrealised(arr_close[i])
|
|
position_arr[i] = position
|
|
if balance <= 0:
|
|
balance = 0.0
|
|
equity_arr[i:] = 0.0
|
|
position_arr[i:] = 0
|
|
break
|
|
continue
|
|
|
|
if cfg.trend_ema_period > 0 and np.isnan(trend_ema[i]):
|
|
equity_arr[i] = balance + unrealised(arr_close[i])
|
|
position_arr[i] = position
|
|
continue
|
|
|
|
if bandwidth[i] < cfg.min_bandwidth:
|
|
equity_arr[i] = balance + unrealised(arr_close[i])
|
|
position_arr[i] = position
|
|
continue
|
|
|
|
touched_upper = arr_high[i] >= upper[i]
|
|
touched_lower = arr_low[i] <= lower[i]
|
|
exec_px = arr_open[i + 1]
|
|
|
|
if cfg.trend_ema_period > 0:
|
|
long_ok = arr_close[i] > trend_ema[i]
|
|
short_ok = arr_close[i] < trend_ema[i]
|
|
else:
|
|
long_ok = True
|
|
short_ok = True
|
|
|
|
if touched_upper and touched_lower:
|
|
pass
|
|
elif touched_upper:
|
|
if position == 1:
|
|
close_position(exec_px, i + 1)
|
|
next_trade_bar = max(next_trade_bar, i + 1 + cfg.cooldown_bars)
|
|
if position == 0 and short_ok:
|
|
open_position(-1, exec_px, i + 1)
|
|
next_trade_bar = max(next_trade_bar, i + 1 + cfg.cooldown_bars)
|
|
elif touched_lower:
|
|
if position == -1:
|
|
close_position(exec_px, i + 1)
|
|
next_trade_bar = max(next_trade_bar, i + 1 + cfg.cooldown_bars)
|
|
if position == 0 and long_ok:
|
|
open_position(1, exec_px, i + 1)
|
|
next_trade_bar = max(next_trade_bar, i + 1 + cfg.cooldown_bars)
|
|
|
|
equity_arr[i] = balance + unrealised(arr_close[i])
|
|
position_arr[i] = position
|
|
|
|
if balance <= 0:
|
|
balance = 0.0
|
|
equity_arr[i:] = 0.0
|
|
position_arr[i:] = 0
|
|
break
|
|
|
|
if position != 0 and balance > 0:
|
|
close_position(arr_close[-1], n - 1)
|
|
|
|
equity_arr[-1] = balance
|
|
position_arr[-1] = 0
|
|
|
|
eq_df = pd.DataFrame({"equity": equity_arr, "position": position_arr}, index=idx)
|
|
daily = eq_df["equity"].resample("1D").last().dropna().to_frame("equity")
|
|
daily["pnl"] = daily["equity"].diff().fillna(0.0)
|
|
|
|
return {
|
|
"equity_curve": eq_df,
|
|
"daily": daily,
|
|
"final_equity": float(daily["equity"].iloc[-1]),
|
|
"trade_count": int(trades),
|
|
"win_rate": float(win_trades / max(trades, 1)),
|
|
"liq_count": int(liq_count),
|
|
"total_fee": float(total_fee),
|
|
"total_rebate": float(total_rebate),
|
|
}
|
|
|
|
|
|
def summarize(label: str, result: dict, initial_capital: float):
|
|
daily = result["daily"]
|
|
eq = daily["equity"].astype(float)
|
|
pnl = daily["pnl"].astype(float)
|
|
final_eq = float(eq.iloc[-1])
|
|
ret_pct = (final_eq - initial_capital) / initial_capital * 100
|
|
max_dd = float((eq - eq.cummax()).min()) if len(eq) else 0.0
|
|
sharpe = float(pnl.mean() / pnl.std() * np.sqrt(365)) if pnl.std() > 0 else 0.0
|
|
|
|
print(f"[{label}]")
|
|
print(f" Final equity: {final_eq:.6f} U")
|
|
print(f" Return: {ret_pct:+.4f}%")
|
|
print(f" Trades: {result['trade_count']} | Win rate: {result['win_rate']*100:.2f}% | Liquidations: {result['liq_count']}")
|
|
print(f" Max drawdown: {max_dd:.6f} U | Sharpe: {sharpe:.4f}")
|
|
print(f" Total fee: {result['total_fee']:.6f} | Total rebate: {result['total_rebate']:.6f}")
|
|
print(" Year-end equity:")
|
|
for year in range(2020, 2026):
|
|
m = daily.index.year == year
|
|
if m.any():
|
|
print(f" {year}: {float(daily.loc[m, 'equity'].iloc[-1]):.6f} U")
|
|
print()
|
|
|
|
return {
|
|
"label": label,
|
|
"final_equity": final_eq,
|
|
"return_pct": ret_pct,
|
|
"trade_count": result["trade_count"],
|
|
"win_rate_pct": result["win_rate"] * 100,
|
|
"liq_count": result["liq_count"],
|
|
"max_drawdown": max_dd,
|
|
"total_fee": result["total_fee"],
|
|
"total_rebate": result["total_rebate"],
|
|
"sharpe": sharpe,
|
|
}
|
|
|
|
|
|
def main():
|
|
df = load_klines("5m", "2020-01-01", "2026-01-01")
|
|
|
|
baseline_cfg = PracticalConfig(
|
|
bb_period=30,
|
|
bb_std=3.0,
|
|
leverage=1.0,
|
|
trend_ema_period=0,
|
|
min_bandwidth=0.0,
|
|
cooldown_bars=0,
|
|
)
|
|
practical_cfg = PracticalConfig(
|
|
bb_period=30,
|
|
bb_std=3.2,
|
|
leverage=1.0,
|
|
trend_ema_period=288,
|
|
min_bandwidth=0.01,
|
|
cooldown_bars=6,
|
|
)
|
|
|
|
print("=" * 118)
|
|
print("Practical Upgrade Backtest | 5m | 2020-2025 | capital=200U | fee=0.05% each side | rebate=90% next day 08:00")
|
|
print("=" * 118)
|
|
print("Execution: signal at bar i, fill at bar i+1 open (conservative)")
|
|
print()
|
|
|
|
baseline = run_practical_backtest(df, baseline_cfg)
|
|
practical = run_practical_backtest(df, practical_cfg)
|
|
|
|
summary_rows = []
|
|
summary_rows.append(summarize("Baseline (BB30/3.0, no filters)", baseline, baseline_cfg.initial_capital))
|
|
summary_rows.append(summarize("Practical (BB30/3.2 + EMA + BW + cooldown)", practical, practical_cfg.initial_capital))
|
|
|
|
out_dir = Path(__file__).resolve().parent / "results"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
baseline_daily = baseline["daily"].rename(columns={"equity": "baseline_equity", "pnl": "baseline_pnl"})
|
|
practical_daily = practical["daily"].rename(columns={"equity": "practical_equity", "pnl": "practical_pnl"})
|
|
daily_compare = baseline_daily.join(practical_daily, how="outer")
|
|
daily_compare.to_csv(out_dir / "bb_5m_practical_upgrade_daily.csv")
|
|
|
|
pd.DataFrame(summary_rows).to_csv(out_dir / "bb_5m_practical_upgrade_summary.csv", index=False)
|
|
|
|
print(f"Saved: {out_dir / 'bb_5m_practical_upgrade_daily.csv'}")
|
|
print(f"Saved: {out_dir / 'bb_5m_practical_upgrade_summary.csv'}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|