Skip to content

Commit 806d60d

Browse files
Pigbibicodex
andauthored
feat: preserve current plugin mounts during continuity recovery (#326)
Co-authored-by: Codex <noreply@openai.com>
1 parent 0496e17 commit 806d60d

4 files changed

Lines changed: 164 additions & 3 deletions

File tree

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

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,13 @@ on:
8282
required: false
8383
type: string
8484
plugin_mode:
85-
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."
85+
description: "none disables mounts. current preserves the selected strategy's existing mounts exactly; it cannot add or change a plugin."
8686
required: true
8787
type: choice
8888
default: none
8989
options:
9090
- none
91+
- current
9192
extra_variables_json:
9293
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."
9394
required: false
@@ -336,7 +337,53 @@ jobs:
336337
if [ -f "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then
337338
echo "existing CLOUD_RUN_SERVICE_TARGETS_JSON requires service_targets_mode=patch or allow_create" >&2
338339
exit 2
340+
fi
341+
342+
- name: Fetch current plugin mounts for continuity
343+
if: env.PLUGIN_MODE == 'current'
344+
env:
345+
TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }}
346+
run: |
347+
set -euo pipefail
348+
case "${PLATFORM}" in
349+
longbridge) mounts_variable="LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON" ;;
350+
ibkr) mounts_variable="IBKR_STRATEGY_PLUGIN_MOUNTS_JSON" ;;
351+
schwab) mounts_variable="SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON" ;;
352+
firstrade) mounts_variable="FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON" ;;
353+
qmt) mounts_variable="QMT_STRATEGY_PLUGIN_MOUNTS_JSON" ;;
354+
binance) mounts_variable="BINANCE_STRATEGY_PLUGIN_MOUNTS_JSON" ;;
355+
*) echo "Unsupported platform: ${PLATFORM}" >&2; exit 2 ;;
356+
esac
357+
target_environment=""
358+
if [ "${VARIABLE_SCOPE}" = "environment" ]; then
359+
target_environment="${GITHUB_ENVIRONMENT_NAME:-${TARGET_NAME}}"
360+
elif [ "${VARIABLE_SCOPE}" = "default" ] && [ "${PLATFORM}" = "longbridge" ]; then
361+
target_environment="longbridge-${TARGET_NAME}"
339362
fi
363+
output_file="${RUNNER_TEMP}/current-plugin-mounts.json"
364+
python - <<'PY' "${TARGET_REPOSITORY}" "${mounts_variable}" "${target_environment}" "${output_file}"
365+
import json
366+
import subprocess
367+
import sys
368+
369+
repo, variable, environment, output_path = sys.argv[1:5]
370+
command = ["gh", "variable", "list", "--repo", repo, "--json", "name,value"]
371+
if environment:
372+
command.extend(["--env", environment])
373+
values = json.loads(subprocess.check_output(command, text=True))
374+
raw = next((item.get("value") for item in values if item.get("name") == variable), None)
375+
if not isinstance(raw, str) or not raw.strip():
376+
raise SystemExit(f"{variable} is required when plugin_mode=current")
377+
try:
378+
payload = json.loads(raw)
379+
except json.JSONDecodeError as exc:
380+
raise SystemExit(f"{variable} must contain valid JSON") from exc
381+
if not isinstance(payload, dict) or not isinstance(payload.get("strategy_plugins"), list):
382+
raise SystemExit(f"{variable} must contain a strategy_plugins array")
383+
with open(output_path, "w", encoding="utf-8") as handle:
384+
json.dump(payload, handle, ensure_ascii=False, separators=(",", ":"))
385+
PY
386+
echo "CURRENT_PLUGIN_MOUNTS_JSON_FILE=${output_file}" >> "$GITHUB_ENV"
340387
341388
- name: Build switch target
342389
run: |
@@ -369,6 +416,9 @@ jobs:
369416
if [ -n "${SERVICE_NAME:-}" ]; then
370417
args+=(--service-name "${SERVICE_NAME}")
371418
fi
419+
if [ "${PLUGIN_MODE}" = "current" ]; then
420+
args+=(--current-plugin-mounts-json-file "${CURRENT_PLUGIN_MOUNTS_JSON_FILE}")
421+
fi
372422
if [ "${LIVE_CONTINUITY_STATE}" != "NONE" ]; then
373423
args+=(--live-continuity-state "${LIVE_CONTINUITY_STATE}")
374424
args+=(--live-continuity-baseline-id "${LIVE_CONTINUITY_BASELINE_ID}")

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ python3 -m unittest discover -s python/tests -v
6565
3. 再运行 `apply=true`,并填写 `confirm_apply=APPLY`,写入目标仓库变量。
6666
4. 对 Cloud Run 平台,如需同步运行环境,额外设置 `trigger_platform_sync=true`,并填写 `confirm_apply=APPLY_AND_SYNC`
6767

68-
若是在**恢复已明确授权的旧实盘基线**,可额外填写 `live_continuity_state``live_continuity_baseline_id``live_continuity_captured_at`。工具会用完整 target 自动计算冻结 hash;它只接受 `legacy_authorized` 基线,不能借此创建新的实盘权限、扩大资金/杠杆或把研究候选直接提升为实盘。
68+
若是在**恢复已明确授权的旧实盘基线**,可额外填写 `live_continuity_state``live_continuity_baseline_id``live_continuity_captured_at`。工具会用完整 target 自动计算冻结 hash;它只接受 `legacy_authorized` 基线,不能借此创建新的实盘权限、扩大资金/杠杆或把研究候选直接提升为实盘。此类恢复若需要保持既有、同一策略的 shadow/观察插件,选择 `plugin_mode=current`:工作流只读取并原样保留当前挂载;读不到、格式无效或挂载属于其他策略时会失败,不会用空配置覆盖。
6969

7070
常用例子:
7171

python/scripts/build_runtime_switch.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,12 +613,37 @@ def _auto_plugin_mounts(strategy_profile: str, artifact_bucket_uri: str, dca_mod
613613
return []
614614

615615

616+
def _current_plugin_mounts(args: argparse.Namespace, strategy_profile: str) -> list[dict[str, Any]]:
617+
path = str(getattr(args, "current_plugin_mounts_json_file", "") or "").strip()
618+
if not path:
619+
raise ValueError("plugin_mode=current requires current_plugin_mounts_json_file")
620+
payload = _load_json_from_file(path, field_name="current_plugin_mounts_json_file")
621+
if not isinstance(payload, dict):
622+
raise ValueError("current_plugin_mounts_json_file must contain an object")
623+
mounts = payload.get("strategy_plugins")
624+
if not isinstance(mounts, list) or any(not isinstance(item, dict) for item in mounts):
625+
raise ValueError("current_plugin_mounts_json_file.strategy_plugins must be an array of objects")
626+
unexpected = {
627+
str(item.get("strategy") or "").strip()
628+
for item in mounts
629+
if str(item.get("strategy") or "").strip() != strategy_profile
630+
}
631+
if unexpected:
632+
raise ValueError(
633+
"plugin_mode=current only preserves mounts for the selected strategy; "
634+
f"found: {', '.join(sorted(unexpected))}"
635+
)
636+
return [dict(item) for item in mounts]
637+
638+
616639
def _plugin_mounts(args: argparse.Namespace, strategy_profile: str, dca_mode: str = "") -> list[dict[str, Any]]:
617640
mode = str(args.plugin_mode or "none").strip().lower()
618641
if mode == "none":
619642
return []
620643
if mode == "auto":
621644
return _auto_plugin_mounts(strategy_profile, args.artifact_bucket_uri, dca_mode)
645+
if mode == "current":
646+
return _current_plugin_mounts(args, strategy_profile)
622647
if mode == "custom":
623648
raise ValueError(
624649
"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:
10771102
)
10781103
parser.add_argument("--live-continuity-baseline-id", default="")
10791104
parser.add_argument("--live-continuity-captured-at", default="")
1080-
parser.add_argument("--plugin-mode", choices=("auto", "none", "custom"), default="none")
1105+
parser.add_argument("--plugin-mode", choices=("auto", "current", "none", "custom"), default="none")
1106+
parser.add_argument("--current-plugin-mounts-json-file", default="")
10811107
parser.add_argument("--custom-plugin-mounts-json", default="")
10821108
parser.add_argument("--artifact-bucket-uri", default=DEFAULT_ARTIFACT_BUCKET_URI)
10831109
parser.add_argument("--extra-variables-json", default="", help="JSON object of non-secret extra variables")

python/tests/test_runtime_settings.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,6 +1452,91 @@ def test_build_switch_target_treats_legacy_auto_plugin_mode_as_none(self):
14521452
{"strategy_plugins": []},
14531453
)
14541454

1455+
def test_build_switch_target_preserves_current_mounts_for_same_strategy(self):
1456+
parser = build_runtime_switch.build_parser()
1457+
with tempfile.TemporaryDirectory() as temp_dir:
1458+
mounts_path = Path(temp_dir) / "current-plugin-mounts.json"
1459+
mounts_path.write_text(
1460+
json.dumps(
1461+
{
1462+
"strategy_plugins": [
1463+
{
1464+
"strategy": "soxl_soxx_trend_income",
1465+
"plugin": "market_regime_control",
1466+
"enabled": True,
1467+
"expected_mode": "shadow",
1468+
"signal_path": "gs://example/plugin.json",
1469+
}
1470+
]
1471+
}
1472+
),
1473+
encoding="utf-8",
1474+
)
1475+
args = parser.parse_args(
1476+
[
1477+
"--platform",
1478+
"longbridge",
1479+
"--target-name",
1480+
"sg",
1481+
"--strategy-profile",
1482+
"soxl_soxx_trend_income",
1483+
"--plugin-mode",
1484+
"current",
1485+
"--current-plugin-mounts-json-file",
1486+
str(mounts_path),
1487+
]
1488+
)
1489+
1490+
target = build_runtime_switch.build_switch_target(args)
1491+
1492+
self.assertEqual(
1493+
target["plugin_mounts"],
1494+
[
1495+
{
1496+
"strategy": "soxl_soxx_trend_income",
1497+
"plugin": "market_regime_control",
1498+
"enabled": True,
1499+
"expected_mode": "shadow",
1500+
"signal_path": "gs://example/plugin.json",
1501+
}
1502+
],
1503+
)
1504+
1505+
def test_build_switch_target_rejects_current_mounts_for_other_strategy(self):
1506+
parser = build_runtime_switch.build_parser()
1507+
with tempfile.TemporaryDirectory() as temp_dir:
1508+
mounts_path = Path(temp_dir) / "current-plugin-mounts.json"
1509+
mounts_path.write_text(
1510+
json.dumps(
1511+
{
1512+
"strategy_plugins": [
1513+
{
1514+
"strategy": "tqqq_growth_income",
1515+
"plugin": "market_regime_control",
1516+
}
1517+
]
1518+
}
1519+
),
1520+
encoding="utf-8",
1521+
)
1522+
args = parser.parse_args(
1523+
[
1524+
"--platform",
1525+
"longbridge",
1526+
"--target-name",
1527+
"sg",
1528+
"--strategy-profile",
1529+
"soxl_soxx_trend_income",
1530+
"--plugin-mode",
1531+
"current",
1532+
"--current-plugin-mounts-json-file",
1533+
str(mounts_path),
1534+
]
1535+
)
1536+
1537+
with self.assertRaisesRegex(ValueError, "only preserves mounts for the selected strategy"):
1538+
build_runtime_switch.build_switch_target(args)
1539+
14551540
def test_build_switch_target_rejects_legacy_custom_plugin_mounts(self):
14561541
parser = build_runtime_switch.build_parser()
14571542
args = parser.parse_args(

0 commit comments

Comments
 (0)