← 返回模块
4.1.1.4beta 可读 · 未来付费内容校验中内容版本 2026-05-28

Tick 数据工程与清洗

4.1.1 · 市场与微观结构数据 · 量化全流程

某沪上 私募 量化 团队 第三周,基金经理把一份 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_1mbars_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_rawtime_bucket('1 day', ts) + symbol 分块;每条逐笔成交一行。bars_1mbars_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_dailyopen 是常规会期首笔成交、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_ofrecorded_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 通知),永远不能让人静默读到陈旧数据。

纪律总结

六句话收尾:

  1. cleaning is the work;生产数据集是对原始 tick 逐步施加的、显式的、单元测试过的转换链。
  2. 坏 tick ​flag​​,永不静默修正。
  3. corporate action 送股 / 拆股 乘法、现金分红 加法,只从 除权除息日 向前应用、永不从 公告日 应用。
  4. survivorship-bias-free 是正确性,不是风格。
  5. point-in-time 是正确性,不是风格。
  6. 干净的数据集通过两项检验——参考收盘到厘 + 与 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 filtercorporate-action adjustmentresample to barssurvivorship-bias-free universepoint-in-time disciplinevalidation)里哪一步最可能是 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 只正是今天市值最高的。

把五条答案写成五行表格。

提示
(a) 的招牌特征是「样本内远好于样本外、零参数改动」——这是基于当前指数成分股回测的幸存者签名。(b) 的「到厘不匹配」是 validation 检验抓到了上游某一步的 bug——但是哪一步?(d) 的 0.001 元 print 是教科书级 fat-finger / 闪崩签名,应该被 flag 但不能盲目丢。
提示
(c) 零代码改动下 Sharpe 飙升是 look-ahead 签名:当前复权因子被反推到 2015。(e) universe 过滤选了今天的存活大盘股—— 幸存者偏差 伪装成高 R²。

Formula Explorer

adj\_close(D) = unadj\_close(D) \cdot \prod_{e} split\_factor_e - \sum_{e} cash\_div_e

后复权 KaTeX 形式

累计因子公式的 KaTeX 形式:

cumulative_split_factor(D)=eex_dates_after(D)split_factor(e)\text{cumulative\_split\_factor}(D) = \prod_{e \in \text{ex\_dates\_after}(D)} \text{split\_factor}(e) cumulative_cash_dividend(D)=eex_dates_after(D)cash_dividend(e)\text{cumulative\_cash\_dividend}(D) = \sum_{e \in \text{ex\_dates\_after}(D)} \text{cash\_dividend}(e) adj_close(D)=unadj_close(D)cumulative_split_factor(D)cumulative_cash_dividend(D)\text{adj\_close}(D) = \text{unadj\_close}(D) \cdot \text{cumulative\_split\_factor}(D) - \text{cumulative\_cash\_dividend}(D)

参考卡

本课装配的组件,按次序:

  • Inline-code listing — 六步管线(bad-tick filtercorporate-action adjustmentresample to barssurvivorship-bias-free universepoint-in-time disciplinevalidation)。
  • Fenced ```sql 代码块 — ticks_raw 输入 schema 与 bars_daily 输出 schema、带 NOT NULL 标记。
  • Inline-code listing — 五条坏 tick 过滤规则、5 * σ_intraday 门槛、「flag, never silently correct」规则。
  • Fenced ```text 块 — 后复权公式:cumulative_split_factorcumulative_cash_dividendadj_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 testvendor-correlation sanity check)、R² > 0.99、斜率 [0.99, 1.01]
  • Inline-code listing — 操作 SLA:bars_1m landing deadline 16:00 北京时间、cleaning-run wall-clock budget < 10 minutesfailure mode fail 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, 中文版)。