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.
fithia2/scripts/audit_short_volume_coverage.py

181 lines
7.2 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
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())