简介:Python-基于TuShare的A股自动选股程序是一份面向量化投资入门者的实战源码,围绕数据驱动选股这一场景,演示了如何利用TuShare接口获取A股历史行情与财务数据,进而完成数据清洗、指标计算、策略筛选与回测分析。项目代码组织清晰,既可作为课程设计参考,也能帮助开发者快速搭建自己的选股框架。资源以zip压缩包形式提供,共包含25个文件,其中17个Python脚本为主体,覆盖数据抓取、策略实现、工作流调度等环节;另附图片、配置说明与Markdown文档,便于理解项目结构与运行方式。压缩包整体仅1.95MB,轻量易部署。目前已有6021人学习下载,受到不少量化初学者的关注。通过阅读源码,可以掌握TuShare常见接口(如历史行情、实时报价)的用法,学习移动平均线、RSI、MACD等技术指标在自动选股中的应用,并借鉴其回测与策略评估思路,为后续独立开发量化策略打下基础。
1. 把“拍脑袋选股”变成“下班跑一遍脚本”:TuShare 自动选股是什么
做 A 股量化或者半量化的人,早晚会遇到同一个尴尬:复盘时想按 PE、ROE、换手率筛一遍全市场,打开行情软件一个个翻,翻到凌晨也翻不完。某开发者最早写这套基于 TuShare 的 A 股自动选股程序,就是因为某天想筛“市值 100 亿到 300 亿、ROE 连续三年大于 12%、最近 20 天涨幅不超过 15%”的票,手工操作到夜里一点半才筛完,还漏了两只。后来他把整套流程拆成了三个模块:数据拉取、因子计算、结果落盘,用 TuShare Pro 接口把全市场日线、财务指标、每日指标拉下来,再按照自己设定的规则批量打分排序,半小时跑完一次全市场扫描。这套程序适合两类人:一类是有 Python 基础、想把自己选股规则固化成代码的散户;另一类是做策略研究的初级量化从业者,拿它当数据管道和因子计算的脚手架。它解决的问题是“规则明确但重复繁琐的筛选工作”,不解决“策略一定会赚钱”——这一点在拆解之前先摆出来,免得后面白忙一场。
2. TuShare 环境搭建与数据准备:初始化 Token 到拉取全市场行情
2.1 为什么选 TuShare Pro 而不是免费行情接口
市面上能拿 A 股数据的 Python 库有好几个,但 TuShare Pro 在“免费额度 + 数据字段完整度”上比较均衡。免费行情接口常见的问题是:行情数据只有不复权或前复权,财务数据更新慢,每日指标字段缺失,最麻烦的是访问频率限制不透明——某开发者之前用一个免费接口,单日请求上限 500 次,跑一次全市场日线就用掉了一半额度,第二天直接卡死。TuShare Pro 注册后送积分,基础积分就能调 daily、daily_basic、fina_indicator 这类核心接口,虽然每分钟调用次数有限制,但按“单次拉全市场,本地缓存”的思路设计程序,不会撞上限。另外,TuShare 的数据字段命名规范,字段名和国内券商研报里的习惯叫法基本一致,比如 pe_ttm、pb、total_mv、turnover_rate,写策略的时候不太需要来回翻译。
选择 TuShare 还有一个隐性理由:它的数据是按交易日更新的,对“自动选股”这种定期跑批的需求来说,数据新鲜度够用。盘中实时行情它不完全擅长,但选股程序本来就是收盘后跑——用收盘数据筛规则,第二天开盘再看结果,这个节奏正好匹配。如果你做的是盘中高频交易,那 TuShare 不合适,那是另一套实时数据源和硬件方案的范畴,别拿选股程序去干这事。
2.2 注册、安装与 Token 初始化:搞定第一道门槛
TuShare 的使用流程分三步:注册账号、拿到 Token、在代码里初始化。注册在 TuShare 官网完成,注册后进入个人主页,找到“接口TOKEN”一栏,复制那一串 Token。安装库直接 pip 装:
pip install tushare装完之后,写一个初始化模块,供整个项目复用。某开发者的习惯是单独建一个ts_init.py,把 token 配置、pro 对象创建、数据拉取封装都放进去,避免每个脚本里重复写 token。
import tushare as ts import pandas as pd # 这里填你自己注册得到的 token 字符串 TOKEN = "你的token字符串" ts.set_token(TOKEN) pro = ts.pro_api() def get_pro(): """返回已初始化的 pro 接口对象""" return pro # 测试连接:拉取最近一个交易日的全市场每日指标 def test_connection(): df = pro.daily_basic(trade_date='20240115', fields='ts_code,pe_ttm,pb,total_mv') print(df.head()) return df if __name__ == '__main__': test_connection()这段代码做了三件事:设置全局 Token、创建 pro 接口对象、用一个读接口验证连通性。需要注意一点,ts.set_token()是设置本地缓存 token,ts.pro_api()是创建接口对象,两者缺一不可——只 set_token 不 pro_api,调用数据接口时会因为实例未初始化而抛异常。第一次跑测试连接时,如果返回空 DataFrame 或者报错“抱歉,您没有访问该接口的权限”,多半是积分不够,需要去 TuShare 官网看积分说明,或者把积分提升到对应接口要求档位。日常使用中,daily_basic这个接口的基础积分就能用,但高频调用会被限流,下面会细说。
2.3 拉取全市场股票列表与交易日历:先把“范围”定下来
自动选股的第一步不是选股,是确定“候选池”。A 股市场有沪深主板、创业板、科创板、北交所,不同板块的上市条件、涨跌幅限制都不一样。选股程序默认应该先拉一份全量股票列表,然后按自己的规则过滤,而不是把范围写死在某几个代码里。TuShare 的stock_basic接口干这个事:
def fetch_stock_basic(): pro = get_pro() # 获取全部上市公司基础信息 df = pro.stock_basic( exchange='', # 空字符串表示所有交易所 list_status='L', # L 上市状态,D 退市,P 暂停 fields='ts_code,name,industry,market,list_date' ) return df # 示例:只看创业板和科创板,且上市时间超过一年 df = fetch_stock_basic() df = df[df['market'].isin(['创业板', '科创板'])] df['list_date'] = pd.to_datetime(df['list_date']) df = df[df['list_date'] < '2023-01-01'] print(df.shape)这里有两个关键参数:list_status='L'只保留当前上市的公司,避免把退市股拉进来干扰数据;market字段用来区分板块。某开发者的经验是:新股前半年波动大、数据不稳定,而且很多因子在次新股上失真(比如上市首日无涨跌幅限制导致的异常换手率),所以回测和实盘筛选都应该加一个上市时间过滤条件。还有一个容易忽略的点——stock_basic返回的ts_code形如000001.SZ、600000.SH,这个格式是后续所有接口的主键,不要自己拼接替代,否则容易在日期匹配时出现字符串格式不一致的问题。
交易日历用trade_cal接口拉,它的作用是在定时任务里判断“今天是不是交易日”:
def get_latest_trade_date(): pro = get_pro() # 从 2024-01-01 到今天的交易日历 cal = pro.trade_cal( start_date='20240101', end_date='20241231', is_open='1' # 只取开市日期 ) cal['cal_date'] = pd.to_datetime(cal['cal_date']) latest = cal['cal_date'].max().strftime('%Y%m%d') return latest # 输出形如 '20241220' 的字符串,直接用于数据接口的 trade_date 参数2.4 批量拉取日线行情与每日指标:循环请求与本地缓存
候选池确定后,核心数据有两份:日线行情(OHLCV)和每日指标(PE、PB、换手率、总市值等)。TuShare 的daily接口按股票代码获取某段时间的日线,daily_basic接口按日期获取全市场每日指标。这里的选型有一个关键权衡:按股票一只只拉,还是按日期一天天拉?
某开发者踩过这个坑:daily接口支持单只股票的区间查询,也支持按 trade_date 查全市场。如果代码写成“遍历所有股票,每只拉一年日线”,3000 只票就要请求 3000 次,但 TuShare 每分钟请求次数限制通常在几百到一千以内,很快会撞限流。反过来,按交易日一天天拉全市场,一年 250 个交易日,只要 250 次请求,效率高一个数量级。所以正确做法是:先确定日期范围,再对每个交易日拉一次全市场行情,然后本地拼接成逐股的时间序列。
def fetch_daily_by_date(trade_date): """按交易日拉取全市场日线行情""" pro = get_pro() df = pro.daily(trade_date=trade_date) return df # 拉取最近 5 个交易日数据,按日期循环 trade_dates = ['20241216', '20241217', '20241218', '20241219', '20241220'] all_daily = pd.DataFrame() for d in trade_dates: try: day_df = fetch_daily_by_date(d) all_daily = pd.concat([all_daily, day_df], ignore_index=True) print(f'{d}: {day_df.shape[0]} 只股票') except Exception as e: print(f'{d} 拉取失败: {e}') # 失败先记录,不要中断整个循环 continue # 保存到本地 CSV,避免重复请求 all_daily.to_csv('./data/daily_data.csv', index=False)这段循环里的 try-except 是必须的。实际运行中某一天的请求可能因为网络波动或限流失败,如果直接中断循环,前面拉的数据全白费。保存 CSV 是本地的第一层缓存——同一个交易日数据在当日不会变化,重复跑选股程序时直接读 CSV,不用再请求接口。参数说明:daily接口如果只传 trade_date,返回该日全部上市公司的 OHLCV 数据;如果要拿单只股票的历史区间,需要传ts_code和start_date、end_date。两种调用方式对应不同场景,选股程序用第一种就够。
daily_basic的拉取逻辑完全一样,也是按 trade_date 拿全市场,但它带的是估值和交易类指标:
def fetch_daily_basic_by_date(trade_date): """按交易日获取全市场每日指标:PE/PB/市值/换手率""" pro = get_pro() df = pro.daily_basic( trade_date=trade_date, fields='ts_code,close,turnover_rate,pe_ttm,pb,total_mv' ) return df # 合并到主数据表 basic_df = fetch_daily_basic_by_date('20241220') daily_df = pd.read_csv('./data/daily_data.csv') # 按股票代码合并,让每行同时拥有行情和指标字段 merged = pd.merge( daily_df[daily_df['trade_date'] == '20241220'], basic_df, on='ts_code', how='left' )合并时用how='left',以日线行情为准,每日指标缺席的行保留 NaN——有些停牌股或者刚上市的公司可能没有完整指标,NaN 后续在因子计算时要专门处理,不能直接丢弃。total_mv的单位是万元,如果要换算成亿元记得除以 10000,这个单位坑很多新手栽过。
3. 设计选股规则与因子打分:从“条件过滤”到“排序打分”
3.1 把选股思路翻译成因子表达式:条件过滤的写法与边界
拿到数据之后,最直接的做法是“条件过滤”——把选股规则写成布尔表达式,筛出同时满足所有条件的股票。这是选股程序的第一层形态,也是容易实现、容易验证的一版。举个例子:某开发者的过滤规则是“总市值在 50 亿到 300 亿之间,PE-TTM 在 0 到 30 之间,最近 5 日涨幅不超过 10%”。
def apply_filters(df): """ 输入:合并后的行情 + 指标 DataFrame 输出:通过全部过滤条件的股票列表 """ df = df.copy() # 单位换算:total_mv 从万元转亿元 df['total_mv_yi'] = df['total_mv'] / 10000 # 条件 1:市值区间 cond_mv = (df['total_mv_yi'] >= 50) & (df['total_mv_yi'] <= 300) # 条件 2:PE-TTM 为正且不太高 cond_pe = (df['pe_ttm'] > 0) & (df['pe_ttm'] <= 30) # 条件 3:近 5 日涨幅过滤(交易日历取最近 5 个交易日) # 这里简化为用收盘价相对 5 日前收盘价的涨幅 df = df.sort_values(['ts_code', 'trade_date']).groupby('ts_code').apply( lambda x: x.assign( chg_5d=(x['close'] / x['close'].shift(5) - 1) * 100 ) ).reset_index(drop=True) cond_chg = (df['chg_5d'] <= 10) # 合并条件 result = df[cond_mv & cond_pe & cond_chg] return result[['ts_code', 'name', 'close', 'total_mv_yi', 'pe_ttm', 'chg_5d']]这段代码有三个细节值得注意。第一,groupby('ts_code').apply里面计算chg_5d时,shfit(5) 用的 5 是“5 个交易日”,不是自然日——这在 A 股很重要,因为中间可能有周末和节假日。第二,市值条件写在数据准备阶段之外,单独抽象成函数,方便以后调整边界值——50 亿到 300 亿只是一个示例阈值,你不一定认同,改参数就能换成自己的规则。第三,条件里pe_ttm > 0这一条看着多余,其实是为了过滤“亏损企业 PE 为负”的情况——很多新手直接把 PE 小于 30 写成pe_ttm < 30,结果把 PE 为负的公司也筛进来了,真的是血泪经验。
3.2 打分模型:把多个因子加权求和,输出可排序的分数
条件过滤的问题是“一刀切”:市值为 51 亿的股票和 299 亿的股票被同等对待,PE 为 5 和 PE 为 29 也没有区别。当规则超过 5 条时,过滤后剩下的股票经常只剩个位数,或者一个条件筛选不当直接筛空。更实用的做法是打分模型——每个因子给一个分项得分,然后按权重加权求和,输出一个总分用于排序。这样即使没有股票满足全部条件,你也能知道“最接近”的是哪些。
def score_stocks(df): """ 打分模型:市值、PE、换手率三个因子加权 每个因子先做分位数归一化,再按权重求和 """ df = df.copy() df['total_mv_yi'] = df['total_mv'] / 10000 # 分位数归一化:把原始值映射到 0~100 分 df['score_mv'] = 100 - (df['total_mv_yi'].rank(pct=True) * 100) # 市值越小分越高,逆序 df['score_pe'] = 100 - (df['pe_ttm'].rank(pct=True) * 100) # PE 越低分越高 df['score_turnover'] = df['turnover_rate'].rank(pct=True) * 100 # 换手率越高分越高(这个因子因策略而异) # 权重分配:市值 0.4,PE 0.4,换手率 0.2 df['total_score'] = ( df['score_mv'] * 0.4 + df['score_pe'] * 0.4 + df['score_turnover'] * 0.2 ) # 过滤掉 PE 为负的股票,避免负值在 rank 中产生扭曲 df = df[df['pe_ttm'] > 0] # 按分数降序排列 result = df.sort_values('total_score', ascending=False) return result[['ts_code', 'name', 'close', 'total_mv_yi', 'pe_ttm', 'turnover_rate', 'total_score']].head(50)打分模型的核心是“分位数归一化”这个思路。市值、PE、换手率三个因子的量纲完全不同——市值是亿元级别,PE 是几倍到几十倍,换手率是百分比——把它们直接相加没有意义。rank(pct=True)把每个因子映射到 0 到 1 的相对位置,乘 100 变成百分制,这样三个因子就“可加”了。权重的设置则是主观的部分:你更看重估值就加大 PE 权重,更看重流动性就加大换手率权重。某开发者常用的做法是先用等权重跑一遍,看结果里股票排名的分布,再根据实际输出调整权重——比如发现 PE 因子打出来全是银行股,就把 PE 权重调低;发现市值因子让小盘股扎堆,也调低权重。这套调参流程其实就是半量化最常见的“因子权重寻优”的简化版。
参数说明:这里的head(50)是输出前 50 名,这个数不是固定的,如果只想要 10 只候选,就改成 10。rank(pct=True)遇到重复值会取平均排名,不会报错。还有一点:打分前必须先过滤 PE 为负、市值为 0 的异常行,因为异常值会把分位数分布整体拉偏——一只 PE 为 500 倍的股票看起来是极端值,其实它只影响 0~100 的映射,但如果 PE 为负的不先剔除,rank会把负 PE 排在前面,打分结果直接翻车。
3.3 因子的时效性:用最新交易日还是一个区间
选股程序最常被质疑的一点是“数据滞后”。如果只拿最新一个交易日的daily_basic,那 PE、PB 都是当天的快照,反映的是“此刻的状态”;但选股有时候要看“趋势”,比如 ROE 连续三期改善、营收增速连续两季回升,这类因子必须拉多期财务数据才能算。TuShare 的fina_indicator接口就是干这个的:
def fetch_fina_indicator(ts_code, period='2024'): """ 获取单只股票的财务指标 period 参数传报告期,如 2024 表示 2024 年三季报 """ pro = get_pro() df = pro.fina_indicator( ts_code=ts_code, start_date='20240101', end_date='20241231', fields='ts_code,end_date,roe,netprofit_yoy,or_yoy' ) return df # 示例:拉取某只股票的财务指标 # 注意这里是按股票循环,控制频率 df_fin = fetch_fina_indicator('000001.SZ') # 检查 ROE 是否连续两个报告期持续为正 df_fin = df_fin.sort_values('end_date') roe_positive_count = (df_fin['roe'] > 0).sum()财务指标的接口和行情接口最大的不同:行情按日期全市场拉一次就行,财务指标必须按股票循环。因为财务数据是“每家公司一个报告期一条记录”,不存在“某一天全市场财报统一更新”的情况。这就带来一个实际问题:全市场 3000 只股票,每只拉一次fina_indicator,就是 3000 次请求,流量控制是个大问题。某开发者的折中方案是:先用行情和每日指标打分,选出前 50 名,再对这 50 只逐一拉财务指标做二次精选——把“全市场全因子扫描”变成“粗筛 + 精排”两段式,请求量从 3000 降到 50,完全够用。
netprofit_yoy(净利润同比增长率)和or_yoy(营业收入同比增长率)这两个字段,在fina_indicator里可以直接用,不需要自己用利润表计算增长。这省了很多事,但也埋了一个坑:这些字段是 TuShare 已经算好的,不同报告期之间的可比性依赖于它的口径,如果发现数据明显异常(比如某公司营收增速突然 1000%),大概率是报告期调整或者重组,要回到原始财报确认,不要直接采信。
4. 避坑指南:TuShare 自动选股最容易翻车的五个细节
4.1 Token 初始化失败:set_token 和 pro_api 的调用顺序
现象:代码运行到pro.daily()直接抛异常,提示Authentication failed或token 不存在。 原因:ts.set_token()和ts.pro_api()是两步——set_token只是把 token 存到本地配置文件,pro_api()才是创建带 token 的接口对象。如果只set_token不pro_api(),或者pro_api()在set_token之前调用,接口对象没有拿到认证信息。 解决:确保每次运行脚本时先set_token再pro_api(),并且初始化代码放在模块顶部,不要放在函数内部反复执行。某开发者习惯把这段初始化逻辑放进ts_init.py,其他脚本统一import,彻底避免忘了初始化。
4.2 行情数据拉下来但合并后大量 NaN:停牌股与新股干扰
现象:merge之后发现相当多股票的close为 NaN,或者daily_basic里部分行缺失pe_ttm。 原因:A 股存在停牌股——停牌期间无日线记录但每日指标可能有缓存值;另外次新股上市前五天不设涨跌幅,部分指标接口在上市初期数据不完整。 解决:合并之后加一步清理逻辑——dropna(subset=['close'])先去除无行情记录的行,再对pe_ttm和total_mv做阈值过滤而不是直接删除,比如total_mv为 NaN 说明当天没更新,可以保留但不算入打分。不要一来就dropna()全删,可能把刚上市但基本面很好的次新股一并删掉。
4.3 按股票循环拉数据撞上限流:一分钟内请求次数超限
现象:循环到第 200 只股票时,接口开始报错抱歉,您每分钟最多访问该接口X次。 原因:TuShare Pro 对每个接口有独立的每分钟请求上限,不同积分档位不同。在“按股票循环拉财务指标”的场景里,很容易无意识地把请求数打到上限。 解决:两种方案。一是把循环改成按日期拉全市场,减少请求次数(行情类接口适用);二是循环里加time.sleep(0.2)主动降速,让每分钟请求数落在限制之内。某开发者的实际经验是:即使积分够高,也建议把 sleep 加上——TuShare 的限流是滑动窗口,短时间突发的 10 请求也可能触发熔断,稳定优先级高于速度。
4.4 市值单位搞错:total_mv 是万元不是亿元
现象:过滤市值小于 300 亿的股票,结果出来的全是贵州茅台、工商银行这种超大盘权重股。 原因:daily_basic接口的total_mv字段单位是万元,不是亿。300 亿 = 3,000,000 万元,如果拿 300 和 total_mv 直接比较,等于在找市值 0.03 亿元的股票——显然不存在。 解决:读接口字段时留意官方文档的单位说明,total_mv拿到后统一除以 10000 得到亿元再参与计算。代码里把它改名为total_mv_yi,后续所有引用这个新字段,避免每次都要心里换算。
4.5 财务指标字段的“报告期”陷阱:end_date 不是发布日期
现象:用fina_indicator拉某股票的 ROE,发现最新数据停留在半年前。 原因:fina_indicator里end_date是报告期截止日,不是财报发布日期。A 股一年报披露截止到次年 4 月 30 日,半年报截止到 8 月 31 日,所以 3 月份看年度报告还没出全,最晚只能取到三季报数据,这是正常的。 解决:在选股程序里不要把“最新报告期”和“当前日期”混为一谈。处理方式是:动态获取最近一个已经完整披露的财报期——写死的话季度切换后会失效。一个简单做法是遍历最近 3 个季度的end_date,取日期上不超过当前且数据完整度最高的那个。另外,财报发布后还可能修订,真要精确到“实际可用”状态,需要用 TuShare 的公告或披露时间字段再二次校准,普通选股场景取最近报告期就够。
5. 把选股程序变成每天自动跑的工具:定时执行与结果验证
5.1 日程化执行:用脚本判断交易日并自动拉数
选股程序不能“手动点一下跑一次”,那和用行情软件手工筛选没本质区别。第一步是让脚本自己能判断“今天该不该跑”,并且自动把数据更新到最新。这里用到前面构建的交易日历逻辑:
import datetime def should_run_today(): """判断今天是否为交易日,以及是否已经收盘""" today = datetime.datetime.now() # 简单判断:周末不跑 if today.weekday() >= 5: return False # 用 TuShare 交易日历做二次确认(跳过节假日) cal = pro.trade_cal( start_date=today.strftime('%Y%m%d'), end_date=today.strftime('%Y%m%d'), is_open='1' ) if cal.empty: return False # 时间判断:默认 18:00 后跑,确保当日数据已经更新完毕 if today.hour < 18: print('今日数据可能未更新完,建议 18:00 后运行') return False return True这个should_run_today()里的两个判断是经验值:TuShare 的日线数据通常晚上才更新完毕,下午 5 点前拉经常拿到不完整的数据;早上 9 点前拉,前一天的数据可能还没入库。某开发者踩过“早上 8 点跑脚本拿到空数据”的坑之后,把执行时间硬性定为交易日 18:30——这个时间点当天所有数据肯定入库了,而且不耽误晚上复盘。代码里时间判断部分可以按你自己的作息改,但“尽量晚跑”这个原则别丢。
5.2 输出结果加时间戳:方便对比哪一天的选股结果
每次运行选股程序后,结果应该写入带日期标识的文件,而不是覆盖同一个文件。这个习惯能让后续“回看某天的选股结果到底准不准”成为可能——只有保留历史结果,你才能评估自己的选股规则是不是真的有效。
import os def save_result(df, output_dir='./output'): """保存选股结果,文件名带日期戳""" os.makedirs(output_dir, exist_ok=True) today = datetime.datetime.now().strftime('%Y%m%d') file_path = os.path.join(output_dir, f'stock_picks_{today}.csv') df.to_csv(file_path, index=False, encoding='utf-8-sig') print(f'结果已保存: {file_path}') # 保存时加一列生成时间,方便追溯 result_df['generate_time'] = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') save_result(result_df)这里用了utf-8-sig编码而不是默认的utf-8,是为了让 Excel 直接打开 CSV 不乱码——Windows 下的 Excel 默认用 GBK 解码,不加 BOM 头的中文 CSV 打开就是乱码,加了utf-8-sig就没这个问题。这个细节对于“把选股结果发给不写代码的搭档看”的场景非常有用,属于“自己省事不如让别人省事”的经验。
5.3 验证选股结果:拿历史数据试算一遍,别急着实盘
程序跑通之后,最忌讳的一件事是:第二天直接按选出来的 50 只票买入。某开发者的习惯是,先让程序跑两周的“纸面交易”——每天记录选股结果,手动记录每只票次日和五日后涨跌幅,两周后统计平均收益率。如果连纸面都跑不赢指数,说明选股因子本身不行,需要调;如果纸面有明显超额收益,再考虑拿小仓位试水。
这个“纸面验证”可以部分自动化:用 TuShare 拉选中股票次日的daily数据,计算实际涨跌幅,把结果追加到验证表里:
def verify_previous_picks(pick_date, hold_days=5): """ 验证某天选出的股票在 hold_days 交易日后的表现 pick_date: 选股日期字符串 'YYYYMMDD' """ # 读取历史选股结果 file_path = f'./output/stock_picks_{pick_date}.csv' if not os.path.exists(file_path): print(f'{pick_date} 无选股记录') return picks = pd.read_csv(file_path) # 获取 pick_date 之后的行情,计算每只股票持有期收益 pro = get_pro() results = [] for _, row in picks.head(20).iterrows(): # 用 trade_cal 推算出 pick_date 后第 hold_days 个交易日 cal = pro.trade_cal( start_date=pick_date, end_date=datetime.datetime.now().strftime('%Y%m%d'), is_open='1' ) if len(cal) < hold_days: continue target_date = cal.iloc[hold_days]['cal_date'] # 拉取该股票在目标日的收盘价 daily = pro.daily( ts_code=row['ts_code'], start_date=target_date, end_date=target_date ) if daily.empty: continue close_after = daily.iloc[0]['close'] close_before = row['close'] ret = (close_after / close_before - 1) * 100 results.append({'ts_code': row['ts_code'], 'ret_pct': ret}) result_df = pd.DataFrame(results) print(f'{pick_date} 选股 {len(result_df)} 只,平均收益: {result_df["ret_pct"].mean():.2f}%') return result_df这个验证函数有个细节:用trade_cal而不是直接用pick_date + hold_days推目标日,是为了自动跳过中间的非交易日——A 股休市日期比较多,直接加自然日算出的持仓天数大概率错。hold_days=5是“持有 5 个交易日”的意思,你可以改成 10 或 20 来观察不同持仓周期下选股策略的表现。
5.4 保持结果可解释:每次保存因子明细,不要只存代码
最后一条技巧,也是某开发者踩过最大的坑:早期版本只保存了选出的股票代码和总分,没有保存当时的因子明细。两周后发现某只股票总分排名第一,想回头看它当时 PE 是多少、换手率是多少,结果完全查不到——因为文件里只有一堆 6 位代码。从那以后,他每次保存结果都会把当时的市值、PE、换手率、5 日涨幅这些因子原样带上,宁多勿少。这样做的价值在于:当策略表现不好想调整权重时,你有一份完整的历史因子快照可以用来复盘,而不是只能对着股票代码拍脑袋猜。选股程序的核心价值不是“今天选出什么”,而是“为什么选出它、这个原因还成不成立”——把因子明细留给未来的自己,就是给决策留后悔药。希望这份拆解帮到你,下次跑完脚本拿到结果时,记得先问一句:这些票入选的原因,数据里还看得到吗?
本文还有配套的精品资源,点击获取