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
| Parameter | Type | Default | Description |
|---|---|---|---|
| bbPeriod | number | 20 | Bollinger Bands period |
| bbStdDev | number | 2 | Bollinger Bands standard deviation |
| atrPeriod | number | 14 | ATR period |
| volumeMaPeriod | number | 20 | Volume MA period |
| lookbackPercentile | number | 20 | Percentile for compression |
| priceFlatPeriod | number | 10 | Bars for flat price check |
| priceFlatThreshold | number | 0.01 | Max 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.