☰
A股机器学习选股实战:从特征工程到回测避坑的完整链路
2026/10/3 3:01:09 网站建设 项目流程

简介:这份资源是面向金融量化初学者与机器学习实践者的A股走势预测系统源码包,围绕用算法识别股价变动模式这一目标,整合了数据获取、特征工程、模型训练与回测的完整链路。包内共12个文件,以6个ipynb交互式笔记为主体,覆盖K线数据入库、FFT滤波、单票LSTM建模与sklearn回测等实验;另有3个csv样本行情数据、2个py脚本负责数据抓取与特征生成,以及1个gitignore配置文件,压缩包约2.53MB,结构紧凑便于按模块研读。目前已有307人学习下载。读者可借此理解线性回归、随机森林、SVM到LSTM等算法在金融时序上的落地方式,掌握缺失值填充、标准化、特征工程、交叉验证与网格搜索调参等关键步骤,并通过MSE、MAE、R²等指标评估模型,借助回测验证预测能力,形成从数据到策略的完整排错与优化思路。

1. 从一份A股走势预测源码说起:机器学习选股到底能不能落地

打开任何一个量化社区,关于“基于机器学习算法实现对A股股票走势预测系统源码”的帖子下面,永远分成两派:一派说这是割韭菜的噱头,另一派默默把代码拉下来跑了一遍,发现回测曲线漂亮得不像话,实盘一上就翻车。我自己第一次做这件事是在几年前,用随机森林跑了一套日频涨跌分类,回测年化三十多个点,实盘三个月亏掉手续费和滑点之后净值几乎贴着基准走。问题不在模型,在于我把“预测走势”这四个字理解得太简单了。

这套系统真正要解决的问题,不是猜明天涨还是跌,而是把A股特有的量价结构、板块轮动、涨跌停约束、停牌复牌这些脏活累活,转化成机器学习模型能吃的特征矩阵,再用一套可复现的流程把训练、验证、回测串起来。它适合两类人:一是想从传统技术指标转向因子+模型的量化爱好者,二是有Python基础、想拿A股数据练手机器学习工程化的开发者。源码本身不是重点,重点是你能不能看懂每一行特征工程背后的交易逻辑,以及回测框架里那些默认参数到底藏了多少未来函数。这一章先把整条链路讲清楚,后面几章直接上代码和参数。

2. 数据层:A股日线与分钟数据的获取、清洗和特征化

2.1 为什么A股数据不能直接喂给模型

A股数据有三个绕不开的坑:复权、停牌和涨跌停。你从任何免费接口拿到的日线数据,如果不做前复权处理,除权除息那天会出现一个巨大的向下跳空,模型会把它当成暴跌信号,实际上只是分红送股。停牌期间数据是缺失的,但很多接口会直接跳过停牌日,导致时间序列不连续,滚动窗口计算出来的均线全是错的。涨跌停更麻烦,涨停板上的成交量和换手率跟正常交易日完全不是一个分布,如果不在特征里做标记,模型会把涨停当成异常值处理。

常见做法是:日线用前复权价格,分钟线用不复权价格但单独标记除权日;停牌日用前值填充并加一个is_suspended布尔特征;涨跌停日用limit_up和limit_down两个哑变量标记。下面这段代码是我一般会用的清洗流程,基于 pandas 和 numpy,不依赖特定数据源。

import pandas as pd import numpy as np def clean_daily(df): """ df 列: date, open, high, low, close, volume, amount, pre_close 返回清洗后的 DataFrame,索引为 date """ df = df.sort_values('date').reset_index(drop=True) # 1. 标记停牌:成交量为0或缺失 df['is_suspended'] = (df['volume'].fillna(0) == 0).astype(int) # 2. 前复权:用 pre_close 和 close 计算复权因子 df['adj_factor'] = df['close'] / df['pre_close'] df['adj_factor'] = df['adj_factor'].fillna(1.0).replace([np.inf, -np.inf], 1.0) df['adj_close'] = df['close'] * df['adj_factor'].cumprod() # 3. 涨跌停标记:以主板10%为例,ST和科创板需单独调整 df['pct_chg'] = df['close'].pct_change() df['limit_up'] = (df['pct_chg'] >= 0.095).astype(int) df['limit_down'] = (df['pct_chg'] <= -0.095).astype(int) # 4. 停牌日价格用前值填充 price_cols = ['open', 'high', 'low', 'close', 'adj_close'] df[price_cols] = df[price_cols].fillna(method='ffill') return df.set_index('date')

逻辑说明:adj_factor用当日收盘价除以前收盘价,累乘得到复权后的价格序列,这样除权日不会产生虚假跳空。limit_up和limit_down的阈值设 0.095 是为了覆盖 10% 涨停板在四舍五入后的实际涨幅,科创板 20% 和 ST 5% 需要单独传参。停牌日填充用ffill而不是插值,因为停牌期间没有真实交易,插值会引入虚假波动。

参数方面,pct_chg的计算窗口是 1 日,如果你做的是周频预测,需要改成 5 日。adj_factor的累乘起点建议从回测起始日往前推至少 250 个交易日,否则复权基准会漂移。

2.2 特征工程:从量价数据到模型可用的因子矩阵

清洗完数据只是第一步,真正决定模型上限的是特征。A股日频预测常用的特征分四类:动量类、波动类、量能类、结构类。动量类包括 1/5/10/20 日收益率、RSI、MACD 柱;波动类包括 20 日已实现波动率、ATR、布林带宽度;量能类包括换手率、量比、OBV 斜率;结构类包括均线多头排列标记、距离前高前低百分比、涨停板后 N 日表现。

下面这段代码把上面清洗后的 DataFrame 转成特征矩阵,每个特征都做了滚动窗口计算,避免未来函数。

def build_features(df, windows=[1, 5, 10, 20]): feat = pd.DataFrame(index=df.index) # 动量特征 for w in windows: feat[f'ret_{w}d'] = df['adj_close'].pct_change(w) feat['rsi_14'] = compute_rsi(df['adj_close'], 14) # 波动特征 feat['vol_20d'] = df['ret_1d'].rolling(20).std() * np.sqrt(250) feat['atr_14'] = compute_atr(df, 14) # 量能特征 feat['turnover_ma5'] = df['turnover'].rolling(5).mean() feat['volume_ratio'] = df['volume'] / df['volume'].rolling(20).mean() feat['obv_slope'] = compute_obv_slope(df, 10) # 结构特征 feat['ma_bull'] = ((df['adj_close'] > df['ma5']) & (df['ma5'] > df['ma20'])).astype(int) feat['dist_high_20'] = df['adj_close'] / df['adj_close'].rolling(20).max() - 1 # 标签:未来5日收益率是否为正 feat['label'] = (df['adj_close'].shift(-5) / df['adj_close'] - 1 > 0).astype(int) return feat.dropna()

逻辑说明:ret_{w}d用复权收盘价计算,保证跨除权日的一致性。rsi_14和atr_14需要自己实现,网上很多版本用了未来数据,注意检查rolling的center参数必须是 False。obv_slope用 OBV 序列做线性回归取斜率,窗口 10 日。标签用未来 5 日收益率是否为正,这是分类任务;如果你做回归,直接把收益率作为标签,但要注意极端值裁剪。

参数方面,windows列表可以根据你的持仓周期调整,短线用 [1,3,5],中线用 [5,10,20,60]。vol_20d的年化因子用 250 个交易日,如果你做分钟频,改成 252*240。label的 shift 天数必须和你的调仓周期一致,否则回测和实盘会对不上。

注意:特征矩阵里绝对不能出现任何用未来数据计算的列,比如close.shift(-1)这种。一个简单的检查方法是把特征矩阵按时间倒序跑一遍,如果某列的值在倒序后发生了剧烈变化,大概率有未来函数。

3. 模型层:从逻辑回归到 LightGBM 的选型与训练

3.1 为什么我不推荐一上来就用 LSTM

很多源码包喜欢用 LSTM 或 Transformer 做股票预测,看起来高大上,实际在 A 股日频数据上表现往往不如 LightGBM。原因有三个:第一,A 股日频样本量有限,单只股票 10 年也就 2400 个交易日,深度学习容易过拟合;第二,量价因子的非线性关系用树模型已经能捕捉得七七八八,LSTM 的序列建模优势在日频上不明显;第三,树模型的特征重要性可解释,你能知道模型到底在赌什么,LSTM 就是个黑匣子,翻车了都不知道哪一步错了。

我一般会先用逻辑回归跑一个基线,再用 LightGBM 做主力模型,最后用 XGBoost 做对比。逻辑回归的系数可以告诉你每个因子的方向,LightGBM 的feature_importance可以告诉你哪些因子贡献最大。如果 LightGBM 的 AUC 比逻辑回归高不到 0.02,说明你的特征工程有问题,加模型复杂度没用。

3.2 LightGBM 训练脚本与关键参数

下面这段代码是训练流程的核心,包含时间序列交叉验证和早停。

import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit from sklearn.metrics import roc_auc_score def train_lgbm(feat, feature_cols, label_col='label', n_splits=5): X = feat[feature_cols].values y = feat[label_col].values tscv = TimeSeriesSplit(n_splits=n_splits) models = [] aucs = [] for train_idx, val_idx in tscv.split(X): X_train, X_val = X[train_idx], X[val_idx] y_train, y_val = y[train_idx], y[val_idx] dtrain = lgb.Dataset(X_train, y_train) dval = lgb.Dataset(X_val, y_val, reference=dtrain) params = { 'objective': 'binary', 'metric': 'auc', 'boosting_type': 'gbdt', 'num_leaves': 31, 'learning_rate': 0.05, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 5, 'min_data_in_leaf': 50, 'lambda_l2': 1.0, 'verbose': -1, 'seed': 42 } model = lgb.train( params, dtrain, num_boost_round=1000, valid_sets=[dval], callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)] ) models.append(model) aucs.append(roc_auc_score(y_val, model.predict(X_val))) print(f'CV AUC: {np.mean(aucs):.4f} +/- {np.std(aucs):.4f}') return models, aucs

逻辑说明:TimeSeriesSplit保证训练集永远在验证集之前,避免时间穿越。early_stopping(50)表示验证集 AUC 连续 50 轮不提升就停止,防止过拟合。min_data_in_leaf=50是 A 股日频数据的关键参数,设太小会让模型记住噪声,设太大又欠拟合,50 是我试下来比较稳的值。

参数方面,num_leaves控制在 31 以内,超过 63 在日频数据上几乎必过拟合。learning_rate用 0.05,配合早停,比 0.1 更稳。feature_fraction和bagging_fraction都设 0.8,增加随机性。lambda_l2设 1.0,正则化力度中等。如果你做的是分钟频,min_data_in_leaf可以降到 20,因为样本量大了。

提示:训练完之后一定要看model.feature_importance(importance_type='gain'),如果排名第一的特征是某个你无法解释的列,比如index或者date的衍生变量,赶紧删掉,大概率是数据泄露。

4. 回测层:用 backtrader 验证策略真实表现

4.1 回测框架选型:为什么是 backtrader

A 股回测框架常见的有 backtrader、vnpy、qlib 和自研。backtrader 的优势是轻量、文档全、社区大,适合个人开发者快速验证想法。qlib 功能更全但学习曲线陡,vnpy 偏重实盘交易。如果你只是想把机器学习信号跑一遍历史数据,backtrader 足够了。

backtrader 的核心概念是Cerebro、Strategy、DataFeed和Analyzer。你需要把特征矩阵和模型预测结果合并成一个 DataFrame,然后喂给 backtrader 的自定义 DataFeed。下面这段代码展示如何把 LightGBM 的预测概率转成交易信号并回测。

import backtrader as bt import pandas as pd class MLSignalStrategy(bt.Strategy): params = ( ('prob_threshold', 0.6), # 买入概率阈值 ('sell_threshold', 0.4), # 卖出概率阈值 ('stake_pct', 0.95), # 每次买入仓位比例 ) def __init__(self): self.prob = self.datas[0].prob def next(self): if self.order: return if not self.position: if self.prob[0] > self.params.prob_threshold: cash = self.broker.getcash() size = int(cash * self.params.stake_pct / self.data.close[0] / 100) * 100 if size > 0: self.buy(size=size) else: if self.prob[0] < self.params.sell_threshold: self.close() def run_backtest(df, prob_col='prob'): cerebro = bt.Cerebro() cerebro.addstrategy(MLSignalStrategy) data = bt.feeds.PandasData(dataname=df, prob=prob_col) cerebro.adddata(data) cerebro.broker.setcash(1000000) cerebro.broker.setcommission(commission=0.0003) # 万三佣金 cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe') cerebro.addanalyzer(bt.analyzers.DrawDown, _name='dd') results = cerebro.run() strat = results[0] print(f"Sharpe: {strat.analyzers.sharpe.get_analysis()}") print(f"Max Drawdown: {strat.analyzers.dd.get_analysis()}") return strat

逻辑说明:prob_threshold=0.6表示模型预测上涨概率超过 60% 才买入,sell_threshold=0.4表示概率低于 40% 就卖出。stake_pct=0.95是仓位比例,留 5% 现金防止手续费不够。size计算时向下取整到 100 股,符合 A 股一手交易规则。佣金设万三,实际还有印花税和过户费,回测时最好加上。

参数方面,prob_threshold和sell_threshold需要根据模型输出的概率分布调整。如果模型输出的概率集中在 0.5 附近,阈值要设窄一点,比如 0.52/0.48。如果概率分布很分散,可以设宽一点。stake_pct不要设 1.0,留一点现金缓冲。

4.2 回测里必须检查的三个指标

第一个是夏普比率,但不要只看数值,要看它的计算周期。backtrader 默认用日收益率算夏普,年化因子是 sqrt(250)。第二个是最大回撤,A 股策略回撤超过 30% 基本就很难实盘执行了。第三个是换手率,机器学习信号如果每天翻仓,手续费会吃掉大部分收益。我一般会在 Analyzer 里加一个TradeAnalyzer,看平均持仓天数和盈亏比。

注意:回测曲线漂亮不代表实盘能赚钱。一定要做样本外测试,把最后 20% 的数据留出来不参与训练,只用来回测。如果样本外夏普比样本内低一半以上,说明过拟合严重。

5. 避坑与排查:A股机器学习预测最常见的五个翻车点

5.1 未来函数:回测年化 50%,实盘亏 20%

现象:回测净值曲线平滑向上,夏普超过 2,实盘一上就连续回撤。原因:特征工程里用了未来数据,比如用当日收盘价计算均线后,又用这个均线判断当日买卖。解决:所有滚动计算必须用shift(1)把数据推到前一天,标签的 shift 天数要和调仓周期严格对齐。检查方法是用df.shift(-1)手动构造一个未来收益率列,如果模型在特征里能找到它,说明有泄露。

5.2 复权处理不当:除权日被当成暴跌

现象:模型在除权除息日附近频繁发出卖出信号。原因:用了不复权价格计算收益率,除权日价格跳空被当成真实下跌。解决:日线统一用前复权价格,分钟线如果不复权,必须在特征里加is_ex_dividend标记。复权因子要从回测起始日往前推至少一年开始累乘。

5.3 样本不平衡:涨停样本太少导致模型偏向预测下跌

现象:模型预测上涨的准确率很低,但预测下跌的准确率很高。原因:A 股大部分交易日涨跌幅在 ±3% 以内,涨停和跌停样本占比不到 5%,模型学会了全部预测“不涨”也能拿到 95% 的准确率。解决:用scale_pos_weight调整正负样本权重,或者在训练时对正样本过采样。LightGBM 的is_unbalance参数也可以试试,但效果不如手动调权重。

5.4 回测滑点设置太小:实盘成交价差吃掉利润

现象:回测收益不错,实盘一跑发现买入价总是比信号价高几个点。原因:回测时滑点设了 0 或者 0.001,实际 A 股小盘股买卖价差经常在 0.5% 以上。解决:回测滑点至少设 0.002,小盘股设 0.005。backtrader 里用cerebro.broker.set_slippage_perc(0.002)。另外,涨停板买不进去的情况也要在回测里模拟,可以用cheat_on_open或者自定义过滤器。

5.5 特征维度爆炸:加了 200 个因子,模型反而变差

现象:特征从 20 个加到 200 个,AUC 不升反降。原因:A 股日频样本量有限,特征太多会导致维度灾难,树模型虽然能处理高维,但噪声特征会稀释有效信号。解决:先用feature_importance筛掉排名后 50% 的特征,再用相关性分析删掉相关系数大于 0.9 的冗余特征。我一般控制在 30 到 50 个特征之间。

6. 进阶技巧:用滚动训练和概率校准提升实盘稳定性

6.1 滚动训练:让模型跟上市场风格切换

A 股的市场风格每隔几个月就会切换一次,2021 年炒新能源,2023 年炒 AI,2024 年又轮到红利。用一个固定模型跑三年,前两年的规律到第三年可能完全失效。滚动训练的做法是:每次用过去 N 个月的训练数据重新训练模型,预测未来 M 天的信号。N 一般取 24 到 36 个月,M 取 5 到 20 个交易日。

def walk_forward_train(feat, feature_cols, train_months=24, retrain_freq=20): """ 滚动训练:每 retrain_freq 个交易日重新训练一次 """ dates = feat.index.unique() models = {} for i in range(train_months * 20, len(dates), retrain_freq): train_start = dates[i - train_months * 20] train_end = dates[i] train_data = feat.loc[train_start:train_end] if len(train_data) < 500: continue model, _ = train_lgbm(train_data, feature_cols, n_splits=3) models[dates[i]] = model return models

逻辑说明:train_months * 20是把月换算成交易日,retrain_freq=20表示每 20 个交易日重新训练。n_splits=3是因为滚动训练时数据量比全量训练少,交叉验证折数要降低。训练好的模型按日期存到字典里,预测时用最近一次训练的模型。

参数方面,train_months设 24 适合风格切换较快的市场,设 36 更稳但反应慢。retrain_freq设 20 是月度调仓,设 5 是周度调仓,频率越高交易成本越大。

6.2 概率校准:让 0.6 真的代表 60% 上涨概率

LightGBM 输出的概率往往不是校准的,可能模型说 0.6 但实际只有 0.52 的上涨概率。概率校准可以用 Platt Scaling 或 Isotonic Regression,sklearn 的CalibratedClassifierCV直接支持。

from sklearn.calibration import CalibratedClassifierCV def calibrate_model(model, X_val, y_val): calibrated = CalibratedClassifierCV(model, method='isotonic', cv='prefit') calibrated.fit(X_val, y_val) return calibrated

逻辑说明:method='isotonic'适合样本量大的情况,method='sigmoid'适合小样本。cv='prefit'表示用已经训练好的模型做校准,不再重新训练。校准后的概率可以直接用于阈值判断,0.6 就是 0.6。

参数方面,校准集不能和训练集重叠,否则校准会过拟合。一般用最近 3 个月的样本外数据做校准。

6.3 一个我踩过的坑:不要用全市场股票一起训练

我最早做这套系统时,把全市场 4000 多只股票的数据混在一起训练一个模型,想着样本量大总能学到点东西。结果模型在回测里表现平平,实盘更是惨不忍睹。后来才明白,不同板块、不同市值的股票,量价特征分布差异巨大,混在一起训练等于让模型学一个平均规律,对任何一类股票都不够专精。改成按行业或市值分组训练后,AUC 提升了 0.03 到 0.05,实盘夏普也稳定了不少。

现在的习惯是:先按申万一级行业分组,每个行业单独训练一个模型,如果某个行业的样本量不够,再合并到相近行业。训练完之后对比各行业的 AUC,如果某个行业明显偏低,检查是不是有行业特有的数据问题,比如金融股的换手率天然就低。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询