Skip to content

Commit 8547ba5

Browse files
authored
fix: 互斥不禁止选择,解析所有 sync 函数到具体值 (#111)
fix: 互斥不禁止选择,解析所有 sync 函数到具体值
2 parents cf8be9c + 9b47b0c commit 8547ba5

3 files changed

Lines changed: 300 additions & 8 deletions

File tree

tests/test_cash_financing.js

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,5 +705,279 @@ console.log("\n=== 8. 回归测试:旧行为不应出现 ===\n");
705705
assert(form.reservePolicyMode === "none", "8c: schwab reserve cleared to 'none'");
706706
}
707707

708+
// ============================================================
709+
// 9. syncRuntimeTargetForAccount (解析为具体值)
710+
// ============================================================
711+
712+
// --- 辅助函数 ---
713+
function runtimeTargetStateForAccount(state, platform, account) {
714+
const entry = currentEntryForAccount(state, platform, account);
715+
if (!entry) return { known: false, enabled: null };
716+
const configured = cleanOptionalBoolean(entry.runtime_target_enabled);
717+
return { known: true, enabled: configured ?? true };
718+
}
719+
720+
function syncRuntimeTargetForAccount(state, form, platform, account) {
721+
if (!form || form.runtimeTargetTouched) return;
722+
const target = runtimeTargetStateForAccount(state, platform, account);
723+
form.runtimeTargetMode = target.known ? (target.enabled ? "enabled" : "disabled") : "current";
724+
}
725+
726+
// --- 测试 ---
727+
console.log("\n=== 9. syncRuntimeTargetForAccount (解析为具体值) ===\n");
728+
729+
// 9a: runtime_target_enabled: true → enabled
730+
{
731+
const state = { currentStrategies: makeCurrentStrategies("ibkr", { extra: { runtime_target_enabled: true } }) };
732+
const form = { runtimeTargetMode: "current", runtimeTargetTouched: false };
733+
syncRuntimeTargetForAccount(state, form, "ibkr", makeAccount("preview"));
734+
assert(form.runtimeTargetMode === "enabled", "9a: runtime_target=true → 'enabled'");
735+
}
736+
737+
// 9b: runtime_target_enabled: false → disabled
738+
{
739+
const state = { currentStrategies: makeCurrentStrategies("ibkr", { extra: { runtime_target_enabled: false } }) };
740+
const form = { runtimeTargetMode: "current", runtimeTargetTouched: false };
741+
syncRuntimeTargetForAccount(state, form, "ibkr", makeAccount("preview"));
742+
assert(form.runtimeTargetMode === "disabled", "9b: runtime_target=false → 'disabled'");
743+
}
744+
745+
// 9c: no entry → stays "current"
746+
{
747+
const state = { currentStrategies: {} };
748+
const form = { runtimeTargetMode: "current", runtimeTargetTouched: false };
749+
syncRuntimeTargetForAccount(state, form, "ibkr", makeAccount("preview"));
750+
assert(form.runtimeTargetMode === "current", "9c: no entry → 'current'");
751+
}
752+
753+
// 9d: touched → skip
754+
{
755+
const state = { currentStrategies: makeCurrentStrategies("ibkr", { extra: { runtime_target_enabled: false } }) };
756+
const form = { runtimeTargetMode: "enabled", runtimeTargetTouched: true };
757+
syncRuntimeTargetForAccount(state, form, "ibkr", makeAccount("preview"));
758+
assert(form.runtimeTargetMode === "enabled", "9d: touched → not overwritten");
759+
}
760+
761+
// ============================================================
762+
// 10. syncIncomeLayerForAccount (解析为具体值)
763+
// ============================================================
764+
765+
// --- 辅助函数 ---
766+
function incomeLayerFromEntry(entry) {
767+
return {
768+
enabled: cleanOptionalBoolean(entry?.income_layer_enabled),
769+
startUsd: cleanDisplayNumber(entry?.income_layer_start_usd),
770+
maxRatio: cleanDisplayRatio(entry?.income_layer_max_ratio),
771+
};
772+
}
773+
774+
function incomeLayerFieldsConfigured(entry) {
775+
const current = incomeLayerFromEntry(entry);
776+
return current.enabled !== null || Boolean(current.startUsd || current.maxRatio);
777+
}
778+
779+
function incomeLayerDefaultForStrategy(profile) {
780+
return profile ? { startUsd: 100000, maxRatio: "0.30" } : null;
781+
}
782+
783+
function effectiveIncomeLayerForAccount(state, platform, account, profile) {
784+
const defaults = incomeLayerDefaultForStrategy(profile);
785+
if (!defaults) return null;
786+
const entry = currentEntryForAccount(state, platform, account);
787+
if (!entry) return null;
788+
const current = incomeLayerFromEntry(entry);
789+
if (!incomeLayerFieldsConfigured(entry)) {
790+
return { enabled: true, startUsd: String(defaults.startUsd), maxRatio: defaults.maxRatio };
791+
}
792+
return {
793+
enabled: current.enabled ?? true,
794+
startUsd: current.startUsd || String(defaults.startUsd),
795+
maxRatio: current.maxRatio || defaults.maxRatio,
796+
};
797+
}
798+
799+
function syncIncomeLayerForAccount(state, form, platform, account) {
800+
if (!form || form.incomeLayerTouched) return;
801+
const defaults = incomeLayerDefaultForStrategy(form.strategy);
802+
const current = incomeLayerFromEntry(currentEntryForAccount(state, platform, account));
803+
form.incomeLayerStartUsd = current.startUsd || String(defaults?.startUsd || "");
804+
form.incomeLayerMaxRatio = current.maxRatio || defaults?.maxRatio || "";
805+
const effective = effectiveIncomeLayerForAccount(state, platform, account, form.strategy);
806+
if (effective) {
807+
form.incomeLayerMode = effective.enabled ? "enabled" : "disabled";
808+
} else if (defaults) {
809+
form.incomeLayerMode = "enabled";
810+
} else {
811+
form.incomeLayerMode = "current";
812+
}
813+
}
814+
815+
console.log("\n=== 10. syncIncomeLayerForAccount (解析为具体值) ===\n");
816+
817+
// 10a: income_layer_enabled: true → enabled
818+
{
819+
const state = { currentStrategies: makeCurrentStrategies("ibkr", {
820+
extra: { income_layer_enabled: true, income_layer_start_usd: "200000", income_layer_max_ratio: "0.25" }
821+
}) };
822+
const form = { incomeLayerMode: "current", incomeLayerTouched: false, strategy: "some_profile" };
823+
syncIncomeLayerForAccount(state, form, "ibkr", makeAccount("preview"));
824+
assert(form.incomeLayerMode === "enabled", "10a: income_layer enabled → 'enabled'");
825+
}
826+
827+
// 10b: income_layer_enabled: false → disabled
828+
{
829+
const state = { currentStrategies: makeCurrentStrategies("ibkr", {
830+
extra: { income_layer_enabled: false, income_layer_start_usd: "200000", income_layer_max_ratio: "0.25" }
831+
}) };
832+
const form = { incomeLayerMode: "current", incomeLayerTouched: false, strategy: "some_profile" };
833+
syncIncomeLayerForAccount(state, form, "ibkr", makeAccount("preview"));
834+
assert(form.incomeLayerMode === "disabled", "10b: income_layer disabled → 'disabled'");
835+
}
836+
837+
// 10c: no entry + has defaults → enabled (default)
838+
{
839+
const state = { currentStrategies: {} };
840+
const form = { incomeLayerMode: "current", incomeLayerTouched: false, strategy: "some_profile" };
841+
syncIncomeLayerForAccount(state, form, "ibkr", makeAccount("preview"));
842+
assert(form.incomeLayerMode === "enabled", "10c: no entry + defaults → 'enabled'");
843+
}
844+
845+
// 10d: touched → skip
846+
{
847+
const state = { currentStrategies: makeCurrentStrategies("ibkr", {
848+
extra: { income_layer_enabled: false }
849+
}) };
850+
const form = { incomeLayerMode: "enabled", incomeLayerTouched: true, strategy: "some_profile" };
851+
syncIncomeLayerForAccount(state, form, "ibkr", makeAccount("preview"));
852+
assert(form.incomeLayerMode === "enabled", "10d: touched → not overwritten");
853+
}
854+
855+
// ============================================================
856+
// 11. syncOptionOverlayForAccount (解析为具体值)
857+
// ============================================================
858+
859+
function optionOverlaySupported(profile) { return profile !== "no_overlay"; }
860+
function currentOptionOverlayForAccount(state, platform, account) {
861+
return cleanOptionalBoolean(currentEntryForAccount(state, platform, account)?.option_overlay_enabled);
862+
}
863+
function effectiveOptionOverlayForAccount(state, platform, account, profile) {
864+
const configured = currentOptionOverlayForAccount(state, platform, account);
865+
if (configured !== null) return configured;
866+
if (!optionOverlaySupported(profile)) return null;
867+
return true;
868+
}
869+
function normalizeOptionOverlayMode(value) {
870+
return ["current", "enabled", "disabled"].includes(value) ? value : "current";
871+
}
872+
873+
function syncOptionOverlayForAccount(state, form, platform, account) {
874+
if (!form || form.optionOverlayTouched) return;
875+
const configured = normalizeOptionOverlayMode(account?.option_overlay_mode);
876+
if (configured !== "current") {
877+
form.optionOverlayMode = configured;
878+
return;
879+
}
880+
if (!optionOverlaySupported(form.strategy)) {
881+
form.optionOverlayMode = "disabled";
882+
return;
883+
}
884+
const effective = effectiveOptionOverlayForAccount(state, platform, account, form.strategy);
885+
if (effective !== null && effective !== undefined) {
886+
form.optionOverlayMode = effective ? "enabled" : "disabled";
887+
} else {
888+
form.optionOverlayMode = "enabled";
889+
}
890+
}
891+
892+
console.log("\n=== 11. syncOptionOverlayForAccount (解析为具体值) ===\n");
893+
894+
// 11a: option_overlay_enabled: true → enabled
895+
{
896+
const state = { currentStrategies: makeCurrentStrategies("ibkr", { extra: { option_overlay_enabled: true } }) };
897+
const form = { optionOverlayMode: "current", optionOverlayTouched: false, strategy: "some_profile" };
898+
syncOptionOverlayForAccount(state, form, "ibkr", makeAccount("preview"));
899+
assert(form.optionOverlayMode === "enabled", "11a: option overlay enabled → 'enabled'");
900+
}
901+
902+
// 11b: option_overlay_enabled: false → disabled
903+
{
904+
const state = { currentStrategies: makeCurrentStrategies("ibkr", { extra: { option_overlay_enabled: false } }) };
905+
const form = { optionOverlayMode: "current", optionOverlayTouched: false, strategy: "some_profile" };
906+
syncOptionOverlayForAccount(state, form, "ibkr", makeAccount("preview"));
907+
assert(form.optionOverlayMode === "disabled", "11b: option overlay disabled → 'disabled'");
908+
}
909+
910+
// 11c: no entry + supported → enabled (default)
911+
{
912+
const state = { currentStrategies: {} };
913+
const form = { optionOverlayMode: "current", optionOverlayTouched: false, strategy: "some_profile" };
914+
syncOptionOverlayForAccount(state, form, "ibkr", makeAccount("preview"));
915+
assert(form.optionOverlayMode === "enabled", "11c: no entry + supported → 'enabled'");
916+
}
917+
918+
// 11d: not supported → disabled
919+
{
920+
const state = { currentStrategies: makeCurrentStrategies("ibkr", { extra: { option_overlay_enabled: true } }) };
921+
const form = { optionOverlayMode: "current", optionOverlayTouched: false, strategy: "no_overlay" };
922+
syncOptionOverlayForAccount(state, form, "ibkr", makeAccount("preview"));
923+
assert(form.optionOverlayMode === "disabled", "11d: not supported → 'disabled'");
924+
}
925+
926+
// 11e: account has explicit mode → use it
927+
{
928+
const form = { optionOverlayMode: "current", optionOverlayTouched: false, strategy: "some_profile" };
929+
syncOptionOverlayForAccount({}, form, "ibkr", { key: "preview", option_overlay_mode: "disabled" });
930+
assert(form.optionOverlayMode === "disabled", "11e: account explicit 'disabled' → 'disabled'");
931+
}
932+
933+
// ============================================================
934+
// 12. 互斥 UI 不再禁用选项 (回归测试)
935+
// ============================================================
936+
937+
console.log("\n=== 12. 互斥 UI 不再禁用选项 ===\n");
938+
939+
// 12a: 预留现金覆盖活跃时,select cash-only=disabled 不应因 option.disabled 被阻挡
940+
// (此测试验证 reconcileExecutionCashPolicy 可处理用户选择,无需前置禁用)
941+
{
942+
const form = {
943+
cashOnlyExecutionMode: "enabled", // 当前:不允许融资
944+
reservePolicyMode: "ratio",
945+
minReservedCashUsd: "",
946+
reservedCashRatio: "0.05",
947+
reservedCashTouched: true,
948+
cashOnlyExecutionTouched: false,
949+
};
950+
// 用户选择"允许融资: 是"
951+
form.cashOnlyExecutionMode = "disabled";
952+
form.cashOnlyExecutionTouched = true;
953+
reconcileExecutionCashPolicy(form, "margin");
954+
// 预留现金应被清除
955+
assert(form.reservePolicyMode === "none", "12a: margin=yes → reserve cleared");
956+
assert(form.cashOnlyExecutionMode === "disabled", "12a: margin stays 'disabled'");
957+
// 不再冲突
958+
assert(executionCashPolicyConflict(form) === false, "12a: no conflict after reconciliation");
959+
}
960+
961+
// 12b: margin 启用时,reserve 下拉框不应 disabled,用户可选 ratio
962+
{
963+
const form = {
964+
cashOnlyExecutionMode: "disabled", // margin enabled
965+
reservePolicyMode: "none",
966+
minReservedCashUsd: "",
967+
reservedCashRatio: "",
968+
reservedCashTouched: false,
969+
cashOnlyExecutionTouched: true,
970+
};
971+
// 用户选择预留现金策略: ratio
972+
form.reservePolicyMode = "ratio";
973+
form.reservedCashRatio = "0.03";
974+
form.reservedCashTouched = true;
975+
reconcileExecutionCashPolicy(form, "reserve");
976+
// 融资应被禁用
977+
assert(form.cashOnlyExecutionMode === "enabled", "12b: reserve=ratio → margin disabled");
978+
assert(form.reservePolicyMode === "ratio", "12b: reserve stays 'ratio'");
979+
assert(executionCashPolicyConflict(form) === false, "12b: no conflict after reconciliation");
980+
}
981+
708982
// ============================================================
709983
summary();

web/strategy-switch-console/index.html

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3133,7 +3133,9 @@ <h2 data-i18n="summary">切换摘要</h2>
31333133
function syncRuntimeTargetForAccount(platform) {
31343134
const form = state.forms[platform];
31353135
if (!form || form.runtimeTargetTouched) return;
3136-
form.runtimeTargetMode = "current";
3136+
// Resolve to a concrete value so the UI shows enabled/disabled directly
3137+
const target = runtimeTargetStateForAccount(platform, selectedAccount(platform));
3138+
form.runtimeTargetMode = target.known ? (target.enabled ? "enabled" : "disabled") : "current";
31373139
}
31383140

31393141
function syncReservePolicyForAccount(platform) {
@@ -3161,9 +3163,17 @@ <h2 data-i18n="summary">切换摘要</h2>
31613163
if (!form || form.incomeLayerTouched) return;
31623164
const defaults = incomeLayerDefaultForStrategy(form.strategy);
31633165
const current = currentIncomeLayerForAccount(platform, selectedAccount(platform));
3164-
form.incomeLayerMode = "current";
31653166
form.incomeLayerStartUsd = current.startUsd || String(defaults?.startUsd || "");
31663167
form.incomeLayerMaxRatio = current.maxRatio || defaults?.maxRatio || "";
3168+
// Resolve to a concrete mode so the UI shows enabled/disabled directly
3169+
const effective = effectiveIncomeLayerForAccount(platform, selectedAccount(platform), form.strategy);
3170+
if (effective) {
3171+
form.incomeLayerMode = effective.enabled ? "enabled" : "disabled";
3172+
} else if (defaults) {
3173+
form.incomeLayerMode = "enabled";
3174+
} else {
3175+
form.incomeLayerMode = "current";
3176+
}
31673177
}
31683178

31693179
function syncOptionOverlayForAccount(platform) {
@@ -3175,7 +3185,17 @@ <h2 data-i18n="summary">切换摘要</h2>
31753185
form.optionOverlayMode = configured;
31763186
return;
31773187
}
3178-
form.optionOverlayMode = optionOverlaySupported(form.strategy) ? "current" : "disabled";
3188+
// Resolve to a concrete mode so the UI shows enabled/disabled directly
3189+
if (!optionOverlaySupported(form.strategy)) {
3190+
form.optionOverlayMode = "disabled";
3191+
return;
3192+
}
3193+
const effective = effectiveOptionOverlayForAccount(platform, account, form.strategy);
3194+
if (effective !== null && effective !== undefined) {
3195+
form.optionOverlayMode = effective ? "enabled" : "disabled";
3196+
} else {
3197+
form.optionOverlayMode = "enabled";
3198+
}
31793199
}
31803200

31813201
function syncCashOnlyExecutionForAccount(platform) {
@@ -4171,9 +4191,8 @@ <h2 data-i18n="summary">切换摘要</h2>
41714191
"{currency}",
41724192
selectedCashCurrency(platform, account),
41734193
);
4174-
reservePolicyModeSelect.disabled = marginBlocksReserve;
4175-
minReservedCashInput.disabled = marginBlocksReserve || reserveMode === "current" || reserveMode === "none" || reserveMode === "ratio";
4176-
reservedCashRatioInput.disabled = marginBlocksReserve || reserveMode === "current" || reserveMode === "none" || reserveMode === "floor";
4194+
minReservedCashInput.disabled = reserveMode === "current" || reserveMode === "none" || reserveMode === "ratio";
4195+
reservedCashRatioInput.disabled = reserveMode === "current" || reserveMode === "none" || reserveMode === "floor";
41774196
minReservedCashInput.value = reserveMode === "ratio" || reserveMode === "none" ? "" : form.minReservedCashUsd;
41784197
reservedCashRatioInput.value = reserveMode === "floor" || reserveMode === "none" ? "" : form.reservedCashRatio;
41794198
el("reserve-policy-block").classList.toggle("policy-block-muted", marginBlocksReserve);
@@ -4226,7 +4245,6 @@ <h2 data-i18n="summary">切换摘要</h2>
42264245
false,
42274246
mode === normalizeCashOnlyExecutionMode(form.cashOnlyExecutionMode),
42284247
);
4229-
if (mode === "disabled" && reserveBlocksMargin) option.disabled = true;
42304248
cashOnlyExecutionModeSelect.append(option);
42314249
}
42324250
el("cash-only-policy-block").classList.toggle("policy-block-muted", reserveBlocksMargin);

web/strategy-switch-console/page_asset.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)