← Back to list

Does RSI Actually Know Where the Turns Are?

Scoring a classic reversal signal against a random baseline — before touching a single backtest

Kryptera · 2026-09-06 12:01 · 6 claps · 7.1 min read paywalled
#algorithmic-trading #python #trading #finance #technical-indicator
Open on Medium ↗
Wiki topics: ECO · Economy · General 💻 · Programming

Does RSI Actually Know Where the Turns Are?

Scoring a classic reversal signal against a random baseline — before touching a single backtest

Inspired by Sofien Kaabar, CFA’s “How to Properly Judge Reversal Strategies,” which introduced the Extrema Precision Index (EPI 2.0) framework this piece builds on. This article is an independent implementation and test of that idea on SPY — not a summary of the original.

Every retail trader has, at some point, watched RSI dip under 30 and thought: there it is, the bottom. Sometimes it is. Often it isn’t. The problem is that we usually only find out which one it was by trading it — and by the time the equity curve tells us, profit and loss has already mixed together a dozen different things: signal quality, position sizing, exit timing, risk management, the regime the market happened to be in.

So before running RSI through a backtest, I wanted to ask a narrower, cleaner question first:

When RSI fires a reversal signal, how close does it actually land to a real market turning point — and is that better than chance?

This is a diagnostic question, not a trading question. It doesn’t care whether the signal made money. It only cares whether the signal understands market structure at all. If it doesn’t, no amount of stop-loss tuning or position sizing is going to fix that; you’d just be optimizing noise.

Separating “did it win” from “was it smart”

Two traders can use the identical RSI setup. One loses money, one makes money. That tells you almost nothing about whether the signal itself has any structural skill — one trader might have terrible risk management, the other might just be riding a trend that had nothing to do with RSI.

Sharpe ratio and win rate can’t untangle that. They’re downstream of everything: sizing, timing, luck. To isolate the signal, you need to stop asking “did trades win” and start asking “how close were the signals to actual highs and lows.”

Building the scorecard

I ran this on SPY daily data going back to 2003, using the classic setup: RSI(14), signal fires when RSI crosses back out of overbought (70) or oversold (30). Nothing exotic — the same indicator most people already have running on a chart somewhere.

The scoring has four parts:

1. Turning points are defined objectively. A swing high or low is anything that is the highest high or lowest low within a window on both sides of it. I tested three window sizes — 5, 10, and 20 bars — so the diagnostic isn’t tuned to one arbitrary scale.

2. Distance is continuous, not hit-or-miss. Instead of asking “did the signal land within X bars, yes or no,” each signal gets a score between 0 and 1 based on how far it sits from the nearest turning point. A signal one bar away scores much higher than one ten bars away, rather than both being lumped into the same “miss” bucket.

3. Distance is volatility-adjusted. Five bars during a calm summer chop is not the same gap as five bars during a March 2020-style unwind. The raw bar-distance gets scaled by the market’s local realized volatility, so the score means the same thing across regimes.

4. Random signals are the benchmark. This is the part that actually matters. Markets naturally cluster highs and lows — a coin-flip signal will sometimes look deceptively good just because turning points are common. So for every horizon, I generated 200 sets of random “signals” at the same frequency as the real RSI signals, scored those the same way, and used the average as a baseline. The gap between RSI’s score and the random baseline is what I’m calling EPI Alpha — evidence of genuine structural skill, once luck is subtracted out.

Python Full Code

import pandas as pd
import numpy as np
import yfinance as yf
import matplotlib.pyplot as plt

# -------------------------
# Download Data
# -------------------------

symbol = "SPY"
start_date = "2003-01-01"
end_date = "2030-01-01"
interval = "1d"

df = yf.download(symbol, start=start_date, end=end_date, interval=interval, multi_level_index=False)
df.reset_index(inplace=True)
df['Date'] = pd.to_datetime(df['Date'])
df.set_index('Date', inplace=True)
df = df.dropna(subset=['Open', 'High', 'Low', 'Close']).copy()

# -------------------------
# Parameters
# -------------------------

RSI_PERIOD = 14
RSI_OVERBOUGHT = 70
RSI_OVERSOLD = 30

TURNING_POINT_HORIZONS = [5, 10, 20]   # test several scales at once, per the article
VOL_WINDOW = 20                        # rolling window for volatility normalization
N_RANDOM_TRIALS = 200                  # Monte Carlo trials for the random benchmark
DISTANCE_SENSITIVITY = 0.01            # controls how fast the proximity score decays

np.random.seed(42)

# -------------------------
# 1. Classic reversal signal: RSI overbought/oversold
#    (this is the exact indicator the article back-tests)
# -------------------------

delta = df['Close'].diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
avg_gain = gain.rolling(RSI_PERIOD).mean()
avg_loss = loss.rolling(RSI_PERIOD).mean()
rs = avg_gain / avg_loss
df['rsi'] = 100 - (100 / (1 + rs))

# A reversal "fires" when RSI crosses back out of an extreme zone
df['signal_short'] = (df['rsi'].shift(1) >= RSI_OVERBOUGHT) & (df['rsi'] < RSI_OVERBOUGHT)
df['signal_long'] = (df['rsi'].shift(1) <= RSI_OVERSOLD) & (df['rsi'] > RSI_OVERSOLD)
df['signal'] = df['signal_long'] | df['signal_short']

# -------------------------
# 2. Objectively defined turning points (swing highs/lows)
# -------------------------

def find_turning_points(high, low, horizon):
    n = len(high)
    is_high = np.zeros(n, dtype=bool)
    is_low = np.zeros(n, dtype=bool)
    for i in range(horizon, n - horizon):
        w_high = high[i - horizon:i + horizon + 1]
        w_low = low[i - horizon:i + horizon + 1]
        if high[i] == w_high.max():
            is_high[i] = True
        if low[i] == w_low.min():
            is_low[i] = True
    return is_high, is_low

# -------------------------
# 3. Volatility measure used to normalize distance
#    (close-to-close realized vol, expressed in price terms)
# -------------------------

df['vol'] = df['Close'].pct_change().rolling(VOL_WINDOW).std() * df['Close']
df['vol'] = df['vol'].bfill()
df['vol'] = df['vol'].fillna(df['vol'].mean())

close_arr = df['Close'].values
vol_arr = df['vol'].values

# -------------------------
# 4. Continuous proximity scoring
#    score = exp(-adjusted_distance): 1 when a signal sits right
#    on a turning point, decaying smoothly (not a binary hit/miss)
#    the further away the nearest turning point is. Distance is
#    scaled by *relative* local volatility, so 5 quiet-market bars
#    aren't treated the same as 5 chaotic-market bars.
# -------------------------

def score_signals(signal_idx, turning_idx):
    if len(signal_idx) == 0 or len(turning_idx) == 0:
        return np.array([])
    scores = np.empty(len(signal_idx))
    for j, i in enumerate(signal_idx):
        bar_dist = np.min(np.abs(turning_idx - i))
        rel_vol = vol_arr[i] / close_arr[i] if close_arr[i] != 0 else 1e-6
        rel_vol = max(rel_vol, 1e-6)
        adj_dist = bar_dist * (DISTANCE_SENSITIVITY / rel_vol)
        scores[j] = np.exp(-adj_dist)
    return scores

# -------------------------
# 5. Run the diagnostic across multiple horizons
# -------------------------

results = []
signal_idx_all = np.where(df['signal'].values)[0]

for horizon in TURNING_POINT_HORIZONS:
    swing_high, swing_low = find_turning_points(df['High'].values, df['Low'].values, horizon)
    turning_idx = np.where(swing_high | swing_low)[0]

    valid_lo, valid_hi = horizon, len(df) - horizon
    signal_idx = signal_idx_all[(signal_idx_all >= valid_lo) & (signal_idx_all < valid_hi)]

    rsi_scores = score_signals(signal_idx, turning_idx)
    rsi_epi = rsi_scores.mean() if len(rsi_scores) else np.nan

    valid_range = np.arange(valid_lo, valid_hi)
    n_signals = len(signal_idx)

    random_epis = []
    if n_signals > 0:
        for _ in range(N_RANDOM_TRIALS):
            rand_idx = np.random.choice(valid_range, size=n_signals, replace=False)
            rand_scores = score_signals(rand_idx, turning_idx)
            random_epis.append(rand_scores.mean())

    random_mean = np.mean(random_epis) if random_epis else np.nan
    random_std = np.std(random_epis) if random_epis else np.nan
    epi_alpha = rsi_epi - random_mean if random_epis else np.nan

    results.append({
        "horizon": horizon,
        "n_signals": n_signals,
        "rsi_epi": rsi_epi,
        "random_epi_mean": random_mean,
        "random_epi_std": random_std,
        "epi_alpha": epi_alpha,
    })

results_df = pd.DataFrame(results)

# -------------------------
# 6. Report
# -------------------------

print("=" * 70)
print(f"Extrema Precision Index (EPI 2.0) Diagnostic: {symbol}")
print(f"Signal tested: RSI({RSI_PERIOD}) overbought({RSI_OVERBOUGHT})/oversold({RSI_OVERSOLD}) reversals")
print("=" * 70)
for _, row in results_df.iterrows():
    print(f"\nHorizon: {int(row['horizon'])} bars")
    print(f"  Signals tested:        {int(row['n_signals'])}")
    print(f"  RSI signal EPI score:  {row['rsi_epi']:.4f}")
    print(f"  Random EPI (mean/std): {row['random_epi_mean']:.4f} / {row['random_epi_std']:.4f}")
    print(f"  EPI Alpha:             {row['epi_alpha']:+.4f}  "
          f"({'genuine structural edge' if row['epi_alpha'] > 0 else 'no edge over randomness'})")
print("=" * 70)

# -------------------------
# 7. Plot: price with signals/turning points, and EPI Alpha by horizon
# -------------------------

fig, axes = plt.subplots(2, 1, figsize=(13, 9), gridspec_kw={"height_ratios": [2, 1]})

# Top panel: price with signals and turning points at the middle horizon
mid_horizon = TURNING_POINT_HORIZONS[len(TURNING_POINT_HORIZONS) // 2]
swing_high, swing_low = find_turning_points(df['High'].values, df['Low'].values, mid_horizon)

axes[0].plot(df.index, df['Close'], color="#1f77b4", linewidth=1, label="Close")
axes[0].scatter(df.index[swing_high], df['Close'][swing_high], color="#d62728", marker="v", s=25, label="Swing high", zorder=3)
axes[0].scatter(df.index[swing_low], df['Close'][swing_low], color="#2ca02c", marker="^", s=25, label="Swing low", zorder=3)
axes[0].scatter(df.index[df['signal_short']], df['Close'][df['signal_short']], facecolors='none', edgecolors="#d62728", s=70, linewidths=1.3, label="RSI sell signal", zorder=4)
axes[0].scatter(df.index[df['signal_long']], df['Close'][df['signal_long']], facecolors='none', edgecolors="#2ca02c", s=70, linewidths=1.3, label="RSI buy signal", zorder=4)
axes[0].set_title(f"{symbol} - RSI reversal signals vs. objective turning points (horizon={mid_horizon})")
axes[0].legend(loc="upper left", fontsize=8)
axes[0].set_ylabel("Price")

# Bottom panel: EPI alpha by horizon, with random baseline
x = np.arange(len(results_df))
axes[1].bar(x - 0.15, results_df['rsi_epi'], width=0.3, label="RSI EPI score", color="#1f77b4")
axes[1].bar(x + 0.15, results_df['random_epi_mean'], width=0.3, label="Random EPI (baseline)", color="#7f7f7f")
axes[1].errorbar(x + 0.15, results_df['random_epi_mean'], yerr=results_df['random_epi_std'], fmt='none', ecolor='black', capsize=3)
axes[1].set_xticks(x)
axes[1].set_xticklabels([f"{int(h)}d" for h in results_df['horizon']])
axes[1].set_xlabel("Turning-point horizon")
axes[1].set_ylabel("EPI score")
axes[1].set_title("RSI signal quality vs. random baseline, by horizon")
axes[1].legend(loc="upper right", fontsize=8)

plt.tight_layout()
plt.savefig("epi_diagnostic.png", dpi=150)
print("\nSaved chart to epi_diagnostic.png")

What SPY’s RSI actually showed

Horizon     RSI score     Random baseline     EPI Alpha
5 days          0.247               0.218        +0.029
10 days         0.130               0.112        +0.018
20 days         0.095               0.062        +0.034

At every horizon tested, RSI beat the random baseline. That’s the good news. The less exciting news is the size of the gap — a few hundredths, not a few tenths. RSI is doing something real; it is not doing something dramatic.

Visually, this is easy to see once you plot every RSI reversal signal against the objectively-detected swing points:

The top panel is almost uncomfortably dense — RSI(14) against 70/30 fires constantly on daily SPY, and most of those circles do sit near a red or green marker. That’s consistent with the score: proximity is common, it’s just not impressively more common than randomness would produce on its own.

The honest takeaway

RSI is not a fraud. It also isn’t a standalone reversal detector worth trusting on its own. It has a small, measurable, real edge at recognizing where a market is turning — small enough that it’s easy to imagine a poorly-managed backtest either burying that edge under fees and bad exits, or a lucky backtest making it look far stronger than the underlying signal actually is.

That’s exactly why I built this as a diagnostic before a backtest, not instead of one. Turning this into an actual strategy — entries, exits, position sizing, a trend filter to decide which side of the market is even worth taking reversal trades against — is the next step, and a separate piece of work with its own failure modes (slippage assumptions, look-ahead bias, regime dependence) that deserve their own honest accounting rather than being folded into a signal-quality question that isn’t about them at all.

For now, the finding stands on its own: the signal knows something. Whether that something survives contact with a real strategy is a different article.

This article is not investment advice but is created solely for educational purposes. Investing involves risks and volatility, and users of any trading system should carefully conduct their own research before proceeding.

Follow Me on Medium: Kryptera

Follow Me on Gumroad: Kryptera Gumroad

Follow Me on Substack: Kryptera Substack


메타데이터
post_id
51e6f4407d8d
slug
does-rsi-actually-know-where-the-turns-are-51e6f4407d8d
url
https://medium.com/@Kryptera/does-rsi-actually-know-where-the-turns-are-51e6f4407d8d
canonical_url
https://medium.com/@Kryptera/does-rsi-actually-know-where-the-turns-are-51e6f4407d8d
author_url
https://medium.com/@Kryptera
status
ok
fetched_at
2026-09-16 16:46:37