From e390e3cea91f7988a158e621439feccfc1c4a0ee Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:13:08 +0800 Subject: [PATCH] test: parameterize Binance live order baselines --- docs/binance-orderbook-trade-ui-automation.md | 16 +- .../helpers/live-order-scale-config.js | 216 ++++++++++++++++++ .../no-orders-2026-08-26.capture.json | 6 + .../live-baselines/no-orders.baseline.json | 3 + e2e/binance-orderbook/live-configs/smoke.json | 11 + scripts/binance-live-performance.mjs | 102 ++++++++- .../binance-live-order-scale-config.test.js | 84 +++++++ test/unit/binance-live-performance.test.js | 93 +++++++- 8 files changed, 523 insertions(+), 8 deletions(-) create mode 100644 e2e/binance-orderbook/helpers/live-order-scale-config.js create mode 100644 e2e/binance-orderbook/live-configs/smoke.json create mode 100644 test/unit/binance-live-order-scale-config.test.js diff --git a/docs/binance-orderbook-trade-ui-automation.md b/docs/binance-orderbook-trade-ui-automation.md index 34eeed6..d84cfbd 100644 --- a/docs/binance-orderbook-trade-ui-automation.md +++ b/docs/binance-orderbook-trade-ui-automation.md @@ -195,8 +195,14 @@ used as a reason to retain event buffers or persistent probes. - Hard reload the logged-in page and verify the loaded source through raw CDP. - Run no-order, dialog-cancel, and explicitly authorized dialog-confirm paths. -- Run 1/20/40/120 order-scale performance samples without exceeding the current live - exchange limit minus one. +- Start live financial smoke coverage with one far-from-market order and one cleanup + cycle. Expand to multiple order scales only when the diagnostic goal explicitly + requires scale evidence and the live account has sufficient capacity. +- Run configurable small/medium/large order-scale performance samples. Derive the + effective counts from the internal test profile, exact rounded order notional, + available balance, current leverage, current-symbol order count, outstanding + test-owned count, and the live exchange limit minus one. These scale counts are + test-run inputs, not userscript UI settings or permanent product defaults. - 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: @@ -224,6 +230,12 @@ isolated samples. Every sample must prove restored UI state, no fills, zero resi test-owned orders, zero uncaught errors, and bounded long-task observations. Missing segments are invalid data, not zero-duration work. +Order-scale scenarios also persist the internal profile name, semantic scale label, +preferred and effective target counts, and sample count. Every sample persists its +live capacity evidence. Capacity shortfalls must skip or abort the scale explicitly; +they must never silently relabel a smaller run as medium or large. Current leverage is +read-only evidence for this calculation and the live runner must not change it. + `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 diff --git a/e2e/binance-orderbook/helpers/live-order-scale-config.js b/e2e/binance-orderbook/helpers/live-order-scale-config.js new file mode 100644 index 0000000..833bf3c --- /dev/null +++ b/e2e/binance-orderbook/helpers/live-order-scale-config.js @@ -0,0 +1,216 @@ +const PROFILE_FIELDS = [ + 'schemaVersion', + 'profileName', + 'sampleCount', + 'maxOrderCount', + 'scaleRatios', +]; +const LIVE_CONTEXT_FIELDS = [ + 'availableBalance', + 'currentLeverage', + 'perOrderPrice', + 'perOrderQuantity', + 'safetyFactor', + 'liveMaxNumOrdersLimit', + 'existingCurrentSymbolOpenOrders', + 'outstandingTestOwnedOrders', +]; +export const CAPACITY_EVIDENCE_FIELDS = Object.freeze([ + ...LIVE_CONTEXT_FIELDS, + 'perOrderNotional', + 'maxNewOrdersBySlots', + 'maxNewOrdersByMargin', +]); + +function assertRecord(value, path) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${path} must be an object`); + } +} + +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 parsePositiveDecimal(value, path) { + if (typeof value !== 'string' || !/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) { + throw new Error(`${path} must be an unsigned decimal string`); + } + const [whole, fraction = ''] = value.split('.'); + const numerator = BigInt(`${whole}${fraction}`); + if (numerator <= 0n) throw new Error(`${path} must be greater than zero`); + return { numerator, scale: 10n ** BigInt(fraction.length) }; +} + +function normalizeFraction(numerator, scale) { + let value = numerator; + let divisor = scale; + while (divisor > 1n && value % 10n === 0n) { + value /= 10n; + divisor /= 10n; + } + if (divisor === 1n) return value.toString(); + const digits = divisor.toString().length - 1; + return `${value / divisor}.${(value % divisor).toString().padStart(digits, '0')}`; +} + +function multiplyDecimalStrings(left, right, path) { + const a = parsePositiveDecimal(left, `${path}.left`); + const b = parsePositiveDecimal(right, `${path}.right`); + return normalizeFraction(a.numerator * b.numerator, a.scale * b.scale); +} + +function floorAffordableOrders({ + availableBalance, + currentLeverage, + safetyFactor, + perOrderNotional, +}) { + const balance = parsePositiveDecimal(availableBalance, 'liveContext.availableBalance'); + const safety = parsePositiveDecimal(safetyFactor, 'liveContext.safetyFactor'); + const notional = parsePositiveDecimal(perOrderNotional, 'capacity.perOrderNotional'); + const numerator = balance.numerator * BigInt(currentLeverage) * safety.numerator * notional.scale; + const denominator = balance.scale * safety.scale * notional.numerator; + return Number(numerator / denominator); +} + +export function validateLiveOrderScaleProfile(profile) { + assertRecord(profile, 'profile'); + assertExactKeys(profile, PROFILE_FIELDS, 'profile'); + if (profile.schemaVersion !== 1) throw new Error('profile.schemaVersion must equal 1'); + if (typeof profile.profileName !== 'string' || profile.profileName.length === 0) { + throw new Error('profile.profileName must be a non-empty string'); + } + for (const field of ['sampleCount', 'maxOrderCount']) { + if (!Number.isInteger(profile[field]) || profile[field] < 3) { + throw new Error(`profile.${field} must be an integer of at least 3`); + } + } + assertRecord(profile.scaleRatios, 'profile.scaleRatios'); + assertExactKeys(profile.scaleRatios, ['small', 'medium', 'large'], 'profile.scaleRatios'); + const ratios = ['small', 'medium', 'large'].map((label) => profile.scaleRatios[label]); + if (!ratios.every((ratio) => Number.isFinite(ratio) && ratio > 0 && ratio <= 1)) { + throw new Error('profile.scaleRatios must contain ratios in (0, 1]'); + } + if (!(ratios[0] < ratios[1] && ratios[1] < ratios[2] && ratios[2] === 1)) { + throw new Error('profile.scaleRatios must be strictly increasing and large must equal 1'); + } + return profile; +} + +function validateLiveContext(context) { + assertRecord(context, 'liveContext'); + assertExactKeys(context, LIVE_CONTEXT_FIELDS, 'liveContext'); + for (const field of ['availableBalance', 'perOrderPrice', 'perOrderQuantity', 'safetyFactor']) { + parsePositiveDecimal(context[field], `liveContext.${field}`); + } + const safety = parsePositiveDecimal(context.safetyFactor, 'liveContext.safetyFactor'); + if (safety.numerator > safety.scale) { + throw new Error('liveContext.safetyFactor must not exceed 1'); + } + for (const field of [ + 'currentLeverage', + 'liveMaxNumOrdersLimit', + 'existingCurrentSymbolOpenOrders', + 'outstandingTestOwnedOrders', + ]) { + if (!Number.isInteger(context[field]) || context[field] < 0) { + throw new Error(`liveContext.${field} must be a non-negative integer`); + } + } + if (context.currentLeverage === 0) throw new Error('liveContext.currentLeverage must be positive'); + if (context.liveMaxNumOrdersLimit < 2) { + throw new Error('liveContext.liveMaxNumOrdersLimit must be at least 2'); + } + return context; +} + +function deriveScaleCount(capacity, ratio, minimum) { + return Math.max(minimum, Math.round(capacity * ratio)); +} + +export function createLiveOrderScalePlan(profile, liveContext) { + validateLiveOrderScaleProfile(profile); + validateLiveContext(liveContext); + const perOrderNotional = multiplyDecimalStrings( + liveContext.perOrderPrice, + liveContext.perOrderQuantity, + 'capacity.perOrderNotional', + ); + const maxNewOrdersBySlots = Math.max( + 0, + Math.min(liveContext.liveMaxNumOrdersLimit - 1, 199) + - liveContext.existingCurrentSymbolOpenOrders + - liveContext.outstandingTestOwnedOrders, + ); + const maxNewOrdersByMargin = floorAffordableOrders({ + ...liveContext, + perOrderNotional, + }); + const effectiveCapacity = Math.min( + profile.maxOrderCount, + maxNewOrdersBySlots, + maxNewOrdersByMargin, + ); + if (effectiveCapacity < 3) { + throw new Error(`Live order capacity ${effectiveCapacity} cannot form three distinct scales`); + } + + const labels = ['small', 'medium', 'large']; + const counts = labels.map((label, index) => deriveScaleCount( + effectiveCapacity, + profile.scaleRatios[label], + index + 1, + )); + if (new Set(counts).size !== counts.length || counts[2] !== effectiveCapacity) { + throw new Error(`Scale ratios collapse at effective capacity ${effectiveCapacity}`); + } + + const capacityEvidence = Object.freeze({ + ...liveContext, + perOrderNotional, + maxNewOrdersBySlots, + maxNewOrdersByMargin, + }); + return Object.freeze({ + profileName: profile.profileName, + sampleCount: profile.sampleCount, + effectiveCapacity, + capacityEvidence, + scales: labels.map((label, index) => Object.freeze({ + label, + preferredTargetOrderCount: deriveScaleCount( + profile.maxOrderCount, + profile.scaleRatios[label], + index + 1, + ), + effectiveTargetOrderCount: counts[index], + })), + }); +} + +export function validateLiveOrderCapacityEvidence(evidence, path = 'capacityEvidence') { + assertRecord(evidence, path); + assertExactKeys(evidence, CAPACITY_EVIDENCE_FIELDS, path); + const context = Object.fromEntries(LIVE_CONTEXT_FIELDS.map((field) => [field, evidence[field]])); + const plan = createLiveOrderScalePlan({ + schemaVersion: 1, + profileName: 'evidence-validation', + sampleCount: 3, + maxOrderCount: 3, + scaleRatios: { small: 0.25, medium: 0.5, large: 1 }, + }, context); + if (evidence.perOrderNotional !== plan.capacityEvidence.perOrderNotional) { + throw new Error(`${path}.perOrderNotional does not match price x quantity`); + } + for (const field of ['maxNewOrdersBySlots', 'maxNewOrdersByMargin']) { + if (evidence[field] !== plan.capacityEvidence[field]) { + throw new Error(`${path}.${field} does not match its inputs`); + } + } + return evidence; +} 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 index 1d530ac..2b26b8e 100644 --- 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 @@ -12,12 +12,16 @@ "scenarios": [ { "name": "cancel-current-symbol-no-orders", + "parameters": { + "kind": "no-orders" + }, "applicableSegments": [ "clickToFirstFeedback", "clickToFinalReady" ], "samples": [ { + "capacityEvidence": null, "segmentsMs": { "clickToFirstFeedback": 30.6, "clickToFinalReady": 637.1 @@ -35,6 +39,7 @@ "maxLongAnimationFrameMs": 0 }, { + "capacityEvidence": null, "segmentsMs": { "clickToFirstFeedback": 33, "clickToFinalReady": 630.8 @@ -52,6 +57,7 @@ "maxLongAnimationFrameMs": 0 }, { + "capacityEvidence": null, "segmentsMs": { "clickToFirstFeedback": 38.3, "clickToFinalReady": 646.7 diff --git a/e2e/binance-orderbook/live-baselines/no-orders.baseline.json b/e2e/binance-orderbook/live-baselines/no-orders.baseline.json index d8b0a9c..a65c414 100644 --- a/e2e/binance-orderbook/live-baselines/no-orders.baseline.json +++ b/e2e/binance-orderbook/live-baselines/no-orders.baseline.json @@ -12,6 +12,9 @@ "scenarios": [ { "name": "cancel-current-symbol-no-orders", + "parameters": { + "kind": "no-orders" + }, "sampleCount": 3, "segmentsMs": { "clickToFirstFeedback": { diff --git a/e2e/binance-orderbook/live-configs/smoke.json b/e2e/binance-orderbook/live-configs/smoke.json new file mode 100644 index 0000000..7dab72f --- /dev/null +++ b/e2e/binance-orderbook/live-configs/smoke.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "profileName": "smoke", + "sampleCount": 3, + "maxOrderCount": 3, + "scaleRatios": { + "small": 0.25, + "medium": 0.5, + "large": 1 + } +} diff --git a/scripts/binance-live-performance.mjs b/scripts/binance-live-performance.mjs index 99a3299..ee989ab 100644 --- a/scripts/binance-live-performance.mjs +++ b/scripts/binance-live-performance.mjs @@ -2,7 +2,10 @@ import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { validateLiveOrderCapacityEvidence } from '../e2e/binance-orderbook/helpers/live-order-scale-config.js'; + const REQUIRED_SAMPLE_FIELDS = [ + 'capacityEvidence', 'testOrderLedger', 'stateRestored', 'noFills', @@ -104,6 +107,63 @@ function validateOrderLedger(ledger, path) { } } +function validateScenarioParameters(parameters, path) { + assertRecord(parameters, path); + if (parameters.kind === 'no-orders') { + assertExactKeys(parameters, ['kind'], path); + return parameters; + } + if (parameters.kind !== 'order-scale') { + throw new Error(`${path}.kind must equal no-orders or order-scale`); + } + assertExactKeys(parameters, [ + 'kind', + 'profileName', + 'label', + 'preferredTargetOrderCount', + 'effectiveTargetOrderCount', + 'sampleCount', + ], path); + if (typeof parameters.profileName !== 'string' || parameters.profileName.length === 0) { + throw new Error(`${path}.profileName must be a non-empty string`); + } + if (!['small', 'medium', 'large'].includes(parameters.label)) { + throw new Error(`${path}.label must equal small, medium, or large`); + } + for (const field of [ + 'preferredTargetOrderCount', + 'effectiveTargetOrderCount', + 'sampleCount', + ]) { + if (!Number.isInteger(parameters[field]) || parameters[field] <= 0) { + throw new Error(`${path}.${field} must be a positive integer`); + } + } + if (parameters.effectiveTargetOrderCount > parameters.preferredTargetOrderCount) { + throw new Error(`${path}.effectiveTargetOrderCount must not exceed the preferred target`); + } + if (parameters.sampleCount < 3) throw new Error(`${path}.sampleCount must be at least 3`); + return parameters; +} + +function validateCapacityEvidenceForScenario(evidence, parameters, ledger, path) { + if (parameters.kind === 'no-orders') { + if (evidence !== null) throw new Error(`${path} must be null for a no-orders scenario`); + if (ledger.created.length !== 0) throw new Error(`${path} no-orders ledger must be empty`); + return; + } + validateLiveOrderCapacityEvidence(evidence, path); + if (parameters.effectiveTargetOrderCount > evidence.maxNewOrdersBySlots) { + throw new Error(`${path} has insufficient order slots for the effective target`); + } + if (parameters.effectiveTargetOrderCount > evidence.maxNewOrdersByMargin) { + throw new Error(`${path} has insufficient margin capacity for the effective target`); + } + if (ledger.created.length !== parameters.effectiveTargetOrderCount) { + throw new Error(`${path} created ledger count must match the effective target`); + } +} + export function validateLivePerformanceCapture(capture) { assertRecord(capture, 'capture'); assertExactKeys(capture, ['schemaVersion', 'capturedAt', 'environment', 'scenarios'], 'capture'); @@ -118,12 +178,13 @@ export function validateLivePerformanceCapture(capture) { for (const [scenarioIndex, scenario] of capture.scenarios.entries()) { const path = `capture.scenarios[${scenarioIndex}]`; assertRecord(scenario, path); - assertExactKeys(scenario, ['name', 'applicableSegments', 'samples'], path); + assertExactKeys(scenario, ['name', 'parameters', '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); + validateScenarioParameters(scenario.parameters, `${path}.parameters`); if (!Array.isArray(scenario.applicableSegments) || scenario.applicableSegments.length === 0) { throw new Error(`${path}.applicableSegments must not be empty`); } @@ -138,12 +199,24 @@ export function validateLivePerformanceCapture(capture) { if (!Array.isArray(scenario.samples) || scenario.samples.length < 3) { throw new Error(`${path}.samples must contain at least three isolated samples`); } + if ( + scenario.parameters.kind === 'order-scale' + && scenario.parameters.sampleCount !== scenario.samples.length + ) { + throw new Error(`${path}.parameters.sampleCount must match samples.length`); + } 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`); + validateCapacityEvidenceForScenario( + sample.capacityEvidence, + scenario.parameters, + sample.testOrderLedger, + `${samplePath}.capacityEvidence`, + ); assertRecord(sample.segmentsMs, `${samplePath}.segmentsMs`); assertExactKeys(sample.segmentsMs, scenario.applicableSegments, `${samplePath}.segmentsMs`); for (const segment of scenario.applicableSegments) { @@ -195,7 +268,14 @@ export function validateLivePerformanceSummary(summary, path = 'summary') { assertRecord(scenario, scenarioPath); assertExactKeys( scenario, - ['name', 'sampleCount', 'segmentsMs', 'maxLongTaskMs', 'maxLongAnimationFrameMs'], + [ + 'name', + 'parameters', + 'sampleCount', + 'segmentsMs', + 'maxLongTaskMs', + 'maxLongAnimationFrameMs', + ], scenarioPath, ); if (typeof scenario.name !== 'string' || scenario.name.length === 0) { @@ -203,9 +283,16 @@ export function validateLivePerformanceSummary(summary, path = 'summary') { } if (names.has(scenario.name)) throw new Error(`Duplicate scenario name: ${scenario.name}`); names.add(scenario.name); + validateScenarioParameters(scenario.parameters, `${scenarioPath}.parameters`); if (!Number.isInteger(scenario.sampleCount) || scenario.sampleCount < 3) { throw new Error(`${scenarioPath}.sampleCount must be an integer of at least 3`); } + if ( + scenario.parameters.kind === 'order-scale' + && scenario.parameters.sampleCount !== scenario.sampleCount + ) { + throw new Error(`${scenarioPath}.parameters.sampleCount must match sampleCount`); + } assertRecord(scenario.segmentsMs, `${scenarioPath}.segmentsMs`); if (Object.keys(scenario.segmentsMs).length === 0) { throw new Error(`${scenarioPath}.segmentsMs must not be empty`); @@ -280,6 +367,7 @@ export function summarizeLivePerformanceCapture(capture) { environment: capture.environment, scenarios: capture.scenarios.map((scenario) => Object.freeze({ name: scenario.name, + parameters: structuredClone(scenario.parameters), sampleCount: scenario.samples.length, segmentsMs: Object.fromEntries(scenario.applicableSegments.map((segment) => [ segment, @@ -309,6 +397,16 @@ export function compareLivePerformanceSummaries(current, baseline, policy) { findings.push({ scenario: scenario.name, metric: null, reason: 'missing-baseline' }); continue; } + if (JSON.stringify(scenario.parameters) !== JSON.stringify(previous.parameters)) { + findings.push({ + scenario: scenario.name, + metric: null, + reason: 'configuration-mismatch', + baseline: previous.parameters, + current: scenario.parameters, + }); + continue; + } for (const [segment, stats] of Object.entries(scenario.segmentsMs)) { const previousStats = previous.segmentsMs[segment]; if (!previousStats) { diff --git a/test/unit/binance-live-order-scale-config.test.js b/test/unit/binance-live-order-scale-config.test.js new file mode 100644 index 0000000..9321d93 --- /dev/null +++ b/test/unit/binance-live-order-scale-config.test.js @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { + createLiveOrderScalePlan, + validateLiveOrderCapacityEvidence, + validateLiveOrderScaleProfile, +} from '../../e2e/binance-orderbook/helpers/live-order-scale-config.js'; + +const profile = JSON.parse(await readFile( + new URL('../../e2e/binance-orderbook/live-configs/smoke.json', import.meta.url), +)); + +function liveContext(overrides = {}) { + return { + availableBalance: '13.28', + currentLeverage: 5, + perOrderPrice: '2', + perOrderQuantity: '2.5', + safetyFactor: '0.8', + liveMaxNumOrdersLimit: 200, + existingCurrentSymbolOpenOrders: 0, + outstandingTestOwnedOrders: 0, + ...overrides, + }; +} + +test('smoke profile derives three internal scales from live capacity', () => { + assert.equal(validateLiveOrderScaleProfile(profile).profileName, 'smoke'); + const plan = createLiveOrderScalePlan(profile, liveContext()); + + assert.equal(plan.effectiveCapacity, 3); + assert.deepEqual(plan.scales, [ + { label: 'small', preferredTargetOrderCount: 1, effectiveTargetOrderCount: 1 }, + { label: 'medium', preferredTargetOrderCount: 2, effectiveTargetOrderCount: 2 }, + { label: 'large', preferredTargetOrderCount: 3, effectiveTargetOrderCount: 3 }, + ]); + assert.equal(plan.capacityEvidence.perOrderNotional, '5'); + assert.equal(plan.capacityEvidence.maxNewOrdersBySlots, 199); + assert.equal(plan.capacityEvidence.maxNewOrdersByMargin, 10); +}); + +test('scale counts are configuration-driven rather than fixed to smoke values', () => { + const plan = createLiveOrderScalePlan({ ...profile, maxOrderCount: 40 }, liveContext({ + availableBalance: '100', + })); + + assert.deepEqual(plan.scales.map((scale) => scale.effectiveTargetOrderCount), [10, 20, 40]); +}); + +test('insufficient live capacity fails instead of silently collapsing scale labels', () => { + assert.throws( + () => createLiveOrderScalePlan(profile, liveContext({ availableBalance: '0.4' })), + /cannot form three distinct scales/, + ); +}); + +test('capacity evidence validates actual notional and slot arithmetic', () => { + const evidence = createLiveOrderScalePlan(profile, liveContext()).capacityEvidence; + assert.equal(validateLiveOrderCapacityEvidence(evidence).perOrderNotional, '5'); + assert.throws( + () => validateLiveOrderCapacityEvidence({ ...evidence, perOrderNotional: '4.9' }), + /does not match price x quantity/, + ); +}); + +test('live order slots subtract existing and outstanding test-owned orders', () => { + const plan = createLiveOrderScalePlan(profile, liveContext({ + liveMaxNumOrdersLimit: 8, + existingCurrentSymbolOpenOrders: 2, + outstandingTestOwnedOrders: 1, + })); + + assert.equal(plan.capacityEvidence.maxNewOrdersBySlots, 4); + assert.equal(plan.effectiveCapacity, 3); +}); + +test('zero leverage fails instead of inventing test capacity', () => { + assert.throws( + () => createLiveOrderScalePlan(profile, liveContext({ currentLeverage: 0 })), + /currentLeverage must be positive/, + ); +}); diff --git a/test/unit/binance-live-performance.test.js b/test/unit/binance-live-performance.test.js index bc4a973..80b29c4 100644 --- a/test/unit/binance-live-performance.test.js +++ b/test/unit/binance-live-performance.test.js @@ -11,11 +11,13 @@ import { validateLivePerformanceBaseline, validateLivePerformanceCapture, } from '../../scripts/binance-live-performance.mjs'; +import { createLiveOrderScalePlan } from '../../e2e/binance-orderbook/helpers/live-order-scale-config.js'; const EMPTY_ORDER_LEDGER = Object.freeze({ created: [], fills: [], residual: [] }); function sample(clickToFeedback, dialog, finalReady, maxLongTaskMs = 0) { return { + capacityEvidence: null, segmentsMs: { clickToFirstFeedback: clickToFeedback, clickToDialog: dialog, @@ -45,12 +47,55 @@ function capture(samples = [sample(90, 150, 140), sample(100, 160, 150), sample( }, scenarios: [{ name: 'cancel-dialog-cancel', + parameters: { kind: 'no-orders' }, applicableSegments: ['clickToFirstFeedback', 'clickToDialog', 'decisionToFinalReady'], samples, }], }; } +function scaleSample(index) { + const result = sample(90 + index, 150 + index, 140 + index); + result.capacityEvidence = createLiveOrderScalePlan({ + schemaVersion: 1, + profileName: 'smoke', + sampleCount: 3, + maxOrderCount: 3, + scaleRatios: { small: 0.25, medium: 0.5, large: 1 }, + }, { + availableBalance: '13.28', + currentLeverage: 5, + perOrderPrice: '2', + perOrderQuantity: '2.5', + safetyFactor: '0.8', + liveMaxNumOrdersLimit: 200, + existingCurrentSymbolOpenOrders: 0, + outstandingTestOwnedOrders: 0, + }).capacityEvidence; + result.testOrderLedger.created.push({ + symbol: 'XRPUSDT', + side: 'SELL', + positionSide: 'SHORT', + price: '2', + quantity: '2.5', + createdAt: `2026-08-26T08:00:0${index + 1}.000Z`, + }); + return result; +} + +function scaleCapture() { + const result = capture([scaleSample(0), scaleSample(1), scaleSample(2)]); + result.scenarios[0].parameters = { + kind: 'order-scale', + profileName: 'smoke', + label: 'small', + preferredTargetOrderCount: 1, + effectiveTargetOrderCount: 1, + sampleCount: 3, + }; + return result; +} + test('live performance capture requires three complete isolated samples', () => { assert.equal(validateLivePerformanceCapture(capture()).schemaVersion, 1); assert.throws( @@ -78,22 +123,34 @@ test('live performance capture requires fill and residual claims to be backed by quantity: '0.1', createdAt: '2026-08-26T08:00:01.000Z', }; - const unsupportedFill = capture(); + const unsupportedFill = scaleCapture(); 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); + const mismatchedClaim = scaleCapture(); + mismatchedClaim.scenarios[0].samples[0].testOrderLedger.fills.push( + mismatchedClaim.scenarios[0].samples[0].testOrderLedger.created[0], + ); assert.throws( () => validateLivePerformanceCapture(mismatchedClaim), /noFills must match testOrderLedger.fills/, ); }); +test('order-scale capture binds effective target to capacity evidence and created ledger', () => { + assert.equal(validateLivePerformanceCapture(scaleCapture()).schemaVersion, 1); + const mismatchedCount = scaleCapture(); + mismatchedCount.scenarios[0].parameters.preferredTargetOrderCount = 2; + mismatchedCount.scenarios[0].parameters.effectiveTargetOrderCount = 2; + assert.throws( + () => validateLivePerformanceCapture(mismatchedCount), + /created ledger count must match the effective target/, + ); +}); + test('live performance summary calculates median and nearest-rank p95', () => { const summary = summarizeLivePerformanceCapture(capture()); const scenario = summary.scenarios[0]; @@ -145,6 +202,34 @@ test('baseline comparison reports only regressions beyond both tolerances', () = ]); }); +test('baseline comparison rejects the same scale name with different effective counts', () => { + const baseline = summarizeLivePerformanceCapture(capture()); + const current = structuredClone(baseline); + current.scenarios[0].parameters = { + kind: 'order-scale', + profileName: 'smoke', + label: 'small', + preferredTargetOrderCount: 1, + effectiveTargetOrderCount: 1, + sampleCount: 3, + }; + + assert.deepEqual( + compareLivePerformanceSummaries(current, baseline, { + absoluteToleranceMs: 50, + medianRatio: 1.5, + p95Ratio: 1.5, + }), + [{ + scenario: 'cancel-dialog-cancel', + metric: null, + reason: 'configuration-mismatch', + baseline: { kind: 'no-orders' }, + current: current.scenarios[0].parameters, + }], + ); +}); + test('baseline validation rejects missing summary statistics instead of silently passing comparison', () => { const summary = summarizeLivePerformanceCapture(capture()); const baseline = {