From f186395a80bf51de94a69554f7f8a76e2f74e2f9 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:07:01 +0800 Subject: [PATCH] fix: summarize continuous ladder progress --- docs/binance-orderbook-trade-development.md | 3 + scripts/binance-orderbook-trade.user.js | 271 +++++++++++++----- .../core/continuous-ladder.js | 91 ++++++ .../core/ladder-progress.js | 10 + src/binance-orderbook-trade/index.user.js | 111 +++++-- .../continuous-ladder.test.js | 111 +++++++ .../ladder-progress.test.js | 17 ++ .../source-regressions.test.js | 8 +- 8 files changed, 533 insertions(+), 89 deletions(-) diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md index b370766..b300218 100644 --- a/docs/binance-orderbook-trade-development.md +++ b/docs/binance-orderbook-trade-development.md @@ -157,6 +157,8 @@ Ladder replacement must stay scoped and direction-aware. Automatic replacement m Continuous ladder trading is available only for close actions through `Option/Alt + click`; an ordinary click remains one round. A round is the complete existing ladder-close workflow, including any scoped same-direction replacement and cleanup. After a completed round, the runner must observe the same symbol, close mode, current precision, and native close button as ready before starting a full one-second cooldown. It must validate readiness again after the cooldown; losing readiness restarts the wait and a new full cooldown. Every new round must rebuild its plan from the latest panel profile and live trading context. A failed, stopped, or interrupted round ends the continuous session. +Continuous-session feedback stays in the shared ladder status row and uses compact Chinese counters. `2/3 轮` means two rounds completed out of three started, `本轮 1/3 笔` reports the latest partial plan, and `累计 7 笔` reports all confirmed submissions across the session. Confirmed cancellations are appended only when greater than zero. Round outcomes must expose a detached progress snapshot so a terminal continuous summary cannot be overwritten by the latest single-round message. + When selecting account-order tabs, scope to the bottom account-orders tab group. Do not globally match `当前委托` or `Open Orders`. When a pane is found through `aria-controls`, confirm it contains current-orders controls such as `隐藏其他合约` or `全撤`. Binance may reuse pane ids in unrelated tab systems. @@ -196,6 +198,7 @@ Run manual checks when behavior touches trading flow, DOM selectors, account ord - start ladder order, confirm start buttons are disabled while running - Option/Alt-click a close ladder button, confirm the next round starts only after the prior round is complete, the native close action is ready, and one full second has elapsed; change ratio, levels, row span, and precision during the wait and confirm the next plan uses the updated profile - during a continuous close cooldown, make the native close action temporarily unavailable and confirm the cooldown restarts only after readiness returns; confirm Stop also aborts the cooldown immediately +- complete multiple continuous close rounds, then stop both during a round and during cooldown; confirm the Chinese status shows completed/started rounds, latest-round order ratio, cumulative confirmed submissions, and no zero-cancellation segment - cancel current-symbol orders, verify only Binance native confirmation opens - keep the native cancel-all dialog open for longer than the former decision deadline, verify the script remains in the dialog-tracking state, then cancel and confirm the original order count and temporary page state are restored - cancel the native cancel-all dialog through its secondary button, Escape, and backdrop; verify chart and account-order UI state restores immediately without waiting for order clearing diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index cbf60f7..9b40322 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.156 +// @version 2.7.157 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -865,52 +865,6 @@ }); } - // src/binance-orderbook-trade/core/continuous-ladder.js - var CONTINUOUS_LADDER_COOLDOWN_MS = 1e3; - var CONTINUOUS_LADDER_READY_CHECK_MS = 50; - function assertReadinessState(state) { - if (!["ready", "waiting", "stopped"].includes(state?.status)) { - throw new Error("Invalid continuous ladder readiness state"); - } - return state; - } - async function waitUntilReadyOrStopped({ - readReadiness, - delay, - signal, - readyCheckMs - }) { - while (true) { - throwIfAborted(signal); - const state = assertReadinessState(readReadiness()); - if (state.status !== "waiting") return state; - await waitForPromiseOrAbort(delay(readyCheckMs), signal); - } - } - async function waitForContinuousLadderNextRound({ - readReadiness, - delay, - signal = null, - cooldownMs = CONTINUOUS_LADDER_COOLDOWN_MS, - readyCheckMs = CONTINUOUS_LADDER_READY_CHECK_MS - }) { - if (!(cooldownMs >= 0)) throw new Error("Invalid continuous ladder cooldown"); - if (!(readyCheckMs > 0)) throw new Error("Invalid continuous ladder readiness interval"); - while (true) { - const readyState = await waitUntilReadyOrStopped({ - readReadiness, - delay, - signal, - readyCheckMs - }); - if (readyState.status === "stopped") return readyState; - await waitForPromiseOrAbort(delay(cooldownMs), signal); - throwIfAborted(signal); - const afterCooldown = assertReadinessState(readReadiness()); - if (afterCooldown.status !== "waiting") return afterCooldown; - } - } - // src/binance-orderbook-trade/core/ladder-progress.js function assertLadderProgress(progress) { if (!progress || !Number.isInteger(progress.submittedOrders) || progress.submittedOrders < 0 || !Number.isInteger(progress.cancelledOrders) || progress.cancelledOrders < 0 || !Number.isInteger(progress.currentPlanSubmittedOrders) || progress.currentPlanSubmittedOrders < 0 || (progress.plannedOrders === null ? progress.currentPlanSubmittedOrders !== 0 : !(Number.isInteger(progress.plannedOrders) && progress.plannedOrders > 0 && progress.currentPlanSubmittedOrders <= progress.plannedOrders))) { @@ -950,6 +904,15 @@ currentPlanSubmittedOrders: 0 }; } + function snapshotLadderProgress(progress) { + assertLadderProgress(progress); + return { + submittedOrders: progress.submittedOrders, + cancelledOrders: progress.cancelledOrders, + plannedOrders: progress.plannedOrders, + currentPlanSubmittedOrders: progress.currentPlanSubmittedOrders + }; + } function setLadderPlannedOrders(progress, plannedOrders) { assertLadderProgress(progress); if (!Number.isInteger(plannedOrders) || plannedOrders <= 0) { @@ -1002,6 +965,122 @@ return `${label}已完成 · 已挂 ${completedOrders}/${totalOrders} 笔${cancelledText}`; } + // src/binance-orderbook-trade/core/continuous-ladder.js + var CONTINUOUS_LADDER_COOLDOWN_MS = 1e3; + var CONTINUOUS_LADDER_READY_CHECK_MS = 50; + var recordedRoundOutcomes = /* @__PURE__ */ new WeakSet(); + var CONTINUOUS_LADDER_PHASE_TEXT = Object.freeze({ + running: "连续中", + stopping: "连续停止中", + stopped: "连续已停止", + failed: "连续失败", + interrupted: "连续已中止" + }); + 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)) { + throw new Error("Invalid continuous ladder progress"); + } + if (progress.lastRound !== null) snapshotLadderProgress(progress.lastRound); + } + function createContinuousLadderProgress() { + return { + startedRounds: 0, + completedRounds: 0, + submittedOrders: 0, + cancelledOrders: 0, + lastRound: null + }; + } + function recordContinuousLadderRound(progress, outcome) { + assertContinuousLadderProgress(progress); + if (!outcome || typeof outcome !== "object") { + throw new Error("Invalid continuous ladder round outcome"); + } + if (recordedRoundOutcomes.has(outcome)) { + throw new Error("Continuous ladder round was already recorded"); + } + if (!["completed", "stopped", "failed", "interrupted"].includes(outcome.status)) { + throw new Error("Invalid continuous ladder round outcome"); + } + const roundProgress = snapshotLadderProgress(outcome.progress); + progress.startedRounds += 1; + if (outcome.status === "completed") progress.completedRounds += 1; + progress.submittedOrders += roundProgress.submittedOrders; + progress.cancelledOrders += roundProgress.cancelledOrders; + progress.lastRound = { + status: outcome.status, + ...roundProgress + }; + recordedRoundOutcomes.add(outcome); + assertContinuousLadderProgress(progress); + } + function formatContinuousLadderProgress(label, phase, progress, reason = null) { + if (typeof label !== "string" || label.trim() === "") { + throw new Error("Invalid continuous ladder label"); + } + const phaseText = CONTINUOUS_LADDER_PHASE_TEXT[phase]; + if (!phaseText) throw new Error("Invalid continuous ladder phase"); + if (reason !== null && (typeof reason !== "string" || reason.trim() === "")) { + throw new Error("Invalid continuous ladder reason"); + } + assertContinuousLadderProgress(progress); + const parts = [`${label}${phaseText}`]; + parts.push(progress.startedRounds === 0 ? "0 轮" : `${progress.completedRounds}/${progress.startedRounds} 轮`); + if (progress.lastRound?.plannedOrders !== null) { + parts.push( + `本轮 ${progress.lastRound.currentPlanSubmittedOrders}/${progress.lastRound.plannedOrders} 笔` + ); + } else if (progress.lastRound?.submittedOrders > 0) { + parts.push(`本轮 ${progress.lastRound.submittedOrders} 笔`); + } + parts.push(`累计 ${progress.submittedOrders} 笔`); + if (progress.cancelledOrders > 0) parts.push(`撤 ${progress.cancelledOrders} 笔`); + if (reason !== null) parts.push(reason); + return parts.join(" · "); + } + function assertReadinessState(state) { + if (!["ready", "waiting", "stopped"].includes(state?.status)) { + throw new Error("Invalid continuous ladder readiness state"); + } + return state; + } + async function waitUntilReadyOrStopped({ + readReadiness, + delay, + signal, + readyCheckMs + }) { + while (true) { + throwIfAborted(signal); + const state = assertReadinessState(readReadiness()); + if (state.status !== "waiting") return state; + await waitForPromiseOrAbort(delay(readyCheckMs), signal); + } + } + async function waitForContinuousLadderNextRound({ + readReadiness, + delay, + signal = null, + cooldownMs = CONTINUOUS_LADDER_COOLDOWN_MS, + readyCheckMs = CONTINUOUS_LADDER_READY_CHECK_MS + }) { + if (!(cooldownMs >= 0)) throw new Error("Invalid continuous ladder cooldown"); + if (!(readyCheckMs > 0)) throw new Error("Invalid continuous ladder readiness interval"); + while (true) { + const readyState = await waitUntilReadyOrStopped({ + readReadiness, + delay, + signal, + readyCheckMs + }); + if (readyState.status === "stopped") return readyState; + await waitForPromiseOrAbort(delay(cooldownMs), signal); + throwIfAborted(signal); + const afterCooldown = assertReadinessState(readReadiness()); + if (afterCooldown.status !== "waiting") return afterCooldown; + } + } + // src/binance-orderbook-trade/core/status-symbol.js var STATUS_QUOTE_ASSETS = Object.freeze(["USDT", "USDC"]); function formatStatusBaseAsset(symbol) { @@ -2621,6 +2700,7 @@ let continuousLadderAbortController = null; let activeLadderActionType = null; let activeContinuousLadderActionType = null; + let activeContinuousLadderProgress = null; let activeLadderPanelContext = null; let cancelCurrentSymbolOpenOrdersTask = null; let cancelCurrentSymbolOpenOrdersBlocksLadderActions = false; @@ -4563,7 +4643,11 @@ "交易对已切换", progress )); - return { status: "interrupted" }; + return { + status: "interrupted", + reason: "交易对已切换", + progress: snapshotLadderProgress(progress) + }; } const diagnostics = formatLadderRepriceDiagnostics(repriceAttempts, lastRepriceApiErrorCode); setLadderStatus( @@ -4574,13 +4658,19 @@ progress )}${diagnostics}` ); - return { status: wasStopped ? "stopped" : "completed" }; + return { + status: wasStopped ? "stopped" : "completed", + progress: snapshotLadderProgress(progress) + }; }).catch((e) => { if (isLadderStoppedError(e)) { if (isCurrentObservedSymbol(actionSymbol)) { setLadderStatus(formatStoppedLadderProgress(spec.label, progress)); } - return { status: "stopped" }; + return { + status: "stopped", + progress: snapshotLadderProgress(progress) + }; } err("Maker 阶梯执行失败:", e); if (!isCurrentObservedSymbol(actionSymbol)) { @@ -4589,13 +4679,23 @@ "交易对已切换", progress )); - return { status: "interrupted" }; + return { + status: "interrupted", + reason: "交易对已切换", + progress: snapshotLadderProgress(progress) + }; } const failureMessage = e?.message || "未知错误"; const failureText = formatFailedLadderProgress(spec.label, failureMessage, progress); const failureTitle = e?.statusTitle ? formatFailedLadderProgress(spec.label, e.statusTitle, progress) : failureText; setLadderStatus(failureText, failureTitle); - return { status: "failed", error: e }; + return { + status: "failed", + error: e, + reason: failureMessage, + titleReason: e?.statusTitle || failureMessage, + progress: snapshotLadderProgress(progress) + }; }).finally(() => { if (ladderAbortController === abortController) ladderAbortController = null; ladderTask = null; @@ -4610,7 +4710,7 @@ function readContinuousLadderReadiness(actionType, actionSymbol) { const spec = getLadderActionSpec2(actionType); if (!spec || spec.mode !== "CLOSE") { - throw new Error("Continuous ladder trading requires a close action"); + throw new Error("连续交易仅支持阶梯平仓"); } if (!isCurrentObservedSymbol(actionSymbol)) { return { status: "stopped", reason: "symbol_changed" }; @@ -4627,14 +4727,20 @@ } return { status: "ready" }; } - function setContinuousLadderStoppedStatus(reason) { + function getContinuousLadderStopReason(reason) { const reasonTextByCode = { - symbol_changed: "symbol changed", - mode_changed: "trade mode changed" + symbol_changed: "交易对已切换", + mode_changed: "开仓/平仓模式已切换" }; const reasonText = reasonTextByCode[reason]; - if (!reasonText) throw new Error(`Unknown continuous ladder stop reason: ${reason}`); - setLadderStatus(`Continuous trading stopped: ${reasonText}`); + if (!reasonText) throw new Error(`未知连续交易停止原因:${reason}`); + return reasonText; + } + function setContinuousLadderProgressStatus(label, phase, progress, reason = null, titleReason = reason) { + setLadderStatus( + formatContinuousLadderProgress(label, phase, progress, reason), + formatContinuousLadderProgress(label, phase, progress, titleReason) + ); } async function startContinuousLadder(actionType) { const spec = getLadderActionSpec2(actionType); @@ -4646,28 +4752,56 @@ return { status: "not_started" }; } const abortController = new AbortController(); + const continuousProgress = createContinuousLadderProgress(); continuousLadderAbortController = abortController; activeContinuousLadderActionType = actionType; + activeContinuousLadderProgress = continuousProgress; const executionTask = (async () => { while (true) { throwIfAborted(abortController.signal); const outcome = await startLadder(actionType, true); - if (outcome?.status !== "completed") return outcome; + if (!outcome?.progress) return outcome; + recordContinuousLadderRound(continuousProgress, outcome); + if (outcome.status !== "completed") { + setContinuousLadderProgressStatus( + spec.label, + outcome.status, + continuousProgress, + outcome.reason || null, + outcome.titleReason || outcome.reason || null + ); + return outcome; + } + setContinuousLadderProgressStatus(spec.label, "running", continuousProgress); const readiness = await waitForContinuousLadderNextRound({ readReadiness: () => readContinuousLadderReadiness(actionType, actionSymbol), delay, signal: abortController.signal }); if (readiness.status === "stopped") { - setContinuousLadderStoppedStatus(readiness.reason); + setContinuousLadderProgressStatus( + spec.label, + "stopped", + continuousProgress, + getContinuousLadderStopReason(readiness.reason) + ); return readiness; } } })(); continuousLadderTask = executionTask.catch((e) => { - if (isLadderStoppedError(e)) return { status: "stopped" }; - err("Continuous ladder trading failed:", e); - setLadderStatus(`Continuous trading failed: ${e?.message || "unknown error"}`); + if (isLadderStoppedError(e)) { + setContinuousLadderProgressStatus(spec.label, "stopped", continuousProgress); + return { status: "stopped" }; + } + err("连续阶梯交易失败:", e); + setContinuousLadderProgressStatus( + spec.label, + "failed", + continuousProgress, + e?.message || "未知错误", + e?.statusTitle || e?.message || "未知错误" + ); return { status: "failed", error: e }; }).finally(() => { if (continuousLadderAbortController === abortController) { @@ -4675,6 +4809,7 @@ } continuousLadderTask = null; activeContinuousLadderActionType = null; + activeContinuousLadderProgress = null; scheduleRenderPanel(); }); scheduleRenderPanel(); @@ -4697,9 +4832,13 @@ if (ladderAbortController && !ladderAbortController.signal.aborted) { ladderAbortController.abort(stoppedError); } - setLadderStatus(`${activeSpec.label}停止中`); + setLadderStatus(`${activeSpec.label}${continuousLadderTask ? "连续" : ""}停止中`); } else { - setLadderStatus(`${ladderStatusText} · Continuous trading stopped`); + setContinuousLadderProgressStatus( + activeSpec.label, + "stopping", + activeContinuousLadderProgress + ); } scheduleRenderPanel(); } @@ -6868,7 +7007,7 @@ const borderColor = isBuyTone ? "var(--color-Buy)" : "var(--color-Sell)"; const background = isBuyTone ? "var(--color-GreenAlpha01)" : "var(--color-RedAlpha01)"; const disabledAttrs = disabled ? ' disabled aria-disabled="true"' : ""; - const continuousHint = actionType.startsWith("CLOSE_") ? ' title="Option/Alt + click: continuous trading"' : ""; + const continuousHint = actionType.startsWith("CLOSE_") ? ' title="Option/Alt + 单击:连续交易"' : ""; const cursor = disabled ? "not-allowed" : "pointer"; return ``; } diff --git a/src/binance-orderbook-trade/core/continuous-ladder.js b/src/binance-orderbook-trade/core/continuous-ladder.js index 2eb7541..74068d5 100644 --- a/src/binance-orderbook-trade/core/continuous-ladder.js +++ b/src/binance-orderbook-trade/core/continuous-ladder.js @@ -2,10 +2,101 @@ import { throwIfAborted, waitForPromiseOrAbort, } from './abort.js'; +import { snapshotLadderProgress } from './ladder-progress.js'; export const CONTINUOUS_LADDER_COOLDOWN_MS = 1000; export const CONTINUOUS_LADDER_READY_CHECK_MS = 50; +const recordedRoundOutcomes = new WeakSet(); +const CONTINUOUS_LADDER_PHASE_TEXT = Object.freeze({ + running: '连续中', + stopping: '连续停止中', + stopped: '连续已停止', + failed: '连续失败', + interrupted: '连续已中止', +}); + +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) + ) { + throw new Error('Invalid continuous ladder progress'); + } + if (progress.lastRound !== null) snapshotLadderProgress(progress.lastRound); +} + +export function createContinuousLadderProgress() { + return { + startedRounds: 0, + completedRounds: 0, + submittedOrders: 0, + cancelledOrders: 0, + lastRound: null, + }; +} + +export function recordContinuousLadderRound(progress, outcome) { + assertContinuousLadderProgress(progress); + if (!outcome || typeof outcome !== 'object') { + throw new Error('Invalid continuous ladder round outcome'); + } + if (recordedRoundOutcomes.has(outcome)) { + throw new Error('Continuous ladder round was already recorded'); + } + if (!['completed', 'stopped', 'failed', 'interrupted'].includes(outcome.status)) { + throw new Error('Invalid continuous ladder round outcome'); + } + const roundProgress = snapshotLadderProgress(outcome.progress); + progress.startedRounds += 1; + if (outcome.status === 'completed') progress.completedRounds += 1; + progress.submittedOrders += roundProgress.submittedOrders; + progress.cancelledOrders += roundProgress.cancelledOrders; + progress.lastRound = { + status: outcome.status, + ...roundProgress, + }; + recordedRoundOutcomes.add(outcome); + assertContinuousLadderProgress(progress); +} + +export function formatContinuousLadderProgress(label, phase, progress, reason = null) { + if (typeof label !== 'string' || label.trim() === '') { + throw new Error('Invalid continuous ladder label'); + } + const phaseText = CONTINUOUS_LADDER_PHASE_TEXT[phase]; + if (!phaseText) throw new Error('Invalid continuous ladder phase'); + if (reason !== null && (typeof reason !== 'string' || reason.trim() === '')) { + throw new Error('Invalid continuous ladder reason'); + } + assertContinuousLadderProgress(progress); + + const parts = [`${label}${phaseText}`]; + parts.push(progress.startedRounds === 0 + ? '0 轮' + : `${progress.completedRounds}/${progress.startedRounds} 轮`); + if (progress.lastRound?.plannedOrders !== null) { + parts.push( + `本轮 ${progress.lastRound.currentPlanSubmittedOrders}/${progress.lastRound.plannedOrders} 笔`, + ); + } else if (progress.lastRound?.submittedOrders > 0) { + parts.push(`本轮 ${progress.lastRound.submittedOrders} 笔`); + } + parts.push(`累计 ${progress.submittedOrders} 笔`); + if (progress.cancelledOrders > 0) parts.push(`撤 ${progress.cancelledOrders} 笔`); + if (reason !== null) parts.push(reason); + return parts.join(' · '); +} + function assertReadinessState(state) { if (!['ready', 'waiting', 'stopped'].includes(state?.status)) { throw new Error('Invalid continuous ladder readiness state'); diff --git a/src/binance-orderbook-trade/core/ladder-progress.js b/src/binance-orderbook-trade/core/ladder-progress.js index eba2328..12f906b 100644 --- a/src/binance-orderbook-trade/core/ladder-progress.js +++ b/src/binance-orderbook-trade/core/ladder-progress.js @@ -62,6 +62,16 @@ export function createLadderProgress() { }; } +export function snapshotLadderProgress(progress) { + assertLadderProgress(progress); + return { + submittedOrders: progress.submittedOrders, + cancelledOrders: progress.cancelledOrders, + plannedOrders: progress.plannedOrders, + currentPlanSubmittedOrders: progress.currentPlanSubmittedOrders, + }; +} + export function setLadderPlannedOrders(progress, plannedOrders) { assertLadderProgress(progress); if (!Number.isInteger(plannedOrders) || plannedOrders <= 0) { diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index a869d5a..f4f6caa 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.156 +// @version 2.7.157 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -78,6 +78,9 @@ import { } from './core/ladder-plan.js'; import { keepInteractionFeedbackVisible } from './core/interaction-feedback.js'; import { + createContinuousLadderProgress, + formatContinuousLadderProgress, + recordContinuousLadderRound, waitForContinuousLadderNextRound, } from './core/continuous-ladder.js'; import { @@ -89,6 +92,7 @@ import { recordLadderCancelledOrder, recordLadderSubmittedOrder, setLadderPlannedOrders, + snapshotLadderProgress, } from './core/ladder-progress.js'; import { throwIfAborted, @@ -377,6 +381,7 @@ import { let continuousLadderAbortController = null; let activeLadderActionType = null; let activeContinuousLadderActionType = null; + let activeContinuousLadderProgress = null; let activeLadderPanelContext = null; let cancelCurrentSymbolOpenOrdersTask = null; let cancelCurrentSymbolOpenOrdersBlocksLadderActions = false; @@ -2759,7 +2764,11 @@ import { '交易对已切换', progress, )); - return { status: 'interrupted' }; + return { + status: 'interrupted', + reason: '交易对已切换', + progress: snapshotLadderProgress(progress), + }; } const diagnostics = formatLadderRepriceDiagnostics(repriceAttempts, lastRepriceApiErrorCode); setLadderStatus( @@ -2772,14 +2781,20 @@ import { progress, )}${diagnostics}`, ); - return { status: wasStopped ? 'stopped' : 'completed' }; + return { + status: wasStopped ? 'stopped' : 'completed', + progress: snapshotLadderProgress(progress), + }; }) .catch((e) => { if (isLadderStoppedError(e)) { if (isCurrentObservedSymbol(actionSymbol)) { setLadderStatus(formatStoppedLadderProgress(spec.label, progress)); } - return { status: 'stopped' }; + return { + status: 'stopped', + progress: snapshotLadderProgress(progress), + }; } err('Maker 阶梯执行失败:', e); if (!isCurrentObservedSymbol(actionSymbol)) { @@ -2788,7 +2803,11 @@ import { '交易对已切换', progress, )); - return { status: 'interrupted' }; + return { + status: 'interrupted', + reason: '交易对已切换', + progress: snapshotLadderProgress(progress), + }; } const failureMessage = e?.message || '未知错误'; const failureText = formatFailedLadderProgress(spec.label, failureMessage, progress); @@ -2796,7 +2815,13 @@ import { ? formatFailedLadderProgress(spec.label, e.statusTitle, progress) : failureText; setLadderStatus(failureText, failureTitle); - return { status: 'failed', error: e }; + return { + status: 'failed', + error: e, + reason: failureMessage, + titleReason: e?.statusTitle || failureMessage, + progress: snapshotLadderProgress(progress), + }; }) .finally(() => { if (ladderAbortController === abortController) ladderAbortController = null; @@ -2813,7 +2838,7 @@ import { function readContinuousLadderReadiness(actionType, actionSymbol) { const spec = getLadderActionSpec(actionType); if (!spec || spec.mode !== 'CLOSE') { - throw new Error('Continuous ladder trading requires a close action'); + throw new Error('连续交易仅支持阶梯平仓'); } if (!isCurrentObservedSymbol(actionSymbol)) { return { status: 'stopped', reason: 'symbol_changed' }; @@ -2843,14 +2868,27 @@ import { return { status: 'ready' }; } - function setContinuousLadderStoppedStatus(reason) { + function getContinuousLadderStopReason(reason) { const reasonTextByCode = { - symbol_changed: 'symbol changed', - mode_changed: 'trade mode changed', + symbol_changed: '交易对已切换', + mode_changed: '开仓/平仓模式已切换', }; const reasonText = reasonTextByCode[reason]; - if (!reasonText) throw new Error(`Unknown continuous ladder stop reason: ${reason}`); - setLadderStatus(`Continuous trading stopped: ${reasonText}`); + if (!reasonText) throw new Error(`未知连续交易停止原因:${reason}`); + return reasonText; + } + + function setContinuousLadderProgressStatus( + label, + phase, + progress, + reason = null, + titleReason = reason, + ) { + setLadderStatus( + formatContinuousLadderProgress(label, phase, progress, reason), + formatContinuousLadderProgress(label, phase, progress, titleReason), + ); } async function startContinuousLadder(actionType) { @@ -2865,14 +2903,28 @@ import { } const abortController = new AbortController(); + const continuousProgress = createContinuousLadderProgress(); continuousLadderAbortController = abortController; activeContinuousLadderActionType = actionType; + activeContinuousLadderProgress = continuousProgress; const executionTask = (async () => { while (true) { throwIfAborted(abortController.signal); const outcome = await startLadder(actionType, true); - if (outcome?.status !== 'completed') return outcome; + if (!outcome?.progress) return outcome; + recordContinuousLadderRound(continuousProgress, outcome); + if (outcome.status !== 'completed') { + setContinuousLadderProgressStatus( + spec.label, + outcome.status, + continuousProgress, + outcome.reason || null, + outcome.titleReason || outcome.reason || null, + ); + return outcome; + } + setContinuousLadderProgressStatus(spec.label, 'running', continuousProgress); const readiness = await waitForContinuousLadderNextRound({ readReadiness: () => readContinuousLadderReadiness(actionType, actionSymbol), @@ -2880,7 +2932,12 @@ import { signal: abortController.signal, }); if (readiness.status === 'stopped') { - setContinuousLadderStoppedStatus(readiness.reason); + setContinuousLadderProgressStatus( + spec.label, + 'stopped', + continuousProgress, + getContinuousLadderStopReason(readiness.reason), + ); return readiness; } } @@ -2888,9 +2945,18 @@ import { continuousLadderTask = executionTask .catch((e) => { - if (isLadderStoppedError(e)) return { status: 'stopped' }; - err('Continuous ladder trading failed:', e); - setLadderStatus(`Continuous trading failed: ${e?.message || 'unknown error'}`); + if (isLadderStoppedError(e)) { + setContinuousLadderProgressStatus(spec.label, 'stopped', continuousProgress); + return { status: 'stopped' }; + } + err('连续阶梯交易失败:', e); + setContinuousLadderProgressStatus( + spec.label, + 'failed', + continuousProgress, + e?.message || '未知错误', + e?.statusTitle || e?.message || '未知错误', + ); return { status: 'failed', error: e }; }) .finally(() => { @@ -2899,6 +2965,7 @@ import { } continuousLadderTask = null; activeContinuousLadderActionType = null; + activeContinuousLadderProgress = null; scheduleRenderPanel(); }); scheduleRenderPanel(); @@ -2922,9 +2989,13 @@ import { if (ladderAbortController && !ladderAbortController.signal.aborted) { ladderAbortController.abort(stoppedError); } - setLadderStatus(`${activeSpec.label}停止中`); + setLadderStatus(`${activeSpec.label}${continuousLadderTask ? '连续' : ''}停止中`); } else { - setLadderStatus(`${ladderStatusText} · Continuous trading stopped`); + setContinuousLadderProgressStatus( + activeSpec.label, + 'stopping', + activeContinuousLadderProgress, + ); } scheduleRenderPanel(); } @@ -5466,7 +5537,7 @@ import { const background = isBuyTone ? 'var(--color-GreenAlpha01)' : 'var(--color-RedAlpha01)'; const disabledAttrs = disabled ? ' disabled aria-disabled="true"' : ''; const continuousHint = actionType.startsWith('CLOSE_') - ? ' title="Option/Alt + click: continuous trading"' + ? ' title="Option/Alt + 单击:连续交易"' : ''; const cursor = disabled ? 'not-allowed' : 'pointer'; return ``; diff --git a/test/unit/binance-orderbook-trade/continuous-ladder.test.js b/test/unit/binance-orderbook-trade/continuous-ladder.test.js index 4575075..a3f11bb 100644 --- a/test/unit/binance-orderbook-trade/continuous-ladder.test.js +++ b/test/unit/binance-orderbook-trade/continuous-ladder.test.js @@ -4,9 +4,120 @@ import assert from 'node:assert/strict'; import { CONTINUOUS_LADDER_COOLDOWN_MS, CONTINUOUS_LADDER_READY_CHECK_MS, + createContinuousLadderProgress, + formatContinuousLadderProgress, + recordContinuousLadderRound, waitForContinuousLadderNextRound, } from '../../../src/binance-orderbook-trade/core/continuous-ladder.js'; +function roundProgress({ + submittedOrders, + cancelledOrders = 0, + plannedOrders, + currentPlanSubmittedOrders, +}) { + return { + submittedOrders, + cancelledOrders, + plannedOrders, + currentPlanSubmittedOrders, + }; +} + +test('continuous ladder status summarizes completed rounds and cumulative submissions', () => { + const progress = createContinuousLadderProgress(); + const completedRound = roundProgress({ + submittedOrders: 3, + plannedOrders: 3, + currentPlanSubmittedOrders: 3, + }); + + recordContinuousLadderRound(progress, { status: 'completed', progress: completedRound }); + recordContinuousLadderRound(progress, { status: 'completed', progress: completedRound }); + + assert.deepEqual(progress, { + startedRounds: 2, + completedRounds: 2, + submittedOrders: 6, + cancelledOrders: 0, + lastRound: { + status: 'completed', + submittedOrders: 3, + cancelledOrders: 0, + plannedOrders: 3, + currentPlanSubmittedOrders: 3, + }, + }); + assert.equal( + formatContinuousLadderProgress('阶梯平空', 'running', progress), + '阶梯平空连续中 · 2/2 轮 · 本轮 3/3 笔 · 累计 6 笔', + ); +}); + +test('continuous ladder stopped status separates completed rounds from a partial round', () => { + const progress = createContinuousLadderProgress(); + const completedRound = roundProgress({ + submittedOrders: 3, + plannedOrders: 3, + currentPlanSubmittedOrders: 3, + }); + recordContinuousLadderRound(progress, { status: 'completed', progress: completedRound }); + recordContinuousLadderRound(progress, { status: 'completed', progress: completedRound }); + recordContinuousLadderRound(progress, { + status: 'stopped', + progress: roundProgress({ + submittedOrders: 1, + plannedOrders: 3, + currentPlanSubmittedOrders: 1, + }), + }); + + assert.equal( + formatContinuousLadderProgress('阶梯平空', 'stopped', progress), + '阶梯平空连续已停止 · 2/3 轮 · 本轮 1/3 笔 · 累计 7 笔', + ); +}); + +test('continuous ladder status shows cancellations only when they occurred', () => { + const progress = createContinuousLadderProgress(); + recordContinuousLadderRound(progress, { + status: 'failed', + progress: roundProgress({ + submittedOrders: 2, + cancelledOrders: 1, + plannedOrders: 3, + currentPlanSubmittedOrders: 2, + }), + }); + + assert.equal( + formatContinuousLadderProgress('阶梯平多', 'failed', progress, '下单按钮 3 秒内未恢复可点击'), + '阶梯平多连续失败 · 0/1 轮 · 本轮 2/3 笔 · 累计 2 笔 · 撤 1 笔 · 下单按钮 3 秒内未恢复可点击', + ); +}); + +test('continuous ladder progress rejects an invalid or duplicate round outcome', () => { + const progress = createContinuousLadderProgress(); + const outcome = { + status: 'completed', + progress: roundProgress({ + submittedOrders: 1, + plannedOrders: 1, + currentPlanSubmittedOrders: 1, + }), + }; + recordContinuousLadderRound(progress, outcome); + + assert.throws( + () => recordContinuousLadderRound(progress, outcome), + /Continuous ladder round was already recorded/, + ); + assert.throws( + () => formatContinuousLadderProgress('阶梯平空', 'unknown', progress), + /Invalid continuous ladder phase/, + ); +}); + test('continuous ladder starts its cooldown only after the previous round is ready', async () => { const states = [ { status: 'waiting' }, diff --git a/test/unit/binance-orderbook-trade/ladder-progress.test.js b/test/unit/binance-orderbook-trade/ladder-progress.test.js index a6c0cd4..eead34a 100644 --- a/test/unit/binance-orderbook-trade/ladder-progress.test.js +++ b/test/unit/binance-orderbook-trade/ladder-progress.test.js @@ -10,8 +10,25 @@ import { recordLadderCancelledOrder, recordLadderSubmittedOrder, setLadderPlannedOrders, + snapshotLadderProgress, } from '../../../src/binance-orderbook-trade/core/ladder-progress.js'; +test('ladder progress snapshot is detached from later mutations', () => { + const progress = createLadderProgress(); + setLadderPlannedOrders(progress, 2); + recordLadderSubmittedOrder(progress); + + const snapshot = snapshotLadderProgress(progress); + recordLadderSubmittedOrder(progress); + + assert.deepEqual(snapshot, { + submittedOrders: 1, + cancelledOrders: 0, + plannedOrders: 2, + currentPlanSubmittedOrders: 1, + }); +}); + test('stopped ladder status reports confirmed submitted and cancelled orders', () => { const progress = createLadderProgress(); diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 17e72af..e4257c4 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -625,7 +625,8 @@ test('Option or Alt click continuously repeats close ladders only after readines assert.match(continuousBody, /spec\.mode !== 'CLOSE'\) return startLadder\(actionType\)/); assert.match(continuousBody, /while \(true\)/); assert.match(continuousBody, /await startLadder\(actionType, true\)/); - assert.match(continuousBody, /outcome\?\.status !== 'completed'/); + assert.match(continuousBody, /recordContinuousLadderRound\(continuousProgress, outcome\)/); + assert.match(continuousBody, /setContinuousLadderProgressStatus\(/); assert.match(continuousBody, /await waitForContinuousLadderNextRound\(/); assert.match(continuousBody, /readContinuousLadderReadiness\(actionType, actionSymbol\)/); @@ -638,7 +639,8 @@ test('Option or Alt click continuously repeats close ladders only after readines assert.match(readinessBody, /isSubmitButtonBusy\(button\)/); assert.match(stopBody, /continuousLadderAbortController\.abort\(stoppedError\)/); - assert.match(actionButtonBody, /Option\/Alt \+ click: continuous trading/); + assert.match(actionButtonBody, /Option\/Alt \+ 单击:连续交易/); + assert.doesNotMatch(source, /Continuous trading (?:stopped|failed)/); assert.match(executionButtonBody, /activeLadderActionType \|\| activeContinuousLadderActionType/); assert.match(controlSectionsBody, /!!ladderTask \|\| !!continuousLadderTask/); }); @@ -866,7 +868,7 @@ test('ladder task statuses name the active action and observed outcome', () => { assert.match(startBody, /`\$\{spec\.label\}尚未开始:仓位确认中`/); assert.match(startBody, /formatInterruptedLadderProgress\(\s*spec\.label,\s*'交易对已切换',\s*progress/); assert.match(startBody, /formatFailedLadderProgress\(spec\.label,\s*failureMessage,\s*progress\)/); - assert.match(stopBody, /`\$\{activeSpec\.label\}停止中`/); + assert.match(stopBody, /`\$\{activeSpec\.label\}\$\{continuousLadderTask \? '连续' : ''\}停止中`/); assert.match(cancelPlanBody, /setPlanStepStatus\(`撤销 \$\{rowsToCancel\.length\} 笔当前币挂单`\)/); assert.match(planStatusBody, /`\$\{plan\.spec\.label\}计划:\$\{plan\.percent\}% \/ \$\{levelText\} \/ 幅\$\{plan\.ladderStep\}`/); assert.doesNotMatch(planStatusBody, /%\/|\/幅/);