先把结论放在前面:这个策略本身不是新东西,ETF轮动是老玩法,k-means也不是高深算法,但把两者结合做成“动态股票池”,对散户做量化来说,是个性价比极高的切入点。我一向反对一上来就上深度学习、强化学习那套,数据量不够、过拟合严重、实盘还跑不动。反而是k-means这种无监督方法,用在ETF这种“数量有限、逻辑清晰、流动性好”的标的上,既能把规则讲明白,又能在QMT和ptrade这类平台里稳定落地。
这篇文章我会按照一个完整的实战路径来写:先理清策略到底解决什么问题,再做平台选型,然后是数据清洗、特征构造、聚类实现,最后落到动态股票池的生成和调仓逻辑上。每一步都会写清楚为什么这么做,以及我踩过的坑。
1. 先想清楚再写码:这个策略到底要解决什么问题
1.1 为什么是“核心轮动”
做ETF轮动的人很多,但大部分人的做法比较朴素:拉出全市场几百只ETF,算一下动量或者均线关系,谁强买谁。这个思路没什么错,但有个致命隐患——ETF之间高度相关。你看着选了十只“强势”ETF,实际上可能都是在同一轮半导体行情里跳舞的兄弟,持仓分散了个寂寞。
“核心轮动”这个词,我理解的重点在“核心”两个字。它不是说让你把全市场ETF都抓进来排序,而是先把标的池缩到一个相对有限的、能代表市场主要风格的集合里。比如宽基指数类的、金融地产类的、消费类的、医药类的、科技成长类的、新能源类的、周期资源类的,每个方向选一两只代表性ETF,加起来大概十几只到二十只。这个池子就是“核心池”。
那为什么核心池还需要动态调整?因为风格会切换。2021年消费医药是核心,2023年变成了AI和红利,2024年又轮到高股息和科技。如果你把池子固化,几年不换,那策略实际上是在跟市场风格较劲。所以需要一种方法,定期帮你看清楚:当前这段时间,市场里到底哪些ETF在抱团上涨、哪些在阴跌、哪些在震荡。这就是聚类的用武之地。
1.2 动态股票池和静态池的本质区别
静态池就是一次性把标的定死,后面所有的轮动都在这个池子里选。动态池则是每隔一段时间重新审视一次,让新的强势标的进入视野,把走坏的标的踢出去。
用k-means做动态池,本质上是做三件事:
- 定期对核心ETF池做聚类,把走势形态相似的ETF分成一组。
- 各组内部选出“代表性ETF”,代表这一种市场风格。
- 每一期只在少数几个“当前强势风格”的代表里做仓位分配。
这样一来,池子的大小不变,但池子里面的成员会变,权重也会变。你买的还是ETF,不会踩到个股的黑天鹅,但你的持仓结构会跟着市场风格走,这就是“核心轮动”的意义。
很多人把动态池想复杂了,觉得是不是要搞什么复杂的优化模型。实际上k-means在这里起的作用就是一个“分诊台”,它不预测涨跌,它只负责告诉你:现在市场上有哪几类走势、你手头的ETF属于哪一类。至于哪一类值得买,那是后续动量或者趋势规则的事。
2. 平台选型与回测框架怎么搭
2.1 QMT和ptrade的真实差异
标题里同时出现了QMT和ptrade,大概率是读者在纠结选哪个平台。我可以直接说结论:如果你的策略逻辑已经在本地用数据验证过了,只是需要一个执行和实盘托管的平台,这两个都能干,关键是看你的资金量和券商支持情况。
从架构上讲,这两个平台的定位不同。QMT更偏向专业的程序化交易终端,支持VBA和Python,数据落地本地,回测和实盘都可以在客户端里搞定。它的优点是对交易细节的控制粒度更细,比如你可以在策略里直接操作订单、撤单、查持仓,盘中定时任务这些做起来也顺手。缺点是学习曲线稍微陡一些,刚上手时容易在窗口初始化、账号绑定、合约代码这些环节卡住。
ptrade则更偏向“轻量级策略编写”,Python环境内置了很多现成的接口,比如定时任务、行情订阅、下单函数,写法更接近我们平时写脚本的习惯。它对新手友好,尤其适合做日线级、周线级的低频策略。缺点是灵活性不如QMT,如果你要做高频、或者特别复杂的订单管理,会感觉受限。
我个人的建议是:做ETF轮动这种日线级别的策略,两个平台都绰绰有余。如果你的券商只支持其中一个,就用那个。如果两个都支持,那我建议你先在本地用Python把策略完整跑通,再移植到平台上去。因为不管是QMT还是ptrade,它们的回测环境都会有一些“黑盒子”特征,不如jupyter里自己写的代码透明。
2.2 回测的坑:前视偏差与手续费
平台选好之后,第一件事不是写策略,而是检查回测设置。我在QMT里第一次跑ETF策略时,就踩过前视偏差的坑——策略会在当天收盘信号出现后,使用当天收盘价成交。看着收益曲线特别漂亮,实盘一跑就变样。
正确的做法是:信号在收盘后产生,持仓调整在次日开盘执行。也就是说,你用的是昨日的聚类结果和动量数据,交易的是今日的开盘价。这样虽然会损失一点点信号时效性,但不会引入未来数据。
手续费也别忽视。ETF的佣金通常很低,但如果你每个月调仓一次甚至每周调仓,累计下来也不少。回测时我一般设置双边万三的手续费,再叠加一个滑点0.001,这样实盘出入不会太大。别小看这个滑点,ETF流动性好,但你想一次性买入几十万时,冲击成本是真实存在的。
3. 数据清洗与特征构造:聚类吃的是“特征”,不是K线
3.1 用哪些数据、怎么清洗
做ETF聚类,推荐的数据是日线级别的收盘价、成交额和成交量,不需要分钟数据。原因是聚类和轮动逻辑本身就偏中低频,用日线足够,分钟数据反而会引入很多噪声,还拖慢计算速度。
清洗这一步很多人会偷懒,但这一偷懒后面全是坑。我一般会做这么几件事:
- 剔除上市时间不足120个交易日的ETF,次新品短期数据不稳定,聚类很容易被带偏。
- 剔除日均成交额低于3000万的ETF,流动性太差的标的,实盘里你的下单会直接影响价格。
- 对停牌期间的数据做填充或者直接剔除该时间段。ETF停牌情况少,但真出现了,别留空值。
- 做复权处理。ETF有分红,虽然幅度不大,但聚类的输入如果是原始价格,分红会导致价格跳空,影响相似度计算。
我一直强调,聚类算法本身对输入数据很敏感,你喂给它的是垃圾特征,它聚出来的“规律”就是垃圾规律。数据清洗投入的时间,会在模型稳定性上加倍回报。
3.2 特征怎么造才有意义
很多人问我:为什么不能直接用收盘价序列做聚类?因为绝对价格水平不是走势特征。一只ETF价格是1块钱,另一只是2块钱,它们的走势可能完全一样,但直接放在欧式距离里算,相似度会被价格水平主导。
所以特征要经过标准化处理。我常用的特征组合是下面三组:
- 区间涨跌幅:过去20个交易日的累计涨跌幅,反映中期动量。
- 区间波动率:过去20个交易日收益率的标准差,反映风险特征。
- 成交额变化率:过去5个交易日的日均成交额,相对于过去20个交易日的日均成交额的比例,反映资金关注度的边际变化。
这三个特征放在一起,聚出来的类群会比较干净。你做聚类的时候,要先把这些特征做z-score标准化,否则成交额特征因为数值大,会把涨跌幅特征“吃掉”。
为什么选20日而不是60日或者5日?这个没有绝对标准,我选20日是因为它是A股一个“自然月”的交易天数,既能覆盖短期趋势,又不会过于敏感。后面我会讲到,这个参数不需要一锤定音,你可以做敏感性测试,但策略实盘时固定下来就好,不要频繁改。
4. k-means聚类的实现细节
4.1 标准化与相似度度量
先讲一下k-means的基本逻辑。它做的事情就是:给你一堆样本点,每个点有几个维度的特征,算法自动把它们分成K个组,每组内部样本尽量“近”,组与组之间尽量“远”。
这里的“近”和“远”,默认用欧式距离衡量。欧式距离的问题是:如果不同特征的量纲差异大,它会失衡。所以我前面强调的标准化,就是解决这个问题的。公式不复杂,就是减去均值除以标准差,让每个特征在同一量纲下参与计算。
有人问能不能用相关系数做距离度量。可以,但k-means本身不支持任意距离函数,你需要把数据先做一个变换。实操中我建议直接用标准化后的欧式距离,结果已经足够好,不要过度设计。
标准差standardization和数据标准化的区别在于,standardization不会改变数据的分布形态,它只是平移和缩放。这不是一回事,但有些资料会混着说,你只要知道,聚类的输入一定要是中心化、单位化之后的数据就行。
4.2 K值怎么选
k-means必须要你提前指定K值,这个“必须”是好事也是坏事。好事是它逼着你去思考“市场到底有几种状态”,坏事是如果你随便拍脑袋定一个K,聚类结果会很随机。
我一般用两种方法辅助选K:
- 肘部法则:画出不同K值下的簇内误差平方和(SSE),找曲线由陡变缓的“肘点”。
- 轮廓系数:计算每个样本的轮廓系数,取均值,越大说明聚类越合理。
在ETF池子大概15到25只的情况下,K等于3到6是比较合理的范围。少于3,市场风格切分得太粗糙;多于6,每个组里的ETF太少,轮动信号不稳定。
我个人的经验:K=4是一个不错的起点。四个簇大致对应“强势进攻”、“温和上涨”、“震荡整理”、“弱势下跌”这四种市场状态。你在回测里看聚类结果时,这四个状态的区分度也比较明显。
4.3 聚类代码实战
下面放一段我用Python做ETF聚类的核心代码,数据是自己模拟的,但逻辑可以直接搬到你自己的数据上。这里用的是sklearn里的KMeans,配合StandardScaler做标准化。
import numpy as np import pandas as pd from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans # 假设 df 是日线数据,index为日期,columns为ETF代码,values为复权收盘价 # 这里模拟生成 20只ETF、250个交易日的价格数据 np.random.seed(42) dates = pd.date_range('2024-01-01', periods=250, freq='B') etf_codes = [f'ETF_{i:02d}' for i in range(20)] df = pd.DataFrame( np.random.randn(250, 20).cumsum(axis=0) + 100, index=dates, columns=etf_codes ) # 特征计算 def build_features(price_df, lookback=20, vol_window=20): feat_list = [] for code in price_df.columns: p = price_df[code].dropna() if len(p) < lookback + 5: continue ret = p.iloc[-1] / p.iloc[-lookback] - 1 vol = np.std(np.log(p).diff().dropna().iloc[-vol_window:]) amount_ratio = 1.0 # 有成交额数据时替换为真实计算 feat_list.append({ 'code': code, 'momentum_20d': ret, 'volatility_20d': vol, 'amount_ratio': amount_ratio, }) features = pd.DataFrame(feat_list).set_index('code') return features features = build_features(df) # 标准化 scaler = StandardScaler() X = scaler.fit_transform(features) # 聚类 k = 4 model = KMeans(n_clusters=k, random_state=0, n_init=10) features['cluster'] = model.fit_predict(X) # 查看每个组的成员 for c in range(k): members = features[features['cluster'] == c].index.tolist() print(f'Cluster {c}: {members}')代码里我故意留了一个amount_ratio字段,实际用的时候你要从行情数据里把成交额拼接进来。这里模拟数据没有成交额,所以先置为1.0,不影响理解整个流程。
有几个细节值得注意:
n_init=10要显式指定。新版本的sklearn里,如果不指定,默认是10,但版本不同可能会有警告。random_state=0必须固定,否则每次运行聚类结果可能不一样,策略的“可复现性”就没了。这在实盘里尤其重要,你不能让同一个策略今天聚出A组、明天聚出B组。- 特征数量不要太多。三个特征刚好,加太多维度后K-means在高维空间里的效果会退化,而且解释起来很痛苦。
5. 从聚类结果到动态股票池
5.1 每组的“代言人”怎么选
聚类完成之后,你会得到几个组。下一步不是把整组都买下来,而是从每组里挑出一只“代表ETF”进入最终的股票池。
我常用的选代表方式有两种:
- 方法是选组内动量最强的ETF。理由很简单:聚类告诉你哪些ETF走势相似,但相似的走势内部也有强弱之分。强者恒强在A股ETF上还是比较明显的,选组内动量最强的,相当于在“同类风格”里挑了最强的表达工具。
- 另一种是选离质心最近的ETF,也就是这一组里最“典型”的样本。这种方式选出来的代表更中庸,波动相对小,但进攻性会弱一些。
我自己的偏好是选动量最强的。轮动策略赚的就是趋势的钱,为什么要选个中庸的呢?当然,如果你回测下来发现动量最强的ETF因为波动太大导致回撤超标,那可以换成离质心最近的,用回测数据说话。
5.2 股票池调仓与信号生成
动态股票池不是每天重新算一遍聚类然后调仓,那样交易成本会高到没法看。我一般设置一个调仓周期,比如每20个交易日做一次聚类,重新生成股票池,然后只在“池子成员发生变化”的时候触发调仓。
如果连续两期聚类结果完全一样,那就不动仓,省手续费。只有当某个ETF被剔除、新的ETF被选入,或者权重变化超过阈值,才执行交易。
信号生成的部分,我用的是一套非常朴素的趋势过滤规则:
- 调仓日计算每只代表ETF的20日均线和60日均线。
- 只买入价格在20日均线上方、且20日均线在60日均线上方的那几组。
- 如果某个组的代表ETF不满足条件,整个组就不配置,空出来的仓位分到其他满足条件的组。
这种“聚类分组 + 组内选强 + 趋势过滤”的结构,好处是每一步都有明确的业务含义,不是一个让人看不懂的黑盒子。
权重分配上,我建议起步阶段用等权。等权意味着你承认自己不知道哪个组会更优,让市场来决定。等跑了一段时间、积累了自己数据上的统计规律后,再考虑按动量加权。
这部分的代码逻辑不复杂,就是跟聚类结果连接起来:
# 假设 features 已经带 cluster 列 # 选出每个cluster里 momentum最强的ETF作为代表 representatives = features.loc[features.groupby('cluster')['momentum_20d'].idxmax()] # 调仓信号:检查20日均线是否在60日均线上方 # 这需要你回到原始 price_df 去计算均线 def trend_filter(code, price_df, short=20, long=60): series = price_df[code].dropna() ma_short = series.rolling(short).mean().iloc[-1] ma_long = series.rolling(long).mean().iloc[-1] price = series.iloc[-1] return price > ma_short and ma_short > ma_long valid_reps = [ code for code in representatives.index if trend_filter(code, df) ] # 等权分配到 valid_reps 中 weights = {code: 1.0 / len(valid_reps) for code in valid_reps}代码里只写了核心两步:选代表、过滤信号。实际你还需要处理“没有ETF通过过滤”的情况,这时候就空仓。别小看空仓,空仓本身就是一种仓位,能帮你躲过2018年那种单边下跌。
6. 常见问题与排查技巧
6.1 聚类结果“蹦迪”:调仓太频繁怎么办
跑聚类最头疼的一个问题是:这次聚类结果和上次差别巨大,上次还在同一组的ETF这次拆到了不同组,导致股票池大换血,调仓交易频繁,收益全被手续费吃掉了。
这种情况有几种原因。第一是数据窗口太短,建议把聚类用的数据窗口从20日拉长到40日或者60日,平滑一些。第二是特征里有异常值,比如某只ETF因为单日暴拉或者暴跌,把聚类质心拉偏了,这时可以考虑用中位数或者去掉极值。第三是K值选得不对,K越大的时候聚类结果越敏感,因为每个组样本数变少,单个样本对聚类结果的影响就变大。调小K值,比如从5改成4,就能稳很多。
6.2 ETF数量太少时聚类有意义吗
有些读者手中的核心池子只有五六只ETF,这时候跑k-means其实意义不大。聚类本质是探索数据的内在结构,数量太少时,你肉眼都能看明白走势差异,不需要算法来分组。
如果只有五六只ETF,我建议你把方法简化:不做聚类,直接用动量和波动率做一个综合打分排名,取前两名买入,后两名剔除。工具是死的,思路是活的,不要为了用算法而用算法。
6.3 实盘和回测差异:开盘价tt滑点
最后讲一个实盘最常见的问题——成交价格和回测差异。我前面说过信号次日开盘执行,但ETF在开盘瞬间如果出现跳空高开,你会发现回测里的成交价根本买不到。所以回测时我建议把开盘价做一个偏移处理,买价设置为开盘价的1.001倍,卖价设置为开盘价的0.999倍,相当于预留滑点。
平台层面,QMT和ptrade对开盘价成交的处理逻辑不完全一致,有些平台在回测引擎里默认了级别不同的滑点模型。移植策略到平台时,务必跑一遍样本外区间的回测,看看净值曲线跟你本地跑的差异有多大。如果差异超过10%,优先检查成交设置,而不是检查策略逻辑。
结尾:我给新手的三个实在建议
这个策略我从一开始构建到现在迭代了挺多版本,最深的体会有三条。第一,不要把重心放在“模型多复杂”上,而要把重心放在“数据是否干净、逻辑是否自洽”上。k-means只是一个分组工具,真正让策略赚钱的是你对ETF池子、趋势过滤、调仓频率这些规则的设计。第二,先跑通再优化,先用10只ETF、K=4、20日调仓这种最朴素的配置跑起来,有了基准之后,再一个个变量地调,千万不要一上来就搞一大堆对照组,最后根本不知道为什么结果变了。第三,实盘和模拟盘的差距永远比你想的大,前三个月用最小仓位跑,记录每一次滑点和偏离,这些都是回测里学不到的宝贵数据。
如果你正准备在QMT或者ptrade上实现这个“k-means聚类ETF核心轮动动态股票池”,我的建议是从本地验证逻辑开始,再移植到平台上。希望这篇内容能让你少走一些弯路。