diff --git a/requirements_streamlit.txt b/requirements_streamlit.txt new file mode 100644 index 0000000..d98edd8 --- /dev/null +++ b/requirements_streamlit.txt @@ -0,0 +1,5 @@ +# Streamlit 可视化应用依赖 +streamlit>=1.28.0 +plotly>=5.15.0 +pandas>=2.0.0 +requests>=2.31.0 diff --git a/start_streamlit.sh b/start_streamlit.sh new file mode 100755 index 0000000..7e96f18 --- /dev/null +++ b/start_streamlit.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Streamlit 应用启动脚本 + +cd "$(dirname "$0")" + +echo "🚀 启动 ETF轮动策略可视化应用..." +echo "" + +# 检查虚拟环境 +if [ -d "venv" ]; then + source venv/bin/activate +fi + +# 安装依赖(如果需要) +# pip install -r requirements_streamlit.txt + +# 设置环境变量 +export API_BASE_URL=${API_BASE_URL:-"https://k3s.tokenpluse.xyz"} + +echo "📊 正在启动 Streamlit 服务..." +echo "🌐 API地址: $API_BASE_URL" +echo "" + +streamlit run streamlit_app.py --server.port 8501 --server.address 0.0.0.0 diff --git a/streamlit_app.py b/streamlit_app.py new file mode 100644 index 0000000..45b0ad8 --- /dev/null +++ b/streamlit_app.py @@ -0,0 +1,366 @@ +""" +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(""" + +""", unsafe_allow_html=True) + +# 标题 +st.markdown('

📈 ETF轮动策略回测结果

', 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(""" +
+

ETF轮动策略回测系统 | 数据更新时间: {}

+
+""".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S')), unsafe_allow_html=True)