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
52 changes: 51 additions & 1 deletion .github/workflows/manual-strategy-switch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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}")
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:工作流只读取并原样保留当前挂载;读不到、格式无效或挂载属于其他策略时会失败,不会用空配置覆盖。

常用例子:

Expand Down
28 changes: 27 additions & 1 deletion python/scripts/build_runtime_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
85 changes: 85 additions & 0 deletions python/tests/test_runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down