Skip to content

Commit 8eb0921

Browse files
jackhai9claude
andauthored
perf(orderbook): cut hot-path work in long trading sessions (#330)
* perf(orderbook): cut hot-path work in long trading sessions Long Binance sessions with automated ladders spend main-thread time in three orderbook paths that grow with page activity, not with useful work. - Chart mirror copies charged every primitive and key through TextEncoder.encode(JSON.stringify(x)) and one defineProperty per key. The copy runs synchronously for every mirrored chart save and again for each stored old value. It now counts the exact UTF-8 JSON size without allocating, creates data properties with map/Object.fromEntries, and verifies each realm's Object constructor once. A 450 KiB TradingView-like snapshot drops from about 25 ms to 9 ms per copy (Node 24). A differential test against the previous implementation over 16,066 value/budget combinations found no difference in acceptance, remaining budget, structure, key order or prototypes. - Order feedback detection ran getComputedStyle and getClientRects on every toast/message/aria-live candidate before checking its text, on each body mutation batch while an order is pending. The scan now lives in dom/order-feedback-scan.js and checks layout only for short order-feedback text; the returned entries are unchanged. - Automatic USDT activity observation POSTed account identity on every account-counter change while positions or orders existed, even though it can only change a consumed episode. It now starts only when a stored record could change (consumed, completed or unrecognized). Regression tests fail on the previous implementation: 9,003 vs 21 encodings for a large snapshot, 45 vs 3 layout visibility checks, and identity requests with no consumed episode. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf(orderbook): address review of hot-path changes Keep one recursion frame per nesting level in the chart copy and count strings of 256+ code units natively, where encoding is faster for ASCII. Settle shared account locks before asserting identity requests, and cover stored active, unrecognized and other-account consumed episodes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 36d7bc9 commit 8eb0921

9 files changed

Lines changed: 483 additions & 69 deletions

File tree

‎docs/binance-chart-storage-development.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ execute the original native expression once, before any optimized transaction
7070
exists. Native handling may exceed the optimizer's limits; these limits bound
7171
optimization, not Binance's original work. Shared references, cycles and non-JSON
7272
input use that native path instead of silently changing their stored meaning.
73+
The copy charges each value its exact UTF-8 JSON size while it walks the tree,
74+
without encoding every value separately; the accepted batch is measured once.
75+
Stored old values are validated inside the transaction with the same copy.
7376

7477
Within a transaction, unsupported old values and exhausted comparison budgets
7578
cause direct writes. They do not reject otherwise valid replacements. A genuine

‎docs/binance-orderbook-trade-development.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,14 @@ with SHA-256 and stores only a versioned episode status under
407407
`userscripts:automatic-usdt-rebalance:v1:<digest>`. Missing state initially
408408
qualifies; `consumed` becomes `active` only after a new authoritative nonflat
409409
snapshot. That activity observation uses the same lock in shared mode so it can
410-
observe an ongoing script trade while remaining excluded by transfers. The
410+
observe an ongoing script trade while remaining excluded by transfers. It starts
411+
only while a stored record could change: a `consumed` or completed record, or any
412+
unrecognized value, which keeps the validating path that reports it. Stored
413+
`active`, `in_flight` and `blocked` records alone never request account identity
414+
on account-counter changes during trading. Skipping those no-op observations leaves
415+
persisted episodes and transfer decisions unchanged; it only removes the
416+
eligibility-window restart and shared-lock hold that each finished observation
417+
used to cause incidentally. The
411418
`in_flight` marker is persisted before each transfer POST. It and `blocked` remain
412419
terminal across reloads and later activity; neither a timer nor another tab
413420
clears an uncertain outcome. There is no automatic retry or rollback. Missing

‎e2e/binance-orderbook/specs/automatic-account-rebalance.pw.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,56 @@ test('user qualifies again only after independently verified new account activit
654654
expect(api.snapshot().balances).toEqual(TARGET);
655655
});
656656

657+
const identityRequests = api => api.snapshot().requests.filter(request => request.pathname === ACCOUNT_PATHS.identity);
658+
659+
async function seedRawEpisode(page, account, value) {
660+
await page.evaluate(async ({ prefix, account, value }) => {
661+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(account));
662+
localStorage.setItem(prefix + Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''), value);
663+
}, { prefix: PREFIX, account, value });
664+
}
665+
666+
/** An observation holds or awaits the shared account lock until its requests settle. */
667+
async function settleAccountObservations(page) {
668+
await expect.poll(() => page.evaluate(async () => {
669+
const { held, pending } = await navigator.locks.query();
670+
return [...held, ...pending].filter(lock => lock.name === 'userscripts:usdt-account-operation:v1').length;
671+
})).toBe(0);
672+
}
673+
674+
for (const episode of [
675+
{ name: 'no stored episode', account: null, value: null, requestsIdentity: false },
676+
{ name: 'a stored active episode', account: 'fixture-account', value: '{"version":1,"status":"active"}', requestsIdentity: false },
677+
{ name: 'an unrecognized stored record', account: 'fixture-account', value: '{"version":1,"status":"active","note":true}', requestsIdentity: true },
678+
{ name: 'a consumed episode of another account', account: 'other-account', value: '{"version":1,"status":"consumed"}', requestsIdentity: true },
679+
]) {
680+
test(`user ${episode.requestsIdentity ? 'still verifies' : 'skips'} account identity on trading activity with ${episode.name}`, async ({ page }) => {
681+
// Given an account with open orders and the stored automatic episode state.
682+
const api = createAccountRebalanceApi(EXCESS);
683+
const loaded = await openAutomatic(page, api);
684+
await page.clock.runFor(32);
685+
await settleAccountObservations(page);
686+
if (episode.value !== null) await seedRawEpisode(page, episode.account, episode.value);
687+
const before = identityRequests(api).length;
688+
const storedBefore = await page.evaluate(prefix => Object.fromEntries(Object.keys(localStorage)
689+
.filter(key => key.startsWith(prefix)).map(key => [key, localStorage.getItem(key)])), PREFIX);
690+
691+
// When the open-order counters change repeatedly during trading.
692+
await page.evaluate(orders => window.__BINANCE_FIXTURE__.setOrders(orders), ORDER_SETS.both);
693+
await page.clock.runFor(32);
694+
await settleAccountObservations(page);
695+
await page.evaluate(orders => window.__BINANCE_FIXTURE__.setOrders(orders), ORDER_SETS.other);
696+
await page.clock.runFor(32);
697+
await settleAccountObservations(page);
698+
699+
// Then identity is requested only when a stored record could change, and stored state stays intact.
700+
expect(identityRequests(api).length > before).toBe(episode.requestsIdentity);
701+
expect(await page.evaluate(prefix => Object.fromEntries(Object.keys(localStorage)
702+
.filter(key => key.startsWith(prefix)).map(key => [key, localStorage.getItem(key)])), PREFIX)).toEqual(storedBefore);
703+
expect(loaded.errors).toEqual([]);
704+
});
705+
}
706+
657707
test('user never repeats a transfer with an unknown response after reloading', async ({ page }) => {
658708
// Given the server commits a transfer but its response is lost.
659709
const api = createAccountRebalanceApi(EXCESS);

‎scripts/binance-orderbook-trade.user.js‎

Lines changed: 90 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// @namespace binance.orderbook.trade
44
// @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
55
// @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
6-
// @version 2.7.230
6+
// @version 2.7.231
77
// @author jackhai9
88
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
99
// @match https://www.binance.com/*/futures/*
@@ -5837,6 +5837,28 @@ Copyright (C) 2014 Yusuke Suzuki <utatane.tea@gmail.com>
58375837
return parts.join("");
58385838
}
58395839

5840+
// src/binance-orderbook-trade/dom/order-feedback-scan.js
5841+
var ORDER_FEEDBACK_SELECTOR = [
5842+
'[role="alert"]',
5843+
"[aria-live]",
5844+
'[class*="toast"]',
5845+
'[class*="Toast"]',
5846+
'[class*="message"]',
5847+
'[class*="Message"]',
5848+
'[class*="notification"]',
5849+
'[class*="Notification"]'
5850+
].join(",");
5851+
var MAX_ORDER_FEEDBACK_TEXT_LENGTH = 300;
5852+
function readVisibleOrderFeedbackEntries(document2, { isVisibleElement }) {
5853+
const entries = [];
5854+
for (const el of document2.querySelectorAll(ORDER_FEEDBACK_SELECTOR)) {
5855+
const text = (el.textContent || "").replace(/\s+/g, " ").trim();
5856+
if (!text || text.length > MAX_ORDER_FEEDBACK_TEXT_LENGTH || !isPotentialOrderFeedbackText(text)) continue;
5857+
if (isVisibleElement(el)) entries.push({ el, text });
5858+
}
5859+
return entries;
5860+
}
5861+
58405862
// src/binance-orderbook-trade/dom/chart-orders.js
58415863
var ACTIVE_POPOVER_SELECTOR = ".bn-bubble.active";
58425864
var OPEN_ORDERS_LABEL_PATTERN = /^(?:当前委托|Open Orders)$/i;
@@ -7490,29 +7512,69 @@ Copyright (C) 2014 Yusuke Suzuki <utatane.tea@gmail.com>
74907512
// src/binance-orderbook-trade/chart-storage/json.js
74917513
var encoder = new TextEncoder();
74927514
var nativeObjectSource = Function.prototype.toString.call(Object);
7515+
var nativeObjectConstructors = /* @__PURE__ */ new WeakSet();
74937516
function assert(condition, message) {
74947517
if (!condition) throw new Error(message);
74957518
}
7519+
function isNativeObjectConstructor(candidate) {
7520+
if (nativeObjectConstructors.has(candidate)) return true;
7521+
if (Function.prototype.toString.call(candidate) !== nativeObjectSource) return false;
7522+
nativeObjectConstructors.add(candidate);
7523+
return true;
7524+
}
74967525
function isPlainJsonObject(value) {
74977526
const prototype = Object.getPrototypeOf(value);
74987527
if (prototype === null || Object.getPrototypeOf(prototype) !== null) return false;
74997528
const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor");
75007529
if (!constructor || !Object.hasOwn(constructor, "value") || typeof constructor.value !== "function") return false;
7501-
if (Function.prototype.toString.call(constructor.value) !== nativeObjectSource) return false;
7530+
if (!isNativeObjectConstructor(constructor.value)) return false;
75027531
const descriptor = Object.getOwnPropertyDescriptor(constructor.value, "prototype");
75037532
return !!descriptor && Object.hasOwn(descriptor, "value") && descriptor.value === prototype;
75047533
}
75057534
function byteLength(value) {
75067535
return encoder.encode(JSON.stringify(value)).byteLength;
75077536
}
7537+
function jsonStringByteLength(text) {
7538+
if (text.length >= 256) return byteLength(text);
7539+
let bytes = 2;
7540+
for (let index = 0; index < text.length; index += 1) {
7541+
const code = text.charCodeAt(index);
7542+
if (code < 128) {
7543+
if (code === 34 || code === 92) bytes += 2;
7544+
else if (code >= 32) bytes += 1;
7545+
else bytes += code === 8 || code === 9 || code === 10 || code === 12 || code === 13 ? 2 : 6;
7546+
} else if (code < 2048) {
7547+
bytes += 2;
7548+
} else if (code >= 55296 && code <= 56319) {
7549+
const next = text.charCodeAt(index + 1);
7550+
if (next >= 56320 && next <= 57343) {
7551+
bytes += 4;
7552+
index += 1;
7553+
} else {
7554+
bytes += 6;
7555+
}
7556+
} else if (code >= 56320 && code <= 57343) {
7557+
bytes += 6;
7558+
} else {
7559+
bytes += 3;
7560+
}
7561+
}
7562+
return bytes;
7563+
}
7564+
function primitiveJsonByteLength(value) {
7565+
if (value === null) return 4;
7566+
if (typeof value === "boolean") return value ? 4 : 5;
7567+
if (typeof value === "number") return String(value).length;
7568+
return jsonStringByteLength(value);
7569+
}
75087570
function snapshotJson(value, budget = { remaining: Infinity }, seen = /* @__PURE__ */ new Set()) {
75097571
function consume(bytes) {
75107572
budget.remaining -= bytes;
75117573
assert(budget.remaining >= 0, "Chart JSON byte limit exceeded");
75127574
}
75137575
if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") {
75147576
assert(typeof value !== "number" || Number.isFinite(value), "Chart JSON numbers must be finite");
7515-
consume(byteLength(value));
7577+
consume(primitiveJsonByteLength(value));
75167578
return value;
75177579
}
75187580
assert(typeof value === "object", "Chart values must be JSON");
@@ -7525,19 +7587,18 @@ Copyright (C) 2014 Yusuke Suzuki <utatane.tea@gmail.com>
75257587
if (array) assert(keys.length === value.length && keys.every((key, index) => key === String(index)), "Chart JSON arrays must be dense");
75267588
seen.add(value);
75277589
consume(2 + Math.max(0, keys.length - 1));
7528-
const result = array ? [] : {};
7590+
const copies = [];
75297591
for (const key of keys) {
75307592
const descriptor = descriptors[key];
75317593
assert(descriptor.enumerable && Object.hasOwn(descriptor, "value"), "Chart JSON requires enumerable data properties");
7532-
if (!array) consume(byteLength(key) + 1);
7533-
Object.defineProperty(result, key, {
7534-
value: snapshotJson(descriptor.value, budget, seen),
7535-
enumerable: true,
7536-
writable: true,
7537-
configurable: true
7538-
});
7594+
if (array) {
7595+
copies.push(snapshotJson(descriptor.value, budget, seen));
7596+
continue;
7597+
}
7598+
consume(jsonStringByteLength(key) + 1);
7599+
copies.push([key, snapshotJson(descriptor.value, budget, seen)]);
75397600
}
7540-
return result;
7601+
return array ? copies : Object.fromEntries(copies);
75417602
}
75427603
function equalJson(first, second) {
75437604
if (Object.is(first, second)) return true;
@@ -15581,6 +15642,10 @@ Copyright (C) 2014 Yusuke Suzuki <utatane.tea@gmail.com>
1558115642
const USDT_REBALANCE_FLAT_STABLE_MS = 2e3;
1558215643
const USDT_REBALANCE_REQUEST_TIMEOUT_MS = 5e3;
1558315644
const USDT_REBALANCE_BALANCE_POLL_MS = 1e3;
15645+
const AUTOMATIC_USDT_REBALANCE_EPISODE_PREFIX = "userscripts:automatic-usdt-rebalance:v1:";
15646+
const NON_CONSUMED_AUTOMATIC_USDT_REBALANCE_EPISODES = new Set(
15647+
["active", "in_flight", "blocked"].map((status) => JSON.stringify({ version: 1, status }))
15648+
);
1558415649
const USDT_REBALANCE_RATIO = formatUsdtRebalanceRatio(USDT_REBALANCE_TARGET_PERCENTS);
1558515650
const USDT_REBALANCE_COPY = Object.freeze({
1558615651
allocated: formatUsdtRebalanceAllocatedStatus(USDT_REBALANCE_RATIO),
@@ -17912,30 +17977,11 @@ Copyright (C) 2014 Yusuke Suzuki <utatane.tea@gmail.com>
1791217977
`下单按钮 ${TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS} 秒内未达到可点击状态`
1791317978
);
1791417979
}
17915-
function readVisibleOrderFeedbackEntries() {
17916-
const selectors = [
17917-
'[role="alert"]',
17918-
"[aria-live]",
17919-
'[class*="toast"]',
17920-
'[class*="Toast"]',
17921-
'[class*="message"]',
17922-
'[class*="Message"]',
17923-
'[class*="notification"]',
17924-
'[class*="Notification"]'
17925-
];
17926-
const seen = /* @__PURE__ */ new Set();
17927-
const entries = [];
17928-
for (const el of document.querySelectorAll(selectors.join(","))) {
17929-
if (seen.has(el) || !isVisibleElement(el)) continue;
17930-
seen.add(el);
17931-
const text = (el.textContent || "").replace(/\s+/g, " ").trim();
17932-
if (!text || text.length > 300) continue;
17933-
if (isPotentialOrderFeedbackText(text)) entries.push({ el, text });
17934-
}
17935-
return entries;
17980+
function readVisibleOrderFeedbackEntries2() {
17981+
return readVisibleOrderFeedbackEntries(document, { isVisibleElement });
1793617982
}
1793717983
function takeOrderFeedbackSnapshot() {
17938-
const entries = readVisibleOrderFeedbackEntries();
17984+
const entries = readVisibleOrderFeedbackEntries2();
1793917985
return {
1794017986
elements: new Set(entries.map(({ el }) => el)),
1794117987
textByElement: new Map(entries.map(({ el, text }) => [el, text])),
@@ -17944,7 +17990,7 @@ Copyright (C) 2014 Yusuke Suzuki <utatane.tea@gmail.com>
1794417990
}
1794517991
function readNewVisibleOrderFeedbackText(previousSnapshot) {
1794617992
const snapshot = previousSnapshot || { elements: /* @__PURE__ */ new Set(), textByElement: /* @__PURE__ */ new Map(), htmlByElement: /* @__PURE__ */ new Map() };
17947-
for (const { el, text } of readVisibleOrderFeedbackEntries()) {
17993+
for (const { el, text } of readVisibleOrderFeedbackEntries2()) {
1794817994
if (!snapshot.elements.has(el)) return text;
1794917995
if (snapshot.textByElement.get(el) !== text) return text;
1795017996
if (snapshot.htmlByElement.get(el) !== el.innerHTML) return text;
@@ -21000,7 +21046,7 @@ Copyright (C) 2014 Yusuke Suzuki <utatane.tea@gmail.com>
2100021046
throw new Error("Unsupported or unverified account identity");
2100121047
}
2100221048
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload.data.userId));
21003-
return "userscripts:automatic-usdt-rebalance:v1:" + Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
21049+
return AUTOMATIC_USDT_REBALANCE_EPISODE_PREFIX + Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
2100421050
}
2100521051
function readUsdtRebalanceEpisode(key) {
2100621052
return readAutomaticRebalanceEpisode(localStorage.getItem(key));
@@ -21048,8 +21094,16 @@ Copyright (C) 2014 Yusuke Suzuki <utatane.tea@gmail.com>
2104821094
if (next.status !== previous.status) writeUsdtRebalanceEpisode(key, next.status);
2104921095
}, null, false);
2105021096
}
21097+
function hasRearmableAutomaticUsdtRebalanceEpisode() {
21098+
for (let index = 0; index < localStorage.length; index += 1) {
21099+
const key = localStorage.key(index);
21100+
if (key.startsWith(AUTOMATIC_USDT_REBALANCE_EPISODE_PREFIX) && !NON_CONSUMED_AUTOMATIC_USDT_REBALANCE_EPISODES.has(localStorage.getItem(key))) return true;
21101+
}
21102+
return false;
21103+
}
2105121104
function queueAutomaticUsdtRebalanceActivityObservation() {
2105221105
if (automaticUsdtRebalanceObservationTask || !cachedBncHeaders) return;
21106+
if (!hasRearmableAutomaticUsdtRebalanceEpisode()) return;
2105321107
automaticUsdtRebalanceObservationTask = observeAutomaticUsdtRebalanceActivity().catch((error) => {
2105421108
log("Automatic USDT activity observation failed", error.name, error.message.slice(0, 240));
2105521109
}).finally(() => {

0 commit comments

Comments
 (0)