跳到主内容
研究主线资产定价与组合构建 · 第30–36讲6/7模块导览 →
讲义

第35讲:风险约束的投资组合优化 (Risk-Constrained Portfolio Optimization)

以 Fama-French 模型分解组合风险,构建总协方差与暴露约束优化,并检查可行性、归因和样本外表现。

完成状态保存在当前浏览器,可随时取消。

一、核心概念与工具准备#

关键公式

R=BF+ϵ\vec{R} = B\vec{F} + \epsilon

其中:

R\vec{R}表示资产收益率向量

BB 表示因子暴露度矩阵(因子载荷)

F\vec{F} 表示因子收益率向量

ϵ\epsilon 表示特质收益向量(随机误差项)

这个公式体现了投资组合收益可以分解为:

  1. 系统性部分:因子暴露与因子收益的乘积(BFB\vec{F})

  2. 非系统性部分:特质收益(ϵ\epsilon)

工具包配置

code
import numpy as np
import pandas as pd
import yfinance as yf
import statsmodels.api as sm
import matplotlib.pyplot as plt
from tqdm import tqdm
from pandas_datareader import data as web

二、数据获取与预处理#

步骤详解

  1. 获取标普500成分股(示例替代QTradableStocksUS):
code
sp500 = pd.read_html('https://en.wikipedia.org/wiki/List_of_S%26P_500_companies')[0]
tickers = sp500.Symbol.tolist()[:50]  # 取前50支简化计算

这只能作为 API 演示。用“当前成分股”回测 2009 年会产生幸存者偏差;策略评估必须改用每个历史时点可见的成分股和基本面数据。

  1. 下载历史价格数据
code
def download_prices(tickers, start, end):
    data = yf.download(
        tickers, start=start, end=end, auto_adjust=True,
        group_by='column', multi_level_index=True,
    )['Close']
    return data.dropna(axis=1)

prices = download_prices(tickers, '2009-01-01', '2011-01-01')
returns = prices.pct_change().dropna()  # 日收益率矩阵

数据结构示例

code
Date        AAPL      MSFT      AMZN      ...
2009-01-02  0.012     -0.005    0.018
2009-01-03  -0.007     0.015    0.002

三、因子模型构建#

Fama-French三因子数据获取

code
ff_all = web.DataReader(
    'F-F_Research_Data_Factors_daily', 'famafrench',
    start='2009-01-01', end='2011-01-01',
)[0] / 100
ff_data = ff_all[['Mkt-RF', 'SMB', 'HML']]

因子对齐处理

code
common_dates = returns.index.intersection(ff_data.index)
F = ff_data.loc[common_dates]  # 因子收益矩阵
# 因子中的市场列是 Mkt-RF,因此资产端也使用超额收益
R = returns.loc[common_dates].sub(ff_all.loc[common_dates, 'RF'], axis=0)

四、因子暴露计算#

滚动回归示例

code
betas = pd.DataFrame(index=R.columns, columns=F.columns, dtype=float)
residual_var = pd.Series(index=R.columns, dtype=float)

for stock in tqdm(R.columns):
    model = sm.OLS(R[stock], sm.add_constant(F))
    results = model.fit()
    betas.loc[stock] = results.params.drop('const')
    residual_var.loc[stock] = results.resid.var(ddof=1)

暴露矩阵结构

code
           Mkt-RF     SMB     HML
AAPL        1.05   -0.09   -0.34
MSFT        0.92    0.12    0.21
...

五、风险分解实战#

方差分解实现

code
# 协方差矩阵计算
factor_cov = F.cov()
specific_var = residual_var

# 组合权重(等权示例)
weights = np.ones(len(R.columns)) / len(R.columns)

# 风险分解
common_risk = weights @ betas @ factor_cov @ betas.T @ weights
specific_risk = weights @ np.diag(specific_var) @ weights
total_risk = common_risk + specific_risk

风险占比计算

code
print(f"系统性风险占比: {common_risk/total_risk:.2%}")

六、优化模型构建#

CVXPY优化示例

code
import cvxpy as cp

# 定义优化变量和因子模型协方差
w = cp.Variable(len(weights))
total_cov = (
    betas.values @ factor_cov.values @ betas.values.T
    + np.diag(specific_var.values)
)
benchmark_exposure = betas.T.values @ weights
exposure_band = 0.05
risk_aversion = 10.0

# 构建约束
constraints = [
    cp.sum(w) == 1,
    w >= 0,
    cp.norm(betas.T.values @ w - benchmark_exposure, 'inf') <= exposure_band,
]

# 在预期收益与总方差之间权衡,避免仅最大化样本均值导致单一持仓
portfolio_return = R.mean().values @ w
portfolio_variance = cp.quad_form(w, cp.psd_wrap(total_cov))
problem = cp.Problem(
    cp.Maximize(portfolio_return - risk_aversion * portfolio_variance),
    constraints,
)
problem.solve()
if problem.status not in {cp.OPTIMAL, cp.OPTIMAL_INACCURATE}:
    raise RuntimeError(f'优化失败:{problem.status}')

优化结果分析

code
print("最优权重:", w.value.round(3))
print("预期收益:", portfolio_return.value)

七、绩效归因分析#

使用同一因子模型进行归因

code
# 各因子的逐日收益贡献
portfolio_exposure = betas.T.values @ w.value
factor_contributions = F.mul(portfolio_exposure, axis=1)

# 特异收益是组合超额收益减去模型解释部分
portfolio_excess_return = R @ w.value
specific_return = portfolio_excess_return - factor_contributions.sum(axis=1)
attribution = factor_contributions.assign(Specific=specific_return)

print('样本期算术收益贡献:')
print(attribution.sum())

八、关键演进对比#

方法类型优点局限性
均值-方差优化理论完备对输入参数敏感
风险平价模型风险均衡配置忽略收益预测
因子风险约束模型明确控制风险来源依赖因子模型准确性

小练习

尝试将市场因子暴露限制在0.8以下,比较优化前后组合的收益波动比变化

code
# 参考答案
market_limit = 0.8
style_exposure = betas[['SMB', 'HML']].T.values @ w
style_benchmark = betas[['SMB', 'HML']].T.values @ weights
exercise_constraints = [
    cp.sum(w) == 1,
    w >= 0,
    cp.norm(style_exposure - style_benchmark, 'inf') <= exposure_band,
    betas['Mkt-RF'].values @ w <= market_limit,
]
constrained_problem = cp.Problem(
    cp.Maximize(portfolio_return - risk_aversion * portfolio_variance),
    exercise_constraints,
)
constrained_problem.solve()
if constrained_problem.status not in {cp.OPTIMAL, cp.OPTIMAL_INACCURATE}:
    raise RuntimeError(f'市场暴露约束不可行:{constrained_problem.status}')

# 夏普比率必须由同一段样本外超额收益计算,不能使用约束的对偶变量
test_excess_returns = ...  # 与训练期分离、列顺序与 R 一致的 DataFrame
portfolio_test_returns = test_excess_returns @ w.value
sharpe = np.sqrt(252) * portfolio_test_returns.mean() / portfolio_test_returns.std(ddof=1)
print(f"样本外年化夏普比率: {sharpe:.2f}")