1. 从"爬虫抓行情"到"接口拿数据":先把路走对
1.1 为什么爬虫方案天然有天花板
很多人拿到"爬取股票历史数据"这个需求,第一反应就是写个爬虫去抓行情网站。这个思路不能说错,但实际跑起来会发现自己陷入一场无休止的猫鼠游戏:先是对面加了请求频率限制,你需要伪造请求头、维护代理池;接着你会发现列表页和详情页的数据对不上,复权价格永远算不准;最难受的是涨停历史数据这种高价值字段,很多网站要么不提供,要么渲染在JS里,爬下来还要自己做解析清洗。
我见过不止一个人,光在数据采集上就耗了两三周,最后拿到的表还是缺胳膊少腿的。所以对于"股票历史数据"这种强结构化、强时效性、高一致性要求的数据,最理性的方案不是爬虫,而是直接走数据接口。这也是为什么国内做量化的人,无论个人还是团队,最终都会回到Tushare这条路线上来。
1.2 三种主流取数方案对比
为了让你更清楚为什么选Tushare,我把市面上常见的三种取数方式放在一起做了个对比,大家可以根据自己的实际场景对号入座。
| 方案 | 数据完整性 | 维护成本 | 复权支持 | 限速与权限 | 适合场景 |
|---|---|---|---|---|---|
| 自写爬虫抓行情网站 | 字段碎片化,全市场抓取困难 | 高,页面改版就要重写 | 基本不支持,需自己算 | 容易封IP | 爬取特定页面的临时任务 |
| 部分互联网财经免费接口 | 有基础日线,但字段有限 | 中,接口经常悄悄变更 | 有限支持 | 请求次数限制严格 | 个人简单看盘 |
| Tushare Pro接口 | 全市场日线、复权因子、财务、涨停等完整字段 | 低,接口稳定文档全 | 内置复权因子,计算方便 | 积分制,高积分高权限 | 量化回测、数据研究、模型训练 |
从我自己的经验看,Tushare最核心的价值不是"多一个数据源",而是把最脏最累的数据规整工作替你做了。比如复权因子、交易日历、上市状态这些,都是标准化后直接可用的,不需要你再去各家公告里拼凑。
1.3 这条路适合谁、不适合谁
如果你是做量化策略回测、机器学习模型训练,或者想搭一套个人行情数据库,Tushare是选择。但如果你要的是毫秒级实时行情,或者高频tick级数据,Tushare并不是那一类方案,它更适合日线级别的历史数据研究,而不是极速交易链路。
所以这篇文章讨论的"爬取",本质是"通过Tushare接口批量、增量地拉取历史行情并落地存储"。
2. 注册、积分与token:真正动手前的三道门槛
2.1 注册后先搞清楚积分规则
Tushare的数据不是注册就能拿全量,它有一套积分权限体系。新注册用户会获得一定的基础积分,基础积分可以调用一部分低频、低数据量的接口,比如日线行情、股票列表等。但如果你要拉全市场历史数据、财务数据、或者高频调用,就需要积分达到一定阈值。
我当时第一次用的时候犯了个错误:代码写好,直接跑,结果返回抱歉,您没有访问该接口的权限。一查才知道是积分不够。所以动手前务必去官网查看最新的积分说明和对应接口权限,避免白写一晚上代码。
为了保险起见,你在注册后应该先检查两件事:一是账号当前积分,二是目标接口的最低积分要求。如果积分不够,也先别慌,Tushare有积分提升机制,通过完善个人信息、社区贡献、邀请等都可以提升。
2.2 token的正确保管方式
注册后你会拿到一个token字符串,这是所有接口调用的凭证。很多教程会告诉你直接把它贴在代码里,我强烈不建议这么做。一旦你的代码被传到公开仓库,token泄露就意味着别人可以冒用你的账号额度。
我在实际项目中习惯用环境变量或者独立配置文件来管理:
# Linux / mac 环境变量写法 export TUSHARE_TOKEN="你的token" # Windows PowerShell setx TUSHARE_TOKEN "你的token"Python代码里直接读取,避免明文写在脚本中:
import os import tushare as ts # 从环境变量中读取token token = os.getenv("TUSHARE_TOKEN") if not token: raise ValueError("请先设置 TUSHARE_TOKEN 环境变量") ts.set_token(token)2.3 最小可用Demo:拉取一只股票近一年的日线
环境准备完毕,先用最小代码验证整条链路是否通畅。注意,需要先安装一下tushare库:
pip install tushare pandas然后写一个最小调用:
import tushare as ts import pandas as pd # 初始化接口 pro = ts.pro_api() # 拉取某只股票最近一年的日线 df = pro.daily( ts_code='000001.SZ', start_date='20240101', end_date='20241231' ) print(df.head()) print(df.shape)如果你是从零开始的初学者,可能对ts_code的格式不太熟悉。它由股票代码加交易所后缀组成,000001.SZ表示深交所的平安银行,600000.SH表示上交所的浦发银行。后缀其实很容易记,SH是上海,SZ是深圳,BJ是北交所。
跑通这个demo之后,你会发现Tushare返回的dataframe非常规整,字段齐全,连中文列名都不需要你自己映射。但别高兴太早,后面才是真正考验人的地方。
3. 核心接口与复权机制:想要数据不出错,先搞懂这几点
3.1 为什么不直接调daily,而是用pro_bar
很多人在Tushare上拿到的第一份数据是pro.daily(),它返回的是未复权的原始价格。但做回测的时候,如果直接用未复权价格计算收益率,遇到分红送股的日子计算就会严重失真。
比如某只股票10派5元,除息日当天价格会"凭空"低开,你的策略可能会被误判为暴跌信号。所以真正专业的做法是使用复权价格。Tushare里最省心的接口是ts.pro_bar(),它已经帮你把复权逻辑封装好了,只需要通过adj参数指定复权方式:
import tushare as ts # 拉取前复权数据 df_qfq = ts.pro_bar( ts_code='000001.SZ', adj='qfq', # 前复权 start_date='20240101', end_date='20241231' ) # 拉取后复权数据 df_hfq = ts.pro_bar( ts_code='000001.SZ', adj='hfq', # 后复权 start_date='20240101', end_date='20241231' )初次接触复权概念的读者,可以这样理解:前复权是保持最新价格不变,把历史价格按分红送股比例"调低";后复权是保持最早价格不变,把后续价格"调高"。两者趋势一致,但数值量级不同。日常回测中前复权用得最多,因为它能保证最近的价格是真实价格,方便和实时行情对照。
3.2 复权因子adj_factor到底要怎么用
Tushare的adj_factor接口返回每日复权因子,很多文档没有讲清楚这个因子和复权价之间的关系。实际上,daily接口的原始收盘价乘以当日的adj_factor,再除以一个基准日的adj_factor,就能算出任意口径的复权价,公式大致是:
前复权价 = 原始价 × (当日adj_factor / 最新交易日adj_factor) 后复权价 = 原始价 × 当日adj_factor所以如果你想把所有股票的复权因子统一管理,完全可以一次性拉取全市场的adj_factor存到本地,后续通过因子自行计算复权价,而不必每次重复请求复权行情。这样既能减少接口调用次数,也能让本地数据和Tushare新接口保持解耦。
我当时做某量化回测项目时,就选择只存原始价格和复权因子两张表,需要复权数据时在本地算好,整个过程很快,而且逻辑透明,排查问题也方便。
3.3 返回字段里面那些容易误读的量纲
Tushare的日线字段基本是以下这些:
| 字段名 | 含义 | 量纲陷阱 |
|---|---|---|
| ts_code | 股票代码 | 注意带上交易所后缀 |
| trade_date | 交易日期 | 字符串格式YYYYMMDD,不是日期对象 |
| open / high / low / close | 开高低收 | 单位是元,未复权时为原始价 |
| pre_close | 昨日收盘价 | 用于计算涨跌幅 |
| change | 涨跌额 | 元 |
| pct_chg | 涨跌幅 | 百分比,注意是百分数不是小数 |
| vol | 成交量 | 单位是"手",1手=100股 |
| amount | 成交额 | 单位是"千元",不是元 |
这里最坑的地方是vol和amount。你要是直接用vol去乘收盘价算成交额,会发现数量级对不上,原因就是"手"和"千元"这两个量纲。我第一次用的时候,校验成交额算出来比真实值小了100倍,找了半天才发现是这个原因。建议入库前统一换算成股和元,后续计算会省心很多。
4. 批量抓全市场历史数据:限速条件下也有优雅方案
4.1 全量任务拆解思路
真实项目中几乎不会只抓一只股票,往往需求是"把A股全市场的历史日线都拉下来,越快越好"。这时你不能简单地写一个for循环遍历所有股票代码然后逐个请求,因为Tushare对单次调用频率有严格限制,积分不高的话,每秒只能请求几次,跑几千只股票耗时很长。
我的做法是把任务拆成两个维度:一个是时间维度,一个是股票维度。
时间维度上,Tushare的daily接口单次最多能返回多少条数据取决于你的积分,但即便没有明确条数上限,单次全市场返回的数据量也非常大,不利于断点续跑。所以我会按年切分时间段,比如2019年、2020年、2021年这样分开请求,每次只拉一个区间。
股票维度上,则把全市场股票列表按一定数量分组,例如每50只一组,组内串行请求,组间可以适当并发,但别忘了限速控制。
4.2 限速控制与重试机制
Tushare的限制通常体现为返回错误码,比如"每分钟访问次数超过限制"之类的提示。你在代码里要捕获这类异常,做一个退避重试。下面是一个简单版的限速重试封装:
import time import tushare as ts pro = ts.pro_api() def fetch_daily_with_retry(ts_code, start_date, end_date, max_retry=5): """带重试的日线获取函数""" for attempt in range(max_retry): try: df = pro.daily(ts_code=ts_code, start_date=start_date, end_date=end_date) return df except Exception as e: wait_seconds = 2 ** attempt # 2的幂次退避,1s、2s、4s... print(f"请求 {ts_code} 失败:{e},{wait_seconds}秒后重试") time.sleep(wait_seconds) return None退避重试的核心思想是:第一次失败等1秒,第二次失败等2秒,第三次等4秒,逐步拉开间隔,给服务器喘息的机会。这种策略比固定等待更高效,因为并不是每次失败都需要等同样长的时间。
4.3 断点续跑:给任务做上进度标记
一次性拉完全市场历史数据可能要跑几十分钟甚至几个小时。一旦网络波动导致脚本中途崩溃,你就前功尽弃了。所以写批量脚本时,最好做一个简单的进度记录文件,每成功处理一只股票就记录一行,下次启动时跳过已完成的部分。
我当时的一个模拟项目里就是这么做的:
import os finished_file = "finished_stocks.txt" def load_finished(): """加载已完成的股票列表""" if not os.path.exists(finished_file): return set() with open(finished_file, "r") as f: return set(line.strip() for line in f if line.strip()) def mark_finished(ts_code): """标记一只股票已完成""" with open(finished_file, "a") as f: f.write(ts_code + "\n") finished = load_finished() for stock in all_stock_list: if stock in finished: continue df = fetch_daily_with_retry(stock, start_date, end_date) if df is not None and not df.empty: save_to_local(stock, df) mark_finished(stock)这个方案的优点是即使中断,重跑时只会处理未完成的股票,不会重复请求已经成功的部分,既节省时间又降低被封风险。
5. 数据本地化:SQLite增量更新带来的持久收益
5.1 为什么我最终放弃了CSV
刚开始做数据落地时,图省事,按照"一只股票一个CSV"的方式存储。滞后发现这种方案非常痛苦:更新数据时,需要先读取整个CSV,再判断最新日期,再决定追加还是覆盖;要跨股票做筛选统计时,得把所有CSV遍历一遍,效率极其低下。
后来我改用SQLite,一套单文件数据库就把所有股票日线装进来,查询、更新、去重都变成了标准SQL操作。SQLite对个人项目来说足够稳定,不需要额外起服务,还支持事务,配合Python自带的sqlite3库就能直接用。
5.2 表结构设计与主键去重
建表时最核心的是主键设计。日线数据的天然唯一键是(ts_code, trade_date),只要保证同一只股票同一交易日只有一条记录,就不会出现重复。建表语句如下:
CREATE TABLE IF NOT EXISTS daily_data ( ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, open REAL, high REAL, low REAL, close REAL, pre_close REAL, change REAL, pct_chg REAL, vol REAL, amount REAL, PRIMARY KEY (ts_code, trade_date) ); CREATE INDEX IF NOT EXISTS idx_trade_date ON daily_data (trade_date);注意一点:trade_date我直接用TEXT存储,因为Tushare返回的日期就是格式化的字符串。如果你在Python里转成date对象,写库时还得做转换,容易出错。保持一致用字符串,查询时也方便排序。
写入数据时,使用"先查存在再插入"的逻辑,或者直接使用INSERT OR REPLACE,让SQLite帮你去重:
import sqlite3 def upsert_daily(conn, df): """将Tushare日线数据写入SQLite,重复数据自动覆盖""" rows = df[['ts_code', 'trade_date', 'open', 'high', 'low', 'close', 'pre_close', 'change', 'pct_chg', 'vol', 'amount']].values.tolist() sql = """ INSERT OR REPLACE INTO daily_data (ts_code, trade_date, open, high, low, close, pre_close, change, pct_chg, vol, amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ conn.executemany(sql, rows) conn.commit()INSERT OR REPLACE的好处是简单粗暴,只要有重复记录就直接覆盖,非常适合从Tushare拉取完整日线后,直接全量同步到本地表。这种用法依赖主键约束,所以前面的建表语句一定不能省主键。
5.3 增量更新策略:只拉本地缺失的那段
数据库搭好之后,更新策略就非常清晰了:每次更新前先查一下每只股票在本地已有的最大交易日期,然后从那一天的下一个交易日开始拉新数据。
def get_max_trade_date(conn, ts_code): """查询某只股票本地最大已经存在的交易日""" sql = "SELECT MAX(trade_date) FROM daily_data WHERE ts_code = ?" cur = conn.execute(sql, (ts_code,)) row = cur.fetchone() return row[0] if row and row[0] else None如果本地没有这只股票的数据,这个函数返回None,那就从历史最早日期开始全量拉取。如果有,就把start_date设为max_date加1天,利用Tushare的exchange规则,只请求本地缺失的部分。
增量更新还有个隐藏好处:每天跑定时任务时,接口调用量大大减少。全市场刷新一次可能只需要几分钟,对积分有限的用户来说非常友好。
6. 数据质量问题与排查链路:文档里没有明说的坑
6.1 停牌日期缺口超出你预期
A股股票经常因为各种原因停牌,停牌期间没有交易数据,接口也不会返回任何记录。这意味着你在做收益统计时,不能简单地把日期缺口的复权价当作上一交易日的延续,而应该做对应的填充或剔除处理。
我当时处理某只长期停牌股票时,发现连续60天没有数据,但复牌后第一天最高涨幅达到系统限制,直接把我的动量策略信号扭曲了。后来我一律以trade_cal交易日历为准,将所有股票的日期对齐到完整日历上,缺口数据用前值填充,这种处理才让回测结果稳定下来。
6.2 财务数据对齐是另一层地狱
如果你不只做行情,还要结合财务指标选股,比如市盈率、市净率、ROE,那就要小心了。Tushare的财务数据发布时间和报告期往往不一致,直接按报告期匹配会碰到未来函数问题。
最稳妥的方式是使用Tushare的income、balancesheet接口配合end_date和ann_date字段,其中ann_date是公告日期,用它去匹配交易日,才能保证在回测当天真的能拿到这份财报。由于这是个很容易出错的地方,我特别强调:永远用公告日期,而不是报告期。
6.3 指标字段突然为NaN的排查思路
项目中最常遇到的诡异问题就是某个字段某一天突然变成NaN,代码运行时也不报错,但最后统计结果就是不对劲。遇到这种情况,别急着改业务逻辑,先做以下三步排查:
- 打印出NaN出现的那一行,对比前后交易日的字段,看是不是除权除息导致数据本身缺失。
- 调用接口单独查询该日的原始数据,确认Tushare源头是否就没有返回该字段。
- 检查自己的清洗代码里是否有过度的空值替换,比如将0写成了NaN,或反向操作。
我遇到过最隐蔽的一次,是某一行的amount字段被接口返回为0,我以为是正常数值,后来发现是当时的公司没有成交记录。这类数据需要结合成交量字段综合判断,单独看很难察觉。
7. 进阶扩展:从日线到周月线、指数和涨停数据
7.1 周线与月线怎么拿
Tushare提供weekly和monthly接口,字段逻辑和daily基本一致。如果你做中期策略,直接在接口层面把周期切好,回测速度会快不少。
我记得一个简单用法:
# 周线数据 df_week = ts.pro_bar(ts_code='000001.SZ', freq='W', start_date='20240101', end_date='20241231') # 月线数据 df_month = ts.pro_bar(ts_code='000001.SZ', freq='M', start_date='20240101', end_date='20241231')这里有件事要提醒:不同周期的数据是Tushare服务端聚合好的,它已经帮你处理了"每周最后一个交易日"这种逻辑,不会因为复权基准不同而出现偏差,所以优先使用接口而不是自己去daily表里重采样。
7.2 涨停历史数据脚本
前面网络热词里提到的"股票涨停历史数据",Tushare中也提供了相应字段。通过daily的pct_chg和close逻辑自行筛选,或者直接使用涨停相关的市场统计接口。我自己做短线策略时,通常用涨停股数量、连板高度这些统计特征作为情绪指标。
可以基于本地SQLite快速统计某一段时间的涨停股票数量:
SELECT trade_date, COUNT(*) AS limit_up_count FROM daily_data WHERE pct_chg >= 9.5 AND close = high GROUP BY trade_date ORDER BY trade_date;需要注意A股不同板块的涨停幅度不同,主板是10%,创业板和科创板是20%,ST股票是5%。所以严谨的做法是先拿到每只股票的板块属性,再按板块设置不同的涨停阈值,不能用统一的9.5这个阈值去套所有股票。
7.3 与本地投资框架联动的个人体会
数据入库只是第一步。我目前经常用的模式是:每天收盘后跑一遍更新脚本,把新数据写入SQLite,然后直接在本地做因子计算、可视化、策略信号生成。整个过程不依赖任何外部服务,全部由Tushare加SQLite组成。
有一次我临时要做一个跨板块的历史回测,因为本地库里已经包含了所有股票、复权因子和交易日历,直接用SQL聚合,不到两分钟就完成了数据准备工作。如果没有前期的本地化沉淀,这肯定又是一次漫长的拉数过程。
从数据获取到数据清洗再到数据落地,Tushare给我的最大感受是:把宝贵时间留给策略研究和模型验证,而不是每天趴在网页解析器上跟动态渲染做斗争。你在搭建自己的历史数据体系时,越早看清这个方向,后面走的弯路就越少。