295 lines
9.3 KiB
Python
295 lines
9.3 KiB
Python
"""布林带均线策略回测(优化版)
|
||
|
||
策略逻辑:
|
||
- 阳线 + 碰到布林带均线 → 开多(可选 1m 线过滤:先涨碰到才开)
|
||
- 持多: 碰到上轨 → 止盈(无下轨止损)
|
||
- 阴线 + 碰到布林带均线 → 平多开空(可选 1m:先跌碰到才开)
|
||
- 持空: 碰到下轨 → 止盈(无上轨止损)
|
||
|
||
全仓模式 | 200U | 1% 权益/单 | 万五手续费 | 90%返佣次日8点到账 | 100x杠杆
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import List, Optional
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
from .indicators import bollinger
|
||
|
||
|
||
@dataclass
|
||
class BBMidlineConfig:
|
||
bb_period: int = 20
|
||
bb_std: float = 2.0
|
||
initial_capital: float = 200.0
|
||
margin_pct: float = 0.01
|
||
leverage: float = 100.0
|
||
cross_margin: bool = True
|
||
fee_rate: float = 0.0005
|
||
rebate_pct: float = 0.90
|
||
rebate_hour_utc: int = 0
|
||
slippage_pct: float = 0.0
|
||
fill_at_close: bool = True
|
||
# 是否用 1m 线判断「先涨碰到」/「先跌碰到」均线
|
||
use_1m_touch_filter: bool = True
|
||
# 主K线周期(分钟),用于 1m 触及方向时的桶对齐,5/15/30
|
||
kline_step_min: int = 5
|
||
|
||
|
||
@dataclass
|
||
class BBTrade:
|
||
side: str
|
||
entry_price: float
|
||
exit_price: float
|
||
entry_time: object
|
||
exit_time: object
|
||
margin: float
|
||
leverage: float
|
||
qty: float
|
||
gross_pnl: float
|
||
fee: float
|
||
net_pnl: float
|
||
exit_reason: str
|
||
|
||
|
||
@dataclass
|
||
class BBMidlineResult:
|
||
equity_curve: pd.DataFrame
|
||
trades: List[BBTrade]
|
||
daily_stats: pd.DataFrame
|
||
total_fee: float
|
||
total_rebate: float
|
||
config: BBMidlineConfig
|
||
|
||
|
||
def run_bb_midline_backtest(
|
||
df: pd.DataFrame,
|
||
cfg: BBMidlineConfig,
|
||
df_1m: Optional[pd.DataFrame] = None,
|
||
arr_touch_dir_override: Optional[np.ndarray] = None,
|
||
) -> BBMidlineResult:
|
||
close = df["close"].astype(float)
|
||
high = df["high"].astype(float)
|
||
low = df["low"].astype(float)
|
||
open_ = df["open"].astype(float)
|
||
n = len(df)
|
||
|
||
bb_mid, bb_upper, bb_lower, _ = bollinger(close, cfg.bb_period, cfg.bb_std)
|
||
arr_mid = bb_mid.values
|
||
|
||
# 1m 触及方向:1=先涨碰到, -1=先跌碰到, 0=未碰到
|
||
arr_touch_dir = None
|
||
if arr_touch_dir_override is not None:
|
||
arr_touch_dir = np.asarray(arr_touch_dir_override, dtype=np.int32)
|
||
if len(arr_touch_dir) != n:
|
||
raise ValueError(f"arr_touch_dir_override 长度不匹配: {len(arr_touch_dir)} != {n}")
|
||
elif cfg.use_1m_touch_filter and df_1m is not None and len(df_1m) > 0:
|
||
from .data_loader import get_1m_touch_direction
|
||
arr_touch_dir = get_1m_touch_direction(df, df_1m, arr_mid, kline_step_min=cfg.kline_step_min)
|
||
|
||
arr_close = close.values
|
||
arr_high = high.values
|
||
arr_low = low.values
|
||
arr_open = open_.values
|
||
arr_upper = bb_upper.values
|
||
arr_lower = bb_lower.values
|
||
ts_index = df.index
|
||
|
||
balance = cfg.initial_capital
|
||
position = 0
|
||
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
|
||
|
||
day_pnl = 0.0
|
||
current_day = None
|
||
today_fees = 0.0
|
||
pending_rebate = 0.0
|
||
rebate_applied_today = False
|
||
|
||
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)
|
||
return entry_qty * (entry_price - price)
|
||
|
||
def close_position(exit_price, exit_idx, reason: str):
|
||
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:
|
||
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
|
||
net = gross - fee
|
||
|
||
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,
|
||
exit_reason=reason,
|
||
))
|
||
|
||
balance += net
|
||
total_fee += fee
|
||
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 side == "long":
|
||
price = price * (1 + cfg.slippage_pct)
|
||
else:
|
||
price = price * (1 - cfg.slippage_pct)
|
||
|
||
equity = balance + unrealised(price) if position != 0 else balance
|
||
margin = equity * cfg.margin_pct
|
||
margin = min(margin, balance * 0.95)
|
||
if margin <= 0:
|
||
return False
|
||
|
||
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
|
||
return True
|
||
|
||
for i in range(n):
|
||
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:
|
||
pending_rebate += today_fees * cfg.rebate_pct
|
||
today_fees = 0.0
|
||
rebate_applied_today = False
|
||
day_pnl = 0.0
|
||
current_day = bar_day
|
||
|
||
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
|
||
|
||
if np.isnan(arr_upper[i]) or np.isnan(arr_lower[i]) or np.isnan(arr_mid[i]):
|
||
out_equity[i] = balance + unrealised(arr_close[i])
|
||
out_balance[i] = balance
|
||
out_position[i] = position
|
||
continue
|
||
|
||
fill_price = arr_close[i] if cfg.fill_at_close else None
|
||
bullish = arr_close[i] > arr_open[i]
|
||
bearish = arr_close[i] < arr_open[i]
|
||
# 碰到均线:K 线贯穿或触及 mid
|
||
touched_mid = arr_low[i] <= arr_mid[i] <= arr_high[i]
|
||
touched_upper = arr_high[i] >= arr_upper[i]
|
||
touched_lower = arr_low[i] <= arr_lower[i]
|
||
|
||
exec_upper = fill_price if fill_price is not None else arr_upper[i]
|
||
exec_lower = fill_price if fill_price is not None else arr_lower[i]
|
||
|
||
# 1m 过滤:开多需先涨碰到,开空需先跌碰到
|
||
touch_up_ok = True if arr_touch_dir is None else (arr_touch_dir[i] == 1)
|
||
touch_down_ok = True if arr_touch_dir is None else (arr_touch_dir[i] == -1)
|
||
|
||
# 单根 K 线只允许一次操作
|
||
if position == 1 and touched_upper:
|
||
# 持多止盈
|
||
close_position(exec_upper, i, "tp_upper")
|
||
elif position == -1 and touched_lower:
|
||
# 持空止盈
|
||
close_position(exec_lower, i, "tp_lower")
|
||
elif position == 1 and bearish and touched_mid and touch_down_ok:
|
||
# 阴线触中轨: 平多并反手开空
|
||
close_position(arr_close[i], i, "flip_to_short")
|
||
if balance > 0:
|
||
open_position("short", arr_close[i], i)
|
||
elif position == -1 and bullish and touched_mid and touch_up_ok:
|
||
# 阳线触中轨: 平空并反手开多
|
||
close_position(arr_close[i], i, "flip_to_long")
|
||
if balance > 0:
|
||
open_position("long", arr_close[i], i)
|
||
elif position == 0 and bullish and touched_mid and touch_up_ok:
|
||
# 空仓开多
|
||
open_position("long", arr_close[i], i)
|
||
elif position == 0 and bearish and touched_mid and touch_down_ok:
|
||
# 空仓开空
|
||
open_position("short", arr_close[i], i)
|
||
|
||
out_equity[i] = balance + unrealised(arr_close[i])
|
||
out_balance[i] = balance
|
||
out_position[i] = position
|
||
|
||
if position != 0:
|
||
close_position(arr_close[n - 1], n - 1, "end")
|
||
out_equity[n - 1] = balance
|
||
out_balance[n - 1] = balance
|
||
out_position[n - 1] = 0
|
||
|
||
eq_df = pd.DataFrame({
|
||
"equity": out_equity,
|
||
"balance": out_balance,
|
||
"price": arr_close,
|
||
"position": out_position,
|
||
}, index=ts_index)
|
||
|
||
daily_eq = eq_df["equity"].resample("1D").last().dropna().to_frame("equity")
|
||
daily_eq["pnl"] = daily_eq["equity"].diff().fillna(0.0)
|
||
|
||
return BBMidlineResult(
|
||
equity_curve=eq_df,
|
||
trades=trades,
|
||
daily_stats=daily_eq,
|
||
total_fee=total_fee,
|
||
total_rebate=total_rebate,
|
||
config=cfg,
|
||
)
|