diff --git a/docs/iterations/LEARNINGS.md b/docs/iterations/LEARNINGS.md index cf08e1d5..0f616ef4 100644 --- a/docs/iterations/LEARNINGS.md +++ b/docs/iterations/LEARNINGS.md @@ -20,6 +20,7 @@ | Title | File | Date | Summary | |-------|------|------|---------| +| 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 | | PEAD Post-Earnings Drift | research/2026-04-14-pead-earnings-beat.md | 2026-04-14 | Bernard & Thomas (1989): 18% annualized PEAD; QuantPedia: 15% annualized (1987-2004); implemented as earnings_beat scanner (distinct from earnings_calendar's upcoming-only scope) | diff --git a/docs/iterations/research/2026-04-14-obv-divergence.md b/docs/iterations/research/2026-04-14-obv-divergence.md new file mode 100644 index 00000000..7c4e35e2 --- /dev/null +++ b/docs/iterations/research/2026-04-14-obv-divergence.md @@ -0,0 +1,60 @@ +# Research: OBV Divergence as Multi-Week Accumulation Signal + +**Date:** 2026-04-14 +**Mode:** autonomous + +## Summary +On-Balance Volume (OBV) divergence — price flat or falling while OBV trends up — is an established +signal for detecting multi-week institutional accumulation. Academic evidence on volume-price +causality is mixed at the mean, but commercial backtests consistently show divergence strategies +outperforming simple momentum in qualitative studies. The signal is distinct from the existing +`volume_accumulation` scanner (which detects single-day spikes) and uses already-integrated price +and volume data, making it straightforward to implement. + +## Sources Reviewed +- **ArrowAlgo OBV Guide**: OBV divergence strategy: price lower low + OBV higher low = 68% win + rate, 12% avg annual return (4-year backtest on individual stocks). Breakout confirmation: + Sharpe 1.4. No standalone significance without price structure confirmation. +- **Vestinda OBV Backtesting**: OBV + Ichimoku reversal on RIOT: 25% win rate but 64% annual ROI + (high-reward lottery approach); OBV + Ichimoku on BAC: 46% win rate, 5.5% annual ROI, + 35% outperformance vs. buy-and-hold. Confirms OBV is better as a filter than a trigger. +- **StockCharts ChartSchool**: Canonical OBV definition (Granville 1963). Rising OBV during + sideways/declining price = quiet accumulation. Bullish divergence entry: price at lower low, + OBV at higher low. Failure modes: volume spikes from news events, standalone unreliability. +- **NinjaTrader OBV Blog**: Three strategies (trend-following, divergence, breakout confirmation). + Key: OBV crossing above EMA = bullish entry. No hard win-rate stats. +- **ScienceDirect volume-return causality study**: Lagged volume coefficient insignificant at the + mean (OLS), but quantile regressions show higher predictive power when informed trading is + elevated. Suggests OBV works better in high-conviction accumulation regimes. +- **TradingAgents codebase**: OBV calculation already exists in `technical_analyst.py:298-348` + for per-stock analysis, not for scanning. Reuse is straightforward. + +## Fit Evaluation +| Dimension | Score | Notes | +|-----------|-------|-------| +| Data availability | ✅ | Price + volume history via `y_finance.py`; scan cache already built by `volume_accumulation` scanner (shared `"default"` cache key) | +| Complexity | moderate | OBV computation is a simple loop (~30 lines); divergence detection requires loading cached history per ticker; bulk scan is feasible within scanner timeout | +| Signal uniqueness | low overlap | `volume_accumulation` detects single-day 2x+ spikes with same-day direction filter; OBV divergence detects sustained multi-week buying pressure during price consolidation — complementary, not redundant | +| Evidence quality | qualitative | Commercial backtests: 68% win rate (divergence), Sharpe 1.4 (breakout); academic: mixed — volume-return causality illusive at mean but stronger in high-conviction regimes (ScienceDirect 2025) | + +## Recommendation +**Implement** — meets all four auto-implement thresholds. Signal is complementary to existing +`volume_accumulation` scanner, reuses cached data, and has qualitative-level evidence (same tier as +`short_squeeze` at time of implementation). Weak academic backing is a known limitation; the +signal should be treated as a discovery filter and validated with `/iterate` performance data. + +## Proposed Scanner Spec +- **Scanner name:** `obv_divergence` +- **Data source:** `tradingagents/dataflows/alpha_vantage_volume.py` (`download_volume_data` + + `_records_to_dataframe`); reuses the `"default"` volume cache shared with `volume_accumulation` +- **Signal logic:** + 1. Load 90d daily price+volume history from the shared cache + 2. Compute OBV: cumulative sum, add volume if close > prev_close, subtract if close < prev_close + 3. Bullish divergence: `price_change_pct (lookback_days ago) ≤ max_price_change_pct (default 2%)` + AND `obv_pct_gain ≥ min_obv_pct_gain (default 8%)`, where obv_pct_gain is the OBV change + over the lookback period normalized by `avg_daily_volume × lookback_days` + 4. Filter out stocks where price fell >5% (likely distribution, not accumulation) +- **Priority rules:** + - HIGH if `obv_pct_gain ≥ 20%` AND `price_change_pct ≤ 0` (clear divergence, price unchanged or down) + - MEDIUM if `obv_pct_gain ≥ 8%` (mild divergence during consolidation) +- **Context format:** `"OBV divergence: price {price_change_20d:+.1f}% over {lookback}d, OBV +{obv_pct_gain:.1f}% of avg vol — multi-week accumulation signal"` diff --git a/tradingagents/dataflows/discovery/scanners/__init__.py b/tradingagents/dataflows/discovery/scanners/__init__.py index 5bdecd69..50f3c71d 100644 --- a/tradingagents/dataflows/discovery/scanners/__init__.py +++ b/tradingagents/dataflows/discovery/scanners/__init__.py @@ -10,6 +10,7 @@ from . import ( market_movers, # noqa: F401 minervini, # noqa: F401 ml_signal, # noqa: F401 + obv_divergence, # noqa: F401 options_flow, # noqa: F401 reddit_dd, # noqa: F401 reddit_trending, # noqa: F401 diff --git a/tradingagents/dataflows/discovery/scanners/obv_divergence.py b/tradingagents/dataflows/discovery/scanners/obv_divergence.py new file mode 100644 index 00000000..dcdfff3d --- /dev/null +++ b/tradingagents/dataflows/discovery/scanners/obv_divergence.py @@ -0,0 +1,153 @@ +"""OBV divergence scanner — detects multi-week accumulation via OBV/price divergence.""" + +from typing import Any, Dict, List, Optional + +from tradingagents.dataflows.discovery.scanner_registry import SCANNER_REGISTRY, BaseScanner +from tradingagents.dataflows.discovery.utils import Priority +from tradingagents.utils.logger import get_logger + +logger = get_logger(__name__) + + +class OBVDivergenceScanner(BaseScanner): + """Scan for OBV bullish divergence: price flat/falling while OBV trends up. + + Distinguishes multi-week institutional accumulation (sustained OBV rise + during price consolidation) from the single-day spikes caught by the + volume_accumulation scanner. + """ + + name = "obv_divergence" + pipeline = "momentum" + strategy = "volume_divergence" + + def scan(self, state: Dict[str, Any]) -> List[Dict[str, Any]]: + if not self.is_enabled(): + return [] + + logger.info("📈 Scanning OBV divergence (multi-week accumulation)...") + + lookback = self.scanner_config.get("lookback_days", 20) + min_obv_pct = self.scanner_config.get("min_obv_pct_gain", 8.0) + max_price_change = self.scanner_config.get("max_price_change_pct", 2.0) + max_tickers = self.scanner_config.get("max_tickers", 2000) + + try: + from tradingagents.dataflows.alpha_vantage_volume import ( + _records_to_dataframe, + download_volume_data, + ) + from tradingagents.dataflows.y_finance import _get_ticker_universe + + tickers = _get_ticker_universe(max_tickers=max_tickers) + if not tickers: + logger.info("No tickers in universe") + return [] + + # Reuse shared volume cache built by volume_accumulation scanner + cache_key = self.scanner_config.get("volume_cache_key", "default") + raw_data = download_volume_data( + tickers=tickers, + history_period_days=90, + use_cache=True, + cache_key=cache_key, + ) + + candidates = [] + for ticker, records in raw_data.items(): + result = self._detect_divergence( + ticker, records, lookback, min_obv_pct, max_price_change, _records_to_dataframe + ) + if result: + candidates.append(result) + + # Sort by OBV gain descending; strongest divergences first + candidates.sort(key=lambda x: x.get("_obv_pct_gain", 0), reverse=True) + + # Strip internal sort key before returning + final = [] + for c in candidates[: self.limit]: + c.pop("_obv_pct_gain", None) + final.append(c) + + logger.info(f"Found {len(final)} OBV divergence candidates") + return final + + except Exception as e: + logger.warning(f"⚠️ OBV divergence scan failed: {e}") + return [] + + def _detect_divergence( + self, + ticker: str, + records: List[Dict], + lookback: int, + min_obv_pct: float, + max_price_change: float, + records_to_df, + ) -> Optional[Dict[str, Any]]: + """Compute OBV and detect bullish price/OBV divergence.""" + try: + hist = records_to_df(records) + if hist.empty or len(hist) < lookback + 5: + return None + + closes = hist["Close"].values + volumes = hist["Volume"].values + + # Compute OBV as cumulative sum + obv = [0.0] + for i in range(1, len(closes)): + if closes[i] > closes[i - 1]: + obv.append(obv[-1] + volumes[i]) + elif closes[i] < closes[i - 1]: + obv.append(obv[-1] - volumes[i]) + else: + obv.append(obv[-1]) + + current_obv = obv[-1] + past_obv = obv[-(lookback + 1)] + current_price = float(closes[-1]) + past_price = float(closes[-(lookback + 1)]) + + if past_price <= 0: + return None + + price_change_pct = ((current_price - past_price) / past_price) * 100 + + # Skip clear distribution: price dropped hard + if price_change_pct < -5.0: + return None + + # Normalize OBV change by avg_vol × lookback to get a scale-free percentage + avg_vol = float(hist["Volume"].mean()) + if avg_vol <= 0: + return None + + obv_change = current_obv - past_obv + obv_pct_gain = (obv_change / (avg_vol * lookback)) * 100 + + # Bullish divergence: OBV rising while price flat or falling + if price_change_pct <= max_price_change and obv_pct_gain >= min_obv_pct: + if obv_pct_gain >= 20.0 and price_change_pct <= 0.0: + priority = Priority.HIGH.value + else: + priority = Priority.MEDIUM.value + + return { + "ticker": ticker, + "source": self.name, + "context": ( + f"OBV divergence: price {price_change_pct:+.1f}% over {lookback}d, " + f"OBV +{obv_pct_gain:.1f}% of avg vol — multi-week accumulation signal" + ), + "priority": priority, + "strategy": self.strategy, + "_obv_pct_gain": obv_pct_gain, + } + except Exception: + pass + return None + + +SCANNER_REGISTRY.register(OBVDivergenceScanner) diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 3d2db515..21895509 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -159,6 +159,16 @@ DEFAULT_CONFIG = { "compression_bb_width_max": 6.0, # Max Bollinger bandwidth for compression "compression_min_volume_ratio": 1.3, # Min volume ratio for compression }, + "obv_divergence": { + "enabled": True, + "pipeline": "momentum", + "limit": 10, + "lookback_days": 20, # Days to measure price/OBV change over + "min_obv_pct_gain": 8.0, # Min OBV gain as % of avg_vol × lookback_days + "max_price_change_pct": 2.0, # Max price change % over lookback (divergence window) + "volume_cache_key": "default", # Shares cache with volume_accumulation scanner + "max_tickers": 2000, # Universe size cap + }, "market_movers": { "enabled": False, "pipeline": "momentum",