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.
440 lines
18 KiB
Python
440 lines
18 KiB
Python
"""Canonical snapshot build orchestration."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pyarrow as pa
|
|
import pyarrow.compute as pc
|
|
import pyarrow.parquet as pq
|
|
|
|
from libs.backtest.snapshots import (
|
|
canonical_snapshot_dir,
|
|
get_canonical_spec,
|
|
get_requested_aliases,
|
|
resolve_snapshot,
|
|
)
|
|
from libs.backtest.snapshot_store import SnapshotStore
|
|
from libs.common.config import get_settings
|
|
from libs.common.logging import get_logger
|
|
from libs.common.time_utils import utc_now
|
|
from libs.db.session import get_session
|
|
from libs.export.snapshot_export import export_dataset_snapshot
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
_ENRICHMENT_SCRIPT_BY_STEP = {
|
|
"earnings_history_enrich": Path("scripts/enrich_earnings_history_features.py"),
|
|
"peer_surprise_enrich": Path("scripts/enrich_peer_surprise_features.py"),
|
|
"catalyst_persistence_enrich": Path("scripts/enrich_catalyst_persistence_features.py"),
|
|
"tier2_enrich": Path("scripts/enrich_tier2_features.py"),
|
|
"tier3_enrich": Path("scripts/enrich_tier3_features.py"),
|
|
"technical_enrich": Path("scripts/enrich_technical_features.py"),
|
|
"macro_enrich": Path("scripts/enrich_macro_features.py"),
|
|
"prior_drift_enrich": Path("scripts/enrich_prior_drift.py"),
|
|
}
|
|
|
|
|
|
def _coverage_end_date(snapshot_dir: Path) -> str | None:
|
|
max_date: dt.date | None = None
|
|
for split_name in ("train", "valid", "test"):
|
|
parquet_path = snapshot_dir / f"{split_name}.parquet"
|
|
if not parquet_path.exists():
|
|
continue
|
|
table = pq.read_table(str(parquet_path))
|
|
if "event_date" not in table.column_names:
|
|
continue
|
|
table = table.select(["event_date"])
|
|
for value in table.column("event_date").to_pylist():
|
|
if isinstance(value, str):
|
|
candidate = dt.date.fromisoformat(value[:10])
|
|
elif isinstance(value, dt.datetime):
|
|
candidate = value.date()
|
|
elif isinstance(value, dt.date):
|
|
candidate = value
|
|
else:
|
|
continue
|
|
if max_date is None or candidate > max_date:
|
|
max_date = candidate
|
|
return max_date.isoformat() if max_date is not None else None
|
|
|
|
|
|
def _write_canonical_manifest(
|
|
snapshot_dir: Path,
|
|
canonical_snapshot_id: str,
|
|
spec: dict[str, Any],
|
|
extra_fields: dict[str, Any] | None = None,
|
|
) -> None:
|
|
manifest_path = snapshot_dir / "manifest.json"
|
|
manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else {}
|
|
manifest.update(
|
|
{
|
|
"snapshot_id": canonical_snapshot_id,
|
|
"canonical_snapshot_id": canonical_snapshot_id,
|
|
"output_dir": str(snapshot_dir.resolve()),
|
|
"feature_sets": list(spec.get("feature_sets") or []),
|
|
"enrichment_steps": list(spec.get("enrichment_steps") or []),
|
|
"requested_aliases": get_requested_aliases(canonical_snapshot_id),
|
|
"coverage_end_date": _coverage_end_date(snapshot_dir),
|
|
"last_refresh_utc": utc_now().isoformat(),
|
|
"refresh_policy": spec.get("refresh_policy"),
|
|
"purpose": spec.get("purpose"),
|
|
"expected_feature_columns": list(spec.get("expected_feature_columns") or []),
|
|
"universe_profile": spec.get("universe_profile"),
|
|
"start_date": spec.get("start_date"),
|
|
"end_date": spec.get("end_date"),
|
|
}
|
|
)
|
|
if extra_fields:
|
|
manifest.update(extra_fields)
|
|
manifest_path.write_text(json.dumps(manifest, indent=2))
|
|
|
|
|
|
def _run_enrichment_step(step_name: str, input_dir: Path, output_dir: Path) -> None:
|
|
script_path = _ENRICHMENT_SCRIPT_BY_STEP[step_name]
|
|
cmd = [
|
|
sys.executable,
|
|
str(script_path),
|
|
"--input",
|
|
str(input_dir),
|
|
"--output",
|
|
str(output_dir),
|
|
]
|
|
logger.info("canonical_snapshot_enrichment_start", step=step_name, cmd=cmd)
|
|
env = {**__import__("os").environ, "PYTHONUNBUFFERED": "1"}
|
|
subprocess.run(cmd, check=True, env=env)
|
|
|
|
|
|
async def _materialize_runtime_backfills(snapshot_dir: Path) -> list[str]:
|
|
settings = get_settings()
|
|
return await SnapshotStore._async_materialize_snapshot_dir(
|
|
snapshot_dir,
|
|
oracle_url=settings.stock_oracle_url,
|
|
db_dsn=settings.postgres_dsn,
|
|
)
|
|
|
|
|
|
def _atomic_swap_directory(staged_final_dir: Path, target_dir: Path) -> None:
|
|
backup_dir = target_dir.with_name(f"{target_dir.name}.bak.{utc_now().strftime('%Y%m%d%H%M%S%f')}")
|
|
backup_created = False
|
|
try:
|
|
if target_dir.exists():
|
|
target_dir.rename(backup_dir)
|
|
backup_created = True
|
|
staged_final_dir.rename(target_dir)
|
|
except Exception:
|
|
if backup_created and not target_dir.exists() and backup_dir.exists():
|
|
backup_dir.rename(target_dir)
|
|
raise
|
|
else:
|
|
if backup_created and backup_dir.exists():
|
|
shutil.rmtree(backup_dir)
|
|
|
|
|
|
def _resolve_bootstrap_source_dir(source_snapshot_id: str) -> Path:
|
|
"""Resolve an existing snapshot dir for bootstrap from managed roots."""
|
|
source_path = Path(source_snapshot_id)
|
|
if source_path.is_absolute() and source_path.exists():
|
|
return source_path
|
|
|
|
settings = get_settings()
|
|
candidate_roots = [
|
|
Path(settings.parquet_dir),
|
|
Path("data/datasets/snapshots"),
|
|
]
|
|
for root in candidate_roots:
|
|
candidate = root / source_snapshot_id
|
|
if candidate.exists():
|
|
return candidate.resolve()
|
|
raise FileNotFoundError(f"Bootstrap source snapshot not found: {source_snapshot_id}")
|
|
|
|
|
|
async def build_canonical_snapshot(
|
|
requested_snapshot_id: str,
|
|
*,
|
|
manual: bool = False,
|
|
) -> Path:
|
|
"""Build a registry-managed canonical snapshot via full rebuild + atomic swap."""
|
|
resolution = resolve_snapshot(requested_snapshot_id, allow_legacy_passthrough=False)
|
|
canonical_snapshot_id = resolution.canonical_snapshot_id
|
|
spec = get_canonical_spec(canonical_snapshot_id)
|
|
|
|
refresh_policy = str(spec.get("refresh_policy") or "")
|
|
if refresh_policy == "manual_only" and not manual:
|
|
raise RuntimeError(f"Snapshot '{canonical_snapshot_id}' is frozen for auto-refresh.")
|
|
|
|
target_dir = canonical_snapshot_dir(canonical_snapshot_id)
|
|
target_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with tempfile.TemporaryDirectory(
|
|
prefix=f".{canonical_snapshot_id}.stage.",
|
|
dir=str(target_dir.parent),
|
|
) as stage_root_str:
|
|
stage_root = Path(stage_root_str)
|
|
current_dir = stage_root / "00_base"
|
|
|
|
start_date = spec.get("start_date")
|
|
end_date = spec.get("end_date")
|
|
async with get_session() as session:
|
|
await export_dataset_snapshot(
|
|
session=session,
|
|
snapshot_id=current_dir.name,
|
|
split_policy="temporal_70_15_15",
|
|
output_dir=stage_root,
|
|
feature_versions=list(spec.get("feature_versions") or []),
|
|
universe_profile=spec.get("universe_profile"),
|
|
label_version=spec.get("label_version", "label-1.0.0"),
|
|
start_date=dt.date.fromisoformat(start_date) if start_date else None,
|
|
end_date=dt.date.fromisoformat(end_date) if end_date else None,
|
|
include_export_enrichments=False,
|
|
)
|
|
|
|
steps = [step for step in list(spec.get("enrichment_steps") or []) if step != "base_export"]
|
|
for idx, step in enumerate(steps, start=1):
|
|
is_last = idx == len(steps)
|
|
next_dir = stage_root / (canonical_snapshot_id if is_last else f"{idx:02d}_{step}")
|
|
_run_enrichment_step(step, current_dir, next_dir)
|
|
current_dir = next_dir
|
|
|
|
if current_dir.name != canonical_snapshot_id:
|
|
final_dir = stage_root / canonical_snapshot_id
|
|
shutil.copytree(current_dir, final_dir)
|
|
current_dir = final_dir
|
|
|
|
materialized_columns = await _materialize_runtime_backfills(current_dir)
|
|
_write_canonical_manifest(
|
|
current_dir,
|
|
canonical_snapshot_id,
|
|
spec,
|
|
extra_fields={
|
|
"materialized_feature_columns": materialized_columns,
|
|
"materialization_mode": "runtime_backfill_persisted_v1",
|
|
},
|
|
)
|
|
_atomic_swap_directory(current_dir, target_dir)
|
|
|
|
return target_dir
|
|
|
|
|
|
async def bootstrap_canonical_snapshot(
|
|
requested_snapshot_id: str,
|
|
*,
|
|
source_snapshot_id: str,
|
|
enrichment_steps: list[str] | None = None,
|
|
) -> Path:
|
|
"""Materialize a canonical snapshot from an existing local snapshot directory.
|
|
|
|
This is a parity-preserving migration path for phase 1 when the legacy
|
|
enriched snapshot is the current source of truth.
|
|
"""
|
|
resolution = resolve_snapshot(requested_snapshot_id, allow_legacy_passthrough=False)
|
|
canonical_snapshot_id = resolution.canonical_snapshot_id
|
|
spec = get_canonical_spec(canonical_snapshot_id)
|
|
|
|
source_dir = _resolve_bootstrap_source_dir(source_snapshot_id)
|
|
|
|
target_dir = canonical_snapshot_dir(canonical_snapshot_id)
|
|
target_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with tempfile.TemporaryDirectory(
|
|
prefix=f".{canonical_snapshot_id}.bootstrap.",
|
|
dir=str(target_dir.parent),
|
|
) as stage_root_str:
|
|
stage_root = Path(stage_root_str)
|
|
current_dir = stage_root / canonical_snapshot_id
|
|
shutil.copytree(source_dir, current_dir)
|
|
|
|
for idx, step in enumerate(list(enrichment_steps or []), start=1):
|
|
is_last = idx == len(list(enrichment_steps or []))
|
|
next_dir = stage_root / (canonical_snapshot_id if is_last else f"bootstrap_{idx:02d}_{step}")
|
|
_run_enrichment_step(step, current_dir, next_dir)
|
|
current_dir = next_dir
|
|
|
|
materialized_columns = await _materialize_runtime_backfills(current_dir)
|
|
_write_canonical_manifest(
|
|
current_dir,
|
|
canonical_snapshot_id,
|
|
spec,
|
|
extra_fields={
|
|
"bootstrap_source_snapshot_id": source_snapshot_id,
|
|
"bootstrap_mode": "copy_existing_snapshot",
|
|
"materialized_feature_columns": materialized_columns,
|
|
"materialization_mode": "runtime_backfill_persisted_v1",
|
|
},
|
|
)
|
|
_atomic_swap_directory(current_dir, target_dir)
|
|
|
|
return target_dir
|
|
|
|
|
|
async def incremental_update_canonical_snapshot(
|
|
requested_snapshot_id: str,
|
|
) -> Path:
|
|
"""Append only new events (since coverage_end_date) from DB to existing canonical.
|
|
|
|
Does not rebuild existing rows. Runs enrichment scripts only on new rows.
|
|
New rows are appended to the test split (they are always the most recent events).
|
|
"""
|
|
resolution = resolve_snapshot(requested_snapshot_id, allow_legacy_passthrough=False)
|
|
canonical_snapshot_id = resolution.canonical_snapshot_id
|
|
spec = get_canonical_spec(canonical_snapshot_id)
|
|
target_dir = canonical_snapshot_dir(canonical_snapshot_id)
|
|
|
|
if not target_dir.exists():
|
|
raise FileNotFoundError(f"Canonical snapshot not found: {target_dir}")
|
|
|
|
# Determine start_date for new events
|
|
max_date_str = _coverage_end_date(target_dir)
|
|
if max_date_str is None:
|
|
raise ValueError("Cannot determine coverage_end_date from existing canonical")
|
|
new_start = dt.date.fromisoformat(max_date_str) + dt.timedelta(days=1)
|
|
|
|
logger.info(
|
|
"incremental_update_canonical_start",
|
|
canonical_snapshot_id=canonical_snapshot_id,
|
|
existing_coverage_end=max_date_str,
|
|
new_start=str(new_start),
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory(
|
|
prefix=f".{canonical_snapshot_id}.incremental.",
|
|
dir=str(target_dir.parent),
|
|
) as stage_root_str:
|
|
stage_root = Path(stage_root_str)
|
|
|
|
# 1. Export new events from DB (start_date = day after current coverage)
|
|
end_date_str = spec.get("end_date")
|
|
async with get_session() as session:
|
|
await export_dataset_snapshot(
|
|
session=session,
|
|
snapshot_id="new_events",
|
|
split_policy="temporal_70_15_15",
|
|
output_dir=stage_root,
|
|
feature_versions=list(spec.get("feature_versions") or []),
|
|
universe_profile=spec.get("universe_profile"),
|
|
label_version=spec.get("label_version", "label-1.0.0"),
|
|
start_date=new_start,
|
|
end_date=dt.date.fromisoformat(end_date_str) if end_date_str else None,
|
|
include_export_enrichments=False,
|
|
)
|
|
|
|
new_export_dir = stage_root / "new_events"
|
|
|
|
# Combine all exported splits into one batch (all new rows are recent → go to test)
|
|
all_new_tables = []
|
|
for split in ("train", "valid", "test"):
|
|
p = new_export_dir / f"{split}.parquet"
|
|
if p.exists():
|
|
t = pq.read_table(str(p))
|
|
if len(t) > 0:
|
|
all_new_tables.append(t)
|
|
|
|
if not all_new_tables:
|
|
logger.info("incremental_update_no_new_events", new_start=str(new_start))
|
|
return target_dir
|
|
|
|
all_new_rows = pa.concat_tables(all_new_tables, promote_options="default")
|
|
total_new = len(all_new_rows)
|
|
logger.info("incremental_update_new_rows_exported", count=total_new, new_start=str(new_start))
|
|
|
|
# 2. Write new rows to enrichment input dir (all as test split, empty train/valid)
|
|
enrich_input_dir = stage_root / "00_new_base"
|
|
enrich_input_dir.mkdir()
|
|
empty = all_new_rows.schema.empty_table()
|
|
pq.write_table(empty, enrich_input_dir / "train.parquet")
|
|
pq.write_table(empty, enrich_input_dir / "valid.parquet")
|
|
pq.write_table(all_new_rows, enrich_input_dir / "test.parquet")
|
|
# Copy manifest from export
|
|
manifest_src = new_export_dir / "manifest.json"
|
|
if manifest_src.exists():
|
|
shutil.copy2(manifest_src, enrich_input_dir / "manifest.json")
|
|
|
|
# 3. Run enrichment steps on new rows only
|
|
steps = [s for s in list(spec.get("enrichment_steps") or []) if s != "base_export"]
|
|
current_dir = enrich_input_dir
|
|
for idx, step in enumerate(steps, start=1):
|
|
is_last = idx == len(steps)
|
|
next_dir = stage_root / (f"enrich_{canonical_snapshot_id}" if is_last else f"enrich_{idx:02d}_{step}")
|
|
_run_enrichment_step(step, current_dir, next_dir)
|
|
current_dir = next_dir
|
|
|
|
if current_dir.name != f"enrich_{canonical_snapshot_id}":
|
|
final_enrich_dir = stage_root / f"enrich_{canonical_snapshot_id}"
|
|
shutil.copytree(current_dir, final_enrich_dir)
|
|
current_dir = final_enrich_dir
|
|
|
|
# 4. Materialize runtime backfills for new rows
|
|
await _materialize_runtime_backfills(current_dir)
|
|
|
|
# 5. Load enriched new test rows
|
|
new_test = pq.read_table(str(current_dir / "test.parquet"))
|
|
logger.info("incremental_update_new_rows_enriched", count=len(new_test))
|
|
|
|
# 6. Load existing canonical splits
|
|
existing_train = pq.read_table(str(target_dir / "train.parquet"))
|
|
existing_valid = pq.read_table(str(target_dir / "valid.parquet"))
|
|
existing_test = pq.read_table(str(target_dir / "test.parquet"))
|
|
|
|
# 7. Append new rows to test split; align schemas (new columns get null in old rows)
|
|
merged_test = pa.concat_tables([existing_test, new_test], promote_options="default")
|
|
if "event_date" in merged_test.column_names:
|
|
sort_idx = pc.sort_indices(merged_test, sort_keys=[("event_date", "ascending")])
|
|
merged_test = merged_test.take(sort_idx)
|
|
|
|
# 8. Write merged canonical to staging
|
|
merged_dir = stage_root / canonical_snapshot_id
|
|
merged_dir.mkdir()
|
|
pq.write_table(existing_train, str(merged_dir / "train.parquet"))
|
|
pq.write_table(existing_valid, str(merged_dir / "valid.parquet"))
|
|
pq.write_table(merged_test, str(merged_dir / "test.parquet"))
|
|
|
|
# 9. Write manifest and atomic swap
|
|
_write_canonical_manifest(merged_dir, canonical_snapshot_id, spec)
|
|
_atomic_swap_directory(merged_dir, target_dir)
|
|
|
|
logger.info(
|
|
"incremental_update_canonical_done",
|
|
canonical_snapshot_id=canonical_snapshot_id,
|
|
new_rows_added=total_new,
|
|
)
|
|
return target_dir
|
|
|
|
|
|
async def materialize_canonical_snapshot(
|
|
requested_snapshot_id: str,
|
|
) -> Path:
|
|
"""Persist runtime-backfilled features into an existing canonical snapshot."""
|
|
resolution = resolve_snapshot(requested_snapshot_id, allow_legacy_passthrough=False)
|
|
canonical_snapshot_id = resolution.canonical_snapshot_id
|
|
spec = get_canonical_spec(canonical_snapshot_id)
|
|
target_dir = canonical_snapshot_dir(canonical_snapshot_id)
|
|
if not target_dir.exists():
|
|
raise FileNotFoundError(f"Canonical snapshot not found: {target_dir}")
|
|
|
|
with tempfile.TemporaryDirectory(
|
|
prefix=f".{canonical_snapshot_id}.materialize.",
|
|
dir=str(target_dir.parent),
|
|
) as stage_root_str:
|
|
stage_root = Path(stage_root_str)
|
|
current_dir = stage_root / canonical_snapshot_id
|
|
shutil.copytree(target_dir, current_dir)
|
|
materialized_columns = await _materialize_runtime_backfills(current_dir)
|
|
_write_canonical_manifest(
|
|
current_dir,
|
|
canonical_snapshot_id,
|
|
spec,
|
|
extra_fields={
|
|
"materialized_feature_columns": materialized_columns,
|
|
"materialization_mode": "runtime_backfill_persisted_v1",
|
|
},
|
|
)
|
|
_atomic_swap_directory(current_dir, target_dir)
|
|
|
|
return target_dir
|