Triangular Arbitrage Detection Algorithm: Crypto Guide
A complete guide to building a triangular arbitrage detection algorithm for crypto markets, covering Python code, exchange examples, backtesting, and live signal integration.
A complete guide to building a triangular arbitrage detection algorithm for crypto markets, covering Python code, exchange examples, backtesting, and live signal integration.
Triangular arbitrage has been a staple of forex trading desks for decades, but in crypto markets it takes on a whole new dimension. Unlike traditional finance, exchanges like Binance, Bybit, and OKX update order books hundreds of times per second — and fleeting price mismatches between three currency pairs can appear and vanish in milliseconds. The traders who catch them are not watching charts. They have built detection algorithms that run continuously, scanning for the moment when the math works in their favor.
Triangular arbitrage exploits price inconsistencies across three trading pairs on the same exchange. The classic example: you start with USDT, buy BTC with it, convert BTC to ETH, then sell ETH back to USDT. If the exchange rates are temporarily misaligned, you end up with more USDT than you started with — no external price movement required. The profit comes purely from the market's momentary inefficiency. You are not predicting direction. You are exploiting arithmetic.
The key insight is that for any three currencies A, B, C, the theoretical no-arbitrage relationship is: (1/P_AB) × P_BC × (1/P_CA) = 1. When this product deviates from 1 by more than the combined fees — typically 0.1% to 0.2% per trade on Binance or OKX — a real opportunity exists. Detecting that deviation fast enough to act on it is the entire engineering challenge.
At its core, triangular arbitrage detection is a graph problem. Currencies are nodes. Trading pairs are directed edges with weights representing exchange rates. An opportunity exists when a cycle of three nodes has a product of edge weights greater than 1 after fees. The algorithm's job is to find these profitable cycles continuously as prices update. Most production systems maintain an in-memory price graph updated via WebSocket feeds from the exchange. On every price tick, all currency triplets that include the updated pair get re-evaluated. The implied cross rate is compared to the actual market rate. If the discrepancy exceeds the minimum threshold — accounting for fees, slippage, and execution latency — a trade signal fires.
Platforms like VoiceOfChain aggregate these price feeds across multiple exchange WebSocket streams and surface actionable arbitrage signals in real time. This is especially useful during the strategy development phase, letting you validate signal frequency against live markets before committing to your own infrastructure. Once you understand the opportunity distribution on a given exchange, you build the detection loop locally and run it co-located.
Speed is everything. On Binance, the average triangular arb window closes in under 500ms. If your detection-to-execution pipeline takes longer than that, you will consistently arrive after the opportunity has closed. WebSocket connections and low-latency VPS hosting are table stakes, not optional upgrades.
Here is a clean implementation that handles the core logic. This runs on every price update, checking all permutations of currency triplets for profitable cycles. In production you connect this to a WebSocket feed and run it asynchronously — the class itself is stateless and fast enough to call on every tick.
import itertools
class TriangularArbitrageDetector:
def __init__(self, prices: dict, fee_rate: float = 0.001):
# prices = {'BTC/USDT': 65000, 'ETH/BTC': 0.052, 'ETH/USDT': 3380}
self.prices = prices
self.fee_rate = fee_rate
def _get_rate(self, base: str, quote: str) -> float:
'''Returns the rate to convert 1 unit of base into quote.
Handles both direct and inverse pair lookups.
'''
direct = f'{base}/{quote}'
inverse = f'{quote}/{base}'
if direct in self.prices:
return self.prices[direct]
if inverse in self.prices:
return 1.0 / self.prices[inverse]
return 0.0
def calculate_profit(self, a: str, b: str, c: str) -> float:
'''Net profit ratio for the cycle A -> B -> C -> A (after fees).
Returns a positive float if profitable, negative if not.
'''
r1 = self._get_rate(a, b)
r2 = self._get_rate(b, c)
r3 = self._get_rate(c, a)
if not all([r1, r2, r3]):
return 0.0
gross = r1 * r2 * r3
fee_multiplier = (1 - self.fee_rate) ** 3
return gross * fee_multiplier - 1.0
def detect_opportunities(self, threshold: float = 0.001) -> list:
'''Return all profitable triplets above threshold, sorted by profit desc.'''
currencies = set()
for pair in self.prices:
base, quote = pair.split('/')
currencies.update([base, quote])
results = []
for a, b, c in itertools.permutations(currencies, 3):
profit = self.calculate_profit(a, b, c)
if profit > threshold:
results.append({
'path': f'{a} -> {b} -> {c} -> {a}',
'profit_pct': round(profit * 100, 4)
})
return sorted(results, key=lambda x: x['profit_pct'], reverse=True)
# Example: ETH/BTC is slightly misaligned vs the USDT rates
if __name__ == '__main__':
prices = {
'BTC/USDT': 65000,
'ETH/USDT': 3380,
'ETH/BTC': 0.0521, # implied fair value: 3380/65000 = 0.05200
}
detector = TriangularArbitrageDetector(prices, fee_rate=0.001)
opps = detector.detect_opportunities(threshold=0.0005)
for o in opps:
path = o['path']
profit = o['profit_pct']
print(f'{path} | Profit: {profit}%')
The _get_rate method handles both direct and inverse pairs — crucial because not all exchanges list every combination. On Binance you can find ETH/BTC listed directly. On Bybit or Coinbase Advanced you may need to compute the implied rate from ETH/USDT and BTC/USDT separately. The threshold of 0.1% is conservative by default; in practice tune this based on the exchange's actual fee tier and your average execution latency so you are not chasing unexecutable signals.
Backtesting triangular arb is notoriously tricky. Standard OHLCV candle data does not capture the actual bid-ask spread at the moment you would have executed — which is where most theoretical profits disappear in practice. The best approach is tick-level or order book snapshot data, which Binance, KuCoin, and OKX all provide via their data APIs. Here is a backtesting framework that works with mid-price tick data and simulates realistic fee and slippage costs.
import pandas as pd
import numpy as np
def backtest_triangular_arb(
price_df: pd.DataFrame,
fee_rate: float = 0.001,
slippage_per_leg: float = 0.0005,
min_profit_pct: float = 0.002,
initial_capital: float = 10_000
) -> tuple:
'''
Backtest on a DataFrame with columns:
[timestamp, BTC/USDT, ETH/BTC, ETH/USDT]
Returns (trades_df, metrics_dict)
'''
effective_fee = fee_rate + slippage_per_leg # combined cost per leg
capital = initial_capital
trades = []
for _, row in price_df.iterrows():
detector = TriangularArbitrageDetector(
prices={
'BTC/USDT': row['BTC/USDT'],
'ETH/BTC': row['ETH/BTC'],
'ETH/USDT': row['ETH/USDT'],
},
fee_rate=effective_fee
)
opps = detector.detect_opportunities(threshold=min_profit_pct)
if opps:
best = opps[0]
profit_usd = capital * best['profit_pct'] / 100
capital += profit_usd
trades.append({
'timestamp': row['timestamp'],
'path': best['path'],
'profit_usd': profit_usd,
'capital': capital
})
trades_df = pd.DataFrame(trades)
metrics = _calculate_metrics(trades_df, initial_capital, capital)
return trades_df, metrics
def _calculate_metrics(trades_df: pd.DataFrame,
initial: float, final: float) -> dict:
if trades_df.empty:
return {'total_return_pct': 0, 'total_trades': 0}
profits = trades_df['profit_usd'].values
equity = trades_df['capital'].values
peak = np.maximum.accumulate(equity)
drawdowns = (equity - peak) / peak
return {
'total_return_pct': round((final - initial) / initial * 100, 2),
'total_trades': len(trades_df),
'avg_profit_usd': round(profits.mean(), 4),
'std_profit_usd': round(profits.std(), 4),
'sharpe_ratio': round(profits.mean() / (profits.std() + 1e-9), 2),
'max_drawdown_pct': round(drawdowns.min() * 100, 2)
}
Critical backtest caveat: OHLCV candles use mid-prices, not fill prices. Always add at least 0.05% simulated slippage per leg on top of fees. If the strategy is still profitable after that adjustment, you have something genuinely worth deploying to live markets.
Triangular arbitrage is often marketed as risk-free, which is misleading. The risks are real: partial fills, latency spikes, API outages, and sudden volatility gaps can all leave you holding an unintended position mid-cycle. On illiquid pairs — common on Gate.io or in smaller KuCoin markets — the available liquidity at the quoted price may be far less than your intended trade size, forcing worse fills or incomplete execution. Always have exit logic for an incomplete cycle.
def calculate_position_size(
capital: float,
profit_pct: float,
slippage_est: float = 0.001,
max_risk_pct: float = 0.02
) -> dict:
'''
Conservative Kelly sizing for triangular arbitrage.
Caps position at max_risk_pct of capital to limit execution risk.
profit_pct and slippage_est are decimal fractions (e.g. 0.003 = 0.3%).
'''
net_edge = profit_pct - slippage_est
if net_edge <= 0:
return {'position_size_usd': 0, 'reason': 'No edge after slippage'}
# Kelly fraction: edge / (edge + loss_scenario)
# Using 25% Kelly to reduce variance from execution uncertainty
kelly = (net_edge / (profit_pct + slippage_est)) * 0.25
position_fraction = min(kelly, max_risk_pct)
return {
'position_size_usd': round(capital * position_fraction, 2),
'position_fraction': round(position_fraction, 4),
'net_edge_pct': round(net_edge * 100, 4),
'kelly_raw': round(kelly, 4)
}
# Example: 0.3% gross profit, 0.1% slippage estimate, $50k capital
result = calculate_position_size(
capital=50_000,
profit_pct=0.003,
slippage_est=0.001,
max_risk_pct=0.02
)
print(result)
# {'position_size_usd': 500.0, 'position_fraction': 0.01, 'net_edge_pct': 0.2, 'kelly_raw': 0.025}
| Exchange | Maker Fee | Taker Fee | WebSocket Latency | Arb Viability |
|---|---|---|---|---|
| Binance | 0.02% | 0.04% | ~20ms | High |
| Bybit | 0.01% | 0.06% | ~25ms | High |
| OKX | 0.02% | 0.05% | ~30ms | Medium-High |
| KuCoin | 0.05% | 0.10% | ~50ms | Medium |
| Gate.io | 0.05% | 0.10% | ~60ms | Medium |
| Coinbase Advanced | 0.00% | 0.05% | ~40ms | Medium |
Lower fees expand the set of profitable opportunities directly. On Binance with VIP tier pricing, the combined 3-leg fee drops to under 0.15%, meaning any gross edge above roughly 0.2% is theoretically profitable. On a platform with 0.3% combined fees, you need edges of 0.4% or more before slippage — which occur far less frequently and are harder to execute at size. This is why most serious triangular arb bots operate on Binance or Bybit as their primary venue.
The detection class above is exchange-agnostic. Connecting it to a live feed requires a WebSocket listener that normalizes incoming data and calls the detector on every update. Here is a minimal async implementation for Binance's bookTicker stream, which pushes best bid and ask on every price change — far lower latency than OHLCV candle subscriptions.
import asyncio
import json
import websockets
BINANCE_WS = 'wss://stream.binance.com:9443/stream'
PAIRS = ['BTCUSDT', 'ETHUSDT', 'ETHBTC']
NORMALIZE = {
'BTCUSDT': 'BTC/USDT',
'ETHUSDT': 'ETH/USDT',
'ETHBTC': 'ETH/BTC'
}
price_cache: dict = {}
async def on_price_update(symbol: str, mid_price: float):
norm = NORMALIZE.get(symbol)
if norm:
price_cache[norm] = mid_price
if len(price_cache) == 3:
detector = TriangularArbitrageDetector(price_cache.copy(), fee_rate=0.001)
opps = detector.detect_opportunities(threshold=0.002)
if opps:
best = opps[0]
path = best['path']
profit = best['profit_pct']
print(f'[SIGNAL] {path} | {profit}%')
async def listen():
streams = '/'.join([f'{p.lower()}@bookTicker' for p in PAIRS])
url = f'{BINANCE_WS}?streams={streams}'
async with websockets.connect(url) as ws:
async for raw in ws:
msg = json.loads(raw)
data = msg.get('data', {})
if 's' in data and 'b' in data and 'a' in data:
symbol = data['s']
mid = (float(data['b']) + float(data['a'])) / 2
await on_price_update(symbol, mid)
if __name__ == '__main__':
asyncio.run(listen())
Triangular arbitrage is one of the most intellectually clean strategies in crypto trading — the logic is pure math, the risk is execution risk, and the edge is real. The Python detector above gives you a working foundation; the backtesting framework helps you validate signal frequency before going live; and the Kelly-based position sizing keeps you from overcommitting on any single cycle. Whether you run your own WebSocket infrastructure, pull in signals from VoiceOfChain, or combine both, the key is closing the loop between detection and execution as tightly as possible. In this game, the difference between consistently profitable and break-even is often measured in milliseconds and basis points.