Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 4 additions & 12 deletions platform-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,7 @@
],
"cash_currency": "USD",
"default_execution_mode": "live",
"default_strategy_profile": "tqqq_growth_income",
"reserved_cash_ratio": "0.03",
"min_reserved_cash_usd": ""
"default_strategy_profile": "tqqq_growth_income"
},
"deployment": {
"default_execution_mode": "live",
Expand Down Expand Up @@ -94,9 +92,7 @@
],
"cash_currency": "USD",
"default_execution_mode": "live",
"default_strategy_profile": "soxl_soxx_trend_income",
"reserved_cash_ratio": "0.03",
"min_reserved_cash_usd": ""
"default_strategy_profile": "soxl_soxx_trend_income"
},
"deployment": {
"default_execution_mode": "live",
Expand Down Expand Up @@ -134,9 +130,7 @@
],
"cash_currency": "USD",
"default_execution_mode": "live",
"default_strategy_profile": "soxl_soxx_trend_income",
"reserved_cash_ratio": "0.03",
"min_reserved_cash_usd": "150"
"default_strategy_profile": "soxl_soxx_trend_income"
},
"deployment": {
"default_execution_mode": "live",
Expand Down Expand Up @@ -174,9 +168,7 @@
],
"cash_currency": "USD",
"default_execution_mode": "live",
"default_strategy_profile": "ibit_smart_dca",
"reserved_cash_ratio": "0.03",
"min_reserved_cash_usd": ""
"default_strategy_profile": "ibit_smart_dca"
},
"deployment": {
"default_execution_mode": "live",
Expand Down
47 changes: 47 additions & 0 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";

import worker, { __test } from "../web/strategy-switch-console/worker.js";
import { DEFAULT_ACCOUNT_OPTIONS } from "../web/strategy-switch-console/config.js";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const indexHtml = [
Expand Down Expand Up @@ -67,6 +68,7 @@ assert.ok(indexHtml.includes('optionOverlayDefaultSimple: "开启"'));
assert.ok(indexHtml.includes('cashOnlyExecutionDefault: "仅用现金"'));
assert.match(indexHtml, /function platformCashOnlyExecutionDefault\(\) \{\s+return true;/);
assert.ok(indexHtml.includes("function effectiveOptionOverlayForAccount("));
assert.ok(indexHtml.includes("selectedAccount(platform)?.option_overlay_mode"));
assert.ok(indexHtml.includes("function effectiveCashOnlyExecutionForAccount("));
assert.ok(indexHtml.includes('cashOnlyExecutionValueYes: "是"'));
assert.ok(indexHtml.includes('cashOnlyExecutionMode: "Allow margin"'));
Expand Down Expand Up @@ -406,6 +408,12 @@ assert.deepEqual(accountOptions.longbridge[0].supported_domains, ["us_equity", "
assert.deepEqual(accountOptions.longbridge[1].supported_domains, ["us_equity", "hk_equity"]);
assert.deepEqual(accountOptions.ibkr[0].supported_domains, ["us_equity", "hk_equity"]);
assert.equal(accountOptions.longbridge[0].cash_currency, "HKD");
for (const platformOptions of Object.values(DEFAULT_ACCOUNT_OPTIONS)) {
for (const option of platformOptions) {
assert.equal("reserved_cash_ratio" in option, false);
assert.equal("min_reserved_cash_usd" in option, false);
}
}

const accountOptionsWithCashOnlyMode = __test.normalizeAccountOptionsPayload(
{
Expand All @@ -422,6 +430,21 @@ const accountOptionsWithCashOnlyMode = __test.normalizeAccountOptionsPayload(
);
assert.equal(accountOptionsWithCashOnlyMode.longbridge[0].cash_only_execution_mode, "enabled");

const accountOptionsWithOptionOverlayMode = __test.normalizeAccountOptionsPayload(
{
ibkr: [
{
key: "ibkr-primary",
label: "ibkr-primary",
target_name: "ibkr-primary",
option_overlay_mode: "disabled",
},
],
},
"test_account_options",
);
assert.equal(accountOptionsWithOptionOverlayMode.ibkr[0].option_overlay_mode, "disabled");

const kvUnboundSyncResponse = await worker.fetch(
new Request("https://switch.example/api/internal/sync-account-default", {
method: "POST",
Expand Down Expand Up @@ -1115,6 +1138,30 @@ try {
globalThis.fetch = originalFetch;
}

globalThis.fetch = async (url) => {
const requestUrl = String(url);
if (requestUrl.endsWith("/CLOUD_RUN_SERVICE_TARGETS_JSON")) {
return new Response("", { status: 404 });
}
if (requestUrl.endsWith("/IBKR_CASH_ONLY_EXECUTION")) {
return new Response(JSON.stringify({ value: "false" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("", { status: 404 });
};
try {
const currentStrategies = await __test.loadCurrentStrategies(
{ ibkr: accountOptions.ibkr },
{ RUNTIME_SETTINGS_DISPATCH_TOKEN: "test-token" },
);
assert.equal(currentStrategies.ibkr["ibkr-primary"].cash_only_execution, false);
assert.equal(currentStrategies.ibkr["ibkr-primary"].source, "CASH_ONLY_EXECUTION_VARIABLE");
} finally {
globalThis.fetch = originalFetch;
}

globalThis.fetch = async (url) => {
const requestUrl = String(url);
if (requestUrl.includes("/LongBridgePlatform/actions/variables/CLOUD_RUN_SERVICE_TARGETS_JSON")) {
Expand Down
40 changes: 40 additions & 0 deletions tests/test_cash_financing.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,16 @@ function enforceMutualExclusionAfterSync(form) {
}
}

function executionCashControlDisabledState(form) {
const reserveMode = normalizeReservePolicyMode(form?.reservePolicyMode);
return {
reservePolicyModeDisabled: false,
minReservedCashDisabled: reserveMode === "current" || reserveMode === "none" || reserveMode === "ratio",
reservedCashRatioDisabled: reserveMode === "current" || reserveMode === "none" || reserveMode === "floor",
allowMarginYesDisabled: false,
};
}

// --- 表单工厂 ---
function defaultReserveForm() {
return {
Expand Down Expand Up @@ -835,6 +845,23 @@ console.log("\n=== 15. syncStrategyForAccount 初始互斥纠偏 ===\n");
assert(form.reservedCashRatio === "0.05", "15b: reserve ratio preserved");
}

// 15c: 账号配置指定允许融资 + 当前只读到预留现金时,也应以融资为准清空预留
{
const state = {
currentStrategies: {
ibkr: {
soxl: {
reserved_cash_ratio: "0.03",
},
},
},
};
const form = defaultReserveForm();
syncStrategyForAccount(state, form, "ibkr", makeAccount("soxl", "disabled"));
assert(form.cashOnlyExecutionMode === "disabled", "15c: account mode disabled → allow margin");
assert(form.reservePolicyMode === "none", "15c: account allow-margin clears current reserve ratio");
}

// ============================================================
// 9. syncRuntimeTargetForAccount (解析为具体值)
// ============================================================
Expand Down Expand Up @@ -1329,6 +1356,19 @@ console.log("\n=== 13. 互斥 UI 不再禁用选项 ===\n");
assert(executionCashPolicyConflict(form) === false, "13b: no conflict after reconciliation");
}

// 13c: 即使进入初始冲突态,也不应形成两个选择器互相禁用的 UI 死锁
{
const form = {
cashOnlyExecutionMode: "disabled",
reservePolicyMode: "ratio",
minReservedCashUsd: "",
reservedCashRatio: "0.03",
};
const disabled = executionCashControlDisabledState(form);
assert(disabled.reservePolicyModeDisabled === false, "13c: reserve policy select remains usable");
assert(disabled.allowMarginYesDisabled === false, "13c: allow-margin yes option remains usable");
}

// ============================================================
// 14. 融资切换 save/restore 预留现金配置
// ============================================================
Expand Down
55 changes: 40 additions & 15 deletions web/strategy-switch-console/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,8 @@
pendingCashOnlyExecution: "待提交允许融资",
executionCashPolicyTitle: "现金与融资",
executionCashPolicyNote: "允许融资与预留现金覆盖不能同时生效;选「是」会清空预留覆盖,设预留覆盖会强制「否」。",
executionCashMarginBlocksReserve: "已选允许融资,预留现金覆盖已禁用。",
executionCashReserveBlocksMargin: "已设预留现金覆盖,不能选允许融资。",
executionCashMarginBlocksReserve: "已选允许融资;提交时会清空预留现金覆盖。",
executionCashReserveBlocksMargin: "已设预留现金覆盖;提交时会强制不允许融资。",
qmtPlatformCashNote: "A 股 QMT 不使用 margin / 平台预留现金;现金约束在策略参数 execution_cash_reserve_ratio 内配置。",
qmtDryRunOnlyNote: "QMT 当前仅支持 dry-run(模拟),尚无 live 券商账号。",
binancePlatformNote: "Binance 平台不使用券商级收入层与期权层;相关功能由策略内部实现。",
Expand Down Expand Up @@ -505,8 +505,8 @@
pendingCashOnlyExecution: "Pending allow margin",
executionCashPolicyTitle: "Cash and margin",
executionCashPolicyNote: "Allow margin and reserve-cash overrides cannot both apply. Yes clears reserve overrides; reserve overrides force No.",
executionCashMarginBlocksReserve: "Allow margin is selected; reserve-cash overrides are disabled.",
executionCashReserveBlocksMargin: "Reserve-cash override is active; allow margin Yes is disabled.",
executionCashMarginBlocksReserve: "Allow margin is selected; submitting will clear reserve-cash overrides.",
executionCashReserveBlocksMargin: "Reserve-cash override is active; submitting will force allow margin to No.",
qmtPlatformCashNote: "QMT A-share does not use margin or platform reserve cash; cash constraints live in strategy execution_cash_reserve_ratio.",
qmtDryRunOnlyNote: "QMT is dry-run only for now; no live broker accounts are configured.",
binancePlatformNote: "Binance does not use broker-level income/option layers; features are implemented inside strategies.",
Expand Down Expand Up @@ -750,18 +750,32 @@
}

function reconcileExecutionCashPolicy(form, changed) {
if (!form || !executionCashPolicyConflict(form)) return;
if (changed === "margin") {
if (!form) return;
if (changed === "margin" && allowMarginExplicitlySelected(form)) {
if (form.reservePolicyMode !== "none" && (form.minReservedCashUsd || form.reservedCashRatio)) {
form._prevReserve = {
mode: form.reservePolicyMode,
floor: form.minReservedCashUsd,
ratio: form.reservedCashRatio,
};
}
form.reservePolicyMode = "none";
form.reservedCashTouched = true;
form.minReservedCashUsd = "";
form.reservedCashRatio = "";
} else if (changed === "reserve") {
} else if (changed === "reserve" && reserveCashOverrideActive(form)) {
form.cashOnlyExecutionMode = "enabled";
form.cashOnlyExecutionTouched = true;
}
}

function restoreReserveAfterMarginDisabled(form) {
if (!form || allowMarginExplicitlySelected(form) || !form._prevReserve) return;
form.reservePolicyMode = form._prevReserve.mode;
form.minReservedCashUsd = form._prevReserve.floor;
form.reservedCashRatio = form._prevReserve.ratio;
form.reservedCashTouched = true;
delete form._prevReserve;
Comment on lines +771 to +776

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear stale saved reserve before restoring it

After a reserve policy is saved in _prevReserve by toggling allow-margin on, that field is not cleared when the user later changes accounts or explicitly changes the reserve policy to none. In those flows this restore path can resurrect the previous account's or previously-cleared reserve settings the next time allow-margin is toggled off, causing a dispatch with an unexpected reserve-cash override.

Useful? React with 👍 / 👎.

}

function strategyDomain(profile) {
return strategyCatalog[profile]?.domain || "";
}
Expand Down Expand Up @@ -1491,6 +1505,11 @@
function syncOptionOverlayForAccount(platform) {
const form = state.forms[platform];
if (!form || form.optionOverlayTouched) return;
const configured = normalizeOptionOverlayMode(selectedAccount(platform)?.option_overlay_mode);
if (configured !== "current") {
form.optionOverlayMode = configured;
return;
Comment on lines +1509 to +1511

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate saved option overlays by strategy support

When an account has option_overlay_mode: "enabled" saved and the user switches that account to a strategy without option overlay defaults, such as ibit_smart_dca, this early return keeps form.optionOverlayMode as enabled. The render path then disables the option-overlay select for unsupported strategies, while hasValidOptionOverlayPolicy() rejects enabled on unsupported strategies, leaving the dispatch button disabled with no way for the user to clear the saved mode from the UI.

Useful? React with 👍 / 👎.

}
const entry = currentEntryForAccount(platform, selectedAccount(platform));
const rawValue = cleanOptionalBoolean(entry?.option_overlay_enabled);
if (rawValue !== null) {
Expand All @@ -1503,6 +1522,11 @@
function syncCashOnlyExecutionForAccount(platform) {
const form = state.forms[platform];
if (!form || form.cashOnlyExecutionTouched) return;
const configured = normalizeCashOnlyExecutionMode(selectedAccount(platform)?.cash_only_execution_mode);
if (configured !== "current") {
form.cashOnlyExecutionMode = configured;
return;
}
const entry = currentEntryForAccount(platform, selectedAccount(platform));
const rawValue = cleanOptionalBoolean(entry?.cash_only_execution);
if (rawValue !== null) {
Expand Down Expand Up @@ -2344,6 +2368,8 @@
}
const supportsMargin = platformSupportsMarginPolicy(platform);
const supportsReserve = platformSupportsReservedCashPolicy(platform);
if (supportsMargin) syncCashOnlyExecutionForAccount(platform);
reconcileExecutionCashPolicy(form, "margin");
const executionCashPolicyGrid = el("execution-cash-policy-grid");
const qmtPlatformCashNote = el("qmt-platform-cash-note");
const executionCashPolicyNote = el("execution-cash-policy-note");
Expand All @@ -2364,9 +2390,9 @@
"{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";
reservePolicyModeSelect.disabled = false;
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);
Expand Down Expand Up @@ -2410,7 +2436,6 @@
}

if (supportsMargin) {
syncCashOnlyExecutionForAccount(platform);
cashOnlyExecutionModeSelect.replaceChildren();
for (const mode of cashOnlyExecutionModes) {
const option = new Option(
Expand All @@ -2419,7 +2444,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);
Expand Down Expand Up @@ -2887,7 +2911,8 @@
const form = state.forms[state.selected];
form.cashOnlyExecutionMode = normalizeCashOnlyExecutionMode(el("cash-only-execution-mode-select").value);
form.cashOnlyExecutionTouched = form.cashOnlyExecutionMode !== "current";
reconcileExecutionCashPolicy(form, "margin");
if (allowMarginExplicitlySelected(form)) reconcileExecutionCashPolicy(form, "margin");
else restoreReserveAfterMarginDisabled(form);
render();
});

Expand Down
2 changes: 1 addition & 1 deletion web/strategy-switch-console/app_js.js

Large diffs are not rendered by default.

8 changes: 0 additions & 8 deletions web/strategy-switch-console/config.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading