research(autonomous): 2026-04-14 — automated research run
This commit is contained in:
parent
17e77f036f
commit
1dd00e467f
|
|
@ -20,6 +20,7 @@
|
||||||
|
|
||||||
| Title | File | Date | Summary |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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) |
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
@ -10,6 +10,7 @@ from . import (
|
||||||
market_movers, # noqa: F401
|
market_movers, # noqa: F401
|
||||||
minervini, # noqa: F401
|
minervini, # noqa: F401
|
||||||
ml_signal, # noqa: F401
|
ml_signal, # noqa: F401
|
||||||
|
obv_divergence, # noqa: F401
|
||||||
options_flow, # noqa: F401
|
options_flow, # noqa: F401
|
||||||
reddit_dd, # noqa: F401
|
reddit_dd, # noqa: F401
|
||||||
reddit_trending, # noqa: F401
|
reddit_trending, # noqa: F401
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -159,6 +159,16 @@ DEFAULT_CONFIG = {
|
||||||
"compression_bb_width_max": 6.0, # Max Bollinger bandwidth for compression
|
"compression_bb_width_max": 6.0, # Max Bollinger bandwidth for compression
|
||||||
"compression_min_volume_ratio": 1.3, # Min volume ratio 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": {
|
"market_movers": {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"pipeline": "momentum",
|
"pipeline": "momentum",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue