102 lines
4.2 KiB
Python
102 lines
4.2 KiB
Python
"""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 = list(snapshot.get("observations", []))
|
|
if not observations:
|
|
raise ValueError("tourism snapshot must contain observations")
|
|
for observation in observations:
|
|
_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 = []
|
|
for exposure in snapshot.get("exposures", []):
|
|
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"),
|
|
}
|