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)
windowintLookback for gain/loss averaging. Wilder's classic default is 14.
marketstr | NoneOptional 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 = 100when all bars in the window were upRSI = 0when all bars were downRSI = 50when gains and losses balance- Classic thresholds:
> 70overbought,< 30oversold
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