diff --git a/.github/workflows/reusable-drift-check.yml b/.github/workflows/reusable-drift-check.yml index 3c98dca3..97685ad7 100644 --- a/.github/workflows/reusable-drift-check.yml +++ b/.github/workflows/reusable-drift-check.yml @@ -43,6 +43,10 @@ on: required: false type: string default: "" + strategy_profile: + required: false + type: string + default: "" secrets: codex_audit_service_url: required: false @@ -66,6 +70,7 @@ jobs: QUANT_PROJECTS_ROOT: ${{ github.workspace }}/external LIFECYCLE_PERFORMANCE_BUCKET: ${{ inputs.lifecycle_performance_bucket || vars.LIFECYCLE_PERFORMANCE_BUCKET || '' }} LIFECYCLE_LOCAL_ROOT: ${{ github.workspace }}/data/lifecycle_store + LIFECYCLE_STRATEGY_PROFILE: ${{ inputs.strategy_profile }} steps: - name: Validate trusted caller @@ -208,13 +213,34 @@ jobs: fi - name: Build lifecycle performance snapshots - run: quant-lifecycle monitor --domain ${{ inputs.strategy_domain }} + shell: bash + run: | + set -euo pipefail + lifecycle_args=() + if [ -n "${LIFECYCLE_STRATEGY_PROFILE:-}" ]; then + lifecycle_args+=(--strategy "${LIFECYCLE_STRATEGY_PROFILE}") + fi + quant-lifecycle monitor --domain ${{ inputs.strategy_domain }} "${lifecycle_args[@]}" - name: Validate lifecycle prerequisites - run: quant-lifecycle doctor --domain ${{ inputs.strategy_domain }} --require-snapshot --require-backtest --max-freshness-days 7 + shell: bash + run: | + set -euo pipefail + lifecycle_args=() + if [ -n "${LIFECYCLE_STRATEGY_PROFILE:-}" ]; then + lifecycle_args+=(--strategy "${LIFECYCLE_STRATEGY_PROFILE}") + fi + quant-lifecycle doctor --domain ${{ inputs.strategy_domain }} --require-snapshot --require-backtest --max-freshness-days 7 "${lifecycle_args[@]}" - name: Run drift detection - run: quant-lifecycle drift --domain ${{ inputs.strategy_domain }} --no-alerts + shell: bash + run: | + set -euo pipefail + lifecycle_args=() + if [ -n "${LIFECYCLE_STRATEGY_PROFILE:-}" ]; then + lifecycle_args+=(--strategy "${LIFECYCLE_STRATEGY_PROFILE}") + fi + quant-lifecycle drift --domain ${{ inputs.strategy_domain }} --no-alerts "${lifecycle_args[@]}" - name: Sync drift alerts to GitHub Issues env: diff --git a/src/quant_platform_kit/strategy_lifecycle/cli.py b/src/quant_platform_kit/strategy_lifecycle/cli.py index d30f5af9..53cbf04b 100644 --- a/src/quant_platform_kit/strategy_lifecycle/cli.py +++ b/src/quant_platform_kit/strategy_lifecycle/cli.py @@ -298,6 +298,7 @@ def _run_doctor(args: argparse.Namespace) -> int: require_backtest=args.require_backtest, require_drift=args.require_drift, max_freshness_days=args.max_freshness_days, + strategy_profile=args.strategy, ) _print( f"[doctor] profiles={result.get('profiles_discovered', 0)} " @@ -385,6 +386,7 @@ def build_parser() -> argparse.ArgumentParser: doctor.add_argument("--require-backtest", action="store_true") doctor.add_argument("--require-drift", action="store_true") doctor.add_argument("--max-freshness-days", type=int, default=None) + doctor.add_argument("--strategy", default=None) doctor.set_defaults(func=_run_doctor) lifecycle = subparsers.add_parser("lifecycle", help="Run the full lifecycle pipeline.") diff --git a/src/quant_platform_kit/strategy_lifecycle/doctor.py b/src/quant_platform_kit/strategy_lifecycle/doctor.py index cdd77f1e..0490052d 100644 --- a/src/quant_platform_kit/strategy_lifecycle/doctor.py +++ b/src/quant_platform_kit/strategy_lifecycle/doctor.py @@ -17,12 +17,26 @@ def doctor_lifecycle( require_backtest: bool = False, require_drift: bool = False, max_freshness_days: int | None = None, + strategy_profile: str | None = None, store: PerformanceStore | None = None, collector: ReturnCollector | None = None, ) -> dict[str, Any]: lifecycle_store = store or PerformanceStore.from_env() return_collector = collector or ReturnCollector(store=lifecycle_store) profiles = sorted(return_collector.collect(domain)) + requested_profile = str(strategy_profile or "").strip() + if requested_profile: + if requested_profile not in profiles: + return { + "ok": False, + "domain": domain, + "profiles_discovered": 0, + "issues": [ + f"{requested_profile}: no strategy return series discovered for domain={domain!r}." + ], + "profiles": [], + } + profiles = [requested_profile] if not profiles: return { "ok": False, @@ -74,6 +88,7 @@ def build_arg_parser() -> argparse.ArgumentParser: parser.add_argument("--require-backtest", action="store_true") parser.add_argument("--require-drift", action="store_true") parser.add_argument("--max-freshness-days", type=int, default=None) + parser.add_argument("--strategy", default=None) return parser @@ -85,6 +100,7 @@ def main(argv: Sequence[str] | None = None) -> int: require_backtest=args.require_backtest, require_drift=args.require_drift, max_freshness_days=args.max_freshness_days, + strategy_profile=args.strategy, ) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if result["ok"] else 1 diff --git a/tests/test_lifecycle_doctor.py b/tests/test_lifecycle_doctor.py index c9ba2ab1..5df38678 100644 --- a/tests/test_lifecycle_doctor.py +++ b/tests/test_lifecycle_doctor.py @@ -102,6 +102,23 @@ def test_doctor_passes_when_snapshot_and_backtest_exist(self) -> None: self.assertTrue(result["ok"]) self.assertEqual(result["issues"], []) + def test_doctor_can_require_one_profile(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = PerformanceStore(local_root=Path(tmp)) + collector = ReturnCollector(store=store) + collector.collect_from_live_runs = lambda domain: {"global_etf_rotation": pd.Series([0.01], index=pd.to_datetime(["2026-06-30"]))} # type: ignore[method-assign] + + result = doctor_lifecycle( + "us_equity", + strategy_profile="missing_profile", + store=store, + collector=collector, + ) + + self.assertFalse(result["ok"]) + self.assertEqual(result["profiles_discovered"], 0) + self.assertIn("missing_profile: no strategy return series discovered", result["issues"][0]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_reusable_drift_workflow.py b/tests/test_reusable_drift_workflow.py index 0bdc7312..36602782 100644 --- a/tests/test_reusable_drift_workflow.py +++ b/tests/test_reusable_drift_workflow.py @@ -22,6 +22,7 @@ def test_reusable_drift_workflow_enforces_lifecycle_preflight() -> None: assert "caller_event_name:" in workflow assert "caller_pr_head_repository:" in workflow assert "lifecycle_preflight_artifact:" in workflow + assert "strategy_profile:" in workflow assert "codex_audit_service_url:" in workflow assert 'python-version: ${{ inputs.python_version }}' in workflow assert "LIFECYCLE_PERFORMANCE_BUCKET: ${{ inputs.lifecycle_performance_bucket || vars.LIFECYCLE_PERFORMANCE_BUCKET || '' }}" in workflow @@ -33,12 +34,11 @@ def test_reusable_drift_workflow_enforces_lifecycle_preflight() -> None: assert "snapshot_repository mismatch for caller" in workflow assert "Fork pull_request callers are not trusted" in workflow assert "SNAPSHOT_REPOSITORY_TOKEN:" not in workflow - assert "quant-lifecycle monitor --domain ${{ inputs.strategy_domain }}" in workflow - assert ( - "quant-lifecycle doctor --domain ${{ inputs.strategy_domain }} --require-snapshot " - "--require-backtest --max-freshness-days 7" - ) in workflow - assert "quant-lifecycle drift --domain ${{ inputs.strategy_domain }} --no-alerts" in workflow + assert "LIFECYCLE_STRATEGY_PROFILE: ${{ inputs.strategy_profile }}" in workflow + assert 'lifecycle_args+=(--strategy "${LIFECYCLE_STRATEGY_PROFILE}")' in workflow + assert 'quant-lifecycle monitor --domain ${{ inputs.strategy_domain }} "${lifecycle_args[@]}"' in workflow + assert 'quant-lifecycle doctor --domain ${{ inputs.strategy_domain }} --require-snapshot --require-backtest --max-freshness-days 7 "${lifecycle_args[@]}"' in workflow + assert 'quant-lifecycle drift --domain ${{ inputs.strategy_domain }} --no-alerts "${lifecycle_args[@]}"' in workflow assert 'repository: ${{ inputs.snapshot_repository }}' in workflow assert 'ref: ${{ inputs.snapshot_repository_ref }}' in workflow assert 'path: ${{ inputs.snapshot_checkout_path }}' in workflow