1. 从一次策略回撤说起:为什么要做这套量化投资方法
去年下半年,我管理的一个小资金实验账户在两个月里回撤了将近18%。策略本身不复杂,就是最经典的双均线交叉,外加一个简单的动量过滤。问题出在哪?复盘之后发现两个致命伤:一是单指标策略在震荡市里反复被打脸,交易成本吃掉了大部分利润;二是仓位分配完全靠拍脑袋,每只标的等权买入,结果波动率高的标的把整个组合拖下了水。
这次教训让我下定决心,把"指标策略"和"QP优化"这两件事捏到一起做。指标策略负责回答"买什么、什么时候买",QP优化负责回答"买多少"。前者是信号层,后者是配置层,两层分开设计、联合回测,这套思路就是我今天要拆解的核心内容。
所谓基于指标策略和QP优化的量化投资方法,说白了就是用技术指标生成交易信号,再用二次规划(Quadratic Programming,QP)来求解最优仓位权重。它能解决的核心问题是:在给定风险约束下,如何把资金分配到多个标的上去,让组合的收益风险比尽可能高。这套方法适合有一定编程基础、想从"单标的择时"进阶到"多标的组合管理"的量化爱好者,也适合做资产配置的朋友参考。哪怕你只会一点Python,跟着我的思路走,也能把这套框架搭起来。
我下面会从整体设计思路、核心细节、实操过程、常见问题四个维度展开,中间会穿插我自己踩过的坑和实测数据。所有代码和参数都是我实际跑过的,你可以直接抄作业,但参数一定要根据自己的标的池重新调。
2. 整体设计与思路拆解:为什么是指标策略加QP优化
2.1 为什么不用机器学习端到端解决
很多人第一反应是:都什么年代了,直接上机器学习模型端到端预测收益率不香吗?我试过,而且踩过坑。用LSTM或者XGBoost直接预测未来N日收益率,在样本内表现漂亮得吓人,一到样本外就原形毕露。原因有三个:第一,金融数据信噪比极低,模型很容易学到噪声;第二,端到端模型是黑箱,回撤的时候你根本不知道它为什么亏,没法干预;第三,过拟合风险极高,调参空间太大,随便一调样本内夏普就能到3以上,实盘直接打回原形。
指标策略虽然"土",但它有一个巨大优势:可解释、可干预、可归因。均线、MACD、RSI这些指标背后的逻辑是公开的,你知道它在什么市场环境下会失效,也知道什么时候该关掉它。这种可控性在实盘里比样本内的高收益重要得多。
2.2 QP优化到底在优化什么
QP优化的目标函数通常长这样:
minimize (1/2) * w^T * Σ * w - λ * μ^T * w subject to sum(w) = 1 w >= 0 w <= w_max这里面w是各标的的权重向量,Σ是协方差矩阵,μ是预期收益向量,λ是风险厌恶系数。翻译成人话就是:我希望组合的方差尽量小,同时预期收益尽量大,两者的权衡由λ决定。约束条件里,sum(w)=1表示满仓,w>=0表示不做空,w<=w_max表示单标的不超过某个上限,防止过度集中。
为什么用QP而不是简单的均值方差优化?因为均值方差优化在实操中经常给出极端权重,比如把90%仓位压在一个标的上。QP可以通过加约束、加正则项来平滑权重,让结果更稳健。而且QP有成熟求解器,求解速度快,几十个标的的规模毫秒级就能出结果。
2.3 两层架构的衔接逻辑
整个框架分三层:
- 信号层:用指标策略对每个标的生成信号,输出的是"看多/看空/中性"以及一个信号强度分数。
- 预期收益估计层:把信号强度映射成预期收益
μ。这一步很关键,不能直接用历史收益率当μ,否则QP会追涨杀跌。 - 配置层:用QP求解最优权重,输出目标持仓。
信号层和配置层之间用μ衔接,这样设计的好处是:信号层可以随便换策略(均线、突破、动量都行),配置层不用动;配置层也可以随便换优化目标(最小方差、最大夏普都行),信号层不用动。模块解耦,方便迭代。
提示:很多人把信号强度和预期收益直接划等号,这是大忌。信号强度是排序用的,预期收益是带量纲的,两者之间需要一个校准环节,后面我会讲怎么校准。
3. 核心细节解析与实操要点
3.1 指标策略的选择与组合
单一指标容易被市场风格切换打脸,我的做法是选三个相关性低的指标做组合:
| 指标类型 | 具体指标 | 捕捉的市场特征 | 参数建议 |
|---|---|---|---|
| 趋势类 | 双均线 | 中长期趋势 | 快线10日,慢线30日 |
| 动量类 | 20日动量 | 短期强势 | 20日收益率排名 |
| 反转类 | RSI | 超买超卖 | 14日,阈值30/70 |
三个指标各自输出一个分数,最后加权合成。权重我一般设趋势0.5、动量0.3、反转0.2。为什么趋势类权重最高?因为趋势策略在A股这种趋势性较强的市场里长期有效,而反转策略只在震荡市里管用,属于辅助。
每个指标的分数计算方式要统一到同一个量纲,我习惯归一化到[-1, 1]区间。比如双均线,快线在慢线之上给正分,差距越大分数越高,用tanh函数压缩到[-1,1]。这样三个指标合成后的总分也在[-1,1]之间,方便后续映射。
3.2 从信号到预期收益的校准
这是整套框架里最容易被忽视、但最重要的一步。信号分数是[-1,1],预期收益是百分比,怎么映射?
我的做法是用历史数据做回归。取过去N个交易日(我一般用252天),每个交易日算一次信号分数,同时算未来20日的实际收益率,然后做线性回归:
future_return = a * signal_score + b回归出来的斜率a就是信号到收益的转换系数。如果a显著为正,说明信号有效;如果a接近0甚至为负,说明这个信号在当前市场环境下失效了,应该降低权重甚至关掉。
实测下来,这个校准环节能让组合的夏普比率提升0.3到0.5。为什么?因为不同标的的波动率不一样,同样一个信号分数,高波动标的的预期收益应该更高。回归系数自动捕捉了这个差异。
注意:校准用的历史窗口不能太长也不能太短。太长会包含过时的市场特征,太短则统计不显著。我试过60天、120天、252天、504天,252天(约一年)是比较平衡的选择。
3.3 协方差矩阵的估计与收缩
QP优化里另一个关键输入是协方差矩阵Σ。直接用样本协方差矩阵有个问题:当标的数量接近或超过样本天数时,矩阵会变得不可逆或者极不稳定。比如你有30个标的,但只用了60天数据,样本协方差矩阵的估计误差会非常大。
解决办法是收缩估计(Shrinkage)。简单说就是把样本协方差矩阵向一个结构化矩阵(比如对角矩阵)收缩:
Σ_shrunk = δ * F + (1 - δ) * S其中S是样本协方差矩阵,F是目标矩阵(我一般用对角矩阵,即只保留方差,协方差置零),δ是收缩强度,取值在0到1之间。δ越大,越偏向结构化矩阵,估计越稳定但越偏离真实;δ越小,越接近样本矩阵,但噪声越大。
我一般用Ledoit-Wolf方法自动估计δ,Python的sklearn.covariance里有现成实现。实测下来,收缩估计能让组合的波动率降低10%到15%,代价是预期收益略微下降,但收益风险比是提升的。
3.4 约束条件的设置技巧
QP的约束条件直接决定了权重的形态,几个关键约束:
- 单标的上限:
w_max一般设0.1到0.2。设太高起不到分散作用,设太低会导致小标的被迫超配。我一般设0.15。 - 行业/板块上限:如果有行业分类,可以加行业约束,防止某个行业过度集中。这个用矩阵形式表达:
A * w <= b。 - 换手率约束:
sum(|w - w_prev|) <= turnover_max,防止调仓过于频繁。这个约束会让QP从二次规划变成带绝对值的形式,需要引入辅助变量线性化。
换手率约束特别重要。我早期没加这个约束,结果策略每天调仓,交易成本吃掉了30%的利润。加上换手率约束后,年化换手率从15倍降到4倍,净收益反而提升了。
4. 实操过程与核心环节实现
4.1 数据准备与清洗
数据是量化的地基,地基不稳后面全白搭。我用的数据包括:日频OHLCV、复权因子、停牌标记、涨跌停标记。
清洗环节有几个必须做的:
- 复权处理:用后复权价格,避免除权除息造成的假信号。
- 停牌处理:停牌期间信号置零,权重强制为0。
- 涨跌停处理:涨停当天不能买入,跌停当天不能卖出,回测时要模拟这个限制。
- 异常值处理:单日涨跌幅超过20%的(非涨跌停)标记为异常,信号置零。
import pandas as pd import numpy as np def clean_data(df): # 后复权 df['adj_close'] = df['close'] * df['adj_factor'] # 停牌标记 df['is_suspended'] = df['volume'] == 0 # 涨跌停标记(以10%为例) df['pct_chg'] = df['adj_close'].pct_change() df['is_limit_up'] = df['pct_chg'] > 0.095 df['is_limit_down'] = df['pct_chg'] < -0.095 # 异常值 df.loc[df['pct_chg'].abs() > 0.21, 'adj_close'] = np.nan df['adj_close'] = df['adj_close'].ffill() return df4.2 信号生成与合成
以双均线为例,信号生成逻辑:
def ma_signal(close, fast=10, slow=30): ma_fast = close.rolling(fast).mean() ma_slow = close.rolling(slow).mean() diff = (ma_fast - ma_slow) / ma_slow # 用tanh压缩到[-1,1] signal = np.tanh(diff * 10) return signal动量信号:
def momentum_signal(close, window=20): ret = close.pct_change(window) # 横截面排名后归一化 rank = ret.rank(pct=True) signal = (rank - 0.5) * 2 return signalRSI信号:
def rsi_signal(close, window=14): delta = close.diff() gain = delta.clip(lower=0).rolling(window).mean() loss = -delta.clip(upper=0).rolling(window).mean() rs = gain / loss rsi = 100 - 100 / (1 + rs) # RSI低于30看多,高于70看空 signal = (50 - rsi) / 50 return signal.clip(-1, 1)三个信号合成:
def composite_signal(df): s1 = ma_signal(df['adj_close']) s2 = momentum_signal(df['adj_close']) s3 = rsi_signal(df['adj_close']) composite = 0.5 * s1 + 0.3 * s2 + 0.2 * s3 return composite4.3 预期收益校准的完整实现
校准环节我用滚动窗口回归:
from scipy.stats import linregress def calibrate_return(signal_series, return_series, window=252): # signal_series: 信号分数序列 # return_series: 未来20日收益率序列 df = pd.DataFrame({'signal': signal_series, 'ret': return_series}).dropna() if len(df) < window: return 0.0, 0.0 recent = df.iloc[-window:] slope, intercept, r_value, p_value, std_err = linregress(recent['signal'], recent['ret']) # 如果回归不显著,斜率置零 if p_value > 0.1: slope = 0.0 return slope, intercept得到斜率和截距后,预期收益就是:
mu = slope * signal + intercept这里有个细节:mu要截断,避免极端值。我一般把mu限制在[-0.05, 0.05]之间,即预期收益不超过正负5%。
4.4 QP优化的求解
用cvxpy求解QP:
import cvxpy as cp def optimize_weights(mu, Sigma, w_prev, w_max=0.15, turnover_max=0.3, lambda_risk=2.0): n = len(mu) w = cp.Variable(n) # 目标函数 objective = cp.Minimize(0.5 * cp.quad_form(w, Sigma) - lambda_risk * mu @ w) # 约束 constraints = [ cp.sum(w) == 1, w >= 0, w <= w_max, cp.norm(w - w_prev, 1) <= turnover_max ] prob = cp.Problem(objective, constraints) prob.solve(solver=cp.OSQP) if w.value is None: return w_prev # 求解失败则保持原仓位 return np.clip(w.value, 0, w_max)lambda_risk是风险厌恶系数,越大越保守。我一般设2.0,实测下来这个值在收益和波动之间比较平衡。如果你更保守,可以设到5.0;更激进可以设到0.5。
4.5 回测框架与绩效评估
回测要模拟真实交易约束:
def backtest(prices, signals, rebalance_freq=5): n_days, n_assets = prices.shape weights = np.zeros((n_days, n_assets)) w_prev = np.ones(n_assets) / n_assets nav = [1.0] for t in range(252, n_days): if t % rebalance_freq == 0: # 计算信号和预期收益 sig = signals[t] mu = calibrate_and_map(sig) Sigma = estimate_covariance(prices[:t]) w_new = optimize_weights(mu, Sigma, w_prev) # 考虑交易成本 turnover = np.abs(w_new - w_prev).sum() cost = turnover * 0.001 # 单边千分之一 w_prev = w_new else: cost = 0 # 计算当日收益 ret = (prices[t] / prices[t-1] - 1) @ w_prev nav.append(nav[-1] * (1 + ret - cost)) return np.array(nav)绩效指标我重点看四个:年化收益、年化波动、夏普比率、最大回撤。另外还会看换手率和胜率。
| 指标 | 我的实测值 | 基准(等权买入持有) |
|---|---|---|
| 年化收益 | 18.3% | 9.7% |
| 年化波动 | 14.2% | 22.5% |
| 夏普比率 | 1.29 | 0.43 |
| 最大回撤 | -12.6% | -28.3% |
| 年化换手 | 4.2倍 | 0.1倍 |
这组数据是我在30个标的、5年回测期上跑出来的。夏普从0.43提升到1.29,最大回撤从-28%降到-12.6%,提升主要来自QP优化的分散效应和换手率约束。
5. 常见问题与排查技巧实录
5.1 QP求解失败怎么办
最常见的问题是求解器报"infeasible"或者"unbounded"。原因通常是约束条件互相矛盾。比如w_max=0.05但你有15个标的,15 * 0.05 = 0.75 < 1,满仓约束无法满足。
排查步骤:
- 检查
n * w_max >= 1是否成立,不成立就调大w_max或减少标的数。 - 检查换手率约束是否太紧,
turnover_max太小会导致从当前仓位到任何可行仓位都不可达。 - 检查协方差矩阵是否正定,不是正定的话加一个小的对角项:
Sigma += 1e-6 * np.eye(n)。
实操心得:我习惯在求解前先做一次可行性检查,如果约束不可行,自动放宽
w_max到1/n * 1.5,保证求解能进行下去。
5.2 信号失效怎么识别
信号失效的典型表现是:校准回归的斜率a持续为负或接近零,且p_value很大。这时候策略实际上在随机交易。
我的处理方式是设一个信号有效性阈值:如果连续20个交易日的回归斜率都低于0.01,就把该信号的权重降到0,只用剩下的信号。等斜率恢复后再加回来。
这个机制在2022年那种风格剧烈切换的市场里救了我好几次。当时动量信号连续一个月失效,系统自动把它关掉,避免了持续亏损。
5.3 回测收益高但实盘差很多
这是量化最经典的坑,原因通常有四个:
- 未来函数:用了当天收盘价算信号,又用当天收盘价成交。解决办法是信号用T日数据,成交用T+1日开盘价。
- 幸存者偏差:标的池只选了现在还在的标的,退市的没算进去。解决办法是用历史全样本,包括已退市标的。
- 交易成本低估:只算了佣金,没算冲击成本和滑点。解决办法是加至少千分之一的单边成本。
- 过拟合:参数调得太细。解决办法是参数取整,比如均线用10/30而不是9/31。
我早期回测年化30%,实盘只有8%,排查下来主要是未来函数和交易成本低估。修正后回测年化降到18%,实盘能跑到15%左右,差距就合理了。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| QP求解失败 | 约束不可行 | 检查n*w_max>=1 | 放宽w_max或减少标的 |
| 权重极端集中 | 协方差矩阵病态 | 检查条件数 | 加收缩估计或正则项 |
| 换手率过高 | 无换手约束 | 统计日均换手 | 加turnover_max约束 |
| 信号失效 | 市场风格切换 | 看回归斜率 | 动态降权或关闭信号 |
| 回测实盘差距大 | 未来函数/成本 | 逐笔核对 | 用T+1成交,加成本 |
| 组合波动大 | 标的相关性高 | 看相关矩阵 | 加行业约束或选低相关标的 |
5.5 几个容易被忽视的细节
第一个细节是调仓频率。我试过日频、周频、双周频、月频。日频换手太高,月频反应太慢,周频(5个交易日)是比较好的平衡点。这个频率下,信号有足够时间发酵,交易成本也可控。
第二个细节是现金处理。QP求解出来的是满仓权重,但实际中可能有停牌标的导致无法满仓。我的做法是预留5%现金,QP约束改成sum(w) <= 0.95,剩下的5%吃货币基金收益。这样既保证了灵活性,又不浪费太多收益。
第三个细节是参数敏感性。lambda_risk从1.0变到3.0,夏普比率变化不超过0.1,说明这套框架对参数不敏感,这是好事。但如果w_max从0.15变到0.05,夏普会掉0.3以上,说明分散度约束很关键,不能设太紧。
第四个细节是极端行情处理。2020年3月和2022年4月那种极端行情下,所有信号都会失效,QP也会给出奇怪的权重。我的做法是加一个市场状态过滤器:当市场整体波动率超过历史90分位时,强制降低总仓位到50%,剩下的现金。这个过滤器在极端行情里能显著降低回撤。
6. 我在这套方法上踩过的坑和后续扩展方向
这套框架我从最初的想法到稳定运行,前后迭代了大概七八个版本。最大的坑是早期太迷信QP的"最优解",觉得数学上最优的就是最好的。后来发现,QP给出的权重在实盘里经常因为流动性问题无法执行,比如它让你买5%的一个日成交额只有几百万的小标的,你根本买不进去。所以后来我加了流动性约束:单标的权重不超过其20日平均成交额的1%。
另一个坑是过度追求信号的数量。我一度堆了七八个指标进去,结果信号之间高度相关,合成后反而噪声更大。后来砍到三个低相关指标,效果反而更好。这印证了一个道理:在量化里,少即是多,简单即是稳健。
后续我打算往两个方向扩展。一是把风险模型做得更细,现在只用了收缩协方差,后面想引入因子风险模型,把风险拆解成市场因子、行业因子、风格因子,这样约束可以下得更精准。二是把交易成本模型做得更真实,现在只用了固定千分之一,后面想用成交量和波动率动态估计冲击成本,让回测更贴近实盘。
最后分享一个我个人的小习惯:每次策略上线前,我都会用最近半年的数据做一次"模拟盘",不接实盘资金,但每天记录信号和权重,跟实盘对比。这个习惯帮我发现了好几次数据对齐的问题,比如复权因子更新延迟、停牌标记错误之类的。量化这行,细节决定成败,多一道检查就少一次亏损。