"""Enrich snapshot with Tier 2 features: FINRA short ratio, Hurst, Entropy, Sector momentum. Usage: PYTHONUNBUFFERED=1 uv run python3 scripts/enrich_tier2_features.py \ --input data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit \ --output data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_tier2 """ from __future__ import annotations import argparse import asyncio import concurrent.futures import datetime as dt import json import sys from collections import defaultdict from pathlib import Path import asyncpg import pyarrow as pa import pyarrow.parquet as pq import requests sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from libs.features.market_features import pre_event_entropy, pre_event_hurst from libs.common.config import get_settings from libs.oracle_client.models import PriceBar ORACLE_URL = "http://localhost:18001" FEATURES = ["pre_event_hurst_60d", "pre_event_entropy_60d", "pre_event_short_ratio", "pre_event_sector_momentum_20d"] _PRICE_HISTORY_START = "2019-01-01" _SHORT_RATIO_HISTORY_DAYS = 2000 _SHORT_RATIO_HISTORY_LOOKBACK_BUFFER_DAYS = 10 _FINRA_DAILY_MAX_CALENDAR_LOOKBACK_DAYS = 14 _FINRA_DAILY_URL_TEMPLATE = "https://cdn.finra.org/equity/regsho/daily/CNMSshvol{yyyymmdd}.txt" SECTOR_ETF = { "Technology": "XLK", "Health Care": "XLV", "Financials": "XLF", "Consumer Discretionary": "XLY", "Industrials": "XLI", "Energy": "XLE", "Utilities": "XLU", "Real Estate": "XLRE", "Materials": "XLB", "Communication Services": "XLC", "Consumer Staples": "XLP", "Healthcare": "XLV", } SECTOR_ALIASES = { "Financial Services": "Financials", "Consumer Cyclical": "Consumer Discretionary", "Consumer Defensive": "Consumer Staples", "Basic Materials": "Materials", } # Cache sector ETF / index bars and FINRA history to keep enrich runs fast. _sector_etf_cache: dict[str, list[dict]] = {} _short_ratio_cache: dict[str, tuple[int, list[dict]]] = {} _short_ratio_db_cache: dict[str, list[dict]] = {} _finra_daily_cache: dict[str, dict[str, float]] = {} _oracle_short_ratio_unavailable = False _http = requests.Session() def _fetch_bars_raw(ticker: str, start: str, end: str) -> list[dict]: resp = requests.get(f"{ORACLE_URL}/api/v1/price/data/{ticker}", params={"start_date": start, "end_date": end}, timeout=30) if resp.status_code != 200: return [] data = resp.json() return data.get("bars") or data.get("data") or [] def _fetch_full_bars_raw(ticker: str) -> list[dict]: if ticker not in _sector_etf_cache: _sector_etf_cache[ticker] = _fetch_bars_raw( ticker, _PRICE_HISTORY_START, dt.date.today().isoformat(), ) return _sector_etf_cache[ticker] def normalize_sector_name(sector: str | None) -> str | None: if sector is None: return None normalized = str(sector).strip() if not normalized: return None return SECTOR_ALIASES.get(normalized, normalized) def _extract_short_ratio_history(payload: dict) -> list[dict]: history = payload.get("history") if isinstance(history, list): return history data = payload.get("data") if isinstance(data, list): return data return [] def _required_short_ratio_days(event_date: str) -> int: event_dt = dt.date.fromisoformat(event_date) raw_days = (dt.date.today() - event_dt).days + _SHORT_RATIO_HISTORY_LOOKBACK_BUFFER_DAYS return max(60, min(raw_days, _SHORT_RATIO_HISTORY_DAYS)) async def _query_short_ratio_history_from_db(ticker: str) -> list[dict]: dsn = get_settings().postgres_dsn.replace("+asyncpg", "") conn = await asyncpg.connect(dsn=dsn) try: rows = await conn.fetch( """ select trade_date::text as date, short_volume::double precision / nullif(total_volume::double precision, 0.0) as short_ratio from short_sale_daily where ticker_raw = $1 and total_volume is not null and total_volume > 0 order by trade_date desc """, ticker, ) finally: await conn.close() return [{"date": row["date"], "short_ratio": row["short_ratio"]} for row in rows if row["short_ratio"] is not None] def _fetch_short_ratio_history_from_db(ticker: str) -> list[dict]: if ticker in _short_ratio_db_cache: return _short_ratio_db_cache[ticker] try: history = asyncio.run(_query_short_ratio_history_from_db(ticker)) except Exception: history = [] _short_ratio_db_cache[ticker] = history return history def _fetch_short_ratio_history_from_oracle(ticker: str, days: int) -> list[dict]: global _oracle_short_ratio_unavailable if _oracle_short_ratio_unavailable: return [] cached = _short_ratio_cache.get(ticker) if cached is not None: cached_days, cached_history = cached if cached_days >= days: return cached_history try: resp = _http.get( f"{ORACLE_URL}/api/v1/finra/short-ratio/{ticker}", params={"days": days}, timeout=10, ) except requests.RequestException: _oracle_short_ratio_unavailable = True _short_ratio_cache[ticker] = (days, []) return [] if resp.status_code != 200: _short_ratio_cache[ticker] = (days, []) return [] history = _extract_short_ratio_history(resp.json()) _short_ratio_cache[ticker] = (days, history) return history def _load_finra_daily_ratios(date_value: dt.date) -> dict[str, float]: cache_key = date_value.isoformat() if cache_key in _finra_daily_cache: return _finra_daily_cache[cache_key] url = _FINRA_DAILY_URL_TEMPLATE.format(yyyymmdd=date_value.strftime("%Y%m%d")) try: resp = _http.get(url, timeout=10) except requests.RequestException: _finra_daily_cache[cache_key] = {} return {} if resp.status_code != 200: _finra_daily_cache[cache_key] = {} return {} lines = [line.strip() for line in resp.text.splitlines() if line.strip()] if not lines: _finra_daily_cache[cache_key] = {} return {} header = [part.strip() for part in lines[0].split("|")] try: symbol_idx = header.index("Symbol") short_idx = header.index("ShortVolume") total_idx = header.index("TotalVolume") except ValueError: _finra_daily_cache[cache_key] = {} return {} ratios: dict[str, float] = {} for line in lines[1:]: parts = [part.strip() for part in line.split("|")] if len(parts) <= max(symbol_idx, short_idx, total_idx): continue symbol = parts[symbol_idx].upper() try: total_volume = float(parts[total_idx]) short_volume = float(parts[short_idx]) except ValueError: continue if total_volume <= 0: continue ratios[symbol] = short_volume / total_volume _finra_daily_cache[cache_key] = ratios return ratios def _fetch_short_ratio_history_from_finra_cdn(ticker: str, event_date: str) -> list[dict]: target = ticker.upper() event_dt = dt.date.fromisoformat(event_date) history: list[dict] = [] current = event_dt - dt.timedelta(days=1) for _ in range(_FINRA_DAILY_MAX_CALENDAR_LOOKBACK_DAYS): ratios = _load_finra_daily_ratios(current) ratio = ratios.get(target) if ratio is not None: history.append({"date": current.isoformat(), "short_ratio": ratio}) if len(history) >= 5: break current -= dt.timedelta(days=1) return history def fetch_bars(ticker: str, event_date: str) -> list[PriceBar]: from datetime import datetime, timedelta end_dt = datetime.strptime(event_date, "%Y-%m-%d") start_dt = end_dt - timedelta(days=120) start_str = start_dt.strftime("%Y-%m-%d") # Use full-history cache to avoid per-row HTTP calls all_bars_raw = _fetch_full_bars_raw(ticker) bars_raw = [b for b in all_bars_raw if start_str <= b.get("date", "") <= event_date] bars = [] for b in bars_raw: try: bars.append(PriceBar(date=b["date"], open=float(b.get("open", 0)), high=float(b.get("high", 0)), low=float(b.get("low", 0)), close=float(b.get("close", 0)), volume=int(b.get("volume", 0)))) except (KeyError, ValueError, TypeError): continue return bars def _prefetch_ticker_bars(tickers: list, max_workers: int = 16) -> None: """Pre-warm full-history bar cache for all unique tickers in parallel.""" unique = sorted({str(t) for t in tickers if t}) print(f" Prefetching price bars for {len(unique)} tickers...") with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool: for loaded, _ in enumerate(pool.map(_fetch_full_bars_raw, unique), start=1): if loaded % 100 == 0 or loaded == len(unique): print(f" bars prefetch {loaded}/{len(unique)}") async def _prefetch_short_ratio_db_batch(tickers: list[str]) -> None: """Bulk-fetch short ratio history for all tickers in a single DB query.""" dsn = get_settings().postgres_dsn.replace("+asyncpg", "") conn = await asyncpg.connect(dsn=dsn) try: rows = await conn.fetch( """ SELECT ticker_raw, trade_date::text AS date, short_volume::double precision / NULLIF(total_volume::double precision, 0.0) AS short_ratio FROM short_sale_daily WHERE ticker_raw = ANY($1) AND total_volume IS NOT NULL AND total_volume > 0 ORDER BY trade_date DESC """, tickers, ) finally: await conn.close() grouped: dict[str, list[dict]] = defaultdict(list) for row in rows: if row["short_ratio"] is not None: grouped[row["ticker_raw"]].append({"date": row["date"], "short_ratio": float(row["short_ratio"])}) for ticker in tickers: _short_ratio_db_cache[ticker] = grouped.get(ticker, []) def fetch_short_ratio(ticker: str, event_date: str) -> float | None: """Fetch average short ratio over 5 days before event.""" points = _fetch_short_ratio_history_from_db(ticker) if not points: points = _fetch_short_ratio_history_from_finra_cdn(ticker, event_date) if not points: points = _fetch_short_ratio_history_from_oracle( ticker, _required_short_ratio_days(event_date), ) if not points: return None # Filter to dates before event before = [] for point in points: value = point.get("short_ratio") if value is None: value = point.get("short_percent") if point.get("date", "") >= event_date or value is None: continue try: before.append({"date": point["date"], "short_ratio": float(value)}) except (KeyError, TypeError, ValueError): continue before.sort(key=lambda p: p["date"], reverse=True) recent = before[:5] if not recent: return None return sum(p["short_ratio"] for p in recent) / len(recent) def fetch_sector_momentum(sector: str, event_date: str) -> float | None: """Compute sector ETF 20d return minus SPY 20d return.""" normalized_sector = normalize_sector_name(sector) etf = SECTOR_ETF.get(normalized_sector or "") if not etf: return None etf_bars = _fetch_full_bars_raw(etf) etf_dated = {b["date"]: float(b.get("close", 0)) for b in etf_bars if b.get("close")} etf_dates = sorted(etf_dated.keys()) if event_date not in etf_dated or len(etf_dates) < 21: return None idx = etf_dates.index(event_date) if idx < 20: return None etf_ret = (etf_dated[etf_dates[idx]] - etf_dated[etf_dates[idx - 20]]) / etf_dated[etf_dates[idx - 20]] spy_bars = _fetch_full_bars_raw("SPY") spy_dated = {b["date"]: float(b.get("close", 0)) for b in spy_bars if b.get("close")} spy_dates = sorted(spy_dated.keys()) if event_date not in spy_dated: return None spy_idx = spy_dates.index(event_date) if spy_idx < 20: return None spy_ret = (spy_dated[spy_dates[spy_idx]] - spy_dated[spy_dates[spy_idx - 20]]) / spy_dated[spy_dates[spy_idx - 20]] return etf_ret - spy_ret def compute_missing_features( ticker: str, event_date: str, sector: str, existing: dict[str, float | None], ) -> dict[str, float | None]: result = dict(existing) need_price_bars = ( result.get("pre_event_hurst_60d") is None or result.get("pre_event_entropy_60d") is None ) bars = fetch_bars(ticker, event_date) if need_price_bars else [] if result.get("pre_event_hurst_60d") is None: result["pre_event_hurst_60d"] = pre_event_hurst(bars, event_date, 60) if bars else None if result.get("pre_event_entropy_60d") is None: result["pre_event_entropy_60d"] = pre_event_entropy(bars, event_date, 60) if bars else None if result.get("pre_event_short_ratio") is None: try: result["pre_event_short_ratio"] = fetch_short_ratio(ticker, event_date) except Exception: result["pre_event_short_ratio"] = None if result.get("pre_event_sector_momentum_20d") is None: try: result["pre_event_sector_momentum_20d"] = fetch_sector_momentum(sector, event_date) except Exception: result["pre_event_sector_momentum_20d"] = None return result def enrich_split(input_path: Path, output_path: Path): table = pq.read_table(input_path) n = len(table) if n == 0 or "ticker" not in table.column_names or "event_date" not in table.column_names: print(" Empty or schema-less split, copying") pq.write_table(table, output_path) return existing_cols = set(table.column_names) if all(f in existing_cols for f in FEATURES): null_counts = {f: table.column(f).null_count for f in FEATURES} if all(v < n * 0.3 for v in null_counts.values()): print(f" Already enriched ({null_counts}), copying") pq.write_table(table, output_path) return tickers = table.column("ticker").to_pylist() event_dates = table.column("event_date").to_pylist() existing_values = { feature: table.column(feature).to_pylist() if feature in existing_cols else [None] * n for feature in FEATURES } # Get sector info if available sectors = table.column("asset_type_proxy").to_pylist() if "asset_type_proxy" in table.column_names else ["UNKNOWN"] * n # Actually sector might be elsewhere — check for common sector columns for col_name in ["sector", "asset_type_proxy"]: if col_name in table.column_names: sectors = table.column(col_name).to_pylist() break results = {f: [None] * n for f in FEATURES} success = 0 # Pre-fetch price bars and short ratio history for all unique tickers in batch _prefetch_ticker_bars(tickers) unique_tickers = sorted({str(t) for t in tickers if t}) try: asyncio.run(_prefetch_short_ratio_db_batch(unique_tickers)) print(f" Short ratio DB prefetch done for {len(unique_tickers)} tickers") except Exception as e: print(f" Short ratio DB prefetch failed (will fall back per-row): {e}") for i in range(n): ticker = tickers[i] event_date = str(event_dates[i]) sector = str(sectors[i]) if sectors[i] else "UNKNOWN" if i % 100 == 0: print(f" {i}/{n} ({success} ok)...") existing = {feature: existing_values[feature][i] for feature in FEATURES} if all(value is not None for value in existing.values()): for feature in FEATURES: results[feature][i] = existing[feature] success += 1 continue try: feats = compute_missing_features(ticker, event_date, sector, existing) for f in FEATURES: results[f][i] = feats.get(f) if feats.get("pre_event_hurst_60d") is not None: success += 1 except Exception as e: if i < 5: print(f" Error {ticker} {event_date}: {e}") print(f" Done: {success}/{n}") for f in FEATURES: arr = pa.array(results[f], type=pa.float64()) if f in existing_cols: idx = table.column_names.index(f) table = table.set_column(idx, f, arr) else: table = table.append_column(f, arr) pq.write_table(table, output_path) print(f" Written to {output_path}") def main(): parser = argparse.ArgumentParser() parser.add_argument("--input", required=True) parser.add_argument("--output", required=True) args = parser.parse_args() input_dir = Path(args.input) output_dir = Path(args.output) output_dir.mkdir(parents=True, exist_ok=True) manifest_src = input_dir / "manifest.json" if manifest_src.exists(): manifest = json.loads(manifest_src.read_text()) source_snapshot_id = manifest.get("snapshot_id") manifest["snapshot_id"] = output_dir.name manifest["output_dir"] = str(output_dir.resolve()) manifest["created_at_utc"] = dt.datetime.now(dt.UTC).isoformat() manifest["enrichment_source_snapshot_id"] = source_snapshot_id manifest["export_enrichments"] = sorted(set((manifest.get("export_enrichments") or []) + FEATURES)) (output_dir / "manifest.json").write_text(json.dumps(manifest, indent=2)) for split in ["train", "valid", "test"]: input_path = input_dir / f"{split}.parquet" if not input_path.exists(): continue output_path = output_dir / f"{split}.parquet" print(f"Enriching {split}...") enrich_split(input_path, output_path) print("All done!") if __name__ == "__main__": main()