VaultCharts
Trading Strategycryptostocksforexindices

Volatility Compression Breakout Strategy

Trades volatility expansion after compression with volume confirmation. Works well on crypto, equities, indices, and FX.

What is Volatility Compression Breakout?

Trades volatility expansion after compression with volume confirmation. Uses Bollinger Band width compression, ATR compression, and volume expansion signals. Waits for a squeeze (BB width or ATR in lower percentile) then enters on breakout with volume surge. Exits at band midline or ATR-based stop.

Strategy Parameters

ParameterTypeDefaultDescription
bbPeriodnumber20Bollinger Bands period
bbStdDevnumber2Bollinger Bands standard deviation
atrPeriodnumber14ATR period
volumeMaPeriodnumber20Volume MA period
lookbackPercentilenumber20Percentile for compression
priceFlatPeriodnumber10Bars for flat price check
priceFlatThresholdnumber0.01Max price change for flat

Use Cases

  • Breakout after consolidation
  • Volume-confirmed expansion
  • Crypto and equities squeeze setups
  • Stop at middle band or ATR

Strategy Script (JavaScript)

This strategy runs in VaultCharts using the built-in strategy engine. Below is the full script in a readable format. You can copy it or run it directly in VaultCharts.

strategy.jsVaultCharts built-in
module.exports = {
  meta: {
    name: "Volatility Compression Breakout",
    params: {
      bbPeriod: { type: "number", default: 20 },
      bbStdDev: { type: "number", default: 2 },
      atrPeriod: { type: "number", default: 14 },
      volumeMaPeriod: { type: "number", default: 20 },
      lookbackPercentile: { type: "number", default: 20 },
      priceFlatPeriod: { type: "number", default: 10 },
      priceFlatThreshold: { type: "number", default: 0.01 }
    }
  },
  compute: (data, params, utils) => {
    // Data sanitization
    const cleanData = data.filter(d => 
      d && 
      Number.isFinite(d.high) && 
      Number.isFinite(d.low) && 
      Number.isFinite(d.close) &&
      Number.isFinite(d.time) &&
      Number.isFinite(d.volume) &&
      d.high >= d.low &&
      d.close > 0 &&
      d.volume >= 0
    );
    
    if (!cleanData || cleanData.length < 100) {
      console.warn('[VCB] Insufficient data:', cleanData?.length || 0);
      return { signals: [] };
    }
    
    const { technicalindicators: TI } = utils;
    if (!TI || !TI.BollingerBands || !TI.ATR || !TI.EMA) {
      console.warn('[VCB] Technical indicators not available');
      return { signals: [] };
    }
    
    const bbPeriod = params?.bbPeriod ?? 20;
    const bbStdDev = params?.bbStdDev ?? 2;
    const atrPeriod = params?.atrPeriod ?? 14;
    const volumeMaPeriod = params?.volumeMaPeriod ?? 20;
    const lookbackPercentile = params?.lookbackPercentile ?? 20;
    const priceFlatPeriod = params?.priceFlatPeriod ?? 10;
    const priceFlatThreshold = params?.priceFlatThreshold ?? 0.01;
    
    const closes = cleanData.map(d => d.close);
    const highs = cleanData.map(d => d.high);
    const lows = cleanData.map(d => d.low);
    const volumes = cleanData.map(d => d.volume || 0);
    
    // Calculate indicators
    let bb;
    try {
      if (TI.BollingerBands && TI.BollingerBands.calculate) {
        bb = TI.BollingerBands.calculate({ period: bbPeriod, values: closes, stdDev: bbStdDev });
      } else if (TI.BBANDS) {
        bb = TI.BBANDS.calculate({ period: bbPeriod, values: closes, stdDev: bbStdDev });
      } else {
        return { signals: [] };
      }
    } catch (err) {
      console.error('[VCB] BollingerBands error:', err);
      return { signals: [] };
    }
    
    const atr = TI.ATR.calculate({ high: highs, low: lows, close: closes, period: atrPeriod });
    const volumeMA = TI.EMA.calculate({ period: volumeMaPeriod, values: volumes });
    
    // Calculate True Range for each candle
    const trueRanges = [];
    for (let i = 0; i < cleanData.length; i++) {
      if (i === 0) {
        trueRanges.push(highs[i] - lows[i]);
      } else {
        const tr1 = highs[i] - lows[i];
        const tr2 = Math.abs(highs[i] - closes[i - 1]);
        const tr3 = Math.abs(lows[i] - closes[i - 1]);
        trueRanges.push(Math.max(tr1, tr2, tr3));
      }
    }
    
    const signals = [];
    const lookbackWindow = Math.max(50, bbPeriod * 2); // Minimum 50 bars for percentile calculation
    
    // Iterate over data starting from where we have all indicators
    const startIdx = Math.max(bbPeriod, atrPeriod, volumeMaPeriod, lookbackWindow, priceFlatPeriod);
    
    let checkedCount = 0;
    let skippedCount = 0;
    let compressionCount = 0;
    let breakoutCount = 0;
    let volumeSurgeCount = 0;
    
    for (let i = startIdx; i < cleanData.length; i++) {
      const candle = cleanData[i];
      if (!candle || candle.time === undefined) continue;
      
      // Get indicator indices
      const bbIdx = i - (bbPeriod - 1);
      const atrIdx = i - (atrPeriod - 1);
      const volumeMaIdx = i - (volumeMaPeriod - 1);
      
      if (bbIdx < 0 || atrIdx < 0 || volumeMaIdx < 0) {
        skippedCount++;
        continue;
      }
      if (bbIdx >= bb.length || atrIdx >= atr.length || volumeMaIdx >= volumeMA.length) {
        skippedCount++;
        continue;
      }
      
      const bbCurr = bb[bbIdx];
      const atrCurr = atr[atrIdx];
      const volumeMaCurr = volumeMA[volumeMaIdx];
      const trueRangeCurr = trueRanges[i];
      
      if (!bbCurr || !Number.isFinite(bbCurr.upper) || !Number.isFinite(bbCurr.lower) || !Number.isFinite(bbCurr.middle)) {
        skippedCount++;
        continue;
      }
      if (!Number.isFinite(atrCurr) || !Number.isFinite(volumeMaCurr) || !Number.isFinite(trueRangeCurr)) {
        skippedCount++;
        continue;
      }
      if (bbCurr.middle === 0 || candle.close === 0 || volumeMaCurr === 0) {
        skippedCount++;
        continue;
      }
      
      checkedCount++;
      
      // Calculate BB Width normalized
      const bbWidth = (bbCurr.upper - bbCurr.lower) / bbCurr.middle;
      
      // Calculate ATR normalized
      const atrNormalized = atrCurr / candle.close;
      
      // Get historical BB widths and ATR normalized for percentile calculation
      const bbWidths = [];
      const atrNormalizeds = [];
      
      for (let j = Math.max(0, i - lookbackWindow); j < i; j++) {
        const histBbIdx = j - (bbPeriod - 1);
        if (histBbIdx >= 0 && histBbIdx < bb.length) {
          const histBb = bb[histBbIdx];
          if (histBb && histBb.middle > 0) {
            const histBbWidth = (histBb.upper - histBb.lower) / histBb.middle;
            bbWidths.push(histBbWidth);
          }
        }
        
        const histAtrIdx = j - (atrPeriod - 1);
        if (histAtrIdx >= 0 && histAtrIdx < atr.length && cleanData[j].close > 0) {
          const histAtrNorm = atr[histAtrIdx] / cleanData[j].close;
          atrNormalizeds.push(histAtrNorm);
        }
      }
      
      if (bbWidths.length < 10 || atrNormalizeds.length < 10) {
        skippedCount++;
        continue;
      }
      
      // Calculate percentiles
      bbWidths.sort((a, b) => a - b);
      atrNormalizeds.sort((a, b) => a - b);
      const bbWidthPercentile = bbWidths[Math.floor(bbWidths.length * (lookbackPercentile / 100))];
      const atrPercentile = atrNormalizeds[Math.floor(atrNormalizeds.length * (lookbackPercentile / 100))];
      
      // Check compression conditions
      const bbCompressed = bbWidth < bbWidthPercentile;
      const atrCompressed = atrNormalized < atrPercentile;
      
      // Check volume rising
      const volumePrevIdx = volumeMaIdx - 1;
      const volumeRising = volumeMaIdx > 0 && volumeMaCurr > volumeMA[volumePrevIdx];
      
      // Check price flat (close to close over priceFlatPeriod)
      const priceFlatIdx = i - priceFlatPeriod;
      let priceFlat = false;
      if (priceFlatIdx >= 0) {
        const priceChange = Math.abs(candle.close - cleanData[priceFlatIdx].close) / candle.close;
        priceFlat = priceChange < priceFlatThreshold;
      }
      
      // Compression regime: relaxed - at least BB or ATR compressed, and volume rising
      const inCompression = (bbCompressed || atrCompressed) && volumeRising;
      
      if (inCompression) compressionCount++;
      
      // Get position state
      const lastSignal = signals.length > 0 ? signals[signals.length - 1] : null;
      const inLongPosition = lastSignal && lastSignal.type === 'entry' && lastSignal.direction === 'long';
      const inShortPosition = lastSignal && lastSignal.type === 'entry' && lastSignal.direction === 'short';
      
      // Entry conditions (expansion trigger) - relaxed requirements
      const volumeSurge = candle.volume > 1.2 * volumeMaCurr; // Reduced from 1.5 to 1.2
      const rangeExpansion = trueRangeCurr > 1.1 * atrCurr; // Reduced from 1.2 to 1.1
      
      const breakoutLong = candle.close > bbCurr.upper;
      const breakoutShort = candle.close < bbCurr.lower;
      
      if (breakoutLong || breakoutShort) breakoutCount++;
      if (volumeSurge) volumeSurgeCount++;
      
      // Entry signals - simplified: compression + (breakout OR volume surge)
      if (!inLongPosition && !inShortPosition && inCompression) {
        if (breakoutLong && (volumeSurge || rangeExpansion)) {
          signals.push({ type: "entry", direction: "long", time: candle.time, price: candle.close, index: i });
        } else if (breakoutShort && (volumeSurge || rangeExpansion)) {
          signals.push({ type: "entry", direction: "short", time: candle.time, price: candle.close, index: i });
        }
      }
      
      // Exit signals (1 ATR stop, or trail with EMA)
      if (inLongPosition) {
        const entrySignal = signals.find(s => s.type === 'entry' && s.direction === 'long' && !signals.slice(signals.indexOf(s) + 1).some(s2 => s2.type === 'exit' && s2.direction === 'long'));
        if (entrySignal) {
          const stopLoss = entrySignal.price - atrCurr;
          if (candle.close < stopLoss || candle.close < bbCurr.middle) {
            signals.push({ type: "exit", direction: "long", time: candle.time, price: candle.close, index: i });
          }
        }
      }
      
      if (inShortPosition) {
        const entrySignal = signals.find(s => s.type === 'entry' && s.direction === 'short' && !signals.slice(signals.indexOf(s) + 1).some(s2 => s2.type === 'exit' && s2.direction === 'short'));
        if (entrySignal) {
          const stopLoss = entrySignal.price + atrCurr;
          if (candle.close > stopLoss || candle.close > bbCurr.middle) {
            signals.push({ type: "exit", direction: "short", time: candle.time, price: candle.close, index: i });
          }
        }
      }
    }
    
    console.log('[VCB] Analysis:', {
      dataLength: cleanData.length,
      startIdx,
      checkedCount,
      skippedCount,
      compressionCount,
      breakoutCount,
      volumeSurgeCount,
      signalsGenerated: signals.length
    });
    
    return { signals };
  }
};

Run Volatility Compression Breakout in VaultCharts

VaultCharts includes this strategy as a built-in option. Backtest it, adjust parameters, and use it on your own data—all stored locally on your device.

Related Strategies

Explore More