每天下午两三点,我都要打开基金App,盯一眼盘中估值,再翻到昨晚公布的实际净值,心算一下两者差了百分之几。做这事时间长了,手算速度倒是练出来了,时间也悄悄耗掉了。后来干脆用Python写了个小工具,每天定时把估算净值和实际净值拉回来放一起,算偏差、画曲线、超阈值就提醒,这就是FVTracker——一个纯自用的基金估值跟踪工具。如果你也在做基金定投,或者想学Python爬虫、数据处理和定时任务,这篇内容应该能帮你省不少折腾时间。
先说清楚这个工具解决的核心问题:平台上的“盘中估值”和晚上公布的“实际净值”经常对不上,对不上本身就是信息,基金的估值偏差会随着持仓变化、市场行情、调仓动作而漂移。FVTracker要做的就是把这种漂移量化,让你不用再每天手动对比几十只基金。
1. 项目概述:FVTracker到底在做什么
1.1 手动对比估值和净值的日子
我以前跟踪基金的方式很原始。白天打开天天基金App看一眼“估算净值”,晚上等净值出来之后再打开看一眼“单位净值”,两个数做个减法,再除以净值,算出偏差率,心里记一下这只基金今天估高了还是估低了。一开始只跟踪三四只,还能应付;后来自选基金越来越多,每天这么来回切换App、算偏差,十分钟就没了,而且中间经常被工作打断,算到一半就去开会,回来看已经收盘了,数据又变了一个样子。
更麻烦的是,偏差不是一天看完就完了,它是一个持续累积的信号。某只基金今天估高1%,明天可能继续估高,后天又变正常,只有连续观察一段时间,才能看出这只基金的估值体系到底偏乐观还是偏保守。手工记在Excel里也能做,但每天复制粘贴、对齐日期、再拉公式,重复劳动极其消耗耐心,中途一旦漏填一天,前面所有的数据就全断了连贯性。
后来我意识到,这类事本质上就是一个“定时抓数 + 清洗对齐 + 算指标 + 输出结果”的流水线,完全符合Python能干的范畴。于是动了自研的念头,FVTracker的名字也就是这么来的,Fund Value Tracker,基金价值跟踪器。
1.2 为什么选择用Python自研
市面上其实已经有不少基金分析工具,App本身也带估值功能,但都只解决“看当下”的问题,解决不了“攒历史”和“自定义规则”的问题。我想做的是长期积累估值偏差数据、画趋势、看分布,并且能在异常时主动提醒我,这些需求在现成工具里基本实现不了,或者说就算实现了也是封闭的,数据拿不出来。
选择Python有四个现实理由:一是爬虫生态太成熟了,requests抓公开接口、BeautifulSoup解析页面,几十行代码就能拿到数据;二是pandas处理表格数据顺手,基金净值、估值、日期这些字段做对齐和去重非常方便;三是matplotlib画图够用,不需要额外引入重型BI工具;四是定时任务方案简单,windows上可以用计划任务,Linux上用crontab,Python内部还有schedule库,一个小工具用不到分布式调度那么复杂的东西。
另外我本身就在用Python做数据统计相关的事,vscode环境现成,虚拟环境也配好了,写这个工具几乎没有额外的环境成本。这也是一个很现实的选型理由:工具开发不一定要选最牛的架构,选自己最顺手、能最快跑起来的方案才是正解。
1.3 1.22版本的整体框架与亮点
FVTracker 1.22这版整体结构分四层:数据采集层负责从公开接口拉取基金估值、历史净值和基础信息;数据处理层负责清洗、对齐日期、算偏差率和滚动指标;存储层用SQLite保存历史快照,防止程序重启之后数据丢失;展示层负责输出终端表格、生成偏差趋势图,以及发送自定义阈值提醒。
这版比较大的更新有三个。第一是数据源解析做了适配,之前有个估值接口改过字段格式,旧代码直接取错列,这版把解析逻辑改成按字段名取值而不是按下标硬取,兼容性好了很多。第二是增加了SQLite持久化,历史估值和净值不再只存在CSV里,支持按日期增量写入,即使某天忘了跑任务,下次启动也能自动补拉缺失数据。第三是盘中提醒改成可配置阈值,超过设定偏差可以发通知,不用全天盯着。
整个框架不复杂,大概一千多行Python代码,但把“跟踪基金估值”这件事完整闭环了。下一章聊聊里面最关键的几个实现逻辑。
2. 核心技术拆解:估值跟踪的底层逻辑
2.1 估值数据与净值数据的来源
FVTracker用到的数据源主要是国内基金销售平台的公开接口,比如天天基金那套fund.eastmoney.com的域名体系。基金列表用fundcode_search.js这个文件,一次性拉回全市场基金代码和名称;盘中估值用fundgz接口,按基金代码返回实时估算值;历史单位净值用f10的lsjz接口,按页返回过去几十个交易日的净值和增长率。
这里要说明一个重要概念:盘中估值本身不是基金公司官方发布的,它是平台根据基金定期报告里披露的持仓信息,结合当天实时行情估算出来的结果。也就是说估值天然会和实际净值存在偏差,因为季报持仓有滞后性,基金经理可能早就调仓了,但估值系统还拿着旧持仓在算。这就是为什么“估高”和“估低”不是bug,而是金融数据本身的特性。
所以在设计时我没有把估值当成“准净值”来用,而是把它当作一个过程信号。工具的真正作用是把估值变成一条可对比、可统计的时间序列,通过跟踪它和官方净值的差值,反推这只基金当前的估算模型是否还跟得上实际持仓变化。一只基金如果连续一个月每天都在估高,那大概率是它实际持仓和最近一期季报披露的差异很大,这个信号在决策时比单日估值有意义得多。
爬接口的时候我加了两层保护:一是requests的headers里带上UA和Referer,模拟正常浏览器访问,避免被当异常流量拦掉;二是加了重试机制,请求失败或返回空数据时最多重试三次,间隔两秒,还是失败就跳过,不影响主流程。公开接口只做个人研究用途,请求频率我也压制得比较低,半小时跑一次足够用了。
2.2 偏差计算:从单日偏差到滚动均值
FVTracker最核心的指标就叫“估值偏差率”。计算公式很简单:当日盘中估值减去最近一个公布净值的差,再除以最近净值,乘以100得到百分比。这个数每天不是固定的,盘中随行情波动,所以我取的是下午某个固定时刻的快照,而不是全天实时值,固定时刻的数据才具有日间可比性。
单日偏差率只能看出这只基金今天估得准不准,看不出趋势。所以我在1.22版本里加了两个衍生指标:滚动5日均值偏差,用来抹平单日噪音;滚动10日偏差标准差,用来观察估值偏离的稳定性。均值为正说明这只基金整体被高估,均值为负说明整体被低估,标准差大说明它的估值忽高忽低,模型极不稳定。
实际使用时,这三个指标组合起来就是一套完整的估值体检报告。我举个例子,一只指数基金因为跟踪的是公开指数,持仓透明,偏差率通常很小,标准差也低;主动型基金则完全不同,市场风格切换快的月份,它的估值偏差可能一天一个样。这个差异本身就可以作为工具筛选基金时的一个维度。
建立滚动指标的代码很简单,pandas里用rolling窗口就好,但有一个细节要注意:数据必须严格按交易日排序,然后只保留每个自然日最新一条估值记录,否则rolling计算会把非交易日的空行也算进去,结果直接崩掉。这个坑我踩过一次,后面在数据处理章节专门做了去重和排序处理。
2.3 异常提醒与阈值设计
FVTracker的提醒逻辑比较直觉:盘中估值偏差超过设定阈值就通知我当时注意。1.22版本把阈值从写死的值改成了配置项,可以按基金分组配置。比如指数基金阈值设1.5%,主动型基金阈值设3%,不同风格分开管理,避免误报。
阈值应该设多大才算合理,我给个参考:指数基金偏差超过1%已经比较多,主动型基金偏差超过2%也不算异常,真正需要关注的是连续三日偏差超过阈值的基金,这种才说明它可能正在调仓。所以我设计提醒时加了一个连续命中逻辑,连续N次偏差超阈值才触发,单独一天的毛刺不会打扰我,这也算踩过几次坑之后总结出的经验。
提醒通道我先做了最简单的终端高亮显示,后来又加了可选的第三方通知渠道,要做也不难,HTTP POST一个JSON就行。但我的建议是第一步先做终端输出,确认数据和逻辑没问题,再上通知,否则频率太高会把提醒渠道变成骚扰渠道。
3. 实操过程:从零搭建FVTracker并跑通
3.1 环境准备:Python安装与VSCode配置
如果你是第一次接触Python,环境这块值得多说两句。Windows用户去Python官网下载安装包,安装时最关键的步骤是勾选“Add Python to PATH”,这个选项默认是不勾的,漏掉之后命令行里敲python会直接提示找不到命令。装完之后打开命令行,输入python --version,能显示版本号就说明环境变量配置成功。
我自己习惯用VSCode配合Python插件来写代码。VSCode安装Python扩展之后,按Ctrl+Shift+P打开命令面板,选择“Python: Select Interpreter”,指定刚才装好的Python解释器,这样开发和运行的Python环境才一致。很多人写完代码能跑,换到命令行就跑不了,多半是解释器选错了或者PATH没配好。
FVTracker不依赖多复杂的Python版本,3.8以上即可。如果你机器上同时有Python 2和Python 3,一定注意命令行用的是哪个,Windows平台建议用python而不是python3来指定解释器,Linux/macOS则建议用python3,两边习惯不一样,别混淆。
3.2 依赖安装:一次搞定requests/pandas/matplotlib
FVTracker依赖不多,核心四个库:requests负责HTTP请求,pandas负责数据处理,matplotlib负责画图,schedule负责定时任务。numpy是pandas的底层依赖,通常会自动装上,不用单独操心。
安装命令就是一行:
pip install requests pandas matplotlib schedule国内网络环境下如果下载慢,可以用清华镜像源加速:
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple requests pandas matplotlib schedule我遇到过最常见的问题是命令行提示pip不是内部或外部命令,这本质上是Python的Scripts目录没有加进PATH,要么重新安装并勾选Add to PATH,要么手动把Python安装目录下的Scripts文件夹加进环境变量。另一个容易踩的坑是直接用系统环境装的Python混用了多个版本,装库装进A版本,运行代码用的却是B版本,这种现象尤其容易发生在电脑里装了Anaconda又装了官方Python的人身上,建议项目里用虚拟环境隔离一下。
最后用pip list命令确认库都装上,检查版本号无误,环境就算准备好了。依赖这块花费的时间往往比写代码还长,出了一次环境问题之后我强烈建议任何人都在项目目录下建虚拟环境,这是Python社区给出的一致答案。
3.3 核心代码:抓取、清洗、计算、画图
先讲抓估值数据。天天基金的盘中估值接口是fundgz.1234567.com.cn/js/{基金代码}.js,返回格式类似jsonpgz({"fundcode":"161725", "name":"招商中证白酒", "gsz":"0.7134", "gztime":"2024-12-20 14:30"}),前面有jsonpgz字符串包着,需要先截取括号里的内容再解析。代码逻辑如下:
import json import requests def fetch_estimate(fund_code): url = f"https://fundgz.1234567.com.cn/js/{fund_code}.js" headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Referer": "https://fund.eastmoney.com/"} resp = requests.get(url, headers=headers, timeout=10) text = resp.content.decode("utf-8") if "jsonpgz" not in text: return None payload = text[text.find("(") + 1 : text.rfind(")")] data = json.loads(payload) return data # 返回 dict,含 gsz(估值)、gztime(估值时间)再取历史净值。f10的lsjz接口需要Referer头,而且返回的是JSON嵌套结构,路径比较深。代码里我专门拆了一个函数,把基金代码、起始页、每页条数都做成参数,方便拉取不同时间跨度的数据:
def fetch_nav_history(fund_code, page=1, page_size=30): url = "https://api.fund.eastmoney.com/f10/lsjz" params = {"fundCode": fund_code, "pageIndex": page, "pageSize": page_size} headers = {"User-Agent": "Mozilla/5.0", "Referer": f"https://fundf10.eastmoney.com/jjjz_{fund_code}.html"} resp = requests.get(url, params=params, headers=headers, timeout=10) if resp.status_code != 200: return [] body = resp.json() records = body.get("Data", {}).get("LSJZList", []) # 我们只需要日期和单位净值 return [{"date": r["FSRQ"], "net_value": float(r["DWJZ"])} for r in records]两条数据拿到之后,进入核心计算流程。首先把历史净值转成以日期为索引的Series,然后取最近一条作为基准净值;其次取当天的盘中估值,计算偏差率。1.22版里我把这个逻辑封装成了函数,输入估值数据和净值数据,输出标准化的记录:
def calc_deviation(estimate, latest_nav): if estimate is None or latest_nav is None: return None gsz = float(estimate["gsz"]) base_nav = float(latest_nav["net_value"]) deviation = (gsz - base_nav) / base_nav * 100 return { "code": estimate["fundcode"], "name": estimate["name"], "gsz": gsz, "base_nav": base_nav, "deviation_pct": round(deviation, 4), "timestamp": estimate["gztime"], }这里有一个重要细节:偏差率分母用的是最近一个公布净值,不是上一交易日的净值。因为盘中估值反映的是“现在这个时间点值多少钱”,要比较的对象就得是“最近官方认可的净值锚点”,两者时间差不能混。拿到偏差之后,pandas处理一行代码就能更新滚动均值:
import pandas as pd records_df = pd.DataFrame(all_records) records_df["avg_dev_5"] = records_df["deviation_pct"].rolling(5).mean() records_df["std_dev_10"] = records_df["deviation_pct"].rolling(10).std()画图部分用matplotlib,横轴是日期,纵轴是偏差率,画两条线:一条单日偏差率,一条5日均值线。收盘之后图表自动保存成本地PNG文件,文件名带日期,方便翻历史。画图还有一个Python细节:中文标签默认会显示成方块或者乱码,需要在代码里指定中文字体文件,或者设置plt.rcParams里的font.sans-serif为SimHei、Microsoft YaHei之类的字体名称,折腾一次之后就成了经验常驻项。
3.4 1.22版本的新增功能与发布说明
FVTracker 1.22这版发布说明我简单整理一下,整体分四块。
第一块是数据源兼容升级。之前f10净值接口在某次改版之后,返回体里的DWJZ字段名发生变化,旧代码按下标取列会拿错数据,这次统一改成按字段名取值并增加字段缺失校验,接口结构再变时至少能报出明确的错而不是静默出脏数据。
第二块是引入SQLite持久化。旧版本每次运行都重新拉历史数据,不仅慢,而且当天运行多次会生成重复记录。新版按日期做唯一索引,增量写入,程序启动时自动检测缺口并补拉。这也让趋势图的数据来源从内存变成了本地库,数据积累越多越能发挥作用。
第三块是偏差提醒可配置。在config.json里就可以设置每只基金的偏差阈值和连续命中次数,不用改代码。提醒触发后会记录到单独日志表,方便事后复盘当天触发提醒到底合不合理。
第四块是修复了收盘后定时任务跑飞的问题。老版本定时任务每天下午14:30跑,但遇到基金提前收盘或者接口空数据会直接卡住,新版加了空数据判断和超时跳过,保证任务到点一定正常结束。
这四块都不是大改,但对长期自用来说每一点都实打实提升了体验。工具是我自己写给自己用的,所以版本更新完全跟着真实需求走,不追求复杂,只追求跑得稳。
4. 常见问题与排查技巧实录
4.1 接口被限制或返回数据乱码
用requests请求公开接口时,最容易遇到两种情况:一是返回403,二是返回内容里带乱码。403一般是缺header或请求太频繁,我解决的办法是加完整UA和Referer,并且把请求间隔控制在10秒以上。乱码十有八九是编码问题,resp.content.decode("utf-8")就能解决,别直接用resp.text,requests会根据响应头猜测编码,但这类基金接口返回的头部不全,猜错的概率不小。
还有一个问题是接口返回空括号或空JSON,这多数发生在非交易时段或者基金代码不存在。处理逻辑必须做好空值兜底,否则pandas合并数据时直接报错。我封装了一个try_except全局异常捕获,任何一只基金请求失败都不会中断全流程,只会记录error日志,程序结束时统一汇总打印。
历史净值接口还有一个特殊性:必须带Referer头,不带Referer会返回数据格式错误。这种坑只有在真机调试时才会发现,文档不会写,所以排查这类问题时要有意识先怀疑headers再怀疑代码逻辑,往往几分钟就找到原因了。
4.2 非交易日与收盘后的空数据
基金只在交易日有盘中和净值数据,周六周日和节假日接口返回的估值往往是空字符串。FVTracker在计算偏差时一开始没做处理,导致周末跑定时任务时把昨天的旧数据重复算一遍,滚动均值被重复计数拉偏。
后来我加了两个开关:第一个开关判断今天是否交易日,判断逻辑是请求一次当天接口,如果返回空数据就认为是非交易日直接跳过;第二个开关是数据写入SQLite前判断日期是否已存在,存在就跳过,从源头避免重复。这里有个正经的交易规则值得提示:交易日不一定是周一至周五,法定节假日调休的周末补班日往往也是非交易日,所以不能简单按星期判断,必须以接口返回为准。
盘中估值时间也会影响计算结果。上午11点和下午14点的估值差很多,如果一天跑多次任务,取哪次的快照就必须明确。我的方案很固定,每天14:30取一次快照,并把gztime写进库里,分析时只取每个自然日gztime最接近15:00的一条记录。这样无论任务漏跑还是多跑,口径始终一致。
4.3 定时任务不执行或时间对不上
schedule库是Python内部定时方案的常用选择,代码很简单,但有个坑是run_pending()必须持续轮询,程序不能退出。它的运行循环长这样:
import schedule import time schedule.every().day.at("14:30").do(job) while True: schedule.run_pending() time.sleep(30)如果程序所在机器在固定时间段休眠,比如笔记本合盖,到点任务自然就跑了。所以自用工具我建议跑在常开设备上,比如NAS、小主机、云服务器或者公司的常开电脑。Windows下用计划任务配python执行脚本也是一个稳定方案,不建议用while True轮询挂在一台会待机的电脑上。
时间对不上还有一个坑是时区问题。国内服务器一般默认北京时间,但如果你用的是海外VPS或者某些云函数,系统时区可能是UTC,14:30执行就成了晚上10点半。排查方法很简单:先在脚本里打印datetime.now()看当前时间,确认系统时间正确再谈定时逻辑。另外基金净值是晚上才更新,如果任务设在下午,拿到的是昨天的净值,这时对比的对象要调整,否则偏差率算出来毫无意义。
4.4 数据重复与脏数据清理
数据积累到一定规模后,重复记录和脏数据几乎必然出现。常见脏数据有:净值字段缺失、净值为0、日期格式不统一、单位错误(万和元混用)。我在数据写入SQLite前加了一道清洗步骤,用pandas先把Date列统一转成datetime类型,再把净值列用pd.to_numeric强制转float,errors参数设成coerce,转不了的就变NaN,最后统一dropna后再入库。
重复数据靠SQLite的唯一约束从根本解决。建表时把fund_code和date设为联合主键,重复写入直接报错跳过。如果以前已经积累了一批带重复数据的CSV,需要一次性清理,用pandas的drop_duplicates(subset=["code", "date"], keep="last")就能搞定。
清洗逻辑不要贪多,够用就好。工具开发里最容易犯的错是总想一步到位处理所有异常,结果清洗代码比功能代码还长。我的经验是先做最常出现的空值和去重,还有历史和重复写保护,遇到新的脏数据模式再针对性加一条清洗规则,慢慢演进比一次设计完美更实际。下面把几个高频问题整理成速查表,方便直接查:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 请求返回403 | 缺少UA/Referer或频率过高 | 补全headers,请求间隔至少10秒 |
| 返回内容乱码 | 编码判断错误 | 使用resp.content.decode("utf-8")而非resp.text |
| 周末数据异常 | 非交易日无新数据 | 按接口返回判断交易日,非交易日整体跳过 |
| 定时任务没跑 | 机器休眠或时区不对 | 部署到常开设备,先打印datetime.now()核对时间 |
| 净值为0或缺失 | 接口偶发异常 | pd.to_numeric转float,dropna清洗后入库 |
| 重复记录 | 一天运行多次 | SQLite联合主键,插入前按code+date去重 |
| 图表中文乱码 | matplotlib默认字体不支持 | 设置plt.rcParams["font.sans-serif"]为SimHei |
写到这儿,FVTracker 1.22从设计思路到部署踩坑算是全流程过了一遍。说个我自己的体会,做这个工具最大的收获不是每天省下的那几分钟,而是把“估值偏差”这个原本只靠感觉去判断的东西变成了一组可以回看、可以统计、可以验证的硬指标。以前我调仓多少带点拍脑袋,现在至少能翻出过去一段时间的偏差曲线,心里有了数据支撑,下单时底气足了不少。后续我还想把板块行情和持仓集中度一起接进来,在估值跟踪基础上做一个盘中综合快照,让FVTracker不只是跟踪,还能辅助理解盘面风格切换。工具持续自己用自己改,这种按需生长的感觉,比一开始就做大而全的平台踏实得多。