Files
codex_jxs_code/generate_backtest_chart.py
2026-03-04 16:48:24 +08:00

524 lines
17 KiB
Python
Raw 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.

"""
生成回测可视化图表
显示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):
"""计算布林带"""
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']
df['bb_mid'] = df['sma']
df['bb_lower'] = df['sma'] - std_dev * df['std']
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("正在生成回测可视化图表...")
# 生成图表数据
chart_data, trades_markers = generate_chart_data(
start_date='2026-03-01',
end_date='2026-03-03',
trades_file='bb_backtest_march_2026_trades.csv'
)
print(f"📊 K线数据: {len(chart_data)}")
print(f"📍 交易标记: {len(trades_markers)}")
# 生成HTML
generate_html(chart_data, trades_markers, 'bb_backtest_visualization.html')
print("\n🎉 完成!请在浏览器中打开 bb_backtest_visualization.html 查看图表")
finally:
db.close()