主题
01 — 数据层 (Data Layer)
Status: 🟢 v0.2.0 (Tushare 按需查询 + DuckDB TTL 缓存) 已上线 · v0.3.0 仓库化阶段规划中 Updated: 2026-05-08(v2:加入 §6.4-6.6 数据库使用模式与格式评估;回填窗口改为 2 年) Owner: liang Sibling spec:
docs/planning/superpowers/specs/2026-05-08-tushare-integration-design.md(数据集成层 / Provider 抽象 / Composer / cache 策略)
一、当前状态
1.1 v0.2.0 线上的数据通路
fetch_price.py / /price / /fundamentals 路由 → core/core/data/:
tushare.py:HTTP wrapper,call(api_name, params, fields, token)一次一调akshare.py:盘中实时报价(仅/price在开盘时段使用)schemas.py:get_price(走daily)、get_fundamentals(走fina_indicator)
落仓只有两张 TTL 缓存(src/service/cache.py → ~/twilight/data/):
| 表 | 接口 | TTL | 主键 | 用途 |
|---|---|---|---|---|
daily_cache | tushare daily | 24h | (code, trade_date) | /price 命中即返回 |
fundamentals_cache | tushare fina_indicator | 30d | (code, period) | /fundamentals 命中即返回 |
1.2 已经识破的局限
- 没有历史回填:每次只抓最新或单点,没有积累
- 没有跨表关联能力:缓存只是字符串/JSON blob,无法跑因子/选股等批查询
- fundamentals 范围窄:只取
fina_indicator的 4 个字段,三大报表完全没动 - 没有指数 / 成分 / 资金流 / 复权因子:研究 Agent 答不了"vs. 沪深300"、"前复权年化收益"、"机构持仓变动"
→ v0.3.0 目标:把 Tushare 上 A 股研究够用的全部接口落到本地 DuckDB 仓库,schedule + backfill,对外暴露 /warehouse/* 查询。
二、Tushare 账户与接入面
2.1 账户实证(2026-05-08 探针)
文档名义上 120 积分,但实际探针 21 个接口全过,实质处于 2000 档或更高。完整探针记录:scripts/probe_tushare_tier.py(待迁入仓库;当前在 /tmp/)。
| api_name | 文档积分门槛 | 实测 | 单次行数 |
|---|---|---|---|
daily | 120 | ✅ | 5,493 |
stock_basic | 2000 | ✅ | 5,513 |
trade_cal | 2000 | ✅ | 31 (单月) |
daily_basic | 2000 | ✅ | 5,493 |
adj_factor | 2000 | ✅ | 5,519 |
stk_limit | 2000 | ✅ | 7,576 |
moneyflow | 2000 | ✅ | 5,182 |
margin | 2000 | ✅ | 3 (= 三个交易所) |
dividend | 2000 | ✅ | 57 (单股全历史) |
top10_holders | 2000 | ✅ | 10 |
index_daily | 2000 | ✅ | 20 (单指数单月) |
index_dailybasic | 400 | ✅ | 12 |
income / balancesheet / cashflow | 2000 | ✅ | 1 (单期) |
fina_indicator | 2000 | ✅ | 1 |
forecast | 2000 | ✅ | 30 (单股全历史) |
disclosure_date | 500 | ✅ | 5,515 (单期) |
repurchase | 600 | ✅ | 2,000 (cap) |
share_float | 120 | ✅ | 6,000 |
stock_company | 120 | ✅ | 1 |
频次假设(按 2000 档官方上限推断):500 calls/min,常规接口无每日总量上限。 即使账户更高,规划用 2000 档上限即可,留余量。
2.2 显式不在采集面内
- 实时 / 分钟数据(
rt_k、stk_mins、rt_min)—— 走独立付费权限,跟积分无关 - 5000+ 接口:
bak_daily、fund_daily(ETF)、stock_st、bak_basic - 港股 / 美股:当前只做 A 股
- 跨源:pytdx3 / akshare / Yahoo —— v0.3.0 只用 Tushare 单源
三、采集计划(按更新频率分桶)
3.1 每日批跑(trade_cal 判定的交易日 18:30 / 19:00)
按 trade_date 拉全市场,单次调用一次性拿全 A 股 ~5,500 行。失败重跑,幂等 upsert。
| api_name | 中文 | 主键 | rows / call | doc | 优先级 |
|---|---|---|---|---|---|
daily | A 股日线(未复权) | ts_code+trade_date | ~5,500 | doc=27 | T1 |
daily_basic | 每日指标(PE/PB/换手/市值) | ts_code+trade_date | ~5,500 | doc=32 | T1 |
adj_factor | 复权因子 | ts_code+trade_date | ~5,500 | doc=28 | T1 |
stk_limit | 涨跌停价 | ts_code+trade_date | ~7,500 | doc=183 | T2 |
moneyflow | 个股资金流 | ts_code+trade_date | ~5,500 | doc=170 | T2 |
margin | 融资融券大盘汇总 | trade_date+exchange_id | 3 | doc=58 | T2 |
index_daily | 指数日线(限沪深300/中证500/中证1000/上证综指) | ts_code+trade_date | 4 (4 只指数) | doc=95 | T2 |
index_dailybasic | 大盘指数每日指标 | ts_code+trade_date | ~12 | doc=128 | T2 |
单日总量:约 30k rows,6-8 calls,30 秒以内完成。
3.2 每周(每周一 03:00)
| api_name | 中文 | 主键 | doc | 优先级 |
|---|---|---|---|---|
pledge_stat | 股权质押统计 | ts_code+end_date | doc=110 | T5 (defer) |
3.3 每月(每月 1 日 02:00)
| api_name | 中文 | 主键 | doc | 优先级 |
|---|---|---|---|---|
stock_basic | 股票列表快照(list_status=L|D|P 各拉一次) | ts_code | doc=25 | T1 |
namechange | 股票曾用名 | ts_code+start_date | doc=100 | T4 |
3.4 季报披露窗口(4/30、8/31、10/31、次年 4/30;窗口内每日 02:00)
按 period(YYYYMMDD 季末)拉,单股单期单调用。先用 disclosure_date 拿到当天有公告的 ts_code 列表,再循环抓。
| api_name | 中文 | 主键 | doc | 优先级 |
|---|---|---|---|---|
disclosure_date | 财报披露日表(调度入口) | ts_code+end_date | doc=162 | T2 |
income | 利润表 | ts_code+end_date+report_type | doc=33 | T3 |
balancesheet | 资产负债表 | ts_code+end_date+report_type | doc=36 | T3 |
cashflow | 现金流量表 | ts_code+end_date+report_type | doc=44 | T3 |
fina_indicator | 财务指标 | ts_code+end_date | doc=79 | T3 |
forecast | 业绩预告 | ts_code+end_date+ann_date | doc=45 | T3 |
express | 业绩快报 | ts_code+end_date | doc=46 | T3 |
top10_holders | 前十大股东 | ts_code+end_date+holder_name | doc=61 | T4 |
top10_floatholders | 前十大流通股东 | ts_code+end_date+holder_name | doc=62 | T4 |
说明(report_type): Tushare 同一 (ts_code, end_date) 可能有多份记录,对应原始报表(1)、单季合计(2)、合并报表(4)等不同口径。落仓必须把 report_type 进 PK。
3.5 事件触发(每天 09:00 扫一次 disclosure_date / 公告增量)
| api_name | 中文 | 主键 | doc | 优先级 |
|---|---|---|---|---|
dividend | 分红送股 | ts_code+end_date+div_proc | doc=103 | T4 |
share_float | 限售解禁 | ts_code+ann_date+float_date | doc=160 | T4 |
repurchase | 股票回购 | ts_code+ann_date+end_date | doc=124 | T4 |
stk_holdertrade | 股东增减持 | ts_code+ann_date+holder_name | doc=175 | T4 |
block_trade | 大宗交易 | ts_code+trade_date+price+vol | doc=161 | T5 |
new_share | IPO 新股 | ts_code+ipo_date | doc=123 | T5 |
3.6 一次性 + 年度刷新
| api_name | 中文 | 主键 | doc | 频率 |
|---|---|---|---|---|
trade_cal | 交易日历 | exchange+cal_date | doc=26 | 每年 12 月续 +1 年 |
stock_company | 上市公司基本信息 | ts_code | doc=112 | 季度 + 事件 |
四、Tier 1 字段明细
下面 5 个表是首发实施目标。其余 tier 的字段明细待对应 tier 实施时再去抓 Tushare 文档填充。
4.1 daily — A 股日线(未复权)
Doc: https://tushare.pro/document/2?doc_id=27主键(落仓): ts_code + trade_date调用方式: 按 trade_date 拉全市场(推荐),或按 ts_code + start_date/end_date 拉单股区间
输入参数:
| 参数 | 类型 | 必选 | 说明 |
|---|---|---|---|
ts_code | str | 任选 | 股票代码(如 600519.SH) |
trade_date | str | 任选 | 交易日期 YYYYMMDD |
start_date | str | N | 开始日期 |
end_date | str | N | 结束日期 |
输出字段:
| 字段 | 类型 | 中文 | 备注 |
|---|---|---|---|
ts_code | str | TS 代码 | PK |
trade_date | str | 交易日期 | PK,YYYYMMDD |
open | float | 开盘价 | 元 |
high | float | 最高价 | 元 |
low | float | 最低价 | 元 |
close | float | 收盘价 | 元 |
pre_close | float | 昨收价 | 元(前一交易日) |
change | float | 涨跌额 | close - pre_close |
pct_chg | float | 涨跌幅 | %(基于未复权) |
vol | float | 成交量 | 单位手(注意不是股) |
amount | float | 成交额 | 单位千元 |
v0.3.0 落仓表: normalized_daily(ts_code, trade_date, open, high, low, close, pre_close, change, pct_chg, vol, amount, source='tushare', ingested_at, raw_id)
4.2 daily_basic — 每日指标
Doc: https://tushare.pro/document/2?doc_id=32主键(落仓): ts_code + trade_date调用方式: 按 trade_date 拉全市场
输入参数: 同 daily
输出字段:
| 字段 | 类型 | 中文 | 备注 |
|---|---|---|---|
ts_code | str | TS 代码 | PK |
trade_date | str | 交易日期 | PK |
close | float | 收盘价 | 元(与 daily.close 重复) |
turnover_rate | float | 换手率 | %(基于流通股) |
turnover_rate_f | float | 换手率(自由流通股) | % |
volume_ratio | float | 量比 | — |
pe | float | 市盈率(总市值/净利润,亏损为负) | — |
pe_ttm | float | 市盈率 TTM | — |
pb | float | 市净率(总市值/净资产) | — |
ps | float | 市销率 | — |
ps_ttm | float | 市销率 TTM | — |
dv_ratio | float | 股息率 | % |
dv_ttm | float | 股息率 TTM | % |
total_share | float | 总股本 | 万股 |
float_share | float | 流通股本 | 万股 |
free_share | float | 自由流通股本 | 万股 |
total_mv | float | 总市值 | 万元 |
circ_mv | float | 流通市值 | 万元 |
注意: 单位均为"万"。落仓时是否标准化为元/股 / 元 是个 open question — 倾向保留原单位减少歧义,对外查询接口统一口径。
4.3 adj_factor — 复权因子
Doc: https://tushare.pro/document/2?doc_id=28主键(落仓): ts_code + trade_date调用方式: 按 trade_date 拉全市场
输出字段:
| 字段 | 类型 | 中文 | 备注 |
|---|---|---|---|
ts_code | str | TS 代码 | PK |
trade_date | str | 交易日期 | PK |
adj_factor | float | 复权因子 | 前复权 = close × adj_factor / latest_adj_factor;后复权 = close × adj_factor |
只有 3 列,但是因子本身的语义重要。前复权是对历史价做 adjust 让现价对齐,后复权让基准价对齐到首日。仓库存原始因子,复权计算放在查询层。
4.4 stock_basic — 股票列表
Doc: https://tushare.pro/document/2?doc_id=25主键(落仓): ts_code + list_status(同一只股票从 L → D 时多一行记录) 调用方式: list_status=L|D|P 各拉一次(默认 L 只返回上市中的)
输入参数:
| 参数 | 类型 | 必选 | 说明 |
|---|---|---|---|
ts_code | str | N | TS 代码 |
name | str | N | 名称 |
market | str | N | 市场(主板/创业板/科创板/北交所/CDR) |
list_status | str | N | 上市状态:L 上市,D 退市,P 暂停(默认 L) |
exchange | str | N | SSE / SZSE / BSE |
is_hs | str | N | 是否沪深港通 |
输出字段:
| 字段 | 类型 | 中文 | 备注 |
|---|---|---|---|
ts_code | str | TS 代码 | PK 一部分 |
symbol | str | 股票代码(无后缀) | |
name | str | 股票名称 | |
area | str | 地域 | 省份 |
industry | str | 行业 | Tushare 自定义分类 |
fullname | str | 全称 | |
enname | str | 英文名 | |
cnspell | str | 拼音缩写 | |
market | str | 市场 | 主板/创业板/科创板/北交所 |
exchange | str | 交易所 | SSE/SZSE/BSE |
curr_type | str | 交易货币 | CNY |
list_status | str | 上市状态 | L/D/P,PK 一部分 |
list_date | str | 上市日期 | YYYYMMDD |
delist_date | str | 退市日期 | YYYYMMDD,仅 D 状态有 |
is_hs | str | 是否沪深港通 | N/H/S |
act_name | str | 实际控制人 | |
act_ent_type | str | 实际控制人企业性质 |
调度: 月初快照一次。回填用: 给所有"日级接口"的循环提供 ts_code 列表。
4.5 trade_cal — 交易日历
Doc: https://tushare.pro/document/2?doc_id=26主键(落仓): exchange + cal_date调用方式: 按交易所拉一段 start_date ~ end_date
输入参数:
| 参数 | 类型 | 必选 | 说明 |
|---|---|---|---|
exchange | str | N | SSE / SZSE / BSE / CFFEX 等(默认 SSE) |
start_date | str | N | 开始日期 |
end_date | str | N | 结束日期 |
is_open | str | N | 是否交易(0 否 / 1 是) |
输出字段:
| 字段 | 类型 | 中文 | 备注 |
|---|---|---|---|
exchange | str | 交易所 | PK 一部分 |
cal_date | str | 日历日期 | PK 一部分,YYYYMMDD |
is_open | int | 是否交易 | 0/1 |
pretrade_date | str | 上一交易日 | 周末/节假日时指向之前最近的交易日 |
调度: 每年 12 月底续 +1 年;每日 18:00 调度入口先查这张表判定今天是不是 is_open=1,否则跳过日级批跑。
五、Tier 2-4 字段明细 (TODO)
以下接口的字段明细待各 tier 实施时按 §4 的格式补全。落仓 schema 名 normalized_<api_name>,主键见 §3 表格的"主键"列。
| Tier | api_name | 状态 |
|---|---|---|
| T2 | stk_limit | 待文档抓取后填充 |
| T2 | moneyflow | 同上 |
| T2 | margin | 同上 |
| T2 | index_daily | 同上 |
| T2 | index_dailybasic | 同上 |
| T2 | disclosure_date | 同上 |
| T3 | income | 字段量大(~70 列),单独章节 |
| T3 | balancesheet | 字段量大(~140 列),单独章节 |
| T3 | cashflow | 字段量大(~70 列),单独章节 |
| T3 | fina_indicator | 字段量大(~80 列),单独章节 |
| T3 | forecast | 待补 |
| T3 | express | 待补 |
| T4 | dividend | 待补 |
| T4 | top10_holders / top10_floatholders | 待补 |
| T4 | share_float | 待补 |
| T4 | repurchase | 待补 |
| T4 | stk_holdertrade | 待补 |
六、仓库 Schema 与数据格式
6.1 Raw 层(append-only)
每次 Tushare 调用的整团响应原样落,永不修改、永不删除。出错时可回放,schema 演进时可重灌。
sql
CREATE SEQUENCE seq_raw_daily_id;
CREATE TABLE raw_daily (
id BIGINT NOT NULL DEFAULT nextval('seq_raw_daily_id'),
api_name VARCHAR NOT NULL, -- 'daily',冗余防误用
params VARCHAR NOT NULL, -- 调用参数 JSON 字串
fetched_at TIMESTAMP NOT NULL, -- UTC
http_status INTEGER NOT NULL,
response JSON NOT NULL, -- DuckDB JSON 类型,可 ->'data'->>'items' 解构
row_count INTEGER NOT NULL,
PRIMARY KEY (id)
);字段类型选择:
params用VARCHAR(不是JSON):参数小、查询场景几乎只是 debug,无需 JSONB 索引response用JSON:DuckDB 原生 JSON 类型,回放 normalized 时可直接 SQL 解构fetched_at用TIMESTAMP:DuckDB microsecond 精度,按 UTC 约定(写入前astimezone(UTC).replace(tzinfo=None))
容量估算(T1 三表 2 年):~720 MB raw JSON。
6.2 Normalized 层(按 PK upsert)
字段拆出来、类型严格化、加 source / ingested_at / raw_id 三个元数据列。
sql
CREATE TABLE normalized_daily (
ts_code VARCHAR NOT NULL,
trade_date DATE NOT NULL, -- ⚠️ 用 DATE,不是 VARCHAR
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE NOT NULL,
pre_close DOUBLE,
change DOUBLE,
pct_chg DOUBLE,
vol BIGINT, -- 单位手;BIGINT 防超大盘
amount DOUBLE, -- 单位千元
source VARCHAR NOT NULL DEFAULT 'tushare',
ingested_at TIMESTAMP NOT NULL,
raw_id BIGINT NOT NULL, -- 指回 raw_daily.id
PRIMARY KEY (ts_code, trade_date, source)
);字段类型选择(重要):
| 字段类 | 类型 | 原因 |
|---|---|---|
trade_date | DATE | DuckDB DATE 支持 BETWEEN / date_trunc / 排序;VARCHAR 字串虽然在 YYYY-MM-DD 格式下能字典序对,但失去 SQL 时间函数 |
价格 (open/close/...) | DOUBLE | A 股价最大 ~200,000 元,DOUBLE 15 位有效位绰绰有余;DECIMAL(10,4) 更"精确"但 DuckDB 上无性能优势 |
vol | BIGINT | A 股最大单日成交量 ~5×10⁸ 手,超 INT32 范围 |
amount | DOUBLE | 千元单位最大 ~10⁹;保留小数 |
pct_chg | DOUBLE | 涨跌幅是百分比(如 4.39 表示 4.39%),不是 0.0439 |
pe / pb / dv_ratio | DOUBLE NULL-able | 亏损股 / 新股可能 NULL |
重灌策略: 解析逻辑改了 / 字段加了 → 清空 normalized,从 raw 回放重灌。Tushare 不需要再调。
6.3 不做的层
- Curated(前复权后调整、报表合并口径)—— 业务逻辑后置到查询层(视图 / API),不持久化
- Feature(MA、PE 带、动量)—— 衍生指标暂不做;Agent 还没明确批量需求
6.4 数据库使用模式分析
落仓后查询会出现 5 类 archetype。设计要保证每一类都跑得动:
| 类型 | 例子 | 行扫规模 | 关键性能要素 |
|---|---|---|---|
| Q1 — 点查 | 永鼎股份今天 close | 1 行 | PK 上 ART index 命中 |
| Q2 — 单股时序 | 璞泰来 60 日 MA | 60 行 | PK 前缀 (ts_code) + 物理排序 |
| Q3 — 横截面 | 今天涨幅前 20 名 | 5,500 行扫 | 物理排序 by (trade_date, ts_code) 时 zone-map 命中 |
| Q4 — 窗口聚合 | 所有股票 20 日波动率 | 5,500 × 20 行 | 同 Q3 + 向量化 window function |
| Q5 — 多表 JOIN | close + PE + 行业 | 双 5,500 + JOIN | 两边都按 (ts_code, trade_date) 排序 |
Q3-Q5 是 DuckDB 选型的核心价值。 若只考虑 Q1-Q2,SQLite 也够;但 stock-screening / 因子计算 / 跨股聚合(Hermes profile 真实查询模式)属于 Q3-Q5,DuckDB 列存 + 向量化执行能比 SQLite 快 10-100 倍。
预期 QPS: 单租户 v0.3.0 阶段:
- Q1(/price 点查)—— 几十次/天
- Q2(agent 时序请求)—— 几十次/天
- Q3-Q4(screening / 因子)—— 个位数/天
- Q5(多表)—— 大概 Q1 的 3-4 倍(agent 查行情时常想顺带知道 PE)
- 总 QPS < 0.01。性能不是约束,正确性才是。
6.5 数据格式选型评估
6.5.1 物理排序 + Zone Map(替代传统索引)
DuckDB 不像 PostgreSQL 那样自由建 B-tree。主要靠物理排序 + zone map(每个 row group 的 min/max 统计)。
回填完成后跑:
sql
-- 让 (ts_code, trade_date) 在物理上聚簇 —— Q1 / Q2 / Q5 受益
CREATE TABLE normalized_daily_sorted AS
SELECT * FROM normalized_daily
ORDER BY ts_code, trade_date;
DROP TABLE normalized_daily;
ALTER TABLE normalized_daily_sorted RENAME TO normalized_daily;每次重大 backfill 后跑一次,等同于 PostgreSQL 的 CLUSTER。日常增量 INSERT 不需要重排(DuckDB row group 内部依然有序,新数据加入新 row group)。
6.5.2 二级索引(必要时)
DuckDB 支持 ART index:
sql
-- 仅当 Q3 横截面查询频繁且物理排序未命中时再加(否则浪费空间)
CREATE INDEX idx_daily_date_code ON normalized_daily(trade_date, ts_code);实测前不要预加。物理排序通常足够。
6.5.3 大表(财报)的纵列 vs 横列
income ~70 列、balancesheet ~140 列、fina_indicator ~80 列。两种存法:
| 形式 | 例 | 优 | 劣 |
|---|---|---|---|
| 横列(wide) | 一行 = 一个 (ts_code, end_date, report_type),70-140 列字段 | 1:1 镜像 Tushare 输出,调试 / 回放容易 | 单行宽,部分字段是 NULL(Tushare 不同报表披露不全) |
| 纵列(tall / EAV) | 一行 = 一个 (ts_code, end_date, report_type, metric_name, value) | 字段未知时灵活 | 失去类型 / 失去 IDE 补全 / SQL 累 |
→ 选 wide。 财报字段是 Tushare 文档定义的、相对稳定;DuckDB 列存对宽表 + 大量 NULL 友好(NULL 几乎零成本)。
6.5.4 单一文件 vs 分库
DuckDB 是嵌入式单文件库。我们的选择:
- 一个文件
~/twilight/data/warehouse.duckdb:所有raw_*/normalized_*/ 元数据表合一- ✅ ACID,跨表 JOIN 零开销
- ✅ 备份 =
cp warehouse.duckdb warehouse.duckdb.bak(停 service 3 秒) - ✅ 我们的体量(< 10 GB)单文件无压力
- ❌ 拆多个文件:除非有不同生命周期 / 容量考量,否则只是给自己找麻烦
→ 单文件。
6.5.5 Schema 演进
DuckDB 支持 ALTER TABLE ADD COLUMN、DROP COLUMN、RENAME COLUMN。Tushare 加新字段时:
ALTER TABLE normalized_daily_basic ADD COLUMN <new_field> DOUBLE;- 重灌(从 raw 回放,不调 Tushare)
- 旧 raw 行没有该字段 → NULL
禁止: 复用旧 normalized 表做不兼容改动(如改字段类型)。改类型 = 新建表 + 数据迁移 + DROP 旧表。
6.5.6 时区约定(再强调)
| 字段类 | 时区 | 注释 |
|---|---|---|
trade_date (DATE) | "上海日期" | 与 Tushare 一致;不存时间部分 |
ingested_at / fetched_at (TIMESTAMP) | UTC,naive 存 | 写入前 astimezone(UTC).replace(tzinfo=None) |
| Quote.as_of (02 spec, in-memory) | UTC, aware | 出库读出后 replace(tzinfo=UTC) |
任何混用都是 bug。
6.6 视图与连接管理
6.6.1 跨表 JOIN 视图
为了让上层查询不必每次手写 JOIN,定义几个常用视图(视图无额外存储成本):
sql
-- 当日完整信息:行情 + 指标 + 复权因子合一
CREATE OR REPLACE VIEW v_daily_full AS
SELECT
d.ts_code, d.trade_date,
d.open, d.high, d.low, d.close, d.pre_close, d.pct_chg,
d.vol, d.amount,
b.pe, b.pe_ttm, b.pb, b.ps_ttm, b.dv_ratio, b.turnover_rate,
b.total_mv, b.circ_mv, b.total_share, b.float_share,
a.adj_factor,
d.source, d.ingested_at
FROM normalized_daily d
LEFT JOIN normalized_daily_basic b USING (ts_code, trade_date)
LEFT JOIN normalized_adj_factor a USING (ts_code, trade_date);QFQ / HFQ close 的计算放在 02 spec 的 Quote dataclass 上,不在视图里算(需要拿到"最新 adj_factor",是查询参数而非常量)。
6.6.2 连接管理(FastAPI ↔ DuckDB)
DuckDB 单进程多连接:写串行,读并发。 我们的访问模式:
- Ingest 任务(APScheduler in-process):长寿命 1 个写连接,跨多个 INSERT/UPSERT 复用
- API 路由(
/price,/warehouse/*):每请求开短寿命读连接(pool 复用),关闭释放
python
# src/service/db.py(v0.3.0 W1 新建)
class WarehousePool:
"""读连接池,FastAPI lifespan 持有。"""
def __init__(self, path: str, size: int = 4): ...
@contextmanager
def read(self) -> duckdb.DuckDBPyConnection: ...
class WarehouseWriter:
"""写连接,APScheduler 任务持有;进程内单例。"""
def __init__(self, path: str): ...
def execute(self, sql: str, params: list): ...
def upsert(self, table: str, rows: list[dict]): ...DuckDB 连接很轻(无 wire protocol,是内存对象),pool size = 4 在 1 vCPU / 1500 MB 容器内够用。
已知陷阱: DuckDB 写事务中如果有读连接打开,读拿的是事务之前的 snapshot。Ingest 完成 commit 后立刻可读 —— 但读连接如果在 ingest 期间持有 transaction,需要 close + reopen。Pool 实现里 read connection 默认 BEGIN; ... COMMIT; 短事务即可。
6.7 元数据辅助表
sql
CREATE TABLE source_freshness (
source VARCHAR PRIMARY KEY, -- 'tushare:daily', 'tushare:income' 等
last_success TIMESTAMP,
last_failure TIMESTAMP,
error_msg VARCHAR,
rows_today INTEGER DEFAULT 0
);
CREATE TABLE quality_log (
id BIGINT NOT NULL DEFAULT nextval('seq_quality_log_id'),
ts TIMESTAMP NOT NULL,
check_name VARCHAR NOT NULL, -- 'row_count_threshold' | 'schema_validation' | 'backfill_gap' | ...
source VARCHAR,
ts_code VARCHAR,
trade_date DATE,
severity VARCHAR NOT NULL, -- 'info' | 'warning' | 'error'
message VARCHAR NOT NULL,
PRIMARY KEY (id)
);
-- 回填断点续跑:每个 (api_name, trade_date) 一行;幂等
CREATE TABLE backfill_progress (
api_name VARCHAR NOT NULL,
trade_date DATE NOT NULL,
status VARCHAR NOT NULL, -- 'pending' | 'success' | 'failed'
raw_id BIGINT, -- 成功时指回 raw_<api>.id
attempts INTEGER NOT NULL DEFAULT 0,
last_error VARCHAR,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (api_name, trade_date)
);七、调度
7.1 选型
APScheduler in-process,跟 FastAPI 同进程同容器。理由:
- 单实例够用(v0.3.0 没有横向扩展)
- 跟
/warehouse/*查询路由共享 DuckDB 连接,写完立即可读 - 失败重试、错过补跑都内置
- 无外部依赖(不引入 Celery/Redis)
7.2 触发表
| Job | Cron | 任务 |
|---|---|---|
trade_cal_check | 每日 17:30 | 查 trade_cal,决定今晚是否跑日级批 |
daily_batch | 交易日 18:30 | §3.1 八个接口顺序跑 |
event_scan | 每日 09:00 | §3.5 事件接口增量扫描 |
monthly_refresh | 每月 1 日 02:00 | §3.3 |
quarterly_disclosure | 季报窗口(4-5/8-9/10-11 月)每日 02:00 | §3.4 |
yearly_refresh | 每年 12 月 28 日 03:00 | trade_cal 续期 |
7.3 失败策略
每个 job 调用 wrapper:
- 起一行 raw INSERT 之前不算开始
- 任何一步失败 →
source_freshness.last_failure+ 异常入日志 - 同一 job 当天最多重试 3 次(间隔 5/15/60 分钟)
- 第二天如果上一日
last_success < cutoff,用start_date/end_date补跑缺口
八、历史回填(两年)
8.1 Stage 1 范围(默认)
T1 三表(daily / daily_basic / adj_factor)回填 T-730d ~ today(两年)。
- 245 trade days/年 × 2 年 = ~490 trade_dates
- 3 接口 × 490 = ~1,470 calls
- 单次 ~5,500 rows × 1,470 calls ≈ ~8M rows
为什么 2 年是 sweet spot:
- 跨完整年度周期(年报披露 / 股息 / 52 周高低)
- 容纳 60-200 日均线 / 季同比 / 跨年财报对比所需历史
- 不到 20 MB 比 5 年回填便宜 ~150 MB 磁盘 + 25 分钟时间
8.2 资源预算
| 维度 | 数值 |
|---|---|
| API 时间 | 500 calls/min @ 2000 档 → 纯调用 ~3 分钟 |
| Wall time(含重试 / 网络抖动) | ~10-15 分钟 |
| 平均频次 | ~70 calls/min(远低于 500 上限,不会触发限流) |
| HTTP 下载流量 | ~120 MB |
| Raw JSON 落盘 | ~360 MB |
| Normalized 落盘 | ~150 MB |
| 内存峰值 | < 200 MB(流式 per-call append) |
8.3 实施
一次性脚本 scripts/backfill_tushare_t1.py,不进调度器:
python
# 伪代码
trade_dates = warehouse.query(
"SELECT cal_date FROM normalized_trade_cal "
"WHERE exchange='SSE' AND is_open=1 "
" AND cal_date BETWEEN today - INTERVAL 730 DAYS AND today"
)
for api_name in ["daily", "daily_basic", "adj_factor"]:
for trade_date in trade_dates:
if backfill_progress.is_done(api_name, trade_date):
continue # 断点续跑
items = tushare.call(api_name, params={"trade_date": trade_date}, ...)
raw_id = warehouse.insert_raw(api_name, items, ...)
warehouse.upsert_normalized(api_name, items, raw_id=raw_id)
backfill_progress.mark_done(api_name, trade_date, raw_id)幂等性保证: backfill_progress 表 + normalized PK upsert,脚本随便重跑。
回填后再做一次物理排序(§6.5.1)让 zone-map 命中。
8.4 T2-T4 各自的回填规模
| Tier | 回填策略 | 调用次数 | 估时 |
|---|---|---|---|
| T2 日级(5 表) | 按 trade_date 拉,2 年 | 490 × 5 = 2,450 | ~10 分钟 |
| T3 季报(5 表,按 ts_code+period) | 5,500 stocks × 8 季 × 5 表 | ~220k | ~7 小时(过夜跑,4 worker 并发) |
| T4 公司动作 | 按事件,规模小 | ~5k | ~10 分钟 |
T3 是大头(~7 小时),原因不是数据量而是 Tushare 的"按 (ts_code, period) 单股单期调用"模式。可 4 worker 并发拉,但仍受 500/min 总限流约束 → 实际 ~125/min throttled。
8.5 Stage 2(可选 / 后置)
两年数据稳定 + 容量充足 + Agent 提需求时再跑:
- 范围: T-1825d ~ T-730d(再补 3 年深度)
- 量: ~735 × 3 = ~2,205 额外 calls
- 耗时: ~15-25 分钟
- 磁盘: +~225 MB normalized
独立任务,不阻塞 T2-T4 路线图。
8.6 通达信兜底(暂不做)
短期内 Tushare 2000 档配额相对回填量级是数量级冗余,通达信不作为兜底必需。
通达信真正的用武之地是跨源对账(02 spec §6 / §8 的 provider_diff_log):
- 来源独立(TDX 本地协议 vs Tushare HTTP),不同质化
- 可发现 Tushare 已知 bug(曾用名延迟、复权因子边界)
→ 列入 W7+ 独立 spec,不在本 doc 范围。可选数据源:
- tdx.com.cn 财务数据下载
- pytdx3 Python 协议库
- tdx2db ETL 参考
九、存储与主机
9.1 单机方案(v0.3.0)
跑在 tvps(现有 Vultr,1 vCPU / 4 GB RAM / 容器配额 0.75 cpu / 1500 MB)。
- DuckDB 文件:
/data/warehouse.duckdb(容器内挂载到 host~/twilight/data/warehouse.duckdb) - 与现有
bearers.sqlite/daily_cache.duckdb/fundamentals_cache.duckdb同盘不同文件 - 现有两张 cache 表暂时保留并行运行;T1 落仓后
/price路由切到查normalized_daily_basic,cache 表逐步废弃
9.2 容量预算
按 §8 的两年 backfill + 1 年增量 ingest 算(含 T1-T4 全部):
| 类别 | 体积 | 备注 |
|---|---|---|
| Raw 层 — T1 三表 (2y backfill + 1y 增量) | ~720 MB | JSON, 未压缩 |
| Raw 层 — T2 五表 (3 年) | ~1.5 GB | |
| Raw 层 — T3 季报 (2y × 8 季 × 5,500 stocks × 5 表) | ~1 GB | |
| Raw 层 — T4 事件类 | ~200 MB | 事件稀疏 |
| Normalized 层 — 全部 daily 类(3 年) | ~600 MB | DuckDB 列存压缩 (5-10x) |
| Normalized 层 — 季报 | ~400 MB | 宽表 + 大量 NULL,DuckDB 友好 |
| 索引 + 元数据 + 临时 | ~1 GB | DuckDB 自身开销 |
quote_cache(02 spec) | ~50 MB | 滚动 |
| 合计预算 | ~5.5 GB | 留余量到 8 GB |
| Stage 2 (3 年深度补充,可选) | +~225 MB | 见 §8.5 |
Open question: tvps 当前磁盘 / 剩余空间未确认。第一步实施前 ssh tvps df -h 验证;预算 10 GB+ 可用空间(之前给 25 GB 是按 5 年估的,2 年方案 10 GB 即可)。
9.3 不做的事(与原 Phase 2 §A spec 的差异)
- ❌ Mac Mini PRIMARY → Vultr REPLICA + 夜间 rsync 副本 —— v0.3.0 单机够用
- ❌ 4 层(Curated / Feature)—— 见 §6.3
- ❌ 多源(pytdx3 / akshare / Yahoo)
→ Phase 2 §A spec (docs/planning/superpowers/specs/2026-05-07-twilight-drive-phase2.md §A) 的相关章节已经被本 doc 的 §3-§8 取代。该 spec 留作 Phase 2 整体规划存档,但 §A 视为 superseded by this。
十、API 暴露(落仓后)
T1 完成后追加(不破坏现有 /price / /fundamentals):
GET /warehouse/daily?code=600519.SH&start=2025-01-01&end=2025-12-31&adjust=qfq
GET /warehouse/fundamentals?code=600519.SH&period=20251231
GET /warehouse/factors?code=600519.SH&date=2026-04-30
GET /quality/status # source_freshness 一览
POST /admin/ingest/run?api_name=daily&trade_date=20260507 # 手动触发/price 内部逐步切换到查 normalized_daily_basic:
- 现:
tushare.call(daily) → cache → return - 切换后:
SELECT close FROM normalized_daily_basic WHERE ts_code=? AND trade_date=?(命中即返回;未命中且当日交易日 → fallbacktushare.call)
十一、数据质量(v0 极简)
只保留 4 条:
| 检查 | 触发 | 动作 |
|---|---|---|
| Row count 阈值 | daily 接口收到 < 4,500 行 | warning → source_freshness.error_msg |
| Schema validation | 必填列 NULL(如 close 为空) | error → 该行 reject + 写入 quality_log |
| Freshness | last_success > 36h ago | warning |
| Backfill 缺口 | trade_cal.is_open=1 但 normalized_daily 该日无对应行 | nightly check 写入 quality_log |
显式不做: 跨源对账(只有 Tushare 一个源)、价格异常检测(涨跌停 ±20%)、单股手动审核流。
十二、路线图
| 周 | Tier | 内容 | 02 spec 同步 |
|---|---|---|---|
| W1 | T1 落仓 + Stage 1 回填 | trade_cal 种子 + daily / daily_basic / adj_factor / stock_basic 落仓;scripts/backfill_tushare_t1.py 跑 2 年(约 15 min wall time);物理排序 (ts_code, trade_date) | 02 W1(DataProvider 骨架 + trade_cal 种子)可并行 |
| W2 | T1 切流 + Self-Healing | /price 路由切到查 normalized_daily_basic;/warehouse/daily 上线;v_daily_full 视图;连接池 WarehousePool;retry + gap-fill + watchdog | 02 W2(包装 TushareDailyProvider / AkshareSpotProvider) |
| W3 | T2a 周/月线 + 指数 | weekly / monthly / index_daily / index_dailybasic / index_basic / index_weight 落仓 + 2 年回填;scripts/backfill_all.py 并行回填;MCP 工具切到仓库 | 02 W3(Composer + Cache) |
| W4 | T3 | 三大报表 + fina_indicator + forecast + express 落仓 + 季报窗口调度;T3 2 年回填(~7h,过夜跑) | 02 W4(/price 切流 breaking change) |
| W5 | T4 | dividend / top10_holders / share_float / repurchase / stk_holdertrade 事件采集 | 02 W5(可观测性 endpoints) |
| W6 | 收尾 | /warehouse/* 全暴露;/fundamentals 切到 normalized;老 daily_cache / fundamentals_cache 下线;可选 Stage 2 (3 年深度) | 02 W6(WarehouseProvider 接入) |
已实现 (W3, 2026-05-14):
- ✅ 6 张新 normalized 表:
weekly,monthly,index_daily,index_dailybasic,index_basic,index_weight - ✅ 6 张新 raw 表
- ✅ 4 个新调度 job:
weekly_refresh,monthly_refresh_price,index_daily,index_monthly - ✅
scripts/backfill_all.py— 并行回填(8 workers),支持--tiers t1,t2a,t2b - ✅
scripts/data/incremental_update.py— 日常增量更新 - ✅ 8 个 MCP 工具仓库-backed(was 5, now 11 of 28)
十三、显式不做(Out of Scope)
- 多源(pytdx3、akshare、Yahoo、Alpha Vantage)—— 单 Tushare
- Curated / Feature 层
- Mac Mini PRIMARY + rsync replica
- 实时 / 分钟数据(独立付费)
- ETF / 港股 / 美股 / 期货
- 跨源校验
- 报表 PDF 入库(Phase 2 内容范畴,见 02-research-reports.md)
- 多租户的访问控制 / 配额
十四、开放问题
- 磁盘容量。 tvps 当前
df -h多少?现在预算 10 GB+ 可用(2 年方案,Stage 2 不阻塞)。 - 日级批跑时窗。 Tushare
daily_basic的更新时间在 15:00–17:00,adj_factor在次日 09:15–09:20。是当日 18:30 跑(adj_factor 永远滞后一天)还是次日 09:30 跑(fresher 但调度逻辑稍复杂)? daily_basic.total_share等单位"万"是否标准化。 §6.5 定下:保留原单位(万股 / 万元 / 千元),unit 元数据由响应层(02 spec)携带。Resolved./price切流时机。 T1 落仓 + Stage 1 回填完后切流;02 spec W4 的 breaking change 同步发布。不带 feature flag(双轨增加心智负担)。Resolved.- 现有 cache 表清理时机。 02 spec W6(与 01 W6 同周)下掉。Resolved.
stock_basic的 list_status PK。 复合 PK(ts_code, list_status),让退市/重上是不同行。Resolved.- 回填脚本能否进容器跑。 2 年方案 ~15 min wall time + < 200 MB peak,进容器没问题(容器配额 1500 MB 足够)。T3 季报 ~7h 才需要单独 host shell 跑。
- Phase 2 §A spec §A 是否更新为指向本 doc,还是直接删除该章节。 倾向加一段 "see docs/planning/01-data-layer.md" superseded 提示,整段保留作历史。
- 物理排序的代价。 §6.5.1 的
CREATE TABLE ... AS SELECT ... ORDER BY等于全表重写。8M 行规模下 DuckDB 跑 ~30 秒;可接受。 - 二级索引 (§6.5.2) 何时加。 默认不加,Q3 横截面查询 P95 > 200 ms 后再考虑。
v_daily_full视图维护。 T2 加stk_limit等表后,视图要不要扩字段?倾向加 —— 让视图保持"一行 = 一支股票一天的所有日级信息"。- Stage 2 触发条件。 默认不跑;agent 显式提了 "去年同期 vs 去年同期" 类查询失败时再跑(log 自动 capture)。
十五、附录:历史决策(v0.1.0 时期)
以下内容来自 v0.1.0 的源选型讨论,留作决策记录。
Q1. 主数据源选型
- A) Tushare 单源:质量高、统一、要钱;fundamental + estimates 都有
- B) akshare 单源:免费、聚合多源,但稳定性、口径一致性需要验证
- C) 混合:Tushare 做 fundamental 和 estimates,akshare/通达信本地做行情冗余
- D) 本地通达信为主:免费、低延迟,但 fundamental 与 estimates 缺口大
→ v0.3.0 选型:A(Tushare 单源)。 跨源对账的复杂度对当前规模收益太低;2000 档接入面已足够覆盖 A 股研究。
Q2-Q4
原文档关于"4 层数据模型"、"复权口径"、"存储介质"的讨论,其结论已在本 doc §6 / §3 落地。Q3"分多少层"的答案从 4 层降为 2 层(Raw + Normalized);Q4"存储介质"答案为 DuckDB 单文件。