阅读提示

这篇面向想理解 Betalens 数据层设计细节的读者。如果你只想快速跑策略,可以跳过这篇,等需要优化查询或做数据治理时再回来。

导言

Betalens 把所有研究数据存在 PostgreSQL 里,但这个数据库不是简单的”一张大宽表塞所有字段”,而是遵循了一套 fact/dim 架构(事实表 + 维表)。理解这套架构,能帮助你:

  • 在查询报错时快速定位是哪个表/字段出了问题。
  • 理解 PIT(Point-in-Time)查询的原理。
  • 在做数据导入时知道该往哪里写、怎么写。
  • 在查询性能出问题时判断瓶颈在 SQL 层面还是数据本身。

十五张基础表一览

Betalens 的 betalens schema 下有 15 张物理表,分为三类:

维度表(6 张)

表名 作用
entity_dim 证券实体维表,存储代码(如 000001.SZ)与数据库内部 ID 的映射
entity_name_history 证券名称历史(包含更名、上市/退市状态)
metric_dim 指标维表,存储指标名(如 "收盘价(元)")与 ID 的映射
metric_alias 指标别名映射(如 "close""收盘价(元)"
industry_scheme_dim 行业分类体系(如”申万一级行业”、”证监会行业”)
industry_dim 行业维度,存储每个行业分类体系下的具体行业
trade_calendar_day 交易日历,按交易所(exchange)存储每个交易日

事实表(7 张)

表名 作用
market_daily_fact 行情日频事实表,以 (entity_id, trade_date) 为主键,存储所有行情字段
observation_fact 观测值事实表,通用宽表,存储任意指标的时间序列,支持 PIT
industry_membership 行业成分关系,某股票在某日期属于哪个行业
index_snapshot 指数快照,某个指数在某日期的成分股列表
index_constituent 指数成分关系,某股票在某日期是否是某指数的成分股
trade_status_event 交易状态事件,记录停牌/复牌/上市/退市的时间点

管理表(2 张)

表名 作用
schema_migration Migration 版本记录,确保数据库 schema 版本一致
dataset_coverage 数据集覆盖情况,记录哪些 entity/metric 在哪些日期有数据

fact/dim 架构解析

行情事实表:market_daily_fact

这是最常用的表之一,结构大概是:

1
2
3
4
5
6
7
8
9
10
11
12
13
market_daily_fact (
entity_id BIGINT, -- 股票内码(FK → entity_dim)
trade_date DATE, -- 交易日期
-- 以下是行情字段(列式存储)
开盘价(元) DECIMAL,
最高价(元) DECIMAL,
收盘价(元) DECIMAL,
最低价(元) DECIMAL,
成交量(股) BIGINT,
成交额(元) DECIMAL,
...
PRIMARY KEY (entity_id, trade_date)
)

daily_marketdaily_indexdaily_funddaily_bond 本质上都是对 market_daily_fact 的视图映射,通过 entity_dim 中的 entity_type 字段区分股票/指数/基金/债券。

通用观测值表:observation_fact

fundamentals 表对应的底层存储是 observation_fact。它的结构是行式存储(每行一个 entity × metric × date):

1
2
3
4
5
6
7
8
9
10
observation_fact (
entity_id BIGINT,
trade_date DATE, -- 披露日/观测日
metric_id BIGINT,
value DECIMAL,
value_end_date DATE, -- PIT 关键:数据实际截止日期(报告期末)
valid_from TIMESTAMP, -- 数据入库时间(版本追踪)
valid_to TIMESTAMP,
PRIMARY KEY (entity_id, metric_id, trade_date)
)

value_end_date 是 PIT 查询的核心。举例:某公司在 2021-04-29 披露年报,股息率为 3.2%,年报截止日是 2020-12-31。那么:

  • trade_date = 2021-04-29(披露日)
  • value_end_date = 2020-12-31(数据对应的事实期末)
  • metric_id 对应 “股息率(报告期)”

如果我们在 2020-06-01 查询”当时能看到的股息率”,由于 value_end_date=2020-12-31 还没到,理论上这条数据当时不可见(年报还没披露)。PIT 查询通过比较 trade_datevalue_end_date 来实现严格的历史可见性控制。

交易状态表:trade_status_event

这张表不是每天每只股票的完整状态,而是一张事件表(稀疏存储):

1
2
3
4
5
6
trade_status_event (
entity_id BIGINT,
event_type SMALLINT, -- 1=上市, 0=停牌, -1=退市
event_date DATE,
PRIMARY KEY (entity_id, event_type, event_date)
)

解读逻辑在查询端还原:

1
2
3
4
5
# 查询端根据事件表还原每天的状态
# 某股票在 event_date 发生 event_type 事件
# event_type=1:从 event_date 起正常交易
# event_type=0:从 event_date 起停牌
# event_type=-1:从 event_date 起退市/未上市

这就是 trade_status 表(trade_status_matrix 在内存中的表示)的来源。框架根据事件表动态还原每天的状态。

PIT(Point-in-Time)查询原理

PIT 查询的意思是:在某个历史时点,我知道什么信息,就只能用什么信息做决策。

以股息率为例:

  • 2021-04-30 披露的年报(对应 2020 年末数据),在 2021-04-30 之前是不可见的。
  • 如果你在 2021-04-01 做调仓决策,不应该看到这条年报数据。
  • Betalens 通过 observation_fact.value_end_datetrade_date 的比较,在 SQL 层面过滤掉”历史上不可见”的数据。
1
2
3
4
5
6
7
8
# Betalens 的 PIT 查询等价 SQL 逻辑(简化)
SELECT * FROM observation_fact
WHERE metric_id = :metric_id
AND entity_id = :entity_id
AND trade_date <= :query_date -- 披露日在查询日之前
AND value_end_date <= :query_date -- 实际数据截止日也在查询日之前
ORDER BY trade_date DESC
LIMIT 1

pre_query_characteristic_data 中,time_tolerance 控制的是”最多往前找多少小时的数据”——比如设 time_tolerance=24*2*365(两年),意思是:如果某天没有找到该指标的数据,最多往前找两年内的最近一条。

行业维度与成分映射

行业信息存储在两张表里:

1
2
3
industry_scheme_dim (scheme_id, scheme_name)
industry_dim (dim_id, scheme_id, industry_name)
industry_membership (entity_id, scheme_id, dim_id, start_date, end_date)

industry_membership 记录某股票在某段时间内属于哪个行业(允许历史上变更行业)。

preprocess_factorindustry_scheme 参数(如 "申万一级行业")会在查询时自动做 PIT join,找到某股票在某调仓日的正确行业分类。

开发者侧:Schema 创建流程

Schema 不需要手动写 SQL,全部由 betalens_db_manager 管理。核心流程:

1
2
3
4
5
6
7
8
# 查看建表计划(dry-run)
python -m betalens_db_manager plan

# 初始化
.\betalens_db_manager\init_local.bat

# 深度验证
python -m betalens_db_manager verify --deep

内部用版本化的 migration 文件(0001 ~ 0009 等),每个 migration 有 checksum。如果数据库 checksum 不匹配,会拒绝启动,保证团队多人协作时 schema 一致。

常见误解

  • “fact/dim 是银弹”:这套架构适合因子研究,但不代表任何场景都最优。如果你的数据是分钟级 Tick 量,market_daily_fact 按天存储的设计就会成为瓶颈,需要额外的分区策略。
  • “trade_status 是每天的完整状态表”:它是事件表,不是每天每只股票一行。框架在查询时会实时还原。
  • “fundamentals 表是一张物理表”:实际上它是对 observation_fact 的视图,物理表只有 observation_fact 一张,所有非行情因子(财务、情绪、技术指标)都在这里。

延伸阅读