From 7aea2cf3cd812154af0c23b7a0b0a9ba35c5d49f Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:10:28 +0800 Subject: [PATCH] fix: synchronize RPI depth from snapshot predecessor --- docs/binance-orderbook-trade-development.md | 2 +- scripts/binance-orderbook-trade.user.js | 13 +++-- .../core/depth-profile-book.js | 17 ++++-- src/binance-orderbook-trade/index.user.js | 2 +- .../binance-native-depth-source.test.js | 21 +++++++ .../depth-profile-book.test.js | 55 +++++++++++++++++++ 6 files changed, 100 insertions(+), 10 deletions(-) diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md index 8ab415e..856da45 100644 --- a/docs/binance-orderbook-trade-development.md +++ b/docs/binance-orderbook-trade-development.md @@ -140,7 +140,7 @@ Host synchronization passes its already-validated geometry directly into the synchronous paint. A separately scheduled render still obtains a fresh geometry; no snapshot is retained across frames or scale changes. -The userscript installs `core/binance-native-depth-source.js` at `document-start` and passively observes the native `/fapi/v1/rpiDepth?limit=1000` response and `{symbol}@rpiDepth@500ms` messages. It preserves Binance's original `fetch` result and WebSocket instances and never opens a second depth connection. `core/depth-profile-book.js` applies the observed `lastUpdateId`, `U`, `u`, and `pu` sequence contract and treats quantities as absolute values; zero removes a price level. The profile keeps every active price accumulated from the native stream and defers pixel-row aggregation to rendering, so distant levels are not discarded by the snapshot limit. Rendering remains sparse: a row is painted only when at least one real price level maps to that chart pixel. `core/depth-profile-session.js` only subscribes the active symbol to that page-owned source. A sequence gap waits for Binance's native resynchronization instead of issuing a userscript-owned retry request, while a changed private RPI contract fails the profile explicitly without blocking Binance's own request. +The userscript installs `core/binance-native-depth-source.js` at `document-start` and passively observes the native `/fapi/v1/rpiDepth?limit=1000` response and `{symbol}@rpiDepth@500ms` messages. It preserves Binance's original `fetch` result and WebSocket instances and never opens a second depth connection. `core/depth-profile-book.js` applies the observed `lastUpdateId`, `U`, `u`, and `pu` sequence contract and treats quantities as absolute values; zero removes a price level. The first buffered event may connect to a snapshot either by covering `lastUpdateId` within `U..u` or by naming that exact snapshot ID in `pu`; every later non-stale event must name the preceding event's `u` in `pu`. The profile keeps every active price accumulated from the native stream and defers pixel-row aggregation to rendering, so distant levels are not discarded by the snapshot limit. Rendering remains sparse: a row is painted only when at least one real price level maps to that chart pixel. `core/depth-profile-session.js` only subscribes the active symbol to that page-owned source. A sequence gap waits for Binance's native resynchronization instead of issuing a userscript-owned retry request, while a changed native RPI contract fails the profile explicitly without blocking Binance's own request. The session must stop and invalidate old work on symbol change, non-trading routes, hidden documents, and `pagehide`. The overlay canvas uses `pointer-events: none`; only its compact collapse control may receive pointer input. Do not connect this visualization book to ladder pricing or any trading decision. diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 2d3db4f..57dc052 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.212 +// @version 2.7.213 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -3714,9 +3714,14 @@ if (payload.st !== void 0 && payload.st !== 1) { throw new Error(`Depth profile received non-USD-M data: ${payload.st}`); } + const firstUpdateId = assertUpdateId(payload.U, "first update id"); + const finalUpdateId = assertUpdateId(payload.u, "final update id"); + if (firstUpdateId > finalUpdateId) { + throw new Error("Invalid depth profile update id range"); + } return { - firstUpdateId: assertUpdateId(payload.U, "first update id"), - finalUpdateId: assertUpdateId(payload.u, "final update id"), + firstUpdateId, + finalUpdateId, previousFinalUpdateId: assertUpdateId(payload.pu, "previous final update id"), bids: parseLevels(payload.b, "bid updates"), asks: parseLevels(payload.a, "ask updates") @@ -3779,7 +3784,7 @@ return false; } const firstIndex = eligibleUpdates.findIndex( - (update) => update.firstUpdateId <= book.snapshotUpdateId && update.finalUpdateId >= book.snapshotUpdateId + (update) => update.firstUpdateId <= book.snapshotUpdateId && update.finalUpdateId >= book.snapshotUpdateId || update.previousFinalUpdateId === book.snapshotUpdateId ); if (firstIndex < 0) { const first = eligibleUpdates[0]; diff --git a/src/binance-orderbook-trade/core/depth-profile-book.js b/src/binance-orderbook-trade/core/depth-profile-book.js index 926599b..f3ad6c3 100644 --- a/src/binance-orderbook-trade/core/depth-profile-book.js +++ b/src/binance-orderbook-trade/core/depth-profile-book.js @@ -65,9 +65,14 @@ function parseUpdate(payload, symbol) { if (payload.st !== undefined && payload.st !== 1) { throw new Error(`Depth profile received non-USD-M data: ${payload.st}`); } + const firstUpdateId = assertUpdateId(payload.U, 'first update id'); + const finalUpdateId = assertUpdateId(payload.u, 'final update id'); + if (firstUpdateId > finalUpdateId) { + throw new Error('Invalid depth profile update id range'); + } return { - firstUpdateId: assertUpdateId(payload.U, 'first update id'), - finalUpdateId: assertUpdateId(payload.u, 'final update id'), + firstUpdateId, + finalUpdateId, previousFinalUpdateId: assertUpdateId(payload.pu, 'previous final update id'), bids: parseLevels(payload.b, 'bid updates'), asks: parseLevels(payload.a, 'ask updates'), @@ -138,8 +143,12 @@ function applyBufferedUpdates(book) { const firstIndex = eligibleUpdates.findIndex( (update) => ( - update.firstUpdateId <= book.snapshotUpdateId - && update.finalUpdateId >= book.snapshotUpdateId + ( + update.firstUpdateId <= book.snapshotUpdateId + && update.finalUpdateId >= book.snapshotUpdateId + ) + // RPI can skip internal IDs in U while pu still proves direct stream continuity. + || update.previousFinalUpdateId === book.snapshotUpdateId ), ); if (firstIndex < 0) { diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index ba74560..674584c 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.212 +// @version 2.7.213 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* diff --git a/test/unit/binance-orderbook-trade/binance-native-depth-source.test.js b/test/unit/binance-orderbook-trade/binance-native-depth-source.test.js index fffce92..e4990fe 100644 --- a/test/unit/binance-orderbook-trade/binance-native-depth-source.test.js +++ b/test/unit/binance-orderbook-trade/binance-native-depth-source.test.js @@ -467,6 +467,27 @@ test('user observes a Request-based native snapshot and waits for its first cove source.restore(); }); +test('user receives a ready RPI profile when the first event directly follows the snapshot', { timeout: 2000 }, async () => { + // Given the native snapshot has synchronized through update 101 + const { globalObject, source } = createHarness(); + const events = recordDepthEvents(source); + const response = await globalObject.fetch('/fapi/v1/rpiDepth?symbol=BTCUSDT&limit=1000'); + await events.waitFor(({ statuses }) => statuses.length === 3); + + // When the first RPI event starts later but names the snapshot as its predecessor + const socket = new globalObject.WebSocket('wss://native-binance-stream.example/ws'); + socket.message(rpiMessage(update({ U: 103, u: 104, pu: 101 }))); + await events.waitFor(({ profiles }) => profiles.length === 1); + + // Then the native source publishes one ready profile without entering resynchronization + assert.equal(response.ok, true); + assert.equal(events.profiles.length, 1); + assert.equal(events.profiles[0].bids[0].quantity, 2); + assert.equal(events.statuses.at(-1).status, 'ready'); + assert.equal(events.statuses.some(({ status }) => status === 'resyncing'), false); + source.restore(); +}); + test('user sees reconnection only for symbols observed on the native socket that closes', { timeout: 2000 }, async () => { // Given a synchronized native BTC stream and an unrelated ETH subscription const { globalObject, source } = createHarness(); diff --git a/test/unit/binance-orderbook-trade/depth-profile-book.test.js b/test/unit/binance-orderbook-trade/depth-profile-book.test.js index 954e685..366241c 100644 --- a/test/unit/binance-orderbook-trade/depth-profile-book.test.js +++ b/test/unit/binance-orderbook-trade/depth-profile-book.test.js @@ -206,6 +206,60 @@ test('user synchronizes a snapshot that arrives before its first covering stream assert.equal(profile.asks[0].quantity, 4); }); +test('user synchronizes a buffered RPI update from its snapshot predecessor', () => { + // Given the first buffered RPI event names the coming snapshot as its predecessor + const book = createDepthProfileBook('BTCUSDT'); + pushDepthProfileUpdate(book, update({ + U: 103, + u: 104, + pu: 101, + b: [['100', '7']], + a: [['101', '8']], + })); + + // When the snapshot arrives behind that event's first update id + const ready = applyDepthProfileSnapshot(book, snapshot()); + const profile = buildDepthProfile(book); + + // Then the predecessor bridge synchronizes the exact event quantities + assert.equal(ready, true); + assert.equal(book.previousFinalUpdateId, 104); + assert.deepEqual(book.bufferedUpdates, []); + assert.equal(profile.bids[0].quantity, 7); + assert.equal(profile.asks[0].quantity, 8); +}); + +test('user keeps strict update sequencing after an RPI snapshot predecessor bridge', () => { + // Given the snapshot arrives before an RPI event that directly follows it + const book = createDepthProfileBook('BTCUSDT'); + const snapshotReady = applyDepthProfileSnapshot(book, snapshot()); + + // When the first event advances beyond the snapshot while naming it as the predecessor + const bridgeReady = pushDepthProfileUpdate(book, update({ U: 103, u: 104, pu: 101 })); + + // Then the snapshot waits and the direct predecessor bridge becomes ready + assert.equal(snapshotReady, false); + assert.equal(bridgeReady, true); + assert.equal(book.previousFinalUpdateId, 104); + + // When a consecutive event names the bridged final update id + const nextReady = pushDepthProfileUpdate(book, update({ U: 105, u: 106, pu: 104 })); + + // Then the synchronized sequence advances normally + assert.equal(nextReady, true); + assert.equal(book.previousFinalUpdateId, 106); + + // When a later new event skips that synchronized predecessor + const failure = captureThrownError(() => pushDepthProfileUpdate( + book, + update({ U: 107, u: 108, pu: 107 }), + )); + + // Then the ordinary strict predecessor guard still rejects the gap + assert.equal(failure instanceof DepthProfileSequenceError, true); + assert.equal(failure.message, 'Depth update sequence gap: expected pu 106, received 107'); +}); + test('user drains consecutive buffered events while ignoring a duplicate final update', () => { // Given the native stream queued a covering event, its duplicate, and the next update const book = createDepthProfileBook('BTCUSDT'); @@ -341,6 +395,7 @@ for (const { label, payload, expected } of [ { label: 'negative first id', payload: update({ U: -1 }), expected: 'Invalid depth profile first update id' }, { label: 'fractional final id', payload: update({ u: 102.5 }), expected: 'Invalid depth profile final update id' }, { label: 'unsafe predecessor id', payload: update({ pu: Number.MAX_SAFE_INTEGER + 1 }), expected: 'Invalid depth profile previous final update id' }, + { label: 'descending update id range', payload: update({ U: 103, u: 102 }), expected: 'Invalid depth profile update id range' }, ]) { test(`user rejects a native depth update with ${label}`, () => { // Given a new book and an invalid native stream event