"""Enrich snapshot with macro regime features: macro_vix, macro_hy_spread, macro_t10y2y from FRED. Usage: uv run python3 scripts/enrich_macro_features.py \ --input data/parquet/midwide-liquid-long-v1_tier2 \ --output data/parquet/midwide-liquid-long-v1_tier2_macro """ from __future__ import annotations import argparse import json import shutil import sys from pathlib import Path 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" FEATURES = ["macro_vix", "macro_hy_spread", "macro_t10y2y"] def fetch_fred_series(series_id: str, start: str, end: str) -> dict[str, float]: """Fetch FRED observations and return {date: value} dict.""" resp = requests.get( f"{ORACLE_URL}/api/v1/fred/proxy/series/observations", params={"series_id": series_id, "observation_start": start, "observation_end": end}, timeout=30, ) if resp.status_code != 200: print(f" FRED error {resp.status_code} for {series_id}") return {} data = resp.json() obs = data.get("data", {}).get("observations", []) result = {} for o in obs: v = o.get("value") if v not in (None, ".", ""): try: result[o["date"]] = float(v) except (ValueError, TypeError): pass return result def get_prior_value(series_dict: dict[str, float], date: str) -> float | None: """Get the most recent value on or before date (forward-fill).""" sorted_dates = sorted(series_dict.keys()) result = None for d in sorted_dates: if d <= date: result = series_dict[d] else: break return result def enrich_split(input_path: Path, output_path: Path, vix_dict: dict, hy_dict: dict, t10y2y_dict: dict): table = pq.read_table(input_path) n = len(table) existing_cols = set(table.column_names) if "event_date" not in existing_cols: print(" Empty or schema-less split, copying") pq.write_table(table, output_path) return # Check if already enriched (all 3 features present with <10% null) 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.1 for v in null_counts.values()): print(f" Already enriched, copying as-is") pq.write_table(table, output_path) return event_dates = table.column("event_date").to_pylist() vix_vals = [] hy_vals = [] t10y2y_vals = [] for ed in event_dates: date_str = str(ed) vix_vals.append(get_prior_value(vix_dict, date_str)) hy_vals.append(get_prior_value(hy_dict, date_str)) t10y2y_vals.append(get_prior_value(t10y2y_dict, date_str)) enriched = sum(1 for v in vix_vals if v is not None) enriched_yc = sum(1 for v in t10y2y_vals if v is not None) print(f" Enriched {enriched}/{n} rows with macro_vix/macro_hy_spread, {enriched_yc}/{n} with macro_t10y2y") for col_name, values in [("macro_vix", vix_vals), ("macro_hy_spread", hy_vals), ("macro_t10y2y", t10y2y_vals)]: arr = pa.array(values, type=pa.float64()) if col_name in existing_cols: idx = table.column_names.index(col_name) table = table.set_column(idx, col_name, arr) else: table = table.append_column(col_name, 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(): manifest = json.loads(manifest_src.read_text()) manifest["snapshot_id"] = output_dir.name (output_dir / "manifest.json").write_text(json.dumps(manifest, indent=2)) # Determine date range across all splits all_dates = [] for split in ["train", "valid", "test"]: p = input_dir / f"{split}.parquet" if p.exists(): t = pq.read_table(p) if "event_date" not in t.column_names: continue t = t.select(["event_date"]) all_dates.extend([str(d) for d in t.column("event_date").to_pylist()]) if not all_dates: print("No dates found, copying as-is") for split in ["train", "valid", "test"]: p = input_dir / f"{split}.parquet" if p.exists(): shutil.copy2(p, output_dir / f"{split}.parquet") return start = min(all_dates) end = max(all_dates) print(f"Fetching FRED data for {start} to {end}...") vix_dict = fetch_fred_series("VIXCLS", start, end) hy_dict = fetch_fred_series("BAMLH0A0HYM2", start, end) t10y2y_dict = fetch_fred_series("T10Y2Y", start, end) print(f" VIX: {len(vix_dict)} obs, HY: {len(hy_dict)} obs, T10Y2Y: {len(t10y2y_dict)} obs") 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, vix_dict, hy_dict, t10y2y_dict) print("\nDone.") if __name__ == "__main__": main()