From b43064d733fc664c90dbf2332fefb183b4e9ce54 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:35:48 +0800 Subject: [PATCH] Add freshness and data quality gates Co-Authored-By: Codex --- .github/workflows/ci.yml | 2 +- .github/workflows/theme_momentum_snapshot.yml | 1 + data/output/latest_signal.json | 6 +- data/output/latest_signal.manifest.json | 8 +- data/output/signal_history/2026-05-28.json | 4 +- data/output/signal_history/2026-05-31.json | 4 +- data/output/signal_history/2026-06-25.json | 361 ++++++++++++++++++ data/output/theme_momentum_snapshot.json | 33 +- docs/architecture.md | 14 + examples/latest_signal.example.json | 9 +- examples/signal_history/2026-01-02.json | 4 +- examples/signal_history/2026-02-06.json | 4 +- examples/signal_history/2026-03-20.json | 4 +- examples/signal_history/2026-04-17.json | 4 +- examples/theme_momentum_snapshot.example.json | 35 ++ scripts/validate_latest_signal.py | 14 +- scripts/validate_theme_momentum_snapshot.py | 40 ++ .../__init__.py | 10 +- .../overlay_backtest.py | 2 +- .../schema.py | 53 ++- .../theme_momentum.py | 124 +++++- tests/test_signal_validation.py | 56 ++- tests/test_theme_momentum.py | 90 ++++- 23 files changed, 850 insertions(+), 32 deletions(-) create mode 100644 data/output/signal_history/2026-06-25.json create mode 100644 examples/theme_momentum_snapshot.example.json create mode 100644 scripts/validate_theme_momentum_snapshot.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c42eee..60aba2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: Run tests run: python -m pytest -q - name: Validate example signal - run: python scripts/validate_latest_signal.py examples/latest_signal.example.json + run: python scripts/validate_latest_signal.py examples/latest_signal.example.json --allow-expired - name: Run example overlay replay run: python scripts/backtest_signal_overlay.py --prices examples/price_history.example.csv --signals examples/signal_history --symbol QQQ - name: Run example price extraction diff --git a/.github/workflows/theme_momentum_snapshot.yml b/.github/workflows/theme_momentum_snapshot.yml index 6f819af..8a254df 100644 --- a/.github/workflows/theme_momentum_snapshot.yml +++ b/.github/workflows/theme_momentum_snapshot.yml @@ -70,6 +70,7 @@ jobs: ARGS+=(--strict-downloads) fi python scripts/build_theme_momentum_snapshot.py "${ARGS[@]}" | tee /tmp/theme_momentum_summary.json + python scripts/validate_theme_momentum_snapshot.py data/output/theme_momentum_snapshot.json { echo "## Theme momentum snapshot" echo "" diff --git a/data/output/latest_signal.json b/data/output/latest_signal.json index 46b9383..831b965 100644 --- a/data/output/latest_signal.json +++ b/data/output/latest_signal.json @@ -1,5 +1,7 @@ { - "schema_version": "1", + "schema_version": "2", + "model_version": "ai-shadow-context-v1", + "scoring_version": "ai-shadow-context-rules-v1", "as_of": "2026-06-25", "generated_at": "2026-06-27T08:22:28.245044Z", "mode": "shadow", @@ -356,4 +358,4 @@ "rationale": "Industrial automation remains a background context rather than a current positive signal." } } -} \ No newline at end of file +} diff --git a/data/output/latest_signal.manifest.json b/data/output/latest_signal.manifest.json index 76d875e..a528628 100644 --- a/data/output/latest_signal.manifest.json +++ b/data/output/latest_signal.manifest.json @@ -1,9 +1,9 @@ { "artifact": "data/output/latest_signal.json", - "artifact_sha256": "39368aa3eae5da168665fde58edbe17bbef1c70fa4070b58051c928aaefc0b1a", - "as_of": "2026-05-31", - "generated_at": "2026-05-31T10:52:51Z", - "history_artifact": "data/output/signal_history/2026-05-31.json", + "artifact_sha256": "5fb8891cb793663636b65a70c4bb96c889533ded2a94cef2f7ae6e0c6fa482eb", + "as_of": "2026-06-25", + "generated_at": "2026-06-27T08:22:28.245044Z", + "history_artifact": "data/output/signal_history/2026-06-25.json", "mode": "shadow", "policy": { "downstream_use": "Manifest only records provenance for a shadow research artifact; it does not authorize execution.", diff --git a/data/output/signal_history/2026-05-28.json b/data/output/signal_history/2026-05-28.json index 87d0e73..6e354b5 100644 --- a/data/output/signal_history/2026-05-28.json +++ b/data/output/signal_history/2026-05-28.json @@ -1,5 +1,7 @@ { - "schema_version": "1", + "schema_version": "2", + "model_version": "shadow-signal-example-v1", + "scoring_version": "shadow-signal-example-rules-v1", "as_of": "2026-05-28", "generated_at": "2026-05-28T14:38:47.474891Z", "mode": "shadow", diff --git a/data/output/signal_history/2026-05-31.json b/data/output/signal_history/2026-05-31.json index f7ec969..96ae2f4 100644 --- a/data/output/signal_history/2026-05-31.json +++ b/data/output/signal_history/2026-05-31.json @@ -31,7 +31,9 @@ "requires_fundamental_and_valuation_review", "no_account_level_advice" ], - "schema_version": "1", + "schema_version": "2", + "model_version": "shadow-signal-example-v1", + "scoring_version": "shadow-signal-example-rules-v1", "symbol_bias": { "AMD": { "bias": "watch", diff --git a/data/output/signal_history/2026-06-25.json b/data/output/signal_history/2026-06-25.json new file mode 100644 index 0000000..831b965 --- /dev/null +++ b/data/output/signal_history/2026-06-25.json @@ -0,0 +1,361 @@ +{ + "schema_version": "2", + "model_version": "ai-shadow-context-v1", + "scoring_version": "ai-shadow-context-rules-v1", + "as_of": "2026-06-25", + "generated_at": "2026-06-27T08:22:28.245044Z", + "mode": "shadow", + "horizon": "1-3 years", + "universe": [ + "SPY", + "QQQ", + "SOXX", + "TQQQ", + "BIL", + "BOXX", + "IWM", + "DIA", + "XLF", + "XLV", + "XLE", + "XLI", + "XLU", + "XLK", + "NVDA", + "AMD", + "AVGO", + "TSM", + "MU", + "INTC", + "DELL", + "SMCI", + "VRT", + "MSFT", + "GOOGL", + "AMZN", + "META", + "CRWD", + "PANW", + "LMT", + "RTX", + "NOC", + "CVX", + "XOM", + "JPM", + "UNH", + "NEE", + "TSLA", + "COIN", + "MSTR" + ], + "regime": "mixed", + "risk_flags": [ + "shadow_only_context", + "theme_membership_is_static_research_context", + "requires_fundamental_and_valuation_review", + "no_account_level_advice" + ], + "candidate_bias": { + "AMD": { + "bias": "watch", + "confidence": 0.57, + "linked_themes": [ + "ai_compute", + "foundry_semicap" + ], + "rationale": "AI accelerator and CPU/GPU data-center exposure is relevant; recommendation escalation should still depend on momentum and fundamentals." + }, + "DELL": { + "bias": "watch", + "confidence": 0.56, + "linked_themes": [ + "ai_server_infrastructure", + "ai_compute" + ], + "rationale": "AI server and enterprise infrastructure exposure remains relevant; margin and order-conversion risks still need review." + }, + "INTC": { + "bias": "watch", + "confidence": 0.58, + "linked_themes": [ + "foundry_semicap", + "ai_compute" + ], + "rationale": "Foundry and domestic semiconductor policy exposure remains relevant, but turnaround execution risk remains material." + }, + "MU": { + "bias": "watch", + "confidence": 0.62, + "linked_themes": [ + "hbm_memory", + "ai_compute" + ], + "rationale": "HBM and memory-cycle exposure is strong in the static theme map and ranks highly in the latest theme momentum snapshot." + }, + "VRT": { + "bias": "watch", + "confidence": 0.56, + "linked_themes": [ + "data_center_power", + "ai_server_infrastructure" + ], + "rationale": "Data-center power and cooling exposure remains a durable AI infrastructure context." + } + }, + "confidence": 0.58, + "evidence": { + "sources": [ + "config/theme_taxonomy.csv", + "config/symbol_theme_exposure.csv", + "data/output/theme_momentum_snapshot.json", + ".codex-audit/monthly_issue.md" + ], + "summary": "Long-horizon shadow context combines versioned theme taxonomy, static symbol-theme exposure, and the latest medium-horizon theme momentum snapshot. It is a research context artifact only, not an execution or allocation signal.", + "data_gaps": [ + "Issuer-level valuation, earnings, balance-sheet, and liquidity checks are not embedded in this artifact.", + "Policy/news events are supplied by PoliticalEventTrackingResearch and may be absent or low-scoring for otherwise strong theme and momentum candidates.", + "Historical AI judgments must come from saved point-in-time artifacts, not regenerated prompts." + ] + }, + "expires_at": "2026-06-30", + "policy": { + "downstream_use": "Research-only long-horizon context for QuantAdvisorResearch. Execution, account allocation, and broker routing remain blocked unless a separate deterministic policy engine explicitly consumes this artifact under its own controls.", + "execution_allowed": false + }, + "symbol_theme_exposure": { + "AAPL": [ + "consumer_platforms", + "ai_compute" + ], + "AMD": [ + "ai_compute", + "foundry_semicap" + ], + "AMZN": [ + "ai_compute", + "consumer_platforms" + ], + "AVGO": [ + "ai_compute", + "foundry_semicap" + ], + "BA": [ + "defense_aerospace", + "industrial_automation" + ], + "COIN": [ + "crypto_infrastructure", + "financial_market_infrastructure" + ], + "CRWD": [ + "cybersecurity" + ], + "CVX": [ + "energy_security" + ], + "DELL": [ + "ai_server_infrastructure", + "ai_compute" + ], + "ENPH": [ + "clean_energy_grid" + ], + "F": [ + "automobility_ev", + "industrial_automation" + ], + "GE": [ + "industrial_automation", + "defense_aerospace" + ], + "GOOGL": [ + "ai_compute", + "consumer_platforms" + ], + "INTC": [ + "foundry_semicap", + "ai_compute" + ], + "IONQ": [ + "ai_compute" + ], + "JPM": [ + "financial_market_infrastructure" + ], + "LMT": [ + "defense_aerospace" + ], + "META": [ + "ai_compute", + "consumer_platforms" + ], + "MSFT": [ + "ai_compute", + "cybersecurity" + ], + "MSTR": [ + "crypto_infrastructure" + ], + "MU": [ + "hbm_memory", + "ai_compute" + ], + "NEE": [ + "data_center_power", + "clean_energy_grid" + ], + "NOC": [ + "defense_aerospace" + ], + "NVDA": [ + "ai_compute" + ], + "OKLO": [ + "data_center_power", + "clean_energy_grid" + ], + "ORCL": [ + "ai_compute", + "ai_server_infrastructure" + ], + "PANW": [ + "cybersecurity" + ], + "PLTR": [ + "ai_compute", + "defense_aerospace" + ], + "QCOM": [ + "ai_compute", + "consumer_platforms" + ], + "RBLX": [ + "consumer_platforms" + ], + "RTX": [ + "defense_aerospace" + ], + "SMCI": [ + "ai_server_infrastructure", + "data_center_power" + ], + "SMR": [ + "data_center_power", + "clean_energy_grid" + ], + "SOFI": [ + "financial_market_infrastructure" + ], + "TSLA": [ + "automobility_ev", + "ai_compute", + "data_center_power" + ], + "TSM": [ + "foundry_semicap", + "ai_compute" + ], + "UNH": [ + "healthcare_policy" + ], + "VRT": [ + "data_center_power", + "ai_server_infrastructure" + ], + "XOM": [ + "energy_security" + ] + }, + "theme_bias": { + "ai_compute": { + "bias": "neutral", + "confidence": 0.56, + "horizon": "1-3 years", + "rationale": "AI compute is a broad enabling theme; symbol-level escalation should rely on momentum and company evidence rather than theme membership alone." + }, + "ai_server_infrastructure": { + "bias": "watch", + "confidence": 0.58, + "horizon": "6-24 months", + "rationale": "AI server and data-center hardware demand remains a watch-only long-context input." + }, + "automobility_ev": { + "bias": "neutral", + "confidence": 0.45, + "horizon": "1-3 years", + "rationale": "EV and automobility context remains mixed and execution-dependent." + }, + "clean_energy_grid": { + "bias": "neutral", + "confidence": 0.47, + "horizon": "1-3 years", + "rationale": "Clean energy and grid context requires project-level evidence before escalation." + }, + "consumer_platforms": { + "bias": "neutral", + "confidence": 0.48, + "horizon": "6-24 months", + "rationale": "Consumer platform context remains neutral because regulatory and demand evidence is mixed." + }, + "crypto_infrastructure": { + "bias": "neutral", + "confidence": 0.45, + "horizon": "3-18 months", + "rationale": "Crypto infrastructure remains policy-sensitive and volatile." + }, + "cybersecurity": { + "bias": "neutral", + "confidence": 0.5, + "horizon": "1-3 years", + "rationale": "Cybersecurity remains a durable theme but requires issuer and contract evidence for escalation." + }, + "data_center_power": { + "bias": "watch", + "confidence": 0.55, + "horizon": "1-3 years", + "rationale": "Data-center power and cooling remains a durable infrastructure context with execution and valuation risk." + }, + "defense_aerospace": { + "bias": "neutral", + "confidence": 0.5, + "horizon": "1-3 years", + "rationale": "Defense and aerospace context remains evidence-dependent." + }, + "energy_security": { + "bias": "neutral", + "confidence": 0.5, + "horizon": "6-24 months", + "rationale": "Energy-security context is present but not a stand-alone positive long signal." + }, + "financial_market_infrastructure": { + "bias": "neutral", + "confidence": 0.48, + "horizon": "6-24 months", + "rationale": "Financial and market-structure context remains neutral without stronger source evidence." + }, + "foundry_semicap": { + "bias": "watch", + "confidence": 0.58, + "horizon": "1-3 years", + "rationale": "Foundry, semiconductor policy, and reshoring context remain relevant, but issuer fundamentals and policy evidence still need review." + }, + "hbm_memory": { + "bias": "watch", + "confidence": 0.62, + "horizon": "6-24 months", + "rationale": "HBM and memory-cycle context remains supported by the latest medium-horizon theme momentum snapshot and static exposure map." + }, + "healthcare_policy": { + "bias": "neutral", + "confidence": 0.48, + "horizon": "6-24 months", + "rationale": "Healthcare policy context remains neutral until official and issuer evidence improves." + }, + "industrial_automation": { + "bias": "neutral", + "confidence": 0.48, + "horizon": "1-3 years", + "rationale": "Industrial automation remains a background context rather than a current positive signal." + } + } +} diff --git a/data/output/theme_momentum_snapshot.json b/data/output/theme_momentum_snapshot.json index 3674c30..abf5ff6 100644 --- a/data/output/theme_momentum_snapshot.json +++ b/data/output/theme_momentum_snapshot.json @@ -8,10 +8,37 @@ "price_coverage_ratio": 1.0, "priced_symbol_count": 53 }, + "gate": { + "allow_downstream_recommendation": false, + "reasons": [ + "stale price data: latest as_of 2026-06-26 is older than 5 days", + "extreme return_3m for MU: 2.1869 exceeds +/-1.00; secondary validation required", + "extreme return_6_1m for MU: 3.4808 exceeds +/-2.00; secondary validation required", + "extreme return_12_1m for MU: 8.9671 exceeds +/-4.00; secondary validation required", + "extreme return_3m for INTC: 1.9098 exceeds +/-1.00; secondary validation required", + "extreme return_6_1m for INTC: 2.5296 exceeds +/-2.00; secondary validation required", + "extreme return_12_1m for INTC: 5.0733 exceeds +/-4.00; secondary validation required", + "extreme return_3m for AMD: 1.5597 exceeds +/-1.00; secondary validation required", + "extreme return_3m for DELL: 1.2792 exceeds +/-1.00; secondary validation required" + ], + "status": "blocked" + }, "insufficient_history_symbols": [], "missing_price_symbols": [], - "unranked_themes": [] + "unranked_themes": [], + "warnings": [ + "stale price data: latest as_of 2026-06-26 is older than 5 days", + "extreme return_3m for MU: 2.1869 exceeds +/-1.00; secondary validation required", + "extreme return_6_1m for MU: 3.4808 exceeds +/-2.00; secondary validation required", + "extreme return_12_1m for MU: 8.9671 exceeds +/-4.00; secondary validation required", + "extreme return_3m for INTC: 1.9098 exceeds +/-1.00; secondary validation required", + "extreme return_6_1m for INTC: 2.5296 exceeds +/-2.00; secondary validation required", + "extreme return_12_1m for INTC: 5.0733 exceeds +/-4.00; secondary validation required", + "extreme return_3m for AMD: 1.5597 exceeds +/-1.00; secondary validation required", + "extreme return_3m for DELL: 1.2792 exceeds +/-1.00; secondary validation required" + ] }, + "expires_at": "2026-09-18", "generated_at": "2026-06-27T12:57:19.193780Z", "horizon": "medium", "horizon_window": "2-12 weeks", @@ -40,13 +67,15 @@ } }, "mode": "theme_momentum_snapshot", + "model_version": "theme-momentum-v1", "policy": { "downstream_use": "Medium-horizon theme context for research ranking and replay only; do not route to broker execution.", "execution_allowed": false, "portfolio_allocation_allowed": false, "theme_rank_is_research_context_only": true }, - "schema_version": "1", + "schema_version": "2", + "scoring_version": "theme-momentum-rules-v1", "source_artifacts": { "prices": "yahoo_chart_download", "theme_exposures": "config/symbol_theme_exposure.csv", diff --git a/docs/architecture.md b/docs/architecture.md index a24697c..55025c4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -146,6 +146,20 @@ strong members inside a theme, but it does not encode short-term recommendations orders, target weights, or execution policy. Future replay must consume saved snapshots rather than recomputing old theme ranks with revised constituents or revised weights. +Every signal and theme snapshot records `as_of`, `generated_at`, `expires_at`, +`schema_version`, `model_version`, and `scoring_version`. Consumers must reject +expired artifacts. Theme snapshots also expose +`data_quality.gate.allow_downstream_recommendation`; stale prices, extreme +returns, or split/adjustment continuity concerns set this gate to `false` and +must remain a warning rather than being silently treated as healthy data. +Missing prices, insufficient history, unranked themes, or price coverage below +the minimum `0.80` ratio also block this gate. `validate_latest_signal()` +revalidates the linked theme artifact and gate; CLI `--allow-expired` only +suppresses expiry/freshness failures and never bypasses linked-artifact or +coverage checks. Historical `--as-of` builds compare the requested cutoff with +the latest priced row and report provider/weekend lag through the gate instead +of failing solely because rebuild time is later. + ## Repository Name Decision `ResearchSignalContextPipelines` is the canonical name. The short/medium/long diff --git a/examples/latest_signal.example.json b/examples/latest_signal.example.json index 4dc2062..c42cd4a 100644 --- a/examples/latest_signal.example.json +++ b/examples/latest_signal.example.json @@ -1,5 +1,7 @@ { - "schema_version": "1", + "schema_version": "2", + "model_version": "shadow-signal-example-v1", + "scoring_version": "shadow-signal-example-rules-v1", "as_of": "2026-05-28", "generated_at": "2026-05-28T00:00:00Z", "mode": "shadow", @@ -44,7 +46,8 @@ "confidence": 0.25, "evidence": { "sources": [ - "examples/context_bundle.example.json" + "examples/context_bundle.example.json" + ,"examples/theme_momentum_snapshot.example.json" ], "summary": "Synthetic example artifact for schema validation only, including optional cross-sector theme fields.", "data_gaps": [ @@ -52,7 +55,7 @@ "No replay evidence is attached." ] }, - "expires_at": "2026-06-28", + "expires_at": "2026-12-31", "policy": { "execution_allowed": false, "downstream_use": "Shadow context only; deterministic policy must explicitly opt in before any future use." diff --git a/examples/signal_history/2026-01-02.json b/examples/signal_history/2026-01-02.json index 9cc8432..e61ddef 100644 --- a/examples/signal_history/2026-01-02.json +++ b/examples/signal_history/2026-01-02.json @@ -1,5 +1,7 @@ { - "schema_version": "1", + "schema_version": "2", + "model_version": "shadow-signal-example-v1", + "scoring_version": "shadow-signal-example-rules-v1", "as_of": "2026-01-02", "generated_at": "2026-01-02T22:00:00Z", "mode": "shadow", diff --git a/examples/signal_history/2026-02-06.json b/examples/signal_history/2026-02-06.json index 4ef29a1..4a8c9ac 100644 --- a/examples/signal_history/2026-02-06.json +++ b/examples/signal_history/2026-02-06.json @@ -1,5 +1,7 @@ { - "schema_version": "1", + "schema_version": "2", + "model_version": "shadow-signal-example-v1", + "scoring_version": "shadow-signal-example-rules-v1", "as_of": "2026-02-06", "generated_at": "2026-02-06T22:00:00Z", "mode": "shadow", diff --git a/examples/signal_history/2026-03-20.json b/examples/signal_history/2026-03-20.json index 7440b33..2cc4c61 100644 --- a/examples/signal_history/2026-03-20.json +++ b/examples/signal_history/2026-03-20.json @@ -1,5 +1,7 @@ { - "schema_version": "1", + "schema_version": "2", + "model_version": "shadow-signal-example-v1", + "scoring_version": "shadow-signal-example-rules-v1", "as_of": "2026-03-20", "generated_at": "2026-03-20T22:00:00Z", "mode": "shadow", diff --git a/examples/signal_history/2026-04-17.json b/examples/signal_history/2026-04-17.json index 6c0a2ae..400e5d4 100644 --- a/examples/signal_history/2026-04-17.json +++ b/examples/signal_history/2026-04-17.json @@ -1,5 +1,7 @@ { - "schema_version": "1", + "schema_version": "2", + "model_version": "shadow-signal-example-v1", + "scoring_version": "shadow-signal-example-rules-v1", "as_of": "2026-04-17", "generated_at": "2026-04-17T22:00:00Z", "mode": "shadow", diff --git a/examples/theme_momentum_snapshot.example.json b/examples/theme_momentum_snapshot.example.json new file mode 100644 index 0000000..cb0b30d --- /dev/null +++ b/examples/theme_momentum_snapshot.example.json @@ -0,0 +1,35 @@ +{ + "schema_version": "2", + "as_of": "2026-05-28", + "generated_at": "2026-05-28T00:00:00Z", + "expires_at": "2026-08-20", + "model_version": "theme-momentum-example-v1", + "scoring_version": "theme-momentum-example-rules-v1", + "mode": "theme_momentum_snapshot", + "artifact_type": "medium_horizon_theme_context", + "horizon": "medium", + "theme_ranks": [], + "data_quality": { + "warnings": [], + "gate": { + "status": "pass", + "allow_downstream_recommendation": true, + "reasons": [] + }, + "coverage": { + "configured_symbol_count": 1, + "priced_symbol_count": 1, + "price_coverage_ratio": 1.0, + "insufficient_history_symbol_count": 0 + }, + "missing_price_symbols": [], + "insufficient_history_symbols": [], + "unranked_themes": [] + }, + "policy": { + "execution_allowed": false, + "portfolio_allocation_allowed": false, + "theme_rank_is_research_context_only": true, + "downstream_use": "Example validation only." + } +} diff --git a/scripts/validate_latest_signal.py b/scripts/validate_latest_signal.py index bfd98cc..372692c 100644 --- a/scripts/validate_latest_signal.py +++ b/scripts/validate_latest_signal.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +import datetime as dt import json import sys from pathlib import Path @@ -9,7 +10,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) -from research_signal_context_pipelines import SignalValidationError, validate_signal # noqa: E402 +from research_signal_context_pipelines import SignalValidationError, validate_latest_signal # noqa: E402 DEFAULT_SIGNAL_PATH = Path("data/output/latest_signal.json") @@ -19,6 +20,9 @@ def main() -> int: parser = argparse.ArgumentParser(description="Validate a long-horizon shadow signal artifact.") parser.add_argument("path", nargs="?", default=str(DEFAULT_SIGNAL_PATH), help="Signal JSON path") parser.add_argument("--allow-missing", action="store_true", help="Exit successfully when the path does not exist") + parser.add_argument("--allow-expired", action="store_true", help="Validate schema but do not allow expiry to fail the command") + parser.add_argument("--reference-date", help="Stable UTC YYYY-MM-DD freshness date") + parser.add_argument("--theme-artifact", help="Referenced theme momentum snapshot path") args = parser.parse_args() path = Path(args.path) @@ -30,7 +34,13 @@ def main() -> int: payload = json.loads(path.read_text(encoding="utf-8")) try: - validate_signal(payload) + reference_date = dt.date.fromisoformat(args.reference_date) if args.reference_date else None + validate_latest_signal( + payload, + reference_date=reference_date, + theme_artifact_path=args.theme_artifact, + check_freshness=not args.allow_expired, + ) except SignalValidationError as exc: raise SystemExit(f"invalid signal artifact: {exc}") from exc diff --git a/scripts/validate_theme_momentum_snapshot.py b/scripts/validate_theme_momentum_snapshot.py new file mode 100644 index 0000000..e0aee12 --- /dev/null +++ b/scripts/validate_theme_momentum_snapshot.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from research_signal_context_pipelines import validate_theme_momentum_snapshot # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate a theme momentum snapshot artifact.") + parser.add_argument("path", nargs="?", default="data/output/theme_momentum_snapshot.json") + parser.add_argument("--allow-expired", action="store_true") + parser.add_argument("--allow-legacy", action="store_true", help="Allow schema 1 compatibility validation") + parser.add_argument("--reference-date", help="Stable UTC YYYY-MM-DD freshness date") + args = parser.parse_args() + path = Path(args.path) + payload = json.loads(path.read_text(encoding="utf-8")) + try: + validate_theme_momentum_snapshot( + payload, + reference_date=None + if args.allow_expired + else (dt.date.fromisoformat(args.reference_date) if args.reference_date else dt.datetime.now(dt.UTC).date()), + compatibility=args.allow_legacy, + ) + except ValueError as exc: + raise SystemExit(f"invalid theme momentum snapshot: {exc}") from exc + print(f"valid theme momentum snapshot: {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/research_signal_context_pipelines/__init__.py b/src/research_signal_context_pipelines/__init__.py index 14ef0e4..2991594 100644 --- a/src/research_signal_context_pipelines/__init__.py +++ b/src/research_signal_context_pipelines/__init__.py @@ -3,8 +3,12 @@ from .overlay_backtest import OverlayPolicy, backtest_overlay 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 .schema import SignalValidationError, validate_latest_signal, validate_signal +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__ = [ @@ -17,9 +21,11 @@ "build_context_from_source", "build_theme_context", "build_theme_momentum_snapshot", + "validate_theme_momentum_snapshot", "load_symbol_theme_exposure", "load_theme_taxonomy", "validate_signal", + "validate_latest_signal", "write_filtered_price_history", "write_theme_momentum_snapshot", ] diff --git a/src/research_signal_context_pipelines/overlay_backtest.py b/src/research_signal_context_pipelines/overlay_backtest.py index 4f9edfa..42c4bb4 100644 --- a/src/research_signal_context_pipelines/overlay_backtest.py +++ b/src/research_signal_context_pipelines/overlay_backtest.py @@ -51,7 +51,7 @@ def load_signals(path: Path) -> list[dict[str, Any]]: signals: list[dict[str, Any]] = [] for signal_path in signal_paths: payload = json.loads(signal_path.read_text(encoding="utf-8")) - validate_signal(payload) + validate_signal(payload, check_freshness=False) signals.append(payload) signals.sort(key=lambda item: parse_date(str(item["as_of"]))) return signals diff --git a/src/research_signal_context_pipelines/schema.py b/src/research_signal_context_pipelines/schema.py index 8495faf..101cb90 100644 --- a/src/research_signal_context_pipelines/schema.py +++ b/src/research_signal_context_pipelines/schema.py @@ -1,7 +1,9 @@ from __future__ import annotations import datetime as dt +import json from collections.abc import Mapping, Sequence +from pathlib import Path from typing import Any @@ -77,16 +79,28 @@ def _require_number_0_1(value: Any, name: str) -> None: raise SignalValidationError(f"{name} must be between 0 and 1") -def validate_signal(payload: Mapping[str, Any]) -> None: +def validate_signal( + payload: Mapping[str, Any], *, reference_date: dt.date | None = None, check_freshness: bool = False +) -> None: missing = [key for key in REQUIRED_TOP_LEVEL_KEYS if key not in payload] 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 {"1", "2"}: + 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": + for key in ("model_version", "scoring_version"): + _require_string(payload.get(key), key) + if dt.date.fromisoformat(payload["expires_at"]) < dt.date.fromisoformat(payload["as_of"]): + raise SignalValidationError("expires_at must not be before as_of") + if check_freshness and dt.date.fromisoformat(payload["expires_at"]) < ( + reference_date or dt.datetime.now(dt.UTC).date() + ): + raise SignalValidationError(f"signal artifact expired on {payload['expires_at']}") if payload["mode"] != "shadow": raise SignalValidationError("mode must be 'shadow'") @@ -125,6 +139,39 @@ def validate_signal(payload: Mapping[str, Any]) -> None: _require_string(policy.get("downstream_use"), "policy.downstream_use") +def validate_latest_signal( + payload: Mapping[str, Any], + *, + reference_date: dt.date | None = None, + theme_artifact_path: str | Path | None = None, + check_freshness: bool = True, +) -> None: + """Validate a signal for current/latest consumption, including freshness.""" + validate_signal(payload, reference_date=reference_date, check_freshness=check_freshness) + source_paths = list(payload.get("evidence", {}).get("sources", [])) + artifact = Path(theme_artifact_path) if theme_artifact_path else next( + (Path(source) for source in source_paths if "theme_momentum_snapshot" in str(source)), + None, + ) + if artifact is None: + raise SignalValidationError("latest signal requires a referenced theme momentum artifact") + if not artifact.exists(): + raise SignalValidationError(f"referenced theme momentum artifact not found: {artifact}") + theme_payload = json.loads(artifact.read_text(encoding="utf-8")) + from .theme_momentum import validate_theme_momentum_snapshot + + try: + validate_theme_momentum_snapshot( + theme_payload, + reference_date=reference_date if check_freshness else None, + ) + except ValueError as exc: + raise SignalValidationError(f"invalid referenced theme momentum artifact: {exc}") from exc + gate = theme_payload.get("data_quality", {}).get("gate", {}) + if gate.get("allow_downstream_recommendation") is not True: + raise SignalValidationError("referenced theme momentum artifact is blocked by data_quality.gate") + + def _validate_bias_mapping(mapping: Mapping[str, Any], name: str) -> None: for key, bias in mapping.items(): _require_string(key, f"{name} key") diff --git a/src/research_signal_context_pipelines/theme_momentum.py b/src/research_signal_context_pipelines/theme_momentum.py index 6d9805d..0ed9463 100644 --- a/src/research_signal_context_pipelines/theme_momentum.py +++ b/src/research_signal_context_pipelines/theme_momentum.py @@ -25,6 +25,17 @@ 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 +MAX_PRICE_AGE_DAYS = 5 +EXTREME_RETURN_THRESHOLDS = { + "return_3m": 1.0, + "return_6_1m": 2.0, + "return_12_1m": 4.0, +} +SPLIT_SUSPECT_DAILY_RETURN = 0.50 +MIN_PRICE_COVERAGE_RATIO = 0.80 def utc_now_iso() -> str: @@ -143,7 +154,32 @@ def build_theme_momentum_snapshot( symbol for symbol in priced_exposure_symbols if symbol_scores[symbol]["momentum_score"] is None ) latest_dates = [parse_price_date(item["as_of"]) for item in symbol_scores.values()] + actual_latest_date = max(latest_dates) if latest_dates else None snapshot_as_of = (as_of_date or max(latest_dates)).isoformat() if latest_dates or as_of_date else dt.date.today().isoformat() + timestamp = generated_at or dt.datetime.now(dt.UTC) + quality_warnings: list[str] = [] + + freshness_boundary = as_of_date or actual_latest_date + if freshness_boundary and actual_latest_date and freshness_boundary - actual_latest_date > dt.timedelta(days=MAX_PRICE_AGE_DAYS): + quality_warnings.append( + f"stale price data: latest priced row {actual_latest_date.isoformat()} is older than {MAX_PRICE_AGE_DAYS} days before as_of {freshness_boundary.isoformat()}" + ) + for symbol, symbol_data in symbol_scores.items(): + metrics = symbol_data["metrics"] + for metric, threshold in EXTREME_RETURN_THRESHOLDS.items(): + value = metrics.get(metric) + if value is not None and abs(value) > threshold: + quality_warnings.append( + f"extreme {metric} for {symbol}: {value:.4f} exceeds +/-{threshold:.2f}; secondary validation required" + ) + ordered_rows = sorted(rows_by_symbol[symbol], key=lambda item: item.date) + continuity_window = ordered_rows[-(max(spec["lookback"] + spec["skip"] for spec in MOMENTUM_WINDOWS.values()) + 1) :] + for previous, current in zip(continuity_window, continuity_window[1:]): + if previous.close > 0 and abs(current.close / previous.close - 1.0) > SPLIT_SUSPECT_DAILY_RETURN: + quality_warnings.append( + f"possible split/adjustment for {symbol} on {current.date.isoformat()}; adjusted-price continuity requires review" + ) + break theme_members: dict[str, list[str]] = defaultdict(list) for symbol, exposure in exposures.items(): @@ -217,10 +253,27 @@ def build_theme_momentum_snapshot( item["rank"] = idx taxonomy_versions = sorted({theme.taxonomy_version for theme in themes.values() if theme.taxonomy_version}) + coverage_ratio = len(priced_exposure_symbols) / len(exposure_symbols) if exposure_symbols else 0.0 + if missing_price_symbols: + quality_warnings.append(f"missing price symbols: {', '.join(sorted(missing_price_symbols))}") + if insufficient_history_symbols: + quality_warnings.append( + f"insufficient history symbols: {', '.join(insufficient_history_symbols)}" + ) + if unranked_themes: + quality_warnings.append(f"unranked themes: {', '.join(sorted(unranked_themes))}") + if coverage_ratio < MIN_PRICE_COVERAGE_RATIO: + quality_warnings.append( + f"price coverage ratio {coverage_ratio:.4f} is below minimum {MIN_PRICE_COVERAGE_RATIO:.2f}" + ) + quality_warnings = list(dict.fromkeys(quality_warnings)) 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"), + "generated_at": timestamp.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, @@ -244,11 +297,17 @@ def build_theme_momentum_snapshot( }, "theme_ranks": theme_ranks, "data_quality": { + "warnings": quality_warnings, + "gate": { + "status": "blocked" if quality_warnings else "pass", + "allow_downstream_recommendation": not quality_warnings, + "reasons": quality_warnings, + }, "coverage": { "configured_symbol_count": len(exposure_symbols), "priced_symbol_count": len(priced_exposure_symbols), "price_coverage_ratio": round_optional( - len(priced_exposure_symbols) / len(exposure_symbols) if exposure_symbols else None + coverage_ratio if exposure_symbols else None ), "insufficient_history_symbol_count": len(insufficient_history_symbols), }, @@ -265,6 +324,65 @@ def build_theme_momentum_snapshot( } +def validate_theme_momentum_snapshot( + snapshot: Mapping[str, Any], *, reference_date: dt.date | None = None, compatibility: bool = False +) -> None: + required = ("schema_version", "as_of", "generated_at") + missing = [key for key in required if not snapshot.get(key)] + 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'") + as_of = parse_price_date(snapshot["as_of"]) + if schema_version == "1": + if not compatibility: + raise ValueError("schema 1 theme momentum snapshots require explicit compatibility=True") + legacy_required = ("mode", "artifact_type", "horizon", "theme_ranks", "data_quality", "policy") + missing_legacy = [key for key in legacy_required if key not in snapshot] + if missing_legacy: + raise ValueError(f"legacy theme momentum snapshot missing required keys: {', '.join(missing_legacy)}") + generated_at = snapshot["generated_at"] + if not isinstance(generated_at, str) or not generated_at.strip(): + raise ValueError("legacy theme momentum snapshot generated_at must be a non-empty ISO datetime") + try: + dt.datetime.fromisoformat(generated_at[:-1] + "+00:00" if generated_at.endswith("Z") else generated_at) + except ValueError as exc: + raise ValueError("legacy theme momentum snapshot generated_at must be an ISO datetime") from exc + if not isinstance(snapshot["theme_ranks"], list) or not isinstance(snapshot["data_quality"], Mapping): + raise ValueError("legacy theme momentum snapshot core shape is invalid") + return + for key in ("expires_at", "model_version", "scoring_version"): + if not snapshot.get(key): + raise ValueError(f"theme momentum snapshot missing required keys: {key}") + expires_at = parse_price_date(snapshot["expires_at"]) + 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 ISO datetime") + try: + dt.datetime.fromisoformat(generated_at[:-1] + "+00:00" if generated_at.endswith("Z") else generated_at) + except ValueError as exc: + raise ValueError("theme momentum snapshot generated_at must be an ISO datetime") from exc + for key in ("model_version", "scoring_version"): + if not isinstance(snapshot[key], str) or not snapshot[key].strip(): + raise ValueError(f"theme momentum snapshot {key} must be a non-empty string") + if expires_at < as_of: + raise ValueError("theme momentum snapshot expires_at must not be before as_of") + if reference_date and expires_at < reference_date: + raise ValueError(f"theme momentum snapshot expired on {expires_at.isoformat()}") + data_quality = snapshot.get("data_quality") + if not isinstance(data_quality, Mapping) or not isinstance(data_quality.get("warnings"), list): + raise ValueError("theme momentum snapshot data_quality.warnings must be a list") + gate = data_quality.get("gate") + if not isinstance(gate, Mapping) or gate.get("status") not in {"pass", "blocked"}: + raise ValueError("theme momentum snapshot data_quality.gate.status is invalid") + if not isinstance(gate.get("allow_downstream_recommendation"), bool): + raise ValueError("theme momentum snapshot data_quality.gate.allow_downstream_recommendation must be boolean") + warnings = data_quality["warnings"] + if gate["allow_downstream_recommendation"] != (not warnings) or gate["status"] != ("pass" if not warnings else "blocked"): + raise ValueError("theme momentum snapshot data_quality.gate is inconsistent with warnings") + + 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..337d2d4 100644 --- a/tests/test_signal_validation.py +++ b/tests/test_signal_validation.py @@ -1,11 +1,12 @@ from __future__ import annotations import json +import datetime as dt from pathlib import Path import pytest -from research_signal_context_pipelines import SignalValidationError, validate_signal +from research_signal_context_pipelines import SignalValidationError, validate_latest_signal, validate_signal ROOT = Path(__file__).resolve().parents[1] @@ -19,6 +20,8 @@ def test_example_signal_is_valid() -> None: payload = load_example() validate_signal(payload) assert payload["horizon"] == "1-3 years" + assert payload["model_version"] + assert payload["scoring_version"] def test_signal_requires_long_horizon_contract() -> None: @@ -100,13 +103,62 @@ def test_signal_rejects_invalid_structured_bias_confidence() -> None: validate_signal(payload) +def test_signal_requires_versioned_scoring_metadata() -> None: + payload = load_example() + payload["schema_version"] = "2" + payload.pop("model_version") + + with pytest.raises(SignalValidationError, match="model_version"): + validate_signal(payload) + + +def test_signal_rejects_expiry_before_as_of() -> None: + payload = load_example() + payload["expires_at"] = "2026-05-27" + + with pytest.raises(SignalValidationError, match="expires_at"): + validate_signal(payload) + + +def test_public_signal_validation_preserves_historical_compatibility() -> None: + payload = load_example() + payload["expires_at"] = "2026-06-30" + validate_signal(payload) + + with pytest.raises(SignalValidationError, match="expired"): + validate_latest_signal(payload, reference_date=dt.date(2026, 7, 13)) + + def test_committed_latest_signal_covers_advisor_long_context() -> None: payload = json.loads((ROOT / "data" / "output" / "latest_signal.json").read_text(encoding="utf-8")) - validate_signal(payload) + validate_signal(payload, check_freshness=False) assert payload["horizon"] == "1-3 years" assert payload.get("theme_bias") assert payload.get("symbol_theme_exposure") covered_symbols = set(payload.get("symbol_bias", {})) | set(payload.get("symbol_theme_exposure", {})) assert {"MU", "INTC", "AMD", "VRT", "DELL"} <= covered_symbols + + +def test_strict_latest_signal_rejects_blocked_theme_gate() -> None: + payload = json.loads((ROOT / "data" / "output" / "latest_signal.json").read_text(encoding="utf-8")) + + with pytest.raises(SignalValidationError, match="blocked"): + validate_latest_signal( + payload, + reference_date=dt.date(2026, 6, 30), + theme_artifact_path=ROOT / "data" / "output" / "theme_momentum_snapshot.json", + ) + + +def test_allow_expired_only_skips_freshness_not_theme_gate() -> None: + payload = json.loads((ROOT / "data" / "output" / "latest_signal.json").read_text(encoding="utf-8")) + + with pytest.raises(SignalValidationError, match="blocked"): + validate_latest_signal( + payload, + reference_date=dt.date(2026, 7, 13), + check_freshness=False, + theme_artifact_path=ROOT / "data" / "output" / "theme_momentum_snapshot.json", + ) diff --git a/tests/test_theme_momentum.py b/tests/test_theme_momentum.py index 94943e0..19c4aab 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 @@ -96,3 +99,88 @@ 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 + assert snapshot["data_quality"]["gate"]["allow_downstream_recommendation"] is False + assert any("missing price symbols" in warning for warning in snapshot["data_quality"]["warnings"]) + + +def test_theme_momentum_emits_freshness_versions_and_data_quality_gate() -> None: + themes = { + "ai_compute": ThemeDefinition("test-v1", "ai_compute", "AI", "technology", "6-24 months", "AI", "primary") + } + exposures = {"MU": SymbolThemeExposure("MU", ("ai_compute",), "high", "memory")} + rows = _trend_rows("MU", start_close=10, daily_step=0.5, days=280) + + snapshot = build_theme_momentum_snapshot( + rows, + themes=themes, + exposures=exposures, + generated_at=dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc), + ) + + assert snapshot["schema_version"] + assert snapshot["model_version"] + assert snapshot["scoring_version"] + assert snapshot["expires_at"] == "2025-12-30" + assert snapshot["data_quality"]["gate"]["allow_downstream_recommendation"] is False + assert any("extreme" in warning for warning in snapshot["data_quality"]["warnings"]) + + +def test_historical_as_of_uses_point_in_time_price_coverage_not_wall_clock() -> None: + themes = { + "ai_compute": ThemeDefinition("test-v1", "ai_compute", "AI", "technology", "6-24 months", "AI", "primary") + } + exposures = {"MU": SymbolThemeExposure("MU", ("ai_compute",), "high", "memory")} + rows = _trend_rows("MU", start_close=100, daily_step=0.1, days=280) + as_of = rows[-1].date + + snapshot = build_theme_momentum_snapshot( + rows, + themes=themes, + exposures=exposures, + as_of=as_of, + generated_at=dt.datetime(2026, 7, 13, tzinfo=dt.timezone.utc), + ) + + assert not any("stale" in warning for warning in snapshot["data_quality"]["warnings"]) + + +def test_as_of_after_latest_price_uses_lag_gate_instead_of_raising() -> None: + themes = { + "ai_compute": ThemeDefinition("test-v1", "ai_compute", "AI", "technology", "6-24 months", "AI", "primary") + } + exposures = {"MU": SymbolThemeExposure("MU", ("ai_compute",), "high", "memory")} + rows = _trend_rows("MU", start_close=100, daily_step=0.1, days=280) + requested_as_of = rows[-1].date + dt.timedelta(days=2) + + snapshot = build_theme_momentum_snapshot( + rows, + themes=themes, + exposures=exposures, + as_of=requested_as_of, + ) + + assert snapshot["as_of"] == requested_as_of.isoformat() + assert not any("stale" in warning for warning in snapshot["data_quality"]["warnings"]) + + +def test_theme_schema_one_requires_explicit_compatibility() -> None: + snapshot = { + "schema_version": "1", + "as_of": "2026-01-01", + "generated_at": "2026-01-02T00:00:00Z", + "mode": "theme_momentum_snapshot", + "artifact_type": "medium_horizon_theme_context", + "horizon": "medium", + "theme_ranks": [], + "data_quality": {}, + "policy": {}, + } + + try: + validate_theme_momentum_snapshot(snapshot) + except ValueError as exc: + assert "compatibility" in str(exc) + else: + raise AssertionError("schema 1 must be rejected without compatibility") + + validate_theme_momentum_snapshot(snapshot, compatibility=True)