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.
169 lines
5.7 KiB
Python
169 lines
5.7 KiB
Python
"""Enrich snapshot with prior_event_fwd5d (cross-event momentum feature).
|
|
|
|
Computes from existing snapshot data — no Oracle API needed.
|
|
PIT-safe: only uses realized forward returns from prior events.
|
|
|
|
Usage:
|
|
uv run python3 scripts/enrich_prior_drift.py \
|
|
--input data/parquet/midwide-liquid-long-v1_tier3_macro \
|
|
--output data/parquet/midwide-liquid-long-v1_full
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pyarrow as pa
|
|
import pyarrow.parquet as pq
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
|
|
def trading_days_between(start: dt.date, end: dt.date) -> list[dt.date]:
|
|
"""Count approximate trading days (Mon-Fri, no holiday exclusion)."""
|
|
days = []
|
|
cur = start
|
|
while cur <= end:
|
|
if cur.weekday() < 5:
|
|
days.append(cur)
|
|
cur += dt.timedelta(days=1)
|
|
return days
|
|
|
|
|
|
def compute_prior_drift(rows: list[dict]) -> list[float | None]:
|
|
"""Compute prior_event_fwd5d for all rows. PIT-safe."""
|
|
sorted_rows = sorted(
|
|
rows,
|
|
key=lambda r: (
|
|
str(r.get("ticker", "")),
|
|
str(r.get("event_date", "")),
|
|
str(r.get("entry_date", "")),
|
|
),
|
|
)
|
|
|
|
realized_on_cache: dict[dt.date, dt.date | None] = {}
|
|
|
|
def _fwd5_realized_on(entry_date_str: str | None) -> dt.date | None:
|
|
if not entry_date_str:
|
|
return None
|
|
try:
|
|
entry_date = dt.date.fromisoformat(str(entry_date_str))
|
|
except (ValueError, TypeError):
|
|
return None
|
|
cached = realized_on_cache.get(entry_date)
|
|
if cached is not None or entry_date in realized_on_cache:
|
|
return cached
|
|
tdays = trading_days_between(entry_date, entry_date + dt.timedelta(days=14))
|
|
realized_on = tdays[5] if len(tdays) > 5 else None
|
|
realized_on_cache[entry_date] = realized_on
|
|
return realized_on
|
|
|
|
prev_by_ticker: dict[str, tuple[float | None, dt.date | None]] = {}
|
|
result_map: dict[int, float | None] = {}
|
|
|
|
for row in sorted_rows:
|
|
orig_idx = row["_orig_idx"]
|
|
ticker = str(row.get("ticker", ""))
|
|
try:
|
|
current_event_date = dt.date.fromisoformat(str(row.get("event_date", "")))
|
|
except (ValueError, TypeError):
|
|
result_map[orig_idx] = None
|
|
continue
|
|
|
|
prior_value: float | None = None
|
|
prior = prev_by_ticker.get(ticker)
|
|
if prior is not None:
|
|
candidate_value, realized_on = prior
|
|
if realized_on is not None and current_event_date > realized_on:
|
|
prior_value = candidate_value
|
|
|
|
result_map[orig_idx] = prior_value
|
|
|
|
fwd5 = row.get("fwd_return_5d")
|
|
entry_date_str = row.get("entry_date")
|
|
prev_by_ticker[ticker] = (
|
|
float(fwd5) if fwd5 is not None else None,
|
|
_fwd5_realized_on(str(entry_date_str) if entry_date_str else None),
|
|
)
|
|
|
|
return [result_map[i] for i in range(len(rows))]
|
|
|
|
|
|
def enrich_split(input_path: Path, output_path: Path):
|
|
table = pq.read_table(input_path)
|
|
n = len(table)
|
|
|
|
if "prior_event_fwd5d" in table.column_names:
|
|
null_count = table.column("prior_event_fwd5d").null_count
|
|
if null_count < n * 0.3:
|
|
print(f" Already has prior_event_fwd5d ({null_count} nulls), copying as-is")
|
|
pq.write_table(table, output_path)
|
|
return
|
|
|
|
cols = table.column_names
|
|
rows = []
|
|
tickers = table.column("ticker").to_pylist() if "ticker" in cols else [None] * n
|
|
event_dates = table.column("event_date").to_pylist() if "event_date" in cols else [None] * n
|
|
entry_dates = table.column("entry_date").to_pylist() if "entry_date" in cols else [None] * n
|
|
fwd5s = table.column("fwd_return_5d").to_pylist() if "fwd_return_5d" in cols else [None] * n
|
|
|
|
for i in range(n):
|
|
rows.append({
|
|
"_orig_idx": i,
|
|
"ticker": tickers[i],
|
|
"event_date": str(event_dates[i]) if event_dates[i] else None,
|
|
"entry_date": str(entry_dates[i]) if entry_dates[i] else None,
|
|
"fwd_return_5d": fwd5s[i],
|
|
})
|
|
|
|
values = compute_prior_drift(rows)
|
|
enriched = sum(1 for v in values if v is not None)
|
|
print(f" prior_event_fwd5d: {enriched}/{n} non-null")
|
|
|
|
arr = pa.array(values, type=pa.float64())
|
|
if "prior_event_fwd5d" in table.column_names:
|
|
idx = table.column_names.index("prior_event_fwd5d")
|
|
table = table.set_column(idx, "prior_event_fwd5d", arr)
|
|
else:
|
|
table = table.append_column("prior_event_fwd5d", arr)
|
|
|
|
pq.write_table(table, output_path)
|
|
print(f" Written to {output_path}")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--input", required=True, help="Input snapshot dir")
|
|
parser.add_argument("--output", required=True, help="Output snapshot dir")
|
|
args = parser.parse_args()
|
|
|
|
input_dir = Path(args.input)
|
|
output_dir = Path(args.output)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Copy manifest
|
|
manifest_src = input_dir / "manifest.json"
|
|
if manifest_src.exists():
|
|
import json as _json
|
|
manifest = _json.loads(manifest_src.read_text())
|
|
manifest["snapshot_id"] = output_dir.name
|
|
(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():
|
|
print(f"Skipping {split} (not found)")
|
|
continue
|
|
output_path = output_dir / f"{split}.parquet"
|
|
print(f"\nProcessing {split}...")
|
|
enrich_split(input_path, output_path)
|
|
|
|
print("\nDone.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|