Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 66 additions & 4 deletions scripts/binance-orderbook-trade.user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.141
// @version 2.7.142
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
Expand Down Expand Up @@ -1507,6 +1507,57 @@
frameHandle = view.requestAnimationFrame(check);
});
}
function waitForTradeActionButtonFrameState(observationRoot, findButton, isVisibleElement, timeoutMs, requiredStableFrames = 2) {
const view = observationRoot?.ownerDocument?.defaultView || observationRoot?.defaultView;
if (!view || typeof view.requestAnimationFrame !== "function" || typeof view.cancelAnimationFrame !== "function") {
throw new Error("Trade action button frame scheduler is unavailable");
}
if (typeof findButton !== "function" || typeof isVisibleElement !== "function") {
throw new Error("Trade action button resolver is unavailable");
}
if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) {
throw new Error("requiredStableFrames must be a positive integer");
}
return new Promise((resolve) => {
let settled = false;
let frameHandle = 0;
let timer = 0;
let stableFrames = 0;
let stableButton = null;
const finish = (value) => {
if (settled) return;
settled = true;
if (frameHandle) view.cancelAnimationFrame(frameHandle);
view.clearTimeout(timer);
resolve(value);
};
const check = () => {
frameHandle = 0;
const button = findButton();
const actionable = Boolean(
button && button.isConnected && isVisibleElement(button) && !button.disabled && button.getAttribute("aria-disabled") !== "true"
);
if (actionable) {
if (button === stableButton) {
stableFrames += 1;
} else {
stableButton = button;
stableFrames = 1;
}
if (stableFrames >= requiredStableFrames) {
finish(button);
return;
}
} else {
stableButton = null;
stableFrames = 0;
}
frameHandle = view.requestAnimationFrame(check);
};
timer = view.setTimeout(() => finish(null), timeoutMs);
frameHandle = view.requestAnimationFrame(check);
});
}
function isTradeModeTab(node, { panelId }) {
if (!node?.matches?.('[role="tab"]')) return false;
if (node.closest(`#${panelId}`)) return false;
Expand Down Expand Up @@ -2145,6 +2196,7 @@
const LADDER_CONTROL_BUTTON_FONT_SIZE = 14;
const PANEL_BOTTOM_TOOLTIP_GAP = 12;
const TRADE_UI_STATE_TIMEOUT_MS = 1e3;
const TRADE_ACTION_BUTTON_READY_TIMEOUT_MS = 2e3;
let lastTs = 0;
let isEditingMultiplier = false;
let multiplierEditContext = null;
Expand Down Expand Up @@ -3890,9 +3942,19 @@
const submittedPrice = synchronizedInputs.submittedPrice;
assertLadderExecutionContext(plan);
assertLadderMakerPrice(plan, submittedPrice);
const button = plan.spec.buttonGetter();
if (!button || button.disabled || button.getAttribute("aria-disabled") === "true") {
throw new Error(`未找到可点击的${plan.spec.label}按钮`);
const button = await waitForTradeActionButtonFrameState(
document,
plan.spec.buttonGetter,
isVisibleElement,
TRADE_ACTION_BUTTON_READY_TIMEOUT_MS
);
throwIfAborted(abortSignal);
if (!button) {
const currentButton = plan.spec.buttonGetter();
if (!currentButton || !currentButton.isConnected || !isVisibleElement(currentButton)) {
throw new Error(`${plan.spec.label}按钮尚未渲染完成,已停止`);
}
throw new Error(`${plan.spec.label}按钮当前不可点击,已停止`);
}
if (!CFG.SAFE_MODE) {
const previousFeedback = takeOrderFeedbackSnapshot();
Expand Down
72 changes: 72 additions & 0 deletions src/binance-orderbook-trade/dom/trade-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,78 @@ export function waitForTradeFormFrameState(
});
}

/**
* Binance can mark the requested trade mode active before React replaces the
* native action buttons. Require one live button identity to remain actionable
* across consecutive paint frames so callers never click the outgoing node.
*/
export function waitForTradeActionButtonFrameState(
observationRoot,
findButton,
isVisibleElement,
timeoutMs,
requiredStableFrames = 2,
) {
const view = observationRoot?.ownerDocument?.defaultView || observationRoot?.defaultView;
if (
!view
|| typeof view.requestAnimationFrame !== 'function'
|| typeof view.cancelAnimationFrame !== 'function'
) {
throw new Error('Trade action button frame scheduler is unavailable');
}
if (typeof findButton !== 'function' || typeof isVisibleElement !== 'function') {
throw new Error('Trade action button resolver is unavailable');
}
if (!Number.isInteger(requiredStableFrames) || requiredStableFrames < 1) {
throw new Error('requiredStableFrames must be a positive integer');
}

return new Promise((resolve) => {
let settled = false;
let frameHandle = 0;
let timer = 0;
let stableFrames = 0;
let stableButton = null;
const finish = (value) => {
if (settled) return;
settled = true;
if (frameHandle) view.cancelAnimationFrame(frameHandle);
view.clearTimeout(timer);
resolve(value);
};
const check = () => {
frameHandle = 0;
const button = findButton();
const actionable = Boolean(
button
&& button.isConnected
&& isVisibleElement(button)
&& !button.disabled
&& button.getAttribute('aria-disabled') !== 'true'
);
if (actionable) {
if (button === stableButton) {
stableFrames += 1;
} else {
stableButton = button;
stableFrames = 1;
}
if (stableFrames >= requiredStableFrames) {
finish(button);
return;
}
} else {
stableButton = null;
stableFrames = 0;
}
frameHandle = view.requestAnimationFrame(check);
};
timer = view.setTimeout(() => finish(null), timeoutMs);
frameHandle = view.requestAnimationFrame(check);
});
}

export function isTradeModeTab(node, { panelId }) {
if (!node?.matches?.('[role="tab"]')) return false;
if (node.closest(`#${panelId}`)) return false;
Expand Down
20 changes: 16 additions & 4 deletions src/binance-orderbook-trade/index.user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.141
// @version 2.7.142
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
Expand Down Expand Up @@ -129,6 +129,7 @@ import {
parseLeverageButtonText,
placeTradePanelSpacer,
readTradeAvailableBalance,
waitForTradeActionButtonFrameState,
waitForTradeFormFrameState,
waitForTradeFormMutationState,
} from './dom/trade-form.js';
Expand Down Expand Up @@ -325,6 +326,7 @@ import {
const LADDER_CONTROL_BUTTON_FONT_SIZE = 14;
const PANEL_BOTTOM_TOOLTIP_GAP = 12;
const TRADE_UI_STATE_TIMEOUT_MS = 1000;
const TRADE_ACTION_BUTTON_READY_TIMEOUT_MS = 2000;

let lastTs = 0;
let isEditingMultiplier = false;
Expand Down Expand Up @@ -2497,9 +2499,19 @@ import {
assertLadderExecutionContext(plan);
assertLadderMakerPrice(plan, submittedPrice);

const button = plan.spec.buttonGetter();
if (!button || button.disabled || button.getAttribute('aria-disabled') === 'true') {
throw new Error(`未找到可点击的${plan.spec.label}按钮`);
const button = await waitForTradeActionButtonFrameState(
document,
plan.spec.buttonGetter,
isVisibleElement,
TRADE_ACTION_BUTTON_READY_TIMEOUT_MS,
);
throwIfAborted(abortSignal);
if (!button) {
const currentButton = plan.spec.buttonGetter();
if (!currentButton || !currentButton.isConnected || !isVisibleElement(currentButton)) {
throw new Error(`${plan.spec.label}按钮尚未渲染完成,已停止`);
}
throw new Error(`${plan.spec.label}按钮当前不可点击,已停止`);
}

if (!CFG.SAFE_MODE) {
Expand Down
8 changes: 8 additions & 0 deletions test/unit/binance-orderbook-trade/source-regressions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,14 @@ test('trade mode and Post Only switches wait for observed state instead of fixed
assert.doesNotMatch(source, /findConditionalSubtypeCombobox|findPostOnlyOption|clickElementLikeUser/);
});

test('ladder execution waits for the current semantic action button before every submit', () => {
const executeBody = readFunctionBody('executeLadderPlan');

assert.match(executeBody, /await waitForTradeActionButtonFrameState/);
assert.match(executeBody, /plan\.spec\.buttonGetter/);
assert.doesNotMatch(executeBody, /const button = plan\.spec\.buttonGetter\(\);\s*if/);
});

test('trade input synchronization confirms live controlled values instead of sleeping', () => {
const setInputBody = readFunctionBody('setInputValueReact');
const syncBody = readFunctionBody('syncTradeInputs');
Expand Down
83 changes: 83 additions & 0 deletions test/unit/binance-orderbook-trade/trade-form.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
parseTradeModeLabel,
placeTradePanelSpacer,
readTradeAvailableBalance,
waitForTradeActionButtonFrameState,
waitForTradeFormFrameState,
waitForTradeFormMutationState,
} from '../../../src/binance-orderbook-trade/dom/trade-form.js';
Expand Down Expand Up @@ -227,6 +228,88 @@ test('trade form frame wait rejects a value that never remains synchronized', as
);
});

test('trade action button wait survives the selected tab preceding its action buttons', async () => {
const dom = loadFixtureDom('<section id="trade-form"><div role="tab" aria-selected="true">开仓</div></section>');
const { document } = dom.window;
const root = document.querySelector('#trade-form');
let frame = 0;
dom.window.requestAnimationFrame = (callback) => dom.window.setTimeout(() => {
frame += 1;
if (frame === 2) {
const button = document.createElement('button');
button.textContent = '开空';
root.append(button);
}
callback();
}, 0);
dom.window.cancelAnimationFrame = (handle) => dom.window.clearTimeout(handle);

const button = await waitForTradeActionButtonFrameState(
document,
() => Array.from(root.querySelectorAll('button')).find((candidate) => candidate.textContent === '开空') || null,
() => true,
100,
2,
);

assert.equal(button?.textContent, '开空');
assert.equal(button?.isConnected, true);
assert.equal(frame, 3);
});

test('trade action button wait restarts stability after React replaces the button node', async () => {
const dom = loadFixtureDom('<section id="trade-form"><button>平空</button></section>');
const { document } = dom.window;
const root = document.querySelector('#trade-form');
const originalButton = root.querySelector('button');
let replacementButton = null;
let resolvedButton = originalButton;
let frame = 0;
dom.window.requestAnimationFrame = (callback) => dom.window.setTimeout(() => {
frame += 1;
if (frame === 2) {
replacementButton = document.createElement('button');
replacementButton.textContent = '平空';
originalButton.replaceWith(replacementButton);
}
if (frame === 3) resolvedButton = replacementButton;
callback();
}, 0);
dom.window.cancelAnimationFrame = (handle) => dom.window.clearTimeout(handle);

const button = await waitForTradeActionButtonFrameState(
document,
() => resolvedButton,
() => true,
100,
2,
);

assert.equal(button, replacementButton);
assert.equal(originalButton.isConnected, false);
assert.equal(button?.isConnected, true);
assert.equal(frame, 4);
});

test('trade action button wait rejects a button that remains disabled', async () => {
const dom = loadFixtureDom('<section id="trade-form"><button aria-disabled="true">开多</button></section>');
const { document } = dom.window;
const root = document.querySelector('#trade-form');
dom.window.requestAnimationFrame = (callback) => dom.window.setTimeout(callback, 0);
dom.window.cancelAnimationFrame = (handle) => dom.window.clearTimeout(handle);

assert.equal(
await waitForTradeActionButtonFrameState(
document,
() => root.querySelector('button'),
() => true,
10,
2,
),
null,
);
});

test('unexpected trade-mode structure is rejected instead of inserting at a guessed location', () => {
const dom = loadFixtureDom(`
<main>
Expand Down
Loading