diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md index c09a19f..b34e689 100644 --- a/docs/binance-orderbook-trade-development.md +++ b/docs/binance-orderbook-trade-development.md @@ -171,6 +171,34 @@ Required properties: - readable, non-minified output - generated `@version` matches `src/binance-orderbook-trade/index.user.js` +## Continuous Close Reduce-Only Recovery + +A continuous close round handles `90802022` only when one settled native JSON +response reports `success=false`, its parsed code is `90802022`, and no successful +or additional response was captured. Toast text alone never authorizes recovery. +The first rejection establishes an authoritative same-symbol, same-side position +quantity baseline. Recovery waits the existing three-second readiness cooldown +and reads that quantity again. Confirmed flat ends normally; an increased +position stops the round. A strict decrease permits rebuilding without cancelling. + +An unchanged quantity permits one confirmed replacement through the existing +current-symbol, same-close-direction Basic-order workflow. Rejection after that +replacement triggers another cooldown and position read, not another immediate +replacement. If the position still has not decreased, recovery ends with the +native error and a no-progress explanation. Further replacements require a strict +decrease in authoritative position quantity. Submissions and cancellations do not +count as position progress. The guard lasts for the whole active round. + +Recovery rebuilds use the smaller of the confirmed API position quantity and the +latest valid DOM closeable quantity. Missing DOM quantity never falls back to the +API amount. Symbol, direction, precision, and option context stay pinned to the +active round. Cancel, position-read, context, and unconfirmed-submit errors during +this recovery are terminal and cannot inherit unrelated continuous retry policies. +Once reduce-only recovery begins, the executor disables max-open-orders capacity +recovery too, so a later `90802025` cannot trigger a separate batch of cancellations. +Ordinary single-round clicks and OPEN ladders retain their existing replacement +contract. No additional API or DOM cancellation path is introduced. + ## Versioning Bump `@version` in `src/binance-orderbook-trade/index.user.js` when behavior changes, then run: diff --git a/e2e/binance-orderbook/specs/close-ladder-recovery.pw.js b/e2e/binance-orderbook/specs/close-ladder-recovery.pw.js new file mode 100644 index 0000000..00a58a6 --- /dev/null +++ b/e2e/binance-orderbook/specs/close-ladder-recovery.pw.js @@ -0,0 +1,132 @@ +import { test, expect } from '../test.js'; +import { CURRENT_SYMBOL, createCancelScenario } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario, readFixtureState } from '../helpers/userscript-page.js'; + +test('continuous close preserves partial submissions through repeated reduce-only rejections and confirmed flat', async ({ page }) => { + test.setTimeout(30_000); + const scenario = createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], + ui: { tradeMode: 'CLOSE', orderbookPrecision: '0.1' }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + let submissions = 0; + const quantities = ['100', '80', '80', '60', '0']; + let positionReads = 0; + await page.route('**/bapi/futures/v1/private/future/order/place-order', async (route) => { + submissions += 1; + expect(submissions).toBeLessThanOrEqual(5); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(submissions <= 2 ? { success: true } : { + success: false, code: '90802022', message: 'Reduce-only order failed', + }), + }); + }); + await page.route('**/bapi/futures/v6/private/future/user-data/user-position', async (route) => { + const quantity = quantities[positionReads++]; + expect(quantity).not.toBeUndefined(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: [{ symbol: CURRENT_SYMBOL, positionSide: 'SHORT', positionAmount: `-${quantity}` }], + }), + }); + }); + const panel = page.locator('#jh-binance-close-qty-multiplier-panel'); + await panel.getByRole('button', { name: '阶梯平空', exact: true }).click({ modifiers: ['Alt'] }); + const status = panel.locator('#jh-binance-ladder-status'); + await expect(status).toContainText('只减仓冲突,3s 后复核仓位', { timeout: 8_000 }); + await expect(status).toContainText('当前方向已无持仓', { timeout: 18_000 }); + await expect(status).toContainText('连续阶梯平空'); + await expect(status).toContainText('累计 2 笔'); + expect(submissions).toBe(5); + expect(positionReads).toBe(5); + const events = (await readFixtureState(page)).events; + const orders = events.filter(({ type }) => type === 'order-submitted'); + expect(orders.map(({ quantity }) => quantity)).toEqual(['0.06', '0.06', '0.06', '0.04', '0.03']); + expect(events.filter(({ type }) => /cancel/.test(type))).toEqual([]); + expect(errors).toEqual([]); +}); + +test('a close that disables the native button during recovery still confirms flat', async ({ page }) => { + const scenario = createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], + ui: { tradeMode: 'CLOSE', orderbookPrecision: '0.1' }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + let submissions = 0; + let positionReads = 0; + await page.route('**/bapi/futures/v1/private/future/order/place-order', async (route) => { + submissions += 1; + await route.fulfill({ + status: 200, contentType: 'application/json', + body: JSON.stringify({ success: false, code: 90802022, message: 'Reduce-only order failed' }), + }); + }); + await page.route('**/bapi/futures/v6/private/future/user-data/user-position', async (route) => { + positionReads += 1; + expect(positionReads).toBeLessThanOrEqual(2); + if (positionReads === 1) { + await page.getByRole('button', { name: '平空', exact: true }).evaluate((button) => { + button.disabled = true; + }); + } + await route.fulfill({ + status: 200, contentType: 'application/json', + body: JSON.stringify({ success: true, data: [{ + symbol: CURRENT_SYMBOL, positionSide: 'SHORT', positionAmount: positionReads === 1 ? '-100' : '0', + }] }), + }); + }); + const panel = page.locator('#jh-binance-close-qty-multiplier-panel'); + await panel.getByRole('button', { name: '阶梯平空', exact: true }).click({ modifiers: ['Alt'] }); + await expect(panel.locator('#jh-binance-ladder-status')).toContainText('当前方向已无持仓', { timeout: 10_000 }); + expect(submissions).toBe(1); + expect(positionReads).toBe(2); + expect(errors).toEqual([]); +}); + +test('a capacity rejection during reduce-only recovery stops without entering batch cancellation', async ({ page }) => { + const scenario = createCancelScenario({ + positions: [{ symbol: CURRENT_SYMBOL, side: 'SHORT', quantity: '100' }], + ui: { tradeMode: 'CLOSE', orderbookPrecision: '0.1' }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + let submissions = 0; + let positionReads = 0; + await page.route('**/bapi/futures/v1/private/future/order/place-order', async (route) => { + submissions += 1; + expect(submissions).toBeLessThanOrEqual(2); + await route.fulfill({ + status: 200, contentType: 'application/json', + body: JSON.stringify(submissions === 1 + ? { success: false, code: 90802022 } + : { success: false, code: 90802025, message: 'Maximum open orders' }), + }); + }); + await page.route('**/bapi/futures/v6/private/future/user-data/user-position', async (route) => { + positionReads += 1; + expect(positionReads).toBeLessThanOrEqual(2); + await route.fulfill({ + status: 200, contentType: 'application/json', + body: JSON.stringify({ success: true, data: [{ + symbol: CURRENT_SYMBOL, positionSide: 'SHORT', positionAmount: positionReads === 1 ? '-100' : '-80', + }] }), + }); + }); + const panel = page.locator('#jh-binance-close-qty-multiplier-panel'); + await panel.getByRole('button', { name: '阶梯平空', exact: true }).click({ modifiers: ['Alt'] }); + const status = panel.locator('#jh-binance-ladder-status'); + await expect(status).toContainText('失败', { timeout: 10_000 }); + await expect(status).toContainText('Maximum open orders'); + await expect(status).toContainText('90802025'); + await expect(status).toContainText('只减仓订单被拒绝'); + await expect(status).not.toContainText('null'); + expect(submissions).toBe(2); + expect(positionReads).toBe(2); + expect((await readFixtureState(page)).events.filter(({ type }) => /cancel/.test(type))).toEqual([]); + expect(errors).toEqual([]); +}); diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 78eccb3..d6bfad3 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.201 +// @version 2.7.202 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -474,79 +474,6 @@ return Boolean(actionDisabled || knowsPosition && !hasPosition); } - // src/binance-orderbook-trade/core/auto-open-leverage.js - var POSITION_STATUSES = /* @__PURE__ */ new Set(["unknown", "has_position", "flat"]); - function createPositionPayloadContractError(message) { - const error = new Error(message); - error.name = "PositionPayloadContractError"; - return error; - } - function parsePositionAmount(value) { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && /^-?(?:\d+\.?\d*|\.\d+)$/.test(value.trim())) { - return Number(value); - } - throw createPositionPayloadContractError(`持仓数量无效:${String(value)}`); - } - function resolveSymbolPositionStatus(payload, symbol) { - if (payload?.success !== true) throw createPositionPayloadContractError("持仓接口返回失败"); - if (!Array.isArray(payload.data)) throw createPositionPayloadContractError("持仓接口数据格式异常"); - if (!symbol) throw createPositionPayloadContractError("持仓接口缺少交易对"); - const positions = payload.data.filter((position) => position?.symbol === symbol); - const hasPosition = positions.some((position) => parsePositionAmount(position.positionAmount) !== 0); - return { - status: hasPosition ? "has_position" : "flat", - matchingPositionCount: positions.length - }; - } - function resolveSymbolPositionSideStatus(payload, symbol, side) { - if (payload?.success !== true) throw createPositionPayloadContractError("持仓接口返回失败"); - if (!Array.isArray(payload.data)) throw createPositionPayloadContractError("持仓接口数据格式异常"); - if (!symbol) throw createPositionPayloadContractError("持仓接口缺少交易对"); - if (side !== "LONG" && side !== "SHORT") { - throw createPositionPayloadContractError(`目标持仓方向无效:${String(side)}`); - } - const positions = payload.data.filter((position) => position?.symbol === symbol); - let matchingPositionCount = 0; - let hasPosition = false; - for (const position of positions) { - const positionSide = position.positionSide; - if (!["BOTH", "LONG", "SHORT"].includes(positionSide)) { - throw createPositionPayloadContractError(`持仓方向无效:${String(positionSide)}`); - } - const amount = parsePositionAmount(position.positionAmount); - if (positionSide === side) { - matchingPositionCount += 1; - if (amount !== 0) hasPosition = true; - continue; - } - if (positionSide === "BOTH") { - matchingPositionCount += 1; - if (side === "LONG" && amount > 0 || side === "SHORT" && amount < 0) { - hasPosition = true; - } - } - } - return { - status: hasPosition ? "has_position" : "flat", - matchingPositionCount - }; - } - function observeAutoOpenLeveragePositionState(previousState, observation) { - const { symbol, status } = observation; - if (!symbol) throw new Error("自动杠杆检查缺少交易对"); - if (!POSITION_STATUSES.has(status)) { - throw new Error(`自动杠杆持仓状态无效:${status}`); - } - const isSameSymbol = previousState?.symbol === symbol; - const previousKnownStatus = isSameSymbol ? previousState.lastKnownStatus : null; - const lastKnownStatus = status === "unknown" ? previousKnownStatus : status; - return { - state: { symbol, lastKnownStatus }, - shouldReset: status === "flat" && previousKnownStatus !== "flat" - }; - } - // src/binance-orderbook-trade/core/decimal.js function pow10(exp) { let result = 1n; @@ -669,6 +596,98 @@ return !!parsed && parsed.digits > 0n; } + // src/binance-orderbook-trade/core/auto-open-leverage.js + var POSITION_STATUSES = /* @__PURE__ */ new Set(["unknown", "has_position", "flat"]); + function createPositionPayloadContractError(message) { + const error = new Error(message); + error.name = "PositionPayloadContractError"; + return error; + } + function parsePositionAmount(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && /^-?(?:\d+\.?\d*|\.\d+)$/.test(value.trim())) { + return Number(value); + } + throw createPositionPayloadContractError(`持仓数量无效:${String(value)}`); + } + function parsePositionQuantity(value) { + parsePositionAmount(value); + const raw = String(value).trim(); + const negative = raw.startsWith("-"); + const magnitude = negative ? raw.slice(1) : raw; + let quantity; + if (typeof value === "number" && /e/i.test(magnitude)) { + const [coefficient, exponent] = magnitude.toLowerCase().split("e"); + const [integer, fraction = ""] = coefficient.split("."); + const scale = fraction.length - Number(exponent); + const digits = BigInt(integer + fraction); + quantity = scale >= 0 ? formatDecimalParts(digits, scale) : formatDecimalParts(digits * 10n ** BigInt(-scale), 0); + } else { + quantity = normalizeDecimalString(magnitude.replace(/^\./, "0.").replace(/\.$/, ".0")); + } + if (quantity === null) throw createPositionPayloadContractError("Invalid position quantity"); + return { negative, quantity }; + } + function resolveSymbolPositionStatus(payload, symbol) { + if (payload?.success !== true) throw createPositionPayloadContractError("持仓接口返回失败"); + if (!Array.isArray(payload.data)) throw createPositionPayloadContractError("持仓接口数据格式异常"); + if (!symbol) throw createPositionPayloadContractError("持仓接口缺少交易对"); + const positions = payload.data.filter((position) => position?.symbol === symbol); + const hasPosition = positions.some((position) => parsePositionAmount(position.positionAmount) !== 0); + return { + status: hasPosition ? "has_position" : "flat", + matchingPositionCount: positions.length + }; + } + function resolveSymbolPositionSideStatus(payload, symbol, side) { + if (payload?.success !== true) throw createPositionPayloadContractError("持仓接口返回失败"); + if (!Array.isArray(payload.data)) throw createPositionPayloadContractError("持仓接口数据格式异常"); + if (!symbol) throw createPositionPayloadContractError("持仓接口缺少交易对"); + if (side !== "LONG" && side !== "SHORT") { + throw createPositionPayloadContractError(`目标持仓方向无效:${String(side)}`); + } + const positions = payload.data.filter((position) => position?.symbol === symbol); + let matchingPositionCount = 0; + let positionQty = "0"; + for (const position of positions) { + const positionSide = position.positionSide; + if (!["BOTH", "LONG", "SHORT"].includes(positionSide)) { + throw createPositionPayloadContractError(`持仓方向无效:${String(positionSide)}`); + } + const { negative, quantity } = parsePositionQuantity(position.positionAmount); + if (positionSide === side) { + matchingPositionCount += 1; + positionQty = addDecimalStrings(positionQty, quantity); + continue; + } + if (positionSide === "BOTH") { + matchingPositionCount += 1; + if (side === "LONG" && !negative || side === "SHORT" && negative) { + positionQty = addDecimalStrings(positionQty, quantity); + } + } + } + return { + status: positionQty !== "0" ? "has_position" : "flat", + matchingPositionCount, + positionQty + }; + } + function observeAutoOpenLeveragePositionState(previousState, observation) { + const { symbol, status } = observation; + if (!symbol) throw new Error("自动杠杆检查缺少交易对"); + if (!POSITION_STATUSES.has(status)) { + throw new Error(`自动杠杆持仓状态无效:${status}`); + } + const isSameSymbol = previousState?.symbol === symbol; + const previousKnownStatus = isSameSymbol ? previousState.lastKnownStatus : null; + const lastKnownStatus = status === "unknown" ? previousKnownStatus : status; + return { + state: { symbol, lastKnownStatus }, + shouldReset: status === "flat" && previousKnownStatus !== "flat" + }; + } + // src/binance-orderbook-trade/core/usdt-rebalance.js var USDT_SCALE = 8; var ACCOUNT_ORDER = ["FUNDING", "MAIN", "UMFUTURE"]; @@ -1202,6 +1221,108 @@ }); } + // src/binance-orderbook-trade/core/close-ladder-recovery.js + function capCloseLadderBaseQty(domQty, positionQty) { + const comparison = compareDecimalStrings(domQty, positionQty); + if (comparison === null) throw new Error("Invalid close recovery quantity"); + return normalizeDecimalString(comparison <= 0 ? domQty : positionQty); + } + function isConfirmedReduceOnlyConflict(error) { + return error?.ladderFailureKind === "reduce_only_conflict" && error.binanceCode === 90802022 && error.safeNoSubmit === true; + } + function recoveryFailure(reason, rejection) { + const text = combineLocalizedText([reason, rejection.message], " · "); + const error = new Error(formatLocalizedText(text, "zh-CN"), { cause: rejection }); + error.name = "CloseLadderRecoveryError"; + error.localizedText = text; + return error; + } + async function runCloseLadderWithPositionRecovery({ + buildPlan, + executePlan, + readPositionQty, + replaceOrders, + waitForRecovery, + assertContext, + signal = null + }) { + let plan = await buildPlan(null); + let previousQty = null; + let replacementQty = null; + let lastRejection = null; + const check = () => { + throwIfAborted(signal); + assertContext(plan); + }; + const read = async () => { + check(); + const qty = normalizeDecimalString(await readPositionQty(plan)); + check(); + if (qty === null) throw new Error("Invalid confirmed position quantity"); + return qty; + }; + const wait = async () => { + check(); + await waitForRecovery(plan, lastRejection); + check(); + }; + const assertNotIncreased = (qty, baseline) => { + if (compareDecimalStrings(qty, baseline) > 0) { + throw recoveryFailure(localizedText( + "目标方向持仓增加,已停止只减仓冲突恢复", + "Position increased; reduce-only recovery stopped" + ), lastRejection); + } + }; + try { + while (true) { + check(); + try { + const execution = await executePlan(plan, { recovering: lastRejection !== null }); + return { plan, ...execution }; + } catch (error) { + if (!isConfirmedReduceOnlyConflict(error)) throw error; + lastRejection = error; + } + const beforeWaitQty = await read(); + if (beforeWaitQty === "0") return { status: "position_closed" }; + if (previousQty !== null) assertNotIncreased(beforeWaitQty, previousQty); + await wait(); + let qty = await read(); + if (qty === "0") return { status: "position_closed" }; + assertNotIncreased(qty, beforeWaitQty); + const decreased = compareDecimalStrings(qty, previousQty ?? beforeWaitQty) < 0; + if (!decreased) { + if (replacementQty !== null && compareDecimalStrings(qty, replacementQty) >= 0) { + throw recoveryFailure(localizedText( + "替换后仍有只减仓冲突,复核持仓未减少,已停止", + "Reduce-only conflict persists after replacement; position has not decreased; stopped" + ), lastRejection); + } + check(); + const result = await replaceOrders(plan); + check(); + if (result.ok !== true) throw recoveryFailure(result.message, lastRejection); + replacementQty = qty; + await wait(); + const afterReplacementQty = await read(); + if (afterReplacementQty === "0") return { status: "position_closed" }; + assertNotIncreased(afterReplacementQty, qty); + qty = afterReplacementQty; + } + previousQty = qty; + check(); + plan = await buildPlan(qty); + check(); + } + } catch (error) { + if (!lastRejection || ["LadderStoppedError", "ClosePositionCompletedError", "CloseLadderRecoveryError"].includes(error.name)) { + throw error; + } + throw recoveryFailure(error.localizedText || error.message, lastRejection); + } + } + // src/binance-orderbook-trade/core/ladder-progress.js function assertLadderProgress(progress) { if (!progress || !Number.isInteger(progress.submittedOrders) || progress.submittedOrders < 0 || !Number.isInteger(progress.cancelledOrders) || progress.cancelledOrders < 0 || !Number.isInteger(progress.currentPlanSubmittedOrders) || progress.currentPlanSubmittedOrders < 0 || (progress.plannedOrders === null ? progress.currentPlanSubmittedOrders !== 0 : !(Number.isInteger(progress.plannedOrders) && progress.plannedOrders > 0 && progress.currentPlanSubmittedOrders <= progress.plannedOrders))) { @@ -1740,6 +1861,16 @@ function isBinanceMaxOpenOrdersErrorCode(code) { return code === BINANCE_MAX_OPEN_ORDERS_ERROR_CODE; } + function readConfirmedReduceOnlyRejection(mode, observation, successes) { + if (mode !== "CLOSE" || observation.settled !== true || successes.length !== 0) return null; + if (observation.diagnostics.length !== 1 || observation.apiErrors.length !== 1) return null; + const diagnostic = observation.diagnostics[0]; + const apiError = observation.apiErrors[0]; + if (!Number.isInteger(diagnostic.httpStatus) || diagnostic.httpStatus < 200 || diagnostic.httpStatus >= 300 || diagnostic.bodyKind !== "json") return null; + if (diagnostic.payloadSummary?.success !== false || getBinanceApiErrorCode(diagnostic.payloadSummary) !== 90802022) return null; + if (apiError.success !== false || apiError.code !== 90802022) return null; + return apiError; + } function parseRetryAfterMs(value) { if (value == null || value === "") return null; const seconds = Number(value); @@ -6718,6 +6849,7 @@ throw new Error("确认平仓结果时下单模式已变化"); } if (state.status === "flat") throw createClosePositionCompletedError(); + return state; } async function buildLadderPlan(actionType, expectedContext = null) { const spec = getLadderActionSpec2(actionType); @@ -6762,6 +6894,9 @@ ); } const optionContext = readLadderOptionContext(spec, startSymbol, startPrecision); + if (expectedContext?.optionContext && !areLadderOptionContextsEqual(optionContext, expectedContext.optionContext)) { + throw new Error("Ladder settings changed during reduce-only recovery"); + } const levels = optionContext.levels; const ladderStep = optionContext.ladderStep; const prices = getBufferedMakerPrices(spec.priceSide, levels, ladderStep); @@ -6822,7 +6957,7 @@ "读取下单数量时比例、笔数或间距已变化" ); } - const baseQty = normalizeDecimalString(base?.qty ?? ""); + const baseQty = expectedContext?.closePositionQty !== void 0 ? capCloseLadderBaseQty(base.qty, expectedContext.closePositionQty) : normalizeDecimalString(base?.qty ?? ""); let unavailableQuantityMessage = getUnavailableLadderQuantityMessage( spec.mode, baseQty, @@ -6996,6 +7131,18 @@ error.safeNoSubmit = true; return error; } + function createLadderReduceOnlyConflictError(apiError) { + const parts = [localizedText("只减仓订单被拒绝", "Reduce-only order rejected")]; + if (typeof apiError.message === "string" && apiError.message.length > 0) parts.push(apiError.message); + parts.push(localizedText(`错误码 ${apiError.code}`, `Error code ${apiError.code}`)); + const message = combineLocalizedText(parts, " · "); + const error = new Error(formatLocalizedText(message, "zh-CN")); + error.localizedText = message; + error.binanceCode = apiError.code; + error.ladderFailureKind = "reduce_only_conflict"; + error.safeNoSubmit = true; + return error; + } function formatLadderRepriceDiagnostics(repriceAttempts, lastRepriceApiErrorCode) { if (repriceAttempts <= 0) return ""; const zhCode = lastRepriceApiErrorCode == null ? "" : `,错误码 ${lastRepriceApiErrorCode}`; @@ -7319,6 +7466,20 @@ ) : { settled: false, apiErrors: [], diagnostics: [] }; const capturedApiErrors = responseObservation.apiErrors; const capturedApiSuccesses = readLadderSubmitApiSuccesses(submitCaptureId); + const reduceOnlyRejection = readConfirmedReduceOnlyRejection( + mode, + responseObservation, + capturedApiSuccesses + ); + if (reduceOnlyRejection) throw createLadderReduceOnlyConflictError(reduceOnlyRejection); + if (mode === "CLOSE" && capturedApiErrors.some(({ code }) => code === 90802022)) { + const error = new Error("只减仓拒单响应不完整或存在冲突,订单结果未确认(错误码 90802022)"); + error.localizedText = localizedText( + error.message, + "Incomplete or conflicting reduce-only response; order outcome unconfirmed (90802022)" + ); + throw error; + } if (capturedApiErrors.length === 1 && isBinancePostOnlyMakerRejectCode(capturedApiErrors[0].code)) { throw createLadderSubmitApiError(capturedApiErrors[0].code); } @@ -7654,6 +7815,7 @@ abortController.signal, { allowMaxOpenOrdersRecovery: continuousSession && spec.mode === "CLOSE", + allowReduceOnlyRecovery: continuousSession && spec.mode === "CLOSE", chartSaveController, chartSaveRound } @@ -9718,6 +9880,7 @@ } function isReplaceableCloseLadderOpenOrdersFailure(plan, error) { if (plan?.spec?.mode !== "CLOSE") return false; + if (error?.ladderFailureKind === "reduce_only_conflict" && error.safeNoSubmit === true) return true; return isReduceOnlyOpenOrdersConflictFeedback(error?.message || ""); } function isReplaceableOpenLadderOpenOrdersFailure(plan, error) { @@ -9761,7 +9924,63 @@ precision: plan.precision }; } + async function runContinuousCloseLadderPlan(actionType, progress, setExecutionStatus, abortSignal, options) { + let expectedContext = null; + const result = await runCloseLadderWithPositionRecovery({ + signal: abortSignal, + assertContext: assertLadderExecutionContext, + buildPlan: async (positionQty) => { + const context = expectedContext === null ? null : { ...expectedContext, closePositionQty: positionQty }; + const plan = await buildLadderPlan(actionType, context); + if (expectedContext === null) { + expectedContext = { ...createLadderExpectedContext(plan), optionContext: plan.optionContext }; + } + setLadderPlannedOrders(progress, plan.orders.length); + setExecutionStatus(formatLadderPlanStatus(plan), formatLadderPlanDetail(plan)); + return plan; + }, + executePlan: (plan, { recovering }) => executeLadderPlan(plan, progress, setExecutionStatus, abortSignal, { + ...options, + allowMaxOpenOrdersRecovery: options.allowMaxOpenOrdersRecovery && !recovering + }), + readPositionQty: async (plan) => (await throwIfClosePositionCompleted(plan, abortSignal)).positionQty, + replaceOrders: (plan) => cancelCurrentSymbolOpenOrdersForPlan( + plan, + progress, + setExecutionStatus, + abortSignal + ), + waitForRecovery: async (plan) => { + let nextPositionCheckAt = Date.now() + CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS; + await waitForContinuousLadderNextRound({ + signal: abortSignal, + delay, + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + readReadiness: async () => { + assertLadderExecutionContext(plan); + const button = plan.spec.buttonGetter(); + const ready = !document.hidden && isCloseSnapshotReady(plan.symbol) && button && button.isConnected && isVisibleElement(button) && !isSubmitButtonBusy(button); + if (!ready && !document.hidden && Date.now() >= nextPositionCheckAt) { + await throwIfClosePositionCompleted(plan, abortSignal); + nextPositionCheckAt = Date.now() + CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS; + assertLadderExecutionContext(plan); + } + return { status: ready ? "ready" : "waiting" }; + }, + onWaitStateChange: ({ phase }) => { + const detail = phase === "waiting_ready" ? localizedText("只减仓冲突,等待按钮恢复后复核", "Reduce-only conflict; waiting for the button before rechecking") : localizedText("只减仓冲突,3s 后复核仓位", "Reduce-only conflict; rechecking position in 3s"); + setExecutionStatus(combineLocalizedText([plan.spec.statusLabel, detail], " · "), detail); + } + }); + } + }); + if (result.status === "position_closed") throw createClosePositionCompletedError(); + return result; + } async function runLadderPlanWithOpenOrderReplacement(actionType, progress, setExecutionStatus, abortSignal = null, options = null) { + if (options?.allowReduceOnlyRecovery === true && getLadderActionSpec2(actionType)?.mode === "CLOSE") { + return runContinuousCloseLadderPlan(actionType, progress, setExecutionStatus, abortSignal, options); + } let replacementContext = null; for (let attempt = 0; attempt < 2; attempt += 1) { throwIfAborted(abortSignal); diff --git a/src/binance-orderbook-trade/core/auto-open-leverage.js b/src/binance-orderbook-trade/core/auto-open-leverage.js index 6c7dc0e..02772ba 100644 --- a/src/binance-orderbook-trade/core/auto-open-leverage.js +++ b/src/binance-orderbook-trade/core/auto-open-leverage.js @@ -1,3 +1,5 @@ +import { addDecimalStrings, formatDecimalParts, normalizeDecimalString } from './decimal.js'; + const POSITION_STATUSES = new Set(['unknown', 'has_position', 'flat']); function createPositionPayloadContractError(message) { @@ -14,6 +16,28 @@ function parsePositionAmount(value) { throw createPositionPayloadContractError(`持仓数量无效:${String(value)}`); } +/** Preserve API decimal strings when measuring progress between position reads. */ +function parsePositionQuantity(value) { + parsePositionAmount(value); + const raw = String(value).trim(); + const negative = raw.startsWith('-'); + const magnitude = negative ? raw.slice(1) : raw; + let quantity; + if (typeof value === 'number' && /e/i.test(magnitude)) { + const [coefficient, exponent] = magnitude.toLowerCase().split('e'); + const [integer, fraction = ''] = coefficient.split('.'); + const scale = fraction.length - Number(exponent); + const digits = BigInt(integer + fraction); + quantity = scale >= 0 + ? formatDecimalParts(digits, scale) + : formatDecimalParts(digits * 10n ** BigInt(-scale), 0); + } else { + quantity = normalizeDecimalString(magnitude.replace(/^\./, '0.').replace(/\.$/, '.0')); + } + if (quantity === null) throw createPositionPayloadContractError('Invalid position quantity'); + return { negative, quantity }; +} + export function resolveSymbolPositionStatus(payload, symbol) { if (payload?.success !== true) throw createPositionPayloadContractError('持仓接口返回失败'); if (!Array.isArray(payload.data)) throw createPositionPayloadContractError('持仓接口数据格式异常'); @@ -37,28 +61,29 @@ export function resolveSymbolPositionSideStatus(payload, symbol, side) { const positions = payload.data.filter((position) => position?.symbol === symbol); let matchingPositionCount = 0; - let hasPosition = false; + let positionQty = '0'; for (const position of positions) { const positionSide = position.positionSide; if (!['BOTH', 'LONG', 'SHORT'].includes(positionSide)) { throw createPositionPayloadContractError(`持仓方向无效:${String(positionSide)}`); } - const amount = parsePositionAmount(position.positionAmount); + const { negative, quantity } = parsePositionQuantity(position.positionAmount); if (positionSide === side) { matchingPositionCount += 1; - if (amount !== 0) hasPosition = true; + positionQty = addDecimalStrings(positionQty, quantity); continue; } if (positionSide === 'BOTH') { matchingPositionCount += 1; - if ((side === 'LONG' && amount > 0) || (side === 'SHORT' && amount < 0)) { - hasPosition = true; + if ((side === 'LONG' && !negative) || (side === 'SHORT' && negative)) { + positionQty = addDecimalStrings(positionQty, quantity); } } } return { - status: hasPosition ? 'has_position' : 'flat', + status: positionQty !== '0' ? 'has_position' : 'flat', matchingPositionCount, + positionQty, }; } diff --git a/src/binance-orderbook-trade/core/close-ladder-recovery.js b/src/binance-orderbook-trade/core/close-ladder-recovery.js new file mode 100644 index 0000000..d5a513b --- /dev/null +++ b/src/binance-orderbook-trade/core/close-ladder-recovery.js @@ -0,0 +1,120 @@ +import { compareDecimalStrings, normalizeDecimalString } from './decimal.js'; +import { throwIfAborted } from '../../shared/abort.js'; +import { combineLocalizedText, formatLocalizedText, localizedText } from '../contracts/panel-copy.js'; + +export function capCloseLadderBaseQty(domQty, positionQty) { + const comparison = compareDecimalStrings(domQty, positionQty); + if (comparison === null) throw new Error('Invalid close recovery quantity'); + return normalizeDecimalString(comparison <= 0 ? domQty : positionQty); +} + +function isConfirmedReduceOnlyConflict(error) { + return error?.ladderFailureKind === 'reduce_only_conflict' + && error.binanceCode === 90802022 + && error.safeNoSubmit === true; +} + +function recoveryFailure(reason, rejection) { + const text = combineLocalizedText([reason, rejection.message], ' · '); + const error = new Error(formatLocalizedText(text, 'zh-CN'), { cause: rejection }); + error.name = 'CloseLadderRecoveryError'; + error.localizedText = text; + return error; +} + +/** + * A cancelled or newly submitted order is not evidence of a smaller position. + * Spend one scoped replacement at an unchanged authoritative quantity, then + * require a strict position decrease before spending another. Failed recovery + * adapters cannot inherit the outer runner's unrelated retry permissions. + */ +export async function runCloseLadderWithPositionRecovery({ + buildPlan, + executePlan, + readPositionQty, + replaceOrders, + waitForRecovery, + assertContext, + signal = null, +}) { + let plan = await buildPlan(null); + let previousQty = null; + let replacementQty = null; + let lastRejection = null; + const check = () => { + throwIfAborted(signal); + assertContext(plan); + }; + const read = async () => { + check(); + const qty = normalizeDecimalString(await readPositionQty(plan)); + check(); + if (qty === null) throw new Error('Invalid confirmed position quantity'); + return qty; + }; + const wait = async () => { + check(); + await waitForRecovery(plan, lastRejection); + check(); + }; + const assertNotIncreased = (qty, baseline) => { + if (compareDecimalStrings(qty, baseline) > 0) { + throw recoveryFailure(localizedText( + '目标方向持仓增加,已停止只减仓冲突恢复', + 'Position increased; reduce-only recovery stopped', + ), lastRejection); + } + }; + + try { + while (true) { + check(); + try { + const execution = await executePlan(plan, { recovering: lastRejection !== null }); + return { plan, ...execution }; + } catch (error) { + if (!isConfirmedReduceOnlyConflict(error)) throw error; + lastRejection = error; + } + + const beforeWaitQty = await read(); + if (beforeWaitQty === '0') return { status: 'position_closed' }; + if (previousQty !== null) assertNotIncreased(beforeWaitQty, previousQty); + await wait(); + let qty = await read(); + if (qty === '0') return { status: 'position_closed' }; + assertNotIncreased(qty, beforeWaitQty); + const decreased = compareDecimalStrings(qty, previousQty ?? beforeWaitQty) < 0; + + if (!decreased) { + if (replacementQty !== null && compareDecimalStrings(qty, replacementQty) >= 0) { + throw recoveryFailure(localizedText( + '替换后仍有只减仓冲突,复核持仓未减少,已停止', + 'Reduce-only conflict persists after replacement; position has not decreased; stopped', + ), lastRejection); + } + check(); + const result = await replaceOrders(plan); + check(); + if (result.ok !== true) throw recoveryFailure(result.message, lastRejection); + replacementQty = qty; + await wait(); + const afterReplacementQty = await read(); + if (afterReplacementQty === '0') return { status: 'position_closed' }; + assertNotIncreased(afterReplacementQty, qty); + qty = afterReplacementQty; + } + + previousQty = qty; + check(); + plan = await buildPlan(qty); + check(); + } + } catch (error) { + if (!lastRejection + || ['LadderStoppedError', 'ClosePositionCompletedError', 'CloseLadderRecoveryError'].includes(error.name)) { + throw error; + } + throw recoveryFailure(error.localizedText || error.message, lastRejection); + } +} diff --git a/src/binance-orderbook-trade/core/order-feedback.js b/src/binance-orderbook-trade/core/order-feedback.js index 1451387..8e1ed53 100644 --- a/src/binance-orderbook-trade/core/order-feedback.js +++ b/src/binance-orderbook-trade/core/order-feedback.js @@ -95,6 +95,19 @@ export function isBinanceMaxOpenOrdersErrorCode(code) { return code === BINANCE_MAX_OPEN_ORDERS_ERROR_CODE; } +/** Only a single settled native rejection can authorize position recovery. */ +export function readConfirmedReduceOnlyRejection(mode, observation, successes) { + if (mode !== 'CLOSE' || observation.settled !== true || successes.length !== 0) return null; + if (observation.diagnostics.length !== 1 || observation.apiErrors.length !== 1) return null; + const diagnostic = observation.diagnostics[0]; + const apiError = observation.apiErrors[0]; + if (!Number.isInteger(diagnostic.httpStatus) + || diagnostic.httpStatus < 200 || diagnostic.httpStatus >= 300 || diagnostic.bodyKind !== 'json') return null; + if (diagnostic.payloadSummary?.success !== false || getBinanceApiErrorCode(diagnostic.payloadSummary) !== 90802022) return null; + if (apiError.success !== false || apiError.code !== 90802022) return null; + return apiError; +} + function parseRetryAfterMs(value) { if (value == null || value === '') return null; const seconds = Number(value); diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 8100c6b..42a293f 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.201 +// @version 2.7.202 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -91,7 +91,9 @@ import { getUnavailableLadderQuantityMessage, } from './core/ladder-plan.js'; import { keepInteractionFeedbackVisible } from './core/interaction-feedback.js'; +import { capCloseLadderBaseQty, runCloseLadderWithPositionRecovery } from './core/close-ladder-recovery.js'; import { + CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, createContinuousLadderProgress, formatActiveContinuousLadderProgress, formatContinuousLadderProgress, @@ -130,6 +132,7 @@ import { isPostOnlyMakerRejectionFeedback, isReduceOnlyOpenOrdersConflictFeedback, isPotentialOrderFeedbackText, + readConfirmedReduceOnlyRejection, resolveBinanceSubmitResponseRecovery, summarizeBinancePlaceOrderPayload, } from './core/order-feedback.js'; @@ -2546,6 +2549,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; throw new Error('确认平仓结果时下单模式已变化'); } if (state.status === 'flat') throw createClosePositionCompletedError(); + return state; } async function buildLadderPlan(actionType, expectedContext = null) { @@ -2595,6 +2599,9 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; } const optionContext = readLadderOptionContext(spec, startSymbol, startPrecision); + if (expectedContext?.optionContext && !areLadderOptionContextsEqual(optionContext, expectedContext.optionContext)) { + throw new Error('Ladder settings changed during reduce-only recovery'); + } const levels = optionContext.levels; const ladderStep = optionContext.ladderStep; const prices = getBufferedMakerPrices(spec.priceSide, levels, ladderStep); @@ -2664,7 +2671,9 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; '读取下单数量时比例、笔数或间距已变化', ); } - const baseQty = normalizeDecimalString(base?.qty ?? ''); + const baseQty = expectedContext?.closePositionQty !== undefined + ? capCloseLadderBaseQty(base.qty, expectedContext.closePositionQty) + : normalizeDecimalString(base?.qty ?? ''); let unavailableQuantityMessage = getUnavailableLadderQuantityMessage( spec.mode, baseQty, @@ -2867,6 +2876,19 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return error; } + function createLadderReduceOnlyConflictError(apiError) { + const parts = [localizedText('只减仓订单被拒绝', 'Reduce-only order rejected')]; + if (typeof apiError.message === 'string' && apiError.message.length > 0) parts.push(apiError.message); + parts.push(localizedText(`错误码 ${apiError.code}`, `Error code ${apiError.code}`)); + const message = combineLocalizedText(parts, ' · '); + const error = new Error(formatLocalizedText(message, 'zh-CN')); + error.localizedText = message; + error.binanceCode = apiError.code; + error.ladderFailureKind = 'reduce_only_conflict'; + error.safeNoSubmit = true; + return error; + } + function formatLadderRepriceDiagnostics(repriceAttempts, lastRepriceApiErrorCode) { if (repriceAttempts <= 0) return ''; const zhCode = lastRepriceApiErrorCode == null ? '' : `,错误码 ${lastRepriceApiErrorCode}`; @@ -3251,6 +3273,19 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; const capturedApiErrors = responseObservation.apiErrors; const capturedApiSuccesses = readLadderSubmitApiSuccesses(submitCaptureId); + const reduceOnlyRejection = readConfirmedReduceOnlyRejection( + mode, + responseObservation, + capturedApiSuccesses, + ); + if (reduceOnlyRejection) throw createLadderReduceOnlyConflictError(reduceOnlyRejection); + if (mode === 'CLOSE' && capturedApiErrors.some(({ code }) => code === 90802022)) { + const error = new Error('只减仓拒单响应不完整或存在冲突,订单结果未确认(错误码 90802022)'); + error.localizedText = localizedText(error.message, + 'Incomplete or conflicting reduce-only response; order outcome unconfirmed (90802022)'); + throw error; + } + if ( capturedApiErrors.length === 1 && isBinancePostOnlyMakerRejectCode(capturedApiErrors[0].code) @@ -3638,6 +3673,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; abortController.signal, { allowMaxOpenOrdersRecovery: continuousSession && spec.mode === 'CLOSE', + allowReduceOnlyRecovery: continuousSession && spec.mode === 'CLOSE', chartSaveController, chartSaveRound, }, @@ -6045,6 +6081,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; function isReplaceableCloseLadderOpenOrdersFailure(plan, error) { if (plan?.spec?.mode !== 'CLOSE') return false; + if (error?.ladderFailureKind === 'reduce_only_conflict' && error.safeNoSubmit === true) return true; return isReduceOnlyOpenOrdersConflictFeedback(error?.message || ''); } @@ -6105,6 +6142,64 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; }; } + async function runContinuousCloseLadderPlan(actionType, progress, setExecutionStatus, abortSignal, options) { + let expectedContext = null; + const result = await runCloseLadderWithPositionRecovery({ + signal: abortSignal, + assertContext: assertLadderExecutionContext, + buildPlan: async (positionQty) => { + const context = expectedContext === null + ? null + : { ...expectedContext, closePositionQty: positionQty }; + const plan = await buildLadderPlan(actionType, context); + if (expectedContext === null) { + expectedContext = { ...createLadderExpectedContext(plan), optionContext: plan.optionContext }; + } + setLadderPlannedOrders(progress, plan.orders.length); + setExecutionStatus(formatLadderPlanStatus(plan), formatLadderPlanDetail(plan)); + return plan; + }, + executePlan: (plan, { recovering }) => executeLadderPlan(plan, progress, setExecutionStatus, abortSignal, { + ...options, + allowMaxOpenOrdersRecovery: options.allowMaxOpenOrdersRecovery && !recovering, + }), + readPositionQty: async (plan) => (await throwIfClosePositionCompleted(plan, abortSignal)).positionQty, + replaceOrders: (plan) => cancelCurrentSymbolOpenOrdersForPlan( + plan, progress, setExecutionStatus, abortSignal, + ), + waitForRecovery: async (plan) => { + let nextPositionCheckAt = Date.now() + CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS; + await waitForContinuousLadderNextRound({ + signal: abortSignal, + delay, + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + readReadiness: async () => { + assertLadderExecutionContext(plan); + const button = plan.spec.buttonGetter(); + const ready = !document.hidden && isCloseSnapshotReady(plan.symbol) + && button && button.isConnected && isVisibleElement(button) && !isSubmitButtonBusy(button); + // A completed close can disable the button permanently; recheck the + // position while waiting rather than waiting for an impossible ready state. + if (!ready && !document.hidden && Date.now() >= nextPositionCheckAt) { + await throwIfClosePositionCompleted(plan, abortSignal); + nextPositionCheckAt = Date.now() + CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS; + assertLadderExecutionContext(plan); + } + return { status: ready ? 'ready' : 'waiting' }; + }, + onWaitStateChange: ({ phase }) => { + const detail = phase === 'waiting_ready' + ? localizedText('只减仓冲突,等待按钮恢复后复核', 'Reduce-only conflict; waiting for the button before rechecking') + : localizedText('只减仓冲突,3s 后复核仓位', 'Reduce-only conflict; rechecking position in 3s'); + setExecutionStatus(combineLocalizedText([plan.spec.statusLabel, detail], ' · '), detail); + }, + }); + }, + }); + if (result.status === 'position_closed') throw createClosePositionCompletedError(); + return result; + } + async function runLadderPlanWithOpenOrderReplacement( actionType, progress, @@ -6112,6 +6207,9 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; abortSignal = null, options = null, ) { + if (options?.allowReduceOnlyRecovery === true && getLadderActionSpec(actionType)?.mode === 'CLOSE') { + return runContinuousCloseLadderPlan(actionType, progress, setExecutionStatus, abortSignal, options); + } let replacementContext = null; for (let attempt = 0; attempt < 2; attempt += 1) { throwIfAborted(abortSignal); diff --git a/test/unit/binance-orderbook-trade/auto-open-leverage.test.js b/test/unit/binance-orderbook-trade/auto-open-leverage.test.js index ab6bbe7..a95ac8a 100644 --- a/test/unit/binance-orderbook-trade/auto-open-leverage.test.js +++ b/test/unit/binance-orderbook-trade/auto-open-leverage.test.js @@ -56,10 +56,12 @@ test('close completion checks only the requested position side in hedge mode', ( assert.deepEqual(resolveSymbolPositionSideStatus(payload, 'HYPEUSDT', 'LONG'), { status: 'has_position', matchingPositionCount: 1, + positionQty: '1.25', }); assert.deepEqual(resolveSymbolPositionSideStatus(payload, 'HYPEUSDT', 'SHORT'), { status: 'flat', matchingPositionCount: 1, + positionQty: '0', }); }); @@ -93,6 +95,37 @@ test('close completion rejects unknown position directions instead of guessing', ); }); +test('directional recovery quantities preserve exact signed decimals and existing numeric inputs', () => { + for (const [positionSide, positionAmount, side, positionQty] of [ + ['SHORT', '-1.000000000000000002', 'SHORT', '1.000000000000000002'], + ['LONG', '1.000000000000000001', 'LONG', '1.000000000000000001'], + ['BOTH', '-1.25', 'SHORT', '1.25'], + ['BOTH', '-1.25', 'LONG', '0'], + ['BOTH', '1.25', 'LONG', '1.25'], + ['BOTH', '1.25', 'SHORT', '0'], + ['SHORT', '-0.000', 'SHORT', '0'], + ['SHORT', '-.50', 'SHORT', '0.5'], + ['LONG', '1.', 'LONG', '1'], + ['LONG', 0, 'LONG', '0'], + ['BOTH', -1e-8, 'SHORT', '0.00000001'], + ['BOTH', 1e21, 'LONG', '1000000000000000000000'], + ]) { + const state = resolveSymbolPositionSideStatus({ + success: true, data: [{ symbol: 'HYPEUSDT', positionSide, positionAmount }], + }, 'HYPEUSDT', side); + assert.equal(state.positionQty, positionQty); + assert.equal(state.status, positionQty === '0' ? 'flat' : 'has_position'); + } + assert.deepEqual(resolveSymbolPositionSideStatus({ success: true, data: [] }, 'HYPEUSDT', 'SHORT'), { + status: 'flat', matchingPositionCount: 0, positionQty: '0', + }); + for (const positionAmount of ['1e-8', '--1', 'NaN', Infinity, null]) { + assert.throws(() => resolveSymbolPositionSideStatus({ + success: true, data: [{ symbol: 'HYPEUSDT', positionSide: 'BOTH', positionAmount }], + }, 'HYPEUSDT', 'SHORT'), { name: 'PositionPayloadContractError' }); + } +}); + test('rejects unsuccessful or malformed current-symbol position responses', () => { assert.throws( () => resolveSymbolPositionStatus({ success: false, data: [] }, 'HYPEUSDT'), diff --git a/test/unit/binance-orderbook-trade/close-ladder-recovery.test.js b/test/unit/binance-orderbook-trade/close-ladder-recovery.test.js new file mode 100644 index 0000000..4d630ab --- /dev/null +++ b/test/unit/binance-orderbook-trade/close-ladder-recovery.test.js @@ -0,0 +1,200 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + capCloseLadderBaseQty, + runCloseLadderWithPositionRecovery, +} from '../../../src/binance-orderbook-trade/core/close-ladder-recovery.js'; +import { formatLocalizedText } from '../../../src/binance-orderbook-trade/contracts/panel-copy.js'; + +function rejection() { + return Object.assign(new Error('Reduce-only rejected (90802022)'), { + binanceCode: 90802022, + safeNoSubmit: true, + ladderFailureKind: 'reduce_only_conflict', + }); +} + +function scenario({ quantities, failures = [rejection()], replaceResult = { ok: true }, onWait = () => {} }) { + const events = []; + let readIndex = 0; + let executionIndex = 0; + const signal = new AbortController(); + const options = { + signal: signal.signal, + assertContext: () => {}, + buildPlan: async (positionQty) => { + const baseQty = positionQty === null ? '100' : capCloseLadderBaseQty('100', positionQty); + events.push(['build', baseQty]); + return { spec: { mode: 'CLOSE' }, baseQty }; + }, + executePlan: async () => { + events.push(['execute']); + const error = failures[executionIndex++]; + if (error) throw error; + return { done: 3 }; + }, + readPositionQty: async () => { + const qty = quantities[readIndex++]; + assert.notEqual(qty, undefined, 'Unexpected position read'); + events.push(['position', qty]); + return qty; + }, + replaceOrders: async () => { + events.push(['replace']); + return replaceResult; + }, + waitForRecovery: async () => { + events.push(['wait']); + onWait(signal); + }, + }; + return { options, events }; +} + +test('a confirmed flat position ends recovery without cancellation or another submit', async () => { + const { options, events } = scenario({ quantities: ['0'] }); + assert.deepEqual(await runCloseLadderWithPositionRecovery(options), { status: 'position_closed' }); + assert.deepEqual(events, [['build', '100'], ['execute'], ['position', '0']]); +}); + +test('a real position decrease rebuilds beyond the old two-attempt limit without cancelling', async () => { + const { options, events } = scenario({ + quantities: ['100', '80', '80', '60', '60', '40'], + failures: [rejection(), rejection(), rejection()], + }); + const result = await runCloseLadderWithPositionRecovery(options); + assert.equal(result.done, 3); + assert.deepEqual(events.filter(([type]) => type === 'build'), [ + ['build', '100'], ['build', '80'], ['build', '60'], ['build', '40'], + ]); + assert.equal(events.filter(([type]) => type === 'replace').length, 0); + assert.equal(events.filter(([type]) => type === 'wait').length, 3); +}); + +test('unchanged position permits one confirmed scoped replacement before replanning', async () => { + const { options, events } = scenario({ quantities: ['100', '100', '100'] }); + assert.equal((await runCloseLadderWithPositionRecovery(options)).done, 3); + assert.deepEqual(events, [ + ['build', '100'], ['execute'], ['position', '100'], ['wait'], ['position', '100'], + ['replace'], ['wait'], ['position', '100'], ['build', '100'], ['execute'], + ]); +}); + +test('rejection after replacement waits for progress and stops unchanged state without another cancellation', async () => { + const { options, events } = scenario({ + quantities: ['100', '100', '100', '100', '100'], + failures: [rejection(), rejection()], + }); + await assert.rejects(runCloseLadderWithPositionRecovery(options), (error) => { + assert.match(formatLocalizedText(error.localizedText, 'en'), /position has not decreased/i); + assert.match(error.message, /90802022/); + assert.equal(error.continuousRecoveryKind, undefined); + return true; + }); + assert.equal(events.filter(([type]) => type === 'replace').length, 1); + assert.equal(events.filter(([type]) => type === 'execute').length, 2); +}); + +test('actual position reduction after replacement allows further recovery', async () => { + const { options, events } = scenario({ + quantities: ['100', '100', '100', '100', '80', '80', '80', '80'], + failures: [rejection(), rejection(), rejection()], + }); + assert.equal((await runCloseLadderWithPositionRecovery(options)).done, 3); + assert.equal(events.filter(([type]) => type === 'replace').length, 2); + assert.deepEqual(events.filter(([type]) => type === 'build'), [ + ['build', '100'], ['build', '100'], ['build', '80'], ['build', '80'], + ]); +}); + +test('an increased position cannot reset the recovery guard', async () => { + const { options, events } = scenario({ quantities: ['100', '101'] }); + await assert.rejects(runCloseLadderWithPositionRecovery(options), /90802022/); + assert.equal(events.filter(([type]) => type === 'replace').length, 0); + assert.equal(events.filter(([type]) => type === 'execute').length, 1); +}); + +test('unconfirmed cancellation stops without another position read or submission', async () => { + const { options, events } = scenario({ + quantities: ['100', '100'], + replaceResult: { ok: false, status: 'row_cancel_failed', message: 'Cancellation unconfirmed' }, + }); + await assert.rejects(runCloseLadderWithPositionRecovery(options), /Cancellation unconfirmed/); + assert.equal(events.filter(([type]) => type === 'execute').length, 1); +}); + +test('position failures do not escape into the outer continuous retry policy', async () => { + const { options, events } = scenario({ quantities: [] }); + options.readPositionQty = async () => { + throw Object.assign(new Error('Position unavailable'), { + continuousRecoveryKind: 'position_state_not_ready', safeNoSubmit: true, + }); + }; + await assert.rejects(runCloseLadderWithPositionRecovery(options), (error) => { + assert.match(error.message, /Position unavailable/); + assert.equal(error.continuousRecoveryKind, undefined); + assert.equal(error.safeNoSubmit, undefined); + return true; + }); + assert.equal(events.filter(([type]) => type === 'execute').length, 1); +}); + +test('unknown submission during recovery cannot start a new continuous round', async () => { + const unknown = Object.assign(new Error('Submission unconfirmed'), { continuousRecoveryKind: 'submit_unconfirmed' }); + const { options } = scenario({ quantities: ['100', '80'], failures: [rejection(), unknown] }); + await assert.rejects(runCloseLadderWithPositionRecovery(options), (error) => { + assert.match(error.message, /Submission unconfirmed/); + assert.equal(error.continuousRecoveryKind, undefined); + return true; + }); +}); + +test('the executor is told to disable unrelated recovery after the first reduce-only rejection', async () => { + const { options, events } = scenario({ quantities: ['100', '80'] }); + const executions = []; + options.executePlan = async (_plan, { recovering }) => { + executions.push(recovering); + if (!recovering) throw rejection(); + throw Object.assign(new Error('Maximum open orders (90802025)'), { + safeNoSubmit: true, binanceCode: 90802025, ladderFailureKind: 'max_open_orders', + }); + }; + await assert.rejects(runCloseLadderWithPositionRecovery(options), (error) => { + assert.match(error.message, /90802025/); + assert.equal(error.continuousRecoveryKind, undefined); + return true; + }); + assert.deepEqual(executions, [false, true]); + assert.equal(events.filter(([type]) => type === 'replace').length, 0); +}); + +test('manual stop during cooldown prevents the next read, cancel and submit', async () => { + const stopped = Object.assign(new Error('Stopped'), { name: 'LadderStoppedError' }); + const { options, events } = scenario({ quantities: ['100'], onWait: (signal) => signal.abort(stopped) }); + await assert.rejects(runCloseLadderWithPositionRecovery(options), (error) => error === stopped); + assert.deepEqual(events, [['build', '100'], ['execute'], ['position', '100'], ['wait']]); +}); + +test('context changes after a wait are terminal before another side effect', async () => { + let changed = false; + const { options, events } = scenario({ quantities: ['100'], onWait: () => { changed = true; } }); + options.assertContext = () => { if (changed) throw new Error('Symbol changed'); }; + await assert.rejects(runCloseLadderWithPositionRecovery(options), /Symbol changed/); + assert.equal(events.filter(([type]) => type === 'replace').length, 0); +}); + +test('text-only reduce-only feedback is not proof of a safe retry', async () => { + const error = new Error('Reduce-only rejected (90802022)'); + const { options, events } = scenario({ quantities: [], failures: [error] }); + await assert.rejects(runCloseLadderWithPositionRecovery(options), (actual) => actual === error); + assert.deepEqual(events, [['build', '100'], ['execute']]); +}); + +test('recovery quantity respects both fresh DOM availability and exact API position', () => { + assert.equal(capCloseLadderBaseQty('60', '80'), '60'); + assert.equal(capCloseLadderBaseQty('100', '80'), '80'); + assert.equal(capCloseLadderBaseQty('1.000000000000000002', '1.000000000000000001'), '1.000000000000000001'); + assert.equal(capCloseLadderBaseQty('0', '80'), '0'); + assert.throws(() => capCloseLadderBaseQty(null, '80'), /quantity/i); + assert.throws(() => capCloseLadderBaseQty('100', 'invalid'), /quantity/i); +}); diff --git a/test/unit/binance-orderbook-trade/order-feedback.test.js b/test/unit/binance-orderbook-trade/order-feedback.test.js index a927957..3b0f171 100644 --- a/test/unit/binance-orderbook-trade/order-feedback.test.js +++ b/test/unit/binance-orderbook-trade/order-feedback.test.js @@ -12,10 +12,42 @@ import { isOpenLadderOpenOrdersCapacityFeedback, isPostOnlyMakerRejectionFeedback, isReduceOnlyOpenOrdersConflictFeedback, + readConfirmedReduceOnlyRejection, resolveBinanceSubmitResponseRecovery, summarizeBinancePlaceOrderPayload, } from '../../../src/binance-orderbook-trade/core/order-feedback.js'; +test('reduce-only recovery requires a single settled native close rejection', () => { + const apiError = { success: false, code: 90802022, message: 'Reduce-only order failed' }; + const observation = { + settled: true, + diagnostics: [{ httpStatus: 200, bodyKind: 'json', payloadSummary: apiError }], + apiErrors: [apiError], + }; + assert.deepEqual(readConfirmedReduceOnlyRejection('CLOSE', observation, []), apiError); + assert.deepEqual(readConfirmedReduceOnlyRejection('CLOSE', { + ...observation, + diagnostics: [{ ...observation.diagnostics[0], payloadSummary: { ...apiError, code: '90802022' } }], + }, []), apiError); + assert.equal(readConfirmedReduceOnlyRejection('OPEN', observation, []), null); + assert.equal(readConfirmedReduceOnlyRejection('CLOSE', observation, [{}]), null); + for (const patch of [ + { settled: false }, + { diagnostics: [] }, + { diagnostics: [observation.diagnostics[0], observation.diagnostics[0]] }, + { apiErrors: [] }, + { apiErrors: [apiError, apiError] }, + { apiErrors: [{ ...apiError, success: true }] }, + { apiErrors: [{ ...apiError, code: 90802025 }] }, + ...[null, 0, 400, 503].map((httpStatus) => ({ diagnostics: [{ ...observation.diagnostics[0], httpStatus }] })), + { diagnostics: [{ ...observation.diagnostics[0], bodyKind: 'invalid_json' }] }, + { diagnostics: [{ ...observation.diagnostics[0], payloadSummary: { ...apiError, success: true } }] }, + { diagnostics: [{ ...observation.diagnostics[0], payloadSummary: { ...apiError, code: 90802025 } }] }, + ]) { + assert.equal(readConfirmedReduceOnlyRejection('CLOSE', { ...observation, ...patch }, []), null); + } +}); + test('classifies only rate limits and uncertain server responses as recoverable', () => { assert.deepEqual(resolveBinanceSubmitResponseRecovery([ { httpStatus: 429, retryAfter: '7' }, diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index ca321e2..e7fadd2 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -265,6 +265,27 @@ test('ladder retries with restricted open-order replacement after supported feed assert.match(startBody, /runLadderPlanWithOpenOrderReplacement\(\s*actionType,\s*progress,\s*setExecutionStatus,\s*abortController\.signal/); }); +test('only continuous close routes confirmed conflicts through position-based recovery', () => { + const startBody = readFunctionBody('startLadder'); + const routeBody = readFunctionBody('runLadderPlanWithOpenOrderReplacement'); + const recoveryBody = readFunctionBody('runContinuousCloseLadderPlan'); + const acknowledgementBody = readFunctionBody('waitForOrderSubmitAcknowledgement'); + const planBody = readFunctionBody('buildLadderPlan'); + assert.match(startBody, /allowReduceOnlyRecovery: continuousSession && spec\.mode === 'CLOSE'/); + assert.match(routeBody, /options\?\.allowReduceOnlyRecovery === true && getLadderActionSpec\(actionType\)\?\.mode === 'CLOSE'/); + assert.match(recoveryBody, /runCloseLadderWithPositionRecovery/); + assert.match(recoveryBody, /cancelCurrentSymbolOpenOrdersForPlan\(/); + assert.doesNotMatch(recoveryBody, /cancelCurrentSymbolOpenOrders\(|findCurrentSymbolCancelAllButton|resolveContinuousLadderRecovery/); + assert.match(recoveryBody, /throwIfClosePositionCompleted\(plan, abortSignal\)\)\.positionQty/); + assert.match(recoveryBody, /CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS/); + assert.match(recoveryBody, /assertContext: assertLadderExecutionContext/); + assert.match(recoveryBody, /allowMaxOpenOrdersRecovery: options.allowMaxOpenOrdersRecovery && !recovering/); + assert.match(acknowledgementBody, /readConfirmedReduceOnlyRejection\(\s*mode,\s*responseObservation,\s*capturedApiSuccesses/); + assert.match(planBody, /capCloseLadderBaseQty\(base.qty, expectedContext.closePositionQty\)/); + assert.match(recoveryBody, /optionContext: plan.optionContext/); + assert.match(planBody, /areLadderOptionContextsEqual\(optionContext, expectedContext.optionContext\)/); +}); + test('continuous close recovers a confirmed max-open-orders rejection by freeing farthest slots', () => { const acknowledgementBody = readFunctionBody('waitForOrderSubmitAcknowledgement'); const executeBody = readFunctionBody('executeLadderPlan');