← 返回目录
p8.3动手⏱ 约 15 分钟

数据管理服务:落盘优先、任务化、增量

分析脚本永远不该自己联网——p5.9 那次事故里,同一条请求两次返回不同,ERC 期末倍数在 3.38~3.95 之间漂

🔎 最后验证 2026-08📚 来源:qsys 数据适配器与 JsonlStore 实测(51 个标的、622 条权益记录),2026-08-01 于 ECS 实测,输出见 code/outputs/stdout.txt🧰 pandas 3.0.5、Python 3.12.13、qsys(本课自研)
为什么学这个

这一层只有一条铁律:

分析脚本永远不该自己联网。

理由在 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不联网

分析脚本只碰第三层。 这条分离让整个系统获得两个性质:

  1. 可复现——同一份落盘文件,跑一百次结果一样
  2. 可排障——出问题时不用担心"是不是数据源今天不一样了"

p7.5 那个 linter 的 QL009 规则扫出 41 处联网调用,全部在 build_*.py——这正是它们该在的地方

二、落盘格式:为什么是 JSON Lines

交易流水用 JsonlStore,而不是数据库:

理由说明
追加写、永不改写交易记录是流水账,改写流水账本身就是问题
人能直接读grepdifftail -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 只,且不报错。
# 所以:①校验数量 ②重试 ③一旦拿到就缓存。

"能取到 ≠ 取对了"——这条在系统里的形态,就是一段带数量校验的重试。

❓ 测验
你的增量任务每天只拉「最后一行日期之后」的数据,跑了半年一切正常。最可能已经发生但你没发现的问题是?
✏️ 填空
三层分离里,只有 ___ 层可以联网;存储层与读取层都不联网。

📌 免责:本课为技术教学,数据仅用于教学演示,不构成投资建议。

✅ 小结

四件事:

  1. 一条铁律:分析脚本永远不该自己联网。p5.9 的事故:同一条请求返回的历史值哈希不同,同一份代码跑出 3.38 / 3.63 / 3.78 / 3.95 四个期末倍数。qsys 的 LocalParquetData 没有网络调用
  2. 三层分离:抓取(只有它能联网)/ 存储 / 读取。分析脚本只碰第三层,换来可复现可排障。p7.5 的 linter 扫出 41 处联网调用全部在 build_*.py——那正是它们该在的地方
  3. 落盘用 JSON Lines:追加写、永不改写(交易记录是流水账,改写流水账本身就是问题)、人能直接读、可截取重放。实测 fills=98 rejects=108 equity=622——rejects 是独立的一份流水,而大多数系统只记成交
  4. 增量任务的三个坑都不报错:按"最后一行日期"续拉(应按交易日历)、append 不去重(应按主键)、只补新数据不校验旧数据(应记 value_hash 并定期全量比对)。第三条让你对历史值的变化完全失明

数据校验四类必查:行数与区间 · 缺失率 · 极值合理性 · 主键唯一性。而 p1.1 那条"能取到 ≠ 取对了"必须写成带数量校验的重试,而不是写在文档里。

下一节讲策略这一层:注册、版本、指纹——以及为什么"改了规格就要升版本"是一条不能绕过的硬约束。

下一节 → 策略管理与版本化
🔎 来源与核验· 2 条,点开核对
本节每个关键论断都对应一个可追溯的来源 —— 这是本课程"靠谱、不过时"的底线。
「qsys 数据适配器 LocalParquetData 只读本地 parquet(51 个标的,fetched_at=2026-07-31 14:38:54),无网络调用;JsonlStore 落盘实测 fills=98、rejects=108、equity=622」
📚 本节 code/p8_demo.py 与 _shared/qsys/,2026-08-01 于 ECS 实测,输出见 code/outputs/stdout.txt✓ 已核验 2026-08
「p5.9 复现性事故:同一条 yfinance 请求在不同进程返回的历史值哈希不同,ERC 期末倍数在 3.38/3.63/3.78/3.95 之间漂」
📚 p5.9 code/outputs/build_stdout.txt✓ 已核验 2026-07
数据仅用于教学演示;抓取脚本(build_*.py)是唯一允许联网的一层,分析与服务层一律只读落盘文件。
智图软件的赞赏码
都看到这了,打个赏呗!
接下来 · p8.4
策略管理与版本化:改了规格就要升版本
继续读下一节 →