三角套利·综合运用
🔺

三角套利·综合运用

(3/3)· 接前两篇基础与实现,本篇把三角套利完整跑通成机器人并直面实盘滑点

实战向 第 3/3 篇

很多交易者把三角套利想成无风险提款机,却在第一次实盘发现报价不同步让对冲仓变成了裸敞口。论坛里反复讨论的「中立组合」一旦忽略竞买竞卖方向,回测漂亮的信号上线就持续磨损。这一篇我们不再谈概念,直接把前面铺垫的三角跑成可重启、可测试的 EA。

三角套利里的品种配对与加载细节

构建三角套利组合时,利润货币与基准货币的匹配顺序直接影响乘算逻辑。代码里先用 SymbolInfoString 取出第三对的基准货币 sm3base 和第二对的利润货币 sm2prft,只要两者不相等,就把第一对和第二对的 name 互换位置,保证后续始终走乘法路径。 加载阶段用 CSymbolInfo 批量初始化每个品种的数据集。Digits() 拿到报价小数位,TickSize()*DEVIATION 把整数滑点转成小数格式,1/Point 取整后存进 Rpoint,用乘法替代除法来折算点数,少一次浮点除能省一点执行开销。 手数精度靠 NumberCount(LotsStep()) 确定,再用 NormalizeDouble 把 LotsMin / LotsMax / LotsStep 统一舍入到对应小数位。这个 fnSmbLoad 只在 EA 初始化时跑一次,重复请求品种数据的时间损耗可以接受,但若 Name() 返回失败,宏 prnt 会直接把该三角标记为空并跳过。 开 MT5 把这段接进自己的三角扫描器,先 Print 出 '全部使用的三角' 计数,确认组合数量与预期一致,再去看每组的 smb1.Rpoint 是否非零,能避开半数以上的加载期静默错误。外汇与贵金属杠杆品种波动剧烈,三角价差可能在毫秒级消失,实盘前务必用策略测试器跑历史 tick 验证。

MQL5 / C++
  class=class="str">"cmt">// 第二个位置是与第三个基准货币匹配的利润货币对。 
  class=class="str">"cmt">// 在这种情况下, 我们总是使用乘法。
  sm3base=SymbolInfoString(MxSmb[i].smb3.name,SYMBOL_CURRENCY_BASE);
  class="type">class="kw">string sm2prft=SymbolInfoString(MxSmb[i].smb2.name,SYMBOL_CURRENCY_PROFIT);
  
  class=class="str">"cmt">// 交换第一和第二对的位置。 
  if(sm3base!=sm2prft)
  {
    class="type">class="kw">string temp=MxSmb[i].smb1.name;
    MxSmb[i].smb1.name=MxSmb[i].smb2.name;
    MxSmb[i].smb2.name=temp;
  }
  
  class=class="str">"cmt">// 显示已处理三角的消息。 
  Print("使用三角: "+MxSmb[i].smb1.name+" + "+MxSmb[i].smb2.name+" + "+MxSmb[i].smb3.name);
  count++;
  }class=class="str">"cmt">//
  class=class="str">"cmt">// 通知操作中使用的三角总数。 
  Print("全部使用的三角: "+(class="type">class="kw">string)count);
}
class="type">void fnSmbLoad(class="type">class="kw">double lot,stThree &MxSmb[])
  {
  
  class=class="str">"cmt">// 用来打印的简单宏定义  
  class="macro">#define prnt(nm) {nm="";Print("不正确的上载: "+nm);class="kw">continue;}
  
  class=class="str">"cmt">// 循环遍历所有形成的三角。在此, 同一品种重复数据请求会过度消耗我们的时间 
  class=class="str">"cmt">// 但由于这个操作只在加载机器人的时候执行, 所以为了减少代码, 我们仍然可以这样做。
  class=class="str">"cmt">// 使用标准库来获取数据。 
  for(class="type">int i=ArraySize(MxSmb)-class="num">1;i>=class="num">0;i--)
  {
    class=class="str">"cmt">// 通过上传品种到 CSymbolInfo 类, 我们初始化了所有必要数据的集合
    class=class="str">"cmt">// 检查它们的可用性。如果出现问题, 三角标记为不可操作。              
    if (!csmb.Name(MxSmb[i].smb1.name))    prnt(MxSmb[i].smb1.name);
    
    class=class="str">"cmt">// 获得每个品种的 _capacity
    MxSmb[i].smb1.digits=csmb.Digits();
    
    class=class="str">"cmt">// 将滑点从整数转换为小数点。我们将需要这种格式进行进一步的计算
    MxSmb[i].smb1.dev=csmb.TickSize()*DEVIATION;        
    
    class=class="str">"cmt">// 为了将报价转换为点数, 通常我们必须将价格除以 _Point 值。
    class=class="str">"cmt">// 把这个值显示为 class="num">1/Point 是比较合理的, 这样我们就可以用乘法代替除法。
    class=class="str">"cmt">// 没有检查 csmb.Point() 是否为 class="num">0: 它不能等于 class="num">0, 但如果
    class=class="str">"cmt">//  由于某种原因没有收到参数, 三角按 if (!csmb.Name(MxSmb[i].smb1.name)) 这行排序。        
    MxSmb[i].smb1.Rpoint=class="type">int(NormalizeDouble(class="num">1/csmb.Point(),class="num">0));
    
    class=class="str">"cmt">// 我们要将手数舍入到小数位数。 
    MxSmb[i].smb1.digits_lot=csup.NumberCount(csmb.LotsStep());
    
    class=class="str">"cmt">// 交易量限制 (一次性常规化)
    MxSmb[i].smb1.lot_min=NormalizeDouble(csmb.LotsMin(),MxSmb[i].smb1.digits_lot);
    MxSmb[i].smb1.lot_max=NormalizeDouble(csmb.LotsMax(),MxSmb[i].smb1.digits_lot);
    MxSmb[i].smb1.lot_step=NormalizeDouble(csmb.LotsStep(),MxSmb[i].smb1.digits_lot);

◍ 三角套利里的手数边界怎么收口

做多品种联动策略时,每个品种的合约属性必须分开抓。下面这段把三个品种的小数位、跳价、最小/最大/步进手数以及合约大小各自存进结构体,注意品种2和品种3在取名字失败时直接 prnt 打印出来,方便你上 MT5 跑的时候看是哪一对没接上。 DEVIAION 乘 TickSize 得到 dev,是用来放宽成交容差的;Rpoint 用 1/Point 取整,本质是算出该品种一个点对应多少整数步。外汇和贵金属点值差异大,贵金属跳价可能到 0.01 美元一级,不分开存就会在后面算手数时串味。 三角整体手数不能只看单对。代码里先取三个品种最小手数中的最大值,再按最大小数位舍入,得到三角能下的 lot_min;反过来用三个最大手数里的最小值定 lot_max。注释里给了一个具体场景:0.01 + 0.01 + 0.1 这类不对称步长,最终三角最小手数会被抬到 0.1 并保留 1 位小数。 当外部传入 lot==0,不按每对最少下,而是统一用三角 lot_min 填平三个腿。这样避免某对下了 0.01 另一对被动 0.1 导致三角敞口歪掉。外汇/贵金属杠杆高,手数边界算错可能瞬间爆仓,建议把这段直接贴进 EA 初始化里验证打印值。

MQL5 / C++
  class=class="str">"cmt">// 合约大小 
  MxSmb[i].smb1.contract=csmb.ContractSize();
  
  class=class="str">"cmt">// 同上, 但取自品种 class="num">2
  if (!csmb.Name(MxSmb[i].smb2.name))    prnt(MxSmb[i].smb2.name);
  MxSmb[i].smb2.digits=csmb.Digits();
  MxSmb[i].smb2.dev=csmb.TickSize()*DEVIATION;
  MxSmb[i].smb2.Rpoint=class="type">int(NormalizeDouble(class="num">1/csmb.Point(),class="num">0));
  MxSmb[i].smb2.digits_lot=csup.NumberCount(csmb.LotsStep());
  MxSmb[i].smb2.lot_min=NormalizeDouble(csmb.LotsMin(),MxSmb[i].smb2.digits_lot);
  MxSmb[i].smb2.lot_max=NormalizeDouble(csmb.LotsMax(),MxSmb[i].smb2.digits_lot);
  MxSmb[i].smb2.lot_step=NormalizeDouble(csmb.LotsStep(),MxSmb[i].smb2.digits_lot);        
  MxSmb[i].smb2.contract=csmb.ContractSize();
  
  class=class="str">"cmt">// 同上, 但针对品种 class="num">3
  if (!csmb.Name(MxSmb[i].smb3.name))    prnt(MxSmb[i].smb3.name);
  MxSmb[i].smb3.digits=csmb.Digits();
  MxSmb[i].smb3.dev=csmb.TickSize()*DEVIATION;
  MxSmb[i].smb3.Rpoint=class="type">int(NormalizeDouble(class="num">1/csmb.Point(),class="num">0));
  MxSmb[i].smb3.digits_lot=csup.NumberCount(csmb.LotsStep());
  MxSmb[i].smb3.lot_min=NormalizeDouble(csmb.LotsMin(),MxSmb[i].smb3.digits_lot);
  MxSmb[i].smb3.lot_max=NormalizeDouble(csmb.LotsMax(),MxSmb[i].smb3.digits_lot);
  MxSmb[i].smb3.lot_step=NormalizeDouble(csmb.LotsStep(),MxSmb[i].smb3.digits_lot);        
  MxSmb[i].smb3.contract=csmb.ContractSize();  
  
  class=class="str">"cmt">// 取齐交易量。对于 货币对和整个三角都有限制。
  class=class="str">"cmt">// 货币对限制写在这里: MxSmb[i].smbN.lotN
  class=class="str">"cmt">// 三角限制写在这里: MxSmb[i].lotN
  
  class=class="str">"cmt">// 选择所有最低数值中的最高值。将它舍入到最大值。
  class=class="str">"cmt">// 整个代码块仅适用于交易量大致如下的情况: class="num">0.01 + class="num">0.01 + class="num">0.1。 
  class=class="str">"cmt">// 在这种情况下, 尽可能少的交易量被设置为 class="num">0.1 并四舍五入到小数点后 class="num">1 位。
  class="type">class="kw">double lt=MathMax(MxSmb[i].smb1.lot_min,MathMax(MxSmb[i].smb2.lot_min,MxSmb[i].smb3.lot_min));
  MxSmb[i].lot_min=NormalizeDouble(lt,(class="type">int)MathMax(MxSmb[i].smb1.digits_lot,MathMax(MxSmb[i].smb2.digits_lot,MxSmb[i].smb3.digits_lot)));
  
  class=class="str">"cmt">// 另外, 最低交易量从最高交易量中取出并立即舍入。 
  lt=MathMin(MxSmb[i].smb1.lot_max,MathMin(MxSmb[i].smb2.lot_max,MxSmb[i].smb3.lot_max));
  MxSmb[i].lot_max=NormalizeDouble(lt,(class="type">int)MathMax(MxSmb[i].smb1.digits_lot,MathMax(MxSmb[i].smb2.digits_lot,MxSmb[i].smb3.digits_lot)));
  
  class=class="str">"cmt">// 如果交易量输入参数为 class="num">0, 则使用尽可能少的交易量, 但并非取每对最少的,  
  class=class="str">"cmt">// 而是所有对中最少的一个。 
  if (lot==class="num">0)
  {
    MxSmb[i].smb1.lot=MxSmb[i].lot_min;
    MxSmb[i].smb2.lot=MxSmb[i].lot_min;
    MxSmb[i].smb3.lot=MxSmb[i].lot_min;

「三角套利里的手数边界检查」

在三角套利的预处理里,若选择按统一手数(lot)撮合,需要把该值同时赋给前两个货币对的持仓字段,第三个货币对的量通常在输入前已算好。 真正决定这个三角能不能用的,是手数是否落在各自品种的限制区间内。代码里用一行逻辑判断同时覆盖了 smb1 与 smb2 的 lot_min / lot_max:只要 lot 小于最小或大于最大,就直接清空 smb1.name 并弹 Alert 提示“交易量不正确”,随后 continue 跳过该组合。 这种写法意味着:外汇与贵金属市场的高波动可能让某边最小手数临时变大,原本能跑的三角会瞬间失效。开 MT5 把这段接进自己的三角扫描器,观察 Alert 触发频率,就能评估当前经纪商限制下可交易三角的实际数量。

MQL5 / C++
      } else
      {
         class=class="str">"cmt">// 如果您需要取齐交易量, 那么您知道货币对 class="num">1 和 class="num">2 的值, 而第三个交易量是在输入之前计算的。 
         MxSmb[i].smb1.lot=lot;   
         MxSmb[i].smb2.lot=lot;
         
         class=class="str">"cmt">// 如果投入的交易量不在当前的限制范围内, 则三角不能在操作中使用。 
         class=class="str">"cmt">// 使用警报通知这一点
         if (lot<MxSmb[i].smb1.lot_min || lot>MxSmb[i].smb1.lot_max || lot<MxSmb[i].smb2.lot_min || lot>MxSmb[i].smb2.lot_max)
         {
            MxSmb[i].smb1.name="";
            Alert("三角: "+MxSmb[i].smb1.name+" "+MxSmb[i].smb2.name+" "+MxSmb[i].smb3.name+" - 交易量不正确");
            class="kw">continue;
         }         
      }
   }
}

EA 启动时的三种三角来源切换

在 MT5 里挂这个 EA 时,启动模式直接决定三角(three symbols)从哪来。最常用的是「来自市场观察的品名」:在当前图表品种上跑,EA 自己从市场观察窗口抓可交易标的凑成三角,不需要你额外准备任何文件。 另外两种模式都围绕文件转。「来自文件的品名」和第一种逻辑几乎一样,只是三角清单改从预先生成的文件读取;「用品名创建文件」则只干一件事——把三角和品种属性落盘,写完 EA 就自动退出,方便你之后用第二种模式或策略测试器批量跑。 写文件的函数没有花活,核心是把三角以外的属性一并存下来:允许交易量、合约大小、报价小数位。这些字段留着,是为了你后面打开 csv 肉眼核对品种参数时对得上号,不用再回 MT5 终端翻规格。 下面这段是 CREATE_FILE 分支和 fnCreateFileSymbols 的原文逻辑。先判模式删旧文件再写新文件,写失败直接弹窗并卸裁 EA;fnCreateFileSymbols 先写表头(含 3 个品种各自的合约大小、最小/最大手数、手数步进、小数位,以及公用最小最大手数),再倒序遍历三角数组填行,末尾补空行并 FileFlush 落盘。 别把文件逻辑当累赘 生成 csv 后顺手用编辑器打开,重点看「公用最小手数 / 公用最大手数」那两列。若三角里某个品种的最大手数比另外两个小一个数量级,实盘组合手数会被迫压到很低,外汇和贵金属杠杆差异大,这种错配容易让对冲比例失真。

MQL5 / C++
    if(inMode==CREATE_FILE)
    {
        class=class="str">"cmt">// 删除文件, 如果它存在。
        FileDelete(FILENAME);  
        class="type">int fh=FILEOPENWRITE(FILENAME);
        if (fh==INVALID_HANDLE) 
        {
            Alert("品种文件未创建");
            ExpertRemove();
        }
        class=class="str">"cmt">// 将三角和一些其它数据写入文件
        fnCreateFileSymbols(MxThree,fh);
        Print("品种文件已创建");
        
        class=class="str">"cmt">// 关闭文件并完成 EA 操作
        FileClose(fh);
        ExpertRemove();
    }
class="type">void fnCreateFileSymbols(stThree &MxSmb[], class="type">int filehandle)
  {
    class=class="str">"cmt">// 在文件中定义头文件
    FileWrite(filehandle,"品名 class="num">1","品名 class="num">2","品名 class="num">3","合约大小 class="num">1","合约大小 class="num">2","合约大小 class="num">3",
    "最小手数 class="num">1","最小手数 class="num">2","最小手数 class="num">3","最大手数 class="num">1","最大手数 class="num">2","最大手数 class="num">3","手数增量 class="num">1","手数增量 class="num">2","手数增量 class="num">3",
    "公用最小手数","公用最大手数","小数位 class="num">1","小数位 class="num">2","小数位 class="num">3");
    
    class=class="str">"cmt">// 根据上面指定的头文件填写文件
    for(class="type">int i=ArraySize(MxSmb)-class="num">1;i>=class="num">0;i--)
    {
        FileWrite(filehandle,MxSmb[i].smb1.name,MxSmb[i].smb2.name,MxSmb[i].smb3.name,
        MxSmb[i].smb1.contract,MxSmb[i].smb2.contract,MxSmb[i].smb3.contract,
        MxSmb[i].smb1.lot_min,MxSmb[i].smb2.lot_min,MxSmb[i].smb3.lot_min,
        MxSmb[i].smb1.lot_max,MxSmb[i].smb2.lot_max,MxSmb[i].smb3.lot_max,
        MxSmb[i].smb1.lot_step,MxSmb[i].smb2.lot_step,MxSmb[i].smb3.lot_step,
        MxSmb[i].lot_min,MxSmb[i].lot_max,
        MxSmb[i].smb1.digits,MxSmb[i].smb2.digits,MxSmb[i].smb3.digits);        
    }
    FileWrite(filehandle,"");      
      class=class="str">"cmt">// 在所有品名之后留下一个空字符串
    
    class=class="str">"cmt">// 操作完成后, 出于安全原因将所有数据写入磁盘 
    FileFlush(filehandle);
  }

◍ 崩后重启如何续上三角仓

EA 初始化快收尾时,真正麻烦的不是断网——重连后机器人自己会接着跑。怕的是 MT5 进程挂掉、你手动重启 EA,这时内存里的三角套利状态全丢,必须从账户里的真实持仓反推回来。 作者把恢复逻辑封进 fnRestart.mqh 单独文件,核心思路按账户类型分治。对冲账户(ACCOUNT_MARGIN_MODE_RETAIL_HEDGING)最简单:遍历 PositionsTotal() 返回的持仓,用魔幻数字区间 [magic, magic+MAGIC] 筛出本机器人下的单,再把三张票凑成一组三角。 净持仓账户就恶心了,光看当前仓位不够,还得翻机器人自己落盘的仓位数据库才能还原。这段恢复代码作者明说没做优化、纯直写,因为触发频率极低,性能损耗可忽略。 下单走的是异步模式(异步发单 + 额外跟踪回执),策略假设 maximally aggressive,所以不阻塞等成交。下面贴的函数只展开到对冲账户里三层 for 循环定位三角的前半段,后面 k 循环收尾同理。外汇与贵金属杠杆高,重启用错 magic 区间可能把别人 EA 的仓误认成自己的,实盘前务必在策略测试器里造一次崩溃重启验证。

MQL5 / C++
class="type">void fnRestart(stThree &MxSmb[],class="type">class="kw">ulong magic,class="type">int accounttype)
  {
        class="type">class="kw">string   smb1,smb2,smb3;
        class="type">long     tkt1,tkt2,tkt3;
        class="type">class="kw">ulong    mg;
        class="type">uchar    count=class="num">0;     class=class="str">"cmt">// 还原三角的计数器
        
        class="kw">switch(accounttype)
        {
         class=class="str">"cmt">// 在对冲账户中恢复仓位非常容易: 遍历所有未平仓位, 使用魔幻数字定义持仓 
         class=class="str">"cmt">// 并将它们组合为三角
         class=class="str">"cmt">// 如果净持账户, 情况会变得更加复杂 - 首先, 我们需要参考保存的仓位数据库, 这些仓位是由机器人打单的。 
         
         class=class="str">"cmt">// 搜索必要仓位并将其恢复为三角的算法已经以相当直接的方式实现了, 没有任何装饰和 
         class=class="str">"cmt">// 优化。但是, 由于这个阶段是不经常需要的, 我们可能会忽略其性能
         class=class="str">"cmt">// 以便简化代码。 
         
         case  ACCOUNT_MARGIN_MODE_RETAIL_HEDGING:
              class=class="str">"cmt">// 遍历所有已开仓位, 并检测魔幻数字匹配。 
              class=class="str">"cmt">// 记住第一个检测到的仓位的魔幻数字: 用它来检测另外两个。 
              
              for(class="type">int i=PositionsTotal()-class="num">1;i>=class="num">2;i--)
              {class=class="str">"cmt">//for i
                 smb1=PositionGetSymbol(i);
                 mg=PositionGetInteger(POSITION_MAGIC);
                 if (mg<magic || mg>(magic+MAGIC)) class="kw">continue;
                 
                 class=class="str">"cmt">// 记住票号, 以便更方便地访问这个仓位。 
                 tkt1=PositionGetInteger(POSITION_TICKET);
                 
                 class=class="str">"cmt">// 寻找具有相同魔幻数字的第二个仓位。 
                 for(class="type">int j=i-class="num">1;j>=class="num">1;j--)
                 {class=class="str">"cmt">//for j
                    smb2=PositionGetSymbol(j);
                    if (mg!=PositionGetInteger(POSITION_MAGIC)) class="kw">continue;  
                    tkt2=PositionGetInteger(POSITION_TICKET);              
                     
                    class=class="str">"cmt">// 查找最后的仓位。
                    for(class="type">int k=j-class="num">1;k>=class="num">0;k--)
                    {class=class="str">"cmt">//for k
                       smb3=PositionGetSymbol(k);

「三角持仓匹配与状态回填」

机器人扫完当前账户持仓后,会按魔术码过滤掉非本策略订单:magic 不符的仓位直接 continue 跳过,留下的才是自己开的三角腿。拿到 ticket 后,代码进入 MxSmb 数组的倒序遍历,只处理 status==0 的空白三角模板。 匹配逻辑并不复杂但容易写错:用三组 OR 判断模板里的 smb1/smb2/smb3 名称是否恰好覆盖当前检测到的 smb1/smb2/smb3,命中即说明这个三角已经真实开仓。此时把 status 置为 2、写入 magic 与 pl=0,相当于在内存里“还原”了三角。 单号回填阶段按名称对齐:模板里 smb1 若等于实际 smb1 就绑 tkt1,等于 smb2 绑 tkt2,等于 smb3 绑 tkt3,smb2 同理。这样下一笔 tick 来临时,机器人就能直接用这些 ticket 拉取实时盈亏与平仓,不必重跑搜索循环。外汇与贵金属三角套利受滑点与点差挤压,实盘触发率可能明显低于回测。

MQL5 / C++
if (mg!=PositionGetInteger(POSITION_MAGIC)) class="kw">continue;
tkt3=PositionGetInteger(POSITION_TICKET);

class=class="str">"cmt">// 如果您到达这个阶段, 已经找齐了已开单三角。数据已下载。机器人在下一笔分笔报价时计算其余数据。

for(class="type">int m=ArraySize(MxSmb)-class="num">1;m>=class="num">0;m--)
{class=class="str">"cmt">//for m
  class=class="str">"cmt">// 遍历三角数组, 忽略已开三角。
  if (MxSmb[m].status!=class="num">0) class="kw">continue;

  class=class="str">"cmt">// "bluntly" 完成。起初, 我们似乎可以
class=class="str">"cmt">// 多次参考相同 货币对若干次。但事实并非如此, 因为在检测到另一种货币对之后,
  class=class="str">"cmt">// 我们从下一对继续我们的搜索, 而不是从搜索循环的开始
  if (   (MxSmb[m].smb1.name==smb1 || MxSmb[m].smb1.name==smb2 || MxSmb[m].smb1.name==smb3) &&
         (MxSmb[m].smb2.name==smb1 || MxSmb[m].smb2.name==smb2 || MxSmb[m].smb2.name==smb3) &&
         (MxSmb[m].smb3.name==smb1 || MxSmb[m].smb3.name==smb2 || MxSmb[m].smb3.name==smb3)); else class="kw">continue;

  class=class="str">"cmt">// 我们已经检测到这个三角。现在, 我们为其分配适当的状态
  MxSmb[m].status=class="num">2;
  MxSmb[m].magic=magic;
  MxSmb[m].pl=class="num">0;

  class=class="str">"cmt">// 按所需顺序排列单号。三角已经还原了。
  if (MxSmb[m].smb1.name==smb1) MxSmb[m].smb1.tkt=tkt1;
  if (MxSmb[m].smb1.name==smb2) MxSmb[m].smb1.tkt=tkt2;
  if (MxSmb[m].smb1.name==smb3) MxSmb[m].smb1.tkt=tkt3;

  if (MxSmb[m].smb2.name==smb1) MxSmb[m].smb2.tkt=tkt1;
  if (MxSmb[m].smb2.name==smb2) MxSmb[m].smb2.tkt=tkt2;

三角套利票号回填与异步下单配置

这段逻辑处在多币种三角组合的恢复阶段:外层四重 for 循环(i/j/k/m)遍历已记录的 MxSmb 结构,内层通过比对 smb1、smb2、smb3 的名称,把对应的票据号 tkt1~tkt3 写回结构体成员。命中后 count 自增并 break 跳出 m 循环,避免重复赋值。 循环结束后用 if(count>0) 打印一句 Restore N triangles,N 就是成功回填的三角组合数。这个打印是纯日志,不影响交易,但能让你在 MT5 专家日志里确认恢复了多少组挂单关系。 收尾部分对 CTrade 实例做了四项硬设定:点差偏离设为 DEVIATION 点,成交方式强制 ORDER_FILLING_FOK(未成交即废单,外汇与贵金属价格跳空时容易全拒),异步模式开 true,日志级别压到 NO。最后 EventSetTimer(1) 挂上 1 秒定时器,后续逻辑将按秒轮询。 外汇与贵金属三角套利涉及多单同时成交,FOK 模式在高波动时段失败概率明显上升,实盘前建议在策略测试器用真实点差回放验证拒绝率。

MQL5 / C++
if (MxSmb[m].smb2.name==smb3) MxSmb[m].smb2.tkt=tkt3;

if (MxSmb[m].smb3.name==smb1) MxSmb[m].smb3.tkt=tkt1;
if (MxSmb[m].smb3.name==smb2) MxSmb[m].smb3.tkt=tkt2;
if (MxSmb[m].smb3.name==smb3) MxSmb[m].smb3.tkt=tkt3;

count++;
class="kw">break;
 }class=class="str">"cmt">//for m
 }class=class="str">"cmt">//for k
 }class=class="str">"cmt">//for j
 }class=class="str">"cmt">//for i
 class="kw">break;
 class="kw">default:
 class="kw">break;
}

if (count>class="num">0) Print("Restore "+(class="type">class="kw">string)count+" triangles");
 }
 ctrade.SetDeviationInPoints(DEVIATION);
 ctrade.SetTypeFilling(ORDER_FILLING_FOK);
 ctrade.SetAsyncMode(true);
 ctrade.LogLevel(LOG_LEVEL_NO);

 EventSetTimer(class="num">1);

◍ 分笔报价里的三角额度闸门

在 OnTick 里第一件事不是算偏离,而是先确认设置里有没有限制最大同开三角数。若 inMaxThree>0 且已开数量触顶,这一 tick 的大部分逻辑直接跳过,避免无节制铺单。外汇与贵金属三角套利本身杠杆高、滑点凶,这种硬上限是控风险的第一道闸。 计数方式很直白:声明 ushort OpenThree=0,倒序扫 MxThree 数组,只要 status!=0 就累加。注意这里未平的单也计入,所以 OpenThree 反映的是「在场三角」而非「本 tick 新开」。 额度没满才进 fnCalcDelta()。这个函数不直接开仓,只负责算三角偏离并调用开单。被遗忘的两块是魔幻数字选取和三角下单,分别丢在 fnMagicGet.mqh 与 fnOpen.mqh 里,主文件只留调用。 开单后我不依赖 OnTrade / OnTradeTransaction——这两个事件在极端行情可能不回。改用当前 PositionTotal 做 100% 确定的持仓校验,再决定平单与日志。屏幕注释会打出跟踪三角总数、已开三角、最近五个开单符号,肉眼确认机器人还活着。

MQL5 / C++
    class="type">class="kw">ushort OpenThree=class="num">0;                        class=class="str">"cmt">// 开单的三角数量
    for(class="type">int j=ArraySize(MxThree)-class="num">1;j>=class="num">0;j--)
    if (MxThree[j].status!=class="num">0) OpenThree++;       class=class="str">"cmt">// 未平单的也被考虑在内
    
    if (inMaxThree==class="num">0 || (inMaxThree>class="num">0 && inMaxThree>OpenThree))
       fnCalcDelta(MxThree,inProfit,inCmnt,inMagic,inLot,inMaxThree,OpenThree);   class=class="str">"cmt">// 计算偏离并立即开单
class="type">void fnCalcDelta(stThree &MxSmb[],class="type">class="kw">double prft, class="type">class="kw">string cmnt, class="type">class="kw">ulong magic,class="type">class="kw">double lot, class="type">class="kw">ushort lcMaxThree, class="type">class="kw">ushort &lcOpenThree)
  {
    class="type">class="kw">double   temp=class="num">0;
    class="type">class="kw">string   cmnt_pos="";
    
    for(class="type">int i=ArraySize(MxSmb)-class="num">1;i>=class="num">0;i--)
    {class=class="str">"cmt">//for i
       class=class="str">"cmt">// 如果三角已激活, 则跳过它
       if(MxSmb[i].status!=class="num">0) class="kw">continue;
       
       class=class="str">"cmt">// 重新检查所有三个货币对的可用性: 如果至少有一个不可用,
       class=class="str">"cmt">// 那么计算整个三角就没有意义
       if (!fnSmbCheck(MxSmb[i].smb1.name)) class="kw">continue;  
       if (!fnSmbCheck(MxSmb[i].smb2.name)) class="kw">continue;  class=class="str">"cmt">// 其中一个货币对平单
       if (!fnSmbCheck(MxSmb[i].smb3.name)) class="kw">continue;    
       
       class=class="str">"cmt">// 在每次分笔报价伊始计算开单三角的数量,
       class=class="str">"cmt">// 但是它们也可在分笔报价之内开单。所以, 要持续跟踪它们的数量
       if (lcMaxThree>class="num">0) {if (lcMaxThree>lcOpenThree); else class="kw">continue;}
       
       class=class="str">"cmt">// 之后, 获取所有必要的数据进行计算。 
       
       class=class="str">"cmt">// 获取分笔报价的价格。
       if(!SymbolInfoDouble(MxSmb[i].smb1.name,SYMBOL_TRADE_TICK_VALUE,MxSmb[i].smb1.tv)) class="kw">continue;
       if(!SymbolInfoDouble(MxSmb[i].smb2.name,SYMBOL_TRADE_TICK_VALUE,MxSmb[i].smb2.tv)) class="kw">continue;
       if(!SymbolInfoDouble(MxSmb[i].smb3.name,SYMBOL_TRADE_TICK_VALUE,MxSmb[i].smb3.tv)) class="kw">continue;
       
       class=class="str">"cmt">// 获取当前价格。
       if(!SymbolInfoTick(MxSmb[i].smb1.name,MxSmb[i].smb1.tick)) class="kw">continue;
       if(!SymbolInfoTick(MxSmb[i].smb2.name,MxSmb[i].smb2.tick)) class="kw">continue;

「三角套利里的手数推导与成本闸门」

做三角套利时,第三对的成交量不能写死。前两只货币对的手数相同且固定,第三只随报价浮动;只有当输入 lot 大于 0 时才按中间价推算,否则退回最小相似手数。 推算逻辑借用了 EURUSD=EURGBP*GBPUSD 的关系:用第二对的(ask+bid)/2 乘第一对固定手数,再按第三对的小数位 NormalizeDouble。这样相当于把第二种货币的报价直接当成交量因子,省掉一层换算。 一旦算出的 lot 越界(小于 lot_min 或大于 lot_max),脚本会弹 Alert 把具体 Min/Max/Calc 三个值打印出来,并把该三角对 name 清空、continue 跳过。实盘前务必在 MT5 里把这三个边界和你经纪商的最小交易单位对一遍,外汇和贵金属杠杆高,边界算错可能直接废掉一整组三角。 成本侧先把点差转成整数点数:sppoint = NormalizeDouble(ask-bid, digits) * Rpoint,任一对小于等于 0 就跳过。接着乘每点金额 tv 和手数得到存款币种下的 spcost。点差和佣金是这套策略的天敌,任何省略点差的计算都会让回测利润虚高。 下面这段是核心校验与推算的原文,可直接贴进 EA 循环里对照: if(!SymbolInfoTick(MxSmb[i].smb3.name,MxSmb[i].smb3.tick)) continue; // 检查竞买价和竞卖价是否为 0。

if(MxSmb[i].smb1.tick.ask<=0MxSmb[i].smb1.tick.bid<=0MxSmb[i].smb2.tick.ask<=0MxSmb[i].smb2.tick.bid<=0MxSmb[i].smb3.tick.ask<=0MxSmb[i].smb3.tick.bid<=0) continue;

// 计算第三对的交易量。我们知道前两对的交易量 — 它相同且固定 // 第三对的交易量总是在变化。但只在初始变量中手数不为 0 时才会计算。 // 如果手数为零, 则使用最小 (相似) 交易量。 // 交易量计算逻辑很简单。我们回到我们的三角: EURUSD=EURGBP*GBPUSD。买入或卖出 GBP 数量 // 直接取决于 EURGBP 的报价, 而在第三个货币对中, 这第三种货币先到。通过使用第二个货币对的价格作为一个交易量, // 我们消减了一些计算。我已经取得了竞卖价和竞买价之间的平均价格。 // 不要忘记调整输入交易量。 if (lot>0) MxSmb[i].smb3.lot=NormalizeDouble((MxSmb[i].smb2.tick.ask+MxSmb[i].smb2.tick.bid)/2*MxSmb[i].smb1.lot,MxSmb[i].smb3.digits_lot); // 如果计算的交易量超过允许的边界, 通知用户。 // 三角标记为非操作

if (MxSmb[i].smb3.lot<MxSmb[i].smb3.lot_minMxSmb[i].smb3.lot>MxSmb[i].smb3.lot_max)

{ Alert("计算 ",MxSmb[i].smb3.name," 超界。Min/Max/Calc: ", DoubleToString(MxSmb[i].smb3.lot_min,MxSmb[i].smb3.digits_lot),"/", DoubleToString(MxSmb[i].smb3.lot_max,MxSmb[i].smb3.digits_lot),"/", DoubleToString(MxSmb[i].smb3.lot,MxSmb[i].smb3.digits_lot)); Alert("三角: "+MxSmb[i].smb1.name+" "+MxSmb[i].smb2.name+" "+MxSmb[i].smb3.name+" - 禁用"); MxSmb[i].smb1.name=""; continue; } // 计算我们的成本, 即点差+佣金。pr = 点差的整数形式点数。 // 点差妨碍我们使用这种策略赚钱, 因此, 应随时考虑到这一点。 // 您可用以点数为单位的点差替代差价乘以相反的点数。 MxSmb[i].smb1.sppoint=NormalizeDouble(MxSmb[i].smb1.tick.ask-MxSmb[i].smb1.tick.bid,MxSmb[i].smb1.digits)*MxSmb[i].smb1.Rpoint; MxSmb[i].smb2.sppoint=NormalizeDouble(MxSmb[i].smb2.tick.ask-MxSmb[i].smb2.tick.bid,MxSmb[i].smb2.digits)*MxSmb[i].smb2.Rpoint; MxSmb[i].smb3.sppoint=NormalizeDouble(MxSmb[i].smb3.tick.ask-MxSmb[i].smb3.tick.bid,MxSmb[i].smb3.digits)*MxSmb[i].smb3.Rpoint;

if (MxSmb[i].smb1.sppoint<=0MxSmb[i].smb2.sppoint<=0MxSmb[i].smb3.sppoint<=0) continue;

// 现在, 我们来计算存款币种的点差。 // 在货币中, 1 个分笔报价的价格总是等于 SYMBOL_TRADE_TICK_VALUE。 // 另外, 不要忘记交易量 MxSmb[i].smb1.spcost=MxSmb[i].smb1.sppoint*MxSmb[i].smb1.tv*MxSmb[i].smb1.lot; MxSmb[i].smb2.spcost=MxSmb[i].smb2.sppoint*MxSmb[i].smb2.tv*MxSmb[i].smb2.lot;

MQL5 / C++
if(!SymbolInfoTick(MxSmb[i].smb3.name,MxSmb[i].smb3.tick)) class="kw">continue;
class=class="str">"cmt">// 检查竞买价和竞卖价是否为 class="num">0。
if(MxSmb[i].smb1.tick.ask<=class="num">0 || MxSmb[i].smb1.tick.bid<=class="num">0 || MxSmb[i].smb2.tick.ask<=class="num">0 || MxSmb[i].smb2.tick.bid<=class="num">0 || MxSmb[i].smb3.tick.ask<=class="num">0 || MxSmb[i].smb3.tick.bid<=class="num">0) class="kw">continue;
class=class="str">"cmt">// 计算第三对的交易量。我们知道前两对的交易量 — 它相同且固定
class=class="str">"cmt">// 第三对的交易量总是在变化。但只在初始变量中手数不为 class="num">0 时才会计算。
class=class="str">"cmt">// 如果手数为零, 则使用最小 (相似) 交易量。
class=class="str">"cmt">// 交易量计算逻辑很简单。我们回到我们的三角: EURUSD=EURGBP*GBPUSD。买入或卖出 GBP 数量
class=class="str">"cmt">// 直接取决于 EURGBP 的报价, 而在第三个货币对中, 这第三种货币先到。通过使用第二个货币对的价格作为一个交易量,
class=class="str">"cmt">// 我们消减了一些计算。我已经取得了竞卖价和竞买价之间的平均价格。
class=class="str">"cmt">// 不要忘记调整输入交易量。
if (lot>class="num">0)
 MxSmb[i].smb3.lot=NormalizeDouble((MxSmb[i].smb2.tick.ask+MxSmb[i].smb2.tick.bid)/class="num">2*MxSmb[i].smb1.lot,MxSmb[i].smb3.digits_lot);
class=class="str">"cmt">// 如果计算的交易量超过允许的边界, 通知用户。
class=class="str">"cmt">// 三角标记为非操作
if (MxSmb[i].smb3.lot<MxSmb[i].smb3.lot_min || MxSmb[i].smb3.lot>MxSmb[i].smb3.lot_max)
 {
  Alert("计算 ",MxSmb[i].smb3.name," 超界。Min/Max/Calc: ",
  DoubleToString(MxSmb[i].smb3.lot_min,MxSmb[i].smb3.digits_lot),"/",
  DoubleToString(MxSmb[i].smb3.lot_max,MxSmb[i].smb3.digits_lot),"/",
  DoubleToString(MxSmb[i].smb3.lot,MxSmb[i].smb3.digits_lot));
  Alert("三角: "+MxSmb[i].smb1.name+" "+MxSmb[i].smb2.name+" "+MxSmb[i].smb3.name+" - 禁用");
  MxSmb[i].smb1.name="";  
  class="kw">continue;  
 }
class=class="str">"cmt">// 计算我们的成本, 即点差+佣金。pr = 点差的整数形式点数。
class=class="str">"cmt">// 点差妨碍我们使用这种策略赚钱, 因此, 应随时考虑到这一点。 
class=class="str">"cmt">// 您可用以点数为单位的点差替代差价乘以相反的点数。
MxSmb[i].smb1.sppoint=NormalizeDouble(MxSmb[i].smb1.tick.ask-MxSmb[i].smb1.tick.bid,MxSmb[i].smb1.digits)*MxSmb[i].smb1.Rpoint;
MxSmb[i].smb2.sppoint=NormalizeDouble(MxSmb[i].smb2.tick.ask-MxSmb[i].smb2.tick.bid,MxSmb[i].smb2.digits)*MxSmb[i].smb2.Rpoint;
MxSmb[i].smb3.sppoint=NormalizeDouble(MxSmb[i].smb3.tick.ask-MxSmb[i].smb3.tick.bid,MxSmb[i].smb3.digits)*MxSmb[i].smb3.Rpoint;
if (MxSmb[i].smb1.sppoint<=class="num">0 || MxSmb[i].smb2.sppoint<=class="num">0 || MxSmb[i].smb3.sppoint<=class="num">0) class="kw">continue;
class=class="str">"cmt">// 现在, 我们来计算存款币种的点差。 
class=class="str">"cmt">// 在货币中, class="num">1 个分笔报价的价格总是等于 SYMBOL_TRADE_TICK_VALUE。
class=class="str">"cmt">// 另外, 不要忘记交易量
MxSmb[i].smb1.spcost=MxSmb[i].smb1.sppoint*MxSmb[i].smb1.tv*MxSmb[i].smb1.lot;
MxSmb[i].smb2.spcost=MxSmb[i].smb2.sppoint*MxSmb[i].smb2.tv*MxSmb[i].smb2.lot;

三角套利的净收益与保证金闸门

三角套利里,光看到 eurusd 买价小于 eurgbp 买价乘 gbpusd 买价还不够,这种价格倒挂现象在 EUR 相关货币对中频繁出现,但多数时候点差就把空间吃光了。代码里先把三个品种各自的点差成本算清,再汇总成组合总 spread,作为入场的硬门槛。 净收益的计算把每个报价都按不利方向减了 dev 滑点:买向用后两个买价之积减第一个卖价,卖向用第一个买价减后两个卖价之积,再乘第一个货币对的点值交易量 temp。这样得到的 PLBuy / PLSell 是扣除最坏滑点后的点数折币值,随后 NormalizeDouble 到两位小数方便比对。 别把价格倒挂当信号 倒挂只是必要条件,PLBuy 或 PLSell 必须严格大于 spread 才有入场意义,否则就是给经纪商打工。 过了收益关还要过保证金关。脚本对三个品种分别用 OrderCalcMargin 算买入保证金,取较高者,再乘递增因子 CF(默认 20%)留缓冲,最后看 ACCOUNT_MARGIN_FREE 是否覆盖。外汇与贵金属三角套利杠杆高,保证金不足时硬开仓可能触发强平,属高风险操作。

MQL5 / C++
MxSmb[i].smb3.spcost=MxSmb[i].smb3.sppoint*MxSmb[i].smb3.tv*MxSmb[i].smb3.lot;
class=class="str">"cmt">// 那么, 这里是指定交易量加上用户指定佣金后的成本
MxSmb[i].spread=MxSmb[i].smb1.spcost+MxSmb[i].smb2.spcost+MxSmb[i].smb3.spcost+prft;
class=class="str">"cmt">// 我们可以跟踪投资组合的竞卖价<竞买价的情况, 但这种情况很少见 
class=class="str">"cmt">// 且可单独考虑。同时, 时间上的套利空间也能够处理这样的情况。
class=class="str">"cmt">// 仓位是没有风险的, 这就是为什么: 假设您已经买入了 eurusd,
class=class="str">"cmt">// 并立即抛售 eurgbp 和 gbpusd。 
class=class="str">"cmt">// 换言之, 我们看到 eurusd 竞买价 < eurgbp 竞买价 * gbpusd 竞买价。这种情况很多, 但是这对于一个成功的入场是不够的。
class=class="str">"cmt">// 计算点差成本。当竞卖价<竞买价时, 不要机械地入场, 而要等到它们之间的差值
class=class="str">"cmt">// 超过点差成本。 

class=class="str">"cmt">// 我们同意买入意味着买入第一个品种, 然后卖出剩下的两个品种,
class=class="str">"cmt">// 而卖出意味着卖出第一个货币双并买入其余的两个货币双。

temp=MxSmb[i].smb1.tv*MxSmb[i].smb1.Rpoint*MxSmb[i].smb1.lot;

class=class="str">"cmt">// 我们仔细看看计算公式。 
class=class="str">"cmt">// class="num">1. 在括号内, 每个价格都针对较差方向的滑点进行了调整: MxSmb[i].smb2.tick.bid-MxSmb[i].smb2.dev
class=class="str">"cmt">// class="num">2. 如上面等式所示, eurgbp 竞买价 * gbpusd 竞买价 - 乘以第二和第三个品种的价格:
class=class="str">"cmt">//    (MxSmb[i].smb2.tick.bid-MxSmb[i].smb2.dev)*(MxSmb[i].smb3.tick.bid-MxSmb[i].smb3.dev)
class=class="str">"cmt">// class="num">3. 然后, 计算竞卖价和竞买价之间的差值
class=class="str">"cmt">// class="num">4. 我们收到的以点数为单位的差价现在应该转换为币值: 乘以 
class=class="str">"cmt">// 点数价格和交易量。取第一个货币对的数值。 
class=class="str">"cmt">// 如果我们通过把所有的货币对放在一侧来构建一个三角, 并与 class="num">1 进行比较, 就会有更多的计算。 
MxSmb[i].PLBuy=((MxSmb[i].smb2.tick.bid-MxSmb[i].smb2.dev)*(MxSmb[i].smb3.tick.bid-MxSmb[i].smb3.dev)-(MxSmb[i].smb1.tick.ask+MxSmb[i].smb1.dev))*temp;
MxSmb[i].PLSell=((MxSmb[i].smb1.tick.bid-MxSmb[i].smb1.dev)-(MxSmb[i].smb2.tick.ask+MxSmb[i].smb2.dev)*(MxSmb[i].smb3.tick.ask+MxSmb[i].smb3.dev))*temp;

class=class="str">"cmt">// 我们已得到买入或卖出三角后可盈利或亏损的汇总计算。 
class=class="str">"cmt">// 现在, 我们只需将其与成本进行比较, 以便做出是否入场交易的决策。我们将所有数值均常规化到小数点后两位。
MxSmb[i].PLBuy=   NormalizeDouble(MxSmb[i].PLBuy,class="num">2);
MxSmb[i].PLSell=  NormalizeDouble(MxSmb[i].PLSell,class="num">2);
MxSmb[i].spread=  NormalizeDouble(MxSmb[i].spread,class="num">2);

class=class="str">"cmt">// 如果有潜在的利润, 检查资金是否足够开单。 
if (MxSmb[i].PLBuy>MxSmb[i].spread || MxSmb[i].PLSell>MxSmb[i].spread)
{
  class=class="str">"cmt">// 我只简单计算了入场买入的保证金。由于它比卖出稍高, 我们不必考虑交易方向。  
  class=class="str">"cmt">// 还要注意递增因子。如果保证金不足, 我们不能为三角开单。默认的递增因子是 class="num">20%
  if(OrderCalcMargin(ORDER_TYPE_BUY,MxSmb[i].smb1.name,MxSmb[i].smb1.lot,MxSmb[i].smb1.tick.ask,MxSmb[i].smb1.mrg))
  if(OrderCalcMargin(ORDER_TYPE_BUY,MxSmb[i].smb2.name,MxSmb[i].smb2.lot,MxSmb[i].smb2.tick.ask,MxSmb[i].smb2.mrg))
  if(OrderCalcMargin(ORDER_TYPE_BUY,MxSmb[i].smb3.name,MxSmb[i].smb3.lot,MxSmb[i].smb3.tick.ask,MxSmb[i].smb3.mrg))
  if(AccountInfoDouble(ACCOUNT_MARGIN_FREE)>((MxSmb[i].smb1.mrg+MxSmb[i].smb2.mrg+MxSmb[i].smb3.mrg)*CF)) class=class="str">"cmt">//check the free margin

◍ 魔幻数字耗尽时如何优雅退单

三角套利 EA 在真正发单前,要先从预设区间里捞一个没被占用的魔幻数字。默认起点是 inMagic=300,可用区间宽度由 MAGIC 宏控制,出厂值 200,也就是 300~499 这 200 个号轮流用。 核心分配逻辑在 fnMagicGet 里:它不从已开三角数组倒查,而是直接按 magic 到 magic+MAGIC 遍历候选号,再拿每个候选号去比对数组里已有三角的 magic 字段。一旦某个号没撞车就返回,撞车就继续往下找。 如果循环跑完返回 0,说明 200 个号全被占用。主流程里会 Print("可用魔幻数字结束\n新三角不会开单") 然后 break,这一轮三角直接放弃开单,不会硬塞。 拿到合法 magic 后,ctrade.SetExpertMagicNumber 立刻绑定,注释里拼上号和“开单”二字,timeopen 记下调用的 TimeCurrent。这里有个隐藏风控:MAXTIMEWAIT 默认 3 秒,若 3 秒内没把三角三条腿全开齐,已开的腿会被平掉,避免半截三角裸奔。 开单条件很直白——PLBuy 或 PLSell 大于 spread 才调 fnOpen,且只在这俩分支里触发。最后 status==1 时打印三角组合名和 magic,方便在 MT5 专家日志里对账。

MQL5 / C++
{
   class=class="str">"cmt">// 我们几乎已为开单做好了各种准备。现在只需从我们的范围内找到一个可用的魔幻数字。
   class=class="str">"cmt">// 初始的魔幻数字是在 inMagic 变量中指定的。默认值是 class="num">300。
   class=class="str">"cmt">// 魔幻数字的范围在 MAGIC 定义中指定, 默认值是 class="num">200。
   MxSmb[i].magic=fnMagicGet(MxSmb,magic);
   if (MxSmb[i].magic<=class="num">0)
   { class=class="str">"cmt">// 若为 class="num">0, 则所有的魔幻数字都被占用。通知此消息并退出。
     Print("可用魔幻数字结束\n新三角不会开单");
     class="kw">break;
   }

   class=class="str">"cmt">// 设置检测到的魔幻数字
   ctrade.SetExpertMagicNumber(MxSmb[i].magic);

   class=class="str">"cmt">// 写一个三角的注释
   cmnt_pos=cmnt+(class="type">class="kw">string)MxSmb[i].magic+" 开单";

   class=class="str">"cmt">// 开单, 同时记住三角开单的时间。
   class=class="str">"cmt">// 这是避免等待所必需的。
   class=class="str">"cmt">// 默认情况下, 完全开单的等待时间在 MAXTIMEWAIT 定义中设置为 class="num">3 秒。
   class=class="str">"cmt">// 如果在这段时间内我们未能完全开单, 则所有已开单均要平单。

   MxSmb[i].timeopen=TimeCurrent();

   if (MxSmb[i].PLBuy>MxSmb[i].spread)   fnOpen(MxSmb,i,cmnt_pos,true,lcOpenThree);
   if (MxSmb[i].PLSell>MxSmb[i].spread)  fnOpen(MxSmb,i,cmnt_pos,false,lcOpenThree);

   class=class="str">"cmt">// 打印有关三角开单的消息。
   if (MxSmb[i].status==class="num">1) Print("开单三角: "+MxSmb[i].smb1.name+" + "+MxSmb[i].smb2.name+" + "+MxSmb[i].smb3.name+" 魔幻数字: "+(class="type">class="kw">string)MxSmb[i].magic);
   }
   }
   }class=class="str">"cmt">//for i
 }

class="type">class="kw">ulong fnMagicGet(stThree &MxSmb[],class="type">class="kw">ulong magic)
  {
    class="type">int mxsize=ArraySize(MxSmb);
    class="type">bool find;

    class=class="str">"cmt">// 我们可以遍历数组中的所有开单三角。
    class=class="str">"cmt">// 但我已选择了另外一个选项 - 遍历魔幻数字的范围,
    class=class="str">"cmt">// 然后沿数组移动选定的一个。
    for(class="type">class="kw">ulong i=magic;i<magic+MAGIC;i++)
    {
      find=false;

      class=class="str">"cmt">// 魔幻数字 i。我们来看看它是否被分配到了任何一个已开单三角。
      for(class="type">int j=class="num">0;j<mxsize;j++)

「三角套利的原子开单与断点平仓」

三角套利机器人不能接受「部分成交」的尴尬状态。fnOpen 里先卡死交易权限与连接:IsTradeAllowed() 和 IsConnected() 任一为假直接 return(false),避免在无授权或掉线时乱发单。 开多分支(side=true)先对 smb1 做 Buy,成交后 status 置 1、opt 自增,再链式尝试 Sell smb2、Sell smb3;若 smb1 未成,openflag 保持 false,本次三个货币对全部放弃,等下一 tick 重投。外汇与贵金属市场滑点频繁,这种「全有或全无」逻辑能压住半残三角仓的风控敞口。 前面循环里的 find 标志也关键:当 MxSmb[j].status>0 且 magic 匹配时 find=true 并 break;若扫完没找到同 magic 活动单,直接 return(i) 让上层知道该序号空闲可开。 别把部分开单当正常状态 源码对 smb2/smb3 的发送并未判断返回值,注释已写明:任一腿没跟上,就等 MAXTIMEWAIT 再把残腿平掉。实盘里你应把这段等待平仓的补全逻辑在 fnOpenCheck 中接上,否则 opt 计数虚高、仓位却不对。

MQL5 / C++
if (MxSmb[j].status>class="num">0 && MxSmb[j].magic==i)
  {
    find=true;
    class="kw">break;  
  }  
  
  class=class="str">"cmt">// 如果不使用魔幻数字, 则不等待其完成即退出循环。   
  if (!find) class="kw">return(i);        
  }
  class="kw">return(class="num">0);  
 }
class="type">bool fnOpen(stThree &MxSmb[],class="type">int i,class="type">class="kw">string cmnt,class="type">bool side, class="type">class="kw">ushort &opt)
  {
  class=class="str">"cmt">// 首个订单开单标志。 
   class="type">bool openflag=false;
  
  class=class="str">"cmt">// 无授权则不能交易。 
   if (!cterm.IsTradeAllowed())  class="kw">return(false);
   if (!cterm.IsConnected())     class="kw">return(false);
  
   class="kw">switch(side)
  {
    case  true:
  
   class=class="str">"cmt">// 如果在发送开单后返回 "假", 则发送剩余的两个货币对没有意义。
class=class="str">"cmt">// 我们在下一次分笔报价时再次尝试。另外, 机器人不能部分进行三角开单。
class=class="str">"cmt">// 如果某些部分在发送订单后未开单, 等待
   class=class="str">"cmt">// MAXTIMEWAIT 定义中设置的时间, 然后将部分开单的三角平单。 
    if(ctrade.Buy(MxSmb[i].smb1.lot,MxSmb[i].smb1.name,class="num">0,class="num">0,class="num">0,cmnt))
    {
      openflag=true;
      MxSmb[i].status=class="num">1;
      opt++;
      class=class="str">"cmt">// 进一步的逻辑是相同的: 如果无法开单, 则该三角平单。 
     if(ctrade.Sell(MxSmb[i].smb2.lot,MxSmb[i].smb2.name,class="num">0,class="num">0,class="num">0,cmnt))
      ctrade.Sell(MxSmb[i].smb3.lot,MxSmb[i].smb3.name,class="num">0,class="num">0,class="num">0,cmnt);          
    }           
     class="kw">break;
    case  false:
  
    if(ctrade.Sell(MxSmb[i].smb1.lot,MxSmb[i].smb1.name,class="num">0,class="num">0,class="num">0,cmnt))
    {
      openflag=true;
      MxSmb[i].status=class="num">1;  
      opt++;     
      if(ctrade.Buy(MxSmb[i].smb2.lot,MxSmb[i].smb2.name,class="num">0,class="num">0,class="num">0,cmnt))
      ctrade.Buy(MxSmb[i].smb3.lot,MxSmb[i].smb3.name,class="num">0,class="num">0,class="num">0,cmnt);     
    }         
     class="kw">break;
  }     
   class="kw">return(openflag);
  }
class="type">void fnOpenCheck(stThree &MxSmb[], class="type">int accounttype, class="type">int fh)
  {

三角套利挂单的超时与持仓核验

在三角套利逻辑里,状态为 1 的三角代表已经发出开仓指令、正等待成交。如果市场流动性差或滑点异常,可能出现部分腿始终没成交的情况,必须靠超时机制兜底。 代码用 MAXTIMEWAIT 控制最长等待:一旦 TimeCurrent()-timeopen 超过该阈值,就把三角 status 置为 3(准备平仓),并打印未正确开单的品种组合,方便你直接在 MT5 专家日志里抓异常。 对零售对冲账户(ACCOUNT_MARGIN_MODE_RETAIL_HEDGING),需要遍历 PositionsTotal() 所有未平仓位,用 magic 号匹配属于该三角的订单。每命中 smb1/smb2/smb3 中一个品种,cnt 加 1 并回写 ticket 与开仓价;当 cnt 累计到 3,说明三条腿齐全,三角状态才切到 2(已开单)。 实盘里建议把 MAXTIMEWAIT 设成比你经纪商平均成交延迟大一个数量级的值(例如 30~60 秒),否则正常滑点也会误触发平仓。外汇与贵金属三角套利杠杆高、跨品种腿不同步风险大,未成交腿带来的残余敞口可能快速扩大。

MQL5 / C++
class="type">uchar cnt=class="num">0;       class=class="str">"cmt">// 三角中开仓计数器
class="type">class="kw">ulong  tkt=class="num">0;      class=class="str">"cmt">// 当前单号
class="type">class="kw">string smb="";      class=class="str">"cmt">// 当前品种

class=class="str">"cmt">// 检查我们的三角阵列
for(class="type">int i=ArraySize(MxSmb)-class="num">1;i>=class="num">0;i--)
{
   class=class="str">"cmt">// 只考虑具有状态 class="num">1 的三角, 即被送出用于开单
   if(MxSmb[i].status!=class="num">1) class="kw">continue;

   if ((TimeCurrent()-MxSmb[i].timeopen)>MAXTIMEWAIT)
   {  
      class=class="str">"cmt">// 如果超出开单时间, 请将三角标记为准备平单      
      MxSmb[i].status=class="num">3;
      Print("未正确开单: "+MxSmb[i].smb1.name+" + "+MxSmb[i].smb2.name+" + "+MxSmb[i].smb3.name);
      class="kw">continue;
   }

   cnt=class="num">0;

   class="kw">switch(accounttype)
   {
      case  ACCOUNT_MARGIN_MODE_RETAIL_HEDGING:

      class=class="str">"cmt">// 检查所有未结仓位。针对每个三角执行此检查。 
      for(class="type">int j=PositionsTotal()-class="num">1;j>=class="num">0;j--)
      if (PositionSelectByTicket(PositionGetTicket(j)))
      if (PositionGetInteger(POSITION_MAGIC)==MxSmb[i].magic)
      {
         class=class="str">"cmt">// 获取品种并考虑仓位的单号。
         tkt=PositionGetInteger(POSITION_TICKET);
         smb=PositionGetString(POSITION_SYMBOL);

         class=class="str">"cmt">// 检查在所考虑的三角中是否有我们需要的当前仓位。 
         class=class="str">"cmt">// 如果是, 增加计数, 并记住单号和开单价格。 
         if (smb==MxSmb[i].smb1.name){ cnt++;   MxSmb[i].smb1.tkt=tkt;  MxSmb[i].smb1.price=PositionGetDouble(POSITION_PRICE_OPEN);} else
         if (smb==MxSmb[i].smb2.name){ cnt++;   MxSmb[i].smb2.tkt=tkt;  MxSmb[i].smb2.price=PositionGetDouble(POSITION_PRICE_OPEN);} else
         if (smb==MxSmb[i].smb3.name){ cnt++;   MxSmb[i].smb3.tkt=tkt;  MxSmb[i].smb3.price=PositionGetDouble(POSITION_PRICE_OPEN);}

         class=class="str">"cmt">// 如果有三个必需的仓位, 我们的三角已经成功开单。将其状态更改为 class="num">2 (开单)。
         class=class="str">"cmt">// 将开单数据写入日志文件
      }
   }
}

◍ 三单齐平后落盘记录什么

当组合计数器 cnt 累到 3,说明三 leg 都已就位,代码把 status 置为 2 并调用 fnControlFile 写盘,随后 break 退出循环。这套动作意味着该三腿结构已完成建仓判定,进入文件留痕阶段。 fnControlFile 接收结构体数组引用、下标 i 与文件句柄 fh,用 FileWrite 逐行落账。它先写分隔线‘============’,再依次输出三腿的名称、单号、手数(按各自 digits_lot 精度)、保证金(固定 2 位小数)、ask/bid、开单价、点值、点差点数(0 位)、点差美元(3 位)、总点差、买卖盈亏(3 位)、魔幻数字与开单时间(日期+秒)。 在 MT5 里跑这套,你能直接打开生成的 txt 核对每腿的 spread cost 是否如预期——比如某腿 spcost 显示 0.375 而总 spread 为 1.120,就说明另两腿合计吞了 0.745。外汇与贵金属杠杆高,点差侵蚀可能快速放大,留这种原始账是本地的第一道校验。

MQL5 / C++
    if(cnt==class="num">3)
    {
      MxSmb[i].status=class="num">2;
      fnControlFile(MxSmb,i,fh);
      class="kw">break;  
    }
      class="kw">break;
      class="kw">default:
      class="kw">break;
    }
  }
class="type">void fnControlFile(stThree &MxSmb[],class="type">int i, class="type">int fh)
  {
    FileWrite(fh,"============");
    FileWrite(fh,"开单:",MxSmb[i].smb1.name,MxSmb[i].smb2.name,MxSmb[i].smb3.name);
    FileWrite(fh,"单号:",MxSmb[i].smb1.tkt,MxSmb[i].smb2.tkt,MxSmb[i].smb3.tkt);
    FileWrite(fh,"手数",DoubleToString(MxSmb[i].smb1.lot,MxSmb[i].smb1.digits_lot),DoubleToString(MxSmb[i].smb2.lot,MxSmb[i].smb2.digits_lot),DoubleToString(MxSmb[i].smb3.lot,MxSmb[i].smb3.digits_lot));
    FileWrite(fh,"Margin",DoubleToString(MxSmb[i].smb1.mrg,class="num">2),DoubleToString(MxSmb[i].smb2.mrg,class="num">2),DoubleToString(MxSmb[i].smb3.mrg,class="num">2));
    FileWrite(fh,"竞卖价",DoubleToString(MxSmb[i].smb1.tick.ask,MxSmb[i].smb1.digits),DoubleToString(MxSmb[i].smb2.tick.ask,MxSmb[i].smb2.digits),DoubleToString(MxSmb[i].smb3.tick.ask,MxSmb[i].smb3.digits));
    FileWrite(fh,"竞买价",DoubleToString(MxSmb[i].smb1.tick.bid,MxSmb[i].smb1.digits),DoubleToString(MxSmb[i].smb2.tick.bid,MxSmb[i].smb2.digits),DoubleToString(MxSmb[i].smb3.tick.bid,MxSmb[i].smb3.digits));      
    FileWrite(fh,"开单价格",DoubleToString(MxSmb[i].smb1.price,MxSmb[i].smb1.digits),DoubleToString(MxSmb[i].smb2.price,MxSmb[i].smb2.digits),DoubleToString(MxSmb[i].smb3.price,MxSmb[i].smb3.digits));
    FileWrite(fh,"点值",DoubleToString(MxSmb[i].smb1.tv,MxSmb[i].smb1.digits),DoubleToString(MxSmb[i].smb2.tv,MxSmb[i].smb2.digits),DoubleToString(MxSmb[i].smb3.tv,MxSmb[i].smb3.digits));
    FileWrite(fh,"点差点数",DoubleToString(MxSmb[i].smb1.sppoint,class="num">0),DoubleToString(MxSmb[i].smb2.sppoint,class="num">0),DoubleToString(MxSmb[i].smb3.sppoint,class="num">0));
    FileWrite(fh,"点差 $",DoubleToString(MxSmb[i].smb1.spcost,class="num">3),DoubleToString(MxSmb[i].smb2.spcost,class="num">3),DoubleToString(MxSmb[i].smb3.spcost,class="num">3));
    FileWrite(fh,"所有点差",DoubleToString(MxSmb[i].spread,class="num">3));
    FileWrite(fh,"买入盈亏",DoubleToString(MxSmb[i].PLBuy,class="num">3));
    FileWrite(fh,"卖出盈亏",DoubleToString(MxSmb[i].PLSell,class="num">3));     
    FileWrite(fh,"魔幻数字",class="type">class="kw">string(MxSmb[i].magic));
    FileWrite(fh,"开单时间",TimeToString(MxSmb[i].timeopen,TIME_DATE|TIME_SECONDS));

「三角持仓盈利核算与平仓触发逻辑」

在三角套利 EA 里,fnCalcPL 负责遍历已部分或完全开仓的三角组合并实时核算浮动盈亏。函数先取 IsTradeAllowed 与 IsConnected 的与值作为 flag,仅当交易环境就绪时才继续处理,避免离线或禁交易状态下误判。 循环从数组末尾向前扫,只关注 status 为 2(已开单)或 3(已平单)的元素;其余状态直接 continue 跳过。对 status==2 的三角,先把 pl 清零,再按账户类型汇总三张单的 POSITION_PROFIT,零售对冲账户下用 PositionSelectByTicket 依次选中 smb1~smb3 的票号并累加。 盈利累加后用 NormalizeDouble(...,2) 四舍五入到小数点后两位,方便日志与阈值比较。作者明确:三角套利利润以点数计,不能指望大走势;当点差和佣金被抹平、即 pl 覆盖成本后,就应将仓位标记为“送出平单”,因为套利机会本身异常且不可持续,拖久反而可能回吐。 外汇与贵金属三角套利受点差、滑点、经纪商限制影响极大,实盘触发平仓的时机可能偏离回测,请先在 MT5 策略测试器用真实点差验证再上资金。

MQL5 / C++
class="type">void fnCalcPL(stThree &MxSmb[], class="type">int accounttype, class="type">class="kw">double prft)
  {
  class=class="str">"cmt">// 再次遍历我们的三角数组。 
  class=class="str">"cmt">// 开单和平单的速度是这一策略的重要组成部分。 
  class=class="str">"cmt">// 因此, 只要我们发现已平单的三角, 立即将之平单。
  
  class="type">bool flag=cterm.IsTradeAllowed() & cterm.IsConnected();      
  
  for(class="type">int i=ArraySize(MxSmb)-class="num">1;i>=class="num">0;i--)
  {class=class="str">"cmt">//for
     class=class="str">"cmt">// 我们只对状态为 class="num">2 或 class="num">3 的三角感兴趣。
     class=class="str">"cmt">// 如果三角只是部分开单, 我们会得到状态 class="num">3 (三角平单)
     if(MxSmb[i].status==class="num">2 || MxSmb[i].status==class="num">3); else class="kw">continue;                              
     
     class=class="str">"cmt">// 我们来计算三角形的盈利 
     if (MxSmb[i].status==class="num">2)
     {
     MxSmb[i].pl=class="num">0;         class=class="str">"cmt">// 盈利清零
     class="kw">switch(accounttype)
     {class=class="str">"cmt">//class="kw">switch
       case  ACCOUNT_MARGIN_MODE_RETAIL_HEDGING:  
       
       if (PositionSelectByTicket(MxSmb[i].smb1.tkt)) MxSmb[i].pl=PositionGetDouble(POSITION_PROFIT);
       if (PositionSelectByTicket(MxSmb[i].smb2.tkt)) MxSmb[i].pl+=PositionGetDouble(POSITION_PROFIT);
       if (PositionSelectByTicket(MxSmb[i].smb3.tkt)) MxSmb[i].pl+=PositionGetDouble(POSITION_PROFIT);                 
       class="kw">break;
       class="kw">default:
       class="kw">break;
     }class=class="str">"cmt">//class="kw">switch
     
     class=class="str">"cmt">// 四舍五入到小数点后两位
     MxSmb[i].pl=NormalizeDouble(MxSmb[i].pl,class="num">2);
     
     class=class="str">"cmt">// 我们来近距离查看平单。我使用以下逻辑:
     class=class="str">"cmt">// 套利的情况不正常, 不应该发生。当它出现时, 我们可以期待返回 
     class=class="str">"cmt">// 到没有套利的状态。我们可以赚钱吗?换句话说, 我们不知道,
class=class="str">"cmt">// 是否我们能够继续获得利润。因此, 我希望在点差和佣金被抹平之后立即平仓。 
     class=class="str">"cmt">// 三角套利是以点数计算的。您不能在此依赖大走势。 
     class=class="str">"cmt">// 尽管您可以等待输入中 Commission 变量的期望利润。 
     class=class="str">"cmt">// 如果我们赚得比我们花费的多, 则将 "送出平单" 状态分配给该仓位。
     }
  }class=class="str">"cmt">//for
  }

三角套利平仓的防裂解校验

三角组合进入 status=3 后,真正动手平掉三个货币对之前,必须先逐个确认 smb1、smb2、smb3 都还在市场可交易。原文用 fnSmbCheck 连写三层 if,只要有一个货币对不可用就直接跳过平仓——这是刻意防「裂解三角」的硬约束。 在净持账户(netting)上若只平掉其中一两个腿,剩余仓位会和同标的其他头寸混在一起,后续对冲逻辑会彻底乱掉。外汇与贵金属三角套利本身杠杆高、滑点敏感,这种部分平仓引发的仓位污染属于极端危险操作,实盘前务必在策略测试器里跑通全腿校验。 目前代码只实现了 ACCOUNT_MARGIN_MODE_RETAIL_HEDGING 分支下的 ctrade.PositionClose 三连平,default 分支是空 break;如果你接的是净持账户,这段直接复用会静默不平仓,需要自己补 netting 下的单票平仓路径。 平仓后由 fnCloseCheck 倒序扫数组,只对 status==3 的项做 PositionSelectByTicket 三连查,三个票都选不到了才写回 status=0 并 Print 出三角名、魔幻数和保留两位小数的 P/L。这个「选不到 = 成功」的判定,比查交易返回值更绕但能避开异步平仓的时序坑。

MQL5 / C++
if(flag && MxSmb[i].pl>prft) MxSmb[i].status=class="num">3;
}

class=class="str">"cmt">// 仅在允许交易的情况下将三角平单。
if (flag && MxSmb[i].status==class="num">3) fnCloseThree(MxSmb,accounttype,i);
}class=class="str">"cmt">//for
 }
class="type">void fnCloseThree(stThree &MxSmb[], class="type">int accounttype, class="type">int i)
  {
   class=class="str">"cmt">// 在平单之前, 检查三角中所有货币对的可用性。 
   class=class="str">"cmt">// 裂解三角是错误的, 极端危险。在净持账户上操作时,
   class=class="str">"cmt">// 这可能会导致以后的仓位混乱。 
   
   if(fnSmbCheck(MxSmb[i].smb1.name))
   if(fnSmbCheck(MxSmb[i].smb2.name))
   if(fnSmbCheck(MxSmb[i].smb3.name))               
   
   class=class="str">"cmt">// 如果全部可用, 则使用标准库将全部三个仓位平仓。 
   class=class="str">"cmt">// 平仓后, 检查操作是否成功。 
   class="kw">switch(accounttype)
   {
    case  ACCOUNT_MARGIN_MODE_RETAIL_HEDGING:       
    ctrade.PositionClose(MxSmb[i].smb1.tkt);
    ctrade.PositionClose(MxSmb[i].smb2.tkt);
    ctrade.PositionClose(MxSmb[i].smb3.tkt);                
    class="kw">break;
    class="kw">default:
    class="kw">break;
   }   
  } 
class="type">void fnCloseCheck(stThree &MxSmb[], class="type">int accounttype,class="type">int fh)
  {
   class=class="str">"cmt">// 遍历三角数组。 
   for(class="type">int i=ArraySize(MxSmb)-class="num">1;i>=class="num">0;i--)
   {
    class=class="str">"cmt">// 我们只对那些状态为 class="num">3 的感兴趣, 即已平仓或送出平仓的那些。 
    if(MxSmb[i].status!=class="num">3) class="kw">continue;
    
    class="kw">switch(accounttype)
    {
     case  ACCOUNT_MARGIN_MODE_RETAIL_HEDGING:
     
     class=class="str">"cmt">// 如果从三角中不能选择一个货币对, 则平仓成功。返回状态 class="num">0
     if (!PositionSelectByTicket(MxSmb[i].smb1.tkt))
     if (!PositionSelectByTicket(MxSmb[i].smb2.tkt))
     if (!PositionSelectByTicket(MxSmb[i].smb3.tkt))
     {  class=class="str">"cmt">// 意味着平仓已成功
      MxSmb[i].status=class="num">0;  
      
      Print("三角平仓: "+MxSmb[i].smb1.name+" + "+MxSmb[i].smb2.name+" + "+MxSmb[i].smb3.name+" 魔幻数字: "+(class="type">class="kw">string)MxSmb[i].magic+"  P/L: "+DoubleToString(MxSmb[i].pl,class="num">2));
      
      class=class="str">"cmt">// 将平仓数据写入日志文件。 

◍ 平仓记录与图表注释的落地写法

三角套利平仓后,把关键字段落盘是最直接的复盘手段。下面这段逻辑在文件句柄有效时,向日志文件写入分隔线、三条腿的品种名、各自手数(按 digits_lot 精度)、ticket 单号、魔幻数字、浮动盈亏(保留 3 位小数)以及当前服务器时间(日期+秒),随后 FileFlush 强制刷盘,避免 MT5 缓冲导致漏记。 [CODE] if(fh!=INVALID_HANDLE) { FileWrite(fh,"============"); FileWrite(fh,"平单:",MxSmb[i].smb1.name,MxSmb[i].smb2.name,MxSmb[i].smb3.name); FileWrite(fh,"手数",DoubleToString(MxSmb[i].smb1.lot,MxSmb[i].smb1.digits_lot),DoubleToString(MxSmb[i].smb2.lot,MxSmb[i].smb2.digits_lot),DoubleToString(MxSmb[i].smb3.lot,MxSmb[i].smb3.digits_lot)); FileWrite(fh,"单号",string(MxSmb[i].smb1.tkt),string(MxSmb[i].smb2.tkt),string(MxSmb[i].smb3.tkt)); FileWrite(fh,"魔幻数字",string(MxSmb[i].magic)); FileWrite(fh,"盈利",DoubleToString(MxSmb[i].pl,3)); FileWrite(fh,"当前时间",TimeToString(TimeCurrent(),TIME_DATE|TIME_SECONDS)); FileFlush(fh); } [/CODE] 逐行拆解:第 1 行确认句柄非 INVALID_HANDLE;第 3 行写分隔线;第 4 行记录三条腿品种名;第 5 行用手数精度格式化输出各腿仓位;第 6 行转 string 写 ticket;第 7 行写 magic 便于多策略隔离;第 8 行 pl 保留 3 位;第 9 行记时间;第 10 行刷盘。外汇与贵金属三角组合流动性突变时,这套记录能帮你定位某次滑点发生在哪条腿。 图表端注释函数 fnCmnt 限制最多显示 5 个三角(max=5,用 MathMin 截断),遍历数组跳过 status!=0 的已平记录,把前 max 个未平仓索引塞进 index[]。屏幕文字含程序名、三角总计与开单三角数,老手可直接把 max 改成 10 看更多挂单上下文。

MQL5 / C++
if(fh!=INVALID_HANDLE)
  {
  FileWrite(fh,"============");
  FileWrite(fh,"平单:",MxSmb[i].smb1.name,MxSmb[i].smb2.name,MxSmb[i].smb3.name);
  FileWrite(fh,"手数",DoubleToString(MxSmb[i].smb1.lot,MxSmb[i].smb1.digits_lot),DoubleToString(MxSmb[i].smb2.lot,MxSmb[i].smb2.digits_lot),DoubleToString(MxSmb[i].smb3.lot,MxSmb[i].smb3.digits_lot));
  FileWrite(fh,"单号",class="type">class="kw">string(MxSmb[i].smb1.tkt),class="type">class="kw">string(MxSmb[i].smb2.tkt),class="type">class="kw">string(MxSmb[i].smb3.tkt));
  FileWrite(fh,"魔幻数字",class="type">class="kw">string(MxSmb[i].magic));
  FileWrite(fh,"盈利",DoubleToString(MxSmb[i].pl,class="num">3));
  FileWrite(fh,"当前时间",TimeToString(TimeCurrent(),TIME_DATE|TIME_SECONDS));
  FileFlush(fh);
  }

class="type">void fnCmnt(stThree &MxSmb[], class="type">class="kw">ushort lcOpenThree)
  {
  class="type">int total=ArraySize(MxSmb);
  class="type">class="kw">string line="=============================\n";
  class="type">class="kw">string txt=line+MQLInfoString(MQL_PROGRAM_NAME)+": ON\n";
  txt=txt+"三角总计: "+(class="type">class="kw">string)total+"\n";
  txt=txt+"开单三角: "+(class="type">class="kw">string)lcOpenThree+"\n"+line;
  class=class="str">"cmt">// 屏幕上显示的最大三角数量
  class="type">class="kw">short max=class="num">5;
  max=(class="type">class="kw">short)MathMin(total,max);
  class=class="str">"cmt">// 显示最近五个开单三角 
  class="type">class="kw">short index[];                    class=class="str">"cmt">// 索引数字
  ArrayResize(index,max);
  ArrayInitialize(index,-class="num">1);        class=class="str">"cmt">// 未使用
  class="type">class="kw">short cnt=class="num">0,num=class="num">0;
  class="kw">while(cnt<max && num<total)     class=class="str">"cmt">// 第一个最大平仓三角索引作为开始
  {
   if(MxSmb[num].status!=class="num">0)  {num++;class="kw">continue;}
   index[cnt]=num;
   num++;cnt++;           
  }
  

「三角组合超出屏显时的排序与输出」

当监控的三角组合总数 total 超过屏幕可容纳的 max 时,才进入排序与搜索逻辑,否则直接显示。这一判断避免了小样本下的无效计算,也保证了图表注释不会因条目过少而错位。 外层循环从 i=max 扫到 total-1,跳过 status 非 0(已占用或已平仓)的组合;内层对 j 从 0 到 max-1 比较 PLBuy 与 PLSell,若当前组合的买入或卖出盈亏更大,就把 index[j] 替换为 i 并 break,相当于把最显眼的三角塞进前 max 个展示位。 显示阶段用一个 flag 控制表头只打印一次,表头含品种1/2/3、买入盈亏、卖出盈亏、点差五列;随后按 index 顺序拼出每个组合的名称与 DoubleToString 保留两位小数的数值。最后逆序遍历 total,把 status==2(已开单)的三角连同 P/L 和开单时间用 TimeToString 格式化后追加,并调用 Comment 一次性输出到图表左上角。 在 MT5 里把 max 设成 10、total 跑到 50 以上,你能直观看到只有盈亏最极端的 10 组常驻屏显,其余开单记录沉在下方——外汇与贵金属点差跳动快,这种裁剪能降低盯盘时的信息过载,但高频切换品种仍属高风险操作。

MQL5 / C++
  class=class="str">"cmt">// 只有当元素的数量超过了屏幕上可以显示的数量时, 才能进行排序和搜索。 
   if (total>max)
   for(class="type">class="kw">short i=max;i<total;i++)
   {
     class=class="str">"cmt">// 开单三角显示在下面。
     if(MxSmb[i].status!=class="num">0) class="kw">continue;
     
     for(class="type">class="kw">short j=class="num">0;j<max;j++)
      {
       if (MxSmb[i].PLBuy>MxSmb[index[j]].PLBuy)  {index[j]=i;class="kw">break;}
       if (MxSmb[i].PLSell>MxSmb[index[j]].PLSell)  {index[j]=i;class="kw">break;}
      }  
   }
   
   class=class="str">"cmt">// 显示最近开单的三角。
   class="type">bool flag=true;
   for(class="type">class="kw">short i=class="num">0;i<max;i++)
   {
     cnt=index[i];
     if (cnt<class="num">0) class="kw">continue;
     if (flag)
     {
       txt=txt+"品种1          品种2          品种3          买入盈亏          卖出盈亏          点差\n";
       flag=false;
     }        
     txt=txt+MxSmb[cnt].smb1.name+" + "+MxSmb[cnt].smb2.name+" + "+MxSmb[cnt].smb3.name+":";
     txt=txt+"     "+DoubleToString(MxSmb[cnt].PLBuy,class="num">2)+"        "+DoubleToString(MxSmb[cnt].PLSell,class="num">2)+"        "+DoubleToString(MxSmb[cnt].spread,class="num">2)+"\n";       
   }       
   
   class=class="str">"cmt">// 显示开单三角。 
   txt=txt+line+"\n";
   for(class="type">int i=total-class="num">1;i>=class="num">0;i--)
   if (MxSmb[i].status==class="num">2)
   {
     txt=txt+MxSmb[i].smb1.name+"+"+MxSmb[i].smb2.name+"+"+MxSmb[i].smb3.name+" P/L: "+DoubleToString(MxSmb[i].pl,class="num">2);
     txt=txt+"  开单时间: "+TimeToString(MxSmb[i].timeopen,TIME_DATE|TIME_MINUTES|TIME_SECONDS);
     txt=txt+"\n";
   }   
   Comment(txt);

用分笔模拟照出多元测试器的距离感

在分笔报价模拟模式下跑一遍,再和真实分笔报价测试对照,能明显看出多元测试器离真实成交环境还有距离。更进一步拿真实分笔报价下的实际动作回测,结论倾向是:它目前仍无法完整还原现实。 回测里平均每周触发 3–4 次交易,多数挂在夜间,三角组合常带 TRY、NOK、SEK 这类低流动性货币。因交易频次低,EA 吃得下大交易量,也能跟别的机器人并行跑,不抢资源。 风险很好算:3 个点差乘以开单三角的数量,就是这趟策略暴露的刚性成本。外汇与贵金属属高风险品类,点差跳变时实际磨损可能偏离该估算。 测前要把品种池备好:先显示全部品种,再隐掉禁交易和非货币对项。多货币策略玩家常用一个脚本一键收尾(市场产品编号 25256)。 提醒一句:测试器历史不会从经纪商服务器自动拉取,得先手动传进客户端。这件事要么测前单独做,要么直接复用上面那个脚本,否则回测跑的是空历史。

◍ 流动性汇聚与算力加速的两条路

想进一步改善这套三角套利机器人的结果,一个直接方向是做流动性汇聚——把分散在不同经纪商的报价与深度并到一起,降低滑点损耗。代价也很现实:你需要在多个经纪商同时开户并打通接口,运维成本陡增。 回测速度本身也能榨出空间。第一步可引入离散计算,只持续跟踪那些入场概率明显偏高的三角组合,跳过大量低价值扫描。第二步是用 OpenCL 把计算丢到 GPU 上跑,对这类矩阵型套利逻辑非常合理,MT5 原生支持相关调用。 外汇与贵金属三角套利属高风险策略,多账户与并行计算只解决执行效率,不消除报价断裂与对手吃单风险,参数上线前务必在策略测试器用真实点差验证。

「三角套利 EA 的包含文件清单」

这套三角套利 EA 不是单个 mq5 文件能跑起来的,它由 19 个 .mqh 头文件协同组成,编译时必须把它们放在同一 Include 目录下。var.mqh 统管所有变量与输入参数,head.mqh 再把其余 18 个文件一次性挂进来,缺一个就会像评论区 Sibusiso 那样报编译错误。 具体分工上,fnSetThree.mqh 负责从市场观察或预存文件里挑三角货币对,fnCalcDelta.mqh 把点差、佣金、滑点全部算进成本,fnOpen.mqh 与 fnCloseThree.mqh 管开平,fnControlFile.mqh 写日志,fnRestart.mqh 在重启时恢复未平三角。Support.mqh 只干一件事:算分数小数位。 回测文件通过 tester_file 属性导出,代码就一行: #property tester_file FILENAME 意思是把测试产生的数据写到名为 FILENAME 的文件里,供 fnCreateFileSymbols.mqh 后续生成交易对文件。 外汇与贵金属三角套利涉及多单同开,点差滑点放大,真实账户未成交而模拟跑通的情况在评论区已多次出现,属高概率风险事件,动手前先用策略测试器跑一遍再上真实账户。

MQL5 / C++
class="macro">#class="kw">property tester_file FILENAME
把重复劳动交给小布
这些三角价差的诊断与多品种报价同步监控,小布盯盘的 AIGC 已内置,打开对应货币页就能看到实时偏离提醒,你只需判断要不要介入。

常见问题

因为要通过 GBPUSD 和 EURGBP 凑出等价的 EURUSD 多头,两步都在增加对应 base 货币并消减 quote,所以方向统一用竞卖价建等价敞口,再用竞买价一侧找反手盈利空间。
可以,小布盯盘内置了多品种价差偏离的 AIGC 看板,覆盖 EURUSD/GBPUSD/EURGBP 这类三角的竞买竞卖错位,打开品种页即可看到,不必自己写脚本轮询。
应在 OnTick 外设独立重连与状态复位逻辑,机器人重启后先清空未平等价仓再重新评估,避免残仓和新信号叠加成单边风险。
实盘流动性和点差扩张会让不平衡消失得更慢或反向,历史数据里的瞬时套利点在真实环境可能被滑点吃掉,概率上更常见的是被动等待收敛。
通常是 EA 源码、自定义报价结构头文件与测试用的历史片段,便于在策略测试器里复现三角逻辑并调整重启参数。