Files
codex_jxs_code/strategy/bb_backtest.py
2026-02-26 16:34:30 +08:00

486 lines
18 KiB
Python

"""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 .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
max_daily_loss_pct: float = 0.0 # if >0, daily loss limit = equity * pct (overrides fixed)
stop_loss_pct: float = 0.0 # 0 = disabled; e.g. 0.02 = 2% SL from entry
# Liquidation
liq_enabled: bool = True # enable liquidation simulation
cross_margin: bool = True # 全仓模式: 仅当 equity<=0 爆仓; False=逐仓: 按仓位保证金算强平价
maint_margin_rate: float = 0.005 # 逐仓时用: 0.5% 维持保证金率
# Slippage: applied to each trade execution price
slippage_pct: float = 0.0005 # 0.05% slippage per trade
# 成交价模式: False=理想(在触轨的极价成交), True=真实(在K线收盘价成交)
# 实盘检测到触及布林带后以市价单成交,通常接近收盘价
fill_at_close: bool = False
# Max single order notional (market capacity limit, USDT)
max_notional: float = 0.0 # 0 = unlimited; e.g. 500000 = 50万U max per order
# 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)
# Pyramid (加仓): add to position on repeated same-direction BB touch
pyramid_enabled: bool = False
pyramid_decay: float = 0.99 # each add uses margin * decay^n (n = add count)
pyramid_max: int = 10 # max number of adds (0 = unlimited)
# Increment mode: each add uses equity * (margin_pct + pyramid_step * n)
# e.g. step=0.01 → 1st open=1%, 1st add=2%, 2nd add=3% ...
pyramid_step: float = 0.0 # 0 = use decay mode; >0 = use increment mode
@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
pyramid_count = 0 # number of adds so far
last_add_margin = 0.0 # margin used in last open/add
trades: List[BBTrade] = []
total_fee = 0.0
total_rebate = 0.0
# Daily tracking
day_pnl = 0.0
day_stopped = False
current_day = None
day_start_equity = cfg.initial_capital # equity at start of each day
# 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
nonlocal pyramid_count, last_add_margin
if position == 0:
return
# Apply slippage: closing long sells lower, closing short buys higher
if position == 1:
exit_price = exit_price * (1 - cfg.slippage_pct)
else:
exit_price = exit_price * (1 + cfg.slippage_pct)
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
pyramid_count = 0
last_add_margin = 0.0
def open_position(side, price, idx, is_add=False):
nonlocal position, entry_price, entry_time, entry_margin, entry_qty
nonlocal balance, total_fee, day_pnl, today_fees
nonlocal pyramid_count, last_add_margin
# Apply slippage: buy higher, sell lower
if side == "long" or (is_add and position == 1):
price = price * (1 + cfg.slippage_pct)
else:
price = price * (1 - cfg.slippage_pct)
if is_add and cfg.pyramid_step > 0:
# 递增加仓: margin = equity * (margin_pct + step * (count+1))
equity = balance + unrealised(price)
pct = cfg.margin_pct + cfg.pyramid_step * (pyramid_count + 1)
margin = equity * pct
elif is_add:
# 衰减加仓: margin = last_add_margin * decay
margin = last_add_margin * cfg.pyramid_decay
elif 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
# Cap notional to market capacity limit
if cfg.max_notional > 0 and notional > cfg.max_notional:
notional = cfg.max_notional
margin = notional / cfg.leverage
qty = notional / price
fee = notional * cfg.fee_rate
balance -= fee
total_fee += fee
today_fees += fee
day_pnl -= fee
if is_add and position != 0:
# 加仓: weighted average entry price
old_notional = entry_qty * entry_price
new_notional = qty * price
entry_qty += qty
entry_price = (old_notional + new_notional) / entry_qty
entry_margin += margin
pyramid_count += 1
last_add_margin = margin
else:
# 新开仓
position = 1 if side == "long" else -1
entry_price = price
entry_time = ts_index[idx]
entry_margin = margin
entry_qty = qty
pyramid_count = 0
last_add_margin = margin
# 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_start_equity = balance + unrealised(arr_close[i])
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 (percentage-based if configured, else fixed)
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 cfg.max_daily_loss_pct > 0:
# percentage-based: use start-of-day equity
daily_loss_limit = day_start_equity * cfg.max_daily_loss_pct
else:
daily_loss_limit = cfg.max_daily_loss
if day_pnl + unrealised(arr_close[i]) <= -daily_loss_limit:
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)
# Liquidation check
# 全仓(cross_margin): 仅当 账户权益<=0 时爆仓,整仓担保
# 逐仓: 按仓位保证金算强平价
if position != 0 and cfg.liq_enabled and entry_margin > 0:
cur_equity = balance + unrealised(arr_close[i])
upnl = unrealised(arr_close[i])
if cfg.cross_margin:
# 全仓: 只有权益归零才爆仓
if cur_equity <= 0:
balance += upnl # 实现亏损
balance = max(0.0, balance)
day_pnl += upnl
trades.append(BBTrade(
side="long" if position == 1 else "short",
entry_price=entry_price, exit_price=arr_close[i],
entry_time=entry_time, exit_time=ts_index[i],
margin=entry_margin, leverage=cfg.leverage, qty=entry_qty,
gross_pnl=upnl, fee=0.0, net_pnl=upnl,
))
position = 0
entry_price = 0.0
entry_time = None
entry_margin = 0.0
entry_qty = 0.0
pyramid_count = 0
last_add_margin = 0.0
out_equity[i] = balance
out_balance[i] = balance
out_position[i] = 0
if balance <= 0:
out_equity[i:] = 0.0
out_balance[i:] = 0.0
break
else:
# 逐仓: 按强平价
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
trades.append(BBTrade(
side="long", entry_price=entry_price, exit_price=liq_price,
entry_time=entry_time, exit_time=ts_index[i],
margin=entry_margin, leverage=cfg.leverage, qty=entry_qty,
gross_pnl=-entry_margin, fee=0.0, net_pnl=-entry_margin,
))
position = 0
entry_price = 0.0
entry_time = None
entry_margin = 0.0
entry_qty = 0.0
pyramid_count = 0
last_add_margin = 0.0
if balance <= 0:
balance = 0.0
out_equity[i] = 0.0
out_balance[i] = 0.0
out_position[i] = 0
out_equity[i:] = 0.0
out_balance[i:] = 0.0
break
elif position == -1:
liq_price = entry_price * (1 + liq_threshold)
if arr_high[i] >= liq_price:
balance -= entry_margin
day_pnl -= entry_margin
trades.append(BBTrade(
side="short", entry_price=entry_price, exit_price=liq_price,
entry_time=entry_time, exit_time=ts_index[i],
margin=entry_margin, leverage=cfg.leverage, qty=entry_qty,
gross_pnl=-entry_margin, fee=0.0, net_pnl=-entry_margin,
))
position = 0
entry_price = 0.0
entry_time = None
entry_margin = 0.0
entry_qty = 0.0
pyramid_count = 0
last_add_margin = 0.0
if balance <= 0:
balance = 0.0
out_equity[i:] = 0.0
out_balance[i:] = 0.0
break
# 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]
# 成交价: fill_at_close=True 时用收盘价(模拟实盘市价单),否则用触轨价(理想化)
fill_price = arr_close[i] if cfg.fill_at_close else None
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
exec_price = fill_price if fill_price is not None else arr_upper[i]
if position == 1:
close_position(exec_price, i)
if position == 0:
open_position("short", exec_price, i)
elif position == -1 and cfg.pyramid_enabled:
can_add = cfg.pyramid_max <= 0 or pyramid_count < cfg.pyramid_max
if can_add:
open_position("short", exec_price, i, is_add=True)
elif touched_lower:
# Price touched lower BB → go long
exec_price = fill_price if fill_price is not None else arr_lower[i]
if position == -1:
close_position(exec_price, i)
if position == 0:
open_position("long", exec_price, i)
elif position == 1 and cfg.pyramid_enabled:
can_add = cfg.pyramid_max <= 0 or pyramid_count < cfg.pyramid_max
if can_add:
open_position("long", exec_price, i, is_add=True)
# 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,
)