PAYATHON 2026

高频期权 tick 数据如何设计数据库,兼顾批量导入与回测查询性能?

数据小许

建议采用什么方案?

如果每天在 EOD 批量导入数据,查询主要是按历史时间范围扫描和聚合,可以优先验证 ClickHouse。明细表保存选定合约的秒级行情,按月分区,排序键根据回测中最常用的过滤条件设计。原始 CSV 应另行归档,方便重算和补充行权价。

这项建议基于描述的工作负载。ClickHouse 在你的数据上是否最快,还需要用实际回测查询比较;查询跨度、合约数量、并发、硬件和数据修订频率目前都尚未明确。

还要先确认每行数据是秒级快照、该秒最后一条记录,还是交易事件。1 秒分辨率不一定包含完整的逐笔事件,这会影响去重、成交量统计和回测精度。

先补齐合约身份,再设计行情表

现有列没有显式列出到期日、行权价和期权类型。要构建期权链,至少需要明确以下字段:

  • underlying:NIFTY、BANKNIFTY 或 SENSEX。
  • expiry:合约到期日。
  • strike:行权价。
  • option_type:例如 CE、PE,具体编码以数据源为准。
  • 稳定的合约标识,以及原始 Ticker。

如果这些信息包含在 Ticker 中,可以按照经过确认的数据源规则解析;否则需要合约主数据。不能仅凭当前字段列表假定信息已经齐全,也不能用当前合约规则反推所有历史合约。

合约主表适合保存交易所、合约标识、到期日、行权价、期权类型和具有生效日期的合约规格。行情明细表可以冗余常用过滤字段,减少回测时反复关联主表的开销。

Date 和 Time 应合并为 timestamp

建议在查询表中保存统一的 ts,同时保留交易日 trading_date:

  • 将原始 Date、Time 按数据源时区解析,再统一为 UTC。
  • trading_date 使用数据源定义的交易日,便于分区、批次核对和交易日查询。
  • 原始时间文本可以留在归档或暂存层,用于排查解析问题。

当前数据只有秒级精度,示例使用 DateTime64(0, 'UTC')。如果未来获得更精细的数据,再调整精度;增加小数位不会恢复原始数据中缺失的事件顺序。

ClickHouse 表结构如何设计?

下面假设合约信息已经补齐,CE、PE 已统一编码,数量字段为非负整数。价格和行权价使用的六位小数仅为示例,应根据数据源精度及取值范围调整。

CREATE TABLE option_ticks
(
    underlying LowCardinality(String),
    expiry Date,
    strike Decimal(18, 6),
    option_type Enum8('CE' = 1, 'PE' = 2),
    Ticker String,
    trading_date Date,
    ts DateTime64(0, 'UTC'),
    LTP Nullable(Decimal(18, 6)),
    BuyPrice Nullable(Decimal(18, 6)),
    BuyQty Nullable(UInt64),
    SellPrice Nullable(Decimal(18, 6)),
    SellQty Nullable(UInt64),
    LTQ Nullable(UInt64),
    OpenInterest Nullable(UInt64)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(trading_date)
ORDER BY (underlying, expiry, option_type, strike, ts);

这里保留 Nullable,以区分缺失报价和真实的零值。如果数据源保证某些字段完整,可以使用非空类型;不要为了压缩而把所有缺失值都改成零。

分区按月起步,按日取决于维护需求

月分区可以作为初始方案。如果每天的数据量很大,或者经常需要完整重导、移除某个交易日,可以比较日分区。

不要按每个 Ticker 或 strike 建分区,分区过细会增加管理和合并负担。分区主要服务于数据生命周期管理及分区裁剪,分区内部的读取效率还取决于排序键。ClickHouse 分区与排序机制

排序键跟随实际查询

示例排序适用于先限定标的、到期日、期权类型和行权价,再读取一段时间序列的查询。

如果主要操作是反复获取某一时刻的整条期权链,可以比较将 ts 放到行权价之前的布局,例如 (underlying, expiry, ts, option_type, strike)。这会改变单合约历史读取的局部性,应通过代表性查询来取舍;必要时再维护另一种排序的数据副本。

ClickHouse 的主索引是稀疏索引,ORDER BY 不提供唯一性约束。相同排序键的记录可以同时存在,因此不能把它当成业务去重机制。MergeTree 官方说明

如何提高 EOD 导入速度?

建议将导入流程组织为:

  1. 登记文件校验值、数据日期和批次状态。
  2. 在暂存层解析时间、校验字段、补齐合约信息,并筛选指定行权价。
  3. 通过校验后分批写入正式表,避免逐行提交。
  4. 核对行数、合约覆盖和时间范围,再将批次标记为可用于回测。

ClickHouse 官方建议同步写入时在客户端合并批次,通常可以从每批 1 万至 10 万行开始调整。实际批次还应考虑行宽、内存和并发;观察导入吞吐时,也要留意后台合并是否持续积压。批量写入策略

重跑需要明确的幂等策略:文件重复、任务部分成功和数据源修订是不同情况。除非已确认一个合约每秒只允许一条记录,否则不要只靠 Ticker + ts 删除重复行。

如果采用分区替换,暂存数据必须覆盖整个待替换分区。不能直接用某一天的数据替换月分区,否则会丢失同月其他日期的数据。回测也应只读取已经完成校验的批次或数据版本。

时间范围查询和聚合怎么写?

查询应同时带上交易日范围及必要的合约过滤条件,避免无条件读取全部历史。下面使用 ClickHouse 类型化查询参数,参数值由客户端提供:

SELECT
    expiry,
    strike,
    option_type,
    count() AS sample_count,
    min(LTP) AS min_ltp,
    max(LTP) AS max_ltp
FROM option_ticks
WHERE underlying = {underlying:String}
  AND expiry = {expiry:Date}
  AND trading_date >= {day_start:Date}
  AND trading_date < {day_end:Date}
  AND ts >= {ts_start:DateTime}
  AND ts < {ts_end:DateTime}
GROUP BY expiry, strike, option_type;

这里的 sample_count 是记录数。若源数据是秒级快照,它不代表成交笔数;min_ltp、max_ltp 也只是保留样本中的极值,不一定覆盖该时段全部交易。

回放单合约时,增加 strike、option_type 条件,只选择策略需要的列,并明确使用 ORDER BY ts。长时间历史可以分段读取,避免把整份数据一次性转换成 Python 对象,也避免对每一秒发起一次数据库查询。

Greeks 和期权链如何处理?

建议将 Greeks 作为独立的派生数据保存,记录合约、时间、模型版本和输入版本。现有列不足以独立完成所有计算,还需要根据定价模型准备对应时点的标的价格、剩余期限、利率,以及波动率或其求解输入。到期时刻和年化规则也必须明确。

期权链可以先按 underlying + expiry + ts 从明细表读取,再按行权价组织。如果这类查询成为明确瓶颈,再考虑预计算快照。

回测还需要处理几个容易影响结果的问题:

  • 对齐不同合约和标的行情时,只使用决策时刻已经可见的数据,并限制报价允许的陈旧程度。
  • 不要用日终信息决定当天早些时候应保留哪些行权价,否则可能引入前视偏差。
  • 仅保存指定行权价会限制未来策略的研究范围;原始归档应保留重新筛选的可能。
  • OpenInterest 不应沿时间直接累加;如果 LTQ 表示最近一笔成交数量,秒级快照中重复出现的数值也不能直接相加作为成交量。

分钟级聚合表适合确实只需要该粒度的策略。依赖秒内执行顺序、排队或精细成交模拟的策略,无法从 1 秒快照中恢复所需细节。

什么时候选择 PostgreSQL?

如果系统更依赖事务、唯一约束、频繁修订和业务表关联,PostgreSQL 也值得比较。可以采用时间范围分区、与查询匹配的复合索引,并通过 COPY 批量导入。PostgreSQL 分区文档、批量导入文档

根据当前以历史扫描和聚合为主的需求,我会先验证 ClickHouse,再用同一份数据与 PostgreSQL 比较。测试至少覆盖单合约历史、单日整条期权链和跨月多合约回测,记录导入耗时、查询延迟、扫描量、内存及返回数据的传输成本,同时验证结果一致性。

先在单机上确定数据布局和瓶颈,再决定是否分布式扩展。仅凭每天数百万行的数据量,无法判断需要多少节点,也无法承诺查询延迟。

备注:内容仅供参考。