DEX-CEX Arbitrage
Arbitrages the price spread for the same token between an on-chain DEX and a centralized exchange (CEX) spot market. For each trade size, it gets the executable price on each side and subtracts fees, gas and a safety buffer. If the net profit reaches the threshold, it places one trade on each side.
How It Works
- Getting prices: on the DEX side,
exchange.IO("route")requests a quote for the per-trade sizeQTY(one quote for buying and one for selling, using the best route; quotes already include pool fees and price impact). On the CEX side,exchange.GetDepthgets the order book depth, and the strategy simulates fillingQTYlevel by level to work out the average fill price. - The net profit is calculated for each direction. DEX buy → CEX sell = CEX sell proceeds × (1 −
CEX_FEE) − DEX buy cost −DEX_GAS_USD. CEX buy → DEX sell = DEX sell proceeds − CEX buy cost × (1 +CEX_FEE) −DEX_GAS_USD. - A trade is placed when all of these are true: the net profit (in bps of trade value), after subtracting the safety buffer
SAFETY_BPS, is still at leastMIN_NET_BPS; no cooldown is active; both sides hold enough inventory; and the base-currency position is no more thanMAX_BASE. The DEX leg goes first, because on-chain transactions can fail or slip, so its limit price allowsSLIPPAGE.exchange.GetOrderwaits for the swap to be confirmed on-chain and checks the filled amount. Then a limit order for that filled amount is placed on the CEX. - Both sides must hold base and quote currency in advance; the strategy does not make any transfers. Once inventory builds up on one side, it can only wait for an arbitrage opportunity in the opposite direction. No trade is placed if either side runs low on inventory.
- Status bar: for each direction, shows the DEX price, CEX average fill price, gross spread, net profit, decision and DEX route. It also shows the inventory on both sides.
Exchange Objects
Add two exchange objects in this order: exchanges[0] is the DEX (Uniswap / PancakeSwap, Aerodrome / Velodrome, Jupiter, etc.), and exchanges[1] is the CEX spot exchange (e.g. Binance). Use the same quote currency on both sides if you can (both default to ETH_USDC). Otherwise you are also exposed to the risk of a stablecoin losing its peg.
Parameters
| Parameter | Default | Description |
|---|---|---|
DEX_SYMBOL | ETH_USDC | DEX trading pair |
CEX_SYMBOL | ETH_USDC | CEX trading pair (must be the same token; ideally the same quote currency) |
QTY | 0.01 | Size of each trade (in base currency) |
DEX_GAS_USD | 0.05 | Gas cost per DEX swap (USD): about 0.01–0.1 on Base and Arbitrum, about 0.001 on Solana, about 2–10 on Ethereum mainnet |
CEX_FEE | 0.001 | CEX taker fee rate |
SAFETY_BPS | 10 | Safety buffer (bps): covers price moves between the quote and the fill |
MIN_NET_BPS | 20 | Net profit threshold after the safety buffer (bps) |
SLIPPAGE | 0.003 | Order price protection: the DEX and CEX limit prices each allow 0.3% |
MAX_BASE | 0.1 | Maximum base-currency position on each side |
COOLDOWN_SEC | 60 | Cooldown after each arbitrage trade (seconds) |
INTERVAL | 15 | Check interval (seconds) |
PAPER_BASE | 0.05 | DRY_RUN paper inventory: starting base currency on each side |
PAPER_QUOTE | 200 | DRY_RUN paper inventory: starting quote currency on each side |
DRY_RUN | true | When true, only logs and simulates; no real orders are placed |
Risks
- The two legs are not atomic. After the DEX leg fills, the price may have moved, so the CEX leg fills at a worse price. If the CEX order fails, you are left with one-sided exposure (the log shows it in red, and you must handle it manually).
- On-chain transactions can be front-run or sandwich-attacked (MEV). They can also revert, which still costs gas. Prices also move while waiting for confirmation.
- Spreads are usually small. On chains with high gas costs (such as Ethereum mainnet), there are almost no arbitrage opportunities.
DRY_RUN and Live Trading
When DRY_RUN is true (the default), no real orders are placed. The strategy only logs the trades it "would execute". The DEX leg is simulated with exchange.IO("simulate"). Jupiter does not support simulate, so only the quote is checked. The simulation reports an error when the wallet balance is too low; this is expected. Inventory is tracked as paper balances starting from PAPER_BASE and PAPER_QUOTE. Set DRY_RUN to false to trade live. Put inventory on both sides first, and start with a small QTY.
Examples
Full strategy code (with DRY_RUN = true it only logs and simulates, without placing real orders).
javascript
/*
* DEX ↔ CEX Spot Arbitrage (Example Strategy)
*
* Arbitrages the price spread of the same coin between an on-chain DEX and a centralized exchange's spot market: for each trade size QTY, obtains the executable price on both sides:
* DEX: the best-route quote from exchange.IO("route", trading pair, "buy"/"sell", QTY) (already includes liquidity pool fees and price impact);
* CEX: the average fill price obtained by walking the GetDepth order book level by level for QTY;
* Computes the net profit for each direction:
* DEX buy → CEX sell: CEX sell proceeds × (1 − CEX_FEE) − DEX buy cost − DEX_GAS_USD
* CEX buy → DEX sell: DEX sell proceeds − CEX buy cost × (1 + CEX_FEE) − DEX_GAS_USD
* When the net profit (bps, relative to notional) minus the safety buffer SAFETY_BPS is still at least MIN_NET_BPS, one trade is executed on each side: the DEX leg first
* (on-chain trades may fail or incur slippage), then, once the fill is confirmed, the CEX leg for the actual filled amount. Both sides must be pre-funded with base and quote currency (the strategy makes no transfers; once inventory
* piles up on one side it can only wait for a reverse opportunity), base-currency holdings on each side never exceed MAX_BASE, and no trade is made if either side lacks inventory.
*
* With DRY_RUN = true (default) no orders are placed: trades that "would have been executed" are only logged, and the DEX leg is simulated with IO("simulate", …) (Jupiter does not support simulate,
* quote only; simulation reports an error when the wallet balance is insufficient, which is normal), and inventory is paper-tracked starting from PAPER_*.
*
* Risks: the two legs are not executed atomically, so the price may have moved after the DEX leg fills, causing the CEX leg to fill at a worse price; on-chain trades may be front-run (MEV),
* reverted (only gas is charged), or hit by price changes while waiting for confirmation; when the CEX and DEX quote currencies differ (e.g. USDT vs. USDC) there is also depeg risk,
* so by default both sides use the same quote currency. Spreads are usually tiny, and on high-gas chains (such as Ethereum mainnet) there are virtually no opportunities.
*
* Usage: add a DEX as exchanges[0] (Uniswap, PancakeSwap, Aerodrome, Velodrome, Jupiter, etc.), and as exchanges[1]
* add a CEX spot exchange (e.g. Binance); both sides must be funded with base and quote currency.
*/
// ---------------- Parameters ----------------
var DEX_SYMBOL = "ETH_USDC" // DEX trading pair
var CEX_SYMBOL = "ETH_USDC" // CEX trading pair (same coin; ideally the same quote currency)
var QTY = 0.01 // Size per trade (base currency)
var DEX_GAS_USD = 0.05 // Gas per DEX swap (USD): Base/Arbitrum ~0.01–0.1, Solana ~0.001, Ethereum mainnet 2–10
var CEX_FEE = 0.001 // CEX taker fee rate
var SAFETY_BPS = 10 // Safety buffer (bps): covers price movement between quote and fill
var MIN_NET_BPS = 20 // Net profit threshold after the safety buffer (bps)
var SLIPPAGE = 0.003 // Order price protection: DEX and CEX limit prices each concede 0.3%
var MAX_BASE = 0.1 // Max base-currency holding per side
var COOLDOWN_SEC = 60 // Cooldown after each arbitrage trade (seconds)
var INTERVAL = 15 // Check interval (seconds)
var PAPER_BASE = 0.05 // DRY_RUN paper inventory: initial base-currency amount per side
var PAPER_QUOTE = 200 // DRY_RUN paper inventory: initial quote-currency amount per side
var DRY_RUN = true // true: log and simulate only, no real orders
var dex, cex
var paper = null // DRY_RUN paper inventory {dex: {base, quote}, cex: {base, quote}}
var stats = _G("arb_stats") || { trades: 0, pnl: 0 }
var lastTrade = 0
var cexDecimals = 4 // CEX amount precision (read from GetMarkets at startup)
function fmt(x, d) {
return x === null || x === undefined || isNaN(x) ? "-" : Number(x).toFixed(d === undefined ? 2 : d)
}
// Walk the order book level by level for the given quantity: returns {avg, worst}, or null if depth is insufficient
function walk(levels, qty) {
var left = qty, cost = 0
for (var i = 0; i < levels.length && left > 1e-12; i++) {
var q = Math.min(left, levels[i].Amount)
cost += q * levels[i].Price
left -= q
if (left <= 1e-12) return { avg: cost / qty, worst: levels[i].Price }
}
return null
}
function inventory() {
if (DRY_RUN) return paper
var a = dex.GetAccount(), b = cex.GetAccount()
if (!a || !b) throw new Error("GetAccount failed: " + GetLastError())
return { dex: { base: a.Stocks, quote: a.Balance }, cex: { base: b.Stocks, quote: b.Balance } }
}
// Wait for the DEX swap to be confirmed on-chain; returns the filled base-currency amount (0 on failure)
function dexFill(id) {
for (var i = 0; i < 60; i++) {
var o = dex.GetOrder(id)
if (o && o.Status === ORDER_STATE_CLOSED) return o.DealAmount
if (o && o.Status === ORDER_STATE_CANCELED) return 0
Sleep(2000)
}
Log("DEX order", id, "not confirmed after two minutes; CEX leg skipped, please check manually", "#FF0000")
return 0
}
function execute(op, inv) {
var dexSide = op.dir === "A" ? "buy" : "sell", cexSide = op.dir === "A" ? "sell" : "buy"
var dexPx = op.dexPrice * (dexSide === "buy" ? 1 + SLIPPAGE : 1 - SLIPPAGE)
var cexPx = op.cexWorst * (cexSide === "buy" ? 1 + SLIPPAGE : 1 - SLIPPAGE)
Log("Arbitrage", op.name, "qty", QTY, "DEX", dexSide, fmt(op.dexPrice, 4), "CEX", cexSide, fmt(op.cexAvg, 4),
"est. net profit", fmt(op.net, 4), "(" + fmt(op.netBps, 1) + " bps)", "#0000FF")
lastTrade = Date.now()
if (DRY_RUN) {
if (dex.GetName() !== "Jupiter") {
var sim = dex.IO("simulate", DEX_SYMBOL, dexSide, QTY, dexPx)
Log(sim ? "DEX simulate passed: " + sim.route : "DEX simulate failed (normal when the wallet balance is insufficient): " + GetLastError())
}
var sign = op.dir === "A" ? 1 : -1
inv.dex.base += sign * QTY; inv.dex.quote -= sign * op.dexPrice * QTY
inv.cex.base -= sign * QTY; inv.cex.quote += sign * op.cexAvg * QTY * (1 - sign * CEX_FEE)
stats.trades++; stats.pnl += op.net
return
}
var id = dex.CreateOrder(DEX_SYMBOL, dexSide, dexPx, QTY)
if (!id) { Log("DEX order failed (CEX leg skipped):", GetLastError(), "#FF0000"); return }
var filled = dexFill(id)
if (filled <= 0) { Log("DEX leg not filled, CEX leg skipped"); return }
var p = Math.pow(10, cexDecimals)
var cid = cex.CreateOrder(CEX_SYMBOL, cexSide, cexPx, Math.floor(filled * p) / p)
if (!cid) { Log("CEX leg order failed, one-sided exposure", filled, "please handle manually:", GetLastError(), "#FF0000"); return }
stats.trades++; stats.pnl += op.net * filled / QTY
}
function scan() {
var buy = dex.IO("route", DEX_SYMBOL, "buy", QTY)
var sell = dex.IO("route", DEX_SYMBOL, "sell", QTY)
var depth = cex.GetDepth(CEX_SYMBOL)
if (!buy || !sell || !depth) throw new Error("Failed to get prices: " + GetLastError())
var bid = walk(depth.Bids, QTY), ask = walk(depth.Asks, QTY)
var ops = []
if (buy.price && bid) { // A: DEX buy → CEX sell
var netA = bid.avg * QTY * (1 - CEX_FEE) - buy.price * QTY - DEX_GAS_USD
ops.push({ dir: "A", name: "DEX buy → CEX sell", dexPrice: buy.price, route: buy.best || buy.route, cexAvg: bid.avg, cexWorst: bid.worst, net: netA, netBps: 0 })
}
if (sell.price && ask) { // B: CEX buy → DEX sell
var netB = sell.price * QTY - ask.avg * QTY * (1 + CEX_FEE) - DEX_GAS_USD
ops.push({ dir: "B", name: "CEX buy → DEX sell", dexPrice: sell.price, route: sell.best || sell.route, cexAvg: ask.avg, cexWorst: ask.worst, net: netB, netBps: 0 })
}
var inv = inventory()
var rows = ops.map(function (op) {
op.netBps = op.net / (op.cexAvg * QTY) * 10000
var gross = (op.dir === "A" ? op.cexAvg / op.dexPrice - 1 : op.dexPrice / op.cexAvg - 1) * 10000
var block = ""
if (op.netBps - SAFETY_BPS < MIN_NET_BPS) block = "Below threshold"
else if (Date.now() - lastTrade < COOLDOWN_SEC * 1000) block = "Cooling down"
else if (op.dir === "A" && (inv.dex.quote < op.dexPrice * QTY * (1 + SLIPPAGE) || inv.cex.base < QTY || inv.dex.base + QTY > MAX_BASE)) block = "Insufficient inventory/over limit"
else if (op.dir === "B" && (inv.cex.quote < op.cexAvg * QTY * (1 + CEX_FEE + SLIPPAGE) || inv.dex.base < QTY || inv.cex.base + QTY > MAX_BASE)) block = "Insufficient inventory/over limit"
if (!block) { execute(op, inv); block = DRY_RUN ? "Logged (DRY_RUN)" : "Executed" }
return [op.name, fmt(op.dexPrice, 4), fmt(op.cexAvg, 4), fmt(gross, 1), fmt(op.net, 4), fmt(op.netBps, 1), block,
Array.isArray(op.route) ? op.route.join("+") : op.route]
})
_G("arb_stats", stats)
var t1 = { type: "table", title: "Spread (per trade " + QTY + ", DEX quote incl. pool fee, net profit after CEX fee and gas " + DEX_GAS_USD + ")",
cols: ["Direction", "DEX Price", "CEX Avg Price", "Gross Spread bps", "Net Profit", "Net Profit bps", "Result", "DEX Route"], rows: rows }
var t2 = { type: "table", title: "Inventory" + (DRY_RUN ? " (DRY_RUN paper)" : ""), cols: ["", "Base Currency", "Quote Currency"], rows: [
[dex.GetName() + " " + DEX_SYMBOL, fmt(inv.dex.base, 6), fmt(inv.dex.quote, 4)],
[cex.GetName() + " " + CEX_SYMBOL, fmt(inv.cex.base, 6), fmt(inv.cex.quote, 4)]] }
LogStatus(_D(), "Arbitrage trades", stats.trades, "Cumulative est. net profit", fmt(stats.pnl, 4), "\n`" + JSON.stringify([t1, t2]) + "`")
}
function main() {
if (exchanges.length < 2) throw new Error("Two exchange objects required: exchanges[0] is the DEX, exchanges[1] is the CEX spot exchange")
dex = exchanges[0]; cex = exchanges[1]
dex.SetCurrency(DEX_SYMBOL); cex.SetCurrency(CEX_SYMBOL)
var m = (cex.GetMarkets() || {})[CEX_SYMBOL]
if (m) cexDecimals = m.AmountPrecision
if (DRY_RUN) paper = { dex: { base: PAPER_BASE, quote: PAPER_QUOTE }, cex: { base: PAPER_BASE, quote: PAPER_QUOTE } }
Log("DEX", dex.GetName(), DEX_SYMBOL, "CEX", cex.GetName(), CEX_SYMBOL, DRY_RUN ? "DRY_RUN: log only, no orders" : "Live trading")
while (true) {
try {
scan()
} catch (e) {
Log("Error in this round:", e.message || e, "#FF0000")
}
Sleep(INTERVAL * 1000)
}
}See Also