|
25 | 25 | THEME_MOMENTUM_HORIZON = "medium" |
26 | 26 | THEME_MOMENTUM_HORIZON_WINDOW = "2-12 weeks" |
27 | 27 | 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 |
28 | 31 |
|
29 | 32 |
|
30 | 33 | def utc_now_iso() -> str: |
@@ -218,9 +221,12 @@ def build_theme_momentum_snapshot( |
218 | 221 |
|
219 | 222 | taxonomy_versions = sorted({theme.taxonomy_version for theme in themes.values() if theme.taxonomy_version}) |
220 | 223 | return { |
221 | | - "schema_version": "1", |
| 224 | + "schema_version": "2", |
222 | 225 | "as_of": snapshot_as_of, |
223 | 226 | "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, |
224 | 230 | "mode": "theme_momentum_snapshot", |
225 | 231 | "artifact_type": THEME_MOMENTUM_ARTIFACT_TYPE, |
226 | 232 | "horizon": THEME_MOMENTUM_HORIZON, |
@@ -265,6 +271,29 @@ def build_theme_momentum_snapshot( |
265 | 271 | } |
266 | 272 |
|
267 | 273 |
|
| 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 | + |
268 | 297 | def write_theme_momentum_snapshot(snapshot: Mapping[str, Any], path: str | Path) -> Path: |
269 | 298 | output_path = Path(path) |
270 | 299 | output_path.parent.mkdir(parents=True, exist_ok=True) |
|
0 commit comments