远思为径,码动价值。欢迎来到远思码技术实验室的最新技术实践记录。
在量化数据存储选型那篇里,我留下了一个结论:冷数据归档用 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 GB | 210 ms |
| 压缩后 | 780 MB | 240 ms |
| 压缩后 + 带 symbol 条件 | 780 MB | 35 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 全部显式建模。
远思为径,码动价值。基础设施的耐心,最终都会体现在回测曲线是否可信上。