news 2026/10/24 10:41:48

拆解tushare源码:从请求链路到重试复权与数据落盘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
拆解tushare源码:从请求链路到重试复权与数据落盘

简介:这份 tushare 最新源代码面向具备一定 Python 基础、希望深入理解财经数据接口实现的金融数据分析学习者,可用于研究行情数据获取、存储与预处理的完整链路。压缩包共 44 个文件,以 25 个 py 源码为主体,辅以 7 个 rst 文档及 txt、md、yml 等配置与说明文件,整体约 269KB,目录覆盖接口调用、测试脚本和文档模块。已有 1494 人学习,说明其对入门与进阶读者均有参考价值。源码中可重点研读 HTTP 请求封装、数据解析与异常处理,SQLite、MongoDB 等存储操作,以及缺失值填充、时间序列处理、matplotlib/seaborn 可视化和多线程、异步 IO 调度等实现,从而借鉴数据获取模块设计、理解并发控制与日志记录思路,提升金融数据项目的开发与排错能力。

1. 拿到 tushare 最新源代码,先看的是两百行的 client.py

第一次拆 tushare 最新源代码的时候,我没急着跑示例,而是先打开pro/client.py。整份源码里最能说明这个项目性质的就是它:一个两三百行的文件,把 token 怎么存、请求往哪发、返回怎么变成 DataFrame,全交代清楚了。tushare 本身是 Python 的金融数据接口包,把 A 股行情、财务、指数、基金、期货、宏观这些数据统一收成「一个函数一次调用、直接返回 DataFrame」的形态。它适合三类人:做量化回测需要干净历史数据的、搭内部数据管道要稳定增量更新的、以及想拿真实行情练手又不想碰网页解析的。读它的价值不在于会调pro.daily(),而在于你能看到一个接口层项目怎么把几百个数据接口压进一套统一的请求骨架里。

2. 包结构拆解:从 set_token 到 DataFrame 的一条完整链路

tushare 的源码不大,但分层非常清楚。真正值得逐行看的是三个地方:client.py负责网络与鉴权,data_pro.py铺开所有业务接口,util/upass.py管 token 的落盘。其余文件基本是常量和格式化工具。把这三块读懂,整份源码就没什么黑匣子了。

2.1 目录骨架:三层分工,别找错文件

先看结构。不同版本的文件名会有些出入,但职责划分是一致的:

tushare/ ├── __init__.py # 对外只暴露 set_token / pro_api / pro_bar 等少数入口 ├── pro/ │ ├── __init__.py │ ├── client.py # 网络层:拼 payload、发 POST、异常码判断 │ ├── data_pro.py # 接口层:daily / stock_basic / income 等几百个薄函数 │ ├── cons.py # 常量层:接口名、字段列表、权限要求 │ └── data_api.py # 早期版本的动态分发实现,新版多为显式函数 └── util/ ├── upass.py # token 的读写 └── formatter.py # ts_code、日期这类参数的正规化

这个分层的关键在于:接口层不碰网络,网络层不认识业务。data_pro.py里的daily()只是把参数原样递给client.query(),自己不做任何校验;client.query()也不知道daily是什么业务,它只认api_name这个字符串。好处是加接口的成本极低——服务端上线一个新接口,客户端只需要补一个同名函数,网络层一行都不用动。代价是所有参数校验的责任被推给了调用方,这一点后面会反复提到。

提示:如果你只想搞清楚某个字段是什么意思,去cons.py里搜接口名,比翻文档快;想搞清楚某个接口为什么报错,去client.py看异常是怎么抛的。

2.2 token 存取:一个隐藏文件撑起整套鉴权

tushare 的鉴权模型很朴素:一个字符串 token,跟着每次请求发过去。源码里负责这件事的是util/upass.py,逻辑大致如下:

# tushare/util/upass.py 的等价写法 import os TOKEN_FILE = os.path.join(os.path.expanduser('~'), '.tushare.csv') def set_token(token: str) -> None: """把 token 落到用户目录,之后所有进程共用""" with open(TOKEN_FILE, 'w', encoding='utf-8') as f: f.write(token.strip()) def get_token() -> str: """读不到就返回空串,由上层决定抛什么错""" if not os.path.exists(TOKEN_FILE): return '' with open(TOKEN_FILE, 'r', encoding='utf-8') as f: return f.read().strip()

逻辑说明:set_token()写文件、get_token()读文件,中间没有加密也没有缓存失效机制。~/.tushare.csv这个名字带 csv 后缀但其实只是一行纯文本,文件名是历史遗留,不用纠结。参数上唯一的坑是os.path.expanduser('~')——它依赖HOME环境变量。

这意味着三件事。第一,在 Docker 容器里跑,HOME可能是/root也可能是空,token 文件写进去下次重建就没了。第二,CI 环境里没有交互式终端,你不能指望人工执行一次set_token。第三,同一台机器上多个项目共用一个 token 文件,想切账号就得手动改。

我一般的做法是不依赖落盘文件,而是在程序启动时从环境变量注入:

import os import tushare as ts def init_api(): token = os.environ.get('TS_TOKEN', '') if not token: raise RuntimeError('缺少 TS_TOKEN 环境变量,拒绝启动') # 显式设置,不依赖 ~/.tushare.csv 是否存在 ts.set_token(token) return ts.pro_api()

把 token 从「文件状态」变成「启动参数」,容器化、CI、多账号三个问题一次解决。这也是读这份源码最容易拿到的一个改造点。

2.3 请求发出与响应组装:三段式 payload

client.py里的query()是整个包的心脏,剥掉注释大概长这样:

# tushare/pro/client.py 核心(简化) import requests import pandas as pd class Client: def __init__(self): self.__token = '' self.__http_url = 'http://api.tushare.pro' # 服务端入口,源码里的常量 self.__timeout = 30 def query(self, api_name, fields='', **kwargs): payload = { 'api_name': api_name, # 接口名:daily / stock_basic / income ... 'token': self.__token or get_token(), 'params': kwargs, # 业务参数,原样透传,不做清洗 'fields': fields, # 逗号分隔的字段串,空串表示走服务端默认 } resp = requests.post(self.__http_url, json=payload, timeout=self.__timeout) result = resp.json() if result.get('code') != 0: # 服务端把业务错误也放在 200 响应里,只能靠 code 判断 raise Exception(result.get('msg')) data = result['data'] return pd.DataFrame(data['items'], columns=data['fields'])

逐段拆。payload是固定的四个键,多一个少一个都不行:api_name决定调哪个接口,token决定你有没有权限,params是业务参数原样塞进去,fields是字段裁剪。注意params这一层完全没有校验——你传start_date='2024-01-01'(带横杠)还是'20240101',它都照发,服务端认不认是另一回事。

响应体是{code, msg, data}三段。code != 0时msg里是人话错误信息,但 HTTP 状态码依然是 200,所以不能用resp.raise_for_status()判错,必须看code。data里fields是列名数组、items是二维数组,两者按位置对应——这也是为什么最终能用pd.DataFrame(items, columns=fields)一行搞定。这种「列存 + 行存分离」的传输格式在行情类接口里很常见,因为一次返回几千行、每行十几个字段时,它比对象数组省一半以上的体积。

timeout=30是个容易被忽略的点:源码里给了超时,但没有重试。网络抖一下就抛异常,循环跑到一半断掉,前面拉的几百个交易日全白费。这个问题我在第 3 章单独处理。

3. 核心实现细读:重试包装、接口模板与复权分支

这一章讲三个真正需要动手改的地方。client.py缺重试,data_pro.py缺校验,pro_bar的复权逻辑跟大多数人想的不一样。这三处搞明白,你就能把这份源码变成自己能用的东西。

3.1 给 query 补一层重试:区分可重试与不可重试

源码里的query()是一次性买卖,失败就抛。生产环境下你必须区分两类错误:限频、超时这类是「等一会儿再来」,参数错误、权限不足这类是「再来一万次也一样」。混在一起重试只会把配额烧光。

import time def query_with_retry(client, api_name, retries=3, backoff=2.0, **kwargs): """在 Client.query 外面套一层有差别的重试""" last_err = None for i in range(retries): try: return client.query(api_name, **kwargs) except Exception as e: last_err = e msg = str(e) # 限频:固定退避,指数增长 if '每分钟' in msg or '频率' in msg: time.sleep(backoff ** i) continue # 网络层超时,同样值得重试 if 'timeout' in msg.lower() or 'connection' in msg.lower(): time.sleep(backoff ** i) continue # 其余(权限、参数)直接抛,重试没意义 raise raise last_err

逻辑说明:retries控制总次数,backoff是退避基数,第 i 次等待backoff ** i秒——三次重试分别等 1 秒、2 秒、4 秒(backoff=2.0)。判断依据是错误信息里的关键词,这不算优雅但足够用,因为服务端的msg文案相对固定。真正不可重试的是权限类错误(返回信息里通常带「积分」或「权限」字样),这类错误重试只会让你在日志里刷屏,看不出真正原因。

参数上有个细节:**kwargs必须原样透传给client.query(),因为你不知道调用方会传ts_code还是trade_date。这也是接口层薄的好处——包装层不需要认识业务参数。

3.2 data_pro:一个模板铺开几百个接口

data_pro.py打开会看到大量长得几乎一样的函数,这是它最没技术含量也最省事的地方:

# tushare/pro/data_pro.py 里成百上千个这样的薄函数 def daily(ts_code='', trade_date='', start_date='', end_date='', fields=''): return pro.query('daily', ts_code=ts_code, trade_date=trade_date, start_date=start_date, end_date=end_date, fields=fields) def stock_basic(exchange='', list_status='L', fields=''): return pro.query('stock_basic', exchange=exchange, list_status=list_status, fields=fields)

逻辑说明:每个函数只做两件事——固定api_name,把形参搬进 kwargs。默认值全是空串,空串也会被塞进params发出去。多数情况下服务端会把空串当成「不筛选」,但个别接口会因此返回全量数据,这就是「我只想查一只股票,结果拉回来五千行」的来源。

所以接口层之上我通常会加一个参数收敛函数,把常用的坑一次堵掉:

import re def normalize_code(code: str) -> str: """把 000001 / sz000001 / 000001.sz 统一成 000001.SZ""" code = code.strip().upper() m = re.match(r'^([A-Z]{2})(\d{6})$', code) # sz000001 if m: return f'{m.group(2)}.{m.group(1)}' m = re.match(r'^(\d{6})\.([A-Z]{2})$', code) # 000001.SZ if m: return code if re.match(r'^\d{6}$', code): # 裸代码,默认深市 return f'{code}.SZ' raise ValueError(f'无法识别的证券代码: {code}') def check_date(d: str) -> str: """只接受 YYYYMMDD,其他格式一律报错而不是透传""" if not re.match(r'^\d{8}$', d): raise ValueError(f'日期必须是 YYYYMMDD: {d}') return d

normalize_code处理的是 tushare 里最常见的一类翻车:裸代码不带后缀,服务端查不到但不报错,返回空 DataFrame。这比抛异常更难查,因为你看到的是「没数据」而不是「出错了」。check_date则是把格式问题挡在本地,省一次网络往返和被扣的调用次数。

3.3 pro_bar:复权行情为什么要单独走一条路

pro_bar是整份源码里唯一带业务逻辑的函数,也是最容易用错的一个。它不是一个独立接口,而是「不除权行情 + 复权因子」在本地算出来的:

def pro_bar(ts_code, adj=None, start_date=None, end_date=None): """adj: None 不复权 / 'qfq' 前复权 / 'hfq' 后复权""" df = pro.daily(ts_code=ts_code, start_date=start_date, end_date=end_date) if adj in ('qfq', 'hfq'): factor = pro.adj_factor(ts_code=ts_code, start_date=start_date, end_date=end_date) df = df.merge(factor[['trade_date', 'adj_factor']], on='trade_date', how='left') # 前复权以最新一天为基准,后复权以首日为基准 base = df.sort_values('trade_date')['adj_factor'].iloc[-1] if adj == 'qfq' else 1.0 for col in ['open', 'high', 'low', 'close', 'pre_close']: df[col] = df[col] * df['adj_factor'] / base return df.sort_values('trade_date').reset_index(drop=True)

逻辑说明:adj_factor是复权因子,除权除息日会跳变。前复权让最新价保持不变、历史价等比缩放;后复权让首日价保持不变、后面价格往上叠。base取最新一天的因子做前复权基准,是常见做法。

两个必须注意的点。第一,复权不是只复权收盘价,open/high/low/pre_close都得乘同一个因子,只改close会导致你画出来的 K 线是断的,回测里算出来的振幅也是错的。第二,merge用left——因为adj_factor接口在某些停牌区间可能缺行,用内连接会静默丢数据,比报错更难发现。

注意:前复权价格会随最新交易日滚动变化,今天存下来的 qfq 数据,明天再拉一遍就对不上了。做长期存储建议存不复权价 + 复权因子,用的时候现算。

4. 跑起来:从装包到全市场日线落盘

源码读到这里,该动手了。这一章按「能不能跑通 → 能不能批量拉 → 能不能每天自动跑」三级推进,每一步都给可抄的代码。

4.1 环境与安装

python -m venv .venv && source .venv/bin/activate pip install tushare pandas pyarrow python -c "import tushare as ts; print(ts.__version__)"

三件事说明一下。用虚拟环境是为了避免和系统里其他 pandas 版本打架,tushare 对 pandas 版本不算特别敏感但也不是完全无感。装pyarrow是为了后面落 Parquet——CSV 存全市场日线一年大概几百兆,Parquet 能压到十分之一,读回来还快。最后那行打印版本号,是确认装上了而不是装了个同名空包。

4.2 第一个能跑通的脚本

import tushare as ts ts.set_token('你的token') # 只需一次,会写进 ~/.tushare.csv pro = ts.pro_api() df = pro.daily(ts_code='000001.SZ', start_date='20240101', end_date='20240331') print(df.shape, df.columns.tolist()) print(df.head(3))

set_token只需执行一次,之后pro_api()会自动去读。daily的四个参数全都有默认值,最省事的写法是pro.daily(ts_code='000001.SZ'),但它默认只返回最近若干个交易日,想要完整区间必须显式给start_date和end_date。日期格式是YYYYMMDD字符串,带横杠的2024-01-01不会报错,但会返回空表——这是新手最常见的第一次翻车。

跑通之后立刻做一件事:打印df.dtypes。trade_date回来是字符串,vol单位是手,amount单位是千元,这几个单位不搞清楚,后面算出来的换手率和成交额全是错的。

4.3 拉全市场日线并按日分片落盘

按股票循环拉是错的——五千只股票就是五千次请求,配额瞬间见底。正确姿势是按交易日拉,一次拿全市场:

import os import time import pandas as pd import tushare as ts pro = ts.pro_api() os.makedirs('data/daily', exist_ok=True) def fetch_by_date(trade_date: str) -> pd.DataFrame: """按交易日拉全市场日线,一次请求覆盖五千多只股票""" df = pro.daily(trade_date=trade_date) if df.empty: return df df['trade_date'] = pd.to_datetime(df['trade_date']) # 单位统一:手 -> 股,千元 -> 元 df['vol'] = df['vol'] * 100 df['amount'] = df['amount'] * 1000 return df for d in pd.date_range('2024-01-02', '2024-01-31', freq='B'): ds = d.strftime('%Y%m%d') out = f'data/daily/{ds}.parquet' if os.path.exists(out): # 断点续传,别重复拉 continue df = fetch_by_date(ds) if df.empty: print(f'{ds} 无数据,跳过') continue df.to_parquet(out, index=False) print(f'{ds} 写入 {len(df)} 行') time.sleep(0.4) # 别贴着限频跑

逻辑说明:freq='B'是工作日,但 A 股有节假日,所以空表要正常跳过而不是当失败。os.path.exists做断点续传,脚本中断后重跑不会重复消耗配额。time.sleep(0.4)是给自己留余量,具体间隔取决于你的账号等级——这个值宁大勿小。

按日分片存的好处是增量极其简单:想知道拉到哪天了,看目录里最后一个文件名就行。

4.4 增量更新:用交易日历,别用工作日

def pending_days(start='20240101', end='20241231'): """返回还没落盘的交易日列表""" cal = pro.trade_cal(exchange='SSE', start_date=start, end_date=end, is_open='1') days = sorted(cal['cal_date'].tolist()) return [d for d in days if not os.path.exists(f'data/daily/{d}.parquet')]

trade_cal是必须用的:is_open='1'只返回开市日,这样春节、国庆不会被误判成「拉了但没数据」。这个接口一年只需要调一次,把结果缓存成 CSV,后面每天比对文件是否存在即可。整套流程挂到定时任务上,每天收盘后跑一次pending_days(),正常情况下只会返回当天一天的数据,几秒钟跑完。

5. 排查手册:token、限频、空表和复权这四个坑

下面这五条是我自己在用这套源码时真实踩过的,按「现象 → 原因 → 解决」写,遇到了直接对号入座。

5.1 权限与 token 相关的坑

现象一:报错信息里带「积分」或「权限」,但同样的代码别人能跑。

原因:tushare 的接口是分层的,不同接口要求不同的积分门槛,daily这种基础接口门槛低,income、moneyflow这类就要高一些。你调不通不是代码问题,是账号权限不够。另一种情况是 token 根本没读到——get_token()读不到文件时返回空串,请求照样发出去,服务端返回的是权限错误,看起来像是积分问题。

解决:先用最基础的接口探活,pro.stock_basic()能返回数据说明 token 是通的,问题出在接口权限上。探活这一步建议写进启动自检里:

def health_check(pro): try: df = pro.stock_basic(exchange='', list_status='L', fields='ts_code') print(f'[OK] token 有效,股票列表 {len(df)} 条') return True except Exception as e: print(f'[FAIL] 鉴权或网络异常: {e}') return False

现象二:本地能跑,部署到服务器就报鉴权失败。

原因:~/.tushare.csv在服务器的HOME下不存在,或者进程用户没有写权限。容器每次重建,这个文件就没了。

解决:不要依赖落盘文件,改成环境变量注入(见 2.2 的init_api())。如果必须用文件,就在镜像构建时COPY进去并把权限设成 600。

5.2 限频与超时相关的坑

现象三:循环跑到一半抛异常,提示每分钟访问次数超限,前面的数据白拉了。

原因:client.query()里没有重试也没有节流,循环写得多快就发得多快。5000 次请求打出去,服务端一定拦。

解决:两层防护。一层是固定间隔time.sleep(),简单粗暴但有效;另一层是query_with_retry里的退避重试。真正要注意的是不要把重试写成死循环,退避次数用完必须抛出去,否则你会盯着一个卡住的进程不知道发生了什么。

现象四:偶发超时,重跑就好了,但自动化脚本没人盯着。

原因:源码给了timeout=30但没给重试,网络抖动直接变成任务失败。

解决:把重试包在批量循环的最外层,而不是每个接口里。单个接口失败重试三次仍失败,就跳过这一天并记录到失败清单,下一轮增量更新会自动补上——因为pending_days()是靠文件存在性判断的,天然支持补偿。

5.3 数据本身的坑

现象五:接口没报错,但返回空 DataFrame,查半天查不出原因。

原因:三种可能——证券代码少了后缀(000001而不是000001.SZ)、日期格式带横杠、查的那天不是交易日。这三种情况服务端都当成「查询无结果」处理,不报错。参数原样透传的设计让这些错误在客户端完全无法拦截。

解决:所有入参走一遍normalize_code()和check_date(),把格式问题挡在本地。批量拉取时对空表做显式判断并打日志,别让它静默写进一个空文件——否则下次增量更新会误以为这天已经拉过了。

现象六:复权后的价格和行情软件对不上,差的那点不多不少刚好是分红。

原因:只复权了close,open/high/low/pre_close还是原始价;或者前复权的基准日选错了,用了区间首日而不是最新交易日。

解决:全列同因子相乘,基准统一取最新交易日的adj_factor。长期存储改成「不复权价 + 因子」,用的时候现算,这样历史数据永远不需要回溯修正。

6. 二次开发:把接口包改造成自己的数据层

读完源码最该带走的一个认知是:tushare 是个传输层,不是数据层。它负责把请求发出去、把 JSON 变成 DataFrame,至于缓存、幂等、字段裁剪、失败补偿,一概不管。所以二次开发的方向不是改它,而是在它外面包一层。

我一般的结构是三层:最底下是Client,中间是「接口注册表」,最上面是业务函数。注册表用一个字典描述每个接口的特性,比散落在各处的if好维护得多:

配置项作用常见取值
api_name对应服务端接口名daily/adj_factor
key_fields幂等去重用的主键['ts_code', 'trade_date']
min_interval两次调用的最小间隔(秒)0.3~1.0
cache_ttl本地缓存有效期(秒)静态表 86400,行情 0

有了这张表,缓存和限频就能写成通用逻辑:调之前先查min_interval决定要不要 sleep,静态表(stock_basic、trade_cal)走本地缓存,行情表不缓存直接落 Parquet。

第二个值得做的改造是把网络层抽成接口。这样单元测试里可以塞一个假 client,返回固定的{fields, items},整个数据管道不用联网就能跑。我吃过这个亏——早期测试直接连真实接口,跑一次测试烧掉半天配额,还把测试结果搞得依赖网络状态,时快时慢。后来改成离线回放,把真实响应存成 JSON,测试时按api_name取,跑得快还没副作用。

验证改造是否成功,我只看两个指标:同一批参数跑两遍,落的文件字节数完全一致(幂等);断网情况下重跑,能从断点继续而不是从头再来(补偿)。这两个都对,这套管道才算能用。

从那以后我每次包一层 SDK,都强制先写一个假的 client 把离线测试跑通,再连真实接口——不然你永远分不清是代码写错了还是配额用完了。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 10:33:20

职业规划书模板拆解:从SWOT到季度检查清单的实操指南

简介:《国际商务.docx》是一份以国际商务专业为背景的大学生职业生涯规划书,面向国际商务专业学生、应届毕业生及希望进入外贸、跨国管理领域的新人,帮助他们将个人兴趣、能力与行业需求结合,制定清晰可行的职业发展路径。文档严格…

作者头像 李华
网站建设 2026/10/24 10:25:16

AI Agent健忘症:为什么它记不住自己说过的错话

1. 这个标题到底在说啥?——一个被多数人忽略的AI Agent“健忘症”你有没有试过让AI Agent帮你订餐厅,它信心满满地告诉你“已成功预约今晚七点”,结果你打开APP发现根本没这单;或者让它整理会议纪要,它把张三说的A观点…

作者头像 李华
网站建设 2026/10/24 10:21:08

JSX与ReactElement:从编译原理到性能优化

开头 做前端这些年,几乎每天都在写 JSX,但真正被问到“JSX 到底是个什么、ReactElement 又是怎么一回事”的时候,能讲清楚的人其实不多。大多数同学的认知停留在“JSX 是 React 的模板语法”这个层面,甚至面试时也只会说“JSX 最后…

作者头像 李华
网站建设 2026/10/24 10:09:22

把“skills”当作项目来经营:一套技能管理系统搭建指南

1. 起点:一次面试复盘让我重新理解“skills”这个词很多人把 skills 当成简历上的一排关键词,Java、Python、项目管理、沟通协作,填得满满当当,好像在超市货架上摆商品。但真正用这些技能去解决问题的时候,情况完全不是…

作者头像 李华
网站建设 2026/10/24 10:07:34

网约车打车小程序:实时调度、状态机与WebSocket位置服务

简介:一套面向微信小程序开发学习者的仿滴滴打车出行应用源码,聚焦网约车核心流程的拆解与复现,适合已掌握小程序基础、希望理解地图定位、订单流转与页面组织的中级开发者参考。压缩包为 rar 格式,共 151 个文件、约 652KB&#…

作者头像 李华
网站建设 2026/10/24 10:04:57

Linux操作系统实验全链路:环境搭建、/proc观测与进程调度实战

简介:西南交通大学操作系统实验报告,是一份获得95分(A)的高分课程成果,面向操作系统课程学习者、Linux初学者及需要撰写实验报告的高校学生。报告基于2020-2021学年软件工程专业教学要求,完整记录多个核心实…

作者头像 李华