"""Bollinger Band mean-reversion strategy backtest. Logic: - Price touches upper BB → close any long, open short - Price touches lower BB → close any short, open long - Always in position (flip between long and short) Uses 5-minute OHLC data from the database. """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone from typing import List, Optional import numpy as np import pandas as pd from .data_loader import KlineSource, load_klines from .indicators import bollinger # --------------------------------------------------------------------------- # Config & result types # --------------------------------------------------------------------------- @dataclass class BBConfig: # Bollinger Band parameters bb_period: int = 20 # SMA window bb_std: float = 2.0 # standard deviation multiplier # Position sizing margin_per_trade: float = 80.0 leverage: float = 100.0 initial_capital: float = 1000.0 # Risk management max_daily_loss: float = 150.0 # stop trading after this daily loss stop_loss_pct: float = 0.0 # 0 = disabled; e.g. 0.02 = 2% SL from entry # Dynamic sizing: if > 0, margin = equity * margin_pct (overrides margin_per_trade) margin_pct: float = 0.0 # e.g. 0.01 = 1% of equity per trade # Fee structure (taker) fee_rate: float = 0.0006 # 0.06% rebate_rate: float = 0.0 # instant maker rebate (if any) # Delayed rebate: rebate_pct of daily fees returned next day at rebate_hour UTC rebate_pct: float = 0.0 # e.g. 0.70 = 70% rebate rebate_hour_utc: int = 0 # hour in UTC when rebate arrives (0 = 8am UTC+8) @dataclass class BBTrade: side: str # "long" or "short" entry_price: float exit_price: float entry_time: object # pd.Timestamp exit_time: object margin: float leverage: float qty: float gross_pnl: float fee: float net_pnl: float @dataclass class BBResult: equity_curve: pd.DataFrame # columns: equity, balance, price, position trades: List[BBTrade] daily_stats: pd.DataFrame # daily equity + pnl total_fee: float total_rebate: float config: BBConfig # --------------------------------------------------------------------------- # Backtest engine # --------------------------------------------------------------------------- def run_bb_backtest(df: pd.DataFrame, cfg: BBConfig) -> BBResult: """Run Bollinger Band mean-reversion backtest on 5m OHLC data.""" close = df["close"].astype(float) high = df["high"].astype(float) low = df["low"].astype(float) n = len(df) # Compute Bollinger Bands bb_mid, bb_upper, bb_lower, bb_width = bollinger(close, cfg.bb_period, cfg.bb_std) # Convert to numpy for speed arr_close = close.values arr_high = high.values arr_low = low.values arr_upper = bb_upper.values arr_lower = bb_lower.values ts_index = df.index # State balance = cfg.initial_capital position = 0 # +1 = long, -1 = short, 0 = flat entry_price = 0.0 entry_time = None entry_margin = 0.0 entry_qty = 0.0 trades: List[BBTrade] = [] total_fee = 0.0 total_rebate = 0.0 # Daily tracking day_pnl = 0.0 day_stopped = False current_day = None # Delayed rebate tracking pending_rebate = 0.0 # fees from previous day to be rebated today_fees = 0.0 # fees accumulated today rebate_applied_today = False # Output arrays out_equity = np.full(n, np.nan) out_balance = np.full(n, np.nan) out_position = np.zeros(n) def unrealised(price): if position == 0: return 0.0 if position == 1: return entry_qty * (price - entry_price) else: return entry_qty * (entry_price - price) def close_position(exit_price, exit_idx): nonlocal balance, position, entry_price, entry_time, entry_margin, entry_qty nonlocal total_fee, total_rebate, day_pnl, today_fees if position == 0: return if position == 1: gross = entry_qty * (exit_price - entry_price) else: gross = entry_qty * (entry_price - exit_price) exit_notional = entry_qty * exit_price fee = exit_notional * cfg.fee_rate rebate = exit_notional * cfg.rebate_rate # instant rebate only net = gross - fee + rebate trades.append(BBTrade( side="long" if position == 1 else "short", entry_price=entry_price, exit_price=exit_price, entry_time=entry_time, exit_time=ts_index[exit_idx], margin=entry_margin, leverage=cfg.leverage, qty=entry_qty, gross_pnl=gross, fee=fee, net_pnl=net, )) balance += net total_fee += fee total_rebate += rebate today_fees += fee day_pnl += net position = 0 entry_price = 0.0 entry_time = None entry_margin = 0.0 entry_qty = 0.0 def open_position(side, price, idx): nonlocal position, entry_price, entry_time, entry_margin, entry_qty nonlocal balance, total_fee, day_pnl, today_fees if cfg.margin_pct > 0: equity = balance + unrealised(price) if position != 0 else balance margin = equity * cfg.margin_pct else: margin = cfg.margin_per_trade margin = min(margin, balance * 0.95) if margin <= 0: return notional = margin * cfg.leverage qty = notional / price fee = notional * cfg.fee_rate balance -= fee total_fee += fee today_fees += fee day_pnl -= fee position = 1 if side == "long" else -1 entry_price = price entry_time = ts_index[idx] entry_margin = margin entry_qty = qty # Main loop for i in range(n): # Daily reset + delayed rebate bar_day = ts_index[i].date() if hasattr(ts_index[i], 'date') else None bar_hour = ts_index[i].hour if hasattr(ts_index[i], 'hour') else 0 if bar_day is not None and bar_day != current_day: # New day: move today's fees to pending, reset if cfg.rebate_pct > 0: pending_rebate = today_fees * cfg.rebate_pct today_fees = 0.0 rebate_applied_today = False day_pnl = 0.0 day_stopped = False current_day = bar_day # Apply delayed rebate at specified hour if cfg.rebate_pct > 0 and not rebate_applied_today and bar_hour >= cfg.rebate_hour_utc and pending_rebate > 0: balance += pending_rebate total_rebate += pending_rebate pending_rebate = 0.0 rebate_applied_today = True # Skip if BB not ready if np.isnan(arr_upper[i]) or np.isnan(arr_lower[i]): out_equity[i] = balance + unrealised(arr_close[i]) out_balance[i] = balance out_position[i] = position continue # Daily loss check if day_stopped: out_equity[i] = balance + unrealised(arr_close[i]) out_balance[i] = balance out_position[i] = position continue cur_equity = balance + unrealised(arr_close[i]) if day_pnl + unrealised(arr_close[i]) <= -cfg.max_daily_loss: close_position(arr_close[i], i) day_stopped = True out_equity[i] = balance out_balance[i] = balance out_position[i] = 0 continue # Stop loss check if position != 0 and cfg.stop_loss_pct > 0: if position == 1 and arr_low[i] <= entry_price * (1 - cfg.stop_loss_pct): sl_price = entry_price * (1 - cfg.stop_loss_pct) close_position(sl_price, i) elif position == -1 and arr_high[i] >= entry_price * (1 + cfg.stop_loss_pct): sl_price = entry_price * (1 + cfg.stop_loss_pct) close_position(sl_price, i) # Signal detection: use high/low to check if price touched BB touched_upper = arr_high[i] >= arr_upper[i] touched_lower = arr_low[i] <= arr_lower[i] if touched_upper and touched_lower: # Both touched in same bar (wide bar) — skip, too volatile pass elif touched_upper: # Price touched upper BB → go short if position == 1: # Close long at upper BB price close_position(arr_upper[i], i) if position != -1: # Open short open_position("short", arr_upper[i], i) elif touched_lower: # Price touched lower BB → go long if position == -1: # Close short at lower BB price close_position(arr_lower[i], i) if position != 1: # Open long open_position("long", arr_lower[i], i) # Record equity out_equity[i] = balance + unrealised(arr_close[i]) out_balance[i] = balance out_position[i] = position # Force close at end if position != 0: close_position(arr_close[n - 1], n - 1) out_equity[n - 1] = balance out_balance[n - 1] = balance out_position[n - 1] = 0 # Build equity DataFrame eq_df = pd.DataFrame({ "equity": out_equity, "balance": out_balance, "price": arr_close, "position": out_position, }, index=ts_index) # Daily stats daily_eq = eq_df["equity"].resample("1D").last().dropna().to_frame("equity") daily_eq["pnl"] = daily_eq["equity"].diff().fillna(0.0) return BBResult( equity_curve=eq_df, trades=trades, daily_stats=daily_eq, total_fee=total_fee, total_rebate=total_rebate, config=cfg, )