Indicator · Pine Script v6 · Own pane

Oscillator Divergence Zones [WavesUnchained]

by casoon

Oscillator Divergence Zones calculates nine selectable momentum oscillators, including RSI, CCI, MFI, TSI, STC, and Ehlers filters, to detect regular and hidden divergences.

View on GitHub

GitHub stars9on the repository
Pine versionv6current
Lines of code608
Settings40inputs
LicenceMIT
Last updateyesterday27 Oct 2026

Add It to Your TradingView Chart

  1. 1Copy the scriptPress Copy script above, or the copy button on the code below.
  2. 2Open the Pine EditorOpen a TradingView chart and choose Pine Editor at the bottom of the screen.
  3. 3Paste over the templateCreate a new indicator, select all of the template text and paste. Press Save and give it a name.
  4. 4Add it to the chartPress Add to chart, then adjust the settings with the gear icon next to its name.

Source Code

608 lines of Pine Script v6 from indicators/momentum/oscillator_divergence_zones/oscillator_divergence_zones.pine, published under MIT. Keep the author's credit and licence lines when you reuse it.

oscillator_divergence_zones.pine
//@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.

Details

Published by

casoon on GitHub

Type

Indicator, Pine Script v6

Draws

Separate pane below the chart

Category

Oscillators & momentum, Support & resistance, Screeners & dashboards

Markets

Any symbol

Timeframes

Any timeframe

Risk Notes

No repainting, lookahead or stacked entries were found in its code or README. That says nothing about how well it works: test it first.

Topics

pine-scriptpinescripttechnical-analysistrading-indicatorstrading-strategiestradingview

Settings

The inputs you can change from the gear icon on the chart. Defaults are the author's.

SettingTypeDefaultVariable
TypestringRSIoscType
Auto Source per OscillatorbooltruesrcAuto
Source (manual)sourcecloseoscSrc
Lengthint14oscLen
TSI Short Lengthint13tsiShortLen
STC Fast Lengthint23stcFast
STC Slow Lengthint50stcSlow
Roofing HighPassint48hpLen
Roofing SuperSmootherint10ssLen
Cyber Cycle Alphafloat0.07cyberAlpha
Auto OB/OS per OscillatorbooltrueautoLevels
Overboughtint70obLevel
Show all 40 settings
SettingTypeDefaultVariable
Oversoldint30osLevel
Pivot Left Barsint7pivLeft
Pivot Right Barsint7pivRight
Min Bars Between Pivotsint10minRange
Max Bars Between Pivotsint100maxRange
Show Hidden DivergenceboolfalseshowHidden
Level FilterstringFixedfilterMode
Max for Bull (%)int75bullFilterPct
Min for Bear (%)int25bearFilterPct
DZ Sample Lengthint100dynSampleLen
DZ Bull Threshold (%)float10.0bullDynPcntile
DZ Bear Threshold (%)float90.0bearDynPcntile
Statistical Overextension FilterboolfalseuseStdFilter
Lookback Periodint100stdPeriod
StdDev Multiplierfloat1.5stdMult
Trend EMA Lengthint200trendLen
Filter Hidden Divergence by TrendboolfalseuseHiddenTrendFilter
Compute Quality ScorebooltrueuseQualityScore
Show Zones on ChartbooltrueshowZones
Max Active Zonesint10maxZones
Zone Lookahead (bars)int15zoneExtend
ATR Length (zone width)int14atrLen
Zone Width (ATR×)float0.25zoneFactor
Break on CloseboolfalsebreakOnClose
Show Retest CountbooltrueshowTouches
Bullishcolor#26a69abullCol
Bearishcolor#ef5350bearCol
Show DashboardbooltrueshowDashboard
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.

Similar Pine Scripts

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

Indicator v6 Support & resistanceDivergence
Updated 2 months ago · GPL-3.0 GitHub repository of 缠论 - 分型与笔 [Chanlun Core]

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

Indicator v6 Oscillators & momentumDivergence
Updated 1 year ago · MPL-2.0 GitHub repository of Momentum Setup - RSI Directional Momentum Scanner
261 stars

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

Indicator v4 Oscillators & momentumScreeners & dashboards
Updated 4 years ago · None stated GitHub repository of Tables-003 Oscillator Distribution

Market Speedometer

by TradersPost
218 stars

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

Indicator v6 Volatility & bandsOscillators & momentum
Updated 3 months ago · None stated GitHub repository of Market Speedometer

KamaFama

by Mastaaa1987
141 stars

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

Indicator v6 Moving averagesOscillators & momentum
Updated 6 weeks ago · MIT GitHub repository of KamaFama

More indicators

Trade Your Charts With a Regulated Broker

Compare brokers that connect to TradingView, with spreads, minimum deposits and leverage side by side.

Compare brokers