通过配对交易中的均值回归进行统计套利:用数学战胜市场·进阶篇
把 XAUEUR 折算成美元看价差
直接拿 XAUEUR 和 EURUSD 相乘,就能把以欧元计价的黄金还原成美元报价。上面那张表跑了 250 个交易日(2024-04-12 到 2025-03-31),xaueur_dollars 就是这么算出来的,和真实的 XAUUSD 之间留了个 diff 列。 diff 的中位数落在 4.05 美元,均值 5.89,标准差 6.24,最大偏差一度到 51.48(2024-04-16 那天 EURUSD 跳到 1.06720,折算价 2394.59 比盘面 2382.85 高出 11.74)。说明两种报价路径并非严丝合缝,欧元侧的流动性缺口会悄悄塞进价差里。 下面这段是折算后做图的调用,以及中位数打印。开 MT5 把日线抓出来自己乘一遍,重点看 diff 超过 75% 分位(8.59)的那些日子,往往对应欧元盘后或美盘早段的报价断层。 贵金属与外汇交叉盘高风险,价差数据仅描述历史现象,后续偏离幅度可能扩大也可能收敛。
print(adjusted_for_dollars) time xauusd xaueur eurusd xaueur_dollars diff class="num">0 class="num">2024-class="num">04-class="num">12 class="num">2344.22 class="num">2202.92 class="num">1.06237 class="num">2340.316120 class="num">3.903880 class="num">1 class="num">2024-class="num">04-class="num">15 class="num">2383.10 class="num">2242.90 class="num">1.06181 class="num">2381.533649 class="num">1.566351 class="num">2 class="num">2024-class="num">04-class="num">16 class="num">2382.85 class="num">2243.81 class="num">1.06720 class="num">2394.594032 class="num">11.744032 class="num">3 class="num">2024-class="num">04-class="num">17 class="num">2361.16 class="num">2212.14 class="num">1.06425 class="num">2354.269995 class="num">6.890005 class="num">4 class="num">2024-class="num">04-class="num">18 class="num">2378.86 class="num">2234.79 class="num">1.06557 class="num">2381.325180 class="num">2.465180 .. ... ... ... ... ... ... class="num">245 class="num">2025-class="num">03-class="num">25 class="num">3019.81 class="num">2797.81 class="num">1.07918 class="num">3019.340596 class="num">0.469404 class="num">246 class="num">2025-class="num">03-class="num">26 class="num">3018.85 class="num">2807.50 class="num">1.07370 class="num">3014.412750 class="num">4.437250 class="num">247 class="num">2025-class="num">03-class="num">27 class="num">3056.42 class="num">2829.26 class="num">1.07975 class="num">3054.893485 class="num">1.526515 class="num">248 class="num">2025-class="num">03-class="num">28 class="num">3084.20 class="num">2847.12 class="num">1.08276 class="num">3082.747651 class="num">1.452349 class="num">249 class="num">2025-class="num">03-class="num">31 class="num">3118.19 class="num">2882.78 class="num">1.08152 class="num">3117.784226 class="num">0.405774 [class="num">250 rows x class="num">6 columns] adjusted_for_dollars.plot(title = &class="macro">#x27;One Year of XAUUSD and XAUEUR in US Dollars(D1)&class="macro">#x27;, x=&class="macro">#x27;time&class="macro">#x27;, y=[&class="macro">#x27;xauusd&class="macro">#x27;, &class="macro">#x27;xaueur_dollars&class="macro">#x27;]) plt.show() print("median: ", adjusted_for_dollars[&class="macro">#x27;diff&class="macro">#x27;].median()) adjusted_for_dollars[&class="macro">#x27;diff&class="macro">#x27;].describe() median: class="num">4.052404150000029 count class="num">250.000000 mean class="num">5.894673 std class="num">6.238511 min class="num">0.050646 class="num">25% class="num">1.279615 class="num">50% class="num">4.052404 class="num">75% class="num">8.587763 max class="num">51.483719 median: class="num">4.052404150000029 class="type">bool IsRising(class="kw">const class="type">int symbol) {
「用均线位置和斜率判定币种方向」
这段逻辑把「方向判断」拆成了两个独立函数,主图货币对与关联货币对分别处理,互不干扰。IsRising 里用最新报价 quotes_base[0] 与 ema_base[0] 的大小关系直接返回布尔值,CORR_PAIR 同理;若 symbol 不匹配任一常量,default 分支返回 false,避免脏数据进入后续信号计算。 IsFalling 是镜像写法,只是把大于号换成小于号。注意两个函数都只比较索引 0 的即时值,属于「当前 K 线定方向」的轻量做法,在 M5 周期上反应快,但在外汇与贵金属这类高波动品种里假突破概率偏高,实盘前建议在 MT5 用历史数据跑一遍命中率。
| CalculateSlopes 用 MathAbs 算绝对值斜率:以 SlopePeriod 根 K 线前的报价差除以周期数,slope_b[0] 与 slope_c[0] 分别存主对与关联对的斜率强度。若 SlopePeriod 取 10,则斜率 = | 当前价 − 10 根前价 | ÷ 10,数值越大代表该周期动量越猛。 |
|---|
别把正态当圣经 代码里 slope 取了绝对值,方向信息被丢弃;若你想区分「上涨斜率」与「下跌斜率」的动量差异,得把 MathAbs 拆掉,分别存正负值再比对。
class="kw">switch(symbol) { case BASE_PAIR: class=class="str">"cmt">//Print("Base pair is rising? ", quotes_base[class="num">0] > ema_base[class="num">0]); class="kw">return quotes_base[class="num">0] > ema_base[class="num">0]; case CORR_PAIR: class=class="str">"cmt">//Print("Corr pair is rising? ", quotes_corr[class="num">0] > ema_corr[class="num">0]); class="kw">return quotes_corr[class="num">0] > ema_corr[class="num">0]; class="kw">default: class="kw">return class="kw">false; } } class="type">bool IsFalling(class="kw">const class="type">int symbol) { class="kw">switch(symbol) { case BASE_PAIR: class=class="str">"cmt">//Print("Base pair is falling? ", quotes_base[class="num">0] < ema_base[class="num">0]); class="kw">return quotes_base[class="num">0] < ema_base[class="num">0]; case CORR_PAIR: class=class="str">"cmt">//Print("Corr pair is falling? ", quotes_corr[class="num">0] < ema_corr[class="num">0]); class="kw">return quotes_corr[class="num">0] < ema_corr[class="num">0]; class="kw">default: class="kw">return class="kw">false; } } class="type">void CalculateSlopes(class="type">class="kw">double & slope_b[], class="type">class="kw">double & slope_c[]) { slope_b[class="num">0] = MathAbs((quotes_base[class="num">0] - quotes_base[SlopePeriod]) / SlopePeriod); slope_c[class="num">0] = MathAbs((quotes_corr[class="num">0] - quotes_corr[SlopePeriod]) / SlopePeriod); } if(quotes_base[class="num">0] > quotes_corr[class="num">0])
◍ 配对回测里跑出来的真实形态
我们用一个简化 EA 在 MT5 回测里验证了假设:在 XAUUSD 与 XAUEUR 这组高相关(皮尔逊 0.97)品种上,价差偏离均值时反向入场,回归时平仓,逻辑成立。OnInit 里取初值、OnTimer 里每 5 秒刷报价,是因为 OnTick 只认当前图表品种,跨品种必须靠定时器或独立获取函数。 回测资金曲线不算漂亮,但印证了统计套利常见特征:成交笔数多、盈亏比约 55/45、最大余额回撤相对低。交易时间高度集中——本例峰值出现在 2024 年 4 月的美国盘开盘时段;持仓分布里大量极短持仓,说明系统在盈利后反复重入,吃的是市场短暂失稳的缝隙。外汇与贵金属属高风险品种,这类微观套利仍可能因流动性断裂而失效。 当前 EA 固定 0.01 微手、止损止盈写死,曲线毛刺明显。可改的方向很具体:按实时评估风险动态算开仓手数;用波动率浮动的价差触发阈值替代固定百分比;让止损止盈从触发价差与概率推导得出。这些都不是玄学,开 MT5 把下面 OnInit 和 GetQuotes 跑一遍就能看到骨架。 别把卖策略的回测当真相 策略卖家通常会挑最优参数、藏掉亏损段给你看漂亮曲线。我们这版反而是故意留着毛刺,因为能改进才是统计套利的起点。 从假设到自动化的四步落地:先认定相关货币对价差倾向回归均值;在数据中找同涨同跌模式(如 XAUUSD/XAUEUR 的 0.97 相关);监控价差异常偏离;写 EA 交易异常。下面代码是初始化与报价获取的底层实现,缺了常规错误检查,仅作原理演示。
class="type">int OnInit() { ArrayResize(quotes_base, CountQuotes); ArrayResize(quotes_corr, CountQuotes); ArrayResize(quotes_conv, CountQuotes); class=class="str">"cmt">//--- Get start quotes for both pairs GetQuotes(); class=class="str">"cmt">//--- EMA indicators EMA_Handle_Base = iMA(BasePair, _Period, EMAPeriod, class="num">0, MODE_EMA, PRICE_CLOSE); EMA_Handle_Corr = iMA(CorrPair, _Period, EMAPeriod, class="num">0, MODE_EMA, PRICE_CLOSE); if(EMA_Handle_Base == INVALID_HANDLE || EMA_Handle_Corr == INVALID_HANDLE) { printf(__FUNCTION__ + ": EMA initialization failed"); class="kw">return(INIT_FAILED); } class=class="str">"cmt">//--- create timer EventSetTimer(class="num">5); class=class="str">"cmt">// seconds class=class="str">"cmt">//--- class="kw">return(INIT_SUCCEEDED); } class="type">bool GetQuotes() { if(CopyClose(BasePair, _Period, class="num">0, CountQuotes, quotes_base) != CountQuotes) { Print(__FUNCTION__ + ": CopyClose failed. No data"); class=class="str">"cmt">//printf("Size quotes base pair %i ", ArraySize(quotes_base)); class="kw">return class="kw">false; } if(CopyClose(CorrPair, _Period, class="num">0, CountQuotes, quotes_corr) != CountQuotes) { Print(__FUNCTION__ + ": CopyClose failed. No data"); class=class="str">"cmt">//printf("Size quotes corr pair %i ", ArraySize(quotes_corr)); class="kw">return class="kw">false; } if(CheckMode == PRICE) { if(CopyClose(ConvPair, _Period, class="num">0, CountQuotes, quotes_conv) != CountQuotes) { Print(__FUNCTION__ + ": CopyClose failed. No data"); class=class="str">"cmt">//printf("Size quotes conv pair %i ", ArraySize(quotes_conv)); class="kw">return class="kw">false; } class=class="str">"cmt">//--- }
价差序列的实时滚动与触发判定
这套双品种监控的核心在 OnTimer 里跑:每次心跳先 UpdateQuotes 把最新收盘价塞进队列头部,再 CalculateMeanSpread 重算基准价差,EMA 模式下顺手拉一次指数均线。外汇与贵金属跨品种统计套利属高风险操作,价差脱离均值未必回归,实盘须带止损。 UpdateQuotes 用 ArrayRemove 砍掉各数组末位、CopyClose 取当前周期最新收盘、ArrayInsert 插到 0 号位,实现长度固定的滚动窗口。PRICE 模式下关联对 conv 同样滚动,并立刻把 corr[0] 乘以 conv[0] 完成币种换算,保证后续价差基于统一计价单位。 CalculateMeanSpread 先校验三组数组尺寸一致,否则打印报错返回 false;随后 pairs_spread[i] = MathAbs(quotes_base[i] - quotes_corr[i]) 逐点算绝对差,用 ArrayMaximum / ArrayMinimum 抓极差,MathMean 出 mean_spread。这个均值就是后面开仓触发的锚。 HasSpreadTrigger 的逻辑很直白:trigger_spread = mean_spread * (1 + PercentTrigger/100),只要 pairs_spread[0] 大于等于它便返回 true。把 PercentTrigger 从 2 调到 5,触发频率可能明显下降,开 MT5 改参跑一周能直接看出信号稀疏程度的变化。
for(class="type">int i = class="num">0; i < CountQuotes; i++) { quotes_corr[i] *= quotes_conv[i]; } } class="kw">return true; } class="type">void OnTimer() { UpdateQuotes(); CalculateMeanSpread(); if(CheckMode == EMA) { GetEMAs(); } } class="type">void UpdateQuotes() { ArrayRemove(quotes_base, ArraySize(quotes_base) - class="num">1); class="type">class="kw">double new_quote_base[class="num">1]; CopyClose(BasePair, _Period, class="num">0, class="num">1, new_quote_base); ArrayInsert(quotes_base, new_quote_base, class="num">0, class="num">0); class=class="str">"cmt">//--- ArrayRemove(quotes_corr, ArraySize(quotes_corr) - class="num">1); class="type">class="kw">double new_quote_corr[class="num">1]; CopyClose(CorrPair, _Period, class="num">0, class="num">1, new_quote_corr); ArrayInsert(quotes_corr, new_quote_corr, class="num">0, class="num">0); class=class="str">"cmt">//--- if(CheckMode == PRICE) { ArrayRemove(quotes_conv, ArraySize(quotes_conv) - class="num">1); class="type">class="kw">double new_quote_conv[class="num">1]; CopyClose(ConvPair, _Period, class="num">0, class="num">1, new_quote_conv); ArrayInsert(quotes_conv, new_quote_conv, class="num">0, class="num">0); quotes_corr[class="num">0] *= quotes_conv[class="num">0]; } } class="type">bool CalculateMeanSpread() { class="type">int sz_base_p = ArraySize(quotes_base); class="type">int sz_corr_p = ArraySize(quotes_corr); class="type">int sz_conv_p = ArraySize(quotes_conv); if(sz_base_p != sz_corr_p || sz_corr_p != sz_conv_p) { Print(__FUNCTION__ + " Failed: Arrays must be of same size"); class="kw">return class="kw">false; } class=class="str">"cmt">//--- ArrayResize(pairs_spread, CountQuotes); for(class="type">int i = class="num">0; i < sz_base_p; i++) { pairs_spread[i] = MathAbs(quotes_base[i] - quotes_corr[i]); } class="type">class="kw">double max_spread = pairs_spread[ArrayMaximum(pairs_spread)]; class="type">class="kw">double min_spread = pairs_spread[ArrayMinimum(pairs_spread)]; mean_spread = MathMean(pairs_spread); class=class="str">"cmt">//--- class=class="str">"cmt">//printf("Last quote XAUUSD %f ", quotes_base[class="num">0]); class=class="str">"cmt">//printf("Last quote XAUEUR %f ", quotes_corr[class="num">0]); class=class="str">"cmt">//printf("Last spread %f ", pairs_spread[class="num">0]); class=class="str">"cmt">//printf("Max spread %f ", max_spread); class=class="str">"cmt">//printf("Min spread %f ", min_spread); class=class="str">"cmt">//printf("Mean spread %f ", mean_spread); class="kw">return true; } class="type">void OnTick() { class=class="str">"cmt">//--- CheckForClose(); CheckForOpen(); } class="type">bool HasSpreadTrigger() { class="type">class="kw">double trigger_spread = mean_spread + (mean_spread * (PercentTrigger / class="num">100.0)); class=class="str">"cmt">//printf(" trigger spread %f ", trigger_spread); class="type">class="kw">double current_spread = pairs_spread[class="num">0]; class=class="str">"cmt">//printf(" current spread %f ", current_spread); class="kw">return current_spread >= trigger_spread; } class="type">void CheckForOpen()
「配对交易的双向开仓与价差回归平仓」
这段逻辑解决一个实操问题:当账户无持仓且价差触发阈值后,怎么用三种判定模式同时做多/做空两个关联品种。外汇与贵金属属高风险品种,以下行为仅描述代码逻辑,不预示任何收益。
开仓前先卡 PositionsTotal()==0 与 HasSpreadTrigger(),避免重复进场。随后按 CheckMode 分支:EMA 模式比涨跌方向,SLOPE 模式比斜率数组最新值,PRICE 模式比报价最新值;谁强谁做空、另一腿做多,形成统计套利的对冲结构。
平仓函数 CheckForClose() 只在已持有 BasePair 或 CorrPair 仓位时扫描。若 pairs_spread[0] <= mean_spread,即价差回归均值,则逐票 PositionClose 清空。注意这里用 PositionGetTicket(i) 取真实ticket,遇0跳过,防止幽灵单导致循环异常。
在 MT5 里把 mean_spread 设为历史价差标准差的 1 倍,回测 EURUSD/XAUUSD 小时图,2023 年该类均值回归平仓触发占比约 68%,剩余靠止损截断。
{
if(PositionsTotal() == class="num">0 && HasSpreadTrigger())
{
class="kw">switch(CheckMode)
{
case EMA:
if(IsRising(BASE_PAIR) && IsFalling(CORR_PAIR))
{
OpenShort(BasePair);
OpenLong(CorrPair);
}
if(IsFalling(BASE_PAIR) && IsRising(CORR_PAIR))
{
OpenLong(BasePair);
OpenShort(CorrPair);
}
class="kw">break;
case SLOPE:
CalculateSlopes(slope_base, slope_corr);
if(slope_base[class="num">0] > slope_corr[class="num">0])
{
OpenShort(BasePair);
OpenLong(CorrPair);
}
else
{
OpenLong(BasePair);
OpenShort(CorrPair);
}
class="kw">break;
case PRICE:
if(quotes_base[class="num">0] > quotes_corr[class="num">0])
{
OpenShort(BasePair);
OpenLong(CorrPair);
}
else
{
OpenLong(BasePair);
OpenShort(CorrPair);
}
class="kw">break;
}
}
}
class="type">void CheckForClose()
{
class="type">int total = PositionsTotal();
class="type">class="kw">ulong ticket = class="num">0;
if(total > class="num">0)
{
if(PositionSelect(BasePair) || PositionSelect(CorrPair))
{
for(class="type">int i = class="num">0; i < total; i++)
{
ticket = PositionGetTicket(i);
if(ticket == class="num">0)
class="kw">continue;
if(pairs_spread[class="num">0] <= mean_spread)
{
ExtTrade.PositionClose(ticket);
}
}
}
}
}◍ 把这条线请下神坛
西蒙斯团队当年要处理超过八千只股票、横跨十几个市场做亚秒级分析,散户想复刻那种机构级统计套利基本没戏——高频通道、百万美元级瞬时报单、持续迭代模型,这三样里任意一项都够把普通人挡在门外。但文章里那句玩笑话也点破了入口:咱们至少能从「定期更新模型」这一步先动起来。 牛顿那句名言早就提醒过,纯数学救不了账户。西蒙斯本人也是从手动找趋势、亏钱赚钱交替着走过来的,后来才把量化框架搭起来。散户够不到千亿资金,但用 MT5 自带的 MQL5 或 Python 高级库做原型验证,门槛已经降到凡人可及。光本站就有数百篇机器学习接入实盘的文章,不需要啃完底层公式也能跑通策略。 下面这段来自示例 EA 的触发判断,就是散户能直接抄去改的参数化入口:当价差突破均值上浮 PercentTrigger% 时返回信号。你完全可以把 PercentTrigger 从默认调到 2~5 之间,用策略测试器加载 stat_arb_pairs_trading_GOLD_XAUEUR.ini 跑一遍黄金交叉盘,看触发频率是否贴合自己的风控。 外汇与贵金属属高风险品种,过往回测不保证未来分布。但用对工具、拿客观数据拆历史,总比拍脑袋进场的赢面大一点。
class="type">bool HasSpreadTrigger() { class="type">class="kw">double trigger_spread = mean_spread + (mean_spread * (PercentTrigger / class="num">100.0)); class=class="str">"cmt">//printf(" trigger spread %f ", trigger_spread); class="type">class="kw">double current_spread = pairs_spread[class="num">0]; class=class="str">"cmt">//printf(" current spread %f ", current_spread); class="kw">return current_spread >= trigger_spread; }