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
28 changes: 28 additions & 0 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
{
Expand Down
2 changes: 2 additions & 0 deletions web/strategy-switch-console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions web/strategy-switch-console/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 对齐规范
Expand Down
82 changes: 75 additions & 7 deletions web/strategy-switch-console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
}

button,
select {
select,
input {
font: inherit;
letter-spacing: 0;
}
Expand All @@ -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;
Expand Down Expand Up @@ -417,7 +419,8 @@
font-weight: 760;
}

select {
select,
input {
width: 100%;
min-width: 0;
height: 48px;
Expand All @@ -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);
Expand Down Expand Up @@ -734,6 +743,18 @@ <h2 id="platform-title">LongBridge</h2>
<button type="button" data-mode="paper" data-i18n="paper">模拟</button>
</div>
</div>

<label class="control-block">
<span data-i18n="minReservedCash">最小预留现金</span>
<input id="min-reserved-cash-input" type="number" inputmode="decimal" min="0" step="1" placeholder="150">
<span class="selection-meta" data-i18n="reservedCashMeta">留空则沿用当前平台默认值。</span>
</label>

<label class="control-block">
<span data-i18n="reservedCashRatio">预留现金比例</span>
<input id="reserved-cash-ratio-input" type="number" inputmode="decimal" min="0" max="1" step="0.01" placeholder="0.03">
<span class="selection-meta" data-i18n="reservedCashRatioMeta">例如 0.03 表示 3%。</span>
</label>
</div>

<div class="run-area" id="run-area">
Expand Down Expand Up @@ -823,6 +844,10 @@ <h2 data-i18n="summary">切换摘要</h2>
mode: "模式",
live: "实盘",
paper: "Dry run",
minReservedCash: "最小预留现金",
reservedCashRatio: "预留现金比例",
reservedCashMeta: "留空则沿用当前平台默认值。",
reservedCashRatioMeta: "例如 0.03 表示 3%。",
summary: "当前配置状态",
copySummary: "复制状态",
loginToRun: "登录后切换",
Expand All @@ -840,6 +865,8 @@ <h2 data-i18n="summary">切换摘要</h2>
repository: "平台仓库",
selectedAccount: "账号",
selectedMarket: "市场",
reservedCashPolicy: "预留现金",
unchanged: "不变",
copied: "已复制状态",
dispatching: "正在触发 workflow...",
dispatched: "已触发 workflow",
Expand Down Expand Up @@ -871,6 +898,10 @@ <h2 data-i18n="summary">切换摘要</h2>
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",
Expand All @@ -888,6 +919,8 @@ <h2 data-i18n="summary">切换摘要</h2>
repository: "Repository",
selectedAccount: "Account",
selectedMarket: "Market",
reservedCashPolicy: "Reserved cash",
unchanged: "Unchanged",
copied: "State copied",
dispatching: "Dispatching workflow...",
dispatched: "Workflow dispatched",
Expand All @@ -907,6 +940,7 @@ <h2 data-i18n="summary">切换摘要</h2>
? storedLang
: ((navigator.language || "").toLowerCase().startsWith("zh") ? "zh" : "en");
const clone = (value) => JSON.parse(JSON.stringify(value));
const defaultReserveForm = () => ({ minReservedCashUsd: "", reservedCashRatio: "" });

const state = {
selected: "longbridge",
Expand All @@ -919,10 +953,10 @@ <h2 data-i18n="summary">切换摘要</h2>
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() },
},
};

Expand Down Expand Up @@ -1173,16 +1207,34 @@ <h2 data-i18n="summary">切换摘要</h2>
]) {
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);
const rows = [
[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) {
Expand Down Expand Up @@ -1242,6 +1294,8 @@ <h2 data-i18n="summary">切换摘要</h2>
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);
Expand All @@ -1254,6 +1308,8 @@ <h2 data-i18n="summary">切换摘要</h2>
if (!showPrivateControls) {
accountSelect.replaceChildren();
strategySelect.replaceChildren();
minReservedCashInput.value = "";
reservedCashRatioInput.value = "";
el("account-meta").textContent = "";
el("strategy-meta").textContent = "";
return;
Expand Down Expand Up @@ -1284,6 +1340,8 @@ <h2 data-i18n="summary">切换摘要</h2>
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);
Expand Down Expand Up @@ -1555,6 +1613,16 @@ <h2 data-i18n="summary">切换摘要</h2>
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());
Expand Down
2 changes: 1 addition & 1 deletion web/strategy-switch-console/page_asset.js

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions web/strategy-switch-console/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -1674,6 +1699,7 @@ export const __test = {
assertStrategyAllowedForAccount,
inferAccountSupportedDomains,
loadCurrentStrategies,
normalizeSwitchInputs,
normalizeAccountOptionsPayload,
normalizeStrategyProfilesPayload,
platformRepositories,
Expand Down