diff --git a/src/research_signal_context_pipelines/__init__.py b/src/research_signal_context_pipelines/__init__.py index 14ef0e4..99b98fe 100644 --- a/src/research_signal_context_pipelines/__init__.py +++ b/src/research_signal_context_pipelines/__init__.py @@ -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__ = [ @@ -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", ] diff --git a/src/research_signal_context_pipelines/schema.py b/src/research_signal_context_pipelines/schema.py index 8495faf..4076cdc 100644 --- a/src/research_signal_context_pipelines/schema.py +++ b/src/research_signal_context_pipelines/schema.py @@ -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]: @@ -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'") diff --git a/src/research_signal_context_pipelines/theme_momentum.py b/src/research_signal_context_pipelines/theme_momentum.py index 6d9805d..0edd39c 100644 --- a/src/research_signal_context_pipelines/theme_momentum.py +++ b/src/research_signal_context_pipelines/theme_momentum.py @@ -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: @@ -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, @@ -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) diff --git a/tests/test_signal_validation.py b/tests/test_signal_validation.py index 2c6e502..b2c33fe 100644 --- a/tests/test_signal_validation.py +++ b/tests/test_signal_validation.py @@ -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" diff --git a/tests/test_theme_momentum.py b/tests/test_theme_momentum.py index 94943e0..2fbf055 100644 --- a/tests/test_theme_momentum.py +++ b/tests/test_theme_momentum.py @@ -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 @@ -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周" @@ -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)