How to Use the RSI Indicator in Pine Script v6: Overbought, Oversold, and Signal Logic

The Relative Strength Index (RSI) is a momentum oscillator that measures the speed and magnitude of recent price changes on a normalized 0–100 scale. In this article, we examine the mathematical foundation of RSI, how Pine Script v6 computes it internally, and how to build a verified, production-ready RSI indicator with overbought/oversold signal logic from scratch.

1. What Is RSI? The Mathematical Definition

RSI was introduced by J. Welles Wilder Jr. in 1978. It is defined as:

$$RSI = 100 - \frac{100}{1 + RS}$$

where RS (Relative Strength) is the ratio of the average gain to the average loss over a lookback period $n$:

$$RS = \frac{\text{Average Gain}_n}{\text{Average Loss}_n}$$

Wilder used a Wilder Smoothing (RMA) — also called Exponential Moving Average with $\alpha = \frac{1}{n}$ — to compute the rolling averages. The recurrence relation is:

$$\text{AvgGain}_t = \frac{\text{Gain}_t + (n-1) \cdot \text{AvgGain}_{t-1}}{n}$$

The seed value for $\text{AvgGain}$ at bar $t = n-1$ is the simple arithmetic mean of the first $n$ gains.

2. Overbought and Oversold Thresholds

Wilder's original thresholds are the most widely cited reference levels:

RSI Zone Value Range Interpretation
Overbought ≥ 70 Upward momentum is strong; price may be extended
Neutral 30 – 70 No extreme momentum signal
Oversold ≤ 30 Downward momentum is strong; price may be depressed

These thresholds are reference levels, not guarantees of reversal. They indicate the relative magnitude of recent gains versus losses — nothing more.

3. RSI Logic Flow

graph TD A[Price Series: close] --> B[ta.change: chg = close - close1] B --> C{chg > 0?} C -- Yes --> D[gain = chg] C -- No --> E[gain = 0.0] B --> F{chg < 0?} F -- Yes --> G[loss = -chg] F -- No --> H[loss = 0.0] D --> I[ta.sma gain rsiLength at global scope] E --> I G --> J[ta.sma loss rsiLength at global scope] H --> J I --> K{bar_index == rsiLength - 1?} J --> K K -- Yes --> L[Seed avgGain and avgLoss with SMA values] K -- No --> M{bar_index > rsiLength - 1?} M -- Yes --> N[Apply Wilder RMA recurrence formula] M -- No --> O[avgGain = na, avgLoss = na] L --> P[Compute RS = avgGain divided by avgLoss] N --> P O --> Q[RSI = na warm-up] P --> R{avgLoss == 0?} R -- Yes --> S{avgGain == 0?} S -- Yes --> T[RSI = na flat market] S -- No --> U[RSI = 100] R -- No --> V[RSI = 100 - 100 divided by 1 plus RS] V --> W[Plot RSI with 70 and 30 threshold lines]

4. Pine Script v6 Built-in vs. Manual RSI

Pine Script v6 provides ta.rsi(source, length) as a built-in. Its length parameter requires simple int — meaning it cannot accept a series int computed at runtime. For the full parameter specification, see the official Reference Manual: ta.rsi() Reference.

The built-in is equivalent to the manual RMA-seeded implementation shown below. Both produce identical output when the seed condition is handled correctly.

5. Full Pine Script v6 Implementation

The script below implements RSI manually using the correct Wilder seeding pattern, then overlays overbought/oversold signal markers. It also demonstrates the built-in ta.rsi() for comparison.

🔽 [Click to expand] View Full Pine Script Code
//@version=6
indicator(title = "RSI — Manual + Built-in Comparison", shorttitle = "RSI v6", overlay = false)

// ─── INPUTS ───────────────────────────────────────────────────────────────────
// input.int returns input int, which satisfies simple int (required by ta.rsi)
int    rsiLength   = input.int(14,   title = "RSI Length",        minval = 2)
float  obLevel     = input.float(70.0, title = "Overbought Level", minval = 50.0, maxval = 100.0)
float  osLevel     = input.float(30.0, title = "Oversold Level",   minval = 0.0,  maxval = 50.0)
float  src         = input.source(close, title = "Source")
// Note: input.source() returns series float — do NOT declare as input float

// ─── PRICE CHANGE ─────────────────────────────────────────────────────────────
// ta.change(src) = src - src[1]; returns na on bar 0
float chg = ta.change(src)

// ─── GAIN / LOSS DECOMPOSITION ────────────────────────────────────────────────
// On bar 0: chg is na.
// (na < 0) evaluates to false → loss = 0.0 (safe, no na propagation)
// (na > 0) evaluates to false → gain = 0.0 (safe, no na propagation)
// Both gain and loss are 0.0 on bar 0, NOT na.
// Therefore ta.sma(gain, rsiLength) first non-na result is at bar_index == rsiLength - 1.
float gain = chg > 0 ? chg  : 0.0   // upward price movement
float loss = chg < 0 ? -chg : 0.0   // downward price movement (always >= 0)

// ─── WILDER SMOOTHING SEED (RMA) ──────────────────────────────────────────────
// ta.sma() is called at GLOBAL scope so its internal history accumulates every bar.
// We only READ the result inside the conditional — no stateful call inside if block.
float seedAvgGain = ta.sma(gain, rsiLength)   // global scope — runs every bar
float seedAvgLoss = ta.sma(loss, rsiLength)   // global scope — runs every bar

// ─── MANUAL RMA ACCUMULATORS ──────────────────────────────────────────────────
var float avgGain = na   // persists across bars
var float avgLoss = na   // persists across bars

// Seed on the first bar where a full SMA window is available.
// Because gain/loss = 0.0 on bar 0, the first non-na SMA is at bar_index == rsiLength - 1.
if bar_index == rsiLength - 1
    avgGain := seedAvgGain
    avgLoss := seedAvgLoss

// After seeding, apply Wilder's recurrence: RMA(x, n) = (x + (n-1)*prev) / n
else if bar_index > rsiLength - 1
    avgGain := (gain + (rsiLength - 1) * avgGain[1]) / rsiLength
    avgLoss := (loss + (rsiLength - 1) * avgLoss[1]) / rsiLength

// ─── RSI CALCULATION ──────────────────────────────────────────────────────────
// Guard against avgLoss == 0 (all gains, no losses → RSI = 100)
// Guard against na (warm-up period → RSI = na)
float rsiManual = na(avgGain) or na(avgLoss) ? na :
                  avgLoss == 0.0 ? (avgGain == 0.0 ? na : 100.0) :
                  100.0 - (100.0 / (1.0 + avgGain / avgLoss))

// ─── BUILT-IN RSI FOR COMPARISON ──────────────────────────────────────────────
// ta.rsi() requires simple int for length — rsiLength is input int which satisfies simple int
float rsiBuiltin = ta.rsi(src, rsiLength)

// ─── PLOTS ────────────────────────────────────────────────────────────────────
plot(rsiManual,  title = "RSI (Manual)",   color = color.new(color.blue,   0),  linewidth = 2)
plot(rsiBuiltin, title = "RSI (Built-in)", color = color.new(color.orange, 60), linewidth = 1)

// Overbought / Oversold threshold lines
hline(obLevel, title = "Overbought", color = color.red,   linestyle = hline.style_dashed, linewidth = 1)
hline(osLevel, title = "Oversold",   color = color.green, linestyle = hline.style_dashed, linewidth = 1)
hline(50,      title = "Midline",    color = color.gray,  linestyle = hline.style_dotted, linewidth = 1)

// ─── OVERBOUGHT / OVERSOLD SIGNAL DETECTION ───────────────────────────────────
// Crossover: RSI crosses ABOVE overbought level
bool crossOB = ta.crossover(rsiManual, obLevel)
// Crossunder: RSI crosses BELOW oversold level
bool crossOS = ta.crossunder(rsiManual, osLevel)

// ─── BACKGROUND HIGHLIGHT ─────────────────────────────────────────────────────
// Highlight background when RSI is in extreme zones
bgcolor(rsiManual >= obLevel ? color.new(color.red,   88) : na, title = "Overbought Zone")
bgcolor(rsiManual <= osLevel ? color.new(color.green, 88) : na, title = "Oversold Zone")

// ─── SIGNAL MARKERS ───────────────────────────────────────────────────────────
// plotshape draws a marker on the RSI pane at the crossing bar
plotshape(crossOB, title = "OB Cross",  style = shape.triangledown,
          location = location.top,    color = color.red,   size = size.small)
plotshape(crossOS, title = "OS Cross",  style = shape.triangleup,
          location = location.bottom, color = color.green, size = size.small)

6. Key Implementation Details Explained

6-A. Why gain/loss = 0.0 (not na) on bar 0

On bar 0, ta.change(src) returns na. The comparison na > 0 evaluates to false in Pine Script — comparisons with na do not propagate na; they return false. Therefore gain = chg > 0 ? chg : 0.0 returns 0.0 on bar 0. This is critical because it determines when ta.sma(gain, n) first produces a non-na result: at bar_index == rsiLength - 1.

6-B. Why ta.sma() must be at global scope

Functions like ta.sma() maintain an internal rolling buffer. If called inside a conditional block that does not execute on every bar, the buffer does not accumulate correctly, producing na or incorrect values (Pine Script compiler warning CW10003). The correct pattern is to call ta.sma() at global scope and only read its result inside the conditional.

6-C. The avgLoss == 0 edge case

When all price changes in the window are gains (no losses), avgLoss equals exactly 0.0. Division by zero must be guarded explicitly:

  • If avgLoss == 0 and avgGain > 0 → RSI = 100
  • If both are 0 (flat market) → RSI = na (undefined)

Note: 0.0 is a valid numeric value. na(0.0) returns false. Always use == 0 to check for zero, never na().

6-D. input.source() returns series float

input.source() is the only input function that returns series float. Declaring its result as input float src causes compile error CE10159. Always use float src or inferred typing.

7. RSI Signal Interpretation Table

Event Pine Script Detection What It Means Mathematically
RSI crosses above 70 ta.crossover(rsi, 70) AvgGain / AvgLoss ratio exceeded 2.333…
RSI crosses below 30 ta.crossunder(rsi, 30) AvgGain / AvgLoss ratio fell below 0.4286
RSI = 50 rsi == 50 AvgGain == AvgLoss (RS = 1.0)
RSI = 100 avgLoss == 0 No losses in the entire window
RSI = 0 avgGain == 0 No gains in the entire window

8. Conclusion

  • RSI is a ratio-based momentum oscillator: It normalizes the ratio of average gains to average losses into a 0–100 scale using Wilder's RMA smoothing. The 70/30 thresholds are reference levels derived from this ratio, not arbitrary lines.
  • Correct seeding is non-negotiable: The ta.sma() seed call must execute at global scope on every bar. Placing stateful functions inside conditional blocks causes CW10003 and produces incorrect results. The seed condition index depends on whether gain/loss is 0.0 or na on bar 0.
  • Edge cases require explicit guards: Division by zero (avgLoss == 0), flat markets (both averages zero), and the warm-up period (na values) must each be handled explicitly. 0.0 is not na — never use na() to check for zero.

Ideas for Further Development

  • RSI Divergence Detection: Compare RSI peaks/troughs against price peaks/troughs using ta.pivothigh() and ta.pivotlow() to programmatically detect bullish and bearish divergence patterns.
  • Dynamic Threshold Adaptation: Replace fixed 70/30 levels with percentile-based thresholds computed over a rolling window using ta.percentile_nearest_rank(), adapting the overbought/oversold definition to the current volatility regime.

Related Posts

Comments

Popular posts from this blog

How to Build a Volume Indicator in Pine Script v6: Buying & Selling Pressure Analysis

Pine Script v6 plot() Function: Complete Guide to Lines, Histograms, and Circles

SuperTrend Indicator in Pine Script v6: Canonical Formula, Flip Logic, and Non-Repainting Signals