jscmp4 commited on
Commit
083d48f
·
verified ·
1 Parent(s): 1406002

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +202 -38
src/streamlit_app.py CHANGED
@@ -1,40 +1,204 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
4
  import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
1
  import streamlit as st
2
+ 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
+ # 下载数据,使用 Adj Close 以包含分红
17
+ data = yf.download(tickers, start="2007-01-01")['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
+ is_rebalance_day = data.reset_index().groupby(data.index.to_period('M'))['Date'].idxmax().isin(data.index)
61
+ # 上面这行稍复杂,简化处理:取重采样后的索引
62
+ resampled_dates = data.resample('ME').last().index
63
+ # 找到最接近的交易日
64
+ search_indexer = data.index.searchsorted(resampled_dates)
65
+ search_indexer = search_indexer[search_indexer < len(data)]
66
+ valid_dates = data.index[search_indexer]
67
+ is_rebalance_day = pd.Series(data.index.isin(valid_dates), index=data.index)
68
+ # (Quarterly, Annually 逻辑类似,此处为了demo简化,重点放在阈值逻辑)
69
+
70
+ # --- 每日循环 ---
71
+ # 为了性能,这里不使用iterrows,而是简单循环
72
+ # 注意:这里模拟的是 "Close 价格触发,Close 价格成交" (简化模型)
73
+
74
+ dates = data.index
75
+ prices_values = data.values # Numpy array 更快
76
+
77
+ # 映射 column index
78
+ asset_idx = {asset: i for i, asset in enumerate(data.columns)}
79
+
80
+ last_rebalance_val = initial_cash
81
+
82
+ for i in range(len(dates)):
83
+ current_date = dates[i]
84
+ current_prices = prices_values[i]
85
+
86
+ # 1. 计算当前市值
87
+ current_vals = {}
88
+ total_value = 0.0
89
+ for asset in assets:
90
+ val = holdings[asset] * current_prices[asset_idx[asset]]
91
+ current_vals[asset] = val
92
+ total_value += val
93
+
94
+ portfolio_history.append({'Date': current_date, 'Total Value': total_value})
95
+
96
+ # 2. 检查是否需要检测
97
+ # 如果不是检测日,直接跳过
98
+ if check_freq != 'Daily' and not is_rebalance_day[i]:
99
+ continue
100
+
101
+ # 3. 计算权重并检查阈值
102
+ needs_rebalance = False
103
+
104
+ # 绝对阈值逻辑 (Absolute Band)
105
+ # 比如 25% ± 5% -> [20%, 30%]
106
+ lower_bound = 0.25 - threshold
107
+ upper_bound = 0.25 + threshold
108
+
109
+ for asset in assets:
110
+ weight = current_vals[asset] / total_value
111
+ if weight < lower_bound or weight > upper_bound:
112
+ needs_rebalance = True
113
+ break
114
+
115
+ # 4. 执行再平衡
116
+ if needs_rebalance:
117
+ target_val = total_value * 0.25
118
+ for asset in assets:
119
+ price = current_prices[asset_idx[asset]]
120
+ holdings[asset] = target_val / price
121
+
122
+ rebalance_log.append({
123
+ 'Date': current_date,
124
+ 'Type': 'Rebalance',
125
+ 'Value': total_value
126
+ })
127
+
128
+ return pd.DataFrame(portfolio_history).set_index('Date'), pd.DataFrame(rebalance_log)
129
+
130
+ # --- 4. 侧边栏控制面板 ---
131
+ st.sidebar.header("⚙️ 回测参数设置")
132
+
133
+ # 用户选择阈值
134
+ selected_thresholds = st.sidebar.multiselect(
135
+ "选择要对比的阈值 (Bands)",
136
+ options=[0.0, 0.05, 0.10, 0.15, 0.20, 0.25],
137
+ default=[0.05, 0.10, 0.15],
138
+ format_func=lambda x: f"±{int(x*100)}% ({25-int(x*100)}%-{25+int(x*100)}%)"
139
+ )
140
+
141
+ # 频率选择 (简化版,强制 Daily Check 以配合阈值策略)
142
+ st.sidebar.info("注意:阈值策略通常配合 '每日检测' 使用,以捕捉瞬间波动。")
143
+
144
+ # --- 5. 执行对比 ---
145
+ if st.button("开始枚举回测 (Run Benchmark)"):
146
+
147
+ results_summary = []
148
+ all_equity_curves = pd.DataFrame()
149
+
150
+ for thresh in selected_thresholds:
151
+ # 运行回测
152
+ equity_df, log_df = run_backtest(df, threshold=thresh, check_freq='Daily')
153
+
154
+ # 计算指标
155
+ start_val = equity_df['Total Value'].iloc[0]
156
+ end_val = equity_df['Total Value'].iloc[-1]
157
+ years = (equity_df.index[-1] - equity_df.index[0]).days / 365.25
158
+ cagr = (end_val / start_val) ** (1/years) - 1
159
+
160
+ # 计算最大回撤
161
+ roll_max = equity_df['Total Value'].cummax()
162
+ drawdown = (equity_df['Total Value'] - roll_max) / roll_max
163
+ max_dd = drawdown.min()
164
+
165
+ # 交易次数
166
+ num_trades = len(log_df)
167
+
168
+ name = f"Threshold ±{int(thresh*100)}%"
169
+
170
+ # 存结果
171
+ results_summary.append({
172
+ "策略名称": name,
173
+ "总收益率": (end_val/start_val) - 1,
174
+ "年化收益 (CAGR)": cagr,
175
+ "最大回撤": max_dd,
176
+ "再平衡次数": num_trades,
177
+ "最终资金": end_val
178
+ })
179
+
180
+ all_equity_curves[name] = equity_df['Total Value']
181
 
182
+ # --- 6. 展示结果 ---
183
+ st.subheader("📊 资金曲线对比")
184
+ st.line_chart(all_equity_curves)
185
+
186
+ st.subheader("🏆 绩效统计表")
187
+ summary_df = pd.DataFrame(results_summary)
188
+
189
+ # 格式化表格显示
190
+ st.dataframe(summary_df.style.format({
191
+ "总收益率": "{:.2%}",
192
+ "年化收益 (CAGR)": "{:.2%}",
193
+ "最大回撤": "{:.2%}",
194
+ "最终资金": "${:,.2f}"
195
+ }))
196
+
197
+ st.markdown("""
198
+ **解读指南:**
199
+ * **再平衡次数**: 如果次数太少(比如0次),说明阈值设得太宽了。如果次数太多(比如几百次),交易成本(滑点、佣金、税)会吃掉利润。
200
+ * **最大回撤**: 观察哪个阈值在极端行情(如2008, 2020)下保护得最好。
201
+ """)
202
+
203
+ else:
204
+ st.info("👈 请在左侧选择参数并点击 '开始回测'")