Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/binance-chart-storage-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ outcomes are independent; one changed upstream module does not cancel a matching
other module. Late injection leaves both native. Metadata does not guarantee
early injection on every extension configuration; rejection remains explicit.

The orderbook installer also supplies independently tracked
[notification targets](binance-order-notifications.md) to this same observer.
`startChartStorageOptimizer({ additionalTargets })` accepts explicit
`replace`, `onCapture` and `onFailure` callbacks for each non-storage module ID;
storage IDs cannot be replaced by those descriptors. They share the original
capture deadline, but mirror `stop()` does not stop their capture or handling.

### Drawing ownership

Native loading puts historical symbols' drawings into every chart that shares
Expand Down
95 changes: 95 additions & 0 deletions docs/binance-order-notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Native Order Notification Scope

The orderbook userscript scopes Binance's ordinary order toasts and trade sound
to the symbol currently displayed by its supported USDT/USDC futures route. It
does not filter the account stream, positions, open orders, or native trade
acknowledgements. There is no separate userscript or notification preference.
The native toast and sound switches retain their meaning.

## Observed cause and intervention

The public factories captured on 2026-10-07 show two independent paths:

- `39116` subscribes to both UM and CM account order streams. FILLED,
PARTIALLY_FILLED and EXPIRED events enter a 500 ms trailing debounce with only
the order ID. There is no symbol test. `55401` plays queued notifications with
a two-second cooldown; its audio helper calls `play()` twice.
- `4189` updates the native account cache and emits order events using the old
cached order. `34122` registers notification listeners. `30877` converts
cancellation, partial-fill and fill events into toasts through a 30 ms
debounce, without checking the page symbol.

`order-notifications/native-modules.js` pins complete source for `39116`, `55401`
and all six observed `30877` variants. Changes are limited to those notification
consumers. Ordinary events are checked before debounce and immediately before
presentation. Queued tokens contain only a symbol, or null for native handling;
order IDs, client order IDs, prices and quantities are not retained in tokens.
Both audio calls check the same selected token. The sound queue is pruned only
when native playback can start, never while its current cooldown still owns the
queue head.

The page symbol is read on each check, including after a debounce or pending
audio Promise. Each matching tab may notify, even if two tabs show the same
symbol. A symbol with no matching tab produces no ordinary notification in the
other supported trading tabs. Unknown routes retain native handling.

## Risk and unknown events

Only known ordinary order types with a valid exchange symbol and nonempty
`clientOrderId` are scoped. `origType` and `operate`, when present, must also be
known ordinary values. The native REST cache can omit those stream fields, so
their absence alone does not classify a toast as unknown. Notifications receive
the cached record, not necessarily the latest stream execution type.

LIQUIDATION, CALCULATED, unknown shapes and the documented system client ID
prefixes `autoclose-`, `adl_autoclose` and `settlement_autoclose-` retain native
handling. This does not add a toast that Binance itself suppresses. Other
account/system alerts do not enter the patched consumers. The public `5558`
adapter preserves the corresponding raw `s`, `o`, `c`, `x` and `ot` fields.
See Binance's [system order ID documentation](https://www.binance.com/zh-CN/support/announcement/detail/f2809259702b46f7abdc9c97b977908f)
and [order stream reference](https://developers.binance.com/en/docs/products/derivatives-trading-coin-futures/user-data-streams).

## Startup and source changes

The existing orderbook installer supplies notification target descriptors to the
single early chart-storage queue observer. Each target has independent capture
and failure outcomes; stopping mirror writes does not stop notification scope.
No second queue accessor, global Audio override, WebSocket replacement, new
account subscription or DOM notification removal is installed by this feature.

Both sound factories must execute their pinned replacements before either
changes the native string queue to classified tokens. If either source mismatches,
both sides retain the original string contract. Pending native strings are also
preserved during activation. Toast capture is independent. Late injection,
unknown source or the original 30-second capture deadline leaves the affected
path native and reports its reason; native reminders may therefore still repeat
after an upstream change.

`self.__BINANCE_ORDER_NOTIFICATIONS__.snapshot()` exposes only capture status and
aggregate scope-check counts. `suppressedChecks` is a check count, not a unique
order or notification count. No account data is exposed by the diagnostic API.
Already-open pages require a fresh script load to activate this early hook.

## Reproduction and validation

`test/fixtures/binance-order-notifications/notification-manifest.json` records
factory hashes, observed source URLs, available complete chunk hashes and every
AST replacement. Three toast variants were captured as complete function source
without the complete chunk response; that distinction is explicit in the
manifest. Generate with:

```sh
node scripts/generate-order-notification-modules.mjs
npm run build:binance-orderbook-trade
node --test test/dom/binance-orderbook-trade/order-notifications.test.js test/unit/binance-chart-storage-runtime.test.js
npm run lint:tests
npm run check:binance-orderbook-trade
```

The regression first failed against the complete unmodified factories: an ETH
fill on a BTC page produced the native toast and load/play/play calls. Isolated
tests run the captured adapters, event cache, listeners, React 18.2 hooks and
native debounce with controlled clocks. Only preferences, account stream and
presentation I/O are replaced. Real orders are never required to validate this
feature. Installed-source and live notification behavior remain separate from
these deterministic checks.
10 changes: 10 additions & 0 deletions docs/binance-orderbook-trade-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ scripts/binance-orderbook-trade.user.js

## Runtime

### Native order reminders

`order-notifications/` scopes native ordinary order toasts and sound to the
current futures symbol. The storage installer shares its single early queue
observer with these independently pinned targets; no separate userscript is
installed. Risk and unknown events retain native handling. The native account
data flow and trading feedback readers remain unchanged. See
[notification scope](binance-order-notifications.md) for source pins, delayed
delivery, cross-tab behavior and validation boundaries.

### Chart mirror storage

`chart-storage/install.js` runs before the orderbook business initializer. This
Expand Down
192 changes: 192 additions & 0 deletions e2e/binance-orderbook/helpers/order-notifications-host.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { readFile } from 'node:fs/promises';
import { build } from 'esbuild';

import { nativeOrderNotificationSources } from '../../../test/fixtures/binance-order-notifications/original-factories.js';
import { openUserscriptScenario } from './userscript-page.js';
import { createCancelScenario } from '../scenarios/cancel-current-symbol.js';

let dependenciesSource;

/** Bundle only library dependencies; captured native factories retain their exact source. */
async function readReactDependencies() {
if (!dependenciesSource) {
const result = await build({
stdin: {
contents: `import * as React from 'react'; import {createRoot} from 'react-dom/client'; import {flushSync} from 'react-dom'; import {jsx,jsxs} from 'react/jsx-runtime'; self.__NOTIFICATION_REACT__={React,createRoot,flushSync,jsx,jsxs};`,
resolveDir: new URL('../../../', import.meta.url).pathname,
},
bundle: true,
write: false,
platform: 'browser',
format: 'iife',
define: { 'process.env.NODE_ENV': '"production"' },
});
dependenciesSource = result.outputFiles[0].text;
}
return dependenciesSource;
}

/** Native code owns all classification, events, throttles and playback scheduling. */
function installNotificationHost(nativeFactories) {
const { React, createRoot, flushSync, jsx, jsxs } = self.__NOTIFICATION_REACT__;
const channels = new Map([[false, new Set()], [true, new Set()]]);
const audio = [];
const toasts = [];
const parameters = Object.freeze({});
const getSDK = ({ isCM }) => ({
getUserOrderStream: () => ({
subscribe(listener) {
channels.get(isCM).add(listener);
return () => channels.get(isCM).delete(listener);
},
}),
});
const emit = (order, isCM) => {
for (const listener of channels.get(isCM)) listener(order);
};
const createExternalStore = (initialize) => {
const listeners = new Set();
let state = initialize((change) => {
state = { ...state, ...change };
for (const listener of listeners) listener();
});
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const snapshot = () => state;
return () => React.useSyncExternalStore(subscribe, snapshot, snapshot);
};
HTMLMediaElement.prototype.load = function load() {
audio.push({ kind: 'load', pathname: location.pathname });
};
HTMLMediaElement.prototype.play = function play() {
audio.push({ kind: 'play', pathname: location.pathname });
return Promise.resolve();
};
const enqueueNotification = (message, options) => {
toasts.push({ message, options, pathname: location.pathname });
const alert = document.createElement('div');
alert.setAttribute('role', 'alert');
alert.textContent = message;
alert.style.cssText = 'padding:16px 24px;margin:8px;background:#102a20;color:#d7ffec;border:1px solid #43b581;border-radius:8px;font:16px system-ui;box-shadow:0 5px 24px #0004';
document.getElementById('native-order-notifications').appendChild(alert);
};
const stores = new Map();
const queryClient = { setQueryData: (key, update) => stores.set(key, update(stores.get(key))) };
const makeBatcher = () => {
const workers = new Set();
return {
addWorker: (worker) => workers.add(worker),
removeWorker: (worker) => workers.delete(worker),
push: (order) => {
for (const worker of workers) worker(Array.isArray(order) ? order : [order]);
},
};
};
const cmBatcher = makeBatcher();
const umBatcher = makeBatcher();
const dependencies = {
41594: React,
31085: { jsx, jsxs },
75510: { _: (array) => [...array] },
94917: { _: (instance, Constructor) => {
if (!(instance instanceof Constructor)) throw new TypeError('Expected native constructor invocation');
} },
17409: { a0: 'classic', K5: 'https://static.test.invalid' },
61523: { d4: (selector) => selector({ setting: { layout: 'classic' } }) },
92873: { o: () => ({ getI18n: (_key, options) => options.defaultValue }) },
64041: { h: () => ({ enqueueNotification }) },
51471: { zr: (key) => {
if (!['open_order_status_toast', 'open-order-notification-sound-open'].includes(key)) throw new Error('Unknown native preference');
const [data, setData] = React.useState(true);
return { data, setData, hasInitialized: true };
} },
80065: { A: createExternalStore },
96636: { Z: () => ({ isEUFuturesUrl: false }) },
16921: { Gw: () => parameters },
72363: { Ri: getSDK },
79515: { nH: () => true, Py: () => ({ isExistFutureAccount: true }), ON: () => ({ isPM2: false }) },
88478: { A: () => null },
48651: { ud: () => ({ getI18n: (_key, options) => options.defaultValue }) },
34175: { Zu: () => undefined },
10157: { XE: 'notification-switch', IG: 'notification-label' },
87017: {},
48187: { mp: () => { throw new Error('Notification fixture cannot access financial APIs'); } },
57861: { post: () => { throw new Error('Notification fixture cannot access financial APIs'); } },
41466: {},
43335: { Bz: { OPEN_ORDERS: (isCM) => `orders:${isCM}` } },
26860: {},
84266: {},
90291: {},
47738: { Y: ({ subscribeToStreamFn }) => (options) => subscribeToStreamFn({
...options, stream: options.getSDK({ isCM: options.isCM }),
}) },
95541: { CX: cmBatcher, kc: () => umBatcher },
};
const hostFactories = Object.fromEntries(Object.entries(dependencies).map(([id, value]) => [id, (module) => { module.exports = value; }]));
self.webpackChunkfutures_trade_ui.push([['notification-host'], { ...hostFactories, ...nativeFactories }, (require) => {
const NativeEmitter = require(22584).b;
const emitter = new NativeEmitter();
require.m[71822] = (module) => { module.exports = { J: emitter }; };
const nativeOrders = require(4189);
for (const isCM of [false, true]) {
nativeOrders.$t({ enabled: true, isCM, isPM2: false, getSDK, queryClient, copyTradingPayload: parameters });
}
const Provider = require(55401).SoundNotificationProvider;
const Register = require(34122).OrderToastNotifyRegister;
const mount = () => {
const presentation = document.createElement('section');
presentation.id = 'native-order-notifications';
presentation.setAttribute('aria-label', 'Native order reminders');
presentation.style.cssText = 'position:fixed;top:24px;right:24px;z-index:2147483646;max-width:410px';
const rootElement = document.createElement('div');
rootElement.id = 'native-order-notification-provider';
document.body.append(presentation, rootElement);
const root = createRoot(rootElement);
flushSync(() => root.render(React.createElement(Provider, null, React.createElement(Register))));
self.__NOTIFICATION_HOST__ = {
ready: () => channels.get(false).size === 2 && channels.get(true).size === 2,
notify(symbol, orderId) {
const order = { symbol, orderId, clientOrderId: `manual-${orderId}`, type: 'LIMIT', orderType: 'LIMIT', origType: 'LIMIT', side: 'BUY', operate: 'TRADE', status: 'FILLED' };
flushSync(() => {
emit({ ...order, status: 'NEW' }, false);
emit(order, false);
});
},
snapshot() {
flushSync(() => undefined);
return { audio: [...audio], toasts: [...toasts] };
},
};
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true });
else mount();
}]);
}

/** Install the built artifact at document-start, then enter through the real Rspack queue. */
export async function openNotificationScenario(page, symbol) {
await page.route('**/*', (route) => route.abort('blockedbyclient'));
const [artifact, runtime, react] = await Promise.all([
readFile(new URL('../../../scripts/binance-orderbook-trade.user.js', import.meta.url), 'utf8'),
readFile(new URL('../../../test/fixtures/binance-chart-storage/webpack-runtime.js', import.meta.url), 'utf8'),
readReactDependencies(),
]);
await page.addInitScript({ content: `self.__NOTIFICATION_EARLY_ROOT__=Boolean(document.documentElement);\n${artifact}` });
const ids = new Set(['30877', '39116', '55401', '40477', '70020', '22584', '34122', '4189']);
const factories = nativeOrderNotificationSources.filter((entry) => ids.has(entry.id)
&& (entry.id !== '30877' || entry.chunks.includes('37511'))).map((entry) => entry.source).join(',\n');
const host = await openUserscriptScenario(page, createCancelScenario({ currentSymbol: symbol }), {
beforeOrderbook: `${react}\n${runtime}\n(${installNotificationHost.toString()})({${factories}});\nif(false){`,
afterOrderbook: '}',
});
await page.waitForFunction(() => self.__NOTIFICATION_HOST__?.ready() === true);
await page.clock.install({ time: new Date('2026-10-07T12:00:00Z') });
await page.clock.pauseAt(new Date('2026-10-07T12:00:01Z'));
return host;
}

export async function readNotificationEffects(page) {
return page.evaluate(() => self.__NOTIFICATION_HOST__.snapshot());
}
Loading
Loading