0xJosee.
HomeAboutProjectsBlogContact
0xJosee.

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

Navigation

HomeAboutProjectsBlogContact

Connect

© 2026 0xJosee. All rights reserved.
DeFi & SolanaJune 20, 20257 min read

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.

SolanaOrcaDriftDeFiTypeScriptVolatility

Setting LP ranges for concentrated liquidity is one of those problems that sounds simple and turns out to be surprisingly deep. The naive approach is a fixed percentage offset: put liquidity within 3% of the current price, collect fees, rebalance when you fall out of range. This works well enough during stable markets but fails systematically because it ignores the single most important input: how volatile the market actually is right now.

I wanted a system where the LP range emerges from statistical analysis of recent price behavior rather than from a manually tuned constant. When I was researching this I found almost nothing on applying Fisher Transform specifically to LP range setting -- plenty of material on using it as a trading oscillator, but nothing on the volatility normalization application for concentrated liquidity. So I had to work it out myself from the underlying math.

The Problem with Fixed Ranges

Consider a 3% range on SOL/USDC. During a calm week with 1% daily volatility, this range is comfortable -- price stays inside it for days, and the position earns fees continuously. During a volatile week with 4% daily swings, the same range gets blown through within hours, triggering expensive rebalances that eat into (or eliminate) the fees you earned.

You could widen the range to accommodate volatility, but then during calm periods you are earning far less in fees than you should because your liquidity is spread too thin. The fundamental issue is that a static range cannot adapt to changing market conditions.

Computing Realized Volatility

The first step is measuring what the market is actually doing. I compute realized volatility from 1-minute OHLCV data, using a standard returns-based approach:

interface PricePoint {
  price: number
  timestamp: number
}
 
function computeReturns(prices: PricePoint[]): number[] {
  return prices
    .slice(1)
    .map((p, i) => (p.price - prices[i].price) / prices[i].price)
}
 
function computeRealizedVolatility(prices: PricePoint[]): number {
  const returns = computeReturns(prices)
  const mean = returns.reduce((a, b) => a + b, 0) / returns.length
  const variance =
    returns.reduce((acc, r) => acc + Math.pow(r - mean, 2), 0) / returns.length
  const stdDev = Math.sqrt(variance)
 
  // Scale from per-minute to hourly volatility
  // sqrt(60) because volatility scales with sqrt of time
  return stdDev * Math.sqrt(60)
}

This gives you hourly realized volatility as a decimal (e.g., 0.012 means 1.2% hourly vol). But using this directly to set LP ranges has a problem: the returns are not normally distributed.

Why Fisher Transform Matters

Standard deviation assumes normally distributed data. Real price returns have fat tails -- extreme moves happen more often than a Gaussian model predicts. If you set your LP range at 1.96 standard deviations (the classic 95% confidence interval), you will catch 95% of moves assuming normality, but in reality you might only catch 90% because of those fat tails.

The Fisher Transform converts any bounded distribution to one that is approximately Gaussian. Applied to normalized price data, it makes standard confidence intervals accurate:

function fisherTransform(values: number[]): number[] {
  // First, normalize values to the [-1, 1] range
  const min = Math.min(...values)
  const max = Math.max(...values)
  const range = max - min
 
  return values.map((v) => {
    // Normalize to [-1, 1]
    let normalized = 2 * ((v - min) / range) - 1
    // Clamp to avoid infinity at ±1
    normalized = Math.max(-0.999, Math.min(0.999, normalized))
    // Fisher Transform: arctanh
    return 0.5 * Math.log((1 + normalized) / (1 - normalized))
  })
}
 
function inverseFisher(value: number): number {
  // Convert back from Fisher space to normalized space
  return (Math.exp(2 * value) - 1) / (Math.exp(2 * value) + 1)
}

After transforming, the data approximates a normal distribution where standard deviation-based confidence intervals actually mean what they claim. The 95% interval really captures 95% of outcomes.

A caveat worth noting: the Fisher Transform is mathematically designed for bounded values in [-1, 1], and price returns are theoretically unbounded. The min/max normalization within a rolling window makes this work empirically -- within any finite lookback window, returns are naturally bounded. This is not a textbook application of the transform, but the key insight is that it corrects for the fat-tailed distribution of returns within the window, which is what matters for LP range setting. I validated this by comparing the predicted 95% range against actual price outcomes over several months of data: the Fisher-adjusted cone captured 93-96% of realized moves, versus 87-89% for a raw standard deviation cone.

Building the Volatility Cone

A volatility cone projects expected price ranges at a given confidence level. Instead of a single volatility number, it provides an envelope that shows where price is likely to be over a given time horizon.

interface VolatilityCone {
  currentPrice: number
  hourlyVol: number
  lower: number // Lower price bound at confidence level
  upper: number // Upper price bound at confidence level
  confidence: number // e.g., 0.95
  timeHorizonHours: number
}
 
function buildVolatilityCone(
  prices: PricePoint[],
  currentPrice: number,
  timeHorizonHours: number = 4,
  confidence: number = 0.95
): VolatilityCone {
  const returns = computeReturns(prices)
 
  // Apply Fisher Transform to normalize the returns distribution
  const fisherReturns = fisherTransform(returns)
 
  // Now standard deviation is meaningful
  const fisherMean =
    fisherReturns.reduce((a, b) => a + b, 0) / fisherReturns.length
  const fisherStd = Math.sqrt(
    fisherReturns.reduce((a, r) => a + Math.pow(r - fisherMean, 2), 0) /
      fisherReturns.length
  )
 
  // Z-score for desired confidence level (1.96 for 95%)
  const zScore =
    confidence === 0.95 ? 1.96 : confidence === 0.99 ? 2.576 : 1.645
 
  // Project over time horizon (volatility scales with sqrt of time)
  const scaledStd = fisherStd * Math.sqrt(timeHorizonHours * 60) // Scale from per-minute
 
  // Compute bounds in Fisher space, then convert back
  const fisherUpper = fisherMean + zScore * scaledStd
  const fisherLower = fisherMean - zScore * scaledStd
 
  // Convert Fisher bounds back to price deviation
  const upperDev = inverseFisher(fisherUpper)
  const lowerDev = inverseFisher(fisherLower)
 
  // Raw hourly volatility for reference
  const hourlyVol = computeRealizedVolatility(prices)
 
  return {
    currentPrice,
    hourlyVol,
    lower: currentPrice * (1 + lowerDev),
    upper: currentPrice * (1 + upperDev),
    confidence,
    timeHorizonHours,
  }
}

The beauty of this approach is that it naturally adapts. During calm markets the cone is narrow, producing tight LP ranges that maximize fee capture. During volatile markets it widens automatically, reducing rebalance frequency without manual intervention.

Rate of Change for Entry Timing

Knowing the right range is half the problem. The other half is knowing when to enter. Opening a position during a period of rapid price movement means it will immediately start drifting toward the edge of its range.

function calculatePriceROC(
  prices: PricePoint[],
  lookbackMinutes: number = 60
): number {
  if (prices.length < lookbackMinutes) return Infinity // Not enough data
 
  const current = prices[prices.length - 1].price
  const past = prices[prices.length - lookbackMinutes].price
  return (current - past) / past
}
 
function calculateVolatilityROC(
  prices: PricePoint[],
  windowMinutes: number = 60
): number {
  if (prices.length < windowMinutes * 2) return Infinity
 
  const recentPrices = prices.slice(-windowMinutes)
  const priorPrices = prices.slice(-windowMinutes * 2, -windowMinutes)
 
  const recentVol = computeRealizedVolatility(recentPrices)
  const priorVol = computeRealizedVolatility(priorPrices)
 
  if (priorVol === 0) return Infinity
  return (recentVol - priorVol) / priorVol
}
 
interface EntryAssessment {
  favorable: boolean
  priceROC: number
  volROC: number
  reason: string
}
 
function assessEntryConditions(
  prices: PricePoint[],
  priceRocThreshold: number = 0.01, // 1% price change
  volRocThreshold: number = 0.05 // 5% volatility change
): EntryAssessment {
  const priceROC = calculatePriceROC(prices)
  const volROC = calculateVolatilityROC(prices)
 
  const priceStable = Math.abs(priceROC) <= priceRocThreshold
  const volStable = Math.abs(volROC) <= volRocThreshold
  const favorable = priceStable && volStable
 
  return {
    favorable,
    priceROC,
    volROC,
    reason: favorable
      ? 'Price consolidating, volatility stable'
      : !priceStable
        ? `Price moving too fast (ROC: ${(priceROC * 100).toFixed(2)}%)`
        : `Volatility shifting (ROC: ${(volROC * 100).toFixed(2)}%)`,
  }
}

In my testing, adding the ROC gate increased the average time-in-range per position by roughly 40%, which translates directly to more fees earned per rebalance cycle.

Mapping the Cone to Tick Ranges

The volatility cone gives you price bounds. Orca Whirlpools work in tick space. You need to convert between the two and add an adaptive buffer.

function priceToTick(price: number, tickSpacing: number): number {
  // Whirlpool tick formula: tick = log(price) / log(1.0001)
  const rawTick = Math.log(price) / Math.log(1.0001)
  // Snap to nearest valid tick (must be multiple of tickSpacing)
  return Math.round(rawTick / tickSpacing) * tickSpacing
}
 
function coneToPositionParams(
  cone: VolatilityCone,
  tickSpacing: number
): { tickLower: number; tickUpper: number; buffer: number } {
  // Base ticks from the cone
  const rawTickLower = priceToTick(cone.lower, tickSpacing)
  const rawTickUpper = priceToTick(cone.upper, tickSpacing)
 
  // Adaptive buffer: tighter cones need proportionally more buffer
  // to avoid immediate out-of-range events
  const rangeTicks = rawTickUpper - rawTickLower
  const bufferTicks = Math.max(
    tickSpacing * 2, // Minimum 2 tick spacings
    Math.round(rangeTicks * 0.1) // 10% of range as buffer
  )
 
  return {
    tickLower: rawTickLower - bufferTicks,
    tickUpper: rawTickUpper + bufferTicks,
    buffer: bufferTicks,
  }
}

The adaptive buffer is important. When the cone is narrow (calm market), the raw range would be very tight -- maximizing fee capture but leaving almost no room for price to move. The buffer adds breathing room proportional to how tight the range is. When the cone is already wide, the extra buffer is minimal because the range itself provides enough cushion.

Process-Isolated Drift Hedging

The hedge component manages perpetual short positions on Drift Protocol. Early versions ran Drift in the same process as Orca, which caused intermittent failures because both SDKs maintain internal state and connection pools that occasionally conflicted under load.

The solution was to move Drift into a child process communicating via IPC:

import { fork, ChildProcess } from 'child_process'
 
class DriftHedgeManager {
  private worker: ChildProcess
 
  constructor(workerPath: string) {
    this.worker = fork(workerPath)
    this.worker.on('exit', (code) => {
      // Auto-restart on crash, reconstruct state from on-chain data
      console.log(`Drift worker exited with code ${code}, restarting...`)
      this.worker = fork(workerPath)
      this.reconstructState()
    })
  }
 
  async openShort(solAmount: number): Promise<{ txSignature: string }> {
    return this.sendCommand({
      action: 'open',
      size: solAmount,
      direction: 'short',
    })
  }
 
  async getPosition(): Promise<{
    size: number
    unrealizedPnl: number
    fundingAccrued: number
  }> {
    return this.sendCommand({ action: 'query_status' })
  }
 
  private sendCommand<T>(command: HedgeCommand): Promise<T> {
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(
        () => reject(new Error('Drift worker timeout')),
        30_000
      )
 
      const handler = (msg: any) => {
        clearTimeout(timeout)
        this.worker.off('message', handler)
        if (msg.status === 'error') reject(new Error(msg.message))
        else resolve(msg as T)
      }
 
      this.worker.on('message', handler)
      this.worker.send(command)
    })
  }
}

The crash isolation has proven its value in production. Drift's RPC endpoints occasionally have issues that would have taken down the entire bot. With process isolation, the LP side continues operating normally while the hedge worker restarts and reconstructs its state by querying the on-chain Drift account.

Session-Based Analytics

Measuring LP performance requires decomposing each position's lifecycle into its components. Total portfolio value over time is a useless metric because it conflates fee income with price moves and hides IL inside unrealized changes.

The bot stores each LP position as a "session" in PostgreSQL:

interface LPSession {
  id: string
  openedAt: Date
  closedAt: Date | null
  // Entry conditions (what the market looked like when we entered)
  entryPrice: number
  entryVolatility: number
  entryPriceROC: number
  entryFisherValue: number
  // Position parameters
  tickLower: number
  tickUpper: number
  initialLiquidityUsd: number
  // Outcomes
  feesEarnedUsd: number
  impermanentLossUsd: number
  hedgePnlUsd: number
  gasCostUsd: number
  netPnlUsd: number
  timeInRangePercent: number
  rebalanceCount: number
}
 
// Query: compare sessions by entry conditions
// "Do positions opened during low-ROC conditions actually perform better?"
async function compareByEntryCondition(db: Pool): Promise<void> {
  const result = await db.query(`
    SELECT
      CASE WHEN entry_price_roc < 0.01 THEN 'low_roc' ELSE 'high_roc' END as condition,
      AVG(net_pnl_usd / initial_liquidity_usd * 100) as avg_return_pct,
      AVG(time_in_range_percent) as avg_time_in_range,
      AVG(fees_earned_usd / initial_liquidity_usd * 100) as avg_fee_pct,
      COUNT(*) as session_count
    FROM lp_sessions
    WHERE closed_at IS NOT NULL
    GROUP BY condition
  `)
  // Result: low_roc positions earn ~30% more net yield
}

This enables answering questions with data rather than assumptions: do positions opened during low ROC conditions perform better? (Yes, roughly 30% more net yield.) What is the real cost of the adaptive buffer across different volatility regimes? (Negligible -- it reduces fee capture by about 2% but prevents 15% of unnecessary rebalances.) At what Fisher Transform value do positions have the longest time-in-range? (Between -0.5 and 0.5, as expected for a normalized oscillator.)

To put numbers on it: over comparable periods, a fixed 3% range averaged about 4 rebalances per week with net yield eaten by costs. A simple volatility-scaled range (no Fisher Transform) reduced that to 2.5 rebalances per week with better net yield. The Fisher Transform-adjusted cone brought it down to about 1.5 rebalances per week with the best net yield of the three -- roughly 25% better than the raw volatility approach on a risk-adjusted basis. The improvement comes almost entirely from fewer unnecessary rebalances during periods when the raw volatility estimate underestimates tail risk.

The Fisher Transform approach removes the guesswork from LP range setting. Instead of tweaking percentage offsets after every losing position, the ranges emerge naturally from the data. And with session-based analytics, you can actually prove that the approach works rather than just believing it does.

On this page

  • The Problem with Fixed Ranges
  • Computing Realized Volatility
  • Why Fisher Transform Matters
  • Building the Volatility Cone
  • Rate of Change for Entry Timing
  • Mapping the Cone to Tick Ranges
  • Process-Isolated Drift Hedging
  • Session-Based Analytics
Share:

Previous

When NOT to Rebalance: Regime Detection and EV-Based LP Decisions

Next

Taming Wormhole: Async Generators for Cross-Chain Bridge Transactions

Related Posts

Trading Bots
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.

June 15, 2025
SolanaMeteoraDrift
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
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