RSI

Wilder's Relative Strength Index

RSI computes Wilder’s Relative Strength Index: a bounded oscillator in [0, 100] that measures the balance of gains vs losses over a rolling window. It’s one of the most widely used technical indicators for mean reversion.

Import

python
from horizon.features import RSI

Signature

python
RSI(window: int = 14, market: str | None = None)
windowint
Lookback for gain/loss averaging. Wilder's classic default is 14.
marketstr | None
Optional pin.

Math (Wilder’s formulation)

1. Compute per-bar price differences
2. Split into gains (positive diffs) and losses (|negative diffs|)
3. Average gains:    avg_gain = Σ gains   / window
4. Average losses:   avg_loss = Σ losses  / window
5. Relative strength: RS = avg_gain / avg_loss
6. RSI = 100 - (100 / (1 + RS))

Ranges:

  • RSI = 100 when all bars in the window were up
  • RSI = 0 when all bars were down
  • RSI = 50 when gains and losses balance
  • Classic thresholds: > 70 overbought, < 30 oversold

Behavior

python
def compute(self, market_id, history, feeds):
    prices = history.last_n_prices(self.window + 1)
    if len(prices) < self.window + 1:
        return float("nan")
    diffs = [prices[i] - prices[i-1] for i in range(1, len(prices))]
    gains = [d if d > 0 else 0.0 for d in diffs]
    losses = [-d if d < 0 else 0.0 for d in diffs]
    avg_gain = sum(gains) / self.window
    avg_loss = sum(losses) / self.window
    if avg_loss == 0:
        return 100.0 if avg_gain > 0 else 50.0
    rs = avg_gain / avg_loss
    return 100.0 - (100.0 / (1.0 + rs))

Interpretation

RSI > 70 Overbought. Classical interpretation: a reversal to the downside is likely.
RSI < 30 Oversold. Classical interpretation: a bounce is likely.
RSI = 50 Neutral. gains and losses balance.
RSI divergence Price makes a new high but RSI doesn't. often a warning signal of trend exhaustion.

Use cases

Classic mean reversion

python
from horizon import Strategy, Signal
from horizon.asset_classes import Equity
from horizon.features import RSI

class RSIContrarian(Strategy):
    asset_classes = [Equity]
    features = {"rsi": RSI(window=14)}

    def evaluate(self, f, universe):
        signals = []
        for m in universe:
            rsi = f.rsi[m.id]
            if rsi < 30:
                signals.append(Signal.increase(m, edge_bps=40, horizon="3d"))
            elif rsi > 70:
                signals.append(Signal.decrease(m, edge_bps=40, horizon="3d"))
        return signals

This is the basic RSIMeanRev from horizon.quant.

RSI divergence filter

python
features = {
    "rsi": RSI(window=14),
    "r20": Return(window=20),
}

def evaluate(self, f, universe):
    # Price up 5% but RSI below 50. divergence
    return [
        Signal.decrease(m, edge_bps=40)
        for m in universe
        if f.r20[m.id] > 0.05 and f.rsi[m.id] < 50
    ]

Divergences are subtle and require multi-tick comparison; a simple evaluator can’t catch the full pattern. Consider storing history in a stateful strategy.

Multi-timeframe RSI

python
features = {
    "rsi_fast": RSI(window=7),
    "rsi_slow": RSI(window=21),
}

def evaluate(self, f, universe):
    # Both timeframes agree on oversold
    return [
        Signal.increase(m, edge_bps=50)
        for m in universe
        if f.rsi_fast[m.id] < 30 and f.rsi_slow[m.id] < 40
    ]

Pitfalls

Tests

python
# tests/test_features.py::TestRSI

def test_all_gains_near_100(self) -> None:
    store = FeatureStore()
    for i in range(20):
        store.update_feeds({"T": FeedData(market_id="T", price=100.0 + i)})
    rsi = RSI(window=14)
    ns = store.compute({"r": rsi}, ["T"], {})
    assert ns.r["T"] > 95.0   # all gains → RSI near 100

def test_all_losses_near_0(self) -> None:
    store = FeatureStore()
    for i in range(20):
        store.update_feeds({"T": FeedData(market_id="T", price=120.0 - i)})
    rsi = RSI(window=14)
    ns = store.compute({"r": rsi}, ["T"], {})
    assert ns.r["T"] < 5.0   # all losses → RSI near 0

Next