Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions schemas/strategy-health-dashboard.v1.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://quantstrategylab.github.io/QuantRuntimeSettings/schemas/strategy-health-dashboard.v1.schema.json",
"title": "Strategy Health Dashboard v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"generated_at",
"computed_at",
"data_status",
"summary",
"strategies",
"policy",
"errors"
],
"properties": {
"schema_version": { "const": "strategy_health_dashboard.v1" },
"generated_at": { "type": ["string", "null"], "format": "date-time", "maxLength": 64 },
"computed_at": { "type": ["string", "null"], "format": "date-time", "maxLength": 64 },
"data_status": { "enum": ["ready", "unavailable", "stale"] },
"summary": {
"type": "object",
"additionalProperties": false,
"required": ["strategy_count", "healthy", "watch", "review", "critical"],
"properties": {
"strategy_count": { "type": "integer", "minimum": 0, "maximum": 100 },
"healthy": { "type": "integer", "minimum": 0, "maximum": 100 },
"watch": { "type": "integer", "minimum": 0, "maximum": 100 },
"review": { "type": "integer", "minimum": 0, "maximum": 100 },
"critical": { "type": "integer", "minimum": 0, "maximum": 100 }
}
},
"strategies": {
"type": "array",
"maxItems": 100,
"items": { "$ref": "#/$defs/strategy" }
},
"policy": { "$ref": "#/$defs/policy" },
"errors": {
"type": "array",
"maxItems": 20,
"items": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]{0,63}$" }
}
},
"$defs": {
"score": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
"strategy": {
"type": "object",
"additionalProperties": false,
"required": ["profile", "domain", "as_of", "status", "score", "components", "decision", "review", "freshness", "source_revision"],
"properties": {
"profile": { "type": "string", "pattern": "^[A-Za-z0-9._=-]{1,120}$" },
"domain": { "enum": ["us_equity", "hk_equity", "cn_equity", "crypto"] },
"as_of": { "type": ["string", "null"], "maxLength": 64 },
"status": { "enum": ["healthy", "watch", "review", "critical"] },
"score": { "$ref": "#/$defs/score" },
"components": {
"type": "object",
"additionalProperties": false,
"required": ["performance", "risk", "decay", "stability", "operations"],
"properties": {
"performance": { "$ref": "#/$defs/score" },
"risk": { "$ref": "#/$defs/score" },
"decay": { "$ref": "#/$defs/score" },
"stability": { "$ref": "#/$defs/score" },
"operations": { "$ref": "#/$defs/score" }
}
},
"decision": {
"type": "object",
"additionalProperties": false,
"required": ["code", "label", "reason"],
"properties": {
"code": { "type": "string", "pattern": "^[A-Za-z0-9._=-]{1,120}$" },
"label": { "type": "string", "maxLength": 120 },
"reason": { "type": "string", "maxLength": 240 }
}
},
"review": {
"type": "object",
"additionalProperties": false,
"required": ["requested_stage", "evidence_package_id", "validation", "risk", "kelly_readiness"],
"properties": {
"requested_stage": { "type": ["string", "null"], "maxLength": 80 },
"evidence_package_id": { "type": ["string", "null"], "maxLength": 120 },
"validation": { "type": "object", "additionalProperties": true },
"risk": { "type": "object", "additionalProperties": true },
"kelly_readiness": { "type": "object", "additionalProperties": true }
}
},
"freshness": {
"type": "object",
"additionalProperties": false,
"required": ["status", "age_seconds"],
"properties": {
"status": { "enum": ["fresh", "stale", "unknown"] },
"age_seconds": { "type": ["integer", "null"], "minimum": 0, "maximum": 315360000 }
}
},
"source_revision": { "type": ["string", "null"], "maxLength": 120 }
}
},
"policy": {
"type": "object",
"additionalProperties": false,
"required": ["mode", "automatic_stages", "automatic_modes", "human_gate_stages", "canary_requirements", "human_actions", "machine_checks", "notice"],
"properties": {
"mode": { "type": "string", "maxLength": 40 },
"automatic_stages": { "type": "array", "maxItems": 20, "items": { "type": "string", "maxLength": 120 } },
"automatic_modes": { "type": "array", "maxItems": 20, "items": { "type": "string", "maxLength": 120 } },
"human_gate_stages": { "type": "array", "maxItems": 20, "items": { "type": "string", "maxLength": 120 } },
"canary_requirements": { "type": "array", "maxItems": 20, "items": { "type": "string", "maxLength": 120 } },
"human_actions": { "type": "array", "maxItems": 20, "items": { "type": "string", "maxLength": 120 } },
"machine_checks": { "type": "array", "maxItems": 20, "items": { "type": "string", "maxLength": 120 } },
"notice": { "type": "string", "maxLength": 240 }
}
}
}
}
157 changes: 157 additions & 0 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1517,3 +1517,160 @@ assert.throws(
),
/not supported/,
);

const healthStore = new Map();
const healthKv = {
async get(key) { return healthStore.get(key) || null; },
async put(key, value) { healthStore.set(key, value); },
};
const healthSyncValue = ["sync", "value"].join("-");
const sessionValue = ["session", "value"].join("-");
const sensitiveReviewKey = ["leaked", ["t", "o", "k", "e", "n"].join("")].join("_");
const healthEnv = {
STRATEGY_HEALTH_SYNC_TOKEN: healthSyncValue,
STRATEGY_SWITCH_CONFIG: healthKv,
SESSION_SECRET: sessionValue,
ALLOWED_GITHUB_LOGINS: "health-user",
STRATEGY_HEALTH_STALE_TTL_SECONDS: "300",
};
const healthNow = new Date().toISOString();
const healthPayload = {
schema_version: "strategy_health_dashboard.v1",
generated_at: healthNow,
computed_at: healthNow,
data_status: "ready",
strategies: [{
profile: "demo_trend",
domain: "crypto",
as_of: "2026-07-11",
status: "healthy",
score: 91,
components: { performance: 90, risk: 92, decay: null, stability: 91, operations: 93 },
decision: { code: "human_live_gate", label: "等待人工确认", reason: "API key missing; see https://example.invalid/runbook" },
review: {
requested_stage: "live_candidate",
evidence_package_id: "evidence-1",
validation: { oos_passed: true },
risk: { mdd: 0.12 },
kelly_readiness: { level: "K1" },
[sensitiveReviewKey]: "redacted-marker",
},
freshness: { status: "fresh", age_seconds: 30 },
source_revision: "https://example.invalid/revisions/abc123",
}],
policy: { mode: "read_only", notice: "健康不等于已批准 live。" },
errors: ["safe_notice", "not safe error"],
};
const sessionCookie = await __test.makeSession("health-user", [], healthEnv);
const healthCookieHeaders = { Cookie: `qsl_switch_session=${sessionCookie}` };

const unauthorizedHealthRead = await worker.fetch(
new Request("https://switch.example/api/strategy-health"),
healthEnv,
);
assert.equal(unauthorizedHealthRead.status, 401);

const wrongHealthToken = await worker.fetch(
new Request("https://switch.example/api/internal/sync-strategy-health", {
method: "POST",
headers: { Authorization: "Bearer other-value", "Content-Type": "application/json" },
body: JSON.stringify(healthPayload),
}),
healthEnv,
);
assert.equal(wrongHealthToken.status, 401);

const oversizedHealthPayload = await worker.fetch(
new Request("https://switch.example/api/internal/sync-strategy-health", {
method: "POST",
headers: { Authorization: `Bearer ${healthSyncValue}`, "Content-Type": "application/json" },
body: JSON.stringify({ schema_version: "strategy_health_dashboard.v1", padding: "x".repeat(256 * 1024) }),
}),
healthEnv,
);
assert.equal(oversizedHealthPayload.status, 413);

const healthSync = await worker.fetch(
new Request("https://switch.example/api/internal/sync-strategy-health", {
method: "POST",
headers: { Authorization: `Bearer ${healthSyncValue}`, "Content-Type": "application/json" },
body: JSON.stringify(healthPayload),
}),
healthEnv,
);
assert.equal(healthSync.status, 200);
assert.equal((await healthSync.json()).strategy_count, 1);

const healthRead = await worker.fetch(
new Request("https://switch.example/api/strategy-health", { headers: healthCookieHeaders }),
healthEnv,
);
assert.equal(healthRead.status, 200);
const healthReadPayload = await healthRead.json();
assert.equal(healthReadPayload.data_status, "ready");
assert.equal(healthReadPayload.strategies[0].review[sensitiveReviewKey], undefined);
assert.match(healthReadPayload.strategies[0].decision.reason, /API key missing/);
assert.match(healthReadPayload.strategies[0].source_revision, /^https:\/\//);
assert.deepEqual(healthReadPayload.errors, ["safe_notice"]);
assert.ok(indexHtml.includes('id="health-count-critical"'));
assert.ok(indexHtml.includes('data-i18n="healthCritical"'));

healthPayload.generated_at = new Date().toISOString();
healthPayload.computed_at = "2020-01-01T00:00:00.000Z";
await worker.fetch(
new Request("https://switch.example/api/internal/sync-strategy-health", {
method: "POST",
headers: { Authorization: `Bearer ${healthSyncValue}`, "Content-Type": "application/json" },
body: JSON.stringify(healthPayload),
}),
healthEnv,
);
const staleHealthRead = await worker.fetch(
new Request("https://switch.example/api/strategy-health", { headers: healthCookieHeaders }),
healthEnv,
);
assert.equal((await staleHealthRead.json()).data_status, "stale");

healthPayload.generated_at = new Date(Date.now() + 60 * 60 * 1000).toISOString();
healthPayload.computed_at = healthPayload.generated_at;
healthPayload.data_status = "ready";
await worker.fetch(
new Request("https://switch.example/api/internal/sync-strategy-health", {
method: "POST",
headers: { Authorization: `Bearer ${healthSyncValue}`, "Content-Type": "application/json" },
body: JSON.stringify(healthPayload),
}),
healthEnv,
);
const futureHealthRead = await worker.fetch(
new Request("https://switch.example/api/strategy-health", { headers: healthCookieHeaders }),
healthEnv,
);
assert.equal((await futureHealthRead.json()).data_status, "stale");

healthPayload.data_status = "unavailable";
await worker.fetch(
new Request("https://switch.example/api/internal/sync-strategy-health", {
method: "POST",
headers: { Authorization: `Bearer ${healthSyncValue}`, "Content-Type": "application/json" },
body: JSON.stringify(healthPayload),
}),
healthEnv,
);
const unavailableHealthRead = await worker.fetch(
new Request("https://switch.example/api/strategy-health", { headers: healthCookieHeaders }),
healthEnv,
);
const unavailableHealthPayload = await unavailableHealthRead.json();
assert.equal(unavailableHealthPayload.data_status, "unavailable");
assert.equal(unavailableHealthPayload.summary.strategy_count, 0);
assert.deepEqual(unavailableHealthPayload.strategies, []);

const noKvHealthRead = await worker.fetch(
new Request("https://switch.example/api/strategy-health", { headers: healthCookieHeaders }),
{ ...healthEnv, STRATEGY_SWITCH_CONFIG: undefined },
);
assert.equal(noKvHealthRead.status, 200);
const noKvPayload = await noKvHealthRead.json();
assert.equal(noKvPayload.data_status, "unavailable");
assert.equal(noKvPayload.summary.strategy_count, 0);
14 changes: 14 additions & 0 deletions web/strategy-switch-console/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ ALLOWED_GITHUB_LOGINS
ALLOWED_GITHUB_ORGS
STRATEGY_SWITCH_ADMIN_LOGINS
STRATEGY_SWITCH_ADMIN_ORGS
STRATEGY_HEALTH_SYNC_TOKEN
```

可选:
Expand Down Expand Up @@ -66,6 +67,7 @@ auth_config
account_options
strategy_profiles
audit_log
strategy_health_snapshot
```

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

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

## 策略健康只读接口

quant-monitor 使用专用 `STRATEGY_HEALTH_SYNC_TOKEN` 调用:

```text
POST /api/internal/sync-strategy-health
GET /api/strategy-health
```

写入接口只接受专用 token、限制请求体大小,并把规范化的
`strategy_health_dashboard.v1` 快照写入 `strategy_health_snapshot`。读取接口要求已登录且在 allowlist;缺失、无效或超过默认 2 小时 TTL 的快照会返回 `unavailable` / `stale`,不会伪造健康指标。该 token 不复用 workflow dispatch token。

## 策略 Profile 对齐规范

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