Triangular Arbitrage API Crypto: Build a Bot That Profits
Triangular arbitrage exploits three-currency price loops on a single exchange. Learn how to detect these opportunities with crypto APIs and automate execution using Python code.
Triangular arbitrage exploits three-currency price loops on a single exchange. Learn how to detect these opportunities with crypto APIs and automate execution using Python code.
Triangular arbitrage on a crypto exchange works by exploiting a pricing imbalance across three related trading pairs. Start with USDT, buy BTC, use that BTC to buy ETH, then sell ETH back to USDT — if the math is in your favor, you end up with more USDT than you started. It sounds simple, and conceptually it is. The hard part is finding these opportunities in milliseconds and executing all three legs before the window closes. That is exactly what an API-driven bot does, and building one is more accessible than most traders assume.
A triangular arbitrage opportunity arises when three currency pairs on the same exchange form a mispriced cycle. For example, if the BTC/USDT, ETH/BTC, and ETH/USDT rates are slightly out of sync with each other, converting through all three in sequence yields a net profit. This is distinct from cross-exchange arbitrage, which moves funds between platforms and introduces withdrawal delays. Triangular arbitrage stays inside one exchange — Binance, Bybit, OKX — which means execution is fast and you only need one account's API credentials. There is no blockchain transfer latency to worry about.
The math behind the strategy is deterministic. Given three pairs P1, P2, P3, you compute the product of their conversion rates and compare it against 1.0, adjusted for fees. If the result exceeds 1.0, the trade is profitable. In practice, fees on Binance spot run 0.1% per leg, meaning three legs cost 0.3% minimum. The raw spread in the market must exceed that for a trade to clear a profit. On top-tier pairs like BTC/USDT and ETH/BTC, these windows are rare and short-lived — often measured in single-digit milliseconds. On mid-cap pairs available on exchanges like Gate.io or KuCoin, spreads occasionally persist long enough for a well-architected bot to capture them consistently. Understanding where competition is thinner is half the battle.
The fastest path to working API access across multiple exchanges is the ccxt library, which provides a unified Python interface for Binance, Bybit, OKX, KuCoin, Gate.io, and over a hundred more platforms. Install it with pip install ccxt. Generate API keys in your exchange account security settings — enable spot trading permissions and restrict keys to specific IP addresses whenever the exchange allows it. Never hardcode credentials in source files. Load them from environment variables or a secrets manager. The initialization patterns differ slightly by exchange, particularly for OKX which requires a passphrase in addition to key and secret.
import ccxt
import os
# Binance initialization
exchange = ccxt.binance({
'apiKey': os.environ.get('BINANCE_API_KEY'),
'secret': os.environ.get('BINANCE_SECRET'),
'options': {'defaultType': 'spot'}
})
# Bybit — same structure, swap the class:
# exchange = ccxt.bybit({'apiKey': ..., 'secret': ...})
# OKX requires an additional passphrase:
# exchange = ccxt.okx({
# 'apiKey': os.environ.get('OKX_API_KEY'),
# 'secret': os.environ.get('OKX_SECRET'),
# 'password': os.environ.get('OKX_PASSPHRASE')
# })
try:
balance = exchange.fetch_balance()
usdt_free = balance['USDT']['free']
print(f"USDT available for trading: {usdt_free}")
except ccxt.AuthenticationError as e:
print(f"Auth failed — check your API key and secret: {e}")
except ccxt.NetworkError as e:
print(f"Network error — verify connectivity: {e}")
except ccxt.ExchangeError as e:
print(f"Exchange returned an error: {e}")
Security critical: always restrict API keys to specific IP addresses and enable only spot trading — never withdrawal permissions. A leaked key with withdrawal access can drain your account in seconds. Rotate keys regularly and monitor for unexpected activity.
The scanner's job is to fetch current prices for all relevant pairs, compute the theoretical profit for each triangle path, and surface the ones that exceed your minimum threshold. Rather than computing every possible combination of pairs — which is slow and mostly noise — predefine a shortlist of viable triangles based on trading volume. Focus on high-liquidity pairs: BTC/USDT, ETH/BTC, ETH/USDT, BNB/BTC, BNB/USDT, and a selection of mid-cap alts your target exchange lists with decent depth. The key efficiency trick is using fetch_tickers() to pull all prices in a single API call rather than querying pairs one by one.
import ccxt
def scan_triangles(exchange, min_profit_pct=0.3):
"""
Scan predefined triangle paths for net-positive arbitrage opportunities.
Adjust min_profit_pct based on your actual fee tier.
"""
tickers = exchange.fetch_tickers() # single call — much faster than per-pair fetches
triangles = [
('BTC/USDT', 'ETH/BTC', 'ETH/USDT'),
('BTC/USDT', 'BNB/BTC', 'BNB/USDT'),
('ETH/USDT', 'SOL/ETH', 'SOL/USDT'),
('BTC/USDT', 'XRP/BTC', 'XRP/USDT'),
('BTC/USDT', 'LTC/BTC', 'LTC/USDT'),
]
fee = 0.001 # 0.1% per leg — standard Binance spot rate
results = []
for tri in triangles:
try:
p1, p2, p3 = tri
ask1 = tickers[p1]['ask'] # Buy coin1 with USDT
ask2 = tickers[p2]['ask'] # Buy coin2 with coin1
bid3 = tickers[p3]['bid'] # Sell coin2 back to USDT
after_leg1 = (1.0 / ask1) * (1 - fee)
after_leg2 = (after_leg1 / ask2) * (1 - fee)
final_usdt = after_leg2 * bid3 * (1 - fee)
profit_pct = (final_usdt - 1.0) * 100
if profit_pct >= min_profit_pct:
results.append({
'path': f"{p1} -> {p2} -> {p3}",
'profit_pct': round(profit_pct, 4),
'rates': {'ask1': ask1, 'ask2': ask2, 'bid3': bid3}
})
except (KeyError, TypeError, ZeroDivisionError):
continue # skip pairs not listed or with missing data
return sorted(results, key=lambda x: x['profit_pct'], reverse=True)
exchange = ccxt.binance({'apiKey': 'YOUR_KEY', 'secret': 'YOUR_SECRET'})
opportunities = scan_triangles(exchange, min_profit_pct=0.25)
for opp in opportunities:
print(f"{opp['path']} => +{opp['profit_pct']}%")
This scanner works fine for initial testing but has a fundamental production limitation: it polls on demand rather than streaming. By the time you call scan_triangles(), receive the data, compute the math, and send an order, a significant window may have already closed. Professional triangular arb systems subscribe to WebSocket feeds on Binance or Bybit, maintaining an in-memory price table that updates in real time. When a new tick arrives that pushes a triangle above threshold, the execution fires immediately from cached data — no REST round-trip needed. Platforms like VoiceOfChain provide complementary real-time market signal feeds that help contextualize when markets are volatile versus range-bound, which is useful information for deciding whether to pause your scanner during choppy conditions.
Execution is where theory meets reality and where most bots fail in production. If leg 1 fills and leg 2 is rejected due to a rate limit or insufficient minimum order size, you are now holding an unintended BTC position with no automatic resolution. This partial-fill scenario is the primary operational risk of triangular arbitrage. Your execution code must handle every failure mode with clear logging so you can intervene manually if automation breaks down. Never run untested execution code with real capital — always start with dry_run=True until you have verified that your fill prices match your theoretical model within acceptable slippage.
import ccxt
import logging
import time
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s'
)
logger = logging.getLogger('arb_executor')
def execute_triangle(exchange, path, usdt_amount, dry_run=True):
"""
Execute a triangular arbitrage across three market orders.
path: tuple like ('BTC/USDT', 'ETH/BTC', 'ETH/USDT')
dry_run: always True until production-validated
"""
p1, p2, p3 = path
placed = []
try:
if dry_run:
logger.info(f"[DRY RUN] path={path} amount=${usdt_amount} USDT")
return {'status': 'dry_run', 'path': path}
# Leg 1: USDT -> coin1
t1 = exchange.fetch_ticker(p1)
qty1 = (usdt_amount / t1['ask']) * 0.999 # small buffer for rounding
order1 = exchange.create_market_buy_order(p1, qty1)
placed.append(order1)
logger.info(f"Leg1 filled: {order1['filled']} {p1.split('/')[0]} @ avg {order1['average']}")
time.sleep(0.05)
# Leg 2: coin1 -> coin2
t2 = exchange.fetch_ticker(p2)
qty2 = (order1['filled'] / t2['ask']) * 0.999
order2 = exchange.create_market_buy_order(p2, qty2)
placed.append(order2)
logger.info(f"Leg2 filled: {order2['filled']} {p2.split('/')[0]} @ avg {order2['average']}")
time.sleep(0.05)
# Leg 3: coin2 -> USDT
order3 = exchange.create_market_sell_order(p3, order2['filled'])
placed.append(order3)
logger.info(f"Leg3 returned: {order3['cost']} USDT")
net_pct = ((order3['cost'] - usdt_amount) / usdt_amount) * 100
logger.info(f"Net result: {net_pct:+.4f}%")
return {'status': 'success', 'net_pct': net_pct, 'orders': [o['id'] for o in placed]}
except ccxt.InsufficientFunds:
logger.error("Insufficient funds — lower usdt_amount or check balances")
return {'status': 'error', 'reason': 'insufficient_funds', 'partial': [o['id'] for o in placed]}
except ccxt.NetworkError as e:
logger.error(f"Network dropped mid-trade: {e} — CHECK OPEN POSITIONS IMMEDIATELY")
return {'status': 'error', 'reason': 'network', 'partial': [o['id'] for o in placed]}
except ccxt.ExchangeError as e:
logger.error(f"Exchange rejected an order: {e}")
return {'status': 'error', 'reason': str(e), 'partial': [o['id'] for o in placed]}
# Usage
exchange = ccxt.binance({'apiKey': 'KEY', 'secret': 'SECRET'})
path = ('BTC/USDT', 'ETH/BTC', 'ETH/USDT')
result = execute_triangle(exchange, path, usdt_amount=1000, dry_run=True)
print(result)
Note the 0.999 buffer on quantity calculations. Exchange minimum lot sizes and precision rules often reject orders that are too precise or slightly over your available balance after fees. The buffer gives you a small margin of safety. In production, you should also call exchange.load_markets() at startup to retrieve the exact precision rules for each pair and use exchange.amount_to_precision() to format quantities correctly before placing orders. Bybit and OKX have slightly different precision handling than Binance, so test each exchange independently before going live.
Triangular arbitrage looks compelling on paper and genuinely works — but the gap between theoretical profit and realized profit is where most bots get humbled. Three factors systematically erode your edge: compounding trading fees, network latency, and market-order slippage. Understanding each one honestly before deploying capital will save you from expensive surprises.
| Exchange | Spot Fee (Maker/Taker) | WebSocket Feed | REST Rate Limit |
|---|---|---|---|
| Binance | 0.10% / 0.10% | Yes | 1200 req/min |
| Bybit | 0.10% / 0.10% | Yes | 600 req/min |
| OKX | 0.08% / 0.10% | Yes | 600 req/min |
| KuCoin | 0.10% / 0.10% | Yes | 600 req/min |
| Gate.io | 0.20% / 0.20% | Yes | 900 req/min |
Fees define your floor. Standard Binance spot at 0.1% per leg means three legs cost 0.3% of your position before any profit calculation. Pay fees using BNB on Binance and that drops to roughly 0.225%. Your gross spread must exceed this threshold every single time, not just on average. On high-frequency pairs like BTC/USDT and ETH/BTC, the spread almost never appears cleanly — sophisticated market makers and colocated HFT systems are watching the same order books with purpose-built networking stacks. Where retail bots have a fighting chance is on mid-cap pairs: think SOL/ETH or XRP/BTC on OKX or Bybit, where fewer dedicated arb systems compete and spreads occasionally widen enough to clear your fee cost.
Latency compounds the problem. Every millisecond your scanner spends fetching prices or your orders spend traversing the public internet is a millisecond for the market to move against you. If you are running a latency-sensitive arb strategy on Binance, deploying on AWS ap-southeast-1 (Singapore) — colocated with Binance's matching engine — cuts round-trip time from 100-200ms on a home connection to under 5ms. Bybit's infrastructure runs similarly on AWS Tokyo. That difference is often the line between capturing an opportunity and arriving to find it closed. For context on broader market conditions that affect when arbitrage windows open and close, real-time signal platforms like VoiceOfChain provide actionable data on volatility and liquidity that complements your own scanning logic.
Slippage trap: market orders on pairs with thin order books can move price against you mid-execution. Before sizing a trade, always check the bid-ask spread and available depth at your expected fill quantity. A 0.5% spread on leg 3 alone wipes out the entire theoretical profit from legs 1 and 2.
Triangular arbitrage with a crypto API is not a passive income machine — it is a precision engineering problem that rewards discipline over cleverness. The math is straightforward, the tooling is accessible via ccxt, and the opportunities are real. What separates the bots that make money from the ones that quietly bleed it are details: accurate fee accounting, latency-aware architecture, robust partial-fill handling, and honest expectations about where you sit in the competitive landscape. Start on Binance or Bybit in dry-run mode, instrument every leg of the trade, measure your actual fill prices against theoretical, and only move to live execution once the numbers align. Combine your scanner with a real-time signal layer like VoiceOfChain to avoid trading into adverse market conditions — knowing when not to trade is as valuable as knowing when to.