Skip to content

Commit 353a5d1

Browse files
authored
feat: add v2 artifact metadata
Squash merge Slice A after CI, gate, and Codex review cleared blocking findings.
1 parent f7dd58b commit 353a5d1

5 files changed

Lines changed: 106 additions & 5 deletions

File tree

src/research_signal_context_pipelines/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
from .context_bundle import DEFAULT_UNIVERSE, build_context_bundle, build_context_from_source
55
from .price_history import PriceExtractionSummary, write_filtered_price_history
66
from .schema import SignalValidationError, validate_signal
7-
from .theme_momentum import build_theme_momentum_snapshot, write_theme_momentum_snapshot
7+
from .theme_momentum import (
8+
build_theme_momentum_snapshot,
9+
validate_theme_momentum_snapshot,
10+
write_theme_momentum_snapshot,
11+
)
812
from .theme_universe import build_theme_context, load_symbol_theme_exposure, load_theme_taxonomy
913

1014
__all__ = [
@@ -20,6 +24,7 @@
2024
"load_symbol_theme_exposure",
2125
"load_theme_taxonomy",
2226
"validate_signal",
27+
"validate_theme_momentum_snapshot",
2328
"write_filtered_price_history",
2429
"write_theme_momentum_snapshot",
2530
]

src/research_signal_context_pipelines/schema.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class SignalValidationError(ValueError):
2828
ALLOWED_REGIMES = frozenset({"risk_on", "risk_off", "neutral", "mixed", "unknown"})
2929
ALLOWED_BIAS_VALUES = frozenset({"positive", "negative", "neutral", "watch", "avoid"})
3030
REQUIRED_SIGNAL_HORIZON = "1-3 years"
31+
SUPPORTED_SCHEMA_VERSIONS = frozenset({"1", "2"})
3132

3233

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

85-
if _require_string(payload["schema_version"], "schema_version") != "1":
86-
raise SignalValidationError("schema_version must be '1'")
86+
schema_version = _require_string(payload["schema_version"], "schema_version")
87+
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
88+
raise SignalValidationError("schema_version must be '1' or '2'")
8789
_require_iso_date(payload["as_of"], "as_of")
8890
_require_iso_datetime(payload["generated_at"], "generated_at")
8991
_require_iso_date(payload["expires_at"], "expires_at")
92+
if schema_version == "2":
93+
_require_string(payload.get("model_version"), "model_version")
94+
_require_string(payload.get("scoring_version"), "scoring_version")
9095

9196
if payload["mode"] != "shadow":
9297
raise SignalValidationError("mode must be 'shadow'")

src/research_signal_context_pipelines/theme_momentum.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
THEME_MOMENTUM_HORIZON = "medium"
2626
THEME_MOMENTUM_HORIZON_WINDOW = "2-12 weeks"
2727
THEME_MOMENTUM_HORIZON_WINDOW_ZH = "2-12周"
28+
THEME_MOMENTUM_MODEL_VERSION = "theme-momentum-v1"
29+
THEME_MOMENTUM_SCORING_VERSION = "theme-momentum-rules-v1"
30+
THEME_MOMENTUM_EXPIRY_DAYS = 84
2831

2932

3033
def utc_now_iso() -> str:
@@ -218,9 +221,12 @@ def build_theme_momentum_snapshot(
218221

219222
taxonomy_versions = sorted({theme.taxonomy_version for theme in themes.values() if theme.taxonomy_version})
220223
return {
221-
"schema_version": "1",
224+
"schema_version": "2",
222225
"as_of": snapshot_as_of,
223226
"generated_at": (generated_at or dt.datetime.now(dt.UTC)).isoformat().replace("+00:00", "Z"),
227+
"expires_at": (parse_price_date(snapshot_as_of) + dt.timedelta(days=THEME_MOMENTUM_EXPIRY_DAYS)).isoformat(),
228+
"model_version": THEME_MOMENTUM_MODEL_VERSION,
229+
"scoring_version": THEME_MOMENTUM_SCORING_VERSION,
224230
"mode": "theme_momentum_snapshot",
225231
"artifact_type": THEME_MOMENTUM_ARTIFACT_TYPE,
226232
"horizon": THEME_MOMENTUM_HORIZON,
@@ -265,6 +271,29 @@ def build_theme_momentum_snapshot(
265271
}
266272

267273

274+
def validate_theme_momentum_snapshot(snapshot: Mapping[str, Any]) -> None:
275+
"""Validate the stable metadata and core shape of v1/v2 theme artifacts."""
276+
required = ("schema_version", "as_of", "generated_at", "mode", "artifact_type", "theme_ranks", "data_quality", "policy")
277+
missing = [key for key in required if key not in snapshot]
278+
if missing:
279+
raise ValueError(f"theme momentum snapshot missing required keys: {', '.join(missing)}")
280+
schema_version = str(snapshot["schema_version"])
281+
if schema_version not in {"1", "2"}:
282+
raise ValueError("theme momentum snapshot schema_version must be '1' or '2'")
283+
parse_price_date(snapshot["as_of"])
284+
generated_at = snapshot["generated_at"]
285+
if not isinstance(generated_at, str) or not generated_at.strip():
286+
raise ValueError("theme momentum snapshot generated_at must be a non-empty string")
287+
if schema_version == "2":
288+
for key in ("expires_at", "model_version", "scoring_version"):
289+
if not isinstance(snapshot.get(key), str) or not snapshot[key].strip():
290+
raise ValueError(f"theme momentum snapshot {key} must be a non-empty string")
291+
if parse_price_date(snapshot["expires_at"]) < parse_price_date(snapshot["as_of"]):
292+
raise ValueError("theme momentum snapshot expires_at must not be before as_of")
293+
if not isinstance(snapshot["theme_ranks"], list) or not isinstance(snapshot["data_quality"], Mapping):
294+
raise ValueError("theme momentum snapshot core shape is invalid")
295+
296+
268297
def write_theme_momentum_snapshot(snapshot: Mapping[str, Any], path: str | Path) -> Path:
269298
output_path = Path(path)
270299
output_path.parent.mkdir(parents=True, exist_ok=True)

tests/test_signal_validation.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,33 @@ def test_example_signal_is_valid() -> None:
2121
assert payload["horizon"] == "1-3 years"
2222

2323

24+
def test_v2_signal_requires_versioned_model_metadata() -> None:
25+
payload = load_example()
26+
payload.update(
27+
{
28+
"schema_version": "2",
29+
"model_version": "shadow-v2",
30+
"scoring_version": "rules-v2",
31+
}
32+
)
33+
34+
validate_signal(payload)
35+
36+
37+
def test_v1_signal_remains_readable_without_v2_metadata() -> None:
38+
payload = load_example()
39+
40+
validate_signal(payload)
41+
42+
43+
def test_v2_signal_requires_model_and_scoring_versions() -> None:
44+
payload = load_example()
45+
payload["schema_version"] = "2"
46+
47+
with pytest.raises(SignalValidationError, match="model_version"):
48+
validate_signal(payload)
49+
50+
2451
def test_signal_requires_long_horizon_contract() -> None:
2552
payload = load_example()
2653
payload["horizon"] = "1-3 months"

tests/test_theme_momentum.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import datetime as dt
44

55
from research_signal_context_pipelines.price_history import PriceRow
6-
from research_signal_context_pipelines.theme_momentum import build_theme_momentum_snapshot
6+
from research_signal_context_pipelines.theme_momentum import (
7+
build_theme_momentum_snapshot,
8+
validate_theme_momentum_snapshot,
9+
)
710
from research_signal_context_pipelines.theme_universe import SymbolThemeExposure, ThemeDefinition
811

912

@@ -58,6 +61,10 @@ def test_theme_momentum_ranks_strong_broad_theme_first() -> None:
5861

5962
ranked = snapshot["theme_ranks"]
6063
assert snapshot["artifact_type"] == "medium_horizon_theme_context"
64+
assert snapshot["schema_version"] == "2"
65+
assert snapshot["expires_at"] == "2025-12-30"
66+
assert snapshot["model_version"] == "theme-momentum-v1"
67+
assert snapshot["scoring_version"] == "theme-momentum-rules-v1"
6168
assert snapshot["horizon"] == "medium"
6269
assert snapshot["horizon_window"] == "2-12 weeks"
6370
assert snapshot["horizon_window_label"] == "2-12周"
@@ -96,3 +103,31 @@ def test_theme_momentum_records_missing_price_coverage() -> None:
96103
assert snapshot["data_quality"]["coverage"]["price_coverage_ratio"] == 0.5
97104
assert snapshot["theme_ranks"][0]["component_count"] == 2
98105
assert snapshot["theme_ranks"][0]["priced_symbol_count"] == 1
106+
107+
108+
def test_theme_momentum_validator_reads_v1_and_v2_metadata() -> None:
109+
themes = {
110+
"hbm_memory": ThemeDefinition(
111+
taxonomy_version="test-v1",
112+
theme_id="hbm_memory",
113+
theme_name="HBM and memory",
114+
sector="technology",
115+
horizon="6-24 months",
116+
description="memory theme",
117+
source_policy="primary evidence required",
118+
)
119+
}
120+
exposures = {"MU": SymbolThemeExposure("MU", ("hbm_memory",), "high", "memory exposure")}
121+
snapshot = build_theme_momentum_snapshot(
122+
_trend_rows("MU", start_close=50, daily_step=0.22),
123+
themes=themes,
124+
exposures=exposures,
125+
generated_at=dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc),
126+
)
127+
128+
validate_theme_momentum_snapshot(snapshot)
129+
legacy = dict(snapshot)
130+
legacy["schema_version"] = "1"
131+
for key in ("expires_at", "model_version", "scoring_version"):
132+
legacy.pop(key)
133+
validate_theme_momentum_snapshot(legacy)

0 commit comments

Comments
 (0)