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
100 changes: 100 additions & 0 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2180,6 +2180,106 @@ const recordedOwnerDecisionQueue = await worker.fetch(
const recordedOwnerDecisionPayload = await recordedOwnerDecisionQueue.json();
assert.equal(recordedOwnerDecisionPayload.candidates[0].intent.decision, "keep_parked");

const riskProfileStore = new Map();
const riskProfileKv = {
async get(key) { return riskProfileStore.get(key) || null; },
async put(key, value) { riskProfileStore.set(key, value); },
};
const riskProfileEnv = {
SESSION_SECRET: "risk-profile-session-value",
ALLOWED_GITHUB_LOGINS: "risk-admin,risk-reader",
STRATEGY_SWITCH_ADMIN_LOGINS: "risk-admin",
STRATEGY_SWITCH_CONFIG: riskProfileKv,
STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON: JSON.stringify({
longbridge: [{ key: "sg", label: "Singapore", target_name: "sg" }],
schwab: [{ key: "default", label: "US", target_name: "default" }],
}),
};
const riskAdminCookie = await __test.makeSession("risk-admin", [], riskProfileEnv);
const riskReaderCookie = await __test.makeSession("risk-reader", [], riskProfileEnv);
const riskAdminHeaders = { Cookie: `qsl_switch_session=${riskAdminCookie}` };
const riskReaderHeaders = { Cookie: `qsl_switch_session=${riskReaderCookie}` };

const initialRiskProfiles = await worker.fetch(
new Request("https://switch.example/api/risk-profiles", { headers: riskAdminHeaders }),
riskProfileEnv,
);
assert.equal(initialRiskProfiles.status, 200);
assert.deepEqual((await initialRiskProfiles.json()).bindings, []);

const riskProfileWrite = await worker.fetch(
new Request("https://switch.example/api/risk-profiles", {
method: "POST",
headers: { ...riskAdminHeaders, Origin: "https://switch.example", "Content-Type": "application/json" },
body: JSON.stringify({
bindings: [{ platform: "longbridge", target_name: "sg", risk_preference: "BALANCED_COMPOUNDING" }],
}),
}),
riskProfileEnv,
);
assert.equal(riskProfileWrite.status, 200);
const riskProfileWritePayload = await riskProfileWrite.json();
assert.equal(riskProfileWritePayload.no_order, true);
assert.equal(riskProfileWritePayload.execution_authority_granted, false);
assert.equal(riskProfileWritePayload.bindings[0].scope_id, "longbridge--sg");
assert.equal(riskProfileWritePayload.bindings[0].profile_selection.schema, "qsl.risk_profile_selection.v1");
assert.equal(riskProfileWritePayload.bindings[0].profile_selection.profile_id, "balanced_compounding_v1");
assert.equal(riskProfileWritePayload.bindings[0].profile_selection.risk_preference, "BALANCED_COMPOUNDING");
assert.match(riskProfileWritePayload.bindings[0].profile_selection.selection_sha256, /^[0-9a-f]{64}$/);
assert.match(riskProfileWritePayload.bindings[0].binding_sha256, /^[0-9a-f]{64}$/);
assert.equal(riskProfileStore.has("risk_profile_bindings"), true);
assert.equal((await worker.fetch(
new Request("https://switch.example/api/risk-profiles", { headers: riskReaderHeaders }),
riskProfileEnv,
)).status, 403);

const invalidRiskProfileTarget = await worker.fetch(
new Request("https://switch.example/api/risk-profiles", {
method: "POST",
headers: { ...riskAdminHeaders, Origin: "https://switch.example", "Content-Type": "application/json" },
body: JSON.stringify({
bindings: [{ platform: "longbridge", target_name: "missing", risk_preference: "BALANCED_COMPOUNDING" }],
}),
}),
riskProfileEnv,
);
assert.equal(invalidRiskProfileTarget.status, 400);
assert.match((await invalidRiskProfileTarget.json()).error, /not configured/);

const crossOriginRiskProfileWrite = await worker.fetch(
new Request("https://switch.example/api/risk-profiles", {
method: "POST",
headers: { ...riskAdminHeaders, Origin: "https://evil.example", "Content-Type": "application/json" },
body: JSON.stringify({ bindings: [] }),
}),
riskProfileEnv,
);
assert.equal(crossOriginRiskProfileWrite.status, 403);

const tamperedRiskRegistry = JSON.parse(riskProfileStore.get("risk_profile_bindings"));
tamperedRiskRegistry.bindings[0].profile_selection.risk_preference = "GROWTH_COMPOUNDING";
riskProfileStore.set("risk_profile_bindings", JSON.stringify(tamperedRiskRegistry));
const tamperedRiskProfileRead = await worker.fetch(
new Request("https://switch.example/api/risk-profiles", { headers: riskAdminHeaders }),
riskProfileEnv,
);
assert.equal(tamperedRiskProfileRead.status, 409);
assert.equal((await tamperedRiskProfileRead.json()).reason, "risk_profile_bindings_invalid");

const directRiskProfileBindings = await __test.buildRiskProfileBindings(
{ bindings: [{ platform: "schwab", target_name: "default", risk_preference: "CAPITAL_PRESERVATION" }] },
JSON.parse(riskProfileEnv.STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON),
"risk-admin",
);
assert.equal(directRiskProfileBindings[0].profile_selection.profile_id, "capital_preservation_v1");
assert.deepEqual(
await __test.normalizeRiskProfileBindingRegistry({
schema_version: "qsl.risk_profile_binding_registry.v1",
bindings: directRiskProfileBindings,
}),
directRiskProfileBindings,
);

const staleDecision = await worker.fetch(
new Request("https://switch.example/api/owner-decisions", {
method: "POST",
Expand Down
7 changes: 7 additions & 0 deletions web/strategy-switch-console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,18 @@ For editable admin settings, bind a Cloudflare KV namespace named `STRATEGY_SWIT
auth_config
account_options
strategy_profiles
risk_profile_bindings
audit_log
```

Without the KV binding, `/admin` is read-only and the Worker falls back to `ALLOWED_GITHUB_LOGINS`, `ALLOWED_GITHUB_ORGS`, `STRATEGY_SWITCH_ADMIN_LOGINS`, `STRATEGY_SWITCH_ADMIN_ORGS`, and `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`.

## Portfolio Risk Preference (non-executable intent)

Administrators can select Capital Preservation, Balanced Compounding, or Growth Compounding for a configured platform target in `/admin`. Same-origin, admin-only `GET` / `POST /api/risk-profiles` stores a self-validating `qsl.risk_profile_binding.v1` record under `risk_profile_bindings`; its portable selection is exactly `qsl.risk_profile_selection.v1`, the contract used by the core risk composer.

Every record is fixed to `no_order=true` and `execution_authority_granted=false`. It never enters `RUNTIME_TARGET_JSON`, changes strategy parameters or sizing, dispatches a workflow, accesses brokers or execution cloud resources, or enables paper, shadow, or live. A malformed KV record is unavailable rather than silently defaulted. A future independent, read-only control-plane adapter may consume only `profile_selection`, after separately validating observation evidence and all P4/P5/P6 gates.

## Web Owner Decisions (P6 intent)

Only a fresh P6 candidate with `owner_decision_required` and an `owner_live_decision` recommendation appears in the owner-decision area. Console administrators can record one of three choices: approve a limited-canary intent, keep the candidate parked, or retire it.
Expand Down
7 changes: 7 additions & 0 deletions web/strategy-switch-console/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ STRATEGY_SWITCH_ADMIN_LOGINS=your-github-login
auth_config
account_options
strategy_profiles
risk_profile_bindings
audit_log
strategy_health_snapshot
control_plane_snapshot
Expand All @@ -89,6 +90,12 @@ research_task_source:<source_id>

没有绑定 KV 时,`/admin` 只读;Worker 会回退读取 `ALLOWED_GITHUB_LOGINS`、`ALLOWED_GITHUB_ORGS`、`STRATEGY_SWITCH_ADMIN_LOGINS`、`STRATEGY_SWITCH_ADMIN_ORGS` 和 `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`。

## 组合风险偏好(非执行意图)

管理员可在 `/admin` 为已配置的平台目标选择“保本优先 / 平衡复利 / 增长复利”。页面调用受同源校验和管理员权限保护的 `GET` / `POST /api/risk-profiles`,并只向 `risk_profile_bindings` 保存自校验的 `qsl.risk_profile_binding.v1` 记录;其中可移植的选择部分与核心风险合成器的 `qsl.risk_profile_selection.v1` 完全一致。

此记录固定为 `no_order=true` 和 `execution_authority_granted=false`:它不进入 `RUNTIME_TARGET_JSON`、不改策略参数或仓位、不调度 workflow、不读写券商或云执行资源,也不能启用 paper、shadow 或 live。KV 中记录损坏时接口会返回不可用,绝不会静默回退为默认风险偏好。未来独立的只读控制面适配器只能读取其中的 `profile_selection`,仍需另外验证观察证据和完整的 P4/P5/P6 门槛。

## 只读研究任务索引

`/api/internal/sync-research-task-source` 只接受 `qsl_research_task_source_snapshot.v1`,并要求独立的 `RESEARCH_TASK_SYNC_TOKEN`。每个任务均须是 SHA-256 自校验通过的 `qsl.research_task.v1`,固定为 `research_only=true`、`no_order=true`、`size_zero_required=true`、`p4_p5_p6_authorized=false`。已登录 allowlist 用户可从 `/api/research-tasks` 读取脱敏聚合结果。
Expand Down
Loading