Files
set50-system/backend/app/tourism.py
Kunthawat Greethong ead9aeb25c chore: pre-existing in-tree work (event-study/research/vintages/prices + migration script + integrity docs)
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.
2026-08-29 09:19:24 +07:00

109 lines
4.5 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 = 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"),
}