从零搭建一个 A 股基金实时跟踪面板

这篇文章记录了我给 7 只 A 股基金搭一个盘中走势跟踪面板的全过程。每 10 分钟自动刷新,ECharts 画折线图,超过 ±3% 微信预警。中间遭遇了一次全线崩溃:跑了大半年的数据源在 7 月 21 号突然挂掉,于是兜了一圈,试了 A、B 两套方案,最后发现最优解一直在眼皮底下。

全文约 6000 字,代码均可在终端直接跑。

1. 为什么要自己搭

市面上有很多基金 App,但基本上你都看不到盘中实时涨跌。2026 年 1 月底,监管一纸通报要求全行业下架"基金实时估值"功能,蚂蚁财富、天天基金、同花顺、京东金融一夜之间全撤了。

但作为一个每天想看一眼持仓走势的人,这种需求不会因为监管禁令就消失。替代思路也很自然:我的 7 只基金里,6 只是 ETF 联接基金,直接看底层 ETF 的实时行情就行了;剩下 1 只主动基金,拿它的季度持仓做个股加权估算。

于是就有了这个面板。

2. 整体架构

三层管线,所有代码加起来不到 500 行 Python:

Sina/东方财富 行情
    │
    ▼
realtime-6.py        fund-monitor.py
(采集快照)          (阈值预警)
    │                    │
    ▼                    ▼
2026-07-21.json      微信推送
    │
    ▼
fund-tracker cron
    │
    ▼
Flask + ECharts 页面

两个核心 Python 脚本,两个 cron 定时任务,一个 Flask 页面展示。

3. 第一层:数据采集

先说指数。4 个大盘指数(上证、深证、创业板、科创50),东方财富的 Push2 API 直接拿:

https://push2.eastmoney.com/api/qt/stock/get?secid=1.000001&fields=f43,f169,f170

返回 f43(当前点位)和 f169(涨跌额),一除就是涨跌幅。四行代码的事。

基金数据分两路。6 只 ETF 联接基金直接查底层 ETF 的实时价,Sina 行情 API 一次 batch 请求全拉回来:

https://hq.sinajs.cn/list=sh515050,sh516510,sh512760,sh588200,sz159338,sh518880

返回的数据用 var hq_str_ 前缀 + 逗号分隔的字段,第 3 字段是昨收、第 4 字段是现价。解析逻辑只需要不到 20 行:

import urllib.request

def sina_batch(codes):
    url = f"https://hq.sinajs.cn/list={','.join(codes)}"
    req = urllib.request.Request(url, headers={
        "Referer": "https://finance.sina.com.cn/"
    })
    raw = urllib.request.urlopen(req, timeout=10).read().decode("gbk")
    results = {}
    for line in raw.strip().split("\n"):
        if "=" not in line:
            continue
        code = line.split("=")[0].replace("var hq_str_", "").strip()
        data = line.split("=")[1].strip('";\n ').split(",")
        cur, prev = float(data[3] or 0), float(data[2] or 0)
        if prev:
            results[code] = round((cur - prev) / prev * 100, 2)
    return results

# 一次调用拿到全部 ETF
prices = sina_batch(["sh515050", "sh516510", "sh512760"])
# {'sh515050': -3.93, 'sh516510': -2.32, ...}

映射关系是固定的:

基金 底层 ETF Sina 代码
008086 5G 5G ETF sh515050
017853 云 云计算 ETF sh516510
020631 芯片 芯片 ETF sh512760
017470 科芯 科创芯片 ETF sh588200
022429 A500 A500 ETF sz159338
002611 黄金 黄金 ETF sh518880

6 只基金,一个 HTTP 请求,2 秒搞定。

剩下一只 016370(信澳 5G)是主动基金,没有底层 ETF。这条路需要两步:先调天天基金的持仓 API 拿到前十大重仓股和权重:

https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=016370&topline=10

页面 HTML 里有个 <div id='gpdmList'> 存着带市场前缀的股票代码(如 0.300308, 1.688256),后面的 <td class='tor'> 单元格里是权重。拿到 10 只个股代码之后,用 Sina 统一批量查实时价,加权平均:

预估涨跌幅 = Σ(个股涨跌幅 × 占净值比例) / Σ(占净值比例)

这一步比 ETF 直查多 1 个 HTTP 请求(天天基金持仓页),但整体也在 3 秒内完成。

把这些逻辑打包成 realtime-6.py,输出标准格式的文本快报。fund-tracker.py 每 10 分钟调一次,解析输出写进当天的 JSON 文件,按标准时间点去重覆盖。

4. 第二层:预警推送

采集只解决了"看"的问题,但不能一直盯着屏幕。还需要一个"喊"的机制。

fund-monitor.py 是个独立的 watchdog 脚本,和 realtime-6.py 用完全相同的数据源(Sina ETF + 个股加权)。每 10 分钟跑一次,涨跌幅超过 ±3% 就推微信。cron 配置是 no_agent=true,不经过 LLM,纯脚本执行,有告警才输出,静默时零开销。

5. 第三层:页面展示

数据有了,得画出来。Flask 读 JSON 文件,Jinja2 模板渲染,ECharts 画折线图。

ECharts 的配置有不少细节:零轴红绿分区用 markArea 而不是灰虚线,tooltip 合计行用橘色加粗置顶,纵轴标签对称留白,非交易日 null + connectNulls 跨周末直连。这些不是一次写对的,是每次看一眼、改一点、部署验证,反复迭代磨出来的。

部署走 venxine-gunicorn,改模板后 sudo kill -HUP 零停机重载。这条经验是踩过坑的:曾经有两个 systemd service 抢 5000 端口,排查了一下午。

6. 翻车:数据源全线崩溃

2026 年 7 月 21 日早上,打开面板发现基金数据全空。

排查过程很直接:看当天的 JSON 文件,所有 funds 字段都是空的 {}。指数数据正常,说明 cron 在跑、网络通的。问题出在基金数据采集。

原始的 realtime-6.py 用的是天天基金的 fund_query.py,底层调用 fundgz.1234567.com.cn。直接 curl 测试:

$ curl -sI http://fundgz.1234567.com.cn/js/016370.js
HTTP/1.1 301 Moved Permanently
Location: https://fund.eastmoney.com/notfound.html

301 跳转到 notfound.html。HTTPS 也一样:

$ curl -sI https://fundgz.1234567.com.cn/js/016370.js
HTTP/1.1 301 Moved Permanently
Location: https://fund.eastmoney.com/notfound.html

连原始数据长什么样都看不到了。紧接着检查了实时快报脚本的输出,7 只基金全部返回"获取失败"。

这个接口此前偶尔超时、变慢,但这次是直接没了。事后查才知道,今年 1 月的监管通报不只是下架 App 里的估值入口,API 层也在逐步清理。

7. 三个方案

接口死了就得换,后面经历了三个方案。先看概览:

A 方案 B 方案 最终方案
数据源 同花顺 Playwright 天天基金 + Push2 Sina HTTP
单次耗时 120s 35s 3s
覆盖 4/7 7/7 7/7
外部依赖 Chromium 浏览器 Push2 批量查询

A 方案:同花顺 Playwright 抓取

同花顺基金页面(fund.10jqka.com.cn)还显示重仓持股表格。装个 Playwright + Chromium,模拟点击"重仓持股"标签,解析 div.cz-content 里的文本数据做加权。20 秒能跑完。

问题来了:同花顺只给有股票持仓的基金显示这个表格。ETF 联接基金和黄金基金因为不直接持有个股,显示"暂无数据"。7 只里只有 4 只能拿到数据,剩下 3 只(云、芯片、黄金)空着;每次采集要起 Chromium 进程、等页面渲染、解析 DOM,慢而且不稳定。

B 方案:天天基金持仓 API + 东方财富 Push2

天天基金虽然估值 API 挂了,但持仓详情 API 还能用。从它的 HTML 里提取 gpdmList 和权重,批量查 Push2 的个股实时价,加权计算。7 只全覆盖,比 A 方案强。

但这个方案有两个问题。一是慢,7 只基金要串行请求 7 个持仓页 + 7 次 Push2 批量查询,一趟跑下来 35 秒。二是 Push2 的 Referer 有坑:持仓 API(fundf10)要求 Referer 是 fund.eastmoney.com,但指数 Push2 用的是 quote.eastmoney.com,混用会 404。

最终方案:Sina 统一行情

回头检查预警 cron 的时候发现,fund-monitor.py 一直在用 Sina 行情查 ETF,从来没挂过。那为什么不把页面也切过去?

改起来很简单:realtime-6.py 里把 ETF 查询从腾讯行情/Push2 换成 Sina,016370 的个股加权也从 Push2 换成 Sina。反正 Sina 既能查 ETF 也能查个股,外部依赖从 4 个收敛到 2 个(Sina + 天天基金持仓 API),一次 batch 请求覆盖全部标的,耗时从 35 秒降到 3 秒。

最终的 realtime-6.py 不到 270 行,零第三方依赖,纯标准库 HTTP 请求。fund-monitor.py 也同步切到同一套逻辑,两个脚本共享完全一致的数据源。

8. 小细节,大坑

写代码的时候有几个容易忽略的点,单独列出来:

  • 天天基金持仓页的权重提取:每行 HTML 有 3 个 <td class='tor'>,前两个是实时价和涨跌幅(有 span 子元素),第三个才是占净值比例。正则直接匹配 .tor 会拿到 39 个值而不是 10 个。解决方法是加个范围过滤:0 < weight < 15。占比超过 15% 的要么是别的数值字段,要么数据有问题。
  • Referer 分裂:同一套 HEADERS_EM 字典被 Push2 和 fundf10 共用,但两个 API 要求的 Referer 不同。Push2 要 quote.eastmoney.com,fundf10 要 fund.eastmoney.com。用错了 fundf10 直接 404,报错信息毫无提示。
  • Sina 字段顺序:ETF 和个股在 Sina 的返回格式一样,都是逗号分隔的第 2 字段昨收、第 3 字段现价。但指数是另一套格式,字段位置不同。别搞混。
  • cron 语法差异:realtime-6 的 cron 覆盖 9-15(全天含午休),fund-monitor 覆盖 9-11,13-15(仅交易时段)。前者需要采集午间休市期间的点位(虽然不变),后者不需要在休市时发预警。

9. 最终效果

就是这个页面:https://venxine.vip/fundtracker/

每天盘中 10 分钟一个数据点,26 个时点全覆盖(9:30-15:00)。4 条指数线 + 7 条基金线 + 两张明细表。预警通过微信推送,超过 ±3% 会收到消息。

写这篇文章的当天(7 月 21 日)刚好赶上一波深 V 反弹。早上开盘芯片和科芯跌了 4 到 5 个点,预警直接触发;午前全线翻红,芯片收涨 6.64%,科创 50 涨了 4.75%。如果没有这个面板和预警,大概率只能收盘后才知道发生了什么。

整个系统运行在 Hermes Agent 的 cron 调度器上,两个 no_agent watchdog 脚本 + 一个 Flask 页面。没用任何第三方基金数据服务,全靠公开行情 API。

10. 如果你也想搭

核心代码都在这里了,不需要什么复杂的框架:

  • realtime-6.py:主采集脚本,约 270 行
  • fund-monitor.py:预警脚本,约 180 行
  • fund-tracker.py:JSON 存储 + 调度入口

数据源选 Sina 行情 + 天天基金持仓 API。纯 HTTP,零浏览器依赖,一次 batch 请求全部搞定。先把 ETF 映射表配好,再把主动基金的持仓抓取和加权逻辑补上,剩下的就是 ECharts 配置和页面美化。这部分反而是最花时间的。

整件事情里最大的收获其实不是技术方案本身:同一套系统里,预警 cron 一直在用最快的数据源,而页面展示走了三套方案才回到同一条路上。不同管线之间定期对齐检查,这个习惯,比换什么数据源都管用。