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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions docs/binance-strategy27-events-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,21 @@ 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

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
Expand All @@ -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
Expand Down
222 changes: 203 additions & 19 deletions scripts/binance-strategy27-events.user.js

Large diffs are not rendered by default.

22 changes: 16 additions & 6 deletions src/binance-strategy27-events/core/compound-candidate-client.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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;
Expand All @@ -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 });
Expand All @@ -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;
}
}
},
});
Expand Down
30 changes: 30 additions & 0 deletions src/binance-strategy27-events/core/compound-candidate-contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {}) {
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -115,6 +125,9 @@ export function createCompoundCandidateController({
pendingCandidateId = null;
}
}
if (response.status === 'bootstrap' && current()) {
lifecycle.finishBootstrap(response.last_sequence);
}
}

return Object.freeze({
Expand Down
14 changes: 14 additions & 0 deletions src/binance-strategy27-events/core/compound-candidate-lifecycle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
33 changes: 25 additions & 8 deletions src/binance-strategy27-events/core/live-event-client.js
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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');
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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({
Expand All @@ -148,17 +151,31 @@ 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) {
reconnecting = false;
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;
}
}
},
});
Expand Down
74 changes: 73 additions & 1 deletion src/binance-strategy27-events/core/live-event-contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading