某沪上 私募 量化 团队 第三周,基金经理把一份 Jupyter notebook 递给你,结果是:2010-2020 沪深300 + 中证500 全市场上 Sharpe = 2.4,问你为何 2022 以来实盘版本只跑出 Sharpe = 0.5。你审数据,发现三个 bug:历史 成分股 表是按 今天的 沪深300 拉出来的(测试样本里每只标的都是当前成分——只剩幸存者);后复权 收盘价 用最新一份 复权因子 表对每一历史日期回推(策略在 2015 「知道了」2018 的送转事件);2010 年 5 月某一天一条 0.001 元的异常价格 print 没被坏 tick 过滤器拦住。三个 bug,一条清洗管线。修完之后 Sharpe 从 2.4 掉到 0.6——仍然正值,但不再是英雄。本课是本模块可运行的 capstone:把原始 tick 变成研究就绪 bar 数据集 的 六步 管线,加上每个初级量化人每天早晨都跑一遍的两个客观检验。
六步与每一步的作用
清洗 就是 工作。生产数据集是一条对原始 tick 逐步施加、每一步都独立单元测试过的显式转换链:
1. bad-tick filter — drop impossible / heartbeat / sale-condition / halted-period / fat-finger ticks
2. corporate-action adjustment — split factor multiplicative, cash dividend additive, forward from ex-date
3. resample to bars — time_bucket to 1-minute and daily bars with end-of-bar timestamp convention
4. survivorship-bias-free universe — a universe(date, symbol) table including every historically-tradeable symbol via a stable id
5. point-in-time discipline — every value indexed by when-it-became-known; a query at time T filters on as_of <= T
6. validation — cent-level reference test + > 0.99 R² vendor-correlation sanity check
每一步都有它要防的已知 bug 类别;每一步都被独立单元测试。Cleaning is the work——告诉你数据工程师位于 量化研究员 下游的人,把依赖图画反了。
步骤 1:输入 ticks_raw,输出什么
管线消费 3.6.3 L4 / 3.6.4 L4 提供的 ticks_raw TimescaleDB 超表,输出 bars_1m 加 bars_daily:
CREATE TABLE ticks_raw (
symbol VARCHAR NOT NULL,
ts BIGINT NOT NULL,
price DOUBLE NOT NULL,
size BIGINT NOT NULL,
side CHAR(1) NOT NULL,
condition_flags VARCHAR,
sequence_no BIGINT NOT NULL,
PRIMARY KEY (symbol, ts, sequence_no)
);
CREATE TABLE bars_daily (
symbol VARCHAR NOT NULL,
date DATE NOT NULL,
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume BIGINT,
vwap DOUBLE,
n_trades INTEGER,
unadj_close DOUBLE NOT NULL,
adj_factor DOUBLE NOT NULL DEFAULT 1.0,
adj_close DOUBLE NOT NULL,
PRIMARY KEY (symbol, date)
);
ticks_raw 按 time_bucket('1 day', ts) + symbol 分块;每条逐笔成交一行。bars_1m 与 bars_daily 是下游超表,同样的索引策略。
步骤 2:坏 tick 过滤器
五条规则、按此次序作用,每条移除一类 bug:
1. impossible price / size — drop price <= 0 or size <= 0
2. out-of-order sequence — flag (do not silently re-sort) when sequence_no is not strictly increasing within (symbol, session)
3. condition-flag exclusion — drop venue-specific flags marking ex-pit / extended-hours / cross / cash-only / block trades
4. halted-period exclusion — drop any trade with ts inside a published halt window, except resume-auction prints which are kept
5. within-day price-jump flag — optional; flag any trade with |price - mid| > 5 * σ_intraday for human / trade-bust review
规则 (a):不可能的 price / size。 交易所 偶尔会发心跳 tick(size = 0)或非成交 tick(price = 0)。直接丢,不出声。
规则 (b):单调 sequence_no。 在 (symbol, session) 分区内,sequence_no 必须按到达次序严格递增。若不然,feed 有 gap 或 out-of-order 包——flag,不要 静默重排。静默重排会掩盖 feed handler 的 bug。
规则 (c):condition_flag 排除。 对 A 股 Level-2 行情,丢弃下述 condition_flag:异常交易(交易所 的异常标记,常对应 fat-finger / trade-bust 候选);盘后定价(盘后定价交易 机制,15:05-15:30 按收盘价撮合——不属于连续价格发现);大宗交易(在常规簿外清算的大单,价格可远离 BBO,绝不用于在簿 bar 计算)。引上交所 / 深交所 Level-2 行情接口说明书作为 condition_flag 的权威分类。停牌:临时停牌(盘中停牌窗口)与 全天停牌(全日停牌)以交易所每日发布的停牌列表为准;恢复交易的 集合竞价 撮合 print 是真实成交,保留。
规则 (d):停牌期排除。 上交所 / 深交所 每日发布停牌恢复表;ts 落在停牌窗口内的成交全部丢弃。停牌恢复时段的 集合竞价 撮合 print 是真实成交、不丢。
规则 (e):日内价格跳变 flag。 任何 |price - mid| > 5 * σ_intraday 的成交标记给人审或 trade-bust 流程,不要 静默丢弃——σ_intraday 是日内波动的滚动估计。这些有时是 fat-finger 错单(交易所事后通过 异常交易 公告撤销),有时是真实的闪崩 print(必须保留)。次日跑 trade-bust check,按交易所公告事后追溯更新数据集。这是少数对的「事后追溯更新」场景——也仍符合 point-in-time 纪律,因为 bust 公告自带 bust_ts:「从这一刻起、视为该 print 不存在」。一条策略在原 print 出现到 bust 公告之间的窗口内交易过这条价格,是被允许看到这条价格的。原则:flag,永不静默修正。
步骤 3:复权调整
两层。先用 ticks_raw 做简单重采样得到 不复权 bars_daily:open 是常规会期首笔成交、close 是末笔;high / low 为极值;volume 为加总;vwap = sum(size * price) / sum(size);n_trades 为计数。这一层是 可交易价格 序列——每个值都是策略当时可以成交的价格。
再用供应商提供的 adjustments(symbol, ex_date, split_factor, cash_dividend) 表导出 后复权 层。按日期由最近 ex-date 向前推:
cumulative_split_factor(D) = prod(split_factor(e) for e in ex_dates_after(D))
cumulative_cash_dividend(D) = sum(cash_dividend(e) for e in ex_dates_after(D))
adj_close(D) = unadj_close(D) * cumulative_split_factor(D) - cumulative_cash_dividend(D)
规则:送股送转用乘法、现金分红用加法,绝不混用;只从 除权除息日 (ex-date) 向前应用、永远不从 公告日 (announcement date) 应用。只向前应用保护 point-in-time 纪律。回测一个 2024-01-15 交易过的策略,它必须看到 2024-01-15 当天真实存在的不复权价。如果某 corporate action 在 2024-03-01 公告、除权除息日 2024-03-15,那么 2024-01-15 的复权收盘价 只能 出现在 2024-03-15 或之后的表快照里,不能 出现在 2024-01-15 当天的快照里。
A 股供应商数据来源:万得(Wind) / 通联数据 / Choice 数据 提供 前复权因子 / 后复权因子 表,按日更新;上交所 / 深交所 信息披露公告 + CSRC 中国证监会 公告 是监管口径的源头。贵州茅台 600519 的 2024 末期分红 + 历史送转事件 给你足够的 cumulative_split_factor + cumulative_cash_dividend 教学锚点;2015 的 10送10转30 在累计因子里乘进去后,2014 年的不复权价要除以累计因子才能落到后复权研究价。要警觉的 bug 类:把复权收盘价同时用于 pnl 计算与信号计算 = 复权调整被双重计入。pnl 列用不复权;signal 列用后复权;如果你的数据集只有一列叫 close,那就是错的。
步骤 4:重采样到 bars
用 3.6.3 L3 的 time_bucket('1 minute', ts),从过滤后的 tick 算出 bars_1m:
import pandas as pd
def resample_to_bars(ticks_df: pd.DataFrame, frequency: str = "1min") -> pd.DataFrame:
"""把过滤后的 tick 重采样为指定频率的 OHLCV bars。
输入列:[symbol, ts, price, size]
输出列:[symbol, ts_close, ts_open, open, high, low, close, volume, vwap, n_trades]
"""
grouped = ticks_df.set_index("ts").groupby("symbol").resample(frequency)
bars = grouped.agg({"price": ["first", "max", "min", "last"], "size": "sum"})
bars.columns = ["open", "high", "low", "close", "volume"]
bars["vwap"] = (ticks_df["price"] * ticks_df["size"]).groupby(level=[0, 1]).sum() / bars["volume"]
bars["n_trades"] = grouped["price"].count()
bars["ts_close"] = bars.index.get_level_values("ts") + pd.Timedelta(frequency)
bars["ts_open"] = bars["ts_close"] - pd.Timedelta(frequency)
# zero-trade bar handling: forward-fill OHLC from previous bar; volume=0, n_trades=0
for col in ["open", "high", "low", "close"]:
bars[col] = bars[col].groupby(level="symbol").ffill()
bars["volume"] = bars["volume"].fillna(0).astype(int)
bars["n_trades"] = bars["n_trades"].fillna(0).astype(int)
return bars.reset_index()
来自 L1 的「bar 时间戳是结束」约定:ts_close 是 bar 数据可知的那一刻(time_bucket('1 minute', ts) + '1 minute');ts_open = ts_close - '1 minute';open 是半开区间 [ts_open, ts_close) 内首笔成交;close 为末笔。vwap 是按 size 加权的均价;n_trades 是行数。
零成交 bar 的处理:生产默认是「沿用上一根 bar 的 close,set volume = 0、n_trades = 0」(研究员偏好这种——bar 序列等距、好与信号拼接);另一种「让零成交 bar 缺位」是数据库纯粹派的选择、会打断下游 join。时区:bar 边界按交易所时区(A 股按 Asia/Shanghai)计算;底层在 UTC 跑,只在 bucketing 时加时区偏移。
步骤 5:survivorship-bias-free 全市场表
universe(date, symbol) 表必须、对每个历史日期,包含 当天可交易 的每一只标的——包括之后退市、长期停牌、改名、变更主上市场所的标的:
CREATE TABLE universe (
date DATE NOT NULL,
symbol VARCHAR NOT NULL,
stable_id VARCHAR NOT NULL,
is_active BOOLEAN NOT NULL,
listing_venue VARCHAR,
as_of TIMESTAMPTZ NOT NULL,
PRIMARY KEY (date, symbol)
);
-- universe tradeable on 2017-06-15, point-in-time-correct as of the strategy run time
SELECT symbol, stable_id
FROM universe
WHERE date = '2017-06-15'
AND is_active = TRUE
AND as_of <= :strategy_run_time;
A 股量化早期普遍犯的 bug:用 今天 的 沪深300 成分股 回测历史——这会把 收益高估 2-3 倍。A 股 量化 团队 早期 普遍 用 今天 的 沪深300 成分股 回测 历史 — 这 把 收益 高估 2-3x;point-in-time 成分股 文件 (which Wind / 通联 both publish per-day) 是 正确 答案。500 / 300 这种宽基指数每年 25-30 次成分调整——业绩末位 10% 会被剔除、中盘业绩前 10% 会被纳入,今天的名单是向上偏的样本。
CN 参考标识符:Wind 万得 stable-id 与 通联数据 内部 ID 是跨 ticker 变化、跨 A→H 跨上市、跨退市仍稳定的内部数字标识——证券代码 (6 位 ticker) 不稳定(合并 / 重组会变)。引 Wind 退市 / 暂停上市 历史数据 作为退市历史的源头。universe 表用 Wind / 通联 提供的 退市历史 + 当前 上市 标的 列 join 出来;每一个历史日期都要列出当天可交易的每一只标的。资深同事的规则:如果你不能用单条 SELECT 回答「列出 2017-06-15 当天可交易的全部 A 股」,你的数据集就有 幸存者偏差(survivorship bias) bug。
步骤 6:point-in-time 纪律
一句话说清楚:每张表里的每个值,索引键是它「被知道」的那一刻(as_of 或 recorded_at 时间戳),不是它「指代」的那一刻(effective_date 时间戳);时间 T 的查询必须只看 as_of <= T 的行。
经典例子:2024-03-01 发布的、除权除息日 2024-03-15 的 corporate action 公告影响每个历史日期的复权因子——复权因子表必须有 as_of 列(值 = 2024-03-01)。回测查询日期 D 的复权因子,必须取 adj_factor(symbol, D, as_of = strategy_run_time)。更简洁的版本是 bitemporal 表模式:每个事实带两个时间戳(值在什么时候为真;我们什么时候知道它为真);策略运行时 T 的查询过滤 learned_at <= T。
显式的 bug 类:每次回测都从最新供应商文件快照里读复权因子——这就是 look-ahead 偏差:策略在历史日期上「看到了」那时还未公告的复权事件。
前向指针:4.1.2(基本面、另类数据与基础设施)L4 讲企业级 bitemporal 数据湖;本课讲行情数据层的 同名纪律。
校验:每天早晨的两个客观检验
每次刷新自动跑两条:
1. cent-level reference test — pick a reference symbol on a hand-picked date with a known official close;
assert bars_daily.unadj_close matches the official close to the cent;
failure is stop-the-presses
2. vendor-correlation sanity check — over a one-year window, regress pipeline_return ~ vendor_return;
require R² > 0.99 (typically > 0.998), slope in [0.99, 1.01],
intercept within +/- 5 bp / year
检验 1:参考收盘到厘。 挑参考标的 510300 沪深300 ETF 与一个手挑的、已知官方收盘价的交易日(例:510300 在 2024-03-15 收 3.842 元);管线的 bars_daily.unadj_close 必须匹配到 0.001 元(A 股 ETF tick 颗粒度)。不匹配 = 紧急停机 bug。
检验 2:与供应商相关性 sanity check。 一年滚动窗口内,回归 pipeline_return ~ wind_return(基准为 Wind 万得 日 收盘 数据 或 通联 / Choice 同等);R² 必须 > 0.99(通常 > 0.998);斜率落在 [0.99, 1.01];截距在 ±5bp / 年内。剩下的差异反映 trade-bust 处理、condition_flag 过滤、corporate action 边角差异;超过 ±5bp / 年说明有 bug。
收尾规则:修好管线再算任何信号。在脏数据上算的微观结构信号看起来真,实盘交易里消失。
操作 SLA
管线每天早晨跑,三条契约式 SLA:
1. bars_1m landing deadline — within 30 minutes of the regular-session close: 16:00 北京时间
2. cleaning-run wall-clock budget — the pipeline completes for a 5000-symbol universe in < 10 minutes
3. failure mode — fail loud, not silent: stale dataset triggers pager + Slack alert; never a silent stale read
forward-pointer to 3.6.6 for the alerting layer
前向指针:3.6.6(可观测性与系统设计)讲告警层;本课讲 SLA 和到此为止。静默失败 的过期数据集是数据工程层之上的 bug 类——数据集必须 大声失败(pager 加 Slack 通知),永远不能让人静默读到陈旧数据。
纪律总结
六句话收尾:
- cleaning is the work;生产数据集是对原始 tick 逐步施加的、显式的、单元测试过的转换链。
- 坏 tick flag,永不静默修正。
- corporate action 送股 / 拆股 乘法、现金分红 加法,只从 除权除息日 向前应用、永不从 公告日 应用。
- survivorship-bias-free 是正确性,不是风格。
- point-in-time 是正确性,不是风格。
- 干净的数据集通过两项检验——参考收盘到厘 + 与 Wind 万得 日 收盘 数据 R² > 0.99。
本课触碰的术语—— 成交量加权平均价(VWAP)、时间加权平均价(TWAP)、买卖价差(bid-ask spread)、最小变动单位(tick size)、市场冲击(market impact)、订单流(order flow)、涨停(limit-up)、跌停(limit-down)、A 股(A-shares)、ETF 期权、印花税(stamp duty)、T+1 结算(T+1 settlement)、CSRC 中国证监会、中基协(AMAC)、实施差额(implementation shortfall)、滑点(slippage)、交易成本(transaction cost)——是 4.2(alpha 研究)与 4.5(回测与执行)的底座。
练习
Exercise
你在审一家新公司的 tick 数据清洗管线。报告了五条异常。对每条异常,标识:(i) 本课的六步管线(bad-tick filter、corporate-action adjustment、resample to bars、survivorship-bias-free universe、point-in-time discipline、validation)里哪一步最可能是 bug 来源;(ii) 用一句话说明 bug 是什么;(iii) 用一句话说明修法。
(a) 2010-2020 的动量因子回测年化 25%;过去两年实盘同一因子年化 3%。
(b) 2024-03-15 管线汇报 510300 的 bars_daily.unadj_close = 3.836 元;当天官方收盘价是 3.842 元。
(c) 管线在 2024-04-01 跑了一遍,按 2024-04-01 的复权因子对 贵州茅台 600519 回到 2010 的每一历史日期重算价格;某个本来 +18% Sharpe 的动量策略,零代码改动下 Sharpe 现在变成 +24%。
(d) 510300 在 2024-05-06 北京时间 09:35:00-09:36:00 的 1 分钟 bar 的 close = 0.001 元;附近的 1 分钟 bar 都正常落在 ~4.13 元。
(e) 一位微观结构研究员用 L3 算订单流不平衡回归,得到 R² = 5%(远高于预期的 0.5-2%);查下来 OFI 信号只在全市场 universe 表 5000 标的中的约 200 只上算——而这 200 只正是今天市值最高的。
把五条答案写成五行表格。
提示
0.001 元 print 是教科书级 fat-finger / 闪崩签名,应该被 flag 但不能盲目丢。提示
Formula Explorer
adj\_close(D) = unadj\_close(D) \cdot \prod_{e} split\_factor_e - \sum_{e} cash\_div_e后复权 KaTeX 形式
累计因子公式的 KaTeX 形式:
参考卡
本课装配的组件,按次序:
- Inline-code listing — 六步管线(
bad-tick filter、corporate-action adjustment、resample to bars、survivorship-bias-free universe、point-in-time discipline、validation)。 - Fenced ```sql 代码块 —
ticks_raw输入 schema 与bars_daily输出 schema、带NOT NULL标记。 - Inline-code listing — 五条坏 tick 过滤规则、
5 * σ_intraday门槛、「flag, never silently correct」规则。 - Fenced ```text 块 — 后复权公式:
cumulative_split_factor、cumulative_cash_dividend、adj_close。 - Fenced ```python 代码块 —
resample_to_bars(ticks_df, frequency='1min')函数与聚合字典、零成交 bar 处理。 - Fenced ```sql 代码块 —
universe(date, symbol)成员表与含as_of <= :strategy_run_time过滤的 PIT-正确 SELECT。 - Inline-code listing — 两个校验测试(
cent-level reference test、vendor-correlation sanity check)、R² > 0.99、斜率[0.99, 1.01]。 - Inline-code listing — 操作 SLA:
bars_1m landing deadline16:00 北京时间、cleaning-run wall-clock budget< 10 minutes、failure modefail loud, not silent(前向指针 3.6.6)。 - Exercise — 五行异常审计,含 Two progressive Hints 各保持简短。
- FormulaExplorer — 后复权公式
adj_close(D) = unadj_close(D) · ∏ split − ∑ div。
六句话纪律收尾:cleaning is the work;坏 tick flag、不静默修正;送股 / 拆股 乘法 + 现金分红 加法、只从 除权除息日 向前应用;survivorship-bias-free 是正确性;point-in-time 是正确性;两个检验是参考收盘到厘 + R² > 0.99(与 万得)。
下一课
下一模块把数据工程的故事上升一级——基本面数据(财报)、另类数据(新闻 / 情绪 / 卫星)、企业级数据基础设施(vendor API、数据湖、bitemporal 存储),建立在本课确立的 point-in-time 纪律之上。本模块的 capstone 交付物就是你刚写出来的这条六步管线,以及两项客观检验。把清洗这项手艺带走:4.2 的每一个信号、4.5 的每一个回测都靠它。
阅读清单
- 上海证券交易所 / 深圳证券交易所 Level-2 行情接口说明书(公开节选)。
- 中国证监会 关于公司信息披露的公告。
- 万得 / 通联数据 / Choice 数据 复权因子表 用户文档。
- 《量化投资策略:如何实现超额收益》(丁鹏)关于数据清洗与复权的章节。
- 《市场微观结构理论》(O'Hara, 中文版)。