research(autonomous): 2026-04-15 — automated research run (#19)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
bcbecdeb1e
commit
5c56f8dc26
|
|
@ -20,6 +20,7 @@
|
|||
|
||||
| Title | File | Date | Summary |
|
||||
|-------|------|------|---------|
|
||||
| RSI(2) Mean Reversion Oversold Bounce | research/2026-04-15-rsi-mean-reversion.md | 2026-04-15 | Connors RSI(2)<10 + price above 200d SMA = 75-79% win rate over 25y backtest; only contrarian signal in pipeline; implemented as rsi_oversold scanner |
|
||||
| OBV Divergence Accumulation | research/2026-04-14-obv-divergence.md | 2026-04-14 | OBV rising while price flat/down = multi-week institutional accumulation; qualitative 68% win rate; implemented as obv_divergence scanner |
|
||||
| Short Interest Squeeze Scanner | research/2026-04-12-short-interest-squeeze.md | 2026-04-12 | High SI (>20%) + DTC >5 as squeeze-risk discovery; implemented as short_squeeze scanner |
|
||||
| 52-Week High Breakout Momentum | research/2026-04-13-52-week-high-breakout.md | 2026-04-13 | George & Hwang (2004) validated: 52w high crossing + 1.5x volume = 72% win rate, +11.4% avg over 31d; implemented as high_52w_breakout scanner |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
# Research: RSI(2) Mean Reversion Oversold Bounce
|
||||
|
||||
**Date:** 2026-04-15
|
||||
**Mode:** autonomous
|
||||
|
||||
## Summary
|
||||
|
||||
Larry Connors' 2-period RSI mean-reversion strategy surfaces stocks in uptrends (price
|
||||
above 200-day SMA) that have pulled back sharply enough to register RSI(2) < 10. The
|
||||
200-day SMA filter is the critical guard against catching falling knives — without it,
|
||||
the plain RSI < 30 rule fails in persistent downtrends. Academic evidence from Lehmann
|
||||
(1990) and Alpha Architect confirms weekly losers revert at 0.86–1.24% per week, with
|
||||
contrarian strategies generating >2% per month in abnormal returns. This is the only
|
||||
contrarian signal not represented anywhere in the current momentum-heavy pipeline.
|
||||
|
||||
## Sources Reviewed
|
||||
|
||||
- **QuantifiedStrategies (search results)**: RSI(2) strategy with 75–79% win rate over
|
||||
25-year backtest (2000–2025); lower RSI at entry → higher subsequent returns; profit
|
||||
factor ≈ 2.08 at best settings.
|
||||
- **Medium / FMZQuant — Larry Connors RSI2**: Exact rule: price above 200d SMA AND
|
||||
RSI(2) < 10 → buy; exit when RSI(2) > 90. Tested on DIA and individual equities.
|
||||
Described as "fairly aggressive short-term" with entry on close.
|
||||
- **StockCharts ChartSchool — RSI(2)**: Entry RSI(2) ≤ 5 (aggressive) or ≤ 10; exit
|
||||
on move above 5-day SMA or RSI(2) > 90. Volume filter: 20-day avg volume > 40k.
|
||||
Warns: "RSI(2) can remain oversold a long time in a bear" → SMA200 filter mandatory.
|
||||
- **Alpha Architect — Short-Term Return Reversal (Lehmann 1990)**: Weekly losers
|
||||
generate +0.86% to +1.24% per week in the subsequent week; contrarian strategies
|
||||
(buy losers, sell winners) produce >2%/month abnormal returns. Effect is strongest
|
||||
for liquid, actively-traded stocks.
|
||||
- **Alpha Architect — Combining Reversals + Momentum**: Reversal and momentum coexist
|
||||
at the 1-month horizon — reversal is dominant among low-turnover stocks, momentum
|
||||
among high-turnover. Filtering to high-liquidity names (min avg volume) reduces noise.
|
||||
- **WebSearch aggregate**: Connors 25-year backtest CAGR 8.2%, max drawdown 16%;
|
||||
performance degrades in prolonged bear markets (2008, Mar 2020) — SMA200 filter
|
||||
critical; best results when SPY itself is not in freefall.
|
||||
|
||||
## Cross-Reference: Existing Pipeline
|
||||
|
||||
- **No existing mean-reversion scanner.** All current scanners (minervini,
|
||||
high_52w_breakout, technical_breakout, obv_divergence, short_squeeze, insider_buying,
|
||||
options_flow, earnings_beat) are momentum- or event-driven. The RSI oversold bounce
|
||||
is fully orthogonal.
|
||||
- **technical_breakout** (scanners/technical_breakout.md): targets resistance breakouts,
|
||||
opposite signal direction. No overlap.
|
||||
- **obv_divergence**: detects flat price + rising OBV (accumulation). Partial overlap
|
||||
in that both can flag a beaten-down stock, but OBV divergence requires volume evidence
|
||||
of buying; RSI oversold can fire on pure price action.
|
||||
- **No prior research file** on mean reversion or RSI.
|
||||
|
||||
## Fit Evaluation
|
||||
|
||||
| Dimension | Score | Notes |
|
||||
|-----------|-------|-------|
|
||||
| Data availability | ✅ | yfinance OHLCV + `download_ohlcv_cached` fully integrated; RSI(2) computable from close prices, 200d SMA from same data |
|
||||
| Complexity | trivial/moderate | RSI(2) is a 6-line calculation; same code pattern as `high_52w_breakout` which already uses `download_ohlcv_cached` |
|
||||
| Signal uniqueness | low overlap | Only contrarian scanner in the entire pipeline; orthogonal to all momentum signals |
|
||||
| Evidence quality | backtested | Connors 25-year backtest, 75–79% win rate; Lehmann (1990) academic paper; Alpha Architect reversal review |
|
||||
|
||||
All four auto-implement thresholds pass → **implement**.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Implement** — Pipeline gap: zero mean-reversion coverage. RSI(2) with SMA200 trend
|
||||
filter is one of the most replicated mean-reversion signals in quant literature, data
|
||||
is fully available, and implementation is trivial following the `high_52w_breakout`
|
||||
template. Expected holding period: 3–7 days (exit when RSI(2) > 90 or closes above
|
||||
5-day SMA).
|
||||
|
||||
## Proposed Scanner Spec
|
||||
|
||||
- **Scanner name:** `rsi_oversold`
|
||||
- **Data source:** `tradingagents/dataflows/data_cache/ohlcv_cache.py` via
|
||||
`download_ohlcv_cached` (same as `high_52w_breakout`)
|
||||
- **Signal logic:**
|
||||
1. Load 1-year OHLCV for full universe
|
||||
2. Compute RSI(2) from last 3 closes: avg_gain/avg_loss over 2 periods
|
||||
3. Compute 200-day SMA from close series
|
||||
4. **Filter:** price > 200d SMA (uptrend guard) AND RSI(2) < `max_rsi` (default 10)
|
||||
AND close > `min_price` (default $5) AND avg_vol_20d > `min_avg_volume` (default 100k)
|
||||
5. Sort by RSI(2) ascending (most oversold first)
|
||||
- **Priority rules:**
|
||||
- CRITICAL if RSI(2) < 5 (extreme oversold, highest expected bounce)
|
||||
- HIGH if RSI(2) < 8
|
||||
- MEDIUM if RSI(2) < 10
|
||||
- **Context format:**
|
||||
`"RSI(2) oversold at {rsi:.1f} | Price ${price:.2f} above 200d SMA ${sma200:.2f}
|
||||
(+{pct:.1f}%) | 3–7d mean-reversion bounce setup"`
|
||||
|
|
@ -14,6 +14,7 @@ from . import (
|
|||
options_flow, # noqa: F401
|
||||
reddit_dd, # noqa: F401
|
||||
reddit_trending, # noqa: F401
|
||||
rsi_oversold, # noqa: F401
|
||||
sector_rotation, # noqa: F401
|
||||
semantic_news, # noqa: F401
|
||||
short_squeeze, # noqa: F401
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
"""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 = "momentum"
|
||||
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)
|
||||
|
|
@ -271,6 +271,18 @@ DEFAULT_CONFIG = {
|
|||
"min_price": 5.0, # Filter penny stocks
|
||||
"min_avg_volume": 100_000, # Min avg daily volume for liquidity
|
||||
},
|
||||
"rsi_oversold": {
|
||||
"enabled": True,
|
||||
"pipeline": "momentum",
|
||||
"limit": 10,
|
||||
"max_tickers": 0, # 0 = no cap (nightly cache makes full universe fast)
|
||||
"rsi_period": 2, # Connors RSI(2): most sensitive to short-term pullbacks
|
||||
"max_rsi": 10.0, # Entry threshold: RSI(2) < 10 (aggressive: < 5)
|
||||
"sma_period": 200, # Trend filter SMA period; critical falling-knife guard
|
||||
"min_price": 5.0, # Filter penny stocks
|
||||
"min_avg_volume": 100_000, # Min avg daily volume for liquidity
|
||||
"vol_avg_days": 20, # Days for volume average baseline
|
||||
},
|
||||
},
|
||||
},
|
||||
# Memory settings
|
||||
|
|
|
|||
Loading…
Reference in New Issue