From 2742d26abe215ed9212f12a7848c428e7b85efa9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:58:33 +0800 Subject: [PATCH] feat: preserve current plugin mounts during continuity recovery Co-Authored-By: Codex --- .github/workflows/manual-strategy-switch.yml | 52 +++++++++++- README.zh-CN.md | 2 +- python/scripts/build_runtime_switch.py | 28 ++++++- python/tests/test_runtime_settings.py | 85 ++++++++++++++++++++ 4 files changed, 164 insertions(+), 3 deletions(-) diff --git a/.github/workflows/manual-strategy-switch.yml b/.github/workflows/manual-strategy-switch.yml index 620f00a..c4fae44 100644 --- a/.github/workflows/manual-strategy-switch.yml +++ b/.github/workflows/manual-strategy-switch.yml @@ -82,12 +82,13 @@ on: required: false type: string plugin_mode: - description: "none is the safe default. auto is a compatibility alias for none; legacy custom mounts are disabled until P1/P2-bound plugin artifacts exist." + description: "none disables mounts. current preserves the selected strategy's existing mounts exactly; it cannot add or change a plugin." required: true type: choice default: none options: - none + - current extra_variables_json: description: "Optional JSON object of non-secret extra variables. DCA profiles may include dca_mode and dca_base_investment_usd control fields. cash_only_execution_mode may be current, enabled, or disabled. Research-only option overlays are rejected." required: false @@ -336,7 +337,53 @@ jobs: if [ -f "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then echo "existing CLOUD_RUN_SERVICE_TARGETS_JSON requires service_targets_mode=patch or allow_create" >&2 exit 2 + fi + + - name: Fetch current plugin mounts for continuity + if: env.PLUGIN_MODE == 'current' + env: + TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }} + run: | + set -euo pipefail + case "${PLATFORM}" in + longbridge) mounts_variable="LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON" ;; + ibkr) mounts_variable="IBKR_STRATEGY_PLUGIN_MOUNTS_JSON" ;; + schwab) mounts_variable="SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON" ;; + firstrade) mounts_variable="FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON" ;; + qmt) mounts_variable="QMT_STRATEGY_PLUGIN_MOUNTS_JSON" ;; + binance) mounts_variable="BINANCE_STRATEGY_PLUGIN_MOUNTS_JSON" ;; + *) echo "Unsupported platform: ${PLATFORM}" >&2; exit 2 ;; + esac + target_environment="" + if [ "${VARIABLE_SCOPE}" = "environment" ]; then + target_environment="${GITHUB_ENVIRONMENT_NAME:-${TARGET_NAME}}" + elif [ "${VARIABLE_SCOPE}" = "default" ] && [ "${PLATFORM}" = "longbridge" ]; then + target_environment="longbridge-${TARGET_NAME}" fi + output_file="${RUNNER_TEMP}/current-plugin-mounts.json" + python - <<'PY' "${TARGET_REPOSITORY}" "${mounts_variable}" "${target_environment}" "${output_file}" + import json + import subprocess + import sys + + repo, variable, environment, output_path = sys.argv[1:5] + command = ["gh", "variable", "list", "--repo", repo, "--json", "name,value"] + if environment: + command.extend(["--env", environment]) + values = json.loads(subprocess.check_output(command, text=True)) + raw = next((item.get("value") for item in values if item.get("name") == variable), None) + if not isinstance(raw, str) or not raw.strip(): + raise SystemExit(f"{variable} is required when plugin_mode=current") + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise SystemExit(f"{variable} must contain valid JSON") from exc + if not isinstance(payload, dict) or not isinstance(payload.get("strategy_plugins"), list): + raise SystemExit(f"{variable} must contain a strategy_plugins array") + with open(output_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, separators=(",", ":")) + PY + echo "CURRENT_PLUGIN_MOUNTS_JSON_FILE=${output_file}" >> "$GITHUB_ENV" - name: Build switch target run: | @@ -369,6 +416,9 @@ jobs: if [ -n "${SERVICE_NAME:-}" ]; then args+=(--service-name "${SERVICE_NAME}") fi + if [ "${PLUGIN_MODE}" = "current" ]; then + args+=(--current-plugin-mounts-json-file "${CURRENT_PLUGIN_MOUNTS_JSON_FILE}") + fi if [ "${LIVE_CONTINUITY_STATE}" != "NONE" ]; then args+=(--live-continuity-state "${LIVE_CONTINUITY_STATE}") args+=(--live-continuity-baseline-id "${LIVE_CONTINUITY_BASELINE_ID}") diff --git a/README.zh-CN.md b/README.zh-CN.md index 7af483f..5ed5bdb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -65,7 +65,7 @@ python3 -m unittest discover -s python/tests -v 3. 再运行 `apply=true`,并填写 `confirm_apply=APPLY`,写入目标仓库变量。 4. 对 Cloud Run 平台,如需同步运行环境,额外设置 `trigger_platform_sync=true`,并填写 `confirm_apply=APPLY_AND_SYNC`。 -若是在**恢复已明确授权的旧实盘基线**,可额外填写 `live_continuity_state`、`live_continuity_baseline_id` 和 `live_continuity_captured_at`。工具会用完整 target 自动计算冻结 hash;它只接受 `legacy_authorized` 基线,不能借此创建新的实盘权限、扩大资金/杠杆或把研究候选直接提升为实盘。 +若是在**恢复已明确授权的旧实盘基线**,可额外填写 `live_continuity_state`、`live_continuity_baseline_id` 和 `live_continuity_captured_at`。工具会用完整 target 自动计算冻结 hash;它只接受 `legacy_authorized` 基线,不能借此创建新的实盘权限、扩大资金/杠杆或把研究候选直接提升为实盘。此类恢复若需要保持既有、同一策略的 shadow/观察插件,选择 `plugin_mode=current`:工作流只读取并原样保留当前挂载;读不到、格式无效或挂载属于其他策略时会失败,不会用空配置覆盖。 常用例子: diff --git a/python/scripts/build_runtime_switch.py b/python/scripts/build_runtime_switch.py index be40ad5..c06ac88 100644 --- a/python/scripts/build_runtime_switch.py +++ b/python/scripts/build_runtime_switch.py @@ -613,12 +613,37 @@ def _auto_plugin_mounts(strategy_profile: str, artifact_bucket_uri: str, dca_mod return [] +def _current_plugin_mounts(args: argparse.Namespace, strategy_profile: str) -> list[dict[str, Any]]: + path = str(getattr(args, "current_plugin_mounts_json_file", "") or "").strip() + if not path: + raise ValueError("plugin_mode=current requires current_plugin_mounts_json_file") + payload = _load_json_from_file(path, field_name="current_plugin_mounts_json_file") + if not isinstance(payload, dict): + raise ValueError("current_plugin_mounts_json_file must contain an object") + mounts = payload.get("strategy_plugins") + if not isinstance(mounts, list) or any(not isinstance(item, dict) for item in mounts): + raise ValueError("current_plugin_mounts_json_file.strategy_plugins must be an array of objects") + unexpected = { + str(item.get("strategy") or "").strip() + for item in mounts + if str(item.get("strategy") or "").strip() != strategy_profile + } + if unexpected: + raise ValueError( + "plugin_mode=current only preserves mounts for the selected strategy; " + f"found: {', '.join(sorted(unexpected))}" + ) + return [dict(item) for item in mounts] + + def _plugin_mounts(args: argparse.Namespace, strategy_profile: str, dca_mode: str = "") -> list[dict[str, Any]]: mode = str(args.plugin_mode or "none").strip().lower() if mode == "none": return [] if mode == "auto": return _auto_plugin_mounts(strategy_profile, args.artifact_bucket_uri, dca_mode) + if mode == "current": + return _current_plugin_mounts(args, strategy_profile) if mode == "custom": raise ValueError( "legacy custom plugin mounts are retired; a P1/P2/P3-bound strategy_plugin_signal.v2 adapter is required" @@ -1077,7 +1102,8 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--live-continuity-baseline-id", default="") parser.add_argument("--live-continuity-captured-at", default="") - parser.add_argument("--plugin-mode", choices=("auto", "none", "custom"), default="none") + parser.add_argument("--plugin-mode", choices=("auto", "current", "none", "custom"), default="none") + parser.add_argument("--current-plugin-mounts-json-file", default="") parser.add_argument("--custom-plugin-mounts-json", default="") parser.add_argument("--artifact-bucket-uri", default=DEFAULT_ARTIFACT_BUCKET_URI) parser.add_argument("--extra-variables-json", default="", help="JSON object of non-secret extra variables") diff --git a/python/tests/test_runtime_settings.py b/python/tests/test_runtime_settings.py index 8db5bf8..8ad5fd5 100644 --- a/python/tests/test_runtime_settings.py +++ b/python/tests/test_runtime_settings.py @@ -1452,6 +1452,91 @@ def test_build_switch_target_treats_legacy_auto_plugin_mode_as_none(self): {"strategy_plugins": []}, ) + def test_build_switch_target_preserves_current_mounts_for_same_strategy(self): + parser = build_runtime_switch.build_parser() + with tempfile.TemporaryDirectory() as temp_dir: + mounts_path = Path(temp_dir) / "current-plugin-mounts.json" + mounts_path.write_text( + json.dumps( + { + "strategy_plugins": [ + { + "strategy": "soxl_soxx_trend_income", + "plugin": "market_regime_control", + "enabled": True, + "expected_mode": "shadow", + "signal_path": "gs://example/plugin.json", + } + ] + } + ), + encoding="utf-8", + ) + args = parser.parse_args( + [ + "--platform", + "longbridge", + "--target-name", + "sg", + "--strategy-profile", + "soxl_soxx_trend_income", + "--plugin-mode", + "current", + "--current-plugin-mounts-json-file", + str(mounts_path), + ] + ) + + target = build_runtime_switch.build_switch_target(args) + + self.assertEqual( + target["plugin_mounts"], + [ + { + "strategy": "soxl_soxx_trend_income", + "plugin": "market_regime_control", + "enabled": True, + "expected_mode": "shadow", + "signal_path": "gs://example/plugin.json", + } + ], + ) + + def test_build_switch_target_rejects_current_mounts_for_other_strategy(self): + parser = build_runtime_switch.build_parser() + with tempfile.TemporaryDirectory() as temp_dir: + mounts_path = Path(temp_dir) / "current-plugin-mounts.json" + mounts_path.write_text( + json.dumps( + { + "strategy_plugins": [ + { + "strategy": "tqqq_growth_income", + "plugin": "market_regime_control", + } + ] + } + ), + encoding="utf-8", + ) + args = parser.parse_args( + [ + "--platform", + "longbridge", + "--target-name", + "sg", + "--strategy-profile", + "soxl_soxx_trend_income", + "--plugin-mode", + "current", + "--current-plugin-mounts-json-file", + str(mounts_path), + ] + ) + + with self.assertRaisesRegex(ValueError, "only preserves mounts for the selected strategy"): + build_runtime_switch.build_switch_target(args) + def test_build_switch_target_rejects_legacy_custom_plugin_mounts(self): parser = build_runtime_switch.build_parser() args = parser.parse_args(