diff --git a/e2e/binance-orderbook/specs/control-flows.pw.js b/e2e/binance-orderbook/specs/control-flows.pw.js
index 3573e05..8742638 100644
--- a/e2e/binance-orderbook/specs/control-flows.pw.js
+++ b/e2e/binance-orderbook/specs/control-flows.pw.js
@@ -31,6 +31,51 @@ async function expectStandardDisabledStyle(locator) {
await expect(locator).toHaveCSS('cursor', 'not-allowed');
}
+test('multiplier controls show local press feedback without changing operation status', async ({ page }) => {
+ const scenario = createCancelScenario({
+ ui: { tradeMode: 'OPEN', orderbookPrecision: '0.1' },
+ });
+ const { errors } = await openUserscriptScenario(page, scenario);
+ const panel = page.locator(PANEL_SELECTOR);
+ const status = panel.locator('#jh-binance-ladder-status');
+ const statusBefore = await status.textContent();
+ const input = panel.locator('#jh-binance-close-qty-multiplier-input');
+ const increment = panel.locator('#jh-binance-close-qty-multiplier-inc');
+
+ await expect(input).toHaveValue('1');
+ await increment.click();
+ await expect(increment).toHaveAttribute('data-jh-press-feedback', 'true');
+ await expect(increment).toHaveCSS('background-color', 'rgb(245, 245, 245)');
+ await increment.click();
+ await expect(input).toHaveValue('3');
+ await expect(status).toHaveText(statusBefore);
+ await expect(increment).not.toHaveAttribute('data-jh-press-feedback', 'true', {
+ timeout: 1_000,
+ });
+ expect(errors).toEqual([]);
+});
+
+test('single order reports success only after the matching Binance API response', async ({ page }) => {
+ const scenario = createCancelScenario({
+ ui: { tradeMode: 'OPEN', orderbookPrecision: '0.1' },
+ host: { submitApiResponseDelayMsByOrder: [350, 0, 0, 0, 0] },
+ });
+ const { errors } = await openUserscriptScenario(page, scenario);
+ const panel = page.locator(PANEL_SELECTOR);
+ const status = panel.locator('#jh-binance-ladder-status');
+ const ladderGroup = panel.locator('[data-panel-group="ladder"]');
+
+ await expect(ladderGroup.locator('#jh-binance-ladder-status')).toHaveCount(0);
+ await page.locator('#futuresOrderbook .bid-light.emit-price').first().click();
+ await expect(status).toContainText('单击开多确认中');
+ await expect(status).toContainText('单击开多已提交', { timeout: 3_000 });
+
+ const events = (await readFixtureState(page)).events;
+ expect(events.filter((event) => event.type === 'order-submitted')).toHaveLength(1);
+ expect(events.filter((event) => event.type === 'order-submit-api-success')).toHaveLength(1);
+ expect(errors).toEqual([]);
+});
+
test('native open and close tabs drive one stable panel direction selector', async ({ page }) => {
const scenario = createCancelScenario({
positions: POSITION_SETS.current,
@@ -38,11 +83,14 @@ test('native open and close tabs drive one stable panel direction selector', asy
});
const { errors } = await openUserscriptScenario(page, scenario);
const panel = page.locator(PANEL_SELECTOR);
+ const status = panel.locator('#jh-binance-ladder-status');
+ const statusBefore = await status.textContent();
const directionGroup = panel.locator('[data-panel-group="direction"]');
const initialRect = await readRect(directionGroup);
await expect(panel.getByRole('radio', { name: '开多' })).toBeEnabled();
await expect(panel.getByRole('radio', { name: '开空' })).toBeEnabled();
+ await expect(status).toHaveText(statusBefore);
await installInteractionProbe(page, '#position-direction [data-trade-mode="CLOSE"]');
await page.locator('#position-direction [data-trade-mode="CLOSE"]').click();
await expect(panel.getByRole('radio', { name: '平多' })).toBeEnabled();
@@ -51,6 +99,7 @@ test('native open and close tabs drive one stable panel direction selector', asy
await expect(panel.getByRole('button', { name: '阶梯平空' })).toBeDisabled();
await expect(page.locator('.order-entry').getByRole('button', { name: '平多' })).toBeEnabled();
await expect(page.locator('.order-entry').getByRole('button', { name: '平空' })).toBeDisabled();
+ await expect(status).toHaveText(statusBefore);
const closeProbe = await finishInteractionProbe(page);
assertResponsiveInteraction(expect, closeProbe);
expect(await readRect(directionGroup)).toEqual(initialRect);
@@ -58,6 +107,7 @@ test('native open and close tabs drive one stable panel direction selector', asy
await page.locator('#position-direction [data-trade-mode="OPEN"]').click();
await expect(panel.getByRole('radio', { name: '开多' })).toBeEnabled();
await expect(panel.getByRole('radio', { name: '开空' })).toBeEnabled();
+ await expect(status).toHaveText(statusBefore);
expect(await readRect(directionGroup)).toEqual(initialRect);
const state = await readFixtureState(page);
@@ -75,6 +125,8 @@ test('a precision shortcut selects the exact native orderbook option once', asyn
});
const { errors } = await openUserscriptScenario(page, scenario);
const panel = page.locator(PANEL_SELECTOR);
+ const status = panel.locator('#jh-binance-ladder-status');
+ const statusBefore = await status.textContent();
const precisionGroup = panel.locator('[data-panel-group="precision"]');
const initialRect = await readRect(precisionGroup);
const target = panel.locator('[data-orderbook-precision-value="0.01"]');
@@ -88,6 +140,7 @@ test('a precision shortcut selects the exact native orderbook option once', asyn
await expect(target).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('#futuresOrderbook .tick-content')).toHaveText('0.01');
await expect(page.locator('.ob-ticksize-overlay')).toHaveCount(0);
+ await expect(status).toHaveText(statusBefore);
const probe = await finishInteractionProbe(page);
assertResponsiveInteraction(expect, probe);
expect(await readRect(precisionGroup)).toEqual(initialRect);
@@ -131,6 +184,8 @@ test('precision refresh explains when the complete visible trade list still lack
});
const { errors } = await openUserscriptScenario(page, scenario);
const panel = page.locator(PANEL_SELECTOR);
+ const status = panel.locator('#jh-binance-ladder-status');
+ const statusBefore = await status.textContent();
await page.locator('.tradew-tradelist .price.emit-price').evaluateAll((nodes) => {
nodes.forEach((node) => { node.textContent = '81.00'; });
});
@@ -139,8 +194,7 @@ test('precision refresh explains when the complete visible trade list still lack
await refresh.click();
await expect(refresh).toHaveAttribute('data-orderbook-precision-refresh-state', 'retry');
await expect(refresh).toHaveAttribute('aria-label', '近期价格变化不足,请稍后重试');
- await expect(panel.locator('#jh-binance-ladder-status'))
- .toHaveText('近期价格变化不足,请稍后重试');
+ await expect(status).toHaveText(statusBefore);
await expect(panel.locator('[aria-label*="推荐档位"]')).toHaveCount(0);
expect(await page.evaluate((symbol) => JSON.parse(
localStorage.getItem(`jh_binance_orderbook_precision_samples_v3:${symbol}`)
diff --git a/e2e/binance-orderbook/specs/panel-visual-contract.pw.js-snapshots/close-fixed-visual.json b/e2e/binance-orderbook/specs/panel-visual-contract.pw.js-snapshots/close-fixed-visual.json
index 79a0134..533435a 100644
--- a/e2e/binance-orderbook/specs/panel-visual-contract.pw.js-snapshots/close-fixed-visual.json
+++ b/e2e/binance-orderbook/specs/panel-visual-contract.pw.js-snapshots/close-fixed-visual.json
@@ -28,7 +28,7 @@
"rect": {
"top": 135,
"width": 312,
- "height": 263
+ "height": 239
},
"display": "block",
"visibility": "visible"
diff --git a/e2e/binance-orderbook/specs/panel-visual-contract.pw.js-snapshots/open-fixed-visual.json b/e2e/binance-orderbook/specs/panel-visual-contract.pw.js-snapshots/open-fixed-visual.json
index 4045c0a..b4f5b7a 100644
--- a/e2e/binance-orderbook/specs/panel-visual-contract.pw.js-snapshots/open-fixed-visual.json
+++ b/e2e/binance-orderbook/specs/panel-visual-contract.pw.js-snapshots/open-fixed-visual.json
@@ -28,7 +28,7 @@
"rect": {
"top": 135,
"width": 312,
- "height": 263
+ "height": 239
},
"display": "block",
"visibility": "visible"
diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js
index 670d7a7..1f1ead8 100644
--- a/scripts/binance-orderbook-trade.user.js
+++ b/scripts/binance-orderbook-trade.user.js
@@ -3,7 +3,7 @@
// @namespace binance.orderbook.trade
// @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E
// @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E
-// @version 2.7.150
+// @version 2.7.151
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
@@ -2396,6 +2396,7 @@
const SIDE_SHORT_ID = "jh-binance-close-side-short";
const LADDER_BODY_ID = "jh-binance-ladder-body";
const LADDER_STATUS_ID = "jh-binance-ladder-status";
+ const MULTIPLIER_PRESS_FEEDBACK_ATTR = "data-jh-press-feedback";
const ORDERBOOK_PRECISION_RECOMMENDATION_ID = "jh-binance-orderbook-precision-recommendation";
const DEFAULT_MULTIPLIER = "1";
const DEFAULT_CLOSE_SIDE = "LONG";
@@ -2436,6 +2437,7 @@
];
const LADDER_SUBMIT_ACK_TIMEOUT_MS = 3500;
const LADDER_ACTION_FEEDBACK_MIN_MS = 240;
+ const MULTIPLIER_PRESS_FEEDBACK_MS = 140;
const LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS = 6500;
const LADDER_REPLACE_ROW_SETTLE_MS = 240;
const CANCEL_OPEN_ORDERS_CLEAR_SETTLE_MS = 1200;
@@ -2529,6 +2531,7 @@
let tradeButtonCache = { mode: null, expiresAt: 0, buttons: [] };
let tradeScopeCache = { activeTab: null, expiresAt: 0, scopes: [] };
let ladderTask = null;
+ let singleOrderTask = null;
let ladderAbortController = null;
let activeLadderActionType = null;
let activeLadderPanelContext = null;
@@ -2548,6 +2551,7 @@
let panelResizeObserver = null;
let ladderSubmitCaptureSequence = 0;
let activeLadderSubmitCapture = null;
+ const multiplierPressFeedbackTimers = /* @__PURE__ */ new WeakMap();
let orderbookPrecisionSelectionTask = null;
let orderbookPrecisionOptionsLoadRequestedSymbol = null;
let orderbookPrecisionOptionsLoadAttemptedSymbol = null;
@@ -2586,6 +2590,12 @@
font-weight: ${CONTROL_FONT_WEIGHT} !important;
cursor: not-allowed !important;
}
+ #${PANEL_ID} button[${MULTIPLIER_PRESS_FEEDBACK_ATTR}="true"] {
+ background: ${DISABLED_CONTROL_BG} !important;
+ color: ${DISABLED_CONTROL_TEXT} !important;
+ border-color: ${DISABLED_CONTROL_BORDER} !important;
+ opacity: ${DISABLED_CONTROL_OPACITY} !important;
+ }
button[${NATIVE_ACTION_DISABLED_ATTR}="true"] {
pointer-events: none !important;
}
@@ -2710,7 +2720,7 @@
return true;
}
function getLadderClosePercent(symbol = getCurrentSymbol(), precision = readCurrentOrderbookPrecisionValue()) {
- const migrated = migrateModeSymbolPrecisionNumberOption(
+ migrateModeSymbolPrecisionNumberOption(
localStorage,
LADDER_PERCENT_STORAGE_KEYS,
"CLOSE",
@@ -2720,9 +2730,6 @@
DEFAULT_LADDER_CLOSE_PERCENT,
LADDER_CLOSE_PERCENTS
);
- if (migrated) {
- setLadderStatus(`平仓量 100% 已调整为 ${DEFAULT_LADDER_CLOSE_PERCENT}%`);
- }
return loadModeSymbolPrecisionNumberOption(
localStorage,
LADDER_PERCENT_STORAGE_KEYS,
@@ -3726,9 +3733,6 @@
queueOrderbookPrecisionOptionsLoad(symbol, true);
}
refreshOrderbookPrecisionRecommendation();
- setLadderStatus(
- recommendation ? PANEL_COPY.status.precisionUpdated : PANEL_COPY.status.precisionInsufficient
- );
showOrderbookPrecisionRefreshFeedback(symbol, recommendation ? "success" : "retry");
scheduleRenderPanel();
}
@@ -4432,6 +4436,9 @@
setLadderStatus(`${spec.label}尚未开始:撤本币挂单处理中`);
return;
}
+ if (singleOrderTask) {
+ return;
+ }
if (ladderTask) {
setLadderStatus(`${spec.label}尚未开始:已有阶梯任务正在执行,请先停止`);
return;
@@ -5777,6 +5784,9 @@
}
async function cancelCurrentSymbolOpenOrders(options = null) {
if (cancelCurrentSymbolOpenOrdersTask) return cancelCurrentSymbolOpenOrdersTask;
+ if (singleOrderTask) {
+ return { ok: false, status: "single_order_running", message: "单击下单处理中" };
+ }
if (ladderTask) {
const message = "阶梯任务运行中,请先停止阶梯挂单";
setLadderStatus(message);
@@ -6633,6 +6643,16 @@
renderPanel();
return true;
}
+ function showMultiplierPressFeedback(button) {
+ const existingTimer = multiplierPressFeedbackTimers.get(button);
+ if (existingTimer) window.clearTimeout(existingTimer);
+ button.setAttribute(MULTIPLIER_PRESS_FEEDBACK_ATTR, "true");
+ const timer = window.setTimeout(() => {
+ button.removeAttribute(MULTIPLIER_PRESS_FEEDBACK_ATTR);
+ multiplierPressFeedbackTimers.delete(button);
+ }, MULTIPLIER_PRESS_FEEDBACK_MS);
+ multiplierPressFeedbackTimers.set(button, timer);
+ }
function setNativeActionButtonDisabled(button, disabled) {
if (!button) return;
const alreadyDisabled = button.getAttribute(NATIVE_ACTION_DISABLED_ATTR) === "true";
@@ -6704,7 +6724,7 @@
}
function getLadderControlSections(tradeMode, closeContext, symbol, precision) {
const ladderRunning = !!ladderTask;
- const actionDisabled = ladderRunning || cancelCurrentSymbolOpenOrdersBlocksLadderActions;
+ const actionDisabled = ladderRunning || !!singleOrderTask || cancelCurrentSymbolOpenOrdersBlocksLadderActions;
if (!["OPEN", "CLOSE"].includes(tradeMode)) {
return {
optionRows: [`
等待开仓/平仓状态
`],
@@ -6770,9 +6790,10 @@
const precision = activeLadderPanelContext?.precision || readCurrentOrderbookPrecisionValue();
if (body) {
const ladderRunning = !!ladderTask;
+ const singleOrderRunning = !!singleOrderTask;
const cancelRunning = !!cancelCurrentSymbolOpenOrdersTask;
const cancelPresentation = resolveCancelSymbolButtonPresentation({
- ladderRunning,
+ ladderRunning: ladderRunning || singleOrderRunning,
cancelRunning,
noOrdersFeedback: cancelNoOrdersFeedbackActive
});
@@ -7103,8 +7124,8 @@
``,
`
${PANEL_COPY.section.ladderMaker}
`,
`
`,
- `
空闲
`,
- "
"
+ "",
+ `空闲
`
].join("");
panelPositionInvalidated = true;
document.body.appendChild(panel);
@@ -7165,7 +7186,9 @@
const context = getPanelOptionContext();
if (!context || !isCurrentObservedSymbol(context.symbol)) return;
const current = Number(loadMultiplier(context.mode, context.symbol, context.precision));
- updateMultiplier(String(Math.max(1, current - 1)), context);
+ if (updateMultiplier(String(Math.max(1, current - 1)), context)) {
+ showMultiplierPressFeedback(decBtn);
+ }
});
}
if (incBtn) {
@@ -7173,7 +7196,9 @@
const context = getPanelOptionContext();
if (!context || !isCurrentObservedSymbol(context.symbol)) return;
const current = Number(loadMultiplier(context.mode, context.symbol, context.precision));
- updateMultiplier(String(current + 1), context);
+ if (updateMultiplier(String(current + 1), context)) {
+ showMultiplierPressFeedback(incBtn);
+ }
});
}
if (sideLongBtn) {
@@ -7505,10 +7530,12 @@
const clickedSymbol = getCurrentSymbol();
if (!isCurrentObservedSymbol(clickedSymbol)) {
warn("交易对正在切换,已忽略本次点击");
+ setLadderStatus("单击下单未执行:交易对正在切换");
return;
}
if (getActiveTradeMode() === "CLOSE" && !isCloseSnapshotReady(clickedSymbol)) {
warn("仓位确认中");
+ setLadderStatus("单击下单未执行:仓位确认中");
return;
}
if (CFG.DEBUG) {
@@ -7530,85 +7557,126 @@
const qtyInput = findQtyInput();
if (!qtyInput) {
warn("未找到数量输入框");
+ setLadderStatus("单击下单未执行:未找到数量输入框");
return;
}
const priceInput = findPriceInput();
if (!priceInput) {
warn("未找到价格输入框");
+ setLadderStatus("单击下单未执行:未找到价格输入框");
return;
}
const action = resolveTradeAction();
if (!action || !action.button) {
- warn(`未找到可用${getActiveTradeMode() === "OPEN" ? "开仓" : "平仓"}动作`);
+ const message = `未找到可用${getActiveTradeMode() === "OPEN" ? "开仓" : "平仓"}动作`;
+ warn(message);
+ setLadderStatus(`单击下单未执行:${message}`);
return;
}
const qtyPlan = resolveTargetQty(action.mode, clickedPrice);
if (!qtyPlan || !qtyPlan.qty) {
warn("未找到可用数量来源(数量倍率/有效最小量)");
+ setLadderStatus("单击下单未执行:数量规则读取中");
return;
}
- lastTs = now;
- await syncTradeInputs(clickedPrice, qtyPlan.qty, {
- priceLabel: "点击价",
- qtyLabel: "目标量"
- });
- const currentSymbol = getCurrentSymbol();
- if (!isCurrentObservedSymbol(qtyPlan.symbol)) {
- const clickedBaseAsset = formatStatusBaseAsset(qtyPlan.symbol);
- const currentBaseAsset = currentSymbol ? formatStatusBaseAsset(currentSymbol) : "未知";
- throw new Error(`交易对已变化,点击时 ${clickedBaseAsset},当前 ${currentBaseAsset}`);
- }
- if (getActiveTradeMode() !== action.mode) {
- throw new Error("开仓/平仓模式已变化,已停止提交");
- }
- if (readCurrentOrderbookPrecisionValue() !== qtyPlan.precision) {
- throw new Error("订单簿缩放值已变化,已停止提交");
- }
- const currentAction = resolveTradeAction();
- if (!currentAction || currentAction.mode !== action.mode || currentAction.side !== action.side || !currentAction.button || currentAction.button.disabled || currentAction.button.getAttribute("aria-disabled") === "true") {
- throw new Error(`提交前${action.side}按钮状态已变化,已停止`);
- }
- log(
- "已填价格/数量",
- clickedPrice,
- qtyPlan.qty,
- "来源",
- qtyPlan.source,
- "symbol",
- qtyPlan.symbol,
- "effectiveMinQty",
- qtyPlan.rule?.effectiveMinQty,
- "referencePrice",
- qtyPlan.rule?.referencePrice,
- "触发价格",
- clickedPrice,
- "mode",
- action.mode,
- "action",
- action.side,
- "by",
- action.by,
- "qtySource",
- action.qtySource,
- "longQty",
- action.longQty,
- "shortQty",
- action.shortQty
- );
- if (CFG.SAFE_MODE) {
- warn(`SAFE_MODE=true,仅填价格/数量,不点击${action.side}`);
+ if (ladderTask || cancelCurrentSymbolOpenOrdersTask || singleOrderTask) {
return;
}
- if (!isCurrentObservedSymbol(qtyPlan.symbol)) {
- throw new Error("提交前交易对已变化,已停止");
- }
- currentAction.button.click();
+ lastTs = now;
+ setLadderStatus(`单击${action.side}准备中`);
+ singleOrderTask = (async () => {
+ await syncTradeInputs(clickedPrice, qtyPlan.qty, {
+ priceLabel: "点击价",
+ qtyLabel: "目标量"
+ });
+ const currentSymbol = getCurrentSymbol();
+ if (!isCurrentObservedSymbol(qtyPlan.symbol)) {
+ const clickedBaseAsset = formatStatusBaseAsset(qtyPlan.symbol);
+ const currentBaseAsset = currentSymbol ? formatStatusBaseAsset(currentSymbol) : "未知";
+ throw new Error(`交易对已变化,点击时 ${clickedBaseAsset},当前 ${currentBaseAsset}`);
+ }
+ if (getActiveTradeMode() !== action.mode) {
+ throw new Error("开仓/平仓模式已变化,已停止提交");
+ }
+ if (readCurrentOrderbookPrecisionValue() !== qtyPlan.precision) {
+ throw new Error("订单簿缩放值已变化,已停止提交");
+ }
+ const currentAction = resolveTradeAction();
+ if (!currentAction || currentAction.mode !== action.mode || currentAction.side !== action.side || !currentAction.button || currentAction.button.disabled || currentAction.button.getAttribute("aria-disabled") === "true") {
+ throw new Error(`提交前${action.side}按钮状态已变化,已停止`);
+ }
+ log(
+ "已填价格/数量",
+ clickedPrice,
+ qtyPlan.qty,
+ "来源",
+ qtyPlan.source,
+ "symbol",
+ qtyPlan.symbol,
+ "effectiveMinQty",
+ qtyPlan.rule?.effectiveMinQty,
+ "referencePrice",
+ qtyPlan.rule?.referencePrice,
+ "触发价格",
+ clickedPrice,
+ "mode",
+ action.mode,
+ "action",
+ action.side,
+ "by",
+ action.by,
+ "qtySource",
+ action.qtySource,
+ "longQty",
+ action.longQty,
+ "shortQty",
+ action.shortQty
+ );
+ if (CFG.SAFE_MODE) {
+ warn(`SAFE_MODE=true,仅填价格/数量,不点击${action.side}`);
+ return;
+ }
+ if (!isCurrentObservedSymbol(qtyPlan.symbol)) {
+ throw new Error("提交前交易对已变化,已停止");
+ }
+ const previousFeedback = takeOrderFeedbackSnapshot();
+ const submitCaptureId = beginLadderSubmitResponseCapture();
+ try {
+ currentAction.button.click();
+ setLadderStatus(`单击${action.side}确认中 · ${clickedPrice} × ${qtyPlan.qty}`);
+ waitForTradeUiMutation({ timeoutMs: 400 });
+ await waitForOrderSubmitAcknowledgement(
+ currentAction.button,
+ `单击${action.side}`,
+ previousFeedback,
+ submitCaptureId,
+ action.mode
+ );
+ } finally {
+ endLadderSubmitResponseCapture(submitCaptureId);
+ }
+ setLadderStatus(`单击${action.side}已提交 · ${clickedPrice} × ${qtyPlan.qty}`);
+ log(`单击${action.side}已确认提交`);
+ })();
scheduleRenderPanel();
- waitForTradeUiMutation({ timeoutMs: 400 });
- log(`已点击${action.side}`);
+ try {
+ await singleOrderTask;
+ } catch (singleOrderError) {
+ const message = singleOrderError?.message || "订单簿点击提交失败";
+ setLadderStatus(`单击${action.side}失败:${message}`, message);
+ err("single order submit failed:", singleOrderError);
+ warn(message);
+ } finally {
+ singleOrderTask = null;
+ scheduleRenderPanel();
+ }
} catch (e2) {
err("click handler 异常:", e2);
- warn(e2?.message || "订单簿点击提交失败");
+ const message = e2?.message || "订单簿点击提交失败";
+ warn(message);
+ if (!ladderTask && !cancelCurrentSymbolOpenOrdersTask && !singleOrderTask) {
+ setLadderStatus(`单击下单失败:${message}`, message);
+ }
}
}, true);
window.addEventListener("storage", (event) => {
diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js
index 09f2090..983b69c 100644
--- a/src/binance-orderbook-trade/index.user.js
+++ b/src/binance-orderbook-trade/index.user.js
@@ -3,7 +3,7 @@
// @namespace binance.orderbook.trade
// @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E
// @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E
-// @version 2.7.150
+// @version 2.7.151
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
@@ -231,6 +231,7 @@ import {
const SIDE_SHORT_ID = 'jh-binance-close-side-short';
const LADDER_BODY_ID = 'jh-binance-ladder-body';
const LADDER_STATUS_ID = 'jh-binance-ladder-status';
+ const MULTIPLIER_PRESS_FEEDBACK_ATTR = 'data-jh-press-feedback';
const ORDERBOOK_PRECISION_RECOMMENDATION_ID = 'jh-binance-orderbook-precision-recommendation';
const DEFAULT_MULTIPLIER = '1';
const DEFAULT_CLOSE_SIDE = 'LONG';
@@ -271,6 +272,7 @@ import {
];
const LADDER_SUBMIT_ACK_TIMEOUT_MS = 3500;
const LADDER_ACTION_FEEDBACK_MIN_MS = 240;
+ const MULTIPLIER_PRESS_FEEDBACK_MS = 140;
const LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS = 6500;
const LADDER_REPLACE_ROW_SETTLE_MS = 240;
const CANCEL_OPEN_ORDERS_CLEAR_SETTLE_MS = 1200;
@@ -365,6 +367,7 @@ import {
let tradeButtonCache = { mode: null, expiresAt: 0, buttons: [] };
let tradeScopeCache = { activeTab: null, expiresAt: 0, scopes: [] };
let ladderTask = null;
+ let singleOrderTask = null;
let ladderAbortController = null;
let activeLadderActionType = null;
let activeLadderPanelContext = null;
@@ -385,6 +388,7 @@ import {
let panelResizeObserver = null;
let ladderSubmitCaptureSequence = 0;
let activeLadderSubmitCapture = null;
+ const multiplierPressFeedbackTimers = new WeakMap();
let orderbookPrecisionSelectionTask = null;
let orderbookPrecisionOptionsLoadRequestedSymbol = null;
let orderbookPrecisionOptionsLoadAttemptedSymbol = null;
@@ -427,6 +431,12 @@ import {
font-weight: ${CONTROL_FONT_WEIGHT} !important;
cursor: not-allowed !important;
}
+ #${PANEL_ID} button[${MULTIPLIER_PRESS_FEEDBACK_ATTR}="true"] {
+ background: ${DISABLED_CONTROL_BG} !important;
+ color: ${DISABLED_CONTROL_TEXT} !important;
+ border-color: ${DISABLED_CONTROL_BORDER} !important;
+ opacity: ${DISABLED_CONTROL_OPACITY} !important;
+ }
button[${NATIVE_ACTION_DISABLED_ATTR}="true"] {
pointer-events: none !important;
}
@@ -590,7 +600,7 @@ import {
symbol = getCurrentSymbol(),
precision = readCurrentOrderbookPrecisionValue(),
) {
- const migrated = migrateModeSymbolPrecisionNumberOption(
+ migrateModeSymbolPrecisionNumberOption(
localStorage,
LADDER_PERCENT_STORAGE_KEYS,
'CLOSE',
@@ -600,9 +610,6 @@ import {
DEFAULT_LADDER_CLOSE_PERCENT,
LADDER_CLOSE_PERCENTS,
);
- if (migrated) {
- setLadderStatus(`平仓量 100% 已调整为 ${DEFAULT_LADDER_CLOSE_PERCENT}%`);
- }
return loadModeSymbolPrecisionNumberOption(
localStorage,
LADDER_PERCENT_STORAGE_KEYS,
@@ -1873,11 +1880,6 @@ import {
queueOrderbookPrecisionOptionsLoad(symbol, true);
}
refreshOrderbookPrecisionRecommendation();
- setLadderStatus(
- recommendation
- ? PANEL_COPY.status.precisionUpdated
- : PANEL_COPY.status.precisionInsufficient
- );
showOrderbookPrecisionRefreshFeedback(symbol, recommendation ? 'success' : 'retry');
scheduleRenderPanel();
}
@@ -2712,6 +2714,9 @@ import {
setLadderStatus(`${spec.label}尚未开始:撤本币挂单处理中`);
return;
}
+ if (singleOrderTask) {
+ return;
+ }
if (ladderTask) {
setLadderStatus(`${spec.label}尚未开始:已有阶梯任务正在执行,请先停止`);
return;
@@ -4271,6 +4276,9 @@ import {
async function cancelCurrentSymbolOpenOrders(options = null) {
if (cancelCurrentSymbolOpenOrdersTask) return cancelCurrentSymbolOpenOrdersTask;
+ if (singleOrderTask) {
+ return { ok: false, status: 'single_order_running', message: '单击下单处理中' };
+ }
if (ladderTask) {
const message = '阶梯任务运行中,请先停止阶梯挂单';
setLadderStatus(message);
@@ -5281,6 +5289,17 @@ import {
return true;
}
+ function showMultiplierPressFeedback(button) {
+ const existingTimer = multiplierPressFeedbackTimers.get(button);
+ if (existingTimer) window.clearTimeout(existingTimer);
+ button.setAttribute(MULTIPLIER_PRESS_FEEDBACK_ATTR, 'true');
+ const timer = window.setTimeout(() => {
+ button.removeAttribute(MULTIPLIER_PRESS_FEEDBACK_ATTR);
+ multiplierPressFeedbackTimers.delete(button);
+ }, MULTIPLIER_PRESS_FEEDBACK_MS);
+ multiplierPressFeedbackTimers.set(button, timer);
+ }
+
function setNativeActionButtonDisabled(button, disabled) {
if (!button) return;
// 状态没变则不操作 DOM,避免产生多余的 mutation 触发 observer 死循环
@@ -5365,7 +5384,9 @@ import {
function getLadderControlSections(tradeMode, closeContext, symbol, precision) {
const ladderRunning = !!ladderTask;
- const actionDisabled = ladderRunning || cancelCurrentSymbolOpenOrdersBlocksLadderActions;
+ const actionDisabled = ladderRunning
+ || !!singleOrderTask
+ || cancelCurrentSymbolOpenOrdersBlocksLadderActions;
if (!['OPEN', 'CLOSE'].includes(tradeMode)) {
return {
optionRows: [`等待开仓/平仓状态
`],
@@ -5434,9 +5455,10 @@ import {
const precision = activeLadderPanelContext?.precision || readCurrentOrderbookPrecisionValue();
if (body) {
const ladderRunning = !!ladderTask;
+ const singleOrderRunning = !!singleOrderTask;
const cancelRunning = !!cancelCurrentSymbolOpenOrdersTask;
const cancelPresentation = resolveCancelSymbolButtonPresentation({
- ladderRunning,
+ ladderRunning: ladderRunning || singleOrderRunning,
cancelRunning,
noOrdersFeedback: cancelNoOrdersFeedbackActive,
});
@@ -5815,8 +5837,8 @@ import {
``,
`
${PANEL_COPY.section.ladderMaker}
`,
`
`,
- `
空闲
`,
'
',
+ `空闲
`,
].join('');
panelPositionInvalidated = true;
document.body.appendChild(panel);
@@ -5880,7 +5902,9 @@ import {
const context = getPanelOptionContext();
if (!context || !isCurrentObservedSymbol(context.symbol)) return;
const current = Number(loadMultiplier(context.mode, context.symbol, context.precision));
- updateMultiplier(String(Math.max(1, current - 1)), context);
+ if (updateMultiplier(String(Math.max(1, current - 1)), context)) {
+ showMultiplierPressFeedback(decBtn);
+ }
});
}
if (incBtn) {
@@ -5888,7 +5912,9 @@ import {
const context = getPanelOptionContext();
if (!context || !isCurrentObservedSymbol(context.symbol)) return;
const current = Number(loadMultiplier(context.mode, context.symbol, context.precision));
- updateMultiplier(String(current + 1), context);
+ if (updateMultiplier(String(current + 1), context)) {
+ showMultiplierPressFeedback(incBtn);
+ }
});
}
if (sideLongBtn) {
@@ -6268,10 +6294,12 @@ import {
const clickedSymbol = getCurrentSymbol();
if (!isCurrentObservedSymbol(clickedSymbol)) {
warn('交易对正在切换,已忽略本次点击');
+ setLadderStatus('单击下单未执行:交易对正在切换');
return;
}
if (getActiveTradeMode() === 'CLOSE' && !isCloseSnapshotReady(clickedSymbol)) {
warn('仓位确认中');
+ setLadderStatus('单击下单未执行:仓位确认中');
return;
}
@@ -6296,98 +6324,138 @@ import {
const qtyInput = findQtyInput();
if (!qtyInput) {
warn('未找到数量输入框');
+ setLadderStatus('单击下单未执行:未找到数量输入框');
return;
}
const priceInput = findPriceInput();
if (!priceInput) {
warn('未找到价格输入框');
+ setLadderStatus('单击下单未执行:未找到价格输入框');
return;
}
const action = resolveTradeAction();
if (!action || !action.button) {
- warn(`未找到可用${getActiveTradeMode() === 'OPEN' ? '开仓' : '平仓'}动作`);
+ const message = `未找到可用${getActiveTradeMode() === 'OPEN' ? '开仓' : '平仓'}动作`;
+ warn(message);
+ setLadderStatus(`单击下单未执行:${message}`);
return;
}
const qtyPlan = resolveTargetQty(action.mode, clickedPrice);
if (!qtyPlan || !qtyPlan.qty) {
warn('未找到可用数量来源(数量倍率/有效最小量)');
+ setLadderStatus('单击下单未执行:数量规则读取中');
+ return;
+ }
+ if (ladderTask || cancelCurrentSymbolOpenOrdersTask || singleOrderTask) {
return;
}
lastTs = now;
+ setLadderStatus(`单击${action.side}准备中`);
+ singleOrderTask = (async () => {
+ await syncTradeInputs(clickedPrice, qtyPlan.qty, {
+ priceLabel: '点击价',
+ qtyLabel: '目标量',
+ });
- await syncTradeInputs(clickedPrice, qtyPlan.qty, {
- priceLabel: '点击价',
- qtyLabel: '目标量',
- });
-
- const currentSymbol = getCurrentSymbol();
- if (!isCurrentObservedSymbol(qtyPlan.symbol)) {
- const clickedBaseAsset = formatStatusBaseAsset(qtyPlan.symbol);
- const currentBaseAsset = currentSymbol ? formatStatusBaseAsset(currentSymbol) : '未知';
- throw new Error(`交易对已变化,点击时 ${clickedBaseAsset},当前 ${currentBaseAsset}`);
- }
- if (getActiveTradeMode() !== action.mode) {
- throw new Error('开仓/平仓模式已变化,已停止提交');
- }
- if (readCurrentOrderbookPrecisionValue() !== qtyPlan.precision) {
- throw new Error('订单簿缩放值已变化,已停止提交');
- }
- const currentAction = resolveTradeAction();
- if (
- !currentAction ||
- currentAction.mode !== action.mode ||
- currentAction.side !== action.side ||
- !currentAction.button ||
- currentAction.button.disabled ||
- currentAction.button.getAttribute('aria-disabled') === 'true'
- ) {
- throw new Error(`提交前${action.side}按钮状态已变化,已停止`);
- }
- log(
- '已填价格/数量',
- clickedPrice,
- qtyPlan.qty,
- '来源',
- qtyPlan.source,
- 'symbol',
- qtyPlan.symbol,
- 'effectiveMinQty',
- qtyPlan.rule?.effectiveMinQty,
- 'referencePrice',
- qtyPlan.rule?.referencePrice,
- '触发价格',
- clickedPrice,
- 'mode',
- action.mode,
- 'action',
- action.side,
- 'by',
- action.by,
- 'qtySource',
- action.qtySource,
- 'longQty',
- action.longQty,
- 'shortQty',
- action.shortQty
- );
+ const currentSymbol = getCurrentSymbol();
+ if (!isCurrentObservedSymbol(qtyPlan.symbol)) {
+ const clickedBaseAsset = formatStatusBaseAsset(qtyPlan.symbol);
+ const currentBaseAsset = currentSymbol ? formatStatusBaseAsset(currentSymbol) : '未知';
+ throw new Error(`交易对已变化,点击时 ${clickedBaseAsset},当前 ${currentBaseAsset}`);
+ }
+ if (getActiveTradeMode() !== action.mode) {
+ throw new Error('开仓/平仓模式已变化,已停止提交');
+ }
+ if (readCurrentOrderbookPrecisionValue() !== qtyPlan.precision) {
+ throw new Error('订单簿缩放值已变化,已停止提交');
+ }
+ const currentAction = resolveTradeAction();
+ if (
+ !currentAction ||
+ currentAction.mode !== action.mode ||
+ currentAction.side !== action.side ||
+ !currentAction.button ||
+ currentAction.button.disabled ||
+ currentAction.button.getAttribute('aria-disabled') === 'true'
+ ) {
+ throw new Error(`提交前${action.side}按钮状态已变化,已停止`);
+ }
+ log(
+ '已填价格/数量',
+ clickedPrice,
+ qtyPlan.qty,
+ '来源',
+ qtyPlan.source,
+ 'symbol',
+ qtyPlan.symbol,
+ 'effectiveMinQty',
+ qtyPlan.rule?.effectiveMinQty,
+ 'referencePrice',
+ qtyPlan.rule?.referencePrice,
+ '触发价格',
+ clickedPrice,
+ 'mode',
+ action.mode,
+ 'action',
+ action.side,
+ 'by',
+ action.by,
+ 'qtySource',
+ action.qtySource,
+ 'longQty',
+ action.longQty,
+ 'shortQty',
+ action.shortQty
+ );
- if (CFG.SAFE_MODE) {
- warn(`SAFE_MODE=true,仅填价格/数量,不点击${action.side}`);
- return;
- }
+ if (CFG.SAFE_MODE) {
+ warn(`SAFE_MODE=true,仅填价格/数量,不点击${action.side}`);
+ return;
+ }
- if (!isCurrentObservedSymbol(qtyPlan.symbol)) {
- throw new Error('提交前交易对已变化,已停止');
- }
- currentAction.button.click();
+ if (!isCurrentObservedSymbol(qtyPlan.symbol)) {
+ throw new Error('提交前交易对已变化,已停止');
+ }
+ const previousFeedback = takeOrderFeedbackSnapshot();
+ const submitCaptureId = beginLadderSubmitResponseCapture();
+ try {
+ currentAction.button.click();
+ setLadderStatus(`单击${action.side}确认中 · ${clickedPrice} × ${qtyPlan.qty}`);
+ waitForTradeUiMutation({ timeoutMs: 400 });
+ await waitForOrderSubmitAcknowledgement(
+ currentAction.button,
+ `单击${action.side}`,
+ previousFeedback,
+ submitCaptureId,
+ action.mode,
+ );
+ } finally {
+ endLadderSubmitResponseCapture(submitCaptureId);
+ }
+ setLadderStatus(`单击${action.side}已提交 · ${clickedPrice} × ${qtyPlan.qty}`);
+ log(`单击${action.side}已确认提交`);
+ })();
scheduleRenderPanel();
- waitForTradeUiMutation({ timeoutMs: 400 });
- log(`已点击${action.side}`);
+ try {
+ await singleOrderTask;
+ } catch (singleOrderError) {
+ const message = singleOrderError?.message || '订单簿点击提交失败';
+ setLadderStatus(`单击${action.side}失败:${message}`, message);
+ err('single order submit failed:', singleOrderError);
+ warn(message);
+ } finally {
+ singleOrderTask = null;
+ scheduleRenderPanel();
+ }
} catch (e2) {
err('click handler 异常:', e2);
- warn(e2?.message || '订单簿点击提交失败');
+ const message = e2?.message || '订单簿点击提交失败';
+ warn(message);
+ if (!ladderTask && !cancelCurrentSymbolOpenOrdersTask && !singleOrderTask) {
+ setLadderStatus(`单击下单失败:${message}`, message);
+ }
}
}, true);
diff --git a/test/unit/binance-orderbook-trade/ladder-options.test.js b/test/unit/binance-orderbook-trade/ladder-options.test.js
index 8f6c930..e996c1b 100644
--- a/test/unit/binance-orderbook-trade/ladder-options.test.js
+++ b/test/unit/binance-orderbook-trade/ladder-options.test.js
@@ -86,10 +86,7 @@ test('retired close 100 percent profiles explicitly migrate to the close default
assert.deepEqual(storage.entries(), [
['jh_binance_ladder_close_percent:BTCUSDT:0.01', '0.3'],
]);
- assert.match(
- source,
- /if \(migrated\) \{\s*setLadderStatus\(`平仓量 100% 已调整为 \$\{DEFAULT_LADDER_CLOSE_PERCENT\}%`\);\s*\}/,
- );
+ assert.doesNotMatch(source, /平仓量 100% 已调整为/);
});
test('retired option migration rejects an unsupported replacement', () => {
diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js
index 798f5f4..29c5060 100644
--- a/test/unit/binance-orderbook-trade/source-regressions.test.js
+++ b/test/unit/binance-orderbook-trade/source-regressions.test.js
@@ -467,6 +467,15 @@ test('multiplier row reads as a labeled value followed by decrement and incremen
assert.match(refreshBody, /let multiplierHintText = PANEL_COPY\.field\.minimumOrderQuantity/);
});
+test('multiplier clicks use non-blocking local feedback without writing business status', () => {
+ const feedbackBody = readFunctionBody('showMultiplierPressFeedback');
+ assert.match(feedbackBody, /button\.setAttribute\(MULTIPLIER_PRESS_FEEDBACK_ATTR, 'true'\)/);
+ assert.match(feedbackBody, /MULTIPLIER_PRESS_FEEDBACK_MS/);
+ assert.match(feedbackBody, /button\.removeAttribute\(MULTIPLIER_PRESS_FEEDBACK_ATTR\)/);
+ assert.doesNotMatch(feedbackBody, /disabled|setLadderStatus/);
+ assert.match(source, /if \(updateMultiplier\(String\(current \+ 1\), context\)\) \{\s*showMultiplierPressFeedback\(incBtn\);/);
+});
+
test('multiplier calculation keeps the formula primary and separates the notional constraint visually', () => {
const ensurePanelBody = readFunctionBody('ensurePanel');
const refreshBody = readFunctionBody('refreshComputedInfo');
@@ -1110,7 +1119,7 @@ test('cancel current-symbol open orders are single-flight and follow the native
const startBody = readFunctionBody('startLadder');
assert.match(startBody, /if \(cancelCurrentSymbolOpenOrdersTask\)[\s\S]*`\$\{spec\.label\}尚未开始:撤本币挂单处理中`/);
const actionRowsBody = readFunctionBody('getLadderControlSections');
- assert.match(actionRowsBody, /actionDisabled = ladderRunning \|\| cancelCurrentSymbolOpenOrdersBlocksLadderActions/);
+ assert.match(actionRowsBody, /actionDisabled = ladderRunning[\s\S]*\|\| !!singleOrderTask[\s\S]*\|\| cancelCurrentSymbolOpenOrdersBlocksLadderActions/);
assert.doesNotMatch(actionRowsBody, /!!cancelCurrentSymbolOpenOrdersTask/);
assert.match(actionRowsBody, /ladderExecutionButton\('OPEN_LONG',[\s\S]*actionDisabled\)/);
assert.match(startBody, /activeLadderActionType = actionType/);
@@ -1175,7 +1184,7 @@ test('orderbook precision recommendation marks one shortcut without applying it
assert.match(sampleBody, /saveStoredOrderbookPrecisionSamples/);
assert.match(sampleBody, /samples: latestSamples,[\s\S]*recommendation,/);
assert.match(sampleBody, /status: recommendation \? 'ready' : PANEL_COPY\.status\.precisionInsufficient/);
- assert.match(sampleBody, /PANEL_COPY\.status\.precisionUpdated/);
+ assert.doesNotMatch(sampleBody, /setLadderStatus/);
assert.doesNotMatch(sampleBody, /setTimeout|setInterval|await delay/);
assert.doesNotMatch(sampleBody, /getCurrentOrderbookDisplayStep|fallbackMovement/);
@@ -1400,7 +1409,7 @@ test('pending close actions report position confirmation without starting execut
assert.match(startBody, /spec\?\.mode === 'CLOSE' && !isCloseSnapshotReady\(actionSymbol\)/);
assert.match(startBody, /setLadderStatus\(`\$\{spec\.label\}尚未开始:仓位确认中`\)/);
- assert.match(source, /if \(getActiveTradeMode\(\) === 'CLOSE' && !isCloseSnapshotReady\(clickedSymbol\)\) \{\s*warn\('仓位确认中'\);\s*return;/);
+ assert.match(source, /if \(getActiveTradeMode\(\) === 'CLOSE' && !isCloseSnapshotReady\(clickedSymbol\)\) \{\s*warn\('仓位确认中'\);\s*setLadderStatus\('单击下单未执行:仓位确认中'\);\s*return;/);
});
test('cancel flow rechecks the captured symbol before destructive click and cleanup', () => {
@@ -1519,6 +1528,10 @@ test('single-order sizing and submission retain the captured orderbook precision
assert.match(resolveBody, /loadMultiplier\(tradeMode, symbol, precision\)/);
assert.match(resolveBody, /precision,/);
assert.match(source, /readCurrentOrderbookPrecisionValue\(\) !== qtyPlan\.precision/);
+ assert.match(source, /const submitCaptureId = beginLadderSubmitResponseCapture\(\)/);
+ assert.match(source, /await waitForOrderSubmitAcknowledgement\([\s\S]*submitCaptureId,[\s\S]*action\.mode/);
+ assert.match(source, /setLadderStatus\(`单击\$\{action\.side\}已提交 · \$\{clickedPrice\} × \$\{qtyPlan\.qty\}`\)/);
+ assert.match(source, /singleOrderTask = null;[\s\S]*scheduleRenderPanel\(\)/);
});
test('precision shortcut selection and refresh do not commit after a symbol switch', () => {