Spaces:
Sleeping
Sleeping
| 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() |