Skip to Content
Recipes

Phase 3 — Adaptive Strategy

Make the earning zone width respond to market conditions. In calm markets, a narrow zone captures more fees per trade. In choppy markets, a wider zone avoids costly repositions.

Time: ~5 minutes | Requires: Phase 2 complete


The idea

In Phase 2, the earning zone is always 200 price units wide. But the right width depends on how volatile the market is:

  • Calm market (price barely moves): narrow zone = more fee income, rare repositions
  • Choppy market (price swings a lot): wide zone = fewer repositions, lower gas costs

The agent tracks how much the price has been moving and adjusts automatically.

New configuration

Add to .env:

# Volatility adaptation VOLATILITY_WINDOW=60 # Number of cycles to look back (60 cycles = 5 hours at 5min intervals) MIN_RANGE_TICKS=100 # Narrowest earning zone MAX_RANGE_TICKS=400 # Widest earning zone VOLATILITY_MULTIPLIER=3.0 # How aggressively to widen for volatility

New code: volatility calculation

Add these to agent.js:

const VOLATILITY_WINDOW = parseInt(process.env.VOLATILITY_WINDOW || '60'); const MIN_RANGE_TICKS = parseInt(process.env.MIN_RANGE_TICKS || '100'); const MAX_RANGE_TICKS = parseInt(process.env.MAX_RANGE_TICKS || '400'); const VOLATILITY_MULTIPLIER = parseFloat(process.env.VOLATILITY_MULTIPLIER || '3.0'); const BASE_RANGE = parseInt(process.env.POSITION_RANGE_TICKS || '200'); const tickHistory = []; // rolling window of price samples function calculateVolatility() { if (tickHistory.length < 2) return { volatility: 0, sampleSize: tickHistory.length }; const changes = []; for (let i = 1; i < tickHistory.length; i++) { changes.push(Math.abs(tickHistory[i].tick - tickHistory[i - 1].tick)); } const mean = changes.reduce((a, b) => a + b, 0) / changes.length; const variance = changes.reduce((a, b) => a + (b - mean) ** 2, 0) / changes.length; return { volatility: Math.sqrt(variance), sampleSize: tickHistory.length }; } function getAdaptiveRange(tickSpacing) { const { volatility, sampleSize } = calculateVolatility(); if (sampleSize < 10) return BASE_RANGE; // not enough data yet const adaptiveRange = Math.round(volatility * VOLATILITY_MULTIPLIER * 2); const clamped = Math.max(MIN_RANGE_TICKS, Math.min(MAX_RANGE_TICKS, adaptiveRange)); return Math.ceil(clamped / tickSpacing) * tickSpacing || BASE_RANGE; }

Update the main cycle

At the start of each cycle, record the current price:

// Inside runCycle(), after getting the pool state: tickHistory.push({ ts: Date.now(), tick: currentTick }); if (tickHistory.length > VOLATILITY_WINDOW) tickHistory.shift();

Update position opening

Replace the fixed RANGE with the adaptive range everywhere you open a position:

// Instead of: const tickLower = Math.floor((currentTick - RANGE) / tickSpacing) * tickSpacing; // Use: const range = getAdaptiveRange(tickSpacing); const tickLower = Math.floor((currentTick - range) / tickSpacing) * tickSpacing; const tickUpper = Math.ceil((currentTick + range) / tickSpacing) * tickSpacing;

Do this in both openPosition() and the rebalance flow.

How it works

The agent keeps a rolling window of the last 60 price readings. It calculates the standard deviation of price changes (how much the price typically moves between checks).

  • Standard deviation near 0 = calm market = use minimum range (100)
  • Standard deviation around 30 = moderate = use ~180 range
  • Standard deviation above 60 = choppy = use maximum range (400)

The VOLATILITY_MULTIPLIER controls how aggressively the range responds. Higher = wider zones sooner. The default (3.0) is conservative.

Next step

You’re now optimizing within one pool. Phase 4 — Compare Pools scans all Cetus pools to find higher-yielding opportunities.

Last updated on