""" Phase 0: Audit FINRA short_sale_daily coverage for V25 short-volume overlay. Checks whether the DB has sufficient historical short-volume data for: - The midlarge universe over the 200d backtest window (2025-07-07 → 2026-04-21) - The V24 200d trade set (ticker, entry_date) pairs Gates: G0a: Median universe coverage ≥ 80% G0b: V24 trade-set coverage ≥ 80% (≥ 114/142 trades) """ from __future__ import annotations import asyncio import datetime as dt import json import os import sys from pathlib import Path import asyncpg import yaml sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from libs.common.config import get_settings from libs.common.time_utils import trading_days_between UNIVERSE_FILE = "configs/symbols_midlarge_snapshot_exact.yaml" V24_RUN_FILE = "runs/intraday_orb/intraday_20260421_205350_67d5361a.json" LOOKBACK_DAYS = 200 END_DATE = dt.date(2026, 4, 21) async def main() -> None: print("=== Phase 0: short_sale_daily Coverage Audit ===\n") # 1. Compute 200d window all_td = trading_days_between(END_DATE - dt.timedelta(days=400), END_DATE) trading_days = [d.isoformat() for d in all_td[-LOOKBACK_DAYS:]] start_date = trading_days[0] end_date = trading_days[-1] print(f"Window: {start_date} → {end_date} ({len(trading_days)} trading days)") # 2. Load universe with open(UNIVERSE_FILE) as f: udata = yaml.safe_load(f) universe: list[str] = udata.get("symbols", udata) if isinstance(udata, dict) else udata print(f"Universe: {len(universe)} tickers") # 3. Connect to DB (raw asyncpg, same pattern as enrich_tier2_features.py:263) dsn = get_settings().postgres_dsn.replace("+asyncpg", "") conn = await asyncpg.connect(dsn=dsn) try: # 4. Check overall DB date range for short_sale_daily date_range_row = await conn.fetchrow( "SELECT MIN(trade_date)::text AS min_date, MAX(trade_date)::text AS max_date, COUNT(*) AS total_rows FROM short_sale_daily" ) print(f"\nDB table short_sale_daily:") print(f" Total rows: {date_range_row['total_rows']:,}") print(f" Date range: {date_range_row['min_date']} → {date_range_row['max_date']}") # 5. Bulk fetch coverage for universe tickers × window rows = await conn.fetch( """ SELECT ticker_raw, COUNT(DISTINCT trade_date) AS row_count, MIN(trade_date)::text AS min_date, MAX(trade_date)::text AS max_date FROM short_sale_daily WHERE ticker_raw = ANY($1) AND trade_date >= $2::date AND trade_date <= $3::date AND total_volume IS NOT NULL AND total_volume > 0 GROUP BY ticker_raw """, universe, dt.date.fromisoformat(start_date), dt.date.fromisoformat(end_date), ) coverage_by_ticker: dict[str, int] = {r["ticker_raw"]: r["row_count"] for r in rows} n_trading = len(trading_days) pcts = [] zero_tickers = [] for ticker in universe: count = coverage_by_ticker.get(ticker, 0) pct = count / n_trading pcts.append(pct) if count == 0: zero_tickers.append(ticker) pcts_sorted = sorted(pcts) median_pct = pcts_sorted[len(pcts_sorted) // 2] mean_pct = sum(pcts) / len(pcts) tickers_above_80 = sum(1 for p in pcts if p >= 0.80) tickers_above_60 = sum(1 for p in pcts if p >= 0.60) print(f"\nUniverse coverage over window:") print(f" Median: {median_pct*100:.1f}%") print(f" Mean: {mean_pct*100:.1f}%") print(f" Tickers ≥80% coverage: {tickers_above_80}/{len(universe)}") print(f" Tickers ≥60% coverage: {tickers_above_60}/{len(universe)}") print(f" Zero-coverage tickers: {len(zero_tickers)}") if zero_tickers[:10]: print(f" (first 10): {zero_tickers[:10]}") gate_g0a = median_pct >= 0.80 print(f"\n G0a Median ≥ 80%: {median_pct*100:.1f}% → {'PASS ✓' if gate_g0a else 'FAIL ✗'}") # 6. V24 trade-set coverage if Path(V24_RUN_FILE).exists(): with open(V24_RUN_FILE) as f: v24_data = json.load(f) v24_trades = v24_data.get("trades", []) print(f"\nV24 trade set: {len(v24_trades)} trades") # For each trade, check if short volume exists for the entry date and prev 20 days # Use a single bulk query for all (ticker, date) combos within window trade_tickers = list({t["ticker"] for t in v24_trades}) short_rows = await conn.fetch( """ SELECT ticker_raw, trade_date::text AS date FROM short_sale_daily WHERE ticker_raw = ANY($1) AND trade_date >= $2::date AND trade_date <= $3::date AND total_volume IS NOT NULL AND total_volume > 0 """, trade_tickers, dt.date.fromisoformat(start_date), dt.date.fromisoformat(end_date), ) # Build set of (ticker, date) with data short_set: set[tuple[str, str]] = {(r["ticker_raw"], r["date"]) for r in short_rows} # For each trade, check if the prior-day short volume is available # (any short data in the 10 trading days before entry = sufficient for prior-day feature) trade_idx = {d: i for i, d in enumerate(trading_days)} covered = 0 missing_trades: list[str] = [] for trade in v24_trades: ticker = trade["ticker"] entry_date = trade["date"] # Find prior trading days (look back up to 10) if entry_date in trade_idx: idx = trade_idx[entry_date] prior_days = trading_days[max(0, idx - 10):idx] else: prior_days = [] has_prior = any((ticker, d) in short_set for d in prior_days) if has_prior: covered += 1 else: missing_trades.append(f"{trade['date']}:{ticker}") trade_coverage_pct = covered / len(v24_trades) gate_g0b = covered >= int(0.80 * len(v24_trades)) print(f" Trades with prior-day short data: {covered}/{len(v24_trades)} ({trade_coverage_pct*100:.1f}%)") print(f" G0b V24 trade coverage ≥ 80%: {trade_coverage_pct*100:.1f}% → {'PASS ✓' if gate_g0b else 'FAIL ✗'}") if missing_trades[:10]: print(f" Missing trades (first 10): {missing_trades[:10]}") both_pass = gate_g0a and gate_g0b else: print(f"\nWARNING: V24 run file not found: {V24_RUN_FILE}") both_pass = gate_g0a print(f"\n{'='*60}") print(f"PHASE 0 VERDICT: {'PASS — proceed to Phase 1' if both_pass else 'FAIL — backfill required before Phase 1'}") if not both_pass: print(" Action: run `apps/sync/short_volume_sync/main.py --days 300` then re-audit") finally: await conn.close() if __name__ == "__main__": asyncio.run(main())