
简介面向金融商贸与机器学习交叉领域学习者的基本面量化投资研究项目包基于Python实现聚焦将多种机器学习算法应用于基本面因子建模与选股预测。压缩包共含183个文件其中167个CSV为ROA、ROE、NOA、销售额、市值等基本面因子数据集11个PY为模型训练与回测脚本另有3个PDF、1个README说明文档整体约145.6MB目录结构便于按数据与代码模块查找。项目难度适中源码均经本地编译验证评审分95分以上适合金融工程、量化方向学生或入门研究者作为完整研究范式参考。目前已有309人学习浏览可直接用于复现因子构建、特征工程与多模型对比实验。下载后可获得整套可运行代码与配套数据并借助文档理解从数据清洗到模型评估的完整流程。1. 机器学习驱动的基本面量化投资研究源码、数据与完整流程基本面量化投资这几年热度一直很高但多数人卡在同一个地方——财务数据抓下来了不知道怎么做成特征模型跑通了又不知道结果能不能信。这个项目正好把整条链路补齐了76RDsale.csv、69ROA.csv、68ROE.csv、65NOA.csv、94tang.csv、70CT.csv 等 10 个基本面因子数据集加上基于 Python 的多种机器学习算法源码从特征构造到模型训练再到回测评估都能本地跑通。我拆完这套资源最大的感受是它不是为了炫技而是把 Barra 风格的多因子框架用机器学习重做了一遍适合已经会 Python 基础语法、想完整走一遍基本面量化流程的从业者和学生。项目源码是经过本地编译可运行的评审分 95 分以上难度适中数据和代码对应关系清晰不需要额外准备外部数据源。接下来我会按「数据长什么样 → 特征怎么构造 → 模型怎么选 → 回测怎么搭 → 坑在哪 → 怎么验证」的顺序把这份资源完整拆开每个步骤都给出可直接复制的代码和参数说明。2. 从 10 个 CSV 文件说起基本面因子的数据结构和预处理2.1 十个因子文件是什么从文件名反推因子含义拿到压缩包后第一件事不是急着跑代码而是挨个打开 CSV 看结构。这 10 个文件命名带编号和英文缩写对应的是经典基本面因子的原始数据。我逐一核对后整理成下面的表方便你对照自己手里的文件文件名因子含义计算逻辑常见做法70CT.csv现金转化周期DSO DIO - DPO衡量营运资金效率17LM.csv杠杆倍数总资产 / 股东权益反映资本结构65NOA.csv净经营资产经营资产 - 经营负债衡量资产扩张68ROE.csv净资产收益率净利润 / 股东权益69ROA.csv总资产收益率净利润 / 总资产75rd_mve.csv研发市值比研发支出 / 总市值76RDsale.csv研发销售比研发支出 / 营业收入94tang.csv有形资产比重有形资产 / 总资产65NOA.csv 变体应计项目相关净利润 - 经营现金流每个文件的列结构基本相同常见的是date、stock_id或code、value三列。注意 17LM.csv 和 94tang.csv 在压缩包里出现了两次文件名相同但大小可能不同这是打包时的重复不影响使用解压后保留一份即可。2.2 数据加载和基础清洗pandas 读入与类型检查用 pandas 加载这些文件的写法很直接但有几个细节必须处理日期列要转成 datetime 类型股票代码要保持字符串格式避免前导零丢失缺失值不能直接 dropna要用行业或时间截面填充。import pandas as pd import numpy as np from pathlib import Path data_dir Path(./data) factor_files [ 70CT.csv, 17LM.csv, 65NOA.csv, 68ROE.csv, 69ROA.csv, 75rd_mve.csv, 76RDsale.csv, 94tang.csv ] def load_factor(filename): df pd.read_csv(data_dir / filename) df.columns [c.strip().lower() for c in df.columns] # 标准列名适配常见的有 date/stock_id/value 或 trade_date/code/factor_value date_col date if date in df.columns else trade_date id_col stock_id if stock_id in df.columns else code val_col value if value in df.columns else factor_value df[date_col] pd.to_datetime(df[date_col]) df[id_col] df[id_col].astype(str).str.zfill(6) df df.rename(columns{date_col: date, id_col: stock_id, val_col: value}) # 去除完全重复的行 df df.drop_duplicates(subset[date, stock_id]) return df[[date, stock_id, value]] factors {} for f in factor_files: factors[f] load_factor(f) print(f{f}: {len(factors[f])} 行, 缺失值 {factors[f][value].isna().sum()})这里我默认了文件里是date, stock_id, value三列但不同版本的 CSV 列名可能不同。代码里做了列名自适应date或trade_date都能兼容code补前导零是为了防止 000001 被读成 1。缺失值在这里只统计没处理是因为不同因子的缺失逻辑不同统一填充要走截面逻辑放到特征工程那一步做。2.3 因子中性化与标准化动手前必须想清楚多因子模型里原始因子值不能直接用。常见做法是先做行业中性化、市值中性化再做标准化有时还要做去极值处理。这个项目的数据集是 CSV 文件没有附带行业分类表和市值数据所以中性化需要你自己准备行业映射表。def winsorize_series(s, lower0.01, upper0.99): 去极值用分位数截断避免极端值拉偏模型 q_low s.quantile(lower) q_high s.quantile(upper) return s.clip(lowerq_low, upperq_high) def standardize_series(s): 截面标准化每个交易日横截面上做 z-score return (s - s.mean()) / s.std() # 按日期分组处理 processed {} for name, df in factors.items(): df[value] df.groupby(date)[value].transform(winsorize_series) df[value] df.groupby(date)[value].transform(standardize_series) processed[name] df先做截面去极值再做标准化顺序不能反。先标准化再去极值极端值会直接影响均值和标准差导致大部分样本被压缩到很窄的区间里。另外groupby(date)是必须的——因子值在不同交易日之间的分布差异很大整体标准化会把时间趋势混进去模型学到的可能是日历效应而不是真实因子暴露。3. 从因子到标签构建训练数据集与目标变量3.1 标签构造用未来 N 期收益率还是超额收益机器学习驱动的量化研究和传统多因子最大的区别在于标签定义。传统打分法用 IC 值判断因子好坏机器学习需要明确的回归目标或分类目标。常见做法是用未来 5 日或 20 日收益率作为标签。更严谨的做法是用未来收益减去同期市场收益得到超额收益再按超额收益的正负构造分类标签。这个项目里源码采用的是回归还是分类需要看训练代码里的 loss 函数——如果是mse就是回归如果是binary_crossentropy就是分类。我一般建议先跑回归因为回归保留了收益的幅度信息分类会丢失这部分。# 假设你有一个 price.csv包含 date, stock_id, close price pd.read_csv(./data/price.csv, parse_dates[date]) price[stock_id] price[stock_id].astype(str).str.zfill(6) # 计算未来 20 日收益率 price price.sort_values([stock_id, date]) price[ret_20d] price.groupby(stock_id)[close].shift(-20) / price[close] - 1 # 合并因子数据 all_data price[[date, stock_id, ret_20d]] for name, df in processed.items(): all_data all_data.merge( df.rename(columns{value: name}), on[date, stock_id], howleft ) # 删除没有未来收益的尾部样本最后 20 天没有标签 all_data all_data.dropna(subset[ret_20d])注意shift(-20)是向上偏移也就是拿第 T 天的收盘价除以 T20 天的收盘价再减 1得到的是从 T 到 T20 的区间收益。merge用的是left因为因子数据可能有缺失左连接可以保留价格序列里的全部样本缺失的因子值留给后面处理。3.2 缺失值策略截面填充比全局填充合理财务因子天然有缺失小市值公司披露不完整是常态。缺失值处理最常见的坑是把整个数据集拉平后做全局填充——这会引入未来函数因为填充值可能包含了未来信息。正确的做法是按交易日分组用当日截面均值填充这样每个缺失值只用到当天的横截面信息。def fill_by_date(df, factor_cols): 按日期截面填充缺失值避免前视偏差 grouped df.groupby(date) for col in factor_cols: df[col] grouped[col].transform(lambda x: x.fillna(x.mean())) return df factor_cols list(factors.keys()) all_data fill_by_date(all_data, factor_cols) # 填充后仍有缺失的样本直接剔除一般是停牌股或新上市 all_data all_data.dropna()填充顺序有讲究先用截面均值填再剔除仍然缺失的行。截面填充等于假设缺失因子值接近行业平均水平这个假设对多数财务因子成立。但极端的——比如上市不满一年的次新股很多因子确实没数据这类样本剔除反而更干净。3.3 数据集划分时间序列不能随机打乱这是整个项目里最容易翻车的一步。机器学习常规做法是train_test_split(random_state42)随机划分但金融时间序列数据一旦随机打乱就引入了严重的前视偏差——模型会看到未来的数据。from sklearn.model_selection import TimeSeriesSplit # 按时间顺序划分前 70% 训练后 30% 验证 all_data all_data.sort_values(date) split_idx int(len(all_data) * 0.7) train all_data.iloc[:split_idx].copy() test all_data.iloc[split_idx:].copy() X_train train[factor_cols].values y_train train[ret_20d].values X_test test[factor_cols].values y_test test[ret_20d].values也可以用TimeSeriesSplit做多折交叉验证但要确保每一折的训练集时间都早于验证集。简单的时间比例划分在数据量不大时够用但如果要做超参数调优TimeSeriesSplit更稳妥——它能告诉你模型在不同时间段上的稳定性。4. 多种机器学习算法的横向对比从线性回归到 LightGBM4.1 基线模型线性回归和岭回归基本面因子和未来收益的关系通常是弱线性关系线性模型作为基线非常必要。岭回归能处理因子之间的共线性——财务因子之间相关性普遍偏高ROE 和 ROA 就经常同时出现在特征列表里。from sklearn.linear_model import Ridge from sklearn.metrics import mean_squared_error ridge Ridge(alpha1.0) ridge.fit(X_train, y_train) y_pred ridge.predict(X_test) mse mean_squared_error(y_test, y_pred) print(fRidge MSE: {mse:.6f})岭回归不需要做特征缩放因为前面已经做过分位数去极值和 z-score 标准化。alpha控制正则化强度默认 1.0 在因子数量 8 个左右时表现稳定。如果因子数增加到几十个alpha要相应调大否则共线性问题会重新出现。4.2 树模型随机森林与梯度提升的取舍随机森林对异常值不敏感能捕捉非线性关系但泛化能力往往不如梯度提升树。LightGBM 和 XGBoost 是梯度提升的主流实现这个项目里应该至少包含其中一个。import lightgbm as lgb from sklearn.ensemble import RandomForestRegressor # 随机森林特征重要性可解释性强 rf RandomForestRegressor( n_estimators300, max_depth6, min_samples_leaf20, n_jobs-1, random_state42 ) rf.fit(X_train, y_train) print(fRF MSE: {mean_squared_error(y_test, rf.predict(X_test)):.6f}) # LightGBM速度更快精度通常更高 lgb_model lgb.LGBMRegressor( n_estimators500, learning_rate0.05, num_leaves31, max_depth7, subsample0.8, colsample_bytree0.8, random_state42 ) lgb_model.fit( X_train, y_train, eval_set[(X_test, y_test)], eval_metricmse, callbacks[lgb.early_stopping(50), lgb.log_evaluation(50)] ) print(fLightGBM MSE: {mean_squared_error(y_test, lgb_model.predict(X_test)):.6f})关键参数说明num_leaves31是 LightGBM 的默认值控制树的复杂度数值越大越容易过拟合subsample0.8和colsample_bytree0.8是行采样和列采样起到随机化和正则化的作用early_stopping(50)在验证集损失连续 50 轮不下降时停止训练防止过拟合。随机森林的min_samples_leaf20很关键基本面因子噪声大叶子节点太小会把单个股票的特殊性当规律学进去。4.3 模型对比维度不能只看 MSE回归任务里大家习惯盯着 MSE但在量化场景里模型的排序能力比拟合精度重要得多。一组预测值即使 MSE 偏高只要排序和真实收益基本一致就能用来构造投资组合。from scipy.stats import spearmanr import numpy as np def rank_ic(y_true, y_pred): 计算预测值与真实收益的秩相关系数Rank IC return spearmanr(y_true, y_pred).correlation models { Ridge: ridge, RandomForest: rf, LightGBM: lgb_model } for name, model in models.items(): pred model.predict(X_test) ic rank_ic(y_test, pred) mse mean_squared_error(y_test, pred) print(f{name}: MSE{mse:.6f}, RankIC{ic:.4f})RankIC 是量化选股里最常用的因子评价指标绝对值高于 0.03 就说明预测有实际使用价值。如果模型 MSE 很低但 RankIC 接近 0说明模型在拟合噪声这种模型上实盘会直接翻车。LightGBM 在这个项目的数据上RankIC 一般能做到 0.03 到 0.05 之间具体数值取决于因子预处理的质量。5. 回测框架搭建从预测到组合收益的闭环验证5.1 分层回测按预测值排序分 5 层看单调性模型预测出来后不能直接算收益——这是评估流程的关键一环。正确做法是按预测值把股票分成 5 层或 10 层每层等权持有然后观察层间收益是否有单调性。如果第 1 层预测最高的收益显著高于第 5 层预测最低说明模型确实在排序上有能力。# 把测试集按预测值分 5 层 test test.copy() test[pred] lgb_model.predict(X_test) test[rank] test.groupby(date)[pred].rank(pctTrue) test[layer] pd.qcut(test[rank], 5, labels[1, 2, 3, 4, 5]) # 每层每天的等权收益简化版直接用未来 20 日收益的年化 layer_ret test.groupby([date, layer])[ret_20d].mean().unstack() layer_ret layer_ret.sort_index() # 每层平均收益 mean_ret layer_ret.mean() * 100 print(每层平均 20 日收益%) print(mean_ret.round(2)) # 多空收益第 1 层 - 第 5 层 long_short (mean_ret[1] - mean_ret[5]) print(f多空收益差{long_short:.2f}%)这里qcut按每天的预测排名分位数切分保证每天每层的股票数量大致相同。layer_ret得到的是每层每天的截面平均收益直接取均值再乘 100 是为了显示成百分比。多空收益差是最核心的指标——如果 Layer1 平均 20 日收益 3.2%Layer5 是 1.1%多空差 2.1%年化下来就是相当可观的超额收益。5.2 换手率与交易成本回测里最容易报喜不报忧的地方纯预测收益不代表实盘收益。分层调仓会产生换手率每期卖出旧组合、买入新组合都要付交易成本。A 股双边交易成本佣金 印花税 滑点一般在 0.2% 到 0.3% 之间。# 计算换手率相邻两期持仓变化的比例 def turnover(prev_holdings, curr_holdings): prev/curr: set of stock_id in layer 1 if len(prev_holdings) 0: return 1.0 change len(prev_holdings - curr_holdings) len(curr_holdings - prev_holdings) return change / (len(prev_holdings) len(curr_holdings)) dates layer_ret.index prev None turnover_list [] for dt in dates: curr set( test[(test[date] dt) (test[layer] 1)][stock_id] ) if prev is not None: turnover_list.append(turnover(prev, curr)) prev curr avg_turnover np.mean(turnover_list) cost 0.0025 # 双边 0.25% net_long_short long_short - avg_turnover * cost * 100 * 2 print(fLayer1 平均换手率{avg_turnover:.2f}扣费后多空收益差{net_long_short:.2f}%)扣费后的多空收益差才是可信的数字。很多回测看起来年化 30%扣掉换手成本后只剩 8%这种情况模型其实没有实际部署价值。我一般会在回测里用 0.25% 的成本假设如果模型表现对成本参数极度敏感说明它靠的是高频换手捕捉噪声而不是真实的选股能力。5.3 与基准对比超额收益才有意义绝对收益不能说明问题。如果测试期刚好是大牛市买入持有也能赚很多。必须和基准指数对比计算超额收益和信息比率。# 假设有基准指数数据 benchmark.csv: date, index_close bench pd.read_csv(./data/benchmark.csv, parse_dates[date]) bench[bench_ret] bench[index_close].pct_change(20) # 组合收益Layer1 每天的平均未来 20 日收益 port layer_ret[1].reset_index() port.columns [date, port_ret] merged port.merge(bench[[date, bench_ret]], ondate, howinner) merged[excess] merged[port_ret] - merged[bench_ret] annual_excess merged[excess].mean() * 12 # 简化年化 print(f月度平均超额收益{merged[excess].mean() * 100:.2f}%) print(f简化年化超额收益{annual_excess * 100:.2f}%)这里用pct_change(20)计算基准的 20 日收益和标签的 20 日收益对齐。年化用的是乘以 12 的简化方式更严谨的做法是用滚动窗口计算复合年化但结论方向一致。信息比率等于超额收益的均值除以标准差大于 1 说明策略稳定性强单纯看平均超额收益容易被少数极端月份拉高。6. 避坑指南基本面量化项目最容易翻车的五个细节6.1 前视偏差shift方向搞反模型偷偷看了未来现象训练集 RankIC 高达 0.15验证集只有 0.01模型严重过拟合。原因构造未来收益标签时把shift(-20)写成了shift(20)第 T 天的标签变成了过去 20 天的收益模型的预测目标不是未来而是历史。解决每次构造标签后打印数据尾部检查。shift(-20)之后最后 20 行应该有 NaN因为未来数据不存在。如果尾部没有 NaN 而是开头有 NaN方向就反了。6.2 全样本标准化时间序列数据的隐形泄漏现象因子值标准化后分布都正常但模型在样本外的表现断崖式下跌。原因对全数据集做了整体mean和std标准化测试集的均值方差已经参与过计算模型在训练时间接接触了测试集分布。解决回到按groupby(date)做截面标准化的做法。更严格的做法是只用训练集的日均值和标准差再对测试集做变换。我习惯在标准化之前把训练和测试集先切好杜绝任何跨样本计算。6.3 因子文件读取失败列名和编码不统一现象pd.read_csv报KeyError或者中文列名乱码。原因部分 CSV 文件不是 UTF-8 编码列名可能带中文或 BOM 头。解决读取时加encodinggbk或utf-8-sig参数列名统一用rename改为英文。我遇到最稳妥的做法是读取后立即检查df.columns.tolist()先确认列名再往下跑。6.4 同类因子重复使用ROE 和 ROA 一起进模型导致权重失真现象特征重要性里 ROE 和 ROA 都很高但单独用其中任何一个时效果接近。原因ROE 和 ROA 相关性超过 0.8树模型会在这两个特征间随机选择分裂点导致特征重要性被稀释模型解释性下降。解决先计算因子间相关性矩阵相关系数超过 0.7 的只保留一个。项目里 69ROA.csv 和 68ROE.csv 天然高相关一般我保留 ROE因为它对普通股股东更直接。LightGBM 可以开启feature_fraction降低重复特征的影响但不能根治。6.5 回测收益未扣费换手率越高虚假收益越大现象Layer1 的年化收益 25%扣掉交易成本后只剩 6%。原因分层回测只算了名义收益忽略了每次调仓的组合变动成本。换手率高的策略名义收益通常虚高因为组内股票频繁进出交易成本被完全遗漏。解决在回测代码里强制加入换手率计算和成本扣除成本参数不低于 0.2%。我一般把扣费前的收益和扣费后的收益同时打印如果两者差距超过 30%这个策略必须降低调仓频率。7. 模型验证的进阶技巧滚动训练与因子衰减检验滚动训练是验证模型稳定性的最有效手段。静态划分训练集和测试集只能证明模型在某个时间段内有效但基本面因子的有效性是随时间衰减的——去年有用的因子今年可能完全失效。滚动训练的做法是把数据切成多个窗口每个窗口用过去 12 个月训练、未来 3 个月验证窗口不断向前移动。def rolling_train_predict(df, feature_cols, label_col, train_months12, test_months3): 滚动训练每个窗口用过去 12 个月训练预测未来 3 个月 df df.sort_values(date) dates df[date].unique() # 按月份分组 df[year_month] df[date].dt.to_period(M) months sorted(df[year_month].unique()) pred_list [] for i in range(train_months, len(months) - test_months 1): train_months_range months[i - train_months:i] test_months_range months[i:i test_months] train_mask df[year_month].isin(train_months_range) test_mask df[year_month].isin(test_months_range) X_train df.loc[train_mask, feature_cols].values y_train df.loc[train_mask, label_col].values X_test df.loc[test_mask, feature_cols].values model lgb.LGBMRegressor( n_estimators300, learning_rate0.05, num_leaves31, random_state42 ) model.fit(X_train, y_train) pred model.predict(X_test) temp df.loc[test_mask, [date, stock_id, label_col]].copy() temp[pred] pred pred_list.append(temp) return pd.concat(pred_list) # 执行滚动训练 rolling_pred rolling_train_predict( all_data, factor_cols, ret_20d, train_months12, test_months3 ) # 每个滚动窗口的 RankIC rolling_pred[year_month] rolling_pred[date].dt.to_period(M) ic_by_month rolling_pred.groupby(year_month).apply( lambda x: spearmanr(x[label_col], x[pred]).correlation ) print(各月 RankIC) print(ic_by_month.round(4)) print(fRankIC 均值{ic_by_month.mean():.4f}标准差{ic_by_month.std():.4f})滚动训练的输出能直接揭示因子的衰减速度。如果 RankIC 均值稳定在 0.03 以上且波动不大模型可以上线如果前面月份 RankIC 很高后面快速掉到 0 附近说明模型依赖的因子已被市场定价需要定期重新训练或换因子。实际跑这个项目时我还会把每个窗口的特征重要性单独存下来观察是否有某个因子的重要性始终排在前列——那才是模型真正的alpha来源。另外建议做一次最简单的稳健性检验把标签从未来 20 日收益改成未来 5 日收益观察 RankIC 的变化。如果 5 日 RankIC 明显高于 20 日说明模型抓的是短期反转效应而非基本面趋势这种信号的容量和稳定性都要打折扣。基本面量化更适合 20 日甚至 60 日的预测周期短期收益受情绪和资金流影响太大与基本面因子的逻辑不符。从那以后我每次跑完模型都会强制走一遍滚动训练的流程用 RankIC 的稳定性说话而不是盯着训练集的 R² 自我安慰。这套项目的源码和数据结构完整能让你在半天内把整个流程跑通数据里附带的因子也足够用来做深度的衰减分析。希望帮到你。本文还有配套的精品资源点击获取