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', () => {