TradingAgents/tradingagents/dataflows/discovery/scanners/rsi_oversold.py

173 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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: 37 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 37 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)