0xJosee.
HomeAboutProjectsBlogContact
0xJosee.

Full-stack developer specializing in Solana DeFi, blockchain engineering, and Web3 application development.

Navigation

HomeAboutProjectsBlogContact

Connect

© 2026 0xJosee. All rights reserved.
Trading BotsJune 15, 202510 min read

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.

SolanaMeteoraDriftTradingTypeScriptDeFi

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.

On this page

  • The Problem with Eager Rebalancing
  • Building the Indicator Pipeline
    • Average True Range (ATR)
    • Average Directional Index (ADX)
    • EMA and Bollinger Bands
  • Market Regime Classification
  • Risk Checks
  • Expected Value as a Gate
  • The Wait Timer Pattern
  • Delta-Based Hedging
  • The Orchestrator Loop
  • Results
Share:

Previous

This Site's Tech Stack: Next.js 15, MDX, and Full-Stack TypeScript

Next

Volatility-Based LP Ranges: Fisher Transform for Concentrated Liquidity

Related Posts

DeFi & Solana
Volatility-Based LP Ranges: Fisher Transform for Concentrated Liquidity

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.

June 20, 2025
SolanaOrcaDrift
Trading Bots
Building Trading Bots in TypeScript: Lessons from Production

Hard-won lessons from building and running automated trading bots on Solana. Covers architecture patterns, error handling, and the operational concerns nobody talks about.

April 20, 2025
TypeScriptTradingSolana
DeFi & Solana
Getting Started with Solana DeFi: A Developer's Perspective

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.

April 1, 2025
SolanaDeFiTypeScript