"""Tourism Pulse deterministic signal engine.""" from __future__ import annotations import math from statistics import fmean from typing import Any def _validate_observation(observation: dict[str, Any]) -> None: required = {"metric_key", "value", "expected", "scale", "unit"} missing = required.difference(observation) if missing: raise ValueError(f"observation missing fields: {sorted(missing)}") scale = float(observation["scale"]) if not math.isfinite(scale) or scale <= 0: raise ValueError(f"observation scale must be positive: {observation['metric_key']}") for key in ("value", "expected"): value = float(observation[key]) if not math.isfinite(value): raise ValueError(f"observation {key} must be finite: {observation['metric_key']}") def _data_quality(snapshot: dict[str, Any], observations: list[dict[str, Any]]) -> str: source = snapshot.get("source", {}) source_id = str(source.get("source_id", "")) if source_id.startswith("fixture"): return "fixture" declared_quality = str(snapshot.get("data_quality", "")).strip().lower() if declared_quality in {"provisional", "high", "low"}: return declared_quality if source.get("release_status") == "provisional": return "provisional" required_source = {"source_id", "source_url", "published_at", "retrieved_at", "vintage_id"} if not required_source.issubset(source): return "low" if not observations: return "low" return "high" def compute_tourism_signal(snapshot: dict[str, Any]) -> dict[str, Any]: """Compute a replayable Tourism Pulse signal from a frozen snapshot.""" observations = snapshot.get("observations", []) if not isinstance(observations, list) or not observations: raise ValueError("tourism snapshot must contain observations") for observation in observations: if not isinstance(observation, dict): raise ValueError("tourism observation must be an object") _validate_observation(observation) standardized = [] observation_output = [] for observation in observations: surprise = (float(observation["value"]) - float(observation["expected"])) / float(observation["scale"]) surprise = round(surprise, 8) standardized.append(surprise) observation_output.append({**observation, "surprise": surprise}) theme_surprise = round(fmean(standardized), 8) signal_rows = [] exposures = snapshot.get("exposures", []) if not isinstance(exposures, list): raise ValueError("tourism snapshot exposures must be a list") for exposure in exposures: if not isinstance(exposure, dict): raise ValueError("tourism exposure must be an object") symbol = str(exposure.get("symbol", "")).strip().upper() coefficient = float(exposure.get("coefficient", 0)) confidence = float(exposure.get("confidence", 1.0)) if not symbol: raise ValueError("exposure symbol must not be empty") if not math.isfinite(coefficient) or not math.isfinite(confidence): raise ValueError(f"exposure must be finite: {symbol}") if confidence < 0 or confidence > 1: raise ValueError(f"exposure confidence must be between 0 and 1: {symbol}") score = round(theme_surprise * coefficient * confidence, 8) evidence = str(exposure.get("evidence", "exposure")).strip().upper().replace(" ", "_") side = "LONG" if score > 0.15 else "SHORT" if score < -0.15 else "NEUTRAL" signal_rows.append( { "symbol": symbol, "score": score, "coefficient": coefficient, "confidence": confidence, "side": side, "reason_codes": ["TOURISM_SURPRISE", evidence], "evidence": exposure.get("evidence", ""), } ) signal_rows.sort(key=lambda row: (-row["score"], row["symbol"])) total_abs = sum(abs(row["score"]) for row in signal_rows) for rank, row in enumerate(signal_rows, start=1): row["rank"] = rank row["target_weight"] = round((row["score"] / total_abs) * 0.5, 8) if total_abs else 0.0 return { "theme": "tourism", "as_of": snapshot.get("as_of"), "theme_surprise": theme_surprise, "data_quality": _data_quality(snapshot, observations), "source": snapshot.get("source", {}), "observations": observation_output, "signals": signal_rows, "strategy_version": snapshot.get("strategy_version", "tourism-v0.1"), }