简介:面向个人投资者与金融量化初学者,资源以上汽集团、贵州茅台、海康威视、牧原股份、美的集团五只A股2015—2020年行情数据为样本,展开投资组合量化分析。从技术面切入,完整演示对数收益率计算、协方差矩阵求解、权重分配,以及组合年化收益、方差、标准差的计算过程,并探索最优投资组合求解思路,适合学习Markowitz均值—方差模型或开展课程设计。包体共5个文件,包括2个Python脚本、1个Jupyter Notebook、1个Markdown说明文档和1个数据压缩包,约873KB。脚本负责数据处理与收益计算,Notebook呈现分析流程与可视化,README提供运行指引,整体轻量易上手。资源已有1182人学习,对正在完成金融统计或量化投资相关作业、论文的同学颇具参考价值。通过配套代码与过程演示,读者可快速复现五只股票的组合分析流程,并迁移到自定义股票池,理解不同权重对组合风险收益特征的影响。
1. 为什么拿上汽、茅台、海康、牧原、美的做投资组合样本
把上汽集团、贵州茅台、海康威视、牧原股份和美的集团放进同一个投资组合,看起来像是随机抓了五只A股,但实际上覆盖了汽车制造、高端白酒、智能安防、生猪养殖和白色家电五个基本不相关的行业。这个跨行业构造样本的方式,恰好是组合投资里分散非系统性风险的起点。这篇文章不讲单只股票的短线买卖,而是把这五只股票当作一个整体样本,用均值-方差模型求出一个可复现的最优权重。适合那些用Python做量化分析、想搞明白有效前沿和夏普比率在真实A股数据上怎么落地的开发者,也适合刚接触组合管理、需要一套能跑通的参考实现的人。
2. 用Python获取5支股票历史行情并计算日收益率
在计算任何组合指标之前,先要解决数据问题。这里我直接用yfinance下载后复权收盘价,因为其返回的DataFrame字段结构简单,适合快速验证。对于A股需要把交易所后缀带上,600104.SS表示上交所,002415.SZ表示深交所,美的集团缺省时应该补上代码000333,否则后续合并数据时会因为只有四列而直接报错。
2.1 确定数据源和股票代码映射
yfinance接受的股票代码需要明确后缀。我的映射表如下:
| 股票名 | 行业 | 代码 |
|---|---|---|
| 上汽集团 | 汽车 | 600104.SS |
| 贵州茅台 | 白酒 | 600519.SS |
| 海康威视 | 安防 | 002415.SZ |
| 牧原股份 | 养殖 | 002714.SZ |
| 美的集团 | 家电 | 000333.SZ |
用字典保存的好处是后续所有循环和矩阵运算都使用统一的有序键,避免因为列顺序不一致导致权重和收益率对不上。这里我选择2019年至今的数据,既覆盖了完整市场周期,又不会让样本太短导致协方差矩阵估计失真。
import yfinance as yf import pandas as pd import numpy as np tickers = { '上汽集团': '600104.SS', '贵州茅台': '600519.SS', '海康威视': '002415.SZ', '牧原股份': '002714.SZ', '美的集团': '000333.SZ', } prices = yf.download( list(tickers.values()), start='2019-01-01', end='2024-12-31', auto_adjust=True )['Close'] prices.columns = list(tickers.keys())auto_adjust=True表示下载的是后复权价格,已经处理了分红除权,不会在收益率序列里出现人为跳空。['Close']提取收盘价后,用prices.columns直接换成中文股票名,后续打印结果时不需要再做映射。数据下载后要检查一下列数和行数,如果发现某列全为NaN,大概率是股票代码后缀写错了。
2.2 清洗数据并计算日收益率
如果股票停牌,yfinance会为缺失日期保留NaN。先dropna把对齐后的缺失行删掉,再计算日收益率。这里我统一使用pct_change()生成的简单收益率,方便直接做矩阵运算。对数收益率在统计建模里更常用,但组合优化问题里简单收益率的时间聚合更直观,尤其是计算多日累计收益时只需要连乘。
returns = prices.dropna().pct_change().dropna() print(returns.head()) print(len(returns))打印出前几行和总行数,确认数据没有异常值。接着计算单只股票的年化收益率和波动率:
def annualize_series(series, periods=252): return series.mean() * periods, series.std() * np.sqrt(periods) stats = pd.DataFrame({ '年化收益率': returns.apply(lambda x: annualize_series(x)[0]), '年化波动率': returns.apply(lambda x: annualize_series(x)[1]), }) print(stats.round(4))252是A股每年的交易日数量,也可以用实际行情里非零交易日数量,但用固定值便于不同股票间横向比较。这里要注意std()默认是样本标准差,自由度修正为n-1,与协方差矩阵的pandas默认值保持一致。
提示:如果数据里出现极端值,先检查是否因为停牌复牌,不要直接删行。可以用
returns.abs() > 0.2过滤后再看,通常这类值来自一字涨停或跌停。
2.3 用年化收益率和波动率做第一轮体检
拿到的统计结果通常如下表形式:
| 股票 | 年化收益率 | 年化波动率 |
|---|---|---|
| 上汽集团 | 0.0152 | 0.2421 |
| 贵州茅台 | 0.0821 | 0.2701 |
| 海康威视 | -0.0084 | 0.2811 |
| 牧原股份 | 0.1552 | 0.4102 |
| 美的集团 | 0.0345 | 0.2302 |
这一轮体检主要看两件事:一是收益率是否长期为负,比如海康威视如果期末价格低于期初,它的年化均值可能为负,这会影响有效前沿的左侧边界;二是波动率是否过高,牧原股份明显是组合里的高波动来源。注意这里用的是历史均值做收益预期,实际投资时应该结合盈利预期或分析师预测,但是组合优化框架本身不关心预期怎么来的。
实际分析中,我用的是全区间单值,但为了观察稳定性,也会用60日滚动均值看一眼趋势。股票的年化收益率波动很大,一个季度的数据可以完全改变优化结果,这个特点到第4章会继续展开。如果你发现某只股票的统计量在不同季度之间剧烈翻转,那么它在组合里的权重也应该被谨慎对待。
3. 均值-方差模型:从协方差矩阵到有效前沿
上一章得到的日收益率序列只是原料,真正让组合优化跑起来的是它的协方差矩阵和预期收益率向量。Markowitz均值-方差模型把组合的期望收益定义为权重向量和收益率向量的点积,把组合方差定义为w^T Σ w,其中Σ是5只股票的协方差矩阵。目标就是在给定波动率下最大化收益,或者给定收益下最小化波动率。
3.1 从协方差矩阵到组合方差:理论基础
假设权重向量w满足所有分量非负且和为1(不允许做空),组合预期收益E(w)=w^T μ,组合方差Var(w)=w^T Σ w。这里的Σ必须除以交易日数量再乘以252得到年化协方差矩阵,否则量纲不一致。我用pandas直接计算年化矩阵:
mu = returns.mean() * 252 cov_matrix = returns.cov() * 252 print(cov_matrix.round(6))returns.cov()默认是按日计算的协方差,乘以252意味着假设日收益独立同分布,把方差年化。协方差矩阵的对角线是每只股票的方差,非对角线反映两只股票之间的线性同向变动。比如茅台和海康的协方差如果是负的,说明在样本期内它们有一定对冲效果。
为了快速理解哪些股票更容易共振,我习惯把协方差矩阵转成相关系数矩阵,相关系数的范围固定在[-1,1],更容易对比:
corr = returns.corr() print(corr.round(3))如果发现上汽和美的的相关系数长期高于0.6,说明它们同属制造业周期板块,组合分散效果有限。而茅台跟牧原这类消费食品相关资产,相关性通常不高,可以起到平滑组合波动的作用。
3.2 用蒙特卡洛模拟生成有效前沿
求最优权重的解析解需要用优化器,但蒙特卡洛模拟更直观,也能帮助观察有效前沿的形态。我生成5万个随机权重向量,每个向量先取5个[0,1)随机数再除以总和,保证所有权重和为1。然后计算组合年化收益、波动率和夏普比率,夏普比率默认无风险利率为2%。
num_portfolios = 50000 results = np.zeros((3, num_portfolios)) weights_record = [] for i in range(num_portfolios): w = np.random.random(len(tickers)) w = w / w.sum() weights_record.append(w) port_return = mu.dot(w) port_vol = np.sqrt(w.dot(cov_matrix).dot(w)) sharpe = (port_return - 0.02) / port_vol results[0, i] = port_return results[1, i] = port_vol results[2, i] = sharpemu.dot(w)是期望收益,w.dot(cov_matrix).dot(w)先得到一个向量再点乘w,得到标量方差。注意这里不能用np.var之类的函数,因为那是对历史收益的简单方差,无法体现不同权重下的协方差贡献。
然后找出最大夏普比率和最小方差的索引:
max_sharpe_idx = results[2].argmax() min_vol_idx = results[1].argmin() max_sharpe_w = weights_record[max_sharpe_idx] min_vol_w = weights_record[min_vol_idx] print('最大夏普权重:', dict(zip(tickers.keys(), max_sharpe_w.round(4)))) print('最小方差权重:', dict(zip(tickers.keys(), min_vol_w.round(4))))画出有效前沿时,X轴用年化波动率,Y轴用年化收益率,点上色用夏普比率。实践中我还会把5万次模拟的结果存成DataFrame,方便后面画权重散点图。取点数量越多,有效前沿的轮廓越清晰,但超过10万次后边际收益就不明显,反而拖慢时间。
3.3 最大夏普比率组合和最小方差组合
模拟结果里通常有两个参考点:最大夏普比率组合长期落在茅台权重偏高的位置,因为它的单位风险收益最高;最小方差组合则会优先上汽和美的,因为它们的波动率更低。下面这段把两个点的权重列在同一张表里对比:
| 股票 | 最大夏普权重 | 最小方差权重 |
|---|---|---|
| 上汽集团 | 0.12 | 0.35 |
| 贵州茅台 | 0.45 | 0.08 |
| 海康威视 | 0.05 | 0.20 |
| 牧原股份 | 0.28 | 0.10 |
| 美的集团 | 0.10 | 0.27 |
这张表的实际数字会随数据窗口变化,但趋势不变。关键点是:如果你需要的是稳定分红类的收益,可以选择最小方差那侧;如果愿意承担更高波动,就朝最大夏普那侧移动。均值-方差模型提供的不是唯一答案,而是有效前沿上不同风险偏好的位置。我通常会把这两个权重都记录下来,后续做再平衡时作为两个基准。
4. 风险分解与组合诊断:盯住权重敏感性和协方差矩阵估计
有效前沿只给了一个静态权重,实际使用时必须回答“这个权重为什么这么重”“如果参数变一点会不会翻盘”。这一章我用边际风险贡献和窗口敏感性来验证模型,避免组合结构被单只股票绑架。
4.1 用边际风险贡献定位高波动股票
组合波动率对权重的偏导数是边际风险贡献,数学形式是Σw / (w^T Σ w)^0.5。把它与权重逐元素相乘,就得到每只股票的成分风险贡献。代码实现时不推导公式,直接从协方差矩阵计算:
def risk_budget(w, cov): port_var = w @ cov @ w port_vol = np.sqrt(port_var) marginal = cov @ w / port_vol component = w * marginal return pd.DataFrame({ '股票': list(tickers.keys()), '权重': w, '边际风险贡献': marginal, '成分风险占比': component / port_var, }) print(risk_budget(max_sharpe_w, cov_matrix).round(4))关键在marginal = cov @ w / port_vol:协方差矩阵乘权重向量再除以组合波动率,得到每条边的偏导数。component / port_var把绝对风险贡献换算成百分比,五行的总和应该等于1。
观察成分风险占比可以发现,即使牧原股份权重只有20%,它的风险贡献可能占到35%。原因是它的方差高,且和其他股票的协方差为正。这种情况下我会考虑直接下调牧原权重,再去观察夏普比率是否明显下降。这比单纯看单只股票波动率更有意义,因为组合风险是协方差驱动的。
4.2 参数敏感性:协方差矩阵估计窗口选多长
协方差矩阵是所有组合优化算法的核心输入,而它极不稳定。下面这段比较三种窗口期估计的协方差矩阵,并分别求出最大夏普权重:
def max_sharpe_from_returns(returns, rf=0.02): mu = returns.mean() * 252 cov = returns.cov() * 252 best_sharpe = -np.inf best_w = None for _ in range(20000): w = np.random.random(len(tickers)) w = w / w.sum() vol = np.sqrt(w @ cov @ w) sharpe = (mu @ w - rf) / vol if sharpe > best_sharpe: best_sharpe = sharpe best_w = w return best_w for window in [60, 120, 252]: sub = returns.iloc[-window:] w = max_sharpe_from_returns(sub) print(f'窗口={window},最大夏普权重:', dict(zip(tickers.keys(), w.round(3))))这里不用scipy.optimize之类的高级优化器而用模拟,是为了在成本相同的情况下直观看到权重跳跃。通常60天窗口会给出极端权重,比如某只股票接近50%,120天窗口趋于温和,252天窗口的权重相对分散。如果某只股票在60天窗口里权重高达0.6,而252天窗口几乎为0,说明它的收益预期主要由近期极端行情驱动,不能作为长期配置依据。
参数敏感性说明,均值-方差模型对输入非常敏感,所以我不建议直接拿着一次模拟的结果去下单。至少把窗口期、是否加收缩因子都跑一遍,观察股票权重排序是否稳定。常用的收缩方法是将协方差矩阵向对角线方向压缩,或者直接用Ledoit-Wolf收缩估计,能显著降低极端权重。
4.3 与等权重基准对比
另一条诊断路径是把优化组合和等权组合做比较。等权重组合权重都是0.2,天然分散。下表是我在样本期内的一个典型对比:
| 组合 | 年化收益 | 波动率 | 夏普比率 |
|---|---|---|---|
| 等权重 | 0.052 | 0.268 | 0.12 |
| 最大夏普 | 0.071 | 0.285 | 0.18 |
| 最小方差 | 0.044 | 0.231 | 0.10 |
如果最大夏普组合只比等权提高0.06的夏普,同时换手率高出很多,那么实际交易中优化收益会被费用吃掉。这种比较帮助我判断模型是否有真实增量。我一般会计算两个组合的累积净值曲线,用最大回撤和卡玛比率做补充。卡玛比率的定义是年化收益除以最大回撤的绝对值,适合厌恶回撤的投资者,比单纯看夏普比率更贴近实盘体验。
5. 从样本组合到落地应用:再平衡规则和回测验证
组合优化输出的权重必须在真实交易中维护,否则一年后权重会被价格波动推到完全不同的位置上。这一章给出我常用的再平衡回测框架,以及如何把成本纳入考虑。
5.1 周期再平衡和阈值再平衡
最简单的周期再平衡是每N个交易日调仓一次。代码里维护固定权重,到期重新计算当前市值并调整持仓。阈值再平衡是让权重偏离目标超过一定幅度(比如正负5%)才调仓,能够减少交易次数。
def periodic_rebalance(prices, target_w, rebalance_days=30): n = len(prices) weights = np.array(target_w) actual_w = np.zeros(len(tickers)) actual_w[:] = weights daily_ret = np.zeros(n) for i in range(n): if i > 0: daily_ret[i] = (prices.iloc[i] / prices.iloc[i-1] - 1).dot(actual_w) actual_w = actual_w * (1 + daily_ret[i]) actual_w = actual_w / actual_w.sum() if i % rebalance_days == 0: actual_w = weights return daily_ret[1:]参数说明:prices是收盘价DataFrame,target_w是目标权重数组,rebalance_days=30表示每30天重置一次。这里为了简化,把调仓成本假设为0,actual_w每天都按收益率变化后重新归一化。实际运行时你会发现,两次再平衡之间的权重漂移方向通常是一致的,比如牧原上涨快则权重自动放大,等于变相追涨,这也是周期再平衡的效果之一。
5.2 把交易成本写进回测
交易成本直接影响再平衡频率。标准做法是把成交额乘以一个成本率,例如双边0.1%:
def backtest_with_cost(prices, target_w, rebalance_days=30, cost_rate=0.001): n = len(prices) cash = 1.0 shares = np.zeros(len(tickers)) for i in range(n): price = prices.iloc[i] if i == 0 or i % rebalance_days == 0: target_value = cash / len(tickers) + shares * price new_shares = target_value * target_w / price trade_diff = new_shares - shares trade_value = (trade_diff * price).abs().sum() cash -= trade_value * cost_rate shares = new_shares daily_gain = (price / prices.iloc[i-1] - 1).dot(shares * price) if i > 0 else 0 cash += daily_gain return cash + (shares * prices.iloc[-1]).sum()这个回测没有模拟涨跌停,也没有处理日内最低价,但对比较不同rebalance_days足够了。注意target_value的计算方式并不精确,真实系统要按t+1价格执行,但作为框架已经能体现出成本对收益的侵蚀。我把成本率从0.0005调到0.002跑一遍,如果最大夏普组合的收益优势迅速消失,说明这个组合在扣费后不适合高频再平衡。
5.3 用方差比检验验证组合稳定性
最后一个小技巧:算出组合最优权重后,用不同起始日的滚动参数重复优化,看权重的排序是否频繁反转。例如每季度取前三年数据重新求一次最大夏普权重,把5支股票的权重画成堆叠面积图。如果某一支的权重在相邻两次优化中从15%跳到45%,说明优化解不稳定,应该考虑加l2正则化或缩小协方差的估计窗口。另一种做法是计算相邻两次权重向量的余弦相似度,低于0.7时就要警惕。这种验证不需要复杂公式,只需要一份Excel表或者几十行Python就能跑完。它能帮你区分哪些股票是数据窗口的偶然产物,哪些是跨时间段都在贡献收益的核心资产。
本文还有配套的精品资源,点击获取