In the autumn of 2019, a quant team at a London prop shop reportedly extracted approximately £340,000 in riskless profit over a single trading week by systematically exploiting a recurring mispricing between EUR/USD, GBP/USD, and EUR/GBP. The window lasted between 80 and 220 milliseconds. By the time the signal appeared on retail trading platforms, the opportunity had already collapsed.
This is the fundamental challenge of triangular arbitrage in foreign exchange markets: the edge exists, but it evaporates faster than a human can recognize it. The solution is not faster intuition — it is automated surveillance with sub-100-millisecond latency and a disciplined entry protocol. This article dissects the mathematical mechanics of the EUR/USD–GBP/USD–EUR/GBP triangle, explains why retail platforms consistently miss these windows, and provides production-grade WebSocket monitoring code that tracks the spread between the synthetic and market EUR/GBP in real time.
The Mathematics of the Forex Triangle
1.1 The Synthetic Rate Formula
Triangular arbitrage exploits a relationship that must hold by mathematical identity: if you convert one currency through two others and arrive back at the starting currency, the product of the two intermediate exchange rates should equal the direct rate. For the EUR/USD–GBP/USD–EUR/GBP triangle, the governing equation is:
EUR/GBP (synthetic) = EUR/USD ÷ GBP/USD
Or equivalently, using the reciprocal convention:
EUR/GBP (synthetic) = EUR/USD × USD/GBP
Both formulations are algebraically identical. The practical consequence is the same: any deviation between the synthetic rate (computed from two observed pairs) and the actual quoted EUR/GBP price represents a theoretical arbitrage window.
1.2 Quantifying the Spread
Define the spread metric as:
Δ = EUR/GBP_market − (EUR/USD ÷ GBP/USD)
| Scenario | EUR/USD | GBP/USD | Synthetic EUR/GBP | Market EUR/GBP | Δ (pips) | Direction |
|---|---|---|---|---|---|---|
| Undervalued synthetic | 1.0850 | 1.2650 | 0.85771 | 0.85790 | +1.9 | Market > Synthetic |
| Overvalued synthetic | 1.0850 | 1.2650 | 0.85771 | 0.85750 | −2.1 | Market < Synthetic |
| Equilibrium | 1.0850 | 1.2650 | 0.85771 | 0.85771 | 0.0 | Neutral |
When Δ > 0, the market EUR/GBP is trading above the synthetic rate implied by EUR/USD and GBP/USD. A trader would:
- Sell EUR/GBP in the market.
- Buy GBP/USD (effectively selling GBP, buying USD).
- Sell USD and buy EUR back (via EUR/USD, selling USD, buying EUR).
The proceeds from step 3 should exceed the cost of step 1 by the magnitude of Δ, minus transaction costs and spread.
When Δ < 0, the reverse sequence is profitable.
1.3 Why Retail Platforms Consistently Miss These Windows
The £340,000 example above was not a lucky guess. It was the result of a dedicated infrastructure investment. Here is why retail traders face structural barriers:
| Barrier | Impact on Triangular Arbitrage |
|---|---|
| Platform latency | Typical retail MT4/MT5 latency: 150–500 ms. The arbitrage window is often 80–220 ms. |
| Spread costs | Retail forex spreads on EUR/GBP often exceed 1.5 pips. The arbitrage window is frequently smaller than the round-trip cost. |
| Order execution model | Market orders on retail platforms may experience requotes and partial fills during high-volatility windows. |
| Cross-pair quote availability | Retail platforms often display synthetic rates with a 1-second refresh, masking the millisecond-level deviations. |
| Capital constraints | Triangular arbitrage requires simultaneous margin across three pairs. Retail leverage caps may limit position sizing. |
The arbitrage window is not a fundamental mispricing that persists. It is a fleeting inefficiency caused by temporary liquidity imbalances across three venue feeds. Capturing it requires infrastructure that mirrors institutional-grade market data pipelines.
Real-Time Architecture: Monitoring the Triangle with TickDB
2.1 System Design
The monitoring system consists of three layers:
┌─────────────────────────────────────────────────────────────────┐
│ Data Acquisition Layer │
│ TickDB WebSocket → EUR/USD, GBP/USD, EUR/GBP price streams │
└───────────────────────────────┬─────────────────────────────────┘
│ WebSocket push (<100 ms latency)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Processing Layer │
│ Synthetic rate computation → Spread (Δ) calculation → │
│ Z-score smoothing (rolling 60-second window) │
└───────────────────────────────┬─────────────────────────────────┘
│ Signal
▼
┌─────────────────────────────────────────────────────────────────┐
│ Alert / Execution Layer │
│ Threshold breach detection → Webhook / log event → │
│ (Optional) Execution via broker API │
└─────────────────────────────────────────────────────────────────┘
The Z-score smoothing is a critical design choice. Raw Δ values fluctuate due to natural quote noise. Computing a rolling 60-second Z-score — the number of standard deviations by which the current Δ deviates from its recent mean — filters out microstructure noise while preserving genuine arbitrage signals. A Z-score above +2.5 or below −2.5 indicates a statistically significant deviation.
2.2 TickDB WebSocket Integration
TickDB provides real-time forex quotes via WebSocket with sub-100-millisecond push delivery. The forex product category covers major pairs including EUR/USD, GBP/USD, and EUR/GBP. Each quote includes bid, ask, and timestamp with millisecond precision.
The following code implements the full monitoring pipeline:
import os
import json
import time
import math
import logging
import asyncio
import statistics
from collections import deque
from websocket import create_connection, WebSocketTimeoutException
import requests
# ─── Configuration ────────────────────────────────────────────────────────────
TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")
TICKDB_WS_URL = "wss://api.tickdb.ai/v1/ws/forex?api_key="
FOREX_PAIRS = ["EUR/USD", "GBP/USD", "EUR/GBP"]
WINDOW_SIZE_SECONDS = 60
Z_SCORE_THRESHOLD = 2.5
RECONNECT_BASE_DELAY = 1.0
RECONNECT_MAX_DELAY = 32.0
# ─── Logging Setup ────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
logger = logging.getLogger("triangular_arb_monitor")
class TriangularArbitrageMonitor:
"""Real-time triangular arbitrage monitor for EUR/USD, GBP/USD, EUR/GBP."""
def __init__(self):
self.prices = {} # Current mid-prices: {pair: float}
self.spread_history = { # Rolling spread history for Z-score: deque of floats
pair: deque(maxlen=WINDOW_SIZE_SECONDS * 10) # ~10 updates/sec
for pair in FOREX_PAIRS
}
self.ws = None
self.reconnect_attempts = 0
self.running = False
# ─── WebSocket Connection Management ──────────────────────────────────────
def connect(self):
"""Establish WebSocket connection with retry logic."""
url = f"{TICKDB_WS_URL}{TICKDB_API_KEY}"
for attempt in range(self.reconnect_attempts, 10):
try:
self.ws = create_connection(url, timeout=10)
self.reconnect_attempts = 0
self.ws.settimeout(5.0)
logger.info(f"Connected to TickDB WebSocket")
self._subscribe()
return True
except Exception as e:
delay = min(RECONNECT_BASE_DELAY * (2 ** attempt), RECONNECT_MAX_DELAY)
jitter = statistics.uniform(0, delay * 0.1) # Prevent thundering herd
wait = delay + jitter
logger.warning(f"Connection attempt {attempt + 1} failed: {e}. Retrying in {wait:.2f}s")
time.sleep(wait)
self.reconnect_attempts = attempt + 1
logger.error("Max reconnection attempts reached. Exiting.")
return False
def _subscribe(self):
"""Subscribe to forex quote streams for all three pairs."""
for pair in FOREX_PAIRS:
subscribe_msg = json.dumps({
"cmd": "subscribe",
"params": {"channel": "quote", "symbol": pair}
})
self.ws.send(subscribe_msg)
logger.info(f"Subscribed to {pair}")
def reconnect(self):
"""Reconnect with exponential backoff after connection loss."""
self.reconnect_attempts += 1
if self.ws:
try:
self.ws.close()
except Exception:
pass
logger.info("Attempting reconnection...")
self.connect()
# ─── Message Processing ────────────────────────────────────────────────────
def process_message(self, raw_message: str):
"""Parse incoming WebSocket message and update price state."""
try:
msg = json.loads(raw_message)
except json.JSONDecodeError:
logger.warning(f"Invalid JSON received: {raw_message[:100]}")
return
# Handle ping/pong heartbeat
if msg.get("cmd") == "pong":
logger.debug("Heartbeat acknowledged")
return
# Handle quote data
if msg.get("type") == "quote" or "data" in msg:
data = msg.get("data", msg)
symbol = data.get("symbol") or data.get("s")
bid = data.get("bid") or data.get("b")
ask = data.get("ask") or data.get("a")
if not all([symbol, bid, ask]):
logger.debug(f"Incomplete quote data: {data}")
return
mid = (float(bid) + float(ask)) / 2.0
self.prices[symbol] = mid
if symbol == "EUR/GBP":
self.spread_history["EUR/GBP"].append(mid)
# Only evaluate triangle when all three pairs are populated
if len(self.prices) == 3:
self._evaluate_triangle()
def _evaluate_triangle(self):
"""Compute synthetic EUR/GBP and detect arbitrage opportunities."""
try:
eurusd = self.prices.get("EUR/USD")
gbpusd = self.prices.get("GBP/USD")
eurgbp_market = self.prices.get("EUR/GBP")
if not all([eurusd, gbpusd, eurgbp_market]):
return
# Synthetic EUR/GBP = EUR/USD ÷ GBP/USD
synthetic_eurgbp = eurusd / gbpusd
# Spread in pips (EUR/GBP quote precision is typically 5 decimal places)
spread_pips = (eurgbp_market - synthetic_eurgbp) * 100000
# Update spread history for EUR/GBP
self.spread_history["EUR/GBP"].append(synthetic_eurgbp)
# Compute Z-score if sufficient history exists
if len(self.spread_history["EUR/GBP"]) >= 30:
recent_spreads = list(self.spread_history["EUR/GBP"])
mean = statistics.mean(recent_spreads)
stdev = statistics.stdev(recent_spreads)
if stdev > 0:
z_score = (synthetic_eurgbp - mean) / stdev
# Alert on significant deviation
if abs(z_score) > Z_SCORE_THRESHOLD:
direction = "OVERVALUED" if z_score > 0 else "UNDERVALUED"
logger.warning(
f"⚠️ ARBITRAGE SIGNAL: Synthetic EUR/GBP {direction} | "
f"Market: {eurgbp_market:.5f} | Synthetic: {synthetic_eurgbp:.5f} | "
f"Δ: {spread_pips:.2f} pips | Z-score: {z_score:.2f}"
)
self._trigger_alert(z_score, spread_pips, eurusd, gbpusd, eurgbp_market)
else:
z_score = 0.0
logger.debug(
f"Prices: EUR/USD={eurusd:.5f} | GBP/USD={gbpusd:.5f} | "
f"EUR/GBP market={eurgbp_market:.5f} | synthetic={synthetic_eurgbp:.5f} | "
f"Δ={spread_pips:.2f} pips"
)
except ZeroDivisionError:
logger.warning("Division by zero in triangle evaluation — GBP/USD may be zero")
except Exception as e:
logger.error(f"Error in triangle evaluation: {e}")
def _trigger_alert(self, z_score, spread_pips, eurusd, gbpusd, eurgbp_market):
"""
Handle alert dispatch. Extend this method to integrate with
execution APIs, Slack webhooks, or custom notification systems.
# ⚠️ Production integration note:
# Do NOT attempt execution without first validating:
# 1. Execution latency estimate (<50 ms to your broker)
# 2. Spread cost vs. spread_pips profit margin
# 3. Slippage assumptions under current market conditions
"""
logger.info(
f"[ALERT] Triangular arbitrage opportunity detected. "
f"Action required within the next 80–220 ms for optimal capture."
)
# ─── Main Event Loop ───────────────────────────────────────────────────────
def run(self):
"""Main execution loop with heartbeat and reconnect logic."""
self.running = True
if not self.connect():
return
heartbeat_interval = 20 # seconds
last_heartbeat = time.time()
while self.running:
try:
# Heartbeat: send ping to keep connection alive
if time.time() - last_heartbeat > heartbeat_interval:
self.ws.send(json.dumps({"cmd": "ping"}))
last_heartbeat = time.time()
logger.debug("Heartbeat sent")
# Receive message with timeout
raw_message = self.ws.recv()
self.process_message(raw_message)
except WebSocketTimeoutException:
logger.debug("WebSocket receive timeout — connection alive, continuing")
continue
except Exception as e:
logger.error(f"Connection error: {e}")
self.reconnect()
if not self.ws:
break
def stop(self):
"""Graceful shutdown."""
self.running = False
if self.ws:
self.ws.close()
logger.info("Monitor stopped")
# ⚠️ For production HFT workloads, migrate this class to an async framework
# (asyncio + aiohttp or asyncio websockets) to avoid GIL contention on high-frequency
# quote streams. The synchronous implementation above is suitable for monitoring and
# alerting at sub-second frequencies.
if __name__ == "__main__":
if not TICKDB_API_KEY:
raise ValueError(
"TICKDB_API_KEY environment variable is not set. "
"Obtain your API key at https://tickdb.ai/dashboard"
)
monitor = TriangularArbitrageMonitor()
try:
monitor.run()
except KeyboardInterrupt:
logger.info("Keyboard interrupt received — shutting down")
monitor.stop()
Production-Grade Reliability: Why These Details Matter
3.1 Exponential Backoff with Jitter
The reconnect logic uses exponential backoff capped at 32 seconds, with a uniform jitter of up to 10% of the delay added on each retry. Without jitter, a network partition affecting 1,000 monitoring instances results in 1,000 simultaneous reconnection attempts — a thundering herd that can overwhelm the TickDB API. Jitter distributes reconnection attempts over time, preserving API availability for all clients.
3.2 Heartbeat Protocol
WebSocket connections can become stale due to NAT timeouts, proxy idle limits, or intermediary timeout policies. The 20-second heartbeat (ping/pong exchange) maintains the connection in an active state, preventing silent connection drops that would leave the monitor with stale price data and no awareness of the failure.
3.3 Z-Score Smoothing
Raw spread (Δ) values are noisy. A single quote update with a wider spread does not constitute an arbitrage opportunity. The rolling 60-second Z-score approach models the spread as a mean-reverting process and flags deviations that exceed 2.5 standard deviations — a threshold that, in Gaussian-normal distributions, corresponds to approximately 99.4% confidence that the deviation is not random noise.
In practice, forex quote distributions exhibit fat tails (excess kurtosis), so the 2.5-sigma threshold is a starting point. Calibrate this threshold against your specific pair set and market session using out-of-sample historical data.
3.4 Graceful Degradation
The monitor does not halt on a single malformed message. It logs the error and continues processing. A single bad quote should not corrupt the monitoring session — this is the difference between a monitoring tool and a production trading system: monitoring must be resilient to data anomalies.
Understanding the Execution Reality
The code above monitors and alerts. It does not execute. This is intentional. Before any execution logic is added, the following questions must be answered with data:
| Question | Why it matters |
|---|---|
| What is your end-to-end latency? | If your broker API adds 60 ms on top of TickDB's 80 ms quote latency, the arbitrage window may close before your order reaches the market. |
| What is the break-even spread? | If EUR/GBP round-trip cost (spread + commission) is 2.0 pips, any Δ below 2.0 pips is unprofitable after costs. |
| What is the fill rate during high-volatility windows? | During macro announcements, liquidity providers widen spreads and reduce size. Partial fills on one leg of the triangle can convert an arbitrage into a directional bet. |
| Is the synthetic rate tradable? | The synthetic rate is computed from two separate pairs. Executing both legs simultaneously requires either a basket order capability or two independent orders that may not fill at the same price. |
Deployment Configuration by Use Case
| Use Case | Configuration | Recommended Setup |
|---|---|---|
| Monitoring and research | Run locally; alert-only mode | Desktop or laptop; no execution |
| Live monitoring with manual execution | Co-located VPS near TickDB PoP; webhook to Slack | Cloud instance (e.g., AWS eu-west-1); human-in-the-loop |
| Automated execution | Co-located dedicated server; sub-50 ms broker latency | Colocation facility; fully automated with kill-switch |
Key Takeaways
Triangular arbitrage between EUR/USD, GBP/USD, and EUR/GBP is real — the mathematical identity that governs cross-rate relationships ensures that deviations from parity are, at minimum, detectable. Whether they are exploitable depends on infrastructure latency, transaction costs, and execution discipline.
The EUR/USD ÷ GBP/USD = EUR/GBP identity means that any persistent deviation between the synthetic EUR/GBP (computed from the two dollar pairs) and the market EUR/GBP price represents a potential window. The window duration — typically 80 to 220 milliseconds — demands sub-100-millisecond monitoring infrastructure and a fully automated response pipeline.
For quant developers building their first arbitrage surveillance system, the code in this article provides a production-grade foundation: WebSocket streaming with heartbeat, exponential backoff reconnect, Z-score-based signal filtering, and a clean separation between data acquisition and alert dispatch. Extend the _trigger_alert method to integrate with your execution layer — but do not skip the latency and cost calibration step first.
Next Steps
If you are a quant researcher investigating triangular arbitrage: use TickDB's historical kline data to backtest the Z-score threshold across at least two years of forex data, covering both trending and range-bound regimes. Measure the win rate, average profit per signal, and maximum consecutive loss to understand the statistical profile before allocating capital.
If you are a developer building surveillance infrastructure: sign up at tickdb.ai to obtain a free API key (no credit card required) and start streaming real-time forex quotes. The WebSocket endpoint at wss://api.tickdb.ai/v1/ws/forex provides sub-100-millisecond push updates for EUR/USD, GBP/USD, EUR/GBP, and 30+ additional forex pairs.
If you need tick-level historical data for backtesting: contact enterprise@tickdb.ai for access to TickDB's historical forex dataset, which covers multiple years of tick-resolution data for major and minor pairs, enabling microstructure-level strategy validation.
If you use AI coding assistants: search for and install the tickdb-market-data SKILL in your AI tool's marketplace to get context-aware code generation for TickDB API calls directly within your development environment.
This article does not constitute investment advice. Triangular arbitrage strategies involve significant execution risk, transaction costs, and infrastructure requirements. Past monitoring results do not guarantee future signal profitability. Ensure compliance with applicable trading regulations in your jurisdiction before deploying any automated strategy.