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
36 changes: 36 additions & 0 deletions docs/qsl_lifecycle_truth_v1.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# QSL 生命周期唯一语义 v1

策略 catalog、跨资产 inventory 和 evidence package 只描述策略所处阶段,
不产生 paper 或 live 权限。规范状态由 QuantPlatformKit 定义:

```text
research_active -> shadow_active -> paper_active(平台支持时)
-> live_candidate -> live_enabled
```

旧状态按只读 catalog 语义保守映射:

| 旧状态 | 规范状态 |
| --- | --- |
| `research_backtest_only` | `research_active` |
| `ai_monitored_candidate` | `research_active` |
| `shadow_candidate` | `shadow_active` |
| `runtime_enabled` | `live_candidate` |

`runtime_enabled` 是旧策略包的“runtime 可选择”字段,不能单独证明真实下单已获批。
旧 live 策略可以继续运行,但 QuantRuntimeSettings 只在以下部署字段全部明确成立时
接受 live 请求:

```text
runtime_enabled == true
can_switch_live == true
lifecycle_stage in {live_enabled, runtime_enabled}
allowed_execution_modes contains live
blocked_live_reason is empty
```

其中 `runtime_enabled` lifecycle 名仅为旧部署兼容。新部署应写
`live_enabled`,并继续经过当前 Risk Gate、broker/account 权限和部署授权检查。

缺少任意字段时都 fail closed。设置网站、Worker、配置生成器和后端验证不得从
catalog 名称、默认值或 inventory 状态推导 live 权限。
17 changes: 10 additions & 7 deletions python/scripts/build_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ def strategy_to_json_compat(strategies: dict) -> list[dict]:
"label_en": s.get("label_en", s["label"]),
"label_zh": s["label"],
"domain": s["domain"],
"runtime_enabled": s.get("runtime_enabled", True),
"runtime_enabled": s.get("runtime_enabled", False),
}
entry.update(_strategy_profile_gate_fields(s))
f = s.get("features", {})
Expand Down Expand Up @@ -641,7 +641,7 @@ def _sort_key(sdata: dict) -> tuple[int, str]:

def _normalize_allowed_execution_modes(raw_modes: object) -> list[str]:
if raw_modes is None:
return ["live", "paper", "dry_run"]
return ["paper", "dry_run"]
if isinstance(raw_modes, str):
modes = [raw_modes.strip()]
elif isinstance(raw_modes, list):
Expand All @@ -651,18 +651,21 @@ def _normalize_allowed_execution_modes(raw_modes: object) -> list[str]:
elif isinstance(raw_modes, set):
modes = [str(mode).strip() for mode in sorted(raw_modes)]
else:
modes = ["live", "paper", "dry_run"]
modes = ["paper", "dry_run"]
modes = [mode for mode in modes if mode]
return modes if modes else ["live", "paper", "dry_run"]
return modes if modes else ["paper", "dry_run"]


def _strategy_profile_gate_fields(sdata: dict) -> dict[str, object]:
runtime_enabled = sdata.get("runtime_enabled", True)
runtime_enabled = sdata.get("runtime_enabled", False)
lifecycle_stage = str(
sdata.get("lifecycle_stage") or ("runtime_enabled" if runtime_enabled else "research_backtest_only")
sdata.get("lifecycle_stage") or ("runtime_enabled" if runtime_enabled else "research_active")
).strip()
blocked_live_reason = sdata.get("blocked_live_reason")
can_switch_live = sdata.get("can_switch_live", runtime_enabled and lifecycle_stage == "runtime_enabled")
can_switch_live = sdata.get(
"can_switch_live",
runtime_enabled and lifecycle_stage in {"live_enabled", "runtime_enabled"},
)
if blocked_live_reason is None and not can_switch_live:
blocked_live_reason = lifecycle_stage or "not_runtime_enabled"
return {
Expand Down
17 changes: 10 additions & 7 deletions python/scripts/build_platform_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ def build_strategy_profile_entries(config: dict) -> list[dict]:
"label_en": sdata.get("label_en", sid),
"label_zh": sdata.get("label", sid),
"domain": sdata.get("domain", ""),
"runtime_enabled": sdata.get("runtime_enabled", True),
"runtime_enabled": sdata.get("runtime_enabled", False),
"income_layer_enabled": feat.get("income_layer", False),
"option_overlay_enabled": feat.get("option_overlay", False),
"combo_enabled": feat.get("combo", False),
Expand Down Expand Up @@ -258,7 +258,7 @@ def build_strategy_profile_entries(config: dict) -> list[dict]:

def _normalize_allowed_execution_modes(raw_modes: object) -> list[str]:
if raw_modes is None:
return ["live", "paper", "dry_run"]
return ["paper", "dry_run"]
if isinstance(raw_modes, str):
modes = [raw_modes.strip()]
elif isinstance(raw_modes, list):
Expand All @@ -268,18 +268,21 @@ def _normalize_allowed_execution_modes(raw_modes: object) -> list[str]:
elif isinstance(raw_modes, set):
modes = [str(mode).strip() for mode in sorted(raw_modes)]
else:
modes = ["live", "paper", "dry_run"]
modes = ["paper", "dry_run"]
modes = [mode for mode in modes if mode]
return modes if modes else ["live", "paper", "dry_run"]
return modes if modes else ["paper", "dry_run"]


def _strategy_profile_gate_fields(sdata: dict) -> dict[str, object]:
runtime_enabled = sdata.get("runtime_enabled", True)
runtime_enabled = sdata.get("runtime_enabled", False)
lifecycle_stage = str(
sdata.get("lifecycle_stage") or ("runtime_enabled" if runtime_enabled else "research_backtest_only")
sdata.get("lifecycle_stage") or ("runtime_enabled" if runtime_enabled else "research_active")
).strip()
blocked_live_reason = sdata.get("blocked_live_reason")
can_switch_live = sdata.get("can_switch_live", runtime_enabled and lifecycle_stage == "runtime_enabled")
can_switch_live = sdata.get(
"can_switch_live",
runtime_enabled and lifecycle_stage in {"live_enabled", "runtime_enabled"},
)
if blocked_live_reason is None and not can_switch_live:
blocked_live_reason = lifecycle_stage or "not_runtime_enabled"
return {
Expand Down
15 changes: 9 additions & 6 deletions python/scripts/inject_platform_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,14 @@ def main() -> int:

def _strategy_profile_entry(sid: str, sdata: dict) -> dict:
feat = sdata.get("features", {})
runtime_enabled = sdata.get("runtime_enabled", True)
runtime_enabled = sdata.get("runtime_enabled", False)
lifecycle_stage = str(
sdata.get("lifecycle_stage") or ("runtime_enabled" if runtime_enabled else "research_backtest_only")
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 == "runtime_enabled")
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"
Expand All @@ -177,7 +180,7 @@ def _strategy_profile_entry(sid: str, sdata: dict) -> dict:

def _normalize_allowed_execution_modes(raw_modes: object) -> list[str]:
if raw_modes is None:
return ["live", "paper", "dry_run"]
return ["paper", "dry_run"]
if isinstance(raw_modes, str):
modes = [raw_modes.strip()]
elif isinstance(raw_modes, list):
Expand All @@ -187,9 +190,9 @@ def _normalize_allowed_execution_modes(raw_modes: object) -> list[str]:
elif isinstance(raw_modes, set):
modes = [str(mode).strip() for mode in sorted(raw_modes)]
else:
modes = ["live", "paper", "dry_run"]
modes = ["paper", "dry_run"]
modes = [mode for mode in modes if mode]
return modes if modes else ["live", "paper", "dry_run"]
return modes if modes else ["paper", "dry_run"]


if __name__ == "__main__":
Expand Down
13 changes: 10 additions & 3 deletions python/scripts/runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,10 +530,17 @@ def validate_runtime_target_strategy_policy(runtime_target: dict[str, Any], erro
lifecycle_stage = str(strategy.get("lifecycle_stage") or "").strip()
if strategy.get("runtime_enabled") is not True:
errors.append(f"runtime_target.strategy_profile {profile} is not runtime_enabled")
if strategy.get("can_switch_live") is False:
if strategy.get("can_switch_live") is not True:
errors.append(f"runtime_target.strategy_profile {profile} cannot switch live")
if lifecycle_stage and lifecycle_stage != "runtime_enabled":
errors.append(f"runtime_target.strategy_profile {profile} lifecycle_stage must be runtime_enabled for live")
if lifecycle_stage not in {"live_enabled", "runtime_enabled"}:
errors.append(
f"runtime_target.strategy_profile {profile} lifecycle_stage must be "
"live_enabled (or legacy runtime_enabled) for live"
)
if "live" not in allowed_modes:
errors.append(
f"runtime_target.strategy_profile {profile} must explicitly allow live execution"
)
blocked_reason = str(strategy.get("blocked_live_reason") or "").strip()
if blocked_reason:
errors.append(f"runtime_target.strategy_profile {profile} is blocked for live: {blocked_reason}")
Expand Down
16 changes: 10 additions & 6 deletions python/scripts/sync_strategy_switch_page_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

def _normalize_allowed_execution_modes(raw_modes: object) -> list[str]:
if raw_modes is None:
return ["live", "paper", "dry_run"]
return ["paper", "dry_run"]
if isinstance(raw_modes, str):
modes = [raw_modes.strip()]
elif isinstance(raw_modes, list):
Expand All @@ -31,9 +31,9 @@ def _normalize_allowed_execution_modes(raw_modes: object) -> list[str]:
elif isinstance(raw_modes, set):
modes = [str(mode).strip() for mode in sorted(raw_modes)]
else:
modes = ["live", "paper", "dry_run"]
modes = ["paper", "dry_run"]
modes = [mode for mode in modes if mode]
return modes if modes else ["live", "paper", "dry_run"]
return modes if modes else ["paper", "dry_run"]


def _load_strategy_config_fields() -> dict[str, dict]:
Expand All @@ -54,15 +54,19 @@ def _enrich_profiles_from_config(profiles: list[dict]) -> list[dict]:
item = dict(profile)
sid = str(item.get("profile", "")).strip()
config_fields = strategy_config.get(sid, {})
runtime_enabled = config_fields.get("runtime_enabled", item.get("runtime_enabled", True))
runtime_enabled = config_fields.get("runtime_enabled", item.get("runtime_enabled", False))
lifecycle_stage = str(
config_fields.get("lifecycle_stage")
or item.get("lifecycle_stage")
or ("runtime_enabled" if runtime_enabled else "research_backtest_only")
or ("runtime_enabled" if runtime_enabled else "research_active")
).strip()
can_switch_live = config_fields.get(
"can_switch_live",
item.get("can_switch_live", runtime_enabled and lifecycle_stage == "runtime_enabled"),
item.get(
"can_switch_live",
runtime_enabled
and lifecycle_stage in {"live_enabled", "runtime_enabled"},
),
)
blocked_live_reason = config_fields.get("blocked_live_reason", item.get("blocked_live_reason"))
if blocked_live_reason is None and not can_switch_live:
Expand Down
42 changes: 38 additions & 4 deletions python/tests/test_runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,40 @@ def test_runtime_target_rejects_live_switch_for_non_runtime_profile(self):
errors,
)

def test_runtime_target_never_infers_live_permission_from_catalog_status(self):
config = build_config.load_config()
config["strategies"]["global_etf_rotation"] = {
**config["strategies"]["global_etf_rotation"],
"runtime_enabled": True,
"lifecycle_stage": "runtime_enabled",
}
config["strategies"]["global_etf_rotation"].pop("can_switch_live", None)
config["strategies"]["global_etf_rotation"].pop(
"allowed_execution_modes", None
)
errors = []

with patch.object(
runtime_settings, "load_platform_config", return_value=config
):
runtime_settings.validate_runtime_target_strategy_policy(
{
"platform_id": "ibkr",
"strategy_profile": "global_etf_rotation",
"execution_mode": "live",
},
errors,
)

self.assertIn(
"runtime_target.strategy_profile global_etf_rotation cannot switch live",
errors,
)
self.assertIn(
"runtime_target.strategy_profile global_etf_rotation must explicitly allow live execution",
errors,
)

def load_target(self, relative_path: str):
path = ROOT / relative_path
return path, runtime_settings.load_target(path)
Expand Down Expand Up @@ -736,10 +770,10 @@ def test_build_platform_config_build_strategy_profile_entries_defaults_gate_fiel
})
profile = payload[0]

self.assertEqual(profile["lifecycle_stage"], "runtime_enabled")
self.assertTrue(profile["can_switch_live"])
self.assertEqual(profile["allowed_execution_modes"], ["live", "paper", "dry_run"])
self.assertEqual(profile["blocked_live_reason"], "")
self.assertEqual(profile["lifecycle_stage"], "research_active")
self.assertFalse(profile["can_switch_live"])
self.assertEqual(profile["allowed_execution_modes"], ["paper", "dry_run"])
self.assertEqual(profile["blocked_live_reason"], "research_active")

def test_assignment_payload_can_redact_values(self):
_, target = self.load_target("examples/targets/longbridge/sg.example.json")
Expand Down
16 changes: 14 additions & 2 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ const strategyProfiles = __test.normalizeStrategyProfilesPayload(
label_zh: "TQQQ 增长收益",
domain: "us_equity",
runtime_enabled: true,
lifecycle_stage: "live",
lifecycle_stage: "live_enabled",
can_switch_live: true,
allowed_execution_modes: ["live", "paper"],
income_layer_enabled: true,
Expand All @@ -348,6 +348,9 @@ const strategyProfiles = __test.normalizeStrategyProfilesPayload(
label: "HK Low-Vol Dividend Quality Snapshot",
domain: "hk_equity",
runtime_enabled: true,
lifecycle_stage: "live_enabled",
can_switch_live: true,
allowed_execution_modes: ["live", "paper"],
},
{
profile: "us_equity_combo_leveraged",
Expand All @@ -373,7 +376,7 @@ const strategyProfiles = __test.normalizeStrategyProfilesPayload(
);
assert.equal(strategyProfiles[0].label_en, "TQQQ Growth Income");
assert.equal(strategyProfiles[0].label_zh, "TQQQ 增长收益");
assert.equal(strategyProfiles[0].lifecycle_stage, "live");
assert.equal(strategyProfiles[0].lifecycle_stage, "live_enabled");
assert.equal(strategyProfiles[0].can_switch_live, true);
assert.deepEqual(strategyProfiles[0].allowed_execution_modes, ["live", "paper"]);
assert.equal(strategyProfiles[0].income_layer_enabled, true);
Expand Down Expand Up @@ -422,6 +425,15 @@ assert.throws(
),
/not live-enabled/,
);
assert.throws(
() =>
__test.assertStrategyAllowedForAccount(
{ platform: "longbridge", strategy_profile: "nasdaq_sp500_smart_dca", execution_mode: "live" },
DEFAULT_ACCOUNT_OPTIONS.longbridge[0],
strategyProfiles,
),
/not live-enabled/,
);
assert.doesNotThrow(() =>
__test.assertStrategyAllowedForAccount(
{ platform: "longbridge", strategy_profile: "us_equity_combo_leveraged", execution_mode: "paper" },
Expand Down
10 changes: 5 additions & 5 deletions web/strategy-switch-console/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1498,7 +1498,7 @@
label_en: nextLabels[profile].en || "",
label_zh: nextLabels[profile].zh || "",
domain,
runtime_enabled: cleanOptionalBoolean(item?.runtime_enabled ?? item?.live_enabled ?? true) !== false,
runtime_enabled: cleanOptionalBoolean(item?.runtime_enabled ?? false) === true,
};
const lifecycleStage = normalizeLifecycleStage(item?.lifecycle_stage ?? item?.lifecycleStage);
if (lifecycleStage) nextCatalog[profile].lifecycle_stage = lifecycleStage;
Expand Down Expand Up @@ -1679,12 +1679,12 @@

function strategyCanSwitchLive(entry) {
if (!entry || typeof entry !== "object") return false;
if (entry.runtime_enabled === false) return false;
if (entry.runtime_enabled !== true) return false;
const allowedModes = normalizeAllowedExecutionModes(entry.allowed_execution_modes);
if (allowedModes.length && !allowedModes.includes("live")) return false;
if (cleanOptionalBoolean(entry.can_switch_live) === false) return false;
if (!allowedModes.includes("live")) return false;
if (cleanOptionalBoolean(entry.can_switch_live) !== true) return false;
const lifecycleStage = normalizeLifecycleStage(entry.lifecycle_stage);
if (lifecycleStage && ["research", "draft", "blocked", "archived", "disabled"].includes(lifecycleStage)) return false;
if (!["live_enabled", "runtime_enabled"].includes(lifecycleStage)) return false;
const blockedReason = cleanDisplayText(entry.blocked_live_reason);
if (blockedReason) return false;
const evidenceStatus = cleanDisplayText(entry.latest_evidence_status);
Expand Down
2 changes: 1 addition & 1 deletion web/strategy-switch-console/app_js.js

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions web/strategy-switch-console/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2778,10 +2778,15 @@ function assertStrategyAllowedForAccount(inputs, accountOption, strategyProfiles
const executionMode = cleanExecutionMode(inputs.execution_mode);
const allowedModes = strategy.allowed_execution_modes || [];
if (executionMode === "live") {
if (strategy.runtime_enabled !== true || strategy.can_switch_live === false) {
const lifecycleStage = cleanLifecycleStage(strategy.lifecycle_stage || "research_active");
if (
strategy.runtime_enabled !== true ||
strategy.can_switch_live !== true ||
!["live_enabled", "runtime_enabled"].includes(lifecycleStage)
) {
throw new Error(`strategy ${inputs.strategy_profile} is not live-enabled`);
}
if (allowedModes.length && !allowedModes.includes(executionMode)) {
if (!allowedModes.includes(executionMode)) {
throw new Error(`strategy ${inputs.strategy_profile} is not live-enabled`);
}
if (strategy.blocked_live_reason) {
Expand Down Expand Up @@ -2911,7 +2916,7 @@ function normalizeStrategyProfilesPayload(payload, fieldName = "strategy profile
const entry = {
profile,
label: cleanLabel(item.label || item.display_name || profile, `${fieldName}[${index}].label`),
runtime_enabled: cleanProfileBoolean(item.runtime_enabled ?? item.live_enabled ?? true),
runtime_enabled: cleanProfileBoolean(item.runtime_enabled ?? false),
};
addConfigOptional(
entry,
Expand Down