简介:面向量化交易与机器学习初学者,这套算法交易实验代码以苹果股价预测为场景,分别采用遗传编程和遗传算法两种进化计算策略。遗传编程通过进化基于树的种群来最小化预测价格与实际价格之间的误差,并融合纳斯达克、苹果、标普等市场信号;遗传算法则进化字符串以猜测第二天的股价涨跌,两种思路形成鲜明对比。资源共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", start="2018-01-01", end="2024-01-01", auto_adjust=True, progress=False) df.columns = [c.lower() for c in df.columns] close = df["close"] volume = df["volume"] print(close.head())逻辑说明:auto_adjust=True 会同时调整 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[f"ma{w}"] = df["close"].rolling(w).mean() df[f"close_ma{w}_ratio"] = df["close"] / df[f"ma{w}"] - 1 # 波动类 df["ret_1d"] = df["close"].pct_change() for w in (5, 10, 20): df[f"volatility_{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(lower=0).rolling(14).mean() down = (-diff.clip(upper=0)).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,终点是 T,shift(-5) 之后标签变成了 T+5 的数据,方向对不上。我一般这样构造:
future_ret = close.shift(-6) / close.shift(-1) - 1 label_up = (future_ret > 0.005).astype(int) label_reg = future_ret说明:close.shift(-6) 是 T+6 日收盘价,close.shift(-1) 是 T+1 日收盘价,两者相减得到的是从 T+1 收盘到 T+6 收盘的持有期收益。为什么要从 T+1 开始而不是从 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, fitness=creator.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_splits=4) aucs = [] for trn_idx, val_idx in tscv.split(X): clf = RandomForestClassifier(n_estimators=50, max_depth=3, random_state=0) 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, n=len(feature_cols)) toolbox.register("population", tools.initRepeat, list, toolbox.individual) toolbox.register("evaluate", eval_features, X=X_train, y=y_train) toolbox.register("mate", tools.cxTwoPoint) toolbox.register("mutate", tools.mutFlipBit, indpb=0.05) toolbox.register("select", tools.selTournament, tournsize=3) pop = toolbox.population(n=40) for ind in pop: ind.fitness.values = toolbox.evaluate(ind) for gen in range(30): offspring = algorithms.varAnd(pop, toolbox, cxpb=0.6, mutpb=0.3) for ind in offspring: ind.fitness.values = toolbox.evaluate(ind) pop = toolbox.select(offspring, k=len(pop)) best = tools.selBest(pop, k=1)[0] print(f"gen {gen}: best auc = {best.fitness.values[0]:.4f}")逻辑说明:eval_features 里用 TimeSeriesSplit 做 4 折时间序列交叉验证,每折只按时间先后划分,避免随机洗牌泄漏未来。分类器选 RandomForest 而不是逻辑回归,是因为技术因子和未来收益之间大多是非线性关系,浅层随机森林能在不引入太多过拟合的前提下捕捉交互。如果只有两个特征被选中,直接给 0 分,因为太少的特征几乎不可能构成稳定策略。
参数说明里重点是三个:indpb=0.05 控制变异概率,太大了最优解容易被拆散,太小了种群会早熟;cxpb=0.6 是交叉概率,两点交叉对 0/1 编码效果比单点好;tournsize=3 是锦标赛选择的竞争人数,越大选择压力越大,但过大会导致种群多样性快速下降。另外代码里用 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: T+1 到 T+6 的持有期收益 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_size=300, generations=20, function_set=('add', 'sub', 'mul', 'div', 'neg', 'sqrt'), parsimony_coefficient=0.01, metric=sharpe_metric, max_samples=0.9, init_depth=(2, 6), random_state=42, n_jobs=-1, ) gp.fit(X_train, y_train) print(gp._program)逻辑说明:sharpe_metric 里 y_pred 是表达式在 T 日收盘后算出的值,取符号后当作当日信号,而 y_true 是从 T+1 收盘到 T+6 收盘的收益,两者在时间轴上严格错开了一天,没有前视。这里没有考虑手续费和滑点,因为规则演化阶段追求的是“相对排序能力”,绝对收益受单笔交易影响太大,进化过程会往噪声上靠。
参数说明:function_set 里只放加减乘除、取负和平方根,刻意不放 sin、cos 这类周期函数,因为它们会让表达式输出在 0 附近震荡,产生极高换手的虚假信号。parsimony_coefficient=0.01 是复杂度惩罚系数,每多一个节点扣 0.01 适应度,这个值能让树长到可读深度但不会无限膨胀。init_depth=(2, 6) 限制了初始个体的树深度,太深会消耗大量代数去修剪,太浅又搜不到有意义的组合。max_samples=0.9 表示每代用 90% 的训练样本评估适应度,刻意引入一点随机性,降低过拟合风险。
我一般会跑完把最优表达式打印出来,然后做一个变量映射。gplearn 输出的表达式里 X0、X1 是按 fit 时 DataFrame 的列顺序排列的,和当初特征选择的结果不一定一致,所以记得先建立列名索引再去读规则。比如:
expr = str(gp._program) for i, col in enumerate(X_train.columns): expr = expr.replace(f"X{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_rate=0.0003, slippage=0.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,把标签起点挪到 T+1 收盘,彻底隔断 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 个交易日重跑一次演化。但重新从随机种群跑既慢又不稳定。我一般把上一期的最优个体复制进新种群,占三成,再把变异步长调大一点,让新种群在旧解附近搜索。这样策略平滑过渡,不会出现一次重训练后收益风格突变的情况。
技巧三:把每代的适应度方差记录下来存档。进化的最后几代如果方差已经趋近于零,说明种群收敛到了同一个局部最优,这时候要么接受它,要么加大变异重启。我现在的习惯是每一代都存一份种群快照,策略如果持续失效,可以从上一次收敛的种群重新演化,而不是推倒重来。最近一次策略衰退时,我就是靠三周前的种群快照救回来的。
这三个技巧的共同点,是把遗传算法从一个“跑完就完”的批处理任务,改造成一个可以随时回退、平滑升级的工程系统。我现在每跑一轮实验,都会把上一代的模型文件和种群快照一起存下来,防止策略退化之后没有后悔药可吃。希望帮到你。
本文还有配套的精品资源,点击获取