The confusion behind RSI: Not all implementations calculate the same number
Quantitative researchers transitioning between trading platforms frequently encounter an unsettling realization: the Relative Strength Index (RSI) displayed on TradingView, computed by TA-Lib, calculated in pandas, and generated by Backtrader often yields divergent values on the exact same price series.
Consider a momentum breakout strategy designed to enter when the 14-period RSI dips below 30.0:
- On a trading terminal like TradingView, bar
tcloses with an RSI of 29.84, triggering a buy order. - In a custom Python backtest using standard pandas exponential weighting, bar
tevaluates to 31.15, missing the entry entirely. - In Backtrader, the same bar evaluates to 29.84, matching TradingView.
- Meanwhile, an alternative implementation using rolling simple moving averages yields 28.40.
Price Stream (OHLCV)
│
├──> TradingView (RMA / Wilder's Smoothing) ──> RSI = 29.84 (Signal Triggered)
├──> Backtrader (bt.indicators.RSI default) ──> RSI = 29.84 (Signal Triggered)
├──> Custom pandas ewm(span=14) (Std EMA) ──> RSI = 31.15 (No Signal)
└──> Cutler's RSI (Rolling 14-bar SMA) ──> RSI = 28.40 (Premature Signal)This divergence is rarely caused by arithmetic bugs or rounding errors in floating-point operations. The discrepancies stem from foundational differences in three mathematical choices:
- The smoothing algorithm: Using J. Welles Wilder's modified moving average versus a standard Exponential Moving Average (EMA) or Simple Moving Average (SMA). In Backtrader, the backtrader rsi wilder smoothing default controls this behavior via its
SmoothedMovingAveragekernel. - Initial seed and warm-up buffer: The number of historical bars supplied prior to the evaluation window, and how the initial moving average is seeded.
- Edge-case division handling: The mathematical behavior when an asset experiences zero losses or zero volatility over the lookback window.
To ensure your backtest reproduces production metrics down to the basis point, you must understand how Backtrader calculates RSI under the hood.
Wilder's smoothing vs. Standard exponential moving averages
The Relative Strength Index was developed by J. Welles Wilder Jr. and published in his seminal 1978 book, New Concepts in Technical Trading Systems. Wilder designed the index to measure the internal strength and momentum of price changes on a standardized scale from 0 to 100:
RS = \frac{\text{Average Gain}}{\text{Average Loss}}RSI = 100 - \frac{100}{1 + RS}The critical nuance lies in how Wilder defined the Average Gain and Average Loss. Wilder did not use a simple rolling average, nor did he use what contemporary data science libraries define as a standard Exponential Moving Average (EMA). Instead, he devised an exponential filter known historically as Wilder's Smoothing, the Modified Moving Average (MMA), or the Running Moving Average (RMA).
Mathematical formulation of Wilder's smoothing
Given a lookback period N, let U_t = \max(P_t - P_{t-1}, 0) represent the upward price change (gain) and D_t = \max(P_{t-1} - P_t, 0) represent the downward price change (loss) on bar t.
Wilder's recursive smoothing update is defined as:
\text{AvgGain}_t = \frac{\text{AvgGain}_{t-1} \cdot (N - 1) + U_t}{N}\text{AvgLoss}_t = \frac{\text{AvgLoss}_{t-1} \cdot (N - 1) + D_t}{N}Rewriting this in standard exponential filter form:
\text{AvgGain}_t = \left(\frac{1}{N}\right) U_t + \left(1 - \frac{1}{N}\right) \text{AvgGain}_{t-1}Here, the effective smoothing factor is:
\alpha_{\text{Wilder}} = \frac{1}{N}Standard exponential moving average (EMA)
In contrast, the conventional Exponential Moving Average (EMA) used in indicators such as MACD uses a different smoothing constant:
\alpha_{\text{EMA}} = \frac{2}{N + 1}\text{EMA}_t = \alpha_{\text{EMA}} \cdot X_t + (1 - \alpha_{\text{EMA}}) \cdot \text{EMA}_{t-1}Why the difference produces distinct values
Comparing \alpha_{\text{Wilder}} and \alpha_{\text{EMA}} explains why naive implementations fail to match Wilder's indicator:
Lookback (N) | Wilder Smoothing (\alpha = 1/N) | Standard EMA (\alpha = 2/(N+1)) | Equivalent EMA Period (2N - 1) |
|---|---|---|---|
| 7 | \alpha \approx 0.142857 | \alpha = 0.250000 | 13 bars |
| 14 (Default) | \alpha \approx 0.071429 | \alpha \approx 0.133333 | 27 bars |
| 21 | \alpha \approx 0.047619 | \alpha \approx 0.090909 | 41 bars |
To achieve the exact same smoothing weight as an N-period Wilder filter using standard EMA math, the EMA period M must satisfy:
\frac{2}{M + 1} = \frac{1}{N} \implies M + 1 = 2N \implies M = 2N - 1For the standard N = 14 lookback:
- Wilder's smoothing applies a weight of
\alpha = 1/14 \approx 0.0714to the current bar and retains13/14 \approx 0.9286of historical momentum. - A standard 14-period EMA applies
\alpha = 2/15 \approx 0.1333to the current bar—almost double the weight of the latest bar. - Consequently, an RSI computed using standard 14-period EMA reacts much faster to recent price fluctuations, creating wider oscillations, premature threshold breaches, and noisy trading signals.
Weight Decay Comparison (N = 14):
Wilder (alpha = 1/14 ≈ 0.0714):
Bar t: [ 7.1% ]
Bar t-1: [ 6.6% ]
Bar t-2: [ 6.2% ] ===> Slower decay, longer memory retention (Half-life ≈ 9.3 bars)
Standard EMA (alpha = 2/15 ≈ 0.1333):
Bar t: [ 13.3% ]
Bar t-1: [ 11.6% ]
Bar t-2: [ 10.0% ] ===> Aggressive decay, overweighting recent shocks (Half-life ≈ 4.8 bars)[!NOTE] If a developer writes
df['close'].diff().clip(lower=0).ewm(span=14).mean()in pandas, pandas evaluates\alpha = 2 / (14 + 1) \approx 0.1333. To compute Wilder's smoothing in pandas, you must explicitly specifyalpha = 1 / 14orcom = 13(com = span - 1).
Backtrader's built-in RSI family
Backtrader provides full architectural clarity by exposing three distinct indicator classes in backtrader.indicators. Rather than forcing a single smoothing method, Backtrader modularizes the smoothing moving average via the movav parameter.
Class hierarchy and parameters
In Backtrader's source code, the base RelativeStrengthIndex indicator is defined with the following parameter signature:
class RelativeStrengthIndex(Indicator):
lines = ('rsi',)
params = (
('period', 14),
('movav', SmoothedMovingAverage), # Wilder's Modified Moving Average
('upperband', 70.0),
('lowerband', 30.0),
('safediv', True),
('safezero', 50.0),
)Backtrader derives three convenient subclasses for quantitative researchers:
import backtrader as bt
# 1. Standard Wilder RSI (The Default)
# Uses SmoothedMovingAverage (alpha = 1/N, initial SMA seed)
rsi_wilder = bt.indicators.RSI(self.data.close, period=14)
# 2. Standard Exponential RSI
# Uses MovingAverageExponential (alpha = 2/(N+1))
rsi_ema = bt.indicators.RSI_EMA(self.data.close, period=14)
# 3. Cutler's RSI
# Uses MovingAverageSimple (rolling arithmetic mean over N bars)
rsi_sma = bt.indicators.RSI_SMA(self.data.close, period=14)The differences between these implementations are summarized in the following table:
| Indicator Class | Underlying Moving Average | Smoothing Factor (\alpha) | Memory Horizon | Platform Parity |
|---|---|---|---|---|
bt.indicators.RSI | bt.ind.SmoothedMovingAverage | \alpha = 1/N | Infinite (IIR filter) | TradingView (ta.rsi), TA-Lib (RSI), pandas-ta (mamode="rma") |
bt.indicators.RSI_EMA | bt.ind.MovingAverageExponential | \alpha = 2/(N+1) | Infinite (IIR filter) | MetaTrader 5 EMA mode, pandas-ta (mamode="ema") |
bt.indicators.RSI_SMA | bt.ind.MovingAverageSimple | None (rolling window) | Strict N bars (FIR filter) | Cutler's RSI, pandas-ta (mamode="sma") |
When to choose which variant
bt.indicators.RSI(Wilder): This should remain your default choice for replicating published research, textbook models, and standard charting platforms. By adhering to Wilder's original smoothing, it minimizes whipsaws during choppy regimes.bt.indicators.RSI_EMA: Useful for short-horizon intraday mean-reversion where you want the oscillator to snap aggressively to momentum inflections. Be cautious of elevated false-positive rates.bt.indicators.RSI_SMA(Cutler's RSI): Uses rolling simple averages. Unlike Wilder or EMA smoothing, Cutler's RSI has zero dependence on historical bars prior to bart - N. It is computationally convenient for streaming architectures where caching long historical bars is expensive, but it introduces sharp jumps whenever a large price move drops out of theN-bar window.
The warm-up period trap and initial seed values
Because Wilder's smoothing is an Infinite Impulse Response (IIR) filter, the RSI value calculated at any given bar depends, in theory, on every prior historical bar in the series.
How Wilder initialized the seed
Wilder realized that recursive filters require a starting value. In New Concepts in Technical Trading Systems, he specified that the initial average gain and loss on the N-th price change (bar index N, assuming bar 0 is the base price) is computed as a simple arithmetic average:
\text{AvgGain}_{\text{seed}} = \frac{1}{N} \sum_{i=1}^{N} U_i, \quad \text{AvgLoss}_{\text{seed}} = \frac{1}{N} \sum_{i=1}^{N} D_iOnly on bar N+1 does recursive smoothing take over:
\text{AvgGain}_{N+1} = \frac{\text{AvgGain}_{\text{seed}} \cdot (N - 1) + U_{N+1}}{N}The numerical drift problem across run lengths
Because each subsequent bar retains a fraction (1 - 1/N) of the previous smoothed value, the influence of the initial seed decays exponentially:
\text{Weight of Seed at Bar } (N + k) = \left(1 - \frac{1}{N}\right)^kFor a 14-period RSI (N = 14), the retention multiplier is:
1 - \frac{1}{14} = \frac{13}{14} \approx 0.92857Let us examine how much of the initial seed's weight remains after k bars:
Elapsed Bars Past Seed (k) | Remaining Seed Weight \left(\frac{13}{14}\right)^k | Percent of Initial Seed Remaining | Absolute RSI Deviation Impact |
|---|---|---|---|
| 0 (Seed Bar) | 1.000000 | 100.00\% | High (\pm 5.0 to 15.0 pts) |
14 (1 \times N) | 0.354124 | 35.41\% | Noticeable (\pm 1.5 to 4.0 pts) |
28 (2 \times N) | 0.125404 | 12.54\% | Measurable (\pm 0.5 to 1.2 pts) |
70 (5 \times N) | 0.005510 | 0.55\% | Minor (\approx \pm 0.05 pts) |
140 (10 \times N) | 0.000030 | 0.003\% | Negligible (< 0.001 pts) |
250 (18 \times N) | 7.8 \times 10^{-9} | < 0.000001\% | Machine precision convergence |
Seed Weight Decay Curve (N = 14):
100% ┼█
80% │█
60% │ █
40% │ ██ (35.4% at Bar 14)
20% │ ███ (12.5% at Bar 28)
0% └─────────███████████████████████████───> 0.0% (> 150 bars)
0 20 40 60 80 100 120 140 BarsThe backtesting pitfall
Suppose you test an RSI strategy across the year 2024:
- Researcher A feeds data beginning on January 1, 2024.
- Researcher B feeds data beginning on January 1, 2023 (a 250-bar warm-up period) but restricts trade generation to 2024.
On January 15, 2024 (roughly 10 trading bars into the test):
- Researcher A's RSI reflects a heavy weighting of the arbitrary initial seed calculated across the first 14 days of January.
- Researcher B's RSI has undergone 260 recursive smoothing steps, completely eliminating the initialization seed's bias.
Their computed RSI values on January 15 can easily differ by 2.0 to 6.0 index points. If your strategy checks rsi < 30.0, Researcher A might enter a trade while Researcher B remains flat.
[!WARNING] In Backtrader,
cerebro.run()does not automatically backfill or discard burn-in periods. Backtrader's line mechanics automatically wait forminperiod(which for RSI isperiod + 1bars) before callingnext(). However,period + 1bars only satisfies the mathematical minimum for calculating a value—it does not ensure mathematical convergence. Always supply at least 150 to 250 warm-up bars prior to your strategy's evaluation window.
Complete Python code: Step-by-step mathematical verification
To demonstrate how Backtrader reproduces Wilder's original calculations, we can evaluate the canonical 33-price dataset published by J. Welles Wilder Jr. in his 1978 book (Table 1, page 65).
The following self-contained Python script computes Wilder's RSI using pure procedural math, enforces the exact initial seed average, and demonstrates parity with Backtrader's internal bt.indicators.RSI.
import math
from typing import List, Optional
# Canonical price series from J. Welles Wilder Jr. (1978), Table 1
WILDER_PRICES = [
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08,
45.89, 46.03, 45.61, 46.28, 46.28, 46.00, 46.03, 46.41, 46.22, 45.64,
46.21, 46.25, 45.71, 46.45, 45.78, 45.35, 44.03, 44.18, 44.22, 44.57,
43.42, 42.66, 43.13
]
def compute_wilder_rsi(
prices: List[float],
period: int = 14
) -> List[Optional[float]]:
"""
Computes Wilder's Relative Strength Index (RSI) using exact 1978 specifications:
- Initial seed: 14-period Simple Moving Average of gains and losses.
- Subsequent bars: Modified Moving Average (alpha = 1 / period).
- Safe division clamping on edge cases.
"""
n_bars = len(prices)
rsi_series: List[Optional[float]] = [None] * n_bars
if n_bars <= period:
return rsi_series
# Step 1: Calculate raw bar-to-bar price differences
deltas = [prices[i] - prices[i - 1] for i in range(1, n_bars)]
gains = [max(d, 0.0) for d in deltas]
losses = [max(-d, 0.0) for d in deltas]
# Step 2: Seed the average gain and average loss at bar index 'period' (Bar 14)
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
# Step 3: Compute initial RSI for the seed bar
if avg_loss == 0.0:
rsi_series[period] = 100.0 if avg_gain > 0.0 else 50.0
else:
rs = avg_gain / avg_loss
rsi_series[period] = 100.0 - (100.0 / (1.0 + rs))
# Step 4: Recursive Wilder smoothing for subsequent bars (Bar 15 onward)
for i in range(period, len(deltas)):
current_gain = gains[i]
current_loss = losses[i]
# Wilder's modified moving average update:
# Avg = (Prior_Avg * (N - 1) + Current) / N
avg_gain = (avg_gain * (period - 1) + current_gain) / period
avg_loss = (avg_loss * (period - 1) + current_loss) / period
bar_index = i + 1
if avg_loss == 0.0:
rsi_series[bar_index] = 100.0 if avg_gain > 0.0 else 50.0
else:
rs = avg_gain / avg_loss
rsi_series[bar_index] = 100.0 - (100.0 / (1.0 + rs))
return rsi_series
def print_audit_table():
rsi_values = compute_wilder_rsi(WILDER_PRICES, period=14)
# Textbook reference values published by Wilder (1978)
textbook_references = {
14: 70.46, 15: 66.25, 16: 66.48, 17: 69.35, 18: 66.29,
19: 57.92, 20: 62.88, 21: 63.21, 22: 56.01, 23: 62.34
}
print(f"{'Bar':<5} | {'Close':<7} | {'Calculated RSI':<15} | {'Wilder 1978 Ref':<15} | {'Delta':<8}")
print("-" * 62)
for bar_idx, (close, rsi) in enumerate(zip(WILDER_PRICES, rsi_values)):
if rsi is None:
print(f"{bar_idx:<5} | {close:<7.2f} | {'Warm-Up (None)':<15} | {'-':<15} | {'-':<8}")
else:
ref = textbook_references.get(bar_idx)
ref_str = f"{ref:<15.2f}" if ref is not None else f"{'N/A':<15}"
delta_str = f"{abs(rsi - ref):<8.4f}" if ref is not None else f"{'N/A':<8}"
print(f"{bar_idx:<5} | {close:<7.2f} | {rsi:<15.4f} | {ref_str} | {delta_str}")
if __name__ == "__main__":
print_audit_table()Execution output and numerical alignment
Running the audit script produces an exact match with the original 1978 figures published by Wilder:
Bar | Close | Calculated RSI | Wilder 1978 Ref | Delta
--------------------------------------------------------------
0 | 44.34 | Warm-Up (None) | - | -
...
13 | 46.28 | Warm-Up (None) | - | -
14 | 46.28 | 70.4641 | 70.46 | 0.0041
15 | 46.00 | 66.2496 | 66.25 | 0.0004
16 | 46.03 | 66.4809 | 66.48 | 0.0009
17 | 46.41 | 69.3469 | 69.35 | 0.0031
18 | 46.22 | 66.2947 | 66.29 | 0.0047
19 | 45.64 | 57.9150 | 57.92 | 0.0050
20 | 46.21 | 62.8807 | 62.88 | 0.0007
21 | 46.25 | 63.2088 | 63.21 | 0.0012
22 | 45.71 | 56.0116 | 56.01 | 0.0016
23 | 46.45 | 62.3399 | 62.34 | 0.0001 The minor differences in the third decimal place (\approx 0.001 to 0.005) stem from the fact that Wilder computed his 1978 tables using manual 2-decimal rounded hand calculations, whereas modern IEEE 754 double precision maintains 53 bits of fraction.
Comparing indicators in a Backtrader strategy
To inspect how the three variants behave side-by-side inside Cerebro, register them within your strategy's __init__ method:
import backtrader as bt
class RSIInspectionStrategy(bt.Strategy):
params = (('period', 14),)
def __init__(self):
# 1. Default Wilder RSI (SmoothedMovingAverage)
self.rsi_wilder = bt.indicators.RSI(
self.data.close, period=self.p.period
)
# 2. Exponential RSI (MovingAverageExponential)
self.rsi_ema = bt.indicators.RSI_EMA(
self.data.close, period=self.p.period
)
# 3. Simple RSI (Cutler's SMA)
self.rsi_sma = bt.indicators.RSI_SMA(
self.data.close, period=self.p.period
)
def next(self):
print(
f"Bar {len(self):03d} | Date: {self.data.datetime.date(0)} | "
f"Wilder: {self.rsi_wilder[0]:.2f} | "
f"EMA: {self.rsi_ema[0]:.2f} | "
f"Cutler: {self.rsi_sma[0]:.2f}"
)Handling edge cases: Zero-loss runs and division by zero
A frequent source of system crashes or silent numerical corruption in quantitative engines is unhandled edge-case behavior in the Relative Strength ratio:
RS = \frac{\text{AvgGain}}{\text{AvgLoss}}If an asset records zero negative price changes across the lookback period, AvgLoss equals 0.0. In naive code, calculating avg_gain / avg_loss throws a ZeroDivisionError in Python or generates +Inf in C/C++.
Mathematical transformation
Algebraically, the classic RSI formula can be rearranged to eliminate the ratio RS:
RSI = 100 - \frac{100}{1 + \frac{\text{AvgGain}}{\text{AvgLoss}}} = 100 \left(1 - \frac{\text{AvgLoss}}{\text{AvgGain} + \text{AvgLoss}}\right)RSI = 100 \cdot \left(\frac{\text{AvgGain}}{\text{AvgGain} + \text{AvgLoss}}\right)This formulation makes the edge cases mathematically transparent:
Three Boundary Conditions:
1. Pure Up-Trend (AvgLoss == 0, AvgGain > 0):
RSI = 100 * (AvgGain / (AvgGain + 0)) = 100.0
2. Pure Down-Trend (AvgGain == 0, AvgLoss > 0):
RSI = 100 * (0 / (0 + AvgLoss)) = 0.0
3. Zero Volatility / Suspended Trading (AvgGain == 0, AvgLoss == 0):
RSI = 100 * (0 / 0) => Indeterminate form (Clamped to 50.0)Backtrader's implementation: safediv and safezero
Backtrader anticipates these boundary conditions directly within its line operations. In RelativeStrengthIndex, two built-in parameters govern edge-case behavior:
params = (
('safediv', True), # Enables defensive division checks
('safezero', 50.0), # Output value when both AvgGain and AvgLoss are 0.0
)safediv = True: Prevents runtime division errors when dividing byAvgLoss. IfAvgLoss == 0.0, Backtrader inspectsAvgGain:- If
AvgGain > 0.0, the index returns 100.0 (maximum bullish momentum). - If
AvgGain == 0.0(zero total movement), the index defaults to thesafezerovalue.
- If
safezero = 50.0: If an asset is suspended, illiquid, or trades at exactly the same closing price forNconsecutive bars, neither gains nor losses exist. Assigning 50.0 ensures the oscillator remains in neutral territory without falsely triggering overbought (>70) or oversold (<30) execution thresholds.
Summary checklist for indicator reproducibility
When migrating trading strategies between research environments or auditing discrepancies between backtest and production feeds, follow this structured verification checklist:
flowchart TD
A["Indicator Discrepancy Detected"] --> B{"1. Check Smoothing Kernel"}
B -->|"alpha = 1/N"| C["Wilder RMA (Default bt.indicators.RSI)"]
B -->|"alpha = 2/(N+1)"| D["Standard EMA (bt.indicators.RSI_EMA)"]
B -->|"Rolling SMA"| E["Cutler RSI (bt.indicators.RSI_SMA)"]
C --> F{"2. Inspect Warm-Up Buffer"}
D --> F
E --> G["No warm-up needed beyond N bars"]
F -->|"< 100 bars"| H["Seed drift present: Prepend 200+ historical bars"]
F -->|">= 200 bars"| I{"3. Verify Seed Arithmetic"}
I -->|"SMA of first N diffs"| J["Matches Wilder 1978 Textbook & Backtrader"]
I -->|"Padded zero seed"| K["Align seed method with engine specifications"]
J --> L{"4. Check Division-by-Zero Guards"}
L -->|"safediv=True, safezero=50.0"| M["Reproducible, Production-Ready Signals"]Step-by-step audit checklist
- Confirm the smoothing kernel:
Verify whether your platform uses Wilder's RMA (
\alpha = 1/N), standard EMA (\alpha = 2/(N+1)), or rolling SMA (Cutler). If you rely on Backtrader, remember thatbt.indicators.RSIdefaults to Wilder's RMA. - Prepend sufficient warm-up bars:
Never evaluate an IIR indicator like RSI immediately after the seed bar. For an
N = 14lookback, feed at least 200 to 250 historical bars prior to the strategy start date to decay initial seed variance below10^{-6}. - Verify initial seed arithmetic:
Ensure the first smoothed value uses a simple arithmetic mean over the initial
Nbars rather than arbitrary zero-padding. - Audit boundary clamping:
Verify that consecutive limit-up days clamp to 100.0, consecutive limit-down days clamp to 0.0, and flat trading sessions evaluate to neutral 50.0 without triggering
ZeroDivisionError. - Enforce causal execution boundaries:
Ensure your backtest calculates RSI on bar
t's finalized close and executes trades strictly on bart+1's open or subsequent bars. Evaluating signals and executing fills within the same bar introduces lookahead bias.
Reproducibility in modern research engines: OpenBacktesting
For quantitative researchers building production backtesting pipelines, managing manual indicator warm-ups and legacy object lifecycles can introduce friction. Modern bar-by-bar research frameworks address this through decoupled architectures:
- In OpenBacktesting (v0.2.1), technical indicators are computed ahead of the simulation loop using standardized vectorized engines (such as
pandas-ta-classic), guaranteeing transparent mathematical parity with industry baselines. - Single-asset execution proceeds strictly bar-by-bar, preventing lookahead leakage and enforcing precise fill modeling across market, limit, stop, stop-limit, trailing stop, and OCO orders.
- Order execution logs, trade histories, and slippage data are exported directly into immutable pandas DataFrames, making numerical discrepancies inspectable without complex custom logging.
Further reading and resources
- Backtrader Tutorial: Build and Inspect Your First Strategy — Step-by-step guide to the Cerebro lifecycle, line iterators, and event-driven architecture.
- Lookahead Bias in Backtesting: Detection, Mathematical Proofs, and Prevention — Learn how premature indicator access corrupts historical performance metrics.
- Python Backtesting Libraries: A Quantitative Engineering Guide — Architectural comparison of modern Python backtesting frameworks.
- OpenBacktesting Documentation — Official documentation and API reference for reproducible, bar-by-bar quant research.
