在量化交易和数据挖掘这个圈子里,Tushare 大概是国内个人开发者接触最多的一套金融数据接口了。从最开始的攻克积分门槛,到后来每天定时拉取行情、清洗入库,这套流程几乎成了很多人的数据启蒙课。但恰恰是“启蒙”这两个字,让不少人走了一段弯路——很多隐藏规则文档里一句带过,只有自己踩进去才会发现,坑比想象中深得多。今天我把这几年用 Tushare 攒下的问题集中梳理一下,不讲那些官网写清楚的基础用法,只挑最常见的 5 个坑来拆。这 5 个坑分布在数据获取和数据清洗两个环节,每一个我都见过不止一次,也帮人排查过不止一次,相信对正在折腾 Tushare 的朋友会有帮助。
1. 第一个坑:Token 与积分权限,数据还没拿到就先被卡在门外
1.1 Token 获取的正确姿势,别再往代码里硬编码了
很多新手第一次接触 Tushare,最容易卡在 Token 这一步。其实 Token 就是你的身份凭证,注册登录 tushare.pro 官网之后,鼠标放到右上角头像上,下拉菜单里能看到一个“接口TOKEN”入口,点进去就能复制到一串很长的字符串。这个字符串就是调用接口的钥匙,初始化的时候用:
import tushare as ts ts.set_token('你的token字符串') pro = ts.pro_api()这里想说句实在话,我见过不少人在群里直接把 Token 贴在代码里发出来,然后被别人拿去白嫖积分额度。Token 这个东西本质上就是你的账户钥匙,泄露之后别人能调用你的接口权限,额度耗尽你的程序就废了。建议的做法是放到环境变量里,比如在.env文件中配置,然后代码里通过os.getenv('TUSHARE_TOKEN')读取,这样既不会泄露在代码仓库里,换机器也方便。
另外一个容易忽略的问题是,Token 是有积分绑定的。注册之后的基础积分只有 120,而不少接口都有积分门槛,比如财务三大报表接口一般要求 2000 积分以上。所以初始化成功了不代表所有接口都能用,调用之前先去官网文档里查一下目标接口的积分要求,不然会白白踩一脚“抱歉,您没有权限”的报错。
1.2 积分不够,接口再好也白搭
Tushare 的积分体系刚开始看会觉得有点绕,但核心逻辑其实很简单:越核心、越精细的数据,对积分的要求越高。120 积分的基础账号能用日线行情、股票列表、交易日历这些基础接口,但如果想拿财务数据、衍生指标、分钟数据,基本上得往上攒积分。
攒积分的方式官网写得很清楚,无非是完善个人信息、绑定手机号、关注公众号、参与社区活动这些。这套体系确实有点门槛,但换个角度想,它也是 Tushare 能持续提供稳定服务的商业逻辑。我的建议是:动手之前先去官网文档中心把你需要的接口挨个查一遍,确认自己当前积分够不够。如果不够,别硬等积分增长,可以先用手头能拿到的数据把整个流程跑通,等积分够了再补充细节数据。
注意:Token 泄露之后,别人挥霍的是你的调用额度,轻则限流,重则封号。务必像保管密码一样保管 Token。
2. 第二个坑:接口参数的隐藏规则,字符串和日期格式坑到怀疑人生
2.1 trade_date 必须是字符串,这个细节卡住了一半新人
Tushare 的接口参数对类型有严格的要求,最典型的就属trade_date了。官网示例里写的是trade_date='20240101',看起来平平无奇,但如果你图省事直接传一个整数 20240101,接口立刻就报错。类似的情况还有start_date、end_date,全部要求字符串格式,且必须是YYYYMMDD这种八位格式,带横杠的2024-01-01也不行。
这个坑特别隐蔽的地方在于,如果你用 pandas 读入日期列,然后再传给接口,经常会因为类型已经变成了 Timestamp 或 int64 而报错。所以正确操作是,在调接口之前统一做一次字符串转换:
date_str = '20240101' df = pro.daily(trade_date=date_str)如果你是从 DataFrame 里取日期,强烈建议提前用astype(str)转好,不要图一时方便直接塞进去,否则报错之后你还得回头排查是哪个环节把类型改了,纯属浪费时间。
2.2 ts_code 的格式也藏着门道
另一个参数坑是ts_code。Tushare 的股票代码格式是“代码 + 交易所后缀”,例如平安银行是000001.SZ,贵州茅台是600519.SH。上交所的股票以6开头,后缀是.SH;深交所的股票以0或3开头,后缀是.SZ;北交所的股票后缀则是.BJ。
这个后缀一旦写错,比如把上证股票写成.SZ,接口要么返回空数据,要么报错。有些朋友从网上爬到的股票列表里只有纯代码,没有后缀,直接拿去调 Tushare 就会一头雾水。解决办法也很简单,用pro.stock_basic()拉一次全市场股票列表,从里面拿标准的ts_code字段,不要自己拼。
2.3 参数优先级的坑:trade_date 和 ts_code 同时传会怎样
第三个隐藏规则是参数优先级。以pro.daily为例,这个接口允许你按股票代码拉历史行情,也允许你按交易日拉全市场行情。官方文档的规则是,ts_code和trade_date两个参数二选一,如果同时传入,接口会报错。这其实很好理解,按关键字段查还是按时间点扫全市场,这是两种完全不同的查询模式,混在一起语义就会乱。
理解了这一点,你就知道为什么有些人的程序跑着跑着就报“参数错误”了——大概率是循环里既给了ts_code,又在上一次迭代里漏清了trade_date。排查思路也很简单:每次都重新构造参数 dict,不要复用同一个 dict。
3. 第三个坑:循环拉取被限流,效率瓶颈不在网速在调用姿势
3.1 全市场按股票一个个拉,慢到想砸电脑
先说一个我特别常见到的场景:有人要拿全市场几千只股票的日线数据,于是写了一个 for 循环,挨个给pro.daily(ts_code=xxx)发请求。跑起来之后发现速度慢得离谱,几千只股票跑完可能要几个小时,跑着跑着还频繁报错。
这个姿势的问题在于,Tushare 日线接口支持按交易日拉取全市场快照。也就是说,你只需要传入一个trade_date,就能一次拿到当天所有有交易的股票数据,根本不需要一股一股地去查。按交易日循环,一年也就两百多个交易日,比按股票循环几千次要高效太多了。
我整理了一个简单的对比:
| 拉取方式 | 请求次数 | 耗时(估算) |
|---|---|---|
| 按股票循环 5000 只 | 5000 次 | 25~50 分钟+频繁限流 |
| 按交易日循环 250 天 | 250 次 | 2~5 分钟,稳定不报错 |
差距就是这么大。做全市场数据,首选按交易日拉,然后再根据ts_code分组使用。
3.2 限流机制与重试策略,别再闷头撞墙了
Tushare 对接口调用频率是有限制的,具体的频次和你的积分等级挂钩。低积分账号如果短时间内请求太密集,控制台会直接返回“频率限制”之类的错误。这个机制本身是为了保护服务器,但对于不熟悉的人而言,往往表现为“跑着跑着程序突然中断”或者“明明刚才还能调,现在全在报错”。
我的做法是两层防护。第一层是主动降速,每次请求之间加一个time.sleep(0.2~0.3),宁可慢一点也别触发限流。第二层是做好失败重试,捕获异常后先等几秒再重试,连续失败好几次才放弃,并打印出错的日期方便排查:
import time def fetch_with_retry(func, **kwargs): for attempt in range(3): try: return func(**kwargs) except Exception as e: print(f'第 {attempt + 1} 次请求失败: {e}') time.sleep(2) return None3.3 分段缓存,防止拉到一半全部重来
还有一个效率相关的经验是:拉数据一定要做增量缓存。比如你要拉过去五年的日线数据,一次性全拉完当然可以,但只要中途网络抖一下、程序崩一下,之前拉的全白费了。更稳的做法是按年或按季度分段拉取,每一段拉完就落盘成 CSV 或 Parquet 文件,最后再统一读出来合并。这样就算中断,也只需要补跑缺失的那几段,不用从头再来。
4. 第四个坑:复权计算搞错一次,回测结果全得推翻
4.1 不复权、前复权、后复权,到底该用哪个
Tushare 的pro.daily接口返回的是不复权价格,这一点很多人在一开始根本没注意到。不复权价格有个很显著的特征:遇到股票除权除息的日子,价格会突然向下跳空,但成交量、市值这些其实没变。如果直接用不复权价格计算收益率,回测结果会惨不忍睹,尤其遇到分红送股比较多的股票,历史收益会被严重扭曲。
复权本质上就是把除权除息造成的价格断层修补起来,让你看到一条连续的、反映真实涨跌的曲线。前复权是以当前价格为基准,把历史价格向下调整;后复权是以历史价格为基准,把当前价格向上调整。两者的最终计算结果是等价的,但有个重要的区别:前复权价格会随着时间推移、最新价的变化而不断变化,也就是说同一段历史数据,你上个月下载的前复权价和这个月下载的前复权价可能是不同的。所以在回测中我更推荐用后复权价格,因为它确定性强,历史数据一旦算出就是固定的,不会因为今天股价涨了就被整体重写。
4.2 用复权因子计算前复权和后复权价格
新版 Tushare Pro 接口里,pro.daily是没有adj参数可以直接返回复权价的。正确做法是再调一次pro.adj_factor拿到复权因子,然后自己计算。复权因子的计算逻辑是:
- 后复权价格 = 不复权收盘价 × 当日复权因子
- 前复权价格 = 不复权收盘价 × 当日的复权因子 / 最新交易日的复权因子
代码如下:
import tushare as ts import pandas as pd pro = ts.pro_api() # 1. 拉取不复权行情 df = pro.daily(ts_code='000001.SZ', start_date='20230101', end_date='20240101') # 2. 拉取复权因子 adj = pro.adj_factor(ts_code='000001.SZ', start_date='20230101', end_date='20240101') # 3. 合并 df = df.merge(adj[['ts_code', 'trade_date', 'adj_factor']], on=['ts_code', 'trade_date']) df = df.sort_values('trade_date').reset_index(drop=True) # 4. 计算复权价 latest_factor = df['adj_factor'].iloc[-1] df['close_qfq'] = df['close'] * df['adj_factor'] / latest_factor # 前复权 df['close_hfq'] = df['close'] * df['adj_factor'] # 后复权这段代码交付出去之后,我经常会补一句提醒:做因子研究时,涉及价格的一定要检查一下复权口径。我遇到过一个把前复权当后复权存库的案例,结果某只股票在分红之后的历史收益曲线完全对不上,排查了很久才发现是复权因子除以最新因子这一步多算了一次。这种错误一旦发生,回测结果就是错的,再好看的策略曲线也是空中楼阁。
注意:如果只需要做收益率计算,还有一种更不容易出错的思路——直接用 Tushare 返回的
pct_chg字段。但需要注意这个字段同样基于不复权价格计算,除权日那天的涨跌幅会异常。稳妥起见,还是用复权后的收盘价自己算一遍收益率。
5. 第五个坑:数据清洗的隐形陷阱,trade_date 居然变成了浮点数
5.1 读取 CSV 后日期列变成浮点数或者整数
数据拉取下来之后,很多人习惯直接to_csv保存,等下次要用的时候再read_csv读回来。这个操作看起来人畜无害,其实埋了一个大雷:如果你没有显式指定 dtype,pandas 在读 CSV 的时候会把trade_date这种全是数字的列自动解析成 int64,而一旦这一列里有空值,整个列就会在读取时变成 float64。最终你看到的trade_date不是20240101,而是20240101.0。
这个坑特别隐蔽,原因是程序不会报错,但后续所有基于日期的字符串操作、拼接、比较都会出问题,甚至你根本察觉不到。解决办法有两个,任选其一:
# 方法一:读入时指定列类型 df = pd.read_csv('daily.csv', dtype={'trade_date': str}) # 方法二:读入后统一转换 df['trade_date'] = df['trade_date'].astype('Int64').astype(str).str.replace('.0', '')更稳健的做法是不要用 CSV 存中间结果,改用 Parquet 格式,这样可以在存储时把字段类型固定好,避免反复转换带来的数据隐患。刚开始可能不习惯,但用一次就回不去了。
5.2 数据去重与排序,一次到位别拖泥带水
Tushare 接口在正常情况下返回的数据不会重复,但在自己拼接数据的时候很容易引入重复行,尤其是按交易日拉全市场,然后多个日期数据做 concat,如果中途补跑了一段、重复拉了某一区间,最后拼出来的 DataFrame 就会有重复记录。
这种重复很难用肉眼发现,但一旦进入计算,最后统计出来一个离谱的数字,你又得回头查数据质量。所以合并之后立刻做一步去重和排序,是成本最低的保险:
df = df.sort_values(['ts_code', 'trade_date'], ascending=[True, True]) df = df.drop_duplicates(subset=['ts_code', 'trade_date'], keep='last') df = df.reset_index(drop=True)排序和去重的顺序有讲究:先排序再去重,可以保证keep='last'留下的是时间更靠后的记录。如果你先去了重再排序,谁会被留下就完全随机了。
5.3 缺失值的处理,不是所有 NaN 都该用 0 填充
最后一个清洗层面的坑是关于缺失值的。Tushare 返回的数据里出现 NaN 是很正常的,原因各不相同。比如某只股票当天停牌,按交易日拉全市场时就可能没有这一天的数据;又比如某些次新股上市较晚,在上市之前当然没有行情记录。
有些人图省事,拿到数据之后直接fillna(0),这种做法非常危险。价格列是 0 的话,计算收益率时会直接出现 -100% 的荒谬数值;成交量是 0 的话,一些流动性指标也会被污染。正确的思路是:区分数据是“真的没有”还是“应该是 0”。价格、市值、成交额这些字段出现 NaN,大部分情况都应该用前值填充或者剔除该行;涨跌幅、成交量这类字段如果是停牌导致的,填充为 0 是可以接受的,但需要明确记录这一步。
另外,如果想做面板数据,经常需要把宽表转成长表或者反过来。这种转换过程中 pandas 的pivot会自动产生 NaN,这时候的 NaN 代表“该股票在该时间点没有交易数据”,处理方式和上面类似,先想清楚业务含义再动手。
6. 可直接抄作业的完整代码框架
前面把坑拆开讲了,这一节给出一套完整的、可以直接拿来改改就用的流程,把获取、清洗、保存串联起来。注意替换成自己的 token,并根据实际需要调整起止日期。
import tushare as ts import pandas as pd import time import os # ---------- 初始化 ---------- ts.set_token(os.getenv('TUSHARE_TOKEN')) pro = ts.pro_api() # ---------- 获取交易日历 ---------- cal = pro.trade_cal(exchange='SSE', start_date='20240101', end_date='20240201', is_open='1') trade_dates = cal['cal_date'].tolist() print(f'共 {len(trade_dates)} 个交易日') # ---------- 按交易日拉取全市场日线行情 ---------- all_daily = [] for date in trade_dates: try: df = pro.daily(trade_date=date) if df is not None and not df.empty: all_daily.append(df) time.sleep(0.3) # 主动降速,避免触发限流 except Exception as e: print(f'{date} 拉取失败: {e}') time.sleep(2) # ---------- 合并与清洗 ---------- result = pd.concat(all_daily, ignore_index=True) result['trade_date'] = result['trade_date'].astype(str) result = result.sort_values(['ts_code', 'trade_date'], ascending=[True, True]) result = result.drop_duplicates(subset=['ts_code', 'trade_date'], keep='last') result = result.reset_index(drop=True) # ---------- 计算后复权价(如果需要) ---------- # 说明:全市场批量计算复权时,建议先按 ts_code 分组再逐组调用 adj_factor # 下面的代码是单只股票的示例,批量场景请在此基础上用 groupby 封装 # ---------- 保存 ---------- result.to_csv('daily_data.csv', index=False, encoding='utf-8-sig') print(f'清洗完成,共 {len(result)} 条记录')这套代码里每行都有它的作用,不是花架子。比如time.sleep(0.3)就是专门用来对抗限流机制的;先排序再去重是为了确保留下后面那条数据;encoding='utf-8-sig'是为了在 Excel 里打开 CSV 不乱码。这些细节单拎出来看都不起眼,但结合起来就是一套非常耐用的数据拉取管道。
7. 常见问题速查表(附排查思路)
最后把上面提到的坑整理成一张速查表,方便以后遇到问题直接对号入座。
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 调用接口报“抱歉,您没有权限” | 当前积分不满足接口要求 | 先去官网文档查目标接口的积分门槛,再确认自己的积分 |
| 报错提示参数类型错误 | trade_date传了 int 或 Timestamp | 统一用'YYYYMMDD'格式字符串,用astype(str)提前转换 |
| 循环拉取全市场数据太慢 | 按股票循环,请求次数爆炸 | 改成按交易日循环,一次拿当天全市场数据 |
| 跑着跑着大量请求失败 | 触发每分钟调用频率限制 | 每次请求之间加time.sleep(0.2~0.3),并做好重试机制 |
| 收益率计算结果异常,出现剧烈跳变 | 除权日数据未复权 | 用adj_factor计算前复权或后复权价格后再计算收益率 |
读回 CSV 后trade_date变成20240101.0 | 列类型被 pandas 推断为 float | 读取时指定dtype={'trade_date': str},或改用 Parquet 格式存储 |
| 数据量对不上,疑似有重复 | concat 时区间重叠或重复拉取 | 用drop_duplicates(subset=['ts_code', 'trade_date'])去重 |
| 某只股票在某天没有记录 | 停牌或未上市 | 按业务含义决定是前向填充还是剔除,不要所有 NaN 一刀切填充为 0 |
这张表不是给你背的,而是建议你收藏起来,等哪天程序出问题的时候翻一翻。数据获取和数据清洗这个领域,很多问题不是逻辑多复杂,而是细节太多,一步没注意就会绕远路。我这几年用 Tushare 攒下来的经验,说到底也就一句话:在做任何计算之前,先确认数据是怎么来的、经过了哪些转换、类型是什么、有没有重复和缺失。数据管道建得越稳,后面做分析和建模就越省心。
另外多说一句,Tushare 的功能不止日线行情这一块,像资金流向、龙虎榜、财务指标这些接口都是好东西,等基础流程跑通之后可以慢慢往上加。数据源本身只是一个起点,怎么把数据变成可靠、干净的分析素材,才是真正需要花时间打磨的核心能力。