三角套利EA这个概念,在外汇量化圈子里流传了很久,但真正把它讲透、能直接落地为代码的文章并不多。我最早接触这个思路,是在研究高频交易和做市商机制的时候,后来用Java实现过一版完整的三角套利EA,跑过仿真、也上过实盘验证,期间踩了不少坑。这篇文章会把我的完整思路和核心代码拆开来讲,从套利的数学原理,到Java实现的架构设计,再到回测参数调优和实盘阶段的坑点,一条线走到底。适合已经会Java基础语法、想了解量化交易或者套利策略的开发者,也适合正在研究EA但苦于资料零散的朋友。
1. 三角套利的底层逻辑:无风险价差从哪里来
1.1 一个例子看懂三角套利
先别急着看代码,我们用最朴素的方式把三角套利这件事说清楚。
外汇交易市场是一个分散的全球市场,不同流动性提供商、不同交易平台之间的报价并不完全同步。假设你在平台上看到三个货币对:EUR/USD、GBP/USD、EUR/GBP。这三个货币对之间存在一个天然的数学关系——交叉汇率。
如果EUR/USD当前报价是1.1000,GBP/USD当前报价是1.2500,那么理论上EUR/GBP的价格就是1.1000除以1.2500,等于0.8800。这个0.8800不是哪家平台直接报出来的,而是通过前两个货币对推算出来的隐含价格。
现在有意思的事情来了。假如EUR/GBP在市场上的实际报价是0.8795,而理论上是0.8800,两者之间差了5个点。这个价差就意味着一个套利窗口:
- 先买入EUR/GBP,用0.8795的价格。
- 然后通过EUR/USD和GBP/USD的路径,以0.8800的理论价格反向卖出。
- 一买一卖之间,每一个单位就赚了0.0005的差价。
这个过程不需要判断行情涨跌,不承担方向性风险,赚的是市场内部报价不一致的钱,所以被称为"无风险套利"。但要注意,这个"无风险"要打引号——在实际交易中,点差、手续费、滑点和成交延迟都会侵蚀利润,处理不好照样亏钱。这一点我在后面第五部分会展开说。
1.2 三角套利与三角对冲,EA到底在做什么
标题里同时出现了"三角套利"和"三角对冲"两个词。严格来说,两者不完全是一回事。
三角套利的核心是"套取价差",三笔交易同时开仓、随后同时平仓,最终锁定一个无方向性风险的利润。它的本质是市场定价错误被纠正的过程。
三角对冲则更强调"对冲结构",即持有三个货币对的仓位,通过两两之间的关联性对冲掉大部分市场风险,保留的是一个价差收敛的收益。在外汇市场里,有人用它做均值回归,有人用它做波动率套利,形态更加多样。
但在EA开发的语境下,这两个词经常被混用,大家默认指的是用程序自动监控三个货币对之间的价格关系,一旦发现价差超过阈值就自动完成三笔对冲交易。本文会用"三角套利EA"这个叫法,同时你会发现,写成代码之后的核心逻辑同时适用于两种思路。
EA是Expert Advisor的缩写,直译是"专家顾问",其实就是跑在交易终端里的自动化交易机器人。传统EA大多用MQL4或MQL5编写,运行在MT4/MT5里面。但我选择用Java来写,原因很直接:
- Java跨平台,能部署在Linux服务器上,稳定性远高于Windows终端。
- Java的并发处理能力更强,三个货币对的报价是独立推送的,天然是多线程场景。
- Java生态丰富,无论对接数据库、消息队列、监控告警,还是写HTTP接口,都比MQL舒服太多。
当然,Java不能直接作为MT插件运行,通常是以独立进程配合桥接方式工作,或者直接对接券商提供的HTTP/WebSocket行情与交易API。不管用哪种方式,三角套利的算法核心是不变的。
2. 核心算法:路径枚举与价差计算
2.1 三条腿,六条路径
三角套利涉及三个货币对、三种货币。听起来简单,但排列组合之后一共有六种套利路径。以A、B、C代表三种货币,套利就是先从一个货币出发,经过两次兑换回到初始货币:
- A → B → C → A
- A → C → B → A
- B → A → C → B
- B → C → A → B
- C → A → B → C
- C → B → A → C
在实际的外汇市场里,货币对通常有三个方向:直接货币对AB、交叉货币对AC,以及另一个直接或交叉货币对BC。以EUR、GBP、USD为例,可交易的货币对是EUR/USD、GBP/USD、EUR/GBP,对应的六条路径会收缩成两条有效路径:
- 正套路径:EUR → USD → GBP → EUR,买入EUR/USD,再买入GBP/USD,最后卖出EUR/GBP。
- 反套路径:EUR → GBP → USD → EUR,先买入EUR/GBP,再卖出GBP/USD,最后卖出EUR/USD。
为什么要先做路径枚举?因为不同路径下,每一笔交易是买入还是卖出,决定了你是用ask价还是bid价来算价差。方向搞错,整个计算就是错的。
2.2 价差率计算的数学推导
假设我们要判断正套路径EUR → USD → GBP → EUR是否有利可图,也就是用美元作为中间媒介,最终换成欧元。
第一步:把1个单位EUR换成USD。此时用到的价格是EUR/USD的ask价,记为ask_eurusd。换到的USD数量是1 × ask_eurusd。
第二步:把USD换成GBP。这个时候要看GBP/USD的报价。因为我们需要用USD买入GBP,对应的是GBP/USD的ask价,记为ask_gbpusd。用USD换GBP就是除以这个价格,得到GBP数量是ask_eurusd ÷ ask_gbpusd。
第三步:把GBP换回EUR。这时用EUR/GBP的bid价,记为bid_eur_gbp。因为我们是卖出EUR/GBP(即用GBP换回EUR),得到EUR数量是(ask_eurusd ÷ ask_gbpusd) × bid_eur_gbp。
最终这一轮兑换结束后,手里的EUR数量如果是大于1的,说明存在正套机会。这个比值就是收益倍数:
rate = ask_eurusd / ask_gbpusd * bid_eur_gbp当rate大于1时,理论上每一欧元能赚到(rate - 1)的收益。
反过来,反套路径EUR → GBP → USD → EUR的计算同理,但买卖方向会变:
rate = bid_eur_gbp * bid_gbpusd / ask_eurusd这里每一个价格的选取都必须严格按照交易方向来:买的时候用ask,卖的时候用bid。理解这条规则,三角套利的代码就完成了一大半。
2.3 成本核算:点差、手续费与滑点怎么算进信号
很多人把rate大于1作为交易信号,这是大错特错的。上面的rate是毛收益,没有扣任何成本。真实交易中,每一笔成交都要支付点差成本和手续费。
来看一个具体例子。假设当前价格如下:
- EUR/USD ask = 1.1002,bid = 1.1000
- GBP/USD ask = 1.2502,bid = 1.2500
- EUR/GBP ask = 0.8802,bid = 0.8798
按正套路径计算毛收益倍数:
rate = 1.1002 ÷ 1.2502 × 0.8798 = 0.7743
等等,这个数字看着不对?因为这里的数字关系我刻意没有用理论交叉价。实际判断时,要对比的是"经过路径换算得到的交叉汇率"和"市场上直接给出的交叉汇率"之间的差距。为了简化,我们可以直接算最终的收益倍数。
把实际点差成本考虑进去。三笔交易,平均每笔点差约2个点,也就是0.0002,三笔就是0.0006。如果按EUR/GBP报价的基数来看,0.0006大约占报价的0.068%。也就是说,当毛收益倍数大于1.0007时,扣掉成本后才真正有赚头。
不同的券商手续费结构差别很大。有的按固定金额收,有的按点差加收佣金,还有的会针对高频交易额外加价。算信号阈值时,务必要把自己实际支付的综合成本折算进去,否则EA在回测里看着赚钱,实盘却永远在亏手续费。
3. Java实现:一个可落地的三角套利引擎
3.1 整体架构与模块划分
用Java实现三角套利EA,我建议把系统拆成几个职责单一的模块,这样调试和替换都很方便。我实际使用的模块划分是这样的:
- 行情订阅模块:对接行情源,接收三个货币对的实时报价,统一封装成内部数据结构。
- 套利计算引擎:核心模块,周期性扫描最新报价,计算所有路径的价差,发出交易信号。
- 订单执行模块:把信号转换为具体的下单指令,管理三笔订单的异步提交和状态同步。
- 风险控制模块:管理最大交易次数、最大亏损、持仓时间等风控参数。
- 日志与监控模块:记录每一次信号、每一笔订单、每一分钟的账户状态,方便事后复盘。
这个架构的核心思想是"数据流单向流动"。行情模块产生数据,计算引擎消费数据,订单模块又被计算引擎触发。模块之间不互相依赖,可以独立测试。尤其是行情模块和订单模块,我强烈建议用接口隔离,因为你初期可能在仿真环境对接模拟行情,后期切换到实盘,如果接口设计得好,只需要替换实现类。
3.2 行情模型与最新价缓存设计
三个货币对的报价是独立到达的,每毫秒都可能更新。设计行情模型时,最关键的是要记录价格本身和报价的时间戳,缺时间戳的价格在套利计算里是致命的。
先定义一个不可变的价格对象:
public final class Quote { private final String symbol; private final double bid; private final double ask; private final long timestamp; public Quote(String symbol, double bid, double ask, long timestamp) { this.symbol = symbol; this.bid = bid; this.ask = ask; this.timestamp = timestamp; } public String getSymbol() { return symbol; } public double getBid() { return bid; } public double getAsk() { return ask; } public long getTimestamp() { return timestamp; } }用不可变对象的好处是线程安全,不需要额外的锁。价格更新时,直接把旧对象替换成新对象,计算线程不会读到半更新的中间状态。
行情缓存我建议用ConcurrentHashMap,key是货币对名称,value是最新Quote对象:
private final Map<String, Quote> latestQuotes = new ConcurrentHashMap<>(); public void onQuote(Quote quote) { latestQuotes.put(quote.getSymbol(), quote); }这里有一个细节:并发更新和读取之间可能出现时间差。计算线程可能在t1时刻读到EUR/USD的新报价,但GBP/USD还是t1时刻之前的老报价。解决方法是记录每次计算的"数据对齐时间",如果三个报价的时间戳相差超过一个阈值,就放弃本次计算。这一条是我实盘阶段最深刻的教训,后面第五部分会细说。
3.3 套利检测核心代码实现
套利计算引擎是整个EA的心脏。我把它设计成一个独立的类,输入是三个Quote,输出是一条可能交易的信号对象。
public class ArbitrageCalculator { // 正套:EUR -> USD -> GBP -> EUR public double calculateRateForward(Quote eurusd, Quote gbpusd, Quote eurgbp) { // 买入EUR/USD,用ask double step1 = 1.0 * eurusd.getAsk(); // 买入GBP/USD,即USD换GBP,用GBP/USD的ask double step2 = step1 / gbpusd.getAsk(); // 卖出EUR/GBP,用bid double step3 = step2 * eurgbp.getBid(); return step3; } // 反套:EUR -> GBP -> USD -> EUR public double calculateRateReverse(Quote eurusd, Quote gbpusd, Quote eurgbp) { // 买入EUR/GBP,用ask double step1 = 1.0 * eurgbp.getAsk(); // 卖出GBP/USD,即GBP换USD,用GBP/USD的bid double step2 = step1 * gbpusd.getBid(); // 卖出EUR/USD,即USD换EUR,用EUR/USD的bid double step3 = step2 / eurusd.getBid(); return step3; } }注意这里的除法顺序。很多新手会把GBP/USD的换算方向搞反。永远记住一条:如果你用X币去买Y币,用的是Y/X这个货币对的ask价;如果你卖出Y币换回X币,用的是Y/X的bid价。
接下来是信号判定逻辑。我需要用一个阈值判断是否值得入场:
public ArbitrageSignal check(Quote eurusd, Quote gbpusd, Quote eurgbp, double minProfitRate, long maxTimeDiffMs) { long now = System.currentTimeMillis(); if (Math.abs(now - eurusd.getTimestamp()) > maxTimeDiffMs || Math.abs(now - gbpusd.getTimestamp()) > maxTimeDiffMs || Math.abs(now - eurgbp.getTimestamp()) > maxTimeDiffMs) { return null; } double forwardRate = calculateRateForward(eurusd, gbpusd, eurgbp); if (forwardRate - 1.0 >= minProfitRate) { return new ArbitrageSignal("FORWARD", forwardRate, now); } double reverseRate = calculateRateReverse(eurusd, gbpusd, eurgbp); if (reverseRate - 1.0 >= minProfitRate) { return new ArbitrageSignal("REVERSE", reverseRate, now); } return null; }minProfitRate这个阈值是整个策略最核心的参数,它决定了你捕捉信号的频率和每次盈利的质量。阈值设得太低,信号频繁,但每一单赚的钱还不够覆盖成本和滑点;阈值设得高,单笔利润可观,但可能一天都不会触发一次。具体怎么取舍,第四章会专门讲。
3.4 三腿订单的并发执行与风控
检测到套利信号后,如何把三笔订单及时、准确地发出去,是另一个难点。
三角套利的三笔订单存在严格的依赖关系,他们共同构成一个对冲组合。我用的方式是并发提交三笔订单,然后用一个状态机跟踪每笔订单的成交情况:
public class ArbitrageOrderGroup { private final String direction; private final OrderState state = new OrderState(); public void submitAll() { CompletableFuture.allOf( CompletableFuture.runAsync(() -> orderExecutor.execute(symbol1, side1, volume1)), CompletableFuture.runAsync(() -> orderExecutor.execute(symbol2, side2, volume2)), CompletableFuture.runAsync(() -> orderExecutor.execute(symbol3, side3, volume3)) ).thenRun(() -> state.markAllSubmitted()); } }这里最大的风险是三笔订单不是同时成交的。第一笔成交了,第二笔滑点太大,第三笔还没成交,套利窗口已经关闭。这时如果不做处理,就会变成持有裸敞口的风险单。
我建议的做法是给整个订单组设置一个"最长等待时间"。比如三笔订单必须在500毫秒内全部成交,否则启动应急平仓逻辑——把已经成交的仓位按市价平掉,放弃这次套利。虽然会亏损一点手续费,但避免了更大的方向性风险。
风控参数我通常会放在一个独立配置类里统一管理:
| 参数 | 默认值 | 说明 |
|---|---|---|
| minProfitRate | 0.0008 | 最小净利润率触发阈值 |
| maxTimeDiffMs | 200 | 三个报价最大时间差 |
| orderTimeoutMs | 500 | 三笔订单最长完成时间 |
| maxDailyLoss | 200 | 单日最大亏损触发停盘 |
| maxOpenPositions | 3 | 最大同时持仓组数 |
这些参数没有绝对正确的值,必须根据你所对接的流动性、市场波动程度和账户资金量做动态调整。
4. 回测与调参:参数筛不好,上实盘就是送钱
4.1 回测数据怎么准备
很多人在三角套利回测上犯的第一个错误,就是用M1的K线数据去做回测。三角套利的利润窗口往往是秒级别的,M1数据一压就完全失真了。
我建议用tick数据,至少也要用1秒级别的数据快照。如果你的券商提供tick历史数据,那是首选。如果没有,可以通过行情源自己持续记录一段时间的tick数据,攒上一个月再做回测。数据量不需要很大,但要覆盖不同的市场阶段,包括正常交易时段、重要经济数据公布时段、市场剧烈波动时段。
另一个关键点是点差的建模。在回测模拟中,最保守的做法是给每一笔模拟成交都加上当前市场常见的最大点差,再加一点点滑点余量。宁愿回测结果难看一点,也比实盘被不断打脸强。
4.2 关键参数调优方向
三角套利EA最核心的参数就是上一章提到的minProfitRate和扫描频率。
minProfitRate的调优方向,是先用历史数据算出一个市场自然而然会出现的"毛价差分布"。怎么做呢?很简单,把回测区间内的所有可交易tick都过一遍套利计算器,记录下每次rate的最高值。然后统计这些值落在哪个区间最密集。如果毛价差长期在1.0015附近波动,那你把阈值设在1.0018,意味着你只吃最高的那一小撮机会,次数少但每单质量高;如果设在1.0008,信号频率会明显增加,但被点差和滑点吃掉的概率也大。
扫描频率方面,我实测过100ms、200ms、500ms三档。100ms的扫描对CPU压力不大,但因为网络延迟和行情推送本身存在不确定性,过高的扫描频率并不能提高成交质量。200ms是平衡点,在这个频率下,既不会漏掉大部分1秒以内的窗口,也不会因为频繁计算而产生不必要的开销。
还有一个容易忽略的参数是"连续信号冷却时间"。实际行情中,同一个套利窗口往往会持续几百毫秒到几秒,扫描线程可能会在一瞬间连续触发多次信号。如果每次都下单,就会在同一个窗口里重复开仓,风险瞬间放大。我建议检测到信号并提交订单后,至少冷却3到5秒,等套利窗口自然关闭。
4.3 用Java写一个轻量回测循环
完整的回测框架有很多现成的库,但三角套利这种小众策略,我建议自己写一个轻量循环,反而是最可控的。
核心逻辑就是逐条读取tick数据,每来一条tick,更新对应的报价缓存,然后调用套利计算器。如果产生信号,就模拟成交并累计盈亏:
public class BacktestEngine { private final ArbitrageCalculator calculator = new ArbitrageCalculator(); private final Map<String, Quote> quoteCache = new HashMap<>(); public void run(List<TickData> ticks) { double netProfit = 0.0; int tradeCount = 0; for (TickData tick : ticks) { quoteCache.put(tick.getSymbol(), tick.toQuote()); if (quoteCache.size() < 3) { continue; } ArbitrageSignal signal = calculator.check( quoteCache.get("EURUSD"), quoteCache.get("GBPUSD"), quoteCache.get("EURGBP"), minProfitRate, maxTimeDiffMs ); if (signal != null) { double simulatedProfit = signal.getRate() - 1.0 - costPerRound; if (simulatedProfit > 0) { netProfit += simulatedProfit; tradeCount++; } } } System.out.printf("总交易次数: %d, 净利润: %.4f%n", tradeCount, netProfit); } }这个循环有几处需要注意。第一,每个tick只更新一个货币对,所以需要判断三个货币对是否都有了报价,才能开始计算。第二,模拟成交时要减去成本,否则回测结果虚高。第三,对于一个持续存在的套利窗口,同一个tick序列里可能连续产生多个信号,需要引入冷却时间过滤,否则模拟出来的交易次数会远超实盘可能性。
回测结果怎么看呢?除了看总净利润,更要看交易次数、胜率、平均每单利润、最大连续回撤这几个指标。如果总利润很高但交易次数只有几次,说明参数过严,策略没有统计意义;如果交易次数很多但总利润很低,说明信号质量太差。
5. 实盘阶段踩过的坑与排查技能
5.1 假信号:报价时间戳不同步
我第一次上仿真盘时遇到的最大问题,就是系统频繁产生套利信号,但点击下单后几乎全部没法成交或偏离预期。
排查了很久才发现,问题出在三个货币对的报价推送不是同一时刻到达的。我的行情订阅模块分别订阅了三个货币对,它们的推送间隔大约在几十到几百毫秒不等。计算线程在t1时刻看到EUR/USD的新价格,同时在t1时刻取到了500毫秒之前GBP/USD的旧价格,算出来的价差自然会异乎寻常地大,但这完全是数据错位造成的假象。
解决方式就是我前面说的:在计算价差之前,先比较三个报价时间戳。如果新旧相差超过200毫秒,直接跳过本次计算。这个判断看似简单,却能在实盘里过滤掉至少一半的假信号。
5.2 点差瞬间放大:市价单成交在坑里
三角套利的利润也就几十个点到上百个点,经不起太大的滑点。我早期用市价单下单,遇到市场点差瞬间扩大,三笔订单的实际成交价与信号触发时相差甚远,本来是套利单,结果变成了亏损单。
后来我把订单改成限价单,严格按照信号触发时的ask或bid价格去挂单。只接受不劣于信号价格的成交。如果200毫秒内没有成交,就撤单放弃这次机会。这样做的代价是成交率降低了,但每一笔成交的质量显著提高。
限价单也有坑。三个货币对是分别挂单的,有可能一只成交了,另外两只是废单。这个时候已经形成了部分敞口,需要立刻触发应急平仓逻辑。千万不要心存侥幸继续等。
5.3 GC停顿拖慢计算线程
Java程序在长时间运行时,JVM的垃圾回收会造成短暂的停顿。在普通业务系统里,几百毫秒的停顿没什么影响;但在套利EA里,这个停顿正好可能出现在套利窗口打开的时刻,导致成交机会被别人抢先。
我实际使用的是G1垃圾回收器,通过配置调整最大停顿时间:
java -XX:+UseG1GC -XX:MaxGCPauseMillis=20 -jar triangle-arbitrage.jar同时,在计算路径里尽量减少新对象的创建。比如避免在每次tick处理时都new一个StringBuilder、避免无意义的装箱拆箱。让GC的发生频率和单次停顿时间都保持在较低水平。
5.4 常见问题速查表
下面这张表格是我整理的最常见问题排查清单,基本涵盖了从开发到上线会遇到的典型故障:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 信号频繁但不成交 | 报价时间戳不同步产生假信号 | 检查三个货币对的时间戳差值,超过阈值直接丢弃 |
| 成交后利润远低于预期 | 市价单遇到瞬间点差扩大 | 改用限价单,设定可接受的滑点范围 |
| 三笔订单只有两笔成交 | 限价单部分成交撤单 | 启动应急平仓逻辑,把已成交仓位按市价处理 |
| 回测盈利但实盘亏损 | 回测点差模型太乐观 | 回测时使用保守点差,加入滑点成本模拟 |
| 程序运行几小时后交易延迟变大 | JVM GC停顿或内存泄漏 | 调整GC参数,检查日志中是否有频繁Full GC |
| 同一窗口重复触发开仓 | 缺少冷却时间 | 信号触发后强制冷却3到5秒 |
最后再分享一个我的经验总结。做三角套利EA,最忌讳的是追求"高频高利润"。这个策略本质上是捡市场犯错的钱,窗口小、机会少,一次成功的套利可能只赚千分之一甚至万分之一。把阈值抬高一点,减少出手次数,提高每单质量,是我在实盘阶段找到的最稳妥的生存方式。如果你刚开始做,建议先用模拟账号跑至少一个月,把日志里的每一次信号、每一笔成交都记录下来,定期复盘。你会发现,大部分亏损的单子都集中在某几个特定的时间窗口,比如重要数据公布前后几分钟——这时候市场的报价混乱程度远超平时,看起来是套利机会,实际上是吞噬资本的陷阱。把这些时段手动屏蔽掉,你的夏普比率会好看得多。