From 54af9f3ad1217e540cab6d4e0ec04f662145fa90 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:59:52 +0800 Subject: [PATCH] feat(risk): gate CN combo and add lifecycle/thesis workflows Co-Authored-By: Claude Co-authored-by: Cursor --- .github/workflows/drift-check.yml | 52 +++++++ .github/workflows/evidence-gate.yml | 76 +++++++++++ .github/workflows/thesis-check.yml | 70 ++++++++++ docs/thesis-ledger.json | 28 ++++ scripts/gate_evidence_package.py | 129 ++++++++++++++++++ scripts/run_drift_github_issues.py | 42 ++++++ .../entrypoints/__init__.py | 2 +- 7 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/drift-check.yml create mode 100644 .github/workflows/evidence-gate.yml create mode 100644 .github/workflows/thesis-check.yml create mode 100644 docs/thesis-ledger.json create mode 100755 scripts/gate_evidence_package.py create mode 100755 scripts/run_drift_github_issues.py diff --git a/.github/workflows/drift-check.yml b/.github/workflows/drift-check.yml new file mode 100644 index 0000000..eb0cea2 --- /dev/null +++ b/.github/workflows/drift-check.yml @@ -0,0 +1,52 @@ +name: Drift Check + +# Daily drift detection for CN equity strategies. + +on: + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + drift: + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + STRATEGY_DOMAIN: cn_equity + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Checkout QuantPlatformKit + uses: actions/checkout@v6 + with: + repository: QuantStrategyLab/QuantPlatformKit + ref: main + path: external/QuantPlatformKit + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + set -euo pipefail + python -m pip install --upgrade pip + python -m pip install -e . pandas + python -m pip install --no-deps -e external/QuantPlatformKit + + - name: Run drift detection + run: quant-lifecycle drift --domain hk_equity --no-alerts + + - name: Sync drift alerts to GitHub Issues + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: python scripts/run_drift_github_issues.py diff --git a/.github/workflows/evidence-gate.yml b/.github/workflows/evidence-gate.yml new file mode 100644 index 0000000..9261593 --- /dev/null +++ b/.github/workflows/evidence-gate.yml @@ -0,0 +1,76 @@ +name: Evidence Gate + +# Block PRs that promote catalog status without a valid evidence package. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "src/**/catalog.py" + - "src/**/combo_manifests.py" + - "docs/evidence/**" + - "evidence/**" + +permissions: + contents: read + pull-requests: read + +concurrency: + group: evidence-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + gate: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Resolve QuantPlatformKit ref + id: quant-platform-kit-ref + run: | + set -euo pipefail + ref="main" + if [ -n "${GITHUB_HEAD_REF:-}" ]; then + case "${GITHUB_HEAD_REF}" in + dependabot/*) + ;; + *) + if git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/QuantPlatformKit.git "${GITHUB_HEAD_REF}" >/dev/null 2>&1; then + ref="${GITHUB_HEAD_REF}" + fi + ;; + esac + fi + echo "ref=${ref}" >> "$GITHUB_OUTPUT" + + - name: Checkout QuantPlatformKit + uses: actions/checkout@v6 + with: + repository: QuantStrategyLab/QuantPlatformKit + ref: ${{ steps.quant-platform-kit-ref.outputs.ref }} + path: external/QuantPlatformKit + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + set -euo pipefail + python -m pip install --upgrade pip + python -m pip install -e . pandas + python -m pip install --no-deps -e external/QuantPlatformKit + + - name: Evaluate Evidence Gate + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: python scripts/gate_evidence_package.py diff --git a/.github/workflows/thesis-check.yml b/.github/workflows/thesis-check.yml new file mode 100644 index 0000000..1ebaf13 --- /dev/null +++ b/.github/workflows/thesis-check.yml @@ -0,0 +1,70 @@ +name: Thesis Check + +on: + schedule: + - cron: "0 2 1 * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + thesis: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Validate thesis ledger + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + python - <<'PY' + import json, os, pathlib, subprocess, sys + path = pathlib.Path("docs/thesis-ledger.json") + if not path.exists(): + print("::error::docs/thesis-ledger.json missing") + sys.exit(1) + data = json.loads(path.read_text()) + strategies = data.get("strategies") or {} + if not strategies: + print("::error::no strategies in thesis ledger") + sys.exit(1) + failures = [] + warnings = [] + for name, row in strategies.items(): + fals = row.get("falsification_conditions") or [] + if not row.get("thesis"): + failures.append(f"{name}: missing thesis") + if len(fals) < 2: + failures.append(f"{name}: need >=2 falsification_conditions") + hit = row.get("hit_rate") + if hit is not None and float(hit) < 0.5: + warnings.append(f"{name}: hit_rate {hit} < 0.5") + if str(row.get("verdict", "")).lower() in {"falsified", "failed", "reject"}: + failures.append(f"{name}: verdict={row.get('verdict')}") + print(f"[thesis-check] strategies={len(strategies)} failures={len(failures)} warnings={len(warnings)}") + for item in failures: + print(f"::error::{item}") + for item in warnings: + print(f"::warning::{item}") + repo = os.environ.get("GITHUB_REPOSITORY", "") + token = os.environ.get("GH_TOKEN", "") + if failures and repo and token: + title = f"[thesis-check] falsification/validation failures ({len(failures)})" + body = "\n".join(f"- {x}" for x in failures) + # create issue (best-effort) + subprocess.run([ + "gh", "issue", "create", "--title", title, "--body", body + "\n\ncc @Pigbibi" + ], check=False) + sys.exit(1 if failures else 0) + PY diff --git a/docs/thesis-ledger.json b/docs/thesis-ledger.json new file mode 100644 index 0000000..2603c44 --- /dev/null +++ b/docs/thesis-ledger.json @@ -0,0 +1,28 @@ +{ + "strategies": { + "cn_industry_etf_rotation": { + "thesis": "A股行业ETF存在月度动量效应,排名靠前的行业在随后窗口有显著超额收益", + "falsification_conditions": [ + "滚动12个月 IC < 0.02", + "沪深300上涨月份策略超额为负连续3个月" + ], + "created": "2025-08-01", + "last_verified": "2026-07-08", + "verdict": "active", + "hit_rate": null, + "status_note": "runtime_enabled" + }, + "cn_industry_etf_rotation_aggressive": { + "thesis": "激进版行业动量在更高换手约束下仍能保留核心行业轮动 alpha", + "falsification_conditions": [ + "OOS 12个月 Sharpe < 0.3", + "相对标准版滚动超额连续6个月为负" + ], + "created": "2026-01-01", + "last_verified": "2026-07-08", + "verdict": "active", + "hit_rate": null, + "status_note": "live_candidate" + } + } +} diff --git a/scripts/gate_evidence_package.py b/scripts/gate_evidence_package.py new file mode 100755 index 0000000..7547c7c --- /dev/null +++ b/scripts/gate_evidence_package.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""PR gate: require a valid evidence package when catalog status is promoted.""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from pathlib import Path + +GATE_STAGES = frozenset( + { + "ai_monitored_candidate", + "shadow_candidate", + "live_candidate", + "runtime_enabled", + } +) +STATUS_ADDED_RE = re.compile(r'^\+.*status="([^"]+)"') +EVIDENCE_SUFFIXES = {".json", ".toml"} + + +def _git_diff(base_ref: str) -> str: + result = subprocess.run( + ["git", "diff", f"origin/{base_ref}...HEAD", "--", "src"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + result = subprocess.run( + ["git", "diff", f"{base_ref}...HEAD", "--", "src"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout + + +def _promotion_detected(diff: str) -> bool: + if "status=" not in diff: + return False + return any(match.group(1) in GATE_STAGES for line in diff.splitlines() if (match := STATUS_ADDED_RE.match(line))) + + +def _evidence_paths_from_diff(diff: str) -> list[Path]: + paths: list[Path] = [] + for line in diff.splitlines(): + if not line.startswith("+++ b/"): + continue + candidate = Path(line[6:]) + if candidate.suffix.lower() not in EVIDENCE_SUFFIXES: + continue + if "evidence" in candidate.parts or candidate.parent.name == "evidence": + paths.append(candidate) + return paths + + +def _discover_evidence_files(diff: str) -> list[Path]: + discovered = _evidence_paths_from_diff(diff) + for folder in (Path("docs/evidence"), Path("evidence")): + if folder.is_dir(): + discovered.extend(path for path in folder.iterdir() if path.suffix.lower() in EVIDENCE_SUFFIXES) + explicit = os.environ.get("EVIDENCE_PACKAGE_PATH", "").strip() + if explicit: + discovered.append(Path(explicit)) + return sorted({path for path in discovered if path.exists()}) + + +def _validate_with_lifecycle(path: Path) -> tuple[bool, list[str]]: + from quant_platform_kit.strategy_lifecycle.evidence_gate import validate_evidence_package_file + + result = validate_evidence_package_file(path) + issues = list(result.issues) + return result.valid, issues + + +def _validate_with_promotion_standard(path: Path) -> tuple[bool, list[str]]: + script = Path("external/QuantPlatformKit/scripts/validate_strategy_evidence_package.py") + if not script.exists(): + return True, [] + result = subprocess.run( + [sys.executable, str(script), str(path)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return True, [] + issues = [line for line in result.stderr.splitlines() if line.strip()] + issues.extend(line for line in result.stdout.splitlines() if line.strip()) + return False, issues or ["promotion evidence package validation failed"] + + +def main() -> int: + base_ref = os.environ.get("GITHUB_BASE_REF", "main").strip() or "main" + diff = _git_diff(base_ref) + + if not _promotion_detected(diff): + print("[evidence-gate] No lifecycle status promotion detected; skipping validation") + return 0 + + evidence_files = _discover_evidence_files(diff) + if not evidence_files: + print( + "::error::Catalog status promotion detected but no evidence package file was found. " + "Add docs/evidence/.json with the 11 required artifacts.", + file=sys.stderr, + ) + return 1 + + failed = False + for path in evidence_files: + lifecycle_ok, lifecycle_issues = _validate_with_lifecycle(path) + standard_ok, standard_issues = _validate_with_promotion_standard(path) + if lifecycle_ok and standard_ok: + print(f"[evidence-gate] PASS {path}") + continue + failed = True + print(f"[evidence-gate] FAIL {path}", file=sys.stderr) + for issue in lifecycle_issues + standard_issues: + print(f" - {issue}", file=sys.stderr) + + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_drift_github_issues.py b/scripts/run_drift_github_issues.py new file mode 100755 index 0000000..830b33c --- /dev/null +++ b/scripts/run_drift_github_issues.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Create GitHub issues for drift alerts after quant-lifecycle drift detection.""" + +from __future__ import annotations + +import os +import sys + + +def main() -> int: + domain = os.environ.get("STRATEGY_DOMAIN", "").strip() + if not domain: + print("::error::STRATEGY_DOMAIN is required", file=sys.stderr) + return 1 + + repository = os.environ.get("GITHUB_REPOSITORY", "").strip() + if "/" in repository: + owner, repo = repository.split("/", 1) + os.environ.setdefault("CODEX_AUDIT_ORG", owner) + os.environ.setdefault("CODEX_AUDIT_ORCHESTRATOR_REPO", repo) + + from quant_platform_kit.strategy_lifecycle.codex_integration import create_issues_for_domain + from quant_platform_kit.strategy_lifecycle.drift_detector import run_drift_detection + + drifts = run_drift_detection(domain) + critical = sum(1 for item in drifts if getattr(getattr(item, "status", None), "value", "") == "critical") + review = sum(1 for item in drifts if getattr(getattr(item, "status", None), "value", "") == "review") + print(f"[drift-check] domain={domain} checked={len(drifts)} review={review} critical={critical}") + + results = create_issues_for_domain(domain, dry_run=False) + created = [item for item in results if item.get("issue_url")] + errors = [item for item in results if item.get("error")] + print(f"[drift-check] issues_created={len(created)} errors={len(errors)}") + for item in created: + print(f" - {item.get('issue_url')}") + for item in errors: + print(f"::warning::{item.get('title')}: {item.get('error')}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/cn_equity_strategies/entrypoints/__init__.py b/src/cn_equity_strategies/entrypoints/__init__.py index fc3c387..a21e3d3 100644 --- a/src/cn_equity_strategies/entrypoints/__init__.py +++ b/src/cn_equity_strategies/entrypoints/__init__.py @@ -319,7 +319,7 @@ def evaluate_cn_dividend_quality_snapshot(ctx: StrategyContext) -> StrategyDecis def evaluate_cn_equity_combo(ctx: StrategyContext) -> StrategyDecision: from cn_equity_strategies.combo_entrypoints import evaluate_cn_equity_combo as _eval - return _eval(ctx) + return apply_risk_gate(_eval(ctx)) from cn_equity_strategies.combo_manifests import cn_equity_combo_manifest # noqa: E402