拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python爬虫实战:获取天天基金网历史净值数据并导出CSV

Python爬虫实战:获取天天基金网历史净值数据并导出CSV

做基金定投复盘和量化回测时,最让我头疼的不是策略怎么写,而是历史净值数据从哪儿来。天天基金网数据全、更新快,确实是最顺手的公开数据源,但官网并没有提供“一键导出CSV”的按钮,历史净值页面一页只给20条,几百页数据靠手动翻,人直接麻了。所以我用Python写了一个小爬虫,把天天基金网的历史净值数据自动拉下来,统一清洗、落地成本地CSV,后面做回测也好、做可视化也好,直接用这份本地数据就行。

这篇文章会完整走一遍整个流程:从浏览器开发者工具里定位真实的JSON数据接口,到requests模拟请求、翻页抓全,再到pandas清洗字段、保存文件。全程用一只具体的基金做演示,代码可以直接抄。适合有一定Python基础、想拿真实金融数据做个人研究的朋友;如果你完全没接触过爬虫,先把requests和pandas装上,跟着文章一步步跑,也能跑通。

1. 项目目标与整体设计思路

1.1 需求拆解与数据源选型

先说说我当时的需求。我要拿某只主动型基金过去五年的净值数据,用来计算最大回撤、年化波动率,顺便做一次定投策略的简单回测。天天基金网网页上有完整的“历史净值”列表,包含了净值日期、单位净值、累计净值、日增长率这几个字段,理论上手动复制也能用,但数据量一大,人工操作既慢又容易出错。

所以我把需求拆成了四步:第一,找到天天基金网历史净值数据背后的真实接口;第二,用Python模拟浏览器请求,把这个接口的数据完整拉下来;第三,把JSON数据转成结构化的表格,补全字段含义和格式;第四,保存成CSV文件,方便后续直接读取分析。

数据源选型我当时也纠结过。市面上有tushare、akshare这类现成的金融数据库,注册后也能拿到基金净值数据,但有的需要Token,有的需要积分,有的更新不是特别及时。而天天基金网本身就是基金公司净值公告的聚合展示平台,数据源公开、覆盖全、更新快,对个人研究来说完全够用。更重要的是,自己动手写一遍爬虫,能让你彻底搞清楚数据从哪来、字段代表什么、为什么会出现某些异常值,这些经验是直接调用库拿数据学不到的。

1.2 动态页面背后的接口思维

很多人第一次看到“爬取天天基金网”,第一反应就是用requests直接请求那个历史净值的HTML页面,然后用BeautifulSoup解析表格。这个思路放在十年前没问题,但现在基本行不通了。因为我打开浏览器开发者工具,切到Network面板,Refresh页面后会发现,那个净值表格根本不在最开始的HTML里,而是页面加载完成后,通过JavaScript异步请求了一个接口,拿到JSON数据,再动态渲染出来的。

这就是典型的动态页面。处理动态页面有两个思路:一是用selenium之类的工具模拟浏览器,等页面渲染完再去抓DOM;二是直接从开发者工具里找到那个真实的JSON接口,模拟它发请求。我选择了后者,原因是它更轻量、更稳定,返回的数据本来就是结构化的,不需要在解析HTML上花太多时间。说白了,爬虫的核心不是机械地模仿“人看网页”这个过程,而是理解“网页从哪拿数据”,直接去数据源头取。这个思维转变是整个项目最关键的一步,比写代码本身重要得多。

2. 环境准备与接口参数分析

2.1 Python环境与依赖安装

基础环境方面,我建议用Python 3.8以上版本,不需要装什么重型框架。整个项目只依赖两个库:requests负责发HTTP请求,pandas负责数据处理和CSV导出。安装命令很简单:

pip install requests pandas

如果你用Anaconda,也可以用conda install requests pandas,效果一样。我本地实测的版本组合是Python 3.11.5、requests 2.32.3、pandas 2.2.2,这套组合跑得很稳。

这里说明一下为什么不用scrapy、不用selenium。scrapy功能强大,但学习成本高,而且项目体量就是“单站、单表、单脚本”,杀鸡用牛刀;selenium能处理动态渲染,但每次都要启动一个浏览器实例,又慢又容易被反爬识别,维护成本也高。直接请求JSON接口,既绕开了页面渲染,又不需要额外的浏览器驱动,是最省事的方案。

2.2 接口地址与关键参数解析

天天基金网的前端版本经常更新,接口路径和参数名可能会变,所以你不需要死记硬背具体的URL,而是要学会怎么在开发者工具里定位它。操作方法是:打开天天基金网的基金详情页,按F12进入开发者工具,切到Network面板,勾选Fetch/XHR,然后刷新页面。等净值表格渲染出来后,在请求列表里找“lsjz”“LSJZ”或“f10”字样的请求,点开它的Response,如果看到一长串包含净值的JSON,那基本就是这个接口了。

我当时拿到的接口形式类似这样:

base_url = "https://api.fund.eastmoney.com/f10/lsjz"

请求方式为GET,核心参数如下:

参数名含义示例值
fundCode基金代码,6位数字110022
pageIndex页码,从1开始1
pageSize每页条数,页面默认2020
startDate起始日期,可选2019-01-01
endDate结束日期,可选2025-01-01

响应数据是一个JSON对象,核心部分在Data字段里。Data.TotalCount表示总条数,Data.LSJZList是净值列表,列表里每个元素包含净值日期、单位净值、累计净值、日增长率等字段。需要注意,接口返回的字段名是英文缩写,实际写代码时要做好映射。

2.3 请求头的设置逻辑与频率控制

请求头里面有几个细节容易被忽略,但很影响成功率。第一个是User-Agent,它告诉服务器“你是谁”,不设置的话很多站点会直接拒绝,建议写成浏览器正常的UA;第二个是Referer,表示请求从哪个页面跳转过来,天天基金网对Referer有一定校验,最好填成对应的基金详情页地址。这样做的本质是让服务器觉得这是一个正常的浏览器在访问,而不是脚本在扫数据。

另一个必须控制的是请求频率。很多人第一次写爬虫,喜欢一口气把所有页面拉完,把time.sleep设成0.1秒甚至不设,结果翻页翻到一半就发现接口开始返回空数据,或者干脆被临时限制。我实测下来,翻页间隔在1.5秒左右比较稳,如果是长时间、大批量抓取,间隔放到3到5秒更安全。基金历史净值的更新频率低,数据量也没有到 “必须抢时间” 的程度,慢一点完全不影响体验。

3. 完整代码实现与实操过程

3.1 先跑通单页请求

拿到接口地址和参数之后,不要急着写完整循环,而是先跑通单页请求,确认数据结构,再逐步扩展。

import requests fund_code = "110022" url = "https://api.fund.eastmoney.com/f10/lsjz" params = { "fundCode": fund_code, "pageIndex": 1, "pageSize": 20, } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": f"https://fund.eastmoney.com/{fund_code}.html", } resp = requests.get(url, params=params, headers=headers, timeout=15) data = resp.json() print("总条数:", data["Data"]["TotalCount"]) print("当前页条数:", len(data["Data"]["LSJZList"])) print("第一条数据:", data["Data"]["LSJZList"][0])

这段代码里,requests.get的params参数会自动帮我把字典拼接成URL后面的查询字符串,比手动拼URL安全,也不用担心特殊字符转义。timeout=15是必须加的,不然网络异常时请求可能一直挂在那里。

跑完代码,你应该能在控制台看到类似“总条数: 3523”这样的输出。如果看到Data为null,别急着改代码,先检查是不是请求频率被限制了,或者把resp.text打印出来看具体返回内容。

3.2 翻页抓取与数据合并的完整脚本

单页通了之后,接下来就是把所有页的数据都拉下来。我习惯用一个列表all_rows保存每页返回的记录,最后一次性转成DataFrame,而不是每页都去拼接DataFrame,这样性能更好,逻辑也更清晰。

import time import requests import pandas as pd fund_code = "110022" base_url = "https://api.fund.eastmoney.com/f10/lsjz" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": f"https://fund.eastmoney.com/{fund_code}.html", } page_size = 20 all_rows = [] params = { "fundCode": fund_code, "pageIndex": 1, "pageSize": page_size, } resp = requests.get(base_url, params=params, headers=headers, timeout=15) data = resp.json() total_count = data["Data"]["TotalCount"] total_pages = (total_count + page_size - 1) // page_size print(f"总共 {total_count} 条数据,{total_pages} 页") for page in range(1, total_pages + 1): params["pageIndex"] = page for attempt in range(3): try: resp = requests.get(base_url, params=params, headers=headers, timeout=15) js = resp.json() if js.get("Data") is None: raise ValueError(f"Data为空: {resp.text[:200]}") rows = js["Data"]["LSJZList"] if not rows: print(f"第{page}页无数据,提前结束") break all_rows.extend(rows) print(f"第{page}/{total_pages}页完成,累计{len(all_rows)}条") break except Exception as e: print(f"第{page}页第{attempt+1}次请求失败: {e}") time.sleep(3) time.sleep(1.5) df = pd.DataFrame(all_rows) column_map = { "FSRQ": "净值日期", "DWJZ": "单位净值", "LJJZ": "累计净值", "JZZZL": "日增长率", } df = df.rename(columns=column_map) df = df[list(column_map.values())] df["净值日期"] = pd.to_datetime(df["净值日期"]) df["单位净值"] = pd.to_numeric(df["单位净值"], errors="coerce") df["累计净值"] = pd.to_numeric(df["累计净值"], errors="coerce") df["日增长率"] = pd.to_numeric(df["日增长率"], errors="coerce") df = df.drop_duplicates(subset=["净值日期"]).sort_values("净值日期").reset_index(drop=True) df["净值日期"] = df["净值日期"].dt.strftime("%Y-%m-%d") df.to_csv("fund_history.csv", index=False, encoding="utf-8-sig") print(df.head())

逐个解释一下这段代码里几个关键设计。

第一,total_pages = (total_count + page_size - 1) // page_size是向上取整的经典写法。比如总条数3523,每页20条,直接整除是176页余3条,如果不加page_size - 1,最后那3条就会漏掉;加上之后结果是177页。

第二,每页请求都包在一个for attempt in range(3)的重试循环里。爬虫跑久了难免会遇到偶发超时,加一层重试能明显提高成功率。如果Data返回null,我直接抛异常,而不是继续往后处理,这样能及时发现问题。

第三,errors="coerce"的作用是把无法转成数值的内容变成NaN,避免因为某条数据异常导致整个脚本崩溃。净值字段在接口里经常是字符串,转成float之后才能做计算。

第四,drop_duplicates(subset=["净值日期"])用于去重。正常情况下同一只基金同一天只可能有一条净值数据,但为了防止翻页边界重复采样,加上这一步总是好的。

第五,导出的CSV用了encoding="utf-8-sig"。这个-sig后缀不是随便加的,它会在文件开头写入BOM标记,Excel打开时才不会乱码。如果写成普通的utf-8,用记事本看没问题,Excel打开就全成乱码了。

3.3 落地保存与结果验收

脚本跑完之后,生成一个fund_history.csv文件,我会先做一次结果验收,而不是直接拿去分析。验收方式很简单:用Excel或pandas打开文件,检查第一条和最后一条记录的日期,确认数据覆盖范围正确;抽几个日期的单位净值和天天基金网页面交叉核对;再看一眼行数是否和接口返回的TotalCount一致。

如果发现行数少于预期,最可能的原因有两个:一是中间有页请求失败且重试也没成功,需要检查异常日志;二是某些净值日期重复,被drop_duplicates去掉了。这里需要特别说明:去重可能导致最终行数小于TotalCount,因为接口的TotalCount是原始记录数,并不保证没有重复,所以遇到这种差异不用太慌。

4. 财务字段理解与数据清洗细节

4.1 单位净值、累计净值、日增长率

拿到了数据不等于能直接用。我见过太多人拿着“单位净值”直接算收益率,算出结果跟基金公司官方公布的收益率对不上,然后一脸懵。问题出在字段含义上。

单位净值,全称“基金份额净值”,就是每一份基金份额当日收盘后的净资产价值,计算公式是(基金总资产-基金总负债)/基金总份额。它是每天交易结算后的结果,不是盘中的实时估值。注意,天天基金网上还有一个“实时估值”功能,那个是盘中的预测值,不是官方结算数据,跟我们要爬的“历史净值”完全不是一回事,别混在一起。

累计净值,是在单位净值的基础上,把历史上的每一次分红都加回去。举个例子,某基金单位净值1.5元,每份分红0.1元之后,单位净值会变成1.4元,但累计净值可能从1.8元变成1.9元。所以累计净值比单位净值更能反映一只基金的长期真实表现,但要注意它并没有考虑“份额拆分”的影响,严格做收益回测时最好用“复权净值”,天天基金网页面上没直接给,后续需要自己算。

日增长率,表示当日单位净值相对前一交易日单位净值的涨跌幅。这里有个关键点:它是基于“单位净值”计算的,不是基于“累计净值”。分红当天,单位净值下降,日增长率就是负的,但这不是基金经理亏了钱,而是钱以分红的形式给到了投资者手里。

字段含义梳理成一个关系表会更直观:

字段含义典型用途
净值日期交易日时间序列索引
单位净值每份净资产计算短期涨跌、估值
累计净值考虑分红后的净值衡量长期业绩
日增长率单日涨跌幅波动率、回撤计算

4.2 分红与拆分对数据的影响

这是基金净值数据清洗里最容易踩坑的地方。基金分红时,单位净值会除息,也就是净值突然向下跳空,但投资者的总资产并没有变化,因为分红款会到账。如果这时候你拿着单位净值序列去画收益曲线,会看到一条“假摔”的曲线,好像基金某天亏了很多,实际上不是。

我就犯过这个错。当时爬了一只分红频率很高的债券基金,直接把单位净值拿来算累计收益,算出来的年化收益比基金公司官方公布的低了一大截。查了半天才发现,所有分红造成的净值下跌都被我误判成了亏损。

解决办法有两个。第一种,直观但粗糙:直接看“日增长率”字段,因为基金公司官方在计算日增长率时已经把分红除息的因素处理过了,用这个字段做日度收益基本不会有跳跃。第二种,比较严格:用“累计净值”或“复权净值”去做长期收益计算,这样分红会自动被计回到净值里。但要注意,还有其他特殊情况会发生净值“跳空”,比如基金份额折算、大额赎回导致持仓比例被动变化等,这类极端情况用数据就能筛出来。

4.3 数据完整性与质量检查

爬完数据,我还习惯做一次“自检”,方法是用净值序列反推一个“实测日增长率”,跟接口返回的“日增长率”对比,看有多少对不上。

df["前一日单位净值"] = df["单位净值"].shift(1) df["实测日增长率"] = (df["单位净值"] / df["前一日单位净值"] - 1) * 100 df["偏差"] = (df["日增长率"] - df["实测日增长率"]).abs() outliers = df[df["偏差"] > 0.1] print(outliers[["净值日期", "单位净值", "累计净值", "日增长率", "实测日增长率"]])

这里的阈值我设为0.1个百分点,也就是0.1%。为什么留这个容忍度?因为单位净值通常保留4位小数,日增长率保留2位小数,进位误差会造成小幅偏差。如果偏差超过0.1%,要么是分红除息,要么是接口数据异常,要么是当天有特殊事件,逐一排查即可。

这样做还有一个额外好处:能够发现那些“看起来正常、实际上漏了数据”的情况。如果某一天净值缺失,shift(1)会把前一天的净值跟后一天的净值放在一起比较,算出来的实测日增长率会非常大,一眼就能看出来。

5. 常见问题与排查技巧实录

5.1 高频异常场景速查表

写这个爬虫的过程中,我踩了不少坑,也帮朋友排查过类似问题。把高频异常整理成一个速查表,遇到问题直接对应着看:

现象可能原因处理方式
返回JSON里Data为null请求频率过高,或UA被识别为脚本放慢请求,改成浏览器UA,等几分钟再试
只有最后一页数据或数据不完整pageIndex传成了0pageIndex从1开始
CSV用Excel打开乱码文件编码用了utf-8而不是utf-8-sig保存时用encoding="utf-8-sig"
日期排序不对,最新日期在最后接口默认按净值日期倒序返回用sort_values(ascending=True)重排
净值字段类型不是数值接口直接返回文本类型用pd.to_numeric转数值
日增长率出现超过10%的极端值分红、拆分、停牌复牌等特殊事件用累计净值联动判断,不一定是爬虫出错
请求偶尔超时,导致某几页缺失网络波动加重试机制,建议3次,间隔3秒

5.2 几个我踩过的坑和规避方法

第一个坑是“用页面URL当接口URL”。我开始时图省事,直接请求历史净值页面的URL,返回的HTML里确实有表格,但那是经过服务端模板渲染的部分数据,翻页参数藏在JavaScript代码里,解析起来特别痛苦。后来切到开发者工具去看真正的XHR请求,才发现背后的JSON接口,效率和稳定性完全不一样。所以再强调一次:优先找接口,不要死磕HTML。

第二个坑是“pageSize设太大以为能一次拉完”。有段时间我把pageSize设成1000,想一页搞定,结果接口返回的数据明显被截断,看起来像是被服务端限制。实际上很多站点会忽略超范围的pageSize,或者按上限截断,导致数据不完整。保险做法是先按20请求,观察TotalCount和实际返回条数的关系,再决定要不要调大。

第三个坑是“不做异常日志,失败后无从查起”。早期版本我只print了“第X页完成”,没有打印失败信息。结果某次跑到第78页挂了,脚本直接退出,我也不知道挂在哪里。后来改成每页失败都打印页码和原因,配合重试机制,问题就好查多了。

第四个坑更重要:接口地址会变。天天基金网只要改版,接口路径、参数名、字段名都可能调整,这个没办法一劳永逸,只能熟练使用开发者工具,学会自己定位新接口。这也是我在这篇文章里反复强调“思路比固定代码重要”的原因。

6. 从单只基金到批量数据的扩展思路

6.1 批量爬取多只基金

单只基金跑通之后,批量就简单了。把基金代码整理成一个CSV文件,比如叫fund_list.csv,里面放一列fundCode,然后在外层套一个for循环:

import pandas as pd fund_list = pd.read_csv("fund_list.csv") for code in fund_list["fundCode"]: print(f"开始处理 {code}") # 复用前面翻页抓取的逻辑 # 保存文件时命名为 fund_history_{code}.csv time.sleep(3)

需要提醒的是,批量任务里每只基金之间的间隔要明显拉长,至少3秒以上。基金越多,越要克制,否则很容易触发频率限制,整批任务报废。

6.2 增量更新与定时任务

历史净值是每天收盘后更新的,所以爬虫跑一次拿到的是全量数据。如果之后只想每天拉一次增量,不要重新全量抓,那样既浪费请求次数又容易被限制。更好的做法是先从本地CSV里读出最大净值日期,然后请求参数里带上startDate,只抓这个日期之后的新数据:

import pandas as pd old_df = pd.read_csv("fund_history.csv") latest_date = pd.to_datetime(old_df["净值日期"]).max().strftime("%Y-%m-%d") params["startDate"] = latest_date

这样可以大幅减少每次的数据量,同时减轻服务器压力。配合Linux的crontab或者Windows的任务计划程序,就能实现每天自动更新。我个人建议增量任务频率不要高于每天一次,毕竟基金净值一天只更新一次,跑多了没有意义。

6.3 后续可视化和接口复用

数据到手之后,画净值走势图、计算最大回撤、做定投回测,都是顺理成章的事。用matplotlib或plotly画两条线,一条单位净值、一条累计净值,基金的长期表现和回撤情况会清晰很多。

如果还想继续扩展,可以把爬虫封装成一个FundDataCrawler类,提供get_history(fund_code)、get_dividend(fund_code)这样的方法,以后写策略框架时直接调用,不用每次都复制粘贴脚本。天天基金网除了历史净值,还有基金分红、规模变动、基金经理变更等数据,接口风格类似,学会了这一套,迁移到其他数据接口并不难。

最后再分享一个我自己的习惯:数据落地之后,不要急着画图,先花几分钟做数据体检,用第三节说的反推法把日增长率校验一遍。我因为偷懒跳过这一步,曾经把某只分红频率很高的基金净值直接拿来算收益,结果曲线看着不错,实际收益率跟官方公布的对不上。后来才醒悟,是分红除息造成的净值下跌被误当成了真实亏损。这个项目本身不难,难的是对数据保持敏感。如果你能把净值数据从源头跑下来,还能说清楚每个字段的来龙去脉,后面做策略回测、做理财复盘,就已经赢在起跑线上了。

返回列表