diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 1611ca4..64a5748 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -19,6 +19,8 @@ assert.ok(indexHtml.includes('switchSurface.classList.toggle("summary-hidden", ! assert.equal(indexHtml.includes("publicSummary"), false); assert.ok(indexHtml.includes("function hasPrivateConfig()")); assert.ok(indexHtml.includes('el("quick-form").hidden = !showPrivateControls')); +assert.ok(indexHtml.includes('id="min-reserved-cash-input"')); +assert.ok(indexHtml.includes('id="reserved-cash-ratio-input"')); assert.equal(indexHtml.includes("ibkr-primary"), false); assert.equal(indexHtml.includes("longbridge-quant-sg-service"), false); assert.equal(indexHtml.includes('account_selector: "SG"'), false); @@ -177,6 +179,32 @@ 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"]); +const normalizedReservedCashInputs = __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibkr-primary", + strategy_profile: "tqqq_growth_income", + execution_mode: "live", + account_selector: "DEMO_IBKR_PRIMARY", + deployment_selector: "demo-ibkr-tqqq", + account_scope: "demo-ibkr-tqqq", + service_name: "interactive-brokers-demo-ibkr-tqqq-service", + apply: "true", + trigger_platform_sync: "true", + reserved_cash_ratio: "0.03", + min_reserved_cash_usd: "150", +}); +assert.equal(normalizedReservedCashInputs.reserved_cash_ratio, "0.03"); +assert.equal(normalizedReservedCashInputs.min_reserved_cash_usd, "150"); +assert.throws( + () => __test.normalizeSwitchInputs({ + platform: "ibkr", + target_name: "ibkr-primary", + strategy_profile: "tqqq_growth_income", + reserved_cash_ratio: "1.25", + }), + /reserved_cash_ratio must be between 0 and 1/, +); + const updatedAccountOptions = __test.updateAccountOptionsDefaultStrategy( accountOptions, { diff --git a/web/strategy-switch-console/README.md b/web/strategy-switch-console/README.md index 185f994..56bb2bb 100644 --- a/web/strategy-switch-console/README.md +++ b/web/strategy-switch-console/README.md @@ -117,6 +117,8 @@ The Worker validates dispatch inputs against this config, including whether the For signed-in users, `/api/config` also reads the target repositories' current GitHub Variables. It prefers account-specific `CLOUD_RUN_SERVICE_TARGETS_JSON`, then matching `RUNTIME_TARGET_JSON.strategy_profile`, then `STRATEGY_PROFILE`; if none can be read safely, the page falls back to `default_strategy_profile`. +The switch form also accepts optional reserved-cash overrides: minimum reserved cash in USD and reserved-cash ratio. Leave them blank to keep the platform's existing defaults. When set, the Worker passes them to `manual-strategy-switch.yml`, which writes the platform-specific variables such as `IBKR_MIN_RESERVED_CASH_USD` and `IBKR_RESERVED_CASH_RATIO`. + 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 aa1ee8f..1c3fab3 100644 --- a/web/strategy-switch-console/README.zh-CN.md +++ b/web/strategy-switch-console/README.zh-CN.md @@ -124,6 +124,8 @@ Worker 会校验 dispatch 参数必须匹配这里的某个账号项,也会校 登录用户访问 `/api/config` 时,Worker 还会读取目标平台仓库的当前 GitHub Variables。读取优先级是账号匹配的 `CLOUD_RUN_SERVICE_TARGETS_JSON`、匹配的 `RUNTIME_TARGET_JSON.strategy_profile`、`STRATEGY_PROFILE`;都读不到时,页面才回退到 `default_strategy_profile`。 +切换表单也支持可选的预留现金覆盖项:最小预留现金 USD 和预留现金比例。留空表示沿用平台现有默认值。填写后,Worker 会把它们传给 `manual-strategy-switch.yml`,由 workflow 写入平台对应变量,例如 `IBKR_MIN_RESERVED_CASH_USD` 和 `IBKR_RESERVED_CASH_RATIO`。 + 策略切换成功后也会把当前账号的 `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 7357709..b5e73b8 100644 --- a/web/strategy-switch-console/index.html +++ b/web/strategy-switch-console/index.html @@ -45,7 +45,8 @@ } button, - select { + select, + input { font: inherit; letter-spacing: 0; } @@ -60,6 +61,7 @@ button:focus-visible, select:focus-visible, + input:focus-visible, a:focus-visible { outline: 3px solid color-mix(in srgb, var(--platform-color, var(--accent)) 24%, transparent); outline-offset: 2px; @@ -417,7 +419,8 @@ font-weight: 760; } - select { + select, + input { width: 100%; min-width: 0; height: 48px; @@ -435,11 +438,17 @@ transition: border-color 150ms ease, box-shadow 150ms ease; } - select:focus { + select:focus, + input:focus { border-color: var(--platform-color, var(--accent)); box-shadow: 0 0 0 3px rgba(19, 111, 99, 0.12); } + input::placeholder { + color: #9aa3ad; + opacity: 1; + } + .selection-meta { min-height: 20px; color: var(--muted); @@ -734,6 +743,18 @@

LongBridge

+ + + +
@@ -823,6 +844,10 @@

切换摘要

mode: "模式", live: "实盘", paper: "Dry run", + minReservedCash: "最小预留现金", + reservedCashRatio: "预留现金比例", + reservedCashMeta: "留空则沿用当前平台默认值。", + reservedCashRatioMeta: "例如 0.03 表示 3%。", summary: "当前配置状态", copySummary: "复制状态", loginToRun: "登录后切换", @@ -840,6 +865,8 @@

切换摘要

repository: "平台仓库", selectedAccount: "账号", selectedMarket: "市场", + reservedCashPolicy: "预留现金", + unchanged: "不变", copied: "已复制状态", dispatching: "正在触发 workflow...", dispatched: "已触发 workflow", @@ -871,6 +898,10 @@

切换摘要

mode: "Mode", live: "Live", paper: "Dry run", + minReservedCash: "Minimum reserved cash", + reservedCashRatio: "Reserved cash ratio", + reservedCashMeta: "Leave blank to keep the platform default.", + reservedCashRatioMeta: "Use 0.03 for 3%.", summary: "Current Config", copySummary: "Copy state", loginToRun: "Sign in to switch", @@ -888,6 +919,8 @@

切换摘要

repository: "Repository", selectedAccount: "Account", selectedMarket: "Market", + reservedCashPolicy: "Reserved cash", + unchanged: "Unchanged", copied: "State copied", dispatching: "Dispatching workflow...", dispatched: "Workflow dispatched", @@ -907,6 +940,7 @@

切换摘要

? storedLang : ((navigator.language || "").toLowerCase().startsWith("zh") ? "zh" : "en"); const clone = (value) => JSON.parse(JSON.stringify(value)); + const defaultReserveForm = () => ({ minReservedCashUsd: "", reservedCashRatio: "" }); const state = { selected: "longbridge", @@ -919,10 +953,10 @@

切换摘要

configSource: "default", repositories: clone(defaultRepositories), forms: { - longbridge: { accountKey: "preview", strategy: "", executionMode: "live" }, - ibkr: { accountKey: "preview", strategy: "", executionMode: "live" }, - schwab: { accountKey: "preview", strategy: "", executionMode: "live" }, - firstrade: { accountKey: "preview", strategy: "", executionMode: "live" }, + longbridge: { accountKey: "preview", strategy: "", executionMode: "live", ...defaultReserveForm() }, + ibkr: { accountKey: "preview", strategy: "", executionMode: "live", ...defaultReserveForm() }, + schwab: { accountKey: "preview", strategy: "", executionMode: "live", ...defaultReserveForm() }, + firstrade: { accountKey: "preview", strategy: "", executionMode: "live", ...defaultReserveForm() }, }, }; @@ -1173,9 +1207,26 @@

切换摘要

]) { if (account[field]) inputs[field] = account[field]; } + if (form.reservedCashRatio) inputs.reserved_cash_ratio = form.reservedCashRatio; + if (form.minReservedCashUsd) inputs.min_reserved_cash_usd = form.minReservedCashUsd; return inputs; } + function reservedCashPolicyText(inputs) { + const floor = inputs.min_reserved_cash_usd; + const ratio = inputs.reserved_cash_ratio; + if (!floor && !ratio) return t("unchanged"); + if (floor && ratio) return `max($${floor}, ${formatRatioPercent(ratio)})`; + if (floor) return `$${floor}`; + return formatRatioPercent(ratio); + } + + function formatRatioPercent(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return String(value); + return `${(numeric * 100).toFixed(2).replace(/\.?0+$/, "")}%`; + } + function summaryRows(inputs) { const account = selectedAccount(); const currentProfile = currentStrategyForAccount(state.selected, account); @@ -1183,6 +1234,7 @@

切换摘要

[t("repository"), state.repositories[state.selected] || defaultRepositories[state.selected]], [t("selectedAccount"), account.label], [t("selectedMarket"), supportedDomainLabel(state.selected, account)], + [t("reservedCashPolicy"), reservedCashPolicyText(inputs)], [t("nextStrategy"), strategyLabel(inputs.strategy_profile)], ]; if (state.auth.allowed) { @@ -1242,6 +1294,8 @@

切换摘要

const choices = strategyChoicesForAccount(platform, account); const accountSelect = el("account-select"); const strategySelect = el("strategy-select"); + const minReservedCashInput = el("min-reserved-cash-input"); + const reservedCashRatioInput = el("reserved-cash-ratio-input"); const showPrivateControls = hasPrivateConfig(); el("switch-panel").style.setProperty("--platform-color", meta.accent); @@ -1254,6 +1308,8 @@

切换摘要

if (!showPrivateControls) { accountSelect.replaceChildren(); strategySelect.replaceChildren(); + minReservedCashInput.value = ""; + reservedCashRatioInput.value = ""; el("account-meta").textContent = ""; el("strategy-meta").textContent = ""; return; @@ -1284,6 +1340,8 @@

切换摘要

el("strategy-meta").textContent = account ? t("strategyMeta").replace("{domains}", supportedDomainLabel(platform, account)) : ""; + minReservedCashInput.value = form.minReservedCashUsd; + reservedCashRatioInput.value = form.reservedCashRatio; document.querySelectorAll("[data-mode]").forEach((button) => { button.classList.toggle("active", button.dataset.mode === form.executionMode); @@ -1555,6 +1613,16 @@

切换摘要

render(); }); + el("min-reserved-cash-input").addEventListener("input", () => { + state.forms[state.selected].minReservedCashUsd = el("min-reserved-cash-input").value.trim(); + render(); + }); + + el("reserved-cash-ratio-input").addEventListener("input", () => { + state.forms[state.selected].reservedCashRatio = el("reserved-cash-ratio-input").value.trim(); + render(); + }); + el("copy-button").addEventListener("click", async () => { try { await navigator.clipboard.writeText(summaryText()); diff --git a/web/strategy-switch-console/page_asset.js b/web/strategy-switch-console/page_asset.js index 94d5837..a0123c7 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

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"; +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

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"; diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 609bae3..61d6960 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -747,6 +747,8 @@ function normalizeSwitchInputs(raw) { addOptional(inputs, "account_scope", raw.account_scope, cleanSlug); addOptional(inputs, "service_name", raw.service_name, cleanSlug); addOptional(inputs, "custom_plugin_mounts_json", raw.custom_plugin_mounts_json, cleanJson); + addOptional(inputs, "reserved_cash_ratio", raw.reserved_cash_ratio, cleanRatio); + addOptional(inputs, "min_reserved_cash_usd", raw.min_reserved_cash_usd, cleanNonNegativeNumber); if (extraVariablesJson) inputs.extra_variables_json = extraVariablesJson; return inputs; } @@ -1038,6 +1040,29 @@ function cleanBoolean(value) { throw new Error("boolean input is invalid"); } +function cleanRatio(value, field) { + const text = cleanNumberText(value, field); + const numeric = Number(text); + if (numeric < 0 || numeric > 1) throw new Error(`${field} must be between 0 and 1`); + return text; +} + +function cleanNonNegativeNumber(value, field) { + const text = cleanNumberText(value, field); + if (Number(text) < 0) throw new Error(`${field} must be non-negative`); + return text; +} + +function cleanNumberText(value, field) { + const text = String(value || "").trim(); + if (!text || text.length > 32 || !/^(?:\d+|\d*\.\d+)$/.test(text)) { + throw new Error(`${field} must be a finite decimal number`); + } + const numeric = Number(text); + if (!Number.isFinite(numeric)) throw new Error(`${field} must be finite`); + return text; +} + function cleanSlug(value, field) { const text = String(value || "").trim(); if (!text || text.length > 120 || !/^[A-Za-z0-9._=-]+$/.test(text)) { @@ -1674,6 +1699,7 @@ export const __test = { assertStrategyAllowedForAccount, inferAccountSupportedDomains, loadCurrentStrategies, + normalizeSwitchInputs, normalizeAccountOptionsPayload, normalizeStrategyProfilesPayload, platformRepositories,