Skip to content

Commit f2aa1d7

Browse files
authored
Merge pull request #113 from QuantStrategyLab/agent/audit-uesp-research-watcher
feat: consume verified P3 research metrics in watcher
2 parents 5f37f07 + dd34fb9 commit f2aa1d7

9 files changed

Lines changed: 427 additions & 36 deletions

.github/workflows/strategy_optimization_watcher.yml

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,25 @@ on:
1515
description: "JSON metrics payload path inside the source repository"
1616
required: false
1717
default: "data/output/strategy_metrics.json"
18+
workflow_file:
19+
description: "Trusted source workflow file that produced the metrics artifact"
20+
required: false
21+
default: "monthly_publish.yml"
22+
metrics_filename:
23+
description: "Sanitized metrics filename inside each source artifact"
24+
required: false
25+
default: "strategy_metrics.json"
1826
dry_run:
1927
description: "Do not create GitHub issues"
2028
required: false
2129
type: boolean
2230
default: true
2331
schedule:
24-
- cron: "17 3 * * *"
32+
- cron: "17 6 * * *"
2533

2634
permissions:
2735
contents: read
36+
actions: read
2837
issues: write
2938

3039
concurrency:
@@ -40,6 +49,8 @@ jobs:
4049
SOURCE_REPO: ${{ github.event.inputs.source_repo || vars.STRATEGY_WATCH_SOURCE_REPO || 'QuantStrategyLab/CryptoLivePoolPipelines' }}
4150
SOURCE_REF: ${{ github.event.inputs.source_ref || vars.STRATEGY_WATCH_SOURCE_REF || 'main' }}
4251
METRICS_PATH: ${{ github.event.inputs.metrics_path || vars.STRATEGY_WATCH_METRICS_PATH || 'data/output/strategy_metrics.json' }}
52+
SOURCE_WORKFLOW_FILE: ${{ github.event.inputs.workflow_file || vars.STRATEGY_WATCH_WORKFLOW_FILE || 'monthly_publish.yml' }}
53+
METRICS_FILENAME: ${{ github.event.inputs.metrics_filename || vars.STRATEGY_WATCH_METRICS_FILENAME || 'strategy_metrics.json' }}
4354
STRATEGY_WATCH_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && format('{0}', inputs.dry_run) || vars.STRATEGY_WATCH_DRY_RUN || 'true' }}
4455
ALLOWED_SOURCE_REPOS: ${{ vars.STRATEGY_WATCH_ALLOWED_SOURCE_REPOS || 'QuantStrategyLab/CryptoLivePoolPipelines' }}
4556
ALLOWED_SOURCE_REFS: ${{ vars.STRATEGY_WATCH_ALLOWED_SOURCE_REFS || 'main' }}
@@ -112,6 +123,7 @@ jobs:
112123
private-key: ${{ secrets.CROSS_REPO_GITHUB_APP_PRIVATE_KEY }}
113124
owner: ${{ steps.source_repo.outputs.owner }}
114125
repositories: ${{ steps.source_repo.outputs.repository }}
126+
permission-actions: read
115127
permission-contents: read
116128
permission-issues: write
117129

@@ -134,46 +146,83 @@ jobs:
134146
token: ${{ steps.source_app_token.outputs.token || github.token }}
135147
persist-credentials: false
136148

137-
- name: Fetch strategy metrics from latest Monthly Publish artifact
149+
- name: Fetch comparable strategy metrics from trusted source artifacts
138150
id: fetch-metrics
139151
env:
140152
GH_TOKEN: ${{ steps.source_app_token.outputs.token || github.token }}
141153
run: |
142154
set -euo pipefail
143-
echo "Looking for latest successful Monthly Publish run on ${SOURCE_REF} in ${SOURCE_REPO}..."
155+
if [[ ! "${SOURCE_WORKFLOW_FILE}" =~ ^[A-Za-z0-9_.-]+\.ya?ml$ ]]; then
156+
echo "Invalid SOURCE_WORKFLOW_FILE" >&2
157+
exit 1
158+
fi
159+
if [[ ! "${METRICS_FILENAME}" =~ ^[A-Za-z0-9_.-]+\.json$ ]]; then
160+
echo "Invalid METRICS_FILENAME" >&2
161+
exit 1
162+
fi
163+
echo "Looking for completed ${SOURCE_WORKFLOW_FILE} metrics on ${SOURCE_REF} in ${SOURCE_REPO}..."
144164
145-
WORKFLOW_FILE="monthly_publish.yml"
146165
# Restrict to the canonical branch so we never consume metrics from a
147166
# feature-branch or PR workflow run.
148-
RUN_ID=$(gh run list --repo "${SOURCE_REPO}" --workflow "${WORKFLOW_FILE}" --branch "${SOURCE_REF}" --status success --limit 1 --json databaseId --jq '.[0].databaseId // ""')
167+
mapfile -t RUN_IDS < <(gh run list --repo "${SOURCE_REPO}" --workflow "${SOURCE_WORKFLOW_FILE}" --branch "${SOURCE_REF}" --status success --limit 2 --json databaseId --jq '.[].databaseId')
149168
150-
if [ -z "${RUN_ID}" ]; then
151-
echo "No successful Monthly Publish run on ${SOURCE_REF} in ${SOURCE_REPO} — metrics not available yet"
169+
if [ "${#RUN_IDS[@]}" -eq 0 ]; then
170+
echo "No successful source run on ${SOURCE_REF} in ${SOURCE_REPO} — metrics not available yet"
152171
echo "downloaded=false" >> "$GITHUB_OUTPUT"
153172
exit 0
154173
fi
155174
156-
echo "Found run ${RUN_ID}, downloading artifacts..."
157175
mkdir -p source/data/output
158-
# Artifact download failures (expired artifacts, missing bundles) are
159-
# expected when the monthly publish hasn't run recently or doesn't
160-
# produce the file yet. Let the watcher script skip gracefully.
161-
if ! gh run download "${RUN_ID}" --repo "${SOURCE_REPO}" --dir source/data/output/_artifacts 2>/dev/null; then
162-
echo "Artifact download from run ${RUN_ID} failed (may have expired or not contain the expected bundle)"
163-
echo "downloaded=false" >> "$GITHUB_OUTPUT"
176+
if [ "${METRICS_FILENAME}" = "strategy_metrics.json" ]; then
177+
current_run_id="${RUN_IDS[0]}"
178+
if ! gh run download "${current_run_id}" --repo "${SOURCE_REPO}" --dir "source/data/output/_artifacts/${current_run_id}" 2>/dev/null; then
179+
echo "Artifact download from run ${current_run_id} failed"
180+
echo "downloaded=false" >> "$GITHUB_OUTPUT"
181+
exit 0
182+
fi
183+
metrics_file=$(find "source/data/output/_artifacts/${current_run_id}" -name "strategy_metrics.json" -type f -print -quit)
184+
if [ -z "${metrics_file}" ]; then
185+
echo "strategy_metrics.json not found in trusted source artifacts"
186+
echo "downloaded=false" >> "$GITHUB_OUTPUT"
187+
exit 0
188+
fi
189+
cp "${metrics_file}" "source/${METRICS_PATH}"
190+
echo "Downloaded compatible strategy_metrics.json from run ${current_run_id}"
191+
echo "downloaded=true" >> "$GITHUB_OUTPUT"
164192
exit 0
165193
fi
166194
167-
# The artifact name is dynamic; search for strategy_metrics.json in all downloaded artifacts
168-
METRICS_FILE=$(find source/data/output/_artifacts -name "strategy_metrics.json" -type f 2>/dev/null | head -1)
169-
if [ -n "${METRICS_FILE}" ]; then
170-
cp "${METRICS_FILE}" source/data/output/strategy_metrics.json
171-
echo "Downloaded strategy_metrics.json from run ${RUN_ID}"
172-
echo "downloaded=true" >> "$GITHUB_OUTPUT"
173-
else
174-
echo "strategy_metrics.json not found in Monthly Publish artifacts — source repo may not generate it yet"
195+
if [ "${#RUN_IDS[@]}" -lt 2 ]; then
196+
echo "Two completed observations are required before comparing strategy_performance.v2 metrics"
175197
echo "downloaded=false" >> "$GITHUB_OUTPUT"
198+
exit 0
199+
fi
200+
current_run_id="${RUN_IDS[0]}"
201+
baseline_run_id="${RUN_IDS[1]}"
202+
for run_id in "${current_run_id}" "${baseline_run_id}"; do
203+
if ! gh run download "${run_id}" --repo "${SOURCE_REPO}" --dir "source/data/output/_artifacts/${run_id}" 2>/dev/null; then
204+
echo "Artifact download from run ${run_id} failed"
205+
echo "downloaded=false" >> "$GITHUB_OUTPUT"
206+
exit 0
207+
fi
208+
done
209+
current_file=$(find "source/data/output/_artifacts/${current_run_id}" -name "${METRICS_FILENAME}" -type f -print -quit)
210+
baseline_file=$(find "source/data/output/_artifacts/${baseline_run_id}" -name "${METRICS_FILENAME}" -type f -print -quit)
211+
if [ -z "${current_file}" ] || [ -z "${baseline_file}" ]; then
212+
echo "Comparable ${METRICS_FILENAME} artifacts are not available yet"
213+
echo "downloaded=false" >> "$GITHUB_OUTPUT"
214+
exit 0
176215
fi
216+
python bridge/scripts/build_strategy_watcher_artifact_payload.py \
217+
--current "${current_file}" \
218+
--baseline "${baseline_file}" \
219+
--source-repository "${SOURCE_REPO}" \
220+
--workflow-file "${SOURCE_WORKFLOW_FILE}" \
221+
--current-run-id "${current_run_id}" \
222+
--baseline-run-id "${baseline_run_id}" \
223+
--output "source/${METRICS_PATH}"
224+
echo "Built comparable watcher payload from ${baseline_run_id} and ${current_run_id}"
225+
echo "downloaded=true" >> "$GITHUB_OUTPUT"
177226
178227
- name: Run Strategy Optimization Watcher
179228
env:

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ AIAuditBridge rejects absolute paths, `.git` paths, secret-like paths, and block
181181

182182
- Treat generated reports as evidence or review material, not automatic trading instructions.
183183
- Keep source traceability and artifact timestamps visible.
184-
- Require human review before using outputs in downstream strategy or platform changes.
184+
- Research outputs may feed only a separately validated, inactive no-order candidate; P6 live use still requires an explicit owner decision.
185185
- Keep credentials, private data, and external service tokens out of Git and logs.
186186

187187
## Repository layout

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ AIAuditBridge 会在本地写文件前拒绝绝对路径、`.git` 路径、疑
132132

133133
- 生成报告应作为证据或审阅材料,不是自动交易指令。
134134
- 保留来源可追溯性和 artifact 时间戳。
135-
- 输出用于下游策略或平台改动前,需要人工 review
135+
- 研究输出只能进入另行验证的、未激活且无订单的候选;P6 live 使用仍需要所有者明确决定
136136
- 凭据、私人数据和外部服务 token 不能提交到 Git,也不能写入日志。
137137

138138
## 仓库结构

docs/ai_autonomy_architecture.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ GitHub Codex App 是唯一 AI PR reviewer。AIAuditBridge 只保留月度审计
266266
2. 生成 evidence bundle;
267267
3. 只创建 optimization issue / task proposal;
268268
4. 经过 authority / registry gate 校验后再决定是否进入下一步;
269-
5. 后续如需执行,再由人工或 CI gate 接管
269+
5. 后续只可生成通过 `qsl.research_task.v1` 校验的离线、no-order 实验;P4/P5 仍由独立策略接管,P6 才需要所有者决定
270270

271271
当前首批实现的安全边界是:
272272

@@ -276,6 +276,8 @@ GitHub Codex App 是唯一 AI PR reviewer。AIAuditBridge 只保留月度审计
276276
- 不联网检索;
277277
- 不自动 merge / deploy。
278278

279+
研究 issue 不要求逐次人工点击:它本身没有运行、策略或资金影响。无人值守的后续动作只能是创建/验证一个固定上限的离线研究任务和未激活候选;任何试图越过 P3 进入 P4/P5/P6 的动作都必须被独立的 lifecycle gate 拒绝。
280+
279281
这样可以把策略优化先收敛为可审计、可回放的建议流,再逐步扩展到受控执行面。
280282

281283
Quant Monitor 的具体路由遵循同一边界:
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
#!/usr/bin/env python3
2+
"""Build one comparable watcher payload from two sanitized run artifacts."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import math
9+
import re
10+
from datetime import datetime
11+
from pathlib import Path
12+
from typing import Any, Mapping
13+
14+
15+
SCHEMA_VERSION = "strategy_performance.v2"
16+
METRICS_KIND = "performance"
17+
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
18+
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
19+
_REVISION = re.compile(r"^[0-9a-f]{40}$")
20+
_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
21+
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
22+
_METRICS = frozenset({"sharpe", "cagr", "calmar", "win_rate", "max_dd"})
23+
_FORBIDDEN_KEYS = re.compile(r"(?:secret|token|password|credential|api[_-]?key|order|fill|capital|account|broker|path)", re.IGNORECASE)
24+
_SAFE_RESEARCH_KEYS = frozenset({"no_order"})
25+
26+
27+
class StrategyWatcherArtifactError(ValueError):
28+
"""Raised when two artifacts cannot form a safe metric comparison."""
29+
30+
31+
def _exact_mapping(value: object, fields: frozenset[str], label: str) -> dict[str, Any]:
32+
if not isinstance(value, Mapping) or set(value) != fields:
33+
raise StrategyWatcherArtifactError(f"invalid {label}")
34+
return dict(value)
35+
36+
37+
def _timestamp(value: object, label: str) -> str:
38+
if not isinstance(value, str) or not _TIMESTAMP.fullmatch(value):
39+
raise StrategyWatcherArtifactError(f"invalid {label}")
40+
try:
41+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
42+
except ValueError as exc:
43+
raise StrategyWatcherArtifactError(f"invalid {label}") from exc
44+
if parsed.tzinfo is None or parsed.utcoffset() is None:
45+
raise StrategyWatcherArtifactError(f"invalid {label}")
46+
return value
47+
48+
49+
def _finite_metrics(value: object, label: str) -> dict[str, float]:
50+
metrics = _exact_mapping(value, _METRICS, label)
51+
result: dict[str, float] = {}
52+
for key, raw in metrics.items():
53+
if isinstance(raw, bool) or not isinstance(raw, (int, float)) or not math.isfinite(float(raw)):
54+
raise StrategyWatcherArtifactError(f"invalid {label}")
55+
result[key] = float(raw)
56+
return result
57+
58+
59+
def _sha256(value: object, label: str) -> str:
60+
if not isinstance(value, str) or not _SHA256.fullmatch(value):
61+
raise StrategyWatcherArtifactError(f"invalid {label}")
62+
return value
63+
64+
65+
def _revision(value: object, label: str) -> str:
66+
if not isinstance(value, str) or not _REVISION.fullmatch(value):
67+
raise StrategyWatcherArtifactError(f"invalid {label}")
68+
return value
69+
70+
71+
def _forbid_unsafe_keys(value: object) -> None:
72+
if isinstance(value, Mapping):
73+
for key, nested in value.items():
74+
if str(key) not in _SAFE_RESEARCH_KEYS and _FORBIDDEN_KEYS.search(str(key)):
75+
raise StrategyWatcherArtifactError("unsafe artifact field")
76+
_forbid_unsafe_keys(nested)
77+
elif isinstance(value, list):
78+
for nested in value:
79+
_forbid_unsafe_keys(nested)
80+
elif isinstance(value, float) and not math.isfinite(value):
81+
raise StrategyWatcherArtifactError("non-finite artifact value")
82+
83+
84+
def _performance_artifact(value: object, *, expected_repository: str) -> dict[str, Any]:
85+
artifact = _exact_mapping(
86+
value,
87+
frozenset(
88+
{
89+
"schema_version",
90+
"metrics_kind",
91+
"repository",
92+
"strategy_profile",
93+
"candidate_kind",
94+
"domain",
95+
"generated_at",
96+
"as_of",
97+
"current_metrics",
98+
"evidence",
99+
"lifecycle",
100+
"authority",
101+
}
102+
),
103+
"strategy performance artifact",
104+
)
105+
_forbid_unsafe_keys(artifact)
106+
if artifact["schema_version"] != SCHEMA_VERSION or artifact["metrics_kind"] != METRICS_KIND:
107+
raise StrategyWatcherArtifactError("unsupported strategy performance artifact")
108+
if artifact["repository"] != expected_repository:
109+
raise StrategyWatcherArtifactError("artifact repository does not match validated source repository")
110+
if not isinstance(artifact["strategy_profile"], str) or not re.fullmatch(r"[A-Za-z0-9._=-]{1,120}", artifact["strategy_profile"]):
111+
raise StrategyWatcherArtifactError("invalid strategy profile")
112+
if artifact["candidate_kind"] not in {"individual", "portfolio", "plugin"}:
113+
raise StrategyWatcherArtifactError("invalid candidate kind")
114+
if artifact["domain"] not in {"us_equity", "hk_equity", "cn_equity", "crypto"}:
115+
raise StrategyWatcherArtifactError("invalid domain")
116+
_timestamp(artifact["generated_at"], "generated_at")
117+
if not isinstance(artifact["as_of"], str) or not _DATE.fullmatch(artifact["as_of"]):
118+
raise StrategyWatcherArtifactError("invalid as_of")
119+
try:
120+
datetime.fromisoformat(artifact["as_of"])
121+
except ValueError as exc:
122+
raise StrategyWatcherArtifactError("invalid as_of") from exc
123+
artifact["current_metrics"] = _finite_metrics(artifact["current_metrics"], "current_metrics")
124+
evidence = _exact_mapping(
125+
artifact["evidence"],
126+
frozenset({"p1_input_digest", "p2_config_digest", "p3_evidence_id", "strategy_revision", "producer_revision"}),
127+
"evidence",
128+
)
129+
for key in ("p1_input_digest", "p2_config_digest", "p3_evidence_id"):
130+
_sha256(evidence[key], f"evidence.{key}")
131+
_revision(evidence["strategy_revision"], "evidence.strategy_revision")
132+
_revision(evidence["producer_revision"], "evidence.producer_revision")
133+
if artifact["lifecycle"] != {"stage": "P3", "status": "verified"}:
134+
raise StrategyWatcherArtifactError("artifact is not verified P3 evidence")
135+
if artifact["authority"] != {"research_only": True, "no_order": True, "p4_p5_p6_authorized": False}:
136+
raise StrategyWatcherArtifactError("artifact authority is not research-only")
137+
return artifact
138+
139+
140+
def build_strategy_watcher_artifact_payload(
141+
*,
142+
current_artifact: object,
143+
baseline_artifact: object,
144+
source_repository: object,
145+
workflow_file: object,
146+
current_run_id: object,
147+
baseline_run_id: object,
148+
) -> dict[str, object]:
149+
"""Join two completed P3 performance observations for an issue-only watcher."""
150+
if not isinstance(source_repository, str) or not _REPOSITORY.fullmatch(source_repository):
151+
raise StrategyWatcherArtifactError("invalid source repository")
152+
if not isinstance(workflow_file, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+\.ya?ml", workflow_file):
153+
raise StrategyWatcherArtifactError("invalid workflow file")
154+
if not isinstance(current_run_id, str) or not current_run_id.isdigit() or not isinstance(baseline_run_id, str) or not baseline_run_id.isdigit():
155+
raise StrategyWatcherArtifactError("invalid workflow run id")
156+
current = _performance_artifact(current_artifact, expected_repository=source_repository)
157+
baseline = _performance_artifact(baseline_artifact, expected_repository=source_repository)
158+
for key in ("strategy_profile", "candidate_kind", "domain"):
159+
if current[key] != baseline[key]:
160+
raise StrategyWatcherArtifactError("artifacts describe different research candidates")
161+
if _timestamp(baseline["generated_at"], "baseline generated_at") >= _timestamp(current["generated_at"], "current generated_at"):
162+
raise StrategyWatcherArtifactError("baseline must precede current observation")
163+
if baseline["as_of"] >= current["as_of"]:
164+
raise StrategyWatcherArtifactError("baseline must use an earlier data cutoff")
165+
return {
166+
"schema_version": SCHEMA_VERSION,
167+
"metrics_kind": METRICS_KIND,
168+
"repo": source_repository,
169+
"strategy_profile": current["strategy_profile"],
170+
"candidate_kind": current["candidate_kind"],
171+
"domain": current["domain"],
172+
"generated_at": current["generated_at"],
173+
"current_metrics": current["current_metrics"],
174+
"baseline_metrics": baseline["current_metrics"],
175+
"source": f"github_actions:{source_repository}:{workflow_file}:{baseline_run_id}-{current_run_id}",
176+
}
177+
178+
179+
def _arguments() -> argparse.Namespace:
180+
parser = argparse.ArgumentParser(description=__doc__)
181+
parser.add_argument("--current", required=True, type=Path)
182+
parser.add_argument("--baseline", required=True, type=Path)
183+
parser.add_argument("--source-repository", required=True)
184+
parser.add_argument("--workflow-file", required=True)
185+
parser.add_argument("--current-run-id", required=True)
186+
parser.add_argument("--baseline-run-id", required=True)
187+
parser.add_argument("--output", required=True, type=Path)
188+
return parser.parse_args()
189+
190+
191+
def _read_json(path: Path) -> object:
192+
try:
193+
return json.loads(path.read_bytes())
194+
except (OSError, TypeError, json.JSONDecodeError) as exc:
195+
raise StrategyWatcherArtifactError("invalid strategy performance artifact") from exc
196+
197+
198+
def main() -> None:
199+
args = _arguments()
200+
payload = build_strategy_watcher_artifact_payload(
201+
current_artifact=_read_json(args.current),
202+
baseline_artifact=_read_json(args.baseline),
203+
source_repository=args.source_repository,
204+
workflow_file=args.workflow_file,
205+
current_run_id=args.current_run_id,
206+
baseline_run_id=args.baseline_run_id,
207+
)
208+
args.output.write_text(json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False), encoding="utf-8")
209+
210+
211+
if __name__ == "__main__":
212+
main()

0 commit comments

Comments
 (0)