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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/binance-orderbook-trade-ui-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,20 @@ wall-clock segment from the recorded semantic events and verifies that the dialo
action matches the declared scenario kind. Do not manually transcribe timing values
from console output, screenshots, or chat into a capture.

Persist the environment, scenario parameters, raw probe snapshots, capacity evidence,
test-order ledgers, and restoration assertions as one strict raw bundle. Assemble the
validated capture with:

```bash
npm run assemble:binance-orderbook-live -- \
/path/to/raw-bundle.json \
/path/to/capture.json
```

The assembler rejects unknown bundle fields and refuses to overwrite its input file
or an existing capture. This command is the standard boundary between CDP collection
and capture summary; do not replace it with an ad hoc Node snippet.

The current checked-in L4 reference is the isolated zero-order HYPEUSDT run:

- `e2e/binance-orderbook/live-baselines/no-orders-2026-08-27.capture.json`
Expand Down
57 changes: 45 additions & 12 deletions e2e/binance-orderbook/helpers/live-capture-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ function assertRecord(value, path) {
}
}

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 readUniqueEvent(probe, kind) {
const matches = probe.events.filter((event) => event.kind === kind);
if (matches.length !== 1) {
Expand Down Expand Up @@ -81,13 +89,22 @@ function buildSegments(parameters, probe) {
};
}

export function buildLivePerformanceSample({
parameters,
probe,
capacityEvidence,
testOrderLedger,
stateRestored,
}) {
export function buildLivePerformanceSample(input) {
assertRecord(input, 'sample input');
assertExactKeys(input, [
'parameters',
'probe',
'capacityEvidence',
'testOrderLedger',
'stateRestored',
], 'sample input');
const {
parameters,
probe,
capacityEvidence,
testOrderLedger,
stateRestored,
} = input;
assertRecord(parameters, 'parameters');
validateLivePerformanceProbeSnapshot(probe);
assertRecord(testOrderLedger, 'testOrderLedger');
Expand All @@ -112,7 +129,10 @@ export function buildLivePerformanceSample({
});
}

export function buildLivePerformanceCapture({ capturedAt, environment, scenarios }) {
export function buildLivePerformanceCapture(input) {
assertRecord(input, 'input');
assertExactKeys(input, ['capturedAt', 'environment', 'scenarios'], 'input');
const { capturedAt, environment, scenarios } = input;
if (!Array.isArray(scenarios) || scenarios.length === 0) {
throw new Error('scenarios must contain at least one scenario');
}
Expand All @@ -122,17 +142,30 @@ export function buildLivePerformanceCapture({ capturedAt, environment, scenarios
environment: structuredClone(environment),
scenarios: scenarios.map((scenario, scenarioIndex) => {
assertRecord(scenario, `scenarios[${scenarioIndex}]`);
assertExactKeys(
scenario,
['name', 'parameters', 'samples'],
`scenarios[${scenarioIndex}]`,
);
if (!Array.isArray(scenario.samples)) {
throw new Error(`scenarios[${scenarioIndex}].samples must be an array`);
}
return {
name: scenario.name,
parameters: structuredClone(scenario.parameters),
applicableSegments: getLiveScenarioSegments(scenario.parameters?.kind),
samples: scenario.samples.map((sample) => buildLivePerformanceSample({
parameters: scenario.parameters,
...sample,
})),
samples: scenario.samples.map((sample, sampleIndex) => {
assertRecord(sample, `scenarios[${scenarioIndex}].samples[${sampleIndex}]`);
assertExactKeys(
sample,
['probe', 'capacityEvidence', 'testOrderLedger', 'stateRestored'],
`scenarios[${scenarioIndex}].samples[${sampleIndex}]`,
);
return buildLivePerformanceSample({
parameters: scenario.parameters,
...sample,
});
}),
};
}),
};
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"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",
"verify:binance-orderbook-stage3": "node scripts/binance-stage3-evidence.mjs",
"assemble:binance-orderbook-live": "node scripts/binance-live-capture.mjs",
"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",
Expand Down
41 changes: 41 additions & 0 deletions scripts/binance-live-capture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';

import {
buildLivePerformanceCapture,
} from '../e2e/binance-orderbook/helpers/live-capture-builder.js';

function parseArguments(args) {
if (args.length !== 2) {
throw new Error('Usage: node scripts/binance-live-capture.mjs <raw-bundle.json> <capture.json>');
}
const inputPath = resolve(args[0]);
const outputPath = resolve(args[1]);
if (inputPath === outputPath) throw new Error('input and output paths must differ');
return { inputPath, outputPath };
}

export async function runLiveCaptureAssemblyCli(args) {
const { inputPath, outputPath } = parseArguments(args);
const input = JSON.parse(await readFile(inputPath, 'utf8'));
const capture = buildLivePerformanceCapture(input);
await writeFile(outputPath, `${JSON.stringify(capture, null, 2)}\n`, {
encoding: 'utf8',
flag: 'wx',
});
return Object.freeze({
inputPath,
outputPath,
scenarioCount: capture.scenarios.length,
sampleCount: capture.scenarios.reduce(
(total, scenario) => total + scenario.samples.length,
0,
),
});
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const result = await runLiveCaptureAssemblyCli(process.argv.slice(2));
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}
12 changes: 12 additions & 0 deletions test/unit/binance-live-capture-builder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,15 @@ test('builds and validates a complete three-sample capture without manual timing
'clickToFinalReady',
]);
});

test('rejects unknown raw-bundle fields instead of silently dropping them', () => {
assert.throws(
() => buildLivePerformanceCapture({
capturedAt: '2026-08-27T01:00:00.000Z',
environment: {},
scenarios: [],
ignored: true,
}),
/input keys must be exactly/,
);
});
102 changes: 102 additions & 0 deletions test/unit/binance-live-capture-cli.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';

import { runLiveCaptureAssemblyCli } from '../../scripts/binance-live-capture.mjs';

function probeSnapshot(firstFeedbackMs) {
return {
schemaVersion: 1,
sessionId: `probe-${firstFeedbackMs}`,
scenarioName: 'cancel-current-symbol-no-orders',
capturedAt: '2026-08-27T01:00:00.000Z',
timeOriginMs: 1000,
armedAtMonotonicMs: 10,
startedAtMonotonicMs: 20,
startedAtWallClock: '2026-08-27T01:00:00.010Z',
finishedAtMonotonicMs: 120,
performanceSupport: { longTask: true, longAnimationFrame: true },
dropped: { events: 0, errors: 0, longTasks: 0, longAnimationFrames: 0 },
events: [
{ kind: 'cancel-click', atMs: 0, detail: null },
{ kind: 'first-feedback', atMs: firstFeedbackMs, detail: null },
{ kind: 'finished', atMs: 100, detail: null },
],
errors: [],
longTasks: [],
longAnimationFrames: [],
clickSemanticSignature: '{}',
lastSemanticSignature: '{}',
lastSemanticState: {},
firstFeedbackCaptured: true,
};
}

function rawBundle() {
return {
capturedAt: '2026-08-27T01:00:00.000Z',
environment: {
browser: 'Chrome 151.0.0.0',
os: 'macOS (MacIntel)',
route: 'https://www.binance.com/zh-CN/futures/HYPEUSDT',
symbol: 'HYPEUSDT',
userscriptSha256: 'a'.repeat(64),
userscriptVersion: '2.7.126',
},
scenarios: [{
name: 'cancel-current-symbol-no-orders',
parameters: { kind: 'no-orders' },
samples: [3, 4, 5].map((firstFeedbackMs) => ({
probe: probeSnapshot(firstFeedbackMs),
capacityEvidence: null,
testOrderLedger: { created: [], fills: [], residual: [] },
stateRestored: true,
})),
}],
};
}

test('CLI assembles a strict capture file from a raw probe bundle', async (context) => {
const directory = await mkdtemp(join(tmpdir(), 'binance-live-capture-'));
context.after(() => rm(directory, { recursive: true, force: true }));
const inputPath = join(directory, 'raw-bundle.json');
const outputPath = join(directory, 'capture.json');
await writeFile(inputPath, `${JSON.stringify(rawBundle(), null, 2)}\n`);

const result = await runLiveCaptureAssemblyCli([inputPath, outputPath]);
const capture = JSON.parse(await readFile(outputPath, 'utf8'));

assert.equal(result.outputPath, outputPath);
assert.equal(result.scenarioCount, 1);
assert.equal(result.sampleCount, 3);
assert.deepEqual(capture.scenarios[0].samples[0].segmentsMs, {
clickToFirstFeedback: 3,
clickToFinalReady: 100,
});
});

test('CLI refuses to overwrite its raw input bundle', async () => {
await assert.rejects(
runLiveCaptureAssemblyCli(['/tmp/live-bundle.json', '/tmp/live-bundle.json']),
/input and output paths must differ/,
);
});

test('CLI preserves an existing capture instead of overwriting evidence', async (context) => {
const directory = await mkdtemp(join(tmpdir(), 'binance-live-capture-existing-'));
context.after(() => rm(directory, { recursive: true, force: true }));
const inputPath = join(directory, 'raw-bundle.json');
const outputPath = join(directory, 'capture.json');
await Promise.all([
writeFile(inputPath, `${JSON.stringify(rawBundle(), null, 2)}\n`),
writeFile(outputPath, 'existing evidence\n'),
]);

await assert.rejects(
runLiveCaptureAssemblyCli([inputPath, outputPath]),
(error) => error.code === 'EEXIST',
);
assert.equal(await readFile(outputPath, 'utf8'), 'existing evidence\n');
});
Loading