From 9ee7f4e8c954f27d298f152fd7bfd45ce670255e Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 28 May 2026 19:09:42 +0800 Subject: [PATCH] Complete shadow signal notification workflow --- .github/workflows/dispatch_shadow_signal.yml | 58 +++--- README.md | 13 +- docs/architecture.md | 4 + scripts/post_shadow_signal_request.py | 181 +++++++++++++++++++ tests/test_post_shadow_signal_request.py | 89 +++++++++ 5 files changed, 321 insertions(+), 24 deletions(-) create mode 100644 scripts/post_shadow_signal_request.py create mode 100644 tests/test_post_shadow_signal_request.py diff --git a/.github/workflows/dispatch_shadow_signal.yml b/.github/workflows/dispatch_shadow_signal.yml index 31f5c12..881b714 100644 --- a/.github/workflows/dispatch_shadow_signal.yml +++ b/.github/workflows/dispatch_shadow_signal.yml @@ -1,6 +1,10 @@ name: Long Horizon Shadow Signal on: + schedule: + # Weekly after Friday US market close. This remains shadow-only and does not + # place trades. + - cron: "30 2 * * 6" workflow_dispatch: inputs: provider: @@ -29,8 +33,9 @@ jobs: env: BRIDGE_REPOSITORY: ${{ vars.SELFHOSTED_CODEX_REVIEW_REPOSITORY || 'QuantStrategyLab/CodexAuditBridge' }} BRIDGE_TASK: long_horizon_signal_shadow - BRIDGE_PROVIDER: ${{ inputs.provider || 'auto' }} - SOURCE_REF: ${{ inputs.source_ref || 'main' }} + BRIDGE_PROVIDER: ${{ github.event.inputs.provider || 'auto' }} + SOURCE_REF: ${{ github.event.inputs.source_ref || 'main' }} + SHADOW_SIGNAL_LABEL: long-horizon-shadow steps: - uses: actions/checkout@v6 @@ -41,6 +46,31 @@ jobs: - name: Validate existing latest signal if present run: python scripts/validate_latest_signal.py --allow-missing + - name: Create or update shadow signal issue + id: shadow_issue + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + python scripts/post_shadow_signal_request.py \ + --repo "${GITHUB_REPOSITORY}" \ + --source-ref "${SOURCE_REF}" \ + --provider "${BRIDGE_PROVIDER}" \ + --bridge-repository "${BRIDGE_REPOSITORY}" \ + --label "${SHADOW_SIGNAL_LABEL}" + + - name: Append shadow signal job summary + run: | + { + echo "## Long-horizon shadow signal" + echo "" + echo "- Issue: [${{ steps.shadow_issue.outputs.issue_title }}](${{ steps.shadow_issue.outputs.issue_url }})" + echo "- Issue action: \`${{ steps.shadow_issue.outputs.issue_action }}\`" + echo "- Source ref: \`${SOURCE_REF}\`" + echo "- Provider: \`${BRIDGE_PROVIDER}\`" + echo "- Bridge: \`${BRIDGE_REPOSITORY}\`" + echo "- Mode: \`shadow\`" + } >> "$GITHUB_STEP_SUMMARY" + - name: Detect GitHub App Credentials id: app_credentials env: @@ -72,6 +102,7 @@ jobs: GH_TOKEN: ${{ github.token }} APP_TOKEN: ${{ steps.bridge_app_token.outputs.token }} CODEX_AUDIT_DISPATCH_TOKEN: ${{ secrets.CODEX_AUDIT_DISPATCH_TOKEN }} + ISSUE_NUMBER: ${{ steps.shadow_issue.outputs.issue_number }} run: | python - <<'PY' import json @@ -102,30 +133,11 @@ jobs: body = response.read().decode("utf-8") return response.status, json.loads(body) if body else None - issue_body = "\n".join([ - "## Long-Horizon Shadow Signal Request", - "", - f"- Source ref: `{os.environ['SOURCE_REF']}`", - "- Mode: `shadow`", - "- Required output: `data/output/latest_signal.json`", - "- AI output must not place orders or change live strategy rules.", - "", - "## Context", - "", - "Use repository examples and any committed context bundles as evidence.", - "If evidence is insufficient, report findings and do not edit artifacts.", - ]) - _, issue = request( - "POST", - f"https://api.github.com/repos/{repo}/issues", - {"title": "Long-horizon AI shadow signal review", "body": issue_body}, - ) - dispatch_payload = { "ref": "main", "inputs": { "source_repo": repo, - "issue_number": str(issue["number"]), + "issue_number": os.environ["ISSUE_NUMBER"], "source_ref": os.environ["SOURCE_REF"], "mode": "review_and_fix", "provider": os.environ["BRIDGE_PROVIDER"], @@ -141,5 +153,5 @@ jobs: ) if status not in (201, 204): raise RuntimeError(f"unexpected dispatch status: {status}") - print(f"Created issue #{issue['number']} and dispatched {bridge_repo}") + print(f"Dispatched {bridge_repo} for issue #{os.environ['ISSUE_NUMBER']}") PY diff --git a/README.md b/README.md index d2a002d..1924b42 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ This repo does not own: ## Operating Model -1. A workflow creates a long-horizon shadow-signal issue. +1. A weekly workflow creates or updates a dated long-horizon shadow-signal issue. 2. The issue is dispatched to `QuantStrategyLab/CodexAuditBridge` with task `long_horizon_signal_shadow`. 3. `CodexAuditBridge` tries self-hosted Codex first and uses its own OpenAI or @@ -57,6 +57,17 @@ Configured non-secret variables: - `SELFHOSTED_CODEX_REVIEW_PROVIDER=auto` - `CROSS_REPO_GITHUB_APP_ID=3250578` +## Notification Policy + +The GitHub issue created by `.github/workflows/dispatch_shadow_signal.yml` is the +initial operator notification channel. It is labeled `long-horizon-shadow`, +deduplicated by date, and receives the CodexAuditBridge result as comments or a +focused PR. + +Do not add Telegram, broker, or runtime plugin notifications at this stage. Those +belong downstream only after the signal graduates from shadow research to a +deterministic plugin contract. + ## Local Validation Validate the example artifact: diff --git a/docs/architecture.md b/docs/architecture.md index f5ab947..deee7b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,6 +17,8 @@ order routing. - `AiLongHorizonSignalPipelines` stores context examples, schema, validation, and shadow artifacts. - `CodexAuditBridge` owns provider routing and API keys. +- GitHub Issues are the first operator notification layer for scheduled shadow + signal runs. - `QuantStrategyPlugins` may later read promoted artifacts as sidecar context. - Platform repositories remain unchanged. @@ -28,6 +30,8 @@ order routing. execution mode. - Re-generating old AI judgments during replay instead of replaying stored artifacts. +- Sending runtime Telegram or broker-facing notifications directly from this + research repository before a deterministic plugin contract exists. ## Validation Strategy diff --git a/scripts/post_shadow_signal_request.py b/scripts/post_shadow_signal_request.py new file mode 100644 index 0000000..721ed6a --- /dev/null +++ b/scripts/post_shadow_signal_request.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + + +DEFAULT_API_URL = "https://api.github.com" +DEFAULT_LABEL = "long-horizon-shadow" + + +def github_request(method: str, url: str, token: str, payload: dict[str, Any] | None = None) -> Any: + data = None + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "ai-long-horizon-shadow-signal", + } + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + request = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(request, timeout=30) as response: + charset = response.headers.get_content_charset("utf-8") + raw = response.read().decode(charset) + return json.loads(raw) if raw else None + + +def ensure_label(api_url: str, repo: str, token: str, label: str) -> None: + encoded = urllib.parse.quote(label, safe="") + label_url = f"{api_url}/repos/{repo}/labels/{encoded}" + try: + github_request("GET", label_url, token) + except urllib.error.HTTPError as exc: + if exc.code != 404: + raise + github_request( + "POST", + f"{api_url}/repos/{repo}/labels", + token, + { + "name": label, + "color": "5319E7", + "description": "Long-horizon AI shadow signal request and review", + }, + ) + + +def find_existing_issue(api_url: str, repo: str, token: str, label: str, title: str) -> dict[str, Any] | None: + issues = github_request( + "GET", + f"{api_url}/repos/{repo}/issues?state=open&labels={urllib.parse.quote(label)}&per_page=100", + token, + ) + return next((issue for issue in issues if issue.get("title") == title), None) + + +def build_issue_title(as_of_date: str) -> str: + return f"Long-horizon AI shadow signal: {as_of_date}" + + +def build_issue_body(*, as_of_date: str, source_ref: str, provider: str, bridge_repository: str) -> str: + return "\n".join( + [ + "## Long-Horizon Shadow Signal Request", + "", + f"- As of date: `{as_of_date}`", + f"- Source ref: `{source_ref}`", + f"- Bridge repository: `{bridge_repository}`", + f"- Provider: `{provider}`", + "- Mode: `shadow`", + "- Required output: `data/output/latest_signal.json`", + "- Historical copy: `data/output/signal_history/YYYY-MM-DD.json` when evidence is sufficient", + "", + "## Operator Notification", + "", + "This issue is the operator-facing notification for the shadow signal run.", + "CodexAuditBridge should post its review result here and may open a focused PR", + "only for schema-valid shadow artifacts.", + "", + "## Boundaries", + "", + "- AI output must not place orders or change live strategy rules.", + "- Any downstream use must remain advisory until a deterministic policy consumes the artifact.", + "- If evidence is insufficient, report findings and leave artifacts unchanged.", + "", + "## Context", + "", + "Use committed examples, context bundles, and existing shadow artifacts as evidence.", + "Do not infer historical AI signals that were not generated point-in-time.", + ] + ) + + +def upsert_issue( + *, + api_url: str, + repo: str, + token: str, + title: str, + body: str, + label: str, +) -> tuple[str, int, str]: + ensure_label(api_url, repo, token, label) + payload = {"title": title, "body": body, "labels": [label]} + existing = find_existing_issue(api_url, repo, token, label, title) + if existing: + updated = github_request("PATCH", f"{api_url}/repos/{repo}/issues/{existing['number']}", token, payload) + return "updated", int(updated["number"]), str(updated["html_url"]) + created = github_request("POST", f"{api_url}/repos/{repo}/issues", token, payload) + return "created", int(created["number"]), str(created["html_url"]) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Create or update a long-horizon shadow signal issue.") + parser.add_argument("--repo", required=True) + parser.add_argument("--source-ref", default="main") + parser.add_argument("--provider", default="auto") + parser.add_argument("--bridge-repository", default="QuantStrategyLab/CodexAuditBridge") + parser.add_argument("--as-of-date", default=dt.date.today().isoformat()) + parser.add_argument("--label", default=DEFAULT_LABEL) + parser.add_argument("--api-url", default=DEFAULT_API_URL) + return parser.parse_args() + + +def write_outputs(*, action: str, issue_number: int, issue_url: str, title: str) -> None: + output = os.environ.get("GITHUB_OUTPUT") + if output: + with open(output, "a", encoding="utf-8") as handle: + print(f"issue_action={action}", file=handle) + print(f"issue_number={issue_number}", file=handle) + print(f"issue_url={issue_url}", file=handle) + print(f"issue_title={title}", file=handle) + print(f"issue_action={action}") + print(f"issue_number={issue_number}") + print(f"issue_url={issue_url}") + print(f"issue_title={title}") + + +def main() -> int: + args = parse_args() + token = os.environ.get("GITHUB_TOKEN") + if not token: + print("GITHUB_TOKEN is required", file=sys.stderr) + return 1 + + title = build_issue_title(args.as_of_date) + body = build_issue_body( + as_of_date=args.as_of_date, + source_ref=args.source_ref, + provider=args.provider, + bridge_repository=args.bridge_repository, + ) + try: + action, issue_number, issue_url = upsert_issue( + api_url=args.api_url.rstrip("/"), + repo=args.repo, + token=token, + title=title, + body=body, + label=args.label, + ) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + print(f"GitHub API request failed: {exc.code} {detail}", file=sys.stderr) + return 1 + + write_outputs(action=action, issue_number=issue_number, issue_url=issue_url, title=title) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_post_shadow_signal_request.py b/tests/test_post_shadow_signal_request.py new file mode 100644 index 0000000..8214b2b --- /dev/null +++ b/tests/test_post_shadow_signal_request.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import json + +from scripts import post_shadow_signal_request as shadow_issue + + +def test_build_issue_body_marks_notification_and_shadow_boundary() -> None: + body = shadow_issue.build_issue_body( + as_of_date="2026-05-29", + source_ref="main", + provider="auto", + bridge_repository="QuantStrategyLab/CodexAuditBridge", + ) + + assert "operator-facing notification" in body + assert "Mode: `shadow`" in body + assert "must not place orders" in body + assert "Do not infer historical AI signals" in body + + +def test_upsert_issue_updates_existing_issue(monkeypatch) -> None: + calls: list[tuple[str, str, dict | None]] = [] + + def fake_github_request(method, url, token, payload=None): + calls.append((method, url, payload)) + if method == "GET" and url.endswith("/labels/long-horizon-shadow"): + return {"name": "long-horizon-shadow"} + if method == "GET" and "/issues?state=open" in url: + return [ + { + "number": 7, + "title": "Long-horizon AI shadow signal: 2026-05-29", + "html_url": "https://github.test/issues/7", + } + ] + if method == "PATCH": + return {"number": 7, "html_url": "https://github.test/issues/7"} + raise AssertionError(f"unexpected request: {method} {url} {json.dumps(payload)}") + + monkeypatch.setattr(shadow_issue, "github_request", fake_github_request) + + action, issue_number, issue_url = shadow_issue.upsert_issue( + api_url="https://api.github.test", + repo="QuantStrategyLab/AiLongHorizonSignalPipelines", + token="token", + title="Long-horizon AI shadow signal: 2026-05-29", + body="body", + label="long-horizon-shadow", + ) + + assert action == "updated" + assert issue_number == 7 + assert issue_url == "https://github.test/issues/7" + assert calls[-1][0] == "PATCH" + + +def test_upsert_issue_creates_missing_label_and_issue(monkeypatch) -> None: + import urllib.error + + calls: list[tuple[str, str, dict | None]] = [] + + def fake_github_request(method, url, token, payload=None): + calls.append((method, url, payload)) + if method == "GET" and url.endswith("/labels/long-horizon-shadow"): + raise urllib.error.HTTPError(url, 404, "Not Found", hdrs=None, fp=None) + if method == "POST" and url.endswith("/labels"): + return {"name": payload["name"]} + if method == "GET" and "/issues?state=open" in url: + return [] + if method == "POST" and url.endswith("/issues"): + return {"number": 8, "html_url": "https://github.test/issues/8"} + raise AssertionError(f"unexpected request: {method} {url} {json.dumps(payload)}") + + monkeypatch.setattr(shadow_issue, "github_request", fake_github_request) + + action, issue_number, issue_url = shadow_issue.upsert_issue( + api_url="https://api.github.test", + repo="QuantStrategyLab/AiLongHorizonSignalPipelines", + token="token", + title="Long-horizon AI shadow signal: 2026-05-29", + body="body", + label="long-horizon-shadow", + ) + + assert action == "created" + assert issue_number == 8 + assert issue_url == "https://github.test/issues/8" + assert [call[0] for call in calls] == ["GET", "POST", "GET", "POST"]