From 4d3853bbd2d6b7f79b637fcfc633e63d7b795dc7 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:36:34 +0800 Subject: [PATCH] fix: recover continuous ladder from transient states --- scripts/binance-orderbook-trade.user.js | 253 ++++++++++++++---- .../core/continuous-ladder.js | 34 +++ src/binance-orderbook-trade/index.user.js | 235 +++++++++++++--- .../continuous-ladder.test.js | 79 ++++++ .../ladder-options.test.js | 12 +- .../source-regressions.test.js | 48 +++- 6 files changed, 558 insertions(+), 103 deletions(-) diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index c984d8f..a368812 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.161 +// @version 2.7.162 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -743,11 +743,6 @@ const spec = LADDER_ACTION_SPECS[actionType]; return spec ? { ...spec } : null; } - function getLadderPercentForMode(mode, openPercent, closePercent) { - if (mode === "OPEN") return openPercent; - if (mode === "CLOSE") return closePercent; - return null; - } function getUnavailableLadderQuantityMessage(mode, quantity, confirmedZeroOpenBalance = false) { if (mode !== "OPEN" && mode !== "CLOSE") { throw new Error(`未知阶梯数量模式:${mode}`); @@ -981,6 +976,26 @@ // src/binance-orderbook-trade/core/continuous-ladder.js var CONTINUOUS_LADDER_COOLDOWN_MS = 1e3; var CONTINUOUS_LADDER_READY_CHECK_MS = 50; + var CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS = 3e3; + var CONTINUOUS_LADDER_RECOVERY = Object.freeze({ + input_unstable: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS + }, + controls_not_ready: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS + }, + market_data_not_ready: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS + }, + precision_changed: { + cooldownMs: CONTINUOUS_LADDER_COOLDOWN_MS, + reason: "价格精度已变化,下一轮按新精度继续" + }, + options_changed: { + cooldownMs: CONTINUOUS_LADDER_COOLDOWN_MS, + reason: "比例、笔数或间距已变化,下一轮按新设置继续" + } + }); var recordedRoundOutcomes = /* @__PURE__ */ new WeakSet(); var CONTINUOUS_LADDER_PHASE_TEXT = Object.freeze({ running: null, @@ -1004,6 +1019,15 @@ lastRound: null }; } + function resolveContinuousLadderRecovery(error) { + if (error?.safeNoSubmit !== true) return null; + const recovery = CONTINUOUS_LADDER_RECOVERY[error.continuousRecoveryKind]; + if (!recovery) return null; + return { + cooldownMs: recovery.cooldownMs, + reason: recovery.reason || error.message + }; + } function recordContinuousLadderRound(progress, outcome) { assertContinuousLadderProgress(progress); if (!outcome || typeof outcome !== "object") { @@ -2684,7 +2708,8 @@ const CHART_ORDERS_MENU_TIMEOUT_MS = 1800; const CHART_ORDERS_MENU_POLL_MS = 50; const LADDER_MAKER_BUFFER_LEVELS = 1; - const LADDER_REPRICE_MAX_ATTEMPTS = 5; + const LADDER_REPRICE_PAUSE_EVERY_ATTEMPTS = 5; + const LADDER_REPRICE_PAUSE_MS = 3e3; const LADDER_REPRICE_DELAY_MS = 180; const BINANCE_PLACE_ORDER_BAPI_PATH = "/bapi/futures/v1/private/future/order/place-order"; const BINANCE_USER_POSITION_BAPI_PATH = "/bapi/futures/v6/private/future/user-data/user-position"; @@ -2937,6 +2962,12 @@ function isLadderStoppedError(error) { return error?.name === "LadderStoppedError"; } + function createContinuousRecoverableLadderError(kind, message) { + const error = new Error(message); + error.safeNoSubmit = true; + error.continuousRecoveryKind = kind; + return error; + } function getLadderOpenPercent(symbol = getCurrentSymbol(), precision = readCurrentOrderbookPrecisionValue()) { return loadModeSymbolPrecisionNumberOption( localStorage, @@ -3050,6 +3081,21 @@ scheduleRenderPanel(); return true; } + function readLadderOptionContext(spec, symbol, precision) { + const percent = spec.mode === "OPEN" ? getLadderOpenPercent(symbol, precision) : getLadderClosePercent(symbol, precision); + return { + percent, + levels: getLadderLevels(spec.mode, symbol, precision), + ladderStep: getLadderStep(spec.mode, symbol, precision) + }; + } + function areLadderOptionContextsEqual(left, right) { + return left.percent === right.percent && left.levels === right.levels && left.ladderStep === right.ladderStep; + } + function hasLadderOptionContextChanged(plan) { + const current = readLadderOptionContext(plan.spec, plan.symbol, plan.precision); + return !areLadderOptionContextsEqual(current, plan.optionContext); + } function setLadderStatus(text, title = null) { ladderStatusText = String(text || "空闲"); const statusTitle = String(title || ladderStatusText); @@ -4107,31 +4153,64 @@ if (getCurrentSymbol() !== startSymbol) { throw new Error(`切换至${modeLabel}时交易对已变化,已停止`); } - if (!modeReady) throw new Error(`未能切换至${modeLabel}`); + if (!modeReady) { + throw createContinuousRecoverableLadderError( + "controls_not_ready", + `未能切换至${modeLabel}` + ); + } const startPrecision = readCurrentOrderbookPrecisionValue(); - if (!startPrecision) throw new Error("未识别价格精度"); + if (!startPrecision) { + throw createContinuousRecoverableLadderError( + "market_data_not_ready", + "未识别价格精度" + ); + } if (expectedContext?.precision && startPrecision !== expectedContext.precision) { - throw new Error("重挂前价格精度已变化,已停止"); + throw createContinuousRecoverableLadderError( + "precision_changed", + "重挂前价格精度已变化,已停止" + ); } const postOnlyReady = await ensurePostOnlyOrderType(); - if (!postOnlyReady) throw new Error("只做 Maker 未生效,请刷新页面后重试"); - const levels = getLadderLevels(spec.mode, startSymbol, startPrecision); - const ladderStep = getLadderStep(spec.mode, startSymbol, startPrecision); + if (!postOnlyReady) { + throw createContinuousRecoverableLadderError( + "controls_not_ready", + "只做 Maker 未生效,请刷新页面后重试" + ); + } + const optionContext = readLadderOptionContext(spec, startSymbol, startPrecision); + const levels = optionContext.levels; + const ladderStep = optionContext.ladderStep; const prices = getBufferedMakerPrices(spec.priceSide, levels, ladderStep); if (prices.length < levels) { - throw new Error(`订单簿${spec.priceSide === "BID" ? "买盘" : "卖盘"}不足 ${levels} 档,档幅 ${ladderStep}`); + throw createContinuousRecoverableLadderError( + "market_data_not_ready", + `订单簿${spec.priceSide === "BID" ? "买盘" : "卖盘"}不足 ${levels} 档,档幅 ${ladderStep}` + ); } const rules = await ensureRules(startSymbol); if (getCurrentSymbol() !== startSymbol) { throw new Error("读取交易规则时交易对已变化,已停止"); } if (readCurrentOrderbookPrecisionValue() !== startPrecision) { - throw new Error("读取交易规则时价格精度已变化,已停止"); + throw createContinuousRecoverableLadderError( + "precision_changed", + "读取交易规则时价格精度已变化,已停止" + ); + } + if (!rules) { + throw createContinuousRecoverableLadderError( + "market_data_not_ready", + "交易规则尚未就绪,请稍后重试" + ); } - if (!rules) throw new Error("交易规则尚未就绪,请稍后重试"); const ruleContext = getQtyRuleContext(startSymbol, spec.mode, prices[0]); if (ruleContext.status !== "ready" || !ruleContext.stepSize || !ruleContext.baseMinQty) { - throw new Error("下单数量规则尚未就绪,请稍后重试"); + throw createContinuousRecoverableLadderError( + "market_data_not_ready", + "下单数量规则尚未就绪,请稍后重试" + ); } const minRequiredQtyByLevel = spec.mode === "OPEN" ? prices.map((price) => getQtyRuleContext(startSymbol, spec.mode, price).effectiveMinQty || ruleContext.baseMinQty) : prices.map(() => ruleContext.baseMinQty); let minRequiredQty = minRequiredQtyByLevel.filter(Boolean).reduce((maxQty, qty) => maxDecimalString(maxQty, qty), ruleContext.baseMinQty); @@ -4144,23 +4223,39 @@ throw new Error(`读取${quantityLabel}时下单模式已变化,已停止`); } if (readCurrentOrderbookPrecisionValue() !== startPrecision) { - throw new Error(`读取${quantityLabel}时价格精度已变化,已停止`); + throw createContinuousRecoverableLadderError( + "precision_changed", + `读取${quantityLabel}时价格精度已变化,已停止` + ); } if (!isPostOnlyOrderTypeActive()) { throw new Error(`读取${quantityLabel}时只做 Maker 已失效,请刷新页面后重试`); } + if (!areLadderOptionContextsEqual( + readLadderOptionContext(spec, startSymbol, startPrecision), + optionContext + )) { + throw createContinuousRecoverableLadderError( + "options_changed", + "读取下单数量时比例、笔数或间距已变化" + ); + } const baseQty = normalizeDecimalString(base?.qty ?? ""); const unavailableQuantityMessage = getUnavailableLadderQuantityMessage( spec.mode, baseQty, base?.confirmedZeroOpenBalance === true ); - if (unavailableQuantityMessage) throw new Error(unavailableQuantityMessage); - let percent = getLadderPercentForMode( - spec.mode, - spec.mode === "OPEN" ? getLadderOpenPercent(startSymbol, startPrecision) : null, - spec.mode === "CLOSE" ? getLadderClosePercent(startSymbol, startPrecision) : null - ); + if (unavailableQuantityMessage) { + if (!baseQty) { + throw createContinuousRecoverableLadderError( + "market_data_not_ready", + unavailableQuantityMessage + ); + } + throw new Error(unavailableQuantityMessage); + } + let percent = optionContext.percent; if (percent == null) throw new Error("未知阶梯模式"); const totalQty = multiplyDecimalByRatio(baseQty, percent, 100); let allocation = allocateLadderQuantities(totalQty, levels, ruleContext.stepSize, minRequiredQty); @@ -4202,6 +4297,7 @@ spec, symbol: startSymbol, precision: startPrecision, + optionContext, percent, ladderStep, levels: allocation.actualLevels, @@ -4251,7 +4347,12 @@ function assertLadderMakerPrice(plan, price) { const oppositeSide = plan.spec.orderSide === "BUY" ? "ASK" : "BID"; const oppositePrice = getBestOrderbookPrice(oppositeSide); - if (!oppositePrice) throw new Error("盘口已刷新,未读取到对手盘价格"); + if (!oppositePrice) { + throw createContinuousRecoverableLadderError( + "market_data_not_ready", + "盘口已刷新,未读取到对手盘价格" + ); + } const cmp = compareDecimalStrings(price, oppositePrice); if (cmp == null) throw new Error("盘口价格校验失败"); if (plan.spec.orderSide === "BUY" && cmp >= 0) { @@ -4289,7 +4390,10 @@ if (remainingCount <= 0) throw new Error("没有待重定价的阶梯订单"); const prices = getBufferedMakerPrices(plan.spec.priceSide, remainingCount, plan.ladderStep); if (prices.length !== remainingCount) { - throw new Error(`刷新后订单簿${plan.spec.priceSide === "BID" ? "买盘" : "卖盘"}不足 ${remainingCount} 档`); + throw createContinuousRecoverableLadderError( + "market_data_not_ready", + `刷新后订单簿${plan.spec.priceSide === "BID" ? "买盘" : "卖盘"}不足 ${remainingCount} 档` + ); } const repricedOrders = repriceRemainingLadderOrders({ orders: plan.orders, @@ -4301,7 +4405,10 @@ const order = repricedOrders[index]; const ruleContext = getQtyRuleContext(plan.symbol, "OPEN", order.price); if (ruleContext.status !== "ready" || !ruleContext.effectiveMinQty) { - throw new Error("刷新盘口后最小下单量未就绪,已停止"); + throw createContinuousRecoverableLadderError( + "market_data_not_ready", + "刷新盘口后最小下单量未就绪" + ); } const comparison = compareDecimalStrings(order.qty, ruleContext.effectiveMinQty); if (comparison == null) throw new Error("刷新盘口后数量校验失败,已停止"); @@ -4317,7 +4424,16 @@ if (!isCurrentObservedSymbol(plan.symbol)) throw new Error("执行中交易对已变化,已停止"); if (getActiveTradeMode() !== plan.spec.mode) throw new Error("执行中下单模式已变化,已停止"); if (readCurrentOrderbookPrecisionValue() !== plan.precision) { - throw new Error("执行中价格精度已变化,已停止"); + throw createContinuousRecoverableLadderError( + "precision_changed", + "执行中价格精度已变化,已停止" + ); + } + if (hasLadderOptionContextChanged(plan)) { + throw createContinuousRecoverableLadderError( + "options_changed", + "执行中比例、笔数或间距已变化" + ); } if (!isPostOnlyOrderTypeActive()) throw new Error("执行中只做 Maker 已失效,请刷新页面后重试"); } @@ -4367,7 +4483,12 @@ maxWriteAttempts }) : setInputValueReact; const inputs = findTradeInputs(); - if (!inputs) throw new Error("未能确定唯一的当前交易表单输入框"); + if (!inputs) { + throw createContinuousRecoverableLadderError( + "controls_not_ready", + "未能确定唯一的当前交易表单输入框" + ); + } const observationRoot = inputs.root; const readTradeState = createTradeInputStateReader({ resolveInputs: findTradeInputs, @@ -4400,7 +4521,10 @@ priceLabel ); assertSubmittedQtyMatchesExpectedQty(expectedQty, findQtyInput()?.value || "", qtyLabel); - throw new Error("价格框或数量框未稳定,已停止提交"); + throw createContinuousRecoverableLadderError( + "input_unstable", + "价格框或数量框未稳定" + ); } function isSubmitButtonBusy(button) { if (!button) return false; @@ -4422,12 +4546,21 @@ if (button) return button; const currentButton = plan.spec.buttonGetter(); if (!currentButton || !currentButton.isConnected || !isVisibleElement(currentButton)) { - throw new Error(`下单按钮 ${TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS} 秒内未渲染完成`); + throw createContinuousRecoverableLadderError( + "controls_not_ready", + `下单按钮 ${TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS} 秒内未渲染完成` + ); } if (isSubmitButtonBusy(currentButton)) { - throw new Error(`下单按钮 ${TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS} 秒内未恢复可点击`); + throw createContinuousRecoverableLadderError( + "controls_not_ready", + `下单按钮 ${TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS} 秒内未恢复可点击` + ); } - throw new Error(`下单按钮 ${TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS} 秒内未达到可点击状态`); + throw createContinuousRecoverableLadderError( + "controls_not_ready", + `下单按钮 ${TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS} 秒内未达到可点击状态` + ); } function readVisibleOrderFeedbackEntries() { const selectors = [ @@ -4575,10 +4708,15 @@ async function executeLadderPlan(plan, progress, setExecutionStatus, abortSignal = null) { const priceInput = findPriceInput(); const qtyInput = findQtyInput(); - if (!priceInput) throw new Error("未找到价格输入框"); - if (!qtyInput) throw new Error("未找到数量输入框"); + if (!priceInput) { + throw createContinuousRecoverableLadderError("controls_not_ready", "未找到价格输入框"); + } + if (!qtyInput) { + throw createContinuousRecoverableLadderError("controls_not_ready", "未找到数量输入框"); + } let done = 0; let repriceAttempts = 0; + let consecutiveRepriceAttempts = 0; let lastRepriceApiErrorCode = null; let previousAcknowledgedInputs = null; while (done < plan.orders.length) { @@ -4597,8 +4735,18 @@ assertLadderMakerPrice(plan, order.price); const currentPriceInput = findPriceInput(); const currentQtyInput = findQtyInput(); - if (!currentPriceInput) throw new Error("执行中价格输入框已消失,已停止"); - if (!currentQtyInput) throw new Error("执行中数量输入框已消失,已停止"); + if (!currentPriceInput) { + throw createContinuousRecoverableLadderError( + "controls_not_ready", + "执行中价格输入框已消失" + ); + } + if (!currentQtyInput) { + throw createContinuousRecoverableLadderError( + "controls_not_ready", + "执行中数量输入框已消失" + ); + } const synchronizedInputs = await syncTradeInputs(order.price, order.qty, { priceLabel: "计划价", qtyLabel: "计划量", @@ -4611,6 +4759,7 @@ assertLadderMakerPrice(plan, submittedPrice); const button = await waitForReadyLadderSubmitButton(plan); throwIfAborted(abortSignal); + assertLadderExecutionContext(plan); assertSubmittedPriceMatchesExpectedPrice( order.price, findPriceInput()?.value || "", @@ -4652,20 +4801,24 @@ if (isBinancePostOnlyMakerRejectCode(e?.binanceCode)) { lastRepriceApiErrorCode = e.binanceCode; } - if (repriceAttempts >= LADDER_REPRICE_MAX_ATTEMPTS) { - const codeText = lastRepriceApiErrorCode == null ? "" : `(错误码 ${lastRepriceApiErrorCode})`; - throw new Error(`盘口连续移动,已自动刷新 ${repriceAttempts} 次${codeText};已挂 ${done}/${plan.orders.length} 笔后停止`); - } repriceAttempts += 1; - const repriceDetail = `盘口已移动,刷新剩余 ${plan.orders.length - done} 档 (${repriceAttempts}/${LADDER_REPRICE_MAX_ATTEMPTS})`; + consecutiveRepriceAttempts += 1; + const shouldPause = consecutiveRepriceAttempts >= LADDER_REPRICE_PAUSE_EVERY_ATTEMPTS; + const remainingLevels = plan.orders.length - done; + const repriceDetail = shouldPause ? `盘口持续移动,3s 后继续 · 剩余 ${remainingLevels} 档 · 已刷新 ${repriceAttempts} 次` : `盘口已移动,刷新剩余 ${remainingLevels} 档 · 已刷新 ${repriceAttempts} 次`; setExecutionStatus(`${plan.spec.label}:${repriceDetail}`, repriceDetail); - await delay(LADDER_REPRICE_DELAY_MS); + await waitForPromiseOrAbort( + delay(shouldPause ? LADDER_REPRICE_PAUSE_MS : LADDER_REPRICE_DELAY_MS), + abortSignal + ); throwIfAborted(abortSignal); if (ladderStopRequested) break; refreshRemainingLadderOrders(plan, done); + if (shouldPause) consecutiveRepriceAttempts = 0; continue; } done++; + consecutiveRepriceAttempts = 0; recordLadderSubmittedOrder(progress); setExecutionStatus(`${plan.spec.label}已挂 ${done}/${plan.orders.length} 笔`, null); throwIfAborted(abortSignal); @@ -4908,7 +5061,8 @@ const outcome = await startLadder(actionType, continuousProgress); if (!outcome?.progress) return outcome; recordContinuousLadderRound(continuousProgress, outcome); - if (outcome.status !== "completed") { + const recovery = outcome.status === "completed" ? null : resolveContinuousLadderRecovery(outcome.error); + if (outcome.status !== "completed" && !recovery) { setContinuousLadderProgressStatus( spec.label, outcome.status, @@ -4918,18 +5072,22 @@ ); return outcome; } - setContinuousLadderProgressStatus(spec.label, "running", continuousProgress); + if (!recovery) { + setContinuousLadderProgressStatus(spec.label, "running", continuousProgress); + } const readiness = await waitForContinuousLadderNextRound({ readReadiness: () => readContinuousLadderReadiness(actionType, actionSymbol), delay, signal: abortController.signal, + cooldownMs: recovery?.cooldownMs, onWaitStateChange: ({ phase, cooldownMs }) => { - setLadderStatus(formatContinuousLadderWaitProgress( + const waitStatus = formatContinuousLadderWaitProgress( spec.label, continuousProgress, phase, cooldownMs - )); + ); + setLadderStatus(recovery ? `${waitStatus} · ${recovery.reason}` : waitStatus); } }); if (readiness.status === "stopped") { @@ -4941,6 +5099,7 @@ ); return readiness; } + if (recovery) continue; } })(); continuousLadderTask = executionTask.catch((e) => { diff --git a/src/binance-orderbook-trade/core/continuous-ladder.js b/src/binance-orderbook-trade/core/continuous-ladder.js index 7d0795b..cba8207 100644 --- a/src/binance-orderbook-trade/core/continuous-ladder.js +++ b/src/binance-orderbook-trade/core/continuous-ladder.js @@ -6,6 +6,27 @@ import { snapshotLadderProgress } from './ladder-progress.js'; export const CONTINUOUS_LADDER_COOLDOWN_MS = 1000; export const CONTINUOUS_LADDER_READY_CHECK_MS = 50; +export const CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS = 3000; + +const CONTINUOUS_LADDER_RECOVERY = Object.freeze({ + input_unstable: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + }, + controls_not_ready: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + }, + market_data_not_ready: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + }, + precision_changed: { + cooldownMs: CONTINUOUS_LADDER_COOLDOWN_MS, + reason: '价格精度已变化,下一轮按新精度继续', + }, + options_changed: { + cooldownMs: CONTINUOUS_LADDER_COOLDOWN_MS, + reason: '比例、笔数或间距已变化,下一轮按新设置继续', + }, +}); const recordedRoundOutcomes = new WeakSet(); const CONTINUOUS_LADDER_PHASE_TEXT = Object.freeze({ @@ -45,6 +66,19 @@ export function createContinuousLadderProgress() { }; } +/** + * Recovers only when the failed submission attempt is proven not to have sent an order. + */ +export function resolveContinuousLadderRecovery(error) { + if (error?.safeNoSubmit !== true) return null; + const recovery = CONTINUOUS_LADDER_RECOVERY[error.continuousRecoveryKind]; + if (!recovery) return null; + return { + cooldownMs: recovery.cooldownMs, + reason: recovery.reason || error.message, + }; +} + export function recordContinuousLadderRound(progress, outcome) { assertContinuousLadderProgress(progress); if (!outcome || typeof outcome !== 'object') { diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 2810ad5..f41efb3 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.161 +// @version 2.7.162 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -74,7 +74,6 @@ import { allocateLadderQuantities } from './core/quantity.js'; import { fitLadderPlanForMinimumQty, getLadderActionSpec as getLadderActionSpecData, - getLadderPercentForMode, getUnavailableLadderQuantityMessage, } from './core/ladder-plan.js'; import { keepInteractionFeedbackVisible } from './core/interaction-feedback.js'; @@ -84,6 +83,7 @@ import { formatContinuousLadderProgress, formatContinuousLadderWaitProgress, recordContinuousLadderRound, + resolveContinuousLadderRecovery, waitForContinuousLadderNextRound, } from './core/continuous-ladder.js'; import { @@ -292,7 +292,8 @@ import { const CHART_ORDERS_MENU_TIMEOUT_MS = 1800; const CHART_ORDERS_MENU_POLL_MS = 50; const LADDER_MAKER_BUFFER_LEVELS = 1; - const LADDER_REPRICE_MAX_ATTEMPTS = 5; + const LADDER_REPRICE_PAUSE_EVERY_ATTEMPTS = 5; + const LADDER_REPRICE_PAUSE_MS = 3000; const LADDER_REPRICE_DELAY_MS = 180; const BINANCE_PLACE_ORDER_BAPI_PATH = '/bapi/futures/v1/private/future/order/place-order'; const BINANCE_USER_POSITION_BAPI_PATH = '/bapi/futures/v6/private/future/user-data/user-position'; @@ -578,6 +579,13 @@ import { return error?.name === 'LadderStoppedError'; } + function createContinuousRecoverableLadderError(kind, message) { + const error = new Error(message); + error.safeNoSubmit = true; + error.continuousRecoveryKind = kind; + return error; + } + function getLadderOpenPercent( symbol = getCurrentSymbol(), precision = readCurrentOrderbookPrecisionValue(), @@ -731,6 +739,30 @@ import { return true; } + function readLadderOptionContext(spec, symbol, precision) { + const percent = spec.mode === 'OPEN' + ? getLadderOpenPercent(symbol, precision) + : getLadderClosePercent(symbol, precision); + return { + percent, + levels: getLadderLevels(spec.mode, symbol, precision), + ladderStep: getLadderStep(spec.mode, symbol, precision), + }; + } + + function areLadderOptionContextsEqual(left, right) { + return ( + left.percent === right.percent + && left.levels === right.levels + && left.ladderStep === right.ladderStep + ); + } + + function hasLadderOptionContextChanged(plan) { + const current = readLadderOptionContext(plan.spec, plan.symbol, plan.precision); + return !areLadderOptionContextsEqual(current, plan.optionContext); + } + function setLadderStatus(text, title = null) { ladderStatusText = String(text || '空闲'); const statusTitle = String(title || ladderStatusText); @@ -2041,21 +2073,43 @@ import { if (getCurrentSymbol() !== startSymbol) { throw new Error(`切换至${modeLabel}时交易对已变化,已停止`); } - if (!modeReady) throw new Error(`未能切换至${modeLabel}`); + if (!modeReady) { + throw createContinuousRecoverableLadderError( + 'controls_not_ready', + `未能切换至${modeLabel}`, + ); + } const startPrecision = readCurrentOrderbookPrecisionValue(); - if (!startPrecision) throw new Error('未识别价格精度'); + if (!startPrecision) { + throw createContinuousRecoverableLadderError( + 'market_data_not_ready', + '未识别价格精度', + ); + } if (expectedContext?.precision && startPrecision !== expectedContext.precision) { - throw new Error('重挂前价格精度已变化,已停止'); + throw createContinuousRecoverableLadderError( + 'precision_changed', + '重挂前价格精度已变化,已停止', + ); } const postOnlyReady = await ensurePostOnlyOrderType(); - if (!postOnlyReady) throw new Error('只做 Maker 未生效,请刷新页面后重试'); + if (!postOnlyReady) { + throw createContinuousRecoverableLadderError( + 'controls_not_ready', + '只做 Maker 未生效,请刷新页面后重试', + ); + } - const levels = getLadderLevels(spec.mode, startSymbol, startPrecision); - const ladderStep = getLadderStep(spec.mode, startSymbol, startPrecision); + const optionContext = readLadderOptionContext(spec, startSymbol, startPrecision); + const levels = optionContext.levels; + const ladderStep = optionContext.ladderStep; const prices = getBufferedMakerPrices(spec.priceSide, levels, ladderStep); if (prices.length < levels) { - throw new Error(`订单簿${spec.priceSide === 'BID' ? '买盘' : '卖盘'}不足 ${levels} 档,档幅 ${ladderStep}`); + throw createContinuousRecoverableLadderError( + 'market_data_not_ready', + `订单簿${spec.priceSide === 'BID' ? '买盘' : '卖盘'}不足 ${levels} 档,档幅 ${ladderStep}`, + ); } const rules = await ensureRules(startSymbol); @@ -2063,13 +2117,24 @@ import { throw new Error('读取交易规则时交易对已变化,已停止'); } if (readCurrentOrderbookPrecisionValue() !== startPrecision) { - throw new Error('读取交易规则时价格精度已变化,已停止'); + throw createContinuousRecoverableLadderError( + 'precision_changed', + '读取交易规则时价格精度已变化,已停止', + ); + } + if (!rules) { + throw createContinuousRecoverableLadderError( + 'market_data_not_ready', + '交易规则尚未就绪,请稍后重试', + ); } - if (!rules) throw new Error('交易规则尚未就绪,请稍后重试'); const ruleContext = getQtyRuleContext(startSymbol, spec.mode, prices[0]); if (ruleContext.status !== 'ready' || !ruleContext.stepSize || !ruleContext.baseMinQty) { - throw new Error('下单数量规则尚未就绪,请稍后重试'); + throw createContinuousRecoverableLadderError( + 'market_data_not_ready', + '下单数量规则尚未就绪,请稍后重试', + ); } const minRequiredQtyByLevel = spec.mode === 'OPEN' ? prices.map((price) => getQtyRuleContext(startSymbol, spec.mode, price).effectiveMinQty || ruleContext.baseMinQty) @@ -2089,24 +2154,40 @@ import { throw new Error(`读取${quantityLabel}时下单模式已变化,已停止`); } if (readCurrentOrderbookPrecisionValue() !== startPrecision) { - throw new Error(`读取${quantityLabel}时价格精度已变化,已停止`); + throw createContinuousRecoverableLadderError( + 'precision_changed', + `读取${quantityLabel}时价格精度已变化,已停止`, + ); } if (!isPostOnlyOrderTypeActive()) { throw new Error(`读取${quantityLabel}时只做 Maker 已失效,请刷新页面后重试`); } + if (!areLadderOptionContextsEqual( + readLadderOptionContext(spec, startSymbol, startPrecision), + optionContext, + )) { + throw createContinuousRecoverableLadderError( + 'options_changed', + '读取下单数量时比例、笔数或间距已变化', + ); + } const baseQty = normalizeDecimalString(base?.qty ?? ''); const unavailableQuantityMessage = getUnavailableLadderQuantityMessage( spec.mode, baseQty, base?.confirmedZeroOpenBalance === true, ); - if (unavailableQuantityMessage) throw new Error(unavailableQuantityMessage); + if (unavailableQuantityMessage) { + if (!baseQty) { + throw createContinuousRecoverableLadderError( + 'market_data_not_ready', + unavailableQuantityMessage, + ); + } + throw new Error(unavailableQuantityMessage); + } - let percent = getLadderPercentForMode( - spec.mode, - spec.mode === 'OPEN' ? getLadderOpenPercent(startSymbol, startPrecision) : null, - spec.mode === 'CLOSE' ? getLadderClosePercent(startSymbol, startPrecision) : null, - ); + let percent = optionContext.percent; if (percent == null) throw new Error('未知阶梯模式'); const totalQty = multiplyDecimalByRatio(baseQty, percent, 100); let allocation = allocateLadderQuantities(totalQty, levels, ruleContext.stepSize, minRequiredQty); @@ -2149,6 +2230,7 @@ import { spec, symbol: startSymbol, precision: startPrecision, + optionContext, percent, ladderStep, levels: allocation.actualLevels, @@ -2211,7 +2293,12 @@ import { function assertLadderMakerPrice(plan, price) { const oppositeSide = plan.spec.orderSide === 'BUY' ? 'ASK' : 'BID'; const oppositePrice = getBestOrderbookPrice(oppositeSide); - if (!oppositePrice) throw new Error('盘口已刷新,未读取到对手盘价格'); + if (!oppositePrice) { + throw createContinuousRecoverableLadderError( + 'market_data_not_ready', + '盘口已刷新,未读取到对手盘价格', + ); + } const cmp = compareDecimalStrings(price, oppositePrice); if (cmp == null) throw new Error('盘口价格校验失败'); @@ -2256,7 +2343,10 @@ import { if (remainingCount <= 0) throw new Error('没有待重定价的阶梯订单'); const prices = getBufferedMakerPrices(plan.spec.priceSide, remainingCount, plan.ladderStep); if (prices.length !== remainingCount) { - throw new Error(`刷新后订单簿${plan.spec.priceSide === 'BID' ? '买盘' : '卖盘'}不足 ${remainingCount} 档`); + throw createContinuousRecoverableLadderError( + 'market_data_not_ready', + `刷新后订单簿${plan.spec.priceSide === 'BID' ? '买盘' : '卖盘'}不足 ${remainingCount} 档`, + ); } const repricedOrders = repriceRemainingLadderOrders({ orders: plan.orders, @@ -2268,7 +2358,10 @@ import { const order = repricedOrders[index]; const ruleContext = getQtyRuleContext(plan.symbol, 'OPEN', order.price); if (ruleContext.status !== 'ready' || !ruleContext.effectiveMinQty) { - throw new Error('刷新盘口后最小下单量未就绪,已停止'); + throw createContinuousRecoverableLadderError( + 'market_data_not_ready', + '刷新盘口后最小下单量未就绪', + ); } const comparison = compareDecimalStrings(order.qty, ruleContext.effectiveMinQty); if (comparison == null) throw new Error('刷新盘口后数量校验失败,已停止'); @@ -2285,7 +2378,16 @@ import { if (!isCurrentObservedSymbol(plan.symbol)) throw new Error('执行中交易对已变化,已停止'); if (getActiveTradeMode() !== plan.spec.mode) throw new Error('执行中下单模式已变化,已停止'); if (readCurrentOrderbookPrecisionValue() !== plan.precision) { - throw new Error('执行中价格精度已变化,已停止'); + throw createContinuousRecoverableLadderError( + 'precision_changed', + '执行中价格精度已变化,已停止', + ); + } + if (hasLadderOptionContextChanged(plan)) { + throw createContinuousRecoverableLadderError( + 'options_changed', + '执行中比例、笔数或间距已变化', + ); } if (!isPostOnlyOrderTypeActive()) throw new Error('执行中只做 Maker 已失效,请刷新页面后重试'); } @@ -2352,7 +2454,12 @@ import { }) : setInputValueReact; const inputs = findTradeInputs(); - if (!inputs) throw new Error('未能确定唯一的当前交易表单输入框'); + if (!inputs) { + throw createContinuousRecoverableLadderError( + 'controls_not_ready', + '未能确定唯一的当前交易表单输入框', + ); + } const observationRoot = inputs.root; const readTradeState = createTradeInputStateReader({ @@ -2389,7 +2496,10 @@ import { priceLabel, ); assertSubmittedQtyMatchesExpectedQty(expectedQty, findQtyInput()?.value || '', qtyLabel); - throw new Error('价格框或数量框未稳定,已停止提交'); + throw createContinuousRecoverableLadderError( + 'input_unstable', + '价格框或数量框未稳定', + ); } function isSubmitButtonBusy(button) { @@ -2422,12 +2532,21 @@ import { const currentButton = plan.spec.buttonGetter(); if (!currentButton || !currentButton.isConnected || !isVisibleElement(currentButton)) { - throw new Error(`下单按钮 ${TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS} 秒内未渲染完成`); + throw createContinuousRecoverableLadderError( + 'controls_not_ready', + `下单按钮 ${TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS} 秒内未渲染完成`, + ); } if (isSubmitButtonBusy(currentButton)) { - throw new Error(`下单按钮 ${TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS} 秒内未恢复可点击`); + throw createContinuousRecoverableLadderError( + 'controls_not_ready', + `下单按钮 ${TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS} 秒内未恢复可点击`, + ); } - throw new Error(`下单按钮 ${TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS} 秒内未达到可点击状态`); + throw createContinuousRecoverableLadderError( + 'controls_not_ready', + `下单按钮 ${TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS} 秒内未达到可点击状态`, + ); } function readVisibleOrderFeedbackEntries() { @@ -2607,11 +2726,16 @@ import { async function executeLadderPlan(plan, progress, setExecutionStatus, abortSignal = null) { const priceInput = findPriceInput(); const qtyInput = findQtyInput(); - if (!priceInput) throw new Error('未找到价格输入框'); - if (!qtyInput) throw new Error('未找到数量输入框'); + if (!priceInput) { + throw createContinuousRecoverableLadderError('controls_not_ready', '未找到价格输入框'); + } + if (!qtyInput) { + throw createContinuousRecoverableLadderError('controls_not_ready', '未找到数量输入框'); + } let done = 0; let repriceAttempts = 0; + let consecutiveRepriceAttempts = 0; let lastRepriceApiErrorCode = null; let previousAcknowledgedInputs = null; while (done < plan.orders.length) { @@ -2632,8 +2756,18 @@ import { const currentPriceInput = findPriceInput(); const currentQtyInput = findQtyInput(); - if (!currentPriceInput) throw new Error('执行中价格输入框已消失,已停止'); - if (!currentQtyInput) throw new Error('执行中数量输入框已消失,已停止'); + if (!currentPriceInput) { + throw createContinuousRecoverableLadderError( + 'controls_not_ready', + '执行中价格输入框已消失', + ); + } + if (!currentQtyInput) { + throw createContinuousRecoverableLadderError( + 'controls_not_ready', + '执行中数量输入框已消失', + ); + } const synchronizedInputs = await syncTradeInputs(order.price, order.qty, { priceLabel: '计划价', @@ -2648,6 +2782,7 @@ import { const button = await waitForReadyLadderSubmitButton(plan); throwIfAborted(abortSignal); + assertLadderExecutionContext(plan); assertSubmittedPriceMatchesExpectedPrice( order.price, findPriceInput()?.value || '', @@ -2690,20 +2825,26 @@ import { if (isBinancePostOnlyMakerRejectCode(e?.binanceCode)) { lastRepriceApiErrorCode = e.binanceCode; } - if (repriceAttempts >= LADDER_REPRICE_MAX_ATTEMPTS) { - const codeText = lastRepriceApiErrorCode == null ? '' : `(错误码 ${lastRepriceApiErrorCode})`; - throw new Error(`盘口连续移动,已自动刷新 ${repriceAttempts} 次${codeText};已挂 ${done}/${plan.orders.length} 笔后停止`); - } repriceAttempts += 1; - const repriceDetail = `盘口已移动,刷新剩余 ${plan.orders.length - done} 档 (${repriceAttempts}/${LADDER_REPRICE_MAX_ATTEMPTS})`; + consecutiveRepriceAttempts += 1; + const shouldPause = consecutiveRepriceAttempts >= LADDER_REPRICE_PAUSE_EVERY_ATTEMPTS; + const remainingLevels = plan.orders.length - done; + const repriceDetail = shouldPause + ? `盘口持续移动,3s 后继续 · 剩余 ${remainingLevels} 档 · 已刷新 ${repriceAttempts} 次` + : `盘口已移动,刷新剩余 ${remainingLevels} 档 · 已刷新 ${repriceAttempts} 次`; setExecutionStatus(`${plan.spec.label}:${repriceDetail}`, repriceDetail); - await delay(LADDER_REPRICE_DELAY_MS); + await waitForPromiseOrAbort( + delay(shouldPause ? LADDER_REPRICE_PAUSE_MS : LADDER_REPRICE_DELAY_MS), + abortSignal, + ); throwIfAborted(abortSignal); if (ladderStopRequested) break; refreshRemainingLadderOrders(plan, done); + if (shouldPause) consecutiveRepriceAttempts = 0; continue; } done++; + consecutiveRepriceAttempts = 0; recordLadderSubmittedOrder(progress); setExecutionStatus(`${plan.spec.label}已挂 ${done}/${plan.orders.length} 笔`, null); throwIfAborted(abortSignal); @@ -2981,7 +3122,10 @@ import { const outcome = await startLadder(actionType, continuousProgress); if (!outcome?.progress) return outcome; recordContinuousLadderRound(continuousProgress, outcome); - if (outcome.status !== 'completed') { + const recovery = outcome.status === 'completed' + ? null + : resolveContinuousLadderRecovery(outcome.error); + if (outcome.status !== 'completed' && !recovery) { setContinuousLadderProgressStatus( spec.label, outcome.status, @@ -2991,19 +3135,23 @@ import { ); return outcome; } - setContinuousLadderProgressStatus(spec.label, 'running', continuousProgress); + if (!recovery) { + setContinuousLadderProgressStatus(spec.label, 'running', continuousProgress); + } const readiness = await waitForContinuousLadderNextRound({ readReadiness: () => readContinuousLadderReadiness(actionType, actionSymbol), delay, signal: abortController.signal, + cooldownMs: recovery?.cooldownMs, onWaitStateChange: ({ phase, cooldownMs }) => { - setLadderStatus(formatContinuousLadderWaitProgress( + const waitStatus = formatContinuousLadderWaitProgress( spec.label, continuousProgress, phase, cooldownMs, - )); + ); + setLadderStatus(recovery ? `${waitStatus} · ${recovery.reason}` : waitStatus); }, }); if (readiness.status === 'stopped') { @@ -3015,6 +3163,7 @@ import { ); return readiness; } + if (recovery) continue; } })(); diff --git a/test/unit/binance-orderbook-trade/continuous-ladder.test.js b/test/unit/binance-orderbook-trade/continuous-ladder.test.js index 9b1d8bc..8ef73e0 100644 --- a/test/unit/binance-orderbook-trade/continuous-ladder.test.js +++ b/test/unit/binance-orderbook-trade/continuous-ladder.test.js @@ -4,12 +4,14 @@ import assert from 'node:assert/strict'; import { CONTINUOUS_LADDER_COOLDOWN_MS, CONTINUOUS_LADDER_READY_CHECK_MS, + CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, createContinuousLadderProgress, formatActiveContinuousLadderProgress, formatContinuousLadderProgress, formatContinuousLadderWaitProgress, formatContinuousLadderWaitReason, recordContinuousLadderRound, + resolveContinuousLadderRecovery, waitForContinuousLadderNextRound, } from '../../../src/binance-orderbook-trade/core/continuous-ladder.js'; @@ -147,6 +149,83 @@ test('continuous ladder progress rejects an invalid or duplicate round outcome', ); }); +test('continuous ladder retries only explicitly safe transient failures', () => { + const inputUnstable = new Error('价格框或数量框未稳定'); + inputUnstable.safeNoSubmit = true; + inputUnstable.continuousRecoveryKind = 'input_unstable'; + assert.deepEqual(resolveContinuousLadderRecovery(inputUnstable), { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + reason: '价格框或数量框未稳定', + }); + + const marketDataNotReady = new Error('盘口数据未就绪'); + marketDataNotReady.safeNoSubmit = true; + marketDataNotReady.continuousRecoveryKind = 'market_data_not_ready'; + assert.deepEqual(resolveContinuousLadderRecovery(marketDataNotReady), { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + reason: '盘口数据未就绪', + }); + + const precisionChanged = new Error('价格精度已变化'); + precisionChanged.safeNoSubmit = true; + precisionChanged.continuousRecoveryKind = 'precision_changed'; + assert.deepEqual(resolveContinuousLadderRecovery(precisionChanged), { + cooldownMs: CONTINUOUS_LADDER_COOLDOWN_MS, + reason: '价格精度已变化,下一轮按新精度继续', + }); + + const optionsChanged = new Error('阶梯设置已变化'); + optionsChanged.safeNoSubmit = true; + optionsChanged.continuousRecoveryKind = 'options_changed'; + assert.deepEqual(resolveContinuousLadderRecovery(optionsChanged), { + cooldownMs: CONTINUOUS_LADDER_COOLDOWN_MS, + reason: '比例、笔数或间距已变化,下一轮按新设置继续', + }); + + const unknownOutcome = new Error('仍未确认订单结果'); + unknownOutcome.safeNoSubmit = false; + unknownOutcome.continuousRecoveryKind = 'input_unstable'; + assert.equal(resolveContinuousLadderRecovery(unknownOutcome), null); + + const unsupported = new Error('未知错误'); + unsupported.safeNoSubmit = true; + unsupported.continuousRecoveryKind = 'unknown'; + assert.equal(resolveContinuousLadderRecovery(unsupported), null); +}); + +test('a settings change preserves the partial round before the next round uses new settings', () => { + const progress = createContinuousLadderProgress(); + const optionsChanged = new Error('执行中比例、笔数或间距已变化'); + optionsChanged.safeNoSubmit = true; + optionsChanged.continuousRecoveryKind = 'options_changed'; + const outcome = { + status: 'failed', + error: optionsChanged, + progress: roundProgress({ + submittedOrders: 2, + plannedOrders: 3, + currentPlanSubmittedOrders: 2, + }), + }; + + recordContinuousLadderRound(progress, outcome); + const recovery = resolveContinuousLadderRecovery(optionsChanged); + + assert.deepEqual(recovery, { + cooldownMs: CONTINUOUS_LADDER_COOLDOWN_MS, + reason: '比例、笔数或间距已变化,下一轮按新设置继续', + }); + assert.equal( + formatContinuousLadderWaitProgress( + '阶梯平空', + progress, + 'cooldown', + recovery.cooldownMs, + ), + '连续阶梯平空 · 1s 后继续 · 0/1 轮 · 本轮 2/3 笔 · 累计 2 笔', + ); +}); + test('continuous ladder starts its cooldown only after the previous round is ready', async () => { const states = [ { status: 'waiting' }, diff --git a/test/unit/binance-orderbook-trade/ladder-options.test.js b/test/unit/binance-orderbook-trade/ladder-options.test.js index 5f770db..fb63f9a 100644 --- a/test/unit/binance-orderbook-trade/ladder-options.test.js +++ b/test/unit/binance-orderbook-trade/ladder-options.test.js @@ -215,12 +215,12 @@ test('storage events accept six mode-scoped keys and reject legacy shared keys', }); test('ladder execution and UI updates use one captured mode-symbol-precision context', () => { - assert.match(source, /getLadderLevels\(spec\.mode,\s*startSymbol,\s*startPrecision\)/); - assert.match(source, /getLadderStep\(spec\.mode,\s*startSymbol,\s*startPrecision\)/); - assert.match(source, /getLadderOpenPercent\(startSymbol,\s*startPrecision\)/); - assert.match(source, /getLadderClosePercent\(startSymbol,\s*startPrecision\)/); - assert.match(source, /spec\.mode === 'OPEN' \? getLadderOpenPercent\(startSymbol, startPrecision\) : null/); - assert.match(source, /spec\.mode === 'CLOSE' \? getLadderClosePercent\(startSymbol, startPrecision\) : null/); + assert.match(source, /const optionContext = readLadderOptionContext\(spec,\s*startSymbol,\s*startPrecision\)/); + assert.match(source, /levels: getLadderLevels\(spec\.mode,\s*symbol,\s*precision\)/); + assert.match(source, /ladderStep: getLadderStep\(spec\.mode,\s*symbol,\s*precision\)/); + assert.match(source, /getLadderOpenPercent\(symbol,\s*precision\)/); + assert.match(source, /getLadderClosePercent\(symbol,\s*precision\)/); + assert.match(source, /let percent = optionContext\.percent/); assert.match(source, /const optionContext = getPanelOptionContext\(\)/); assert.match(source, /setLadderLevels\(value, optionContext\.mode, optionContext\.symbol, optionContext\.precision\)/); assert.match(source, /setLadderStep\(value, optionContext\.mode, optionContext\.symbol, optionContext\.precision\)/); diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 3a5ca74..8101f66 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -271,8 +271,10 @@ test('open and close ladders reprice only remaining orders after explicit maker assert.match(retryBody, /isBinancePostOnlyMakerRejectCode\(error\?\.binanceCode\)/); assert.match(retryBody, /maker_price_conflict'\) return error\.safeNoSubmit === true/); assert.match(retryBody, /isBinancePostOnlyMakerRejectCode\(error\?\.binanceCode\) && error\.safeNoSubmit === true/); - assert.match(source, /const LADDER_REPRICE_MAX_ATTEMPTS = 5;/); - assert.match(generatedSource, /const LADDER_REPRICE_MAX_ATTEMPTS = 5;/); + assert.match(source, /const LADDER_REPRICE_PAUSE_EVERY_ATTEMPTS = 5;/); + assert.match(source, /const LADDER_REPRICE_PAUSE_MS = 3000;/); + assert.match(generatedSource, /const LADDER_REPRICE_PAUSE_EVERY_ATTEMPTS = 5;/); + assert.doesNotMatch(source, /LADDER_REPRICE_MAX_ATTEMPTS/); const interceptBody = readFunctionBody('installFetchInterceptor'); assert.match(interceptBody, /activeLadderSubmitCapture/); @@ -326,17 +328,24 @@ test('open and close ladders reprice only remaining orders after explicit maker assert.doesNotMatch(repriceBody, /cancelCurrentSymbolOpenOrders/); const executeBody = readFunctionBody('executeLadderPlan'); - assert.match(executeBody, /LADDER_REPRICE_MAX_ATTEMPTS/); + assert.match(executeBody, /consecutiveRepriceAttempts/); + assert.match(executeBody, /LADDER_REPRICE_PAUSE_EVERY_ATTEMPTS/); + assert.match(executeBody, /LADDER_REPRICE_PAUSE_MS/); + assert.doesNotMatch(executeBody, /throw new Error\(`盘口连续移动/); assert.match(executeBody, /beginLadderSubmitResponseCapture\(\)/); const readyButtonBody = readFunctionBody('waitForReadyLadderSubmitButton'); assert.match(readyButtonBody, /!isSubmitButtonBusy\(candidate\)/); assert.match(source, /button\.getAttribute\('aria-busy'\) === 'true'/); assert.match(executeBody, /endLadderSubmitResponseCapture\(submitCaptureId\)/); assert.match(executeBody, /waitForOrderSubmitAcknowledgement\([\s\S]*plan\.spec\.mode/); + assert.match( + executeBody, + /const button = await waitForReadyLadderSubmitButton\(plan\);\s*throwIfAborted\(abortSignal\);\s*assertLadderExecutionContext\(plan\);/, + ); assert.match(executeBody, /refreshRemainingLadderOrders\(plan,\s*done\)/); assert.match(executeBody, /lastRepriceApiErrorCode/); assert.match(executeBody, /return \{ done, repriceAttempts, lastRepriceApiErrorCode \}/); - assert.match(executeBody, /盘口连续移动/); + assert.match(executeBody, /盘口持续移动/); assert.doesNotMatch(executeBody, /binanceCode\s*=\s*BINANCE_GTX_ORDER_REJECT_CODE/); assert.match(generatedSource, /90805022/); @@ -527,10 +536,8 @@ test('direction selector is a compact mutually exclusive radio group', () => { test('ladder feedback labels captured API codes without exposing bare numbers', () => { const apiErrorBody = readFunctionBody('createLadderSubmitApiError'); const diagnosticsBody = readFunctionBody('formatLadderRepriceDiagnostics'); - const executeBody = readFunctionBody('executeLadderPlan'); assert.match(apiErrorBody, /Maker 挂单被拒绝(错误码 \$\{apiErrorCode\})/); assert.match(diagnosticsBody, /错误码 \$\{lastRepriceApiErrorCode\}/); - assert.match(executeBody, /错误码 \$\{lastRepriceApiErrorCode\}/); assert.doesNotMatch(source, /错误码:/); assert.doesNotMatch(source, /\(\$\{apiErrorCode\}\)/); }); @@ -628,6 +635,9 @@ test('Option or Alt click continuously repeats close ladders only after readines assert.match(continuousBody, /while \(true\)/); assert.match(continuousBody, /await startLadder\(actionType, continuousProgress\)/); assert.match(continuousBody, /recordContinuousLadderRound\(continuousProgress, outcome\)/); + assert.match(continuousBody, /resolveContinuousLadderRecovery\(outcome\.error\)/); + assert.match(continuousBody, /recovery\?\.cooldownMs/); + assert.match(continuousBody, /continue/); assert.match(continuousBody, /setContinuousLadderProgressStatus\(/); assert.match(continuousBody, /await waitForContinuousLadderNextRound\(/); assert.match(continuousBody, /readContinuousLadderReadiness\(actionType, actionSymbol\)/); @@ -1585,11 +1595,16 @@ test('precision changes invalidate edits and immediately rerender the panel', () test('ladder plans fail closed when orderbook precision changes', () => { const buildBody = readFunctionBody('buildLadderPlan'); assert.match(buildBody, /const startPrecision = readCurrentOrderbookPrecisionValue\(\)/); - assert.match(buildBody, /if \(!startPrecision\) throw new Error\('未识别价格精度'\)/); + assert.match(buildBody, /createContinuousRecoverableLadderError\(\s*'market_data_not_ready',\s*'未识别价格精度'/); assert.match(buildBody, /precision: startPrecision/); + assert.match(buildBody, /const optionContext = readLadderOptionContext\(spec,\s*startSymbol,\s*startPrecision\)/); + assert.match(buildBody, /areLadderOptionContextsEqual\(/); const contextBody = readFunctionBody('assertLadderExecutionContext'); assert.match(contextBody, /readCurrentOrderbookPrecisionValue\(\) !== plan\.precision/); + assert.match(contextBody, /createContinuousRecoverableLadderError\(\s*'precision_changed'/); + assert.match(contextBody, /hasLadderOptionContextChanged\(plan\)/); + assert.match(contextBody, /createContinuousRecoverableLadderError\(\s*'options_changed'/); const replacementBody = readFunctionBody('createLadderExpectedContext'); assert.match(replacementBody, /symbol: plan\.symbol/); @@ -1597,6 +1612,25 @@ test('ladder plans fail closed when orderbook precision changes', () => { assert.match(replacementBody, /precision: plan\.precision/); }); +test('continuous close ladders recover only from tagged pre-submit transients', () => { + const syncBody = readFunctionBody('syncTradeInputs'); + const buttonBody = readFunctionBody('waitForReadyLadderSubmitButton'); + const executeBody = readFunctionBody('executeLadderPlan'); + const buildBody = readFunctionBody('buildLadderPlan'); + const makerBody = readFunctionBody('assertLadderMakerPrice'); + const repriceBody = readFunctionBody('refreshRemainingLadderOrders'); + const recoveryBody = readFunctionBody('createContinuousRecoverableLadderError'); + + assert.match(syncBody, /createContinuousRecoverableLadderError\(\s*'input_unstable'/); + assert.match(buttonBody, /createContinuousRecoverableLadderError\(\s*'controls_not_ready'/); + assert.match(executeBody, /createContinuousRecoverableLadderError\(\s*'controls_not_ready'/); + assert.match(buildBody, /createContinuousRecoverableLadderError\(\s*'market_data_not_ready'/); + assert.match(makerBody, /createContinuousRecoverableLadderError\(\s*'market_data_not_ready'/); + assert.match(repriceBody, /createContinuousRecoverableLadderError\(\s*'market_data_not_ready'/); + assert.match(recoveryBody, /error\.safeNoSubmit = true/); + assert.match(recoveryBody, /error\.continuousRecoveryKind = kind/); +}); + test('single-order sizing and submission retain the captured orderbook precision', () => { const resolveBody = readFunctionBody('resolveTargetQty'); assert.match(resolveBody, /const precision = readCurrentOrderbookPrecisionValue\(\)/);