diff --git a/docs/qsl_unified_control_console_architecture_v1.zh-CN.md b/docs/qsl_unified_control_console_architecture_v1.zh-CN.md index fdb35df..4b9e4ef 100644 --- a/docs/qsl_unified_control_console_architecture_v1.zh-CN.md +++ b/docs/qsl_unified_control_console_architecture_v1.zh-CN.md @@ -23,9 +23,9 @@ ## 当前基础与主要缺口 -现有 `web/strategy-switch-console` 已具备 GitHub OAuth、allowlist、管理员配置、KV 审计,以及受专用 token 保护的 `strategy_health_dashboard.v1` 只读健康快照。这是合适的起点。 +现有 `web/strategy-switch-console` 已具备 GitHub OAuth、allowlist、管理员配置、KV 审计,以及受专用 token 保护的 `strategy_health_dashboard.v1` 只读健康快照。首页现在采用“人工决策台”:默认展示待处理决策,研究、证据和运行资料作为按需展开的下钻内容;历史策略切换则收敛到“策略设置”。 -但它今天仍以“策略切换”作为主要界面,并混合了历史的 `live` 配置元数据与手工 workflow dispatch。它尚未有跨仓候选目录、P0–P6 生命周期快照、统一决策队列或 P6 决策收据。因此不能把现有按钮当作新的全局执行授权。 +它仍不能被误解为券商终端:跨仓目录、完整 P0–P6 生命周期、P6 决策收据和确定性执行网关仍须以独立快照/适配器逐步接线。因此,现有设置页的按钮不是新的全局执行授权。 ## 推荐的低风险演进 @@ -56,6 +56,19 @@ Worker 以**独立于 dispatch token 的同步身份**接收来源快照、按 ` 这遵循“仪表盘应直接回答问题、减少认知负担、异常优先”的运维原则;参考 [Grafana dashboard best practices](https://grafana.com/docs/grafana/latest/visualizations/dashboards/build-dashboards/best-practices/) 与 [Google SRE monitoring guidance](https://sre.google/workbook/monitoring/)。 +### 成熟控制台对照后的产品准则 + +成熟的量化和运维控制台并不是把所有数据都放在首页,而是让每个异常都能被负责的人快速、可审计地处理。基于 QuantConnect、Grafana、Datadog、PagerDuty 与 Backstage 的公开设计,本站遵循以下约束: + +1. **首页只放可行动事项。** 每项应逐步具备“策略 × 平台/账户目标、严重度、影响、证据新鲜度、当前安全状态、建议动作、责任人/运行手册”的最小字段。缺字段时显示“证据不足”,不能用绿灯代替。 +2. **告警按影响合并和分流。** 同一根因的运行告警应归为一个事项;高风险即时通知,需复核的事项批量通知,纯信息留在系统状态页。告警必须说明触发原因、如何判断与下一步,而不是只复制日志。[Grafana 告警实践](https://grafana.com/docs/grafana/latest/alerting/guides/best-practices/) 与其 [通知策略](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/create-notification-policy/) 都强调可行动性、分组与路由。 +3. **变更先证明,再执行。** 策略设置页只显示当前值、拟议差异、适用范围和就绪检查;执行证据、持仓、现金与运行状态保持只读。QuantConnect 的实盘模型同样将算法状态、持仓、现金、订单事件与停止/清仓操作分离,避免把“看见状态”误解为“可直接改券商”。[QuantConnect 实盘入门](https://www.quantconnect.com/docs/v2/local-platform/live-trading/getting-started) +4. **事故有时间线,日常没有噪音。** 系统状态页仅突出 critical/review、最后可信样本与关联证据;后续补充的审计视图应记录状态、影响、负责人、检测与根因时间线,符合 [Datadog 事故管理](https://docs.datadoghq.com/incident_response/incident_management/) 的可追溯原则。 +5. **策略、插件与平台是同一套目录对象。** 它们应从代码/快照衍生身份、版本、责任、生命周期、运行手册和部署目标,而不是由网页手填。这个“目录先于大屏”的思路与 [Backstage Software Catalog](https://backstage.io/docs/features/software-catalog/) 一致。 +6. **不在控制台新增直连券商的紧急按钮。** 需要停止或降风险时,只创建绑定证据的受限意图,并由独立、幂等且可对账的执行网关消费;浏览器、AI、网页配置和 GitHub workflow 都不能绕开该边界。 + +当前三项主导航正好对应这套低频人工流程:**决策**(今天要判断什么)、**策略设置**(准备提交什么差异)、**系统状态**(为什么需要判断)。目录、通知策略与审计会在各自已有真实快照/权限接口后作为下钻加入,不能先用静态假数据占据首页。 + ### 2. 决策队列(仍不直接执行) 网页将“策略健康”扩展为“全局概览 + 决策队列”: diff --git a/python/scripts/inject_platform_config.py b/python/scripts/inject_platform_config.py index ef79c16..46a6056 100644 --- a/python/scripts/inject_platform_config.py +++ b/python/scripts/inject_platform_config.py @@ -1,204 +1,44 @@ #!/usr/bin/env python3 -"""Inject platform-config globals into index.html before .""" +"""Keep the console's configuration bootstrap as a same-origin script. + +The Worker serializes the non-secret configuration from ``config.js`` at +``/bootstrap-config.js``. Keeping this loader external lets the page retain +its strict ``script-src 'self'`` CSP without a hash or ``unsafe-inline``. +""" -import json from pathlib import Path + ROOT = Path(__file__).resolve().parents[2] SOURCE = ROOT / "web" / "strategy-switch-console" / "index.html" -CONFIG = ROOT / "platform-config.json" +MARKER = "" +BOOTSTRAP = '' def main() -> int: - config = json.loads(CONFIG.read_text(encoding="utf-8")) - platforms = config["platforms"] - strategies = config["strategies"] - meta = config.get("meta", {}) - runtime_authority = meta.get("runtime_authority", {}) if isinstance(meta, dict) else {} - - pc, dao, dca, inc_layer, opt_overlay = {}, {}, {}, {}, {} - strategy_profiles = [] - domain_labels = { - did: {"zh": ddata.get("label_zh", did), "en": ddata.get("label_en", did)} - for did, ddata in config.get("domains", {}).items() - } - for pid, pdata in platforms.items(): - caps, depl = pdata["capabilities"], pdata["deployment"] - pc[pid] = dict( - dry_run_only=depl.get("dry_run_only", False), - margin_policy=caps.get("margin_policy", False), - reserved_cash=caps.get("reserved_cash", False), - income_layer=caps.get("income_layer", False), - option_overlay=caps.get("option_overlay", False), - dca=caps.get("dca", False), - execution_mode=depl.get("default_execution_mode", "live"), - service_name=depl.get("service_name", ""), - default_execution_mode=depl.get("default_execution_mode", "live"), - ) - acct = pdata.get("default_account", {}) - entry = dict( - key=acct.get("key", pid), - label=acct.get("label", pdata.get("label", pid)), - target_name=acct.get("target_name", acct.get("key", pid)), - supported_domains=acct.get("supported_domains", pdata.get("supported_domains", [])), - cash_currency=acct.get("cash_currency", "USD"), - ) - for fld in ( - "service_name", - "account_scope", - "deployment_selector", - "account_selector", - "default_execution_mode", - "min_reserved_cash_usd", - "reserved_cash_ratio", - "cash_only_execution_mode", - "dca_mode", - "dca_base_investment_usd", - ): - if acct.get(fld): - entry[fld] = acct[fld] - if "service_name" not in entry: - entry["service_name"] = depl.get("service_name", "") - if "default_execution_mode" not in entry: - entry["default_execution_mode"] = depl.get("default_execution_mode", "live") - dao[pid] = [entry] - for sid, sdata in strategies.items(): - feat = sdata.get("features", {}) - strategy_profiles.append(_strategy_profile_entry(sid, sdata)) - dd = sdata.get("dca_defaults") - if dd: - dca[sid] = dict( - defaultMode=dd.get("default_mode", "fixed"), - defaultBaseInvestmentUsd=str(dd.get("default_base_investment_usd", "1000")), - ) - if feat.get("income_layer"): - idl = sdata.get("income_layer_defaults", {}) - inc_layer[sid] = dict( - startUsd=int(idl.get("start_usd", 0)), - maxRatio=str(idl.get("max_ratio", "")), - allocations=idl.get("allocations", {}), - ) - if feat.get("option_overlay"): - odl = sdata.get("option_overlay_defaults", {}) - families = [] - if odl.get("growth_enabled"): - families.append( - dict( - family="growth", - recipe=odl["growth_recipe"], - startUsd=odl["growth_start_usd"], - ratio=str(odl.get("nav_budget_ratio", "")), - ratioKind="budget", - ) - ) - if odl.get("income_enabled"): - families.append( - dict( - family="income", - recipe=odl["income_recipe"], - startUsd=odl["income_start_usd"], - ratio=str(odl.get("nav_risk_ratio", "")), - ratioKind="risk", - ) - ) - opt_overlay[sid] = dict( - liveGate=odl.get("live_gate", ""), liveStatus=odl.get("live_status", ""), families=families - ) - - block = "\n".join( - [ - "", - '", - ] - ) - html = SOURCE.read_text(encoding="utf-8") - marker = "" - existing = html.find('", existing) + 9 - html = html[:start].rstrip() + "\n\n" + block + html[end:] - else: + replacement = f"{MARKER}\n{BOOTSTRAP}" + legacy_script = html.find('", legacy_script) + if end < 0: + raise RuntimeError("platform-config script is missing its closing tag") + html = html[:start].rstrip() + "\n\n" + replacement + html[end + len(""):] + elif BOOTSTRAP not in html: head_end = html.find("") - html = html[:head_end] + "\n" + block + "\n" + html[head_end:] + if head_end < 0: + raise RuntimeError("index.html is missing ") + html = html[:head_end] + "\n" + replacement + "\n" + html[head_end:] SOURCE.write_text(html, encoding="utf-8") - print("Injected platform-config into index.html") + print("Configured same-origin bootstrap-config.js loader") return 0 -def _strategy_profile_entry(sid: str, sdata: dict) -> dict: - feat = sdata.get("features", {}) - runtime_enabled = sdata.get("runtime_enabled", False) - lifecycle_stage = str( - sdata.get("lifecycle_stage") or ("runtime_enabled" if runtime_enabled else "research_active") - ).strip() - can_switch_live = sdata.get( - "can_switch_live", - runtime_enabled and lifecycle_stage in {"live_enabled", "runtime_enabled"}, - ) - blocked_live_reason = sdata.get("blocked_live_reason") - if blocked_live_reason is None and not can_switch_live: - blocked_live_reason = lifecycle_stage or "not_runtime_enabled" - continuity = sdata.get("live_continuity") if isinstance(sdata.get("live_continuity"), dict) else {} - entry = { - "profile": sid, - "label": sdata.get("label", sid), - "label_en": sdata.get("label_en", sid), - "label_zh": sdata.get("label", sid), - "domain": sdata.get("domain", ""), - "runtime_enabled": runtime_enabled, - "lifecycle_stage": lifecycle_stage, - "can_switch_live": can_switch_live, - "allowed_execution_modes": _normalize_allowed_execution_modes(sdata.get("allowed_execution_modes")), - "blocked_live_reason": "" if blocked_live_reason is None else str(blocked_live_reason).strip(), - "live_continuity": { - "eligible": continuity.get("eligible") is True, - "allowed_platforms": list(continuity.get("allowed_platforms") or []), - }, - "income_layer_enabled": feat.get("income_layer", False), - "option_overlay_enabled": feat.get("option_overlay", False), - "combo_enabled": feat.get("combo", False), - } - if feat.get("combo"): - entry["combo_mode"] = feat.get("combo_mode", "dynamic") - return entry - - -def _normalize_allowed_execution_modes(raw_modes: object) -> list[str]: - if raw_modes is None: - return ["paper", "dry_run"] - if isinstance(raw_modes, str): - modes = [raw_modes.strip()] - elif isinstance(raw_modes, list): - modes = [str(mode).strip() for mode in raw_modes] - elif isinstance(raw_modes, tuple): - modes = [str(mode).strip() for mode in raw_modes] - elif isinstance(raw_modes, set): - modes = [str(mode).strip() for mode in sorted(raw_modes)] - else: - modes = ["paper", "dry_run"] - modes = [mode for mode in modes if mode] - return modes if modes else ["paper", "dry_run"] - - if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 428510c..5eba1e7 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -96,6 +96,8 @@ assert.ok(indexHtml.includes(".switch-surface.summary-hidden")); assert.ok(indexHtml.includes('summaryPanel.hidden = !showSummary')); assert.ok(indexHtml.includes('switchSurface.classList.toggle("summary-hidden", !showSummary)')); assert.equal(indexHtml.match(/Generated by inject_platform_config\.py/g)?.length, 1); +assert.ok(indexHtml.includes('')); +assert.equal(indexHtml.includes('')); +assert.equal(servedHtml.includes(' +
diff --git a/web/strategy-switch-console/page_asset.js b/web/strategy-switch-console/page_asset.js index 47b3eac..d7fdfdc 100644 --- a/web/strategy-switch-console/page_asset.js +++ b/web/strategy-switch-console/page_asset.js @@ -1,2 +1,2 @@ // Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand. -export const PAGE_HTML = "\n\n\n \n \n \n QuantStrategyLab 控制台\n \n \n\n\n\n\n\n
\n
\n

QuantStrategyLab

\n

已验证证据、运行许可和所有者决策。

\n
\n
\n \n \n \n \n
\n
\n\n
\n
\n 初始化控制台\n

读取策略配置

\n

正在读取登录状态、账号配置和当前状态。

\n
\n
\n
\n\n
\n \n \n\n
\n
\n
\n 人工决策台 / 只读\n

只看需要你判断的事。

\n

系统会持续监测与筛选;这里仅保留需要你确认的风险边界和恢复决定。

\n
\n
\n 等待登录\n 最近计算:—\n
\n
\n
登录后读取私有全局快照;页面不会授予实盘权限。
\n
\n
等待我决定0
\n
已停车0
\n
本次延期0
\n
候选总数0
\n
\n
\n
\n
\n
\n 优先队列\n

需要你确认

\n
\n 只显示可操作事项\n
\n
\n
\n \n
\n
\n
\n 研究观察\n
登录后读取已验证 M0 研究台账;仅研究、无订单。
\n
\n
\n
\n Shadow 建议\n
登录后读取已验证的 Shadow 建议;它始终不下单。
\n
\n
\n
\n 恢复核验\n
登录后读取私有恢复核验;页面不会直接恢复运行或下单。
\n
\n
\n
\n 执行证据\n
登录后读取已验证执行证据;页面不会推断 paper 或 live 状态。
\n
\n
\n
\n 平台运行状态\n
登录后读取已验证的平台运行状态;页面不会启用目标或提交订单。
\n
\n
\n
\n 研究任务\n
登录后读取已验证研究任务;不会运行或调参。
\n
\n
\n
\n
\n\n \n\n \n
\n\n \n\n\n"; +export const PAGE_HTML = "\n\n\n \n \n \n QuantStrategyLab 控制台\n \n \n\n\n\n\n\n
\n
\n

QuantStrategyLab

\n

已验证证据、运行许可和所有者决策。

\n
\n
\n \n \n \n \n
\n
\n\n
\n
\n 初始化控制台\n

读取策略配置

\n

正在读取登录状态、账号配置和当前状态。

\n
\n
\n
\n\n
\n \n \n\n
\n
\n
\n 人工决策台 / 只读\n

只看需要你判断的事。

\n

系统会持续监测与筛选;这里仅保留需要你确认的风险边界和恢复决定。

\n
\n
\n 等待登录\n 最近计算:—\n
\n
\n
登录后读取私有全局快照;页面不会授予实盘权限。
\n
\n
等待我决定0
\n
已停车0
\n
本次延期0
\n
候选总数0
\n
\n
\n
\n
\n
\n 优先队列\n

需要你确认

\n
\n 只显示可操作事项\n
\n
\n
\n \n
\n
\n
\n 研究观察\n
登录后读取已验证 M0 研究台账;仅研究、无订单。
\n
\n
\n
\n Shadow 建议\n
登录后读取已验证的 Shadow 建议;它始终不下单。
\n
\n
\n
\n 恢复核验\n
登录后读取私有恢复核验;页面不会直接恢复运行或下单。
\n
\n
\n
\n 执行证据\n
登录后读取已验证执行证据;页面不会推断 paper 或 live 状态。
\n
\n
\n
\n 平台运行状态\n
登录后读取已验证的平台运行状态;页面不会启用目标或提交订单。
\n
\n
\n
\n 研究任务\n
登录后读取已验证研究任务;不会运行或调参。
\n
\n
\n
\n
\n\n \n\n \n
\n\n \n\n\n"; diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index a4f7124..8515754 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -13,6 +13,7 @@ import { FALLBACK_INCOME_LAYER_DEFAULTS, FALLBACK_OPTION_OVERLAY_DEFAULTS, DCA_PROFILE_DEFAULTS, + RUNTIME_AUTHORITY_STATUS, RUNTIME_CATALOG_PROJECTION, STRATEGY_FEATURES, } from "./config.js"; @@ -302,6 +303,22 @@ const SECURITY_HEADERS = { "X-Frame-Options": "DENY", }; +// This is deliberately a same-origin, public configuration bootstrap. It +// contains only the config projections already shown by the public console; +// private account routing still arrives after an authenticated /api/config +// request. Keeping it external preserves the strict CSP without unsafe-inline. +const BOOTSTRAP_CONFIG_JS = [ + "// Generated from non-secret console configuration. Do not edit by hand.", + `window.__PLATFORM_CONFIG__ = ${JSON.stringify(PLATFORM_CONFIG)};`, + `window.__QSL_RUNTIME_AUTHORITY_STATUS__ = ${JSON.stringify(RUNTIME_AUTHORITY_STATUS)};`, + `window.__DEFAULT_ACCOUNT_OPTIONS__ = ${JSON.stringify(DEFAULT_ACCOUNT_OPTIONS)};`, + `window.__DOMAIN_LABELS__ = ${JSON.stringify(DOMAIN_LABELS)};`, + `window.__DEFAULT_STRATEGY_PROFILES__ = ${JSON.stringify(DEFAULT_STRATEGY_PROFILES)};`, + `window.__DCA_PROFILE_DEFAULTS__ = ${JSON.stringify(DCA_PROFILE_DEFAULTS)};`, + `window.__INCOME_LAYER_DEFAULTS__ = ${JSON.stringify(FALLBACK_INCOME_LAYER_DEFAULTS)};`, + `window.__OPTION_OVERLAY_DEFAULTS__ = ${JSON.stringify(FALLBACK_OPTION_OVERLAY_DEFAULTS)};`, +].join("\n"); + export default { async fetch(request, env, ctx) { const url = new URL(request.url); @@ -396,6 +413,16 @@ export default { } if (url.pathname === "/api/logout" && request.method === "POST") return logout(request); if (url.pathname === "/api/switch" && request.method === "POST") return await dispatchSwitch(request, env); + if (url.pathname === "/bootstrap-config.js") { + return new Response(BOOTSTRAP_CONFIG_JS, { + status: 200, + headers: { + "Content-Type": "application/javascript; charset=utf-8", + "Cache-Control": "no-cache", + "X-Content-Type-Options": "nosniff", + }, + }); + } if (url.pathname === "/app.css") return new Response(APP_CSS, { status: 200, headers: { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "public, max-age=3600" } }); if (url.pathname === "/app.js") return new Response(APP_JS, { status: 200, headers: { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "public, max-age=3600" } }); return html(PAGE_HTML);