Committing the prior uncommitted working-tree state that predates this session's data-source work (was already modified/untracked at session start) so the tree is clean before push. Includes: event-study + research report integrity/forward observation work, prices tests, research hash migration script, and the 2026-08-23/24 engineering-log + test-evidence notes. Verified green as part of the full 362-test suite.
536 lines
25 KiB
Python
536 lines
25 KiB
Python
"""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)
|