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 + + + +
+

数据面板实现预览 示例数据

Trading ${artifacts[0].version} · CMC ${artifacts[1].version}
+ + + + +
+

使用实际生成脚本与离线示例数据。可拖动、收起、查看历史点;不会连接交易所。切换选项会重置预览。

+ + + + + +`; + +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 = [ + "", '
', - '
', - '☰', - 'CMC 数据', + '-header">', + '
', + '', + '', '', + '-symbol">', "
", - '
', - '', + '', - '", - '', + '-close">×', "
", "
", '
', - '
", + '', + '', + '
', + '-rows">', + "", '', + '-footer">
', "
" ].join(""); document.body.appendChild(panel); + updatePanelLanguage(panel); keepPanelInViewport(panel); cleanupPanelDrag(); dragCleanup = setupDrag(panel); setupControls(panel); + window.dispatchEvent(new CustomEvent(DATA_PANEL_LAYOUT_EVENT)); return panel; } + function updatePanelLanguage(panel) { + panel.lang = resolveUiLocaleFromPathname(location.pathname); + panel.setAttribute("aria-label", uiText(CMC_COPY.title)); + panel.querySelector("table").setAttribute("aria-label", uiText(CMC_COPY.title)); + panel.querySelectorAll("[data-copy]").forEach(function(element) { + element.textContent = uiText(CMC_COPY[element.dataset.copy]); + }); + const collapsed = panel.querySelector("#" + PANEL_ID + "-body").style.display === "none"; + for (const [suffix, copy] of [ + ["refresh", CMC_COPY.refresh], + ["collapse", collapsed ? CMC_COPY.expand : CMC_COPY.collapse], + ["close", CMC_COPY.close] + ]) { + const button = panel.querySelector("#" + PANEL_ID + "-" + suffix); + button.title = uiText(copy); + button.setAttribute("aria-label", uiText(copy)); + } + panel.querySelector("#" + PANEL_ID + "-collapse").setAttribute("aria-expanded", String(!collapsed)); + } function renderLoading(symbol) { const panel = ensurePanel(); const symbolEl = panel.querySelector("#" + PANEL_ID + "-symbol"); const rowsEl = panel.querySelector("#" + PANEL_ID + "-rows"); const footerEl = panel.querySelector("#" + PANEL_ID + "-footer"); if (symbolEl) symbolEl.textContent = symbol || ""; - if (rowsEl) rowsEl.innerHTML = '
正在读取 CoinMarketCap...
'; + if (rowsEl) rowsEl.innerHTML = '
' + escapeHtml(uiText(CMC_COPY.loading)) + "
"; if (footerEl) footerEl.textContent = ""; } function renderError(symbol, message) { @@ -592,18 +1025,18 @@ if (symbolEl) symbolEl.textContent = symbol || ""; if (rowsEl) { lastRowsHtml = [ - '
读取失败
', - '
', + '
', + '
', + escapeHtml(uiText(CMC_COPY.failed)), + "
", + "
", escapeHtml(message), - "
" + "
", + "
" ].join(""); rowsEl.innerHTML = lastRowsHtml; } - if (footerEl) footerEl.textContent = "来源:CoinMarketCap 中文页"; + if (footerEl) footerEl.textContent = uiText(CMC_COPY.source); } function renderData(symbol, data) { const panel = ensurePanel(); @@ -611,27 +1044,59 @@ const rowsEl = panel.querySelector("#" + PANEL_ID + "-rows"); const footerEl = panel.querySelector("#" + PANEL_ID + "-footer"); if (symbolEl) { - const rank = Number.isFinite(Number(data.rank)) ? " #" + data.rank : ""; + const rank = numberOrNull(data.rank) !== null ? " #" + data.rank : ""; symbolEl.textContent = (data.symbol || symbol || "") + rank; } if (rowsEl) { lastRowsHtml = data.rows.map(function(row) { - const change = row.change && row.change !== "--" ? '' + escapeHtml(row.change) + "" : ""; - const cardStyle = row.highlight ? "background:linear-gradient(180deg, rgba(56,97,251,.055), rgba(22,199,132,.045));box-shadow:inset 0 0 0 1.5px rgba(56,97,251,.62);border-radius:6px;padding:5px 6px;margin:2px -6px;" : "padding:4px 0;"; + const changeTone = row.change && row.change.startsWith("-") ? "negative" : row.change && row.change.startsWith("+") ? "positive" : "neutral"; + const change = row.change && row.change !== "--" ? '' + escapeHtml(row.change) + " · " + escapeHtml(uiText(CMC_COPY.changePeriod)) + "" : ""; + const detailId = PANEL_ID + "-explanation-" + row.id; + const expanded = expandedMetricId === row.id; return [ - '
', - '', + '', escapeHtml(row.label), - "", - '', + row.qualifier ? "" + escapeHtml(row.qualifier) + "" : "", + "", + '', escapeHtml(row.value), - change, "", - "
" + row.unit ? '' + escapeHtml(row.unit) + "" : "", + change, + "", + '", + escapeHtml(row.note), + "", + "", + '', + escapeHtml(row.label), + "", + escapeHtml(row.explanation), + "" ].join(""); }).join(""); rowsEl.innerHTML = lastRowsHtml; @@ -639,22 +1104,29 @@ if (footerEl) { lastUpdateTs = Date.now(); const cmcClock = data.lastUpdated ? formatClock(Date.parse(data.lastUpdated)) : "--"; - const sourceLabel = data.source === "page-snapshot" ? "CMC 页面快照" : "CMC data-api"; + const sourceLabel = uiText(data.source === "page-snapshot" ? CMC_COPY.page : CMC_COPY.api); footerEl.innerHTML = [ - '
', + '
', '', - sourceLabel, + '" target="_blank" rel="noopener noreferrer">', + escapeHtml(sourceLabel), "", - 'CMC ', + 'CMC ', cmcClock, - " / 拉取 ", + ' / ', + escapeHtml(uiText(CMC_COPY.fetched)), + " ", formatClock(lastUpdateTs), - "", - "
" + "", + "
", + "", + escapeHtml(uiText(CMC_COPY.refreshPeriod)), + "" ].join(""); } } @@ -718,8 +1190,12 @@ function removePanel() { cleanupPanelDrag(); const panel = document.getElementById(PANEL_ID); - if (panel) panel.remove(); + if (panel) { + panel.remove(); + window.dispatchEvent(new CustomEvent(DATA_PANEL_LAYOUT_EVENT)); + } lastRowsHtml = ""; + expandedMetricId = null; } function pauseForNonTradingPage() { stopDataLoop(); @@ -786,6 +1262,7 @@ const newTop = Math.max(0, Math.min(startTop + event.clientY - startY, window.innerHeight - panel.offsetHeight)); panel.style.left = newLeft + "px"; panel.style.top = newTop + "px"; + panel.style.setProperty("--cmc-panel-top", newTop + "px"); panel.style.right = "auto"; }; const onMouseUp = function(event) { @@ -799,6 +1276,7 @@ document.addEventListener("mouseup", onMouseUp); window.addEventListener("blur", cancelDrag); window.addEventListener("resize", onResize); + window.addEventListener(DATA_PANEL_LAYOUT_EVENT, onResize); return function cleanupDrag() { dragging = false; header.removeEventListener("mousedown", onMouseDown); @@ -806,6 +1284,7 @@ document.removeEventListener("mouseup", onMouseUp); window.removeEventListener("blur", cancelDrag); window.removeEventListener("resize", onResize); + window.removeEventListener(DATA_PANEL_LAYOUT_EVENT, onResize); }; } function setupControls(panel) { @@ -813,6 +1292,17 @@ const collapseBtn = panel.querySelector("#" + PANEL_ID + "-collapse"); const closeBtn = panel.querySelector("#" + PANEL_ID + "-close"); const body = panel.querySelector("#" + PANEL_ID + "-body"); + const rows = panel.querySelector("#" + PANEL_ID + "-rows"); + rows.addEventListener("click", function(event) { + const button = event.target.closest("[data-expand-metric]"); + if (!button) return; + expandedMetricId = expandedMetricId === button.dataset.expandMetric ? null : button.dataset.expandMetric; + rows.querySelectorAll("[data-expand-metric]").forEach(function(control) { + const expanded = control.dataset.expandMetric === expandedMetricId; + control.setAttribute("aria-expanded", String(expanded)); + panel.querySelector("#" + control.getAttribute("aria-controls")).hidden = !expanded; + }); + }); if (refreshBtn) { refreshBtn.addEventListener("click", function() { refreshForCurrentSymbol(true, false); @@ -824,6 +1314,7 @@ body.style.display = isHidden ? "block" : "none"; collapseBtn.innerHTML = isHidden ? "_" : "□"; saveCollapsed(!isHidden); + updatePanelLanguage(panel); }); } if (closeBtn) { @@ -831,6 +1322,8 @@ panel.style.display = "none"; panelClosed = true; stopLoop(); + cleanupPanelDrag(); + window.dispatchEvent(new CustomEvent(DATA_PANEL_LAYOUT_EVENT)); }); } } @@ -848,9 +1341,18 @@ } function keepPanelInViewport(panel) { const width = panel.offsetWidth || PANEL_WIDTH; - const normalized = normalizeSavedPosition(loadPosition(), width) || normalizeSavedPosition({ left: window.innerWidth - width - 16, top: 360 }, width); + const normalized = calculateDataPanelLayout({ + kind: "cmc", + panelWidth: width, + viewportWidth: window.innerWidth || document.documentElement.clientWidth || width, + viewportHeight: window.innerHeight || document.documentElement.clientHeight || 80, + savedPosition: normalizeSavedPosition(loadPosition(), width), + hasPeer: hasVisibleDataPanelPeer(document, "cmc") + }); panel.style.left = normalized.left + "px"; panel.style.top = normalized.top + "px"; + panel.style.maxHeight = normalized.maxHeight + "px"; + panel.style.setProperty("--cmc-panel-top", normalized.top + "px"); panel.style.right = "auto"; } function savePanelPosition(panel) { @@ -859,6 +1361,7 @@ const normalized = normalizeSavedPosition({ left: rect.left, top: rect.top }, panel.offsetWidth || PANEL_WIDTH); if (!normalized) return; savePosition(normalized.left, normalized.top); + keepPanelInViewport(panel); } function loadPosition() { try { diff --git a/scripts/binance-trading-data.user.js b/scripts/binance-trading-data.user.js index d02728b7..3b13ba70 100644 --- a/scripts/binance-trading-data.user.js +++ b/scripts/binance-trading-data.user.js @@ -3,9 +3,9 @@ // @namespace binance.trading.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 1.1.18 +// @version 1.2.0 // @author jackhai9 -// @description 在合约交易页面叠加浮动面板,定时拉取交易数据(持仓量、多空比、资金费率等)并显示当前值 + 多空信号 +// @description Bilingual futures metrics with historical trends, current funding, settlement countdown, and indicator signals. // @match https://www.binance.com/*/futures/* // @match https://www.binance.com/futures/* // @exclude https://www.binance.com/*/my/wallet/futures/* @@ -87,6 +87,1091 @@ }; } + // 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-trading-data/market-data.js + var HISTORY_FIELDS = Object.freeze({ + topAccountRatio: "longShortRatio", + topPositionRatio: "longShortRatio", + globalAccountRatio: "longShortRatio", + takerRatio: "buySellRatio", + basis: "basisRate", + fundingRate: "fundingRate" + }); + function numeric(value, field) { + if (typeof value !== "number" && typeof value !== "string" || value === "" || typeof value === "string" && value.trim() === "") { + throw new TypeError(`Missing numeric ${field}`); + } + const result = Number(value); + if (!Number.isFinite(result)) throw new TypeError(`Invalid numeric ${field}`); + return result; + } + function timestamp(value, field) { + const result = numeric(value, field); + if (!Number.isSafeInteger(result) || result <= 0) throw new TypeError(`Invalid timestamp ${field}`); + return result; + } + function parseHistory(key, payload, symbol) { + if (!Array.isArray(payload)) throw new TypeError(`${key} must be an array`); + let previousTime = 0; + return payload.map((row) => { + if (!row || typeof row !== "object") throw new TypeError(`Invalid ${key} record`); + if (row.symbol !== void 0 && row.symbol !== symbol) throw new Error(`${key} symbol mismatch`); + if (row.pair !== void 0 && row.pair !== symbol) throw new Error(`${key} pair mismatch`); + const time = timestamp(row[key === "fundingRate" ? "fundingTime" : "timestamp"], key); + if (time <= previousTime) throw new Error(`${key} history must have increasing timestamps`); + previousTime = time; + if (key === "openInterest") { + const value = numeric(row.sumOpenInterest, "sumOpenInterest"); + if (value < 0) throw new RangeError("Open interest cannot be negative"); + const rawSupply = row.CMCCirculatingSupply; + const supply = rawSupply === null || rawSupply === void 0 || rawSupply === "" ? null : numeric(rawSupply, "CMCCirculatingSupply"); + return { timestamp: time, value, supply }; + } + const field = HISTORY_FIELDS[key]; + if (!field) throw new Error(`Unknown historical endpoint: ${key}`); + const point = { timestamp: time, value: numeric(row[field], field) }; + if (key === "takerRatio") { + point.buy = numeric(row.buyVol, "buyVol"); + point.sell = numeric(row.sellVol, "sellVol"); + if (point.buy < 0 || point.sell < 0) throw new RangeError("Taker volume cannot be negative"); + } + return point; + }); + } + function parseCurrentFunding(payload, symbol) { + if (!payload || Array.isArray(payload) || payload.symbol !== symbol) throw new Error("Current funding symbol mismatch"); + return { + value: numeric(payload.lastFundingRate, "lastFundingRate"), + time: timestamp(payload.time, "premiumIndex.time"), + nextFundingTime: timestamp(payload.nextFundingTime, "nextFundingTime") + }; + } + function parseFundingInterval(payload, symbol) { + if (!Array.isArray(payload)) throw new TypeError("Funding information must be an array"); + const matches = payload.filter((row) => row && row.symbol === symbol); + if (matches.length === 0) return null; + if (matches.length !== 1) throw new Error("Funding interval symbol is ambiguous"); + const hours = numeric(matches[0].fundingIntervalHours, "fundingIntervalHours"); + if (!Number.isInteger(hours) || hours <= 0) throw new RangeError("Invalid funding interval"); + return hours; + } + function formatFundingPercent(value) { + if (value === null) return "--"; + if (!Number.isFinite(value)) throw new TypeError("Invalid funding rate"); + const percent = value * 100; + if (percent === 0) return "0%"; + const magnitude = Math.abs(percent); + if (magnitude < 1e-12) return percent.toExponential(2) + "%"; + const decimals = Math.max(6, Math.min(14, 2 - Math.floor(Math.log10(magnitude)))); + return new Intl.NumberFormat("en-US", { maximumFractionDigits: decimals, useGrouping: false }).format(percent) + "%"; + } + function compactQuantity(value, locale) { + const scales = locale === "zh-CN" ? [[1e12, "万亿"], [1e8, "亿"], [1e4, "万"]] : [[1e12, "T"], [1e9, "B"], [1e6, "M"], [1e3, "K"]]; + const scale = scales.find(([base]) => Math.abs(value) >= base); + const amount = scale ? value / scale[0] : value; + const decimals = locale === "zh-CN" ? 4 : 2; + return new Intl.NumberFormat("en-US", { maximumFractionDigits: decimals, useGrouping: false }).format(amount) + (scale ? scale[1] : ""); + } + function formatMetricValue(id, value, locale) { + if (value === null) return "--"; + if (!Number.isFinite(value)) throw new TypeError(`Invalid ${id} value`); + if (id === "funding") return formatFundingPercent(value); + if (id === "oi" || id === "volume") return compactQuantity(value, locale); + if (id === "basis" || id === "oi-supply") { + return (id === "basis" && value > 0 ? "+" : "") + (value * 100).toFixed(2) + "%"; + } + return value.toFixed(2); + } + var INDICATORS = Object.freeze([ + ["oi", "openInterest"], + ["top-accounts", "topAccountRatio"], + ["top-positions", "topPositionRatio"], + ["global-accounts", "globalAccountRatio"], + ["taker", "takerRatio"], + ["basis", "basis"], + ["funding", "fundingRate"], + ["oi-supply", "openInterest"] + ]); + function computeTradingSignals(data, cachedKeys, locale, symbol, endpointErrors = {}) { + const unit = symbol.replace(/USDT$|USDC$/, ""); + const indicators = INDICATORS.map(([id, key]) => { + const source = data[key] || []; + const history = id === "oi-supply" ? source.map((point) => ({ timestamp: point.timestamp, value: point.supply > 0 ? point.value / point.supply : null })) : source; + const value = history.length ? history.at(-1).value : null; + let signal = "neutral"; + let arrow = ""; + if (value !== null) { + if (id === "oi" && history.length > 6) { + const previous = history.at(-7).value; + signal = value > previous ? "long" : value < previous ? "short" : "neutral"; + arrow = value > previous ? " ▲" : value < previous ? " ▼" : ""; + } else if (id === "basis") { + signal = value > 0 ? "long" : value < 0 ? "short" : "neutral"; + } else if (id === "funding") { + signal = value < -1e-4 ? "long" : value > 1e-4 ? "short" : "neutral"; + } else if (id !== "oi" && id !== "oi-supply") { + signal = value > 1 ? "long" : value < 1 ? "short" : "neutral"; + } + } + return { + id, + signal, + value, + history, + display: formatMetricValue(id, value, locale) + arrow, + unit: id === "oi" || id === "taker" ? unit : "", + cached: cachedKeys.has(key), + error: endpointErrors[key] || null, + vote: id !== "oi-supply" && value !== null + }; + }); + const voters = indicators.filter((indicator) => indicator.vote && !indicator.cached); + return { + symbol, + indicators, + longCount: voters.filter((indicator) => indicator.signal === "long").length, + shortCount: voters.filter((indicator) => indicator.signal === "short").length, + total: voters.length + }; + } + + // src/binance-trading-data/ui-copy.js + var COPY = Object.freeze({ + title: localizedText("交易数据", "Trading data"), + metric: localizedText("指标", "Metric"), + history: localizedText("历史趋势", "History"), + value: localizedText("数值", "Value"), + collapse: localizedText("收起面板", "Collapse panel"), + expand: localizedText("展开面板", "Expand panel"), + close: localizedText("关闭", "Close"), + loading: localizedText("加载中", "Loading"), + missing: localizedText("数据缺失", "Data unavailable"), + loadFailed: localizedText("加载失败", "Load failed"), + cached: localizedText("缓存", "Cached"), + cachedFailure: localizedText("缓存 · 更新失败", "Cached · refresh failed"), + intervalCached: localizedText("周期缓存", "Cached interval"), + intervalCachedFailure: localizedText("周期缓存 · 更新失败", "Cached interval · refresh failed"), + intervalLoadFailed: localizedText("周期加载失败", "Interval load failed"), + refreshFailed: localizedText("更新失败", "Refresh failed"), + noHistory: localizedText("暂无历史", "No history"), + inspect: localizedText("查看历史数据", "Inspect history"), + keyboard: localizedText("左右方向键切换历史点;Esc 关闭明细", "Use left and right arrows to inspect points; Esc closes details"), + historyRecord: localizedText("历史数据", "Historical data"), + settledRecord: localizedText("历史结算", "Settled funding"), + last: localizedText("上次", "Last"), + current: localizedText("当前", "Current"), + intervalPending: localizedText("周期待确认", "Interval pending"), + countdown: localizedText("倒计时", "Countdown"), + clockUnavailable: localizedText("校时不可用", "Clock unavailable"), + waitingUpdate: localizedText("等待更新", "Waiting for update"), + buy: localizedText("买", "Buy"), + sell: localizedText("卖", "Sell"), + buySellRatio: localizedText("买/卖比", "Buy/sell ratio"), + basisRate: localizedText("比率", "Rate"), + composite: localizedText("综合信号", "Composite signal"), + long: localizedText("偏多", "Bullish"), + short: localizedText("偏空", "Bearish"), + neutral: localizedText("中性", "Neutral"), + fundingNote: localizedText( + "当前费率在结算前仍可能变化;历史曲线只含已结算记录。", + "The current rate may change before settlement. History contains settled rates only." + ), + simplifiedNote: localizedText( + "简化规则;资金费率投票使用最新已结算值。缓存及未平仓量与市值比率不参与投票。", + "Simplified rules; the funding vote uses the latest settled rate. Cached data and the open-interest-to-market-cap ratio do not vote." + ) + }); + var METRICS = Object.freeze({ + oi: { + name: localizedText("合约持仓量", "Open Interest"), + detail: localizedText( + "尚未平仓的合约规模,以当前交易币种计量。持仓量是存量,成交量是一段时间内的交易量。", + "Outstanding contract quantity in the current asset. Open interest measures open contracts; volume measures trading during a period." + ) + }, + "top-accounts": { + name: localizedText("大户账户数多空比", "Top Trader Long/Short Ratio (Accounts)"), + detail: localizedText( + "大户净多头账户数 ÷ 净空头账户数。大户按保证金余额前 20% 定义。", + "Top-trader net-long accounts divided by net-short accounts. Top traders are the top 20% by margin balance." + ) + }, + "top-positions": { + name: localizedText("大户持仓量多空比", "Top Trader Long/Short Ratio (Positions)"), + detail: localizedText( + "大户多头持仓量 ÷ 空头持仓量,按仓位规模统计。", + "Top-trader long position quantity divided by short position quantity, weighted by position size." + ) + }, + "global-accounts": { + name: localizedText("多空账户数比", "Long/Short Ratio"), + detail: localizedText( + "统计账户中的净多头账户数 ÷ 净空头账户数。", + "Net-long accounts divided by net-short accounts across the reported trading population." + ) + }, + taker: { + name: localizedText("合约主动买卖量", "Taker Buy/Sell Volume"), + detail: localizedText( + "柱图分别显示主动买入量与主动卖出量;右侧数值是买入量 ÷ 卖出量。", + "Bars show taker buy and sell volumes separately. The value at right is buy volume divided by sell volume." + ) + }, + basis: { + name: localizedText("基差", "Basis"), + detail: localizedText( + "以比率展示:(合约价格 − 现货指数价格)÷ 现货指数价格。", + "Shown as a rate: (futures price minus spot index price) divided by spot index price." + ) + }, + funding: { + name: localizedText("资金费率", "Funding Rate"), + detail: COPY.fundingNote + }, + "oi-supply": { + name: localizedText("未平仓量与市值比率", "Open Interest to Market Cap Ratio"), + detail: localizedText( + "未平仓币数 ÷ CMC 流通供应量,分子与分母按同一币种计量。这一项不参与综合信号投票。", + "Open-interest token quantity divided by CMC circulating supply, measured in the same asset. This row does not vote in the composite signal." + ) + } + }); + function tradingText(key, locale) { + return formatLocalizedText(COPY[key], locale); + } + function tradingMetricText(id, key, locale) { + return formatLocalizedText(METRICS[id][key], locale); + } + function formatFundingPeriod(hours, locale) { + const period = hours === null ? COPY.intervalPending : localizedText(`${hours}小时`, `${hours}h`); + return `${tradingText("current", locale)} · ${formatLocalizedText(period, locale)}`; + } + function formatHistoryCount(id, count, locale) { + return formatLocalizedText(id === "funding" ? localizedText(`已结算 · ${count}次`, `Settled · ${count} points`) : localizedText(`5分钟 · ${count}点`, `5m · ${count} points`), locale); + } + function formatVotes({ longCount, shortCount, total }, locale) { + const neutral = total - longCount - shortCount; + return formatLocalizedText(localizedText( + `指标票数:${longCount}多 · ${shortCount}空 · ${neutral}中性`, + `Votes: ${longCount} long · ${shortCount} short · ${neutral} neutral` + ), locale); + } + function formatHistoryTime(timestamp2, locale) { + return new Intl.DateTimeFormat(locale === "zh-CN" ? "zh-CN" : "en-GB", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + timeZoneName: "short" + }).format(timestamp2); + } + + // src/binance-trading-data/sparkline.js + var SVG_NS = "http://www.w3.org/2000/svg"; + var WIDTH = 144; + var HEIGHT = 44; + var PADDING = 4; + var RATIO_METRICS = /* @__PURE__ */ new Set(["top-accounts", "top-positions", "global-accounts"]); + function createHistoryChart({ document: document2, indicator, locale, onInspect }) { + const { history, id, unit } = indicator; + const volume = id === "taker"; + const listeners = []; + const listen = (node, type, listener) => { + node.addEventListener(type, listener); + listeners.push(() => node.removeEventListener(type, listener)); + }; + const destroy = () => listeners.splice(0).forEach((remove) => remove()); + const svgElement = (tag, attributes) => { + const node = document2.createElementNS(SVG_NS, tag); + for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value)); + return node; + }; + const points = history.map((point, index) => ({ ...point, index })); + for (const point of points) { + if (!Number.isFinite(point.timestamp) || point.value !== null && !Number.isFinite(point.value)) { + throw new Error(`Invalid historical observation for ${id}`); + } + } + const available = (point) => volume ? Number.isFinite(point.buy) && Number.isFinite(point.sell) : point.value !== null; + const observations = points.filter(available); + if (observations.length === 0) { + const empty = document2.createElement("span"); + empty.className = "td-no-history"; + empty.textContent = tradingText("noHistory", locale); + return { element: empty, count: 0, destroy }; + } + const button = document2.createElement("button"); + button.type = "button"; + button.className = "td-spark-button"; + const name = tradingMetricText(id, "name", locale); + button.setAttribute("aria-label", `${name} · ${tradingText("inspect", locale)}`); + button.setAttribute("aria-description", tradingText("keyboard", locale)); + button.setAttribute("aria-expanded", "false"); + const svg = svgElement("svg", { + class: "td-sparkline", + viewBox: `0 0 ${WIDTH} ${HEIGHT}`, + preserveAspectRatio: "none", + "aria-hidden": "true" + }); + const chartTitle = svgElement("title", {}); + chartTitle.textContent = `${name} · ${tradingText(id === "funding" ? "settledRecord" : "historyRecord", locale)}`; + svg.append(chartTitle); + button.append(svg); + const reference = RATIO_METRICS.has(id) ? 1 : volume || id === "funding" || id === "basis" ? 0 : null; + const values = observations.flatMap((point) => volume ? [point.buy, point.sell] : [point.value]); + if (reference !== null) values.push(reference); + const minimum = Math.min(...values); + const maximum = Math.max(...values); + const firstTime = Math.min(...points.map((point) => point.timestamp)); + const lastTime = Math.max(...points.map((point) => point.timestamp)); + const x = (point) => firstTime === lastTime ? WIDTH / 2 : PADDING + (point.timestamp - firstTime) * (WIDTH - PADDING * 2) / (lastTime - firstTime); + const y = (value) => maximum === minimum ? HEIGHT / 2 : PADDING + (maximum - value) * (HEIGHT - PADDING * 2) / (maximum - minimum); + const coordinate = (value) => Number(value.toFixed(3)); + if (reference !== null) { + svg.append(svgElement("line", { + x1: 0, + x2: WIDTH, + y1: y(reference), + y2: y(reference), + class: "td-spark-reference" + })); + } + if (volume) { + for (let index = 0; index < observations.length; index += 1) { + const point = observations[index]; + const previousGap = index === 0 ? WIDTH : x(point) - x(observations[index - 1]); + const nextGap = index === observations.length - 1 ? WIDTH : x(observations[index + 1]) - x(point); + const pairWidth = Math.min(12, previousGap * 0.8, nextGap * 0.8); + const barWidth = pairWidth * 0.42; + for (const [side, offset] of [["buy", -pairWidth / 2], ["sell", pairWidth * 0.08]]) { + svg.append(svgElement("rect", { + x: coordinate(x(point) + offset), + y: coordinate(y(point[side])), + width: coordinate(barWidth), + height: coordinate(y(0) - y(point[side])), + class: side === "buy" ? "td-buy-bar" : "td-sell-bar" + })); + } + } + } else { + const segments = []; + let segment = []; + for (const point of points) { + if (point.value === null) { + if (segment.length > 0) segments.push(segment); + segment = []; + } else { + segment.push(point); + } + } + if (segment.length > 0) segments.push(segment); + for (const observed of segments) { + if (observed.length > 1) { + const path = observed.map((point, index) => `${index === 0 ? "M" : "L"}${coordinate(x(point))},${coordinate(y(point.value))}`).join(" "); + svg.append(svgElement("path", { d: path, class: "td-spark-path" })); + } + const last = observed[observed.length - 1]; + svg.append(svgElement("circle", { + cx: coordinate(x(last)), + cy: coordinate(y(last.value)), + r: 2, + class: "td-spark-point" + })); + } + } + const guide = svgElement("line", { y1: 1, y2: HEIGHT - 1, class: "td-spark-guide", opacity: 0 }); + svg.append(guide); + let selected = observations.length - 1; + function record(point) { + const label = tradingText(id === "funding" ? "settledRecord" : "historyRecord", locale); + const quantity = (value2) => `${formatMetricValue("oi", value2, locale)}${unit ? ` ${unit}` : ""}`; + const value = volume ? `${tradingText("buy", locale)} ${quantity(point.buy)} · ${tradingText("sell", locale)} ${quantity(point.sell)} · ${tradingText("buySellRatio", locale)} ${formatMetricValue("taker", point.value, locale)}` : `${id === "funding" ? formatFundingPercent(point.value) : formatMetricValue(id, point.value, locale)}${id === "oi" && unit ? ` ${unit}` : ""}`; + return { + name, + label, + value, + timestamp: point.timestamp, + time: formatHistoryTime(point.timestamp, locale) + }; + } + function select(index, action) { + selected = index; + const point = observations[index]; + const item = record(point); + button.title = `${name} · ${item.label} +${item.time} +${item.value}`; + button.setAttribute("aria-label", `${name} · ${tradingText("inspect", locale)} · ${item.time} · ${item.value}`); + guide.setAttribute("x1", String(x(point))); + guide.setAttribute("x2", String(x(point))); + guide.setAttribute("opacity", action === "initial" ? "0" : "0.65"); + if (action !== "initial") onInspect(item, action); + } + for (let index = 0; index < observations.length; index += 1) { + const point = observations[index]; + const left = index === 0 ? 0 : (x(observations[index - 1]) + x(point)) / 2; + const right = index === observations.length - 1 ? WIDTH : (x(point) + x(observations[index + 1])) / 2; + const hit = svgElement("rect", { + x: coordinate(left), + y: 0, + width: coordinate(right - left), + height: HEIGHT, + class: "td-spark-hit", + "data-history-index": index, + "data-timestamp": point.timestamp + }); + const title = svgElement("title", {}); + const item = record(point); + title.textContent = `${item.time} +${item.value}`; + hit.append(title); + listen(hit, "pointerenter", () => select(index, "hover")); + listen(hit, "pointerdown", () => select(index, "hover")); + svg.append(hit); + } + listen(button, "click", (event) => { + const hit = event.target.closest("[data-history-index]"); + select(hit ? Number(hit.dataset.historyIndex) : selected, "toggle"); + }); + listen(button, "keydown", (event) => { + const movement = { ArrowLeft: -1, ArrowRight: 1 }; + if (event.key in movement) { + event.preventDefault(); + select(Math.max(0, Math.min(observations.length - 1, selected + movement[event.key])), "open"); + } else if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + select(event.key === "Home" ? 0 : observations.length - 1, "open"); + } else if (event.key === "Escape") { + event.preventDefault(); + onInspect(record(observations[selected]), "close"); + } + }); + listen(button, "pointerleave", () => { + if (button.getAttribute("aria-expanded") === "false") guide.setAttribute("opacity", "0"); + }); + select(selected, "initial"); + return { element: button, count: observations.length, destroy }; + } + + // src/binance-trading-data/panel-view.js + var PANEL_ID = "jh-binance-trading-data-panel"; + var INITIAL_FUNDING_STATE = Object.freeze({ + current: null, + intervalHours: null, + receivedAt: null, + cached: false, + error: null, + intervalCached: false, + intervalError: null + }); + function styles() { + const scope = `#${PANEL_ID}`; + return ` + ${scope} { + --td-bg: var(--color-PrimaryBg, light-dark(#ffffff, #1e2329)); + --td-head: var(--color-SecondaryBg, light-dark(#fafbfc, #242a32)); + --td-text: var(--color-TextPrimary, light-dark(#202630, #eaecef)); + --td-muted: var(--color-TextSecondary, light-dark(#626c7d, #a6b0bf)); + --td-border: var(--color-Line, light-dark(#e4e8ef, #353d48)); + --td-buy: var(--color-Buy, light-dark(#24865e, #65c59c)); + --td-sell: var(--color-Sell, light-dark(#db3a50, #ff7c8f)); + --td-chart: var(--color-PrimaryYellow, light-dark(#b68208, #f0b90b)); + --td-highlight: color-mix(in srgb, var(--td-chart) 7%, var(--td-bg)); + --td-focus: light-dark(#315fe8, #9bb7ff); + color-scheme: light dark; + box-sizing: border-box; + background: var(--td-bg); + color: var(--td-text); + border: 1px solid var(--td-border); + border-radius: 10px; + box-shadow: 0 6px 24px #00000020; + font: 400 13px/1.45 BinancePlex, system-ui, -apple-system, sans-serif; + text-align: left; + overflow: hidden; + } + ${scope} * { box-sizing: border-box; } + ${scope} [hidden] { display: none !important; } + ${scope} #${PANEL_ID}-header { + display: flex; align-items: center; gap: 7px; min-height: 49px; + padding: 10px 12px; flex: 0 0 auto; cursor: move; user-select: none; + border-bottom: 1px solid var(--td-border); background: var(--td-head); + } + ${scope} .td-grip { color: var(--td-muted); font-size: 16px; flex: 0 0 auto; } + ${scope} .td-title { margin: 0; font-size: 15px; font-weight: 600; white-space: nowrap; } + ${scope} #${PANEL_ID}-symbol { + min-width: 0; color: var(--td-muted); font-size: 12px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + ${scope} .td-actions { display: flex; gap: 3px; margin-left: auto; flex: 0 0 auto; } + ${scope} button { font: inherit; cursor: pointer; border: 0; color: inherit; background: transparent; } + ${scope} .td-actions button { + display: flex; align-items: center; justify-content: center; + width: 26px; height: 26px; padding: 2px; border-radius: 4px; + color: var(--td-muted); font-size: 17px; + } + ${scope} button:hover { background: var(--td-highlight); } + ${scope} button:focus-visible { outline: 2px solid var(--td-focus); outline-offset: 2px; } + ${scope} #${PANEL_ID}-body { min-height: 0; overflow: auto; overscroll-behavior: contain; } + ${scope} table { width: 100%; table-layout: fixed; border-collapse: collapse; } + ${scope} .td-name-col { width: 36%; } + ${scope} .td-trend-col { width: 31%; } + ${scope} .td-value-col { width: 33%; } + ${scope} thead { position: sticky; top: 0; z-index: 1; background: var(--td-head); } + ${scope} thead th { color: var(--td-muted); font-size: 11px; font-weight: 500; padding: 8px 10px; text-align: left; } + ${scope} thead th:nth-child(2) { text-align: center; } + ${scope} thead th:last-child { text-align: right; } + ${scope} tbody th, ${scope} tbody td { padding: 10px; vertical-align: middle; border-top: 1px solid var(--td-border); } + ${scope} .td-name { color: var(--td-muted); text-align: left; font-weight: 400; overflow-wrap: anywhere; } + ${scope} .td-trend { padding: 8px 3px; text-align: center; } + ${scope} .td-value { text-align: right; font-variant-numeric: tabular-nums; } + ${scope} .td-number { font-weight: 500; overflow-wrap: anywhere; } + ${scope} .jh-td-flash { animation: jh-td-value-change 1s ease-out; } + @keyframes jh-td-value-change { from { background: color-mix(in srgb, var(--td-chart) 25%, transparent); } to { background: transparent; } } + ${scope} small { display: block; margin-top: 3px; font-size: 11px; font-weight: 400; color: var(--td-muted); } + ${scope} .td-value small { line-height: 1.4; } + ${scope} .td-spark-button { display: block; width: 100%; padding: 0; border-radius: 4px; color: var(--td-chart); touch-action: pan-y; } + ${scope} .td-sparkline { display: block; width: 100%; height: 44px; overflow: visible; } + ${scope} .td-spark-path { fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; } + ${scope} .td-spark-reference { stroke: var(--td-border); stroke-width: 1; } + ${scope} .td-spark-guide { stroke: var(--td-muted); stroke-width: 1; pointer-events: none; } + ${scope} .td-spark-point { fill: currentColor; } + ${scope} .td-spark-hit { fill: transparent; pointer-events: all; } + ${scope} .td-buy-bar { fill: var(--td-buy); } + ${scope} .td-sell-bar { fill: var(--td-sell); } + ${scope} .td-no-history { display: block; padding: 9px 0; color: var(--td-muted); font-size: 11px; } + ${scope} .td-trend-caption, ${scope} .td-last-settled { font-size: 11px; } + ${scope} .td-last-settled { display: block; margin-top: 2px; color: var(--td-text); font-variant-numeric: tabular-nums; } + ${scope} .td-last-value { display: inline-block; white-space: nowrap; } + ${scope} .td-legend { display: flex; justify-content: center; gap: 9px; margin-top: 3px; color: var(--td-muted); font-size: 11px; } + ${scope} .td-legend span { display: inline-flex; align-items: center; gap: 3px; } + ${scope} .td-legend i { display: inline-block; width: 5px; height: 7px; background: var(--td-buy); } + ${scope} .td-legend span:last-child i { background: var(--td-sell); } + ${scope} .td-signal-long { color: var(--td-buy); } + ${scope} .td-signal-short { color: var(--td-sell); } + ${scope} .td-signal-neutral { color: var(--td-text); } + ${scope} .td-dot { display: inline-block; width: 7px; height: 7px; margin-left: 5px; border-radius: 50%; border: 1px solid currentColor; background: currentColor; } + ${scope} .td-signal-neutral .td-dot { color: var(--td-muted); } + ${scope} .td-cached .td-dot { background: transparent; } + ${scope} .td-data-status { color: var(--td-muted); } + ${scope} .td-funding-row { background: var(--td-highlight); } + ${scope} .td-funding-row .td-name { color: var(--td-text); font-weight: 500; } + ${scope} .td-funding-row .td-number { font-size: 14px; font-weight: 600; } + ${scope} .td-current-label { color: var(--td-text); margin: 0 0 3px; } + ${scope} .td-countdown { font-variant-numeric: tabular-nums; } + ${scope} .td-history-detail td { padding: 10px 12px; background: var(--td-head); } + ${scope} .td-history-inspection { display: flex; flex-wrap: wrap; gap: 5px 12px; font-size: 11px; color: var(--td-muted); user-select: text; } + ${scope} .td-history-inspection strong { flex: 1 0 100%; color: var(--td-text); font-size: 12px; font-weight: 500; } + ${scope} .td-history-value { color: var(--td-text); font-variant-numeric: tabular-nums; } + ${scope} #${PANEL_ID}-composite { padding: 12px; border-top: 1px solid var(--td-border); } + ${scope} .td-composite-line { display: flex; justify-content: space-between; gap: 8px; font-weight: 600; } + ${scope} .td-bias { white-space: nowrap; } + ${scope} .td-vote-track { display: flex; height: 5px; margin-top: 10px; border-radius: 5px; overflow: hidden; background: var(--td-border); } + ${scope} .td-vote-track span:first-child { background: var(--td-buy); } + ${scope} .td-vote-track span:last-child { background: var(--td-sell); margin-left: auto; } + ${scope} .td-composite-votes { margin-top: 8px; } + ${scope} .td-method-note { line-height: 1.5; } + ${scope} #${PANEL_ID}-footer { padding: 9px 12px; color: var(--td-muted); font-size: 11px; border-top: 1px solid var(--td-border); font-variant-numeric: tabular-nums; } + ${scope} .td-footer-line { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 5px 10px; } + @media (max-width: 540px) { + ${scope} { font-size: 12px; } + ${scope} #${PANEL_ID}-header { padding: 9px 10px; gap: 6px; } + ${scope} .td-title { font-size: 14px; } + ${scope} .td-name-col { width: 34%; } + ${scope} .td-value-col { width: 35%; } + ${scope} thead th, ${scope} tbody th, ${scope} tbody td { padding: 9px 5px; } + ${scope} .td-trend { padding: 8px 3px; } + ${scope} .td-funding-row .td-number { font-size: 13px; } + } + `; + } + function createTradingDataView({ document: document2, panel, locale, collapsed, onCollapse, onClose }) { + const style = document2.createElement("style"); + style.textContent = styles(); + panel.append(style); + Object.assign(panel.style, { + width: "480px", + maxWidth: "calc(100vw - 16px)", + maxHeight: "calc(100vh - 24px)", + display: "flex", + flexDirection: "column" + }); + const node = (tag, className = "", text = "") => { + const element = document2.createElement(tag); + element.className = className; + element.textContent = text; + return element; + }; + const setText = (element, value) => { + if (element.textContent !== value) element.textContent = value; + }; + const identify = (element, suffix) => { + element.id = `${PANEL_ID}-${suffix}`; + return element; + }; + const role = (element, value) => { + element.dataset.role = value; + return element; + }; + const header = identify(node("header"), "header"); + const grip = node("span", "td-grip", "☰"); + grip.setAttribute("aria-hidden", "true"); + const title = node("h2", "td-title"); + const symbol = identify(node("span"), "symbol"); + const actions = node("div", "td-actions"); + const collapseButton = identify(node("button"), "collapse"); + const closeButton = identify(node("button", "", "×"), "close"); + collapseButton.type = closeButton.type = "button"; + collapseButton.setAttribute("aria-controls", `${PANEL_ID}-body`); + actions.append(collapseButton, closeButton); + header.append(grip, title, symbol, actions); + const body = identify(node("div"), "body"); + const table = node("table"); + const columns = node("colgroup"); + for (const name of ["name", "trend", "value"]) columns.append(node("col", `td-${name}-col`)); + const tableHead = node("thead"); + const headingRow = node("tr"); + const headings = ["metric", "history", "value"].map((key) => { + const heading = node("th"); + heading.scope = "col"; + heading.dataset.copy = key; + headingRow.append(heading); + return heading; + }); + tableHead.append(headingRow); + const rows = identify(node("tbody"), "rows"); + table.append(columns, tableHead, rows); + const composite = identify(node("div"), "composite"); + const footer = identify(node("footer"), "footer"); + const footerLine = node("div", "td-footer-line"); + footerLine.append(role(node("span"), "updated-at"), role(node("span"), "elapsed")); + footer.append(footerLine); + body.append(table, composite, footer); + panel.append(header, body); + let currentModel = null; + let currentFunding = INITIAL_FUNDING_STATE; + let currentClock = null; + let fundingNodes = null; + let charts = []; + let openDetail = null; + function closeDetails() { + if (openDetail !== null) { + openDetail.row.remove(); + openDetail.button.setAttribute("aria-expanded", "false"); + openDetail = null; + } + } + function updateHeading() { + const panelTitle = tradingText("title", locale); + panel.lang = locale; + panel.setAttribute("aria-label", panelTitle); + table.setAttribute("aria-label", panelTitle); + setText(title, panelTitle); + for (const heading of headings) setText(heading, tradingText(heading.dataset.copy, locale)); + const collapseTitle = tradingText(collapsed ? "expand" : "collapse", locale); + collapseButton.title = collapseTitle; + collapseButton.setAttribute("aria-label", collapseTitle); + collapseButton.setAttribute("aria-expanded", String(!collapsed)); + setText(collapseButton, collapsed ? "□" : "−"); + closeButton.title = tradingText("close", locale); + closeButton.setAttribute("aria-label", closeButton.title); + body.style.display = collapsed ? "none" : "block"; + } + function collapse() { + collapsed = !collapsed; + updateHeading(); + onCollapse(collapsed); + } + function close() { + onClose(); + } + collapseButton.addEventListener("click", collapse); + closeButton.addEventListener("click", close); + function showStatus(element, key, error) { + const text = key === null ? "" : tradingText(key, locale); + setText(element, text); + element.hidden = text === ""; + element.title = error ? `${text}: ${error}` : text; + } + function updateCountdown() { + if (fundingNodes === null || !fundingNodes.number.isConnected) return; + let value; + if (currentClock === null || !currentClock.calibrated) { + value = tradingText("clockUnavailable", locale); + } else if (currentFunding.current === null) { + value = "--"; + } else { + const remaining = currentFunding.current.nextFundingTime - currentClock.now; + if (remaining <= 0) { + value = tradingText("waitingUpdate", locale); + } else { + const seconds = Math.ceil(remaining / 1e3); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor(seconds % 3600 / 60); + value = [hours, minutes, seconds % 60].map((part) => String(part).padStart(2, "0")).join(":"); + } + } + setText(fundingNodes.countdown, `${tradingText("countdown", locale)} ${value}`); + } + function updateFunding() { + if (fundingNodes === null || !fundingNodes.number.isConnected) return; + const state = currentFunding; + const expired = state.current !== null && currentClock !== null && currentClock.calibrated && state.current.nextFundingTime <= currentClock.now; + setText(fundingNodes.number, formatFundingPercent(state.current === null || expired ? null : state.current.value)); + setText(fundingNodes.period, formatFundingPeriod(state.intervalHours, locale)); + fundingNodes.number.title = tradingText("fundingNote", locale); + if (state.current !== null) fundingNodes.number.title += ` +${formatHistoryTime(state.current.time, locale)}`; + const status = expired && state.error === null ? "waitingUpdate" : state.current === null ? state.error ? "loadFailed" : "loading" : state.cached ? state.error ? "cachedFailure" : "cached" : state.error ? "refreshFailed" : null; + const intervalStatus = state.intervalHours !== null && state.intervalCached ? state.intervalError ? "intervalCachedFailure" : "intervalCached" : state.intervalError ? "intervalLoadFailed" : null; + showStatus(fundingNodes.status, status, state.error); + showStatus(fundingNodes.intervalStatus, intervalStatus, state.intervalError); + updateCountdown(); + } + function renderIndicator(indicator, previousValue) { + const { id } = indicator; + const isFunding = id === "funding"; + const row = node("tr", isFunding ? "td-funding-row" : ""); + row.dataset.metric = id; + row.classList.toggle("td-cached", indicator.cached && indicator.value !== null); + const name = node("th", "td-name", tradingMetricText(id, "name", locale)); + name.scope = "row"; + name.title = tradingMetricText(id, "detail", locale); + const trend = node("td", "td-trend"); + const detailRow = node("tr", "td-history-detail"); + detailRow.id = `${PANEL_ID}-history-${id}`; + const detailCell = node("td"); + detailCell.colSpan = 3; + const inspection = node("div", "td-history-inspection"); + inspection.setAttribute("role", "status"); + inspection.setAttribute("aria-atomic", "true"); + const detailTitle = node("strong"); + const detailTime = node("time"); + const detailValue = node("span", "td-history-value"); + inspection.append(detailTitle, detailTime, detailValue); + detailCell.append(inspection); + detailRow.append(detailCell); + const chart = createHistoryChart({ + document: document2, + indicator, + locale, + onInspect(record, action) { + setText(detailTitle, `${record.name} · ${record.label}`); + setText(detailTime, record.time); + detailTime.dateTime = new Date(record.timestamp).toISOString(); + setText(detailValue, record.value); + if (action === "hover") return; + const shouldClose = action === "close" || action === "toggle" && openDetail?.row === detailRow; + closeDetails(); + if (shouldClose) return; + row.after(detailRow); + chart.element.setAttribute("aria-expanded", "true"); + openDetail = { row: detailRow, button: chart.element }; + } + }); + charts.push(chart); + if (chart.count > 0) chart.element.setAttribute("aria-controls", detailRow.id); + trend.append(chart.element, node("small", "td-trend-caption", formatHistoryCount(id, chart.count, locale))); + if (isFunding) { + const settled = node("span", "td-last-settled"); + settled.append(document2.createTextNode(`${tradingText("last", locale)} `), node("span", "td-last-value", indicator.display)); + trend.append(settled); + } + if (id === "taker") { + const legend = node("div", "td-legend"); + for (const side of ["buy", "sell"]) { + const item = node("span"); + const swatch = node("i"); + swatch.setAttribute("aria-hidden", "true"); + item.append(swatch, document2.createTextNode(tradingText(side, locale))); + legend.append(item); + } + trend.append(legend); + } + const value = node("td", `td-value td-signal-${isFunding ? "neutral" : indicator.signal}`); + const number = node("span", "td-number", isFunding ? "--" : indicator.display); + if (!isFunding && previousValue !== void 0 && previousValue !== null && indicator.value !== null && previousValue !== indicator.value) { + number.classList.add("jh-td-flash"); + } + if (isFunding) { + const period = role(node("small", "td-current-label"), "funding-period"); + const countdown = role(node("small", "td-countdown"), "funding-countdown"); + const status = role(node("small", "td-data-status"), "funding-status"); + const intervalStatus = role(node("small", "td-data-status"), "funding-interval-status"); + role(number, "current-funding"); + value.append(period, number, countdown, status, intervalStatus); + fundingNodes = { number, period, countdown, status, intervalStatus }; + } else { + const reading = node("div"); + const dot = node("span", "td-dot"); + dot.setAttribute("role", "img"); + dot.setAttribute("aria-label", tradingText(indicator.signal, locale)); + reading.append(number, dot); + value.append(reading); + const unit = id === "taker" ? tradingText("buySellRatio", locale) : id === "basis" ? tradingText("basisRate", locale) : indicator.unit; + if (unit) value.append(node("small", "td-unit", unit)); + } + const dataStatus = node("small", "td-data-status"); + const statusKey = indicator.value === null ? indicator.error ? "loadFailed" : "missing" : indicator.cached ? indicator.error ? "cachedFailure" : "cached" : null; + showStatus(dataStatus, statusKey, indicator.error); + (isFunding ? trend : value).append(dataStatus); + row.append(name, trend, value); + rows.append(row); + } + function renderComposite(model) { + const signal = model.longCount === model.shortCount ? "neutral" : model.longCount > model.shortCount ? "long" : "short"; + const line = node("div", "td-composite-line"); + line.append(node("span", "", tradingText("composite", locale)), node("span", `td-bias td-signal-${signal}`, `${tradingText(signal, locale)} ${model.longCount}:${model.shortCount}`)); + const track = node("div", "td-vote-track"); + const votes = formatVotes(model, locale); + track.setAttribute("role", "img"); + track.setAttribute("aria-label", votes); + for (const count of [model.longCount, model.shortCount]) { + const bar = node("span"); + bar.style.width = `${model.total > 0 ? count / model.total * 100 : 0}%`; + track.append(bar); + } + composite.replaceChildren(line, track, node("small", "td-composite-votes", votes), node("small", "td-method-note", tradingText("simplifiedNote", locale))); + } + function render(model) { + const previousValues = new Map(currentModel !== null && currentModel.symbol === model.symbol ? currentModel.indicators.map((indicator) => [indicator.id, indicator.value]) : []); + currentModel = model; + if (![symbol, rows, composite, footer].every((element) => element.isConnected)) return; + setText(symbol, model.symbol); + symbol.title = model.symbol; + closeDetails(); + charts.forEach((chart) => chart.destroy()); + charts = []; + rows.replaceChildren(); + fundingNodes = null; + for (const indicator of model.indicators) renderIndicator(indicator, previousValues.get(indicator.id)); + renderComposite(model); + updateFunding(); + } + updateHeading(); + return { + render, + setFunding(state, clock) { + currentFunding = state; + currentClock = clock; + updateFunding(); + }, + updateClock(clock) { + currentClock = clock; + updateFunding(); + }, + setLocale(nextLocale) { + if (locale === nextLocale) return; + locale = nextLocale; + updateHeading(); + if (currentModel !== null) render(currentModel); + }, + destroy() { + collapseButton.removeEventListener("click", collapse); + closeButton.removeEventListener("click", close); + charts.forEach((chart) => chart.destroy()); + charts = []; + closeDetails(); + style.remove(); + } + }; + } + + // 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-trading-data/index.user.js (function() { "use strict"; @@ -94,10 +1179,10 @@ return isFuturesTradingPathname(location.pathname); } const PREFIX = "[交易数据]"; - const PANEL_ID = "jh-binance-trading-data-panel"; + const PANEL_ID2 = "jh-binance-trading-data-panel"; const STORAGE_POS_KEY = "jh_binance_trading_data_pos"; const STORAGE_COLLAPSED_KEY = "jh_binance_trading_data_collapsed"; - const PANEL_WIDTH = 240; + const PANEL_WIDTH = 480; const DEBUG = false; const PERIOD_MS = 5 * 60 * 1e3; const FIRST_DELAY = 5e3; @@ -106,8 +1191,11 @@ const ROUTE_WATCHDOG_MS = 5e3; const DEFAULT_PERIOD = "5m"; const DATA_LIMIT = 30; - const OI_TREND_PERIODS = 6; - const FUNDING_RATE_THRESHOLD = 1e-4; + const FUNDING_HISTORY_LIMIT = 40; + const CURRENT_FUNDING_REFRESH_MS = 15e3; + const FUNDING_REQUEST_TIMEOUT_MS = 1e4; + const CLOCK_REQUEST_TIMEOUT_MS = 5e3; + const CLOCK_MAX_ROUND_TRIP_MS = 2e3; const API_BASE = "https://www.binance.com"; const API_PATHS = { openInterest: "/futures/data/openInterestHist", @@ -117,6 +1205,8 @@ takerRatio: "/futures/data/takerlongshortRatio", basis: "/futures/data/basis", fundingRate: "/fapi/v1/fundingRate", + currentFunding: "/fapi/v1/premiumIndex", + fundingInterval: "/fapi/v1/fundingInfo", serverTime: "/fapi/v1/time" }; const PERIOD_KEYS = ["openInterest", "topAccountRatio", "topPositionRatio", "globalAccountRatio", "takerRatio", "basis"]; @@ -137,18 +1227,19 @@ function isActiveTradingPage() { return !panelClosed && !document.hidden && isFuturesTradingPage(); } - async function fetchJson(path, params) { + async function fetchJson(path, params, signal) { const url = new URL(path, API_BASE); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); const href = url.toString(); try { - const resp = await fetch(href); + const resp = await fetch(href, { signal }); if (!resp.ok) throw Object.assign(new Error(`HTTP ${resp.status}`), { status: resp.status }); return await resp.json(); } catch (e1) { + if (signal?.aborted) throw e1; if (e1.status && e1.status >= 400 && e1.status < 500) throw e1; log("重试:", path); - const resp = await fetch(href); + const resp = await fetch(href, { signal }); if (!resp.ok) throw new Error(`HTTP ${resp.status} (retry)`); return await resp.json(); } @@ -172,7 +1263,7 @@ return fetchJson(API_PATHS.basis, { pair: symbol, period: DEFAULT_PERIOD, limit: DATA_LIMIT, contractType: "PERPETUAL" }); } function fetchFundingRate(symbol) { - return fetchJson(API_PATHS.fundingRate, { symbol, limit: 1 }); + return fetchJson(API_PATHS.fundingRate, { symbol, limit: FUNDING_HISTORY_LIMIT }); } const FETCHER_MAP = { openInterest: fetchOpenInterest, @@ -183,16 +1274,44 @@ basis: fetchBasis }; let serverOffset = 0; - async function syncServerTime() { + let clockCalibrated = false; + let sessionGeneration = 0; + let clockRequestId = 0; + let serverTimeRequest = null; + function currentSession() { + return { generation: sessionGeneration, path: location.pathname, symbol: getCurrentSymbol() }; + } + function sessionIsCurrent(session) { + return session.generation === sessionGeneration && session.path === location.pathname && session.symbol === getCurrentSymbol() && isActiveTradingPage(); + } + async function syncServerTime(session) { + const requestId = ++clockRequestId; + const startedAt = Date.now(); + const request = { controller: new AbortController(), timeout: null }; + serverTimeRequest = request; + request.timeout = setTimeout(function() { + request.controller.abort(new DOMException("Server time request timed out", "TimeoutError")); + }, CLOCK_REQUEST_TIMEOUT_MS); try { - const resp = await fetch(API_BASE + API_PATHS.serverTime); + const resp = await fetch(API_BASE + API_PATHS.serverTime, { signal: request.controller.signal }); if (!resp.ok) throw new Error("HTTP " + resp.status); const json = await resp.json(); - serverOffset = json.serverTime - Date.now(); + if (!Number.isSafeInteger(json.serverTime) || json.serverTime <= 0) throw new TypeError("Invalid server time"); + if (!sessionIsCurrent(session) || requestId !== clockRequestId) return; + const receivedAt = Date.now(); + const roundTrip = receivedAt - startedAt; + if (roundTrip < 0 || roundTrip > CLOCK_MAX_ROUND_TRIP_MS) throw new Error("Server time round trip exceeded the calibration limit"); + serverOffset = json.serverTime - (startedAt + receivedAt) / 2; + clockCalibrated = true; log("服务器时间偏移:", serverOffset + "ms"); } catch (e) { - err("获取服务器时间失败,使用本地时间"); + if (!sessionIsCurrent(session) || requestId !== clockRequestId) return; + err("获取服务器时间失败,使用本地时间", e.message); serverOffset = 0; + clockCalibrated = false; + } finally { + clearTimeout(request.timeout); + if (serverTimeRequest === request) serverTimeRequest = null; } } function serverNow() { @@ -201,28 +1320,30 @@ let dataStore = {}; let dataCache = {}; let failedKeys = /* @__PURE__ */ new Set(); + let endpointErrors = {}; + const historyUpdatedAt = {}; function extractEndpointTs(data) { if (!Array.isArray(data) || data.length === 0) return 0; return Number(data[data.length - 1].timestamp) || 0; } async function fetchPeriodData(symbol, keys) { if (!keys || keys.length === 0) return {}; - var fetchers = keys.map(function(k) { - return FETCHER_MAP[k](symbol); + var fetchers = keys.map(async function(k) { + return parseHistory(k, await FETCHER_MAP[k](symbol), symbol); }); var results = await Promise.allSettled(fetchers); var backup = dataCache[symbol] || {}; var entries = {}; keys.forEach(function(key, i) { if (results[i].status === "fulfilled") { - entries[key] = { data: results[i].value, cached: false }; + entries[key] = { data: results[i].value, cached: false, error: null }; } else { err(key + " 请求失败:", results[i].reason?.message || results[i].reason); if (backup[key]) { - entries[key] = { data: backup[key], cached: true }; + entries[key] = { data: backup[key], cached: true, error: String(results[i].reason?.message || results[i].reason) }; log(key + " 使用缓存数据"); } else { - entries[key] = { data: null, cached: true }; + entries[key] = { data: null, cached: true, error: String(results[i].reason?.message || results[i].reason) }; } } }); @@ -231,11 +1352,11 @@ async function fetchFundingRateData(symbol) { var backup = dataCache[symbol] || {}; try { - var data = await fetchFundingRate(symbol); - return { data, cached: false }; + var data = parseHistory("fundingRate", await fetchFundingRate(symbol), symbol); + return { data, cached: false, error: null }; } catch (e) { err("fundingRate 请求失败:", e); - return { data: backup.fundingRate || null, cached: true }; + return { data: backup.fundingRate || null, cached: true, error: String(e.message || e) }; } } function applyResults(symbol, periodEntries, fundingEntry) { @@ -245,6 +1366,7 @@ for (var key in periodEntries) { var e = periodEntries[key]; dataStore[symbol][key] = e.data; + endpointErrors[key] = e.error; if (!e.cached) { dataCache[symbol][key] = e.data; failedKeys.delete(key); @@ -255,6 +1377,7 @@ } if (fundingEntry) { dataStore[symbol].fundingRate = fundingEntry.data; + endpointErrors.fundingRate = fundingEntry.error; if (!fundingEntry.cached) { dataCache[symbol].fundingRate = fundingEntry.data; failedKeys.delete("fundingRate"); @@ -262,6 +1385,9 @@ failedKeys.add("fundingRate"); } } + const freshHistory = periodEntries && Object.values(periodEntries).some((entry) => !entry.cached) || fundingEntry && !fundingEntry.cached; + if (freshHistory) historyUpdatedAt[symbol] = Date.now(); + lastUpdateTs = historyUpdatedAt[symbol] || 0; } function getPendingKeys(symbol, targetTs) { var store = dataStore[symbol] || {}; @@ -269,346 +1395,149 @@ return extractEndpointTs(store[key]) < targetTs; }); } - function parseOpenInterest(data) { - if (!Array.isArray(data) || data.length === 0) return null; - const latest = data[data.length - 1]; - const value = parseFloat(latest.sumOpenInterest); - const valueUsd = parseFloat(latest.sumOpenInterestValue); - let trend = null; - if (data.length > OI_TREND_PERIODS) { - const prev = parseFloat(data[data.length - 1 - OI_TREND_PERIODS].sumOpenInterest); - trend = value > prev ? "up" : value < prev ? "down" : "neutral"; - } - return { value, valueUsd, trend }; - } - function parseOIMarketCapRatio(data) { - if (!Array.isArray(data) || data.length === 0) return null; - const latest = data[data.length - 1]; - const oi = parseFloat(latest.sumOpenInterest); - const supply = parseFloat(latest.CMCCirculatingSupply); - if (!supply || supply === 0) return null; - return { value: oi / supply }; - } - function parseRatio(data, field) { - if (!Array.isArray(data) || data.length === 0) return null; - const latest = data[data.length - 1]; - return { value: parseFloat(latest[field]) }; - } - function parseBasis(data) { - if (!Array.isArray(data) || data.length === 0) return null; - const latest = data[data.length - 1]; - return { value: parseFloat(latest.basisRate) }; - } - function parseFundingRate(data) { - if (!Array.isArray(data) || data.length === 0) return null; - return { value: parseFloat(data[0].fundingRate) }; - } - function signalOpenInterest(parsed) { - if (!parsed || !parsed.trend) return "neutral"; - return parsed.trend === "up" ? "long" : parsed.trend === "down" ? "short" : "neutral"; - } - function signalRatio(parsed) { - if (!parsed) return "neutral"; - return parsed.value > 1 ? "long" : parsed.value < 1 ? "short" : "neutral"; - } - function signalBasis(parsed) { - if (!parsed) return "neutral"; - return parsed.value > 0 ? "long" : parsed.value < 0 ? "short" : "neutral"; - } - function signalFundingRate(parsed) { - if (!parsed) return "neutral"; - if (parsed.value < -FUNDING_RATE_THRESHOLD) return "long"; - if (parsed.value > FUNDING_RATE_THRESHOLD) return "short"; - return "neutral"; - } - function computeSignals(data, cachedKeys) { - const oi = parseOpenInterest(data.openInterest); - const oiMcRatio = parseOIMarketCapRatio(data.openInterest); - const topAccount = parseRatio(data.topAccountRatio, "longShortRatio"); - const topPosition = parseRatio(data.topPositionRatio, "longShortRatio"); - const globalAccount = parseRatio(data.globalAccountRatio, "longShortRatio"); - const taker = parseRatio(data.takerRatio, "buySellRatio"); - const basis = parseBasis(data.basis); - const funding = parseFundingRate(data.fundingRate); - const c = cachedKeys || /* @__PURE__ */ new Set(); - const indicators = [ - { name: "合约持仓量", signal: signalOpenInterest(oi), display: fmtOI(oi), vote: true, cached: c.has("openInterest") }, - { name: "大户账户多空比", signal: signalRatio(topAccount), display: fmtRatio(topAccount), vote: true, cached: c.has("topAccountRatio") }, - { name: "大户持仓多空比", signal: signalRatio(topPosition), display: fmtRatio(topPosition), vote: true, cached: c.has("topPositionRatio") }, - { name: "多空账户数比", signal: signalRatio(globalAccount), display: fmtRatio(globalAccount), vote: true, cached: c.has("globalAccountRatio") }, - { name: "主动买卖比", signal: signalRatio(taker), display: fmtRatio(taker), vote: true, cached: c.has("takerRatio") }, - { name: "基差", signal: signalBasis(basis), display: fmtBasis(basis), vote: true, cached: c.has("basis") }, - { name: "资金费率", signal: signalFundingRate(funding), display: fmtFunding(funding), vote: true, cached: c.has("fundingRate") }, - { name: "未平仓量/市值", signal: "neutral", display: fmtOIMarketCap(oiMcRatio), vote: false, cached: c.has("openInterest") } - ]; - const voters = indicators.filter((i) => i.vote && !i.cached); - const total = voters.length; - const longCount = voters.filter((i) => i.signal === "long").length; - const shortCount = voters.filter((i) => i.signal === "short").length; - return { indicators, longCount, shortCount, total }; - } - function fmtOI(parsed) { - if (!parsed) return "--"; - const v = parsed.value; - const arrow = parsed.trend === "up" ? " ▲" : parsed.trend === "down" ? " ▼" : ""; - if (v >= 1e9) return (v / 1e9).toFixed(2) + "B" + arrow; - if (v >= 1e6) return (v / 1e6).toFixed(2) + "M" + arrow; - if (v >= 1e3) return (v / 1e3).toFixed(0) + "K" + arrow; - return v.toFixed(2) + arrow; - } - function fmtRatio(parsed) { - if (!parsed) return "--"; - return parsed.value.toFixed(4); - } - function fmtBasis(parsed) { - if (!parsed) return "--"; - const sign = parsed.value >= 0 ? "+" : ""; - return sign + (parsed.value * 100).toFixed(4) + "%"; - } - function fmtFunding(parsed) { - if (!parsed) return "--"; - return (parsed.value * 100).toFixed(4) + "%"; - } - function fmtOIMarketCap(parsed) { - if (!parsed) return "--"; - return (parsed.value * 100).toFixed(2) + "%"; - } - const FLASH_STYLE_ID = "jh-trading-data-flash-style"; - function injectFlashStyle() { - if (document.getElementById(FLASH_STYLE_ID)) return; - const style = document.createElement("style"); - style.id = FLASH_STYLE_ID; - style.textContent = [ - "@keyframes jh-td-flash {", - " 0%, 100% { background: transparent; }", - " 50% { background: rgba(240, 160, 0, 0.45); }", - "}", - ".jh-td-flash { animation: jh-td-flash 1s ease-in-out 5; }" - ].join("\n"); - (document.head || document.documentElement).appendChild(style); - } - let prevDisplayValues = {}; - const C = { - long: "var(--color-Buy, #0ecb81)", - short: "var(--color-Sell, #f6465d)", - neutral: "#76808f", - bg: "#ffffff", - text: "#1e2329", - sub: "#5e6673", - border: "#eaecef" - }; - function signalColor(s) { - return s === "long" ? C.long : s === "short" ? C.short : C.neutral; + let panelView = null; + function uiLocale() { + return resolveUiLocaleFromPathname(location.pathname); + } + function fundingClock() { + return { now: serverNow(), calibrated: clockCalibrated, localNow: Date.now() }; } function ensurePanel() { - let panel = document.getElementById(PANEL_ID); + let panel = document.getElementById(PANEL_ID2); if (panel) return panel; - injectFlashStyle(); - panel = document.createElement("div"); - panel.id = PANEL_ID; - const collapsed = loadCollapsed(); - Object.assign(panel.style, { - position: "fixed", - top: "60px", - left: "auto", - right: "16px", - width: PANEL_WIDTH + "px", - zIndex: "999998", - 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 = [ - // --- header --- - '
', - '
', - '☰', - '交易数据', - '', - "
", - '
', - '", - '', - "
", - "
", - // --- body --- - '
', - '
', - '
', - '", - "
" - ].join(""); + panel = document.createElement("section"); + panel.id = PANEL_ID2; + Object.assign(panel.style, { position: "fixed", top: "60px", right: "16px", zIndex: "999998" }); document.body.appendChild(panel); + panelView = createTradingDataView({ + document, + panel, + locale: uiLocale(), + collapsed: loadCollapsed(), + onCollapse(collapsed) { + saveCollapsed(collapsed); + keepPanelInViewport(panel); + }, + onClose() { + panel.style.display = "none"; + panelClosed = true; + stopLoop(); + cleanupPanelDrag(); + panelView.destroy(); + window.dispatchEvent(new CustomEvent(DATA_PANEL_LAYOUT_EVENT)); + } + }); keepPanelInViewport(panel); cleanupPanelDrag(); dragCleanup = setupDrag(panel); - setupCollapseAndClose(panel); + window.dispatchEvent(new CustomEvent(DATA_PANEL_LAYOUT_EVENT)); return panel; } - function renderPanel(result, symbolOverride) { - if (!isActiveTradingPage()) return; + function renderPanel(result, symbol) { + if (!isActiveTradingPage() || getCurrentSymbol() !== symbol) return; const panel = ensurePanel(); - const { indicators, longCount, shortCount, total } = result; - const symbol = symbolOverride || getCurrentSymbol(); - if (!symbol) return; - const changed = {}; - for (const ind of indicators) { - if (prevDisplayValues[ind.name] !== void 0 && prevDisplayValues[ind.name] !== ind.display) { - changed[ind.name] = true; - } - } - for (const ind of indicators) { - prevDisplayValues[ind.name] = ind.display; - } - const symbolEl = panel.querySelector("#" + PANEL_ID + "-symbol"); - if (symbolEl) symbolEl.textContent = symbol || ""; - const rowsEl = panel.querySelector("#" + PANEL_ID + "-rows"); - if (rowsEl) { - rowsEl.innerHTML = indicators.map(function(ind) { - const valColor = ind.signal === "long" ? C.long : ind.signal === "short" ? C.short : C.text; - const dotColor = signalColor(ind.signal); - const dotStyle = ind.cached ? "display:inline-block;width:10px;height:10px;border-radius:50%;border:2px solid " + dotColor + ";background:transparent;" : "display:inline-block;width:10px;height:10px;border-radius:50%;background:" + dotColor + ";"; - const flashClass = changed[ind.name] ? " jh-td-flash" : ""; - return [ - '
', - '', - ind.name, - "", - '', - ind.display, - "", - '', - "
" - ].join(""); - }).join(""); - } - const compositeEl = panel.querySelector("#" + PANEL_ID + "-composite"); - if (compositeEl) { - const neutral = longCount === shortCount; - const biasLong = longCount > shortCount; - const biasLabel = neutral ? "中性" : biasLong ? "偏多" : "偏空"; - const biasColor = neutral ? C.neutral : biasLong ? C.long : C.short; - const longPct = total > 0 ? Math.round(longCount / total * 100) : 0; - const shortPct = total > 0 ? Math.round(shortCount / total * 100) : 0; - compositeEl.innerHTML = [ - '
', - '复合信号', - '', - biasLabel, - " ", - longCount, - ":", - shortCount, - "", - "
", - '
', - '
', - '
', - '
', - "
" - ].join(""); - } - const footerEl = panel.querySelector("#" + PANEL_ID + "-footer"); - if (footerEl) { - lastUpdateTs = Date.now(); - updateFooter(footerEl); - if (!agoTimer && !document.hidden) { - agoTimer = setInterval(function() { - const el = document.querySelector("#" + PANEL_ID + "-footer"); - if (el && lastUpdateTs) updateFooter(el); - }, 1e3); - } - } + panelView.setLocale(uiLocale()); + panelView.render(result); + panelView.setFunding(currentFundingState, fundingClock()); + const footer = panel.querySelector("#" + PANEL_ID2 + "-footer"); + if (footer) updateFooter(footer); + } + function startDisplayClock() { + if (agoTimer) return; + agoTimer = setInterval(function() { + if (!isActiveTradingPage() || !panelView) return; + const footer = document.getElementById(PANEL_ID2 + "-footer"); + if (footer) updateFooter(footer); + panelView.updateClock(fundingClock()); + }, 1e3); } function updateFooter(el) { - const d = new Date(lastUpdateTs); - const hh = String(d.getHours()).padStart(2, "0"); - const mm = String(d.getMinutes()).padStart(2, "0"); - const ss = String(d.getSeconds()).padStart(2, "0"); - const ago = Math.floor((Date.now() - lastUpdateTs) / 1e3); - const updatedText = "更新于 " + hh + ":" + mm + ":" + ss; - const elapsedText = ago + "秒前"; + const locale = uiLocale(); + let updatedText = locale === "zh-CN" ? "等待数据" : "Waiting for data"; + let elapsedText = ""; + if (lastUpdateTs) { + const clock = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(lastUpdateTs); + const ago = Math.max(0, Math.floor((Date.now() - lastUpdateTs) / 1e3)); + updatedText = (locale === "zh-CN" ? "更新于 " : "Updated ") + clock; + elapsedText = locale === "zh-CN" ? ago + "秒前" : ago + "s ago"; + } const updated = el.querySelector('[data-role="updated-at"]'); const elapsed = el.querySelector('[data-role="elapsed"]'); if (updated.textContent !== updatedText) updated.textContent = updatedText; if (elapsed.textContent !== elapsedText) elapsed.textContent = elapsedText; } + let currentFundingTimer = null; + let currentFundingRequest = null; + let fundingIntervalRequest = null; + let currentFundingState = emptyCurrentFundingState(null); + function emptyCurrentFundingState(symbol) { + const interval = symbol ? dataCache[symbol]?.fundingInterval : null; + return { + current: null, + intervalHours: interval ?? null, + receivedAt: null, + cached: false, + error: null, + intervalCached: interval !== void 0 && interval !== null, + intervalError: null + }; + } + async function refreshCurrentFunding(session) { + if (!sessionIsCurrent(session) || currentFundingRequest) return; + const request = { controller: new AbortController(), timeout: null }; + currentFundingRequest = request; + request.timeout = setTimeout(function() { + request.controller.abort(new DOMException("Funding request timed out", "TimeoutError")); + }, FUNDING_REQUEST_TIMEOUT_MS); + try { + const payload = await fetchJson(API_PATHS.currentFunding, { symbol: session.symbol }, request.controller.signal); + const current = parseCurrentFunding(payload, session.symbol); + if (!sessionIsCurrent(session) || currentFundingRequest !== request) return; + currentFundingState = { ...currentFundingState, current, receivedAt: Date.now(), cached: false, error: null }; + } catch (error) { + if (!sessionIsCurrent(session) || currentFundingRequest !== request) return; + currentFundingState = { ...currentFundingState, cached: currentFundingState.current !== null, error: String(error.message || error) }; + err("Current funding request failed:", error.message); + } finally { + clearTimeout(request.timeout); + if (sessionIsCurrent(session) && currentFundingRequest === request) { + currentFundingRequest = null; + panelView.setFunding(currentFundingState, fundingClock()); + currentFundingTimer = setTimeout(function() { + refreshCurrentFunding(session); + }, CURRENT_FUNDING_REFRESH_MS); + } + } + } + async function refreshFundingInterval(session) { + if (!sessionIsCurrent(session) || fundingIntervalRequest) return; + const request = { controller: new AbortController(), timeout: null }; + fundingIntervalRequest = request; + request.timeout = setTimeout(function() { + request.controller.abort(new DOMException("Funding interval request timed out", "TimeoutError")); + }, FUNDING_REQUEST_TIMEOUT_MS); + try { + const payload = await fetchJson(API_PATHS.fundingInterval, {}, request.controller.signal); + const intervalHours = parseFundingInterval(payload, session.symbol); + if (!sessionIsCurrent(session) || fundingIntervalRequest !== request) return; + if (!dataCache[session.symbol]) dataCache[session.symbol] = {}; + dataCache[session.symbol].fundingInterval = intervalHours; + currentFundingState = { ...currentFundingState, intervalHours, intervalCached: false, intervalError: null }; + } catch (error) { + if (!sessionIsCurrent(session) || fundingIntervalRequest !== request) return; + const intervalHours = dataCache[session.symbol]?.fundingInterval ?? null; + currentFundingState = { ...currentFundingState, intervalHours, intervalCached: intervalHours !== null, intervalError: String(error.message || error) }; + err("Funding interval request failed:", error.message); + } finally { + clearTimeout(request.timeout); + if (sessionIsCurrent(session) && fundingIntervalRequest === request) { + fundingIntervalRequest = null; + panelView.setFunding(currentFundingState, fundingClock()); + } + } + } function setupDrag(panel) { - const header = panel.querySelector("#" + PANEL_ID + "-header"); + const header = panel.querySelector("#" + PANEL_ID2 + "-header"); if (!header) return null; let dragging = false, startX, startY, startLeft, startTop; const cancelDrag = function() { @@ -653,6 +1582,7 @@ document.addEventListener("mouseup", onMouseUp); window.addEventListener("blur", cancelDrag); window.addEventListener("resize", onResize); + window.addEventListener(DATA_PANEL_LAYOUT_EVENT, onResize); return function cleanupDrag() { dragging = false; header.removeEventListener("mousedown", onMouseDown); @@ -660,28 +1590,9 @@ document.removeEventListener("mouseup", onMouseUp); window.removeEventListener("blur", cancelDrag); window.removeEventListener("resize", onResize); + window.removeEventListener(DATA_PANEL_LAYOUT_EVENT, onResize); }; } - function setupCollapseAndClose(panel) { - const collapseBtn = panel.querySelector("#" + PANEL_ID + "-collapse"); - const closeBtn = panel.querySelector("#" + PANEL_ID + "-close"); - const body = panel.querySelector("#" + PANEL_ID + "-body"); - if (collapseBtn && body) { - collapseBtn.addEventListener("click", function() { - const isHidden = body.style.display === "none"; - body.style.display = isHidden ? "block" : "none"; - collapseBtn.innerHTML = isHidden ? "_" : "□"; - saveCollapsed(!isHidden); - }); - } - if (closeBtn) { - closeBtn.addEventListener("click", function() { - panel.style.display = "none"; - panelClosed = true; - stopLoop(); - }); - } - } function clampNumber(value, min, max) { return Math.max(min, Math.min(value, max)); } @@ -696,9 +1607,17 @@ } function keepPanelInViewport(panel) { const width = panel.offsetWidth || PANEL_WIDTH; - const normalized = normalizeSavedPosition(loadPosition(), width) || normalizeSavedPosition({ left: window.innerWidth - width - 16, top: 60 }, width); + const normalized = calculateDataPanelLayout({ + kind: "trading", + panelWidth: width, + viewportWidth: window.innerWidth || document.documentElement.clientWidth || width, + viewportHeight: window.innerHeight || document.documentElement.clientHeight || 80, + savedPosition: normalizeSavedPosition(loadPosition(), width), + hasPeer: hasVisibleDataPanelPeer(document, "trading") + }); panel.style.left = normalized.left + "px"; panel.style.top = normalized.top + "px"; + panel.style.maxHeight = normalized.maxHeight + "px"; panel.style.right = "auto"; } function savePanelPosition(panel) { @@ -707,6 +1626,7 @@ const normalized = normalizeSavedPosition({ left: rect.left, top: rect.top }, panel.offsetWidth || PANEL_WIDTH); if (!normalized) return; savePosition(normalized.left, normalized.top); + keepPanelInViewport(panel); } function loadPosition() { try { @@ -743,7 +1663,7 @@ function renderAll(symbol) { if (!isActiveTradingPage() || getCurrentSymbol() !== symbol) return; var data = dataStore[symbol] || {}; - var result = computeSignals(data, failedKeys); + var result = computeTradingSignals(data, failedKeys, uiLocale(), symbol, endpointErrors); renderPanel(result, symbol); } async function initialFetch(symbol) { @@ -753,16 +1673,16 @@ clearTimeout(retryTimer); if (symbol !== lastSymbol) { lastSymbol = symbol; - failedKeys = /* @__PURE__ */ new Set(); - prevDisplayValues = {}; log("交易对:", symbol); } fetching = myEpoch; try { - var [periodEntries, fundingEntry] = await Promise.all([ + var history = Promise.all([ fetchPeriodData(symbol, PERIOD_KEYS), fetchFundingRateData(symbol) ]); + refreshFundingInterval(currentSession()); + var [periodEntries, fundingEntry] = await history; if (epoch !== myEpoch || !isActiveTradingPage() || getCurrentSymbol() !== symbol) return; applyResults(symbol, periodEntries, fundingEntry); renderAll(symbol); @@ -818,7 +1738,6 @@ if (symbol !== lastSymbol) { lastSymbol = symbol; failedKeys = /* @__PURE__ */ new Set(); - prevDisplayValues = {}; log("交易对:", symbol); } var targetTs = boundary; @@ -827,10 +1746,12 @@ try { var periodEntries, fundingEntry; if (attempt === 0) { - [periodEntries, fundingEntry] = await Promise.all([ + var history = Promise.all([ fetchPeriodData(symbol, PERIOD_KEYS), fetchFundingRateData(symbol) ]); + refreshFundingInterval(currentSession()); + [periodEntries, fundingEntry] = await history; } else { var pending = getPendingKeys(symbol, targetTs); if (pending.length === 0) { @@ -882,11 +1803,29 @@ clearInterval(serverTimeTimer); serverTimeTimer = null; } + if (serverTimeRequest) { + clearTimeout(serverTimeRequest.timeout); + serverTimeRequest.controller.abort(); + serverTimeRequest = null; + } + clearTimeout(currentFundingTimer); + currentFundingTimer = null; + if (currentFundingRequest) { + clearTimeout(currentFundingRequest.timeout); + currentFundingRequest.controller.abort(); + currentFundingRequest = null; + } + if (fundingIntervalRequest) { + clearTimeout(fundingIntervalRequest.timeout); + fundingIntervalRequest.controller.abort(); + fundingIntervalRequest = null; + } + clockCalibrated = false; } function startServerTimeLoop() { if (serverTimeTimer) return; serverTimeTimer = setInterval(function() { - if (isActiveTradingPage()) syncServerTime(); + if (isActiveTradingPage()) syncServerTime(currentSession()); }, 60 * 60 * 1e3); } function stopRouteWatcher() { @@ -900,6 +1839,7 @@ } } function stopLoop() { + sessionGeneration++; epoch++; stopBusinessLoop(); stopRouteWatcher(); @@ -911,10 +1851,16 @@ } function removePanel() { cleanupPanelDrag(); - var panel = document.getElementById(PANEL_ID); - if (panel) panel.remove(); + if (panelView) panelView.destroy(); + panelView = null; + var panel = document.getElementById(PANEL_ID2); + if (panel) { + panel.remove(); + window.dispatchEvent(new CustomEvent(DATA_PANEL_LAYOUT_EVENT)); + } } function pauseForNonTradingPage() { + sessionGeneration++; epoch++; stopBusinessLoop(); lastSymbol = null; @@ -922,14 +1868,26 @@ } async function activateTradingPage() { if (!isActiveTradingPage()) return; - await syncServerTime(); - if (!isActiveTradingPage()) return; + sessionGeneration++; + epoch++; + stopBusinessLoop(); + fetching = 0; + const session = currentSession(); + const symbol = session.symbol; + if (!symbol) return; + failedKeys = /* @__PURE__ */ new Set([...PERIOD_KEYS, "fundingRate"]); + endpointErrors = {}; + currentFundingState = emptyCurrentFundingState(symbol); + lastUpdateTs = historyUpdatedAt[symbol] || 0; ensurePanel(); + renderAll(symbol); + await syncServerTime(session); + if (!sessionIsCurrent(session)) return; startServerTimeLoop(); - var symbol = getCurrentSymbol(); - if (!symbol) return; + startDisplayClock(); + refreshCurrentFunding(session); await initialFetch(symbol); - if (!isActiveTradingPage() || getCurrentSymbol() !== symbol) return; + if (!sessionIsCurrent(session)) return; scheduleCycle(); } function handlePathChange() { diff --git a/src/binance-coinmarketcap-data/index.user.js b/src/binance-coinmarketcap-data/index.user.js index ba34aad0..21a8ba0b 100644 --- a/src/binance-coinmarketcap-data/index.user.js +++ b/src/binance-coinmarketcap-data/index.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/* @@ -27,6 +27,10 @@ import { ensureSpaRouteChangePatched, installSpaRouteChangeListener, } from '../shared/spa-route-change.js'; +import { buildCmcMetricRows, numberOrNull } from './metrics.js'; +import { CMC_COPY, formatLocalizedText, resolveUiLocaleFromPathname } from './ui-copy.js'; +import { cmcPanelStyles } from './panel-styles.js'; +import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, hasVisibleDataPanelPeer } from '../shared/data-panel-layout.js'; (function () { 'use strict'; @@ -38,10 +42,9 @@ import { 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 * 1000; const ROUTE_WATCHDOG_MS = 5_000; - 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'; @@ -50,16 +53,6 @@ import { 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; @@ -71,8 +64,13 @@ import { let refreshEpoch = 0; let lastRowsHtml = ''; let lastPath = location.pathname; + let expandedMetricId = null; const assetCache = Object.create(null); + function uiText(value) { + return formatLocalizedText(value, resolveUiLocaleFromPathname(location.pathname)); + } + function getCurrentSymbol() { return parseFuturesTradingSymbolFromPathname(location.pathname); } @@ -154,59 +152,8 @@ import { } 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) { @@ -319,119 +266,9 @@ import { 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 percentage = numberOrNull(value.percentage); - return percentage !== null ? percentage.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); } @@ -447,7 +284,7 @@ import { 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); @@ -480,80 +317,81 @@ import { 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 = [ - '
', - '
', - '☰', - 'CMC 数据', - '', + '', + '
', + '
', + '', + '', + '', '
', - '
', - '', - '', - '', + '
', + '', + '', + '', '
', '
', '
', - '
', - '', + '', + '', + '', + '', + '
', + '', '
', ].join(''); - document.body.appendChild(panel); + updatePanelLanguage(panel); keepPanelInViewport(panel); cleanupPanelDrag(); dragCleanup = setupDrag(panel); setupControls(panel); + window.dispatchEvent(new CustomEvent(DATA_PANEL_LAYOUT_EVENT)); return panel; } + function updatePanelLanguage(panel) { + panel.lang = resolveUiLocaleFromPathname(location.pathname); + panel.setAttribute('aria-label', uiText(CMC_COPY.title)); + panel.querySelector('table').setAttribute('aria-label', uiText(CMC_COPY.title)); + panel.querySelectorAll('[data-copy]').forEach(function (element) { + element.textContent = uiText(CMC_COPY[element.dataset.copy]); + }); + const collapsed = panel.querySelector('#' + PANEL_ID + '-body').style.display === 'none'; + for (const [suffix, copy] of [ + ['refresh', CMC_COPY.refresh], + ['collapse', collapsed ? CMC_COPY.expand : CMC_COPY.collapse], + ['close', CMC_COPY.close], + ]) { + const button = panel.querySelector('#' + PANEL_ID + '-' + suffix); + button.title = uiText(copy); + button.setAttribute('aria-label', uiText(copy)); + } + panel.querySelector('#' + PANEL_ID + '-collapse').setAttribute('aria-expanded', String(!collapsed)); + } + function renderLoading(symbol) { const panel = ensurePanel(); const symbolEl = panel.querySelector('#' + PANEL_ID + '-symbol'); const rowsEl = panel.querySelector('#' + PANEL_ID + '-rows'); const footerEl = panel.querySelector('#' + PANEL_ID + '-footer'); if (symbolEl) symbolEl.textContent = symbol || ''; - if (rowsEl) rowsEl.innerHTML = '
正在读取 CoinMarketCap...
'; + if (rowsEl) rowsEl.innerHTML = '
' + escapeHtml(uiText(CMC_COPY.loading)) + '
'; if (footerEl) footerEl.textContent = ''; } @@ -565,12 +403,14 @@ import { if (symbolEl) symbolEl.textContent = symbol || ''; if (rowsEl) { lastRowsHtml = [ - '
读取失败
', - '
', escapeHtml(message), '
', + '
', + '
', escapeHtml(uiText(CMC_COPY.failed)), '
', + '
', escapeHtml(message), '
', + '
', ].join(''); rowsEl.innerHTML = lastRowsHtml; } - if (footerEl) footerEl.textContent = '来源:CoinMarketCap 中文页'; + if (footerEl) footerEl.textContent = uiText(CMC_COPY.source); } function renderData(symbol, data) { @@ -579,24 +419,29 @@ import { const rowsEl = panel.querySelector('#' + PANEL_ID + '-rows'); const footerEl = panel.querySelector('#' + PANEL_ID + '-footer'); if (symbolEl) { - const rank = Number.isFinite(Number(data.rank)) ? ' #' + data.rank : ''; + const rank = numberOrNull(data.rank) !== null ? ' #' + data.rank : ''; symbolEl.textContent = (data.symbol || symbol || '') + rank; } if (rowsEl) { lastRowsHtml = data.rows.map(function (row) { + const changeTone = row.change && row.change.startsWith('-') ? 'negative' + : row.change && row.change.startsWith('+') ? 'positive' : 'neutral'; const change = row.change && row.change !== '--' - ? '' + escapeHtml(row.change) + '' + ? '' + escapeHtml(row.change) + ' · ' + escapeHtml(uiText(CMC_COPY.changePeriod)) + '' : ''; - const cardStyle = row.highlight - ? 'background:linear-gradient(180deg, rgba(56,97,251,.055), rgba(22,199,132,.045));box-shadow:inset 0 0 0 1.5px rgba(56,97,251,.62);border-radius:6px;padding:5px 6px;margin:2px -6px;' - : 'padding:4px 0;'; + const detailId = PANEL_ID + '-explanation-' + row.id; + const expanded = expandedMetricId === row.id; return [ - '
', - '', escapeHtml(row.label), '', - '', - escapeHtml(row.value), change, - '', - '
', + '', + '', escapeHtml(row.label), row.qualifier ? '' + escapeHtml(row.qualifier) + '' : '', '', + '', escapeHtml(row.value), '', + row.unit ? '' + escapeHtml(row.unit) + '' : '', change, + '', + '', escapeHtml(row.note), '', + '', + '', escapeHtml(row.label), '', escapeHtml(row.explanation), '', ].join(''); }).join(''); rowsEl.innerHTML = lastRowsHtml; @@ -604,12 +449,13 @@ import { if (footerEl) { lastUpdateTs = Date.now(); const cmcClock = data.lastUpdated ? formatClock(Date.parse(data.lastUpdated)) : '--'; - const sourceLabel = data.source === 'page-snapshot' ? 'CMC 页面快照' : 'CMC data-api'; + const sourceLabel = uiText(data.source === 'page-snapshot' ? CMC_COPY.page : CMC_COPY.api); footerEl.innerHTML = [ - '
', - '', sourceLabel, '', - 'CMC ', cmcClock, ' / 拉取 ', formatClock(lastUpdateTs), '', + '
', + '', escapeHtml(sourceLabel), '', + 'CMC ', cmcClock, ' / ', escapeHtml(uiText(CMC_COPY.fetched)), ' ', formatClock(lastUpdateTs), '', '
', + '', escapeHtml(uiText(CMC_COPY.refreshPeriod)), '', ].join(''); } } @@ -681,8 +527,12 @@ import { function removePanel() { cleanupPanelDrag(); const panel = document.getElementById(PANEL_ID); - if (panel) panel.remove(); + if (panel) { + panel.remove(); + window.dispatchEvent(new CustomEvent(DATA_PANEL_LAYOUT_EVENT)); + } lastRowsHtml = ''; + expandedMetricId = null; } function pauseForNonTradingPage() { @@ -756,6 +606,7 @@ import { const newTop = Math.max(0, Math.min(startTop + event.clientY - startY, window.innerHeight - panel.offsetHeight)); panel.style.left = newLeft + 'px'; panel.style.top = newTop + 'px'; + panel.style.setProperty('--cmc-panel-top', newTop + 'px'); panel.style.right = 'auto'; }; @@ -771,6 +622,7 @@ import { document.addEventListener('mouseup', onMouseUp); window.addEventListener('blur', cancelDrag); window.addEventListener('resize', onResize); + window.addEventListener(DATA_PANEL_LAYOUT_EVENT, onResize); return function cleanupDrag() { dragging = false; @@ -779,6 +631,7 @@ import { document.removeEventListener('mouseup', onMouseUp); window.removeEventListener('blur', cancelDrag); window.removeEventListener('resize', onResize); + window.removeEventListener(DATA_PANEL_LAYOUT_EVENT, onResize); }; } @@ -787,6 +640,18 @@ import { const collapseBtn = panel.querySelector('#' + PANEL_ID + '-collapse'); const closeBtn = panel.querySelector('#' + PANEL_ID + '-close'); const body = panel.querySelector('#' + PANEL_ID + '-body'); + const rows = panel.querySelector('#' + PANEL_ID + '-rows'); + + rows.addEventListener('click', function (event) { + const button = event.target.closest('[data-expand-metric]'); + if (!button) return; + expandedMetricId = expandedMetricId === button.dataset.expandMetric ? null : button.dataset.expandMetric; + rows.querySelectorAll('[data-expand-metric]').forEach(function (control) { + const expanded = control.dataset.expandMetric === expandedMetricId; + control.setAttribute('aria-expanded', String(expanded)); + panel.querySelector('#' + control.getAttribute('aria-controls')).hidden = !expanded; + }); + }); if (refreshBtn) { refreshBtn.addEventListener('click', function () { @@ -799,6 +664,7 @@ import { body.style.display = isHidden ? 'block' : 'none'; collapseBtn.innerHTML = isHidden ? '_' : '□'; saveCollapsed(!isHidden); + updatePanelLanguage(panel); }); } if (closeBtn) { @@ -806,6 +672,8 @@ import { panel.style.display = 'none'; panelClosed = true; stopLoop(); + cleanupPanelDrag(); + window.dispatchEvent(new CustomEvent(DATA_PANEL_LAYOUT_EVENT)); }); } } @@ -827,10 +695,17 @@ import { /** Project the saved preference without letting smaller windows overwrite it. */ function keepPanelInViewport(panel) { const width = panel.offsetWidth || PANEL_WIDTH; - const normalized = normalizeSavedPosition(loadPosition(), width) - || normalizeSavedPosition({ left: window.innerWidth - width - 16, top: 360 }, width); + const normalized = calculateDataPanelLayout({ + kind: 'cmc', panelWidth: width, + viewportWidth: window.innerWidth || document.documentElement.clientWidth || width, + viewportHeight: window.innerHeight || document.documentElement.clientHeight || 80, + savedPosition: normalizeSavedPosition(loadPosition(), width), + hasPeer: hasVisibleDataPanelPeer(document, 'cmc'), + }); panel.style.left = normalized.left + 'px'; panel.style.top = normalized.top + 'px'; + panel.style.maxHeight = normalized.maxHeight + 'px'; + panel.style.setProperty('--cmc-panel-top', normalized.top + 'px'); panel.style.right = 'auto'; } @@ -840,6 +715,7 @@ import { const normalized = normalizeSavedPosition({ left: rect.left, top: rect.top }, panel.offsetWidth || PANEL_WIDTH); if (!normalized) return; savePosition(normalized.left, normalized.top); + keepPanelInViewport(panel); } function loadPosition() { diff --git a/src/binance-coinmarketcap-data/metrics.js b/src/binance-coinmarketcap-data/metrics.js new file mode 100644 index 00000000..85a14c28 --- /dev/null +++ b/src/binance-coinmarketcap-data/metrics.js @@ -0,0 +1,173 @@ +import { CMC_COPY, CMC_METRIC_COPY, localizedText, formatLocalizedText } from './ui-copy.js'; + +/** CMC numeric fields may be numbers or numeric strings; missing and invalid values stay missing. */ +export 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; + // Small token prices need significant digits rather than a fixed decimal ceiling. + 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)}%`; +} + +/** Ratios do not invent comparable supply or valuation denominators. */ +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) + : undefined; + return { id: 'holders', number: count === undefined ? null : count }; +} + +/** Build only presentation data; preserve CMC-reported ratios and their source definitions. */ +export 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] === undefined ? 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, + ]; +} diff --git a/src/binance-coinmarketcap-data/panel-styles.js b/src/binance-coinmarketcap-data/panel-styles.js new file mode 100644 index 00000000..80b28785 --- /dev/null +++ b/src/binance-coinmarketcap-data/panel-styles.js @@ -0,0 +1,96 @@ +export 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; } + } + `; +} diff --git a/src/binance-coinmarketcap-data/ui-copy.js b/src/binance-coinmarketcap-data/ui-copy.js new file mode 100644 index 00000000..18dc0246 --- /dev/null +++ b/src/binance-coinmarketcap-data/ui-copy.js @@ -0,0 +1,154 @@ +import { + localizedText, formatLocalizedText, resolveUiLocaleFromPathname, +} from '../binance-orderbook-trade/contracts/panel-copy.js'; + +export { localizedText, formatLocalizedText, resolveUiLocaleFromPathname }; + +export const 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'), +}); + +export const 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.', + ), + }, +}); diff --git a/src/binance-trading-data/index.user.js b/src/binance-trading-data/index.user.js index 13d53851..8d5fd054 100644 --- a/src/binance-trading-data/index.user.js +++ b/src/binance-trading-data/index.user.js @@ -3,9 +3,9 @@ // @namespace binance.trading.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 1.1.18 +// @version 1.2.0 // @author jackhai9 -// @description 在合约交易页面叠加浮动面板,定时拉取交易数据(持仓量、多空比、资金费率等)并显示当前值 + 多空信号 +// @description Bilingual futures metrics with historical trends, current funding, settlement countdown, and indicator signals. // @match https://www.binance.com/*/futures/* // @match https://www.binance.com/futures/* // @exclude https://www.binance.com/*/my/wallet/futures/* @@ -24,6 +24,10 @@ import { ensureSpaRouteChangePatched, installSpaRouteChangeListener, } from '../shared/spa-route-change.js'; +import { resolveUiLocaleFromPathname } from '../binance-orderbook-trade/contracts/panel-copy.js'; +import { computeTradingSignals, parseCurrentFunding, parseFundingInterval, parseHistory } from './market-data.js'; +import { createTradingDataView } from './panel-view.js'; +import { calculateDataPanelLayout, DATA_PANEL_LAYOUT_EVENT, hasVisibleDataPanelPeer } from '../shared/data-panel-layout.js'; (function () { 'use strict'; @@ -38,7 +42,7 @@ import { const PANEL_ID = 'jh-binance-trading-data-panel'; const STORAGE_POS_KEY = 'jh_binance_trading_data_pos'; const STORAGE_COLLAPSED_KEY = 'jh_binance_trading_data_collapsed'; - const PANEL_WIDTH = 240; + const PANEL_WIDTH = 480; const DEBUG = false; const PERIOD_MS = 5 * 60 * 1000; // 数据周期 5 分钟 @@ -48,8 +52,11 @@ import { const ROUTE_WATCHDOG_MS = 5_000; const DEFAULT_PERIOD = '5m'; const DATA_LIMIT = 30; - const OI_TREND_PERIODS = 6; - const FUNDING_RATE_THRESHOLD = 0.0001; // 0.01% + const FUNDING_HISTORY_LIMIT = 40; + const CURRENT_FUNDING_REFRESH_MS = 15_000; + const FUNDING_REQUEST_TIMEOUT_MS = 10_000; + const CLOCK_REQUEST_TIMEOUT_MS = 5_000; + const CLOCK_MAX_ROUND_TRIP_MS = 2_000; const API_BASE = 'https://www.binance.com'; const API_PATHS = { @@ -60,6 +67,8 @@ import { takerRatio: '/futures/data/takerlongshortRatio', basis: '/futures/data/basis', fundingRate: '/fapi/v1/fundingRate', + currentFunding: '/fapi/v1/premiumIndex', + fundingInterval: '/fapi/v1/fundingInfo', serverTime: '/fapi/v1/time', }; @@ -90,20 +99,21 @@ import { /* ========== API 层 ========== */ - async function fetchJson(path, params) { + async function fetchJson(path, params, signal) { const url = new URL(path, API_BASE); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); const href = url.toString(); try { - const resp = await fetch(href); + const resp = await fetch(href, { signal }); if (!resp.ok) throw Object.assign(new Error(`HTTP ${resp.status}`), { status: resp.status }); return await resp.json(); } catch (e1) { + if (signal?.aborted) throw e1; // 4xx 是确定性失败(参数错误、限流),不重试 if (e1.status && e1.status >= 400 && e1.status < 500) throw e1; // 网络错误或 5xx,重试一次 log('重试:', path); - const resp = await fetch(href); + const resp = await fetch(href, { signal }); if (!resp.ok) throw new Error(`HTTP ${resp.status} (retry)`); return await resp.json(); } @@ -128,7 +138,7 @@ import { return fetchJson(API_PATHS.basis, { pair: symbol, period: DEFAULT_PERIOD, limit: DATA_LIMIT, contractType: 'PERPETUAL' }); } function fetchFundingRate(symbol) { - return fetchJson(API_PATHS.fundingRate, { symbol, limit: 1 }); + return fetchJson(API_PATHS.fundingRate, { symbol, limit: FUNDING_HISTORY_LIMIT }); } // key -> fetcher 映射 @@ -144,17 +154,51 @@ import { /* ========== 服务器时间 ========== */ let serverOffset = 0; // serverTime - localTime - - async function syncServerTime() { + let clockCalibrated = false; + let sessionGeneration = 0; + let clockRequestId = 0; + let serverTimeRequest = null; + + function currentSession() { + return { generation: sessionGeneration, path: location.pathname, symbol: getCurrentSymbol() }; + } + + function sessionIsCurrent(session) { + return session.generation === sessionGeneration + && session.path === location.pathname + && session.symbol === getCurrentSymbol() + && isActiveTradingPage(); + } + + async function syncServerTime(session) { + const requestId = ++clockRequestId; + const startedAt = Date.now(); + const request = { controller: new AbortController(), timeout: null }; + serverTimeRequest = request; + request.timeout = setTimeout(function () { + request.controller.abort(new DOMException('Server time request timed out', 'TimeoutError')); + }, CLOCK_REQUEST_TIMEOUT_MS); try { - const resp = await fetch(API_BASE + API_PATHS.serverTime); + const resp = await fetch(API_BASE + API_PATHS.serverTime, { signal: request.controller.signal }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const json = await resp.json(); - serverOffset = json.serverTime - Date.now(); + if (!Number.isSafeInteger(json.serverTime) || json.serverTime <= 0) throw new TypeError('Invalid server time'); + if (!sessionIsCurrent(session) || requestId !== clockRequestId) return; + const receivedAt = Date.now(); + const roundTrip = receivedAt - startedAt; + if (roundTrip < 0 || roundTrip > CLOCK_MAX_ROUND_TRIP_MS) throw new Error('Server time round trip exceeded the calibration limit'); + // The midpoint bounds network transit error; slow samples cannot certify a countdown. + serverOffset = json.serverTime - (startedAt + receivedAt) / 2; + clockCalibrated = true; log('服务器时间偏移:', serverOffset + 'ms'); } catch (e) { - err('获取服务器时间失败,使用本地时间'); + if (!sessionIsCurrent(session) || requestId !== clockRequestId) return; + err('获取服务器时间失败,使用本地时间', e.message); serverOffset = 0; + clockCalibrated = false; + } finally { + clearTimeout(request.timeout); + if (serverTimeRequest === request) serverTimeRequest = null; } } @@ -167,6 +211,8 @@ import { let dataStore = {}; // symbol -> { key: responseData } 当前展示用 let dataCache = {}; // symbol -> { key: responseData } 失败回退用 let failedKeys = new Set(); // 当前使用回退缓存的 key + let endpointErrors = {}; + const historyUpdatedAt = {}; // 提取接口返回的最新数据点时间戳 function extractEndpointTs(data) { @@ -177,21 +223,21 @@ import { // 纯函数:拉取指定 5m 接口,返回结果但不写全局状态 async function fetchPeriodData(symbol, keys) { if (!keys || keys.length === 0) return {}; - var fetchers = keys.map(function (k) { return FETCHER_MAP[k](symbol); }); + var fetchers = keys.map(async function (k) { return parseHistory(k, await FETCHER_MAP[k](symbol), symbol); }); var results = await Promise.allSettled(fetchers); var backup = dataCache[symbol] || {}; var entries = {}; keys.forEach(function (key, i) { if (results[i].status === 'fulfilled') { - entries[key] = { data: results[i].value, cached: false }; + entries[key] = { data: results[i].value, cached: false, error: null }; } else { err(key + ' 请求失败:', results[i].reason?.message || results[i].reason); if (backup[key]) { - entries[key] = { data: backup[key], cached: true }; + entries[key] = { data: backup[key], cached: true, error: String(results[i].reason?.message || results[i].reason) }; log(key + ' 使用缓存数据'); } else { - entries[key] = { data: null, cached: true }; + entries[key] = { data: null, cached: true, error: String(results[i].reason?.message || results[i].reason) }; } } }); @@ -202,11 +248,11 @@ import { async function fetchFundingRateData(symbol) { var backup = dataCache[symbol] || {}; try { - var data = await fetchFundingRate(symbol); - return { data: data, cached: false }; + var data = parseHistory('fundingRate', await fetchFundingRate(symbol), symbol); + return { data: data, cached: false, error: null }; } catch (e) { err('fundingRate 请求失败:', e); - return { data: backup.fundingRate || null, cached: true }; + return { data: backup.fundingRate || null, cached: true, error: String(e.message || e) }; } } @@ -219,6 +265,7 @@ import { for (var key in periodEntries) { var e = periodEntries[key]; dataStore[symbol][key] = e.data; + endpointErrors[key] = e.error; if (!e.cached) { dataCache[symbol][key] = e.data; failedKeys.delete(key); @@ -230,6 +277,7 @@ import { if (fundingEntry) { dataStore[symbol].fundingRate = fundingEntry.data; + endpointErrors.fundingRate = fundingEntry.error; if (!fundingEntry.cached) { dataCache[symbol].fundingRate = fundingEntry.data; failedKeys.delete('fundingRate'); @@ -237,6 +285,10 @@ import { failedKeys.add('fundingRate'); } } + const freshHistory = (periodEntries && Object.values(periodEntries).some(entry => !entry.cached)) + || (fundingEntry && !fundingEntry.cached); + if (freshHistory) historyUpdatedAt[symbol] = Date.now(); + lastUpdateTs = historyUpdatedAt[symbol] || 0; } // 哪些 5m 接口的最新数据时间戳还没到 targetTs @@ -247,340 +299,155 @@ import { }); } - /* ========== 数据处理 ========== */ - - function parseOpenInterest(data) { - if (!Array.isArray(data) || data.length === 0) return null; - const latest = data[data.length - 1]; - const value = parseFloat(latest.sumOpenInterest); - const valueUsd = parseFloat(latest.sumOpenInterestValue); - let trend = null; - if (data.length > OI_TREND_PERIODS) { - const prev = parseFloat(data[data.length - 1 - OI_TREND_PERIODS].sumOpenInterest); - trend = value > prev ? 'up' : value < prev ? 'down' : 'neutral'; - } - return { value, valueUsd, trend }; - } - - function parseOIMarketCapRatio(data) { - if (!Array.isArray(data) || data.length === 0) return null; - const latest = data[data.length - 1]; - const oi = parseFloat(latest.sumOpenInterest); - const supply = parseFloat(latest.CMCCirculatingSupply); - if (!supply || supply === 0) return null; - return { value: oi / supply }; - } - - function parseRatio(data, field) { - if (!Array.isArray(data) || data.length === 0) return null; - const latest = data[data.length - 1]; - return { value: parseFloat(latest[field]) }; - } - - function parseBasis(data) { - if (!Array.isArray(data) || data.length === 0) return null; - const latest = data[data.length - 1]; - return { value: parseFloat(latest.basisRate) }; - } - - function parseFundingRate(data) { - if (!Array.isArray(data) || data.length === 0) return null; - return { value: parseFloat(data[0].fundingRate) }; - } - - /* ========== 信号计算 ========== */ + /* ========== Panel presentation ========== */ - function signalOpenInterest(parsed) { - if (!parsed || !parsed.trend) return 'neutral'; - return parsed.trend === 'up' ? 'long' : parsed.trend === 'down' ? 'short' : 'neutral'; - } - - function signalRatio(parsed) { - if (!parsed) return 'neutral'; - return parsed.value > 1 ? 'long' : parsed.value < 1 ? 'short' : 'neutral'; - } - - function signalBasis(parsed) { - if (!parsed) return 'neutral'; - return parsed.value > 0 ? 'long' : parsed.value < 0 ? 'short' : 'neutral'; - } - - function signalFundingRate(parsed) { - if (!parsed) return 'neutral'; - // 反向指标:高正费率 = 偏空,高负费率 = 偏多 - if (parsed.value < -FUNDING_RATE_THRESHOLD) return 'long'; - if (parsed.value > FUNDING_RATE_THRESHOLD) return 'short'; - return 'neutral'; - } + let panelView = null; - function computeSignals(data, cachedKeys) { - const oi = parseOpenInterest(data.openInterest); - const oiMcRatio = parseOIMarketCapRatio(data.openInterest); - const topAccount = parseRatio(data.topAccountRatio, 'longShortRatio'); - const topPosition = parseRatio(data.topPositionRatio, 'longShortRatio'); - const globalAccount = parseRatio(data.globalAccountRatio, 'longShortRatio'); - const taker = parseRatio(data.takerRatio, 'buySellRatio'); - const basis = parseBasis(data.basis); - const funding = parseFundingRate(data.fundingRate); - const c = cachedKeys || new Set(); - - const indicators = [ - { name: '合约持仓量', signal: signalOpenInterest(oi), display: fmtOI(oi), vote: true, cached: c.has('openInterest') }, - { name: '大户账户多空比', signal: signalRatio(topAccount), display: fmtRatio(topAccount), vote: true, cached: c.has('topAccountRatio') }, - { name: '大户持仓多空比', signal: signalRatio(topPosition), display: fmtRatio(topPosition), vote: true, cached: c.has('topPositionRatio') }, - { name: '多空账户数比', signal: signalRatio(globalAccount), display: fmtRatio(globalAccount), vote: true, cached: c.has('globalAccountRatio') }, - { name: '主动买卖比', signal: signalRatio(taker), display: fmtRatio(taker), vote: true, cached: c.has('takerRatio') }, - { name: '基差', signal: signalBasis(basis), display: fmtBasis(basis), vote: true, cached: c.has('basis') }, - { name: '资金费率', signal: signalFundingRate(funding), display: fmtFunding(funding), vote: true, cached: c.has('fundingRate') }, - { name: '未平仓量/市值', signal: 'neutral', display: fmtOIMarketCap(oiMcRatio), vote: false, cached: c.has('openInterest') }, - ]; - - const voters = indicators.filter(i => i.vote && !i.cached); - const total = voters.length; - const longCount = voters.filter(i => i.signal === 'long').length; - const shortCount = voters.filter(i => i.signal === 'short').length; - - return { indicators, longCount, shortCount, total }; + function uiLocale() { + return resolveUiLocaleFromPathname(location.pathname); } - /* ========== 格式化 ========== */ - - function fmtOI(parsed) { - if (!parsed) return '--'; - const v = parsed.value; - const arrow = parsed.trend === 'up' ? ' ▲' : parsed.trend === 'down' ? ' ▼' : ''; - if (v >= 1e9) return (v / 1e9).toFixed(2) + 'B' + arrow; - if (v >= 1e6) return (v / 1e6).toFixed(2) + 'M' + arrow; - if (v >= 1e3) return (v / 1e3).toFixed(0) + 'K' + arrow; - return v.toFixed(2) + arrow; + function fundingClock() { + return { now: serverNow(), calibrated: clockCalibrated, localNow: Date.now() }; } - function fmtRatio(parsed) { - if (!parsed) return '--'; - return parsed.value.toFixed(4); - } - - function fmtBasis(parsed) { - if (!parsed) return '--'; - const sign = parsed.value >= 0 ? '+' : ''; - return sign + (parsed.value * 100).toFixed(4) + '%'; - } - - function fmtFunding(parsed) { - if (!parsed) return '--'; - return (parsed.value * 100).toFixed(4) + '%'; - } - - function fmtOIMarketCap(parsed) { - if (!parsed) return '--'; - return (parsed.value * 100).toFixed(2) + '%'; - } - - /* ========== 闪烁样式注入 ========== */ - - const FLASH_STYLE_ID = 'jh-trading-data-flash-style'; - function injectFlashStyle() { - if (document.getElementById(FLASH_STYLE_ID)) return; - const style = document.createElement('style'); - style.id = FLASH_STYLE_ID; - style.textContent = [ - '@keyframes jh-td-flash {', - ' 0%, 100% { background: transparent; }', - ' 50% { background: rgba(240, 160, 0, 0.45); }', - '}', - '.jh-td-flash { animation: jh-td-flash 1s ease-in-out 5; }', - ].join('\n'); - (document.head || document.documentElement).appendChild(style); - } - - /* ========== 数据变化追踪 ========== */ - - let prevDisplayValues = {}; // name -> display string - - /* ========== 颜色 ========== */ - - const C = { - long: 'var(--color-Buy, #0ecb81)', - short: 'var(--color-Sell, #f6465d)', - neutral: '#76808f', - bg: '#ffffff', - text: '#1e2329', - sub: '#5e6673', - border: '#eaecef', - }; - - function signalColor(s) { - return s === 'long' ? C.long : s === 'short' ? C.short : C.neutral; - } - - /* ========== 面板 UI ========== */ - function ensurePanel() { let panel = document.getElementById(PANEL_ID); if (panel) return panel; - - injectFlashStyle(); - - panel = document.createElement('div'); + panel = document.createElement('section'); panel.id = PANEL_ID; - - const collapsed = loadCollapsed(); - - Object.assign(panel.style, { - position: 'fixed', - top: '60px', - left: 'auto', - right: '16px', - width: PANEL_WIDTH + 'px', - zIndex: '999998', - 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 = [ - // --- header --- - '
', - '
', - '☰', - '交易数据', - '', - '
', - '
', - '', - '', - '
', - '
', - // --- body --- - '
', - '
', - '
', - '', - '
', - ].join(''); - + Object.assign(panel.style, { position: 'fixed', top: '60px', right: '16px', zIndex: '999998' }); document.body.appendChild(panel); + panelView = createTradingDataView({ + document, panel, locale: uiLocale(), collapsed: loadCollapsed(), + onCollapse(collapsed) { + saveCollapsed(collapsed); + keepPanelInViewport(panel); + }, + onClose() { + panel.style.display = 'none'; + panelClosed = true; + stopLoop(); + cleanupPanelDrag(); + panelView.destroy(); + window.dispatchEvent(new CustomEvent(DATA_PANEL_LAYOUT_EVENT)); + }, + }); keepPanelInViewport(panel); cleanupPanelDrag(); dragCleanup = setupDrag(panel); - setupCollapseAndClose(panel); - + window.dispatchEvent(new CustomEvent(DATA_PANEL_LAYOUT_EVENT)); return panel; } - function renderPanel(result, symbolOverride) { - if (!isActiveTradingPage()) return; + function renderPanel(result, symbol) { + if (!isActiveTradingPage() || getCurrentSymbol() !== symbol) return; const panel = ensurePanel(); - const { indicators, longCount, shortCount, total } = result; - const symbol = symbolOverride || getCurrentSymbol(); - if (!symbol) return; + panelView.setLocale(uiLocale()); + panelView.render(result); + panelView.setFunding(currentFundingState, fundingClock()); + const footer = panel.querySelector('#' + PANEL_ID + '-footer'); + if (footer) updateFooter(footer); + } - // 检测哪些指标的值发生了变化 - const changed = {}; - for (const ind of indicators) { - if (prevDisplayValues[ind.name] !== undefined && prevDisplayValues[ind.name] !== ind.display) { - changed[ind.name] = true; - } - } - // 更新缓存 - for (const ind of indicators) { - prevDisplayValues[ind.name] = ind.display; - } + /** Funding updates and clock ticks must not refresh the historical data timestamp. */ + function startDisplayClock() { + if (agoTimer) return; + agoTimer = setInterval(function () { + if (!isActiveTradingPage() || !panelView) return; + const footer = document.getElementById(PANEL_ID + '-footer'); + if (footer) updateFooter(footer); + panelView.updateClock(fundingClock()); + }, 1000); + } - // symbol - const symbolEl = panel.querySelector('#' + PANEL_ID + '-symbol'); - if (symbolEl) symbolEl.textContent = symbol || ''; - - // rows - const rowsEl = panel.querySelector('#' + PANEL_ID + '-rows'); - if (rowsEl) { - rowsEl.innerHTML = indicators.map(function (ind) { - const valColor = ind.signal === 'long' ? C.long : ind.signal === 'short' ? C.short : C.text; - const dotColor = signalColor(ind.signal); - const dotStyle = ind.cached - ? 'display:inline-block;width:10px;height:10px;border-radius:50%;border:2px solid ' + dotColor + ';background:transparent;' - : 'display:inline-block;width:10px;height:10px;border-radius:50%;background:' + dotColor + ';'; - const flashClass = changed[ind.name] ? ' jh-td-flash' : ''; - return [ - '
', - '', ind.name, '', - '', ind.display, '', - '', - '
', - ].join(''); - }).join(''); + function updateFooter(el) { + const locale = uiLocale(); + let updatedText = locale === 'zh-CN' ? '等待数据' : 'Waiting for data'; + let elapsedText = ''; + if (lastUpdateTs) { + const clock = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(lastUpdateTs); + const ago = Math.max(0, Math.floor((Date.now() - lastUpdateTs) / 1000)); + updatedText = (locale === 'zh-CN' ? '更新于 ' : 'Updated ') + clock; + elapsedText = locale === 'zh-CN' ? ago + '秒前' : ago + 's ago'; } + const updated = el.querySelector('[data-role="updated-at"]'); + const elapsed = el.querySelector('[data-role="elapsed"]'); + if (updated.textContent !== updatedText) updated.textContent = updatedText; + if (elapsed.textContent !== elapsedText) elapsed.textContent = elapsedText; + } - // composite - const compositeEl = panel.querySelector('#' + PANEL_ID + '-composite'); - if (compositeEl) { - const neutral = longCount === shortCount; - const biasLong = longCount > shortCount; - const biasLabel = neutral ? '中性' : biasLong ? '偏多' : '偏空'; - const biasColor = neutral ? C.neutral : biasLong ? C.long : C.short; - const longPct = total > 0 ? Math.round(longCount / total * 100) : 0; - const shortPct = total > 0 ? Math.round(shortCount / total * 100) : 0; - - compositeEl.innerHTML = [ - '
', - '复合信号', - '', biasLabel, ' ', longCount, ':', shortCount, '', - '
', - '
', - '
', - '
', - '
', - '
', - ].join(''); - } + /* ========== Current funding feed ========== */ + + let currentFundingTimer = null; + let currentFundingRequest = null; + let fundingIntervalRequest = null; + let currentFundingState = emptyCurrentFundingState(null); - // footer - const footerEl = panel.querySelector('#' + PANEL_ID + '-footer'); - if (footerEl) { - lastUpdateTs = Date.now(); - updateFooter(footerEl); - if (!agoTimer && !document.hidden) { - agoTimer = setInterval(function () { - const el = document.querySelector('#' + PANEL_ID + '-footer'); - if (el && lastUpdateTs) updateFooter(el); - }, 1000); + function emptyCurrentFundingState(symbol) { + const interval = symbol ? dataCache[symbol]?.fundingInterval : null; + return { + current: null, intervalHours: interval ?? null, receivedAt: null, + cached: false, error: null, intervalCached: interval !== undefined && interval !== null, + intervalError: null, + }; + } + + async function refreshCurrentFunding(session) { + if (!sessionIsCurrent(session) || currentFundingRequest) return; + const request = { controller: new AbortController(), timeout: null }; + currentFundingRequest = request; + request.timeout = setTimeout(function () { + request.controller.abort(new DOMException('Funding request timed out', 'TimeoutError')); + }, FUNDING_REQUEST_TIMEOUT_MS); + try { + const payload = await fetchJson(API_PATHS.currentFunding, { symbol: session.symbol }, request.controller.signal); + const current = parseCurrentFunding(payload, session.symbol); + if (!sessionIsCurrent(session) || currentFundingRequest !== request) return; + currentFundingState = { ...currentFundingState, current, receivedAt: Date.now(), cached: false, error: null }; + } catch (error) { + if (!sessionIsCurrent(session) || currentFundingRequest !== request) return; + currentFundingState = { ...currentFundingState, cached: currentFundingState.current !== null, error: String(error.message || error) }; + err('Current funding request failed:', error.message); + } finally { + clearTimeout(request.timeout); + if (sessionIsCurrent(session) && currentFundingRequest === request) { + currentFundingRequest = null; + panelView.setFunding(currentFundingState, fundingClock()); + currentFundingTimer = setTimeout(function () { + refreshCurrentFunding(session); + }, CURRENT_FUNDING_REFRESH_MS); } } } - function updateFooter(el) { - const d = new Date(lastUpdateTs); - const hh = String(d.getHours()).padStart(2, '0'); - const mm = String(d.getMinutes()).padStart(2, '0'); - const ss = String(d.getSeconds()).padStart(2, '0'); - const ago = Math.floor((Date.now() - lastUpdateTs) / 1000); - const updatedText = '更新于 ' + hh + ':' + mm + ':' + ss; - const elapsedText = ago + '秒前'; - const updated = el.querySelector('[data-role="updated-at"]'); - const elapsed = el.querySelector('[data-role="elapsed"]'); - // The second timer changes text only; retained elements avoid reparsing the footer on every tick. - if (updated.textContent !== updatedText) updated.textContent = updatedText; - if (elapsed.textContent !== elapsedText) elapsed.textContent = elapsedText; + /** Optional interval metadata publishes independently and cannot delay historical data. */ + async function refreshFundingInterval(session) { + if (!sessionIsCurrent(session) || fundingIntervalRequest) return; + const request = { controller: new AbortController(), timeout: null }; + fundingIntervalRequest = request; + request.timeout = setTimeout(function () { + request.controller.abort(new DOMException('Funding interval request timed out', 'TimeoutError')); + }, FUNDING_REQUEST_TIMEOUT_MS); + try { + const payload = await fetchJson(API_PATHS.fundingInterval, {}, request.controller.signal); + const intervalHours = parseFundingInterval(payload, session.symbol); + if (!sessionIsCurrent(session) || fundingIntervalRequest !== request) return; + if (!dataCache[session.symbol]) dataCache[session.symbol] = {}; + dataCache[session.symbol].fundingInterval = intervalHours; + currentFundingState = { ...currentFundingState, intervalHours, intervalCached: false, intervalError: null }; + } catch (error) { + if (!sessionIsCurrent(session) || fundingIntervalRequest !== request) return; + const intervalHours = dataCache[session.symbol]?.fundingInterval ?? null; + currentFundingState = { ...currentFundingState, intervalHours, intervalCached: intervalHours !== null, intervalError: String(error.message || error) }; + err('Funding interval request failed:', error.message); + } finally { + clearTimeout(request.timeout); + if (sessionIsCurrent(session) && fundingIntervalRequest === request) { + fundingIntervalRequest = null; + panelView.setFunding(currentFundingState, fundingClock()); + } + } } /* ========== 拖拽 ========== */ @@ -636,6 +503,7 @@ import { document.addEventListener('mouseup', onMouseUp); window.addEventListener('blur', cancelDrag); window.addEventListener('resize', onResize); + window.addEventListener(DATA_PANEL_LAYOUT_EVENT, onResize); return function cleanupDrag() { dragging = false; @@ -644,34 +512,10 @@ import { document.removeEventListener('mouseup', onMouseUp); window.removeEventListener('blur', cancelDrag); window.removeEventListener('resize', onResize); + window.removeEventListener(DATA_PANEL_LAYOUT_EVENT, onResize); }; } - /* ========== 折叠 & 关闭 ========== */ - - function setupCollapseAndClose(panel) { - const collapseBtn = panel.querySelector('#' + PANEL_ID + '-collapse'); - const closeBtn = panel.querySelector('#' + PANEL_ID + '-close'); - const body = panel.querySelector('#' + PANEL_ID + '-body'); - - if (collapseBtn && body) { - collapseBtn.addEventListener('click', function () { - const isHidden = body.style.display === 'none'; - body.style.display = isHidden ? 'block' : 'none'; - collapseBtn.innerHTML = isHidden ? '_' : '□'; - saveCollapsed(!isHidden); - }); - } - - if (closeBtn) { - closeBtn.addEventListener('click', function () { - panel.style.display = 'none'; - panelClosed = true; - stopLoop(); - }); - } - } - function clampNumber(value, min, max) { return Math.max(min, Math.min(value, max)); } @@ -689,10 +533,16 @@ import { /** Project the saved preference without letting smaller windows overwrite it. */ function keepPanelInViewport(panel) { const width = panel.offsetWidth || PANEL_WIDTH; - const normalized = normalizeSavedPosition(loadPosition(), width) - || normalizeSavedPosition({ left: window.innerWidth - width - 16, top: 60 }, width); + const normalized = calculateDataPanelLayout({ + kind: 'trading', panelWidth: width, + viewportWidth: window.innerWidth || document.documentElement.clientWidth || width, + viewportHeight: window.innerHeight || document.documentElement.clientHeight || 80, + savedPosition: normalizeSavedPosition(loadPosition(), width), + hasPeer: hasVisibleDataPanelPeer(document, 'trading'), + }); panel.style.left = normalized.left + 'px'; panel.style.top = normalized.top + 'px'; + panel.style.maxHeight = normalized.maxHeight + 'px'; panel.style.right = 'auto'; } @@ -702,6 +552,7 @@ import { const normalized = normalizeSavedPosition({ left: rect.left, top: rect.top }, panel.offsetWidth || PANEL_WIDTH); if (!normalized) return; savePosition(normalized.left, normalized.top); + keepPanelInViewport(panel); } /* ========== localStorage ========== */ @@ -747,7 +598,7 @@ import { function renderAll(symbol) { if (!isActiveTradingPage() || getCurrentSymbol() !== symbol) return; var data = dataStore[symbol] || {}; - var result = computeSignals(data, failedKeys); + var result = computeTradingSignals(data, failedKeys, uiLocale(), symbol, endpointErrors); renderPanel(result, symbol); } @@ -761,16 +612,16 @@ import { if (symbol !== lastSymbol) { lastSymbol = symbol; - failedKeys = new Set(); - prevDisplayValues = {}; log('交易对:', symbol); } fetching = myEpoch; try { - var [periodEntries, fundingEntry] = await Promise.all([ + var history = Promise.all([ fetchPeriodData(symbol, PERIOD_KEYS), fetchFundingRateData(symbol), ]); + refreshFundingInterval(currentSession()); + var [periodEntries, fundingEntry] = await history; if (epoch !== myEpoch || !isActiveTradingPage() || getCurrentSymbol() !== symbol) return; // 已被更新的调用取代 applyResults(symbol, periodEntries, fundingEntry); renderAll(symbol); @@ -836,7 +687,6 @@ import { if (symbol !== lastSymbol) { lastSymbol = symbol; failedKeys = new Set(); - prevDisplayValues = {}; log('交易对:', symbol); } @@ -847,10 +697,12 @@ import { try { var periodEntries, fundingEntry; if (attempt === 0) { - [periodEntries, fundingEntry] = await Promise.all([ + var history = Promise.all([ fetchPeriodData(symbol, PERIOD_KEYS), fetchFundingRateData(symbol), ]); + refreshFundingInterval(currentSession()); + [periodEntries, fundingEntry] = await history; } else { var pending = getPendingKeys(symbol, targetTs); if (pending.length === 0) { @@ -904,12 +756,29 @@ import { clearTimeout(retryTimer); retryTimer = null; if (agoTimer) { clearInterval(agoTimer); agoTimer = null; } if (serverTimeTimer) { clearInterval(serverTimeTimer); serverTimeTimer = null; } + if (serverTimeRequest) { + clearTimeout(serverTimeRequest.timeout); + serverTimeRequest.controller.abort(); + serverTimeRequest = null; + } + clearTimeout(currentFundingTimer); currentFundingTimer = null; + if (currentFundingRequest) { + clearTimeout(currentFundingRequest.timeout); + currentFundingRequest.controller.abort(); + currentFundingRequest = null; + } + if (fundingIntervalRequest) { + clearTimeout(fundingIntervalRequest.timeout); + fundingIntervalRequest.controller.abort(); + fundingIntervalRequest = null; + } + clockCalibrated = false; } function startServerTimeLoop() { if (serverTimeTimer) return; serverTimeTimer = setInterval(function () { - if (isActiveTradingPage()) syncServerTime(); + if (isActiveTradingPage()) syncServerTime(currentSession()); }, 60 * 60 * 1000); } @@ -922,6 +791,7 @@ import { } function stopLoop() { + sessionGeneration++; epoch++; stopBusinessLoop(); stopRouteWatcher(); @@ -935,11 +805,17 @@ import { function removePanel() { cleanupPanelDrag(); + if (panelView) panelView.destroy(); + panelView = null; var panel = document.getElementById(PANEL_ID); - if (panel) panel.remove(); + if (panel) { + panel.remove(); + window.dispatchEvent(new CustomEvent(DATA_PANEL_LAYOUT_EVENT)); + } } function pauseForNonTradingPage() { + sessionGeneration++; epoch++; stopBusinessLoop(); lastSymbol = null; @@ -948,14 +824,26 @@ import { async function activateTradingPage() { if (!isActiveTradingPage()) return; - await syncServerTime(); - if (!isActiveTradingPage()) return; + sessionGeneration++; + epoch++; + stopBusinessLoop(); + fetching = 0; + const session = currentSession(); + const symbol = session.symbol; + if (!symbol) return; + failedKeys = new Set([...PERIOD_KEYS, 'fundingRate']); + endpointErrors = {}; + currentFundingState = emptyCurrentFundingState(symbol); + lastUpdateTs = historyUpdatedAt[symbol] || 0; ensurePanel(); + renderAll(symbol); + await syncServerTime(session); + if (!sessionIsCurrent(session)) return; startServerTimeLoop(); - var symbol = getCurrentSymbol(); - if (!symbol) return; + startDisplayClock(); + refreshCurrentFunding(session); await initialFetch(symbol); - if (!isActiveTradingPage() || getCurrentSymbol() !== symbol) return; + if (!sessionIsCurrent(session)) return; scheduleCycle(); } diff --git a/src/binance-trading-data/market-data.js b/src/binance-trading-data/market-data.js new file mode 100644 index 00000000..d0de85fa --- /dev/null +++ b/src/binance-trading-data/market-data.js @@ -0,0 +1,159 @@ +const HISTORY_FIELDS = Object.freeze({ + topAccountRatio: 'longShortRatio', + topPositionRatio: 'longShortRatio', + globalAccountRatio: 'longShortRatio', + takerRatio: 'buySellRatio', + basis: 'basisRate', + fundingRate: 'fundingRate', +}); + +function numeric(value, field) { + if ((typeof value !== 'number' && typeof value !== 'string') || value === '' || (typeof value === 'string' && value.trim() === '')) { + throw new TypeError(`Missing numeric ${field}`); + } + const result = Number(value); + if (!Number.isFinite(result)) throw new TypeError(`Invalid numeric ${field}`); + return result; +} + +function timestamp(value, field) { + const result = numeric(value, field); + if (!Number.isSafeInteger(result) || result <= 0) throw new TypeError(`Invalid timestamp ${field}`); + return result; +} + +/** Both OI and Binance's CMC supply use the contract base unit, including 1000-token contracts. */ +export function parseHistory(key, payload, symbol) { + if (!Array.isArray(payload)) throw new TypeError(`${key} must be an array`); + let previousTime = 0; + return payload.map(row => { + if (!row || typeof row !== 'object') throw new TypeError(`Invalid ${key} record`); + if (row.symbol !== undefined && row.symbol !== symbol) throw new Error(`${key} symbol mismatch`); + if (row.pair !== undefined && row.pair !== symbol) throw new Error(`${key} pair mismatch`); + const time = timestamp(row[key === 'fundingRate' ? 'fundingTime' : 'timestamp'], key); + if (time <= previousTime) throw new Error(`${key} history must have increasing timestamps`); + previousTime = time; + if (key === 'openInterest') { + const value = numeric(row.sumOpenInterest, 'sumOpenInterest'); + if (value < 0) throw new RangeError('Open interest cannot be negative'); + const rawSupply = row.CMCCirculatingSupply; + const supply = rawSupply === null || rawSupply === undefined || rawSupply === '' + ? null : numeric(rawSupply, 'CMCCirculatingSupply'); + return { timestamp: time, value, supply }; + } + const field = HISTORY_FIELDS[key]; + if (!field) throw new Error(`Unknown historical endpoint: ${key}`); + const point = { timestamp: time, value: numeric(row[field], field) }; + if (key === 'takerRatio') { + point.buy = numeric(row.buyVol, 'buyVol'); + point.sell = numeric(row.sellVol, 'sellVol'); + if (point.buy < 0 || point.sell < 0) throw new RangeError('Taker volume cannot be negative'); + } + return point; + }); +} + +export function parseCurrentFunding(payload, symbol) { + if (!payload || Array.isArray(payload) || payload.symbol !== symbol) throw new Error('Current funding symbol mismatch'); + return { + value: numeric(payload.lastFundingRate, 'lastFundingRate'), + time: timestamp(payload.time, 'premiumIndex.time'), + nextFundingTime: timestamp(payload.nextFundingTime, 'nextFundingTime'), + }; +} + +/** An absent symbol is unknown: the adjusted-symbol endpoint does not promise an 8h default. */ +export function parseFundingInterval(payload, symbol) { + if (!Array.isArray(payload)) throw new TypeError('Funding information must be an array'); + const matches = payload.filter(row => row && row.symbol === symbol); + if (matches.length === 0) return null; + if (matches.length !== 1) throw new Error('Funding interval symbol is ambiguous'); + const hours = numeric(matches[0].fundingIntervalHours, 'fundingIntervalHours'); + if (!Number.isInteger(hours) || hours <= 0) throw new RangeError('Invalid funding interval'); + return hours; +} + +/** Fractional rates need more precision than ratios; genuine zero remains distinguishable. */ +export function formatFundingPercent(value) { + if (value === null) return '--'; + if (!Number.isFinite(value)) throw new TypeError('Invalid funding rate'); + const percent = value * 100; + if (percent === 0) return '0%'; + const magnitude = Math.abs(percent); + if (magnitude < 1e-12) return percent.toExponential(2) + '%'; + const decimals = Math.max(6, Math.min(14, 2 - Math.floor(Math.log10(magnitude)))); + return new Intl.NumberFormat('en-US', { maximumFractionDigits: decimals, useGrouping: false }).format(percent) + '%'; +} + +function compactQuantity(value, locale) { + const scales = locale === 'zh-CN' + ? [[1e12, '万亿'], [1e8, '亿'], [1e4, '万']] + : [[1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'K']]; + const scale = scales.find(([base]) => Math.abs(value) >= base); + const amount = scale ? value / scale[0] : value; + const decimals = locale === 'zh-CN' ? 4 : 2; + return new Intl.NumberFormat('en-US', { maximumFractionDigits: decimals, useGrouping: false }).format(amount) + (scale ? scale[1] : ''); +} + +export function formatMetricValue(id, value, locale) { + if (value === null) return '--'; + if (!Number.isFinite(value)) throw new TypeError(`Invalid ${id} value`); + if (id === 'funding') return formatFundingPercent(value); + if (id === 'oi' || id === 'volume') return compactQuantity(value, locale); + if (id === 'basis' || id === 'oi-supply') { + return (id === 'basis' && value > 0 ? '+' : '') + (value * 100).toFixed(2) + '%'; + } + return value.toFixed(2); +} + +const INDICATORS = Object.freeze([ + ['oi', 'openInterest'], + ['top-accounts', 'topAccountRatio'], + ['top-positions', 'topPositionRatio'], + ['global-accounts', 'globalAccountRatio'], + ['taker', 'takerRatio'], + ['basis', 'basis'], + ['funding', 'fundingRate'], + ['oi-supply', 'openInterest'], +]); + +/** Preserve the existing directional rules; only settled funding participates in its vote. */ +export function computeTradingSignals(data, cachedKeys, locale, symbol, endpointErrors = {}) { + const unit = symbol.replace(/USDT$|USDC$/, ''); + const indicators = INDICATORS.map(([id, key]) => { + const source = data[key] || []; + const history = id === 'oi-supply' + ? source.map(point => ({ timestamp: point.timestamp, value: point.supply > 0 ? point.value / point.supply : null })) + : source; + const value = history.length ? history.at(-1).value : null; + let signal = 'neutral'; + let arrow = ''; + if (value !== null) { + if (id === 'oi' && history.length > 6) { + const previous = history.at(-7).value; + signal = value > previous ? 'long' : value < previous ? 'short' : 'neutral'; + arrow = value > previous ? ' ▲' : value < previous ? ' ▼' : ''; + } else if (id === 'basis') { + signal = value > 0 ? 'long' : value < 0 ? 'short' : 'neutral'; + } else if (id === 'funding') { + signal = value < -0.0001 ? 'long' : value > 0.0001 ? 'short' : 'neutral'; + } else if (id !== 'oi' && id !== 'oi-supply') { + signal = value > 1 ? 'long' : value < 1 ? 'short' : 'neutral'; + } + } + return { + id, signal, value, history, + display: formatMetricValue(id, value, locale) + arrow, + unit: id === 'oi' || id === 'taker' ? unit : '', + cached: cachedKeys.has(key), error: endpointErrors[key] || null, + vote: id !== 'oi-supply' && value !== null, + }; + }); + const voters = indicators.filter(indicator => indicator.vote && !indicator.cached); + return { + symbol, indicators, + longCount: voters.filter(indicator => indicator.signal === 'long').length, + shortCount: voters.filter(indicator => indicator.signal === 'short').length, + total: voters.length, + }; +} diff --git a/src/binance-trading-data/panel-view.js b/src/binance-trading-data/panel-view.js new file mode 100644 index 00000000..e6e07377 --- /dev/null +++ b/src/binance-trading-data/panel-view.js @@ -0,0 +1,441 @@ +import { formatFundingPercent } from './market-data.js'; +import { createHistoryChart } from './sparkline.js'; +import { + formatFundingPeriod, formatHistoryCount, formatHistoryTime, formatVotes, + tradingMetricText, tradingText, +} from './ui-copy.js'; + +const PANEL_ID = 'jh-binance-trading-data-panel'; +const INITIAL_FUNDING_STATE = Object.freeze({ + current: null, intervalHours: null, receivedAt: null, cached: false, error: null, + intervalCached: false, intervalError: null, +}); + +function styles() { + const scope = `#${PANEL_ID}`; + return ` + ${scope} { + --td-bg: var(--color-PrimaryBg, light-dark(#ffffff, #1e2329)); + --td-head: var(--color-SecondaryBg, light-dark(#fafbfc, #242a32)); + --td-text: var(--color-TextPrimary, light-dark(#202630, #eaecef)); + --td-muted: var(--color-TextSecondary, light-dark(#626c7d, #a6b0bf)); + --td-border: var(--color-Line, light-dark(#e4e8ef, #353d48)); + --td-buy: var(--color-Buy, light-dark(#24865e, #65c59c)); + --td-sell: var(--color-Sell, light-dark(#db3a50, #ff7c8f)); + --td-chart: var(--color-PrimaryYellow, light-dark(#b68208, #f0b90b)); + --td-highlight: color-mix(in srgb, var(--td-chart) 7%, var(--td-bg)); + --td-focus: light-dark(#315fe8, #9bb7ff); + color-scheme: light dark; + box-sizing: border-box; + background: var(--td-bg); + color: var(--td-text); + border: 1px solid var(--td-border); + border-radius: 10px; + box-shadow: 0 6px 24px #00000020; + font: 400 13px/1.45 BinancePlex, system-ui, -apple-system, sans-serif; + text-align: left; + overflow: hidden; + } + ${scope} * { box-sizing: border-box; } + ${scope} [hidden] { display: none !important; } + ${scope} #${PANEL_ID}-header { + display: flex; align-items: center; gap: 7px; min-height: 49px; + padding: 10px 12px; flex: 0 0 auto; cursor: move; user-select: none; + border-bottom: 1px solid var(--td-border); background: var(--td-head); + } + ${scope} .td-grip { color: var(--td-muted); font-size: 16px; flex: 0 0 auto; } + ${scope} .td-title { margin: 0; font-size: 15px; font-weight: 600; white-space: nowrap; } + ${scope} #${PANEL_ID}-symbol { + min-width: 0; color: var(--td-muted); font-size: 12px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + ${scope} .td-actions { display: flex; gap: 3px; margin-left: auto; flex: 0 0 auto; } + ${scope} button { font: inherit; cursor: pointer; border: 0; color: inherit; background: transparent; } + ${scope} .td-actions button { + display: flex; align-items: center; justify-content: center; + width: 26px; height: 26px; padding: 2px; border-radius: 4px; + color: var(--td-muted); font-size: 17px; + } + ${scope} button:hover { background: var(--td-highlight); } + ${scope} button:focus-visible { outline: 2px solid var(--td-focus); outline-offset: 2px; } + ${scope} #${PANEL_ID}-body { min-height: 0; overflow: auto; overscroll-behavior: contain; } + ${scope} table { width: 100%; table-layout: fixed; border-collapse: collapse; } + ${scope} .td-name-col { width: 36%; } + ${scope} .td-trend-col { width: 31%; } + ${scope} .td-value-col { width: 33%; } + ${scope} thead { position: sticky; top: 0; z-index: 1; background: var(--td-head); } + ${scope} thead th { color: var(--td-muted); font-size: 11px; font-weight: 500; padding: 8px 10px; text-align: left; } + ${scope} thead th:nth-child(2) { text-align: center; } + ${scope} thead th:last-child { text-align: right; } + ${scope} tbody th, ${scope} tbody td { padding: 10px; vertical-align: middle; border-top: 1px solid var(--td-border); } + ${scope} .td-name { color: var(--td-muted); text-align: left; font-weight: 400; overflow-wrap: anywhere; } + ${scope} .td-trend { padding: 8px 3px; text-align: center; } + ${scope} .td-value { text-align: right; font-variant-numeric: tabular-nums; } + ${scope} .td-number { font-weight: 500; overflow-wrap: anywhere; } + ${scope} .jh-td-flash { animation: jh-td-value-change 1s ease-out; } + @keyframes jh-td-value-change { from { background: color-mix(in srgb, var(--td-chart) 25%, transparent); } to { background: transparent; } } + ${scope} small { display: block; margin-top: 3px; font-size: 11px; font-weight: 400; color: var(--td-muted); } + ${scope} .td-value small { line-height: 1.4; } + ${scope} .td-spark-button { display: block; width: 100%; padding: 0; border-radius: 4px; color: var(--td-chart); touch-action: pan-y; } + ${scope} .td-sparkline { display: block; width: 100%; height: 44px; overflow: visible; } + ${scope} .td-spark-path { fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; } + ${scope} .td-spark-reference { stroke: var(--td-border); stroke-width: 1; } + ${scope} .td-spark-guide { stroke: var(--td-muted); stroke-width: 1; pointer-events: none; } + ${scope} .td-spark-point { fill: currentColor; } + ${scope} .td-spark-hit { fill: transparent; pointer-events: all; } + ${scope} .td-buy-bar { fill: var(--td-buy); } + ${scope} .td-sell-bar { fill: var(--td-sell); } + ${scope} .td-no-history { display: block; padding: 9px 0; color: var(--td-muted); font-size: 11px; } + ${scope} .td-trend-caption, ${scope} .td-last-settled { font-size: 11px; } + ${scope} .td-last-settled { display: block; margin-top: 2px; color: var(--td-text); font-variant-numeric: tabular-nums; } + ${scope} .td-last-value { display: inline-block; white-space: nowrap; } + ${scope} .td-legend { display: flex; justify-content: center; gap: 9px; margin-top: 3px; color: var(--td-muted); font-size: 11px; } + ${scope} .td-legend span { display: inline-flex; align-items: center; gap: 3px; } + ${scope} .td-legend i { display: inline-block; width: 5px; height: 7px; background: var(--td-buy); } + ${scope} .td-legend span:last-child i { background: var(--td-sell); } + ${scope} .td-signal-long { color: var(--td-buy); } + ${scope} .td-signal-short { color: var(--td-sell); } + ${scope} .td-signal-neutral { color: var(--td-text); } + ${scope} .td-dot { display: inline-block; width: 7px; height: 7px; margin-left: 5px; border-radius: 50%; border: 1px solid currentColor; background: currentColor; } + ${scope} .td-signal-neutral .td-dot { color: var(--td-muted); } + ${scope} .td-cached .td-dot { background: transparent; } + ${scope} .td-data-status { color: var(--td-muted); } + ${scope} .td-funding-row { background: var(--td-highlight); } + ${scope} .td-funding-row .td-name { color: var(--td-text); font-weight: 500; } + ${scope} .td-funding-row .td-number { font-size: 14px; font-weight: 600; } + ${scope} .td-current-label { color: var(--td-text); margin: 0 0 3px; } + ${scope} .td-countdown { font-variant-numeric: tabular-nums; } + ${scope} .td-history-detail td { padding: 10px 12px; background: var(--td-head); } + ${scope} .td-history-inspection { display: flex; flex-wrap: wrap; gap: 5px 12px; font-size: 11px; color: var(--td-muted); user-select: text; } + ${scope} .td-history-inspection strong { flex: 1 0 100%; color: var(--td-text); font-size: 12px; font-weight: 500; } + ${scope} .td-history-value { color: var(--td-text); font-variant-numeric: tabular-nums; } + ${scope} #${PANEL_ID}-composite { padding: 12px; border-top: 1px solid var(--td-border); } + ${scope} .td-composite-line { display: flex; justify-content: space-between; gap: 8px; font-weight: 600; } + ${scope} .td-bias { white-space: nowrap; } + ${scope} .td-vote-track { display: flex; height: 5px; margin-top: 10px; border-radius: 5px; overflow: hidden; background: var(--td-border); } + ${scope} .td-vote-track span:first-child { background: var(--td-buy); } + ${scope} .td-vote-track span:last-child { background: var(--td-sell); margin-left: auto; } + ${scope} .td-composite-votes { margin-top: 8px; } + ${scope} .td-method-note { line-height: 1.5; } + ${scope} #${PANEL_ID}-footer { padding: 9px 12px; color: var(--td-muted); font-size: 11px; border-top: 1px solid var(--td-border); font-variant-numeric: tabular-nums; } + ${scope} .td-footer-line { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 5px 10px; } + @media (max-width: 540px) { + ${scope} { font-size: 12px; } + ${scope} #${PANEL_ID}-header { padding: 9px 10px; gap: 6px; } + ${scope} .td-title { font-size: 14px; } + ${scope} .td-name-col { width: 34%; } + ${scope} .td-value-col { width: 35%; } + ${scope} thead th, ${scope} tbody th, ${scope} tbody td { padding: 9px 5px; } + ${scope} .td-trend { padding: 8px 3px; } + ${scope} .td-funding-row .td-number { font-size: 13px; } + } + `; +} + +/** Owns display nodes only; the parent owns route identity, data refreshes and clocks. */ +export function createTradingDataView({ document, panel, locale, collapsed, onCollapse, onClose }) { + const style = document.createElement('style'); + style.textContent = styles(); + panel.append(style); + Object.assign(panel.style, { + width: '480px', maxWidth: 'calc(100vw - 16px)', maxHeight: 'calc(100vh - 24px)', + display: 'flex', flexDirection: 'column', + }); + const node = (tag, className = '', text = '') => { + const element = document.createElement(tag); + element.className = className; + element.textContent = text; + return element; + }; + const setText = (element, value) => { + if (element.textContent !== value) element.textContent = value; + }; + const identify = (element, suffix) => { + element.id = `${PANEL_ID}-${suffix}`; + return element; + }; + const role = (element, value) => { + element.dataset.role = value; + return element; + }; + + const header = identify(node('header'), 'header'); + const grip = node('span', 'td-grip', '☰'); + grip.setAttribute('aria-hidden', 'true'); + const title = node('h2', 'td-title'); + const symbol = identify(node('span'), 'symbol'); + const actions = node('div', 'td-actions'); + const collapseButton = identify(node('button'), 'collapse'); + const closeButton = identify(node('button', '', '×'), 'close'); + collapseButton.type = closeButton.type = 'button'; + collapseButton.setAttribute('aria-controls', `${PANEL_ID}-body`); + actions.append(collapseButton, closeButton); + header.append(grip, title, symbol, actions); + const body = identify(node('div'), 'body'); + const table = node('table'); + const columns = node('colgroup'); + for (const name of ['name', 'trend', 'value']) columns.append(node('col', `td-${name}-col`)); + const tableHead = node('thead'); + const headingRow = node('tr'); + const headings = ['metric', 'history', 'value'].map(key => { + const heading = node('th'); + heading.scope = 'col'; + heading.dataset.copy = key; + headingRow.append(heading); + return heading; + }); + tableHead.append(headingRow); + const rows = identify(node('tbody'), 'rows'); + table.append(columns, tableHead, rows); + const composite = identify(node('div'), 'composite'); + const footer = identify(node('footer'), 'footer'); + const footerLine = node('div', 'td-footer-line'); + footerLine.append(role(node('span'), 'updated-at'), role(node('span'), 'elapsed')); + footer.append(footerLine); + body.append(table, composite, footer); + panel.append(header, body); + + let currentModel = null; + let currentFunding = INITIAL_FUNDING_STATE; + let currentClock = null; + let fundingNodes = null; + let charts = []; + let openDetail = null; + + function closeDetails() { + if (openDetail !== null) { + openDetail.row.remove(); + openDetail.button.setAttribute('aria-expanded', 'false'); + openDetail = null; + } + } + + function updateHeading() { + const panelTitle = tradingText('title', locale); + panel.lang = locale; + panel.setAttribute('aria-label', panelTitle); + table.setAttribute('aria-label', panelTitle); + setText(title, panelTitle); + for (const heading of headings) setText(heading, tradingText(heading.dataset.copy, locale)); + const collapseTitle = tradingText(collapsed ? 'expand' : 'collapse', locale); + collapseButton.title = collapseTitle; + collapseButton.setAttribute('aria-label', collapseTitle); + collapseButton.setAttribute('aria-expanded', String(!collapsed)); + setText(collapseButton, collapsed ? '□' : '−'); + closeButton.title = tradingText('close', locale); + closeButton.setAttribute('aria-label', closeButton.title); + body.style.display = collapsed ? 'none' : 'block'; + } + + function collapse() { + collapsed = !collapsed; + updateHeading(); + onCollapse(collapsed); + } + function close() { onClose(); } + collapseButton.addEventListener('click', collapse); + closeButton.addEventListener('click', close); + + function showStatus(element, key, error) { + const text = key === null ? '' : tradingText(key, locale); + setText(element, text); + element.hidden = text === ''; + element.title = error ? `${text}: ${error}` : text; + } + + function updateCountdown() { + if (fundingNodes === null || !fundingNodes.number.isConnected) return; + let value; + if (currentClock === null || !currentClock.calibrated) { + value = tradingText('clockUnavailable', locale); + } else if (currentFunding.current === null) { + value = '--'; + } else { + const remaining = currentFunding.current.nextFundingTime - currentClock.now; + if (remaining <= 0) { + value = tradingText('waitingUpdate', locale); + } else { + const seconds = Math.ceil(remaining / 1_000); + const hours = Math.floor(seconds / 3_600); + const minutes = Math.floor(seconds % 3_600 / 60); + value = [hours, minutes, seconds % 60].map(part => String(part).padStart(2, '0')).join(':'); + } + } + setText(fundingNodes.countdown, `${tradingText('countdown', locale)} ${value}`); + } + + function updateFunding() { + if (fundingNodes === null || !fundingNodes.number.isConnected) return; + const state = currentFunding; + const expired = state.current !== null && currentClock !== null && currentClock.calibrated + && state.current.nextFundingTime <= currentClock.now; + setText(fundingNodes.number, formatFundingPercent(state.current === null || expired ? null : state.current.value)); + setText(fundingNodes.period, formatFundingPeriod(state.intervalHours, locale)); + fundingNodes.number.title = tradingText('fundingNote', locale); + if (state.current !== null) fundingNodes.number.title += `\n${formatHistoryTime(state.current.time, locale)}`; + const status = expired && state.error === null ? 'waitingUpdate' : state.current === null + ? state.error ? 'loadFailed' : 'loading' + : state.cached ? state.error ? 'cachedFailure' : 'cached' : state.error ? 'refreshFailed' : null; + const intervalStatus = state.intervalHours !== null && state.intervalCached + ? state.intervalError ? 'intervalCachedFailure' : 'intervalCached' + : state.intervalError ? 'intervalLoadFailed' : null; + showStatus(fundingNodes.status, status, state.error); + showStatus(fundingNodes.intervalStatus, intervalStatus, state.intervalError); + updateCountdown(); + } + + function renderIndicator(indicator, previousValue) { + const { id } = indicator; + const isFunding = id === 'funding'; + const row = node('tr', isFunding ? 'td-funding-row' : ''); + row.dataset.metric = id; + row.classList.toggle('td-cached', indicator.cached && indicator.value !== null); + const name = node('th', 'td-name', tradingMetricText(id, 'name', locale)); + name.scope = 'row'; + name.title = tradingMetricText(id, 'detail', locale); + const trend = node('td', 'td-trend'); + const detailRow = node('tr', 'td-history-detail'); + detailRow.id = `${PANEL_ID}-history-${id}`; + const detailCell = node('td'); + detailCell.colSpan = 3; + const inspection = node('div', 'td-history-inspection'); + inspection.setAttribute('role', 'status'); + inspection.setAttribute('aria-atomic', 'true'); + const detailTitle = node('strong'); + const detailTime = node('time'); + const detailValue = node('span', 'td-history-value'); + inspection.append(detailTitle, detailTime, detailValue); + detailCell.append(inspection); + detailRow.append(detailCell); + const chart = createHistoryChart({ + document, indicator, locale, + onInspect(record, action) { + setText(detailTitle, `${record.name} · ${record.label}`); + setText(detailTime, record.time); + detailTime.dateTime = new Date(record.timestamp).toISOString(); + setText(detailValue, record.value); + if (action === 'hover') return; + const shouldClose = action === 'close' || (action === 'toggle' && openDetail?.row === detailRow); + closeDetails(); + if (shouldClose) return; + row.after(detailRow); + chart.element.setAttribute('aria-expanded', 'true'); + openDetail = { row: detailRow, button: chart.element }; + }, + }); + charts.push(chart); + if (chart.count > 0) chart.element.setAttribute('aria-controls', detailRow.id); + trend.append(chart.element, node('small', 'td-trend-caption', formatHistoryCount(id, chart.count, locale))); + if (isFunding) { + const settled = node('span', 'td-last-settled'); + settled.append(document.createTextNode(`${tradingText('last', locale)} `), node('span', 'td-last-value', indicator.display)); + trend.append(settled); + } + if (id === 'taker') { + const legend = node('div', 'td-legend'); + for (const side of ['buy', 'sell']) { + const item = node('span'); + const swatch = node('i'); + swatch.setAttribute('aria-hidden', 'true'); + item.append(swatch, document.createTextNode(tradingText(side, locale))); + legend.append(item); + } + trend.append(legend); + } + + const value = node('td', `td-value td-signal-${isFunding ? 'neutral' : indicator.signal}`); + const number = node('span', 'td-number', isFunding ? '--' : indicator.display); + if (!isFunding && previousValue !== undefined && previousValue !== null && indicator.value !== null && previousValue !== indicator.value) { + number.classList.add('jh-td-flash'); + } + if (isFunding) { + const period = role(node('small', 'td-current-label'), 'funding-period'); + const countdown = role(node('small', 'td-countdown'), 'funding-countdown'); + const status = role(node('small', 'td-data-status'), 'funding-status'); + const intervalStatus = role(node('small', 'td-data-status'), 'funding-interval-status'); + role(number, 'current-funding'); + value.append(period, number, countdown, status, intervalStatus); + fundingNodes = { number, period, countdown, status, intervalStatus }; + } else { + const reading = node('div'); + const dot = node('span', 'td-dot'); + dot.setAttribute('role', 'img'); + dot.setAttribute('aria-label', tradingText(indicator.signal, locale)); + reading.append(number, dot); + value.append(reading); + const unit = id === 'taker' ? tradingText('buySellRatio', locale) + : id === 'basis' ? tradingText('basisRate', locale) : indicator.unit; + if (unit) value.append(node('small', 'td-unit', unit)); + } + const dataStatus = node('small', 'td-data-status'); + const statusKey = indicator.value === null + ? indicator.error ? 'loadFailed' : 'missing' + : indicator.cached ? indicator.error ? 'cachedFailure' : 'cached' : null; + showStatus(dataStatus, statusKey, indicator.error); + (isFunding ? trend : value).append(dataStatus); + row.append(name, trend, value); + rows.append(row); + } + + function renderComposite(model) { + const signal = model.longCount === model.shortCount ? 'neutral' : model.longCount > model.shortCount ? 'long' : 'short'; + const line = node('div', 'td-composite-line'); + line.append(node('span', '', tradingText('composite', locale)), node('span', `td-bias td-signal-${signal}`, `${tradingText(signal, locale)} ${model.longCount}:${model.shortCount}`)); + const track = node('div', 'td-vote-track'); + const votes = formatVotes(model, locale); + track.setAttribute('role', 'img'); + track.setAttribute('aria-label', votes); + for (const count of [model.longCount, model.shortCount]) { + const bar = node('span'); + bar.style.width = `${model.total > 0 ? count / model.total * 100 : 0}%`; + track.append(bar); + } + composite.replaceChildren(line, track, node('small', 'td-composite-votes', votes), node('small', 'td-method-note', tradingText('simplifiedNote', locale))); + } + + function render(model) { + const previousValues = new Map(currentModel !== null && currentModel.symbol === model.symbol + ? currentModel.indicators.map(indicator => [indicator.id, indicator.value]) : []); + currentModel = model; + // Binance can temporarily detach output slots during a host DOM update. + if (![symbol, rows, composite, footer].every(element => element.isConnected)) return; + setText(symbol, model.symbol); + symbol.title = model.symbol; + closeDetails(); + charts.forEach(chart => chart.destroy()); + charts = []; + rows.replaceChildren(); + fundingNodes = null; + for (const indicator of model.indicators) renderIndicator(indicator, previousValues.get(indicator.id)); + renderComposite(model); + updateFunding(); + } + + updateHeading(); + return { + render, + setFunding(state, clock) { + currentFunding = state; + currentClock = clock; + updateFunding(); + }, + updateClock(clock) { + currentClock = clock; + updateFunding(); + }, + setLocale(nextLocale) { + if (locale === nextLocale) return; + locale = nextLocale; + updateHeading(); + if (currentModel !== null) render(currentModel); + }, + destroy() { + collapseButton.removeEventListener('click', collapse); + closeButton.removeEventListener('click', close); + charts.forEach(chart => chart.destroy()); + charts = []; + closeDetails(); + style.remove(); + }, + }; +} diff --git a/src/binance-trading-data/sparkline.js b/src/binance-trading-data/sparkline.js new file mode 100644 index 00000000..74bf1bdd --- /dev/null +++ b/src/binance-trading-data/sparkline.js @@ -0,0 +1,186 @@ +import { formatFundingPercent, formatMetricValue } from './market-data.js'; +import { formatHistoryTime, tradingMetricText, tradingText } from './ui-copy.js'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const WIDTH = 144; +const HEIGHT = 44; +const PADDING = 4; +const RATIO_METRICS = new Set(['top-accounts', 'top-positions', 'global-accounts']); + +/** Responsive viewBox coordinates retain observed time gaps without resize listeners. */ +export function createHistoryChart({ document, indicator, locale, onInspect }) { + const { history, id, unit } = indicator; + const volume = id === 'taker'; + const listeners = []; + const listen = (node, type, listener) => { + node.addEventListener(type, listener); + listeners.push(() => node.removeEventListener(type, listener)); + }; + const destroy = () => listeners.splice(0).forEach(remove => remove()); + const svgElement = (tag, attributes) => { + const node = document.createElementNS(SVG_NS, tag); + for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value)); + return node; + }; + + const points = history.map((point, index) => ({ ...point, index })); + for (const point of points) { + if (!Number.isFinite(point.timestamp) || (point.value !== null && !Number.isFinite(point.value))) { + throw new Error(`Invalid historical observation for ${id}`); + } + } + const available = point => volume + ? Number.isFinite(point.buy) && Number.isFinite(point.sell) + : point.value !== null; + const observations = points.filter(available); + if (observations.length === 0) { + const empty = document.createElement('span'); + empty.className = 'td-no-history'; + empty.textContent = tradingText('noHistory', locale); + return { element: empty, count: 0, destroy }; + } + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'td-spark-button'; + const name = tradingMetricText(id, 'name', locale); + button.setAttribute('aria-label', `${name} · ${tradingText('inspect', locale)}`); + button.setAttribute('aria-description', tradingText('keyboard', locale)); + button.setAttribute('aria-expanded', 'false'); + const svg = svgElement('svg', { + class: 'td-sparkline', viewBox: `0 0 ${WIDTH} ${HEIGHT}`, + preserveAspectRatio: 'none', 'aria-hidden': 'true', + }); + const chartTitle = svgElement('title', {}); + chartTitle.textContent = `${name} · ${tradingText(id === 'funding' ? 'settledRecord' : 'historyRecord', locale)}`; + svg.append(chartTitle); + button.append(svg); + + const reference = RATIO_METRICS.has(id) ? 1 : (volume || id === 'funding' || id === 'basis') ? 0 : null; + const values = observations.flatMap(point => volume ? [point.buy, point.sell] : [point.value]); + if (reference !== null) values.push(reference); + const minimum = Math.min(...values); + const maximum = Math.max(...values); + const firstTime = Math.min(...points.map(point => point.timestamp)); + const lastTime = Math.max(...points.map(point => point.timestamp)); + const x = point => firstTime === lastTime + ? WIDTH / 2 + : PADDING + (point.timestamp - firstTime) * (WIDTH - PADDING * 2) / (lastTime - firstTime); + const y = value => maximum === minimum + ? HEIGHT / 2 + : PADDING + (maximum - value) * (HEIGHT - PADDING * 2) / (maximum - minimum); + const coordinate = value => Number(value.toFixed(3)); + + if (reference !== null) { + svg.append(svgElement('line', { + x1: 0, x2: WIDTH, y1: y(reference), y2: y(reference), class: 'td-spark-reference', + })); + } + + if (volume) { + for (let index = 0; index < observations.length; index += 1) { + const point = observations[index]; + const previousGap = index === 0 ? WIDTH : x(point) - x(observations[index - 1]); + const nextGap = index === observations.length - 1 ? WIDTH : x(observations[index + 1]) - x(point); + const pairWidth = Math.min(12, previousGap * 0.8, nextGap * 0.8); + const barWidth = pairWidth * 0.42; + for (const [side, offset] of [['buy', -pairWidth / 2], ['sell', pairWidth * 0.08]]) { + svg.append(svgElement('rect', { + x: coordinate(x(point) + offset), y: coordinate(y(point[side])), + width: coordinate(barWidth), height: coordinate(y(0) - y(point[side])), + class: side === 'buy' ? 'td-buy-bar' : 'td-sell-bar', + })); + } + } + } else { + /** Missing observations break the line; an isolated record is a point, never a synthetic segment. */ + const segments = []; + let segment = []; + for (const point of points) { + if (point.value === null) { + if (segment.length > 0) segments.push(segment); + segment = []; + } else { + segment.push(point); + } + } + if (segment.length > 0) segments.push(segment); + for (const observed of segments) { + if (observed.length > 1) { + const path = observed.map((point, index) => `${index === 0 ? 'M' : 'L'}${coordinate(x(point))},${coordinate(y(point.value))}`).join(' '); + svg.append(svgElement('path', { d: path, class: 'td-spark-path' })); + } + const last = observed[observed.length - 1]; + svg.append(svgElement('circle', { + cx: coordinate(x(last)), cy: coordinate(y(last.value)), r: 2, class: 'td-spark-point', + })); + } + } + + const guide = svgElement('line', { y1: 1, y2: HEIGHT - 1, class: 'td-spark-guide', opacity: 0 }); + svg.append(guide); + let selected = observations.length - 1; + + function record(point) { + const label = tradingText(id === 'funding' ? 'settledRecord' : 'historyRecord', locale); + const quantity = value => `${formatMetricValue('oi', value, locale)}${unit ? ` ${unit}` : ''}`; + const value = volume + ? `${tradingText('buy', locale)} ${quantity(point.buy)} · ${tradingText('sell', locale)} ${quantity(point.sell)} · ${tradingText('buySellRatio', locale)} ${formatMetricValue('taker', point.value, locale)}` + : `${id === 'funding' ? formatFundingPercent(point.value) : formatMetricValue(id, point.value, locale)}${id === 'oi' && unit ? ` ${unit}` : ''}`; + return { + name, label, value, timestamp: point.timestamp, + time: formatHistoryTime(point.timestamp, locale), + }; + } + + function select(index, action) { + selected = index; + const point = observations[index]; + const item = record(point); + button.title = `${name} · ${item.label}\n${item.time}\n${item.value}`; + button.setAttribute('aria-label', `${name} · ${tradingText('inspect', locale)} · ${item.time} · ${item.value}`); + guide.setAttribute('x1', String(x(point))); + guide.setAttribute('x2', String(x(point))); + guide.setAttribute('opacity', action === 'initial' ? '0' : '0.65'); + if (action !== 'initial') onInspect(item, action); + } + + for (let index = 0; index < observations.length; index += 1) { + const point = observations[index]; + const left = index === 0 ? 0 : (x(observations[index - 1]) + x(point)) / 2; + const right = index === observations.length - 1 ? WIDTH : (x(point) + x(observations[index + 1])) / 2; + const hit = svgElement('rect', { + x: coordinate(left), y: 0, width: coordinate(right - left), height: HEIGHT, + class: 'td-spark-hit', 'data-history-index': index, 'data-timestamp': point.timestamp, + }); + const title = svgElement('title', {}); + const item = record(point); + title.textContent = `${item.time}\n${item.value}`; + hit.append(title); + listen(hit, 'pointerenter', () => select(index, 'hover')); + listen(hit, 'pointerdown', () => select(index, 'hover')); + svg.append(hit); + } + listen(button, 'click', event => { + const hit = event.target.closest('[data-history-index]'); + select(hit ? Number(hit.dataset.historyIndex) : selected, 'toggle'); + }); + listen(button, 'keydown', event => { + const movement = { ArrowLeft: -1, ArrowRight: 1 }; + if (event.key in movement) { + event.preventDefault(); + select(Math.max(0, Math.min(observations.length - 1, selected + movement[event.key])), 'open'); + } else if (event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + select(event.key === 'Home' ? 0 : observations.length - 1, 'open'); + } else if (event.key === 'Escape') { + event.preventDefault(); + onInspect(record(observations[selected]), 'close'); + } + }); + listen(button, 'pointerleave', () => { + if (button.getAttribute('aria-expanded') === 'false') guide.setAttribute('opacity', '0'); + }); + select(selected, 'initial'); + return { element: button, count: observations.length, destroy }; +} diff --git a/src/binance-trading-data/ui-copy.js b/src/binance-trading-data/ui-copy.js new file mode 100644 index 00000000..6d53e97a --- /dev/null +++ b/src/binance-trading-data/ui-copy.js @@ -0,0 +1,142 @@ +import { + localizedText, formatLocalizedText, +} from '../binance-orderbook-trade/contracts/panel-copy.js'; + +const COPY = Object.freeze({ + title: localizedText('交易数据', 'Trading data'), + metric: localizedText('指标', 'Metric'), + history: localizedText('历史趋势', 'History'), + value: localizedText('数值', 'Value'), + collapse: localizedText('收起面板', 'Collapse panel'), + expand: localizedText('展开面板', 'Expand panel'), + close: localizedText('关闭', 'Close'), + loading: localizedText('加载中', 'Loading'), + missing: localizedText('数据缺失', 'Data unavailable'), + loadFailed: localizedText('加载失败', 'Load failed'), + cached: localizedText('缓存', 'Cached'), + cachedFailure: localizedText('缓存 · 更新失败', 'Cached · refresh failed'), + intervalCached: localizedText('周期缓存', 'Cached interval'), + intervalCachedFailure: localizedText('周期缓存 · 更新失败', 'Cached interval · refresh failed'), + intervalLoadFailed: localizedText('周期加载失败', 'Interval load failed'), + refreshFailed: localizedText('更新失败', 'Refresh failed'), + noHistory: localizedText('暂无历史', 'No history'), + inspect: localizedText('查看历史数据', 'Inspect history'), + keyboard: localizedText('左右方向键切换历史点;Esc 关闭明细', 'Use left and right arrows to inspect points; Esc closes details'), + historyRecord: localizedText('历史数据', 'Historical data'), + settledRecord: localizedText('历史结算', 'Settled funding'), + last: localizedText('上次', 'Last'), + current: localizedText('当前', 'Current'), + intervalPending: localizedText('周期待确认', 'Interval pending'), + countdown: localizedText('倒计时', 'Countdown'), + clockUnavailable: localizedText('校时不可用', 'Clock unavailable'), + waitingUpdate: localizedText('等待更新', 'Waiting for update'), + buy: localizedText('买', 'Buy'), + sell: localizedText('卖', 'Sell'), + buySellRatio: localizedText('买/卖比', 'Buy/sell ratio'), + basisRate: localizedText('比率', 'Rate'), + composite: localizedText('综合信号', 'Composite signal'), + long: localizedText('偏多', 'Bullish'), + short: localizedText('偏空', 'Bearish'), + neutral: localizedText('中性', 'Neutral'), + fundingNote: localizedText( + '当前费率在结算前仍可能变化;历史曲线只含已结算记录。', + 'The current rate may change before settlement. History contains settled rates only.', + ), + simplifiedNote: localizedText( + '简化规则;资金费率投票使用最新已结算值。缓存及未平仓量与市值比率不参与投票。', + 'Simplified rules; the funding vote uses the latest settled rate. Cached data and the open-interest-to-market-cap ratio do not vote.', + ), +}); + +const METRICS = Object.freeze({ + oi: { + name: localizedText('合约持仓量', 'Open Interest'), + detail: localizedText( + '尚未平仓的合约规模,以当前交易币种计量。持仓量是存量,成交量是一段时间内的交易量。', + 'Outstanding contract quantity in the current asset. Open interest measures open contracts; volume measures trading during a period.', + ), + }, + 'top-accounts': { + name: localizedText('大户账户数多空比', 'Top Trader Long/Short Ratio (Accounts)'), + detail: localizedText( + '大户净多头账户数 ÷ 净空头账户数。大户按保证金余额前 20% 定义。', + 'Top-trader net-long accounts divided by net-short accounts. Top traders are the top 20% by margin balance.', + ), + }, + 'top-positions': { + name: localizedText('大户持仓量多空比', 'Top Trader Long/Short Ratio (Positions)'), + detail: localizedText( + '大户多头持仓量 ÷ 空头持仓量,按仓位规模统计。', + 'Top-trader long position quantity divided by short position quantity, weighted by position size.', + ), + }, + 'global-accounts': { + name: localizedText('多空账户数比', 'Long/Short Ratio'), + detail: localizedText( + '统计账户中的净多头账户数 ÷ 净空头账户数。', + 'Net-long accounts divided by net-short accounts across the reported trading population.', + ), + }, + taker: { + name: localizedText('合约主动买卖量', 'Taker Buy/Sell Volume'), + detail: localizedText( + '柱图分别显示主动买入量与主动卖出量;右侧数值是买入量 ÷ 卖出量。', + 'Bars show taker buy and sell volumes separately. The value at right is buy volume divided by sell volume.', + ), + }, + basis: { + name: localizedText('基差', 'Basis'), + detail: localizedText( + '以比率展示:(合约价格 − 现货指数价格)÷ 现货指数价格。', + 'Shown as a rate: (futures price minus spot index price) divided by spot index price.', + ), + }, + funding: { + name: localizedText('资金费率', 'Funding Rate'), + detail: COPY.fundingNote, + }, + 'oi-supply': { + name: localizedText('未平仓量与市值比率', 'Open Interest to Market Cap Ratio'), + detail: localizedText( + '未平仓币数 ÷ CMC 流通供应量,分子与分母按同一币种计量。这一项不参与综合信号投票。', + 'Open-interest token quantity divided by CMC circulating supply, measured in the same asset. This row does not vote in the composite signal.', + ), + }, +}); + +export function tradingText(key, locale) { + return formatLocalizedText(COPY[key], locale); +} + +export function tradingMetricText(id, key, locale) { + return formatLocalizedText(METRICS[id][key], locale); +} + +export function formatFundingPeriod(hours, locale) { + const period = hours === null + ? COPY.intervalPending + : localizedText(`${hours}小时`, `${hours}h`); + return `${tradingText('current', locale)} · ${formatLocalizedText(period, locale)}`; +} + +export function formatHistoryCount(id, count, locale) { + return formatLocalizedText(id === 'funding' + ? localizedText(`已结算 · ${count}次`, `Settled · ${count} points`) + : localizedText(`5分钟 · ${count}点`, `5m · ${count} points`), locale); +} + +export function formatVotes({ longCount, shortCount, total }, locale) { + const neutral = total - longCount - shortCount; + return formatLocalizedText(localizedText( + `指标票数:${longCount}多 · ${shortCount}空 · ${neutral}中性`, + `Votes: ${longCount} long · ${shortCount} short · ${neutral} neutral`, + ), locale); +} + +export function formatHistoryTime(timestamp, locale) { + return new Intl.DateTimeFormat(locale === 'zh-CN' ? 'zh-CN' : 'en-GB', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hourCycle: 'h23', timeZoneName: 'short', + }).format(timestamp); +} diff --git a/src/shared/data-panel-layout.js b/src/shared/data-panel-layout.js new file mode 100644 index 00000000..b590e368 --- /dev/null +++ b/src/shared/data-panel-layout.js @@ -0,0 +1,29 @@ +export const DATA_PANEL_LAYOUT_EVENT = 'jh-data-panels-layout-change'; + +const PANEL_IDS = Object.freeze({ + trading: 'jh-binance-trading-data-panel', + cmc: 'jh-binance-cmc-data-panel', +}); +const TWO_COLUMN_WIDTH = 480 + 500 + 48; + +export function hasVisibleDataPanelPeer(document, kind) { + const peer = document.getElementById(PANEL_IDS[kind === 'trading' ? 'cmc' : 'trading']); + return peer !== null && peer.style.display !== 'none'; +} + +/** Saved positions are already viewport-projected by the caller and remain authoritative. */ +export 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 }; +} diff --git a/test/dom/binance-cmc-panel-presentation.test.js b/test/dom/binance-cmc-panel-presentation.test.js new file mode 100644 index 00000000..86159ded --- /dev/null +++ b/test/dom/binance-cmc-panel-presentation.test.js @@ -0,0 +1,255 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; +import { JSDOM } from 'jsdom'; +import { + afterDataMediaResponseTurn, cmcDetail, completeCmcData, createDataMediaNetwork, installDataMediaClock, observeDom, +} from '../helpers/data-media-migration-host.js'; + +const entry = fileURLToPath(new URL('../../src/binance-coinmarketcap-data/index.user.js', import.meta.url)); +const bundle = await build({ + entryPoints: [entry], bundle: true, write: false, charset: 'utf8', format: 'iife', + platform: 'browser', target: ['es2020'], legalComments: 'none', +}); +const source = bundle.outputFiles[0].text; +const PANEL_ID = 'jh-binance-cmc-data-panel'; + +/** Reuse the independently checked GM transport and clock with the real source entrypoint. */ +function createCmcHost(t, { path = '/zh-CN/futures/BTCUSDT', storage = {} } = {}) { + const dom = new JSDOM('', { + url: `https://www.binance.com${path}`, runScripts: 'outside-only', pretendToBeVisual: true, + }); + const { window } = dom; + const clock = installDataMediaClock(t, window); + const network = createDataMediaNetwork(); + let hidden = false; + Object.defineProperty(window.document, 'hidden', { configurable: true, get: () => hidden }); + window.GM_xmlhttpRequest = network.gmRequest; + for (const [key, value] of Object.entries(storage)) window.localStorage.setItem(key, value); + const setHidden = value => { + hidden = value; + window.document.dispatchEvent(new window.Event('visibilitychange')); + }; + t.after(() => { setHidden(true); window.close(); }); + return { + window, document: window.document, network, clock, setHidden, + panel: () => window.document.getElementById(PANEL_ID), + element: suffix => window.document.getElementById(`${PANEL_ID}-${suffix}`), + navigate: path => window.history.pushState({}, '', path), + rendered: predicate => observeDom(window, () => predicate(window.document.getElementById(PANEL_ID))), + async start() { + if (window.document.readyState === 'loading') { + await new Promise(resolve => window.document.addEventListener('DOMContentLoaded', resolve, { once: true })); + } + new vm.Script(source, { filename: entry }).runInContext(dom.getInternalVMContext()); + }, + }; +} + +test('user sees unavailable CMC values instead of zero when the provider returns null or empty fields', { timeout: 5_000 }, async t => { + // Given a valid asset has explicitly empty statistics and optional metadata + const host = createCmcHost(t); + const detail = cmcDetail({ profileCompletionScore: null, holders: { total: null } }); + for (const key of Object.keys(detail.statistics)) detail.statistics[key] = key === 'price' ? '' : null; + await host.start(); + + // When the API and holder endpoint return their missing values + await completeCmcData(host, detail, { holder: { showFlag: true, count: null } }); + + // Then no absent field becomes a zero price, change, ratio, score, or rank + assert.doesNotMatch(host.element('rows').textContent, /\$0|0\.00%/); + assert.match(host.element('rows').textContent, /--/); + assert.equal(host.element('symbol').textContent, 'BTC'); +}); + +test('user can read calculated CMC interpretations and expand their basis in a three-column table', { timeout: 5_000 }, async t => { + // Given CMC reports a positive daily move and a fully specified valuation snapshot + const host = createCmcHost(t); + const detail = cmcDetail(); + Object.assign(detail.statistics, { + price: 2, priceChangePercentage24h: 12.5, marketCap: 200_000_000, + fullyDilutedMarketCap: 500_000_000, volume24h: 150_000_000, turnover: 0.75, + totalSupply: 250_000_000, circulatingSupply: 100_000_000, + }); + await host.start(); + + // When the current asset snapshot is rendered through the userscript entrypoint + await completeCmcData(host, detail); + + // Then every metric has a value and interpretation and the supply comparison uses the response + assert.deepEqual([...host.panel().querySelectorAll('thead th')].map(node => node.textContent), ['指标', '数值', '解读']); + assert.equal(host.element('rows').querySelectorAll('tr[data-metric]').length, 12); + const fdv = host.element('rows').querySelector('[data-metric="fdv"]'); + assert.match(fdv.textContent, /2\.50倍/); + const supply = host.element('rows').querySelector('[data-metric="circulating-supply"]'); + assert.match(supply.textContent, /40\.0%/); + const button = supply.querySelector('button'); + assert.equal(button.getAttribute('aria-expanded'), 'false'); + + // When the user opens the explanation for circulating supply + button.click(); + + // Then the associated explanation becomes readable without issuing a data request + const explanation = host.document.getElementById(button.getAttribute('aria-controls')); + assert.equal(button.getAttribute('aria-expanded'), 'true'); + assert.equal(explanation.hidden, false); + assert.match(explanation.textContent, /40\.0%/); + assert.equal(host.network.requests.length, 3); +}); + +for (const locale of ['zh-CN', 'en']) { + test(`user sees CMC loading and mapping errors in ${locale} with their original cause`, { timeout: 5_000 }, async t => { + // Given the supported futures route selects one of the panel languages + const host = createCmcHost(t, { path: `/${locale}/futures/BTCUSDT` }); + await host.start(); + assert.equal(host.panel().lang, locale); + assert.equal(host.element('rows').textContent, locale === 'zh-CN' ? '正在读取 CoinMarketCap...' : 'Loading CoinMarketCap...'); + + // When no active CMC asset can be mapped to the current contract + host.network.requests[0].respond({ data: [] }); + await host.rendered(() => host.element('rows').textContent.includes('CMC symbol not found: BTC')); + + // Then the visible wrapper and controls use the selected language without losing the cause + assert.equal(host.element('rows').textContent, `${locale === 'zh-CN' ? '读取失败' : 'Unable to load data'}CMC symbol not found: BTC`); + assert.equal(host.element('refresh').title, locale === 'zh-CN' ? '刷新' : 'Refresh'); + assert.equal(host.element('close').getAttribute('aria-label'), locale === 'zh-CN' ? '关闭' : 'Close'); + assert.equal(host.element('footer').textContent, locale === 'zh-CN' ? '来源:CoinMarketCap' : 'Source: CoinMarketCap'); + assert.equal(host.network.requests.length, 1); + }); +} + +test('user switches the complete CMC panel language and number units while retaining source timestamps', { timeout: 5_000 }, async t => { + // Given a Chinese CMC snapshot is visible with a known data timestamp and saved panel preferences + const storage = { jh_binance_cmc_data_pos: '{"left":100,"top":80}', jh_binance_cmc_data_collapsed: '0' }; + const host = createCmcHost(t, { storage }); + await host.start(); + await completeCmcData(host); + const dataTime = host.element('footer').querySelector('.cmc-times > span').textContent; + host.clock.tick(1_000); + + // When an English route refreshes the same asset through its cached deterministic mapping + host.navigate('/en/futures/BTCUSDT'); + assert.equal(host.element('rows').textContent, 'Loading CoinMarketCap...'); + await completeCmcData(host, cmcDetail(), { map: false }); + + // Then all text, units, controls, and source links are English but the reported CMC time is unchanged + assert.deepEqual([...host.panel().querySelectorAll('thead th')].map(node => node.textContent), ['Metric', 'Value', 'Interpretation']); + assert.equal(host.panel().querySelector('.cmc-title').textContent, 'CMC data'); + assert.equal(host.element('rows').querySelector('[data-metric="price"] [data-role="metric-value"]').textContent, '$60K'); + assert.equal(host.element('rows').querySelector('[data-metric="total-supply"] .cmc-number').textContent, '21M'); + assert.doesNotMatch(host.element('rows').textContent, /\p{Script=Han}/u); + assert.equal(host.element('refresh').title, 'Refresh'); + assert.equal(host.element('collapse').title, 'Collapse'); + assert.equal(host.element('close').title, 'Close'); + assert.match(host.element('rows').querySelector('button').getAttribute('aria-label'), /View interpretation basis/); + assert.equal(host.element('footer').querySelector('a').href, 'https://coinmarketcap.com/currencies/bitcoin/'); + assert.equal(host.element('footer').querySelector('.cmc-times > span').textContent, dataTime); + assert.match(host.element('footer').textContent, /Fetched .*Refreshes every 30s/); + assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/map')).length, 1); + assert.equal(host.window.localStorage.getItem('jh_binance_cmc_data_pos'), storage.jh_binance_cmc_data_pos); + assert.equal(host.window.localStorage.getItem('jh_binance_cmc_data_collapsed'), '0'); + + // When the user returns to the Chinese route and collapses the panel + host.navigate('/zh-CN/futures/BTCUSDT'); + await completeCmcData(host, cmcDetail(), { map: false }); + host.element('collapse').click(); + + // Then Chinese units return and collapse affects only its own stored preference + assert.equal(host.element('rows').querySelector('[data-metric="price"] .cmc-number').textContent, '$6万'); + assert.equal(host.element('rows').querySelector('[data-metric="total-supply"] .cmc-number').textContent, '2100万'); + assert.equal(host.element('body').style.display, 'none'); + assert.equal(host.element('collapse').title, '展开'); + assert.equal(host.element('collapse').getAttribute('aria-expanded'), 'false'); + assert.equal(host.window.localStorage.getItem('jh_binance_cmc_data_collapsed'), '1'); + assert.equal(host.window.localStorage.getItem('jh_binance_cmc_data_pos'), storage.jh_binance_cmc_data_pos); +}); + +test('user sees an English page-snapshot source and treasury unit after an API failure', { timeout: 5_000 }, async t => { + // Given the documented RAVE mapping does not need a symbol map request + const host = createCmcHost(t, { path: '/futures/RAVEUSDT' }); + const detail = cmcDetail({ id: 38967, symbol: 'RAVE', showTreasuriesFlag: true, treasuryHoldings: 1200, latestUpdateTime: '2026-09-15T22:00:00Z' }); + await host.start(); + + // When the API fails and the localized page provides its older snapshot + host.network.requests[0].respond({}, 503); + const page = await host.network.waitForRequest(request => request.url.hostname === 'coinmarketcap.com'); + page.respond(``); + await host.rendered(() => host.element('footer').textContent.includes('CMC page snapshot')); + + // Then the source remains a page snapshot and treasury quantity is not mislabeled as addresses + assert.equal(page.url.href, 'https://coinmarketcap.com/currencies/ravedao/'); + assert.equal(host.element('footer').querySelector('a').textContent, 'CMC page snapshot'); + assert.equal(host.element('footer').querySelector('a').href, page.url.href); + const treasury = host.element('rows').querySelector('[data-metric="treasury"]'); + assert.equal(treasury.querySelector('.cmc-number').textContent, '1.2K'); + assert.equal(treasury.querySelector('.cmc-unit').textContent, 'RAVE'); + assert.match(treasury.textContent, /Treasury token holdings/); + assert.equal(host.network.requests.length, 2); + const sourceClock = new host.window.Date(detail.latestUpdateTime).toTimeString().slice(0, 8); + assert.equal(host.element('footer').querySelector('.cmc-times > span').textContent, `CMC ${sourceClock}`); +}); + +test('user retains an expanded CMC explanation across the thirty-second refresh without duplicate requests', { timeout: 5_000 }, async t => { + // Given a loaded CMC panel has its FDV explanation expanded + const host = createCmcHost(t, { path: '/en/futures/BTCUSDT' }); + await host.start(); + await completeCmcData(host); + host.element('rows').querySelector('[data-expand-metric="fdv"]').click(); + const previousRows = host.element('rows').innerHTML; + host.clock.tick(29_999); + assert.equal(host.network.requests.length, 3); + + // When the unchanged thirty-second schedule starts its next snapshot + host.clock.tick(1); + const pending = await host.network.waitForRequest(request => !request.settled && request.url.pathname.endsWith('/detail')); + assert.equal(host.element('rows').innerHTML, previousRows); + host.clock.tick(30_000); + assert.equal(host.network.requests.filter(request => !request.settled).length, 1); + const detail = cmcDetail(); + detail.statistics.price = 65_000; + pending.respond({ data: detail }); + const holder = await host.network.waitForRequest(request => !request.settled && request.url.pathname.endsWith('/show_holders')); + holder.respond({ data: { showFlag: true, count: 10_000 } }); + await host.rendered(() => host.element('rows').querySelector('[data-metric="price"] .cmc-number').textContent === '$65K'); + + // Then the latest value renders while the selected explanation remains expanded + const button = host.element('rows').querySelector('[data-expand-metric="fdv"]'); + assert.equal(button.getAttribute('aria-expanded'), 'true'); + assert.equal(host.document.getElementById(button.getAttribute('aria-controls')).hidden, false); + assert.equal(host.network.requests.length, 5); + + // When the user expands another metric and then closes that explanation + const supply = host.element('rows').querySelector('[data-expand-metric="total-supply"]'); + supply.click(); + assert.equal(button.getAttribute('aria-expanded'), 'false'); + supply.click(); + + // Then both disclosures close without changing the data-fetch schedule + assert.equal(supply.getAttribute('aria-expanded'), 'false'); + assert.equal(host.document.getElementById(supply.getAttribute('aria-controls')).hidden, true); + assert.equal(host.network.requests.length, 5); +}); + +test('user keeps new-language CMC rows after the superseded symbol mapping fails', { timeout: 5_000 }, async t => { + // Given a Chinese Bitcoin lookup is pending when the user opens English Ethereum + const host = createCmcHost(t); + await host.start(); + const oldMapping = host.network.requests[0]; + host.navigate('/en/futures/ETHUSDT'); + await completeCmcData(host, cmcDetail({ id: 2, symbol: 'ETH' }), { symbol: 'ETH', slug: 'ethereum' }); + const rows = host.element('rows').innerHTML; + const footer = host.element('footer').innerHTML; + + // When the original Bitcoin lookup reports a late error + oldMapping.fail('error'); + await afterDataMediaResponseTurn(); + + // Then the old epoch cannot replace the English asset, interpretations, or source + assert.equal(host.element('rows').innerHTML, rows); + assert.equal(host.element('footer').innerHTML, footer); + assert.equal(host.element('symbol').textContent, 'ETH #1'); + assert.equal(host.panel().lang, 'en'); + assert.equal(host.element('footer').querySelector('a').href, 'https://coinmarketcap.com/currencies/ethereum/'); +}); diff --git a/test/dom/binance-data-panels-entrypoint.test.js b/test/dom/binance-data-panels-entrypoint.test.js index ff334d7d..a317b6cf 100644 --- a/test/dom/binance-data-panels-entrypoint.test.js +++ b/test/dom/binance-data-panels-entrypoint.test.js @@ -1,21 +1,21 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - createDataPanelHost, tradingDataset, completeTradingBatch, cmcDetail, completeCmcData, + createDataPanelHost, tradingDataset, completeTradingBatch, cmcDetail, completeCmcData, afterDataMediaResponseTurn, } from '../helpers/data-media-migration-host.js'; async function activateTrading(host, dataset = tradingDataset(Date.now()), options) { const time = await host.network.waitForRequest(request => !request.settled && request.url.pathname.endsWith('/time')); time.respond({ serverTime: Date.now() }); await completeTradingBatch(host, dataset, options); - await host.rendered(panel => panel?.querySelector('[data-role="updated-at"]')?.textContent.startsWith('更新于')); + await afterDataMediaResponseTurn(); } for (const scenario of [ - { name: 'long', values: {}, oi: '2.00M ▲', composite: '偏多 7:0' }, - { name: 'short', values: { oi: 2_000, previousOi: 4_000, ratio: 0.5, basis: -0.01, funding: 0.0002 }, oi: '2K ▼', composite: '偏空 0:7' }, - { name: 'neutral', values: { oi: 50, previousOi: 50, ratio: 1, basis: 0, funding: 0, supply: 0 }, oi: '50.00', composite: '中性 0:0' }, - { name: 'billion', values: { oi: 2_000_000_000 }, oi: '2.00B ▲', composite: '偏多 7:0' }, + { name: 'long', values: {}, oi: '200万 ▲', composite: '偏多 7:0' }, + { name: 'short', values: { oi: 2_000, previousOi: 4_000, ratio: 0.5, basis: -0.01, funding: 0.0002 }, oi: '2000 ▼', composite: '偏空 0:7' }, + { name: 'neutral', values: { oi: 50, previousOi: 50, ratio: 1, basis: 0, funding: 0, supply: 0 }, oi: '50', composite: '中性 0:0' }, + { name: 'billion', values: { oi: 2_000_000_000 }, oi: '20亿 ▲', composite: '偏多 7:0' }, ]) { test(`user sees ${scenario.name} indicators and the corresponding fresh directional votes`, { timeout: 5_000 }, async t => { // Given a futures page with complete data for one directional scenario @@ -49,10 +49,10 @@ test('user sees missing endpoint data explicitly and receives no retry for clien await activateTrading(host, dataset, { status: statuses }); // Then missing rows remain visible and cannot contribute directional votes - assert.equal([...host.element('rows').children].every(row => row.children[1].textContent === '--'), true); + assert.deepEqual([...host.element('rows').querySelectorAll('.td-number')].map(value => value.textContent), Array(8).fill('--')); assert.match(host.element('composite').textContent, /中性 0:0/); - assert.equal(host.network.requests.length, 8); - assert.equal(host.errors.length, 7); + assert.equal(host.network.requests.length, 10); + assert.equal(host.errors.length, 9); }); test('user sees cached rows without their directional votes after a failed refresh', { timeout: 5_000 }, async t => { @@ -69,8 +69,8 @@ test('user sees cached rows without their directional votes after a failed refre await host.rendered(() => host.element('composite').textContent.includes('中性 0:0')); // Then cached values are retained with hollow markers and no fresh votes - assert.match(host.element('rows').textContent, /2\.00M ▲/); - assert.equal([...host.element('rows').children].every(row => row.lastElementChild.style.background === 'transparent'), true); + assert.match(host.element('rows').textContent, /200万 ▲/); + assert.equal([...host.element('rows').children].every(row => row.classList.contains('td-cached')), true); assert.match(host.element('composite').textContent, /中性 0:0/); }); @@ -89,7 +89,7 @@ test('user receives one retry for transient endpoint errors and then sees fresh await host.rendered(panel => panel?.querySelector('[data-role="updated-at"]')?.textContent.startsWith('更新于')); // Then the rendered value is fresh and exactly two calls targeted that endpoint - assert.match(host.element('rows').textContent, /2\.00M ▲/); + assert.match(host.element('rows').textContent, /200万 ▲/); assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/openInterestHist')).length, 2); assert.match(host.element('composite').textContent, /偏多 7:0/); }); @@ -109,7 +109,7 @@ test('user sees the current period fetched only after the server boundary delay' await host.rendered(() => host.element('composite').textContent.includes('偏空 3:4')); // Then one new batch updates the votes and highlights the changed ratios - assert.equal(host.network.requests.length, previousCount + 7); + assert.equal(host.network.requests.length, previousCount + 8); assert.match(host.element('composite').textContent, /偏空 3:4/); assert.equal(host.element('rows').querySelectorAll('.jh-td-flash').length, 4); }); @@ -206,10 +206,11 @@ test('user sees CMC API provenance and all valuation rows after a deterministic // Then values, ranking, holder count, and source appear in the real panel DOM assert.equal(host.element('symbol').textContent, 'BTC #1'); - assert.equal(host.element('rows').children.length, 12); - assert.match(host.element('rows').textContent, /流通市值\$1\.2万亿-1\.00%/); - assert.match(host.element('rows').textContent, /持有者1万/); - assert.match(host.element('rows').textContent, /Profile score85%/); + assert.equal(host.element('rows').querySelectorAll('tr[data-metric]').length, 12); + assert.equal(host.element('rows').querySelector('[data-metric="market-cap"] [data-role="metric-value"]').textContent, '$1.2万亿'); + assert.equal(host.element('rows').querySelector('[data-metric="market-cap"] .cmc-change').textContent, '-1.00% · 24小时'); + assert.equal(host.element('rows').querySelector('[data-metric="holders"] [data-role="metric-value"]').textContent, '1万'); + assert.equal(host.element('rows').querySelector('[data-metric="profile"] [data-role="metric-value"]').textContent, '85%'); assert.equal(host.element('footer').querySelector('a').href, 'https://coinmarketcap.com/zh/currencies/bitcoin/'); assert.match(host.element('footer').textContent, /CMC data-api/); }); @@ -251,11 +252,12 @@ for (const outcome of ['http', 'network', 'timeout', 'json', 'statistics']) { else api.respond({ data: {} }); const page = await host.network.waitForRequest(request => request.url.hostname === 'coinmarketcap.com'); page.respond(``); - await host.rendered(() => host.element('footer').textContent.includes('CMC 页面快照')); + await host.rendered(() => host.element('footer').textContent.includes('CMC page snapshot')); // Then the source remains visibly a page snapshot and the override bypasses mapping - assert.equal(host.element('footer').querySelector('a').href, 'https://coinmarketcap.com/zh/currencies/ravedao/'); - assert.match(host.element('rows').textContent, /金库资产1200 RAVE/); + assert.equal(host.element('footer').querySelector('a').href, 'https://coinmarketcap.com/currencies/ravedao/'); + assert.equal(host.element('rows').querySelector('[data-metric="treasury"] [data-role="metric-value"]').textContent, '1.2K'); + assert.equal(host.element('rows').querySelector('[data-metric="treasury"] .cmc-unit').textContent, 'RAVE'); assert.equal(host.network.requests.length, 2); }); } @@ -279,6 +281,6 @@ test('user can refresh CMC data from the cached asset mapping without losing the // Then the new value appears and no second map request was made assert.match(host.element('rows').textContent, /价格-\$123/); assert.equal(host.element('symbol').textContent, 'BTC'); - assert.match(host.element('rows').textContent, /Profile score95%/); + assert.equal(host.element('rows').querySelector('[data-metric="profile"] [data-role="metric-value"]').textContent, '95%'); assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/map')).length, 1); }); diff --git a/test/dom/binance-data-panels-responses.test.js b/test/dom/binance-data-panels-responses.test.js index 727575f7..5f8752b2 100644 --- a/test/dom/binance-data-panels-responses.test.js +++ b/test/dom/binance-data-panels-responses.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { activateTradingData, afterDataMediaResponseTurn, completeCmcData, completeTradingBatch, - createDataPanelHost, cmcDetail, tradingDataset, + createDataPanelHost, cmcDetail, tradingDataset, isTradingHistoryRequest, } from '../helpers/data-media-migration-host.js'; for (const scenario of [ @@ -62,7 +62,7 @@ test('user keeps valid CMC valuation data when its detail cannot identify a hold // Then the existing count and valuation rows remain usable without an invalid-ID request assert.match(host.element('rows').textContent, /价格\$6万/); - assert.match(host.element('rows').textContent, /持有者17/); + assert.equal(host.element('rows').querySelector('[data-metric="holders"] [data-role="metric-value"]').textContent, '17'); assert.equal(host.network.requests.length, 2); assert.equal(host.network.requests.some(request => request.url.pathname.endsWith('/show_holders')), false); }); @@ -108,9 +108,9 @@ for (const scenario of [ await host.rendered(() => host.element('rows').textContent.includes(scenario.error)); // Then the original page failure reason remains visible to the user - assert.equal(host.element('rows').textContent, `读取失败${scenario.error}`); + assert.equal(host.element('rows').textContent, `Unable to load data${scenario.error}`); assert.equal(host.network.requests.length, 2); - assert.equal(host.element('footer').textContent, '来源:CoinMarketCap 中文页'); + assert.equal(host.element('footer').textContent, 'Source: CoinMarketCap'); }); } @@ -126,12 +126,13 @@ for (const holderKey of ['total', 'count']) { await completeCmcData(host, detail, { holder: { showFlag: false } }); // Then unit formatting, negative signs, unavailable fields, and fallback holder identity are preserved - const text = host.element('rows').textContent; - assert.match(text, /总供应量1\.2万亿 BTC/); - assert.match(text, /最大供应量3亿 BTC/); - assert.match(text, /流通供应量-2万 BTC/); - assert.match(text, /持有者42/); - assert.match(text, /Profile score--/); + const rows = host.element('rows'); + for (const [id, expected] of [['total-supply', '1.2万亿'], ['max-supply', '3亿'], ['circulating-supply', '-2万']]) { + assert.equal(rows.querySelector(`[data-metric="${id}"] [data-role="metric-value"]`).textContent, expected); + assert.equal(rows.querySelector(`[data-metric="${id}"] .cmc-unit`).textContent, 'BTC'); + } + assert.equal(rows.querySelector('[data-metric="holders"] [data-role="metric-value"]').textContent, '42'); + assert.equal(rows.querySelector('[data-metric="profile"] [data-role="metric-value"]').textContent, '--'); assert.match(host.element('footer').textContent, /CMC -- \/ 拉取/); }); } @@ -147,7 +148,7 @@ test('user sees explicit unavailable metrics when an otherwise valid CMC respons // Then unavailable values stay visible instead of becoming directional changes or guessed quantities assert.equal(host.element('symbol').textContent, 'BTC'); - assert.equal([...host.element('rows').children].every(row => row.children[1].textContent === '--'), true); + assert.deepEqual([...host.element('rows').querySelectorAll('[data-role="metric-value"]')].map(value => value.textContent), Array(12).fill('--')); assert.match(host.element('footer').textContent, /CMC -- \/ 拉取/); }); @@ -163,8 +164,8 @@ test('user retains CMC detail rows when the optional holder endpoint times out', await host.rendered(() => host.element('footer').textContent.includes('CMC data-api')); // Then the successful valuation details remain visible with an unavailable holder metric - assert.match(host.element('rows').textContent, /流通市值\$1\.2万亿/); - assert.match(host.element('rows').textContent, /持有者--/); + assert.equal(host.element('rows').querySelector('[data-metric="market-cap"] [data-role="metric-value"]').textContent, '$1.2T'); + assert.equal(host.element('rows').querySelector('[data-metric="holders"] [data-role="metric-value"]').textContent, '--'); assert.equal(host.element('symbol').textContent, 'RAVE #1'); }); @@ -210,7 +211,7 @@ for (const outcome of ['http', 'network']) { await host.rendered(panel => panel?.querySelector('[data-role="updated-at"]')?.textContent.startsWith('更新于')); // Then that row stays unavailable while unrelated successful endpoints retain their votes - assert.equal(host.element('rows').firstElementChild.children[1].textContent, '--'); + assert.equal(host.element('rows').querySelector('[data-metric="oi"] .td-number').textContent, '--'); assert.match(host.element('composite').textContent, /偏多 6:0/); assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/openInterestHist')).length, 2); }); @@ -242,7 +243,7 @@ test('user sees no fabricated open-interest trend before enough history is avail await activateTradingData(host, dataset); // Then the quantity has no directional arrow and only the other six indicators vote - assert.equal(host.element('rows').firstElementChild.children[1].textContent, '1.00M'); + assert.equal(host.element('rows').querySelector('[data-metric="oi"] .td-number').textContent, '100万'); assert.match(host.element('composite').textContent, /偏多 6:0/); }); @@ -258,13 +259,13 @@ test('user retries only delayed period endpoints after the publication grace int mixed.takerlongshortRatio = old.takerlongshortRatio; await completeTradingBatch(host, mixed); await afterDataMediaResponseTurn(); - const count = host.network.requests.length; + const count = host.network.requests.filter(isTradingHistoryRequest).length; // When the first retry deadline arrives and the two delayed endpoints catch up host.clock.tick(9_999); - assert.equal(host.network.requests.length, count); + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, count); host.clock.tick(1); - const pending = host.network.requests.filter(request => !request.settled); + const pending = host.network.requests.filter(request => !request.settled && isTradingHistoryRequest(request)); const current = tradingDataset(Date.now()); pending.forEach(request => request.respond(current[request.url.pathname.split('/').at(-1)])); await afterDataMediaResponseTurn(); @@ -275,8 +276,8 @@ test('user retries only delayed period endpoints after the publication grace int assert.match(host.element('composite').textContent, /偏多 7:0/); }); -test('user keeps valid trading metrics visible while retrying their missing publication timestamp', { timeout: 5_000 }, async t => { - // Given the basis endpoint supplies a valid metric without the current period timestamp +test('user excludes an untimestamped trading metric until its dated publication arrives', { timeout: 5_000 }, async t => { + // Given the basis endpoint omits the timestamp needed for a historical observation const host = createDataPanelHost(t, 'trading'); const dataset = tradingDataset(Date.now()); delete dataset.basis[0].timestamp; @@ -285,21 +286,22 @@ test('user keeps valid trading metrics visible while retrying their missing publ host.clock.tick(5_000); await completeTradingBatch(host, dataset); await afterDataMediaResponseTurn(); - const count = host.network.requests.length; - assert.match(host.element('composite').textContent, /偏多 7:0/); + const count = host.network.requests.filter(isTradingHistoryRequest).length; + assert.match(host.element('composite').textContent, /偏多 6:0/); + assert.equal(host.element('rows').querySelector('[data-metric="basis"] .td-number').textContent, '--'); // When only the timestamp-less endpoint reaches its retry deadline and publishes a fresh value host.clock.tick(9_999); - assert.equal(host.network.requests.length, count); + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, count); host.clock.tick(1); - const pending = host.network.requests.filter(request => !request.settled); + const pending = host.network.requests.filter(request => !request.settled && isTradingHistoryRequest(request)); assert.deepEqual(pending.map(request => request.url.pathname.split('/').at(-1)), ['basis']); pending[0].respond([{ timestamp: Date.now(), basisRate: '-0.02' }]); await afterDataMediaResponseTurn(); // Then the fresh basis value changes its vote without retrying already current endpoints assert.match(host.element('composite').textContent, /偏多 6:1/); - assert.equal(host.network.requests.length, count + 1); + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, count + 1); assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/fundingRate')).length, 2); }); @@ -316,11 +318,11 @@ test('user receives the documented bounded retry schedule while period data rema // When each explicit retry deadline is reached without advancing endpoint timestamps for (const delay of [10_000, 15_000, 20_000, 30_000]) { - const before = host.network.requests.length; + const before = host.network.requests.filter(isTradingHistoryRequest).length; host.clock.tick(delay - 1); - assert.equal(host.network.requests.length, before); + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, before); host.clock.tick(1); - const requests = host.network.requests.filter(request => !request.settled); + const requests = host.network.requests.filter(request => !request.settled && isTradingHistoryRequest(request)); observed.push({ delay, count: requests.length }); requests.forEach(request => request.respond(stale[request.url.pathname.split('/').at(-1)])); await afterDataMediaResponseTurn(); @@ -342,19 +344,19 @@ test('user stops retrying an expired period and resumes at the next publication await completeTradingBatch(host, stale); await afterDataMediaResponseTurn(); host.clock.tick(10_000); - host.network.requests.filter(request => !request.settled).forEach(request => request.respond(stale[request.url.pathname.split('/').at(-1)])); + host.network.requests.filter(request => !request.settled && isTradingHistoryRequest(request)).forEach(request => request.respond(stale[request.url.pathname.split('/').at(-1)])); await afterDataMediaResponseTurn(); - const count = host.network.requests.length; + const count = host.network.requests.filter(isTradingHistoryRequest).length; // When the old window closes and the next boundary's five-second grace elapses host.clock.tick(14_999); - assert.equal(host.network.requests.length, count); + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, count); host.clock.tick(1); await completeTradingBatch(host, tradingDataset(Date.now())); await afterDataMediaResponseTurn(); // Then one complete new-period batch replaces further retries of the expired window - assert.equal(host.network.requests.length, count + 7); + assert.equal(host.network.requests.filter(isTradingHistoryRequest).length, count + 7); assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/fundingRate')).length, 3); assert.match(host.element('composite').textContent, /偏多 7:0/); }); diff --git a/test/dom/binance-trading-data-funding.test.js b/test/dom/binance-trading-data-funding.test.js new file mode 100644 index 00000000..299f0d90 --- /dev/null +++ b/test/dom/binance-trading-data-funding.test.js @@ -0,0 +1,252 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + activateTradingData, afterDataMediaResponseTurn, completeTradingBatch, + createDataPanelHost, tradingDataset, +} from '../helpers/data-media-migration-host.js'; + +const endpoint = request => request.url.pathname.split('/').at(-1); +const read = (host, role) => host.panel().querySelector(`[data-role="${role}"]`); +const historyRequests = host => host.network.requests.filter(request => + request.url.pathname.startsWith('/futures/data/') || endpoint(request) === 'fundingRate'); + +test('user receives historical metrics while funding interval metadata is still pending', { timeout: 5_000 }, async t => { + // Given all market endpoints are available except the optional interval metadata + const host = createDataPanelHost(t, 'trading'); + const dataset = tradingDataset(Date.now()); + await host.start(); + host.network.requests[0].respond({ serverTime: Date.now() }); + await host.network.waitForRequest(request => endpoint(request) === 'fundingRate'); + + // When history and the current rate complete before fundingInfo + for (const request of host.network.requests.filter(request => !request.settled && endpoint(request) !== 'fundingInfo')) { + request.respond(dataset[endpoint(request)]); + } + await afterDataMediaResponseTurn(); + + // Then the market panel renders independently while the interval stays explicitly unknown + assert.equal(host.panel().querySelector('[data-metric="oi"] .td-number').textContent, '200万 ▲'); + assert.equal(read(host, 'current-funding').textContent, '0.00378%'); + assert.equal(read(host, 'funding-period').textContent, '当前 · 周期待确认'); + assert.match(read(host, 'updated-at').textContent, /^更新于 /); + + // When the optional metadata exceeds its deadline and the next historical cycle begins + const interval = host.network.requests.find(request => endpoint(request) === 'fundingInfo'); + host.clock.tick(9_999); + assert.equal(interval.settled, false); + host.clock.tick(1); + await afterDataMediaResponseTurn(); + assert.equal(interval.aborted, true); + assert.equal(read(host, 'funding-interval-status').textContent, '周期加载失败'); + host.clock.tick(295_000); + await host.network.waitForRequest(request => !request.settled && endpoint(request) === 'fundingRate'); + + // Then interval failure cannot prevent the next normal historical refresh + assert.equal(historyRequests(host).length, 14); +}); + +test('user sees first-request failures without a fabricated cache label', { timeout: 5_000 }, async t => { + // Given a new panel has no cached history or interval metadata + const host = createDataPanelHost(t, 'trading'); + await host.start(); + const dataset = tradingDataset(Date.now()); + const status = Object.fromEntries(Object.keys(dataset).map(key => [key, 403])); + + // When the initial endpoint requests all fail definitively + await activateTradingData(host, dataset, { status }); + + // Then missing data is labelled as a load failure and no row claims an existing cache + assert.equal(host.panel().querySelector('[data-metric="oi"] .td-data-status').textContent, '加载失败'); + assert.equal(read(host, 'funding-interval-status').textContent, '周期加载失败'); + assert.doesNotMatch(host.element('rows').textContent, /缓存/); + assert.equal(read(host, 'updated-at').textContent, '等待数据'); +}); + +test('user sees an uncalibrated countdown after a high-latency server time sample', { timeout: 5_000 }, async t => { + // Given server time was sampled before a three-second response delay + const host = createDataPanelHost(t, 'trading'); + await host.start(); + const sampledTime = Date.now(); + const time = host.network.requests[0]; + + // When the delayed sample arrives and the current market data completes + host.clock.tick(3_000); + time.respond({ serverTime: sampledTime }); + await completeTradingBatch(host, tradingDataset(Date.now())); + await afterDataMediaResponseTurn(); + + // Then current data remains available without certifying the stale clock sample + assert.equal(read(host, 'current-funding').textContent, '0.00378%'); + assert.equal(read(host, 'funding-countdown').textContent, '倒计时 校时不可用'); + assert.equal(historyRequests(host).length, 7); +}); + +for (const hours of [1, 4, 8, null]) { + test(`user sees the reported ${hours === null ? 'unknown' : hours + '-hour'} funding period`, { timeout: 5_000 }, async t => { + // Given period metadata either identifies the symbol or omits it explicitly + const host = createDataPanelHost(t, 'trading'); + const data = tradingDataset(Date.now(), { intervalHours: hours, funding: 0.00000029 }); + await host.start(); + + // When both current funding and settled history arrive + await activateTradingData(host, data); + + // Then the reported period and both distinct rate values remain visible + assert.equal(read(host, 'funding-period').textContent, hours === null ? '当前 · 周期待确认' : `当前 · ${hours}小时`); + assert.equal(read(host, 'current-funding').textContent, '0.00378%'); + assert.equal(host.panel().querySelector('.td-last-value').textContent, '0.000029%'); + assert.equal(read(host, 'funding-interval-status').hidden, true); + }); +} + +test('user refreshes current funding without replacing history or resetting its update time', { timeout: 5_000 }, async t => { + // Given a displayed history and its original receipt time have been recorded + const host = createDataPanelHost(t, 'trading'); + await host.start(); + await activateTradingData(host, tradingDataset(Date.now(), { funding: 0.00000029 })); + const history = host.panel().querySelector('[data-metric="funding"] svg'); + const updatedAt = read(host, 'updated-at'); + const originalTime = updatedAt.textContent; + + // When the independent fifteen-second current-rate refresh completes + host.clock.tick(14_999); + assert.equal(host.network.requests.filter(request => endpoint(request) === 'premiumIndex').length, 1); + host.clock.tick(1); + const pending = await host.network.waitForRequest(request => !request.settled && endpoint(request) === 'premiumIndex'); + pending.respond({ symbol: 'BTCUSDT', lastFundingRate: '0.00008', time: Date.now(), nextFundingTime: Date.now() + 3_600_000 }); + await afterDataMediaResponseTurn(); + + // Then only the current value and age change while history nodes and provenance remain intact + assert.equal(read(host, 'current-funding').textContent, '0.008%'); + assert.equal(read(host, 'funding-countdown').textContent, '倒计时 01:00:00'); + assert.equal(host.panel().querySelector('[data-metric="funding"] svg'), history); + assert.equal(host.panel().querySelector('.td-last-value').textContent, '0.000029%'); + assert.equal(read(host, 'updated-at'), updatedAt); + assert.equal(updatedAt.textContent, originalTime); + assert.equal(read(host, 'elapsed').textContent, '15秒前'); + assert.equal(historyRequests(host).length, 7); +}); + +test('user stops displaying a current quote when its settlement boundary expires', { timeout: 5_000 }, async t => { + // Given the authoritative next settlement is two seconds away + const host = createDataPanelHost(t, 'trading'); + const data = tradingDataset(Date.now(), { funding: 0.00000029 }); + data.premiumIndex.nextFundingTime = Date.now() + 2_000; + await host.start(); + await activateTradingData(host, data); + + // When the displayed server clock reaches that settlement boundary + host.clock.tick(1_999); + assert.equal(read(host, 'current-funding').textContent, '0.00378%'); + host.clock.tick(1); + + // Then the panel waits for a new current quote without inventing another settlement or changing history + assert.equal(read(host, 'current-funding').textContent, '--'); + assert.equal(read(host, 'funding-countdown').textContent, '倒计时 等待更新'); + assert.equal(host.panel().querySelector('.td-last-value').textContent, '0.000029%'); + assert.equal(historyRequests(host).length, 7); +}); + +test('user retains a labelled current-rate cache through failure and timeout and recovers on refresh', { timeout: 5_000 }, async t => { + // Given a successful current quote exists separately from historical market data + const host = createDataPanelHost(t, 'trading'); + await host.start(); + await activateTradingData(host); + + // When a definite client error is followed by a request that exceeds its ten-second deadline + host.clock.tick(15_000); + let pending = await host.network.waitForRequest(request => !request.settled && endpoint(request) === 'premiumIndex'); + pending.respond({}, 403); + await afterDataMediaResponseTurn(); + assert.equal(read(host, 'funding-status').textContent, '缓存 · 更新失败'); + assert.match(read(host, 'funding-status').title, /HTTP 403/); + host.clock.tick(15_000); + pending = await host.network.waitForRequest(request => !request.settled && endpoint(request) === 'premiumIndex'); + host.clock.tick(9_999); + assert.equal(pending.settled, false); + host.clock.tick(1); + await afterDataMediaResponseTurn(); + + // Then cancellation preserves the known rate with an explicit timeout and leaves history untouched + assert.equal(pending.aborted, true); + assert.equal(read(host, 'current-funding').textContent, '0.00378%'); + assert.match(read(host, 'funding-status').title, /Funding request timed out/); + assert.equal(historyRequests(host).length, 7); + + // When the next scheduled request succeeds + host.clock.tick(15_000); + pending = await host.network.waitForRequest(request => !request.settled && endpoint(request) === 'premiumIndex'); + pending.respond({ symbol: 'BTCUSDT', lastFundingRate: '0', time: Date.now(), nextFundingTime: Date.now() + 60_000 }); + await afterDataMediaResponseTurn(); + + // Then a genuine zero is shown with a cleared cache marker + assert.equal(read(host, 'current-funding').textContent, '0%'); + assert.equal(read(host, 'funding-status').hidden, true); + assert.equal(host.network.requests.filter(request => endpoint(request) === 'premiumIndex').length, 4); +}); + +test('user receives data after the initial server-time request reaches its deadline', { timeout: 5_000 }, async t => { + // Given the server-time request never produces an HTTP response + const host = createDataPanelHost(t, 'trading'); + await host.start(); + const time = host.network.requests[0]; + + // When its five-second deadline expires and the market endpoints succeed + host.clock.tick(4_999); + assert.equal(time.settled, false); + host.clock.tick(1); + await completeTradingBatch(host, tradingDataset(Date.now())); + await afterDataMediaResponseTurn(); + + // Then data remains usable but the countdown does not claim an unverified clock + assert.equal(time.aborted, true); + assert.equal(read(host, 'current-funding').textContent, '0.00378%'); + assert.equal(read(host, 'funding-countdown').textContent, '倒计时 校时不可用'); + assert.match(read(host, 'updated-at').textContent, /^更新于 /); +}); + +/** Advancing one hour executes 3600 DOM ticks, which needs a larger runner budget under parallel load. */ +test('user loses the calibrated countdown when hourly clock synchronization fails', { timeout: 15_000 }, async t => { + // Given the current panel began with a calibrated clock + const host = createDataPanelHost(t, 'trading'); + await host.start(); + await activateTradingData(host); + assert.equal(read(host, 'funding-countdown').textContent, '倒计时 04:00:00'); + + // When the next hourly calibration returns a definite failure + host.clock.tick(3_600_000); + const time = await host.network.waitForRequest(request => !request.settled && endpoint(request) === 'time'); + time.respond({}, 500); + await afterDataMediaResponseTurn(); + host.clock.tick(1_000); + + // Then the next display tick withdraws the calibrated clock claim + assert.equal(read(host, 'funding-countdown').textContent, '倒计时 校时不可用'); + assert.equal(host.network.requests.filter(request => endpoint(request) === 'time').length, 2); +}); + +test('user cancels old current funding when switching symbols and keeps the new symbol rate', { timeout: 5_000 }, async t => { + // Given Bitcoin funding remains pending while its history request has started + const host = createDataPanelHost(t, 'trading'); + await host.start(); + host.network.requests[0].respond({ serverTime: Date.now() }); + await host.network.waitForRequest(request => endpoint(request) === 'fundingRate'); + const current = host.network.requests.find(request => endpoint(request) === 'premiumIndex'); + const interval = host.network.requests.find(request => endpoint(request) === 'fundingInfo'); + + // When the user navigates to STRK before Bitcoin completes + host.navigate('/en/futures/STRKUSDT'); + const data = tradingDataset(Date.now(), { symbol: 'STRKUSDT', currentFunding: 0.00005, funding: 0.00000029 }); + await activateTradingData(host, data, { symbol: 'STRKUSDT' }); + await completeTradingBatch(host, tradingDataset(Date.now()), { includeInterval: false }); + await afterDataMediaResponseTurn(); + + // Then cancelled old requests and late history cannot change the new identity or rate + assert.equal(current.aborted, true); + assert.equal(interval.aborted, true); + assert.equal(host.element('symbol').textContent, 'STRKUSDT'); + assert.equal(read(host, 'current-funding').textContent, '0.005%'); + assert.equal(read(host, 'funding-period').textContent, 'Current · 4h'); + assert.equal(host.panel().querySelector('.td-last-value').textContent, '0.000029%'); + assert.equal(host.element('close').getAttribute('aria-label'), 'Close'); +}); diff --git a/test/dom/binance-trading-data-presentation.test.js b/test/dom/binance-trading-data-presentation.test.js new file mode 100644 index 00000000..c55cb3d2 --- /dev/null +++ b/test/dom/binance-trading-data-presentation.test.js @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + activateTradingData, afterDataMediaResponseTurn, completeTradingBatch, + createDataPanelHost, tradingDataset, +} from '../helpers/data-media-migration-host.js'; + +test('user can distinguish a tiny settled funding rate from zero', { timeout: 5_000 }, async t => { + // Given the history endpoint reports the nonzero rate observed for STRK + const host = createDataPanelHost(t, 'trading', { path: '/zh-CN/futures/STRKUSDT' }); + const dataset = tradingDataset(Date.now(), { symbol: 'STRKUSDT', funding: 0.00000029 }); + + // When the installed script renders that settled funding record + await host.start(); + await activateTradingData(host, dataset, { symbol: 'STRKUSDT' }); + + // Then the exact small percentage remains visible instead of a rounded zero + assert.match(host.element('rows').textContent, /0\.000029%/); +}); + +test('user sees English trading labels on an English futures page', { timeout: 5_000 }, async t => { + // Given the trading route explicitly selects English + const host = createDataPanelHost(t, 'trading', { path: '/en/futures/BTCUSDT' }); + + // When the installed panel receives complete market data + await host.start(); + const time = await host.network.waitForRequest(request => request.url.pathname.endsWith('/time')); + time.respond({ serverTime: Date.now() }); + await completeTradingBatch(host, tradingDataset(Date.now())); + await host.rendered(() => host.element('symbol')?.textContent === 'BTCUSDT'); + + // Then indicator names and actions match the English page + assert.match(host.element('rows').textContent, /Open Interest/); + assert.match(host.element('rows').textContent, /Funding Rate/); + assert.equal(host.element('close').getAttribute('aria-label'), 'Close'); +}); + +test('user keeps the resumed data session when an earlier time request completes late', { timeout: 5_000 }, async t => { + // Given the original activation has an outstanding server-time request + const host = createDataPanelHost(t, 'trading'); + await host.start(); + const oldTime = await host.network.waitForRequest(request => request.url.pathname.endsWith('/time')); + + // When a resumed activation completes before the old calibration + host.setHidden(true); + host.setHidden(false); + const newTime = await host.network.waitForRequest(request => request !== oldTime && request.url.pathname.endsWith('/time')); + newTime.respond({ serverTime: Date.now() }); + await completeTradingBatch(host, tradingDataset(Date.now())); + await afterDataMediaResponseTurn(); + const requestCount = host.network.requests.length; + const rendered = host.element('rows').textContent; + assert.equal(oldTime.aborted, true); + await afterDataMediaResponseTurn(); + + // Then late calibration cannot change the data or start another request batch + assert.equal(host.network.requests.length, requestCount); + assert.equal(host.element('rows').textContent, rendered); +}); diff --git a/test/dom/binance-trading-data-view.test.js b/test/dom/binance-trading-data-view.test.js new file mode 100644 index 00000000..f868a56f --- /dev/null +++ b/test/dom/binance-trading-data-view.test.js @@ -0,0 +1,300 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { JSDOM } from 'jsdom'; +import { createTradingDataView } from '../../src/binance-trading-data/panel-view.js'; + +const PANEL_ID = 'jh-binance-trading-data-panel'; +const START = Date.UTC(2026, 9, 9, 4); + +function indicator(id, values = {}) { + return { + id, signal: 'neutral', display: '1.20', unit: '', cached: false, vote: true, + history: [ + { timestamp: START, value: 1.1 }, + { timestamp: START + 300_000, value: 1.2 }, + ], + ...values, + }; +} + +function model() { + return { + symbol: 'BTCUSDT', longCount: 3, shortCount: 1, total: 5, + indicators: [ + indicator('oi', { display: '120.00', unit: 'BTC', history: [ + { timestamp: START, value: 100 }, + { timestamp: START + 300_000, value: 120 }, + ] }), + indicator('top-accounts'), indicator('top-positions'), indicator('global-accounts'), + indicator('taker', { display: '0.50', unit: 'BTC', history: [ + { timestamp: START, value: 1.2, buy: 12, sell: 10 }, + { timestamp: START + 300_000, value: 0.5, buy: 6, sell: 12 }, + ] }), + indicator('basis', { display: '-0.10%', history: [{ timestamp: START, value: -0.001 }] }), + indicator('funding', { + display: '0.000029%', history: [{ timestamp: START, value: 0.00000029 }], + }), + indicator('oi-supply', { display: '5.00%', vote: false, history: [{ timestamp: START, value: 0.05 }] }), + ], + }; +} + +function fundingState(values = {}) { + return { + current: { value: 0.0000424, nextFundingTime: START + 5_400_000, time: START }, + intervalHours: 4, receivedAt: START, cached: false, error: null, + intervalCached: false, intervalError: null, + ...values, + }; +} + +function harness(t, locale = 'zh-CN', collapsed = false) { + const dom = new JSDOM(''); + const { document } = dom.window; + const panel = document.createElement('div'); + panel.id = PANEL_ID; + document.body.append(panel); + const actions = []; + const view = createTradingDataView({ + document, panel, locale, collapsed, + onCollapse: value => actions.push(['collapse', value]), + onClose: () => actions.push(['close']), + }); + t.after(() => { view.destroy(); dom.window.close(); }); + return { + view, panel, window: dom.window, actions, + element: suffix => document.getElementById(`${PANEL_ID}-${suffix}`), + row: id => panel.querySelector(`[data-metric="${id}"]`), + }; +} + +test('user sees the approved eight indicator names in Chinese and English', t => { + // Given a Chinese panel receives all eight real indicator records + const h = harness(t); + const data = model(); + + // When the display model is rendered + h.view.render(data); + + // Then the three-column table uses the approved names and exposes current-symbol units + assert.deepEqual([...h.element('rows').querySelectorAll('.td-name')].map(node => node.textContent), [ + '合约持仓量', '大户账户数多空比', '大户持仓量多空比', '多空账户数比', + '合约主动买卖量', '基差', '资金费率', '未平仓量与市值比率', + ]); + assert.deepEqual([...h.panel.querySelectorAll('thead th')].map(node => node.textContent), ['指标', '历史趋势', '数值']); + assert.equal(h.element('rows').children.length, 8); + assert.equal(h.element('symbol').textContent, 'BTCUSDT'); + assert.equal(h.row('oi').querySelector('.td-unit').textContent, 'BTC'); + assert.equal(h.row('taker').querySelector('.td-unit').textContent, '买/卖比'); + assert.equal(h.row('basis').querySelector('.td-unit').textContent, '比率'); + assert.match(h.element('composite').textContent, /偏多 3:1/); + assert.match(h.element('composite').textContent, /简化规则/); + + // When the page locale switches to English + h.view.setLocale('en'); + + // Then every indicator name and header action follows the English locale + assert.deepEqual([...h.element('rows').querySelectorAll('.td-name')].map(node => node.textContent), [ + 'Open Interest', 'Top Trader Long/Short Ratio (Accounts)', 'Top Trader Long/Short Ratio (Positions)', + 'Long/Short Ratio', 'Taker Buy/Sell Volume', 'Basis', 'Funding Rate', 'Open Interest to Market Cap Ratio', + ]); + assert.equal(h.element('close').getAttribute('aria-label'), 'Close'); + assert.equal(h.element('collapse').getAttribute('aria-label'), 'Collapse panel'); + assert.deepEqual([...h.panel.querySelectorAll('thead th')].map(node => node.textContent), ['Metric', 'History', 'Value']); +}); + +test('user inspects the actual historical timestamp and value with pointer and keyboard', t => { + // Given open-interest samples have a real irregular interval between observations + const h = harness(t, 'en'); + const data = model(); + data.indicators[0].history = [ + { timestamp: START, value: 10 }, + { timestamp: START + 1_000, value: 12 }, + { timestamp: START + 10_000, value: 11 }, + ]; + h.view.render(data); + const chart = h.row('oi').querySelector('.td-spark-button'); + const middlePoint = chart.querySelector('[data-history-index="1"]'); + + // When the user points to the middle observation and opens its details + middlePoint.dispatchEvent(new h.window.Event('pointerenter')); + middlePoint.dispatchEvent(new h.window.MouseEvent('click', { bubbles: true })); + + // Then the curve spacing and inspection retain the supplied time and quantity + assert.equal(chart.querySelector('.td-spark-path').getAttribute('d'), 'M4,40 L17.6,4 L140,22'); + assert.equal(h.panel.querySelector('.td-history-detail time').dateTime, new Date(START + 1_000).toISOString()); + assert.match(h.panel.querySelector('.td-history-value').textContent, /12(?:\.00)? BTC/); + assert.match(chart.title, /12(?:\.00)? BTC/); + assert.equal(chart.getAttribute('aria-expanded'), 'true'); + + // When the user moves left with the keyboard + chart.dispatchEvent(new h.window.KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + + // Then the preceding real observation is displayed without a synthesized timestamp + assert.equal(h.panel.querySelector('.td-history-detail time').dateTime, new Date(START).toISOString()); + assert.match(h.panel.querySelector('.td-history-value').textContent, /10(?:\.00)? BTC/); + assert.match(chart.getAttribute('aria-label'), /10(?:\.00)? BTC/); + assert.equal(h.panel.querySelector('.td-history-inspection').getAttribute('role'), 'status'); + + // When the user closes the inspection with Escape + chart.dispatchEvent(new h.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + // Then the detail row closes and the keyboard focus target remains available + assert.equal(h.panel.querySelectorAll('.td-history-detail').length, 0); + assert.equal(chart.getAttribute('aria-expanded'), 'false'); +}); + +test('user sees separate taker buy and sell quantities while the main value remains a ratio', t => { + // Given the taker history contains two observations with independent buy and sell volumes + const h = harness(t, 'en'); + h.view.render(model()); + const row = h.row('taker'); + const chart = row.querySelector('.td-spark-button'); + + // When the user inspects the last volume pair + chart.click(); + + // Then separate bars and explicit quantity units accompany the unchanged ratio + assert.equal(chart.querySelectorAll('.td-buy-bar').length, 2); + assert.equal(chart.querySelectorAll('.td-sell-bar').length, 2); + assert.equal(row.querySelector('.td-number').textContent, '0.50'); + assert.equal(row.querySelector('.td-unit').textContent, 'Buy/sell ratio'); + assert.match(h.panel.querySelector('.td-history-value').textContent, /Buy 6(?:\.00)? BTC/); + assert.match(h.panel.querySelector('.td-history-value').textContent, /Sell 12(?:\.00)? BTC/); + assert.match(h.panel.querySelector('.td-history-value').textContent, /Buy\/sell ratio 0\.50/); +}); + +test('user sees missing history and isolated observations without invented connecting lines', t => { + // Given one history is absent and another contains a missing observation between two values + const h = harness(t, 'en'); + const data = model(); + data.indicators[0].history = []; + data.indicators[1].history = [ + { timestamp: START, value: 1 }, + { timestamp: START + 300_000, value: null }, + { timestamp: START + 600_000, value: 2 }, + ]; + + // When the panel renders those histories + h.view.render(data); + + // Then absent data has a visible state while isolated values are points without false continuity + assert.match(h.row('oi').querySelector('.td-trend').textContent, /No history/); + assert.equal(h.row('oi').querySelectorAll('svg').length, 0); + assert.equal(h.row('top-accounts').querySelectorAll('.td-spark-path').length, 0); + assert.equal(h.row('top-accounts').querySelectorAll('.td-spark-point').length, 2); + assert.equal(h.row('funding').querySelectorAll('.td-spark-path').length, 0); + assert.equal(h.row('funding').querySelectorAll('.td-spark-point').length, 1); +}); + +test('user keeps settled history and footer nodes while the current funding countdown advances', t => { + // Given the panel has a tiny settled rate and an independent current funding snapshot + const h = harness(t); + h.view.render(model()); + const footer = h.element('footer'); + const updatedAt = footer.querySelector('[data-role="updated-at"]'); + updatedAt.textContent = '更新于 12:00:00'; + const settledChart = h.row('funding').querySelector('svg'); + const ordinaryRow = h.row('oi'); + const fundingRow = h.row('funding'); + + // When a current snapshot arrives and the parent advances its clock sixty times + h.view.setFunding(fundingState(), { now: START, calibrated: true, localNow: START }); + for (let second = 1; second <= 60; second += 1) { + h.view.updateClock({ now: START + second * 1_000, calibrated: true, localNow: START + second * 1_000 }); + } + + // Then current values stay separate from the preserved settled chart and existing timestamp nodes + assert.equal(h.row('funding'), fundingRow); + assert.equal(h.row('oi'), ordinaryRow); + assert.equal(h.row('funding').querySelector('svg'), settledChart); + assert.equal(h.element('footer'), footer); + assert.equal(footer.querySelector('[data-role="updated-at"]'), updatedAt); + assert.equal(updatedAt.textContent, '更新于 12:00:00'); + assert.equal(fundingRow.querySelector('[data-role="current-funding"]').textContent, '0.00424%'); + assert.equal(fundingRow.querySelector('[data-role="funding-period"]').textContent, '当前 · 4小时'); + assert.equal(fundingRow.querySelector('[data-role="funding-countdown"]').textContent, '倒计时 01:29:00'); + assert.match(fundingRow.querySelector('.td-last-settled').textContent, /上次 0\.000029%/); + assert.equal(settledChart.querySelectorAll('[data-history-index]').length, 1); +}); + +test('user sees unavailable funding and clock states without guessed periods or zero rates', t => { + // Given there is no current rate and no confirmed funding interval + const h = harness(t); + h.view.render(model()); + const clock = { now: START, calibrated: false, localNow: START }; + + // When the unconfirmed funding state is displayed + h.view.setFunding(fundingState({ current: null, intervalHours: null }), clock); + + // Then missing data stays missing and the panel does not assume an eight-hour cycle + assert.equal(h.row('funding').querySelector('[data-role="current-funding"]').textContent, '--'); + assert.equal(h.row('funding').querySelector('[data-role="funding-period"]').textContent, '当前 · 周期待确认'); + assert.match(h.row('funding').querySelector('[data-role="funding-status"]').textContent, /加载中/); + assert.equal(h.row('funding').querySelector('[data-role="funding-countdown"]').textContent, '倒计时 校时不可用'); + + // When a current snapshot arrives while server-time calibration remains unavailable + h.view.setFunding(fundingState({ intervalHours: null }), clock); + + // Then the current rate is shown but its uncalibrated countdown remains explicitly unavailable + assert.equal(h.row('funding').querySelector('[data-role="current-funding"]').textContent, '0.00424%'); + assert.equal(h.row('funding').querySelector('[data-role="funding-countdown"]').textContent, '倒计时 校时不可用'); +}); + +test('user sees cached refresh failures and waits for an authoritative next funding time', t => { + // Given an English panel has a cached rate and an independently cached interval + const h = harness(t, 'en'); + h.view.render(model()); + const state = fundingState({ cached: true, error: 'HTTP 503', intervalCached: true, intervalError: 'HTTP 500' }); + + // When the server-calibrated time reaches the supplied funding boundary + h.view.setFunding(state, { now: START + 5_400_000, calibrated: true, localNow: START + 5_400_000 }); + + // Then failures are written explicitly and the display never advances to an invented next interval + assert.match(h.row('funding').querySelector('[data-role="funding-status"]').textContent, /Cached.*refresh failed/); + assert.match(h.row('funding').querySelector('[data-role="funding-interval-status"]').textContent, /Cached.*refresh failed/); + assert.equal(h.row('funding').querySelector('[data-role="funding-countdown"]').textContent, 'Countdown Waiting for update'); + assert.equal(h.row('funding').querySelector('[data-role="current-funding"]').textContent, '--'); +}); + +test('user retains cached indicator provenance after switching the display language', t => { + // Given one indicator has a retained cached value after a failed refresh + const h = harness(t); + const data = model(); + data.indicators[0].cached = true; + data.indicators[0].error = 'HTTP 503'; + h.view.render(data); + + // When the locale changes to English without fetching new market data + h.view.setLocale('en'); + + // Then the retained value is still explicitly marked as cached and failed + assert.match(h.row('oi').querySelector('.td-data-status').textContent, /Cached.*refresh failed/); + assert.equal(h.row('oi').querySelector('.td-number').textContent, '120.00'); + assert.match(h.row('oi').querySelector('.td-data-status').title, /HTTP 503/); +}); + +test('user can collapse and close the panel and destroyed views stop handling input', t => { + // Given a panel starts collapsed and its callbacks record user actions + const h = harness(t, 'en', true); + h.view.render(model()); + const close = h.element('close'); + const collapse = h.element('collapse'); + + // When the user expands the body and then closes the panel + collapse.click(); + close.click(); + + // Then the callback contract and visible expansion state match the actions + assert.deepEqual(h.actions, [['collapse', false], ['close']]); + assert.equal(h.element('body').style.display, 'block'); + assert.equal(collapse.getAttribute('aria-expanded'), 'true'); + + // When the view is destroyed and old button references receive clicks + h.view.destroy(); + collapse.click(); + close.click(); + + // Then removed listeners cannot call the parent again + assert.deepEqual(h.actions, [['collapse', false], ['close']]); +}); diff --git a/test/dom/data-media-remaining-boundaries.test.js b/test/dom/data-media-remaining-boundaries.test.js index e4a51b35..afa1f61b 100644 --- a/test/dom/data-media-remaining-boundaries.test.js +++ b/test/dom/data-media-remaining-boundaries.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { activateTradingData, afterDataMediaResponseTurn, cmcDetail, completeCmcData, - completeTradingBatch, createDataPanelHost, tradingDataset, + completeTradingBatch, createDataPanelHost, tradingDataset, isTradingHistoryRequest, } from '../helpers/data-media-migration-host.js'; import { createMediaHost } from '../helpers/data-media-migration-media-host.js'; @@ -79,7 +79,7 @@ for (const kind of ['trading', 'cmc']) { // Then the restored panel renders the newest response with one mounted panel and no stale mutation assert.equal(host.element('body').style.display, 'block'); assert.equal(host.document.querySelectorAll(`#${host.panelId}`).length, 1); - assert.match(host.element('rows').textContent, kind === 'trading' ? /4\.00M ▲/ : /价格\$8万/); + assert.match(host.element('rows').textContent, kind === 'trading' ? /400万 ▲/ : /价格\$8万/); assert.match(host.element('footer').textContent, kind === 'trading' ? /更新于/ : /CMC data-api/); assert.deepEqual(host.errors, []); }); @@ -109,7 +109,8 @@ for (const kind of ['trading', 'cmc']) { // Then the viewport affects displayed styles without saving them and removal creates no replacement assert.deepEqual(position, { left: 900, top: 900 }); - assert.deepEqual(panelPosition, viewport.displayed); + const expectedLeft = viewport.clientWidth ? String(viewport.clientWidth - (kind === 'trading' ? 480 : 500)) + 'px' : '0px'; + assert.deepEqual(panelPosition, { ...viewport.displayed, left: expectedLeft }); assert.equal(host.panel(), null); assert.equal(host.window.localStorage.getItem(`${prefix}_pos`), '{"left":900,"top":900}'); assert.deepEqual(host.errors, []); @@ -127,17 +128,19 @@ for (const kind of ['trading', 'cmc']) { if (kind === 'trading') await activateTradingData(host); else await completeCmcData(host, cmcDetail(), { map: false }); await afterDataMediaResponseTurn(); - const count = host.network.requests.length; + const countRequests = () => kind === 'trading' + ? host.network.requests.filter(isTradingHistoryRequest).length : host.network.requests.length; + const count = countRequests(); const deadline = kind === 'trading' ? 305_000 : 30_000; host.clock.tick(deadline - 1); - assert.equal(host.network.requests.length, count); + assert.equal(countRequests(), count); host.clock.tick(1); if (kind === 'trading') await completeTradingBatch(host, tradingDataset(Date.now())); else await respondCmcDetail(host, cmcDetail()); await afterDataMediaResponseTurn(); // Then the next deadline issues one batch and retains exactly one current panel - assert.equal(host.network.requests.length, count + (kind === 'trading' ? 7 : 2)); + assert.equal(countRequests(), count + (kind === 'trading' ? 7 : 2)); assert.equal(host.document.querySelectorAll(`#${host.panelId}`).length, 1); assert.equal(host.element('symbol').textContent, kind === 'trading' ? 'BTCUSDT' : 'BTC #1'); assert.deepEqual(host.errors, []); @@ -184,10 +187,10 @@ test('user receives an asset identification error when CMC supplies a numeric sy // When the upstream row has a numeric symbol even though its ID and slug are present request.respond({ data: [{ id: 4, symbol: 4, slug: 'four', is_active: 1 }] }); - await host.rendered(() => host.element('rows').textContent.includes('无法识别当前合约')); + await host.rendered(() => host.element('rows').textContent.includes('Unable to identify the current contract')); // Then invalid mapping data remains a visible failure and never triggers a guessed detail request - assert.equal(host.element('rows').textContent, '读取失败无法识别当前合约'); + assert.equal(host.element('rows').textContent, 'Unable to load dataUnable to identify the current contract'); assert.equal(host.element('symbol').textContent, '4USDT'); assert.equal(host.network.requests.length, 1); }); @@ -201,8 +204,8 @@ test('user keeps trading data operational when the page temporarily has no head await host.start(); await activateTradingData(host); - // Then style installation uses the remaining document root and complete indicators remain usable - assert.equal(host.document.getElementById('jh-trading-data-flash-style').parentNode, host.document.documentElement); + // Then the panel owns its stylesheet and complete indicators remain usable without a head + assert.equal(host.panel().querySelector('style').parentNode, host.panel()); assert.equal(host.element('rows').children.length, 8); assert.match(host.element('composite').textContent, /偏多 7:0/); assert.deepEqual(host.errors, []); @@ -228,7 +231,7 @@ test('user sees unavailable trading data after the bounded retry also fails with await host.rendered(panel => panel?.querySelector('[data-role="updated-at"]')?.textContent.startsWith('更新于')); // Then the missing row cannot vote and the complete error object remains available for diagnosis - assert.equal(host.element('rows').firstElementChild.children[1].textContent, '--'); + assert.equal(host.element('rows').querySelector('[data-metric="oi"] .td-number').textContent, '--'); assert.match(host.element('composite').textContent, /偏多 6:0/); assert.equal(host.network.requests.filter(request => request.url.pathname.endsWith('/openInterestHist')).length, 2); assert.equal(host.errors.at(-1).at(-1), failure); diff --git a/test/helpers/data-media-migration-host.js b/test/helpers/data-media-migration-host.js index 76acb953..bd8e3480 100644 --- a/test/helpers/data-media-migration-host.js +++ b/test/helpers/data-media-migration-host.js @@ -65,21 +65,34 @@ export function createDataMediaNetwork() { listeners.add(listener); }); } - function fetch(url) { + function fetch(url, { signal } = {}) { return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + const complete = () => { + assert.equal(request.settled, false, 'each HTTP request has one terminal outcome'); + request.settled = true; + signal?.removeEventListener('abort', abort); + }; + const abort = () => { + complete(); + request.aborted = true; + reject(signal.reason); + }; const request = { - url: new URL(url), kind: 'fetch', settled: false, + url: new URL(url), kind: 'fetch', settled: false, aborted: false, respond(body, status = 200) { - assert.equal(request.settled, false, 'each HTTP request has one terminal outcome'); - request.settled = true; + complete(); resolve({ ok: status >= 200 && status < 300, status, json: async () => body }); }, fail(error = new Error('fixture network unavailable')) { - assert.equal(request.settled, false); - request.settled = true; + complete(); reject(error); }, }; + signal?.addEventListener('abort', abort, { once: true }); publish(request); }); } @@ -148,30 +161,48 @@ export function createDataPanelHost(t, kind, { export function tradingDataset(timestamp, { oi = 2_000_000, previousOi = 1_000_000, supply = 10_000_000, ratio = 1.5, basis = 0.01, funding = -0.0002, + symbol = 'BTCUSDT', currentFunding = 0.0000378, intervalHours = 4, } = {}) { return { openInterestHist: Array.from({ length: 7 }, (_, index) => ({ - timestamp, sumOpenInterest: String(index === 6 ? oi : previousOi), + timestamp: timestamp - (6 - index) * 300_000, sumOpenInterest: String(index === 6 ? oi : previousOi), sumOpenInterestValue: '100000000', CMCCirculatingSupply: String(supply), })), topLongShortAccountRatio: [{ timestamp, longShortRatio: String(ratio) }], topLongShortPositionRatio: [{ timestamp, longShortRatio: String(ratio) }], globalLongShortAccountRatio: [{ timestamp, longShortRatio: String(ratio) }], - takerlongshortRatio: [{ timestamp, buySellRatio: String(ratio) }], + takerlongshortRatio: [{ timestamp, buySellRatio: String(ratio), buyVol: String(100 * ratio), sellVol: '100' }], basis: [{ timestamp, basisRate: String(basis) }], - fundingRate: [{ fundingRate: String(funding) }], + fundingRate: [{ symbol, fundingTime: timestamp, fundingRate: String(funding) }], + premiumIndex: { symbol, lastFundingRate: String(currentFunding), time: timestamp, nextFundingTime: timestamp + 14_400_000 }, + fundingInfo: intervalHours === null ? [] : [{ symbol, fundingIntervalHours: intervalHours }], }; } -export async function completeTradingBatch(host, dataset, { symbol = 'BTCUSDT', status = {} } = {}) { +export function isTradingHistoryRequest(request) { + return request.url.pathname.startsWith('/futures/data/') || request.url.pathname.endsWith('/fundingRate'); +} + +export async function completeTradingBatch(host, dataset, { symbol = 'BTCUSDT', status = {}, includeInterval = true } = {}) { const pending = await host.network.waitForRequest(request => !request.settled && request.url.pathname.endsWith('/fundingRate') && request.url.searchParams.get('symbol') === symbol); assert.equal(pending.kind, 'fetch'); - const batch = host.network.requests.filter(request => !request.settled && (request.url.searchParams.get('symbol') || request.url.searchParams.get('pair')) === symbol); + const batch = host.network.requests.filter(request => !request.settled + && isTradingHistoryRequest(request) + && (request.url.searchParams.get('symbol') || request.url.searchParams.get('pair')) === symbol); assert.equal(batch.length, 7); + if (includeInterval) { + const interval = host.network.requests.slice(host.network.requests.indexOf(pending) + 1) + .find(request => !request.settled && request.url.pathname.endsWith('/fundingInfo')); + assert.ok(interval, 'the historical refresh also requests funding metadata'); + batch.push(interval); + } for (const request of batch) { const key = request.url.pathname.split('/').at(-1); request.respond(dataset[key], status[key] ?? 200); } + const current = host.network.requests.find(request => !request.settled + && request.url.pathname.endsWith('/premiumIndex') && request.url.searchParams.get('symbol') === symbol); + if (current) current.respond(dataset.premiumIndex, status.premiumIndex ?? 200); return batch; } @@ -179,7 +210,6 @@ export async function activateTradingData(host, dataset = tradingDataset(Date.no const time = await host.network.waitForRequest(request => !request.settled && request.url.pathname.endsWith('/time')); time.respond({ serverTime: Date.now() }); await completeTradingBatch(host, dataset, options); - await host.rendered(panel => panel?.querySelector('[data-role="updated-at"]')?.textContent.startsWith('更新于')); await afterDataMediaResponseTurn(); } @@ -190,7 +220,7 @@ export function cmcDetail(overrides = {}) { statistics: { price: 60_000, priceChangePercentage24h: 2.5, marketCap: 1_200_000_000_000, marketCapChangePercentage24h: -1, ucm: 1_000_000_000, volume24h: 100_000_000, - turnover: 0.05, fullyDilutedMarketCap: 1_300_000_000_000, + turnover: 100_000_000 / 1_200_000_000_000, fullyDilutedMarketCap: 1_300_000_000_000, fullyDilutedMarketCapChangePercentage24h: 0, liquidityMcapRatio: 0.03, totalSupply: 21_000_000, maxSupply: 21_000_000, circulatingSupply: 20_000_000, rank: 1, diff --git a/test/unit/binance-cmc-panel-metrics.test.js b/test/unit/binance-cmc-panel-metrics.test.js new file mode 100644 index 00000000..6025abf2 --- /dev/null +++ b/test/unit/binance-cmc-panel-metrics.test.js @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildCmcMetricRows, numberOrNull } from '../../src/binance-coinmarketcap-data/metrics.js'; +import { cmcDetail } from '../helpers/data-media-migration-host.js'; + +for (const invalid of [null, undefined, '', ' ', 'unavailable', false, [], {}, Infinity, NaN]) { + test(`user retains a missing CMC number for ${String(invalid)} instead of a fabricated zero`, () => { + // Given an upstream field contains a missing or unsupported numeric representation + const value = invalid; + + // When the numeric adapter normalizes that provider field + const actual = numberOrNull(value); + + // Then the value remains explicitly missing + assert.equal(actual, null); + }); +} + +test('user retains valid numeric zero and numeric strings in CMC statistics', () => { + // Given CMC sends zero quantities and numeric strings + const values = [0, '0', ' 12.5 ', -2.75]; + + // When the provider fields pass through the numeric adapter + const actual = values.map(numberOrNull); + + // Then valid zero and signed numbers retain their exact numeric meaning + assert.deepEqual(actual, [0, 0, 12.5, -2.75]); +}); + +for (const locale of ['zh-CN', 'en']) { + test(`user sees nonzero small token prices and genuine zero distinctly in ${locale}`, () => { + // Given the price provider supplies a small token price, a tiny price, and a genuine zero + const snapshots = [0.00000378, 1e-18, 0].map(price => ({ statistics: { price } })); + + // When the panel formats the actual numeric price fields + const prices = snapshots.map(detail => buildCmcMetricRows(detail, locale).find(row => row.id === 'price').value); + + // Then small prices retain significant digits and cannot be mistaken for zero + assert.deepEqual(prices, ['$0.00000378', '$1.000e-18', '$0']); + }); + + test(`user reads computed valuation and supply comparisons in ${locale}`, () => { + // Given a snapshot has a 2.5 valuation multiple and forty percent circulating supply + const detail = cmcDetail(); + Object.assign(detail.statistics, { marketCap: 200_000_000, fullyDilutedMarketCap: 500_000_000, totalSupply: 250_000_000, circulatingSupply: 100_000_000 }); + + // When the presentation is built for the requested language + const rows = buildCmcMetricRows(detail, locale); + + // Then both comparisons use the snapshot and quantity units remain separate from the number + const fdv = rows.find(row => row.id === 'fdv'); + const supply = rows.find(row => row.id === 'circulating-supply'); + assert.equal(fdv.interpretation, locale === 'zh-CN' ? '流通市值的2.50倍' : '2.50× market cap'); + assert.equal(fdv.value, locale === 'zh-CN' ? '$5亿' : '$500M'); + assert.equal(supply.interpretation, locale === 'zh-CN' ? '约占总供应量40.0%' : 'About 40.0% of total supply'); + assert.equal(supply.value, locale === 'zh-CN' ? '1亿' : '100M'); + assert.equal(supply.unit, 'BTC'); + }); +} + +for (const scenario of [ + { change: 12.5, reading: 'Up 12.50% over 24h', tone: 'positive' }, + { change: -3, reading: 'Down 3.00% over 24h', tone: 'negative' }, + { change: 0, reading: 'Unchanged over 24h', tone: 'neutral' }, + { change: null, reading: '24h change unavailable', tone: 'neutral' }, +]) { + test(`user sees the actual ${scenario.change} daily CMC price change without a forecast`, () => { + // Given the current snapshot contains the specified daily price change + const detail = cmcDetail(); + detail.statistics.priceChangePercentage24h = scenario.change; + + // When the price interpretation is prepared + const price = buildCmcMetricRows(detail, 'en').find(row => row.id === 'price'); + + // Then its direction and magnitude agree with the response + assert.equal(price.interpretation, scenario.reading); + assert.equal(price.tone, scenario.tone); + assert.match(price.explanation, /does not establish sentiment or predict direction/); + }); +} + +for (const scenario of [ + { turnover: 0, reading: 'Below the 50% threshold' }, + { turnover: 0.4999, reading: 'Below the 50% threshold' }, + { turnover: 0.5, reading: 'Relatively active trading' }, + { turnover: 0.75, reading: 'Relatively active trading' }, +]) { + test(`user sees CMC reported turnover ${scenario.turnover} with its stated descriptive threshold`, () => { + // Given the provider reports a turnover field independently of the displayed value timestamps + const detail = cmcDetail(); + Object.assign(detail.statistics, { marketCap: 200_000_000, volume24h: 100_000_000, turnover: scenario.turnover }); + + // When the reported turnover is presented + const row = buildCmcMetricRows(detail, 'en').find(row => row.id === 'turnover'); + + // Then the supplied ratio is preserved and the interpretation discloses its threshold + assert.equal(row.value, `${(scenario.turnover * 100).toFixed(2)}%`); + assert.equal(row.interpretation, scenario.reading); + assert.match(row.explanation, /CMC-reported turnover/); + assert.match(row.explanation, /descriptive threshold only/); + assert.match(row.explanation, /historical baseline/); + }); +} + +for (const denominator of [null, '', 0, -1]) { + test(`user gets no CMC ratio inference from a ${String(denominator)} denominator`, () => { + // Given valuation and supply denominators are absent or non-positive + const detail = cmcDetail(); + Object.assign(detail.statistics, { marketCap: denominator, totalSupply: denominator, turnover: 0.75 }); + + // When CMC values are converted to presentation rows + const rows = buildCmcMetricRows(detail, 'en'); + + // Then neither derived ratios nor activity claims infer a usable denominator + for (const id of ['fdv', 'circulating-supply', 'turnover', 'liquidity']) { + assert.equal(rows.find(row => row.id === id).interpretation, 'Comparison unavailable'); + } + }); +} + +test('user keeps zero CMC values without inventing a supply comparison or discarding zero holders', () => { + // Given all statistics are zero and the first available holder count is genuinely zero + const detail = cmcDetail({ profileCompletionScore: 0, holders: { holderCount: 0, total: 99 } }); + for (const key of Object.keys(detail.statistics)) detail.statistics[key] = 0; + + // When the English panel rows are prepared + const rows = buildCmcMetricRows(detail, 'en'); + + // Then price, score, and holders retain zero while zero supply cannot support a ratio + assert.equal(rows.find(row => row.id === 'price').value, '$0'); + assert.equal(rows.find(row => row.id === 'holders').value, '0'); + assert.equal(rows.find(row => row.id === 'profile').value, '0%'); + assert.equal(rows.find(row => row.id === 'circulating-supply').interpretation, 'Comparison unavailable'); +}); + +test('user sees missing CMC metrics as unavailable rather than zero supply or unlimited issuance', () => { + // Given the asset exists but its optional statistics and score are missing + const detail = { statistics: {}, symbol: 'BTC', profileCompletionScore: null }; + + // When the panel builds an English snapshot + const rows = buildCmcMetricRows(detail, 'en'); + + // Then all values remain absent and the explanations preserve the limits of the data + assert.equal(rows.length, 12); + assert.deepEqual(rows.map(row => row.value), Array(12).fill('--')); + assert.equal(rows.find(row => row.id === 'max-supply').interpretation, 'Supply cap unavailable'); + assert.match(rows.find(row => row.id === 'max-supply').explanation, /does not establish unlimited supply/); + assert.match(rows.find(row => row.id === 'holders').explanation, /does not measure unique investors/); +}); + +test('user sees treasury holdings with token units instead of an address-count interpretation', () => { + // Given CMC explicitly replaces holder information with a positive treasury position + const detail = cmcDetail({ showTreasuriesFlag: true, treasuryHoldings: 1200 }); + + // When the CMC panel builds the treasury row + const row = buildCmcMetricRows(detail, 'en').find(row => row.id === 'treasury'); + + // Then the quantity and explanation retain treasury semantics + assert.equal(row.label, 'Treasury holdings'); + assert.equal(row.value, '1.2K'); + assert.equal(row.unit, 'BTC'); + assert.equal(row.interpretation, 'Treasury token holdings'); + assert.match(row.explanation, /not a holder address count/); +}); + +for (const score of [-1, 101, '', null]) { + test(`user sees CMC profile score ${String(score)} as unavailable without a safety judgment`, () => { + // Given CMC provides a missing or out-of-range profile percentage + const detail = cmcDetail({ profileCompletionScore: { percentage: score } }); + + // When the profile score is interpreted + const row = buildCmcMetricRows(detail, 'en').find(row => row.id === 'profile'); + + // Then an invalid percentage stays absent and the score purpose remains explicit + assert.equal(row.value, '--'); + assert.equal(row.interpretation, 'Data unavailable'); + assert.match(row.explanation, /not project safety or investment success/); + }); +} diff --git a/test/unit/binance-data-panel-lifecycle-regressions.test.js b/test/unit/binance-data-panel-lifecycle-regressions.test.js index c5b046bb..e3721d43 100644 --- a/test/unit/binance-data-panel-lifecycle-regressions.test.js +++ b/test/unit/binance-data-panel-lifecycle-regressions.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { activateTradingData, afterDataMediaResponseTurn, completeCmcData, - completeTradingBatch, createDataPanelHost, cmcDetail, tradingDataset, + completeTradingBatch, createDataPanelHost, cmcDetail, tradingDataset, isTradingHistoryRequest, } from '../helpers/data-media-migration-host.js'; async function finishInitial(host, kind) { @@ -26,7 +26,7 @@ for (const kind of ['trading', 'cmc']) { // Then the current Bitcoin panel renders one initial request batch assert.match(host.element('symbol').textContent, /^BTC/); assert.equal(host.document.querySelectorAll(`#${host.panelId}`).length, 1); - assert.equal(host.network.requests.length, kind === 'trading' ? 8 : 3); + assert.equal(host.network.requests.length, kind === 'trading' ? 10 : 3); }); test(`user operates a ${kind} header control without dragging or saving a new position`, { timeout: 5_000 }, async t => { @@ -119,7 +119,7 @@ for (const kind of ['trading', 'cmc']) { else await completeCmcData(host, cmcDetail(), { map: false }); // Then a new current-symbol refresh renders after route reactivation - assert.equal(host.panel().style.width, '240px'); + assert.equal(host.panel().style.width, kind === 'trading' ? '480px' : '500px'); assert.match(host.element('symbol').textContent, /^BTC/); assert.ok(host.network.requests.length > beforePause); }); @@ -151,19 +151,15 @@ for (const kind of ['trading', 'cmc']) { await finishInitial(host, kind); const firstRow = host.element('rows').firstElementChild; - // Then the actual styles reserve stable label and value space inside 240 pixels - assert.equal(host.panel().style.width, '240px'); - assert.equal(firstRow.children[1].style.fontVariantNumeric, 'tabular-nums'); - assert.equal(firstRow.children[1].style.textAlign, 'right'); - if (kind === 'trading') { - assert.equal(firstRow.children[0].style.minWidth, '90px'); - assert.equal(firstRow.children[1].style.flex, '1 1 0%'); - } else { - assert.equal(firstRow.children[0].style.overflow, 'hidden'); - assert.equal(firstRow.children[0].style.textOverflow, 'ellipsis'); - assert.equal(firstRow.children[1].style.whiteSpace, 'nowrap'); - assert.equal(firstRow.children[1].style.flex, '0 0 auto'); - } + // Then the table keeps three semantic columns and aligned tabular numeric values + assert.equal(host.panel().style.width, kind === 'trading' ? '480px' : '500px'); + assert.equal(host.element('rows').tagName, 'TBODY'); + assert.equal(firstRow.children.length, 3); + assert.equal(firstRow.firstElementChild.getAttribute('scope'), 'row'); + const value = firstRow.querySelector(kind === 'trading' ? '.td-value' : '.cmc-value'); + const styles = host.window.getComputedStyle(value); + assert.equal(styles.fontVariantNumeric, 'tabular-nums'); + assert.equal(styles.textAlign, 'right'); }); test(`user keeps the new symbol when a superseded ${kind} response arrives late`, { timeout: 5_000 }, async t => { @@ -178,8 +174,8 @@ for (const kind of ['trading', 'cmc']) { // When Ethereum renders before the old Bitcoin request is allowed to finish host.navigate('/zh-CN/futures/ETHUSDT'); if (kind === 'trading') { - await activateTradingData(host, tradingDataset(Date.now(), { oi: 3000 }), { symbol: 'ETHUSDT' }); - await completeTradingBatch(host, tradingDataset(Date.now()), { symbol: 'BTCUSDT' }); + await activateTradingData(host, tradingDataset(Date.now(), { symbol: 'ETHUSDT', oi: 3000 }), { symbol: 'ETHUSDT' }); + await completeTradingBatch(host, tradingDataset(Date.now()), { symbol: 'BTCUSDT', includeInterval: false }); } else { await completeCmcData(host, cmcDetail({ id: 2, symbol: 'ETH' }), { symbol: 'ETH', slug: 'ethereum' }); await completeCmcData(host); @@ -188,28 +184,30 @@ for (const kind of ['trading', 'cmc']) { // Then the late response cannot replace the displayed Ethereum identity or data assert.match(host.element('symbol').textContent, /^ETH/); - if (kind === 'trading') assert.match(host.element('rows').textContent, /3K ▼/); + if (kind === 'trading') assert.match(host.element('rows').textContent, /3000 ▼/); else assert.equal(host.element('footer').querySelector('a').href, 'https://coinmarketcap.com/zh/currencies/ethereum/'); }); } for (const state of ['hidden', 'off-route']) { test(`user starts no trading data batch when server synchronization finishes after becoming ${state}`, { timeout: 5_000 }, async t => { - // Given the initial server-time request is still pending before the panel mounts + // Given initial server time is pending while the panel shows its loading state const host = createDataPanelHost(t, 'trading'); await host.start(); const timeRequest = host.network.requests[0]; - assert.equal(host.panel(), null); + const panel = host.panel(); + const initialRows = host.element('rows').innerHTML; // When the route or document becomes inactive before synchronization completes if (state === 'hidden') host.setHidden(true); else host.navigate('/zh-CN/futures'); - timeRequest.respond({ serverTime: Date.now() }); await afterDataMediaResponseTurn(); host.clock.tick(60_000); - // Then synchronization cannot mount a panel or start any period endpoints - assert.equal(host.panel(), null); + // Then cancellation prevents data requests and leaves no late render + assert.equal(timeRequest.aborted, true); + assert.equal(host.panel(), state === 'hidden' ? panel : null); + if (state === 'hidden') assert.equal(host.element('rows').innerHTML, initialRows); assert.deepEqual(host.network.requests.map(request => request.url.pathname), ['/fapi/v1/time']); }); } @@ -247,7 +245,7 @@ for (const state of ['closed', 'hidden', 'off-route', 'new symbol']) { await activateTradingData(host); host.clock.tick(305_000); await host.network.waitForRequest(request => !request.settled && request.url.pathname.endsWith('/fundingRate')); - const oldRequests = host.network.requests.filter(request => !request.settled); + const oldRequests = host.network.requests.filter(request => !request.settled && isTradingHistoryRequest(request)); assert.equal(oldRequests.length, 7); const oldPanel = host.panel(); const oldRows = host.element('rows'); @@ -258,7 +256,7 @@ for (const state of ['closed', 'hidden', 'off-route', 'new symbol']) { else if (state === 'off-route') host.navigate('/zh-CN/futures'); else { host.navigate('/zh-CN/futures/ETHUSDT'); - await activateTradingData(host, tradingDataset(Date.now(), { oi: 3000 }), { symbol: 'ETHUSDT' }); + await activateTradingData(host, tradingDataset(Date.now(), { symbol: 'ETHUSDT', oi: 3000 }), { symbol: 'ETHUSDT' }); } const rows = state === 'new symbol' ? host.element('rows') : oldRows; const before = rows.innerHTML; @@ -271,7 +269,7 @@ for (const state of ['closed', 'hidden', 'off-route', 'new symbol']) { assert.equal(oldPanel.isConnected, state !== 'off-route'); if (state === 'new symbol') { assert.match(host.element('symbol').textContent, /^ETH/); - assert.match(rows.textContent, /3K ▼/); + assert.match(rows.textContent, /3000 ▼/); } if (state === 'closed') assert.equal(oldPanel.style.display, 'none'); }); @@ -286,16 +284,17 @@ for (const state of ['closed', 'hidden', 'off-route']) { await host.network.waitForRequest(request => request.url.pathname.endsWith('/fundingRate')); const panel = host.panel(); const rows = host.element('rows'); + const initialRows = rows.innerHTML; // When the lifecycle is invalidated before the pending responses finish if (state === 'closed') host.element('close').click(); else if (state === 'hidden') host.setHidden(true); else host.navigate('/zh-CN/futures'); - await completeTradingBatch(host, tradingDataset(Date.now())); + await completeTradingBatch(host, tradingDataset(Date.now()), { includeInterval: false }); await afterDataMediaResponseTurn(); // Then the pending request cannot write its rows into the inactive panel - assert.equal(rows.innerHTML, ''); + assert.equal(rows.innerHTML, initialRows); assert.equal(panel.isConnected, state !== 'off-route'); if (state === 'closed') assert.equal(panel.style.display, 'none'); }); diff --git a/test/unit/binance-data-panel-route-regressions.test.js b/test/unit/binance-data-panel-route-regressions.test.js index 96435108..f7e957c7 100644 --- a/test/unit/binance-data-panel-route-regressions.test.js +++ b/test/unit/binance-data-panel-route-regressions.test.js @@ -26,7 +26,7 @@ for (const kind of ['cmc', 'trading']) { assert.equal(host.network.requests.length, 0); // When SPA navigation enters a supported contract and its first refresh completes host.navigate('/zh-CN/futures/USUSDT'); - if (kind === 'trading') await activateTradingData(host, tradingDataset(Date.now()), { symbol: 'USUSDT' }); + if (kind === 'trading') await activateTradingData(host, tradingDataset(Date.now(), { symbol: 'USUSDT' }), { symbol: 'USUSDT' }); else await completeCmcData(host, cmcDetail({ id: 42, symbol: 'US' }), { symbol: 'US', slug: 'us' }); // Then exactly one panel owns the current contract assert.equal(host.document.querySelectorAll(`#${host.panelId}`).length, 1); @@ -61,7 +61,7 @@ for (const kind of ['cmc', 'trading']) { // When SPA navigation enters a Unicode futures trading route host.navigate('/zh-CN/futures/龙虾USDT'); - if (kind === 'trading') await activateTradingData(host, tradingDataset(Date.now()), { symbol: '龙虾USDT' }); + if (kind === 'trading') await activateTradingData(host, tradingDataset(Date.now(), { symbol: '龙虾USDT' }), { symbol: '龙虾USDT' }); else await completeCmcData(host, cmcDetail({ id: 42, symbol: '龙虾' }), { symbol: '龙虾', slug: 'lobster' }); // Then the route controls the visible identity and the outbound symbol parameters diff --git a/test/unit/binance-trading-data-market.test.js b/test/unit/binance-trading-data-market.test.js new file mode 100644 index 00000000..4c67258d --- /dev/null +++ b/test/unit/binance-trading-data-market.test.js @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + computeTradingSignals, formatFundingPercent, formatMetricValue, + parseCurrentFunding, parseFundingInterval, parseHistory, +} from '../../src/binance-trading-data/market-data.js'; + +// Public Binance/CMC samples captured at 2026-10-09T07:16:15Z, documented in userscript-validation.md. +const funding = [ + { symbol: 'STRKUSDT', fundingTime: 1791504000000, fundingRate: '-0.00000644' }, + { symbol: 'STRKUSDT', fundingTime: 1791518400000, fundingRate: '0.00000029' }, +]; + +test('user sees the latest settled funding separately from the current period', () => { + // Given the public STRK history and current premium-index response + const history = parseHistory('fundingRate', funding, 'STRKUSDT'); + const current = { symbol: 'STRKUSDT', lastFundingRate: '0.00005000', nextFundingTime: 1791532800000, time: 1791530175000 }; + + // When both contracts are parsed and formatted independently + const result = parseCurrentFunding(current, 'STRKUSDT'); + const model = computeTradingSignals({ fundingRate: history }, new Set(), 'zh-CN', 'STRKUSDT'); + + // Then the current value is different and never appended to settled history + assert.equal(formatFundingPercent(result.value), '0.005%'); + assert.equal(model.indicators[6].display, '0.000029%'); + assert.equal(model.indicators[6].history.length, 2); + assert.equal(result.nextFundingTime - result.time, 2_625_000); +}); + +test('user sees the reported four-hour interval without an invented default', () => { + // Given the current symbol has explicit interval metadata + const data = [{ symbol: 'STRKUSDT', fundingIntervalHours: 4 }]; + + // When matching and absent symbols are resolved + const current = parseFundingInterval(data, 'STRKUSDT'); + const absent = parseFundingInterval(data, 'BTCUSDT'); + + // Then only the matching contract supplies a settlement interval + assert.equal(current, 4); + assert.equal(absent, null); + assert.throws(() => parseFundingInterval([...data, ...data], 'STRKUSDT'), /ambiguous/); +}); + +test('user gets the correct open-interest ratio for a thousand-token contract', () => { + // Given Binance has already scaled the CMC supply into 1000PEPE units + const data = [{ symbol: '1000PEPEUSDT', sumOpenInterest: '17073744019.00000000', CMCCirculatingSupply: '413772355107.94400000', timestamp: 1791530100000 }]; + + // When the actual endpoint sample becomes a displayed indicator + const history = parseHistory('openInterest', data, '1000PEPEUSDT'); + const result = computeTradingSignals({ openInterest: history }, new Set(), 'en', '1000PEPEUSDT'); + + // Then the ratio needs no extra multiplication by one thousand + assert.equal(result.indicators[7].display, '4.13%'); + assert.equal(result.indicators[0].unit, '1000PEPE'); + assert.ok(Math.abs(result.indicators[7].value - 0.0412636171676) < 1e-10); +}); + +test('user sees short ratios and nonzero small funding rates', () => { + // Given ordinary ratios and fractional rates use different precision contracts + const cases = [[0.00000029, '0.000029%'], [0.0000424, '0.00424%'], [0, '0%'], [-0.00000029, '-0.000029%'], [5e-16, '5.00e-14%']]; + + // When the public formatters prepare their display strings + const actual = cases.map(([value]) => formatFundingPercent(value)); + + // Then ratios are compact and tiny genuine funding is never shown as zero + assert.deepEqual(actual, cases.map(([, expected]) => expected)); + assert.equal(formatMetricValue('top-accounts', 1.4963, 'en'), '1.50'); + assert.equal(formatMetricValue('basis', -0.001, 'en'), '-0.10%'); + assert.equal(formatMetricValue('oi', 373_780_000, 'zh-CN'), '3.7378亿'); + assert.equal(formatMetricValue('oi', 373_780_000, 'en'), '373.78M'); +}); + +test('user receives explicit invalid-data errors instead of zero-valued history', () => { + // Given malformed and wrong-symbol data cannot describe this contract + const base = { timestamp: 1791530100000, longShortRatio: '1.5' }; + + // When the endpoint parser encounters a missing value or mismatched identity + const missing = () => parseHistory('topAccountRatio', [{ ...base, longShortRatio: null }], 'STRKUSDT'); + const mismatch = () => parseHistory('topAccountRatio', [{ ...base, symbol: 'BTCUSDT' }], 'STRKUSDT'); + + // Then the invalid source is rejected while a valid empty history remains empty + assert.throws(missing, /Missing numeric/); + assert.throws(mismatch, /symbol mismatch/); + assert.deepEqual(parseHistory('fundingRate', [], 'STRKUSDT'), []); + assert.throws(() => parseHistory('fundingRate', [...funding].reverse(), 'STRKUSDT'), /increasing timestamps/); +}); + +test('user sees buy and sell volumes and excludes unavailable indicators from votes', () => { + // Given a single valid volume period and a zero circulating supply + const taker = parseHistory('takerRatio', [{ timestamp: 1791530100000, buySellRatio: '1.1339', buyVol: '22678000', sellVol: '20000000' }], 'STRKUSDT'); + const oi = parseHistory('openInterest', [{ timestamp: 1791530100000, sumOpenInterest: '20', CMCCirculatingSupply: '0' }], 'STRKUSDT'); + + // When the panel computes the available observations + const result = computeTradingSignals({ takerRatio: taker, openInterest: oi }, new Set(), 'en', 'STRKUSDT'); + + // Then bar values stay in coin units and a missing supply ratio is not zero + assert.equal(result.indicators[4].display, '1.13'); + assert.equal(result.indicators[4].history[0].buy, 22_678_000); + assert.equal(result.indicators[4].history[0].sell, 20_000_000); + assert.equal(result.indicators[7].display, '--'); + assert.equal(result.total, 2); + assert.equal(result.longCount, 1); +}); diff --git a/test/unit/data-media-host-contract.test.js b/test/unit/data-media-host-contract.test.js index 733696ee..33382e44 100644 --- a/test/unit/data-media-host-contract.test.js +++ b/test/unit/data-media-host-contract.test.js @@ -73,6 +73,53 @@ test('user receives distinct GM response, network failure, and timeout callbacks assert.equal(network.requests.every(request => request.settled), true); }); +test('user aborts a pending fetch once without delivering a later response', async () => { + // Given an HTTP request is pending with the browser AbortSignal contract + const network = createDataMediaNetwork(); + const controller = new AbortController(); + const pending = network.fetch('https://example.test/funding', { signal: controller.signal }); + const rejection = assert.rejects(pending, { name: 'AbortError' }); + + // When the request owner aborts before the response is available + controller.abort(); + await rejection; + + // Then cancellation is terminal and a late response cannot be delivered + assert.equal(network.requests[0].settled, true); + assert.equal(network.requests[0].aborted, true); + assert.throws(() => network.requests[0].respond({ late: true }), /one terminal outcome/); +}); + +test('user does not start a fetch with an already cancelled signal', async () => { + // Given the browser signal already records a specific timeout reason + const network = createDataMediaNetwork(); + const controller = new AbortController(); + controller.abort(new DOMException('Funding request timed out', 'TimeoutError')); + + // When the external fetch boundary receives the cancelled signal + const pending = network.fetch('https://example.test/funding', { signal: controller.signal }); + + // Then the original timeout is preserved without issuing a request + await assert.rejects(pending, { name: 'TimeoutError', message: 'Funding request timed out' }); + assert.equal(network.requests.length, 0); +}); + +test('user keeps a completed fetch successful when its signal is cancelled later', async () => { + // Given a fetch owns a signal that is still active when its response arrives + const network = createDataMediaNetwork(); + const controller = new AbortController(); + const pending = network.fetch('https://example.test/funding', { signal: controller.signal }); + network.requests[0].respond({ value: 7 }); + + // When the owner cancels after completion + controller.abort(); + const response = await pending; + + // Then the completed response remains successful and is not marked aborted + assert.deepEqual(await response.json(), { value: 7 }); + assert.equal(network.requests[0].aborted, false); +}); + test('user waits for matching DOM content without waiting for elapsed wall time', async t => { // Given a document whose completion text is not yet present const dom = new JSDOM('
Loading
');