连载系列「Python量化实战」第 12 篇(模块六 · 量化策略实战)。上一篇《Python双均线金叉策略完整实现》做的是"择时",本篇转向量化的另一半:“选股”。
Python多因子选股是机构量化的主流框架,思路并不神秘:给每只股票的估值、质量、成长、动量等维度分别打分,加权合成总分后排序取前 N 只。难点在工程侧——PE、ROE、营收增速、动量分散在行情快照和财务报表两类接口里,字段格式不统一、缺失值处理麻烦。本文用 mairui SDK 一站式取齐 5 个核心因子的原始数据,完成z-score 标准化 → 方向调整 → 等权合成 → 排序输出的完整多因子选股模型,全部代码可直接运行。
本文你将得到什么
- 一套可直接运行的多因子框架:5 因子取数 →
to_num清洗 → z-score 标准化 + 方向调整 → 等权合成排序 - 真实打分输出(15 只样本 Top10)及"综合均衡才能排前"的逻辑解读
- 负 PE 陷阱、缺失值处理、行业偏斜等 6 个真实避坑点
一、环境准备
本文代码使用 mairui SDK 获取股票数据,安装方法如下:
pipinstallmairui运行环境:
- Python 3.9+
- mairui SDK 1.0.0
- pandas 2.2+ / numpy
importosimportmairui# 证书从环境变量读取,注册后可在官网获取api=mairui.Client("LICENCE-66D8-9F96-0C7F0FBCD073")# 演示证书,演示证书(读者复制即用,无需替换)本文数据截至 2026-07-27(行情为实时快照,财务为 2026 年一季报),读者复现时数值会随行情与财报更新而变化。
二、5 个核心因子与数据来源
| 因子 | 维度 | 字段来源 | 方向 |
|---|---|---|---|
| 市盈率 PE | 估值 | stock_ssjy→pe | 越低越好 |
| 市净率 PB | 估值 | stock_ssjy→sjl | 越低越好 |
| 净资产收益率 ROE | 质量 | stock_pershareindex→jzcsyl | 越高越好 |
| 营收同比增速 | 成长 | stock_pershareindex→zyyrsrzz | 越高越好 |
| 60日涨跌幅 | 动量 | stock_ssjy→zdf60 | 越高越好 |
两个接口分工明确:stock_ssjy一次返回估值与动量(pe市盈率、sjl市净率、zdf6060日涨跌幅),动量因子不用自己拉K线计算;stock_pershareindex返回历季财务主要指标,取最后一条即最新报告期。
importnumpyasnpimportpandasaspddefto_num(value):"""接口里缺失值以 '-' 返回,统一转成 NaN。"""try:returnfloat(value)except(TypeError,ValueError):returnnp.nandeffetch_factors(api,code,name):"""取单只股票的 5 个因子原始值。"""snap=api.stock_ssjy(code)# 实时快照:pe / sjl / zdf60snap=snap[0]ifisinstance(snap,list)elsesnap fin_rows=api.stock_pershareindex(code)# 历季财务指标,[-1] 为最新一期fin=fin_rows[-1]iffin_rowselse{}return{"code":code,"name":name,"pe":to_num(snap.get("pe")),"pb":to_num(snap.get("sjl")),"roe":to_num(fin.get("jzcsyl")),"rev_growth":to_num(fin.get("zyyrsrzz")),"momentum_60d":to_num(snap.get("zdf60")),}关键解释:
- 财务接口的缺失值是字符串
"-"而非 null,直接float()会抛异常,to_num统一兜底; - 最新报告期若是一季报,ROE 为单季未年化值——横截面比较时所有股票同期,可比性不受影响,但不要拿去和年度 ROE 混着看。
三、z-score 标准化与方向调整
不同因子量纲差异巨大(PE 几十、增速可能上百),直接相加会被大数值因子主导。标准做法是z-score:减均值除以标准差,把所有因子拉到同一尺度。估值因子取负号(越便宜分越高):
defzscore(series):"""z-score 标准化,NaN 记 0 分(中性,不奖不罚)。"""z=(series-series.mean(skipna=True))/series.std(skipna=True)returnz.fillna(0.0)defbuild_score(factor_df):"""5 因子打分:估值负向,其余正向,等权合成。"""# 亏损股 PE<=0 不具可比性,记 NaN 中性处理factor_df.loc[factor_df["pe"]<=0,"pe"]=np.nan factor_df["score_value_pe"]=-zscore(factor_df["pe"])factor_df["score_value_pb"]=-zscore(factor_df["pb"])factor_df["score_quality"]=zscore(factor_df["roe"])factor_df["score_growth"]=zscore(factor_df["rev_growth"])factor_df["score_momentum"]=zscore(factor_df["momentum_60d"])score_cols=["score_value_pe","score_value_pb","score_quality","score_growth","score_momentum"]factor_df["total_score"]=factor_df[score_cols].mean(axis=1)returnfactor_df.sort_values("total_score",ascending=False).reset_index(drop=True)关键解释:
- 亏损股 PE 为负,数值上"最低"但含义完全相反,必须剔除后中性化,这是多因子模型最常见的数据陷阱;
- NaN 填 0 分表示"该因子不奖不罚",比直接丢弃股票更稳健;
- 等权是最朴素的加权方式,进阶做法(IC 加权、行业中性化)在文末讨论。
四、完整实战示例与真实运行结果
示例用一个跨行业的 15 只大盘股固定池(便于复现;实际使用可替换为指数成分股,成分股列表可通过官网指数接口获取):
importtime STOCK_POOL={"600519":"贵州茅台","000858":"五粮液","601318":"中国平安","600036":"招商银行","601166":"兴业银行","000333":"美的集团","600900":"长江电力","601088":"中国神华","600030":"中信证券","000651":"格力电器","600276":"恒瑞医药","300750":"宁德时代","601899":"紫金矿业","600887":"伊利股份","601668":"中国建筑",}defmain():api=mairui.Client("LICENCE-66D8-9F96-0C7F0FBCD073")# 演示证书,演示证书(读者复制即用,无需替换)records=[]forcode,nameinSTOCK_POOL.items():records.append(fetch_factors(api,code,name))time.sleep(0.2)# 控制请求频率ranked_df=build_score(pd.DataFrame(records))cols=["code","name","pe","pb","roe","rev_growth","momentum_60d","total_score"]print(ranked_df[cols].head(10).round(2).to_string(index=False))ranked_df.to_csv("factor_scores.csv",index=False,encoding="utf-8-sig")main()真实运行输出(2026-07-27 盘中快照):
因子采集: 成功 15 只, 失败 0 只 === 多因子打分排序(Top 10)=== code name pe pb roe rev_growth momentum_60d total_score 600030 中信证券 10.15 1.45 3.12 40.91 4.05 0.52 600887 伊利股份 7.73 3.09 9.44 5.47 7.20 0.50 601899 紫金矿业 10.37 4.39 10.40 24.79 -3.87 0.34 601166 兴业银行 4.05 0.47 2.63 -1.06 3.51 0.22 000651 格力电器 9.26 1.48 4.08 3.46 4.68 0.19 601668 中国建筑 3.48 0.40 2.79 -7.85 2.41 0.15 000333 美的集团 12.59 3.09 5.56 2.45 9.12 0.15 600036 招商银行 6.47 0.89 2.96 3.81 0.67 0.14 300750 宁德时代 21.17 4.83 5.97 52.45 -7.36 0.00 000858 五粮液 8.87 2.42 6.50 33.67 -24.51 -0.07读一下这个排序的逻辑是否自洽:中信证券靠"低估值 + 40.91% 的高营收增速"登顶;宁德时代增速最高(52.45%)但估值最贵、动量为负,各因子互相抵消后总分归零——这正是多因子模型"不偏科"的特点:单一亮点不足以上榜,综合均衡才能排前。打分结果是数据的横截面快照,不是买入名单。
五、避坑与进阶
- 缺失值:财务字段的
"-"必须统一转 NaN,且 zscore 后填 0 中性化,不要直接dropna丢股票; - 负 PE 陷阱:亏损股 PE 为负会被"低估值"因子误判为最优,务必先剔除;
- 报告期对齐:横截面所有股票用同一最新报告期,不要混用年报和季报;
- 小样本失真:15 只股票的 z-score 仅作演示,实际应在全市场或指数成分内计算(全市场列表可用
stock_list,共 5000+ 只); - 行业偏斜:银行天然低 PB、科技天然高 PE,直接全市场打分会重仓金融——进阶做法是行业内分别 z-score(行业中性化),行业归属可通过官网板块接口获取;
- 频率限制:批量采集加
time.sleep控频,全市场级别的并发拉取方案见本系列第 16 篇。
六、总结与延伸
本文搭出了多因子选股的最小可用框架:两个接口取齐 5 因子 →to_num清洗 → z-score 标准化 + 方向调整 → 等权合成排序,并讨论了负 PE、行业偏斜等真实陷阱。下一篇《从零搭建Python量化选股系统:多条件筛选与历史回测》将把"因子打分"升级成"可配置的选股系统",并与第 11 篇的回测框架打通。
本文为技术演示,打分结果为特定时点的数据快照,不构成投资建议,亦不代表任何个股推荐。