RSI mean reversion
A study of Welles Wilder's RSI, with his smoothing and a compact 30, 50 and 70 state machine. Educational only, not a signal.
Educational study of a published rule. It is not a signal, a recommendation, or a forecast. The series is the ECB daily US dollar/euro reference rate from 2 January 2020 to 31 December 2024, one rate per TARGET business day, copied into open, high, low and close. Source: ECB statistics. The study charges a spread of 0.01 percent of the rate, a fraction of 0.0001, once, against the trader. Commission is zero. Swap and slippage are not modelled. The script does not print a return. Past performance does not predict future results.
Idea
The relative strength index compares the recent upward changes in a series with the recent downward changes, and scales that ratio to a number from 0 to 100. This study uses period 14. It wants to be long when RSI is at or below 30, and short when RSI is at or above 70. It leaves a long once RSI has recovered to 50 or more, and leaves a short once RSI has fallen to 50 or less, unless the observation is still beyond the outer level.
Between 30 and 50 a long is held. Between 50 and 70 a short is held. A reading in the middle, with no position open, does not open one. The rule does not scale in. One position, then flat, then perhaps the other side.
Wilder’s average, not a simple mean of the whole window
J. Welles Wilder Jr. introduced RSI in New Concepts in Technical Trading Systems (1978). The calculation this code uses is the one StockCharts documents for that index. The first average gain is the sum of the gains over the first 14 changes, divided by 14. Losses are stored as positive numbers. Every later average is (previous * 13 + current) / 14. An implementation that instead takes a simple mean of the last 14 changes is a different indicator. It is often called Cutler’s RSI. This study does not use it.
The code checks a short hand case. For prices 10, 11, 12, 11, 13 and a period of 3, the first RSI sits on the fourth price and is 100 - 100/3. The next value is 100 - 100/6. Both languages assert that before they touch the ECB file. If the average gain and the average loss are both zero, RSI is defined here as 50, because the two usual edge cases (no gains, and no losses) would otherwise disagree. StockCharts states the two edge cases separately: RSI is 0 when the average gain is zero, and 100 when the average loss is zero.
Wilder discussed tops and bottoms around 70 and 30, and he also wrote about divergence and failure swings. Those are not in this file. The state machine is a compact study of the levels, not a claim that the book reduces to this loop. The exit at 50 is part of this study so a position has a defined way to go flat. It is not a level quoted here as Wilder’s exit.
Why the seed matters, and why a trend hurts this rule
The smoothing carries the first average forward forever. Two programs with the same later prices but a different starting window will not print the same RSI. The prefix check in the run uses the same start, at the beginning of the series, so observation i does not change when rows after i are removed. It would change if the average were recomputed from a window that could see the future, or if the seed used a different slice.
The economic weakness is the one the levels create. A long is held for as long as RSI stays at or below 30, and that is exactly when a decline is still producing more down changes than up changes. The study is long while the series is falling, and it only stands aside once the changes have calmed enough for RSI to reach 50. In a range, 30 and 70 are visited and left. In a trend, they can stay visited. Nothing in the code detects which of those two markets it is in. There is no stop underneath the long.
The fill is still the next observation, widened by a spread fraction of 0.0001, with commission at zero and no swap. Python passes the signal to backtesting.py and does not read a return. C# must match the signal digest. The ECB series is one reference rate a day, copied into open, high, low and close. It is not a dealing price. At 1.10 the spread fraction moves the fill by 0.00011, as arithmetic, not as a broker’s spread.
The MQL5 sketch calls the terminal’s iRSI and replays the same levels. It does not reimplement the seed, it was not compiled, and it was not checked against the fixture. Treat it as a reading of the rule on a chart, not as a second copy of the Python numbers.
On a demo account
Write down 14, 30, 50 and 70, and write down that the average is Wilder’s, before you attach anything to a chart. Use a demo account and the smallest size. Decide from the last closed bar. A platform RSI that uses a simple 14-bar mean will not match this study even when the levels match, so check the platform’s formula rather than assuming the line on the chart is this one.
Log the RSI you calculated, the platform’s RSI, and the fill. If they differ, the usual causes are the seed, the price field (close against a typical price), and the bar that was still forming. Do not move 30 to 20 because a demo long was early. That is a new rule, fitted to a few fills. This study has no stop, so a demo that follows it can stay in while the market trends. That is the rule being demonstrated. It is not a suggestion to do it with money. Past demo fills do not predict later ones.
Implementations
The decision uses only data up to that observation. The fill is the next observation. Python and C# are run against the ECB file and must produce the same signal digest. The MQL5 file is illustrative: it was not compiled or run in MetaTrader, and a chart has high and low prices that this sample does not. The code is released under the MIT licence. The ECB file is not.
Python
#!/usr/bin/env python3
# Copyright (c) 2026 fxtradingguide.com
# SPDX-License-Identifier: MIT
"""RSI mean-reversion study.
The RSI is Welles Wilder's: the first average is a simple mean of the first
``period`` changes, and every later average is (previous * (period - 1) + current) / period.
A close with RSI at or below 30 wants to be long. A close at or above 70 wants
to be short. A long is left once RSI is back at or above 50, and a short once
it is back at or below 50. The extreme wins if both could apply. The fill is
the next observation. Nothing here is a forecast or a recommendation.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
from backtesting import Backtest
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "strategies" / "shared" / "python"))
from fxstudy.data import assert_sample, load_observations
from fxstudy.library import SignalStrategy
from fxstudy.report import (
MARGIN,
SPREAD_FRACTION,
STARTING_CASH,
assert_causal,
write_report,
)
STUDY = "rsi-mean-reversion"
PERIOD = 14
LOWER = 30.0
UPPER = 70.0
MIDLINE = 50.0
def rsi_value(average_gain: float, average_loss: float) -> float:
"""Map the two averages to RSI. Both zero (no movement) is defined as 50."""
if average_gain == 0.0 and average_loss == 0.0:
return 50.0
if average_loss == 0.0:
return 100.0
if average_gain == 0.0:
return 0.0
relative = average_gain / average_loss
return 100.0 - (100.0 / (1.0 + relative))
def wilder_rsi(close: list[float], period: int) -> list[float | None]:
if period <= 0:
raise SystemExit("RSI period must be positive")
gains = [0.0] * len(close)
losses = [0.0] * len(close)
for index in range(1, len(close)):
change = close[index] - close[index - 1]
if change > 0:
gains[index] = change
elif change < 0:
losses[index] = -change
out: list[float | None] = [None] * len(close)
if len(close) <= period:
return out
average_gain = sum(gains[1 : period + 1]) / period
average_loss = sum(losses[1 : period + 1]) / period
out[period] = rsi_value(average_gain, average_loss)
for index in range(period + 1, len(close)):
average_gain = ((average_gain * (period - 1)) + gains[index]) / period
average_loss = ((average_loss * (period - 1)) + losses[index]) / period
out[index] = rsi_value(average_gain, average_loss)
return out
def signals(
close: list[float],
period: int = PERIOD,
lower: float = LOWER,
upper: float = UPPER,
midline: float = MIDLINE,
) -> list[int | None]:
if not 0 < lower < midline < upper < 100:
raise SystemExit("RSI levels must sit in order between 0 and 100")
rsi = wilder_rsi(close, period)
position = 0
desired: list[int | None] = []
for value in rsi:
if value is None:
desired.append(None)
continue
if value <= lower:
position = 1
elif value >= upper:
position = -1
elif position == 1 and value >= midline:
position = 0
elif position == -1 and value <= midline:
position = 0
desired.append(position)
return desired
def _assert_wilder_seed() -> None:
# Period 3. Changes from [10, 11, 12, 11, 13] are +1, +1, -1, +2.
# First RSI, at index 3, averages those first three changes:
# gain (1+1+0)/3, loss (0+0+1)/3, RS = 2, RSI = 100 - 100/3.
# Next bar: gain (2/3*2 + 2)/3 = 10/9, loss (1/3*2)/3 = 2/9, RS = 5,
# RSI = 100 - 100/6.
rsi = wilder_rsi([10, 11, 12, 11, 13], 3)
if rsi[:3] != [None, None, None]:
raise SystemExit(f"RSI warmup failed: {rsi}")
if abs(rsi[3] - (100.0 - 100.0 / 3.0)) > 1e-9:
raise SystemExit(f"RSI seed failed: {rsi[3]}")
if abs(rsi[4] - (100.0 - 100.0 / 6.0)) > 1e-9:
raise SystemExit(f"RSI smoothing failed: {rsi[4]}")
def library_trade_count(dates: list[str], rates: list[float], desired: list[int | None]) -> int:
frame = pd.DataFrame(
{
"Open": rates,
"High": rates,
"Low": rates,
"Close": rates,
"Signal": [float("nan") if value is None else float(value) for value in desired],
},
index=pd.to_datetime(dates),
)
backtest = Backtest(
frame,
SignalStrategy,
cash=STARTING_CASH,
spread=SPREAD_FRACTION,
commission=0.0,
margin=MARGIN,
trade_on_close=False,
hedging=False,
exclusive_orders=True,
)
return int(backtest.run()["# Trades"])
def main() -> None:
_assert_wilder_seed()
observations = load_observations()
assert_sample(observations)
rates = [item.rate for item in observations]
assert_causal(len(rates), lambda end: signals(rates[:end]))
desired = signals(rates)
trades = library_trade_count([item.date for item in observations], rates, desired)
write_report(STUDY, "python", observations, desired, str(trades))
if __name__ == "__main__":
main()
C#
// Copyright (c) 2026 fxtradingguide.com
// SPDX-License-Identifier: MIT
using FxTradingGuide.Studies;
namespace FxTradingGuide.Strategies.RsiMeanReversion;
/// <summary>
/// Wilder RSI. The first average is a simple mean of the first <c>period</c>
/// changes. Later averages use (previous * (period - 1) + current) / period.
/// </summary>
public static class RsiMeanReversion
{
public const int Period = 14;
public const double Lower = 30.0;
public const double Upper = 70.0;
public const double Midline = 50.0;
public static double RsiValue(double averageGain, double averageLoss)
{
if (averageGain == 0.0 && averageLoss == 0.0)
{
return 50.0;
}
if (averageLoss == 0.0)
{
return 100.0;
}
if (averageGain == 0.0)
{
return 0.0;
}
var relative = averageGain / averageLoss;
return 100.0 - (100.0 / (1.0 + relative));
}
public static double?[] WilderRsi(IReadOnlyList<double> close, int period)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(period);
var gains = new double[close.Count];
var losses = new double[close.Count];
for (var index = 1; index < close.Count; index++)
{
var change = close[index] - close[index - 1];
if (change > 0)
{
gains[index] = change;
}
else if (change < 0)
{
losses[index] = -change;
}
}
var result = new double?[close.Count];
if (close.Count <= period)
{
return result;
}
double averageGain = 0;
double averageLoss = 0;
for (var index = 1; index <= period; index++)
{
averageGain += gains[index];
averageLoss += losses[index];
}
averageGain /= period;
averageLoss /= period;
result[period] = RsiValue(averageGain, averageLoss);
for (var index = period + 1; index < close.Count; index++)
{
averageGain = ((averageGain * (period - 1)) + gains[index]) / period;
averageLoss = ((averageLoss * (period - 1)) + losses[index]) / period;
result[index] = RsiValue(averageGain, averageLoss);
}
return result;
}
public static int?[] Signals(
IReadOnlyList<double> close,
int period = Period,
double lower = Lower,
double upper = Upper,
double midline = Midline)
{
if (!(0 < lower && lower < midline && midline < upper && upper < 100))
{
throw new ArgumentOutOfRangeException(nameof(lower), "RSI levels must sit in order between 0 and 100");
}
var rsi = WilderRsi(close, period);
var position = 0;
var desired = new int?[close.Count];
for (var index = 0; index < rsi.Length; index++)
{
if (rsi[index] is not double value)
{
desired[index] = null;
continue;
}
if (value <= lower)
{
position = 1;
}
else if (value >= upper)
{
position = -1;
}
else if (position == 1 && value >= midline)
{
position = 0;
}
else if (position == -1 && value <= midline)
{
position = 0;
}
desired[index] = position;
}
return desired;
}
public static void AssertFixture()
{
var rsi = WilderRsi(new[] { 10d, 11d, 12d, 11d, 13d }, 3);
if (rsi[0] is not null || rsi[1] is not null || rsi[2] is not null)
{
throw new InvalidOperationException("RSI warmup failed");
}
if (rsi[3] is not double seed || Math.Abs(seed - (100.0 - (100.0 / 3.0))) > 1e-9)
{
throw new InvalidOperationException("RSI seed failed");
}
if (rsi[4] is not double next || Math.Abs(next - (100.0 - (100.0 / 6.0))) > 1e-9)
{
throw new InvalidOperationException("RSI smoothing failed");
}
}
}
public static class Program
{
public static void Main()
{
RsiMeanReversion.AssertFixture();
var observations = SampleSeries.Load();
var rates = observations.Select(item => item.Rate).ToArray();
StudyReport.AssertCausal(rates.Length, end =>
{
var slice = new double[end];
Array.Copy(rates, slice, end);
return RsiMeanReversion.Signals(slice);
});
StudyReport.Write("rsi-mean-reversion", observations, RsiMeanReversion.Signals(rates));
}
}
MQL5 — Illustrative. This file was not compiled or run in MetaTrader. It calls the terminal iRSI and does not reimplement Wilder's seed, so it was not checked against the Python fixture.
//+------------------------------------------------------------------+
//| RsiMeanReversion.mq5 |
//| Illustrative only. This file was not compiled or run. |
//| Copyright (c) 2026 fxtradingguide.com |
//| SPDX-License-Identifier: MIT |
//| https://fxtradingguide.com/strategies/rsi-mean-reversion/ |
//+------------------------------------------------------------------+
#property copyright "Copyright (c) 2026 fxtradingguide.com"
#property link "https://fxtradingguide.com/strategies/rsi-mean-reversion/"
#property version "1.00"
#property description "Educational RSI study. Not a signal."
#property description "Uses the terminal iRSI. It does not reimplement Wilder's seed."
#property description "Orders are refused unless the account is a demo account."
#include <Trade/Trade.mqh>
input int RsiPeriod = 14;
input double Lower = 30.0;
input double Upper = 70.0;
input double Midline = 50.0;
input int Lookback = 400;
input double Lots = 0.01;
CTrade trade;
int rsiHandle = INVALID_HANDLE;
datetime lastClosedBar = 0;
int OnInit()
{
if(AccountInfoInteger(ACCOUNT_TRADE_MODE) != ACCOUNT_TRADE_MODE_DEMO)
{
Print("Refusing to run: this study sends orders only on a demo account.");
return INIT_FAILED;
}
if(RsiPeriod <= 0 || !(0.0 < Lower && Lower < Midline && Midline < Upper && Upper < 100.0) || Lots <= 0.0)
return INIT_PARAMETERS_INCORRECT;
rsiHandle = iRSI(_Symbol, PERIOD_CURRENT, RsiPeriod, PRICE_CLOSE);
if(rsiHandle == INVALID_HANDLE)
{
Print("iRSI failed. Error ", GetLastError());
return INIT_FAILED;
}
return INIT_SUCCEEDED;
}
void OnDeinit(const int reason)
{
if(rsiHandle != INVALID_HANDLE)
IndicatorRelease(rsiHandle);
}
void OnTick()
{
datetime closed = iTime(_Symbol, PERIOD_CURRENT, 1);
if(closed == 0 || closed == lastClosedBar)
return;
lastClosedBar = closed;
int target = 0;
if(!DesiredPosition(target))
return;
AlignDemoPosition(target);
}
bool DesiredPosition(int &target)
{
int available = BarsCalculated(rsiHandle);
int count = MathMin(Lookback, available - 1);
if(count < RsiPeriod + 2)
return false;
double rsi[];
ArraySetAsSeries(rsi, false);
if(CopyBuffer(rsiHandle, 0, 1, count, rsi) != count)
return false;
// Replay the levels across closed bars. The terminal supplies iRSI, so this
// is not the Python fixture and was not compared with it.
int position = 0;
for(int i = 0; i < count; i++)
{
double value = rsi[i];
if(value <= Lower)
position = 1;
else if(value >= Upper)
position = -1;
else if(position == 1 && value >= Midline)
position = 0;
else if(position == -1 && value <= Midline)
position = 0;
}
target = position;
return true;
}
int OpenDirection()
{
if(!PositionSelect(_Symbol))
return 0;
long type = PositionGetInteger(POSITION_TYPE);
if(type == POSITION_TYPE_BUY)
return 1;
if(type == POSITION_TYPE_SELL)
return -1;
return 0;
}
void AlignDemoPosition(const int target)
{
int current = OpenDirection();
if(current == target)
return;
if(current != 0 && !trade.PositionClose(_Symbol))
{
Print("Demo close was not accepted: ", trade.ResultRetcodeDescription());
return;
}
if(target == 1)
trade.Buy(Lots);
else if(target == -1)
trade.Sell(Lots);
}
The order adapter and the shared report live next to the rule, so the page above is the decision and the links below are the fill.
Glossary
These are the directory’s definitions for words this study uses. They are not part of the rule, and they are not a recommendation.
Software catalogue
The Python file imports backtesting.py. MetaTrader 5 itself is in the platform directory below. The catalogue also lists the MetaTrader 5 Python package and the MQL5 Code Base. This study does not import that package, and the MQL5 file is not a Code Base entry.
Published broker costs
The spread in this study is one assumed fraction of the reference rate, applied on every fill. Swap is not modelled, and slippage is not measured. Firms publish their own spread, commission and financing figures, and those figures are not this assumption. The broker comparison sets the published figures side by side, with the source and the last-checked date. It does not score or rank firms, and it is not a recommendation.
Related guides
Quoting units such as pips, lots and leverage are explained in How FX trading works. What MetaTrader 4, MetaTrader 5, cTrader and TradingView are each for is in the platform guide. Neither page is a recommendation, and neither changes this study.
Sources and last review
Last reviewed 10 November 2026.
- J. Welles Wilder Jr., New Concepts in Technical Trading Systems, Trend Research, 1978. The book introduces the relative strength index.
- StockCharts ChartSchool, Relative Strength Index. Calculation notes used for the seed and the Wilder smoothing in the code.
- ECB Data Portal, euro foreign exchange reference rates, including the daily US dollar/euro series EXR.D.USD.EUR.SP00.A
- ECB, policy on reuse of ESCB statistics
- backtesting.py 0.6, Backtest constructor: spread, commission, and trade_on_close
The code on this page is released under the MIT licence. The ECB series is not covered by that licence. Source: ECB statistics.