Type/to search
Built-in Functions
Structures
Built-in Variables and Constants
Conventions
Exchanges
Capability Matrix
Centralized Exchanges
Decentralized Exchanges (DEX)
Hyperliquid
Lighter
edgeX
Aster
Aevo
Pendle
WOOFi Pro
Uniswap / PancakeSwap
Aerodrome / Velodrome
KyberSwap
1inch
EVM DEX Common Commands
Jupiter
OKX DEX
DEX Strategy Examples
LP Auto-Rebalancing
Gauge Staking Auto-Compound
DEX-CEX Arbitrage
Jupiter Limit Order Grid
DEX DCA
pump.fun Token Sniping Strategy
Prediction Markets
Securities and Futures
On-chain & Custom

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
  1. Open a position: exchange.IO("lp.plan") (shape set to spot, range set to {pct: [-RANGE_PCT, RANGE_PCT]}) → exchange.IO("lp.add"). On the first open, the pool is chosen by the rules of lp.select. You can set the fee tier with FEE. Every later rebalance uses the same pool. The position ID is stored in _G, and after a restart it is registered again with exchange.IO("lp.track").
  2. Every INTERVAL seconds, query the position with exchange.IO("lp.positions"): whether it is in range, its current value and its unclaimed fees.
  3. After OUT_CONFIRM consecutive out-of-range checks, rebalance. First withdraw everything with exchange.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 above BUDGET stays in the wallet.
  4. Guardrails: rebalances must be at least MIN_REBALANCE_MIN minutes apart, with at most MAX_REBALANCE_PER_DAY rebalances in 24 hours (after the limit is reached, the strategy only monitors). The estimated daily fees of the new range (estDailyFeesUsd in the plan) × PAYBACK_DAYS must cover the gas of one rebalance (REBALANCE_TXS × GAS_USD). Otherwise the strategy skips this rebalance and keeps waiting.
  5. Every COLLECT_HOURS hours, if unclaimed fees are at least COLLECT_MIN_USD, collect them with exchange.IO("lp.collect").
  6. 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
ParameterDefaultDescription
SYMBOLETH_USDCTrading pair, in the format base currency_quote currency
BUDGET100Capital cap (in the quote currency): the budget for each open = min(current position value, BUDGET)
RANGE_PCT5Range half-width: current price ±5%
FEEnullFee tier to use (e.g. 500). When null, the pool is chosen by the lp.select rules
INTERVAL60Check interval (seconds)
OUT_CONFIRM5Number of consecutive out-of-range checks that triggers a rebalance
MIN_REBALANCE_MIN60Minimum time between two rebalances (minutes)
MAX_REBALANCE_PER_DAY4Maximum number of rebalances in 24 hours
GAS_USD0.3Estimated gas per on-chain transaction (USD): about 0.01–0.3 on Base and Arbitrum, about 2–10 on Ethereum mainnet
REBALANCE_TXS3Number of transactions in one rebalance: withdraw, swap, open
PAYBACK_DAYS3Number of days in which the new range's estimated fees must cover the rebalance gas
COLLECT_HOURS24Interval between fee collections (hours)
COLLECT_MIN_USD1Fees are not collected while unclaimed fees are below this value
DRY_RUNtrueWhen 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_USD for 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