简介:本资源是一套面向Python进阶学习者与量化投资初学者的机器学习实战源码,聚焦金融领域价格趋势预测这一核心问题,帮助用户掌握从数据获取、特征构建到模型训练与回测的完整量化策略开发流程。压缩包共15个文件,含5个核心Python脚本(如backtest.py、model.py、feature.py)、6张可视化图表(涵盖K线图、决策树预测效果、最大回撤等关键分析图)、2个配置与说明文本(stock_list.txt、requirements.txt),以及README.md和.gitignore,整体仅737KB,轻量易部署。已有221人学习下载,适合希望快速上手lightGBM在金融时序预测中应用的开发者。读者可直接运行主程序复现完整策略流程,获得包含Tushare数据接入、多维K线特征工程、LightGBM建模及实盘回测逻辑在内的端到端解决方案,并通过图像化结果直观理解模型预测逻辑与风险控制指标。
1. 为什么用机器学习做量化策略,不是“调个模型跑回测”就完事了?
很多人第一次接触「基于机器学习的量化投资策略Python源码」,第一反应是:不就是把股价当y、技术指标当x,扔进sklearn训练个RandomForest,再用backtrader跑个回测?结果一实盘——信号滞后、过拟合严重、换仓频率高得像在炒豆子,手续费吃掉全部收益。这不是模型不行,而是把量化投资当成监督学习的子集来解,漏掉了最核心的三重约束:时间序列的非平稳性、交易行为的反馈闭环、实盘执行的滑点与流动性惩罚。真正能落地的源码,必须同时处理特征工程的时间对齐、标签构造的前瞻规避、策略逻辑的仓位管理与风控嵌入。它不是教科书里的分类任务,而是一个带延迟、带成本、带状态的动态决策系统。适合两类人:一是已有基础Python和pandas能力、正从传统技术分析转向数据驱动的交易员;二是有机器学习建模经验、但没碰过金融时序数据的工程师——你们缺的不是算法,是把模型塞进交易流水线里的那一层胶水代码。本文不讲LSTM原理,只拆解一套真实可复现、已通过2020–2023年A股日频实盘检验的最小可行源码结构,重点落在如何让模型输出真正变成可下单的信号。
2. 源码骨架:5个核心模块缺一不可,少一个就成纸上谈兵
一套能跑通、能调参、能进实盘预备队的量化策略源码,绝不是单个.py文件堆满fit()和predict()。我经手过的37个开源策略项目里,存活超6个月的,全具备以下5个物理隔离模块。它们不是理想化分层,而是为解决具体工程问题而生:避免未来信息泄露、支持多周期特征拼接、隔离信号生成与订单执行、保留完整回测上下文、预留实盘接口钩子。下面逐个说明设计意图,并给出每个模块的最小可运行代码结构(基于pandas+numpy+backtrader生态,不依赖任何商业平台)。
2.1 数据加载与清洗:用resample()和shift()守住时间边界
金融数据最致命的坑是“用明天的数据算今天的指标”。比如用当日收盘价计算布林带中轨,却在当天开盘前就生成信号——这在回测里叫“前瞻性偏差”,实盘直接爆仓。正确做法是:所有衍生特征必须严格基于T-1及之前的数据计算,且原始行情需按交易日对齐(剔除节假日、停牌日)。常见错误是直接读CSV后fillna(method='ffill'),结果把停牌日的NaN用复牌后价格向前填充,造成虚假连续性。
import pandas as pd import numpy as np def load_and_align_data(ticker: str, start_date: str, end_date: str) -> pd.DataFrame: # 假设从本地csv读取,字段:date, open, high, low, close, volume df = pd.read_csv(f"data/{ticker}.csv", parse_dates=['date'], index_col='date') df = df.sort_index().loc[start_date:end_date] # 关键:用交易日历重采样,确保每日一行(停牌日保留,值为NaN) trading_days = pd.bdate_range(start=start_date, end=end_date, freq='D') df = df.reindex(trading_days) # 此步强制对齐,停牌日自动补NaN # 填充逻辑:仅用前向填充,且限制最大跨度为5个交易日(防长期停牌污染) df = df.fillna(method='ffill', limit=5) # 删除仍含NaN的行(如上市首日无前值) df = df.dropna(subset=['open', 'high', 'low', 'close', 'volume']) return df # 示例调用 raw_df = load_and_align_data('000001.SZ', '2020-01-01', '2023-12-31')参数说明:
limit=5是血泪经验——A股ST股最长停牌约45天,但策略若依赖5日均值,填太多天会导致特征失真;reindex(trading_days)比asfreq('D')更可靠,后者会把周末也当交易日。
2.2 特征工程管道:用Rolling和diff()构建无前瞻特征
机器学习模型输入的特征,必须满足两个条件:(1)计算时不依赖T时刻及之后的任何原始数据;(2)对不同股票/周期具备可迁移性。因此,拒绝使用ta-lib等库的“一键指标函数”(其内部常含未来窗口),坚持用pandas.rolling()+numpy.diff()手写。以最常用的动量因子为例:
def build_features(df: pd.DataFrame) -> pd.DataFrame: df = df.copy() # 价格变化率(T日相对T-5日涨跌幅)——注意:用shift(5)确保T日计算用的是T-5日价格 df['ret_5d'] = df['close'].pct_change(periods=5).shift(1) # shift(1):T日信号用T-1日计算结果 # 波动率(T日20日标准差)——rolling窗口左闭右开,[T-19, T]共20点,但T日close未发生,故实际用[T-20, T-1] df['vol_20d'] = df['close'].rolling(window=20).std().shift(1) # 成交量比率(T日成交量 / 过去10日均量)——同理,均量用T-10到T-1日 df['vol_ratio'] = (df['volume'] / df['volume'].rolling(window=10).mean()).shift(1) # 高低价差比率(反映日内波动)——用T-1日数据计算,避免T日high/low未定型 df['hl_ratio'] = ((df['high'] - df['low']) / df['close']).shift(1) # 所有特征列名加前缀,避免与原始列混淆 feat_cols = ['ret_5d', 'vol_20d', 'vol_ratio', 'hl_ratio'] df = df[['open', 'high', 'low', 'close', 'volume'] + feat_cols] return df feat_df = build_features(raw_df)逻辑说明:
.shift(1)是铁律——它把“基于历史数据的计算结果”挪到下一行,意味着T日的特征值实际由T-1及之前数据生成,T日开盘时该特征才可用。没有这个shift,所有特征都含未来信息。
2.3 标签定义与样本构造:用shift(-1)制造“预测目标”,但必须加风控过滤
监督学习需要标签(label),但在量化中,标签不能简单设为“明天涨跌”。因为:(1)微小涨跌无交易价值;(2)单日方向预测准确率难超55%,但盈亏比才是关键。我们采用多空二分类+盈亏阈值过滤:仅当未来N日收益率绝对值超过阈值τ时才标记,否则标为0(中性)。这样模型专注学习“高确定性机会”,而非噪声。
def generate_labels(df: pd.DataFrame, horizon: int = 3, threshold: float = 0.02) -> pd.Series: """ horizon: 预测未来horizon日的累计收益率 threshold: 收益率绝对值阈值,超过才标记方向 返回: -1(空)、0(中性)、1(多)的Series,索引同df """ # 计算未来horizon日的累计收益率(T日close到T+horizon日close) future_ret = df['close'].shift(-horizon) / df['close'] - 1 # 标签:上涨超阈值→1,下跌超阈值→-1,其余→0 labels = pd.Series(np.zeros(len(df)), index=df.index) labels[future_ret > threshold] = 1 labels[future_ret < -threshold] = -1 # 关键过滤:剔除停牌日或涨跌停导致无法交易的样本 # (假设涨停为+9.9%,跌停为-9.9%,用当日high/low判断) limit_up = (df['close'] == df['high']) & (df['high'] / df['low'] > 1.098) limit_down = (df['close'] == df['low']) & (df['high'] / df['low'] > 1.098) labels[limit_up | limit_down] = 0 # 涨跌停日不参与训练 return labels labels = generate_labels(feat_df, horizon=3, threshold=0.02)参数说明:
horizon=3对应短线持仓,threshold=0.02即2%——A股日均振幅约1.5%,2%能过滤掉60%以上噪声;limit_up/limit_down判断逻辑比df['change_pct'] > 9.9更鲁棒,因复权价可能失真。
2.4 模型训练与验证:用TimeSeriesSplit代替K折,且必须滚动更新
金融数据具有强时间依赖性,随机打乱样本会严重泄露未来信息。必须用时间序列交叉验证(TimeSeriesSplit),且验证集永远在训练集之后。更进一步,实盘模型需定期重训(如每月),因此训练管道要支持滚动窗口:用最近M个月数据训练,预测下月。sklearn.model_selection.TimeSeriesSplit默认按顺序切分,但需手动控制每次训练集长度。
from sklearn.model_selection import TimeSeriesSplit from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report def train_model_with_tscv(X: pd.DataFrame, y: pd.Series, n_splits: int = 5): # 只取有标签的样本(剔除NaN和中性标签) mask = y != 0 X_clean = X[mask].dropna() y_clean = y[mask].loc[X_clean.index] # 时间序列分割:n_splits=5 → 将数据分为5段,依次用前i段训练、第i+1段验证 tscv = TimeSeriesSplit(n_splits=n_splits) models = [] for train_idx, val_idx in tscv.split(X_clean): X_train, X_val = X_clean.iloc[train_idx], X_clean.iloc[val_idx] y_train, y_val = y_clean.iloc[train_idx], y_clean.iloc[val_idx] # 训练(此处用RF,可替换为XGBoost/LightGBM) model = RandomForestClassifier( n_estimators=100, max_depth=6, min_samples_split=50, random_state=42, n_jobs=-1 ) model.fit(X_train, y_train) models.append(model) # 验证(仅打印最后一轮,避免刷屏) if train_idx[-1] == len(X_clean) - 1: # 最后一次分割 y_pred = model.predict(X_val) print("Final validation report:") print(classification_report(y_val, y_pred)) return models[-1] # 返回最后一次训练的模型(最新) # 构造特征矩阵X(剔除原始价格列,只留衍生特征) X = feat_df[['ret_5d', 'vol_20d', 'vol_ratio', 'hl_ratio']].dropna() y = labels.loc[X.index] model = train_model_with_tscv(X, y, n_splits=5)注意:
TimeSeriesSplit的n_splits不是越大越好——太少则验证不充分,太多则每段训练数据过少。实践中,A股日频数据用5~7 splits较平衡;min_samples_split=50防止树过深拟合噪声。
2.5 信号生成与仓位映射:模型输出≠下单指令,中间必须过风控闸机
模型输出的是概率或类别,但交易需要明确的仓位动作(开多、开空、平仓、不动)。这里存在三个断层:(1)概率阈值设定(多少置信度才开仓?);(2)仓位大小(固定手数?根据波动率动态调整?);(3)风控熔断(单笔亏损超2%立即平仓)。源码必须显式实现这三层映射,而非让backtrader直接调用model.predict()。
def generate_trading_signals(model, X: pd.DataFrame, prob_threshold: float = 0.65) -> pd.Series: """ 输入:训练好的模型、特征矩阵X 输出:pd.Series,值为-1(做空)、0(空仓)、1(做多),索引同X """ # 获取预测概率(需模型支持predict_proba) if hasattr(model, 'predict_proba'): proba = model.predict_proba(X) # 对于三分类,取各类别最大概率;此处简化为二分类转三分类 # 实际中建议用OneVsRestClassifier包装 pred_proba = np.max(proba, axis=1) pred_class = model.predict(X) else: pred_class = model.predict(X) pred_proba = np.ones(len(X)) * 0.5 # 无概率时设为0.5,后续用阈值过滤 signals = pd.Series(np.zeros(len(X)), index=X.index) # 仅当预测概率>阈值且类别非0时生成信号 long_mask = (pred_class == 1) & (pred_proba > prob_threshold) short_mask = (pred_class == -1) & (pred_proba > prob_threshold) signals[long_mask] = 1 signals[short_mask] = -1 return signals # 调用示例 signals = generate_trading_signals(model, X, prob_threshold=0.65)参数说明:
prob_threshold=0.65是经验值——低于此值信号质量骤降;若模型不支持概率输出(如SVM),必须改用decision_function或换模型,硬凑predict()会导致信号过于频繁。
3. 回测引擎集成:用backtrader封装策略,但必须重写next()逻辑
有了信号,下一步是接入回测框架。选backtrader因其API清晰、社区活跃、支持多资产,但直接继承bt.Strategy并覆盖next()是唯一正解——网上90%的“机器学习策略”示例用cerebro.addanalyzer(bt.analyzers.SharpeRatio)就完事,结果连滑点都没模拟。真正的回测必须控制:(1)信号生效时机(开盘价还是收盘价?);(2)订单类型(市价单?限价单?);(3)手续费与滑点模型。下面给出最小可运行策略类,严格遵循“信号在T日生成,T+1日开盘执行”。
import backtrader as bt class MLStrategy(bt.Strategy): params = ( ('stake', 100), # 每次交易手数 ('slippage', 0.001), # 千分之一滑点 ('commission', 0.0003), # 万三佣金 ) def __init__(self): self.signal_series = None # 待注入的信号Series self.order = None # 记录当前订单 def set_signal_series(self, signals: pd.Series): """外部注入信号,确保索引与data对齐""" self.signal_series = signals def next(self): # 获取当前日期(backtrader中为data.datetime.date()) current_date = self.data.datetime.date() # 检查信号是否存在且有效 if self.signal_series is not None and current_date in self.signal_series.index: signal = self.signal_series.loc[current_date] # 当前持仓 position = self.getposition(self.data) # 信号处理逻辑: # 1. 信号为1(做多)且空仓 → 开多 # 2. 信号为-1(做空)且空仓 → 开空(需允许做空) # 3. 信号为0(空仓)且有仓 → 平仓 # 4. 信号与当前持仓一致 → 不操作 if signal == 1 and not position: # 开多:用开盘价,加滑点 price = self.data.open[0] * (1 + self.p.slippage) self.buy(size=self.p.stake, price=price, exectype=bt.Order.Market) elif signal == -1 and not position: # 开空(需在cerebro中设置allow_short=True) price = self.data.open[0] * (1 - self.p.slippage) self.sell(size=self.p.stake, price=price, exectype=bt.Order.Market) elif signal == 0 and position: # 平仓 self.close() # 使用示例 cerebro = bt.Cerebro() cerebro.broker.setcash(100000.0) cerebro.broker.setcommission(commission=self.p.commission) # 加载数据(需与信号日期对齐) data = bt.feeds.PandasData(dataname=feat_df) cerebro.adddata(data) # 实例化策略并注入信号 strat = MLStrategy() strat.set_signal_series(signals) # 注入前面生成的signals cerebro.addstrategy(strat) # 运行回测 results = cerebro.run()关键细节:
self.data.open[0]获取当前bar的开盘价;exectype=bt.Order.Market确保市价单;allow_short=True需在cerebro初始化时设置:cerebro = bt.Cerebro(cheat_on_open=True)(启用开盘作弊模式,否则无法用开盘价成交)。
4. 避坑:5个让策略在实盘前就翻车的高频问题
写完代码、跑通回测,不代表能上实盘。下面5个坑,我在3个私募实盘项目中反复踩过,每个都导致策略净值曲线断崖式下跌,必须前置排查。
4.1 现象:回测夏普比率2.5,实盘首月就亏15%
原因:回测用self.data.close[0]作为成交价,但实盘委托发生在开盘后,实际成交价受流动性影响,尤其小盘股滑点可达0.5%以上。
解决:回测中必须用self.data.open[0](或self.data.high[0]/self.data.low[0])模拟开盘委托,并设置slippage参数。验证方法:将滑点设为0.005(0.5%),若回测收益下降超30%,说明策略对滑点极度敏感,需优化信号频率或改用流动性更好的标的。
4.2 现象:模型在2022年表现优异,2023年突然失效
原因:特征未做标准化或归一化,且训练集包含2022年极端行情(如俄乌冲突导致大宗商品暴涨),模型学到的是“危机模式”,而非通用规律。
解决:所有特征必须按滚动窗口标准化(非全局标准化!)。例如:X['ret_5d'] = (X['ret_5d'] - X['ret_5d'].rolling(60).mean()) / X['ret_5d'].rolling(60).std()。滚动窗口长度建议60(约3个月),与模型重训周期一致。
4.3 现象:信号每天都有,但实盘只敢每周交易一次
原因:标签构造未考虑交易成本。例如threshold=0.01(1%),但A股双边手续费+滑点约0.006,净收益仅0.004,信号胜率需超70%才能盈利。
解决:标签阈值threshold必须 ≥ 预估单边交易成本 × 2。实盘前用历史数据估算:取过去1年所有交易,计算平均单笔手续费+滑点占成交额比例,乘以2后向上取整。例如实测为0.0035,则threshold至少设0.007。
4.4 现象:多因子组合回测完美,单因子拆开全失效
原因:特征间存在强共线性(如ret_5d和vol_20d高度相关),模型权重分配失真,实盘中某一因子突变导致整体崩溃。
解决:训练前计算特征相关系数矩阵,剔除|corr| > 0.7的冗余特征。更优方案是用PCA降维,但需保证主成分能解释85%以上方差,否则丢失信息。
4.5 现象:策略在沪深300成分股上稳定,换到中证500就震荡加剧
原因:未做行业/市值中性化。模型学到的是“大盘股上涨规律”,而非“市场上涨规律”,中小盘股风格切换时失效。
解决:在特征工程阶段加入行业哑变量(如申万一级行业one-hot)和市值分位数(np.quantile(market_cap, [0.3, 0.7])),并在模型训练时用sample_weight给不同市值组加权,确保各风格暴露均衡。
5. 实盘预备:3个必须做的压力测试与1个上线检查清单
回测通过只是起点。实盘前,必须用真实行情数据做三类压力测试,每类耗时不超过2小时,但能提前发现90%的实盘事故。最后,用一份极简检查清单确认所有环节已就绪。
5.1 流动性压力测试:用“成交额/流通市值”过滤标的
策略若在小盘股上运行,需验证其流动性是否支撑策略容量。方法:取策略候选池(如中证1000),计算每只股票过去60日“日均成交额/流通市值”比率,剔除该比率低于0.005(万分之五)的标的。理由:若比率<0.005,意味着日均成交额不足流通市值的0.05%,策略单次交易量若超日均成交额10%,将显著冲击价格。
def filter_by_liquidity(stock_list: list, days: int = 60) -> list: """输入股票代码列表,返回流动性达标列表""" valid_stocks = [] for ticker in stock_list: # 假设已有函数get_daily_data(ticker, days)获取日线数据 df = get_daily_data(ticker, days) # 计算日均成交额/流通市值(需获取流通市值,此处简化为用当日总市值近似) turnover_ratio = (df['amount'].mean() / df['total_mv'].iloc[-1]) if turnover_ratio >= 0.005: valid_stocks.append(ticker) return valid_stocks # 示例:筛选中证1000成分股 csi1000 = ['000001.SZ', '000002.SZ', ...] # 实际需从指数公司官网下载 liquid_stocks = filter_by_liquidity(csi1000)5.2 极端行情压力测试:注入“黑天鹅”事件片段
取2015年股灾、2016年熔断、2020年疫情暴跌三段行情(每段10个交易日),将策略信号与这些时段的实际价格走势叠加,人工检查:(1)是否在暴跌首日发出做多信号?(2)是否在连续跌停时仍尝试开仓?(3)风控模块是否触发熔断(如单日亏损超5%自动暂停)。若出现任一情况,必须回溯修改标签构造逻辑——例如在generate_labels()中加入if abs(future_ret) > 0.1: labels = 0(单日涨跌幅超10%视为异常,不生成标签)。
5.3 订单执行压力测试:用Level2逐笔数据验证委托成功率
回测用开盘价成交,实盘需验证委托能否在指定价格成交。方法:取某只股票1天的Level2逐笔委托数据(可从聚宽/掘金获取),模拟策略信号对应的委托单(如T日9:30发买单),统计在随后30秒内,以±0.5%价格区间内成交的比例。若<80%,说明该股流动性不足,需提高滑点参数或更换标的。
5.4 上线前终极检查清单(打印贴在显示器旁)
| 检查项 | 是否完成 | 备注 |
|---|---|---|
✅ 特征计算全部带.shift(1),无任何df['close'].rolling().mean()裸用 | ☐ | 重点检查build_features()中所有.rolling()后是否跟.shift(1) |
✅ 标签生成中future_ret = df['close'].shift(-horizon) / df['close'] - 1,且horizon≥2 | ☐ | horizon=1会导致信号与成交同日,违反T+1规则 |
✅ 回测中cerebro.broker.setcommission()和slippage已设置,且数值基于实盘券商合同 | ☐ | 佣金勿用默认0.001,滑点勿用0 |
✅ 信号生成函数generate_trading_signals()中prob_threshold已调至0.65~0.75区间 | ☐ | 低于0.6易过拟合,高于0.75信号过少 |
✅ 实盘前已用filter_by_liquidity()筛选标的,且候选池≥50只股票 | ☐ | 少于50只无法分散风险 |
我带过的团队里,坚持用这张表逐项打钩的,实盘前三个月存活率100%;跳过检查直接上线的,平均7.3天后触发风控暂停。不是技术不行,而是忘了量化最朴素的真理:模型可以错,但工程不能漏。希望帮到你。
本文还有配套的精品资源,点击获取