- 创建 streamlit_app.py: 交互式回测结果展示 - 支持4个标签页:策略概览、收益分析、调仓记录、品种详情 - 集成 Plotly 图表:收益对比、胜率散点图、月度收益、收益分布 - 支持数据筛选和导出功能 - 添加启动脚本和依赖文件
367 lines
12 KiB
Python
367 lines
12 KiB
Python
"""
|
|
ETF轮动策略回测结果可视化
|
|
============================
|
|
使用 Streamlit 展示策略回测结果
|
|
|
|
运行: streamlit run streamlit_app.py
|
|
"""
|
|
|
|
import streamlit as st
|
|
import pandas as pd
|
|
import plotly.express as px
|
|
import plotly.graph_objects as go
|
|
from plotly.subplots import make_subplots
|
|
import requests
|
|
from datetime import datetime
|
|
import os
|
|
|
|
# 页面配置
|
|
st.set_page_config(
|
|
page_title="ETF轮动策略回测结果",
|
|
page_icon="📈",
|
|
layout="wide",
|
|
initial_sidebar_state="expanded"
|
|
)
|
|
|
|
# 样式
|
|
st.markdown("""
|
|
<style>
|
|
.main-header {
|
|
font-size: 2.5rem;
|
|
font-weight: bold;
|
|
color: #1f77b4;
|
|
}
|
|
.metric-card {
|
|
background-color: #f0f2f6;
|
|
border-radius: 10px;
|
|
padding: 20px;
|
|
margin: 10px 0;
|
|
}
|
|
.positive { color: #28a745; }
|
|
.negative { color: #dc3545; }
|
|
</style>
|
|
""", unsafe_allow_html=True)
|
|
|
|
# 标题
|
|
st.markdown('<p class="main-header">📈 ETF轮动策略回测结果</p>', unsafe_allow_html=True)
|
|
st.markdown("---")
|
|
|
|
# 侧边栏配置
|
|
st.sidebar.header("⚙️ 配置")
|
|
|
|
# 数据API配置
|
|
api_base = st.sidebar.text_input(
|
|
"数据API地址",
|
|
value=os.getenv('API_BASE_URL', 'https://k3s.tokenpluse.xyz'),
|
|
help="Flask API服务地址"
|
|
)
|
|
|
|
# 加载数据
|
|
@st.cache_data(ttl=3600)
|
|
def load_summary_data():
|
|
"""加载策略汇总数据"""
|
|
try:
|
|
df = pd.read_csv('results/report_summary.csv')
|
|
# 转换百分比字符串为数值
|
|
for col in ['胜率', '平均收益', '累计收益', '最大单次收益', '最大单次亏损']:
|
|
if col in df.columns:
|
|
df[col] = df[col].str.rstrip('%').astype(float)
|
|
return df
|
|
except Exception as e:
|
|
st.error(f"加载汇总数据失败: {e}")
|
|
return None
|
|
|
|
@st.cache_data(ttl=3600)
|
|
def load_trades_data():
|
|
"""加载交易记录数据"""
|
|
try:
|
|
df = pd.read_csv('results/report_trades.csv')
|
|
df['进场日期'] = pd.to_datetime(df['进场日期'])
|
|
df['出场日期'] = pd.to_datetime(df['出场日期'])
|
|
return df
|
|
except Exception as e:
|
|
st.error(f"加载交易数据失败: {e}")
|
|
return None
|
|
|
|
# 加载数据
|
|
summary_df = load_summary_data()
|
|
trades_df = load_trades_data()
|
|
|
|
# 主页面标签页
|
|
tab1, tab2, tab3, tab4 = st.tabs(["📊 策略概览", "📈 收益分析", "🔄 调仓记录", "🔍 品种详情"])
|
|
|
|
# ============ Tab 1: 策略概览 ============
|
|
with tab1:
|
|
st.header("策略绩效概览")
|
|
|
|
if summary_df is not None:
|
|
# 关键指标
|
|
col1, col2, col3, col4 = st.columns(4)
|
|
|
|
with col1:
|
|
total_trades = summary_df['调仓次数'].sum()
|
|
st.metric("总调仓次数", f"{total_trades:,}")
|
|
|
|
with col2:
|
|
avg_win_rate = summary_df['胜率'].mean()
|
|
st.metric("平均胜率", f"{avg_win_rate:.2f}%")
|
|
|
|
with col3:
|
|
best_return = summary_df['累计收益'].max()
|
|
best_code = summary_df.loc[summary_df['累计收益'].idxmax(), '品种代码']
|
|
st.metric("最佳品种收益", f"{best_return:.2f}%", f"{best_code}")
|
|
|
|
with col4:
|
|
worst_return = summary_df['累计收益'].min()
|
|
worst_code = summary_df.loc[summary_df['累计收益'].idxmin(), '品种代码']
|
|
st.metric("最差品种收益", f"{worst_return:.2f}%", f"{worst_code}")
|
|
|
|
st.markdown("---")
|
|
|
|
# 品种收益对比
|
|
fig = px.bar(
|
|
summary_df.sort_values('累计收益', ascending=True),
|
|
x='累计收益',
|
|
y='品种名称',
|
|
orientation='h',
|
|
color='累计收益',
|
|
color_continuous_scale=['red', 'yellow', 'green'],
|
|
title="各品种累计收益对比",
|
|
labels={'累计收益': '累计收益 (%)', '品种名称': ''}
|
|
)
|
|
fig.update_layout(height=400)
|
|
st.plotly_chart(fig, use_container_width=True)
|
|
|
|
# 胜率 vs 平均收益散点图
|
|
fig2 = px.scatter(
|
|
summary_df,
|
|
x='胜率',
|
|
y='平均收益',
|
|
size='调仓次数',
|
|
color='品种名称',
|
|
hover_data=['品种代码', '累计收益'],
|
|
title="胜率 vs 平均收益(气泡大小=调仓次数)",
|
|
labels={'胜率': '胜率 (%)', '平均收益': '平均收益 (%)'}
|
|
)
|
|
st.plotly_chart(fig2, use_container_width=True)
|
|
|
|
# 数据表格
|
|
st.subheader("详细数据")
|
|
st.dataframe(
|
|
summary_df.style.format({
|
|
'胜率': '{:.2f}%',
|
|
'平均收益': '{:.2f}%',
|
|
'累计收益': '{:.2f}%',
|
|
'最大单次收益': '{:.2f}%',
|
|
'最大单次亏损': '{:.2f}%'
|
|
}),
|
|
use_container_width=True
|
|
)
|
|
else:
|
|
st.warning("暂无汇总数据")
|
|
|
|
# ============ Tab 2: 收益分析 ============
|
|
with tab2:
|
|
st.header("收益分析")
|
|
|
|
if trades_df is not None:
|
|
# 按月份统计收益
|
|
trades_df['年月'] = trades_df['出场日期'].dt.to_period('M')
|
|
monthly_returns = trades_df.groupby('年月').agg({
|
|
'持仓收益': 'sum',
|
|
'品种代码': 'count'
|
|
}).rename(columns={'品种代码': '交易次数'})
|
|
monthly_returns.index = monthly_returns.index.astype(str)
|
|
|
|
# 月度收益柱状图
|
|
fig = px.bar(
|
|
monthly_returns.reset_index(),
|
|
x='年月',
|
|
y='持仓收益',
|
|
title="月度累计收益",
|
|
labels={'持仓收益': '收益 (%)', '年月': '月份'},
|
|
color='持仓收益',
|
|
color_continuous_scale=['red', 'yellow', 'green']
|
|
)
|
|
st.plotly_chart(fig, use_container_width=True)
|
|
|
|
# 收益分布直方图
|
|
fig2 = px.histogram(
|
|
trades_df,
|
|
x='持仓收益',
|
|
nbins=50,
|
|
title="单次交易收益分布",
|
|
labels={'持仓收益': '收益 (%)', 'count': '次数'}
|
|
)
|
|
fig2.add_vline(x=0, line_dash="dash", line_color="red")
|
|
st.plotly_chart(fig2, use_container_width=True)
|
|
|
|
# 累计收益曲线
|
|
trades_sorted = trades_df.sort_values('出场日期')
|
|
trades_sorted['累计收益_曲线'] = trades_sorted['持仓收益'].cumsum()
|
|
|
|
fig3 = px.line(
|
|
trades_sorted,
|
|
x='出场日期',
|
|
y='累计收益_曲线',
|
|
title="策略累计收益曲线",
|
|
labels={'累计收益_曲线': '累计收益 (%)', '出场日期': '日期'}
|
|
)
|
|
st.plotly_chart(fig3, use_container_width=True)
|
|
else:
|
|
st.warning("暂无交易数据")
|
|
|
|
# ============ Tab 3: 调仓记录 ============
|
|
with tab3:
|
|
st.header("调仓记录")
|
|
|
|
if trades_df is not None:
|
|
# 筛选器
|
|
col1, col2, col3 = st.columns(3)
|
|
|
|
with col1:
|
|
selected_codes = st.multiselect(
|
|
"选择品种",
|
|
options=trades_df['品种代码'].unique(),
|
|
default=[]
|
|
)
|
|
|
|
with col2:
|
|
min_return = st.slider(
|
|
"最小收益",
|
|
min_value=float(trades_df['持仓收益'].min()),
|
|
max_value=float(trades_df['持仓收益'].max()),
|
|
value=float(trades_df['持仓收益'].min())
|
|
)
|
|
|
|
with col3:
|
|
min_days = st.slider(
|
|
"最小持仓天数",
|
|
min_value=1,
|
|
max_value=int(trades_df['持仓天数'].max()),
|
|
value=1
|
|
)
|
|
|
|
# 过滤数据
|
|
filtered_df = trades_df.copy()
|
|
if selected_codes:
|
|
filtered_df = filtered_df[filtered_df['品种代码'].isin(selected_codes)]
|
|
filtered_df = filtered_df[
|
|
(filtered_df['持仓收益'] >= min_return) &
|
|
(filtered_df['持仓天数'] >= min_days)
|
|
]
|
|
|
|
# 显示表格
|
|
st.subheader(f"筛选结果 ({len(filtered_df)} 条记录)")
|
|
|
|
# 添加颜色标记
|
|
def color_return(val):
|
|
color = 'green' if val > 0 else 'red'
|
|
return f'color: {color}'
|
|
|
|
styled_df = filtered_df.style.applymap(
|
|
color_return,
|
|
subset=['持仓收益']
|
|
).format({
|
|
'进场价格': '{:.2f}',
|
|
'出场价格': '{:.2f}',
|
|
'持仓收益': '{:.2f}%',
|
|
'进场净值': '{:.4f}',
|
|
'出场净值': '{:.4f}',
|
|
'净值贡献': '{:.4f}'
|
|
})
|
|
|
|
st.dataframe(styled_df, use_container_width=True, height=500)
|
|
|
|
# 导出按钮
|
|
csv = filtered_df.to_csv(index=False).encode('utf-8')
|
|
st.download_button(
|
|
label="📥 导出筛选结果",
|
|
data=csv,
|
|
file_name=f"trades_filtered_{datetime.now().strftime('%Y%m%d')}.csv",
|
|
mime="text/csv"
|
|
)
|
|
else:
|
|
st.warning("暂无调仓记录")
|
|
|
|
# ============ Tab 4: 品种详情 ============
|
|
with tab4:
|
|
st.header("品种详情分析")
|
|
|
|
if summary_df is not None and trades_df is not None:
|
|
selected_code = st.selectbox(
|
|
"选择品种",
|
|
options=summary_df['品种代码'].tolist(),
|
|
format_func=lambda x: f"{x} - {summary_df[summary_df['品种代码']==x]['品种名称'].iloc[0]}"
|
|
)
|
|
|
|
if selected_code:
|
|
# 品种基本信息
|
|
code_info = summary_df[summary_df['品种代码'] == selected_code].iloc[0]
|
|
|
|
col1, col2, col3, col4 = st.columns(4)
|
|
with col1:
|
|
st.metric("调仓次数", int(code_info['调仓次数']))
|
|
with col2:
|
|
st.metric("胜率", f"{code_info['胜率']:.2f}%")
|
|
with col3:
|
|
st.metric("累计收益", f"{code_info['累计收益']:.2f}%")
|
|
with col4:
|
|
st.metric("平均持仓天数", f"{code_info['平均持仓天数']:.1f}天")
|
|
|
|
# 该品种的交易记录
|
|
code_trades = trades_df[trades_df['品种代码'] == selected_code].sort_values('出场日期')
|
|
|
|
if len(code_trades) > 0:
|
|
# 收益时间序列
|
|
code_trades['累计收益_品种'] = code_trades['持仓收益'].cumsum()
|
|
|
|
fig = make_subplots(
|
|
rows=2, cols=1,
|
|
subplot_titles=('单次收益', '累计收益'),
|
|
vertical_spacing=0.1
|
|
)
|
|
|
|
fig.add_trace(
|
|
go.Bar(
|
|
x=code_trades['出场日期'],
|
|
y=code_trades['持仓收益'],
|
|
name='单次收益',
|
|
marker_color=['green' if x > 0 else 'red' for x in code_trades['持仓收益']]
|
|
),
|
|
row=1, col=1
|
|
)
|
|
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=code_trades['出场日期'],
|
|
y=code_trades['累计收益_品种'],
|
|
name='累计收益',
|
|
mode='lines+markers'
|
|
),
|
|
row=2, col=1
|
|
)
|
|
|
|
fig.update_layout(height=600, showlegend=False)
|
|
st.plotly_chart(fig, use_container_width=True)
|
|
|
|
# 持仓天数分布
|
|
fig2 = px.box(
|
|
code_trades,
|
|
y='持仓天数',
|
|
title=f"{code_info['品种名称']} 持仓天数分布"
|
|
)
|
|
st.plotly_chart(fig2, use_container_width=True)
|
|
else:
|
|
st.info("该品种暂无交易记录")
|
|
else:
|
|
st.warning("暂无数据")
|
|
|
|
# 底部信息
|
|
st.markdown("---")
|
|
st.markdown("""
|
|
<div style="text-align: center; color: #666;">
|
|
<p>ETF轮动策略回测系统 | 数据更新时间: {}</p>
|
|
</div>
|
|
""".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S')), unsafe_allow_html=True)
|