From 4d12b97d1c0b1c15a4eabcae0a6f74d70ab635c6 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:21:57 +0800 Subject: [PATCH 1/3] Recover continuous ladders from UI render races --- scripts/binance-orderbook-trade.user.js | 35 ++++++++++++---- src/binance-orderbook-trade/index.user.js | 40 +++++++++++++++---- .../source-regressions.test.js | 14 +++++++ 3 files changed, 74 insertions(+), 15 deletions(-) diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index b402b8b..ec99375 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.174 +// @version 2.7.175 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -5637,7 +5637,10 @@ const submitted = normalizeDecimalString(submittedPrice); const cmp = compareDecimalStrings(expected, submitted); if (cmp !== 0) { - throw new Error(`价格框未同步,${expectedLabel} ${expected || expectedPrice},当前提交价 ${submitted || submittedPrice || "-"}`); + throw createContinuousRecoverableLadderError( + "input_unstable", + `价格框未同步,${expectedLabel} ${expected || expectedPrice},当前提交价 ${submitted || submittedPrice || "-"}` + ); } } function assertSubmittedQtyMatchesExpectedQty(expectedQty, submittedQty, expectedLabel = "目标量") { @@ -5645,7 +5648,10 @@ const submitted = normalizeDecimalString(submittedQty); const cmp = compareDecimalStrings(expected, submitted); if (cmp !== 0) { - throw new Error(`数量框未同步,${expectedLabel} ${expected || expectedQty},当前提交量 ${submitted || submittedQty || "-"}`); + throw createContinuousRecoverableLadderError( + "input_unstable", + `数量框未同步,${expectedLabel} ${expected || expectedQty},当前提交量 ${submitted || submittedQty || "-"}` + ); } } async function syncTradeInputs(expectedPrice, expectedQty, options = null) { @@ -6943,17 +6949,32 @@ if (!isCurrentObservedSymbol(symbol)) throw new Error("加载待撤挂单时交易对已变化"); currentRoot = getActiveOpenOrdersScope2() || currentRoot; if (!currentRoot) throw new Error("加载待撤挂单时当前委托面板已消失"); + const renderRemainingMs = deadline - Date.now(); + const renderState = await waitForAccountOrdersState(() => { + const refreshedRoot = getActiveOpenOrdersScope2(); + if (!refreshedRoot || !isCurrentObservedSymbol(symbol)) return null; + const settledState2 = readCurrentSymbolOpenOrderRowsState(refreshedRoot, symbol, plan); + if (!settledState2) return null; + return { + root: refreshedRoot, + settledState: settledState2, + scrollContainer: findOpenOrderRowsScrollContainer(refreshedRoot) + }; + }, Math.min(OPEN_ORDERS_LAZY_LOAD_SETTLE_MS, renderRemainingMs), abortSignal); + if (!renderState) continue; + currentRoot = renderState.root; + const { settledState, scrollContainer } = renderState; + if (!scrollContainer && settledState) return settledState.rows; const beforeCount = readOpenOrderRowElements(currentRoot).length; - const scrollContainer = findOpenOrderRowsScrollContainer(currentRoot); - if (!scrollContainer) return readCurrentSymbolOpenOrderRows(currentRoot, symbol, plan); scrollOpenOrderRowsToBottom(scrollContainer); - const remainingMs = deadline - Date.now(); + const growthRemainingMs = deadline - Date.now(); + if (growthRemainingMs <= 0) break; const growth = await waitForAccountOrdersState(() => { const refreshedRoot = getActiveOpenOrdersScope2(); if (!refreshedRoot || !isCurrentObservedSymbol(symbol)) return null; const loadedCount = readOpenOrderRowElements(refreshedRoot).length; return loadedCount > beforeCount ? { root: refreshedRoot, loadedCount } : null; - }, Math.min(OPEN_ORDERS_LAZY_LOAD_SETTLE_MS, remainingMs), abortSignal); + }, Math.min(OPEN_ORDERS_LAZY_LOAD_SETTLE_MS, growthRemainingMs), abortSignal); throwIfAborted(abortSignal); currentRoot = growth?.root || getActiveOpenOrdersScope2() || currentRoot; const afterCount = readOpenOrderRowElements(currentRoot).length; diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index a93d6e2..c360fd8 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.174 +// @version 2.7.175 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -2811,7 +2811,10 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; const submitted = normalizeDecimalString(submittedPrice); const cmp = compareDecimalStrings(expected, submitted); if (cmp !== 0) { - throw new Error(`价格框未同步,${expectedLabel} ${expected || expectedPrice},当前提交价 ${submitted || submittedPrice || '-'}`); + throw createContinuousRecoverableLadderError( + 'input_unstable', + `价格框未同步,${expectedLabel} ${expected || expectedPrice},当前提交价 ${submitted || submittedPrice || '-'}`, + ); } } @@ -2820,7 +2823,10 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; const submitted = normalizeDecimalString(submittedQty); const cmp = compareDecimalStrings(expected, submitted); if (cmp !== 0) { - throw new Error(`数量框未同步,${expectedLabel} ${expected || expectedQty},当前提交量 ${submitted || submittedQty || '-'}`); + throw createContinuousRecoverableLadderError( + 'input_unstable', + `数量框未同步,${expectedLabel} ${expected || expectedQty},当前提交量 ${submitted || submittedQty || '-'}`, + ); } } @@ -4356,18 +4362,36 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; currentRoot = getActiveOpenOrdersScope() || currentRoot; if (!currentRoot) throw new Error('加载待撤挂单时当前委托面板已消失'); - const beforeCount = readOpenOrderRowElements(currentRoot).length; - const scrollContainer = findOpenOrderRowsScrollContainer(currentRoot); - if (!scrollContainer) return readCurrentSymbolOpenOrderRows(currentRoot, symbol, plan); + // Binance commits the active tab and symbol filter before React mounts + // the matching rows and scroll container, so an empty DOM is not settled. + const renderRemainingMs = deadline - Date.now(); + const renderState = await waitForAccountOrdersState(() => { + const refreshedRoot = getActiveOpenOrdersScope(); + if (!refreshedRoot || !isCurrentObservedSymbol(symbol)) return null; + const settledState = readCurrentSymbolOpenOrderRowsState(refreshedRoot, symbol, plan); + if (!settledState) return null; + return { + root: refreshedRoot, + settledState, + scrollContainer: findOpenOrderRowsScrollContainer(refreshedRoot), + }; + }, Math.min(OPEN_ORDERS_LAZY_LOAD_SETTLE_MS, renderRemainingMs), abortSignal); + if (!renderState) continue; + currentRoot = renderState.root; + const { settledState, scrollContainer } = renderState; + if (!scrollContainer && settledState) return settledState.rows; + + const beforeCount = readOpenOrderRowElements(currentRoot).length; scrollOpenOrderRowsToBottom(scrollContainer); - const remainingMs = deadline - Date.now(); + const growthRemainingMs = deadline - Date.now(); + if (growthRemainingMs <= 0) break; const growth = await waitForAccountOrdersState(() => { const refreshedRoot = getActiveOpenOrdersScope(); if (!refreshedRoot || !isCurrentObservedSymbol(symbol)) return null; const loadedCount = readOpenOrderRowElements(refreshedRoot).length; return loadedCount > beforeCount ? { root: refreshedRoot, loadedCount } : null; - }, Math.min(OPEN_ORDERS_LAZY_LOAD_SETTLE_MS, remainingMs), abortSignal); + }, Math.min(OPEN_ORDERS_LAZY_LOAD_SETTLE_MS, growthRemainingMs), abortSignal); throwIfAborted(abortSignal); currentRoot = growth?.root || getActiveOpenOrdersScope() || currentRoot; diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 8ed7f8d..ceb5b68 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -294,6 +294,16 @@ test('continuous close recovers a confirmed max-open-orders rejection by freeing assert.doesNotMatch(cancelPlanBody, /findCurrentSymbolCancelAllButton/); }); +test('capacity recovery waits through an unrendered open-orders list instead of treating it as empty', () => { + const loadRowsBody = readFunctionBody('loadAllCurrentSymbolOpenOrderRows'); + + assert.doesNotMatch(loadRowsBody, /if \(!scrollContainer\) return readCurrentSymbolOpenOrderRows/); + assert.match(loadRowsBody, /readCurrentSymbolOpenOrderRowsState\(refreshedRoot,\s*symbol,\s*plan\)/); + assert.match(loadRowsBody, /if \(!scrollContainer && settledState\) return settledState\.rows;/); + assert.match(loadRowsBody, /waitForAccountOrdersState\([\s\S]*readCurrentSymbolOpenOrderRowsState/); + assert.match(loadRowsBody, /scrollOpenOrderRowsToBottom\(scrollContainer\);[\s\S]*const growthRemainingMs = deadline - Date\.now\(\);/); +}); + test('capacity recovery skips an unconfirmed row cancellation without claiming the slot was released', () => { const cancelOneRowBody = readFunctionBody('cancelOneOpenOrderRowForPlan'); const cancelFarthestBody = readFunctionBody('cancelFarthestOpenOrderRowsForPlan'); @@ -1748,6 +1758,8 @@ test('ladder plans fail closed when orderbook precision changes', () => { test('continuous close ladders recover only from tagged pre-submit transients', () => { const syncBody = readFunctionBody('syncTradeInputs'); + const priceAssertionBody = readFunctionBody('assertSubmittedPriceMatchesExpectedPrice'); + const quantityAssertionBody = readFunctionBody('assertSubmittedQtyMatchesExpectedQty'); const buttonBody = readFunctionBody('waitForReadyLadderSubmitButton'); const executeBody = readFunctionBody('executeLadderPlan'); const buildBody = readFunctionBody('buildLadderPlan'); @@ -1756,6 +1768,8 @@ test('continuous close ladders recover only from tagged pre-submit transients', const recoveryBody = readFunctionBody('createContinuousRecoverableLadderError'); assert.match(syncBody, /createContinuousRecoverableLadderError\(\s*'input_unstable'/); + assert.match(priceAssertionBody, /createContinuousRecoverableLadderError\(\s*'input_unstable'/); + assert.match(quantityAssertionBody, /createContinuousRecoverableLadderError\(\s*'input_unstable'/); assert.match(buttonBody, /createContinuousRecoverableLadderError\(\s*'controls_not_ready'/); assert.match(executeBody, /createContinuousRecoverableLadderError\(\s*'controls_not_ready'/); assert.match(buildBody, /createContinuousRecoverableLadderError\(\s*'market_data_not_ready'/); From a478fd46c4b3293f757997a2ae85d4f6c2624550 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:26:52 +0800 Subject: [PATCH 2/3] Continue after unconfirmed ladder submissions --- scripts/binance-orderbook-trade.user.js | 31 ++++++++++++++----- .../core/continuous-ladder.js | 12 +++++-- src/binance-orderbook-trade/index.user.js | 18 ++++++++++- .../continuous-ladder.test.js | 9 ++++-- .../source-regressions.test.js | 10 ++++++ 5 files changed, 65 insertions(+), 15 deletions(-) diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index ec99375..0fb2ef1 100644 --- a/scripts/binance-orderbook-trade.user.js +++ b/scripts/binance-orderbook-trade.user.js @@ -136,14 +136,14 @@ } return Object.freeze({ zhCN, en }); } - function isLocalizedText(value) { + function isLocalizedText2(value) { return Boolean( value && typeof value === "object" && typeof value.zhCN === "string" && typeof value.en === "string" ); } function formatLocalizedText(value, locale) { if (typeof value === "string") return value; - if (!isLocalizedText(value)) throw new Error("Invalid localized UI text"); + if (!isLocalizedText2(value)) throw new Error("Invalid localized UI text"); if (locale === UI_LOCALE_ZH_CN) return value.zhCN; if (locale === UI_LOCALE_EN) return value.en; throw new Error(`Unsupported UI locale: ${locale}`); @@ -1204,12 +1204,12 @@ } } function assertLadderLabel(label) { - if (!(isLocalizedText(label) || typeof label === "string" && label.trim() !== "")) { + if (!(isLocalizedText2(label) || typeof label === "string" && label.trim() !== "")) { throw new Error("阶梯动作名称无效"); } } function assertLadderMessage(message) { - if (!(isLocalizedText(message) || typeof message === "string" && message.trim() !== "")) { + if (!(isLocalizedText2(message) || typeof message === "string" && message.trim() !== "")) { throw new Error("阶梯进度信息无效"); } } @@ -1336,6 +1336,10 @@ var CONTINUOUS_LADDER_READY_CHECK_MS = 50; var CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS = 3e3; var CONTINUOUS_LADDER_RECOVERY = Object.freeze({ + submit_unconfirmed: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + requiresSafeNoSubmit: false + }, input_unstable: { cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS }, @@ -1369,7 +1373,7 @@ interrupted: localizedText("已中止", "Interrupted") }); function isValidLocalizedValue(value) { - return isLocalizedText(value) || typeof value === "string" && value.trim() !== ""; + return isLocalizedText2(value) || typeof value === "string" && value.trim() !== ""; } function assertContinuousLadderProgress(progress) { if (!progress || !Number.isInteger(progress.startedRounds) || progress.startedRounds < 0 || !Number.isInteger(progress.completedRounds) || progress.completedRounds < 0 || progress.completedRounds > progress.startedRounds || !Number.isInteger(progress.submittedOrders) || progress.submittedOrders < 0 || !Number.isInteger(progress.cancelledOrders) || progress.cancelledOrders < 0 || progress.startedRounds === 0 !== (progress.lastRound === null)) { @@ -1387,9 +1391,9 @@ }; } function resolveContinuousLadderRecovery(error) { - if (error?.safeNoSubmit !== true) return null; - const recovery = CONTINUOUS_LADDER_RECOVERY[error.continuousRecoveryKind]; + const recovery = CONTINUOUS_LADDER_RECOVERY[error?.continuousRecoveryKind]; if (!recovery) return null; + if (recovery.requiresSafeNoSubmit !== false && error.safeNoSubmit !== true) return null; return { cooldownMs: recovery.cooldownMs, reason: recovery.reason || error.localizedText || error.message @@ -3823,6 +3827,13 @@ error.localizedText = localizedMessage; return error; } + function createContinuousUnconfirmedSubmitError(message) { + if (!isLocalizedText(message)) throw new Error("未确认下单结果文案必须提供中英文"); + const error = new Error(formatLocalizedText(message, "zh-CN")); + error.continuousRecoveryKind = "submit_unconfirmed"; + error.localizedText = message; + return error; + } function getLadderOpenPercent(symbol = getCurrentSymbol(), precision = readCurrentOrderbookPrecisionValue()) { return loadModeSymbolPrecisionNumberOption( localStorage, @@ -5913,7 +5924,11 @@ if (capturedApiSuccesses.length === 1) return; const responseDiagnostic = responseObservation.diagnostics.length === 0 ? "" : responseObservation.diagnostics.map(formatBinancePlaceOrderResponseDiagnostic).join(" | "); const settleHint = responseObservation.settled ? `下单请求已返回,但结果未识别${responseDiagnostic ? `:${responseDiagnostic}` : ""}` : sawBusy ? "下单按钮已恢复,但下单请求仍未返回" : "下单请求仍未返回"; - throw new Error(`未确认${label}成功(${settleHint}),已停止;请在当前委托和历史成交中核对`); + const englishSettleHint = responseObservation.settled ? `the request returned but its result was not recognized${responseDiagnostic ? `: ${responseDiagnostic}` : ""}` : sawBusy ? "the submit button recovered but the request has not returned" : "the request has not returned"; + throw createContinuousUnconfirmedSubmitError(localizedText( + `未确认${label}成功(${settleHint});请在当前委托和历史成交中核对`, + `Order submission was not confirmed (${englishSettleHint}); check Open Orders and Trade History` + )); } async function executeLadderPlan(plan, progress, setExecutionStatus, abortSignal = null, options = null) { const priceInput = findPriceInput(); diff --git a/src/binance-orderbook-trade/core/continuous-ladder.js b/src/binance-orderbook-trade/core/continuous-ladder.js index d9f9502..77c32b8 100644 --- a/src/binance-orderbook-trade/core/continuous-ladder.js +++ b/src/binance-orderbook-trade/core/continuous-ladder.js @@ -17,6 +17,10 @@ export const CONTINUOUS_LADDER_READY_CHECK_MS = 50; export const CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS = 3000; const CONTINUOUS_LADDER_RECOVERY = Object.freeze({ + submit_unconfirmed: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + requiresSafeNoSubmit: false, + }, input_unstable: { cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, }, @@ -85,12 +89,14 @@ export function createContinuousLadderProgress() { } /** - * Recovers only when the failed submission attempt is proven not to have sent an order. + * Most recoveries require proof that no order was submitted. The explicit + * submit_unconfirmed policy accepts a possible duplicate in continuous mode + * and advances to a new round instead of retrying the uncertain order in place. */ export function resolveContinuousLadderRecovery(error) { - if (error?.safeNoSubmit !== true) return null; - const recovery = CONTINUOUS_LADDER_RECOVERY[error.continuousRecoveryKind]; + const recovery = CONTINUOUS_LADDER_RECOVERY[error?.continuousRecoveryKind]; if (!recovery) return null; + if (recovery.requiresSafeNoSubmit !== false && error.safeNoSubmit !== true) return null; return { cooldownMs: recovery.cooldownMs, reason: recovery.reason || error.localizedText || error.message, diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index c360fd8..9b9bf7c 100644 --- a/src/binance-orderbook-trade/index.user.js +++ b/src/binance-orderbook-trade/index.user.js @@ -633,6 +633,14 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return error; } + function createContinuousUnconfirmedSubmitError(message) { + if (!isLocalizedText(message)) throw new Error('未确认下单结果文案必须提供中英文'); + const error = new Error(formatLocalizedText(message, 'zh-CN')); + error.continuousRecoveryKind = 'submit_unconfirmed'; + error.localizedText = message; + return error; + } + function getLadderOpenPercent( symbol = getCurrentSymbol(), precision = readCurrentOrderbookPrecisionValue(), @@ -3165,7 +3173,15 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; const settleHint = responseObservation.settled ? `下单请求已返回,但结果未识别${responseDiagnostic ? `:${responseDiagnostic}` : ''}` : (sawBusy ? '下单按钮已恢复,但下单请求仍未返回' : '下单请求仍未返回'); - throw new Error(`未确认${label}成功(${settleHint}),已停止;请在当前委托和历史成交中核对`); + const englishSettleHint = responseObservation.settled + ? `the request returned but its result was not recognized${responseDiagnostic ? `: ${responseDiagnostic}` : ''}` + : (sawBusy + ? 'the submit button recovered but the request has not returned' + : 'the request has not returned'); + throw createContinuousUnconfirmedSubmitError(localizedText( + `未确认${label}成功(${settleHint});请在当前委托和历史成交中核对`, + `Order submission was not confirmed (${englishSettleHint}); check Open Orders and Trade History`, + )); } async function executeLadderPlan( diff --git a/test/unit/binance-orderbook-trade/continuous-ladder.test.js b/test/unit/binance-orderbook-trade/continuous-ladder.test.js index e4b6a4e..576e11f 100644 --- a/test/unit/binance-orderbook-trade/continuous-ladder.test.js +++ b/test/unit/binance-orderbook-trade/continuous-ladder.test.js @@ -159,7 +159,7 @@ test('continuous ladder progress rejects an invalid or duplicate round outcome', ); }); -test('continuous ladder retries only explicitly safe transient failures', () => { +test('continuous ladder retries only explicitly designed recoverable failures', () => { const inputUnstable = new Error('价格框或数量框未稳定'); inputUnstable.safeNoSubmit = true; inputUnstable.continuousRecoveryKind = 'input_unstable'; @@ -194,8 +194,11 @@ test('continuous ladder retries only explicitly safe transient failures', () => const unknownOutcome = new Error('仍未确认订单结果'); unknownOutcome.safeNoSubmit = false; - unknownOutcome.continuousRecoveryKind = 'input_unstable'; - assert.equal(resolveContinuousLadderRecovery(unknownOutcome), null); + unknownOutcome.continuousRecoveryKind = 'submit_unconfirmed'; + assert.deepEqual(resolveContinuousLadderRecovery(unknownOutcome), { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + reason: '仍未确认订单结果', + }); const unsupported = new Error('未知错误'); unsupported.safeNoSubmit = true; diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index ceb5b68..d7487c3 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -1779,6 +1779,16 @@ test('continuous close ladders recover only from tagged pre-submit transients', assert.match(recoveryBody, /error\.continuousRecoveryKind = kind/); }); +test('continuous close ladders continue after an explicitly tagged unconfirmed submission', () => { + const acknowledgementBody = readFunctionBody('waitForOrderSubmitAcknowledgement'); + const recoveryBody = readFunctionBody('createContinuousUnconfirmedSubmitError'); + + assert.match(acknowledgementBody, /createContinuousUnconfirmedSubmitError\(/); + assert.doesNotMatch(acknowledgementBody, /未确认\$\{label\}成功[\s\S]*已停止/); + assert.match(recoveryBody, /error\.continuousRecoveryKind = 'submit_unconfirmed'/); + assert.doesNotMatch(recoveryBody, /error\.safeNoSubmit = true/); +}); + test('confirmed directional flat state ends close ladders without masking uncertain outcomes', () => { const startBody = readFunctionBody('startLadder'); const planBody = readFunctionBody('buildLadderPlan'); From 80ba695612ceb4e7aa689d929aa8cad110a5beb1 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:54:06 +0800 Subject: [PATCH 3/3] Keep continuous close ladders running --- scripts/binance-orderbook-trade.user.js | 251 ++++++++++++++---- .../core/auto-open-leverage.js | 24 +- .../core/continuous-ladder.js | 25 +- .../core/order-feedback.js | 34 +++ src/binance-orderbook-trade/index.user.js | 211 ++++++++++++--- .../auto-open-leverage.test.js | 10 +- .../continuous-ladder.test.js | 24 ++ .../order-feedback.test.js | 28 ++ .../source-regressions.test.js | 34 ++- 9 files changed, 542 insertions(+), 99 deletions(-) diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 0fb2ef1..93f7609 100644 --- a/scripts/binance-orderbook-trade.user.js +++ b/scripts/binance-orderbook-trade.user.js @@ -476,17 +476,22 @@ // 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 new Error(`持仓数量无效:${String(value)}`); + throw createPositionPayloadContractError(`持仓数量无效:${String(value)}`); } function resolveSymbolPositionStatus(payload, symbol) { - if (payload?.success !== true) throw new Error("持仓接口返回失败"); - if (!Array.isArray(payload.data)) throw new Error("持仓接口数据格式异常"); - if (!symbol) throw new Error("持仓接口缺少交易对"); + 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 { @@ -495,11 +500,11 @@ }; } 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 (payload?.success !== true) throw createPositionPayloadContractError("持仓接口返回失败"); + if (!Array.isArray(payload.data)) throw createPositionPayloadContractError("持仓接口数据格式异常"); + if (!symbol) throw createPositionPayloadContractError("持仓接口缺少交易对"); if (side !== "LONG" && side !== "SHORT") { - throw new Error(`目标持仓方向无效:${String(side)}`); + throw createPositionPayloadContractError(`目标持仓方向无效:${String(side)}`); } const positions = payload.data.filter((position) => position?.symbol === symbol); let matchingPositionCount = 0; @@ -507,7 +512,7 @@ for (const position of positions) { const positionSide = position.positionSide; if (!["BOTH", "LONG", "SHORT"].includes(positionSide)) { - throw new Error(`持仓方向无效:${String(positionSide)}`); + throw createPositionPayloadContractError(`持仓方向无效:${String(positionSide)}`); } const amount = parsePositionAmount(position.positionAmount); if (positionSide === side) { @@ -1335,11 +1340,24 @@ var CONTINUOUS_LADDER_COOLDOWN_MS = 1e3; var CONTINUOUS_LADDER_READY_CHECK_MS = 50; var CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS = 3e3; + var CONTINUOUS_LADDER_LONG_RECOVERY_COOLDOWN_MS = 1e4; var CONTINUOUS_LADDER_RECOVERY = Object.freeze({ submit_unconfirmed: { cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, requiresSafeNoSubmit: false }, + rate_limited: { + cooldownMs: CONTINUOUS_LADDER_LONG_RECOVERY_COOLDOWN_MS, + requiresSafeNoSubmit: false + }, + order_capacity_not_ready: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + requiresSafeNoSubmit: false + }, + open_orders_not_ready: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + requiresSafeNoSubmit: false + }, input_unstable: { cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS }, @@ -1349,6 +1367,12 @@ market_data_not_ready: { cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS }, + position_state_not_ready: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS + }, + position_quantity_not_ready: { + cooldownMs: CONTINUOUS_LADDER_LONG_RECOVERY_COOLDOWN_MS + }, precision_changed: { cooldownMs: CONTINUOUS_LADDER_COOLDOWN_MS, reason: localizedText( @@ -1394,8 +1418,12 @@ const recovery = CONTINUOUS_LADDER_RECOVERY[error?.continuousRecoveryKind]; if (!recovery) return null; if (recovery.requiresSafeNoSubmit !== false && error.safeNoSubmit !== true) return null; + const cooldownMs = error.continuousRecoveryCooldownMs ?? recovery.cooldownMs; + if (!Number.isFinite(cooldownMs) || cooldownMs < 0) { + throw new Error("连续阶梯恢复等待时间无效"); + } return { - cooldownMs: recovery.cooldownMs, + cooldownMs, reason: recovery.reason || error.localizedText || error.message }; } @@ -1712,6 +1740,31 @@ function isBinanceMaxOpenOrdersErrorCode(code) { return code === BINANCE_MAX_OPEN_ORDERS_ERROR_CODE; } + function parseRetryAfterMs(value) { + if (value == null || value === "") return null; + const seconds = Number(value); + return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : null; + } + function resolveBinanceSubmitResponseRecovery(diagnostics, apiErrors) { + if (!Array.isArray(diagnostics) || !Array.isArray(apiErrors)) { + throw new Error("下单响应恢复证据无效"); + } + const rateLimitDiagnostic = diagnostics.find(({ httpStatus }) => httpStatus === 418 || httpStatus === 429); + const hasRateLimitCode = apiErrors.some(({ code }) => code === -1003); + if (rateLimitDiagnostic || hasRateLimitCode) { + return { + kind: "rate_limited", + cooldownMs: parseRetryAfterMs(rateLimitDiagnostic?.retryAfter) ?? 1e4 + }; + } + if (diagnostics.some(({ httpStatus }) => httpStatus >= 500 && httpStatus <= 599)) { + return { + kind: "submit_unconfirmed", + cooldownMs: 3e3 + }; + } + return null; + } function getBinanceApiErrorCode(payload) { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; if (Number.isSafeInteger(payload.code)) return payload.code === 0 ? null : payload.code; @@ -3834,6 +3887,20 @@ error.localizedText = message; return error; } + function removeContinuousTerminalWording(message) { + const localizedMessage = localizeKnownUiStatus(message); + const zh = formatLocalizedText(localizedMessage, "zh-CN").replace(/[,;]\s*已停止(?:重新挂单)?/g, ""); + const en = formatLocalizedText(localizedMessage, "en").replace(/;\s*(?:replacement\s+)?stopped\b/gi, ""); + return localizedText(zh, en); + } + function createContinuousRoundRecoveryError(kind, message, cooldownMs = null) { + const localizedMessage = removeContinuousTerminalWording(message); + const error = new Error(formatLocalizedText(localizedMessage, "zh-CN")); + error.continuousRecoveryKind = kind; + error.localizedText = localizedMessage; + if (cooldownMs !== null) error.continuousRecoveryCooldownMs = cooldownMs; + return error; + } function getLadderOpenPercent(symbol = getCurrentSymbol(), precision = readCurrentOrderbookPrecisionValue()) { return loadModeSymbolPrecisionNumberOption( localStorage, @@ -5298,12 +5365,35 @@ throw new Error("确认平仓结果时下单模式已变化"); } if (!await waitForBncHeaders(context.symbol)) { - throw new Error("确认平仓结果时币安请求头尚未就绪"); + const error = createContinuousRecoverableLadderError( + "position_state_not_ready", + "确认平仓结果时币安请求头尚未就绪" + ); + error.skipImmediateCloseRecheck = true; + throw error; + } + let state; + try { + state = await fetchCurrentSymbolPositionSideState( + context.symbol, + context.spec.side + ); + } catch (error) { + if (error?.name === "PositionPayloadContractError" || [401, 403].includes(error?.httpStatus) || error?.httpStatus >= 400 && error.httpStatus < 500 && ![418, 429].includes(error.httpStatus)) { + throw error; + } + const rateLimited = [418, 429].includes(error?.httpStatus); + const recoveryError = createContinuousRecoverableLadderError( + rateLimited ? "rate_limited" : "position_state_not_ready", + rateLimited ? "仓位确认请求频率受限" : "仓位确认暂未完成" + ); + if (rateLimited) { + const retryAfterSeconds = Number(error.retryAfter); + recoveryError.continuousRecoveryCooldownMs = Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0 ? retryAfterSeconds * 1e3 : 1e4; + } + recoveryError.skipImmediateCloseRecheck = true; + throw recoveryError; } - const state = await fetchCurrentSymbolPositionSideState( - context.symbol, - context.spec.side - ); throwIfAborted(abortSignal); if (!isCurrentObservedSymbol(context.symbol)) { throw new Error("确认平仓结果时交易对已变化"); @@ -5431,7 +5521,12 @@ } if (spec.mode === "CLOSE") { await throwIfClosePositionCompleted({ spec, symbol: startSymbol }); - unavailableQuantityMessage = "当前方向暂无可平数量"; + const error = createContinuousRecoverableLadderError( + "position_quantity_not_ready", + "当前方向暂无可平数量" + ); + error.skipImmediateCloseRecheck = true; + throw error; } throw new Error(unavailableQuantityMessage); } @@ -5524,6 +5619,10 @@ error.statusTitle, `Current ${mode === "OPEN" ? "open" : "close"} ratio is ${percent}%. The target quantity is below the minimum order quantity ${minRequiredQty}, so the ladder cannot ${mode === "OPEN" ? "open" : "close"}. ${levels ? `Current plan: ${levels} orders. ` : ""}${minimumPercent ? `At least ${minimumPercent}% is required to keep the current order count. ` : ""}${maxAutoFitPercent ? `Automatic limit: ${maxAutoFitPercent}%. ` : ""}The script tried increasing the ratio and reducing the order count. ${mode === "OPEN" ? "Only same-symbol, same-direction Basic open orders may be replaced; Cancel All is never automatic." : "Orders are not cancelled automatically."}` ); + if (mode === "CLOSE") { + error.safeNoSubmit = true; + error.continuousRecoveryKind = "position_quantity_not_ready"; + } if (mode === "OPEN" && spec && symbol && precision && replacementTotalQty && isPositiveDecimalString(replacementTotalQty)) { error.openOrdersReplacementPlan = { spec, @@ -5905,6 +6004,25 @@ if (capturedApiErrors.length === 1 && isBinanceMaxOpenOrdersErrorCode(capturedApiErrors[0].code)) { throw createLadderMaxOpenOrdersError(capturedApiErrors[0]); } + const responseRecovery = resolveBinanceSubmitResponseRecovery( + responseObservation.diagnostics, + capturedApiErrors + ); + if (responseRecovery) { + const responseDiagnostic2 = responseObservation.diagnostics.length === 0 ? "" : responseObservation.diagnostics.map(formatBinancePlaceOrderResponseDiagnostic).join(" | "); + const reason = responseRecovery.kind === "rate_limited" ? localizedText( + `下单请求频率受限${responseDiagnostic2 ? `:${responseDiagnostic2}` : ""}`, + `Order request rate limited${responseDiagnostic2 ? `: ${responseDiagnostic2}` : ""}` + ) : localizedText( + `Binance 服务异常,订单结果未确认${responseDiagnostic2 ? `:${responseDiagnostic2}` : ""}`, + `Binance service error; order outcome unconfirmed${responseDiagnostic2 ? `: ${responseDiagnostic2}` : ""}` + ); + throw createContinuousRoundRecoveryError( + responseRecovery.kind, + reason, + responseRecovery.cooldownMs + ); + } let failureActivity = activity.status === "failure" ? activity : null; if (!failureActivity && capturedApiErrors.length > 0) { failureActivity = await waitForOrderFailureFeedback( @@ -6037,7 +6155,10 @@ options?.allowMaxOpenOrdersRecovery )) { if (maxOpenOrdersRecoveryAttempts > 0) { - throw new Error("已释放挂单名额,但本轮仍达到最大下单限制,已停止"); + throw createContinuousRoundRecoveryError( + "order_capacity_not_ready", + "已释放挂单名额,但本轮仍达到最大下单限制" + ); } maxOpenOrdersRecoveryAttempts += 1; const recovery = await cancelCurrentSymbolOpenOrdersForPlan( @@ -6049,7 +6170,14 @@ ); throwIfAborted(abortSignal); if (!recovery?.ok) { - throw new Error(recovery?.message || "未能释放挂单名额,已停止"); + const message = recovery?.message || "未能释放挂单名额"; + if (["symbol_changed", "dialog_not_closed"].includes(recovery?.status)) { + throw new Error(message); + } + throw createContinuousRoundRecoveryError( + "order_capacity_not_ready", + message + ); } continue; } @@ -6257,7 +6385,7 @@ progress: snapshotLadderProgress(progress) }; } - if (spec.mode === "CLOSE" && e?.safeNoSubmit === true) { + if (spec.mode === "CLOSE" && e?.safeNoSubmit === true && e.skipImmediateCloseRecheck !== true) { try { await throwIfClosePositionCompleted( { spec, symbol: actionSymbol }, @@ -6340,9 +6468,18 @@ const button = spec.buttonGetter(); if (!isCloseSnapshotReady(actionSymbol) || !button || !button.isConnected || !isVisibleElement(button) || isSubmitButtonBusy(button)) { const now = Date.now(); - if (now - positionCheckState.checkedAt >= CONTINUOUS_CLOSE_POSITION_CHECK_MS) { + const retryAt = positionCheckState.retryAt || 0; + if (now >= retryAt && now - positionCheckState.checkedAt >= CONTINUOUS_CLOSE_POSITION_CHECK_MS) { positionCheckState.checkedAt = now; - await throwIfClosePositionCompleted({ spec, symbol: actionSymbol }); + try { + await throwIfClosePositionCompleted({ spec, symbol: actionSymbol }); + positionCheckState.retryAt = 0; + } catch (error) { + if (isClosePositionCompletedError(error)) throw error; + const recovery = resolveContinuousLadderRecovery(error); + if (!recovery) throw error; + positionCheckState.retryAt = Date.now() + recovery.cooldownMs; + } } return { status: "waiting" }; } @@ -6378,7 +6515,7 @@ } const abortController = new AbortController(); const continuousProgress = createContinuousLadderProgress(); - const positionCheckState = { checkedAt: Date.now() }; + const positionCheckState = { checkedAt: Date.now(), retryAt: 0 }; continuousLadderAbortController = abortController; activeContinuousLadderActionType = actionType; activeContinuousLadderProgress = continuousProgress; @@ -6386,28 +6523,36 @@ while (true) { throwIfAborted(abortController.signal); const outcome = await startLadder(actionType, continuousProgress); - if (!outcome?.progress) return outcome; - recordContinuousLadderRound(continuousProgress, outcome); - if (outcome.status === "position_closed") { - setLadderStatus(formatContinuousLadderPositionClosedProgress( - spec.statusLabel, - continuousProgress - )); - return outcome; - } - const recovery = outcome.status === "completed" ? null : resolveContinuousLadderRecovery(outcome.error); - if (outcome.status !== "completed" && !recovery) { - setContinuousLadderProgressStatus( - spec.statusLabel, - outcome.status, - continuousProgress, - outcome.reason || null, - outcome.titleReason || outcome.reason || null - ); - return outcome; - } - if (!recovery) { - setContinuousLadderProgressStatus(spec.statusLabel, "running", continuousProgress); + let recovery = null; + if (!outcome?.progress) { + if (outcome.status !== "not_started") return outcome; + recovery = { + cooldownMs: 3e3, + reason: localizedText("等待交易页面就绪", "Waiting for the trading page") + }; + } else { + recordContinuousLadderRound(continuousProgress, outcome); + if (outcome.status === "position_closed") { + setLadderStatus(formatContinuousLadderPositionClosedProgress( + spec.statusLabel, + continuousProgress + )); + return outcome; + } + recovery = outcome.status === "completed" ? null : resolveContinuousLadderRecovery(outcome.error); + if (outcome.status !== "completed" && !recovery) { + setContinuousLadderProgressStatus( + spec.statusLabel, + outcome.status, + continuousProgress, + outcome.reason || null, + outcome.titleReason || outcome.reason || null + ); + return outcome; + } + if (!recovery) { + setContinuousLadderProgressStatus(spec.statusLabel, "running", continuousProgress); + } } const readiness = await waitForContinuousLadderNextRound({ readReadiness: async () => { @@ -6453,7 +6598,7 @@ ); return readiness; } - if (recovery) continue; + continue; } })(); continuousLadderTask = executionTask.catch((e) => { @@ -8254,7 +8399,14 @@ err("替换挂单失败后确认当前方向持仓失败:", positionError); } } - throw new Error(result.message || "原挂单未完成替换,已停止重新挂单"); + const message = result.message || "原挂单未完成替换"; + if (["symbol_changed", "dialog_not_closed"].includes(result.status)) { + throw new Error(message); + } + throw createContinuousRoundRecoveryError( + "open_orders_not_ready", + message + ); } } } @@ -8529,7 +8681,12 @@ credentials: "include", signal: controller.signal }); - if (!resp.ok) throw new Error(`持仓接口异常:HTTP ${resp.status}`); + if (!resp.ok) { + const error = new Error(`持仓接口异常:HTTP ${resp.status}`); + error.httpStatus = resp.status; + error.retryAfter = resp.headers.get("retry-after"); + throw error; + } return await resp.json(); } finally { window.clearTimeout(timer); diff --git a/src/binance-orderbook-trade/core/auto-open-leverage.js b/src/binance-orderbook-trade/core/auto-open-leverage.js index 6378191..6c7dc0e 100644 --- a/src/binance-orderbook-trade/core/auto-open-leverage.js +++ b/src/binance-orderbook-trade/core/auto-open-leverage.js @@ -1,17 +1,23 @@ const POSITION_STATUSES = 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 new Error(`持仓数量无效:${String(value)}`); + throw createPositionPayloadContractError(`持仓数量无效:${String(value)}`); } export function resolveSymbolPositionStatus(payload, symbol) { - if (payload?.success !== true) throw new Error('持仓接口返回失败'); - if (!Array.isArray(payload.data)) throw new Error('持仓接口数据格式异常'); - if (!symbol) throw new Error('持仓接口缺少交易对'); + 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); @@ -22,11 +28,11 @@ 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 (payload?.success !== true) throw createPositionPayloadContractError('持仓接口返回失败'); + if (!Array.isArray(payload.data)) throw createPositionPayloadContractError('持仓接口数据格式异常'); + if (!symbol) throw createPositionPayloadContractError('持仓接口缺少交易对'); if (side !== 'LONG' && side !== 'SHORT') { - throw new Error(`目标持仓方向无效:${String(side)}`); + throw createPositionPayloadContractError(`目标持仓方向无效:${String(side)}`); } const positions = payload.data.filter((position) => position?.symbol === symbol); @@ -35,7 +41,7 @@ export function resolveSymbolPositionSideStatus(payload, symbol, side) { for (const position of positions) { const positionSide = position.positionSide; if (!['BOTH', 'LONG', 'SHORT'].includes(positionSide)) { - throw new Error(`持仓方向无效:${String(positionSide)}`); + throw createPositionPayloadContractError(`持仓方向无效:${String(positionSide)}`); } const amount = parsePositionAmount(position.positionAmount); if (positionSide === side) { diff --git a/src/binance-orderbook-trade/core/continuous-ladder.js b/src/binance-orderbook-trade/core/continuous-ladder.js index 77c32b8..cf2f024 100644 --- a/src/binance-orderbook-trade/core/continuous-ladder.js +++ b/src/binance-orderbook-trade/core/continuous-ladder.js @@ -15,12 +15,25 @@ import { export const CONTINUOUS_LADDER_COOLDOWN_MS = 1000; export const CONTINUOUS_LADDER_READY_CHECK_MS = 50; export const CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS = 3000; +export const CONTINUOUS_LADDER_LONG_RECOVERY_COOLDOWN_MS = 10000; const CONTINUOUS_LADDER_RECOVERY = Object.freeze({ submit_unconfirmed: { cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, requiresSafeNoSubmit: false, }, + rate_limited: { + cooldownMs: CONTINUOUS_LADDER_LONG_RECOVERY_COOLDOWN_MS, + requiresSafeNoSubmit: false, + }, + order_capacity_not_ready: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + requiresSafeNoSubmit: false, + }, + open_orders_not_ready: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + requiresSafeNoSubmit: false, + }, input_unstable: { cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, }, @@ -30,6 +43,12 @@ const CONTINUOUS_LADDER_RECOVERY = Object.freeze({ market_data_not_ready: { cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, }, + position_state_not_ready: { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + }, + position_quantity_not_ready: { + cooldownMs: CONTINUOUS_LADDER_LONG_RECOVERY_COOLDOWN_MS, + }, precision_changed: { cooldownMs: CONTINUOUS_LADDER_COOLDOWN_MS, reason: localizedText( @@ -97,8 +116,12 @@ export function resolveContinuousLadderRecovery(error) { const recovery = CONTINUOUS_LADDER_RECOVERY[error?.continuousRecoveryKind]; if (!recovery) return null; if (recovery.requiresSafeNoSubmit !== false && error.safeNoSubmit !== true) return null; + const cooldownMs = error.continuousRecoveryCooldownMs ?? recovery.cooldownMs; + if (!Number.isFinite(cooldownMs) || cooldownMs < 0) { + throw new Error('连续阶梯恢复等待时间无效'); + } return { - cooldownMs: recovery.cooldownMs, + cooldownMs, reason: recovery.reason || error.localizedText || error.message, }; } diff --git a/src/binance-orderbook-trade/core/order-feedback.js b/src/binance-orderbook-trade/core/order-feedback.js index e7919c2..1451387 100644 --- a/src/binance-orderbook-trade/core/order-feedback.js +++ b/src/binance-orderbook-trade/core/order-feedback.js @@ -95,6 +95,40 @@ export function isBinanceMaxOpenOrdersErrorCode(code) { return code === BINANCE_MAX_OPEN_ORDERS_ERROR_CODE; } +function parseRetryAfterMs(value) { + if (value == null || value === '') return null; + const seconds = Number(value); + return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null; +} + +/** + * HTTP 418/429 requires backoff. HTTP 5xx leaves an order submission outcome + * uncertain, so continuous mode may advance only under its explicit duplicate- + * tolerant policy. Other 4xx and business errors remain terminal. + */ +export function resolveBinanceSubmitResponseRecovery(diagnostics, apiErrors) { + if (!Array.isArray(diagnostics) || !Array.isArray(apiErrors)) { + throw new Error('下单响应恢复证据无效'); + } + const rateLimitDiagnostic = diagnostics.find(({ httpStatus }) => ( + httpStatus === 418 || httpStatus === 429 + )); + const hasRateLimitCode = apiErrors.some(({ code }) => code === -1003); + if (rateLimitDiagnostic || hasRateLimitCode) { + return { + kind: 'rate_limited', + cooldownMs: parseRetryAfterMs(rateLimitDiagnostic?.retryAfter) ?? 10000, + }; + } + if (diagnostics.some(({ httpStatus }) => httpStatus >= 500 && httpStatus <= 599)) { + return { + kind: 'submit_unconfirmed', + cooldownMs: 3000, + }; + } + return null; +} + export function getBinanceApiErrorCode(payload) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; if (Number.isSafeInteger(payload.code)) return payload.code === 0 ? null : payload.code; diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 9b9bf7c..e2e00b7 100644 --- a/src/binance-orderbook-trade/index.user.js +++ b/src/binance-orderbook-trade/index.user.js @@ -129,6 +129,7 @@ import { isPostOnlyMakerRejectionFeedback, isReduceOnlyOpenOrdersConflictFeedback, isPotentialOrderFeedbackText, + resolveBinanceSubmitResponseRecovery, summarizeBinancePlaceOrderPayload, } from './core/order-feedback.js'; import { @@ -641,6 +642,24 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; return error; } + function removeContinuousTerminalWording(message) { + const localizedMessage = localizeKnownUiStatus(message); + const zh = formatLocalizedText(localizedMessage, 'zh-CN') + .replace(/[,;]\s*已停止(?:重新挂单)?/g, ''); + const en = formatLocalizedText(localizedMessage, 'en') + .replace(/;\s*(?:replacement\s+)?stopped\b/gi, ''); + return localizedText(zh, en); + } + + function createContinuousRoundRecoveryError(kind, message, cooldownMs = null) { + const localizedMessage = removeContinuousTerminalWording(message); + const error = new Error(formatLocalizedText(localizedMessage, 'zh-CN')); + error.continuousRecoveryKind = kind; + error.localizedText = localizedMessage; + if (cooldownMs !== null) error.continuousRecoveryCooldownMs = cooldownMs; + return error; + } + function getLadderOpenPercent( symbol = getCurrentSymbol(), precision = readCurrentOrderbookPrecisionValue(), @@ -2423,12 +2442,46 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; throw new Error('确认平仓结果时下单模式已变化'); } if (!await waitForBncHeaders(context.symbol)) { - throw new Error('确认平仓结果时币安请求头尚未就绪'); + const error = createContinuousRecoverableLadderError( + 'position_state_not_ready', + '确认平仓结果时币安请求头尚未就绪', + ); + error.skipImmediateCloseRecheck = true; + throw error; + } + let state; + try { + state = await fetchCurrentSymbolPositionSideState( + context.symbol, + context.spec.side, + ); + } catch (error) { + if ( + error?.name === 'PositionPayloadContractError' + || [401, 403].includes(error?.httpStatus) + || ( + error?.httpStatus >= 400 + && error.httpStatus < 500 + && ![418, 429].includes(error.httpStatus) + ) + ) { + throw error; + } + const rateLimited = [418, 429].includes(error?.httpStatus); + const recoveryError = createContinuousRecoverableLadderError( + rateLimited ? 'rate_limited' : 'position_state_not_ready', + rateLimited ? '仓位确认请求频率受限' : '仓位确认暂未完成', + ); + if (rateLimited) { + const retryAfterSeconds = Number(error.retryAfter); + recoveryError.continuousRecoveryCooldownMs = Number.isFinite(retryAfterSeconds) + && retryAfterSeconds >= 0 + ? retryAfterSeconds * 1000 + : 10000; + } + recoveryError.skipImmediateCloseRecheck = true; + throw recoveryError; } - const state = await fetchCurrentSymbolPositionSideState( - context.symbol, - context.spec.side, - ); throwIfAborted(abortSignal); if (!isCurrentObservedSymbol(context.symbol)) { throw new Error('确认平仓结果时交易对已变化'); @@ -2570,7 +2623,12 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; } if (spec.mode === 'CLOSE') { await throwIfClosePositionCompleted({ spec, symbol: startSymbol }); - unavailableQuantityMessage = '当前方向暂无可平数量'; + const error = createContinuousRecoverableLadderError( + 'position_quantity_not_ready', + '当前方向暂无可平数量', + ); + error.skipImmediateCloseRecheck = true; + throw error; } throw new Error(unavailableQuantityMessage); } @@ -2670,6 +2728,10 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; error.statusTitle, `Current ${mode === 'OPEN' ? 'open' : 'close'} ratio is ${percent}%. The target quantity is below the minimum order quantity ${minRequiredQty}, so the ladder cannot ${mode === 'OPEN' ? 'open' : 'close'}. ${levels ? `Current plan: ${levels} orders. ` : ''}${minimumPercent ? `At least ${minimumPercent}% is required to keep the current order count. ` : ''}${maxAutoFitPercent ? `Automatic limit: ${maxAutoFitPercent}%. ` : ''}The script tried increasing the ratio and reducing the order count. ${mode === 'OPEN' ? 'Only same-symbol, same-direction Basic open orders may be replaced; Cancel All is never automatic.' : 'Orders are not cancelled automatically.'}`, ); + if (mode === 'CLOSE') { + error.safeNoSubmit = true; + error.continuousRecoveryKind = 'position_quantity_not_ready'; + } if ( mode === 'OPEN' && spec && @@ -3140,6 +3202,31 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; ) { throw createLadderMaxOpenOrdersError(capturedApiErrors[0]); } + const responseRecovery = resolveBinanceSubmitResponseRecovery( + responseObservation.diagnostics, + capturedApiErrors, + ); + if (responseRecovery) { + const responseDiagnostic = responseObservation.diagnostics.length === 0 + ? '' + : responseObservation.diagnostics + .map(formatBinancePlaceOrderResponseDiagnostic) + .join(' | '); + const reason = responseRecovery.kind === 'rate_limited' + ? localizedText( + `下单请求频率受限${responseDiagnostic ? `:${responseDiagnostic}` : ''}`, + `Order request rate limited${responseDiagnostic ? `: ${responseDiagnostic}` : ''}`, + ) + : localizedText( + `Binance 服务异常,订单结果未确认${responseDiagnostic ? `:${responseDiagnostic}` : ''}`, + `Binance service error; order outcome unconfirmed${responseDiagnostic ? `: ${responseDiagnostic}` : ''}`, + ); + throw createContinuousRoundRecoveryError( + responseRecovery.kind, + reason, + responseRecovery.cooldownMs, + ); + } let failureActivity = activity.status === 'failure' ? activity : null; if (!failureActivity && capturedApiErrors.length > 0) { failureActivity = await waitForOrderFailureFeedback( @@ -3303,7 +3390,10 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; options?.allowMaxOpenOrdersRecovery, )) { if (maxOpenOrdersRecoveryAttempts > 0) { - throw new Error('已释放挂单名额,但本轮仍达到最大下单限制,已停止'); + throw createContinuousRoundRecoveryError( + 'order_capacity_not_ready', + '已释放挂单名额,但本轮仍达到最大下单限制', + ); } maxOpenOrdersRecoveryAttempts += 1; const recovery = await cancelCurrentSymbolOpenOrdersForPlan( @@ -3315,7 +3405,14 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; ); throwIfAborted(abortSignal); if (!recovery?.ok) { - throw new Error(recovery?.message || '未能释放挂单名额,已停止'); + const message = recovery?.message || '未能释放挂单名额'; + if (['symbol_changed', 'dialog_not_closed'].includes(recovery?.status)) { + throw new Error(message); + } + throw createContinuousRoundRecoveryError( + 'order_capacity_not_ready', + message, + ); } continue; } @@ -3532,7 +3629,11 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; progress: snapshotLadderProgress(progress), }; } - if (spec.mode === 'CLOSE' && e?.safeNoSubmit === true) { + if ( + spec.mode === 'CLOSE' + && e?.safeNoSubmit === true + && e.skipImmediateCloseRecheck !== true + ) { try { await throwIfClosePositionCompleted( { spec, symbol: actionSymbol }, @@ -3635,9 +3736,21 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; || isSubmitButtonBusy(button) ) { const now = Date.now(); - if (now - positionCheckState.checkedAt >= CONTINUOUS_CLOSE_POSITION_CHECK_MS) { + const retryAt = positionCheckState.retryAt || 0; + if ( + now >= retryAt + && now - positionCheckState.checkedAt >= CONTINUOUS_CLOSE_POSITION_CHECK_MS + ) { positionCheckState.checkedAt = now; - await throwIfClosePositionCompleted({ spec, symbol: actionSymbol }); + try { + await throwIfClosePositionCompleted({ spec, symbol: actionSymbol }); + positionCheckState.retryAt = 0; + } catch (error) { + if (isClosePositionCompletedError(error)) throw error; + const recovery = resolveContinuousLadderRecovery(error); + if (!recovery) throw error; + positionCheckState.retryAt = Date.now() + recovery.cooldownMs; + } } return { status: 'waiting' }; } @@ -3684,7 +3797,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; const abortController = new AbortController(); const continuousProgress = createContinuousLadderProgress(); - const positionCheckState = { checkedAt: Date.now() }; + const positionCheckState = { checkedAt: Date.now(), retryAt: 0 }; continuousLadderAbortController = abortController; activeContinuousLadderActionType = actionType; activeContinuousLadderProgress = continuousProgress; @@ -3693,30 +3806,38 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; while (true) { throwIfAborted(abortController.signal); const outcome = await startLadder(actionType, continuousProgress); - if (!outcome?.progress) return outcome; - recordContinuousLadderRound(continuousProgress, outcome); - if (outcome.status === 'position_closed') { - setLadderStatus(formatContinuousLadderPositionClosedProgress( - spec.statusLabel, - continuousProgress, - )); - return outcome; - } - const recovery = outcome.status === 'completed' - ? null - : resolveContinuousLadderRecovery(outcome.error); - if (outcome.status !== 'completed' && !recovery) { - setContinuousLadderProgressStatus( - spec.statusLabel, - outcome.status, - continuousProgress, - outcome.reason || null, - outcome.titleReason || outcome.reason || null, - ); - return outcome; - } - if (!recovery) { - setContinuousLadderProgressStatus(spec.statusLabel, 'running', continuousProgress); + let recovery = null; + if (!outcome?.progress) { + if (outcome.status !== 'not_started') return outcome; + recovery = { + cooldownMs: 3000, + reason: localizedText('等待交易页面就绪', 'Waiting for the trading page'), + }; + } else { + recordContinuousLadderRound(continuousProgress, outcome); + if (outcome.status === 'position_closed') { + setLadderStatus(formatContinuousLadderPositionClosedProgress( + spec.statusLabel, + continuousProgress, + )); + return outcome; + } + recovery = outcome.status === 'completed' + ? null + : resolveContinuousLadderRecovery(outcome.error); + if (outcome.status !== 'completed' && !recovery) { + setContinuousLadderProgressStatus( + spec.statusLabel, + outcome.status, + continuousProgress, + outcome.reason || null, + outcome.titleReason || outcome.reason || null, + ); + return outcome; + } + if (!recovery) { + setContinuousLadderProgressStatus(spec.statusLabel, 'running', continuousProgress); + } } const readiness = await waitForContinuousLadderNextRound({ @@ -3765,7 +3886,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; ); return readiness; } - if (recovery) continue; + continue; } })(); @@ -5886,7 +6007,14 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; err('替换挂单失败后确认当前方向持仓失败:', positionError); } } - throw new Error(result.message || '原挂单未完成替换,已停止重新挂单'); + const message = result.message || '原挂单未完成替换'; + if (['symbol_changed', 'dialog_not_closed'].includes(result.status)) { + throw new Error(message); + } + throw createContinuousRoundRecoveryError( + 'open_orders_not_ready', + message, + ); } } } @@ -6212,7 +6340,12 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; credentials: 'include', signal: controller.signal, }); - if (!resp.ok) throw new Error(`持仓接口异常:HTTP ${resp.status}`); + if (!resp.ok) { + const error = new Error(`持仓接口异常:HTTP ${resp.status}`); + error.httpStatus = resp.status; + error.retryAfter = resp.headers.get('retry-after'); + throw error; + } return await resp.json(); } finally { window.clearTimeout(timer); 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 ae51f79..ab6bbe7 100644 --- a/test/unit/binance-orderbook-trade/auto-open-leverage.test.js +++ b/test/unit/binance-orderbook-trade/auto-open-leverage.test.js @@ -96,14 +96,20 @@ test('close completion rejects unknown position directions instead of guessing', test('rejects unsuccessful or malformed current-symbol position responses', () => { assert.throws( () => resolveSymbolPositionStatus({ success: false, data: [] }, 'HYPEUSDT'), - /持仓接口返回失败/, + (error) => ( + error.name === 'PositionPayloadContractError' + && /持仓接口返回失败/.test(error.message) + ), ); assert.throws( () => resolveSymbolPositionStatus({ success: true, data: [{ symbol: 'HYPEUSDT', positionAmount: 'unknown' }], }, 'HYPEUSDT'), - /持仓数量无效/, + (error) => ( + error.name === 'PositionPayloadContractError' + && /持仓数量无效/.test(error.message) + ), ); }); diff --git a/test/unit/binance-orderbook-trade/continuous-ladder.test.js b/test/unit/binance-orderbook-trade/continuous-ladder.test.js index 576e11f..8aa6513 100644 --- a/test/unit/binance-orderbook-trade/continuous-ladder.test.js +++ b/test/unit/binance-orderbook-trade/continuous-ladder.test.js @@ -9,6 +9,7 @@ import { import { CONTINUOUS_LADDER_COOLDOWN_MS, + CONTINUOUS_LADDER_LONG_RECOVERY_COOLDOWN_MS, CONTINUOUS_LADDER_READY_CHECK_MS, CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, createContinuousLadderProgress, @@ -200,6 +201,29 @@ test('continuous ladder retries only explicitly designed recoverable failures', reason: '仍未确认订单结果', }); + const openOrdersNotReady = new Error('当前委托列表暂未就绪'); + openOrdersNotReady.continuousRecoveryKind = 'open_orders_not_ready'; + assert.deepEqual(resolveContinuousLadderRecovery(openOrdersNotReady), { + cooldownMs: CONTINUOUS_LADDER_RECOVERY_COOLDOWN_MS, + reason: '当前委托列表暂未就绪', + }); + + const positionQuantityNotReady = new Error('当前方向暂无可平数量'); + positionQuantityNotReady.safeNoSubmit = true; + positionQuantityNotReady.continuousRecoveryKind = 'position_quantity_not_ready'; + assert.deepEqual(resolveContinuousLadderRecovery(positionQuantityNotReady), { + cooldownMs: CONTINUOUS_LADDER_LONG_RECOVERY_COOLDOWN_MS, + reason: '当前方向暂无可平数量', + }); + + const rateLimited = new Error('请求频率受限'); + rateLimited.continuousRecoveryKind = 'rate_limited'; + rateLimited.continuousRecoveryCooldownMs = 17000; + assert.deepEqual(resolveContinuousLadderRecovery(rateLimited), { + cooldownMs: 17000, + reason: '请求频率受限', + }); + const unsupported = new Error('未知错误'); unsupported.safeNoSubmit = true; unsupported.continuousRecoveryKind = 'unknown'; diff --git a/test/unit/binance-orderbook-trade/order-feedback.test.js b/test/unit/binance-orderbook-trade/order-feedback.test.js index 98c738a..a927957 100644 --- a/test/unit/binance-orderbook-trade/order-feedback.test.js +++ b/test/unit/binance-orderbook-trade/order-feedback.test.js @@ -12,9 +12,37 @@ import { isOpenLadderOpenOrdersCapacityFeedback, isPostOnlyMakerRejectionFeedback, isReduceOnlyOpenOrdersConflictFeedback, + resolveBinanceSubmitResponseRecovery, summarizeBinancePlaceOrderPayload, } from '../../../src/binance-orderbook-trade/core/order-feedback.js'; +test('classifies only rate limits and uncertain server responses as recoverable', () => { + assert.deepEqual(resolveBinanceSubmitResponseRecovery([ + { httpStatus: 429, retryAfter: '7' }, + ], []), { + kind: 'rate_limited', + cooldownMs: 7000, + }); + assert.deepEqual(resolveBinanceSubmitResponseRecovery([ + { httpStatus: 200, retryAfter: null }, + ], [{ code: -1003 }]), { + kind: 'rate_limited', + cooldownMs: 10000, + }); + assert.deepEqual(resolveBinanceSubmitResponseRecovery([ + { httpStatus: 503, retryAfter: null }, + ], []), { + kind: 'submit_unconfirmed', + cooldownMs: 3000, + }); + assert.equal(resolveBinanceSubmitResponseRecovery([ + { httpStatus: 400, retryAfter: null }, + ], []), null); + assert.equal(resolveBinanceSubmitResponseRecovery([ + { httpStatus: 403, retryAfter: null }, + ], []), null); +}); + test('classifies localized and English order feedback', () => { assert.equal(classifyOrderFeedback('委托已提交'), 'success'); assert.equal(classifyOrderFeedback('Order placed successfully'), 'success'); diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index d7487c3..85cfc79 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -1789,6 +1789,38 @@ test('continuous close ladders continue after an explicitly tagged unconfirmed s assert.doesNotMatch(recoveryBody, /error\.safeNoSubmit = true/); }); +test('continuous close defers temporary startup, position, capacity, and open-order failures', () => { + const continuousBody = readFunctionBody('startContinuousLadder'); + const planBody = readFunctionBody('buildLadderPlan'); + const minimumBody = readFunctionBody('createLadderMinimumQtyFailure'); + const executeBody = readFunctionBody('executeLadderPlan'); + const replacementBody = readFunctionBody('runLadderPlanWithOpenOrderReplacement'); + const readinessBody = readFunctionBody('readContinuousLadderReadiness'); + const confirmationBody = readFunctionBody('throwIfClosePositionCompleted'); + const roundRecoveryBody = readFunctionBody('createContinuousRoundRecoveryError'); + + assert.match(continuousBody, /outcome\.status !== 'not_started'/); + assert.match(continuousBody, /waitForContinuousLadderNextRound/); + assert.match(planBody, /createContinuousRecoverableLadderError\(\s*'position_quantity_not_ready'/); + assert.match(minimumBody, /mode === 'CLOSE'[\s\S]*continuousRecoveryKind = 'position_quantity_not_ready'/); + assert.match(executeBody, /createContinuousRoundRecoveryError\(\s*'order_capacity_not_ready'/); + assert.match(replacementBody, /createContinuousRoundRecoveryError\(\s*'open_orders_not_ready'/); + assert.match(readinessBody, /resolveContinuousLadderRecovery\(error\)/); + assert.match(confirmationBody, /createContinuousRecoverableLadderError\(\s*'position_state_not_ready'/); + assert.match(confirmationBody, /skipImmediateCloseRecheck = true/); + assert.match(roundRecoveryBody, /removeContinuousTerminalWording\(message\)/); +}); + +test('continuous close backs off rate limits and unconfirmed server responses without swallowing fatal rejections', () => { + const acknowledgementBody = readFunctionBody('waitForOrderSubmitAcknowledgement'); + const positionFetchBody = readFunctionBody('fetchCurrentPositionsPayload'); + + assert.match(acknowledgementBody, /resolveBinanceSubmitResponseRecovery/); + assert.match(acknowledgementBody, /createContinuousRoundRecoveryError\(/); + assert.match(positionFetchBody, /error\.httpStatus = resp\.status/); + assert.match(source, /\[401,\s*403\]\.includes\(error\?\.httpStatus\)/); +}); + test('confirmed directional flat state ends close ladders without masking uncertain outcomes', () => { const startBody = readFunctionBody('startLadder'); const planBody = readFunctionBody('buildLadderPlan'); @@ -1800,7 +1832,7 @@ test('confirmed directional flat state ends close ladders without masking uncert 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, /spec\.mode === 'CLOSE'[\s\S]*e\?\.safeNoSubmit === true[\s\S]*e\.skipImmediateCloseRecheck !== true/); assert.match(startBody, /status: 'position_closed'/); assert.doesNotMatch(startBody, /未确认.*throwIfClosePositionCompleted/); assert.match(replacementBody, /!\['symbol_changed', 'dialog_not_closed'\]\.includes\(result\.status\)/);