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位数字110022pageIndex页码从1开始1pageSize每页条数页面默认2020startDate起始日期可选2019-01-01endDate结束日期可选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: fhttps://fund.eastmoney.com/{fund_code}.html, } resp requests.get(url, paramsparams, headersheaders, timeout15) data resp.json() print(总条数:, data[Data][TotalCount]) print(当前页条数:, len(data[Data][LSJZList])) print(第一条数据:, data[Data][LSJZList][0])这段代码里requests.get的params参数会自动帮我把字典拼接成URL后面的查询字符串比手动拼URL安全也不用担心特殊字符转义。timeout15是必须加的不然网络异常时请求可能一直挂在那里。跑完代码你应该能在控制台看到类似“总条数: 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: fhttps://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, paramsparams, headersheaders, timeout15) 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, paramsparams, headersheaders, timeout15) js resp.json() if js.get(Data) is None: raise ValueError(fData为空: {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}页第{attempt1}次请求失败: {e}) time.sleep(3) time.sleep(1.5) df pd.DataFrame(all_rows) column_map { FSRQ: 净值日期, DWJZ: 单位净值, LJJZ: 累计净值, JZZZL: 日增长率, } df df.rename(columnscolumn_map) df df[list(column_map.values())] df[净值日期] pd.to_datetime(df[净值日期]) df[单位净值] pd.to_numeric(df[单位净值], errorscoerce) df[累计净值] pd.to_numeric(df[累计净值], errorscoerce) df[日增长率] pd.to_numeric(df[日增长率], errorscoerce) df df.drop_duplicates(subset[净值日期]).sort_values(净值日期).reset_index(dropTrue) df[净值日期] df[净值日期].dt.strftime(%Y-%m-%d) df.to_csv(fund_history.csv, indexFalse, encodingutf-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我直接抛异常而不是继续往后处理这样能及时发现问题。第三errorscoerce的作用是把无法转成数值的内容变成NaN避免因为某条数据异常导致整个脚本崩溃。净值字段在接口里经常是字符串转成float之后才能做计算。第四drop_duplicates(subset[净值日期])用于去重。正常情况下同一只基金同一天只可能有一条净值数据但为了防止翻页边界重复采样加上这一步总是好的。第五导出的CSV用了encodingutf-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保存时用encodingutf-8-sig日期排序不对最新日期在最后接口默认按净值日期倒序返回用sort_values(ascendingTrue)重排净值字段类型不是数值接口直接返回文本类型用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)这样的方法以后写策略框架时直接调用不用每次都复制粘贴脚本。天天基金网除了历史净值还有基金分红、规模变动、基金经理变更等数据接口风格类似学会了这一套迁移到其他数据接口并不难。最后再分享一个我自己的习惯数据落地之后不要急着画图先花几分钟做数据体检用第三节说的反推法把日增长率校验一遍。我因为偷懒跳过这一步曾经把某只分红频率很高的基金净值直接拿来算收益结果曲线看着不错实际收益率跟官方公布的对不上。后来才醒悟是分红除息造成的净值下跌被误当成了真实亏损。这个项目本身不难难的是对数据保持敏感。如果你能把净值数据从源头跑下来还能说清楚每个字段的来龙去脉后面做策略回测、做理财复盘就已经赢在起跑线上了。