"""Coverage Check: Validate Stock Oracle data availability for symbol universe. Checks each ticker for: 1. Price data coverage (daily bars since start_date, require 90%+ trading days) 2. Filing data (8-K filings, require 3+ filings) 3. Company info (name, CIK, sector must exist) 4. Market cap range ($500M - $10B for small/mid-cap) Outputs CSV report + pass/fail summary. Optionally writes a filtered YAML with only passing tickers. Usage: python -m dev.analysis.coverage_check \ --symbols-file configs/symbols_smallmid.yaml \ --start-date 2022-07-01 \ [--output-dir ./data/analysis] \ [--write-filtered] """ from __future__ import annotations import argparse import asyncio import csv from datetime import datetime from pathlib import Path from typing import Any import yaml from libs.oracle_client import FilingsService, FinancialService, PriceService, make_oracle_client # --- Constants --- MIN_MARKET_CAP = 5e8 # $500M MAX_MARKET_CAP = 1e10 # $10B MIN_FILINGS = 3 MIN_PRICE_COVERAGE = 0.90 # 90% of expected trading days APPROX_TRADING_DAYS_PER_YEAR = 252 def _load_symbols(path: str) -> list[str]: with open(path) as f: cfg = yaml.safe_load(f) or {} symbols = cfg.get("symbols", []) # Deduplicate while preserving order seen: set[str] = set() unique: list[str] = [] for s in symbols: if s not in seen: seen.add(s) unique.append(s) return unique async def _check_ticker( ticker: str, price_svc: PriceService, filings_svc: FilingsService, financial_svc: FinancialService, start_date: str, expected_trading_days: int, ) -> dict[str, Any]: """Run all coverage checks for a single ticker.""" result: dict[str, Any] = { "ticker": ticker, "price_bars": 0, "price_coverage": 0.0, "price_pass": False, "filing_count": 0, "filing_pass": False, "has_name": False, "has_cik": False, "has_sector": False, "sector": None, "info_pass": False, "market_cap": None, "market_cap_pass": False, "overall_pass": False, "error": None, } # 1. Price data try: end_date = datetime.now().strftime("%Y-%m-%d") price_resp = await price_svc.get_daily_bars(ticker, start=start_date, end=end_date) n_bars = len(price_resp.bars) result["price_bars"] = n_bars coverage = n_bars / expected_trading_days if expected_trading_days > 0 else 0 result["price_coverage"] = round(coverage, 3) result["price_pass"] = coverage >= MIN_PRICE_COVERAGE except Exception as e: result["error"] = f"price: {e}" # 2. Filings (8-K) try: filings_resp = await filings_svc.search_filings( ticker, form_type="8-K", start_date=start_date ) n_filings = len(filings_resp.filings) result["filing_count"] = n_filings result["filing_pass"] = n_filings >= MIN_FILINGS except Exception as e: err = f"filings: {e}" result["error"] = err if not result["error"] else f"{result['error']}; {err}" # 3. Company info + market cap try: info = await financial_svc.get_company_info(ticker) result["has_name"] = bool(info.name) result["has_cik"] = bool(info.cik) result["has_sector"] = bool(info.sector) result["sector"] = info.sector result["info_pass"] = all([info.name, info.cik, info.sector]) if info.market_cap is not None: result["market_cap"] = info.market_cap result["market_cap_pass"] = MIN_MARKET_CAP <= info.market_cap <= MAX_MARKET_CAP else: # If market_cap not available, pass this check (can't verify) result["market_cap_pass"] = True except Exception as e: err = f"info: {e}" result["error"] = err if not result["error"] else f"{result['error']}; {err}" # If company info fails entirely, market_cap check is inconclusive result["market_cap_pass"] = True # Overall result["overall_pass"] = all([ result["price_pass"], result["filing_pass"], result["info_pass"], result["market_cap_pass"], ]) return result async def run_coverage_check( symbols: list[str], start_date: str, output_dir: Path, write_filtered: bool = False, symbols_file: str | None = None, concurrency: int = 5, ) -> None: """Run coverage check for all symbols.""" output_dir.mkdir(parents=True, exist_ok=True) # Calculate expected trading days start_dt = datetime.strptime(start_date, "%Y-%m-%d") years_elapsed = (datetime.now() - start_dt).days / 365.25 expected_trading_days = int(years_elapsed * APPROX_TRADING_DAYS_PER_YEAR) print(f"\n{'='*80}") print(f"Coverage Check — {len(symbols)} tickers, start_date={start_date}") print(f"Expected trading days: ~{expected_trading_days}, concurrency={concurrency}") print(f"{'='*80}\n") results: list[dict[str, Any]] = [] sem = asyncio.Semaphore(concurrency) counter = {"done": 0} async def _check_with_sem(ticker: str) -> dict[str, Any]: async with sem: result = await _check_ticker( ticker, price_svc, filings_svc, financial_svc, start_date, expected_trading_days, ) counter["done"] += 1 status = "PASS" if result["overall_pass"] else "FAIL" mcap_str = f"${result['market_cap']/1e6:.0f}M" if result["market_cap"] else "N/A" print( f" [{counter['done']:3d}/{len(symbols)}] {ticker:<6s} " f"price={result['price_bars']:>4d} bars ({result['price_coverage']:.0%}) " f"filings={result['filing_count']:>3d} " f"mcap={mcap_str:>8s} " f"-> {status}" ) if result["error"]: print(f" ! {result['error']}") return result async with make_oracle_client() as client: price_svc = PriceService(client) filings_svc = FilingsService(client) financial_svc = FinancialService(client) tasks = [_check_with_sem(ticker) for ticker in symbols] results_unordered = await asyncio.gather(*tasks) # Preserve original symbol order ticker_to_result = {r["ticker"]: r for r in results_unordered} results = [ticker_to_result[s] for s in symbols] # Write CSV csv_path = output_dir / "coverage_check.csv" if results: fieldnames = list(results[0].keys()) with open(csv_path, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(results) # Summary passed = [r for r in results if r["overall_pass"]] failed = [r for r in results if not r["overall_pass"]] print(f"\n{'='*80}") print(f"Summary") print(f"{'='*80}") print(f" Total: {len(results)}") print(f" Passed: {len(passed)}") print(f" Failed: {len(failed)}") if failed: # Breakdown of failure reasons price_fail = sum(1 for r in failed if not r["price_pass"]) filing_fail = sum(1 for r in failed if not r["filing_pass"]) info_fail = sum(1 for r in failed if not r["info_pass"]) mcap_fail = sum(1 for r in failed if not r["market_cap_pass"]) print(f"\n Failure breakdown:") print(f" Price coverage < {MIN_PRICE_COVERAGE:.0%}: {price_fail}") print(f" Filings < {MIN_FILINGS}: {filing_fail}") print(f" Missing company info: {info_fail}") print(f" Market cap out of range: {mcap_fail}") print(f"\n Failed tickers: {', '.join(r['ticker'] for r in failed)}") # Sector distribution of passed tickers sector_counts: dict[str, int] = {} for r in passed: sector = r.get("sector") or "Unknown" sector_counts[sector] = sector_counts.get(sector, 0) + 1 if sector_counts: print(f"\n Sector distribution (passed):") for sector, count in sorted(sector_counts.items(), key=lambda x: -x[1]): print(f" {sector:<30s} {count:>3d}") print(f"\n CSV report: {csv_path}") # Write filtered YAML if write_filtered and passed: filtered_path = output_dir / "symbols_smallmid_filtered.yaml" filtered_symbols = [r["ticker"] for r in passed] with open(filtered_path, "w") as f: yaml.dump( {"symbols": filtered_symbols}, f, default_flow_style=False, sort_keys=False, ) print(f" Filtered YAML ({len(filtered_symbols)} tickers): {filtered_path}") if symbols_file: print(f"\n To use filtered list:") print(f" cp {filtered_path} {symbols_file}") # Go/No-Go print(f"\n{'='*80}") if len(passed) >= 150: print(f"GO: {len(passed)} tickers passed (>= 150 threshold)") elif len(passed) >= 100: print(f"MARGINAL: {len(passed)} tickers passed (100-150 range, consider adding more)") else: print(f"NO-GO: Only {len(passed)} tickers passed (< 100, need more tickers)") print(f"{'='*80}\n") def main() -> None: parser = argparse.ArgumentParser(description="Coverage Check for Symbol Universe") parser.add_argument( "--symbols-file", required=True, help="Path to symbols YAML file", ) parser.add_argument( "--start-date", default="2022-07-01", help="Start date for data coverage check (YYYY-MM-DD)", ) parser.add_argument( "--output-dir", default="./data/analysis", help="Output directory for CSV report", ) parser.add_argument( "--write-filtered", action="store_true", help="Write filtered YAML with only passing tickers", ) parser.add_argument( "--concurrency", type=int, default=5, help="Max concurrent API calls (default: 5)", ) args = parser.parse_args() symbols = _load_symbols(args.symbols_file) if not symbols: print(f"No symbols found in {args.symbols_file}") return asyncio.run( run_coverage_check( symbols=symbols, start_date=args.start_date, output_dir=Path(args.output_dir), write_filtered=args.write_filtered, symbols_file=args.symbols_file, concurrency=args.concurrency, ) ) if __name__ == "__main__": main()