765 lines
29 KiB
Python
765 lines
29 KiB
Python
"""
|
||
量化交易回测系统 - 三分之一回归策略(双向触发版)
|
||
|
||
========== 策略规则 ==========
|
||
|
||
1. 触发价格计算(基于有效的前一根K线,实体>=0.1):
|
||
- 做多触发价格 = 收盘价 + 实体/3(从收盘价往上涨1/3)
|
||
- 做空触发价格 = 收盘价 - 实体/3(从收盘价往下跌1/3)
|
||
|
||
2. 信号触发条件:
|
||
- 当前K线最高价 >= 做多触发价格 → 做多信号
|
||
- 当前K线最低价 <= 做空触发价格 → 做空信号
|
||
|
||
3. 执行逻辑:
|
||
- 做多时遇到做空信号 -> 平多并反手开空
|
||
- 做空时遇到做多信号 -> 平空并反手开多
|
||
- 同一根K线内只交易一次,防止频繁反手
|
||
|
||
4. 实体过滤:
|
||
- 如果前一根K线的实体部分(|open - close|)< 0.1,继续往前查找
|
||
- 直到找到实体>=0.1的K线,再用那根K线来计算触发价格
|
||
|
||
示例1(阳线):
|
||
前一根K线:开盘3000,收盘3100(阳线,实体=100)
|
||
- 做多触发价格 = 3100 + 33 = 3133(继续上涨做多)
|
||
- 做空触发价格 = 3100 - 33 = 3067(回调做空)
|
||
|
||
示例2(阴线):
|
||
前一根K线:开盘3100,收盘3000(阴线,实体=100)
|
||
- 做多触发价格 = 3000 + 33 = 3033(反弹做多)
|
||
- 做空触发价格 = 3000 - 33 = 2967(继续下跌做空)
|
||
"""
|
||
|
||
import datetime
|
||
import calendar
|
||
import os
|
||
from typing import List, Dict, Optional
|
||
from loguru import logger
|
||
import pandas as pd
|
||
import mplfinance as mpf
|
||
import matplotlib.pyplot as plt
|
||
import matplotlib
|
||
try:
|
||
import plotly.graph_objects as go
|
||
except Exception:
|
||
go = None
|
||
from models.bitmart_klines import BitMartETH5M
|
||
|
||
# 配置中文字体
|
||
import matplotlib.font_manager as fm
|
||
import warnings
|
||
|
||
# 忽略matplotlib的字体警告
|
||
warnings.filterwarnings('ignore', category=UserWarning, module='matplotlib.font_manager')
|
||
warnings.filterwarnings('ignore', message='.*Glyph.*missing.*', category=UserWarning)
|
||
|
||
# 尝试设置中文字体,按优先级尝试
|
||
chinese_fonts = ['SimHei', 'Microsoft YaHei', 'SimSun', 'KaiTi', 'FangSong', 'STSong', 'STHeiti']
|
||
available_fonts = [f.name for f in fm.fontManager.ttflist]
|
||
|
||
# 找到第一个可用的中文字体
|
||
font_found = None
|
||
for font_name in chinese_fonts:
|
||
if font_name in available_fonts:
|
||
font_found = font_name
|
||
break
|
||
|
||
if font_found:
|
||
plt.rcParams['font.sans-serif'] = [font_found] + ['DejaVu Sans']
|
||
logger.info(f"使用中文字体: {font_found}")
|
||
else:
|
||
# 如果没有找到中文字体,尝试使用系统默认字体
|
||
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'SimSun', 'Arial Unicode MS', 'DejaVu Sans']
|
||
logger.warning("未找到中文字体,使用默认配置")
|
||
|
||
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
|
||
plt.rcParams['font.size'] = 10 # 设置默认字体大小
|
||
|
||
# 尝试清除字体缓存(如果可能)
|
||
try:
|
||
# 不强制重建,避免性能问题
|
||
pass
|
||
except:
|
||
pass
|
||
|
||
# 获取当前脚本所在目录
|
||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
# ========================= 工具函数 =========================
|
||
|
||
def is_bullish(c): # 阳线
|
||
return float(c['close']) > float(c['open'])
|
||
|
||
|
||
def is_bearish(c): # 阴线
|
||
return float(c['close']) < float(c['open'])
|
||
|
||
|
||
def get_body_size(candle):
|
||
"""计算K线实体大小(绝对值)"""
|
||
return abs(float(candle['open']) - float(candle['close']))
|
||
|
||
|
||
def find_valid_prev_bar(all_data: List[Dict], current_idx: int, min_body_size: float = 0.1):
|
||
"""
|
||
从当前索引往前查找,直到找到实体>=min_body_size的K线
|
||
返回:(有效K线的索引, K线数据) 或 (None, None)
|
||
"""
|
||
if current_idx <= 0:
|
||
return None, None
|
||
|
||
for i in range(current_idx - 1, -1, -1):
|
||
prev = all_data[i]
|
||
body_size = get_body_size(prev)
|
||
if body_size >= min_body_size:
|
||
return i, prev
|
||
|
||
return None, None
|
||
|
||
|
||
def get_one_third_levels(prev):
|
||
"""
|
||
计算前一根K线实体的 1/3 双向触发价格
|
||
返回:(做多触发价格, 做空触发价格)
|
||
|
||
基于收盘价计算(无论阴线阳线):
|
||
- 做多触发价格 = 收盘价 + 实体/3(从收盘价往上涨1/3实体)
|
||
- 做空触发价格 = 收盘价 - 实体/3(从收盘价往下跌1/3实体)
|
||
|
||
示例:
|
||
阳线 open=3000, close=3100, 实体=100
|
||
- 做多触发 = 3100 + 33 = 3133(继续涨)
|
||
- 做空触发 = 3100 - 33 = 3067(回调)
|
||
|
||
阴线 open=3100, close=3000, 实体=100
|
||
- 做多触发 = 3000 + 33 = 3033(反弹)
|
||
- 做空触发 = 3000 - 33 = 2967(继续跌)
|
||
"""
|
||
p_open = float(prev['open'])
|
||
p_close = float(prev['close'])
|
||
|
||
body = abs(p_open - p_close)
|
||
|
||
if body < 0.001: # 十字星,忽略
|
||
return None, None
|
||
|
||
# 基于收盘价的双向触发价格
|
||
long_trigger = p_close + body / 3 # 从收盘价往上涨1/3触发做多
|
||
short_trigger = p_close - body / 3 # 从收盘价往下跌1/3触发做空
|
||
|
||
return long_trigger, short_trigger
|
||
|
||
|
||
def check_trigger(all_data: List[Dict], current_idx: int, min_body_size: float = 0.1):
|
||
"""
|
||
检查当前K线是否触发了交易信号(双向检测)
|
||
返回:(方向, 触发价格, 有效前一根K线索引) 或 (None, None, None)
|
||
|
||
规则:
|
||
- 当前K线高点 >= 做多触发价格 → 做多信号
|
||
- 当前K线低点 <= 做空触发价格 → 做空信号
|
||
- 如果同时触发两个方向,以先触发的为准(这里简化为优先做空,因为下跌更快)
|
||
"""
|
||
if current_idx <= 0:
|
||
return None, None, None
|
||
|
||
curr = all_data[current_idx]
|
||
|
||
# 查找实体>=min_body_size的前一根K线
|
||
valid_prev_idx, prev = find_valid_prev_bar(all_data, current_idx, min_body_size)
|
||
|
||
if prev is None:
|
||
return None, None, None
|
||
|
||
long_trigger, short_trigger = get_one_third_levels(prev)
|
||
|
||
if long_trigger is None:
|
||
return None, None, None
|
||
|
||
# 使用影线部分(high/low)来判断
|
||
c_high = float(curr['high'])
|
||
c_low = float(curr['low'])
|
||
|
||
# 检测是否触发
|
||
long_triggered = c_high >= long_trigger
|
||
short_triggered = c_low <= short_trigger
|
||
|
||
# 如果两个方向都触发,需要判断哪个先触发
|
||
# 简化处理:比较触发价格距离开盘价的远近,更近的先触发
|
||
if long_triggered and short_triggered:
|
||
c_open = float(curr['open'])
|
||
dist_to_long = abs(long_trigger - c_open)
|
||
dist_to_short = abs(short_trigger - c_open)
|
||
if dist_to_short <= dist_to_long:
|
||
return 'short', short_trigger, valid_prev_idx
|
||
else:
|
||
return 'long', long_trigger, valid_prev_idx
|
||
|
||
if short_triggered:
|
||
return 'short', short_trigger, valid_prev_idx
|
||
|
||
if long_triggered:
|
||
return 'long', long_trigger, valid_prev_idx
|
||
|
||
return None, None, None
|
||
|
||
|
||
def get_data_by_date(model, date_str: str):
|
||
"""按天获取指定表的数据"""
|
||
try:
|
||
target_date = datetime.datetime.strptime(date_str, '%Y-%m-%d')
|
||
except ValueError:
|
||
logger.error("日期格式不正确,请使用 YYYY-MM-DD 格式。")
|
||
return []
|
||
|
||
start_ts = int(target_date.timestamp() * 1000)
|
||
end_ts = int((target_date + datetime.timedelta(days=1)).timestamp() * 1000) - 1
|
||
|
||
query = model.select().where(model.id.between(start_ts, end_ts)).order_by(model.id.asc())
|
||
data = [{'id': i.id, 'open': i.open, 'high': i.high, 'low': i.low, 'close': i.close} for i in query]
|
||
|
||
if data:
|
||
data.sort(key=lambda x: x['id'])
|
||
|
||
return data
|
||
|
||
|
||
# ========================= 回测逻辑 =========================
|
||
|
||
def backtest_one_third_strategy(dates: List[str]):
|
||
"""三分之一回归策略回测(优化版)"""
|
||
all_data: List[Dict] = []
|
||
|
||
for d in dates:
|
||
day_data = get_data_by_date(BitMartETH5M, d)
|
||
all_data.extend(day_data)
|
||
|
||
logger.info(f"总共查询了 {len(dates)} 天,获取到 {len(all_data)} 条K线数据")
|
||
|
||
if not all_data:
|
||
logger.warning("未获取到任何数据")
|
||
return [], {'long': {'count': 0, 'wins': 0, 'total_profit': 0.0},
|
||
'short': {'count': 0, 'wins': 0, 'total_profit': 0.0}}
|
||
|
||
all_data.sort(key=lambda x: x['id'])
|
||
|
||
if len(all_data) > 1:
|
||
first_time = datetime.datetime.fromtimestamp(all_data[0]['id'] / 1000)
|
||
last_time = datetime.datetime.fromtimestamp(all_data[-1]['id'] / 1000)
|
||
logger.info(f"数据范围:{first_time.strftime('%Y-%m-%d %H:%M')} 到 {last_time.strftime('%Y-%m-%d %H:%M')}")
|
||
|
||
# 验证排序:打印前5条数据
|
||
logger.info("===== 前5条数据(验证排序)=====")
|
||
for i in range(min(5, len(all_data))):
|
||
d = all_data[i]
|
||
t = datetime.datetime.fromtimestamp(d['id'] / 1000).strftime('%Y-%m-%d %H:%M:%S')
|
||
k_type = "阳线" if is_bullish(d) else ("阴线" if is_bearish(d) else "十字星")
|
||
logger.info(f" [{i}] {t} | {k_type} | O={d['open']:.2f} H={d['high']:.2f} L={d['low']:.2f} C={d['close']:.2f}")
|
||
|
||
stats = {
|
||
'long': {'count': 0, 'wins': 0, 'total_profit': 0.0, 'name': '做多'},
|
||
'short': {'count': 0, 'wins': 0, 'total_profit': 0.0, 'name': '做空'},
|
||
}
|
||
|
||
# 额外统计信息
|
||
extra_stats = {
|
||
'same_dir_ignored': 0, # 同向信号被忽略次数
|
||
'no_signal_bars': 0, # 无信号K线数
|
||
'total_bars': len(all_data) - 1, # 总K线数(排除第一根)
|
||
}
|
||
|
||
trades: List[Dict] = []
|
||
current_position: Optional[Dict] = None
|
||
last_trade_bar: Optional[int] = None # 记录上次交易的K线索引,防止同一K线重复交易
|
||
|
||
for idx in range(1, len(all_data)):
|
||
curr = all_data[idx]
|
||
|
||
# 使用check_trigger函数,它会自动查找实体>=0.1的前一根K线
|
||
direction, trigger_price, valid_prev_idx = check_trigger(all_data, idx, min_body_size=0.1)
|
||
|
||
# 获取有效的前一根K线用于日志输出
|
||
valid_prev = all_data[valid_prev_idx] if valid_prev_idx is not None else None
|
||
|
||
# 无信号时跳过
|
||
if direction is None:
|
||
extra_stats['no_signal_bars'] += 1
|
||
continue
|
||
|
||
# 同一K线内已交易,跳过(与交易代码逻辑一致)
|
||
if last_trade_bar == idx:
|
||
continue
|
||
|
||
# 空仓时,有信号就开仓
|
||
if current_position is None:
|
||
if valid_prev is not None:
|
||
# 打印开仓时的K线信息
|
||
prev_time = datetime.datetime.fromtimestamp(valid_prev['id'] / 1000).strftime('%Y-%m-%d %H:%M')
|
||
curr_time = datetime.datetime.fromtimestamp(curr['id'] / 1000).strftime('%Y-%m-%d %H:%M')
|
||
prev_type = "阳线" if is_bullish(valid_prev) else ("阴线" if is_bearish(valid_prev) else "十字星")
|
||
curr_type = "阳线" if is_bullish(curr) else ("阴线" if is_bearish(curr) else "十字星")
|
||
prev_body = get_body_size(valid_prev)
|
||
logger.info(f"【开仓】{direction} @ {trigger_price:.2f}")
|
||
logger.info(f" 有效前一根[{prev_time}]: {prev_type} 实体={prev_body:.2f} O={valid_prev['open']:.2f} H={valid_prev['high']:.2f} L={valid_prev['low']:.2f} C={valid_prev['close']:.2f}")
|
||
logger.info(f" 当前根[{curr_time}]: {curr_type} O={curr['open']:.2f} H={curr['high']:.2f} L={curr['low']:.2f} C={curr['close']:.2f}")
|
||
|
||
current_position = {
|
||
'direction': direction,
|
||
'entry_price': trigger_price,
|
||
'entry_time': curr['id'],
|
||
'entry_bar': idx
|
||
}
|
||
stats[direction]['count'] += 1
|
||
last_trade_bar = idx # 记录交易K线
|
||
continue
|
||
|
||
# 有仓位时,检查信号
|
||
pos_dir = current_position['direction']
|
||
|
||
# 同向信号,忽略(与交易代码逻辑一致)
|
||
if direction == pos_dir:
|
||
extra_stats['same_dir_ignored'] += 1
|
||
continue
|
||
|
||
# 反向信号,平仓反手
|
||
if valid_prev is not None:
|
||
exit_price = trigger_price
|
||
|
||
if pos_dir == 'long':
|
||
diff = exit_price - current_position['entry_price']
|
||
else:
|
||
diff = current_position['entry_price'] - exit_price
|
||
|
||
# 打印平仓时的K线信息
|
||
prev_time = datetime.datetime.fromtimestamp(valid_prev['id'] / 1000).strftime('%Y-%m-%d %H:%M')
|
||
curr_time = datetime.datetime.fromtimestamp(curr['id'] / 1000).strftime('%Y-%m-%d %H:%M')
|
||
prev_type = "阳线" if is_bullish(valid_prev) else ("阴线" if is_bearish(valid_prev) else "十字星")
|
||
curr_type = "阳线" if is_bullish(curr) else ("阴线" if is_bearish(curr) else "十字星")
|
||
prev_body = get_body_size(valid_prev)
|
||
logger.info(f"【平仓反手】{pos_dir} -> {direction} @ {exit_price:.2f}, 盈亏: {diff:.2f}")
|
||
logger.info(f" 有效前一根[{prev_time}]: {prev_type} 实体={prev_body:.2f} O={valid_prev['open']:.2f} H={valid_prev['high']:.2f} L={valid_prev['low']:.2f} C={valid_prev['close']:.2f}")
|
||
logger.info(f" 当前根[{curr_time}]: {curr_type} O={curr['open']:.2f} H={curr['high']:.2f} L={curr['low']:.2f} C={curr['close']:.2f}")
|
||
|
||
trades.append({
|
||
'entry_time': datetime.datetime.fromtimestamp(current_position['entry_time'] / 1000),
|
||
'exit_time': datetime.datetime.fromtimestamp(curr['id'] / 1000),
|
||
'entry_time_ms': current_position['entry_time'],
|
||
'exit_time_ms': curr['id'],
|
||
'direction': '做多' if pos_dir == 'long' else '做空',
|
||
'entry': current_position['entry_price'],
|
||
'exit': exit_price,
|
||
'diff': diff,
|
||
'hold_bars': idx - current_position['entry_bar'] # 持仓K线数
|
||
})
|
||
|
||
stats[pos_dir]['total_profit'] += diff
|
||
if diff > 0:
|
||
stats[pos_dir]['wins'] += 1
|
||
|
||
# 反手开仓
|
||
current_position = {
|
||
'direction': direction,
|
||
'entry_price': trigger_price,
|
||
'entry_time': curr['id'],
|
||
'entry_bar': idx
|
||
}
|
||
stats[direction]['count'] += 1
|
||
last_trade_bar = idx # 记录交易K线
|
||
|
||
# 尾仓处理
|
||
if current_position:
|
||
last = all_data[-1]
|
||
last_idx = len(all_data) - 1
|
||
exit_price = float(last['close'])
|
||
pos_dir = current_position['direction']
|
||
|
||
if pos_dir == 'long':
|
||
diff = exit_price - current_position['entry_price']
|
||
else:
|
||
diff = current_position['entry_price'] - exit_price
|
||
|
||
trades.append({
|
||
'entry_time': datetime.datetime.fromtimestamp(current_position['entry_time'] / 1000),
|
||
'exit_time': datetime.datetime.fromtimestamp(last['id'] / 1000),
|
||
'entry_time_ms': current_position['entry_time'],
|
||
'exit_time_ms': last['id'],
|
||
'direction': '做多' if pos_dir == 'long' else '做空',
|
||
'entry': current_position['entry_price'],
|
||
'exit': exit_price,
|
||
'diff': diff,
|
||
'hold_bars': last_idx - current_position['entry_bar'], # 持仓K线数
|
||
'is_tail': True # 标记为尾仓平仓
|
||
})
|
||
stats[pos_dir]['total_profit'] += diff
|
||
if diff > 0:
|
||
stats[pos_dir]['wins'] += 1
|
||
|
||
logger.info(f"【尾仓平仓】{pos_dir} @ {exit_price:.2f}, 盈亏: {diff:.2f}")
|
||
|
||
# 打印额外统计信息
|
||
logger.info(f"\n===== 信号统计 =====")
|
||
logger.info(f"总K线数: {extra_stats['total_bars']}")
|
||
logger.info(f"无信号K线: {extra_stats['no_signal_bars']} ({extra_stats['no_signal_bars']/extra_stats['total_bars']*100:.1f}%)")
|
||
logger.info(f"同向信号忽略: {extra_stats['same_dir_ignored']}")
|
||
|
||
return trades, stats, all_data, extra_stats
|
||
|
||
|
||
# ========================= 绘图函数 =========================
|
||
def plot_trades(all_data: List[Dict], trades: List[Dict], save_path: str = None):
|
||
"""
|
||
绘制K线图并标注交易点位
|
||
"""
|
||
if not all_data:
|
||
logger.warning("没有数据可绘制")
|
||
return
|
||
|
||
# 转换为 DataFrame
|
||
df = pd.DataFrame(all_data)
|
||
df['datetime'] = pd.to_datetime(df['id'], unit='ms')
|
||
df.set_index('datetime', inplace=True)
|
||
df = df.rename(columns={'open': 'Open', 'high': 'High', 'low': 'Low', 'close': 'Close'})
|
||
df = df[['Open', 'High', 'Low', 'Close']]
|
||
|
||
# 准备标记点
|
||
buy_signals = [] # 做多开仓
|
||
sell_signals = [] # 做空开仓
|
||
buy_exits = [] # 做多平仓
|
||
sell_exits = [] # 做空平仓
|
||
|
||
for trade in trades:
|
||
entry_time = pd.to_datetime(trade['entry_time_ms'], unit='ms')
|
||
exit_time = pd.to_datetime(trade['exit_time_ms'], unit='ms')
|
||
direction = trade['direction']
|
||
entry_price = trade['entry']
|
||
exit_price = trade['exit']
|
||
|
||
if direction == '做多':
|
||
buy_signals.append((entry_time, entry_price))
|
||
buy_exits.append((exit_time, exit_price))
|
||
else:
|
||
sell_signals.append((entry_time, entry_price))
|
||
sell_exits.append((exit_time, exit_price))
|
||
|
||
# 创建标记序列
|
||
buy_markers = pd.Series(index=df.index, dtype=float)
|
||
sell_markers = pd.Series(index=df.index, dtype=float)
|
||
buy_exit_markers = pd.Series(index=df.index, dtype=float)
|
||
sell_exit_markers = pd.Series(index=df.index, dtype=float)
|
||
|
||
for t, p in buy_signals:
|
||
if t in buy_markers.index:
|
||
buy_markers[t] = p
|
||
for t, p in sell_signals:
|
||
if t in sell_markers.index:
|
||
sell_markers[t] = p
|
||
for t, p in buy_exits:
|
||
if t in buy_exit_markers.index:
|
||
buy_exit_markers[t] = p
|
||
for t, p in sell_exits:
|
||
if t in sell_exit_markers.index:
|
||
sell_exit_markers[t] = p
|
||
|
||
# 添加标记
|
||
add_plots = []
|
||
|
||
if buy_markers.notna().any():
|
||
add_plots.append(mpf.make_addplot(buy_markers, type='scatter', markersize=100,
|
||
marker='^', color='green', label='做多开仓'))
|
||
if sell_markers.notna().any():
|
||
add_plots.append(mpf.make_addplot(sell_markers, type='scatter', markersize=100,
|
||
marker='v', color='red', label='做空开仓'))
|
||
if buy_exit_markers.notna().any():
|
||
add_plots.append(mpf.make_addplot(buy_exit_markers, type='scatter', markersize=80,
|
||
marker='x', color='darkgreen', label='做多平仓'))
|
||
if sell_exit_markers.notna().any():
|
||
add_plots.append(mpf.make_addplot(sell_exit_markers, type='scatter', markersize=80,
|
||
marker='x', color='darkred', label='做空平仓'))
|
||
|
||
# 绘制K线图(更接近交易所风格)
|
||
market_colors = mpf.make_marketcolors(
|
||
up='#26a69a', # 常见交易所绿色
|
||
down='#ef5350', # 常见交易所红色
|
||
edge='inherit',
|
||
wick='inherit',
|
||
volume='inherit'
|
||
)
|
||
style = mpf.make_mpf_style(
|
||
base_mpf_style='binance',
|
||
marketcolors=market_colors,
|
||
gridstyle='-',
|
||
gridcolor='#e6e6e6'
|
||
)
|
||
|
||
fig, axes = mpf.plot(
|
||
df,
|
||
type='candle',
|
||
style=style,
|
||
title='三分之一回归策略回测',
|
||
ylabel='价格',
|
||
addplot=add_plots if add_plots else None,
|
||
figsize=(16, 9),
|
||
returnfig=True
|
||
)
|
||
|
||
# 添加图例
|
||
axes[0].legend(['做多开仓 ▲', '做空开仓 ▼', '做多平仓 ✕', '做空平仓 ✕'], loc='upper left')
|
||
|
||
# 标注开仓细节(方向、价格、时间)
|
||
if trades:
|
||
ax = axes[0]
|
||
max_annotate = 60 # 过多会拥挤,可按需调大/调小
|
||
annotated = 0
|
||
for i, trade in enumerate(trades):
|
||
if annotated >= max_annotate:
|
||
break
|
||
entry_time = pd.to_datetime(trade['entry_time_ms'], unit='ms')
|
||
if entry_time not in df.index:
|
||
continue
|
||
entry_price = trade['entry']
|
||
direction = trade['direction']
|
||
color = 'green' if direction == '做多' else 'red'
|
||
text = f"{direction} @ {entry_price:.2f}\n{entry_time.strftime('%m-%d %H:%M')}"
|
||
y_offset = 20 if (i % 2 == 0) else -30
|
||
ax.annotate(
|
||
text,
|
||
xy=(entry_time, entry_price),
|
||
xytext=(0, y_offset),
|
||
textcoords='offset points',
|
||
ha='center',
|
||
va='bottom' if y_offset > 0 else 'top',
|
||
fontsize=8,
|
||
color=color,
|
||
arrowprops=dict(arrowstyle='->', color=color, lw=0.6, alpha=0.6)
|
||
)
|
||
annotated += 1
|
||
|
||
if save_path:
|
||
plt.savefig(save_path, dpi=150, bbox_inches='tight')
|
||
logger.info(f"图表已保存到: {save_path}")
|
||
|
||
plt.show()
|
||
|
||
|
||
def plot_trades_interactive(all_data: List[Dict], trades: List[Dict], html_path: str = None):
|
||
"""
|
||
交互式K线图(TradingView风格:支持缩放、时间区间/价格区间平移缩放)
|
||
"""
|
||
if not all_data:
|
||
logger.warning("没有数据可绘制")
|
||
return
|
||
if go is None:
|
||
logger.warning("未安装 plotly,无法绘制交互式图。请先安装:pip install plotly")
|
||
return
|
||
|
||
df = pd.DataFrame(all_data)
|
||
df['datetime'] = pd.to_datetime(df['id'], unit='ms')
|
||
df.sort_values('datetime', inplace=True)
|
||
|
||
fig = go.Figure(
|
||
data=[
|
||
go.Candlestick(
|
||
x=df['datetime'],
|
||
open=df['open'],
|
||
high=df['high'],
|
||
low=df['low'],
|
||
close=df['close'],
|
||
increasing_line_color='#26a69a',
|
||
decreasing_line_color='#ef5350',
|
||
name='K线'
|
||
)
|
||
]
|
||
)
|
||
|
||
# 标注开仓点
|
||
if trades:
|
||
entry_x = []
|
||
entry_y = []
|
||
entry_text = []
|
||
entry_color = []
|
||
for t in trades:
|
||
entry_x.append(pd.to_datetime(t['entry_time_ms'], unit='ms'))
|
||
entry_y.append(t['entry'])
|
||
entry_text.append(f"{t['direction']} @ {t['entry']:.2f}<br>{t['entry_time']}")
|
||
entry_color.append('#26a69a' if t['direction'] == '做多' else '#ef5350')
|
||
|
||
fig.add_trace(
|
||
go.Scatter(
|
||
x=entry_x,
|
||
y=entry_y,
|
||
mode='markers',
|
||
marker=dict(size=8, color=entry_color),
|
||
name='开仓',
|
||
text=entry_text,
|
||
hoverinfo='text'
|
||
)
|
||
)
|
||
|
||
# TradingView风格:深色背景 + 交互缩放
|
||
fig.update_layout(
|
||
title='三分之一回归策略回测(交互式)',
|
||
xaxis=dict(
|
||
rangeslider=dict(visible=True),
|
||
type='date',
|
||
showgrid=False
|
||
),
|
||
yaxis=dict(
|
||
showgrid=False,
|
||
fixedrange=False
|
||
),
|
||
plot_bgcolor='#0b0e11',
|
||
paper_bgcolor='#0b0e11',
|
||
font=dict(color='#d1d4dc'),
|
||
hovermode='x unified',
|
||
dragmode='zoom'
|
||
)
|
||
|
||
fig.show()
|
||
if html_path:
|
||
fig.write_html(html_path)
|
||
logger.info(f"交互图已保存到: {html_path}")
|
||
|
||
|
||
# ========================= 主程序 =========================
|
||
if __name__ == '__main__':
|
||
# ==================== 配置参数 ====================
|
||
START_DATE = "2025-01-01"
|
||
END_DATE = "2025-12-31"
|
||
|
||
# ==================== 生成日期列表 ====================
|
||
dates = []
|
||
if START_DATE and END_DATE:
|
||
start_dt = datetime.datetime.strptime(START_DATE, '%Y-%m-%d')
|
||
end_dt = datetime.datetime.strptime(END_DATE, '%Y-%m-%d')
|
||
|
||
current_dt = start_dt
|
||
while current_dt <= end_dt:
|
||
dates.append(current_dt.strftime('%Y-%m-%d'))
|
||
current_dt += datetime.timedelta(days=1)
|
||
|
||
logger.info(f"查询日期范围:{START_DATE} 到 {END_DATE},共 {len(dates)} 天")
|
||
|
||
# ==================== 执行回测 ====================
|
||
trades, stats, all_data, extra_stats = backtest_one_third_strategy(dates)
|
||
|
||
# ==================== 输出结果 ====================
|
||
logger.info("===== 每笔交易详情 =====")
|
||
|
||
contract_size = 10000
|
||
open_fee_fixed = 5
|
||
close_fee_rate = 0.0005
|
||
|
||
total_points_profit = 0
|
||
total_money_profit = 0
|
||
total_fee = 0
|
||
|
||
for t in trades:
|
||
entry = t['entry']
|
||
exit_p = t['exit']
|
||
direction = t['direction']
|
||
|
||
point_diff = t['diff']
|
||
money_profit = point_diff / entry * contract_size
|
||
fee = open_fee_fixed + (contract_size / entry * exit_p * close_fee_rate)
|
||
net_profit = money_profit - fee
|
||
|
||
t.update({
|
||
'point_diff': point_diff,
|
||
'raw_profit': money_profit,
|
||
'fee': fee,
|
||
'net_profit': net_profit
|
||
})
|
||
|
||
total_points_profit += point_diff
|
||
total_money_profit += money_profit
|
||
total_fee += fee
|
||
|
||
logger.info(
|
||
f"{t['entry_time']} {direction} "
|
||
f"入={entry:.2f} 出={exit_p:.2f} 差价={point_diff:.2f} "
|
||
f"原始盈利={money_profit:.2f} 手续费={fee:.2f} 净利润={net_profit:.2f}"
|
||
)
|
||
|
||
# ==================== 汇总统计 ====================
|
||
total_net_profit = total_money_profit - total_fee
|
||
|
||
# 计算额外统计
|
||
win_count = len([t for t in trades if t['diff'] > 0])
|
||
lose_count = len([t for t in trades if t['diff'] <= 0])
|
||
total_win_rate = (win_count / len(trades) * 100) if trades else 0
|
||
|
||
# 计算平均持仓K线数
|
||
hold_bars_list = [t.get('hold_bars', 0) for t in trades if 'hold_bars' in t]
|
||
avg_hold_bars = sum(hold_bars_list) / len(hold_bars_list) if hold_bars_list else 0
|
||
|
||
# 计算最大连续亏损
|
||
max_consecutive_loss = 0
|
||
current_consecutive_loss = 0
|
||
for t in trades:
|
||
if t['diff'] <= 0:
|
||
current_consecutive_loss += 1
|
||
max_consecutive_loss = max(max_consecutive_loss, current_consecutive_loss)
|
||
else:
|
||
current_consecutive_loss = 0
|
||
|
||
# 计算最大回撤
|
||
cumulative_profit = 0
|
||
peak = 0
|
||
max_drawdown = 0
|
||
for t in trades:
|
||
cumulative_profit += t.get('net_profit', t['diff'])
|
||
peak = max(peak, cumulative_profit)
|
||
drawdown = peak - cumulative_profit
|
||
max_drawdown = max(max_drawdown, drawdown)
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f"【三分之一回归策略 回测结果】")
|
||
print(f"{'='*60}")
|
||
print(f"交易笔数:{len(trades)}")
|
||
print(f"盈利笔数:{win_count} 亏损笔数:{lose_count}")
|
||
print(f"总胜率:{total_win_rate:.2f}%")
|
||
print(f"平均持仓K线数:{avg_hold_bars:.1f}")
|
||
print(f"最大连续亏损:{max_consecutive_loss} 笔")
|
||
print(f"{'='*60}")
|
||
print(f"总点差:{total_points_profit:.2f}")
|
||
print(f"总原始盈利:{total_money_profit:.2f}")
|
||
print(f"总手续费:{total_fee:.2f}")
|
||
print(f"总净利润:{total_net_profit:.2f}")
|
||
print(f"最大回撤:{max_drawdown:.2f}")
|
||
print(f"{'='*60}")
|
||
|
||
print("\n===== 方向统计 =====")
|
||
for k, v in stats.items():
|
||
count = v['count']
|
||
wins = v['wins']
|
||
total_p = v['total_profit']
|
||
win_rate = (wins / count * 100) if count > 0 else 0.0
|
||
avg_p = (total_p / count) if count > 0 else 0.0
|
||
print(f"{v['name']}: 次数={count} 胜率={win_rate:.2f}% 总价差={total_p:.2f} 平均价差={avg_p:.2f}")
|
||
|
||
print("\n===== 信号统计 =====")
|
||
print(f"总K线数: {extra_stats['total_bars']}")
|
||
print(f"无信号K线: {extra_stats['no_signal_bars']} ({extra_stats['no_signal_bars']/extra_stats['total_bars']*100:.1f}%)")
|
||
print(f"同向信号忽略: {extra_stats['same_dir_ignored']}")
|
||
|
||
# ==================== 绘制图表 ====================
|
||
if trades and all_data:
|
||
# 如果数据太多,只绘制最近一部分(比如最近500根K线)
|
||
max_bars = 500
|
||
if len(all_data) > max_bars:
|
||
logger.info(f"数据量较大({len(all_data)}条),只绘制最近 {max_bars} 根K线")
|
||
plot_data = all_data[-max_bars:]
|
||
# 过滤出在这个时间范围内的交易
|
||
min_time = datetime.datetime.fromtimestamp(plot_data[0]['id'] / 1000)
|
||
plot_trades_filtered = [t for t in trades if t['entry_time'] >= min_time]
|
||
else:
|
||
plot_data = all_data
|
||
plot_trades_filtered = trades
|
||
|
||
save_path = os.path.join(SCRIPT_DIR, '回测图表.png')
|
||
plot_trades(plot_data, plot_trades_filtered, save_path=save_path)
|
||
# 交互式版本(TradingView风格):支持时间区间/价格缩放
|
||
html_path = os.path.join(SCRIPT_DIR, '回测图表_交互式.html')
|
||
plot_trades_interactive(plot_data, plot_trades_filtered, html_path=html_path)
|