"""Frozen, replayable Tourism research runs. Validated runs require point-in-time inputs. Explicit exploratory runs may use revised vendor history, but their result is permanently labelled descriptive only and does not satisfy the validated backtest gate. """ from __future__ import annotations import copy import hashlib import json import math import re from datetime import datetime, timezone from pathlib import Path from typing import Any, Mapping, Sequence from .event_study import EventStudyError, _canonical_timestamp, assess_backtest_readiness, run_event_study from .prices import PIT_ARCHIVE_CONTRACT, PriceSnapshotStore, PriceSourceError from .tourism import compute_tourism_signal from .vintages import VintageStore, VintageStoreError RUN_SCHEMA_VERSION = 1 REPORT_HASH_ALGORITHM = "sha256-json-canonical-v1" _RUN_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}") RESEARCH_MODES = {"validated", "exploratory"} _OBSERVATION_RUNTIME_FIELDS = frozenset({"first_seen_at", "last_seen_at", "observation_count"}) class ResearchRunError(ValueError): """Raised when a research run cannot be safely created or replayed.""" def _canonical_hash(payload: Mapping[str, Any]) -> str: encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") return hashlib.sha256(encoded).hexdigest() def _report_hash(report: Mapping[str, Any]) -> str: payload = copy.deepcopy(dict(report)) payload.pop("report_hash", None) payload.pop("report_hash_algorithm", None) return _canonical_hash(payload) def _manifest_entry_hash(entry: Mapping[str, Any]) -> str: payload = copy.deepcopy(dict(entry)) payload.pop("manifest_entry_hash", None) payload.pop("manifest_entry_hash_algorithm", None) return _canonical_hash(payload) def _atomic_write(path: Path, payload: Mapping[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(f".{path.name}.tmp") temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") temporary.replace(path) class ResearchRunStore: """Immutable JSON reports with a compact run manifest.""" def __init__(self, root: Path | str) -> None: self.root = Path(root).resolve() self.report_dir = self.root / "reports" self.manifest_path = self.root / "manifest.json" def load_manifest(self) -> dict[str, Any]: if not self.manifest_path.is_file(): return {"schema_version": RUN_SCHEMA_VERSION, "runs": {}} try: manifest = json.loads(self.manifest_path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise ResearchRunError("research run manifest is unreadable") from exc if ( not isinstance(manifest, dict) or manifest.get("schema_version") != RUN_SCHEMA_VERSION or not isinstance(manifest.get("runs"), dict) ): raise ResearchRunError("unsupported research run manifest schema") return manifest def _report_path(self, run_id: str) -> Path: if not _RUN_ID_RE.fullmatch(run_id): raise ResearchRunError("invalid research run id") candidate = (self.report_dir / f"{run_id}.json").resolve() if candidate.parent != self.report_dir.resolve(): raise ResearchRunError("research report path escaped store root") return candidate def load(self, run_id: str) -> dict[str, Any]: path = self._report_path(run_id) if not path.is_file(): raise FileNotFoundError(run_id) try: report = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise ResearchRunError("research report is unreadable") from exc if not isinstance(report, dict): raise ResearchRunError("research report is invalid") if report.get("run_id") != run_id or report.get("schema_version") != RUN_SCHEMA_VERSION: raise ResearchRunError("research report identity mismatch") declared_hash = report.get("report_hash") if ( report.get("report_hash_algorithm") != REPORT_HASH_ALGORITHM or not isinstance(declared_hash, str) or _report_hash(report) != declared_hash ): raise ResearchRunError("research report integrity hash mismatch") manifest_entry = self.load_manifest().get("runs", {}).get(run_id) declared_manifest_hash = manifest_entry.get("manifest_entry_hash") if isinstance(manifest_entry, dict) else None if ( not isinstance(manifest_entry, dict) or manifest_entry.get("run_id") != run_id or manifest_entry.get("report_file") != path.name or any(manifest_entry.get(field) != report.get(field) for field in ("status", "reason", "generated_at")) or manifest_entry.get("report_hash_algorithm") != REPORT_HASH_ALGORITHM or manifest_entry.get("report_hash") != declared_hash or manifest_entry.get("manifest_entry_hash_algorithm") != REPORT_HASH_ALGORITHM or not isinstance(declared_manifest_hash, str) or _manifest_entry_hash(manifest_entry) != declared_manifest_hash ): raise ResearchRunError("research report manifest integrity mismatch") return report def migrate_legacy(self) -> int: """Add integrity metadata to explicitly migrated pre-hash reports.""" manifest = self.load_manifest() migrated = 0 for raw_run_id, raw_entry in manifest["runs"].items(): run_id = str(raw_run_id) if not _RUN_ID_RE.fullmatch(run_id) or not isinstance(raw_entry, dict): raise ResearchRunError("invalid research run manifest entry") path = self._report_path(run_id) if raw_entry.get("run_id") != run_id or raw_entry.get("report_file") != path.name: raise ResearchRunError("research run manifest entry identity mismatch") if not path.is_file(): raise FileNotFoundError(run_id) try: report = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise ResearchRunError("research report is unreadable") from exc if not isinstance(report, dict) or report.get("run_id") != run_id or report.get("schema_version") != RUN_SCHEMA_VERSION: raise ResearchRunError("research report identity mismatch") has_report_integrity = "report_hash" in report or "report_hash_algorithm" in report has_manifest_integrity = "manifest_entry_hash" in raw_entry or "manifest_entry_hash_algorithm" in raw_entry if has_manifest_integrity and not has_report_integrity: raise ResearchRunError("research run integrity metadata is incomplete") declared_report_hash = report.get("report_hash") report_integrity_valid = ( report.get("report_hash_algorithm") == REPORT_HASH_ALGORITHM and isinstance(declared_report_hash, str) and _report_hash(report) == declared_report_hash ) if has_report_integrity and not report_integrity_valid: raise ResearchRunError("research run integrity metadata is invalid") if not has_report_integrity or not has_manifest_integrity: if any(raw_entry.get(field) != report.get(field) for field in ("status", "reason", "generated_at")): raise ResearchRunError("research report manifest metadata mismatch") if not has_report_integrity: stored = copy.deepcopy(report) stored["report_hash_algorithm"] = REPORT_HASH_ALGORITHM stored["report_hash"] = _report_hash(stored) declared_report_hash = stored["report_hash"] _atomic_write(path, stored) if not has_manifest_integrity: migrated_entry = copy.deepcopy(raw_entry) migrated_entry["report_hash_algorithm"] = REPORT_HASH_ALGORITHM migrated_entry["report_hash"] = declared_report_hash migrated_entry["manifest_entry_hash_algorithm"] = REPORT_HASH_ALGORITHM migrated_entry["manifest_entry_hash"] = _manifest_entry_hash(migrated_entry) manifest["runs"][run_id] = migrated_entry migrated += 1 continue declared_manifest_hash = raw_entry.get("manifest_entry_hash") if ( not report_integrity_valid or raw_entry.get("report_hash_algorithm") != REPORT_HASH_ALGORITHM or raw_entry.get("report_hash") != declared_report_hash or raw_entry.get("manifest_entry_hash_algorithm") != REPORT_HASH_ALGORITHM or not isinstance(declared_manifest_hash, str) or _manifest_entry_hash(raw_entry) != declared_manifest_hash or any(raw_entry.get(field) != report.get(field) for field in ("status", "reason", "generated_at")) ): raise ResearchRunError("research run integrity metadata is invalid") if migrated: _atomic_write(self.manifest_path, manifest) return migrated def _validated_manifest_entries(self) -> list[dict[str, Any]]: manifest = self.load_manifest() entries: list[dict[str, Any]] = [] for run_id, raw_entry in manifest["runs"].items(): if not _RUN_ID_RE.fullmatch(str(run_id)) or not isinstance(raw_entry, dict) or raw_entry.get("run_id") != run_id: raise ResearchRunError("invalid research run manifest entry") try: self.load(run_id) except FileNotFoundError as exc: raise ResearchRunError("research run manifest entry report is missing") from exc entries.append(raw_entry) return entries def persist(self, report: dict[str, Any]) -> dict[str, Any]: run_id = str(report.get("run_id", "")) path = self._report_path(run_id) stored = copy.deepcopy(report) stored["report_hash_algorithm"] = REPORT_HASH_ALGORITHM stored["report_hash"] = _report_hash(stored) manifest = self.load_manifest() self._validated_manifest_entries() if run_id in manifest["runs"]: if not isinstance(manifest["runs"][run_id], dict): raise ResearchRunError("invalid research run manifest entry") existing_report = self.load(run_id) if existing_report != stored: raise ResearchRunError("research run id already contains a different report") return existing_report _atomic_write(path, stored) manifest_entry = { "run_id": run_id, "status": stored.get("status"), "reason": stored.get("reason"), "generated_at": stored.get("generated_at"), "report_file": path.name, "report_hash_algorithm": REPORT_HASH_ALGORITHM, "report_hash": stored["report_hash"], } manifest_entry["manifest_entry_hash_algorithm"] = REPORT_HASH_ALGORITHM manifest_entry["manifest_entry_hash"] = _manifest_entry_hash(manifest_entry) manifest["runs"][run_id] = manifest_entry _atomic_write(self.manifest_path, manifest) return stored def latest(self) -> dict[str, Any]: entries = self._validated_manifest_entries() if not entries: raise FileNotFoundError("no research runs") latest = max(entries, key=lambda item: (str(item.get("generated_at", "")), str(item.get("run_id", "")))) return self.load(str(latest["run_id"])) def list_runs(self) -> list[dict[str, Any]]: entries = self._validated_manifest_entries() return sorted(entries, key=lambda item: (str(item.get("generated_at", "")), str(item.get("run_id", ""))), reverse=True) def _parse_config_integer(value: Any, field: str, minimum: int) -> int: if isinstance(value, bool) or not isinstance(value, (int, float)): raise ResearchRunError(f"{field} must be an integer") try: parsed = int(value) numeric = float(value) except (OverflowError, TypeError, ValueError) as exc: raise ResearchRunError(f"{field} must be an integer") from exc if not math.isfinite(numeric) or parsed != value or parsed < minimum: qualifier = "positive" if minimum > 0 else "non-negative" raise ResearchRunError(f"{field} must be a {qualifier} integer") return parsed def _validate_config(windows: Sequence[int], cost_bps: float, min_events: int, execution_lag_sessions: int) -> tuple[list[int], float, int, int]: if not isinstance(windows, (list, tuple)) or not windows: raise ResearchRunError("windows must contain positive integers") parsed_windows = [_parse_config_integer(window, "windows", 1) for window in windows] try: if isinstance(cost_bps, bool): raise TypeError parsed_cost = float(cost_bps) except (OverflowError, TypeError, ValueError) as exc: raise ResearchRunError("cost_bps must be finite and non-negative") from exc if not math.isfinite(parsed_cost) or parsed_cost < 0: raise ResearchRunError("cost_bps must be finite and non-negative") parsed_min_events = _parse_config_integer(min_events, "min_events", 1) parsed_execution_lag = _parse_config_integer(execution_lag_sessions, "execution_lag_sessions", 0) return parsed_windows, parsed_cost, parsed_min_events, parsed_execution_lag def _validate_mode(mode: str) -> str: if not isinstance(mode, str) or mode not in RESEARCH_MODES: raise ResearchRunError(f"mode must be one of: {', '.join(sorted(RESEARCH_MODES))}") return mode def _price_gate(price_entry: Mapping[str, Any] | None, mode: str = "validated") -> dict[str, Any]: mode = _validate_mode(mode) if not price_entry: return {"status": "blocked", "reason": "price_snapshot_missing", "snapshot_count": 0} if price_entry.get("point_in_time") is not True: return { "status": "descriptive_only" if mode == "exploratory" else "blocked", "reason": "price_series_not_point_in_time", "snapshot_id": price_entry.get("snapshot_id"), "quality": price_entry.get("quality"), "point_in_time": False, "validated": False, } if price_entry.get("quality") != "point_in_time_archive" or price_entry.get("archive_contract") != PIT_ARCHIVE_CONTRACT: return { "status": "descriptive_only" if mode == "exploratory" else "blocked", "reason": "price_archive_contract_missing", "snapshot_id": price_entry.get("snapshot_id"), "quality": price_entry.get("quality"), "archive_contract": price_entry.get("archive_contract"), "point_in_time": True, "validated": False, } if mode == "exploratory": return { "status": "descriptive_only", "reason": "exploratory_mode_non_validated", "snapshot_id": price_entry.get("snapshot_id"), "quality": price_entry.get("quality"), "archive_contract": price_entry.get("archive_contract"), "provider_release_id": price_entry.get("provider_release_id"), "point_in_time": True, "validated": False, } return { "status": "ready", "reason": None, "snapshot_id": price_entry.get("snapshot_id"), "quality": price_entry.get("quality"), "archive_contract": price_entry.get("archive_contract"), "provider_release_id": price_entry.get("provider_release_id"), "point_in_time": True, "validated": True, } def _revision_key(entry: Mapping[str, Any]) -> tuple[str, str, str]: source_id = str(entry.get("source_id") or "") published_at = _canonical_timestamp(str(entry.get("published_at") or "")) if source_id and published_at: return ("release", source_id, published_at) return ("id", str(entry.get("vintage_id") or ""), "") def _select_independent_vintages(entries: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: selected: dict[tuple[str, str, str], dict[str, Any]] = {} for raw_entry in entries: entry = dict(raw_entry) key = _revision_key(entry) current = selected.get(key) candidate_rank = ( _canonical_timestamp(str(entry.get("first_seen_at") or "")), _canonical_timestamp(str(entry.get("last_seen_at") or "")), str(entry.get("vintage_id", "")), ) current_rank = ( _canonical_timestamp(str(current.get("first_seen_at") or "")), _canonical_timestamp(str(current.get("last_seen_at") or "")), str(current.get("vintage_id", "")), ) if current else ("", "", "") if current is None or candidate_rank > current_rank: selected[key] = entry return sorted( selected.values(), key=lambda item: ( _canonical_timestamp(str(item.get("published_at") or "")), str(item.get("vintage_id", "")), ), ) def _series_rows(series: Mapping[str, Any], price_field: str, symbol: str) -> list[dict[str, Any]]: bars = series.get("bars") if not isinstance(bars, list) or not bars: raise ResearchRunError(f"missing price bars for {symbol}") rows: list[dict[str, Any]] = [] for bar in bars: if not isinstance(bar, dict) or price_field not in bar: raise ResearchRunError(f"price field {price_field} missing for {symbol}") session_date = bar.get("session_date", bar.get("date")) row = {"date": session_date, "close": bar.get(price_field)} if "session_date" in bar: row["session_date"] = session_date if "known_at" in bar: row["known_at"] = bar.get("known_at") if "timezone" in series: row["market_timezone"] = series.get("timezone") rows.append(row) return rows def run_tourism_research( vintage_store: VintageStore, price_store: PriceSnapshotStore, run_store: ResearchRunStore, *, min_events: int = 12, windows: Sequence[int] = (1, 3, 5, 20), cost_bps: float = 20.0, execution_lag_sessions: int = 1, mode: str = "validated", ) -> dict[str, Any]: mode = _validate_mode(mode) windows, cost_bps, min_events, execution_lag_sessions = _validate_config(windows, cost_bps, min_events, execution_lag_sessions) try: vintages = _select_independent_vintages(vintage_store.list_vintages()) price_entry = price_store.latest_snapshot_entry() except (VintageStoreError, PriceSourceError) as exc: raise ResearchRunError(f"research input integrity validation failed: {exc}") from exc vintage_gate = assess_backtest_readiness(vintages, min_events=min_events) validated_vintage_snapshots: dict[str, dict[str, Any]] = {} try: for entry in vintages: vintage_id = str(entry["vintage_id"]) validated_vintage_snapshots[vintage_id] = vintage_store.load_snapshot(vintage_id) except (FileNotFoundError, VintageStoreError, KeyError) as exc: raise ResearchRunError(f"vintage snapshot integrity validation failed: {exc}") from exc price_snapshot: dict[str, Any] | None = None price_gate_entry = price_entry if price_entry: try: price_snapshot = price_store.load_snapshot(str(price_entry["snapshot_id"])) except (FileNotFoundError, PriceSourceError, KeyError) as exc: raise ResearchRunError(f"price snapshot integrity validation failed: {exc}") from exc price_source = price_snapshot.get("source", {}) price_gate_entry = { **price_entry, "point_in_time": price_source.get("point_in_time"), "quality": price_source.get("quality"), "archive_contract": price_source.get("archive_contract"), "provider_release_id": price_source.get("provider_release_id"), "normalized_snapshot_hash": price_source.get("normalized_snapshot_hash"), } price_gate = _price_gate(price_gate_entry, mode) input_vintages = [ { "vintage_id": entry.get("vintage_id"), "source_id": entry.get("source_id"), "as_of": entry.get("as_of"), "published_at": entry.get("published_at"), "raw_payload_hash": entry.get("raw_payload_hash"), "normalized_snapshot_hash": entry.get("normalized_snapshot_hash"), "normalized_hash_algorithm": entry.get("normalized_hash_algorithm"), "parser_version": entry.get("parser_version"), "revision_status": entry.get("revision_status"), } for entry in vintages ] input_price = ( { key: copy.deepcopy(value) for key, value in price_gate_entry.items() if key not in _OBSERVATION_RUNTIME_FIELDS } if price_gate_entry else None ) config = { "min_events": min_events, "windows": windows, "cost_bps": cost_bps, "execution_lag_sessions": execution_lag_sessions, "mode": mode, } input_fingerprint = _canonical_hash({"theme": "tourism", "config": config, "vintages": input_vintages, "price_snapshot": input_price}) run_id = f"tourism-run-{input_fingerprint[:16]}" try: return run_store.load(run_id) except FileNotFoundError: pass generated_at = datetime.now(timezone.utc).isoformat() report: dict[str, Any] = { "schema_version": RUN_SCHEMA_VERSION, "run_id": run_id, "theme": "tourism", "status": "blocked", "reason": None, "generated_at": generated_at, "research_mode": mode, "result_scope": "blocked", "config": config, "gates": {"vintages": vintage_gate, "prices": price_gate}, "inputs": {"vintages": input_vintages, "price_snapshot": input_price}, } if vintage_gate["status"] != "ready": report["reason"] = vintage_gate["reason"] return run_store.persist(report) if price_gate["status"] == "blocked": report["reason"] = price_gate["reason"] return run_store.persist(report) if not price_entry or price_snapshot is None: raise ResearchRunError("price snapshot disappeared after readiness check") price_source = price_snapshot.get("source", {}) price_field = str(price_source.get("return_price_field", "close")) series = price_snapshot.get("series") if not isinstance(series, dict): report["reason"] = "price_snapshot_invalid" return run_store.persist(report) events: list[dict[str, Any]] = [] try: for entry in vintages: snapshot = validated_vintage_snapshots[str(entry["vintage_id"])] result = compute_tourism_signal(snapshot) events.append({ "event_id": entry["vintage_id"], "published_at": snapshot["source"]["published_at"], "signals": result["signals"], }) price_rows = {symbol: _series_rows(value, price_field, symbol) for symbol, value in series.items() if symbol != price_snapshot.get("benchmark_symbol")} benchmark_symbol = price_snapshot.get("benchmark_symbol") benchmark_rows = _series_rows(series[benchmark_symbol], price_field, str(benchmark_symbol)) if benchmark_symbol else None result = run_event_study( events, price_rows, benchmark_prices=benchmark_rows, windows=windows, cost_bps=cost_bps, min_events=min_events, execution_lag_sessions=execution_lag_sessions, require_price_known_at=mode == "validated", ) except (FileNotFoundError, VintageStoreError, EventStudyError, ResearchRunError, KeyError, TypeError, ValueError) as exc: report["reason"] = "research_inputs_invalid" report["error"] = str(exc) return run_store.persist(report) report["status"] = "descriptive_only" if mode == "exploratory" else "ready" report["reason"] = None report["result_scope"] = "validated_pit_event_study" if mode == "validated" else "non_pit_descriptive_only" if mode == "exploratory": report["limitations"] = ["This result is descriptive only and must not be used as validated backtest evidence."] if price_source.get("point_in_time") is not True: report["limitations"].insert(0, "Historical price data may be revised vendor history and is not point-in-time validated.") else: report["limitations"].insert(0, "Point-in-time-capable inputs are intentionally not promoted by exploratory mode.") report["result"] = result report["input_summary"] = { "event_count": len(events), "price_symbols": sorted(price_rows), "benchmark_symbol": benchmark_symbol, "return_price_field": price_field, } return run_store.persist(report)