☰
深度学习期货程序化交易:数据、特征、模型与回测全流程
2026/10/12 20:11:29 网站建设 项目流程

简介:面向量化交易开发者与金融科技研究者,这是一套完整的python结合深度学习的期货程序化交易系统源码包,涵盖行情接口、信号生成、策略回测与交易执行等环节,适合有Python基础、希望将深度学习模型落地到期货场景的中高级读者。压缩包共729个文件,容量19.56MB,核心代码以401个Python脚本、36个C++源文件及52个头文件为主,另有pkl模型文件、csv训练数据、json/xml配置和dll动态库,分别对应模型权重、历史行情、参数设置与底层交易接口封装。从内容预览可见,包内包含vnctptd.cpp等CTP对接代码以及多个配置文件,能够帮助理解交易终端与柜台通信的完整细节。目前已有240人学习,对于希望将深度学习技术应用于期货策略的开发者,这套源码提供了可直接运行的工程骨架,便于二次开发与学术实验。

1. python+深度学习做期货程序化交易:先把“预测任务”拆成“可执行信号”

期货和股票最大的区别就是T+0、多空双向、带杠杆,行情信号密度很高,人工盯盘很容易被分钟级别的噪声牵着走。python在这个方向上可以干完整条链路:数据清洗、特征计算、模型训练、回测、下单执行,而深度学习的真正价值不在“预测精确涨跌”,而在把历史量价关系和盘口特征映射成一个概率信号。我习惯把这套系统拆成四层:数据层处理连续合约和分钟bar,特征层负责归一化和序列窗口,模型层输出多/空/观望三档信号,执行层用规则把信号转成仓位并做风控。适合读这篇文章的人,是已经会写Python、愿意在数据上花时间而不是急着调模型的从业者。

2. 期货行情数据与特征工程:分钟bar怎么建、连续合约怎么对,特征才不偷看未来

2.1 先解决“数据对不上”:主力合约怎么选、换月跳空怎么处理

期货不能像股票那样直接拿一个代码拉十年日线就完事。主力合约会定期换月,换月那天价格经常跳空,这个跳空不是市场真的涨跌,只是换了合约标的。如果你直接把不同月份的K线拼在一起,深度学习模型会把“换月跳空”当成一个高收益信号去学习,实盘一跑就翻车。

我一般会先拿到交易所官方品种指数(比如螺纹指数、铁矿指数),或者自己做主力映射:每天按持仓量和成交量排序,取最大那个合约作为当日主力,得到一个“主力合约代码+Bar数据”的序列。注意换月当日前后两根bar之间的价格断裂要单独处理,常见做法是记录换月日期,在特征计算时跳过跨越换月点的收益率,或者用复权因子把两根价格接上。关键原则是:训练、验证、回测必须用同一套合约拼接逻辑,不能训练用指数、回测用主力连续,否则模型学到的分布和回测环境对不上。

这里的坑在于很多人直接用第三方库里的“主力连续”数据,以为它是干净的。实际上不同数据商的主力连续算法不同,有的是按成交量加权,有的是按持仓量切换,复权方式也不一样。你在模型里看到的“涨跌”很可能有一半是换月造成的假信号。所以做特征工程之前,先花一天时间把合约序列和换月表对齐,比多调十个超参都值。

2.2 从tick到分钟bar:一段可直接跑的聚合代码

模型吃的最小时钟单位我一般用1分钟。tick数据量太大,特征噪声也太高;5分钟或15分钟bar又太粗,信号更新频率低,交易执行跟不上。下面这段代码把tick聚合成1分钟bar,并额外计算主动买卖量比作为盘口维度的补充特征。

import pandas as pd import numpy as np def ticks_to_1m_bars(ticks: pd.DataFrame) -> pd.DataFrame: """ ticks 至少包含列: ts(秒), price, volume, side(主动方向: 'B'买/'S'卖) """ ticks = ticks.sort_values("ts").copy() ticks["bar_ts"] = ticks["ts"] // 60 * 60 # 向下取整到分钟 # ---- 基础OHLCV ---- agg = ticks.groupby("bar_ts").agg( open=("price", "first"), high=("price", "max"), low=("price", "min"), close=("price", "last"), volume=("volume", "sum"), ) # ---- 主动买卖量比:买盘主动性越强,short-term动量往往越强 ---- side_volume = ticks.groupby(["bar_ts", "side"])["volume"].sum().unstack(fill_value=0) agg["buy_volume"] = side_volume["B"] if "B" in side_volume else 0 agg["sell_volume"] = side_volume["S"] if "S" in side_volume else 0 agg["buy_sell_ratio"] = (agg["buy_volume"] + 1) / (agg["sell_volume"] + 1) agg = agg.reset_index() return agg

这段代码的关键逻辑:先按秒级时间戳把tick切到分钟桶,然后做组内聚合。first取分钟第一笔价格作为open,last取最后一笔作为close,sum累加成交量。主动买卖量比是很多人在分钟级别上容易忽略的维度,它能反映这1分钟里是买方主动吃单还是卖方主动砸单,对短周期方向判断比单纯收盘价更敏感。

参数上值得调整的只有两处:bar_ts = ts // 60 * 60决定分钟周期,改成// 300 * 300就是5分钟bar;buy_sell_ratio里加1是为了避免除零。如果你拿到的是盘口五档数据,还可以把“买一量/卖一量”加进来,但要注意五档快照和成交tick的时间戳对齐问题,很多数据商这两个字段的时间基准不一样,强行对齐反而引入噪声。

2.3 特征矩阵:哪些量价因子能进模型,哪些只是玄学

聚合成bar之后,下一步是构造特征矩阵。我通常给每个bar生成三个维度的特征:基本量价、时序统计、微观结构。基本量价包括对数收益率、bar内振幅、成交量相对前N根的比率;时序统计是过去L根bar的滚动均值、滚动标准差、RSV等;微观结构就是上面算的主动买卖量比。

下面这个特征列表是我在螺纹、铁矿、沪铜上常用的起点,不一定全用,但没这几个特征模型几乎学不到东西。对数收益率比原始价格更适合做输入,因为价格在不同月份合约上绝对值差异很大,模型不该把“价格是4000还是4500”当作有效信息。

特征名计算方式说明
log_retln(close / prev_close)对数收益率,跨合约相对稳定
range_pct(high - low) / prev_close单根bar振幅,代表波动强度
vol_ratiovolume / mean(volume, 20)放量/缩量程度,放量往往是行情启动信号
buy_sell_ratio主动买量 / 主动卖量买卖失衡度,短周期方向参考
max_single_volbar内最大单笔成交量 / avg单笔大单异动标记
rsv(close - min_low) / (max_high - min_low)随机指标原始值,注意窗口不要跨换月点

真正要警惕的是“特征工程玄学化”。我看到有人在分钟特征里加MACD、KDJ这些日线指标的重采样版本,模型没变强,训练时间变长,回测还更好看了——因为指标密集区的边界点和换月点重合,模型学到了换月跳空。判断特征是否有效的方法很简单:在验证集上只替换这一个特征,其他全不动,看绩效指标是否真的变化超过噪声区间。我一般会做两次这样的对照,超过一半的所谓“新特征”其实只是让模型更过拟合。

2.4 按时间切分训练集:唯一不会偷看未来的切法

这是整条流水线上最重要的一条纪律:训练集、验证集、测试集必须按时间顺序切分,不能随机切分。随机切分会把某天的数据同时放进训练和验证里,相邻bar的强自相关性会直接泄漏未来信息,模型在回测里跑出虚高绩效是完全正常的。

我的切法是这样:假设你有三年1分钟bar,按时间顺序取前70%训练,中间15%验证,最后15%做一次性的样本外测试。调超参只看验证集,测试集只跑一次。如果测试集结果明显比验证集差,不是运气问题,是策略过拟合了验证集,应该回头简化模型而不是继续调参。

特征标准化也是一个泄漏高发点。无论你用z-score还是min-max,scaler对象只能用训练段的数据去fit,再用这个训练好的scaler去变换验证段和测试段。很多人的做法是拿到全量数据先标准化再切分,这等于让模型在训练阶段见过验证集的均值和方差,验证集就丧失了检验能力。这一点放在第5章的避坑清单里再展开。

3. 深度学习模型选型与训练:三分类信号为什么比价格回归稳,param量和超参怎么权衡

3.1 别做价格回归:三分类信号设计更符合交易本质

把深度学习接到期货上,第一个要拿定的主意是模型输出什么。不少从股票量化转过来的人会先试LSTM预测下一根bar的收盘价,然后根据预测价和当前价的差决定多空。我试过这个方案,结论是不行:期货分钟级别的价格噪声非常大,回归模型会把大量权重花在拟合噪声上,而且回归损失(MSE)优化的方向和交易目标不一致——预测价差0.5个tick和1.5个tick在交易上可能完全没有区别,但在MSE里会被当成两倍错误惩罚。

更稳的建模方式是做三分类:给定过去L根bar的特征序列,预测未来K根bar的累计收益率属于“上涨/下跌/震荡”哪一类。阈值不设死,可以用未来K根bar的收益率分布分位数切,比如最高的30%标记为上涨,最低的30%标记为下跌,中间40%为震荡。这样模型的优化目标变成了“方向是否判断正确”,和交易决策的耦合度更高。

3.2 网络结构选型:一维CNN+LSTM在期货量价上的理由

我的默认结构是一维CNN接LSTM再接全连接。CNN负责在时间维度上提取局部模式,比如连续3-5根bar的放量突破、买卖比持续失衡;LSTM负责捕捉中短期的序列依赖,比如过去40分钟的趋势持续性。这个结构在分钟级期货数据上比纯LSTM更稳,因为它用卷积先做了一层降噪,LSTM需要记住的原始信息更少。

为什么不上Transformer?我在这个方向上的经验是:分钟级金融数据的有效样本量撑不起Transformer的注意力参数规模,而且它训练不稳定,学习率稍高就发散,稍低就学不动。对于期货程序化交易这个场景,CNN+LSTM的参数量、训练稳定性和推理速度都更实用。如果你确实想试Transformer,先把序列长度控制在64以内,预训练从头开始,不要直接上BERT那套初始化。

3.3 模型代码:PyTorch里的一维CNN+LSTM实现

下面这段代码是一个可以直接跑通的最小实现。输入张量形状是[batch_size, seq_len, feat_dim],输出是三个类别的logits。

import torch import torch.nn as nn class CNNLSTMModel(nn.Module): def __init__(self, feat_dim: int, seq_len: int = 64, num_classes: int = 3): super().__init__() # ---- 一维CNN局部特征提取 ---- self.conv1 = nn.Conv1d(feat_dim, 32, kernel_size=5, padding=2) self.bn1 = nn.BatchNorm1d(32) self.conv2 = nn.Conv1d(32, 32, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm1d(32) self.relu = nn.ReLU() # ---- LSTM序列建模 ---- self.lstm = nn.LSTM(32, 64, num_layers=2, batch_first=True, dropout=0.2) self.head = nn.Linear(64, num_classes) def forward(self, x): # x: [B, L, F] -> 转为 [B, F, L] 给Conv1d x = x.permute(0, 2, 1) x = self.relu(self.bn1(self.conv1(x))) x = self.relu(self.bn2(self.conv2(x))) # 转回 [B, L, 32] 给LSTM x = x.permute(0, 2, 1) lstm_out, _ = self.lstm(x) last = lstm_out[:, -1, :] # 取最后一个时间步的隐状态 logits = self.head(last) return logits

这里三个关键参数需要解释。kernel_size=5意味着卷积一次看5根bar,如果特征里含波动率这类慢变量,可以把一个卷积核改成kernel_size=9做多尺度感受野。num_layers=2已经够用,堆到4层在分钟数据上几乎必然过拟合。dropout=0.2只加在LSTM层之间,输出层不要加dropout,否则推理和训练行为不一致。

训练代码的要点:优化器用AdamW,lr=1e-3是起点,weight_decay=1e-4到1e-5之间按验证集表现选;加梯度裁剪clip_grad_norm_(model.parameters(), 1.0),这能显著避免LSTM梯度爆炸。batch size从64开始调,如果训练集不足10万根bar,batch size降到32。损失函数用带类别权重的CrossEntropyLoss,因为三分类里“震荡”类样本通常占比更高,不让多数类主导梯度方向。

def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 for x_batch, y_batch in loader: x_batch = x_batch.to(device) y_batch = y_batch.to(device) logits = model(x_batch) loss = criterion(logits, y_batch) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss += loss.item() * x_batch.size(0) return total_loss / len(loader.dataset)

这段训练循环里的clip_grad_norm_是我特别强调的。序列模型在长序列上反向传播很容易出现梯度范数突然涨到几十甚至几百的情况,不裁剪的话一两个batch就能把训练搞崩。裁剪阈值1.0是保守值,如果你发现loss曲线下降变慢,可以放到2.0,但超过5.0基本说明学习率或数据本身有问题。

3.4 模型参数量权衡:几十万参数已经很多了

这里想澄清一个容易懵的点:深度学习模型里说的“parameter”单位是M(百万个参数),不是MB。一个CNN+LSTM模型参数量通常在几十万到两三百万之间,也就是0.3M到2.3M,它对应的是结构定义里的权重矩阵规模,跟显存占用是两码事。你在训练日志里看到“参数量2.1M”,指的是210万个浮点数权重,不是210MB显存。

在期货分钟数据上,我的经验是参数量超过3M就要警惕了。以三年1分钟bar计算,一个品种大约50万根训练样本,模型如果做到百万级参数,每个参数平均只能分到不到1个样本的信息,模型几乎必然把训练集背下来。判断是否过拟合的标准很简单:训练loss和验证loss的gap持续拉大,且超过0.15左右,优先减小LSTM隐层维度(64降到32)或把序列长度从64降到48,而不是加正则硬压。

3.5 从softmax概率到交易信号:阈值和margin的粗调方法

模型输出的是三分类logits,经过softmax变成[p_up, p_down, p_flat]三个概率。直接取argmax作为信号是最差的做法,因为概率0.34和0.33之间的差距毫无意义,扣掉手续费滑点就是亏损源。

我一般给每个方向设一个置信度门槛:做多要求p_up > 0.5且p_up - p_down > margin,这里的margin是验证集上调出来的,初值0.1,步长0.05。做空同理,p_down > 0.5且p_down - p_up > margin。其余情况一律输出观望。margin调大,交易次数减少、单笔胜率上升;调小则交易频繁、手续费吃掉利润。这个参数直接决定了策略的“手数密度”,值得用验证集做一次网格搜索,别拍脑袋定。

4. 信号到下单之间:回测引擎怎么写、滑点和手续费怎么扣、哪些指标才算数

4.1 信号到仓位:三档信号为什么不能直接满仓

模型输出“多/空/观望”之后,这只是原始信号,不是仓位指令。直接全仓多或全仓空,在期货的杠杆下,连续三次错误信号就能让账户接近爆仓。我常用的映射方式是:多 = +0.5,空 = -0.5,观望 = 0,这里的0.5是账户净值的仓位比例,再叠加一个整体杠杆系数。杠杆系数按品种波动率定,波动率大的品种(比如原油)用小杠杆,波动率小的(比如国债期货)可以放大一些。

还要加一条铁律:单笔交易的风险预算固定。比如单笔最大亏损不超过账户净值的0.5%,根据当前波动率和止损距离倒推开仓手数。这一步必须在执行层做,不能指望模型来控风险。模型的职责是给出方向判断,执行层的职责是确保任何单次判断错误都不会伤到账户根基,这两个角色分开,系统才扛得住连续亏损的行情。

4.2 回测引擎的最小实现:信号在下一根K线开盘成交

回测引擎是检验整个系统能不能用的关键。我写过一个最小版本,核心原则只有一条:第i根bar收盘后产生信号,最早只能在第i+1根bar开盘价成交。这样避免“用收盘价和信号在同一个bar内成交”的典型未来函数。

import pandas as pd def run_backtest(bars: pd.DataFrame, signals: pd.Series, fee_rate: float = 0.0001, slippage_ticks: int = 1, min_tick: float = 1.0, lots: int = 1, init_cash: float = 1_000_000.0): """ bars: 含 open/high/low/close 的bar数据,索引为时间 signals: 与bars对齐的信号序列,取值 -1/0/1,表示第i根bar收盘后产生的目标方向 """ cash = init_cash position = 0 # 当前持仓方向:-1空/0空仓/1多 entry_price = 0.0 equity_curve = [] total_fee = 0.0 for i in range(1, len(bars)): # 第 i 根bar开盘执行第 i-1 根bar收盘产生的信号 target_signal = signals.iloc[i - 1] open_price = bars["open"].iloc[i] if target_signal != position: # 先平旧仓,再开新仓 if position != 0: cash += position * (open_price - entry_price) * min_tick * lots if target_signal != 0: entry_price = open_price fee = (entry_price * min_tick * lots) * fee_rate cash -= fee total_fee += fee position = target_signal # 当日盯市:用收盘价计算浮动权益 close_price = bars["close"].iloc[i] floating = position * (close_price - entry_price) * min_tick * lots equity_curve.append(cash + floating) return pd.Series(equity_curve, index=bars.index[1:])

这个回测引擎的逻辑重点有三处。第一,target_signal = signals.iloc[i - 1]表示信号一定比成交晚一个bar周期,杜绝盘中瞬移成交。第二,平仓时position * (open_price - entry_price)用正负号区分多空方向,做空时价格上涨是负收益,方向判断相反则亏钱。第三,交易手续费只在开仓时扣一次,平仓手续费可以按同样方式在平仓分支再扣一次,这里简化成开仓扣费,实盘记得两边都算。

滑点的处理方式是min_tick * 1,也就是假设每次开仓比理想价多滑1个最小变动价位。如果你的品种流动性一般,我会把slippage_ticks调到2-3。回测结果对滑点非常敏感,一个看起来年化30%的策略,滑点从1个tick变成3个tick可能直接掉到10%以下。

4.3 手续费、滑点、保证金:回测参数按两种口径设

手续费和滑点这两个参数,是回测和实盘差距的最大来源,我一般用保守口径和乐观口径同时跑。乐观口径:手续费按交易所标准费率上浮10%,滑点1个tick;保守口径:手续费按期货公司加收后的真实标准(一般是交易所标准的1.5-2倍),滑点按2-3个tick,同时把跳空开盘的情况也计入滑点。

保证金和强平在这个最小回测引擎里没有建模,这是它的已知边界。如果你做的是日内短线、仓位比例不超过0.5,保证金占用对净值曲线的扰动很小,可以忽略。但如果你做隔夜或者仓位放大,必须把逐日盯市和强平规则加进去,否则回测里一个超过100%的回撤在实盘里早就爆仓了。

4.4 绩效指标要看这些:年化、回撤之外,盈亏分布和换手率

很多人看回测结果只盯年化收益和最大回撤,这两个指标远远不够。我更看重四个数:盈亏比(平均盈利/平均亏损)、胜率、单月最大回撤、换手率。盈亏比低于1.5的策略在实盘里几乎没有生存空间,因为滑点和手续费会进一步压缩每一笔交易的利润。

换手率这个指标特别容易忽视。同样年化20%的策略,一个每天交易20次,一个每天交易2次,实盘表现会完全不同——前者对滑点和手续费的敏感度极高,后者的收益质量更高。我在回测报告里会打印一段交易明细统计:总交易次数、平均持仓bar数、单日最大亏损、连续亏损次数。连续亏损次数超过5次,就要检查是不是市场状态变了,而不是模型坏了。回测跑完先看这批统计量,再去看净值曲线,这个顺序能避免被一条漂亮的曲线骗进去。

5. 期货策略上线前必须避开的六个坑:每条都是“现象→原因→解决”

5.1 标准化泄漏:验证集表现虚高,上线立刻失灵

现象:训练集上loss正常下降,验证集上准确率明显高于训练集,当时我还觉得是模型泛化能力强。结果放到样本外测试段,绩效直接腰斩。

原因:我在切分数据集之前先对全量数据做了z-score标准化,scaler对象用了未来数据计算均值和方差。标准化本身没有信息泄露,但它把未来统计量带进了训练过程,模型实际上“见过”验证集的数据分布。

解决:所有标准化、归一化操作都必须在时间切分之后做,只对训练段调用scaler.fit(),然后对验证段和测试段只调用transform()。更稳妥的做法是把标准化器放进sklearn的Pipeline里,和模型一起做时间序列切分的交叉验证,这样不会漏。

5.2 换月跳空被当成交易信号:策略在换月日集中亏损

现象:回测净值曲线整体向上,但每隔一两个月就出现一根突兀的大阴线,而且这些阴线的时间点高度集中在主力合约换月日附近。

原因:直接用主力连续合约做训练,没有处理换月跳空。模型把换月造成的价格断层学成了“突破信号”,在换月前后频繁开仓,实盘里换月跳空的方向和幅度根本无法预知,止损被打穿。

解决:在特征计算阶段把换月日标记出来,计算收益率和波动率特征时跳过跨越换月点的bar;或者在训练标签里直接删除换月前后各5根bar的样本。回测时同样要剔除这些样本,否则回测和训练的数据分布不一致,结果没有参考价值。

5.3 用收盘价成交:看似完美的净值曲线其实是未来函数

现象:策略在回测里胜率高达65%,但一上模拟盘就完全不是那么回事,模拟盘成交价比回测价差一截,利润被吃掉一大块。

原因:回测引擎里用第i根bar的收盘价生成信号,又用同一根bar的收盘价成交。这在逻辑上是做不到的——你看到收盘价的那一刻,这一根bar已经结束了,最早只能按下一根bar的开盘价成交。

解决:回测成交价统一改成信号产生bar的下一根bar开盘价,且开盘价要加滑点。如果开盘跳空比较大,还可以设一个“跳空超过阈值则不成交”的规则,更贴近实盘执行逻辑。这个改动会让回测收益明显下降,但下降后的数字才是你实盘能拿到的近似值。

5.4 置信度不足还强行开仓:手续费和滑点把微弱优势吃光

现象:模型胜率有53%,手续费扣完以后账户净值曲线几乎是水平的,偶尔还缓慢向下。

原因:53%的胜率来自大量“边缘样本”——模型对每根bar都输出略微偏向某一方向的概率,比如0.52对0.48。这些样本的期望收益扣掉手续费后是负的,拉低了整体绩效。

解决:用验证集做置信度阈值搜索,只保留模型把握度高的信号。比如要求做多时p_up>0.55且p_up-p_down>0.15,把交易频率从每天50次降到每天15次,胜率可能从53%升到58%,扣掉手续费后净值曲线才开始真正向上。信号宁缺毋滥,这是程序化交易系统最实在的一句经验。

5.5 深度强化学习奖励函数没约束回撤:策略赚得猛亏得更猛

现象:用深度强化学习算法训练出来的策略,回测前半段收益曲线陡峭上涨,后来一次大回撤把半年利润全吐回去。调了几轮奖励权重也没有本质改善。

原因:奖励函数只写成了“最大化累计收益”,模型发现加大仓位、提高交易频率能短期获得更多奖励,完全没有把回撤和单笔亏损写进约束。深度强化学习在这个任务里的收敛稳定性本来就比监督学习差,奖励设置不当很容易学到激进赌博式策略。

解决:我的建议是优先做监督学习三分类任务,用第3章的方案,稳定性和可解释性都好得多。如果一定要上深度强化学习算法,至少在奖励函数里加入回撤惩罚项(比如reward - 3 * max_drawdown_penalty),并且把单日最大亏损熔断逻辑放在执行层,不让模型自己决定“要不要扛回来”。

5.6 过度堆参数:深度学习里的param是“M”不是“MB”,堆模型不等于是深度学习

现象:训练集准确率一路飙升到99%,验证集准确率却滞留在61%左右,gap越来越大。我当时以为是训练轮数不够,又加了两个LSTM层,结果验证集反而掉到58%。

原因:模型参数量太大,训练集只有几十万根bar,模型开始背诵训练样本而不是学习可迁移的模式。另外我把“参数量”和“显存占用”混在一起理解,以为3M参数不算多,实际上它在分钟级数据规模下已经非常臃肿。

解决:先打印模型的参数量,控制在1M以下;再对比训练和验证的loss曲线,gap超过0.15就立刻缩小模型,不要靠正则硬压。深度学习在这里是特征提取工具,不是拿来堆容量的。模型够用就好,多出来的参数全部是过拟合的温床。

6. 上模拟盘前先做两件事:样本外滚动验证和信号冷却

模拟盘和回测最大的差异在于:回测是你看着完整历史算出来的结果,模拟盘是你不知道未来、每天只能看到当下数据、还要处理延迟和滑点的真实环境。上模拟盘之前,我会先做两件事。

第一件是样本外滚动验证。把三年数据切成6段,每次用前5段训练、最后1段验证,然后整体往前挪一段重复,最终得到6次独立的样本外验证结果。这比单一的一次性测试集更接近实盘的真实情况——模型不会只适应某一年的行情状态。如果6段结果有某一段明显差于其他段,我不会跳过这段直接上模拟盘,而是先确认差的那段是不是极端行情(比如2020年负油价那种),如果不是,模型可能并没有理解市场,只是在某一两年里运气好。

第二件是给信号加冷却和确认机制。模型输出的信号不要直接转成下单指令,先过一个过滤函数。

def filter_signals(signals: list, min_gap: int = 5, confirm: bool = True) -> list: """ 信号冷却与二次确认 signals: 模型原始输出,-1/0/1 min_gap: 两次同向或反向交易之间的最小间隔(根bar) confirm: 是否要求连续两根同向信号才执行 """ final_signals = [0] * len(signals) last_trade_idx = -len(signals) # 保证策略启动前不受冷却限制 for idx, sig in enumerate(signals): if sig == 0: continue # 冷却期:距上次操作太近则放弃本次信号 if idx - last_trade_idx < min_gap: continue # 二次确认:要求前一根bar信号也同向 if confirm and idx > 0 and signals[idx - 1] != sig: continue final_signals[idx] = sig last_trade_idx = idx return final_signals

我一般把min_gap设为5根1分钟bar(即5分钟冷却),confirm=True要求连续两根bar信号同向才出手。这两个参数的实际价值是过滤掉模型在前一根bar看多、后一根bar看空这种高频翻转信号,减少无效交易。成本是每笔交易少赚前面一两个bar的启动行情,但换来的交易频率下降和噪声过滤通常远大于这个成本。

这个函数还有一个隐藏作用:它强制你每笔交易都有一个可解释的触发条件。实盘出现亏损时,你能明确说清“这笔是冷却前开的”还是“确认信号后开的”,而不是把所有亏损都归咎于“模型不准”。我的习惯是,凡是模型输出和最终下单指令不一致的情况都记日志,每周统计一次过滤率。过滤率超过50%,就说明模型训练阶段置信度阈值设得太松了,返回第3章调margin参数。一个过滤率长期低于20%的系统,本质上是在跟模型的每个轻微波动较劲,这种系统通常活不过一个完整的多空周期。

希望这个从数据处理到信号过滤的完整链路,能帮你少走我踩过的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询