Files
lm_code/交易/bitmart-返佣策略-回测.py
Your Name b5af5b07f3 哈哈
2026-02-15 02:16:45 +08:00

543 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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()