diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index b3f8669..64b47d3 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.136 +// @version 2.7.137 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -1238,6 +1238,7 @@ requiredStableMismatchFrames = 2, requiredStableMatchFrames = 1, maxWriteAttempts = 2, + recoverProvisionalMatchRollback = false, isRecoveryWriteAllowed = () => true }) { if (typeof resolveInputs !== "function" || typeof normalizeValue !== "function" || typeof compareValues !== "function" || typeof writeValue !== "function" || typeof isRecoveryWriteAllowed !== "function") { @@ -1252,6 +1253,9 @@ if (!Number.isInteger(maxWriteAttempts) || maxWriteAttempts < 1) { throw new Error("Trade input write attempts must be a positive integer"); } + if (typeof recoverProvisionalMatchRollback !== "boolean") { + throw new Error("Provisional trade input recovery flag must be boolean"); + } const createSyncSlot = () => { let root = null; let input = null; @@ -1276,7 +1280,7 @@ writeCount += 1; const postWriteValue = normalizeValue(currentInput.value); const rejected = compareValues(expectedValue, postWriteValue) !== 0; - recoveryEligible = rejected && writeCount < maxWriteAttempts; + recoveryEligible = writeCount < maxWriteAttempts && (rejected || recoverProvisionalMatchRollback); rollbackValue = rejected ? postWriteValue : submittedValue; stableRollbackFrames = 0; stableMatchFrames = 0; @@ -3692,6 +3696,7 @@ requiredStableMismatchFrames: stableMismatchFrames, requiredStableMatchFrames: settleControlledForm ? LADDER_INPUT_SETTLE_MISMATCH_FRAMES : 1, maxWriteAttempts, + recoverProvisionalMatchRollback: settleControlledForm, isRecoveryWriteAllowed }); readQtyState(); @@ -3719,6 +3724,7 @@ requiredStableMismatchFrames: stableMismatchFrames, requiredStableMatchFrames: settleControlledForm ? LADDER_INPUT_SETTLE_MISMATCH_FRAMES : 1, maxWriteAttempts, + recoverProvisionalMatchRollback: settleControlledForm, isRecoveryWriteAllowed }); readTradeState(); diff --git a/src/binance-orderbook-trade/dom/trade-form.js b/src/binance-orderbook-trade/dom/trade-form.js index 6142615..2ede08e 100644 --- a/src/binance-orderbook-trade/dom/trade-form.js +++ b/src/binance-orderbook-trade/dom/trade-form.js @@ -146,9 +146,10 @@ export function createBoundedInputWriter({ writeValue, maxWriteAttempts }) { /** * 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 another bounded write. Callers must opt into more than one recovery - * write and define when that recovery remains safe. + * settling. A caller may preserve the pre-write value as a provisional rollback + * contract because React can restore it after the write returns but before the + * first frame observation. Only an identical same-node rollback observed across + * consecutive frame reads earns another bounded write. */ export function createTradeInputStateReader({ resolveInputs, @@ -161,6 +162,7 @@ export function createTradeInputStateReader({ requiredStableMismatchFrames = 2, requiredStableMatchFrames = 1, maxWriteAttempts = 2, + recoverProvisionalMatchRollback = false, isRecoveryWriteAllowed = () => true, }) { if ( @@ -184,6 +186,9 @@ export function createTradeInputStateReader({ if (!Number.isInteger(maxWriteAttempts) || maxWriteAttempts < 1) { throw new Error('Trade input write attempts must be a positive integer'); } + if (typeof recoverProvisionalMatchRollback !== 'boolean') { + throw new Error('Provisional trade input recovery flag must be boolean'); + } const createSyncSlot = () => { let root = null; @@ -211,7 +216,10 @@ export function createTradeInputStateReader({ writeCount += 1; const postWriteValue = normalizeValue(currentInput.value); const rejected = compareValues(expectedValue, postWriteValue) !== 0; - recoveryEligible = rejected && writeCount < maxWriteAttempts; + recoveryEligible = ( + writeCount < maxWriteAttempts + && (rejected || recoverProvisionalMatchRollback) + ); rollbackValue = rejected ? postWriteValue : submittedValue; stableRollbackFrames = 0; stableMatchFrames = 0; diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index f6841fb..b5815a0 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.136 +// @version 2.7.137 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -2272,6 +2272,7 @@ import { ? LADDER_INPUT_SETTLE_MISMATCH_FRAMES : 1, maxWriteAttempts, + recoverProvisionalMatchRollback: settleControlledForm, isRecoveryWriteAllowed, }); readQtyState(); @@ -2302,6 +2303,7 @@ import { ? LADDER_INPUT_SETTLE_MISMATCH_FRAMES : 1, maxWriteAttempts, + recoverProvisionalMatchRollback: settleControlledForm, isRecoveryWriteAllowed, }); readTradeState(); diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 370dcf0..4c13176 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -159,6 +159,10 @@ test('trade input synchronization confirms live controlled values instead of sle 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.equal( + (syncBody.match(/recoverProvisionalMatchRollback:\s*settleControlledForm/g) || []).length, + 2, + ); assert.match(syncBody, /settleControlledForm[\s\S]*LADDER_INPUT_SETTLE_MISMATCH_FRAMES/); assert.match(syncBody, /waitForTradeFormFrameState/); assert.match(syncBody, /includePrice:\s*false/); diff --git a/test/unit/binance-orderbook-trade/trade-form.test.js b/test/unit/binance-orderbook-trade/trade-form.test.js index ed35195..71ba28e 100644 --- a/test/unit/binance-orderbook-trade/trade-form.test.js +++ b/test/unit/binance-orderbook-trade/trade-form.test.js @@ -421,6 +421,75 @@ test('trade input synchronization can settle a ladder input after repeated stabl assert.deepEqual(writes.map(({ value }) => value), ['0.02', '0.02', '0.02']); }); +test('trade input synchronization recovers a provisional match rolled back before the first observation frame', () => { + const currentInputs = { + root: {}, + priceInput: null, + qtyInput: { value: '' }, + }; + const writes = []; + const readState = createTradeInputStateReader({ + resolveInputs: () => currentInputs, + expectedQty: '0.07', + includePrice: false, + normalizeValue: String, + compareValues: (expected, actual) => expected === actual ? 0 : 1, + writeValue: (input, value) => { + writes.push({ input, value }); + input.value = value; + }, + requiredStableMismatchFrames: 2, + requiredStableMatchFrames: 2, + maxWriteAttempts: 5, + recoverProvisionalMatchRollback: true, + isRecoveryWriteAllowed: ({ rollbackValue }) => rollbackValue === '', + }); + + assert.equal(readState(), null); + currentInputs.qtyInput.value = ''; + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.equal(readState(), null); + assert.deepEqual(readState(), { + ...currentInputs, + submittedQty: '0.07', + }); + assert.deepEqual(writes.map(({ value }) => value), ['0.07', '0.07']); +}); + +test('trade input synchronization cancels provisional recovery for a different non-empty value', () => { + const currentInputs = { + root: {}, + priceInput: null, + qtyInput: { value: '' }, + }; + const writes = []; + const readState = createTradeInputStateReader({ + resolveInputs: () => currentInputs, + expectedQty: '0.07', + includePrice: false, + normalizeValue: String, + compareValues: (expected, actual) => expected === actual ? 0 : 1, + writeValue: (input, value) => { + writes.push({ input, value }); + input.value = value; + }, + requiredStableMismatchFrames: 2, + requiredStableMatchFrames: 2, + maxWriteAttempts: 5, + recoverProvisionalMatchRollback: true, + isRecoveryWriteAllowed: ({ rollbackValue }) => rollbackValue === '', + }); + + assert.equal(readState(), null); + currentInputs.qtyInput.value = '0.005'; + for (let frame = 0; frame < 5; frame += 1) { + assert.equal(readState(), null); + } + assert.equal(currentInputs.qtyInput.value, '0.005'); + assert.deepEqual(writes.map(({ value }) => value), ['0.07']); +}); + test('trade input synchronization rejects a provisionally accepted value that rolls back before settling', () => { const currentInputs = { root: {}, @@ -569,6 +638,18 @@ test('trade input synchronization rejects an invalid rollback stability contract }), /Trade input accepted stability must be a positive integer/, ); + assert.throws( + () => createTradeInputStateReader({ + resolveInputs: () => null, + expectedQty: '0.01', + includePrice: false, + normalizeValue: String, + compareValues: () => 1, + writeValue: () => {}, + recoverProvisionalMatchRollback: 'yes', + }), + /Provisional trade input recovery flag must be boolean/, + ); }); test('trade input synchronization stops when the recovery policy rejects the rollback', () => {