Skip to content

Commit ac2ebda

Browse files
authored
fix: sync trusted lifecycle artifacts for quant monitor (#105)
1 parent dece1e8 commit ac2ebda

9 files changed

Lines changed: 1378 additions & 11 deletions

ops/quant-monitor/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@ bash scripts/health_check.sh
1111
bash scripts/daily_briefing.sh
1212
```
1313

14+
`health_check.sh` 会先更新代码仓库,再从四个策略仓库选择最近 7 天内、
15+
`main` 分支定时或手动 workflow 的成功 `preflight_backtests` 任务生成的
16+
`lifecycle-preflight-*` 工件。工件经路径、文件类型、domain/profile、JSON/CSV
17+
contract 和大小限制校验后原子切换;代码仓库与 lifecycle 数据分别保存在:
18+
19+
- `PROJECTS_ROOT`:策略代码和 `QuantPlatformKit`
20+
- `QUANT_PROJECTS_ROOT`:只读收益矩阵镜像;
21+
- `LIFECYCLE_LOCAL_ROOT`:backtest、monitor snapshot 和 drift 状态。
22+
23+
任一 domain 缺少可信工件时只阻断该 domain,并写入
24+
`data/lifecycle-artifacts/status.json`;不会回退到演示或合成数据。
25+
1426
## Telegram(量化哨兵)
1527

1628
Token 从 GCP Secret `quant-sentinel-telegram-bot-token` 加载;**不要**把 token 或 chat id 写进 git。

ops/quant-monitor/scripts/common_env.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ set -euo pipefail
44

55
ROOT="${QUANT_MONITOR_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
66
AAB_ROOT="${AIAUDIT_BRIDGE_ROOT:-$(cd "$ROOT/../.." && pwd)}"
7-
PROJECTS_ROOT="${QUANT_PROJECTS_ROOT:-${PROJECTS_ROOT:-$HOME/Projects}}"
8-
QUANT_PROJECTS_ROOT="$PROJECTS_ROOT"
7+
PROJECTS_ROOT="${PROJECTS_ROOT:-$HOME/Projects}"
8+
QUANT_PROJECTS_ROOT="${QUANT_PROJECTS_ROOT:-$ROOT/data/lifecycle-projects}"
9+
LIFECYCLE_LOCAL_ROOT="${LIFECYCLE_LOCAL_ROOT:-$ROOT/data/lifecycle-store}"
910
QPK_ROOT="${QUANT_PLATFORM_KIT_ROOT:-$PROJECTS_ROOT/QuantPlatformKit}"
1011
VENV="${QUANT_MONITOR_VENV:-$ROOT/.venv}"
1112

@@ -14,6 +15,7 @@ export AIAUDIT_BRIDGE_ROOT="$AAB_ROOT"
1415
export QUANT_PLATFORM_KIT_ROOT="$QPK_ROOT"
1516
export PROJECTS_ROOT
1617
export QUANT_PROJECTS_ROOT
18+
export LIFECYCLE_LOCAL_ROOT
1719

1820
if [[ -x "$VENV/bin/python" ]]; then
1921
export PATH="$VENV/bin:$PATH"

ops/quant-monitor/scripts/health_check.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ ROOT="${QUANT_MONITOR_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
55
source "$ROOT/scripts/common_env.sh"
66

77
bash "$ROOT/scripts/sync_strategy_repos.sh"
8+
python3 "$ROOT/scripts/sync_lifecycle_artifacts.py"
89
python3 "$ROOT/scripts/health_cycle.py"

ops/quant-monitor/scripts/health_cycle.py

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
import hashlib
77
import json
88
import os
9+
import re
910
import subprocess
1011
import sys
1112
import tempfile
12-
from datetime import datetime, timezone
13+
from datetime import datetime, timedelta, timezone
1314
from pathlib import Path
1415
from typing import Any
1516

@@ -18,6 +19,9 @@
1819
DRIFT_REVIEW = 0.50
1920
DRIFT_CRITICAL = 0.75
2021
_ALERT_STATE_RELATIVE_PATH = Path("data/alert-state/health_cycle.json")
22+
_ARTIFACT_STATUS_RELATIVE_PATH = Path("data/lifecycle-artifacts/status.json")
23+
_ARTIFACT_STATUS_SCHEMA = "quant_monitor_lifecycle_artifact_status.v1"
24+
_SAFE_TOKEN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,99}$")
2125

2226

2327
def _collect_drift_results(run_drift_detection, *, domains=DOMAINS):
@@ -37,6 +41,111 @@ def _collect_drift_results(run_drift_detection, *, domains=DOMAINS):
3741
return results, errors
3842

3943

44+
def _refresh_and_collect_drift(run_monitor, run_drift_detection, *, domains=DOMAINS):
45+
snapshots: dict[str, list[Any]] = {}
46+
results: dict[str, list[Any]] = {}
47+
errors: list[dict[str, str]] = []
48+
for domain in domains:
49+
try:
50+
domain_snapshots = list(run_monitor(domain))
51+
if not domain_snapshots:
52+
raise RuntimeError("monitor produced no snapshots")
53+
snapshots[domain] = domain_snapshots
54+
except Exception as exc:
55+
errors.append(
56+
{
57+
"domain": domain,
58+
"code": "monitor_data_unavailable",
59+
"error_type": type(exc).__name__,
60+
}
61+
)
62+
continue
63+
try:
64+
results[domain] = list(run_drift_detection(domain))
65+
except Exception as exc:
66+
errors.append(
67+
{
68+
"domain": domain,
69+
"code": "drift_data_unavailable",
70+
"error_type": type(exc).__name__,
71+
}
72+
)
73+
return snapshots, results, errors
74+
75+
76+
def _artifact_status_error(
77+
domain: str,
78+
*,
79+
code: str = "artifact_sync_status_unavailable",
80+
error_type: str = "RuntimeError",
81+
) -> dict[str, str]:
82+
safe_code = code if _SAFE_TOKEN.fullmatch(code) else "artifact_sync_status_unavailable"
83+
safe_error_type = error_type if _SAFE_TOKEN.fullmatch(error_type) else "RuntimeError"
84+
return {"domain": domain, "code": safe_code, "error_type": safe_error_type}
85+
86+
87+
def _load_lifecycle_artifact_status(
88+
root: Path,
89+
*,
90+
domains=DOMAINS,
91+
now: datetime | None = None,
92+
max_age: timedelta = timedelta(hours=2),
93+
) -> tuple[tuple[str, ...], list[dict[str, str]]]:
94+
path = root / _ARTIFACT_STATUS_RELATIVE_PATH
95+
try:
96+
payload = json.loads(path.read_text(encoding="utf-8"))
97+
as_of = datetime.fromisoformat(str(payload["as_of"]).replace("Z", "+00:00"))
98+
if as_of.tzinfo is None:
99+
raise ValueError("status timestamp has no timezone")
100+
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
101+
age = current - as_of.astimezone(timezone.utc)
102+
domain_statuses = payload["domains"]
103+
if (
104+
not isinstance(payload, dict)
105+
or payload.get("schema_version") != _ARTIFACT_STATUS_SCHEMA
106+
or not isinstance(domain_statuses, dict)
107+
or age > max_age
108+
or age < -timedelta(minutes=5)
109+
):
110+
raise ValueError("artifact status is invalid or stale")
111+
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
112+
return (), [
113+
_artifact_status_error(domain, error_type=type(exc).__name__)
114+
for domain in domains
115+
]
116+
117+
ready: list[str] = []
118+
errors: list[dict[str, str]] = []
119+
for domain in domains:
120+
status = domain_statuses.get(domain)
121+
if not isinstance(status, dict):
122+
errors.append(_artifact_status_error(domain))
123+
continue
124+
profiles = status.get("profiles")
125+
valid_ready = (
126+
status.get("status") == "ready"
127+
and isinstance(status.get("artifact_id"), int)
128+
and status["artifact_id"] > 0
129+
and isinstance(status.get("run_id"), int)
130+
and status["run_id"] > 0
131+
and re.fullmatch(r"[0-9a-f]{40}", str(status.get("head_sha") or ""))
132+
and isinstance(profiles, list)
133+
and bool(profiles)
134+
and all(isinstance(profile, str) and profile for profile in profiles)
135+
)
136+
if valid_ready:
137+
ready.append(domain)
138+
continue
139+
errors.append(
140+
_artifact_status_error(
141+
domain,
142+
code=str(status.get("code") or "artifact_sync_status_unavailable"),
143+
error_type=str(status.get("error_type") or "RuntimeError"),
144+
)
145+
)
146+
return tuple(ready), errors
147+
148+
40149
def _alert_fingerprint(lines: list[str]) -> str:
41150
payload = "\n".join(sorted(str(line) for line in lines))
42151
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@@ -161,8 +270,15 @@ def main() -> int:
161270
from quant_platform_kit.strategy_lifecycle.codex_integration import create_issues_for_domain
162271
from quant_platform_kit.strategy_lifecycle.drift_detector import run_drift_detection
163272
from quant_platform_kit.strategy_lifecycle.health_dashboard import build_dashboard
273+
from quant_platform_kit.strategy_lifecycle.performance_monitor import run_monitor
164274

165-
drift_results, drift_errors = _collect_drift_results(run_drift_detection)
275+
ready_domains, artifact_errors = _load_lifecycle_artifact_status(root)
276+
snapshot_results, drift_results, lifecycle_errors = _refresh_and_collect_drift(
277+
run_monitor,
278+
run_drift_detection,
279+
domains=ready_domains,
280+
)
281+
data_errors = artifact_errors + lifecycle_errors
166282
build_dashboard(output_dir=str(dash_dir), output_format="json")
167283

168284
strategies: list[dict[str, Any]] = []
@@ -250,7 +366,7 @@ def main() -> int:
250366
)
251367

252368
data_error_lines: list[str] = []
253-
for error in drift_errors:
369+
for error in data_errors:
254370
data_error_lines.append(
255371
f"[{error['domain']}] {error['code']} ({error['error_type']})"
256372
)
@@ -281,7 +397,8 @@ def main() -> int:
281397
"telegram_alerts": notify_lines,
282398
"telegram_sent": telegram_sent,
283399
"duplicate_alert_suppressed": duplicate_alert_suppressed,
284-
"data_errors": drift_errors,
400+
"data_errors": data_errors,
401+
"snapshot_count": sum(len(rows) for rows in snapshot_results.values()),
285402
"issues_created": len([r for r in issue_results if r.get("issue_url")]),
286403
"ok": not notify_lines and not collector_payload_invalid,
287404
"collector_payload_valid": not collector_payload_invalid,

ops/quant-monitor/scripts/setup_vps_runtime.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@ python3 -m venv "$VENV"
3434
"$VENV/bin/pip" install numpy pandas google-cloud-storage
3535

3636
if ! command -v gh >/dev/null 2>&1; then
37-
echo "[setup] warning: gh CLI not installed; drift issues will be skipped" >&2
37+
echo "[setup] gh CLI is required for trusted lifecycle artifact synchronization" >&2
38+
exit 1
39+
fi
40+
if ! gh auth status >/dev/null 2>&1; then
41+
echo "[setup] gh CLI authentication is required for lifecycle artifacts" >&2
42+
exit 1
3843
fi
3944
if ! command -v gcloud >/dev/null 2>&1; then
4045
echo "[setup] warning: gcloud not installed; telegram env load may fail" >&2

0 commit comments

Comments
 (0)