From 9b47b0c2b8e8e94c48a8ae29dbbd68e2d9040be6 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:50:55 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BA=92=E6=96=A5=E4=B8=8D=E7=A6=81?= =?UTF-8?q?=E6=AD=A2=E9=80=89=E6=8B=A9=EF=BC=8C=E8=A7=A3=E6=9E=90=E6=89=80?= =?UTF-8?q?=E6=9C=89=20sync=20=E5=87=BD=E6=95=B0=E5=88=B0=E5=85=B7?= =?UTF-8?q?=E4=BD=93=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 margin/reserve 相互禁用下拉框选项的行为,改为选择时自动清除冲突项 - syncRuntimeTargetForAccount: 解析为 enabled/disabled - syncIncomeLayerForAccount: 解析为 enabled/disabled - syncOptionOverlayForAccount: 解析为 enabled/disabled - 更新测试: 104 个用例 Co-Authored-By: Claude --- tests/test_cash_financing.js | 274 ++++++++++++++++++++++ web/strategy-switch-console/index.html | 32 ++- web/strategy-switch-console/page_asset.js | 2 +- 3 files changed, 300 insertions(+), 8 deletions(-) diff --git a/tests/test_cash_financing.js b/tests/test_cash_financing.js index 29638aa..fccf89a 100644 --- a/tests/test_cash_financing.js +++ b/tests/test_cash_financing.js @@ -705,5 +705,279 @@ console.log("\n=== 8. 回归测试:旧行为不应出现 ===\n"); assert(form.reservePolicyMode === "none", "8c: schwab reserve cleared to 'none'"); } +// ============================================================ +// 9. syncRuntimeTargetForAccount (解析为具体值) +// ============================================================ + +// --- 辅助函数 --- +function runtimeTargetStateForAccount(state, platform, account) { + const entry = currentEntryForAccount(state, platform, account); + if (!entry) return { known: false, enabled: null }; + const configured = cleanOptionalBoolean(entry.runtime_target_enabled); + return { known: true, enabled: configured ?? true }; +} + +function syncRuntimeTargetForAccount(state, form, platform, account) { + if (!form || form.runtimeTargetTouched) return; + const target = runtimeTargetStateForAccount(state, platform, account); + form.runtimeTargetMode = target.known ? (target.enabled ? "enabled" : "disabled") : "current"; +} + +// --- 测试 --- +console.log("\n=== 9. syncRuntimeTargetForAccount (解析为具体值) ===\n"); + +// 9a: runtime_target_enabled: true → enabled +{ + const state = { currentStrategies: makeCurrentStrategies("ibkr", { extra: { runtime_target_enabled: true } }) }; + const form = { runtimeTargetMode: "current", runtimeTargetTouched: false }; + syncRuntimeTargetForAccount(state, form, "ibkr", makeAccount("preview")); + assert(form.runtimeTargetMode === "enabled", "9a: runtime_target=true → 'enabled'"); +} + +// 9b: runtime_target_enabled: false → disabled +{ + const state = { currentStrategies: makeCurrentStrategies("ibkr", { extra: { runtime_target_enabled: false } }) }; + const form = { runtimeTargetMode: "current", runtimeTargetTouched: false }; + syncRuntimeTargetForAccount(state, form, "ibkr", makeAccount("preview")); + assert(form.runtimeTargetMode === "disabled", "9b: runtime_target=false → 'disabled'"); +} + +// 9c: no entry → stays "current" +{ + const state = { currentStrategies: {} }; + const form = { runtimeTargetMode: "current", runtimeTargetTouched: false }; + syncRuntimeTargetForAccount(state, form, "ibkr", makeAccount("preview")); + assert(form.runtimeTargetMode === "current", "9c: no entry → 'current'"); +} + +// 9d: touched → skip +{ + const state = { currentStrategies: makeCurrentStrategies("ibkr", { extra: { runtime_target_enabled: false } }) }; + const form = { runtimeTargetMode: "enabled", runtimeTargetTouched: true }; + syncRuntimeTargetForAccount(state, form, "ibkr", makeAccount("preview")); + assert(form.runtimeTargetMode === "enabled", "9d: touched → not overwritten"); +} + +// ============================================================ +// 10. syncIncomeLayerForAccount (解析为具体值) +// ============================================================ + +// --- 辅助函数 --- +function incomeLayerFromEntry(entry) { + return { + enabled: cleanOptionalBoolean(entry?.income_layer_enabled), + startUsd: cleanDisplayNumber(entry?.income_layer_start_usd), + maxRatio: cleanDisplayRatio(entry?.income_layer_max_ratio), + }; +} + +function incomeLayerFieldsConfigured(entry) { + const current = incomeLayerFromEntry(entry); + return current.enabled !== null || Boolean(current.startUsd || current.maxRatio); +} + +function incomeLayerDefaultForStrategy(profile) { + return profile ? { startUsd: 100000, maxRatio: "0.30" } : null; +} + +function effectiveIncomeLayerForAccount(state, platform, account, profile) { + const defaults = incomeLayerDefaultForStrategy(profile); + if (!defaults) return null; + const entry = currentEntryForAccount(state, platform, account); + if (!entry) return null; + const current = incomeLayerFromEntry(entry); + if (!incomeLayerFieldsConfigured(entry)) { + return { enabled: true, startUsd: String(defaults.startUsd), maxRatio: defaults.maxRatio }; + } + return { + enabled: current.enabled ?? true, + startUsd: current.startUsd || String(defaults.startUsd), + maxRatio: current.maxRatio || defaults.maxRatio, + }; +} + +function syncIncomeLayerForAccount(state, form, platform, account) { + if (!form || form.incomeLayerTouched) return; + const defaults = incomeLayerDefaultForStrategy(form.strategy); + const current = incomeLayerFromEntry(currentEntryForAccount(state, platform, account)); + form.incomeLayerStartUsd = current.startUsd || String(defaults?.startUsd || ""); + form.incomeLayerMaxRatio = current.maxRatio || defaults?.maxRatio || ""; + const effective = effectiveIncomeLayerForAccount(state, platform, account, form.strategy); + if (effective) { + form.incomeLayerMode = effective.enabled ? "enabled" : "disabled"; + } else if (defaults) { + form.incomeLayerMode = "enabled"; + } else { + form.incomeLayerMode = "current"; + } +} + +console.log("\n=== 10. syncIncomeLayerForAccount (解析为具体值) ===\n"); + +// 10a: income_layer_enabled: true → enabled +{ + const state = { currentStrategies: makeCurrentStrategies("ibkr", { + extra: { income_layer_enabled: true, income_layer_start_usd: "200000", income_layer_max_ratio: "0.25" } + }) }; + const form = { incomeLayerMode: "current", incomeLayerTouched: false, strategy: "some_profile" }; + syncIncomeLayerForAccount(state, form, "ibkr", makeAccount("preview")); + assert(form.incomeLayerMode === "enabled", "10a: income_layer enabled → 'enabled'"); +} + +// 10b: income_layer_enabled: false → disabled +{ + const state = { currentStrategies: makeCurrentStrategies("ibkr", { + extra: { income_layer_enabled: false, income_layer_start_usd: "200000", income_layer_max_ratio: "0.25" } + }) }; + const form = { incomeLayerMode: "current", incomeLayerTouched: false, strategy: "some_profile" }; + syncIncomeLayerForAccount(state, form, "ibkr", makeAccount("preview")); + assert(form.incomeLayerMode === "disabled", "10b: income_layer disabled → 'disabled'"); +} + +// 10c: no entry + has defaults → enabled (default) +{ + const state = { currentStrategies: {} }; + const form = { incomeLayerMode: "current", incomeLayerTouched: false, strategy: "some_profile" }; + syncIncomeLayerForAccount(state, form, "ibkr", makeAccount("preview")); + assert(form.incomeLayerMode === "enabled", "10c: no entry + defaults → 'enabled'"); +} + +// 10d: touched → skip +{ + const state = { currentStrategies: makeCurrentStrategies("ibkr", { + extra: { income_layer_enabled: false } + }) }; + const form = { incomeLayerMode: "enabled", incomeLayerTouched: true, strategy: "some_profile" }; + syncIncomeLayerForAccount(state, form, "ibkr", makeAccount("preview")); + assert(form.incomeLayerMode === "enabled", "10d: touched → not overwritten"); +} + +// ============================================================ +// 11. syncOptionOverlayForAccount (解析为具体值) +// ============================================================ + +function optionOverlaySupported(profile) { return profile !== "no_overlay"; } +function currentOptionOverlayForAccount(state, platform, account) { + return cleanOptionalBoolean(currentEntryForAccount(state, platform, account)?.option_overlay_enabled); +} +function effectiveOptionOverlayForAccount(state, platform, account, profile) { + const configured = currentOptionOverlayForAccount(state, platform, account); + if (configured !== null) return configured; + if (!optionOverlaySupported(profile)) return null; + return true; +} +function normalizeOptionOverlayMode(value) { + return ["current", "enabled", "disabled"].includes(value) ? value : "current"; +} + +function syncOptionOverlayForAccount(state, form, platform, account) { + if (!form || form.optionOverlayTouched) return; + const configured = normalizeOptionOverlayMode(account?.option_overlay_mode); + if (configured !== "current") { + form.optionOverlayMode = configured; + return; + } + if (!optionOverlaySupported(form.strategy)) { + form.optionOverlayMode = "disabled"; + return; + } + const effective = effectiveOptionOverlayForAccount(state, platform, account, form.strategy); + if (effective !== null && effective !== undefined) { + form.optionOverlayMode = effective ? "enabled" : "disabled"; + } else { + form.optionOverlayMode = "enabled"; + } +} + +console.log("\n=== 11. syncOptionOverlayForAccount (解析为具体值) ===\n"); + +// 11a: option_overlay_enabled: true → enabled +{ + const state = { currentStrategies: makeCurrentStrategies("ibkr", { extra: { option_overlay_enabled: true } }) }; + const form = { optionOverlayMode: "current", optionOverlayTouched: false, strategy: "some_profile" }; + syncOptionOverlayForAccount(state, form, "ibkr", makeAccount("preview")); + assert(form.optionOverlayMode === "enabled", "11a: option overlay enabled → 'enabled'"); +} + +// 11b: option_overlay_enabled: false → disabled +{ + const state = { currentStrategies: makeCurrentStrategies("ibkr", { extra: { option_overlay_enabled: false } }) }; + const form = { optionOverlayMode: "current", optionOverlayTouched: false, strategy: "some_profile" }; + syncOptionOverlayForAccount(state, form, "ibkr", makeAccount("preview")); + assert(form.optionOverlayMode === "disabled", "11b: option overlay disabled → 'disabled'"); +} + +// 11c: no entry + supported → enabled (default) +{ + const state = { currentStrategies: {} }; + const form = { optionOverlayMode: "current", optionOverlayTouched: false, strategy: "some_profile" }; + syncOptionOverlayForAccount(state, form, "ibkr", makeAccount("preview")); + assert(form.optionOverlayMode === "enabled", "11c: no entry + supported → 'enabled'"); +} + +// 11d: not supported → disabled +{ + const state = { currentStrategies: makeCurrentStrategies("ibkr", { extra: { option_overlay_enabled: true } }) }; + const form = { optionOverlayMode: "current", optionOverlayTouched: false, strategy: "no_overlay" }; + syncOptionOverlayForAccount(state, form, "ibkr", makeAccount("preview")); + assert(form.optionOverlayMode === "disabled", "11d: not supported → 'disabled'"); +} + +// 11e: account has explicit mode → use it +{ + const form = { optionOverlayMode: "current", optionOverlayTouched: false, strategy: "some_profile" }; + syncOptionOverlayForAccount({}, form, "ibkr", { key: "preview", option_overlay_mode: "disabled" }); + assert(form.optionOverlayMode === "disabled", "11e: account explicit 'disabled' → 'disabled'"); +} + +// ============================================================ +// 12. 互斥 UI 不再禁用选项 (回归测试) +// ============================================================ + +console.log("\n=== 12. 互斥 UI 不再禁用选项 ===\n"); + +// 12a: 预留现金覆盖活跃时,select cash-only=disabled 不应因 option.disabled 被阻挡 +// (此测试验证 reconcileExecutionCashPolicy 可处理用户选择,无需前置禁用) +{ + const form = { + cashOnlyExecutionMode: "enabled", // 当前:不允许融资 + reservePolicyMode: "ratio", + minReservedCashUsd: "", + reservedCashRatio: "0.05", + reservedCashTouched: true, + cashOnlyExecutionTouched: false, + }; + // 用户选择"允许融资: 是" + form.cashOnlyExecutionMode = "disabled"; + form.cashOnlyExecutionTouched = true; + reconcileExecutionCashPolicy(form, "margin"); + // 预留现金应被清除 + assert(form.reservePolicyMode === "none", "12a: margin=yes → reserve cleared"); + assert(form.cashOnlyExecutionMode === "disabled", "12a: margin stays 'disabled'"); + // 不再冲突 + assert(executionCashPolicyConflict(form) === false, "12a: no conflict after reconciliation"); +} + +// 12b: margin 启用时,reserve 下拉框不应 disabled,用户可选 ratio +{ + const form = { + cashOnlyExecutionMode: "disabled", // margin enabled + reservePolicyMode: "none", + minReservedCashUsd: "", + reservedCashRatio: "", + reservedCashTouched: false, + cashOnlyExecutionTouched: true, + }; + // 用户选择预留现金策略: ratio + form.reservePolicyMode = "ratio"; + form.reservedCashRatio = "0.03"; + form.reservedCashTouched = true; + reconcileExecutionCashPolicy(form, "reserve"); + // 融资应被禁用 + assert(form.cashOnlyExecutionMode === "enabled", "12b: reserve=ratio → margin disabled"); + assert(form.reservePolicyMode === "ratio", "12b: reserve stays 'ratio'"); + assert(executionCashPolicyConflict(form) === false, "12b: no conflict after reconciliation"); +} + // ============================================================ summary(); diff --git a/web/strategy-switch-console/index.html b/web/strategy-switch-console/index.html index 0f14969..19655cd 100644 --- a/web/strategy-switch-console/index.html +++ b/web/strategy-switch-console/index.html @@ -3133,7 +3133,9 @@

切换摘要

function syncRuntimeTargetForAccount(platform) { const form = state.forms[platform]; if (!form || form.runtimeTargetTouched) return; - form.runtimeTargetMode = "current"; + // Resolve to a concrete value so the UI shows enabled/disabled directly + const target = runtimeTargetStateForAccount(platform, selectedAccount(platform)); + form.runtimeTargetMode = target.known ? (target.enabled ? "enabled" : "disabled") : "current"; } function syncReservePolicyForAccount(platform) { @@ -3161,9 +3163,17 @@

切换摘要

if (!form || form.incomeLayerTouched) return; const defaults = incomeLayerDefaultForStrategy(form.strategy); const current = currentIncomeLayerForAccount(platform, selectedAccount(platform)); - form.incomeLayerMode = "current"; form.incomeLayerStartUsd = current.startUsd || String(defaults?.startUsd || ""); form.incomeLayerMaxRatio = current.maxRatio || defaults?.maxRatio || ""; + // Resolve to a concrete mode so the UI shows enabled/disabled directly + const effective = effectiveIncomeLayerForAccount(platform, selectedAccount(platform), form.strategy); + if (effective) { + form.incomeLayerMode = effective.enabled ? "enabled" : "disabled"; + } else if (defaults) { + form.incomeLayerMode = "enabled"; + } else { + form.incomeLayerMode = "current"; + } } function syncOptionOverlayForAccount(platform) { @@ -3175,7 +3185,17 @@

切换摘要

form.optionOverlayMode = configured; return; } - form.optionOverlayMode = optionOverlaySupported(form.strategy) ? "current" : "disabled"; + // Resolve to a concrete mode so the UI shows enabled/disabled directly + if (!optionOverlaySupported(form.strategy)) { + form.optionOverlayMode = "disabled"; + return; + } + const effective = effectiveOptionOverlayForAccount(platform, account, form.strategy); + if (effective !== null && effective !== undefined) { + form.optionOverlayMode = effective ? "enabled" : "disabled"; + } else { + form.optionOverlayMode = "enabled"; + } } function syncCashOnlyExecutionForAccount(platform) { @@ -4171,9 +4191,8 @@

切换摘要

"{currency}", selectedCashCurrency(platform, account), ); - reservePolicyModeSelect.disabled = marginBlocksReserve; - minReservedCashInput.disabled = marginBlocksReserve || reserveMode === "current" || reserveMode === "none" || reserveMode === "ratio"; - reservedCashRatioInput.disabled = marginBlocksReserve || reserveMode === "current" || reserveMode === "none" || reserveMode === "floor"; + minReservedCashInput.disabled = reserveMode === "current" || reserveMode === "none" || reserveMode === "ratio"; + reservedCashRatioInput.disabled = reserveMode === "current" || reserveMode === "none" || reserveMode === "floor"; minReservedCashInput.value = reserveMode === "ratio" || reserveMode === "none" ? "" : form.minReservedCashUsd; reservedCashRatioInput.value = reserveMode === "floor" || reserveMode === "none" ? "" : form.reservedCashRatio; el("reserve-policy-block").classList.toggle("policy-block-muted", marginBlocksReserve); @@ -4226,7 +4245,6 @@

切换摘要

false, mode === normalizeCashOnlyExecutionMode(form.cashOnlyExecutionMode), ); - if (mode === "disabled" && reserveBlocksMargin) option.disabled = true; cashOnlyExecutionModeSelect.append(option); } el("cash-only-policy-block").classList.toggle("policy-block-muted", reserveBlocksMargin); diff --git a/web/strategy-switch-console/page_asset.js b/web/strategy-switch-console/page_asset.js index fff15f5..c92ee9a 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";