RSIMeanRev

RSI-based mean reversion. buy oversold, sell overbought

RSIMeanRev trades against Wilder’s Relative Strength Index extremes. When RSI drops below the oversold threshold, it goes long. When RSI rises above overbought, it goes short.

Import

python
from horizon.quant import RSIMeanRev

Signature

python
RSIMeanRev(
    window: int = 14,
    oversold: float = 30.0,
    overbought: float = 70.0,
    vol_window: int = 60,
    edge_bps: float = 35.0,
    horizon_days: int = 3,
)
windowint
RSI lookback (the classic Wilder default is 14). Must be ≥ 2.
oversoldfloat
Threshold below which the strategy goes long. Lower = more selective.
overboughtfloat
Threshold above which the strategy goes short. Higher = more selective.
vol_windowint
Window for `RealizedVol` used to populate expected vol on emitted signals.
edge_bpsfloat
Expected edge per $1 notional in bps.
horizon_daysint
Expected holding horizon.

Features it uses

python
features = {
    "rsi": RSI(window=window),
    "vol": RealizedVol(window=vol_window),
}

See RSI for the Wilder’s RSI math.

Signal logic

python
def evaluate(self, f, universe):
    signals = []
    horizon = timedelta(days=self.horizon_days)
    for m in universe:
        rsi = f.rsi[m.id]
        vol = f.vol[m.id]
        if isnan(rsi):
            continue

        vol_bps = max((vol if not isnan(vol) else 0.20) * 10_000, 50)

        if rsi < self.oversold:
            signals.append(Signal(
                market_id=m.id,
                direction=Direction.Increase,
                confidence=min(1.0, (self.oversold - rsi) / 30),
                expected_edge_bps=self.edge_bps,
                expected_expected_vol_bps=vol_bps,
                horizon=horizon,
                reason=f"RSI={rsi:.1f} oversold",
            ))
        elif rsi > self.overbought:
            signals.append(Signal(
                market_id=m.id,
                direction=Direction.Decrease,
                confidence=min(1.0, (rsi - self.overbought) / 30),
                expected_edge_bps=self.edge_bps,
                expected_expected_vol_bps=vol_bps,
                horizon=horizon,
                reason=f"RSI={rsi:.1f} overbought",
            ))
    return signals
  • RSI under 30 → long (bet on bounce)
  • RSI > 70 → short (bet on pullback)
  • Confidence scales with how deeply RSI has moved past the threshold
  • No signal in the 30-70 dead zone

Use it

python
import horizon as hz
from horizon.asset_classes import AssetClass, Equity
from horizon.data import SyntheticGBM
from horizon.discovery import StaticUniverse
from horizon.discovery.base import Market
from horizon.portfolio import KellyOptimizer
from horizon.quant import RSIMeanRev
from horizon.risk import RiskProfile

result = hz.run(
    mode="backtest",
    strategies=[RSIMeanRev(window=14, oversold=30, overbought=70, edge_bps=40)],
    asset_classes=[Equity],
    universe=StaticUniverse([
        Market(id=t, asset_class=AssetClass.Equity)
        for t in ["AAPL", "MSFT", "NVDA"]
    ]),
    portfolio=KellyOptimizer(kelly_fraction=0.25, max_gross_leverage=1.0),
    risk=RiskProfile.moderate(),
    data_source=SyntheticGBM(["AAPL", "MSFT", "NVDA"], n_bars=252, seed=42),
    backtest=hz.BacktestConfig(initial_cash_usd=100_000),
)

When to use

Range-bound equities RSI mean reversion works best when there's no strong directional bias: the market oscillates and RSI swings back toward 50 reliably.
High-beta names after a pullback Classic use case: a high-beta stock drops 5% on noise and RSI hits 25. The strategy buys the bounce.

When NOT to use

Parameter tuning

Risk companions

Same as BollingerMeanRev: mean reversion needs tight stops:

python
from horizon.risk import RiskConfig, StopLoss, TimeStop

risk = RiskConfig(
    stop_loss=StopLoss(per_position_pct=0.05),
    max_gross_leverage=1.0,
)

A time stop is especially useful here:

python
from datetime import timedelta
stop_loss = StopLoss(per_position_pct=0.05)
time_stop = TimeStop(max_hold_time=timedelta(days=5))

If the bounce doesn’t happen in 5 days, close the position. Mean reversion that doesn’t revert within the expected horizon is not mean reversion. it’s a new trend against you.

Ensemble with MovingAverageCross

A classic pairing is RSI mean reversion + MA cross trend-following:

python
from horizon.quant import Ensemble, MovingAverageCrossStrategy, RSIMeanRev

strategy = Ensemble(
    strategies=[
        MovingAverageCrossStrategy(fast=10, slow=30),
        RSIMeanRev(window=14, oversold=30, overbought=70),
    ],
    weights=[0.5, 0.5],
    name="trend_plus_rsi",
)

MA cross catches trends; RSI catches counter-trend bounces within those trends. Natural regime diversification.

Pitfalls

Source

horizon/quant/timeseries.py. ~40 lines.

Next