When NOT to Rebalance: Regime Detection and EV-Based LP Decisions
How market regime detection, expected value calculations, and delta-based hedging transformed a simple DLMM rebalancer into a bot that knows when to sit still. Covers ATR, ADX, EMA indicators, the math behind profitable patience, and LP delta hedging on Drift.
TL;DR: Adding regime detection and EV-gated rebalancing to a Meteora DLMM bot cut rebalance frequency by 70% and dropped all-in transaction costs from ~3% to under 0.5% of position size. The second biggest win was switching from regime-based hedging to delta-based hedging on Drift (open short at 60% LP delta, close at 40%), which eliminated hedge thrashing during regime transitions and reduced funding costs. The core insight: the best trade is often no trade at all.
The most profitable change I ever made to a trading bot was making it trade less. I had been running a DLMM liquidity provision bot on Meteora's SOL/USDC pool for several months, rebalancing the position whenever it drifted out of range. The bot was active, responsive, and consistently losing money to its own execution costs. Every rebalance cost transaction fees, priority fees, Jito tips, swap slippage to rebalance the token ratio, and -- most insidiously -- crystallized impermanent loss that might have reverted if I had simply waited.
The fix was not a better rebalancing algorithm. It was a system that understands when rebalancing is a bad idea and has the discipline to do nothing. And later, a delta-based hedging layer that decouples hedge decisions from regime classification entirely.
The Problem with Eager Rebalancing
A naive DLMM bot rebalances whenever the active bin moves outside its position range. This sounds reasonable until you watch it in production. SOL/USDC frequently makes sharp moves that revert within hours. Each move triggers a rebalance: the bot swaps tokens to match the new price center, pays fees to close and reopen the LP position, and locks in impermanent loss. Then the price reverts, the position goes out of range in the other direction, and the cycle repeats.
Over three months of live trading with aggressive rebalancing, transaction costs alone consumed roughly 3% of the position, and poorly-timed IL crystallization added another 2%. The fees earned were solid, but the net yield after costs was disappointing. The strategy was right; the execution frequency was wrong.
Building the Indicator Pipeline
The regime detection system uses four technical indicators computed from hourly candles fetched via the Birdeye API. Each indicator captures a different dimension of market behavior.
Average True Range (ATR)
ATR measures the magnitude of recent price volatility. I use Wilder's smoothing method, which gives a more stable reading than simple moving averages.
interface Candle {
open: number
high: number
low: number
close: number
timestamp: number
}
function calculateATR(candles: Candle[], period: number = 14): number {
const trueRanges = candles.map((c, i) => {
if (i === 0) return c.high - c.low
const prevClose = candles[i - 1].close
return Math.max(
c.high - c.low,
Math.abs(c.high - prevClose),
Math.abs(c.low - prevClose)
)
})
// Wilder's smoothing: first value is SMA, then EMA-like
let atr = trueRanges.slice(0, period).reduce((a, b) => a + b, 0) / period
for (let i = period; i < trueRanges.length; i++) {
atr = (atr * (period - 1) + trueRanges[i]) / period
}
return atr
}Average Directional Index (ADX)
ADX tells you how strongly the market is trending, regardless of direction. Values below 18 suggest range-bound conditions; above 20 suggests a developing trend. This is the most important indicator for LP because it directly predicts whether a rebalance will be chasing a trend or catching a mean reversion.
function calculateADX(candles: Candle[], period: number = 14): number {
const plusDM: number[] = []
const minusDM: number[] = []
for (let i = 1; i < candles.length; i++) {
const upMove = candles[i].high - candles[i - 1].high
const downMove = candles[i - 1].low - candles[i].low
plusDM.push(upMove > downMove && upMove > 0 ? upMove : 0)
minusDM.push(downMove > upMove && downMove > 0 ? downMove : 0)
}
const atr = calculateATR(candles, period)
// Smooth the directional movement with Wilder's method
let smoothPlusDM = plusDM.slice(0, period).reduce((a, b) => a + b, 0) / period
let smoothMinusDM =
minusDM.slice(0, period).reduce((a, b) => a + b, 0) / period
for (let i = period; i < plusDM.length; i++) {
smoothPlusDM = (smoothPlusDM * (period - 1) + plusDM[i]) / period
smoothMinusDM = (smoothMinusDM * (period - 1) + minusDM[i]) / period
}
const plusDI = (smoothPlusDM / atr) * 100
const minusDI = (smoothMinusDM / atr) * 100
const dx = (Math.abs(plusDI - minusDI) / (plusDI + minusDI)) * 100
// ADX is DX smoothed with Wilder's method over another `period` window.
// Without this smoothing, raw DX is too noisy for regime classification.
// In production I accumulate DX values and apply the same smoothing as ATR:
// adx = (prevAdx * (period - 1) + dx) / period
return dx
}EMA and Bollinger Bands
The 20-period EMA serves as a trend direction filter. Instead of using a fast/slow crossover, the bot checks whether the last N candles closed consistently above or below the EMA20 -- this gives a more reliable trend confirmation signal that filters out brief whipsaws. Bollinger Band width detects range compression: a squeeze often precedes a breakout.
function calculateEMA(values: number[], period: number): number[] {
const multiplier = 2 / (period + 1)
const ema: number[] = [values[0]]
for (let i = 1; i < values.length; i++) {
ema.push((values[i] - ema[i - 1]) * multiplier + ema[i - 1])
}
return ema
}
function calculateBollingerWidth(
closes: number[],
period: number = 20,
stdDevMultiplier: number = 2
): number {
const recent = closes.slice(-period)
const sma = recent.reduce((a, b) => a + b, 0) / period
const stdDev = Math.sqrt(
recent.reduce((a, v) => a + Math.pow(v - sma, 2), 0) / period
)
const upper = sma + stdDevMultiplier * stdDev
const lower = sma - stdDevMultiplier * stdDev
// Width as percentage of the midpoint
return ((upper - lower) / sma) * 100
}Market Regime Classification
With these indicators computed, the regime classification is straightforward. The key insight is the priority ordering: high volatility overrides everything, then trend detection, then default to ranging.
type MarketRegime = 'RANGING' | 'TRENDING_UP' | 'TRENDING_DOWN' | 'HIGH_VOL'
interface MarketIndicators {
atrPercent: number // ATR as percentage of price
adx: number // 0-100 scale
ema20: number // 20-period EMA value
bbWidth: number // Bollinger Band width as percentage
currentPrice: number
consecutiveAboveEma: number // Candles closing above EMA20
consecutiveBelowEma: number // Candles closing below EMA20
}
function classifyRegime(ind: MarketIndicators): {
regime: MarketRegime
confidence: number
} {
// Priority 1: Volatility spike is an emergency
if (ind.atrPercent > 3.0) {
return { regime: 'HIGH_VOL', confidence: Math.min(1, ind.atrPercent / 8) }
}
// Priority 2: Confirmed trend (ATR above baseline + ADX + EMA persistence)
if (ind.atrPercent > 1.5 && ind.adx > 20) {
if (ind.consecutiveAboveEma >= 3) {
return {
regime: 'TRENDING_UP',
confidence: Math.min(1, (ind.adx - 20) / 30),
}
}
if (ind.consecutiveBelowEma >= 3) {
return {
regime: 'TRENDING_DOWN',
confidence: Math.min(1, (ind.adx - 20) / 30),
}
}
}
// Priority 3: Ranging -- low ATR or Bollinger squeeze reinforces
const rangingConfidence = 1 - ind.atrPercent / 1.5
const bbBoost = ind.bbWidth < 4.0 ? 0.1 : 0
return {
regime: 'RANGING',
confidence: Math.min(1, rangingConfidence + bbBoost),
}
}Each regime maps to a complete set of position parameters. The key change from earlier versions: hedging is no longer a boolean flag per regime. Instead, the hedge decision is driven by the LP position's actual delta (covered in a later section). The regime config now includes a trendBias parameter that shifts liquidity distribution toward the expected direction in trending markets.
interface RegimeParams {
binStep: number
rangePercent: number
distribution: 'Spot' | 'BidAsk' | 'Curve'
trendBias: number // 0.5 = symmetric, >0.5 = bias toward trend side
feeClaimHours: number // How often to claim accrued fees
}
const REGIME_CONFIG: Record<MarketRegime, RegimeParams> = {
RANGING: {
binStep: 40,
rangePercent: 0.04,
distribution: 'Spot',
trendBias: 0.5,
feeClaimHours: 24,
},
TRENDING_UP: {
binStep: 80,
rangePercent: 0.07,
distribution: 'BidAsk',
trendBias: 0.35,
feeClaimHours: 12,
},
TRENDING_DOWN: {
binStep: 80,
rangePercent: 0.07,
distribution: 'BidAsk',
trendBias: 0.65,
feeClaimHours: 12,
},
HIGH_VOL: {
binStep: 80,
rangePercent: 0.1,
distribution: 'Spot',
trendBias: 0.5,
feeClaimHours: 24,
},
}The trendBias controls how liquidity is distributed across the range. In an uptrend (bias 0.35), more liquidity is placed below the current price, so the position captures fees as price moves up while reducing the amount of SOL you accumulate on the way down. The inverse applies for downtrends.
Risk Checks
Before any action, the bot runs a risk assessment that can override everything else. This was a later addition but turned out to be essential -- without it, compounding losses during a drawdown would keep the bot opening new positions into a deteriorating situation.
interface RiskAssessment {
approved: boolean
action: 'proceed' | 'hold' | 'emergency_exit'
notes: string[]
}
function assessRisk(
drawdownPercent: number,
dailyLossPercent: number,
solBalance: number,
marginRatio: number | null
): RiskAssessment {
const notes: string[] = []
// Hard stop: portfolio drawdown from peak
if (drawdownPercent > 15) {
return {
approved: false,
action: 'emergency_exit',
notes: ['Drawdown exceeds 15%'],
}
}
// Daily loss limit -- don't exit, but stop opening/rebalancing
if (dailyLossPercent > 5) {
notes.push('Daily loss exceeds 5%, holding')
return { approved: false, action: 'hold', notes }
}
// Drift margin safety
if (marginRatio !== null && marginRatio < 1.2) {
return {
approved: false,
action: 'emergency_exit',
notes: ['Drift margin ratio critical'],
}
}
// Gas reserve
if (solBalance < 0.02) {
notes.push('SOL balance low for gas')
}
return { approved: true, action: 'proceed', notes }
}Expected Value as a Gate
Regime detection tells the bot what kind of market it is in. The expected value calculation tells it whether a specific rebalance is worth doing right now. Every time the position goes out of range and the wait timer expires, the bot computes the EV of rebalancing versus waiting.
interface EVResult {
expectedValue: number
breakEvenDays: number
confidence: number
recommendation: 'rebalance' | 'wait' | 'exit'
}
function calculateRebalanceEV(
positionValue: number,
totalFeesEarned: number, // Lifetime fees earned by this position
positionAgeDays: number, // Days since position opened
swapCostPercent: number, // Typically ~0.2% for Jupiter
priceChangePercent: number, // How far price moved since last rebalance
rangePercent: number, // Width of the new range
atrPercent: number, // Current ATR as % of price
regimeConfidence: number // Confidence from regime classification
): EVResult {
// Cost side: everything you pay to rebalance
const swapCost = swapCostPercent * positionValue
const ilCrystallized = 0.5 * Math.pow(priceChangePercent, 2) * positionValue
const totalCost = (swapCost + ilCrystallized) * 1.5 // 1.5x safety buffer
// Revenue side: estimate daily fee rate from actual performance
const observedDailyRate =
positionAgeDays > 0 ? totalFeesEarned / positionAgeDays / positionValue : 0
const adjustedFeeRate = Math.max(observedDailyRate, 0.001) // Floor at 0.1% daily
// How long will the new position stay in range?
const estimatedDaysInRange = rangePercent / Math.max(atrPercent, 0.01)
const expectedFeeIncome =
adjustedFeeRate * positionValue * estimatedDaysInRange
const ev = expectedFeeIncome - totalCost
const breakEvenDays = totalCost / (adjustedFeeRate * positionValue)
// Confidence scales with regime stability and expected duration
const confidence = regimeConfidence * Math.min(1, estimatedDaysInRange / 3)
let recommendation: 'rebalance' | 'wait' | 'exit'
if (ev > 0 && confidence > 0.6) recommendation = 'rebalance'
else recommendation = 'wait'
return { expectedValue: ev, breakEvenDays, confidence, recommendation }
}The 1.5x buffer on costs is conservative by design. Concentrated LP positions suffer more IL than the standard formula predicts because the liquidity is not spread across all prices, and swap slippage during volatile periods tends to exceed estimates. The buffer accounts for both.
A key change from earlier versions: the confidence score is no longer a static per-regime value. It now combines the regime detector's own confidence with the expected time in range. A high-confidence RANGING regime with a wide expected duration produces a very confident rebalance signal, while a borderline TRENDING classification with a narrow range produces a weak one. The 0.6 threshold was tuned empirically -- lower values led to unprofitable rebalances during regime transitions.
The Wait Timer Pattern
When the position goes out of range, the bot does not immediately compute EV. It starts a wait timer with a duration that depends on the current regime. The base wait is 2 hours, reduced to 1 hour in RANGING (since ranging markets mean-revert faster and the position is more likely to come back in range on its own).
class WaitTimer {
private outOfRangeAt: number | null = null
checkShouldEvaluate(regime: MarketRegime): boolean {
const now = Date.now()
if (this.outOfRangeAt === null) {
this.outOfRangeAt = now
return false // Just went out of range, start waiting
}
const waitMs =
regime === 'RANGING'
? 1 * 60 * 60 * 1000 // 1 hour for ranging
: 2 * 60 * 60 * 1000 // 2 hours base
return now - this.outOfRangeAt >= waitMs
}
reset(): void {
this.outOfRangeAt = null
}
}On top of the wait timer, the bot enforces hard rate limits: a minimum of 4 hours between any two rebalances and a maximum of 2 rebalances per day. These caps prevent runaway rebalancing during rapid regime transitions where the EV calculation might repeatedly produce marginal positives.
During the wait window, the bot re-evaluates every 5-minute tick. If the price reverts and the position comes back in range, the timer resets and the rebalance is canceled -- saving the full cost of a round trip that would have been wasted. In practice, roughly 40% of out-of-range events revert within the wait window.
Delta-Based Hedging
The hedging strategy went through a significant redesign. The original version activated Drift shorts whenever the regime was TRENDING and deactivated them in RANGING. This caused two problems: hedge activation lagged behind actual price movement (the regime takes time to confirm), and during regime transitions the hedge would thrash on and off as classification flickered.
The current version decouples hedging from regime classification entirely. Instead, it monitors the LP position's actual delta -- the percentage of position value exposed to SOL price movements -- and hedges based on thresholds.
function calculateLPDelta(
currentPrice: number,
lowerPrice: number,
upperPrice: number,
totalValueUsd: number
): { deltaPercent: number; solExposure: number } {
// Out of range above: all USDC, zero delta
if (currentPrice >= upperPrice) return { deltaPercent: 0, solExposure: 0 }
// Out of range below: all SOL, full delta
if (currentPrice <= lowerPrice) {
return { deltaPercent: 1, solExposure: totalValueUsd / currentPrice }
}
// In range: linear interpolation
const priceInRange = (currentPrice - lowerPrice) / (upperPrice - lowerPrice)
const deltaPercent = 1 - priceInRange
const solExposure = (deltaPercent * totalValueUsd) / currentPrice
return { deltaPercent, solExposure }
}
// Hedge thresholds
const DELTA_OPEN_THRESHOLD = 0.6 // Open hedge when LP delta > 60%
const DELTA_CLOSE_THRESHOLD = 0.4 // Close hedge when LP delta < 40%
const DELTA_TARGET = 0.5 // Target 50% (market-neutral)
const DELTA_TOLERANCE = 0.1 // Adjust if mismatch > 10%
function evaluateHedge(
lpDelta: number,
currentHedgeSizeSOL: number,
lpValueUsd: number,
solPrice: number
): { action: 'open' | 'close' | 'adjust' | 'hold'; sizeSOL: number } {
const hasHedge = currentHedgeSizeSOL > 0
if (!hasHedge && lpDelta > DELTA_OPEN_THRESHOLD) {
// Open new hedge: short the excess delta above target
const excessDelta = lpDelta - DELTA_TARGET
const hedgeSOL = (excessDelta * lpValueUsd) / solPrice
return { action: 'open', sizeSOL: hedgeSOL }
}
if (hasHedge && lpDelta < DELTA_CLOSE_THRESHOLD) {
return { action: 'close', sizeSOL: 0 }
}
if (hasHedge) {
// Check if existing hedge needs adjustment
const targetHedgeSOL = Math.max(
0,
((lpDelta - DELTA_TARGET) * lpValueUsd) / solPrice
)
const mismatch =
Math.abs(targetHedgeSOL - currentHedgeSizeSOL) /
Math.max(currentHedgeSizeSOL, 0.001)
if (mismatch > DELTA_TOLERANCE) {
return { action: 'adjust', sizeSOL: targetHedgeSOL }
}
}
return { action: 'hold', sizeSOL: currentHedgeSizeSOL }
}This approach has several advantages. The hedge responds to the actual market position rather than to a classification that might be uncertain. It naturally scales -- a position that has drifted far to one side gets a larger hedge than one near the center. And it eliminates the thrashing problem entirely: the open/close thresholds create a dead zone that prevents rapid toggling.
The hedge tracks funding payments from Drift and monitors consecutive negative funding cycles. If the short position accumulates more than 6 consecutive hours of negative funding, the bot flags it for review -- at some point the cost of the hedge exceeds the protection it provides.
The Orchestrator Loop
All of these components come together in the main orchestrator loop that runs every 5 minutes:
async function tick(services: BotServices): Promise<void> {
const price = await services.priceFeed.getCurrentPrice()
const candles = await services.priceFeed.getCandles('1h', 100)
// 1. Detect current regime
const indicators = computeIndicators(candles)
const { regime, confidence } = classifyRegime(indicators)
// 2. Refresh all positions
const lpState = await services.positionManager.refreshState()
const hedgeState = await services.hedgeManager.refreshState()
// 3. Risk check -- can override everything
const risk = assessRisk(
services.pnlTracker.getDrawdown(),
services.pnlTracker.getDailyLoss(),
await services.wallet.getSolBalance(),
hedgeState?.marginRatio ?? null
)
if (risk.action === 'emergency_exit') {
await services.positionManager.emergencyExit()
await services.hedgeManager.closeAll()
return
}
// 4. No position: open if conditions allow
if (!lpState.positionAddress) {
if (risk.approved && regime !== 'HIGH_VOL') {
await services.positionManager.openPosition(regime)
}
return
}
// 5. HIGH_VOL: exit immediately
if (regime === 'HIGH_VOL') {
await services.positionManager.emergencyExit()
return
}
// 6. Delta-based hedge evaluation (independent of regime)
const { deltaPercent } = calculateLPDelta(
price,
lpState.lowerPriceUSD,
lpState.upperPriceUSD,
lpState.totalValueUSD
)
const hedgeAction = evaluateHedge(
deltaPercent,
Math.abs(hedgeState?.sizeSOL ?? 0),
lpState.totalValueUSD,
price
)
if (hedgeAction.action !== 'hold') {
await services.hedgeManager.execute(hedgeAction)
}
// 7. In range: claim fees on schedule
if (lpState.isInRange) {
const claimInterval = REGIME_CONFIG[regime].feeClaimHours * 60 * 60 * 1000
if (
Date.now() - lpState.lastFeeClaim > claimInterval &&
lpState.unclaimedFeesUSD > 1
) {
await services.positionManager.claimFees()
}
services.waitTimer.reset()
return
}
// 8. Out of range: rate-limit, wait, then evaluate EV
if (!risk.approved) return
if (!services.waitTimer.checkShouldEvaluate(regime)) return
if (services.rateLimiter.isBlocked()) return
const ev = calculateRebalanceEV(
lpState.totalValueUSD,
lpState.totalFeesEarnedUSD,
lpState.ageDays,
0.002,
lpState.priceChangeSinceEntry,
REGIME_CONFIG[regime].rangePercent,
indicators.atrPercent,
confidence
)
if (ev.recommendation === 'rebalance') {
await services.positionManager.rebalance(regime)
services.waitTimer.reset()
services.rateLimiter.record()
}
// else: keep waiting, re-evaluate next tick
}Results
After deploying the regime-aware, EV-gated version with delta-based hedging, rebalance frequency dropped by about 70%. The bot went from multiple rebalances per day to sometimes going several days without acting during calm markets where the position stayed in range and quietly accumulated fees.
Net yield improved meaningfully. The gross fee capture decreased slightly because the bot occasionally missed fees by staying out of range longer than necessary. But the cost savings from avoided rebalances more than compensated. The all-in transaction cost dropped from roughly 3% to under 0.5% of position size over a comparable period.
The delta-based hedging was the second biggest improvement after the EV gate. By decoupling hedge decisions from regime classification, the hedge responds faster to actual market moves and avoids the thrashing problem that plagued the regime-based approach. Funding costs are lower because the hedge is only active when the position genuinely needs protection, not whenever the market looks "trendy."
The lesson generalizes beyond LP bots. In any system that takes costly actions in response to market movements, the first optimization should not be making the actions faster or cheaper. It should be asking whether the action needs to happen at all.
Related Posts
How I use Fisher Transform-derived volatility cones to set optimal LP ranges on Orca Whirlpools, with process-isolated Drift hedging and session-based analytics for measuring what actually works.
Hard-won lessons from building and running automated trading bots on Solana. Covers architecture patterns, error handling, and the operational concerns nobody talks about.
Practical lessons from building DeFi bots on Solana. Covers the account model, transaction patterns, real-time monitoring via WebSocket, and production pitfalls that documentation does not warn you about.