Oscillator Divergence Zones calculates nine selectable momentum oscillators, including RSI, CCI, MFI, TSI, STC, and Ehlers filters, to detect regular and hidden divergences.
//@version=6
// ============================================================================
// Oscillator Divergence Zones [WavesUnchained]
// Version: 1.5.4
// Author: WavesUnchained
// Build: 2026-10-12 00:00:00
// ============================================================================
// Detects regular and hidden divergences for RSI, CCI, MFI, Fisher Transform, TSI,
// Schaff Trend Cycle, DPO, Ehlers Roofing Filter or Ehlers Cyber Cycle.
//
// Features:
// ✓ Oscillator selector: RSI (default), CCI, MFI, Fisher, TSI, STC, DPO, Roofing, Cyber
// ✓ Regular divergence: price/oscillator structural opposition at pivots
// ✓ Hidden divergence: trend-continuation counterpart (toggle separately)
// ✓ Optional trend filter for hidden divergence (EMA alignment, off by default)
// ✓ Three-mode level filter: Off / Fixed OB-OS / Dynamic Zones (percentile-based)
// ✓ StdDev filter: optional statistical overextension gate for regular divergences
// ✓ Divergence Quality Score (0-100): pivot spacing, oscillator magnitude, OB/OS
// extremity, trend context — informational, does not filter signals
// ✓ ATR-based zone width: band height locked to creation-time volatility
// ✓ Configurable break trigger: full-candle (default) or close-based, checked
// against the actual zone boundary
// ✓ Zone retest counter: debounced touch count displayed at zone right edge
// ✓ Dashboard: oscillator, last signal + quality, active zones, filter mode, trend bias
// ✓ Dual-pane visualization: oscillator pane markers and chart overlay
// ✓ Alert conditions for all four divergence types
//
// ============================================================================
indicator("Oscillator Divergence Zones [WavesUnchained]", format=format.price, precision=2,
max_lines_count=500, max_labels_count=500, max_boxes_count=500)
// Synthetische Bar-Preise (Heikin Ashi, Renko …): Orders füllen zu Marktpreisen,
// der Backtest gilt dort nicht. Kurzer Hinweis unten rechts statt auf der Kerze.
var table chartTypeWarn = table.new(position.bottom_right, 1, 1)
if barstate.islast and not chart.is_standard
table.cell(chartTypeWarn, 0, 0, "ODZ · backtest invalid on this chart type",
bgcolor = color.new(color.red, 10), text_color = color.white, text_size = size.small)
// ─── Inputs ──────────────────────────────────────────────────────────────────
g_osc = "Oscillator"
oscType = input.string("RSI", "Type", options=["RSI", "CCI", "MFI", "Fisher", "TSI", "STC", "DPO", "Roofing", "Cyber"], group=g_osc,
tooltip="STC: Schaff Trend Cycle (bounded 0–100). DPO: Detrended Price Oscillator (centered). Roofing: Ehlers Roofing Filter (cycle, centered). Cyber: Ehlers Cyber Cycle (centered). DPO/Roofing/Cyber are unbounded — prefer the Dynamic Zones level filter for them.")
srcAuto = input.bool(true, "Auto Source per Oscillator", group=g_osc,
tooltip="On: each oscillator uses its canonical source automatically — close for RSI/STC/DPO/TSI, hl2 for Fisher and the Ehlers filters (Roofing/Cyber), hlc3 for CCI/MFI. Off: every oscillator uses the manual Source below.")
oscSrc = input.source(close, "Source (manual)", group=g_osc,
tooltip="Used only when Auto Source is off — then this source applies to all oscillator types.")
oscLen = input.int(14, "Length", minval=2, group=g_osc,
tooltip="Lookback for RSI/CCI/MFI/Fisher/DPO. For TSI the long (first) smoothing length; for STC the cycle length. Roofing/Cyber use their own params below.")
tsiShortLen = input.int(13, "TSI Short Length", minval=1, group=g_osc,
tooltip="Second (short) smoothing length — TSI only. Classic TSI is 25 (long) / 13 (short).")
stcFast = input.int(23, "STC Fast Length", minval=1, group=g_osc,
tooltip="STC only — fast MACD EMA. The double-stochastic cycle uses 'Length'.")
stcSlow = input.int(50, "STC Slow Length", minval=2, group=g_osc,
tooltip="STC only — slow MACD EMA.")
hpLen = input.int(48, "Roofing HighPass", minval=2, group=g_osc,
tooltip="Roofing only — high-pass period (removes the trend).")
ssLen = input.int(10, "Roofing SuperSmoother", minval=2, group=g_osc,
tooltip="Roofing only — SuperSmoother period (removes the noise).")
cyberAlpha = input.float(0.07, "Cyber Cycle Alpha", minval=0.01, maxval=0.5, step=0.01, group=g_osc,
tooltip="Cyber Cycle only — smaller = smoother/slower cycle.")
autoLevels = input.bool(true, "Auto OB/OS per Oscillator", group=g_osc,
tooltip="Automatically applies standard OB/OS levels per oscillator: RSI 70/30 · CCI 100/−100 · MFI 80/20 · Fisher ±1.5 · TSI ±25 · STC 75/25 · DPO/Roofing/Cyber ±1.5σ rolling band. Disable to set custom values below.")
obLevel = input.int(70, "Overbought", minval=-500, maxval=500, group=g_osc,
tooltip="Used only when Auto OB/OS is disabled. RSI: 70 · CCI: 100 · MFI: 80 · Fisher: 1.5 · TSI: 25")
osLevel = input.int(30, "Oversold", minval=-500, maxval=500, group=g_osc,
tooltip="Used only when Auto OB/OS is disabled. RSI: 30 · CCI: −100 · MFI: 20 · Fisher: −1.5 · TSI: −25")
g_div = "Divergence"
pivLeft = input.int(7, "Pivot Left Bars", minval=1, group=g_div)
pivRight = input.int(7, "Pivot Right Bars", minval=1, group=g_div)
minRange = input.int(10, "Min Bars Between Pivots", minval=1, group=g_div)
maxRange = input.int(100, "Max Bars Between Pivots", minval=5, group=g_div)
showHidden = input.bool(false, "Show Hidden Divergence", group=g_div)
filterMode = input.string("Fixed", "Level Filter", options=["Off", "Fixed", "Dynamic Zones"], group=g_div,
tooltip="Off: no level filter. Fixed: suppress signals outside a fixed % of the OB–OS range. Dynamic Zones: adaptive percentile-based thresholds computed from recent oscillator history.")
bullFilterPct = input.int(75, "Max for Bull (%)", minval=0, maxval=100, group=g_div,
tooltip="Fixed mode only. Upper limit for bullish signals as % of the OB–OS range. 75% ≈ RSI 60 · CCI 50 · MFI 65")
bearFilterPct = input.int(25, "Min for Bear (%)", minval=0, maxval=100, group=g_div,
tooltip="Fixed mode only. Lower limit for bearish signals as % of the OB–OS range. 25% ≈ RSI 40 · CCI −50 · MFI 35")
dynSampleLen = input.int(100, "DZ Sample Length", minval=10, group=g_div,
tooltip="Dynamic Zones mode only. Number of bars used to compute the percentile distribution of the oscillator.")
bullDynPcntile = input.float(10.0, "DZ Bull Threshold (%)", minval=1.0, maxval=50.0, step=1.0, group=g_div,
tooltip="Dynamic Zones mode only. Upper percentile limit for bullish signals (e.g. 10 means bottom 10% of recent history).")
bearDynPcntile = input.float(90.0, "DZ Bear Threshold (%)", minval=50.0, maxval=99.0, step=1.0, group=g_div,
tooltip="Dynamic Zones mode only. Lower percentile limit for bearish signals (e.g. 90 means top 10% of recent history).")
g_std = "StdDev Filter"
useStdFilter = input.bool(false, "Statistical Overextension Filter", group=g_std,
tooltip="Only fire regular divergences when the oscillator is outside its rolling mean ± N×StdDev band at the pivot (tested on the oscillator extreme within the pivot window — the same value the divergence compares). Reduces false signals in flat/choppy markets. Does not apply to hidden divergences.")
stdPeriod = input.int(100, "Lookback Period", minval=10, group=g_std)
stdMult = input.float(1.5, "StdDev Multiplier", minval=0.5, step=0.1, group=g_std)
g_trend = "Trend Context"
trendLen = input.int(200, "Trend EMA Length", minval=2, group=g_trend,
tooltip="EMA used for trend bias. Feeds the Hidden Divergence filter below, the Quality Score's trend component, and the Dashboard's Trend Bias field.")
useHiddenTrendFilter = input.bool(false, "Filter Hidden Divergence by Trend", group=g_trend,
tooltip="Off (default): hidden divergences fire regardless of trend. On: hidden bullish only fires above the Trend EMA, hidden bearish only below it — hidden divergences are continuation signals and should align with the prevailing trend.")
g_score = "Quality Score"
useQualityScore = input.bool(true, "Compute Quality Score", group=g_score,
tooltip="0-100 score per divergence combining pivot spacing, oscillator divergence magnitude, OB/OS extremity, and trend context. Informational only — never filters or blocks a signal. Shown in the signal tag, tooltip, and zone shading.")
g_zone = "Zones"
showZones = input.bool(true, "Show Zones on Chart", group=g_zone)
maxZones = input.int(10, "Max Active Zones", minval=1, maxval=50, group=g_zone)
zoneExtend = input.int(15, "Zone Lookahead (bars)", minval=1, group=g_zone)
atrLen = input.int(14, "ATR Length (zone width)", minval=1, group=g_zone)
zoneFactor = input.float(0.25, "Zone Width (ATR×)", minval=0.05, maxval=3.0, step=0.05, group=g_zone)
breakOnClose = input.bool(false, "Break on Close", group=g_zone)
showTouches = input.bool(true, "Show Retest Count", group=g_zone)
g_col = "Colors"
bullCol = input.color(#26a69a, "Bullish", group=g_col)
bearCol = input.color(#ef5350, "Bearish", group=g_col)
g_dash = "Dashboard"
showDashboard = input.bool(true, "Show Dashboard", group=g_dash)
dashPos = input.string("top_right", "Dashboard Position",
options=["top_left", "top_center", "top_right", "middle_left", "middle_center", "middle_right", "bottom_left", "bottom_center", "bottom_right"],
group=g_dash)
g_debug = "Debug"
showPivots = input.bool(false, "Show All Pivots", group=g_debug,
tooltip="Marks every detected price pivot as a small circle — helps diagnose why a divergence was not triggered. Filled = passed all filters. Semi-transparent = pivot detected but no divergence signal.")
// ─── Types ───────────────────────────────────────────────────────────────────
type Zone
line edge
box area
int touches
label info
float halfW
bool bull
int score
bool wasInside
// ─── State ───────────────────────────────────────────────────────────────────
var array<Zone> bullZones = array.new<Zone>()
var array<Zone> bearZones = array.new<Zone>()
var array<Zone> hidBullZones = array.new<Zone>()
var array<Zone> hidBearZones = array.new<Zone>()
// ─── Oscillator ──────────────────────────────────────────────────────────────
// Effective source per oscillator: canonical when Auto, else the manual Source.
srcClose = srcAuto ? close : oscSrc
srcHl2 = srcAuto ? hl2 : oscSrc
srcHlc3 = srcAuto ? hlc3 : oscSrc
atr = ta.atr(atrLen)
trendEMA = ta.ema(close, trendLen)
rsiSeries = ta.rsi(srcClose, oscLen)
cciSeries = ta.cci(srcHlc3, oscLen)
mfiSeries = ta.mfi(srcHlc3, oscLen)
// Fisher Transform (Ehlers) — normalize source into [-1,1] then transform; centered on 0
fHigh = ta.highest(srcHl2, oscLen)
fLow = ta.lowest(srcHl2, oscLen)
fRng = fHigh - fLow
fNorm = fRng > 0 ? (srcHl2 - fLow) / fRng - 0.5 : 0.0
var float fishVal = 0.0
fishVal := 0.66 * fNorm + 0.67 * nz(fishVal[1])
fishVal := math.max(-0.999, math.min(0.999, fishVal))
var float fisherSeries = 0.0
fisherSeries := 0.5 * math.log((1.0 + fishVal) / (1.0 - fishVal)) + 0.5 * nz(fisherSeries[1])
// True Strength Index — double-smoothed momentum, ×100, centered on 0
tsiPC = ta.change(srcClose)
tsiDS = ta.ema(ta.ema(tsiPC, oscLen), tsiShortLen)
tsiAbs = ta.ema(ta.ema(math.abs(tsiPC), oscLen), tsiShortLen)
tsiSeries = tsiAbs != 0.0 ? 100.0 * tsiDS / tsiAbs : 0.0
// Schaff Trend Cycle — MACD run through a double stochastic; bounded 0..100
stcMacd = ta.ema(srcClose, stcFast) - ta.ema(srcClose, stcSlow)
stcLL1 = ta.lowest(stcMacd, oscLen)
stcHH1 = ta.highest(stcMacd, oscLen) - stcLL1
var float stcF1 = 0.0
stcF1 := stcHH1 > 0.0 ? (stcMacd - stcLL1) / stcHH1 * 100.0 : nz(stcF1[1])
var float stcD1 = 0.0
stcD1 := na(stcD1[1]) ? stcF1 : stcD1[1] + 0.5 * (stcF1 - stcD1[1])
stcLL2 = ta.lowest(stcD1, oscLen)
stcHH2 = ta.highest(stcD1, oscLen) - stcLL2
var float stcF2 = 0.0
stcF2 := stcHH2 > 0.0 ? (stcD1 - stcLL2) / stcHH2 * 100.0 : nz(stcF2[1])
var float stcSeries = 0.0
stcSeries := na(stcSeries[1]) ? stcF2 : stcSeries[1] + 0.5 * (stcF2 - stcSeries[1])
// Detrended Price Oscillator — price minus a displaced SMA; centered on 0
dpoSeries = srcClose - ta.sma(srcClose, oscLen)[int(oscLen / 2) + 1]
// Ehlers Roofing Filter — high-pass (trend out) + SuperSmoother (noise out); centered on 0
_pi = 2.0 * math.asin(1.0)
rfA1 = (math.cos(0.707 * 2.0 * _pi / hpLen) + math.sin(0.707 * 2.0 * _pi / hpLen) - 1.0) / math.cos(0.707 * 2.0 * _pi / hpLen)
var float rfHp = 0.0
rfHp := math.pow(1.0 - rfA1 / 2.0, 2.0) * (srcHl2 - 2.0 * nz(srcHl2[1]) + nz(srcHl2[2])) + 2.0 * (1.0 - rfA1) * nz(rfHp[1]) - math.pow(1.0 - rfA1, 2.0) * nz(rfHp[2])
rfA2 = math.exp(-1.414 * _pi / ssLen)
rfB2 = 2.0 * rfA2 * math.cos(1.414 * _pi / ssLen)
rfC2 = rfB2
rfC3 = -rfA2 * rfA2
rfC1 = 1.0 - rfC2 - rfC3
var float roofingSeries = 0.0
roofingSeries := rfC1 * (rfHp + nz(rfHp[1])) / 2.0 + rfC2 * nz(roofingSeries[1]) + rfC3 * nz(roofingSeries[2])
// Ehlers Cyber Cycle — smoothed second difference of price; centered on 0
ccSmooth = (srcHl2 + 2.0 * nz(srcHl2[1]) + 2.0 * nz(srcHl2[2]) + nz(srcHl2[3])) / 6.0
var float cyberSeries = 0.0
cyberSeries := bar_index < 7 ? (srcHl2 - 2.0 * nz(srcHl2[1]) + nz(srcHl2[2])) / 4.0 : (1.0 - 0.5 * cyberAlpha) * (1.0 - 0.5 * cyberAlpha) * (ccSmooth - 2.0 * nz(ccSmooth[1]) + nz(ccSmooth[2])) + 2.0 * (1.0 - cyberAlpha) * nz(cyberSeries[1]) - (1.0 - cyberAlpha) * (1.0 - cyberAlpha) * nz(cyberSeries[2])
oscVal = switch oscType
"CCI" => cciSeries
"MFI" => mfiSeries
"Fisher" => fisherSeries
"TSI" => tsiSeries
"STC" => stcSeries
"DPO" => dpoSeries
"Roofing" => roofingSeries
"Cyber" => cyberSeries
=> rsiSeries
// DPO/Roofing/Cyber are unbounded and centered on 0 — no fixed OB/OS exists.
bool oscUnbounded = oscType == "DPO" or oscType == "Roofing" or oscType == "Cyber"
// CCI / Fisher / TSI and the unbounded types are centered on 0; RSI / MFI / STC on 50
midLevel = (oscType == "CCI" or oscType == "Fisher" or oscType == "TSI" or oscUnbounded) ? 0 : 50
oscAtPiv = oscVal[pivRight]
// Unbounded oscillators use a rolling ±1.5σ band as their auto OB/OS guide.
float lvlBand = ta.stdev(oscVal, stdPeriod) * 1.5
effectiveOB = autoLevels ? (oscUnbounded ? lvlBand : oscType == "CCI" ? 100.0 : oscType == "MFI" ? 80.0 : oscType == "Fisher" ? 1.5 : oscType == "TSI" ? 25.0 : oscType == "STC" ? 75.0 : 70.0) : obLevel
effectiveOS = autoLevels ? (oscUnbounded ? -lvlBand : oscType == "CCI" ? -100.0 : oscType == "MFI" ? 20.0 : oscType == "Fisher" ? -1.5 : oscType == "TSI" ? -25.0 : oscType == "STC" ? 25.0 : 30.0) : osLevel
// ─── Pivots on price ─────────────────────────────────────────────────────────
foundPL = not na(ta.pivotlow(low, pivLeft, pivRight))
foundPH = not na(ta.pivothigh(high, pivLeft, pivRight))
pivBarIdx = bar_index - pivRight
// Oscillator extreme inside the pivot window (pivLeft on the left, pivRight on the
// right of the price pivot). Used as the divergence value AND the draw point, so
// markers and lines sit on the oscillator's own trough/peak, not on a slope.
winLen = pivLeft + pivRight + 1
oscLowVal = ta.lowest(oscVal, winLen)
oscLowBar = bar_index + ta.lowestbars(oscVal, winLen)
oscHighVal = ta.highest(oscVal, winLen)
oscHighBar = bar_index + ta.highestbars(oscVal, winLen)
bullFilterLvl = effectiveOS + bullFilterPct / 100.0 * (effectiveOB - effectiveOS)
bearFilterLvl = effectiveOS + bearFilterPct / 100.0 * (effectiveOB - effectiveOS)
dZoneAbove = ta.percentile_nearest_rank(oscVal, dynSampleLen, bearDynPcntile)
dZoneBelow = ta.percentile_nearest_rank(oscVal, dynSampleLen, bullDynPcntile)
// Level/StdDev filters test the same oscillator value the divergence compares
// (the window extreme), not the oscillator on the price-pivot bar.
bullOk = switch filterMode
"Fixed" => oscLowVal < bullFilterLvl
"Dynamic Zones" => oscLowVal < dZoneBelow[pivRight]
=> true
bearOk = switch filterMode
"Fixed" => oscHighVal > bearFilterLvl
"Dynamic Zones" => oscHighVal > dZoneAbove[pivRight]
=> true
// StdDev overextension filter (regular divergences only)
_oscMean = ta.sma(oscVal, stdPeriod)
_oscStd = ta.stdev(oscVal, stdPeriod)
_oscUpper = _oscMean + stdMult * _oscStd
_oscLower = _oscMean - stdMult * _oscStd
stdBullOk = not useStdFilter or oscLowVal < _oscLower[pivRight]
stdBearOk = not useStdFilter or oscHighVal > _oscUpper[pivRight]
// Check divergence against one of the last 3 preceding pivots so that intermediate
// pivots do not prevent detection of the structurally relevant pair. Price pivots
// set the timing; the oscillator side uses the window EXTREME (trough for bull, peak
// for bear) for both the comparison and the draw point. Returns [hit, extBar, extVal]
// of the matched prior pivot's oscillator extreme.
// The current window extreme must lie after the prior price-pivot bar: with
// minRange < winLen the two windows overlap, and without this check the current
// extreme could be the prior pivot's own trough/peak (or one before it).
_bullData(bool hid) =>
p1 = ta.valuewhen(foundPL, low[pivRight], 1)
b1 = ta.valuewhen(foundPL, pivBarIdx, 1)
ov1 = ta.valuewhen(foundPL, oscLowVal, 1)
ob1 = ta.valuewhen(foundPL, oscLowBar, 1)
p2 = ta.valuewhen(foundPL, low[pivRight], 2)
b2 = ta.valuewhen(foundPL, pivBarIdx, 2)
ov2 = ta.valuewhen(foundPL, oscLowVal, 2)
ob2 = ta.valuewhen(foundPL, oscLowBar, 2)
p3 = ta.valuewhen(foundPL, low[pivRight], 3)
b3 = ta.valuewhen(foundPL, pivBarIdx, 3)
ov3 = ta.valuewhen(foundPL, oscLowVal, 3)
ob3 = ta.valuewhen(foundPL, oscLowBar, 3)
d1 = pivBarIdx - b1
d2 = pivBarIdx - b2
d3 = pivBarIdx - b3
ok1 = not na(p1) and d1 >= minRange and d1 <= maxRange and oscLowBar > b1 and (hid ? low[pivRight] > p1 and oscLowVal < ov1 : low[pivRight] < p1 and oscLowVal > ov1)
ok2 = not na(p2) and d2 >= minRange and d2 <= maxRange and oscLowBar > b2 and (hid ? low[pivRight] > p2 and oscLowVal < ov2 : low[pivRight] < p2 and oscLowVal > ov2)
ok3 = not na(p3) and d3 >= minRange and d3 <= maxRange and oscLowBar > b3 and (hid ? low[pivRight] > p3 and oscLowVal < ov3 : low[pivRight] < p3 and oscLowVal > ov3)
[ok1 or ok2 or ok3, ok1 ? ob1 : ok2 ? ob2 : ob3, ok1 ? ov1 : ok2 ? ov2 : ov3, ok1 ? d1 : ok2 ? d2 : d3]
_bearData(bool hid) =>
p1 = ta.valuewhen(foundPH, high[pivRight], 1)
b1 = ta.valuewhen(foundPH, pivBarIdx, 1)
ov1 = ta.valuewhen(foundPH, oscHighVal, 1)
ob1 = ta.valuewhen(foundPH, oscHighBar, 1)
p2 = ta.valuewhen(foundPH, high[pivRight], 2)
b2 = ta.valuewhen(foundPH, pivBarIdx, 2)
ov2 = ta.valuewhen(foundPH, oscHighVal, 2)
ob2 = ta.valuewhen(foundPH, oscHighBar, 2)
p3 = ta.valuewhen(foundPH, high[pivRight], 3)
b3 = ta.valuewhen(foundPH, pivBarIdx, 3)
ov3 = ta.valuewhen(foundPH, oscHighVal, 3)
ob3 = ta.valuewhen(foundPH, oscHighBar, 3)
d1 = pivBarIdx - b1
d2 = pivBarIdx - b2
d3 = pivBarIdx - b3
ok1 = not na(p1) and d1 >= minRange and d1 <= maxRange and oscHighBar > b1 and (hid ? high[pivRight] < p1 and oscHighVal > ov1 : high[pivRight] > p1 and oscHighVal < ov1)
ok2 = not na(p2) and d2 >= minRange and d2 <= maxRange and oscHighBar > b2 and (hid ? high[pivRight] < p2 and oscHighVal > ov2 : high[pivRight] > p2 and oscHighVal < ov2)
ok3 = not na(p3) and d3 >= minRange and d3 <= maxRange and oscHighBar > b3 and (hid ? high[pivRight] < p3 and oscHighVal > ov3 : high[pivRight] > p3 and oscHighVal < ov3)
[ok1 or ok2 or ok3, ok1 ? ob1 : ok2 ? ob2 : ob3, ok1 ? ov1 : ok2 ? ov2 : ov3, ok1 ? d1 : ok2 ? d2 : d3]
[bullHit, bullPrevBar, bullPrevOsc, bullPrevD] = _bullData(false)
[bearHit, bearPrevBar, bearPrevOsc, bearPrevD] = _bearData(false)
[bullHidHit, bullHidPrevBar, bullHidPrevOsc, bullHidPrevD] = _bullData(true)
[bearHidHit, bearHidPrevBar, bearHidPrevOsc, bearHidPrevD] = _bearData(true)
// Trend alignment for hidden (continuation) divergences — off by default (see g_trend)
hidTrendBullOk = not useHiddenTrendFilter or close > trendEMA
hidTrendBearOk = not useHiddenTrendFilter or close < trendEMA
// Regular: price/oscillator oppose → reversal signal
bullDiv = foundPL and bullOk and stdBullOk and bullHit
bearDiv = foundPH and bearOk and stdBearOk and bearHit
// Hidden: price/oscillator diverge in trend direction → continuation signal
// Always calculated so strategy/alerts can use them; display gated by showHidden
bullHidDiv = foundPL and bullOk and bullHidHit and hidTrendBullOk
bearHidDiv = foundPH and bearOk and bearHidHit and hidTrendBearOk
// ─── Divergence Quality Score (0-100, informational only) ─────────────────────
// Averages four components: pivot spacing (structural significance), oscillator
// divergence magnitude (relative to its own stdev), extremity past the active
// filter boundary, and trend context (overextension for regular, alignment for hidden).
_qualityScore(int d, float pivotOsc, float prevOsc, bool isBull, bool isHidden) =>
float spacingScore = math.min(100.0, math.max(0.0, (d - minRange) / math.max(1, maxRange - minRange) * 100.0))
float oscMag = math.abs(pivotOsc - prevOsc)
float magScore = _oscStd > 0 ? math.min(100.0, oscMag / _oscStd * 50.0) : 50.0
// Extremity relative to the active filter boundary (not the hard OB/OS line), so a
// signal that merely clears the filter already gets a non-zero score instead of
// needing to reach the OB/OS extreme to earn any credit.
float extScore = isBull ? math.min(100.0, math.max(0.0, (bullFilterLvl - pivotOsc) / math.max(1e-10, bullFilterLvl - effectiveOS) * 100.0)) : math.min(100.0, math.max(0.0, (pivotOsc - bearFilterLvl) / math.max(1e-10, effectiveOB - bearFilterLvl) * 100.0))
bool wantAbove = isHidden ? isBull : not isBull // hidden bull / regular bear want price above trend EMA
float distATR = atr > 0 ? (wantAbove ? (close - trendEMA) / atr : (trendEMA - close) / atr) : 0.0
float trendScore = math.min(100.0, math.max(0.0, distATR / 2.0 * 100.0))
int(math.round((spacingScore + magScore + extScore + trendScore) / 4.0))
bullScore = useQualityScore and bullDiv ? _qualityScore(bullPrevD, oscLowVal, bullPrevOsc, true, false) : int(na)
bearScore = useQualityScore and bearDiv ? _qualityScore(bearPrevD, oscHighVal, bearPrevOsc, false, false) : int(na)
bullHidScore = useQualityScore and bullHidDiv ? _qualityScore(bullHidPrevD, oscLowVal, bullHidPrevOsc, true, true) : int(na)
bearHidScore = useQualityScore and bearHidDiv ? _qualityScore(bearHidPrevD, oscHighVal, bearHidPrevOsc, false, true) : int(na)
// ─── Zone functions ───────────────────────────────────────────────────────────
_addZone(arr, col, priceLevel, isHidden, isBull, score) =>
halfW = atr * zoneFactor / 2
x1 = bar_index - pivRight
x2 = bar_index + zoneExtend
// Label sits outside the zone box — below for bull, above for bear
labelY = isBull ? priceLevel - halfW : priceLevel + halfW
bool hasScore = not na(score)
int borderW = hasScore and score >= 70 ? 2 : 1
color areaBg = isHidden ? color.new(col, 100) : (hasScore and score >= 70 ? color.new(col, 82) : hasScore and score < 40 ? color.new(col, 95) : color.new(col, 90))
edgeLine = line.new(x1, priceLevel, x2, priceLevel,
color=col, width=1,
style=isHidden ? line.style_dashed : line.style_solid,
force_overlay=true)
areaBox = box.new(x1, priceLevel + halfW, x2, priceLevel - halfW,
border_color=color.new(col, 60), border_width=borderW,
bgcolor=areaBg,
force_overlay=true)
// Retest counter: text-only (no bubble), stays invisible until price actually
// re-enters the zone, so untouched zones show nothing.
infoLbl = label.new(x2, labelY, "",
xloc=xloc.bar_index, style=label.style_none,
textcolor=col, size=size.small,
tooltip="Retest count: how many times price re-entered this zone without breaking it.",
force_overlay=true)
arr.push(Zone.new(edgeLine, areaBox, 0, infoLbl, halfW, isBull, score, false))
while arr.size() > maxZones
old = arr.shift()
old.edge.delete()
old.area.delete()
old.info.delete()
// Break/touch are checked against the actual zone boundary (lvl ± halfW), not the
// bare pivot price. Break-on-close uses the candle close; the default "full-candle"
// mode breaks on a wick piercing the boundary (immediate, not delayed).
_updateZones(arr, isBull) =>
if arr.size() > 0
toRemove = array.new<int>()
for i = 0 to arr.size() - 1
z = arr.get(i)
lvl = z.edge.get_y1()
lower = lvl - z.halfW
upper = lvl + z.halfW
broken = breakOnClose ? (isBull ? close < lower : close > upper) : (isBull ? low < lower : high > upper)
inside = not broken and high >= lower and low <= upper
if broken
z.edge.set_x2(bar_index)
z.edge.set_style(line.style_dotted)
z.area.set_right(bar_index)
z.area.set_bgcolor(color.new(color.gray, 94))
z.area.set_border_color(color.new(color.gray, 88))
z.info.delete()
toRemove.unshift(i)
else
z.edge.set_x2(bar_index + zoneExtend)
z.area.set_right(bar_index + zoneExtend)
// Only count a new touch on entry into the zone, not every bar spent inside it
if inside and not z.wasInside
z.touches += 1
z.wasInside := inside
if showTouches and z.touches > 0
z.info.set_x(bar_index + zoneExtend)
z.info.set_text("×" + str.tostring(z.touches))
if toRemove.size() > 0
for j = 0 to toRemove.size() - 1
arr.remove(toRemove.get(j))
// ─── Execute ─────────────────────────────────────────────────────────────────
_updateZones(bullZones, true)
_updateZones(bearZones, false)
_updateZones(hidBullZones, true)
_updateZones(hidBearZones, false)
if showZones
if bullDiv
_addZone(bullZones, bullCol, low[pivRight], false, true, bullScore)
if bearDiv
_addZone(bearZones, bearCol, high[pivRight], false, false, bearScore)
if showHidden and bullHidDiv
_addZone(hidBullZones, bullCol, low[pivRight], true, true, bullHidScore)
if showHidden and bearHidDiv
_addZone(hidBearZones, bearCol, high[pivRight], true, false, bearHidScore)
// Divergence lines: connect the matched prior pivot's oscillator extreme to the
// current one, so both ends sit on the oscillator's own trough/peak.
currBar = bar_index - pivRight
_divDraw(int x1, float y1, int x2, float y2, color col, bool hid) =>
line.new(x1, y1, x2, y2,
color=color.new(col, hid ? 50 : 25),
style=hid ? line.style_dashed : line.style_dotted,
width=hid ? 1 : 2)
// Pane marker sitting on the oscillator extreme, carrying the hover explanation.
_mark(int x, float y, color col, bool up, string txt) =>
label.new(x, y, "",
style=up ? label.style_label_up : label.style_label_down,
color=col, textcolor=color.white, size=size.tiny, tooltip=txt)
// Chart-overlay signal: a coloured bubble at the price pivot with a hover explanation.
_sig(float y, color col, bool up, string tag, string txt) =>
label.new(currBar, y, tag,
style=up ? label.style_label_up : label.style_label_down,
color=col, textcolor=color.white, size=size.small,
tooltip=txt, force_overlay=true)
// Compact signal tag, optionally suffixed with the quality score
_tag(string base, int score) => useQualityScore and not na(score) ? base + " " + str.tostring(score) : base
_qLine(int score) => useQualityScore and not na(score) ? "\nQuality: " + str.tostring(score) + "/100" : ""
if bullDiv
tt = oscType + " · Bullish divergence (reversal)\nPrice: lower low " + oscType + ": higher low\n" + oscType + " " + str.tostring(bullPrevOsc, "#.0") + " → " + str.tostring(oscLowVal, "#.0") + _qLine(bullScore)
_divDraw(bullPrevBar, bullPrevOsc, oscLowBar, oscLowVal, bullCol, false)
_mark(oscLowBar, oscLowVal, bullCol, true, tt)
_sig(low[pivRight], bullCol, true, _tag("RD+", bullScore), tt)
if bearDiv
tt = oscType + " · Bearish divergence (reversal)\nPrice: higher high " + oscType + ": lower high\n" + oscType + " " + str.tostring(bearPrevOsc, "#.0") + " → " + str.tostring(oscHighVal, "#.0") + _qLine(bearScore)
_divDraw(bearPrevBar, bearPrevOsc, oscHighBar, oscHighVal, bearCol, false)
_mark(oscHighBar, oscHighVal, bearCol, false, tt)
_sig(high[pivRight], bearCol, false, _tag("RD-", bearScore), tt)
if showHidden and bullHidDiv
tt = oscType + " · Hidden bullish divergence (continuation)\nPrice: higher low " + oscType + ": lower low\n" + oscType + " " + str.tostring(bullHidPrevOsc, "#.0") + " → " + str.tostring(oscLowVal, "#.0") + _qLine(bullHidScore)
_divDraw(bullHidPrevBar, bullHidPrevOsc, oscLowBar, oscLowVal, bullCol, true)
_mark(oscLowBar, oscLowVal, color.new(bullCol, 30), true, tt)
_sig(low[pivRight], color.new(bullCol, 30), true, _tag("HD+", bullHidScore), tt)
if showHidden and bearHidDiv
tt = oscType + " · Hidden bearish divergence (continuation)\nPrice: lower high " + oscType + ": higher high\n" + oscType + " " + str.tostring(bearHidPrevOsc, "#.0") + " → " + str.tostring(oscHighVal, "#.0") + _qLine(bearHidScore)
_divDraw(bearHidPrevBar, bearHidPrevOsc, oscHighBar, oscHighVal, bearCol, true)
_mark(oscHighBar, oscHighVal, color.new(bearCol, 30), false, tt)
_sig(high[pivRight], color.new(bearCol, 30), false, _tag("HD-", bearHidScore), tt)
// ─── Oscillator pane plots ───────────────────────────────────────────────────
p_ob = plot(effectiveOB, "OB", color=color.new(bearCol, 50))
p_os = plot(effectiveOS, "OS", color=color.new(bullCol, 50))
p_mid = plot(midLevel, "Mid", color=color.new(color.gray, 70))
plot(filterMode == "Dynamic Zones" ? dZoneAbove : na, "DZ Upper",
color=color.new(bearCol, 20), linewidth=1, style=plot.style_circles)
plot(filterMode == "Dynamic Zones" ? dZoneBelow : na, "DZ Lower",
color=color.new(bullCol, 20), linewidth=1, style=plot.style_circles)
p_osc = plot(oscVal, "Oscillator",
color=color.from_gradient(oscVal, effectiveOS, effectiveOB, bullCol, bearCol),
linewidth=2)
fill(p_osc, p_mid, effectiveOB, effectiveOS, color.new(bearCol, 85), na)
fill(p_osc, p_mid, effectiveOB, effectiveOS, na, color.new(bullCol, 85))
// Pane divergence markers and chart-overlay signal bubbles are drawn as labels in the
// divergence section above — pane markers now sit on the oscillator's trough/peak.
// ─── Debug: all detected pivots ──────────────────────────────────────────────
// Semi-transparent circles on every pivot — bull pivots teal, bear pivots red.
// A circle without a divergence label means a filter blocked the signal.
plotshape(showPivots and foundPL ? oscAtPiv : na, offset=-pivRight,
title="Debug: Pivot Low (pane)", style=shape.circle, location=location.absolute,
color=color.new(bullCol, 60), size=size.tiny)
plotshape(showPivots and foundPH ? oscAtPiv : na, offset=-pivRight,
title="Debug: Pivot High (pane)", style=shape.circle, location=location.absolute,
color=color.new(bearCol, 60), size=size.tiny)
plotshape(showPivots and foundPL ? low[pivRight] : na, offset=-pivRight,
title="Debug: Pivot Low (chart)", style=shape.circle, location=location.absolute,
color=color.new(bullCol, 60), size=size.tiny, force_overlay=true)
plotshape(showPivots and foundPH ? high[pivRight] : na, offset=-pivRight,
title="Debug: Pivot High (chart)", style=shape.circle, location=location.absolute,
color=color.new(bearCol, 60), size=size.tiny, force_overlay=true)
// ─── Dashboard ───────────────────────────────────────────────────────────────
var string lastSignalType = "—"
var int lastSignalScore = int(na)
if bullDiv
lastSignalType := "Bull Div"
lastSignalScore := bullScore
if bearDiv
lastSignalType := "Bear Div"
lastSignalScore := bearScore
if showHidden and bullHidDiv
lastSignalType := "Hid Bull"
lastSignalScore := bullHidScore
if showHidden and bearHidDiv
lastSignalType := "Hid Bear"
lastSignalScore := bearHidScore
if showDashboard and barstate.islast
color tc = color.new(color.gray, 20)
color hd = color.new(color.gray, 90)
var table dash = table.new(dashPos, 2, 6,
bgcolor=color.new(color.white, 5),
border_color=color.new(color.gray, 60),
border_width=1,
frame_color=color.new(color.gray, 40),
frame_width=1)
string signalStr = lastSignalType + (na(lastSignalScore) ? "" : " Q" + str.tostring(lastSignalScore))
color signalCol = lastSignalType == "Bull Div" or lastSignalType == "Hid Bull" ? bullCol : lastSignalType == "Bear Div" or lastSignalType == "Hid Bear" ? bearCol : tc
int activeZones = bullZones.size() + bearZones.size() + hidBullZones.size() + hidBearZones.size()
string biasStr = close > trendEMA ? "Up" : close < trendEMA ? "Down" : "Flat"
color biasCol = biasStr == "Up" ? bullCol : biasStr == "Down" ? bearCol : tc
table.cell(dash, 0, 0, "Osc. Div. Zones", text_color=tc, bgcolor=hd, text_size=size.small)
table.cell(dash, 1, 0, "", text_color=tc, bgcolor=hd, text_size=size.small)
table.cell(dash, 0, 1, "Oscillator", text_color=tc, text_size=size.small)
table.cell(dash, 1, 1, oscType, text_color=tc, text_size=size.small)
table.cell(dash, 0, 2, "Last Signal", text_color=tc, text_size=size.small)
table.cell(dash, 1, 2, signalStr, text_color=signalCol, text_size=size.small)
table.cell(dash, 0, 3, "Active Zones", text_color=tc, text_size=size.small)
table.cell(dash, 1, 3, str.tostring(activeZones), text_color=tc, text_size=size.small)
table.cell(dash, 0, 4, "Filter Mode", text_color=tc, text_size=size.small)
table.cell(dash, 1, 4, filterMode, text_color=tc, text_size=size.small)
table.cell(dash, 0, 5, "Trend Bias", text_color=tc, text_size=size.small)
table.cell(dash, 1, 5, biasStr, text_color=biasCol, text_size=size.small)
// ─── Alerts ──────────────────────────────────────────────────────────────────
// alertcondition: named alerts, require one setup per condition in the Alert dialog
bool alertsOnBarClose = input.bool(true, "Alerts only on bar close (confirmed)", group="Alerts")
bool alertOk = not alertsOnBarClose or barstate.isconfirmed
alertcondition(alertOk and (bullDiv), "Bullish Divergence", "ODZ · LONG · {{ticker}} {{interval}}")
alertcondition(alertOk and (bearDiv), "Bearish Divergence", "ODZ · SHORT · {{ticker}} {{interval}}")
alertcondition(alertOk and (bullHidDiv), "Bullish Hidden Divergence", "ODZ · HIDDEN LONG · {{ticker}} {{interval}}")
alertcondition(alertOk and (bearHidDiv), "Bearish Hidden Divergence", "ODZ · HIDDEN SHORT · {{ticker}} {{interval}}")
// alert(): all signals through a single "any alert() call" setup on the indicator
_ticker = syminfo.tickerid + " (" + timeframe.period + ")"
if bullDiv
alert("Bullish Divergence — " + _ticker + " | " + oscType, alert.freq_once_per_bar_close)
if bearDiv
alert("Bearish Divergence — " + _ticker + " | " + oscType, alert.freq_once_per_bar_close)
if bullHidDiv
alert("Bullish Hidden Divergence — " + _ticker + " | " + oscType, alert.freq_once_per_bar_close)
if bearHidDiv
alert("Bearish Hidden Divergence — " + _ticker + " | " + oscType, alert.freq_once_per_bar_close)
// @strategy-config
// long: bullDiv
// short: bearDiv
// sl_type: pivot_atr
// pivot_low: low[pivRight]
// pivot_high: high[pivRight]
// @end-strategy-config
Overview
Oscillator Divergence Zones [WavesUnchained] evaluates price and momentum pivots to spot structural divergence, applying optional fixed overbought and oversold thresholds, dynamic percentile zones, standard deviation overextension, or trend filters. The script draws the oscillator line, divergence markers, dynamic level bands, and an informational dashboard in a separate pane. Pivot detection requires confirmation bars, so signals plot retroactively upon pivot completion.
Detects regular and hidden divergences across nine oscillator types
Supports fixed thresholds, dynamic percentile zones, and StdDev filtering
Calculates a divergence quality score based on magnitude and context
Displays an informational dashboard with trend bias and signal details
The inputs you can change from the gear icon on the chart. Defaults are the author's.
Setting
Type
Default
Variable
Type
string
RSI
oscType
Auto Source per Oscillator
bool
true
srcAuto
Source (manual)
source
close
oscSrc
Length
int
14
oscLen
TSI Short Length
int
13
tsiShortLen
STC Fast Length
int
23
stcFast
STC Slow Length
int
50
stcSlow
Roofing HighPass
int
48
hpLen
Roofing SuperSmoother
int
10
ssLen
Cyber Cycle Alpha
float
0.07
cyberAlpha
Auto OB/OS per Oscillator
bool
true
autoLevels
Overbought
int
70
obLevel
Show all 40 settings
Setting
Type
Default
Variable
Oversold
int
30
osLevel
Pivot Left Bars
int
7
pivLeft
Pivot Right Bars
int
7
pivRight
Min Bars Between Pivots
int
10
minRange
Max Bars Between Pivots
int
100
maxRange
Show Hidden Divergence
bool
false
showHidden
Level Filter
string
Fixed
filterMode
Max for Bull (%)
int
75
bullFilterPct
Min for Bear (%)
int
25
bearFilterPct
DZ Sample Length
int
100
dynSampleLen
DZ Bull Threshold (%)
float
10.0
bullDynPcntile
DZ Bear Threshold (%)
float
90.0
bearDynPcntile
Statistical Overextension Filter
bool
false
useStdFilter
Lookback Period
int
100
stdPeriod
StdDev Multiplier
float
1.5
stdMult
Trend EMA Length
int
200
trendLen
Filter Hidden Divergence by Trend
bool
false
useHiddenTrendFilter
Compute Quality Score
bool
true
useQualityScore
Show Zones on Chart
bool
true
showZones
Max Active Zones
int
10
maxZones
Zone Lookahead (bars)
int
15
zoneExtend
ATR Length (zone width)
int
14
atrLen
Zone Width (ATR×)
float
0.25
zoneFactor
Break on Close
bool
false
breakOnClose
Show Retest Count
bool
true
showTouches
Bullish
color
#26a69a
bullCol
Bearish
color
#ef5350
bearCol
Show Dashboard
bool
true
showDashboard
Third-party code. ForexR did not write, test or endorse this script, and the summary above was generated automatically from its code and README. Indicators describe the past, and a strategy's backtest can look better than live trading, especially when a script repaints. Check it in the Strategy Tester and on a demo account before you rely on it.
This indicator tracks intermediate market cycles inspired by Linda Raschke's approach using daily NYSE TICK statistics. It fetches daily high, low, and close values for the TICK index via security requests and computes moving averages of the highs and lows. It
This indicator implements a full Chanlun pipeline on the price chart. It merges contained bars, marks top and bottom fractals, and connects alternating fractals into strokes. Overlapping strokes form pivot boxes, and MACD area ratios flag trend or consolidatio
This indicator scans a pasted list of symbols for RSI directional momentum and shows matching long and short signals in a table in a separate pane. The user chooses continuous break, continuous retracement, flip break, flip retracement, divergence, or hidden d
This indicator plots the RSI oscillator on the main chart and displays its value distribution in a table positioned at the top right. The script calculates the relative strength index using the input source and length, then plots horizontal lines at 0, 50, and
Market Speedometer plots a 0-100 market speed reading in a separate pane. It measures movement by bar-to-bar change, true range, or a short average of recent bars, then ranks that movement against ATR and a percentile lookback. An EMA smooths the reading, and
KamaFama is an indicator that translates a Freqtrade trading strategy into Pine Script for visualization. It combines Kaufman Adaptive Moving Average (KAMA), MAMA/FAMA calculations derived via Hilbert Transform, Williams %R, and correlation trend filters to ev