简介面向量化交易与机器学习初学者这套算法交易实验代码以苹果股价预测为场景分别采用遗传编程和遗传算法两种进化计算策略。遗传编程通过进化基于树的种群来最小化预测价格与实际价格之间的误差并融合纳斯达克、苹果、标普等市场信号遗传算法则进化字符串以猜测第二天的股价涨跌两种思路形成鲜明对比。资源共40个文件、约1.21MB以21个Python脚本、8个CSV行情数据、7个TXT说明及2个Markdown文档为主体Python脚本覆盖策略实现与数据预处理CSV提供测试集文档给出运行配置与使用指引。已有330人参与学习。这套代码内含完整可运行项目、配套数据与README说明便于读者在本地复现实验并对比不同进化计算在特征构造和预测效果上的差异适合作为课程设计或入门量化交易的参考。1. 算法交易程序里用遗传编程和遗传算法预测股票价格到底靠不靠谱算法交易程序里用遗传编程和遗传算法去预测股票价格很多人第一反应是玄学。我最早也这么想直到发现真正能落地的地方和直觉完全不一样直接预测涨跌成功率上不去但把进化搜索用在两个环节——特征选择和交易规则发现——效果是肉眼可见的。这套方案就是先用遗传算法从几十个技术因子里挑出有效组合再用遗传编程演化出可解释的买卖规则最后在历史数据上做带摩擦成本的回测。适合已经会 Python 基础、跑通过简单回测、被手工调参烦够的人。做完这套你得到的不是黑匣子而是一条能反复迭代的策略搜索流水线。2. 从数据到问题定义价格序列怎么变成进化算法能优化的问题2.1 数据获取与前复权别让除权除息污染你的特征写 python 量化交易策略代码的人都有个共同习惯从数据接口把日线拉回来就直接算指标。这个习惯在常规策略里问题不大但放到遗传算法这种“疯狂组合特征”的场景里除权除息产生的价格跳空会被进化过程当成有效信号于是演化出来的规则可能在每个分红日附近反复开仓。常见做法是用前复权价格也就是把历史价格统一到当前股本口径下让价格序列连续可比。用 yfinance 拉数据时把 auto_adjust 打开就是前复权我在本地一般这样写import yfinance as yf import pandas as pd df yf.download(600519.SS, start2018-01-01, end2024-01-01, auto_adjustTrue, progressFalse) df.columns [c.lower() for c in df.columns] close df[close] volume df[volume] print(close.head())逻辑说明auto_adjustTrue 会同时调整 open、high、low、close 四个价格避免复权因子不一致导致后续指标算错。volume 在除权日也会失真但量化里通常只做相对变化处理不直接当绝对数用。参数说明里最值得记住的是 start/end 要留够窗口因为后面要算 5 日、10 日、20 日多组滚动指标前 20 行会因为窗口不足全是 NaN得统一丢掉。数据拿到后我一般会顺手做一次肉眼检查把复权后的收盘价画出来看在财报季附近有没有异常跳空。如果发现某天单日涨跌幅超过 20%先别急着当机会大概率是数据源配股或者复牌导致的这类样本要么剔除要么单独打标记不能混进训练集。2.2 特征与标签构造先定好“未来收益”再谈预测遗传算法要优化的是“预测股票价格”这个目标但价格本身是非平稳的直接回归预测价格会让模型学到“昨天的价格就是今天的价格”这种垃圾规律。正确做法是把问题转换成预测未来 N 日收益再把收益二值化成分类标签或者保留连续值给遗传编程做回归。我个人常用的特征集合是动量类、均值类、波动类和成交量类总共 20 到 30 个。这里的关键是每个特征在 T 日只能用到 T 日及以前的数据这是我踩过最深的一个坑后面会专门讲。先看代码def build_features(close, volume): df pd.DataFrame({close: close, volume: volume}) # 均线类 for w in (5, 10, 20, 60): df[fma{w}] df[close].rolling(w).mean() df[fclose_ma{w}_ratio] df[close] / df[fma{w}] - 1 # 波动类 df[ret_1d] df[close].pct_change() for w in (5, 10, 20): df[fvolatility_{w}] df[ret_1d].rolling(w).std() # 量价类 df[volume_ma5] df[volume].rolling(5).mean() df[volume_ratio] df[volume] / df[volume_ma5] - 1 # RSI 近似实现 diff df[close].diff() up diff.clip(lower0).rolling(14).mean() down (-diff.clip(upper0)).rolling(14).mean() df[rsi_14] 100 - 100 / (1 up / (down 1e-9)) return df.dropna()逻辑说明close_ma_w_ratio 这类比值特征比绝对价格更适合进化算法因为不同股票的价格绝对值差异很大比值天然做了无量纲化。volatility 用滚动标准差反映的是近期波动状态。volume_ratio 用来捕捉放量缩量这个特征在 A 股和美股都有一定区分度。标签构造是整套方案里最需要较真的地方。这里有一个新手的常见误解用 close.pct_change(5) 然后 shift(-5) 作为未来收益表面上是未来 5 日收益但因为 pct_change(5) 的起点是 T-5终点是 Tshift(-5) 之后标签变成了 T5 的数据方向对不上。我一般这样构造future_ret close.shift(-6) / close.shift(-1) - 1 label_up (future_ret 0.005).astype(int) label_reg future_ret说明close.shift(-6) 是 T6 日收盘价close.shift(-1) 是 T1 日收盘价两者相减得到的是从 T1 收盘到 T6 收盘的持有期收益。为什么要从 T1 开始而不是从 T 收盘开始因为特征里包含了 T 日收盘价如果标签也从 T 收盘起算模型会从收盘价里学到大量和未来 5 日收益相关的“脏信息”这在严格意义上属于泄漏。实测下来用这种构造方式进化搜出来的特征明显更稳样本外衰减也更慢。2.3 时间序列切分随机打乱是回测造假的第一步遗传算法天然容易过拟合这几乎是写在基因里的。所以数据切分上绝对不能像普通机器学习那样用 train_test_split 随机打乱。时间序列数据一旦打乱训练集里会混入未来信息进化算法会把“记住未来”当成最优解样本外直接翻车。我一般把数据切成三段前 60% 做特征选择和规则演化的训练段中间 20% 做验证段最后 20% 做样本外测试段。验证段用来做早停和参数选择测试段只在最后跑一次。这里的时间切分用 pandas 的切片就能完成cut1 int(len(df) * 0.6) cut2 int(len(df) * 0.8) X_train, X_val, X_test df.iloc[:cut1], df.iloc[cut1:cut2], df.iloc[cut2:] y_train, y_val, y_test label_reg.iloc[:cut1], label_reg.iloc[cut1:cut2], label_reg.iloc[cut2:]参数说明60/20/20 不是死标准但如果样本只有几千根日线我建议把训练段比例提高到 70%因为遗传算法种群大、代数多太短的训练段会让适应度估计方差变大。另一个细节是切分前先 dropna否则 rolling 指标前面一堆 NaN 会让切分的实际位置和索引对不上。3. 遗传算法做特征选择把 30 个技术因子砍到 8 个3.1 为什么特征选择要先于规则演化特征工程做完你手上通常有二三十个因子。直接丢给遗传编程去演化规则不是不行但搜索空间会大到离谱假设每个因子有 5 种变换方式再组合成树暴力搜索需要跑几天。而遗传算法做特征选择相当于先降维把真正有用的因子筛出来再做规则演化精度和速度都会好很多。选择遗传算法而不是常规的相关系数过滤或者 Lasso原因是技术因子之间高度相关而且有效因子往往藏在交互作用里。比如单独看 RSI 可能没用但 RSI 和成交量比率的组合在震荡行情里区分度很强这类交互用线性方法很难捕捉而遗传算法天然在组合空间里搜索。GA 的输出是一个 0/1 编码的特征子集这个子集本身就是可以解释的和黑匣子不同。你说它进化的过程是黑匣子没错但最后你拿到的是“哪几个特征被选中”这一步的可解释性比神经网络强得多。3.2 基于 DEAP 实现 GA 特征选择代码骨架DEAP 是 Python 里做遗传算法最常用的库网上遗传算法 python 代码详解大部分也是基于它。核心概念就四个个体、种群、适应度函数、遗传算子。个体在这里是定长的 0/1 列表1 表示选中对应特征适应度函数用时间序列交叉验证的 AUC遗传算子用两点交叉和位翻转变异。import random import numpy as np from deap import base, creator, tools, algorithms from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import TimeSeriesSplit from sklearn.metrics import roc_auc_score random.seed(42) np.random.seed(42) creator.create(FitnessMax, base.Fitness, weights(1.0,)) creator.create(Individual, list, fitnesscreator.FitnessMax) feature_cols [c for c in X_train.columns if c.startswith((ma, close, vol, rsi, ret))] def eval_features(individual, X, y): cols [i for i, v in enumerate(individual) if v 1] if len(cols) 2: return 0.0, tscv TimeSeriesSplit(n_splits4) aucs [] for trn_idx, val_idx in tscv.split(X): clf RandomForestClassifier(n_estimators50, max_depth3, random_state0) try: clf.fit(X.iloc[trn_idx, cols], y.iloc[trn_idx]) proba clf.predict_proba(X.iloc[val_idx, cols])[:, 1] aucs.append(roc_auc_score(y.iloc[val_idx], proba)) except ValueError: return 0.0, return np.mean(aucs), toolbox base.Toolbox() toolbox.register(attr_bool, random.randint, 0, 1) toolbox.register(individual, tools.initRepeat, creator.Individual, toolbox.attr_bool, nlen(feature_cols)) toolbox.register(population, tools.initRepeat, list, toolbox.individual) toolbox.register(evaluate, eval_features, XX_train, yy_train) toolbox.register(mate, tools.cxTwoPoint) toolbox.register(mutate, tools.mutFlipBit, indpb0.05) toolbox.register(select, tools.selTournament, tournsize3) pop toolbox.population(n40) for ind in pop: ind.fitness.values toolbox.evaluate(ind) for gen in range(30): offspring algorithms.varAnd(pop, toolbox, cxpb0.6, mutpb0.3) for ind in offspring: ind.fitness.values toolbox.evaluate(ind) pop toolbox.select(offspring, klen(pop)) best tools.selBest(pop, k1)[0] print(fgen {gen}: best auc {best.fitness.values[0]:.4f})逻辑说明eval_features 里用 TimeSeriesSplit 做 4 折时间序列交叉验证每折只按时间先后划分避免随机洗牌泄漏未来。分类器选 RandomForest 而不是逻辑回归是因为技术因子和未来收益之间大多是非线性关系浅层随机森林能在不引入太多过拟合的前提下捕捉交互。如果只有两个特征被选中直接给 0 分因为太少的特征几乎不可能构成稳定策略。参数说明里重点是三个indpb0.05 控制变异概率太大了最优解容易被拆散太小了种群会早熟cxpb0.6 是交叉概率两点交叉对 0/1 编码效果比单点好tournsize3 是锦标赛选择的竞争人数越大选择压力越大但过大会导致种群多样性快速下降。另外代码里用 varAnd 配合 selTournament没有精英保留实际项目里我建议在每代末尾把上一代最优的 10% 个体直接塞回下一代防止最优解在交叉变异中丢失这是 ga 遗传算法实践里性价比最高的一个小改动。3.3 适应度设计为什么用 AUC 而不是直接算策略收益特征选择阶段的适应度函数不要用策略收益。原因很简单策略收益涉及仓位、交易成本、信号滞后一堆因素优化它等于在特征选择阶段就把整个策略流水线卷进来搜索空间暴增而且收益曲线噪声极大两代之间可能因为一笔交易产生巨大方差进化过程很不稳定。我用 AUC 是因为它对类别不平衡不敏感而且和“排序能力”直接挂钩。预测股票涨跌本质上是排序问题AUC 衡量的是模型把涨的排在跌的前面的概率比准确率有意义得多。我还会加一个“稳定性惩罚”如果一次特征子集在 4 折交叉验证里的 AUC 标准差超过 0.03就在均值的基础上扣 0.02。这个惩罚不重但能过滤掉那些偶尔爆出高分、整体不稳定的组合。GA 结束后把最终特征子集对应的列名打印出来人工过一遍确认没有明显逻辑硬伤再进入遗传编程阶段。特征选择阶段常见参数表参数建议范围说明种群大小40 到 80太小容易早熟太大单代评估太慢进化代数20 到 40配合早停使用看适应度曲线变异概率0.03 到 0.08超过 0.1 最优解容易震荡交叉概率0.5 到 0.7和变异概率互补总和控制在 0.9 内评估器浅层随机森林深度 3 到 5树的数量 50 左右即可4. 遗传编程演化交易规则从特征到可解释的买卖信号4.1 为什么选遗传编程而不是继续用 GA特征选完了下一步是把特征组合成“什么时候买、什么时候卖”的规则。这个阶段如果继续用 GA你就要手工定义规则的结构比如 if RSI 30 then buy这种写法把所有可能性都限制在你预设的框架里。而遗传编程GP直接把程序表示为树结构树的节点是特征和运算符通过进化自动生成一段可执行的表达式搜索空间比固定结构的 GA 大得多。GP 的树结构天然适合交易规则。一个表达式比如 sub(ma5_div_ma20, rsi_14)输出的是一个连续值取正负号就变成多空信号。整个过程结束后最优个体可以被打印成数学表达式人工阅读也可以直接转成 Python 函数这是深度学习给不了的透明性。GP 的缺点是容易长出又深又复杂的树也就是常说的“过度工程化”。控制它的手段有两个一个是限制树的深度另一个是在适应度里加复杂度惩罚两者缺一不可。4.2 基于 gplearn 演化交易规则核心代码gplearn 是 Python 里做符号回归最省事的库。网上示例代码讲解很多但大部分在演示回归拟合很少有人把它接到自定义的交易适应度上。关键点在于传入自定义 metric 函数让进化过程直接优化夏普比率而不是默认的均方误差。import numpy as np from gplearn.genetic import SymbolicRegressor def sharpe_metric(y_true, y_pred, sample_weight): y_true: T1 到 T6 的持有期收益 y_pred: GP 表达式在 T 日的输出取符号作为仓位 signal np.sign(y_pred) daily_ret signal * y_true if daily_ret.std() 1e-8: return -10.0 return daily_ret.mean() / daily_ret.std() * np.sqrt(252) gp SymbolicRegressor( population_size300, generations20, function_set(add, sub, mul, div, neg, sqrt), parsimony_coefficient0.01, metricsharpe_metric, max_samples0.9, init_depth(2, 6), random_state42, n_jobs-1, ) gp.fit(X_train, y_train) print(gp._program)逻辑说明sharpe_metric 里 y_pred 是表达式在 T 日收盘后算出的值取符号后当作当日信号而 y_true 是从 T1 收盘到 T6 收盘的收益两者在时间轴上严格错开了一天没有前视。这里没有考虑手续费和滑点因为规则演化阶段追求的是“相对排序能力”绝对收益受单笔交易影响太大进化过程会往噪声上靠。参数说明function_set 里只放加减乘除、取负和平方根刻意不放 sin、cos 这类周期函数因为它们会让表达式输出在 0 附近震荡产生极高换手的虚假信号。parsimony_coefficient0.01 是复杂度惩罚系数每多一个节点扣 0.01 适应度这个值能让树长到可读深度但不会无限膨胀。init_depth(2, 6) 限制了初始个体的树深度太深会消耗大量代数去修剪太浅又搜不到有意义的组合。max_samples0.9 表示每代用 90% 的训练样本评估适应度刻意引入一点随机性降低过拟合风险。我一般会跑完把最优表达式打印出来然后做一个变量映射。gplearn 输出的表达式里 X0、X1 是按 fit 时 DataFrame 的列顺序排列的和当初特征选择的结果不一定一致所以记得先建立列名索引再去读规则。比如expr str(gp._program) for i, col in enumerate(X_train.columns): expr expr.replace(fX{i}, col) print(expr)这一步不是为了炫技是为了人工审查。我知道一个血泪案例某人跑出的规则是“当某只股票的 5 日均线和 20 日均线比值小于 0.99 时买入”听起来合理但仔细一看那个 0.99 阈值是被进化硬凑出来的样本内刚好避开几次大跌样本外毫无意义。人工审查的核心是看规则是否和你对市场的认知一致不合理的规则再漂亮也是过拟合。4.3 规则演化的收敛判断GP 进化过程中种群平均适应度和最优适应度都会波动。我一般记录每代的最优适应度画一条曲线连续 8 代没有上升就提前停止。如果曲线一直震荡不收敛优先检查是不是特征里混入了太多噪声因子或者 function_set 里运算符太自由。还要看一个指标最优规则的树深度。如果最优解的深度接近 init_depth 上限说明表达式太复杂大概率在记忆训练段噪声。此时把 parsimony_coefficient 调大一倍重新跑通常能压下来。反过来如果最优解深度很浅比如只有两三个节点说明特征本身区分度不够先回特征选择阶段调整不要在 GP 里硬拼。5. 回测、验证与避坑先证明有效再谈实盘5.1 带交易成本的快速回测信号次日生效是底线规则演化完第一件事不是看收益率也不是看夏普而是先确认信号在时间轴上的对齐方式。我见过太多人把 T 日收盘算出的信号直接乘 T 日收益这等于假设你能在收盘那一刻以收盘价成交实际根本做不到。常见做法是信号 shift(1)次日开盘或收盘生效回测里按次日收盘价成交已经算乐观了。下面是一个最简但完整的回测函数def run_backtest(signal, close, fee_rate0.0003, slippage0.0002): signal signal.reindex(close.index).fillna(0) position signal.shift(1).fillna(0) market_ret close.pct_change().fillna(0) trade position.diff().abs().fillna(position.abs()) cost trade * (fee_rate slippage) strategy_ret position * market_ret - cost equity (1 strategy_ret).cumprod() sharpe strategy_ret.mean() / strategy_ret.std() * np.sqrt(252) max_drawdown (equity / equity.cummax() - 1).min() return equity, sharpe, max_drawdown逻辑说明position 是当日持仓由前一天信号决定shift(1) 强制了这个因果关系。trade 是仓位变动position.diff().abs() 计算每天调仓量乘以费率就是交易成本。注意首次开仓那天 cost 里包含平仓之外的建仓成本这里用 fillna(position.abs()) 补上了避免第一笔交易不扣手续费。参数说明fee_rate 取万分之三对应常见佣金水平slippage 取万分之二模拟冲击成本这在中低频策略里已经算比较保守。高频策略或者小盘股滑点要放大到千分之一以上。夏普用日收益年化时乘以 sqrt(252)如果信号是持有 5 日的低频规则严格算应该用持仓周期做年化但为了统一比较我仍然按日收益年化只要所有策略用同一口径就公平。5.2 五个真实踩坑记录现象、原因、解决坑一样本内夏普 1.8样本外直接变负。现象遗传算法在训练段找到了一个胜率极高的特征组合但一旦切到样本外收益率立刻变成一条向下的曲线。原因我在特征选择阶段用了全部数据做筛选那段代码切分没生效GA 等于提前偷看了验证段信息。解决把特征选择、GP 演化、参数微调全部限制在训练段内完成验证段只允许在最终规则上跑一次。这个流程用了半年所有策略的样本外衰减都变得可控了。坑二预测准得像开了上帝视角。现象回测里年化收益率超过 200%换手率还很低怎么看都不真实。原因标签构造用了 close.shift(-5) 和特征时间轴擦边T 日收盘价同时出现在特征里和未来收益的起点上。解决统一改成 close.shift(-6) / close.shift(-1) - 1把标签起点挪到 T1 收盘彻底隔断 T 日信息的泄漏。这个改动单看微小但对目标排序的影响极大。坑三GP 产出的规则复杂到无法人工审查。现象打印出来的表达式有 60 多个节点肉眼根本看不出逻辑。原因parsimony_coefficient 没设或者设得太小进化过程倾向于用更复杂的树硬套噪声。解决把 parsimony_coefficient 从 0 调到 0.01 再到 0.05同时限制 init_depth 最大为 6。一个我经常用的检验标准是最优规则如果不能在五秒内用人话复述出来大概率是过拟合的。坑四整个流程换个股票就失效。现象在贵州茅台上演化出的规则搬到宁德时代收益变成随机游走。原因不同股票的波动率、价格区间、流动性差异太大固定阈值规则天然不具备迁移性。解决特征里少用绝对价格和绝对成交量多用比值类特征另外把持仓周期从 5 日拉长到 10 日规则的一般性会显著提升。这也是为什么我在特征工程阶段坚持用 close_ma_w_ratio 和 volume_ratio 这类无量纲特征。坑五手续费没算收益好看到不敢信。现象信号每天在 1 和 0 之间反复横跳回测净值涨得飞快实盘一跑就亏。原因适应度函数里没有交易成本GP 学出了“频繁开平仓博噪声”的策略。解决在适应度函数里增加一个换手惩罚项比如 sharpe - 0.1 * 日均换手率。还有一个笨办法每天信号先做一次去抖如果连续两天信号方向不一致保持和前一日相同的仓位把高换手直接抹平。5.3 一个固定的验证习惯先跑随机基线每次拿到新的策略规则我做的第一件事不是看收益曲线而是跑三组随机基线随机信号、随机特征子集的 GA、只保留单特征的规则。如果进化出的规则连随机基线都跑不赢说明问题不在参数而在数据或问题定义本身。这个习惯帮我避免了很多次过度兴奋。随机基线的实现很简单把固定随机种子打乱重跑整个流程或者更简单用 numpy 随机生成一组信号套用同一个回测函数。对比时重点看三个数字夏普比率、最大回撤、年化换手率。如果进化策略的夏普比随机基线高不了 0.3那它本质上还是一个噪声策略。6. 把搜索空间焊死在有效区间三个让工程效率翻倍的落地技巧技巧一特征进进化之前先做一轮单因子 IC 筛选。把每个特征和标签算 Spearman 相关系数只保留 IC 绝对值大于 0.02 的特征。这一刀看起来很粗暴但能砍掉一半以上的无效因子GA 和 GP 的搜索空间立刻缩小收敛速度明显加快。技巧二滚动窗口加种群热启动。策略规则会随时间衰减常见做法是每隔 60 个交易日重跑一次演化。但重新从随机种群跑既慢又不稳定。我一般把上一期的最优个体复制进新种群占三成再把变异步长调大一点让新种群在旧解附近搜索。这样策略平滑过渡不会出现一次重训练后收益风格突变的情况。技巧三把每代的适应度方差记录下来存档。进化的最后几代如果方差已经趋近于零说明种群收敛到了同一个局部最优这时候要么接受它要么加大变异重启。我现在的习惯是每一代都存一份种群快照策略如果持续失效可以从上一次收敛的种群重新演化而不是推倒重来。最近一次策略衰退时我就是靠三周前的种群快照救回来的。这三个技巧的共同点是把遗传算法从一个“跑完就完”的批处理任务改造成一个可以随时回退、平滑升级的工程系统。我现在每跑一轮实验都会把上一代的模型文件和种群快照一起存下来防止策略退化之后没有后悔药可吃。希望帮到你。本文还有配套的精品资源点击获取
