Files
set50-system/backend/app/tourism.py
2026-08-23 10:19:40 +07:00

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"),
}