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
94 changes: 94 additions & 0 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ assert.ok(indexHtml.includes('id="runtime-authority-status"'));
assert.ok(indexHtml.includes('id="control-plane-view-button"'));
assert.ok(indexHtml.includes('id="control-plane-view"'));
assert.ok(indexHtml.includes('id="control-plane-list"'));
assert.ok(indexHtml.includes('id="adaptive-selection-list"'));
assert.ok(indexHtml.includes('id="adaptive-selection-notice"'));
assert.ok(indexHtml.includes('data-i18n="planEyebrow"'));
assert.ok(indexHtml.includes('id="plan-check-authority"'));
assert.ok(indexHtml.includes('class="control-disclosure"'));
Expand All @@ -40,6 +42,7 @@ assert.ok(indexHtml.includes('window.__QSL_RUNTIME_AUTHORITY_STATUS__'));
assert.ok(indexHtml.includes('execution_metadata_is_runtime_authority'));
assert.ok(indexHtml.includes('P1–P3 non-live 数据获取仍需独立、精确的契约'));
assert.ok(indexHtml.includes('requestJson("/api/execution-evidence")'));
assert.ok(indexHtml.includes('requestJson("/api/adaptive-selection")'));
assert.equal(indexHtml.includes('missing_current_promotion_evidence_and_human_acceptance'), false);
assert.ok(indexHtml.includes('missing_current_promotion_evidence_and_preauthorized_autonomy_policy'));
assert.ok(indexHtml.includes(".switch-surface.summary-hidden"));
Expand Down Expand Up @@ -2088,6 +2091,97 @@ assert.deepEqual(sourceControlPayload.summary, { candidate_count: 1, deferred: 0
assert.deepEqual(sourceControlPayload.attention, { status: "research_only", reason_codes: [] });
assert.equal(sourceControlPayload.candidates[0].candidate_id, "tqqq_core_only_p2_v5");

const adaptiveSelectionSyncValue = ["adaptive", "selection", "sync"].join("-");
const adaptiveSelectionEnv = { ...controlEnv, ADAPTIVE_SELECTION_SYNC_TOKEN: adaptiveSelectionSyncValue };
const adaptiveSelectionCookie = await __test.makeSession("health-user", [], adaptiveSelectionEnv);
const adaptiveSelectionCookieHeaders = { Cookie: `qsl_switch_session=${adaptiveSelectionCookie}` };
const adaptiveSelectionSourcePayload = {
schema_version: "qsl.adaptive_selection_source_snapshot.v1",
source_id: "uesp.us_equity_combo_shadow",
generated_at: controlNow,
computed_at: controlNow,
data_status: "ready",
decision: {
schema: "qsl.selection_decision.v1",
decision_id: "shadow-us-equity-combo-001",
created_at: controlNow,
authority: "shadow_only",
no_order: true,
market_context: {
schema: "qsl.market_context_snapshot.v1",
as_of: "2026-08-28",
domain: "us_equity",
data_version: "trusted-prices-v1",
data_freshness_days: 0,
regime: "normal",
regime_confidence: 0.9,
factors: { momentum: 0.12 },
},
policy_id: "shadow-policy-v1",
recommended_strategy_profile: "us_equity_combo",
recommended_platform_id: "longbridge-paper",
candidates: [{
strategy_profile: "us_equity_combo",
release_digest: `sha256:${"a".repeat(64)}`,
selected_platform_id: "longbridge-paper",
score: 0.42,
risk_multiplier: 1,
accepted: true,
reasons: ["shadow_candidate_ranked"],
proposed_weight: 0,
}],
input_digest: "b".repeat(64),
},
errors: [],
};

const unauthorizedAdaptiveSelectionRead = await worker.fetch(
new Request("https://switch.example/api/adaptive-selection"),
adaptiveSelectionEnv,
);
assert.equal(unauthorizedAdaptiveSelectionRead.status, 401);
const wrongAdaptiveSelectionToken = await worker.fetch(
new Request("https://switch.example/api/internal/sync-adaptive-selection-source", {
method: "POST",
headers: { Authorization: `Bearer ${controlSyncValue}`, "Content-Type": "application/json" },
body: JSON.stringify(adaptiveSelectionSourcePayload),
}),
adaptiveSelectionEnv,
);
assert.equal(wrongAdaptiveSelectionToken.status, 401);
assert.throws(
() => __test.normalizeAdaptiveSelectionSourceSnapshot({
...adaptiveSelectionSourcePayload,
decision: {
...adaptiveSelectionSourcePayload.decision,
candidates: [{ ...adaptiveSelectionSourcePayload.decision.candidates[0], proposed_weight: 0.01 }],
},
}),
/proposed_weight must remain zero/,
);
const adaptiveSelectionSync = await worker.fetch(
new Request("https://switch.example/api/internal/sync-adaptive-selection-source", {
method: "POST",
headers: { Authorization: `Bearer ${adaptiveSelectionSyncValue}`, "Content-Type": "application/json" },
body: JSON.stringify(adaptiveSelectionSourcePayload),
}),
adaptiveSelectionEnv,
);
assert.equal(adaptiveSelectionSync.status, 200);
assert.equal((await adaptiveSelectionSync.json()).no_order, true);
const adaptiveSelectionRead = await worker.fetch(
new Request("https://switch.example/api/adaptive-selection", { headers: adaptiveSelectionCookieHeaders }),
adaptiveSelectionEnv,
);
assert.equal(adaptiveSelectionRead.status, 200);
const adaptiveSelectionPayload = await adaptiveSelectionRead.json();
assert.equal(adaptiveSelectionPayload.data_status, "ready");
assert.deepEqual(adaptiveSelectionPayload.summary, {
source_count: 1, decision_count: 1, candidate_count: 1, recommended_count: 1, rejected_candidate_count: 0,
});
assert.equal(adaptiveSelectionPayload.policy.no_order, true);
assert.equal(adaptiveSelectionPayload.selections[0].decision.recommended_strategy_profile, "us_equity_combo");

const ownerDecisionStore = new Map();
const ownerDecisionKv = {
async get(key) { return ownerDecisionStore.get(key) || null; },
Expand Down
19 changes: 19 additions & 0 deletions web/strategy-switch-console/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,25 @@ GET /api/control-plane

这个接口不是订单、paper、shadow 或 live API。`CONTROL_PLANE_SYNC_TOKEN` 必须与 OAuth、workflow dispatch、策略 root 和任何券商凭证完全分离。部署 workflow 会在 `runtime-strategy-switch` environment 提供该 secret 时,把它同步为 Worker secret;每个来源仓库需要保存同一值到其专用 GitHub Environment,URL 使用只读控制台的 `/api/internal/sync-control-plane-source`。

## M1 Shadow 建议只读接口

M1 把 P0 已生成的 `qsl.selection_decision.v1` 呈现给人工;它不创建策略、不会开始
Shadow runtime,也不会修改仓位。每个来源使用单独凭据提交:

```text
POST /api/internal/sync-adaptive-selection-source
GET /api/adaptive-selection
```

写入的外层契约是 `qsl.adaptive_selection_source_snapshot.v1`,其中的 decision 必须仍是
`authority=shadow_only`、`no_order=true`,且每个 `proposed_weight=0`。Worker 会拒绝非零
权重、非 Shadow authority、缺少完整输入摘要或格式不安全的字段。读取接口要求已登录
allowlist;默认 36 小时后快照标为 `stale`,不会被当作当前结论。

`ADAPTIVE_SELECTION_SYNC_TOKEN` 必须专用,不能复用 OAuth、策略切换、控制面、执行证据、
顾投、券商或账户凭据。顾投研究与 M1 的卡片也必须保持分开:顾投只能提出研究假设,不能
直接成为市场因子、策略基础分、插件风险缩放、平台健康、目标权重或订单输入。

## 网页人工审批(P6 决策意图)

当且仅当候选仍是**新鲜**的 P6 `owner_decision_required`、机器建议仍为 `owner_live_decision` 时,控制台会在“全局概览”显示人工决定区。只有管理员可以选择:批准受限试运行意图、保持暂停或退役候选。
Expand Down
137 changes: 137 additions & 0 deletions web/strategy-switch-console/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,17 @@
executionEvidenceDetail: "策略:{strategy} · 数据:{data} · 执行:{execution} · Shadow:{shadow} · Paper:{paper}",
executionEvidenceNoOrder: "固定边界:只读证据;不包含账户、订单、资金或 P6 实盘授权。",
executionEvidenceNext: "下一步",
adaptiveSelectionBoard: "按需查看 M1 Shadow 建议",
adaptiveSelectionLoginNotice: "登录后读取私有 Shadow 建议;缺失来源不会被补成策略或订单。",
adaptiveSelectionStaleNotice: "此 Shadow 建议已超出新鲜度窗口,仅保留为历史上下文。",
adaptiveSelectionUnavailableNotice: "还没有可用的 M1 Shadow 建议;页面保持 fail-closed 空状态。",
adaptiveSelectionUpstreamNotice: "Shadow 建议带有 {count} 个上游提示;页面不会推断策略、平台或订单。",
adaptiveSelectionEmpty: "暂无可展示的 Shadow 建议。",
adaptiveSelectionMeta: "{source} · {domain} · 市场截至 {asOf}",
adaptiveSelectionRecommended: "建议观察",
adaptiveSelectionNoCandidate: "无合格 Shadow 候选",
adaptiveSelectionReason: "原因:{reasons}",
adaptiveSelectionNoOrder: "固定边界:仅供人工查看,零建议权重,不改策略、平台、资金、运行状态或订单。",
researchTaskBoard: "按需查看研究任务队列",
researchTaskLoginNotice: "登录后读取私有研究任务索引;没有来源快照时不会展示虚构任务。",
researchTaskStaleNotice: "研究任务来源已超过允许的新鲜度窗口;任务保留为历史记录,但不作为当前工作指令。",
Expand Down Expand Up @@ -1118,6 +1129,17 @@
executionEvidenceDetail: "strategy: {strategy} · data: {data} · execution: {execution} · shadow: {shadow} · paper: {paper}",
executionEvidenceNoOrder: "Fixed boundary: read-only evidence; no account, order, funds, or P6 live authority.",
executionEvidenceNext: "NEXT",
adaptiveSelectionBoard: "View M1 Shadow suggestions on demand",
adaptiveSelectionLoginNotice: "Sign in to read private Shadow suggestions. Missing sources never imply a strategy or order.",
adaptiveSelectionStaleNotice: "This Shadow suggestion is beyond its freshness window and remains historical context only.",
adaptiveSelectionUnavailableNotice: "No usable M1 Shadow suggestion is available yet. This view remains fail-closed and empty.",
adaptiveSelectionUpstreamNotice: "Shadow suggestions include {count} upstream notice(s); no strategy, platform, or order is inferred.",
adaptiveSelectionEmpty: "No Shadow suggestion is available to display.",
adaptiveSelectionMeta: "{source} · {domain} · market as of {asOf}",
adaptiveSelectionRecommended: "Observe suggestion",
adaptiveSelectionNoCandidate: "No eligible Shadow candidate",
adaptiveSelectionReason: "Reasons: {reasons}",
adaptiveSelectionNoOrder: "Fixed boundary: human-readable only, zero proposed weight, no strategy, platform, funds, runtime, or order change.",
researchTaskBoard: "View research queue on demand",
researchTaskLoginNotice: "Sign in to read the private research task index. Missing source snapshots never imply a task.",
researchTaskStaleNotice: "The research task source is beyond its freshness window. It remains historical context, not a current instruction.",
Expand Down Expand Up @@ -1387,6 +1409,16 @@
candidates: [],
errors: [],
},
adaptiveSelection: {
payload: {
data_status: "unavailable",
computed_at: null,
summary: { source_count: 0, decision_count: 0, candidate_count: 0, recommended_count: 0, rejected_candidate_count: 0 },
selections: [],
policy: { authority: "shadow_only", no_order: true, execution_authority_granted: false },
errors: [],
},
},
executionEvidence: {
payload: {
data_status: "unavailable",
Expand Down Expand Up @@ -3688,6 +3720,89 @@
}
}

function normalizeAdaptiveSelectionPayload(payload) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("invalid adaptive selection payload");
const selections = Array.isArray(payload.selections) ? payload.selections : [];
return {
data_status: ["ready", "stale", "unavailable"].includes(payload.data_status) ? payload.data_status : "unavailable",
computed_at: payload.computed_at || null,
summary: payload.summary && typeof payload.summary === "object" ? payload.summary : {},
selections: selections.filter((item) => item && typeof item === "object"
&& typeof item.source_id === "string" && item.decision && typeof item.decision === "object"),
policy: payload.policy && typeof payload.policy === "object" ? payload.policy : {},
errors: Array.isArray(payload.errors) ? payload.errors : [],
};
}

function renderAdaptiveSelection() {
const payload = state.adaptiveSelection.payload;
const notice = el("adaptive-selection-notice");
if (!state.auth.allowed) {
notice.textContent = t("adaptiveSelectionLoginNotice");
} else if (payload.data_status === "stale") {
notice.textContent = t("adaptiveSelectionStaleNotice");
} else if (payload.data_status !== "ready") {
notice.textContent = t("adaptiveSelectionUnavailableNotice");
} else if (payload.errors?.length) {
notice.textContent = t("adaptiveSelectionUpstreamNotice").replace("{count}", String(payload.errors.length));
} else {
notice.textContent = payload.policy?.notice || t("adaptiveSelectionNoOrder");
}

const list = el("adaptive-selection-list");
list.replaceChildren();
if (!payload.selections.length) {
const empty = document.createElement("div");
empty.className = "health-card__empty";
empty.textContent = t("adaptiveSelectionEmpty");
list.appendChild(empty);
return;
}
for (const entry of payload.selections) {
const decision = entry.decision || {};
const context = decision.market_context || {};
const recommended = decision.candidates?.find((item) => item.accepted
&& item.strategy_profile === decision.recommended_strategy_profile) || null;
const rejected = (decision.candidates || []).filter((item) => !item.accepted);
const reasons = (recommended?.reasons?.length ? recommended.reasons : rejected.flatMap((item) => item.reasons || []))
.slice(0, 3)
.join(", ");

const card = document.createElement("article");
card.className = "health-card";
const main = document.createElement("div");
main.className = "health-card__main";
const meta = document.createElement("div");
meta.className = "health-card__meta";
meta.textContent = t("adaptiveSelectionMeta")
.replace("{source}", entry.source_id || "unknown")
.replace("{domain}", domainLabel(context.domain || ""))
.replace("{asOf}", context.as_of || "—");
const title = document.createElement("h4");
title.className = "health-card__title";
title.textContent = recommended
? `${t("adaptiveSelectionRecommended")} · ${recommended.strategy_profile}`
: t("adaptiveSelectionNoCandidate");
const reason = document.createElement("p");
reason.className = "health-card__reason";
reason.textContent = reasons
? t("adaptiveSelectionReason").replace("{reasons}", reasons)
: t("adaptiveSelectionNoOrder");
main.append(meta, title, reason);
const status = document.createElement("div");
status.className = "health-card__score";
const label = document.createElement("small");
label.textContent = "NO ORDER";
const score = document.createElement("strong");
score.textContent = typeof recommended?.score === "number" ? recommended.score.toFixed(3) : "—";
const freshness = document.createElement("small");
freshness.textContent = entry.freshness?.data_status || "unknown";
status.append(label, score, freshness);
card.append(main, status);
list.appendChild(card);
}
}

function normalizeExecutionEvidencePayload(payload) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("invalid execution evidence payload");
const deployments = Array.isArray(payload.deployments) ? payload.deployments : [];
Expand Down Expand Up @@ -3959,6 +4074,7 @@
renderRuntimeAuthorityStatus();
renderConsoleView();
renderControlPlane();
renderAdaptiveSelection();
renderExecutionEvidence();
renderResearchTasks();
renderHealth();
Expand Down Expand Up @@ -3987,6 +4103,7 @@
if (state.auth.allowed) {
await refreshControlPlane();
await refreshOwnerDecisions();
await refreshAdaptiveSelection();
await refreshExecutionEvidence();
await refreshResearchTasks();
await refreshHealth();
Expand Down Expand Up @@ -4056,6 +4173,26 @@
renderControlPlane();
}

async function refreshAdaptiveSelection() {
if (!state.auth.allowed) {
renderAdaptiveSelection();
return;
}
try {
state.adaptiveSelection.payload = normalizeAdaptiveSelectionPayload(await requestJson("/api/adaptive-selection"));
} catch {
state.adaptiveSelection.payload = {
data_status: "unavailable",
computed_at: null,
summary: { source_count: 0, decision_count: 0, candidate_count: 0, recommended_count: 0, rejected_candidate_count: 0 },
selections: [],
policy: { authority: "shadow_only", no_order: true, execution_authority_granted: false },
errors: ["adaptive_selection_request_failed"],
};
}
renderAdaptiveSelection();
}

async function recordOwnerDecision(button) {
if (!state.auth.admin) return;
const candidateId = String(button.dataset.candidateId || "");
Expand Down
2 changes: 1 addition & 1 deletion web/strategy-switch-console/app_js.js

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions web/strategy-switch-console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ <h2 id="control-plane-view-title" data-i18n="controlPlaneTitle">只处理需要
</div>
<div class="health-board-head"><h3 data-i18n="controlCandidateBoard">需要跟进</h3></div>
<div class="health-list" id="control-plane-list"></div>
<details class="control-disclosure">
<summary data-i18n="adaptiveSelectionBoard">按需查看 M1 Shadow 建议</summary>
<div id="adaptive-selection-notice" class="health-notice" role="status" aria-live="polite">登录后读取已验证的 Shadow 建议;它始终不下单。</div>
<div class="health-list" id="adaptive-selection-list"></div>
</details>
<details class="control-disclosure">
<summary data-i18n="executionEvidenceBoard">按需查看策略 × 平台证据覆盖</summary>
<div id="execution-evidence-notice" class="health-notice" role="status" aria-live="polite">登录后读取已验证执行证据;页面不会推断 paper 或 live 状态。</div>
Expand Down
2 changes: 1 addition & 1 deletion web/strategy-switch-console/page_asset.js

Large diffs are not rendered by default.

Loading