Build a Squeeze Momentum Indicator in Pine Script v6
By HorizonAI Team · 12 min read · Intermediate
The market doesn't move in a straight line. It coils, compresses, then explodes. The Squeeze Momentum indicator — popularized by John Carter and coded for TradingView by LazyBear — is one of the few indicators that actually captures that mechanical process rather than just reacting to it after the fact.
This guide builds the full indicator from scratch in Pine Script v6. You'll understand every calculation, every color choice, and every alert condition. By the end, you'll have a production-ready script you can deploy, extend, or hand off to HorizonAI to turn into an automated strategy.
What You'll Learn
- How Bollinger Bands inside Keltner Channels define a volatility squeeze
- The exact math behind the momentum histogram (linear regression on delta)
- How to code 4-color momentum coding that shows acceleration vs. deceleration
- How to plot squeeze dots on the zero line with on/off state colors
- How to fire alerts on squeeze release — the highest-probability entry signal
- How to read squeeze + momentum together for directional bias
The Core Idea: Coiling Before the Explosion
Bollinger Bands (BB) measure price volatility using standard deviation. Keltner Channels (KC) measure volatility using Average True Range. Both are volatility envelopes, but they use different math — and that difference is exactly what makes this indicator work.
When Bollinger Bands are wider than Keltner Channels, the market is in a normal or expanding volatility state. When Bollinger Bands contract inside Keltner Channels, the market has entered a squeeze: volatility has compressed to an abnormally low level. Historically, these low-volatility periods precede sharp directional moves.
The squeeze itself doesn't tell you which direction the move will go. That's where the momentum histogram comes in. It measures the delta between price and a midpoint average using linear regression — positive and rising means bullish momentum is building, positive and falling means bulls are losing steam, and so on.
Think of it like a compressed spring. The squeeze tells you the spring is loaded. The histogram tells you which way it's pointing.
Building the Bollinger Bands Component
Bollinger Bands use a simple moving average (SMA) as the midline and add/subtract a multiple of standard deviation to get upper and lower bands.
//@version=6
indicator("Squeeze Momentum [HorizonAI]", shorttitle="SQZ", overlay=false)
// ─── INPUTS ───────────────────────────────────────────────────────────────────
bbLength = input.int(20, "BB Length", minval=1)
bbMult = input.float(2.0, "BB Mult", minval=0.1, step=0.1)
kcLength = input.int(20, "KC Length", minval=1)
kcMult = input.float(1.5, "KC Mult", minval=0.1, step=0.1)
useTrue = input.bool(true, "Use True Range for KC")
// ─── BOLLINGER BANDS ──────────────────────────────────────────────────────────
basis = ta.sma(close, bbLength)
dev = bbMult * ta.stdev(close, bbLength)
upperBB = basis + dev
lowerBB = basis - dev
The bbMult of 2.0 is the standard setting. Some traders tighten it to 1.5 to make the squeeze condition trigger more frequently — useful on lower timeframes where volatility compresses less dramatically.
Building the Keltner Channels Component
Keltner Channels use an EMA as the midline and ATR (or a simpler true range average) for the envelope width. The useTrue toggle matters: when true, it uses ta.atr() for a more responsive channel; when false, it uses a range-based average that's smoother.
// ─── KELTNER CHANNELS ─────────────────────────────────────────────────────────
kcBasis = ta.ema(close, kcLength)
span = useTrue ? ta.atr(kcLength) : ta.sma(high - low, kcLength)
upperKC = kcBasis + kcMult * span
lowerKC = kcBasis - kcMult * span
The kcMult default of 1.5 is the LazyBear standard. A tighter multiple (1.0) makes the squeeze harder to trigger — you'll get fewer signals but they'll represent more extreme compression. A looser multiple (2.0) fires more often.
Pro tip: On crypto pairs like BTC/USD, the default 1.5 KC multiplier works well. On forex majors like EUR/USD, dropping to 1.0 often produces cleaner squeeze signals because those markets have structurally lower volatility.
Detecting the Squeeze: Boolean Logic
The squeeze condition is a single boolean: are the BB bands entirely inside the KC bands?
// ─── SQUEEZE DETECTION ────────────────────────────────────────────────────────
sqzOn = (lowerBB > lowerKC) and (upperBB < upperKC)
sqzOff = (lowerBB < lowerKC) and (upperBB > upperKC)
noSqz = not sqzOn and not sqzOff
Three states:
sqzOn— BB is inside KC. The spring is loaded.sqzOff— BB has broken outside KC. The squeeze just fired.noSqz— Neither condition is fully met. Transitional state.
The sqzOff condition is the one you want to alert on. The bar where sqzOff becomes true (after a period of sqzOn) is the squeeze release — statistically, the highest-probability entry bar.
The Momentum Histogram: Linear Regression on Delta
This is the part most traders don't fully understand, and it's the most important part of the indicator.
The momentum value isn't just RSI or MACD. It's the linear regression of a delta value: the difference between the current close and the midpoint of the highest high / lowest low and EMA average over the lookback period.
// ─── MOMENTUM CALCULATION ─────────────────────────────────────────────────────
// Delta: close minus the average of (highest high + lowest low) / 2 and EMA
highestHigh = ta.highest(high, kcLength)
lowestLow = ta.lowest(low, kcLength)
midHL = math.avg(highestHigh, lowestLow) // midpoint of range
midEMA = math.avg(ta.ema(close, kcLength), midHL) // blend with EMA
delta = close - midEMA
// Linear regression smooths the delta over the same lookback
val = ta.linreg(delta, kcLength, 0)
The ta.linreg(delta, kcLength, 0) call fits a linear regression line to the last kcLength bars of delta values and returns the current endpoint. This smooths out noise while preserving the trend direction of momentum. The 0 offset means you're reading the current bar's value (use 1 to read the previous bar — useful for non-repainting alerts).
If you want to understand how linear regression works in a broader indicator context, the WaveTrend Oscillator build guide covers similar smoothing techniques applied to a different oscillator.
4-Color Momentum Coding
A single color histogram is readable. A 4-color histogram is actionable. The colors encode not just direction but acceleration:
| Color | Condition | Meaning |
|---|---|---|
| Bright Green | val > 0 and val > val[1] | Bullish and accelerating |
| Dark Green | val > 0 and val <= val[1] | Bullish but decelerating |
| Bright Red | val < 0 and val < val[1] | Bearish and accelerating |
| Dark Red | val < 0 and val >= val[1] | Bearish but decelerating |
// ─── 4-COLOR MOMENTUM HISTOGRAM ───────────────────────────────────────────────
color histColor =
val > 0 ?
(val > val[1] ? color.new(#00E676, 0) : color.new(#00E676, 60)) :
(val < val[1] ? color.new(#FF1744, 0) : color.new(#FF1744, 60))
plot(val, style=plot.style_histogram, color=histColor, linewidth=4)
The color.new(color, transparency) call is doing the heavy lifting here. Full opacity (0) for accelerating, 60% transparency for decelerating. This gives you an immediate visual read: bright bars are momentum building, faded bars are momentum exhausting.
The deceleration signal is particularly valuable. A faded red bar after several bright red bars often precedes a reversal — the bears are running out of fuel. Combined with a squeeze release, a deceleration signal in the direction opposite to the move can be an early warning to tighten stops.
Plotting the Squeeze Dots
The dots on the zero line are the squeeze state visualized. Three colors, three states:
// ─── ZERO LINE AND SQUEEZE DOTS ───────────────────────────────────────────────
plot(0, color=color.new(color.gray, 80), linewidth=1)
// Squeeze state dots on the zero line
plotshape(sqzOn, style=shape.circle, location=location.absolute,
color=color.new(#FF6D00, 0), size=size.tiny,
title="Squeeze ON")
plotshape(sqzOff, style=shape.circle, location=location.absolute,
color=color.new(#00E5FF, 0), size=size.tiny,
title="Squeeze OFF")
plotshape(noSqz, style=shape.circle, location=location.absolute,
color=color.new(color.gray, 40), size=size.tiny,
title="No Squeeze")
Wait — location.absolute places the shape at y = 0 only if no offset is given. For dots on the zero line specifically, the cleaner approach is to use plot() with conditional coloring:
// ─── CLEANER ZERO-LINE DOTS ────────────────────────────────────────────────────
sqzColor = sqzOn ? color.new(#FF6D00, 0) : sqzOff ? color.new(#00E5FF, 0) : color.new(color.gray, 40)
plot(0, color=sqzColor, style=plot.style_circles, linewidth=3, title="Squeeze State")
This is cleaner because it uses a single plot() call with conditional color assignment. Orange dots mean the squeeze is active. Cyan dots mean the squeeze just released. Gray dots mean neither state.
Adding Alerts for Squeeze Release
The squeeze release is the trigger event. You want an alert the moment sqzOff becomes true after being in a squeeze — not every bar that sqzOff is true.
// ─── ALERTS ───────────────────────────────────────────────────────────────────
// Fires on the first bar of squeeze release (transition from sqzOn to sqzOff)
sqzFireLong = sqzOff and val > 0 and not sqzOff[1]
sqzFireShort = sqzOff and val < 0 and not sqzOff[1]
alertcondition(sqzFireLong,
title = "Squeeze Release - Bullish",
message = "{{ticker}} {{interval}}: Squeeze released with bullish momentum. Val: {{plot_0}}")
alertcondition(sqzFireShort,
title = "Squeeze Release - Bearish",
message = "{{ticker}} {{interval}}: Squeeze released with bearish momentum. Val: {{plot_0}}")
The not sqzOff[1] condition is critical. Without it, the alert fires on every bar while sqzOff is true, not just the first. The [1] references the previous bar's value — a standard Pine Script pattern for detecting state transitions.
This is the same transition-detection pattern used in the Supertrend automation guide for direction-change alerts. Worth reading if you want to extend this into a full strategy with entries and exits.
The Complete Script
Here's the full, clean version assembled from all the pieces above:
//@version=6
indicator("Squeeze Momentum [HorizonAI]", shorttitle="SQZ", overlay=false)
// ─── INPUTS ───────────────────────────────────────────────────────────────────
bbLength = input.int(20, "BB Length", minval=1, group="Bollinger Bands")
bbMult = input.float(2.0, "BB Mult", minval=0.1, step=0.1, group="Bollinger Bands")
kcLength = input.int(20, "KC Length", minval=1, group="Keltner Channels")
kcMult = input.float(1.5, "KC Mult", minval=0.1, step=0.1, group="Keltner Channels")
useTrue = input.bool(true, "Use True Range (ATR)", group="Keltner Channels")
// ─── BOLLINGER BANDS ──────────────────────────────────────────────────────────
basis = ta.sma(close, bbLength)
dev = bbMult * ta.stdev(close, bbLength)
upperBB = basis + dev
lowerBB = basis - dev
// ─── KELTNER CHANNELS ─────────────────────────────────────────────────────────
kcBasis = ta.ema(close, kcLength)
span = useTrue ? ta.atr(kcLength) : ta.sma(high - low, kcLength)
upperKC = kcBasis + kcMult * span
lowerKC = kcBasis - kcMult * span
// ─── SQUEEZE DETECTION ────────────────────────────────────────────────────────
sqzOn = (lowerBB > lowerKC) and (upperBB < upperKC)
sqzOff = (lowerBB < lowerKC) and (upperBB > upperKC)
noSqz = not sqzOn and not sqzOff
// ─── MOMENTUM CALCULATION ─────────────────────────────────────────────────────
highestHigh = ta.highest(high, kcLength)
lowestLow = ta.lowest(low, kcLength)
midHL = math.avg(highestHigh, lowestLow)
midEMA = math.avg(ta.ema(close, kcLength), midHL)
delta = close - midEMA
val = ta.linreg(delta, kcLength, 0)
// ─── 4-COLOR HISTOGRAM ────────────────────────────────────────────────────────
color histColor =
val > 0 ?
(val > val[1] ? color.new(#00E676, 0) : color.new(#00E676, 60)) :
(val < val[1] ? color.new(#FF1744, 0) : color.new(#FF1744, 60))
plot(val, title="Momentum", style=plot.style_histogram, color=histColor, linewidth=4)
// ─── ZERO LINE WITH SQUEEZE STATE DOTS ────────────────────────────────────────
sqzColor = sqzOn ? color.new(#FF6D00, 0) :
sqzOff ? color.new(#00E5FF, 0) :
color.new(color.gray, 40)
plot(0, title="Squeeze State", color=sqzColor, style=plot.style_circles, linewidth=3)
// ─── ALERTS ───────────────────────────────────────────────────────────────────
sqzFireLong = sqzOff and val > 0 and not sqzOff[1]
sqzFireShort = sqzOff and val < 0 and not sqzOff[1]
alertcondition(sqzFireLong,
title = "Squeeze Release - Bullish",
message = "{{ticker}} {{interval}}: Squeeze released with bullish momentum")
alertcondition(sqzFireShort,
title = "Squeeze Release - Bearish",
message = "{{ticker}} {{interval}}: Squeeze released with bearish momentum")
Reading Squeeze + Momentum Together
The indicator only becomes a trading tool when you combine both components. Here's the practical read:
High-conviction long setup:
- Multiple consecutive orange dots (squeeze active for 5+ bars)
- Momentum histogram crosses above zero during the squeeze
- Cyan dot appears (squeeze releases)
- Histogram is bright green (accelerating bullish)
High-conviction short setup:
- Multiple consecutive orange dots
- Histogram crosses below zero during the squeeze
- Cyan dot appears
- Histogram is bright red (accelerating bearish)
Avoid:
- Squeezes that last only 1-2 bars. Real compression needs time.
- Histogram that's already extended (far from zero) when squeeze releases. The move may already be priced in.
- Squeeze releases during low-volume sessions (Asian session on forex, pre-market on equities).
For confirmation, many traders layer this with a simple moving average crossover on the main chart — using the 9/21 EMA cross direction as a filter to only take squeezes aligned with the trend.
Common Mistakes
❌ Using the same length for BB and KC, then wondering why signals are rare The default is 20/20 for both, which is correct. But some traders accidentally set KC length to 14 (matching RSI) and get mismatched volatility measurements. Keep them equal unless you have a specific reason not to.
❌ Alerting on sqzOff without the not sqzOff[1] transition filter
This fires an alert on every bar after the squeeze releases, not just the first. You'll get 10 alerts for a single squeeze event. Always use the transition pattern.
✅ Use the useTrue = false (range-based KC) on indices like SPX or NQ
ATR on indices can be inflated by gap opens. The simple high-low range for KC often gives cleaner squeeze detection on these instruments.
❌ Taking every squeeze release as an entry The squeeze tells you something is coming. The histogram direction tells you what. A squeeze release with the histogram near zero and no clear direction is a skip — wait for the histogram to commit.
✅ Backtest your specific asset before deploying On BTC/USD 4H, squeezes lasting 8+ bars have historically produced the strongest moves. On EUR/USD 1H, 5+ bars is a reasonable threshold. These numbers come from observation, not theory — run your own count.
Pro Tips
Tip 1: Multi-timeframe squeeze confirmation
A squeeze on the 1H chart is more powerful when the 4H chart is also in a squeeze. You can check this manually or extend the script with request.security() to pull the higher-timeframe squeeze state.
Tip 2: Count the squeeze bars
Add a counter variable that increments while sqzOn is true and resets to 0 when sqzOff. Plot this as a label or use it as an alert filter. Squeezes under 4 bars are noise; 8+ bars are setups worth trading.
// Squeeze bar counter
var int sqzCount = 0
sqzCount := sqzOn ? sqzCount + 1 : 0
Tip 3: Combine with volume
A squeeze release on above-average volume is significantly more reliable. Add volume > ta.sma(volume, 20) as a filter in your alert condition for a higher signal-to-noise ratio.
Build It Faster with HorizonAI
If you want to extend this indicator into a full automated strategy without writing all the strategy logic from scratch, HorizonAI can generate the Pine Script or MQL5 code from a plain-English prompt.
Some prompts that work well for this:
- "Create a Pine Script v6 strategy that enters long when the Squeeze Momentum indicator fires a bullish release (sqzOff with positive histogram), uses a 1.5x ATR stop-loss, and exits when the histogram turns from bright green to dark green."
- "Build a multi-timeframe squeeze scanner in Pine Script v6 that checks for active squeezes on both the current timeframe and the 4-hour timeframe simultaneously."
- "Generate an MQL5 EA that trades squeeze releases on EURUSD M15, with a risk of 1% per trade and a 2:1 reward-to-risk target."
You get working, commented code you can paste directly into TradingView or MetaTrader. Try it free →
FAQs
Is this the same as the LazyBear Squeeze Momentum Indicator?
Functionally, yes. This implementation follows the same core logic: BB inside KC for squeeze detection, linear regression of delta for momentum. The Pine Script v6 syntax is updated from LazyBear's original v2/v3 code, and the color scheme is slightly different, but the signals are equivalent.
Why does my histogram repaint on the current bar?
ta.linreg() recalculates on each tick of the current bar, so the histogram value will change until the bar closes. This is normal and expected. For alert conditions, always reference val[1] (the previous closed bar) if you need non-repainting alerts on real-time data.
What timeframe works best for the Squeeze Momentum indicator?
The 4H and daily timeframes produce the cleanest squeezes on most assets because there's enough price action to form genuine compression. On 15M and lower, squeezes are frequent but many are noise. If you trade intraday, use the 1H as a filter and only take 15M squeeze releases that align with the 1H trend direction.
Can I use this indicator in a strategy with strategy.entry?
Yes. Replace alertcondition() with strategy.entry("Long", strategy.long, when=sqzFireLong) and add a strategy.exit() call with a stop-loss based on ATR. The Supertrend automation guide shows this pattern applied to a different indicator — the structure is identical.
Why use linear regression instead of a simple moving average for momentum?
Linear regression fits a trend line to the data rather than averaging it. This makes it more responsive to the direction of change in the delta values, which is exactly what you want for momentum measurement. A simple average lags more and can stay flat while the underlying delta is clearly trending.
Final Thoughts
The Squeeze Momentum indicator is powerful because it separates two distinct market conditions — the compression phase and the expansion phase — and gives you a directional read on each. Most traders use it as a black box. Building it yourself means you understand every variable, every threshold, and every edge case.
The one concrete thing to do before trading this live: pull up your target instrument, mark every squeeze release over the last 200 bars, and count how many had 5+ squeeze bars before the release. That ratio is your baseline win rate before any other filter. If it's under 50%, tighten the KC multiplier. If it's over 65%, you have a genuine edge worth building on.
Related Articles
- Build a WaveTrend Oscillator in Pine Script v6 — Another from-scratch oscillator build with smoothing techniques and alert logic
- Automate a Supertrend Strategy with Alerts in Pine Script v6 — Extend any indicator into a full strategy with entries, exits, and alerts
- Simple Moving Average Crossover Strategy: Complete Guide — Pair with squeeze signals for trend-direction filtering
- ICT Premium & Discount Zones (OTE): Auto-Plot in Pine Script v6 — Combine squeeze entries with premium/discount zone confluence
- Break and Retest Strategy — The Complete Trading Guide — Use squeeze releases at key structural levels for high-probability entries
Questions about building the Squeeze Momentum indicator? Join our Discord to discuss with other traders!
