TimescaleDB 量化行情库进阶:分区、压缩与连续聚合

接续 ClickHouse 与 DolphinDB 的选型讨论,落地实验室的 TimescaleDB 行情库:超表分区策略、按 symbol 分段压缩、连续聚合做月线、幂等增量写入与保留策略,附日线/分钟线量级的实测数字。

远思为径,码动价值。欢迎来到远思码技术实验室的最新技术实践记录。

在量化数据存储选型那篇里,我留下了一个结论:冷数据归档用 ClickHouse,热数据计算交给 DolphinDB。但实验室真正跑日常研究的那套库,最后选了第三个选项——TimescaleDB。

原因很朴素:A 股日线一年也就一百多万行,任何数据库都扛得住;真正稀缺的是"能不能用 SQL 直接把数据拼出来"。TimescaleDB 是 PostgreSQL 扩展,意味着我可以用窗口函数、DISTINCT ON、CTE、generate_series 这些熟悉的东西,而不用为了一次取数去写一段 DolphinDB 脚本。

这篇记录的是把它从"能存"做到"能长期跑"的几个关键点。

一、超表分区:时间维度 + 空间维度

上一篇文章里我们建了最简单的超表:

CREATE TABLE daily_bar (
    symbol      text        NOT NULL,
    trade_date  date        NOT NULL,
    open        double precision,
    high        double precision,
    low         double precision,
    close       double precision,
    volume      bigint,
    amount      double precision,
    turnover    double precision,
    pct_chg     double precision,
    adj_factor  double precision DEFAULT 1.0,
    PRIMARY KEY (symbol, trade_date)
);

SELECT create_hypertable('daily_bar', 'trade_date',
    chunk_time_interval => INTERVAL '1 year',
    if_not_exists => TRUE);

日线量级下这样完全够用。但当我把分钟线也接进来(5 分钟 K 线,全市场一年约 1.6 亿行)之后,问题出现了:单个 chunk 内部扫的是全市场。查询"贵州茅台最近一年 5 分钟线"仍然要扫过所有股票的 5 分钟数据。

解决办法是引入空间维度,按 symbol 做哈希分区:

SELECT create_hypertable('minute_bar', 'ts',
    partitioning_column   => 'symbol',
    number_partitions     => 8,
    chunk_time_interval   => INTERVAL '7 days',
    if_not_exists         => TRUE);

建完之后,一张超表实际变成 8 × 时间片 个物理子表。查询条件里带 symbol 时,规划器直接裁掉 7/8 的分区。

经验值:number_partitions 别超过 CPU 核心数的两倍太多。分区太碎会让插入时的元数据开销超过收益,8 分片在实验室的 8 核机器上正好。

二、压缩:按 symbol 分段,按时间排序

时序数据最大的浪费是重复。同一只股票、连续的交易日,symbol 完全重复,价格变化很小。TimescaleDB 的列式压缩把"同一 segment 内的行"转成数组存储,压缩比通常在 5~20 倍。

ALTER TABLE minute_bar SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'symbol',
    timescaledb.compress_orderby   = 'ts DESC'
);

-- 7 天以前的数据自动压缩
SELECT add_compression_policy('minute_bar', INTERVAL '7 days');

两个参数的选择逻辑:

  • compress_segmentby 决定了"哪些行会被放在一起"。因为绝大多数查询都是按股票取一段连续时间,所以分段键选 symbol。
  • compress_orderby 决定段内排序。时间倒序排列,让"最新 N 条"的查询能快速定位。

实验室实测(8 核 / 32GB / NVMe,A 股 5 分钟线 1 年):

状态表大小单股 1 年查询耗时
未压缩6.3 GB210 ms
压缩后780 MB240 ms
压缩后 + 带 symbol 条件780 MB35 ms

压缩后写入变慢(约 1.4 倍),但我们的写入集中在盘后批处理,完全无所谓。如果你的场景是盘中高频写入 + 实时查询,压缩窗口不要设得太短——被压缩的 chunk 不能再直接 INSERT,新数据会跑到新的 chunk 去,这是设计使然而不是缺陷。

三、连续聚合:日线自动滚成月线

研究里最烦的是"每次都要手写一段 group by 把日线变月线"。连续聚合(Continuous Aggregate)把这一步变成库里的物化视图,增量刷新:

CREATE MATERIALIZED VIEW monthly_bar
WITH (timescaledb.continuous) AS
SELECT symbol,
       time_bucket('1 month', trade_date) AS trade_month,
       first(open, trade_date)  AS open,
       max(high)                AS high,
       min(low)                 AS low,
       last(close, trade_date)  AS close,
       sum(volume)              AS volume,
       sum(amount)              AS amount
FROM daily_bar
GROUP BY symbol, trade_month
WITH NO DATA;

SELECT add_continuous_aggregate_policy('monthly_bar',
    start_offset     => INTERVAL '3 years',
    end_offset       => INTERVAL '1 day',
    schedule_interval => INTERVAL '1 hour');

注意 end_offset => INTERVAL '1 day':只刷新到昨天为止。当月数据还在变,如果聚合到当天,物化结果就是一个会不断被改写的"半成品",容易在回测里用到未完成的月线。查询时当月数据仍然走实时聚合(TimescaleDB 会自动 union 未物化的部分)。

真实用法是这样:

-- 查询某只股票的月线序列,物化部分 + 实时部分自动拼接
SELECT trade_month, open, close, volume
FROM monthly_bar
WHERE symbol = '600519' AND trade_month >= '2020-01-01'
ORDER BY trade_month;

四、增量写入:幂等 upsert 与复权因子

盘后同步任务每天会重跑,必须保证幂等。ON CONFLICT DO UPDATE 是标准解法:

INSERT INTO daily_bar (symbol, trade_date, open, high, low, close,
                       volume, amount, turnover, pct_chg, adj_factor)
VALUES (:symbol, :trade_date, :open, :high, :low, :close,
        :volume, :amount, :turnover, :pct_chg, :adj_factor)
ON CONFLICT (symbol, trade_date) DO UPDATE
SET close = EXCLUDED.close,
    volume = EXCLUDED.volume,
    pct_chg = EXCLUDED.pct_chg,
    adj_factor = EXCLUDED.adj_factor;

这里有个容易忽略的设计:存未复权价 + 复权因子,而不是直接存前复权价。

  • 前复权价会在每次除权后整体重算,历史数据被"改写",导致同一天的因子值在不同时间点算出来不一样;
  • 业界惯例(也是 akshare 的 hfq 接口)是存后复权或存 adj_factor,复权在查询层做,历史数值永远不变。

查前复权价时在 SQL 里乘一下即可:

SELECT trade_date,
       close * adj_factor / MAX(adj_factor) OVER () AS close_qfq
FROM daily_bar WHERE symbol = '000001' ORDER BY trade_date;

反面教材:早期我用"每天重写整段前复权序列"的方式同步数据,结果同一个因子在 3 月跑出来 IC 是 0.031,6 月重跑变成 0.027。差的那一点就是除权。数值漂移是量化研究里最隐蔽的 bug。

五、保留策略与冷热分层

分钟线不能无限堆。实验室的策略是:

-- 3 年前的分钟线直接删除
SELECT add_retention_policy('minute_bar', INTERVAL '3 years');

日线则不删,因为它小到可以忽略(15 年 A 股日线约 2 GB,压缩后 300 MB 左右)。

如果要做真正的归档,可以把老 chunk 的压缩数据 COPY 导出到对象存储(实验室用的是内网的 MinIO),再用 drop_chunks 释放:

-- 导出后再删除
COPY (SELECT * FROM minute_bar WHERE ts < '2021-01-01')
TO PROGRAM 'gzip > /mnt/minio-archive/minute_bar_2020.csv.gz' CSV HEADER;

SELECT drop_chunks('minute_bar', older_than => INTERVAL '5 years');

六、一个取数函数,统一到矩阵

数据库再好,研究时用的还是 DataFrame。实验室封装了一个按需取数的函数,把"复权、停牌、字段选择"都收在这里:

# src/data/loader.py
from __future__ import annotations

import pandas as pd
from sqlalchemy import create_engine

DSN = "postgresql+psycopg2://quant:quant@127.0.0.1:5432/quant"


def load_bars(symbols: list[str], start: str, end: str,
              freq: str = "1d", adjust: str = "qfq",
              engine=None) -> pd.DataFrame:
    """取行情长表,自动按 adjust 做复权。

    adjust: 'none' | 'qfq'(前复权) | 'hfq'(后复权)
    返回列: symbol, trade_date, open, high, low, close, volume, amount
    """
    engine = engine or create_engine(DSN, pool_pre_ping=True)
    table = "daily_bar" if freq == "1d" else "minute_bar"
    tcol = "trade_date" if freq == "1d" else "ts"

    sql = f"""
        SELECT symbol, {tcol} AS trade_date, open, high, low, close,
               volume, amount, adj_factor
        FROM {table}
        WHERE {tcol} BETWEEN :start AND :end
          AND symbol = ANY(:symbols)
        ORDER BY symbol, {tcol}
    """
    df = pd.read_sql(sql, engine, params={"start": start, "end": end, "symbols": symbols})

    if adjust in ("qfq", "hfq") and not df.empty:
        df = df.sort_values(["symbol", "trade_date"])
        grp = df.groupby("symbol", sort=False)["adj_factor"]
        ratio = grp.transform("last") if adjust == "qfq" else 1.0
        for col in ["open", "high", "low", "close"]:
            df[col] = df[col] * df["adj_factor"] / ratio
    return df.drop(columns=["adj_factor"])

ratio 那一行就是前复权的全部秘密:把当前复权因子归一,最新的价格保持原始数值,历史价格被等比缩小。

七、踩过的坑清单

  • ALTER TABLE hypertable 的坑:超表上加索引必须建在超表上(CREATE INDEX ON daily_bar (...))),不要手动往 chunk 上建,重建 chunk 之后索引就丢了。
  • 主键必须包含时间分区列。TimescaleDB 的约束要求唯一索引包含分区列,PRIMARY KEY (symbol) 会直接报错。
  • time_bucket 与交易日历不是一回事。“按月"聚合出来的是自然月,2 月只有 15 个交易日,用它算月收益没问题,但用它算"每月调仓"就会和交易日错位。
  • 别在压缩 chunk 上做大量 UPDATE。压缩块和解压块的写入路径不同,反复更新老数据会把压缩收益吃光。需要修正历史数据时,一次性 decompress_chunk → 更新 → compress_chunk。
  • vacuum 不是万能的。时序表几乎只增不改,但 drop_chunks 之后还是建议看一眼 autovacuum 的触发情况。

结语

数据层做到这里,已经不是"能存数据"的层次,而是”取数不成为研究瓶颈":单股分钟线 35 ms、全市场日线截面 1 秒内出结果。

数据齐了、因子有了,接下来是最容易自欺欺人的一环:回测。下一篇从零写一个向量化回测引擎,把手续费、滑点、涨跌停、T+1 全部显式建模。

远思为径,码动价值。基础设施的耐心,最终都会体现在回测曲线是否可信上。