Files
etf/streamlit_app.py
aszerW d47a29f09e feat(viz): 添加 Streamlit 可视化应用
- 创建 streamlit_app.py: 交互式回测结果展示
- 支持4个标签页:策略概览、收益分析、调仓记录、品种详情
- 集成 Plotly 图表:收益对比、胜率散点图、月度收益、收益分布
- 支持数据筛选和导出功能
- 添加启动脚本和依赖文件
2026-05-08 20:56:38 +08:00

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)