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
56 changes: 56 additions & 0 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ 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.ok(indexHtml.includes('function selectedCashCurrency('));
assert.ok(indexHtml.includes('function currentReservedCashPolicyText('));
assert.ok(indexHtml.includes('reservedCashTouched: false'));
assert.ok(indexHtml.includes('.reserve-ratio-block'));
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 @@ -257,6 +260,18 @@ globalThis.fetch = async (url) => {
if (requestUrl.endsWith("/CLOUD_RUN_SERVICE_TARGETS_JSON")) {
return new Response("", { status: 404 });
}
if (requestUrl.endsWith("/SCHWAB_MIN_RESERVED_CASH_USD")) {
return new Response(JSON.stringify({ value: "150" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (requestUrl.endsWith("/SCHWAB_RESERVED_CASH_RATIO")) {
return new Response(JSON.stringify({ value: "0.03" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (requestUrl.endsWith("/RUNTIME_TARGET_JSON")) {
return new Response(JSON.stringify({
value: JSON.stringify({
Expand All @@ -278,11 +293,52 @@ try {
);
assert.equal(currentStrategies.schwab.default.strategy_profile, "soxl_soxx_trend_income");
assert.equal(currentStrategies.schwab.default.execution_mode, "live");
assert.equal(currentStrategies.schwab.default.min_reserved_cash_usd, "150");
assert.equal(currentStrategies.schwab.default.reserved_cash_ratio, "0.03");
assert.equal(currentStrategies.schwab.default.source, "RUNTIME_TARGET_JSON");
} finally {
globalThis.fetch = originalFetch;
}

globalThis.fetch = async (url) => {
const requestUrl = String(url);
if (requestUrl.endsWith("/CLOUD_RUN_SERVICE_TARGETS_JSON")) {
return new Response(JSON.stringify({
value: JSON.stringify({
targets: [
{
service: "interactive-brokers-demo-ibkr-tqqq-service",
ACCOUNT_GROUP: "demo-ibkr-tqqq",
IBKR_MIN_RESERVED_CASH_USD: "150",
IBKR_RESERVED_CASH_RATIO: "0.03",
runtime_target: {
platform_id: "ibkr",
strategy_profile: "tqqq_growth_income",
dry_run_only: false,
account_scope: "demo-ibkr-tqqq",
service_name: "interactive-brokers-demo-ibkr-tqqq-service",
execution_mode: "live",
},
},
],
}),
}), { 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"].strategy_profile, "tqqq_growth_income");
assert.equal(currentStrategies.ibkr["ibkr-primary"].min_reserved_cash_usd, "150");
assert.equal(currentStrategies.ibkr["ibkr-primary"].reserved_cash_ratio, "0.03");
assert.equal(currentStrategies.ibkr["ibkr-primary"].source, "CLOUD_RUN_SERVICE_TARGETS_JSON");
} finally {
globalThis.fetch = originalFetch;
}

const longbridgeHk = __test.assertConfiguredAccount(
{
platform: "longbridge",
Expand Down
94 changes: 86 additions & 8 deletions web/strategy-switch-console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -407,18 +407,30 @@
.quick-form {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
align-items: start;
gap: 14px;
}

.control-block {
display: grid;
grid-template-rows: auto 48px minmax(20px, auto);
align-content: start;
gap: 8px;
min-width: 0;
color: var(--muted);
font-size: 12px;
font-weight: 760;
}

.mode-block {
grid-column: 1;
}

.min-reserve-block,
.reserve-ratio-block {
grid-column: 2;
}

select,
input {
width: 100%;
Expand Down Expand Up @@ -635,6 +647,12 @@
grid-template-columns: 1fr;
}

.mode-block,
.min-reserve-block,
.reserve-ratio-block {
grid-column: auto;
}

.switch-panel {
padding: 18px;
}
Expand Down Expand Up @@ -736,21 +754,21 @@ <h2 id="platform-title">LongBridge</h2>
<span class="selection-meta" id="strategy-meta"></span>
</label>

<div class="control-block">
<div class="control-block mode-block">
<span data-i18n="mode">模式</span>
<div class="mode" id="mode-control">
<button type="button" data-mode="live" class="active" data-i18n="live">实盘</button>
<button type="button" data-mode="paper" data-i18n="paper">模拟</button>
</div>
</div>

<label class="control-block">
<label class="control-block min-reserve-block">
<span id="min-reserved-cash-label">最小预留现金</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">
<label class="control-block reserve-ratio-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>
Expand Down Expand Up @@ -940,7 +958,11 @@ <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 defaultReserveForm = () => ({
minReservedCashUsd: "",
reservedCashRatio: "",
reservedCashTouched: false,
});

const state = {
selected: "longbridge",
Expand Down Expand Up @@ -1103,6 +1125,33 @@ <h2 data-i18n="summary">切换摘要</h2>
return profile || "";
}

function currentReservePolicyForAccount(platform, account) {
const entry = currentEntryForAccount(platform, account);
return reservePolicyFromEntry(entry);
}

function reservePolicyFromEntry(entry) {
return {
minReservedCashUsd: cleanDisplayNumber(entry?.min_reserved_cash_usd ?? entry?.reserved_cash_floor_usd),
reservedCashRatio: cleanDisplayRatio(entry?.reserved_cash_ratio),
};
}

function cleanDisplayNumber(value) {
const text = String(value ?? "").trim();
if (!text || text.length > 32 || !/^(?:\d+|\d*\.\d+)$/.test(text)) return "";
const numeric = Number(text);
if (!Number.isFinite(numeric) || numeric < 0) return "";
return text;
}

function cleanDisplayRatio(value) {
const text = cleanDisplayNumber(value);
if (!text) return "";
const numeric = Number(text);
return numeric >= 0 && numeric <= 1 ? text : "";
}

function normalizeExecutionMode(value, dryRunOnly) {
const mode = String(value || "").trim().toLowerCase();
if (mode === "live" || mode === "paper") return mode;
Expand Down Expand Up @@ -1160,14 +1209,25 @@ <h2 data-i18n="summary">切换摘要</h2>
platform,
account,
);
syncReservePolicyForAccount(platform);
}

function syncReservePolicyForAccount(platform) {
const form = state.forms[platform];
if (!form || form.reservedCashTouched) return;
const policy = currentReservePolicyForAccount(platform, selectedAccount(platform));
form.minReservedCashUsd = policy.minReservedCashUsd;
form.reservedCashRatio = policy.reservedCashRatio;
}

function ensureAccountSelection(platform) {
const options = optionsFor(platform);
if (!options.length) return;
if (!options.some((option) => option.key === state.forms[platform].accountKey)) {
state.forms[platform].accountKey = options[0].key;
state.forms[platform].reservedCashTouched = false;
state.forms[platform].strategy = defaultStrategyForAccount(platform, options[0], state.forms[platform].strategy);
syncReservePolicyForAccount(platform);
}
}

Expand Down Expand Up @@ -1226,16 +1286,29 @@ <h2 data-i18n="summary">切换摘要</h2>
return inputs;
}

function reservedCashPolicyText(inputs) {
function reservedCashPolicyText(inputs, platform = state.selected, account = selectedAccount(platform), fallback = t("unchanged")) {
const floor = inputs.min_reserved_cash_usd;
const ratio = inputs.reserved_cash_ratio;
const currency = selectedCashCurrency();
if (!floor && !ratio) return t("unchanged");
const currency = selectedCashCurrency(platform, account);
if (!floor && !ratio) return fallback;
if (floor && ratio) return `max(${floor} ${currency}, ${formatRatioPercent(ratio)})`;
if (floor) return `${floor} ${currency}`;
return formatRatioPercent(ratio);
}

function currentReservedCashPolicyText(platform = state.selected, account = selectedAccount(platform)) {
const policy = currentReservePolicyForAccount(platform, account);
return reservedCashPolicyText(
{
min_reserved_cash_usd: policy.minReservedCashUsd,
reserved_cash_ratio: policy.reservedCashRatio,
},
platform,
account,
t("notRead"),
);
}

function formatRatioPercent(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return String(value);
Expand All @@ -1249,7 +1322,7 @@ <h2 data-i18n="summary">切换摘要</h2>
[t("repository"), state.repositories[state.selected] || defaultRepositories[state.selected]],
[t("selectedAccount"), account.label],
[t("selectedMarket"), supportedDomainLabel(state.selected, account)],
[t("reservedCashPolicy"), reservedCashPolicyText(inputs)],
[t("reservedCashPolicy"), currentReservedCashPolicyText(state.selected, account)],

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 Show edited reserve policy in the confirmation

When a user edits the reserve cash fields, buildInputs() still sends those edited values, but this row now ignores inputs and always displays the currently read policy. In that scenario the confirmation panel and copied summary can show the old reserve policy while the dispatch request applies a different one, so users lose the last chance to verify the cash settings they are about to submit.

Useful? React with 👍 / 👎.

[t("nextStrategy"), strategyLabel(inputs.strategy_profile)],
];
if (state.auth.allowed) {
Expand Down Expand Up @@ -1562,6 +1635,8 @@ <h2 data-i18n="summary">切换摘要</h2>
strategy_profile: profile,
execution_mode: normalizeExecutionMode(entry?.execution_mode, entry?.dry_run_only),
dry_run_only: entry?.dry_run_only === true || entry?.dry_run_only === "true" || entry?.dry_run_only === "1",
min_reserved_cash_usd: cleanDisplayNumber(entry?.min_reserved_cash_usd ?? entry?.reserved_cash_floor_usd),
reserved_cash_ratio: cleanDisplayRatio(entry?.reserved_cash_ratio),
source: entry?.source ? String(entry.source) : "",
};
}
Expand Down Expand Up @@ -1619,6 +1694,7 @@ <h2 data-i18n="summary">切换摘要</h2>

el("account-select").addEventListener("change", () => {
state.forms[state.selected].accountKey = el("account-select").value;
state.forms[state.selected].reservedCashTouched = false;
syncStrategyForAccount(state.selected);
render();
});
Expand All @@ -1636,11 +1712,13 @@ <h2 data-i18n="summary">切换摘要</h2>
});

el("min-reserved-cash-input").addEventListener("input", () => {
state.forms[state.selected].reservedCashTouched = true;
state.forms[state.selected].minReservedCashUsd = el("min-reserved-cash-input").value.trim();
render();
});

el("reserved-cash-ratio-input").addEventListener("input", () => {
state.forms[state.selected].reservedCashTouched = true;
state.forms[state.selected].reservedCashRatio = el("reserved-cash-ratio-input").value.trim();
render();
});
Expand Down
2 changes: 1 addition & 1 deletion web/strategy-switch-console/page_asset.js

Large diffs are not rendered by default.

Loading