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.

174 lines
6.0 KiB
Python

"""Patch ftb_fix_v2 snapshot by merging fresh base export with existing canonical enrichments.
Strategy:
- 00_base has correct market features + labels (built from freshly-corrected DB)
- Existing canonical has enriched features (tier2/tier3/technical/macro/prior_drift)
- These enrichments don't depend on reaction_date → valid for ALL rows including 1,153 affected
- For rows in both: use base features, copy enrichments from canonical
- For new rows (not in canonical): use base features, NULL enrichments
"""
from __future__ import annotations
import json
import datetime as dt
from pathlib import Path
import pyarrow as pa
import pyarrow.parquet as pq
CANONICAL_DIR = Path("data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_canonical")
BASE_DIR = Path("data/parquet/.midlarge-liquid-long-v1_bucketfix_full_audit_canonical_ftb_fix_v2.stage.3eb6jxkd/00_base")
OUTPUT_DIR = Path("data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_canonical_ftb_fix_v2")
# Columns that come FROM the base export (fresh correct data from DB)
# These are the columns affected by the filing_time_bucket fix
BASE_COLUMNS = {
# Market features (reaction-date-dependent)
"reaction_day_return",
"volume_ratio_20d",
"avg_dollar_volume_20d",
"gap_size",
"close_location",
"event_close",
"event_volume",
"reaction_day_low",
"reaction_day_high",
# Pre-event features (shift by 1 day for affected rows, minor change - use fresh)
"pre_event_momentum_20d",
"price_vs_sma20",
"pre_event_volatility_20d",
"pre_event_rsi_14",
"pre_event_bb_position",
"pre_event_obv_slope_20d",
"atr_14",
# Event features
"filing_time_bucket",
# Labels (reaction_date/entry_date/entry_price/fwd_returns - all depend on fix)
"reaction_date",
"entry_date",
"entry_price",
"fwd_return_1d",
"fwd_return_3d",
"fwd_return_5d",
"fwd_return_10d",
"fwd_return_20d",
"mfe_3d", "mae_3d",
"mfe_5d", "mae_5d",
"mfe_10d", "mae_10d",
"mfe_20d", "mae_20d",
"hit_pos_1r_within_3d",
"hit_neg_1r_within_3d",
"close_up_after_3d",
"close_up_after_5d",
"bars_to_mfe_3d",
"days_to_peak_close_5d",
"label_status",
"label_version",
# Metadata
"event_date",
"market_cap_proxy",
"exchange_proxy",
}
def patch_split(split: str) -> int:
base_path = BASE_DIR / f"{split}.parquet"
canonical_path = CANONICAL_DIR / f"{split}.parquet"
output_path = OUTPUT_DIR / f"{split}.parquet"
if not base_path.exists():
print(f" No {split} split in base, skipping")
return 0
base_table = pq.read_table(base_path)
n = len(base_table)
print(f" Base {split}: {n} rows, {len(base_table.column_names)} cols")
# Build index from existing canonical by event_id
canonical_data: dict[str, dict] = {}
if canonical_path.exists():
canon_table = pq.read_table(canonical_path)
canon_cols = set(canon_table.column_names)
print(f" Canonical {split}: {len(canon_table)} rows, {len(canon_cols)} cols")
# Get event_id column name (might be 'event_id' or other)
id_col = "event_id" if "event_id" in canon_cols else None
if id_col:
ids = canon_table.column(id_col).to_pylist()
# Build dict: event_id -> row dict for enrichment columns
enrichment_cols = [c for c in canon_cols if c not in BASE_COLUMNS and c != id_col]
for j, eid in enumerate(ids):
row = {}
for col in enrichment_cols:
val = canon_table.column(col)[j].as_py()
row[col] = val
canonical_data[eid] = row
print(f" Indexed {len(canonical_data)} canonical rows")
# Build output table
# Start with all base columns
base_cols = base_table.column_names
# Find enrichment columns from canonical not in base
all_canonical_cols = set()
if canonical_path.exists():
canon_table2 = pq.read_table(canonical_path)
all_canonical_cols = set(canon_table2.column_names)
enrich_cols = sorted([c for c in all_canonical_cols if c not in base_cols and c not in {"event_id"}])
print(f" Adding {len(enrich_cols)} enrichment columns from canonical")
# Get event_ids from base
base_id_col = "event_id" if "event_id" in base_cols else None
base_ids = base_table.column(base_id_col).to_pylist() if base_id_col else [None] * n
# For each enrichment column, build array
result_table = base_table
for col in enrich_cols:
# Get type from canonical
if canonical_path.exists():
canon_field = canon_table2.schema.field(col)
col_type = canon_field.type
else:
col_type = pa.float64()
values = []
for eid in base_ids:
val = canonical_data.get(eid, {}).get(col) if eid else None
values.append(val)
try:
arr = pa.array(values, type=col_type)
except Exception:
arr = pa.array(values)
result_table = result_table.append_column(col, arr)
pq.write_table(result_table, output_path)
print(f" Written {len(result_table)} rows, {len(result_table.column_names)} cols to {split}.parquet")
return n
def main():
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
print("Patching ftb_fix_v2 snapshot...")
total = 0
for split in ["train", "valid", "test"]:
print(f"\nProcessing {split}:")
total += patch_split(split)
# Copy manifest from base, update metadata
base_manifest = json.loads((BASE_DIR / "manifest.json").read_text())
base_manifest["snapshot_id"] = OUTPUT_DIR.name
base_manifest["output_dir"] = str(OUTPUT_DIR.resolve())
base_manifest["created_at_utc"] = dt.datetime.now(dt.UTC).isoformat()
base_manifest["ftb_fix_note"] = "Patched: base features from corrected DB, enrichments from existing canonical"
(OUTPUT_DIR / "manifest.json").write_text(json.dumps(base_manifest, indent=2))
print(f"\nDone! Total rows: {total}")
print(f"Output: {OUTPUT_DIR}")
if __name__ == "__main__":
main()