From 7f39f11299cf6adb494f87a4f43ec72c0d1a0871 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:55:02 +0800 Subject: [PATCH] fix: settle Binance ladder inputs before submit --- scripts/binance-orderbook-trade.user.js | 145 +++++++++++---- src/binance-orderbook-trade/dom/trade-form.js | 120 +++++++++--- src/binance-orderbook-trade/index.user.js | 48 ++++- .../source-regressions.test.js | 15 +- .../trade-form.test.js | 175 ++++++++++++++++++ 5 files changed, 426 insertions(+), 77 deletions(-) diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 32ad718..b3f8669 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.135 +// @version 2.7.136 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -1211,6 +1211,22 @@ } return matches.length === 1 ? matches[0] : null; } + function createBoundedInputWriter({ writeValue, maxWriteAttempts }) { + if (typeof writeValue !== "function") { + throw new Error("Bounded input writer dependency is invalid"); + } + if (!Number.isInteger(maxWriteAttempts) || maxWriteAttempts < 1) { + throw new Error("Trade input write attempts must be a positive integer"); + } + const attemptsByInput = /* @__PURE__ */ new WeakMap(); + return (input, value) => { + const attempts = attemptsByInput.get(input) || 0; + if (attempts >= maxWriteAttempts) return false; + attemptsByInput.set(input, attempts + 1); + writeValue(input, value); + return true; + }; + } function createTradeInputStateReader({ resolveInputs, expectedPrice, @@ -1219,14 +1235,23 @@ normalizeValue, compareValues, writeValue, - requiredStableMismatchFrames = 2 + requiredStableMismatchFrames = 2, + requiredStableMatchFrames = 1, + maxWriteAttempts = 2, + isRecoveryWriteAllowed = () => true }) { - if (typeof resolveInputs !== "function" || typeof normalizeValue !== "function" || typeof compareValues !== "function" || typeof writeValue !== "function") { + if (typeof resolveInputs !== "function" || typeof normalizeValue !== "function" || typeof compareValues !== "function" || typeof writeValue !== "function" || typeof isRecoveryWriteAllowed !== "function") { throw new Error("Trade input synchronizer dependencies are invalid"); } if (!Number.isInteger(requiredStableMismatchFrames) || requiredStableMismatchFrames < 1) { throw new Error("Trade input rollback stability must be a positive integer"); } + if (!Number.isInteger(requiredStableMatchFrames) || requiredStableMatchFrames < 1) { + throw new Error("Trade input accepted stability must be a positive integer"); + } + if (!Number.isInteger(maxWriteAttempts) || maxWriteAttempts < 1) { + throw new Error("Trade input write attempts must be a positive integer"); + } const createSyncSlot = () => { let root = null; let input = null; @@ -1234,6 +1259,28 @@ let rollbackValue = null; let recoveryEligible = false; let stableRollbackFrames = 0; + let stableMatchFrames = 0; + const clearRecovery = () => { + rollbackValue = null; + recoveryEligible = false; + stableRollbackFrames = 0; + }; + const writeExpectedValue = (currentInput, expectedValue, submittedValue) => { + const wrote = writeValue(currentInput, expectedValue); + if (wrote === false) { + writeCount = maxWriteAttempts; + clearRecovery(); + stableMatchFrames = 0; + return; + } + writeCount += 1; + const postWriteValue = normalizeValue(currentInput.value); + const rejected = compareValues(expectedValue, postWriteValue) !== 0; + recoveryEligible = rejected && writeCount < maxWriteAttempts; + rollbackValue = rejected ? postWriteValue : submittedValue; + stableRollbackFrames = 0; + stableMatchFrames = 0; + }; return ({ currentRoot, currentInput, @@ -1245,39 +1292,44 @@ root = currentRoot; input = currentInput; writeCount = 0; - rollbackValue = null; - recoveryEligible = false; - stableRollbackFrames = 0; + clearRecovery(); + stableMatchFrames = 0; } if (matchesExpected) { - rollbackValue = null; recoveryEligible = false; stableRollbackFrames = 0; - return; + stableMatchFrames += 1; + return stableMatchFrames >= requiredStableMatchFrames; } - if (writeCount === 0) { - writeCount = 1; - writeValue(currentInput, expectedValue); - const postWriteValue = normalizeValue(currentInput.value); - recoveryEligible = compareValues(expectedValue, postWriteValue) !== 0; - rollbackValue = recoveryEligible ? postWriteValue : null; + if (stableMatchFrames > 0) { + stableMatchFrames = 0; + recoveryEligible = rollbackValue === submittedValue && writeCount < maxWriteAttempts; stableRollbackFrames = 0; - return; + if (!recoveryEligible) clearRecovery(); + } + if (writeCount === 0) { + writeExpectedValue(currentInput, expectedValue, submittedValue); + return false; } - if (writeCount >= 2 || !recoveryEligible) return; + if (writeCount >= maxWriteAttempts || !recoveryEligible) return false; if (rollbackValue !== submittedValue) { - rollbackValue = null; - recoveryEligible = false; - stableRollbackFrames = 0; - return; + clearRecovery(); + return false; + } + if (!isRecoveryWriteAllowed({ + currentRoot, + currentInput, + expectedValue, + rollbackValue, + writeCount + })) { + clearRecovery(); + return false; } stableRollbackFrames += 1; - if (stableRollbackFrames < requiredStableMismatchFrames) return; - writeCount = 2; - rollbackValue = null; - recoveryEligible = false; - stableRollbackFrames = 0; - writeValue(currentInput, expectedValue); + if (stableRollbackFrames < requiredStableMismatchFrames) return false; + writeExpectedValue(currentInput, expectedValue, submittedValue); + return false; }; }; const syncQty = createSyncSlot(); @@ -1287,27 +1339,27 @@ if (!inputs?.qtyInput || includePrice && !inputs.priceInput) return null; const submittedQty = normalizeValue(inputs.qtyInput.value); const qtyMatches = compareValues(expectedQty, submittedQty) === 0; - syncQty({ + const qtyStable = syncQty({ currentRoot: inputs.root, currentInput: inputs.qtyInput, expectedValue: expectedQty, submittedValue: submittedQty, matchesExpected: qtyMatches }); - if (!qtyMatches) return null; + if (!qtyMatches || !qtyStable) return null; if (!includePrice) { return { ...inputs, submittedQty }; } const submittedPrice = normalizeValue(inputs.priceInput.value); const priceMatches = compareValues(expectedPrice, submittedPrice) === 0; - syncPrice({ + const priceStable = syncPrice({ currentRoot: inputs.root, currentInput: inputs.priceInput, expectedValue: expectedPrice, submittedValue: submittedPrice, matchesExpected: priceMatches }); - if (!priceMatches) return null; + if (!priceMatches || !priceStable) return null; return { ...inputs, submittedPrice, submittedQty }; }; } @@ -2028,6 +2080,9 @@ const LADDER_OPEN_QTY_READY_TIMEOUT_MS = 1200; const TRADE_INPUT_SYNC_TIMEOUT_MS = 350; const TRADE_INPUT_SYNC_STABLE_FRAMES = 2; + const LADDER_INPUT_SETTLE_TIMEOUT_MS = 1200; + const LADDER_INPUT_SETTLE_MISMATCH_FRAMES = 12; + const LADDER_INPUT_SETTLE_MAX_WRITES = 5; const ORDERBOOK_PRECISION_READY_POLL_MS = 100; const ORDERBOOK_PRECISION_READY_TIMEOUT_MS = 5e3; const ORDERBOOK_PRECISION_OPTION_WAIT_MS = 1200; @@ -3615,6 +3670,15 @@ async function syncTradeInputs(expectedPrice, expectedQty, options = null) { const priceLabel = options?.priceLabel || "点击价"; const qtyLabel = options?.qtyLabel || "目标量"; + const settleControlledForm = options?.settleControlledForm === true; + const syncTimeoutMs = settleControlledForm ? LADDER_INPUT_SETTLE_TIMEOUT_MS : TRADE_INPUT_SYNC_TIMEOUT_MS; + const stableMismatchFrames = settleControlledForm ? LADDER_INPUT_SETTLE_MISMATCH_FRAMES : TRADE_INPUT_SYNC_STABLE_FRAMES; + const maxWriteAttempts = settleControlledForm ? LADDER_INPUT_SETTLE_MAX_WRITES : 2; + const isRecoveryWriteAllowed = settleControlledForm ? ({ rollbackValue }) => rollbackValue === "" : () => true; + const writeTradeInputValue = settleControlledForm ? createBoundedInputWriter({ + writeValue: setInputValueReact, + maxWriteAttempts + }) : setInputValueReact; let inputs = findTradeInputs(); if (!inputs) throw new Error("未找到唯一的当前交易表单输入框"); let observationRoot = inputs.root; @@ -3624,14 +3688,17 @@ includePrice: false, normalizeValue: normalizeDecimalString, compareValues: compareDecimalStrings, - writeValue: setInputValueReact, - requiredStableMismatchFrames: TRADE_INPUT_SYNC_STABLE_FRAMES + writeValue: writeTradeInputValue, + requiredStableMismatchFrames: stableMismatchFrames, + requiredStableMatchFrames: settleControlledForm ? LADDER_INPUT_SETTLE_MISMATCH_FRAMES : 1, + maxWriteAttempts, + isRecoveryWriteAllowed }); readQtyState(); const qtyReady = await waitForTradeFormFrameState( observationRoot, readQtyState, - TRADE_INPUT_SYNC_TIMEOUT_MS, + syncTimeoutMs, TRADE_INPUT_SYNC_STABLE_FRAMES ); if (!qtyReady) { @@ -3648,14 +3715,17 @@ includePrice: true, normalizeValue: normalizeDecimalString, compareValues: compareDecimalStrings, - writeValue: setInputValueReact, - requiredStableMismatchFrames: TRADE_INPUT_SYNC_STABLE_FRAMES + writeValue: writeTradeInputValue, + requiredStableMismatchFrames: stableMismatchFrames, + requiredStableMatchFrames: settleControlledForm ? LADDER_INPUT_SETTLE_MISMATCH_FRAMES : 1, + maxWriteAttempts, + isRecoveryWriteAllowed }); readTradeState(); const synchronized = await waitForTradeFormFrameState( observationRoot, readTradeState, - TRADE_INPUT_SYNC_TIMEOUT_MS, + syncTimeoutMs, TRADE_INPUT_SYNC_STABLE_FRAMES ); if (synchronized) return synchronized; @@ -3777,7 +3847,8 @@ if (!currentPriceInput || !currentQtyInput) throw new Error("执行中价格或数量输入框丢失"); const synchronizedInputs = await syncTradeInputs(order.price, order.qty, { priceLabel: "计划价", - qtyLabel: "计划量" + qtyLabel: "计划量", + settleControlledForm: true }); throwIfAborted(abortSignal); const submittedPrice = synchronizedInputs.submittedPrice; diff --git a/src/binance-orderbook-trade/dom/trade-form.js b/src/binance-orderbook-trade/dom/trade-form.js index 8fc3085..6142615 100644 --- a/src/binance-orderbook-trade/dom/trade-form.js +++ b/src/binance-orderbook-trade/dom/trade-form.js @@ -125,11 +125,30 @@ export function findActiveTradeInputs(ownerDocument, { return matches.length === 1 ? matches[0] : null; } +export function createBoundedInputWriter({ writeValue, maxWriteAttempts }) { + if (typeof writeValue !== 'function') { + throw new Error('Bounded input writer dependency is invalid'); + } + if (!Number.isInteger(maxWriteAttempts) || maxWriteAttempts < 1) { + throw new Error('Trade input write attempts must be a positive integer'); + } + + const attemptsByInput = new WeakMap(); + return (input, value) => { + const attempts = attemptsByInput.get(input) || 0; + if (attempts >= maxWriteAttempts) return false; + attemptsByInput.set(input, attempts + 1); + writeValue(input, value); + return true; + }; +} + /** - * Synchronize each live React input identity with one bounded post-transition write. + * Synchronize each live React input identity with a bounded post-transition budget. * Binance can synchronously restore a controlled input while a replacement form is * settling. Only an identical same-node rollback observed across consecutive frame - * reads earns one final write; a second rejection remains a hard failure. + * reads earns another bounded write. Callers must opt into more than one recovery + * write and define when that recovery remains safe. */ export function createTradeInputStateReader({ resolveInputs, @@ -140,12 +159,16 @@ export function createTradeInputStateReader({ compareValues, writeValue, requiredStableMismatchFrames = 2, + requiredStableMatchFrames = 1, + maxWriteAttempts = 2, + isRecoveryWriteAllowed = () => true, }) { if ( typeof resolveInputs !== 'function' || typeof normalizeValue !== 'function' || typeof compareValues !== 'function' || typeof writeValue !== 'function' + || typeof isRecoveryWriteAllowed !== 'function' ) { throw new Error('Trade input synchronizer dependencies are invalid'); } @@ -155,6 +178,12 @@ export function createTradeInputStateReader({ ) { throw new Error('Trade input rollback stability must be a positive integer'); } + if (!Number.isInteger(requiredStableMatchFrames) || requiredStableMatchFrames < 1) { + throw new Error('Trade input accepted stability must be a positive integer'); + } + if (!Number.isInteger(maxWriteAttempts) || maxWriteAttempts < 1) { + throw new Error('Trade input write attempts must be a positive integer'); + } const createSyncSlot = () => { let root = null; @@ -163,6 +192,30 @@ export function createTradeInputStateReader({ let rollbackValue = null; let recoveryEligible = false; let stableRollbackFrames = 0; + let stableMatchFrames = 0; + + const clearRecovery = () => { + rollbackValue = null; + recoveryEligible = false; + stableRollbackFrames = 0; + }; + + const writeExpectedValue = (currentInput, expectedValue, submittedValue) => { + const wrote = writeValue(currentInput, expectedValue); + if (wrote === false) { + writeCount = maxWriteAttempts; + clearRecovery(); + stableMatchFrames = 0; + return; + } + writeCount += 1; + const postWriteValue = normalizeValue(currentInput.value); + const rejected = compareValues(expectedValue, postWriteValue) !== 0; + recoveryEligible = rejected && writeCount < maxWriteAttempts; + rollbackValue = rejected ? postWriteValue : submittedValue; + stableRollbackFrames = 0; + stableMatchFrames = 0; + }; return ({ currentRoot, @@ -175,44 +228,53 @@ export function createTradeInputStateReader({ root = currentRoot; input = currentInput; writeCount = 0; - rollbackValue = null; - recoveryEligible = false; - stableRollbackFrames = 0; + clearRecovery(); + stableMatchFrames = 0; } if (matchesExpected) { - rollbackValue = null; recoveryEligible = false; stableRollbackFrames = 0; - return; + stableMatchFrames += 1; + return stableMatchFrames >= requiredStableMatchFrames; } - if (writeCount === 0) { - writeCount = 1; - writeValue(currentInput, expectedValue); - const postWriteValue = normalizeValue(currentInput.value); - recoveryEligible = compareValues(expectedValue, postWriteValue) !== 0; - rollbackValue = recoveryEligible ? postWriteValue : null; + if (stableMatchFrames > 0) { + stableMatchFrames = 0; + recoveryEligible = ( + rollbackValue === submittedValue + && writeCount < maxWriteAttempts + ); stableRollbackFrames = 0; - return; + if (!recoveryEligible) clearRecovery(); + } + + if (writeCount === 0) { + writeExpectedValue(currentInput, expectedValue, submittedValue); + return false; } - if (writeCount >= 2 || !recoveryEligible) return; + if (writeCount >= maxWriteAttempts || !recoveryEligible) return false; if (rollbackValue !== submittedValue) { - rollbackValue = null; - recoveryEligible = false; - stableRollbackFrames = 0; - return; + clearRecovery(); + return false; + } + if (!isRecoveryWriteAllowed({ + currentRoot, + currentInput, + expectedValue, + rollbackValue, + writeCount, + })) { + clearRecovery(); + return false; } stableRollbackFrames += 1; - if (stableRollbackFrames < requiredStableMismatchFrames) return; + if (stableRollbackFrames < requiredStableMismatchFrames) return false; - writeCount = 2; - rollbackValue = null; - recoveryEligible = false; - stableRollbackFrames = 0; - writeValue(currentInput, expectedValue); + writeExpectedValue(currentInput, expectedValue, submittedValue); + return false; }; }; @@ -224,14 +286,14 @@ export function createTradeInputStateReader({ const submittedQty = normalizeValue(inputs.qtyInput.value); const qtyMatches = compareValues(expectedQty, submittedQty) === 0; - syncQty({ + const qtyStable = syncQty({ currentRoot: inputs.root, currentInput: inputs.qtyInput, expectedValue: expectedQty, submittedValue: submittedQty, matchesExpected: qtyMatches, }); - if (!qtyMatches) return null; + if (!qtyMatches || !qtyStable) return null; if (!includePrice) { return { ...inputs, submittedQty }; @@ -239,14 +301,14 @@ export function createTradeInputStateReader({ const submittedPrice = normalizeValue(inputs.priceInput.value); const priceMatches = compareValues(expectedPrice, submittedPrice) === 0; - syncPrice({ + const priceStable = syncPrice({ currentRoot: inputs.root, currentInput: inputs.priceInput, expectedValue: expectedPrice, submittedValue: submittedPrice, matchesExpected: priceMatches, }); - if (!priceMatches) return null; + if (!priceMatches || !priceStable) return null; return { ...inputs, submittedPrice, submittedQty }; }; diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index ff5b415..f6841fb 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.135 +// @version 2.7.136 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -115,6 +115,7 @@ import { import { calculateFloatingPanelLayout, collectTradeButtonsFromScopes, + createBoundedInputWriter, createTradeInputStateReader, findActiveTradeInputs as findActiveTradeInputsDom, findTradeFormRoot, @@ -273,6 +274,9 @@ import { const LADDER_OPEN_QTY_READY_TIMEOUT_MS = 1200; const TRADE_INPUT_SYNC_TIMEOUT_MS = 350; const TRADE_INPUT_SYNC_STABLE_FRAMES = 2; + const LADDER_INPUT_SETTLE_TIMEOUT_MS = 1200; + const LADDER_INPUT_SETTLE_MISMATCH_FRAMES = 12; + const LADDER_INPUT_SETTLE_MAX_WRITES = 5; const ORDERBOOK_PRECISION_READY_POLL_MS = 100; const ORDERBOOK_PRECISION_READY_TIMEOUT_MS = 5000; const ORDERBOOK_PRECISION_OPTION_WAIT_MS = 1200; @@ -2233,6 +2237,25 @@ import { async function syncTradeInputs(expectedPrice, expectedQty, options = null) { const priceLabel = options?.priceLabel || '点击价'; const qtyLabel = options?.qtyLabel || '目标量'; + const settleControlledForm = options?.settleControlledForm === true; + const syncTimeoutMs = settleControlledForm + ? LADDER_INPUT_SETTLE_TIMEOUT_MS + : TRADE_INPUT_SYNC_TIMEOUT_MS; + const stableMismatchFrames = settleControlledForm + ? LADDER_INPUT_SETTLE_MISMATCH_FRAMES + : TRADE_INPUT_SYNC_STABLE_FRAMES; + const maxWriteAttempts = settleControlledForm + ? LADDER_INPUT_SETTLE_MAX_WRITES + : 2; + const isRecoveryWriteAllowed = settleControlledForm + ? ({ rollbackValue }) => rollbackValue === '' + : () => true; + const writeTradeInputValue = settleControlledForm + ? createBoundedInputWriter({ + writeValue: setInputValueReact, + maxWriteAttempts, + }) + : setInputValueReact; let inputs = findTradeInputs(); if (!inputs) throw new Error('未找到唯一的当前交易表单输入框'); @@ -2243,14 +2266,19 @@ import { includePrice: false, normalizeValue: normalizeDecimalString, compareValues: compareDecimalStrings, - writeValue: setInputValueReact, - requiredStableMismatchFrames: TRADE_INPUT_SYNC_STABLE_FRAMES, + writeValue: writeTradeInputValue, + requiredStableMismatchFrames: stableMismatchFrames, + requiredStableMatchFrames: settleControlledForm + ? LADDER_INPUT_SETTLE_MISMATCH_FRAMES + : 1, + maxWriteAttempts, + isRecoveryWriteAllowed, }); readQtyState(); const qtyReady = await waitForTradeFormFrameState( observationRoot, readQtyState, - TRADE_INPUT_SYNC_TIMEOUT_MS, + syncTimeoutMs, TRADE_INPUT_SYNC_STABLE_FRAMES, ); if (!qtyReady) { @@ -2268,14 +2296,19 @@ import { includePrice: true, normalizeValue: normalizeDecimalString, compareValues: compareDecimalStrings, - writeValue: setInputValueReact, - requiredStableMismatchFrames: TRADE_INPUT_SYNC_STABLE_FRAMES, + writeValue: writeTradeInputValue, + requiredStableMismatchFrames: stableMismatchFrames, + requiredStableMatchFrames: settleControlledForm + ? LADDER_INPUT_SETTLE_MISMATCH_FRAMES + : 1, + maxWriteAttempts, + isRecoveryWriteAllowed, }); readTradeState(); const synchronized = await waitForTradeFormFrameState( observationRoot, readTradeState, - TRADE_INPUT_SYNC_TIMEOUT_MS, + syncTimeoutMs, TRADE_INPUT_SYNC_STABLE_FRAMES, ); if (synchronized) return synchronized; @@ -2431,6 +2464,7 @@ import { const synchronizedInputs = await syncTradeInputs(order.price, order.qty, { priceLabel: '计划价', qtyLabel: '计划量', + settleControlledForm: true, }); throwIfAborted(abortSignal); const submittedPrice = synchronizedInputs.submittedPrice; diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 423a4b0..370dcf0 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -149,12 +149,17 @@ test('trade input synchronization confirms live controlled values instead of sle const executeBody = readFunctionBody('executeLadderPlan'); assert.match(syncBody, /createTradeInputStateReader/); + assert.match(syncBody, /createBoundedInputWriter/); assert.match(syncBody, /resolveInputs:\s*findTradeInputs/); - assert.match(syncBody, /writeValue:\s*setInputValueReact/); + assert.equal((syncBody.match(/writeValue:\s*writeTradeInputValue/g) || []).length, 2); assert.equal( - (syncBody.match(/requiredStableMismatchFrames:\s*TRADE_INPUT_SYNC_STABLE_FRAMES/g) || []).length, + (syncBody.match(/requiredStableMismatchFrames:\s*stableMismatchFrames/g) || []).length, 2, ); + assert.equal((syncBody.match(/maxWriteAttempts,/g) || []).length, 3); + assert.equal((syncBody.match(/isRecoveryWriteAllowed,/g) || []).length, 2); + assert.equal((syncBody.match(/requiredStableMatchFrames:/g) || []).length, 2); + assert.match(syncBody, /settleControlledForm[\s\S]*LADDER_INPUT_SETTLE_MISMATCH_FRAMES/); assert.match(syncBody, /waitForTradeFormFrameState/); assert.match(syncBody, /includePrice:\s*false/); assert.match(syncBody, /includePrice:\s*true/); @@ -164,9 +169,11 @@ test('trade input synchronization confirms live controlled values instead of sle assert.match(syncBody, /assertSubmittedPriceMatchesExpectedPrice/); assert.match(syncBody, /assertSubmittedQtyMatchesExpectedQty/); assert.doesNotMatch(syncBody, /delay\(/); - assert.match(executeBody, /syncTradeInputs\(order\.price,\s*order\.qty,\s*\{[\s\S]*priceLabel:\s*'计划价'/); + assert.match(executeBody, /syncTradeInputs\(order\.price,\s*order\.qty,\s*\{[\s\S]*priceLabel:\s*'计划价'[\s\S]*settleControlledForm:\s*true/); assert.doesNotMatch(executeBody, /await delay\(90\)|await delay\(120\)/); - assert.match(source, /syncTradeInputs\(clickedPrice,\s*qtyPlan\.qty,\s*\{[\s\S]*priceLabel:\s*'点击价'/); + const clickSyncCall = source.match(/syncTradeInputs\(clickedPrice,\s*qtyPlan\.qty,\s*\{[\s\S]*?\}\);/)?.[0] || ''; + assert.match(clickSyncCall, /priceLabel:\s*'点击价'/); + assert.doesNotMatch(clickSyncCall, /settleControlledForm/); }); test('labeled quantity matching resets its global regexp for every DOM node', () => { diff --git a/test/unit/binance-orderbook-trade/trade-form.test.js b/test/unit/binance-orderbook-trade/trade-form.test.js index b0c069d..ed35195 100644 --- a/test/unit/binance-orderbook-trade/trade-form.test.js +++ b/test/unit/binance-orderbook-trade/trade-form.test.js @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { calculateFloatingPanelLayout, + createBoundedInputWriter, createTradeInputStateReader, findActiveTradeInputs, findTradeFormRoot, @@ -382,6 +383,100 @@ test('trade input synchronization performs one post-transition write after a sta assert.deepEqual(writes.map(({ value }) => value), ['0.01', '0.01', '81.9']); }); +test('trade input synchronization can settle a ladder input after repeated stable same-node rollbacks', () => { + const currentInputs = { + root: {}, + priceInput: null, + qtyInput: { value: '' }, + }; + const writes = []; + let qtyWrites = 0; + const readState = createTradeInputStateReader({ + resolveInputs: () => currentInputs, + expectedQty: '0.02', + includePrice: false, + normalizeValue: String, + compareValues: (expected, actual) => expected === actual ? 0 : 1, + writeValue: (input, value) => { + writes.push({ input, value }); + qtyWrites += 1; + input.value = qtyWrites < 3 ? '' : value; + }, + requiredStableMismatchFrames: 2, + maxWriteAttempts: 5, + isRecoveryWriteAllowed: ({ currentInput, rollbackValue }) => ( + currentInput === currentInputs.qtyInput && rollbackValue === '' + ), + }); + + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.deepEqual(readState(), { + ...currentInputs, + submittedQty: '0.02', + }); + assert.deepEqual(writes.map(({ value }) => value), ['0.02', '0.02', '0.02']); +}); + +test('trade input synchronization rejects a provisionally accepted value that rolls back before settling', () => { + const currentInputs = { + root: {}, + priceInput: null, + qtyInput: { value: '' }, + }; + const writes = []; + const readState = createTradeInputStateReader({ + resolveInputs: () => currentInputs, + expectedQty: '0.19', + includePrice: false, + normalizeValue: String, + compareValues: (expected, actual) => expected === actual ? 0 : 1, + writeValue: (input, value) => { + writes.push({ input, value }); + input.value = value; + }, + requiredStableMismatchFrames: 2, + requiredStableMatchFrames: 3, + maxWriteAttempts: 5, + isRecoveryWriteAllowed: ({ rollbackValue }) => rollbackValue === '', + }); + + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(readState(), null); + currentInputs.qtyInput.value = ''; + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.deepEqual(readState(), { + ...currentInputs, + submittedQty: '0.19', + }); + assert.deepEqual(writes.map(({ value }) => value), ['0.19', '0.19']); +}); + +test('bounded input writer shares one total budget for each input identity', () => { + const writes = []; + const writeValue = createBoundedInputWriter({ + writeValue: (input, value) => writes.push({ input, value }), + maxWriteAttempts: 3, + }); + const input = {}; + const replacement = {}; + + assert.equal(writeValue(input, '0.01'), true); + assert.equal(writeValue(input, '0.02'), true); + assert.equal(writeValue(input, '0.03'), true); + assert.equal(writeValue(input, '0.04'), false); + assert.equal(writeValue(input, '0.05'), false); + assert.equal(writeValue(replacement, '0.06'), true); + assert.deepEqual(writes.map(({ value }) => value), ['0.01', '0.02', '0.03', '0.06']); +}); + test('trade input synchronization remains fail-closed after the post-transition write is rejected', () => { const currentInputs = { root: {}, @@ -409,6 +504,34 @@ test('trade input synchronization remains fail-closed after the post-transition assert.deepEqual(writes.map(({ value }) => value), ['0.01', '0.01']); }); +test('trade input synchronization never exceeds an expanded write-attempt budget', () => { + const currentInputs = { + root: {}, + priceInput: null, + qtyInput: { value: '' }, + }; + const writes = []; + const readState = createTradeInputStateReader({ + resolveInputs: () => currentInputs, + expectedQty: '0.02', + includePrice: false, + normalizeValue: String, + compareValues: (expected, actual) => expected === actual ? 0 : 1, + writeValue: (input, value) => { + writes.push({ input, value }); + input.value = ''; + }, + requiredStableMismatchFrames: 1, + maxWriteAttempts: 3, + isRecoveryWriteAllowed: ({ rollbackValue }) => rollbackValue === '', + }); + + for (let frame = 0; frame < 10; frame += 1) { + assert.equal(readState(), null); + } + assert.deepEqual(writes.map(({ value }) => value), ['0.02', '0.02', '0.02']); +}); + test('trade input synchronization rejects an invalid rollback stability contract', () => { assert.throws( () => createTradeInputStateReader({ @@ -422,6 +545,58 @@ test('trade input synchronization rejects an invalid rollback stability contract }), /Trade input rollback stability must be a positive integer/, ); + assert.throws( + () => createTradeInputStateReader({ + resolveInputs: () => null, + expectedQty: '0.01', + includePrice: false, + normalizeValue: String, + compareValues: () => 1, + writeValue: () => {}, + maxWriteAttempts: 0, + }), + /Trade input write attempts must be a positive integer/, + ); + assert.throws( + () => createTradeInputStateReader({ + resolveInputs: () => null, + expectedQty: '0.01', + includePrice: false, + normalizeValue: String, + compareValues: () => 1, + writeValue: () => {}, + requiredStableMatchFrames: 0, + }), + /Trade input accepted stability must be a positive integer/, + ); +}); + +test('trade input synchronization stops when the recovery policy rejects the rollback', () => { + const currentInputs = { + root: {}, + priceInput: null, + qtyInput: { value: '' }, + }; + const writes = []; + const readState = createTradeInputStateReader({ + resolveInputs: () => currentInputs, + expectedQty: '0.02', + includePrice: false, + normalizeValue: String, + compareValues: (expected, actual) => expected === actual ? 0 : 1, + writeValue: (input, value) => { + writes.push({ input, value }); + input.value = ''; + }, + maxWriteAttempts: 5, + isRecoveryWriteAllowed: () => false, + }); + + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.deepEqual(writes.map(({ value }) => value), ['0.02']); }); test('trade input synchronization cancels the post-transition write when the rollback value changes', () => {