LP自动调仓
在现价±RANGE_PCT%范围内开设单区间仓位赚取手续费;价格连续多次处于区间外时撤出,并以新价格为中心重新建仓,持续做市。定期领取手续费,并设有调仓频率、gas与资金上限护栏。
做法
- 建仓:
exchange.IO("lp.plan")(shape为spot、range为{pct: [-RANGE_PCT, RANGE_PCT]})→exchange.IO("lp.add")。首次建仓按lp.select的规则选池(可通过FEE指定费率),之后的调仓始终使用同一个池子。仓位ID保存在_G中,重启后通过exchange.IO("lp.track")重新登记。 - 每隔
INTERVAL秒通过exchange.IO("lp.positions")查询仓位:是否处于区间内、当前价值及未领取的手续费。 - 连续
OUT_CONFIRM次处于区间外时执行调仓:先用exchange.IO("lp.remove")全部撤出(lp.remove(ids, 1, false),不卖回计价币),再按min(仓位现值,BUDGET)以新价格为中心重新规划并建仓,超出BUDGET的部分留在钱包中。 - 护栏:两次调仓至少间隔
MIN_REBALANCE_MIN分钟,24小时内最多调仓MAX_REBALANCE_PER_DAY次(达到上限后仅监控);新区间的预估日手续费(计划中的estDailyFeesUsd)×PAYBACK_DAYS须能覆盖一次调仓的gas(REBALANCE_TXS×GAS_USD),否则本次不调仓,继续等待。 - 每隔
COLLECT_HOURS小时,且未领取手续费不少于COLLECT_MIN_USD时,通过exchange.IO("lp.collect")领取。 - 状态栏:显示区间、现价、仓位价值、未领取与已领取手续费、调仓次数、gas估计,以及相对于持币不动(HODL:首次建仓时存入的两种代币始终不动)的盈亏。
与「建仓 → 监控 → 出区间或止损即撤出离场」的做法不同,本策略在价格出区间后会跟随价格重新居中,持续做市;离场型策略的简单版本见exchange.IO("lp.add")页面中的完整LP策略示例。
交易所对象
一个Uniswap / PancakeSwap或Aerodrome / Velodrome交易所对象(使用专用钱包),钱包中存放计价币以及少量用于支付gas的原生币。Aerodrome、Velodrome的仓位不做质押,仅赚取交易手续费;质押领取奖励的做法见Gauge质押复投。
参数
| 参数 | 默认值 | 说明 |
|---|---|---|
SYMBOL | ETH_USDC | 交易对,格式为基础币_计价币 |
BUDGET | 100 | 资金上限(计价币):每次建仓的预算 = min(仓位现值, BUDGET) |
RANGE_PCT | 5 | 区间半宽:现价±5% |
FEE | null | 指定费率(如500);为null时按lp.select规则选池 |
INTERVAL | 60 | 检查间隔(秒) |
OUT_CONFIRM | 5 | 连续多少次处于区间外才触发调仓 |
MIN_REBALANCE_MIN | 60 | 两次调仓的最小间隔(分钟) |
MAX_REBALANCE_PER_DAY | 4 | 24小时内最多调仓次数 |
GAS_USD | 0.3 | 每笔链上交易的gas估计(美元):Base、Arbitrum约0.01~0.3,以太坊主网约2~10 |
REBALANCE_TXS | 3 | 一次调仓的交易笔数:撤出、换币、建仓 |
PAYBACK_DAYS | 3 | 新区间的预估手续费需在多少天内覆盖调仓gas |
COLLECT_HOURS | 24 | 领取手续费的间隔(小时) |
COLLECT_MIN_USD | 1 | 未领取手续费低于此值时不领取 |
DRY_RUN | true | 为true时仅规划与模拟,不发送交易 |
风险
- 无常损失:价格偏离建仓价越远,仓位价值相对于持币不动就越低。每次调仓都发生在价格单边运动之后,撤出时仓位已基本转换为单一代币,重新建仓又需换回一半,频繁调仓会将无常损失兑现为实际亏损。
- gas:每次调仓和领取都是链上交易,gas成本可能超过手续费收入。请在Base、Arbitrum、BNB Chain等低gas链上运行,并按所在链修改
GAS_USD。 - 预估日手续费是根据过去24小时的成交量与仓位占池比例外推得出的,不代表未来收益。
DRY_RUN与实盘
DRY_RUN为true(默认)时不发送任何交易:使用lp.plan进行规划,使用exchange.IO("lp.simulate")模拟建仓交易(钱包余额不足时模拟会报错,属正常现象),并用一个纸面仓位按V3公式跟踪价值;价格处于区间内时,手续费按计划中的预估日手续费累计(仅为估算)。将DRY_RUN改为false即为实盘,建议先以较小的BUDGET运行一段时间,确认调仓频率与gas成本符合预期。
示例
完整策略代码(DRY_RUN = true 时只做规划与模拟,不发送交易)。
javascript
/*
* 集中流动性自动调仓做市(Uniswap / PancakeSwap / Aerodrome / Velodrome 通用,范例策略)
*
* 在现价 ±RANGE_PCT% 开一个单区间仓位赚手续费;价格连续 OUT_CONFIRM 次检查都在区间外时,撤出并以新价格
* 为中心重新建仓(调仓),定期领取手续费。与 uniswap-lp-monitor.js(建仓 → 监控 → 出区间就撤退离场)
* 不同,这里出区间后跟随价格重新居中,持续做市。
*
* 护栏:
* - 两次调仓至少间隔 MIN_REBALANCE_MIN 分钟,24 小时内最多 MAX_REBALANCE_PER_DAY 次(达到后只监控);
* - 成本检查:新区间的预估日手续费(lp.plan 的 estDailyFeesUsd)× PAYBACK_DAYS 要能覆盖一次调仓的 gas
* (REBALANCE_TXS 笔 × GAS_USD),否则这次不调、继续等;
* - 资金上限:每次建仓的预算 = min(仓位现值, BUDGET),投入的资金不会超过 BUDGET,多出的留在钱包;
* - 领手续费:每 COLLECT_HOURS 小时、且未领手续费不少于 COLLECT_MIN_USD 时才领(领取也是一笔交易)。
*
* 状态栏:区间、现价、是否在区间内、仓位价值、未领 / 已领手续费、调仓次数、gas 估计、相对持币(HODL)的
* 盈亏。HODL 基准 = 第一次建仓时存入的两种代币数量一直不动。
*
* DRY_RUN = true(默认)不发任何交易:用 lp.plan 规划、lp.simulate 模拟建仓交易(钱包余额不够时模拟报错,
* 属正常),用一个"纸面仓位"按 V3 公式跟踪价值,手续费按计划的预估日手续费在区间内时累计(只是估算)。
*
* 风险:区间流动性有无常损失;每次调仓都在价格单边运动之后"高买低卖"地换币,频繁调仓会把无常损失兑现;
* 每次调仓、领取都是链上交易,gas 可能超过手续费收入——请在 Base、Arbitrum、BNB Chain 等低 gas 链上运行,
* 按链修改 GAS_USD,预算从小额开始。
*
* 用法:添加 Uniswap、PancakeSwap、Aerodrome 或 Velodrome 交易所对象之一(专用钱包),钱包里放计价币和
* 少量原生币付 gas。Aerodrome / Velodrome 的仓位不质押,赚交易手续费(质押领奖励见 aerodrome-gauge-compound.js)。
*/
// ---------------- 参数 ----------------
var SYMBOL = "ETH_USDC" // 交易对:基础币_计价币
var BUDGET = 100 // 资金上限(计价币)
var RANGE_PCT = 5 // 区间半宽:现价 ±5%
var FEE = null // 指定费率(如 500);null = 按 lp.select 规则选池
var INTERVAL = 60 // 检查间隔(秒)
var OUT_CONFIRM = 5 // 连续几次在区间外才调仓
var MIN_REBALANCE_MIN = 60 // 两次调仓的最小间隔(分钟)
var MAX_REBALANCE_PER_DAY = 4 // 24 小时内最多调仓次数
var GAS_USD = 0.3 // 每笔链上交易的 gas 估计(美元):Base/Arbitrum 约 0.01~0.3,以太坊主网 2~10
var REBALANCE_TXS = 3 // 一次调仓的交易笔数:撤出 + 换币 + 建仓
var PAYBACK_DAYS = 3 // 新区间的预估手续费要在几天内覆盖调仓 gas
var COLLECT_HOURS = 24 // 领手续费的间隔(小时)
var COLLECT_MIN_USD = 1 // 未领手续费少于这个数不领
var DRY_RUN = true // true 只规划与模拟,不发交易
var DEXES = ["Uniswap", "PancakeSwap", "Aerodrome", "Velodrome"]
var KEY = DRY_RUN ? "lp_rebalance_paper" : "lp_rebalance"
var S = null // 持久状态(_G)
function fmt(x, d) {
return x === null || x === undefined || isNaN(x) ? "-" : Number(x).toFixed(d === undefined ? 2 : d)
}
function save() { _G(KEY, S) }
// 纸面仓位:按 V3 公式由流动性 L 算两种代币数量(价格 = 计价币/基础币)
function paperAmounts(p) {
var sp = Math.sqrt(Math.min(Math.max(p, S.lower), S.upper))
return { base: S.L * (1 / sp - 1 / Math.sqrt(S.upper)), quote: S.L * (sp - Math.sqrt(S.lower)) }
}
function makePlan(budget) {
var params = { symbol: SYMBOL, budget: budget, range: { pct: [-RANGE_PCT, RANGE_PCT] }, shape: "spot" }
if (S && S.pool) params.pool = S.pool // 调仓留在同一个池子
else if (FEE) params.fee = FEE
var plan = exchange.IO("lp.plan", params)
if (!plan) throw new Error("lp.plan failed: " + GetLastError())
return plan
}
// 建仓(首次或调仓),返回是否成功
function open(plan) {
var x = plan.positions[0]
Log("建仓计划:池子", plan.pool, "费率", plan.fee, "现价", fmt(plan.price, 4), "区间", fmt(x.priceLower, 4), "~", fmt(x.priceUpper, 4),
"存入", fmt(x.amountBase, 6), "+", fmt(x.amountQuote, 4), "预估日手续费 $" + fmt(plan.estDailyFeesUsd, 4),
plan.swap ? "(先" + (plan.swap.side === "buy" ? "买入 " : "卖出 ") + plan.swap.amount + " " + plan.swap.currency + ")" : "")
var ids, r = null, base = x.amountBase, quote = x.amountQuote
if (DRY_RUN) {
var sim = exchange.IO("lp.simulate", plan)
Log(sim ? "lp.simulate 通过,价值 " + fmt(sim.value, 4) : "lp.simulate 未通过(钱包余额不够时正常):" + GetLastError())
ids = ["paper"]
S.L = x.amountQuote > 0 ? x.amountQuote / (Math.sqrt(plan.price) - Math.sqrt(x.priceLower))
: x.amountBase / (1 / Math.sqrt(plan.price) - 1 / Math.sqrt(x.priceUpper))
} else {
r = exchange.IO("lp.add", plan) // 执行时按最新池价与余额重算
if (!r) { Log("建仓失败:", GetLastError(), "#FF0000"); return false }
ids = r.positions.map(function (p) { return String(p.id) })
x = r.positions[0]
base = 0; quote = 0
r.positions.forEach(function (p) { base += p.amountBase; quote += p.amountQuote })
exchange.IO("lp.track", ids)
Log("建仓完成", r.txHash, "仓位", ids.join(","), "#00AA00")
}
S.ids = ids; S.pool = (r && r.pool) || plan.pool; S.lower = x.priceLower; S.upper = x.priceUpper
S.estDaily = plan.estDailyFeesUsd || 0
S.gasUsd += GAS_USD * (plan.swap ? 2 : 1)
if (S.hodlBase === null) { S.hodlBase = base; S.hodlQuote = quote }
save()
return true
}
// 当前仓位:{price, inRange, value, fees}
function readPosition() {
if (DRY_RUN) {
var t = exchange.GetTicker(SYMBOL)
if (!t) throw new Error("GetTicker failed: " + GetLastError())
var p = (t.Buy + t.Sell) / 2, a = paperAmounts(p), now = Date.now()
var inRange = p >= S.lower && p < S.upper
if (inRange && S.lastCheck) S.fees += S.estDaily * (now - S.lastCheck) / 86400000
S.lastCheck = now
return { price: p, inRange: inRange, value: a.base * p + a.quote, fees: S.fees }
}
var ps = exchange.IO("lp.positions", SYMBOL)
if (!ps) throw new Error("lp.positions failed: " + GetLastError())
ps = ps.filter(function (x) { return !x.closed && S.ids.indexOf(String(x.id)) >= 0 })
if (ps.length === 0) return null
var out = { price: ps[0].price, inRange: false, value: 0, fees: 0 }
ps.forEach(function (x) { out.value += x.value; out.fees += x.feesValue; if (x.inRange) out.inRange = true })
return out
}
function rebalance(cur) {
var now = Date.now()
S.rebalances = S.rebalances.filter(function (t) { return now - t < 86400000 })
if (S.rebalances.length >= MAX_REBALANCE_PER_DAY) return "24 小时内已调仓 " + S.rebalances.length + " 次,暂停调仓"
if (S.lastRebalance && now - S.lastRebalance < MIN_REBALANCE_MIN * 60000) return "距上次调仓不足 " + MIN_REBALANCE_MIN + " 分钟"
var budget = Math.min(cur.value, BUDGET) // 手续费另计入已领,不再投入
var plan = makePlan(budget)
var cost = REBALANCE_TXS * GAS_USD
if (plan.estDailyFeesUsd !== null && plan.estDailyFeesUsd * PAYBACK_DAYS < cost) {
return "新区间预估 " + PAYBACK_DAYS + " 天手续费 $" + fmt(plan.estDailyFeesUsd * PAYBACK_DAYS, 4) + " 不够调仓 gas $" + fmt(cost, 2)
}
Log("调仓:价格", fmt(cur.price, 4), "已连续", S.outStreak, "次在区间外", "#FF9900")
if (DRY_RUN) {
S.collected += S.fees
} else {
var r = exchange.IO("lp.remove", S.ids, 1, false)
if (!r) { Log("撤出失败:", GetLastError(), "#FF0000"); return "撤出失败" }
r.positions.forEach(function (p) { S.collected += p.feesBase * cur.price + p.feesQuote })
}
S.leftover += cur.value - budget
S.fees = 0; S.outStreak = 0; S.lastRebalance = now; S.rebalances.push(now); S.count++
S.gasUsd += GAS_USD
S.ids = []; S.budget = budget // 撤出后先清空:重新建仓失败时下一轮接着重试
if (!open(plan)) return "重新建仓失败,下一轮重试"
return "已调仓"
}
function collect(cur) {
if (Date.now() - S.lastCollect < COLLECT_HOURS * 3600000 || cur.fees < COLLECT_MIN_USD) return
Log("领取手续费 $" + fmt(cur.fees, 4))
if (!DRY_RUN) {
var r = exchange.IO("lp.collect", S.ids, false)
if (!r) { Log("领取失败:", GetLastError(), "#FF0000"); return }
}
S.collected += cur.fees
S.fees = 0
S.gasUsd += GAS_USD
S.lastCollect = Date.now()
save()
}
function main() {
if (DEXES.indexOf(exchange.GetName()) < 0) throw new Error("请添加 " + DEXES.join(" / ") + " 交易所对象,当前是 " + exchange.GetName())
S = _G(KEY)
if (!S || S.symbol !== SYMBOL || S.dex !== exchange.GetName()) {
S = { symbol: SYMBOL, dex: exchange.GetName(), ids: [], budget: BUDGET, count: 0, pool: null, lower: 0, upper: 0, L: 0, estDaily: 0, fees: 0, collected: 0, gasUsd: 0, leftover: 0,
hodlBase: null, hodlQuote: null, rebalances: [], lastRebalance: 0, lastCollect: Date.now(), outStreak: 0, lastCheck: 0 }
}
if (S.ids.length && !DRY_RUN) exchange.IO("lp.track", S.ids)
if (S.ids.length) Log("从 _G 恢复仓位", S.ids.join(","), "区间", fmt(S.lower, 4), "~", fmt(S.upper, 4))
var note = ""
while (true) {
try {
if (!S.ids.length && !open(makePlan(S.budget))) throw new Error("建仓失败,下一轮重试")
var cur = readPosition()
if (!cur) { Log("仓位已不在(手动撤出?),停止", "#FF0000"); _G(KEY, null); return }
S.outStreak = cur.inRange ? 0 : S.outStreak + 1
note = cur.inRange ? "" : "区间外 " + S.outStreak + "/" + OUT_CONFIRM
if (S.outStreak >= OUT_CONFIRM) note = rebalance(cur)
else collect(cur)
save()
var hodl = S.hodlBase * cur.price + S.hodlQuote
var total = cur.value + cur.fees + S.collected + S.leftover - S.gasUsd
var day = S.rebalances.filter(function (t) { return Date.now() - t < 86400000 }).length
var table = { type: "table", title: SYMBOL + " 自动调仓 LP" + (DRY_RUN ? "(DRY_RUN 纸面仓位)" : ""), cols: ["项目", "值"], rows: [
["仓位", S.ids.join(",")], ["池子", S.pool], ["区间", fmt(S.lower, 4) + " ~ " + fmt(S.upper, 4)], ["现价", fmt(cur.price, 4)],
["在区间内", cur.inRange ? "是" : (cur.price < S.lower ? "否(下方)" : "否(上方)")],
["仓位价值", fmt(cur.value, 4)], ["未领手续费" + (DRY_RUN ? "(估算)" : ""), fmt(cur.fees, 4)], ["已领手续费", fmt(S.collected, 4)],
["调仓次数(近 24h / 每日上限)", S.count + "(" + day + " / " + MAX_REBALANCE_PER_DAY + ")"], ["gas 估计", fmt(S.gasUsd, 4)],
["LP 合计(含已领、未再投入、扣 gas)", fmt(total, 4)], ["持币不动(HODL)", fmt(hodl, 4)],
["相对 HODL 盈亏", fmt(total - hodl, 4)], ["说明", note || "-"]] }
LogStatus(_D(), "\n`" + JSON.stringify(table) + "`")
} catch (e) {
Log("本轮出错:", e.message || e, "#FF0000")
}
Sleep(INTERVAL * 1000)
}
}参考