第七部分 · 把研究变成可运行项目 · 第 46 章

实战项目一: 从零做一个指数增强回测

很多人第一次跑回测,看到屏幕上那条一路向右上角的净值曲线,都会产生一种错觉:赚钱这件事好像也就这样了,明天就可以辞职。先别急。这一章带你从零下场,完整走一遍教学版指数增强项目的研究流程:选一个指数当基准,在它的股票池里造几个简单因子,每月调仓一次,老老实实扣掉成本,再和基准比一比。目标不是让你复制一个保证赚钱的策略——这种东西不存在——而是让你看清量化项目真正难在哪里:难的不是写出买卖信号,而是让数据、时间顺序、交易规则、风险和报告每一条都对得上号。

主线七 本部分第 1 / 5 章

本部分的问题:怎样让研究穿过代码、模拟盘、真实订单和人的情绪,仍然保持可信?

读完后,你会完成两类实战,并知道何时推进、何时停手、怎样复盘。

读完这一章,你会明白

  • 指数增强项目的核心是一个闭环:基准、股票池、因子、组合、成本和报告,一环都不能缺。
  • 教学项目要先把时间顺序、交易规则和成本做对,再追求模型复杂度——顺序反了,得到的只是漂亮的假象。

金融现场 1. 年化 28% 的第一版,为什么改完只剩 6%

小陈第一次跑出指数增强结果时,年化收益接近 28%,回撤又小,他已经开始给项目起产品名。第二天按检查表重跑:股票池改成点时成分,财报延迟到公告后使用,涨停买不到,交易加入费用和滑点,曲线一层层矮下去,最后超额只剩约 6%。

这不是项目失败,而是幻觉被逐项剥掉。前一条曲线里混着未来信息、不可成交价格和免费的交易,数字很高却无法兑换;后一条曲线没那么漂亮,至少回答了“当时真的做得到吗”。量化研究的进步经常表现为收益下降、可信度上升。

实战项目应该保留每一次缩水的原因。把原始版本和修正版并排放进报告,比只展示最终曲线更能说明能力:你不仅会找到信号,还知道怎样亲手拆掉自己最喜欢的结果。

2. 先把项目目标和边界钉死

做项目的第一步不是打开编辑器写代码,而是拿纸笔把边界钉死。这一版我们这样定:基准从两个里选一个,中证 500 或沪深 300;股票池用历史当时的指数成分股,不是拿今天的名单往回抄;每月调仓一次;成交价用下一个交易日的开盘价或成交均价来近似;成本一项不落——佣金、印花税、过户费和滑点,统统要扣。

目标也要说死:这个项目不求找出史上最强策略,而是搭一个能反复使用的研究框架。搭好之后,你要能面不改色地回答一串问题:策略相对基准有没有超额收益?超额来自哪几个因子?换手多高、成本吃掉多少?最大回撤有多深?是不是某几年特别灵、某几年彻底失效?结果是否依赖少数几个参数?这些问题答不出来,净值曲线再好看也只是一张图。

为什么先钉边界?因为回测有一个魔咒:做着做着总会越来越好看。而变好看的原因往往不是策略变强了,是你不知不觉偷偷改了假设——股票池放宽一点、调仓时点挪一挪、成本参数调一调。先把边界写下来,后面每一次改动都记录在案,你才能分清是自己进步了,还是在自己骗自己。

输入第一版字段为什么需要
行情date, code, open, close, amount, adj_factor, paused, limit_up, limit_down算收益、判断可交易、估计成本
股票池date, code, index_code, index_weight保证只在当时指数成分里选股
行业date, code, industry行业中性和暴露分析
因子date, code, value_bp, momentum_60, low_vol_60, quality_roe生成综合分数

第一版项目,上面这些表就够跑了。别一上来就追求字段大而全,先保证每个字段你能解释它是什么、能检查它对不对、能追溯它从哪来。

指数增强的最小回测:八步流水 ① 数据入库 股票池、历史 K 线、成分股权重表; 特别强调:必须是点时可见版本 ② 写因子 选一两个简单因子比如 20 日动量 先写一个,再多写 ③ 打分 把因子值标准化后加起来, 给每只股票算总分 ④ 调仓名单 选出高分股前100名 按权重排比例 ⑧ 复盘结论 把结果告诉下一步要修正的部分, 形成报告 ⑦ 出报告 净值、年化、夏普、最大回撤 按指标出 ⑥ 扣成本 双边佣金 + 滑点 + 印花, 合在一起算总成本 ⑤ 模拟调仓 日频推进,不过未来的数据, 形成串行序列 数据→因子→打分→名单→模拟→成本→报告→复盘——少任何一步,结论都能被某个隐藏的坑拆掉

最小回测不是一段代码精灵,是八个环节的传导链。数据到结论全对一条龙才有意义,中间断掉任何一环都可能变成“模型很好但那只是没上纲上线”。

3. 别让今天的名单穿越回过去

先想一个坑:你今天打开行情软件看到的中证 500 成分股名单,和 2018 年的那份一样吗?当然不一样,成分股会定期调整、进进出出。股票池决定你在哪个市场里选股,指数增强项目必须使用历史当时的指数成分,而不是今天的成分回填过去。比如你要回测 2018 年,就得知道 2018 年每一个调仓日,中证 500 里装的到底是哪五百只股票。拿今天的名单回填过去,等于提前知道了谁以后会混得好,这是回测里最隐蔽的作弊方式之一。

有了池子,还要设可交易过滤。上市未满一定天数的新股可以剔除;ST 股票可以剔除;停牌的股票不能买入;涨停的股票通常买不进,不能假设买入;跌停的股票不能假设卖出;成交额太低的股票要小心容量,纸面上买一层,实盘可能只买得到零星几单。

关键

最关键的纪律是:过滤规则要同时用在回测和未来的模拟盘里。回测里剔除了流动性差的股票,实盘也要剔除;实盘不能买 ST,回测里也不能买。否则你训练的是一个假目标——回测里拳打脚踢,实场上等着你的是另一套规则。

名册也有生日:只用今天的名单,就是未来函数

指数成分会定期调整,2018 年的中证 500 和今天的中证 500 是两份不同的名册。拿今天的名册回测 2018 年,等于提前知道了这些年谁混得好——回测自然替你“挑”出一批赢家,漂亮得毫无意外。所以成分股入库时要给每份名册打上生效日期,回测只查调仓日当天的那一份。日期错一格,整条曲线都是租来的。

4. 让时间线先立正

价格数据是第一大宗原料,至少备齐这些:交易日、代码、开盘价、收盘价、成交量、成交额、复权因子、涨停价、跌停价和停牌状态。两个口径从一开始就要分清:收益计算可以用复权价格,不然一次分红除权就能给你造出一个假暴跌;交易模拟要用真实价格口径,因为你下单时面对的就是那个没复权的价格。

数据到手先别急着用,先验货。有没有 0 价格?有没有重复日期?复权因子有没有异常跳变?停牌日成交量是不是 0?涨跌停价格和昨收的关系合不合理?数据检查不是可选项,它是门卫——这道检查会替你挡住成批的假收益。

调仓时点同样不能含糊。标准做法是:每月最后一个交易日收盘后生成信号,下一个交易日才能买卖。千万别干这种事:用调仓日收盘价算特征,又假设自己按调仓日收盘价成交。这等于用收盘后才知道的信息,做了收盘时的决策,是教科书级别的未来函数。

时间点动作允许看到什么
T 日收盘后计算因子和目标组合T 日收盘及以前已公开数据
T+1 开盘按规则提交订单不能改变 T 日已经确定的信号
T+1 盘中模拟成交和未成交涨跌停、停牌、成交额约束
T+1 收盘后更新持仓和净值成交回报、收盘估值、费用

把这张时间线钉在墙上,它能挡住最常见的未来函数。以后每次你想在某个价格上成交,先问自己一句:信号是不是也用了这个价格?这个数据在那个时刻真的已经公开了吗?

回测里的一天:T 日算什么,T+1 做什么,钉死不许挪 标准做法:每月最后一个交易日收盘后生成信号,下一个交易日才买卖——每个动作只允许看到当时已公开的数据 T 日收盘后 计算因子和目标组合 只能见 T 日收盘 及以前已公开数据 T+1 开盘 按规则提交订单 不得修改 T 日 已经确定的信号 T+1 盘中 模拟成交和未成交 涨跌停 · 停牌 成交额约束 T+1 收盘后 更新持仓和净值 成交回报 · 收盘估值 费用入账 教科书级未来函数长这样: 用 T 日收盘价算特征,又假设自己按 T 日收盘价成交 = 用收盘后才知道的信息,做了收盘时的决策 × 每次你想按某个价格成交,先问自己:信号是不是也用了这个价格?这个数据在那个时刻真的已经公开了吗?

把这张时间线钉在墙上:T 日收盘后算因子、订目标;T+1 开盘交订单、盘中按涨跌停和成交额约束模拟成交、收盘后再记账。最常见的未来函数就一种长相——用 T 日收盘价算特征,又假设自己按 T 日收盘价成交。

5. 先落地,再谈花活

因子听起来玄,教学版其实四类就够开张。第一是估值,比如 PB 或 PE 的倒数,越便宜分越高;第二是动量,比如过去 60 日收益率,涨得顺的分高;第三是波动,取过去 60 日日收益标准差的负值,走得稳的分高;第四是质量,比如 ROE(净资产收益率),赚钱效率高的分高。

原始因子值还不能直接上战场,先处理脏数据。每个因子都要处理极端值和缺失值:常见做法是 winsorize 截尾,把最离谱的那部分按回边界,再做标准化。行业差异明显的因子,还可以在每个行业内部各自标准化——不然有的行业天生估值低、有的行业天生估值高,你的组合做着做着就变成一把行业押注,你还以为自己在认真选股。

还有一个新手必踩的坑:方向统一。先立好规矩:分数越高代表越看好。低 PB 如果代表便宜,可以使用 PB 的负值,或者干脆用 BP = 1 / PB;波动越低越好,可以使用负波动。方向不统一就急着合成,就像把正着数和倒着数的排行榜直接相加,结果什么意义都没有。

value_bp = 1 / pb
    momentum_60 = close_adj[t] / close_adj[t-60] - 1
    low_vol_60 = -std(daily_return[t-59:t])
    quality_roe = net_profit_ttm / equity

要强调一句:这些公式只是教学口径。真实项目里到处是边角:PB 为负怎么办、上市不满 60 天怎么算动量、财务数据按公告日还是报告期对齐、行业差异和极端值怎么收敛,每一个都能写一页。但先把第一版公式白纸黑字写出来,读者才知道因子不是一句抽象口号,而是一行能运行的代码。

6. 四个分数怎么拧成一个

手里有四个因子,怎么变成一个总分?最简单的多因子合成是等权平均:先把每个因子标准化成均值 0、标准差 1,方向统一后直接相加或求平均,得到综合分数。分数越高,模型越看好。别小看这个土办法,它是检验因子质量的基准线。

当然也可以给不同因子设置权重,比如价值 30%、质量 30%、动量 20%、低波 20%。但初学时不要急着优化权重。权重优化的本质是拿历史数据调参,你不断挑历史最好的组合,调出来的往往是最会讨好过去、而不是最懂未来的那一组。这件事有个大家熟知的名字:过拟合。

合成完先别急着选股,看看分数分布长什么样:有没有极端值?是不是某个因子一枝独秀主导了总分?是不是某些行业整体高分?缺失覆盖的股票是不是太多?用工程师的话说:模型输出之前,先把输入和中间结果打日志看分布,别直接接到下游。

7. 分数高不等于能买成

教学版最朴素的组合:挑综合分数最高的 50 只股票,等权持有。等权的好处是简单透明,问题也明显:小市值股票和大市值股票拿一样的权重,组合整体长相可能偏离基准较大。

更接近指数增强的做法是加入约束,把组合往基准上拉一拉:单只股票不超过 3%;行业权重相对基准不超过一定范围;组合股票数不少于 80 只;永远保留 1% 或 2% 的现金;成交额过低的股票降低权重,免得纸面上买得下、实盘买不到。

请按台阶上楼:先从简单组合开始,再逐步加约束。每加一个约束,都比较收益、回撤、换手和跟踪误差的变化。一口气堆上十条限制,出了问题你根本不知道哪个限制在起作用、哪个在帮倒忙。

candidates = tradable_universe[date]
    score = z(value_bp) + z(momentum_60) + z(quality_roe) + z(low_vol_60)
    selected = top_n(score, n=50)
    target_weight = 0.98 / len(selected)
    for code in selected:
        target[code] = min(target_weight, 0.03)

这段伪代码故意写得很笨:先选前 50,再等权,再保留 2% 现金,再限制单票 3%。它不是最优组合,但足够让第一版回测从一列分数走到一份真实的仓位清单。

给策略加约束,也要走灰度发布

一次只加一个约束,先看收益、回撤、换手的变化,再加下一个——这和上线灰度是同一个道理:变量一次只动一个,出问题才知道是谁引起的。一口气堆上十条限制,组合赚钱了你说不清谁立的功,不赚钱更说不清谁闯的祸。慢一步不是保守,是保住归因能力。

8. 纸面组合落地的第一道关

每个调仓日,流程是这样的:先读取当时股票池和因子,生成目标组合;再和当前持仓比较,得到需要买卖的数量。接下来的细节不性感但必须做:买入普通 A 股通常按 100 股整数取整;卖出还要处理历史持仓产生的零股;ETF、可转债等品种交易单位不完全相同,需要按品种另行建模;资金不足时要按比例缩减。

下单前逐只检查:买入时要看股票是否停牌、是否涨停、成交额是否足够;卖出时要看是否停牌、是否跌停、是否有可卖持仓。任何一项不满足,交易就发生不了。如果某只股票卖不掉,它会继续赖在组合里——从这一刻起,目标组合和真实组合就是两个东西了。

调仓模拟一定要保存未成交原因。很多回测只记录成功交易,相当于一份把所有报错都吞掉的日志系统:跑起来岁月静好,问题全被掩盖。真实市场里,买不到、卖不出、部分成交,每一种都会实打实地改变策略。

前面说的“T 日收盘出信号、T+1 开盘成交”,要同时落实信号时间和成交价格。下面是 examples/ex02_index_enhance.py 的时序摘录,省略号处的逐笔成交逻辑见完整脚本:

examples/ex02_index_enhance.py
for t in range(1, n_days + 1):
    if pending is not None:
        values = {c: qty * by_code[c].opens[t]
                  for c, qty in holdings.items()}
        open_nav = cash + sum(values.values())      1
        # 按开盘价先卖后买,逐笔扣费并更新 cash、holdings
        ...                                        2
        pending = None

    strat_nav.append(cash + sum(
        qty * by_code[c].prices[t]
        for c, qty in holdings.items()))            3

    if t > WARMUP and t % REBALANCE_EVERY == 0 and t < n_days:
        score = factors_at(stocks, t)
        ranked = sorted(stocks, key=lambda s: score[s.code], reverse=True)
        selected = ranked[:HOLD_NUM]
        pending = {s.code: 1.0 / len(selected)
                   for s in selected}              4
  1. 旧持仓先按开盘价估值:隔夜涨跌属于昨晚已经持有的股票。pending 是前一日收盘后的信号,不能让它倒回昨晚成交。
  2. 成交才改变股数和现金:按当日开盘价先卖后买,买卖每边按成交额扣费;现金不够支付买入金额和费用时,同比缩小买单。发出信号时不扣费。
  3. 收盘净值来自实际账本:现金加股数乘收盘价。昨收 100、今开 110、今收仍为 110,今天新买入的股票价格收益为 0,不能把隔夜的 10% 算给它;两次调仓之间,股数保持不变。
  4. 收盘后只生成待执行目标:factors_at 只读取 [0, t] 内的数据,目标等到下一交易日开盘执行。样本末尾没有下一交易日,就不再生成待成交目标或扣费。

完整脚本加载合成开盘价和收盘价、按时点算因子、月频调仓,买卖每边按成交额的万 13 扣成本,最后输出年化、回撤、超额、跟踪误差、IR 和换手。这里的换手是买卖成交额之和相对调仓前净值的比例,完整卖旧买新约为 2 倍,对应约万 26 成本。脚本允许碎股并假设开盘可全部成交,尚未模拟停牌、涨跌停、交易单位和成交量限制;接入真实行情时,这些规则仍要逐项补上。

资金的下限,可能比策略更早卡住你

账户小影响的不只是收益数字,还影响交易能不能成立:A 股买入按 100 股取整,一只 50 元的股票一手就要 5000 元。十万元账户想做 50 只等权,每只目标才 2000 元,连一手都不够——这套组合在回测纸上成立,在券商柜台根本不成立。资金越小,交易单位、股价高低和取整规则越先决定你能做什么,策略聪明与否反而排第二。

回测就是在历史里,一天天重放这个循环 ① 读取当时可见数据 ② 计算信号 ③ 生成目标持仓 ④ 模拟成交+扣成本 ⑤ 更新现金持仓 ⑥ 记录净值 下一天,重复

回测的本质就是这个每天重放的循环。每一步都可能藏坑:数据当时真可见吗?信号偷看未来了吗?成交价真实吗?成本扣了吗?循环里任何一环失真,收益就是假的。

9. 先算清摩擦,再谈赚钱

只要交易就有摩擦。成本至少包括佣金、卖出印花税、过户费和滑点。教学版可以分别设定买卖费率,比如假设买入 0.08%、卖出 0.13%,后面再做成本敏感性测试。配套脚本为便于核对,另用买卖每边均为 0.13% 的对称假设;两种假设都要乘各自的成交金额,不能把单边费率当成完整一轮的成本。

想算得更细一点,可以让滑点和成交额相关:成交额大的股票滑点低,成交额小的股票滑点高;订单金额占当天成交额比例越高,冲击越大。

成本敏感性测试是照妖镜。把成本乘以 0、1、2、3 倍分别跑一遍看结果。如果策略只在 0 成本下赚钱,现实意义很弱——那只是信号在纸面上恰好追上了摩擦;如果成本翻倍后仍有稳定超额,才值得继续研究。

第一版成本公式可以写得很朴素: 买入成本 = 买入金额 × 买入成本率,卖出成本 = 卖出金额 × 卖出成本率。拿数字演一遍:买入 10000 元,买入成本率 0.08%,成本 8 元;卖出 10000 元,卖出成本率 0.13%,成本 13 元。净收益必须在扣掉这 21 元后再算,顺序不能反。

如果要加入冲击,可以把订单金额除以当天成交额作为参与率。参与率越高,滑点越高。教学版不必复杂,但至少要知道:大单和小单面对的不是同一个成本世界。

成本敏感性实验台:把换手和成本各自推高,看毛收益被吞掉多少——照妖镜就是这么照的。

成本要按 0、1、2、3 倍各压一遍

只在零成本下赚钱的策略,是信号只比摩擦快了纸面上的一点点;成本翻倍后超额还稳,才配谈下一步。更细一点,把佣金、印花税、滑点拆开各自加压——有的策略只怕滑点,有的只怕换手,拆不开就永远不知道该治哪一处。这关的成本预算很低,多跑几次而已,别省。

10. 别报流水账,要出体检报告

回测跑完,先出一份体检报告。结果评价至少包括净值、基准净值、超额净值、年化收益、最大回撤、波动率、夏普、年化超额、跟踪误差、信息比率、换手率和交易成本,一项都别少。

然后按年份拆开看。某个策略总收益不错,拆开才发现全部来自 2019 年和 2020 年,之后持续失效。年度表能暴露这个问题;月度收益热力图也能显示收益是否过度集中在少数月份。

行业和风格暴露也要查。你以为自己做多因子选股,打开报告一看,组合长期超配某个上涨行业——那超额收益就不全是模型能力,里面有相当一块是你无意中押中的行业方向。

报告模块必须输出看什么问题
收益策略净值、基准净值、超额净值是否真的跑赢基准
风险最大回撤、波动、夏普、信息比率收益路径是否可承受
交易换手率、成本、未成交数量纸面收益能否落地
暴露行业权重、单票集中度、风格倾向是否偷偷押了别的风险

报告模板固定后,每次实验都用同一套指标。好处有二:你不会下意识地只挑对自己有利的图看,不同因子和不同约束之间也有了可比性。

11. 最后交付的是结论,不是曲线

项目最后要写复盘。不要只说策略好或不好,要正面回答:哪些因子有贡献?哪些阶段失效?成本吃掉多少?跟踪误差是否可接受?组合是否过度集中?结果是否稳健?

复盘还要落到下一步行动:比如加入行业约束、降低换手、替换财务因子数据源、增加样本外、加入风险模型,或者直接放弃这个方向。写不出行动项的复盘只是一份抒情散文——研究结论要能指导行动。

如果你能把这个项目完整做完,你已经跨过量化入门最重要的一道门。不是因为策略一定赚钱——很可能不赚钱——而是因为你开始用可验证流程替代感觉。流程对了,策略可以慢慢变好;流程错了,曲线再美都是租来的。

小结

这一章的正文不是让你记住几个孤立名词,而是要把它们放回同一条因果链里。读完后,先用自己的话复述下面几条判断,再顺着“小节回看”检查是否能解释每一步。

  • 指数增强项目的核心是一个闭环:基准、股票池、因子、组合、成本和报告,一环都不能缺。
  • 教学项目要先把时间顺序、交易规则和成本做对,再追求模型复杂度——顺序反了,得到的只是漂亮的假象。

小节回看

  • 1. 年化 28% 的第一版,为什么改完只剩 6%
  • 2. 先把项目目标和边界钉死
  • 3. 别让今天的名单穿越回过去
  • 4. 让时间线先立正
  • 5. 先落地,再谈花活
  • 6. 四个分数怎么拧成一个
  • 7. 分数高不等于能买成
  • 8. 纸面组合落地的第一道关
  • 9. 先算清摩擦,再谈赚钱
  • 10. 别报流水账,要出体检报告
  • 11. 最后交付的是结论,不是曲线

自测

为什么指数增强项目要使用历史当时成分股?

用今天的成分回填过去,等于提前知道谁以后会进指数,会产生幸存者偏差和成分回填偏差,回测直接失真。

多因子合成前为什么要统一方向和标准化?

因子尺度和朝向各不相同;不标准化、不统一方向就相加,合成分数是混乱的,没有可解释意义。

下一章为什么接在这里

市场中性模拟盘: 把策略放进真实时间

搭建多头、对冲、保证金和归因账本,再用一周日志处理数据、信号、成交与对账异常。