Trend Persistence Score calculates a composite trend-efficiency oscillator plotted in a separate pane, grading trend strength and durability on a 0 to 100 scale.
//@version=6
// ============================================================================
// Trend Persistence Score [WavesUnchained]
// Version: 1.1.2
// Author: WavesUnchained
// Build: 2026-10-02 00:00:00
// ============================================================================
// Single-role trend-strength oscillator: how persistent, clean and durable is
// the current trend? Fuses four trend-efficiency sensors (Regression R²,
// Kaufman Efficiency Ratio, ADX strength+slope, Fractal Dimension) onto one
// graded 0-100 persistence axis. Direction-agnostic by design — it grades the
// magnitude of trendiness; direction is shown as visual context only.
//
// Features:
// ✓ 4-sensor trend-persistence score (R² · ER · ADX · FDI) on a 0-100 axis
// ✓ Role-level weights — see which sensor drives the verdict (sub-score lines)
// ✓ State read: Strong / Healthy / Transition / Weak / Dead with hysteresis
// ✓ Calibration anchors exposed as inputs (no hardcoded thresholds in logic)
// ✓ Directional context (▲/▼/•) from regression sign — visual only, not scored
// ✓ Compact info label + debug log on every state change (why it changed)
// ✓ Weakening / Transition / Recovery / Transition-Risk-High alerts
//
// ============================================================================
indicator("Trend Persistence Score [WavesUnchained]", "TPS", format=format.price, precision=2, overlay=false, max_labels_count=500)
// ============================================================================
// Inputs
// ============================================================================
src = input.source(close, "Source", group="Core")
len = input.int(34, "Main Length", minval=10, group="Core")
adxLen = input.int(14, "ADX Length", minval=5, group="Core")
smoothLen = input.int(5, "Score Smoothing", minval=1, group="Core")
wR2 = input.float(40.0, "Weight: Regression R²", minval=0, group="Sensor Weights")
wER = input.float(25.0, "Weight: Efficiency Ratio", minval=0, group="Sensor Weights")
wADX = input.float(20.0, "Weight: ADX / Slope", minval=0, group="Sensor Weights")
wFDI = input.float(15.0, "Weight: Fractal Dimension", minval=0, group="Sensor Weights")
// Role-level calibration: each anchor pair maps a sensor's full-range vs.
// full-trend reading onto 0..1. These are the "trend is strong / dead"
// thresholds — kept as inputs, never hardcoded into the signal logic.
adxLo = input.float(12.0, "ADX: range anchor", group="Calibration", tooltip="ADX at/below this reads as no trend strength")
adxHi = input.float(35.0, "ADX: trend anchor", group="Calibration", tooltip="ADX at/above this reads as full trend strength")
fdiTrnd = input.float(1.20, "FDI: trend anchor", group="Calibration", tooltip="Fractal Dimension at/below this reads as a clean trend")
fdiRng = input.float(1.65, "FDI: range anchor", group="Calibration", tooltip="Fractal Dimension at/above this reads as choppy/range")
// ADX measures movement/strength, not directional cleanliness. When BOTH the
// structure sensors (R², ER) are weak, ADX must not be allowed to "rescue" the
// score — its sub-score is damped so the composite reflects the dead structure.
deadThresh = input.float(20.0, "ADX damp: structure-dead threshold", group="Calibration", tooltip="If both R² Score and Efficiency Score are below this, ADX is damped")
adxDamp = input.float(0.35, "ADX damp: factor", minval=0.0, maxval=1.0, group="Calibration", tooltip="ADX sub-score multiplier while structure is dead")
lvlStrong = input.int(75, "State: Strong ≥", group="State Levels")
lvlHealthy= input.int(60, "State: Healthy ≥", group="State Levels")
lvlTrans = input.int(45, "State: Transition ≥", group="State Levels")
lvlWeak = input.int(30, "State: Weak ≥", group="State Levels")
colorMode = input.string("Direction × Strength", "Oscillator Color", options=["Direction × Strength", "Persistence State"], group="Display", tooltip="Direction × Strength: line/fill colored by trend direction (corr sign), faded by how weak the trend is. Persistence State: the Strong/Healthy/Transition/Weak/Dead band colors.")
showFill = input.bool(true, "Show Heat Column (fill to 0)", group="Display")
showZones = input.bool(true, "Show Background Zones", group="Display")
showTrail = input.bool(true, "Show Persistence Trail", group="Display")
showMarkers = input.bool(true, "Show Key Event Labels", group="Display", tooltip="Shows only the alert-level TPS events: Weakening, Transition and Recovery. These are context events, not long/short trade signals.")
markerCooldown = input.int(10, "Event Label Cooldown", minval=0, group="Display", tooltip="Minimum bars between visible event labels, reducing repeated labels in choppy threshold zones")
transitionLead = input.float(3.0, "Transition Lead Buffer", minval=0.0, group="Display", tooltip="Shows T slightly before the hard Transition cross when TPS is falling near the Transition level and transition risk is rising")
showLines = input.bool(false, "Show Sensor Lines", group="Display")
showLabel = input.bool(true, "Show Info Label", group="Display", tooltip="Single compact label (mobile-friendly) with score, state, bias and sensor values — replaces the old table")
labelSize = input.string("Normal", "Label Size", options=["Small", "Normal", "Large"], group="Display")
// ============================================================================
// Helpers
// ============================================================================
f_clamp(x, lo, hi) =>
math.max(lo, math.min(hi, x))
f_norm(x, lo, hi) =>
hi == lo ? 0.0 : f_clamp((x - lo) / (hi - lo), 0.0, 1.0)
f_lblSize(s) =>
s == "Small" ? size.small : s == "Large" ? size.large : size.normal
// ============================================================================
// 1. Linear Regression R² (magnitude of fit — direction stripped by squaring)
// ============================================================================
corr = ta.correlation(src, bar_index, len)
r2Raw = math.pow(nz(corr), 2.0)
r2Score = r2Raw * 100.0
// ============================================================================
// 2. Kaufman Efficiency Ratio
// ============================================================================
change = math.abs(src - src[len])
volatility = math.sum(math.abs(src - src[1]), len)
erRaw = volatility == 0.0 ? 0.0 : change / volatility
erScore = erRaw * 100.0
// ============================================================================
// 3. ADX Strength + ADX Slope
// ============================================================================
[plusDI, minusDI, adx] = ta.dmi(adxLen, adxLen)
adxStrength = f_norm(adx, adxLo, adxHi)
adxSlope = ta.ema(adx - adx[1], 3)
adxSlopeNorm = f_norm(adxSlope, -1.0, 1.5)
adxScore = ((adxStrength * 0.7) + (adxSlopeNorm * 0.3)) * 100.0
// Damp ADX when price structure is dead (R² and ER both weak) — ADX activity
// without directional cleanliness should not prop up the persistence score.
structureDead = r2Score < deadThresh and erScore < deadThresh
adxScoreFinal = structureDead ? adxScore * adxDamp : adxScore
// ============================================================================
// 4. Fractal Dimension Index
// Lower FDI = cleaner trend (~1.0 trend, ~1.5 random, ~2.0 noisy/choppy)
// ============================================================================
highestHigh = ta.highest(high, len)
lowestLow = ta.lowest(low, len)
rangeHL = highestHigh - lowestLow
pathLength = math.sum(math.abs(src - src[1]), len)
float fdiRaw = 1.5
if rangeHL > 0.0 and pathLength > 0.0
fdiRaw := math.log(pathLength / rangeHL) / math.log(len) + 1.0
fdiTrendScore = (1.0 - f_norm(fdiRaw, fdiTrnd, fdiRng)) * 100.0
// ============================================================================
// Composite Score
// ============================================================================
weightSum = wR2 + wER + wADX + wFDI
float tpsRaw = na
if weightSum > 0.0
tpsRaw := (r2Score * wR2 + erScore * wER + adxScoreFinal * wADX + fdiTrendScore * wFDI) / weightSum
tps = ta.ema(tpsRaw, smoothLen)
// Transition Risk — derived inverse of TPS (NOT a structure-break detector;
// that is a separate reversal module). Blends inverse-TPS with inverse-ER so a
// low-efficiency tape reads as elevated risk even when ADX lingers.
transitionRisk = (100.0 - tps) * 0.7 + (100.0 - erScore) * 0.3
// ============================================================================
// State (with one-band hysteresis so it does not flip on score noise)
// ============================================================================
f_stateIdx(v) =>
int idx = 0
if v >= lvlStrong
idx := 4
else if v >= lvlHealthy
idx := 3
else if v >= lvlTrans
idx := 2
else if v >= lvlWeak
idx := 1
idx
rawIdx = f_stateIdx(tps)
var int stateIdx = rawIdx
// upgrade immediately; downgrade only after a clear overshoot below the band
// edge (hysteresis), so the state does not flip on score noise
if rawIdx > stateIdx
stateIdx := rawIdx
else if rawIdx < stateIdx
edge = stateIdx == 4 ? lvlStrong : stateIdx == 3 ? lvlHealthy : stateIdx == 2 ? lvlTrans : lvlWeak
if tps < edge - 3.0
stateIdx := rawIdx
string state = "Trend Dead"
if stateIdx == 4
state := "Trend Strong"
else if stateIdx == 3
state := "Trend Healthy"
else if stateIdx == 2
state := "Transition"
else if stateIdx == 1
state := "Weak / Range"
// Project accent palette
color cBull = #16a34a
color cWarn = #f59e0b
color cBear = #dc2626
color cMute = #64748b
color cGrid = #94a3b8
color cInk = #1f2937
// Strength ramp — neutral cool palette, kept distinct from the green/red
// direction language so color never carries two meanings at once.
color cStrong = #1d4ed8
color cHealthy = #3b82f6
color stateColor = cGrid
if stateIdx == 4
stateColor := cStrong
else if stateIdx == 3
stateColor := cHealthy
else if stateIdx == 2
stateColor := cWarn
else if stateIdx == 1
stateColor := cMute
// ============================================================================
// Directional context — VISUAL ONLY, never fed into the score
// ============================================================================
dirUp = corr > 0.15
dirDn = corr < -0.15
// Compression — MEASURED (not inferred from low TPS, which can also be high-vol
// chop). Bollinger-width percentile rank; a low rank = volatility contraction.
basis = ta.sma(src, len)
dev = ta.stdev(src, len)
bbWidth = basis == 0.0 ? 0.0 : (4.0 * dev) / basis
compress = ta.percentrank(bbWidth, 100) < 30
flatTxt = compress ? "• Compress" : "• Flat"
biasTxt = dirUp ? "▲ Up" : dirDn ? "▼ Down" : flatTxt
biasCol = dirUp ? cBull : dirDn ? cBear : (compress ? cWarn : color.new(cMute, 10))
string driver = "Fit"
driverVal = r2Score
if erScore > driverVal
driver := "Efficiency"
driverVal := erScore
if adxScoreFinal > driverVal
driver := structureDead ? "ADX damped" : "ADX building"
driverVal := adxScoreFinal
if fdiTrendScore > driverVal
driver := "Clean path"
driverVal := fdiTrendScore
string drag = "Weak fit"
dragVal = r2Score
if erScore < dragVal
drag := "Low efficiency"
dragVal := erScore
if adxScoreFinal < dragVal
drag := structureDead ? "Dead structure" : "Weak ADX"
dragVal := adxScoreFinal
if fdiTrendScore < dragVal
drag := "Choppy path"
dragVal := fdiTrendScore
readTxt = state + " · " + biasTxt
reasonTxt = tps >= lvlHealthy ? "Driver: " + driver : "Drag: " + drag
moveTxt = dirUp ? "Up-move" : dirDn ? "Down-move" : compress ? "Compression" : "No clear move"
// ============================================================================
// Plot
// ============================================================================
// Two-dimensional read: HEIGHT = persistence (tps), COLOR = direction (corr
// sign), SATURATION = strength (faded when the trend is weak/dead). One color
// language driven by colorMode.
useDir = colorMode == "Direction × Strength"
dirBase = dirUp ? cBull : dirDn ? cBear : cMute
// transparency scaled by tps: strong trend = vivid, dead market = pale
lineT = f_clamp(60.0 - tps * 0.55, 0.0, 60.0)
fillT = f_clamp(93.0 - tps * 0.35, 55.0, 93.0)
oscCol = useDir ? color.new(dirBase, lineT) : stateColor
heatCol = useDir ? color.new(dirBase, fillT) : color.new(stateColor, 80)
trailCol = color.new(useDir ? dirBase : stateColor, 82)
plot(showTrail ? tps : na, "Persistence Trail", color=trailCol, linewidth=7)
pTps = plot(tps, "Trend Persistence Score", color=oscCol, linewidth=3)
pZero = plot(0, "", color=color.new(color.gray, 100), display=display.none, editable=false)
fill(pTps, pZero, color=showFill ? heatCol : na, title="Heat Column")
plot(showLines ? r2Score : na, "R² Score", color=color.new(#2563eb, 45), linewidth=1)
plot(showLines ? erScore : na, "Efficiency Ratio", color=color.new(#0d9488, 45), linewidth=1)
plot(showLines ? adxScoreFinal : na, "ADX Score (effective)", color=color.new(#f97316, 45), linewidth=1)
plot(showLines ? fdiTrendScore : na, "FDI Trend Score", color=color.new(#7c3aed, 45), linewidth=1)
hTop = hline(100, "Top", color=color.new(cGrid, 92), linestyle=hline.style_dotted)
hStrong = hline(lvlStrong, "Strong Trend", color=color.new(cStrong, 35), linestyle=hline.style_dashed)
hHealthy = hline(lvlHealthy, "Healthy Trend", color=color.new(cHealthy, 60), linestyle=hline.style_dashed)
hTrans = hline(lvlTrans, "Transition", color=color.new(cWarn, 48), linestyle=hline.style_dashed)
hWeak = hline(lvlWeak, "Weak", color=color.new(cMute, 58), linestyle=hline.style_dashed)
hBase = hline(0, "Base", color=color.new(cGrid, 88), linestyle=hline.style_dotted)
fill(hTop, hStrong, color=showZones ? color.new(cStrong, 93) : na, title="Strong Zone")
fill(hStrong, hHealthy, color=showZones ? color.new(cHealthy, 95) : na, title="Healthy Zone")
fill(hHealthy, hTrans, color=showZones ? color.new(cWarn, 94) : na, title="Transition Zone")
fill(hTrans, hWeak, color=showZones ? color.new(cMute, 95) : na, title="Weak Zone")
fill(hWeak, hBase, color=showZones ? color.new(cMute, 95) : na, title="Dead Zone")
// ============================================================================
// Info label + state markers — compact mobile read, no table
// ============================================================================
tc = cInk
trCol = transitionRisk >= 70 ? cWarn : tc
var label infoLbl = na
if showLabel and barstate.islast
label.delete(infoLbl)
txt = "TPS " + str.tostring(tps, "#.0") + " · " + readTxt + "\n" +
reasonTxt + " · Risk " + str.tostring(transitionRisk, "#.0") + "\n" +
"R² " + str.tostring(r2Score, "#") +
" ER " + str.tostring(erScore, "#") +
" ADX " + str.tostring(adxScoreFinal, "#") +
" FDI " + str.tostring(fdiTrendScore, "#")
tip = "Trend Persistence Score\n" +
"Height: trend persistence from 0 to 100.\n" +
"Color: visual direction context only.\n" +
"Driver/Drag names the strongest or weakest sensor.\n" +
"Transition Risk is derived from TPS and ER; it is not a break detector."
infoLbl := label.new(bar_index + 3, 100, txt,
xloc=xloc.bar_index, yloc=yloc.price, style=label.style_label_left,
color=color.new(color.white, 8), textcolor=trCol,
size=f_lblSize(labelSize), textalign=text.align_left, tooltip=tip)
else if not showLabel and barstate.islast
label.delete(infoLbl)
weakeningEvent = ta.crossunder(tps, lvlHealthy)
transitionHardEvent = ta.crossunder(tps, lvlTrans)
transitionLeadEvent = ta.crossunder(tps, lvlTrans + transitionLead) and tps > lvlTrans and tps < tps[1] and transitionRisk > transitionRisk[1]
transitionEvent = transitionHardEvent or transitionLeadEvent
recoveryEvent = ta.crossover(tps, lvlHealthy)
string eventText = ""
string eventTip = ""
color eventColor = cMute
if transitionEvent
eventText := "T"
eventColor := cWarn
eventTip := transitionLeadEvent
? "Transition building\n" + moveTxt + ": clean trend phase is starting to fail.\nContinuation edge is fading before the hard Transition cross."
: "Transition\n" + moveTxt + ": clean trend phase is ending.\nContinuation edge is off. Expect pause, range, chop or structure-break context."
else if weakeningEvent
eventText := "W"
eventColor := #f97316
eventTip := "Weakening\n" + moveTxt + ": strength is fading.\nThe move can continue, but follow-through quality is deteriorating."
else if recoveryEvent
eventText := "R"
eventColor := cHealthy
eventTip := "Recovery\n" + moveTxt + ": strong clean move confirmed.\nContinuation filter is back on. If price is already extended, wait for structure or pullback timing."
hasKeyEvent = eventText != ""
var int lastEventBar = na
var int lastTransitionBar = na
transitionReady = not transitionEvent or na(lastTransitionBar) or bar_index - lastTransitionBar >= markerCooldown
eventReady = transitionReady and (transitionEvent or na(lastEventBar) or bar_index - lastEventBar >= markerCooldown)
if showMarkers and barstate.isconfirmed and hasKeyEvent and eventReady
label.new(bar_index, tps, eventText,
xloc=xloc.bar_index, yloc=yloc.price, style=label.style_label_center,
color=color.new(eventColor, 8), textcolor=color.white,
size=size.tiny,
tooltip=eventTip + "\nTPS: " + str.tostring(tps, "#.0") +
"\n" + reasonTxt +
"\nR² " + str.tostring(r2Score, "#.0") +
" · ER " + str.tostring(erScore, "#.0") +
" · ADX " + str.tostring(adxScoreFinal, "#.0") +
" · FDI " + str.tostring(fdiTrendScore, "#.0"))
lastEventBar := bar_index
if transitionEvent
lastTransitionBar := bar_index
// ============================================================================
// Debug — log on every state change: WHY it changed (which sensor drove it)
// ============================================================================
if barstate.isconfirmed and stateIdx != stateIdx[1]
log.info("TPS STATE {0} -> {1} | tps={2} TR={3} R2={4} ER={5} ADX={6}{7} FDI={8} bias={9}",
state[1], state, str.tostring(tps, "#.0"), str.tostring(transitionRisk, "#.0"),
str.tostring(r2Score, "#.0"), str.tostring(erScore, "#.0"),
str.tostring(adxScoreFinal, "#.0"), structureDead ? "(damped)" : "",
str.tostring(fdiTrendScore, "#.0"), biasTxt)
// ============================================================================
// Alerts
// ============================================================================
bool alertsOnBarClose = input.bool(true, "Alerts only on bar close (confirmed)", group="Alerts")
bool alertOk = not alertsOnBarClose or barstate.isconfirmed
alertcondition(alertOk and (ta.crossunder(tps, lvlHealthy)), "TPS Weakening", "TPS · WEAKENING · {{ticker}} {{interval}}")
alertcondition(alertOk and (ta.crossunder(tps, lvlTrans)), "TPS Transition", "TPS · TRANSITION · {{ticker}} {{interval}}")
alertcondition(alertOk and (ta.crossover(tps, lvlHealthy)), "TPS Recovery", "TPS · RECOVERY · {{ticker}} {{interval}}")
alertcondition(alertOk and (ta.crossover(transitionRisk, 70)), "TPS Transition Risk High", "TPS · TRANSITION RISK HIGH · {{ticker}} {{interval}}")
Overview
Trend Persistence Score [WavesUnchained] integrates four distinct metrics: linear regression R-squared, Kaufman Efficiency Ratio, ADX with slope, and Fractal Dimension Index. Users can calibrate individual sensor weights, range and trend anchors, ADX damping thresholds, and state levels defining strong, healthy, transition, or weak conditions. Linear regression slope supplies visual directional context. The script includes background state zones, historical trail plots, event markers, and configurable bar-close alerts.
Four-sensor composite trend score combining R-squared, ER, ADX, and FDI
Customizable sensor weights and calibration anchors
Trend state classification across Strong, Healthy, Transition, and Weak levels
Visual directional context based on regression slope
Key event markers, background zones, and confirmed bar-close alerts
The inputs you can change from the gear icon on the chart. Defaults are the author's.
Setting
Type
Default
Variable
Source
source
close
src
Main Length
int
34
len
ADX Length
int
14
adxLen
Score Smoothing
int
5
smoothLen
Weight: Regression R²
float
40.0
wR2
Weight: Efficiency Ratio
float
25.0
wER
Weight: ADX / Slope
float
20.0
wADX
Weight: Fractal Dimension
float
15.0
wFDI
ADX: range anchor
float
12.0
adxLo
ADX: trend anchor
float
35.0
adxHi
FDI: trend anchor
float
1.20
fdiTrnd
FDI: range anchor
float
1.65
fdiRng
Show all 29 settings
Setting
Type
Default
Variable
ADX damp: structure-dead threshold
float
20.0
deadThresh
ADX damp: factor
float
0.35
adxDamp
State: Strong ≥
int
75
lvlStrong
State: Healthy ≥
int
60
lvlHealthy
State: Transition ≥
int
45
lvlTrans
State: Weak ≥
int
30
lvlWeak
Oscillator Color
string
Direction × Strength
colorMode
Show Heat Column (fill to 0)
bool
true
showFill
Show Background Zones
bool
true
showZones
Show Persistence Trail
bool
true
showTrail
Show Key Event Labels
bool
true
showMarkers
Event Label Cooldown
int
10
markerCooldown
Transition Lead Buffer
float
3.0
transitionLead
Show Sensor Lines
bool
false
showLines
Show Info Label
bool
true
showLabel
Label Size
string
Normal
labelSize
Alerts only on bar close (confirmed)
bool
true
alertsOnBarClose
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 plots a Supertrend-based trend-following setup in a separate pane and skips conditions it treats as a sideways market. A volatility range is built from an ATR value, with RMA, SMA, EMA, or WMA smoothing, or from a fixed point distance. Optional
This indicator generates trade entry signals and target levels optimized for 1-hour timeframes, designed for external signal extraction. It calculates 7-period and 16-period EMAs alongside 10-period RSI and ATR filters. Long signals occur when the fast EMA cro
MultiIndicator combines multiple technical indicators into a single separate pane to save chart space. It calculates and displays market regime detection based on ordinary least squares regression, a Holt exponential smoothing crossover, normalized RSI, ADX, a
This indicator plots an oscillator in a separate pane. It builds several SuperTrend series across a user-defined factor range and step, using ATR as the band width. On each bar it measures the distance between the close and each SuperTrend output, then groups
The script plots the Choppiness Index in a separate pane. It sums true range over a lookback and compares that sum with the highest-high to lowest-low range, then scales the result from 0 to 100. Lower readings indicate a trending market and higher readings in
This indicator combines Chaikin Money Flow (CMF), a SuperTrend baseline, and an Average True Range (ATR) calculation inside a separate sub-pane. It calculates a standard 20-period CMF plotted as a line and colored histogram columns around a zero baseline, trac