From d6e7cc311af7dfd63eb9f05419f4b6bc9405702f Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:55:37 +0800 Subject: [PATCH] fix(strategy27): restore markers after refresh --- docs/binance-strategy27-events-development.md | 23 +- scripts/binance-strategy27-events.user.js | 222 ++++++++++++++++-- .../core/compound-candidate-client.js | 22 +- .../core/compound-candidate-contract.js | 30 +++ .../core/compound-candidate-controller.js | 21 +- .../core/compound-candidate-lifecycle.js | 14 ++ .../core/live-event-client.js | 33 ++- .../core/live-event-contract.js | 74 +++++- src/binance-strategy27-events/index.user.js | 31 ++- .../compound-candidate-controller.test.js | 62 ++--- .../strategy27-entrypoint.test.js | 118 +++++++++- .../compound-candidate-client.test.js | 29 ++- .../compound-candidate-lifecycle.test.js | 10 + .../live-event-client.test.js | 38 +-- .../live-event-contract.test.js | 102 ++++++++ test/unit/userscript-release-contract.test.js | 2 +- 16 files changed, 721 insertions(+), 110 deletions(-) diff --git a/docs/binance-strategy27-events-development.md b/docs/binance-strategy27-events-development.md index 4ff3a24..d4d53df 100644 --- a/docs/binance-strategy27-events-development.md +++ b/docs/binance-strategy27-events-development.md @@ -97,9 +97,13 @@ record shares one in-flight repair across timer and message callbacks. Cleanup skips IDs proven absent, while native removal failures still stop the owning job. Clear, reset, context changes and retention eviction invalidate repair ownership; late-created entities are removed instead of resurrecting retired records. -Reconciliation does not refresh retention timestamps or persist drawings across -reloads. A panel history reset is a separate lifecycle event, not evidence of -native entity eviction. +Reconciliation does not refresh retention timestamps. Drawings remain transient +and use `disableSave: true`, but a full page reload requests a bounded display +snapshot from the gateway before long polling and rebuilds retained ordinary and +compound records. The snapshot and its continuation cursor are committed with the +same Redis operation, so live messages after that cursor cannot be skipped. A +panel history reset is a separate lifecycle event, not evidence of native entity +eviction. ## Compound Candidate Extension @@ -107,7 +111,7 @@ ADR 032 in CorsairQuant owns the server-side rule and transport contract. The browser does not reconstruct candidates from ordinary events or recalculate market evidence. The client, lifecycle, panel, native chart layer and optional-job controller are wired into the entrypoint and tested together. The source and -generated install artifact are version 0.4.1 with identical metadata headers. +generated install artifact are version 0.4.2 with identical metadata headers. The generated artifact passes syntax, release-contract and isolated execution checks, including candidate delivery, paired entities, clear and context stop. Binance operator-page validation remains outstanding. Server/gateway rollout @@ -123,6 +127,17 @@ Do not treat source unit tests or the panel fixture as deployment evidence. Typed request transport failures retain the cursor. Other contract failures are not retried. Cancellation is checked after request and async validation boundaries so a stopped context cannot publish a late status. +- On startup and after a stale cursor, each client first requests its dedicated + `/bootstrap` endpoint. The ordinary snapshot preserves the latest event facts, + the first directional marker evidence and the latest outcome per retained + event. The compound snapshot preserves immutable candidates by original + decision time. Both snapshots are bounded to 80 records and two hours; the + browser applies them at the gateway's fixed observation time before continuing + from the returned Redis Stream cursor. + Ordinary bootstrap may replay a retained active marker envelope immediately + before the same event's latest outcome; only this explicit bootstrap phase + treats the omitted close transition as closed. Live polling still requires the + normal close-before-outcome lifecycle. - Canonical Python/JavaScript SHA-256 identities are checked against synthetic Python detector fixtures. Wire decimals remain exact strings for validation; numeric conversion is limited to presentation. Small nonzero display diff --git a/scripts/binance-strategy27-events.user.js b/scripts/binance-strategy27-events.user.js index 312ed35..2df328a 100644 --- a/scripts/binance-strategy27-events.user.js +++ b/scripts/binance-strategy27-events.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @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.4.1 +// @version 0.4.2 // @author jackhai9 // @description 在 Binance 一秒图表标注 VPS Strategy 27 的实时订单流候选观察 // @match https://www.binance.com/*/futures/* @@ -392,6 +392,59 @@ } throw new Error("Gateway response status is invalid"); } + function validateGatewayBootstrapResponse(value, httpStatus) { + if (value?.status === "error") { + assertExactKeys(value, ["schema_version", "status", "error_code"], "gateway bootstrap error response"); + assertCondition(value.schema_version === 1, "Gateway bootstrap schema_version must be 1"); + const expected = { 401: "unauthorized", 503: ["bootstrap_unavailable", "redis_unavailable"] }[httpStatus]; + assertCondition(Array.isArray(expected) ? expected.includes(value.error_code) : value.error_code === expected, "Gateway bootstrap error response is invalid"); + return value; + } + assertCondition(httpStatus === 200, "Gateway bootstrap success must use HTTP 200"); + assertExactKeys(value, [ + "schema_version", + "status", + "projection_kind", + "requested_cursor", + "next_cursor", + "runtime_epoch", + "last_sequence", + "bootstrap_observed_at_ms", + "records" + ], "gateway bootstrap response"); + assertCondition(value.schema_version === 1 && value.status === "bootstrap", "Gateway bootstrap status is invalid"); + assertCondition(value.projection_kind === "strategy27_events", "Gateway bootstrap projection kind is invalid"); + assertCondition(value.requested_cursor === null, "Gateway bootstrap requested_cursor must be null"); + assertCondition(typeof value.next_cursor === "string" && STREAM_ID_PATTERN.test(value.next_cursor), "Gateway bootstrap next cursor is invalid"); + assertCondition(typeof value.runtime_epoch === "string" && EPOCH_PATTERN.test(value.runtime_epoch), "Gateway bootstrap epoch is invalid"); + assertInteger(value.last_sequence, "Gateway bootstrap last_sequence", { minimum: 1 }); + assertInteger(value.bootstrap_observed_at_ms, "Gateway bootstrap observed time"); + assertCondition(Array.isArray(value.records) && value.records.length <= 80, "Gateway bootstrap record bound is invalid"); + for (const record of value.records) { + assertExactKeys(record, ["event_id", "event_envelope", "marker_envelope", "outcome_envelope"], "gateway bootstrap event record"); + assertCondition(typeof record.event_id === "string" && EVENT_ID_PATTERN.test(record.event_id), "Gateway bootstrap event ID is invalid"); + assertCondition(record.event_envelope !== null, "Gateway bootstrap event envelope is required"); + const envelopes = [record.event_envelope, record.marker_envelope, record.outcome_envelope].filter((item) => item !== null); + for (const envelope of envelopes) { + validateLiveEnvelope(envelope); + assertCondition(envelope.runtime_epoch === value.runtime_epoch, "Gateway bootstrap event epoch is inconsistent"); + assertCondition(envelope.event_id === record.event_id, "Gateway bootstrap event identity is inconsistent"); + assertCondition(envelope.sequence <= value.last_sequence, "Gateway bootstrap event sequence exceeds tail"); + } + assertCondition( + record.event_envelope.message_kind !== "event_outcome" || record.outcome_envelope !== null && JSON.stringify(record.event_envelope) === JSON.stringify(record.outcome_envelope), + "Gateway bootstrap event envelope is invalid" + ); + if (record.marker_envelope !== null) { + assertCondition(record.marker_envelope.message_kind !== "event_outcome", "Gateway bootstrap marker envelope is invalid"); + assertCondition(record.marker_envelope.payload.event.latest_snapshot.candidate_observations.length > 0, "Gateway bootstrap marker evidence is missing"); + } + if (record.outcome_envelope !== null) { + assertCondition(record.outcome_envelope.message_kind === "event_outcome", "Gateway bootstrap outcome envelope is invalid"); + } + } + return value; + } var LiveEventLifecycle = class { constructor(canonicalSymbol, { maxEvents, maxAgeMs }) { canonicalSymbolToRoute(canonicalSymbol); @@ -411,6 +464,24 @@ this.evictedEvents = /* @__PURE__ */ new Map(); this.allowUnknownRehydrate = true; this.rehydrationCutoffMs = null; + this.bootstrapActive = false; + } + beginBootstrap({ runtimeEpoch, observedAtMs }) { + assertCondition(typeof runtimeEpoch === "string" && EPOCH_PATTERN.test(runtimeEpoch), "Bootstrap runtime epoch is invalid"); + assertInteger(observedAtMs, "Bootstrap observed time"); + this.reset("initial_cursor"); + this.runtimeEpoch = runtimeEpoch; + this.lastSequence = 0; + this.epochEnvelopeAccepted = true; + this.rehydrationCutoffMs = observedAtMs; + this.bootstrapActive = true; + } + finishBootstrap(lastSequence) { + assertInteger(lastSequence, "Bootstrap last sequence", { minimum: 1 }); + assertCondition(this.runtimeEpoch !== null && this.lastSequence !== null, "Bootstrap was not started"); + assertCondition(lastSequence >= this.lastSequence, "Bootstrap tail sequence precedes restored records"); + this.lastSequence = lastSequence; + this.bootstrapActive = false; } rememberEviction(eventId, observedAtMs) { this.evictedEvents.delete(eventId); @@ -508,7 +579,9 @@ assertCondition(envelope.message_kind !== "event_closed" || existing.phase === "active", "Duplicate event_closed"); existing.event = envelope.payload.event; existing.observedAtMs = envelope.observed_at_ms; - if (envelope.message_kind === "event_closed") existing.phase = "closed"; + if (envelope.message_kind === "event_closed" || this.bootstrapActive && envelope.message_kind === "event_outcome") { + existing.phase = "closed"; + } } const current = this.events.get(envelope.event_id); if (envelope.message_kind === "event_outcome") { @@ -608,7 +681,7 @@ signal.addEventListener("abort", abort, { once: true }); }); } - function parseResponseJson(response) { + function parseResponseJson(response, bootstrap) { if (!Number.isInteger(response?.status) || typeof response.responseText !== "string") { throw new Error("Strategy 27 gateway returned an invalid GM response"); } @@ -618,7 +691,7 @@ } catch { throw new Error("Strategy 27 gateway returned invalid JSON"); } - return validateGatewayResponse(payload, response.status); + return bootstrap ? validateGatewayBootstrapResponse(payload, response.status) : validateGatewayResponse(payload, response.status); } function compareStreamIds(left, right) { const [leftMs, leftSequence] = left.split("-").map(BigInt); @@ -653,13 +726,14 @@ if (!Number.isInteger(reconnectDelayMs) || reconnectDelayMs < 0) throw new Error("Strategy 27 reconnect delay is invalid"); const origin = normalizeGatewayBaseUrl(gatewayBaseUrl); let cursor = null; + let needsBootstrap = true; let reconnecting = false; return Object.freeze({ async run(signal) { while (!signal.aborted) { - const url = new URL("/v1/strategy27/events", origin); + const url = new URL(needsBootstrap ? "/v1/strategy27/events/bootstrap" : "/v1/strategy27/events", origin); url.searchParams.set("symbol", canonicalSymbol); - if (cursor !== null) url.searchParams.set("cursor", cursor); + if (!needsBootstrap) url.searchParams.set("cursor", cursor); let response; try { response = await request({ @@ -676,9 +750,17 @@ await waitForReconnect(reconnectDelayMs, signal); continue; } - const payload = parseResponseJson(response); - assertCursorContract(payload, cursor); + const payload = parseResponseJson(response, needsBootstrap); + if (!needsBootstrap) assertCursorContract(payload, cursor); if (payload.status === "error") { + if (needsBootstrap && response.status === 503) { + if (!reconnecting) { + reconnecting = true; + onConnectionStateChange("reconnecting"); + } + await waitForReconnect(reconnectDelayMs, signal); + continue; + } throw new Error(`Strategy 27 gateway error: ${payload.error_code}`); } if (reconnecting) { @@ -686,7 +768,13 @@ onConnectionStateChange("connected"); } await onResponse(payload); - cursor = payload.next_cursor; + if (!needsBootstrap && payload.status === "reset") { + cursor = null; + needsBootstrap = true; + } else { + cursor = payload.next_cursor; + needsBootstrap = false; + } } } }); @@ -1912,6 +2000,42 @@ check(typeof value.requested_cursor === "string" && STREAM_ID.test(value.requested_cursor), "requested cursor is invalid"); return value; } + async function validateCompoundBootstrapResponse(value, httpStatus) { + if (value?.status === "error") { + keys(value, ["schema_version", "status", "error_code"], "bootstrap gateway error"); + check(value.schema_version === 1, "bootstrap gateway schema is invalid"); + const expected = { 401: ["unauthorized"], 503: ["compound_unavailable", "redis_unavailable"] }[httpStatus]; + check(expected?.includes(value.error_code), "bootstrap gateway error status/code mismatch"); + return value; + } + check(httpStatus === 200, "bootstrap gateway success must use HTTP 200"); + keys(value, [ + "schema_version", + "status", + "projection_kind", + "requested_cursor", + "next_cursor", + "runtime_epoch", + "last_sequence", + "bootstrap_observed_at_ms", + "records" + ], "bootstrap gateway response"); + check(value.schema_version === 1 && value.status === "bootstrap", "bootstrap gateway status is invalid"); + check(value.projection_kind === "compound_candidates", "bootstrap projection kind is invalid"); + check(value.requested_cursor === null, "bootstrap requested cursor must be null"); + check(typeof value.next_cursor === "string" && STREAM_ID.test(value.next_cursor), "bootstrap next cursor is invalid"); + check(typeof value.runtime_epoch === "string" && /^[a-f0-9]{32}$/.test(value.runtime_epoch), "bootstrap epoch is invalid"); + integer(value.last_sequence, "bootstrap last sequence", 1); + integer(value.bootstrap_observed_at_ms, "bootstrap observed time"); + check(Array.isArray(value.records) && value.records.length <= 80, "bootstrap record bound is invalid"); + for (const envelope of value.records) { + await validateCompoundEnvelope(envelope); + check(envelope.message_kind === "candidate", "bootstrap record must be a candidate"); + check(envelope.runtime_epoch === value.runtime_epoch, "bootstrap candidate epoch is inconsistent"); + check(envelope.sequence <= value.last_sequence, "bootstrap candidate sequence exceeds tail"); + } + return value; + } // src/binance-strategy27-events/core/compound-candidate-client.js function wait(delay, signal) { @@ -1941,6 +2065,7 @@ if (!Number.isSafeInteger(reconnectDelayMs) || reconnectDelayMs < 0) throw new Error("Compound reconnect delay is invalid"); const origin = normalizeGatewayBaseUrl(gatewayBaseUrl); let cursor = null; + let needsBootstrap = true; let state = null; function transition(next) { if (state === next) return; @@ -1950,9 +2075,9 @@ return Object.freeze({ async run(signal) { while (!signal.aborted) { - const url = new URL("/v1/strategy27/compound-candidates", origin); + const url = new URL(needsBootstrap ? "/v1/strategy27/compound-candidates/bootstrap" : "/v1/strategy27/compound-candidates", origin); url.searchParams.set("symbol", canonicalSymbol); - if (cursor !== null) url.searchParams.set("cursor", cursor); + if (!needsBootstrap) url.searchParams.set("cursor", cursor); let response; try { response = await request({ url: url.href, authSecret, signal }); @@ -1969,20 +2094,27 @@ transition("unsupported"); return; } - const payload = await validateCompoundGatewayResponse(JSON.parse(response.responseText), response.status); + const payload = needsBootstrap ? await validateCompoundBootstrapResponse(JSON.parse(response.responseText), response.status) : await validateCompoundGatewayResponse(JSON.parse(response.responseText), response.status); if (signal.aborted) return; if (payload.status === "error") { if (response.status !== 503) throw new Error(`Compound gateway error: ${payload.error_code}`); cursor = null; + needsBootstrap = true; transition("unavailable"); await wait(reconnectDelayMs, signal); continue; } - if (payload.requested_cursor !== cursor || cursor !== null && cursorRegressed(payload.next_cursor, cursor)) throw new Error("Compound gateway response cursor mismatch/regression"); + if (!needsBootstrap && (payload.requested_cursor !== cursor || cursorRegressed(payload.next_cursor, cursor))) throw new Error("Compound gateway response cursor mismatch/regression"); if (signal.aborted) return; transition("connected"); await onResponse(payload); - cursor = payload.next_cursor; + if (!needsBootstrap && payload.status === "reset") { + cursor = null; + needsBootstrap = true; + } else { + cursor = payload.next_cursor; + needsBootstrap = false; + } } } }); @@ -2029,6 +2161,18 @@ this.runtimeEpoch = null; this.lastSequence = null; } + beginBootstrap(runtimeEpoch) { + check2(typeof runtimeEpoch === "string" && /^[a-f0-9]{32}$/.test(runtimeEpoch), "bootstrap epoch is invalid"); + this.reset("initial_cursor"); + this.runtimeEpoch = runtimeEpoch; + this.lastSequence = 0; + } + finishBootstrap(lastSequence) { + check2(Number.isSafeInteger(lastSequence) && lastSequence >= 1, "bootstrap last sequence is invalid"); + check2(this.runtimeEpoch !== null && this.lastSequence !== null, "bootstrap was not started"); + check2(lastSequence >= this.lastSequence, "bootstrap tail sequence precedes restored records"); + this.lastSequence = lastSequence; + } prune(nowMs) { check2(Number.isSafeInteger(nowMs) && nowMs >= 0, "prune time is invalid"); const removed = []; @@ -2152,8 +2296,8 @@ panel.removeCompound(id); } } - function prune() { - if (current()) remove(lifecycle.prune(nowMs())); + function prune(observedAtMs = nowMs()) { + if (current()) remove(lifecycle.prune(observedAtMs)); } function failJob(error, { clear = true } = {}) { lastError = error; @@ -2186,10 +2330,21 @@ if (error) failJob(error, { clear: false }); return; } - for (const message of response.messages) { + let messages = response.messages; + const applicationNowMs = response.status === "bootstrap" ? response.bootstrap_observed_at_ms : nowMs(); + if (response.status === "bootstrap") { + lifecycle.beginBootstrap(response.runtime_epoch); + const error = clearView(); + if (error) { + failJob(error, { clear: false }); + return; + } + messages = [...response.records].sort((left, right) => left.sequence - right.sequence); + } + for (const message of messages) { if (!current()) return; const applicationGeneration = viewGeneration; - const action = await lifecycle.apply(message, nowMs()); + const action = await lifecycle.apply(message, applicationNowMs); if (!current()) return; remove(action.removedCandidateIds); if (action.type === "stream_reset") { @@ -2218,6 +2373,9 @@ pendingCandidateId = null; } } + if (response.status === "bootstrap" && current()) { + lifecycle.finishBootstrap(response.last_sequence); + } } return Object.freeze({ run() { @@ -2577,7 +2735,29 @@ hideStatus(); return; } - for (const message of response.messages) { + let messages = response.messages; + if (response.status === "bootstrap") { + context.lifecycle.beginBootstrap({ + runtimeEpoch: response.runtime_epoch, + observedAtMs: response.bootstrap_observed_at_ms + }); + context.layer.clear(); + context.panel.clear(); + context.candidatePresentations.clear(); + const bySequence = /* @__PURE__ */ new Map(); + for (const record of response.records) { + for (const message of [record.marker_envelope, record.event_envelope, record.outcome_envelope]) { + if (message === null) continue; + const existing = bySequence.get(message.sequence); + if (existing && JSON.stringify(existing) !== JSON.stringify(message)) { + throw new Error("Strategy 27 bootstrap sequence identifies different envelopes"); + } + bySequence.set(message.sequence, message); + } + } + messages = [...bySequence.values()].sort((left, right) => left.sequence - right.sequence); + } + for (const message of messages) { if (active !== context || context.failed) return; const action = context.lifecycle.apply(message); for (const eventId of action.evictedEventIds ?? []) { @@ -2612,6 +2792,10 @@ context.panel.upsert(action.eventId, annotation, action.observedAtMs); hideStatus(); } + if (response.status === "bootstrap") { + context.lifecycle.finishBootstrap(response.last_sequence); + hideStatus(); + } } function startContext({ routeSymbol, canonicalSymbol, target, gatewayOrigin, authSecret }) { const context = { diff --git a/src/binance-strategy27-events/core/compound-candidate-client.js b/src/binance-strategy27-events/core/compound-candidate-client.js index 47126cf..6c035b6 100644 --- a/src/binance-strategy27-events/core/compound-candidate-client.js +++ b/src/binance-strategy27-events/core/compound-candidate-client.js @@ -1,5 +1,5 @@ import { normalizeGatewayBaseUrl, Strategy27GatewayTransportError } from './live-event-client.js'; -import { validateCompoundGatewayResponse } from './compound-candidate-contract.js'; +import { validateCompoundBootstrapResponse, validateCompoundGatewayResponse } from './compound-candidate-contract.js'; function wait(delay, signal) { const aborted = () => new DOMException('Compound request aborted', 'AbortError'); @@ -31,6 +31,7 @@ export function createCompoundCandidateClient({ request, gatewayBaseUrl, authSec if (!Number.isSafeInteger(reconnectDelayMs) || reconnectDelayMs < 0) throw new Error('Compound reconnect delay is invalid'); const origin = normalizeGatewayBaseUrl(gatewayBaseUrl); let cursor = null; + let needsBootstrap = true; let state = null; function transition(next) { if (state === next) return; @@ -40,9 +41,9 @@ export function createCompoundCandidateClient({ request, gatewayBaseUrl, authSec return Object.freeze({ async run(signal) { while (!signal.aborted) { - const url = new URL('/v1/strategy27/compound-candidates', origin); + const url = new URL(needsBootstrap ? '/v1/strategy27/compound-candidates/bootstrap' : '/v1/strategy27/compound-candidates', origin); url.searchParams.set('symbol', canonicalSymbol); - if (cursor !== null) url.searchParams.set('cursor', cursor); + if (!needsBootstrap) url.searchParams.set('cursor', cursor); let response; try { response = await request({ url: url.href, authSecret, signal }); @@ -60,20 +61,29 @@ export function createCompoundCandidateClient({ request, gatewayBaseUrl, authSec transition('unsupported'); return; } - const payload = await validateCompoundGatewayResponse(JSON.parse(response.responseText), response.status); + const payload = needsBootstrap + ? await validateCompoundBootstrapResponse(JSON.parse(response.responseText), response.status) + : await validateCompoundGatewayResponse(JSON.parse(response.responseText), response.status); if (signal.aborted) return; if (payload.status === 'error') { if (response.status !== 503) throw new Error(`Compound gateway error: ${payload.error_code}`); cursor = null; + needsBootstrap = true; transition('unavailable'); await wait(reconnectDelayMs, signal); continue; } - if (payload.requested_cursor !== cursor || (cursor !== null && cursorRegressed(payload.next_cursor, cursor))) throw new Error('Compound gateway response cursor mismatch/regression'); + if (!needsBootstrap && (payload.requested_cursor !== cursor || cursorRegressed(payload.next_cursor, cursor))) throw new Error('Compound gateway response cursor mismatch/regression'); if (signal.aborted) return; transition('connected'); await onResponse(payload); - cursor = payload.next_cursor; + if (!needsBootstrap && payload.status === 'reset') { + cursor = null; + needsBootstrap = true; + } else { + cursor = payload.next_cursor; + needsBootstrap = false; + } } }, }); diff --git a/src/binance-strategy27-events/core/compound-candidate-contract.js b/src/binance-strategy27-events/core/compound-candidate-contract.js index cd7bcf2..8f1119a 100644 --- a/src/binance-strategy27-events/core/compound-candidate-contract.js +++ b/src/binance-strategy27-events/core/compound-candidate-contract.js @@ -179,3 +179,33 @@ export async function validateCompoundGatewayResponse(value, httpStatus) { check(typeof value.requested_cursor === 'string' && STREAM_ID.test(value.requested_cursor), 'requested cursor is invalid'); return value; } + +export async function validateCompoundBootstrapResponse(value, httpStatus) { + if (value?.status === 'error') { + keys(value, ['schema_version', 'status', 'error_code'], 'bootstrap gateway error'); + check(value.schema_version === 1, 'bootstrap gateway schema is invalid'); + const expected = { 401: ['unauthorized'], 503: ['compound_unavailable', 'redis_unavailable'] }[httpStatus]; + check(expected?.includes(value.error_code), 'bootstrap gateway error status/code mismatch'); + return value; + } + check(httpStatus === 200, 'bootstrap gateway success must use HTTP 200'); + keys(value, [ + 'schema_version', 'status', 'projection_kind', 'requested_cursor', 'next_cursor', + 'runtime_epoch', 'last_sequence', 'bootstrap_observed_at_ms', 'records', + ], 'bootstrap gateway response'); + check(value.schema_version === 1 && value.status === 'bootstrap', 'bootstrap gateway status is invalid'); + check(value.projection_kind === 'compound_candidates', 'bootstrap projection kind is invalid'); + check(value.requested_cursor === null, 'bootstrap requested cursor must be null'); + check(typeof value.next_cursor === 'string' && STREAM_ID.test(value.next_cursor), 'bootstrap next cursor is invalid'); + check(typeof value.runtime_epoch === 'string' && /^[a-f0-9]{32}$/.test(value.runtime_epoch), 'bootstrap epoch is invalid'); + integer(value.last_sequence, 'bootstrap last sequence', 1); + integer(value.bootstrap_observed_at_ms, 'bootstrap observed time'); + check(Array.isArray(value.records) && value.records.length <= 80, 'bootstrap record bound is invalid'); + for (const envelope of value.records) { + await validateCompoundEnvelope(envelope); + check(envelope.message_kind === 'candidate', 'bootstrap record must be a candidate'); + check(envelope.runtime_epoch === value.runtime_epoch, 'bootstrap candidate epoch is inconsistent'); + check(envelope.sequence <= value.last_sequence, 'bootstrap candidate sequence exceeds tail'); + } + return value; +} diff --git a/src/binance-strategy27-events/core/compound-candidate-controller.js b/src/binance-strategy27-events/core/compound-candidate-controller.js index 005d9d2..0a2364a 100644 --- a/src/binance-strategy27-events/core/compound-candidate-controller.js +++ b/src/binance-strategy27-events/core/compound-candidate-controller.js @@ -50,8 +50,8 @@ export function createCompoundCandidateController({ } } - function prune() { - if (current()) remove(lifecycle.prune(nowMs())); + function prune(observedAtMs = nowMs()) { + if (current()) remove(lifecycle.prune(observedAtMs)); } function failJob(error, { clear = true } = {}) { @@ -84,10 +84,20 @@ export function createCompoundCandidateController({ if (error) failJob(error, { clear: false }); return; } - for (const message of response.messages) { + let messages = response.messages; + const applicationNowMs = response.status === 'bootstrap' + ? response.bootstrap_observed_at_ms + : nowMs(); + if (response.status === 'bootstrap') { + lifecycle.beginBootstrap(response.runtime_epoch); + const error = clearView(); + if (error) { failJob(error, { clear: false }); return; } + messages = [...response.records].sort((left, right) => left.sequence - right.sequence); + } + for (const message of messages) { if (!current()) return; const applicationGeneration = viewGeneration; - const action = await lifecycle.apply(message, nowMs()); + const action = await lifecycle.apply(message, applicationNowMs); if (!current()) return; remove(action.removedCandidateIds); if (action.type === 'stream_reset') { @@ -115,6 +125,9 @@ export function createCompoundCandidateController({ pendingCandidateId = null; } } + if (response.status === 'bootstrap' && current()) { + lifecycle.finishBootstrap(response.last_sequence); + } } return Object.freeze({ diff --git a/src/binance-strategy27-events/core/compound-candidate-lifecycle.js b/src/binance-strategy27-events/core/compound-candidate-lifecycle.js index 301996d..41f2be3 100644 --- a/src/binance-strategy27-events/core/compound-candidate-lifecycle.js +++ b/src/binance-strategy27-events/core/compound-candidate-lifecycle.js @@ -52,6 +52,20 @@ export class CompoundCandidateLifecycle { this.lastSequence = null; } + beginBootstrap(runtimeEpoch) { + check(typeof runtimeEpoch === 'string' && /^[a-f0-9]{32}$/.test(runtimeEpoch), 'bootstrap epoch is invalid'); + this.reset('initial_cursor'); + this.runtimeEpoch = runtimeEpoch; + this.lastSequence = 0; + } + + finishBootstrap(lastSequence) { + check(Number.isSafeInteger(lastSequence) && lastSequence >= 1, 'bootstrap last sequence is invalid'); + check(this.runtimeEpoch !== null && this.lastSequence !== null, 'bootstrap was not started'); + check(lastSequence >= this.lastSequence, 'bootstrap tail sequence precedes restored records'); + this.lastSequence = lastSequence; + } + #evict(id) { const record = this.#records.get(id); const order = orderOf(record.candidate); diff --git a/src/binance-strategy27-events/core/live-event-client.js b/src/binance-strategy27-events/core/live-event-client.js index fc36f5c..92a07f2 100644 --- a/src/binance-strategy27-events/core/live-event-client.js +++ b/src/binance-strategy27-events/core/live-event-client.js @@ -1,4 +1,4 @@ -import { validateGatewayResponse } from './live-event-contract.js'; +import { validateGatewayBootstrapResponse, validateGatewayResponse } from './live-event-contract.js'; const DEFAULT_RECONNECT_DELAY_MS = 2_000; @@ -76,7 +76,7 @@ function waitForReconnect(delayMs, signal) { }); } -function parseResponseJson(response) { +function parseResponseJson(response, bootstrap) { if (!Number.isInteger(response?.status) || typeof response.responseText !== 'string') { throw new Error('Strategy 27 gateway returned an invalid GM response'); } @@ -86,7 +86,9 @@ function parseResponseJson(response) { } catch { throw new Error('Strategy 27 gateway returned invalid JSON'); } - return validateGatewayResponse(payload, response.status); + return bootstrap + ? validateGatewayBootstrapResponse(payload, response.status) + : validateGatewayResponse(payload, response.status); } function compareStreamIds(left, right) { @@ -124,14 +126,15 @@ export function createLiveEventClient({ if (!Number.isInteger(reconnectDelayMs) || reconnectDelayMs < 0) throw new Error('Strategy 27 reconnect delay is invalid'); const origin = normalizeGatewayBaseUrl(gatewayBaseUrl); let cursor = null; + let needsBootstrap = true; let reconnecting = false; return Object.freeze({ async run(signal) { while (!signal.aborted) { - const url = new URL('/v1/strategy27/events', origin); + const url = new URL(needsBootstrap ? '/v1/strategy27/events/bootstrap' : '/v1/strategy27/events', origin); url.searchParams.set('symbol', canonicalSymbol); - if (cursor !== null) url.searchParams.set('cursor', cursor); + if (!needsBootstrap) url.searchParams.set('cursor', cursor); let response; try { response = await request({ @@ -148,9 +151,17 @@ export function createLiveEventClient({ await waitForReconnect(reconnectDelayMs, signal); continue; } - const payload = parseResponseJson(response); - assertCursorContract(payload, cursor); + const payload = parseResponseJson(response, needsBootstrap); + if (!needsBootstrap) assertCursorContract(payload, cursor); if (payload.status === 'error') { + if (needsBootstrap && response.status === 503) { + if (!reconnecting) { + reconnecting = true; + onConnectionStateChange('reconnecting'); + } + await waitForReconnect(reconnectDelayMs, signal); + continue; + } throw new Error(`Strategy 27 gateway error: ${payload.error_code}`); } if (reconnecting) { @@ -158,7 +169,13 @@ export function createLiveEventClient({ onConnectionStateChange('connected'); } await onResponse(payload); - cursor = payload.next_cursor; + if (!needsBootstrap && payload.status === 'reset') { + cursor = null; + needsBootstrap = true; + } else { + cursor = payload.next_cursor; + needsBootstrap = false; + } } }, }); diff --git a/src/binance-strategy27-events/core/live-event-contract.js b/src/binance-strategy27-events/core/live-event-contract.js index 395c139..2f1bb35 100644 --- a/src/binance-strategy27-events/core/live-event-contract.js +++ b/src/binance-strategy27-events/core/live-event-contract.js @@ -384,6 +384,55 @@ export function validateGatewayResponse(value, httpStatus) { throw new Error('Gateway response status is invalid'); } +export function validateGatewayBootstrapResponse(value, httpStatus) { + if (value?.status === 'error') { + assertExactKeys(value, ['schema_version', 'status', 'error_code'], 'gateway bootstrap error response'); + assertCondition(value.schema_version === 1, 'Gateway bootstrap schema_version must be 1'); + const expected = { 401: 'unauthorized', 503: ['bootstrap_unavailable', 'redis_unavailable'] }[httpStatus]; + assertCondition(Array.isArray(expected) ? expected.includes(value.error_code) : value.error_code === expected, 'Gateway bootstrap error response is invalid'); + return value; + } + assertCondition(httpStatus === 200, 'Gateway bootstrap success must use HTTP 200'); + assertExactKeys(value, [ + 'schema_version', 'status', 'projection_kind', 'requested_cursor', 'next_cursor', + 'runtime_epoch', 'last_sequence', 'bootstrap_observed_at_ms', 'records', + ], 'gateway bootstrap response'); + assertCondition(value.schema_version === 1 && value.status === 'bootstrap', 'Gateway bootstrap status is invalid'); + assertCondition(value.projection_kind === 'strategy27_events', 'Gateway bootstrap projection kind is invalid'); + assertCondition(value.requested_cursor === null, 'Gateway bootstrap requested_cursor must be null'); + assertCondition(typeof value.next_cursor === 'string' && STREAM_ID_PATTERN.test(value.next_cursor), 'Gateway bootstrap next cursor is invalid'); + assertCondition(typeof value.runtime_epoch === 'string' && EPOCH_PATTERN.test(value.runtime_epoch), 'Gateway bootstrap epoch is invalid'); + assertInteger(value.last_sequence, 'Gateway bootstrap last_sequence', { minimum: 1 }); + assertInteger(value.bootstrap_observed_at_ms, 'Gateway bootstrap observed time'); + assertCondition(Array.isArray(value.records) && value.records.length <= 80, 'Gateway bootstrap record bound is invalid'); + for (const record of value.records) { + assertExactKeys(record, ['event_id', 'event_envelope', 'marker_envelope', 'outcome_envelope'], 'gateway bootstrap event record'); + assertCondition(typeof record.event_id === 'string' && EVENT_ID_PATTERN.test(record.event_id), 'Gateway bootstrap event ID is invalid'); + assertCondition(record.event_envelope !== null, 'Gateway bootstrap event envelope is required'); + const envelopes = [record.event_envelope, record.marker_envelope, record.outcome_envelope].filter(item => item !== null); + for (const envelope of envelopes) { + validateLiveEnvelope(envelope); + assertCondition(envelope.runtime_epoch === value.runtime_epoch, 'Gateway bootstrap event epoch is inconsistent'); + assertCondition(envelope.event_id === record.event_id, 'Gateway bootstrap event identity is inconsistent'); + assertCondition(envelope.sequence <= value.last_sequence, 'Gateway bootstrap event sequence exceeds tail'); + } + assertCondition( + record.event_envelope.message_kind !== 'event_outcome' + || (record.outcome_envelope !== null + && JSON.stringify(record.event_envelope) === JSON.stringify(record.outcome_envelope)), + 'Gateway bootstrap event envelope is invalid', + ); + if (record.marker_envelope !== null) { + assertCondition(record.marker_envelope.message_kind !== 'event_outcome', 'Gateway bootstrap marker envelope is invalid'); + assertCondition(record.marker_envelope.payload.event.latest_snapshot.candidate_observations.length > 0, 'Gateway bootstrap marker evidence is missing'); + } + if (record.outcome_envelope !== null) { + assertCondition(record.outcome_envelope.message_kind === 'event_outcome', 'Gateway bootstrap outcome envelope is invalid'); + } + } + return value; +} + export class LiveEventLifecycle { constructor(canonicalSymbol, { maxEvents, maxAgeMs }) { canonicalSymbolToRoute(canonicalSymbol); @@ -404,6 +453,26 @@ export class LiveEventLifecycle { this.evictedEvents = new Map(); this.allowUnknownRehydrate = true; this.rehydrationCutoffMs = null; + this.bootstrapActive = false; + } + + beginBootstrap({ runtimeEpoch, observedAtMs }) { + assertCondition(typeof runtimeEpoch === 'string' && EPOCH_PATTERN.test(runtimeEpoch), 'Bootstrap runtime epoch is invalid'); + assertInteger(observedAtMs, 'Bootstrap observed time'); + this.reset('initial_cursor'); + this.runtimeEpoch = runtimeEpoch; + this.lastSequence = 0; + this.epochEnvelopeAccepted = true; + this.rehydrationCutoffMs = observedAtMs; + this.bootstrapActive = true; + } + + finishBootstrap(lastSequence) { + assertInteger(lastSequence, 'Bootstrap last sequence', { minimum: 1 }); + assertCondition(this.runtimeEpoch !== null && this.lastSequence !== null, 'Bootstrap was not started'); + assertCondition(lastSequence >= this.lastSequence, 'Bootstrap tail sequence precedes restored records'); + this.lastSequence = lastSequence; + this.bootstrapActive = false; } rememberEviction(eventId, observedAtMs) { @@ -512,7 +581,10 @@ export class LiveEventLifecycle { assertCondition(envelope.message_kind !== 'event_closed' || existing.phase === 'active', 'Duplicate event_closed'); existing.event = envelope.payload.event; existing.observedAtMs = envelope.observed_at_ms; - if (envelope.message_kind === 'event_closed') existing.phase = 'closed'; + if (envelope.message_kind === 'event_closed' + || (this.bootstrapActive && envelope.message_kind === 'event_outcome')) { + existing.phase = 'closed'; + } } const current = this.events.get(envelope.event_id); diff --git a/src/binance-strategy27-events/index.user.js b/src/binance-strategy27-events/index.user.js index fa52f26..01f9a6a 100644 --- a/src/binance-strategy27-events/index.user.js +++ b/src/binance-strategy27-events/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @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.4.1 +// @version 0.4.2 // @author jackhai9 // @description 在 Binance 一秒图表标注 VPS Strategy 27 的实时订单流候选观察 // @match https://www.binance.com/*/futures/* @@ -133,7 +133,30 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; return; } - for (const message of response.messages) { + let messages = response.messages; + if (response.status === 'bootstrap') { + context.lifecycle.beginBootstrap({ + runtimeEpoch: response.runtime_epoch, + observedAtMs: response.bootstrap_observed_at_ms, + }); + context.layer.clear(); + context.panel.clear(); + context.candidatePresentations.clear(); + const bySequence = new Map(); + for (const record of response.records) { + for (const message of [record.marker_envelope, record.event_envelope, record.outcome_envelope]) { + if (message === null) continue; + const existing = bySequence.get(message.sequence); + if (existing && JSON.stringify(existing) !== JSON.stringify(message)) { + throw new Error('Strategy 27 bootstrap sequence identifies different envelopes'); + } + bySequence.set(message.sequence, message); + } + } + messages = [...bySequence.values()].sort((left, right) => left.sequence - right.sequence); + } + + for (const message of messages) { if (active !== context || context.failed) return; const action = context.lifecycle.apply(message); for (const eventId of action.evictedEventIds ?? []) { @@ -168,6 +191,10 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; context.panel.upsert(action.eventId, annotation, action.observedAtMs); hideStatus(); } + if (response.status === 'bootstrap') { + context.lifecycle.finishBootstrap(response.last_sequence); + hideStatus(); + } } function startContext({ routeSymbol, canonicalSymbol, target, gatewayOrigin, authSecret }) { diff --git a/test/dom/binance-strategy27-events/compound-candidate-controller.test.js b/test/dom/binance-strategy27-events/compound-candidate-controller.test.js index eaf7dbc..ea98500 100644 --- a/test/dom/binance-strategy27-events/compound-candidate-controller.test.js +++ b/test/dom/binance-strategy27-events/compound-candidate-controller.test.js @@ -9,9 +9,13 @@ import { Strategy27GatewayTransportError } from '../../../src/binance-strategy27 const fixtures = JSON.parse(readFileSync(new URL('../../fixtures/strategy27-compound-candidates.json', import.meta.url), 'utf8')); const EPOCH = 'a'.repeat(32); const response = (body, status = 200) => ({ status, responseText: JSON.stringify(body) }); -const reset = (next = '1-0') => response({ schema_version: 1, status: 'reset', reason: 'initial_cursor', requested_cursor: null, next_cursor: next, messages: [] }); +const bootstrap = (next = '1-0', epoch = EPOCH) => response({ + schema_version: 1, status: 'bootstrap', projection_kind: 'compound_candidates', + requested_cursor: null, next_cursor: next, runtime_epoch: epoch, + last_sequence: 1, bootstrap_observed_at_ms: 7000, records: [], +}); const batch = (messages, from = '1-0', to = '2-0') => response({ schema_version: 1, status: 'ok', requested_cursor: from, next_cursor: to, messages }); -const envelope = (payload = fixtures[0], sequence = 1, epoch = EPOCH) => ({ +const envelope = (payload = fixtures[0], sequence = 2, epoch = EPOCH) => ({ schema_version: 1, projection_kind: 'compound_candidate', runtime_epoch: epoch, sequence, message_kind: 'candidate', symbol: payload.symbol, observed_at_ms: payload.decision.end_ms, payload, }); @@ -106,7 +110,7 @@ function harness(t, steps, { render, reconcile, createError, removeError, clearE } test('controller composes real protocol/lifecycle/panel and renders each independent candidate once', async (t) => { - const h = harness(t, [reset(), batch([state(1), envelope(fixtures[0], 2), envelope(fixtures[1], 3), envelope(fixtures[0], 4)])]); + const h = harness(t, [bootstrap(), batch([envelope(fixtures[0], 2), envelope(fixtures[1], 3), envelope(fixtures[0], 4)])]); h.run(); await h.parked; assert.equal(h.panel.size, 1); @@ -135,9 +139,9 @@ test('unsupported gateways never construct a chart layer and leave ordinary data }); test('same-decision panel ordering uses publication time without extending chart retention time', async (t) => { - const high = { ...envelope(fixtures[0], 1), observed_at_ms: 7010 }; - const low = { ...envelope(fixtures[1], 2), observed_at_ms: 7020 }; - const h = harness(t, [reset(), batch([high, low])]); + const high = { ...envelope(fixtures[0], 2), observed_at_ms: 7010 }; + const low = { ...envelope(fixtures[1], 3), observed_at_ms: 7020 }; + const h = harness(t, [bootstrap(), batch([high, low])]); h.setClock(7030); h.run(); await h.parked; @@ -146,14 +150,14 @@ test('same-decision panel ordering uses publication time without extending chart }); test('503 clears compound state and recovers with a new cursor without clearing ordinary data', async (t) => { - const h = harness(t, [reset(), batch([envelope()]), response({ schema_version: 1, status: 'error', error_code: 'compound_unavailable' }, 503), + const h = harness(t, [bootstrap(), batch([envelope()]), response({ schema_version: 1, status: 'error', error_code: 'compound_unavailable' }, 503), ({ panel, shapes, calls }) => { assert.equal(panel.size, 1); assert.equal(panel.compoundSize, 0); assert.equal(shapes.size, 0); assert.equal(calls.at(-1).searchParams.has('cursor'), false); - return reset('5-0'); - }, batch([envelope(fixtures[1], 1, 'b'.repeat(32))], '5-0', '6-0')]); + return bootstrap('5-0', 'b'.repeat(32)); + }, batch([envelope(fixtures[1], 2, 'b'.repeat(32))], '5-0', '6-0')]); h.run(); await h.parked; assert.equal(h.panel.compoundSize, 1); @@ -162,12 +166,12 @@ test('503 clears compound state and recovers with a new cursor without clearing }); test('network reconnect retains compound history and original cursor', async (t) => { - const h = harness(t, [reset(), batch([envelope()]), new Strategy27GatewayTransportError('fixture connection failure'), + const h = harness(t, [bootstrap(), batch([envelope()]), new Strategy27GatewayTransportError('fixture connection failure'), ({ panel, shapes, calls }) => { assert.equal(panel.compoundSize, 1); assert.equal(shapes.size, 1); assert.equal(calls.at(-1).searchParams.get('cursor'), '2-0'); - return batch([envelope(fixtures[0], 2)], '2-0', '3-0'); + return batch([envelope(fixtures[0], 3)], '2-0', '3-0'); }]); h.run(); await h.parked; @@ -176,7 +180,7 @@ test('network reconnect retains compound history and original cursor', async (t) }); test('stream reset clears compound view but preserves the newly accepted epoch sequence', async (t) => { - const h = harness(t, [reset(), batch([envelope(), state(1, 'b'.repeat(32)), envelope(fixtures[1], 2, 'b'.repeat(32))]), + const h = harness(t, [bootstrap(), batch([envelope(), state(1, 'b'.repeat(32)), envelope(fixtures[1], 2, 'b'.repeat(32))]), batch([envelope(fixtures[0], 2, 'b'.repeat(32))], '2-0', '3-0')]); await h.run(); assert.equal(h.renders.length, 2); @@ -188,7 +192,7 @@ test('stream reset clears compound view but preserves the newly accepted epoch s test('contract and lazy renderer failures are terminal only for the compound job', async (t) => { for (const mode of ['protocol', 'renderer']) { - const h = harness(t, mode === 'protocol' ? [{ status: 200, responseText: 'invalid JSON' }] : [reset(), batch([envelope()])], + const h = harness(t, mode === 'protocol' ? [{ status: 200, responseText: 'invalid JSON' }] : [bootstrap(), batch([envelope()])], mode === 'renderer' ? { createError: new Error('fixture chart capability missing') } : {}); await h.run(); assert.equal(h.panel.size, 1); @@ -200,7 +204,7 @@ test('contract and lazy renderer failures are terminal only for the compound job }); test('manual clear suppresses pending render and exact replay without restarting the stream', async (t) => { - const h = harness(t, [reset(), batch([envelope(), envelope(fixtures[0], 2), envelope(fixtures[1], 3)])], { + const h = harness(t, [bootstrap(), batch([envelope(), envelope(fixtures[0], 3), envelope(fixtures[1], 4)])], { render: ({ controller, id }) => { if (id === fixtures[0].candidate_id) controller.clear(); }, }); h.run(); @@ -213,9 +217,9 @@ test('manual clear suppresses pending render and exact replay without restarting }); test('ordinary clear does not reset compound replay bookkeeping', async (t) => { - const h = harness(t, [reset(), batch([envelope()]), ({ panel }) => { + const h = harness(t, [bootstrap(), batch([envelope()]), ({ panel }) => { panel.clear(); - return batch([envelope(fixtures[0], 2)], '2-0', '3-0'); + return batch([envelope(fixtures[0], 3)], '2-0', '3-0'); }]); h.run(); await h.parked; @@ -235,7 +239,7 @@ test('manual clear during lifecycle hash validation suppresses late display and if (digestCalls === 3) { validating.resolve(); await release.promise; } return digest(...args); }); - const h = harness(t, [reset(), batch([envelope()]), batch([envelope(fixtures[0], 2)], '2-0', '3-0')]); + const h = harness(t, [bootstrap(), batch([envelope()]), batch([envelope(fixtures[0], 3)], '2-0', '3-0')]); h.run(); await validating.promise; h.controller.clear(); @@ -249,14 +253,14 @@ test('manual clear during lifecycle hash validation suppresses late display and }); test('stale cursor reset removes old compound history and accepts the new stream', async (t) => { - const h = harness(t, [reset(), batch([envelope()]), + const h = harness(t, [bootstrap(), batch([envelope()]), response({ schema_version: 1, status: 'reset', reason: 'stale_cursor', requested_cursor: '2-0', next_cursor: '7-0', messages: [] }, 409), ({ panel, shapes }) => { assert.equal(panel.compoundSize, 0); assert.equal(shapes.size, 0); assert.equal(panel.size, 1); - return batch([envelope(fixtures[1], 1, 'b'.repeat(32))], '7-0', '8-0'); - }]); + return bootstrap('7-0', 'b'.repeat(32)); + }, batch([envelope(fixtures[1], 2, 'b'.repeat(32))], '7-0', '8-0')]); h.run(); await h.parked; assert.deepEqual([...h.shapes.keys()], [fixtures[1].candidate_id]); @@ -267,7 +271,7 @@ test('stale cursor reset removes old compound history and accepts the new stream test('stop during an asynchronous draw prevents late shapes and panel publication', async (t) => { const drawing = deferred(); const release = deferred(); - const h = harness(t, [reset(), batch([envelope()])], { render: async () => { drawing.resolve(); await release.promise; } }); + const h = harness(t, [bootstrap(), batch([envelope()])], { render: async () => { drawing.resolve(); await release.promise; } }); const done = h.run(); await drawing.promise; h.setCurrent(false); @@ -283,7 +287,7 @@ test('stop during an asynchronous draw prevents late shapes and panel publicatio test('age eviction during a pending draw prevents late publication and needs no new timer', async (t) => { const drawing = deferred(); const release = deferred(); - const h = harness(t, [reset(), batch([envelope()])], { maxAgeMs: 1000, render: async () => { drawing.resolve(); await release.promise; } }); + const h = harness(t, [bootstrap(), batch([envelope()])], { maxAgeMs: 1000, render: async () => { drawing.resolve(); await release.promise; } }); h.run(); await drawing.promise; h.setClock(8001); @@ -297,7 +301,7 @@ test('age eviction during a pending draw prevents late publication and needs no }); test('post-draw age check rejects a candidate that expired while rendering', async (t) => { - const h = harness(t, [reset(), batch([envelope()])], { maxAgeMs: 1000, render: () => h.setClock(8001) }); + const h = harness(t, [bootstrap(), batch([envelope()])], { maxAgeMs: 1000, render: () => h.setClock(8001) }); h.run(); await h.parked; assert.equal(h.panel.compoundSize, 0); @@ -306,7 +310,7 @@ test('post-draw age check rejects a candidate that expired while rendering', asy }); test('timer-driven prune failures stop only the optional job and do not escape to the shared context timer', async (t) => { - const h = harness(t, [reset(), batch([envelope()])], { maxAgeMs: 1000, removeError: new Error('fixture removal failure') }); + const h = harness(t, [bootstrap(), batch([envelope()])], { maxAgeMs: 1000, removeError: new Error('fixture removal failure') }); const done = h.run(); await h.parked; h.setClock(8001); @@ -321,7 +325,7 @@ test('timer-driven prune failures stop only the optional job and do not escape t test('capacity evictions remove only the evicted compound marker', async (t) => { const sorted = [...fixtures].sort((a, b) => a.candidate_id.localeCompare(b.candidate_id)); - const h = harness(t, [reset(), batch(sorted.map((item, index) => envelope(item, index + 1)))], { maxCandidates: 1 }); + const h = harness(t, [bootstrap(), batch(sorted.map((item, index) => envelope(item, index + 2)))], { maxCandidates: 1 }); h.run(); await h.parked; assert.deepEqual([...h.shapes.keys()], [sorted[1].candidate_id]); @@ -332,7 +336,7 @@ test('capacity evictions remove only the evicted compound marker', async (t) => test('manual clear and stop contain native cleanup failures without retrying removal', async (t) => { for (const action of ['clear', 'stop']) { - const h = harness(t, [reset(), batch([envelope()])], { clearError: new Error('fixture native cleanup failure') }); + const h = harness(t, [bootstrap(), batch([envelope()])], { clearError: new Error('fixture native cleanup failure') }); const done = h.run(); await h.parked; assert.doesNotThrow(() => h.controller[action]('route_changed')); @@ -346,7 +350,7 @@ test('manual clear and stop contain native cleanup failures without retrying rem }); test('terminal protocol failure preserves both the original and cleanup errors', async (t) => { - const h = harness(t, [reset(), batch([envelope()]), { status: 200, responseText: 'invalid JSON' }], { + const h = harness(t, [bootstrap(), batch([envelope()]), { status: 200, responseText: 'invalid JSON' }], { clearError: new Error('fixture native cleanup failure'), }); await assert.doesNotReject(h.run()); @@ -362,7 +366,7 @@ test('late drawing failure after context retirement remains inspectable without const drawing = deferred(); const release = deferred(); const failure = new Error('fixture late cleanup failure'); - const h = harness(t, [reset(), batch([envelope()])], { + const h = harness(t, [bootstrap(), batch([envelope()])], { render: async () => { drawing.resolve(); await release.promise; throw failure; }, }); const done = h.run(); @@ -379,7 +383,7 @@ test('late drawing failure after context retirement remains inspectable without }); test('timer repair failures stop only the compound job and retain ordinary history', async (t) => { - const h = harness(t, [reset(), batch([envelope()])], { + const h = harness(t, [bootstrap(), batch([envelope()])], { reconcile: async () => { throw new Error('fixture native repair failure'); }, }); h.run(); diff --git a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js index 02a016c..08efb29 100644 --- a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js +++ b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js @@ -58,7 +58,7 @@ async function harness(t, { generated = false, beforeCreate } = {}) { GM_registerMenuCommand: (name, callback) => menus.set(name, callback), GM_xmlhttpRequest: (options) => { const path = new URL(options.url).pathname; - const request = { kind: path.endsWith('/compound-candidates') ? 'compound' : 'ordinary', options, settled: false, aborted: false }; + const request = { kind: path.includes('/compound-candidates') ? 'compound' : 'ordinary', options, settled: false, aborted: false }; requests.push(request); return { abort() { request.aborted = true; request.settled = true; options.onabort(); } }; }, @@ -87,7 +87,7 @@ async function harness(t, { generated = false, beforeCreate } = {}) { request.settled = true; request.options.onload({ status, responseText: typeof body === 'string' ? body : JSON.stringify(body) }); } - async function candidate(sequence = 1, cursor = '1-0', next = '2-0') { + async function candidate(sequence = 2, cursor = '1-0', next = '2-0') { await respond('compound', { schema_version: 1, status: 'ok', requested_cursor: cursor, next_cursor: next, messages: [{ schema_version: 1, projection_kind: 'compound_candidate', runtime_epoch: 'a'.repeat(32), @@ -97,7 +97,8 @@ async function harness(t, { generated = false, beforeCreate } = {}) { } return { page, shapes, requests, pending, respond, candidate, timers, - reset: () => respond('compound', { schema_version: 1, status: 'reset', reason: 'initial_cursor', requested_cursor: null, next_cursor: '1-0', messages: [] }), + reset: () => respond('compound', { schema_version: 1, status: 'bootstrap', projection_kind: 'compound_candidates', requested_cursor: null, next_cursor: '1-0', runtime_epoch: 'a'.repeat(32), last_sequence: 1, bootstrap_observed_at_ms: 7000, records: [] }), + ordinaryBootstrap: () => respond('ordinary', { schema_version: 1, status: 'bootstrap', projection_kind: 'strategy27_events', requested_cursor: null, next_cursor: '1-0', runtime_epoch: 'a'.repeat(32), last_sequence: 1, bootstrap_observed_at_ms: 7000, records: [] }), rows: () => page.document.querySelectorAll('[data-role="compound-row"]').length, tick: () => timers.get(1)(), clear: () => menus.get('清除 Strategy 27 图表标注')(), @@ -117,7 +118,7 @@ test('real entrypoint starts independent clients and manual clear preserves comp h.clear(); assert.equal(h.rows(), 0); assert.deepEqual([...h.shapes.keys()], ['user-owned']); - await h.candidate(2, '2-0', '3-0'); + await h.candidate(3, '2-0', '3-0'); assert.equal(h.rows(), 0); assert.equal(h.shapes.size, 1); assert.equal(h.pending('ordinary').length, 1); @@ -209,7 +210,7 @@ for (const generated of [false, true]) { await new Promise(setImmediate); assert.equal(h.shapes.size, 1); assert.equal(h.rows(), 0); - await h.candidate(2, '2-0', '3-0'); + await h.candidate(3, '2-0', '3-0'); assert.equal(h.rows(), 0); assert.deepEqual([...h.shapes.keys()], ['user-owned']); }); @@ -228,7 +229,7 @@ function ordinaryMessage() { }; return { schema_version: 2, strategy_id: '27', spec_version: '27_2_spec_v10', runtime_epoch: 'a'.repeat(32), - sequence: 1, message_kind: 'event_updated', symbol: 'BTC/USDT:USDT', event_id: 'b'.repeat(64), + sequence: 2, message_kind: 'event_updated', symbol: 'BTC/USDT:USDT', event_id: 'b'.repeat(64), observed_at_ms: 2000, event_time_ms: 2000, data_status: 'active', payload: { event: { event_kind: 'orderflow_event', analysis_start_at_ms: 0, triggered_at_ms: 1000, @@ -240,10 +241,111 @@ function ordinaryMessage() { }; } +function ordinaryOutcomeMessage() { + const ordinary = ordinaryMessage(); + return { + ...ordinary, + sequence: 3, + message_kind: 'event_outcome', + event_id: ordinary.event_id, + observed_at_ms: 7000, + event_time_ms: 7000, + data_status: 'complete', + payload: { + event: { + ...ordinary.payload.event, + active_end_at_ms: 2000, + event_status: 'complete', + close_reason: 'quiet_period', + }, + outcome: { + window_seconds: 5, + outcome_boundary_at_ms: 7000, + outcome_status: 'complete', + terminated_at_ms: null, + termination_reason: null, + boundary_mid: '99', + return_from_trigger_bps: '-100', + return_from_active_end_bps: '-100', + maximum_upward_excursion_bps: '0', + maximum_downward_excursion_bps: '100', + pre_event_range_break_up: false, + pre_event_range_break_down: true, + spread_change_from_active_end_bps: '0.1', + eligible_orderbook_observation_count: 4, + impulse_direction: 'down', + directional_outcome: 'continuation', + }, + }, + }; +} + +function compoundMessage() { + return { + schema_version: 1, + projection_kind: 'compound_candidate', + runtime_epoch: 'a'.repeat(32), + sequence: 2, + message_kind: 'candidate', + symbol: fixtures[0].symbol, + observed_at_ms: 7000, + payload: fixtures[0], + }; +} + +for (const generated of [false, true]) { + test(`${generated ? 'generated' : 'source'} refresh bootstrap rebuilds ordinary and compound markers before live polling`, async (t) => { + const h = await harness(t, { generated }); + const ordinary = ordinaryMessage(); + const outcome = ordinaryOutcomeMessage(); + await h.respond('ordinary', { + schema_version: 1, + status: 'bootstrap', + projection_kind: 'strategy27_events', + requested_cursor: null, + next_cursor: '3-0', + runtime_epoch: ordinary.runtime_epoch, + last_sequence: outcome.sequence, + bootstrap_observed_at_ms: 7000, + records: [{ + event_id: ordinary.event_id, + event_envelope: ordinary, + marker_envelope: ordinary, + outcome_envelope: null, + }, { + event_id: outcome.event_id, + event_envelope: outcome, + marker_envelope: null, + outcome_envelope: outcome, + }], + }); + await h.respond('compound', { + schema_version: 1, + status: 'bootstrap', + projection_kind: 'compound_candidates', + requested_cursor: null, + next_cursor: '2-0', + runtime_epoch: 'a'.repeat(32), + last_sequence: 2, + bootstrap_observed_at_ms: 7000, + records: [compoundMessage()], + }); + await until(() => h.shapes.size === 4); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); + assert.equal(h.rows(), 1); + assert.equal(h.pending('ordinary').length, 1); + assert.equal(h.pending('compound').length, 1); + assert.deepEqual( + h.requests.slice(0, 2).map(({ options }) => new URL(options.url).pathname).sort(), + ['/v1/strategy27/compound-candidates/bootstrap', '/v1/strategy27/events/bootstrap'], + ); + }); +} + for (const generated of [false, true]) { test(`${generated ? 'generated' : 'source'} timer restores ordinary drawings and prunes both lifecycles before repair`, async (t) => { const h = await harness(t, { generated }); - await h.respond('ordinary', { schema_version: 1, status: 'reset', reason: 'initial_cursor', requested_cursor: null, next_cursor: '1-0', messages: [] }); + await h.ordinaryBootstrap(); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinaryMessage()] }); await until(() => h.shapes.size === 2 || h.page.document.getElementById('jh-strategy27-event-status')?.dataset.state === 'error'); assert.equal(h.shapes.size, 2, h.page.document.getElementById('jh-strategy27-event-status')?.textContent); @@ -270,7 +372,7 @@ test('timer expiry cancels an ordinary first creation that is still awaiting Tra const entered = Promise.withResolvers(); const release = Promise.withResolvers(); const h = await harness(t, { beforeCreate: async () => { entered.resolve(); await release.promise; } }); - await h.respond('ordinary', { schema_version: 1, status: 'reset', reason: 'initial_cursor', requested_cursor: null, next_cursor: '1-0', messages: [] }); + await h.ordinaryBootstrap(); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinaryMessage()] }); await entered.promise; h.setNow(7207001); diff --git a/test/unit/binance-strategy27-events/compound-candidate-client.test.js b/test/unit/binance-strategy27-events/compound-candidate-client.test.js index 3ea240a..c6ca243 100644 --- a/test/unit/binance-strategy27-events/compound-candidate-client.test.js +++ b/test/unit/binance-strategy27-events/compound-candidate-client.test.js @@ -2,9 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createCompoundCandidateClient } from '../../../src/binance-strategy27-events/core/compound-candidate-client.js'; import { Strategy27GatewayTransportError } from '../../../src/binance-strategy27-events/core/live-event-client.js'; -import { validateCompoundGatewayResponse } from '../../../src/binance-strategy27-events/core/compound-candidate-contract.js'; +import { validateCompoundBootstrapResponse, validateCompoundGatewayResponse } from '../../../src/binance-strategy27-events/core/compound-candidate-contract.js'; const initial = (next = '5-0') => ({ schema_version: 1, status: 'reset', reason: 'initial_cursor', requested_cursor: null, next_cursor: next, messages: [] }); +const bootstrap = (next = '5-0') => ({ schema_version: 1, status: 'bootstrap', projection_kind: 'compound_candidates', requested_cursor: null, next_cursor: next, runtime_epoch: 'a'.repeat(32), last_sequence: 4, bootstrap_observed_at_ms: 7000, records: [] }); const ok = (requested = '5-0', next = '8-0') => ({ schema_version: 1, status: 'ok', requested_cursor: requested, next_cursor: next, messages: [] }); const response = (body, status = 200) => ({ status, responseText: JSON.stringify(body) }); const unavailable = (code = 'compound_unavailable') => response({ schema_version: 1, status: 'error', error_code: code }, 503); @@ -34,14 +35,14 @@ function harness(steps, { onResponse = () => {}, onState = () => {} } = {}) { test('compound route owns its cursor and accepts stale-cursor resets', async () => { const stale = { ...initial('12-0'), reason: 'stale_cursor', requested_cursor: '8-0' }; - const h = harness([response(initial()), response(ok()), response(stale, 409)], { + const h = harness([response(bootstrap()), response(ok()), response(stale, 409)], { onResponse: (payload, controller) => { if (payload.next_cursor === '12-0') controller.abort(); }, }); await h.run(); - assert.deepEqual(h.calls.map((url) => url.pathname), Array(3).fill('/v1/strategy27/compound-candidates')); + assert.deepEqual(h.calls.map((url) => url.pathname), ['/v1/strategy27/compound-candidates/bootstrap', '/v1/strategy27/compound-candidates', '/v1/strategy27/compound-candidates']); assert.deepEqual(h.calls.map((url) => url.searchParams.get('cursor')), [null, '5-0', '8-0']); assert.deepEqual(h.calls.map((url) => url.searchParams.get('symbol')), Array(3).fill('BTR/USDT:USDT')); - assert.deepEqual(h.received, [initial(), ok(), stale]); + assert.deepEqual(h.received, [bootstrap(), ok(), stale]); assert.deepEqual(h.states, ['connected']); }); @@ -55,24 +56,24 @@ test('404 disables compound without parsing HTML or retrying', async () => { test('explicit unavailable responses reset only this cursor before recovery', async () => { for (const code of ['compound_unavailable', 'redis_unavailable']) { - const h = harness([response(initial()), unavailable(code), response(initial('20-0'))], { + const h = harness([response(bootstrap()), unavailable(code), response(bootstrap('20-0'))], { onResponse: (payload, controller) => { if (payload.next_cursor === '20-0') controller.abort(); }, }); await h.run(); assert.deepEqual(h.states, ['connected', 'unavailable', 'connected']); assert.deepEqual(h.calls.map((url) => url.searchParams.get('cursor')), [null, '5-0', null]); - assert.deepEqual(h.received, [initial(), initial('20-0')]); + assert.deepEqual(h.received, [bootstrap(), bootstrap('20-0')]); } }); test('typed network failures retain the cursor and recover without replaying reset', async () => { - const h = harness([response(initial()), new Strategy27GatewayTransportError('fixture transport failure'), response(ok())], { + const h = harness([response(bootstrap()), new Strategy27GatewayTransportError('fixture transport failure'), response(ok())], { onResponse: (payload, controller) => { if (payload.status === 'ok') controller.abort(); }, }); await h.run(); assert.deepEqual(h.states, ['connected', 'reconnecting', 'connected']); assert.deepEqual(h.calls.map((url) => url.searchParams.get('cursor')), [null, '5-0', '5-0']); - assert.deepEqual(h.received, [initial(), ok()]); + assert.deepEqual(h.received, [bootstrap(), ok()]); }); test('protocol failures stop instead of being classified as transient transport failures', async () => { @@ -89,13 +90,13 @@ test('protocol failures stop instead of being classified as transient transport assert.deepEqual(h.received, []); assert.deepEqual(h.states, []); } - const h = harness([response(initial()), response(ok('5-0', '4-9'))]); + const h = harness([response(bootstrap()), response(ok('5-0', '4-9'))]); await assert.rejects(h.run(), /cursor mismatch\/regression/); - assert.deepEqual(h.received, [initial()]); + assert.deepEqual(h.received, [bootstrap()]); }); test('aborted requests cannot publish late unsupported or unavailable states', async () => { - for (const late of [{ status: 404, responseText: 'missing' }, unavailable(), response(initial())]) { + for (const late of [{ status: 404, responseText: 'missing' }, unavailable(), response(bootstrap())]) { const h = harness([(controller) => { controller.abort(); return late; }]); await h.run(); assert.deepEqual(h.states, []); @@ -124,3 +125,9 @@ test('gateway response wrapper validates exact status, cursor and message bounds [{ ...initial(), reason: 'stale_cursor', requested_cursor: '1-0' }, 200], ]) await assert.rejects(validateCompoundGatewayResponse(body, status)); }); + +test('bootstrap wrapper validates its exact metadata and record bound', async () => { + assert.deepEqual(await validateCompoundBootstrapResponse(bootstrap(), 200), bootstrap()); + await assert.rejects(validateCompoundBootstrapResponse({ ...bootstrap(), extra: true }, 200)); + await assert.rejects(validateCompoundBootstrapResponse({ ...bootstrap(), records: Array(81).fill({}) }, 200)); +}); diff --git a/test/unit/binance-strategy27-events/compound-candidate-lifecycle.test.js b/test/unit/binance-strategy27-events/compound-candidate-lifecycle.test.js index 9e5f399..8230e69 100644 --- a/test/unit/binance-strategy27-events/compound-candidate-lifecycle.test.js +++ b/test/unit/binance-strategy27-events/compound-candidate-lifecycle.test.js @@ -119,3 +119,13 @@ test('wrong-symbol data cannot mutate stream state', async () => { assert.equal(state.size, 0); assert.equal(state.runtimeEpoch, null); }); + +test('bootstrap restores retained candidates and advances to the live tail', async () => { + const state = lifecycle(); + state.beginBootstrap(EPOCH); + assert.equal((await state.apply(envelope(fixtures[0], 4), 7000)).type, 'candidate'); + state.finishBootstrap(7); + assert.equal((await state.apply(control(8), 7000)).type, 'heartbeat'); + assert.equal(state.size, 1); + assert.equal(state.lastSequence, 8); +}); diff --git a/test/unit/binance-strategy27-events/live-event-client.test.js b/test/unit/binance-strategy27-events/live-event-client.test.js index 2dabdeb..97f9269 100644 --- a/test/unit/binance-strategy27-events/live-event-client.test.js +++ b/test/unit/binance-strategy27-events/live-event-client.test.js @@ -7,6 +7,12 @@ import { normalizeGatewayBaseUrl, } from '../../../src/binance-strategy27-events/core/live-event-client.js'; +const bootstrap = (next = '5-0') => ({ + schema_version: 1, status: 'bootstrap', projection_kind: 'strategy27_events', + requested_cursor: null, next_cursor: next, runtime_epoch: 'a'.repeat(32), + last_sequence: 4, bootstrap_observed_at_ms: 7000, records: [], +}); + test('accepts only an explicit loopback HTTP gateway origin', () => { assert.equal(normalizeGatewayBaseUrl('http://127.0.0.1:18765/'), 'http://127.0.0.1:18765'); assert.throws(() => normalizeGatewayBaseUrl('https://example.com'), /loopback/); @@ -44,11 +50,7 @@ test('long polling binds each response to its requested cursor', async () => { status: 200, responseText: JSON.stringify({ schema_version: 1, - status: 'reset', - reason: 'initial_cursor', - requested_cursor: null, - next_cursor: '5-0', - messages: [], + ...bootstrap(), }), }, { @@ -78,22 +80,28 @@ test('long polling binds each response to its requested cursor', async () => { }); await client.run(controller.signal); + assert.equal(new URL(urls[0]).pathname, '/v1/strategy27/events/bootstrap'); assert.equal(new URL(urls[0]).searchParams.has('cursor'), false); + assert.equal(new URL(urls[1]).pathname, '/v1/strategy27/events'); assert.equal(new URL(urls[1]).searchParams.get('cursor'), '5-0'); }); test('long polling rejects a response for a different requested cursor', async () => { + let requestCount = 0; const client = createLiveEventClient({ - request: async () => ({ - status: 200, - responseText: JSON.stringify({ + request: async () => { + requestCount += 1; + return { + status: 200, + responseText: JSON.stringify(requestCount === 1 ? bootstrap() : { schema_version: 1, status: 'ok', requested_cursor: '9-0', next_cursor: '10-0', messages: [], - }), - }), + }), + }; + }, gatewayBaseUrl: 'http://127.0.0.1:18765', authSecret: 'secret', canonicalSymbol: 'BTR/USDT:USDT', @@ -118,13 +126,9 @@ test('reconnects after a GM transport failure and keeps the requested cursor', a } options.onload({ status: 200, - responseText: JSON.stringify({ - schema_version: 1, - status: requestCount === 1 ? 'reset' : 'ok', - ...(requestCount === 1 ? { reason: 'initial_cursor' } : {}), - requested_cursor: requestCount === 1 ? null : '5-0', - next_cursor: requestCount === 1 ? '5-0' : '8-0', - messages: [], + responseText: JSON.stringify(requestCount === 1 ? bootstrap() : { + schema_version: 1, status: 'ok', requested_cursor: '5-0', + next_cursor: '8-0', messages: [], }), }); }); diff --git a/test/unit/binance-strategy27-events/live-event-contract.test.js b/test/unit/binance-strategy27-events/live-event-contract.test.js index ef3e260..183bf61 100644 --- a/test/unit/binance-strategy27-events/live-event-contract.test.js +++ b/test/unit/binance-strategy27-events/live-event-contract.test.js @@ -6,6 +6,7 @@ import { eventTimeToChartSecond, LiveEventLifecycle, routeSymbolToCanonical, + validateGatewayBootstrapResponse, validateGatewayResponse, validateLiveEnvelope, } from '../../../src/binance-strategy27-events/core/live-event-contract.js'; @@ -294,6 +295,107 @@ test('tracks exact sequence and event lifecycle while allowing reset rehydration assert.equal(rehydrated.rehydrated, true); }); +test('bootstrap restores a sparse retained subsequence and advances to the live tail', () => { + const retained = envelope({ sequence: 4, kind: 'event_updated', eventTime: 1_250 }); + const body = { + schema_version: 1, + status: 'bootstrap', + projection_kind: 'strategy27_events', + requested_cursor: null, + next_cursor: '12-0', + runtime_epoch: epoch, + last_sequence: 7, + bootstrap_observed_at_ms: 7000, + records: [{ + event_id: eventId, + event_envelope: retained, + marker_envelope: null, + outcome_envelope: null, + }], + }; + assert.equal(validateGatewayBootstrapResponse(body, 200), body); + assert.throws( + () => validateGatewayBootstrapResponse({ + ...body, + records: [{ ...body.records[0], event_envelope: null }], + }, 200), + /event envelope is required/, + ); + const closedEvent = event({ status: 'complete', activeEnd: 2_000 }); + const outcomeOnly = envelope({ + sequence: 6, + kind: 'event_outcome', + payload: { + event: closedEvent, + outcome: { + window_seconds: 1, + outcome_boundary_at_ms: 3_000, + outcome_status: 'complete', + terminated_at_ms: null, + termination_reason: null, + boundary_mid: '1.2', + return_from_trigger_bps: '-4', + return_from_active_end_bps: '-3', + maximum_upward_excursion_bps: '1', + maximum_downward_excursion_bps: '4', + pre_event_range_break_up: false, + pre_event_range_break_down: true, + spread_change_from_active_end_bps: '0.2', + eligible_orderbook_observation_count: 4, + impulse_direction: 'up', + directional_outcome: 'reversal', + }, + }, + status: 'complete', + eventTime: 3_000, + }); + const outcomeOnlyBody = { + ...body, + records: [{ + event_id: eventId, + event_envelope: outcomeOnly, + marker_envelope: null, + outcome_envelope: outcomeOnly, + }], + }; + assert.equal(validateGatewayBootstrapResponse(outcomeOnlyBody, 200), outcomeOnlyBody); + assert.throws( + () => validateGatewayBootstrapResponse({ + ...outcomeOnlyBody, + records: [{ ...outcomeOnlyBody.records[0], outcome_envelope: retained }], + }, 200), + /event envelope is invalid/, + ); + const sparseLifecycle = new LiveEventLifecycle('BTR/USDT:USDT', lifecycleOptions); + sparseLifecycle.beginBootstrap({ + runtimeEpoch: body.runtime_epoch, + observedAtMs: body.bootstrap_observed_at_ms, + }); + assert.equal(sparseLifecycle.apply(retained).phase, 'active'); + const sparseOutcome = sparseLifecycle.apply(outcomeOnly); + assert.equal(sparseOutcome.phase, 'closed'); + assert.equal(sparseOutcome.outcomes.length, 1); + sparseLifecycle.finishBootstrap(body.last_sequence); + const strictLifecycle = new LiveEventLifecycle('BTR/USDT:USDT', lifecycleOptions); + strictLifecycle.apply(envelope()); + assert.throws( + () => strictLifecycle.apply(envelope({ + sequence: 2, + kind: 'event_outcome', + payload: outcomeOnly.payload, + status: 'complete', + eventTime: 3_000, + })), + /requires a closed event/, + ); + const lifecycle = new LiveEventLifecycle('BTR/USDT:USDT', lifecycleOptions); + lifecycle.beginBootstrap({ runtimeEpoch: body.runtime_epoch, observedAtMs: body.bootstrap_observed_at_ms }); + assert.equal(lifecycle.apply(retained).type, 'event'); + lifecycle.finishBootstrap(body.last_sequence); + assert.equal(lifecycle.apply(envelope({ sequence: 8, kind: 'event_updated', eventTime: 1_250 })).type, 'event'); + assert.equal(lifecycle.lastSequence, 8); +}); + test('rejects unknown lifecycle transitions without a reset', () => { const lifecycle = new LiveEventLifecycle('BTR/USDT:USDT', lifecycleOptions); lifecycle.apply(envelope()); diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index dc33c2a..f62ffac 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -57,7 +57,7 @@ test('release contract identifies the generated Strategy 27 annotation artifact' assert.equal(contract.name, '【自写】Binance Strategy 27 事件标注'); assert.equal(contract.namespace, 'binance.strategy27.events'); - assert.equal(contract.version, '0.4.1'); + assert.equal(contract.version, '0.4.2'); assert.equal(contract.runAt, 'document-idle'); assert.equal(contract.updateURL, contract.downloadURL); assert.deepEqual(contract.matches, [