From b3ea29503a4b8966b273229b8d33a2649ab7cd66 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:45:55 +0800 Subject: [PATCH 1/2] fix(strategy29): preserve connection state and show newest signals first --- ...inance-strategy29-bollinger-development.md | 14 +++++++++---- scripts/binance-strategy29-bollinger.user.js | 15 ++++++++----- .../dom/strategy29-summary-panel.js | 10 +++++++-- .../index.user.js | 2 +- .../remote-summary.js | 6 ++++-- .../strategy29-summary-panel.test.js | 10 ++++----- .../remote-summary-controller.test.js | 21 +++++++++++++++++++ .../source-regressions.test.js | 2 +- test/unit/userscript-release-contract.test.js | 2 +- 9 files changed, 61 insertions(+), 21 deletions(-) diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index 3c0355b..59d4ea0 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -10,7 +10,7 @@ already-loaded native chart candles. The summary reads the authenticated unified loopback gateway; it does not call Binance market-data or account APIs, submit orders, rotate hidden charts, or add remote events as chart drawings. -Install Strategy29 0.5.0 with orderbook 2.7.199 or later, or use it alone. +Install Strategy29 0.5.1 with orderbook 2.7.199 or later, or use it alone. Install CorsairQuant signal client 0.6.0 for the remote summary. Do not combine it with the embedded observer in orderbook 2.7.198. After updating/disabling the old script, reload the page. An embedded observer @@ -157,12 +157,15 @@ these separately. Installing the unified client does not activate server monitor The browser polls status first and then consumes at most two event pages per scheduled poll. A new route requests `mode=latest&limit=20` for its canonical -symbol: the server returns the latest retained sequences and a global increment +symbol: the server selects the most recent signal close times and a global increment cursor from one SQLite snapshot. It does not scan retained global history to fill the panel. Subsequent increments can contain no matching events while still advancing that cursor. `cursor_expired` clears only remote rows and requests a new -latest snapshot. Rows are displayed and bounded by descending durable sequence, -independent of detection timestamps. This requires the server's explicit latest +latest snapshot. Rows are displayed and bounded by descending signal close time, +with descending durable sequence breaking ties. The snapshot response remains +sequence-ascending so the global increment contract is unchanged. Historical +backfills cannot evict newer signal times merely by being inserted later. +This requires the server's explicit latest query contract; a server rejecting it stops the remote context visibly. Publish the unified V2 gateway contract before the client, then verify installed source identity and reload before remote acceptance. Publication of either @@ -176,6 +179,9 @@ its panel; visibility or pageshow cannot revive it. Responses check their origin completion handlers also check request ownership, so an old request cannot publish or clear the in-flight flag of resumed work. No extra recurring timer is installed: the existing one-second runtime sample applies a five-second remote gate. +Only the first request displays `Connecting`. Subsequent background polls retain +the last completed connection state until the new result arrives; genuine +unavailability and transport errors remain visible during the next request. Transport failures and a temporarily unavailable database remain retryable remote states. Authentication, request, JSON, and response-contract failures diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index f216280..5eb56d5 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy29.bollinger // @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.5.0 +// @version 0.5.1 // @author jackhai9 // @description Native Bollinger/SMA60 markers and the default read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* @@ -2112,6 +2112,9 @@ // src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js var PANEL_ID = "jh-strategy29-summary-panel"; + function newestSignalFirst(left, right) { + return right.bar_close_ms - left.bar_close_ms || right.sequence - left.sequence; + } var STATE_COLORS = Object.freeze({ disabled: "#848E9C", module_disabled: "#848E9C", @@ -2248,7 +2251,7 @@ } function renderEvents() { events.replaceChildren(); - const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence); + const ordered = [...eventRecords.values()].sort(newestSignalFirst); for (const event of ordered) { const row = element(document, "div", { role: "remote-event", @@ -2350,7 +2353,7 @@ addEvents(incoming, observedAtMs = null) { assertLive(); for (const event of incoming) eventRecords.set(event.event_id, event); - const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence); + const ordered = [...eventRecords.values()].sort(newestSignalFirst); while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id); if (observedAtMs !== null) { lastEventsAt = observedAtMs; @@ -2527,8 +2530,10 @@ const ownsRequest = () => isCurrent(context) && context.abortController === controller && getGatewayState().settingsRevision === context.gatewayState.settingsRevision; context.nextPollAtMs = nowMs + pollIntervalMs; context.inFlight = true; - context.state = "connecting"; - context.panel.setConnection("connecting", SUMMARY_COPY.connecting); + if (context.state === "idle") { + context.state = "connecting"; + context.panel.setConnection("connecting", SUMMARY_COPY.connecting); + } return context.client.poll(controller.signal).then((result) => { if (!ownsRequest()) return; context.lastResult = result; diff --git a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js index 5c36965..94b9bf9 100644 --- a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js +++ b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js @@ -4,6 +4,12 @@ import { SUMMARY_COPY as COPY, SELECTION_REASONS, STATUS_LABELS, SIGNAL_LABELS, import { installPanelPosition } from './panel-position.js'; const PANEL_ID = 'jh-strategy29-summary-panel'; + +/** Backfilled intervals can be inserted later than more recent live signals. */ +function newestSignalFirst(left, right) { + return right.bar_close_ms - left.bar_close_ms || right.sequence - left.sequence; +} + const STATE_COLORS = Object.freeze({ disabled: '#848E9C', module_disabled: '#848E9C', @@ -129,7 +135,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve } function renderEvents() { events.replaceChildren(); - const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence); + const ordered = [...eventRecords.values()].sort(newestSignalFirst); for (const event of ordered) { const row = element(document, 'div', { role: 'remote-event', @@ -240,7 +246,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve addEvents(incoming, observedAtMs = null) { assertLive(); for (const event of incoming) eventRecords.set(event.event_id, event); - const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence); + const ordered = [...eventRecords.values()].sort(newestSignalFirst); while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id); if (observedAtMs !== null) { lastEventsAt = observedAtMs; diff --git a/src/binance-strategy29-bollinger/index.user.js b/src/binance-strategy29-bollinger/index.user.js index 371d196..510cb60 100644 --- a/src/binance-strategy29-bollinger/index.user.js +++ b/src/binance-strategy29-bollinger/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy29.bollinger // @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.5.0 +// @version 0.5.1 // @author jackhai9 // @description Native Bollinger/SMA60 markers and the default read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* diff --git a/src/binance-strategy29-bollinger/remote-summary.js b/src/binance-strategy29-bollinger/remote-summary.js index d78dc04..08e68c3 100644 --- a/src/binance-strategy29-bollinger/remote-summary.js +++ b/src/binance-strategy29-bollinger/remote-summary.js @@ -164,8 +164,10 @@ export function createStrategy29RemoteSummary({ && getGatewayState().settingsRevision === context.gatewayState.settingsRevision; context.nextPollAtMs = nowMs + pollIntervalMs; context.inFlight = true; - context.state = 'connecting'; - context.panel.setConnection('connecting', COPY.connecting); + if (context.state === 'idle') { + context.state = 'connecting'; + context.panel.setConnection('connecting', COPY.connecting); + } return context.client.poll(controller.signal) .then(result => { if (!ownsRequest()) return; diff --git a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js index f19cd5c..5327554 100644 --- a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js +++ b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js @@ -150,15 +150,15 @@ test('makes server/local spec mismatch visible without rendering it as verified' dom.window.close(); }); -test('retains the latest durable sequences even when detection times arrive out of order', () => { +test('retains newest signal close times regardless of insertion or detection order', () => { const dom = new JSDOM(''); const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 2 }); - const first = { ...events.events[0], event_id: 'a'.repeat(64), sequence: 10, detected_at_ms: 3000 }; - const second = { ...events.events[0], event_id: 'b'.repeat(64), sequence: 11, detected_at_ms: 2000 }; - const third = { ...events.events[0], event_id: 'c'.repeat(64), sequence: 12, detected_at_ms: 1000 }; + const first = { ...events.events[0], event_id: 'a'.repeat(64), sequence: 10, detected_at_ms: 3000, bar_close_ms: 3000 }; + const second = { ...events.events[0], event_id: 'b'.repeat(64), sequence: 11, detected_at_ms: 2000, bar_close_ms: 3000 }; + const third = { ...events.events[0], event_id: 'c'.repeat(64), sequence: 12, detected_at_ms: 4000, bar_close_ms: 1000 }; panel.addEvents([first, second]); panel.addEvents([third]); - assert.deepEqual([...dom.window.document.querySelectorAll('[data-role=remote-event]')].map(row => row.dataset.eventId), [third.event_id, second.event_id]); + assert.deepEqual([...dom.window.document.querySelectorAll('[data-role=remote-event]')].map(row => row.dataset.eventId), [second.event_id, first.event_id]); panel.destroy(); dom.window.close(); }); diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js index 6551dd6..89164a5 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js @@ -122,6 +122,27 @@ test('polls the current route symbol independently of the visible chart interval assert.equal(f.clients.length, 1); }); +for (const previousState of ['connected', 'gateway_unavailable', 'unavailable']) { + test(`background polls preserve ${previousState} until the next response`, async () => { + let complete; + let polls = 0; + const f = fixture({ poll: () => ++polls === 1 + ? Promise.resolve({ state: previousState, pages: 1, hasMore: false }) + : new Promise(resolve => { complete = resolve; }) }); + await f.summary.sample(0); + const before = f.panels[0].calls.length; + const pending = f.summary.sample(5_000); + assert.equal(f.summary.diagnostics.inFlight, true); + assert.equal(f.summary.diagnostics.state, previousState); + assert.equal(f.panels[0].calls.length, before); + complete({ state: 'gateway_unavailable', pages: 0, hasMore: false }); + await pending; + assert.equal(f.summary.diagnostics.state, 'gateway_unavailable'); + assert.equal(f.panels[0].calls.at(-1)[1], 'gateway_unavailable'); + f.summary.dispose(); + }); +} + test('route retirement aborts ownership and ignores a late old-symbol response', async () => { let resolve; let polls = 0; diff --git a/test/unit/binance-strategy29-bollinger/source-regressions.test.js b/test/unit/binance-strategy29-bollinger/source-regressions.test.js index c9209da..860b139 100644 --- a/test/unit/binance-strategy29-bollinger/source-regressions.test.js +++ b/test/unit/binance-strategy29-bollinger/source-regressions.test.js @@ -52,7 +52,7 @@ test('Bollinger chart alert failures distinguish snapshot races from contract fa }); test('Strategy29 sandbox metadata is exact and coordinates through unsafeWindow', () => { - assert.equal(readUserscriptVersion(entrySource), '0.5.0'); + assert.equal(readUserscriptVersion(entrySource), '0.5.1'); assert.deepEqual( [...entrySource.matchAll(/^\/\/ @grant\s+(\S+)\s*$/gm)].map(match => match[1]), ['unsafeWindow', 'GM_getValue', 'GM_setValue'], diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index cd4c7b4..358e545 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -21,7 +21,7 @@ test('Strategy29 has an independent observation-only install identity', async () const metadata = parseUserscriptMetadata(text); assert.equal(contract.name, '【自写】Binance Strategy 29 布林带信号'); assert.equal(contract.namespace, 'binance.strategy29.bollinger'); - assert.equal(contract.version, '0.5.0'); + assert.equal(contract.version, '0.5.1'); assert.equal(contract.runAt, 'document-start'); assert.equal(contract.updateURL, 'https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js'); assert.equal(contract.downloadURL, contract.updateURL); From f89387e3f3349103bb5e5aea1db86c3ee6e15ed0 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:34:38 +0800 Subject: [PATCH 2/2] fix(strategy29): separate chronological API and durable event identities --- ...inance-strategy29-bollinger-development.md | 7 +++--- scripts/binance-strategy29-bollinger.user.js | 25 ++++++++++--------- .../core/remote-summary-client.js | 4 +-- .../core/remote-summary-contract.js | 13 +++++----- .../dom/strategy29-summary-panel.js | 12 ++++----- .../remote-summary.js | 4 +-- test/fixtures/strategy29-gateway-events.json | 2 +- test/fixtures/strategy29-gateway-status.json | 2 +- .../remote-summary-contract.test.js | 19 ++++++++++++-- 9 files changed, 53 insertions(+), 35 deletions(-) diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index 59d4ea0..aa2ec1b 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -99,7 +99,8 @@ Server status freshness and signal times remain separate from local chart state. Panel timestamps explicitly use `UTC+08` rather than inheriting the browser's ambient timezone. -Observer compatibility is `29_2_spec_v2`; the chart detector retains the frozen +Observer API compatibility is `29_2_spec_v3`; durable event records retain +`29_2_spec_v2` and are validated independently; the chart detector retains the frozen V1 reference and unchanged hash. The server independently ranks an activity-score universe and applies its configured intervals. Each status poll replaces current membership: removed symbols show "Symbol is not watched by the current selection" while @@ -167,7 +168,7 @@ sequence-ascending so the global increment contract is unchanged. Historical backfills cannot evict newer signal times merely by being inserted later. This requires the server's explicit latest query contract; a server rejecting it stops the remote context visibly. -Publish the unified V2 gateway contract before the client, then verify installed +Publish the V3 observer API contract before the client, then verify installed source identity and reload before remote acceptance. Publication of either component does not enable the observer, gateway, or notifications. @@ -191,7 +192,7 @@ The status validator first checks the shared schema/spec/time identity envelope. A different spec exposes only those three fields; no incompatible unit, selection or delivery payload is interpreted. The panel clears current health rows, displays the mismatch and preserves retained events; event consumption is blocked. Matching -V2 responses still require every exact field and a coherent refresh state/reason +V3 API responses still require every exact field and a coherent refresh state/reason combination. An unknown schema envelope remains a contract error. The local reference hash is displayed and exposed for audit, but the current server status schema does not carry a hash, so the UI does not claim hash-level remote parity. diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index 5eb56d5..b7c4f1b 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -1402,7 +1402,8 @@ // src/binance-strategy29-bollinger/core/remote-summary-contract.js var STRATEGY29_SCHEMA_VERSION = 1; - var STRATEGY29_SPEC_VERSION = "29_2_spec_v2"; + var STRATEGY29_API_SPEC_VERSION = "29_2_spec_v3"; + var STRATEGY29_EVENT_SPEC_VERSION = "29_2_spec_v2"; var STRATEGY29_REFERENCE_SHA256 = "eece8cf16e58340910587962f3bfbb19acb72155c09a52b4b6c0570cc979ef8d"; var TIMEFRAMES = /* @__PURE__ */ new Set(["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "1w"]); var UNIT_STATUSES = /* @__PURE__ */ new Set(["warming", "ready", "stale", "insufficient_history", "data_gap", "failed"]); @@ -1592,7 +1593,7 @@ assertSchema(value.schema_version, "status.schema_version"); assertString(value.spec_version, "status.spec_version"); assertInteger(value.observed_at_ms, "status.observed_at_ms"); - if (value.spec_version !== STRATEGY29_SPEC_VERSION) return { + if (value.spec_version !== STRATEGY29_API_SPEC_VERSION) return { schema_version: value.schema_version, spec_version: value.spec_version, observed_at_ms: value.observed_at_ms @@ -1621,8 +1622,8 @@ } assertSchema(value.schema_version, `${name}.schema_version`); if (value.strategy_id !== "29") throw new TypeError(`${name}.strategy_id must equal 29`); - if (value.spec_version !== STRATEGY29_SPEC_VERSION) { - throw new TypeError(`${name}.spec_version must equal ${STRATEGY29_SPEC_VERSION}`); + if (value.spec_version !== STRATEGY29_EVENT_SPEC_VERSION) { + throw new TypeError(`${name}.spec_version must equal ${STRATEGY29_EVENT_SPEC_VERSION}`); } assertCanonicalSymbol(value.symbol, `${name}.symbol`); assertEnum(value.timeframe, TIMEFRAMES, `${name}.timeframe`); @@ -1648,8 +1649,8 @@ if (httpStatus !== 200) throw new TypeError(`events response requires HTTP 200, received ${httpStatus}`); assertExactKeys(value, EVENTS_KEYS, "events response"); assertSchema(value.schema_version, "events.schema_version"); - if (value.spec_version !== STRATEGY29_SPEC_VERSION) { - throw new TypeError(`events.spec_version must equal ${STRATEGY29_SPEC_VERSION}`); + if (value.spec_version !== STRATEGY29_API_SPEC_VERSION) { + throw new TypeError(`events.spec_version must equal ${STRATEGY29_API_SPEC_VERSION}`); } assertInteger(value.observed_at_ms, "events.observed_at_ms"); assertInteger(value.next_cursor, "events.next_cursor"); @@ -1754,7 +1755,7 @@ } const status = validateStrategy29StatusResponse(statusBody, 200); onStatus(status); - if (status.spec_version !== STRATEGY29_SPEC_VERSION) { + if (status.spec_version !== STRATEGY29_API_SPEC_VERSION) { return { state: "incompatible", pages: 0, hasMore: false }; } let pages = 0; @@ -2207,7 +2208,7 @@ const overview = element(document, "div", { styles: { display: "grid", gap: "4px", padding: "9px 10px" } }); overview.appendChild(element(document, "div", { text: canonicalSymbol, role: "symbol", styles: { fontWeight: "700" } })); const connection = element(document, "div", { text: text(SUMMARY_COPY.waiting), role: "connection", styles: { color: "#848E9C", fontSize: "11px" } }); - const spec = element(document, "div", { text: text(SUMMARY_COPY.observerSpec(STRATEGY29_SPEC_VERSION)), role: "spec", styles: { color: "#848E9C", fontSize: "11px" } }); + const spec = element(document, "div", { text: text(SUMMARY_COPY.observerSpec(STRATEGY29_API_SPEC_VERSION)), role: "spec", styles: { color: "#848E9C", fontSize: "11px" } }); const reference = element(document, "div", { text: text(SUMMARY_COPY.reference(STRATEGY29_REFERENCE_SHA256)), role: "reference", styles: { color: "#848E9C", fontSize: "10px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", userSelect: "text" } }); const statusFreshness = element(document, "div", { text: text(SUMMARY_COPY.noStatus), role: "status-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); const eventsFreshness = element(document, "div", { text: text(SUMMARY_COPY.noEventsCheck), role: "events-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); @@ -2234,7 +2235,7 @@ lastStatus = null; spec.dataset.state = "unavailable"; spec.style.color = "#848E9C"; - spec.textContent = text(SUMMARY_COPY.observerSpec(STRATEGY29_SPEC_VERSION)); + spec.textContent = text(SUMMARY_COPY.observerSpec(STRATEGY29_API_SPEC_VERSION)); statusFreshness.textContent = text(SUMMARY_COPY.noStatus); selection.dataset.state = "unavailable"; selection.style.color = "#848E9C"; @@ -2308,10 +2309,10 @@ renderStatus(snapshot) { assertLive(); lastStatus = snapshot; - const matched = snapshot.spec_version === STRATEGY29_SPEC_VERSION; + const matched = snapshot.spec_version === STRATEGY29_API_SPEC_VERSION; spec.dataset.state = matched ? "matched" : "error"; spec.style.color = matched ? "#0ECB81" : "#F6465D"; - spec.textContent = matched ? text(SUMMARY_COPY.matched(STRATEGY29_SPEC_VERSION)) : text(SUMMARY_COPY.mismatch(STRATEGY29_SPEC_VERSION, snapshot.spec_version)); + spec.textContent = matched ? text(SUMMARY_COPY.matched(STRATEGY29_API_SPEC_VERSION)) : text(SUMMARY_COPY.mismatch(STRATEGY29_API_SPEC_VERSION, snapshot.spec_version)); statusFreshness.textContent = text(SUMMARY_COPY.statusAt(formatClock(snapshot.observed_at_ms))); if (!matched) { selection.dataset.state = "incompatible"; @@ -2632,7 +2633,7 @@ lastError: moduleFailure ?? active?.lastError ?? null, lastResult: active?.lastResult ?? null, cursor: active?.client?.diagnostics.cursor ?? null, - specVersion: STRATEGY29_SPEC_VERSION, + specVersion: STRATEGY29_API_SPEC_VERSION, referenceSha256: STRATEGY29_REFERENCE_SHA256 }); } diff --git a/src/binance-strategy29-bollinger/core/remote-summary-client.js b/src/binance-strategy29-bollinger/core/remote-summary-client.js index cf9b664..581e5cc 100644 --- a/src/binance-strategy29-bollinger/core/remote-summary-client.js +++ b/src/binance-strategy29-bollinger/core/remote-summary-client.js @@ -1,6 +1,6 @@ import { isCanonicalUsdtSymbol } from '../../shared/canonical-symbol.js'; import { - STRATEGY29_SPEC_VERSION, + STRATEGY29_API_SPEC_VERSION, validateStrategy29EventsResponse, validateStrategy29GatewayError, validateStrategy29StatusResponse, @@ -82,7 +82,7 @@ export function createStrategy29SummaryClient({ } const status = validateStrategy29StatusResponse(statusBody, 200); onStatus(status); - if (status.spec_version !== STRATEGY29_SPEC_VERSION) { + if (status.spec_version !== STRATEGY29_API_SPEC_VERSION) { return { state: 'incompatible', pages: 0, hasMore: false }; } diff --git a/src/binance-strategy29-bollinger/core/remote-summary-contract.js b/src/binance-strategy29-bollinger/core/remote-summary-contract.js index 3b71fa5..8d4474d 100644 --- a/src/binance-strategy29-bollinger/core/remote-summary-contract.js +++ b/src/binance-strategy29-bollinger/core/remote-summary-contract.js @@ -1,7 +1,8 @@ import { isCanonicalUsdtSymbol, usdtRouteToCanonical, canonicalUsdtToRoute } from '../../shared/canonical-symbol.js'; export const STRATEGY29_SCHEMA_VERSION = 1; -export const STRATEGY29_SPEC_VERSION = '29_2_spec_v2'; +export const STRATEGY29_API_SPEC_VERSION = '29_2_spec_v3'; +export const STRATEGY29_EVENT_SPEC_VERSION = '29_2_spec_v2'; export const STRATEGY29_REFERENCE_SHA256 = 'eece8cf16e58340910587962f3bfbb19acb72155c09a52b4b6c0570cc979ef8d'; const TIMEFRAMES = new Set(['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', '1d', '1w']); @@ -168,7 +169,7 @@ export function validateStrategy29StatusResponse(value, httpStatus) { assertString(value.spec_version, 'status.spec_version'); assertInteger(value.observed_at_ms, 'status.observed_at_ms'); // An incompatible spec exposes identity only; its payload is not our contract. - if (value.spec_version !== STRATEGY29_SPEC_VERSION) return { + if (value.spec_version !== STRATEGY29_API_SPEC_VERSION) return { schema_version: value.schema_version, spec_version: value.spec_version, observed_at_ms: value.observed_at_ms, }; assertExactKeys(value, STATUS_KEYS, 'status response'); @@ -197,8 +198,8 @@ function validateEvent(value, index) { } assertSchema(value.schema_version, `${name}.schema_version`); if (value.strategy_id !== '29') throw new TypeError(`${name}.strategy_id must equal 29`); - if (value.spec_version !== STRATEGY29_SPEC_VERSION) { - throw new TypeError(`${name}.spec_version must equal ${STRATEGY29_SPEC_VERSION}`); + if (value.spec_version !== STRATEGY29_EVENT_SPEC_VERSION) { + throw new TypeError(`${name}.spec_version must equal ${STRATEGY29_EVENT_SPEC_VERSION}`); } assertCanonicalSymbol(value.symbol, `${name}.symbol`); assertEnum(value.timeframe, TIMEFRAMES, `${name}.timeframe`); @@ -225,8 +226,8 @@ export function validateStrategy29EventsResponse(value, httpStatus) { if (httpStatus !== 200) throw new TypeError(`events response requires HTTP 200, received ${httpStatus}`); assertExactKeys(value, EVENTS_KEYS, 'events response'); assertSchema(value.schema_version, 'events.schema_version'); - if (value.spec_version !== STRATEGY29_SPEC_VERSION) { - throw new TypeError(`events.spec_version must equal ${STRATEGY29_SPEC_VERSION}`); + if (value.spec_version !== STRATEGY29_API_SPEC_VERSION) { + throw new TypeError(`events.spec_version must equal ${STRATEGY29_API_SPEC_VERSION}`); } assertInteger(value.observed_at_ms, 'events.observed_at_ms'); assertInteger(value.next_cursor, 'events.next_cursor'); diff --git a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js index 94b9bf9..5510fa5 100644 --- a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js +++ b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js @@ -1,4 +1,4 @@ -import { STRATEGY29_REFERENCE_SHA256, STRATEGY29_SPEC_VERSION } from '../core/remote-summary-contract.js'; +import { STRATEGY29_REFERENCE_SHA256, STRATEGY29_API_SPEC_VERSION } from '../core/remote-summary-contract.js'; import { SUMMARY_COPY as COPY, SELECTION_REASONS, STATUS_LABELS, SIGNAL_LABELS, processingReason, formatLocalizedText, resolveUiLocaleFromPathname } from '../ui-copy.js'; import { installPanelPosition } from './panel-position.js'; @@ -90,7 +90,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve const overview = element(document, 'div', { styles: { display: 'grid', gap: '4px', padding: '9px 10px' } }); overview.appendChild(element(document, 'div', { text: canonicalSymbol, role: 'symbol', styles: { fontWeight: '700' } })); const connection = element(document, 'div', { text: text(COPY.waiting), role: 'connection', styles: { color: '#848E9C', fontSize: '11px' } }); - const spec = element(document, 'div', { text: text(COPY.observerSpec(STRATEGY29_SPEC_VERSION)), role: 'spec', styles: { color: '#848E9C', fontSize: '11px' } }); + const spec = element(document, 'div', { text: text(COPY.observerSpec(STRATEGY29_API_SPEC_VERSION)), role: 'spec', styles: { color: '#848E9C', fontSize: '11px' } }); const reference = element(document, 'div', { text: text(COPY.reference(STRATEGY29_REFERENCE_SHA256)), role: 'reference', styles: { color: '#848E9C', fontSize: '10px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', userSelect: 'text' } }); const statusFreshness = element(document, 'div', { text: text(COPY.noStatus), role: 'status-freshness', styles: { color: '#848E9C', fontSize: '11px' } }); const eventsFreshness = element(document, 'div', { text: text(COPY.noEventsCheck), role: 'events-freshness', styles: { color: '#848E9C', fontSize: '11px' } }); @@ -118,7 +118,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve lastStatus = null; spec.dataset.state = 'unavailable'; spec.style.color = '#848E9C'; - spec.textContent = text(COPY.observerSpec(STRATEGY29_SPEC_VERSION)); + spec.textContent = text(COPY.observerSpec(STRATEGY29_API_SPEC_VERSION)); statusFreshness.textContent = text(COPY.noStatus); selection.dataset.state = 'unavailable'; selection.style.color = '#848E9C'; @@ -194,12 +194,12 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve renderStatus(snapshot) { assertLive(); lastStatus = snapshot; - const matched = snapshot.spec_version === STRATEGY29_SPEC_VERSION; + const matched = snapshot.spec_version === STRATEGY29_API_SPEC_VERSION; spec.dataset.state = matched ? 'matched' : 'error'; spec.style.color = matched ? '#0ECB81' : '#F6465D'; spec.textContent = matched - ? text(COPY.matched(STRATEGY29_SPEC_VERSION)) - : text(COPY.mismatch(STRATEGY29_SPEC_VERSION, snapshot.spec_version)); + ? text(COPY.matched(STRATEGY29_API_SPEC_VERSION)) + : text(COPY.mismatch(STRATEGY29_API_SPEC_VERSION, snapshot.spec_version)); statusFreshness.textContent = text(COPY.statusAt(formatClock(snapshot.observed_at_ms))); if (!matched) { selection.dataset.state = 'incompatible'; diff --git a/src/binance-strategy29-bollinger/remote-summary.js b/src/binance-strategy29-bollinger/remote-summary.js index 08e68c3..7a6afee 100644 --- a/src/binance-strategy29-bollinger/remote-summary.js +++ b/src/binance-strategy29-bollinger/remote-summary.js @@ -4,7 +4,7 @@ import { } from './core/remote-summary-client.js'; import { STRATEGY29_REFERENCE_SHA256, - STRATEGY29_SPEC_VERSION, + STRATEGY29_API_SPEC_VERSION, routeSymbolToCanonical, } from './core/remote-summary-contract.js'; import { createStrategy29SummaryPanel } from './dom/strategy29-summary-panel.js'; @@ -271,7 +271,7 @@ export function createStrategy29RemoteSummary({ lastError: moduleFailure ?? active?.lastError ?? null, lastResult: active?.lastResult ?? null, cursor: active?.client?.diagnostics.cursor ?? null, - specVersion: STRATEGY29_SPEC_VERSION, + specVersion: STRATEGY29_API_SPEC_VERSION, referenceSha256: STRATEGY29_REFERENCE_SHA256, }); }, diff --git a/test/fixtures/strategy29-gateway-events.json b/test/fixtures/strategy29-gateway-events.json index bd06834..5179641 100644 --- a/test/fixtures/strategy29-gateway-events.json +++ b/test/fixtures/strategy29-gateway-events.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "spec_version": "29_2_spec_v2", + "spec_version": "29_2_spec_v3", "observed_at_ms": 1788580805000, "next_cursor": 42, "has_more": false, diff --git a/test/fixtures/strategy29-gateway-status.json b/test/fixtures/strategy29-gateway-status.json index d90fe16..805bd03 100644 --- a/test/fixtures/strategy29-gateway-status.json +++ b/test/fixtures/strategy29-gateway-status.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "spec_version": "29_2_spec_v2", + "spec_version": "29_2_spec_v3", "observed_at_ms": 1788580805000, "universe": { "source_monitor": "monitor29_bollinger_ma60", diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js index 9a49156..1f3af61 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { - STRATEGY29_SPEC_VERSION, + STRATEGY29_API_SPEC_VERSION, canonicalSymbolToRoute, routeSymbolToCanonical, validateStrategy29EventsResponse, @@ -147,7 +147,7 @@ test('projects only validated identity when status belongs to an incompatible sp }); test('validates exact status fields while preserving visible spec mismatch', () => { - assert.equal(STRATEGY29_SPEC_VERSION, '29_2_spec_v2'); + assert.equal(STRATEGY29_API_SPEC_VERSION, '29_2_spec_v3'); assert.equal(validateStrategy29StatusResponse(status, 200), status); const mismatch = structuredClone(status); mismatch.spec_version = 'other_spec'; @@ -217,3 +217,18 @@ test('validates Strategy29 error bodies using error rather than error_code', () /exact keys/, ); }); + + +test('v3 API envelopes retain v2 event identities and reject mixed layers', () => { + assert.equal(validateStrategy29EventsResponse(events, 200), events); + assert.equal(events.spec_version, '29_2_spec_v3'); + assert.deepEqual([...new Set(events.events.map(event => event.spec_version))], ['29_2_spec_v2']); + const wrongRow = structuredClone(events); + wrongRow.events[0].spec_version = '29_2_spec_v3'; + assert.throws(() => validateStrategy29EventsResponse(wrongRow, 200), /spec_version/); + assert.throws(() => validateStrategy29EventsResponse({ ...events, spec_version: '29_2_spec_v2' }, 200), /spec_version/); + const oldStatus = { ...status, spec_version: '29_2_spec_v2' }; + assert.deepEqual(validateStrategy29StatusResponse(oldStatus, 200), { + schema_version: 1, spec_version: '29_2_spec_v2', observed_at_ms: status.observed_at_ms, + }); +});