From 3e185482663bdc54ca76a800753c31c3a4180f52 Mon Sep 17 00:00:00 2001
From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com>
Date: Fri, 9 Oct 2026 18:09:28 +0800
Subject: [PATCH] feat: localize data panels and add funding trends
---
.gitignore | 1 +
docs/userscript-validation.md | 163 +-
.../helpers/data-panels-host.js | 182 ++
.../helpers/data-panels-preview.mjs | 242 +++
.../helpers/verify-data-panels-preview.mjs | 79 +
.../specs/data-panel-position.pw.js | 205 +-
.../specs/data-panels-presentation.pw.js | 379 ++++
scripts/binance-coinmarketcap-data.user.js | 995 +++++++---
scripts/binance-trading-data.user.js | 1716 +++++++++++++----
src/binance-coinmarketcap-data/index.user.js | 364 ++--
src/binance-coinmarketcap-data/metrics.js | 173 ++
.../panel-styles.js | 96 +
src/binance-coinmarketcap-data/ui-copy.js | 154 ++
src/binance-trading-data/index.user.js | 624 +++---
src/binance-trading-data/market-data.js | 159 ++
src/binance-trading-data/panel-view.js | 441 +++++
src/binance-trading-data/sparkline.js | 186 ++
src/binance-trading-data/ui-copy.js | 142 ++
src/shared/data-panel-layout.js | 29 +
.../binance-cmc-panel-presentation.test.js | 255 +++
.../binance-data-panels-entrypoint.test.js | 44 +-
.../dom/binance-data-panels-responses.test.js | 66 +-
test/dom/binance-trading-data-funding.test.js | 252 +++
.../binance-trading-data-presentation.test.js | 59 +
test/dom/binance-trading-data-view.test.js | 300 +++
.../data-media-remaining-boundaries.test.js | 25 +-
test/helpers/data-media-migration-host.js | 56 +-
test/unit/binance-cmc-panel-metrics.test.js | 179 ++
...e-data-panel-lifecycle-regressions.test.js | 57 +-
...nance-data-panel-route-regressions.test.js | 4 +-
test/unit/binance-trading-data-market.test.js | 103 +
test/unit/data-media-host-contract.test.js | 47 +
32 files changed, 6248 insertions(+), 1529 deletions(-)
create mode 100644 e2e/binance-orderbook/helpers/data-panels-host.js
create mode 100644 e2e/binance-orderbook/helpers/data-panels-preview.mjs
create mode 100644 e2e/binance-orderbook/helpers/verify-data-panels-preview.mjs
create mode 100644 e2e/binance-orderbook/specs/data-panels-presentation.pw.js
create mode 100644 src/binance-coinmarketcap-data/metrics.js
create mode 100644 src/binance-coinmarketcap-data/panel-styles.js
create mode 100644 src/binance-coinmarketcap-data/ui-copy.js
create mode 100644 src/binance-trading-data/market-data.js
create mode 100644 src/binance-trading-data/panel-view.js
create mode 100644 src/binance-trading-data/sparkline.js
create mode 100644 src/binance-trading-data/ui-copy.js
create mode 100644 src/shared/data-panel-layout.js
create mode 100644 test/dom/binance-cmc-panel-presentation.test.js
create mode 100644 test/dom/binance-trading-data-funding.test.js
create mode 100644 test/dom/binance-trading-data-presentation.test.js
create mode 100644 test/dom/binance-trading-data-view.test.js
create mode 100644 test/unit/binance-cmc-panel-metrics.test.js
create mode 100644 test/unit/binance-trading-data-market.test.js
diff --git a/.gitignore b/.gitignore
index 1b2e7d47..fdafb0b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ __pycache__/
node_modules/
playwright-report/
test-results/
+output/data-panels-preview/
diff --git a/docs/userscript-validation.md b/docs/userscript-validation.md
index 0ef48754..6576532d 100644
--- a/docs/userscript-validation.md
+++ b/docs/userscript-validation.md
@@ -94,16 +94,33 @@ primary button already released is canceled without saving.
For every resize, project the saved preference into the current viewport while
keeping the header reachable. Enlarging the viewport restores the saved
-coordinates. Without a saved preference, use the current viewport's right edge
-with a 16 px margin and the separate default top offsets (trading data: 60 px;
-CoinMarketCap: 360 px); do not persist those defaults. Existing stored coordinates
-remain the user's preference; an already overwritten earlier position cannot be
-reconstructed.
+coordinates. The trading panel is 480 px wide and the CMC panel is 500 px wide,
+each limited by the viewport. Without a saved preference, one panel uses the
+right edge with a 16 px margin and a 60 px top offset. When both are present,
+viewports at least 1028 px wide place them side by side, with CMC to the left of
+trading and a 16 px gap. Smaller viewports split the available height into two
+scrollable panels with 8 px outer margins and an 8 px vertical gap. These defaults
+are never persisted. Saved positions remain authoritative, even when a user's
+chosen positions overlap; an overwritten earlier position cannot be reconstructed.
+
+`src/shared/data-panel-layout.js` owns this presentation calculation. Mount,
+close, and removal notify the other panel through the local
+`jh-data-panels-layout-change` event. Reflow cancels an active drag before
+projecting coordinates, so an automatic move cannot become a saved drag when the
+mouse is released. The event only reflows existing panels; it does not start
+business work or broadcast again. Close and removal clean up the listener.
+Completing a real drag saves once and recalculates the available body height.
`e2e/binance-orderbook/specs/data-panel-position.pw.js` covers this contract with
the generated scripts, real browser geometry, native storage, and isolated data
providers. JSDOM lifecycle tests do not establish viewport geometry.
+Both panels use the existing pathname locale contract: `/zh-CN/` selects Chinese;
+English and other supported routes select English. Names, controls, explanations,
+status text, number units, and time formatting change together. Ordinary ratios
+and percentages use two decimal places; funding percentages and small token
+prices retain enough significant digits to distinguish nonzero values from zero.
+
## Binance Trading Data Panel
The panel runs only on an actual Binance futures trading route. It derives the
@@ -123,6 +140,78 @@ a newer symbol or lifecycle epoch. 4xx parameter/authentication failures are not
treated as transient network failures; any recovery behavior must remain explicit
in the source contract.
+### History and current funding
+
+The table has metric, historical trend, and value columns. Names follow the
+approved Binance data-page terminology. Each chart uses the observations' actual
+timestamps, rather than equally spaced synthetic timestamps. Missing observations
+break the line. One observation is a point, and an empty series stays unavailable.
+Taker activity uses separate buy and sell volume bars; its numeric field is clearly
+labelled as the buy/sell ratio. Click or focus a chart to inspect its values and
+dated observations; arrow keys select points and Escape closes the detail.
+
+All Binance requests use the existing `https://www.binance.com` origin:
+
+| Display | Endpoint | Source fields |
+| --- | --- | --- |
+| Open interest | `/futures/data/openInterestHist` | `timestamp`, `sumOpenInterest` |
+| Top trader account ratio | `/futures/data/topLongShortAccountRatio` | `timestamp`, `longShortRatio` |
+| Top trader position ratio | `/futures/data/topLongShortPositionRatio` | `timestamp`, `longShortRatio` |
+| Global account ratio | `/futures/data/globalLongShortAccountRatio` | `timestamp`, `longShortRatio` |
+| Taker volume and buy/sell ratio | `/futures/data/takerlongshortRatio` | `timestamp`, `buyVol`, `sellVol`, `buySellRatio` |
+| Basis rate | `/futures/data/basis` | `timestamp`, `basisRate`; requests use `pair` and `contractType=PERPETUAL` |
+| Settled funding history | `/fapi/v1/fundingRate` | `fundingTime`, `fundingRate`; up to 40 settlements |
+| Current funding and next settlement | `/fapi/v1/premiumIndex` | `symbol`, `lastFundingRate`, `time`, `nextFundingTime` |
+| Current settlement interval | `/fapi/v1/fundingInfo` | the unique matching `symbol` and `fundingIntervalHours` |
+
+The six five-minute series retain `period=5m`, `limit=30`, the publication grace
+period, and the existing delayed-publication retry schedule. History parsing
+requires finite numeric values and increasing timestamps. Malformed values do
+not become zero or fresh votes. The open-interest-to-market-cap row divides
+`sumOpenInterest` by `CMCCirculatingSupply` in the same contract base unit; it does
+not apply another multiplier to 1000-token contracts.
+
+The single funding row combines the last settled rate and its historical chart
+with the separate current rate, interval, and countdown. Only the settled value
+participates in the existing simplified funding vote. Current funding refreshes
+15 seconds after each request completes, with a 10-second request deadline.
+Interval metadata refreshes independently at activation and each full historical
+cycle, also with a 10-second deadline. It must never block a historical refresh.
+An absent metadata row means an unknown interval, not an assumed eight hours.
+
+Requests and responses retain session, path, and symbol ownership. Changing
+routes, hiding, or closing aborts the current-rate, interval, and clock requests;
+superseded responses cannot publish. A failed current refresh can retain an actual
+previous quote with a visible cache/error label. A first failure without data is
+labelled unavailable rather than cached. Metadata cache status is independent.
+
+Server-clock requests have a five-second deadline. Samples whose round trip
+exceeds two seconds are rejected; accepted samples use the request/response
+midpoint to estimate offset. The countdown states when calibration is unavailable.
+At the supplied settlement time it shows waiting for update and withdraws the old
+current quote until a new authoritative next settlement arrives. The one-second
+display timer updates existing value/status nodes. Current and clock updates do
+not recreate historical charts or reset the history footer's receipt timestamp.
+
+### Public provider evidence
+
+`test/unit/binance-trading-data-market.test.js` retains numeric samples observed
+at `2026-10-09T07:16:15Z`: STRK current funding was `0.00005000`, while the latest
+settled rate was `0.00000029` at `1791518400000`; the next settlement was
+`1791532800000`, with a reported four-hour interval. These are dated fixture
+values, not current quotes. A 1000PEPE sample had OI `17073744019` and Binance CMC
+supply `413772355107.944`; CMC's underlying PEPE supply was
+`413772355107943.94`. Their ratio requires no additional 1000 multiplier and
+displays `4.13%`.
+
+Public GETs on the scripts' actual `www.binance.com` origin were also checked at
+`2026-10-09T09:05:25Z`: `premiumIndex?symbol=STRKUSDT` and `fundingInfo` both
+returned HTTP 200, and STRK's unique interval row reported four hours. Protocol
+reference: [official Binance market-data SDK](https://github.com/binance/binance-connector-python/blob/master/clients/derivatives_trading_usds_futures/src/binance_sdk_derivatives_trading_usds_futures/rest_api/api/market_data_api.py).
+SDK titles establish API meaning, not exact website text. The approved English
+labels still need a verbatim check against the live native data page; that visual
+check and installed Tampermonkey verification are separate from offline tests.
+
## Binance CoinMarketCap Panel
The panel runs only on a matching futures route and resolves the current Binance
@@ -138,6 +227,50 @@ refresh epoch and clean up timers and DOM listeners. A route watcher may remain
to detect a later matching page, but it must not continue the business refresh
loop while paused.
+The CMC table has metric, value, and interpretation columns, with expandable
+definitions. A short name and separate qualifier identify each ratio's numerator,
+denominator, period, and unit. The display uses CMC-reported turnover and liquidity
+ratios rather than recomputing them from potentially asynchronous fields. FDV's
+comparison is the snapshot's FDV divided by circulating market cap; supply share
+uses circulating divided by total supply. Missing or non-positive comparison
+denominators do not produce a ratio. Null, empty, or invalid provider numbers stay
+unavailable, while genuine zero stays zero. Treasury holdings retain their token
+unit and cannot become a holder-address count.
+
+Interpretations describe what the value supports. The volume ratio's 50% threshold
+is explicitly a descriptive convention, not a statistical anomaly or trading
+signal. Valuation comparisons need peers, volume changes need history, DEX
+liquidity is not Binance order-book depth, and profile disclosure scores do not
+establish project safety. The existing 30-second refresh, mapping rules, source
+timestamps, and API/page-snapshot provenance remain separate from presentation.
+
+For a browser-openable offline implementation preview, run
+`node e2e/binance-orderbook/helpers/data-panels-preview.mjs` after the two builds
+and open `output/data-panels-preview/index.html` with a `file://` URL. The generator
+embeds unchanged generated artifacts and labelled example data. It supports both
+languages, light/dark themes, and each panel separately, without external network
+requests. It is not live Binance or Tampermonkey evidence.
+
+### Data-panel implementation validation (2026-10-09)
+
+The trading-data `1.2.0` and CMC-data `0.2.0` implementations passed independent
+source review, both builds, generated syntax checks, test lint, metadata/install
+URL checks, and exact preview-to-artifact comparison. Full `npm test` passed
+2501 tests. The four rendered preview combinations cover Chinese/English,
+light/dark themes, desktop, and 390/320 px widths; they have no default panel
+overlap, horizontal overflow, page errors, or external HTTP requests.
+
+Full `npm run test:ui` passed 605 of 606 cases, including all 33 data-panel
+position/presentation cases. The remaining failure is in
+`active-ladder-context-behavior.pw.js`, before its saved-order-count change:
+the existing `pauseScenarioClock` reads page time and then calls
+`pauseAt(pageNow + 100)` across process boundaries. The recorded delay exceeded
+that margin, producing `Cannot fast-forward to the past`. The scenario, helper,
+fixture, orderbook artifact, and package inputs match the unchanged base commit
+`6b0f788e`; this is an outstanding browser-test timing failure, not a passed
+scenario. Live native-page comparison and Tampermonkey installation remain
+unverified, including verbatim English labels.
+
## Auto Refresh
`auto_refresh.user.js` is intentionally narrow:
@@ -166,6 +299,12 @@ claim live behavior from source inspection alone.
preference may change. Complete a drag; only that panel's position is saved.
- Interrupt a drag by resizing, switching window focus, or releasing the mouse
outside the document; later movement cannot continue or save the old drag.
+- Mount or close the other panel while the header is held without movement;
+ releasing it must not save the resulting automatic layout.
+- Check both installation orders, a single panel, and both panels at desktop and
+ narrow widths. Defaults must not cover each other or write position preferences.
+- Collapse, drag to a lower position, and expand; content scrolls inside the
+ remaining height without an extra position write.
### Binance trading data
@@ -180,6 +319,16 @@ claim live behavior from source inspection alone.
excluded from directional vote totals while missing rows remain explicit.
- Navigate from a non-trading route to a futures route and back; only the
matching route owns a panel and active business loop.
+- Switch Chinese/English routes and themes; inspect metric names, ratio units,
+ controls, errors, timestamps, and keyboard chart details in both languages.
+- Compare current funding with the native top-of-page quote and settled points
+ with the native data chart at matching times. Check the reported interval and
+ countdown, including settlement expiry, unavailable metadata, and calibration
+ failure. Do not compare quotes captured at different times as equal snapshots.
+- Delay or fail the current funding and interval endpoints; history must still
+ render. Current-only refreshes must preserve the historical chart and footer.
+- Check a tiny nonzero settled rate, genuine zero, missing data, and a 1000-token
+ contract; none may acquire a fabricated zero, interval, or multiplier.
### Binance CoinMarketCap data
@@ -192,6 +341,10 @@ claim live behavior from source inspection alone.
survives removal.
- Navigate between matching and non-matching Binance routes and verify the
route watcher does not leave a business loop running off-route.
+- Expand interpretations for FDV, volume/cap, liquidity/cap, supply, holders, and
+ profile score; their stated bases must agree with the displayed data.
+- Check partial/missing fields and very small token prices in both languages;
+ unavailable values and genuine zero must remain distinguishable.
### Auto refresh
diff --git a/e2e/binance-orderbook/helpers/data-panels-host.js b/e2e/binance-orderbook/helpers/data-panels-host.js
new file mode 100644
index 00000000..6b139777
--- /dev/null
+++ b/e2e/binance-orderbook/helpers/data-panels-host.js
@@ -0,0 +1,182 @@
+import assert from 'node:assert/strict';
+import { fileURLToPath } from 'node:url';
+import { expect } from '@playwright/test';
+import { cmcDetail, tradingDataset } from '../../../test/helpers/data-media-migration-host.js';
+
+export const DATA_PANEL_ORIGIN = 'https://www.binance.com';
+export const DATA_PANEL_NOW = Date.UTC(2026, 9, 9, 0, 0, 0);
+export const POSITION_AUDIT_KEY = '__data_panel_fixture_position_writes__';
+export const DATA_PANELS = Object.freeze([
+ {
+ name: 'trading', id: 'jh-binance-trading-data-panel', width: 480,
+ key: 'jh_binance_trading_data_pos', artifact: 'binance-trading-data',
+ },
+ {
+ name: 'cmc', id: 'jh-binance-cmc-data-panel', width: 500,
+ key: 'jh_binance_cmc_data_pos', artifact: 'binance-coinmarketcap-data',
+ },
+]);
+export const CMC_HEADERS = Object.freeze({
+ Accept: 'application/json, text/plain, */*',
+ 'Cache-Control': 'no-cache',
+ Pragma: 'no-cache',
+});
+
+/** The fixture accepts only the current public data protocols and keeps native browser storage. */
+export async function createDataPanelsFixture(context, {
+ dataset = tradingDataset(DATA_PANEL_NOW), detail = cmcDetail(),
+} = {}) {
+ const requests = [];
+ const gmRequests = [];
+ const errors = [];
+ const map = { data: [{ id: 1, symbol: 'BTC', slug: 'bitcoin', is_active: 1 }] };
+
+ await context.exposeBinding('__dataPanelFixtureCmcResponse', (_source, request) => {
+ assert.deepEqual(Object.keys(request).sort(), ['headers', 'method', 'timeout', 'url']);
+ assert.equal(request.method, 'GET');
+ assert.equal(request.timeout, 20_000);
+ assert.deepEqual(request.headers, CMC_HEADERS);
+ gmRequests.push(request);
+ const url = new URL(request.url);
+ let body;
+ if (url.origin === 'https://api.coinmarketcap.com' && url.pathname === '/data-api/v1/cryptocurrency/map') {
+ assert.deepEqual(Object.fromEntries(url.searchParams), {
+ symbol: 'BTC', listing_status: 'active', _: String(DATA_PANEL_NOW),
+ });
+ body = map;
+ } else if (url.origin === 'https://api.coinmarketcap.com' && url.pathname === '/data-api/v3/cryptocurrency/detail') {
+ assert.deepEqual(Object.fromEntries(url.searchParams), {
+ id: '1', convertId: '2781', languageCode: 'zh', _: String(DATA_PANEL_NOW),
+ });
+ body = { data: detail };
+ } else if (url.origin === 'https://dapi.coinmarketcap.com' && url.pathname === '/dex-stats/v3/dexer/crypto-holder/show_holders') {
+ assert.deepEqual(Object.fromEntries(url.searchParams), { cryptoId: '1', _: String(DATA_PANEL_NOW) });
+ body = { data: { showFlag: true, count: 10_000 } };
+ } else {
+ assert.fail(`Unmodeled CMC request: ${url.origin}${url.pathname}`);
+ }
+ return { status: 200, responseText: JSON.stringify(body) };
+ });
+
+ await context.addInitScript(({ origin, keys, auditKey }) => {
+ if (location.origin !== origin) return;
+ const nativeSetItem = Storage.prototype.setItem;
+ if (localStorage.getItem(auditKey) === null) nativeSetItem.call(localStorage, auditKey, '[]');
+ window.dataPanelFixture = { resizeEvents: 0, gmCompletions: [], pointerEvents: [] };
+ window.addEventListener('resize', () => { window.dataPanelFixture.resizeEvents += 1; });
+ document.addEventListener('mousemove', event => {
+ window.dataPanelFixture.pointerEvents.push({
+ buttons: event.buttons, x: event.clientX, y: event.clientY, trusted: event.isTrusted,
+ });
+ }, { capture: true });
+
+ // Preserve real storage semantics so another tab and reload see the same preference.
+ Storage.prototype.setItem = function (key, value) {
+ nativeSetItem.call(this, key, value);
+ if (this !== localStorage || !keys.includes(key)) return;
+ const writes = JSON.parse(localStorage.getItem(auditKey));
+ writes.push({ key, value: JSON.parse(value) });
+ nativeSetItem.call(localStorage, auditKey, JSON.stringify(writes));
+ };
+ window.GM_xmlhttpRequest = options => {
+ for (const name of ['onload', 'onerror', 'ontimeout']) {
+ if (typeof options[name] !== 'function') throw new Error(`Missing GM callback: ${name}`);
+ }
+ const request = {
+ method: options.method, url: options.url,
+ timeout: options.timeout, headers: options.headers,
+ };
+ window.__dataPanelFixtureCmcResponse(request).then(response => {
+ window.dataPanelFixture.gmCompletions.push({ url: options.url, response });
+ options.onload(response);
+ });
+ };
+ }, { origin: DATA_PANEL_ORIGIN, keys: DATA_PANELS.map(panel => panel.key), auditKey: POSITION_AUDIT_KEY });
+
+ await context.route('**/*', async route => {
+ const request = route.request();
+ const url = new URL(request.url());
+ assert.equal(request.method(), 'GET');
+ if (url.origin === DATA_PANEL_ORIGIN && /^\/(?:zh-CN|en)\/futures\/BTCUSDT$/.test(url.pathname) && request.isNavigationRequest()) {
+ await route.fulfill({
+ contentType: 'text/html; charset=utf-8',
+ body: '
Offline data panel fixture '
+ + ' '
+ + 'Offline data panel fixture · Example data ',
+ });
+ return;
+ }
+ assert.equal(url.origin, DATA_PANEL_ORIGIN);
+ const params = Object.fromEntries(url.searchParams);
+ requests.push({ method: request.method(), path: url.pathname, params });
+ if (url.pathname === '/fapi/v1/time') {
+ assert.deepEqual(params, {});
+ await route.fulfill({ json: { serverTime: DATA_PANEL_NOW } });
+ return;
+ }
+ const endpoint = url.pathname.split('/').at(-1);
+ assert.equal(Object.hasOwn(dataset, endpoint), true, `Unmodeled fetch: ${url.pathname}`);
+ if (endpoint === 'fundingRate') {
+ assert.equal(url.pathname, '/fapi/v1/fundingRate');
+ assert.deepEqual(params, { symbol: 'BTCUSDT', limit: '40' });
+ } else if (endpoint === 'premiumIndex') {
+ assert.equal(url.pathname, '/fapi/v1/premiumIndex');
+ assert.deepEqual(params, { symbol: 'BTCUSDT' });
+ } else if (endpoint === 'fundingInfo') {
+ assert.equal(url.pathname, '/fapi/v1/fundingInfo');
+ assert.deepEqual(params, {});
+ } else if (endpoint === 'basis') {
+ assert.equal(url.pathname, '/futures/data/basis');
+ assert.deepEqual(params, { pair: 'BTCUSDT', period: '5m', limit: '30', contractType: 'PERPETUAL' });
+ } else {
+ assert.equal(url.pathname, `/futures/data/${endpoint}`);
+ assert.deepEqual(params, { symbol: 'BTCUSDT', period: '5m', limit: '30' });
+ }
+ await route.fulfill({ json: dataset[endpoint] });
+ });
+
+ async function open(page, {
+ viewport = { width: 1600, height: 1200 }, positions, install = true,
+ locale = 'zh-CN', theme = 'light', controlledClock = false,
+ panels = DATA_PANELS.map(panel => panel.name),
+ } = {}) {
+ page.on('pageerror', error => errors.push(error.message));
+ await page.setViewportSize(viewport);
+ await page.emulateMedia({ colorScheme: theme });
+ if (controlledClock) {
+ await page.clock.install({ time: DATA_PANEL_NOW });
+ await page.clock.pauseAt(DATA_PANEL_NOW);
+ } else {
+ await page.clock.setFixedTime(DATA_PANEL_NOW);
+ }
+ await page.goto(`${DATA_PANEL_ORIGIN}/${locale}/futures/BTCUSDT`);
+ if (positions) {
+ await page.evaluate(({ definitions, positions, auditKey }) => {
+ for (const panel of definitions) {
+ if (Object.hasOwn(positions, panel.name)) localStorage.setItem(panel.key, JSON.stringify(positions[panel.name]));
+ }
+ localStorage.setItem(auditKey, '[]');
+ }, { definitions: DATA_PANELS, positions, auditKey: POSITION_AUDIT_KEY });
+ }
+ if (install) await installDataPanels(page, { locale, panels });
+ }
+ return { open, requests, gmRequests, errors, map, dataset };
+}
+
+export async function installDataPanels(page, {
+ locale = 'zh-CN', panels = DATA_PANELS.map(panel => panel.name),
+} = {}) {
+ for (const name of panels) {
+ const panel = DATA_PANELS.find(definition => definition.name === name);
+ assert.ok(panel, `Unknown panel installation: ${name}`);
+ await page.addScriptTag({ path: fileURLToPath(new URL(`../../../scripts/${panel.artifact}.user.js`, import.meta.url)) });
+ }
+ if (panels.includes('trading')) {
+ await expect(page.locator('#jh-binance-trading-data-panel [data-role="updated-at"]')).toContainText(locale === 'zh-CN' ? '更新于' : 'Updated');
+ await expect(page.locator('#jh-binance-trading-data-panel-symbol')).toHaveText('BTCUSDT');
+ }
+ if (panels.includes('cmc')) {
+ await expect(page.locator('#jh-binance-cmc-data-panel-footer a')).toHaveText('CMC data-api');
+ await expect(page.locator('#jh-binance-cmc-data-panel-symbol')).toHaveText('BTC #1');
+ }
+}
diff --git a/e2e/binance-orderbook/helpers/data-panels-preview.mjs b/e2e/binance-orderbook/helpers/data-panels-preview.mjs
new file mode 100644
index 00000000..e5a002f8
--- /dev/null
+++ b/e2e/binance-orderbook/helpers/data-panels-preview.mjs
@@ -0,0 +1,242 @@
+import { createHash } from 'node:crypto';
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { cmcDetail, tradingDataset } from '../../../test/helpers/data-media-migration-host.js';
+
+const output = new URL('../../../output/data-panels-preview/', import.meta.url);
+const anchor = Date.UTC(2026, 9, 9, 0, 0, 0);
+const dataset = tradingDataset(anchor, { oi: 104_000, previousOi: 100_000, supply: 20_000_000, ratio: 1.23, funding: 0.00000029 });
+const shape = [0, 0.15, 0.08, 0.18, 0.11, 0.06, 0.25, 0.23, 0.42, 0.38, 0.46, 0.33, 0.57, 0.62, 0.53, 0.65, 0.72, 0.59, 0.7, 0.75, 0.79, 0.63, 0.69, 0.73, 0.84, 0.92, 0.86, 0.95, 0.89, 1];
+dataset.openInterestHist = shape.map((value, index) => ({
+ timestamp: anchor - (shape.length - 1 - index) * 300_000,
+ sumOpenInterest: String(100_000 + value * 4_000), sumOpenInterestValue: String((100_000 + value * 4_000) * 60_000),
+ CMCCirculatingSupply: '20000000',
+}));
+for (const [key, base, range] of [
+ ['topLongShortAccountRatio', 1.05, 0.18],
+ ['topLongShortPositionRatio', 1.12, 0.28],
+ ['globalLongShortAccountRatio', 1.02, 0.12],
+]) {
+ dataset[key] = shape.map((value, index) => ({ timestamp: anchor - (shape.length - 1 - index) * 300_000, longShortRatio: String(base + value * range) }));
+}
+dataset.takerlongshortRatio = shape.map((value, index) => {
+ const buy = 480 + value * 320;
+ const sell = 430 + shape[shape.length - 1 - index] * 290;
+ return { timestamp: anchor - (shape.length - 1 - index) * 300_000, buyVol: String(buy), sellVol: String(sell), buySellRatio: String(buy / sell) };
+});
+dataset.basis = shape.map((value, index) => ({ timestamp: anchor - (shape.length - 1 - index) * 300_000, basisRate: String(-0.0014 + value * 0.0004) }));
+dataset.fundingRate = Array.from({ length: 40 }, (_, index) => ({
+ symbol: 'BTCUSDT', fundingTime: anchor - (39 - index) * 14_400_000,
+ fundingRate: String(index === 39 ? 0.00000029 : (index % 11 === 0 ? -1 : 1) * (0.00005 + shape[index % shape.length] * 0.00005)),
+}));
+
+const artifacts = [];
+for (const name of ['binance-trading-data', 'binance-coinmarketcap-data']) {
+ const source = await readFile(new URL(`../../../scripts/${name}.user.js`, import.meta.url), 'utf8');
+ const version = source.match(/^\/\/ @version\s+(.+)$/m)?.[1];
+ if (!version) throw new Error(`Missing generated version for ${name}`);
+ artifacts.push({ name, version, sha256: createHash('sha256').update(source).digest('hex'), source });
+}
+
+/** Run unchanged generated source with explicit offline boundaries inside a fresh iframe realm. */
+function bootPreview(configuration) {
+ const { locale, theme, selection, anchor, artifacts } = configuration;
+ const now = Date.now();
+ const shift = now - anchor;
+ const dataset = configuration.dataset;
+ for (const [endpoint, response] of Object.entries(dataset)) {
+ if (endpoint === 'fundingInfo') continue;
+ if (endpoint === 'premiumIndex') {
+ response.time += shift;
+ response.nextFundingTime += shift;
+ } else {
+ for (const row of response) row[endpoint === 'fundingRate' ? 'fundingTime' : 'timestamp'] += shift;
+ }
+ }
+ const detail = configuration.detail;
+ detail.latestUpdateTime = new Date(now).toISOString();
+ const colors = theme === 'dark'
+ ? { PrimaryBg: '#1e2329', SecondaryBg: '#242a32', TextPrimary: '#eaecef', TextSecondary: '#a6b0bf', Line: '#353d48', Buy: '#65c59c', Sell: '#ff7c8f', PrimaryYellow: '#f0b90b' }
+ : { PrimaryBg: '#ffffff', SecondaryBg: '#fafbfc', TextPrimary: '#202630', TextSecondary: '#626c7d', Line: '#e4e8ef', Buy: '#24865e', Sell: '#db3a50', PrimaryYellow: '#b68208' };
+ document.documentElement.lang = locale;
+ document.documentElement.style.colorScheme = theme;
+ for (const [name, color] of Object.entries(colors)) document.documentElement.style.setProperty(`--color-${name}`, color);
+ document.body.style.cssText = `margin:0;background:${theme === 'dark' ? '#0b0e11' : '#f0f2f5'};font:13px system-ui;color:var(--color-TextSecondary)`;
+ const note = document.createElement('div');
+ note.textContent = locale === 'zh-CN' ? '示例数据 · BTCUSDT · 离线预览' : 'Example data · BTCUSDT · Offline preview';
+ note.style.cssText = 'position:fixed;left:16px;top:14px;font-size:12px;z-index:1';
+ document.body.append(note);
+ const previewLocation = {
+ origin: 'https://www.binance.com', hostname: 'www.binance.com',
+ pathname: `/${locale}/futures/BTCUSDT`, href: `https://www.binance.com/${locale}/futures/BTCUSDT`,
+ };
+ const storage = new Map();
+ const previewStorage = {
+ getItem: key => storage.has(String(key)) ? storage.get(String(key)) : null,
+ setItem: (key, value) => storage.set(String(key), String(value)),
+ removeItem: key => storage.delete(String(key)),
+ clear: () => storage.clear(),
+ };
+ const requests = [];
+ window.previewEvidence = { requests, versions: artifacts.map(({ name, version, sha256 }) => ({ name, version, sha256 })) };
+
+ function requireParams(url, expected) {
+ const actual = Object.fromEntries(url.searchParams);
+ if (JSON.stringify(Object.keys(actual).sort()) !== JSON.stringify(Object.keys(expected).sort()) || Object.entries(expected).some(([key, value]) => actual[key] !== value)) {
+ throw new Error(`Unexpected offline request parameters: ${url.pathname}`);
+ }
+ }
+
+ async function offlineFetch(input) {
+ const url = new URL(input, previewLocation.origin);
+ if (url.origin !== previewLocation.origin) throw new Error(`Unmodeled offline request origin: ${url.origin}`);
+ requests.push(url.pathname);
+ if (url.pathname === '/fapi/v1/time') {
+ requireParams(url, {});
+ return new Response(JSON.stringify({ serverTime: Date.now() }), { headers: { 'Content-Type': 'application/json' } });
+ }
+ const endpoint = url.pathname.split('/').at(-1);
+ if (!Object.hasOwn(dataset, endpoint)) throw new Error(`Unmodeled offline request: ${url.pathname}`);
+ if (endpoint === 'fundingRate') {
+ if (url.pathname !== '/fapi/v1/fundingRate') throw new Error('Invalid funding history path');
+ requireParams(url, { symbol: 'BTCUSDT', limit: '40' });
+ } else if (endpoint === 'premiumIndex') {
+ if (url.pathname !== '/fapi/v1/premiumIndex') throw new Error('Invalid current funding path');
+ requireParams(url, { symbol: 'BTCUSDT' });
+ } else if (endpoint === 'fundingInfo') {
+ if (url.pathname !== '/fapi/v1/fundingInfo') throw new Error('Invalid funding interval path');
+ requireParams(url, {});
+ } else if (endpoint === 'basis') {
+ if (url.pathname !== '/futures/data/basis') throw new Error('Invalid basis path');
+ requireParams(url, { pair: 'BTCUSDT', period: '5m', limit: '30', contractType: 'PERPETUAL' });
+ } else {
+ if (url.pathname !== `/futures/data/${endpoint}`) throw new Error('Invalid history path');
+ requireParams(url, { symbol: 'BTCUSDT', period: '5m', limit: '30' });
+ }
+ return new Response(JSON.stringify(dataset[endpoint]), { headers: { 'Content-Type': 'application/json' } });
+ }
+
+ function offlineCmc(options) {
+ if (options.method !== 'GET' || options.timeout !== 20_000) throw new Error('Invalid offline GM request');
+ const url = new URL(options.url);
+ if (!/^\d+$/.test(url.searchParams.get('_'))) throw new Error('Missing CMC request timestamp');
+ url.searchParams.delete('_');
+ requests.push(url.pathname);
+ let response;
+ if (url.origin === 'https://api.coinmarketcap.com' && url.pathname === '/data-api/v1/cryptocurrency/map') {
+ requireParams(url, { symbol: 'BTC', listing_status: 'active' });
+ response = { data: [{ id: 1, symbol: 'BTC', slug: 'bitcoin', is_active: 1 }] };
+ } else if (url.origin === 'https://api.coinmarketcap.com' && url.pathname === '/data-api/v3/cryptocurrency/detail') {
+ requireParams(url, { id: '1', convertId: '2781', languageCode: 'zh' });
+ response = { data: detail };
+ } else if (url.origin === 'https://dapi.coinmarketcap.com' && url.pathname === '/dex-stats/v3/dexer/crypto-holder/show_holders') {
+ requireParams(url, { cryptoId: '1' });
+ response = { data: { showFlag: true, count: 10_000 } };
+ } else {
+ throw new Error(`Unmodeled offline CMC request: ${url.origin}${url.pathname}`);
+ }
+ queueMicrotask(() => options.onload({ status: 200, responseText: JSON.stringify(response) }));
+ }
+
+ // All data boundaries are local, and links cannot navigate this review fixture externally.
+ document.addEventListener('click', event => {
+ if (event.target.closest('a[href]')) event.preventDefault();
+ }, { capture: true });
+ for (const artifact of artifacts) {
+ const name = artifact.name === 'binance-trading-data' ? 'trading' : 'cmc';
+ if (selection !== 'both' && selection !== name) continue;
+ const run = new Function('location', 'fetch', 'GM_xmlhttpRequest', 'localStorage', artifact.source);
+ run(previewLocation, offlineFetch, offlineCmc, previewStorage);
+ }
+}
+
+const payload = { anchor, dataset, detail: cmcDetail(), artifacts };
+const safeJson = value => JSON.stringify(value).replaceAll('<', '\\u003c');
+const html = `
+
+
+
+
+
+ Data panels · Offline implementation preview
+
+
+
+
+ 使用实际生成脚本与离线示例数据。可拖动、收起、查看历史点;不会连接交易所。切换选项会重置预览。
+
+
+
+
+
+`;
+
+await mkdir(output, { recursive: true });
+await writeFile(new URL('index.html', output), html);
+await writeFile(new URL('manifest.json', output), JSON.stringify({
+ kind: 'offline-example-data-preview', generatedAt: new Date().toISOString(),
+ artifacts: artifacts.map(({ name, version, sha256 }) => ({ name, version, sha256 })),
+}, null, 2) + '\n');
+console.log(fileURLToPath(new URL('index.html', output)));
diff --git a/e2e/binance-orderbook/helpers/verify-data-panels-preview.mjs b/e2e/binance-orderbook/helpers/verify-data-panels-preview.mjs
new file mode 100644
index 00000000..ae35bbf8
--- /dev/null
+++ b/e2e/binance-orderbook/helpers/verify-data-panels-preview.mjs
@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import { readFile, writeFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { chromium, expect } from '@playwright/test';
+
+const output = new URL('../../../output/data-panels-preview/', import.meta.url);
+const manifest = JSON.parse(await readFile(new URL('manifest.json', output), 'utf8'));
+for (const artifact of manifest.artifacts) {
+ const source = await readFile(new URL(`../../../scripts/${artifact.name}.user.js`, import.meta.url), 'utf8');
+ assert.equal(createHash('sha256').update(source).digest('hex'), artifact.sha256, `Stale preview artifact: ${artifact.name}`);
+}
+const cases = [
+ { name: 'desktop-zh-light', locale: 'zh-CN', theme: 'light', viewport: { width: 1600, height: 1200 } },
+ { name: 'desktop-en-dark', locale: 'en', theme: 'dark', viewport: { width: 1600, height: 1200 } },
+ { name: 'mobile-zh-light', locale: 'zh-CN', theme: 'light', viewport: { width: 390, height: 844 } },
+ { name: 'mobile-en-dark', locale: 'en', theme: 'dark', viewport: { width: 320, height: 900 } },
+];
+const checks = [];
+const browser = await chromium.launch({ headless: true });
+try {
+ for (const scenario of cases) {
+ const context = await browser.newContext({ viewport: scenario.viewport });
+ await context.setOffline(true);
+ const page = await context.newPage();
+ const errors = [];
+ const externalRequests = [];
+ page.on('pageerror', error => errors.push(error.message));
+ page.on('request', request => { if (/^https?:/.test(request.url())) externalRequests.push(request.url()); });
+ await page.goto(new URL('index.html', output).href);
+ const frame = page.frameLocator('#preview-frame');
+ await expect(frame.locator('#jh-binance-cmc-data-panel-footer a')).toHaveText('CMC data-api');
+ if (scenario.locale !== 'zh-CN') await page.locator('#locale').selectOption(scenario.locale);
+ if (scenario.theme !== 'light') await page.locator('#theme').selectOption(scenario.theme);
+ await expect(frame.locator('#jh-binance-trading-data-panel .td-title')).toHaveText(scenario.locale === 'zh-CN' ? '交易数据' : 'Trading data');
+ await expect(frame.locator('#jh-binance-cmc-data-panel .cmc-title')).toHaveText(scenario.locale === 'zh-CN' ? 'CMC 数据' : 'CMC data');
+ await expect(frame.locator('#jh-binance-trading-data-panel')).toHaveCSS('background-color', scenario.theme === 'dark' ? 'rgb(30, 35, 41)' : 'rgb(255, 255, 255)');
+ await expect(frame.locator('[data-role="current-funding"]')).toHaveText('0.00378%');
+ await expect(frame.locator('[data-metric="funding"] .td-last-value')).toHaveText('0.000029%');
+ await expect(frame.locator('[data-role="funding-period"]')).toHaveText(scenario.locale === 'zh-CN' ? '当前 · 4小时' : 'Current · 4h');
+ if (scenario.viewport.width < 500) await frame.locator('[data-metric="funding"]').scrollIntoViewIfNeeded();
+ const readings = await page.locator('#preview-frame').evaluate(element => {
+ const document = element.contentDocument;
+ return {
+ viewport: { width: element.contentWindow.innerWidth, height: element.contentWindow.innerHeight },
+ versions: element.contentWindow.previewEvidence.versions,
+ panels: ['jh-binance-trading-data-panel', 'jh-binance-cmc-data-panel'].map(id => {
+ const panel = document.getElementById(id);
+ const body = document.getElementById(`${id}-body`);
+ const { x, y, width, height } = panel.getBoundingClientRect();
+ return {
+ id, x, y, width, height, bodyWidth: body.clientWidth, bodyScrollWidth: body.scrollWidth,
+ minFont: Math.min(...[...panel.querySelectorAll('small')].filter(node => node.getClientRects().length).map(node => parseFloat(element.contentWindow.getComputedStyle(node).fontSize))),
+ };
+ }),
+ };
+ });
+ const [trading, cmc] = readings.panels;
+ const overlap = Math.max(0, Math.min(trading.x + trading.width, cmc.x + cmc.width) - Math.max(trading.x, cmc.x))
+ * Math.max(0, Math.min(trading.y + trading.height, cmc.y + cmc.height) - Math.max(trading.y, cmc.y));
+ assert.equal(overlap, 0);
+ for (const panel of readings.panels) {
+ assert.ok(panel.minFont >= 11, `${panel.id} annotation font is below 11px`);
+ assert.ok(panel.bodyScrollWidth <= panel.bodyWidth, `${panel.id} has horizontal overflow`);
+ assert.ok(panel.x >= 0 && panel.x + panel.width <= readings.viewport.width, `${panel.id} exceeds viewport width`);
+ assert.ok(panel.y >= 0 && panel.y + panel.height <= readings.viewport.height, `${panel.id} exceeds viewport height`);
+ }
+ assert.deepEqual(errors, []);
+ assert.deepEqual(externalRequests, []);
+ assert.deepEqual(readings.versions, manifest.artifacts);
+ await page.screenshot({ path: fileURLToPath(new URL(`${scenario.name}.png`, output)), fullPage: true });
+ checks.push({ ...scenario, ...readings, overlap, errors, externalRequests: externalRequests.length });
+ await context.close();
+ }
+} finally {
+ await browser.close();
+}
+await writeFile(new URL('browser-checks.json', output), JSON.stringify(checks, null, 2) + '\n');
+console.log(JSON.stringify(checks.map(({ name, overlap, errors, externalRequests }) => ({ name, overlap, errors, externalRequests }))));
diff --git a/e2e/binance-orderbook/specs/data-panel-position.pw.js b/e2e/binance-orderbook/specs/data-panel-position.pw.js
index 5fbda2fb..9db5eaaf 100644
--- a/e2e/binance-orderbook/specs/data-panel-position.pw.js
+++ b/e2e/binance-orderbook/specs/data-panel-position.pw.js
@@ -1,173 +1,15 @@
-import assert from 'node:assert/strict';
-import { fileURLToPath } from 'node:url';
import { test, expect, reloadPageWithCoverage } from '../test.js';
-import { cmcDetail, tradingDataset } from '../../../test/helpers/data-media-migration-host.js';
+import {
+ DATA_PANEL_NOW as NOW, DATA_PANELS as PANELS, POSITION_AUDIT_KEY as AUDIT_KEY,
+ CMC_HEADERS, createDataPanelsFixture as createFixture, installDataPanels as installPanels,
+} from '../helpers/data-panels-host.js';
-const ORIGIN = 'https://www.binance.com';
-const FIXTURE_URL = `${ORIGIN}/zh-CN/futures/BTCUSDT`;
-const NOW = Date.UTC(2026, 9, 9, 0, 0, 0);
-const AUDIT_KEY = '__data_panel_fixture_position_writes__';
const LARGE = { width: 1600, height: 1200 };
const SMALL = { width: 640, height: 420 };
const INITIAL = {
- trading: { left: 1250, top: 700 },
- cmc: { left: 930, top: 620 },
+ trading: { left: 1080, top: 700 },
+ cmc: { left: 530, top: 620 },
};
-const PANELS = [
- {
- name: 'trading', id: 'jh-binance-trading-data-panel',
- key: 'jh_binance_trading_data_pos', artifact: 'binance-trading-data',
- },
- {
- name: 'cmc', id: 'jh-binance-cmc-data-panel',
- key: 'jh_binance_cmc_data_pos', artifact: 'binance-coinmarketcap-data',
- },
-];
-const CMC_HEADERS = {
- Accept: 'application/json, text/plain, */*',
- 'Cache-Control': 'no-cache',
- Pragma: 'no-cache',
-};
-
-/** Exercise the current successful requestJson boundary; unmodeled requests fail. */
-async function createFixture(context) {
- const requests = [];
- const gmRequests = [];
- const errors = [];
- const dataset = tradingDataset(NOW);
- const map = { data: [{ id: 1, symbol: 'BTC', slug: 'bitcoin', is_active: 1 }] };
-
- await context.exposeBinding('__dataPanelFixtureCmcResponse', (_source, request) => {
- assert.deepEqual(Object.keys(request).sort(), ['headers', 'method', 'timeout', 'url']);
- assert.equal(request.method, 'GET');
- assert.equal(request.timeout, 20_000);
- assert.deepEqual(request.headers, CMC_HEADERS);
- gmRequests.push(request);
- const url = new URL(request.url);
- let body;
- if (url.origin === 'https://api.coinmarketcap.com' && url.pathname === '/data-api/v1/cryptocurrency/map') {
- assert.deepEqual(Object.fromEntries(url.searchParams), {
- symbol: 'BTC', listing_status: 'active', _: String(NOW),
- });
- body = map;
- } else if (url.origin === 'https://api.coinmarketcap.com' && url.pathname === '/data-api/v3/cryptocurrency/detail') {
- assert.deepEqual(Object.fromEntries(url.searchParams), {
- id: '1', convertId: '2781', languageCode: 'zh', _: String(NOW),
- });
- body = { data: cmcDetail() };
- } else if (url.origin === 'https://dapi.coinmarketcap.com' && url.pathname === '/dex-stats/v3/dexer/crypto-holder/show_holders') {
- assert.deepEqual(Object.fromEntries(url.searchParams), { cryptoId: '1', _: String(NOW) });
- body = { data: { showFlag: true, count: 10_000 } };
- } else {
- assert.fail(`Unmodeled CMC request: ${url.origin}${url.pathname}`);
- }
- return { status: 200, responseText: JSON.stringify(body) };
- });
-
- await context.addInitScript(({ origin, keys, auditKey }) => {
- if (location.origin !== origin) return;
- const nativeSetItem = Storage.prototype.setItem;
- if (localStorage.getItem(auditKey) === null) {
- nativeSetItem.call(localStorage, auditKey, '[]');
- }
- window.dataPanelFixture = { resizeEvents: 0, gmCompletions: [], pointerEvents: [] };
- window.addEventListener('resize', () => { window.dataPanelFixture.resizeEvents += 1; });
- document.addEventListener('mousemove', event => {
- window.dataPanelFixture.pointerEvents.push({
- buttons: event.buttons, x: event.clientX, y: event.clientY, trusted: event.isTrusted,
- });
- }, { capture: true });
-
- // Keep actual browser persistence and retain evidence after real tab teardown.
- Storage.prototype.setItem = function (key, value) {
- nativeSetItem.call(this, key, value);
- if (this !== localStorage || !keys.includes(key)) return;
- const writes = JSON.parse(localStorage.getItem(auditKey));
- writes.push({ key, value: JSON.parse(value) });
- nativeSetItem.call(localStorage, auditKey, JSON.stringify(writes));
- };
-
- // The source adapter consumes these callbacks and the status/responseText pair.
- window.GM_xmlhttpRequest = options => {
- for (const name of ['onload', 'onerror', 'ontimeout']) {
- if (typeof options[name] !== 'function') throw new Error(`Missing GM callback: ${name}`);
- }
- const request = {
- method: options.method, url: options.url,
- timeout: options.timeout, headers: options.headers,
- };
- window.__dataPanelFixtureCmcResponse(request).then(response => {
- window.dataPanelFixture.gmCompletions.push({ url: options.url, response });
- options.onload(response);
- });
- };
- }, { origin: ORIGIN, keys: PANELS.map(panel => panel.key), auditKey: AUDIT_KEY });
-
- await context.route('**/*', async route => {
- const request = route.request();
- const url = new URL(request.url());
- assert.equal(request.method(), 'GET');
- if (url.href === FIXTURE_URL && request.isNavigationRequest()) {
- await route.fulfill({
- contentType: 'text/html; charset=utf-8',
- body: 'Data panel position fixture '
- + ''
- + 'Offline data panel position fixture ',
- });
- return;
- }
- assert.equal(url.origin, ORIGIN);
- requests.push({ method: request.method(), path: url.pathname, params: Object.fromEntries(url.searchParams) });
- if (url.pathname === '/fapi/v1/time') {
- assert.deepEqual(Object.fromEntries(url.searchParams), {});
- await route.fulfill({ json: { serverTime: NOW } });
- return;
- }
- const endpoint = url.pathname.split('/').at(-1);
- assert.equal(Object.hasOwn(dataset, endpoint), true, `Unmodeled fetch: ${url.pathname}`);
- if (endpoint === 'fundingRate') {
- assert.equal(url.pathname, '/fapi/v1/fundingRate');
- assert.deepEqual(Object.fromEntries(url.searchParams), { symbol: 'BTCUSDT', limit: '1' });
- } else if (endpoint === 'basis') {
- assert.equal(url.pathname, '/futures/data/basis');
- assert.deepEqual(Object.fromEntries(url.searchParams), {
- pair: 'BTCUSDT', period: '5m', limit: '30', contractType: 'PERPETUAL',
- });
- } else {
- assert.equal(url.pathname, `/futures/data/${endpoint}`);
- assert.deepEqual(Object.fromEntries(url.searchParams), { symbol: 'BTCUSDT', period: '5m', limit: '30' });
- }
- await route.fulfill({ json: dataset[endpoint] });
- });
-
- async function open(page, { viewport = LARGE, positions, install = true } = {}) {
- page.on('pageerror', error => errors.push(error.message));
- await page.setViewportSize(viewport);
- await page.clock.setFixedTime(NOW);
- await page.goto(FIXTURE_URL);
- if (positions) {
- await page.evaluate(({ panels, positions, auditKey }) => {
- for (const panel of panels) localStorage.setItem(panel.key, JSON.stringify(positions[panel.name]));
- localStorage.setItem(auditKey, '[]');
- }, { panels: PANELS, positions, auditKey: AUDIT_KEY });
- }
- if (install) await installPanels(page);
- }
-
- return { open, requests, gmRequests, errors, map };
-}
-
-async function installPanels(page) {
- for (const panel of PANELS) {
- const path = fileURLToPath(new URL(`../../../scripts/${panel.artifact}.user.js`, import.meta.url));
- await page.addScriptTag({ path });
- }
- await expect(page.locator('#jh-binance-trading-data-panel [data-role="updated-at"]')).toContainText('更新于');
- await expect(page.locator('#jh-binance-trading-data-panel-symbol')).toHaveText('BTCUSDT');
- await expect(page.locator('#jh-binance-cmc-data-panel-footer a')).toHaveText('CMC data-api');
- await expect(page.locator('#jh-binance-cmc-data-panel-symbol')).toHaveText('BTC #1');
- await expect(page.locator('#jh-binance-cmc-data-panel-rows')).toContainText('$6万');
-}
async function readPositions(page) {
return page.evaluate(panels => Object.fromEntries(panels.map(panel => [
@@ -182,7 +24,7 @@ async function readWrites(page) {
async function assertPositions(page, positions) {
for (const panel of PANELS) {
expect(await page.locator(`#${panel.id}`).boundingBox()).toMatchObject({
- x: positions[panel.name].left, y: positions[panel.name].top, width: 242,
+ x: positions[panel.name].left, y: positions[panel.name].top, width: panel.width,
});
}
}
@@ -277,7 +119,7 @@ test('user restores both separate panel preferences after a smaller viewport', a
await resize(page, SMALL);
// Then only the displayed positions clamp, leaving each original preference unchanged.
- await assertPositions(page, { trading: { left: 398, top: 372 }, cmc: { left: 398, top: 372 } });
+ await assertPositions(page, { trading: { left: 160, top: 372 }, cmc: { left: 140, top: 372 } });
expect(await readPositions(page)).toEqual(INITIAL);
expect(await readWrites(page)).toEqual([]);
await page.screenshot({ path: testInfo.outputPath('small-clamped.png') });
@@ -303,7 +145,7 @@ test('user keeps large-screen preferences when a small tab opens reloads and clo
await fixture.open(small, { viewport: SMALL });
// Then the small tab displays reachable headers while both tabs retain the original preferences.
- await assertPositions(small, { trading: { left: 398, top: 372 }, cmc: { left: 398, top: 372 } });
+ await assertPositions(small, { trading: { left: 160, top: 372 }, cmc: { left: 140, top: 372 } });
expect(await readPositions(page)).toEqual(INITIAL);
expect(await readWrites(page)).toEqual([]);
@@ -312,7 +154,7 @@ test('user keeps large-screen preferences when a small tab opens reloads and clo
await installPanels(small);
// Then reload and initialization leave the shared preferences untouched.
- await assertPositions(small, { trading: { left: 398, top: 372 }, cmc: { left: 398, top: 372 } });
+ await assertPositions(small, { trading: { left: 160, top: 372 }, cmc: { left: 140, top: 372 } });
expect(await readPositions(page)).toEqual(INITIAL);
expect(await readWrites(page)).toEqual([]);
@@ -361,10 +203,10 @@ test('user keeps a newer drag preference after an older tab closes', async ({ pa
test('user clicks titles and collapse controls on a small screen without replacing preferred positions', async ({ page, context }) => {
// Given both preferred positions clamp vertically but their headers remain separately clickable.
const fixture = await createFixture(context);
- const preferred = { trading: { left: 80, top: 720 }, cmc: { left: 390, top: 660 } };
+ const preferred = { trading: { left: 80, top: 720 }, cmc: { left: 690, top: 660 } };
await fixture.open(page, { positions: preferred });
- await resize(page, { width: 800, height: 460 });
- await assertPositions(page, { trading: { left: 80, top: 412 }, cmc: { left: 390, top: 412 } });
+ await resize(page, { width: 1200, height: 460 });
+ await assertPositions(page, { trading: { left: 80, top: 412 }, cmc: { left: 690, top: 412 } });
// When the user clicks each title without pointer movement and toggles each collapse control.
for (const panel of PANELS) {
@@ -378,7 +220,7 @@ test('user clicks titles and collapse controls on a small screen without replaci
// Then title clicks and collapse changes do not save any clamped display coordinate.
expect(await readPositions(page)).toEqual(preferred);
expect(await readWrites(page)).toEqual([]);
- await assertPositions(page, { trading: { left: 80, top: 412 }, cmc: { left: 390, top: 412 } });
+ await assertPositions(page, { trading: { left: 80, top: 412 }, cmc: { left: 690, top: 412 } });
expect(fixture.errors).toEqual([]);
});
@@ -386,7 +228,7 @@ for (const panel of PANELS) {
test(`user saves the ${panel.name} panel only once after a drag ends at a changed position`, async ({ page, context }) => {
// Given both panels have saved positions and the selected header can move freely.
const fixture = await createFixture(context);
- const initial = { trading: { left: 80, top: 80 }, cmc: { left: 420, top: 100 } };
+ const initial = { trading: { left: 80, top: 80 }, cmc: { left: 660, top: 100 } };
const target = panel.name === 'trading' ? { left: 220, top: 220 } : { left: 640, top: 280 };
await fixture.open(page, { positions: initial });
@@ -433,7 +275,7 @@ for (const panel of PANELS) {
test(`user ignores right-button movement over the ${panel.name} header`, async ({ page, context }) => {
// Given both generated panels have separate saved positions and a real mouse.
const fixture = await createFixture(context);
- const initial = { trading: { left: 80, top: 80 }, cmc: { left: 420, top: 100 } };
+ const initial = { trading: { left: 80, top: 80 }, cmc: { left: 660, top: 100 } };
await fixture.open(page, { positions: initial });
// When the user presses the right button and moves across the selected header.
@@ -462,7 +304,7 @@ for (const panel of PANELS) {
await nextFrames(page);
// Then the viewport adjustment is temporary and cannot become a completed drag preference.
- await assertPositions(page, { trading: { left: 398, top: 372 }, cmc: { left: 398, top: 372 } });
+ await assertPositions(page, { trading: { left: 160, top: 372 }, cmc: { left: 140, top: 372 } });
expect(await readPositions(page)).toEqual(INITIAL);
expect(await readWrites(page)).toEqual([]);
@@ -481,7 +323,7 @@ for (const panel of PANELS) {
test(`user abandons the ${panel.name} drag after ${cancellation}`, async ({ page, context }) => {
// Given both panels have saved preferences and the selected panel is mid-drag.
const fixture = await createFixture(context);
- const initial = { trading: { left: 80, top: 80 }, cmc: { left: 420, top: 100 } };
+ const initial = { trading: { left: 80, top: 80 }, cmc: { left: 660, top: 100 } };
const target = { left: initial[panel.name].left + 80, top: 220 };
await fixture.open(page, { positions: initial });
await startDrag(page, panel, target);
@@ -515,11 +357,16 @@ test('user keeps responsive default panel positions without creating a saved pre
// When the viewport changes to a smaller screen and back through real resize events.
await resize(page, SMALL);
- await assertPositions(page, { trading: { left: 382, top: 60 }, cmc: { left: 382, top: 360 } });
+ const trading = await page.locator(`#${PANELS[0].id}`).boundingBox();
+ const cmc = await page.locator(`#${PANELS[1].id}`).boundingBox();
+ expect(trading).toMatchObject({ x: 144, y: 8, width: 480 });
+ expect(cmc).toMatchObject({ x: 124, width: 500 });
+ expect(cmc.y - trading.y - trading.height).toBeCloseTo(8, 1);
+ expect(cmc.y + cmc.height).toBeLessThanOrEqual(SMALL.height);
await resize(page, LARGE);
- // Then the original right inset and distinct default heights return without new position keys.
- await assertPositions(page, { trading: { left: 1342, top: 60 }, cmc: { left: 1342, top: 360 } });
+ // Then the original right inset and separate desktop columns return without new position keys.
+ await assertPositions(page, { trading: { left: 1104, top: 60 }, cmc: { left: 588, top: 60 } });
expect(await readPositions(page)).toEqual({ trading: null, cmc: null });
expect(await readWrites(page)).toEqual([]);
diff --git a/e2e/binance-orderbook/specs/data-panels-presentation.pw.js b/e2e/binance-orderbook/specs/data-panels-presentation.pw.js
new file mode 100644
index 00000000..2ac2f8df
--- /dev/null
+++ b/e2e/binance-orderbook/specs/data-panels-presentation.pw.js
@@ -0,0 +1,379 @@
+import { test, expect } from '../test.js';
+import { tradingDataset } from '../../../test/helpers/data-media-migration-host.js';
+import {
+ createDataPanelsFixture, installDataPanels, DATA_PANEL_NOW, DATA_PANELS, POSITION_AUDIT_KEY,
+} from '../helpers/data-panels-host.js';
+
+const SIDE_BY_SIDE = { trading: { left: 1040, top: 60 }, cmc: { left: 520, top: 60 } };
+
+async function completedLayoutFrames(page) {
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+}
+
+async function storedLayout(page) {
+ return page.evaluate(({ panels, key }) => ({
+ positions: Object.fromEntries(panels.map(item => [item.name, JSON.parse(localStorage.getItem(item.key))])),
+ writes: JSON.parse(localStorage.getItem(key)),
+ }), { panels: DATA_PANELS, key: POSITION_AUDIT_KEY });
+}
+
+function presentationDataset() {
+ const dataset = tradingDataset(DATA_PANEL_NOW, { ratio: 1.234567, funding: 0.00000029 });
+ dataset.fundingRate = [
+ { symbol: 'BTCUSDT', fundingTime: DATA_PANEL_NOW - 28_800_000, fundingRate: '0.0001' },
+ { symbol: 'BTCUSDT', fundingTime: DATA_PANEL_NOW - 14_400_000, fundingRate: '0.00005' },
+ { symbol: 'BTCUSDT', fundingTime: DATA_PANEL_NOW, fundingRate: '0.00000029' },
+ ];
+ return dataset;
+}
+
+test('user switches both real panels between Chinese and English while retaining precise rates', async ({ page, context }) => {
+ // Given the generated panels receive a tiny settled rate and a distinct current rate
+ const fixture = await createDataPanelsFixture(context, { dataset: presentationDataset() });
+ await fixture.open(page, { positions: SIDE_BY_SIDE });
+ const trading = page.locator('#jh-binance-trading-data-panel');
+ const cmc = page.locator('#jh-binance-cmc-data-panel');
+
+ // When the Chinese route finishes rendering the current historical response
+ await expect(trading.locator('[data-metric="funding"] .td-last-value')).toHaveText('0.000029%');
+
+ // Then names, ratios, current funding and confirmed interval retain their distinct meanings
+ await expect(trading.locator('.td-title')).toHaveText('交易数据');
+ await expect(cmc.locator('.cmc-title')).toHaveText('CMC 数据');
+ await expect(trading.locator('[data-metric="top-accounts"] .td-number')).toHaveText('1.23');
+ await expect(trading.locator('[data-role="current-funding"]')).toHaveText('0.00378%');
+ await expect(trading.locator('[data-role="funding-period"]')).toHaveText('当前 · 4小时');
+ await expect(trading.locator('[data-role="funding-countdown"]')).toHaveText('倒计时 04:00:00');
+ await expect(trading.locator('[data-metric="funding"] [data-history-index]')).toHaveCount(3);
+ await expect(trading.locator('[data-metric="taker"] .td-unit')).toHaveText('买/卖比');
+
+ // When the same SPA changes to its English route
+ await page.evaluate(() => history.pushState({}, '', '/en/futures/BTCUSDT'));
+
+ // Then both panels change their labels without changing the observed numerical values
+ await expect(trading.locator('.td-title')).toHaveText('Trading data');
+ await expect(cmc.locator('.cmc-title')).toHaveText('CMC data');
+ await expect(trading.locator('[data-metric="top-accounts"] .td-name')).toHaveText('Top Trader Long/Short Ratio (Accounts)');
+ await expect(trading.locator('[data-metric="top-accounts"] .td-number')).toHaveText('1.23');
+ await expect(trading.locator('[data-metric="funding"] .td-last-value')).toHaveText('0.000029%');
+ await expect(trading.locator('[data-role="funding-period"]')).toHaveText('Current · 4h');
+ await expect(trading.locator('#jh-binance-trading-data-panel-close')).toHaveAttribute('aria-label', 'Close');
+ await expect(cmc.locator('#jh-binance-cmc-data-panel-refresh')).toHaveAttribute('aria-label', 'Refresh');
+ expect(fixture.errors).toEqual([]);
+});
+
+test('user inspects settled history by click and keyboard without joining the current funding rate', async ({ page, context }) => {
+ // Given three settled observations accompany an independent current funding snapshot
+ const fixture = await createDataPanelsFixture(context, { dataset: presentationDataset() });
+ await fixture.open(page, { locale: 'en', positions: SIDE_BY_SIDE });
+ const trading = page.locator('#jh-binance-trading-data-panel');
+ const chart = trading.locator('[data-metric="funding"] .td-spark-button');
+
+ // When the user opens the last settled point and moves to its predecessor
+ await chart.locator('[data-history-index="2"]').click();
+ await expect(trading.locator('.td-history-detail time')).toHaveAttribute('datetime', new Date(DATA_PANEL_NOW).toISOString());
+ await chart.press('ArrowLeft');
+
+ // Then the detail contains that exact historical time and rate while the current snapshot remains separate
+ await expect(trading.locator('.td-history-detail time')).toHaveAttribute('datetime', new Date(DATA_PANEL_NOW - 14_400_000).toISOString());
+ await expect(trading.locator('.td-history-value')).toHaveText('0.005%');
+ await expect(chart).toHaveAttribute('aria-label', /0\.005%/);
+ await expect(trading.locator('[data-role="current-funding"]')).toHaveText('0.00378%');
+ await expect(chart.locator('[data-history-index]')).toHaveCount(3);
+
+ // When keyboard navigation selects the first and last observed records and closes inspection
+ await chart.press('Home');
+ await expect(trading.locator('.td-history-detail time')).toHaveAttribute('datetime', new Date(DATA_PANEL_NOW - 28_800_000).toISOString());
+ await chart.press('End');
+ await expect(trading.locator('.td-history-value')).toHaveText('0.000029%');
+ await chart.press('Escape');
+
+ // Then details close without removing the accessible history control
+ await expect(trading.locator('.td-history-detail')).toHaveCount(0);
+ await expect(chart).toHaveAttribute('aria-expanded', 'false');
+ expect(fixture.errors).toEqual([]);
+});
+
+test('user reads separate taker quantities and can expand the CMC interpretation', async ({ page, context }) => {
+ // Given both panels have complete public response fixtures with taker buy and sell quantities
+ const fixture = await createDataPanelsFixture(context, { dataset: presentationDataset() });
+ await fixture.open(page, { locale: 'en', positions: SIDE_BY_SIDE });
+ const trading = page.locator('#jh-binance-trading-data-panel');
+ const cmc = page.locator('#jh-binance-cmc-data-panel');
+
+ // When the user opens taker history and the CMC price interpretation
+ await trading.locator('[data-metric="taker"] .td-spark-button').click();
+ await cmc.locator('[data-metric="price"] .cmc-reading-button').click();
+
+ // Then chart details expose both quantity units while interpretation has a visible explanatory row
+ await expect(trading.locator('[data-metric="taker"] .td-buy-bar')).toHaveCount(1);
+ await expect(trading.locator('[data-metric="taker"] .td-sell-bar')).toHaveCount(1);
+ await expect(trading.locator('.td-history-value')).toHaveText('Buy 123.46 BTC · Sell 100 BTC · Buy/sell ratio 1.23');
+ await expect(trading.locator('[data-metric="taker"] .td-number')).toHaveText('1.23');
+ await expect(cmc.locator('#jh-binance-cmc-data-panel-explanation-price')).toBeVisible();
+ await expect(cmc.locator('[data-metric="price"] .cmc-reading-button')).toHaveAttribute('aria-expanded', 'true');
+ expect(fixture.errors).toEqual([]);
+});
+
+test('user sees the calibrated countdown advance while historical nodes and update time stay unchanged', async ({ page, context }) => {
+ // Given the generated trading script runs on a controlled browser clock with complete history
+ const fixture = await createDataPanelsFixture(context, { dataset: presentationDataset() });
+ await fixture.open(page, { controlledClock: true, panels: ['trading'] });
+ const trading = page.locator('#jh-binance-trading-data-panel');
+ await expect(trading.locator('[data-role="current-funding"]')).toHaveText('0.00378%');
+ const updated = await trading.locator('[data-role="updated-at"]').textContent();
+ await page.evaluate(() => {
+ window.dataPanelClockNodes = {
+ row: document.querySelector('#jh-binance-trading-data-panel [data-metric="funding"]'),
+ chart: document.querySelector('#jh-binance-trading-data-panel [data-metric="funding"] svg'),
+ footer: document.querySelector('#jh-binance-trading-data-panel-footer'),
+ };
+ });
+
+ // When exactly one second of business time elapses
+ await page.clock.runFor(1_000);
+
+ // Then countdown and age advance without rebuilding settled history or its update timestamp
+ await expect(trading.locator('[data-role="funding-countdown"]')).toHaveText('倒计时 03:59:59');
+ await expect(trading.locator('[data-role="updated-at"]')).toHaveText(updated);
+ await expect(trading.locator('[data-role="elapsed"]')).toHaveText('1秒前');
+ expect(await page.evaluate(() => ({
+ row: window.dataPanelClockNodes.row === document.querySelector('#jh-binance-trading-data-panel [data-metric="funding"]'),
+ chart: window.dataPanelClockNodes.chart === document.querySelector('#jh-binance-trading-data-panel [data-metric="funding"] svg'),
+ footer: window.dataPanelClockNodes.footer === document.querySelector('#jh-binance-trading-data-panel-footer'),
+ }))).toEqual({ row: true, chart: true, footer: true });
+ expect(fixture.errors).toEqual([]);
+});
+
+for (const locale of ['zh-CN', 'en']) {
+ test(`user reads ${locale} panels in both themes on a narrow screen without tiny text or horizontal overflow`, async ({ page, context }) => {
+ // Given both generated panels are rendered on a narrow viewport in the light theme
+ const fixture = await createDataPanelsFixture(context, { dataset: presentationDataset() });
+ await fixture.open(page, { locale, viewport: { width: 390, height: 844 } });
+ const lightColors = await page.evaluate(ids => ids.map(id => getComputedStyle(document.getElementById(id)).color), DATA_PANELS.map(panel => panel.id));
+
+ // When the user changes to the dark theme and narrows the viewport further
+ await page.emulateMedia({ colorScheme: 'dark' });
+ await page.setViewportSize({ width: 320, height: 680 });
+
+ // Then theme colors change while every visible annotation stays readable and within its panel width
+ const readings = await page.evaluate(ids => ids.map(id => {
+ const panel = document.getElementById(id);
+ const body = document.getElementById(`${id}-body`);
+ const rect = panel.getBoundingClientRect();
+ return {
+ color: getComputedStyle(panel).color, right: rect.right,
+ bodyWidth: body.clientWidth, bodyScrollWidth: body.scrollWidth,
+ minFont: Math.min(...[...panel.querySelectorAll('small')].filter(node => node.getClientRects().length > 0).map(node => parseFloat(getComputedStyle(node).fontSize))),
+ };
+ }), DATA_PANELS.map(panel => panel.id));
+ for (let index = 0; index < readings.length; index += 1) {
+ expect(readings[index].color).not.toBe(lightColors[index]);
+ expect(readings[index].right).toBeLessThanOrEqual(320);
+ expect(readings[index].bodyScrollWidth).toBeLessThanOrEqual(readings[index].bodyWidth);
+ expect(readings[index].minFont).toBeGreaterThanOrEqual(11);
+ }
+ expect(fixture.errors).toEqual([]);
+ });
+}
+
+test('user can see both expanded panels at their default desktop positions without overlap', async ({ page, context }) => {
+ // Given a desktop has no saved position for either panel
+ const fixture = await createDataPanelsFixture(context, { dataset: presentationDataset() });
+
+ // When both generated panels finish their first real render
+ await fixture.open(page);
+
+ // Then the two default panels do not hide each other
+ const trading = await page.locator('#jh-binance-trading-data-panel').boundingBox();
+ const cmc = await page.locator('#jh-binance-cmc-data-panel').boundingBox();
+ const overlapWidth = Math.max(0, Math.min(trading.x + trading.width, cmc.x + cmc.width) - Math.max(trading.x, cmc.x));
+ const overlapHeight = Math.max(0, Math.min(trading.y + trading.height, cmc.y + cmc.height) - Math.max(trading.y, cmc.y));
+ expect({ overlapArea: overlapWidth * overlapHeight, trading, cmc }).toMatchObject({ overlapArea: 0 });
+ expect(fixture.errors).toEqual([]);
+});
+
+for (const installation of [['trading', 'cmc'], ['cmc', 'trading']]) {
+ test(`user receives separate desktop columns and narrow-screen scroll areas when ${installation[0]} loads first`, async ({ page, context }) => {
+ // Given neither panel has saved coordinates and scripts load in the specified order
+ const fixture = await createDataPanelsFixture(context, { dataset: presentationDataset() });
+ await fixture.open(page, { panels: installation });
+ const trading = page.locator('#jh-binance-trading-data-panel');
+ const cmc = page.locator('#jh-binance-cmc-data-panel');
+
+ // When both generated scripts have completed their first desktop render
+ await expect(trading.locator('[data-role="updated-at"]')).toContainText('更新于');
+ await expect(cmc.locator('#jh-binance-cmc-data-panel-footer a')).toHaveText('CMC data-api');
+
+ // Then each desktop panel has its own column regardless of installation order
+ expect(await trading.boundingBox()).toMatchObject({ x: 1104, y: 60, width: 480 });
+ expect(await cmc.boundingBox()).toMatchObject({ x: 588, y: 60, width: 500 });
+
+ // When the viewport becomes too narrow for adjacent columns
+ await page.setViewportSize({ width: 320, height: 680 });
+
+ // Then both headers stay reachable above separate scrolling bodies with an eight-pixel gap
+ const readings = await page.evaluate(ids => ids.map(id => {
+ const panel = document.getElementById(id);
+ const body = document.getElementById(`${id}-body`);
+ const { x, y, width, height, bottom } = panel.getBoundingClientRect();
+ return { x, y, width, height, bottom, bodyHeight: body.clientHeight, bodyScrollHeight: body.scrollHeight };
+ }), DATA_PANELS.map(panel => panel.id));
+ expect(readings[0].y).toBe(8);
+ expect(readings[1].y - readings[0].bottom).toBeCloseTo(8, 1);
+ expect(readings[1].bottom).toBeLessThanOrEqual(680);
+ expect(Math.abs(readings[0].height - readings[1].height)).toBeLessThanOrEqual(1);
+ for (const reading of readings) {
+ expect(reading.x).toBeGreaterThanOrEqual(0);
+ expect(reading.x + reading.width).toBeLessThanOrEqual(320);
+ expect(reading.bodyHeight).toBeGreaterThan(0);
+ expect(reading.bodyScrollHeight).toBeGreaterThan(reading.bodyHeight);
+ }
+ expect(await page.evaluate(({ panels, key }) => ({
+ positions: panels.map(panel => localStorage.getItem(panel.key)), writes: JSON.parse(localStorage.getItem(key)),
+ }), { panels: DATA_PANELS, key: POSITION_AUDIT_KEY })).toEqual({ positions: [null, null], writes: [] });
+ expect(fixture.errors).toEqual([]);
+ });
+}
+
+for (const panel of DATA_PANELS) {
+ test(`user gets an independent top-right default when only the ${panel.name} script is installed`, async ({ page, context }) => {
+ // Given a desktop has no prior position preference and only one data script
+ const fixture = await createDataPanelsFixture(context, { dataset: presentationDataset() });
+
+ // When the selected generated script finishes its initial response
+ await fixture.open(page, { panels: [panel.name] });
+
+ // Then its full panel uses the single-panel top-right default and creates no saved coordinates
+ expect(await page.locator(`#${panel.id}`).boundingBox()).toMatchObject({ x: 1600 - panel.width - 16, y: 60, width: panel.width });
+ await expect(page.locator(`#${DATA_PANELS.find(candidate => candidate.name !== panel.name).id}`)).toHaveCount(0);
+ expect(await page.evaluate(({ positionKey, auditKey }) => ({
+ position: localStorage.getItem(positionKey), writes: JSON.parse(localStorage.getItem(auditKey)),
+ }), { positionKey: panel.key, auditKey: POSITION_AUDIT_KEY })).toEqual({ position: null, writes: [] });
+ expect(fixture.errors).toEqual([]);
+ });
+
+ test(`user keeps saved coordinates when the ${panel.name} panel closes and the remaining panel is removed by navigation`, async ({ page, context }) => {
+ // Given each panel has a distinct user-saved position
+ const fixture = await createDataPanelsFixture(context, { dataset: presentationDataset() });
+ await fixture.open(page, { positions: SIDE_BY_SIDE });
+ const remaining = DATA_PANELS.find(candidate => candidate.name !== panel.name);
+
+ // When the user closes one panel and resizes the surviving panel's viewport
+ await page.locator(`#${panel.id}-close`).click();
+ await page.setViewportSize({ width: 1360, height: 900 });
+
+ // Then the remaining panel projects its saved preference and closing writes no coordinates
+ await expect(page.locator(`#${panel.id}`)).toBeHidden();
+ expect(await page.locator(`#${remaining.id}`).boundingBox()).toMatchObject({
+ x: Math.min(SIDE_BY_SIDE[remaining.name].left, 1360 - remaining.width),
+ y: SIDE_BY_SIDE[remaining.name].top,
+ });
+ expect(await page.evaluate(({ panels, key }) => ({
+ positions: Object.fromEntries(panels.map(item => [item.name, JSON.parse(localStorage.getItem(item.key))])),
+ writes: JSON.parse(localStorage.getItem(key)),
+ }), { panels: DATA_PANELS, key: POSITION_AUDIT_KEY })).toEqual({ positions: SIDE_BY_SIDE, writes: [] });
+
+ // When navigation removes the active panel and the desktop size returns
+ await page.evaluate(() => history.pushState({}, '', '/en/futures'));
+ await expect(page.locator(`#${remaining.id}`)).toHaveCount(0);
+ await page.setViewportSize({ width: 1600, height: 1200 });
+
+ // Then removed layout listeners cannot replace either stored user preference
+ expect(await page.evaluate(({ panels, key }) => ({
+ positions: Object.fromEntries(panels.map(item => [item.name, JSON.parse(localStorage.getItem(item.key))])),
+ writes: JSON.parse(localStorage.getItem(key)),
+ }), { panels: DATA_PANELS, key: POSITION_AUDIT_KEY })).toEqual({ positions: SIDE_BY_SIDE, writes: [] });
+ expect(fixture.errors).toEqual([]);
+ });
+}
+
+test('user releases a stationary CMC header after the trading panel mounts without saving its automatic move', async ({ page, context }) => {
+ // Given only CMC is mounted and its header has a pressed mouse without any movement
+ const fixture = await createDataPanelsFixture(context);
+ await fixture.open(page, { panels: ['cmc'] });
+ const header = await page.locator('#jh-binance-cmc-data-panel-header').boundingBox();
+ await page.mouse.move(header.x + 14, header.y + 14);
+ await page.mouse.down();
+
+ // When trading mounts and automatically moves the CMC panel into the adjacent column
+ await installDataPanels(page, { panels: ['trading'] });
+ await expect.poll(async () => (await page.locator('#jh-binance-cmc-data-panel').boundingBox()).x).toBe(588);
+ await page.mouse.up();
+ await completedLayoutFrames(page);
+
+ // Then the layout event cancels the stationary gesture and cannot turn it into a saved drag
+ expect(await storedLayout(page)).toEqual({ positions: { trading: null, cmc: null }, writes: [] });
+ expect(fixture.errors).toEqual([]);
+});
+
+test('user releases a stationary CMC header after its peer closes without saving its automatic move', async ({ page, context }) => {
+ // Given both panels are mounted and the CMC header is pressed without pointer movement
+ const fixture = await createDataPanelsFixture(context);
+ await fixture.open(page);
+ const header = await page.locator('#jh-binance-cmc-data-panel-header').boundingBox();
+ await page.mouse.move(header.x + 14, header.y + 14);
+ await page.mouse.down();
+
+ // When keyboard activation closes trading while the mouse remains pressed on CMC
+ await page.locator('#jh-binance-trading-data-panel-close').focus();
+ await page.keyboard.press('Enter');
+ await expect.poll(async () => (await page.locator('#jh-binance-cmc-data-panel').boundingBox()).x).toBe(1084);
+ await page.mouse.up();
+ await completedLayoutFrames(page);
+
+ // Then the automatic single-panel layout cannot be recorded as a completed user drag
+ expect(await storedLayout(page)).toEqual({ positions: { trading: null, cmc: null }, writes: [] });
+ expect(fixture.errors).toEqual([]);
+});
+
+test('user keeps the one saved panel coordinate when the other panel uses an automatic layout', async ({ page, context }) => {
+ // Given only CMC has a saved position even though it overlaps the usual trading column
+ const fixture = await createDataPanelsFixture(context);
+ const saved = { left: 1080, top: 60 };
+ await fixture.open(page, { positions: { cmc: saved } });
+
+ // When the desktop shrinks and then returns to its original size
+ await page.setViewportSize({ width: 1000, height: 760 });
+ await expect.poll(async () => (await page.locator('#jh-binance-cmc-data-panel').boundingBox()).x).toBe(500);
+ await page.setViewportSize({ width: 1600, height: 1200 });
+ await completedLayoutFrames(page);
+
+ // Then the exact saved preference returns and automatic layout does not replace either position key
+ expect(await page.locator('#jh-binance-cmc-data-panel').boundingBox()).toMatchObject({ x: saved.left, y: saved.top });
+ expect(await storedLayout(page)).toEqual({ positions: { trading: null, cmc: saved }, writes: [] });
+ expect(fixture.errors).toEqual([]);
+});
+
+test('user saves a collapsed panel lower once and expands a body that fits the remaining viewport height', async ({ page, context }) => {
+ // Given a fully loaded trading panel is collapsed at its default position without a stored preference
+ const fixture = await createDataPanelsFixture(context);
+ await fixture.open(page, { panels: ['trading'] });
+ const panel = page.locator('#jh-binance-trading-data-panel');
+ await expect(panel.locator('[data-role="current-funding"]')).toHaveText('0.00378%');
+ await expect(panel.locator('[data-role="funding-period"]')).toHaveText('当前 · 4小时');
+ const initial = await panel.boundingBox();
+ const target = { left: 80, top: 600 };
+ await page.locator('#jh-binance-trading-data-panel-collapse').click();
+ await expect(page.locator('#jh-binance-trading-data-panel-body')).toBeHidden();
+ await page.mouse.move(initial.x + 14, initial.y + 14);
+ await page.mouse.down();
+
+ // When the user drags the collapsed header lower and expands the saved panel again
+ await page.mouse.move(target.left + 14, target.top + 14, { steps: 3 });
+ await page.mouse.up();
+ await completedLayoutFrames(page);
+ await page.locator('#jh-binance-trading-data-panel-collapse').click();
+ await expect(page.locator('#jh-binance-trading-data-panel-body')).toBeVisible();
+
+ // Then one saved preference owns the position while automatic height recalculation writes nothing else
+ const final = await panel.boundingBox();
+ expect(final).toMatchObject({ x: target.left, y: target.top });
+ expect(final.height).toBeLessThan(initial.height);
+ expect(final.y + final.height).toBeLessThanOrEqual(1192);
+ expect(await storedLayout(page)).toEqual({
+ positions: { trading: target, cmc: null },
+ writes: [{ key: DATA_PANELS[0].key, value: target }],
+ });
+ expect(fixture.errors).toEqual([]);
+});
diff --git a/scripts/binance-coinmarketcap-data.user.js b/scripts/binance-coinmarketcap-data.user.js
index 04fe90d1..4cd33a57 100644
--- a/scripts/binance-coinmarketcap-data.user.js
+++ b/scripts/binance-coinmarketcap-data.user.js
@@ -3,9 +3,9 @@
// @namespace binance.coinmarketcap.data
// @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 0.1.19
+// @version 0.2.0
// @author jackhai9
-// @description 在 Binance 合约页面显示当前币种的 CoinMarketCap 中文页关键估值与供应量数据
+// @description Show localized CoinMarketCap valuation, supply, and metric interpretations on Binance futures pages
// @match https://www.binance.com/*/futures/*
// @match https://www.binance.com/futures/*
// @exclude https://www.binance.com/*/my/wallet/futures/*
@@ -90,6 +90,590 @@
};
}
+ // src/binance-orderbook-trade/contracts/panel-copy.js
+ var UI_LOCALE_ZH_CN = "zh-CN";
+ var UI_LOCALE_EN = "en";
+ var SUPPORTED_UI_LOCALES = Object.freeze([
+ UI_LOCALE_ZH_CN,
+ UI_LOCALE_EN
+ ]);
+ function localizedText(zhCN, en) {
+ if (typeof zhCN !== "string" || zhCN === "" || typeof en !== "string" || en === "") {
+ throw new Error("Localized UI text requires non-empty Chinese and English values");
+ }
+ return Object.freeze({ zhCN, en });
+ }
+ function isLocalizedText(value) {
+ return Boolean(
+ value && typeof value === "object" && typeof value.zhCN === "string" && typeof value.en === "string"
+ );
+ }
+ function formatLocalizedText(value, locale) {
+ if (typeof value === "string") return value;
+ if (!isLocalizedText(value)) throw new Error("Invalid localized UI text");
+ if (locale === UI_LOCALE_ZH_CN) return value.zhCN;
+ if (locale === UI_LOCALE_EN) return value.en;
+ throw new Error(`Unsupported UI locale: ${locale}`);
+ }
+ function resolveUiLocaleFromPathname(pathname) {
+ const firstSegment = String(pathname || "").split(/[?#]/, 1)[0].split("/").filter(Boolean)[0];
+ return firstSegment?.toLowerCase() === "zh-cn" ? UI_LOCALE_ZH_CN : UI_LOCALE_EN;
+ }
+ var freezeCopy = (copy) => Object.freeze(copy);
+ var PANEL_COPY = Object.freeze({
+ section: freezeCopy({
+ singleOrder: localizedText("单击下单", "Single Order"),
+ ladderMaker: localizedText("阶梯下单 · Maker", "Ladder Orders · Maker")
+ }),
+ field: freezeCopy({
+ clickOrderbook: localizedText("单击订单簿时", "On click"),
+ minimumOrderQuantity: localizedText("最小下单量的", "Minimum order qty"),
+ minimumOpenQuantity: localizedText("最小开仓量的", "Minimum open qty"),
+ minimumCloseQuantity: localizedText("最小平仓量的", "Minimum close qty"),
+ ratio: localizedText("比例", "Ratio"),
+ orderCount: localizedText("笔数", "Orders"),
+ interval: localizedText("间距", "Gap"),
+ pricePrecision: localizedText("精度", "Precision"),
+ multiplierUnit: localizedText("倍", "×")
+ }),
+ action: freezeCopy({
+ openLong: localizedText("阶梯开多", "Open Long"),
+ openShort: localizedText("阶梯开空", "Open Short"),
+ closeLong: localizedText("阶梯平多", "Close Long"),
+ closeShort: localizedText("阶梯平空", "Close Short"),
+ cancel: localizedText("撤单", "Cancel"),
+ cancelRunning: localizedText("撤单处理中", "Cancelling"),
+ noOrders: localizedText("无挂单", "No Orders"),
+ accountRebalance: localizedText("账户再平衡", "Account Rebalance"),
+ stopLadderByAction: freezeCopy({
+ OPEN_LONG: localizedText("停止开多", "Stop Open Long"),
+ OPEN_SHORT: localizedText("停止开空", "Stop Open Short"),
+ CLOSE_LONG: localizedText("停止平多", "Stop Close Long"),
+ CLOSE_SHORT: localizedText("停止平空", "Stop Close Short")
+ })
+ }),
+ side: freezeCopy({
+ long: localizedText("多", "Long"),
+ short: localizedText("空", "Short"),
+ openLong: localizedText("开多", "Open Long"),
+ openShort: localizedText("开空", "Open Short"),
+ closeLong: localizedText("平多", "Close Long"),
+ closeShort: localizedText("平空", "Close Short")
+ }),
+ state: freezeCopy({
+ idle: localizedText("空闲", "Idle"),
+ allPositionsClosed: localizedText("已全部平仓", "All positions closed"),
+ waitingTradeMode: localizedText("等待开仓/平仓状态", "Waiting for trade mode"),
+ waitingPricePrecision: localizedText("等待价格精度", "Waiting for precision"),
+ waitingPrecisionOptions: localizedText("等待精度档位", "Waiting for options"),
+ loadingPrecisionOptions: localizedText("读取精度档位", "Loading options"),
+ minimumQuantityLoading: localizedText("最小量读取中", "Loading minimum qty"),
+ positiveIntegerMultiplier: localizedText("请输入正整数倍数", "Enter a positive integer"),
+ noClosablePosition: localizedText("暂无可平仓位", "No position to close")
+ }),
+ status: freezeCopy({
+ precisionUpdated: localizedText("精度推荐已更新", "Precision recommendation updated"),
+ precisionOptionsUnavailable: localizedText("档位读取失败,请刷新", "Options unavailable. Refresh."),
+ precisionInsufficient: localizedText(
+ "近期价格变化不足,请稍后重试",
+ "Recent price movement is insufficient. Try again later."
+ )
+ }),
+ aria: freezeCopy({
+ decrementMultiplier: localizedText("减少倍数", "Decrease multiplier"),
+ incrementMultiplier: localizedText("增加倍数", "Increase multiplier")
+ }),
+ rebalanceDialog: freezeCopy({
+ targetSummary: localizedText(
+ "目标分配:资金 50% / 现货 40% / U本位 10%",
+ "Target allocation: Funding 50% / Spot 40% / USDⓈ-M Futures 10%"
+ ),
+ accountHeading: localizedText("账户", "Account"),
+ currentHeading: localizedText("当前 (USDT)", "Current (USDT)"),
+ targetHeading: localizedText("目标 (USDT)", "Target (USDT)"),
+ transferHeading: localizedText("划转计划", "Transfer Plan"),
+ cancel: localizedText("取消", "Cancel"),
+ confirm: localizedText("确认再平衡", "Confirm Rebalance")
+ }),
+ automaticRebalance: freezeCopy({
+ waitingForFlat: localizedText("自动再平衡:等待账户持续无持仓、无挂单", "Automatic USDT transfer: waiting for stable flat account"),
+ waitingForAccess: localizedText("自动再平衡:等待账户操作完成", "Automatic USDT transfer: waiting for account access"),
+ checkingAccount: localizedText("自动再平衡:正在检查账户", "Automatic USDT transfer: checking account"),
+ blocked: localizedText("自动再平衡已阻止:请先核实上次划转结果", "Automatic USDT transfer blocked: previous outcome requires account review"),
+ completed: localizedText("已自动进行账户再平衡", "Account automatically rebalanced"),
+ notRepeated: localizedText("本轮不再自动执行账户再平衡", "No further automatic rebalance in this round"),
+ noTransfer: localizedText("自动再平衡:无需划转", "Automatic account rebalance: no transfer needed"),
+ paused: localizedText("自动再平衡已暂停:执行条件已变化", "Automatic USDT transfer paused: eligibility changed"),
+ stopped: localizedText("自动再平衡已停止:", "Automatic USDT transfer stopped: "),
+ eligibilityFailed: localizedText("自动再平衡资格检查失败:", "Automatic USDT eligibility check failed: "),
+ accountBusy: localizedText("账户操作已阻止:其他标签页正在划转资金", "Account operation blocked: another tab is transferring funds")
+ }),
+ rebalanceErrors: freezeCopy({
+ pageIneligible: localizedText("当前合约页面不符合账户划转条件", "Current futures page is not eligible for account transfers"),
+ ordinaryAccountRequired: localizedText("自动划转仅支持普通 U 本位合约账户", "Automatic transfers require an ordinary USD-M Futures account"),
+ eligibilityChanged: localizedText("划转前账户执行条件已变化", "Account eligibility changed before transfer"),
+ tradingTaskRunning: localizedText("当前仍有交易任务运行", "A trading task is still running"),
+ identityUnverified: localizedText("账户身份或模式不受支持或尚未核实", "Unsupported or unverified account identity"),
+ identityChanged: localizedText("账户身份已变化", "Account identity changed"),
+ identityChangedAfterConfirmation: localizedText("确认后账户身份已变化", "Account identity changed after confirmation"),
+ reviewRequired: localizedText("请先核实上次划转结果", "Previous transfer outcome requires account review"),
+ flatRequired: localizedText("全账户持仓和当前委托必须为零", "Account-wide positions and open orders must be zero"),
+ accountNotFlat: localizedText("全账户仍有持仓或当前委托", "Positions or open orders still exist in the account"),
+ invalidOutcome: localizedText("自动再平衡结果记录无效", "Invalid automatic rebalance episode outcome")
+ }),
+ tooltip: freezeCopy({
+ singleOrder: localizedText(
+ "单击订单簿中的某个价格,按当前方向和数量设置提交一笔订单。",
+ "Click a price in the order book to submit one order using the current side and quantity settings."
+ ),
+ ladderMaker: localizedText(
+ "根据当前比例、笔数、间距和价格精度设置,依次提交只做 Maker 的阶梯订单。",
+ "Submit Post Only ladder orders sequentially using the current ratio, order count, gap, and precision."
+ ),
+ ratio: localizedText(
+ "本次阶梯下单使用可开/可平数量的百分比。",
+ "Percentage of the available open or close quantity used by this ladder."
+ ),
+ orderCount: localizedText(
+ "计划拆分成多少笔阶梯订单。",
+ "Number of orders in the ladder."
+ ),
+ interval: localizedText(
+ "相邻订单跨越多少个订单簿价格级别。",
+ "Number of order-book price levels between adjacent orders."
+ ),
+ pricePrecision: localizedText(
+ "与订单簿中的价格精度联动。黄点表示推荐值。比例、笔数、间距会随所选精度恢复对应设置。",
+ "Linked to the order-book price precision. The yellow dot marks the recommendation. Ratio, orders, and gap restore their saved values for the selected precision."
+ ),
+ continuousClose: localizedText(
+ "Option/Alt + 单击:连续交易",
+ "Option/Alt + click: continuous trading"
+ ),
+ accountRebalance: localizedText(
+ "将资金、现货和 U 本位账户的 USDT 按 5:4:1 分配",
+ "Allocate USDT across Funding, Spot, and USDⓈ-M Futures accounts at a 5:4:1 ratio"
+ )
+ })
+ });
+
+ // src/binance-coinmarketcap-data/ui-copy.js
+ var CMC_COPY = Object.freeze({
+ title: localizedText("CMC 数据", "CMC data"),
+ metric: localizedText("指标", "Metric"),
+ value: localizedText("数值", "Value"),
+ interpretation: localizedText("解读", "Interpretation"),
+ refresh: localizedText("刷新", "Refresh"),
+ collapse: localizedText("折叠", "Collapse"),
+ expand: localizedText("展开", "Expand"),
+ close: localizedText("关闭", "Close"),
+ details: localizedText("查看解读依据", "View interpretation basis"),
+ loading: localizedText("正在读取 CoinMarketCap...", "Loading CoinMarketCap..."),
+ failed: localizedText("读取失败", "Unable to load data"),
+ unidentifiedContract: localizedText("无法识别当前合约", "Unable to identify the current contract"),
+ source: localizedText("来源:CoinMarketCap", "Source: CoinMarketCap"),
+ api: localizedText("CMC data-api", "CMC data-api"),
+ page: localizedText("CMC 页面快照", "CMC page snapshot"),
+ fetched: localizedText("拉取", "Fetched"),
+ dataTime: localizedText("CMC 数据时间", "CMC data time"),
+ fetchedTime: localizedText("本次拉取时间", "Fetch time"),
+ refreshPeriod: localizedText("每 30 秒刷新", "Refreshes every 30s"),
+ changePeriod: localizedText("24小时", "24h"),
+ unavailable: localizedText("暂无数据", "Data unavailable"),
+ comparisonUnavailable: localizedText("暂无可比数据", "Comparison unavailable"),
+ supplyInvalid: localizedText("供应量口径需核实", "Supply needs verification")
+ });
+ var CMC_METRIC_COPY = Object.freeze({
+ price: {
+ label: localizedText("价格", "Price"),
+ note: localizedText("过去变动不预测后续方向", "Past change does not predict direction"),
+ explanation: localizedText(
+ "价格与24小时涨跌幅由 CMC 提供。涨跌幅描述已经发生的变动,不能单独判断情绪或后续方向。",
+ "CMC provides the price and its 24-hour change. The change describes a past move and does not establish sentiment or predict direction."
+ )
+ },
+ "market-cap": {
+ label: localizedText("流通市值", "Market cap"),
+ qualifier: localizedText("流通部分", "Circulating supply"),
+ interpretation: localizedText("需与同类项目比较", "Needs peer comparison"),
+ note: localizedText("规模不等于估值贵贱", "Size alone does not show value"),
+ explanation: localizedText(
+ "流通市值是价格乘以流通供应量得到的名义估值;判断高估或低估需要同类项目与基本面参照。各字段可能来自不同更新时间。",
+ "Circulating market cap is the nominal value of price multiplied by circulating supply. Valuation needs peer and fundamental comparisons. Fields may have different update times."
+ )
+ },
+ fdv: {
+ label: localizedText("总估值(FDV)", "Total valuation"),
+ qualifier: localizedText("供应估值", "FDV"),
+ note: localizedText("更大供应口径下的估值", "Values a broader token supply"),
+ explanation: localizedText(
+ "展示 CMC 提供的 FDV;倍数为本次 FDV ÷ 流通市值。FDV 使用的供应口径需结合项目资料核实,最大供应量缺失时不能确认最终发行上限。",
+ "The value is CMC-reported FDV; its multiple is this snapshot’s FDV divided by circulating market cap. Verify the supply basis against project disclosures. Missing max supply does not establish a final issuance cap."
+ )
+ },
+ unlocked: {
+ label: localizedText("已解锁市值", "Unlocked market cap"),
+ interpretation: localizedText("已解锁不等于已流通", "Unlocked is not circulating"),
+ unavailable: localizedText("暂无解锁数据", "Unlock data unavailable"),
+ note: localizedText("单项数值不说明解锁压力", "Unlock pressure needs more context"),
+ explanation: localizedText(
+ "已解锁市值为价格乘以已解锁供应量。已解锁代币不一定进入公众流通,缺少解锁计划时不判断未来解锁压力。",
+ "Unlocked market cap is price multiplied by unlocked supply. Unlocked tokens are not necessarily in public circulation; future unlock pressure requires the unlock schedule."
+ )
+ },
+ volume: {
+ label: localizedText("成交额", "Volume"),
+ qualifier: localizedText("24小时", "24h · USD value"),
+ interpretation: localizedText("需与历史成交比较", "Needs historical context"),
+ note: localizedText("成交额不等于净流入", "Trading value is not net inflow"),
+ explanation: localizedText(
+ "这里统计过去24小时的累计美元成交金额。同一批代币可以反复成交;缺少历史基准时不能断言放量或缩量。",
+ "This is cumulative USD trading value over 24 hours. Tokens can trade repeatedly; identifying unusual volume requires a historical baseline."
+ )
+ },
+ turnover: {
+ label: localizedText("成交额 / 流通市值", "Volume / market cap"),
+ qualifier: localizedText("24小时", "24h · circulating cap"),
+ note: localizedText("需结合历史基准", "Needs a historical baseline"),
+ explanation: localizedText(
+ "展示 CMC turnover 字段乘以100后的百分比,口径为24小时成交额 / 流通市值;不假定同屏字段具有相同更新时间。50%及以上描述为“交投较活跃”,仅是描述阈值;是否异常仍需历史基准,不表示净流入或买盘更强。",
+ "This is the CMC-reported turnover field multiplied by 100: 24h volume / circulating market cap. Displayed fields may have different update times. At least 50% is described as relatively active, a descriptive threshold only. Unusual activity requires a historical baseline and does not establish net inflow or buying pressure."
+ )
+ },
+ liquidity: {
+ label: localizedText("链上流动性 / 流通市值", "DEX liquidity / cap"),
+ qualifier: localizedText("DEX 流动性", "Circulating cap"),
+ interpretation: localizedText("仅反映 CMC 统计池", "Limited to CMC-tracked pools"),
+ unavailable: localizedText("暂无流动性数据", "Liquidity data unavailable"),
+ note: localizedText("不代表币安订单簿深度", "Not Binance order-book depth"),
+ explanation: localizedText(
+ "展示 CMC liquidityMcapRatio 字段乘以100后的百分比。分子为 CMC 统计的相关 DEX 池流动性金额,分母为流通市值;不代表币安订单簿深度,缺失值也不等于零流动性。",
+ "The value is CMC’s liquidityMcapRatio multiplied by 100. The numerator is liquidity in CMC-tracked DEX pools and the denominator is circulating market cap. This does not measure Binance order-book depth; missing data does not mean zero liquidity."
+ )
+ },
+ "total-supply": {
+ label: localizedText("总供应量", "Total supply"),
+ interpretation: localizedText("包含未流通部分", "Includes non-circulating tokens"),
+ note: localizedText("已发行量,扣除已销毁部分", "Issued supply, net of burns"),
+ explanation: localizedText(
+ "总供应量是已发行、扣除已销毁部分后的数量。未流通部分可能包含团队、金库等持币,不能全部当作下一次解锁量。",
+ "Total supply is issued supply net of burns. Non-circulating tokens can include team and treasury holdings; this is not the next unlock amount."
+ )
+ },
+ "circulating-supply": {
+ label: localizedText("流通供应量", "Circulating supply"),
+ note: localizedText("未流通部分不等于待解锁", "The remainder is not all locked"),
+ explanation: localizedText(
+ "占比为本次流通供应量 ÷ 总供应量。流通与已解锁供应量的口径不同,未流通部分不能直接当作未来解锁压力;缺失或非正供应量不计算占比。",
+ "The share is this snapshot’s circulating supply divided by total supply. Circulating and unlocked supply use different definitions, so the remainder is not automatically future unlock pressure. Missing or non-positive supplies do not produce a share."
+ )
+ },
+ "max-supply": {
+ label: localizedText("最大供应量", "Max supply"),
+ interpretation: localizedText("项目规则下的供应上限", "Supply cap under project rules"),
+ unavailable: localizedText("暂无上限数据", "Supply cap unavailable"),
+ note: localizedText("缺失不代表无限增发", "Missing does not imply unlimited issuance"),
+ explanation: localizedText(
+ "最大供应量是按项目规则最终可能存在的数量上限。该字段缺失时,不据此推断供应是否无限。",
+ "Max supply is the eventual upper bound defined by project rules. An unavailable field does not establish unlimited supply."
+ )
+ },
+ holders: {
+ label: localizedText("持币地址数", "Holder addresses"),
+ interpretation: localizedText("地址数不等于人数", "Addresses are not people"),
+ note: localizedText("一人可多址,交易所可代管", "Multiple addresses and custodians"),
+ explanation: localizedText(
+ "这是 CMC 统计范围内的持币地址数。一个用户可以拥有多个地址,一个交易所托管地址也可能代表多个用户;地址数不衡量独立投资者人数。",
+ "This is the holder address count within CMC coverage. A person can own multiple addresses, and an exchange custody address can represent multiple users. Address count does not measure unique investors."
+ )
+ },
+ treasury: {
+ label: localizedText("金库资产", "Treasury holdings"),
+ interpretation: localizedText("这是金库持仓数量", "Treasury token holdings"),
+ note: localizedText("不表示持币地址数", "Not a holder address count"),
+ explanation: localizedText(
+ "该资产的 CMC 页面使用金库资产字段。这里保留 CMC 的金库持仓数量与代币单位,不能当作持币地址数或独立用户数。",
+ "CMC uses its treasury-holdings field for this asset. The value retains the reported token quantity and unit; it is not a holder address count or a count of unique users."
+ )
+ },
+ profile: {
+ label: localizedText("资料披露评分", "Profile score"),
+ note: localizedText("衡量完整性与更新时效", "Completeness and freshness"),
+ explanation: localizedText(
+ "评分衡量项目资料的完整程度和更新及时程度,不表示项目安全性或投资胜率。有效范围为0%至100%;缺失或超出范围时显示暂无数据。",
+ "The score measures the completeness and freshness of project information, not project safety or investment success. Valid scores range from 0% to 100%; missing or out-of-range scores are unavailable."
+ )
+ }
+ });
+
+ // src/binance-coinmarketcap-data/metrics.js
+ function numberOrNull(value) {
+ if (typeof value !== "number" && typeof value !== "string") return null;
+ if (typeof value === "string" && value.trim() === "") return null;
+ const number = Number(value);
+ return Number.isFinite(number) ? number : null;
+ }
+ function compactNumber(value, locale, usd = false) {
+ if (value === null) return "--";
+ const magnitude = Math.abs(value);
+ const units = locale === "zh-CN" ? [[1e12, "万亿"], [1e8, "亿"], [1e4, "万"]] : [[1e12, "T"], [1e9, "B"], [1e6, "M"], [1e3, "K"]];
+ const unit = units.find(([scale]) => magnitude >= scale);
+ const scaled = unit ? magnitude / unit[0] : magnitude;
+ const decimals = scaled > 0 && scaled < 1 ? Math.max(4, 3 - Math.floor(Math.log10(scaled))) : 4;
+ const number = decimals > 14 ? scaled.toExponential(3) : scaled.toLocaleString("en-US", {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: decimals,
+ useGrouping: false
+ });
+ return `${value < 0 ? "-" : ""}${usd ? "$" : ""}${number}${unit ? unit[1] : ""}`;
+ }
+ function percent(value, signed = false) {
+ if (value === null) return "--";
+ return `${signed && value > 0 ? "+" : ""}${value.toFixed(2)}%`;
+ }
+ function positiveRatio(numerator, denominator) {
+ return numerator !== null && denominator !== null && numerator > 0 && denominator > 0 ? numerator / denominator : null;
+ }
+ function holderMetric(detail) {
+ const treasury = numberOrNull(detail.treasuryHoldings);
+ if (detail.showTreasuriesFlag && treasury !== null && treasury > 0) {
+ return { id: "treasury", number: treasury };
+ }
+ const pageCount = numberOrNull(detail.cmcHolderCount);
+ if (pageCount !== null) return { id: "holders", number: pageCount };
+ const holders = detail.holders;
+ const count = holders && typeof holders === "object" ? [holders.holderCount, holders.total, holders.count].map(numberOrNull).find((value) => value !== null) : void 0;
+ return { id: "holders", number: count === void 0 ? null : count };
+ }
+ function buildCmcMetricRows(detail, locale) {
+ const text = (value) => formatLocalizedText(value, locale);
+ const pair = (zh, en) => text(localizedText(zh, en));
+ const stats = detail.statistics;
+ const values = Object.fromEntries(Object.entries(stats).map(([key, value]) => [key, numberOrNull(value)]));
+ const numeric = (key) => values[key] === void 0 ? null : values[key];
+ const priceChange = numeric("priceChangePercentage24h");
+ const fdvRatio = positiveRatio(numeric("fullyDilutedMarketCap"), numeric("marketCap"));
+ const supplyRatio = positiveRatio(numeric("circulatingSupply"), numeric("totalSupply"));
+ const rawTurnover = numeric("turnover");
+ const turnover = rawTurnover !== null && rawTurnover >= 0 ? rawTurnover * 100 : null;
+ const rawLiquidity = numeric("liquidityMcapRatio");
+ const liquidity = rawLiquidity !== null && rawLiquidity >= 0 ? rawLiquidity * 100 : null;
+ const holder = holderMetric(detail);
+ const rawProfile = numberOrNull(detail.profileCompletionScore && typeof detail.profileCompletionScore === "object" ? detail.profileCompletionScore.percentage : detail.profileCompletionScore);
+ const profile = rawProfile !== null && rawProfile >= 0 && rawProfile <= 100 ? rawProfile : null;
+ const comparableCap = numeric("marketCap") !== null && numeric("marketCap") > 0;
+ const symbol = detail.symbol || "";
+ function row(id, value, options = {}) {
+ const copy = CMC_METRIC_COPY[id];
+ const available = value !== "--";
+ return {
+ id,
+ label: text(copy.label),
+ qualifier: copy.qualifier ? text(copy.qualifier) : "",
+ value,
+ unit: "",
+ change: null,
+ highlight: false,
+ group: false,
+ tone: available ? "neutral" : "missing",
+ interpretation: available && copy.interpretation ? text(copy.interpretation) : text(copy.unavailable || CMC_COPY.unavailable),
+ note: text(copy.note),
+ explanation: text(copy.explanation),
+ ...options
+ };
+ }
+ const price = row("price", compactNumber(numeric("price"), locale, true));
+ if (numeric("price") !== null && priceChange !== null) {
+ price.change = percent(priceChange, true);
+ price.interpretation = priceChange === 0 ? pair("24小时价格持平", "Unchanged over 24h") : pair(
+ `24小时${priceChange > 0 ? "上涨" : "下跌"}${Math.abs(priceChange).toFixed(2)}%`,
+ `${priceChange > 0 ? "Up" : "Down"} ${Math.abs(priceChange).toFixed(2)}% over 24h`
+ );
+ price.tone = priceChange > 0 ? "positive" : priceChange < 0 ? "negative" : "neutral";
+ price.explanation = `${price.interpretation}${locale === "zh-CN" ? "。" : "."} ${price.explanation}`;
+ } else if (numeric("price") !== null) {
+ price.interpretation = pair("暂无24小时变动", "24h change unavailable");
+ }
+ const fdv = row("fdv", compactNumber(numeric("fullyDilutedMarketCap"), locale, true), { highlight: true });
+ fdv.interpretation = fdvRatio === null ? text(CMC_COPY.comparisonUnavailable) : pair(`流通市值的${fdvRatio.toFixed(2)}倍`, `${fdvRatio.toFixed(2)}× market cap`);
+ if (fdvRatio !== null) fdv.explanation = `${fdv.interpretation}${locale === "zh-CN" ? "。" : "."} ${fdv.explanation}`;
+ const turnoverRow = row("turnover", percent(turnover));
+ if (turnover !== null && comparableCap) {
+ turnoverRow.interpretation = turnover >= 50 ? pair("交投较活跃", "Relatively active trading") : pair("低于50%描述阈值", "Below the 50% threshold");
+ turnoverRow.note = pair(`CMC 报告比值 ${percent(turnover)}`, `CMC reports ${percent(turnover)}`);
+ turnoverRow.tone = turnover >= 50 ? "active" : "neutral";
+ } else if (turnover !== null) {
+ turnoverRow.interpretation = text(CMC_COPY.comparisonUnavailable);
+ }
+ const liquidityRow = row("liquidity", percent(liquidity));
+ if (liquidity !== null && !comparableCap) liquidityRow.interpretation = text(CMC_COPY.comparisonUnavailable);
+ const total = row("total-supply", compactNumber(numeric("totalSupply"), locale), {
+ unit: numeric("totalSupply") === null ? "" : symbol,
+ group: true
+ });
+ if (numeric("totalSupply") !== null && numeric("totalSupply") <= 0) total.interpretation = text(CMC_COPY.supplyInvalid);
+ const circulating = row("circulating-supply", compactNumber(numeric("circulatingSupply"), locale), {
+ unit: numeric("circulatingSupply") === null ? "" : symbol
+ });
+ circulating.interpretation = supplyRatio === null ? text(CMC_COPY.comparisonUnavailable) : pair(`约占总供应量${(supplyRatio * 100).toFixed(1)}%`, `About ${(supplyRatio * 100).toFixed(1)}% of total supply`);
+ if (supplyRatio !== null && supplyRatio > 1) {
+ circulating.interpretation = pair("流通量高于总供应量", "Circulating supply exceeds total");
+ circulating.note = text(CMC_COPY.supplyInvalid);
+ } else if (supplyRatio !== null) {
+ circulating.explanation = `${circulating.interpretation}${locale === "zh-CN" ? "。" : "."} ${circulating.explanation}`;
+ }
+ const max = row("max-supply", compactNumber(numeric("maxSupply"), locale), {
+ unit: numeric("maxSupply") === null ? "" : symbol
+ });
+ if (numeric("maxSupply") !== null && numeric("maxSupply") <= 0) max.interpretation = text(CMC_COPY.supplyInvalid);
+ const profileRow = row("profile", profile === null ? "--" : `${profile.toFixed(0)}%`);
+ if (profile !== null) {
+ profileRow.interpretation = profile < 100 ? pair("资料仍有完善空间", "Room to improve disclosure") : pair("资料评分已满分", "Full profile score");
+ }
+ return [
+ price,
+ row("market-cap", compactNumber(numeric("marketCap"), locale, true), {
+ highlight: true,
+ change: numeric("marketCap") === null ? null : percent(numeric("marketCapChangePercentage24h"), true)
+ }),
+ { ...fdv, change: numeric("fullyDilutedMarketCap") === null ? null : percent(numeric("fullyDilutedMarketCapChangePercentage24h"), true) },
+ row("unlocked", compactNumber(numeric("ucm"), locale, true)),
+ row("volume", compactNumber(numeric("volume24h"), locale, true), { group: true }),
+ turnoverRow,
+ liquidityRow,
+ total,
+ circulating,
+ max,
+ row(holder.id, compactNumber(holder.number, locale), { group: true, unit: holder.id === "treasury" ? symbol : "" }),
+ profileRow
+ ];
+ }
+
+ // src/binance-coinmarketcap-data/panel-styles.js
+ function cmcPanelStyles(panelId) {
+ const scope = `#${panelId}`;
+ return `
+ ${scope} {
+ --cmc-bg: var(--color-PrimaryBg, light-dark(#ffffff, #181a20));
+ --cmc-head: var(--color-SecondaryBg, light-dark(#fafafa, #20232b));
+ --cmc-text: var(--color-TextPrimary, light-dark(#1e2329, #eaecef));
+ --cmc-muted: var(--color-TextSecondary, light-dark(#5e6673, #a6afbb));
+ --cmc-border: var(--color-Line, light-dark(#eaecef, #33383f));
+ --cmc-accent: light-dark(#315fe8, #8aaaff);
+ --cmc-buy: var(--color-Buy, light-dark(#087f5b, #0ecb81));
+ --cmc-sell: var(--color-Sell, light-dark(#c42e45, #f6465d));
+ position: fixed;
+ top: 360px;
+ right: 16px;
+ max-width: calc(100vw - 16px);
+ max-height: calc(100vh - var(--cmc-panel-top, 360px) - 8px);
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ z-index: 999997;
+ color: var(--cmc-text);
+ background: var(--cmc-bg);
+ border: 1px solid var(--cmc-border);
+ border-radius: 10px;
+ box-shadow: 0 6px 24px #00000020;
+ font-family: BinancePlex, system-ui, -apple-system, sans-serif;
+ font-size: 12px;
+ line-height: 1.4;
+ text-align: left;
+ overflow: hidden;
+ user-select: none;
+ }
+ ${scope} * { box-sizing: border-box; }
+ ${scope} [hidden] { display: none !important; }
+ ${scope} #${panelId}-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
+ padding: 8px 10px;
+ flex: 0 0 auto;
+ cursor: move;
+ background: var(--cmc-head);
+ border-bottom: 1px solid var(--cmc-border);
+ }
+ ${scope} .cmc-heading { display: flex; align-items: center; gap: 7px; min-width: 0; }
+ ${scope} .cmc-title { font-size: 14px; font-weight: 600; white-space: nowrap; }
+ ${scope} #${panelId}-symbol { color: var(--cmc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ ${scope} .cmc-controls { display: flex; gap: 2px; flex: 0 0 auto; }
+ ${scope} button { font: inherit; cursor: pointer; color: inherit; background: transparent; border: 0; }
+ ${scope} .cmc-controls button { min-width: 25px; min-height: 26px; font-size: 16px; padding: 2px 4px; color: var(--cmc-muted); }
+ ${scope} button:focus-visible, ${scope} a:focus-visible { outline: 2px solid var(--cmc-accent); outline-offset: 2px; border-radius: 3px; }
+ ${scope} #${panelId}-body { min-height: 0; overflow: auto; overscroll-behavior: contain; }
+ ${scope} table { width: 100%; table-layout: fixed; border-collapse: collapse; }
+ ${scope} .cmc-name-col { width: 31%; }
+ ${scope} .cmc-value-col { width: 26%; }
+ ${scope} .cmc-reading-col { width: 43%; }
+ ${scope} thead { position: sticky; top: 0; z-index: 1; background: var(--cmc-head); }
+ ${scope} thead th { color: var(--cmc-muted); font-size: 11px; font-weight: 500; padding: 7px 10px; text-align: left; }
+ ${scope} thead th:nth-child(2) { text-align: right; }
+ ${scope} tbody th, ${scope} tbody td { padding: 9px 10px; border-top: 1px solid var(--cmc-border); vertical-align: middle; overflow-wrap: anywhere; }
+ ${scope} .cmc-name { text-align: left; font-weight: 500; }
+ ${scope} small { display: block; margin-top: 3px; font-size: 11px; line-height: 1.4; color: var(--cmc-muted); }
+ ${scope} .cmc-value { text-align: right; font-variant-numeric: tabular-nums; }
+ ${scope} .cmc-number { display: block; font-weight: 650; white-space: nowrap; }
+ ${scope} .cmc-change { font-size: 11px; white-space: nowrap; }
+ ${scope} .cmc-change[data-tone="positive"], ${scope} [data-tone="positive"] .cmc-reading-text { color: var(--cmc-buy); }
+ ${scope} .cmc-change[data-tone="negative"], ${scope} [data-tone="negative"] .cmc-reading-text { color: var(--cmc-sell); }
+ ${scope} .cmc-change[data-tone="neutral"] { color: var(--cmc-muted); }
+ ${scope} .cmc-key { background: color-mix(in srgb, var(--color-PrimaryYellow, #f0b90b) 7%, var(--cmc-bg)); }
+ ${scope} .cmc-group th, ${scope} .cmc-group td { border-top-width: 2px; }
+ ${scope} .cmc-reading-button { display: flex; align-items: flex-start; justify-content: space-between; gap: 5px; width: 100%; padding: 0; line-height: inherit; text-align: left; }
+ ${scope} .cmc-reading-text { font-weight: 500; }
+ ${scope} [data-tone="missing"] .cmc-reading-text { color: var(--cmc-muted); }
+ ${scope} [data-tone="active"] .cmc-reading-text { color: var(--cmc-accent); }
+ ${scope} .cmc-info { color: var(--cmc-muted); flex: 0 0 auto; font-size: 11px; }
+ ${scope} .cmc-explanation td { padding: 10px 12px; color: var(--cmc-muted); background: var(--cmc-head); font-size: 11px; line-height: 1.6; user-select: text; }
+ ${scope} .cmc-explanation strong { color: var(--cmc-text); margin-right: 8px; font-weight: 600; }
+ ${scope} .cmc-status { padding: 10px 0; color: var(--cmc-muted); }
+ ${scope} .cmc-error-title { font-weight: 600; color: var(--cmc-sell); margin-bottom: 4px; }
+ ${scope} #${panelId}-footer { padding: 8px 10px; color: var(--cmc-muted); font-size: 11px; border-top: 1px solid var(--cmc-border); }
+ ${scope} .cmc-source-line { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 5px 10px; }
+ ${scope} #${panelId}-footer a { color: var(--cmc-accent); text-decoration: none; }
+ ${scope} .cmc-times { font-variant-numeric: tabular-nums; }
+ @media (max-width: 540px) {
+ ${scope} { font-size: 11px; }
+ ${scope} tbody th, ${scope} tbody td { padding: 8px 6px; }
+ ${scope} thead th { padding: 7px 6px; }
+ ${scope} .cmc-name-col { width: 31%; }
+ ${scope} .cmc-value-col { width: 27%; }
+ ${scope} .cmc-reading-col { width: 42%; }
+ ${scope} .cmc-info { display: none; }
+ }
+ `;
+ }
+
+ // src/shared/data-panel-layout.js
+ var DATA_PANEL_LAYOUT_EVENT = "jh-data-panels-layout-change";
+ var PANEL_IDS = Object.freeze({
+ trading: "jh-binance-trading-data-panel",
+ cmc: "jh-binance-cmc-data-panel"
+ });
+ var TWO_COLUMN_WIDTH = 480 + 500 + 48;
+ function hasVisibleDataPanelPeer(document2, kind) {
+ const peer = document2.getElementById(PANEL_IDS[kind === "trading" ? "cmc" : "trading"]);
+ return peer !== null && peer.style.display !== "none";
+ }
+ function calculateDataPanelLayout({ kind, panelWidth, viewportWidth, viewportHeight, savedPosition, hasPeer }) {
+ if (kind !== "trading" && kind !== "cmc") throw new Error("Unknown data panel kind");
+ if (savedPosition !== null) {
+ return { ...savedPosition, maxHeight: Math.max(48, viewportHeight - savedPosition.top - 8) };
+ }
+ const stacked = hasPeer && viewportWidth < TWO_COLUMN_WIDTH;
+ const sectionHeight = Math.max(48, Math.floor((viewportHeight - 24) / 2));
+ const targetTop = stacked ? kind === "trading" ? 8 : 16 + sectionHeight : 60;
+ const top = Math.max(0, Math.min(targetTop, viewportHeight - 48));
+ const peerWidth = hasPeer && !stacked && kind === "cmc" ? 480 + 16 : 0;
+ const targetLeft = viewportWidth - panelWidth - 16 - peerWidth;
+ const left = Math.max(0, Math.min(Math.max(stacked ? 8 : 16, targetLeft), viewportWidth - panelWidth));
+ const availableHeight = Math.max(48, viewportHeight - top - 8);
+ return { left, top, maxHeight: stacked ? Math.min(sectionHeight, availableHeight) : availableHeight };
+ }
+
// src/binance-coinmarketcap-data/index.user.js
(function() {
"use strict";
@@ -99,25 +683,15 @@
const PANEL_ID = "jh-binance-cmc-data-panel";
const STORAGE_POS_KEY = "jh_binance_cmc_data_pos";
const STORAGE_COLLAPSED_KEY = "jh_binance_cmc_data_collapsed";
- const PANEL_WIDTH = 240;
+ const PANEL_WIDTH = 500;
const REFRESH_MS = 30 * 1e3;
const ROUTE_WATCHDOG_MS = 5e3;
- const CMC_BASE = "https://coinmarketcap.com/zh/currencies/";
const CMC_MAP_API = "https://api.coinmarketcap.com/data-api/v1/cryptocurrency/map";
const CMC_DETAIL_API = "https://api.coinmarketcap.com/data-api/v3/cryptocurrency/detail";
const CMC_HOLDER_API = "https://dapi.coinmarketcap.com/dex-stats/v3/dexer/crypto-holder/show_holders";
const ASSET_OVERRIDES = {
RAVE: { id: 38967, symbol: "RAVE", slug: "ravedao" }
};
- const C = {
- long: "var(--color-Buy, #0ecb81)",
- short: "var(--color-Sell, #f6465d)",
- bg: "#ffffff",
- text: "#1e2329",
- sub: "#5e6673",
- border: "#eaecef",
- accent: "#3861fb"
- };
let panelClosed = false;
let lastSymbol = null;
let lastUpdateTs = 0;
@@ -129,7 +703,11 @@
let refreshEpoch = 0;
let lastRowsHtml = "";
let lastPath = location.pathname;
+ let expandedMetricId = null;
const assetCache = /* @__PURE__ */ Object.create(null);
+ function uiText(value) {
+ return formatLocalizedText(value, resolveUiLocaleFromPathname(location.pathname));
+ }
function getCurrentSymbol() {
return parseFuturesTradingSymbolFromPathname(location.pathname);
}
@@ -192,52 +770,8 @@
return assetCache[base];
}
function cmcUrlForAsset(asset) {
- return asset && asset.slug ? CMC_BASE + asset.slug + "/" : null;
- }
- function formatUsd(value) {
- if (!Number.isFinite(value)) return "--";
- const sign = value < 0 ? "-" : "";
- const abs = Math.abs(value);
- if (abs >= 1e12) return sign + "$" + formatCompactNumber(abs / 1e12, 4) + "万亿";
- if (abs >= 1e8) return sign + "$" + formatCompactNumber(abs / 1e8, 4) + "亿";
- if (abs >= 1e4) return sign + "$" + formatCompactNumber(abs / 1e4, 4) + "万";
- return sign + "$" + formatCompactNumber(abs, 4);
- }
- function formatToken(value, symbol) {
- if (!Number.isFinite(value)) return "--";
- const sign = value < 0 ? "-" : "";
- const abs = Math.abs(value);
- let formatted;
- if (abs >= 1e12) formatted = sign + formatCompactNumber(abs / 1e12, 4) + "万亿";
- else if (abs >= 1e8) formatted = sign + formatCompactNumber(abs / 1e8, 4) + "亿";
- else if (abs >= 1e4) formatted = sign + formatCompactNumber(abs / 1e4, 4) + "万";
- else formatted = sign + formatCompactNumber(abs, 4);
- return symbol ? formatted + " " + symbol : formatted;
- }
- function formatPercent(value) {
- if (!Number.isFinite(value)) return "--";
- const sign = value > 0 ? "+" : "";
- return sign + value.toFixed(2) + "%";
- }
- function formatPlainPercent(value) {
- if (!Number.isFinite(value)) return "--";
- return value.toFixed(2) + "%";
- }
- function formatCount(value) {
- if (!Number.isFinite(value)) return "--";
- return formatToken(value, "");
- }
- function formatCompactNumber(value, maxDecimals) {
- if (!Number.isFinite(value)) return "--";
- return value.toLocaleString("en-US", {
- minimumFractionDigits: 0,
- maximumFractionDigits: maxDecimals,
- useGrouping: false
- });
- }
- function numberOrNull(value) {
- const number = Number(value);
- return Number.isFinite(number) ? number : null;
+ const localePrefix = resolveUiLocaleFromPathname(location.pathname) === "zh-CN" ? "zh/" : "";
+ return asset && asset.slug ? "https://coinmarketcap.com/" + localePrefix + "currencies/" + asset.slug + "/" : null;
}
function requestText(url) {
return new Promise(function(resolve, reject) {
@@ -338,104 +872,9 @@
}
return detail;
}
- function holderDisplayMetric(detail, symbol) {
- const treasuryHoldings = numberOrNull(detail.treasuryHoldings);
- if (detail.showTreasuriesFlag && treasuryHoldings !== null && treasuryHoldings > 0) {
- return {
- label: "金库资产",
- value: formatToken(treasuryHoldings, symbol)
- };
- }
- const holdersFromPageMetric = numberOrNull(detail.cmcHolderCount);
- const holders = holdersFromPageMetric !== null ? holdersFromPageMetric : detail.holders && typeof detail.holders === "object" ? numberOrNull(detail.holders.holderCount || detail.holders.total || detail.holders.count) : null;
- return {
- label: "持有者",
- value: formatCount(holders)
- };
- }
- function formatProfileScore(value) {
- if (value && typeof value === "object") {
- const percentage2 = numberOrNull(value.percentage);
- return percentage2 !== null ? percentage2.toFixed(0) + "%" : "--";
- }
- const percentage = numberOrNull(value);
- return percentage !== null ? percentage.toFixed(0) + "%" : "--";
- }
- function buildRows(detail) {
- const stats = detail.statistics || {};
- const symbol = detail.symbol || "";
- const volumeToMarketCap = numberOrNull(stats.turnover) !== null ? numberOrNull(stats.turnover) * 100 : null;
- const liquidityToMarketCap = numberOrNull(stats.liquidityMcapRatio) !== null ? numberOrNull(stats.liquidityMcapRatio) * 100 : null;
- const holderMetric = holderDisplayMetric(detail, symbol);
- return [
- {
- label: "价格",
- value: formatUsd(numberOrNull(stats.price)),
- change: formatPercent(numberOrNull(stats.priceChangePercentage24h)),
- highlight: false
- },
- {
- label: "流通市值",
- value: formatUsd(numberOrNull(stats.marketCap)),
- change: formatPercent(numberOrNull(stats.marketCapChangePercentage24h)),
- highlight: true
- },
- {
- label: "Unlocked Mkt Cap",
- value: formatUsd(numberOrNull(stats.ucm)),
- highlight: false
- },
- {
- label: "交易量(24h)",
- value: formatUsd(numberOrNull(stats.volume24h)),
- highlight: false
- },
- {
- label: "Vol/Mkt Cap(24h)",
- value: formatPlainPercent(volumeToMarketCap),
- highlight: false
- },
- {
- label: "FDV/总估值",
- value: formatUsd(numberOrNull(stats.fullyDilutedMarketCap)),
- change: formatPercent(numberOrNull(stats.fullyDilutedMarketCapChangePercentage24h)),
- highlight: true
- },
- {
- label: "Liq/Mkt Cap",
- value: formatPlainPercent(liquidityToMarketCap),
- highlight: false
- },
- {
- label: "总供应量",
- value: formatToken(numberOrNull(stats.totalSupply), symbol),
- highlight: false
- },
- {
- label: "最大供应量",
- value: formatToken(numberOrNull(stats.maxSupply), symbol),
- highlight: false
- },
- {
- label: "流通供应量",
- value: formatToken(numberOrNull(stats.circulatingSupply), symbol),
- highlight: false
- },
- {
- label: holderMetric.label,
- value: holderMetric.value,
- highlight: false
- },
- {
- label: "Profile score",
- value: formatProfileScore(detail.profileCompletionScore),
- highlight: false
- }
- ];
- }
async function fetchCmcApiData(asset) {
const url = detailApiUrlForAsset(asset);
- if (!url) throw new Error("无法识别当前合约");
+ if (!url) throw new Error(uiText(CMC_COPY.unidentifiedContract));
const payload = await requestJson(url);
return collectApiDetail(payload);
}
@@ -449,7 +888,7 @@
}
async function fetchCmcPageData(asset) {
const url = cmcUrlForAsset(asset);
- if (!url) throw new Error("无法识别当前合约");
+ if (!url) throw new Error(uiText(CMC_COPY.unidentifiedContract));
const html = await requestText(url);
const nextData = extractNextData(html);
return collectDetail(nextData);
@@ -481,74 +920,47 @@
rank: detail.statistics && detail.statistics.rank,
lastUpdated: detail.latestUpdateTime || "",
source,
- rows: buildRows(detail)
+ rows: buildCmcMetricRows(detail, resolveUiLocaleFromPathname(location.pathname))
};
}
function ensurePanel() {
let panel = document.getElementById(PANEL_ID);
- if (panel) return panel;
+ if (panel) {
+ updatePanelLanguage(panel);
+ return panel;
+ }
panel = document.createElement("div");
panel.id = PANEL_ID;
+ panel.style.width = PANEL_WIDTH + "px";
const collapsed = loadCollapsed();
- Object.assign(panel.style, {
- position: "fixed",
- top: "360px",
- left: "auto",
- right: "16px",
- width: PANEL_WIDTH + "px",
- zIndex: "999997",
- background: C.bg,
- border: "1px solid " + C.border,
- borderRadius: "8px",
- boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
- fontFamily: "BinancePlex, system-ui, -apple-system, sans-serif",
- fontSize: "13px",
- color: C.text,
- userSelect: "none",
- overflow: "hidden"
- });
panel.innerHTML = [
+ "",
'