基金数据管道的七层铠甲:从 Sina API 到微信推送的完整流水线

写完实时跟踪面板之后,有个问题一直没展开聊:数据是怎么来的。

不是「写个 cron 调一下就行了」那么简单。这条管线每天从早上九点半跑到下午三点,每 10 分钟采集 7 只基金 + 4 大指数的实时估值,收盘后跑 DCA 引擎算盈亏,22:00 生成日报,周末做周记,月末做月记。中间任何一个环节断了,不是「少一个数字」,是整条后续链路全哑火。

这篇拆开讲整条管线的架构。


一、管线全景

先把各环节摊开看:

09:3015:00   10 分钟
  Sina API  ──→  realtime-6.py (v7)  ──→  fund-tracker.py  ──→  {date}.json
                                    
                              大盘指数快照

22:00  收盘后
  {date}.json  ──→  daily-pl.json (DCA引擎)  ──→  Flask /api/fund-pl  ──→  网页图表
                                          
                                     微信日报推送

每周五 21:00    周记草稿
每月末 21:00    月记草稿

核心就三层:

  1. 采集层:realtime-6.py — 从公开 API 抓实时数据
  2. 存储层:fund-tracker.py — 解析、对齐、落盘 JSON
  3. 消费层:DCA 引擎 → Flask API → 网页 / 微信推送

每一层都有自己的防崩机制。


二、采集层:三级降级链

realtime-6.py 是整条管线的最前线。它要解决一个核心问题:基金实时估值 API 已经死了。

天天基金的 fundgz.1234567.com.cn 是几乎所有基金跟踪工具依赖的数据源。它在 2026 年上半年彻底下线,一夜之间所有基于这个接口的工具全部瘫痪。

我花了差不多一周的时间,试出了现在的三级降级链:

L1:Sina ETF 实时价(主力)

6 只基金有对应的 ETF 可以映射:

008086(5G)     sh515050
017853()     sh516510
020631(芯片)   sh512760
017470(科芯)   sh588200
022429(A500)   sz159338
002611(黄金)   sh518880

Sina 的 ETF 行情接口是 hq.sinajs.cn,返回格式固定、解析快、不需要 Referer 校验。6 只 ETF 一枪打完,耗时不到 1 秒。

剩下那一只是 信澳 5G (016370),没有对应 ETF。它走的是另一条路:天天基金持仓 API(fundf10.eastmoney.com)拿到前十大重仓股 + 权重,再用东方财富 Push2 接口拉个股实时股价,按权重加权算估值。

这个步骤比 ETF 慢很多:持仓 API 要 2-3 秒,个股行情要再 2-3 秒。所以 L1 只对 016370 这么干,那 6 只直接走 ETF。

L2:天天基金持仓 + 东方财富(纯 HTTP 降级)

如果 Sina 挂了,或者 ETF 行情接口改格式了,切到 L2:

  • 天天基金持仓 API(fundf10.eastmoney.com)→ 拿到基金的重仓股列表
  • 东方财富 Push2 接口 → 拿到每个重仓股的最新价
  • 按权重加权计算估值

重点:Referer 必须填 fund.eastmoney.com,填 quote.eastmoney.com 会 404。 这个坑我踩了两天才定位到:接口返回的是中文「请检查链接是否正确」,不是 HTTP 404,日志里根本看不出来。

L2 比 L1 慢,7 只基金各拉一次持仓,再合起来拉几十只个股行情。但它是纯 HTTP,没有 Playwright 的开销。实测 7 只全走 L2 耗时约 12 秒。

L3:同花顺 Playwright(手动兜底)

前两层都挂了,还有最后一层:同花顺爱基金的网页版。这需要起一个 Playwright headless 浏览器,模拟点击和滚动去抓页面上的净值数字。

但它是手动触发,因为太慢(单个基金就要 10+ 秒)、资源开销大、而且有反爬风险。L3 存在的意义是:万一前两层全部失效,至少还有一个能用的方案,不至于全线空白。自 v6 以来实际上从未触发过 L3。

指数抓取

4 大指数(上证、深证、创业板、科创50)走的是 Sina 的另一个接口,没有基金估值那么脆弱。但 v6 踩过一个坑:Sina 的指数代码有两套格式:sh000001s_sh000001。不同时间段返回可能不一样。现在的代码两套都试,谁先返回用谁。


三、存储层:时间对齐 + 容差匹配

采集层返回的数据是一堆文本行,需要解析成结构化 JSON。

这里最关键的逻辑是 match_std_time():把实际采集时间(比如 10:09:43)对齐到标准时间点(10:10),容差 ±5 分钟。

为什么要这么做?

因为 cron 的调度精度不是秒级的。那个每 10 分钟跑一次的 cron 可能在 10:09:30 触发,也可能在 10:10:45 才跑完 realtime-6.py。如果直接把时间戳写入 JSON,那不同日子的同一个「10:10 快照」可能实际是 10:09 或 10:11,后续画图时横轴就对不齐了。

容差 5 分钟意味着:10:05-10:15 的采集都归入 10:10 时点。所有后续图表和表格都用这 26 个标准时间点做横轴,不会因为 cron 抖动而乱套。

落盘格式是 ~/.hermes/fund-tracker/{YYYY-MM-DD}.json,每天一个文件:

{
  "date": "2026-07-30",
  "snapshots": [
    {"time": "09:30", "indices": {"上证": 3401.23, ...}, "funds": {"008086": 1.2345, ...}},
    ...
  ]
}

盘中的采集脚本(cron e8210df305b4)每 10 分钟跑一次,往当天文件里追加一个快照。收盘后文件定型,不再写入。


四、消费层:DCA 引擎 + 多端消费

收盘之后,原始快照进入两条消费线。

消费线 A:DCA 引擎(daily-pl.json)

~/.hermes/fund-tracker/daily-pl.json 是整个基金 OS 的核心数据文件。它不是简单的「涨了多少跌了多少」,而是基于 DCA(定投模拟)的完整盈亏计算:

  • 期初持仓从 fund-holdings.json 读取(包含每只基金的真实买入记录)
  • 每日模拟定投:按设定金额自动「买入」份额
  • 当日盈亏 = 当日市值 − 昨日市值 − 当日定投金额
  • 累计盈亏 = 当日市值 − 累计投入成本

这个计算看起来简单,但有一个致命陷阱:DCA 引擎需要知道基金的历史净值来模拟定投。每天的净值数据来自实时采集的快照,如果某天的快照缺失,DCA 引擎的买入记录就会断档。

这也是为什么数据完整性如此重要:不是「少了一天的页面」,是「DCA 引擎的定投曲线永久断一个点」。

消费线 B:多端消费

DCA 引擎产出的 daily-pl.json 被 Flask 的 /api/fund-pl 端点读取,然后:

  • fundtracker.html(盘中实时面板):读原始快照,画 26 个时点的折线图
  • fundreturns 月视图:读 DCA 结果,画月累计盈亏
  • pltrack 周/日视图:读 DCA 结果,做多粒度分析
  • 真账周记/月记journal-draft-cron.py 读 DCA 结果,生成草稿 → 微信短摘要推送
  • 每日收盘报告(22:00 cron):读 DCA 结果 + Sina 收盘快照 → 生成完整日报

同一份数据、同一个 DCA 引擎。这是数据一致性的根基。任何新页面都走 /api/fund-pl 这一个端点,不会出现「demo1 和 demo4 的盈亏不一样」的问题(是的,这个 bug 发生过)。


五、数据完整性:三层防线

实时系统最大的敌人是静默失败:cron 显示 ok,但数据没更新。

针对这个问题,我上了三层防线:

第一层:脚本级重试

realtime-6.py v7 内置了 1 次 HTTP 重试(间隔 2 秒),覆盖瞬时网络抖动。超时从 v5 的 8 秒调到了 15 秒,天天基金的持仓 API 在网络差的时候确实需要 10+ 秒。

第二层:cron 级校验

daily-pl-append-actual.py(23:00 cron)会把收盘净值写入 daily-pl.json,然后做一次完整性检查:今天的数据点够 26 个吗?如果不够,标记为异常,次日手动补。

第三层:收盘后补漏

daily-pl-notify.py(23:45 cron)做最终兜底:如果 daily-pl.json 里今天的净值还没写入,或者 DCA 结果为空,触发告警推送到微信。

这三层不是一次设计出来的,而是一层一层被崩出来的。23:45 补漏是某天发现「22:00 报告正常但 daily-pl 空的」之后加的。


六、最后一公里:微信推送

数据到了 daily-pl.json,还有一个最终消费端:推送到用户手机。

微信推送走的是 Hermes cron 的 deliver 机制:

deliver: weixin:o9cq80yk5k3wsCO2FkZXTbe6LCSY@im.wechat

每个消费侧 cron 在创建时必须显式设这个 deliver。Web UI 里创建的 cron 默认推回 Web UI 会话,不会自动走到微信。

推送的内容策略也不同:

  • 每日收盘报告:完整日报,包含涨跌幅、驱动因素、QDII 净值预估
  • 基金异动预警(每 10 分钟):只推涨跌幅 > ±3% 的基金,短文本格式
  • 周记/月记:微信只推一条短摘要 + 链接,完整草稿落盘到 demo2/*.md

微信推送有一个硬限制:iLink 速率限制。如果密集推多条,会被 cooldown。所以盘中异动预警用的是「合并推送」:一个周期内如果多只基金同时异动,合成一条消息,不是推 N 条。


七、这套管线的哲学

写到这里已经讲了将近三千字,但我真正想说的是:管线的复杂度不是一次性堆积上去的,是每一个故障都变成了代码。

  • Sina ETF 接口偶尔返回乱码 → 加了解析容错
  • 天天基金持仓 API 间歇性 403 → 加了 Referer 校验和备用 Header
  • fundgz 彻底死了 → 建了三级降级链
  • daily-pl 某天空了 → 加了 23:45 补漏
  • 两个页面盈亏不同 → 统一到 /api/fund-pl

这条管线现在有 16 个基金相关的 cron 作业在跑,但不是一开始就这样的。它是被真实踩过的坑一点一点堆出来的。

如果你也在做类似的东西,一个建议:先搞核心采集链,跑通了再加防线。不要一开始就设计完美的容错体系,因为真正的错误模式只有跑起来之后才能碰到。


本文涉及的代码和配置全部在 venxine.vip 的生产环境里实际运行。管线的 Python 脚本在 ~/.hermes/skills/finance/ 下,网页路由在 fund-web/app.py 里,cron 作业在 ~/.hermes/cron/jobs.json