diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index a368812..f5a86a7 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.162 +// @version 2.7.163 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -390,6 +390,39 @@ matchingPositionCount: positions.length }; } + function resolveSymbolPositionSideStatus(payload, symbol, side) { + if (payload?.success !== true) throw new Error("持仓接口返回失败"); + if (!Array.isArray(payload.data)) throw new Error("持仓接口数据格式异常"); + if (!symbol) throw new Error("持仓接口缺少交易对"); + if (side !== "LONG" && side !== "SHORT") { + throw new Error(`目标持仓方向无效:${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 new Error(`持仓方向无效:${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("自动杠杆检查缺少交易对"); @@ -972,6 +1005,10 @@ const cancelledText = progress.cancelledOrders > 0 ? ` · 已撤 ${progress.cancelledOrders} 笔` : ""; return `${label}已完成 · 已挂 ${completedOrders}/${totalOrders} 笔${cancelledText}`; } + function formatPositionClosedLadderProgress(label, progress) { + assertLadderLabel(label); + return appendLadderProgressCounts(`${label}已结束 · 当前方向已无持仓`, progress); + } // src/binance-orderbook-trade/core/continuous-ladder.js var CONTINUOUS_LADDER_COOLDOWN_MS = 1e3; @@ -1036,7 +1073,7 @@ if (recordedRoundOutcomes.has(outcome)) { throw new Error("连续阶梯本轮结果已记录"); } - if (!["completed", "stopped", "failed", "interrupted"].includes(outcome.status)) { + if (!["completed", "position_closed", "stopped", "failed", "interrupted"].includes(outcome.status)) { throw new Error("连续阶梯本轮结果无效"); } const roundProgress = snapshotLadderProgress(outcome.progress); @@ -1104,6 +1141,21 @@ if (reason !== null) parts.push(reason); return parts.join(" · "); } + function formatContinuousLadderPositionClosedProgress(label, progress) { + if (typeof label !== "string" || label.trim() === "") { + throw new Error("连续阶梯动作名称无效"); + } + assertContinuousLadderProgress(progress); + const parts = [ + `连续${label}`, + "已结束", + "当前方向已无持仓", + progress.startedRounds === 0 ? "0 轮" : `${progress.completedRounds}/${progress.startedRounds} 轮`, + `累计 ${progress.submittedOrders} 笔` + ]; + if (progress.cancelledOrders > 0) parts.push(`撤 ${progress.cancelledOrders} 笔`); + return parts.join(" · "); + } function formatContinuousLadderWaitReason(phase, cooldownMs) { if (!Number.isFinite(cooldownMs) || cooldownMs < 0) { throw new Error("连续阶梯轮间等待时间无效"); @@ -1136,7 +1188,7 @@ let reported = waitingAlreadyReported; while (true) { throwIfAborted(signal); - const state = assertReadinessState(readReadiness()); + const state = assertReadinessState(await readReadiness()); if (state.status !== "waiting") return state; if (!reported) { onWaitStateChange({ phase: "waiting_ready", cooldownMs }); @@ -1175,7 +1227,7 @@ onWaitStateChange({ phase: "cooldown", cooldownMs }); await waitForPromiseOrAbort(delay(cooldownMs), signal); throwIfAborted(signal); - const afterCooldown = assertReadinessState(readReadiness()); + const afterCooldown = assertReadinessState(await readReadiness()); if (afterCooldown.status !== "waiting") return afterCooldown; onWaitStateChange({ phase: "waiting_ready", cooldownMs }); waitingAlreadyReported = true; @@ -2698,6 +2750,7 @@ ]; const LADDER_SUBMIT_ACK_TIMEOUT_MS = 3500; const LADDER_ACTION_FEEDBACK_MIN_MS = 240; + const CONTINUOUS_CLOSE_POSITION_CHECK_MS = 1e3; const MULTIPLIER_PRESS_FEEDBACK_MS = 140; const LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS = 6500; const LADDER_REPLACE_ROW_SETTLE_MS = 240; @@ -4137,6 +4190,39 @@ qtySource: raw.qtySource }; } + function createClosePositionCompletedError() { + const error = new Error("当前方向已无持仓"); + error.name = "ClosePositionCompletedError"; + return error; + } + function isClosePositionCompletedError(error) { + return error?.name === "ClosePositionCompletedError"; + } + async function throwIfClosePositionCompleted(context, abortSignal = null) { + if (context?.spec?.mode !== "CLOSE") return; + throwIfAborted(abortSignal); + if (!isCurrentObservedSymbol(context.symbol)) { + throw new Error("确认平仓结果时交易对已变化"); + } + if (getActiveTradeMode() !== "CLOSE") { + throw new Error("确认平仓结果时下单模式已变化"); + } + if (!await waitForBncHeaders(context.symbol)) { + throw new Error("确认平仓结果时币安请求头尚未就绪"); + } + const state = await fetchCurrentSymbolPositionSideState( + context.symbol, + context.spec.side + ); + throwIfAborted(abortSignal); + if (!isCurrentObservedSymbol(context.symbol)) { + throw new Error("确认平仓结果时交易对已变化"); + } + if (getActiveTradeMode() !== "CLOSE") { + throw new Error("确认平仓结果时下单模式已变化"); + } + if (state.status === "flat") throw createClosePositionCompletedError(); + } async function buildLadderPlan(actionType, expectedContext = null) { const spec = getLadderActionSpec2(actionType); if (!spec) throw new Error("未知阶梯动作"); @@ -4241,7 +4327,7 @@ ); } const baseQty = normalizeDecimalString(base?.qty ?? ""); - const unavailableQuantityMessage = getUnavailableLadderQuantityMessage( + let unavailableQuantityMessage = getUnavailableLadderQuantityMessage( spec.mode, baseQty, base?.confirmedZeroOpenBalance === true @@ -4253,6 +4339,10 @@ unavailableQuantityMessage ); } + if (spec.mode === "CLOSE") { + await throwIfClosePositionCompleted({ spec, symbol: startSymbol }); + unavailableQuantityMessage = "当前方向暂无可平数量"; + } throw new Error(unavailableQuantityMessage); } let percent = optionContext.percent; @@ -4956,7 +5046,7 @@ status: wasStopped ? "stopped" : "completed", progress: snapshotLadderProgress(progress) }; - }).catch((e) => { + }).catch(async (e) => { if (isLadderStoppedError(e)) { if (!continuousSession && isCurrentObservedSymbol(actionSymbol)) { setLadderStatus(formatStoppedLadderProgress(spec.label, progress)); @@ -4966,6 +5056,36 @@ progress: snapshotLadderProgress(progress) }; } + if (isClosePositionCompletedError(e)) { + if (!continuousSession && isCurrentObservedSymbol(actionSymbol)) { + setLadderStatus(formatPositionClosedLadderProgress(spec.label, progress)); + } + return { + status: "position_closed", + reason: e.message, + progress: snapshotLadderProgress(progress) + }; + } + if (spec.mode === "CLOSE" && e?.safeNoSubmit === true) { + try { + await throwIfClosePositionCompleted( + { spec, symbol: actionSymbol }, + abortController.signal + ); + } catch (positionError) { + if (isClosePositionCompletedError(positionError)) { + if (!continuousSession && isCurrentObservedSymbol(actionSymbol)) { + setLadderStatus(formatPositionClosedLadderProgress(spec.label, progress)); + } + return { + status: "position_closed", + reason: positionError.message, + progress: snapshotLadderProgress(progress) + }; + } + err("安全失败后确认当前方向持仓失败:", positionError); + } + } err("Maker 阶梯执行失败:", e); if (!isCurrentObservedSymbol(actionSymbol)) { if (!continuousSession) { @@ -5006,7 +5126,7 @@ scheduleRenderPanel(); return await ladderTask; } - function readContinuousLadderReadiness(actionType, actionSymbol) { + async function readContinuousLadderReadiness(actionType, actionSymbol, positionCheckState) { const spec = getLadderActionSpec2(actionType); if (!spec || spec.mode !== "CLOSE") { throw new Error("连续交易仅支持阶梯平仓"); @@ -5017,11 +5137,16 @@ if (getActiveTradeMode() !== spec.mode) { return { status: "stopped", reason: "mode_changed" }; } - if (document.hidden || ladderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask || !readCurrentOrderbookPrecisionValue() || !isCloseSnapshotReady(actionSymbol)) { + if (document.hidden || ladderTask || singleOrderTask || cancelCurrentSymbolOpenOrdersTask || !readCurrentOrderbookPrecisionValue()) { return { status: "waiting" }; } const button = spec.buttonGetter(); - if (!button || !button.isConnected || !isVisibleElement(button) || isSubmitButtonBusy(button)) { + if (!isCloseSnapshotReady(actionSymbol) || !button || !button.isConnected || !isVisibleElement(button) || isSubmitButtonBusy(button)) { + const now = Date.now(); + if (now - positionCheckState.checkedAt >= CONTINUOUS_CLOSE_POSITION_CHECK_MS) { + positionCheckState.checkedAt = now; + await throwIfClosePositionCompleted({ spec, symbol: actionSymbol }); + } return { status: "waiting" }; } return { status: "ready" }; @@ -5052,6 +5177,7 @@ } const abortController = new AbortController(); const continuousProgress = createContinuousLadderProgress(); + const positionCheckState = { checkedAt: Date.now() }; continuousLadderAbortController = abortController; activeContinuousLadderActionType = actionType; activeContinuousLadderProgress = continuousProgress; @@ -5061,6 +5187,13 @@ const outcome = await startLadder(actionType, continuousProgress); if (!outcome?.progress) return outcome; recordContinuousLadderRound(continuousProgress, outcome); + if (outcome.status === "position_closed") { + setLadderStatus(formatContinuousLadderPositionClosedProgress( + spec.label, + continuousProgress + )); + return outcome; + } const recovery = outcome.status === "completed" ? null : resolveContinuousLadderRecovery(outcome.error); if (outcome.status !== "completed" && !recovery) { setContinuousLadderProgressStatus( @@ -5076,7 +5209,20 @@ setContinuousLadderProgressStatus(spec.label, "running", continuousProgress); } const readiness = await waitForContinuousLadderNextRound({ - readReadiness: () => readContinuousLadderReadiness(actionType, actionSymbol), + readReadiness: async () => { + try { + return await readContinuousLadderReadiness( + actionType, + actionSymbol, + positionCheckState + ); + } catch (error) { + if (isClosePositionCompletedError(error)) { + return { status: "stopped", reason: "position_flat" }; + } + throw error; + } + }, delay, signal: abortController.signal, cooldownMs: recovery?.cooldownMs, @@ -5091,6 +5237,13 @@ } }); if (readiness.status === "stopped") { + if (readiness.reason === "position_flat") { + setLadderStatus(formatContinuousLadderPositionClosedProgress( + spec.label, + continuousProgress + )); + return { status: "position_closed", reason: "当前方向已无持仓" }; + } setContinuousLadderProgressStatus( spec.label, "stopped", @@ -6659,7 +6812,17 @@ abortSignal ); throwIfAborted(abortSignal); - if (!result?.ok) throw new Error(result.message || "原挂单未完成替换,已停止重新挂单"); + if (!result?.ok) { + if (replacementPlan.spec.mode === "CLOSE" && !["symbol_changed", "dialog_not_closed"].includes(result.status)) { + try { + await throwIfClosePositionCompleted(replacementPlan, abortSignal); + } catch (positionError) { + if (isClosePositionCompletedError(positionError)) throw positionError; + err("替换挂单失败后确认当前方向持仓失败:", positionError); + } + } + throw new Error(result.message || "原挂单未完成替换,已停止重新挂单"); + } } } throw new Error("阶梯重挂流程异常"); @@ -6921,7 +7084,7 @@ ]); return Boolean(cachedBncHeaders && isCurrentObservedSymbol(symbol)); } - async function fetchCurrentSymbolPositionState(symbol) { + async function fetchCurrentPositionsPayload() { if (!cachedBncHeaders) throw new Error("币安请求头尚未就绪"); const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), 5e3); @@ -6934,15 +7097,25 @@ signal: controller.signal }); if (!resp.ok) throw new Error(`持仓接口异常:HTTP ${resp.status}`); - const payload = await resp.json(); - return { - ...resolveSymbolPositionStatus(payload, symbol), - source: "user_position_api" - }; + return await resp.json(); } finally { window.clearTimeout(timer); } } + async function fetchCurrentSymbolPositionState(symbol) { + const payload = await fetchCurrentPositionsPayload(); + return { + ...resolveSymbolPositionStatus(payload, symbol), + source: "user_position_api" + }; + } + async function fetchCurrentSymbolPositionSideState(symbol, side) { + const payload = await fetchCurrentPositionsPayload(); + return { + ...resolveSymbolPositionSideStatus(payload, symbol, side), + source: "user_position_api" + }; + } function isStableOpenContext(symbol) { return getActiveTradeMode() === "OPEN" && isCurrentObservedSymbol(symbol); } diff --git a/src/binance-orderbook-trade/core/auto-open-leverage.js b/src/binance-orderbook-trade/core/auto-open-leverage.js index 6fb0998..6378191 100644 --- a/src/binance-orderbook-trade/core/auto-open-leverage.js +++ b/src/binance-orderbook-trade/core/auto-open-leverage.js @@ -21,6 +21,41 @@ export function resolveSymbolPositionStatus(payload, symbol) { }; } +export function resolveSymbolPositionSideStatus(payload, symbol, side) { + if (payload?.success !== true) throw new Error('持仓接口返回失败'); + if (!Array.isArray(payload.data)) throw new Error('持仓接口数据格式异常'); + if (!symbol) throw new Error('持仓接口缺少交易对'); + if (side !== 'LONG' && side !== 'SHORT') { + throw new Error(`目标持仓方向无效:${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 new Error(`持仓方向无效:${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, + }; +} + /** * Tracks confirmed position epochs without treating a temporarily missing DOM root * as a new flat transition. diff --git a/src/binance-orderbook-trade/core/continuous-ladder.js b/src/binance-orderbook-trade/core/continuous-ladder.js index cba8207..ce4947f 100644 --- a/src/binance-orderbook-trade/core/continuous-ladder.js +++ b/src/binance-orderbook-trade/core/continuous-ladder.js @@ -87,7 +87,7 @@ export function recordContinuousLadderRound(progress, outcome) { if (recordedRoundOutcomes.has(outcome)) { throw new Error('连续阶梯本轮结果已记录'); } - if (!['completed', 'stopped', 'failed', 'interrupted'].includes(outcome.status)) { + if (!['completed', 'position_closed', 'stopped', 'failed', 'interrupted'].includes(outcome.status)) { throw new Error('连续阶梯本轮结果无效'); } const roundProgress = snapshotLadderProgress(outcome.progress); @@ -168,6 +168,24 @@ export function formatContinuousLadderProgress(label, phase, progress, reason = return parts.join(' · '); } +export function formatContinuousLadderPositionClosedProgress(label, progress) { + if (typeof label !== 'string' || label.trim() === '') { + throw new Error('连续阶梯动作名称无效'); + } + assertContinuousLadderProgress(progress); + const parts = [ + `连续${label}`, + '已结束', + '当前方向已无持仓', + progress.startedRounds === 0 + ? '0 轮' + : `${progress.completedRounds}/${progress.startedRounds} 轮`, + `累计 ${progress.submittedOrders} 笔`, + ]; + if (progress.cancelledOrders > 0) parts.push(`撤 ${progress.cancelledOrders} 笔`); + return parts.join(' · '); +} + export function formatContinuousLadderWaitReason(phase, cooldownMs) { if (!Number.isFinite(cooldownMs) || cooldownMs < 0) { throw new Error('连续阶梯轮间等待时间无效'); @@ -205,7 +223,7 @@ async function waitUntilReadyOrStopped({ let reported = waitingAlreadyReported; while (true) { throwIfAborted(signal); - const state = assertReadinessState(readReadiness()); + const state = assertReadinessState(await readReadiness()); if (state.status !== 'waiting') return state; if (!reported) { onWaitStateChange({ phase: 'waiting_ready', cooldownMs }); @@ -250,7 +268,7 @@ export async function waitForContinuousLadderNextRound({ onWaitStateChange({ phase: 'cooldown', cooldownMs }); await waitForPromiseOrAbort(delay(cooldownMs), signal); throwIfAborted(signal); - const afterCooldown = assertReadinessState(readReadiness()); + const afterCooldown = assertReadinessState(await readReadiness()); if (afterCooldown.status !== 'waiting') return afterCooldown; onWaitStateChange({ phase: 'waiting_ready', cooldownMs }); waitingAlreadyReported = true; diff --git a/src/binance-orderbook-trade/core/ladder-progress.js b/src/binance-orderbook-trade/core/ladder-progress.js index dfd9e3d..031158c 100644 --- a/src/binance-orderbook-trade/core/ladder-progress.js +++ b/src/binance-orderbook-trade/core/ladder-progress.js @@ -142,3 +142,8 @@ export function formatCompletedLadderProgress(label, completedOrders, totalOrder : ''; return `${label}已完成 · 已挂 ${completedOrders}/${totalOrders} 笔${cancelledText}`; } + +export function formatPositionClosedLadderProgress(label, progress) { + assertLadderLabel(label); + return appendLadderProgressCounts(`${label}已结束 · 当前方向已无持仓`, progress); +} diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index f41efb3..07f89f2 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.162 +// @version 2.7.163 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -50,6 +50,7 @@ import { } from './core/close-action.js'; import { observeAutoOpenLeveragePositionState, + resolveSymbolPositionSideStatus, resolveSymbolPositionStatus, } from './core/auto-open-leverage.js'; import { @@ -81,6 +82,7 @@ import { createContinuousLadderProgress, formatActiveContinuousLadderProgress, formatContinuousLadderProgress, + formatContinuousLadderPositionClosedProgress, formatContinuousLadderWaitProgress, recordContinuousLadderRound, resolveContinuousLadderRecovery, @@ -91,6 +93,7 @@ import { formatCompletedLadderProgress, formatFailedLadderProgress, formatInterruptedLadderProgress, + formatPositionClosedLadderProgress, formatStoppedLadderProgress, recordLadderCancelledOrder, recordLadderSubmittedOrder, @@ -282,6 +285,7 @@ import { ]; const LADDER_SUBMIT_ACK_TIMEOUT_MS = 3500; const LADDER_ACTION_FEEDBACK_MIN_MS = 240; + const CONTINUOUS_CLOSE_POSITION_CHECK_MS = 1000; const MULTIPLIER_PRESS_FEEDBACK_MS = 140; const LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS = 6500; const LADDER_REPLACE_ROW_SETTLE_MS = 240; @@ -2055,6 +2059,42 @@ import { }; } + function createClosePositionCompletedError() { + const error = new Error('当前方向已无持仓'); + error.name = 'ClosePositionCompletedError'; + return error; + } + + function isClosePositionCompletedError(error) { + return error?.name === 'ClosePositionCompletedError'; + } + + async function throwIfClosePositionCompleted(context, abortSignal = null) { + if (context?.spec?.mode !== 'CLOSE') return; + throwIfAborted(abortSignal); + if (!isCurrentObservedSymbol(context.symbol)) { + throw new Error('确认平仓结果时交易对已变化'); + } + if (getActiveTradeMode() !== 'CLOSE') { + throw new Error('确认平仓结果时下单模式已变化'); + } + if (!await waitForBncHeaders(context.symbol)) { + throw new Error('确认平仓结果时币安请求头尚未就绪'); + } + const state = await fetchCurrentSymbolPositionSideState( + context.symbol, + context.spec.side, + ); + throwIfAborted(abortSignal); + if (!isCurrentObservedSymbol(context.symbol)) { + throw new Error('确认平仓结果时交易对已变化'); + } + if (getActiveTradeMode() !== 'CLOSE') { + throw new Error('确认平仓结果时下单模式已变化'); + } + if (state.status === 'flat') throw createClosePositionCompletedError(); + } + async function buildLadderPlan(actionType, expectedContext = null) { const spec = getLadderActionSpec(actionType); if (!spec) throw new Error('未知阶梯动作'); @@ -2172,7 +2212,7 @@ import { ); } const baseQty = normalizeDecimalString(base?.qty ?? ''); - const unavailableQuantityMessage = getUnavailableLadderQuantityMessage( + let unavailableQuantityMessage = getUnavailableLadderQuantityMessage( spec.mode, baseQty, base?.confirmedZeroOpenBalance === true, @@ -2184,6 +2224,10 @@ import { unavailableQuantityMessage, ); } + if (spec.mode === 'CLOSE') { + await throwIfClosePositionCompleted({ spec, symbol: startSymbol }); + unavailableQuantityMessage = '当前方向暂无可平数量'; + } throw new Error(unavailableQuantityMessage); } @@ -2989,7 +3033,7 @@ import { progress: snapshotLadderProgress(progress), }; }) - .catch((e) => { + .catch(async (e) => { if (isLadderStoppedError(e)) { if (!continuousSession && isCurrentObservedSymbol(actionSymbol)) { setLadderStatus(formatStoppedLadderProgress(spec.label, progress)); @@ -2999,6 +3043,36 @@ import { progress: snapshotLadderProgress(progress), }; } + if (isClosePositionCompletedError(e)) { + if (!continuousSession && isCurrentObservedSymbol(actionSymbol)) { + setLadderStatus(formatPositionClosedLadderProgress(spec.label, progress)); + } + return { + status: 'position_closed', + reason: e.message, + progress: snapshotLadderProgress(progress), + }; + } + if (spec.mode === 'CLOSE' && e?.safeNoSubmit === true) { + try { + await throwIfClosePositionCompleted( + { spec, symbol: actionSymbol }, + abortController.signal, + ); + } catch (positionError) { + if (isClosePositionCompletedError(positionError)) { + if (!continuousSession && isCurrentObservedSymbol(actionSymbol)) { + setLadderStatus(formatPositionClosedLadderProgress(spec.label, progress)); + } + return { + status: 'position_closed', + reason: positionError.message, + progress: snapshotLadderProgress(progress), + }; + } + err('安全失败后确认当前方向持仓失败:', positionError); + } + } err('Maker 阶梯执行失败:', e); if (!isCurrentObservedSymbol(actionSymbol)) { if (!continuousSession) { @@ -3043,7 +3117,11 @@ import { return await ladderTask; } - function readContinuousLadderReadiness(actionType, actionSymbol) { + async function readContinuousLadderReadiness( + actionType, + actionSymbol, + positionCheckState, + ) { const spec = getLadderActionSpec(actionType); if (!spec || spec.mode !== 'CLOSE') { throw new Error('连续交易仅支持阶梯平仓'); @@ -3060,17 +3138,22 @@ import { || singleOrderTask || cancelCurrentSymbolOpenOrdersTask || !readCurrentOrderbookPrecisionValue() - || !isCloseSnapshotReady(actionSymbol) ) { return { status: 'waiting' }; } const button = spec.buttonGetter(); if ( - !button + !isCloseSnapshotReady(actionSymbol) + || !button || !button.isConnected || !isVisibleElement(button) || isSubmitButtonBusy(button) ) { + const now = Date.now(); + if (now - positionCheckState.checkedAt >= CONTINUOUS_CLOSE_POSITION_CHECK_MS) { + positionCheckState.checkedAt = now; + await throwIfClosePositionCompleted({ spec, symbol: actionSymbol }); + } return { status: 'waiting' }; } return { status: 'ready' }; @@ -3112,6 +3195,7 @@ import { const abortController = new AbortController(); const continuousProgress = createContinuousLadderProgress(); + const positionCheckState = { checkedAt: Date.now() }; continuousLadderAbortController = abortController; activeContinuousLadderActionType = actionType; activeContinuousLadderProgress = continuousProgress; @@ -3122,6 +3206,13 @@ import { const outcome = await startLadder(actionType, continuousProgress); if (!outcome?.progress) return outcome; recordContinuousLadderRound(continuousProgress, outcome); + if (outcome.status === 'position_closed') { + setLadderStatus(formatContinuousLadderPositionClosedProgress( + spec.label, + continuousProgress, + )); + return outcome; + } const recovery = outcome.status === 'completed' ? null : resolveContinuousLadderRecovery(outcome.error); @@ -3140,7 +3231,20 @@ import { } const readiness = await waitForContinuousLadderNextRound({ - readReadiness: () => readContinuousLadderReadiness(actionType, actionSymbol), + readReadiness: async () => { + try { + return await readContinuousLadderReadiness( + actionType, + actionSymbol, + positionCheckState, + ); + } catch (error) { + if (isClosePositionCompletedError(error)) { + return { status: 'stopped', reason: 'position_flat' }; + } + throw error; + } + }, delay, signal: abortController.signal, cooldownMs: recovery?.cooldownMs, @@ -3155,6 +3259,13 @@ import { }, }); if (readiness.status === 'stopped') { + if (readiness.reason === 'position_flat') { + setLadderStatus(formatContinuousLadderPositionClosedProgress( + spec.label, + continuousProgress, + )); + return { status: 'position_closed', reason: '当前方向已无持仓' }; + } setContinuousLadderProgressStatus( spec.label, 'stopped', @@ -4982,7 +5093,20 @@ import { abortSignal, ); throwIfAborted(abortSignal); - if (!result?.ok) throw new Error(result.message || '原挂单未完成替换,已停止重新挂单'); + if (!result?.ok) { + if ( + replacementPlan.spec.mode === 'CLOSE' + && !['symbol_changed', 'dialog_not_closed'].includes(result.status) + ) { + try { + await throwIfClosePositionCompleted(replacementPlan, abortSignal); + } catch (positionError) { + if (isClosePositionCompletedError(positionError)) throw positionError; + err('替换挂单失败后确认当前方向持仓失败:', positionError); + } + } + throw new Error(result.message || '原挂单未完成替换,已停止重新挂单'); + } } } throw new Error('阶梯重挂流程异常'); @@ -5295,7 +5419,7 @@ import { return Boolean(cachedBncHeaders && isCurrentObservedSymbol(symbol)); } - async function fetchCurrentSymbolPositionState(symbol) { + async function fetchCurrentPositionsPayload() { if (!cachedBncHeaders) throw new Error('币安请求头尚未就绪'); const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), 5000); @@ -5308,16 +5432,28 @@ import { signal: controller.signal, }); if (!resp.ok) throw new Error(`持仓接口异常:HTTP ${resp.status}`); - const payload = await resp.json(); - return { - ...resolveSymbolPositionStatus(payload, symbol), - source: 'user_position_api', - }; + return await resp.json(); } finally { window.clearTimeout(timer); } } + async function fetchCurrentSymbolPositionState(symbol) { + const payload = await fetchCurrentPositionsPayload(); + return { + ...resolveSymbolPositionStatus(payload, symbol), + source: 'user_position_api', + }; + } + + async function fetchCurrentSymbolPositionSideState(symbol, side) { + const payload = await fetchCurrentPositionsPayload(); + return { + ...resolveSymbolPositionSideStatus(payload, symbol, side), + source: 'user_position_api', + }; + } + function isStableOpenContext(symbol) { return getActiveTradeMode() === 'OPEN' && isCurrentObservedSymbol(symbol); } 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 8080e9f..ae51f79 100644 --- a/test/unit/binance-orderbook-trade/auto-open-leverage.test.js +++ b/test/unit/binance-orderbook-trade/auto-open-leverage.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { observeAutoOpenLeveragePositionState, + resolveSymbolPositionSideStatus, resolveSymbolPositionStatus, } from '../../../src/binance-orderbook-trade/core/auto-open-leverage.js'; @@ -43,6 +44,55 @@ test('all current-symbol position directions must be zero before reset', () => { }); }); +test('close completion checks only the requested position side in hedge mode', () => { + const payload = { + success: true, + data: [ + { symbol: 'HYPEUSDT', positionSide: 'LONG', positionAmount: '1.25' }, + { symbol: 'HYPEUSDT', positionSide: 'SHORT', positionAmount: '0' }, + ], + }; + + assert.deepEqual(resolveSymbolPositionSideStatus(payload, 'HYPEUSDT', 'LONG'), { + status: 'has_position', + matchingPositionCount: 1, + }); + assert.deepEqual(resolveSymbolPositionSideStatus(payload, 'HYPEUSDT', 'SHORT'), { + status: 'flat', + matchingPositionCount: 1, + }); +}); + +test('close completion maps signed one-way positions to the requested side', () => { + const longPayload = { + success: true, + data: [{ symbol: 'HYPEUSDT', positionSide: 'BOTH', positionAmount: '1.25' }], + }; + const shortPayload = { + success: true, + data: [{ symbol: 'HYPEUSDT', positionSide: 'BOTH', positionAmount: '-1.25' }], + }; + + assert.equal(resolveSymbolPositionSideStatus(longPayload, 'HYPEUSDT', 'LONG').status, 'has_position'); + assert.equal(resolveSymbolPositionSideStatus(longPayload, 'HYPEUSDT', 'SHORT').status, 'flat'); + assert.equal(resolveSymbolPositionSideStatus(shortPayload, 'HYPEUSDT', 'LONG').status, 'flat'); + assert.equal(resolveSymbolPositionSideStatus(shortPayload, 'HYPEUSDT', 'SHORT').status, 'has_position'); +}); + +test('close completion rejects unknown position directions instead of guessing', () => { + assert.throws( + () => resolveSymbolPositionSideStatus({ + success: true, + data: [{ symbol: 'HYPEUSDT', positionSide: 'UNKNOWN', positionAmount: '1' }], + }, 'HYPEUSDT', 'LONG'), + /持仓方向无效/, + ); + assert.throws( + () => resolveSymbolPositionSideStatus({ success: true, data: [] }, 'HYPEUSDT', 'UNKNOWN'), + /目标持仓方向无效/, + ); +}); + test('rejects unsuccessful or malformed current-symbol position responses', () => { assert.throws( () => resolveSymbolPositionStatus({ success: false, data: [] }, 'HYPEUSDT'), diff --git a/test/unit/binance-orderbook-trade/continuous-ladder.test.js b/test/unit/binance-orderbook-trade/continuous-ladder.test.js index 8ef73e0..6f845d2 100644 --- a/test/unit/binance-orderbook-trade/continuous-ladder.test.js +++ b/test/unit/binance-orderbook-trade/continuous-ladder.test.js @@ -8,6 +8,7 @@ import { createContinuousLadderProgress, formatActiveContinuousLadderProgress, formatContinuousLadderProgress, + formatContinuousLadderPositionClosedProgress, formatContinuousLadderWaitProgress, formatContinuousLadderWaitReason, recordContinuousLadderRound, @@ -351,6 +352,35 @@ test('continuous ladder returns a terminal readiness state without another coold assert.deepEqual(delays, []); }); +test('continuous ladder supports an asynchronous confirmed-flat readiness check', async () => { + const delays = []; + const result = await waitForContinuousLadderNextRound({ + readReadiness: async () => ({ status: 'stopped', reason: 'position_flat' }), + delay: async (ms) => delays.push(ms), + }); + + assert.deepEqual(result, { status: 'stopped', reason: 'position_flat' }); + assert.deepEqual(delays, []); +}); + +test('continuous ladder formats confirmed flat as an ended outcome without a failed round detail', () => { + const progress = createContinuousLadderProgress(); + recordContinuousLadderRound(progress, { + status: 'position_closed', + progress: roundProgress({ + submittedOrders: 0, + cancelledOrders: 1, + plannedOrders: 3, + currentPlanSubmittedOrders: 0, + }), + }); + + assert.equal( + formatContinuousLadderPositionClosedProgress('阶梯平空', progress), + '连续阶梯平空 · 已结束 · 当前方向已无持仓 · 0/1 轮 · 累计 0 笔 · 撤 1 笔', + ); +}); + test('continuous ladder cooldown is abortable', async () => { const abortController = new AbortController(); const stoppedError = new Error('stopped'); diff --git a/test/unit/binance-orderbook-trade/ladder-progress.test.js b/test/unit/binance-orderbook-trade/ladder-progress.test.js index f8117e6..eaa8f00 100644 --- a/test/unit/binance-orderbook-trade/ladder-progress.test.js +++ b/test/unit/binance-orderbook-trade/ladder-progress.test.js @@ -6,6 +6,7 @@ import { formatCompletedLadderProgress, formatFailedLadderProgress, formatInterruptedLadderProgress, + formatPositionClosedLadderProgress, formatStoppedLadderProgress, recordLadderCancelledOrder, recordLadderSubmittedOrder, @@ -98,6 +99,17 @@ test('completed ladder status names the action and confirmed result', () => { ); }); +test('confirmed flat position is an ended business outcome with retained progress', () => { + const progress = createLadderProgress(); + setLadderPlannedOrders(progress, 3); + recordLadderCancelledOrder(progress); + + assert.equal( + formatPositionClosedLadderProgress('阶梯平空', progress), + '阶梯平空已结束 · 当前方向已无持仓 · 已挂 0/3 笔 · 已撤 1 笔', + ); +}); + test('failed and interrupted ladder statuses retain confirmed progress', () => { const progress = createLadderProgress(); setLadderPlannedOrders(progress, 5); diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 8101f66..6445e2e 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -640,7 +640,10 @@ test('Option or Alt click continuously repeats close ladders only after readines assert.match(continuousBody, /continue/); assert.match(continuousBody, /setContinuousLadderProgressStatus\(/); assert.match(continuousBody, /await waitForContinuousLadderNextRound\(/); - assert.match(continuousBody, /readContinuousLadderReadiness\(actionType, actionSymbol\)/); + assert.match( + continuousBody, + /readContinuousLadderReadiness\(\s*actionType,\s*actionSymbol,\s*positionCheckState/, + ); assert.match(continuousBody, /onWaitStateChange:/); assert.match(continuousBody, /formatContinuousLadderWaitProgress\(/); assert.match(startBody, /formatActiveContinuousLadderProgress\(/); @@ -1631,6 +1634,25 @@ test('continuous close ladders recover only from tagged pre-submit transients', assert.match(recoveryBody, /error\.continuousRecoveryKind = kind/); }); +test('confirmed directional flat state ends close ladders without masking uncertain outcomes', () => { + const startBody = readFunctionBody('startLadder'); + const planBody = readFunctionBody('buildLadderPlan'); + const replacementBody = readFunctionBody('runLadderPlanWithOpenOrderReplacement'); + const readinessBody = readFunctionBody('readContinuousLadderReadiness'); + const confirmationBody = readFunctionBody('throwIfClosePositionCompleted'); + + assert.match(confirmationBody, /fetchCurrentSymbolPositionSideState/); + assert.match(confirmationBody, /state\.status === 'flat'/); + assert.match(confirmationBody, /throw createClosePositionCompletedError\(\)/); + assert.match(planBody, /await throwIfClosePositionCompleted\(\{ spec, symbol: startSymbol \}\)/); + assert.match(startBody, /spec\.mode === 'CLOSE' && e\?\.safeNoSubmit === true/); + assert.match(startBody, /status: 'position_closed'/); + assert.doesNotMatch(startBody, /未确认.*throwIfClosePositionCompleted/); + assert.match(replacementBody, /!\['symbol_changed', 'dialog_not_closed'\]\.includes\(result\.status\)/); + assert.match(replacementBody, /await throwIfClosePositionCompleted\(replacementPlan, abortSignal\)/); + assert.match(readinessBody, /CONTINUOUS_CLOSE_POSITION_CHECK_MS/); +}); + test('single-order sizing and submission retain the captured orderbook precision', () => { const resolveBody = readFunctionBody('resolveTargetQty'); assert.match(resolveBody, /const precision = readCurrentOrderbookPrecisionValue\(\)/); @@ -1684,9 +1706,11 @@ test('auto leverage reset is authorized by a fresh current-symbol position respo source, /BINANCE_USER_POSITION_BAPI_PATH = '\/bapi\/futures\/v6\/private\/future\/user-data\/user-position'/, ); + const fetchPayloadBody = readFunctionBody('fetchCurrentPositionsPayload'); const fetchBody = readFunctionBody('fetchCurrentSymbolPositionState'); assert.match(fetchBody, /resolveSymbolPositionStatus\(payload, symbol\)/); - assert.match(fetchBody, /body: JSON\.stringify\(\{\}\)/); + assert.match(fetchBody, /await fetchCurrentPositionsPayload\(\)/); + assert.match(fetchPayloadBody, /body: JSON\.stringify\(\{\}\)/); const resetBody = readFunctionBody('autoResetOpenLeverageToDefault'); const positionCheckIndex = resetBody.indexOf('await fetchCurrentSymbolPositionState(symbol)');