173 lines
6.5 KiB
Python
173 lines
6.5 KiB
Python
"""RSI(2) mean-reversion oversold-bounce scanner.
|
||
|
||
Based on Larry Connors' 2-period RSI strategy (backtested 25 years, 75-79% win
|
||
rate) and Lehmann (1990) academic evidence for short-term weekly return reversals
|
||
(+0.86% to +1.24% per week for prior-week losers).
|
||
|
||
Signal: RSI(2) < 10 AND price above 200-day SMA.
|
||
The 200-day SMA filter is the critical guard against catching falling knives —
|
||
in persistent downtrends RSI(2) can stay oversold for weeks. With the trend
|
||
filter in place, the signal captures temporary pullbacks within an ongoing uptrend.
|
||
|
||
Expected holding period: 3–7 days. Exit target: RSI(2) > 90 or close above 5-day SMA.
|
||
|
||
Research: docs/iterations/research/2026-04-15-rsi-mean-reversion.md
|
||
"""
|
||
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
import pandas as pd
|
||
|
||
from tradingagents.dataflows.data_cache.ohlcv_cache import download_ohlcv_cached
|
||
from tradingagents.dataflows.discovery.scanner_registry import SCANNER_REGISTRY, BaseScanner
|
||
from tradingagents.dataflows.discovery.utils import Priority
|
||
from tradingagents.dataflows.universe import load_universe
|
||
from tradingagents.utils.logger import get_logger
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
|
||
class RSIOversoldScanner(BaseScanner):
|
||
"""Scan for stocks with RSI(2) deeply oversold while price remains above 200-day SMA.
|
||
|
||
Contrarian mean-reversion signal — the only non-momentum scanner in the pipeline.
|
||
Identifies short-term panic selloffs within broader uptrends where a 3–7 day bounce
|
||
is statistically expected.
|
||
|
||
Data requirement: ~210 trading days of OHLCV (200d SMA + RSI buffer).
|
||
Cost: single batch yfinance download via shared OHLCV cache, zero per-ticker API calls.
|
||
"""
|
||
|
||
name = "rsi_oversold"
|
||
pipeline = "mean_reversion"
|
||
strategy = "mean_reversion_bounce"
|
||
|
||
def __init__(self, config: Dict[str, Any]):
|
||
super().__init__(config)
|
||
self.max_rsi = self.scanner_config.get("max_rsi", 10.0)
|
||
self.sma_period = self.scanner_config.get("sma_period", 200)
|
||
self.rsi_period = self.scanner_config.get("rsi_period", 2)
|
||
self.min_price = self.scanner_config.get("min_price", 5.0)
|
||
self.min_avg_volume = self.scanner_config.get("min_avg_volume", 100_000)
|
||
self.vol_avg_days = self.scanner_config.get("vol_avg_days", 20)
|
||
self.max_tickers = self.scanner_config.get("max_tickers", 0) # 0 = no cap
|
||
|
||
def scan(self, state: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||
if not self.is_enabled():
|
||
return []
|
||
|
||
logger.info(f"📉 Scanning RSI({self.rsi_period}) oversold bounce (RSI < {self.max_rsi})...")
|
||
|
||
tickers = load_universe(self.config)
|
||
if not tickers:
|
||
logger.warning("No tickers loaded for RSI oversold scan")
|
||
return []
|
||
|
||
if self.max_tickers:
|
||
tickers = tickers[: self.max_tickers]
|
||
|
||
cache_dir = self.config.get("discovery", {}).get("ohlcv_cache_dir", "data/ohlcv_cache")
|
||
logger.info(f"Loading OHLCV for {len(tickers)} tickers from cache...")
|
||
data = download_ohlcv_cached(tickers, period="1y", cache_dir=cache_dir)
|
||
|
||
if not data:
|
||
return []
|
||
|
||
candidates = []
|
||
for ticker, df in data.items():
|
||
result = self._check_rsi_oversold(df)
|
||
if result:
|
||
result["ticker"] = ticker
|
||
candidates.append(result)
|
||
|
||
# Sort by RSI ascending — most oversold (lowest RSI) gets highest rank
|
||
candidates.sort(key=lambda c: c.get("_rsi_value", 99))
|
||
# Strip internal sort key
|
||
for c in candidates:
|
||
c.pop("_rsi_value", None)
|
||
|
||
candidates = candidates[: self.limit]
|
||
logger.info(f"RSI oversold bounce: {len(candidates)} candidates")
|
||
return candidates
|
||
|
||
def _check_rsi_oversold(self, df: pd.DataFrame) -> Optional[Dict[str, Any]]:
|
||
"""Compute RSI(2) and 200-day SMA; return candidate dict if signal fires."""
|
||
try:
|
||
df = df.dropna(subset=["Close", "Volume"])
|
||
|
||
# Need at least sma_period + rsi_period + a buffer
|
||
min_rows = self.sma_period + self.rsi_period + 5
|
||
if len(df) < min_rows:
|
||
return None
|
||
|
||
close = df["Close"]
|
||
volume = df["Volume"]
|
||
|
||
current_close = float(close.iloc[-1])
|
||
|
||
# --- Liquidity gates ---
|
||
avg_vol = float(volume.iloc[-(self.vol_avg_days + 1) : -1].mean())
|
||
if avg_vol < self.min_avg_volume:
|
||
return None
|
||
if current_close < self.min_price:
|
||
return None
|
||
|
||
# --- 200-day SMA trend filter ---
|
||
sma200 = float(close.iloc[-self.sma_period :].mean())
|
||
if current_close <= sma200:
|
||
return None # Price below 200d SMA → falling knife risk, skip
|
||
|
||
# --- RSI(2) calculation ---
|
||
# Use the last rsi_period+1 closes to compute one RSI value
|
||
rsi_window = close.iloc[-(self.rsi_period + 2) :].values
|
||
if len(rsi_window) < self.rsi_period + 1:
|
||
return None
|
||
|
||
deltas = [rsi_window[i] - rsi_window[i - 1] for i in range(1, len(rsi_window))]
|
||
gains = [max(d, 0.0) for d in deltas]
|
||
losses = [abs(min(d, 0.0)) for d in deltas]
|
||
|
||
avg_gain = sum(gains[-self.rsi_period :]) / self.rsi_period
|
||
avg_loss = sum(losses[-self.rsi_period :]) / self.rsi_period
|
||
|
||
if avg_loss == 0:
|
||
rsi = 100.0 # No losses → not oversold
|
||
else:
|
||
rs = avg_gain / avg_loss
|
||
rsi = 100.0 - (100.0 / (1.0 + rs))
|
||
|
||
if rsi >= self.max_rsi:
|
||
return None
|
||
|
||
# --- Priority ---
|
||
if rsi < 5.0:
|
||
priority = Priority.CRITICAL.value
|
||
elif rsi < 8.0:
|
||
priority = Priority.HIGH.value
|
||
else:
|
||
priority = Priority.MEDIUM.value
|
||
|
||
pct_above_sma = ((current_close - sma200) / sma200) * 100
|
||
|
||
context = (
|
||
f"RSI({self.rsi_period}) oversold at {rsi:.1f} | "
|
||
f"Price ${current_close:.2f} above 200d SMA ${sma200:.2f} "
|
||
f"(+{pct_above_sma:.1f}%) | "
|
||
f"3-7d mean-reversion bounce setup"
|
||
)
|
||
|
||
return {
|
||
"source": self.name,
|
||
"context": context,
|
||
"priority": priority,
|
||
"strategy": self.strategy,
|
||
"_rsi_value": round(rsi, 2),
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.debug(f"RSI oversold check failed: {e}")
|
||
return None
|
||
|
||
|
||
SCANNER_REGISTRY.register(RSIOversoldScanner)
|