Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- 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 |
-
#
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
return data
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
st.
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'
|
| 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 |
-
# 初始建仓
|
| 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
|
| 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.
|
|
|
|
| 124 |
|
| 125 |
-
#
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 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("开始
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
results_summary = []
|
| 139 |
-
all_equity_curves = pd.DataFrame()
|
| 140 |
|
| 141 |
-
|
| 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 |
-
"
|
| 164 |
-
"
|
| 165 |
-
"
|
| 166 |
-
"
|
| 167 |
-
"
|
| 168 |
-
"
|
| 169 |
})
|
| 170 |
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("👈 请在左侧设置范围并点击 '开始参数扫描'")
|