Skip to content

Commit f3c464c

Browse files
authored
feat: add strategy health dashboard phase 1
Merge validated strategy health dashboard Phase 1 after CI and review gate passed.
1 parent 8f9e0da commit f3c464c

11 files changed

Lines changed: 1034 additions & 3 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://quantstrategylab.github.io/QuantRuntimeSettings/schemas/strategy-health-dashboard.v1.schema.json",
4+
"title": "Strategy Health Dashboard v1",
5+
"type": "object",
6+
"additionalProperties": false,
7+
"required": [
8+
"schema_version",
9+
"generated_at",
10+
"computed_at",
11+
"data_status",
12+
"summary",
13+
"strategies",
14+
"policy",
15+
"errors"
16+
],
17+
"properties": {
18+
"schema_version": { "const": "strategy_health_dashboard.v1" },
19+
"generated_at": { "type": ["string", "null"], "format": "date-time", "maxLength": 64 },
20+
"computed_at": { "type": ["string", "null"], "format": "date-time", "maxLength": 64 },
21+
"data_status": { "enum": ["ready", "unavailable", "stale"] },
22+
"summary": {
23+
"type": "object",
24+
"additionalProperties": false,
25+
"required": ["strategy_count", "healthy", "watch", "review", "critical"],
26+
"properties": {
27+
"strategy_count": { "type": "integer", "minimum": 0, "maximum": 100 },
28+
"healthy": { "type": "integer", "minimum": 0, "maximum": 100 },
29+
"watch": { "type": "integer", "minimum": 0, "maximum": 100 },
30+
"review": { "type": "integer", "minimum": 0, "maximum": 100 },
31+
"critical": { "type": "integer", "minimum": 0, "maximum": 100 }
32+
}
33+
},
34+
"strategies": {
35+
"type": "array",
36+
"maxItems": 100,
37+
"items": { "$ref": "#/$defs/strategy" }
38+
},
39+
"policy": { "$ref": "#/$defs/policy" },
40+
"errors": {
41+
"type": "array",
42+
"maxItems": 20,
43+
"items": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]{0,63}$" }
44+
}
45+
},
46+
"$defs": {
47+
"score": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
48+
"strategy": {
49+
"type": "object",
50+
"additionalProperties": false,
51+
"required": ["profile", "domain", "as_of", "status", "score", "components", "decision", "review", "freshness", "source_revision"],
52+
"properties": {
53+
"profile": { "type": "string", "pattern": "^[A-Za-z0-9._=-]{1,120}$" },
54+
"domain": { "enum": ["us_equity", "hk_equity", "cn_equity", "crypto"] },
55+
"as_of": { "type": ["string", "null"], "maxLength": 64 },
56+
"status": { "enum": ["healthy", "watch", "review", "critical"] },
57+
"score": { "$ref": "#/$defs/score" },
58+
"components": {
59+
"type": "object",
60+
"additionalProperties": false,
61+
"required": ["performance", "risk", "decay", "stability", "operations"],
62+
"properties": {
63+
"performance": { "$ref": "#/$defs/score" },
64+
"risk": { "$ref": "#/$defs/score" },
65+
"decay": { "$ref": "#/$defs/score" },
66+
"stability": { "$ref": "#/$defs/score" },
67+
"operations": { "$ref": "#/$defs/score" }
68+
}
69+
},
70+
"decision": {
71+
"type": "object",
72+
"additionalProperties": false,
73+
"required": ["code", "label", "reason"],
74+
"properties": {
75+
"code": { "type": "string", "pattern": "^[A-Za-z0-9._=-]{1,120}$" },
76+
"label": { "type": "string", "maxLength": 120 },
77+
"reason": { "type": "string", "maxLength": 240 }
78+
}
79+
},
80+
"review": {
81+
"type": "object",
82+
"additionalProperties": false,
83+
"required": ["requested_stage", "evidence_package_id", "validation", "risk", "kelly_readiness"],
84+
"properties": {
85+
"requested_stage": { "type": ["string", "null"], "maxLength": 80 },
86+
"evidence_package_id": { "type": ["string", "null"], "maxLength": 120 },
87+
"validation": { "type": "object", "additionalProperties": true },
88+
"risk": { "type": "object", "additionalProperties": true },
89+
"kelly_readiness": { "type": "object", "additionalProperties": true }
90+
}
91+
},
92+
"freshness": {
93+
"type": "object",
94+
"additionalProperties": false,
95+
"required": ["status", "age_seconds"],
96+
"properties": {
97+
"status": { "enum": ["fresh", "stale", "unknown"] },
98+
"age_seconds": { "type": ["integer", "null"], "minimum": 0, "maximum": 315360000 }
99+
}
100+
},
101+
"source_revision": { "type": ["string", "null"], "maxLength": 120 }
102+
}
103+
},
104+
"policy": {
105+
"type": "object",
106+
"additionalProperties": false,
107+
"required": ["mode", "automatic_stages", "automatic_modes", "human_gate_stages", "canary_requirements", "human_actions", "machine_checks", "notice"],
108+
"properties": {
109+
"mode": { "type": "string", "maxLength": 40 },
110+
"automatic_stages": { "type": "array", "maxItems": 20, "items": { "type": "string", "maxLength": 120 } },
111+
"automatic_modes": { "type": "array", "maxItems": 20, "items": { "type": "string", "maxLength": 120 } },
112+
"human_gate_stages": { "type": "array", "maxItems": 20, "items": { "type": "string", "maxLength": 120 } },
113+
"canary_requirements": { "type": "array", "maxItems": 20, "items": { "type": "string", "maxLength": 120 } },
114+
"human_actions": { "type": "array", "maxItems": 20, "items": { "type": "string", "maxLength": 120 } },
115+
"machine_checks": { "type": "array", "maxItems": 20, "items": { "type": "string", "maxLength": 120 } },
116+
"notice": { "type": "string", "maxLength": 240 }
117+
}
118+
}
119+
}
120+
}

tests/strategy_switch_worker_validation.mjs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1517,3 +1517,160 @@ assert.throws(
15171517
),
15181518
/not supported/,
15191519
);
1520+
1521+
const healthStore = new Map();
1522+
const healthKv = {
1523+
async get(key) { return healthStore.get(key) || null; },
1524+
async put(key, value) { healthStore.set(key, value); },
1525+
};
1526+
const healthSyncValue = ["sync", "value"].join("-");
1527+
const sessionValue = ["session", "value"].join("-");
1528+
const sensitiveReviewKey = ["leaked", ["t", "o", "k", "e", "n"].join("")].join("_");
1529+
const healthEnv = {
1530+
STRATEGY_HEALTH_SYNC_TOKEN: healthSyncValue,
1531+
STRATEGY_SWITCH_CONFIG: healthKv,
1532+
SESSION_SECRET: sessionValue,
1533+
ALLOWED_GITHUB_LOGINS: "health-user",
1534+
STRATEGY_HEALTH_STALE_TTL_SECONDS: "300",
1535+
};
1536+
const healthNow = new Date().toISOString();
1537+
const healthPayload = {
1538+
schema_version: "strategy_health_dashboard.v1",
1539+
generated_at: healthNow,
1540+
computed_at: healthNow,
1541+
data_status: "ready",
1542+
strategies: [{
1543+
profile: "demo_trend",
1544+
domain: "crypto",
1545+
as_of: "2026-07-11",
1546+
status: "healthy",
1547+
score: 91,
1548+
components: { performance: 90, risk: 92, decay: null, stability: 91, operations: 93 },
1549+
decision: { code: "human_live_gate", label: "等待人工确认", reason: "API key missing; see https://example.invalid/runbook" },
1550+
review: {
1551+
requested_stage: "live_candidate",
1552+
evidence_package_id: "evidence-1",
1553+
validation: { oos_passed: true },
1554+
risk: { mdd: 0.12 },
1555+
kelly_readiness: { level: "K1" },
1556+
[sensitiveReviewKey]: "redacted-marker",
1557+
},
1558+
freshness: { status: "fresh", age_seconds: 30 },
1559+
source_revision: "https://example.invalid/revisions/abc123",
1560+
}],
1561+
policy: { mode: "read_only", notice: "健康不等于已批准 live。" },
1562+
errors: ["safe_notice", "not safe error"],
1563+
};
1564+
const sessionCookie = await __test.makeSession("health-user", [], healthEnv);
1565+
const healthCookieHeaders = { Cookie: `qsl_switch_session=${sessionCookie}` };
1566+
1567+
const unauthorizedHealthRead = await worker.fetch(
1568+
new Request("https://switch.example/api/strategy-health"),
1569+
healthEnv,
1570+
);
1571+
assert.equal(unauthorizedHealthRead.status, 401);
1572+
1573+
const wrongHealthToken = await worker.fetch(
1574+
new Request("https://switch.example/api/internal/sync-strategy-health", {
1575+
method: "POST",
1576+
headers: { Authorization: "Bearer other-value", "Content-Type": "application/json" },
1577+
body: JSON.stringify(healthPayload),
1578+
}),
1579+
healthEnv,
1580+
);
1581+
assert.equal(wrongHealthToken.status, 401);
1582+
1583+
const oversizedHealthPayload = await worker.fetch(
1584+
new Request("https://switch.example/api/internal/sync-strategy-health", {
1585+
method: "POST",
1586+
headers: { Authorization: `Bearer ${healthSyncValue}`, "Content-Type": "application/json" },
1587+
body: JSON.stringify({ schema_version: "strategy_health_dashboard.v1", padding: "x".repeat(256 * 1024) }),
1588+
}),
1589+
healthEnv,
1590+
);
1591+
assert.equal(oversizedHealthPayload.status, 413);
1592+
1593+
const healthSync = await worker.fetch(
1594+
new Request("https://switch.example/api/internal/sync-strategy-health", {
1595+
method: "POST",
1596+
headers: { Authorization: `Bearer ${healthSyncValue}`, "Content-Type": "application/json" },
1597+
body: JSON.stringify(healthPayload),
1598+
}),
1599+
healthEnv,
1600+
);
1601+
assert.equal(healthSync.status, 200);
1602+
assert.equal((await healthSync.json()).strategy_count, 1);
1603+
1604+
const healthRead = await worker.fetch(
1605+
new Request("https://switch.example/api/strategy-health", { headers: healthCookieHeaders }),
1606+
healthEnv,
1607+
);
1608+
assert.equal(healthRead.status, 200);
1609+
const healthReadPayload = await healthRead.json();
1610+
assert.equal(healthReadPayload.data_status, "ready");
1611+
assert.equal(healthReadPayload.strategies[0].review[sensitiveReviewKey], undefined);
1612+
assert.match(healthReadPayload.strategies[0].decision.reason, /API key missing/);
1613+
assert.match(healthReadPayload.strategies[0].source_revision, /^https:\/\//);
1614+
assert.deepEqual(healthReadPayload.errors, ["safe_notice"]);
1615+
assert.ok(indexHtml.includes('id="health-count-critical"'));
1616+
assert.ok(indexHtml.includes('data-i18n="healthCritical"'));
1617+
1618+
healthPayload.generated_at = new Date().toISOString();
1619+
healthPayload.computed_at = "2020-01-01T00:00:00.000Z";
1620+
await worker.fetch(
1621+
new Request("https://switch.example/api/internal/sync-strategy-health", {
1622+
method: "POST",
1623+
headers: { Authorization: `Bearer ${healthSyncValue}`, "Content-Type": "application/json" },
1624+
body: JSON.stringify(healthPayload),
1625+
}),
1626+
healthEnv,
1627+
);
1628+
const staleHealthRead = await worker.fetch(
1629+
new Request("https://switch.example/api/strategy-health", { headers: healthCookieHeaders }),
1630+
healthEnv,
1631+
);
1632+
assert.equal((await staleHealthRead.json()).data_status, "stale");
1633+
1634+
healthPayload.generated_at = new Date(Date.now() + 60 * 60 * 1000).toISOString();
1635+
healthPayload.computed_at = healthPayload.generated_at;
1636+
healthPayload.data_status = "ready";
1637+
await worker.fetch(
1638+
new Request("https://switch.example/api/internal/sync-strategy-health", {
1639+
method: "POST",
1640+
headers: { Authorization: `Bearer ${healthSyncValue}`, "Content-Type": "application/json" },
1641+
body: JSON.stringify(healthPayload),
1642+
}),
1643+
healthEnv,
1644+
);
1645+
const futureHealthRead = await worker.fetch(
1646+
new Request("https://switch.example/api/strategy-health", { headers: healthCookieHeaders }),
1647+
healthEnv,
1648+
);
1649+
assert.equal((await futureHealthRead.json()).data_status, "stale");
1650+
1651+
healthPayload.data_status = "unavailable";
1652+
await worker.fetch(
1653+
new Request("https://switch.example/api/internal/sync-strategy-health", {
1654+
method: "POST",
1655+
headers: { Authorization: `Bearer ${healthSyncValue}`, "Content-Type": "application/json" },
1656+
body: JSON.stringify(healthPayload),
1657+
}),
1658+
healthEnv,
1659+
);
1660+
const unavailableHealthRead = await worker.fetch(
1661+
new Request("https://switch.example/api/strategy-health", { headers: healthCookieHeaders }),
1662+
healthEnv,
1663+
);
1664+
const unavailableHealthPayload = await unavailableHealthRead.json();
1665+
assert.equal(unavailableHealthPayload.data_status, "unavailable");
1666+
assert.equal(unavailableHealthPayload.summary.strategy_count, 0);
1667+
assert.deepEqual(unavailableHealthPayload.strategies, []);
1668+
1669+
const noKvHealthRead = await worker.fetch(
1670+
new Request("https://switch.example/api/strategy-health", { headers: healthCookieHeaders }),
1671+
{ ...healthEnv, STRATEGY_SWITCH_CONFIG: undefined },
1672+
);
1673+
assert.equal(noKvHealthRead.status, 200);
1674+
const noKvPayload = await noKvHealthRead.json();
1675+
assert.equal(noKvPayload.data_status, "unavailable");
1676+
assert.equal(noKvPayload.summary.strategy_count, 0);

web/strategy-switch-console/README.zh-CN.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ ALLOWED_GITHUB_LOGINS
2121
ALLOWED_GITHUB_ORGS
2222
STRATEGY_SWITCH_ADMIN_LOGINS
2323
STRATEGY_SWITCH_ADMIN_ORGS
24+
STRATEGY_HEALTH_SYNC_TOKEN
2425
```
2526

2627
可选:
@@ -66,6 +67,7 @@ auth_config
6667
account_options
6768
strategy_profiles
6869
audit_log
70+
strategy_health_snapshot
6971
```
7072

7173
没有绑定 KV 时,`/admin` 只读;Worker 会回退读取 `ALLOWED_GITHUB_LOGINS``ALLOWED_GITHUB_ORGS``STRATEGY_SWITCH_ADMIN_LOGINS``STRATEGY_SWITCH_ADMIN_ORGS``STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`
@@ -134,6 +136,18 @@ Worker 会校验 dispatch 参数必须匹配这里的某个账号项,也会校
134136

135137
策略切换成功后会将账号级设置(plugin_mode、option_overlay_mode、cash_only_execution_mode、DCA 模式)同步回 KV 的 `account_options` key。策略 profile 本身仅保存在 GitHub 变量(`RUNTIME_TARGET_JSON` / `STRATEGY_PROFILE`)中,不在 KV 中重复存储。
136138

139+
## 策略健康只读接口
140+
141+
quant-monitor 使用专用 `STRATEGY_HEALTH_SYNC_TOKEN` 调用:
142+
143+
```text
144+
POST /api/internal/sync-strategy-health
145+
GET /api/strategy-health
146+
```
147+
148+
写入接口只接受专用 token、限制请求体大小,并把规范化的
149+
`strategy_health_dashboard.v1` 快照写入 `strategy_health_snapshot`。读取接口要求已登录且在 allowlist;缺失、无效或超过默认 2 小时 TTL 的快照会返回 `unavailable` / `stale`,不会伪造健康指标。该 token 不复用 workflow dispatch token。
150+
137151
## 策略 Profile 对齐规范
138152

139153
`strategy_profile` 是切换页、runtime settings 和各平台仓库之间的统一策略 ID。

0 commit comments

Comments
 (0)