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
17 changes: 15 additions & 2 deletions docs/qsl_unified_control_console_architecture_v1.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 决策收据和确定性执行网关仍须以独立快照/适配器逐步接线。因此,现有设置页的按钮不是新的全局执行授权

## 推荐的低风险演进

Expand Down Expand Up @@ -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. 决策队列(仍不直接执行)

网页将“策略健康”扩展为“全局概览 + 决策队列”:
Expand Down
212 changes: 26 additions & 186 deletions python/scripts/inject_platform_config.py
Original file line number Diff line number Diff line change
@@ -1,204 +1,44 @@
#!/usr/bin/env python3
"""Inject platform-config globals into index.html before </head>."""
"""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 = "<!-- Generated by inject_platform_config.py -->"
BOOTSTRAP = '<script src="/bootstrap-config.js"></script>'


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(
[
"<!-- Generated by inject_platform_config.py -->",
'<script id="platform-config">',
"window.__PLATFORM_CONFIG__ = " + json.dumps(pc, ensure_ascii=False) + ";",
"window.__QSL_RUNTIME_AUTHORITY_STATUS__ = " + json.dumps(runtime_authority, ensure_ascii=False) + ";",
"window.__DEFAULT_ACCOUNT_OPTIONS__ = " + json.dumps(dao, ensure_ascii=False) + ";",
"window.__DOMAIN_LABELS__ = " + json.dumps(domain_labels, ensure_ascii=False) + ";",
"window.__DEFAULT_STRATEGY_PROFILES__ = " + json.dumps(strategy_profiles, ensure_ascii=False) + ";",
"window.__DCA_PROFILE_DEFAULTS__ = " + json.dumps(dca, ensure_ascii=False) + ";",
"window.__INCOME_LAYER_DEFAULTS__ = " + json.dumps(inc_layer, ensure_ascii=False) + ";",
"window.__OPTION_OVERLAY_DEFAULTS__ = " + json.dumps(opt_overlay, ensure_ascii=False) + ";",
"</script>",
]
)

html = SOURCE.read_text(encoding="utf-8")
marker = "<!-- Generated by inject_platform_config.py -->"
existing = html.find('<script id="platform-config">')
if existing >= 0:
start = existing
while True:
prefix = html[:start].rstrip()
marker_start = prefix.rfind(marker)
if marker_start < 0:
break
if prefix[marker_start:].strip() != marker:
break
start = marker_start
end = html.find("</script>", existing) + 9
html = html[:start].rstrip() + "\n\n" + block + html[end:]
else:
replacement = f"{MARKER}\n{BOOTSTRAP}"
legacy_script = html.find('<script id="platform-config">')

if legacy_script >= 0:
start = legacy_script
prefix = html[:start].rstrip()
if prefix.endswith(MARKER):
start = prefix.rfind(MARKER)
end = html.find("</script>", 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("</script>"):]
elif BOOTSTRAP not in html:
head_end = html.find("</head>")
html = html[:head_end] + "\n" + block + "\n" + html[head_end:]
if head_end < 0:
raise RuntimeError("index.html is missing </head>")
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())
15 changes: 15 additions & 0 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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('<script src="/bootstrap-config.js"></script>'));
assert.equal(indexHtml.includes('<script id="platform-config">'), false);
assert.equal(indexHtml.includes("publicSummary"), false);
assert.ok(indexHtml.includes("function hasPrivateConfig()"));
assert.ok(indexHtml.includes('el("quick-form").hidden = !showPrivateControls'));
Expand Down Expand Up @@ -247,10 +249,23 @@ assert.match(indexHtml, /body\.app-loading \.shell\s*\{\s*display: none;/);
const servedPageResponse = await worker.fetch(new Request("https://switch.example/"), {});
const servedHtml = await servedPageResponse.text();
assert.equal(servedPageResponse.status, 200);
assert.ok(servedHtml.includes('<script src="/bootstrap-config.js"></script>'));
assert.equal(servedHtml.includes('<script id="platform-config">'), false);
assert.equal(servedHtml.includes("ibkr-primary"), false);
assert.equal(servedHtml.includes("longbridge-quant-sg-service"), false);
assert.equal(servedHtml.includes('account_selector: "SG"'), false);

const bootstrapConfigResponse = await worker.fetch(new Request("https://switch.example/bootstrap-config.js"), {});
const bootstrapConfigJs = await bootstrapConfigResponse.text();
assert.equal(bootstrapConfigResponse.status, 200);
assert.equal(bootstrapConfigResponse.headers.get("Content-Type"), "application/javascript; charset=utf-8");
assert.equal(bootstrapConfigResponse.headers.get("X-Content-Type-Options"), "nosniff");
assert.ok(bootstrapConfigJs.includes("window.__PLATFORM_CONFIG__"));
assert.ok(bootstrapConfigJs.includes("window.__DEFAULT_STRATEGY_PROFILES__"));
assert.equal(bootstrapConfigJs.includes("ibkr-primary"), false);
assert.equal(bootstrapConfigJs.includes("longbridge-quant-sg-service"), false);
assert.equal(bootstrapConfigJs.includes('account_selector: "SG"'), false);

const servedAppResponse = await worker.fetch(new Request("https://switch.example/app.js"), {});
const servedAppJs = await servedAppResponse.text();
assert.equal(servedAppResponse.status, 200);
Expand Down
Loading