LP Auto-Rebalancing
Opens a single-range position within ±RANGE_PCT% of the current price to earn trading fees. When the price stays outside the range for several consecutive checks, the strategy withdraws and re-opens the position centered on the new price, so it keeps providing liquidity. It collects fees on a schedule and has guardrails on rebalance frequency, gas and capital.
How It Works
- Open a position:
exchange.IO("lp.plan")(shapeset tospot,rangeset to{pct: [-RANGE_PCT, RANGE_PCT]}) →exchange.IO("lp.add"). On the first open, the pool is chosen by the rules oflp.select. You can set the fee tier withFEE. Every later rebalance uses the same pool. The position ID is stored in_G, and after a restart it is registered again withexchange.IO("lp.track"). - Every
INTERVALseconds, query the position withexchange.IO("lp.positions"): whether it is in range, its current value and its unclaimed fees. - After
OUT_CONFIRMconsecutive out-of-range checks, rebalance. First withdraw everything withexchange.IO("lp.remove")(lp.remove(ids, 1, false), without selling back into the quote currency). Then plan and open a new position centered on the new price, sized at min(current position value,BUDGET). Any amount aboveBUDGETstays in the wallet. - Guardrails: rebalances must be at least
MIN_REBALANCE_MINminutes apart, with at mostMAX_REBALANCE_PER_DAYrebalances in 24 hours (after the limit is reached, the strategy only monitors). The estimated daily fees of the new range (estDailyFeesUsdin the plan) ×PAYBACK_DAYSmust cover the gas of one rebalance (REBALANCE_TXS×GAS_USD). Otherwise the strategy skips this rebalance and keeps waiting. - Every
COLLECT_HOURShours, if unclaimed fees are at leastCOLLECT_MIN_USD, collect them withexchange.IO("lp.collect"). - Status bar: shows the range, current price, position value, unclaimed and collected fees, rebalance count, gas estimate, and PnL compared with holding (HODL: the two tokens deposited on the first open, left untouched).
Some strategies follow "open → monitor → withdraw and exit when the price leaves the range or hits a stop-loss". This strategy instead re-centers on the price after it leaves the range and keeps providing liquidity. For a simple version of the exit-style strategy, see the full LP strategy example on the exchange.IO("lp.add") page.
Exchange Object
One Uniswap / PancakeSwap or Aerodrome / Velodrome exchange object (with a dedicated wallet). The wallet holds the quote currency and a small amount of native coin to pay for gas. Aerodrome and Velodrome positions are not staked and earn only trading fees. For staking to earn rewards, see Gauge Staking and Compounding.
Parameters
| Parameter | Default | Description |
|---|---|---|
SYMBOL | ETH_USDC | Trading pair, in the format base currency_quote currency |
BUDGET | 100 | Capital cap (in the quote currency): the budget for each open = min(current position value, BUDGET) |
RANGE_PCT | 5 | Range half-width: current price ±5% |
FEE | null | Fee tier to use (e.g. 500). When null, the pool is chosen by the lp.select rules |
INTERVAL | 60 | Check interval (seconds) |
OUT_CONFIRM | 5 | Number of consecutive out-of-range checks that triggers a rebalance |
MIN_REBALANCE_MIN | 60 | Minimum time between two rebalances (minutes) |
MAX_REBALANCE_PER_DAY | 4 | Maximum number of rebalances in 24 hours |
GAS_USD | 0.3 | Estimated gas per on-chain transaction (USD): about 0.01–0.3 on Base and Arbitrum, about 2–10 on Ethereum mainnet |
REBALANCE_TXS | 3 | Number of transactions in one rebalance: withdraw, swap, open |
PAYBACK_DAYS | 3 | Number of days in which the new range's estimated fees must cover the rebalance gas |
COLLECT_HOURS | 24 | Interval between fee collections (hours) |
COLLECT_MIN_USD | 1 | Fees are not collected while unclaimed fees are below this value |
DRY_RUN | true | When true, the strategy only plans and simulates and sends no transactions |
Risks
- Impermanent loss: the further the price moves from the entry price, the lower the position value is compared with holding. Every rebalance happens after a one-way price move. By the time the strategy withdraws, the position has mostly turned into a single token, and opening again means swapping half of it back. Frequent rebalancing turns impermanent loss into realized losses.
- Gas: every rebalance and fee collection is an on-chain transaction, and gas costs can exceed fee income. Run the strategy on low-gas chains such as Base, Arbitrum or BNB Chain, and set
GAS_USDfor the chain you use. - Estimated daily fees are extrapolated from the past 24 hours of trading volume and the position's share of the pool. They are not a guarantee of future returns.
DRY_RUN vs. Live Trading
When DRY_RUN is true (the default), no transactions are sent. The strategy plans the position with lp.plan, simulates the position-opening transaction with exchange.IO("lp.simulate"), and tracks the position's value as a paper position using the V3 formulas. The simulation reports an error when the wallet balance is too low; this is expected. While the price is in range, fees accrue at the plan's estimated daily fee rate, which is only an estimate. Set DRY_RUN to false to trade live. Start with a small BUDGET for a while to confirm that the rebalancing frequency and gas costs are what you expect.
Examples
Complete strategy code (when DRY_RUN = true, it only plans and simulates, and sends no transactions).
javascript
/*
* Concentrated liquidity auto-rebalancing market making (for Uniswap / PancakeSwap / Aerodrome / Velodrome, example strategy)
*
* Opens a single-range position within ±RANGE_PCT% of the current price to earn fees; when the price is outside the range for OUT_CONFIRM consecutive checks, withdraws the position and reopens it
* centered on the new price (rebalance), while periodically collecting fees. Unlike uniswap-lp-monitor.js (open → monitor → withdraw and exit once out of range),
* this strategy re-centers on the price after it leaves the range and keeps making markets.
*
* Guardrails:
* - At least MIN_REBALANCE_MIN minutes between two rebalances, at most MAX_REBALANCE_PER_DAY rebalances within 24 hours (monitor only once the limit is reached);
* - Cost check: the new range's estimated daily fees (estDailyFeesUsd returned by lp.plan) × PAYBACK_DAYS must cover the gas of one rebalance
* (REBALANCE_TXS txs × GAS_USD); otherwise skip this rebalance and keep waiting;
* - Capital cap: budget per position = min(current position value, BUDGET); invested capital never exceeds BUDGET, and the excess stays in the wallet;
* - Fee collection: collect only every COLLECT_HOURS hours and only when uncollected fees are at least COLLECT_MIN_USD (collecting is also an on-chain transaction).
*
* Status bar: range, current price, whether in range, position value, uncollected / collected fees, rebalance count, gas estimate, and PnL relative to
* simply holding (HODL). HODL benchmark = the amounts of both tokens deposited at the first position opening, held unchanged.
*
* With DRY_RUN = true (default) no transactions are sent: lp.plan plans and lp.simulate simulates the opening transaction (the simulation errors out when the wallet balance is insufficient,
* which is normal), and a "paper position" tracks value using the V3 formula; while the price is in range, fees accrue at the plan's estimated daily fees (estimate only).
*
* Risks: range liquidity is exposed to impermanent loss; every rebalance happens after a one-directional price move, effectively swapping tokens "buy high, sell low", and frequent rebalancing realizes impermanent loss;
* every rebalance and collection is an on-chain transaction, and gas may exceed fee income — run on low-gas chains such as Base, Arbitrum and BNB Chain,
* adjust GAS_USD per chain, and start with a small budget.
*
* Usage: add one of the Uniswap, PancakeSwap, Aerodrome or Velodrome exchange objects (a dedicated wallet is recommended); the wallet must hold the quote currency and
* a small amount of native coin to pay gas. Aerodrome / Velodrome positions are not staked and earn trading fees only (for staking to claim rewards, see aerodrome-gauge-compound.js).
*/
// ---------------- Parameters ----------------
var SYMBOL = "ETH_USDC" // Trading pair: base_quote
var BUDGET = 100 // Capital cap (in quote currency)
var RANGE_PCT = 5 // Range half-width: current price ±5%
var FEE = null // Specific fee tier (e.g. 500); null = choose the pool by lp.select rules
var INTERVAL = 60 // Check interval (seconds)
var OUT_CONFIRM = 5 // Consecutive out-of-range checks required before rebalancing
var MIN_REBALANCE_MIN = 60 // Minimum interval between two rebalances (minutes)
var MAX_REBALANCE_PER_DAY = 4 // Maximum rebalances within 24 hours
var GAS_USD = 0.3 // Estimated gas per on-chain transaction (USD): Base/Arbitrum about 0.01~0.3, Ethereum mainnet about 2~10
var REBALANCE_TXS = 3 // Transactions per rebalance: withdraw + swap + open
var PAYBACK_DAYS = 3 // Days within which the new range's estimated fees must cover the rebalance gas
var COLLECT_HOURS = 24 // Fee collection interval (hours)
var COLLECT_MIN_USD = 1 // Skip collection when uncollected fees are below this value
var DRY_RUN = true // When true, only plan and simulate; send no transactions
var DEXES = ["Uniswap", "PancakeSwap", "Aerodrome", "Velodrome"]
var KEY = DRY_RUN ? "lp_rebalance_paper" : "lp_rebalance"
var S = null // Persistent state (_G)
function fmt(x, d) {
return x === null || x === undefined || isNaN(x) ? "-" : Number(x).toFixed(d === undefined ? 2 : d)
}
function save() { _G(KEY, S) }
// Paper position: compute both token amounts from liquidity L using the V3 formula (price = quote/base)
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 // Stay in the same pool when rebalancing
else if (FEE) params.fee = FEE
var plan = exchange.IO("lp.plan", params)
if (!plan) throw new Error("lp.plan failed: " + GetLastError())
return plan
}
// Open a position (initial opening or rebalance); returns whether it succeeded
function open(plan) {
var x = plan.positions[0]
Log("Position plan: pool", plan.pool, "fee", plan.fee, "price", fmt(plan.price, 4), "range", fmt(x.priceLower, 4), "~", fmt(x.priceUpper, 4),
"deposit", fmt(x.amountBase, 6), "+", fmt(x.amountQuote, 4), "est. daily fees $" + fmt(plan.estDailyFeesUsd, 4),
plan.swap ? "(first " + (plan.swap.side === "buy" ? "buy " : "sell ") + 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 passed, value " + fmt(sim.value, 4) : "lp.simulate failed (normal when the wallet balance is insufficient): " + 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) // Recomputed at execution time from the latest pool price and balances
if (!r) { Log("Failed to open position:", 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("Position opened", r.txHash, "position", 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
}
// Current position: {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 "Rebalanced " + S.rebalances.length + " times within 24 hours, rebalancing paused"
if (S.lastRebalance && now - S.lastRebalance < MIN_REBALANCE_MIN * 60000) return "Less than " + MIN_REBALANCE_MIN + " minutes since the last rebalance"
var budget = Math.min(cur.value, BUDGET) // Fees are counted separately as collected and not reinvested
var plan = makePlan(budget)
var cost = REBALANCE_TXS * GAS_USD
if (plan.estDailyFeesUsd !== null && plan.estDailyFeesUsd * PAYBACK_DAYS < cost) {
return "New range's estimated " + PAYBACK_DAYS + "-day fees $" + fmt(plan.estDailyFeesUsd * PAYBACK_DAYS, 4) + " are not enough to cover rebalance gas $" + fmt(cost, 2)
}
Log("Rebalancing: price", fmt(cur.price, 4), "out of range for", S.outStreak, "consecutive checks", "#FF9900")
if (DRY_RUN) {
S.collected += S.fees
} else {
var r = exchange.IO("lp.remove", S.ids, 1, false)
if (!r) { Log("Withdrawal failed:", GetLastError(), "#FF0000"); return "Withdrawal failed" }
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 // Clear right after withdrawal: if reopening fails, retry in the next round
if (!open(plan)) return "Reopening failed, retrying next round"
return "Rebalanced"
}
function collect(cur) {
if (Date.now() - S.lastCollect < COLLECT_HOURS * 3600000 || cur.fees < COLLECT_MIN_USD) return
Log("Collecting fees $" + fmt(cur.fees, 4))
if (!DRY_RUN) {
var r = exchange.IO("lp.collect", S.ids, false)
if (!r) { Log("Collection failed:", 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("Please add a " + DEXES.join(" / ") + " exchange object, current: " + 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("Restored position from _G", S.ids.join(","), "range", fmt(S.lower, 4), "~", fmt(S.upper, 4))
var note = ""
while (true) {
try {
if (!S.ids.length && !open(makePlan(S.budget))) throw new Error("Failed to open position, retrying next round")
var cur = readPosition()
if (!cur) { Log("Position no longer exists (withdrawn manually?), stopping", "#FF0000"); _G(KEY, null); return }
S.outStreak = cur.inRange ? 0 : S.outStreak + 1
note = cur.inRange ? "" : "Out of range " + 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 + " Auto-Rebalancing LP" + (DRY_RUN ? " (DRY_RUN paper position)" : ""), cols: ["Item", "Value"], rows: [
["Position", S.ids.join(",")], ["Pool", S.pool], ["Range", fmt(S.lower, 4) + " ~ " + fmt(S.upper, 4)], ["Price", fmt(cur.price, 4)],
["In range", cur.inRange ? "Yes" : (cur.price < S.lower ? "No (below)" : "No (above)")],
["Position value", fmt(cur.value, 4)], ["Uncollected fees" + (DRY_RUN ? " (est.)" : ""), fmt(cur.fees, 4)], ["Collected fees", fmt(S.collected, 4)],
["Rebalances (last 24h / daily limit)", S.count + " (" + day + " / " + MAX_REBALANCE_PER_DAY + ")"], ["Gas estimate", fmt(S.gasUsd, 4)],
["LP total (incl. collected and un-reinvested funds, net of gas)", fmt(total, 4)], ["HODL", fmt(hodl, 4)],
["PnL vs HODL", fmt(total - hodl, 4)], ["Note", note || "-"]] }
LogStatus(_D(), "\n`" + JSON.stringify(table) + "`")
} catch (e) {
Log("Error in this round:", e.message || e, "#FF0000")
}
Sleep(INTERVAL * 1000)
}
}See Also