diff --git a/docs/binance-orderbook-trade-ui-automation.md b/docs/binance-orderbook-trade-ui-automation.md index d744ec3..21566af 100644 --- a/docs/binance-orderbook-trade-ui-automation.md +++ b/docs/binance-orderbook-trade-ui-automation.md @@ -199,6 +199,63 @@ used as a reason to retain event buffers or persistent probes. exchange limit minus one. - Verify no fills, no residual test-owned orders, and restored page state after each run. +Store every live run as a strict capture rather than copying timing numbers from chat: + +```bash +npm run summarize:binance-orderbook-live -- /path/to/capture.json +``` + +The first checked-in L4 reference is the zero-order HYPEUSDT run: + +- `e2e/binance-orderbook/live-baselines/no-orders-2026-08-26.capture.json` +- `e2e/binance-orderbook/live-baselines/no-orders.baseline.json` + +Each scenario declares its applicable wall-clock segments and contains at least three +isolated samples. Every sample must prove restored UI state, no fills, zero residual +test-owned orders, zero uncaught errors, and bounded long-task observations. Missing +segments are invalid data, not zero-duration work. + +`testOrderLedger` is the evidence behind the fill and cleanup claims. Its `created`, +`fills`, and `residual` collections use the same exact order identity: symbol, side, +position side, price, quantity, and creation timestamp. A fill or residual record that +does not match a created test order invalidates the capture. An empty ledger is valid +for no-order and dialog-cancel scenarios; account-wide count changes never create test +ownership. + +Compare a new capture with a checked-in summary without making network timing a PR +gate by default: + +```bash +npm run summarize:binance-orderbook-live -- \ + /path/to/capture.json \ + --compare /path/to/baseline.json +``` + +`--enforce` is reserved for a controlled machine and stable scenario. It fails only +when a metric exceeds both the baseline ratio and absolute tolerance recorded in the +baseline comparison policy. Account orders remain user-owned unless a test ledger +proves symbol, side, price, quantity, and creation-time ownership. + +The baseline JSON root is the generated summary shape plus one required field: + +```json +{ + "schemaVersion": 1, + "capturedAt": "2026-08-26T08:00:00.000Z", + "environment": {}, + "scenarios": [], + "comparisonPolicy": { + "absoluteToleranceMs": 50, + "medianRatio": 1.5, + "p95Ratio": 1.5 + } +} +``` + +The environment and scenarios shown as empty placeholders above must be copied from +the validated summary output. A malformed or incomplete baseline fails validation; +it cannot silently turn a regression into a passing comparison. + ## Change Workflow 1. Convert a defect into a failing scenario or invariant assertion. diff --git a/e2e/binance-orderbook/live-baselines/no-orders-2026-08-26.capture.json b/e2e/binance-orderbook/live-baselines/no-orders-2026-08-26.capture.json new file mode 100644 index 0000000..1d530ac --- /dev/null +++ b/e2e/binance-orderbook/live-baselines/no-orders-2026-08-26.capture.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": 1, + "capturedAt": "2026-08-26T08:30:11.000Z", + "environment": { + "browser": "Chrome 151.0.0.0", + "os": "macOS (MacIntel)", + "route": "https://www.binance.com/zh-CN/futures/HYPEUSDT", + "symbol": "HYPEUSDT", + "userscriptSha256": "cc4135902ad4ad62eb6188828479780a8e194713be8067dc10c8c73428c5dcdb", + "userscriptVersion": "2.7.119" + }, + "scenarios": [ + { + "name": "cancel-current-symbol-no-orders", + "applicableSegments": [ + "clickToFirstFeedback", + "clickToFinalReady" + ], + "samples": [ + { + "segmentsMs": { + "clickToFirstFeedback": 30.6, + "clickToFinalReady": 637.1 + }, + "testOrderLedger": { + "created": [], + "fills": [], + "residual": [] + }, + "stateRestored": true, + "noFills": true, + "residualTestOrders": 0, + "uncaughtErrors": 0, + "maxLongTaskMs": 0, + "maxLongAnimationFrameMs": 0 + }, + { + "segmentsMs": { + "clickToFirstFeedback": 33, + "clickToFinalReady": 630.8 + }, + "testOrderLedger": { + "created": [], + "fills": [], + "residual": [] + }, + "stateRestored": true, + "noFills": true, + "residualTestOrders": 0, + "uncaughtErrors": 0, + "maxLongTaskMs": 0, + "maxLongAnimationFrameMs": 0 + }, + { + "segmentsMs": { + "clickToFirstFeedback": 38.3, + "clickToFinalReady": 646.7 + }, + "testOrderLedger": { + "created": [], + "fills": [], + "residual": [] + }, + "stateRestored": true, + "noFills": true, + "residualTestOrders": 0, + "uncaughtErrors": 0, + "maxLongTaskMs": 54, + "maxLongAnimationFrameMs": 56.1 + } + ] + } + ] +} diff --git a/e2e/binance-orderbook/live-baselines/no-orders.baseline.json b/e2e/binance-orderbook/live-baselines/no-orders.baseline.json new file mode 100644 index 0000000..d8b0a9c --- /dev/null +++ b/e2e/binance-orderbook/live-baselines/no-orders.baseline.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "capturedAt": "2026-08-26T08:30:11.000Z", + "environment": { + "browser": "Chrome 151.0.0.0", + "os": "macOS (MacIntel)", + "route": "https://www.binance.com/zh-CN/futures/HYPEUSDT", + "symbol": "HYPEUSDT", + "userscriptSha256": "cc4135902ad4ad62eb6188828479780a8e194713be8067dc10c8c73428c5dcdb", + "userscriptVersion": "2.7.119" + }, + "scenarios": [ + { + "name": "cancel-current-symbol-no-orders", + "sampleCount": 3, + "segmentsMs": { + "clickToFirstFeedback": { + "min": 30.6, + "median": 33, + "p95": 38.3, + "max": 38.3 + }, + "clickToFinalReady": { + "min": 630.8, + "median": 637.1, + "p95": 646.7, + "max": 646.7 + } + }, + "maxLongTaskMs": { + "min": 0, + "median": 0, + "p95": 54, + "max": 54 + }, + "maxLongAnimationFrameMs": { + "min": 0, + "median": 0, + "p95": 56.1, + "max": 56.1 + } + } + ], + "comparisonPolicy": { + "absoluteToleranceMs": 50, + "medianRatio": 1.5, + "p95Ratio": 1.5 + } +} diff --git a/package.json b/package.json index b136f26..104f8e8 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "import:brooks-media-index": "node scripts/brooks-media-import-index.mjs", "download:brooks-media": "node scripts/brooks-media-download.mjs", "inspect:binance-orderbook-trade": "node scripts/userscript-release-contract.mjs scripts/binance-orderbook-trade.user.js", + "summarize:binance-orderbook-live": "node scripts/binance-live-performance.mjs", "check:binance-userscripts": "node --check scripts/binance-orderbook-trade.user.js && node --check scripts/binance-trading-data.user.js && node --check scripts/binance-coinmarketcap-data.user.js", "check:binance-orderbook-trade": "node --check scripts/binance-orderbook-trade.user.js", "check:m3u8-downloader": "node --check scripts/m3u8-downloader.user.js", diff --git a/scripts/binance-live-performance.mjs b/scripts/binance-live-performance.mjs new file mode 100644 index 0000000..99a3299 --- /dev/null +++ b/scripts/binance-live-performance.mjs @@ -0,0 +1,386 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const REQUIRED_SAMPLE_FIELDS = [ + 'testOrderLedger', + 'stateRestored', + 'noFills', + 'residualTestOrders', + 'uncaughtErrors', + 'maxLongTaskMs', + 'maxLongAnimationFrameMs', +]; + +const ENVIRONMENT_FIELDS = [ + 'browser', + 'os', + 'route', + 'symbol', + 'userscriptSha256', + 'userscriptVersion', +]; + +const ORDER_LEDGER_FIELDS = ['created', 'fills', 'residual']; +const ORDER_RECORD_FIELDS = ['createdAt', 'positionSide', 'price', 'quantity', 'side', 'symbol']; +const SUMMARY_STAT_FIELDS = ['min', 'median', 'p95', 'max']; + +function assertRecord(value, path) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${path} must be an object`); + } +} + +function assertFiniteNonNegative(value, path) { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${path} must be a finite non-negative number`); + } +} + +function assertIsoTimestamp(value, path) { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new Error(`${path} must be an ISO timestamp`); + } +} + +function assertExactKeys(record, expectedKeys, path) { + const actual = Object.keys(record).sort(); + const expected = [...expectedKeys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${path} keys must be exactly: ${expected.join(', ')}`); + } +} + +function validateEnvironment(environment, path) { + assertRecord(environment, path); + assertExactKeys(environment, ENVIRONMENT_FIELDS, path); + for (const key of ['browser', 'os', 'route', 'symbol', 'userscriptVersion']) { + if (typeof environment[key] !== 'string' || environment[key].length === 0) { + throw new Error(`${path}.${key} must be a non-empty string`); + } + } + if (!/^[a-f0-9]{64}$/.test(environment.userscriptSha256)) { + throw new Error(`${path}.userscriptSha256 must be a SHA-256 hex digest`); + } +} + +function serializeOrderRecord(record) { + return ORDER_RECORD_FIELDS.map((field) => record[field]).join('\u0000'); +} + +function validateOrderRecord(record, path) { + assertRecord(record, path); + assertExactKeys(record, ORDER_RECORD_FIELDS, path); + assertIsoTimestamp(record.createdAt, `${path}.createdAt`); + for (const key of ['positionSide', 'price', 'quantity', 'side', 'symbol']) { + if (typeof record[key] !== 'string' || record[key].length === 0) { + throw new Error(`${path}.${key} must be a non-empty string`); + } + } +} + +function validateOrderLedger(ledger, path) { + assertRecord(ledger, path); + assertExactKeys(ledger, ORDER_LEDGER_FIELDS, path); + const keysByCollection = new Map(); + for (const collection of ORDER_LEDGER_FIELDS) { + const records = ledger[collection]; + if (!Array.isArray(records)) throw new Error(`${path}.${collection} must be an array`); + const keys = records.map((record, index) => { + validateOrderRecord(record, `${path}.${collection}[${index}]`); + return serializeOrderRecord(record); + }); + if (new Set(keys).size !== keys.length) { + throw new Error(`${path}.${collection} must not contain duplicate orders`); + } + keysByCollection.set(collection, new Set(keys)); + } + for (const collection of ['fills', 'residual']) { + for (const key of keysByCollection.get(collection)) { + if (!keysByCollection.get('created').has(key)) { + throw new Error(`${path}.${collection} must reference a created test order`); + } + } + } +} + +export function validateLivePerformanceCapture(capture) { + assertRecord(capture, 'capture'); + assertExactKeys(capture, ['schemaVersion', 'capturedAt', 'environment', 'scenarios'], 'capture'); + if (capture.schemaVersion !== 1) throw new Error('capture.schemaVersion must equal 1'); + assertIsoTimestamp(capture.capturedAt, 'capture.capturedAt'); + validateEnvironment(capture.environment, 'capture.environment'); + if (!Array.isArray(capture.scenarios) || capture.scenarios.length === 0) { + throw new Error('capture.scenarios must contain at least one scenario'); + } + + const names = new Set(); + for (const [scenarioIndex, scenario] of capture.scenarios.entries()) { + const path = `capture.scenarios[${scenarioIndex}]`; + assertRecord(scenario, path); + assertExactKeys(scenario, ['name', 'applicableSegments', 'samples'], path); + if (typeof scenario.name !== 'string' || scenario.name.length === 0) { + throw new Error(`${path}.name must be a non-empty string`); + } + if (names.has(scenario.name)) throw new Error(`Duplicate scenario name: ${scenario.name}`); + names.add(scenario.name); + if (!Array.isArray(scenario.applicableSegments) || scenario.applicableSegments.length === 0) { + throw new Error(`${path}.applicableSegments must not be empty`); + } + if (new Set(scenario.applicableSegments).size !== scenario.applicableSegments.length) { + throw new Error(`${path}.applicableSegments must be unique`); + } + for (const [segmentIndex, segment] of scenario.applicableSegments.entries()) { + if (typeof segment !== 'string' || segment.length === 0) { + throw new Error(`${path}.applicableSegments[${segmentIndex}] must be a non-empty string`); + } + } + if (!Array.isArray(scenario.samples) || scenario.samples.length < 3) { + throw new Error(`${path}.samples must contain at least three isolated samples`); + } + + for (const [sampleIndex, sample] of scenario.samples.entries()) { + const samplePath = `${path}.samples[${sampleIndex}]`; + assertRecord(sample, samplePath); + assertExactKeys(sample, ['segmentsMs', ...REQUIRED_SAMPLE_FIELDS], samplePath); + validateOrderLedger(sample.testOrderLedger, `${samplePath}.testOrderLedger`); + assertRecord(sample.segmentsMs, `${samplePath}.segmentsMs`); + assertExactKeys(sample.segmentsMs, scenario.applicableSegments, `${samplePath}.segmentsMs`); + for (const segment of scenario.applicableSegments) { + assertFiniteNonNegative(sample.segmentsMs[segment], `${samplePath}.segmentsMs.${segment}`); + } + if (sample.stateRestored !== true) throw new Error(`${samplePath}.stateRestored must be true`); + if (sample.noFills !== (sample.testOrderLedger.fills.length === 0)) { + throw new Error(`${samplePath}.noFills must match testOrderLedger.fills`); + } + if (sample.noFills !== true) throw new Error(`${samplePath}.noFills must be true`); + if (sample.residualTestOrders !== sample.testOrderLedger.residual.length) { + throw new Error(`${samplePath}.residualTestOrders must match testOrderLedger.residual`); + } + if (sample.residualTestOrders !== 0) throw new Error(`${samplePath}.residualTestOrders must equal 0`); + if (sample.uncaughtErrors !== 0) throw new Error(`${samplePath}.uncaughtErrors must equal 0`); + assertFiniteNonNegative(sample.maxLongTaskMs, `${samplePath}.maxLongTaskMs`); + assertFiniteNonNegative( + sample.maxLongAnimationFrameMs, + `${samplePath}.maxLongAnimationFrameMs`, + ); + } + } + return capture; +} + +function validateSummaryStats(stats, path) { + assertRecord(stats, path); + assertExactKeys(stats, SUMMARY_STAT_FIELDS, path); + for (const field of SUMMARY_STAT_FIELDS) { + assertFiniteNonNegative(stats[field], `${path}.${field}`); + } + if (!(stats.min <= stats.median && stats.median <= stats.p95 && stats.p95 <= stats.max)) { + throw new Error(`${path} must satisfy min <= median <= p95 <= max`); + } +} + +export function validateLivePerformanceSummary(summary, path = 'summary') { + assertRecord(summary, path); + assertExactKeys(summary, ['schemaVersion', 'capturedAt', 'environment', 'scenarios'], path); + if (summary.schemaVersion !== 1) throw new Error(`${path}.schemaVersion must equal 1`); + assertIsoTimestamp(summary.capturedAt, `${path}.capturedAt`); + validateEnvironment(summary.environment, `${path}.environment`); + if (!Array.isArray(summary.scenarios) || summary.scenarios.length === 0) { + throw new Error(`${path}.scenarios must contain at least one scenario`); + } + const names = new Set(); + for (const [scenarioIndex, scenario] of summary.scenarios.entries()) { + const scenarioPath = `${path}.scenarios[${scenarioIndex}]`; + assertRecord(scenario, scenarioPath); + assertExactKeys( + scenario, + ['name', 'sampleCount', 'segmentsMs', 'maxLongTaskMs', 'maxLongAnimationFrameMs'], + scenarioPath, + ); + if (typeof scenario.name !== 'string' || scenario.name.length === 0) { + throw new Error(`${scenarioPath}.name must be a non-empty string`); + } + if (names.has(scenario.name)) throw new Error(`Duplicate scenario name: ${scenario.name}`); + names.add(scenario.name); + if (!Number.isInteger(scenario.sampleCount) || scenario.sampleCount < 3) { + throw new Error(`${scenarioPath}.sampleCount must be an integer of at least 3`); + } + assertRecord(scenario.segmentsMs, `${scenarioPath}.segmentsMs`); + if (Object.keys(scenario.segmentsMs).length === 0) { + throw new Error(`${scenarioPath}.segmentsMs must not be empty`); + } + for (const [segment, stats] of Object.entries(scenario.segmentsMs)) { + if (segment.length === 0) throw new Error(`${scenarioPath}.segmentsMs contains an empty key`); + validateSummaryStats(stats, `${scenarioPath}.segmentsMs.${segment}`); + } + validateSummaryStats(scenario.maxLongTaskMs, `${scenarioPath}.maxLongTaskMs`); + validateSummaryStats( + scenario.maxLongAnimationFrameMs, + `${scenarioPath}.maxLongAnimationFrameMs`, + ); + } + return summary; +} + +function validateComparisonPolicy(policy, path = 'comparisonPolicy') { + assertRecord(policy, path); + assertExactKeys(policy, ['absoluteToleranceMs', 'medianRatio', 'p95Ratio'], path); + assertFiniteNonNegative(policy.absoluteToleranceMs, `${path}.absoluteToleranceMs`); + if (!Number.isFinite(policy.medianRatio) || policy.medianRatio < 1) { + throw new Error(`${path}.medianRatio must be at least 1`); + } + if (!Number.isFinite(policy.p95Ratio) || policy.p95Ratio < 1) { + throw new Error(`${path}.p95Ratio must be at least 1`); + } + return policy; +} + +export function validateLivePerformanceBaseline(baseline) { + assertRecord(baseline, 'baseline'); + assertExactKeys( + baseline, + ['schemaVersion', 'capturedAt', 'environment', 'scenarios', 'comparisonPolicy'], + 'baseline', + ); + const { comparisonPolicy, ...summary } = baseline; + validateLivePerformanceSummary(summary, 'baseline'); + validateComparisonPolicy(comparisonPolicy, 'baseline.comparisonPolicy'); + return baseline; +} + +function percentile(values, percentileValue) { + const sorted = [...values].sort((left, right) => left - right); + const rank = Math.max(1, Math.ceil(sorted.length * percentileValue)); + return sorted[rank - 1]; +} + +function median(values) { + const sorted = [...values].sort((left, right) => left - right); + const midpoint = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 + ? sorted[midpoint] + : (sorted[midpoint - 1] + sorted[midpoint]) / 2; +} + +function summarizeValues(values) { + return Object.freeze({ + min: Math.min(...values), + median: median(values), + p95: percentile(values, 0.95), + max: Math.max(...values), + }); +} + +export function summarizeLivePerformanceCapture(capture) { + validateLivePerformanceCapture(capture); + const summary = Object.freeze({ + schemaVersion: 1, + capturedAt: capture.capturedAt, + environment: capture.environment, + scenarios: capture.scenarios.map((scenario) => Object.freeze({ + name: scenario.name, + sampleCount: scenario.samples.length, + segmentsMs: Object.fromEntries(scenario.applicableSegments.map((segment) => [ + segment, + summarizeValues(scenario.samples.map((sample) => sample.segmentsMs[segment])), + ])), + maxLongTaskMs: summarizeValues(scenario.samples.map((sample) => sample.maxLongTaskMs)), + maxLongAnimationFrameMs: summarizeValues( + scenario.samples.map((sample) => sample.maxLongAnimationFrameMs), + ), + })), + }); + validateLivePerformanceSummary(summary); + return summary; +} + +export function compareLivePerformanceSummaries(current, baseline, policy) { + validateLivePerformanceSummary(current, 'current'); + validateLivePerformanceSummary(baseline, 'baseline'); + validateComparisonPolicy(policy, 'policy'); + + const findings = []; + const baselineScenarios = new Map(baseline.scenarios.map((scenario) => [scenario.name, scenario])); + const currentScenarioNames = new Set(current.scenarios.map((scenario) => scenario.name)); + for (const scenario of current.scenarios) { + const previous = baselineScenarios.get(scenario.name); + if (!previous) { + findings.push({ scenario: scenario.name, metric: null, reason: 'missing-baseline' }); + continue; + } + for (const [segment, stats] of Object.entries(scenario.segmentsMs)) { + const previousStats = previous.segmentsMs[segment]; + if (!previousStats) { + findings.push({ scenario: scenario.name, metric: segment, reason: 'missing-baseline' }); + continue; + } + for (const [stat, ratio] of [['median', policy.medianRatio], ['p95', policy.p95Ratio]]) { + const limit = Math.max( + previousStats[stat] + policy.absoluteToleranceMs, + previousStats[stat] * ratio, + ); + if (stats[stat] > limit) { + findings.push({ + scenario: scenario.name, + metric: `${segment}.${stat}`, + reason: 'regression', + baseline: previousStats[stat], + current: stats[stat], + limit, + }); + } + } + } + } + for (const scenario of baseline.scenarios) { + if (!currentScenarioNames.has(scenario.name)) { + findings.push({ scenario: scenario.name, metric: null, reason: 'missing-current' }); + } + } + return Object.freeze(findings); +} + +function parseArguments(args) { + if (args.length === 0) { + throw new Error('Usage: node scripts/binance-live-performance.mjs [--compare ] [--enforce]'); + } + const capturePath = args[0]; + let baselinePath = null; + let enforce = false; + for (let index = 1; index < args.length; index += 1) { + if (args[index] === '--enforce') { + enforce = true; + continue; + } + if (args[index] === '--compare' && args[index + 1]) { + baselinePath = args[index + 1]; + index += 1; + continue; + } + throw new Error(`Unknown argument: ${args[index]}`); + } + if (enforce && !baselinePath) throw new Error('--enforce requires --compare'); + return { capturePath, baselinePath, enforce }; +} + +export async function runLivePerformanceCli(args) { + const { capturePath, baselinePath, enforce } = parseArguments(args); + const capture = JSON.parse(await readFile(resolve(capturePath), 'utf8')); + const summary = summarizeLivePerformanceCapture(capture); + if (!baselinePath) return { summary, findings: [] }; + + const baseline = JSON.parse(await readFile(resolve(baselinePath), 'utf8')); + validateLivePerformanceBaseline(baseline); + const { comparisonPolicy, ...baselineSummary } = baseline; + const findings = compareLivePerformanceSummaries(summary, baselineSummary, comparisonPolicy); + if (enforce && findings.length > 0) { + throw new Error(`Live performance regressions: ${JSON.stringify(findings)}`); + } + return { summary, findings }; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const result = await runLivePerformanceCli(process.argv.slice(2)); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} diff --git a/test/unit/binance-live-performance.test.js b/test/unit/binance-live-performance.test.js new file mode 100644 index 0000000..bc4a973 --- /dev/null +++ b/test/unit/binance-live-performance.test.js @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + compareLivePerformanceSummaries, + runLivePerformanceCli, + summarizeLivePerformanceCapture, + validateLivePerformanceBaseline, + validateLivePerformanceCapture, +} from '../../scripts/binance-live-performance.mjs'; + +const EMPTY_ORDER_LEDGER = Object.freeze({ created: [], fills: [], residual: [] }); + +function sample(clickToFeedback, dialog, finalReady, maxLongTaskMs = 0) { + return { + segmentsMs: { + clickToFirstFeedback: clickToFeedback, + clickToDialog: dialog, + decisionToFinalReady: finalReady, + }, + testOrderLedger: structuredClone(EMPTY_ORDER_LEDGER), + stateRestored: true, + noFills: true, + residualTestOrders: 0, + uncaughtErrors: 0, + maxLongTaskMs, + maxLongAnimationFrameMs: maxLongTaskMs + 5, + }; +} + +function capture(samples = [sample(90, 150, 140), sample(100, 160, 150), sample(110, 210, 170)]) { + return { + schemaVersion: 1, + capturedAt: '2026-08-26T08:00:00.000Z', + environment: { + browser: 'Chrome', + os: 'macOS', + route: 'https://www.binance.com/zh-CN/futures/HYPEUSDT', + symbol: 'HYPEUSDT', + userscriptVersion: '2.7.119', + userscriptSha256: 'c'.repeat(64), + }, + scenarios: [{ + name: 'cancel-dialog-cancel', + applicableSegments: ['clickToFirstFeedback', 'clickToDialog', 'decisionToFinalReady'], + samples, + }], + }; +} + +test('live performance capture requires three complete isolated samples', () => { + assert.equal(validateLivePerformanceCapture(capture()).schemaVersion, 1); + assert.throws( + () => validateLivePerformanceCapture(capture([sample(90, 150, 140), sample(100, 160, 150)])), + /at least three isolated samples/, + ); +}); + +test('live performance capture rejects missing segments and failed cleanup invariants', () => { + const missingSegment = capture(); + delete missingSegment.scenarios[0].samples[0].segmentsMs.clickToDialog; + assert.throws(() => validateLivePerformanceCapture(missingSegment), /keys must be exactly/); + + const residualOrder = capture(); + residualOrder.scenarios[0].samples[1].residualTestOrders = 1; + assert.throws(() => validateLivePerformanceCapture(residualOrder), /must match testOrderLedger.residual/); +}); + +test('live performance capture requires fill and residual claims to be backed by the test-order ledger', () => { + const order = { + symbol: 'HYPEUSDT', + side: 'SELL', + positionSide: 'SHORT', + price: '100', + quantity: '0.1', + createdAt: '2026-08-26T08:00:01.000Z', + }; + const unsupportedFill = capture(); + unsupportedFill.scenarios[0].samples[0].testOrderLedger.fills.push(order); + assert.throws( + () => validateLivePerformanceCapture(unsupportedFill), + /fills must reference a created test order/, + ); + + const mismatchedClaim = capture(); + mismatchedClaim.scenarios[0].samples[0].testOrderLedger.created.push(order); + mismatchedClaim.scenarios[0].samples[0].testOrderLedger.fills.push(order); + assert.throws( + () => validateLivePerformanceCapture(mismatchedClaim), + /noFills must match testOrderLedger.fills/, + ); +}); + +test('live performance summary calculates median and nearest-rank p95', () => { + const summary = summarizeLivePerformanceCapture(capture()); + const scenario = summary.scenarios[0]; + + assert.equal(scenario.sampleCount, 3); + assert.deepEqual(scenario.segmentsMs.clickToFirstFeedback, { + min: 90, + median: 100, + p95: 110, + max: 110, + }); + assert.deepEqual(scenario.segmentsMs.clickToDialog, { + min: 150, + median: 160, + p95: 210, + max: 210, + }); +}); + +test('baseline comparison reports only regressions beyond both tolerances', () => { + const baseline = summarizeLivePerformanceCapture(capture()); + const current = structuredClone(baseline); + current.scenarios[0].segmentsMs.clickToDialog.median = 260; + current.scenarios[0].segmentsMs.clickToDialog.p95 = 400; + current.scenarios[0].segmentsMs.clickToDialog.max = 400; + const findings = compareLivePerformanceSummaries(current, baseline, { + absoluteToleranceMs: 50, + medianRatio: 1.5, + p95Ratio: 1.5, + }); + + assert.deepEqual(findings, [ + { + scenario: 'cancel-dialog-cancel', + metric: 'clickToDialog.median', + reason: 'regression', + baseline: 160, + current: 260, + limit: 240, + }, + { + scenario: 'cancel-dialog-cancel', + metric: 'clickToDialog.p95', + reason: 'regression', + baseline: 210, + current: 400, + limit: 315, + }, + ]); +}); + +test('baseline validation rejects missing summary statistics instead of silently passing comparison', () => { + const summary = summarizeLivePerformanceCapture(capture()); + const baseline = { + ...structuredClone(summary), + comparisonPolicy: { absoluteToleranceMs: 50, medianRatio: 1.5, p95Ratio: 1.5 }, + }; + delete baseline.scenarios[0].segmentsMs.clickToDialog.median; + + assert.throws(() => validateLivePerformanceBaseline(baseline), /keys must be exactly/); + assert.throws( + () => compareLivePerformanceSummaries(summary, baseline, baseline.comparisonPolicy), + /keys must be exactly/, + ); +}); + +test('baseline comparison reports a baseline scenario omitted from the current capture', () => { + const baseline = summarizeLivePerformanceCapture(capture()); + const missingScenario = structuredClone(baseline.scenarios[0]); + missingScenario.name = 'cancel-dialog-confirm'; + baseline.scenarios.push(missingScenario); + const current = summarizeLivePerformanceCapture(capture()); + + assert.deepEqual( + compareLivePerformanceSummaries(current, baseline, { + absoluteToleranceMs: 50, + medianRatio: 1.5, + p95Ratio: 1.5, + }), + [{ scenario: 'cancel-dialog-confirm', metric: null, reason: 'missing-current' }], + ); +}); + +test('CLI compare and enforce use the documented baseline root shape', async (context) => { + const directory = await mkdtemp(join(tmpdir(), 'binance-live-performance-')); + context.after(() => rm(directory, { recursive: true, force: true })); + const capturePath = join(directory, 'capture.json'); + const baselinePath = join(directory, 'baseline.json'); + const liveCapture = capture(); + const summary = summarizeLivePerformanceCapture(liveCapture); + const baseline = { + ...summary, + comparisonPolicy: { absoluteToleranceMs: 50, medianRatio: 1.5, p95Ratio: 1.5 }, + }; + await writeFile(capturePath, JSON.stringify(liveCapture)); + await writeFile(baselinePath, JSON.stringify(baseline)); + + const passing = await runLivePerformanceCli([capturePath, '--compare', baselinePath, '--enforce']); + assert.deepEqual(passing.findings, []); + + liveCapture.scenarios[0].samples = [ + sample(90, 500, 140), + sample(100, 600, 150), + sample(110, 700, 170), + ]; + await writeFile(capturePath, JSON.stringify(liveCapture)); + await assert.rejects( + runLivePerformanceCli([capturePath, '--compare', baselinePath, '--enforce']), + /Live performance regressions/, + ); +});