ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

tushare源码拆解:接口清单、限频重试与数据契约实践

tushare源码拆解:接口清单、限频重试与数据契约实践

简介:围绕 tushare 最新源代码整理的开源项目包,面向已有 Python 基础、希望从接口调用进阶到源码理解的金融数据分析学习者与量化爱好者。压缩包共 44 个文件,以 25 个 Python 源码文件为主体,搭配 7 个 rst 文档、3 个 txt 说明以及 md、yml、Makefile 等配置与说明文件,整体约 269KB,兼顾核心实现、测试脚本和文档结构。已有 1494 人学习下载。源码覆盖股票、期货、基金等数据的接口封装、HTTP 请求与解析、异常处理、数据库存取、数据清洗及多线程与异步 IO 等环节,读者可据此理解财经数据获取链路的内部机制,并借鉴并发调度、日志记录和测试组织方式。文档与测试文件便于按模块对照学习;其中对 SQLite、MongoDB 等存储方式与可视化调用的实现,也为构建个人数据管道、排查网络延迟和格式转换问题提供了可复用的代码线索,适合作为二次开发、性能优化和排错思路的参考样本。

1. tushare 最新源代码拿到手,先别急着 pip install

上一份 tushare 源代码,多数人的第一反应是装完跑个pro.daily()看到有数据就收工。真到批量拉全市场、字段对不上、接口突然返回空表的时候,才发现自己手里只有一个黑匣子——不知道请求是怎么拼的、参数在哪被改写、错误在哪一层被吞掉。这份最新源代码的价值恰恰在这儿:它把接口清单、参数字典、请求拼装和异常处理全摊开给你看,能解决"接口行为跟预期不一致时无从下手"的问题。适合三类人:要把行情数据接进自己回测/看板流水线的开发者、需要核对字段口径的数据分析岗、以及想读懂第三方数据 SDK 设计思路的工程师。下面按"拆结构 → 跑起来 → 调参数 → 避坑 → 二次封装"的顺序走一遍,每一步都能直接抄。

2. 拆源码包:分层结构与你看到的接口是怎么被"点"出来的

拿到一个 SDK 源码,我一般先做两件事:看目录骨架判断代码新旧,再看入口文件判断接口是硬编码还是动态生成。这个顺序能省掉大量盲读。

2.1 目录骨架与模块分工

解开源码包,顶层目录大致是这么几块,不同小版本的命名会有出入,但职责划分基本稳定:

路径职责是否需要常看
tushare/__init__.py包入口,暴露set_token、pro_api、老版行情函数必看,改动风险最高
tushare/pro/新版接口层,含请求客户端与接口清单必看,参数口径都在这
tushare/stock/老版行情抓取类模块,走页面解析按需,做兼容时才翻
tushare/util/日期处理、代码格式转换、通用请求封装常看,排错一半时间在这
tushare/data/打包进源码的静态数据,如交易日历、代码对照表按需,做离线校验时有用

__init__.py通常只做三件事:把核心对象导入到包命名空间、维护一个全局 token 状态、在pro_api()里做 token 优先级判断(显式传入 > 环境变量 > 本地配置文件)。很多人遇到的"我明明 set_token 了为什么还报错",答案往往就在这十几行里——比如某个分支下 token 被判空后直接走了匿名请求。

util/目录值得单独说一句。日期字符串的补零、600000补成600000.SH这类代码后缀拼接、空值转None,都是在这里做的。你从上层传进去的参数,到真正发请求时可能已经被改写了两三轮,排查参数问题时把断点打在这里比打在上层有效得多。

2.2 接口方法不是一个个写的:动态挂载机制

这是整套源码里最值得看的设计。几百个接口,作者不可能手写几百个def,实际做法是维护一份接口清单(接口名 → 参数定义 → 返回字段说明),在客户端对象初始化时遍历清单,用setattr把方法挂到实例上。

所以你在 IDE 里补全出来的pro.daily、pro.stock_basic、pro.income,全都是运行时生成的。这带来两个直接后果:一是静态类型检查基本失效,二是接口清单才是唯一权威文档,网上二手教程写的字段名很可能已经过期。

先把这个清单挖出来看看:

import inspect import tushare as ts # token 换成自己的,下面所有示例都一样 pro = ts.pro_api("你的token") # 过滤掉内置方法,剩下的基本都是接口名 api_names = [n for n in dir(pro) if not n.startswith("_")] print("接口数量:", len(api_names)) print("前 20 个:", api_names[:20]) # 单个接口的形参顺序,比看文档快 print(inspect.signature(pro.daily)) # 典型输出: (ts_code='', trade_date='', start_date='', end_date='', ...) # 接口自带的说明文档,字段口径以这里为准 print(pro.daily.__doc__[:600])

逻辑说明:dir()拿到的是实例上所有属性名,去掉下划线开头的内置项后剩下的就是挂上去的接口;inspect.signature()读的是实际函数的形参默认值,这个默认值列表直接告诉你哪些参数可以空、空的默认是什么;__doc__里通常带着返回字段的中文说明,字段含义有歧义时以它为准,别信搜索引擎里抄来抄去的老文章。

参数说明:dir()的结果包含你自己 setattr 上去的东西,脚本里别的地方如果给 pro 对象挂过属性,会被一起捞出来,所以做接口遍历时最好在干净进程里跑。另外__doc__可能为None(作者没写),取值前判空。

如果你想进一步确认清单本身长什么样,可以在tushare/pro/下找那个维护接口定义的模块,大概率是一个大字典或一个.json资源文件。把它的 key 全部打印出来,你就得到了一份不会过期的接口目录,做数据仓库映射表的时候直接拿它当主键来源。

2.3 源码安装与发布版安装:什么情况下必须用源码

这一步是选型问题,不是操作问题。pip install tushare装的是打好的发布包,优点是干净、可复现,缺点是有可能落后于主干,字段修正和小 bug 往往先在源码里出现。pip install -e .是可编辑安装,源码目录被链接进环境,你改一行立刻生效。

我的一般判断标准:

  • 只调数据、不改行为:直接pip install,别折腾。
  • 要核对某个字段的原始拼装逻辑:源码可编辑安装,打日志看中间变量。
  • 要加本地适配(比如统一重试、加缓存钩子):源码可编辑安装,但把改动放在自己的封装层,别动tushare/pro/里的请求逻辑,否则下次更新源码会被覆盖。
  • 多环境部署:锁版本号装发布包,源码只用来读。

这里有个容易翻车的点:源码目录里如果有setup.py,且packages字段是用find_packages()自动发现的,那么你在源码目录里临时建的子目录如果带了__init__.py,可能被打进包里。做实验时把临时脚本放在仓库外的同级目录,别塞进去。

3. 从源码跑到第一个请求:环境、安装与落库

3.1 环境准备与依赖

先看依赖。源码包里一般有requirements.txt或setup.py里的install_requires,核心依赖通常是pandas、requests,老版抓取模块还会依赖 HTML 解析库。Python 版本按setup.py里python_requires的声明来,别拿太老的解释器硬跑。

我习惯用独立虚拟环境,因为 tushare 对 pandas 版本比较敏感,大版本跨越时DataFrame.append之类的老 API 被移除会直接报AttributeError:

# 建环境,解释器版本按 setup.py 里的声明选 python -m venv venv_ts source venv_ts/bin/activate # Windows 用 venv_ts\Scripts\activate # 先看源码包声明的依赖,再决定装什么版本 cat requirements.txt 2>/dev/null || grep -A20 install_requires setup.py

逻辑说明:第一行建隔离环境,隔离的是 pandas 版本冲突,不是 tushare 本身。第二行先读依赖声明再装,避免一上来pip install tushare把全局环境保护的 pandas 顶掉。如果你机器上已经有别的项目在跑 pandas,这一步不做,后面大概率会遇到"昨天还好好的今天就报错"。

参数说明:requirements.txt不存在时退回读setup.py,install_requires是官方声明的依赖清单;extras_require里通常还有可选依赖(比如数据库驱动),按需装。

3.2 三种安装方式的选择

# 方式一:从源码目录可编辑安装,改代码立即生效 cd tushare-master pip install -e . # 方式二:常规安装,走发布包 pip install tushare # 方式三:不安装,直接把源码目录挂到 sys.path(临时验证用) # 在脚本开头加:sys.path.insert(0, "/path/to/tushare-master")

逻辑说明:方式一适合"我要读代码并验证我的理解",-e装完之后import tushare指向的就是那个目录,你在源码里加个print立刻能看到。方式二适合生产环境,版本可锁定。方式三只用于临时验证,因为它跳过了setup.py里的元数据安装,某些通过pkg_resources读版本号的逻辑会失效。

参数说明:-e后跟的是含setup.py的目录,路径别写错层级,写成pip install -e .时当前目录必须在源码根目录。如果你装了方式一又想退回方式二,必须先pip uninstall tushare,否则两个安装会打架,import出来的到底是哪份取决于路径顺序,这种玄学问题很难查。

装完验证一下来源:

import tushare print(tushare.__file__) # 确认到底加载的是哪份代码 print(getattr(tushare, "__version__", "未声明版本"))

__file__打印出来是源码目录,说明可编辑安装成功;是site-packages下的路径,说明走的是发布包。排错时这行一定要先跑,我见过太多次"改了没生效"其实是环境装串了。

3.3 token 配置与最小可运行脚本

token 是这套接口的准入凭证,不同接口对权限等级的要求不一样,这一点后面避坑章节会细说。配置方式有优先级,源码里通常是"显式传参 > 环境变量 > 本地配置文件"。

import os import tushare as ts # 推荐走环境变量,别把 token 写死在脚本里提交到仓库 os.environ["TUSHARE_TOKEN"] = "你的token" # 一次 set_token 之后,同进程内 pro_api() 可以直接调 ts.set_token(os.environ["TUSHARE_TOKEN"]) pro = ts.pro_api() # 最小可运行请求:单只标的、小时间窗口 df = pro.daily( ts_code="000001.SZ", # 代码带市场后缀,格式错了直接返回空表 start_date="20240102", end_date="20240131", fields="ts_code,trade_date,open,high,low,close,vol,amount" ) print(df.shape) print(df.head())

逻辑说明:set_token的作用是把 token 写进模块级全局状态,pro_api()不传参时就读这个状态。请求侧我刻意用了小窗口和明确的fields,目的是让第一次运行的结果可肉眼核对——行数不对、字段缺列、值全是 NaN,三种情况分别指向不同的失败原因,先用最小样本定位比直接拉三年全市场快得多。

参数说明:ts_code必须是"代码+后缀"的形式,纯数字会被判成非法参数;start_date/end_date是YYYYMMDD字符串,不是datetime对象,传错类型有的版本抛异常、有的版本静默返回空表,后者更坑;fields传逗号分隔的字符串,不传则返回全字段,字段多的时候传输和内存开销会明显上来。

如果返回空表,先别怀疑网络,按这个顺序查:代码格式 → 日期是否落在交易日 → 该接口权限是否够。这三件事占了我排查时间的八成。

3.4 把返回的 DataFrame 稳定落库

拉下来的数据总得存,最省事的方案是 SQLite,单文件、零部署、适合个人流水线:

import sqlite3 import pandas as pd conn = sqlite3.connect("market.db") def save_daily(df: pd.DataFrame, table: str = "daily") -> int: if df is None or df.empty: return 0 # 主键去重:重复拉同一区间时走替换而不是重复插入 df = df.drop_duplicates(subset=["ts_code", "trade_date"]) n = df.to_sql(table, conn, if_exists="append", index=False) # 建唯一索引,第二次跑同一区间时冲突可直接跳过 conn.execute( f"CREATE UNIQUE INDEX IF NOT EXISTS idx_{table}_pk " f"ON {table}(ts_code, trade_date)" ) conn.commit() return n save_daily(df) print(pd.read_sql("SELECT COUNT(*) AS c FROM daily", conn))

逻辑说明:drop_duplicates处理的是同一次返回内部的重复,跨批次的重复靠唯一索引兜底。用if_exists="append"而不是"replace",是因为数据是分批增量拉的,replace会把之前批次全清掉。索引在插入之后建,避免每次插入都维护索引拖慢速度,但如果表已经很大,先建索引再插反而更快,这个要按数据量试。

参数说明:subset必须用业务主键,不同接口的主键不一样(行情类通常是代码+交易日,财报类是代码+报告期),选错了去重就是无效的;index=False不加的话 pandas 会把行号写进去,多一列脏数据;conn.commit()别漏,某些驱动下不提交连接关闭时数据会丢。

4. 参数怎么设:日期、字段裁剪、限频重试与本地缓存

接口能不能稳定跑,八成取决于参数怎么给。这一章按"最容易出错"到"最能提效"的顺序排。

4.1 日期与代码类参数:格式、边界与空值

日期参数统一是YYYYMMDD八位字符串,中间不要带横杠,不要传datetime.date。源码里虽然有一层转换尝试,但转换失败时不一定抛错,可能直接把非法值拼进请求参数,服务端返回空结果,你看到的就是一张空表——这是最典型的"看起来成功其实失败"。

边界问题更隐蔽:start_date和end_date一般是闭区间,但如果你只传trade_date而同时传了start_date,不同接口的优先级处理不一致,有的以trade_date为准,有的直接忽略后者。我的规矩是同一类参数只传一组,要么传单日trade_date,要么传区间,别混着给。

代码参数同理。ts_code带后缀,symbol不带后缀,同一份源码里两套命名并存,混用就返回空。写代码时把格式校验前置:

import re from datetime import datetime DATE_RE = re.compile(r"^\d{8}$") def normalize_date(s: str) -> str: """把 2024-01-02 / 20240102 / datetime 统一成 YYYYMMDD""" if isinstance(s, (datetime,)): return s.strftime("%Y%m%d") s = str(s).replace("-", "").replace("/", "").strip() if not DATE_RE.match(s): raise ValueError(f"日期格式不合法: {s}") return s def normalize_code(code: str, suffix: str = "SZ") -> str: """纯数字补后缀,已带后缀的原样返回""" code = str(code).strip().upper() if "." in code: return code if len(code) != 6 or not code.isdigit(): raise ValueError(f"代码格式不合法: {code}") return f"{code}.{suffix}"

逻辑说明:这两个函数干的事就是"在入口处把脏数据拦住"。normalize_date兼容常见三种写法并统一输出,最后一道正则校验保证拼进请求的永远是八位数字。normalize_code保留已有后缀,避免把000001.SZ又拼成000001.SZ.SZ。

参数说明:suffix默认值要按你的实际标的池调整,A 股里不同板块后缀不同,硬编码一个默认值有风险,更稳的做法是维护一张代码对照表从源码目录的静态数据里加载。raise而不是return None,是为了让问题在入口暴露,而不是等到服务端返回空表再回头查。

4.2 fields 裁剪:省一半传输和内存

不传fields时接口返回全字段,财报类接口动辄几十上百列,全量拉三年数据,内存能吃掉好几个 G。传fields只取你要的列,效果立竿见影。

DAILY_FIELDS = "ts_code,trade_date,open,high,low,close,pre_close,vol,amount" df = pro.daily( ts_code="000001.SZ", start_date="20240101", end_date="20240329", fields=DAILY_FIELDS ) print(df.memory_usage(deep=True).sum() / 1024 / 1024, "MB")

逻辑说明:字段列表写成一个模块级常量,好处是"要加字段只改一处",而且能直接拿这个常量去校验返回的列是否齐全。

参数说明:字段名拼错不会抛错,服务端会忽略无法识别的字段,你拿到的是少几列的结果。所以落库前一定要做一次列校验,把set(DAILY_FIELDS.split(",")) - set(df.columns)打出来,非空就说明有字段名写错了或者接口口径变了。这个校验我放在封装层里强制执行,宁可报错也别悄悄少列。

4.3 限频与重试:把调用包一层

接口有访问频率限制,按账号权限等级区分,超了会抛异常,异常信息里通常带"每分钟访问次数"之类的提示。不包重试层,批量拉数据必然中断。

import time from functools import wraps def with_retry(max_retry: int = 4, base_wait: int = 15): """针对限频做线性退避重试,其他异常直接抛出""" def deco(fn): @wraps(fn) def wrapper(*args, **kwargs): for i in range(max_retry): try: return fn(*args, **kwargs) except Exception as e: msg = str(e) if "每分钟" in msg or "limit" in msg.lower(): wait = base_wait * (i + 1) # 15s / 30s / 45s / 60s print(f"触发限频,{wait}s 后重试 ({i+1}/{max_retry})") time.sleep(wait) continue raise # 权限、参数类错误不重试,重试也不会成功 raise RuntimeError(f"重试 {max_retry} 次仍失败") return wrapper return deco @with_retry() def fetch_daily(pro, **kwargs): return pro.daily(**kwargs)

逻辑说明:核心是区分可重试异常和不可重试异常。限频是瞬时的,等一会儿就好;权限不足、参数非法是确定性的,重试一百次结果一样,还白等几十分钟。判断依据目前只能靠异常信息字符串匹配,比较脆弱,所以我在封装层留了个日志点,接口文案变了能第一时间发现。

参数说明:base_wait设成 15 秒是经验值,太短会被连续打回,太长浪费时间;max_retry=4配合线性退避总计约 150 秒,够覆盖一次限频窗口。如果你是多线程并发,退避时间要乘线程数,否则几个线程同时冲上去还是会被限。

提示:重试层只应该包在最外层调用点,不要在源码里改请求函数加重试,两处都加会叠加放大等待时间,日志也会重复。

4.4 本地缓存:同一份数据不拉第二遍

历史数据基本不变,日线最多在收盘后更新一次。缓存方案我用得最多的是按"接口名 + 参数哈希"存 Parquet:

import hashlib, json, os import pandas as pd CACHE_DIR = "./cache" os.makedirs(CACHE_DIR, exist_ok=True) def cache_key(api: str, params: dict) -> str: raw = api + json.dumps(params, sort_keys=True, ensure_ascii=False) return hashlib.md5(raw.encode()).hexdigest() def cached_call(api: str, fn, **params): path = os.path.join(CACHE_DIR, f"{api}_{cache_key(api, params)}.parquet") if os.path.exists(path): return pd.read_parquet(path) df = fn(**params) if df is not None and not df.empty: df.to_parquet(path, index=False) return df df = cached_call("daily", pro.daily, ts_code="000001.SZ", start_date="20240101", end_date="20240329")

逻辑说明:sort_keys=True是关键,参数字典顺序不同不应该产生两份缓存。命中就直接读 Parquet,未命中才发请求并落盘。Parquet 比 CSV 小、读写快、还保留 dtype,日期列不会读回来变成字符串。

参数说明:json.dumps里ensure_ascii=False不是必需的,但不加的话中文参数会被转义,哈希值依然稳定,只是可读性差。缓存文件不设过期时间,所以当日数据不要走缓存——做增量更新时,把end_date之前的部分走缓存,最近 N 天强制重新拉,这个策略在封装层用参数控制即可。

5. 避坑与排查:五个血泪翻车记录

这一章按"现象 → 原因 → 解决"列,都是我或者同行实际踩过的。

5.1 现象:本地正常,服务器上全部返回空表

原因:token 的权限等级是按账号算的,但还有一层是调用来源的环境限制。更常见的真实原因是服务器上的环境变量没配,pro_api()走了匿名分支,服务端对匿名请求返回空结果而不是报错,表现就是"代码没问题、结果没有"。

解决:部署脚本里加一条启动自检,把 token 的前后四位和长度打出来(不要打全部),同时确认tushare.__file__指向的版本。我把这一步写进了启动检查,比线上接手服务后再查省事得多。

5.2 现象:请求返回空 DataFrame,但手工用同样的参数在别的工具里能查到

原因:九成是参数格式问题。日期带了横杠、代码少了后缀、区间写成了start_date > end_date,这三种都会静默返回空表。接口层不会因为你参数不合法就抛异常,它只是把参数拼进请求,服务端按没匹配到数据处理。

解决:在封装层强制参数校验,normalize_date和normalize_code各跑一遍,校验失败直接抛。另外加一条守则:任何返回空表的请求,先把最终请求参数字典打印出来看一遍,别靠脑子记。

5.3 现象:复权后的价格和别处对不上,差异还挺大

原因:复权有前复权、后复权、不复权三种口径,不同来源用的基准日不一样,前复权是以最新价为基准往前推,所以历史数据会随新数据变化。你今天拉的、上周拉的、别人给的,三份数据基准日不同,数值自然对不上。

解决:落库时把复权口径作为一个字段存下来,别混在一张表里。做回测时统一用后复权(基准固定,历史值不会变),展示时再转前复权。这个坑我在一个回测项目里连续踩了两周才发现,纯粹是口径不一致。

5.4 现象:老版接口突然大面积返回空或者直接报错

原因:老版行情模块走的是页面解析,页面结构一改就全废,这类问题不是你的代码错。新版接口层走 HTTP 接口,稳定性高得多,但仍然存在字段调整的可能。

解决:新项目一律用 pro 接口层,老版只用来做历史数据兼容。同时把字段校验做成硬性检查,列名对不上立刻报警而不是写进库。如果确实需要解析页面,加一层"结构指纹"校验,解析前先确认关键元素存在,不存在就抛错,别让空结果流进下游。

5.5 现象:同一份代码,昨天返回 5000 行今天返回 3000 行

原因:分批拉取时没有固定排序,或者区间边界重合/遗漏导致重复或缺失。另一类原因是接口对单次返回条数有上限,超过上限会被截断,而你拿到的df.shape看起来完全正常。

解决:按交易日切分批次,每个批次拉完立刻校验行数落在合理区间(比如单日全市场股票数上下浮动 10% 以内),超出范围就拆小窗口重拉。切分逻辑写成函数:

import pandas as pd def split_by_trade_date(cal: pd.DataFrame, start: str, end: str, step: int = 20): """按交易日切批次,step 控制每批包含的交易日数""" days = cal[(cal["cal_date"] >= start) & (cal["cal_date"] <= end)] days = days.sort_values("cal_date")["cal_date"].tolist() for i in range(0, len(days), step): chunk = days[i:i + step] yield chunk[0], chunk[-1], len(chunk)

逻辑说明:cal是从接口拿到的交易日历表,用它而不是自然日,能避开节假日空跑。sort_values保证批次顺序稳定,避免集合遍历顺序带来的随机性。

参数说明:step=20是折中值,太小请求次数多,太大单次返回可能触碰条数上限被截断,按你账号权限和接口实际返回上限调。yield出起始日、结束日和交易日数,后者拿去和实际返回的行数做交叉校验。

6. 进阶:把源码当采集层的底座,加一层自己的契约

读完源码之后我做的最有价值的一件事,是把"接口调用"和"数据契约"拆开。接口层只负责发请求、拿 DataFrame,绝不做业务判断;业务层持有字段清单、主键定义和行数期望,任何不符立刻抛错。这样一来,接口字段变更时你会在第一分钟就知道,而不是在两周后的回测结果异常里发现。

下面是我常用的字段契约表,用字典表达,直接可以被封装层消费:

接口主键必备字段单次行数预期备注
dailyts_code + trade_date开高低收、量额单日全市场正数单只标的按区间返回
stock_basicts_code名称、行业、上市状态变化缓慢,可周更更新频率低,适合缓存
trade_calcal_date是否交易日年为单位所有分批逻辑的前置依赖
财报类接口ts_code + 报告期报告期、科目按报告期分批字段多,务必传fields

校验函数长这样:

REQUIRED = { "daily": ["ts_code", "trade_date", "close", "vol"], "stock_basic": ["ts_code", "name", "list_status"], } def check_contract(api: str, df: pd.DataFrame, min_rows: int = 1): if df is None or df.empty: raise ValueError(f"{api} 返回空表") missing = set(REQUIRED.get(api, [])) - set(df.columns) if missing: raise ValueError(f"{api} 缺字段: {missing}") if len(df) < min_rows: raise ValueError(f"{api} 行数异常: {len(df)} < {min_rows}") return True

逻辑说明:三个检查按成本从低到高排——空表检查最便宜,字段检查次之,行数检查需要你事先知道预期值。REQUIRED用字典管理,接口清单变了只改数据不改函数。

参数说明:min_rows在不同场景下取值差别很大,做单只标的区间拉取时设 1 就够,做全市场单日拉取时要设成股票总数的九成左右,设太低等于没校验。

有一个细节我踩过:校验放在落库之后。听着离谱,但当时是为了"先把数据留下再校验",结果脏数据进了库,清理比重拉贵十倍。从那以后我每次接新接口,都强制让校验跑在写库之前,并且拿最小样本先跑一遍字段和行数,确认无误才放开批量。希望这些能帮你少走几段弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进