"""Universe Builder: expand symbol universe via Stock Oracle screener API. Fetches large-cap liquid equities from screener, merges with existing symbols.yaml, and writes the union back. Usage: python -m apps.tools.universe_builder [--keep-existing] [--output configs/symbols.yaml] """ from __future__ import annotations import argparse import asyncio from pathlib import Path import yaml from libs.common.config import get_settings from libs.common.logging import configure_logging, get_logger from libs.oracle_client import ScreenerService, make_oracle_client logger = get_logger(__name__) # ── Screener defaults ────────────────────────────────────────────── SCREENER_DEFAULTS = { "market_cap_min": 10_000_000_000, # $10B "min_avg_volume": 1_000_000, # 1M shares/day "exchange": "NYSE,NASDAQ", "exclude_types": "ETF,FUND", "price_min": 5, } def load_existing_symbols(path: Path) -> list[str]: """Load current symbols.yaml and return list of tickers.""" if not path.exists(): return [] with open(path) as f: cfg = yaml.safe_load(f) or {} return cfg.get("symbols", []) def build_symbols_yaml( existing: list[str], screener_results: list[dict], ) -> str: """Build YAML content: existing-only tickers first, then screener (sorted).""" screener_set: set[str] = set() for item in screener_results: ticker = item.get("symbol", "") if ticker: screener_set.add(ticker.upper()) existing_set = {s.upper() for s in existing} existing_only = sorted(existing_set - screener_set) screener_sorted = sorted(screener_set) lines = ["symbols:"] if existing_only: lines.append(" # --- Existing-only (not in screener, kept) ---") for t in existing_only: lines.append(f" - {t}") lines.append(f" # --- Screener ($10B+ mcap, 1M+ vol) — {len(screener_sorted)} symbols ---") for t in screener_sorted: lines.append(f" - {t}") lines.append("") # trailing newline return "\n".join(lines) async def run_universe_builder( keep_existing: bool, output_path: Path, ) -> dict[str, int]: existing = load_existing_symbols(output_path) if keep_existing else [] existing_set = {s.upper() for s in existing} async with make_oracle_client() as client: svc = ScreenerService(client) screener_stocks = await svc.search_all_stocks(**SCREENER_DEFAULTS) screener_results = [{"symbol": s.symbol, "name": s.name} for s in screener_stocks] screener_tickers = {s.symbol.upper() for s in screener_stocks if s.symbol} overlap = existing_set & screener_tickers new_only = screener_tickers - existing_set existing_only = existing_set - screener_tickers union = existing_set | screener_tickers stats = { "screener": len(screener_tickers), "existing": len(existing_set), "overlap": len(overlap), "existing_only": len(existing_only), "new": len(new_only), "total": len(union), } yaml_content = build_symbols_yaml(existing, screener_results) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(yaml_content) # Report logger.info("universe_report", **stats) print(f"\n{'='*50}") print("Universe Builder Report") print(f"{'='*50}") print(f" Screener results: {stats['screener']:>6}") print(f" Existing symbols: {stats['existing']:>6}") print(f" Overlap: {stats['overlap']:>6}") print(f" Existing-only (kept): {stats['existing_only']:>6}") print(f" New additions: {stats['new']:>6}") print(f" Total (union): {stats['total']:>6}") print(f"{'='*50}") print(f" Written to: {output_path}") if existing_only: print(f"\n Existing-only tickers (kept): {sorted(existing_only)}") return stats def main() -> None: parser = argparse.ArgumentParser( description="Build symbol universe from Stock Oracle screener", ) parser.add_argument( "--keep-existing", action="store_true", default=True, help="Keep all existing symbols (default: True)", ) parser.add_argument( "--no-keep-existing", action="store_false", dest="keep_existing", help="Replace existing symbols entirely with screener results", ) parser.add_argument( "--output", default="configs/symbols.yaml", help="Output YAML path (default: configs/symbols.yaml)", ) args = parser.parse_args() settings = get_settings() configure_logging(settings.log_level) asyncio.run(run_universe_builder( keep_existing=args.keep_existing, output_path=Path(args.output), )) if __name__ == "__main__": main()