haha
This commit is contained in:
315
strategy/bb_backtest.py
Normal file
315
strategy/bb_backtest.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user