配对交易:基于Z值差异的自动优化算法交易·进阶篇
📊

配对交易:基于Z值差异的自动优化算法交易·进阶篇

(2/3)·从平稳性数学到Z分数阈值,拆解EA如何自动捕捉相关货币对的均值回归窗口

含代码示例偏理论 第 2/3 篇

不少交易者把配对交易当成简单同涨同跌对冲,忽略价格关系平稳性检验就直接开仓,结果在结构性断档期两头止损。另一些人死守固定阈值,市场微结构一变就频繁假信号。本篇接着上篇概念,把统计基底和Z值引擎拆开看。

「配对开仓与相关性滚动维护的代码骨架」

配对策略的核心动作是同时做空一只被高估的品种、做多另一只被低估的品种,或反向操作。下面这段逻辑在判定价差偏离后调用 OpenPairPosition,以 riskLot 手数双向开仓,并把 isPositionOpen 置真,方便后续只维护一对仓位。 相关性历史不是一次性算完,而是每次 tick 平移数组再塞入新值。UpdateCorrelationHistory 把 correlationHistory 从尾到头整体后移一位,下标 0 写入 CalculateCurrentCorrelation 的实时结果,形成长度为 CorrelationPeriod 的滑动窗口。 GetMinimumCorrelation 遍历整个窗口找最小值,初始锚定 1.0。若窗口内出现过强负相关,返回结果会明显低于 1.0,这可作为过滤“相关性破裂”的阈值参考。 Optimize 给出了实打实的参数网格:zScore 周期从 50 到 200、步长 25;入场阈值 1.5~3.0、步长 0.25;出场阈值 0.0~1.0、步长 0.25。三层嵌套循环共 7×7×5=245 组组合,直接丢进 TestParameters 跑回测,外汇与贵金属品种在此类优化中仍属高风险,过拟合概率不低。

MQL5 / C++
class=class="str">"cmt">// Sell Symbol1 and buy Symbol2
if(OpenPairPosition(POSITION_TYPE_SELL, Symbol1,
                    POSITION_TYPE_BUY, Symbol2, riskLot))
{
   Log("Paired position opened: SELL " + Symbol1 + ", BUY " + Symbol2);
   isPositionOpen = true;
}
}
else class=class="str">"cmt">// Symbol1 is undervalued, Symbol2 is overvalued
{
   class=class="str">"cmt">// Buy Symbol1 and sell Symbol2
   if(OpenPairPosition(POSITION_TYPE_BUY, Symbol1,
                       POSITION_TYPE_SELL, Symbol2, riskLot))
   {
      Log("Paired position opened: BUY " + Symbol1 + ", SELL " + Symbol2);
      isPositionOpen = true;
   }
}
}
class="type">void UpdateCorrelationHistory()
{
   class=class="str">"cmt">// Shift the correlation history
   for(class="type">int i = CorrelationPeriod-class="num">1; i > class="num">0; i--)
   {
      correlationHistory[i] = correlationHistory[i-class="num">1];
   }
   
   class=class="str">"cmt">// Add a new correlation
   correlationHistory[class="num">0] = CalculateCurrentCorrelation();
}
class="type">class="kw">double GetMinimumCorrelation()
{
   class="type">class="kw">double minCorr = class="num">1.0;
   for(class="type">int i = class="num">0; i < CorrelationPeriod; i++)
   {
      if(correlationHistory[i] < minCorr)
         minCorr = correlationHistory[i];
   }
   class="kw">return minCorr;
}
class="type">void Optimize()
{
   Print("Starting optimization...");
   
   optimizationResults.Clear();
   
   class=class="str">"cmt">// Optimization ranges
   class="type">int zScorePeriodMin = class="num">50, zScorePeriodMax = class="num">200, zScorePeriodStep = class="num">25;
   class="type">class="kw">double entryThresholdMin = class="num">1.5, entryThresholdMax = class="num">3.0, entryThresholdStep = class="num">0.25;
   class="type">class="kw">double exitThresholdMin = class="num">0.0, exitThresholdMax = class="num">1.0, exitThresholdStep = class="num">0.25;
   
   class=class="str">"cmt">// Iterate over all combinations of parameters
   for(class="type">int period = zScorePeriodMin; period <= zScorePeriodMax; period += zScorePeriodStep)
   {
      for(class="type">class="kw">double entry = entryThresholdMin; entry <= entryThresholdMax; entry += entryThresholdStep)
      {
         for(class="type">class="kw">double exit = exitThresholdMin; exit <= exitThresholdMax; exit += exitThresholdStep)
         {
            class=class="str">"cmt">// Test parameters
            class="type">class="kw">double profit = TestParameters(period, entry, exit);

◍ 把遍历出的参数塞回EA并自动重算

优化循环跑完后,代码先把每一组候选参数封装成 OptimizationResult 对象,再丢进 optimizationResults 容器里。这一步只是暂存,真正的筛选在后面:用一趟线性扫描比 profit 字段,把最大值对应的 bestResult 挑出来。 选中最佳组后,EA 会把 zScorePeriod、entryThreshold、exitThreshold 直接覆写进当前运行变量,并调用 ArrayResize 把 prices1、prices2、ratio、zscore 四个数组按新周期重设长度。注意若新周期比旧的小,历史缓冲会被截断,重算前最好确认行情上下文没断层。 自动优化靠 tickCount 计数器触发:当 AutoOptimize 开启且累计 tick 数跨过 OptimizationPeriod,就调一次 Optimize() 并清零计数。外汇与贵金属市场跳空频繁,这种按 tick 而非按 bar 的触发,可能在重大数据发布时连续重算,实盘前建议在 MT5 策略测试器里把 OptimizationPeriod 调大观察参数漂移幅度。 TestParameters 函数则是离线的回测入口:它用 CopyClose 拉取 Symbol1 / Symbol2 的收盘价序列,若样本数不足 MinDataPoints 直接返回 -DBL_MAX 表示无效。你可以复制下面这段,把 MinDataPoints 设成 500 在 EURUSD/XAUUSD 上跑,看不同 period 下的 profit 分布。

MQL5 / C++
OptimizationResult* result = new OptimizationResult();
result.zScorePeriod = period;
result.entryThreshold = entry;
result.exitThreshold = exit;
result.profit = profit;

optimizationResults.Add(result);
}
}
}

class=class="str">"cmt">// Find the best result and apply new parameters
OptimizationResult* bestResult = NULL;
for(class="type">int i = class="num">0; i < optimizationResults.Total(); i++)
{
	OptimizationResult* currentResult = optimizationResults.At(i);
	if(bestResult == NULL || currentResult.profit > bestResult.profit)
	{
		bestResult = currentResult;
	}
}

if(bestResult != NULL)
{
	class=class="str">"cmt">// Update the EA internal parameters
	CurrentZScorePeriod = bestResult.zScorePeriod;
	CurrentEntryThreshold = bestResult.entryThreshold;
	CurrentExitThreshold = bestResult.exitThreshold;

	class=class="str">"cmt">// Update arrays
	ArrayResize(prices1, CurrentZScorePeriod);
	ArrayResize(prices2, CurrentZScorePeriod);
	ArrayResize(ratio, CurrentZScorePeriod);
	ArrayResize(zscore, CurrentZScorePeriod);

	Print("Optimization complete. New parameters: ZScorePeriod = ", CurrentZScorePeriod,
		", EntryThreshold = ", CurrentEntryThreshold, ", ExitThreshold = ", CurrentExitThreshold);
}
}
class=class="str">"cmt">// Auto optimization
if(AutoOptimize && ++tickCount >= OptimizationPeriod)
{
	Optimize();
	tickCount = class="num">0;
}
class="type">class="kw">double TestParameters(class="type">int period, class="type">class="kw">double entry, class="type">class="kw">double exit)
{
	class=class="str">"cmt">// Initialize test arrays
	class="type">class="kw">double test_prices1[], test_prices2[], test_ratio[], test_zscore[];
	ArrayResize(test_prices1, period);
	ArrayResize(test_prices2, period);
	ArrayResize(test_ratio, period);
	ArrayResize(test_zscore, period);

	class="type">class="kw">double close1[], close2[];
	ArraySetAsSeries(close1, true);
	ArraySetAsSeries(close2, true);

	class="type">int copied1 = CopyClose(Symbol1, PERIOD_CURRENT, class="num">0, MinDataPoints, close1);
	class="type">int copied2 = CopyClose(Symbol2, PERIOD_CURRENT, class="num">0, MinDataPoints, close2);

	if(copied1 < MinDataPoints || copied2 < MinDataPoints)
	{
		Print("Not enough data for testing");
		class="kw">return -DBL_MAX;
	}

回测里怎么用 Z 分数模拟双向平仓

这段逆序历史回测把两个品种的收盘价按 MinDataPoints 倒序填进 test_prices1/2,从 i=period 跑到 MinDataPoints-1,逐根更新 Z 分数并模拟交易决策。注意利润计算乘了 10000,意味着报价按小数点后四位(如 EURUSD 的 pip 尺度)折算,不是裸价差。 持仓状态下,代码用 currentProfit 累加两个标的的浮动盈亏:买权用(现价-开仓价)*10000,卖权反过来。当 currentProfit >= ProfitTarget 时,profit 累加并置 inPosition=false,相当于触发了一次双头平仓。外汇与贵金属杠杆高,这种回测盈利不代表实盘概率,滑点和点差会吃掉薄利。 新仓只在 !inPosition 且 i>CorrelationPeriod 后才允许检查,避免相关性窗口没填满就瞎进场。下面把核心片段拆开看。 别让回测利润骗了眼睛 ProfitTarget 是硬阈值,没考虑持仓时间成本;MT5 里跑一遍把 ProfitTarget 从 20 调到 50,看胜率怎么掉,比盯着总 profit 有用。

MQL5 / C++
class="type">class="kw">double profit = class="num">0;
class="type">bool inPosition = class="kw">false;
class="type">class="kw">double entryPrice1 = class="num">0, entryPrice2 = class="num">0;
class="type">ENUM_POSITION_TYPE posType1 = POSITION_TYPE_BUY, posType2 = POSITION_TYPE_BUY;

class=class="str">"cmt">// Create a correlation history for testing
class="type">class="kw">double testCorrelations[];
ArrayResize(testCorrelations, CorrelationPeriod);

class=class="str">"cmt">// Fill in the initial data
for(class="type">int i = class="num">0; i < period; i++)
{
   test_prices1[i] = close1[MinDataPoints - class="num">1 - i];
   test_prices2[i] = close2[MinDataPoints - class="num">1 - i];
}

class=class="str">"cmt">// Inverse simulation on historical data
for(class="type">int i = period; i < MinDataPoints; i++)
{
   class=class="str">"cmt">// Update data, calculate Z-score and simulate trading decisions 
   class=class="str">"cmt">// ...
   
   class="type">class="kw">double currentZScore = test_zscore[class="num">0];
   
   class=class="str">"cmt">// Simulate closing positions
   if(inPosition)
   {
      class="type">class="kw">double currentProfit = class="num">0;
      
      if(posType1 == POSITION_TYPE_BUY)
         currentProfit += (close1[MinDataPoints - class="num">1 - i] - entryPrice1) * class="num">10000;
      else
         currentProfit += (entryPrice1 - close1[MinDataPoints - class="num">1 - i]) * class="num">10000;
         
      if(posType2 == POSITION_TYPE_BUY)
         currentProfit += (close2[MinDataPoints - class="num">1 - i] - entryPrice2) * class="num">10000;
      else
         currentProfit += (entryPrice2 - close2[MinDataPoints - class="num">1 - i]) * class="num">10000;
         
      if(currentProfit >= ProfitTarget)
      {
         profit += currentProfit;
         inPosition = class="kw">false;
      }
   }
   
   class=class="str">"cmt">// Simulate opening new positions
   if(!inPosition && i > CorrelationPeriod)
   {
      class=class="str">"cmt">// Check entry conditions based on Z-score and correlation
      class=class="str">"cmt">// ...
   }
}

class="kw">return profit; class=class="str">"cmt">// Return the resulting profit for the given set of parameters
}
class=class="str">"cmt">// Logic for averaging positions when correlation drops
if(isPositionOpen && EnableAveraging)
{
   class="type">class="kw">double currentCorrelation = correlationHistory[class="num">0];

「相关性跌破阈值时的反手加码逻辑」

当持仓后监测到两标的相关系数从开仓初值明显回落,且落差超过 CorrelationDropThreshold,系统倾向触发一次反向加码。原逻辑里,首检时把当前相关值存为 initialCorrelation,averagingCount 从 0 变 1;之后每次比对都用 initialCorrelation 减 currentCorrelation,掉得够多才动手。 加码手数不是平推,而是取上笔 lastLotSize 乘 AveragingLotMultiplier。例如初仓 0.1 手、乘子设 1.5,反手单就是 0.15 手,风险敞口随层级非线性放大,外汇与贵金属杠杆下回撤可能加速。 下单前先扫一遍 posTickets 数组,用 PositionSelectByTicket 把 Symbol1、Symbol2 的持仓方向分别读进 posType1、posType2。两个都找到才继续,否则不丢单。 确认双边持仓后,按原方向取反:原买则反手卖,原卖则反手买,Pair 两个符号同步反向。OpenPairPosition 一口气把两个反向单按 averagingLot 发出去,完成一次相关性背离的对冲式加码。

MQL5 / C++
   class=class="str">"cmt">// Check for a drop in correlation from the moment a position is opened
   if(averagingCount == class="num">0)
   {
      class=class="str">"cmt">// If this is the first check after opening a position
      initialCorrelation = currentCorrelation;
      averagingCount++;
   }
   else if(initialCorrelation - currentCorrelation > CorrelationDropThreshold)
   {
      class=class="str">"cmt">// If the correlation has fallen below the threshold, add an averaging counter trade 
      class="type">class="kw">double averagingLot = lastLotSize * AveragingLotMultiplier;
      
      class=class="str">"cmt">// Check the type of our current positions
      class="type">ENUM_POSITION_TYPE posType1 = POSITION_TYPE_BUY;
      class="type">class="kw">string posSymbol = "";
      class="type">bool foundPos1 = class="kw">false, foundPos2 = class="kw">false;
      class="type">ENUM_POSITION_TYPE posType2 = POSITION_TYPE_BUY;
      
      class=class="str">"cmt">// Check our open positions
      for(class="type">int i = class="num">0; i < ArraySize(posTickets); i++)
      {
         if(PositionSelectByTicket(posTickets[i]))
         {
            class="type">class="kw">string symbol = PositionGetString(POSITION_SYMBOL);
            class="type">ENUM_POSITION_TYPE type = (class="type">ENUM_POSITION_TYPE)PositionGetInteger(POSITION_TYPE);
            
            if(symbol == Symbol1)
            {
               posType1 = type;
               foundPos1 = true;
            }
            else if(symbol == Symbol2)
            {
               posType2 = type;
               foundPos2 = true;
            }
            
            if(foundPos1 && foundPos2) break;
         }
      }
      
      if(foundPos1 && foundPos2)
      {
         class=class="str">"cmt">// Open counter trades with an increased lot
         class="type">ENUM_POSITION_TYPE reverseType1 = (posType1 == POSITION_TYPE_BUY) ? POSITION_TYPE_SELL : POSITION_TYPE_BUY;
         class="type">ENUM_POSITION_TYPE reverseType2 = (posType2 == POSITION_TYPE_BUY) ? POSITION_TYPE_SELL : POSITION_TYPE_BUY;
         
         if(OpenPairPosition(reverseType1, Symbol1, reverseType2, Symbol2, averagingLot))

◍ 加仓反手与仓位计算的收口逻辑

当货币对相关性跌破阈值,EA 会按 AveragingLotMultiplier 放大上一份仓位 lastLotSize,开出反向对冲单。reverseType1 / reverseType2 直接对原持仓方向取反,BUY 变 SELL、SELL 变 BUY,并在日志打印双标的动作,便于回放时核对。 若开启 EnableProtectiveStops,多单用 SYMBOL_ASK 减 ProtectiveStopPips*point1 算 sl1,空单用 SYMBOL_BID 加同样距离;第二标的和止盈段用省略号留白,实盘要把注释补完,否则保护单不会挂。 CalculatePositionSize 用 AccountInfoDouble(ACCOUNT_BALANCE) 取余额,RiskPercent 算 riskAmount,再以 tickValue1*(point1/tickSize1) 估每点风险。样例里写死 virtualStopLoss=100 点只是近似,黄金和直盘点值差几倍,照搬可能严重低估敞口。 外汇与贵金属杠杆高,相关性与波动率随时跳变,这类网格反手策略在单边行情中亏损可能快速放大,开 MT5 前先把 SYMBOL_VOLUME_MIN/STEP/MAX 打出来比对,再决定 multiplier 上限。

MQL5 / C++
{
            Log("Averaging counter position opened: " +
                 (reverseType1 == POSITION_TYPE_BUY ? "BUY " : "SELL ") + Symbol1 + ", " +
                 (reverseType2 == POSITION_TYPE_BUY ? "BUY " : "SELL ") + Symbol2);
            
            class=class="str">"cmt">// Update the initial correlation for the next check
            initialCorrelation = currentCorrelation;
            averagingCount++;
         }
      }
   }
}
class=class="str">"cmt">// If the correlation has fallen below the threshold, we add an averaging counter trade
class="type">class="kw">double averagingLot = lastLotSize * AveragingLotMultiplier;
class=class="str">"cmt">// Open counter trades with an increased lot
class="type">ENUM_POSITION_TYPE reverseType1 = (posType1 == POSITION_TYPE_BUY) ? POSITION_TYPE_SELL : POSITION_TYPE_BUY;
class="type">ENUM_POSITION_TYPE reverseType2 = (posType2 == POSITION_TYPE_BUY) ? POSITION_TYPE_SELL : POSITION_TYPE_BUY;
class=class="str">"cmt">// Calculate protective stop losses and take profits, if enabled
class="type">class="kw">double sl1 = class="num">0, sl2 = class="num">0, tp1 = class="num">0, tp2 = class="num">0;
class="type">class="kw">double point1 = SymbolInfoDouble(symbol1, SYMBOL_POINT);
class="type">class="kw">double point2 = SymbolInfoDouble(symbol2, SYMBOL_POINT);
if(EnableProtectiveStops)
{
   if(type1 == POSITION_TYPE_BUY)
   {
      class="type">class="kw">double ask1 = SymbolInfoDouble(symbol1, SYMBOL_ASK);
      sl1 = ask1 - ProtectiveStopPips * point1;
   }
   else
   {
      class="type">class="kw">double bid1 = SymbolInfoDouble(symbol1, SYMBOL_BID);
      sl1 = bid1 + ProtectiveStopPips * point1;
   }
   
   class=class="str">"cmt">// Similar calculations for the second instrument
   class=class="str">"cmt">// ...
}
class=class="str">"cmt">// Take profit calculation
if(EnableTakeProfit)
{
   class=class="str">"cmt">// Calculations for take profits
   class=class="str">"cmt">// ...
}
class="type">class="kw">double CalculatePositionSize()
{
   class="type">class="kw">double balance = AccountInfoDouble(ACCOUNT_BALANCE);
   class="type">class="kw">double riskAmount = balance * RiskPercent / class="num">100.0;
   
   class="type">class="kw">double tickValue1 = SymbolInfoDouble(Symbol1, SYMBOL_TRADE_TICK_VALUE);
   class="type">class="kw">double tickSize1 = SymbolInfoDouble(Symbol1, SYMBOL_TRADE_TICK_SIZE);
   class="type">class="kw">double point1 = SymbolInfoDouble(Symbol1, SYMBOL_POINT);
   
   class="type">class="kw">double lotStep = SymbolInfoDouble(Symbol1, SYMBOL_VOLUME_STEP);
   class="type">class="kw">double minLot = SymbolInfoDouble(Symbol1, SYMBOL_VOLUME_MIN);
   class="type">class="kw">double maxLot = SymbolInfoDouble(Symbol1, SYMBOL_VOLUME_MAX);
   
   class=class="str">"cmt">// Risk-based lot calculation(class="kw">using an approximate stop loss of class="num">100 pips for calculation)
   class="type">class="kw">double virtualStopLoss = class="num">100;
   class="type">class="kw">double riskPerPoint = tickValue1 * (point1 / tickSize1);
把相关性诊断交给小布
小布盯盘已内置EURUSD与GBPUSD等主流货币对的滚动相关与Z值面板,打开对应品种页即可看到实时偏离带,你只需判断要不要介入。

常见问题

相关性只说明两序列联动方向强度,平稳性要求价格关系均值方差不随时间漂移;不平稳的相关配对在危机期会永久脱钩,均值回归假设失效。
不是。不同品种波动结构不同,±2.0仅是经验起点;进阶做法是用滚动窗口估计标准差并做样本外检验,才可能适配多变环境。
宏观冲击下关联会断裂,杠杆会放大双向敞口亏损,贵金属与汇价还受利率预期交叉影响,回归概率并非确定。
目前小布盯盘提供Z值偏离可视化与历史分位参考,不替代决策;你结合本文的阈值逻辑自行触发EA参数更稳妥。
若优化仅用单一区段样本,参数大概率过拟合;应做walk-forward验证,让算法在未知段也保持回归捕获率。