简介:这套算法交易程序聚焦苹果股价预测,面向对量化交易、机器学习预测感兴趣的Python开发者,提供遗传编程与遗传算法两种可对照的实现思路。遗传编程模块通过进化基于树的种群来最小化预测价与真实价误差,引入纳斯达克、苹果、标普等信号;遗传算法模块则进化字符串以判断次日股价涨跌,两者均配有自述文件与数据文件。资源共40个文件,以21个Python脚本为核心,辅以8个CSV行情数据、7个TXT说明及部分辅助文件,整体压缩包仅1.21MB,轻量易部署。已有330人学习下载。通过源码、数据集和文档,读者可快速复现实验,学习特征信号处理、种群进化、误差最小化及涨跌分类等关键设计,适合作为入门算法交易的实战参考。
1. 算法交易程序里的遗传编程和遗传算法:先搞清楚你拿到的代码在做什么
拿到一个标题为“算法交易程序,使用遗传编程和遗传算法来预测股票价格_python_代码_下载”的项目,第一反应别是找“明天的收盘价”。这个方向的代码做得再好,也不会给你一个价格预言机,而是给你一套用历史行情演化出来的交易规则:遗传算法负责在参数空间里搜索最优参数,遗传编程负责从零生长出可解释的信号表达式,两者组合起来,就是一个完整的算法交易程序雏形。它解决的是“如何根据历史数据生成稳定可验证的买卖信号”,而不是“算命”。适合已经会用 Python、拿 pandas 处理过行情数据,但还没把进化算法和量化交易串起来的从业者。后面我基于 deap 库拆开讲实现,参数和踩坑都会说到。
2. 为什么用进化算法做预测:GA 和 GP 的原理与选型取舍
2.1 遗传算法和遗传编程的本质区别:优化参数 vs 演化规则
先看遗传算法原理。GA 的出发点是“你已经知道解长什么样,但不知道具体数值”。比如做双均线策略,需要确定短均线周期 n1、长均线周期 n2 和持有天数 hold,每个个体就是(n1, n2, hold)这样的编码,适应度函数用回测收益打分,然后按选择、交叉、变异迭代。它不会帮你创造新的策略结构,只会在你给定的策略骨架上找最优参数。
遗传编程不一样。GP 把“策略结构”本身也放进搜索空间,个体是一棵表达式树:叶子是行情特征,内部节点是加减乘除、比较和各种技术指标算子。你在遗传算法 python 代码里常见的deap.gp模块,生成的就是类似if bias > 2 then buy这样的规则,而且是程序自己长出来的,不是人先写好的。如果说 GA 是调旋钮,GP 就是帮你设计旋钮的位置。
| 维度 | 遗传算法 GA | 遗传编程 GP |
|---|---|---|
| 编码方式 | 定长向量(实数/二进制/整数) | 变长表达式树 |
| 搜索对象 | 参数值 | 公式、规则、程序结构 |
| 输出形式 | 一组最优参数 | 一棵可以编译成函数的表达式树 |
| 适合场景 | 指标周期、权重、阈值优化 | 交易信号公式、择时规则发现 |
| 解释性 | 容易解释,但要结合策略骨架 | 公式可读,复杂后需要剪枝 |
实际做算法交易程序,我的习惯是让 GP 去演化规则骨架,再用 GA 精调规则里的数值参数。顺序别反:如果你先把几十个特征拼成一个超长向量丢给 GA 优化权重,得到的是一个很难解释的组合权重,一旦行情风格切换,你根本不知道是哪个特征出了问题。先由 GP 给出一个可以读的公式,再用 GA 去调公式里的阈值和周期,出问题时知道从哪里下手。
2.2 数据准备:先解决“预测”到底预测什么
做任何“预测股票价格”的代码,第一步不是选算法,而是定义标签。常见做法有两个:一个是预测未来 n 日收益率,另一个是预测未来 n 日价格移动方向。我强烈不建议直接回归明天的收盘价,因为价格序列本身非平稳,直接回归会让模型退化成一个“昨天收盘价填空”的预测器,训练集上误差很小,实际根本没有预测能力。
下面是一个最小数据预处理代码,也是后面几章共用的基础。列名统一为date, open, high, low, close, volume:
import pandas as pd import numpy as np # 读取日线数据,CSV 列名: date, open, high, low, close, volume df = pd.read_csv("stock_daily.csv", parse_dates=["date"]) df = df.sort_values("date").reset_index(drop=True) # 特征:过去 5 日收益率和 20 日均线乖离率 df["ret_5"] = df["close"].pct_change(5) df["ma_20"] = df["close"].rolling(20).mean() df["bias"] = (df["close"] - df["ma_20"]) / df["ma_20"] * 100.0 # 标签:未来 5 日收益率,供适应度函数计算使用 df["label"] = df["close"].shift(-5) / df["close"] - 1.0 # 去掉无穷值和因窗口导致的缺失值 df = df.replace([np.inf, -np.inf], np.nan).dropna().reset_index(drop=True) print(df[["date", "close", "ret_5", "bias", "label"]].tail())代码逻辑不复杂:pct_change(5)产生过去 5 日收益,rolling(20).mean()产生 20 日均线,bias是收盘价相对均线的乖离率,label是未来 5 日收益。注意一点:shift(-5)在构造标签时可以用,因为标签本来就是未来信息;但任何用于预测的特征都不能出现shift(-n)的负向版本,否则就是未来函数。5和20在这里只是初始值,后面 GA 会去搜最优周期,不要迷信这两个数。
2.3 适应度函数怎么定:预测误差和交易收益不是一回事
很多人在这个项目上翻车,是因为把适应度函数设成了 MSE,让 GA 去最小化预测误差。在回归任务里这没问题,但在算法交易程序里,误差小不等于赚得多。你赚钱靠的是方向判断正确、仓位节奏合适、回撤可控,而不是预测值和真实值的欧式距离。所以确定适应度函数之前,先想清楚你要的是“预测能力”还是“交易能力”。
如果目标是检验预测能力,可以用 IC 或准确率;如果目标是生成策略,直接用回测收益或夏普比率。我一般建议后一类。下面是 GA 适应度函数的一个常见写法,它返回的是双均线信号覆盖下的未来收益总和,负号是因为 deap 默认最小化:
def evaluate(individual, df): # 个体是三个整数:短线周期、长线周期、持有天数 n1 = max(int(individual[0]), 2) n2 = max(int(individual[1]), n1 + 3) hold = max(int(individual[2]), 1) short_ma = df["close"].rolling(n1).mean() long_ma = df["close"].rolling(n2).mean() signal = (short_ma > long_ma).astype(int) # 关键:信号滞后一根 K 线,避免用当天均线预测当天收益 position = signal.shift(1).fillna(0) # 用未来收益乘以持仓,得到策略累计收益 ret = (position * df["label"]).sum() return (ret,)这里position * df["label"]是一种简化的持有期收益:假设信号为 1 时买入并持有到未来 5 日,收益按label计算。真实回测应该逐日累加,后面验证章会补。n2 用max(int(individual[1]), n1 + 3)是硬约束,防止长周期小于短周期;hold 至少是 1。注意 deap 的 evaluate 必须返回元组,单个值也要写成(ret,),这是它内部框架的约定,漏掉会报错。
进化算法和网格搜索、随机搜索的差别也在这里:网格搜索会枚举所有参数组合,在高维空间里代价极高;随机搜索是无记忆的。GA/GP 的优势是通过选择压力把搜索引导到历史表现好的区域,同时保留种群多样性,在参数多、目标函数不平滑、还有噪音的情况下,能更快逼近可用解。但这不是免费午餐,后面避坑章会讲它最容易过拟合的地方。
3. 用遗传算法优化交易参数:可直接运行的 Python 最小例子
3.1 环境依赖和最小代码框架
先把环境准备好。这个项目只需要pandas、numpy、matplotlib和deap。如果还没配置好 Python 基础环境,先按 python 安装教程建一个干净虚拟环境,再执行下面的命令:
pip install pandas numpy matplotlib deap安装完成后,下面是一份完整的可运行代码。它做的事情很简单:读行情数据,定义双均线策略收益,用 GA 搜索短周期、长周期和持有天数三个参数。为了先跑通,我刻意把回测简化成“信号覆盖的未来收益之和”,等你确认收敛正常后再换严格回测。
import random import numpy as np import pandas as pd from deap import base, creator, tools, algorithms def load_data(path="stock_daily.csv"): df = pd.read_csv(path, parse_dates=["date"]) df = df.sort_values("date").reset_index(drop=True) df["ret_5"] = df["close"].pct_change(5) df["ma_20"] = df["close"].rolling(20).mean() df["label"] = df["close"].shift(-5) / df["close"] - 1.0 return df.replace([np.inf, -np.inf], np.nan).dropna().reset_index(drop=True) def evaluate(individual, df): n1 = max(int(individual[0]), 2) n2 = max(int(individual[1]), n1 + 3) hold = max(int(individual[2]), 1) short_ma = df["close"].rolling(n1).mean() long_ma = df["close"].rolling(n2).mean() signal = (short_ma > long_ma).astype(int) position = signal.shift(1).fillna(0) ret = (position * df["label"]).sum() return (ret,) # 定义最大化适应度 creator.create("FitnessMax", base.Fitness, weights=(1.0,)) creator.create("Individual", list, fitness=creator.FitnessMax) toolbox = base.Toolbox() # 整数编码,三个参数范围 2-120 toolbox.register("attr_int", random.randint, 2, 120) toolbox.register("individual", tools.initRepeat, creator.Individual, toolbox.attr_int, 3) toolbox.register("population", tools.initRepeat, list, toolbox.individual) toolbox.register("evaluate", evaluate, df=load_data()) # 混合交叉:alpha 越小子代越接近父代 toolbox.register("mate", tools.cxBlend, alpha=0.3) # 高斯变异:sigma 控制变异步长 toolbox.register("mutate", tools.mutGaussian, mu=0, sigma=10, indpb=0.3) # 锦标赛选择:tournsize 是选择压力 toolbox.register("select", tools.selTournament, tournsize=3) random.seed(42) population = toolbox.population(n=50) algorithms.eaSimple(population, toolbox, cxpb=0.6, mutpb=0.3, ngen=20, verbose=True) best = tools.selBest(population, k=1)[0] print(f"最优参数: n1={int(best[0])}, n2={int(best[1])}, hold={int(best[2])}")先解释这段代码的骨架。creator.create是 deap 定义适应度和个体类型的方式,weights=(1.0,)表示目标函数取最大化;tools.initRepeat用attr_int生成一个长度为 3 的个体,三个位置分别对应 n1、n2、hold。toolbox.register把算子注册成统一接口,方便后面替换策略逻辑。
参数方面有四个值得记。alpha=0.3控制交叉后代偏离父代的程度,alpha 越大子代离父代越远,搜索更发散;sigma=10是变异步长,步长太大会让好参数被一步变异毁掉,太小则困在局部最优;indpb=0.3表示每个基因位有 30% 概率发生变异;tournsize=3是选择压力,值越大种群收敛越快,也越容易早熟。上面这组数值是常见出发点,不是标准答案,我在 5.4 会讲怎么判断它是否合理。
这里我特意把random.seed(42)放在前面,目的是让每次运行结果一致,便于调试。如果你把 seed 去掉,会发现每次最优参数都不同。这是进化算法的正常现象,但也是很多人误以为“代码有问题”的地方。实际做研究时,固定 seed 跑一组基线,再放开 seed 跑多次统计,比单次运行结果更有参考意义。
3.2 个体编码、交叉变异和选择压力设置
上一节代码里把三个参数当成连续整数处理,但在金融参数搜索里,编码方式直接影响搜索效率。我的常见做法是:均线周期这类有明确整数语义的参数,用整数编码;阈值或者止损比例这类带小数的参数,用实数编码后限制范围。混合编码在 deap 里也支持,但需要自定义交叉变异算子,新手不建议一上来就用。
下面是几个需要关注的超参数表格:
| 参数 | 常见范围 | 我在这个项目上的偏好 |
|---|---|---|
| 种群规模 | 30 - 200 | 50 起步,金融数据噪音大时加到 150 |
| 迭代代数 | 10 - 100 | 先跑 20 看收敛曲线,不收敛再拉长到 100 |
| 交叉概率 | 0.5 - 0.9 | 0.6 左右,交叉太高会让良好基因碎片化 |
| 变异概率 | 0.05 - 0.4 | 0.3 起步,发现早熟后降到 0.1 |
| 选择压力 | 2 - 7 | tournsize=3,过拟合信号明显时降到 2 |
如果你用selTournament,tournsize 每增加 1,选择压力都会显著上升。样例代码里最优解收敛得很快时,先别庆祝,多半是选择压力过大或变异步长过大,种群多样性在几代内就塌了。把最优个体输出来看参数是否落在边界上,比如 n1=2 或 n2=120,如果是,说明搜索被边界吸引,应该调整参数范围或改用边界处理逻辑。
3.3 跑完怎么看结果:收敛曲线和参数热力图
跑完 GA 不是看一个“最优参数”就完事,你要确认搜索确实收敛到稳定区域。我的习惯是打开verbose=True看每代日志,同时用tools.Statistics记录每代最优值并画收敛曲线:
import matplotlib.pyplot as plt from deap import tools, algorithms stats = tools.Statistics(key=lambda ind: ind.fitness.values[0]) stats.register("max", np.max) population, logbook = algorithms.eaSimple( population, toolbox, cxpb=0.6, mutpb=0.3, ngen=30, verbose=False, stats=stats ) gen = list(map(int, logbook.select("gen"))) max_fit = logbook.select("max") plt.plot(gen, max_fit) plt.xlabel("generation") plt.ylabel("best fitness") plt.title("GA convergence curve") plt.show()tools.Statistics里的key传入的是一个函数,它从个体中取出适应度值;register("max", np.max)表示统计每代种群中的最大值。logbook.select("gen")返回世代列表,select("max")返回每代最大值。如果曲线是“前几代快速上升,后面走平”,说明搜索正常;如果曲线一直在锯齿状震荡,先看是不是sigma=10太大,或者适应度函数本身噪音大,比如不同时间段收益差异剧烈。把ngen从 20 加到 50 再跑一次,如果还是震荡,问题多半在数据或适应度函数上,而不是 GA 没跑够。
参数热力图是另一个有用的诊断工具。选出 GA 搜索过程中出现的所有个体,把 n1、n2 和适应度画成散点图,能直观看到高收益参数是否集中在某个区域。这个步骤不做也没关系,但在你怀疑“最优参数是运气好”时,它能帮你快速判断。
4. 用遗传编程演化交易规则:从表达式树到买入卖出信号
4.1 GP 的树形编码与函数集设计
遗传编程和遗传算法的差异在函数集上就很明显。GA 的个体是固定长度向量,GP 的个体是一棵可以任意生长的表达式树。在 deap 里,PrimitiveSetTyped用来定义函数集和终端集,gp.compile可以把表达式树编译成 Python 可调用对象。
下面是一段函数集定义代码,也是后面演化方向预测器的核心:
import random import numpy as np from deap import gp def protected_div(a, b): # 保护除法:分母绝对值小于阈值时返回 1.0 with np.errstate(divide="ignore", invalid="ignore"): return np.where(np.abs(b) > 1e-8, a / b, 1.0) # 创建 main 函数,输入 4 个浮点特征,输出 1 个浮点信号 pset = gp.PrimitiveSetTyped("MAIN", [float] * 4, float) # 给终端命名,后面人读公式时不至于看到 ARG0 feature_names = ["ret_5", "bias", "rsi_14", "vol_change"] for i, name in enumerate(feature_names): pset.rename(arg=i, new_name=name) # 函数集:加减乘除 + 最大值最小值 pset.addPrimitive(np.add, [float, float], float, name="add") pset.addPrimitive(np.subtract, [float, float], float, name="sub") pset.addPrimitive(np.multiply, [float, float], float, name="mul") pset.addPrimitive(protected_div, [float, float], float, name="div") pset.addPrimitive(max, [float, float], float, name="max") pset.addPrimitive(min, [float, float], float, name="min") # 随机常量:演化中随时生成的 [-1, 1] 浮点数 pset.addEphemeralConstant("const", lambda: random.uniform(-1.0, 1.0), float)这段代码的要点有三个。第一,PrimitiveSetTyped的第二个参数[float] * 4声明了入口函数的参数类型,每个参数对应一个特征;第三个参数是输出类型。GP 演化过程中,所有子树都必须类型匹配,所以这里统一用float,避免布尔和数值混用带来的类型错误。第二,protected_div是必写的,普通除法在 GP 里几乎必然遇到分母为 0,不用保护除法的代码会在演化到十几代时频繁抛异常。第三,addEphemeralConstant生成的是范围受限的随机常量,它让表达式树能演化出类似ret_5 > 0.3这样的比较阈值,范围设置越合理,搜索越容易,我一般控制在[-1, 1]。
我还加了rsi_14和vol_change两个特征,但在实际预处理里需要额外计算。常见的 RSI 计算用到talib或手动实现,这里不展开,因为 GP 本身对特征精确值没那么敏感,重要的是特征和标签之间存在相关性。如果你没有这些特征,可以先用ma_20和ret_5两个特征跑通,再逐步加。
4.2 用 DEAP 实现 GP 预测股票价格方向
有了函数集,就可以完成 GP 的完整搜索流程。下面的代码会演化出一个浮点表达式,大于 0 表示预测未来上涨,小于 0 表示下跌。为了先验证 GP 结构能否收敛,适应度函数用方向准确率,而不是收益。注意:这只是一个起点,不是最终策略。这一段可以看作遗传算法 python 代码详解里最常见的 GP 写法。
import random import numpy as np from deap import base, creator, tools, gp, algorithms # 假设 df 已经用 2.2 节代码预处理,且已经有 ret_5, bias, rsi_14, vol_change X = df[["ret_5", "bias", "rsi_14", "vol_change"]].values.astype(float) y = (df["label"].values > 0).astype(int) creator.create("FitnessMax", base.Fitness, weights=(1.0,)) creator.create("Individual", gp.PrimitiveTree, fitness=creator.FitnessMax) toolbox = base.Toolbox() # 初始树生成:半高半满法,深度 1-3 toolbox.register("expr", gp.genHalfAndHalf, pset=pset, min_=1, max_=3) toolbox.register("individual", tools.initIterate, creator.Individual, toolbox.expr) toolbox.register("population", tools.initRepeat, list, toolbox.individual) toolbox.register("compile", gp.compile, pset=pset) def eval_direction(individual): try: func = toolbox.compile(expr=individual) pred = np.array([func(*row) for row in X]) pred_binary = (pred > 0).astype(int) acc = (pred_binary == y).mean() # 树深度惩罚:超过 80 个节点时乘以折减系数 if len(individual) > 80: acc *= 0.9 return (acc,) except (OverflowError, ValueError, ZeroDivisionError): return (0.0,) toolbox.register("evaluate", eval_direction) toolbox.register("select", tools.selTournament, tournsize=3) toolbox.register("mate", gp.cxOnePoint) # 变异时用 genFull 生成新的子树 toolbox.register("expr_mut", gp.genFull, min_=0, max_=2) toolbox.register("mutate", gp.mutUniform, expr=toolbox.expr_mut, pset=pset) random.seed(42) pop = toolbox.population(n=60) algorithms.eaSimple(pop, toolbox, cxpb=0.5, mutpb=0.3, ngen=25, verbose=True) best = tools.selBest(pop, k=1)[0] print(best)这段代码的逻辑是:toolbox.compile把PrimitiveTree变成普通 Python 函数,然后逐行把特征向量传入函数,得到数值预测。pred > 0二值化成方向,再与 y 比较得到准确率。len(individual) > 80的惩罚是必须的,GP 不控制深度时容易长出数百节点的树,训练集准确率高但泛化能力差,这种惩罚能引导搜索偏向更简洁的表达式。
参数上,gp.genHalfAndHalf是初始树的生成方式,取随机深度,低于max_限制;gp.cxOnePoint在两个父树中随机选择一个子树交换点做单点交叉;gp.mutUniform把一个随机位置的子树替换成新生成的子树。初始树深度max_=3是一个比较保守的设置,如果你想看到更复杂的规则,可以调到 4 或 5,但搜索时间会明显上升。种群 60、25 代同样只是起点,收敛不理想时优先加种群而不是代数。
4.3 把演化出的公式转成交易策略:从信号到下单逻辑
GP 输出的best是一棵PrimitiveTree,直接打印是一串类似add(mul(ret_5, bias), sub(div(...)))的表达式。要把它用在算法交易程序里,必须编译成 Python 函数,然后逐根 K 线生成信号。
best_func = toolbox.compile(expr=best) # 逐行计算信号,这里为了可读性用 iterrows df["signal"] = [ best_func(ret_5, bias, rsi_14, vol_change) for ret_5, bias, rsi_14, vol_change in zip(df["ret_5"], df["bias"], df["rsi_14"], df["vol_change"]) ] # 信号大于 0 就持仓,信号小于等于 0 空仓 df["position"] = np.where(df["signal"] > 0, 1, 0) # 关键:信号滞后一根 K 线,代表第二天开盘执行 df["position"] = df["position"].shift(1).fillna(0) # 用日收益合成策略净值和基准净值 df["daily_ret"] = df["close"].pct_change() df["strategy_ret"] = df["position"] * df["daily_ret"] df["strategy_net"] = (1 + df["strategy_ret"]).cumprod() df["benchmark_net"] = (1 + df["daily_ret"]).cumprod() print(f"策略累计净值: {df['strategy_net'].iloc[-1]:.4f}") print(f"基准累计净值: {df['benchmark_net'].iloc[-1]:.4f}")这段代码有几个容易出错的地方。best_func的输入顺序必须和定义函数集时的特征顺序一致,我使用zip(df["ret_5"], ...)而不是df.iterrows(),既保留顺序又避免 Pandas 的索引开销。df["position"].shift(1)是信号延迟,如果你把这一行去掉,回测会用到当天收盘后的信息,收益虚高。日收益pct_change()天然的缺失值会在cumprod()之前被处理,dropna()可以保证索引对齐。
注意best_func可能输出inf或nan,在 4.2 的适应度函数里用try/except处理过,但交易阶段也要防止nan进入 position。建议在计算后加一行逻辑:df["position"] = np.where(np.isfinite(df["signal"]), df["position"], 0)。从信号到下单逻辑,中间还隔着交易成本、滑点和最小交易单位。这个最小例子只挂了 position 和日收益,已经能看出 GP 演化出的规则是否具备最基本的盈利能力。
5. 五个避坑记录:遗传算法做股票预测最容易翻车的地方
5.1 未来函数让样本内收益异常高
现象:回测净值曲线看起来接近一条 45 度上升的直线,训练集上胜率超过 90%,但参数一换到别的时间段就迅速失效。如果打印成交记录,会看到大量买卖点恰好发生在每根 K 线的最高价或最低价附近,这是典型的“开天眼”回测特征。
原因:特征或标签里混进了未来信息,最常见的有两种。一种是用close.shift(-1)或当天的close与当天的signal相乘计算收益,等于当天收盘前就知道当天会不会涨;另一种是没把position滞后,信号和收益在同一根 K 线上对齐。GA/GP 的搜索能力很强,只要数据里有一点点未来信息,它就能抓住并无限放大,导致适应度虚高。
解决:定一条铁律:所有持仓信号必须整体shift(1)后再计算收益,构造标签时用shift(-n)可以,但特征列一律禁止使用负向 shift。写完代码后做一个随机化测试:把label或收益列随机打乱,再跑一次 GA/GP。如果随机打乱后适应度仍然很高,说明代码里存在泄漏;如果适应度大幅下降到接近随机水平,说明至少没有明显的未来函数。
5.2 适应度函数选错:预测准确率很高但策略不赚钱
现象:GP 演化出的分类器在训练集上准确率有 56%,看着比随机好不少,但把信号转成持仓后一算,策略累计收益接近零,甚至跑输基准。多次调参后准确率能涨到 58%,资金曲线却没有任何改善。
原因:准确率没有考虑价格波动幅度。模型总是预测对“小涨”,但在“大跌”前给出错误信号;或者模型频繁切换仓位,每笔收益都被交易成本吞噬。股票价格预测的方向准确率本身就很低,55% 和 60% 之间相差的 5% 往往来自少数几根大波动 K 线,而这部分信号恰恰可能被准确率指标忽略。
解决:把适应度函数从准确率改成“信号收益总和”“夏普比率”或“收益回撤比”。如果坚持用准确率,至少把准确率按类别加权,或者只在方向预测之后叠加一个收益过滤模块,比如预测下跌时不持仓、预测上涨时根据置信度调整仓位。记住,算法交易程序最终优化的是资金曲线,不是预测正确次数。
5.3 过拟合到历史噪声:反复用同一段数据调参
现象:训练集和验证集收益都很好,但把窗口挪到另一段时间,收益明显衰减。更隐蔽的是,换一组初始随机种子后,最优参数变化很大,适应度差别也很大,说明搜索结果对随机种子非常敏感。
原因:进化算法本质上是在历史数据里搜索规律,而历史数据里有大量噪声。只要搜索空间足够大,GP/GA 一定能找到一组在训练段上表现优秀的参数或公式,这是过拟合的必然结果。比神经网络更麻烦的是,进化算法没有显式的早停机制,设计不好的话它会继续往噪声里钻,把一段行情的特殊波动当成长期规律。
解决:把数据切成训练、验证、测试三段,搜索只在训练段进行,验证段只用来做最终选优,测试段只允许评估一次。不要反复用测试段调整参数,否则测试段会变成新的验证段。更严格的做法是 6.1 的滚动前推验证,让每一段数据都经历过“训练-验证-测试”的循环,再统计整个测试周期上的表现。另外,尽量多跑几次不同随机种子,看有效参数是否集中在同类区域,而不是依赖单次最优解。
5.4 种群参数乱设:收敛不稳定或早熟
现象:同样的代码每次运行出来的最优参数差异巨大,有时n1=5, n2=20,有时n1=80, n2=110。看收敛曲线,最优适应度一直在 0.1 和 0.5 之间来回跳,几十代都没有稳定下来。
原因:种群太小,选择压力又大,导致种群多样性在几代内就崩溃;或者变异步长sigma设置得太大,最优解附近的好基因被高斯变异反复破坏。另一个常见原因是参数范围设得过大,random.randint(2, 120)把搜索空间撑得太大,50 个个体、20 代根本覆盖不了。
解决:先用固定随机种子跑通,把种群从 50 加到 150,代数从 20 加到 80,看收敛曲线是否变平稳。如果还不稳定,把sigma从 10 降到 5,tournsize从 3 降到 2。如果这时适应度还是锯齿状,检查数据分段是不是太短。样本量少于 300 根 K 线时,GA 很难区分策略能力和运气,先补数据再调参。
5.5 GP 生成非法表达式:除零、类型错误和解释器崩溃
现象:GP 演化过程中突然抛ZeroDivisionError、OverflowError,或者best_func(*row)返回nan/inf,导致回测净值变成空值。有时代码在训练阶段不报错,到实盘信号生成阶段才崩溃,非常难排查。
原因:GP 的搜索空间包含大量无意义的表达式,比如除以一个极小的数会产生超大浮点数,np.exp(1000)会溢出,numpy在符号计算时还可能产生nan。不用保护除法、不捕获异常,程序会在某次评估时直接崩溃。更隐蔽的是,某些表达式只在特定数据段触发问题,前几代没事,到几十代才出现。
解决:函数集里所有可能出错的算子都要包裹保护层,protected_div是必须的;np.log和np.sqrt改成np.log(np.abs(x) + 1e-8)和np.sqrt(np.abs(x))。在适应度函数最外层加上try/except,捕获OverflowError, ValueError, ZeroDivisionError,异常个体直接返回最低分。交易阶段同样要对best_func的输出调用np.isfinite过滤,避免nan/inf进入持仓信号。
6. 验证与进阶:上实盘前先做这三件事
6.1 滚动前推验证:替代随机切分的时间序列交叉验证
随机切分数据集在普通机器学习里能用,但股票数据有强时间相关性,随机切分会让训练集中后期数据出现在测试集里,等于泄露未来。我一般用滚动前推,代码骨架如下:
def walk_forward(df, train_size=800, test_size=200): results = [] for start in range(0, len(df) - train_size - test_size, test_size): train = df.iloc[start:start + train_size] test = df.iloc[start + train_size:start + train_size + test_size] # 在 train 上跑 GA/GP,得到最优个体 pop, _ = run_evolution(train) # 前面定义的搜索函数 best = tools.selBest(pop, k=1)[0] # 在 test 上只评估一次,不参与搜索 metrics = evaluate_on_test(best, test) results.append(metrics) return results这里的run_evolution和evaluate_on_test是前面章节代码的封装,返回值关键是“测试段只被用一次”。滚动前推的平均收益和波动区间,比单次训练测试的结果可靠得多。你可以在多个起始点重复这个流程,最后看测试段总体是否能跑赢基准。
6.2 加入交易成本和非线性惩罚
前面代码都没算交易成本,实际程序里每笔买卖都要扣手续费和滑点。加入成本后,适应度函数要重算。常见做法是给适应度函数增加两个惩罚项:换手率成本和最大回撤惩罚。
cost = 0.001 # 单边千分之一 turnover = df["position"].diff().abs().fillna(0) df["net_ret"] = df["position"] * df["daily_ret"] - turnover * cost df["net_net"] = (1 + df["net_ret"]).cumprod() # 最大回撤惩罚 roll_max = df["net_net"].cummax() drawdown = (df["net_net"] - roll_max) / roll_max max_dd = drawdown.min() sharpe = df["net_ret"].mean() / (df["net_ret"].std() + 1e-12) * np.sqrt(252)position.diff()得到仓位变化,换手次数乘以成本就是策略的摩擦损耗。GP/GA 在没有成本时容易演化出高换手策略,加入成本后这些策略会被淘汰。另一个惩罚是最大回撤:收益高但回撤大的策略不适合实战,可以把适应度改成收益 / (1 + 最大回撤)或夏普比率。我建议至少同时看两个指标,不要只盯累计收益。
6.3 从预测价格到算法交易程序的完整管线
把这些模块串起来是一个标准流程:读数据、构造特征、定义适应度、GA 优化参数、GP 演化规则、滚动前推验证、成本回测。每一层都先打印一个小报告,确认没有未来函数后再进入下一层。比如在特征构造后检查df.columns和索引顺序,在 GA 跑完检查收敛曲线,在回测后检查最大回撤位置对应的原始终是否合理。
最后说一个走了很多次弯路才形成的习惯:任何进化算法结果,先做随机化检验和滚动前推,再决定要不要继续调。遗传算法很擅长“记住”行情,不擅长“理解”行情,如果你发现一个参数组合在历史回测里看起来很完美,第一反应应该是怀疑它是不是在背答案。希望帮到你。
本文还有配套的精品资源,点击获取