"""Unusual options activity scanner. Scans a ticker universe (loaded from data/tickers.txt by default) for unusual options volume relative to open interest. Uses ThreadPoolExecutor for parallel chain fetching so large universes remain practical. """ from concurrent.futures import ThreadPoolExecutor, as_completed 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.dataflows.y_finance import get_option_chain, get_ticker_options 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 (one per line, # comments allowed).""" 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"Options 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 OptionsFlowScanner(BaseScanner): """Scan for unusual options activity across a ticker universe.""" name = "options_flow" pipeline = "edge" strategy = "options_flow" def __init__(self, config: Dict[str, Any]): super().__init__(config) self.min_volume_oi_ratio = self.scanner_config.get("unusual_volume_multiple", 2.0) self.min_volume = self.scanner_config.get("min_volume", 1000) self.min_premium = self.scanner_config.get("min_premium", 25000) self.max_tickers = self.scanner_config.get("max_tickers", 150) self.max_workers = self.scanner_config.get("max_workers", 8) # Load universe: explicit list > ticker_file > default file if "ticker_universe" in self.scanner_config: self.ticker_universe = self.scanner_config["ticker_universe"] else: ticker_file = self.scanner_config.get( "ticker_file", config.get("tickers_file", DEFAULT_TICKER_FILE), ) self.ticker_universe = _load_tickers_from_file(ticker_file) if not self.ticker_universe: logger.warning("No tickers loaded — options scanner will be empty") def scan(self, state: Dict[str, Any]) -> List[Dict[str, Any]]: if not self.is_enabled(): return [] universe = self.ticker_universe[: self.max_tickers] logger.info( f"Scanning {len(universe)} tickers for unusual options activity " f"({self.max_workers} workers)..." ) candidates: List[Dict[str, Any]] = [] with ThreadPoolExecutor(max_workers=self.max_workers) as pool: futures = { pool.submit(self._analyze_ticker_options, ticker): ticker for ticker in universe } for future in as_completed(futures): try: result = future.result() if result: candidates.append(result) if len(candidates) >= self.limit: # Cancel remaining futures for f in futures: f.cancel() break except Exception: continue logger.info(f"Found {len(candidates)} unusual options flows") return candidates def _analyze_ticker_options(self, ticker: str) -> Optional[Dict[str, Any]]: """Scan a single ticker for unusual options activity across multiple expirations.""" try: expirations = get_ticker_options(ticker) if not expirations: return None # Scan up to 3 nearest expirations max_expirations = min(3, len(expirations)) total_unusual_calls = 0 total_unusual_puts = 0 total_call_vol = 0 total_put_vol = 0 best_expiration = None best_unusual_count = 0 for exp in expirations[:max_expirations]: try: options = get_option_chain(ticker, exp) except Exception: continue if options is None: continue calls_df, puts_df = (None, None) if isinstance(options, tuple) and len(options) == 2: calls_df, puts_df = options elif hasattr(options, "calls") and hasattr(options, "puts"): calls_df, puts_df = options.calls, options.puts else: continue exp_unusual_calls = 0 exp_unusual_puts = 0 # Analyze calls if calls_df is not None and not calls_df.empty: for _, opt in calls_df.iterrows(): vol = opt.get("volume", 0) or 0 oi = opt.get("openInterest", 0) or 0 price = opt.get("lastPrice", 0) or 0 if vol < self.min_volume: continue # Premium filter (volume * price * 100 shares per contract) if (vol * price * 100) < self.min_premium: continue if oi > 0 and (vol / oi) >= self.min_volume_oi_ratio: exp_unusual_calls += 1 total_call_vol += vol # Analyze puts if puts_df is not None and not puts_df.empty: for _, opt in puts_df.iterrows(): vol = opt.get("volume", 0) or 0 oi = opt.get("openInterest", 0) or 0 price = opt.get("lastPrice", 0) or 0 if vol < self.min_volume: continue if (vol * price * 100) < self.min_premium: continue if oi > 0 and (vol / oi) >= self.min_volume_oi_ratio: exp_unusual_puts += 1 total_put_vol += vol total_unusual_calls += exp_unusual_calls total_unusual_puts += exp_unusual_puts exp_total = exp_unusual_calls + exp_unusual_puts if exp_total > best_unusual_count: best_unusual_count = exp_total best_expiration = exp total_unusual = total_unusual_calls + total_unusual_puts if total_unusual == 0: return None # Calculate put/call ratio pc_ratio = total_put_vol / total_call_vol if total_call_vol > 0 else 999 if pc_ratio < 0.7: sentiment = "bullish" elif pc_ratio > 1.3: sentiment = "bearish" else: sentiment = "neutral" priority = Priority.HIGH.value if sentiment == "bullish" else Priority.MEDIUM.value context = ( f"Unusual options: {total_unusual} strikes across {max_expirations} exp, " f"P/C={pc_ratio:.2f} ({sentiment}), " f"{total_unusual_calls} unusual calls / {total_unusual_puts} unusual puts" ) return { "ticker": ticker, "source": self.name, "context": context, "priority": priority, "strategy": self.strategy, "put_call_ratio": round(pc_ratio, 2), "unusual_calls": total_unusual_calls, "unusual_puts": total_unusual_puts, "best_expiration": best_expiration, } except Exception as e: logger.debug(f"Error scanning {ticker}: {e}") return None SCANNER_REGISTRY.register(OptionsFlowScanner)