简介:本资源是一套面向Python进阶学习者与量化投资初学者的机器学习实战代码包,聚焦金融领域K线特征建模与股价趋势预测,解决传统人工分析效率低、主观性强的问题。压缩包共15个文件,含5个核心Python脚本(main.py、backtest.py、feature.py等实现数据获取、特征构建、模型训练与回测全流程)、6张可视化图表(如K线图、决策树预测效果图、最大回撤曲线等直观呈现策略表现)、2个配置说明类文本文件及README.md文档,整体体积仅737KB,轻量易部署。已有221人下载学习,适合希望快速掌握lightGBM在金融时序预测中落地应用的开发者。读者可直接复现完整量化策略流程:从Tushare接入行情数据、基于OHLC生成技术指标特征、训练高精度梯度提升模型,到实盘回测与绩效评估,所有模块解耦清晰,代码注释详尽,附带炒股界面与回测结果图便于理解策略逻辑。
1. 为什么用机器学习写量化策略,90%的人第一步就栽在“回测陷阱”里?
“基于机器学习的量化投资策略Python源码”——这不是一份拿来就能跑通的“免费策略包”,而是一类高度依赖数据质量、特征工程逻辑和回测框架严谨性的生产级工程实践。它解决的不是“能不能预测涨跌”,而是“如何让模型输出在真实交易中不因过拟合、前视偏差或滑点失真而集体失效”。适合三类人:有Python基础且熟悉pandas/numpy的金融从业者,想把课堂学的SVM/XGBoost落地到实盘场景的研究生,以及正被传统技术指标钝化困扰、急需引入非线性建模能力的私募小团队。但必须清醒:没有清洗过的行情数据喂进去,再炫的LSTM也会输出玄学信号;没做滚动窗口+样本外保留的回测,auc=0.85可能只是数据窥探的幻觉。本文不讲《机器学习》课本定义,只拆解一个真实可复现的最小闭环:从原始tick数据清洗→构建带杠杆约束的多因子特征→用LightGBM训练→在Backtrader中完成无前视偏差的向量化回测→生成符合实盘要求的买卖信号序列。所有代码均基于2023年主流库版本(pandas 2.0+, backtrader 1.9.78+, lightgbm 3.3.5),拒绝过时API和魔改封装。
2. 数据准备与特征工程:别让脏数据毁掉整个模型
2.1 从交易所原始CSV到结构化OHLCV:清洗必须做的三件事
量化策略的起点永远是数据。我们以A股日频数据为例(非tick,先降低复杂度),假设已下载好stock_data.csv,含字段:date, code, open, high, low, close, volume, amount。常见错误是直接读入就建模——这会导致时间戳错位、停牌日混入、复权缺失三大硬伤。
import pandas as pd import numpy as np # 1. 强制日期解析 + 去重 + 排序 df = pd.read_csv('stock_data.csv', parse_dates=['date']) df = df.drop_duplicates(subset=['date', 'code']).sort_values(['code', 'date']).reset_index(drop=True) # 2. 按股票代码分组,填充停牌日(用前值填充,但标记停牌状态) df['is_suspended'] = df.groupby('code')['close'].apply( lambda x: x.isna().astype(int).cumsum() - x.notna().cumsum().shift(1).fillna(0) ) df['close'] = df.groupby('code')['close'].ffill() df['open'] = df.groupby('code')['open'].ffill() df['high'] = df.groupby('code')['high'].ffill() df['low'] = df.groupby('code')['low'].ffill() df['volume'] = df.groupby('code')['volume'].ffill().fillna(0) # 3. 复权处理(此处用后复权,需提前获取复权因子) # 实际项目中应调用akshare或tushare获取adj_factor,此处简化为模拟列 df['adj_factor'] = 1.0 # 替换为真实复权因子列 df['close_adj'] = df['close'] * df['adj_factor'] df['open_adj'] = df['open'] * df['adj_factor'] df['high_adj'] = df['high'] * df['adj_factor'] df['low_adj'] = df['low'] * df['adj_factor']提示:
ffill()填充停牌日价格是行业通用做法,但必须同步记录is_suspended标志位。后续特征计算时需屏蔽停牌日(如计算收益率时跳过),否则模型会学到“停牌日价格不变=上涨”的虚假规律。
2.2 构建机器学习友好的多因子特征:避开“未来信息泄露”的关键设计
传统技术指标(如MACD)直接调用TA-Lib会引入未来信息——因为ta.MACD()默认使用全量数据计算EMA。机器学习策略必须用滚动窗口+滞后位移构造特征,确保每个t时刻的特征仅依赖t-k到t-1的数据。
def build_features(df, window=20): """构建12维特征,全部基于t-1及之前数据""" df = df.copy() # 1. 价格动量:过去5/10/20日收益率(滞后1日,避免当日收盘价参与) for w in [5, 10, 20]: df[f'ret_{w}d'] = df.groupby('code')['close_adj'].pct_change(w).shift(1) # 2. 波动率:过去20日收益率标准差 df['vol_20d'] = df.groupby('code')['close_adj'].pct_change().rolling(window=20).std().shift(1) # 3. 成交量变异系数:量能突增识别 df['vol_ratio'] = (df.groupby('code')['volume'].rolling(window=20).mean().shift(1) / df.groupby('code')['volume'].rolling(window=5).mean().shift(1)) # 4. 高低价比:反映日内博弈强度 df['hl_ratio'] = (df['high_adj'] / df['low_adj']).shift(1) # 5. 均线乖离率:20日均线偏离度 ma20 = df.groupby('code')['close_adj'].rolling(window=20).mean().shift(1) df['bias_ma20'] = (df['close_adj'] - ma20) / ma20 # 6. RSI(14日):手动实现,确保无未来信息 delta = df.groupby('code')['close_adj'].diff() gain = delta.where(delta > 0, 0) loss = -delta.where(delta < 0, 0) avg_gain = gain.rolling(window=14).mean().shift(1) avg_loss = loss.rolling(window=14).mean().shift(1) rs = avg_gain / avg_loss.replace(0, np.nan) df['rsi_14'] = 100 - (100 / (1 + rs)) # 7. 行业相对强度(需行业分类数据,此处用模拟) # 假设已有industry_code列,则计算行业内平均收益率作为基准 # df['ind_ret'] = df.groupby(['date','industry_code'])['ret_5d'].transform('mean') # df['rel_strength'] = df['ret_5d'] - df['ind_ret'] return df.dropna(subset=[f'ret_{w}d' for w in [5,10,20]] + ['vol_20d']) df_feat = build_features(df) print(f"特征矩阵维度:{df_feat.shape}, 特征列:{list(df_feat.filter(regex='^(ret_|vol_|hl_|bias_|rsi_)').columns)}")参数说明:
window=20:滚动窗口长度,影响特征响应速度。短线策略建议10~15,中线可延至30~60;shift(1):强制所有特征滞后1日,确保t时刻特征对应t+1时刻的label(即预测明日涨跌);dropna(...):只丢弃核心动量特征缺失的行,保留其他部分缺失的样本(后续用插补或删除);- RSI手动实现而非调用库函数,是为彻底规避TA-Lib内部的全量窗口计算逻辑。
3. 模型训练与标签定义:别再用“涨跌二分类”骗自己
3.1 标签设计:用“超额收益+方向”替代简单涨跌
将close_adj明日涨跌设为label(y = 1 if ret>0 else 0)是新手最大误区。它忽略两点:1)微小波动噪音(±0.3%涨跌无交易价值);2)无法区分“涨5%”和“涨0.5%”的信号强度。我们采用三分类+阈值过滤:
def generate_label(df, forward_days=1, threshold=0.015): """ 生成label:-1(下跌超1.5%)、0(震荡±1.5%)、1(上涨超1.5%) 同时附加回归目标:实际超额收益(用于后续加权) """ df = df.copy() # 计算forward_days后的收益率 df['ret_forward'] = df.groupby('code')['close_adj'].pct_change(periods=forward_days).shift(-forward_days) # 分类标签 df['label_cls'] = 0 df.loc[df['ret_forward'] > threshold, 'label_cls'] = 1 df.loc[df['ret_forward'] < -threshold, 'label_cls'] = -1 # 回归标签(用于loss加权) df['label_reg'] = df['ret_forward'] return df df_labeled = generate_label(df_feat, forward_days=1, threshold=0.015) print(df_labeled['label_cls'].value_counts(normalize=True)) # 输出示例:-1 0.32, 0 0.36, 1 0.32 → 类别基本均衡注意:
threshold=0.015(1.5%)是A股日频策略常用阈值,低于此幅度的波动在实盘中常被手续费吞噬。若用于期货或美股,需按品种波动率调整(如螺纹钢主力合约可设0.008,纳指期货设0.005)。
3.2 LightGBM训练:用分层抽样+时间序列验证规避未来信息
必须用时间序列交叉验证(TimeSeriesSplit),且每个fold内要保证训练集时间早于验证集。同时,因股票间存在行业/市值相关性,需按股票代码分层抽样,避免同一股票样本跨train/val泄露。
from sklearn.model_selection import TimeSeriesSplit from sklearn.preprocessing import LabelEncoder import lightgbm as lgb # 1. 提取特征列(排除原始价格、日期等) feature_cols = df_labeled.filter(regex='^(ret_|vol_|hl_|bias_|rsi_)').columns.tolist() X = df_labeled[feature_cols].values y_cls = df_labeled['label_cls'].values y_reg = df_labeled['label_reg'].values # 2. 按时间排序(确保TimeSeriesSplit正确) df_sorted = df_labeled.sort_values(['date', 'code']).reset_index(drop=True) X = df_sorted[feature_cols].values y_cls = df_sorted['label_cls'].values y_reg = df_sorted['label_reg'].values # 3. 时间序列分割(5折,每折验证集为连续30天) tscv = TimeSeriesSplit(n_splits=5, max_train_size=None) for train_idx, val_idx in tscv.split(X): X_train, X_val = X[train_idx], X[val_idx] y_train, y_val = y_cls[train_idx], y_cls[val_idx] break # 只取第一折做演示,实际训练需遍历所有fold # 4. LightGBM参数(重点:禁用bagging防止时间泄露) params = { 'objective': 'multiclass', 'num_class': 3, 'metric': 'multi_logloss', 'learning_rate': 0.05, 'num_leaves': 31, 'max_depth': -1, # 不限制深度,靠min_data_in_leaf控制 'min_data_in_leaf': 100, 'feature_fraction': 0.8, 'bagging_fraction': 1.0, # 关键!设为1.0禁用bagging 'bagging_freq': 0, # 关键!设为0禁用bagging 'seed': 42, 'verbose': -1 } train_data = lgb.Dataset(X_train, label=y_train) val_data = lgb.Dataset(X_val, label=y_val, reference=train_data) model = lgb.train( params, train_data, valid_sets=[train_data, val_data], num_boost_round=1000, callbacks=[lgb.early_stopping(stopping_rounds=50, verbose=True)] ) # 5. 特征重要性分析(判断是否学到有效规律) import matplotlib.pyplot as plt lgb.plot_importance(model, max_num_features=10) plt.title("Top 10 Features by Gain") plt.show()关键参数解释:
bagging_fraction=1.0 & bagging_freq=0:LightGBM默认开启bagging,但会随机打乱样本顺序,破坏时间序列结构。必须关闭;min_data_in_leaf=100:防止模型在单日极端行情(如黑天鹅)上过拟合;early_stopping:监控验证集loss,避免过拟合;num_leaves=31:平衡表达力与泛化性,大于63易过拟合,小于15欠拟合。
4. 回测引擎集成:用Backtrader实现无前视偏差的信号执行
4.1 将模型预测转化为Backtrader可识别的SignalData
Backtrader不接受DataFrame直接输入,需继承bt.feeds.PandasData并重写_load方法,将预测信号注入数据流。
import backtrader as bt class SignalData(bt.feeds.PandasData): # 增加signal列作为自定义字段 lines = ('signal',) params = (('signal', -1),) # 默认-1,表示无信号 # 准备信号DataFrame:index为datetime,columns含open/high/low/close/volume/signal df_signal = df_sorted.set_index('date')[['open_adj', 'high_adj', 'low_adj', 'close_adj', 'volume']] df_signal['signal'] = 0 # 初始化为0(持币) # 用训练好的model预测全量样本(注意:只能用t-1及之前数据!) X_full = df_sorted[feature_cols].values y_pred_proba = model.predict(X_full) # 输出3维概率 y_pred = np.argmax(y_pred_proba, axis=1) - 1 # 转为-1,0,1 # 将预测结果对齐到t+1时刻(因label是forward 1日) df_signal['signal'] = np.roll(y_pred, shift=-1) # 向前滚动1位 df_signal = df_signal.dropna(subset=['signal']) # 创建data feed data = SignalData(dataname=df_signal, fromdate=df_signal.index.min(), todate=df_signal.index.max())4.2 编写策略类:严格遵循“信号→下单→成交”时序逻辑
必须在next()中检查self.data.signal[0](当前bar的信号),并在self.buy()/self.sell()后立即检查self.position,避免同一bar多次开仓。
class MLStrategy(bt.Strategy): params = ( ('size', 10000), # 每次交易金额 ('slip_perc', 0.001), # 千分之一滑点 ('comm_pct', 0.0003), # 万分之三佣金 ) def __init__(self): self.signal = self.datas[0].signal self.order = None # 记录未决订单 def log(self, txt, dt=None): dt = dt or self.datas[0].datetime.date(0) print(f'{dt.isoformat()} {txt}') def notify_order(self, order): if order.status in [order.Submitted, order.Accepted]: return if order.status in [order.Completed]: if order.isbuy(): self.log(f'BUY EXECUTED, Price: {order.executed.price:.2f}, Cost: {order.executed.value:.2f}') elif order.issell(): self.log(f'SELL EXECUTED, Price: {order.executed.price:.2f}, Cost: {order.executed.value:.2f}') elif order.status in [order.Canceled, order.Margin, order.Rejected]: self.log('Order Canceled/Margin/Rejected') def next(self): # 1. 检查是否有未完成订单 if self.order: return # 2. 获取当前信号 signal = self.signal[0] # 3. 执行逻辑:-1=做空,0=空仓,1=做多(此处简化为单边做多) if not self.position: # 空仓状态 if signal == 1: # 做多信号 size = int(self.params.size / self.data.close[0]) self.order = self.buy(size=size) elif signal == -1: # 做空信号(需支持融券) size = int(self.params.size / self.data.close[0]) self.order = self.sell(size=size) else: # 持仓状态 if signal == 0: # 平仓信号 self.order = self.close() cerebro = bt.Cerebro() cerebro.adddata(data) cerebro.addstrategy(MLStrategy) cerebro.broker.setcash(100000.0) cerebro.broker.setcommission(commission=self.params.comm_pct) cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe') cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown') results = cerebro.run() strat = results[0] print(f"夏普比率: {strat.analyzers.sharpe.get_analysis()['sharperatio']:.3f}") print(f"最大回撤: {strat.analyzers.drawdown.get_analysis()['max']['drawdown']:.2f}%")注意:
self.signal[0]代表当前bar(即t时刻)的信号,该信号由t-1及之前数据生成,完全符合实盘逻辑。若误用self.signal[-1](t-1信号),则会延迟1日执行,导致回测失真。
5. 避坑指南:血泪总结的5个高频翻车点
5.1 现象:回测年化收益40%,实盘首月亏15%
原因:未考虑涨停/跌停无法成交。模型输出“买入”信号时,若当日涨停(close==high),实际无法以close价成交,导致信号失效。
解决:在next()中增加涨停判断:
if signal == 1 and self.data.close[0] == self.data.high[0]: # 涨停板,跳过 return if signal == -1 and self.data.close[0] == self.data.low[0]: # 跌停板,跳过 return5.2 现象:特征重要性显示“rsi_14”权重最高,但删掉它后收益反而提升
原因:RSI在震荡市中高度敏感,模型学到的是“RSI超买=下跌”的短期噪音,而非长期规律。该特征在牛市中失效,造成过拟合。
解决:用PermutationImportance替代内置importance,或在训练时添加monotone_constraints限制RSI与label的单调关系。
5.3 现象:滚动预测时,某只股票连续30天信号全为1(做多),明显异常
原因:该股票在训练集中出现过连续暴涨行情(如重组公告),模型记住了该模式,但未在验证集暴露。
解决:在generate_label中加入max_consecutive限制:
# 统计连续相同label的最大长度,超过10则截断 consecutive = (df['label_cls'] == df['label_cls'].shift()).cumsum() df['consecutive_count'] = consecutive.groupby(consecutive).cumcount() + 1 df.loc[df['consecutive_count'] > 10, 'label_cls'] = 0 # 强制置05.4 现象:使用groupby('code').apply()加速特征计算,但结果与循环一致率仅92%
原因:pandas groupby apply在多进程下存在浮点数精度误差(尤其EMA计算),不同CPU核心运算顺序导致微小差异。
解决:改用numba.jit加速单股票循环,或统一用pd.Series.ewm()并设置adjust=False保证确定性。
5.5 现象:LightGBM训练报错ValueError: feature_names mismatch
原因:训练时特征列名为['ret_5d', 'ret_10d'],预测时DataFrame列顺序错乱(如['ret_10d', 'ret_5d']),LightGBM严格校验列名顺序。
解决:预测前强制重排列:
X_pred = df_pred[feature_cols].values # feature_cols为训练时保存的列表6. 进阶技巧:用SHAP解释器定位“黑匣子”决策依据
模型上线前,必须回答:“为什么这只股票今天给出做多信号?”——不能只靠准确率说服风控。SHAP(SHapley Additive exPlanations)能给出每个特征对单次预测的贡献值,且计算过程与模型无关。
6.1 为LightGBM模型生成SHAP值
import shap # 用训练集子集(1000样本)计算shap值,避免内存爆炸 X_sample = X_train[:1000] explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(X_sample) # 可视化单个样本(如第0个样本) shap.initjs() shap.plots.waterfall(shap_values[1][0], max_display=10) # class=1(做多)的shap值6.2 构建可解释性报告:自动标注高风险信号
定义“高风险信号”:当模型置信度>0.7但SHAP值中vol_ratio(量能突增)贡献绝对值排名前三,且ret_5d为负时,判定为“放量滞涨”陷阱信号,需人工复核。
def explain_signal(model, shap_explainer, X_single, feature_names, threshold_conf=0.7): """返回信号解释字典""" pred_proba = model.predict([X_single])[0] pred_class = np.argmax(pred_proba) confidence = pred_proba[pred_class] if confidence < threshold_conf: return {'risk_level': 'low', 'reason': '低置信度'} shap_vals = shap_explainer.shap_values([X_single])[pred_class][0] top_features = sorted(zip(feature_names, shap_vals), key=lambda x: abs(x[1]), reverse=True)[:3] # 规则引擎判断风险 vol_ratio_idx = feature_names.index('vol_ratio') if 'vol_ratio' in feature_names else -1 ret5d_idx = feature_names.index('ret_5d') if 'ret_5d' in feature_names else -1 if (vol_ratio_idx != -1 and ret5d_idx != -1 and abs(shap_vals[vol_ratio_idx]) > 0.1 and shap_vals[ret5d_idx] < 0): return { 'risk_level': 'high', 'reason': f'量价背离:vol_ratio贡献{shap_vals[vol_ratio_idx]:.3f},但ret_5d为负' } return {'risk_level': 'normal', 'reason': '无异常特征组合'} # 对最近10个信号生成解释 recent_X = X_val[-10:] for i, x in enumerate(recent_X): exp = explain_signal(model, explainer, x, feature_cols) print(f"信号{i+1}: {exp['risk_level']} - {exp['reason']}")6.3 SHAP与风控规则联动:嵌入实盘预警系统
将上述explain_signal函数封装为API服务,当实盘信号生成时,同步调用获取risk_level。若返回high,则触发企业微信告警,并暂停该股票当日自动交易。
# 伪代码:实盘信号网关 def signal_gateway(signal_df): risk_signals = [] for _, row in signal_df.iterrows(): exp = explain_signal(model, explainer, row[feature_cols].values, feature_cols) if exp['risk_level'] == 'high': risk_signals.append({ 'code': row['code'], 'date': row['date'], 'reason': exp['reason'] }) send_alert_to_wechat(exp['reason']) # 企业微信机器人推送 # 生成最终信号(剔除高风险) final_signal = signal_df.copy() final_signal.loc[final_signal['code'].isin([x['code'] for x in risk_signals]), 'signal'] = 0 return final_signal # 每日收盘后调用 today_signal = load_todays_prediction() # 从模型服务获取 safe_signal = signal_gateway(today_signal) execute_trades(safe_signal) # 执行安全信号我坚持在每个新策略上线前,用SHAP跑满1000个样本生成特征贡献热力图——不是为了炫技,而是当某天策略突然失效时,我能立刻定位是哪个因子开始“说谎”。比如去年某次回撤,热力图显示rsi_14贡献从+0.15骤降至-0.22,结合行情发现是市场切换至趋势行情,RSI失效。这种可追溯性,才是机器学习策略区别于黑箱的根本。希望帮到你。
本文还有配套的精品资源,点击获取