多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

动量排名策略实盘接入:从回测到一键下单的ETF量化系统

动量排名策略实盘接入:从回测到一键下单的ETF量化系统 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, days100): # 获取ETF日线数据返回收盘价列表 df ak.fund_etf_hist_em( symbolcode, perioddaily, start_date(pd.Timestamp.now() - pd.Timedelta(daysdays)).strftime(%Y%m%d), end_datepd.Timestamp.now().strftime(%Y%m%d), adjustqfq ) return df[收盘].astype(float) def momentum_score(code, window20): 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_n5): scores [momentum_score(code) for code in codes] valid [(c, s) for c, s in scores if s is not None] valid.sort(keylambda x: x[1], reverseTrue) 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日动量得分是否入选1512880.SH8.42%是2516160.SH6.87%是3512690.SH5.94%是4513180.SH5.11%是5588000.SH4.35%是6159949.SZ3.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_pathQMT安装路径, callback_port9101, max_retries3, timeout10.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( symbolorder[symbol], sideorder[side], # buy / sell price_typelimit, priceorder[limit_price], volumeorder[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 idload-data更新动量排名/button button idrun-trading stylemargin-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请求上限控制下单速度加sleep5.2 一个真实的“重复下单”事故复盘这里要展开讲一下那次最危险的重复下单问题。当时我的重试逻辑是订单发送后等待回报如果3秒内没收到回报就重新发一次。表面看没有问题但QMT的回报推送经常会延迟特别是在网络波动时。于是一次网络抖动导致某只ETF的订单被重发了两次一次是买入另外一次重复买入。当时是模拟盘没什么损失但如果是真金白银就是一笔不小的资金错误配置。后来我把重试策略改成了“先查询后重发”回报超时后先调用订单查询接口确认这笔订单是否已被交易所接收如果已接收就直接读取订单状态不再重发如果没有接收再重新发单。这种策略牺牲了一点处理速度但保证了订单幂等性。做量化交易系统速度反而不是第一位资金安全性和订单准确性才是。5.3 大跌求生与动量失效的应对机制动量策略最怕的是市场突然反转。指数连续上涨后的急跌往往会让动量排名瞬间失效系统排出的全是昨日涨幅榜的品种而它们恰恰是最容易补跌的。这种动态下如果机械地按排名调仓会买在半山腰。我加了一道“市场状态过滤器”如果沪深300指数过去5日跌幅超过3%则认为市场进入防守状态暂停动量调仓持仓维持原有的ETF不动直到市场重新转强。这道过滤器是纯规则的但是在回测里能显著降低最大回撤是个很有诚意的改进。接下来我想回答一个很多读者问过的问题动量排名在实盘接入之后还需要做什么优化我的建议是先别再动策略了。让它跑一段时间每周手工记录一次资金曲线和最大回撤再和回测结果对比。如果实盘和回测偏差很大优先排查执行问题而不是策略问题比如手续费、滑点、成交速度这些细节。策略的优化永远在运行数据积累之后而不是凭感觉拍脑袋改参数。6. DAY19之后的扩展方向与个人心得DAY19这套实盘接入的骨架落地后系统和DAY18相比有了质的区别它不再是一个分析工具而是一个能自动执行策略的交易系统。接下来可以扩展的方向有很多比如把调仓周期改成每周一次增加更多的风控过滤器或者把订单回报做成微信推送。我在实际使用中最推荐的一个小升级是把调仓后的资金曲线做成自动记录每笔调仓都在数据库里留下一行记录这样月底复盘时就不用手工整理了。最后说一点个人感觉比较重要的事。实盘接入这件事技术难度其实不是最大的障碍心态和纪律才是。第一次在真实账户里看到自己写的代码执行了一笔真金白银的买入那种感觉和纸上谈兵完全不同。你会发现回测里平淡无奇的数字在实盘里会因为滑点、延迟、临时停牌变得鲜活起来。而这些“鲜活”的细节正是量化交易中最值钱的实战经验。DAY19只能给你一套可以复现的路径真正的打磨还是在每一次实盘交易中慢慢积累。
返回列表