File size: 12,419 Bytes
4477441
083d48f
 
 
 
07643f8
083d48f
b20b6af
 
 
 
6254e21
 
b20b6af
 
6254e21
 
 
 
 
 
b20b6af
 
 
6254e21
b20b6af
 
 
6254e21
b20b6af
 
 
 
 
6254e21
b20b6af
6254e21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b20b6af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
083d48f
b20b6af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
083d48f
b20b6af
 
 
 
 
 
 
 
 
 
083d48f
b20b6af
 
 
 
 
083d48f
b20b6af
 
083d48f
b20b6af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
083d48f
b20b6af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
083d48f
b20b6af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
083d48f
b20b6af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
083d48f
b20b6af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
083d48f
b20b6af
 
 
 
 
 
 
 
083d48f
b20b6af
 
 
 
07643f8
b20b6af
 
 
 
 
 
 
 
 
 
 
083d48f
b20b6af
 
083d48f
07643f8
b20b6af
 
 
07643f8
 
b20b6af
07643f8
b20b6af
083d48f
b20b6af
 
 
083d48f
 
b20b6af
 
083d48f
b20b6af
 
07643f8
 
b20b6af
083d48f
 
b20b6af
 
07643f8
 
b20b6af
 
07643f8
b20b6af
 
 
 
 
 
 
07643f8
b20b6af
 
1
2
3
4
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
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()