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.

505 lines
18 KiB
Python

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