""" 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()