From b68a43ec0dbf9aad4561bd7773a27be85d1b9af0 Mon Sep 17 00:00:00 2001 From: Youssef Aitousarrah Date: Mon, 6 Apr 2026 13:51:33 -0700 Subject: [PATCH] feat(scanners): add minervini scanner to registry minervini.py existed but was never committed. Without the file on the remote, the __init__.py import added in the previous fix causes an ImportError in CI. Co-Authored-By: Claude Sonnet 4.6 --- cli/utils.py | 2 + scripts/build_historical_memories.py | 6 +- scripts/build_strategy_specific_memories.py | 18 +- scripts/update_positions.py | 6 +- .../dataflows/discovery/discovery_config.py | 7 + tradingagents/dataflows/discovery/filter.py | 93 +++++- tradingagents/dataflows/discovery/ranker.py | 101 +++---- .../dataflows/discovery/scanners/minervini.py | 286 ++++++++++++++++++ .../dataflows/discovery/scanners/reddit_dd.py | 2 +- tradingagents/dataflows/discovery/utils.py | 1 + tradingagents/default_config.py | 14 +- tradingagents/tools/registry.py | 31 +- 12 files changed, 478 insertions(+), 89 deletions(-) create mode 100644 tradingagents/dataflows/discovery/scanners/minervini.py diff --git a/cli/utils.py b/cli/utils.py index 9488837e..118032c0 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -157,6 +157,7 @@ def select_shallow_thinking_agent(provider) -> str: ("Gemini 2.5 Pro - Most capable Gemini model", "gemini-2.5-pro"), ("Gemini 3.0 Pro Preview - Next generation preview", "gemini-3-pro-preview"), ("Gemini 3.0 Flash Preview - Latest generation preview", "gemini-3-flash-preview"), + ("Gemini 3.1 Preview - Newest model preview", "gemini-3.1-preview"), ], "openrouter": [ ("Meta: Llama 4 Scout", "meta-llama/llama-4-scout:free"), @@ -237,6 +238,7 @@ def select_deep_thinking_agent(provider) -> str: ("Gemini 2.5 Pro - Most capable Gemini model", "gemini-2.5-pro"), ("Gemini 3.0 Pro Preview - Next generation preview", "gemini-3-pro-preview"), ("Gemini 3.0 Flash Preview - Latest generation preview", "gemini-3-flash-preview"), + ("Gemini 3.1 Preview - Newest model preview", "gemini-3.1-preview"), ], "openrouter": [ ( diff --git a/scripts/build_historical_memories.py b/scripts/build_historical_memories.py index d1eb40e0..e91b6e49 100644 --- a/scripts/build_historical_memories.py +++ b/scripts/build_historical_memories.py @@ -27,13 +27,11 @@ logger = get_logger(__name__) def main(): - logger.info( - """ + logger.info(""" ╔══════════════════════════════════════════════════════════════╗ ║ TradingAgents - Historical Memory Builder ║ ╚══════════════════════════════════════════════════════════════╝ - """ - ) + """) # Configuration tickers = [ diff --git a/scripts/build_strategy_specific_memories.py b/scripts/build_strategy_specific_memories.py index d91dd050..2849367c 100644 --- a/scripts/build_strategy_specific_memories.py +++ b/scripts/build_strategy_specific_memories.py @@ -89,8 +89,7 @@ def build_strategy_memories(strategy_name: str, config: dict): strategy = STRATEGIES[strategy_name] - logger.info( - f""" + logger.info(f""" ╔══════════════════════════════════════════════════════════════╗ ║ Building Memories: {strategy_name.upper().replace('_', ' ')} ╚══════════════════════════════════════════════════════════════╝ @@ -99,8 +98,7 @@ Strategy: {strategy['description']} Lookforward: {strategy['lookforward_days']} days Sampling: Every {strategy['interval_days']} days Tickers: {', '.join(strategy['tickers'])} - """ - ) + """) # Date range - last 2 years end_date = datetime.now() @@ -159,8 +157,7 @@ Tickers: {', '.join(strategy['tickers'])} def main(): - logger.info( - """ + logger.info(""" ╔══════════════════════════════════════════════════════════════╗ ║ TradingAgents - Strategy-Specific Memory Builder ║ ╚══════════════════════════════════════════════════════════════╝ @@ -171,8 +168,7 @@ This script builds optimized memories for different trading styles: 2. Swing Trading - 7-day returns, weekly samples 3. Position Trading - 30-day returns, monthly samples 4. Long-term - 90-day returns, quarterly samples - """ - ) + """) logger.info("Available strategies:") for i, (name, config) in enumerate(STRATEGIES.items(), 1): @@ -220,13 +216,11 @@ This script builds optimized memories for different trading styles: logger.info("\n" + "=" * 70) logger.info("\n💡 TIP: To use a specific strategy's memories, update your config:") - logger.info( - """ + logger.info(""" config = DEFAULT_CONFIG.copy() config["memory_dir"] = "data/memories/swing_trading" # or your strategy config["load_historical_memories"] = True - """ - ) + """) if __name__ == "__main__": diff --git a/scripts/update_positions.py b/scripts/update_positions.py index d727d143..7bb99b60 100755 --- a/scripts/update_positions.py +++ b/scripts/update_positions.py @@ -129,12 +129,10 @@ def main(): 6. Save updated positions 7. Print progress messages """ - logger.info( - """ + logger.info(""" ╔══════════════════════════════════════════════════════════════╗ ║ TradingAgents - Position Updater ║ -╚══════════════════════════════════════════════════════════════╝""".strip() - ) +╚══════════════════════════════════════════════════════════════╝""".strip()) # Initialize position tracker tracker = PositionTracker(data_dir="data") diff --git a/tradingagents/dataflows/discovery/discovery_config.py b/tradingagents/dataflows/discovery/discovery_config.py index fec30b98..63c7e6a7 100644 --- a/tradingagents/dataflows/discovery/discovery_config.py +++ b/tradingagents/dataflows/discovery/discovery_config.py @@ -24,6 +24,10 @@ class FilterConfig: compression_atr_pct_max: float = 2.0 compression_bb_width_max: float = 6.0 compression_min_volume_ratio: float = 1.3 + # Fundamental Risk Filters + min_z_score: float = 1.81 # Default below 1.81 indicates distress + min_f_score: int = 4 # Default below 4 is poor + filter_fundamental_risk: bool = True @dataclass @@ -139,6 +143,9 @@ class DiscoveryConfig: compression_min_volume_ratio=f.get( "compression_min_volume_ratio", _fd.compression_min_volume_ratio ), + min_z_score=f.get("min_z_score", _fd.min_z_score), + min_f_score=f.get("min_f_score", _fd.min_f_score), + filter_fundamental_risk=f.get("filter_fundamental_risk", _fd.filter_fundamental_risk), ) # Enrichment — nested under "enrichment" key, fallback to root diff --git a/tradingagents/dataflows/discovery/filter.py b/tradingagents/dataflows/discovery/filter.py index 8be54382..9444f32f 100644 --- a/tradingagents/dataflows/discovery/filter.py +++ b/tradingagents/dataflows/discovery/filter.py @@ -132,6 +132,11 @@ class CandidateFilter: self.compression_bb_width_max = dc.filters.compression_bb_width_max self.compression_min_volume_ratio = dc.filters.compression_min_volume_ratio + # Fundamental Risk + self.filter_fundamental_risk = dc.filters.filter_fundamental_risk + self.min_z_score = dc.filters.min_z_score + self.min_f_score = dc.filters.min_f_score + # Enrichment settings self.batch_news_vendor = dc.enrichment.batch_news_vendor self.batch_news_batch_size = dc.enrichment.batch_news_batch_size @@ -166,6 +171,7 @@ class CandidateFilter: volume_by_ticker = self._fetch_batch_volume(state, candidates) news_by_ticker = self._fetch_batch_news(start_date, end_date, candidates) + price_by_ticker = self._fetch_batch_prices(candidates) ( filtered_candidates, @@ -177,6 +183,7 @@ class CandidateFilter: candidates=candidates, volume_by_ticker=volume_by_ticker, news_by_ticker=news_by_ticker, + price_by_ticker=price_by_ticker, end_date=end_date, ) @@ -347,12 +354,65 @@ class CandidateFilter: logger.warning(f"Batch news fetch failed, will skip news enrichment: {e}") return {} + def _fetch_batch_prices(self, candidates: List[Dict[str, Any]]) -> Dict[str, float]: + """Batch-fetch current prices for all candidates in one request. + + This avoids per-ticker yfinance calls that get rate-limited after + bulk downloads (e.g., ml_signal scanning 500+ tickers). + """ + tickers = [c.get("ticker", "").upper() for c in candidates if c.get("ticker")] + if not tickers: + return {} + + try: + import yfinance as yf + + logger.info(f"💰 Batch fetching prices for {len(tickers)} tickers...") + # Call yf.download directly — the download_history wrapper only accepts + # a single string (calls symbol.upper()), but yf.download handles lists. + data = yf.download( + tickers, + period="5d", + interval="1d", + auto_adjust=True, + progress=False, + ) + + if data is None or data.empty: + logger.warning("Batch price download returned empty data") + return {} + + prices = {} + if isinstance(data.columns, pd.MultiIndex): + available = data.columns.get_level_values(1).unique() + for ticker in tickers: + try: + if ticker in available: + close = data.xs(ticker, axis=1, level=1)["Close"].dropna() + if not close.empty: + prices[ticker] = float(close.iloc[-1]) + except Exception: + continue + else: + # Single ticker case + close = data["Close"].dropna() + if not close.empty and len(tickers) == 1: + prices[tickers[0]] = float(close.iloc[-1]) + + logger.info(f"✓ Batch prices fetched for {len(prices)}/{len(tickers)} tickers") + return prices + + except Exception as e: + logger.warning(f"Batch price fetch failed, will fall back to per-ticker: {e}") + return {} + def _filter_and_enrich_candidates( self, state: Dict[str, Any], candidates: List[Dict[str, Any]], volume_by_ticker: Dict[str, Any], news_by_ticker: Dict[str, Any], + price_by_ticker: Dict[str, float], end_date: str, ): filtered_candidates = [] @@ -361,6 +421,8 @@ class CandidateFilter: "intraday_moved": 0, "recent_moved": 0, "market_cap": 0, + "z_score": 0, + "f_score": 0, "no_data": 0, } @@ -458,8 +520,10 @@ class CandidateFilter: try: from tradingagents.dataflows.y_finance import get_fundamentals, get_stock_price - # Get current price - current_price = get_stock_price(ticker) + # Get current price — prefer batch result, fall back to per-ticker + current_price = price_by_ticker.get(ticker.upper()) + if current_price is None: + current_price = get_stock_price(ticker) cand["current_price"] = current_price # Track failures for delisted cache @@ -545,6 +609,27 @@ class CandidateFilter: # Assign strategy based on source (prioritize leading indicators) self._assign_strategy(cand) + # Fundamental Risk Check (Altman Z-Score & Piotroski F-Score) + if self.filter_fundamental_risk and cand.get("strategy") != "short_squeeze": + from tradingagents.dataflows.discovery.risk_metrics import ( + calculate_altman_z_score, + calculate_piotroski_f_score, + ) + + z_score = calculate_altman_z_score(ticker) + f_score = calculate_piotroski_f_score(ticker) + + cand["z_score"] = z_score + cand["f_score"] = f_score + + if z_score is not None and z_score < self.min_z_score: + filtered_reasons["z_score"] += 1 + continue + + if f_score is not None and f_score < self.min_f_score: + filtered_reasons["f_score"] += 1 + continue + # Technical Analysis Check (New) today_str = end_date rsi_data = self._run_tool( @@ -747,6 +832,10 @@ class CandidateFilter: logger.info(f" ❌ Low volume: {filtered_reasons['volume']}") if filtered_reasons.get("market_cap", 0) > 0: logger.info(f" ❌ Below market cap: {filtered_reasons['market_cap']}") + if filtered_reasons.get("z_score", 0) > 0: + logger.info(f" ❌ Low Altman Z-Score: {filtered_reasons['z_score']}") + if filtered_reasons.get("f_score", 0) > 0: + logger.info(f" ❌ Low Piotroski F-Score: {filtered_reasons['f_score']}") if filtered_reasons.get("no_data", 0) > 0: logger.info(f" ❌ No data available: {filtered_reasons['no_data']}") logger.info(f" ✅ Passed filters: {len(filtered_candidates)}") diff --git a/tradingagents/dataflows/discovery/ranker.py b/tradingagents/dataflows/discovery/ranker.py index 41350785..4dbcbc87 100644 --- a/tradingagents/dataflows/discovery/ranker.py +++ b/tradingagents/dataflows/discovery/ranker.py @@ -226,6 +226,8 @@ class CandidateRanker: # New enrichment fields confluence_score = cand.get("confluence_score", 1) quant_score = cand.get("quant_score", "N/A") + z_score = cand.get("z_score", "N/A") + f_score = cand.get("f_score", "N/A") # ML prediction ml_win_prob = cand.get("ml_win_probability") @@ -255,7 +257,7 @@ class CandidateRanker: summary = f"""### {ticker} (Priority: {priority.upper()}) - **Strategy Match**: {strategy} - **Sources**: {source_str} | **Confluence**: {confluence_score} source(s) -- **Quant Pre-Score**: {quant_score}/100 | **ML Win Probability**: {ml_str} +- **Quant Pre-Score**: {quant_score}/100 | **ML Win Probability**: {ml_str} | **Altman Z-Score**: {z_score} | **Piotroski F-Score**: {f_score} - **Price**: {price_str} | **Current Price (numeric)**: {current_price if isinstance(current_price, (int, float)) else "N/A"} | **Intraday**: {intraday_str} | **Avg Volume**: {volume_str} - **Short Interest**: {short_str} - **Discovery Context**: {context} @@ -305,6 +307,7 @@ Each candidate was discovered by a specific scanner. Evaluate them using the cri - **contrarian_value**: Focus on oversold technicals (RSI <30), fundamental support (earnings stability), and a clear reason why the selloff is overdone. - **news_catalyst**: Focus on the materiality of the news, whether it's already priced in (check intraday move), and the timeline of impact. - **sector_rotation**: Focus on relative strength vs sector ETF, whether the stock is a laggard in an accelerating sector. +- **minervini**: Focus on the RS Rating (top 30% = RS>=70, top 10% = RS>=90) as the primary signal. Verify all 6 trend template conditions are met (price structure above rising SMAs). Strongest setups combine RS>=85 with price consolidating near highs (within 10-15% of 52w high) — these have minimal overhead supply. Penalize if RS Rating is borderline (70-75) without other confirming signals. - **ml_signal**: Use the ML Win Probability as a strong quantitative signal. Scores above 65% deserve significant weight. HISTORICAL INSIGHTS: @@ -348,9 +351,24 @@ IMPORTANT: Return ONLY valid JSON. No markdown wrapping, no commentary outside t logger.info(f"Full ranking prompt:\n{prompt}") try: - # Use structured output with include_raw for debugging - structured_llm = self.llm.with_structured_output(RankingResponse, include_raw=True) - response = structured_llm.invoke([HumanMessage(content=prompt)]) + # Invoke LLM directly — avoids with_structured_output which fails + # when the LLM wraps JSON in ```json...``` markdown blocks + response = self.llm.invoke([HumanMessage(content=prompt)]) + + # Extract text content from response + raw_text = "" + if hasattr(response, "content"): + content = response.content + if isinstance(content, str): + raw_text = content + elif isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + raw_text = block.get("text", "") + break + elif isinstance(block, str): + raw_text = block + break tool_logs = state.get("tool_logs", []) append_llm_log( @@ -359,72 +377,29 @@ IMPORTANT: Return ONLY valid JSON. No markdown wrapping, no commentary outside t step="Rank candidates", model=resolve_llm_name(self.llm), prompt=prompt, - output=response, + output=raw_text[:2000], ) state["tool_logs"] = tool_logs - # Handle the response (dict with raw, parsed, parsing_error) - if isinstance(response, dict): - result = response.get("parsed") - raw = response.get("raw") - parsing_error = response.get("parsing_error") - - # Log debug info - logger.info(f"Structured output - parsed type: {type(result)}") - if parsing_error: - logger.error(f"Parsing error: {parsing_error}") - if raw and hasattr(raw, "content"): - logger.debug(f"Raw content preview: {str(raw.content)[:500]}...") - else: - # Direct RankingResponse (shouldn't happen with include_raw=True) - result = response - - # Extract rankings - with fallback for markdown-wrapped JSON - if result is None: - logger.warning( - "Structured output parsing returned None - attempting fallback extraction" + if not raw_text.strip(): + raise ValueError( + "LLM returned empty response. This may be due to content filtering or prompt length." ) - # Try to extract JSON from raw response (handles ```json...``` wrapping) - raw_text = None - if raw and hasattr(raw, "content"): - content = raw.content - if isinstance(content, str): - raw_text = content - elif isinstance(content, list): - # Handle list of content blocks (e.g., [{'type': 'text', 'text': '...'}]) - for block in content: - if isinstance(block, dict) and block.get("type") == "text": - raw_text = block.get("text", "") - break - elif isinstance(block, str): - raw_text = block - break + # Strip markdown wrapper (```json...```) and parse JSON + json_str = extract_json_from_markdown(raw_text) + if not json_str: + raise ValueError( + f"LLM response did not contain valid JSON. Preview: {raw_text[:500]}" + ) - if raw_text: - json_str = extract_json_from_markdown(raw_text) - if json_str: - try: - parsed_data = json.loads(json_str) - result = RankingResponse.model_validate(parsed_data) - logger.info( - "Successfully extracted JSON from markdown-wrapped response" - ) - except json.JSONDecodeError as e: - logger.error(f"Failed to parse extracted JSON: {e}") - except Exception as e: - logger.error(f"Failed to validate extracted JSON: {e}") + try: + parsed_data = json.loads(json_str) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to parse JSON from LLM response: {e}") - if result is None: - logger.error("Parsed result is None - check raw response for clues") - raise ValueError( - "LLM returned None. This may be due to content filtering or prompt length. " - "Check LOG_LEVEL=DEBUG for details." - ) - - if not hasattr(result, "rankings"): - logger.error(f"Result missing 'rankings'. Type: {type(result)}, Value: {result}") - raise ValueError(f"Unexpected result format: {type(result)}") + result = RankingResponse.model_validate(parsed_data) + logger.info(f"Parsed {len(result.rankings)} rankings from LLM response") final_ranking_list = [ranking.model_dump() for ranking in result.rankings] diff --git a/tradingagents/dataflows/discovery/scanners/minervini.py b/tradingagents/dataflows/discovery/scanners/minervini.py new file mode 100644 index 00000000..d0e2e369 --- /dev/null +++ b/tradingagents/dataflows/discovery/scanners/minervini.py @@ -0,0 +1,286 @@ +"""Minervini Trend Template scanner — Stage 2 uptrend identification. + +Identifies stocks in a confirmed Stage 2 uptrend using Mark Minervini's +6-condition trend template, then ranks survivors by an IBD-style Relative +Strength (RS) Rating computed within the scanned universe. + +All computation is pure OHLCV math — zero per-ticker API calls during scan. +""" + +from typing import Any, Dict, List, Optional, Tuple + +import pandas as pd + +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__) + +DEFAULT_TICKER_FILE = "data/tickers.txt" + + +def _load_tickers_from_file(path: str) -> List[str]: + """Load ticker symbols from a text file.""" + try: + with open(path) as f: + tickers = [ + line.strip().upper() + for line in f + if line.strip() and not line.strip().startswith("#") + ] + if tickers: + logger.info(f"Minervini scanner: loaded {len(tickers)} tickers from {path}") + return tickers + except FileNotFoundError: + logger.warning(f"Ticker file not found: {path}") + except Exception as e: + logger.warning(f"Failed to load ticker file {path}: {e}") + return [] + + +class MinerviniScanner(BaseScanner): + """Scan for stocks in a confirmed Minervini Stage 2 uptrend. + + Applies Mark Minervini's 6-condition trend template to identify stocks + with healthy price structure (above rising SMAs, well off lows, near highs), + then ranks by an IBD-style RS Rating computed within the scanned universe. + + Data requirement: ~200+ trading days of OHLCV (uses 1y lookback by default). + Cost: single batch yfinance download, zero per-ticker API calls. + """ + + name = "minervini" + pipeline = "momentum" + strategy = "minervini" + + def __init__(self, config: Dict[str, Any]): + super().__init__(config) + self.ticker_file = self.scanner_config.get( + "ticker_file", + config.get("tickers_file", DEFAULT_TICKER_FILE), + ) + self.min_rs_rating = self.scanner_config.get("min_rs_rating", 70) + self.lookback_period = self.scanner_config.get("lookback_period", "1y") + self.sma_200_slope_days = self.scanner_config.get("sma_200_slope_days", 20) + self.min_pct_off_low = self.scanner_config.get("min_pct_off_low", 30) + self.max_pct_from_high = self.scanner_config.get("max_pct_from_high", 25) + + def scan(self, state: Dict[str, Any]) -> List[Dict[str, Any]]: + if not self.is_enabled(): + return [] + + logger.info("📊 Scanning for Minervini Stage 2 uptrends...") + + tickers = _load_tickers_from_file(self.ticker_file) + if not tickers: + logger.warning("No tickers loaded for Minervini scan") + return [] + + # Batch download OHLCV — 1y needed for SMA200 + import yfinance as yf + + try: + logger.info(f"Batch-downloading {len(tickers)} tickers ({self.lookback_period})...") + raw = yf.download( + tickers, + period=self.lookback_period, + interval="1d", + auto_adjust=True, + progress=False, + ) + except Exception as e: + logger.error(f"Batch download failed: {e}") + return [] + + if raw is None or raw.empty: + logger.warning("Minervini scanner: batch download returned empty data") + return [] + + # Compute 12-month returns for RS Rating (need all tickers' data) + universe_returns: Dict[str, float] = {} + passing_tickers: List[Tuple[str, Dict[str, Any]]] = [] + + for ticker in tickers: + result = self._check_minervini(ticker, raw) + if result is not None: + ticker_df, metrics = result + # Compute 12-month cumulative return for RS rating + ret = self._compute_return(ticker_df) + if ret is not None: + universe_returns[ticker] = ret + passing_tickers.append((ticker, metrics)) + + # Also compute returns for tickers that DIDN'T pass (for RS percentile ranking) + for ticker in tickers: + if ticker not in universe_returns: + try: + if isinstance(raw.columns, pd.MultiIndex): + if ticker not in raw.columns.get_level_values(1): + continue + df = raw.xs(ticker, axis=1, level=1).dropna() + else: + df = raw.dropna() + ret = self._compute_return(df) + if ret is not None: + universe_returns[ticker] = ret + except Exception: + continue + + # Compute RS ratings as percentile ranks within the universe + if universe_returns: + all_returns = list(universe_returns.values()) + all_returns_sorted = sorted(all_returns) + n = len(all_returns_sorted) + + def percentile_rank(val: float) -> float: + pos = sum(1 for r in all_returns_sorted if r <= val) + return round((pos / n) * 100, 1) + + rs_ratings = {t: percentile_rank(r) for t, r in universe_returns.items()} + else: + rs_ratings = {} + + # Build final candidates: pass RS filter, sort, limit + candidates = [] + for ticker, metrics in passing_tickers: + rs_rating = rs_ratings.get(ticker, 0) + if rs_rating < self.min_rs_rating: + continue + + pct_off_low = metrics["pct_off_low"] + pct_from_high = metrics["pct_from_high"] + + # Priority based on RS Rating + if rs_rating >= 90: + priority = Priority.CRITICAL.value + elif rs_rating >= 80: + priority = Priority.HIGH.value + else: + priority = Priority.MEDIUM.value + + context = ( + f"Minervini Stage 2: P>SMA50>SMA150>SMA200, " + f"+{pct_off_low:.0f}% off 52w low, " + f"within {pct_from_high:.0f}% of 52w high, " + f"RS Rating {rs_rating:.0f}/100" + ) + + candidates.append( + { + "ticker": ticker, + "source": self.name, + "context": context, + "priority": priority, + "strategy": self.strategy, + "rs_rating": rs_rating, + "pct_off_low": round(pct_off_low, 1), + "pct_from_high": round(pct_from_high, 1), + "sma_50": round(metrics["sma_50"], 2), + "sma_150": round(metrics["sma_150"], 2), + "sma_200": round(metrics["sma_200"], 2), + } + ) + + # Sort by RS Rating descending, then limit + candidates.sort(key=lambda c: c.get("rs_rating", 0), reverse=True) + candidates = candidates[: self.limit] + + logger.info( + f"Minervini scanner: {len(candidates)} Stage 2 candidates " + f"(RS >= {self.min_rs_rating}) from {len(tickers)} tickers" + ) + return candidates + + def _check_minervini( + self, ticker: str, raw: pd.DataFrame + ) -> Optional[Tuple[pd.DataFrame, Dict[str, Any]]]: + """Apply the 6-condition Minervini trend template to one ticker. + + Returns (df, metrics) if all conditions pass, None otherwise. + """ + try: + # Extract single-ticker slice + if isinstance(raw.columns, pd.MultiIndex): + if ticker not in raw.columns.get_level_values(1): + return None + df = raw.xs(ticker, axis=1, level=1).dropna() + else: + df = raw.dropna() + + # Need at least 200 rows for SMA200 + if len(df) < 200: + return None + + close = df["Close"] + + sma_50 = float(close.rolling(50).mean().iloc[-1]) + sma_150 = float(close.rolling(150).mean().iloc[-1]) + sma_200 = float(close.rolling(200).mean().iloc[-1]) + sma_200_prev = float(close.rolling(200).mean().iloc[-self.sma_200_slope_days - 1]) + price = float(close.iloc[-1]) + + low_52w = float(close.iloc[-252:].min()) if len(close) >= 252 else float(close.min()) + high_52w = float(close.iloc[-252:].max()) if len(close) >= 252 else float(close.max()) + + if low_52w <= 0 or sma_50 <= 0 or sma_150 <= 0 or sma_200 <= 0: + return None + + pct_off_low = ((price - low_52w) / low_52w) * 100 + pct_from_high = ((high_52w - price) / high_52w) * 100 + + # Minervini's 6 conditions (all must pass) + conditions = [ + price > sma_150 > sma_200, # 1. Price > SMA150 > SMA200 + sma_150 > sma_200, # 2. SMA150 above SMA200 + sma_200 > sma_200_prev, # 3. SMA200 slope is rising + price > sma_50, # 4. Price above SMA50 + pct_off_low >= self.min_pct_off_low, # 5. At least 30% off 52w low + pct_from_high <= self.max_pct_from_high, # 6. Within 25% of 52w high + ] + + if not all(conditions): + return None + + return df, { + "sma_50": sma_50, + "sma_150": sma_150, + "sma_200": sma_200, + "pct_off_low": pct_off_low, + "pct_from_high": pct_from_high, + } + + except Exception as e: + logger.debug(f"Minervini check failed for {ticker}: {e}") + return None + + def _compute_return(self, df: pd.DataFrame) -> Optional[float]: + """Compute IBD-style 12-month return with recent-quarter double-weighting. + + Formula: (full_year_return * 2 + last_quarter_return) / 3 + This weights recent momentum more heavily, matching IBD's RS methodology. + """ + try: + close = df["Close"] if "Close" in df.columns else df.iloc[:, 0] + close = close.dropna() + if len(close) < 2: + return None + + latest = float(close.iloc[-1]) + year_ago = float(close.iloc[0]) + quarter_ago = float(close.iloc[max(0, len(close) - 63)]) + + if year_ago <= 0 or quarter_ago <= 0: + return None + + full_year_ret = (latest - year_ago) / year_ago + quarter_ret = (latest - quarter_ago) / quarter_ago + + # IBD weighting: recent quarter counts double + return (full_year_ret * 2 + quarter_ret) / 3 + + except Exception: + return None + + +SCANNER_REGISTRY.register(MinerviniScanner) diff --git a/tradingagents/dataflows/discovery/scanners/reddit_dd.py b/tradingagents/dataflows/discovery/scanners/reddit_dd.py index 8dc8f516..540ec045 100644 --- a/tradingagents/dataflows/discovery/scanners/reddit_dd.py +++ b/tradingagents/dataflows/discovery/scanners/reddit_dd.py @@ -28,7 +28,7 @@ class RedditDDScanner(BaseScanner): try: # Use Reddit DD scanner tool - result = execute_tool("scan_reddit_dd", limit=self.limit) + result = execute_tool("scan_reddit_dd", top_n=self.limit, as_list=True) if not result: logger.info("Found 0 DD posts") diff --git a/tradingagents/dataflows/discovery/utils.py b/tradingagents/dataflows/discovery/utils.py index caf81732..5897e6c4 100644 --- a/tradingagents/dataflows/discovery/utils.py +++ b/tradingagents/dataflows/discovery/utils.py @@ -51,6 +51,7 @@ class Strategy(str, Enum): SOCIAL_DD = "social_dd" SECTOR_ROTATION = "sector_rotation" TECHNICAL_BREAKOUT = "technical_breakout" + MINERVINI = "minervini" PRIORITY_ORDER = { diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 6b394412..81197081 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -139,7 +139,7 @@ DEFAULT_CONFIG = { "min_volume": 1000, # Minimum option volume to consider # ticker_file: path to ticker list (defaults to tickers_file from root config) # ticker_universe: explicit list overrides ticker_file if set - "max_tickers": 150, # Max tickers to scan (from start of file) + "max_tickers": 1000, # Max tickers to scan (from start of file) "max_workers": 8, # Parallel option chain fetch threads }, "congress_trades": { @@ -214,12 +214,22 @@ DEFAULT_CONFIG = { "pipeline": "momentum", "limit": 15, "min_win_prob": 0.35, # Minimum P(WIN) to surface as candidate - "lookback_period": "1y", # OHLCV history to fetch (needs ~210 trading days) + "lookback_period": "6mo", # OHLCV history to fetch (needs ~130 trading days) # ticker_file: path to ticker list (defaults to tickers_file from root config) # ticker_universe: explicit list overrides ticker_file if set "fetch_market_cap": False, # Skip for speed (1 NaN out of 30 features) "max_workers": 8, # Parallel feature computation threads }, + "minervini": { + "enabled": True, + "pipeline": "momentum", + "limit": 10, + "min_rs_rating": 70, # Min IBD-style RS Rating (0-100) + "lookback_period": "1y", # Needs 200 trading days for SMA200 + "sma_200_slope_days": 20, # Days back to check SMA200 slope + "min_pct_off_low": 30, # Must be 30%+ above 52w low + "max_pct_from_high": 25, # Must be within 25% of 52w high + }, }, }, # Memory settings diff --git a/tradingagents/tools/registry.py b/tradingagents/tools/registry.py index ea181260..eed41fee 100644 --- a/tradingagents/tools/registry.py +++ b/tradingagents/tools/registry.py @@ -63,6 +63,7 @@ from tradingagents.dataflows.reddit_api import ( get_reddit_discussions, get_reddit_news, get_reddit_trending_tickers, + get_reddit_undiscovered_dd, ) from tradingagents.dataflows.reddit_api import ( get_reddit_global_news as get_reddit_api_global_news, @@ -337,7 +338,7 @@ TOOL_REGISTRY: Dict[str, Dict[str, Any]] = { "reddit": get_reddit_api_global_news, # "alpha_vantage": get_alpha_vantage_global_news, }, - "vendor_priority": ["openai", "google", "reddit"], + "vendor_priority": ["openai", "reddit"], "execution_mode": "aggregate", "parameters": { "date": {"type": "str", "description": "Date for news, yyyy-mm-dd"}, @@ -587,6 +588,34 @@ TOOL_REGISTRY: Dict[str, Dict[str, Any]] = { }, "returns": "str: Reddit discussions and sentiment", }, + "scan_reddit_dd": { + "description": "Scan Reddit for high-quality due diligence posts", + "category": "discovery", + "agents": ["social"], + "vendors": { + "reddit": get_reddit_undiscovered_dd, + }, + "vendor_priority": ["reddit"], + "parameters": { + "lookback_hours": {"type": "int", "description": "Hours to look back", "default": 72}, + "scan_limit": { + "type": "int", + "description": "Number of new posts to scan", + "default": 100, + }, + "top_n": { + "type": "int", + "description": "Number of top DD posts to return", + "default": 10, + }, + "num_comments": { + "type": "int", + "description": "Number of top comments to include", + "default": 10, + }, + }, + "returns": "str: Report of high-quality undiscovered DD", + }, "get_options_activity": { "description": "Get options activity for a specific ticker (volume, open interest, put/call ratios, unusual activity)", "category": "discovery",