第三章 投资组合的多目标模型:收益、风险、夏普比率、最大回撤等多目标函数构建
好,咱们进入正题。
上一章我们聊了多目标优化的基础概念,Pareto前沿、支配关系这些。但说实话,那些都是通用理论。真正到了投资组合这个场景,你得先回答一个核心问题:到底要优化哪些目标?
我见过不少新手,上来就把十几个指标扔进模型,结果跑出来一堆不靠谱的解。嗯,这里有个关键点——目标函数的选择,直接决定了你的投资组合长什么样。
3.1 四个核心目标函数
我个人习惯,先从最经典的四个目标入手。它们几乎覆盖了投资组合最关心的维度:
- 收益:赚钱能力,越高越好
- 风险:波动程度,越低越好
- 夏普比率:风险调整后的收益,越高越好
- 最大回撤:最惨的时候亏多少,越低越好
你想想看,这四个目标其实代表了两种视角:收益端和风险端。夏普比率是两者的比值,最大回撤则是极端风险。
核心思路:多目标优化不是找一个"最优解",而是找一组"不差解"——也就是Pareto前沿上的解。每个解代表一种收益-风险的权衡。
3.2 目标函数的数学定义
咱们一个一个来拆。先定义一些符号:
- \( w \):权重向量,\( w_i \) 表示第 i 只股票的权重
- \( r \):预期收益率向量
- \( \Sigma \):协方差矩阵
- \( T \):回测期长度(天数)
- \( P_t \):第 t 天的组合净值
3.2.1 收益目标
最简单的,组合预期收益:
def objective_return(weights, expected_returns):
return np.dot(weights, expected_returns) # 最大化
注意,这里是最大化目标。但在多目标框架里,我们通常把所有目标统一成最小化方向。所以实际代码里,我会取负号:
def objective_return_min(weights, expected_returns):
return -np.dot(weights, expected_returns) # 最小化负收益
3.2.2 风险目标
用组合方差来衡量风险:
def objective_risk(weights, cov_matrix):
return np.dot(weights.T, np.dot(cov_matrix, weights)) # 最小化
这里用的是方差,不是标准差。为什么?因为方差在优化时更平滑,梯度好算。我在项目中遇到过,用标准差做目标函数,优化器经常震荡不收敛。后来换成方差,问题就解决了。
3.2.3 夏普比率目标
夏普比率 = (组合收益 - 无风险利率) / 组合波动率。但注意,在多目标框架里,我们通常把它拆成两个目标:收益和风险。不过有时候,直接优化夏普比率也有用:
def objective_sharpe(weights, expected_returns, cov_matrix, rf=0.02):
port_return = np.dot(weights, expected_returns)
port_vol = np.sqrt(np.dot(weights.T, np.dot(cov_matrix, weights)))
sharpe = (port_return - rf) / port_vol
return -sharpe # 最小化负夏普
避坑指南:我曾经直接把夏普比率作为唯一目标,结果优化出来的组合权重极度集中,回撤大得吓人。后来才明白,夏普比率只考虑了波动率,没考虑尾部风险。所以我现在做多目标时,一定会把最大回撤也加进去。
3.2.4 最大回撤目标
最大回撤的计算稍微复杂一点,需要遍历整个回测期:
def objective_max_drawdown(weights, price_data):
# price_data: DataFrame, 每列是股票价格
portfolio_value = price_data.dot(weights)
cumulative_max = np.maximum.accumulate(portfolio_value)
drawdown = (portfolio_value - cumulative_max) / cumulative_max
max_dd = np.min(drawdown) # 负值,比如 -0.3 表示亏了30%
return -max_dd # 最小化最大回撤的绝对值
这里有个细节:最大回撤是路径依赖的。同样的收益和波动率,不同的路径会产生不同的最大回撤。所以它捕捉的是"最坏情况",这是方差做不到的。
3.3 多目标函数组合策略
四个目标都有了,怎么组合?我常用的有三种方式:
| 策略 | 方法 | 适用场景 |
|---|---|---|
| 加权求和 | 给每个目标赋权重,合成单目标 | 快速得到一个解,但权重难定 |
| ε-约束法 | 选一个主目标,其他设约束 | 有明确偏好时好用 |
| Pareto前沿法 | 用NSGA-II等算法直接找前沿 | 需要权衡多个解时首选 |
我个人最推荐第三种。为什么?因为加权求和太主观了——你给收益赋0.6,风险赋0.4,凭什么?而Pareto前沿法能给你一整条曲线,你自己选。
3.4 完整的多目标模型代码
下面是一个完整的例子,用NSGA-II算法优化四个目标:
import numpy as np
import pandas as pd
from pymoo.algorithms.moo.nsga2 import NSGA2
from pymoo.core.problem import ElementwiseProblem
from pymoo.optimize import minimize
class PortfolioProblem(ElementwiseProblem):
def __init__(self, expected_returns, cov_matrix, price_data):
self.expected_returns = expected_returns
self.cov_matrix = cov_matrix
self.price_data = price_data
n_assets = len(expected_returns)
super().__init__(
n_var=n_assets,
n_obj=4, # 四个目标
xl=0.0, # 权重下限
xu=1.0, # 权重上限
constr_ieq=[lambda x: np.sum(x) - 1.0] # 权重和为1
)
def _evaluate(self, x, out, *args, **kwargs):
# 目标1:负收益(最小化)
f1 = -np.dot(x, self.expected_returns)
# 目标2:方差(最小化)
f2 = np.dot(x.T, np.dot(self.cov_matrix, x))
# 目标3:负夏普比率(最小化)
port_return = np.dot(x, self.expected_returns)
port_vol = np.sqrt(f2)
f3 = -(port_return - 0.02) / port_vol
# 目标4:最大回撤(最小化)
portfolio_value = self.price_data.dot(x)
cumulative_max = np.maximum.accumulate(portfolio_value)
drawdown = (portfolio_value - cumulative_max) / cumulative_max
f4 = -np.min(drawdown)
out["F"] = [f1, f2, f3, f4]
# 使用示例
# problem = PortfolioProblem(expected_returns, cov_matrix, price_data)
# algorithm = NSGA2(pop_size=100)
# res = minimize(problem, algorithm, ('n_gen', 100), verbose=True)
# pareto_front = res.F # 得到Pareto前沿上的解
注意:实际运行时,最大回撤的计算会显著拖慢优化速度。我建议先用方差和收益跑几轮,筛选出候选解,再计算最大回撤做二次筛选。这样效率高很多。
3.5 知识体系结构图
下面这张图,把本章的核心逻辑串起来了:
3.6 实战中的坑与经验
最后,分享几个我在实战中踩过的坑:
- 目标函数量纲不一致:收益是百分比,方差是百分比的平方,夏普是无量纲。直接放一起优化,数值大的目标会主导搜索方向。我习惯先做归一化,或者用自适应权重。
- 最大回撤的计算频率:用日频数据算最大回撤,和用周频数据算,结果可能差很多。我个人建议用日频,因为回撤是极端事件,日频能捕捉到更细的波动。
- 夏普比率的陷阱:夏普比率假设收益是正态分布,但实际中不是。所以夏普高的组合,可能只是运气好,而不是真的优秀。我一般会同时看偏度和峰度。
- 不要贪多:目标函数不是越多越好。4-5个是黄金数量。超过6个,Pareto前沿会变得非常稀疏,解的质量反而下降。
一个小技巧:如果你发现优化结果总是集中在某个区域,比如全是高收益高风险的解,那说明你的目标函数设置有问题。试着调整一下约束条件,或者给某些目标加惩罚项。
好了,这一章就到这里。四个目标函数,三种组合策略,再加上一张结构图,应该够你上手了。记住,多目标优化的核心不是找"最好",而是找"不后悔"——你选的每个解,都是权衡后的结果。