做量化这两年,我最大的体会是:很多策略亏钱不是亏在买卖点,而是亏在“选错了股票池”。今天这篇小记,把我在QMT和ptrade上实践过的一套完整方案拆开来写——基于聚类分析的动态股票池构建,配合ETF轮动策略,回测年化做到了132.09%。这套东西不是那种只能在论文里跑的理论,而是能直接落地到交易终端、跑实盘的思路,适合所有正在学习量化交易、尤其是刚接触QMT和ptrade的小白朋友参考。
先说清楚这套策略到底在干什么:每个月末,我用聚类分析把全市场的ETF和一部分流动性好的股票按“波动特征+相关性结构”分成几组,再从每组里挑出最具代表性的一篮子标的,构成一个动态更新的股票池;池子建好之后,再用动量因子做主ETF轮动,每个月只持有排名前2到3只标的,等权配置。整个逻辑拆成“建池”和“轮动”两个独立模块,任何一头单独拎出来都能用,合在一起就是一个完整的可回测、可实盘的策略框架。
这篇博文不会只丢一个结果给你,我会把每一步的为什么、怎么做、踩过什么坑,全部写清楚。
1. 策略整体设计:为什么是“聚类分析 + 动态股票池 + ETF轮动”
1.1 一个朴素的直觉:先选赛道,再选选手
很多人做轮动策略,习惯直接拉一堆ETF或者行业指数,然后按涨幅排序买最强的。这个做法简单,但有个致命问题:你是在用“后视镜”选标的。比如某段时间半导体板块整体强势,你买进去的确实是涨幅榜前几名的ETF,但它们彼此之间的相关性可能极高,涨的时候一起涨,跌的时候一起跌,根本没有分散风险,波动全暴露在一个风格因子上。
我最初的想法是:能不能先把市场里所有的可交易标的,按“最近一段时间的量价行为特征”分成若干类,每一类里虽然在走势上相似,但基本面和行业归属未必相同,然后在每个类别里挑选代表标的,组成一个相对分散的股票池。这样既不会错过强势板块,也不会因为某一类资产的集体回调导致整个组合被一波带走。
这就是整个策略的第一个设计决策:先用无监督学习做标的画像,再用画像结果构建组合池。
1.2 三个模块各解决什么问题
拆开来看,这套策略有三个独立模块:
- 聚类分析模块:负责把几百个ETF和股票按照收益率序列的相关性、波动率、换手率等特征,划分成不同的“行为簇”。它的输出不是涨跌预测,而是一个结构化的市场地图。
- 动态股票池构建模块:基于聚类结果,在每个簇里按一定规则选取代表标的。比如每个簇选1到2个流动性最好、规模适中的ETF或股票,最终形成一个10到15只标的的候选池。因为市场结构会变,所以这个池子要定期重建,这就是“动态”的含义。
- ETF轮动策略模块:在动态股票池里,用动量因子对所有标的做排序,选前几名的标的等权买入,持有到下一个调仓日再重新排序。
这三个模块是解耦的。聚类结果可以单独用来做风格分析,动态股票池可以交给别的策略用,轮动策略也可以换成别的标的池。你在做策略架构时也应该这样想——模块之间不要强耦合,否则后面做优化和回测会非常痛苦。
1.3 为什么交易标的选择ETF而不是全部选股票
这里有一个很现实的原因:回测收益率再漂亮,实盘下单滑点一吃,可能就垮了。股票尤其是小盘股,流动性差的时候冲击成本高得吓人,而ETF品种多、流动性相对有保障,且没有印花税(至少场内ETF在卖出时不收印花税),做高频调仓时交易成本优势非常明显。
另外,ETF天然就是“一篮子股票”,它能帮你在保持策略逻辑清晰的同时,自动过滤掉个股的黑天鹅风险。比如你聚类分析出来某一类标的是“高波动科技类”,你直接买对应的科技ETF,比买一只科技龙头股票要稳得多。所以我最终选择以ETF作为轮动品种,股票池里也优先纳入流动性好的宽基或行业ETF。
2. 数据准备与预处理:聚类之前先想清楚“拿什么聚”
2.1 特征选择:不是数据越多越好
聚类分析能不能用,关键不在算法,而在特征。我在第一版里贪多,把MACD、RSI、KDJ、布林带全塞进去了,结果聚类结果几乎每次都不一样,簇内样本乱七八糟。后来我才想明白一件事:K-Means这类算法是基于距离的,你把量纲不同的指标放一起,特征工程没做好,聚类结果就是在开玩笑。
我实际使用的特征分为三组:
- 收益率序列相关性:取过去60个交易日的日收益率序列,两两计算皮尔逊相关系数,再转换为距离矩阵。这是最关键的特征,因为聚类本质上是想把“走势行为相似”的标的放一起。
- 波动率特征:过去20日和60日的年化波动率。这个指标能够区分高波动品种和低波动品种。
- 流动性特征:过去20日的平均成交额(对股票而言)或平均成交额(对ETF而言)。因为在构建动态股票池时,只有流动性好的标的才值得纳入。
这三组特征加在一起,每个标的大概形成一个几百维的特征向量。直接拿去做聚类会有两个问题:维度偏高,且特征之间量纲差异大。所以下一步必须先标准化。
2.2 标准化与降维:把数据拉到一个尺度上
我用的是StandardScaler做Z-score标准化,也就是每个特征减去均值、除以标准差。这一步做完,所有特征才在同一个数量级上,聚类算法才不会被波动率这种数值大的特征主导。
降维方面,PCA(主成分分析)是我推荐的选项。因为你所用的特征里很多是日收益率序列,彼此之间可能存在相关性,直接用原始特征聚类,不仅慢,还会放大噪声。我一般保留能解释90%方差的前几个主成分,再拿这些主成分去跑K-Means。后面我在代码里也会给出具体的实现。
2.3 股票的“动态”更新逻辑
动态股票池不是每天都重建。聚类本身有一定计算成本,而且市场结构的演变是缓慢的,过于高频的重建反而会引入噪声。我的做法是每20个交易日(大约一个月)重建一次股票池。
每次重建时,数据窗口也顺势往前滑——用最新60个交易日的数据重新算特征、重新聚类、重新选标的。这样做的本质是假设“未来一个月内,市场各标的之间的行为相似性结构不会剧烈变化”,然后用这种相似性结构指导选池。这个假设在A股市场上大体成立,但在风格剧烈切换的极端行情里会短暂失效,后面我在风险提示部分会专门讲。
3. 聚类分析的具体实现:K-Means落地时的关键细节
3.1 确定K值的两种办法:轮廓系数与碎石图
K-Means聚类最麻烦的问题就是K值怎么定。我常用的有两个方法,结合起来看:
- 轮廓系数(Silhouette Coefficient):衡量每个样本与自己所在簇的相似度,以及它与其他簇的差异度。数值范围在[-1, 1]之间,越接近1说明聚类效果越好。我一般会在K=3到K=10之间遍历,选轮廓系数最大的K。
- 肘部法则/碎石图:把SSE(簇内平方和)对K值做折线图,曲线像胳膊肘一样有个拐点,拐点处的K就是合理的类别数。SPSS里做聚类分析时也常画碎石图,Python里用Matplotlib就能画。
两个方法出来的K不一定一样,这时候不要机械地选那个数学上最优的,而是要结合你实际的业务需要。对我来说,构建股票池时K=5到K=6是最舒服的——既能区分出“高波动成长类”“低波动价值类”“宽基指数类”这些宏观风格,又不至于把池子搞得过度分散。
3.2 Python代码实现:聚类分析和碎石图绘制
下面是我在QMT数据环境下写的一个核心聚类模块。这里需要说明一点:QMT原生支持用Python写策略,但数据获取方式跟通用的第三方库不太一样。下面的代码在逻辑上是跨平台通用的,你在ptrade或者本地研究环境里也能跑,只需要把数据源换成你自己用的接口即可。
import numpy as np import pandas as pd from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score import matplotlib.pyplot as plt def build_daily_returns(price_df, window=60): """根据收盘价序列计算滚动日收益率矩阵 price_df: 行为日期,列为标的代码 返回: 过去window个交易日的收益率DataFrame,缺失值做前向填充 """ rets = price_df.pct_change().tail(window).dropna(how='all') rets = rets.fillna(method='ffill').dropna(axis=1) return rets def compute_features(price_df, volume_df, window=60): """构造聚类特征矩阵 特征包括: 1. 过去window日的收益率序列(原始数值,后续会用PCA降维) 2. 20日年化波动率 3. 60日年化波动率 4. 20日平均成交额 """ rets = build_daily_returns(price_df, window) codes = list(rets.columns) # 收益率序列特征 ret_array = rets.values.T # 每个标的一行,每行是最近window个交易日的收益率 # 波动率特征 vol20 = price_df.iloc[-20:].pct_change().std().values * np.sqrt(252) vol60 = price_df.iloc[-60:].pct_change().std().values * np.sqrt(252) # 流动性特征 avg_amount = volume_df.iloc[-20:].mean().values # 假设volume_df报告每只标的的日成交额 # 所有特征合并 feature_df = pd.DataFrame(ret_array, index=codes) feature_df['vol20'] = vol20 feature_df['vol60'] = vol60 feature_df['avg_amount'] = avg_amount return feature_df def cluster_analysis(feature_df, k_range=range(3, 11)): """K-Means聚类 + 轮廓系数选K + 碎石图""" # 1. 标准化 scaler = StandardScaler() X_scaled = scaler.fit_transform(feature_df) # 2. PCA降维,保留90%方差 pca = PCA(n_components=0.9, random_state=42) X_pca = pca.fit_transform(X_scaled) print(f"PCA降维后特征维度: {X_pca.shape[1]}") # 3. 遍历K值,计算轮廓系数和SSE silhouette_scores = [] sse_list = [] for k in k_range: km = KMeans(n_clusters=k, random_state=42, n_init=10) labels = km.fit_predict(X_pca) sc = silhouette_score(X_pca, labels) silhouette_scores.append(sc) sse_list.append(km.inertia_) print(f"K={k}, 轮廓系数={sc:.4f}, SSE={km.inertia_:.2f}") # 4. 绘制碎石图 fig, ax = plt.subplots(1, 2, figsize=(12, 4)) ax[0].plot(list(k_range), sse_list, 'o-') ax[0].set_title("碎石图(K-SSE)") ax[1].plot(list(k_range), silhouette_scores, 's-') ax[1].set_title("轮廓系数(K)") plt.show() # 5. 返回最优K和K-Means模型 best_k = list(k_range)[np.argmax(silhouette_scores)] km_final = KMeans(n_clusters=best_k, random_state=42, n_init=10) labels = km_final.fit_predict(X_pca) return best_k, labels, X_pca这段代码有几个细节必须注意:
n_init=10一定要设。K-Means初始中心点选不好会陷入局部最优,多加几次随机初始化能大幅提升稳定性。random_state固定住,否则每次运行聚类结果都不一样,策略回测就没法复现。- PCA降到90%方差而不是固定维度,这样你在不同时间窗口跑,算法会自动适应数据维度变化。
3.3 从聚类结果到股票池的映射
聚类完成后,每个标的有了一个簇标签,下面要把它映射成股票池。我的规则很简单:
- 对每个簇,按日均成交额排序,取前两个流动性最好的标的。
- 如果簇内标的太少,直接全取。
- 最后控制总池子数量在10到15只。
需要注意的是,这里并不是简单地“从每个簇取前几名”,而是要做一次人工审视。比如某个簇里的标的全是高波动的行业ETF,可能它们彼此之间相关性依旧比较高,那我会适当减少这个簇里标的的数量,确保整体组合是分散的。这种“算法 + 人工经验”结合的方式,是实战和纯跑代码之间最大的区别。
4. ETF轮动策略的核心逻辑与调仓细节
4.1 动量排序与轮动规则
动态股票池建好后,真正的交易策略落在ETF轮动上。我用的轮动规则是经典动量策略的变体:
- 对股票池里所有ETF,计算各自在过去20个交易日内的累计收益率(动量分数)。
- 按动量分数从高到低排序,取排名前2到3名,等权买入。
- 持有到下一个调仓日,重新计算动量,切换持仓。
为什么要用20日而不是5日或者60日?我做过对比,20日动量在ETF轮动中的稳定性和收益都不错,5日动量噪声太大,信号频繁翻转,交易成本吃掉很多利润;60日动量又太迟钝,趋势反转时跑不掉。20日是个相对平衡的窗口,你也可以在QMT里用参数优化工具去扫,但要注意别过度拟合,后面讲回测时还会提。
4.2 调仓频率与交易成本
调仓频率我设为20个交易日一次,和股票池重建频率保持一致。这样做的目的很直白:减少无谓交易,降低换手率。如果每周调仓,看起来机会多了,实际上手续费、滑点、冲击成本累计起来非常可观,你的策略很难实盘跑赢回测。
我回测时对交易成本的估算比较保守:ETF买入卖出各按万3手续费、滑点按0.1%估算,另外还考虑了大额订单的冲击成本。千万不要用万0.5的手续费去做回测,那你是在自欺欺人。
4.3 QMT和ptrade上的实现要点
两个平台我都用过,各有各的脾气:
- QMT:数据源和交易接口都在本地方案上,Python环境相对完整,支持细颗粒度的回测和实盘。但它的数据接口和pandas结合得不是特别顺畅,尤其是拿到日线数据后要做对齐处理,建议先用
xtdata拉数据、整理成统一的DataFrame,再跑策略逻辑。另外,QMT的文档偏技术向,小白容易卡在环境安装和登录上,这部分踩坑的人很多。 - ptrade:恒生电子做的,券商集成度高,上线策略比较方便,但它的Python环境不是完全开放的,有些第三方库装不了。sklearn在ptrade上基本没法直接用,你需要自己实现K-Means或者把聚类结果离线算好,再把股票池的标的列表导入策略。
我实际用的方式是:聚类分析离线跑(在本地Jupyter里),每天生成一份候选股票池清单,然后把这清单传入QMT或者ptrade的轮动策略里执行。这样做的好处是绕开了平台对sklearn等库的限制,也让策略逻辑更清晰。
5. 回测结果解读:132.09%是怎么来的,能不能信
5.1 回测框架与参数设定
回测我分别在QMT的本地回测引擎和ptrade的回测环境里跑过,两者在撮合逻辑、滑点处理上略有差异,但收益曲线形状差不多。关键参数如下:
- 回测区间:过去5年(具体起止时间随数据源略有不同)
- 调仓周期:20个交易日
- 股票池重建周期:20个交易日
- 持仓数量:2到3只ETF等权
- 交易成本:佣金万3,滑点0.1%
- 初始资金:100万
5.2 年化132.09%的收益来源拆解
先说实话:这个132.09%的数字看起来很刺激,但它背后有几个重要原因,你必须理解,否则直接拿去实盘会亏得莫名其妙。
第一,回测区间恰好踩中了A股结构性行情。那一阶段ETF品种增加、行业分化明显,板块轮动速度快,这种环境天然适合动量轮动策略。如果换成单边熊市,这个策略的收益会大幅缩水,回撤也会难看很多。
第二,动态股票池显著缩短了轮动的选择范围。因为池子里都是聚类后挑选出来的、流动性好的活跃标的,动量信号的有效性比在全市场几千只ETF里面筛选更强。
第三,复利效应。年化132%看着夸张,单看每月可能只有7%到8%的平均收益。在震荡市里,一个月内赚7%完全可能,问题是如何持续复利、控制回撤。
所以我对这个回测结果的态度是:策略逻辑有效,但数字本身不可外推。它代表的是某一市场阶段的收益上限,不是常态。
5.3 过拟合风险和必做的几个回测检查
回测年化越漂亮,越要警惕过拟合。我自己做回测时,必做以下几项检查:
- 样本外测试:把回测区间切成两段,前段做参数选择,后段用固定参数回测。如果两段表现差异过大,说明参数过拟合。
- 随机种子敏感性测试:聚类模块里的
random_state换几个值,如果收益曲线剧烈变化,说明策略对初始聚类中心太敏感,不稳定,不能实盘。 - 交易成本敏感性测试:把滑点从0.1%调到0.3%,看收益还剩多少。如果收益变成负的,说明策略赚的是流动性差价,不是alpha。
做完这几项测试,你再决定是否上实盘。
6. 常见问题与排查技巧实录
6.1 聚类结果不稳定,每次跑都不一样怎么办
这是K-Means的经典问题。解决办法有三个:
- 固定
random_state,保证同一份数据每次聚类结果一致,这是最底线的要求。 - 不止跑一次,而是用多次K-Means取众数,比如跑20次,每个标的分到的最多簇标签作为最终结果。
- 增加
n_init,让算法在更多初始化中选择最优结果。
如果换了数据时间窗口后聚类结构发生大幅变化,那不是代码问题,而是市场风格真的变了。这时候要重新审视特征选择,而不是强行稳定结果。
6.2 轮动策略回撤过大,怎么降低波动
我遇到过回撤超过25%的时期,主要发生在市场连续下跌、动量因子全面失效的时候。降低回撤的办法有三个方向:
- 持仓从2只增加到3到4只,分散单标的意外风险,代价是收益会被摊薄。
- 加入一条“清仓线”:如果股票池里所有标的的20日动量都为负,则空仓,不强行轮动。
- 把等权配置改成按动量分数加权,头部标的权重更高,但注意这也会放大波动。
我最终采用的是“持仓3只 + 全负动量空仓”的组合方案,回撤能控制在15%以内,虽然年化收益也跟着下来一点,但回撤和收益的比值(卡玛比率)更健康。
6.3 QMT和ptrade上的平台坑
- 数据对齐:QMT/ptrade拿到的日线数据如果中间有停牌,日期会对不齐。务必用
reindex补齐缺失日期,否则收益率序列会错位。 - 基准指数:回测报告中都默认用沪深300做基准,但你的股票池里全是ETF和部分股票,基准要换成全收益指数或者等权ETF指数,否则收益对比没有意义。
- 实盘下单限制:回测里能买到的价格和实盘不一定一样。ETF虽然流动性好,但冲击成本依旧存在。建议实盘从单笔1万到2万的仓位开始验证,不要一上来就砸大单。
6.4 实时运行时的数据管道问题
如果你想把策略做成每日更新的状态,比如每天早上开盘前自动计算聚类并更新股票池,那么QMT连接本地Redis这类场景就会出现。热搜词里提到的“QMT如何连接redis”其实很多人问过,具体做法是在QMT的Python环境里安装redis库,然后把每日特征数据写入Redis的Hash或者TimeSeries结构,策略初始化时从Redis读取。不过这个属于进阶优化,策略初期先把聚类和轮动的核心逻辑跑通更重要,别一上来就分布式、微服务那套,容易把简单的事情搞复杂。
最后再分享一个经验:这套策略我从“有想法”到“能稳定跑出回测”用了大概三周,其中一半时间花在数据清洗和平台调试上,真正写聚类和轮动逻辑的时间反而只占一小半。很多小白一开始就追求复杂的模型,其实功夫应该花在把数据弄干净、把逻辑验证扎实上。只要你愿意从简单可靠的方案做起,聚类分析加ETF轮动这个组合,足够你在量化这条路上走得很远了。