From f060a418e11007ee108f551362305fea13ec50f4 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:12:20 +0800 Subject: [PATCH] Restrict DCA strategy profiles to Firstrade in switch tooling and console. Co-authored-by: Cursor --- scripts/build_runtime_switch.py | 11 ++++ tests/strategy_switch_worker_validation.mjs | 41 ++++++++++--- tests/test_runtime_settings.py | 68 +++++++++++++-------- web/strategy-switch-console/index.html | 19 ++++-- web/strategy-switch-console/page_asset.js | 2 +- web/strategy-switch-console/worker.js | 11 ++++ 6 files changed, 113 insertions(+), 39 deletions(-) diff --git a/scripts/build_runtime_switch.py b/scripts/build_runtime_switch.py index cb3e2de..d04f33d 100644 --- a/scripts/build_runtime_switch.py +++ b/scripts/build_runtime_switch.py @@ -177,6 +177,7 @@ "ibit_smart_dca", } ) +DCA_SUPPORTED_PLATFORMS = frozenset({"firstrade"}) DCA_MODES = frozenset({"fixed", "smart"}) DCA_MODE_VARIABLE = "DCA_MODE" DCA_BASE_INVESTMENT_VARIABLE = "DCA_BASE_INVESTMENT_USD" @@ -329,6 +330,15 @@ def _normalize_dca_mode(value: str) -> str: return mode +def _validate_dca_platform(platform: str, strategy_profile: str) -> None: + profile = str(strategy_profile or "").strip().lower() + if profile in DCA_PROFILES and platform not in DCA_SUPPORTED_PLATFORMS: + raise ValueError( + "DCA strategy profiles are only supported on firstrade; " + f"got platform={platform!r}, strategy_profile={profile!r}" + ) + + 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): @@ -810,6 +820,7 @@ def _build_runtime_target(args: argparse.Namespace) -> dict[str, Any]: account_selector = _split_csv(args.account_selector) or _account_selector_default(platform, account_scope) service_name = args.service_name.strip() if args.service_name else _default_service_name(platform, target_name) strategy_profile = args.strategy_profile.strip().lower() + _validate_dca_platform(platform, strategy_profile) runtime_target: dict[str, Any] = { "platform_id": platform, "strategy_profile": strategy_profile, diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index e33cdf8..1985dda 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -541,8 +541,8 @@ const normalizedPluginInputs = __test.normalizeSwitchInputs({ }); assert.equal(normalizedPluginInputs.plugin_mode, "none"); const normalizedDcaInputs = __test.normalizeSwitchInputs({ - platform: "ibkr", - target_name: "ibkr-primary", + platform: "firstrade", + target_name: "default", strategy_profile: "nasdaq_sp500_smart_dca", execution_mode: "live", plugin_mode: "auto", @@ -555,9 +555,21 @@ assert.deepEqual(JSON.parse(normalizedDcaInputs.extra_variables_json), { dca_mode: "smart", dca_base_investment_usd: "500", }); +assert.throws( + () => __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", + }), + /DCA strategy profiles are only supported on firstrade/, +); const normalizedDcaJsonInputs = __test.normalizeSwitchInputs({ - platform: "ibkr", - target_name: "ibkr-primary", + platform: "firstrade", + target_name: "default", strategy_profile: "nasdaq_sp500_smart_dca", execution_mode: "live", plugin_mode: "auto", @@ -571,8 +583,8 @@ assert.deepEqual(JSON.parse(normalizedDcaJsonInputs.extra_variables_json), { dca_base_investment_usd: "500", }); const normalizedIbitZscoreInputs = __test.normalizeSwitchInputs({ - platform: "ibkr", - target_name: "ibit-primary", + platform: "firstrade", + target_name: "default", strategy_profile: "ibit_smart_dca", execution_mode: "live", plugin_mode: "auto", @@ -581,6 +593,17 @@ const normalizedIbitZscoreInputs = __test.normalizeSwitchInputs({ assert.deepEqual(JSON.parse(normalizedIbitZscoreInputs.extra_variables_json), { ibit_zscore_exit_mode: "live", }); +assert.throws( + () => __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibit-primary", + strategy_profile: "ibit_smart_dca", + execution_mode: "live", + plugin_mode: "auto", + ibit_zscore_exit_mode: "live", + }), + /DCA strategy profiles are only supported on firstrade/, +); assert.throws( () => __test.normalizeSwitchInputs({ platform: "ibkr", @@ -588,7 +611,7 @@ assert.throws( strategy_profile: "nasdaq_sp500_smart_dca", ibit_zscore_exit_mode: "live", }), - /IBIT Z-Score exit settings/, + /DCA strategy profiles are only supported on firstrade/, ); assert.throws( () => __test.normalizeSwitchInputs({ @@ -617,8 +640,8 @@ assert.throws( ); assert.throws( () => __test.normalizeSwitchInputs({ - platform: "ibkr", - target_name: "ibkr-primary", + platform: "firstrade", + target_name: "default", strategy_profile: "nasdaq_sp500_smart_dca", dca_mode: "smart", dca_base_investment_usd: "0", diff --git a/tests/test_runtime_settings.py b/tests/test_runtime_settings.py index c47c400..1a86fa2 100644 --- a/tests/test_runtime_settings.py +++ b/tests/test_runtime_settings.py @@ -542,7 +542,7 @@ def test_build_switch_target_uses_dca_monthly_scheduler_window(self): args = parser.parse_args( [ "--platform", - "ibkr", + "firstrade", "--target-name", "dca", "--strategy-profile", @@ -569,7 +569,7 @@ def test_build_switch_target_uses_daily_scheduler_when_ibit_zscore_plugin_is_aut args = parser.parse_args( [ "--platform", - "ibkr", + "firstrade", "--target-name", "ibit", "--strategy-profile", @@ -579,7 +579,7 @@ def test_build_switch_target_uses_daily_scheduler_when_ibit_zscore_plugin_is_aut 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"]) + plugin_payload = json.loads(assignments["FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON"]) self.assertEqual( target["runtime_target"]["scheduler"], @@ -601,7 +601,7 @@ def test_build_switch_target_sets_ibit_zscore_exit_runtime_controls(self): args = parser.parse_args( [ "--platform", - "ibkr", + "firstrade", "--target-name", "ibit", "--strategy-profile", @@ -625,7 +625,7 @@ def test_build_switch_target_disables_ibit_zscore_exit_when_plugins_are_disabled args = parser.parse_args( [ "--platform", - "ibkr", + "firstrade", "--target-name", "ibit", "--strategy-profile", @@ -647,7 +647,7 @@ def test_build_switch_target_rejects_ibit_zscore_controls_for_other_profiles(sel args = parser.parse_args( [ "--platform", - "ibkr", + "firstrade", "--target-name", "dca", "--strategy-profile", @@ -665,7 +665,7 @@ def test_build_switch_target_sets_dca_settings_for_dca_profile(self): args = parser.parse_args( [ "--platform", - "ibkr", + "firstrade", "--target-name", "dca", "--strategy-profile", @@ -690,7 +690,7 @@ def test_build_switch_target_accepts_dca_control_fields_from_extra_variables_jso args = parser.parse_args( [ "--platform", - "ibkr", + "firstrade", "--target-name", "dca", "--strategy-profile", @@ -728,7 +728,7 @@ def test_build_switch_target_rejects_dca_settings_for_non_dca_profile(self): 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): + def test_build_switch_target_rejects_dca_profile_on_non_firstrade_platform(self): parser = build_runtime_switch.build_parser() args = parser.parse_args( [ @@ -738,6 +738,24 @@ def test_build_switch_target_rejects_direct_dca_extra_variables(self): "dca", "--strategy-profile", "nasdaq_sp500_smart_dca", + "--plugin-mode", + "none", + ] + ) + + with self.assertRaisesRegex(ValueError, "DCA strategy profiles are only supported on firstrade"): + 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", + "firstrade", + "--target-name", + "dca", + "--strategy-profile", + "nasdaq_sp500_smart_dca", "--extra-variables-json", '{"DCA_MODE":"smart"}', ] @@ -799,7 +817,7 @@ def test_build_switch_target_can_disable_option_overlay(self): "--target-name", "live", "--strategy-profile", - "nasdaq_sp500_smart_dca", + "tqqq_growth_income", "--option-overlay-mode", "disabled", ] @@ -838,9 +856,9 @@ def test_build_switch_target_rejects_enabled_option_overlay_without_profile_defa args = parser.parse_args( [ "--platform", - "schwab", + "firstrade", "--target-name", - "live", + "default", "--strategy-profile", "nasdaq_sp500_smart_dca", "--option-overlay-mode", @@ -873,18 +891,18 @@ def test_build_switch_target_preserves_dca_fields_in_service_targets_when_omitte existing = { "targets": [ { - "service": "interactive-brokers-demo-ibkr-dca-service", - "ACCOUNT_GROUP": "demo-ibkr-dca", + "service": "firstrade-quant-service", + "ACCOUNT_GROUP": "firstrade", "DCA_MODE": "smart", "DCA_BASE_INVESTMENT_USD": "500", "runtime_target": { - "platform_id": "ibkr", + "platform_id": "firstrade", "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", + "deployment_selector": "firstrade", + "account_selector": ["firstrade"], + "account_scope": "US", + "service_name": "firstrade-quant-service", "execution_mode": "live", }, }, @@ -897,15 +915,15 @@ def test_build_switch_target_preserves_dca_fields_in_service_targets_when_omitte args = parser.parse_args( [ "--platform", - "ibkr", + "firstrade", "--target-name", - "demo-ibkr-dca", + "default", "--strategy-profile", "nasdaq_sp500_smart_dca", "--account-selector", - "DEMO_IBKR_DCA", + "firstrade", "--service-name", - "interactive-brokers-demo-ibkr-dca-service", + "firstrade-quant-service", "--plugin-mode", "none", "--existing-service-targets-json-file", @@ -954,7 +972,7 @@ def test_build_switch_target_preserves_market_signal_fields_in_service_targets_w "--target-name", "demo-ibkr-dca", "--strategy-profile", - "ibit_smart_dca", + "tqqq_growth_income", "--account-selector", "DEMO_IBKR_DCA", "--service-name", @@ -970,7 +988,7 @@ def test_build_switch_target_preserves_market_signal_fields_in_service_targets_w 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"], "ibit_smart_dca") + self.assertEqual(selected["runtime_target"]["strategy_profile"], "tqqq_growth_income") self.assertEqual(selected["IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI"], "gs://signals/index.json") self.assertEqual(selected["IBKR_MARKET_SIGNAL_REQUIRED"], "true") self.assertEqual(selected["IBKR_MARKET_SIGNAL_FALLBACK_MODE"], "last_valid") diff --git a/web/strategy-switch-console/index.html b/web/strategy-switch-console/index.html index 2eedd4f..5ead7a6 100644 --- a/web/strategy-switch-console/index.html +++ b/web/strategy-switch-console/index.html @@ -1711,6 +1711,7 @@

切换摘要

dcaModeMeta: "仅定投策略可配置。", dcaDefaultMeta: "默认:{mode},基准金额 {amount}。", dcaNotSupported: "该策略不是定投策略", + dcaPlatformNotSupported: "定投策略仅支持 Firstrade", currentDca: "当前定投设置", pendingDca: "待提交定投设置", dcaText: "{mode},基准金额 {amount}", @@ -1876,6 +1877,7 @@

切换摘要

dcaModeMeta: "Only DCA strategies can use this.", dcaDefaultMeta: "Default: {mode}, base amount {amount}.", dcaNotSupported: "This is not a DCA strategy", + dcaPlatformNotSupported: "DCA strategies are Firstrade-only", currentDca: "Current DCA settings", pendingDca: "Pending DCA settings", dcaText: "{mode}, base amount {amount}", @@ -2268,10 +2270,15 @@

切换摘要

return supportedDomainsForAccount(platform, account).map(domainLabel).join(" / "); } + function platformSupportsDca(platform = state.selected) { + return platform === "firstrade"; + } + function strategyAllowedForAccount(platform, account, profile) { const cleanProfile = cleanStrategyProfile(profile); const catalogEntry = strategyCatalog[cleanProfile]; if (!catalogEntry || catalogEntry.runtime_enabled !== true) return false; + if (dcaConfigForStrategy(cleanProfile) && !platformSupportsDca(platform)) return false; return supportedDomainsForAccount(platform, account).includes(catalogEntry.domain); } @@ -2898,7 +2905,7 @@

切换摘要

function hasValidDcaPolicy(platform = state.selected) { const form = state.forms[platform]; - if (!dcaSupported(form?.strategy)) return true; + if (!dcaSupported(form?.strategy) || !platformSupportsDca(platform)) return true; return Boolean(dcaModes.includes(normalizeDcaMode(form?.dcaMode)) && cleanDisplayPositiveNumber(form?.dcaBaseInvestmentUsd)); } @@ -2996,7 +3003,7 @@

切换摘要

} function dcaOverrideForForm(form) { - if (!dcaSupported(form?.strategy)) return null; + if (!dcaSupported(form?.strategy) || !platformSupportsDca(state.selected)) return null; const mode = normalizeDcaMode(form?.dcaMode); const baseInvestmentUsd = cleanDisplayPositiveNumber(form?.dcaBaseInvestmentUsd); if (!baseInvestmentUsd) return null; @@ -3843,7 +3850,8 @@

切换摘要

const dcaDefaults = dcaConfigForStrategy(form.strategy); dcaModeSelect.replaceChildren(); - if (dcaDefaults) { + const dcaAllowed = Boolean(dcaDefaults) && platformSupportsDca(platform); + if (dcaAllowed) { dcaModeSelect.disabled = false; for (const mode of dcaModes) { dcaModeSelect.append(new Option(dcaModeLabel(mode), mode, false, mode === normalizeDcaMode(form.dcaMode))); @@ -3859,7 +3867,10 @@

切换摘要

el("dca-base-meta").textContent = t("dcaModeMeta"); } else { dcaModeSelect.disabled = true; - dcaModeSelect.append(new Option(t("dcaNotSupported"), "fixed")); + dcaModeSelect.append(new Option( + dcaDefaults && !platformSupportsDca(platform) ? t("dcaPlatformNotSupported") : t("dcaNotSupported"), + "fixed", + )); dcaBaseInvestmentUsdInput.disabled = true; dcaBaseInvestmentUsdInput.value = ""; el("dca-mode-meta").textContent = t("dcaModeMeta"); diff --git a/web/strategy-switch-console/page_asset.js b/web/strategy-switch-console/page_asset.js index 6ea24f8..1c5085f 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 当前平台\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
\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 当前平台\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
\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 614666a..a8260f6 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -101,6 +101,7 @@ 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" }, }; +const DCA_SUPPORTED_PLATFORMS = new Set(["firstrade"]); const SECURITY_HEADERS = { "Content-Security-Policy": [ "default-src 'self'", @@ -1054,6 +1055,7 @@ function normalizeSwitchInputs(raw) { const platform = cleanChoice(raw.platform, SUPPORTED_PLATFORMS, "platform"); const targetName = cleanSlug(raw.target_name, "target_name"); const strategyProfile = cleanSlug(raw.strategy_profile, "strategy_profile").toLowerCase(); + assertDcaPlatform(platform, strategyProfile); const executionMode = cleanChoice(raw.execution_mode || "live", ["live", "paper"], "execution_mode"); if (platform === "qmt" && executionMode === "live") { throw new Error("QMT platform does not support live execution yet; use paper/dry_run mode"); @@ -1202,6 +1204,7 @@ function assertStrategyAllowedForAccount(inputs, accountOption, strategyProfiles if (inputs.option_overlay_mode === "enabled" && strategy.option_overlay_enabled !== true) { throw new Error(`strategy ${inputs.strategy_profile} does not define an option overlay`); } + assertDcaPlatform(inputs.platform, inputs.strategy_profile); } function resolvedVariableScope(value, inputs) { @@ -1627,6 +1630,14 @@ function isDcaProfile(profile) { return Boolean(DCA_PROFILE_CONFIG[cleanCurrentStrategy(profile)]); } +function assertDcaPlatform(platform, strategyProfile) { + if (isDcaProfile(strategyProfile) && !DCA_SUPPORTED_PLATFORMS.has(platform)) { + throw new Error( + `DCA strategy profiles are only supported on firstrade; got platform=${platform}, strategy_profile=${strategyProfile}`, + ); + } +} + function cleanDcaMode(value, field = "dca_mode") { const mode = String(value || "").trim().toLowerCase(); const aliases = {