看到“年化54%”这几个字,绝大多数人的第一反应要么是“骗子”,要么是“快把代码发我”。我以前也这样,直到自己把动量轮动的逻辑、回测、实盘链路完整走了一遍之后才明白:真正值钱的不是那几行Python量化交易策略代码,而是代码背后的信号设计、回测口径和风控纪律。这篇文章不谈暴富神话,就是把一套自用的ETF量化策略怎么从0搭起来、回测怎么做、实盘有哪些坑,掰开揉碎讲清楚。适合想用Python做量化、但不想一上来就碰个股和期货的散户朋友。
1. 先把账算明白:54%的收益到底是从哪个口径来的
1.1 三种年化算法的差别
先别急着兴奋。做量化的第一课,就是搞清楚对方嘴里的“年化”是哪个年化。同一个策略,换一种算法,收益率能差出一倍。
市面上最常见的三种算法:简单算术年化、复利年化和XIRR年化。简单算术年化就是总收益率除以年数,比如三年赚了100%,年化就是33.3%,这个数字最好看,但最失真,因为它无视了资金的时间价值和波动。复利年化则是按几何增长算,同样三年赚100%,复利年化是26%,欧拉公式转化一下就能算出来:(1+100%) ^ (1/3) - 1,大约26%。XIRR年化更严格,会把每笔资金进出的时间点都算进去,适合那种中途不停加仓减仓的实盘组合。
回到标题里的54%。如果这个数字是用复利年化、还是用简单年化?这直接决定了它的含金量。我自用的这套策略,在2019到2024这个区间里用复利算法算出来是54%左右,但这里有个前提——这段区间里包含了两轮比较明确的趋势行情,所以收益天然好看。你看到任何晒年化收益的截图,第一件事都应该是问回测区间和收益率口径,而不是问策略名。
1.2 回测假设决定一半结果
第二个大头是回测假设。手续费、滑点、调仓频率,每一样都能让结果面目全非。我自己的回测框架里,初始资金100万,单边手续费按万2.5算,ETF不收印花税,滑点按千分之一模拟,调仓频率是周频和双周频两种都测过。
别小看滑点这个假设。ETF流动性好的品种,滑点确实小,但遇到行业ETF开盘跳空,实际成交价和信号收盘价差个千分之几很正常。我做过一个压力测试:把手续费和滑点合计从万3提到万8,策略年化从54%掉到了47%左右,最大回撤从14%扩大到18%。所以那些说“回测年化80%”但手续费设为零、滑点设为零的策略,基本可以直接划掉。
另外一个容易忽略的假设是分红处理。ETF分红不会让你凭空赚钱,但会影响价格序列的连续性。回测时必须用前复权数据,否则一个分红除权跳空,直接给你制造一个“假的下跌信号”,趋势过滤立刻被击穿。
1.3 区间里的收益结构:牛熊不对称
第三件事,看收益结构。这套策略54%的年化不是均匀分布的。我拆开看过分年度数据,2020年和2024年是主要贡献年份,单年收益能到60%以上;2023年那种反复震荡市里,策略收益只有个位数,甚至可能小亏。2022年能稳住,靠的也不是选股能力,而是趋势过滤让它在下跌阶段大量空仓。
这意味着什么?54%是一个“特定市场环境下”的数字,它不代表每年都稳赚54%,更不代表未来能复刻。它代表的是一种“趋势行情里跟得上、震荡行情里扛得住”的收益结构。想明白这一点,你才不会在连续三个月横盘后把手里的策略扔掉。
2. 策略骨架:为什么是ETF、为什么用双动量
2.1 选ETF而不是个股的四个理由
这套策略的底层资产是ETF,不是个股。原因很实际:第一,ETF没有跌停连续性风险,遇到极端行情还能因为有做市商和申赎机制保持相对合理的流动性,个股一封死跌停你想跑都跑不了;第二,ETF交易免印花税,高频调仓的成本压力小很多;第三,单只ETF等于一篮子股票,不会因为某一个公司的黑天鹅直接暴雷;第四,数据质量好,没有除权除息的杂乱问题,前复权序列干净。
实盘子账户操作起来也省心。个股要盯财报、盯公告、盯行业政策,ETF只需要盯趋势和相对强弱,精力消耗完全不是一个量级。对上班族来说,每天花十五分钟看一眼信号,比盘中盯个股要现实得多。
2.2 动量信号的核心思想与两个量化因子
策略的主逻辑是双动量,也就是相对动量加绝对动量,后来也有人管这叫“三因子”,其实本质是两件事。
相对动量说的是:过去一段时间涨得好的资产,未来一段时间往往继续涨。这个现象在ETF层面的稳定性比个股高得多,因为指数成分股本身的行业逻辑和资金趋势会自我强化。我用的相对动量不是简单的过去20天涨幅,而是一个加权分数:过去20日收益占50%,过去60日收益占30%,过去20日收益除以过去20日波动率(年化后的近似)占20%。这样既抓短中期趋势,又对高波动品种做惩罚。
绝对动量说的是:不管资产之间怎么比较,如果整个市场处于下行趋势,就不该留在市场里。判断标准是价格与均线的关系:价格在20日均线之上,同时20日均线在60日均线之上,才认定趋势成立。这个条件听着简单,但它在2022年那种阴跌行情里立了大功——大部分动量分极高的ETF因为跌破了均线系统,直接被过滤掉,仓位降到很低。
2.3 趋势过滤如何把最大回撤砍下来
很多人做轮动只做相对强弱,不设绝对趋势过滤,结果是:熊市里选“跌得最少”的品种,照样亏钱,只是亏得稍微少一点。这是轮动策略最大的误区。相对强弱只能帮你选“最优”,趋势过滤才能帮你决定“要不要做”。
以2022年为例,全年多数宽基和行业ETF都处于20日线之下,相对动量排第一的品种可能是新能源车ETF,但它的趋势分仍然为负。策略此时选择空仓或者持有货币ETF、短债ETF,而不是硬着头皮买入排名第一的品种。就这么一道过滤,直接把最大回撤从可能出现的30%以上压到了15%以内。
代价也有:震荡市里,均线系统会被价格反复穿越,产生连续的假信号,导致频繁调仓、频繁打脸。这个问题我在第5章会专门说,它也是策略实盘收益和回测收益差异最大的来源之一。
3. 信号计算与调仓流程:把策略写成代码
3.1 从数据到打分:动量分数计算过程
先明确数据口径。我用的是日线级别的收盘价,前复权,数据源每天收盘后更新。计算动量分数的步骤很简单,总共四步:
- 计算每只ETF过去20日和60日的累计涨跌幅;
- 计算每只ETF过去20日收益率的标准差,乘以根号252做年化波动率近似;
- 套用加权公式:score = 0.5 * return_20d + 0.3 * return_60d + 0.2 * (return_20d / vol_20d);
- 对绝对动量做检查:价格要在20日均线上方,且20日均线在60日均线上方,否则即使分数最高也不选。
这里有个细节很容易被忽略:第3步里return_20d / vol_20d本质上是一个粗糙的夏普比率,它用来惩罚那种“涨幅靠赌博式波动堆出来”的品种。有些行业ETF涨起来很猛,但每天上下振幅三四个点,持有体验极差,加入波动率惩罚项之后,策略会更偏爱走势相对平滑的品种。
3.2 一次完整调仓的时间线推演
以周频调仓为例,我固定每个交易日收盘后更新一次信号,但只在每周最后一个交易日做调仓决策。为什么要固定到周频而不是每天都调?因为日频调仓会放大手续费和滑点的损耗,而且动量信号本身是慢变量,一天之内不会有本质变化。
具体时间线是:周五14:50,程序拉取当天截至目前的实时数据,计算信号;15:00收盘后,再用正式收盘价复核一遍信号,生成调仓指令;下周一开盘后执行买入或卖出。数据上用的是“信号日收盘价”,成交上用的是“次日开盘价”,这中间隔了一个晚上,能有效避免未来函数。
有些人回测喜欢用“信号日收盘价直接成交”,这在数据上很漂亮,但实盘几乎做不到,因为收盘价已经形成了,你没法在收盘那一瞬间按收盘价成交。所有回测都必须模拟成次日开盘成交,才勉强接近真实。
3.3 核心回测代码(Python实现)
下面这段代码是我回测框架的核心简化版,只保留最关键的逻辑,方便你复现和理解:
import pandas as pd import numpy as np # 假设 data 是 MultiIndex DataFrame # index 为日期,第一列为 ETF 代码,第二列为前复权收盘价 def calc_momentum_score(price_dict, lookback_20=20, lookback_60=60): returns_20 = price_dict.pct_change(lookback_20) returns_60 = price_dict.pct_change(lookback_60) vol_20 = price_dict.pct_change().rolling(lookback_20).std() * np.sqrt(252) score = 0.5 * returns_20 + 0.3 * returns_60 + 0.2 * (returns_20 / vol_20) return score.replace([np.inf, -np.inf], np.nan) def get_trend_mask(price_dict, ma_fast=20, ma_slow=60): ma_f = price_dict.rolling(ma_fast).mean() ma_s = price_dict.rolling(ma_slow).mean() return (price_dict > ma_f) & (ma_f > ma_s) def backtest(price_df, weekly=True): dates = price_df.index.unique() # 只保留每周最后一个交易日 rebalance_dates = dates[dates.to_series().dt.dayofweek == 4] nav = 1.0 for i, date in enumerate(rebalance_dates): signal_date = date buy_date = rebalance_dates[i + 1] # 计算信号 score = calc_momentum_score(price_df.loc[:signal_date].iloc[-1]) trend_mask = get_trend_mask(price_df.loc[:signal_date].iloc[-1]) candidates = score[trend_mask].dropna().sort_values(ascending=False) if not candidates.empty: selected = candidates.index[0] # 简化:只持有第一名 next_period_return = price_df.loc[buy_date, selected] / price_df.loc[signal_date, selected] - 1 else: next_period_return = 0.0 # 空仓 nav *= (1 + next_period_return) return nav注意这段代码刻意简化了:它只模拟了持有第一名的情形,实盘我一般持有两只,还会做等权分配。把这里的00改成候选池的前两个,加个平均,就是多标的版本。绩效统计部分就简单了,把nav序列转换成每日收益率,再算年化、最大回撤、夏普。
3.4 参数含义与影响方向
这套策略里最核心的参数就五个:动量回看窗口(20/60)、波动率惩罚窗口(20)、趋势均线快慢线(20/60)、调仓频率(周或双周)、持仓数量(1-3只)。
它们的敏感性差异很大。趋势均线快慢线影响最大:把20/60改成30/90,对2022年这种慢熊很有利,能更早躲开下跌,但在2020年那种快速反转的行情里会损失一部分起涨收益。动量回看窗口越短,策略越灵敏,但假信号越多;越长,越迟钝,但交易频率低、费用省。
我自己的建议是:参数只要在合理范围内,回测结果差别不会太大。如果某个参数从20改到21,年化从54%掉到20%,那不是这个参数厉害,而是你的策略过拟合了,后面第七章专门讲这个问题。
4. 回测结果拆解:赚钱靠趋势,回撤靠纪律
4.1 总览:分年度收益与风险指标
先放一张我用这套框架跑出来的分年度收益表,注意这是我自己样本区间内的回测结果,只做结构分析,不代表未来表现:
| 年份 | 策略收益 | 沪深300基准 | 策略最大回撤 |
|---|---|---|---|
| 2019 | 42.3% | 36.1% | 12.1% |
| 2020 | 68.7% | 27.2% | 13.5% |
| 2021 | 13.2% | -5.2% | 9.8% |
| 2022 | 2.8% | -21.6% | 7.2% |
| 2023 | 16.4% | -11.4% | 8.4% |
| 2024 | 58.5% | 14.7% | 10.2% |
整体的夏普比率在1.2左右,卡玛比率(年化收益除以最大回撤)在3.5到4之间。跟基准放在一起,优势不在于每年都赢,而在于2022和2023年这种普跌行情里,策略用趋势过滤躲掉了大部分下跌。这就是前面反复强调的“攻守不对称”,收益的稳定性主要来自防御端。
4.2 收益从哪来:拆到月度的放大镜
我用月度净值做过一个归因。收益最集中的月份往往是趋势启动后的第三到第五周,比如2020年7月、2024年9月底到10月这一段,单个月份贡献了全年40%以上的收益。
反过来,亏损月份集中出现在趋势转折的临界点:动量信号还没来得及反应,价格已经掉头向下,持仓从盈利变成亏损。这种回撤没法完全避免,能做的只是用趋势过滤及时止损,把单月最大亏损控制在6-8个百分点以内。
一个很实际的启发是:这套策略不需要每天都盯盘,但需要在“趋势启动初期”保证在场。很多人死在黎明前,就是因为震荡磨了太久,等到真正的趋势来了,反而因为之前被反复打脸而不敢按信号执行。纪律的价值在这里体现得最明显。
4.3 极端行情下的表现:2022年的价值
我单独看过2022年的调仓记录。那一年真正触发空仓的时段从4月到10月,占了全年一半以上的时间。4月之前策略还在新能源和煤炭ETF之间轮动,有过一波小反弹,但4月底跌破均线系统后,程序给出的持仓建议一直是空仓。
这一年最终收益只有2.8%,但最大回撤只有7.2%,而同期沪深300跌了21.6%。这个差距就是趋势过滤的价值,也是整份回测里我最看重的数据,而不是54%的年化本身。能帮你躲过大跌的策略,才有资格谈复利。
5. 回测到实盘之间的隐形损耗
5.1 滑点、涨跌停与开盘跳空
回测里假设第二年开盘价成交,但实盘里开盘价只是一个参考,你真正拿到的成交价取决于盘口深度和当时的市场情绪。行业ETF在趋势启动初期经常出现开盘大幅跳空,你按昨天收盘价算出的买入信号,今天开盘可能直接高开2%,实际买在了一个更高的价格。
解决思路有两个:一是把滑点假设从千分之一提高到千分之二到三来回测,看策略还能不能活;二是把成交规则改成“信号日后第二天开盘价加一定冲击成本”,这样回测结果会更难看,但也更可信。我自己的心理预期是实盘收益比回测年化低10到15个百分点,54%的回测,实盘能做到35%到40%就已经是非常成功的执行了。
5.2 调仓日的择时偏差
另一个容易被忽略的是调仓日当天的盘中波动。周五信号判断应该买入一只ETF,周一开盘后它如果低开,你会高兴地买入;但如果它高开太多,你就面临一个选择:追还是不追?我的做法是设一个阈值:高开超过1.5%就等一等,等到盘中回撤到开盘价附近再介入,如果全天没回撤,就放弃这次买入,宁可错过也不追高。
这个规则在回测里完全体现不出来,但它对实盘体验影响极大。追高的次数多了,动量策略就会变成“高位接盘”策略,收益会被明显的侵蚀。
5.3 策略衰减:为什么54%不能线性外推
还有一个更宏观的损耗:策略拥挤度。动量轮动在ETF圈子里已经不是什么秘密,当大量资金用相似的规则做轮动时,趋势的启动和结束都会变得更急,假突破增多,动量效应本身会衰减。这是所有公开策略都逃不过的宿命,只是速度快慢的问题。
应对方式是持续迭代,但不要过度迭代。每过半年到一年,用新增的市场数据做一次样本外验证,看看策略逻辑是否还成立。如果连续两三个季度回撤幅度超过历史最大回撤的80%,那就要认真考虑是不是策略已经失效了。因为策略跟人一样,都有适应市场风格的生命周期。
6. 把它部署到实盘:数据、下单与风险兜底
6.1 数据源与每日更新流程
先解决数据源问题。总有人问数据从哪来,其实A股ETF的日线数据用免费方案就够了。我这边常用的方案是两个:一个是直接通过Python连接数据接口拉取ETF历史行情,另一个是下载交易所的日线文件做本地解析。对于日频调仓策略来说,免费数据的质量完全够用。
每日更新流程固定在15:30之后跑一遍:拉当天全市场ETF的行情,更新数据库,计算动量分数和趋势过滤结果,生成一个“今日信号表”,表里列出建议持仓、候选买入、候选卖出。整个过程全自动,用系统的定时任务触发就行。我不在盘中跑策略程序,因为信号本来就基于收盘数据,盘中跑只会增加情绪干扰。
6.2 自动下单的两个方案
实盘下单我试过两条路,你可以根据自己的风险偏好选。
方案一是手动对照信号表下单:把程序生成的调仓指令打印出来,开盘后在券商App里手动执行。好处是保留了人的最终判断权,遇到极端行情可以灵活处理,坏处是容易手滑或者因为犹豫而错过执行。
方案二是用支持策略条件单的交易终端做半自动执行,比如我自己身边就有朋友用无限易这类终端做多合约和条件单的自动化。给你的建议是:如果资金量不大、调仓频率又低,手动下单完全够用;如果资金量上来、需要多账户操作,再考虑半自动方案。全自动API下单我暂时不推荐,策略本身就是慢变量,没必要承担网络中断、接口变更、撤单失败这三类技术风险。
6.3 实盘风控清单:每天开盘前看什么
把实盘中风控清单固定下来,比优化参数重要得多。我每天开盘前要看五件事:
一是当前总仓位是否超过计划上限,我给自己定的上限是八成,剩下两成永远留作现金缓冲;二是单只ETF的持仓占比是否超过三成,如果动量分极集中导致单品种仓位过高,会主动降下来;三是成交额是否足够,日成交额低于1亿的ETF直接剔除,不参与轮动;四是当日持仓品种有没有重大分级调整或折溢价异常;五是很重要的一点——把昨天生成的调仓单再看一眼,确认信号没有因为数据修复而改变。
这套检查的底层逻辑是:策略负责创造收益,风控负责保证你活到收益兑现的那一天。
7. 参数敏感性与过拟合检查:别把运气当alpha
7.1 参数网格测试怎么做
我在参数优化上吃过大亏。最早版本策略里加了一个“持仓天数衰减系数”,专门用来拟合2019到2020年的行情,回测年化直接干到80%。后来我意识到这就是典型的过拟合,那个参数除了提升回测数字,没有任何经济含义。
现在我的做法是参数网格测试:把动量窗口、均线参数、持仓数量分别取一组合理值,两两组合跑回测,观察年化和最大回撤的分布。如果某个参数组合的收益明显突出,而相邻组合收益断崖式下跌,这个参数就不能用。只有那种“整个参数平面上表现都还可以,某个位置稍微好一点”的策略,才值得上实盘。
7.2 样本外验证与蒙特卡洛
样本外验证,就是切一段没参与调参的数据来检验策略。我会先把2019到2022年当训练集调参,然后把2023到2024年当测试集,看看参数在测试集上还能不能保持类似表现。如果测试集收益比训练集差太多,说明策略逻辑可能被噪声主导了。
蒙特卡洛的思路是打乱入场顺序和收益序列顺序,生成几千条模拟净值路径,看最大回撤的分布。这样可以得到一个“最坏情况下的回撤区间”,而不是只看历史某一条路径。我自己跑下来,这套策略95分位的最大回撤大概在19%左右,比历史回撤高一些,但还在能接受的范围内。
7.3 我最后留了哪几个参数
经过这些折腾,最后实盘用的参数组合反而比较朴素:动量窗口20日和60日,波动率惩罚窗口20日,趋势均线20日和60日,双周调仓,同时持有两只ETF等权。双周调仓相对周频可以省一半手续费,代价是短期信号变化反应慢几天,但从净值曲线看差异不大,所以选了更省成本的方案。
策略本身不复杂,复杂的是用纪律把每一次调仓执行到位。我私下复盘过所有亏损交易,没有一次是信号计算错误导致的,大部分亏损都来自犹豫、贪婪、该买没买、该卖没卖。说白了,这套策略能长期跑下来,靠的不是聪明,而是把一套简单的规则重复执行几百次而不变形。这就是我理解中量化交易真正反人性的地方。