1. 动量排名实盘接入到底在做什么
先说说这个DAY19今天完成的事:把前18天搭好的ETF量化交易系统,从“回测很漂亮”推进到“真金白银能下单”。这一步是很多自学者最容易卡住的地方——回测模型跑得再好,面对实盘接口、资金安全、网络异常、手滑操作这些现实问题,一切都要重新来一遍。今天这套方案的核心,就是让动量排名产生的信号能够自动对接券商通道,最后凝聚成一个按钮:一键下单。
如果你跟着这个系列走到现在,应该对这套系统的骨架有概念了:数据模块负责拉ETF行情,策略模块负责算动量排名,回测模块负责验证规则有效性,而今天补上的实盘接入模块,则是整套系统的出口。前面18天解决的问题是“策略怎么找”,今天解决的是“策略怎么用”。
为什么第19天才把实盘接入端出来?有心学量化的人,很容易在前期就急着接实盘,结果往往是拿真金白银去试验一个还没有稳定逻辑的策略,亏了之后得出结论:量化不靠谱。我的顺序是先花了两周多时间把数据打通、把回测框架跑顺、把资金曲线看清楚,确认动量排名这个逻辑在历史数据上确实形成了优势,再开始碰交易接口。顺序反了,后面会很痛苦。
动量排名这个方法,放在ETF池子里的价值尤其明显。ETF是场内基金,没有个股那种频繁的黑天鹅和停牌风险,交易费用低,跟踪的指数逻辑清晰,非常适合用纯数量的方式做纪律化筛选。动量效应在A股ETF上虽然不像美股指数那么稳定,但长期看,强者恒强在行业ETF和部分宽基ETF上依然是有效的。这不是我拍的,而是多因子和CROSS-SECTIONAL MOMENTUM研究里反复被验证的结论。
今天这篇会把实盘接入的全路径讲清楚:包括动量的核心计算方式、买什么、怎么排序、怎么设置风控门槛、怎么把信号推给券商API、下单返回如何确认,以及我在实盘落地时踩过的几个大坑。内容尽量按新手能复现来写,有代码、有参数、有流程,也有教训。
2. 动量排名策略的核心逻辑与参数设计
2.1 动量排名到底在排什么“名”
动量排名的本质很简单:在过去一段时间里,涨得好的资产,未来一段时间大概率继续涨。这在业界叫相对强度。我们需要做的,就是把ETF池子里的所有品种,按过去某个窗口的涨跌幅从高到低排序,选取排名靠前的几只作为买入候选。
这里有个容易忽略的关键点:动量值不是“涨了多少”那么简单,而是“相对强度”。比如某只ETF过去20天涨了5%,另一只涨了8%,在单因子切片下,8%那只动量更强。但实际操作时,我会对收益率做归一化和风险调整,避免单纯被高波动品种带偏。一个相对稳妥的做法是:用过去N日的累计收益率作为主排序,辅以波动率惩罚项——动量得分 = 累计收益率 - 0.5×同期的日收益率标准差×根号N。这个惩罚项类似于夏普比率的思想,过滤掉那种靠单日暴涨刷出来的虚高动量。
考虑到大部分ETF跟踪的是宽基和行业指数,行业轮动带来的动量效应比个股更强,也更适合用短中期窗口捕捉。我用的ETF池子大约80只,覆盖宽基、行业、主题、跨境这几大类,剔除了日均成交额低于3000万和规模小于1亿的品种,这类品种价差大、流动性差,不适合做量化调仓。
2.2 动量窗口怎么取最合理
动量因子最核心的决策变量是窗口长度。短了容易被噪声骗,长了又变成追高接盘。我做了20日、60日、120日三组对比回测,最终选了20日作为主窗口。原因有两条:一是20日大致覆盖一个月的走势,对行业轮动的反应速度够快,不会等趋势走完了才入场;二是A股很多板块行情的持续周期就在一到三个月,60日和120日过于滞后,常常在动量见顶后才发出买入信号。
这组对比我放在下面,数据是最近三年的全样本回测结果,费用按单边千分之一算,信号每月调仓一次:
| 动量窗口 | 年化收益率 | 最大回撤 | 年化波动率 | 胜率 |
|---|---|---|---|---|
| 5日 | 11.2% | 18.5% | 16.7% | 52.3% |
| 20日 | 17.8% | 12.3% | 14.9% | 58.6% |
| 60日 | 10.1% | 15.8% | 15.2% | 54.2% |
| 120日 | 6.3% | 17.2% | 14.1% | 51.8% |
5日窗口虽然对变化敏感,但换手率过高,手续费把利润啃掉一大截;20日四平八稳;60日和120日反应迟钝,跟不上A股行业轮动的节奏。老实说,动量窗口没有普适最优值,每个人资金属性和风险偏好不同,参数也不一样,但拿历史数据逐年验证是必须做的功课,改参数就必须重跑回测,这是铁律。
2.3 排名计算的实际演示
动量得分的实现并不复杂,核心就三步:取行情数据、计算窗口收益率、排序取值。以下是我在系统里实际使用的一段示例代码,数据源用akshare获取ETF历史行情,基于简单累计收益加波动率惩罚打分。
import akshare as ak import pandas as pd import numpy as np def get_etf_history(code, days=100): # 获取ETF日线数据,返回收盘价列表 df = ak.fund_etf_hist_em( symbol=code, period="daily", start_date=(pd.Timestamp.now() - pd.Timedelta(days=days)).strftime("%Y%m%d"), end_date=pd.Timestamp.now().strftime("%Y%m%d"), adjust="qfq" ) return df["收盘"].astype(float) def momentum_score(code, window=20): prices = get_etf_history(code, window + 10) if len(prices) < window: return (code, None) # 动量:N期累计收益率 ret = prices.iloc[-1] / prices.iloc[-window] - 1 # 风险惩罚:近window日收益率的标准差 daily_ret = prices.pct_change().dropna().tail(window) std = daily_ret.std() * (window ** 0.5) score = ret - 0.5 * std return (code, round(score, 6)) def rank_etfs(codes, top_n=5): scores = [momentum_score(code) for code in codes] valid = [(c, s) for c, s in scores if s is not None] valid.sort(key=lambda x: x[1], reverse=True) print("排名前{}:".format(top_n)) for c, s in valid[:top_n]: print(f"{c}: {s:.4%}") return [c for c, _ in valid[:top_n]]这段代码在本地跑的时候,排名结果大概是这个样子的。我列一组今天的真实快照,方便大家理解“排名”长什么样:
| 排名 | ETF代码 | 20日动量得分 | 是否入选 |
|---|---|---|---|
| 1 | 512880.SH | 8.42% | 是 |
| 2 | 516160.SH | 6.87% | 是 |
| 3 | 512690.SH | 5.94% | 是 |
| 4 | 513180.SH | 5.11% | 是 |
| 5 | 588000.SH | 4.35% | 是 |
| 6 | 159949.SZ | 3.76% | 否 |
回测中最优的持仓数量是5只,再往上加会摊薄收益集中度,往下减则分散度不足。5只是一个经过平衡后的选择:既能有效分散单一行业风险,又保留动量策略的进攻性。
2.4 为什么要做排名,而不是只买一只
很多新手在第一次接触动量时都会问:既然最强的ETF动量得分最高,为什么不把全部资金all in第一名?这是对动量策略最典型的误解。
单一资产的收益曲线波动太大,回撤扛不住,即便动量效应整体有效,第一名也会随机切换,单压一只很容易某个月吃掉前几个月的全部利润。更重要的是,动量排名本质上是选出一个“组合”,这个组合在某段时间内表现一致,但个股和行业之间会轮动,用多只品种持有,可以平滑排名切换期间的过渡风险。我的实操里每次至少持有5只,每只权重20%。这样某只ETF暂时失利时,其他持仓能托住整体净值,减少被迫割肉换仓的频率。
3. 实盘接入的整体架构与风控设计
3.1 券商接口的选型与最小化信创打通
把动量信号转化成真实订单,中间要过一道“交易接口”的坎。目前个人投资者能用的量化交易通道大致分三类:券商提供的极速量化终端(如QMT、Ptrade)、开源模拟交易框架、以及普通界面手动下单。
我选的是券商QMT量化终端对接,原因很直接:它在国内券商里支持个人量化交易申请较早,提供Python API,可以直接用pandas处理后的数据生成订单,速度也够用。个人申请的流程通常是资金量达到一定门槛,并在券商APP里开通量化交易权限,各家的标准不太一样,但主流券商的入门门槛在50万左右,小部分券商可以谈。如果你的券商不支持QMT,也可以用Ptrade,逻辑一样,只是接口写法不同。
这里给你一个最省事的最小化打通思路:先不开实盘,把券商API的服务端跑起来,用模拟账号把“拉取持仓-计算动量-生成目标持仓-对比当前持仓-生成买卖单-发送下单-检查回报”整条链路走通。模拟账户不影响真实资金,但API的调用方式、限频规则、回报格式都是一样的,全链路跑通后,切换到真实账户只是改一个账号配置。
3.2 下单前必须检查的四个风控点
实盘接入的核心不在“下单”这个动作,而在“下错单”的概率为零。我设计了一个简单但严格的风控清单,每轮调仓前必须依次过一遍:
第一个检查点是交易状态。开盘集合竞价期间和收盘前最后两分钟的流动性最差,很多时候你以为自己拿到了一个很好的成交价,实际上成交价滑点能被拉爆,所以我的系统只在上午10点到下午2点半之间发单,其他时间一律过滤。第二个检查点是涨跌停过滤。ETF虽然有涨跌停限制,但个别品种封板时根本买不进,发市价单还会以极差价格成交。我在下单前会拉取最新涨跌停价,把现价贴近涨跌停的标的全部跳过。第三个检查点是单笔金额限制。单只ETF下单金额不超总资金20%,剩余资金留作备用;同时设置单笔最低下单金额,避免手续费占比过高。第四个检查点是对比当前持仓。如果说前三个检查点是为了防止买入“不该买的”,第四个就是为了避免卖出“不该卖的”。每次调仓前,程序会把现有持仓和新目标持仓做差集,只对差异部分发单,绝不多动。
| 检查项 | 具体规则 | 目的 |
|---|---|---|
| 交易时段 | 10:00-14:30期间下单 | 避免流动性差的时段 |
| 涨跌停状态 | 现价接近涨跌停则跳过 | 防止无法成交或价格异常 |
| 仓位上限 | 单品种仓位≤20% | 控制集中度风险 |
| 持仓对比 | 只对差异部分下单 | 减少无效交易和手续费 |
3.3 一键下单的流程设计
一键下单不是帮你把鼠标键盘省了,而是把决策变成标准动作。点一下按钮,系统会依次执行:拉取最新行情、更新动量排名、生成目标持仓、比对现有持仓、计算买卖差异、二次确认关键参数、逐笔发送订单、读取回报结果。
这里有一个经验:真正做得好的“一键”,是需要二次确认的。在真正发送订单前,弹出一个确认界面,展示每笔订单的代码、名称、方向、价格、数量,你确认之后才真正发出去。这个机制看起来多了一步,但能挡住绝大多数手滑操作,比如排名数据更新失败导致的错误买入,或者误把5手的数量填成500手。我自己就是在第二天从电脑上确认时才发现排名候选池被一个停牌ETF污染了,如果没有二次确认,那笔单子已经花掉了10%的仓位。
实盘下单代码的大体思路是这样:
from qmtapi import QMTTradingClient client = QMTTradingClient( account_id="你的资金账号", server_path="QMT安装路径", callback_port=9101, max_retries=3, timeout=10.0 ) client.connect() # 构造目标持仓 target_holdings = {"512880.SH": 10000, "516160.SH": 8000} current_holdings = client.get_positions() orders = calc_diff_orders(current_holdings, target_holdings) # 界面确认后再执行 for order in orders: result = client.place_order( symbol=order["symbol"], side=order["side"], # "buy" / "sell" price_type="limit", price=order["limit_price"], volume=order["volume"] ) if result.status != "FILLED": log_error(result)QMT的API文档写得不算友好,我第一次调用place_order时,反复看了很久才搞清楚price_type的枚举值。我的建议是先用模拟资金跑通,再切真实账户,这样能极大减少试错时间和痛苦。
4. 实操:从动量排名到目标持仓,再完成一键下单
4.1 环境准备与依赖清单
把整个实盘接入做扎实,需要一个相对干净的Python环境。我用的是Python 3.10,安装的依赖有pandas、numpy、akshare(用于行情数据)、以及券商QMT提供的Python库。同时需要确保自己电脑上安装的QMT客户端是开启状态的,API的登录流程依托于QMT客户端的加密认证,不是独立于客户端运行的。
这里给大家看一个我踩过的坑:第一次跑通时,我确定已经登录了QMT,但在回调配置上漏了端口号,导致程序完全收不到订单回报。排查了很久才注意到回调端口不一致,后来把callback_port固定成9101,并在QMT客户端配置里同步开放,问题才彻底解决。环境里所有端口配置要一边对着文档一边对着客户端界面填,这一步不能凭记忆。
4.2 核心实现细节
下单函数是整个实盘接入的心脏,需要做成幂等、可重试、可追踪。幂等的意思是系统发单后,如果回报超时,程序能自动查询订单状态,避免重复下单。我写了一个下单函数,它接收订单对象和重试参数,发送后立即检查回报,若超时,则先查询此前的订单记录再决定是否重发。
这里给出最核心的调仓执行函数,它负责把目标持仓和当前持仓做差集,生成差异订单后逐笔执行。在实际使用中,这一步没有太多复杂的算法,关键是要考虑边界情况,比如某只目标持仓当前已经持有超过目标量,那就要卖出超出部分,而不是简单地买入目标量。
def calc_diff_orders(current, target): # 把target和current转换成code->amount的字典 c = {p["symbol"]: p["volume"] for p in current} t = dict(target) orders = [] all_codes = set(c.keys()) | set(t.keys()) for code in all_codes: diff = t.get(code, 0) - c.get(code, 0) if diff > 0: orders.append({"symbol": code, "side": "buy", "volume": diff}) elif diff < 0: orders.append({"symbol": code, "side": "sell", "volume": -diff}) return orders对比目标持仓和当前持仓这一步,如果系统数据源不同步,非常容易产生一个月的连续微调单,这对收益侵蚀很大。所以我会在调仓前给目标持仓设置一个“偏差阈值”,只有当持仓偏差超过5%时才生成调仓单,把50手以内的微调全部忽略。
下单发送后,回报确认是另一个容易出问题的地方。QMT的回报是异步推送的,处理不好就会拿不到成交结果。我的做法是:发送每笔订单后,sleep 0.5秒,再主动查询一次订单状态;如果是“已报”状态,则等待推送成交回报;如果是“废单”状态,就直接把错误原因记录下来,不再重发。这样处理虽然简单,但稳定够用。
4.3 一键下单按钮的前端交互逻辑
如果你和我一样把系统做成了带界面的形式,一键下单其实就是一个按钮绑定的回调。我用Flask搭了一个极简的本地服务,页面里只有三个元素:一个“更新排名”的按钮、一个展示排名结果的表格、一个“执行调仓”的按钮。点击“执行调仓”,后端会做一次最终检查,然后弹出确认框,确认后逐笔下单。
关键在于“更新排名”和“执行调仓”要分成两个动作。排名更新涉及数据拉取和计算,耗时要几秒钟;执行调仓则涉及风控检查和下单。如果混在一个按钮里,很容易在数据还没更新完时就直接下单,造成用旧信号交易的错误。这个设计细节,是从一次事故里总结出来的:某次我把全流程放在一个按钮下,结果数据源还没刷新完成,程序就直接按旧排名生了单,幸好当时是模拟盘,但已经足够让我长了记性。
<button id="load-data">更新动量排名</button> <button id="run-trading" style="margin-left:20px">执行调仓</button> <script> // 将按钮点击绑定到后端接口 document.getElementById("load-data").onclick = () => { fetch("/api/refresh").then(r => r.json()).then(data => render_table(data)); }; document.getElementById("run-trading").onclick = () => { if (confirm("请确认调仓信息无误后点击确定")) { fetch("/api/execute", {method: "POST"}).then(r => r.json()).then(data => alert(data.message)); } }; </script>跑通这个流程后,整个ETF量化系统的闭环就齐了:数据驱动排名,排名驱动信号,信号驱动下单。每天的例行操作就两步:早上开盘后点击一下“更新动量排名”,查看是否出现调仓信号;如果有,点击“执行调仓”,确认弹窗,完成。
5. 实盘接入中踩过的坑与问题排查实录
5.1 实盘接入十大经典问题速查
下面这个表是我在开发和模拟阶段遇到的高频问题,每条都是真实发生的。按问题的出现频率排了序,其他人在接实盘时大概率也会遇到前几个。
| 常见问题 | 典型现象 | 排查思路 | 解决方案 |
|---|---|---|---|
| 登录超时 | QMT客户端正常但API连接失败 | 检查账户状态和进程 | 重新登录,确认回调端口 |
| 订单超时 | 发单后无回报 | 查询订单状态接口 | 主动轮询订单状态,避免重单 |
| 涨跌停误买卖 | 买入价异常偏高 | 检查涨跌停逻辑 | 下单前强制过滤 |
| 行情延迟 | 排名过期 | 数据源刷新周期长 | 开盘前提前拉取数据 |
| 资金不足 | 买入资金超出可用 | 检查账户余额 | 下单前计算最大可用量 |
| 权限不足 | ETF不可交易 | 账户未开通ETF | 柜台开通ETF交易权限 |
| 微调频繁 | 每天产生小单 | 目标持仓与当前持仓偏差小 | 设置5%的偏差阈值 |
| 重复下单 | 同一订单发多次 | 回报超时后重试 | 记录订单编号,去重 |
| 手数不对 | 下单数量多一位零 | 手数单位理解错误 | 确认数量=100股/手的规则 |
| 接口限频 | 后批次订单被拒 | 超过API请求上限 | 控制下单速度,加sleep |
5.2 一个真实的“重复下单”事故复盘
这里要展开讲一下那次最危险的重复下单问题。当时我的重试逻辑是:订单发送后等待回报,如果3秒内没收到回报,就重新发一次。表面看没有问题,但QMT的回报推送经常会延迟,特别是在网络波动时。于是一次网络抖动导致某只ETF的订单被重发了两次,一次是买入,另外一次重复买入。当时是模拟盘,没什么损失,但如果是真金白银,就是一笔不小的资金错误配置。
后来我把重试策略改成了“先查询,后重发”:回报超时后,先调用订单查询接口,确认这笔订单是否已被交易所接收,如果已接收就直接读取订单状态,不再重发;如果没有接收,再重新发单。这种策略牺牲了一点处理速度,但保证了订单幂等性。做量化交易系统,速度反而不是第一位,资金安全性和订单准确性才是。
5.3 大跌求生与动量失效的应对机制
动量策略最怕的是市场突然反转。指数连续上涨后的急跌,往往会让动量排名瞬间失效,系统排出的全是昨日涨幅榜的品种,而它们恰恰是最容易补跌的。这种动态下,如果机械地按排名调仓,会买在半山腰。
我加了一道“市场状态过滤器”:如果沪深300指数过去5日跌幅超过3%,则认为市场进入防守状态,暂停动量调仓,持仓维持原有的ETF不动,直到市场重新转强。这道过滤器是纯规则的,但是在回测里能显著降低最大回撤,是个很有诚意的改进。
接下来我想回答一个很多读者问过的问题:动量排名在实盘接入之后,还需要做什么优化?我的建议是先别再动策略了。让它跑一段时间,每周手工记录一次资金曲线和最大回撤,再和回测结果对比。如果实盘和回测偏差很大,优先排查执行问题而不是策略问题,比如手续费、滑点、成交速度这些细节。策略的优化永远在运行数据积累之后,而不是凭感觉拍脑袋改参数。
6. DAY19之后的扩展方向与个人心得
DAY19这套实盘接入的骨架落地后,系统和DAY18相比有了质的区别:它不再是一个分析工具,而是一个能自动执行策略的交易系统。接下来可以扩展的方向有很多,比如把调仓周期改成每周一次,增加更多的风控过滤器,或者把订单回报做成微信推送。我在实际使用中最推荐的一个小升级,是把调仓后的资金曲线做成自动记录,每笔调仓都在数据库里留下一行记录,这样月底复盘时就不用手工整理了。
最后说一点个人感觉比较重要的事。实盘接入这件事,技术难度其实不是最大的障碍,心态和纪律才是。第一次在真实账户里看到自己写的代码执行了一笔真金白银的买入,那种感觉和纸上谈兵完全不同。你会发现,回测里平淡无奇的数字,在实盘里会因为滑点、延迟、临时停牌变得鲜活起来。而这些“鲜活”的细节,正是量化交易中最值钱的实战经验。DAY19只能给你一套可以复现的路径,真正的打磨还是在每一次实盘交易中慢慢积累。