diff --git a/.github/workflows/manual-strategy-switch.yml b/.github/workflows/manual-strategy-switch.yml index 3391cb9..b31a501 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 variables. DCA profiles may include dca_mode and dca_base_investment_usd control fields." + description: "Optional JSON object of non-secret extra variables. DCA profiles may include dca_mode and dca_base_investment_usd control fields. Research-only option overlays are rejected." required: false type: string reserved_cash_ratio: @@ -90,14 +90,6 @@ on: description: "Optional income layer maximum allocation ratio." required: false type: string - income_threshold_usd: - description: "Optional TQQQ income threshold override." - required: false - type: string - qqqi_income_ratio: - description: "Optional TQQQ QQQI income ratio override." - required: false - type: string service_targets_mode: description: "auto patches IBKR CLOUD_RUN_SERVICE_TARGETS_JSON when it exists." required: true @@ -157,8 +149,6 @@ jobs: MIN_RESERVED_CASH_USD: ${{ inputs.min_reserved_cash_usd }} INCOME_LAYER_START_USD: ${{ inputs.income_layer_start_usd }} INCOME_LAYER_MAX_RATIO: ${{ inputs.income_layer_max_ratio }} - INCOME_THRESHOLD_USD: ${{ inputs.income_threshold_usd }} - QQQI_INCOME_RATIO: ${{ inputs.qqqi_income_ratio }} SERVICE_TARGETS_MODE: ${{ inputs.service_targets_mode }} APPLY_SWITCH: ${{ inputs.apply }} TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }} @@ -297,12 +287,6 @@ jobs: if [ -n "${INCOME_LAYER_MAX_RATIO:-}" ]; then args+=(--income-layer-max-ratio "${INCOME_LAYER_MAX_RATIO}") fi - if [ -n "${INCOME_THRESHOLD_USD:-}" ]; then - args+=(--income-threshold-usd "${INCOME_THRESHOLD_USD}") - fi - if [ -n "${QQQI_INCOME_RATIO:-}" ]; then - args+=(--qqqi-income-ratio "${QQQI_INCOME_RATIO}") - 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 b6e62c7..8d60a2b 100644 --- a/scripts/build_runtime_switch.py +++ b/scripts/build_runtime_switch.py @@ -94,6 +94,30 @@ "INCOME_LAYER_START_USD", "INCOME_LAYER_MAX_RATIO", ) +LEGACY_INCOME_LAYER_VARIABLES = ( + "INCOME_THRESHOLD_USD", + "QQQI_INCOME_RATIO", + "INCOME_LAYER_QQQI_WEIGHT", + "INCOME_LAYER_SPYI_WEIGHT", +) +LEGACY_INCOME_LAYER_CONTROL_FIELDS = ( + "income_threshold_usd", + "qqqi_income_ratio", + "income_layer_qqqi_weight", + "income_layer_spyi_weight", +) +OPTION_OVERLAY_CONTROL_FIELDS = ( + "option_overlay_enabled", + "option_growth_overlay_enabled", + "option_growth_overlay_recipe", + "option_growth_overlay_start_usd", + "option_growth_overlay_nav_budget_ratio", + "option_income_overlay_enabled", + "option_income_overlay_recipe", + "option_income_overlay_start_usd", + "option_income_overlay_nav_risk_ratio", +) +OPTION_OVERLAY_VARIABLES = tuple(field.upper() for field in OPTION_OVERLAY_CONTROL_FIELDS) RUNTIME_TARGET_VARIABLES = ( "RUNTIME_TARGET_ENABLED", ) @@ -380,6 +404,25 @@ def _reject_direct_ibit_zscore_exit_extra_variables(extra_variables: dict[str, A ) +def _reject_research_only_extra_variables(extra_variables: dict[str, Any]) -> None: + blocked = [ + name + for name in ( + *OPTION_OVERLAY_CONTROL_FIELDS, + *OPTION_OVERLAY_VARIABLES, + *LEGACY_INCOME_LAYER_CONTROL_FIELDS, + *LEGACY_INCOME_LAYER_VARIABLES, + ) + if name in extra_variables + ] + if blocked: + names = ", ".join(blocked) + raise ValueError( + "direct option overlay settings and legacy income controls are research-only " + f"and are not supported by live strategy switch settings: {names}" + ) + + def _ibit_zscore_exit_extra_variables( args: argparse.Namespace, strategy_profile: str, @@ -699,6 +742,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: 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) + _reject_research_only_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"]) @@ -710,10 +754,6 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: extra_variables["INCOME_LAYER_START_USD"] = args.income_layer_start_usd if args.income_layer_max_ratio: extra_variables["INCOME_LAYER_MAX_RATIO"] = args.income_layer_max_ratio - if args.income_threshold_usd: - 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"], dca_controls)) extra_variables.update( _ibit_zscore_exit_extra_variables( @@ -786,8 +826,6 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--min-reserved-cash-usd", default="") parser.add_argument("--income-layer-start-usd", default="") 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("--ibit-zscore-exit-mode", choices=("disabled", "paper", "live"), default="") diff --git a/scripts/runtime_settings.py b/scripts/runtime_settings.py index d2d5e5c..d66b186 100644 --- a/scripts/runtime_settings.py +++ b/scripts/runtime_settings.py @@ -47,6 +47,28 @@ SCHEDULER_FIELDS = frozenset({"timezone", "main_time", "probe_time", "precheck_time"}) GENERATED_VARIABLES = {"RUNTIME_TARGET_JSON", "STRATEGY_PROFILE"} SECRET_MARKERS = ("PASSWORD", "PRIVATE_KEY", "TOKEN", "API_KEY", "ACCESS_KEY", "CLIENT_SECRET", "SECRET") +LEGACY_INCOME_LAYER_VARIABLES = frozenset( + { + "INCOME_THRESHOLD_USD", + "QQQI_INCOME_RATIO", + "INCOME_LAYER_QQQI_WEIGHT", + "INCOME_LAYER_SPYI_WEIGHT", + } +) +OPTION_OVERLAY_VARIABLES = frozenset( + { + "OPTION_OVERLAY_ENABLED", + "OPTION_GROWTH_OVERLAY_ENABLED", + "OPTION_GROWTH_OVERLAY_RECIPE", + "OPTION_GROWTH_OVERLAY_START_USD", + "OPTION_GROWTH_OVERLAY_NAV_BUDGET_RATIO", + "OPTION_INCOME_OVERLAY_ENABLED", + "OPTION_INCOME_OVERLAY_RECIPE", + "OPTION_INCOME_OVERLAY_START_USD", + "OPTION_INCOME_OVERLAY_NAV_RISK_RATIO", + } +) +RESEARCH_ONLY_EXTRA_VARIABLES = LEGACY_INCOME_LAYER_VARIABLES | OPTION_OVERLAY_VARIABLES PLATFORM_DRY_RUN_VARIABLES = { "schwab": "SCHWAB_DRY_RUN_ONLY", "longbridge": "LONGBRIDGE_DRY_RUN_ONLY", @@ -421,6 +443,10 @@ def validate_extra_variables(target: dict[str, Any], errors: list[str]) -> None: for name, value in extra_variables.items(): if name in generated_names: errors.append(f"extra_variables.{name} duplicates a generated variable") + if name in RESEARCH_ONLY_EXTRA_VARIABLES: + errors.append( + f"extra_variables.{name} is research-only and must not be stored in live switch settings" + ) if is_secret_variable_name(name): errors.append(f"extra_variables.{name} looks like a secret and must not be stored here") if isinstance(value, str) and "\n" in value: diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 84ec822..3b0a42a 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -32,6 +32,10 @@ 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("fallbackIncomeLayerDefaults")); +assert.ok(indexHtml.includes("incomeLayerDefaultsFromProfileItem")); +assert.equal(indexHtml.includes('id="option-overlay'), false); +assert.equal(indexHtml.includes("option_growth_overlay"), false); assert.ok(indexHtml.includes('id="dca-mode-select"')); assert.ok(indexHtml.includes('id="dca-base-investment-usd-input"')); assert.ok(indexHtml.includes('dcaMode: "定投模式"')); @@ -245,6 +249,18 @@ const strategyProfiles = __test.normalizeStrategyProfilesPayload( label_zh: "TQQQ 增长收益", domain: "us_equity", runtime_enabled: true, + income_layer_enabled: true, + income_layer_start_usd: "250000", + income_layer_max_ratio: "0.55", + income_layer_allocations: { SCHD: 0.3, DGRO: 0.2, SGOV: 0.4, SPYI: 0.08, QQQI: 0.02 }, + option_overlay_enabled: true, + option_overlay_live_gate: "promotion_required", + option_overlay_live_status: "research_only", + option_growth_overlay_enabled: true, + option_growth_overlay_recipe: "tqqq_leaps_growth_v1", + option_growth_overlay_start_usd: "250000", + option_growth_overlay_nav_budget_ratio: "0.03", + option_income_overlay_enabled: false, }, { profile: "hk_low_vol_dividend_quality_snapshot", @@ -264,6 +280,24 @@ const strategyProfiles = __test.normalizeStrategyProfilesPayload( ); assert.equal(strategyProfiles[0].label_en, "TQQQ Growth Income"); assert.equal(strategyProfiles[0].label_zh, "TQQQ 增长收益"); +assert.equal(strategyProfiles[0].income_layer_enabled, true); +assert.equal(strategyProfiles[0].income_layer_start_usd, "250000"); +assert.equal(strategyProfiles[0].income_layer_max_ratio, "0.55"); +assert.deepEqual(strategyProfiles[0].income_layer_allocations, { + SCHD: 0.3, + DGRO: 0.2, + SGOV: 0.4, + SPYI: 0.08, + QQQI: 0.02, +}); +assert.equal(strategyProfiles[0].option_overlay_enabled, true); +assert.equal(strategyProfiles[0].option_overlay_live_gate, "promotion_required"); +assert.equal(strategyProfiles[0].option_overlay_live_status, "research_only"); +assert.equal(strategyProfiles[0].option_growth_overlay_enabled, true); +assert.equal(strategyProfiles[0].option_growth_overlay_recipe, "tqqq_leaps_growth_v1"); +assert.equal(strategyProfiles[0].option_growth_overlay_start_usd, "250000"); +assert.equal(strategyProfiles[0].option_growth_overlay_nav_budget_ratio, "0.03"); +assert.equal(strategyProfiles[0].option_income_overlay_enabled, false); 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"); @@ -506,6 +540,24 @@ assert.throws( }), /control fields/, ); +assert.throws( + () => __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibkr-primary", + strategy_profile: "tqqq_growth_income", + extra_variables_json: JSON.stringify({ option_growth_overlay_enabled: "true" }), + }), + /research-only/, +); +assert.throws( + () => __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibkr-primary", + strategy_profile: "tqqq_growth_income", + extra_variables_json: JSON.stringify({ INCOME_THRESHOLD_USD: "250000" }), + }), + /research-only/, +); const normalizedReserveClearInputs = __test.normalizeSwitchInputs({ platform: "ibkr", target_name: "ibkr-primary", diff --git a/tests/test_runtime_settings.py b/tests/test_runtime_settings.py index 39cdca0..dfb86e9 100644 --- a/tests/test_runtime_settings.py +++ b/tests/test_runtime_settings.py @@ -44,6 +44,8 @@ def test_manual_strategy_switch_workflow_stays_within_dispatch_input_limit(self) self.assertLessEqual(len(input_names), 25) self.assertNotIn("dca_mode", input_names) self.assertNotIn("dca_base_investment_usd", input_names) + self.assertNotIn("income_threshold_usd", input_names) + self.assertNotIn("qqqi_income_ratio", input_names) def load_target(self, relative_path: str): path = ROOT / relative_path @@ -203,6 +205,24 @@ def test_generated_variables_cannot_be_overridden(self): runtime_settings.validate_target(target), ) + def test_research_only_option_overlay_variables_are_rejected(self): + _, target = self.load_target("examples/targets/schwab/live.example.json") + target["extra_variables"] = {"OPTION_GROWTH_OVERLAY_ENABLED": "true"} + + self.assertIn( + "extra_variables.OPTION_GROWTH_OVERLAY_ENABLED is research-only and must not be stored in live switch settings", + runtime_settings.validate_target(target), + ) + + def test_legacy_income_layer_variables_are_rejected(self): + _, target = self.load_target("examples/targets/schwab/live.example.json") + target["extra_variables"] = {"INCOME_THRESHOLD_USD": "250000"} + + self.assertIn( + "extra_variables.INCOME_THRESHOLD_USD is research-only and must not be stored in live switch settings", + runtime_settings.validate_target(target), + ) + def test_extra_variables_reject_secret_values_but_allow_secret_pointers(self): _, target = self.load_target("examples/targets/schwab/live.example.json") target["extra_variables"] = { @@ -596,6 +616,42 @@ def test_build_switch_target_rejects_direct_dca_extra_variables(self): with self.assertRaisesRegex(ValueError, "control fields"): build_runtime_switch.build_switch_target(args) + def test_build_switch_target_rejects_research_only_option_overlay_extra_variables(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "live", + "--strategy-profile", + "tqqq_growth_income", + "--extra-variables-json", + '{"option_growth_overlay_enabled":"true"}', + ] + ) + + with self.assertRaisesRegex(ValueError, "research-only"): + build_runtime_switch.build_switch_target(args) + + def test_build_switch_target_rejects_legacy_income_extra_variables(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "live", + "--strategy-profile", + "tqqq_growth_income", + "--extra-variables-json", + '{"INCOME_THRESHOLD_USD":"250000"}', + ] + ) + + with self.assertRaisesRegex(ValueError, "legacy income"): + build_runtime_switch.build_switch_target(args) + def test_build_switch_target_preserves_dca_fields_in_service_targets_when_omitted(self): existing = { "targets": [ diff --git a/web/strategy-switch-console/README.md b/web/strategy-switch-console/README.md index a4b170c..f0d31e9 100644 --- a/web/strategy-switch-console/README.md +++ b/web/strategy-switch-console/README.md @@ -120,6 +120,8 @@ For signed-in users, `/api/config` also reads the target repositories' current G The switch form also accepts optional reserved-cash overrides: minimum reserved cash in the selected account currency and reserved-cash ratio. Set `cash_currency` to `USD` or `HKD` in account config when the account has a fixed cash currency; otherwise the page infers HKD for HK-equity strategy selections and USD for US-equity selections. Keeping the current policy leaves existing platform variables unchanged; when no explicit platform variables are configured, the platform source default is no extra reserve (`0` in the account currency and `0%`). When set, the Worker passes the values to `manual-strategy-switch.yml`, which writes the platform-specific variables such as `IBKR_MIN_RESERVED_CASH_USD` and `IBKR_RESERVED_CASH_RATIO`. +Income-layer controls are sourced from `strategy-profiles.example.json` metadata for live-validated US equity strategies. The switch form can keep the current setting, enable the income layer with the profile default start amount and cap, or disable it. The strategy profile catalog can also carry default option-overlay metadata such as `option_overlay_enabled`, the default recipe, start amount, and budget; those values describe the strategy repo defaults and promotion-gate status. Manual switch requests still cannot override direct option overlay or LEAPS fields through `extra_variables_json`; the Worker, build script, and target validation reject those direct overrides until separate evidence promotes a recipe. + Successful strategy switches also sync the selected account's `default_strategy_profile` back to the KV `account_options` key. The web endpoint does this immediately after dispatching the workflow, and the manual GitHub workflow calls the Worker's internal sync endpoint after applying platform variables when the `runtime-strategy-switch` environment variable `STRATEGY_SWITCH_CONSOLE_URL` is set. For that workflow callback, set the GitHub environment secret `STRATEGY_SWITCH_SYNC_TOKEN` to the same value as the Worker secret with that name. ## Strategy Profile Alignment diff --git a/web/strategy-switch-console/README.zh-CN.md b/web/strategy-switch-console/README.zh-CN.md index 9955433..b724a52 100644 --- a/web/strategy-switch-console/README.zh-CN.md +++ b/web/strategy-switch-console/README.zh-CN.md @@ -127,6 +127,8 @@ Worker 会校验 dispatch 参数必须匹配这里的某个账号项,也会校 切换表单也支持可选的预留现金覆盖项:所选账号币种下的最小预留现金和预留现金比例。如果账号现金币种固定,可以在账号配置里把 `cash_currency` 设为 `USD` 或 `HKD`;否则页面会按所选策略推断,港股策略显示 HKD,美股策略显示 USD。沿用当前策略会保留平台现有变量;如果平台没有显式配置预留现金变量,源码默认是不额外预留(账号币种 `0`、比例 `0%`)。填写后,Worker 会把它们传给 `manual-strategy-switch.yml`,由 workflow 写入平台对应变量,例如 `IBKR_MIN_RESERVED_CASH_USD` 和 `IBKR_RESERVED_CASH_RATIO`。 +收入层控件来自 `strategy-profiles.example.json` 里的 live 验证策略元数据。切换页可以沿用当前配置、按 profile 默认起始金额和最高比例开启收入层,或关闭收入层。策略 profile 目录也可以携带默认期权层元数据,例如 `option_overlay_enabled`、默认 recipe、起始金额和预算;这些值只表达策略仓库的默认配置和 promotion gate 状态。手工切换请求仍不能通过 `extra_variables_json` 覆盖直接期权 overlay / LEAPS 字段,在有单独证据晋级前,Worker、构建脚本和 target 校验都会拒绝这些直接覆盖项。 + 策略切换成功后也会把当前账号的 `default_strategy_profile` 同步回 KV 的 `account_options` key。网页接口会在触发 workflow 成功后立即同步;如果 `runtime-strategy-switch` 环境变量里配置了 `STRATEGY_SWITCH_CONSOLE_URL`,手动 GitHub workflow 在写入平台变量后也会回调 Worker 内部接口同步。这个 workflow 回调需要 GitHub 环境 secret `STRATEGY_SWITCH_SYNC_TOKEN`,值要和 Worker 里同名 secret 保持一致。 ## 策略 Profile 对齐规范 diff --git a/web/strategy-switch-console/index.html b/web/strategy-switch-console/index.html index 0b2e244..be938c2 100644 --- a/web/strategy-switch-console/index.html +++ b/web/strategy-switch-console/index.html @@ -1439,12 +1439,12 @@

切换摘要

}; const defaultStrategyProfiles = [ - { profile: "tqqq_growth_income", label: "TQQQ Growth Income", label_en: "TQQQ Growth Income", label_zh: "TQQQ 增长收益", domain: "us_equity", runtime_enabled: true }, - { profile: "soxl_soxx_trend_income", label: "SOXL/SOXX Semiconductor Trend Income", label_en: "SOXL/SOXX Semiconductor Trend Income", label_zh: "SOXL/SOXX 半导体趋势收益", domain: "us_equity", runtime_enabled: true }, + { profile: "tqqq_growth_income", label: "TQQQ Growth Income", label_en: "TQQQ Growth Income", label_zh: "TQQQ 增长收益", domain: "us_equity", runtime_enabled: true, income_layer_enabled: true, income_layer_start_usd: "250000", income_layer_max_ratio: "0.55", income_layer_allocations: { SCHD: 0.30, DGRO: 0.20, SGOV: 0.40, SPYI: 0.08, QQQI: 0.02 } }, + { profile: "soxl_soxx_trend_income", label: "SOXL/SOXX Semiconductor Trend Income", label_en: "SOXL/SOXX Semiconductor Trend Income", label_zh: "SOXL/SOXX 半导体趋势收益", domain: "us_equity", runtime_enabled: true, income_layer_enabled: true, income_layer_start_usd: "150000", income_layer_max_ratio: "0.95", income_layer_allocations: { SCHD: 0.15, DGRO: 0.10, SGOV: 0.70, SPYI: 0.04, QQQI: 0.01 } }, { profile: "nasdaq_sp500_smart_dca", label: "Nasdaq 100 / S&P 500 DCA", label_en: "Nasdaq 100 / S&P 500 DCA", label_zh: "纳指100 / 标普500 定投", domain: "us_equity", runtime_enabled: true, dca_enabled: true, dca_default_mode: "fixed", dca_default_base_investment_usd: "1000" }, { profile: "ibit_smart_dca", label: "IBIT Bitcoin ETF DCA", label_en: "IBIT Bitcoin ETF DCA", label_zh: "IBIT 比特币定投", domain: "us_equity", runtime_enabled: true, dca_enabled: true, dca_default_mode: "fixed", dca_default_base_investment_usd: "1000" }, - { profile: "global_etf_rotation", label: "Global ETF Rotation", label_en: "Global ETF Rotation", label_zh: "全球 ETF 轮动", domain: "us_equity", runtime_enabled: true }, - { profile: "russell_top50_leader_rotation", label: "Russell Top50 Leader Rotation", label_en: "Russell Top50 Leader Rotation", label_zh: "罗素 Top50 领涨轮动", domain: "us_equity", runtime_enabled: true }, + { profile: "global_etf_rotation", label: "Global ETF Rotation", label_en: "Global ETF Rotation", label_zh: "全球 ETF 轮动", domain: "us_equity", runtime_enabled: true, income_layer_enabled: true, income_layer_start_usd: "500000", income_layer_max_ratio: "0.15", income_layer_allocations: { SCHD: 0.40, DGRO: 0.25, SGOV: 0.30, SPYI: 0.05 } }, + { profile: "russell_top50_leader_rotation", label: "Russell Top50 Leader Rotation", label_en: "Russell Top50 Leader Rotation", label_zh: "罗素 Top50 领涨轮动", domain: "us_equity", runtime_enabled: true, income_layer_enabled: true, income_layer_start_usd: "300000", income_layer_max_ratio: "0.25", income_layer_allocations: { SCHD: 0.45, DGRO: 0.30, SGOV: 0.25 } }, { profile: "hk_global_etf_tactical_rotation", label: "HK Global ETF Tactical Rotation", label_en: "HK Global ETF Tactical Rotation", label_zh: "港股全球 ETF 战术轮动", domain: "hk_equity", runtime_enabled: true }, { profile: "hk_low_vol_dividend_quality_snapshot", label: "HK Low-Vol Dividend Quality Snapshot", label_en: "HK Low-Vol Dividend Quality Snapshot", label_zh: "港股低波红利质量快照", domain: "hk_equity", runtime_enabled: true }, ]; @@ -1460,7 +1460,7 @@

切换摘要

hk_low_vol_dividend_quality_snapshot: { zh: "港股低波红利质量快照", en: "HK Low-Vol Dividend Quality Snapshot" }, }; - const incomeLayerDefaults = { + const fallbackIncomeLayerDefaults = { tqqq_growth_income: { startUsd: 250000, maxRatio: "0.55", @@ -1482,6 +1482,7 @@

切换摘要

allocations: { SCHD: 0.45, DGRO: 0.30, SGOV: 0.25 }, }, }; + let incomeLayerDefaults = {}; const strategyDomains = ["us_equity", "hk_equity"]; let strategyOptions = []; @@ -1870,6 +1871,7 @@

切换摘要

const nextOptions = []; const nextLabels = {}; const nextCatalog = {}; + const nextIncomeLayerDefaults = {}; for (const item of profiles) { const profile = cleanStrategyProfile(item?.profile || item?.strategy_profile); if (!profile || nextOptions.includes(profile)) continue; @@ -1897,13 +1899,54 @@

切换摘要

item?.default_dca_base_investment_usd || dcaDefaults?.defaultBaseInvestmentUsd || "1000", - ) || "1000"; + ) || "1000"; + } + const profileIncomeDefaults = incomeLayerDefaultsFromProfileItem(item); + const incomeDefaults = profileIncomeDefaults === false + ? null + : (profileIncomeDefaults || fallbackIncomeLayerDefaults[profile] || null); + if (incomeDefaults) { + nextIncomeLayerDefaults[profile] = incomeDefaults; + nextCatalog[profile].income_layer_enabled = true; + nextCatalog[profile].income_layer_start_usd = String(incomeDefaults.startUsd); + nextCatalog[profile].income_layer_max_ratio = incomeDefaults.maxRatio; + nextCatalog[profile].income_layer_allocations = incomeDefaults.allocations; } } if (!nextOptions.length && profiles !== defaultStrategyProfiles) return applyStrategyProfiles(defaultStrategyProfiles); strategyOptions = nextOptions; strategyLabels = nextLabels; strategyCatalog = nextCatalog; + incomeLayerDefaults = nextIncomeLayerDefaults; + } + + function incomeLayerDefaultsFromProfileItem(item) { + const enabled = cleanOptionalBoolean(item?.income_layer_enabled); + const hasConfig = enabled !== null || + item?.income_layer_start_usd !== undefined || + item?.income_layer_max_ratio !== undefined || + item?.income_layer_allocations !== undefined; + if (!hasConfig) return null; + if (enabled === false) return false; + const startUsd = cleanDisplayNumber(item?.income_layer_start_usd); + const maxRatio = cleanDisplayRatio(item?.income_layer_max_ratio); + const allocations = cleanIncomeLayerAllocations(item?.income_layer_allocations); + if (!startUsd || !maxRatio || !allocations) return null; + return { startUsd, maxRatio, allocations }; + } + + function cleanIncomeLayerAllocations(value) { + if (!value || Array.isArray(value) || typeof value !== "object") return null; + const allocations = {}; + let total = 0; + for (const [rawSymbol, rawRatio] of Object.entries(value)) { + const symbol = String(rawSymbol || "").trim().toUpperCase(); + const ratio = cleanDisplayPositiveNumber(rawRatio); + if (!/^[A-Z0-9.-]{1,12}$/.test(symbol) || !ratio) continue; + allocations[symbol] = Number(ratio); + total += Number(ratio); + } + return total > 0 && Object.keys(allocations).length ? allocations : null; } function supportedDomainsForAccount(platform, account) { diff --git a/web/strategy-switch-console/page_asset.js b/web/strategy-switch-console/page_asset.js index b804715..548b8eb 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"; +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"; diff --git a/web/strategy-switch-console/strategy-profiles.example.json b/web/strategy-switch-console/strategy-profiles.example.json index d232644..8d0f9b4 100644 --- a/web/strategy-switch-console/strategy-profiles.example.json +++ b/web/strategy-switch-console/strategy-profiles.example.json @@ -5,7 +5,25 @@ "label_en": "TQQQ Growth Income", "label_zh": "TQQQ 增长收益", "domain": "us_equity", - "runtime_enabled": true + "runtime_enabled": true, + "income_layer_enabled": true, + "income_layer_start_usd": "250000", + "income_layer_max_ratio": "0.55", + "income_layer_allocations": { + "SCHD": 0.3, + "DGRO": 0.2, + "SGOV": 0.4, + "SPYI": 0.08, + "QQQI": 0.02 + }, + "option_overlay_enabled": true, + "option_overlay_live_gate": "promotion_required", + "option_overlay_live_status": "research_only", + "option_growth_overlay_enabled": true, + "option_growth_overlay_recipe": "tqqq_leaps_growth_v1", + "option_growth_overlay_start_usd": "250000", + "option_growth_overlay_nav_budget_ratio": "0.03", + "option_income_overlay_enabled": false }, { "profile": "soxl_soxx_trend_income", @@ -13,7 +31,25 @@ "label_en": "SOXL/SOXX Semiconductor Trend Income", "label_zh": "SOXL/SOXX 半导体趋势收益", "domain": "us_equity", - "runtime_enabled": true + "runtime_enabled": true, + "income_layer_enabled": true, + "income_layer_start_usd": "150000", + "income_layer_max_ratio": "0.95", + "income_layer_allocations": { + "SCHD": 0.15, + "DGRO": 0.1, + "SGOV": 0.7, + "SPYI": 0.04, + "QQQI": 0.01 + }, + "option_overlay_enabled": true, + "option_overlay_live_gate": "promotion_required", + "option_overlay_live_status": "research_only", + "option_growth_overlay_enabled": false, + "option_income_overlay_enabled": true, + "option_income_overlay_recipe": "soxx_put_credit_spread_income_v1", + "option_income_overlay_start_usd": "150000", + "option_income_overlay_nav_risk_ratio": "0.01" }, { "profile": "nasdaq_sp500_smart_dca", @@ -43,7 +79,24 @@ "label_en": "Global ETF Rotation", "label_zh": "全球 ETF 轮动", "domain": "us_equity", - "runtime_enabled": true + "runtime_enabled": true, + "income_layer_enabled": true, + "income_layer_start_usd": "500000", + "income_layer_max_ratio": "0.15", + "income_layer_allocations": { + "SCHD": 0.4, + "DGRO": 0.25, + "SGOV": 0.3, + "SPYI": 0.05 + }, + "option_overlay_enabled": true, + "option_overlay_live_gate": "promotion_required", + "option_overlay_live_status": "research_only", + "option_growth_overlay_enabled": true, + "option_growth_overlay_recipe": "spy_leaps_growth_v1", + "option_growth_overlay_start_usd": "500000", + "option_growth_overlay_nav_budget_ratio": "0.015", + "option_income_overlay_enabled": false }, { "profile": "russell_top50_leader_rotation", @@ -51,7 +104,23 @@ "label_en": "Russell Top50 Leader Rotation", "label_zh": "罗素 Top50 领涨轮动", "domain": "us_equity", - "runtime_enabled": true + "runtime_enabled": true, + "income_layer_enabled": true, + "income_layer_start_usd": "300000", + "income_layer_max_ratio": "0.25", + "income_layer_allocations": { + "SCHD": 0.45, + "DGRO": 0.3, + "SGOV": 0.25 + }, + "option_overlay_enabled": true, + "option_overlay_live_gate": "promotion_required", + "option_overlay_live_status": "research_only", + "option_growth_overlay_enabled": true, + "option_growth_overlay_recipe": "spy_leaps_growth_v1", + "option_growth_overlay_start_usd": "300000", + "option_growth_overlay_nav_budget_ratio": "0.015", + "option_income_overlay_enabled": false }, { "profile": "hk_global_etf_tactical_rotation", diff --git a/web/strategy-switch-console/strategy_profiles_asset.js b/web/strategy-switch-console/strategy_profiles_asset.js index 49d3486..18a4a20 100644 --- a/web/strategy-switch-console/strategy_profiles_asset.js +++ b/web/strategy-switch-console/strategy_profiles_asset.js @@ -1,2 +1,2 @@ // Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand. -export const DEFAULT_STRATEGY_PROFILES = [{"profile": "tqqq_growth_income", "label": "TQQQ Growth Income", "label_en": "TQQQ Growth Income", "label_zh": "TQQQ 增长收益", "domain": "us_equity", "runtime_enabled": true}, {"profile": "soxl_soxx_trend_income", "label": "SOXL/SOXX Semiconductor Trend Income", "label_en": "SOXL/SOXX Semiconductor Trend Income", "label_zh": "SOXL/SOXX 半导体趋势收益", "domain": "us_equity", "runtime_enabled": true}, {"profile": "nasdaq_sp500_smart_dca", "label": "Nasdaq 100 / S&P 500 DCA", "label_en": "Nasdaq 100 / S&P 500 DCA", "label_zh": "纳指100 / 标普500 定投", "domain": "us_equity", "runtime_enabled": true, "dca_enabled": true, "dca_default_mode": "fixed", "dca_default_base_investment_usd": "1000"}, {"profile": "ibit_smart_dca", "label": "IBIT Bitcoin ETF DCA", "label_en": "IBIT Bitcoin ETF DCA", "label_zh": "IBIT 比特币定投", "domain": "us_equity", "runtime_enabled": true, "dca_enabled": true, "dca_default_mode": "fixed", "dca_default_base_investment_usd": "1000"}, {"profile": "global_etf_rotation", "label": "Global ETF Rotation", "label_en": "Global ETF Rotation", "label_zh": "全球 ETF 轮动", "domain": "us_equity", "runtime_enabled": true}, {"profile": "russell_top50_leader_rotation", "label": "Russell Top50 Leader Rotation", "label_en": "Russell Top50 Leader Rotation", "label_zh": "罗素 Top50 领涨轮动", "domain": "us_equity", "runtime_enabled": true}, {"profile": "hk_global_etf_tactical_rotation", "label": "HK Global ETF Tactical Rotation", "label_en": "HK Global ETF Tactical Rotation", "label_zh": "港股全球 ETF 战术轮动", "domain": "hk_equity", "runtime_enabled": true}, {"profile": "hk_low_vol_dividend_quality_snapshot", "label": "HK Low-Vol Dividend Quality Snapshot", "label_en": "HK Low-Vol Dividend Quality Snapshot", "label_zh": "港股低波红利质量快照", "domain": "hk_equity", "runtime_enabled": true}]; +export const DEFAULT_STRATEGY_PROFILES = [{"profile": "tqqq_growth_income", "label": "TQQQ Growth Income", "label_en": "TQQQ Growth Income", "label_zh": "TQQQ 增长收益", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "250000", "income_layer_max_ratio": "0.55", "income_layer_allocations": {"SCHD": 0.3, "DGRO": 0.2, "SGOV": 0.4, "SPYI": 0.08, "QQQI": 0.02}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_growth_overlay_enabled": true, "option_growth_overlay_recipe": "tqqq_leaps_growth_v1", "option_growth_overlay_start_usd": "250000", "option_growth_overlay_nav_budget_ratio": "0.03", "option_income_overlay_enabled": false}, {"profile": "soxl_soxx_trend_income", "label": "SOXL/SOXX Semiconductor Trend Income", "label_en": "SOXL/SOXX Semiconductor Trend Income", "label_zh": "SOXL/SOXX 半导体趋势收益", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "150000", "income_layer_max_ratio": "0.95", "income_layer_allocations": {"SCHD": 0.15, "DGRO": 0.1, "SGOV": 0.7, "SPYI": 0.04, "QQQI": 0.01}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_growth_overlay_enabled": false, "option_income_overlay_enabled": true, "option_income_overlay_recipe": "soxx_put_credit_spread_income_v1", "option_income_overlay_start_usd": "150000", "option_income_overlay_nav_risk_ratio": "0.01"}, {"profile": "nasdaq_sp500_smart_dca", "label": "Nasdaq 100 / S&P 500 DCA", "label_en": "Nasdaq 100 / S&P 500 DCA", "label_zh": "纳指100 / 标普500 定投", "domain": "us_equity", "runtime_enabled": true, "dca_enabled": true, "dca_default_mode": "fixed", "dca_default_base_investment_usd": "1000"}, {"profile": "ibit_smart_dca", "label": "IBIT Bitcoin ETF DCA", "label_en": "IBIT Bitcoin ETF DCA", "label_zh": "IBIT 比特币定投", "domain": "us_equity", "runtime_enabled": true, "dca_enabled": true, "dca_default_mode": "fixed", "dca_default_base_investment_usd": "1000"}, {"profile": "global_etf_rotation", "label": "Global ETF Rotation", "label_en": "Global ETF Rotation", "label_zh": "全球 ETF 轮动", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "500000", "income_layer_max_ratio": "0.15", "income_layer_allocations": {"SCHD": 0.4, "DGRO": 0.25, "SGOV": 0.3, "SPYI": 0.05}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_growth_overlay_enabled": true, "option_growth_overlay_recipe": "spy_leaps_growth_v1", "option_growth_overlay_start_usd": "500000", "option_growth_overlay_nav_budget_ratio": "0.015", "option_income_overlay_enabled": false}, {"profile": "russell_top50_leader_rotation", "label": "Russell Top50 Leader Rotation", "label_en": "Russell Top50 Leader Rotation", "label_zh": "罗素 Top50 领涨轮动", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "300000", "income_layer_max_ratio": "0.25", "income_layer_allocations": {"SCHD": 0.45, "DGRO": 0.3, "SGOV": 0.25}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_growth_overlay_enabled": true, "option_growth_overlay_recipe": "spy_leaps_growth_v1", "option_growth_overlay_start_usd": "300000", "option_growth_overlay_nav_budget_ratio": "0.015", "option_income_overlay_enabled": false}, {"profile": "hk_global_etf_tactical_rotation", "label": "HK Global ETF Tactical Rotation", "label_en": "HK Global ETF Tactical Rotation", "label_zh": "港股全球 ETF 战术轮动", "domain": "hk_equity", "runtime_enabled": true}, {"profile": "hk_low_vol_dividend_quality_snapshot", "label": "HK Low-Vol Dividend Quality Snapshot", "label_en": "HK Low-Vol Dividend Quality Snapshot", "label_zh": "港股低波红利质量快照", "domain": "hk_equity", "runtime_enabled": true}]; diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 4dcf6d1..81a5d52 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -55,6 +55,35 @@ 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 LEGACY_INCOME_LAYER_CONTROL_FIELDS = [ + "income_threshold_usd", + "qqqi_income_ratio", + "income_layer_qqqi_weight", + "income_layer_spyi_weight", +]; +const LEGACY_INCOME_LAYER_VARIABLES = [ + "INCOME_THRESHOLD_USD", + "QQQI_INCOME_RATIO", + "INCOME_LAYER_QQQI_WEIGHT", + "INCOME_LAYER_SPYI_WEIGHT", +]; +const OPTION_OVERLAY_CONTROL_FIELDS = [ + "option_overlay_enabled", + "option_growth_overlay_enabled", + "option_growth_overlay_recipe", + "option_growth_overlay_start_usd", + "option_growth_overlay_nav_budget_ratio", + "option_income_overlay_enabled", + "option_income_overlay_recipe", + "option_income_overlay_start_usd", + "option_income_overlay_nav_risk_ratio", +]; +const OPTION_OVERLAY_VARIABLES = OPTION_OVERLAY_CONTROL_FIELDS.map((field) => field.toUpperCase()); +const OPTION_OVERLAY_PROFILE_FIELDS = [ + ...OPTION_OVERLAY_CONTROL_FIELDS, + "option_overlay_live_gate", + "option_overlay_live_status", +]; 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" }, @@ -989,6 +1018,7 @@ function normalizeSwitchInputs(raw) { if (directIbitZscoreVariables.length) { throw new Error("use ibit_zscore_exit_* control fields instead of IBIT_ZSCORE_EXIT variables"); } + rejectResearchOnlyExtraVariables(extraVariables); const dcaExtraControls = dcaPayloadFromObject(extraVariables); const ibitZscoreExtraControls = ibitZscoreExitPayloadFromObject(extraVariables); @@ -1211,11 +1241,122 @@ function normalizeStrategyProfilesPayload(payload, fieldName = "strategy profile `${fieldName}[${index}].dca_default_base_investment_usd`, ); } + const incomeLayerConfig = incomeLayerConfigFromProfileItem(item, `${fieldName}[${index}]`); + if (incomeLayerConfig) Object.assign(entry, incomeLayerConfig); + const optionOverlayConfig = optionOverlayConfigFromProfileItem(item, `${fieldName}[${index}]`); + if (optionOverlayConfig) Object.assign(entry, optionOverlayConfig); result.push(entry); } return result; } +function rejectResearchOnlyExtraVariables(extraVariables) { + const blocked = [ + ...LEGACY_INCOME_LAYER_CONTROL_FIELDS, + ...LEGACY_INCOME_LAYER_VARIABLES, + ...OPTION_OVERLAY_CONTROL_FIELDS, + ...OPTION_OVERLAY_VARIABLES, + ].filter((name) => extraVariables[name] !== undefined); + if (blocked.length) { + throw new Error( + `direct option overlay settings and legacy income controls are research-only: ${blocked.join(", ")}`, + ); + } +} + +function incomeLayerConfigFromProfileItem(item, fieldName) { + const hasIncomeLayerConfig = [ + "income_layer_enabled", + "income_layer_start_usd", + "income_layer_max_ratio", + "income_layer_allocations", + ].some((field) => item[field] !== undefined && item[field] !== null && String(item[field]).trim() !== ""); + if (!hasIncomeLayerConfig) return null; + const enabled = item.income_layer_enabled === undefined || item.income_layer_enabled === null + ? true + : cleanProfileBoolean(item.income_layer_enabled); + if (!enabled) return { income_layer_enabled: false }; + return { + income_layer_enabled: true, + income_layer_start_usd: cleanNonNegativeNumber( + item.income_layer_start_usd, + `${fieldName}.income_layer_start_usd`, + ), + income_layer_max_ratio: cleanRatio(item.income_layer_max_ratio, `${fieldName}.income_layer_max_ratio`), + income_layer_allocations: cleanIncomeLayerAllocations( + item.income_layer_allocations, + `${fieldName}.income_layer_allocations`, + ), + }; +} + +function optionOverlayConfigFromProfileItem(item, fieldName) { + const hasOptionOverlayConfig = OPTION_OVERLAY_PROFILE_FIELDS.some((field) => + item[field] !== undefined && item[field] !== null && String(item[field]).trim() !== "", + ); + if (!hasOptionOverlayConfig) return null; + const enabled = item.option_overlay_enabled === undefined || item.option_overlay_enabled === null + ? true + : cleanProfileBoolean(item.option_overlay_enabled); + const result = { option_overlay_enabled: enabled }; + addConfigOptional(result, "option_overlay_live_gate", item.option_overlay_live_gate || (enabled ? "promotion_required" : "disabled"), (value, field) => + cleanChoice(value, ["promotion_required", "live_allowed", "disabled"], field), + ); + addConfigOptional(result, "option_overlay_live_status", item.option_overlay_live_status || (enabled ? "research_only" : "disabled"), (value, field) => + cleanChoice(value, ["research_only", "live_allowed", "disabled"], field), + ); + if (!enabled) return result; + + addOptionalOptionFamilyConfig(result, item, "growth", fieldName); + addOptionalOptionFamilyConfig(result, item, "income", fieldName); + return result; +} + +function addOptionalOptionFamilyConfig(target, item, family, fieldName) { + const prefix = `option_${family}_overlay`; + const enabledField = `${prefix}_enabled`; + if (item[enabledField] === undefined || item[enabledField] === null || String(item[enabledField]).trim() === "") { + return; + } + const enabled = cleanProfileBoolean(item[enabledField]); + target[enabledField] = enabled; + if (!enabled) return; + + target[`${prefix}_recipe`] = cleanSlug(item[`${prefix}_recipe`], `${fieldName}.${prefix}_recipe`); + target[`${prefix}_start_usd`] = cleanNonNegativeNumber( + item[`${prefix}_start_usd`], + `${fieldName}.${prefix}_start_usd`, + ); + if (family === "growth") { + target.option_growth_overlay_nav_budget_ratio = cleanRatio( + item.option_growth_overlay_nav_budget_ratio, + `${fieldName}.option_growth_overlay_nav_budget_ratio`, + ); + } else { + target.option_income_overlay_nav_risk_ratio = cleanRatio( + item.option_income_overlay_nav_risk_ratio, + `${fieldName}.option_income_overlay_nav_risk_ratio`, + ); + } +} + +function cleanIncomeLayerAllocations(value, fieldName) { + if (!value || Array.isArray(value) || typeof value !== "object") { + throw new Error(`${fieldName} must be an object`); + } + const result = {}; + let total = 0; + for (const [rawSymbol, rawWeight] of Object.entries(value)) { + const symbol = String(rawSymbol || "").trim().toUpperCase(); + if (!/^[A-Z0-9.-]{1,12}$/.test(symbol)) throw new Error(`${fieldName} contains an invalid symbol`); + const weight = Number(cleanPositiveNumber(rawWeight, `${fieldName}.${symbol}`)); + total += weight; + result[symbol] = weight; + } + if (!Object.keys(result).length || total <= 0) throw new Error(`${fieldName} must contain positive allocations`); + return result; +} + function cleanProfileBoolean(value) { if (value === true || value === "true" || value === "1" || value === 1) return true; if (value === false || value === "false" || value === "0" || value === 0) return false;