这篇面向需要在实际研究中处理大数据量(多年全市场行情、多因子并行查询)的读者。如果你的数据量不大(几年、几百只股票),可以先跳过,等遇到查询慢的问题时再回来。
导言
大多数时候,Betalens 的 Datafeed 查询足够快,你不需要关心 SQL 怎么写。但当数据量上来之后——比如多年全市场行情、多因子并行预查询——查询时间会从几秒跳到几十秒甚至几分钟。
这篇文章从两个角度讲优化:一个是 Betalens 内部的 time_tolerance 参数到底在控制什么;另一个是 PostgreSQL 层面的索引和分区策略。
time_tolerance 的真实含义
在 pre_query_characteristic_data 和 BacktestBase 里,都有一个 time_tolerance 参数。它的单位是小时,默认值不同:
| 函数 | 默认值 | 含义 |
|---|---|---|
pre_query_characteristic_data |
24*2*365 = 175200 小时(2年) |
预查询时,最多往前找两年的数据 |
BacktestBase |
24 小时(1天) |
回测时,价格数据的容差 |
pre_query_characteristic_data 中的 tolerance
1 | data = pre_query_characteristic_data( |
tolerance=175200 小时的含义:如果某只股票在调仓日没有股息率数据,框架最多往前找 2 年内的最近一条。这是为了处理财务数据”披露滞后”的问题——比如在 2021-04-01 调仓,2020 年的年报可能还没披露,框架会自动取 2019 年甚至更早的股息率。
设太大(24*10*365):查询范围过大,速度变慢,数据”过期”。
设太小(24*30):如果最近一期财报还没披露,就查不到任何数据,导致该股票在调仓日被排除。
调优建议:
1 | # 财务因子(年报披露滞后3-4个月):用2年 tolerance |
BacktestBase 中的 tolerance
1 | engine = BacktestBase( |
tolerance=24 的含义:如果某只股票在某天的收盘价缺失,最多往前找1天内的最近价格。这个容差用于处理极端情况(如数据源偶尔缺失某一天的价格)。
PostgreSQL 索引策略
为什么要关心索引
Betalens 的 Datafeed 底层是 SQL 查询。如果你在 market_daily_fact 上做全表扫描(没有索引),查询全市场 10 年日行情:
1 | SELECT * FROM market_daily_fact |
在没有索引的情况下,PostgreSQL 会做一次全表扫描——observation_fact 或 market_daily_fact 如果有几千万行,这个查询会非常慢。
主键索引
market_daily_fact 和 observation_fact 都有复合主键:
1 | -- market_daily_fact |
主键本身就是一个 B-tree 索引,对 entity_id + trade_date 的组合查询非常高效。这就是为什么 query_time_range 按 (code, date_range) 查询时很快。
BRIN 索引(推荐用于时序数据)
BRIN(Block Range Index)是一种轻量级索引,专门适合物理存储顺序和逻辑顺序一致的数据(比如按日期存储的行情表)。它比 B-tree 索引小很多,对大表的范围查询效果也很好。
1 | -- 为 trade_date 列创建 BRIN 索引 |
betalens_db_manager在init时不会自动创建这些额外索引。如果你确定要优化,需要手动执行上述语句(一次性的,DBA 操作)。
复合索引:PIT 查询优化
PIT 查询(query_nearest_before)有一个固定模式:
1 | WHERE metric_id = :mid |
如果这个查询很慢,可以在 observation_fact 上建一个复合索引:
1 | CREATE INDEX idx_observation_pit |
注意索引列的顺序:等于条件(metric_id、entity_id)放前面,范围条件(trade_date DESC)放后面。
分区表(超大数据量场景)
如果你的数据量超过 1 亿行(比如分钟级 Tick 数据),单表查询会成为瓶颈。此时可以对 market_daily_fact 做范围分区(按年份或月份):
1 | -- 创建主表(不带数据) |
分区后,查询某一年数据时 PostgreSQL 只扫描对应分区,不会全表扫描。
警告:分区是 DBA 操作,错误分区会导致数据丢失。在做分区之前,务必备份数据库。 Betalens 框架本身不依赖特定的表结构,分区后只要表名和字段不变,Datafeed 查询不受影响。
查询计划分析
在优化之前,先看查询计划(EXPLAIN):
1 | from betalens.datafeed import Datafeed |
关注几个关键指标:
Seq Scan:全表扫描,如果出现在大表上,需要加索引。Index Scan:用到了索引,好的。Rows:预估行数,如果和实际差异很大(>10x),说明统计信息过时,需要ANALYZE。
1 | -- 更新统计信息(解决预估不准的问题) |
开发者侧:Datafeed 内部的查询优化
Betalens Datafeed 在查询层做了一些内置优化:
- 批量 IN 查询:多只股票查询时,内部会合并成一条 SQL 的
IN条件,而不是逐只循环。 - PIT 查询的 LIMIT 1 优化:每个 entity/metric 组合只取一条最新记录,SQL 层面用
LIMIT 1加ORDER BY配合索引。 - 连接复用:Datafeed 维护一个连接池,频繁查询时不需要每次重新建立连接。
如果你发现某个查询仍然很慢,先用 EXPLAIN ANALYZE 确定瓶颈(是 Datafeed 的 Python 端,还是 PostgreSQL 的 SQL 执行),再决定是加索引、改 tolerance、还是换分区方案。
常见错误
1. 索引建反了顺序
1 | -- 错:把范围列放在前面 |
2. time_tolerance 太小导致数据缺失
1 | # 财务因子设了1个月 tolerance,4月调仓时年报还没披露,查不到数据 |
3. 做了分区但查询条件没包含分区键
1 | -- 如果查询不带 trade_date 条件,会扫描所有分区(性能灾难) |
延伸阅读
- Betalens 新手系列 · 05:数据库架构全图——fact/dim 架构。
- Betalens 新手系列 · 06:Datafeed API 实战——查询接口详解。
- Betalens 新手系列 · 07:数据治理工具 betalens_db_manager——数据导入。
docs/guide/db-manager.rst——官方数据库管理文档。- PostgreSQL 官方文档:BRIN Indexes、Partitioning。