528 lines
17 KiB
Python
528 lines
17 KiB
Python
"""
|
||
生成回测可视化图表
|
||
显示K线、布林带、开仓/平仓位置
|
||
"""
|
||
|
||
import pandas as pd
|
||
import json
|
||
from pathlib import Path
|
||
from peewee import *
|
||
import time
|
||
from loguru import logger
|
||
|
||
# 数据库配置
|
||
DB_PATH = Path(__file__).parent / 'models' / 'database.db'
|
||
db = SqliteDatabase(str(DB_PATH))
|
||
|
||
class BitMartETH5M(Model):
|
||
"""5分钟K线模型"""
|
||
id = BigIntegerField(primary_key=True)
|
||
open = FloatField(null=True)
|
||
high = FloatField(null=True)
|
||
low = FloatField(null=True)
|
||
close = FloatField(null=True)
|
||
|
||
class Meta:
|
||
database = db
|
||
table_name = 'bitmart_eth_5m'
|
||
|
||
|
||
def calculate_bollinger_bands(df, period=10, std_dev=2.5):
|
||
"""计算布林带(右移1根,与回测口径一致)"""
|
||
df['sma'] = df['close'].rolling(window=period).mean()
|
||
df['std'] = df['close'].rolling(window=period).std()
|
||
df['bb_upper'] = (df['sma'] + std_dev * df['std']).shift(1)
|
||
df['bb_mid'] = df['sma'].shift(1)
|
||
df['bb_lower'] = (df['sma'] - std_dev * df['std']).shift(1)
|
||
return df
|
||
|
||
|
||
def generate_chart_data(start_date, end_date, trades_file):
|
||
"""生成图表数据"""
|
||
|
||
# 1. 加载K线数据
|
||
start_ts = int(pd.Timestamp(start_date).timestamp() * 1000)
|
||
end_ts = int(pd.Timestamp(end_date).timestamp() * 1000) + 86400000 # 加一天确保包含end_date当天的数据
|
||
|
||
query = BitMartETH5M.select().where(
|
||
(BitMartETH5M.id >= start_ts) & (BitMartETH5M.id <= end_ts)
|
||
).order_by(BitMartETH5M.id)
|
||
|
||
data = []
|
||
for row in query:
|
||
data.append({
|
||
'timestamp': row.id,
|
||
'open': row.open,
|
||
'high': row.high,
|
||
'low': row.low,
|
||
'close': row.close
|
||
})
|
||
|
||
df = pd.DataFrame(data)
|
||
df['datetime'] = pd.to_datetime(df['timestamp'], unit='ms')
|
||
|
||
# 2. 计算布林带
|
||
df = calculate_bollinger_bands(df)
|
||
|
||
# 3. 加载交易记录
|
||
trades_df = pd.read_csv(trades_file)
|
||
trades_df['datetime'] = pd.to_datetime(trades_df['timestamp'])
|
||
|
||
# 4. 准备图表数据
|
||
chart_data = []
|
||
for idx, row in df.iterrows():
|
||
chart_data.append({
|
||
'timestamp': int(row['timestamp']),
|
||
'datetime': row['datetime'].strftime('%Y-%m-%d %H:%M'),
|
||
'open': float(row['open']) if pd.notna(row['open']) else None,
|
||
'high': float(row['high']) if pd.notna(row['high']) else None,
|
||
'low': float(row['low']) if pd.notna(row['low']) else None,
|
||
'close': float(row['close']) if pd.notna(row['close']) else None,
|
||
'bb_upper': float(row['bb_upper']) if pd.notna(row['bb_upper']) else None,
|
||
'bb_mid': float(row['bb_mid']) if pd.notna(row['bb_mid']) else None,
|
||
'bb_lower': float(row['bb_lower']) if pd.notna(row['bb_lower']) else None,
|
||
})
|
||
|
||
# 5. 准备交易标记数据
|
||
trades_markers = []
|
||
for idx, trade in trades_df.iterrows():
|
||
action = trade['action']
|
||
price = trade['price']
|
||
timestamp = trade['datetime']
|
||
reason = trade['reason']
|
||
|
||
# 找到对应的K线索引(使用最近的K线)
|
||
kline_idx = df[df['datetime'] == timestamp].index
|
||
if len(kline_idx) == 0:
|
||
# 如果找不到完全匹配的,找最近的K线
|
||
time_diff = abs(df['datetime'] - timestamp)
|
||
min_diff = time_diff.min()
|
||
# 如果时间差超过10分钟,跳过这个标记
|
||
if min_diff > pd.Timedelta(minutes=10):
|
||
print(f"跳过标记 {timestamp},找不到匹配的K线(最小时间差: {min_diff})")
|
||
continue
|
||
kline_idx = time_diff.idxmin()
|
||
else:
|
||
kline_idx = kline_idx[0]
|
||
|
||
# 图上显示真实成交价,避免“总在最高/最低点成交”的错觉
|
||
kline = df.loc[kline_idx]
|
||
if pd.notna(price):
|
||
display_price = float(price)
|
||
else:
|
||
display_price = float(kline['close'])
|
||
|
||
marker = {
|
||
'timestamp': timestamp.strftime('%Y-%m-%d %H:%M'),
|
||
'price': display_price, # 使用K线实际价格
|
||
'action': action,
|
||
'reason': reason,
|
||
'index': int(kline_idx)
|
||
}
|
||
|
||
# 关键操作直接显示在图上,便于快速理解“为什么做这笔交易”
|
||
# 新增:开仓/加仓也展示标签
|
||
marker['show_reason_label'] = (
|
||
('延迟反转' in reason)
|
||
or ('止损' in reason)
|
||
or ('开long' in action)
|
||
or ('开short' in action)
|
||
or ('加long' in action)
|
||
or ('加short' in action)
|
||
)
|
||
if '止损' in reason:
|
||
marker['short_reason'] = '止损'
|
||
elif '延迟反转' in reason:
|
||
marker['short_reason'] = '延迟反转'
|
||
elif '触中轨平50%' in reason:
|
||
marker['short_reason'] = '中轨平半'
|
||
elif '回开仓价全平' in reason:
|
||
marker['short_reason'] = '回本全平'
|
||
elif '触上轨开空' in reason:
|
||
marker['short_reason'] = '上轨开空'
|
||
elif '触下轨开多' in reason:
|
||
marker['short_reason'] = '下轨开多'
|
||
elif '触上轨加空' in reason:
|
||
marker['short_reason'] = '上轨加空'
|
||
elif '触下轨加多' in reason:
|
||
marker['short_reason'] = '下轨加多'
|
||
else:
|
||
marker['short_reason'] = reason
|
||
|
||
# 分类标记
|
||
if '开long' in action or '加long' in action:
|
||
marker['type'] = 'open_long'
|
||
marker['color'] = '#00ff00'
|
||
marker['symbol'] = 'triangle'
|
||
elif '开short' in action or '加short' in action:
|
||
marker['type'] = 'open_short'
|
||
marker['color'] = '#ff0000'
|
||
marker['symbol'] = 'triangle'
|
||
elif '平仓' in action:
|
||
if '50%' in action:
|
||
marker['type'] = 'close_half'
|
||
marker['color'] = '#ffff00'
|
||
marker['symbol'] = 'diamond'
|
||
else:
|
||
marker['type'] = 'close_all'
|
||
marker['color'] = '#ff00ff'
|
||
marker['symbol'] = 'circle'
|
||
else:
|
||
marker['type'] = 'other'
|
||
marker['color'] = '#ffffff'
|
||
marker['symbol'] = 'circle'
|
||
|
||
trades_markers.append(marker)
|
||
|
||
return chart_data, trades_markers
|
||
|
||
|
||
def generate_html(chart_data, trades_markers, output_file):
|
||
"""生成HTML文件"""
|
||
|
||
html_content = f"""<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<title>布林带策略回测可视化 - 2026年3月</title>
|
||
<style>
|
||
html,
|
||
body {{
|
||
margin: 0;
|
||
padding: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: #0a0e27;
|
||
color: #e0e6ed;
|
||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||
}}
|
||
#chart {{
|
||
width: 100%;
|
||
height: 100%;
|
||
}}
|
||
.legend {{
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
background: rgba(15, 23, 42, 0.95);
|
||
padding: 20px;
|
||
border-radius: 12px;
|
||
border: 1px solid #334155;
|
||
font-size: 13px;
|
||
line-height: 1.8;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||
backdrop-filter: blur(10px);
|
||
z-index: 1000;
|
||
}}
|
||
.legend-title {{
|
||
font-weight: 600;
|
||
margin-bottom: 12px;
|
||
color: #f1f5f9;
|
||
font-size: 14px;
|
||
letter-spacing: 0.5px;
|
||
}}
|
||
.legend-item {{
|
||
display: flex;
|
||
align-items: center;
|
||
margin: 8px 0;
|
||
}}
|
||
.legend-marker {{
|
||
width: 14px;
|
||
height: 14px;
|
||
margin-right: 10px;
|
||
border-radius: 2px;
|
||
}}
|
||
</style>
|
||
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||
</head>
|
||
<body>
|
||
<div id="chart"></div>
|
||
<div class="legend">
|
||
<div class="legend-title">📊 交易标记说明</div>
|
||
<div class="legend-item">
|
||
<div class="legend-marker" style="background: #00ff00;">▲</div>
|
||
<span>开多/加多</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-marker" style="background: #ff0000;">▼</div>
|
||
<span>开空/加空</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-marker" style="background: #ffff00;">◆</div>
|
||
<span>平仓50%</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-marker" style="background: #ff00ff;">●</div>
|
||
<span>平仓100%</span>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
const chartData = {json.dumps(chart_data, ensure_ascii=False)};
|
||
const tradesMarkers = {json.dumps(trades_markers, ensure_ascii=False)};
|
||
|
||
function main() {{
|
||
const categoryData = [];
|
||
const klineData = [];
|
||
const upper = [];
|
||
const mid = [];
|
||
const lower = [];
|
||
|
||
for (const k of chartData) {{
|
||
const d = new Date(k.timestamp);
|
||
const label = `${{d.getMonth()+1}}/${{d.getDate()}} ${{d.getHours().toString().padStart(2, "0")}}:${{d
|
||
.getMinutes()
|
||
.toString()
|
||
.padStart(2, "0")}}`;
|
||
categoryData.push(label);
|
||
klineData.push([k.open, k.close, k.low, k.high]);
|
||
upper.push(k.bb_upper);
|
||
mid.push(k.bb_mid);
|
||
lower.push(k.bb_lower);
|
||
}}
|
||
|
||
// 准备交易标记数据
|
||
const openLongData = [];
|
||
const openShortData = [];
|
||
const closeHalfData = [];
|
||
const closeAllData = [];
|
||
|
||
for (const marker of tradesMarkers) {{
|
||
const point = {{
|
||
value: [marker.index, marker.price],
|
||
reason: marker.reason,
|
||
action: marker.action,
|
||
shortReason: marker.short_reason,
|
||
label: marker.show_reason_label
|
||
? {{
|
||
show: true,
|
||
formatter: marker.short_reason,
|
||
color: "#f8fafc",
|
||
backgroundColor: "rgba(15,23,42,0.85)",
|
||
borderColor: "#475569",
|
||
borderWidth: 1,
|
||
borderRadius: 4,
|
||
padding: [2, 4],
|
||
fontSize: 10,
|
||
}}
|
||
: {{ show: false }},
|
||
itemStyle: {{ color: marker.color }},
|
||
}};
|
||
|
||
if (marker.type === 'open_long') {{
|
||
openLongData.push(point);
|
||
}} else if (marker.type === 'open_short') {{
|
||
openShortData.push(point);
|
||
}} else if (marker.type === 'close_half') {{
|
||
closeHalfData.push(point);
|
||
}} else if (marker.type === 'close_all') {{
|
||
closeAllData.push(point);
|
||
}}
|
||
}}
|
||
|
||
const chartDom = document.getElementById("chart");
|
||
const chart = echarts.init(chartDom, null, {{ renderer: "canvas" }});
|
||
|
||
const option = {{
|
||
backgroundColor: "#0a0e27",
|
||
tooltip: {{
|
||
trigger: "axis",
|
||
axisPointer: {{ type: "cross" }},
|
||
backgroundColor: "rgba(15, 23, 42, 0.95)",
|
||
borderColor: "#334155",
|
||
textStyle: {{ color: "#e0e6ed" }},
|
||
}},
|
||
axisPointer: {{
|
||
link: [{{ xAxisIndex: "all" }}],
|
||
}},
|
||
grid: {{
|
||
left: "3%",
|
||
right: "200px",
|
||
top: "6%",
|
||
bottom: "8%",
|
||
containLabel: true,
|
||
}},
|
||
xAxis: {{
|
||
type: "category",
|
||
data: categoryData,
|
||
scale: true,
|
||
boundaryGap: true,
|
||
axisLine: {{ lineStyle: {{ color: "#475569" }} }},
|
||
axisLabel: {{
|
||
color: "#94a3b8",
|
||
rotate: 45,
|
||
fontSize: 11
|
||
}},
|
||
}},
|
||
yAxis: {{
|
||
scale: true,
|
||
axisLine: {{ lineStyle: {{ color: "#475569" }} }},
|
||
splitLine: {{ lineStyle: {{ color: "#1e293b" }} }},
|
||
axisLabel: {{ color: "#94a3b8" }},
|
||
}},
|
||
dataZoom: [
|
||
{{
|
||
type: "inside",
|
||
start: 0,
|
||
end: 100,
|
||
}},
|
||
{{
|
||
type: "slider",
|
||
start: 0,
|
||
end: 100,
|
||
height: 30,
|
||
backgroundColor: "#1e293b",
|
||
fillerColor: "rgba(100, 116, 139, 0.3)",
|
||
borderColor: "#334155",
|
||
handleStyle: {{
|
||
color: "#64748b",
|
||
borderColor: "#94a3b8"
|
||
}},
|
||
textStyle: {{ color: "#94a3b8" }},
|
||
}},
|
||
],
|
||
series: [
|
||
{{
|
||
name: "K线",
|
||
type: "candlestick",
|
||
data: klineData,
|
||
itemStyle: {{
|
||
color: "#10b981",
|
||
color0: "#ef4444",
|
||
borderColor: "#10b981",
|
||
borderColor0: "#ef4444",
|
||
}},
|
||
}},
|
||
{{
|
||
name: "BB上轨",
|
||
type: "line",
|
||
data: upper,
|
||
symbol: "none",
|
||
lineStyle: {{ color: "#f59e0b", width: 2 }},
|
||
z: 1,
|
||
}},
|
||
{{
|
||
name: "BB中轨",
|
||
type: "line",
|
||
data: mid,
|
||
symbol: "none",
|
||
lineStyle: {{ color: "#8b5cf6", width: 2, type: "dashed" }},
|
||
z: 1,
|
||
}},
|
||
{{
|
||
name: "BB下轨",
|
||
type: "line",
|
||
data: lower,
|
||
symbol: "none",
|
||
lineStyle: {{ color: "#f59e0b", width: 2 }},
|
||
z: 1,
|
||
}},
|
||
{{
|
||
name: "开多/加多",
|
||
type: "scatter",
|
||
symbol: "triangle",
|
||
symbolSize: 12,
|
||
symbolOffset: [0, 8], // 向下偏移,使三角形底部对齐价格
|
||
data: openLongData,
|
||
itemStyle: {{ color: "#00ff00" }},
|
||
z: 10,
|
||
tooltip: {{
|
||
formatter: (params) => {{
|
||
const marker = params.data;
|
||
return `开多/加多<br/>价格: ${{marker.value[1].toFixed(2)}}<br/>原因: ${{marker.reason}}`;
|
||
}}
|
||
}}
|
||
}},
|
||
{{
|
||
name: "开空/加空",
|
||
type: "scatter",
|
||
symbol: "triangle",
|
||
symbolSize: 12,
|
||
symbolRotate: 180,
|
||
symbolOffset: [0, -8], // 向上偏移,使三角形底部对齐价格
|
||
data: openShortData,
|
||
itemStyle: {{ color: "#ff0000" }},
|
||
z: 10,
|
||
tooltip: {{
|
||
formatter: (params) => {{
|
||
const marker = params.data;
|
||
return `开空/加空<br/>价格: ${{marker.value[1].toFixed(2)}}<br/>原因: ${{marker.reason}}`;
|
||
}}
|
||
}}
|
||
}},
|
||
{{
|
||
name: "平仓50%",
|
||
type: "scatter",
|
||
symbol: "diamond",
|
||
symbolSize: 10,
|
||
data: closeHalfData,
|
||
itemStyle: {{ color: "#ffff00" }},
|
||
z: 10,
|
||
tooltip: {{
|
||
formatter: (params) => {{
|
||
const marker = params.data;
|
||
return `平仓50%<br/>价格: ${{marker.value[1].toFixed(2)}}<br/>原因: ${{marker.reason}}`;
|
||
}}
|
||
}}
|
||
}},
|
||
{{
|
||
name: "平仓100%",
|
||
type: "scatter",
|
||
symbol: "circle",
|
||
symbolSize: 10,
|
||
data: closeAllData,
|
||
itemStyle: {{ color: "#ff00ff" }},
|
||
z: 10,
|
||
tooltip: {{
|
||
formatter: (params) => {{
|
||
const marker = params.data;
|
||
return `平仓100%<br/>价格: ${{marker.value[1].toFixed(2)}}<br/>原因: ${{marker.reason}}`;
|
||
}}
|
||
}}
|
||
}},
|
||
],
|
||
}};
|
||
|
||
chart.setOption(option);
|
||
window.addEventListener("resize", () => chart.resize());
|
||
}}
|
||
|
||
main();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
with open(output_file, 'w', encoding='utf-8') as f:
|
||
f.write(html_content)
|
||
|
||
print(f"✅ 图表已生成: {output_file}")
|
||
|
||
|
||
if __name__ == '__main__':
|
||
db.connect(reuse_if_open=True)
|
||
|
||
try:
|
||
print("正在生成回测可视化图表...")
|
||
|
||
output_dir = Path(__file__).parent / 'backtest_outputs' / 'charts'
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 生成图表数据
|
||
chart_data, trades_markers = generate_chart_data(
|
||
start_date='2026-03-01',
|
||
end_date='2026-03-03',
|
||
trades_file=str(Path(__file__).parent / 'backtest_outputs' / 'trades' / 'bb_backtest_march_2026_trades.csv')
|
||
)
|
||
|
||
print(f"📊 K线数据: {len(chart_data)} 根")
|
||
print(f"📍 交易标记: {len(trades_markers)} 个")
|
||
|
||
# 生成HTML
|
||
output_file = output_dir / 'bb_backtest_visualization.html'
|
||
generate_html(chart_data, trades_markers, str(output_file))
|
||
|
||
print(f"\n🎉 完成!请在浏览器中打开 {output_file} 查看图表")
|
||
|
||
finally:
|
||
db.close()
|