数据科学和机器学习(第 36 部分):与偏颇的金融市场打交道·进阶篇
⚖️

数据科学和机器学习(第 36 部分):与偏颇的金融市场打交道·进阶篇

(2/3)· 当 USDJPY 历史里 59.2% 是看涨烛条,你的机器学习模型正在偷偷忽视空头信号

实战向 第 2/3 篇
很多交易者直接把原始 K 线丢进 ML 模型训练,却没注意品种本身就有方向偏颇。模型为了冲高整体准确率,会本能地押注主流类,把次要类的预测几乎放弃。先看清数据里的不平衡,比急着调参更重要。

◍ 用毛料数据跑出的 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 做输入向量。

MQL5 / C++
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 翻转时是否确实只开一手、且到期被强制关闭。

MQL5 / C++
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() 是为了避免删除元素后索引错位。

MQL5 / C++
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);
}
逐行拆解:函数入口 period_seconds 即允许持仓的最大秒数;for 从最后一个持仓下标开始递减;SelectByIndex 选中第 i 个持仓对象;Magic 比对确保只处理本 EA 订单;TimeCurrent 减开仓时间得到存活秒数,超阈值则 PositionClose 按滑点平仓。 另一处实用的是周期转分钟:把 ENUM_TIMEFRAMES 映射成整数分钟,M1 到 M30 对应 1~30,H1 为 60,H4 为 240,D1 直接是 1440。写跨周期判定或日志统计时,这套映射比反复写条件判断清爽得多。外汇与贵金属杠杆高,任何自动平仓逻辑都可能在流动性稀薄时滑点放大,实盘前请在策略测试器用真实点差验证。

MQL5 / C++
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。

MQL5 / C++
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 上下,比生料数据更均衡。

MQL5 / C++
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
代码逐行拆解:第1行引入 RandomOverSampler;第2行打印重抽样前标签计数,得到 0类304、1类395;第3行设 random_state=42 实例化采样器;第4行 fit_resample 执行复制使两类各 395;第5行确认后分布;第6行用平衡数据重新 fit 模型;第7-8行在训练集预测并打印报告,显示两类 precision/recall 均在 0.82–0.88 区间。 把同样配置丢进 MT5 策略测试器,technique_name 填 randomoversampling,机器人开立空头 238 笔、多头 281 笔,对比训练集原始市场分布(空 225、多 293)更接近,说明模型决策可能更少受类偏置干扰。外汇与贵金属杠杆交易高风险,过抽样只是复制旧样本,新信息量为零,过度拟合的概率会随复制比例上升,实盘前务必走样本外验证。

MQL5 / C++
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
把类分布诊断交给小布
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到多空烛条比例与历史偏颇度,你只需决定用哪种重采样策略,重复统计交给小布。

常见问题

不同市场在不同周期有结构性的方向倾向,例如部分货币对长期偏跌、指数长期偏涨,日线收盘价高于开盘的占比自然偏离一半,这是样本本身的偏颇而非噪声。
整体准确率在不平衡数据下会误导人,应改用 F1、ROC-AUC 或混淆矩阵逐类看表现,否则可能满仓押主类却以为模型稳健。
可以,小布盯盘的 AIGC 品种页会给出近 N 根柱线的多空标记分布与偏颇比例,省去你手动跑脚本统计;发现失衡再考虑过抽样或欠抽样。
混合方法常能兼顾保留主类信息与补强次要类,但也可能放大噪声,实际效果要在 Walk-forward 验证里看泛化表现,不能默认更优。
样本内次要类命中率极低、样本外全面崩坏,且模型把多数特征权重压到主类相关变量上,就可能是记了主类噪声而非学了通用形态。