From fc85e5de7957c557d34d577a00008e1f50ca1bc9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:08:36 +0800 Subject: [PATCH 01/47] feat(strategy): enforce evidence-first review gates Co-Authored-By: Codex --- .../run_crypto_live_pool_baseline_review.py | 100 ++++++++++++++++++ src/strategy_lifecycle/backtest_wrapper.py | 52 ++++----- tests/test_baseline_review.py | 32 ++++++ tests/test_orchestrator_runner.py | 6 ++ 4 files changed, 161 insertions(+), 29 deletions(-) create mode 100644 scripts/run_crypto_live_pool_baseline_review.py create mode 100644 tests/test_baseline_review.py diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py new file mode 100644 index 0000000..4309b34 --- /dev/null +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Produce a fail-closed S2 baseline review for crypto live-pool rotation.""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +PROFILE = "crypto_live_pool_rotation" +GATE_NAMES = { + "H1": "falsifiable_hypothesis", + "H2": "data_provenance", + "H3": "sample_adequacy", + "H4": "benchmark_comparison", + "H5": "cost_model", + "H6": "cost_stress", + "H7": "oos_folds", + "H8": "purge_embargo", + "H9": "leakage_control", + "H10": "parameter_stability", + "H11": "risk_metrics", + "H12": "reproducibility", +} + + +def _gate(gate_id: str, reason: str, *, status: str = "insufficient_evidence", refs: list[str] | None = None) -> dict[str, Any]: + return { + "id": gate_id, + "name": GATE_NAMES[gate_id], + "status": status, + "reason_codes": [reason], + "evidence_refs": refs or [], + } + + +def build_review(*, performance_summary: Path, walkforward_summary: Path) -> dict[str, Any]: + missing = [str(path) for path in (performance_summary, walkforward_summary) if not path.exists()] + reason = "MISSING_REAL_PERFORMANCE_ARTIFACT" if missing else "BASELINE_REVIEW_NOT_YET_FROZEN" + gates = [_gate(gate_id, reason) for gate_id in GATE_NAMES] + return { + "schema_version": "strategy_review.v1", + "profile": PROFILE, + "decision": "insufficient_evidence", + "promotion_allowed": False, + "score": 0, + "hard_gates": gates, + "scorecard": {"total": 0, "max": 100, "scored_gates": 0}, + "blocking_reason_codes": [reason], + "evidence": { + "metrics_kind": "performance", + "data_source": "unavailable" if missing else "unfrozen", + "sample_count": 0, + "oos_folds": 0, + "cost_model": "unavailable", + "placeholder_metrics": False, + "missing_artifacts": missing, + "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + } + + +def render_markdown(review: dict[str, Any]) -> str: + lines = [ + f"# Baseline review: `{review['profile']}`", + "", + f"- Decision: **{review['decision']}**", + f"- Promotion allowed: **{review['promotion_allowed']}**", + f"- Score: **{review['score']}/100**", + "", + "This report is fail-closed. No real performance artifact is promoted by this command.", + "", + "| Gate | Status | Reason |", + "|---|---|---|", + ] + for gate in review["hard_gates"]: + lines.append(f"| {gate['id']} {gate['name']} | {gate['status']} | {', '.join(gate['reason_codes'])} |") + lines.extend(["", "## Blocking reasons", "", *[f"- `{reason}`" for reason in review["blocking_reason_codes"]]]) + return "\n".join(lines) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--performance-summary", type=Path, default=Path("data/reports/performance_summary.csv")) + parser.add_argument("--walkforward-summary", type=Path, default=Path("data/reports/walkforward_validation_summary.csv")) + parser.add_argument("--output-json", type=Path, required=True) + parser.add_argument("--output-md", type=Path, required=True) + args = parser.parse_args(argv) + review = build_review(performance_summary=args.performance_summary, walkforward_summary=args.walkforward_summary) + args.output_json.parent.mkdir(parents=True, exist_ok=True) + args.output_json.write_text(json.dumps(review, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + args.output_md.write_text(render_markdown(review), encoding="utf-8") + print(json.dumps({"decision": review["decision"], "promotion_allowed": False, "output": str(args.output_json)})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 2ece8bd..4e957f6 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -1,47 +1,41 @@ -"""Crypto BacktestRunner — wraps BinancePlatform backtest for the lifecycle system.""" +"""Lifecycle backtest adapter for the crypto live-pool rotation strategy.""" from __future__ import annotations from datetime import date from typing import Any, Mapping -from quant_platform_kit.strategy_lifecycle.contracts import BacktestResult +import pandas as pd + +from .orchestrator_runner import CryptoLivePoolBacktestRunner, PROFILE_NAME class CryptoBacktestRunner: - """BacktestRunner for Crypto strategies. + """Expose the real crypto backtest engine through the lifecycle contract. - Wraps BinancePlatform/research/backtest.py and CryptoLivePoolPipelines scripts. + A market panel is required deliberately: returning hard-coded metrics would + make a lifecycle snapshot look like performance evidence without a data + source. Synthetic panels remain available only through the explicit pilot + runner used by tests/research fixtures. """ + def __init__(self, *, panel: pd.DataFrame | None = None) -> None: + if panel is None: + raise ValueError("CryptoBacktestRunner requires a real prepared market panel") + self._runner = CryptoLivePoolBacktestRunner(panel=panel) + def run( self, strategy_profile: str, params: Mapping[str, Any], start_date: date | None = None, end_date: date | None = None, - ) -> BacktestResult: - """Run backtest for a crypto strategy.""" - return BacktestResult( - strategy_profile=strategy_profile, - domain="crypto", - param_set_id=f"crypto_{strategy_profile}_1", - params=dict(params), - param_version=1, - sharpe_ratio=1.5, - calmar_ratio=1.1, - max_drawdown=-0.20, - cagr=0.35, - volatility=0.45, - win_rate=0.55, - start_date=start_date or date(2020, 1, 1), - end_date=end_date or date.today(), - observation_count=2000, - benchmark_symbol="buy_hold_BTC", - source_script="CryptoLivePoolPipelines/scripts/run_research_backtest.py", - ) - - -def build_backtest_runner() -> CryptoBacktestRunner: - """Factory for the Crypto backtest runner.""" - return CryptoBacktestRunner() + ) -> Any: + if strategy_profile != PROFILE_NAME: + raise ValueError(f"Unsupported strategy_profile={strategy_profile!r}") + return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) + + +def build_backtest_runner(*, panel: pd.DataFrame | None = None) -> CryptoBacktestRunner: + """Build a production adapter; callers must supply prepared real data.""" + return CryptoBacktestRunner(panel=panel) diff --git a/tests/test_baseline_review.py b/tests/test_baseline_review.py new file mode 100644 index 0000000..37b3f6e --- /dev/null +++ b/tests/test_baseline_review.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import importlib.util +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("baseline_review", ROOT / "scripts/run_crypto_live_pool_baseline_review.py") +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +SPEC.loader.exec_module(MODULE) + + +class BaselineReviewTests(unittest.TestCase): + def test_missing_real_artifacts_is_insufficient_and_not_promotable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + review = MODULE.build_review( + performance_summary=root / "performance.csv", + walkforward_summary=root / "walkforward.csv", + ) + self.assertEqual(review["decision"], "insufficient_evidence") + self.assertFalse(review["promotion_allowed"]) + self.assertEqual(len(review["hard_gates"]), 12) + self.assertIn("MISSING_REAL_PERFORMANCE_ARTIFACT", review["blocking_reason_codes"]) + self.assertFalse(review["evidence"]["placeholder_metrics"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index a99ffb4..3e83eed 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -18,6 +18,12 @@ class CryptoOrchestratorRunnerTests(unittest.TestCase): + def test_production_wrapper_requires_real_panel(self) -> None: + from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner + + with self.assertRaisesRegex(ValueError, "real prepared market panel"): + CryptoBacktestRunner() + def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 4c0b08ced6a25e2e3cfa5cf1021b5c4f21801edf Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:12:39 +0800 Subject: [PATCH 02/47] fix(strategy): address review gate findings Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 1 + src/strategy_lifecycle/backtest_wrapper.py | 10 ++++++---- tests/test_orchestrator_runner.py | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index 4309b34..17061fe 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -90,6 +90,7 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) review = build_review(performance_summary=args.performance_summary, walkforward_summary=args.walkforward_summary) args.output_json.parent.mkdir(parents=True, exist_ok=True) + args.output_md.parent.mkdir(parents=True, exist_ok=True) args.output_json.write_text(json.dumps(review, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") args.output_md.write_text(render_markdown(review), encoding="utf-8") print(json.dumps({"decision": review["decision"], "promotion_allowed": False, "output": str(args.output_json)})) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 4e957f6..bc46610 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -7,6 +7,8 @@ import pandas as pd +from quant_platform_kit.strategy_lifecycle.contracts import BacktestResult + from .orchestrator_runner import CryptoLivePoolBacktestRunner, PROFILE_NAME @@ -20,9 +22,7 @@ class CryptoBacktestRunner: """ def __init__(self, *, panel: pd.DataFrame | None = None) -> None: - if panel is None: - raise ValueError("CryptoBacktestRunner requires a real prepared market panel") - self._runner = CryptoLivePoolBacktestRunner(panel=panel) + self._runner = CryptoLivePoolBacktestRunner(panel=panel) if panel is not None else None def run( self, @@ -30,9 +30,11 @@ def run( params: Mapping[str, Any], start_date: date | None = None, end_date: date | None = None, - ) -> Any: + ) -> BacktestResult: if strategy_profile != PROFILE_NAME: raise ValueError(f"Unsupported strategy_profile={strategy_profile!r}") + if self._runner is None: + raise ValueError("CryptoBacktestRunner requires a real prepared market panel") return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 3e83eed..1055512 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -21,8 +21,9 @@ class CryptoOrchestratorRunnerTests(unittest.TestCase): def test_production_wrapper_requires_real_panel(self) -> None: from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner + runner = CryptoBacktestRunner() with self.assertRaisesRegex(ValueError, "real prepared market panel"): - CryptoBacktestRunner() + runner.run(PROFILE_NAME, {}) def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 6bb17184f02c1dc11dab1740938a0cedc1af3575 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:14:51 +0800 Subject: [PATCH 03/47] fix(strategy): fail closed before crypto backtest startup Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 2 +- src/strategy_lifecycle/backtest_wrapper.py | 6 +++--- tests/test_orchestrator_runner.py | 3 +-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index 17061fe..576a7cf 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -94,7 +94,7 @@ def main(argv: list[str] | None = None) -> int: args.output_json.write_text(json.dumps(review, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") args.output_md.write_text(render_markdown(review), encoding="utf-8") print(json.dumps({"decision": review["decision"], "promotion_allowed": False, "output": str(args.output_json)})) - return 0 + return 0 if review["decision"] == "pass" and review["promotion_allowed"] else 2 if __name__ == "__main__": diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index bc46610..070641e 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -22,7 +22,9 @@ class CryptoBacktestRunner: """ def __init__(self, *, panel: pd.DataFrame | None = None) -> None: - self._runner = CryptoLivePoolBacktestRunner(panel=panel) if panel is not None else None + if panel is None: + raise ValueError("CryptoBacktestRunner requires a real prepared market panel") + self._runner = CryptoLivePoolBacktestRunner(panel=panel) def run( self, @@ -33,8 +35,6 @@ def run( ) -> BacktestResult: if strategy_profile != PROFILE_NAME: raise ValueError(f"Unsupported strategy_profile={strategy_profile!r}") - if self._runner is None: - raise ValueError("CryptoBacktestRunner requires a real prepared market panel") return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 1055512..3e83eed 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -21,9 +21,8 @@ class CryptoOrchestratorRunnerTests(unittest.TestCase): def test_production_wrapper_requires_real_panel(self) -> None: from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner - runner = CryptoBacktestRunner() with self.assertRaisesRegex(ValueError, "real prepared market panel"): - runner.run(PROFILE_NAME, {}) + CryptoBacktestRunner() def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From cb6c6e334e1df911a9a319d60e89daee2a945d11 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:20:36 +0800 Subject: [PATCH 04/47] fix(strategy): require panel in crypto runner factory Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 070641e..9111fcf 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -38,6 +38,6 @@ def run( return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) -def build_backtest_runner(*, panel: pd.DataFrame | None = None) -> CryptoBacktestRunner: - """Build a production adapter; callers must supply prepared real data.""" +def build_backtest_runner(*, panel: pd.DataFrame) -> CryptoBacktestRunner: + """Build a production adapter from a required prepared real-data panel.""" return CryptoBacktestRunner(panel=panel) From 883b592b12483aeb3928c8c292408cb973fc4bf2 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:25:14 +0800 Subject: [PATCH 05/47] fix(strategy): preserve crypto adapter factory contract Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 12 +++++------- tests/test_orchestrator_runner.py | 3 ++- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 9111fcf..7e3f738 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -22,9 +22,7 @@ class CryptoBacktestRunner: """ def __init__(self, *, panel: pd.DataFrame | None = None) -> None: - if panel is None: - raise ValueError("CryptoBacktestRunner requires a real prepared market panel") - self._runner = CryptoLivePoolBacktestRunner(panel=panel) + self._runner = CryptoLivePoolBacktestRunner(panel=panel) if panel is not None else None def run( self, @@ -33,11 +31,11 @@ def run( start_date: date | None = None, end_date: date | None = None, ) -> BacktestResult: - if strategy_profile != PROFILE_NAME: - raise ValueError(f"Unsupported strategy_profile={strategy_profile!r}") + if self._runner is None: + raise ValueError("CryptoBacktestRunner requires a real prepared market panel") return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) -def build_backtest_runner(*, panel: pd.DataFrame) -> CryptoBacktestRunner: - """Build a production adapter from a required prepared real-data panel.""" +def build_backtest_runner(*, panel: pd.DataFrame | None = None) -> CryptoBacktestRunner: + """Build an adapter; a real prepared panel is required before running.""" return CryptoBacktestRunner(panel=panel) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 3e83eed..1055512 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -21,8 +21,9 @@ class CryptoOrchestratorRunnerTests(unittest.TestCase): def test_production_wrapper_requires_real_panel(self) -> None: from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner + runner = CryptoBacktestRunner() with self.assertRaisesRegex(ValueError, "real prepared market panel"): - CryptoBacktestRunner() + runner.run(PROFILE_NAME, {}) def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 83b2885c6bc38532d6561ad7e191ea5775df7dda Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:28:01 +0800 Subject: [PATCH 06/47] fix(strategy): remove unused adapter import Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 2 +- tests/test_orchestrator_runner.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 7e3f738..4872c4e 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -9,7 +9,7 @@ from quant_platform_kit.strategy_lifecycle.contracts import BacktestResult -from .orchestrator_runner import CryptoLivePoolBacktestRunner, PROFILE_NAME +from .orchestrator_runner import CryptoLivePoolBacktestRunner class CryptoBacktestRunner: diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 1055512..0924213 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -23,7 +23,7 @@ def test_production_wrapper_requires_real_panel(self) -> None: runner = CryptoBacktestRunner() with self.assertRaisesRegex(ValueError, "real prepared market panel"): - runner.run(PROFILE_NAME, {}) + runner.run("crypto_live_pool_rotation", {}) def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From bf75f56427bdb47ec1ebc6c57c05a34659af07e2 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:34:31 +0800 Subject: [PATCH 07/47] fix(strategy): require valid baseline inputs Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 16 ++++++++++++++-- src/strategy_lifecycle/backtest_wrapper.py | 10 +++++----- tests/test_baseline_review.py | 2 +- tests/test_orchestrator_runner.py | 3 +-- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index 576a7cf..82bb3ca 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import csv import json from datetime import datetime, timezone from pathlib import Path @@ -37,8 +38,8 @@ def _gate(gate_id: str, reason: str, *, status: str = "insufficient_evidence", r def build_review(*, performance_summary: Path, walkforward_summary: Path) -> dict[str, Any]: - missing = [str(path) for path in (performance_summary, walkforward_summary) if not path.exists()] - reason = "MISSING_REAL_PERFORMANCE_ARTIFACT" if missing else "BASELINE_REVIEW_NOT_YET_FROZEN" + missing = [str(path) for path in (performance_summary, walkforward_summary) if not _usable_csv(path)] + reason = "MISSING_OR_INVALID_REAL_PERFORMANCE_ARTIFACT" if missing else "BASELINE_REVIEW_NOT_YET_FROZEN" gates = [_gate(gate_id, reason) for gate_id in GATE_NAMES] return { "schema_version": "strategy_review.v1", @@ -62,6 +63,17 @@ def build_review(*, performance_summary: Path, walkforward_summary: Path) -> dic } +def _usable_csv(path: Path) -> bool: + if not path.exists() or not path.is_file(): + return False + try: + with path.open(newline="", encoding="utf-8") as handle: + rows = list(csv.reader(handle)) + return len(rows) >= 2 and any(cell.strip() for cell in rows[0]) + except (OSError, UnicodeError, csv.Error): + return False + + def render_markdown(review: dict[str, Any]) -> str: lines = [ f"# Baseline review: `{review['profile']}`", diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 4872c4e..0854712 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -22,7 +22,9 @@ class CryptoBacktestRunner: """ def __init__(self, *, panel: pd.DataFrame | None = None) -> None: - self._runner = CryptoLivePoolBacktestRunner(panel=panel) if panel is not None else None + if panel is None: + raise ValueError("CryptoBacktestRunner requires a real prepared market panel") + self._runner = CryptoLivePoolBacktestRunner(panel=panel) def run( self, @@ -31,11 +33,9 @@ def run( start_date: date | None = None, end_date: date | None = None, ) -> BacktestResult: - if self._runner is None: - raise ValueError("CryptoBacktestRunner requires a real prepared market panel") return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) -def build_backtest_runner(*, panel: pd.DataFrame | None = None) -> CryptoBacktestRunner: - """Build an adapter; a real prepared panel is required before running.""" +def build_backtest_runner(*, panel: pd.DataFrame) -> CryptoBacktestRunner: + """Build an adapter from a required prepared real-data panel.""" return CryptoBacktestRunner(panel=panel) diff --git a/tests/test_baseline_review.py b/tests/test_baseline_review.py index 37b3f6e..241c82a 100644 --- a/tests/test_baseline_review.py +++ b/tests/test_baseline_review.py @@ -24,7 +24,7 @@ def test_missing_real_artifacts_is_insufficient_and_not_promotable(self) -> None self.assertEqual(review["decision"], "insufficient_evidence") self.assertFalse(review["promotion_allowed"]) self.assertEqual(len(review["hard_gates"]), 12) - self.assertIn("MISSING_REAL_PERFORMANCE_ARTIFACT", review["blocking_reason_codes"]) + self.assertIn("MISSING_OR_INVALID_REAL_PERFORMANCE_ARTIFACT", review["blocking_reason_codes"]) self.assertFalse(review["evidence"]["placeholder_metrics"]) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 0924213..3e83eed 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -21,9 +21,8 @@ class CryptoOrchestratorRunnerTests(unittest.TestCase): def test_production_wrapper_requires_real_panel(self) -> None: from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner - runner = CryptoBacktestRunner() with self.assertRaisesRegex(ValueError, "real prepared market panel"): - runner.run("crypto_live_pool_rotation", {}) + CryptoBacktestRunner() def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 394263ea550d5c67f788340f1f8d75933d0f40e5 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:38:26 +0800 Subject: [PATCH 08/47] fix(strategy): preserve legacy crypto factory Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 6 ++++-- src/strategy_lifecycle/backtest_wrapper.py | 9 +++++++-- tests/test_orchestrator_runner.py | 3 ++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index 82bb3ca..c311ce1 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -68,8 +68,10 @@ def _usable_csv(path: Path) -> bool: return False try: with path.open(newline="", encoding="utf-8") as handle: - rows = list(csv.reader(handle)) - return len(rows) >= 2 and any(cell.strip() for cell in rows[0]) + reader = csv.reader(handle) + header = next(reader, None) + data_row = next(reader, None) + return bool(header and data_row and any(cell.strip() for cell in header)) except (OSError, UnicodeError, csv.Error): return False diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 0854712..b8fa99f 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -36,6 +36,11 @@ def run( return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) -def build_backtest_runner(*, panel: pd.DataFrame) -> CryptoBacktestRunner: - """Build an adapter from a required prepared real-data panel.""" +def build_backtest_runner() -> CryptoBacktestRunner: + """Preserve the legacy entrypoint; running without evidence fails closed.""" + return CryptoBacktestRunner() + + +def build_real_backtest_runner(*, panel: pd.DataFrame) -> CryptoBacktestRunner: + """Build the production adapter from a required prepared real-data panel.""" return CryptoBacktestRunner(panel=panel) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 3e83eed..2763757 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -19,8 +19,9 @@ class CryptoOrchestratorRunnerTests(unittest.TestCase): def test_production_wrapper_requires_real_panel(self) -> None: - from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner + from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner, build_real_backtest_runner + self.assertIsNotNone(build_real_backtest_runner) with self.assertRaisesRegex(ValueError, "real prepared market panel"): CryptoBacktestRunner() From 3e47556af0de06e1cc06596050d61c5600552707 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:39:57 +0800 Subject: [PATCH 09/47] feat(strategy): emit chinese decision packet Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 11 +++++++++++ tests/test_baseline_review.py | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index c311ce1..e1fb7f4 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -60,6 +60,17 @@ def build_review(*, performance_summary: Path, walkforward_summary: Path) -> dic "missing_artifacts": missing, "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), }, + "decision_packet": { + "strategy_what": "按评分选择加密资产并进行 live-pool rotation;当前仅评审流程,不执行交易。", + "return_source": "真实 performance artifacts 未提供,收益来源无法确认。", + "loss_scenarios": "未完成真实回测,主要亏损场景无法确认;不得用 placeholder 指标替代。", + "max_risk": "证据不足,最大回撤、容量和流动性风险均不可确认。", + "evidence_sufficiency": "insufficient_evidence", + "version_change": "新增 fail-closed 基线评审与人工决策 packet;未改变 live 参数。", + "system_recommendation": "insufficient_evidence", + "technical_evidence_refs": missing, + "allowed_human_decisions": ["approve_research", "approve_shadow", "approve_canary", "approve_live", "reject_rollback"], + }, } diff --git a/tests/test_baseline_review.py b/tests/test_baseline_review.py index 241c82a..0b4eb3d 100644 --- a/tests/test_baseline_review.py +++ b/tests/test_baseline_review.py @@ -26,6 +26,10 @@ def test_missing_real_artifacts_is_insufficient_and_not_promotable(self) -> None self.assertEqual(len(review["hard_gates"]), 12) self.assertIn("MISSING_OR_INVALID_REAL_PERFORMANCE_ARTIFACT", review["blocking_reason_codes"]) self.assertFalse(review["evidence"]["placeholder_metrics"]) + packet = review["decision_packet"] + self.assertEqual(packet["system_recommendation"], "insufficient_evidence") + self.assertEqual(packet["evidence_sufficiency"], "insufficient_evidence") + self.assertEqual(len(packet["allowed_human_decisions"]), 5) if __name__ == "__main__": From 87ad08bbfb72eb81f9e994967f3951209b10b08e Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:44:28 +0800 Subject: [PATCH 10/47] fix(strategy): preserve fail-closed legacy adapter Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 2 +- src/strategy_lifecycle/backtest_wrapper.py | 6 +++--- tests/test_baseline_review.py | 2 +- tests/test_orchestrator_runner.py | 3 ++- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index e1fb7f4..bb17923 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -69,7 +69,7 @@ def build_review(*, performance_summary: Path, walkforward_summary: Path) -> dic "version_change": "新增 fail-closed 基线评审与人工决策 packet;未改变 live 参数。", "system_recommendation": "insufficient_evidence", "technical_evidence_refs": missing, - "allowed_human_decisions": ["approve_research", "approve_shadow", "approve_canary", "approve_live", "reject_rollback"], + "allowed_human_decisions": ["approve_research", "reject_rollback"], }, } diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index b8fa99f..fd29a8c 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -22,9 +22,7 @@ class CryptoBacktestRunner: """ def __init__(self, *, panel: pd.DataFrame | None = None) -> None: - if panel is None: - raise ValueError("CryptoBacktestRunner requires a real prepared market panel") - self._runner = CryptoLivePoolBacktestRunner(panel=panel) + self._runner = CryptoLivePoolBacktestRunner(panel=panel) if panel is not None else None def run( self, @@ -33,6 +31,8 @@ def run( start_date: date | None = None, end_date: date | None = None, ) -> BacktestResult: + if self._runner is None: + raise ValueError("CryptoBacktestRunner requires a real prepared market panel") return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) diff --git a/tests/test_baseline_review.py b/tests/test_baseline_review.py index 0b4eb3d..668e29f 100644 --- a/tests/test_baseline_review.py +++ b/tests/test_baseline_review.py @@ -29,7 +29,7 @@ def test_missing_real_artifacts_is_insufficient_and_not_promotable(self) -> None packet = review["decision_packet"] self.assertEqual(packet["system_recommendation"], "insufficient_evidence") self.assertEqual(packet["evidence_sufficiency"], "insufficient_evidence") - self.assertEqual(len(packet["allowed_human_decisions"]), 5) + self.assertEqual(packet["allowed_human_decisions"], ["approve_research", "reject_rollback"]) if __name__ == "__main__": diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 2763757..b22abce 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -22,8 +22,9 @@ def test_production_wrapper_requires_real_panel(self) -> None: from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner, build_real_backtest_runner self.assertIsNotNone(build_real_backtest_runner) + runner = CryptoBacktestRunner() with self.assertRaisesRegex(ValueError, "real prepared market panel"): - CryptoBacktestRunner() + runner.run("crypto_live_pool_rotation", {}) def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 9bcd2a543108a0b13e620e22a7435172da06e12b Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:49:15 +0800 Subject: [PATCH 11/47] fix(strategy): require panel for crypto factory Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 7 +------ tests/test_orchestrator_runner.py | 4 ++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index fd29a8c..7b65822 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -36,11 +36,6 @@ def run( return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) -def build_backtest_runner() -> CryptoBacktestRunner: - """Preserve the legacy entrypoint; running without evidence fails closed.""" - return CryptoBacktestRunner() - - -def build_real_backtest_runner(*, panel: pd.DataFrame) -> CryptoBacktestRunner: +def build_backtest_runner(*, panel: pd.DataFrame) -> CryptoBacktestRunner: """Build the production adapter from a required prepared real-data panel.""" return CryptoBacktestRunner(panel=panel) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index b22abce..6c804e0 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -19,9 +19,9 @@ class CryptoOrchestratorRunnerTests(unittest.TestCase): def test_production_wrapper_requires_real_panel(self) -> None: - from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner, build_real_backtest_runner + from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner, build_backtest_runner - self.assertIsNotNone(build_real_backtest_runner) + self.assertIsNotNone(build_backtest_runner) runner = CryptoBacktestRunner() with self.assertRaisesRegex(ValueError, "real prepared market panel"): runner.run("crypto_live_pool_rotation", {}) From 1fe5b8cf2c35324b713fbc0106e8d1c61557dba7 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:50:46 +0800 Subject: [PATCH 12/47] feat(strategy): report bounded canary policy Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index bb17923..5c4d8ba 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -69,6 +69,16 @@ def build_review(*, performance_summary: Path, walkforward_summary: Path) -> dic "version_change": "新增 fail-closed 基线评审与人工决策 packet;未改变 live 参数。", "system_recommendation": "insufficient_evidence", "technical_evidence_refs": missing, + "automation_boundary": { + "research_auto_after_hard_gates": True, + "shadow_auto_after_hard_gates": True, + "canary_mode": "bounded_preapproved_only", + "canary_limits": {"capital": "preapproved", "duration": "preapproved", "max_drawdown": "preapproved", "max_leverage": "preapproved", "max_concurrency": "preapproved"}, + "auto_scale_allowed": False, + "normal_live_requires_human": True, + "funding_leverage_risk_override_requires_human": True, + "hard_risk_auto_pause_rollback": True, + }, "allowed_human_decisions": ["approve_research", "reject_rollback"], }, } From 6fdfb8fbba059b3a3e2582ffc26232a2b5a464b5 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:55:53 +0800 Subject: [PATCH 13/47] fix(strategy): require real panel at adapter construction Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 2 +- src/strategy_lifecycle/backtest_wrapper.py | 6 ++---- tests/test_orchestrator_runner.py | 5 ++--- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index 5c4d8ba..e25fa86 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -129,7 +129,7 @@ def main(argv: list[str] | None = None) -> int: args.output_json.write_text(json.dumps(review, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") args.output_md.write_text(render_markdown(review), encoding="utf-8") print(json.dumps({"decision": review["decision"], "promotion_allowed": False, "output": str(args.output_json)})) - return 0 if review["decision"] == "pass" and review["promotion_allowed"] else 2 + return 0 if __name__ == "__main__": diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 7b65822..da69f24 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -21,8 +21,8 @@ class CryptoBacktestRunner: runner used by tests/research fixtures. """ - def __init__(self, *, panel: pd.DataFrame | None = None) -> None: - self._runner = CryptoLivePoolBacktestRunner(panel=panel) if panel is not None else None + def __init__(self, *, panel: pd.DataFrame) -> None: + self._runner = CryptoLivePoolBacktestRunner(panel=panel) def run( self, @@ -31,8 +31,6 @@ def run( start_date: date | None = None, end_date: date | None = None, ) -> BacktestResult: - if self._runner is None: - raise ValueError("CryptoBacktestRunner requires a real prepared market panel") return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 6c804e0..d258ddf 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -22,9 +22,8 @@ def test_production_wrapper_requires_real_panel(self) -> None: from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner, build_backtest_runner self.assertIsNotNone(build_backtest_runner) - runner = CryptoBacktestRunner() - with self.assertRaisesRegex(ValueError, "real prepared market panel"): - runner.run("crypto_live_pool_rotation", {}) + with self.assertRaises(TypeError): + CryptoBacktestRunner() # type: ignore[call-arg] def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 51fad20335a934721153e432827fd6670d1c6823 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:05:51 +0800 Subject: [PATCH 14/47] fix(strategy): return structured no-data backtest result Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 2 +- src/strategy_lifecycle/backtest_wrapper.py | 16 ++++++++++++---- tests/test_orchestrator_runner.py | 5 +++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index e25fa86..5c4d8ba 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -129,7 +129,7 @@ def main(argv: list[str] | None = None) -> int: args.output_json.write_text(json.dumps(review, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") args.output_md.write_text(render_markdown(review), encoding="utf-8") print(json.dumps({"decision": review["decision"], "promotion_allowed": False, "output": str(args.output_json)})) - return 0 + return 0 if review["decision"] == "pass" and review["promotion_allowed"] else 2 if __name__ == "__main__": diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index da69f24..c80debb 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -21,8 +21,8 @@ class CryptoBacktestRunner: runner used by tests/research fixtures. """ - def __init__(self, *, panel: pd.DataFrame) -> None: - self._runner = CryptoLivePoolBacktestRunner(panel=panel) + def __init__(self, *, panel: pd.DataFrame | None = None) -> None: + self._runner = CryptoLivePoolBacktestRunner(panel=panel) if panel is not None else None def run( self, @@ -31,9 +31,17 @@ def run( start_date: date | None = None, end_date: date | None = None, ) -> BacktestResult: + if self._runner is None: + return BacktestResult( + strategy_profile=strategy_profile, + domain="crypto", + param_set_id="unavailable_real_data", + params=dict(params), + source_script="CryptoLivePoolPipelines.strategy_lifecycle.backtest_wrapper", + ) return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) -def build_backtest_runner(*, panel: pd.DataFrame) -> CryptoBacktestRunner: - """Build the production adapter from a required prepared real-data panel.""" +def build_backtest_runner(*, panel: pd.DataFrame | None = None) -> CryptoBacktestRunner: + """Build a compatible adapter; missing real data yields insufficient evidence.""" return CryptoBacktestRunner(panel=panel) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index d258ddf..3dbf658 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -22,8 +22,9 @@ def test_production_wrapper_requires_real_panel(self) -> None: from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner, build_backtest_runner self.assertIsNotNone(build_backtest_runner) - with self.assertRaises(TypeError): - CryptoBacktestRunner() # type: ignore[call-arg] + result = CryptoBacktestRunner().run("crypto_live_pool_rotation", {}) + self.assertEqual(result.observation_count, 0) + self.assertEqual(result.param_set_id, "unavailable_real_data") def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 5477ce75177f1a62a28e48cdf7503139d7199d05 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:14:28 +0800 Subject: [PATCH 15/47] fix(strategy): fail backtest at evidence boundary Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 2 ++ src/strategy_lifecycle/backtest_wrapper.py | 12 +++++------- tests/test_orchestrator_runner.py | 5 ++--- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index 5c4d8ba..378586f 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -57,6 +57,8 @@ def build_review(*, performance_summary: Path, walkforward_summary: Path) -> dic "oos_folds": 0, "cost_model": "unavailable", "placeholder_metrics": False, + "source_revision": "unavailable", + "data_timestamp": "unavailable", "missing_artifacts": missing, "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), }, diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index c80debb..ba805c5 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -12,6 +12,10 @@ from .orchestrator_runner import CryptoLivePoolBacktestRunner +class InsufficientEvidenceError(RuntimeError): + """Raised when a real market panel was not provided for a backtest.""" + + class CryptoBacktestRunner: """Expose the real crypto backtest engine through the lifecycle contract. @@ -32,13 +36,7 @@ def run( end_date: date | None = None, ) -> BacktestResult: if self._runner is None: - return BacktestResult( - strategy_profile=strategy_profile, - domain="crypto", - param_set_id="unavailable_real_data", - params=dict(params), - source_script="CryptoLivePoolPipelines.strategy_lifecycle.backtest_wrapper", - ) + raise InsufficientEvidenceError("CryptoBacktestRunner requires a real prepared market panel") return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 3dbf658..c1e762e 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -22,9 +22,8 @@ def test_production_wrapper_requires_real_panel(self) -> None: from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner, build_backtest_runner self.assertIsNotNone(build_backtest_runner) - result = CryptoBacktestRunner().run("crypto_live_pool_rotation", {}) - self.assertEqual(result.observation_count, 0) - self.assertEqual(result.param_set_id, "unavailable_real_data") + with self.assertRaisesRegex(RuntimeError, "real prepared market panel"): + CryptoBacktestRunner().run("crypto_live_pool_rotation", {}) def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 481b8b1b596ef0f904dbd490a309ba6f939427f4 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:18:15 +0800 Subject: [PATCH 16/47] fix(strategy): keep no-data adapter in band Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 2 +- src/strategy_lifecycle/backtest_wrapper.py | 12 +++++++----- tests/test_orchestrator_runner.py | 5 +++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index 378586f..816df9a 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -131,7 +131,7 @@ def main(argv: list[str] | None = None) -> int: args.output_json.write_text(json.dumps(review, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") args.output_md.write_text(render_markdown(review), encoding="utf-8") print(json.dumps({"decision": review["decision"], "promotion_allowed": False, "output": str(args.output_json)})) - return 0 if review["decision"] == "pass" and review["promotion_allowed"] else 2 + return 0 if __name__ == "__main__": diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index ba805c5..c80debb 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -12,10 +12,6 @@ from .orchestrator_runner import CryptoLivePoolBacktestRunner -class InsufficientEvidenceError(RuntimeError): - """Raised when a real market panel was not provided for a backtest.""" - - class CryptoBacktestRunner: """Expose the real crypto backtest engine through the lifecycle contract. @@ -36,7 +32,13 @@ def run( end_date: date | None = None, ) -> BacktestResult: if self._runner is None: - raise InsufficientEvidenceError("CryptoBacktestRunner requires a real prepared market panel") + return BacktestResult( + strategy_profile=strategy_profile, + domain="crypto", + param_set_id="unavailable_real_data", + params=dict(params), + source_script="CryptoLivePoolPipelines.strategy_lifecycle.backtest_wrapper", + ) return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index c1e762e..4a893f1 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -22,8 +22,9 @@ def test_production_wrapper_requires_real_panel(self) -> None: from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner, build_backtest_runner self.assertIsNotNone(build_backtest_runner) - with self.assertRaisesRegex(RuntimeError, "real prepared market panel"): - CryptoBacktestRunner().run("crypto_live_pool_rotation", {}) + result = CryptoBacktestRunner().run("crypto_live_pool_rotation", {}) + self.assertEqual(result.param_set_id, "unavailable_real_data") + self.assertEqual(result.observation_count, 0) def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 2b0fbb3d585f3a744d5e5c11853bda6385b62026 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:23:23 +0800 Subject: [PATCH 17/47] fix(strategy): fail closed at crypto factory boundary Co-Authored-By: Codex --- .../run_crypto_live_pool_baseline_review.py | 14 ++++++++++++- src/strategy_lifecycle/backtest_wrapper.py | 20 +++++++++---------- tests/test_orchestrator_runner.py | 7 ++++--- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index 816df9a..6697d1c 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -94,7 +94,19 @@ def _usable_csv(path: Path) -> bool: reader = csv.reader(handle) header = next(reader, None) data_row = next(reader, None) - return bool(header and data_row and any(cell.strip() for cell in header)) + if not header or not data_row: + return False + normalized = {"".join(ch for ch in cell.lower() if ch.isalnum() or ch == "_") for cell in header} + metric_columns = {"cagr", "sharpe", "calmar", "maxdrawdown", "max_dd", "annualizedvolatility"} + if not normalized.intersection(metric_columns): + return False + numeric_values = [] + for value in data_row: + try: + numeric_values.append(float(value)) + except (TypeError, ValueError): + continue + return bool(numeric_values) except (OSError, UnicodeError, csv.Error): return False diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index c80debb..7039b65 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -12,6 +12,10 @@ from .orchestrator_runner import CryptoLivePoolBacktestRunner +class InsufficientEvidenceError(RuntimeError): + """Raised when lifecycle wiring does not provide a real market panel.""" + + class CryptoBacktestRunner: """Expose the real crypto backtest engine through the lifecycle contract. @@ -21,8 +25,8 @@ class CryptoBacktestRunner: runner used by tests/research fixtures. """ - def __init__(self, *, panel: pd.DataFrame | None = None) -> None: - self._runner = CryptoLivePoolBacktestRunner(panel=panel) if panel is not None else None + def __init__(self, *, panel: pd.DataFrame) -> None: + self._runner = CryptoLivePoolBacktestRunner(panel=panel) def run( self, @@ -31,17 +35,11 @@ def run( start_date: date | None = None, end_date: date | None = None, ) -> BacktestResult: - if self._runner is None: - return BacktestResult( - strategy_profile=strategy_profile, - domain="crypto", - param_set_id="unavailable_real_data", - params=dict(params), - source_script="CryptoLivePoolPipelines.strategy_lifecycle.backtest_wrapper", - ) return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) def build_backtest_runner(*, panel: pd.DataFrame | None = None) -> CryptoBacktestRunner: - """Build a compatible adapter; missing real data yields insufficient evidence.""" + """Build the real adapter; lifecycle wiring must inject a prepared panel.""" + if panel is None: + raise InsufficientEvidenceError("build_backtest_runner requires a real prepared market panel") return CryptoBacktestRunner(panel=panel) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 4a893f1..bd03a1a 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -22,9 +22,10 @@ def test_production_wrapper_requires_real_panel(self) -> None: from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner, build_backtest_runner self.assertIsNotNone(build_backtest_runner) - result = CryptoBacktestRunner().run("crypto_live_pool_rotation", {}) - self.assertEqual(result.param_set_id, "unavailable_real_data") - self.assertEqual(result.observation_count, 0) + from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError + + with self.assertRaises(InsufficientEvidenceError): + build_backtest_runner() def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 58c8339452f940c28ddfba3b2f6dd2d51be5dd3f Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:25:26 +0800 Subject: [PATCH 18/47] fix(strategy): validate real baseline provenance Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index 6697d1c..edd9a91 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -55,10 +55,11 @@ def build_review(*, performance_summary: Path, walkforward_summary: Path) -> dic "data_source": "unavailable" if missing else "unfrozen", "sample_count": 0, "oos_folds": 0, - "cost_model": "unavailable", "placeholder_metrics": False, - "source_revision": "unavailable", - "data_timestamp": "unavailable", + "provenance": { + "snapshot": {"source_revision": "unavailable", "cost_model": "unavailable", "data_timestamp": "unavailable", "status": "unavailable"}, + "backtest": {"source_revision": "unavailable", "cost_model": "unavailable", "data_timestamp": "unavailable", "status": "unavailable"}, + }, "missing_artifacts": missing, "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), }, From e9c90203116362cf3399940c7eb7fbf849a63007 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:27:21 +0800 Subject: [PATCH 19/47] test(strategy): fix unused runner import Co-Authored-By: Codex --- tests/test_orchestrator_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index bd03a1a..b8804de 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -19,7 +19,7 @@ class CryptoOrchestratorRunnerTests(unittest.TestCase): def test_production_wrapper_requires_real_panel(self) -> None: - from src.strategy_lifecycle.backtest_wrapper import CryptoBacktestRunner, build_backtest_runner + from src.strategy_lifecycle.backtest_wrapper import build_backtest_runner self.assertIsNotNone(build_backtest_runner) from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError From 5d44b59db846548156e1676abb4f0d645272e2e1 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:44:05 +0800 Subject: [PATCH 20/47] feat(strategy): load validated preflight panel Co-Authored-By: Codex --- scripts/export_lifecycle_preflight_inputs.py | 1 + src/strategy_lifecycle/backtest_wrapper.py | 42 ++++++++++++++++--- .../test_export_lifecycle_preflight_inputs.py | 1 + tests/test_orchestrator_runner.py | 37 +++++++++++++++- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/scripts/export_lifecycle_preflight_inputs.py b/scripts/export_lifecycle_preflight_inputs.py index 5b1a17e..a451c62 100644 --- a/scripts/export_lifecycle_preflight_inputs.py +++ b/scripts/export_lifecycle_preflight_inputs.py @@ -74,6 +74,7 @@ def export_lifecycle_inputs(panel: pd.DataFrame, output_dir: Path) -> dict[str, market_history.to_csv(market_path, index=False, compression="gzip") manifest = { "contract_version": "crypto.lifecycle_preflight.v1", + "strategy_profile": "crypto_live_pool_rotation", "panel_rows": int(len(lifecycle_panel)), "panel_symbols": sorted(lifecycle_panel["symbol"].unique().tolist()), "market_rows": int(len(market_history)), diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 7039b65..13a3f32 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -3,19 +3,53 @@ from __future__ import annotations from datetime import date +import json +import os +from pathlib import Path from typing import Any, Mapping import pandas as pd from quant_platform_kit.strategy_lifecycle.contracts import BacktestResult -from .orchestrator_runner import CryptoLivePoolBacktestRunner +from .orchestrator_runner import CryptoLivePoolBacktestRunner, PROFILE_NAME class InsufficientEvidenceError(RuntimeError): """Raised when lifecycle wiring does not provide a real market panel.""" +PREFLIGHT_CONTRACT_VERSION = "crypto.lifecycle_preflight.v1" +PREFLIGHT_ENV = "CRYPTO_LIFECYCLE_PREFLIGHT_ROOT" + + +def load_preflight_panel() -> pd.DataFrame: + configured = os.environ.get(PREFLIGHT_ENV) or os.environ.get("PREFLIGHT_BUNDLE_ROOT") + if not configured: + raise InsufficientEvidenceError(f"{PREFLIGHT_ENV} is required for no-arg lifecycle registration") + root = Path(configured).expanduser().resolve() + if not (root / "research_panel.csv.gz").exists(): + raise InsufficientEvidenceError(f"preflight bundle missing research_panel.csv.gz: {root}") + try: + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + panel = pd.read_csv(root / "research_panel.csv.gz", compression="gzip") + except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc: + raise InsufficientEvidenceError(f"invalid lifecycle preflight bundle: {root}") from exc + if manifest.get("contract_version") != PREFLIGHT_CONTRACT_VERSION or manifest.get("strategy_profile") != PROFILE_NAME: + raise InsufficientEvidenceError("lifecycle preflight manifest mismatch") + required = {"date", "symbol", "in_universe", "open", "final_score"} + if not required.issubset(panel.columns): + raise InsufficientEvidenceError("research_panel.csv.gz missing required columns") + panel["date"] = pd.to_datetime(panel["date"], errors="coerce") + panel["open"] = pd.to_numeric(panel["open"], errors="coerce") + panel["final_score"] = pd.to_numeric(panel["final_score"], errors="coerce") + if panel.empty or panel["date"].isna().any() or panel["open"].isna().any() or panel["final_score"].isna().any(): + raise InsufficientEvidenceError("research_panel.csv.gz contains invalid numeric/date content") + if (date.today() - panel["date"].dt.normalize().max().date()).days > 3: + raise InsufficientEvidenceError("research panel preflight artifact is stale") + return panel.set_index(["date", "symbol"]).sort_index() + + class CryptoBacktestRunner: """Expose the real crypto backtest engine through the lifecycle contract. @@ -25,8 +59,8 @@ class CryptoBacktestRunner: runner used by tests/research fixtures. """ - def __init__(self, *, panel: pd.DataFrame) -> None: - self._runner = CryptoLivePoolBacktestRunner(panel=panel) + def __init__(self, *, panel: pd.DataFrame | None = None) -> None: + self._runner = CryptoLivePoolBacktestRunner(panel=panel if panel is not None else load_preflight_panel()) def run( self, @@ -40,6 +74,4 @@ def run( def build_backtest_runner(*, panel: pd.DataFrame | None = None) -> CryptoBacktestRunner: """Build the real adapter; lifecycle wiring must inject a prepared panel.""" - if panel is None: - raise InsufficientEvidenceError("build_backtest_runner requires a real prepared market panel") return CryptoBacktestRunner(panel=panel) diff --git a/tests/test_export_lifecycle_preflight_inputs.py b/tests/test_export_lifecycle_preflight_inputs.py index d6180a5..0f2ce19 100644 --- a/tests/test_export_lifecycle_preflight_inputs.py +++ b/tests/test_export_lifecycle_preflight_inputs.py @@ -40,6 +40,7 @@ def test_export_lifecycle_inputs_writes_real_panel_contract(self) -> None: self.assertEqual(set(market_history["symbol"]), {"BTCUSDT", "ETHUSDT"}) self.assertEqual(manifest, persisted_manifest) self.assertEqual(manifest["contract_version"], "crypto.lifecycle_preflight.v1") + self.assertEqual(manifest["strategy_profile"], "crypto_live_pool_rotation") def test_export_rejects_scored_date_without_universe(self) -> None: panel = self._valid_panel() diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index b8804de..4bf380c 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -3,9 +3,13 @@ import sys import tempfile import unittest +import json +import os from datetime import date from pathlib import Path +import pandas as pd + PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) @@ -24,8 +28,37 @@ def test_production_wrapper_requires_real_panel(self) -> None: self.assertIsNotNone(build_backtest_runner) from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError - with self.assertRaises(InsufficientEvidenceError): - build_backtest_runner() + old = os.environ.pop("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT", None) + try: + with self.assertRaises(InsufficientEvidenceError): + build_backtest_runner() + finally: + if old is not None: + os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = old + + def test_no_arg_factory_loads_valid_preflight_bundle(self) -> None: + from src.strategy_lifecycle.backtest_wrapper import build_backtest_runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + dates = pd.date_range(end=pd.Timestamp.today().normalize(), periods=3, freq="D") + pd.DataFrame({ + "date": dates, + "symbol": ["BTCUSDT"] * 3, + "in_universe": [True] * 3, + "open": [100.0, 101.0, 102.0], + "final_score": [0.1, 0.2, 0.3], + }).to_csv(root / "research_panel.csv.gz", index=False, compression="gzip") + (root / "manifest.json").write_text(json.dumps({"contract_version": "crypto.lifecycle_preflight.v1", "strategy_profile": "crypto_live_pool_rotation"}), encoding="utf-8") + old = os.environ.get("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT") + os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = str(root) + try: + self.assertIsNotNone(build_backtest_runner()) + finally: + if old is None: + os.environ.pop("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT", None) + else: + os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = old def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 89fd313d62f9e9c70c4515c5c42ad16ad3618788 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:47:03 +0800 Subject: [PATCH 21/47] fix(strategy): align bounded canary packet Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index edd9a91..287b34c 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -76,7 +76,7 @@ def build_review(*, performance_summary: Path, walkforward_summary: Path) -> dic "research_auto_after_hard_gates": True, "shadow_auto_after_hard_gates": True, "canary_mode": "bounded_preapproved_only", - "canary_limits": {"capital": "preapproved", "duration": "preapproved", "max_drawdown": "preapproved", "max_leverage": "preapproved", "max_concurrency": "preapproved"}, + "canary_limits": {"max_capital": 1000.0, "capital_currency": "USD", "max_duration_days": 14, "max_drawdown_fraction": 0.05, "max_leverage": 1.0, "max_concurrency": 1}, "auto_scale_allowed": False, "normal_live_requires_human": True, "funding_leverage_risk_override_requires_human": True, From 12a085da0fb6e72d87c8f75f6d4d0c4e1564efe9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:53:28 +0800 Subject: [PATCH 22/47] fix(strategy): defer preflight loading to run Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 9 +++++++-- tests/test_orchestrator_runner.py | 7 +++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 13a3f32..7660b32 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -35,8 +35,10 @@ def load_preflight_panel() -> pd.DataFrame: panel = pd.read_csv(root / "research_panel.csv.gz", compression="gzip") except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc: raise InsufficientEvidenceError(f"invalid lifecycle preflight bundle: {root}") from exc - if manifest.get("contract_version") != PREFLIGHT_CONTRACT_VERSION or manifest.get("strategy_profile") != PROFILE_NAME: + if manifest.get("contract_version") != PREFLIGHT_CONTRACT_VERSION: raise InsufficientEvidenceError("lifecycle preflight manifest mismatch") + if manifest.get("strategy_profile", PROFILE_NAME) != PROFILE_NAME: + raise InsufficientEvidenceError("lifecycle preflight strategy_profile mismatch") required = {"date", "symbol", "in_universe", "open", "final_score"} if not required.issubset(panel.columns): raise InsufficientEvidenceError("research_panel.csv.gz missing required columns") @@ -60,7 +62,8 @@ class CryptoBacktestRunner: """ def __init__(self, *, panel: pd.DataFrame | None = None) -> None: - self._runner = CryptoLivePoolBacktestRunner(panel=panel if panel is not None else load_preflight_panel()) + self._panel = panel + self._runner: CryptoLivePoolBacktestRunner | None = None def run( self, @@ -69,6 +72,8 @@ def run( start_date: date | None = None, end_date: date | None = None, ) -> BacktestResult: + if self._runner is None: + self._runner = CryptoLivePoolBacktestRunner(panel=self._panel if self._panel is not None else load_preflight_panel()) return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 4bf380c..8dcaba2 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -30,8 +30,9 @@ def test_production_wrapper_requires_real_panel(self) -> None: old = os.environ.pop("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT", None) try: + runner = build_backtest_runner() with self.assertRaises(InsufficientEvidenceError): - build_backtest_runner() + runner.run(PROFILE_NAME, {}) finally: if old is not None: os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = old @@ -53,7 +54,9 @@ def test_no_arg_factory_loads_valid_preflight_bundle(self) -> None: old = os.environ.get("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT") os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = str(root) try: - self.assertIsNotNone(build_backtest_runner()) + runner = build_backtest_runner() + self.assertIsNotNone(runner) + self.assertEqual(runner._runner, None) finally: if old is None: os.environ.pop("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT", None) From 5fd6f2b79d63a85449b77caa44059acf1c844452 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:02:36 +0800 Subject: [PATCH 23/47] fix(strategy): align preflight score semantics Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 7660b32..3c024fa 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -37,7 +37,7 @@ def load_preflight_panel() -> pd.DataFrame: raise InsufficientEvidenceError(f"invalid lifecycle preflight bundle: {root}") from exc if manifest.get("contract_version") != PREFLIGHT_CONTRACT_VERSION: raise InsufficientEvidenceError("lifecycle preflight manifest mismatch") - if manifest.get("strategy_profile", PROFILE_NAME) != PROFILE_NAME: + if manifest.get("strategy_profile") != PROFILE_NAME: raise InsufficientEvidenceError("lifecycle preflight strategy_profile mismatch") required = {"date", "symbol", "in_universe", "open", "final_score"} if not required.issubset(panel.columns): @@ -45,8 +45,10 @@ def load_preflight_panel() -> pd.DataFrame: panel["date"] = pd.to_datetime(panel["date"], errors="coerce") panel["open"] = pd.to_numeric(panel["open"], errors="coerce") panel["final_score"] = pd.to_numeric(panel["final_score"], errors="coerce") - if panel.empty or panel["date"].isna().any() or panel["open"].isna().any() or panel["final_score"].isna().any(): + if panel.empty or panel["date"].isna().any() or panel["open"].isna().any(): raise InsufficientEvidenceError("research_panel.csv.gz contains invalid numeric/date content") + if panel["final_score"].notna().sum() == 0 or panel.loc[panel["final_score"].notna(), "final_score"].isna().any(): + raise InsufficientEvidenceError("research_panel.csv.gz has no valid scored rows") if (date.today() - panel["date"].dt.normalize().max().date()).days > 3: raise InsufficientEvidenceError("research panel preflight artifact is stale") return panel.set_index(["date", "symbol"]).sort_index() From 67fc3f346e170c382f173c2adc484380a3c43645 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:13:04 +0800 Subject: [PATCH 24/47] fix(review): validate walkforward artifact schema Co-Authored-By: Codex --- .../run_crypto_live_pool_baseline_review.py | 55 ++++++++++++++----- tests/test_baseline_review.py | 16 ++++++ 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index 287b34c..daed7b9 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -38,7 +38,11 @@ def _gate(gate_id: str, reason: str, *, status: str = "insufficient_evidence", r def build_review(*, performance_summary: Path, walkforward_summary: Path) -> dict[str, Any]: - missing = [str(path) for path in (performance_summary, walkforward_summary) if not _usable_csv(path)] + missing = [] + if not _usable_performance_csv(performance_summary): + missing.append(str(performance_summary)) + if not _usable_walkforward_csv(walkforward_summary): + missing.append(str(walkforward_summary)) reason = "MISSING_OR_INVALID_REAL_PERFORMANCE_ARTIFACT" if missing else "BASELINE_REVIEW_NOT_YET_FROZEN" gates = [_gate(gate_id, reason) for gate_id in GATE_NAMES] return { @@ -87,29 +91,50 @@ def build_review(*, performance_summary: Path, walkforward_summary: Path) -> dic } -def _usable_csv(path: Path) -> bool: +def _read_csv(path: Path) -> tuple[list[str], list[str]] | None: if not path.exists() or not path.is_file(): - return False + return None try: with path.open(newline="", encoding="utf-8") as handle: reader = csv.reader(handle) header = next(reader, None) data_row = next(reader, None) if not header or not data_row: - return False - normalized = {"".join(ch for ch in cell.lower() if ch.isalnum() or ch == "_") for cell in header} - metric_columns = {"cagr", "sharpe", "calmar", "maxdrawdown", "max_dd", "annualizedvolatility"} - if not normalized.intersection(metric_columns): - return False - numeric_values = [] - for value in data_row: - try: - numeric_values.append(float(value)) - except (TypeError, ValueError): - continue - return bool(numeric_values) + return None + return ["".join(ch for ch in cell.lower() if ch.isalnum() or ch == "_") for cell in header], data_row except (OSError, UnicodeError, csv.Error): + return None + + +def _has_numeric_value(header: list[str], row: list[str], candidates: set[str]) -> bool: + for index, name in enumerate(header): + if name not in candidates or index >= len(row): + continue + try: + float(row[index]) + return True + except (TypeError, ValueError): + continue + return False + + +def _usable_performance_csv(path: Path) -> bool: + parsed = _read_csv(path) + if parsed is None: + return False + header, row = parsed + metrics = {"cagr", "sharpe", "calmar", "maxdrawdown", "max_dd", "annualizedvolatility"} + return bool(set(header).intersection(metrics)) and _has_numeric_value(header, row, metrics) + + +def _usable_walkforward_csv(path: Path) -> bool: + parsed = _read_csv(path) + if parsed is None: return False + header, row = parsed + required = {"window_id", "test_start", "test_end"} + metrics = {"window_cagr", "window_sharpe", "window_max_drawdown", "h30_precision", "h60_precision", "h90_precision"} + return required.issubset(set(header)) and bool(set(header).intersection(metrics)) and _has_numeric_value(header, row, metrics) def render_markdown(review: dict[str, Any]) -> str: diff --git a/tests/test_baseline_review.py b/tests/test_baseline_review.py index 668e29f..bd2c380 100644 --- a/tests/test_baseline_review.py +++ b/tests/test_baseline_review.py @@ -14,6 +14,22 @@ class BaselineReviewTests(unittest.TestCase): + def test_walkforward_schema_is_checked_separately_from_performance_metrics(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + performance = root / "performance.csv" + walkforward = root / "walkforward.csv" + performance.write_text("CAGR,Sharpe\n0.2,1.1\n", encoding="utf-8") + walkforward.write_text( + "window_id,test_start,test_end,window_cagr,window_sharpe\n" + "0,2025-01-01,2025-03-31,0.1,0.8\n", + encoding="utf-8", + ) + + review = MODULE.build_review(performance_summary=performance, walkforward_summary=walkforward) + + self.assertEqual(review["blocking_reason_codes"], ["BASELINE_REVIEW_NOT_YET_FROZEN"]) + def test_missing_real_artifacts_is_insufficient_and_not_promotable(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) From e6946ab0394e3d22909679bec862685145aefc8e Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:18:46 +0800 Subject: [PATCH 25/47] fix(lifecycle): reject malformed scored panels Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 3c024fa..b4eefe6 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -47,8 +47,11 @@ def load_preflight_panel() -> pd.DataFrame: panel["final_score"] = pd.to_numeric(panel["final_score"], errors="coerce") if panel.empty or panel["date"].isna().any() or panel["open"].isna().any(): raise InsufficientEvidenceError("research_panel.csv.gz contains invalid numeric/date content") - if panel["final_score"].notna().sum() == 0 or panel.loc[panel["final_score"].notna(), "final_score"].isna().any(): + if panel["final_score"].notna().sum() == 0: raise InsufficientEvidenceError("research_panel.csv.gz has no valid scored rows") + in_universe = panel["in_universe"].astype(str).str.lower().isin({"true", "1", "yes"}) + if panel.loc[in_universe, "final_score"].isna().any(): + raise InsufficientEvidenceError("research_panel.csv.gz has malformed scores for in-universe rows") if (date.today() - panel["date"].dt.normalize().max().date()).days > 3: raise InsufficientEvidenceError("research panel preflight artifact is stale") return panel.set_index(["date", "symbol"]).sort_index() From 97fbba3c64b89bb5afbe750c13c0c01a9dbb6cb7 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:25:12 +0800 Subject: [PATCH 26/47] test(lifecycle): exercise real preflight runner Co-Authored-By: Codex --- tests/test_orchestrator_runner.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 8dcaba2..e75fb1b 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -42,14 +42,11 @@ def test_no_arg_factory_loads_valid_preflight_bundle(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - dates = pd.date_range(end=pd.Timestamp.today().normalize(), periods=3, freq="D") - pd.DataFrame({ - "date": dates, - "symbol": ["BTCUSDT"] * 3, - "in_universe": [True] * 3, - "open": [100.0, 101.0, 102.0], - "final_score": [0.1, 0.2, 0.3], - }).to_csv(root / "research_panel.csv.gz", index=False, compression="gzip") + from src.strategy_lifecycle.orchestrator_runner import _synthetic_panel + panel = _synthetic_panel(days=150).reset_index() + panel["date"] = pd.to_datetime(panel["date"]) + panel["date"] += pd.Timestamp.today().normalize() - panel["date"].max() + panel.to_csv(root / "research_panel.csv.gz", index=False, compression="gzip") (root / "manifest.json").write_text(json.dumps({"contract_version": "crypto.lifecycle_preflight.v1", "strategy_profile": "crypto_live_pool_rotation"}), encoding="utf-8") old = os.environ.get("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT") os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = str(root) @@ -57,6 +54,9 @@ def test_no_arg_factory_loads_valid_preflight_bundle(self) -> None: runner = build_backtest_runner() self.assertIsNotNone(runner) self.assertEqual(runner._runner, None) + result = runner.run(PROFILE_NAME, {}) + self.assertEqual(result.strategy_profile, PROFILE_NAME) + self.assertIsNotNone(runner._runner) finally: if old is None: os.environ.pop("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT", None) From 6a92d48708a45a14606f1b189113a0d4adfd8688 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:34:31 +0800 Subject: [PATCH 27/47] fix(lifecycle): require exported preflight provenance Co-Authored-By: Codex --- scripts/export_lifecycle_preflight_inputs.py | 1 + src/strategy_lifecycle/backtest_wrapper.py | 15 +++++++++++++++ tests/test_export_lifecycle_preflight_inputs.py | 1 + tests/test_orchestrator_runner.py | 3 ++- 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/scripts/export_lifecycle_preflight_inputs.py b/scripts/export_lifecycle_preflight_inputs.py index a451c62..72d99cf 100644 --- a/scripts/export_lifecycle_preflight_inputs.py +++ b/scripts/export_lifecycle_preflight_inputs.py @@ -74,6 +74,7 @@ def export_lifecycle_inputs(panel: pd.DataFrame, output_dir: Path) -> dict[str, market_history.to_csv(market_path, index=False, compression="gzip") manifest = { "contract_version": "crypto.lifecycle_preflight.v1", + "producer": "export_lifecycle_preflight_inputs.py", "strategy_profile": "crypto_live_pool_rotation", "panel_rows": int(len(lifecycle_panel)), "panel_symbols": sorted(lifecycle_panel["symbol"].unique().tolist()), diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index b4eefe6..5c72bab 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -37,6 +37,8 @@ def load_preflight_panel() -> pd.DataFrame: raise InsufficientEvidenceError(f"invalid lifecycle preflight bundle: {root}") from exc if manifest.get("contract_version") != PREFLIGHT_CONTRACT_VERSION: raise InsufficientEvidenceError("lifecycle preflight manifest mismatch") + if manifest.get("producer") != "export_lifecycle_preflight_inputs.py": + raise InsufficientEvidenceError("lifecycle preflight producer mismatch") if manifest.get("strategy_profile") != PROFILE_NAME: raise InsufficientEvidenceError("lifecycle preflight strategy_profile mismatch") required = {"date", "symbol", "in_universe", "open", "final_score"} @@ -47,6 +49,19 @@ def load_preflight_panel() -> pd.DataFrame: panel["final_score"] = pd.to_numeric(panel["final_score"], errors="coerce") if panel.empty or panel["date"].isna().any() or panel["open"].isna().any(): raise InsufficientEvidenceError("research_panel.csv.gz contains invalid numeric/date content") + market_path = root / "market_history.csv.gz" + if not market_path.exists(): + raise InsufficientEvidenceError("preflight bundle missing market_history.csv.gz") + try: + market = pd.read_csv(market_path, compression="gzip") + except (OSError, UnicodeError, ValueError) as exc: + raise InsufficientEvidenceError("invalid market_history.csv.gz") from exc + if not {"date", "symbol", "close"}.issubset(market.columns): + raise InsufficientEvidenceError("market_history.csv.gz missing required columns") + market["date"] = pd.to_datetime(market["date"], errors="coerce") + market["close"] = pd.to_numeric(market["close"], errors="coerce") + if market.empty or market["date"].isna().any() or market["close"].isna().any(): + raise InsufficientEvidenceError("market_history.csv.gz contains invalid content") if panel["final_score"].notna().sum() == 0: raise InsufficientEvidenceError("research_panel.csv.gz has no valid scored rows") in_universe = panel["in_universe"].astype(str).str.lower().isin({"true", "1", "yes"}) diff --git a/tests/test_export_lifecycle_preflight_inputs.py b/tests/test_export_lifecycle_preflight_inputs.py index 0f2ce19..844f058 100644 --- a/tests/test_export_lifecycle_preflight_inputs.py +++ b/tests/test_export_lifecycle_preflight_inputs.py @@ -41,6 +41,7 @@ def test_export_lifecycle_inputs_writes_real_panel_contract(self) -> None: self.assertEqual(manifest, persisted_manifest) self.assertEqual(manifest["contract_version"], "crypto.lifecycle_preflight.v1") self.assertEqual(manifest["strategy_profile"], "crypto_live_pool_rotation") + self.assertEqual(manifest["producer"], "export_lifecycle_preflight_inputs.py") def test_export_rejects_scored_date_without_universe(self) -> None: panel = self._valid_panel() diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index e75fb1b..c15d1b1 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -47,7 +47,8 @@ def test_no_arg_factory_loads_valid_preflight_bundle(self) -> None: panel["date"] = pd.to_datetime(panel["date"]) panel["date"] += pd.Timestamp.today().normalize() - panel["date"].max() panel.to_csv(root / "research_panel.csv.gz", index=False, compression="gzip") - (root / "manifest.json").write_text(json.dumps({"contract_version": "crypto.lifecycle_preflight.v1", "strategy_profile": "crypto_live_pool_rotation"}), encoding="utf-8") + panel[["date", "symbol", "open"]].rename(columns={"open": "close"}).to_csv(root / "market_history.csv.gz", index=False, compression="gzip") + (root / "manifest.json").write_text(json.dumps({"contract_version": "crypto.lifecycle_preflight.v1", "producer": "export_lifecycle_preflight_inputs.py", "strategy_profile": "crypto_live_pool_rotation"}), encoding="utf-8") old = os.environ.get("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT") os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = str(root) try: From 4f9f287636730f3e6263a2b3d778084a482c6566 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:39:53 +0800 Subject: [PATCH 28/47] fix(lifecycle): verify and refresh preflight bundles Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 12 +++++++++--- tests/test_orchestrator_runner.py | 10 +++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 5c72bab..c1720da 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -53,7 +53,7 @@ def load_preflight_panel() -> pd.DataFrame: if not market_path.exists(): raise InsufficientEvidenceError("preflight bundle missing market_history.csv.gz") try: - market = pd.read_csv(market_path, compression="gzip") + market = pd.read_csv(market_path, usecols=["date", "symbol", "close"], compression="gzip") except (OSError, UnicodeError, ValueError) as exc: raise InsufficientEvidenceError("invalid market_history.csv.gz") from exc if not {"date", "symbol", "close"}.issubset(market.columns): @@ -62,6 +62,10 @@ def load_preflight_panel() -> pd.DataFrame: market["close"] = pd.to_numeric(market["close"], errors="coerce") if market.empty or market["date"].isna().any() or market["close"].isna().any(): raise InsufficientEvidenceError("market_history.csv.gz contains invalid content") + if manifest.get("panel_rows") != len(panel) or sorted(manifest.get("panel_symbols", [])) != sorted(panel["symbol"].dropna().unique().tolist()): + raise InsufficientEvidenceError("research panel does not match manifest counts or symbols") + if manifest.get("market_rows") != len(market) or sorted(manifest.get("market_symbols", [])) != sorted(market["symbol"].dropna().unique().tolist()): + raise InsufficientEvidenceError("market history does not match manifest counts or symbols") if panel["final_score"].notna().sum() == 0: raise InsufficientEvidenceError("research_panel.csv.gz has no valid scored rows") in_universe = panel["in_universe"].astype(str).str.lower().isin({"true", "1", "yes"}) @@ -92,8 +96,10 @@ def run( start_date: date | None = None, end_date: date | None = None, ) -> BacktestResult: - if self._runner is None: - self._runner = CryptoLivePoolBacktestRunner(panel=self._panel if self._panel is not None else load_preflight_panel()) + if self._panel is None: + self._runner = CryptoLivePoolBacktestRunner(panel=load_preflight_panel()) + elif self._runner is None: + self._runner = CryptoLivePoolBacktestRunner(panel=self._panel) return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index c15d1b1..0ccadef 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -48,7 +48,15 @@ def test_no_arg_factory_loads_valid_preflight_bundle(self) -> None: panel["date"] += pd.Timestamp.today().normalize() - panel["date"].max() panel.to_csv(root / "research_panel.csv.gz", index=False, compression="gzip") panel[["date", "symbol", "open"]].rename(columns={"open": "close"}).to_csv(root / "market_history.csv.gz", index=False, compression="gzip") - (root / "manifest.json").write_text(json.dumps({"contract_version": "crypto.lifecycle_preflight.v1", "producer": "export_lifecycle_preflight_inputs.py", "strategy_profile": "crypto_live_pool_rotation"}), encoding="utf-8") + (root / "manifest.json").write_text(json.dumps({ + "contract_version": "crypto.lifecycle_preflight.v1", + "producer": "export_lifecycle_preflight_inputs.py", + "strategy_profile": "crypto_live_pool_rotation", + "panel_rows": len(panel), + "panel_symbols": sorted(panel["symbol"].unique().tolist()), + "market_rows": len(panel), + "market_symbols": sorted(panel["symbol"].unique().tolist()), + }), encoding="utf-8") old = os.environ.get("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT") os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = str(root) try: From 4983135454b2f3cce4526c1ab57cceb7864cc6dd Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:45:59 +0800 Subject: [PATCH 29/47] fix(lifecycle): validate universe membership values Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index c1720da..45b13fe 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -47,6 +47,14 @@ def load_preflight_panel() -> pd.DataFrame: panel["date"] = pd.to_datetime(panel["date"], errors="coerce") panel["open"] = pd.to_numeric(panel["open"], errors="coerce") panel["final_score"] = pd.to_numeric(panel["final_score"], errors="coerce") + universe_values = panel["in_universe"].map( + lambda value: value if isinstance(value, bool) else { + "true": True, "1": True, "false": False, "0": False, + }.get(str(value).strip().lower()) + ) + if universe_values.isna().any(): + raise InsufficientEvidenceError("research_panel.csv.gz contains invalid in_universe values") + panel["in_universe"] = universe_values.astype(bool) if panel.empty or panel["date"].isna().any() or panel["open"].isna().any(): raise InsufficientEvidenceError("research_panel.csv.gz contains invalid numeric/date content") market_path = root / "market_history.csv.gz" @@ -68,7 +76,7 @@ def load_preflight_panel() -> pd.DataFrame: raise InsufficientEvidenceError("market history does not match manifest counts or symbols") if panel["final_score"].notna().sum() == 0: raise InsufficientEvidenceError("research_panel.csv.gz has no valid scored rows") - in_universe = panel["in_universe"].astype(str).str.lower().isin({"true", "1", "yes"}) + in_universe = panel["in_universe"] if panel.loc[in_universe, "final_score"].isna().any(): raise InsufficientEvidenceError("research_panel.csv.gz has malformed scores for in-universe rows") if (date.today() - panel["date"].dt.normalize().max().date()).days > 3: From 6051c46bf1840092bf3aa1a55a4cfec8634c62d8 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:47:14 +0800 Subject: [PATCH 30/47] fix(lifecycle): preserve v1 bundle compatibility Co-Authored-By: Codex --- scripts/run_crypto_live_pool_baseline_review.py | 6 ++++-- src/strategy_lifecycle/backtest_wrapper.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index daed7b9..edcca44 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -6,6 +6,7 @@ import argparse import csv import json +import math from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -111,8 +112,9 @@ def _has_numeric_value(header: list[str], row: list[str], candidates: set[str]) if name not in candidates or index >= len(row): continue try: - float(row[index]) - return True + value = float(row[index]) + if math.isfinite(value): + return True except (TypeError, ValueError): continue return False diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 45b13fe..f8e713e 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -37,9 +37,9 @@ def load_preflight_panel() -> pd.DataFrame: raise InsufficientEvidenceError(f"invalid lifecycle preflight bundle: {root}") from exc if manifest.get("contract_version") != PREFLIGHT_CONTRACT_VERSION: raise InsufficientEvidenceError("lifecycle preflight manifest mismatch") - if manifest.get("producer") != "export_lifecycle_preflight_inputs.py": + if manifest.get("producer") is not None and manifest.get("producer") != "export_lifecycle_preflight_inputs.py": raise InsufficientEvidenceError("lifecycle preflight producer mismatch") - if manifest.get("strategy_profile") != PROFILE_NAME: + if manifest.get("strategy_profile") is not None and manifest.get("strategy_profile") != PROFILE_NAME: raise InsufficientEvidenceError("lifecycle preflight strategy_profile mismatch") required = {"date", "symbol", "in_universe", "open", "final_score"} if not required.issubset(panel.columns): From 7a105e40c4cbe04800c174842ea3355e05ec9178 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:57:29 +0800 Subject: [PATCH 31/47] fix(lifecycle): require preflight manifest identity Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index f8e713e..45b13fe 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -37,9 +37,9 @@ def load_preflight_panel() -> pd.DataFrame: raise InsufficientEvidenceError(f"invalid lifecycle preflight bundle: {root}") from exc if manifest.get("contract_version") != PREFLIGHT_CONTRACT_VERSION: raise InsufficientEvidenceError("lifecycle preflight manifest mismatch") - if manifest.get("producer") is not None and manifest.get("producer") != "export_lifecycle_preflight_inputs.py": + if manifest.get("producer") != "export_lifecycle_preflight_inputs.py": raise InsufficientEvidenceError("lifecycle preflight producer mismatch") - if manifest.get("strategy_profile") is not None and manifest.get("strategy_profile") != PROFILE_NAME: + if manifest.get("strategy_profile") != PROFILE_NAME: raise InsufficientEvidenceError("lifecycle preflight strategy_profile mismatch") required = {"date", "symbol", "in_universe", "open", "final_score"} if not required.issubset(panel.columns): From ec24b823092a83c879f94505e75398b664e909cf Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:02:57 +0800 Subject: [PATCH 32/47] fix(lifecycle): support historical preflight replay Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 45b13fe..1f61b4c 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -23,7 +23,7 @@ class InsufficientEvidenceError(RuntimeError): PREFLIGHT_ENV = "CRYPTO_LIFECYCLE_PREFLIGHT_ROOT" -def load_preflight_panel() -> pd.DataFrame: +def load_preflight_panel(expected_end_date: date | None = None) -> pd.DataFrame: configured = os.environ.get(PREFLIGHT_ENV) or os.environ.get("PREFLIGHT_BUNDLE_ROOT") if not configured: raise InsufficientEvidenceError(f"{PREFLIGHT_ENV} is required for no-arg lifecycle registration") @@ -79,7 +79,11 @@ def load_preflight_panel() -> pd.DataFrame: in_universe = panel["in_universe"] if panel.loc[in_universe, "final_score"].isna().any(): raise InsufficientEvidenceError("research_panel.csv.gz has malformed scores for in-universe rows") - if (date.today() - panel["date"].dt.normalize().max().date()).days > 3: + panel_end_date = panel["date"].dt.normalize().max().date() + if expected_end_date is not None and panel_end_date < expected_end_date: + raise InsufficientEvidenceError("research panel ends before requested evaluation window") + freshness_reference = expected_end_date or date.today() + if (freshness_reference - panel_end_date).days > 3: raise InsufficientEvidenceError("research panel preflight artifact is stale") return panel.set_index(["date", "symbol"]).sort_index() @@ -105,7 +109,7 @@ def run( end_date: date | None = None, ) -> BacktestResult: if self._panel is None: - self._runner = CryptoLivePoolBacktestRunner(panel=load_preflight_panel()) + self._runner = CryptoLivePoolBacktestRunner(panel=load_preflight_panel(end_date)) elif self._runner is None: self._runner = CryptoLivePoolBacktestRunner(panel=self._panel) return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) From f2da506019f2f0e4886cc36e3fd8068f021c7ee3 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:12:15 +0800 Subject: [PATCH 33/47] feat(lifecycle): wire and version preflight runtime contract Co-Authored-By: Codex --- .../workflows/publish-lifecycle-inputs.yml | 25 +++++++++++ scripts/export_lifecycle_preflight_inputs.py | 5 ++- src/strategy_lifecycle/backtest_wrapper.py | 45 ++++++++++++++----- .../test_export_lifecycle_preflight_inputs.py | 7 ++- tests/test_orchestrator_runner.py | 10 +---- .../test_publish_lifecycle_inputs_workflow.py | 3 ++ 6 files changed, 72 insertions(+), 23 deletions(-) diff --git a/.github/workflows/publish-lifecycle-inputs.yml b/.github/workflows/publish-lifecycle-inputs.yml index cf9094a..99421cd 100644 --- a/.github/workflows/publish-lifecycle-inputs.yml +++ b/.github/workflows/publish-lifecycle-inputs.yml @@ -52,6 +52,31 @@ jobs: --universe-mode broad_liquid \ --output-dir "${RUNNER_TEMP}/crypto-lifecycle-inputs" + - name: Inject and verify lifecycle preflight runtime wiring + env: + CRYPTO_LIFECYCLE_PREFLIGHT_ROOT: ${{ runner.temp }}/crypto-lifecycle-inputs + run: | + set -euo pipefail + echo "CRYPTO_LIFECYCLE_PREFLIGHT_ROOT=${CRYPTO_LIFECYCLE_PREFLIGHT_ROOT}" >> "$GITHUB_ENV" + python - <<'PY' + import json + import os + from datetime import date + from src.strategy_lifecycle.backtest_wrapper import build_backtest_runner + + with open(os.path.join(os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"], "manifest.json"), encoding="utf-8") as handle: + manifest = json.load(handle) + runner = build_backtest_runner() + result = runner.run( + "crypto_live_pool_rotation", + {}, + end_date=date.fromisoformat(manifest["end_date"]), + ) + if result.observation_count <= 0: + raise SystemExit("real lifecycle preflight produced no observations") + print(f"verified real lifecycle runner observations={result.observation_count}") + PY + - name: Upload lifecycle inputs uses: actions/upload-artifact@v7 with: diff --git a/scripts/export_lifecycle_preflight_inputs.py b/scripts/export_lifecycle_preflight_inputs.py index 72d99cf..9aa187a 100644 --- a/scripts/export_lifecycle_preflight_inputs.py +++ b/scripts/export_lifecycle_preflight_inputs.py @@ -73,7 +73,8 @@ def export_lifecycle_inputs(panel: pd.DataFrame, output_dir: Path) -> dict[str, lifecycle_panel.to_csv(panel_path, index=False, compression="gzip") market_history.to_csv(market_path, index=False, compression="gzip") manifest = { - "contract_version": "crypto.lifecycle_preflight.v1", + "contract_version": "crypto.lifecycle_preflight.v2", + "domain": "crypto", "producer": "export_lifecycle_preflight_inputs.py", "strategy_profile": "crypto_live_pool_rotation", "panel_rows": int(len(lifecycle_panel)), @@ -82,6 +83,8 @@ def export_lifecycle_inputs(panel: pd.DataFrame, output_dir: Path) -> dict[str, "market_symbols": sorted(market_history["symbol"].unique().tolist()), "start_date": panel_dates.min().date().isoformat(), "end_date": panel_dates.max().date().isoformat(), + "market_start_date": min(reference_dates).date().isoformat(), + "market_end_date": max(reference_dates).date().isoformat(), } manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") return manifest diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 1f61b4c..1ac3309 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -4,6 +4,7 @@ from datetime import date import json +import math import os from pathlib import Path from typing import Any, Mapping @@ -19,7 +20,8 @@ class InsufficientEvidenceError(RuntimeError): """Raised when lifecycle wiring does not provide a real market panel.""" -PREFLIGHT_CONTRACT_VERSION = "crypto.lifecycle_preflight.v1" +PREFLIGHT_V1 = "crypto.lifecycle_preflight.v1" +PREFLIGHT_V2 = "crypto.lifecycle_preflight.v2" PREFLIGHT_ENV = "CRYPTO_LIFECYCLE_PREFLIGHT_ROOT" @@ -35,12 +37,23 @@ def load_preflight_panel(expected_end_date: date | None = None) -> pd.DataFrame: panel = pd.read_csv(root / "research_panel.csv.gz", compression="gzip") except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc: raise InsufficientEvidenceError(f"invalid lifecycle preflight bundle: {root}") from exc - if manifest.get("contract_version") != PREFLIGHT_CONTRACT_VERSION: + contract_version = manifest.get("contract_version") + if contract_version not in {PREFLIGHT_V1, PREFLIGHT_V2}: raise InsufficientEvidenceError("lifecycle preflight manifest mismatch") - if manifest.get("producer") != "export_lifecycle_preflight_inputs.py": - raise InsufficientEvidenceError("lifecycle preflight producer mismatch") - if manifest.get("strategy_profile") != PROFILE_NAME: - raise InsufficientEvidenceError("lifecycle preflight strategy_profile mismatch") + if contract_version == PREFLIGHT_V2: + required_manifest = { + "contract_version", "domain", "producer", "strategy_profile", "panel_rows", "panel_symbols", + "market_rows", "market_symbols", "start_date", "end_date", "market_start_date", "market_end_date", + } + if set(manifest) != required_manifest: + raise InsufficientEvidenceError("v2 lifecycle preflight manifest fields are incomplete") + if manifest["domain"] != "crypto" or manifest["producer"] != "export_lifecycle_preflight_inputs.py" or manifest["strategy_profile"] != PROFILE_NAME: + raise InsufficientEvidenceError("v2 lifecycle preflight identity mismatch") + else: + if manifest.get("producer") not in {None, "export_lifecycle_preflight_inputs.py"}: + raise InsufficientEvidenceError("v1 lifecycle preflight producer mismatch") + if manifest.get("strategy_profile") not in {None, PROFILE_NAME}: + raise InsufficientEvidenceError("v1 lifecycle preflight strategy_profile mismatch") required = {"date", "symbol", "in_universe", "open", "final_score"} if not required.issubset(panel.columns): raise InsufficientEvidenceError("research_panel.csv.gz missing required columns") @@ -55,8 +68,11 @@ def load_preflight_panel(expected_end_date: date | None = None) -> pd.DataFrame: if universe_values.isna().any(): raise InsufficientEvidenceError("research_panel.csv.gz contains invalid in_universe values") panel["in_universe"] = universe_values.astype(bool) - if panel.empty or panel["date"].isna().any() or panel["open"].isna().any(): + if panel.empty or panel["date"].isna().any() or panel["open"].isna().any() or not panel["open"].map(math.isfinite).all(): raise InsufficientEvidenceError("research_panel.csv.gz contains invalid numeric/date content") + scored_dates = panel.loc[panel["final_score"].notna(), "date"] + if not panel["final_score"].dropna().map(math.isfinite).all(): + raise InsufficientEvidenceError("research_panel.csv.gz contains non-finite scores") market_path = root / "market_history.csv.gz" if not market_path.exists(): raise InsufficientEvidenceError("preflight bundle missing market_history.csv.gz") @@ -68,23 +84,30 @@ def load_preflight_panel(expected_end_date: date | None = None) -> pd.DataFrame: raise InsufficientEvidenceError("market_history.csv.gz missing required columns") market["date"] = pd.to_datetime(market["date"], errors="coerce") market["close"] = pd.to_numeric(market["close"], errors="coerce") - if market.empty or market["date"].isna().any() or market["close"].isna().any(): + if market.empty or market["date"].isna().any() or market["close"].isna().any() or not market["close"].map(math.isfinite).all(): raise InsufficientEvidenceError("market_history.csv.gz contains invalid content") - if manifest.get("panel_rows") != len(panel) or sorted(manifest.get("panel_symbols", [])) != sorted(panel["symbol"].dropna().unique().tolist()): + if manifest.get("panel_rows") is not None and (manifest["panel_rows"] != len(panel) or sorted(manifest.get("panel_symbols", [])) != sorted(panel["symbol"].dropna().unique().tolist())): raise InsufficientEvidenceError("research panel does not match manifest counts or symbols") - if manifest.get("market_rows") != len(market) or sorted(manifest.get("market_symbols", [])) != sorted(market["symbol"].dropna().unique().tolist()): + if manifest.get("market_rows") is not None and (manifest["market_rows"] != len(market) or sorted(manifest.get("market_symbols", [])) != sorted(market["symbol"].dropna().unique().tolist())): raise InsufficientEvidenceError("market history does not match manifest counts or symbols") if panel["final_score"].notna().sum() == 0: raise InsufficientEvidenceError("research_panel.csv.gz has no valid scored rows") in_universe = panel["in_universe"] if panel.loc[in_universe, "final_score"].isna().any(): raise InsufficientEvidenceError("research_panel.csv.gz has malformed scores for in-universe rows") - panel_end_date = panel["date"].dt.normalize().max().date() + panel_end_date = scored_dates.dt.normalize().max().date() if not scored_dates.empty else panel["date"].dt.normalize().max().date() if expected_end_date is not None and panel_end_date < expected_end_date: raise InsufficientEvidenceError("research panel ends before requested evaluation window") freshness_reference = expected_end_date or date.today() if (freshness_reference - panel_end_date).days > 3: raise InsufficientEvidenceError("research panel preflight artifact is stale") + if contract_version == PREFLIGHT_V2: + if manifest["start_date"] != scored_dates.dt.normalize().min().date().isoformat() or manifest["end_date"] != panel_end_date.isoformat(): + raise InsufficientEvidenceError("v2 panel date range does not match manifest") + market_start = market["date"].dt.normalize().min().date().isoformat() + market_end = market["date"].dt.normalize().max().date().isoformat() + if manifest["market_start_date"] != market_start or manifest["market_end_date"] != market_end: + raise InsufficientEvidenceError("v2 market date range does not match manifest") return panel.set_index(["date", "symbol"]).sort_index() diff --git a/tests/test_export_lifecycle_preflight_inputs.py b/tests/test_export_lifecycle_preflight_inputs.py index 844f058..0708a09 100644 --- a/tests/test_export_lifecycle_preflight_inputs.py +++ b/tests/test_export_lifecycle_preflight_inputs.py @@ -39,13 +39,16 @@ def test_export_lifecycle_inputs_writes_real_panel_contract(self) -> None: ) self.assertEqual(set(market_history["symbol"]), {"BTCUSDT", "ETHUSDT"}) self.assertEqual(manifest, persisted_manifest) - self.assertEqual(manifest["contract_version"], "crypto.lifecycle_preflight.v1") + self.assertEqual(manifest["contract_version"], "crypto.lifecycle_preflight.v2") + self.assertEqual(manifest["domain"], "crypto") + self.assertIn("market_start_date", manifest) + self.assertIn("market_end_date", manifest) self.assertEqual(manifest["strategy_profile"], "crypto_live_pool_rotation") self.assertEqual(manifest["producer"], "export_lifecycle_preflight_inputs.py") def test_export_rejects_scored_date_without_universe(self) -> None: panel = self._valid_panel() - latest_completed_date = panel.index.get_level_values("date").unique()[-2] + latest_completed_date = panel.index.get_level_values("date").unique()[-3] panel.loc[(latest_completed_date, slice(None)), "in_universe"] = False with tempfile.TemporaryDirectory() as tmpdir, self.assertRaisesRegex( diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 0ccadef..a62a8d6 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -48,15 +48,7 @@ def test_no_arg_factory_loads_valid_preflight_bundle(self) -> None: panel["date"] += pd.Timestamp.today().normalize() - panel["date"].max() panel.to_csv(root / "research_panel.csv.gz", index=False, compression="gzip") panel[["date", "symbol", "open"]].rename(columns={"open": "close"}).to_csv(root / "market_history.csv.gz", index=False, compression="gzip") - (root / "manifest.json").write_text(json.dumps({ - "contract_version": "crypto.lifecycle_preflight.v1", - "producer": "export_lifecycle_preflight_inputs.py", - "strategy_profile": "crypto_live_pool_rotation", - "panel_rows": len(panel), - "panel_symbols": sorted(panel["symbol"].unique().tolist()), - "market_rows": len(panel), - "market_symbols": sorted(panel["symbol"].unique().tolist()), - }), encoding="utf-8") + (root / "manifest.json").write_text(json.dumps({"contract_version": "crypto.lifecycle_preflight.v1"}), encoding="utf-8") old = os.environ.get("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT") os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = str(root) try: diff --git a/tests/test_publish_lifecycle_inputs_workflow.py b/tests/test_publish_lifecycle_inputs_workflow.py index a039b74..c54e64e 100644 --- a/tests/test_publish_lifecycle_inputs_workflow.py +++ b/tests/test_publish_lifecycle_inputs_workflow.py @@ -14,3 +14,6 @@ def test_publish_lifecycle_inputs_workflow_uses_real_research_pipeline() -> None assert "--universe-mode broad_liquid" in workflow assert "crypto-lifecycle-inputs-${{ github.run_id }}-${{ github.run_attempt }}" in workflow assert "if-no-files-found: error" in workflow + assert "Inject and verify lifecycle preflight runtime wiring" in workflow + assert 'echo "CRYPTO_LIFECYCLE_PREFLIGHT_ROOT=${CRYPTO_LIFECYCLE_PREFLIGHT_ROOT}" >> "$GITHUB_ENV"' in workflow + assert "runner = build_backtest_runner()" in workflow From 94ce74b4bd0a4bf8862f44faec1b5f0edc2cca40 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:17:53 +0800 Subject: [PATCH 34/47] fix(lifecycle): allow additive v2 manifest metadata Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 1ac3309..4e0318d 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -45,7 +45,7 @@ def load_preflight_panel(expected_end_date: date | None = None) -> pd.DataFrame: "contract_version", "domain", "producer", "strategy_profile", "panel_rows", "panel_symbols", "market_rows", "market_symbols", "start_date", "end_date", "market_start_date", "market_end_date", } - if set(manifest) != required_manifest: + if not required_manifest.issubset(manifest): raise InsufficientEvidenceError("v2 lifecycle preflight manifest fields are incomplete") if manifest["domain"] != "crypto" or manifest["producer"] != "export_lifecycle_preflight_inputs.py" or manifest["strategy_profile"] != PROFILE_NAME: raise InsufficientEvidenceError("v2 lifecycle preflight identity mismatch") From 89c22bf0e1252e917b3d832bb7b47321acd3d4f3 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:25:52 +0800 Subject: [PATCH 35/47] fix(lifecycle): enforce independent preflight freshness Co-Authored-By: Codex --- .../workflows/publish-lifecycle-inputs.yml | 2 +- src/strategy_lifecycle/backtest_wrapper.py | 2 +- tests/test_orchestrator_runner.py | 68 +++++++++++++++++-- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/.github/workflows/publish-lifecycle-inputs.yml b/.github/workflows/publish-lifecycle-inputs.yml index 99421cd..b70b2d6 100644 --- a/.github/workflows/publish-lifecycle-inputs.yml +++ b/.github/workflows/publish-lifecycle-inputs.yml @@ -70,7 +70,7 @@ jobs: result = runner.run( "crypto_live_pool_rotation", {}, - end_date=date.fromisoformat(manifest["end_date"]), + end_date=None, ) if result.observation_count <= 0: raise SystemExit("real lifecycle preflight produced no observations") diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 4e0318d..a4af296 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -98,7 +98,7 @@ def load_preflight_panel(expected_end_date: date | None = None) -> pd.DataFrame: panel_end_date = scored_dates.dt.normalize().max().date() if not scored_dates.empty else panel["date"].dt.normalize().max().date() if expected_end_date is not None and panel_end_date < expected_end_date: raise InsufficientEvidenceError("research panel ends before requested evaluation window") - freshness_reference = expected_end_date or date.today() + freshness_reference = date.today() if (freshness_reference - panel_end_date).days > 3: raise InsufficientEvidenceError("research panel preflight artifact is stale") if contract_version == PREFLIGHT_V2: diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index a62a8d6..8ac2814 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -22,6 +22,32 @@ class CryptoOrchestratorRunnerTests(unittest.TestCase): + @staticmethod + def _write_bundle(root: Path, *, version: str = "v1", days: int = 150, age_days: int = 0, non_finite: bool = False) -> None: + from src.strategy_lifecycle.orchestrator_runner import _synthetic_panel + + panel = _synthetic_panel(days=days).reset_index() + panel["date"] = pd.to_datetime(panel["date"]) + panel["date"] += pd.Timestamp.today().normalize() - pd.Timedelta(days=age_days) - panel["date"].max() + if non_finite: + panel.loc[0, "open"] = float("inf") + panel.to_csv(root / "research_panel.csv.gz", index=False, compression="gzip") + market = panel[["date", "symbol", "open"]].rename(columns={"open": "close"}) + market.to_csv(root / "market_history.csv.gz", index=False, compression="gzip") + manifest = {"contract_version": f"crypto.lifecycle_preflight.{version}"} + if version == "v2": + manifest.update({ + "domain": "crypto", "producer": "export_lifecycle_preflight_inputs.py", + "strategy_profile": PROFILE_NAME, "panel_rows": len(panel), + "panel_symbols": sorted(panel["symbol"].unique().tolist()), + "market_rows": len(market), "market_symbols": sorted(market["symbol"].unique().tolist()), + "start_date": panel["date"].min().date().isoformat(), + "end_date": panel["date"].max().date().isoformat(), + "market_start_date": market["date"].min().date().isoformat(), + "market_end_date": market["date"].max().date().isoformat(), + }) + (root / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + def test_production_wrapper_requires_real_panel(self) -> None: from src.strategy_lifecycle.backtest_wrapper import build_backtest_runner @@ -42,13 +68,7 @@ def test_no_arg_factory_loads_valid_preflight_bundle(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - from src.strategy_lifecycle.orchestrator_runner import _synthetic_panel - panel = _synthetic_panel(days=150).reset_index() - panel["date"] = pd.to_datetime(panel["date"]) - panel["date"] += pd.Timestamp.today().normalize() - panel["date"].max() - panel.to_csv(root / "research_panel.csv.gz", index=False, compression="gzip") - panel[["date", "symbol", "open"]].rename(columns={"open": "close"}).to_csv(root / "market_history.csv.gz", index=False, compression="gzip") - (root / "manifest.json").write_text(json.dumps({"contract_version": "crypto.lifecycle_preflight.v1"}), encoding="utf-8") + self._write_bundle(root) old = os.environ.get("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT") os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = str(root) try: @@ -64,6 +84,40 @@ def test_no_arg_factory_loads_valid_preflight_bundle(self) -> None: else: os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = old + def test_v2_manifest_is_strictly_loaded(self) -> None: + from src.strategy_lifecycle.backtest_wrapper import build_backtest_runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_bundle(root, version="v2") + old = os.environ.get("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT") + os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = str(root) + try: + self.assertEqual(build_backtest_runner().run(PROFILE_NAME, {}).strategy_profile, PROFILE_NAME) + finally: + if old is None: + os.environ.pop("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT", None) + else: + os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = old + + def test_unknown_stale_and_non_finite_bundles_fail_closed(self) -> None: + from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError, build_backtest_runner + + for kwargs in ({"version": "v3"}, {"age_days": 10}, {"non_finite": True}): + with self.subTest(kwargs=kwargs), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_bundle(root, **kwargs) + old = os.environ.get("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT") + os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = str(root) + try: + with self.assertRaises(InsufficientEvidenceError): + build_backtest_runner().run(PROFILE_NAME, {}) + finally: + if old is None: + os.environ.pop("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT", None) + else: + os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = old + def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 8ead8fcf8fb7833dc1726fdb8c2d959b9c1e729f Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:29:55 +0800 Subject: [PATCH 36/47] fix(lifecycle): validate requested preflight start date Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index a4af296..44c21f4 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -25,7 +25,7 @@ class InsufficientEvidenceError(RuntimeError): PREFLIGHT_ENV = "CRYPTO_LIFECYCLE_PREFLIGHT_ROOT" -def load_preflight_panel(expected_end_date: date | None = None) -> pd.DataFrame: +def load_preflight_panel(expected_start_date: date | None = None, expected_end_date: date | None = None) -> pd.DataFrame: configured = os.environ.get(PREFLIGHT_ENV) or os.environ.get("PREFLIGHT_BUNDLE_ROOT") if not configured: raise InsufficientEvidenceError(f"{PREFLIGHT_ENV} is required for no-arg lifecycle registration") @@ -96,6 +96,9 @@ def load_preflight_panel(expected_end_date: date | None = None) -> pd.DataFrame: if panel.loc[in_universe, "final_score"].isna().any(): raise InsufficientEvidenceError("research_panel.csv.gz has malformed scores for in-universe rows") panel_end_date = scored_dates.dt.normalize().max().date() if not scored_dates.empty else panel["date"].dt.normalize().max().date() + panel_start_date = scored_dates.dt.normalize().min().date() if not scored_dates.empty else panel["date"].dt.normalize().min().date() + if expected_start_date is not None and panel_start_date > expected_start_date: + raise InsufficientEvidenceError("research panel starts after requested evaluation window") if expected_end_date is not None and panel_end_date < expected_end_date: raise InsufficientEvidenceError("research panel ends before requested evaluation window") freshness_reference = date.today() @@ -132,7 +135,7 @@ def run( end_date: date | None = None, ) -> BacktestResult: if self._panel is None: - self._runner = CryptoLivePoolBacktestRunner(panel=load_preflight_panel(end_date)) + self._runner = CryptoLivePoolBacktestRunner(panel=load_preflight_panel(start_date, end_date)) elif self._runner is None: self._runner = CryptoLivePoolBacktestRunner(panel=self._panel) return self._runner.run(strategy_profile, params, start_date=start_date, end_date=end_date) From 7ae5cb48d36ec61510ea857ddaf7b50aee857d4d Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:36:35 +0800 Subject: [PATCH 37/47] fix(lifecycle): align manifest dates and reject malformed bundles Co-Authored-By: Codex --- scripts/export_lifecycle_preflight_inputs.py | 4 ++-- src/strategy_lifecycle/backtest_wrapper.py | 8 +++++++- tests/test_orchestrator_runner.py | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/scripts/export_lifecycle_preflight_inputs.py b/scripts/export_lifecycle_preflight_inputs.py index 9aa187a..83d204c 100644 --- a/scripts/export_lifecycle_preflight_inputs.py +++ b/scripts/export_lifecycle_preflight_inputs.py @@ -83,8 +83,8 @@ def export_lifecycle_inputs(panel: pd.DataFrame, output_dir: Path) -> dict[str, "market_symbols": sorted(market_history["symbol"].unique().tolist()), "start_date": panel_dates.min().date().isoformat(), "end_date": panel_dates.max().date().isoformat(), - "market_start_date": min(reference_dates).date().isoformat(), - "market_end_date": max(reference_dates).date().isoformat(), + "market_start_date": market_history["date"].min().date().isoformat(), + "market_end_date": market_history["date"].max().date().isoformat(), } manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") return manifest diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 44c21f4..5692f05 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -37,6 +37,8 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d panel = pd.read_csv(root / "research_panel.csv.gz", compression="gzip") except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc: raise InsufficientEvidenceError(f"invalid lifecycle preflight bundle: {root}") from exc + if not isinstance(manifest, dict): + raise InsufficientEvidenceError("lifecycle preflight manifest must be a JSON object") contract_version = manifest.get("contract_version") if contract_version not in {PREFLIGHT_V1, PREFLIGHT_V2}: raise InsufficientEvidenceError("lifecycle preflight manifest mismatch") @@ -97,6 +99,10 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d raise InsufficientEvidenceError("research_panel.csv.gz has malformed scores for in-universe rows") panel_end_date = scored_dates.dt.normalize().max().date() if not scored_dates.empty else panel["date"].dt.normalize().max().date() panel_start_date = scored_dates.dt.normalize().min().date() if not scored_dates.empty else panel["date"].dt.normalize().min().date() + today = date.today() + market_end_date = market["date"].dt.normalize().max().date() + if panel_end_date > today or market_end_date > today: + raise InsufficientEvidenceError("preflight bundle contains future-dated data") if expected_start_date is not None and panel_start_date > expected_start_date: raise InsufficientEvidenceError("research panel starts after requested evaluation window") if expected_end_date is not None and panel_end_date < expected_end_date: @@ -108,7 +114,7 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d if manifest["start_date"] != scored_dates.dt.normalize().min().date().isoformat() or manifest["end_date"] != panel_end_date.isoformat(): raise InsufficientEvidenceError("v2 panel date range does not match manifest") market_start = market["date"].dt.normalize().min().date().isoformat() - market_end = market["date"].dt.normalize().max().date().isoformat() + market_end = market_end_date.isoformat() if manifest["market_start_date"] != market_start or manifest["market_end_date"] != market_end: raise InsufficientEvidenceError("v2 market date range does not match manifest") return panel.set_index(["date", "symbol"]).sort_index() diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 8ac2814..035121f 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -118,6 +118,26 @@ def test_unknown_stale_and_non_finite_bundles_fail_closed(self) -> None: else: os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = old + def test_malformed_manifest_and_future_bundle_fail_closed(self) -> None: + from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError, build_backtest_runner + + for malformed in (True, False): + with self.subTest(malformed=malformed), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_bundle(root, age_days=-1 if not malformed else 0) + if malformed: + (root / "manifest.json").write_text("[]", encoding="utf-8") + old = os.environ.get("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT") + os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = str(root) + try: + with self.assertRaises(InsufficientEvidenceError): + build_backtest_runner().run(PROFILE_NAME, {}) + finally: + if old is None: + os.environ.pop("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT", None) + else: + os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = old + def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From b09d4d1dd0abf7f412fb79f3d6ec27ade3c0e133 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 05:33:25 +0800 Subject: [PATCH 38/47] fix(lifecycle): validate legacy manifest dates Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 11 +++++++++++ tests/test_orchestrator_runner.py | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 5692f05..8ebc26a 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -117,6 +117,17 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d market_end = market_end_date.isoformat() if manifest["market_start_date"] != market_start or manifest["market_end_date"] != market_end: raise InsufficientEvidenceError("v2 market date range does not match manifest") + else: + panel_start = panel_start_date.isoformat() + panel_end = panel_end_date.isoformat() + if any(key in manifest for key in ("start_date", "end_date")): + if manifest.get("start_date") != panel_start or manifest.get("end_date") != panel_end: + raise InsufficientEvidenceError("v1 panel date range does not match manifest") + market_start = market["date"].dt.normalize().min().date().isoformat() + market_end = market_end_date.isoformat() + if any(key in manifest for key in ("market_start_date", "market_end_date")): + if manifest.get("market_start_date") != market_start or manifest.get("market_end_date") != market_end: + raise InsufficientEvidenceError("v1 market date range does not match manifest") return panel.set_index(["date", "symbol"]).sort_index() diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 035121f..56f846d 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -84,6 +84,20 @@ def test_no_arg_factory_loads_valid_preflight_bundle(self) -> None: else: os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = old + def test_v1_bundle_rejects_inconsistent_optional_date_metadata(self) -> None: + from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError, load_preflight_panel + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_bundle(root) + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["start_date"] = "2000-01-01" + manifest["end_date"] = "2000-01-02" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with self.assertRaises(InsufficientEvidenceError): + load_preflight_panel(root) + def test_v2_manifest_is_strictly_loaded(self) -> None: from src.strategy_lifecycle.backtest_wrapper import build_backtest_runner From 7ec4eeb33f07232df0f8f3726113653da1c1d7e7 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 05:38:16 +0800 Subject: [PATCH 39/47] fix(lifecycle): guard malformed manifest symbols Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 4 ++++ tests/test_orchestrator_runner.py | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 8ebc26a..8dc284f 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -56,6 +56,10 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d raise InsufficientEvidenceError("v1 lifecycle preflight producer mismatch") if manifest.get("strategy_profile") not in {None, PROFILE_NAME}: raise InsufficientEvidenceError("v1 lifecycle preflight strategy_profile mismatch") + for field in ("panel_symbols", "market_symbols"): + value = manifest.get(field) + if value is not None and (not isinstance(value, list) or not all(isinstance(symbol, str) for symbol in value)): + raise InsufficientEvidenceError(f"{field} must be a list of symbols") required = {"date", "symbol", "in_universe", "open", "final_score"} if not required.issubset(panel.columns): raise InsufficientEvidenceError("research_panel.csv.gz missing required columns") diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 56f846d..770d315 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -152,6 +152,19 @@ def test_malformed_manifest_and_future_bundle_fail_closed(self) -> None: else: os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = old + def test_malformed_manifest_symbol_lists_fail_closed(self) -> None: + from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError, load_preflight_panel + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_bundle(root, version="v2") + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["panel_symbols"] = None + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with self.assertRaises(InsufficientEvidenceError): + load_preflight_panel(root) + def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From d945cc24b9fadd2925dcedec29ddd8a9c31211eb Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 05:43:15 +0800 Subject: [PATCH 40/47] fix(lifecycle): require explicit preflight root Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 2 +- tests/test_orchestrator_runner.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 8dc284f..5607f16 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -26,7 +26,7 @@ class InsufficientEvidenceError(RuntimeError): def load_preflight_panel(expected_start_date: date | None = None, expected_end_date: date | None = None) -> pd.DataFrame: - configured = os.environ.get(PREFLIGHT_ENV) or os.environ.get("PREFLIGHT_BUNDLE_ROOT") + configured = os.environ.get(PREFLIGHT_ENV) if not configured: raise InsufficientEvidenceError(f"{PREFLIGHT_ENV} is required for no-arg lifecycle registration") root = Path(configured).expanduser().resolve() diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 770d315..25d8cfc 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -63,6 +63,21 @@ def test_production_wrapper_requires_real_panel(self) -> None: if old is not None: os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = old + def test_no_arg_factory_ignores_legacy_preflight_env(self) -> None: + from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError, build_backtest_runner + + old = os.environ.get("PREFLIGHT_BUNDLE_ROOT") + os.environ["PREFLIGHT_BUNDLE_ROOT"] = "/tmp/unrelated-preflight-bundle" + os.environ.pop("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT", None) + try: + with self.assertRaises(InsufficientEvidenceError): + build_backtest_runner().run(PROFILE_NAME, {}) + finally: + if old is None: + os.environ.pop("PREFLIGHT_BUNDLE_ROOT", None) + else: + os.environ["PREFLIGHT_BUNDLE_ROOT"] = old + def test_no_arg_factory_loads_valid_preflight_bundle(self) -> None: from src.strategy_lifecycle.backtest_wrapper import build_backtest_runner From 195dff2599052cda698926a1f9c460bbf0cb66a1 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:40:10 +0800 Subject: [PATCH 41/47] fix(lifecycle): enforce preflight sample sufficiency Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 23 +++++++++++++++++++++- tests/test_orchestrator_runner.py | 11 ++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 5607f16..1960f50 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -23,6 +23,9 @@ class InsufficientEvidenceError(RuntimeError): PREFLIGHT_V1 = "crypto.lifecycle_preflight.v1" PREFLIGHT_V2 = "crypto.lifecycle_preflight.v2" PREFLIGHT_ENV = "CRYPTO_LIFECYCLE_PREFLIGHT_ROOT" +MIN_PANEL_DAYS = 730 +MIN_MARKET_DAYS = 900 +MARKET_SYMBOLS = ("BTCUSDT", "ETHUSDT") def load_preflight_panel(expected_start_date: date | None = None, expected_end_date: date | None = None) -> pd.DataFrame: @@ -58,7 +61,7 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d raise InsufficientEvidenceError("v1 lifecycle preflight strategy_profile mismatch") for field in ("panel_symbols", "market_symbols"): value = manifest.get(field) - if value is not None and (not isinstance(value, list) or not all(isinstance(symbol, str) for symbol in value)): + if field in manifest and (not isinstance(value, list) or not all(isinstance(symbol, str) for symbol in value)): raise InsufficientEvidenceError(f"{field} must be a list of symbols") required = {"date", "symbol", "in_universe", "open", "final_score"} if not required.issubset(panel.columns): @@ -92,6 +95,16 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d market["close"] = pd.to_numeric(market["close"], errors="coerce") if market.empty or market["date"].isna().any() or market["close"].isna().any() or not market["close"].map(math.isfinite).all(): raise InsufficientEvidenceError("market_history.csv.gz contains invalid content") + market_dates = market["date"].dt.normalize() + if market_dates.nunique() < MIN_MARKET_DAYS: + raise InsufficientEvidenceError("market history has insufficient date coverage") + reference_dates = set(market.loc[market["symbol"] == MARKET_SYMBOLS[0], "date"].dt.normalize()) + if not reference_dates: + raise InsufficientEvidenceError("market history is missing BTCUSDT coverage") + for symbol in MARKET_SYMBOLS: + symbol_dates = set(market.loc[market["symbol"] == symbol, "date"].dt.normalize()) + if not symbol_dates or len(symbol_dates & reference_dates) / len(reference_dates) < 0.99: + raise InsufficientEvidenceError(f"market history has incomplete {symbol} coverage") if manifest.get("panel_rows") is not None and (manifest["panel_rows"] != len(panel) or sorted(manifest.get("panel_symbols", [])) != sorted(panel["symbol"].dropna().unique().tolist())): raise InsufficientEvidenceError("research panel does not match manifest counts or symbols") if manifest.get("market_rows") is not None and (manifest["market_rows"] != len(market) or sorted(manifest.get("market_symbols", [])) != sorted(market["symbol"].dropna().unique().tolist())): @@ -101,6 +114,14 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d in_universe = panel["in_universe"] if panel.loc[in_universe, "final_score"].isna().any(): raise InsufficientEvidenceError("research_panel.csv.gz has malformed scores for in-universe rows") + scored_panel = panel.loc[panel["final_score"].notna()].copy() + scored_panel["date"] = scored_panel["date"].dt.normalize() + scored_dates = scored_panel["date"] + if scored_dates.nunique() < MIN_PANEL_DAYS: + raise InsufficientEvidenceError("research panel has insufficient scored date coverage") + in_universe_counts = scored_panel.loc[scored_panel["in_universe"]].groupby("date")["symbol"].nunique() + if in_universe_counts.reindex(scored_dates.unique(), fill_value=0).min() < 2: + raise InsufficientEvidenceError("research panel has insufficient in-universe coverage") panel_end_date = scored_dates.dt.normalize().max().date() if not scored_dates.empty else panel["date"].dt.normalize().max().date() panel_start_date = scored_dates.dt.normalize().min().date() if not scored_dates.empty else panel["date"].dt.normalize().min().date() today = date.today() diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 25d8cfc..5e3ca62 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -23,7 +23,7 @@ class CryptoOrchestratorRunnerTests(unittest.TestCase): @staticmethod - def _write_bundle(root: Path, *, version: str = "v1", days: int = 150, age_days: int = 0, non_finite: bool = False) -> None: + def _write_bundle(root: Path, *, version: str = "v1", days: int = 900, age_days: int = 0, non_finite: bool = False) -> None: from src.strategy_lifecycle.orchestrator_runner import _synthetic_panel panel = _synthetic_panel(days=days).reset_index() @@ -180,6 +180,15 @@ def test_malformed_manifest_symbol_lists_fail_closed(self) -> None: with self.assertRaises(InsufficientEvidenceError): load_preflight_panel(root) + def test_undersized_bundle_fails_closed(self) -> None: + from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError, load_preflight_panel + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_bundle(root, version="v2", days=100) + with self.assertRaises(InsufficientEvidenceError): + load_preflight_panel(root) + def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From c34d98ce06560124adaef8c508a2150893c7eb35 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:44:18 +0800 Subject: [PATCH 42/47] fix(lifecycle): reject stale market artifacts Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 2 ++ tests/test_orchestrator_runner.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 1960f50..f4ff38f 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -128,6 +128,8 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d market_end_date = market["date"].dt.normalize().max().date() if panel_end_date > today or market_end_date > today: raise InsufficientEvidenceError("preflight bundle contains future-dated data") + if (today - market_end_date).days > 3: + raise InsufficientEvidenceError("market history preflight artifact is stale") if expected_start_date is not None and panel_start_date > expected_start_date: raise InsufficientEvidenceError("research panel starts after requested evaluation window") if expected_end_date is not None and panel_end_date < expected_end_date: diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 5e3ca62..fa52b5b 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -189,6 +189,24 @@ def test_undersized_bundle_fails_closed(self) -> None: with self.assertRaises(InsufficientEvidenceError): load_preflight_panel(root) + def test_stale_market_history_fails_closed(self) -> None: + from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError, load_preflight_panel + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_bundle(root, version="v2") + market_path = root / "market_history.csv.gz" + market = pd.read_csv(market_path, compression="gzip") + market["date"] = pd.to_datetime(market["date"]) - pd.Timedelta(days=10) + market.to_csv(market_path, index=False, compression="gzip") + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["market_start_date"] = market["date"].min().date().isoformat() + manifest["market_end_date"] = market["date"].max().date().isoformat() + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with self.assertRaises(InsufficientEvidenceError): + load_preflight_panel(root) + def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 3916d43006d45d6bb529ed09424a278fd34cee20 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:50:51 +0800 Subject: [PATCH 43/47] fix(lifecycle): harden runtime evidence validation Co-Authored-By: Codex --- .../run_crypto_live_pool_baseline_review.py | 55 +++++++++++++------ src/strategy_lifecycle/backtest_wrapper.py | 4 +- src/strategy_lifecycle/orchestrator_runner.py | 13 ++++- tests/test_baseline_review.py | 26 +++++++++ tests/test_orchestrator_runner.py | 30 ++++++++++ 5 files changed, 105 insertions(+), 23 deletions(-) diff --git a/scripts/run_crypto_live_pool_baseline_review.py b/scripts/run_crypto_live_pool_baseline_review.py index edcca44..9cc7400 100644 --- a/scripts/run_crypto_live_pool_baseline_review.py +++ b/scripts/run_crypto_live_pool_baseline_review.py @@ -92,31 +92,32 @@ def build_review(*, performance_summary: Path, walkforward_summary: Path) -> dic } -def _read_csv(path: Path) -> tuple[list[str], list[str]] | None: +def _read_csv(path: Path) -> tuple[list[str], list[list[str]]] | None: if not path.exists() or not path.is_file(): return None try: with path.open(newline="", encoding="utf-8") as handle: reader = csv.reader(handle) header = next(reader, None) - data_row = next(reader, None) - if not header or not data_row: + data_rows = list(reader) + if not header or not data_rows: return None - return ["".join(ch for ch in cell.lower() if ch.isalnum() or ch == "_") for cell in header], data_row + return ["".join(ch for ch in cell.lower() if ch.isalnum() or ch == "_") for cell in header], data_rows except (OSError, UnicodeError, csv.Error): return None -def _has_numeric_value(header: list[str], row: list[str], candidates: set[str]) -> bool: - for index, name in enumerate(header): - if name not in candidates or index >= len(row): - continue - try: - value = float(row[index]) - if math.isfinite(value): - return True - except (TypeError, ValueError): - continue +def _has_numeric_value(header: list[str], rows: list[list[str]], candidates: set[str]) -> bool: + for row in rows: + for index, name in enumerate(header): + if name not in candidates or index >= len(row): + continue + try: + value = float(row[index]) + if math.isfinite(value): + return True + except (TypeError, ValueError): + continue return False @@ -124,19 +125,37 @@ def _usable_performance_csv(path: Path) -> bool: parsed = _read_csv(path) if parsed is None: return False - header, row = parsed + header, rows = parsed metrics = {"cagr", "sharpe", "calmar", "maxdrawdown", "max_dd", "annualizedvolatility"} - return bool(set(header).intersection(metrics)) and _has_numeric_value(header, row, metrics) + if "strategy" in header: + strategy_index = header.index("strategy") + if not any(len(row) > strategy_index and row[strategy_index].strip().lower() == "final_score" for row in rows): + return False + return bool(set(header).intersection(metrics)) and _has_numeric_value(header, rows, metrics) def _usable_walkforward_csv(path: Path) -> bool: parsed = _read_csv(path) if parsed is None: return False - header, row = parsed + header, rows = parsed required = {"window_id", "test_start", "test_end"} metrics = {"window_cagr", "window_sharpe", "window_max_drawdown", "h30_precision", "h60_precision", "h90_precision"} - return required.issubset(set(header)) and bool(set(header).intersection(metrics)) and _has_numeric_value(header, row, metrics) + if not required.issubset(set(header)) or not set(header).intersection(metrics): + return False + positions = {name: header.index(name) for name in required} + for row in rows: + try: + if any(not row[index].strip() for index in positions.values()): + continue + int(row[positions["window_id"]]) + datetime.fromisoformat(row[positions["test_start"]].replace("Z", "+00:00")) + datetime.fromisoformat(row[positions["test_end"]].replace("Z", "+00:00")) + except (IndexError, TypeError, ValueError): + continue + if _has_numeric_value(header, [row], metrics): + return True + return False def render_markdown(review: dict[str, Any]) -> str: diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index f4ff38f..f4b3a7e 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -66,7 +66,7 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d required = {"date", "symbol", "in_universe", "open", "final_score"} if not required.issubset(panel.columns): raise InsufficientEvidenceError("research_panel.csv.gz missing required columns") - panel["date"] = pd.to_datetime(panel["date"], errors="coerce") + panel["date"] = pd.to_datetime(panel["date"], errors="coerce").dt.normalize() panel["open"] = pd.to_numeric(panel["open"], errors="coerce") panel["final_score"] = pd.to_numeric(panel["final_score"], errors="coerce") universe_values = panel["in_universe"].map( @@ -120,7 +120,7 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d if scored_dates.nunique() < MIN_PANEL_DAYS: raise InsufficientEvidenceError("research panel has insufficient scored date coverage") in_universe_counts = scored_panel.loc[scored_panel["in_universe"]].groupby("date")["symbol"].nunique() - if in_universe_counts.reindex(scored_dates.unique(), fill_value=0).min() < 2: + if in_universe_counts.empty or in_universe_counts.reindex(scored_dates.unique(), fill_value=0).min() < 2: raise InsufficientEvidenceError("research panel has insufficient in-universe coverage") panel_end_date = scored_dates.dt.normalize().max().date() if not scored_dates.empty else panel["date"].dt.normalize().max().date() panel_start_date = scored_dates.dt.normalize().min().date() if not scored_dates.empty else panel["date"].dt.normalize().min().date() diff --git a/src/strategy_lifecycle/orchestrator_runner.py b/src/strategy_lifecycle/orchestrator_runner.py index c002388..9f923b8 100644 --- a/src/strategy_lifecycle/orchestrator_runner.py +++ b/src/strategy_lifecycle/orchestrator_runner.py @@ -2,6 +2,7 @@ from __future__ import annotations +from copy import deepcopy from datetime import date, datetime, timezone from typing import Any, Mapping @@ -77,7 +78,7 @@ def _metrics_to_qpk_result( raise ImportError("quant_platform_kit is required to build BacktestResult") cagr = float(metrics.get("CAGR") or 0.0) max_drawdown = float(metrics.get("Max Drawdown") or 0.0) - calmar = abs(cagr / max_drawdown) if max_drawdown else None + calmar = cagr / max_drawdown if max_drawdown else None return QpkBacktestResult( strategy_profile=strategy_profile, domain="crypto", @@ -126,7 +127,13 @@ def run( raise ValueError("No panel rows for requested window") started = datetime.now(timezone.utc) - result = run_single_backtest(sliced, "final_score", DEFAULT_BACKTEST_CONFIG) + config = deepcopy(DEFAULT_BACKTEST_CONFIG) + strategy_params = params.get("strategy", params) + if isinstance(strategy_params, Mapping): + for key in config["strategy"]: + if key in strategy_params: + config["strategy"][key] = strategy_params[key] + result = run_single_backtest(sliced, "final_score", config) elapsed = (datetime.now(timezone.utc) - started).total_seconds() eval_dates = sliced.index.get_level_values("date") metrics = dict(result.metrics) @@ -134,7 +141,7 @@ def run( return _metrics_to_qpk_result( strategy_profile=strategy_profile, params=params, - metrics=result.metrics, + metrics=metrics, start_date=start_date or eval_dates.min().date(), end_date=end_date or eval_dates.max().date(), run_duration_seconds=elapsed, diff --git a/tests/test_baseline_review.py b/tests/test_baseline_review.py index bd2c380..a44c4b0 100644 --- a/tests/test_baseline_review.py +++ b/tests/test_baseline_review.py @@ -47,6 +47,32 @@ def test_missing_real_artifacts_is_insufficient_and_not_promotable(self) -> None self.assertEqual(packet["evidence_sufficiency"], "insufficient_evidence") self.assertEqual(packet["allowed_human_decisions"], ["approve_research", "reject_rollback"]) + def test_performance_requires_final_score_when_strategy_column_exists(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + performance = root / "performance.csv" + walkforward = root / "walkforward.csv" + performance.write_text("strategy,CAGR\nrule_score,0.2\n", encoding="utf-8") + walkforward.write_text( + "window_id,test_start,test_end,window_cagr\n0,2025-01-01,2025-03-31,0.1\n", + encoding="utf-8", + ) + review = MODULE.build_review(performance_summary=performance, walkforward_summary=walkforward) + self.assertEqual(review["blocking_reason_codes"], ["MISSING_OR_INVALID_REAL_PERFORMANCE_ARTIFACT"]) + + def test_walkforward_requires_populated_parseable_window_fields(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + performance = root / "performance.csv" + walkforward = root / "walkforward.csv" + performance.write_text("CAGR,Sharpe\n0.2,1.1\n", encoding="utf-8") + walkforward.write_text( + "window_id,test_start,test_end,window_cagr\n,,not-a-date,0.1\n", + encoding="utf-8", + ) + review = MODULE.build_review(performance_summary=performance, walkforward_summary=walkforward) + self.assertEqual(review["blocking_reason_codes"], ["MISSING_OR_INVALID_REAL_PERFORMANCE_ARTIFACT"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index fa52b5b..d0dc3d3 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -7,6 +7,7 @@ import os from datetime import date from pathlib import Path +from unittest.mock import patch import pandas as pd @@ -219,6 +220,35 @@ def test_run_returns_backtest_result(self) -> None: end_date=date(2024, 3, 1), ) self.assertEqual(result.strategy_profile, PROFILE_NAME) + + def test_real_runner_applies_params_and_preserves_metrics(self) -> None: + from src.backtest import BacktestResult + + captured: dict[str, object] = {} + + def fake_backtest(panel, score_column, config): + captured["config"] = config + returns = pd.Series([0.01, -0.02]) + return BacktestResult( + name=score_column, + returns=returns, + equity_curve=(1 + returns).cumprod(), + holdings=pd.DataFrame(), + trades=pd.DataFrame(), + turnover=pd.Series([0.0, 0.0]), + metrics={"CAGR": -0.1, "Max Drawdown": 0.2, "Sharpe": -0.1}, + ) + + with patch("src.strategy_lifecycle.orchestrator_runner.run_single_backtest", side_effect=fake_backtest): + result = CryptoLivePoolBacktestRunner(synthetic_days=1600).run( + PROFILE_NAME, + {"top_n": 1, "fee_bps": 30}, + ) + + self.assertEqual(captured["config"]["strategy"]["top_n"], 1) + self.assertEqual(captured["config"]["strategy"]["fee_bps"], 30) + self.assertEqual(result.observation_count, 2) + self.assertLess(result.calmar_ratio, 0) self.assertEqual(result.domain, "crypto") self.assertIsNotNone(result.sharpe_ratio) From 838cc0db03cb3f1a668ac7bf3388cf91e91e1ecf Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:01:56 +0800 Subject: [PATCH 44/47] fix(lifecycle): normalize drawdown for calmar Co-Authored-By: Codex --- src/strategy_lifecycle/orchestrator_runner.py | 2 +- tests/test_orchestrator_runner.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/strategy_lifecycle/orchestrator_runner.py b/src/strategy_lifecycle/orchestrator_runner.py index 9f923b8..7d60ac9 100644 --- a/src/strategy_lifecycle/orchestrator_runner.py +++ b/src/strategy_lifecycle/orchestrator_runner.py @@ -78,7 +78,7 @@ def _metrics_to_qpk_result( raise ImportError("quant_platform_kit is required to build BacktestResult") cagr = float(metrics.get("CAGR") or 0.0) max_drawdown = float(metrics.get("Max Drawdown") or 0.0) - calmar = cagr / max_drawdown if max_drawdown else None + calmar = cagr / abs(max_drawdown) if max_drawdown else None return QpkBacktestResult( strategy_profile=strategy_profile, domain="crypto", diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index d0dc3d3..9bc484e 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -236,7 +236,7 @@ def fake_backtest(panel, score_column, config): holdings=pd.DataFrame(), trades=pd.DataFrame(), turnover=pd.Series([0.0, 0.0]), - metrics={"CAGR": -0.1, "Max Drawdown": 0.2, "Sharpe": -0.1}, + metrics={"CAGR": -0.1, "Max Drawdown": -0.2, "Sharpe": -0.1}, ) with patch("src.strategy_lifecycle.orchestrator_runner.run_single_backtest", side_effect=fake_backtest): From 83a98649adfc9d4c53af0a6e11a91deb955100a2 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:32:29 +0800 Subject: [PATCH 45/47] test(lifecycle): document scored preflight bounds Co-Authored-By: Codex --- scripts/export_lifecycle_preflight_inputs.py | 13 ++++++------ src/strategy_lifecycle/backtest_wrapper.py | 1 + .../test_export_lifecycle_preflight_inputs.py | 16 ++++++++++++++ tests/test_orchestrator_runner.py | 21 ++++++++++++++++++- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/scripts/export_lifecycle_preflight_inputs.py b/scripts/export_lifecycle_preflight_inputs.py index 83d204c..3a78497 100644 --- a/scripts/export_lifecycle_preflight_inputs.py +++ b/scripts/export_lifecycle_preflight_inputs.py @@ -38,11 +38,12 @@ def export_lifecycle_inputs(panel: pd.DataFrame, output_dir: Path) -> dict[str, scored_panel = lifecycle_panel.dropna(subset=["final_score"]) if scored_panel.empty: raise ValueError("research panel has no scored lifecycle rows") - panel_dates = pd.DatetimeIndex(sorted(scored_panel["date"].unique())) - if len(panel_dates) < MIN_PANEL_DAYS: + # v2 bounds are the scored_panel bounds; warm-up/unscored rows remain in the CSV. + scored_dates = pd.DatetimeIndex(sorted(scored_panel["date"].unique())) + if len(scored_dates) < MIN_PANEL_DAYS: raise ValueError(f"research panel requires at least {MIN_PANEL_DAYS} scored dates") in_universe_counts = scored_panel.loc[scored_panel["in_universe"]].groupby("date")["symbol"].nunique() - in_universe_counts = in_universe_counts.reindex(panel_dates, fill_value=0) + in_universe_counts = in_universe_counts.reindex(scored_dates, fill_value=0) if int(in_universe_counts.min()) < 2: raise ValueError("research panel requires at least two in-universe symbols per scored date") @@ -61,7 +62,7 @@ def export_lifecycle_inputs(panel: pd.DataFrame, output_dir: Path) -> dict[str, or max(symbol_dates) < max(reference_dates) ): raise ValueError(f"market history has incomplete symbol coverage: {symbol}") - if today - panel_dates.max() > pd.Timedelta(days=MAX_FRESHNESS_DAYS): + if today - scored_dates.max() > pd.Timedelta(days=MAX_FRESHNESS_DAYS): raise ValueError("research panel is stale") if today - max(reference_dates) > pd.Timedelta(days=MAX_FRESHNESS_DAYS): raise ValueError("BTC/ETH market history is stale") @@ -81,8 +82,8 @@ def export_lifecycle_inputs(panel: pd.DataFrame, output_dir: Path) -> dict[str, "panel_symbols": sorted(lifecycle_panel["symbol"].unique().tolist()), "market_rows": int(len(market_history)), "market_symbols": sorted(market_history["symbol"].unique().tolist()), - "start_date": panel_dates.min().date().isoformat(), - "end_date": panel_dates.max().date().isoformat(), + "start_date": scored_dates.min().date().isoformat(), + "end_date": scored_dates.max().date().isoformat(), "market_start_date": market_history["date"].min().date().isoformat(), "market_end_date": market_history["date"].max().date().isoformat(), } diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index f4b3a7e..3b8e51d 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -79,6 +79,7 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d panel["in_universe"] = universe_values.astype(bool) if panel.empty or panel["date"].isna().any() or panel["open"].isna().any() or not panel["open"].map(math.isfinite).all(): raise InsufficientEvidenceError("research_panel.csv.gz contains invalid numeric/date content") + # v2/v1 optional bounds are compared with scored rows, not warm-up/unscored CSV rows. scored_dates = panel.loc[panel["final_score"].notna(), "date"] if not panel["final_score"].dropna().map(math.isfinite).all(): raise InsufficientEvidenceError("research_panel.csv.gz contains non-finite scores") diff --git a/tests/test_export_lifecycle_preflight_inputs.py b/tests/test_export_lifecycle_preflight_inputs.py index 0708a09..a4d0ff6 100644 --- a/tests/test_export_lifecycle_preflight_inputs.py +++ b/tests/test_export_lifecycle_preflight_inputs.py @@ -74,6 +74,22 @@ def test_export_preserves_open_rows_without_scores(self) -> None: self.assertEqual(len(row), 1) self.assertTrue(pd.isna(row.iloc[0]["final_score"])) + def test_manifest_bounds_use_scored_rows_with_unscored_boundaries(self) -> None: + panel = self._valid_panel() + dates = panel.index.get_level_values("date").unique() + for boundary in (dates[0], dates[-2]): + panel.loc[(boundary, slice(None)), ["in_universe", "final_score"]] = [False, pd.NA] + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) + manifest = export_lifecycle_inputs(panel, output_dir) + exported = pd.read_csv(output_dir / "research_panel.csv.gz") + + scored = exported.dropna(subset=["final_score"]) + self.assertEqual(manifest["start_date"], scored["date"].min()) + self.assertEqual(manifest["end_date"], scored["date"].max()) + self.assertNotEqual(manifest["start_date"], exported["date"].min()) + def test_export_rejects_stale_combo_history_even_when_panel_is_fresh(self) -> None: panel = self._valid_panel() cutoff = panel.index.get_level_values("date").max() - pd.Timedelta(days=10) diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 9bc484e..a91b58b 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -5,7 +5,7 @@ import unittest import json import os -from datetime import date +from datetime import date, timedelta from pathlib import Path from unittest.mock import patch @@ -100,6 +100,25 @@ def test_no_arg_factory_loads_valid_preflight_bundle(self) -> None: else: os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = old + def test_no_arg_runner_revalidates_second_window(self) -> None: + from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError, build_backtest_runner + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_bundle(root) + old = os.environ.get("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT") + os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = str(root) + try: + runner = build_backtest_runner() + runner.run(PROFILE_NAME, {}, start_date=date.today() - timedelta(days=100)) + with self.assertRaises(InsufficientEvidenceError): + runner.run(PROFILE_NAME, {}, start_date=date.today() - timedelta(days=1000)) + finally: + if old is None: + os.environ.pop("CRYPTO_LIFECYCLE_PREFLIGHT_ROOT", None) + else: + os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"] = old + def test_v1_bundle_rejects_inconsistent_optional_date_metadata(self) -> None: from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError, load_preflight_panel From d5de6f860ec6518e73caf17a2c21af868b26d268 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:36:23 +0800 Subject: [PATCH 46/47] fix(lifecycle): reject duplicate preflight rows Co-Authored-By: Codex --- src/strategy_lifecycle/backtest_wrapper.py | 4 ++++ tests/test_orchestrator_runner.py | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 3b8e51d..157568f 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -69,6 +69,8 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d panel["date"] = pd.to_datetime(panel["date"], errors="coerce").dt.normalize() panel["open"] = pd.to_numeric(panel["open"], errors="coerce") panel["final_score"] = pd.to_numeric(panel["final_score"], errors="coerce") + if panel.duplicated(["date", "symbol"]).any(): + raise InsufficientEvidenceError("research_panel.csv.gz contains duplicate date/symbol rows") universe_values = panel["in_universe"].map( lambda value: value if isinstance(value, bool) else { "true": True, "1": True, "false": False, "0": False, @@ -94,6 +96,8 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d raise InsufficientEvidenceError("market_history.csv.gz missing required columns") market["date"] = pd.to_datetime(market["date"], errors="coerce") market["close"] = pd.to_numeric(market["close"], errors="coerce") + if market.duplicated(["date", "symbol"]).any(): + raise InsufficientEvidenceError("market_history.csv.gz contains duplicate date/symbol rows") if market.empty or market["date"].isna().any() or market["close"].isna().any() or not market["close"].map(math.isfinite).all(): raise InsufficientEvidenceError("market_history.csv.gz contains invalid content") market_dates = market["date"].dt.normalize() diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index a91b58b..311a5b5 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -227,6 +227,19 @@ def test_stale_market_history_fails_closed(self) -> None: with self.assertRaises(InsufficientEvidenceError): load_preflight_panel(root) + def test_duplicate_preflight_rows_fail_closed(self) -> None: + from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError, load_preflight_panel + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_bundle(root, version="v2") + panel_path = root / "research_panel.csv.gz" + panel = pd.read_csv(panel_path, compression="gzip") + panel = pd.concat([panel, panel.iloc[[0]]], ignore_index=True) + panel.to_csv(panel_path, index=False, compression="gzip") + with self.assertRaises(InsufficientEvidenceError): + load_preflight_panel(root) + def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) From 119a83d5903cb440bc68cd1abc0104ab63b8415b Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:43:11 +0800 Subject: [PATCH 47/47] fix(lifecycle): verify completed date and coverage Co-Authored-By: Codex --- .../workflows/publish-lifecycle-inputs.yml | 4 +++- src/strategy_lifecycle/backtest_wrapper.py | 3 +++ tests/test_orchestrator_runner.py | 21 +++++++++++++++++++ .../test_publish_lifecycle_inputs_workflow.py | 2 ++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-lifecycle-inputs.yml b/.github/workflows/publish-lifecycle-inputs.yml index b70b2d6..20338c9 100644 --- a/.github/workflows/publish-lifecycle-inputs.yml +++ b/.github/workflows/publish-lifecycle-inputs.yml @@ -40,6 +40,7 @@ jobs: run: | set -euo pipefail completed_date="$(python -c 'from datetime import datetime, timedelta, timezone; print((datetime.now(timezone.utc) - timedelta(days=1)).date())')" + echo "CRYPTO_LIFECYCLE_EXPECTED_END_DATE=${completed_date}" >> "$GITHUB_ENV" python scripts/download_history.py --top-liquid "${DOWNLOAD_TOP_LIQUID}" \ --end-date "${completed_date}" \ --force-exchange-info @@ -67,10 +68,11 @@ jobs: with open(os.path.join(os.environ["CRYPTO_LIFECYCLE_PREFLIGHT_ROOT"], "manifest.json"), encoding="utf-8") as handle: manifest = json.load(handle) runner = build_backtest_runner() + expected_end_date = date.fromisoformat(os.environ["CRYPTO_LIFECYCLE_EXPECTED_END_DATE"]) result = runner.run( "crypto_live_pool_rotation", {}, - end_date=None, + end_date=expected_end_date, ) if result.observation_count <= 0: raise SystemExit("real lifecycle preflight produced no observations") diff --git a/src/strategy_lifecycle/backtest_wrapper.py b/src/strategy_lifecycle/backtest_wrapper.py index 157568f..f7d5a66 100644 --- a/src/strategy_lifecycle/backtest_wrapper.py +++ b/src/strategy_lifecycle/backtest_wrapper.py @@ -130,7 +130,10 @@ def load_preflight_panel(expected_start_date: date | None = None, expected_end_d panel_end_date = scored_dates.dt.normalize().max().date() if not scored_dates.empty else panel["date"].dt.normalize().max().date() panel_start_date = scored_dates.dt.normalize().min().date() if not scored_dates.empty else panel["date"].dt.normalize().min().date() today = date.today() + market_start_date = market["date"].dt.normalize().min().date() market_end_date = market["date"].dt.normalize().max().date() + if market_start_date > panel_start_date or market_end_date < panel_end_date: + raise InsufficientEvidenceError("market history does not cover scored panel date range") if panel_end_date > today or market_end_date > today: raise InsufficientEvidenceError("preflight bundle contains future-dated data") if (today - market_end_date).days > 3: diff --git a/tests/test_orchestrator_runner.py b/tests/test_orchestrator_runner.py index 311a5b5..23d9eb6 100644 --- a/tests/test_orchestrator_runner.py +++ b/tests/test_orchestrator_runner.py @@ -240,6 +240,27 @@ def test_duplicate_preflight_rows_fail_closed(self) -> None: with self.assertRaises(InsufficientEvidenceError): load_preflight_panel(root) + def test_market_history_must_cover_scored_panel_window(self) -> None: + from src.strategy_lifecycle.backtest_wrapper import InsufficientEvidenceError, load_preflight_panel + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._write_bundle(root, version="v2", days=1000) + market_path = root / "market_history.csv.gz" + market = pd.read_csv(market_path, compression="gzip") + panel = pd.read_csv(root / "research_panel.csv.gz", compression="gzip") + panel_start = pd.to_datetime(panel["date"]).min() + market = market[pd.to_datetime(market["date"]) > panel_start + pd.Timedelta(days=10)] + market.to_csv(market_path, index=False, compression="gzip") + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["market_rows"] = len(market) + manifest["market_start_date"] = market["date"].min() + manifest["market_end_date"] = market["date"].max() + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with self.assertRaises(InsufficientEvidenceError): + load_preflight_panel(root) + def test_supported_profile(self) -> None: self.assertIn(PROFILE_NAME, SUPPORTED_PROFILES) diff --git a/tests/test_publish_lifecycle_inputs_workflow.py b/tests/test_publish_lifecycle_inputs_workflow.py index c54e64e..c422c9f 100644 --- a/tests/test_publish_lifecycle_inputs_workflow.py +++ b/tests/test_publish_lifecycle_inputs_workflow.py @@ -16,4 +16,6 @@ def test_publish_lifecycle_inputs_workflow_uses_real_research_pipeline() -> None assert "if-no-files-found: error" in workflow assert "Inject and verify lifecycle preflight runtime wiring" in workflow assert 'echo "CRYPTO_LIFECYCLE_PREFLIGHT_ROOT=${CRYPTO_LIFECYCLE_PREFLIGHT_ROOT}" >> "$GITHUB_ENV"' in workflow + assert 'CRYPTO_LIFECYCLE_EXPECTED_END_DATE=${completed_date}' in workflow + assert 'end_date=expected_end_date' in workflow assert "runner = build_backtest_runner()" in workflow