permanent-portfolio-backtest / src /streamlit_app.py
jscmp4's picture
Update src/streamlit_app.py
6254e21 verified
import streamlit as st
import yfinance as yf
import pandas as pd
import numpy as np
import plotly.express as px
import os
# ==========================================
# 🧱 MODULE 1: 配置与策略定义 (Configuration)
# ==========================================
# 定义数据文件名 (建议改名,触发重新下载)
DATA_FILE = 'market_data_v3.csv'
# 定义涉及到的所有 Ticker (全集)
# 新增:
# - VNQ: 房地产信托 (Real Estate) -> 耶鲁模式用
# - EEM: 新兴市场 (Emerging Markets) -> 耶鲁模式用
# - IEF: 中期国债 (7-10 Year Treasury) -> 60/40 和 耶鲁模式常用
ALL_TICKERS = ['SPY', 'TLT', 'GLD', 'SHV', 'VTI', 'VBR', 'VNQ', 'EEM', 'IEF']
# 定义策略配置
STRATEGIES = {
# --- 防御型 ---
"永久组合 (Permanent Portfolio)": {
"assets": ["SPY", "TLT", "GLD", "SHV"],
"weights": [0.25, 0.25, 0.25, 0.25],
"description": "哈利·布朗:极度防御。25% 股票, 25% 长债, 25% 黄金, 25% 现金。",
"color": "blue"
},
"黄金蝴蝶 (Golden Butterfly)": {
"assets": ["VTI", "VBR", "TLT", "SHV", "GLD"],
"weights": [0.20, 0.20, 0.20, 0.20, 0.20],
"description": "Tyler:永久组合进阶版。加入小盘价值股(VBR)增强增长。",
"color": "orange"
},
# --- 平衡型 (机构/基准) ---
"经典股债 60/40 (The Benchmark)": {
"assets": ["SPY", "IEF"],
"weights": [0.60, 0.40],
"description": "行业基准线。60% 标普500 + 40% 中期国债。如果跑不赢它,策略就没意义。",
"color": "gray"
},
"耶鲁大学捐赠基金 (Swensen Model)": {
"assets": ["VTI", "VEA", "EEM", "VNQ", "IEF", "TIP"],
# 注意:这里为了简化,用 SPY 代替 VTI,EEM 代替新兴市场,VNQ 是地产
# 简化版配方:
"assets": ["SPY", "EEM", "VNQ", "IEF", "TLT"],
"weights": [0.30, 0.15, 0.20, 0.15, 0.20],
"description": "大卫·斯文森:高度分散。引入房地产(VNQ)和新兴市场(EEM)。",
"color": "green"
},
# --- 进攻型 ---
"巴菲特 90/10 (Buffett's Bet)": {
"assets": ["SPY", "SHV"],
"weights": [0.90, 0.10],
"description": "股神遗嘱:90% 标普500 + 10% 短期国债。极简暴力,赌国运。",
"color": "red"
}
}
# ==========================================
# 💾 MODULE 2: 数据管理 (Data Manager)
# ==========================================
class DataManager:
def __init__(self, filepath, tickers):
self.filepath = filepath
self.tickers = tickers
def download_data(self):
"""从 Yahoo 下载数据"""
with st.spinner(f'正在下载包含 {len(self.tickers)} 个资产的数据...'):
# auto_adjust=False 确保获取 Adj Close
data = yf.download(self.tickers, start="2007-01-01", auto_adjust=False)['Adj Close']
data = data.dropna()
data.to_csv(self.filepath)
return data
def get_data(self, force_update=False):
"""获取数据的统一接口"""
if force_update:
return self.download_data()
if os.path.exists(self.filepath):
try:
df = pd.read_csv(self.filepath, index_col=0, parse_dates=True)
# 简单校验:确保本地文件包含了我们需要的所有列
missing_cols = [t for t in self.tickers if t not in df.columns]
if missing_cols:
st.warning(f"本地数据缺少 {missing_cols},正在重新下载...")
return self.download_data()
return df
except Exception as e:
st.error(f"读取本地文件失败: {e}")
return None
else:
return None
# ==========================================
# ⚙️ MODULE 3: 回测引擎 (Backtest Engine)
# ==========================================
class Backtester:
def __init__(self, data):
self.data = data
def run(self, strategy_config, threshold=0.15, check_freq='Daily', initial_cash=10000.0):
"""
通用的回测函数
strategy_config: 包含 'assets' 和 'weights' 的字典
"""
assets = strategy_config['assets']
target_weights = np.array(strategy_config['weights'])
# 提取当前策略需要的数据子集
# 这一步很关键,因为 self.data 可能包含很多不相关的列
try:
strategy_data = self.data[assets].dropna()
except KeyError as e:
st.error(f"数据缺失: {e}")
return None, None
dates = strategy_data.index
prices = strategy_data.values # numpy array for speed
# 初始化
holdings = np.zeros(len(assets))
# 初始建仓
first_prices = prices[0]
holdings = (initial_cash * target_weights) / first_prices
portfolio_history = []
trades = 0
# 预计算检测日
is_check_day = np.full(len(dates), False)
if check_freq == 'Daily':
is_check_day[:] = True
elif check_freq == 'Monthly':
# 找到每个月最后一个交易日
resampled = strategy_data.resample('ME').last().index
# searchsorted 找到最近的索引位置
idx = strategy_data.index.searchsorted(resampled)
idx = idx[idx < len(strategy_data)] # 防止越界
is_check_day[idx] = True
# --- 核心循环 (向量化很难做再平衡,所以还是用循环) ---
for i in range(len(dates)):
current_date = dates[i]
current_prices = prices[i]
# 1. 计算当前市值
asset_values = holdings * current_prices
total_value = np.sum(asset_values)
portfolio_history.append({'Date': current_date, 'Total Value': total_value})
# 2. 检查是否是检测日
if not is_check_day[i]:
continue
# 3. 计算当前权重
current_weights = asset_values / total_value
# 4. 检查是否触发阈值 (Absolute Band)
# 逻辑:只要有一个资产偏离目标权重超过 threshold,就全部再平衡
# 比如目标 20%,阈值 5%,则允许 [15%, 25%]
lower_bounds = target_weights - threshold
upper_bounds = target_weights + threshold
# np.any() 检查是否有任何一个资产越界
needs_rebalance = np.any((current_weights < lower_bounds) | (current_weights > upper_bounds))
if needs_rebalance:
# 执行再平衡:卖强买弱,回到目标权重
holdings = (total_value * target_weights) / current_prices
trades += 1
return pd.DataFrame(portfolio_history).set_index('Date'), trades
# ==========================================
# 🖥️ MODULE 4: 应用程序界面 (App Layout)
# ==========================================
def main():
st.set_page_config(page_title="Portfolio War Room", layout="wide", page_icon="🛡️")
st.title("🛡️ 资产配置实验室:永久组合 vs 黄金蝴蝶")
# 1. 初始化数据管理器
dm = DataManager(DATA_FILE, ALL_TICKERS)
# 侧边栏:数据控制
with st.sidebar:
st.header("1. 数据控制")
if st.button("🔄 强制更新数据"):
df = dm.get_data(force_update=True)
if df is not None:
st.success("数据已更新!")
else:
df = dm.get_data()
if df is not None:
st.success(f"✅ 数据就绪 ({df.index[0].date()} - {df.index[-1].date()})")
else:
st.warning("本地无数据,请点击下方下载")
if st.button("📥 下载初始数据"):
df = dm.get_data(force_update=True)
st.rerun()
st.stop() # 如果没数据,停止渲染后续内容
# 侧边栏:策略参数
with st.sidebar:
st.divider()
st.header("2. 策略参数")
# 选择策略
selected_strat_name = st.selectbox(
"选择策略模型",
list(STRATEGIES.keys()),
index=1 # 默认选黄金蝴蝶
)
strategy_config = STRATEGIES[selected_strat_name]
st.info(f"📝 **说明**: {strategy_config['description']}")
# 参数设置
threshold = st.slider("再平衡阈值 (Threshold ±%)", 1, 30, 15, 1) / 100.0
check_freq = st.selectbox("检测频率", ["Daily", "Monthly"], index=0)
st.divider()
st.markdown("### 🛠️ 进阶功能")
if st.button("🚀 运行参数扫描 (比较 0%-30%)"):
run_sweep_mode(df, strategy_config)
st.stop() # 扫描模式下不显示单次结果
# --- 主界面:单次回测模式 ---
# 实例化回测引擎
engine = Backtester(df)
# 运行回测
equity_curve, trades = engine.run(strategy_config, threshold=threshold, check_freq=check_freq)
if equity_curve is None:
return
# 计算指标
start_val = equity_curve['Total Value'].iloc[0]
end_val = equity_curve['Total Value'].iloc[-1]
years = (equity_curve.index[-1] - equity_curve.index[0]).days / 365.25
cagr = (end_val / start_val) ** (1/years) - 1
roll_max = equity_curve['Total Value'].cummax()
drawdown = (equity_curve['Total Value'] - roll_max) / roll_max
max_dd = drawdown.min()
# 展示结果 KPI
st.subheader(f"📊 回测结果: {selected_strat_name}")
col1, col2, col3, col4 = st.columns(4)
col1.metric("年化收益 (CAGR)", f"{cagr:.2%}")
col2.metric("最大回撤 (Max Drawdown)", f"{max_dd:.2%}")
col3.metric("总收益倍数", f"{(end_val/start_val):.2f}x")
col4.metric("触发再平衡次数", f"{trades} 次")
# 绘图
fig = px.line(equity_curve, y='Total Value', title=f"资金曲线 (阈值 ±{int(threshold*100)}%)")
fig.update_layout(height=500)
st.plotly_chart(fig, use_container_width=True)
# 绘制回撤图
dd_fig = px.area(drawdown, title="历史回撤幅度")
dd_fig.update_layout(yaxis_title="Drawdown", showlegend=False, height=300)
st.plotly_chart(dd_fig, use_container_width=True)
# ==========================================
# 🔬 独立功能:参数扫描模式
# ==========================================
def run_sweep_mode(df, strategy_config):
"""单独的参数扫描页面逻辑"""
st.header(f"🔍 参数扫描分析: {strategy_config['description']}")
engine = Backtester(df)
results = []
progress_bar = st.progress(0)
# 扫描 1% 到 30%
thresholds = np.arange(0.01, 0.31, 0.01)
for i, thresh in enumerate(thresholds):
progress_bar.progress((i + 1) / len(thresholds))
eq, trades = engine.run(strategy_config, threshold=thresh, check_freq='Daily')
start_val = eq['Total Value'].iloc[0]
end_val = eq['Total Value'].iloc[-1]
years = (eq.index[-1] - eq.index[0]).days / 365.25
cagr = (end_val / start_val) ** (1/years) - 1
roll_max = eq['Total Value'].cummax()
max_dd = ((eq['Total Value'] - roll_max) / roll_max).min()
results.append({
"Threshold (%)": round(thresh*100, 1),
"CAGR": cagr,
"Max Drawdown": max_dd,
"Trades": trades
})
res_df = pd.DataFrame(results)
col1, col2 = st.columns(2)
with col1:
fig1 = px.line(res_df, x="Threshold (%)", y="CAGR", markers=True, title="不同阈值下的年化收益")
st.plotly_chart(fig1, use_container_width=True)
with col2:
fig2 = px.bar(res_df, x="Threshold (%)", y="Trades", title="不同阈值下的交易次数")
st.plotly_chart(fig2, use_container_width=True)
st.dataframe(res_df.style.format({"CAGR": "{:.2%}", "Max Drawdown": "{:.2%}"}))
if st.button("⬅️ 返回主界面"):
st.rerun()
if __name__ == "__main__":
main()