从零搭建一个 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 一直在用最快的数据源,而页面展示走了三套方案才回到同一条路上。不同管线之间定期对齐检查,这个习惯,比换什么数据源都管用。