配对交易:基于Z值差异的自动优化算法交易·进阶篇
(2/3)·从平稳性数学到Z分数阈值,拆解EA如何自动捕捉相关货币对的均值回归窗口
不少交易者把配对交易当成简单同涨同跌对冲,忽略价格关系平稳性检验就直接开仓,结果在结构性断档期两头止损。另一些人死守固定阈值,市场微结构一变就频繁假信号。本篇接着上篇概念,把统计基底和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 跑回测,外汇与贵金属品种在此类优化中仍属高风险,过拟合概率不低。
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 分布。
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 有用。
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 发出去,完成一次相关性背离的对冲式加码。
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 上限。
{
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);