From a42d28938dc099576a6d895b0519de825df484bc Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:58:20 +0800 Subject: [PATCH] Add per-platform cash-only execution controls to strategy switch console. Route cash_only_execution_mode through extra_variables_json to avoid undeclared workflow dispatch inputs, sync account defaults after apply, and expose allow-margin yes/no UI. Co-authored-by: Cursor --- .github/workflows/manual-strategy-switch.yml | 11 +- scripts/build_runtime_switch.py | 41 +++++- tests/strategy_switch_worker_validation.mjs | 61 +++++++++ tests/test_runtime_settings.py | 41 ++++++ web/strategy-switch-console/index.html | 133 +++++++++++++++++++ web/strategy-switch-console/page_asset.js | 2 +- web/strategy-switch-console/worker.js | 90 +++++++++++++ 7 files changed, 376 insertions(+), 3 deletions(-) diff --git a/.github/workflows/manual-strategy-switch.yml b/.github/workflows/manual-strategy-switch.yml index ff161d5..26e25c7 100644 --- a/.github/workflows/manual-strategy-switch.yml +++ b/.github/workflows/manual-strategy-switch.yml @@ -72,7 +72,7 @@ on: required: false type: string 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. Research-only option overlays are rejected." + 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 type: string reserved_cash_ratio: @@ -399,6 +399,15 @@ jobs: "account_scope": runtime_target["account_scope"], "service_name": runtime_target["service_name"], } + extra_variables_json = os.environ.get("EXTRA_VARIABLES_JSON", "").strip() + if extra_variables_json: + try: + extra_variables = json.loads(extra_variables_json) + except json.JSONDecodeError: + extra_variables = {} + cash_only_mode = str(extra_variables.get("cash_only_execution_mode") or "current").strip().lower() + if cash_only_mode in {"enabled", "disabled"}: + payload["cash_only_execution_mode"] = cash_only_mode payload.update(runtime_settings.extract_account_sync_controls(target)) if github.get("environment"): payload["github_environment"] = github["environment"] diff --git a/scripts/build_runtime_switch.py b/scripts/build_runtime_switch.py index c10b37f..48c7353 100644 --- a/scripts/build_runtime_switch.py +++ b/scripts/build_runtime_switch.py @@ -105,6 +105,12 @@ "ibkr": "IBKR_MIN_RESERVED_CASH_USD", "firstrade": "FIRSTRADE_MIN_RESERVED_CASH_USD", } +PLATFORM_CASH_ONLY_EXECUTION_VARIABLES = { + "schwab": "SCHWAB_CASH_ONLY_EXECUTION", + "longbridge": "LONGBRIDGE_CASH_ONLY_EXECUTION", + "ibkr": "IBKR_CASH_ONLY_EXECUTION", + "firstrade": "FIRSTRADE_CASH_ONLY_EXECUTION", +} INCOME_LAYER_VARIABLES = ( "INCOME_LAYER_ENABLED", "INCOME_LAYER_START_USD", @@ -132,6 +138,8 @@ for suffix in MARKET_SIGNAL_RUNTIME_SUFFIXES ) CASH_ONLY_EXECUTION_VARIABLE = "CASH_ONLY_EXECUTION" +CASH_ONLY_EXECUTION_MODES = frozenset({"current", "enabled", "disabled"}) +CASH_ONLY_EXECUTION_CONTROL_FIELD = "cash_only_execution_mode" LEGACY_INCOME_LAYER_VARIABLES = ( "INCOME_THRESHOLD_USD", "QQQI_INCOME_RATIO", @@ -398,6 +406,25 @@ def _normalize_symbol_text(value: str, *, field_name: str) -> str: return text +def _cash_only_extra_variables(args: argparse.Namespace, platform: str) -> dict[str, str]: + mode = str(getattr(args, "cash_only_execution_mode", None) or "current").strip().lower() + if mode not in CASH_ONLY_EXECUTION_MODES: + raise ValueError("cash_only_execution_mode must be current, enabled, or disabled") + if mode == "current": + return {} + variable = PLATFORM_CASH_ONLY_EXECUTION_VARIABLES.get(platform) + if not variable: + return {} + return {variable: env_string(mode == "enabled")} + + +def _extract_cash_only_control_fields(extra_variables: dict[str, Any]) -> dict[str, Any]: + controls: dict[str, Any] = {} + if CASH_ONLY_EXECUTION_CONTROL_FIELD in extra_variables: + controls[CASH_ONLY_EXECUTION_CONTROL_FIELD] = extra_variables.pop(CASH_ONLY_EXECUTION_CONTROL_FIELD) + return controls + + def _extract_dca_control_fields(extra_variables: dict[str, Any]) -> dict[str, Any]: controls: dict[str, Any] = {} for field_name in (DCA_MODE_CONTROL_FIELD, DCA_BASE_INVESTMENT_CONTROL_FIELD): @@ -826,9 +853,10 @@ def _preserve_reserved_cash_fields( for variable in ( PLATFORM_MIN_RESERVED_CASH_VARIABLES.get(platform), PLATFORM_RESERVED_CASH_RATIO_VARIABLES.get(platform), + PLATFORM_CASH_ONLY_EXECUTION_VARIABLES.get(platform), + CASH_ONLY_EXECUTION_VARIABLE, *INCOME_LAYER_VARIABLES, *OPTION_OVERLAY_VARIABLES, - CASH_ONLY_EXECUTION_VARIABLE, *RUNTIME_TARGET_VARIABLES, *DCA_RUNTIME_VARIABLES, *IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES, @@ -898,8 +926,13 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: runtime_target["scheduler"] = _scheduler_plan_for_strategy(runtime_target["strategy_profile"], mounts) mounts_variable = f"{SUPPORTED_PLATFORMS[platform]['plugin_mounts_prefix']}STRATEGY_PLUGIN_MOUNTS_JSON" extra_variables = _parse_extra_variables(args.extra_variable, args.extra_variables_json) + cash_only_controls = _extract_cash_only_control_fields(extra_variables) dca_controls = _extract_dca_control_fields(extra_variables) ibit_zscore_exit_controls = _extract_ibit_zscore_exit_control_fields(extra_variables) + if cash_only_controls.get(CASH_ONLY_EXECUTION_CONTROL_FIELD): + args.cash_only_execution_mode = str( + cash_only_controls[CASH_ONLY_EXECUTION_CONTROL_FIELD] + ).strip().lower() _reject_direct_dca_extra_variables(extra_variables) _reject_direct_ibit_zscore_exit_extra_variables(extra_variables) _reject_research_only_extra_variables(extra_variables) @@ -914,6 +947,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: extra_variables["INCOME_LAYER_START_USD"] = args.income_layer_start_usd if args.income_layer_max_ratio: extra_variables["INCOME_LAYER_MAX_RATIO"] = args.income_layer_max_ratio + extra_variables.update(_cash_only_extra_variables(args, platform)) extra_variables.update(_option_overlay_extra_variables(args, runtime_target["strategy_profile"])) extra_variables.update(_dca_extra_variables(args, runtime_target["strategy_profile"], dca_controls)) extra_variables.update( @@ -985,6 +1019,11 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--extra-variable", action="append", default=[], help="NAME=VALUE non-secret extra variable") parser.add_argument("--reserved-cash-ratio", default="") parser.add_argument("--min-reserved-cash-usd", default="") + parser.add_argument( + "--cash-only-execution-mode", + choices=sorted(CASH_ONLY_EXECUTION_MODES), + default="current", + ) parser.add_argument("--income-layer-start-usd", default="") parser.add_argument("--income-layer-max-ratio", default="") parser.add_argument("--option-overlay-mode", choices=sorted(OPTION_OVERLAY_MODES), default="current") diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 5f99cf5..4c8fe51 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -38,6 +38,14 @@ assert.ok(indexHtml.includes('id="option-overlay-mode-select"')); assert.ok(indexHtml.includes('optionOverlayMode: "期权层状态"')); assert.ok(indexHtml.includes('optionOverlayMode: "Option layer"')); assert.ok(indexHtml.includes("optionOverlayDefaultsFromProfileItem")); +assert.ok(indexHtml.includes('id="cash-only-execution-mode-select"')); +assert.ok(indexHtml.includes('class="form-section cash-only-section"')); +assert.ok(indexHtml.includes('cashOnlyExecutionMode: "允许融资"')); +assert.ok(indexHtml.includes('cashOnlyExecutionValueYes: "允许融资:是"')); +assert.ok(indexHtml.includes('cashOnlyExecutionMode: "Allow margin"')); +assert.ok(indexHtml.includes('el("cash-only-execution-mode-select").addEventListener("change"')); +assert.ok(indexHtml.includes("function pendingCashOnlyExecution(")); +assert.ok(indexHtml.includes("function syncCashOnlyExecutionForAccount(")); assert.equal(indexHtml.includes('id="option-growth-overlay'), false); assert.equal(indexHtml.includes('id="option-income-overlay'), false); assert.ok(indexHtml.includes('id="dca-mode-select"')); @@ -460,6 +468,34 @@ assert.equal(normalizedReservedCashInputs.min_reserved_cash_usd, "150"); assert.equal(normalizedReservedCashInputs.income_layer_start_usd, "250000"); assert.equal(normalizedReservedCashInputs.income_layer_max_ratio, "0.55"); assert.equal(normalizedReservedCashInputs.option_overlay_mode, "enabled"); +const normalizedCashOnlyInputs = __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibkr-primary", + strategy_profile: "tqqq_growth_income", + execution_mode: "live", + cash_only_execution_mode: "disabled", +}); +assert.equal(normalizedCashOnlyInputs.cash_only_execution_mode, undefined); +assert.deepEqual(JSON.parse(normalizedCashOnlyInputs.extra_variables_json), { + cash_only_execution_mode: "disabled", +}); +assert.equal("cash_only_execution_mode" in normalizedCashOnlyInputs, false); + +const workflowYaml = readFileSync(resolve(root, ".github/workflows/manual-strategy-switch.yml"), "utf8"); +const workflowInputs = [...workflowYaml.matchAll(/^ ([A-Za-z0-9_]+):\n description:/gm)].map((match) => match[1]); +const dispatchInputs = __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibkr-primary", + strategy_profile: "tqqq_growth_income", + execution_mode: "live", + cash_only_execution_mode: "enabled", + apply: "true", + trigger_platform_sync: "true", + confirm_apply: "APPLY_AND_SYNC", +}); +for (const key of Object.keys(dispatchInputs)) { + assert.ok(workflowInputs.includes(key), `workflow input missing for dispatch field: ${key}`); +} const normalizedPluginInputs = __test.normalizeSwitchInputs({ platform: "ibkr", target_name: "ibkr-primary", @@ -581,6 +617,15 @@ assert.equal( IBKR_RESERVED_CASH_RATIO: "", }), ); +assert.throws( + () => __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibkr-primary", + strategy_profile: "tqqq_growth_income", + extra_variables_json: JSON.stringify({ IBKR_CASH_ONLY_EXECUTION: "true" }), + }), + /cash_only_execution_mode instead of CASH_ONLY_EXECUTION/, +); assert.throws( () => __test.normalizeSwitchInputs({ platform: "ibkr", @@ -659,6 +704,22 @@ const updatedOptionOverlayModeOptions = __test.updateAccountOptionsDefaultStrate assert.equal(updatedOptionOverlayModeOptions.changed, true); assert.equal(updatedOptionOverlayModeOptions.options.longbridge[1].option_overlay_mode, "disabled"); +const updatedCashOnlyModeOptions = __test.updateAccountOptionsDefaultStrategy( + accountOptions, + { + platform: "longbridge", + target_name: "sg", + account_selector: "SG", + strategy_profile: "tqqq_growth_income", + execution_mode: "live", + variable_scope: "default", + plugin_mode: "auto", + cash_only_execution_mode: "enabled", + }, +); +assert.equal(updatedCashOnlyModeOptions.changed, true); +assert.equal(updatedCashOnlyModeOptions.options.longbridge[1].cash_only_execution_mode, "enabled"); + const updatedIbitZscoreModeOptions = __test.updateAccountOptionsDefaultStrategy( { ...accountOptions, diff --git a/tests/test_runtime_settings.py b/tests/test_runtime_settings.py index 6e3d534..518322e 100644 --- a/tests/test_runtime_settings.py +++ b/tests/test_runtime_settings.py @@ -174,6 +174,27 @@ def test_manual_switch_account_default_sync_is_warning_only(self): self.assertIn("raise SystemExit(0)", workflow) self.assertIn('"variable_scope": "default"', workflow) self.assertIn("runtime_settings.extract_account_sync_controls(target)", workflow) + self.assertIn('extra_variables.get("cash_only_execution_mode")', workflow) + + def test_build_switch_target_sets_cash_only_execution_from_control_field(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "ibkr-primary", + "--strategy-profile", + "tqqq_growth_income", + "--extra-variables-json", + '{"cash_only_execution_mode":"enabled"}', + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + + self.assertEqual(assignments["IBKR_CASH_ONLY_EXECUTION"], "true") def test_extract_account_sync_controls_reads_ibkr_service_targets(self): target = { @@ -792,6 +813,26 @@ def test_build_switch_target_can_disable_option_overlay(self): self.assertEqual(assignments["OPTION_GROWTH_OVERLAY_RECIPE"], "") self.assertEqual(assignments["OPTION_INCOME_OVERLAY_ENABLED"], "false") + def test_build_switch_target_sets_platform_cash_only_execution(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "ibkr-primary", + "--strategy-profile", + "tqqq_growth_income", + "--cash-only-execution-mode", + "disabled", + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + + self.assertEqual(assignments["IBKR_CASH_ONLY_EXECUTION"], "false") + def test_build_switch_target_rejects_enabled_option_overlay_without_profile_defaults(self): parser = build_runtime_switch.build_parser() args = parser.parse_args( diff --git a/web/strategy-switch-console/index.html b/web/strategy-switch-console/index.html index ddf94a9..b866fd4 100644 --- a/web/strategy-switch-console/index.html +++ b/web/strategy-switch-console/index.html @@ -1349,6 +1349,14 @@

LongBridge

+
+ +
+