恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
遗传编程与遗传算法在算法交易中的应用:从特征选择到股票预测
首页
资讯中心
/
遗传编程与遗传算法在算法交易中的应用:从特征选择到股票预测
遗传编程与遗传算法在算法交易中的应用:从特征选择到股票预测
发布时间:2026/10/12 12:44:39
简介这份算法交易程序资源基于Python实现面向对量化交易、机器学习预测股价感兴趣的开发者与研究人员。项目包含遗传编程与遗传算法两套独立策略前者通过进化基于树的种群最小化预测价格与实际价格间的误差后者进化字符串来猜测次日股价涨跌并配有相应说明文档与测试数据。压缩包共40个文件以py脚本为核心辅以csv格式的苹果、纳斯达克等历史行情数据以及txt、md维护指南和自述文件整体大小仅1.21MB便于下载与本地运行。已有330人浏览学习适合入门者参考完整代码结构理解特征数据组织、模型进化流程和预测结果评估方法也可作为进一步扩展信号源或改进策略的基线。1. 算法交易程序里的遗传编程与遗传算法先分清两个“遗传”再谈预测股价“算法交易程序 遗传编程 遗传算法 预测股票价格”这个标题组合很容易让人误以为拿到代码就能输入 K 线、输出买卖点从此躺着赚钱。真把一个能跑的 Python 工程跑起来之后你会先撞上两个现实遗传算法GA在干的是“在固定结构的参数空间里做全局搜索”遗传编程GP干的是“在程序结构空间里搜索一条可解释的交易规则”两者都不是玄学预测器而是在历史数据里寻找有统计优势的假设。这篇文章要拆的就是一套 Python 代码该怎么组织特征怎么准备、GA 和 GP 各解决哪一层、参数怎么起步、过拟合在哪几个环节埋伏。新手能照着搭出第一个版本做过一两版的人也能避开几个典型的翻车点。2. 为什么预测股票要用进化计算GA 负责调参数GP 负责找规则2.1 两个“遗传”的边界遗传算法搜参数遗传编程搜结构遗传算法和遗传编程经常被混为一谈很多中文资料干脆把两者都叫“遗传算法”。它们确实共享同一套进化框架种群初始化、适应度评估、选择、交叉、变异。真正的差别在个体编码方式上。GA 的个体是定长向量。向量可以是二进制、实数或枚举值长度在初始化时固定交叉变异只改变每一位的取值不改变长度。比如有 6 个技术指标要不要选入模型就是一个 6 位的 0/1 向量又比如 MACD 的快线周期、慢线周期各取多少就是两个整数坐标。搜索过程本质上是在一个维度固定、边界明确的空间里找最优解。GP 的个体是一棵表达式树。叶子是特征和常数内部节点是算子比如加减乘除、if-then-else、大小比较。交叉操作交换两棵树的子树变异替换树里某个节点。树的深度和节点数是可以变化的所以 GP 搜索的是“程序结构”本身而不是固定结构下的参数。一个简单的类比GA 是给你一台固定型号的机器让你调旋钮GP 允许你改装机器内部结构。放到股票预测里两者的使用层次完全不同。维度遗传算法 GA遗传编程 GP个体编码定长向量二进制/实数变长表达式树搜索对象固定结构下的参数组合程序结构本身交叉操作向量片段交换子树交换典型用途特征选择、超参寻优交易规则、信号表达式结果解释容易直接读参数中等树可打印但可能很深2.2 股票预测任务怎么拆特征、模型、规则各归各“预测股票价格”这句话太宽直接拿进化算法去拟合未来价格很容易做成过拟合的垃圾模型。真正可落地的算法交易程序通常把问题拆成三层。第一层是特征工程。原始 OHLCV 数据不能直接喂给进化程序要先换算成技术指标均线偏离、RSI、MACD 柱、布林带位置、ATR、成交量比率。这一层的核心问题是“哪些特征有用、窗口参数取多少”。搜索空间相对小且维度固定适合用 GA 做特征选择。第二层是预测或打分模型。可以是随机森林、逻辑回归也可以直接用特征构造表达式。如果目标是预测未来 N 根 K 线的涨跌方向常见做法是把它做成二分类用交叉验证 AUC 当适应度。第三层是交易规则。不管模型输出什么分数最终要回答“什么时候买、什么时候卖、仓位多少”。规则层高度结构化适合 GP 来演化因为表达式树天然能表达“当 RSI 低于 30 且 MACD 柱为正时开多仓”这类带条件分支的逻辑。很多开源算法交易程序把第二层和第三层合并直接用 GP 演化一条规则把“预测”和“交易决策”塞进同一个表达式。这样做省事但会混淆两个目标又想预测准又想收益高。我一般建议拆成两个阶段GA 处理特征选择和模型参数GP 处理规则生成串成一条流水线出了问题也容易定位是在预测层还是执行层。2.3 进化计算是加分项不是银弹进化计算在股票预测上的优势来自它对非凸、非平滑、高维搜索空间的适应能力。股票日线数据的信噪比极低收益序列接近随机游走加微弱漂移但这不代表没有可挖掘的统计效应。GA 和 GP 的价值在于它们能在大量特征与条件交互中反复找到那些在历史上重复出现的统计规律而不是声称能精确预测某一天的涨跌。但硬币的另一面是进化过程本身就是在用适应度函数做筛选每一代都在向“这段历史数据的最优解”收敛。种群一大、代数一多它找到的很可能是恰好拟合这段历史噪声的规则。所以在整个系统里数据切分、前视偏差控制、验证集隔离比进化算法本身更决定成败。这也是后面几章所有代码围绕的核心。3. 用 Python 落地遗传算法特征选择与参数寻优的最小代码骨架3.1 准备好特征池与标签先定好“预测什么”任何进化程序的第一步都不是写进化代码而是把数据和标签固定下来。数据不对后面全是玄学。我习惯先用单标的日线数据起步选一只流动性好的股票或指数 ETF。CSV 至少要有六列date、open、high、low、close、volume。特征池固定为以下几个MA20 偏离度、RSI(14)、MACD 柱、布林带位置、ATR(14)、成交量比率。标签是“未来 3 个交易日收益是否为正”。import numpy as np import pandas as pd def prepare_features(df: pd.DataFrame) - pd.DataFrame: df df.copy() df[ma5] df[close].rolling(5).mean() df[ma20] df[close].rolling(20).mean() df[ma_gap] (df[close] - df[ma20]) / df[ma20] # RSI(14)涨幅均值 / 跌幅均值 的经典简化版 delta df[close].diff() up delta.clip(lower0).rolling(14).mean() down (-delta.clip(upper0)).rolling(14).mean() df[rsi] 100 - 100 / (1 up / down) # MACD 柱快线 12、慢线 26、信号 9柱 (DIF - DEA) * 2 ema12 df[close].ewm(span12, adjustFalse).mean() ema26 df[close].ewm(span26, adjustFalse).mean() dif ema12 - ema26 dea dif.ewm(span9, adjustFalse).mean() df[macd_hist] (dif - dea) * 2 # 布林带位置收盘价在 20 日带内的相对位置0 为下轨1 为上轨 mid df[close].rolling(20).mean() std df[close].rolling(20).std() df[boll_pos] (df[close] - mid) / (2 * std) # ATR(14)真实波幅均值用 max 处理跳空缺口 prev_close df[close].shift() tr np.maximum( df[high] - df[low], np.maximum(abs(df[high] - prev_close), abs(df[low] - prev_close)) ) df[atr] tr.rolling(14).mean() # 成交量比率当日成交量 / 20 日均量 df[vol_ratio] df[volume] / df[volume].rolling(20).mean() # 标签未来 3 根 K 线是否上涨。shift(-3) 让第 t 天对齐第 t3 天的结果 df[label] (df[close].shift(-3) df[close]).astype(int) df df.replace([np.inf, -np.inf], np.nan).dropna() return df这段代码有几个必须说清的约定。rolling(14)是 RSI 和 ATR 的行业常用周期MACD 的(12, 26, 9)是经典参数但这三个数字同样可以被 GA 进化后面进阶时可以放开。shift(-3)表示“当天收盘后未来 3 日不含当天是否上涨”标签周期一旦改成 5 日后续所有统计都会变GA 搜索到的特征组合也会跟着变。注意dropna()会把数据尾部最后 3 行丢掉因为那里没有未来数据。还要检查输入的 CSV 里 high 是否永远大于等于 low、volume 是否有 0 值0 值会把 ATR 变成 0造成后面 GP 阶段的除零问题。3.2 GA 最小骨架个体是特征掩码适应度走交叉验证特征准备好之后GA 的任务是“选出一组特征子集让下游分类模型拿到最高的样本外 AUC”。我把这当特征选择问题处理而不是做深度参数搜索。原因是日线数据样本量通常只有几千模型复杂了根本泛化不住所以分类器固定用浅层随机森林深度压到 2 到 3。import random from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import cross_val_score from deap import base, creator, tools, algorithms FEATURE_COLS [ma_gap, rsi, macd_hist, boll_pos, atr, vol_ratio] DF prepare_features(pd.read_csv(your_ohlcv.csv)) # 全局只准备一次 def build_ga(): # creator.create 在同一个进程里只能执行一次重复执行会报错 if not hasattr(creator, FitnessMax): creator.create(FitnessMax, base.Fitness, weights(1.0,)) creator.create(Individual, list, fitnesscreator.FitnessMax) def evaluate(individual): chosen [c for c, keep in zip(FEATURE_COLS, individual) if keep 0] if len(chosen) 2: # 少于两个特征没有意义直接给最低分 return -99.0, X DF[chosen].values y DF[label].values clf RandomForestClassifier(n_estimators50, max_depth3, random_state7) scores cross_val_score(clf, X, y, cv5, scoringroc_auc) return scores.mean(), toolbox base.Toolbox() toolbox.register(attr_bool, random.randint, 0, 1) toolbox.register(individual, tools.initRepeat, creator.Individual, toolbox.attr_bool, len(FEATURE_COLS)) toolbox.register(population, tools.initRepeat, list, toolbox.individual) toolbox.register(evaluate, evaluate) toolbox.register(mate, tools.cxTwoPoint) toolbox.register(mutate, tools.mutFlipBit, indpb0.1) toolbox.register(select, tools.selTournament, tournsize3) return toolbox toolbox build_ga() pop toolbox.population(n40) hof tools.HallOfFame(1) stats tools.Statistics(lambda ind: ind.fitness.values[0]) algorithms.eaSimple(pop, toolbox, cxpb0.6, mutpb0.2, ngen12, halloffamehof, statsstats, verboseTrue) best hof[0] print([name for name, keep in zip(FEATURE_COLS, best) if keep 0])evaluate里有个细节每个个体的适应度都是 5 折交叉验证 AUC 的均值。交叉验证比单次 train/test 切分稳定得多能减少“某一折运气特别好”造成的误判。DEAP 的eaSimple会一代一代地执行选择、交叉、变异并更新 Hall of Fame最终hof[0]就是历史最优个体。这里有一个新手必踩的坑creator.create是 DEAP 的全局注册操作在 Jupyter 里重复执行同一个 cell 会报AttributeError: Cant pickle之类的诡异错误。上面的hasattr保护只对同一个进程有效最稳的做法是把整个流程写成一个.py脚本从头到尾只运行一次。参数解释pop40是种群大小ngen12是进化代数cxpb0.6是交叉概率mutpb0.2是变异概率。6 个特征位在 0.2 的变异概率下平均每个个体每次翻转 1.2 个位属于较激进的搜索如果后面特征池扩到 20 个以上mutpb要降到 0.05 到 0.1否则种群会被变异打散。3.3 一组可起步的参数组合按标的流动性来调以下是我在日线数据上比较常用的起步参数不是标准答案但比盲调靠谱。参数常见范围起步值调整方向pop 种群大小30 ~ 20040特征多、数据多时往上加ngen 进化代数10 ~ 3012超过 30 基本在拟合噪声cxpb 交叉概率0.5 ~ 0.80.6搜索后期可以降一点mutpb 变异概率0.05 ~ 0.30.2特征多就调小tournsize 锦标赛规模2 ~ 53越大选择压力越大越容易早熟调参方向有个通用原则先固定ngen在 10 到 20观察每代适应度曲线是否还在明显上升。如果还在上升优先加pop而不是加ngen。因为代数增长带来的过拟合风险是超线性的种群增长则温和得多。流动性也是参数选择的参考。日线级别的流动性股票和分钟级别的期货合约波动结构完全不同。做分钟线时ngen要更小mutpb要更小因为高频噪声更容易被适应度函数当成信号学进去。4. 用 Python 落地遗传编程让交易规则从表达式树里长出来4.1 设计 primitive set给 GP 一套积木而不是一个答案GA 阶段选出了有用的特征子集接下来 GP 要自己组装交易规则。GP 不需要人为指定规则公式但需要定义两样东西终端集和运算符集合称 primitive set。终端是树的叶子节点。常见做法是把上一步 GA 选出的 2 到 4 个特征作为终端再加一个 0 到 1 之间的随机常数以及一个 0 常量。运算符决定程序能拼出什么结构。最少要提供加减乘除除法必须做保护处理否则某天特征值恰好接近 0 时会产生 inf后续回测里仓位会直接爆炸。if_then_else是 GP 里很关键的三元算子。有了它演化出的规则才能自然带条件分支比如“RSI 低于 30 且 MACD 柱为正时做多”。没有它程序只能拼出连续函数表达力会弱很多。4.2 核心代码演化一条买入规则并按夏普比率打分下面的代码是 GP 阶段的核心。目标不是预测具体涨跌而是演化出一个表达式把四个特征映射成一个仓位信号正值持仓非正值空仓然后按日频夏普比率打分。import operator import numpy as np import pandas as pd from deap import gp, base, creator, tools, algorithms def protected_div(a, b): return a / b if abs(b) 1e-6 else 1.0 def if_then_else(cond, x, y): return x if cond 0 else y # 四个特征作为叶子评估函数按这个顺序接收参数 pset gp.PrimitiveSet(MAIN, 4) pset.renameArguments(ARG0rsi, ARG1macd_hist, ARG2ma_gap, ARG3vol_ratio) pset.addPrimitive(operator.add, 2) pset.addPrimitive(operator.sub, 2) pset.addPrimitive(operator.mul, 2) pset.addPrimitive(protected_div, 2) pset.addPrimitive(if_then_else, 3) pset.addEphemeralConstant(rand_const, lambda: round(np.random.uniform(-2, 2), 3)) pset.addTerminal(0.0) if not hasattr(creator, FitnessMax): creator.create(FitnessMax, base.Fitness, weights(1.0,)) creator.create(Individual, gp.PrimitiveTree, fitnesscreator.FitnessMax) def eval_rule(individual, df): func toolbox.compile(exprindividual) raw df.apply( lambda r: func(r[rsi], r[macd_hist], r[ma_gap], r[vol_ratio]), axis1 ) # 信号当天计算次日开盘才执行这一步是避免前视偏差的生命线 position raw.shift(1).fillna(0.0) signal np.where(position 0, 1.0, 0.0) # 简化只做多不做空 daily_ret df[close].pct_change().fillna(0.0) strat_ret signal * daily_ret std strat_ret.std() if std 1e-6: return -99.0, sharpe np.sqrt(252) * strat_ret.mean() / std # 换手惩罚信号频繁翻转意味着手续费和滑点会吃掉利润 turnover np.abs(np.diff(signal)).sum() / len(signal) return sharpe - 20.0 * turnover, toolbox base.Toolbox() toolbox.register(expr, gp.genHalfAndHalf, psetpset, min_1, max_4) toolbox.register(individual, tools.initIterate, creator.Individual, toolbox.expr) toolbox.register(population, tools.initRepeat, list, toolbox.individual) toolbox.register(compile, gp.compile, psetpset) toolbox.register(evaluate, eval_rule, dfDF) toolbox.register(select, tools.selTournament, tournsize3) toolbox.register(mate, gp.cxOnePoint) toolbox.register(expr_mut, gp.genFull, min_0, max_3) toolbox.register(mutate, gp.mutUniform, exprtoolbox.expr_mut, psetpset) # 树高超过 10 的个体直接淘汰防止结构膨胀 toolbox.decorate(evaluate, gp.staticLimit(keyoperator.attrgetter(height), max_value10)) pop toolbox.population(n50) hof tools.HallOfFame(1) stats tools.Statistics(lambda ind: ind.fitness.values[0]) algorithms.eaSimple(pop, toolbox, cxpb0.5, mutpb0.2, ngen15, halloffamehof, statsstats, verboseTrue)代码逻辑拆开看每个个体是一棵语法树toolbox.compile把树编译成可调用的 Python 函数。df.apply逐行把四个特征传进函数得到一根 K 线对应的原始信号。position raw.shift(1)是整个评估里最关键的一行——信号在第 t 天计算仓位在第 t1 天才生效从机制上杜绝了用当天收盘价偷看当天收益的前视偏差。turnover是换手惩罚项。日频策略如果信号每天跳来跳去交易成本会吞噬全部超额收益所以适应度里直接扣掉 20 倍换手。这个 20 是我常用的起步值量纲大致相当于“把年化夏普打折来模拟手续费”。如果你的交易成本更低可以降到 10做分钟线或者合约成本更高可以加到 30 以上。gp.staticLimit(max_value10)是反膨胀的保险丝。GP 进化到后期树会越变越深很多分支其实已经被常数项压制、永远不会被执行但它们占内存、拖慢评估。高度限制让这类个体直接判负而不是继续繁殖。4.3 树深、种群规模、代数三者的联动GP 的调参比 GA 更敏感因为三个参数是联动的。初始树深max_4意味着进化开始时树很小但交叉变异会让树高很快超过 10。这时候如果只调大pop而不限制树高计算量会指数级上涨因为树越深df.apply逐行执行的耗时越长。我的实操原则是pop在 30 到 80 之间ngen不超过 20staticLimit的max_height在 8 到 12 之间。三者必须一起调。比如把pop从 50 加到 200而树高限制不放宽种群多样性会下降因为高个子个体全被淘汰了反过来放开树高到 15ngen就必须降到 8 以内否则膨胀会耗尽内存。适应度函数里还可以再加一个树高惩罚项比如sharpe - 20*turnover - 0.05*individual.height。这样进化过程会自动在“规则复杂”和“规则有效”之间找平衡比单纯一刀切的高度限制更平滑。5. 进化策略在股票预测上的 5 个避坑点过拟合、前视偏差与换手惩罚5.1 训练期夏普 3.0样本外直接变成 -0.5现象回测曲线漂亮得像教科书图表里净值一路向上回撤极小但把时间段往后平移三个月策略表现断崖下跌甚至亏损。原因进化过程每一代都在做“针对这段历史的最优拟合”。种群和代数越大找到恰好符合这段历史噪声的规则概率越高。这是结构过拟合不是简单改个参数能解决的。解决把数据按时间切成训练段、验证段、测试段三段。进化只在训练段进行验证段用来挑选最终保留的个体测试段全流程只允许跑一次。如果验证段结果就明显下滑先回去查适应度函数和特征是否有泄漏而不是加大种群继续跑。5.2 归一化用了全样本最大最小值等于偷看未来现象特征数值在训练集看起来正常放到几年后的测试集里出现极端离群值模型输出完全变形。原因在整段数据上一次性计算了均值和标准差归一化参数里混入了“未来数据”。市场波动率在不同年份差异巨大2015 年的波动率极值与 2020 年的完全不同全样本归一化等于把未来的分布信息泄露给了训练过程。解决所有归一化参数只能在第 t 天及之前的数据上计算。用滚动窗口或者严格的时序切分训练集算好 mean 和 std 后验证集和测试集直接沿用训练集的参数。特征工程里的 rolling 操作本身就满足这一点但如果是手动做(x - x.mean()) / x.std()这种全局操作就会踩中这个坑。5.3 适应度只看累计收益忽视回撤和持仓风险现象GP 演化出的规则单次净值曲线很漂亮但最大回撤超过 50%中间有几次接近爆仓持仓集中度极高。原因适应度函数只写成了total_return。进化算法会找到一条“押注少数极端行情”的路子这在历史数据上收益最高但实盘根本拿不住。解决适应度至少包含三个分量年化夏普、最大回撤惩罚、换手惩罚。我常用的起步组合是sharpe - 0.5 * max_drawdown - 20 * turnover系数根据品种波动调整。夏普本身已经包含了对波动率的惩罚但最大回撤需要单独算因为两只策略可以有相同的夏普而回撤形态完全不同。5.4 GP 树无限膨胀越跑越慢现象进化到 15 代以后打印出来的表达式越来越长几百个节点里大部分分支被 if-then-else 的常数条件永久压制实际永远走不到。原因这是 GP 领域著名的 bloating 问题。交叉操作倾向于产生更深的树因为深度大的子树有更多“挂载点”而且大树的适应度往往不比小树差于是膨胀基因代代相传。解决按 4.3 节的方法用gp.staticLimit限制树高或者干脆在适应度里减去 0.05 乘以树高。我还见过一个更实用的做法在每一代结束后把 Hall of Fame 里的个体做一遍剪枝把 if-then-else 中永远不会执行的分支替换成常量再重新评估。这能显著降低计算量。5.5 换手率过高手续费一算就亏现象策略信号每天翻转回测里因为没有扣手续费净值曲线勉强向上一旦按实际佣金加滑点重算收益变成负的。原因日线数据本身噪声大进化算法很容易找出“频繁切换仓位”的规则因为它能捕捉到细微的均值回归现象。但真实交易中每次换手都付出成本进化过程没有把这个代价放进适应度里。解决除了第 4 章代码里的换手惩罚项还有一个更工程化的做法回测时在每一笔交易上按固定比例扣费比如万分之五佣金加一个 ATR 比例的滑点。我一般用“信号滞后一天 每笔扣除万八”作为底线跑完再看换手率指标。如果换手率高于日均 0.3 次无论收益多高都先怀疑是不是过度交易。6. 上线前最后一关样本外滚动验证与随机基准对照这套 GA 加 GP 的流水线最终价值不在于演化出某一条“神奇规则”而在于你能不能稳定地产出“显著优于随机基准”的规则。我的最后一道工序是滚动样本外验证顺便把项目组织成一个可复现的目录结构方便持续迭代。genetic_trader/ ├── data/ # 原始 OHLCVCSV 格式 ├── features.py # 第 3 章的特征工程 ├── ga_select.py # GA 特征选择 ├── gp_rules.py # GP 规则演化 ├── backtest.py # 回测与验证 └── results/ # 每代适应度曲线、最终规则、净值数据滚动验证的做法是用两年日线做训练接下来三个月做验证然后训练窗口向前滚动三个月重复多次把每次验证段的夏普汇总成一组分布。def rolling_walk_forward(df, train_w504, val_w63, step63): results [] for start in range(0, len(df) - train_w - val_w, step): train df.iloc[start:start train_w] val df.iloc[start train_w:start train_w val_w] rule, train_sharpe run_gp(train) # 第 4 章的完整 GP 流程 val_sharpe evaluate_rule(rule, val) # 只用验证段评估 results.append({train_sharpe: train_sharpe, val_sharpe: val_sharpe}) return pd.DataFrame(results)判断标准不是“某一次验证段跑出了高夏普”。正确做法是同一个随机种子下跑几十条随机规则作为基准取它们夏普的中位数和 90 分位数。只有进化出的规则在多个验证段上能稳定压过随机基准的 90 分位我才会继续往实盘方向投入。大多数情况下第一版跑出来的结果会告诉你进化算法找到的规律并没有比随机入场的运气好多少。这不是失败这就是这个方向的常态。我自己在这个项目上吃过最大的亏就是第一版直接用全样本调参前后三天都是“优化一时爽验证火葬场”。后来强制自己每次实验都先画一条随机基准线再谈策略好坏省下了大量自我欺骗的时间。这套办法不一定让你找到圣杯但它能让你知道自己找到的东西到底值不值得继续投入。希望帮到你。本文还有配套的精品资源点击获取