You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
156 lines
4.8 KiB
Python
156 lines
4.8 KiB
Python
"""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()
|