From f16031d364ad0999918dd01386aca8ae32d49462 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 20 Jun 2026 05:42:58 +0800 Subject: [PATCH] Add IBIT zscore switch controls --- internal_dependency_matrix.json | 8 +- scripts/build_runtime_switch.py | 256 ++++++++++++++++++-- tests/strategy_switch_worker_validation.mjs | 61 +++++ tests/test_runtime_settings.py | 96 ++++++++ web/strategy-switch-console/index.html | 208 +++++++++++++++- web/strategy-switch-console/page_asset.js | 2 +- web/strategy-switch-console/worker.js | 127 +++++++++- 7 files changed, 728 insertions(+), 30 deletions(-) diff --git a/internal_dependency_matrix.json b/internal_dependency_matrix.json index 7ff1399..0e791bd 100644 --- a/internal_dependency_matrix.json +++ b/internal_dependency_matrix.json @@ -62,7 +62,7 @@ "path": "pyproject.toml", "package": "us-equity-strategies", "source_repo": "UsEquityStrategies", - "ref": "31406abfb15507270992e62ead8d1068c03328d0" + "ref": "ced1f78827e6112292af24d32dfe0e0f009e2833" }, { "consumer_repo": "FirstradePlatform", @@ -76,7 +76,7 @@ "path": "requirements.txt", "package": "us-equity-strategies", "source_repo": "UsEquityStrategies", - "ref": "31406abfb15507270992e62ead8d1068c03328d0" + "ref": "ced1f78827e6112292af24d32dfe0e0f009e2833" }, { "consumer_repo": "HkEquityStrategies", @@ -97,7 +97,7 @@ "path": "requirements.txt", "package": "us-equity-strategies", "source_repo": "UsEquityStrategies", - "ref": "31406abfb15507270992e62ead8d1068c03328d0" + "ref": "ced1f78827e6112292af24d32dfe0e0f009e2833" }, { "consumer_repo": "InteractiveBrokersPlatform", @@ -118,7 +118,7 @@ "path": "requirements.txt", "package": "us-equity-strategies", "source_repo": "UsEquityStrategies", - "ref": "31406abfb15507270992e62ead8d1068c03328d0" + "ref": "ced1f78827e6112292af24d32dfe0e0f009e2833" }, { "consumer_repo": "LongBridgePlatform", diff --git a/scripts/build_runtime_switch.py b/scripts/build_runtime_switch.py index ea5827a..71d9eb8 100644 --- a/scripts/build_runtime_switch.py +++ b/scripts/build_runtime_switch.py @@ -31,6 +31,8 @@ "russell_top50_leader_rotation", } ) +IBIT_ZSCORE_EXIT_STRATEGY_PROFILE = "ibit_smart_dca" +IBIT_ZSCORE_EXIT_PLUGIN = "ibit_zscore_exit" US_DAILY_SCHEDULER = { "timezone": "America/New_York", "main_time": "45 15 * * *", @@ -108,6 +110,27 @@ ) DCA_MODE_CONTROL_FIELD = "dca_mode" DCA_BASE_INVESTMENT_CONTROL_FIELD = "dca_base_investment_usd" +IBIT_ZSCORE_EXIT_ENABLED_VARIABLE = "IBIT_ZSCORE_EXIT_ENABLED" +IBIT_ZSCORE_EXIT_MODE_VARIABLE = "IBIT_ZSCORE_EXIT_MODE" +IBIT_ZSCORE_EXIT_PARKING_SYMBOL_VARIABLE = "IBIT_ZSCORE_EXIT_PARKING_SYMBOL" +IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE_VARIABLE = "IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE" +IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE_VARIABLE = "IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE" +IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_WINDOW_VARIABLE = "IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW" +IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES = ( + IBIT_ZSCORE_EXIT_ENABLED_VARIABLE, + IBIT_ZSCORE_EXIT_MODE_VARIABLE, + IBIT_ZSCORE_EXIT_PARKING_SYMBOL_VARIABLE, + IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE_VARIABLE, + IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE_VARIABLE, + IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_WINDOW_VARIABLE, +) +IBIT_ZSCORE_EXIT_CONTROL_FIELDS = ( + "ibit_zscore_exit_mode", + "ibit_zscore_exit_parking_symbol", + "ibit_zscore_exit_risk_reduced_exposure", + "ibit_zscore_exit_risk_off_exposure", + "ibit_zscore_exit_allow_outside_execution_window", +) DEFAULT_VARIABLE_SCOPE = { "longbridge": "environment", "ibkr": "repository", @@ -236,6 +259,51 @@ def _normalize_positive_decimal(value: str, *, field_name: str) -> str: return text +def _normalize_ratio_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 decimal number") + numeric = float(text) + if numeric < 0 or numeric > 1: + raise ValueError(f"{field_name} must be between 0 and 1") + return text + + +def _normalize_optional_bool_text(value: str, *, field_name: str) -> str: + text = str(value or "").strip().lower() + if text in {"1", "true", "yes", "y", "on"}: + return "true" + if text in {"0", "false", "no", "n", "off"}: + return "false" + raise ValueError(f"{field_name} must be true or false") + + +def _normalize_ibit_zscore_exit_mode(value: str) -> str: + mode = str(value or "").strip().lower() + aliases = { + "off": "disabled", + "none": "disabled", + "false": "disabled", + "0": "disabled", + "disable": "disabled", + "enabled": "live", + "shadow": "paper", + "dry_run": "paper", + "dry-run": "paper", + } + mode = aliases.get(mode, mode) + if mode not in {"disabled", "paper", "live"}: + raise ValueError("ibit_zscore_exit_mode must be disabled, paper, or live") + return mode + + +def _normalize_symbol_text(value: str, *, field_name: str) -> str: + text = str(value or "").strip().upper().removesuffix(".US") + if not text or not re.fullmatch(r"[A-Z0-9.-]{1,12}", text): + raise ValueError(f"{field_name} must be a symbol") + return text + + 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): @@ -244,6 +312,14 @@ def _extract_dca_control_fields(extra_variables: dict[str, Any]) -> dict[str, An return controls +def _extract_ibit_zscore_exit_control_fields(extra_variables: dict[str, Any]) -> dict[str, Any]: + controls: dict[str, Any] = {} + for field_name in IBIT_ZSCORE_EXIT_CONTROL_FIELDS: + 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, @@ -288,23 +364,130 @@ def _reject_direct_dca_extra_variables(extra_variables: dict[str, Any]) -> None: ) +def _reject_direct_ibit_zscore_exit_extra_variables(extra_variables: dict[str, Any]) -> None: + provided = [ + variable + for variable in IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES + if variable in extra_variables and str(extra_variables.get(variable) or "").strip() + ] + if provided: + names = ", ".join(provided) + raise ValueError( + "use ibit_zscore_exit_* control fields instead of extra_variables_json " + f"for {names}" + ) + + +def _ibit_zscore_exit_extra_variables( + args: argparse.Namespace, + strategy_profile: str, + plugin_mode: str, + controls: dict[str, Any] | None = None, +) -> dict[str, Any]: + controls = dict(controls or {}) + cli_mode = str(getattr(args, "ibit_zscore_exit_mode", "") or "").strip() + mode_value = cli_mode or controls.get("ibit_zscore_exit_mode", "") + has_controls = bool(mode_value) or any( + str(controls.get(field, "") or "").strip() + for field in IBIT_ZSCORE_EXIT_CONTROL_FIELDS + if field != "ibit_zscore_exit_mode" + ) + has_cli_controls = any( + str(getattr(args, attr, "") or "").strip() + for attr in ( + "ibit_zscore_exit_parking_symbol", + "ibit_zscore_exit_risk_reduced_exposure", + "ibit_zscore_exit_risk_off_exposure", + "ibit_zscore_exit_allow_outside_execution_window", + ) + ) + is_ibit_profile = strategy_profile == IBIT_ZSCORE_EXIT_STRATEGY_PROFILE + if not is_ibit_profile: + if has_controls or has_cli_controls: + raise ValueError("IBIT Z-Score exit settings are only supported for ibit_smart_dca") + return {variable: "" for variable in IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES} + + if not mode_value: + mode = "disabled" if plugin_mode == "none" else "live" + else: + mode = _normalize_ibit_zscore_exit_mode(mode_value) + if plugin_mode == "none" and mode != "disabled": + raise ValueError("IBIT Z-Score exit live/paper modes require plugin_mode auto or custom") + + parking_symbol = ( + getattr(args, "ibit_zscore_exit_parking_symbol", "") + or controls.get("ibit_zscore_exit_parking_symbol") + or "BOXX" + ) + risk_reduced_exposure = ( + getattr(args, "ibit_zscore_exit_risk_reduced_exposure", "") + or controls.get("ibit_zscore_exit_risk_reduced_exposure") + or "0.50" + ) + risk_off_exposure = ( + getattr(args, "ibit_zscore_exit_risk_off_exposure", "") + or controls.get("ibit_zscore_exit_risk_off_exposure") + or "0.25" + ) + allow_outside_window = ( + getattr(args, "ibit_zscore_exit_allow_outside_execution_window", "") + or controls.get("ibit_zscore_exit_allow_outside_execution_window") + or "true" + ) + return { + IBIT_ZSCORE_EXIT_ENABLED_VARIABLE: "true" if mode != "disabled" else "false", + IBIT_ZSCORE_EXIT_MODE_VARIABLE: "paper" if mode == "disabled" else mode, + IBIT_ZSCORE_EXIT_PARKING_SYMBOL_VARIABLE: _normalize_symbol_text( + parking_symbol, + field_name="ibit_zscore_exit_parking_symbol", + ), + IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE_VARIABLE: _normalize_ratio_decimal( + risk_reduced_exposure, + field_name="ibit_zscore_exit_risk_reduced_exposure", + ), + IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE_VARIABLE: _normalize_ratio_decimal( + risk_off_exposure, + field_name="ibit_zscore_exit_risk_off_exposure", + ), + IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_WINDOW_VARIABLE: _normalize_optional_bool_text( + allow_outside_window, + field_name="ibit_zscore_exit_allow_outside_execution_window", + ), + } + + 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 [] prefix = artifact_bucket_uri.rstrip("/") - return [ - { - "strategy": strategy_profile, - "plugin": "market_regime_control", - "signal_path": ( - f"{prefix}/strategy-artifacts/us_equity/{strategy_profile}" - "/plugins/market_regime_control/latest_signal.json" - ), - "enabled": True, - "expected_mode": "shadow", - "expected_schema_version": "market_regime_control.v1", - } - ] + mounts: list[dict[str, Any]] = [] + if strategy_profile in MARKET_REGIME_CONTROL_PROFILES: + mounts.append( + { + "strategy": strategy_profile, + "plugin": "market_regime_control", + "signal_path": ( + f"{prefix}/strategy-artifacts/us_equity/{strategy_profile}" + "/plugins/market_regime_control/latest_signal.json" + ), + "enabled": True, + "expected_mode": "shadow", + "expected_schema_version": "market_regime_control.v1", + } + ) + if strategy_profile == IBIT_ZSCORE_EXIT_STRATEGY_PROFILE: + mounts.append( + { + "strategy": strategy_profile, + "plugin": IBIT_ZSCORE_EXIT_PLUGIN, + "signal_path": ( + f"{prefix}/strategy-artifacts/us_equity/{strategy_profile}" + f"/plugins/{IBIT_ZSCORE_EXIT_PLUGIN}/latest_signal.json" + ), + "enabled": True, + "expected_mode": "shadow", + "expected_schema_version": "ibit_zscore_exit.v1", + } + ) + return mounts def _custom_plugin_mounts(raw_json: str) -> list[dict[str, Any]]: @@ -342,8 +525,32 @@ def _execution_mode_and_dry_run(raw_mode: str) -> tuple[str, bool]: raise ValueError("execution_mode must be live or paper") -def _scheduler_plan_for_strategy(strategy_profile: str) -> dict[str, str]: +def _has_enabled_plugin_mount( + mounts: list[dict[str, Any]] | tuple[dict[str, Any], ...], + *, + strategy_profile: str, + plugin: str, +) -> bool: + return any( + isinstance(mount, dict) + and mount.get("strategy") == strategy_profile + and mount.get("plugin") == plugin + and mount.get("enabled") is True + for mount in mounts + ) + + +def _scheduler_plan_for_strategy( + strategy_profile: str, + plugin_mounts: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (), +) -> dict[str, str]: profile = str(strategy_profile or "").strip().lower() + if profile == IBIT_ZSCORE_EXIT_STRATEGY_PROFILE and _has_enabled_plugin_mount( + plugin_mounts, + strategy_profile=profile, + plugin=IBIT_ZSCORE_EXIT_PLUGIN, + ): + return dict(US_DAILY_SCHEDULER) scheduler = STRATEGY_SCHEDULER_PROFILES.get(profile) if scheduler is None: scheduler = HK_DAILY_SCHEDULER if profile.startswith("hk_") else US_DAILY_SCHEDULER @@ -420,6 +627,7 @@ def _preserve_reserved_cash_fields( *INCOME_LAYER_VARIABLES, *RUNTIME_TARGET_VARIABLES, *DCA_RUNTIME_VARIABLES, + *IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES, ): if variable and variable not in replacement and variable in current_entry: replacement[variable] = current_entry[variable] @@ -482,10 +690,13 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: github_environment = args.github_environment or _default_github_environment(platform, target_name, variable_scope) runtime_target = _build_runtime_target(args) mounts = _plugin_mounts(args, runtime_target["strategy_profile"]) + 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) dca_controls = _extract_dca_control_fields(extra_variables) + ibit_zscore_exit_controls = _extract_ibit_zscore_exit_control_fields(extra_variables) _reject_direct_dca_extra_variables(extra_variables) + _reject_direct_ibit_zscore_exit_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"]) @@ -502,6 +713,14 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: 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"], dca_controls)) + extra_variables.update( + _ibit_zscore_exit_extra_variables( + args, + runtime_target["strategy_profile"], + str(args.plugin_mode or "auto").strip().lower(), + ibit_zscore_exit_controls, + ) + ) service_targets = _load_json_from_file( args.existing_service_targets_json_file, @@ -569,6 +788,11 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--qqqi-income-ratio", default="") parser.add_argument("--dca-mode", default="") parser.add_argument("--dca-base-investment-usd", default="") + parser.add_argument("--ibit-zscore-exit-mode", choices=("disabled", "paper", "live"), default="") + parser.add_argument("--ibit-zscore-exit-parking-symbol", default="") + parser.add_argument("--ibit-zscore-exit-risk-reduced-exposure", default="") + parser.add_argument("--ibit-zscore-exit-risk-off-exposure", default="") + parser.add_argument("--ibit-zscore-exit-allow-outside-execution-window", 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 2d3d15b..9377440 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -27,6 +27,7 @@ assert.ok(indexHtml.includes('id="reserved-cash-ratio-input"')); assert.ok(indexHtml.includes('id="reserve-policy-mode-select"')); assert.ok(indexHtml.includes('id="runtime-target-enabled-select"')); assert.ok(indexHtml.includes('id="plugin-mode-select"')); +assert.ok(indexHtml.includes('id="ibit-zscore-exit-mode-select"')); assert.ok(indexHtml.includes('id="income-layer-start-usd-input"')); assert.ok(indexHtml.includes('incomeLayerStartUsd: "收入层起始金额"')); assert.ok(indexHtml.includes('incomeLayerStartUsd: "Income layer start amount"')); @@ -76,6 +77,11 @@ assert.ok(indexHtml.includes('pluginModeAuto: "启用插件"')); assert.ok(indexHtml.includes('pluginModeNone: "禁用插件"')); assert.ok(indexHtml.includes('pluginModeAuto: "Enabled"')); assert.ok(indexHtml.includes('pluginMode: "Plugin scope"')); +assert.ok(indexHtml.includes('ibitZscoreExitMode: "IBIT Z-Score 逃顶"')); +assert.ok(indexHtml.includes('ibitZscoreExitLive: "实盘执行(按信号调仓)"')); +assert.ok(indexHtml.includes('ibitZscoreExitMode: "IBIT Z-Score exit"')); +assert.ok(indexHtml.includes('changes.ibitZscoreExitChanged')); +assert.ok(indexHtml.includes('el("ibit-zscore-exit-mode-select").addEventListener("change"')); assert.ok(indexHtml.includes('reservedCashDefault')); assert.ok(indexHtml.includes('paper: "模拟"')); assert.ok(indexHtml.includes('paper: "Dry run"')); @@ -399,6 +405,26 @@ assert.deepEqual(JSON.parse(normalizedDcaJsonInputs.extra_variables_json), { dca_mode: "smart", dca_base_investment_usd: "500", }); +const normalizedIbitZscoreInputs = __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibit-primary", + strategy_profile: "ibit_smart_dca", + execution_mode: "live", + plugin_mode: "auto", + ibit_zscore_exit_mode: "live", +}); +assert.deepEqual(JSON.parse(normalizedIbitZscoreInputs.extra_variables_json), { + ibit_zscore_exit_mode: "live", +}); +assert.throws( + () => __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibkr-primary", + strategy_profile: "nasdaq_sp500_smart_dca", + ibit_zscore_exit_mode: "live", + }), + /IBIT Z-Score exit settings/, +); assert.throws( () => __test.normalizeSwitchInputs({ platform: "ibkr", @@ -488,6 +514,31 @@ const updatedPluginModeOptions = __test.updateAccountOptionsDefaultStrategy( assert.equal(updatedPluginModeOptions.changed, true); assert.equal(updatedPluginModeOptions.options.longbridge[1].plugin_mode, "none"); +const updatedIbitZscoreModeOptions = __test.updateAccountOptionsDefaultStrategy( + { + ...accountOptions, + ibkr: [ + { + key: "ibit-primary", + target_name: "ibit-primary", + default_strategy_profile: "ibit_smart_dca", + supported_domains: ["us_equity"], + }, + ], + }, + { + platform: "ibkr", + target_name: "ibit-primary", + strategy_profile: "ibit_smart_dca", + execution_mode: "live", + variable_scope: "repository", + plugin_mode: "auto", + extra_variables_json: JSON.stringify({ ibit_zscore_exit_mode: "live" }), + }, +); +assert.equal(updatedIbitZscoreModeOptions.changed, true); +assert.equal(updatedIbitZscoreModeOptions.options.ibkr[0].ibit_zscore_exit_mode, "live"); + const kvWrites = new Map(); const syncResult = await __test.syncDefaultStrategyForAccount( { @@ -609,6 +660,8 @@ globalThis.fetch = async (url) => { RUNTIME_TARGET_ENABLED: "false", DCA_MODE: "smart", DCA_BASE_INVESTMENT_USD: "700", + IBIT_ZSCORE_EXIT_ENABLED: "true", + IBIT_ZSCORE_EXIT_MODE: "live", runtime_target: { platform_id: "ibkr", strategy_profile: "ibit_smart_dca", @@ -637,6 +690,7 @@ try { 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"].ibit_zscore_exit_mode, "live"); assert.equal(currentStrategies.ibkr["ibkr-primary"].source, "CLOUD_RUN_SERVICE_TARGETS_JSON"); } finally { globalThis.fetch = originalFetch; @@ -801,6 +855,12 @@ globalThis.fetch = async (url) => { headers: { "Content-Type": "application/json" }, }); } + if (requestUrl.includes("/FirstradePlatform/actions/variables/IBIT_ZSCORE_EXIT_ENABLED")) { + return new Response(JSON.stringify({ value: "true" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } return new Response("", { status: 404 }); }; try { @@ -829,6 +889,7 @@ try { assert.equal(currentStrategies.firstrade.default.reserved_cash_ratio, "0.02"); assert.equal(currentStrategies.firstrade.default.dca_mode, "fixed"); assert.equal(currentStrategies.firstrade.default.dca_base_investment_usd, "50"); + assert.equal(currentStrategies.firstrade.default.ibit_zscore_exit_mode, "live"); } finally { globalThis.fetch = originalFetch; } diff --git a/tests/test_runtime_settings.py b/tests/test_runtime_settings.py index 198d7c8..9b7ec24 100644 --- a/tests/test_runtime_settings.py +++ b/tests/test_runtime_settings.py @@ -381,6 +381,102 @@ def test_build_switch_target_uses_dca_monthly_scheduler_window(self): }, ) + def test_build_switch_target_uses_daily_scheduler_when_ibit_zscore_plugin_is_auto_mounted(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "ibit", + "--strategy-profile", + "ibit_smart_dca", + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + plugin_payload = json.loads(assignments["IBKR_STRATEGY_PLUGIN_MOUNTS_JSON"]) + + self.assertEqual( + target["runtime_target"]["scheduler"], + { + "timezone": "America/New_York", + "main_time": "45 15 * * *", + "probe_time": "35 9,15 * * *", + "precheck_time": "45 9 * * *", + }, + ) + self.assertEqual(plugin_payload["strategy_plugins"][0]["plugin"], "ibit_zscore_exit") + self.assertEqual(plugin_payload["strategy_plugins"][0]["expected_mode"], "shadow") + self.assertEqual(plugin_payload["strategy_plugins"][0]["expected_schema_version"], "ibit_zscore_exit.v1") + self.assertEqual(assignments["IBIT_ZSCORE_EXIT_ENABLED"], "true") + self.assertEqual(assignments["IBIT_ZSCORE_EXIT_MODE"], "live") + + def test_build_switch_target_sets_ibit_zscore_exit_runtime_controls(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "ibit", + "--strategy-profile", + "ibit_smart_dca", + "--extra-variables-json", + '{"ibit_zscore_exit_mode":"live","ibit_zscore_exit_parking_symbol":"SGOV"}', + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + + self.assertEqual(assignments["IBIT_ZSCORE_EXIT_ENABLED"], "true") + self.assertEqual(assignments["IBIT_ZSCORE_EXIT_MODE"], "live") + self.assertEqual(assignments["IBIT_ZSCORE_EXIT_PARKING_SYMBOL"], "SGOV") + self.assertNotIn("ibit_zscore_exit_mode", target["extra_variables"]) + self.assertNotIn("ibit_zscore_exit_parking_symbol", target["extra_variables"]) + + def test_build_switch_target_disables_ibit_zscore_exit_when_plugins_are_disabled(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "ibit", + "--strategy-profile", + "ibit_smart_dca", + "--plugin-mode", + "none", + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + + self.assertEqual(target["runtime_target"]["scheduler"], build_runtime_switch.US_DCA_SCHEDULER) + self.assertEqual(assignments["IBIT_ZSCORE_EXIT_ENABLED"], "false") + self.assertEqual(assignments["IBIT_ZSCORE_EXIT_MODE"], "paper") + + def test_build_switch_target_rejects_ibit_zscore_controls_for_other_profiles(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", + '{"ibit_zscore_exit_mode":"live"}', + ] + ) + + with self.assertRaisesRegex(ValueError, "IBIT Z-Score exit settings"): + build_runtime_switch.build_switch_target(args) + def test_build_switch_target_sets_dca_settings_for_dca_profile(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 2e1f157..68232db 100644 --- a/web/strategy-switch-console/index.html +++ b/web/strategy-switch-console/index.html @@ -830,6 +830,12 @@

LongBridge

选择是否启用该策略的插件。 + +
@@ -927,6 +933,7 @@

切换摘要

const runtimeTargetModes = ["current", "enabled", "disabled"]; const pluginModes = ["auto", "none"]; const dcaModes = ["fixed", "smart"]; + const ibitZscoreExitModes = ["paper", "live", "disabled"]; const runtimeTargetEnabledVariable = "RUNTIME_TARGET_ENABLED"; const incomeLayerEnabledVariable = "INCOME_LAYER_ENABLED"; const incomeLayerStartUsdVariable = "INCOME_LAYER_START_USD"; @@ -1042,6 +1049,11 @@

切换摘要

pluginModeAuto: "启用插件", pluginModeNone: "禁用插件", pluginModeMeta: "选择是否启用该策略的插件。", + ibitZscoreExitMode: "IBIT Z-Score 逃顶", + ibitZscoreExitPaper: "观察模式(不卖出)", + ibitZscoreExitLive: "实盘执行(按信号调仓)", + ibitZscoreExitDisabled: "禁用逃顶(只买不卖)", + ibitZscoreExitModeMeta: "插件启用后默认实盘消费动态 Z-Score 信号;可改为观察模式,或禁用后只买不卖。", incomeLayerMode: "收入层状态", incomeLayerCurrent: "沿用当前配置", incomeLayerEnabled: "开启收入层", @@ -1094,6 +1106,7 @@

切换摘要

invalidReservePolicyNote: "请为当前预留现金策略填写有效金额或比例。", invalidIncomeLayerNote: "请填写有效的收入层起始金额和最高比例。", invalidDcaNote: "请填写有效的定投模式和基准金额。", + invalidIbitZscoreExitNote: "请选择有效的 IBIT Z-Score 逃顶模式。", noAccount: "没有账号选项", noStrategy: "没有支持的策略", repository: "平台仓库", @@ -1108,6 +1121,8 @@

切换摘要

pendingMode: "待提交模式", currentPluginMode: "当前插件范围", pendingPluginMode: "待提交插件范围", + currentIbitZscoreExit: "当前 Z-Score 逃顶", + pendingIbitZscoreExit: "待提交 Z-Score 逃顶", unchanged: "不变", copied: "已复制状态", dispatching: "正在触发工作流...", @@ -1154,6 +1169,11 @@

切换摘要

pluginModeAuto: "Enabled", pluginModeNone: "Disabled", pluginModeMeta: "Choose whether to enable this strategy's plugins.", + ibitZscoreExitMode: "IBIT Z-Score exit", + ibitZscoreExitPaper: "Paper mode (signals only)", + ibitZscoreExitLive: "Live execution (rebalance on signals)", + ibitZscoreExitDisabled: "Disabled (buy only)", + ibitZscoreExitModeMeta: "When plugins are enabled, live consumption is the default. Paper mode observes only; disabled mode keeps buy-only DCA.", incomeLayerMode: "Income layer", incomeLayerCurrent: "Keep current config", incomeLayerEnabled: "Enable income layer", @@ -1206,6 +1226,7 @@

切换摘要

invalidReservePolicyNote: "Enter a valid amount or ratio for the selected reserved-cash policy.", invalidIncomeLayerNote: "Enter a valid income layer start amount and max ratio.", invalidDcaNote: "Enter a valid DCA mode and base amount.", + invalidIbitZscoreExitNote: "Choose a valid IBIT Z-Score exit mode.", noAccount: "No accounts", noStrategy: "No supported strategies", repository: "Repository", @@ -1220,6 +1241,8 @@

切换摘要

pendingMode: "Pending mode", currentPluginMode: "Current plugin scope", pendingPluginMode: "Pending plugin scope", + currentIbitZscoreExit: "Current Z-Score exit", + pendingIbitZscoreExit: "Pending Z-Score exit", unchanged: "Unchanged", copied: "State copied", dispatching: "Dispatching workflow...", @@ -1259,6 +1282,8 @@

切换摘要

dcaMode: "fixed", dcaBaseInvestmentUsd: "", dcaTouched: false, + ibitZscoreExitMode: "live", + ibitZscoreExitTouched: false, }); const state = { @@ -1457,6 +1482,37 @@

切换摘要

return mode === "none" ? t("pluginModeNone") : t("pluginModeAuto"); } + function ibitZscoreExitSupported(profile) { + return cleanStrategyProfile(profile) === "ibit_smart_dca"; + } + + function cleanIbitZscoreExitMode(value) { + const mode = String(value || "").trim().toLowerCase(); + const aliases = { + off: "disabled", + none: "disabled", + false: "disabled", + disable: "disabled", + enabled: "live", + shadow: "paper", + dry_run: "paper", + "dry-run": "paper", + }; + const normalized = aliases[mode] || mode; + return ibitZscoreExitModes.includes(normalized) ? normalized : ""; + } + + function normalizeIbitZscoreExitMode(value) { + return cleanIbitZscoreExitMode(value) || "live"; + } + + function ibitZscoreExitModeLabel(mode) { + const normalized = normalizeIbitZscoreExitMode(mode); + if (normalized === "live") return t("ibitZscoreExitLive"); + if (normalized === "disabled") return t("ibitZscoreExitDisabled"); + return t("ibitZscoreExitPaper"); + } + function dcaConfigForStrategy(profile) { const cleanProfile = cleanStrategyProfile(profile); const catalog = strategyCatalog[cleanProfile] || {}; @@ -1592,6 +1648,7 @@

切换摘要

cleanOptionalBoolean(entry.runtime_target_enabled) !== null || normalizeDcaMode(entry.dca_mode || "") !== "fixed" || cleanDisplayPositiveNumber(entry.dca_base_investment_usd) || + cleanIbitZscoreExitMode(entry.ibit_zscore_exit_mode) || normalizeExecutionMode(entry.execution_mode, entry.dry_run_only), ); } @@ -1612,6 +1669,18 @@

切换摘要

return normalizePluginMode(account?.plugin_mode); } + function currentIbitZscoreExitForAccount(platform, account, profile = state.forms[platform]?.strategy) { + if (!ibitZscoreExitSupported(profile)) return { supported: false, mode: "" }; + const entry = currentEntryForAccount(platform, account); + const mode = cleanIbitZscoreExitMode(entry?.ibit_zscore_exit_mode) || + cleanIbitZscoreExitMode(account?.ibit_zscore_exit_mode); + if (mode) return { supported: true, mode }; + if (currentPluginModeForAccount(platform, account) === "none") { + return { supported: true, mode: "disabled" }; + } + return { supported: true, mode: "" }; + } + function reservePolicyFromEntry(entry) { return { minReservedCashUsd: cleanDisplayNumber(entry?.min_reserved_cash_usd ?? entry?.reserved_cash_floor_usd), @@ -1707,6 +1776,7 @@

切换摘要

syncReservePolicyForAccount(platform); syncIncomeLayerForAccount(platform); syncDcaForAccount(platform); + syncIbitZscoreExitForAccount(platform); } function syncRuntimeTargetForAccount(platform) { @@ -1742,6 +1812,18 @@

切换摘要

form.dcaBaseInvestmentUsd = current.supported ? current.baseInvestmentUsd : ""; } + function syncIbitZscoreExitForAccount(platform) { + const form = state.forms[platform]; + if (!form) return; + if (!ibitZscoreExitSupported(form.strategy) || normalizePluginMode(form.pluginMode) === "none") { + form.ibitZscoreExitMode = "disabled"; + return; + } + if (form.ibitZscoreExitTouched) return; + const current = currentIbitZscoreExitForAccount(platform, selectedAccount(platform), form.strategy); + form.ibitZscoreExitMode = current.mode || "live"; + } + function ensureAccountSelection(platform) { const options = optionsFor(platform); if (!options.length) return; @@ -1751,12 +1833,14 @@

切换摘要

state.forms[platform].reservedCashTouched = false; state.forms[platform].incomeLayerTouched = false; state.forms[platform].dcaTouched = false; + state.forms[platform].ibitZscoreExitTouched = false; state.forms[platform].strategy = defaultStrategyForAccount(platform, options[0], state.forms[platform].strategy); state.forms[platform].pluginMode = currentPluginModeForAccount(platform, options[0]); syncRuntimeTargetForAccount(platform); syncReservePolicyForAccount(platform); syncIncomeLayerForAccount(platform); syncDcaForAccount(platform); + syncIbitZscoreExitForAccount(platform); } } @@ -1808,11 +1892,19 @@

切换摘要

return Boolean(dcaModes.includes(normalizeDcaMode(form?.dcaMode)) && cleanDisplayPositiveNumber(form?.dcaBaseInvestmentUsd)); } + function hasValidIbitZscoreExitPolicy(platform = state.selected) { + const form = state.forms[platform]; + if (!ibitZscoreExitSupported(form?.strategy)) return true; + if (normalizePluginMode(form?.pluginMode) === "none") return true; + return Boolean(cleanIbitZscoreExitMode(form?.ibitZscoreExitMode)); + } + function hasValidStrategySelection(platform = state.selected) { return hasRunnableStrategySelection(platform) && hasValidReservePolicy(platform) && hasValidIncomeLayerPolicy(platform) && - hasValidDcaPolicy(platform); + hasValidDcaPolicy(platform) && + hasValidIbitZscoreExitPolicy(platform); } function normalizeReservePolicyMode(value) { @@ -1885,6 +1977,14 @@

切换摘要

return { inputs: { dca_mode: mode, dca_base_investment_usd: baseInvestmentUsd } }; } + function ibitZscoreExitOverrideForForm(form) { + if (!ibitZscoreExitSupported(form?.strategy)) return null; + const mode = normalizePluginMode(form?.pluginMode) === "none" + ? "disabled" + : normalizeIbitZscoreExitMode(form?.ibitZscoreExitMode); + return { inputs: {}, extraVariables: { ibit_zscore_exit_mode: mode } }; + } + function mergeExtraVariables(inputs, extraVariables) { if (!extraVariables || !Object.keys(extraVariables).length) return; const merged = inputs.extra_variables_json ? JSON.parse(inputs.extra_variables_json) : {}; @@ -1941,6 +2041,11 @@

切换摘要

if (dcaOverride) { Object.assign(inputs, dcaOverride.inputs); } + const ibitZscoreOverride = ibitZscoreExitOverrideForForm(form); + if (ibitZscoreOverride) { + Object.assign(inputs, ibitZscoreOverride.inputs); + mergeExtraVariables(inputs, ibitZscoreOverride.extraVariables); + } return inputs; } @@ -2066,6 +2171,28 @@

切换摘要

.replace("{amount}", formatUsd(pending.inputs.dca_base_investment_usd)); } + function currentIbitZscoreExitText(platform = state.selected, account = selectedAccount(platform), profile = state.forms[platform]?.strategy) { + const current = currentIbitZscoreExitForAccount(platform, account, profile); + if (!current.supported) return ""; + return current.mode ? ibitZscoreExitModeLabel(current.mode) : t("notRead"); + } + + function pendingIbitZscoreExitText(inputs, platform = state.selected, account = selectedAccount(platform)) { + const pending = pendingIbitZscoreExit(inputs, platform, account); + if (!pending.supported) return ""; + if (!pending.changed) return t("unchanged"); + return ibitZscoreExitModeLabel(pending.inputs.ibit_zscore_exit_mode); + } + + function ibitZscoreExitModeFromInputs(inputs) { + try { + const payload = inputs?.extra_variables_json ? JSON.parse(inputs.extra_variables_json) : {}; + return cleanIbitZscoreExitMode(payload.ibit_zscore_exit_mode || inputs?.ibit_zscore_exit_mode); + } catch { + return cleanIbitZscoreExitMode(inputs?.ibit_zscore_exit_mode); + } + } + function pendingRuntimeTarget(inputs, platform = state.selected, account = selectedAccount(platform)) { const mode = normalizeRuntimeTargetMode(inputs.runtime_target_enabled_mode); const current = runtimeTargetEnabledForAccount(platform, account); @@ -2144,6 +2271,19 @@

切换摘要

}; } + function pendingIbitZscoreExit(inputs, platform = state.selected, account = selectedAccount(platform)) { + const profile = cleanStrategyProfile(inputs.strategy_profile || state.forms[platform]?.strategy); + if (!ibitZscoreExitSupported(profile)) return { supported: false, changed: false, inputs: {} }; + const current = currentIbitZscoreExitForAccount(platform, account, profile); + const nextMode = ibitZscoreExitModeFromInputs(inputs) || + (normalizePluginMode(inputs.plugin_mode) === "none" ? "disabled" : "live"); + return { + supported: true, + changed: current.mode ? current.mode !== nextMode : true, + inputs: { ibit_zscore_exit_mode: nextMode }, + }; + } + function pendingChangeState(inputs, platform = state.selected, account = selectedAccount(platform)) { const currentProfile = currentStrategyForAccount(platform, account); const nextProfile = cleanStrategyProfile(inputs.strategy_profile); @@ -2155,6 +2295,7 @@

切换摘要

const reserve = pendingReservePolicy(inputs, platform, account); const income = pendingIncomeLayer(inputs, platform, account); const dca = pendingDca(inputs, platform, account); + const ibitZscoreExit = pendingIbitZscoreExit(inputs, platform, account); return { currentProfile, nextProfile, @@ -2168,10 +2309,12 @@

切换摘要

reserveCashChanged: reserve.changed, incomeLayerChanged: income.changed, dcaChanged: dca.changed, + ibitZscoreExitChanged: ibitZscoreExit.changed, runtimeTarget, reserve, income, dca, + ibitZscoreExit, }; } @@ -2184,7 +2327,8 @@

切换摘要

changes.runtimeTargetChanged || changes.reserveCashChanged || changes.incomeLayerChanged || - changes.dcaChanged, + changes.dcaChanged || + changes.ibitZscoreExitChanged, ); } @@ -2238,6 +2382,9 @@

切换摘要

if (dcaSupported(inputs.strategy_profile)) { rows.push([t("currentDca"), currentDcaText(state.selected, account, inputs.strategy_profile)]); } + if (ibitZscoreExitSupported(inputs.strategy_profile)) { + rows.push([t("currentIbitZscoreExit"), currentIbitZscoreExitText(state.selected, account, inputs.strategy_profile)]); + } if (changes.reserveCashChanged) { rows.push([t("pendingReservedCashPolicy"), pendingReservedCashPolicyText(inputs, state.selected, account), "pending"]); } @@ -2247,6 +2394,9 @@

切换摘要

if (changes.dcaChanged) { rows.push([t("pendingDca"), pendingDcaText(inputs, state.selected, account), "pending"]); } + if (changes.ibitZscoreExitChanged) { + rows.push([t("pendingIbitZscoreExit"), pendingIbitZscoreExitText(inputs, state.selected, account), "pending"]); + } if (changes.modeChanged) { rows.push([t("pendingMode"), modeLabel(inputs.execution_mode), "pending"]); } @@ -2320,6 +2470,7 @@

切换摘要

const strategySelect = el("strategy-select"); const runtimeTargetEnabledSelect = el("runtime-target-enabled-select"); const pluginModeSelect = el("plugin-mode-select"); + const ibitZscoreExitModeSelect = el("ibit-zscore-exit-mode-select"); const incomeLayerModeSelect = el("income-layer-mode-select"); const incomeLayerStartUsdInput = el("income-layer-start-usd-input"); const incomeLayerMaxRatioInput = el("income-layer-max-ratio-input"); @@ -2342,6 +2493,7 @@

切换摘要

strategySelect.replaceChildren(); runtimeTargetEnabledSelect.replaceChildren(); pluginModeSelect.replaceChildren(); + ibitZscoreExitModeSelect.replaceChildren(); incomeLayerModeSelect.replaceChildren(); dcaModeSelect.replaceChildren(); reservePolicyModeSelect.replaceChildren(); @@ -2352,6 +2504,8 @@

切换摘要

reservedCashRatioInput.value = ""; el("account-meta").textContent = ""; el("strategy-meta").textContent = ""; + el("ibit-zscore-exit-mode-block").hidden = true; + el("ibit-zscore-exit-mode-meta").textContent = ""; el("income-layer-mode-meta").textContent = ""; el("income-layer-start-meta").textContent = ""; el("income-layer-ratio-meta").textContent = ""; @@ -2395,6 +2549,30 @@

切换摘要

for (const mode of pluginModes) { pluginModeSelect.append(new Option(pluginModeLabel(mode), mode, false, mode === normalizePluginMode(form.pluginMode))); } + syncIbitZscoreExitForAccount(platform); + const ibitZscoreSupported = ibitZscoreExitSupported(form.strategy); + const ibitZscoreBlock = el("ibit-zscore-exit-mode-block"); + ibitZscoreBlock.hidden = !ibitZscoreSupported; + ibitZscoreExitModeSelect.replaceChildren(); + if (ibitZscoreSupported) { + const pluginsDisabled = normalizePluginMode(form.pluginMode) === "none"; + if (pluginsDisabled) form.ibitZscoreExitMode = "disabled"; + form.ibitZscoreExitMode = pluginsDisabled ? "disabled" : normalizeIbitZscoreExitMode(form.ibitZscoreExitMode); + ibitZscoreExitModeSelect.disabled = pluginsDisabled; + for (const mode of ibitZscoreExitModes) { + ibitZscoreExitModeSelect.append(new Option( + ibitZscoreExitModeLabel(mode), + mode, + false, + mode === form.ibitZscoreExitMode, + )); + } + el("ibit-zscore-exit-mode-meta").textContent = t("ibitZscoreExitModeMeta"); + } else { + ibitZscoreExitModeSelect.disabled = true; + ibitZscoreExitModeSelect.append(new Option(t("ibitZscoreExitDisabled"), "disabled")); + el("ibit-zscore-exit-mode-meta").textContent = ""; + } const incomeDefaults = incomeLayerDefaultForStrategy(form.strategy); incomeLayerModeSelect.replaceChildren(); if (incomeDefaults) { @@ -2529,7 +2707,8 @@

切换摘要

const hasValidReserve = hasValidReservePolicy(); const hasValidIncomeLayer = hasValidIncomeLayerPolicy(); const hasValidDca = hasValidDcaPolicy(); - const hasValidStrategy = hasRunnableStrategy && hasValidReserve && hasValidIncomeLayer && hasValidDca; + const hasValidIbitZscoreExit = hasValidIbitZscoreExitPolicy(); + const hasValidStrategy = hasRunnableStrategy && hasValidReserve && hasValidIncomeLayer && hasValidDca && hasValidIbitZscoreExit; const hasPendingChange = hasPrivateAccounts && hasValidStrategy && hasPendingChanges(buildInputs()); dispatch.disabled = !state.auth.allowed || loadingConfig || !hasPrivateAccounts || !hasValidStrategy || !hasPendingChange; dispatch.textContent = state.auth.allowed @@ -2545,7 +2724,9 @@

切换摘要

? (hasRunnableStrategy ? (hasValidReserve ? (hasValidIncomeLayer - ? (hasValidDca ? (hasPendingChange ? t("readyNote") : "") : t("invalidDcaNote")) + ? (hasValidDca + ? (hasValidIbitZscoreExit ? (hasPendingChange ? t("readyNote") : "") : t("invalidIbitZscoreExitNote")) + : t("invalidDcaNote")) : t("invalidIncomeLayerNote")) : t("invalidReservePolicyNote")) : t("invalidStrategyNote")) @@ -2668,6 +2849,7 @@

切换摘要

plugin_mode: item.plugin_mode ? String(item.plugin_mode) : "", dca_mode: item.dca_mode ? normalizeDcaMode(item.dca_mode) : "", dca_base_investment_usd: cleanDisplayPositiveNumber(item.dca_base_investment_usd), + ibit_zscore_exit_mode: cleanIbitZscoreExitMode(item.ibit_zscore_exit_mode), })); } return normalized; @@ -2697,6 +2879,7 @@

切换摘要

const runtimeTargetEnabled = cleanOptionalBoolean(entry?.runtime_target_enabled); const dcaMode = entry?.dca_mode ? normalizeDcaMode(entry.dca_mode) : ""; const dcaBaseInvestmentUsd = cleanDisplayPositiveNumber(entry?.dca_base_investment_usd); + const ibitZscoreExitMode = cleanIbitZscoreExitMode(entry?.ibit_zscore_exit_mode); const executionMode = normalizeExecutionMode(entry?.execution_mode, entry?.dry_run_only); if ( !profile && @@ -2708,6 +2891,7 @@

切换摘要

runtimeTargetEnabled === null && !dcaMode && !dcaBaseInvestmentUsd && + !ibitZscoreExitMode && !executionMode ) continue; normalized[platform][String(key)] = { @@ -2722,6 +2906,7 @@

切换摘要

runtime_target_enabled: runtimeTargetEnabled, dca_mode: dcaMode, dca_base_investment_usd: dcaBaseInvestmentUsd, + ibit_zscore_exit_mode: ibitZscoreExitMode, source: entry?.source ? String(entry.source) : "", }; } @@ -2783,6 +2968,7 @@

切换摘要

state.forms[state.selected].reservedCashTouched = false; state.forms[state.selected].incomeLayerTouched = false; state.forms[state.selected].dcaTouched = false; + state.forms[state.selected].ibitZscoreExitTouched = false; syncStrategyForAccount(state.selected); render(); }); @@ -2791,8 +2977,10 @@

切换摘要

state.forms[state.selected].strategy = el("strategy-select").value; state.forms[state.selected].incomeLayerTouched = false; state.forms[state.selected].dcaTouched = false; + state.forms[state.selected].ibitZscoreExitTouched = false; syncIncomeLayerForAccount(state.selected); syncDcaForAccount(state.selected); + syncIbitZscoreExitForAccount(state.selected); render(); }); @@ -2804,7 +2992,17 @@

切换摘要

}); el("plugin-mode-select").addEventListener("change", () => { - state.forms[state.selected].pluginMode = normalizePluginMode(el("plugin-mode-select").value); + const form = state.forms[state.selected]; + form.pluginMode = normalizePluginMode(el("plugin-mode-select").value); + form.ibitZscoreExitTouched = false; + syncIbitZscoreExitForAccount(state.selected); + render(); + }); + + el("ibit-zscore-exit-mode-select").addEventListener("change", () => { + const form = state.forms[state.selected]; + form.ibitZscoreExitTouched = true; + form.ibitZscoreExitMode = normalizeIbitZscoreExitMode(el("ibit-zscore-exit-mode-select").value); render(); }); diff --git a/web/strategy-switch-console/page_asset.js b/web/strategy-switch-console/page_asset.js index f3bb2be..6ce6798 100644 --- a/web/strategy-switch-console/page_asset.js +++ b/web/strategy-switch-console/page_asset.js @@ -1,2 +1,2 @@ // Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand. -export const PAGE_HTML = "\n\n\n \n \n \n QuantRuntimeSettings Strategy Switch\n \n\n\n
\n
\n

策略切换

\n

选平台、目标账号和策略,一次执行完成切换。

\n
\n
\n \n \n \n \n
\n
\n\n
\n
\n 初始化控制台\n

读取策略配置

\n

正在读取登录状态、账号配置和当前状态。

\n
\n
\n
\n\n
\n \n\n
\n
\n
\n 当前平台\n

LongBridge

\n
\n\n \n\n
\n \n\n \n\n
\n 模式\n
\n \n \n
\n
\n\n
\n \n\n \n
\n\n
\n \n\n \n\n \n
\n\n
\n \n\n \n
\n\n
\n \n\n \n\n \n
\n
\n\n
\n \n

登录后才可执行切换。

\n

\n
\n
\n\n \n
\n
\n\n \n\n\n"; +export const PAGE_HTML = "\n\n\n \n \n \n QuantRuntimeSettings Strategy Switch\n \n\n\n
\n
\n

策略切换

\n

选平台、目标账号和策略,一次执行完成切换。

\n
\n
\n \n \n \n \n
\n
\n\n
\n
\n 初始化控制台\n

读取策略配置

\n

正在读取登录状态、账号配置和当前状态。

\n
\n
\n
\n\n
\n \n\n
\n
\n
\n 当前平台\n

LongBridge

\n
\n\n \n\n
\n \n\n \n\n
\n 模式\n
\n \n \n
\n
\n\n
\n \n\n \n\n \n
\n\n
\n \n\n \n\n \n
\n\n
\n \n\n \n
\n\n
\n \n\n \n\n \n
\n
\n\n
\n \n

登录后才可执行切换。

\n

\n
\n
\n\n \n
\n
\n\n \n\n\n"; diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 839a30d..3754efb 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -52,6 +52,9 @@ const INCOME_LAYER_MAX_RATIO_VARIABLE = "INCOME_LAYER_MAX_RATIO"; const RUNTIME_TARGET_ENABLED_VARIABLE = "RUNTIME_TARGET_ENABLED"; const DCA_MODE_VARIABLE = "DCA_MODE"; const DCA_BASE_INVESTMENT_VARIABLE = "DCA_BASE_INVESTMENT_USD"; +const IBIT_ZSCORE_EXIT_MODE_VARIABLE = "IBIT_ZSCORE_EXIT_MODE"; +const IBIT_ZSCORE_EXIT_ENABLED_VARIABLE = "IBIT_ZSCORE_EXIT_ENABLED"; +const IBIT_ZSCORE_EXIT_PARKING_SYMBOL_VARIABLE = "IBIT_ZSCORE_EXIT_PARKING_SYMBOL"; const DCA_PROFILE_CONFIG = { nasdaq_sp500_smart_dca: { default_mode: "fixed", default_base_investment_usd: "1000" }, ibit_smart_dca: { default_mode: "fixed", default_base_investment_usd: "1000" }, @@ -589,6 +592,7 @@ async function resolveCurrentStrategyForAccount({ platform, option, optionsCount const serviceTargetIncomeLayerPayload = incomeLayerPayloadFromObject(serviceTarget); const serviceTargetRuntimeTargetEnabledPayload = runtimeTargetEnabledPayloadFromObject(serviceTarget); const serviceTargetDcaPayload = dcaPayloadFromObject(serviceTarget); + const serviceTargetIbitZscorePayload = ibitZscoreExitPayloadFromObject(serviceTarget); if (serviceTargetProfile) { return { strategy_profile: serviceTargetProfile, @@ -597,6 +601,7 @@ async function resolveCurrentStrategyForAccount({ platform, option, optionsCount ...serviceTargetIncomeLayerPayload, ...serviceTargetRuntimeTargetEnabledPayload, ...dcaPayloadForProfile(serviceTargetProfile, serviceTargetDcaPayload), + ...ibitZscoreExitPayloadForProfile(serviceTargetProfile, serviceTargetIbitZscorePayload), source: "CLOUD_RUN_SERVICE_TARGETS_JSON", variable_scope: "repository", }; @@ -604,13 +609,15 @@ async function resolveCurrentStrategyForAccount({ platform, option, optionsCount if ( Object.keys(serviceTargetReservedCashPayload).length || Object.keys(serviceTargetIncomeLayerPayload).length || - Object.keys(serviceTargetRuntimeTargetEnabledPayload).length + Object.keys(serviceTargetRuntimeTargetEnabledPayload).length || + Object.keys(serviceTargetIbitZscorePayload).length ) { return { ...runtimeModePayload(serviceTarget), ...serviceTargetReservedCashPayload, ...serviceTargetIncomeLayerPayload, ...serviceTargetRuntimeTargetEnabledPayload, + ...serviceTargetIbitZscorePayload, source: "CLOUD_RUN_SERVICE_TARGETS_JSON", variable_scope: "repository", }; @@ -643,12 +650,26 @@ async function resolveCurrentStrategyForAccount({ platform, option, optionsCount githubEnvironment, readVariable, }); + const ibitZscorePayloadPromise = readIbitZscoreExitVariables({ + repository, + variableScope, + githubEnvironment, + readVariable, + }); const runtimeTargetValuePromise = readVariable(repository, variableScope, githubEnvironment, "RUNTIME_TARGET_JSON"); - const [reservedCashPayload, incomeLayerPayload, runtimeTargetEnabledPayload, dcaPayload, runtimeTargetValue] = await Promise.all([ + const [ + reservedCashPayload, + incomeLayerPayload, + runtimeTargetEnabledPayload, + dcaPayload, + ibitZscorePayload, + runtimeTargetValue, + ] = await Promise.all([ reservedCashPayloadPromise, incomeLayerPayloadPromise, runtimeTargetEnabledPayloadPromise, dcaPayloadPromise, + ibitZscorePayloadPromise, runtimeTargetValuePromise, ]); const runtimeTarget = parseJsonObject(runtimeTargetValue); @@ -662,6 +683,7 @@ async function resolveCurrentStrategyForAccount({ platform, option, optionsCount ...incomeLayerPayload, ...runtimeTargetEnabledPayload, ...dcaPayloadForProfile(runtimeTargetProfile, dcaPayload), + ...ibitZscoreExitPayloadForProfile(runtimeTargetProfile, ibitZscorePayload), source: "RUNTIME_TARGET_JSON", variable_scope: variableScope, github_environment: githubEnvironment || "", @@ -678,6 +700,7 @@ async function resolveCurrentStrategyForAccount({ platform, option, optionsCount ...incomeLayerPayload, ...runtimeTargetEnabledPayload, ...dcaPayloadForProfile(profile, dcaPayload), + ...ibitZscoreExitPayloadForProfile(profile, ibitZscorePayload), source: "STRATEGY_PROFILE", variable_scope: variableScope, github_environment: githubEnvironment || "", @@ -692,17 +715,21 @@ async function resolveCurrentStrategyForAccount({ platform, option, optionsCount if ( Object.keys(reservedCashPayload).length || Object.keys(incomeLayerPayload).length || - Object.keys(runtimeTargetEnabledPayload).length + Object.keys(runtimeTargetEnabledPayload).length || + Object.keys(ibitZscorePayload).length ) { return { ...reservedCashPayload, ...incomeLayerPayload, ...runtimeTargetEnabledPayload, + ...ibitZscorePayload, source: Object.keys(reservedCashPayload).length ? "RESERVED_CASH_VARIABLES" : (Object.keys(incomeLayerPayload).length ? "INCOME_LAYER_VARIABLES" - : "RUNTIME_TARGET_ENABLED_VARIABLE"), + : (Object.keys(runtimeTargetEnabledPayload).length + ? "RUNTIME_TARGET_ENABLED_VARIABLE" + : "IBIT_ZSCORE_EXIT_VARIABLES")), variable_scope: variableScope, github_environment: githubEnvironment || "", }; @@ -863,6 +890,16 @@ function updateAccountOptionsDefaultStrategy(accountOptions, inputs) { } } } + const ibitZscoreMode = ibitZscoreExitModeFromInputs(inputs); + if (inputs.strategy_profile === "ibit_smart_dca" && ibitZscoreMode) { + if (nextOption.ibit_zscore_exit_mode !== ibitZscoreMode) { + nextOption.ibit_zscore_exit_mode = ibitZscoreMode; + optionChanged = true; + } + } else if ("ibit_zscore_exit_mode" in nextOption) { + delete nextOption.ibit_zscore_exit_mode; + optionChanged = true; + } changed = changed || optionChanged; if (!optionChanged) return option; return nextOption; @@ -895,7 +932,16 @@ function normalizeSwitchInputs(raw) { if (directDcaVariables.length) { throw new Error("use dca_mode and dca_base_investment_usd control fields instead of DCA_MODE variables"); } + const directIbitZscoreVariables = [ + IBIT_ZSCORE_EXIT_ENABLED_VARIABLE, + IBIT_ZSCORE_EXIT_MODE_VARIABLE, + IBIT_ZSCORE_EXIT_PARKING_SYMBOL_VARIABLE, + ].filter((name) => extraVariables[name] !== undefined && String(extraVariables[name] || "").trim() !== ""); + if (directIbitZscoreVariables.length) { + throw new Error("use ibit_zscore_exit_* control fields instead of IBIT_ZSCORE_EXIT variables"); + } const dcaExtraControls = dcaPayloadFromObject(extraVariables); + const ibitZscoreExtraControls = ibitZscoreExitPayloadFromObject(extraVariables); const inputs = { platform, @@ -941,6 +987,19 @@ function normalizeSwitchInputs(raw) { "dca_base_investment_usd", ); } + const rawHasIbitZscoreMode = raw.ibit_zscore_exit_mode !== undefined && + raw.ibit_zscore_exit_mode !== null && + String(raw.ibit_zscore_exit_mode).trim() !== ""; + const ibitZscoreModeValue = rawHasIbitZscoreMode + ? raw.ibit_zscore_exit_mode + : ibitZscoreExtraControls.ibit_zscore_exit_mode; + const hasIbitZscoreMode = Boolean(String(ibitZscoreModeValue || "").trim()); + if (strategyProfile !== "ibit_smart_dca" && hasIbitZscoreMode) { + throw new Error("IBIT Z-Score exit settings are only supported for ibit_smart_dca"); + } + if (strategyProfile === "ibit_smart_dca" && hasIbitZscoreMode) { + extraVariables.ibit_zscore_exit_mode = cleanIbitZscoreExitMode(ibitZscoreModeValue); + } if (Object.keys(extraVariables).length) inputs.extra_variables_json = JSON.stringify(extraVariables); return inputs; } @@ -1160,6 +1219,7 @@ function cleanAccountOption(item, platform, index) { addConfigOptional(option, "plugin_mode", item.plugin_mode, (value, field) => cleanChoice(value || "auto", ["auto", "none"], field), ); + addConfigOptional(option, "ibit_zscore_exit_mode", item.ibit_zscore_exit_mode, cleanIbitZscoreExitMode); addConfigOptional(option, "dca_mode", item.dca_mode, cleanDcaMode); addConfigOptional(option, "dca_base_investment_usd", item.dca_base_investment_usd, cleanPositiveNumber); option.supported_domains = shouldInferSupportedDomains(item.supported_domains) @@ -1277,6 +1337,21 @@ function cleanDcaMode(value, field = "dca_mode") { return cleanChoice(normalized, ["fixed", "smart"], field); } +function cleanIbitZscoreExitMode(value, field = "ibit_zscore_exit_mode") { + const mode = String(value || "").trim().toLowerCase(); + const aliases = { + off: "disabled", + none: "disabled", + false: "disabled", + disable: "disabled", + enabled: "live", + shadow: "paper", + dry_run: "paper", + "dry-run": "paper", + }; + return cleanChoice(aliases[mode] || mode, ["disabled", "paper", "live"], field); +} + function cleanBoolean(value) { if (value === true || value === "true") return true; if (value === false || value === "false" || value === "" || value === undefined || value === null) return false; @@ -1405,6 +1480,7 @@ function runtimeTargetFromServiceTargets(rawValue, platform, option) { ...incomeLayerPayloadFromObject(entry), ...runtimeTargetEnabledPayloadFromObject(entry), ...dcaPayloadFromObject(entry), + ...ibitZscoreExitPayloadFromObject(entry), }; } return null; @@ -1440,6 +1516,17 @@ async function readDcaVariables({ repository, variableScope, githubEnvironment, return dcaPayloadFromValues(modeValue, baseInvestmentValue); } +async function readIbitZscoreExitVariables({ repository, variableScope, githubEnvironment, readVariable }) { + const [enabledValue, modeValue] = await Promise.all([ + readVariable(repository, variableScope, githubEnvironment, IBIT_ZSCORE_EXIT_ENABLED_VARIABLE), + readVariable(repository, variableScope, githubEnvironment, IBIT_ZSCORE_EXIT_MODE_VARIABLE), + ]); + return ibitZscoreExitPayloadFromObject({ + [IBIT_ZSCORE_EXIT_ENABLED_VARIABLE]: enabledValue, + [IBIT_ZSCORE_EXIT_MODE_VARIABLE]: modeValue, + }); +} + function reservedCashPayloadFromObject(platform, payload) { if (!payload || Array.isArray(payload) || typeof payload !== "object") return {}; return reservedCashPayloadFromValues( @@ -1502,6 +1589,34 @@ function dcaPayloadFromObject(payload) { ); } +function ibitZscoreExitPayloadFromObject(payload) { + if (!payload || Array.isArray(payload) || typeof payload !== "object") return {}; + const mode = cleanCurrentIbitZscoreExitMode( + payload.ibit_zscore_exit_mode ?? + payload[IBIT_ZSCORE_EXIT_MODE_VARIABLE] ?? + payload.ibit_zscore_exit_mode, + payload.ibit_zscore_exit_enabled ?? payload[IBIT_ZSCORE_EXIT_ENABLED_VARIABLE], + ); + return mode ? { ibit_zscore_exit_mode: mode } : {}; +} + +function cleanCurrentIbitZscoreExitMode(modeValue, enabledValue) { + const enabled = cleanOptionalBoolean(enabledValue); + if (enabled === false) return "disabled"; + const text = String(modeValue || "").trim(); + if (!text) return enabled === true ? "live" : ""; + try { + return cleanIbitZscoreExitMode(text); + } catch { + return ""; + } +} + +function ibitZscoreExitModeFromInputs(inputs) { + const payload = inputs?.extra_variables_json ? JSON.parse(inputs.extra_variables_json) : {}; + return ibitZscoreExitPayloadFromObject(payload).ibit_zscore_exit_mode || ""; +} + function dcaPayloadFromValues(modeValue, baseInvestmentValue) { const result = {}; const mode = cleanCurrentDcaMode(modeValue); @@ -1523,6 +1638,10 @@ function dcaPayloadForProfile(profile, payload) { return isDcaProfile(profile) ? payload : {}; } +function ibitZscoreExitPayloadForProfile(profile, payload) { + return cleanCurrentStrategy(profile) === "ibit_smart_dca" ? payload : {}; +} + function runtimeModePayload(runtimeTarget) { const executionMode = normalizeRuntimeExecutionMode(runtimeTarget?.execution_mode, runtimeTarget?.dry_run_only); const payload = {};