jscmp4 commited on
Commit
07643f8
·
verified ·
1 Parent(s): 6c35bd4

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +132 -89
src/streamlit_app.py CHANGED
@@ -3,80 +3,103 @@ import yfinance as yf
3
  import pandas as pd
4
  import numpy as np
5
  import plotly.express as px
 
6
 
7
  # --- 1. 页面配置 ---
8
  st.set_page_config(page_title="Permanent Portfolio Backtester", layout="wide")
9
  st.title("🧩 永久组合 (Permanent Portfolio) 阈值策略回测")
10
 
11
- # --- 2. 数据获取与缓存 ---
12
- # SHV (短债) 是2007年才有的,所以回测通常从2007年开始
13
- @st.cache_data
14
- def get_data():
15
- tickers = ['SPY', 'TLT', 'GLD', 'SHV']
16
- # 【修复点】:添加 auto_adjust=False 以保留 'Adj Close' 列
17
- data = yf.download(tickers, start="2007-01-01", auto_adjust=False)['Adj Close']
18
- data = data.dropna() # 确保所有ETF都有数据的日期才开始
 
 
 
 
 
 
19
  return data
20
 
21
- try:
22
- with st.spinner('正在从 Yahoo Finance 获取数据...'):
23
- df = get_data()
24
- st.success(f"数据获取成功! 范围: {df.index[0].date()} 到 {df.index[-1].date()}")
25
- except Exception as e:
26
- st.error(f"数据获取失败: {e}")
27
- st.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  # --- 3. 回测逻辑核心函数 ---
30
  def run_backtest(data, threshold=0.05, check_freq='Daily'):
31
  """
32
  threshold: 偏离阈值 (例如 0.05 代表 ±5%)
33
- check_freq: 检测频率 ('Daily', 'Monthly', 'Quarterly', 'Annually')
34
  """
35
-
36
- # 初始资金
37
  initial_cash = 10000.0
38
-
39
- # 资产列表
40
  assets = ['SPY', 'TLT', 'GLD', 'SHV']
41
-
42
- # 初始化持仓 (股数)
43
  holdings = {asset: 0.0 for asset in assets}
44
 
45
- # 初始化记录
46
  portfolio_history = []
47
  rebalance_log = []
48
 
49
- # 初始建仓:Day 1 强制 4等分
50
  first_prices = data.iloc[0]
51
  for asset in assets:
52
  holdings[asset] = (initial_cash * 0.25) / first_prices[asset]
53
 
54
- # 定义检测频率的逻辑
55
- # 如果是 Daily,每天都检测;如果是 Monthly,只有月底检测,以此类推
56
  is_rebalance_day = pd.Series(False, index=data.index)
57
  if check_freq == 'Daily':
58
  is_rebalance_day[:] = True
59
  elif check_freq == 'Monthly':
60
- # Pandas 2.x 建议使用 'ME' (Month End) 代替 'M'
61
  resampled_dates = data.resample('ME').last().index
62
- # 找到最接近的交易日
63
  search_indexer = data.index.searchsorted(resampled_dates)
64
  search_indexer = search_indexer[search_indexer < len(data)]
65
  valid_dates = data.index[search_indexer]
66
  is_rebalance_day = pd.Series(data.index.isin(valid_dates), index=data.index)
67
 
68
- # --- 每日循环 ---
69
  dates = data.index
70
- prices_values = data.values # Numpy array 更快
71
-
72
- # 映射 column index
73
  asset_idx = {asset: i for i, asset in enumerate(data.columns)}
74
 
75
  for i in range(len(dates)):
76
  current_date = dates[i]
77
  current_prices = prices_values[i]
78
 
79
- # 1. 计算当前市值
80
  current_vals = {}
81
  total_value = 0.0
82
  for asset in assets:
@@ -86,15 +109,12 @@ def run_backtest(data, threshold=0.05, check_freq='Daily'):
86
 
87
  portfolio_history.append({'Date': current_date, 'Total Value': total_value})
88
 
89
- # 2. 检查是否需要检测
90
  if check_freq != 'Daily' and not is_rebalance_day[i]:
91
  continue
92
 
93
- # 3. 计算权重并检查阈值
94
  needs_rebalance = False
95
-
96
- # 绝对阈值逻辑 (Absolute Band)
97
- # 比如 25% ± 5% -> [20%, 30%]
98
  lower_bound = 0.25 - threshold
99
  upper_bound = 0.25 + threshold
100
 
@@ -119,77 +139,100 @@ def run_backtest(data, threshold=0.05, check_freq='Daily'):
119
 
120
  return pd.DataFrame(portfolio_history).set_index('Date'), pd.DataFrame(rebalance_log)
121
 
122
- # --- 4. 侧边栏控制面板 ---
123
- st.sidebar.header("⚙️ 回测参数设置")
 
124
 
125
- # 用户选择阈值
126
- selected_thresholds = st.sidebar.multiselect(
127
- "选择要对比的阈值 (Bands)",
128
- options=[0.0, 0.05, 0.10, 0.15, 0.20, 0.25],
129
- default=[0.05, 0.10, 0.15],
130
- format_func=lambda x: f"±{int(x*100)}% ({25-int(x*100)}%-{25+int(x*100)}%)"
131
- )
132
 
133
- st.sidebar.info("注意:阈值策略通常配合 '每日检' 使用以捕捉瞬间波动。")
134
 
135
  # --- 5. 执行对比 ---
136
- if st.button("开始枚举回测 (Run Benchmark)"):
 
 
 
 
 
 
 
137
 
138
  results_summary = []
139
- all_equity_curves = pd.DataFrame()
140
 
141
- for thresh in selected_thresholds:
142
- # 运行回测
 
 
 
 
 
 
143
  equity_df, log_df = run_backtest(df, threshold=thresh, check_freq='Daily')
144
 
145
- # 计算指标
146
  start_val = equity_df['Total Value'].iloc[0]
147
  end_val = equity_df['Total Value'].iloc[-1]
148
  years = (equity_df.index[-1] - equity_df.index[0]).days / 365.25
149
  cagr = (end_val / start_val) ** (1/years) - 1
150
 
151
- # 计算最大回撤
152
  roll_max = equity_df['Total Value'].cummax()
153
  drawdown = (equity_df['Total Value'] - roll_max) / roll_max
154
  max_dd = drawdown.min()
155
 
156
- # 交易次数
157
- num_trades = len(log_df)
158
-
159
- name = f"Threshold ±{int(thresh*100)}%"
160
-
161
- # 存结果
162
  results_summary.append({
163
- "策略名称": name,
164
- "总收益率": (end_val/start_val) - 1,
165
- "年化收益 (CAGR)": cagr,
166
- "最大回撤": max_dd,
167
- "再平衡次数": num_trades,
168
- "最终资金": end_val
169
  })
170
 
171
- all_equity_curves[name] = equity_df['Total Value']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
- # --- 6. 展示结果 ---
174
- st.subheader("📊 资金曲线对比")
175
- st.line_chart(all_equity_curves)
176
-
177
- st.subheader("🏆 绩效统计表")
178
- summary_df = pd.DataFrame(results_summary)
179
-
180
- # 格式化表格显示
181
- st.dataframe(summary_df.style.format({
182
- "总收益率": "{:.2%}",
183
- "年化收益 (CAGR)": "{:.2%}",
184
- "最大回撤": "{:.2%}",
185
- "最终资金": "${:,.2f}"
186
- }))
187
-
188
- st.markdown("""
189
- **解读指南:**
190
- * **再平衡次数**: 如果次数太少(比如0次),说明阈值设得太宽了。如果次数太多(比如几百次),交易成本(滑点、佣金、税)会吃掉利润。
191
- * **最大回撤**: 观察哪个阈值在极端行情(如2008, 2020)下保护得最好。
192
- """)
193
-
194
  else:
195
- st.info("👈 请在左侧选择参数并点击 '开始回测'")
 
3
  import pandas as pd
4
  import numpy as np
5
  import plotly.express as px
6
+ import os
7
 
8
  # --- 1. 页面配置 ---
9
  st.set_page_config(page_title="Permanent Portfolio Backtester", layout="wide")
10
  st.title("🧩 永久组合 (Permanent Portfolio) 阈值策略回测")
11
 
12
+ # 定义本地文件名
13
+ DATA_FILE = 'market_data.csv'
14
+ TICKERS = ['SPY', 'TLT', 'GLD', 'SHV']
15
+
16
+ # --- 2. 数据管理模块 (核心修改) ---
17
+
18
+ def download_from_yahoo():
19
+ """从 Yahoo 下载数据并保存为 CSV"""
20
+ with st.spinner('正在连接 Yahoo Finance 下载数据...'):
21
+ # 【关键】auto_adjust=False 确保获取 'Adj Close'
22
+ data = yf.download(TICKERS, start="2007-01-01", auto_adjust=False)['Adj Close']
23
+ data = data.dropna()
24
+ # 保存到本地 CSV
25
+ data.to_csv(DATA_FILE)
26
  return data
27
 
28
+ def load_data():
29
+ """主数据加载逻辑"""
30
+ # 1. 检查侧边栏是否点击了强制刷新
31
+ if st.sidebar.button("🔄 强制重新下载数据 (Force Update)"):
32
+ df = download_from_yahoo()
33
+ st.sidebar.success("数据已更新!")
34
+ return df
35
+
36
+ # 2. 检查本地是否存在文件
37
+ if os.path.exists(DATA_FILE):
38
+ # 从 CSV 读取,注意解析日期索引
39
+ try:
40
+ df = pd.read_csv(DATA_FILE, index_col=0, parse_dates=True)
41
+ st.success(f"✅ 已加载本地缓存数据 | 范围: {df.index[0].date()} 到 {df.index[-1].date()}")
42
+ return df
43
+ except Exception as e:
44
+ st.error(f"本地文件读取出错: {e},请尝试重新下载。")
45
+ return None
46
+ else:
47
+ # 3. 如果没有文件,返回 None,交由 UI 处理
48
+ return None
49
+
50
+ # 执行加载
51
+ df = load_data()
52
+
53
+ # 如果没有数据,显示下载按钮并停止后续运行
54
+ if df is None:
55
+ st.warning("⚠️ 本地未发现数据文件 (market_data.csv)。")
56
+ st.info("首次运行请点击下方按钮获取数据:")
57
+
58
+ if st.button("📥 立即下载并保存数据"):
59
+ df = download_from_yahoo()
60
+ st.rerun() # 重新运行脚本以加载新数据
61
+ else:
62
+ st.stop() # 停止执行下面的代码,直到有数据为止
63
 
64
  # --- 3. 回测逻辑核心函数 ---
65
  def run_backtest(data, threshold=0.05, check_freq='Daily'):
66
  """
67
  threshold: 偏离阈值 (例如 0.05 代表 ±5%)
68
+ check_freq: 检测频率 ('Daily', 'Monthly')
69
  """
 
 
70
  initial_cash = 10000.0
 
 
71
  assets = ['SPY', 'TLT', 'GLD', 'SHV']
 
 
72
  holdings = {asset: 0.0 for asset in assets}
73
 
 
74
  portfolio_history = []
75
  rebalance_log = []
76
 
77
+ # 初始建仓
78
  first_prices = data.iloc[0]
79
  for asset in assets:
80
  holdings[asset] = (initial_cash * 0.25) / first_prices[asset]
81
 
82
+ # 定义检测频率
 
83
  is_rebalance_day = pd.Series(False, index=data.index)
84
  if check_freq == 'Daily':
85
  is_rebalance_day[:] = True
86
  elif check_freq == 'Monthly':
 
87
  resampled_dates = data.resample('ME').last().index
 
88
  search_indexer = data.index.searchsorted(resampled_dates)
89
  search_indexer = search_indexer[search_indexer < len(data)]
90
  valid_dates = data.index[search_indexer]
91
  is_rebalance_day = pd.Series(data.index.isin(valid_dates), index=data.index)
92
 
93
+ # 循环回测
94
  dates = data.index
95
+ prices_values = data.values
 
 
96
  asset_idx = {asset: i for i, asset in enumerate(data.columns)}
97
 
98
  for i in range(len(dates)):
99
  current_date = dates[i]
100
  current_prices = prices_values[i]
101
 
102
+ # 1. 计算市值
103
  current_vals = {}
104
  total_value = 0.0
105
  for asset in assets:
 
109
 
110
  portfolio_history.append({'Date': current_date, 'Total Value': total_value})
111
 
112
+ # 2. 检查频率
113
  if check_freq != 'Daily' and not is_rebalance_day[i]:
114
  continue
115
 
116
+ # 3. 检查阈值
117
  needs_rebalance = False
 
 
 
118
  lower_bound = 0.25 - threshold
119
  upper_bound = 0.25 + threshold
120
 
 
139
 
140
  return pd.DataFrame(portfolio_history).set_index('Date'), pd.DataFrame(rebalance_log)
141
 
142
+ # --- 4. 侧边栏控制面板 (参数扫描) ---
143
+ st.sidebar.markdown("---")
144
+ st.sidebar.header("⚙️ 参数扫描设置")
145
 
146
+ st.sidebar.markdown("### 设置阈值范围")
147
+ start_thresh = st.sidebar.number_input("起始阈值 (%)", min_value=0.0, max_value=50.0, value=1.0, step=1.0)
148
+ end_thresh = st.sidebar.number_input("结束阈值 (%)", min_value=0.0, max_value=50.0, value=30.0, step=1.0)
149
+ step_thresh = st.sidebar.number_input("步长 (%)", min_value=0.5, max_value=10.0, value=1.0, step=0.5)
 
 
 
150
 
151
+ st.sidebar.info(f"将要试范围: {start_thresh}% 到 {end_thresh}%步长 {step_thresh}%")
152
 
153
  # --- 5. 执行对比 ---
154
+ if st.button("🚀 开始参数扫描 (Run Parameter Sweep)"):
155
+
156
+ if start_thresh > end_thresh:
157
+ st.error("起始阈值不能大于结束阈值!")
158
+ st.stop()
159
+
160
+ thresholds = np.arange(start_thresh, end_thresh + step_thresh, step_thresh) / 100.0
161
+ thresholds = sorted(list(set(thresholds)))
162
 
163
  results_summary = []
 
164
 
165
+ progress_bar = st.progress(0)
166
+ status_text = st.empty()
167
+
168
+ for i, thresh in enumerate(thresholds):
169
+ progress = (i + 1) / len(thresholds)
170
+ progress_bar.progress(progress)
171
+ status_text.text(f"正在回测阈值: ±{thresh:.1%} ...")
172
+
173
  equity_df, log_df = run_backtest(df, threshold=thresh, check_freq='Daily')
174
 
 
175
  start_val = equity_df['Total Value'].iloc[0]
176
  end_val = equity_df['Total Value'].iloc[-1]
177
  years = (equity_df.index[-1] - equity_df.index[0]).days / 365.25
178
  cagr = (end_val / start_val) ** (1/years) - 1
179
 
 
180
  roll_max = equity_df['Total Value'].cummax()
181
  drawdown = (equity_df['Total Value'] - roll_max) / roll_max
182
  max_dd = drawdown.min()
183
 
 
 
 
 
 
 
184
  results_summary.append({
185
+ "Threshold (%)": round(thresh * 100, 2),
186
+ "CAGR": cagr,
187
+ "Total Return": (end_val/start_val) - 1,
188
+ "Max Drawdown": max_dd,
189
+ "Trades": len(log_df),
190
+ "Final Value": end_val
191
  })
192
 
193
+ status_text.text("回测完成!")
194
+ progress_bar.empty()
195
+
196
+ res_df = pd.DataFrame(results_summary)
197
+
198
+ # --- 6. 结果可视化 ---
199
+ st.markdown("---")
200
+ st.subheader("🔍 参数敏感性分析")
201
+
202
+ col1, col2 = st.columns(2)
203
+
204
+ with col1:
205
+ st.markdown("##### 1. 阈值 vs 年化收益率 (CAGR)")
206
+ fig_cagr = px.line(res_df, x="Threshold (%)", y="CAGR", markers=True,
207
+ title="不同阈值下的年化收益率",
208
+ hover_data=["Trades", "Max Drawdown"])
209
+ fig_cagr.update_layout(xaxis_title="阈值 (±%)", yaxis_title="年化收益率 (CAGR)")
210
+ st.plotly_chart(fig_cagr, use_container_width=True)
211
+
212
+ with col2:
213
+ st.markdown("##### 2. 阈值 vs 交易次数 (成本分析)")
214
+ fig_trades = px.bar(res_df, x="Threshold (%)", y="Trades",
215
+ title="不同阈值下的总交易次数")
216
+ fig_trades.update_layout(xaxis_title="阈值 (±%)", yaxis_title="总交易次数")
217
+ st.plotly_chart(fig_trades, use_container_width=True)
218
+
219
+ with col1:
220
+ st.markdown("##### 3. 阈值 vs 最大���撤 (风险分析)")
221
+ fig_dd = px.line(res_df, x="Threshold (%)", y="Max Drawdown", markers=True,
222
+ color_discrete_sequence=["red"],
223
+ title="不同阈值下的最大回撤")
224
+ fig_dd.update_layout(xaxis_title="阈值 (±%)", yaxis_title="最大回撤")
225
+ st.plotly_chart(fig_dd, use_container_width=True)
226
+
227
+ with col2:
228
+ st.markdown("##### 🏆 数据总表")
229
+ st.dataframe(res_df.style.format({
230
+ "Threshold (%)": "{:.1f}%",
231
+ "CAGR": "{:.2%}",
232
+ "Total Return": "{:.2%}",
233
+ "Max Drawdown": "{:.2%}",
234
+ "Final Value": "${:,.0f}"
235
+ }), height=300)
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  else:
238
+ st.info("👈 请在左侧设置范围并点击 '开始参数扫描'")