这是日频股票数据文件中的一行:NVDA,2024 年 6 月 7 日星期五,10 拆 1 生效前的最后一个交易日。
| 列 | 值 |
|---|---|
| 日期 | 2024-06-07 |
| 收盘价 | 1208.88 |
| 成交量 | 42,800,000 (四舍五入) |
| 拆股因子 | 1.0 |
| 股息 | 0.00 |
| 调整后收盘价 | 120.8781 |
我特意没有在这里展示 OHLC 列。它们会在接近结尾处重新登场,而它们正是悄悄造成问题的那些列。
这行数据里最值得关注的,是第 2 列和第 6 列之间的差距。6 月 7 日的市场成交价是 1208.88。以后再也不会有人以这个价格交易,原因是它所对应的那一股已经不再作为一个单位存在。120.8781 是重构出来的价格,而且不只是把 1208.88 除以 10。我们逐列来看。
日期:2024-06-07,以及另外两个更重要的日期
这次拆股于 2024 年 5 月 22 日随财报一同宣布。登记日是 6 月 6 日。6 月 10
日星期一开始按拆股调整后的价格交易。这 3 个日期里,数据供应商的
split_factor 列只会对应其中 1
个,通常是拆股调整后的首个交易日。我见过最常见的公司行动 bug 就是日期错 1
天,而且汇总数据里看不出来:你的权益曲线会在某一天出现 90% 的跳空下跌或 900%
的跳空上涨;用 2,000 天数据算出的 Sharpe
几乎察觉不到,但最大回撤或止损规则肯定会受到影响。
股息也有日期问题,而且 2024 年情况有变。NVDA 拆股后的季度股息为 $0.01,登记日是 6 月 11 日。美国股票结算周期于 2024 年 5 月 28 日改为 T+1,这意味着除息日和登记日现在是同一天。任何通过“登记日前 1 个工作日”推算除息日的数据加载器——这种做法几十年来一直正确——从 2024 年 6 月起都会悄悄提前 1 个交易日生成股息调整。程序不会报错,只是把因子放到了错误的行上。
公司行动代码出错时,从来不会抛出异常。它只会给出一个日期错了 1 天、看起来却完全合理的数字。
收盘价:1208.88——文件里没有的那个价格
这是原始成交价,在大多数研究流水线里,它只存在于一个没人读取的列中。信号是根据
adj_close
计算的。任何以美元价格为依据的每股价格规则,现在引用的都是一个市场参与者从未见过的数字。
2024 年春季 NVDA 朝着 $1,000 迈进,是实实在在的行情,背后有真实的订单流:整数价位的吸引力、集中在 950 和 1000 的期权行权价,以及每次接近这些价位时的头条风险。在调整后数据中,这段行情发生在 $100。如果你在测试心理价位行为、整数价附近的缺口回补逻辑,或接近行权价的特征,那你测试所用的价格序列已经被一个当时尚未发生的事件重新缩放了。
股票池筛选也一样。“排除股价低于 $5 的股票”是对一类真实微观结构问题的合理代理指标:相对价差大、保证金资格规则、最小报价单位制度。用调整后历史数据执行这个筛选,就会把那些曾以 $30 交易、后来又反向拆股的公司在某些时期排除在外。执行“股价高于 $500”的筛选,则会在 2024 年 6 月之前的每一年里悄悄剔除 NVDA。价格筛选应该用未调整的成交价。就这么定了。
拆股因子:1.0——以及下一行的 0.1
这个因子是从今天开始向过去回溯计算的累积乘积。2024 年 6 月 10 日之前的每一行都带有这次拆股产生的 0.1,再乘以 2021 年 4 拆 1 产生的 0.25,以及此前历次拆股的因子。NVDA 在 2021 年之前的历史数据按 0.025 缩放,因此 2019 年成交价为 $200 的日线会显示为 $5.00。
有两点值得记住。第一,每次出现新的公司行动,调整后的历史数据都会改变。你在 2024 年 5 月跑的回测,与今天用相同代码、相同日期跑出的结果会不同。如果你缓存特征时只按股票代码和日期做键,没有加上数据版本标记,那么缓存里现在就混杂着两种尺度的数据。我们会把调整版本写入特征键,正是出于这个原因;我们曾花了一个令人困惑的下午才弄明白。
第二,按 0.025 缩放后,原本以 $0.01 为最小跳动单位的股票,现在每次变动 $0.00025。你的跳动单位取整逻辑、“是否创出新高”的比较、浮点数相等判断,都会在远低于市场实际精度的尺度上运作。
股息:0.01——让它之前所有价格脱离价格网格的数字
1 美分。看起来只是噪声,却正是 adj_close 显示为
120.8781,而不是整齐的 120.888 的原因。
6 月 10 日的收盘价是 121.79。除息因子为 (121.79 − 0.01) / 121.79 = 0.99991789。这个因子会乘到 6 月 11 日之前的每个价格上,因此经过拆股调整的 120.888 变成了 120.878074。变化很小,却是永久性的;往回追溯 NVDA 的历史,每一个六十多个除息日都会应用一次。关键在于复利效应:看到 2015 年的数据时,价格已经乘上了几十个不太合理的因子。没有一个调整后的历史价格还落在美分网格上。
这意味着,每次将
==
与整数价位比较,结果都是错的;每次检查“是否触及限价”,都需要设置容差;而且把重构后的
OHLC 朴素地四舍五入到 4 位小数后,就不再满足 low ≤ open ≤ high
这个不变量。这也意味着 adj_close 是总回报序列,而
close
是价格回报序列;如果你用前者计算收益、用后者计算回撤,就混用了对市场表现的两种不同定义。
成交量:42,800,000——没人能始终如一应用的除数
要与拆股后的交易日进行比较,股数成交量需要乘以 10。有些供应商会调整,有些不会。还有些会调整 OHLC,却保留原始成交量;这 3 种情况中,这是最糟的一种,因为在用价格乘成交量之前,文件看起来都还自洽。
更稳妥的做法是停止把股数成交量用作特征,改用美元成交额,因为它天然不受拆股影响。20 日平均美元成交额流动性筛选完全不需要调整逻辑,也不会被公司行动破坏。特征不变性胜过正确的调整代码,因为调整代码需要你一直保证正确。
3 种供应商文件,3 种故障模式。
| 提供的数据 | 会出什么问题 |
|---|---|
| 仅调整收盘价,OHLC 保留原始值 | 跳空和盘中逻辑会跨越两种价格尺度计算 |
| 调整 OHLC,成交量保留原始值 | 美元成交额会因拆股比例而出错;ADV 筛选失灵 |
| 原始 OHLCV 加因子列 | 应用因子就没问题;应用到错误行上就全都出问题 |
缺失的列:adj_open、adj_high、adj_low
现在把 OHLC 加回来。如果数据源只调整收盘价,而你的策略把隔夜跳空算作
open / prev_adj_close − 1,那么在 2024 年 6 月 10 日,它会计算
120.37 / 120.8781 − 1,结果没问题,大约是 −0.4%。把同样的代码回溯到 2024 年 6
月 7 日,它就会拿原始文件里的开盘价(约 1207)去对比经过更早拆股的 0.025
因子调整后的收盘价,得出几千个百分点的跳空。
任何把原始列和调整列混在一起的计算,都是在两套坐标系之间做运算。我现在把调整方式混用视为数据加载器层面的断言,而不是要求研究人员自己记住,因为他们不会记住,包括我自己。
没有任何列会告诉你的事:你实际会买入多少股
这是会在回测成本模型中造成真实影响的一项,也是我专门写拆股这件事的原因。
美国股票佣金通常按股收取。假设每股 $0.005,订单最低收费 $1.00,这是常见的零售券商固定费率档。现在回测一年前的一笔 $400,000 NVDA 仓位:当时原始股价接近 $400,而调整后价格显示为 $40。
| 股数 | 佣金 | |
|---|---|---|
| 实际情况(原始价格 $400) | 1,000 | $5.00 |
| 回测看到的情况(调整后价格 $40) | 10,000 | $50.00 |
敞口相同,交易相同,模型算出的成本却高出 10 倍。每条腿都凭空多出 $45,每次往返交易多出 $90;每日换手的策略每年会承担约 $22,000 的虚构成本。在 $400k 的账簿上,相当于 5.6 个百分点的年化收益,全是除数凭空造出来的。这里它让你的表现看起来比实际更差,这还算能接受。
反向拆股的影响正好相反,而且这才是危险的方向。1 拆 10 的反向拆股会让调整后历史价格变成实际交易价格的 10 倍,股数变成实际交易股数的 1/10。你的成本模型会悄悄把每股佣金除以 10。反向拆股常见的股票,属于小盘股、股价低于 $5、价差大的股票池;在这里,按股收费是成本构成中最大的一项。回测恰好美化了本该受到惩罚的股票。
修复方法很简单,也属于结构性调整:按美元金额确定仓位,再用对应日期的未调整价格换算成股数,并按这个股数收取每股费用。数据加载器要永久保留原始价格列,它不只是用于审计的记录,也是成交引擎的实时输入。
- 信号和收益:调整后序列。
- 股数、每股费用、跳动单位取整、价格水平筛选:未调整序列。
- 流动性和容量:使用美元成交额,两者都不需要。
6 列、1 行,以及一次相当普通的公司行动:提前 3 周宣布,没人感到意外。市场用一个上午就完成了调整。要在研究流水线中忠实重构这段过程,要难得多;而流水线出错的迹象几乎从来不是错误信息。
← 全部文章


