
直接切入正题吧。前阵子我把乔尔·格林布拉特的“神奇公式”在A股市场用backtrader搭了一套多股回测框架跑完以后发现不少有意思的东西——有些结论跟书里讲的一致有些则完全反直觉。这篇文章不打算复述书里的理论直接讲我怎么把策略从公式变成代码、回测结果怎么解读、以及回测过程中踩过的那些非常现实的坑。1. 神奇公式策略的核心思路拆解1.1 两个因子背后的逻辑为什么是ROIC和收益率格林布拉特的原始公式非常简单把所有股票按两个指标分别排序然后综合排名选最优。第一个指标叫资本回报率ROIC公式是息税前利润EBIT除以投入资本净营运资本加固定资产。这个指标跟ROE、ROA最大的区别在于它衡量的是“生意本身赚不赚钱”而不是“股东赚不赚钱”。一家公司如果负债很高ROE可能很漂亮但ROIC可能很一般因为高杠杆把分母撬小了。格林布拉特要的是“好生意”所以必须去掉杠杆效应用ROIC而不用ROE。第二个指标叫股票收益率Earnings Yield公式是EBIT除以企业价值EV市值加净债务。注意这里用的是EV而不是单纯的总市值目的是把公司的债务结构也考虑进去。为什么不用市盈率PE因为PE用的是净利润而净利润已经被财务费用、税率、非经常性损益洗过一遍了。用EBIT可以有效规避不同公司之间税率和资本结构差异带来的失真。筛选逻辑就是把全市场股票的ROIC排名和Earnings Yield排名相加取综合排名最靠前的30只左右等权买入。我最初看到这个逻辑时觉得太简单了但细想之后发现它的巧妙之处在于ROIC负责保证你买的是好公司Earnings Yield负责保证你没在高位接盘两个因子一个管质量、一个管价格互不干扰。1.2 策略参数的目标到底在测什么动手写代码之前先明确回测的目标。我当时的核心问题有三个第一这个策略在A股历史上到底有没有超额收益第二多长的持有周期最合适第三排名靠前的股票池子到底该选多少只。这三个问题其实对应着三组参数持有周期从1个月到12个月、持仓数量10只、20只、30只、50只、以及调仓频率按月还是按季。传统做法是把这些参数全跑一遍做网格搜索但我在实际测试中发现参数之间是有交互作用的——比如持仓数量和持有周期同时变化时收益率的波动会很大不能孤立地看单一参数。所以我后来采用的分层测试思路是先固定持仓数跑遍所有持有周期找最优周期再固定周期跑持仓数量最后再做一个二维参数网格交叉验证防止过拟合。1.3 适用市场的边界A股和美股的根本差异格林布拉特的回测样本是美股从1985年到2005年年化收益率大约23%。但直接把这套逻辑搬到A股之前得先想清楚一个关键差异美股市场做空机制完善、散户占比低、信息传导效率高而A股有涨跌停限制、退市制度不同、财务数据质量参差不齐。其中一个非常现实的问题是神奇公式在美股买入的是“好公司中的相对便宜者”在A股则很可能选出一堆周期股和价值陷阱——因为A股市场的定价逻辑更偏向成长预期而不是静态的低估。这个差异直接导致我在后续回测中加入了一系列额外的过滤条件比如剔除ST股票、剔除上市不满一年的次新股、剔除金融行业银行业的EBIT口径和资本结构完全没有可比性。所以在解读任何回测结果时都必须明确一点你回测的是“在A股市场的参数约束下神奇公式的适用性”而不是对格林布拉特原策略的简单复现。这两件事完全不一样。2. 回测框架选型与数据准备2.1 为什么选backtrader而不是自研框架回测框架的选择直接影响策略实现的工作量和可信度。我对比过自研轮子、backtrader、聚宽和米筐最终选了backtrader原因有三第一backtrader是完全开源免费的本地化运行数据在自己手里想怎么处理怎么处理。聚宽和米筐虽然是国内量化平台数据现成但策略逻辑在云端跑数据细节被平台封装了出了问题不太好排查。第二backtrader的事件驱动模型非常适合处理多股轮动策略。它天然支持多只股票同时持仓每个交易日的行情推送给所有标的后统一决策这种机制和神奇公式的定期调仓逻辑非常匹配。第三社区成熟度足够高网上能找到大量现成的多股回测代码片段遇到问题谷歌一下基本都有答案。相比之下自研框架明明只是用来验证一个策略思路却要先花大量时间处理撮合逻辑、复权数据、停牌处理等底层的脏活累活性价比太低。2.2 数据源从akshare到tushare的取舍数据是整个回测的地基数据错了后面全白搭。我当时的数据方案是行情数据用akshare拉取前复权日线财务数据用tushare Pro接口拉取。为什么行情用akshare因为免费、不需要token、按股票代码逐只拉取前复权日线很方便。但akshare的免费接口有频率限制拉几百只股票要加sleep我实测下来大概一分钟能拉20到30只效率不高但够用。财务数据用tushare Pro是因为它的财务指标接口更规范可以直接拉取每季度的EBIT、净营运资本、固定资产等字段。注意tushare Pro的积分要求比较高有些接口需要2000积分以上我一开始只有低积分账号拉不到完整财务数据后来通过注册企业账号和参与社区活动才攒够。数据准备阶段有一个非常关键的细节财务数据发布有滞后性一季报要等4月30日才全部披露完年报有的要拖到4月底。回测时必须做数据对齐——比如在6月1日调仓时能用的最新财务数据是Q1季报而不是Q2的实时数据。如果不做这个对齐处理直接用财报期末数据做回测会引入严重的前视偏差look-ahead bias回测结果虚高得离谱。2.3 数据清洗的隐藏工作复权、停牌、ST处理行情数据拉下来以后不能直接扔进backtrader就用至少要过三道清洗流程。第一道是复权处理。策略持有一年以上中间会有分红送股不复权的话价格会突然跳空导致收益率计算完全错误。我统一用前复权数据这样历史价格会被调整但最新价格保持真实回测中买入卖出价更贴近实际操作。第二道是停牌处理。A股停牌是常态如果一只股票在调仓日停牌你是没法买入或卖出的。backtrader不会自动处理这种情况需要自己维护一个可交易状态列表在调仓时跳过停牌股票并把仓位平均分给其他标的。第三道是ST和退市处理。ST股票的涨跌幅限制是5%和普通股的10%不同backtrader默认不支持这种差异化。我在数据预处理阶段直接把它们剔除掉了既减少了模型复杂度又防止买入ST后连续跌停卖不出去的极端情况。3. 策略实现与多股回测实操过程3.1 从公式到代码神奇公式的backtrader实现以下是我实现神奇公式策略的核心代码结构。因为整体代码量比较大这里重点展示策略逻辑的关键部分。import backtrader as bt import pandas as pd import numpy as np from datetime import datetime class MagicFormula(bt.Strategy): params ( (rebalance_days, 21), # 调仓周期21个交易日约等于一个月 (num_stocks, 30), # 持仓数量 (lookback_days, 5), # 调仓缓冲期防止刚好在调仓日停牌 ) def __init__(self): self.rank_data None self.rebalance_countdown 0 def next(self): # 调仓倒计时到0才执行换仓 if self.rebalance_countdown 0: self.rebalance_countdown - 1 return self.rebalance_countdown self.params.rebalance_days self.rebalance_portfolio() def rebalance_portfolio(self): # 获取当前所有股票在上一交易日的指标排名 # rank_data是一个字典key是股票代码value是综合排名 target_stocks self.get_top_stocks(self.params.num_stocks) # 计算当前持仓 current_stocks [d._name for d in self.datas if self.getposition(d).size 0] # 卖出不在目标名单中的持仓 for data in self.datas: if data._name not in target_stocks and self.getposition(data).size 0: self.order_target_percent(data, 0) # 买入目标名单中未持仓的股票 target_weight 1.0 / self.params.num_stocks current_cash self.broker.getcash() for stock in target_stocks: data self.getdatabyname(stock) if self.getposition(data).size 0: self.order_target_percent(data, target_weight)这段代码的核心逻辑在rebalance_portfolio方法里先根据外部传入的排名数据选出目标股票然后把当前持仓中不在目标名单里的股票清仓最后按等权买入目标股票。rebalance_days参数控制调仓频率我默认用21个交易日模拟月度调仓。3.2 多股回测的Cerebro配置与数据装载backtrader做多股回测最重要的工作是往Cerebro引擎里添加足够多的数据源。我一开始只加了排名靠前的50只股票结果发现策略在回测过程中会换手新调仓选出来的股票根本不在数据源里导致无法买入。这个问题排查了很久才意识到后来一次性把全市场3000多只股票全加进去了。def load_data_into_cerebro(cerebro, stock_list, price_data): for stock in stock_list: df price_data[stock].copy() df[openinterest] 0 data bt.feeds.PandasData( datanamedf, datetimedate, openopen, highhigh, lowlow, closeclose, volumevolume, openinterestopeninterest ) cerebro.adddata(data, namestock) return cerebro内存方面全市场股票加上多因子计算确实有压力我实际测试下来3000只股票加载到backtrader里每个标的都是5年以上的日线数据内存占用大概在2到3GB尚可接受。但每次回测跑完都会出现内存不释放的问题需要重启Python进程才能继续下一组参数测试后来我写了自动重启机制用脚本循环调子进程跑不同参数组合。3.3 关键参数的设定逻辑持有周期、持仓数量与手续费回测参数不是随便定的每一个都有它的逻辑基础。持有周期我测试了5个档位5天、10天、21天、42天、63天。短期调仓更频繁理论上能更快捕捉排名变化但手续费和滑点成本会吃掉大量收益长期调仓省成本但可能错过最佳卖出时机。我的测试结果是21天和42天的风险调整后收益最好5天的总收益率看起来不错但扣完手续费后收益率大幅缩水夏普比率反而下降。持仓数量我测试了10只、20只、30只、50只四档。30只的表现最稳定10只的收益波动大得吓人50只的收益被明显摊薄了。原因是10只股票的集中度高任何一只踩雷都会对组合产生显著冲击30只正好在集中度和分散度之间取了平衡。手续费和滑点设置也不要不要太乐观。我的佣金设置为万分之三卖出时加千分之一的印花税滑点设为0.1%。A股卖出印花税是千分之一这个不能漏很多人回测的时候只设定佣金忽略印花税结果回测收益比实际操作高出一大截。cerebro.broker.setcash(1000000) cerebro.broker.setcommission( commission0.0003, # 佣金万三 mult1.0, commission_typebt.CommInfoBase.COMM_PERC ) # 印花税在卖出时额外计算backtrader没有直接用参数控制 # 我在order_target_percent之前手动计算成本并验证结果3.4 排名计算避免前视偏差和数据对齐排名计算是神奇公式回测中最容易出问题的地方。核心原则很简单在T日调仓时只能使用T日之前已公开披露的数据。财务数据的发布滞后问题前面已经提过这里说具体怎么处理。我维护了一个财务披露日历表包含每家公司每个季度报告的披露日期。回测过程中在调仓日查询当前日期在上一个披露日和下一个披露日之间然后只使用已经披露的财务数据计算指标。实现逻辑大致如下def get_rank_data(asof_date): # 只使用asof_date之前已披露的财报数据 available_fin fin_data[fin_data[ann_date] asof_date] latest_fin available_fin.sort_values(ann_date).groupby(ts_code).last() # 计算ROIC和Earnings Yield latest_fin[roic] latest_fin[ebit] / (latest_fin[net_working_capital] latest_fin[fixed_assets]) latest_fin[ey] latest_fin[ebit] / latest_fin[ev] # 分别排名后相加 latest_fin[rank_roic] latest_fin[roic].rank(ascendingFalse) latest_fin[rank_ey] latest_fin[ey].rank(ascendingFalse) latest_fin[combined_rank] latest_fin[rank_roic] latest_fin[rank_ey] return latest_fin.nsmallest(self.params.num_stocks * 2, combined_rank)这里有另一个关键细节每次调仓时计算排名用的价格应该使用“调仓日前一交易日的收盘价”而不是调仓日当天的开盘价或收盘价。原因是你做决策的时候只能看到昨天收盘的完整行情今天买入用今天的开盘价或限价单实际成交价和昨天的收盘价是有偏差的。backtrader的next方法中当前bar的close在实盘中还没确定所以计算排名时必须用data.close[-1]也就是上一根bar的收盘价。3.5 实测跑出来的核心结果几个重要发现我以2015年到2024年这10年作为主回测区间初始资金100万月度调仓持有30只股票。几种持仓周期下的核心指标如下持有周期年化收益率最大回撤夏普比率累计收益10年5天13.2%42.5%0.52约248%21天约1月12.8%31.2%0.61约234%42天约2月11.9%28.7%0.58约208%63天约3月10.5%27.1%0.53约171%这几个数字给我的第一印象是神奇公式在A股的长期年化收益率虽达不到美股版的20%以上但拉长到10年看还是有明显正超额收益的。最让我意外的是5天持有期的年化收益率最高但最大回撤也最大——这说明频繁调仓确实能捕捉到更多的短期错误定价机会但付出的代价是波动率大幅上升对实际账户的持仓体验是很大考验。另一个重要发现是神奇公式在A股有很强的“小市值暴露”。我做了个分组统计选出来的30只股票中市值小于50亿的占比通常在60%以上。这意味着策略的收益有很大一部分来自小市值因子贡献而不是单纯的质量和估值因子。如果去掉市值小于30亿的股票年化收益率会下降到9%左右但最大回撤也明显改善。这个发现对实盘指导意义非常大。4. 常见问题与排查技巧实录4.1 backtrader多股回测的“隐形坑”多股回测和单股回测完全不是一回事我踩过的几个坑非常典型。第一个坑是停牌股票的卖出问题。回测中如果某只股票在调仓日停牌order_target_percent(data, 0)这笔卖出订单会一直挂着直到复牌才成交。这会导致现金被占用其他股票的买入资金不足。解决办法是维护一个可交易列表在调仓逻辑里先判断是否停牌停牌的跳过卖出同时从买入计划中剔除。第二个坑是backtrader默认的数据长度对齐问题。不同股票的上市日期不同如果直接adddatabacktrader会自动截取所有数据源都有的时间区间导致上市晚的股票前面一大段历史被丢掉。对于全市场回测这个问题尤其致命。我在加载数据时统一用bt.feeds.PandasData配合fromdate和todate参数明确指定回测区间确保所有股票的数据都完整加载。第三个坑是内存泄漏。前面提过跑多组参数后内存不释放后来我用子进程隔离的方式解决了。每个参数组合单独跑一个Python子进程跑完自动杀掉内存问题彻底消失。4.2 财务数据对齐一个让回测虚高的经典陷阱前视偏差是所有因子回测都要面对的问题神奇公式尤其严重因为它依赖季度财务数据。我最初版本的回测年化收益率超过20%后来发现是直接用季末数据计算指标导致使用了未来才能知道的数据。具体来说一家公司在3月31日结束一季度但一季报通常要等到4月20日到4月30日才披露。如果回测在4月5日调仓时直接使用一季度的财务数据计算排名这笔交易在现实中是不可能发生的因为数据还没公布。正确做法必须建立财务数据披露日历。简单版本是统一假设一季报在4月30日可用半年报在8月31日可用三季报在10月31日可用年报在次年4月30日可用。虽然每家公司实际披露日期不同但统一滞后一个披露期末可以大幅降低前视偏差。我用统一假设版和逐家披露日版分别回测年化收益率相差约3个百分点。这说明数据对齐对回测准确性的影响是非常可观的。4.3 手续费与流动性实盘和回测差距的元凶前面表格里的收益率是基于万分之三佣金加千分之一印花税算出来的。但5天持有期的回测结果在实际操作中大概率实现不了原因有两个。第一是冲击成本。排名靠前的小市值股票日成交量可能只有几百万30只股票平均分配100万资金每只买入3万多看起来不多但对于日均成交额只有500万的股票来说3万块已经是日成交额的0.6%足以推动价格产生明显滑点。我后来在回测中把滑点从0.1%调高到0.3%5天持有期的年化收益率立刻从13.2%下降到10.8%。第二是涨跌停板的影响。A股的小市值股票经常涨停或跌停回测中涨停板上的买单可以成交但实盘中排队都排不上。backtrader默认认为所有限价单在触及价格时都会成交这显然是理想化的。如果严格一点应该加上涨停不买入、跌停不卖出的约束。4.4 结果可信度评估过拟合与参数敏感性的判断最后说一下怎么判断回测结果是真实有效的还是过拟合出来的幻觉。我用了两种方法。第一种是参数敏感性分析把持仓数量从10到50、持有周期从5天到63天全部跑一遍看收益率曲面是否平滑。如果最优参数是一根孤独的尖峰周围全是亏损区这个策略基本不可信如果最优参数周围是一片高原表现差不太多那么策略就有一定稳健性。我的结果显示21天和42天附近确实是一片表现相对较好的区域参数敏感性不高这是加分项。第二种方法是对半区间验证。用2015到2019年的数据选参数再用2020到2024年的数据做样本外验证。如果在样本外期间策略的收益特征和样本内基本一致说明策略逻辑有持续性而不是仅仅在某个特定的历史阶段偶然有效。样本外验证的结果让我更有信心虽然收益略有下降但相对沪深300指数的超额收益依然存在。5. 这套策略的进一步扩展思路5.1 从神奇公式到多因子体系的升级格林布拉特的神奇公式本质上是一个双因子模型但它的框架可以非常自然地扩展到多因子。我在回测完原始版本后试着加入了两个额外因子一个是过去6个月的动量因子剔除最近1个月另一个是低波动率因子。加入动量因子后年化收益率提升了约1.5个百分点最大回撤还略微降低了。原因很简单神奇公式选出来的股票是“好但便宜”的这类股票经常处于左侧底部区域动量因子可以帮你过滤掉那些基本面虽然在改善但市场还不认可的“过早介入”。低波动率因子的效果是降低组合波动我把它和动量因子同时加入后夏普比率从0.61提升到了0.73。这说明多因子组合在A股市场确实能够起到互补的作用单靠两个因子还是有点单薄。5.2 行业中性化与风格暴露控制我在实际测试中发现神奇公式在A股的行业暴露非常不均匀。比如2019到2021年新能源景气度最高的时候策略大量持有化工和新能源产业链的公司因为这些公司同时满足了高ROIC和相对低估值两个条件。行业集中的结果是组合波动被行业beta放大哪一天行业调整回撤就会非常剧烈。解决办法是行业中性化处理在每个行业内部分别排名然后每个行业分配固定的权重。我和原始版本做了对比行业中性化后年化收益率略微下降但最大回撤下降了约5个百分点风险调整后收益明显改善。对于资金量较大、有风控要求的账户行业中性化几乎是必须做的。5.3 估值指标的A股适配改造最后提一个很多人忽略的问题。格林布拉特原版用的是EBIT/EV作为估值指标但在A股有相当一部分公司的EV是负的或者极低的尤其是那些账上现金非常多、几乎没有负债的公司EV可能为零甚至为负这会导致Earnings Yield失真甚至无穷大。我的处理方法是对EV为负或小于市值的公司做一个下限处理避免它们因为估值指标异常而冲进排名前列。另一种更简单的替代方案是直接用EBIT/总市值作为估值指标虽然理论上不如EV严谨但在A股的回测效果反而更稳定。这种原版逻辑到本土化适配的过程我觉得是整个回测项目中最有价值的部分——它让你真正理解了一个策略在什么条件下成立、什么条件下失效而不是机械地照搬公式。