数据科学和机器学习(第 36 部分):与偏颇的金融市场打交道·进阶篇
(2/3)· 当 USDJPY 历史里 59.2% 是看涨烛条,你的机器学习模型正在偷偷忽视空头信号
◍ 用毛料数据跑出的 EA 偏多陷阱
把随机森林模型存成 ONNX 后,最直接的验证方式就是写个 EA 接进策略测试器,按训练样本直接下单。这里用的训练数据由 Collectdata.mq5 脚本采集,时间窗是 2023-01-01 到 2025-01-01,附件里能拿到。 EA 里模型前瞻值设为 1,意味着当前周期收线后必须再走一根栏线才平仓,否则前瞻约束就被破坏了。先拿没做重采样的原始数据模型试水,测试器输入 technique_name = no-sampling。 结果有点迷惑性:519 笔交易整体胜率 62.24%,看起来不差。但拆开看,102 笔空单胜率 70.59%,417 笔多单胜率 60.19%,多空比接近 1:4。同一段 USDJPY 日线里,两年间看涨烛条 293 根、看跌 225 根,市场本身偏多。 也就是说,这模型赚钱大概率只是因为顺了大趋势——它做多频率是做空的 4 倍,而那段时间 USDJPY 大多头,闭眼唱多都能活。外汇和贵金属杠杆高,这种样本偏差下的‘盈利’很容易在风格切换时反噬,不能当真。 下面这段是 EA 初始化和预测的核心代码,注意它从公共文件夹载 ONNX,并用前一根栏线的 OHLC 做输入向量。
class="macro">#include <Random Forest.mqh> CRandomForestClassifier random_forest; class=class="str">"cmt">//A class for loading the RFC in ONNX format class="macro">#include <Trade\Trade.mqh> class="macro">#include <Trade\PositionInfo.mqh> CTrade m_trade; CPositionInfo m_position; input class="type">class="kw">string symbol_ = "USDJPY"; input class="type">int magic_number= class="num">14042025; input class="type">int slippage = class="num">100; input ENUM_TIMEFRAMES timeframe_ = PERIOD_D1; input class="type">class="kw">string technique_name = "randomoversampling"; class="type">int lookahead = class="num">1; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert initialization function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int OnInit() { class=class="str">"cmt">//--- if (!random_forest.Init(StringFormat("%s.%s.%s.onnx", symbol_, EnumToString(timeframe_), technique_name), ONNX_COMMON_FOLDER)) class=class="str">"cmt">//Initializing the RFC in ONNX format from a commmon folder class="kw">return INIT_FAILED; class=class="str">"cmt">//--- Setting up the CTrade module m_trade.SetExpertMagicNumber(magic_number); m_trade.SetDeviationInPoints(slippage); m_trade.SetMarginMode(); m_trade.SetTypeFillingBySymbol(symbol_); 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">//--- } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert tick function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnTick() { class=class="str">"cmt">//--- vector x = { iOpen(symbol_, timeframe_, class="num">1), iHigh(symbol_, timeframe_, class="num">1), iLow(symbol_, timeframe_, class="num">1), iClose(symbol_, timeframe_, class="num">1) }; class="type">long signal = random_forest.predict_bin(x); class=class="str">"cmt">//Predicted class class="type">class="kw">double proba = random_forest.predict_proba(x).Max(); class=class="str">"cmt">//Maximum predicted probability class="type">MqlTick ticks; if (!SymbolInfoTick(symbol_, ticks)) {
信号触发后的下单与持仓闭环
这段逻辑承接信号判定之后:先取品种最小交易量 volume_,作为裸手数下限。signal==1 且当前无多无空时,用 ticks.ask 市价买入;signal==0 同理用 ticks.bid 市价卖出,两个分支都强制空仓才进场,避免重复加仓。 下单后紧跟 CloseTradeAfterTime((Timeframe2Minutes(timeframe_)*lookahead)*60),把训练用的周期分钟数乘前瞻系数再转秒,作为这笔单的自动寿命。外汇与贵金属杠杆高,这种定时强平能截断模型漂移带来的暴露,但是否真能避险要看回测分布。 PosExists 遍历 PositionsTotal()-1 到 0,用 SelectByIndex 按 symbol_、magic_number、持仓类型三重过滤,命中即返回 true。ClosePos 结构几乎一致,只是改调 PositionClose 按 ticket 平掉对应方向的仓。 把这两个函数直接塞进 EA 的辅助区,开 MT5 用策略测试器跑一根 EURUSD 的 M5,观察 signal 翻转时是否确实只开一手、且到期被强制关闭。
printf("Failed to obtain ticks information, Error = %d",GetLastError()); class="kw">return; } class="type">class="kw">double volume_ = SymbolInfoDouble(symbol_, SYMBOL_VOLUME_MIN); if (signal == class="num">1) { if (!PosExists(POSITION_TYPE_BUY) && !PosExists(POSITION_TYPE_SELL)) m_trade.Buy(volume_, symbol_, ticks.ask,class="num">0,class="num">0); } if (signal == class="num">0) { if (!PosExists(POSITION_TYPE_SELL) && !PosExists(POSITION_TYPE_BUY)) m_trade.Sell(volume_, symbol_, ticks.bid,class="num">0,class="num">0); } class=class="str">"cmt">//--- CloseTradeAfterTime((Timeframe2Minutes(timeframe_)*lookahead)*class="num">60); class=class="str">"cmt">//Close the trade after a certain lookahead and according the the trained timeframe } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool PosExists(class="type">ENUM_POSITION_TYPE type) { for (class="type">int i=PositionsTotal()-class="num">1; i>=class="num">0; i--) if (m_position.SelectByIndex(i)) if (m_position.Symbol()==symbol_ && m_position.Magic() == magic_number && m_position.PositionType()==type) class="kw">return (true); class="kw">return (false); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool ClosePos(class="type">ENUM_POSITION_TYPE type) { for (class="type">int i=PositionsTotal()-class="num">1; i>=class="num">0; i--) if (m_position.SelectByIndex(i)) if (m_position.Symbol() == symbol_ && m_position.Magic() == magic_number && m_position.PositionType()==type) { if (m_trade.PositionClose(m_position.Ticket())) class="kw">return true; } class="kw">return (false); } class=class="str">"cmt">//+------------------------------------------------------------------+
「持仓超时强制平仓与时间帧换算」
EA 里经常需要「单子活太久就砍掉」,逻辑并不复杂:从持仓总数倒序遍历,只挑自己魔法码的下单,比较当前服务器时间与开仓时间差是否越过阈值,越过就市价平。 下面这段函数接收以秒为单位的存活上限,遍历时若发现某持仓时间达标便调用 PositionClose 平仓,滑点参数沿用全局设定。注意倒序遍历 PositionsTotal() 是为了避免删除元素后索引错位。
class="type">void CloseTradeAfterTime(class="type">int period_seconds) { for (class="type">int i = PositionsTotal() - class="num">1; i >= class="num">0; i--) if (m_position.SelectByIndex(i)) if (m_position.Magic() == magic_number) if (TimeCurrent() - m_position.Time() >= period_seconds) m_trade.PositionClose(m_position.Ticket(), slippage); }
class="type">void CloseTradeAfterTime(class="type">int period_seconds) { for (class="type">int i = PositionsTotal() - class="num">1; i >= class="num">0; i--) if (m_position.SelectByIndex(i)) if (m_position.Magic() == magic_number) if (TimeCurrent() - m_position.Time() >= period_seconds) m_trade.PositionClose(m_position.Ticket(), slippage); } class="type">int Timeframe2Minutes(ENUM_TIMEFRAMES tf) { class="kw">switch(tf) { case PERIOD_M1: class="kw">return class="num">1; case PERIOD_M2: class="kw">return class="num">2; case PERIOD_M3: class="kw">return class="num">3; case PERIOD_M4: class="kw">return class="num">4; case PERIOD_M5: class="kw">return class="num">5; case PERIOD_M6: class="kw">return class="num">6; case PERIOD_M10: class="kw">return class="num">10; case PERIOD_M12: class="kw">return class="num">12; case PERIOD_M15: class="kw">return class="num">15; case PERIOD_M20: class="kw">return class="num">20; case PERIOD_M30: class="kw">return class="num">30; case PERIOD_H1: class="kw">return class="num">60; case PERIOD_H2: class="kw">return class="num">120; case PERIOD_H3: class="kw">return class="num">180; case PERIOD_H4: class="kw">return class="num">240; case PERIOD_H6: class="kw">return class="num">360; case PERIOD_H8: class="kw">return class="num">480; case PERIOD_H12: class="kw">return class="num">720; case PERIOD_D1: class="kw">return class="num">1440; class=class="str">"cmt">// class="num">1 day = class="num">1440 minutes
◍ 周月周期分钟数映射与类别分布核对
在把时间框架转成分钟数时,周线和月线容易算错。W1 直接返回 10080,即 7 天乘以每天 1440 分钟;MN1 返回 43200,按 30 天近似处理,不是严格日历月。 遇到未定义的时间框架,函数会打印 Unknown timeframe 并返回 0,调用方必须判空,否则后续指标计算会静默失真。 一段二分类样本的分布打印显示:类别 0 有 225 个,类别 1 有 293 个,合计 518 条。样本偏斜约 1.3:1,做训练前最好先确认你的标签阈值是否让多数类吞掉了信号。 开 MT5 把这段 switch 粘进 EA,改个非常规周期如 PERIOD_H2 试试,看 PrintFormat 是否如期报错返回 0。
case PERIOD_W1: class="kw">return class="num">10080; class=class="str">"cmt">// class="num">1 week = class="num">7 * class="num">1440 minutes case PERIOD_MN1: class="kw">return class="num">43200; class=class="str">"cmt">// Approx. class="num">1 month = class="num">30 * class="num">1440 minutes class="kw">default: PrintFormat("Unknown timeframe: %d", tf); class="kw">return class="num">0; } } print("classes in y: ",np.unique(y, return_counts=True)) classes in y: (array([class="num">0, class="num">1]), array([class="num">225, class="num">293]))
用随机过抽样抹平类分布偏斜
随机过抽样干的事很直接:从训练集里挑出少数类样本,复制粘贴直到两类数量持平。目的是不让分类模型在外汇或贵金属信号预测里,因为某类样本太少而偏向多数类。 下面这段 Python 借助 imbalanced-learn 的 RandomOverSampler 把 y_train 的 0/1 两类从 304 对 395 拉平到各 395。复现后喂给同一个随机森林,训练集分类报告里两类 F1 都到了 0.87 上下,比生料数据更均衡。
from imblearn.over_sampling class="kw">import RandomOverSampler print("b4 Target: ",np.unique(y_train, return_counts=True)) rus = RandomOverSampler(random_state=class="num">42) X_resampled, y_resampled = rus.fit_resample(X_train, y_train) print("After Target: ",np.unique(y_resampled, return_counts=True)) b4 Target: (array([class="num">0, class="num">1]), array([class="num">304, class="num">395])) After Target: (array([class="num">0, class="num">1]), array([class="num">395, class="num">395])) model.fit(X_resampled, y_resampled) y_train_pred = model.predict(X_train) print("Train Classification report\n",classification_report(y_train, y_train_pred)) Train Classification report precision recall f1-score support class="num">0 class="num">0.82 class="num">0.85 class="num">0.83 class="num">158 class="num">1 class="num">0.88 class="num">0.86 class="num">0.87 class="num">204 accuracy class="num">0.85 class="num">362 macro avg class="num">0.85 class="num">0.85 class="num">0.85 class="num">362 weighted avg class="num">0.85 class="num">0.85 class="num">0.85 class="num">362
from imblearn.over_sampling class="kw">import RandomOverSampler print("b4 Target: ",np.unique(y_train, return_counts=True)) rus = RandomOverSampler(random_state=class="num">42) X_resampled, y_resampled = rus.fit_resample(X_train, y_train) print("After Target: ",np.unique(y_resampled, return_counts=True)) b4 Target: (array([class="num">0, class="num">1]), array([class="num">304, class="num">395])) After Target: (array([class="num">0, class="num">1]), array([class="num">395, class="num">395])) model.fit(X_resampled, y_resampled) y_train_pred = model.predict(X_train) print("Train Classification report\n",classification_report(y_train, y_train_pred)) Train Classification report precision recall f1-score support class="num">0 class="num">0.82 class="num">0.85 class="num">0.83 class="num">158 class="num">1 class="num">0.88 class="num">0.86 class="num">0.87 class="num">204 accuracy class="num">0.85 class="num">362 macro avg class="num">0.85 class="num">0.85 class="num">0.85 class="num">362 weighted avg class="num">0.85 class="num">0.85 class="num">0.85 class="num">362