◈   ∿ algotrading · Intermediate

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.

Uncle Solieditor · voc · 06.05.2026 ·views 14
◈   Contents
  1. → What Is Triangular Arbitrage in Crypto?
  2. → How the Detection Algorithm Works
  3. → Building the Detection Algorithm in Python
  4. → Backtesting and Performance Metrics
  5. → Risk Management and Position Sizing
  6. → Wiring Up a Live WebSocket Price Feed
  7. → Frequently Asked Questions
  8. → Conclusion

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.

What Is Triangular Arbitrage in Crypto?

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.

How the Detection Algorithm Works

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.

Building the Detection Algorithm in Python

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 and Performance Metrics

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.

Risk Management and Position Sizing

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}
Fee and latency benchmarks across major exchanges (standard tier)
ExchangeMaker FeeTaker FeeWebSocket LatencyArb Viability
Binance0.02%0.04%~20msHigh
Bybit0.01%0.06%~25msHigh
OKX0.02%0.05%~30msMedium-High
KuCoin0.05%0.10%~50msMedium
Gate.io0.05%0.10%~60msMedium
Coinbase Advanced0.00%0.05%~40msMedium

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.

Wiring Up a Live WebSocket Price Feed

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())

Frequently Asked Questions

Is triangular arbitrage still profitable in crypto in 2025?
Yes, but edges are thinner than they were in 2020-2021. On major pairs at Binance and OKX, opportunities still appear dozens of times per day — typically 0.1-0.3% gross — but they close within seconds. Profitability depends heavily on your fee tier and execution speed.
Do I need to co-locate my server near the exchange?
For the most competitive edges, yes. A server in AWS Tokyo for Binance or Singapore for Bybit reduces round-trip latency to 5-20ms, meaningfully increasing the number of windows you can catch. For slower or larger-spread opportunities, a standard VPS in the right region works fine.
What is the minimum capital needed for triangular arbitrage?
The math works at any size, but a 0.2% profit needs to produce a meaningful dollar return after fees. Most practitioners start with $5,000-$20,000 per exchange. Below that, fixed transaction minimums and fee structures eat most of the theoretical gains.
How many pairs should I monitor simultaneously?
On Binance, monitoring 10-15 highly liquid USDT and BTC pairs gives you roughly 300-1,000 triplet combinations and covers the vast majority of opportunity volume. Extending beyond 30 pairs adds noise but diminishing returns — illiquid pairs rarely produce executable arbitrage at meaningful size.
What are the main risks that can cause real losses?
Partial fills are the biggest one — if leg 2 fills at a worse price or fails entirely, you hold an unintended position. API rate limits, network interruptions, and sudden volatility spikes can also disrupt execution mid-cycle. Always have a defined hedge or exit logic for incomplete three-leg sequences.
Can triangular arbitrage be done on DeFi, not just CEX?
Yes, and DeFi actually eliminates execution risk via flash loans — you borrow, execute all three swaps, and repay atomically in one transaction, so it either profits or reverts completely. The tradeoff is that it requires Solidity skills, gas cost modeling, and competition from highly optimized MEV bots.

Conclusion

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.

◈   more on this topic
⌘ api Kraken API Documentation for Crypto Traders: Essentials and Examples ◉ basics Mastering the ccxt library documentation for crypto traders