数据管理服务:落盘优先、任务化、增量
分析脚本永远不该自己联网——p5.9 那次事故里,同一条请求两次返回不同,ERC 期末倍数在 3.38~3.95 之间漂
这一层只有一条铁律:
分析脚本永远不该自己联网。
理由在 p5.9 已经用事故证明过:同一条 yfinance 请求,不同进程返回的形状、索引、最后一天价格完全一致,而历史值的哈希不同——同一份代码因此跑出 3.38 / 3.63 / 3.78 / 3.95 四个不同的期末倍数。
所以 qsys 的数据适配器长这样:
class LocalParquetData:
def __init__(self, path):
self._df = pd.read_parquet(path) # 只读落盘文件
self.fetched_at = self._df["fetched_at"].iloc[0]
它没有网络调用。想换数据只能换文件,而换了文件 fetched_at 就变了。
数据适配器:LocalParquetData(51 个标的,fetched_at=2026-07-31 14:38:54)
实验室里,试剂瓶上贴着批号和开封日期。
没人会在做实验的中途跑去药房现买一瓶——因为你没法保证新买的这瓶和之前那瓶一样。
fetched_at 就是那个批号。
一、三层分离:抓取 / 存储 / 读取
| 层 | 谁做 | 能不能联网 |
|---|---|---|
| 抓取 | build_*.py | 只有它能联网 |
| 存储 | parquet + JsonlStore | 不联网 |
| 读取 | LocalParquetData | 不联网 |
分析脚本只碰第三层。 这条分离让整个系统获得两个性质:
- 可复现——同一份落盘文件,跑一百次结果一样
- 可排障——出问题时不用担心"是不是数据源今天不一样了"
p7.5 那个 linter 的 QL009 规则扫出 41 处联网调用,全部在
build_*.py里——这正是它们该在的地方。
二、落盘格式:为什么是 JSON Lines
交易流水用 JsonlStore,而不是数据库:
| 理由 | 说明 |
|---|---|
| 追加写、永不改写 | 交易记录是流水账,改写流水账本身就是问题 |
| 人能直接读 | grep、diff、tail -f 都能用 |
| 可截取重放 | 出问题时只重放一段,不用整库回滚(p8.9) |
实测落盘:
fills=98 rejects=108 equity=622
注意 rejects 是独立的一份流水。 大多数系统只记成交——而 p4.1 已经证明,被拒才是最容易被静默吞掉的信息。
🔧 动手做:查一查你的分析脚本,是不是自己在联网取数(4 分钟)
qsys 的数据适配器 LocalParquetData 只读本地 parquet,分析脚本永远拿不到"联网取数"的能力。
回去看你自己的量化代码:算指标、跑回测的那个脚本,是不是里面直接 ak.stock_zh_a_hist(...) 联网拉数据?
想明白:分析脚本一旦自己联网,就把两件该分开的事搅在了一起——而且不可复现(p5.9 那次事故:同一条请求两次返回不同,ERC 期末倍数在 3.38~3.95 之间漂)。正确架构是取数和分析彻底分离:取数服务负责联网+落盘+血缘,分析层只读本地落盘文件。你的分析代码里不该出现任何网络调用。
三、任务化:每一次数据操作都是一条记录
数据服务对外只暴露"任务",不暴露"函数":
| 任务 | 输入 | 产出 |
|---|---|---|
build_universe | 代码名单、区间、复权口径 | parquet + fetched_at + 行数 |
build_factors | 代码名单、年份、接口 | parquet + effective_date(p1.7) |
verify | 文件路径 | 行数、缺失率、极值、哈希 |
每条任务都要记下:谁触发的、用了什么参数、产出了什么、耗时多久。
没有这四样,三个月后你无法回答"这个 parquet 是怎么来的"——而那正是 p1.5「血缘」要解决的问题。
四、增量任务:最容易写错的一环
增量更新的三个坑,每一个都不会报错。
| 坑 | 症状 | 正确做法 |
|---|---|---|
| 按"最后一行日期"续拉 | 停牌/节假日会让你反复拉同一段 | 按交易日历判断缺口 |
| 直接 append 不去重 | 同一天被写两次,后续 groupby 全错 | 按 (symbol, date) 主键去重 |
| 只补新数据,不校验旧数据 | 数据源修订历史值(p5.9)你永远不知道 | 定期全量重拉 + 哈希比对 |
第三条最隐蔽:你以为增量只是"省时间",实际上它让你对历史值的变化完全失明。
本课的做法:增量拉新,但每次都记录
value_hash;哈希变了就报警,而不是默默覆盖。
五、数据校验:落盘之后立刻做
build_universe.py 里那段检查,是这一层的模板:
51 个标的,199,150 行,2010-01-04~2026-07-29
(其中含 p1.3 的停牌占位行,需剔除)
turn 缺失率 0.00%,pbMRQ 缺失率 0.22%
ST 标记行 4,938 行(0.52%)
四类必查:行数与区间 · 缺失率 · 极值合理性 · 主键唯一性。
而 p1.1 那条教训必须写进代码,而不是写在文档里:
# ⚠️ query_hs300_stocks() 的返回不稳定:同一天连续调用,
# 一次返回 300 只,下一次只返回 6 只,且不报错。
# 所以:①校验数量 ②重试 ③一旦拿到就缓存。
"能取到 ≠ 取对了"——这条在系统里的形态,就是一段带数量校验的重试。
📌 免责:本课为技术教学,数据仅用于教学演示,不构成投资建议。
四件事:
- 一条铁律:分析脚本永远不该自己联网。p5.9 的事故:同一条请求返回的历史值哈希不同,同一份代码跑出 3.38 / 3.63 / 3.78 / 3.95 四个期末倍数。qsys 的
LocalParquetData没有网络调用 - 三层分离:抓取(只有它能联网)/ 存储 / 读取。分析脚本只碰第三层,换来可复现与可排障。p7.5 的 linter 扫出 41 处联网调用全部在
build_*.py里——那正是它们该在的地方 - 落盘用 JSON Lines:追加写、永不改写(交易记录是流水账,改写流水账本身就是问题)、人能直接读、可截取重放。实测
fills=98 rejects=108 equity=622——rejects是独立的一份流水,而大多数系统只记成交 - 增量任务的三个坑都不报错:按"最后一行日期"续拉(应按交易日历)、append 不去重(应按主键)、只补新数据不校验旧数据(应记
value_hash并定期全量比对)。第三条让你对历史值的变化完全失明
数据校验四类必查:行数与区间 · 缺失率 · 极值合理性 · 主键唯一性。而 p1.1 那条"能取到 ≠ 取对了"必须写成带数量校验的重试,而不是写在文档里。
下一节讲策略这一层:注册、版本、指纹——以及为什么"改了规格就要升版本"是一条不能绕过的硬约束。
🔎 来源与核验· 2 条,点开核对
