diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index e00b53e..32ad718 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.134 +// @version 2.7.135 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -1218,35 +1218,96 @@ includePrice, normalizeValue, compareValues, - writeValue + writeValue, + requiredStableMismatchFrames = 2 }) { if (typeof resolveInputs !== "function" || typeof normalizeValue !== "function" || typeof compareValues !== "function" || typeof writeValue !== "function") { throw new Error("Trade input synchronizer dependencies are invalid"); } - const writtenQtyInputs = /* @__PURE__ */ new WeakSet(); - const writtenPriceInputs = /* @__PURE__ */ new WeakSet(); + if (!Number.isInteger(requiredStableMismatchFrames) || requiredStableMismatchFrames < 1) { + throw new Error("Trade input rollback stability must be a positive integer"); + } + const createSyncSlot = () => { + let root = null; + let input = null; + let writeCount = 0; + let rollbackValue = null; + let recoveryEligible = false; + let stableRollbackFrames = 0; + return ({ + currentRoot, + currentInput, + expectedValue, + submittedValue, + matchesExpected + }) => { + if (root !== currentRoot || input !== currentInput) { + root = currentRoot; + input = currentInput; + writeCount = 0; + rollbackValue = null; + recoveryEligible = false; + stableRollbackFrames = 0; + } + if (matchesExpected) { + rollbackValue = null; + recoveryEligible = false; + stableRollbackFrames = 0; + return; + } + if (writeCount === 0) { + writeCount = 1; + writeValue(currentInput, expectedValue); + const postWriteValue = normalizeValue(currentInput.value); + recoveryEligible = compareValues(expectedValue, postWriteValue) !== 0; + rollbackValue = recoveryEligible ? postWriteValue : null; + stableRollbackFrames = 0; + return; + } + if (writeCount >= 2 || !recoveryEligible) return; + if (rollbackValue !== submittedValue) { + rollbackValue = null; + recoveryEligible = false; + stableRollbackFrames = 0; + return; + } + stableRollbackFrames += 1; + if (stableRollbackFrames < requiredStableMismatchFrames) return; + writeCount = 2; + rollbackValue = null; + recoveryEligible = false; + stableRollbackFrames = 0; + writeValue(currentInput, expectedValue); + }; + }; + const syncQty = createSyncSlot(); + const syncPrice = createSyncSlot(); return () => { const inputs = resolveInputs(); if (!inputs?.qtyInput || includePrice && !inputs.priceInput) return null; const submittedQty = normalizeValue(inputs.qtyInput.value); - if (compareValues(expectedQty, submittedQty) !== 0) { - if (!writtenQtyInputs.has(inputs.qtyInput)) { - writtenQtyInputs.add(inputs.qtyInput); - writeValue(inputs.qtyInput, expectedQty); - } - return null; - } + const qtyMatches = compareValues(expectedQty, submittedQty) === 0; + syncQty({ + currentRoot: inputs.root, + currentInput: inputs.qtyInput, + expectedValue: expectedQty, + submittedValue: submittedQty, + matchesExpected: qtyMatches + }); + if (!qtyMatches) return null; if (!includePrice) { return { ...inputs, submittedQty }; } const submittedPrice = normalizeValue(inputs.priceInput.value); - if (compareValues(expectedPrice, submittedPrice) !== 0) { - if (!writtenPriceInputs.has(inputs.priceInput)) { - writtenPriceInputs.add(inputs.priceInput); - writeValue(inputs.priceInput, expectedPrice); - } - return null; - } + const priceMatches = compareValues(expectedPrice, submittedPrice) === 0; + syncPrice({ + currentRoot: inputs.root, + currentInput: inputs.priceInput, + expectedValue: expectedPrice, + submittedValue: submittedPrice, + matchesExpected: priceMatches + }); + if (!priceMatches) return null; return { ...inputs, submittedPrice, submittedQty }; }; } @@ -3563,7 +3624,8 @@ includePrice: false, normalizeValue: normalizeDecimalString, compareValues: compareDecimalStrings, - writeValue: setInputValueReact + writeValue: setInputValueReact, + requiredStableMismatchFrames: TRADE_INPUT_SYNC_STABLE_FRAMES }); readQtyState(); const qtyReady = await waitForTradeFormFrameState( @@ -3586,7 +3648,8 @@ includePrice: true, normalizeValue: normalizeDecimalString, compareValues: compareDecimalStrings, - writeValue: setInputValueReact + writeValue: setInputValueReact, + requiredStableMismatchFrames: TRADE_INPUT_SYNC_STABLE_FRAMES }); readTradeState(); const synchronized = await waitForTradeFormFrameState( diff --git a/src/binance-orderbook-trade/dom/trade-form.js b/src/binance-orderbook-trade/dom/trade-form.js index a6d3f98..8fc3085 100644 --- a/src/binance-orderbook-trade/dom/trade-form.js +++ b/src/binance-orderbook-trade/dom/trade-form.js @@ -126,9 +126,10 @@ export function findActiveTradeInputs(ownerDocument, { } /** - * Synchronize each live React input identity at most once. A replacement node is - * a defined Binance render transition and must receive the intended value again; - * a value rejected by the same node remains a hard failure instead of a retry loop. + * Synchronize each live React input identity with one bounded post-transition write. + * 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. */ export function createTradeInputStateReader({ resolveInputs, @@ -138,6 +139,7 @@ export function createTradeInputStateReader({ normalizeValue, compareValues, writeValue, + requiredStableMismatchFrames = 2, }) { if ( typeof resolveInputs !== 'function' @@ -147,34 +149,104 @@ export function createTradeInputStateReader({ ) { 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'); + } + + const createSyncSlot = () => { + let root = null; + let input = null; + let writeCount = 0; + let rollbackValue = null; + let recoveryEligible = false; + let stableRollbackFrames = 0; + + return ({ + currentRoot, + currentInput, + expectedValue, + submittedValue, + matchesExpected, + }) => { + if (root !== currentRoot || input !== currentInput) { + root = currentRoot; + input = currentInput; + writeCount = 0; + rollbackValue = null; + recoveryEligible = false; + stableRollbackFrames = 0; + } + + if (matchesExpected) { + rollbackValue = null; + recoveryEligible = false; + stableRollbackFrames = 0; + return; + } + + if (writeCount === 0) { + writeCount = 1; + writeValue(currentInput, expectedValue); + const postWriteValue = normalizeValue(currentInput.value); + recoveryEligible = compareValues(expectedValue, postWriteValue) !== 0; + rollbackValue = recoveryEligible ? postWriteValue : null; + stableRollbackFrames = 0; + return; + } + if (writeCount >= 2 || !recoveryEligible) return; + + if (rollbackValue !== submittedValue) { + rollbackValue = null; + recoveryEligible = false; + stableRollbackFrames = 0; + return; + } - const writtenQtyInputs = new WeakSet(); - const writtenPriceInputs = new WeakSet(); + stableRollbackFrames += 1; + if (stableRollbackFrames < requiredStableMismatchFrames) return; + + writeCount = 2; + rollbackValue = null; + recoveryEligible = false; + stableRollbackFrames = 0; + writeValue(currentInput, expectedValue); + }; + }; + + const syncQty = createSyncSlot(); + const syncPrice = createSyncSlot(); return () => { const inputs = resolveInputs(); if (!inputs?.qtyInput || (includePrice && !inputs.priceInput)) return null; const submittedQty = normalizeValue(inputs.qtyInput.value); - if (compareValues(expectedQty, submittedQty) !== 0) { - if (!writtenQtyInputs.has(inputs.qtyInput)) { - writtenQtyInputs.add(inputs.qtyInput); - writeValue(inputs.qtyInput, expectedQty); - } - return null; - } + const qtyMatches = compareValues(expectedQty, submittedQty) === 0; + syncQty({ + currentRoot: inputs.root, + currentInput: inputs.qtyInput, + expectedValue: expectedQty, + submittedValue: submittedQty, + matchesExpected: qtyMatches, + }); + if (!qtyMatches) return null; if (!includePrice) { return { ...inputs, submittedQty }; } const submittedPrice = normalizeValue(inputs.priceInput.value); - if (compareValues(expectedPrice, submittedPrice) !== 0) { - if (!writtenPriceInputs.has(inputs.priceInput)) { - writtenPriceInputs.add(inputs.priceInput); - writeValue(inputs.priceInput, expectedPrice); - } - return null; - } + const priceMatches = compareValues(expectedPrice, submittedPrice) === 0; + syncPrice({ + currentRoot: inputs.root, + currentInput: inputs.priceInput, + expectedValue: expectedPrice, + submittedValue: submittedPrice, + matchesExpected: priceMatches, + }); + if (!priceMatches) 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 2ff7375..ff5b415 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.134 +// @version 2.7.135 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -2244,6 +2244,7 @@ import { normalizeValue: normalizeDecimalString, compareValues: compareDecimalStrings, writeValue: setInputValueReact, + requiredStableMismatchFrames: TRADE_INPUT_SYNC_STABLE_FRAMES, }); readQtyState(); const qtyReady = await waitForTradeFormFrameState( @@ -2268,6 +2269,7 @@ import { normalizeValue: normalizeDecimalString, compareValues: compareDecimalStrings, writeValue: setInputValueReact, + requiredStableMismatchFrames: TRADE_INPUT_SYNC_STABLE_FRAMES, }); readTradeState(); const synchronized = await waitForTradeFormFrameState( diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 581ec73..423a4b0 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -151,6 +151,10 @@ test('trade input synchronization confirms live controlled values instead of sle assert.match(syncBody, /createTradeInputStateReader/); assert.match(syncBody, /resolveInputs:\s*findTradeInputs/); assert.match(syncBody, /writeValue:\s*setInputValueReact/); + assert.equal( + (syncBody.match(/requiredStableMismatchFrames:\s*TRADE_INPUT_SYNC_STABLE_FRAMES/g) || []).length, + 2, + ); assert.match(syncBody, /waitForTradeFormFrameState/); assert.match(syncBody, /includePrice:\s*false/); assert.match(syncBody, /includePrice:\s*true/); diff --git a/test/unit/binance-orderbook-trade/trade-form.test.js b/test/unit/binance-orderbook-trade/trade-form.test.js index bec2566..b0c069d 100644 --- a/test/unit/binance-orderbook-trade/trade-form.test.js +++ b/test/unit/binance-orderbook-trade/trade-form.test.js @@ -342,8 +342,121 @@ test('active trade form can be resolved before a limit-price input is rendered', assert.equal(form?.priceInput, null); }); -test('trade input synchronization writes each replacement identity once', () => { +test('trade input synchronization performs one post-transition write after a stable same-node rollback', () => { + const currentInputs = { + root: {}, + priceInput: { value: '' }, + qtyInput: { value: '' }, + }; + const writes = []; + let qtyWrites = 0; + const readState = createTradeInputStateReader({ + resolveInputs: () => currentInputs, + expectedPrice: '81.9', + expectedQty: '0.01', + includePrice: true, + normalizeValue: String, + compareValues: (expected, actual) => expected === actual ? 0 : 1, + writeValue: (input, value) => { + writes.push({ input, value }); + if (input === currentInputs.qtyInput) { + qtyWrites += 1; + input.value = qtyWrites === 1 ? '' : value; + return; + } + input.value = value; + }, + }); + + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(writes.length, 1); + assert.equal(readState(), null); + assert.deepEqual(writes.map(({ value }) => value), ['0.01', '0.01']); + assert.equal(readState(), null); + assert.deepEqual(readState(), { + ...currentInputs, + submittedPrice: '81.9', + submittedQty: '0.01', + }); + assert.deepEqual(writes.map(({ value }) => value), ['0.01', '0.01', '81.9']); +}); + +test('trade input synchronization remains fail-closed after the post-transition write is rejected', () => { + const currentInputs = { + root: {}, + priceInput: null, + qtyInput: { value: '' }, + }; + const writes = []; + const readState = createTradeInputStateReader({ + resolveInputs: () => currentInputs, + expectedQty: '0.01', + includePrice: false, + normalizeValue: String, + compareValues: (expected, actual) => expected === actual ? 0 : 1, + writeValue: (input, value) => { + writes.push({ input, value }); + input.value = ''; + }, + }); + + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.deepEqual(writes.map(({ value }) => value), ['0.01', '0.01']); +}); + +test('trade input synchronization rejects an invalid rollback stability contract', () => { + assert.throws( + () => createTradeInputStateReader({ + resolveInputs: () => null, + expectedQty: '0.01', + includePrice: false, + normalizeValue: String, + compareValues: (expected, actual) => expected === actual ? 0 : 1, + writeValue: () => {}, + requiredStableMismatchFrames: 0, + }), + /Trade input rollback stability must be a positive integer/, + ); +}); + +test('trade input synchronization cancels the post-transition write when the rollback value changes', () => { + const currentInputs = { + root: {}, + priceInput: null, + qtyInput: { value: '' }, + }; + const writes = []; + const readState = createTradeInputStateReader({ + resolveInputs: () => currentInputs, + expectedQty: '0.01', + includePrice: false, + normalizeValue: String, + compareValues: (expected, actual) => expected === actual ? 0 : 1, + writeValue: (input, value) => { + writes.push({ input, value }); + input.value = writes.length === 1 ? '' : value; + }, + }); + + assert.equal(readState(), null); + assert.equal(readState(), null); + currentInputs.qtyInput.value = '0.005'; + assert.equal(readState(), null); + assert.equal(writes.length, 1); + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(currentInputs.qtyInput.value, '0.005'); + assert.deepEqual(writes.map(({ value }) => value), ['0.01']); +}); + +test('trade input synchronization gives each replacement identity an independent write budget', () => { let currentInputs = { + root: {}, priceInput: { value: '' }, qtyInput: { value: '' }, }; @@ -370,6 +483,7 @@ test('trade input synchronization writes each replacement identity once', () => }); currentInputs = { + root: {}, priceInput: { value: '' }, qtyInput: { value: '' }, }; @@ -381,10 +495,6 @@ test('trade input synchronization writes each replacement identity once', () => submittedQty: '0.01', }); assert.deepEqual(writes.map(({ value }) => value), ['0.01', '81.9', '0.01', '81.9']); - - currentInputs.qtyInput.value = ''; - assert.equal(readState(), null); - assert.equal(writes.length, 4); }); test('recognizes only close-quantity mutations as a confirmed close snapshot', async () => {