From 7995f778229742989c6c88c910564d7f0f2b1f2e Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:26:33 +0800 Subject: [PATCH] fix(strategy27): retain compound candidate history through recovery --- docs/binance-strategy27-events-development.md | 40 +++-- scripts/binance-strategy27-events.user.js | 109 +++++++------ .../core/compound-candidate-controller.js | 75 +++++---- .../core/compound-candidate-lifecycle.js | 21 +-- .../dom/tradingview-compound-layer.js | 20 ++- src/binance-strategy27-events/index.user.js | 2 +- .../compound-candidate-controller.test.js | 151 ++++++++++++++---- .../strategy27-entrypoint.test.js | 98 +++++++++++- .../tradingview-compound-layer.test.js | 45 ++++++ .../compound-candidate-lifecycle.test.js | 60 ++++++- test/unit/userscript-release-contract.test.js | 2 +- 11 files changed, 483 insertions(+), 140 deletions(-) diff --git a/docs/binance-strategy27-events-development.md b/docs/binance-strategy27-events-development.md index f3c7570..a8f8118 100644 --- a/docs/binance-strategy27-events-development.md +++ b/docs/binance-strategy27-events-development.md @@ -11,7 +11,7 @@ V10 live projection. The VPS remains the only market-data and event-analysis authority. The userscript opens no Binance market-data WebSocket, uses no Binance API key, and does not recalculate the four force groups. -Version 0.6.1 retains this installation's private gateway configuration and +Version 0.6.2 retains this installation's private gateway configuration and provides a shared read-only transport. Strategy29 owns its own summary panel, lifecycle and panel position. The existing `strategy27GatewayOrigin` and `strategy27GatewayAuthSecret` storage keys remain the single credential source; @@ -148,8 +148,8 @@ message arrives. Compound candidates restore only missing parts of their icon/label pair, preserving the original slot and surviving entity IDs. Each 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. -Manual clear, context changes and display retention eviction invalidate ordinary -repair ownership; compound resets also invalidate their own repair ownership; +Manual clear, context changes and display retention eviction invalidate owned +repairs; terminal job failures suspend new presentation and repair; late-created entities are removed instead of resurrecting retired records. Reconciliation does not refresh retention timestamps. Drawings remain transient and use `disableSave: true`, but a full page reload requests a bounded display @@ -165,7 +165,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.6.1 with identical metadata headers. +generated install artifact are version 0.6.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 @@ -176,8 +176,9 @@ Do not treat source unit tests or the panel fixture as deployment evidence. - The compound client has a separate cursor for `/v1/strategy27/compound-candidates`. Non-JSON HTTP 404 disables only that - client until restart. Validated HTTP 503 `compound_unavailable` and - `redis_unavailable` clear only compound state and retry after two seconds. + client until restart while retaining verified history. Validated HTTP 503 + `compound_unavailable` and `redis_unavailable` reset compound protocol state, + preserve verified history, and retry bootstrap after two seconds. 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. @@ -200,8 +201,11 @@ Do not treat source unit tests or the panel fixture as deployment evidence. from the original decision time. Exact replay does not refresh that age or create another marker. Heartbeats do not clear history. Epoch changes require `stream_state`; symbol filtering permits increasing sequence gaps, not - regressions. Capacity eviction follows decision time and candidate ID with a - monotonic cutoff so old replay cannot resurrect evicted observations. + regressions. Protocol resets and bootstrap preserve accepted immutable candidate + IDs, including records suppressed by manual clear. Replayed candidates do not + redraw or republish them. Capacity eviction follows decision time and candidate + ID with a monotonic cutoff that survives protocol resets, so old replay cannot + resurrect evicted observations. Full context retirement clears this ownership. - Base rule identity is `(family, direction, profile_id)`. Reinforcement also includes `parent_candidate_id` in its displayed lineage identity. Each occurrence has its own candidate ID; different rules or parents at the same @@ -213,8 +217,9 @@ Do not treat source unit tests or the panel fixture as deployment evidence. - The compound controller owns its request cancellation and terminal error boundary. It constructs its chart layer only on the first accepted candidate, so a missing compound chart capability cannot fail ordinary startup. A stream - state clears compound views without erasing the newly accepted epoch/sequence; - a gateway reset or explicit unavailability resets both lifecycle and view. + state accepts the new epoch/sequence without clearing verified candidates. + Gateway resets, unavailability and bootstrap also preserve their native entity + IDs, slots and panel history, including candidates absent from a newer snapshot. Manual clear preserves replay bookkeeping but invalidates pending presentation. Age eviction also invalidates a pending draw, and a second age check runs after drawing before publication to the panel. The existing context timer calls @@ -222,11 +227,16 @@ Do not treat source unit tests or the panel fixture as deployment evidence. second timer. Route/interval changes and disappearance of the visible chart stop both clients before destroying the shared panel. The clear menu clears both views without restarting either client. -- Native cleanup attempts every owned entity once and aggregates failures. - Cleanup failure stops only the compound job, clears its panel records and - reports the original and cleanup errors without interrupting ordinary - shutdown. An asynchronous drawing failure after context retirement is retained - as the controller's `lastError`, without writing into a retired panel. +- Terminal protocol/render/repair failures stop only the compound job and freeze + verified pairs and panel records with an explicit error and reconnect guidance. + Pending candle waits and late creates/repairs lose presentation ownership; + incomplete pairs are removed, while surviving verified entities remain. + The existing context timer continues decision-time age pruning after failure, + and manual clear and context retirement remain effective. Native cleanup + attempts every retired entity once and aggregates failures without retrying an + unknown removal. Cleanup errors do not interrupt ordinary shutdown or discard + unrelated retained history. An asynchronous drawing failure after context + retirement is retained as `lastError`, without writing into a retired panel. - Each candidate owns a 36-pixel native icon arrow and a short text label: dark red down/`候选高` above the candle, dark green up/`候选低` below it. Annotation direction remains `arrow_down`/`arrow_up`; native drawing options diff --git a/scripts/binance-strategy27-events.user.js b/scripts/binance-strategy27-events.user.js index d4deaac..8c9b520 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.6.1 +// @version 0.6.2 // @author jackhai9 // @description Display Strategy 27 events and provide the shared private CorsairQuant gateway connection // @match https://www.binance.com/*/futures/* @@ -2445,16 +2445,20 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, this.reset("initial_cursor"); } reset(reason) { - check2(["initial_cursor", "stale_cursor", "route_changed", "interval_changed", "unavailable", "stopped"].includes(reason), "reset reason is invalid"); - __privateSet(this, _generation, __privateGet(this, _generation) + 1); + this.resetProtocol(reason); __privateGet(this, _records).clear(); __privateSet(this, _evictionBoundary, null); + } + /** Invalidate in-flight validation without retiring verified observations. */ + resetProtocol(reason) { + check2(["initial_cursor", "stale_cursor", "route_changed", "interval_changed", "unavailable", "stopped"].includes(reason), "reset reason is invalid"); + __privateSet(this, _generation, __privateGet(this, _generation) + 1); 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.resetProtocol("initial_cursor"); this.runtimeEpoch = runtimeEpoch; this.lastSequence = 0; } @@ -2494,10 +2498,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, this.runtimeEpoch = envelope.runtime_epoch; this.lastSequence = envelope.sequence; if (isState) { - const removedCandidateIds2 = [...__privateGet(this, _records).keys()]; - __privateGet(this, _records).clear(); - __privateSet(this, _evictionBoundary, null); - return { type: "stream_reset", removedCandidateIds: removedCandidateIds2 }; + return { type: "stream_reset", removedCandidateIds: this.prune(nowMs) }; } const removedCandidateIds = this.prune(nowMs); if (envelope.message_kind === "heartbeat") return { type: "heartbeat", removedCandidateIds }; @@ -2569,17 +2570,19 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, return CONNECTION_STATUS; } function renderStatus() { - if (lastError) panel.setCompoundStatus(t("复合候选已停止:", "Compound candidates stopped: ") + lastError.message, "error"); + if (lastError) panel.setCompoundStatus(t("复合候选已停止,历史记录已保留。请使用重新连接菜单恢复:", "Compound candidates stopped; history retained. Use the reconnect menu to resume: ") + lastError.message, "error"); else panel.setCompoundStatus(...connectionStatus()[connectionState]); } const lifecycle = new CompoundCandidateLifecycle(canonicalSymbol, { maxCandidates, maxAgeMs }); const abortController = new AbortController(); let layer = null; let started = false; + let stopped = false; let viewGeneration = 0; let pendingCandidateId = null; let lastError = null; - const current = () => !abortController.signal.aborted && isCurrent(); + const ownsContext = () => !stopped && isCurrent(); + const current = () => !abortController.signal.aborted && ownsContext(); function clearView() { viewGeneration += 1; pendingCandidateId = null; @@ -2593,22 +2596,37 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, return cleanupError; } function remove(ids) { + const errors = []; for (const id of ids) { if (id === pendingCandidateId) viewGeneration += 1; - layer?.remove(id); + try { + layer?.remove(id); + } catch (error) { + errors.push(error); + } panel.removeCompound(id); } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, errors.map((error) => error.message).join("; ")); } function prune(observedAtMs = nowMs()) { - if (current()) remove(lifecycle.prune(observedAtMs)); + if (ownsContext()) remove(lifecycle.prune(observedAtMs)); } - function failJob(error, { clear = true } = {}) { - lastError = error; - if (!current()) return; + function suspendView() { + viewGeneration += 1; + pendingCandidateId = null; + layer?.suspend(); + } + function failJob(error) { + lastError = lastError === null || lastError === error ? error : new AggregateError([lastError, error], `${lastError.message}; ${error.message}`); + if (!ownsContext()) return; abortController.abort(); - lifecycle.reset("stopped"); - const cleanupError = clear ? clearView() : null; - if (cleanupError) lastError = new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); + lifecycle.resetProtocol("stopped"); + try { + suspendView(); + } catch (cleanupError) { + lastError = new AggregateError([lastError, cleanupError], `${lastError.message}; ${cleanupError.message}`); + } renderStatus(); } function onConnectionStateChange(state) { @@ -2616,33 +2634,26 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, const status = connectionStatus()[state]; if (!status) throw new Error(`Unknown compound connection state: ${state}`); if (state === "unavailable" || state === "unsupported") { - lifecycle.reset("unavailable"); - const error = clearView(); - if (error) { - failJob(error, { clear: false }); - return; - } + lifecycle.resetProtocol("unavailable"); + } + if (state === "unsupported") { + abortController.abort(); + suspendView(); } connectionState = state; renderStatus(); } async function onResponse(response) { if (!current()) return; + prune(); if (response.status === "reset") { - lifecycle.reset(response.reason); - const error = clearView(); - if (error) failJob(error, { clear: false }); + lifecycle.resetProtocol(response.reason); return; } 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) { @@ -2652,11 +2663,6 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, if (!current()) return; remove(action.removedCandidateIds); if (action.type === "stream_reset") { - const error = clearView(); - if (error) { - failJob(error, { clear: false }); - return; - } continue; } if (action.type !== "candidate" || applicationGeneration !== viewGeneration) continue; @@ -2716,9 +2722,9 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, })(); }, clear() { - if (!current()) return; + if (!ownsContext()) return; const error = clearView(); - if (error) failJob(error, { clear: false }); + if (error) failJob(error); }, prune() { try { @@ -2736,13 +2742,14 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, } }, stop(reason) { - if (abortController.signal.aborted) return; + if (stopped) return; + stopped = true; abortController.abort(); lifecycle.reset(reason); const error = clearView(); if (error) { - lastError = error; - renderStatus(); + lastError = lastError === null ? error : new AggregateError([lastError, error], `${lastError.message}; ${error.message}`); + if (isCurrent()) renderStatus(); } }, // A late drawing rejection remains inspectable without touching a retired panel. @@ -2777,6 +2784,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, const records = /* @__PURE__ */ new Map(); let pending = null; let reconciliation = null; + let suspended = false; function dispose(recordsToRemove) { const errors = []; const liveIds = readLiveShapeIds(chart); @@ -2811,7 +2819,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, } function restoreCandidate(id, record, liveIds) { if (record.restoring) return record.restoring; - const current = () => records.get(id) === record && isChartCurrent(); + const current = () => !suspended && records.get(id) === record && isChartCurrent(); if (!current()) return Promise.resolve(false); if (record.ids.every((entityId) => liveIds.has(entityId))) return Promise.resolve(true); record.restoring = (async () => { @@ -2835,11 +2843,12 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, return record.restoring; } function reconcile() { + if (suspended) return Promise.resolve(); if (reconciliation) return reconciliation; reconciliation = (async () => { let liveIds = readLiveShapeIds(chart); for (const [id, record] of [...records]) { - if (records.get(id) !== record || !isChartCurrent()) continue; + if (suspended || records.get(id) !== record || !isChartCurrent()) continue; if (!record.restoring && record.ids.every((entityId) => liveIds.has(entityId))) continue; await restoreCandidate(id, record, liveIds); liveIds = readLiveShapeIds(chart); @@ -2870,6 +2879,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, dispose(removals); } async function renderCandidate(id, annotation, decisionAtMs) { + if (suspended) return false; const existing = records.get(id); if (existing) return restoreCandidate(id, existing, readLiveShapeIds(chart)); if (pending !== null) throw new Error("Compound chart rendering must be serial"); @@ -2902,11 +2912,11 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, for (const [drawingPoint, drawing] of drawings) { const entityId = await createDrawing(drawingPoint, drawing); operation.ids.push(entityId); - if (drawing.shape === "text") updateLabel(entityId, drawing, annotation.markerShape); if (operation.controller.signal.aborted || !isChartCurrent()) { dispose([operation]); return false; } + if (drawing.shape === "text") updateLabel(entityId, drawing, annotation.markerShape); } records.set(id, { ids: operation.ids.splice(0), group, slot, decisionAtMs, markerShape: annotation.markerShape, drawings, restoring: null }); return true; @@ -2940,7 +2950,14 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, if (liveIds.has(record.ids[1])) updateLabel(record.ids[1], drawing, record.markerShape); } } - return Object.freeze({ setLocale, renderCandidate, reconcile, remove, clear, get size() { + function suspend() { + suspended = true; + if (pending) { + pending.controller.abort(); + dispose([pending]); + } + } + return Object.freeze({ setLocale, renderCandidate, reconcile, remove, clear, suspend, get size() { return records.size; } }); } diff --git a/src/binance-strategy27-events/core/compound-candidate-controller.js b/src/binance-strategy27-events/core/compound-candidate-controller.js index c49da5a..40a56a3 100644 --- a/src/binance-strategy27-events/core/compound-candidate-controller.js +++ b/src/binance-strategy27-events/core/compound-candidate-controller.js @@ -8,7 +8,9 @@ import { CompoundCandidateLifecycle } from './compound-candidate-lifecycle.js'; * The lifecycle accepts immutable records before asynchronous drawing. A view * generation separately invalidates pending presentation on manual clear or * eviction without erasing sequence/replay bookkeeping. The chart layer must - * cancel pending owned entities on remove/clear, including late create results. + * cancel pending owned entities on remove/clear/suspend, including late creates. + * Polling failure suspends presentation; context retirement alone ends ownership + * of the bounded history and its existing timer-driven age cleanup. */ export function createCompoundCandidateController({ request, gatewayBaseUrl, authSecret, canonicalSymbol, panel, createLayer, @@ -28,17 +30,19 @@ export function createCompoundCandidateController({ return CONNECTION_STATUS; } function renderStatus() { - if (lastError) panel.setCompoundStatus(t('复合候选已停止:', 'Compound candidates stopped: ') + lastError.message, 'error'); + if (lastError) panel.setCompoundStatus(t('复合候选已停止,历史记录已保留。请使用重新连接菜单恢复:', 'Compound candidates stopped; history retained. Use the reconnect menu to resume: ') + lastError.message, 'error'); else panel.setCompoundStatus(...connectionStatus()[connectionState]); } const lifecycle = new CompoundCandidateLifecycle(canonicalSymbol, { maxCandidates, maxAgeMs }); const abortController = new AbortController(); let layer = null; let started = false; + let stopped = false; let viewGeneration = 0; let pendingCandidateId = null; let lastError = null; - const current = () => !abortController.signal.aborted && isCurrent(); + const ownsContext = () => !stopped && isCurrent(); + const current = () => !abortController.signal.aborted && ownsContext(); function clearView() { viewGeneration += 1; @@ -54,24 +58,43 @@ export function createCompoundCandidateController({ } function remove(ids) { + const errors = []; for (const id of ids) { if (id === pendingCandidateId) viewGeneration += 1; - layer?.remove(id); + // Prune has already retired every ID; attempt each cleanup once even if + // one native removal fails, then stop this job with the complete evidence. + try { + layer?.remove(id); + } catch (error) { + errors.push(error); + } panel.removeCompound(id); } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, errors.map((error) => error.message).join('; ')); } function prune(observedAtMs = nowMs()) { - if (current()) remove(lifecycle.prune(observedAtMs)); + if (ownsContext()) remove(lifecycle.prune(observedAtMs)); } - function failJob(error, { clear = true } = {}) { - lastError = error; - if (!current()) return; + function suspendView() { + viewGeneration += 1; + pendingCandidateId = null; + layer?.suspend(); + } + + function failJob(error) { + lastError = lastError === null || lastError === error ? error + : new AggregateError([lastError, error], `${lastError.message}; ${error.message}`); + if (!ownsContext()) return; abortController.abort(); - lifecycle.reset('stopped'); - const cleanupError = clear ? clearView() : null; - if (cleanupError) lastError = new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); + lifecycle.resetProtocol('stopped'); + try { + suspendView(); + } catch (cleanupError) { + lastError = new AggregateError([lastError, cleanupError], `${lastError.message}; ${cleanupError.message}`); + } renderStatus(); } @@ -80,9 +103,11 @@ export function createCompoundCandidateController({ const status = connectionStatus()[state]; if (!status) throw new Error(`Unknown compound connection state: ${state}`); if (state === 'unavailable' || state === 'unsupported') { - lifecycle.reset('unavailable'); - const error = clearView(); - if (error) { failJob(error, { clear: false }); return; } + lifecycle.resetProtocol('unavailable'); + } + if (state === 'unsupported') { + abortController.abort(); + suspendView(); } connectionState = state; renderStatus(); @@ -90,10 +115,9 @@ export function createCompoundCandidateController({ async function onResponse(response) { if (!current()) return; + prune(); if (response.status === 'reset') { - lifecycle.reset(response.reason); - const error = clearView(); - if (error) failJob(error, { clear: false }); + lifecycle.resetProtocol(response.reason); return; } let messages = response.messages; @@ -102,8 +126,6 @@ export function createCompoundCandidateController({ : 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) { @@ -113,9 +135,7 @@ export function createCompoundCandidateController({ if (!current()) return; remove(action.removedCandidateIds); if (action.type === 'stream_reset') { - // apply() already accepted the epoch and sequence. Only clear the view. - const error = clearView(); - if (error) { failJob(error, { clear: false }); return; } + // Only protocol identity changed; retained candidates remain immutable. continue; } if (action.type !== 'candidate' || applicationGeneration !== viewGeneration) continue; @@ -173,9 +193,9 @@ export function createCompoundCandidateController({ })(); }, clear() { - if (!current()) return; + if (!ownsContext()) return; const error = clearView(); - if (error) failJob(error, { clear: false }); + if (error) failJob(error); }, prune() { // The shared context timer is a second entry into this optional job. @@ -195,13 +215,14 @@ export function createCompoundCandidateController({ } }, stop(reason) { - if (abortController.signal.aborted) return; + if (stopped) return; + stopped = true; abortController.abort(); lifecycle.reset(reason); const error = clearView(); if (error) { - lastError = error; - renderStatus(); + lastError = lastError === null ? error : new AggregateError([lastError, error], `${lastError.message}; ${error.message}`); + if (isCurrent()) renderStatus(); } }, // A late drawing rejection remains inspectable without touching a retired panel. diff --git a/src/binance-strategy27-events/core/compound-candidate-lifecycle.js b/src/binance-strategy27-events/core/compound-candidate-lifecycle.js index 41f2be3..5a7eb94 100644 --- a/src/binance-strategy27-events/core/compound-candidate-lifecycle.js +++ b/src/binance-strategy27-events/core/compound-candidate-lifecycle.js @@ -21,11 +21,12 @@ function compareOrder(a, b) { return a.time - b.time || (a.id === b.id ? 0 : a.id < b.id ? -1 : 1); } -/** Bounded immutable observations, independent of ordinary event lifecycles. +/** Bounded immutable observations, independent of transport epochs. * * Retention follows decision time rather than delivery/replay time. A monotonic * eviction boundary prevents old replay from resurrecting markers without an - * unbounded tombstone map. Full payload hashes are validated even after eviction. + * unbounded tombstone map. Protocol recovery preserves both records and that + * boundary. Full payload hashes are validated even after eviction. */ export class CompoundCandidateLifecycle { #records = new Map(); @@ -44,17 +45,22 @@ export class CompoundCandidateLifecycle { } reset(reason) { - check(['initial_cursor', 'stale_cursor', 'route_changed', 'interval_changed', 'unavailable', 'stopped'].includes(reason), 'reset reason is invalid'); - this.#generation += 1; + this.resetProtocol(reason); this.#records.clear(); this.#evictionBoundary = null; + } + + /** Invalidate in-flight validation without retiring verified observations. */ + resetProtocol(reason) { + check(['initial_cursor', 'stale_cursor', 'route_changed', 'interval_changed', 'unavailable', 'stopped'].includes(reason), 'reset reason is invalid'); + this.#generation += 1; this.runtimeEpoch = null; this.lastSequence = null; } beginBootstrap(runtimeEpoch) { check(typeof runtimeEpoch === 'string' && /^[a-f0-9]{32}$/.test(runtimeEpoch), 'bootstrap epoch is invalid'); - this.reset('initial_cursor'); + this.resetProtocol('initial_cursor'); this.runtimeEpoch = runtimeEpoch; this.lastSequence = 0; } @@ -105,10 +111,7 @@ export class CompoundCandidateLifecycle { this.runtimeEpoch = envelope.runtime_epoch; this.lastSequence = envelope.sequence; if (isState) { - const removedCandidateIds = [...this.#records.keys()]; - this.#records.clear(); - this.#evictionBoundary = null; - return { type: 'stream_reset', removedCandidateIds }; + return { type: 'stream_reset', removedCandidateIds: this.prune(nowMs) }; } const removedCandidateIds = this.prune(nowMs); if (envelope.message_kind === 'heartbeat') return { type: 'heartbeat', removedCandidateIds }; diff --git a/src/binance-strategy27-events/dom/tradingview-compound-layer.js b/src/binance-strategy27-events/dom/tradingview-compound-layer.js index bb0791d..cb3cc23 100644 --- a/src/binance-strategy27-events/dom/tradingview-compound-layer.js +++ b/src/binance-strategy27-events/dom/tradingview-compound-layer.js @@ -31,6 +31,7 @@ export function createTradingViewCompoundLayer(target, { maxCandidates, candleWa const records = new Map(); let pending = null; let reconciliation = null; + let suspended = false; function dispose(recordsToRemove) { const errors = []; @@ -72,7 +73,7 @@ export function createTradingViewCompoundLayer(target, { maxCandidates, candleWa /** Retain slots and surviving parts; concurrent callers share one repair. */ function restoreCandidate(id, record, liveIds) { if (record.restoring) return record.restoring; - const current = () => records.get(id) === record && isChartCurrent(); + const current = () => !suspended && records.get(id) === record && isChartCurrent(); if (!current()) return Promise.resolve(false); if (record.ids.every((entityId) => liveIds.has(entityId))) return Promise.resolve(true); record.restoring = (async () => { @@ -95,11 +96,12 @@ export function createTradingViewCompoundLayer(target, { maxCandidates, candleWa } function reconcile() { + if (suspended) return Promise.resolve(); if (reconciliation) return reconciliation; reconciliation = (async () => { let liveIds = readLiveShapeIds(chart); for (const [id, record] of [...records]) { - if (records.get(id) !== record || !isChartCurrent()) continue; + if (suspended || records.get(id) !== record || !isChartCurrent()) continue; if (!record.restoring && record.ids.every((entityId) => liveIds.has(entityId))) continue; await restoreCandidate(id, record, liveIds); liveIds = readLiveShapeIds(chart); @@ -131,6 +133,7 @@ export function createTradingViewCompoundLayer(target, { maxCandidates, candleWa } async function renderCandidate(id, annotation, decisionAtMs) { + if (suspended) return false; const existing = records.get(id); if (existing) return restoreCandidate(id, existing, readLiveShapeIds(chart)); if (pending !== null) throw new Error('Compound chart rendering must be serial'); @@ -162,11 +165,11 @@ export function createTradingViewCompoundLayer(target, { maxCandidates, candleWa for (const [drawingPoint, drawing] of drawings) { const entityId = await createDrawing(drawingPoint, drawing); operation.ids.push(entityId); - if (drawing.shape === 'text') updateLabel(entityId, drawing, annotation.markerShape); if (operation.controller.signal.aborted || !isChartCurrent()) { dispose([operation]); return false; } + if (drawing.shape === 'text') updateLabel(entityId, drawing, annotation.markerShape); } records.set(id, { ids: operation.ids.splice(0), group, slot, decisionAtMs, markerShape: annotation.markerShape, drawings, restoring: null }); return true; @@ -204,5 +207,14 @@ export function createTradingViewCompoundLayer(target, { maxCandidates, candleWa } } - return Object.freeze({ setLocale, renderCandidate, reconcile, remove, clear, get size() { return records.size; } }); + /** Freeze verified pairs while cancelling unfinished presentation and repair. */ + function suspend() { + suspended = true; + if (pending) { + pending.controller.abort(); + dispose([pending]); + } + } + + return Object.freeze({ setLocale, renderCandidate, reconcile, remove, clear, suspend, get size() { return records.size; } }); } diff --git a/src/binance-strategy27-events/index.user.js b/src/binance-strategy27-events/index.user.js index 8b0ebd2..97ad240 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.6.1 +// @version 0.6.2 // @author jackhai9 // @description Display Strategy 27 events and provide the shared private CorsairQuant gateway connection // @match https://www.binance.com/*/futures/* 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 545e4bd..412fb3d 100644 --- a/test/dom/binance-strategy27-events/compound-candidate-controller.test.js +++ b/test/dom/binance-strategy27-events/compound-candidate-controller.test.js @@ -41,6 +41,7 @@ function harness(t, steps, { render, reconcile, createError, removeError, clearE const calls = []; let layerCreates = 0; let layerClears = 0; + let layerSuspensions = 0; let generation = 0; let pending = null; let clock = 7000; @@ -76,11 +77,16 @@ function harness(t, steps, { render, reconcile, createError, removeError, clearE return true; }, remove(id) { - if (removeError) throw removeError; if (pending?.id === id) pending.cancelled = true; removed.push(id); + if (removeError) throw removeError; shapes.delete(id); }, + suspend() { + layerSuspensions += 1; + generation += 1; + if (pending) pending.cancelled = true; + }, clear() { layerClears += 1; generation += 1; @@ -106,6 +112,7 @@ function harness(t, steps, { render, reconcile, createError, removeError, clearE setCurrent: (value) => { current = value; }, get layerCreates() { return layerCreates; }, get layerClears() { return layerClears; }, + get layerSuspensions() { return layerSuspensions; }, }; } @@ -138,6 +145,23 @@ test('unsupported gateways never construct a chart layer and leave ordinary data assert.equal(h.statusKind(), 'inactive'); }); +test('a compound endpoint removed after delivery freezes history without further repairs', async (t) => { + let repairs = 0; + const h = harness(t, [bootstrap(), batch([envelope()]), { status: 404, responseText: 'route removed' }], { + reconcile: () => { repairs += 1; }, + }); + await h.run(); + await h.controller.reconcile(); + assert.equal(repairs, 0); + assert.equal(h.panel.compoundSize, 1); + assert.equal(h.shapes.size, 1); + assert.equal(h.status(), '网关尚未启用复合候选'); + assert.equal(h.calls.length, 3); + h.controller.clear(); + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.shapes.size, 0); +}); + test('same-decision panel ordering uses publication time without extending chart retention time', async (t) => { const high = { ...envelope(fixtures[0], 2), observed_at_ms: 7010 }; const low = { ...envelope(fixtures[1], 3), observed_at_ms: 7020 }; @@ -149,20 +173,23 @@ test('same-decision panel ordering uses publication time without extending chart assert.deepEqual(h.renders.map((item) => item.decisionAtMs), [7000, 7000]); }); -test('503 clears compound state and recovers with a new cursor without clearing ordinary data', async (t) => { - 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 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); - assert.deepEqual([...h.shapes.keys()], [fixtures[1].candidate_id]); - assert.equal(h.status(), '复合候选数据:已连接。接口连通不代表该币种仍在监控中。'); +test('validated 503 recovery merges a new snapshot without deleting compound or ordinary history', async (t) => { + for (const errorCode of ['compound_unavailable', 'redis_unavailable']) { + let recovering; + const h = harness(t, [bootstrap(), batch([envelope()]), response({ schema_version: 1, status: 'error', error_code: errorCode }, 503), + ({ panel, shapes, calls }) => { + recovering = { ordinary: panel.size, compound: panel.compoundSize, ids: [...shapes.keys()], hasCursor: calls.at(-1).searchParams.has('cursor') }; + return bootstrap('5-0', 'b'.repeat(32)); + }, batch([envelope(fixtures[0], 2, 'b'.repeat(32)), envelope(fixtures[1], 3, 'b'.repeat(32))], '5-0', '6-0')]); + h.run(); + await h.parked; + assert.deepEqual(recovering, { ordinary: 1, compound: 1, ids: [fixtures[0].candidate_id], hasCursor: false }); + assert.equal(h.panel.compoundSize, 2); + assert.deepEqual([...h.shapes.keys()], fixtures.map((candidate) => candidate.candidate_id)); + assert.equal(h.renders.length, 2); + assert.equal(h.layerClears, 0); + assert.equal(h.status(), '复合候选数据:已连接。接口连通不代表该币种仍在监控中。'); + } }); test('network reconnect retains compound history and original cursor', async (t) => { @@ -179,12 +206,15 @@ test('network reconnect retains compound history and original cursor', async (t) assert.equal(h.panel.size, 1); }); -test('stream reset clears compound view but preserves the newly accepted epoch sequence', async (t) => { +test('stream reset retains both candidates and sequence failure freezes their verified history', async (t) => { 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); - assert.equal(h.panel.compoundSize, 0); + assert.equal(h.panel.compoundSize, 2); + assert.deepEqual([...h.shapes.keys()], fixtures.map((candidate) => candidate.candidate_id)); + assert.equal(h.layerClears, 0); + assert.equal(h.layerSuspensions, 1); assert.equal(h.panel.size, 1); assert.match(h.status(), /sequence regression/); assert.equal(h.statusKind(), 'error'); @@ -252,19 +282,20 @@ test('manual clear during lifecycle hash validation suppresses late display and assert.equal(h.calls.length, 4); }); -test('stale cursor reset removes old compound history and accepts the new stream', async (t) => { +test('stale cursor reset preserves history absent from the new bootstrap and accepts the new stream', async (t) => { + let recovering; 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); + recovering = { compound: panel.compoundSize, shapes: shapes.size, ordinary: panel.size }; 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]); - assert.equal(h.panel.compoundSize, 1); + assert.deepEqual(recovering, { compound: 1, shapes: 1, ordinary: 1 }); + assert.deepEqual([...h.shapes.keys()], fixtures.map((candidate) => candidate.candidate_id)); + assert.equal(h.panel.compoundSize, 2); + assert.equal(h.layerClears, 0); assert.equal(h.statusKind(), 'normal'); }); @@ -318,7 +349,9 @@ test('timer-driven prune failures stop only the optional job and do not escape t await done; assert.equal(h.panel.size, 1); assert.equal(h.panel.compoundSize, 0); - assert.equal(h.shapes.size, 0); + assert.equal(h.shapes.size, 1, 'a failed native removal has an unknown outcome and is not retried'); + assert.deepEqual(h.removed, [fixtures[0].candidate_id]); + assert.equal(h.layerClears, 0); assert.equal(h.statusKind(), 'error'); assert.match(h.status(), /fixture removal failure/); }); @@ -349,16 +382,21 @@ 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) => { +test('terminal protocol failure retains history and explicit cleanup preserves both errors', async (t) => { const h = harness(t, [bootstrap(), batch([envelope()]), { status: 200, responseText: 'invalid JSON' }], { clearError: new Error('fixture native cleanup failure'), }); await assert.doesNotReject(h.run()); - assert.equal(h.layerClears, 1); + assert.equal(h.layerClears, 0); assert.equal(h.panel.size, 1); - assert.equal(h.panel.compoundSize, 0); + assert.equal(h.panel.compoundSize, 1); + assert.equal(h.shapes.size, 1); assert.equal(h.statusKind(), 'error'); assert.match(h.status(), /JSON/); + h.controller.clear(); + assert.equal(h.layerClears, 1); + assert.equal(h.panel.compoundSize, 0); + assert.match(h.status(), /JSON/); assert.match(h.status(), /fixture native cleanup failure/); }); @@ -382,7 +420,7 @@ test('late drawing failure after context retirement remains inspectable without assert.equal(h.panel.size, 1); }); -test('timer repair failures stop only the compound job and retain ordinary history', async (t) => { +test('timer repair failures stop only the compound job and retain both histories', async (t) => { const h = harness(t, [bootstrap(), batch([envelope()])], { reconcile: async () => { throw new Error('fixture native repair failure'); }, }); @@ -390,9 +428,62 @@ test('timer repair failures stop only the compound job and retain ordinary histo await h.parked; await h.controller.reconcile(); assert.equal(h.panel.size, 1); - assert.equal(h.panel.compoundSize, 0); - assert.equal(h.shapes.size, 0); + assert.equal(h.panel.compoundSize, 1); + assert.equal(h.shapes.size, 1); + assert.equal(h.layerClears, 0); + assert.equal(h.layerSuspensions, 1); assert.equal(h.statusKind(), 'error'); assert.match(h.status(), /fixture native repair failure/); assert.match(h.controller.lastError.message, /fixture native repair failure/); }); + +test('manual clear stays effective across unavailable, bootstrap replay and another epoch', async (t) => { + const epoch = 'b'.repeat(32); + const snapshot = JSON.parse(bootstrap('5-0', epoch).responseText); + snapshot.records = [envelope(fixtures[0], 2, epoch)]; + snapshot.last_sequence = 2; + const h = harness(t, [bootstrap(), batch([envelope()]), ({ controller }) => { + controller.clear(); + return response({ schema_version: 1, status: 'error', error_code: 'redis_unavailable' }, 503); + }, response(snapshot), batch([state(1, 'c'.repeat(32)), envelope(fixtures[0], 2, 'c'.repeat(32)), envelope(fixtures[1], 3, 'c'.repeat(32))], '5-0', '6-0')]); + h.run(); + await h.parked; + assert.deepEqual(h.renders.map((render) => render.id), fixtures.map((candidate) => candidate.candidate_id)); + assert.deepEqual([...h.shapes.keys()], [fixtures[1].candidate_id]); + assert.equal(h.panel.compoundSize, 1); + assert.equal(h.layerClears, 1); +}); + +test('retained history remains clearable and expires after terminal failure', async (t) => { + for (const action of ['clear', 'prune', 'stop']) { + const h = harness(t, [bootstrap(), batch([envelope()]), { status: 200, responseText: 'invalid JSON' }], { maxAgeMs: 1000 }); + await h.run(); + assert.equal(h.panel.compoundSize, 1); + assert.equal(h.shapes.size, 1); + assert.equal(h.statusKind(), 'error'); + if (action === 'prune') h.setClock(8001); + h.controller[action]('route_changed'); + assert.equal(h.panel.compoundSize, 0, action); + assert.equal(h.shapes.size, 0, action); + assert.equal(h.panel.size, 1, action); + assert.match(h.status(), /JSON/); + assert.equal(h.calls.length, 3); + } +}); + +test('age cleanup attempts every expired candidate once when a native removal fails', async (t) => { + const h = harness(t, [bootstrap(), batch([envelope(fixtures[0], 2), envelope(fixtures[1], 3)])], { + maxAgeMs: 1000, removeError: new Error('fixture removal failure'), + }); + const done = h.run(); + await h.parked; + h.setClock(8001); + h.controller.prune(); + await done; + assert.deepEqual(h.removed, fixtures.map((candidate) => candidate.candidate_id)); + assert.equal(h.panel.compoundSize, 0); + h.controller.prune(); + assert.equal(h.removed.length, 2); + assert.equal(h.layerClears, 0); + assert.match(h.status(), /fixture removal failure/); +}); diff --git a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js index 7e86a55..ab18631 100644 --- a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js +++ b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js @@ -291,20 +291,108 @@ function ordinaryOutcomeMessage() { }; } -function compoundMessage() { +function compoundMessage(payload = fixtures[0], sequence = 2, epoch = 'a'.repeat(32)) { return { schema_version: 1, projection_kind: 'compound_candidate', - runtime_epoch: 'a'.repeat(32), - sequence: 2, + runtime_epoch: epoch, + sequence, message_kind: 'candidate', - symbol: fixtures[0].symbol, + symbol: payload.symbol, observed_at_ms: 7000, - payload: fixtures[0], + payload, }; } for (const generated of [false, true]) { + test(`${generated ? 'generated' : 'source'} compound recovery retains exact paired entities across epochs, stale cursors and 503`, async (t) => { + const h = await harness(t, { generated }); + 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.pending('ordinary').length === 1); + await h.reset(); + await h.respond('compound', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [compoundMessage(fixtures[0], 2), compoundMessage(fixtures[1], 3)] }); + await until(() => h.pending('compound').length === 1); + const ids = [...h.shapes.keys()]; + const points = ids.slice(1).map((id) => h.shapes.get(id).getPoints()); + assert.equal(ids.length, 6); + assert.equal(h.rows(), 2); + const epoch = 'b'.repeat(32); + await h.respond('compound', { schema_version: 1, status: 'ok', requested_cursor: '2-0', next_cursor: '3-0', messages: [ + { ...compoundMessage(fixtures[0], 1, epoch), message_kind: 'stream_state', symbol: null, payload: { state: 'ready', reason: 'transport_recovered' } }, + compoundMessage(fixtures[0], 2, epoch), compoundMessage(fixtures[1], 3, epoch), + ] }); + await until(() => h.pending('compound').length === 1); + assert.deepEqual([...h.shapes.keys()], ids); + assert.equal(h.rows(), 2); + await h.respond('compound', { schema_version: 1, status: 'reset', reason: 'stale_cursor', requested_cursor: '3-0', next_cursor: '5-0', messages: [] }, 409); + await until(() => h.pending('compound').length === 1); + assert.equal(new URL(h.pending('compound')[0].options.url).pathname, '/v1/strategy27/compound-candidates/bootstrap'); + assert.deepEqual([...h.shapes.keys()], ids); + await h.respond('compound', { schema_version: 1, status: 'bootstrap', projection_kind: 'compound_candidates', requested_cursor: null, next_cursor: '5-0', runtime_epoch: 'c'.repeat(32), last_sequence: 4, bootstrap_observed_at_ms: 7000, records: [compoundMessage(fixtures[0], 3, 'c'.repeat(32))] }); + await until(() => h.pending('compound').length === 1); + assert.deepEqual([...h.shapes.keys()], ids); + assert.equal(h.rows(), 2, 'the candidate absent from the new snapshot remains visible'); + t.mock.timers.enable({ apis: ['setTimeout'] }); + await h.respond('compound', { schema_version: 1, status: 'error', error_code: 'redis_unavailable' }, 503); + await until(() => h.page.document.querySelector('[data-role="compound-status"]').dataset.state === 'inactive'); + assert.deepEqual([...h.shapes.keys()], ids); + assert.equal(h.pending('compound').length, 0); + t.mock.timers.tick(2000); + await until(() => h.pending('compound').length === 1); + assert.equal(new URL(h.pending('compound')[0].options.url).pathname, '/v1/strategy27/compound-candidates/bootstrap'); + await h.respond('compound', { schema_version: 1, status: 'bootstrap', projection_kind: 'compound_candidates', requested_cursor: null, next_cursor: '8-0', runtime_epoch: 'd'.repeat(32), last_sequence: 1, bootstrap_observed_at_ms: 7000, records: [] }); + await until(() => h.pending('compound').length === 1); + assert.deepEqual([...h.shapes.keys()], ids); + assert.deepEqual(ids.slice(1).map((id) => h.shapes.get(id).getPoints()), points); + assert.equal(h.rows(), 2); + assert.equal(h.pending('ordinary').length, 1); + h.setNow(7207001); + h.tick(); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.equal(h.rows(), 0); + }); + + test(`${generated ? 'generated' : 'source'} compound repair failure freezes surviving pairs until clear or context retirement`, async (t) => { + const h = await harness(t, { generated }); + await h.reset(); + await h.respond('compound', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [compoundMessage(fixtures[0], 2), compoundMessage(fixtures[1], 3)] }); + await until(() => h.pending('compound').length === 1); + const ids = [...h.shapes.keys()]; + assert.equal(ids.length, 5); + h.shapes.delete(ids[1]); + const create = h.chart.createShape; + h.chart.createShape = (point, options) => create({ ...point, time: point.time - 1 }, options); + h.tick(); + await until(() => h.page.document.querySelector('[data-role="compound-status"]').dataset.state === 'error'); + const surviving = [ids[0], ...ids.slice(2)]; + assert.deepEqual([...h.shapes.keys()], surviving); + assert.equal(h.rows(), 2); + assert.equal(h.pending('compound').length, 0); + assert.equal(h.pending('ordinary').length, 1); + assert.match(h.page.document.querySelector('[data-role="compound-status"]').textContent, /历史记录已保留.*time alignment failed/); + h.tick(); + await new Promise(setImmediate); + assert.deepEqual([...h.shapes.keys()], surviving); + h.clear(); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.equal(h.rows(), 0); + assert.match(h.page.document.querySelector('[data-role="compound-status"]').textContent, /time alignment failed/); + h.chart.createShape = create; + h.restart(); + await h.reset(); + await h.candidate(); + assert.equal(h.rows(), 1); + assert.equal(h.shapes.size, 3); + await h.respond('compound', 'invalid JSON'); + await until(() => h.page.document.querySelector('[data-role="compound-status"]').dataset.state === 'error'); + h.setResolution('1'); + h.tick(); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.equal(h.rows(), 0); + assert.equal(h.pending('compound').length, 0); + }); + 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(); diff --git a/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js b/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js index 9448151..1e0f4fd 100644 --- a/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js +++ b/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js @@ -226,6 +226,51 @@ test('a repeated candidate restores externally removed entities', async () => { assert.equal(f.shapes.size, 3); }); +test('suspension preserves completed pairs and cancels a pending candle wait', async () => { + const f = fixture(); + const layer = createTradingViewCompoundLayer({ chart: f.chart }, { maxCandidates: 80, candleWaitMs: 3000 }); + await layer.renderCandidate('verified', annotation(), 11000); + const pending = layer.renderCandidate('waiting', annotation({ markerTime: 11 }), 12000); + assert.equal(f.listeners.size, 1); + layer.suspend(); + assert.equal(await pending, false); + assert.equal(f.listeners.size, 0); + assert.deepEqual([...f.shapes.keys()], ['user-owned', 'owned-1', 'owned-2']); + assert.equal(layer.size, 1); + f.shapes.delete('owned-1'); + await layer.reconcile(); + assert.equal(await layer.renderCandidate('new', annotation(), 11000), false); + assert.equal(f.created.length, 2); + layer.clear(); + assert.deepEqual([...f.shapes.keys()], ['user-owned']); +}); + +for (const phase of ['icon', 'label', 'repair']) { + test(`suspension removes late ${phase} entities without removing verified history`, async () => { + const entered = deferred(); + const release = deferred(); + const blockedCreate = phase === 'label' ? 4 : 3; + const f = fixture({ beforeCreate: async (count) => { + if (count === blockedCreate) { entered.resolve(); await release.promise; } + } }); + const layer = f.layer(); + await layer.renderCandidate('verified', annotation(), 11000); + if (phase === 'repair') f.shapes.delete('owned-1'); + const pending = phase === 'repair' ? layer.reconcile() : layer.renderCandidate('pending', annotation(), 11000); + await entered.promise; + layer.suspend(); + release.resolve(); + await pending; + assert.deepEqual([...f.shapes.keys()], phase === 'repair' ? ['user-owned', 'owned-2'] : ['user-owned', 'owned-1', 'owned-2']); + assert.deepEqual(f.removed, phase === 'label' ? ['owned-3', 'owned-4'] : ['owned-3']); + assert.equal(layer.size, 1); + await layer.reconcile(); + assert.equal(f.created.length, blockedCreate); + layer.clear(); + assert.deepEqual([...f.shapes.keys()], ['user-owned']); + }); +} + test('compound timer and replay share a single repair and discard late parts after invalidation', async () => { for (const action of ['retain', 'clear', 'remove', 'interval', 'symbol']) { const entered = deferred(); 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 8230e69..ae403c9 100644 --- a/test/unit/binance-strategy27-events/compound-candidate-lifecycle.test.js +++ b/test/unit/binance-strategy27-events/compound-candidate-lifecycle.test.js @@ -45,7 +45,7 @@ test('independent high/low records coexist at one second and exact replay is imm assert.equal(state.lastSequence, 8); }); -test('heartbeat retains history, epoch reset clears it, and sequence jumps are valid', async () => { +test('heartbeat and epoch reset retain immutable candidates while sequence validation stays strict', async () => { const state = lifecycle(); assert.equal((await state.apply(control(1, 'stream_state'), 7000)).type, 'stream_reset'); await state.apply(envelope(), 7000); @@ -55,9 +55,54 @@ test('heartbeat retains history, epoch reset clears it, and sequence jumps are v await assert.rejects(state.apply(control(8, 'stream_state'), 7000), /Unexpected stream_state/); await assert.rejects(state.apply(envelope(fixtures[0], 1, 'b'.repeat(32)), 7000), /epoch changed without stream_state/); const reset = await state.apply(control(1, 'stream_state', 'b'.repeat(32)), 7000); + assert.deepEqual(reset.removedCandidateIds, []); + assert.equal(state.size, 1); + assert.equal(state.runtimeEpoch, 'b'.repeat(32)); + assert.equal(state.lastSequence, 1); + assert.equal((await state.apply(envelope(fixtures[0], 2, 'b'.repeat(32)), 7000)).type, 'replay'); + assert.equal(state.size, 1); +}); + +test('protocol resets and bootstrap preserve retained candidates and the eviction cutoff', async () => { + for (const reason of ['initial_cursor', 'stale_cursor', 'unavailable']) { + const state = lifecycle(2); + const candidates = await Promise.all([shiftedCandidate(0), shiftedCandidate(1), shiftedCandidate(2)]); + for (const [index, candidate] of candidates.entries()) await state.apply(envelope(candidate, index + 2), 9000); + state.resetProtocol(reason); + assert.equal(state.runtimeEpoch, null); + assert.equal(state.lastSequence, null); + assert.equal(state.size, 2); + const epoch = 'b'.repeat(32); + state.beginBootstrap(epoch); + assert.equal((await state.apply(envelope(candidates[0], 2, epoch), 9000)).type, 'expired'); + assert.equal((await state.apply(envelope(candidates[1], 3, epoch), 9000)).type, 'replay'); + state.finishBootstrap(5); + assert.equal(state.lastSequence, 5); + assert.equal(state.size, 2); + await state.apply(control(1, 'stream_state', 'c'.repeat(32)), 9000); + assert.equal((await state.apply(envelope(candidates[0], 2, 'c'.repeat(32)), 9000)).type, 'expired'); + assert.deepEqual(state.prune(7209001), candidates.slice(1).map((candidate) => candidate.candidate_id)); + } +}); + +test('stream reset prunes expired candidates without extending their decision-time retention', async () => { + const state = lifecycle(80, 1000); + await state.apply(envelope(), 7000); + const reset = await state.apply(control(1, 'stream_state', 'b'.repeat(32)), 8001); assert.deepEqual(reset.removedCandidateIds, [fixtures[0].candidate_id]); assert.equal(state.size, 0); - assert.equal(state.runtimeEpoch, 'b'.repeat(32)); + assert.equal((await state.apply(envelope(fixtures[0], 2, 'b'.repeat(32)), 8001)).type, 'expired'); +}); + +test('context retirement clears protocol and candidate retention', async () => { + for (const reason of ['route_changed', 'interval_changed', 'stopped']) { + const state = lifecycle(); + await state.apply(envelope(), 7000); + state.reset(reason); + assert.equal(state.runtimeEpoch, null); + assert.equal(state.lastSequence, null); + assert.equal(state.size, 0); + } }); test('age is based on the original decision, not replay or heartbeat delivery', async () => { @@ -113,6 +158,17 @@ test('a reset during async validation invalidates the in-flight application', as assert.equal(action.candidate.seed.buy_notional, fixtures[0].seed.buy_notional); }); +test('a protocol reset cancels async validation without deleting previously accepted candidates', async () => { + const state = lifecycle(); + await state.apply(envelope(fixtures[1]), 7000); + const pending = state.apply(envelope(fixtures[0], 3), 7000); + state.resetProtocol('unavailable'); + assert.equal((await pending).type, 'cancelled'); + assert.equal(state.size, 1); + state.beginBootstrap('b'.repeat(32)); + assert.equal((await state.apply(envelope(fixtures[1], 2, 'b'.repeat(32)), 7000)).type, 'replay'); +}); + test('wrong-symbol data cannot mutate stream state', async () => { const state = new CompoundCandidateLifecycle('ETH/USDT:USDT', { maxCandidates: 2, maxAgeMs: 1000 }); await assert.rejects(state.apply(envelope(), 7000), /symbol does not match/); diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index 8d2d4ba..052e4a9 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -61,7 +61,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.6.1'); + assert.equal(contract.version, '0.6.2'); assert.equal(contract.runAt, 'document-idle'); assert.equal(contract.updateURL, contract.downloadURL); assert.deepEqual(contract.matches, [