Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/research_signal_context_pipelines/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
from .context_bundle import DEFAULT_UNIVERSE, build_context_bundle, build_context_from_source
from .price_history import PriceExtractionSummary, write_filtered_price_history
from .schema import SignalValidationError, validate_signal
from .theme_momentum import build_theme_momentum_snapshot, write_theme_momentum_snapshot
from .theme_momentum import (
build_theme_momentum_snapshot,
validate_theme_momentum_snapshot,
write_theme_momentum_snapshot,
)
from .theme_universe import build_theme_context, load_symbol_theme_exposure, load_theme_taxonomy

__all__ = [
Expand All @@ -20,6 +24,7 @@
"load_symbol_theme_exposure",
"load_theme_taxonomy",
"validate_signal",
"validate_theme_momentum_snapshot",
"write_filtered_price_history",
"write_theme_momentum_snapshot",
]
9 changes: 7 additions & 2 deletions src/research_signal_context_pipelines/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class SignalValidationError(ValueError):
ALLOWED_REGIMES = frozenset({"risk_on", "risk_off", "neutral", "mixed", "unknown"})
ALLOWED_BIAS_VALUES = frozenset({"positive", "negative", "neutral", "watch", "avoid"})
REQUIRED_SIGNAL_HORIZON = "1-3 years"
SUPPORTED_SCHEMA_VERSIONS = frozenset({"1", "2"})


def _require_mapping(value: Any, name: str) -> Mapping[str, Any]:
Expand Down Expand Up @@ -82,11 +83,15 @@ def validate_signal(payload: Mapping[str, Any]) -> None:
if missing:
raise SignalValidationError(f"missing required keys: {', '.join(missing)}")

if _require_string(payload["schema_version"], "schema_version") != "1":
raise SignalValidationError("schema_version must be '1'")
schema_version = _require_string(payload["schema_version"], "schema_version")
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
raise SignalValidationError("schema_version must be '1' or '2'")
_require_iso_date(payload["as_of"], "as_of")
_require_iso_datetime(payload["generated_at"], "generated_at")
_require_iso_date(payload["expires_at"], "expires_at")
if schema_version == "2":
_require_string(payload.get("model_version"), "model_version")
_require_string(payload.get("scoring_version"), "scoring_version")

if payload["mode"] != "shadow":
raise SignalValidationError("mode must be 'shadow'")
Expand Down
31 changes: 30 additions & 1 deletion src/research_signal_context_pipelines/theme_momentum.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
THEME_MOMENTUM_HORIZON = "medium"
THEME_MOMENTUM_HORIZON_WINDOW = "2-12 weeks"
THEME_MOMENTUM_HORIZON_WINDOW_ZH = "2-12周"
THEME_MOMENTUM_MODEL_VERSION = "theme-momentum-v1"
THEME_MOMENTUM_SCORING_VERSION = "theme-momentum-rules-v1"
THEME_MOMENTUM_EXPIRY_DAYS = 84


def utc_now_iso() -> str:
Expand Down Expand Up @@ -218,9 +221,12 @@ def build_theme_momentum_snapshot(

taxonomy_versions = sorted({theme.taxonomy_version for theme in themes.values() if theme.taxonomy_version})
return {
"schema_version": "1",
"schema_version": "2",
"as_of": snapshot_as_of,
"generated_at": (generated_at or dt.datetime.now(dt.UTC)).isoformat().replace("+00:00", "Z"),
"expires_at": (parse_price_date(snapshot_as_of) + dt.timedelta(days=THEME_MOMENTUM_EXPIRY_DAYS)).isoformat(),
"model_version": THEME_MOMENTUM_MODEL_VERSION,
"scoring_version": THEME_MOMENTUM_SCORING_VERSION,
"mode": "theme_momentum_snapshot",
"artifact_type": THEME_MOMENTUM_ARTIFACT_TYPE,
"horizon": THEME_MOMENTUM_HORIZON,
Expand Down Expand Up @@ -265,6 +271,29 @@ def build_theme_momentum_snapshot(
}


def validate_theme_momentum_snapshot(snapshot: Mapping[str, Any]) -> None:
"""Validate the stable metadata and core shape of v1/v2 theme artifacts."""
required = ("schema_version", "as_of", "generated_at", "mode", "artifact_type", "theme_ranks", "data_quality", "policy")
missing = [key for key in required if key not in snapshot]
if missing:
raise ValueError(f"theme momentum snapshot missing required keys: {', '.join(missing)}")
schema_version = str(snapshot["schema_version"])
if schema_version not in {"1", "2"}:
raise ValueError("theme momentum snapshot schema_version must be '1' or '2'")
parse_price_date(snapshot["as_of"])
generated_at = snapshot["generated_at"]
if not isinstance(generated_at, str) or not generated_at.strip():
raise ValueError("theme momentum snapshot generated_at must be a non-empty string")
if schema_version == "2":
for key in ("expires_at", "model_version", "scoring_version"):
if not isinstance(snapshot.get(key), str) or not snapshot[key].strip():
raise ValueError(f"theme momentum snapshot {key} must be a non-empty string")
if parse_price_date(snapshot["expires_at"]) < parse_price_date(snapshot["as_of"]):
raise ValueError("theme momentum snapshot expires_at must not be before as_of")
if not isinstance(snapshot["theme_ranks"], list) or not isinstance(snapshot["data_quality"], Mapping):
raise ValueError("theme momentum snapshot core shape is invalid")


def write_theme_momentum_snapshot(snapshot: Mapping[str, Any], path: str | Path) -> Path:
output_path = Path(path)
output_path.parent.mkdir(parents=True, exist_ok=True)
Expand Down
27 changes: 27 additions & 0 deletions tests/test_signal_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,33 @@ def test_example_signal_is_valid() -> None:
assert payload["horizon"] == "1-3 years"


def test_v2_signal_requires_versioned_model_metadata() -> None:
payload = load_example()
payload.update(
{
"schema_version": "2",
"model_version": "shadow-v2",
"scoring_version": "rules-v2",
}
)

validate_signal(payload)


def test_v1_signal_remains_readable_without_v2_metadata() -> None:
payload = load_example()

validate_signal(payload)


def test_v2_signal_requires_model_and_scoring_versions() -> None:
payload = load_example()
payload["schema_version"] = "2"

with pytest.raises(SignalValidationError, match="model_version"):
validate_signal(payload)


def test_signal_requires_long_horizon_contract() -> None:
payload = load_example()
payload["horizon"] = "1-3 months"
Expand Down
37 changes: 36 additions & 1 deletion tests/test_theme_momentum.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
import datetime as dt

from research_signal_context_pipelines.price_history import PriceRow
from research_signal_context_pipelines.theme_momentum import build_theme_momentum_snapshot
from research_signal_context_pipelines.theme_momentum import (
build_theme_momentum_snapshot,
validate_theme_momentum_snapshot,
)
from research_signal_context_pipelines.theme_universe import SymbolThemeExposure, ThemeDefinition


Expand Down Expand Up @@ -58,6 +61,10 @@ def test_theme_momentum_ranks_strong_broad_theme_first() -> None:

ranked = snapshot["theme_ranks"]
assert snapshot["artifact_type"] == "medium_horizon_theme_context"
assert snapshot["schema_version"] == "2"
assert snapshot["expires_at"] == "2025-12-30"
assert snapshot["model_version"] == "theme-momentum-v1"
assert snapshot["scoring_version"] == "theme-momentum-rules-v1"
assert snapshot["horizon"] == "medium"
assert snapshot["horizon_window"] == "2-12 weeks"
assert snapshot["horizon_window_label"] == "2-12周"
Expand Down Expand Up @@ -96,3 +103,31 @@ def test_theme_momentum_records_missing_price_coverage() -> None:
assert snapshot["data_quality"]["coverage"]["price_coverage_ratio"] == 0.5
assert snapshot["theme_ranks"][0]["component_count"] == 2
assert snapshot["theme_ranks"][0]["priced_symbol_count"] == 1


def test_theme_momentum_validator_reads_v1_and_v2_metadata() -> None:
themes = {
"hbm_memory": ThemeDefinition(
taxonomy_version="test-v1",
theme_id="hbm_memory",
theme_name="HBM and memory",
sector="technology",
horizon="6-24 months",
description="memory theme",
source_policy="primary evidence required",
)
}
exposures = {"MU": SymbolThemeExposure("MU", ("hbm_memory",), "high", "memory exposure")}
snapshot = build_theme_momentum_snapshot(
_trend_rows("MU", start_close=50, daily_step=0.22),
themes=themes,
exposures=exposures,
generated_at=dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc),
)

validate_theme_momentum_snapshot(snapshot)
legacy = dict(snapshot)
legacy["schema_version"] = "1"
for key in ("expires_at", "model_version", "scoring_version"):
legacy.pop(key)
validate_theme_momentum_snapshot(legacy)
Loading