读取策略配置
\n正在读取登录状态、账号配置和当前状态。
\n \ndiff --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(' +
已验证证据、运行许可和所有者决策。
\n正在读取登录状态、账号配置和当前状态。
\n \n系统会持续监测与筛选;这里仅保留需要你确认的风险边界和恢复决定。
\n健康不是实盘许可;它只告诉你是否要复核、降级或保持观察。
\n一次只为一个平台和账号准备一个可审计的运行计划。页面只提交配置意图;运行、订单和实盘授权由独立的证据与阶段门槛决定。
\n登录后查看账号配置。
\n\n运行状态、插件、现金、收入层和定投通常沿用当前已验证配置;只在需要变更时展开。
\n允许融资与预留现金覆盖不能同时生效。
\nA 股 QMT 不使用 margin / 平台预留现金;现金约束在策略参数 execution_cash_reserve_ratio 内配置。
\n登录后才可提交受限配置。
\n \n已验证证据、运行许可和所有者决策。
\n正在读取登录状态、账号配置和当前状态。
\n \n系统会持续监测与筛选;这里仅保留需要你确认的风险边界和恢复决定。
\n健康不是实盘许可;它只告诉你是否要复核、降级或保持观察。
\n一次只为一个平台和账号准备一个可审计的运行计划。页面只提交配置意图;运行、订单和实盘授权由独立的证据与阶段门槛决定。
\n登录后查看账号配置。
\n\n运行状态、插件、现金、收入层和定投通常沿用当前已验证配置;只在需要变更时展开。
\n允许融资与预留现金覆盖不能同时生效。
\nA 股 QMT 不使用 margin / 平台预留现金;现金约束在策略参数 execution_cash_reserve_ratio 内配置。
\n登录后才可提交受限配置。
\n \n