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.
264 lines
8.8 KiB
Python
264 lines
8.8 KiB
Python
"""Enrich snapshot with historical earnings surprise features.
|
|
|
|
Adds PIT-safe trailing quarterly surprise features derived from Oracle's
|
|
earnings surprise endpoint. The current event's earnings surprise is already
|
|
materialized separately as `earnings_surprise_pct`; this script adds the
|
|
prior 12 quarter surprise vector plus compact summary statistics.
|
|
|
|
Usage:
|
|
uv run python3 scripts/enrich_earnings_history_features.py \
|
|
--input data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_canonical \
|
|
--output data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_canonical_eh
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import concurrent.futures
|
|
import datetime as dt
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pyarrow as pa
|
|
import pyarrow.parquet as pq
|
|
import requests
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
ORACLE_URL = "http://localhost:18001"
|
|
SURPRISE_VECTOR_COLS = [f"sue_lag_{i}_pct" for i in range(1, 13)]
|
|
SUMMARY_COLS = [
|
|
"sue_hist_mean_4q",
|
|
"sue_hist_mean_8q",
|
|
"sue_hist_mean_12q",
|
|
"sue_hist_pos_rate_4q",
|
|
"sue_hist_pos_rate_12q",
|
|
"sue_hist_latest_pct",
|
|
"sue_hist_streak_pos",
|
|
]
|
|
FEATURES = SURPRISE_VECTOR_COLS + SUMMARY_COLS
|
|
|
|
|
|
def _parse_date(value: Any) -> dt.date | None:
|
|
if value is None:
|
|
return None
|
|
text = str(value)
|
|
if not text:
|
|
return None
|
|
try:
|
|
return dt.date.fromisoformat(text[:10])
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _fetch_surprise_quarters(ticker: str) -> list[dict[str, Any]]:
|
|
resp = requests.get(f"{ORACLE_URL}/api/v1/earnings/surprise/{ticker}", timeout=30)
|
|
if resp.status_code != 200:
|
|
return []
|
|
data = resp.json()
|
|
quarters = data.get("quarters") or []
|
|
return [q for q in quarters if q.get("reported_date")]
|
|
|
|
|
|
def _match_current_quarter(quarters: list[dict[str, Any]], event_date: dt.date) -> int | None:
|
|
best_idx: int | None = None
|
|
best_delta = 999999
|
|
for idx, quarter in enumerate(quarters):
|
|
reported_date = _parse_date(quarter.get("reported_date"))
|
|
if reported_date is None:
|
|
continue
|
|
delta = abs((reported_date - event_date).days)
|
|
if delta <= 5 and delta < best_delta:
|
|
best_delta = delta
|
|
best_idx = idx
|
|
return best_idx
|
|
|
|
|
|
def _mean(values: list[float]) -> float | None:
|
|
if not values:
|
|
return None
|
|
return sum(values) / len(values)
|
|
|
|
|
|
def _positive_rate(values: list[float]) -> float | None:
|
|
if not values:
|
|
return None
|
|
return sum(1 for value in values if value > 0.0) / len(values)
|
|
|
|
|
|
def _positive_streak(values: list[float]) -> float | None:
|
|
if not values:
|
|
return None
|
|
streak = 0
|
|
for value in values:
|
|
if value > 0.0:
|
|
streak += 1
|
|
else:
|
|
break
|
|
return float(streak)
|
|
|
|
|
|
def compute_history_features(
|
|
ticker: str,
|
|
event_date: str | dt.date | None,
|
|
quarters: list[dict[str, Any]] | None = None,
|
|
) -> dict[str, float | None]:
|
|
event_dt = _parse_date(event_date)
|
|
result = {feature: None for feature in FEATURES}
|
|
if event_dt is None:
|
|
return result
|
|
|
|
if quarters is None:
|
|
quarters = _fetch_surprise_quarters(ticker)
|
|
if not quarters:
|
|
return result
|
|
|
|
matched_idx = _match_current_quarter(quarters, event_dt)
|
|
if matched_idx is None:
|
|
return result
|
|
|
|
prior_quarters = quarters[matched_idx + 1 : matched_idx + 13]
|
|
prior_values: list[float] = []
|
|
for idx, quarter in enumerate(prior_quarters, start=1):
|
|
surprise_pct = quarter.get("surprise_percentage")
|
|
if surprise_pct is None:
|
|
prior_values.append(float("nan"))
|
|
continue
|
|
value = float(surprise_pct)
|
|
prior_values.append(value)
|
|
result[f"sue_lag_{idx}_pct"] = value
|
|
|
|
clean_values = [value for value in prior_values if value == value]
|
|
clean_4 = clean_values[:4]
|
|
clean_8 = clean_values[:8]
|
|
clean_12 = clean_values[:12]
|
|
|
|
result["sue_hist_mean_4q"] = _mean(clean_4)
|
|
result["sue_hist_mean_8q"] = _mean(clean_8)
|
|
result["sue_hist_mean_12q"] = _mean(clean_12)
|
|
result["sue_hist_pos_rate_4q"] = _positive_rate(clean_4)
|
|
result["sue_hist_pos_rate_12q"] = _positive_rate(clean_12)
|
|
result["sue_hist_latest_pct"] = clean_values[0] if clean_values else None
|
|
result["sue_hist_streak_pos"] = _positive_streak(clean_values)
|
|
return result
|
|
|
|
|
|
def enrich_split(input_path: Path, output_path: Path) -> None:
|
|
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(feature in existing_cols for feature in FEATURES):
|
|
null_counts = {feature: table.column(feature).null_count for feature in FEATURES}
|
|
if all(count < n * 0.5 for count 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()
|
|
event_types = table.column("event_type").to_pylist() if "event_type" in table.column_names else [None] * n
|
|
|
|
results = {feature: [None] * n for feature in FEATURES}
|
|
success = 0
|
|
|
|
earnings_tickers = sorted(
|
|
{
|
|
str(tickers[idx])
|
|
for idx in range(n)
|
|
if event_types[idx] == "earnings_release" and tickers[idx]
|
|
}
|
|
)
|
|
surprise_cache: dict[str, list[dict[str, Any]]] = {}
|
|
if earnings_tickers:
|
|
print(f" Prefetching surprise history for {len(earnings_tickers)} tickers...")
|
|
|
|
def _load_one(ticker: str) -> tuple[str, list[dict[str, Any]]]:
|
|
try:
|
|
return ticker, _fetch_surprise_quarters(ticker)
|
|
except Exception:
|
|
return ticker, []
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as pool:
|
|
for loaded, (ticker, quarters) in enumerate(pool.map(_load_one, earnings_tickers), start=1):
|
|
surprise_cache[ticker] = quarters
|
|
if loaded % 100 == 0 or loaded == len(earnings_tickers):
|
|
print(f" surprise prefetch {loaded}/{len(earnings_tickers)}")
|
|
|
|
for idx in range(n):
|
|
ticker = str(tickers[idx]) if tickers[idx] is not None else ""
|
|
event_date = event_dates[idx]
|
|
event_type = str(event_types[idx] or "")
|
|
|
|
if idx % 100 == 0:
|
|
print(f" {idx}/{n} ({success} enriched)...")
|
|
|
|
if event_type != "earnings_release" or not ticker:
|
|
continue
|
|
|
|
quarters = surprise_cache.get(ticker, [])
|
|
|
|
try:
|
|
features = compute_history_features(ticker, event_date, quarters)
|
|
if any(features[feature] is not None for feature in FEATURES):
|
|
success += 1
|
|
for feature in FEATURES:
|
|
results[feature][idx] = features[feature]
|
|
except Exception as exc:
|
|
if idx < 5:
|
|
print(f" Error {ticker} {event_date}: {exc}")
|
|
|
|
print(f" Done: {success}/{n}")
|
|
|
|
for feature in FEATURES:
|
|
arr = pa.array(results[feature], type=pa.float64())
|
|
if feature in existing_cols:
|
|
col_idx = table.column_names.index(feature)
|
|
table = table.set_column(col_idx, feature, arr)
|
|
else:
|
|
table = table.append_column(feature, arr)
|
|
|
|
pq.write_table(table, output_path)
|
|
print(f" Written to {output_path}")
|
|
|
|
|
|
def main() -> None:
|
|
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()
|