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
50 changes: 46 additions & 4 deletions e2e/binance-orderbook/fixtures/binance-futures.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export function renderBinanceFuturesFixture(scenario) {
const visibleOrders = () => state.hideOtherSymbols ? currentOrders() : state.orders;
const selected = (value, expected) => String(value === expected);
const scheduleCommit = (callback) => setTimeout(callback, scenario.host.mutationDelayMs);
let orderSubmitSequence = 0;
const userscriptFetch = window.fetch;
window.fetch = async (...args) => {
const response = await userscriptFetch(...args);
Expand Down Expand Up @@ -182,15 +183,56 @@ export function renderBinanceFuturesFixture(scenario) {
});
orderEntry.querySelectorAll('button').forEach((button) => {
button.addEventListener('click', () => {
const feedback = document.createElement('div');
feedback.setAttribute('role', 'alert');
feedback.textContent = '订单已提交成功';
document.body.append(feedback);
const busyAttribute = scenario.host.submitButtonBusyAttribute;
if (button.getAttribute(busyAttribute) === 'true') {
record('order-submit-while-busy', { action: button.textContent.trim() });
return;
}
if (scenario.host.submitButtonBusyMs > 0) {
button.setAttribute(busyAttribute, 'true');
record('submit-button-busy', { action: button.textContent.trim() });
setTimeout(() => {
if (scenario.host.submitButtonClearsInputsWhenReady) {
orderEntry.querySelectorAll('input').forEach((input) => {
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
record('native-submit-cleanup-cleared-inputs');
}
button.removeAttribute(busyAttribute);
record('submit-button-ready', { action: button.textContent.trim() });
}, scenario.host.submitButtonBusyMs);
}
orderSubmitSequence += 1;
const submitSequence = orderSubmitSequence;
record('order-submitted', {
submitSequence,
action: button.textContent.trim(),
price: orderEntry.querySelector('input[id^="limitPrice-"]')?.value || '',
quantity: orderEntry.querySelector('input[id^="unitAmount-"]')?.value || '',
});
window.fetch('/bapi/futures/v1/private/future/order/place-order', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ submitSequence }),
}).then(() => record('order-submit-api-success', { submitSequence }));
const showFeedback = () => {
const feedback = document.createElement('div');
feedback.setAttribute('role', 'alert');
feedback.textContent = '订单已提交成功';
document.body.append(feedback);
record('order-submit-feedback', { action: button.textContent.trim(), submitSequence });
};
if (scenario.host.submitFeedbackDelayMs > 0) {
setTimeout(showFeedback, scenario.host.submitFeedbackDelayMs);
} else {
showFeedback();
}
});
});
orderEntry.querySelectorAll('input').forEach((input) => {
input.addEventListener('input', () => {
record('trade-input-written', { id: input.id, value: input.value });
});
});
}
Expand Down
15 changes: 15 additions & 0 deletions e2e/binance-orderbook/helpers/userscript-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export async function openUserscriptScenario(page, scenario) {
sha256: createHash('sha256').update(userscriptSource).digest('hex'),
};
evidenceByPage.set(page, { scenario, userscript, errors });
let placeOrderRequestCount = 0;
await page.route('https://www.binance.com/**', async (route) => {
const url = new URL(route.request().url());
if (url.pathname === '/__binance_orderbook_userscript__.js') {
Expand All @@ -46,6 +47,20 @@ export async function openUserscriptScenario(page, scenario) {
});
return;
}
if (url.pathname === '/bapi/futures/v1/private/future/order/place-order') {
const delayMs = scenario.host.submitApiResponseDelayMsByOrder[placeOrderRequestCount];
placeOrderRequestCount += 1;
if (delayMs === undefined) {
throw new Error('Fixture received more than five ladder order requests');
}
if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
return;
}
if (url.pathname === '/bapi/futures/v6/private/future/user-data/user-position') {
await route.fulfill({
status: 200,
Expand Down
25 changes: 25 additions & 0 deletions e2e/binance-orderbook/scenarios/cancel-current-symbol.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export function createCancelScenario(overrides = {}) {
dialogReplacementDelayMs: null,
clearMode: 'currentSymbol',
chartOrdersPopoverCloseMode: 'normal',
submitFeedbackDelayMs: 0,
submitButtonBusyMs: 0,
submitButtonBusyAttribute: 'data-loading',
submitButtonClearsInputsWhenReady: false,
submitApiResponseDelayMsByOrder: [0, 0, 0, 0, 0],
precisionOptions: ['0.001', '0.01', '0.1', '1'],
...overrides.host,
},
Expand Down Expand Up @@ -90,6 +95,26 @@ export function createCancelScenario(overrides = {}) {
`Unsupported chart-orders popover close mode: ${scenario.host.chartOrdersPopoverCloseMode}`,
);
}
for (const key of ['submitFeedbackDelayMs', 'submitButtonBusyMs']) {
if (!Number.isInteger(scenario.host[key]) || scenario.host[key] < 0) {
throw new Error(`${key} must be a non-negative integer`);
}
}
if (!['data-loading', 'aria-busy'].includes(scenario.host.submitButtonBusyAttribute)) {
throw new Error(`Unsupported submit button busy attribute: ${scenario.host.submitButtonBusyAttribute}`);
}
if (typeof scenario.host.submitButtonClearsInputsWhenReady !== 'boolean') {
throw new Error('submitButtonClearsInputsWhenReady must be a boolean');
}
if (
!Array.isArray(scenario.host.submitApiResponseDelayMsByOrder)
|| scenario.host.submitApiResponseDelayMsByOrder.length !== 5
|| scenario.host.submitApiResponseDelayMsByOrder.some(
(delayMs) => !Number.isInteger(delayMs) || delayMs < 0,
)
) {
throw new Error('submitApiResponseDelayMsByOrder must contain five non-negative integers');
}
if (
scenario.host.dialogReplacementDelayMs !== null
&& (!Number.isInteger(scenario.host.dialogReplacementDelayMs)
Expand Down
67 changes: 67 additions & 0 deletions e2e/binance-orderbook/specs/control-flows.pw.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,73 @@ test('a complete ladder submits the planned five native orders and restores cont
expect(submissions.every((event) => event.action === '开多')).toBe(true);
expect(submissions.every((event) => Number(event.price) < 81.1)).toBe(true);
expect(submissions.every((event) => Number(event.quantity) > 0)).toBe(true);
const events = (await readFixtureState(page)).events;
for (let index = 0; index < submissions.length - 1; index += 1) {
const nextInputWrite = events.find(
(event) => event.type === 'trade-input-written' && event.at > submissions[index].at,
);
expect(nextInputWrite.at - submissions[index].at).toBeLessThan(700);
}
expect(errors).toEqual([]);
});

test('a late toast from the previous order cannot acknowledge the next order', async ({ page }) => {
const scenario = createCancelScenario({
ui: { tradeMode: 'OPEN', orderbookPrecision: '0.1' },
host: {
submitFeedbackDelayMs: 500,
submitApiResponseDelayMsByOrder: [20, 700, 20, 20, 20],
},
});
const { errors } = await openUserscriptScenario(page, scenario);
const panel = page.locator(PANEL_SELECTOR);

await panel.getByRole('button', { name: '阶梯开多' }).click();
await expect(panel.locator('#jh-binance-ladder-status')).toHaveText(
'阶梯开多已完成 · 已挂 5/5 笔',
{ timeout: 12_000 },
);

const events = (await readFixtureState(page)).events;
const submissions = events.filter((event) => event.type === 'order-submitted');
expect(submissions).toHaveLength(5);
for (let index = 0; index < submissions.length - 1; index += 1) {
const acknowledgement = events.find(
(event) => event.type === 'order-submit-api-success'
&& event.submitSequence === submissions[index].submitSequence,
);
expect(acknowledgement.at).toBeLessThan(submissions[index + 1].at);
}
expect(errors).toEqual([]);
});

test('ladder waits for the native submit button to leave its busy state', async ({ page }) => {
const scenario = createCancelScenario({
ui: { tradeMode: 'OPEN', orderbookPrecision: '0.1' },
host: {
submitButtonBusyMs: 450,
submitButtonBusyAttribute: 'aria-busy',
submitButtonClearsInputsWhenReady: true,
},
});
const { errors } = await openUserscriptScenario(page, scenario);
const panel = page.locator(PANEL_SELECTOR);

await panel.getByRole('button', { name: '阶梯开多' }).click();
await expect(panel.locator('#jh-binance-ladder-status')).toHaveText(
'阶梯开多已完成 · 已挂 5/5 笔',
{ timeout: 12_000 },
);

const state = await readFixtureState(page);
const submissions = state.events.filter((event) => event.type === 'order-submitted');
expect(submissions).toHaveLength(5);
expect(submissions.every((event) => Number(event.price) > 0)).toBe(true);
expect(submissions.every((event) => Number(event.quantity) > 0)).toBe(true);
expect(state.events.filter((event) => event.type === 'order-submit-while-busy')).toEqual([]);
for (let index = 1; index < submissions.length; index += 1) {
expect(submissions[index].at - submissions[index - 1].at).toBeGreaterThanOrEqual(450);
}
expect(errors).toEqual([]);
});

Expand Down
58 changes: 39 additions & 19 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.147
// @version 2.7.148
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
Expand Down Expand Up @@ -2360,7 +2360,6 @@
LOCAL_LADDER_OPEN_STEP_KEY,
LOCAL_LADDER_CLOSE_STEP_KEY
];
const LADDER_ORDER_DELAY_MS = 520;
const LADDER_SUBMIT_ACK_TIMEOUT_MS = 3500;
const LADDER_SUBMIT_POLL_MS = 80;
const LADDER_ACTION_FEEDBACK_MIN_MS = 240;
Expand Down Expand Up @@ -4061,7 +4060,28 @@
if (!button) return false;
const text = (button.textContent || "").toLowerCase();
const cls = String(button.className || "").toLowerCase();
return button.disabled || button.getAttribute("aria-disabled") === "true" || button.getAttribute("data-loading") === "true" || includesBinancePageText(text, BINANCE_PAGE_TEXT.submitBusy) || cls.includes("loading") || !!button.querySelector('[class*="loading"], [class*="spinner"], [aria-busy="true"]');
return button.disabled || button.getAttribute("aria-disabled") === "true" || button.getAttribute("aria-busy") === "true" || button.getAttribute("data-loading") === "true" || includesBinancePageText(text, BINANCE_PAGE_TEXT.submitBusy) || cls.includes("loading") || !!button.querySelector('[class*="loading"], [class*="spinner"], [aria-busy="true"]');
}
async function waitForReadyLadderSubmitButton(plan) {
const resolveReadyButton = () => {
const candidate = plan.spec.buttonGetter();
return candidate && !isSubmitButtonBusy(candidate) ? candidate : null;
};
const button = await waitForTradeActionButtonFrameState(
document,
resolveReadyButton,
isVisibleElement,
TRADE_ACTION_BUTTON_READY_TIMEOUT_MS
);
if (button) return button;
const currentButton = plan.spec.buttonGetter();
if (!currentButton || !currentButton.isConnected || !isVisibleElement(currentButton)) {
throw new Error(`${plan.spec.label}按钮尚未渲染完成,已停止`);
}
if (isSubmitButtonBusy(currentButton)) {
throw new Error(`${plan.spec.label}按钮持续处理中,已停止`);
}
throw new Error(`${plan.spec.label}按钮当前不可点击,已停止`);
}
function readVisibleOrderFeedbackEntries() {
const selectors = [
Expand Down Expand Up @@ -4137,12 +4157,11 @@
}
const capturedApiSuccessesNow = readLadderSubmitApiSuccesses(submitCaptureId);
if (capturedApiSuccessesNow.length === 1) return;
if (acknowledgement.status === "success") return;
const busy = isSubmitButtonBusy(button);
if (busy) sawBusy = true;
await delay(LADDER_SUBMIT_POLL_MS);
}
const settleHint = sawBusy ? "按钮已恢复但未收到明确成功反馈" : "未观察到提交按钮状态变化";
const settleHint = sawBusy ? "按钮已恢复但未捕获当前下单 API 成功响应" : "未捕获当前下单 API 成功响应";
throw new Error(`未收到明确${label}成功反馈(${settleHint}),已停止;请核对当前委托/历史成交`);
}
async function executeLadderPlan(plan, progress, abortSignal = null) {
Expand All @@ -4163,6 +4182,10 @@
throwIfAborted(abortSignal);
assertLadderExecutionContext(plan);
assertLadderMakerPrice(plan, order.price);
await waitForReadyLadderSubmitButton(plan);
throwIfAborted(abortSignal);
assertLadderExecutionContext(plan);
assertLadderMakerPrice(plan, order.price);
const currentPriceInput = findPriceInput();
const currentQtyInput = findQtyInput();
if (!currentPriceInput || !currentQtyInput) throw new Error("执行中价格或数量输入框丢失");
Expand All @@ -4176,20 +4199,18 @@
const submittedPrice = synchronizedInputs.submittedPrice;
assertLadderExecutionContext(plan);
assertLadderMakerPrice(plan, submittedPrice);
const button = await waitForTradeActionButtonFrameState(
document,
plan.spec.buttonGetter,
isVisibleElement,
TRADE_ACTION_BUTTON_READY_TIMEOUT_MS
);
const button = await waitForReadyLadderSubmitButton(plan);
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}按钮当前不可点击,已停止`);
}
assertSubmittedPriceMatchesExpectedPrice(
order.price,
findPriceInput()?.value || "",
"计划价"
);
assertSubmittedQtyMatchesExpectedQty(
order.qty,
findQtyInput()?.value || "",
"计划量"
);
if (!CFG.SAFE_MODE) {
const previousFeedback = takeOrderFeedbackSnapshot();
const submitCaptureId = beginLadderSubmitResponseCapture();
Expand Down Expand Up @@ -4233,7 +4254,6 @@
done++;
recordLadderSubmittedOrder(progress);
setLadderStatus(`${plan.spec.label}已挂 ${done}/${plan.orders.length} 笔`);
await delay(LADDER_ORDER_DELAY_MS);
throwIfAborted(abortSignal);
}
return { done, repriceAttempts, lastRepriceApiErrorCode };
Expand Down
Loading
Loading