Skip to content

Commit 820e0ad

Browse files
Pigbibicodex
andauthored
feat: verify universal no-order strategy coverage (#299)
* feat: verify universal no-order strategy coverage Co-Authored-By: Codex <noreply@openai.com> * fix: preserve legacy paper request boundaries Co-Authored-By: Codex <noreply@openai.com> --------- Co-authored-by: Codex <noreply@openai.com>
1 parent 0690a9f commit 820e0ad

19 files changed

Lines changed: 434 additions & 60 deletions

.github/workflows/manual-strategy-switch.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,13 @@ on:
2323
required: true
2424
type: string
2525
execution_mode:
26-
description: "live writes live mode; paper sets dry_run_only=true and execution_mode=paper."
26+
description: "live is gated; dry_run is the universal no-order path. paper is retained only as a legacy alias for dry_run, not P4 paper trading."
2727
required: true
2828
type: choice
2929
default: live
3030
options:
3131
- live
32+
- dry_run
3233
- paper
3334
variable_scope:
3435
description: "Where GitHub variables are written. blank = platform default."

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ Notes:
102102
- LongBridge, IBKR, Schwab, and Firstrade `service_targets_mode=auto` checks the target repository's multi-service inventory, so even preview mode requires `RUNTIME_SETTINGS_GH_TOKEN`.
103103
- Binance runs through an Oracle Cloud VPS self-hosted runner. Repository variable writes are consumed by the next externally scheduled `main.yml` dispatch; the central switch does not dispatch that runtime workflow because it may execute live trading. A strategy cadence change also requires a separate review of the external VPS scheduler.
104104
- QMT remains dry-run only and has no live deployment configuration. Its generated target can stage repository variables, but `trigger_platform_sync=true` is rejected.
105+
- Every strategy must retain a domain-matched, no-order `dry_run` route. The health report verifies this coverage; it is not P4 paper trading. See [Universal Dry-run Coverage V1](docs/qsl_universal_dry_run_coverage_v1.zh-CN.md).
105106
- `plugin_mode=none` is the safe default. `auto` remains only as a compatibility alias for `none`; strategy names must not auto-mount a `latest_signal.json` artifact. Legacy `custom` mounts are disabled and cannot bypass P1/P2/P3 binding through the manual form. A future runtime plugin must be referenced by the frozen P2 candidate and recomputable in P3; see the [Strategy Plugin Contract V2](docs/qsl_strategy_plugin_contract_v2.zh-CN.md).
106107
- The workflow is bound to the `runtime-strategy-switch` GitHub Environment. For a personal system, required reviewers are optional; prefer storing `RUNTIME_SETTINGS_GH_TOKEN` as an Environment secret and rely on preview, confirmation text, and a least-privilege token for day-to-day safety.
107108
- Follow the simplified permission-control plan before enabling real switches: [docs/manual_strategy_switch_permission_control.zh-CN.md](docs/manual_strategy_switch_permission_control.zh-CN.md).

README.zh-CN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ confirm_apply=APPLY_AND_SYNC
8888
- LongBridge、IBKR、Schwab、Firstrade 的 `service_targets_mode=auto` 会检查目标仓库是否已有多服务清单,因此即使只做 preview 也需要 `RUNTIME_SETTINGS_GH_TOKEN`
8989
- Binance 运行在 Oracle Cloud VPS 的 self-hosted runner。仓库变量会在外部调度器下一次触发 `main.yml` 时被读取;中控不会自动触发该运行 workflow,因为它可能直接执行实盘。切换到不同运行频率的策略时,还必须单独复核 VPS 外部调度器。
9090
- QMT 当前仅支持 dry-run,尚无实盘部署配置;可以生成目标并暂存仓库变量,但会拒绝 `trigger_platform_sync=true`
91+
- 所有策略都必须有至少一条按策略域匹配的平台 `dry_run`(不下单)路径;该覆盖由健康报告持续校验。它不等同于 P4 paper 交易,详见[通用不下单演练覆盖 V1](docs/qsl_universal_dry_run_coverage_v1.zh-CN.md)
9192
- 当前 `plugin_mode=none` 是安全默认值。`auto` 仅为兼容旧请求而保留,实际等同于 `none`;不得再按策略名称自动挂载 `latest_signal.json`。旧 `custom` mount 已禁用,不能借由手动表单绕过 P1/P2/P3 绑定。未来只有被冻结的 P2 候选明确引用、并可在 P3 复算的插件 artifact 才能接入运行时,详见[策略插件契约 V2](docs/qsl_strategy_plugin_contract_v2.zh-CN.md)
9293
- workflow 绑定 GitHub Environment `runtime-strategy-switch`。个人系统默认不需要 required reviewers;建议把 `RUNTIME_SETTINGS_GH_TOKEN` 配成这个 Environment 的 secret,真实写入靠 preview、确认词和 token 最小权限控制。
9394
- 启用真实切换前请按 [手动策略切换权限控制方案](docs/manual_strategy_switch_permission_control.zh-CN.md) 完成最简 secret、token 权限和回滚准备。

docs/manual_strategy_switch_permission_control.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@
6161

6262
这就是个人模式下的一键切换。不需要找 Codex,也不需要人工审批。
6363

64+
`dry_run` 是所有平台共用的“不下单演练”选项。它不等同于 P4 paper 账户,也不会启用订单或实盘资格;`paper` 仅为旧调用兼容,会按 `dry_run` 处理。每个策略必须保持至少一条按市场域匹配的平台 `dry_run` 路线,健康报告会在配置漂移时失败。
65+
6466
## 保留的安全门
6567

6668
这些防线不会增加太多操作成本,但能挡住常见误操作:
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# QSL 通用不下单演练覆盖 V1
2+
3+
> 状态:已接入 `QuantRuntimeSettings` 配置校验、健康报告与策略切换控制台。
4+
5+
## 要解决的问题
6+
7+
策略档案原本可以声明 `allowed_execution_modes: ["dry_run"]`,但手动切换器会把该请求写成 `execution_mode=paper``dry_run_only=true`。这样虽然多数旧平台仍不会下单,却会在控制平面的策略许可校验中被误判为“策略不允许 paper”,使本该可用的安全演练路径无法使用。
8+
9+
本规范把三件事分开:
10+
11+
| 名称 | 当前含义 | 是否会下单 |
12+
| --- | --- | --- |
13+
| `dry_run` | 所有注册策略和平台必须提供的安全演练路径 ||
14+
|`paper + dry_run_only=true` | 已有运行目标的兼容表示;按策略政策等价于 `dry_run` ||
15+
| P4 `PAPER_BROKER` | 独立 paper 账户、凭据、订单适配器、对账与签名 policy 的未来能力 | 仅在其独立接线后 |
16+
17+
因此,控制台的“演练(不下单)”不是 P4,也不是实盘开关。它不会赋予 broker、订单、资金、P4/P5/P6 或自动晋级权限。
18+
19+
## 通用契约
20+
21+
`platform-config.json` 的每个平台 deployment 必须声明:
22+
23+
```json
24+
{
25+
"default_execution_mode": "live",
26+
"supported_execution_modes": ["live", "dry_run"]
27+
}
28+
```
29+
30+
其中 `supported_execution_modes`**当前控制面可安全提交的模式**,而不是券商产品或凭据能力表。P4 尚未接线,所以不得在这里声明 `paper`。未配置实盘的平台只能声明:
31+
32+
```json
33+
{
34+
"default_execution_mode": "dry_run",
35+
"supported_execution_modes": ["dry_run"],
36+
"dry_run_only": true
37+
}
38+
```
39+
40+
每个策略的 `allowed_execution_modes` 必须包含 `dry_run`。健康报告同时检查策略域与平台支持域的交集:任何策略没有至少一条 `dry_run` 路径时,`strategy_platform_dry_run_coverage` 会以 critical failure 失败,自动修复流程只能修复配置/生成物,不能启用订单或提高生命周期。
41+
42+
## 插件边界
43+
44+
插件 mount 的 `expected_mode` 只能是 `dry_run``paper``shadow`,不得请求 `live`。这只是拒绝插件借配置获得实盘权限;它并不自动挂载插件。旧 custom mount 继续停用,未来插件仍必须由冻结的 P1/P2/P3 `strategy_plugin_signal.v2` 适配器绑定并可复算。
45+
46+
## 操作与验证
47+
48+
控制台和 `Manual Strategy Switch` 都提供 `dry_run`。选择它会写入 `dry_run_only=true`;为兼容现有平台同步适配器,当前生成的运行目标仍使用 `execution_mode=paper``dry_run_only=true` 这个不下单载荷,控制面一律把它按 `dry_run` 校验。`paper` workflow 输入只为旧调用兼容保留,且仍受策略原有 `paper` 许可约束;它不能绕过策略许可,也不能当作 P4 paper 交易使用。
49+
50+
提交任何策略、平台或插件配置变更前后运行:
51+
52+
```bash
53+
python3 python/scripts/build_config.py --check
54+
python3 python/scripts/runtime_settings.py validate
55+
python3 python/scripts/build_config.py --platform-health-report
56+
node tests/strategy_switch_worker_validation.mjs
57+
```
58+
59+
完整 Python、生成物一致性、Worker 与安全检查由 GitHub-hosted CI 执行。通过这些检查只说明配置和不下单控制链完整;真实平台连通、数据质量、P4/P5 receipt 与 P6 实盘资格仍由各自独立的运行证据和策略门槛决定。

platform-config.json

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,9 @@
155155
"default_execution_mode": "live"
156156
},
157157
"deployment": {
158-
"default_execution_mode": "live",
159-
"dry_run_only": false,
158+
"default_execution_mode": "live",
159+
"supported_execution_modes": ["live", "dry_run"],
160+
"dry_run_only": false,
160161
"runtime_model": "cloud_run",
161162
"settings_activation": "cloud_run_sync_workflow",
162163
"live_configured": true,
@@ -197,8 +198,9 @@
197198
"default_execution_mode": "live"
198199
},
199200
"deployment": {
200-
"default_execution_mode": "live",
201-
"dry_run_only": false,
201+
"default_execution_mode": "live",
202+
"supported_execution_modes": ["live", "dry_run"],
203+
"dry_run_only": false,
202204
"runtime_model": "cloud_run",
203205
"settings_activation": "cloud_run_sync_workflow",
204206
"live_configured": true,
@@ -237,8 +239,9 @@
237239
"default_execution_mode": "live"
238240
},
239241
"deployment": {
240-
"default_execution_mode": "live",
241-
"dry_run_only": false,
242+
"default_execution_mode": "live",
243+
"supported_execution_modes": ["live", "dry_run"],
244+
"dry_run_only": false,
242245
"runtime_model": "cloud_run",
243246
"settings_activation": "cloud_run_sync_workflow",
244247
"live_configured": true,
@@ -277,8 +280,9 @@
277280
"default_execution_mode": "live"
278281
},
279282
"deployment": {
280-
"default_execution_mode": "live",
281-
"dry_run_only": false,
283+
"default_execution_mode": "live",
284+
"supported_execution_modes": ["live", "dry_run"],
285+
"dry_run_only": false,
282286
"runtime_model": "cloud_run",
283287
"settings_activation": "cloud_run_sync_workflow",
284288
"live_configured": true,
@@ -316,8 +320,9 @@
316320
]
317321
},
318322
"deployment": {
319-
"default_execution_mode": "paper",
320-
"dry_run_only": true,
323+
"default_execution_mode": "dry_run",
324+
"supported_execution_modes": ["dry_run"],
325+
"dry_run_only": true,
321326
"runtime_model": "not_configured",
322327
"settings_activation": "not_wired",
323328
"live_configured": false,
@@ -355,8 +360,9 @@
355360
]
356361
},
357362
"deployment": {
358-
"default_execution_mode": "live",
359-
"dry_run_only": false,
363+
"default_execution_mode": "live",
364+
"supported_execution_modes": ["live", "dry_run"],
365+
"dry_run_only": false,
360366
"runtime_model": "oracle_vps_self_hosted",
361367
"settings_activation": "next_runtime_workflow_dispatch",
362368
"live_configured": true,

python/scripts/build_config.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@
4242
MARKET_FIELDS = {"market", "market_calendar", "market_timezone"}
4343
FEATURE_SNAPSHOT_FIELDS = {"required", "path", "manifest_path"}
4444
RUNTIME_MODELS = {"cloud_run", "oracle_vps_self_hosted", "not_configured"}
45+
EXECUTION_MODES = {"live", "paper", "dry_run"}
46+
# ``paper`` remains a future P4 capability. The executable non-live path is
47+
# the no-order ``dry_run`` route declared by every registered platform.
48+
CONTROL_EXECUTION_MODES = {"live", "dry_run"}
4549
SETTINGS_ACTIVATION_MODES = {
4650
"cloud_run_sync_workflow",
4751
"next_runtime_workflow_dispatch",
@@ -137,6 +141,34 @@ def validate(config: dict) -> list[str]:
137141
)
138142
if not isinstance(deployment.get("live_configured"), bool):
139143
errors.append(f"platform {pid}: live_configured must be boolean")
144+
supported_execution_modes = deployment.get("supported_execution_modes")
145+
if not isinstance(supported_execution_modes, list) or not supported_execution_modes:
146+
errors.append(f"platform {pid}: supported_execution_modes must be a non-empty list")
147+
supported_execution_modes = []
148+
elif any(
149+
not isinstance(mode, str) or mode not in CONTROL_EXECUTION_MODES
150+
for mode in supported_execution_modes
151+
):
152+
errors.append(
153+
f"platform {pid}: supported_execution_modes must only contain "
154+
f"{sorted(CONTROL_EXECUTION_MODES)}"
155+
)
156+
elif len(set(supported_execution_modes)) != len(supported_execution_modes):
157+
errors.append(f"platform {pid}: supported_execution_modes must not contain duplicates")
158+
default_execution_mode = deployment.get("default_execution_mode")
159+
if default_execution_mode not in EXECUTION_MODES:
160+
errors.append(f"platform {pid}: unsupported default_execution_mode {default_execution_mode!r}")
161+
elif default_execution_mode not in supported_execution_modes:
162+
errors.append(
163+
f"platform {pid}: default_execution_mode {default_execution_mode!r} "
164+
"must be listed in supported_execution_modes"
165+
)
166+
if deployment.get("live_configured") is False and "live" in supported_execution_modes:
167+
errors.append(f"platform {pid}: live_configured false cannot advertise live execution")
168+
if deployment.get("dry_run_only") is True and supported_execution_modes != ["dry_run"]:
169+
errors.append(
170+
f"platform {pid}: dry_run_only true requires supported_execution_modes ['dry_run']"
171+
)
140172
if settings_activation == "cloud_run_sync_workflow" and runtime_model != "cloud_run":
141173
errors.append(
142174
f"platform {pid}: settings_activation {settings_activation!r} "
@@ -230,6 +262,17 @@ def validate(config: dict) -> list[str]:
230262
f"strategy {sid}: scheduler timezone {scheduler_timezone!r} "
231263
f"must match market_timezone {market_timezone!r}"
232264
)
265+
allowed_execution_modes = _normalize_allowed_execution_modes(sdata.get("allowed_execution_modes"))
266+
if not allowed_execution_modes:
267+
errors.append(f"strategy {sid}: allowed_execution_modes must be a non-empty list")
268+
elif any(mode not in EXECUTION_MODES for mode in allowed_execution_modes):
269+
errors.append(
270+
f"strategy {sid}: allowed_execution_modes must only contain {sorted(EXECUTION_MODES)}"
271+
)
272+
elif "dry_run" not in allowed_execution_modes:
273+
errors.append(
274+
f"strategy {sid}: allowed_execution_modes must include dry_run for the universal no-order path"
275+
)
233276
plugin_overrides = sdata.get("scheduler_profile_by_plugin", {})
234277
if not isinstance(plugin_overrides, dict):
235278
errors.append(f"strategy {sid}: scheduler_profile_by_plugin must be an object")
@@ -310,6 +353,63 @@ def validate(config: dict) -> list[str]:
310353
return errors
311354

312355

356+
def build_strategy_platform_dry_run_coverage(config: dict | None = None) -> dict[str, object]:
357+
"""Report whether every strategy has at least one no-order platform route.
358+
359+
This is a control-plane coverage proof, not a claim that P4 paper, P5
360+
shadow, broker credentials, or a scheduler is active. A route exists only
361+
when the strategy domain and both sides' ``dry_run`` declarations agree.
362+
"""
363+
config = config if config is not None else load_config()
364+
platforms = config.get("platforms", {})
365+
strategies = config.get("strategies", {})
366+
rows: list[dict[str, object]] = []
367+
uncovered_profiles: list[str] = []
368+
route_count = 0
369+
370+
for profile, strategy in sorted(strategies.items()):
371+
if not isinstance(strategy, dict):
372+
uncovered_profiles.append(str(profile))
373+
continue
374+
domain = str(strategy.get("domain") or "")
375+
strategy_modes = _normalize_allowed_execution_modes(strategy.get("allowed_execution_modes"))
376+
safe_platforms: list[str] = []
377+
for platform_id, platform in sorted(platforms.items()):
378+
if not isinstance(platform, dict) or domain not in platform.get("supported_domains", []):
379+
continue
380+
deployment = platform.get("deployment")
381+
platform_modes = (
382+
deployment.get("supported_execution_modes", [])
383+
if isinstance(deployment, dict)
384+
else []
385+
)
386+
if "dry_run" in strategy_modes and "dry_run" in platform_modes:
387+
safe_platforms.append(str(platform_id))
388+
route_count += len(safe_platforms)
389+
if not safe_platforms:
390+
uncovered_profiles.append(str(profile))
391+
rows.append(
392+
{
393+
"profile": str(profile),
394+
"domain": domain,
395+
"dry_run_platforms": safe_platforms,
396+
}
397+
)
398+
399+
return {
400+
"schema_version": "strategy_platform_dry_run_coverage.v1",
401+
"summary": {
402+
"strategy_count": len(rows),
403+
"covered_strategy_count": len(rows) - len(uncovered_profiles),
404+
"uncovered_strategy_count": len(uncovered_profiles),
405+
"dry_run_route_count": route_count,
406+
},
407+
"uncovered_profiles": uncovered_profiles,
408+
"profiles": rows,
409+
"boundary": "No-order dry_run coverage only; it does not assert P4/P5/P6 runtime authority.",
410+
}
411+
412+
313413
def validate_notification_references(config: dict, errors: list[str]) -> None:
314414
"""Keep every Telegram notification route in runtime configuration."""
315415
notifications = config.get("notifications")
@@ -568,6 +668,7 @@ def build_platform_health_report(
568668
catalog = _strategy_catalog_by_profile(strategy_catalog)
569669
config_errors = validate(config)
570670
derivation_errors = report_strategy_profile_derivation_drift(config, catalog)
671+
dry_run_coverage = build_strategy_platform_dry_run_coverage(config)
571672
live_candidate_queue = build_live_candidate_queue(catalog)
572673
automation_registry = build_strategy_automation_registry(config)
573674
runtime_enabled_profiles = [
@@ -588,6 +689,19 @@ def build_platform_health_report(
588689
"severity": "critical",
589690
"messages": derivation_errors,
590691
},
692+
{
693+
"name": "strategy_platform_dry_run_coverage",
694+
"status": "fail" if dry_run_coverage["uncovered_profiles"] else "pass",
695+
"severity": "critical",
696+
"messages": (
697+
[
698+
"no configured no-order platform route for: "
699+
+ ", ".join(dry_run_coverage["uncovered_profiles"])
700+
]
701+
if dry_run_coverage["uncovered_profiles"]
702+
else []
703+
),
704+
},
591705
{
592706
"name": "live_candidate_queue",
593707
"status": "warn" if live_candidate_queue else "pass",
@@ -619,9 +733,13 @@ def build_platform_health_report(
619733
"strategy_profile_count": len(catalog),
620734
"runtime_enabled_switchable_count": len(runtime_enabled_profiles),
621735
"live_candidate_queue_count": len(live_candidate_queue),
736+
"dry_run_covered_strategy_count": dry_run_coverage["summary"]["covered_strategy_count"],
737+
"dry_run_uncovered_strategy_count": dry_run_coverage["summary"]["uncovered_strategy_count"],
738+
"dry_run_route_count": dry_run_coverage["summary"]["dry_run_route_count"],
622739
"automation_lane_counts": automation_registry["summary"]["lane_counts"],
623740
},
624741
"live_candidate_queue": live_candidate_queue,
742+
"strategy_platform_dry_run_coverage": dry_run_coverage,
625743
"automation_registry": automation_registry,
626744
"codex_repair_context": {
627745
"safe_to_attempt": bool(failed_checks),

python/scripts/build_platform_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def build_config_module(config: dict) -> str:
5050
"income_layer": caps.get("income_layer", False),
5151
"option_overlay": caps.get("option_overlay", False),
5252
"dca": caps.get("dca", False),
53+
"supported_execution_modes": depl.get("supported_execution_modes", []),
5354
"execution_mode": depl.get("default_execution_mode", "live"),
5455
"service_name": depl.get("service_name", ""),
5556
"default_execution_mode": depl.get("default_execution_mode", "live"),

0 commit comments

Comments
 (0)