543 lines
21 KiB
Python
543 lines
21 KiB
Python
"""
|
||
BitMart 布林带均值回归返佣策略 — 回测脚本
|
||
|
||
从 SQLite 数据库读取 2025 年全年 1 分钟 K 线数据,模拟策略运行。
|
||
输出:交易次数、胜率、盈亏、返佣收入、净收益、最大回撤、月度统计等。
|
||
|
||
用法:
|
||
python 交易/bitmart-返佣策略-回测.py
|
||
"""
|
||
|
||
import time
|
||
import datetime
|
||
import statistics
|
||
import sqlite3
|
||
from pathlib import Path
|
||
from dataclasses import dataclass, field
|
||
from typing import List, Optional
|
||
|
||
# 简易 logger,避免依赖 loguru
|
||
class _Logger:
|
||
@staticmethod
|
||
def info(msg): print(f"[INFO] {msg}")
|
||
@staticmethod
|
||
def success(msg): print(f"[OK] {msg}")
|
||
@staticmethod
|
||
def warning(msg): print(f"[WARN] {msg}")
|
||
@staticmethod
|
||
def error(msg): print(f"[ERR] {msg}")
|
||
|
||
logger = _Logger()
|
||
|
||
|
||
# ========================= 交易记录 =========================
|
||
|
||
@dataclass
|
||
class Trade:
|
||
"""单笔交易记录"""
|
||
open_time: datetime.datetime # 开仓时间
|
||
close_time: datetime.datetime # 平仓时间
|
||
direction: str # 'long' / 'short'
|
||
open_price: float # 开仓价
|
||
close_price: float # 平仓价
|
||
size: float # 仓位大小 (USDT)
|
||
pnl: float # 盈亏 (USDT)
|
||
pnl_pct: float # 盈亏百分比
|
||
fee: float # 手续费
|
||
rebate: float # 返佣
|
||
hold_seconds: float # 持仓时间(秒)
|
||
close_reason: str # 平仓原因
|
||
|
||
|
||
# ========================= 回测引擎 =========================
|
||
|
||
class RebateBacktest:
|
||
def __init__(
|
||
self,
|
||
# 布林带参数
|
||
bb_period: int = 20,
|
||
bb_std: float = 2.0,
|
||
rsi_period: int = 14,
|
||
rsi_long_threshold: float = 35,
|
||
rsi_short_threshold: float = 65,
|
||
# 持仓管理
|
||
min_hold_seconds: int = 200,
|
||
max_hold_seconds: int = 900,
|
||
stop_loss_pct: float = 0.003,
|
||
hard_stop_pct: float = 0.0045,
|
||
take_profit_pct: float = 0.0002,
|
||
# 仓位 & 费用
|
||
initial_balance: float = 1000.0,
|
||
leverage: int = 50,
|
||
risk_percent: float = 0.005,
|
||
taker_fee_rate: float = 0.0006,
|
||
rebate_rate: float = 0.90,
|
||
# 时间范围
|
||
start_date: str = '2025-01-01',
|
||
end_date: str = '2025-12-31',
|
||
):
|
||
# 策略参数
|
||
self.bb_period = bb_period
|
||
self.bb_std = bb_std
|
||
self.rsi_period = rsi_period
|
||
self.rsi_long_threshold = rsi_long_threshold
|
||
self.rsi_short_threshold = rsi_short_threshold
|
||
|
||
self.min_hold_seconds = min_hold_seconds
|
||
self.max_hold_seconds = max_hold_seconds
|
||
self.stop_loss_pct = stop_loss_pct
|
||
self.hard_stop_pct = hard_stop_pct
|
||
self.take_profit_pct = take_profit_pct
|
||
|
||
self.initial_balance = initial_balance
|
||
self.leverage = leverage
|
||
self.risk_percent = risk_percent
|
||
self.taker_fee_rate = taker_fee_rate
|
||
self.rebate_rate = rebate_rate
|
||
|
||
self.start_date = start_date
|
||
self.end_date = end_date
|
||
|
||
# 状态
|
||
self.balance = initial_balance
|
||
self.position = 0 # -1 空, 0 无, 1 多
|
||
self.open_price = 0.0
|
||
self.open_time = None # datetime
|
||
self.position_size = 0.0 # 开仓金额 (USDT)
|
||
|
||
# 结果
|
||
self.trades: List[Trade] = []
|
||
self.equity_curve: List[dict] = [] # [{datetime, equity}]
|
||
self.peak_equity = initial_balance
|
||
self.max_drawdown = 0.0
|
||
self.max_drawdown_pct = 0.0
|
||
|
||
# ========================= 技术指标 =========================
|
||
|
||
@staticmethod
|
||
def calc_bb(closes: list, period: int, num_std: float):
|
||
"""布林带"""
|
||
if len(closes) < period:
|
||
return None, None, None
|
||
recent = closes[-period:]
|
||
mid = statistics.mean(recent)
|
||
std = statistics.stdev(recent)
|
||
return mid + num_std * std, mid, mid - num_std * std
|
||
|
||
@staticmethod
|
||
def calc_rsi(closes: list, period: int):
|
||
"""RSI"""
|
||
if len(closes) < period + 1:
|
||
return None
|
||
gains, losses = [], []
|
||
for i in range(-period, 0):
|
||
change = closes[i] - closes[i - 1]
|
||
gains.append(max(change, 0))
|
||
losses.append(max(-change, 0))
|
||
avg_gain = sum(gains) / period
|
||
avg_loss = sum(losses) / period
|
||
if avg_loss == 0:
|
||
return 100.0
|
||
rs = avg_gain / avg_loss
|
||
return 100 - (100 / (1 + rs))
|
||
|
||
# ========================= 数据加载 =========================
|
||
|
||
def load_data(self) -> list:
|
||
"""从 SQLite 读取 1 分钟 K 线"""
|
||
db_path = Path(__file__).parent.parent / 'models' / 'database.db'
|
||
if not db_path.exists():
|
||
raise FileNotFoundError(f"数据库不存在: {db_path}")
|
||
|
||
start_dt = datetime.datetime.strptime(self.start_date, '%Y-%m-%d')
|
||
end_dt = datetime.datetime.strptime(self.end_date, '%Y-%m-%d') + datetime.timedelta(days=1)
|
||
start_ms = int(start_dt.timestamp()) * 1000
|
||
end_ms = int(end_dt.timestamp()) * 1000
|
||
|
||
conn = sqlite3.connect(str(db_path))
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
"SELECT id, open, high, low, close FROM bitmart_eth_1m "
|
||
"WHERE id >= ? AND id < ? ORDER BY id",
|
||
(start_ms, end_ms)
|
||
)
|
||
rows = cursor.fetchall()
|
||
conn.close()
|
||
|
||
data = []
|
||
for row in rows:
|
||
ts_ms = row[0]
|
||
ts_sec = ts_ms / 1000.0
|
||
dt = datetime.datetime.fromtimestamp(ts_sec)
|
||
data.append({
|
||
'datetime': dt,
|
||
'timestamp': ts_sec,
|
||
'open': row[1],
|
||
'high': row[2],
|
||
'low': row[3],
|
||
'close': row[4],
|
||
})
|
||
|
||
logger.info(f"加载数据: {len(data)} 条 1分钟K线 ({self.start_date} ~ {self.end_date})")
|
||
if data:
|
||
logger.info(f" 起始: {data[0]['datetime']} | 结束: {data[-1]['datetime']}")
|
||
return data
|
||
|
||
# ========================= 开平仓模拟 =========================
|
||
|
||
def _calc_size(self):
|
||
"""计算开仓金额"""
|
||
return self.balance * self.risk_percent * self.leverage
|
||
|
||
def _open(self, direction: str, price: float, dt: datetime.datetime):
|
||
"""模拟开仓"""
|
||
self.position_size = self._calc_size()
|
||
if self.position_size < 1:
|
||
return False
|
||
|
||
# 扣手续费
|
||
fee = self.position_size * self.taker_fee_rate
|
||
self.balance -= fee
|
||
|
||
self.position = 1 if direction == 'long' else -1
|
||
self.open_price = price
|
||
self.open_time = dt
|
||
return True
|
||
|
||
def _close(self, price: float, dt: datetime.datetime, reason: str):
|
||
"""模拟平仓,返回 Trade"""
|
||
if self.position == 0:
|
||
return None
|
||
|
||
# 计算盈亏
|
||
if self.position == 1:
|
||
pnl_pct = (price - self.open_price) / self.open_price
|
||
else:
|
||
pnl_pct = (self.open_price - price) / self.open_price
|
||
|
||
pnl = self.position_size * pnl_pct
|
||
|
||
# 平仓手续费
|
||
close_value = self.position_size * (1 + pnl_pct)
|
||
fee = close_value * self.taker_fee_rate
|
||
|
||
# 总手续费(开+平)
|
||
open_fee = self.position_size * self.taker_fee_rate
|
||
total_fee = open_fee + fee
|
||
|
||
# 返佣
|
||
rebate = total_fee * self.rebate_rate
|
||
|
||
# 更新余额:加上盈亏 - 平仓手续费 + 返佣
|
||
self.balance += pnl - fee + rebate
|
||
|
||
hold_seconds = (dt - self.open_time).total_seconds()
|
||
|
||
trade = Trade(
|
||
open_time=self.open_time,
|
||
close_time=dt,
|
||
direction='long' if self.position == 1 else 'short',
|
||
open_price=self.open_price,
|
||
close_price=price,
|
||
size=self.position_size,
|
||
pnl=pnl,
|
||
pnl_pct=pnl_pct,
|
||
fee=total_fee,
|
||
rebate=rebate,
|
||
hold_seconds=hold_seconds,
|
||
close_reason=reason,
|
||
)
|
||
self.trades.append(trade)
|
||
|
||
# 重置持仓
|
||
self.position = 0
|
||
self.open_price = 0.0
|
||
self.open_time = None
|
||
self.position_size = 0.0
|
||
|
||
return trade
|
||
|
||
# ========================= 回测主循环 =========================
|
||
|
||
def run(self):
|
||
"""运行回测"""
|
||
data = self.load_data()
|
||
if len(data) < self.bb_period + self.rsi_period + 1:
|
||
logger.error("数据不足,无法回测")
|
||
return
|
||
|
||
closes = []
|
||
total_bars = len(data)
|
||
log_interval = total_bars // 20 # 打印 20 次进度
|
||
|
||
logger.info(f"开始回测... 共 {total_bars} 根 K 线")
|
||
t0 = time.time()
|
||
|
||
for i, bar in enumerate(data):
|
||
price = bar['close']
|
||
dt = bar['datetime']
|
||
closes.append(price)
|
||
|
||
# 需要足够数据才能计算指标
|
||
if len(closes) < max(self.bb_period, self.rsi_period + 1) + 1:
|
||
continue
|
||
|
||
# 计算指标
|
||
upper, middle, lower = self.calc_bb(closes, self.bb_period, self.bb_std)
|
||
rsi = self.calc_rsi(closes, self.rsi_period)
|
||
if upper is None or rsi is None:
|
||
continue
|
||
|
||
# —— 有持仓:检查平仓 ——
|
||
if self.position != 0 and self.open_time:
|
||
hold_sec = (dt - self.open_time).total_seconds()
|
||
|
||
# 计算当前浮动盈亏百分比
|
||
if self.position == 1:
|
||
cur_pnl_pct = (price - self.open_price) / self.open_price
|
||
else:
|
||
cur_pnl_pct = (self.open_price - price) / self.open_price
|
||
|
||
# ① 硬止损(不受持仓时间限制)
|
||
if -cur_pnl_pct >= self.hard_stop_pct:
|
||
self._close(price, dt, f"硬止损 ({cur_pnl_pct*100:+.3f}%)")
|
||
continue
|
||
|
||
# ② 满足最低持仓时间后的平仓条件
|
||
if hold_sec >= self.min_hold_seconds:
|
||
# 止盈:回归中轨
|
||
if self.position == 1 and price >= middle * (1 - self.take_profit_pct):
|
||
self._close(price, dt, "止盈回归中轨")
|
||
continue
|
||
if self.position == -1 and price <= middle * (1 + self.take_profit_pct):
|
||
self._close(price, dt, "止盈回归中轨")
|
||
continue
|
||
|
||
# 止损
|
||
if -cur_pnl_pct >= self.stop_loss_pct:
|
||
self._close(price, dt, f"止损 ({cur_pnl_pct*100:+.3f}%)")
|
||
continue
|
||
|
||
# 超时
|
||
if hold_sec >= self.max_hold_seconds:
|
||
self._close(price, dt, f"超时 ({hold_sec:.0f}s)")
|
||
continue
|
||
|
||
# —— 无持仓:检查开仓 ——
|
||
if self.position == 0:
|
||
if price <= lower and rsi < self.rsi_long_threshold:
|
||
self._open('long', price, dt)
|
||
elif price >= upper and rsi > self.rsi_short_threshold:
|
||
self._open('short', price, dt)
|
||
|
||
# 记录权益曲线(每小时记录一次)
|
||
if i % 60 == 0:
|
||
equity = self.balance
|
||
if self.position != 0 and self.open_price > 0:
|
||
if self.position == 1:
|
||
unrealized = self.position_size * (price - self.open_price) / self.open_price
|
||
else:
|
||
unrealized = self.position_size * (self.open_price - price) / self.open_price
|
||
equity += unrealized
|
||
self.equity_curve.append({'datetime': dt, 'equity': equity})
|
||
|
||
# 最大回撤
|
||
if equity > self.peak_equity:
|
||
self.peak_equity = equity
|
||
dd = (self.peak_equity - equity) / self.peak_equity
|
||
if dd > self.max_drawdown_pct:
|
||
self.max_drawdown_pct = dd
|
||
self.max_drawdown = self.peak_equity - equity
|
||
|
||
# 进度
|
||
if log_interval > 0 and i % log_interval == 0 and i > 0:
|
||
pct = i / total_bars * 100
|
||
logger.info(f" 进度 {pct:.0f}% | 余额 {self.balance:.2f} | 交易 {len(self.trades)} 笔")
|
||
|
||
elapsed = time.time() - t0
|
||
logger.info(f"回测完成,耗时 {elapsed:.1f}s")
|
||
|
||
# 如果还有持仓,强制平仓
|
||
if self.position != 0:
|
||
last_bar = data[-1]
|
||
self._close(last_bar['close'], last_bar['datetime'], "回测结束强制平仓")
|
||
|
||
self.print_results()
|
||
|
||
# ========================= 结果输出 =========================
|
||
|
||
def print_results(self):
|
||
"""打印详细回测结果"""
|
||
trades = self.trades
|
||
if not trades:
|
||
logger.warning("无交易记录")
|
||
return
|
||
|
||
# 基础统计
|
||
total_trades = len(trades)
|
||
wins = [t for t in trades if t.pnl > 0]
|
||
losses = [t for t in trades if t.pnl <= 0]
|
||
win_rate = len(wins) / total_trades * 100
|
||
|
||
total_pnl = sum(t.pnl for t in trades)
|
||
total_fee = sum(t.fee for t in trades)
|
||
total_rebate = sum(t.rebate for t in trades)
|
||
net_profit = self.balance - self.initial_balance
|
||
|
||
avg_pnl = total_pnl / total_trades
|
||
avg_win = statistics.mean([t.pnl for t in wins]) if wins else 0
|
||
avg_loss = statistics.mean([t.pnl for t in losses]) if losses else 0
|
||
|
||
avg_hold = statistics.mean([t.hold_seconds for t in trades])
|
||
total_volume = sum(t.size for t in trades) * 2 # 开+平
|
||
|
||
# 盈亏比
|
||
profit_factor = (sum(t.pnl for t in wins) / abs(sum(t.pnl for t in losses))) if losses and sum(t.pnl for t in losses) != 0 else float('inf')
|
||
|
||
# 连续亏损
|
||
max_consecutive_loss = 0
|
||
current_loss_streak = 0
|
||
for t in trades:
|
||
if t.pnl <= 0:
|
||
current_loss_streak += 1
|
||
max_consecutive_loss = max(max_consecutive_loss, current_loss_streak)
|
||
else:
|
||
current_loss_streak = 0
|
||
|
||
# 长/空统计
|
||
long_trades = [t for t in trades if t.direction == 'long']
|
||
short_trades = [t for t in trades if t.direction == 'short']
|
||
long_wins = len([t for t in long_trades if t.pnl > 0])
|
||
short_wins = len([t for t in short_trades if t.pnl > 0])
|
||
|
||
# 平仓原因统计
|
||
close_reasons = {}
|
||
for t in trades:
|
||
r = t.close_reason.split(' (')[0] # 去掉括号部分
|
||
close_reasons[r] = close_reasons.get(r, 0) + 1
|
||
|
||
print("\n" + "=" * 70)
|
||
print(f" 布林带均值回归返佣策略 — 回测报告")
|
||
print(f" 回测区间: {self.start_date} ~ {self.end_date}")
|
||
print("=" * 70)
|
||
|
||
print(f"\n{'─'*35} 账户 {'─'*35}")
|
||
print(f" 初始资金: {self.initial_balance:>12.2f} USDT")
|
||
print(f" 最终余额: {self.balance:>12.2f} USDT")
|
||
print(f" 净收益: {net_profit:>+12.2f} USDT ({net_profit/self.initial_balance*100:+.2f}%)")
|
||
print(f" 最大回撤: {self.max_drawdown:>12.2f} USDT ({self.max_drawdown_pct*100:.2f}%)")
|
||
|
||
print(f"\n{'─'*35} 交易 {'─'*35}")
|
||
print(f" 总交易次数: {total_trades:>8}")
|
||
print(f" 盈利次数: {len(wins):>8} ({win_rate:.1f}%)")
|
||
print(f" 亏损次数: {len(losses):>8} ({100-win_rate:.1f}%)")
|
||
print(f" 做多交易: {len(long_trades):>8} (胜率 {long_wins/len(long_trades)*100:.1f}%)" if long_trades else "")
|
||
print(f" 做空交易: {len(short_trades):>8} (胜率 {short_wins/len(short_trades)*100:.1f}%)" if short_trades else "")
|
||
print(f" 盈亏比: {profit_factor:>8.2f}")
|
||
print(f" 最大连续亏损: {max_consecutive_loss:>8} 笔")
|
||
|
||
print(f"\n{'─'*35} 盈亏 {'─'*35}")
|
||
print(f" 交易总盈亏: {total_pnl:>+12.4f} USDT")
|
||
print(f" 平均每笔盈亏: {avg_pnl:>+12.4f} USDT")
|
||
print(f" 平均盈利: {avg_win:>+12.4f} USDT")
|
||
print(f" 平均亏损: {avg_loss:>+12.4f} USDT")
|
||
print(f" 最大单笔盈利: {max(t.pnl for t in trades):>+12.4f} USDT")
|
||
print(f" 最大单笔亏损: {min(t.pnl for t in trades):>+12.4f} USDT")
|
||
|
||
print(f"\n{'─'*35} 手续费 & 返佣 {'─'*28}")
|
||
print(f" 交易总额: {total_volume:>12.2f} USDT")
|
||
print(f" 总手续费: {total_fee:>12.4f} USDT")
|
||
print(f" 总返佣 (90%): {total_rebate:>+12.4f} USDT")
|
||
print(f" 净手续费成本: {total_fee - total_rebate:>12.4f} USDT")
|
||
print(f" 返佣占净收益: {total_rebate/net_profit*100:.1f}%" if net_profit != 0 else " 返佣占净收益: N/A")
|
||
|
||
print(f"\n{'─'*35} 持仓 {'─'*35}")
|
||
print(f" 平均持仓时间: {avg_hold:>8.0f} 秒 ({avg_hold/60:.1f} 分钟)")
|
||
print(f" 最短持仓: {min(t.hold_seconds for t in trades):>8.0f} 秒")
|
||
print(f" 最长持仓: {max(t.hold_seconds for t in trades):>8.0f} 秒")
|
||
# 持仓<3分钟的交易数(应该只有硬止损的)
|
||
under_3min = len([t for t in trades if t.hold_seconds < 180])
|
||
print(f" 持仓<3分钟: {under_3min:>8} 笔 (仅硬止损触发)")
|
||
|
||
print(f"\n{'─'*35} 平仓原因 {'─'*31}")
|
||
for reason, count in sorted(close_reasons.items(), key=lambda x: -x[1]):
|
||
print(f" {reason:<20} {count:>6} 笔 ({count/total_trades*100:.1f}%)")
|
||
|
||
# ========================= 月度统计 =========================
|
||
print(f"\n{'─'*35} 月度统计 {'─'*31}")
|
||
print(f" {'月份':<10} {'交易数':>6} {'盈利':>10} {'返佣':>10} {'净收益':>10} {'胜率':>6}")
|
||
print(f" {'─'*56}")
|
||
|
||
monthly = {}
|
||
for t in trades:
|
||
key = t.close_time.strftime('%Y-%m')
|
||
if key not in monthly:
|
||
monthly[key] = {'trades': 0, 'pnl': 0, 'rebate': 0, 'wins': 0}
|
||
monthly[key]['trades'] += 1
|
||
monthly[key]['pnl'] += t.pnl
|
||
monthly[key]['rebate'] += t.rebate
|
||
if t.pnl > 0:
|
||
monthly[key]['wins'] += 1
|
||
|
||
for month in sorted(monthly.keys()):
|
||
m = monthly[month]
|
||
net = m['pnl'] + m['rebate'] - (sum(t.fee for t in trades if t.close_time.strftime('%Y-%m') == month) * (1 - self.rebate_rate))
|
||
wr = m['wins'] / m['trades'] * 100 if m['trades'] > 0 else 0
|
||
print(f" {month:<10} {m['trades']:>6} {m['pnl']:>+10.2f} {m['rebate']:>10.2f} {m['pnl']+m['rebate']:>+10.2f} {wr:>5.1f}%")
|
||
|
||
print("=" * 70)
|
||
|
||
# ========================= 保存交易记录到 CSV =========================
|
||
csv_path = Path(__file__).parent.parent / '回测结果.csv'
|
||
try:
|
||
with open(csv_path, 'w', encoding='utf-8-sig') as f:
|
||
f.write("开仓时间,平仓时间,方向,开仓价,平仓价,仓位(USDT),盈亏(USDT),盈亏%,手续费,返佣,持仓秒数,平仓原因\n")
|
||
for t in trades:
|
||
f.write(
|
||
f"{t.open_time},{t.close_time},{t.direction},"
|
||
f"{t.open_price:.2f},{t.close_price:.2f},{t.size:.2f},"
|
||
f"{t.pnl:.4f},{t.pnl_pct*100:.4f}%,{t.fee:.4f},{t.rebate:.4f},"
|
||
f"{t.hold_seconds:.0f},{t.close_reason}\n"
|
||
)
|
||
logger.info(f"交易记录已保存到: {csv_path}")
|
||
except Exception as e:
|
||
logger.error(f"保存 CSV 失败: {e}")
|
||
|
||
# ========================= 保存权益曲线 =========================
|
||
equity_path = Path(__file__).parent.parent / '权益曲线.csv'
|
||
try:
|
||
with open(equity_path, 'w', encoding='utf-8-sig') as f:
|
||
f.write("时间,权益\n")
|
||
for e in self.equity_curve:
|
||
f.write(f"{e['datetime']},{e['equity']:.2f}\n")
|
||
logger.info(f"权益曲线已保存到: {equity_path}")
|
||
except Exception as e:
|
||
logger.error(f"保存权益曲线失败: {e}")
|
||
|
||
|
||
if __name__ == '__main__':
|
||
bt = RebateBacktest(
|
||
# 布林带参数
|
||
bb_period=20,
|
||
bb_std=2.0,
|
||
rsi_period=14,
|
||
rsi_long_threshold=35,
|
||
rsi_short_threshold=65,
|
||
# 持仓管理
|
||
min_hold_seconds=200, # >3分钟
|
||
max_hold_seconds=900, # 15分钟
|
||
stop_loss_pct=0.003, # 0.3% 止损
|
||
hard_stop_pct=0.0045, # 0.45% 硬止损
|
||
take_profit_pct=0.0002, # 中轨容差
|
||
# 仓位 & 费用
|
||
initial_balance=1000.0, # 初始 1000 USDT
|
||
leverage=50,
|
||
risk_percent=0.005, # 0.5%
|
||
taker_fee_rate=0.0006, # 0.06%
|
||
rebate_rate=0.90, # 90% 返佣
|
||
# 时间范围
|
||
start_date='2025-01-01',
|
||
end_date='2025-12-31',
|
||
)
|
||
bt.run()
|