Skip to content

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.pyget_price(走 daily)、get_fundamentals(走 fina_indicator

落仓只有两张 TTL 缓存(src/service/cache.py~/twilight/data/):

接口TTL主键用途
daily_cachetushare daily24h(code, trade_date)/price 命中即返回
fundamentals_cachetushare fina_indicator30d(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文档积分门槛实测单次行数
daily1205,493
stock_basic20005,513
trade_cal200031 (单月)
daily_basic20005,493
adj_factor20005,519
stk_limit20007,576
moneyflow20005,182
margin20003 (= 三个交易所)
dividend200057 (单股全历史)
top10_holders200010
index_daily200020 (单指数单月)
index_dailybasic40012
income / balancesheet / cashflow20001 (单期)
fina_indicator20001
forecast200030 (单股全历史)
disclosure_date5005,515 (单期)
repurchase6002,000 (cap)
share_float1206,000
stock_company1201

频次假设(按 2000 档官方上限推断):500 calls/min,常规接口无每日总量上限。 即使账户更高,规划用 2000 档上限即可,留余量。

2.2 显式不在采集面内

  • 实时 / 分钟数据rt_kstk_minsrt_min)—— 走独立付费权限,跟积分无关
  • 5000+ 接口bak_dailyfund_daily(ETF)、stock_stbak_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 / calldoc优先级
dailyA 股日线(未复权)ts_code+trade_date~5,500doc=27T1
daily_basic每日指标(PE/PB/换手/市值)ts_code+trade_date~5,500doc=32T1
adj_factor复权因子ts_code+trade_date~5,500doc=28T1
stk_limit涨跌停价ts_code+trade_date~7,500doc=183T2
moneyflow个股资金流ts_code+trade_date~5,500doc=170T2
margin融资融券大盘汇总trade_date+exchange_id3doc=58T2
index_daily指数日线(限沪深300/中证500/中证1000/上证综指)ts_code+trade_date4 (4 只指数)doc=95T2
index_dailybasic大盘指数每日指标ts_code+trade_date~12doc=128T2

单日总量:约 30k rows,6-8 calls,30 秒以内完成。

3.2 每周(每周一 03:00)

api_name中文主键doc优先级
pledge_stat股权质押统计ts_code+end_datedoc=110T5 (defer)

3.3 每月(每月 1 日 02:00)

api_name中文主键doc优先级
stock_basic股票列表快照(list_status=L|D|P 各拉一次)ts_codedoc=25T1
namechange股票曾用名ts_code+start_datedoc=100T4

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_datedoc=162T2
income利润表ts_code+end_date+report_typedoc=33T3
balancesheet资产负债表ts_code+end_date+report_typedoc=36T3
cashflow现金流量表ts_code+end_date+report_typedoc=44T3
fina_indicator财务指标ts_code+end_datedoc=79T3
forecast业绩预告ts_code+end_date+ann_datedoc=45T3
express业绩快报ts_code+end_datedoc=46T3
top10_holders前十大股东ts_code+end_date+holder_namedoc=61T4
top10_floatholders前十大流通股东ts_code+end_date+holder_namedoc=62T4

说明(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_procdoc=103T4
share_float限售解禁ts_code+ann_date+float_datedoc=160T4
repurchase股票回购ts_code+ann_date+end_datedoc=124T4
stk_holdertrade股东增减持ts_code+ann_date+holder_namedoc=175T4
block_trade大宗交易ts_code+trade_date+price+voldoc=161T5
new_shareIPO 新股ts_code+ipo_datedoc=123T5

3.6 一次性 + 年度刷新

api_name中文主键doc频率
trade_cal交易日历exchange+cal_datedoc=26每年 12 月续 +1 年
stock_company上市公司基本信息ts_codedoc=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_codestr任选股票代码(如 600519.SH
trade_datestr任选交易日期 YYYYMMDD
start_datestrN开始日期
end_datestrN结束日期

输出字段:

字段类型中文备注
ts_codestrTS 代码PK
trade_datestr交易日期PK,YYYYMMDD
openfloat开盘价
highfloat最高价
lowfloat最低价
closefloat收盘价
pre_closefloat昨收价元(前一交易日)
changefloat涨跌额close - pre_close
pct_chgfloat涨跌幅%(基于未复权)
volfloat成交量单位(注意不是股)
amountfloat成交额单位千元

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_codestrTS 代码PK
trade_datestr交易日期PK
closefloat收盘价元(与 daily.close 重复)
turnover_ratefloat换手率%(基于流通股)
turnover_rate_ffloat换手率(自由流通股)%
volume_ratiofloat量比
pefloat市盈率(总市值/净利润,亏损为负)
pe_ttmfloat市盈率 TTM
pbfloat市净率(总市值/净资产)
psfloat市销率
ps_ttmfloat市销率 TTM
dv_ratiofloat股息率%
dv_ttmfloat股息率 TTM%
total_sharefloat总股本万股
float_sharefloat流通股本万股
free_sharefloat自由流通股本万股
total_mvfloat总市值万元
circ_mvfloat流通市值万元

注意: 单位均为"万"。落仓时是否标准化为元/股 / 元 是个 open question — 倾向保留原单位减少歧义,对外查询接口统一口径。

4.3 adj_factor — 复权因子

Doc: https://tushare.pro/document/2?doc_id=28主键(落仓): ts_code + trade_date调用方式:trade_date 拉全市场

输出字段:

字段类型中文备注
ts_codestrTS 代码PK
trade_datestr交易日期PK
adj_factorfloat复权因子前复权 = 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_codestrNTS 代码
namestrN名称
marketstrN市场(主板/创业板/科创板/北交所/CDR)
list_statusstrN上市状态:L 上市,D 退市,P 暂停(默认 L
exchangestrNSSE / SZSE / BSE
is_hsstrN是否沪深港通

输出字段:

字段类型中文备注
ts_codestrTS 代码PK 一部分
symbolstr股票代码(无后缀)
namestr股票名称
areastr地域省份
industrystr行业Tushare 自定义分类
fullnamestr全称
ennamestr英文名
cnspellstr拼音缩写
marketstr市场主板/创业板/科创板/北交所
exchangestr交易所SSE/SZSE/BSE
curr_typestr交易货币CNY
list_statusstr上市状态L/D/P,PK 一部分
list_datestr上市日期YYYYMMDD
delist_datestr退市日期YYYYMMDD,仅 D 状态有
is_hsstr是否沪深港通N/H/S
act_namestr实际控制人
act_ent_typestr实际控制人企业性质

调度: 月初快照一次。回填用: 给所有"日级接口"的循环提供 ts_code 列表。

4.5 trade_cal — 交易日历

Doc: https://tushare.pro/document/2?doc_id=26主键(落仓): exchange + cal_date调用方式: 按交易所拉一段 start_date ~ end_date

输入参数:

参数类型必选说明
exchangestrNSSE / SZSE / BSE / CFFEX 等(默认 SSE)
start_datestrN开始日期
end_datestrN结束日期
is_openstrN是否交易(0 否 / 1 是)

输出字段:

字段类型中文备注
exchangestr交易所PK 一部分
cal_datestr日历日期PK 一部分,YYYYMMDD
is_openint是否交易0/1
pretrade_datestr上一交易日周末/节假日时指向之前最近的交易日

调度: 每年 12 月底续 +1 年;每日 18:00 调度入口先查这张表判定今天是不是 is_open=1,否则跳过日级批跑。


五、Tier 2-4 字段明细 (TODO)

以下接口的字段明细待各 tier 实施时按 §4 的格式补全。落仓 schema 名 normalized_<api_name>,主键见 §3 表格的"主键"列。

Tierapi_name状态
T2stk_limit待文档抓取后填充
T2moneyflow同上
T2margin同上
T2index_daily同上
T2index_dailybasic同上
T2disclosure_date同上
T3income字段量大(~70 列),单独章节
T3balancesheet字段量大(~140 列),单独章节
T3cashflow字段量大(~70 列),单独章节
T3fina_indicator字段量大(~80 列),单独章节
T3forecast待补
T3express待补
T4dividend待补
T4top10_holders / top10_floatholders待补
T4share_float待补
T4repurchase待补
T4stk_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)
);

字段类型选择:

  • paramsVARCHAR(不是 JSON):参数小、查询场景几乎只是 debug,无需 JSONB 索引
  • responseJSON:DuckDB 原生 JSON 类型,回放 normalized 时可直接 SQL 解构
  • fetched_atTIMESTAMP: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_dateDATEDuckDB DATE 支持 BETWEEN / date_trunc / 排序;VARCHAR 字串虽然在 YYYY-MM-DD 格式下能字典序对,但失去 SQL 时间函数
价格 (open/close/...)DOUBLEA 股价最大 ~200,000 元,DOUBLE 15 位有效位绰绰有余;DECIMAL(10,4) 更"精确"但 DuckDB 上无性能优势
volBIGINTA 股最大单日成交量 ~5×10⁸ 手,超 INT32 范围
amountDOUBLE千元单位最大 ~10⁹;保留小数
pct_chgDOUBLE涨跌幅是百分比(如 4.39 表示 4.39%),不是 0.0439
pe / pb / dv_ratioDOUBLE NULL-able亏损股 / 新股可能 NULL

重灌策略: 解析逻辑改了 / 字段加了 → 清空 normalized,从 raw 回放重灌。Tushare 不需要再调。

6.3 不做的层

  • Curated(前复权后调整、报表合并口径)—— 业务逻辑后置到查询层(视图 / API),不持久化
  • Feature(MA、PE 带、动量)—— 衍生指标暂不做;Agent 还没明确批量需求

6.4 数据库使用模式分析

落仓后查询会出现 5 类 archetype。设计要保证每一类都跑得动:

类型例子行扫规模关键性能要素
Q1 — 点查永鼎股份今天 close1 行PK 上 ART index 命中
Q2 — 单股时序璞泰来 60 日 MA60 行PK 前缀 (ts_code) + 物理排序
Q3 — 横截面今天涨幅前 20 名5,500 行扫物理排序 by (trade_date, ts_code) 时 zone-map 命中
Q4 — 窗口聚合所有股票 20 日波动率5,500 × 20 行同 Q3 + 向量化 window function
Q5 — 多表 JOINclose + 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 COLUMNDROP COLUMNRENAME COLUMN。Tushare 加新字段时:

  1. ALTER TABLE normalized_daily_basic ADD COLUMN <new_field> DOUBLE;
  2. 重灌(从 raw 回放,不调 Tushare)
  3. 旧 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 触发表

JobCron任务
trade_cal_check每日 17:30trade_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:00trade_cal 续期

7.3 失败策略

每个 job 调用 wrapper:

  1. 起一行 raw INSERT 之前不算开始
  2. 任何一步失败 → source_freshness.last_failure + 异常入日志
  3. 同一 job 当天最多重试 3 次(间隔 5/15/60 分钟)
  4. 第二天如果上一日 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 范围。可选数据源:


九、存储与主机

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 MBJSON, 未压缩
Raw 层 — T2 五表 (3 年)~1.5 GB
Raw 层 — T3 季报 (2y × 8 季 × 5,500 stocks × 5 表)~1 GB
Raw 层 — T4 事件类~200 MB事件稀疏
Normalized 层 — 全部 daily 类(3 年)~600 MBDuckDB 列存压缩 (5-10x)
Normalized 层 — 季报~400 MB宽表 + 大量 NULL,DuckDB 友好
索引 + 元数据 + 临时~1 GBDuckDB 自身开销
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=? (命中即返回;未命中且当日交易日 → fallback tushare.call

十一、数据质量(v0 极简)

只保留 4 条:

检查触发动作
Row count 阈值daily 接口收到 < 4,500 行warning → source_freshness.error_msg
Schema validation必填列 NULL(如 close 为空)error → 该行 reject + 写入 quality_log
Freshnesslast_success > 36h agowarning
Backfill 缺口trade_cal.is_open=1normalized_daily 该日无对应行nightly check 写入 quality_log

显式不做: 跨源对账(只有 Tushare 一个源)、价格异常检测(涨跌停 ±20%)、单股手动审核流。


十二、路线图

Tier内容02 spec 同步
W1T1 落仓 + 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 种子)可并行
W2T1 切流 + Self-Healing/price 路由切到查 normalized_daily_basic/warehouse/daily 上线;v_daily_full 视图;连接池 WarehousePool;retry + gap-fill + watchdog02 W2(包装 TushareDailyProvider / AkshareSpotProvider)
W3T2a 周/月线 + 指数weekly / monthly / index_daily / index_dailybasic / index_basic / index_weight 落仓 + 2 年回填;scripts/backfill_all.py 并行回填;MCP 工具切到仓库02 W3(Composer + Cache)
W4T3三大报表 + fina_indicator + forecast + express 落仓 + 季报窗口调度;T3 2 年回填(~7h,过夜跑)02 W4(/price 切流 breaking change)
W5T4dividend / 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)
  • 多租户的访问控制 / 配额

十四、开放问题

  1. 磁盘容量。 tvps 当前 df -h 多少?现在预算 10 GB+ 可用(2 年方案,Stage 2 不阻塞)。
  2. 日级批跑时窗。 Tushare daily_basic 的更新时间在 15:00–17:00,adj_factor 在次日 09:15–09:20。是当日 18:30 跑(adj_factor 永远滞后一天)还是次日 09:30 跑(fresher 但调度逻辑稍复杂)?
  3. daily_basic.total_share 等单位"万"是否标准化。 §6.5 定下:保留原单位(万股 / 万元 / 千元),unit 元数据由响应层(02 spec)携带。Resolved.
  4. /price 切流时机。 T1 落仓 + Stage 1 回填完后切流;02 spec W4 的 breaking change 同步发布。不带 feature flag(双轨增加心智负担)。Resolved.
  5. 现有 cache 表清理时机。 02 spec W6(与 01 W6 同周)下掉。Resolved.
  6. stock_basic 的 list_status PK。 复合 PK (ts_code, list_status),让退市/重上是不同行。Resolved.
  7. 回填脚本能否进容器跑。 2 年方案 ~15 min wall time + < 200 MB peak,进容器没问题(容器配额 1500 MB 足够)。T3 季报 ~7h 才需要单独 host shell 跑。
  8. Phase 2 §A spec §A 是否更新为指向本 doc,还是直接删除该章节。 倾向加一段 "see docs/planning/01-data-layer.md" superseded 提示,整段保留作历史。
  9. 物理排序的代价。 §6.5.1 的 CREATE TABLE ... AS SELECT ... ORDER BY 等于全表重写。8M 行规模下 DuckDB 跑 ~30 秒;可接受。
  10. 二级索引 (§6.5.2) 何时加。 默认不加,Q3 横截面查询 P95 > 200 ms 后再考虑。
  11. v_daily_full 视图维护。 T2 加 stk_limit 等表后,视图要不要扩字段?倾向加 —— 让视图保持"一行 = 一支股票一天的所有日级信息"。
  12. 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 单文件。

团队内部文档