DEX与CEX搬砖
对同一币种在链上DEX与中心化交易所(CEX)现货之间的价差进行套利:按每笔数量分别获取两边的可成交价格,扣除手续费、gas和安全垫后,若净利达到门槛,则在两边各执行一笔交易。
做法
- 获取价格:DEX端使用
exchange.IO("route")按每笔数量QTY询价(买入、卖出各询价一次,取最优路径,报价已包含池子手续费与价格冲击);CEX端使用exchange.GetDepth获取盘口深度,按QTY逐档模拟吃单并计算成交均价。 - 分别计算两个方向的净利:DEX买入 → CEX卖出 = CEX卖出所得 × (1 −
CEX_FEE) − DEX买入花费 −DEX_GAS_USD;CEX买入 → DEX卖出 = DEX卖出所得 − CEX买入花费 × (1 +CEX_FEE) −DEX_GAS_USD。 - 当净利(以bps计,相对于成交额)扣除安全垫
SAFETY_BPS后仍不低于MIN_NET_BPS,且不在冷却期内、两边库存充足、基础币持仓不超过MAX_BASE时执行交易:先执行DEX腿(链上交易可能失败或产生滑点,限价让出SLIPPAGE),通过exchange.GetOrder等待兑换交易上链并确认成交量,再按该成交量在CEX下限价单。 - 两边均需预先备好基础币和计价币,策略本身不执行转账;当库存集中到一边后,只能等待反向套利机会,任一边库存不足时不执行交易。
- 状态栏:显示两个方向的DEX价格、CEX成交均价、毛价差、净利、判断结论与DEX路径,以及两边库存。
交易所对象
需配置两个交易所对象,顺序如下:exchanges[0]为DEX(Uniswap / PancakeSwap、Aerodrome / Velodrome、Jupiter等),exchanges[1]为CEX现货(如Binance)。两边的计价币最好保持一致(默认均为ETH_USDC),否则还需承担稳定币脱锚的风险。
参数
| 参数 | 默认值 | 说明 |
|---|---|---|
DEX_SYMBOL | ETH_USDC | DEX交易对 |
CEX_SYMBOL | ETH_USDC | CEX交易对(须为同一币种,计价币最好相同) |
QTY | 0.01 | 每笔交易数量(以基础币计) |
DEX_GAS_USD | 0.05 | DEX每笔兑换的gas费用(美元):Base、Arbitrum约0.01~0.1,Solana约0.001,以太坊主网约2~10 |
CEX_FEE | 0.001 | CEX吃单(Taker)手续费率 |
SAFETY_BPS | 10 | 安全垫(bps):用于覆盖从报价到成交期间的价格变动 |
MIN_NET_BPS | 20 | 扣除安全垫后的净利门槛(bps) |
SLIPPAGE | 0.003 | 下单价格保护:DEX限价与CEX限价各让出0.3% |
MAX_BASE | 0.1 | 每边基础币持仓上限 |
COOLDOWN_SEC | 60 | 每次搬砖后的冷却时间(秒) |
INTERVAL | 15 | 检查间隔(秒) |
PAPER_BASE | 0.05 | DRY_RUN纸面库存:每边的初始基础币数量 |
PAPER_QUOTE | 200 | DRY_RUN纸面库存:每边的初始计价币数量 |
DRY_RUN | true | 为true时仅记录和模拟,不实际下单 |
风险
- 两条腿并非原子执行:DEX腿成交后价格可能已发生变化,导致CEX腿以更差的价格成交;若CEX腿下单失败,将留下单边敞口(日志会标红提示,需人工处理)。
- 链上交易可能遭遇抢跑或三明治攻击(MEV),也可能回滚(仅扣除gas),且在等待上链期间价格同样会变动。
- 价差通常很小,在gas较高的链(如以太坊主网)上基本没有套利机会。
DRY_RUN与实盘
当DRY_RUN为true(默认)时不实际下单:仅记录“将会执行”的交易,DEX腿通过exchange.IO("simulate")进行模拟(Jupiter不支持simulate,仅查看询价结果;钱包余额不足时模拟会报错,属正常现象),库存按PAPER_BASE、PAPER_QUOTE记录纸面账目。将DRY_RUN改为false即切换为实盘,需先在两边备好库存,QTY建议从小额开始。
示例
完整策略代码(DRY_RUN = true 时仅记录并模拟,不实际下单)。
javascript
/*
* DEX ↔ CEX 现货搬砖(范例策略)
*
* 同一个币在链上 DEX 与中心化交易所现货之间的价差套利:按每笔交易量 QTY 取两边的可成交价——
* DEX:exchange.IO("route", 交易对, "buy"/"sell", QTY) 的最优路径报价(已含池子手续费与价格冲击);
* CEX:GetDepth 盘口按 QTY 逐档吃单的均价;
* 两个方向分别算净利:
* DEX 买 → CEX 卖:CEX 卖出所得 × (1 − CEX_FEE) − DEX 买入花费 − DEX_GAS_USD
* CEX 买 → DEX 卖:DEX 卖出所得 − CEX 买入花费 × (1 + CEX_FEE) − DEX_GAS_USD
* 净利(bps,相对成交额)扣掉安全垫 SAFETY_BPS 后仍不低于 MIN_NET_BPS 时,两边各做一笔:先做 DEX 腿
* (链上可能失败或滑点),确认成交后再按成交量做 CEX 腿。两边都要事先备好币和计价币(不转账,库存
* 搬到一边以后只能等反向机会),每边的基础币持仓不超过 MAX_BASE,单边不够就不做。
*
* DRY_RUN = true(默认)不下单:记录"会做的"交易,DEX 腿用 IO("simulate", …) 模拟(Jupiter 没有 simulate,
* 只看询价;钱包余额不够时模拟报错,属正常),库存按 PAPER_* 起始值记纸面账。
*
* 风险:两条腿不是原子的,DEX 腿成交后价格可能已变,CEX 腿以更差的价格成交;链上交易可能被抢跑(MEV)、
* 回滚(只扣 gas)或等待上链期间价格变化;CEX 和 DEX 的计价币不同(如 USDT 与 USDC)时还有脱锚风险,
* 默认两边用同一个计价币。价差通常很小,gas 高的链(以太坊主网)基本没有机会。
*
* 用法:exchanges[0] 添加 DEX(Uniswap、PancakeSwap、Aerodrome、Velodrome、Jupiter 等),exchanges[1]
* 添加 CEX 现货(如 Binance);两边都放好基础币和计价币。
*/
// ---------------- 参数 ----------------
var DEX_SYMBOL = "ETH_USDC" // DEX 交易对
var CEX_SYMBOL = "ETH_USDC" // CEX 交易对(同一个币;计价币最好相同)
var QTY = 0.01 // 每笔数量(基础币)
var DEX_GAS_USD = 0.05 // DEX 每笔兑换的 gas(美元):Base/Arbitrum 约 0.01~0.1,Solana 约 0.001,以太坊主网 2~10
var CEX_FEE = 0.001 // CEX 吃单手续费率
var SAFETY_BPS = 10 // 安全垫(bps):报价到成交之间的价格变动
var MIN_NET_BPS = 20 // 扣安全垫后的净利门槛(bps)
var SLIPPAGE = 0.003 // 下单价格保护:DEX 限价与 CEX 限价各让出 0.3%
var MAX_BASE = 0.1 // 每边基础币持仓上限
var COOLDOWN_SEC = 60 // 每次搬砖后的冷却时间
var INTERVAL = 15 // 检查间隔(秒)
var PAPER_BASE = 0.05 // DRY_RUN 纸面库存:每边的起始基础币
var PAPER_QUOTE = 200 // DRY_RUN 纸面库存:每边的起始计价币
var DRY_RUN = true // true 只记录与模拟,不下单
var dex, cex
var paper = null // DRY_RUN 纸面库存 {dex: {base, quote}, cex: {base, quote}}
var stats = _G("arb_stats") || { trades: 0, pnl: 0 }
var lastTrade = 0
var cexDecimals = 4 // CEX 数量精度(启动时从 GetMarkets 读)
function fmt(x, d) {
return x === null || x === undefined || isNaN(x) ? "-" : Number(x).toFixed(d === undefined ? 2 : d)
}
// 盘口按数量逐档吃:返回 {avg, worst},深度不够返回 null
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 } }
}
// 等 DEX 兑换上链,返回成交的基础币数量(失败返回 0)
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 订单", id, "两分钟未确认,不做 CEX 腿,请人工检查", "#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("搬砖", op.name, "数量", QTY, "DEX", dexSide, fmt(op.dexPrice, 4), "CEX", cexSide, fmt(op.cexAvg, 4),
"预计净利", 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 通过:" + sim.route : "DEX simulate 未通过(钱包余额不够时正常):" + 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 下单失败(不做 CEX 腿):", GetLastError(), "#FF0000"); return }
var filled = dexFill(id)
if (filled <= 0) { Log("DEX 腿未成交,不做 CEX 腿"); return }
var p = Math.pow(10, cexDecimals)
var cid = cex.CreateOrder(CEX_SYMBOL, cexSide, cexPx, Math.floor(filled * p) / p)
if (!cid) { Log("CEX 腿下单失败,单边敞口", filled, "请人工处理:", 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("取价失败:" + GetLastError())
var bid = walk(depth.Bids, QTY), ask = walk(depth.Asks, QTY)
var ops = []
if (buy.price && bid) { // A:DEX 买 → CEX 卖
var netA = bid.avg * QTY * (1 - CEX_FEE) - buy.price * QTY - DEX_GAS_USD
ops.push({ dir: "A", name: "DEX 买 → CEX 卖", dexPrice: buy.price, route: buy.best || buy.route, cexAvg: bid.avg, cexWorst: bid.worst, net: netA, netBps: 0 })
}
if (sell.price && ask) { // B:CEX 买 → DEX 卖
var netB = sell.price * QTY - ask.avg * QTY * (1 + CEX_FEE) - DEX_GAS_USD
ops.push({ dir: "B", name: "CEX 买 → DEX 卖", 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 = "未达门槛"
else if (Date.now() - lastTrade < COOLDOWN_SEC * 1000) block = "冷却中"
else if (op.dir === "A" && (inv.dex.quote < op.dexPrice * QTY * (1 + SLIPPAGE) || inv.cex.base < QTY || inv.dex.base + QTY > MAX_BASE)) block = "库存不足/超上限"
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 = "库存不足/超上限"
if (!block) { execute(op, inv); block = DRY_RUN ? "已记录(DRY_RUN)" : "已执行" }
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: "价差(每笔 " + QTY + ",DEX 报价含池子手续费,净利扣 CEX 手续费与 gas " + DEX_GAS_USD + ")",
cols: ["方向", "DEX 价", "CEX 均价", "毛价差 bps", "净利", "净利 bps", "结论", "DEX 路径"], rows: rows }
var t2 = { type: "table", title: "库存" + (DRY_RUN ? "(DRY_RUN 纸面)" : ""), cols: ["", "基础币", "计价币"], 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(), "搬砖次数", stats.trades, "累计预计净利", fmt(stats.pnl, 4), "\n`" + JSON.stringify([t1, t2]) + "`")
}
function main() {
if (exchanges.length < 2) throw new Error("需要两个交易所对象:exchanges[0] 为 DEX,exchanges[1] 为 CEX 现货")
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:只记录不下单" : "实盘")
while (true) {
try {
scan()
} catch (e) {
Log("本轮出错:", e.message || e, "#FF0000")
}
Sleep(INTERVAL * 1000)
}
}参考