ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

机器学习预测A股走势:从特征工程到LightGBM源码实战

机器学习预测A股走势:从特征工程到LightGBM源码实战 简介面向金融量化入门者、数据科学爱好者及对股价预测建模感兴趣的开发者这份压缩包内含12个文件仅2.53MB以6个Jupyter Notebook为主穿插3个CSV行情样本、2个Python脚本及配置文件覆盖数据采集、特征工程、模型训练与回测的完整链路。Notebook中既有基于sklearn的单票机器学习回测也有singlelstm的LSTM预测、FFT滤波处理及小市值结合盈利指标的策略验证并配有K线数据入库与行情获取脚本便于直接改动参数复现A股实验。已有305人学习下载。通过源码可以快速掌握K线数据储存、特征构造、机器学习与深度学习方法建模和回测评估的思路尤其适合想在真实A股数据上动手实践量化预测的读者在边读边调试中理解不同算法差异、特征处理方式及模型评估细节。1. 机器学习预测A股走势先想清楚预测的是什么再谈源码把“基于机器学习算法实现对A股股票走势预测系统源码”这个标题拆开看核心词是“预测”而A股走势预测最容易踩的坑是把问题定义成回归——预测明天收盘价是10.05还是10.11。实际做过的人都清楚价格预测的误差稍微偏一点方向就错了而交易决策只关心方向。所以成熟的工程做法是把问题转成分类给定过去N天的特征预测未来M天股价是涨还是跌。本文按这个思路从特征工程、样本构造、模型选型到回测评估给出一套可以直接改来用的源码级方案。这套方案不追求“预测准”的玄学而是把目标设定为“预测方向胜率稳定在55%以上且回撤可控”。适合有两三年Python基础、想用机器学习技术系统性地验证交易思路的开发者和量化爱好者。文中涉及的代码以LightGBM为主力模型因为它在表格型金融数据上的表现、训练速度和可解释性平衡得最好源码可以直接跑通后续替换成其他机器学习算法XGBoost、随机森林也很容易。2. 特征工程是重头A股走势预测系统的数据准备与标签构造2.1 从数据源到本地数据集先解决数据对齐问题A股日线数据常见的数据源包括Tushare、AkShare、Baostock各有特点Tushare积分制部分接口需要付费额度AkShare免费但接口偶尔变动Baostock稳定且完全免费。我一般用Baostock做本地存储因为它对历史日线数据的覆盖完整字段也规整。获取数据时有个关键点前复权和后复权必须选一个并固定下来。不复权数据在除权除息日会出现价格跳空模型会学到假的信号前复权适合回测历史数据后复权适合技术指标计算。A股预测系统里统一用前复权价代码里显式声明import baostock as bs import pandas as pd bs.login() def fetch_daily(code, start_date, end_date): rs bs.query_history_k_data_plus( code, date,code,open,high,low,close,volume,amount,turn,tradestatus, start_datestart_date, end_dateend_date, frequencyd, adjustflag2 # 2表示前复权1表示后复权3表示不复权 ) data [] while (rs.error_code 0) rs.next(): data.append(rs.get_row_data()) df pd.DataFrame(data, columnsrs.fields) bs.logout() return dfadjustflag2这个参数很容易被忽略但它是整个预测系统数据一致性的地基。如果训练集用前复权、推理时用了不复权模型输入分布直接漂移。数据拿到后还要过滤tradestatus字段剔除停牌日否则停牌那天的特征全是上一交易日的重复值模型会把这些空转的样本当作真实交易样本。2.2 构造技术指标特征不要贪多要讲业务逻辑特征不是越多越好。A股日线级别的预测几十个特征足够核心是覆盖趋势、动量、波动率和量价关系四个维度。趋势用均线系统动量用ROC和RSI波动率用ATR和布林带宽度量价关系用OBV和量比。下面是特征构造的完整实现直接基于2.1节拿到的日线数据import numpy as np def build_features(df): df df.sort_values(date).reset_index(dropTrue) # 趋势特征5日、10日、20日均线以及价格相对均线的偏离度 for win in [5, 10, 20]: df[fma{win}] df[close].rolling(win).mean() df[fbias{win}] (df[close] / df[fma{win}] - 1) * 100 # 动量特征ROC和RSI df[roc_10] df[close].pct_change(10) * 100 delta df[close].diff() gain delta.clip(lower0).rolling(10).mean() loss (-delta.clip(upper0)).rolling(10).mean() df[rsi_10] 100 - 100 / (1 gain / (loss 1e-9)) # 波动率特征ATR和布林带宽度 df[tr] np.maximum( df[high] - df[low], np.maximum(abs(df[high] - df[close].shift(1)), abs(df[low] - df[close].shift(1))) ) df[atr_14] df[tr].rolling(14).mean() df[bb_width] (df[ma20] 2 * df[close].rolling(20).std()) - \ (df[ma20] - 2 * df[close].rolling(20).std()) # 量价关系OBV变化率和量比 obv (np.sign(df[close].diff()) * df[volume]).fillna(0).cumsum() df[obv_rate] obv.pct_change(5) df[volume_ratio] df[volume] / df[volume].rolling(5).mean() return df这里没有引入上百个特征主要原因有两个一是日线级别样本量有限单只股票10年也就2400条左右特征过多容易过拟合二是A股的技术指标之间相关性很高均线、MACD、布林带本质上是同一类信息的不同表达机器学习算法里的特征重要性分析会帮你验证这一点。2.3 标签构造预测未来5个交易日收益的方向而非具体数值标签设计直接决定整个预测系统的上限。常见的做法有两种预测次日涨跌T1方向和预测未来N日累计收益方向。前者噪声极大后者更符合实际持仓周期。我推荐用未来5日累计收益作为标签同时把阈值设为0大于0记为上涨1否则记为下跌0。df[label] (df[close].shift(-5) / df[close] - 1 0).astype(int) df df.dropna().reset_index(dropTrue)shift(-5)是关键点它把未来5个交易日的收益平移到当前行让模型在t日利用t日及之前的信息预测t1到t5的方向。这里有个常见的错误写法是df[label] (df[close].shift(-5) - df[close]) 0没有做归一化大市值股票和小市值股票的标签尺度不一致虽然方向判定上差异不大但会影响后续的样本加权。值得强调的是如果预测周期是次日胜率很难稳定超过55%基本和抛硬币差不多拉到5日周期后信号的稳定性会好很多。预测周期和持仓周期必须匹配否则模型预测的是5日方向你却在次日就卖出胜率再高也赚不到钱。2.3.1 样本切分按时间序列不随机打乱这里要特别注意金融时间序列的样本切分和普通机器学习分类完全不同。交叉验证里常用的train_test_split默认随机切分会泄露未来信息——比如用2023年8月的样本来训练而同时期的样本出现在训练集里模型的“高准确率”全是前视偏差。train df.iloc[:int(len(df) * 0.7)] test df.iloc[int(len(df) * 0.7):]按7:3的时间顺序切分训练集在前、测试集在后。更进一步的做法是按年份切分比如用2018-2021年训练、2022-2023年验证这样能验证模型在不同市场环境牛熊转换下的泛化能力。3. 模型选型与核心源码用LightGBM实现A股走势预测分类器3.1 为什么选LightGBM而不是深度学习模型A股日线预测系统本质上是在表格型数据上做二分类这类任务目前业界没有比Gradient Boosting Decision TreeGBDT系列更稳妥的方案。李宏毅机器学习课程里讲过梯度下降和损失函数优化LightGBM就是梯度提升树在工程上的极致实现它的直方图算法比XGBoost的预排序更快在处理几千条到几万条样本的日线数据时训练时间在秒级。深度学习在这里反而有劣势LSTM、Transformer这类模型适合长序列依赖但日线数据只有几十个特征、序列长度不超过60天模型参数动辄几十万样本量不够直接过拟合。而且LSTM类模型的可解释性差做出来的预测结果很难回溯到某个具体特征上这在量化交易里是个致命问题——你不知道模型为什么下单就无法建立信心。3.2 核心训练代码参数、早停和特征重要性import lightgbm as lgb from sklearn.metrics import accuracy_score feature_cols [c for c in df.columns if c not in [date, code, label]] X_train train[feature_cols] y_train train[label] X_test test[feature_cols] y_test test[label] model lgb.LGBMClassifier( n_estimators1000, learning_rate0.05, num_leaves31, max_depth6, subsample0.8, colsample_bytree0.8, random_state42, verbose-1 ) model.fit( X_train, y_train, eval_set[(X_test, y_test)], eval_metricbinary_logloss, callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)] ) y_pred model.predict(X_test) print(Accuracy:, accuracy_score(y_test, y_pred))关键参数说明n_estimators1000配合early_stopping使用设一个较大的初始树数量让模型在验证集上不再提升时自动停止能有效控制过拟合。实际训练中LightGBM通常跑到200-300棵树就停了早停设为50轮足够。num_leaves31和max_depth6是控制单棵树复杂度的核心参数。num_leaves过大模型会学得太细在A股这种噪声极高的数据里尤其要保守。subsample0.8和colsample_bytree0.8行采样和列采样本质是让每棵树看到的样本和特征略有不同减少树与树之间的相关性从而降低整体方差。训练完成后务必看一眼特征重要性这是检验特征工程是否有效的最直接手段importance pd.DataFrame({ feature: feature_cols, gain: model.booster_.feature_importance(importance_typegain) }).sort_values(gain, ascendingFalse) print(importance.head(10))gain类型的重要性表示特征在分裂时带来的平均增益比默认的split分裂次数更能反映特征的真实贡献。如果排名靠前的全是volume_ratio、bias5这类量价特征说明特征构造方向合理如果排名靠前的特征里有明显异常比如日期相关的特征就要回去检查数据是否泄露了未来信息。3.3 等权集成多模型投票降低单模型的随机性单个LightGBM模型的随机性来自样本采样和特征采样同样的数据跑两次结果可能差0.5个百分点。为了稳定胜率常见的做法是训练多个基模型然后做投票。from sklearn.ensemble import VotingClassifier from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier base_models [ (lgb, model), (rf, RandomForestClassifier(n_estimators200, max_depth8, random_state42)), (lr, LogisticRegression(max_iter1000, C0.1)) ] ensemble VotingClassifier(base_models, votingsoft) ensemble.fit(X_train, y_train) y_pred_ensemble ensemble.predict(X_test)votingsoft用概率加权投票比硬投票更平滑。逻辑回归在这个组合里的作用不是单独预测得好而是当树模型在某些样本上过度自信时逻辑回归的概率输出能起到平衡作用。这个集成方案不用做特征归一化因为树模型不敏感逻辑回归则靠C0.1的正则化来防止单特征主导。4. 回测评估与参数调优警惕前视偏差和过拟合的3个必查项4.1 评估指标准确率是底线胜率和盈亏比才是目标训练代码里输出的Accuracy只是一个粗粒度的参考。A股上涨和下跌的样本比例大约在52:48就算模型什么都不学、永远预测上涨准确率也有52%。所以评估预测系统至少要看三个指标准确率、召回率、盈亏比。from sklearn.metrics import classification_report, confusion_matrix print(classification_report(y_test, y_pred, target_names[下跌, 上涨])) tn, fp, fn, tp confusion_matrix(y_test, y_pred).ravel() win_rate tp / (tp fp) # 预测上涨中实际涨的比例 capture_rate tp / (tp fn) # 实际涨的样本中被模型抓到的比例 print(f预测上涨胜率: {win_rate:.4f}, 上涨样本覆盖率: {capture_rate:.4f})预测系统真正有用的是win_rate也就是模型发出买入信号后实际涨幅为正的概率。A股散户水平下这个值如果能稳定在55%以上结合盈亏比大于1就有正期望。如果准确率高于60%先不要高兴大概率是数据泄露或样本切分出了问题按4.2节逐项排查。4.2 前视偏差自查未来函数和数据泄露的经典案例我在实战中遇到最多的坑一个是标签构造时用了未来信息另一个是特征计算时没用shift。下面是排查清单任何一个环节出问题回测结果都不可信检查项错误示例正确做法标签是否用了未来K线close.shift(-5)错写成close标签计算必须基于未来数据但特征中严禁出现未来信息特征是否包含当日收盘后才知道的数据用当日amount预测当日涨跌pct_change()会产生当日值需要shift(1)对齐样本切分是否随机train_test_split默认随机切分必须按时间顺序train.iloc[:70%]预处理是否在全体数据上拟合标准化用了全体数据的均值和方差先在训练集上fit再对测试集transform第四条是最隐蔽的。比如用StandardScaler对全量特征做归一化然后切分训练集和测试集这会导致测试集的均值和方差信息被模型间接看到。虽然树模型不受这个影响但如果你是上面集成代码里的逻辑回归路径这个偏差是存在的。4.3 参数搜索用Optuna找LightGBM最优超参组合手工调参在num_leaves、learning_rate、min_child_samples这几个参数上来回试效率太低而且容易陷入局部最优。用Optuna做贝叶斯搜索是当前机器学习算法调参的主流方案下面的代码可以直接用import optuna def objective(trial): params { n_estimators: 1000, learning_rate: trial.suggest_float(learning_rate, 0.01, 0.1, logTrue), num_leaves: trial.suggest_int(num_leaves, 16, 64), max_depth: trial.suggest_int(max_depth, 4, 8), min_child_samples: trial.suggest_int(min_child_samples, 20, 100), subsample: trial.suggest_float(subsample, 0.6, 0.9), colsample_bytree: trial.suggest_float(colsample_bytree, 0.6, 0.9), reg_alpha: trial.suggest_float(reg_alpha, 1e-4, 10.0, logTrue), } cv_model lgb.LGBMClassifier(**params, random_state42, verbose-1) # 注意这里用TimeSeriesSplit不用普通KFold from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits3) accs [] for train_idx, val_idx in tscv.split(X_train): X_tr, X_val X_train.iloc[train_idx], X_train.iloc[val_idx] y_tr, y_val y_train.iloc[train_idx], y_train.iloc[val_idx] cv_model.fit(X_tr, y_tr, eval_set[(X_val, y_val)], eval_metricbinary_logloss, callbacks[lgb.early_stopping(30)]) accs.append(accuracy_score(y_val, cv_model.predict(X_val))) return np.mean(accs) study optuna.create_study(directionmaximize) study.optimize(objective, n_trials50, show_progress_barFalse) best_params study.best_params print(best_params)调参时用TimeSeriesSplit做交叉验证是关键它在每一步都保证训练集时间在先、验证集在后模拟真实的滚动预测过程。相比标准的K折交叉验证这种评估方式更接近实盘场景得出的参数布不会因为随机切分导致过拟合。Optuna本身内置了剪枝机制如果某组参数在前几折的表现远低于历史最佳直接终止50次搜索实际跑下来大约5-10分钟。5. 滚动预测与特征监控把模型部署到每日实盘流程中5.1 滚动重训用“最近一年最新数据”逐日预测训练一次模型就长期使用是A股预测系统最常见的“短命病”。市场风格轮动2020年的消费白马行情下训练的模型到2023年的AI行情里失效几乎是必然的。我一般采用的策略是滚动窗口重训每天收盘后用截至当天的最新数据重新训练模型然后预测明天的涨跌方向。def rolling_predict(df, retrain_days250): df: 已经构造好特征和标签的完整数据 retrain_days: 滚动窗口长度250个交易日约等于一年 preds [] for i in range(retrain_days, len(df), 1): train_data df.iloc[i-retrain_days:i] test_row df.iloc[[i]] feat_cols [c for c in df.columns if c not in [date, code, label]] model lgb.LGBMClassifier(n_estimators300, learning_rate0.05, verbose-1) model.fit(train_data[feat_cols], train_data[label]) prob model.predict_proba(test_row[feat_cols])[0][1] preds.append((test_row[date].values[0], prob)) return preds这段代码的for循环是逐日滚动的每走一步都重新训练一次。实际运行中会比较慢一年250个交易日每步训练一次大约需要半小时。如果嫌慢可以改成每5个交易日重训一次预测未来5个交易日的方向效果差异不大但速度提升5倍。滚动重训的本质是让模型永远在“最近的数据”上学规律。金融数据的分布漂移速度远超普通机器学习应用场景一年前的特征分布和现在的特征分布可能已经是两个世界了。5.2 特征漂移监控用领域偏差PSI盯住模型何时失效模型上线后不会直接报错但胜率会悄悄下滑。特征分布漂移检测是提前预警的手段。常用的指标是PSIPopulation Stability Index它衡量某个特征在训练时期和当前时期的分布差异。def calculate_psi(expected, actual, bins10): expected_counts, edges np.histogram(expected, binsbins) actual_counts, _ np.histogram(actual, binsedges) expected_pct expected_counts / len(expected) actual_pct actual_counts / len(actual) psi np.sum((expected_pct - actual_pct) * np.log(expected_pct / (actual_pct 1e-9))) return psi # 假设train是训练数据latest是最近20个交易日的数据 psi_dict {} for col in feature_cols: psi_dict[col] calculate_psi(train[col].values, latest[col].values) if psi_dict[col] 0.1: print(f警告: 特征 {col} 的PSI为 {psi_dict[col]:.4f}, 分布漂移显著)PSI的经验阈值是小于0.1表示分布稳定0.1-0.25表示有漂移大于0.25表示显著漂移。当某个特征的PSI超过0.25时模型对这类输入的处理很可能已经不可靠需要检查是市场风格切换还是数据源出现了问题。这个方法比单纯盯准确率要早一步发现问题准确率下滑是结果特征漂移是原因。5.3 信号输出与交易决策的最小对接代码机器学习预测系统的最终产出是一个概率值而不是直接的买卖指令。把这层逻辑显式化能让系统保持纯粹和干净def get_signal(prob, long_threshold0.55, short_threshold0.45): if prob long_threshold: return 买入 elif prob short_threshold: return 空仓 else: return 观望 prob rolling_predict(df)[-1][1] # 最近一个交易日的预测概率 signal get_signal(prob) print(f预测上涨概率: {prob:.4f}, 信号: {signal})阈值55%和45%之间留出10个百分点的缓冲带避免模型在概率接近0.5时发出噪声信号。这个设计和预测周期是配套的5日预测周期配5日持仓信号给出后不是次日追涨而是观察入场时机。预测系统到这里已经可以独立跑起来了后续要接交易接口的把signal映射到对应的下单函数即可。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进