Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- 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 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
"
|
| 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("👈 请在左侧选择参数并点击 '开始回测'")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|