From 8334188e30c6daae4827ba70ffd85d0ecb564978 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:56:03 +0800 Subject: [PATCH] Add DCA strategy switch controls --- .github/workflows/manual-strategy-switch.yml | 21 ++ scripts/build_runtime_switch.py | 73 +++++ tests/strategy_switch_worker_validation.mjs | 89 +++++- tests/test_runtime_settings.py | 113 ++++++++ web/strategy-switch-console/index.html | 260 +++++++++++++++++- web/strategy-switch-console/page_asset.js | 2 +- .../strategy-profiles.example.json | 22 +- .../strategy_profiles_asset.js | 2 +- web/strategy-switch-console/worker.js | 143 +++++++++- 9 files changed, 699 insertions(+), 26 deletions(-) diff --git a/.github/workflows/manual-strategy-switch.yml b/.github/workflows/manual-strategy-switch.yml index 0ae728a..82a3c6d 100644 --- a/.github/workflows/manual-strategy-switch.yml +++ b/.github/workflows/manual-strategy-switch.yml @@ -98,6 +98,14 @@ 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 @@ -159,6 +167,8 @@ 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 }} @@ -303,6 +313,12 @@ 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 @@ -395,6 +411,11 @@ jobs: "account_scope": runtime_target["account_scope"], "service_name": runtime_target["service_name"], } + extra_variables = target.get("extra_variables") or {} + if extra_variables.get("DCA_MODE"): + payload["dca_mode"] = extra_variables["DCA_MODE"] + if extra_variables.get("DCA_BASE_INVESTMENT_USD"): + payload["dca_base_investment_usd"] = extra_variables["DCA_BASE_INVESTMENT_USD"] if github.get("environment"): payload["github_environment"] = github["environment"] diff --git a/scripts/build_runtime_switch.py b/scripts/build_runtime_switch.py index e631e25..8c68756 100644 --- a/scripts/build_runtime_switch.py +++ b/scripts/build_runtime_switch.py @@ -95,6 +95,19 @@ RUNTIME_TARGET_VARIABLES = ( "RUNTIME_TARGET_ENABLED", ) +DCA_PROFILES = frozenset( + { + "nasdaq_sp500_smart_dca", + "ibit_smart_dca", + } +) +DCA_MODES = frozenset({"fixed", "smart"}) +DCA_MODE_VARIABLE = "DCA_MODE" +DCA_BASE_INVESTMENT_VARIABLE = "DCA_BASE_INVESTMENT_USD" +DCA_RUNTIME_VARIABLES = ( + DCA_MODE_VARIABLE, + DCA_BASE_INVESTMENT_VARIABLE, +) DEFAULT_VARIABLE_SCOPE = { "longbridge": "environment", "ibkr": "repository", @@ -199,6 +212,61 @@ def _parse_extra_variables(pairs: list[str], raw_json: str) -> dict[str, Any]: return extras +def _normalize_dca_mode(value: str) -> str: + mode = str(value or "").strip().lower() + aliases = { + "ordinary": "fixed", + "ordinary_dca": "fixed", + "fixed_dca": "fixed", + "smart_dca": "smart", + } + mode = aliases.get(mode, mode) + if mode not in DCA_MODES: + raise ValueError("dca_mode must be fixed or smart") + return mode + + +def _normalize_positive_decimal(value: str, *, field_name: str) -> str: + text = str(value or "").strip() + if not text or not re.fullmatch(r"(?:\d+|\d*\.\d+)", text): + raise ValueError(f"{field_name} must be a positive decimal number") + numeric = float(text) + if not numeric > 0: + raise ValueError(f"{field_name} must be greater than 0") + return text + + +def _dca_extra_variables(args: argparse.Namespace, strategy_profile: str) -> dict[str, Any]: + 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()) + if not is_dca_profile: + if has_dca_mode or has_dca_base: + raise ValueError("DCA settings are only supported for DCA strategy profiles") + return {variable: "" for variable in DCA_RUNTIME_VARIABLES} + + extra_variables: dict[str, Any] = {} + if has_dca_mode: + extra_variables[DCA_MODE_VARIABLE] = _normalize_dca_mode(args.dca_mode) + if has_dca_base: + extra_variables[DCA_BASE_INVESTMENT_VARIABLE] = _normalize_positive_decimal( + args.dca_base_investment_usd, + field_name="dca_base_investment_usd", + ) + return extra_variables + + +def _reject_direct_dca_extra_variables(extra_variables: dict[str, Any]) -> None: + provided = [ + variable + for variable in DCA_RUNTIME_VARIABLES + if variable in extra_variables and str(extra_variables.get(variable) or "").strip() + ] + if provided: + names = ", ".join(provided) + raise ValueError(f"use --dca-mode and --dca-base-investment-usd instead of extra_variables_json for {names}") + + def _auto_plugin_mounts(strategy_profile: str, artifact_bucket_uri: str) -> list[dict[str, Any]]: if strategy_profile not in MARKET_REGIME_CONTROL_PROFILES: return [] @@ -330,6 +398,7 @@ def _preserve_reserved_cash_fields( PLATFORM_RESERVED_CASH_RATIO_VARIABLES.get(platform), *INCOME_LAYER_VARIABLES, *RUNTIME_TARGET_VARIABLES, + *DCA_RUNTIME_VARIABLES, ): if variable and variable not in replacement and variable in current_entry: replacement[variable] = current_entry[variable] @@ -394,6 +463,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) + _reject_direct_dca_extra_variables(extra_variables) if args.set_platform_dry_run_variable: extra_variables[PLATFORM_DRY_RUN_VARIABLES[platform]] = env_string(runtime_target["dry_run_only"]) @@ -409,6 +479,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"])) service_targets = _load_json_from_file( args.existing_service_targets_json_file, @@ -474,6 +545,8 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--income-layer-max-ratio", default="") parser.add_argument("--income-threshold-usd", default="") parser.add_argument("--qqqi-income-ratio", default="") + parser.add_argument("--dca-mode", default="") + parser.add_argument("--dca-base-investment-usd", default="") parser.add_argument("--existing-service-targets-json-file", default="") parser.add_argument("--no-platform-dry-run-variable", dest="set_platform_dry_run_variable", action="store_false") parser.set_defaults(set_platform_dry_run_variable=True) diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 8c444fc..8cce19b 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -31,14 +31,24 @@ assert.ok(indexHtml.includes('id="income-layer-start-usd-input"')); assert.ok(indexHtml.includes('incomeLayerStartUsd: "收入层起始金额"')); assert.ok(indexHtml.includes('incomeLayerStartUsd: "Income layer start amount"')); assert.ok(indexHtml.includes('incomeLayerStartUsdVariable = "INCOME_LAYER_START_USD"')); +assert.ok(indexHtml.includes('id="dca-mode-select"')); +assert.ok(indexHtml.includes('id="dca-base-investment-usd-input"')); +assert.ok(indexHtml.includes('dcaMode: "定投模式"')); +assert.ok(indexHtml.includes('dcaModeFixed: "定额定投"')); +assert.ok(indexHtml.includes('dcaModeSmart: "智能定投"')); +assert.ok(indexHtml.includes('dcaMode: "DCA mode"')); +assert.ok(indexHtml.includes('dcaProfileDefaults')); assert.ok(indexHtml.includes('el("income-layer-mode-select").addEventListener("change"')); assert.ok(indexHtml.includes('el("income-layer-start-usd-input").addEventListener("input"')); assert.ok(indexHtml.includes('el("income-layer-max-ratio-input").addEventListener("input"')); -assert.ok(indexHtml.includes('label_zh: "纳指100 / 标普500 智能定投"')); +assert.ok(indexHtml.includes('el("dca-mode-select").addEventListener("change"')); +assert.ok(indexHtml.includes('el("dca-base-investment-usd-input").addEventListener("input"')); +assert.ok(indexHtml.includes('label_zh: "纳指100 / 标普500 定投"')); assert.ok(indexHtml.includes('class="form-section income-layer-section"')); +assert.ok(indexHtml.includes('class="form-section dca-section"')); assert.ok(indexHtml.includes('class="control-block reserve-policy-block section-wide"')); assert.ok(indexHtml.includes('profile: "ibit_smart_dca"')); -assert.ok(indexHtml.includes('IBIT 比特币 ETF 智能定投')); +assert.ok(indexHtml.includes('IBIT 比特币定投')); assert.ok(indexHtml.includes('localStrategyLabels')); assert.ok(indexHtml.includes('function strategyLabelSet(')); assert.ok(indexHtml.includes("account-block")); @@ -221,11 +231,21 @@ const strategyProfiles = __test.normalizeStrategyProfilesPayload( domain: "hk_equity", runtime_enabled: true, }, + { + profile: "nasdaq_sp500_smart_dca", + label: "Nasdaq 100 / S&P 500 DCA", + label_zh: "纳指100 / 标普500 定投", + domain: "us_equity", + runtime_enabled: true, + }, ], "test_strategy_profiles", ); assert.equal(strategyProfiles[0].label_en, "TQQQ Growth Income"); assert.equal(strategyProfiles[0].label_zh, "TQQQ 增长收益"); +assert.equal(strategyProfiles[2].dca_enabled, true); +assert.equal(strategyProfiles[2].dca_default_mode, "fixed"); +assert.equal(strategyProfiles[2].dca_default_base_investment_usd, "1000"); const accountOptions = __test.normalizeAccountOptionsPayload( { @@ -345,6 +365,45 @@ const normalizedPluginInputs = __test.normalizeSwitchInputs({ plugin_mode: "none", }); assert.equal(normalizedPluginInputs.plugin_mode, "none"); +const normalizedDcaInputs = __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibkr-primary", + strategy_profile: "nasdaq_sp500_smart_dca", + execution_mode: "live", + plugin_mode: "auto", + dca_mode: "smart", + dca_base_investment_usd: "500", +}); +assert.equal(normalizedDcaInputs.dca_mode, "smart"); +assert.equal(normalizedDcaInputs.dca_base_investment_usd, "500"); +assert.throws( + () => __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibkr-primary", + strategy_profile: "tqqq_growth_income", + dca_mode: "smart", + }), + /DCA settings are only supported/, +); +assert.throws( + () => __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibkr-primary", + strategy_profile: "nasdaq_sp500_smart_dca", + dca_mode: "smart", + dca_base_investment_usd: "0", + }), + /dca_base_investment_usd must be greater than 0/, +); +assert.throws( + () => __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibkr-primary", + strategy_profile: "tqqq_growth_income", + extra_variables_json: JSON.stringify({ DCA_MODE: "smart" }), + }), + /instead of extra_variables_json/, +); const normalizedReserveClearInputs = __test.normalizeSwitchInputs({ platform: "ibkr", target_name: "ibkr-primary", @@ -466,11 +525,23 @@ globalThis.fetch = async (url) => { headers: { "Content-Type": "application/json" }, }); } + if (requestUrl.endsWith("/DCA_MODE")) { + return new Response(JSON.stringify({ value: "smart" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (requestUrl.endsWith("/DCA_BASE_INVESTMENT_USD")) { + return new Response(JSON.stringify({ value: "500" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } if (requestUrl.endsWith("/RUNTIME_TARGET_JSON")) { return new Response(JSON.stringify({ value: JSON.stringify({ platform_id: "schwab", - strategy_profile: "soxl_soxx_trend_income", + strategy_profile: "nasdaq_sp500_smart_dca", dry_run_only: false, account_scope: "schwab", service_name: "charles-schwab-quant-service", @@ -485,13 +556,15 @@ try { { schwab: accountOptions.schwab }, { RUNTIME_SETTINGS_DISPATCH_TOKEN: "test-token" }, ); - assert.equal(currentStrategies.schwab.default.strategy_profile, "soxl_soxx_trend_income"); + assert.equal(currentStrategies.schwab.default.strategy_profile, "nasdaq_sp500_smart_dca"); assert.equal(currentStrategies.schwab.default.execution_mode, "live"); assert.equal(currentStrategies.schwab.default.min_reserved_cash_usd, "150"); assert.equal(currentStrategies.schwab.default.reserved_cash_ratio, "0.03"); assert.equal(currentStrategies.schwab.default.income_layer_start_usd, "150000"); assert.equal(currentStrategies.schwab.default.income_layer_max_ratio, "0.95"); assert.equal(currentStrategies.schwab.default.runtime_target_enabled, false); + assert.equal(currentStrategies.schwab.default.dca_mode, "smart"); + assert.equal(currentStrategies.schwab.default.dca_base_investment_usd, "500"); assert.equal(currentStrategies.schwab.default.source, "RUNTIME_TARGET_JSON"); } finally { globalThis.fetch = originalFetch; @@ -511,9 +584,11 @@ globalThis.fetch = async (url) => { INCOME_LAYER_START_USD: "250000", INCOME_LAYER_MAX_RATIO: "0.55", RUNTIME_TARGET_ENABLED: "false", + DCA_MODE: "smart", + DCA_BASE_INVESTMENT_USD: "700", runtime_target: { platform_id: "ibkr", - strategy_profile: "tqqq_growth_income", + strategy_profile: "ibit_smart_dca", dry_run_only: false, account_scope: "demo-ibkr-tqqq", service_name: "interactive-brokers-demo-ibkr-tqqq-service", @@ -531,12 +606,14 @@ try { { ibkr: accountOptions.ibkr }, { RUNTIME_SETTINGS_DISPATCH_TOKEN: "test-token" }, ); - assert.equal(currentStrategies.ibkr["ibkr-primary"].strategy_profile, "tqqq_growth_income"); + assert.equal(currentStrategies.ibkr["ibkr-primary"].strategy_profile, "ibit_smart_dca"); assert.equal(currentStrategies.ibkr["ibkr-primary"].min_reserved_cash_usd, "150"); assert.equal(currentStrategies.ibkr["ibkr-primary"].reserved_cash_ratio, "0.03"); assert.equal(currentStrategies.ibkr["ibkr-primary"].income_layer_start_usd, "250000"); assert.equal(currentStrategies.ibkr["ibkr-primary"].income_layer_max_ratio, "0.55"); assert.equal(currentStrategies.ibkr["ibkr-primary"].runtime_target_enabled, false); + assert.equal(currentStrategies.ibkr["ibkr-primary"].dca_mode, "smart"); + assert.equal(currentStrategies.ibkr["ibkr-primary"].dca_base_investment_usd, "700"); assert.equal(currentStrategies.ibkr["ibkr-primary"].source, "CLOUD_RUN_SERVICE_TARGETS_JSON"); } finally { globalThis.fetch = originalFetch; diff --git a/tests/test_runtime_settings.py b/tests/test_runtime_settings.py index 8a42d44..1f904ef 100644 --- a/tests/test_runtime_settings.py +++ b/tests/test_runtime_settings.py @@ -357,6 +357,119 @@ def test_build_switch_target_uses_dca_monthly_scheduler_window(self): }, ) + def test_build_switch_target_sets_dca_settings_for_dca_profile(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", + "--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") + + def test_build_switch_target_rejects_dca_settings_for_non_dca_profile(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "live", + "--strategy-profile", + "tqqq_growth_income", + "--dca-mode", + "smart", + ] + ) + + with self.assertRaisesRegex(ValueError, "DCA settings are only supported"): + build_runtime_switch.build_switch_target(args) + + def test_build_switch_target_rejects_direct_dca_extra_variables(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "dca", + "--strategy-profile", + "nasdaq_sp500_smart_dca", + "--extra-variables-json", + '{"DCA_MODE":"smart"}', + ] + ) + + with self.assertRaisesRegex(ValueError, "use --dca-mode"): + build_runtime_switch.build_switch_target(args) + + def test_build_switch_target_preserves_dca_fields_in_service_targets_when_omitted(self): + existing = { + "targets": [ + { + "service": "interactive-brokers-demo-ibkr-dca-service", + "ACCOUNT_GROUP": "demo-ibkr-dca", + "DCA_MODE": "smart", + "DCA_BASE_INVESTMENT_USD": "500", + "runtime_target": { + "platform_id": "ibkr", + "strategy_profile": "nasdaq_sp500_smart_dca", + "dry_run_only": False, + "deployment_selector": "demo-ibkr-dca", + "account_selector": ["DEMO_IBKR_DCA"], + "account_scope": "demo-ibkr-dca", + "service_name": "interactive-brokers-demo-ibkr-dca-service", + "execution_mode": "live", + }, + }, + ], + } + path = ROOT / ".pytest_runtime_service_targets_dca.json" + path.write_text(runtime_settings.compact_json(existing), encoding="utf-8") + self.addCleanup(lambda: path.unlink(missing_ok=True)) + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "demo-ibkr-dca", + "--strategy-profile", + "nasdaq_sp500_smart_dca", + "--account-selector", + "DEMO_IBKR_DCA", + "--service-name", + "interactive-brokers-demo-ibkr-dca-service", + "--plugin-mode", + "none", + "--existing-service-targets-json-file", + str(path), + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + selected = json.loads(assignments["CLOUD_RUN_SERVICE_TARGETS_JSON"])["targets"][0] + + self.assertEqual(selected["runtime_target"]["strategy_profile"], "nasdaq_sp500_smart_dca") + self.assertEqual(selected["DCA_MODE"], "smart") + self.assertEqual(selected["DCA_BASE_INVESTMENT_USD"], "500") + def test_build_switch_target_uses_snapshot_scheduler_window(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 0e0fdb4..de95b82 100644 --- a/web/strategy-switch-console/index.html +++ b/web/strategy-switch-console/index.html @@ -820,6 +820,20 @@

LongBridge

+
+ + + +
+