简介:一个基于Python和Tushare的财务指标选股实战包,专为金融从业者、量化投资爱好者以及有Python基础的数据分析人员设计,用于解决从A股市场批量提取财务数据、计算关键财务指标并筛选潜力股票的问题。资源覆盖股票列表获取、财务报告读取、市盈率与净利润增长率计算、自定义条件选股的完整流程,毋需逐个翻阅上市公司财报,即可快速缩小跟踪范围。压缩包内共2个文件,包括1个Python选股脚本和1个TXT程序说明文档,脚本内部实现了上述选股逻辑,说明文档则对每一步代码和参数含义做了详细注释,整个包仅1KB,轻量且便于直接阅读和二次开发。已有1567人参与学习或下载,代码结构清晰,替换Tushare token后即可运行,适合用作业财选股策略入门及量化框架搭建的参考。读者还可举一反三,在现有指标基础上扩展ROE、PEG、营收增速等因子,灵活调整筛选条件,从而建立个性化的财务指标选股模型。
1. 为什么用 Python 做财务指标选股,而不是 Excel 或同花顺条件选股
如果你只是想在周末筛一批市盈率低于 20 的股票,同花顺问财或者东方财富的选股器 30 秒就能搞定,没必要写代码。但当你需要把「ROE 连续三年大于 15%、营收增速大于 20%、且剔除商誉占净资产比过高的公司」这种复合条件做成一个每周自动跑一遍的流程时,图形化工具的劣势就出来了:条件写死、逻辑不透明、没法回溯历史调参。这时候用 Python 加 tushare 拉财务数据,自己写筛选脚本,等于把选股逻辑变成了可版本管理、可回测、可复现的工程。
本文要拆的这份「财务指标选股 Python 源码」正好覆盖了这条链路:从 tushare 拿股票列表和财务报告,计算 PE、净利润增速、ROE 等指标,再按条件过滤出候选股票。适合两类人:一是刚开始接触量化选股、想搞明白财务数据怎么和行情数据对齐的初学者;二是已经在用现成选股器、但觉得阈值不可控、想自己掌控筛选逻辑的从业者。下面的内容会直接落到选股.py里你会遇到的真实问题——接口怎么调、字段怎么对齐、指标怎么算才不会踩到「用的财报还没披露」这种坑。
2. tushare 数据接口选型与股票池构建
2.1 接口不是平替:stock_basic、daily_basic、fina_indicator 的分工
tushare 的 pro 版本接口很多,但做财务选股真正高频用到的就三个:stock_basic拿股票列表,daily_basic拿每日估值指标(PE、PB、总市值),fina_indicator拿财务指标(ROE、净利润增长率、毛利率)。很多人第一次写的时候容易混淆:income是原始利润表,字段是n_income_attr_p这种会计科目;而fina_indicator是已经帮你算好的指标,比如roe、or_yoy、netprofit_yoy,省去了自己手工算环比和同比的麻烦。
import tushare as ts ts.set_token('你的token') # 在 tushare.pro 个人主页复制 pro = ts.pro_api() # 获取当前正常上市的 A 股列表,排除退市和暂停上市 stock_list = pro.stock_basic(exchange='', list_status='L', fields='ts_code,name,industry,market,list_date') print(stock_list.shape) print(stock_list.head(3))代码里list_status='L'表示只取上市状态正常的股票;fields显式指定字段可以省流量,跑全市场时这一项影响速度。ts_code是 tushare 的股票唯一编码,后面所有接口都用它关联数据,注意 A 股的格式是000001.SZ这样的后缀,和你在行情软件里看到的裸代码不一样,很多新手在这里踩了第一个坑。
2.2 估值指标与财务指标怎么对齐到同一天
选股策略里经常需要「某天的 PE」和「该季度财报的 ROE」同时出现。daily_basic是日频数据,每天都有;fina_indicator是季频数据,只有财报发布日才有。对齐的要点是:只能用财报的ann_date(公告日期)去匹配,不能用end_date(报告期截止日)。因为 3 季报可能在 10 月底才披露,如果你用end_date去对齐 9 月 30 日的行情,等于用了未来数据。
# 以贵州茅台为例,拿最近一天的估值数据 df_basic = pro.daily_basic(ts_code='600519.SH', start_date='20240101', end_date='20240630', fields='ts_code,trade_date,pe,pb,total_mv,turnover_rate') # 拿 2023 年报和 2024 一季报的财务指标 df_fina = pro.fina_indicator(ts_code='600519.SH', start_date='20230101', end_date='20240630', fields='ts_code,ann_date,end_date,roe,netprofit_yoy,or_yoy')trade_date和ann_date都是YYYYMMDD格式的字符串,对齐时直接比较大小即可。日常写策略我会建议以daily_basic的交易日为主表,把满足ann_date <= trade_date的最新一条fina_indicator横向拼接过去。这种方法在选股.py里通常会被封装成一个merge_finance_with_daily()函数,核心逻辑就是排序后去重取最近一期。
2.3 拉全市场数据的节奏控制
全市场 5000 多只股票,如果逐只调用fina_indicator,会触发 tushare 的每分钟接口调用频率限制。积分配额低的账号每分钟只能调 50 次左右,逐只拉 5000 次要 100 分钟,完全不可行。所以正确的顺序是:先用fina_indicator按报告期整体拉一次全市场数据,再在本地做过滤,而不是先循环股票再去拉财务。
# 推荐做法:按报告期批量拉全市场财务指标 df_all_fina = pro.fina_indicator(period='20240331', fields='ts_code,ann_date,end_date,roe,netprofit_yoy,or_yoy,grossprofit_margin') df_all_basic = pro.daily_basic(trade_date='20240630', fields='ts_code,trade_date,pe,pb,total_mv')按period和trade_date各调一次接口,得到两张宽表,后续所有筛选都在内存里做,一分钟内就能跑完。这个思路是财务选股脚本性能优化里最值得记住的一点:先按维度批量拉,再在本地连接,而不是在循环里反复请求接口。
3. 财务指标的计算逻辑与选股.py 核心实现
3.1 哪些指标直接取数,哪些必须自己算
tushare 的fina_indicator已经覆盖了大多数常用指标,但实际写策略时会发现两类情况:一类是接口直接给,比如roe(净资产收益率)、netprofit_yoy(归母净利润同比增长率)、or_yoy(营业收入同比增长率);另一类是接口不直接给、需要自己组合的,比如 PEG 需要 PE 除以净利润增速,或者「连续三年 ROE 大于 15%」需要把历史多期的roe字段做时间序列判断。
# 计算 PEG:pe 来自 daily_basic,netprofit_yoy 来自 fina_indicator def calc_peg(row): if row['netprofit_yoy'] is None or row['netprofit_yoy'] <= 0: return None return round(row['pe'] / row['netprofit_yoy'], 2) df_merged['peg'] = df_merged.apply(calc_peg, axis=1)calc_peg里先判断netprofit_yoy是否大于 0,因为负增长时 PEG 没有意义,返回None的股票会在后续筛选里被排除。这个处理的细节值得留意:很多新手直接相除,结果跑出一堆负 PEG 被当成低估值买点,实际上是净利润大幅下滑的困境股。apply按行调用函数,全市场 5000 行数据耗时不到 1 秒,性能上没有压力。
3.2 用财务数据过滤次新股和垃圾标的
拿到原始数据后,第一步不是算指标,而是清洗。次新股上市时间短,历史财务数据不完整,容易被策略误选;ST 股数据波动大,存在退市风险,一般策略直接排除。这些过滤条件应该写在指标计算之前,避免后面白白计算。
# 过滤:剔除 ST、上市不足 3 年、市值小于 50 亿的标的 import datetime def build_pool(df): today = datetime.date.today() df['list_days'] = (today - pd.to_datetime(df['list_date'])).dt.days df = df[~df['name'].str.contains('ST|退', na=False)] # 剔除 ST 和退市整理 df = df[df['list_days'] > 365 * 3] # 上市满 3 年 df = df[df['total_mv'] > 500000] # 总市值大于 50 亿(单位:万元) return dftotal_mv的单位是万元,所以 50 亿市值的判断条件是> 500000。很多资料上写total_mv直接和数字比较,不提单位换算,导致筛出来的结果全是巨无霸或者全被过滤掉,这是实战里最容易忽略的单位问题。dt.days返回的是整数天数,和 365 比较时注意list_date要先转成datetime类型。
3.3 多财务指标打分:从过滤到排名的演化
简单过滤只能告诉你「哪些股票符合条件」,但如果符合条件的股票有 300 只,你还需要一个排序机制。常见做法是用多个财务指标做打分,比如 ROE 排名前 20% 得 5 分、净利润增速排名前 20% 得 5 分,最后按总分排序取前 50 只。
# 对 ROE 和净利润增速做分位数打分 def add_scores(df): df['roe_score'] = pd.qcut(df['roe'].rank(method='first'), 5, labels=[1, 2, 3, 4, 5]) df['growth_score'] = pd.qcut(df['netprofit_yoy'].rank(method='first'), 5, labels=[1, 2, 3, 4, 5]) df['total_score'] = df['roe_score'].astype(int) + df['growth_score'].astype(int) return df.sort_values('total_score', ascending=False)pd.qcut做的是等频分箱,也就是按排名分成 5 组,每组 20% 的股票。使用rank(method='first')是为了处理同值情况,避免qcut因为边界重复而报错。astype(int)是因为qcut默认返回类别类型,不能直接相加。这个方法适合对同行业内股票做相对比较——财务指标在不同行业之间差异极大,银行股 PE 天然比科技股低,跨行业直接比 PE 阈值是不合理的,而分位数打分天然避免了这个问题。
4. 从单一阈值到组合策略:选股条件的实际参数怎么定
4.1 筛选条件拆解:净利润质量比增速更重要
很多初版策略习惯用「净利润增长率 > 30%」做核心筛选,但这类股票往往是因为上一年基数太低,或者靠非经常性损益撑起了报表。真正财务选股做得久的,普遍会用「扣非净利润增速」而不是「净利润增速」,同时要求「经营现金流净额大于净利润」,这两条可以从数据源上过滤掉相当一部分财务质量存疑的公司。
| 指标 | 来源接口 | 推荐筛选条件 | 说明 |
|---|---|---|---|
| 扣非净利润同比增长率 | fina_indicator.netprofit_yoy | > 15% | 剔除一次性收益干扰 |
| ROE(加权) | fina_indicator.roe | > 12% | 连续 3 期均满足可加分 |
| 经营现金流净额/净利润 | cashflow自行计算 | > 0.8 | 利润要有现金支撑 |
| 毛利率 | fina_indicator.grossprofit_margin | > 25% | 行业不同需调整 |
| 资产负债率 | balancesheet自行计算 | < 65% | 排除高杠杆企业 |
4.2 完整筛选代码:参数化让你一周调一次仓不费劲
实际运行时,选股条件会被封装成一个配置字典,而不是散落在代码里的魔法数字。这样调参只需要改文件头部的参数表,不需要动主逻辑。这个思路在选股.py里体现为CONFIG和run_select()的分离。
# 选股配置参数表 CONFIG = { 'start_date': '20240101', # 回看区间起点 'end_date': '20240630', # 回看区间终点 'min_roe': 12.0, # ROE 最低值(%) 'min_growth': 15.0, # 扣非净利润增速最低值(%) 'max_pe': 40.0, # PE 上限,超过则排除 'min_mv': 500000.0, # 最小总市值(万元) 'min_gross_margin': 25.0, # 毛利率最低值(%) 'max_debt_ratio': 65.0, # 资产负债率最高值(%) 'stk_num': 50 # 最终输出的股票数量 } def run_select(): # 1. 构建股票池 stock_list = pro.stock_basic(list_status='L', fields='ts_code,name,list_date') df_basic = pro.daily_basic(trade_date=CONFIG['end_date'], fields='ts_code,pe,pb,total_mv,turnover_rate') df_fina = pro.fina_indicator(period=CONFIG['end_date'], fields='ts_code,roe,netprofit_yoy,grossprofit_margin') # 2. 合并数据 df = stock_list.merge(df_basic, on='ts_code', how='inner') df = df.merge(df_fina, on='ts_code', how='inner') # 3. 应用筛选条件 df = df[(df['roe'] >= CONFIG['min_roe']) & (df['netprofit_yoy'] >= CONFIG['min_growth']) & (df['pe'] <= CONFIG['max_pe']) & (df['total_mv'] >= CONFIG['min_mv']) & (df['grossprofit_margin'] >= CONFIG['min_gross_margin'])] # 4. 按 ROE 降序、PE 升序综合排序 df = df.sort_values(['roe', 'pe'], ascending=[False, True]) return df.head(CONFIG['stk_num'])主流程分三步:取数、合并、过滤排序。合并用的how='inner',意思是三张表中都存在的股票才会保留,任何一张表缺数据都会被剔除。排序用ascending=[False, True]表示 ROE 从高到低、PE 从低到高,这样处理之后前 50 只大致是「高 ROE + 低估值」的集合。实际运行时建议在合并后打印一下df.shape,如果行数低于 1000,大概率是trade_date或period选到了一个没有数据的日期。
4.3 参数调整的行业差异陷阱
固定阈值最大的问题在于它不区分行业。同样是 ROE,白酒行业的龙头常年高于 25%,而公用事业类的公司达到 10% 就算优秀;同样看 PE,银行股在 5 倍左右波动,半导体公司 50 倍也常见。如果你把「ROE > 15%」和「PE < 30」同时作为硬性条件,筛选结果会高度集中于消费和医药板块,而把周期股和金融股全部排除。
一个可行的调整方式是引入行业中性化:先按industry分组,然后在行业内做分位数筛选。比如要求 ROE 在所处行业中排名前 30%,代替绝对阈值min_roe。这样选的逻辑不再依赖你对每个行业的主观判断,而是假设「好公司是同行比出来的,而不是靠统一分数线比出来的」。
注意:
stock_basic里的industry字段用的是申万行业分类,粒度较粗。如果你做精细化策略,可以用 tushare 的index_classify拿更细的行业分类,再和stock_basic关联。
5. 跑批验证与数据质量检查:一个值得照抄的排错方案
5.1 先检查数据行数,再谈选股结果
选股脚本跑完,如果结果和你预期差别很大,不要急着调阈值,先检查三个地方:原始数据行数是不是合理、merge之后是不是丢了很多股票、以及财务指标字段是不是大量为空。tushare 的免费积分等级下,fina_indicator部分历史字段可能没有权限,返回的全是NaN,这时候筛选结果为空不是因为策略差,而是数据源本身缺字段。
# 数据质量自检函数 def check_data_quality(df, df_name): total = len(df) non_null_rate = df.notna().mean().round(3) print(f"{df_name}: 总行数 {total},关键字段非空率:") print(non_null_rate) # 使用示例 check_data_quality(df_fina, 'fina_indicator')逻辑说明:notna()返回布尔矩阵,mean()计算每列非空值的占比,round(3)保留三位小数。如果roe的非空率低于 0.9,说明这期报告期拉下来的数据本身覆盖不全,应该考虑换period或者检查 token 权限。
5.2 用公告日期回验:防止未来函数
财务选股最容易犯的错误是「用了未来数据」。比如现在是 2024 年 8 月,但 2024 年半年报还没披露完,如果你直接按period='20240630'拉数据,tushare 可能返回空,或者只返回部分已披露公司的数据。这时候应该改用ann_date区间拉取,保证你拿到的都是已经真实公告的数据。
# 以公告日拉取更稳妥:end_date 表示公告日期 df_fina_safe = pro.fina_indicator(ann_date='20240830', fields='ts_code,end_date,ann_date,roe,netprofit_yoy')这种写法只拉在 2024 年 8 月 30 日当天公告过财报的公司,用于回测时能精确对齐历史调仓时间点。对比另一段代码里用period的方式,ann_date方式能彻底避免数据穿越:你在 2024 年 9 月 1 日回测时,永远不可能用到 9 月 2 日才披露的财报。
5.3 跑批上手即用的校验模板:把检查写成函数
实际项目里,数据质量检查这步值得做成固定函数,每周跑批时自动执行。下面这个模板检查了空值率、重复值、时间戳合理性三个维度,任何一项异常都会打印警告,不会闷头跑出一份漂亮但实际上不可用的选股结果。
def validate_dataset(df, unique_col, date_col): # 1. 重复值检查 if df[unique_col].duplicated().any(): dup = df[unique_col].duplicated().sum() print(f"[警告] 存在 {dup} 条重复记录") # 2. 日期范围检查 if date_col in df.columns: print(f"[信息] 日期范围: {df[date_col].min()} ~ {df[date_col].max()}") # 3. 关键字段空值率检查 key_fields = ['roe', 'pe', 'total_mv'] missing_df = df[key_fields].isnull().mean() if (missing_df > 0.2).any(): print(f"[警告] 以下字段空值率超过 20%:\n{missing_df[missing_df > 0.2]}")参数说明:unique_col传ts_code,用来检查是否有重复股票;date_col传trade_date,用来确认数据区间没有偏差。空值率阈值 0.2 是一个经验值,行情类数据一般不会有超过 20% 的缺失,如果出现就要去检查 tushare 的 token 权限是否覆盖了目标接口。这套模板复制到任何选股.py里都能直接用,跑批之前先validate_dataset,比对着选股结果猜原因高效得多。
本文还有配套的精品资源,点击获取