From 7178a06fcca18d40881769f571034a9ee4a914ff Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 19 Jun 2026 03:13:29 +0800 Subject: [PATCH] Route DCA workflow settings through extra variables --- .github/workflows/manual-strategy-switch.yml | 18 +------- scripts/build_runtime_switch.py | 38 +++++++++++++--- tests/strategy_switch_worker_validation.mjs | 25 +++++++++-- tests/test_runtime_settings.py | 46 +++++++++++++++++++- web/strategy-switch-console/worker.js | 41 ++++++++++++----- 5 files changed, 129 insertions(+), 39 deletions(-) diff --git a/.github/workflows/manual-strategy-switch.yml b/.github/workflows/manual-strategy-switch.yml index 82a3c6d..3391cb9 100644 --- a/.github/workflows/manual-strategy-switch.yml +++ b/.github/workflows/manual-strategy-switch.yml @@ -71,7 +71,7 @@ on: required: false type: string extra_variables_json: - description: "Optional JSON object of non-secret extra GitHub variables or target env fields." + description: "Optional JSON object of non-secret extra variables. DCA profiles may include dca_mode and dca_base_investment_usd control fields." required: false type: string reserved_cash_ratio: @@ -98,14 +98,6 @@ on: description: "Optional TQQQ QQQI income ratio override." required: false type: string - dca_mode: - description: "Optional DCA mode for DCA profiles only: fixed or smart." - required: false - type: string - dca_base_investment_usd: - description: "Optional base DCA investment amount for DCA profiles only." - required: false - type: string service_targets_mode: description: "auto patches IBKR CLOUD_RUN_SERVICE_TARGETS_JSON when it exists." required: true @@ -167,8 +159,6 @@ jobs: INCOME_LAYER_MAX_RATIO: ${{ inputs.income_layer_max_ratio }} INCOME_THRESHOLD_USD: ${{ inputs.income_threshold_usd }} QQQI_INCOME_RATIO: ${{ inputs.qqqi_income_ratio }} - DCA_MODE: ${{ inputs.dca_mode }} - DCA_BASE_INVESTMENT_USD: ${{ inputs.dca_base_investment_usd }} SERVICE_TARGETS_MODE: ${{ inputs.service_targets_mode }} APPLY_SWITCH: ${{ inputs.apply }} TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }} @@ -313,12 +303,6 @@ jobs: if [ -n "${QQQI_INCOME_RATIO:-}" ]; then args+=(--qqqi-income-ratio "${QQQI_INCOME_RATIO}") fi - if [ -n "${DCA_MODE:-}" ]; then - args+=(--dca-mode "${DCA_MODE}") - fi - if [ -n "${DCA_BASE_INVESTMENT_USD:-}" ]; then - args+=(--dca-base-investment-usd "${DCA_BASE_INVESTMENT_USD}") - fi if [ -s "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then args+=(--existing-service-targets-json-file "${EXISTING_SERVICE_TARGETS_JSON_FILE}") fi diff --git a/scripts/build_runtime_switch.py b/scripts/build_runtime_switch.py index 8c68756..36df456 100644 --- a/scripts/build_runtime_switch.py +++ b/scripts/build_runtime_switch.py @@ -108,6 +108,8 @@ DCA_MODE_VARIABLE, DCA_BASE_INVESTMENT_VARIABLE, ) +DCA_MODE_CONTROL_FIELD = "dca_mode" +DCA_BASE_INVESTMENT_CONTROL_FIELD = "dca_base_investment_usd" DEFAULT_VARIABLE_SCOPE = { "longbridge": "environment", "ibkr": "repository", @@ -236,10 +238,29 @@ def _normalize_positive_decimal(value: str, *, field_name: str) -> str: return text -def _dca_extra_variables(args: argparse.Namespace, strategy_profile: str) -> dict[str, Any]: +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): + if field_name in extra_variables: + controls[field_name] = extra_variables.pop(field_name) + return controls + + +def _dca_extra_variables( + args: argparse.Namespace, + strategy_profile: str, + controls: dict[str, Any] | None = None, +) -> dict[str, Any]: + controls = dict(controls or {}) is_dca_profile = strategy_profile in DCA_PROFILES - has_dca_mode = bool(str(args.dca_mode or "").strip()) - has_dca_base = bool(str(args.dca_base_investment_usd or "").strip()) + dca_mode = args.dca_mode if str(args.dca_mode or "").strip() else controls.get(DCA_MODE_CONTROL_FIELD, "") + dca_base_investment_usd = ( + args.dca_base_investment_usd + if str(args.dca_base_investment_usd or "").strip() + else controls.get(DCA_BASE_INVESTMENT_CONTROL_FIELD, "") + ) + has_dca_mode = bool(str(dca_mode or "").strip()) + has_dca_base = bool(str(dca_base_investment_usd or "").strip()) if not is_dca_profile: if has_dca_mode or has_dca_base: raise ValueError("DCA settings are only supported for DCA strategy profiles") @@ -247,10 +268,10 @@ def _dca_extra_variables(args: argparse.Namespace, strategy_profile: str) -> dic extra_variables: dict[str, Any] = {} if has_dca_mode: - extra_variables[DCA_MODE_VARIABLE] = _normalize_dca_mode(args.dca_mode) + extra_variables[DCA_MODE_VARIABLE] = _normalize_dca_mode(dca_mode) if has_dca_base: extra_variables[DCA_BASE_INVESTMENT_VARIABLE] = _normalize_positive_decimal( - args.dca_base_investment_usd, + dca_base_investment_usd, field_name="dca_base_investment_usd", ) return extra_variables @@ -264,7 +285,9 @@ def _reject_direct_dca_extra_variables(extra_variables: dict[str, Any]) -> None: ] if provided: names = ", ".join(provided) - raise ValueError(f"use --dca-mode and --dca-base-investment-usd instead of extra_variables_json for {names}") + raise ValueError( + f"use dca_mode and dca_base_investment_usd control fields instead of extra_variables_json for {names}" + ) def _auto_plugin_mounts(strategy_profile: str, artifact_bucket_uri: str) -> list[dict[str, Any]]: @@ -463,6 +486,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: mounts = _plugin_mounts(args, runtime_target["strategy_profile"]) 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) + dca_controls = _extract_dca_control_fields(extra_variables) _reject_direct_dca_extra_variables(extra_variables) if args.set_platform_dry_run_variable: @@ -479,7 +503,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: extra_variables["INCOME_THRESHOLD_USD"] = args.income_threshold_usd if args.qqqi_income_ratio: extra_variables["QQQI_INCOME_RATIO"] = args.qqqi_income_ratio - extra_variables.update(_dca_extra_variables(args, runtime_target["strategy_profile"])) + extra_variables.update(_dca_extra_variables(args, runtime_target["strategy_profile"], dca_controls)) service_targets = _load_json_from_file( args.existing_service_targets_json_file, diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 8cce19b..04c98f3 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -374,8 +374,27 @@ const normalizedDcaInputs = __test.normalizeSwitchInputs({ dca_mode: "smart", dca_base_investment_usd: "500", }); -assert.equal(normalizedDcaInputs.dca_mode, "smart"); -assert.equal(normalizedDcaInputs.dca_base_investment_usd, "500"); +assert.equal(normalizedDcaInputs.dca_mode, undefined); +assert.equal(normalizedDcaInputs.dca_base_investment_usd, undefined); +assert.deepEqual(JSON.parse(normalizedDcaInputs.extra_variables_json), { + dca_mode: "smart", + dca_base_investment_usd: "500", +}); +const normalizedDcaJsonInputs = __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibkr-primary", + strategy_profile: "nasdaq_sp500_smart_dca", + execution_mode: "live", + plugin_mode: "auto", + extra_variables_json: JSON.stringify({ + dca_mode: "smart", + dca_base_investment_usd: "500", + }), +}); +assert.deepEqual(JSON.parse(normalizedDcaJsonInputs.extra_variables_json), { + dca_mode: "smart", + dca_base_investment_usd: "500", +}); assert.throws( () => __test.normalizeSwitchInputs({ platform: "ibkr", @@ -402,7 +421,7 @@ assert.throws( strategy_profile: "tqqq_growth_income", extra_variables_json: JSON.stringify({ DCA_MODE: "smart" }), }), - /instead of extra_variables_json/, + /control fields/, ); const normalizedReserveClearInputs = __test.normalizeSwitchInputs({ platform: "ibkr", diff --git a/tests/test_runtime_settings.py b/tests/test_runtime_settings.py index 1f904ef..e6317cb 100644 --- a/tests/test_runtime_settings.py +++ b/tests/test_runtime_settings.py @@ -3,6 +3,7 @@ import importlib.util import json import os +import re import sys import unittest from pathlib import Path @@ -26,6 +27,24 @@ class RuntimeSettingsTest(unittest.TestCase): + def test_manual_strategy_switch_workflow_stays_within_dispatch_input_limit(self): + workflow = (ROOT / ".github/workflows/manual-strategy-switch.yml").read_text(encoding="utf-8") + input_names: list[str] = [] + in_inputs = False + for line in workflow.splitlines(): + if line.strip() == "inputs:": + in_inputs = True + continue + if in_inputs and line.startswith("concurrency:"): + break + match = re.match(r" ([A-Za-z0-9_]+):$", line) + if in_inputs and match: + input_names.append(match.group(1)) + + self.assertLessEqual(len(input_names), 25) + self.assertNotIn("dca_mode", input_names) + self.assertNotIn("dca_base_investment_usd", input_names) + def load_target(self, relative_path: str): path = ROOT / relative_path return path, runtime_settings.load_target(path) @@ -382,6 +401,31 @@ def test_build_switch_target_sets_dca_settings_for_dca_profile(self): self.assertEqual(assignments["DCA_MODE"], "smart") self.assertEqual(assignments["DCA_BASE_INVESTMENT_USD"], "500") + def test_build_switch_target_accepts_dca_control_fields_from_extra_variables_json(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "dca", + "--strategy-profile", + "nasdaq_sp500_smart_dca", + "--plugin-mode", + "none", + "--extra-variables-json", + '{"dca_mode":"smart","dca_base_investment_usd":"500"}', + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + + self.assertEqual(assignments["DCA_MODE"], "smart") + self.assertEqual(assignments["DCA_BASE_INVESTMENT_USD"], "500") + self.assertNotIn("dca_mode", target["extra_variables"]) + self.assertNotIn("dca_base_investment_usd", target["extra_variables"]) + def test_build_switch_target_rejects_dca_settings_for_non_dca_profile(self): parser = build_runtime_switch.build_parser() args = parser.parse_args( @@ -415,7 +459,7 @@ def test_build_switch_target_rejects_direct_dca_extra_variables(self): ] ) - with self.assertRaisesRegex(ValueError, "use --dca-mode"): + with self.assertRaisesRegex(ValueError, "control fields"): build_runtime_switch.build_switch_target(args) def test_build_switch_target_preserves_dca_fields_in_service_targets_when_omitted(self): diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index cf16e9f..5e159b9 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -836,16 +836,17 @@ function updateAccountOptionsDefaultStrategy(accountOptions, inputs) { optionChanged = true; } } + const dcaControls = dcaControlsFromInputs(inputs); if (isDcaProfile(inputs.strategy_profile)) { - if (inputs.dca_mode && nextOption.dca_mode !== inputs.dca_mode) { - nextOption.dca_mode = inputs.dca_mode; + if (dcaControls.dca_mode && nextOption.dca_mode !== dcaControls.dca_mode) { + nextOption.dca_mode = dcaControls.dca_mode; optionChanged = true; } if ( - inputs.dca_base_investment_usd && - nextOption.dca_base_investment_usd !== inputs.dca_base_investment_usd + dcaControls.dca_base_investment_usd && + nextOption.dca_base_investment_usd !== dcaControls.dca_base_investment_usd ) { - nextOption.dca_base_investment_usd = inputs.dca_base_investment_usd; + nextOption.dca_base_investment_usd = dcaControls.dca_base_investment_usd; optionChanged = true; } } else { @@ -886,8 +887,9 @@ function normalizeSwitchInputs(raw) { extraVariables[name] !== undefined && String(extraVariables[name] || "").trim() !== "", ); if (directDcaVariables.length) { - throw new Error("use dca_mode and dca_base_investment_usd instead of extra_variables_json for DCA settings"); + throw new Error("use dca_mode and dca_base_investment_usd control fields instead of DCA_MODE variables"); } + const dcaExtraControls = dcaPayloadFromObject(extraVariables); const inputs = { platform, @@ -913,18 +915,27 @@ function normalizeSwitchInputs(raw) { addOptional(inputs, "min_reserved_cash_usd", raw.min_reserved_cash_usd, cleanNonNegativeNumber); addOptional(inputs, "income_layer_start_usd", raw.income_layer_start_usd, cleanNonNegativeNumber); addOptional(inputs, "income_layer_max_ratio", raw.income_layer_max_ratio, cleanRatio); - const hasDcaMode = raw.dca_mode !== undefined && raw.dca_mode !== null && String(raw.dca_mode).trim() !== ""; - const hasDcaBase = raw.dca_base_investment_usd !== undefined && + const rawHasDcaMode = raw.dca_mode !== undefined && raw.dca_mode !== null && String(raw.dca_mode).trim() !== ""; + const rawHasDcaBase = raw.dca_base_investment_usd !== undefined && raw.dca_base_investment_usd !== null && String(raw.dca_base_investment_usd).trim() !== ""; + const dcaModeValue = rawHasDcaMode ? raw.dca_mode : dcaExtraControls.dca_mode; + const dcaBaseInvestmentValue = rawHasDcaBase + ? raw.dca_base_investment_usd + : dcaExtraControls.dca_base_investment_usd; + const hasDcaMode = Boolean(String(dcaModeValue || "").trim()); + const hasDcaBase = Boolean(String(dcaBaseInvestmentValue || "").trim()); if (!isDcaProfile(strategyProfile) && (hasDcaMode || hasDcaBase)) { throw new Error("DCA settings are only supported for DCA strategy profiles"); } if (isDcaProfile(strategyProfile)) { - addOptional(inputs, "dca_mode", raw.dca_mode, cleanDcaMode); - addOptional(inputs, "dca_base_investment_usd", raw.dca_base_investment_usd, cleanPositiveNumber); + if (hasDcaMode) extraVariables.dca_mode = cleanDcaMode(dcaModeValue); + if (hasDcaBase) extraVariables.dca_base_investment_usd = cleanPositiveNumber( + dcaBaseInvestmentValue, + "dca_base_investment_usd", + ); } - if (extraVariablesJson) inputs.extra_variables_json = extraVariablesJson; + if (Object.keys(extraVariables).length) inputs.extra_variables_json = JSON.stringify(extraVariables); return inputs; } @@ -1494,6 +1505,14 @@ function dcaPayloadFromValues(modeValue, baseInvestmentValue) { return result; } +function dcaControlsFromInputs(inputs) { + const payload = inputs?.extra_variables_json ? JSON.parse(inputs.extra_variables_json) : {}; + return { + ...dcaPayloadFromObject(payload), + ...dcaPayloadFromObject(inputs), + }; +} + function dcaPayloadForProfile(profile, payload) { return isDcaProfile(profile) ? payload : {}; }