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.
453 lines
15 KiB
Python
453 lines
15 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 datetime as dt
|
|
import json
|
|
import sys
|
|
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)
|
|
bars_raw = _fetch_bars_raw(ticker, start_dt.strftime("%Y-%m-%d"), 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 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
|
|
|
|
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()
|