克服机器学习的局限性(第二部分):缺乏可重复性·进阶篇
◍ 用零策略基准挑出更好预测的经纪商
两家经纪商的欧元兑美元报价在大多数时段几乎独立波动,但预测未来10日收益率的难度并不对称。我们引入 TSS(零策略标准)作为底线:把所有输入列刻意填成 0,线性模型只能吐出目标均值,也就是「永远押市场平均收益」的笨办法误差。任何正经模型若连这都超不过,说明根本没有预测能力。 用 TimeSeriesSplit 做 5 折时序交叉验证,且 train_test_split 必须 shuffle=False,避免未来信息泄漏。代码里分别对经纪商 A、B 算 TSS 与真实 RSS 的比值,结果显示经纪商 B 的 RSS/TSS 明显更低——在该样本上超越基准更容易,意味着换一家经纪商,EURUSD 的十日均线收益效率可能变了。 确定聚焦经纪商 B 后,用 GradientBoostingRegressor 拟合其全部输入,并导成 ONNX 丢进 MT5 用。外汇与贵金属属高风险品种,跨经纪商数据差异会直接改写模型胜率,实盘前请在策略测试器里重跑这段代码核对自己数据源的 RSS/TSS。
from sklearn.model_selection class="kw">import train_test_split,TimeSeriesSplit,cross_val_score from sklearn.linear_model class="kw">import Ridge tscv = TimeSeriesSplit(n_splits=class="num">5,gap=HORIZON) def get_model(): class="kw">return(Ridge()) train , test = train_test_split(combined,shuffle=False,test_size=class="num">0.5) broker_a_tss = np.mean(np.abs(cross_val_score(get_model(),train.loc[:,[&class="macro">#x27;Null&class="macro">#x27;]],train.loc[:,&class="macro">#x27;A Target&class="macro">#x27;],scoring=&class="macro">#x27;neg_mean_squared_error&class="macro">#x27;,n_jobs=-class="num">1,cv=tscv))) broker_a_rss = np.mean(np.abs(cross_val_score(get_model(),train.loc[:,inputs[class="num">0:(len(inputs)class=class="str">"cmt">//class="num">2)]],train.loc[:,&class="macro">#x27;A Target&class="macro">#x27;],scoring=&class="macro">#x27;neg_mean_squared_error&class="macro">#x27;,n_jobs=-class="num">1,cv=tscv))) broker_b_tss = np.mean(np.abs(cross_val_score(get_model(),train.loc[:,[&class="macro">#x27;Null&class="macro">#x27;]],train.loc[:,&class="macro">#x27;B Target&class="macro">#x27;],scoring=&class="macro">#x27;neg_mean_squared_error&class="macro">#x27;,n_jobs=-class="num">1,cv=tscv))) broker_b_rss = np.mean(np.abs(cross_val_score(get_model(),train.loc[:,inputs[(len(inputs)class=class="str">"cmt">//class="num">2):]],train.loc[:,&class="macro">#x27;B Target&class="macro">#x27;],scoring=&class="macro">#x27;neg_mean_squared_error&class="macro">#x27;,n_jobs=-class="num">1,cv=tscv))) res = [(broker_a_rss/broker_a_tss),(broker_b_rss/broker_b_tss)] eff = str(((res[class="num">0] - res[class="num">1])/res[class="num">1]) * class="num">100) t = &class="macro">#x27;The EURUSD Appears &class="macro">#x27; + eff[class="num">0:class="num">4] + &class="macro">#x27;% Easier To Forecast With Broker B&class="macro">#x27; sns.barplot(res,class="type">color=&class="macro">#x27;black&class="macro">#x27;) plt.axhline(np.min(res),class="type">color=&class="macro">#x27;red&class="macro">#x27;,linestyle=&class="macro">#x27;:&class="macro">#x27;) plt.ylabel(&class="macro">#x27;class="num">5-Fold Cross Valiated Ratio of RSS/TSS &class="macro">#x27;) plt.title(t) plt.xticks([class="num">0,class="num">1],[&class="macro">#x27;Broker A&class="macro">#x27;,&class="macro">#x27;Broker B&class="macro">#x27;]) b_inputs = inputs[len(inputs)class=class="str">"cmt">//class="num">2:] from sklearn.ensemble class="kw">import GradientBoostingRegressor model = GradientBoostingRegressor() model.fit(train.loc[:,b_inputs[:-class="num">2]],train[&class="macro">#x27;B Target&class="macro">#x27;]) class="kw">import skl2onnx,onnx initial_types = [(&class="macro">#x27;float_input&class="macro">#x27;,skl2onnx.common.data_types.FloatTensorType([class="num">1,class="num">4]))] onnx_proto = skl2onnx.convert_sklearn(model,initial_types=initial_types,target_opset=class="num">12) onnx.save(onnx_proto,"EURUSD GBR D1.onnx")
同一套模型换经纪商就失灵
把 ONNX 模型打进 MT5 程序的第一步,是在编译期用 #resource 把模型文件一起打包,再定义全局系统常量。原文示例里写死了 10 日收益周期(RETURN_PERIOD 10)、D1 周期和 4 维输入 1 维输出,这些常量直接决定了策略回看窗口和推理形状。
初始化时加载 ONNX 句柄、重置全局变量;退出时务必释放模型资源,否则内存泄漏会拖垮终端。价格更新后每天只记一次收盘价,无持仓就跑模型预测开仓,有持仓则在 10 日持有期内尽量用跟踪止损。
回测用了样本外日期,且对两家经纪商 A、B 保持完全一致的设置。经纪商 B 是开发端用的,经纪商 A 模拟客户部署端。图14 里 B 端资金曲线明显正向、样本外拟合不错;但图15 里同样参数丢到 A 端,余额上升完全没复现。
别把经纪商差异当噪声。EURUSD 在两家点差、报价精度、最小止损距离上的细微不同,就足以让没重训练的 GBR 模型从赚钱变白干。外汇和贵金属本身高杠杆高风险,跨经纪商直接搬策略大概率翻车,开发者没法为全球每家经纪商单独定制模型。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| EURUSD.mq5 | class=class="str">"cmt">//| Gamuchirai Ndawana | class=class="str">"cmt">//| [MQL5官方文档] | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#class="kw">property copyright "Gamuchirai Ndawana" class="macro">#class="kw">property link "[MQL5官方文档] class="macro">#class="kw">property version "class="num">1.00" class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| System Constants Definitions | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#include <Trade\Trade.mqh> CTrade Trade; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| System Constants Definitions | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#define ONNX_INPUT_SHAPE class="num">4 class="macro">#define ONNX_OUTPUT_SHAPE class="num">1 class="macro">#define SYSTEM_TIME_FRAME PERIOD_D1 class="macro">#define RETURN_PERIOD class="num">10 class="macro">#define TRADING_VOLUME SymbolInfoDouble(Symbol(),SYMBOL_VOLUME_MIN) class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| System Resources | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#resource "\\Files\\Broker Manipulation\\EURUSD GBR D1.onnx" as const class="type">uchar onnx_proto[]; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Global Variables | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">long model; class="type">int position_timer; class="type">class="kw">double bid,ask; class="type">class="kw">double o,h,l,c; class="type">bool bullish;
「EA 初始化里把 ONNX 模型接进行情流」
把训练好的 EURUSD 自编码器塞进 MT5,核心动作全在 OnInit 里完成:用 OnnxCreateFromBuffer 从内存缓冲区加载模型,再把输入维度锁成 {1, ONNX_INPUT_SHAPE}、输出维度锁成 {1, ONNX_OUTPUT_SHAPE}。任何一步拿不到合法 handle 或 shape 设置失败,直接 Comment 报错并返回 INIT_FAILED,EA 起不来。 初始化末尾顺手定了两个数:position_timer 归零,sl_width 写死 30——后者就是后续止损宽度的基准点数,做贵金属或外汇跨品种时按波动率改这个值就行。 OnDeinit 只干一件干净事:OnnxRelease(model) 释放模型句柄,避免反复加载拖内存。OnTick 则靠静态 time_stamp 做新 K 线过滤,只在 SYSTEM_TIME_FRAME 的 0 号 bar 时间变化时重抓 ask/bid 与 1、2 号 bar 的 OHLC,并算 bullish = (o<c)&&(c>前一根收盘),把价格行为信号喂给后面的模型推理。 外汇与贵金属杠杆高、滑点跳空频繁,sl_width=30 在欧盘平稳段可能够用,美盘数据行情里被扫概率明显上升,实盘前建议在策略测试器里按品种重标。
class="type">class="kw">double sl_width; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert initialization function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int OnInit() { class=class="str">"cmt">//--- model = OnnxCreateFromBuffer(onnx_proto,ONNX_DATA_TYPE_FLOAT); class="type">ulong input_shape[] = {class="num">1,ONNX_INPUT_SHAPE}; class="type">ulong output_shape[] = {class="num">1,ONNX_OUTPUT_SHAPE}; if(model == INVALID_HANDLE) { Comment("Failed To Load EURUSD Auto-Encoder-Decoder: ",GetLastError()); class="kw">return(INIT_FAILED); } if(!OnnxSetInputShape(model,class="num">0,input_shape)) { Comment("Failed To Set EURUSD Auto-Encoder-Decoder Input Shape: ",GetLastError()); class="kw">return(INIT_FAILED); } else if(!OnnxSetOutputShape(model,class="num">0,output_shape)) { Comment("Failed To Set EURUSD Auto-Encoder-Decoder Output Shape: ",GetLastError()); class="kw">return(INIT_FAILED); } position_timer = class="num">0; sl_width = class="num">30; class=class="str">"cmt">//--- class="kw">return(INIT_SUCCEEDED); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert deinitialization function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnDeinit(const class="type">int reason) { class=class="str">"cmt">//--- OnnxRelease(model); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert tick function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnTick() { class=class="str">"cmt">//--- class="kw">static class="type">class="kw">datetime time_stamp; class="type">class="kw">datetime current_time = iTime(Symbol(),SYSTEM_TIME_FRAME,class="num">0); if(time_stamp != current_time) { time_stamp = current_time; ask = SymbolInfoDouble(Symbol(),SYMBOL_ASK); bid = SymbolInfoDouble(Symbol(),SYMBOL_BID); o = iOpen(Symbol(),SYSTEM_TIME_FRAME,class="num">1); h = iHigh(Symbol(),SYSTEM_TIME_FRAME,class="num">1); l = iLow(Symbol(),SYSTEM_TIME_FRAME,class="num">1); c = iClose(Symbol(),SYSTEM_TIME_FRAME,class="num">1); bullish = (o < c) && (c > iClose(Symbol(),SYSTEM_TIME_FRAME,class="num">2));
◍ 持仓状态下的止损与到期逻辑
这段控制流解决一个很实际的问题:没单时清零计时并找信号,有单时动态拉止损、到点强制平仓。外汇与贵金属波动剧烈,硬编码固定止损容易被扫,用波段幅度乘系数去追止损更贴合价格行为。 无持仓分支很简单——PositionsTotal() 为 0 就把 position_timer 归零并调用 find_setup() 扫描入场条件。有持仓后先 PositionSelect(Symbol()) 锁定当前品种订单,读出 POSITION_TYPE 与 POSITION_SL。 多头单的 new_sl 设在 bid - (h-l)*sl_width,仅当算出的止损比原止损更优(更大)才用 Trade.PositionModify 上移;空单镜像处理,new_sl = ask + (h-l)*sl_width,只在更小(更优)时下移。sl_width 是你可以直接调的旋钮,例如 0.5 代表取半个波段高宽作缓冲。 position_timer 每 tick 加 1,达到 RETURN_PERIOD 就 Trade.PositionClose 平掉。RETURN_PERIOD 若设 200,意味着持仓满 200 个计时单位后无论盈亏倾向离场,避免模型单漂成隔夜风险仓。 find_setup 里把当根 K 线的开高低塞进 vectorf model_inputs,维度按 ONNX_INPUT_SHAPE 定,说明信号来自轻量 ONNX 模型推理而非裸指标交叉。
if(PositionsTotal() == class="num">0) { position_timer = class="num">0; find_setup(); } else if(PositionsTotal() > class="num">0) { if(PositionSelect(Symbol())) { class="type">long position_type = PositionGetInteger(POSITION_TYPE); class="type">class="kw">double current_sl = PositionGetDouble(POSITION_SL); class="type">class="kw">double new_sl; class=class="str">"cmt">//--- Buy Trades if(position_type == POSITION_TYPE_BUY) { new_sl = bid - ((h-l)*sl_width); if(new_sl > current_sl) Trade.PositionModify(Symbol(),new_sl,class="num">0); } class=class="str">"cmt">//--- Sell Trades else if(position_type == POSITION_TYPE_SELL) { new_sl = ask + ((h-l)*sl_width); if(new_sl < current_sl) Trade.PositionModify(Symbol(),new_sl,class="num">0); } } if(position_timer < RETURN_PERIOD) position_timer+=class="num">1; else Trade.PositionClose(Symbol()); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Find A Trading Setup | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void find_setup(class="type">void) { vectorf model_inputs(ONNX_INPUT_SHAPE); model_inputs[class="num">0] = (class="type">float) iOpen(Symbol(),SYSTEM_TIME_FRAME,class="num">0); model_inputs[class="num">1] = (class="type">float) iHigh(Symbol(),SYSTEM_TIME_FRAME,class="num">0); model_inputs[class="num">2] = (class="type">float) iLow(Symbol(),SYSTEM_TIME_FRAME,class="num">0);
把模型输出接进下单逻辑
这段代码片段承接前面的 ONNX 推理,把最新收盘价塞进模型输入的第 4 个槽位,然后跑一次前向预测。若 OnnxRun 返回失败,直接在图表上打印错误码并退出本次 tick,不碰仓位。 预测成功后会把 model_output[0] 显示出来,同时用 CopyRates 拉取当前周期最近两根 K 线的开盘与收盘,分别求均值。当开盘均值低于收盘均值(近两根偏向收阳),且模型输出大于 0 并处于多头标记状态时,才用 Trade.Buy 以 ask 入场,止损挂在 bid 减去波动带宽 sl_width 的位置。 反向情形里,开盘均值高于收盘均值且模型输出小于 0、空头标记成立,则 Trade.Sell 以 bid 入场,止损置于 ask 加波动带宽处。外汇与贵金属杠杆高,这类基于两K线均值的触发条件只是概率倾向,实盘前务必在 MT5 策略测试器用历史数据验证滑点与止损宽度表现。 末尾的 #undef 把 ONNX_INPUT_SHAPE、SYSTEM_TIME_FRAME 等宏全部撤掉,避免与其他 EA 模块的常量命名冲突。复制这段代码时注意 h、l、bullish、ask、bid 必须在前文已正确赋值,否则编译期就会报错。
model_inputs[class="num">3] = (class="type">float) iClose(Symbol(),SYSTEM_TIME_FRAME,class="num">0); vectorf model_output(ONNX_OUTPUT_SHAPE); if(!OnnxRun(model,ONNX_DATA_TYPE_FLOAT,model_inputs,model_output)) { Comment("Failed To Get A Prediction From Our Model: ",GetLastError()); class="kw">return; } else { Comment("Prediction: ",model_output[class="num">0]); vector open,close; open.CopyRates(Symbol(),SYSTEM_TIME_FRAME,COPY_RATES_OPEN,class="num">1,class="num">2); close.CopyRates(Symbol(),SYSTEM_TIME_FRAME,COPY_RATES_CLOSE,class="num">1,class="num">2); if(open.Mean() < close.Mean()) { if((model_output[class="num">0] > class="num">0) && (bullish)) Trade.Buy(TRADING_VOLUME,Symbol(),ask,(bid - ((h-l) * sl_width)),class="num">0); } else if(open.Mean() > close.Mean()) { if((model_output[class="num">0] < class="num">0) && (!bullish)) Trade.Sell(TRADING_VOLUME,Symbol(),bid,(ask + ((h-l) * sl_width)),class="num">0); } } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Undefine System Constants | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#undef ONNX_INPUT_SHAPE class="macro">#undef ONNX_OUTPUT_SHAPE class="macro">#undef SYSTEM_TIME_FRAME class="macro">#undef TRADING_VOLUME class="macro">#undef RETURN_PERIOD class=class="str">"cmt">//+------------------------------------------------------------------+
「挑经纪商比挑模型更前置」
不同经纪商的报价分散性是个硬约束:同一套基于 ONNX 的 EA 在 A 券商能跑出的套利空间,换到 B 券商可能价格序列根本对不上,回测盈利直接变实盘亏损。我们附带的 EURUSD.mq5 就是用来在两家券商各跑一遍同模型、比盈利差的,6.94 KB 的源码可以直接丢进 MT5 回溯。 如果你常在社区发需求,别写「求购欧美交易程序」这种空话,直接指定券商并让开发者开同券商模拟账户,需求越细越不容易踩坑。买现成 EA 也优先选标明专属券商的,泛用版往往隐藏了点差和滑点错位。 信号订阅者把券商对齐这一步做了,供应商报告收益和你账户实际收益才可能对得上。外汇和贵金属本身高波动高杠杆,券商间数据不一致会再叠一层执行风险,验证前别轻易上实盘。 开发者这边要认一个账:跨券商共享单模型是把客户推到未知滑点里,交付前用 Requesting Broker Data.ipynb 拉两家日线、再用 Analyzing Broker Data.ipynb 做一致性检测,这两份 notebook 一个 2.69 KB 一个 595.1 KB,跑通再谈通用。