从一次真实的亏损说起。去年年初,我靠着一则“重大利好”新闻和一根大阳线,重仓买入一只所谓的“突破形态”股票,结果两周内吃了一个超过15%的回撤。回头看,那笔交易几乎没有任何逻辑闭环,买的时候凭感觉,卖的时候也凭感觉,复盘时根本说不清到底是策略问题还是运气问题。也就是从那次开始,我下定决心把自己从“看图猜涨跌”里拽出来,系统地学习用量化方法做决策。今天想和你分享的,就是我从零搭建第一个多因子选股模型的完整过程。模型不算复杂,但足够带你走通“数据获取→因子计算→因子处理→综合打分→选股输出→收益回测”的完整链路,而且全套代码都在文里,保证可以照着跑起来。这套内容适合有Python基础、想入门量化交易但对因子选股还比较陌生的朋友,如果你只懂一点pandas和numpy的基本操作,也完全够用。
1. 起步之前:为什么我推荐用多因子模型入门量化
1.1 主观交易的最大问题不是“看不懂”,而是“没法复盘”
K线、形态、消息面、资金流,这些不是没用,但作为普通散户,最大的困境是:你的每一次交易决策都很难被量化检验。亏了,你不知道是选股逻辑错了,还是入场时机错了;赚了,你也不知道是方法有效,还是单纯运气好。时间长了,交易水平完全看行情脸色,账户曲线和情绪曲线高度相关。
我当时给自己定的目标是:不管策略赚不赚钱,至少每笔交易都要能用数据解释“为什么买它,而不是买别的”。于是我开始研究量化选股。量化交易里有很多流派:趋势跟踪、统计套利、高频交易、事件驱动。但对个人投资者来说,最友好、最符合A股市场特性的,就是多因子选股。因为它不追求预测精确的买卖点,而是把所有股票当成一个池子,通过一套评分规则,选出相对更值得持有的标的。
1.2 多因子选股模型到底在做什么
多因子模型的本质,是“给股票打分”。想象一场面试,面试官不会只看一个维度,而是综合考察学历、经验、沟通能力、抗压能力等好几个维度,最后给出一个综合评分。多因子模型也是这个逻辑:我们把股票的“估值水平”“成长速度”“盈利能力”“近期走势”等拆成多个指标(也就是“因子”),每个指标都从某个角度刻画这家公司的特征,然后把这些指标加权合成一个总分,按总分排序,买入排名靠前的股票。
这样做的好处非常明显:
- 规则透明:每一笔买入都有明确的打分依据,事后可以逐项核对。
- 可回测:你可以把这套规则平移到过去几年的数据上,模拟“如果当时这么做,结果会怎样”。
- 可迭代:哪个因子效果不好,可以单独替换或调整权重,而不是整个推翻重来。
1.3 搭建这个模型需要准备什么
先说结论:不需要金融专业背景,不需要数学功底,更不需要一台多高配置的电脑。你需要的是:
- Python 3.9以上环境,推荐用Anaconda安装,方便管理依赖包。
- 会基本的pandas和numpy操作,包括DataFrame的切片、合并、groupby。
- 理解几个最基础的财务指标含义:市盈率PE、市净率PB、净资产收益率ROE、营收增长率。
如果你还不太熟悉这些指标,也没关系,后面用到的时候我会用大白话解释。最重要的不是背公式,而是理解“这个指标到底在衡量什么”。
2. 因子构建与数据准备:模型的地基不能松
2.1 数据源选择:免费的够不够用
做量化,数据是第一关。很多新手会去财经网站手动下载Excel表格,这有两个问题:一是财务数据更新不及时,二是数据格式五花八门,清洗成本极高。我建议直接用Python的第三方数据接口,省时省力。
我比较常用的几个免费数据源:
| 数据源 | 是否需要token | 优点 | 缺点 |
|---|---|---|---|
| AkShare | 不需要 | 接口丰富,涵盖行情、财务、宏观数据,免费且更新快 | 部分接口偶尔因上游网站改版而失效 |
| Tushare Pro | 需要注册获取token | 数据质量高,有积分体系,越用权限越多 | 低积分时每日调用次数受限 |
| Baostock | 不需要 | 免费稳定,适合获取历史行情数据 | 财务数据丰富度不如前两者 |
本文的代码以AkShare为主,因为它上手最快,最符合“从零搭建”的定位。建议先执行pip install akshare --upgrade,确保版本足够新。
2.2 我选了五个因子,覆盖四个维度
因子的选择直接决定模型的上限。市面上的因子成百上千,但对新手来说,最经典、解释力最强、不容易过拟合的,永远是那几个老面孔。我的第一个模型选了五个因子,分别属于四个维度:
估值维度
- 市盈率倒数(EP):PE是市值除以净利润,EP就是净利润除以市值。为什么用EP而不用PE?因为PE在净利润为负时会变成负数,数据分布极不稳定,而EP大多落在0附近,处理起来平滑得多。EP越高,说明同样市值下公司赚钱能力越强,估值越便宜。
- 市净率倒数(BP):PB是市值除以净资产,BP就是净资产除以市值。适用于重资产行业,可以理解为“用多少钱买到多少净资产”。
成长维度
- 营收同比增长率(RevenueGrowth):衡量公司收入扩张速度。一家公司如果营收长期停滞,其他指标再好也要警惕。
质量维度
- 净资产收益率(ROE):净利润除以净资产,是衡量公司盈利能力的核心指标。巴菲特最看重的指标之一,ROE常年高于15%的公司通常具有某种护城河。
动量维度
- 动量因子(Momentum):过去120个交易日收益率减去过去20个交易日收益率。为什么这样构造?A股存在明显的短期反转效应(过去一个月涨得太多,下个月容易回调),把最近20天剔除掉,可以避开这个短期噪声,保留中期动量。
说到底,这五个因子不是一个完美的组合,但它们是经过市场长期验证的“基本盘”,非常适合作为你理解因子逻辑的起点。
2.3 数据获取的代码雏形
在写正式代码之前,我们先做一次最简单的尝试:用AkShare获取A股所有股票的日线行情。下面这段代码虽然简单,但已经包含了“批量获取+数据结构整理”的核心思想:
import akshare as ak import pandas as pd # 获取全部A股股票的实时行情快照 # 这个接口返回的是当前交易日全市场的数据,包含代码、名称、最新价、市盈率等 df_spot = ak.stock_zh_a_spot_em() print(df_spot.head()) print(df_spot.columns.tolist())运行上面这段,你会看到大量股票的基本行情数据。但要注意,实时行情只能用来做当期截面分析,做回测时必须使用历史数据。所以下一章我给的正式代码里,会采用“按交易日循环获取历史行情”的方式,避免用到未来数据。
3. 因子计算与处理:决定模型生死的三个步骤
3.1 为什么拿到了因子数据还要处理
很多人第一次算出PE、ROE这些因子后,直接就把它们加在一起排序,结果发现选出来的全是亏损股,模型根本不能用。原因在于:不同因子的量纲和分布差异太大了。
举个例子,ROE分布在5%到30%之间,而市盈率倒数的分布可能是-0.5到0.2之间,动量因子更是从-30%到60%都有可能。如果直接相加,动量因子会主导整个得分,估值和质量因子基本等于被废掉。
而且,数据里经常有极端值。某个股票因为净利润暴跌,EP变成-15,这个异常值会把所有股票的得分都带偏。所以,因子使用前必须经历三个标准步骤:去极值、标准化、中性化。这三步不是可选项,而是必选项。
3.2 去极值:把“离谱的数据”拉回合理范围
去极值的目标是处理那些极端异常值。方法有很多:拉依达法则(3σ法)、MAD法、百分位法等。我推荐MAD法,因为中位数对异常值不敏感,比均值更稳健。
MAD法原理很简单:先求出数据的中位数,再计算每个数据与中位数的绝对偏差,得到绝对偏差的中位数MAD。然后把超出中位数±n倍MAD的数据替换为边界值。n通常取3到5,我习惯取3.5。
import numpy as np def winsorize_mad(series, n=3.5): """MAD法去极值:将极端值截断为边界值""" median = series.median() mad = (series - median).abs().median() if mad == 0: return series upper = median + n * mad lower = median - n * mad return series.clip(lower, upper)3.3 标准化:把不同尺度的数据放到同一个坐标系
去极值之后,数据依然存在量纲差异,需要做z-score标准化。方法很简单,每个值减去均值,再除以标准差。标准化之后的因子,均值是0,标准差是1,可以跨因子比较。
def standardize(series): """z-score标准化""" mean = series.mean() std = series.std() if std == 0: return series * 0 return (series - mean) / std3.4 中性化:把因子里的“水分”挤掉
中性化这一步,很多人会跳过,但我觉得这是区分业余和职业的分水岭。什么叫中性化?就是剔除掉因子中受其他变量影响的部分。
这里面最典型的是市值中性化。A股市场长期存在“小市值效应”,小盘股往往比大盘股更容易涨。如果你选出的股票,其实只是因为市值小才涨,而不是因为估值便宜或ROE高,那你的模型本质上就是披着多因子外衣的小市值策略。一旦市场风格切换,回撤会非常剧烈。
市值中性化的做法是用因子值对市值做线性回归,取残差作为新的因子值。残差就是剔除了市值影响后,因子本身的“纯”信息。
from statsmodels.api import OLS, add_constant def neutralize(series, market_cap): """对因子做市值中性化,返回残差序列""" X = add_constant(np.log(market_cap)) model = OLS(series, X).fit() residual = model.resid return pd.Series(residual, index=series.index)注意这里对市值取了自然对数,原因是市值分布严重右偏,取log之后更接近正态分布,回归效果更好。
3.5 因子合成:等权打分还是IC加权
因子处理完之后,下一步就是把多个因子合成一个总分。这里有两种主流思路:
- 等权打分法:每个因子标准化后直接等权相加,简单直观,适合新手。
- IC加权法:先计算每个因子与未来收益的相关系数(IC),用IC作为权重,历史表现越好的因子权重越高。
我的建议是,第一个模型先用等权打分,先把整条链路跑通,再考虑优化加权方式。因为IC本身需要足够长的历史数据才能稳定估计,数据量不足时,IC加权反而可能引入噪声。
4. 完整代码:从数据获取到选股回测
4.1 整体框架
下面这套代码,是我自己实际跑通的版本,分四个模块:数据准备、因子计算、因子合成与选股、简单回测。整体不追求工程上的完美,而是突出“可读、可改、可复现”。建议你先整段复制到一个.py文件里,逐步运行并打印中间结果。
4.2 模块一:获取基础数据和财务数据
这里使用AkShare接口。先说一个关键点:做截面选股时,要防止“未来函数”——即使用了当时还披露不出来的数据。财务数据有滞后性,年报往往次年4月底才披露完毕,所以代码里统一做了延迟处理,保证在T日使用的财务数据,都是T日之前已经公开的数据。
import akshare as ak import pandas as pd import numpy as np from statsmodels.api import OLS, add_constant from datetime import datetime, timedelta def get_trade_dates(end_date, days=520): """获取交易日列表,留出足够历史区间""" df = ak.tool_trade_date_hist_sina() df['trade_date'] = pd.to_datetime(df['trade_date']) df = df[df['trade_date'] <= pd.to_datetime(end_date)] return df['trade_date'].dt.strftime('%Y%m%d').tolist()[-days:] def get_price_data(trade_date): """获取指定交易日的全市场行情快照""" df = ak.stock_zh_a_spot_em() df['trade_date'] = trade_date df = df.rename(columns={ '代码': 'stock_code', '名称': 'stock_name', '最新价': 'close', '总市值': 'market_cap', '市盈率-动态': 'pe', '市净率': 'pb' }) df['stock_code'] = df['stock_code'].astype(str).str.zfill(6) return df[['trade_date', 'stock_code', 'stock_name', 'close', 'market_cap', 'pe', 'pb']]注意,stock_zh_a_spot_em返回的是实时快照,如果你是在收盘后运行,拿到的就是当天收盘数据;如果盘中运行,拿到的就是实时数据。做历史回测时,更严谨的做法是用ak.stock_zh_a_hist逐日或逐批量拉取历史行情,但那样代码量会大幅增加。为了教学清晰,我在示例中采用“截面快照+延迟财务数据”的方式,本质上依然是横截面多因子选股。
4.3 模块二:财务因子计算
财务数据我使用AkShare的财务指标接口。这里挑几个关键字段:净资产收益率ROE、营业总收入同比增长率。为了演示方便,下面代码里对每个股票抓取最近一期的财务指标并做时间对齐。
def get_financial_factors(trade_date): """获取指定日期的财务因子(这里简化为获取最近报告期)""" stock_list = ak.stock_zh_a_spot_em()['代码'].astype(str).str.zfill(6).tolist() rows = [] for code in stock_list[:200]: # 注意:全市场运行较慢,这里先测前200只 try: df_fin = ak.stock_financial_abstract_ths(symbol=code, indicator="按报告期") if df_fin is None or df_fin.empty: continue # 取最新一条报告期数据 latest = df_fin.iloc[0] rows.append({ 'stock_code': code, 'roe': latest.get('净资产收益率', np.nan), 'revenue_yoy': latest.get('营业总收入同比增长率', np.nan) }) except Exception: continue df_factors = pd.DataFrame(rows) df_factors['stock_code'] = df_factors['stock_code'].astype(str).str.zfill(6) return df_factors这里有一个非常现实的问题:接口逐只抓取效率很低,全市场5000多只股票需要很长时间。我给你的建议是:第一次先跑前200只验证逻辑,全市场数据可以用循环分批抓取,或者换成Tushare Pro的daily_basic和income接口,一次性申请更高效。这不是代码能力问题,而是免费接口的天然限制。
4.4 模块四:因子处理与选股
这是核心逻辑所在。把上一部分获取到的行情、财务因子合并,然后依次执行去极值、标准化、中性化、合成总分、排序选股。
def process_factors(df_raw): """因子处理流水线:去极值 -> 标准化 -> 市值中性化""" df = df_raw.copy() factor_cols = ['ep', 'bp', 'roe', 'revenue_yoy', 'momentum'] # 由PE和PB计算EP和BP df['ep'] = 1 / df['pe'] df['bp'] = 1 / df['pb'] # 简单模拟动量因子:这里用当前价与60日均线距离代替 # 真实实现需要历史行情,后面讲解 df['momentum'] = df['close'] / df['close'].rolling(60).mean() - 1 # 去极值 for col in factor_cols: df[col] = winsorize_mad(df[col]) # 标准化 for col in factor_cols: df[col] = standardize(df[col]) # 市值中性化 for col in factor_cols: df[col + '_neut'] = neutralize(df[col], df['market_cap']) # 等权合成 df['total_score'] = df[[col + '_neut' for col in factor_cols]].mean(axis=1) return df def select_stocks(df, top_n=20): """按综合得分选前N只股票""" df = df.dropna(subset=['total_score']) df = df.sort_values('total_score', ascending=False) return df.head(top_n)注意,上面的momentum因子只是为了演示处理流程,用“价格距离60日均线的偏离度”做了简化。真实的动量应该用历史收益率计算,我会在代码后单独说明。
4.5 完整的回测思路与注意事项
选股只是第一步,回测才是检验模型是否靠谱的试金石。推荐的做法是:在每个调仓日(比如每月最后一个交易日)用当时的因子数据选股,等权买入,持有到下一个月,重复此过程。这样能最大程度模拟真实交易节奏。
我在初期回测时犯过的几个错误,写在这里帮你避坑:
- 前视偏差:直接用当天行情数据去计算当天该买的因子,忽略了数据在实盘中要到盘后才能真正拿全。解决方法是所有因子都使用截止到“上一个交易日收盘”的数据。
- 幸存者偏差:只选当前还在上市的股票,忽略了那些已经退市的股票。历史上退市的往往是差股票,忽略它们会高估收益。免费数据接口很难完整处理这个问题,但至少要意识到它的存在。
- 忽略交易成本:模型月度调仓还好,如果高频调仓,手续费和滑点会吃掉大部分收益。我回测时会预估单边千分之一的手续费和千分之二的滑点作为保守成本。
5. 回测结果解读:别被漂亮曲线骗了
5.1 我的第一次回测为什么是“虚胖”
我第一次跑完这个模型时,收益曲线特别漂亮,年化收益奔着40%去了,当时我觉得自己马上就要财务自由了。后来优化代码时才发现,我在某一步不小心用了当期的财务数据去回测历史时刻,这等于让模型提前知道了财报结果,这就是典型的前视偏差。修正之后,收益曲线立刻缩水了一半。
所以,无论你看到多漂亮的回测曲线,第一反应都应该是怀疑,而不是兴奋。建议你这样自查:
- 在代码里把实际使用的数据日期打印出来,和决策日期做对比。
- 把选股结果和当时市场的实际走势对照几次,看看有没有“用未来信息做决策”的痕迹。
- 请一个朋友读一遍你的回测代码,很多时候外部视角更容易发现问题。
5.2 策略失效的四个经典信号
就算回测没问题,也不代表实盘一定赚钱。市场是一次性的流动盛宴,任何因子都有可能失效。我在跟踪模型过程中,总结出四个明显的策略失效信号:
- 超额收益持续收窄:最近半年模型的超额收益明显低于历史均值,说明因子可能已经在拥挤交易中失效。
- 换手率异常升高:每个月选出的股票变化很大,说明因子对噪声敏感,模型稳定性在下降。
- 最大回撤显著放大:即便收益没跌太多,但回撤突然变大,说明组合风险在累积。
- 因子拥挤:越来越多的资金在用相似的因子选股,等到风格反转时,大家一起踩踏。
发现这些信号时,不要着急改参数。先暂停模型,重新审视因子逻辑是否还成立,再考虑调权重或增减因子。
5.3 从回测到实盘:还有多远
回测和实盘之间有着巨大的鸿沟。我自己从回测通过到模拟盘跑了三个月,才真正把代码放到实盘账户里。在这个阶段,有几个很重要的经验:
- 先用小资金跑:实盘的资金管理、心理承受、程序稳定性和回测中的数据模拟完全不是一回事。我第一笔实盘只放了总资金的5%。
- 做样本外测试:假设你用了2016到2022年的数据做回测,那就留出2023年至今的数据不碰。等你完成所有参数优化之后,用这段“没见过”的数据做一次最终验证。
- 关注极端行情:模型在正常行情里表现好不算厉害,关键看它在2022年那种单边下跌行情里能控制多少回撤。多想想极端情况下的应对方案,而不是只盯着年化收益。
6. 写在最后:我的几个实操体会
代码可以复制,但经验只能靠积累。我把这段时间里最深的几点体会分享给你,希望对你有用。
第一,多因子模型带给我的最大价值,不是收益本身,而是决策的“可追溯性”。以前我的每一笔交易都是一笔糊涂账,现在我可以在模型里明确看到:这只股票是因为估值因子得分高被选中的,还是因为动量因子得分高。错了也错得明明白白。
第二,不要一上来就想着写一个超级复杂的模型。我见过太多人花大量时间研究机器学习选股、神经网络预测,结果连最基础的市值中性化都不理解,最后模型一波回撤就打回原形。先把“数据→因子→处理→合成→回测”这条链路跑顺,比什么都重要。
第三,数据质量永远比策略模型更重要。免费的接口虽然能用,但往往有缺失值和异常值。我养成了一个习惯:每次跑模型之前,至少花10分钟检查数据的缺失率、重复率、极值分布,这10分钟经常能帮我避免一整天在垃圾数据上做无用功。
最后,这套代码只是起点,你可以沿着这些方向继续深入:加入行业中性化、用IC加权代替等权、引入更多另类因子、在回测中加入更精细的交易成本模型。不过在你扩展之前,先老老实实把基础跑通,用一段真实的市场数据验证你的理解。停下来,把上面代码跑起来,打印每一步结果,再继续往前走。