做A股研究绕不开两个字:解禁。一篇“XX公司大额解禁”的新闻能把一只股票打趴下,但真正等新闻出来再操作,黄花菜都凉了。一套能持续更新的解禁限售数据,配合股票数据API的自动化抓取,才是提前发现风险的正路。这篇文章我从最常用的一条路径讲起:先用akshare一分钟把全市场解禁时间表拉下来,再拆解东方财富底层的股票数据接口,讲清楚数据从网页到DataFrame再到数据库的完整链路,最后把我在实际抓取中踩过的坑一次性说清楚。
1. 解禁限售数据在研究中的真实分量
1.1 解禁的本质:一场有预约的供应释放
很多刚接触A股的朋友会问:为什么一只股票明明业绩不错,某天突然大跌?你去看公告,十有八九写着“本次解除限售股份占公司总股本的比例为X%”。这就是解禁在发挥作用。
解禁,全称是限售股解除限售。公司在IPO、定向增发、股权激励等过程中发行的股票,并不是一上市就能随便卖的。大股东、机构投资者拿到的股份通常有6个月到36个月不等的锁定期。锁定期一满,这部分股份就变成流通股,理论上可以在二级市场抛售。这个“由锁定期满到可流通”的节点,就叫解禁日。
解禁限售数据,本质上就是一张“未来哪些股票会在哪一天释放多少可卖筹码”的时间表。它的价值在于:这是一场有预约的供应释放,你可以提前看到未来几个月市场的潜在抛压分布。这不是什么内幕消息,而是公开披露的信息,只是大多数散户不会主动去整理。
解禁的类型也分好几种,常见的包括首发原股东限售股解禁、定向增发机构配售股解禁、股权激励限售股解禁,以及追加承诺限售股解禁。不同类型的解禁,后面的抛售逻辑是完全不一样的。首发原股东解禁,尤其是控股股东,通常不会立刻清仓式减持,因为要保控制权;但定增机构解禁就不一样了,很多机构参与定增就是冲着折价来的,解禁后落袋为安的动机非常强。这些细微差别,在看数据时一定要留个心眼。
1.2 拿到解禁数据后,我实际用它做什么
先说最直接的用法:避雷。我会定期扫一遍持仓股和自选股未来三个月的解禁计划,如果某只票恰好处于高位、估值又不便宜,同时还有一笔占总股本比例超过5%的解禁,那我会默认它随时可能遭遇抛压,操作上会保守很多。
第二个用法是事件驱动的观察窗口。解禁不等于减持,但解禁之后往往跟着大宗交易、股东减持预披露等公告。把解禁日期作为一个时间锚点,往前推几天、往后推几个星期,去观察成交量、换手率和股东户数的变化,能看出产业资本的真实意图。
第三个用法是做策略研究。比如回测“解禁公告日附近是否存在异常收益”,或者研究“解禁完成后的抛压释放是否带来修复行情”。这些都需要历史上每一期解禁数据的完整记录,而不是等到解禁前一天才去查。这也是为什么我强调“把数据存下来”,而不是每次现抓。
一句话总结:解禁限售数据的核心用途不是预测涨跌,而是帮你识别“潜在的供需失衡区间”,把博弈的主动权握在自己手里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 免费数据源怎么选:为什么我先推akshare
2.1 常见免费数据源一览
做股票数据抓取,数据源是第一步。我这些年试过不少,简单做个横向对比:
| 数据源 | 成本 | 数据完整性 | 接口稳定性 | 上手难度 | 备注 |
|---|---|---|---|---|---|
| 东方财富网页接口 | 免费 | 高 | 中等,偶尔改版 | 中等 | 需要自己抓包解析 |
| 同花顺网页接口 | 免费 | 较高 | 中等 | 中等 | 加密参数较多 |
| 新浪财经接口 | 免费 | 行情为主 | 较稳 | 低 | 解禁类数据覆盖一般 |
| Tushare Pro | 积分制 | 高 | 稳 | 低 | 高权限需要积分 |
| akshare | 免费 | 高 | 稳 | 低 | 封装东财等多源数据 |
如果你只是做个人研究、跑跑策略,我的建议很直接:优先用akshare。它在免费的前提下,把绝大多数你想要的A股数据都封装成了Python函数,解禁限售数据只是其中一块。
2.2 从网页到函数:akshare的封装思路
akshare的本质,是把网页端的接口请求封装成Python函数。比如你手动打开东方财富的解禁页面,浏览器会向后端发送一个HTTP请求,返回JSON数据。akshare替你把“构造请求地址、带请求头、解析JSON、整理成DataFrame”这一步做完了。
这意味着你不需要懂抓包,不需要知道请求参数怎么拼,调一个函数就能拿到结构化数据。对一个以数据分析为主要工作的人来说,这是最省心的一条路径。
有人会担心:akshare免费开源,更新跟不上怎么办?这个担心合理,但从我这几年的使用体验来看,它的维护频率相当高,尤其是解禁这类常规数据,接口变化后的跟进通常在一周内完成。对个人研究而言完全够用。
2.3 什么情况下需要绕过akshare
akshare虽然好用,但有三类场景我会选择直接写请求调底层接口。
第一,akshare的接口恰好正在更新或失效,而你这边数据不能断。第二,你需要定制化字段,比如某些接口返回的列不够全,你希望拿到更底层的原始字段。第三,你想把抓取频率和抓取策略完全掌握在自己手里,不希望中间多一层封装带来的不确定性。
这就是本文后面要讲的内容:先用akshare快速拿到结果建立数据认知,再深入底层接口自己写一遍请求,两条腿走路。
3. akshare拉取解禁数据的完整实操
3.1 环境准备与包安装
开始之前先把环境准备好。我默认你用的是Python 3.9以上版本,用Anaconda或者原生Python环境都可以。
bash复制pip install akshare pandas
安装完成之后,建议先验证一下版本,避免后续脚本出现兼容性问题:
python复制import akshare as ak
print(ak.__version__)
如果你安装的是最新版,大概率接口名称和参数不会有太大出入。但akshare每个月都在更新,强烈建议在调用每个接口之前用help函数看一眼最新签名:
python复制help(ak.stock_lift_reset_em)
这一步花不了十秒钟,但能帮你避开“明明照着文档抄却报参数错误”的尴尬。
3.2 全市场解禁时间表
拿到全市场解禁时间表的接口是 stock_lift_reset_em,我先把代码贴出来:
python复制import akshare as ak
import pandas as pd
# 拉取2025年上半年的全市场解禁时间表
df_lift = ak.stock_lift_reset_em(
start_date="20250101",
end_date="20250630"
)
print(df_lift.shape)
print(df_lift.columns.tolist())
print(df_lift.head(10))
这个接口对应的是东方财富“解禁时间表”页面,返回的是每一笔解禁计划的明细,通常包含代码、简称、解禁日期、解禁股份数量、解禁市值、解禁股份占总股本比例、解禁类型等字段。
我这里要特别提醒一点:不同版本的akshare返回的列名可能不一样。我最早用的时候,列名是“解禁日期”“解禁股份数量”,后来的版本改成了英文或者加了序号。所以拿到DataFrame后,第一件事永远是 df_lift.columns.tolist() 看有哪些列,而不是想当然地按记忆中的列名去取数。
3.3 按个股查询与解禁详情
如果你关心的是某一只具体股票,比如“贵州茅台什么时候解禁、解禁多少”,可以用限售股解禁计划明细接口 stock_restricted_release_queue_em:
python复制# 查询个股的限售股解禁情况
df_queue = ak.stock_restricted_release_queue_em(
symbol="600519",
date="20250101"
)
print(df_queue.head())
这个接口的symbol参数可以直接传股票代码,date参数传日期。注意这里的日期是单个日期,接口返回的是该日期当天(或附近)的排期情况,和全市场时间表按日期段拉取的方式略有不同。
更细粒度的数据可以用解禁详情接口 stock_restricted_release_detail_em,它返回的解禁明细更全,包括解禁股东、解禁原因、实际解禁数量等:
python复制df_detail = ak.stock_restricted_release_detail_em(date="20250101")
print(df_detail.head())
另外还有一个解禁汇总接口 stock_restricted_release_summary_em,适合你只想看当日解禁总市值、总股本占比等统计口径时用:
python复制df_summary = ak.stock_restricted_release_summary_em(
start_date="20250101",
end_date="20250630"
)
print(df_summary.head())
3.4 拿到原始数据后的三步清洗
接口返回的数据不是拿来就能直接入库的,我一般做三步清洗。
第一步,统一日期格式。stock_lift_reset_em返回的日期列很可能是字符串,形如2025-01-10 00:00:00。需要转成真正的日期类型:
python复制df_lift["解禁日期"] = pd.to_datetime(df_lift["解禁日期"])
第二步,检查数值列的类型。解禁股份数量、解禁市值这些列,有的版本返回的是纯粹的数字,有的版本会带着“亿”“万”之类的单位后缀,还有的可能是字符串类型的科学计数法。这一步最稳妥的做法是先把非数值字符清洗掉,再统一转成float:
python复制df_lift["解禁市值"] = (
df_lift["解禁市值"]
.astype(str)
.str.replace("亿", "", regex=False)
.astype(float)
)
在这里我需要多说一句:如果你发现列名里有“亿”“万”后缀,通常说明这一列是以亿股或亿元为单位;如果没有后缀,那可能就是原始股数,单位是股。这个判断很重要,直接决定了你后续计算是否正确。
第三步,去重。同一个日期段拉两次,或者接口分页导致部分数据重复,都会出现重复行。用以下代码一键去掉完全重复的记录:
python复制df_lift = df_lift.drop_duplicates()
清洗完成后,再统计一下总行数、日期范围、按日期分组的总解禁市值,看数据和东方财富网页上显示的数字能否对上,能对上就说明这轮抓取基本是成功的。
4. 手写请求:直接解析东方财富底层接口
4.1 一个请求地址的拆解
akshare封装得再好,本质上还是在调用东方财富的接口。当你需要定制化抓取或者遇到akshare还没跟上的情况,自己写请求反而是最可控的方案。
打开东方财富的“解禁时间表”页面,按F12进入开发者工具,切到Network面板,刷新页面,就能看到一条名为data/v1/get的请求,返回格式是JSON。完整URL大致长这样:
text复制https://datacenter-web.eastmoney.com/api/data/v1/get?
reportName=RPT_LIFT_STAGE
&columns=ALL
&filter=(STAGE_DATE_BEGIN='2025-01-01')(STAGE_DATE_END='2025-06-30')
&pageNumber=1
&pageSize=500
&sortTypes=1
&sortColumns=STAGE_DATE
&source=WEB
&client=WEB
我来把关键参数拆开讲一下。
reportName 是报表名称,RPT_LIFT_STAGE对应解禁时间表,RPT_LIFT_STAGE_DETAIL对应解禁详情,RPT_LIFT_STAGE_SUMMARY对应解禁汇总。filter 是筛选条件,字段名和值都放在一对对括号里。sortTypes=1表示升序,sortColumns=STAGE_DATE表示按解禁日期排序。pageNumber和pageSize控制分页,我测试下来pageSize最大可以设到500,再大会被接口拒绝。
4.2 从JSON到DataFrame的落地代码
我直接用requests库写一个最小可用的抓取函数:
python复制import requests
import pandas as pd
def fetch_lift_stage(begin="2025-01-01", end="2025-06-30", page_size=500):
url = "https://datacenter-web.eastmoney.com/api/data/v1/get"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://data.eastmoney.com/",
}
all_rows = []
page = 1
while True:
params = {
"reportName": "RPT_LIFT_STAGE",
"columns": "ALL",
"filter": f"(STAGE_DATE_BEGIN='{begin}')(STAGE_DATE_END='{end}')",
"pageNumber": page,
"pageSize": page_size,
"sortTypes": 1,
"sortColumns": "STAGE_DATE",
"source": "WEB",
"client": "WEB",
}
resp = requests.get(url, params=params, headers=headers, timeout=15)
resp.raise_for_status()
data = resp.json()
rows = data.get("result", {}).get("data", []) or []
if not rows:
break
all_rows.extend(rows)
pages = data.get("result", {}).get("pages", 1)
if page >= pages:
break
page += 1
return pd.DataFrame(all_rows)
df = fetch_lift_stage("2025-01-01", "2025-06-30")
print(df.shape)
print(df.columns.tolist())
这里有几个关键细节要说明。
首先,Referer头必须带上。东方财富的数据中心接口会校验来源,不带Referer或者Referer不对,返回结果可能会是空数据或者直接报错。
其次,分页逻辑我用的是result.pages字段,也就是接口返回的总页数。判断结束条件时,不要只看本次返回的行数是否为0,因为有些接口最后几页返回空列表但页码还没到,容易死循环。以pages字段为准是最稳的。
最后,columns=ALL的意思是让接口返回全部字段。相比手动指定字段,ALL的好处是接口新增字段时你的脚本不会漏数据,坏处是返回的数据量会大一些。个人研究场景下这点体积完全不是问题。
4.3 封装成自己的股票数据API查询函数
抓取函数有了,再往前一步就是把它封装成自己的“股票数据API”,方便重复调用和后续扩展。
我的习惯是写一个统一的解禁数据客户端,按报表类型分发到不同的抓取逻辑:
python复制class LiftDataClient:
BASE_URL = "https://datacenter-web.eastmoney.com/api/data/v1/get"
HEADERS = {
"User-Agent": "Mozilla/5.0",
"Referer": "https://data.eastmoney.com/",
}
def _fetch(self, report_name, begin, end, extra_filter=""):
page, rows = 1, []
while True:
filter_str = f"(STAGE_DATE_BEGIN='{begin}')(STAGE_DATE_END='{end}'){extra_filter}"
params = {
"reportName": report_name,
"columns": "ALL",
"filter": filter_str,
"pageNumber": page,
"pageSize": 500,
"sortTypes": 1,
"sortColumns": "STAGE_DATE",
"source": "WEB",
"client": "WEB",
}
resp = requests.get(self.BASE_URL, params=params, headers=self.HEADERS, timeout=15)
result = resp.json().get("result") or {}
batch = result.get("data") or []
rows.extend(batch)
if page >= result.get("pages", 1):
break
page += 1
return pd.DataFrame(rows)
def schedule(self, begin, end):
return self._fetch("RPT_LIFT_STAGE", begin, end)
def detail(self, begin, end):
return self._fetch("RPT_LIFT_STAGE_DETAIL", begin, end)
def summary(self, begin, end):
return self._fetch("RPT_LIFT_STAGE_SUMMARY", begin, end)
client = LiftDataClient()
df_my_lift = client.schedule("2025-01-01", "2025-06-30")
封装完之后,每天拉数据就变成一行代码的事情。而且这个类可以继续扩展,比如增加复权行情接口、资金流接口,逐步沉淀成自己独有的数据工具库。
5. 把解禁数据变成可用的本地资产
5.1 存储设计:一张表就够了
数据拉下来之后,最重要的是存起来。个人研究场景我不建议一上来就上MySQL、PostgreSQL,SQLite就足够了,轻量、无服务、单文件。
建表语句可以参考下面的结构:
sql复制CREATE TABLE IF NOT EXISTS lift_stage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT,
lift_date TEXT NOT NULL,
lift_type TEXT,
shares REAL,
market_value REAL,
ratio_total REAL,
ratio_float REAL,
update_time TEXT DEFAULT (datetime('now', 'localtime')),
UNIQUE(code, lift_date, lift_type)
);
字段含义对应解禁计划的核心要素:shares是解禁股份数量,单位统一用“万股”或“股”二选一;market_value是解禁市值,单位统一用“万元”或“元”;ratio_total是解禁股份占总股本比例,ratio_float是占流通股本比例。
加UNIQUE(code, lift_date, lift_type)唯一约束的目的,是防止同一条解禁计划被重复写入。同一只股票同一个解禁日可能有不同类型的解禁,所以唯一键要带上lift_type,否则会错杀数据。
5.2 更新策略与调度时机
解禁计划是动态变化的。公司可能会因为分红送转调整解禁数量,也可能会因为承诺延期而改变解禁日期,偶尔还会有提前解禁的特殊情况。这意味着你昨天拉的数据,过两周可能就不准了。
基于这个特性,我的更新策略是:每次全量覆盖当天的“未来一年”窗口,而不是只增量追加。因为解禁计划表格本身不大,全市场未来一年也就几千行,全量拉取一次不过几秒钟,覆盖更新最省心。
调度频率上,我建议每周一早上更新一次,原因有两个。第一,交易所和上市公司通常在上周五收盘后到周末之间密集披露解禁相关公告,周一早上拉到的数据是最新的。第二,解禁计划本身不会像行情一样每分钟变动,每天拉一次纯属浪费,每周一次刚刚好。
调度工具用系统自带的就够了。Linux和Mac用cron,Windows用任务计划程序。写一个简单的shell脚本或者直接用Python脚本配合apscheduler:
python复制from apscheduler.schedulers.blocking import BlockingScheduler
def job_update_lift_data():
df = fetch_lift_stage()
save_to_sqlite(df)
scheduler = BlockingScheduler()
scheduler.add_job(job_update_lift_data, "cron", day_of_week="mon", hour=8, minute=0)
scheduler.start()
5.3 验证数据的完整性
写完更新流程,别急着跑,先做一次数据完整性验证。我常用的验证方法有三种。
第一种,总数核对。拉取上周五的数据和东财网页对照,全市场解禁条数应该一致。第二种,抽样核对。随机挑三只股票,解禁日期、解禁市值和解禁类型手工比对。第三种,时间跨度检查。确认数据覆盖到未来至少一年,而且不存在中间月份缺失的情况。
这里有个很实用的技巧:用df.groupby("解禁日期")["解禁市值"].sum()按日汇总,然后画个时间序列图。如果某个日期附近出现异常尖峰,多半是有一笔超大额解禁,这恰恰是最需要关注的风险点。如果某个月份整体明显偏低,就要怀疑是不是抓取漏了数据。
6. 抓取解禁数据时踩过的坑
6.1 API 529:别急着重试
很多人在抓东方财富接口时遇到过报错:api error: 529 overloaded. this is a server-side issue, usually temporary。我第一次遇到时也慌了一下,以为接口被封了。
这个529不是封IP,是服务端过载的临时错误,通常几秒到几十秒就恢复了。正确的应对方式是:捕获异常后等待一段时间再重试,重试次数控制在3到5次,每次等待时间递增。
python复制import time
for attempt in range(5):
try:
df = fetch_lift_stage("2025-01-01", "2025-06-30")
break
except Exception as e:
print(f"attempt {attempt + 1} failed: {e}")
time.sleep(2 ** attempt + 1)
这里要注意,千万不要在循环里无脑快速重试。服务端过载时,你重试越频繁,越容易触发限流,反而把临时错误变成持久封禁。我踩过一次连续重试导致IP被暂时限制的教训,之后都改成指数退避,再没出过问题。
6.2 单位不一致:万股与股的换算
这是数据清洗环节最容易翻车的地方。akshare不同接口、不同版本返回的解禁股份数量单位可能不一样。有的版本直接返回股数,比如“123456789”代表一亿多股;有的版本返回万股,比如“12345.6789”代表一亿多股。
我的处理原则是:统一到“原始股数”再入库。具体操作是拉回数据后先看数量级,然后用一个简单的判断做转换:
python复制def normalize_shares(series):
# 如果均值小于1亿,大概率已经是股数,直接用
if series.mean() < 1e8:
return series.astype(float)
# 否则可能是万股,转成股
return series.astype(float) * 10000
这个方法不完全严谨,但对于解禁数据这种数量级差距明显的场景,已经足够可靠。更稳妥的做法还是每轮清洗后人工抽查几行,和网页上的数字比对一次。
6.3 解禁市值是会说谎的
解禁市值不是一个固定的公告值,它是“解禁股份数量”乘以“当前股价”的动态估算。股价每天在变,解禁市值就每天都在变。很多人拿着一个月前拉的数据说“某某股票下个月解禁市值300亿”,其实股价跌了半个月,真实市值可能只剩200亿了。
这意味着两件事。第一,如果你要基于解禁市值做量化分析,尽量用最新数据,不要在本地存一个月前的market_value。第二,统计全市场解禁压力时,需要同步拉取最新行情,用实时股价重新计算市值,而不是直接加总旧数据里的市值字段。
我自己现在处理时,会把历史解禁数据里的市值字段当作“参照值”,真正计算压力指标时会重新用接口返回的最新值覆盖。
6.4 接口字段变了怎么办
东方财富的数据中心接口偶尔会调整字段名或字段含义。比如我遇到过某个版本把FREE_SHARES改名成ACTUAL_FREE_SHARES,再比如某些字段从数字类型变成了字符串类型。
应对这种变化,只有一条经验:代码里永远不要写死字段名。在抓取函数里保留原始列名,在清洗环节做一次列名映射,这样接口改字段时只需要改映射关系,不用改业务逻辑。
python复制COLUMN_MAP = {
"SECURITY_CODE": "code",
"SECURITY_NAME_ABBR": "name",
"STAGE_DATE": "lift_date",
"FREE_SHARES": "shares",
"FREE_CAPITAL": "market_value",
}
df_clean = df_raw.rename(columns=COLUMN_MAP)
如果遇到完全没有见过的字段名,先用print(df_raw.columns.tolist())把字段打出来,再决定映射。
6.5 解禁数据与股价的“非必然”关系
最后说一个认知层面的坑。很多初学者拿到解禁数据后,会直接把“解禁”等同于“下跌”,然后机械地做空或避开所有有解禁的股票。实盘里完全不是这么回事。
解禁股票是否下跌,取决于三个因素:解禁主体的性质(控股股东还是财务投资者)、解禁发生时的股价位置(高位还是低位)、以及市场当时的整体情绪。我见过不少解禁落地后反而上涨的股票,因为市场提前消化了抛压预期,真正到了解禁日,利空出尽变利好。
所以,解禁限售数据在策略里更适合作为风险因子和中长期的事件观察窗口,而不是短期的买卖信号。它是工具箱里的一把尺子,量的是潜在供需,量不出买卖时点。
我自己现在跑策略前,一定会先过一遍未来三个月的解禁时间表,不是为了找买点,而是为了躲开那些“明明基本面没问题、却因为在错误时间碰到大额解禁而被错杀”的票。数据本身是中性的,真正值钱的,是你拿到数据之后对它的解读方式和组合打法。
