From 6354f935a03308efe4a0819d4bf1d4af49a57566 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:15:18 +0800 Subject: [PATCH 1/7] feat(strategy29): support dynamic observer V2 summaries --- ...inance-strategy29-bollinger-development.md | 12 +++++++++-- scripts/binance-strategy29-bollinger.user.js | 6 +++--- .../core/remote-summary-contract.js | 2 +- .../dom/strategy29-summary-panel.js | 2 +- .../index.user.js | 2 +- .../runtime.test.js | 2 +- .../strategy29-summary-panel.test.js | 21 ++++++++++++++++++- test/fixtures/strategy29-gateway-events.json | 6 +++--- test/fixtures/strategy29-gateway-status.json | 2 +- .../remote-summary-client.test.js | 4 ++-- .../remote-summary-contract.test.js | 4 ++-- .../source-regressions.test.js | 2 +- test/unit/userscript-release-contract.test.js | 2 +- 13 files changed, 47 insertions(+), 20 deletions(-) diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index 7c43b5d..e780413 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -9,7 +9,7 @@ already-loaded native chart candles. The optional summary reads the authenticate loopback observer 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.2.2 with orderbook 2.7.199 or later, or use it alone. +Install Strategy29 0.3.0 with orderbook 2.7.199 or later, or use it alone. 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 is an explicit conflict: Strategy29 stops and displays an upgrade/reload notice. @@ -69,6 +69,14 @@ 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 +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 server" while +retained event history remains visible, and re-entering units can show warming +until producer readiness and historical baseline complete. The browser does not +choose markets or infer intervals from other strategies. + 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 @@ -78,7 +86,7 @@ advancing that cursor. `cursor_expired` clears only remote rows and requests a n latest snapshot. Rows are displayed and bounded by descending durable sequence, independent of detection timestamps. This requires the server's explicit latest query contract; a server rejecting it stops the remote context visibly. -Publish the gateway contract before the `0.2.1` client, then verify installed +Publish the V2 gateway contract before the `0.3.0` client, then verify installed source identity and reload before remote acceptance. Publication of either component does not enable the observer, gateway, or notifications. diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index e6619e9..7ae9819 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.2.2 +// @version 0.3.0 // @author jackhai9 // @description Native Bollinger/SMA60 markers with an optional read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* @@ -23,7 +23,7 @@ (() => { // src/binance-strategy29-bollinger/core/remote-summary-contract.js var STRATEGY29_SCHEMA_VERSION = 1; - var STRATEGY29_SPEC_VERSION = "29_2_spec_v1"; + var STRATEGY29_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"]); @@ -1841,7 +1841,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: "Waiting", role: "connection", styles: { color: "#848E9C", fontSize: "11px" } }); - const spec = element(document, "div", { text: `Local spec ${STRATEGY29_SPEC_VERSION}`, role: "spec", styles: { color: "#848E9C", fontSize: "11px" } }); + const spec = element(document, "div", { text: `Observer spec ${STRATEGY29_SPEC_VERSION}`, role: "spec", styles: { color: "#848E9C", fontSize: "11px" } }); const reference = element(document, "div", { text: `Local 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: "Status not received", role: "status-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); const eventsFreshness = element(document, "div", { text: "Events not checked", role: "events-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); diff --git a/src/binance-strategy29-bollinger/core/remote-summary-contract.js b/src/binance-strategy29-bollinger/core/remote-summary-contract.js index e53c301..e07bc2e 100644 --- a/src/binance-strategy29-bollinger/core/remote-summary-contract.js +++ b/src/binance-strategy29-bollinger/core/remote-summary-contract.js @@ -1,5 +1,5 @@ export const STRATEGY29_SCHEMA_VERSION = 1; -export const STRATEGY29_SPEC_VERSION = '29_2_spec_v1'; +export const STRATEGY29_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']); diff --git a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js index 694d27e..6101d63 100644 --- a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js +++ b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js @@ -82,7 +82,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: 'Waiting', role: 'connection', styles: { color: '#848E9C', fontSize: '11px' } }); - const spec = element(document, 'div', { text: `Local spec ${STRATEGY29_SPEC_VERSION}`, role: 'spec', styles: { color: '#848E9C', fontSize: '11px' } }); + const spec = element(document, 'div', { text: `Observer spec ${STRATEGY29_SPEC_VERSION}`, role: 'spec', styles: { color: '#848E9C', fontSize: '11px' } }); const reference = element(document, 'div', { text: `Local 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: 'Status not received', role: 'status-freshness', styles: { color: '#848E9C', fontSize: '11px' } }); const eventsFreshness = element(document, 'div', { text: 'Events not checked', role: 'events-freshness', styles: { color: '#848E9C', fontSize: '11px' } }); diff --git a/src/binance-strategy29-bollinger/index.user.js b/src/binance-strategy29-bollinger/index.user.js index 833df2c..b12ed7c 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.2.2 +// @version 0.3.0 // @author jackhai9 // @description Native Bollinger/SMA60 markers with an optional read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* diff --git a/test/dom/binance-strategy29-bollinger/runtime.test.js b/test/dom/binance-strategy29-bollinger/runtime.test.js index fa2d6e2..be9e370 100644 --- a/test/dom/binance-strategy29-bollinger/runtime.test.js +++ b/test/dom/binance-strategy29-bollinger/runtime.test.js @@ -67,7 +67,7 @@ for (const [name, remoteResponse, expectedState] of [ ['HTTP 401', { status: 401, responseText: JSON.stringify({ schema_version: 1, error: 'unauthorized' }) }, 'stopped'], ['HTTP 503', { status: 503, responseText: JSON.stringify({ schema_version: 1, error: 'database_unavailable' }) }, 'unavailable'], ['invalid JSON', { status: 200, responseText: '' }, 'stopped'], - ['spec mismatch', { status: 200, responseText: JSON.stringify({ ...gatewayStatus, spec_version: '29_2_spec_v2' }) }, 'incompatible'], + ['spec mismatch', { status: 200, responseText: JSON.stringify({ ...gatewayStatus, spec_version: 'other_spec' }) }, 'incompatible'], ]) { test(`${name} remains a remote-only state while the local timer continues`, async () => { const f = fixture(); 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 18cf4d4..a5012a0 100644 --- a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js +++ b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js @@ -8,6 +8,25 @@ import { createStrategy29SummaryPanel } from '../../../src/binance-strategy29-bo const status = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-status.json', import.meta.url))); const events = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-events.json', import.meta.url))); +test('replaces dynamic membership while retaining durable signal history', () => { + const dom = new JSDOM('
'); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); + panel.renderStatus(status); + panel.addEvents(events.events); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 2); + panel.renderStatus({ ...status, units: [] }); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 0); + assert.match(dom.window.document.body.textContent, /Symbol is not watched/); + assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 2); + panel.renderStatus({ ...status, units: [{ ...status.units[0], timeframe: '4h', status: 'warming', reason: 'awaiting_producer_generation' }] }); + const units = dom.window.document.querySelectorAll('[data-role=unit]'); + assert.equal(units.length, 1); + assert.match(units[0].textContent, /4h.*warming.*awaiting_producer_generation/); + assert.doesNotMatch(dom.window.document.body.textContent, /Symbol is not watched/); + panel.destroy(); + dom.window.close(); +}); + test('renders all watched timeframes for the route symbol and labels global delivery totals', () => { const dom = new JSDOM(''); const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); @@ -49,7 +68,7 @@ test('shows multi-timeframe events, deduplicates identities and clears only remo test('makes server/local spec mismatch visible without rendering it as verified', () => { const dom = new JSDOM(''); const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); - panel.renderStatus({ ...status, spec_version: '29_2_spec_v2' }); + panel.renderStatus({ ...status, spec_version: 'other_spec' }); assert.match(dom.window.document.body.textContent, /Spec mismatch/); assert.equal(dom.window.document.querySelector('[data-role=spec]').dataset.state, 'error'); panel.destroy(); diff --git a/test/fixtures/strategy29-gateway-events.json b/test/fixtures/strategy29-gateway-events.json index fa66234..bd06834 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_v1", + "spec_version": "29_2_spec_v2", "observed_at_ms": 1788580805000, "next_cursor": 42, "has_more": false, @@ -10,7 +10,7 @@ "event_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "schema_version": 1, "strategy_id": "29", - "spec_version": "29_2_spec_v1", + "spec_version": "29_2_spec_v2", "symbol": "BTC/USDT:USDT", "timeframe": "1m", "setup_direction": "bearish", @@ -34,7 +34,7 @@ "event_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "schema_version": 1, "strategy_id": "29", - "spec_version": "29_2_spec_v1", + "spec_version": "29_2_spec_v2", "symbol": "BTC/USDT:USDT", "timeframe": "1h", "setup_direction": "bullish", diff --git a/test/fixtures/strategy29-gateway-status.json b/test/fixtures/strategy29-gateway-status.json index a443445..4ff31d4 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_v1", + "spec_version": "29_2_spec_v2", "observed_at_ms": 1788580805000, "units": [ { diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js index 34c1457..0a6256a 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js @@ -228,12 +228,12 @@ test('reports unavailable status separately from fatal HTTP and contract errors' }); test('exposes remote/local spec mismatch before requesting event history', async () => { - const mismatch = { ...status, spec_version: '29_2_spec_v2' }; + const mismatch = { ...status, spec_version: 'other_spec' }; const fixture = clientFixture([response(mismatch)]); assert.deepEqual( await fixture.client.poll(new AbortController().signal), { state: 'incompatible', pages: 0, hasMore: false }, ); assert.equal(fixture.requests.length, 1); - assert.equal(fixture.snapshots[0].spec_version, '29_2_spec_v2'); + assert.equal(fixture.snapshots[0].spec_version, 'other_spec'); }); 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 fd91bc0..130e20d 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js @@ -23,10 +23,10 @@ test('canonical Strategy29 symbols round-trip without server-side normalization' }); test('validates exact status fields while preserving visible spec mismatch', () => { - assert.equal(STRATEGY29_SPEC_VERSION, '29_2_spec_v1'); + assert.equal(STRATEGY29_SPEC_VERSION, '29_2_spec_v2'); assert.equal(validateStrategy29StatusResponse(status, 200), status); const mismatch = structuredClone(status); - mismatch.spec_version = '29_2_spec_v2'; + mismatch.spec_version = 'other_spec'; assert.equal(validateStrategy29StatusResponse(mismatch, 200), mismatch); assert.throws( () => validateStrategy29StatusResponse({ ...status, unexpected: true }, 200), diff --git a/test/unit/binance-strategy29-bollinger/source-regressions.test.js b/test/unit/binance-strategy29-bollinger/source-regressions.test.js index 1597196..2c0df8a 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.2.2'); + assert.equal(readUserscriptVersion(entrySource), '0.3.0'); assert.deepEqual( [...entrySource.matchAll(/^\/\/ @grant\s+(\S+)\s*$/gm)].map(match => match[1]), ['unsafeWindow', 'GM_xmlhttpRequest', 'GM_getValue', 'GM_setValue', 'GM_registerMenuCommand'], diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index 8784ae5..854fd66 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.2.2'); + assert.equal(contract.version, '0.3.0'); 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 5b282ec9c4dcd71a98c1cbc823aa30639fd459c0 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:16:57 +0800 Subject: [PATCH 2/7] fix(strategy29): distinguish unavailable and pending selection --- ...inance-strategy29-bollinger-development.md | 20 ++++- scripts/binance-strategy29-bollinger.user.js | 84 ++++++++++++++++++- .../core/remote-summary-contract.js | 45 +++++++++- .../dom/strategy29-summary-panel.js | 28 ++++++- .../strategy29-summary-panel.test.js | 24 +++++- test/fixtures/strategy29-gateway-status.json | 16 ++++ .../remote-summary-contract.test.js | 12 +++ 7 files changed, 219 insertions(+), 10 deletions(-) diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index e780413..36dc5b3 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -72,11 +72,29 @@ ambient timezone. Observer compatibility is `29_2_spec_v2`; 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 server" while +membership: removed symbols show "Symbol is not watched by the current selection" while retained event history remains visible, and re-entering units can show warming until producer readiness and historical baseline complete. The browser does not choose markets or infer intervals from other strategies. +The exact status contract includes public `universe` metadata: generation, refresh +state/reason, selected markets, configured intervals, selected/ready/pending unit +counts and refresh timestamps/age. It shares the server SQLite snapshot with unit +progress and delivery counts. The panel distinguishes unavailable selection, +selected units awaiting observation and a healthy selection excluding the current +market. Unavailable selection suppresses healthy-looking processing rows while +retained events remain visible. A never-successful refresh displays no successful +time; generation-mismatched producer readiness contributes no ready units. + +The current remote summary is disabled by default and has no panel while disabled. +Authentication failure affects only the remote summary, not local chart detection. +Strategy27 and Strategy29 currently use separate private userscript storage. The +requested one-time gateway configuration across current and future strategies is +a pending unified-client design, including credential ownership, module lifecycle +and migration. Do not bridge credentials through page globals, localStorage or +page events, and do not ask users to duplicate secrets as the long-term solution. +A discoverable disabled-state entry is also pending, not implemented in 0.3.0. + 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 diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index 7ae9819..d9adacd 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -33,7 +33,32 @@ var ORIGINS = /* @__PURE__ */ new Set(["historical", "catch_up", "live"]); var DELIVERY_STATES = /* @__PURE__ */ new Set(["pending", "sending", "sent", "unknown", "expired", "failed"]); var DELIVERY_COUNT_KEYS = ["pending", "sending", "sent", "unknown", "expired", "failed"]; - var STATUS_KEYS = ["schema_version", "spec_version", "observed_at_ms", "units", "delivery_counts"]; + var STATUS_KEYS = ["schema_version", "spec_version", "observed_at_ms", "universe", "units", "delivery_counts"]; + var UNIVERSE_KEYS = [ + "source_monitor", + "generation", + "refresh_status", + "reason", + "selected_markets", + "configured_timeframes", + "selected_unit_count", + "ready_unit_count", + "pending_unit_count", + "refreshed_at_ms", + "last_successful_refreshed_at_ms", + "last_success_age_seconds", + "last_refresh_error_at_ms", + "selection_expires_at_ms" + ]; + var UNIVERSE_STATES = /* @__PURE__ */ new Set(["fresh", "stale_if_error", "fail_closed"]); + var UNIVERSE_REASONS = /* @__PURE__ */ new Set([ + "current", + "using_stale_selection_after_refresh_error", + "selection_fail_closed", + "selection_expired_or_unusable", + "missing_current_universe_facts", + "incompatible_current_universe_facts" + ]); var UNIT_KEYS = [ "symbol", "timeframe", @@ -125,12 +150,43 @@ throw new TypeError(`${name}.last_event_id must be null or a lowercase hexadecimal event id`); } } + function validateUniverse(value) { + assertExactKeys(value, UNIVERSE_KEYS, "status.universe"); + if (value.source_monitor !== "monitor29_bollinger_ma60") throw new TypeError("status.universe.source_monitor is invalid"); + assertInteger(value.generation, "status.universe.generation", { nullable: true, minimum: 1 }); + assertEnum(value.refresh_status, UNIVERSE_STATES, "status.universe.refresh_status"); + assertEnum(value.reason, UNIVERSE_REASONS, "status.universe.reason"); + for (const key of ["selected_markets", "configured_timeframes"]) { + if (!Array.isArray(value[key]) || value[key].length > 128 || new Set(value[key]).size !== value[key].length) { + throw new TypeError(`status.universe.${key} must be a bounded unique array`); + } + } + value.selected_markets.forEach((symbol) => assertCanonicalSymbol(symbol, "status.universe.selected_markets")); + value.configured_timeframes.forEach((timeframe) => assertEnum(timeframe, TIMEFRAMES, "status.universe.configured_timeframes")); + for (const key of ["selected_unit_count", "ready_unit_count", "pending_unit_count"]) { + assertInteger(value[key], `status.universe.${key}`); + if (value[key] > 128) throw new TypeError(`status.universe.${key} exceeds the unit bound`); + } + if (value.ready_unit_count + value.pending_unit_count !== value.selected_unit_count || value.selected_markets.length > value.selected_unit_count) throw new TypeError("status.universe counts are inconsistent"); + if (value.refresh_status === "fail_closed" && value.selected_unit_count !== 0) throw new TypeError("status.universe unavailable selection must be empty"); + for (const key of ["refreshed_at_ms", "last_successful_refreshed_at_ms", "last_refresh_error_at_ms", "selection_expires_at_ms"]) { + assertInteger(value[key], `status.universe.${key}`, { nullable: true }); + } + if (value.last_success_age_seconds !== null) { + assertFiniteNumber(value.last_success_age_seconds, "status.universe.last_success_age_seconds"); + if (value.last_success_age_seconds < 0) throw new TypeError("status.universe last success age must be non-negative"); + } + if (value.last_successful_refreshed_at_ms === null !== (value.last_success_age_seconds === null)) { + throw new TypeError("status.universe last success fields are inconsistent"); + } + } function validateStrategy29StatusResponse(value, httpStatus) { if (httpStatus !== 200) throw new TypeError(`status response requires HTTP 200, received ${httpStatus}`); assertExactKeys(value, STATUS_KEYS, "status response"); assertSchema(value.schema_version, "status.schema_version"); assertString(value.spec_version, "status.spec_version"); assertInteger(value.observed_at_ms, "status.observed_at_ms"); + validateUniverse(value.universe); if (!Array.isArray(value.units)) throw new TypeError("status.units must be an array"); if (value.units.length > 128) throw new TypeError("status.units exceeds the 128-unit bound"); value.units.forEach(validateUnit); @@ -1749,6 +1805,14 @@ // src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js var PANEL_ID = "jh-strategy29-summary-panel"; + var SELECTION_REASONS = Object.freeze({ + current: "Selection is current", + using_stale_selection_after_refresh_error: "Refresh failed; using the previous selection until expiry", + selection_fail_closed: "Selection unavailable after refresh failure", + selection_expired_or_unusable: "Selection expired; waiting for a successful refresh", + missing_current_universe_facts: "Waiting for server selection to initialize", + incompatible_current_universe_facts: "Server selection has an incompatible specification" + }); var STATE_COLORS = Object.freeze({ connected: "#0ECB81", connecting: "#F0B90B", @@ -1845,7 +1909,9 @@ const reference = element(document, "div", { text: `Local 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: "Status not received", role: "status-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); const eventsFreshness = element(document, "div", { text: "Events not checked", role: "events-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); - overview.append(connection, spec, reference, statusFreshness, eventsFreshness); + const selection = element(document, "div", { role: "selection", styles: { color: "#EAECEF", fontSize: "11px" } }); + const selectionRefresh = element(document, "div", { role: "selection-refresh", styles: { color: "#848E9C", fontSize: "11px" } }); + overview.append(connection, spec, reference, statusFreshness, selection, selectionRefresh, eventsFreshness); const unitsTitle = element(document, "div", { text: "Watched timeframes", styles: { padding: "7px 10px 4px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontWeight: "600" } }); const units = element(document, "div", { role: "units", styles: { display: "grid", gap: "3px", padding: "0 7px 8px" } }); const delivery = element(document, "div", { text: "Global delivery — waiting", role: "delivery", styles: { padding: "7px 10px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontSize: "11px" } }); @@ -1899,8 +1965,15 @@ spec.style.color = matched ? "#0ECB81" : "#F6465D"; spec.textContent = matched ? `Spec version matched · ${STRATEGY29_SPEC_VERSION}` : `Spec mismatch · local ${STRATEGY29_SPEC_VERSION} · server ${snapshot.spec_version}`; statusFreshness.textContent = `Status ${formatClock(snapshot.observed_at_ms)}`; + const universe = snapshot.universe; + const unavailable = universe.refresh_status === "fail_closed"; + selection.dataset.state = universe.refresh_status; + selection.style.color = unavailable ? "#F6465D" : universe.refresh_status === "fresh" ? "#0ECB81" : "#F0B90B"; + selection.textContent = `${SELECTION_REASONS[universe.reason]} · Generation ${universe.generation ?? "pending"} · ${universe.selected_markets.length} markets · ${universe.ready_unit_count}/${universe.selected_unit_count} units ready · Intervals ${universe.configured_timeframes.join(", ") || "pending"}`; + selectionRefresh.textContent = universe.last_successful_refreshed_at_ms === null ? "No successful selection has been observed" : `Last successful selection ${formatClock(universe.last_successful_refreshed_at_ms)} · ${universe.last_success_age_seconds.toFixed(1)}s ago`; units.replaceChildren(); - const matching = snapshot.units.filter((unit) => unit.symbol === canonicalSymbol); + const selected = universe.selected_markets.includes(canonicalSymbol); + const matching = unavailable || !selected ? [] : snapshot.units.filter((unit) => unit.symbol === canonicalSymbol); for (const unit of matching) { const row = element(document, "div", { role: "unit", @@ -1911,7 +1984,10 @@ row.appendChild(element(document, "span", { text: unit.reason, styles: { color: "#848E9C", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } })); units.appendChild(row); } - if (matching.length === 0) units.appendChild(element(document, "span", { text: "Symbol is not watched by the server", styles: { color: "#F0B90B", padding: "4px" } })); + if (matching.length === 0) units.appendChild(element(document, "span", { + text: unavailable ? "Server selection is unavailable" : selected ? "Symbol is selected; waiting for unit status" : "Symbol is not watched by the current server selection", + styles: { color: "#F0B90B", padding: "4px" } + })); const counts = snapshot.delivery_counts; delivery.textContent = `Global delivery · Pending ${counts.pending} · Sending ${counts.sending} · Sent ${counts.sent} · Unknown ${counts.unknown} · Expired ${counts.expired} · Failed ${counts.failed}`; }, diff --git a/src/binance-strategy29-bollinger/core/remote-summary-contract.js b/src/binance-strategy29-bollinger/core/remote-summary-contract.js index e07bc2e..6a18087 100644 --- a/src/binance-strategy29-bollinger/core/remote-summary-contract.js +++ b/src/binance-strategy29-bollinger/core/remote-summary-contract.js @@ -10,7 +10,17 @@ const SIGNAL_SIDES = new Set(['short', 'long']); const ORIGINS = new Set(['historical', 'catch_up', 'live']); const DELIVERY_STATES = new Set(['pending', 'sending', 'sent', 'unknown', 'expired', 'failed']); const DELIVERY_COUNT_KEYS = ['pending', 'sending', 'sent', 'unknown', 'expired', 'failed']; -const STATUS_KEYS = ['schema_version', 'spec_version', 'observed_at_ms', 'units', 'delivery_counts']; +const STATUS_KEYS = ['schema_version', 'spec_version', 'observed_at_ms', 'universe', 'units', 'delivery_counts']; +const UNIVERSE_KEYS = [ + 'source_monitor', 'generation', 'refresh_status', 'reason', 'selected_markets', 'configured_timeframes', + 'selected_unit_count', 'ready_unit_count', 'pending_unit_count', 'refreshed_at_ms', + 'last_successful_refreshed_at_ms', 'last_success_age_seconds', 'last_refresh_error_at_ms', 'selection_expires_at_ms', +]; +const UNIVERSE_STATES = new Set(['fresh', 'stale_if_error', 'fail_closed']); +const UNIVERSE_REASONS = new Set([ + 'current', 'using_stale_selection_after_refresh_error', 'selection_fail_closed', + 'selection_expired_or_unusable', 'missing_current_universe_facts', 'incompatible_current_universe_facts', +]); const UNIT_KEYS = [ 'symbol', 'timeframe', 'status', 'reason', 'last_processed_open_ms', 'last_data_at_ms', 'last_event_id', ]; @@ -96,12 +106,45 @@ function validateUnit(value, index) { } } +function validateUniverse(value) { + assertExactKeys(value, UNIVERSE_KEYS, 'status.universe'); + if (value.source_monitor !== 'monitor29_bollinger_ma60') throw new TypeError('status.universe.source_monitor is invalid'); + assertInteger(value.generation, 'status.universe.generation', { nullable: true, minimum: 1 }); + assertEnum(value.refresh_status, UNIVERSE_STATES, 'status.universe.refresh_status'); + assertEnum(value.reason, UNIVERSE_REASONS, 'status.universe.reason'); + for (const key of ['selected_markets', 'configured_timeframes']) { + if (!Array.isArray(value[key]) || value[key].length > 128 || new Set(value[key]).size !== value[key].length) { + throw new TypeError(`status.universe.${key} must be a bounded unique array`); + } + } + value.selected_markets.forEach(symbol => assertCanonicalSymbol(symbol, 'status.universe.selected_markets')); + value.configured_timeframes.forEach(timeframe => assertEnum(timeframe, TIMEFRAMES, 'status.universe.configured_timeframes')); + for (const key of ['selected_unit_count', 'ready_unit_count', 'pending_unit_count']) { + assertInteger(value[key], `status.universe.${key}`); + if (value[key] > 128) throw new TypeError(`status.universe.${key} exceeds the unit bound`); + } + if (value.ready_unit_count + value.pending_unit_count !== value.selected_unit_count + || value.selected_markets.length > value.selected_unit_count) throw new TypeError('status.universe counts are inconsistent'); + if (value.refresh_status === 'fail_closed' && value.selected_unit_count !== 0) throw new TypeError('status.universe unavailable selection must be empty'); + for (const key of ['refreshed_at_ms', 'last_successful_refreshed_at_ms', 'last_refresh_error_at_ms', 'selection_expires_at_ms']) { + assertInteger(value[key], `status.universe.${key}`, { nullable: true }); + } + if (value.last_success_age_seconds !== null) { + assertFiniteNumber(value.last_success_age_seconds, 'status.universe.last_success_age_seconds'); + if (value.last_success_age_seconds < 0) throw new TypeError('status.universe last success age must be non-negative'); + } + if ((value.last_successful_refreshed_at_ms === null) !== (value.last_success_age_seconds === null)) { + throw new TypeError('status.universe last success fields are inconsistent'); + } +} + export function validateStrategy29StatusResponse(value, httpStatus) { if (httpStatus !== 200) throw new TypeError(`status response requires HTTP 200, received ${httpStatus}`); assertExactKeys(value, STATUS_KEYS, 'status response'); assertSchema(value.schema_version, 'status.schema_version'); assertString(value.spec_version, 'status.spec_version'); assertInteger(value.observed_at_ms, 'status.observed_at_ms'); + validateUniverse(value.universe); if (!Array.isArray(value.units)) throw new TypeError('status.units must be an array'); if (value.units.length > 128) throw new TypeError('status.units exceeds the 128-unit bound'); value.units.forEach(validateUnit); diff --git a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js index 6101d63..8eb0576 100644 --- a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js +++ b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js @@ -1,6 +1,14 @@ import { STRATEGY29_REFERENCE_SHA256, STRATEGY29_SPEC_VERSION } from '../core/remote-summary-contract.js'; const PANEL_ID = 'jh-strategy29-summary-panel'; +const SELECTION_REASONS = Object.freeze({ + current: 'Selection is current', + using_stale_selection_after_refresh_error: 'Refresh failed; using the previous selection until expiry', + selection_fail_closed: 'Selection unavailable after refresh failure', + selection_expired_or_unusable: 'Selection expired; waiting for a successful refresh', + missing_current_universe_facts: 'Waiting for server selection to initialize', + incompatible_current_universe_facts: 'Server selection has an incompatible specification', +}); const STATE_COLORS = Object.freeze({ connected: '#0ECB81', connecting: '#F0B90B', @@ -86,7 +94,9 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve const reference = element(document, 'div', { text: `Local 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: 'Status not received', role: 'status-freshness', styles: { color: '#848E9C', fontSize: '11px' } }); const eventsFreshness = element(document, 'div', { text: 'Events not checked', role: 'events-freshness', styles: { color: '#848E9C', fontSize: '11px' } }); - overview.append(connection, spec, reference, statusFreshness, eventsFreshness); + const selection = element(document, 'div', { role: 'selection', styles: { color: '#EAECEF', fontSize: '11px' } }); + const selectionRefresh = element(document, 'div', { role: 'selection-refresh', styles: { color: '#848E9C', fontSize: '11px' } }); + overview.append(connection, spec, reference, statusFreshness, selection, selectionRefresh, eventsFreshness); const unitsTitle = element(document, 'div', { text: 'Watched timeframes', styles: { padding: '7px 10px 4px', borderTop: '1px solid rgba(132,142,156,.18)', color: '#848E9C', fontWeight: '600' } }); const units = element(document, 'div', { role: 'units', styles: { display: 'grid', gap: '3px', padding: '0 7px 8px' } }); const delivery = element(document, 'div', { text: 'Global delivery — waiting', role: 'delivery', styles: { padding: '7px 10px', borderTop: '1px solid rgba(132,142,156,.18)', color: '#848E9C', fontSize: '11px' } }); @@ -145,8 +155,17 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve ? `Spec version matched · ${STRATEGY29_SPEC_VERSION}` : `Spec mismatch · local ${STRATEGY29_SPEC_VERSION} · server ${snapshot.spec_version}`; statusFreshness.textContent = `Status ${formatClock(snapshot.observed_at_ms)}`; + const universe = snapshot.universe; + const unavailable = universe.refresh_status === 'fail_closed'; + selection.dataset.state = universe.refresh_status; + selection.style.color = unavailable ? '#F6465D' : universe.refresh_status === 'fresh' ? '#0ECB81' : '#F0B90B'; + selection.textContent = `${SELECTION_REASONS[universe.reason]} · Generation ${universe.generation ?? 'pending'} · ${universe.selected_markets.length} markets · ${universe.ready_unit_count}/${universe.selected_unit_count} units ready · Intervals ${universe.configured_timeframes.join(', ') || 'pending'}`; + selectionRefresh.textContent = universe.last_successful_refreshed_at_ms === null + ? 'No successful selection has been observed' + : `Last successful selection ${formatClock(universe.last_successful_refreshed_at_ms)} · ${universe.last_success_age_seconds.toFixed(1)}s ago`; units.replaceChildren(); - const matching = snapshot.units.filter(unit => unit.symbol === canonicalSymbol); + const selected = universe.selected_markets.includes(canonicalSymbol); + const matching = unavailable || !selected ? [] : snapshot.units.filter(unit => unit.symbol === canonicalSymbol); for (const unit of matching) { const row = element(document, 'div', { role: 'unit', @@ -157,7 +176,10 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve row.appendChild(element(document, 'span', { text: unit.reason, styles: { color: '#848E9C', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } })); units.appendChild(row); } - if (matching.length === 0) units.appendChild(element(document, 'span', { text: 'Symbol is not watched by the server', styles: { color: '#F0B90B', padding: '4px' } })); + if (matching.length === 0) units.appendChild(element(document, 'span', { + text: unavailable ? 'Server selection is unavailable' : selected ? 'Symbol is selected; waiting for unit status' : 'Symbol is not watched by the current server selection', + styles: { color: '#F0B90B', padding: '4px' }, + })); const counts = snapshot.delivery_counts; delivery.textContent = `Global delivery · Pending ${counts.pending} · Sending ${counts.sending} · Sent ${counts.sent} · Unknown ${counts.unknown} · Expired ${counts.expired} · Failed ${counts.failed}`; }, 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 a5012a0..52424a2 100644 --- a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js +++ b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js @@ -8,13 +8,35 @@ import { createStrategy29SummaryPanel } from '../../../src/binance-strategy29-bo const status = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-status.json', import.meta.url))); const events = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-events.json', import.meta.url))); +test('distinguishes unavailable selection, unselected symbol and pending unit registration', () => { + const dom = new JSDOM(''); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); + panel.addEvents(events.events); + panel.renderStatus({ ...status, units: [] }); + assert.match(dom.window.document.body.textContent, /Symbol is selected; waiting for unit status/); + assert.doesNotMatch(dom.window.document.body.textContent, /Symbol is not watched/); + panel.renderStatus({ ...status, universe: { + ...status.universe, refresh_status: 'fail_closed', reason: 'selection_expired_or_unusable', + selected_markets: [], selected_unit_count: 0, ready_unit_count: 0, pending_unit_count: 0, + } }); + assert.match(dom.window.document.body.textContent, /Selection expired/); + assert.match(dom.window.document.body.textContent, /Server selection is unavailable/); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 0); + assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 2); + panel.renderStatus({ ...status, universe: { ...status.universe, refresh_status: 'stale_if_error', reason: 'using_stale_selection_after_refresh_error' } }); + assert.match(dom.window.document.body.textContent, /Refresh failed; using the previous selection until expiry/); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 2); + panel.destroy(); + dom.window.close(); +}); + test('replaces dynamic membership while retaining durable signal history', () => { const dom = new JSDOM(''); const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); panel.renderStatus(status); panel.addEvents(events.events); assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 2); - panel.renderStatus({ ...status, units: [] }); + panel.renderStatus({ ...status, universe: { ...status.universe, selected_markets: ['ETH/USDT:USDT'] }, units: [] }); assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 0); assert.match(dom.window.document.body.textContent, /Symbol is not watched/); assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 2); diff --git a/test/fixtures/strategy29-gateway-status.json b/test/fixtures/strategy29-gateway-status.json index 4ff31d4..d90fe16 100644 --- a/test/fixtures/strategy29-gateway-status.json +++ b/test/fixtures/strategy29-gateway-status.json @@ -2,6 +2,22 @@ "schema_version": 1, "spec_version": "29_2_spec_v2", "observed_at_ms": 1788580805000, + "universe": { + "source_monitor": "monitor29_bollinger_ma60", + "generation": 1, + "refresh_status": "fresh", + "reason": "current", + "selected_markets": ["BTC/USDT:USDT", "ETH/USDT:USDT"], + "configured_timeframes": ["1m", "5m", "1h"], + "selected_unit_count": 3, + "ready_unit_count": 1, + "pending_unit_count": 2, + "refreshed_at_ms": 1788580800000, + "last_successful_refreshed_at_ms": 1788580800000, + "last_success_age_seconds": 5, + "last_refresh_error_at_ms": null, + "selection_expires_at_ms": 1788583800000 + }, "units": [ { "symbol": "BTC/USDT:USDT", 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 130e20d..8a48da4 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js @@ -14,6 +14,18 @@ import { const status = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-status.json', import.meta.url))); const events = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-events.json', import.meta.url))); +test('rejects malformed or contradictory universe status fields', () => { + for (const change of [ + { generation: '1' }, { refresh_status: 'unknown' }, { unexpected: true }, + { selected_markets: ['BTC'] }, { configured_timeframes: ['1s'] }, + { ready_unit_count: 2 }, { selected_unit_count: 129 }, + { last_success_age_seconds: -1 }, { last_successful_refreshed_at_ms: null }, + { refresh_status: 'fail_closed' }, + ]) { + assert.throws(() => validateStrategy29StatusResponse({ ...status, universe: { ...status.universe, ...change } }, 200), /universe/); + } +}); + test('canonical Strategy29 symbols round-trip without server-side normalization', () => { assert.equal(routeSymbolToCanonical('BTRUSDT'), 'BTR/USDT:USDT'); assert.equal(canonicalSymbolToRoute('BTR/USDT:USDT'), 'BTRUSDT'); From d8a945e5b078099c31374da240c2ae5fdc2509b2 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:27:46 +0800 Subject: [PATCH 3/7] fix(strategy29): classify incompatible specs before payload validation --- ...inance-strategy29-bollinger-development.md | 8 +++- scripts/binance-strategy29-bollinger.user.js | 37 ++++++++++++++----- .../core/remote-summary-contract.js | 21 ++++++++--- .../dom/strategy29-summary-panel.js | 9 +++++ .../strategy29-summary-panel.test.js | 9 ++++- .../remote-summary-client.test.js | 1 + .../remote-summary-contract.test.js | 32 +++++++++++++++- 7 files changed, 97 insertions(+), 20 deletions(-) diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index 36dc5b3..ba6a59a 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -121,8 +121,12 @@ Transport failures and a temporarily unavailable database remain retryable remote states. Authentication, request, JSON, and response-contract failures stop only the remote context until it is restarted through a route or settings change. None of these states stop the local detector or remove local markers. -The panel compares server and local `spec_version`; a mismatch is visible and -event consumption is blocked. The local reference hash is displayed and exposed +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 +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 d9adacd..4ce3b55 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -51,14 +51,16 @@ "selection_expires_at_ms" ]; var UNIVERSE_STATES = /* @__PURE__ */ new Set(["fresh", "stale_if_error", "fail_closed"]); - var UNIVERSE_REASONS = /* @__PURE__ */ new Set([ - "current", - "using_stale_selection_after_refresh_error", - "selection_fail_closed", - "selection_expired_or_unusable", - "missing_current_universe_facts", - "incompatible_current_universe_facts" - ]); + var UNIVERSE_REASONS = { + fresh: /* @__PURE__ */ new Set(["current"]), + stale_if_error: /* @__PURE__ */ new Set(["using_stale_selection_after_refresh_error"]), + fail_closed: /* @__PURE__ */ new Set([ + "selection_fail_closed", + "selection_expired_or_unusable", + "missing_current_universe_facts", + "incompatible_current_universe_facts" + ]) + }; var UNIT_KEYS = [ "symbol", "timeframe", @@ -155,7 +157,7 @@ if (value.source_monitor !== "monitor29_bollinger_ma60") throw new TypeError("status.universe.source_monitor is invalid"); assertInteger(value.generation, "status.universe.generation", { nullable: true, minimum: 1 }); assertEnum(value.refresh_status, UNIVERSE_STATES, "status.universe.refresh_status"); - assertEnum(value.reason, UNIVERSE_REASONS, "status.universe.reason"); + assertEnum(value.reason, UNIVERSE_REASONS[value.refresh_status], "status.universe.reason"); for (const key of ["selected_markets", "configured_timeframes"]) { if (!Array.isArray(value[key]) || value[key].length > 128 || new Set(value[key]).size !== value[key].length) { throw new TypeError(`status.universe.${key} must be a bounded unique array`); @@ -182,10 +184,16 @@ } function validateStrategy29StatusResponse(value, httpStatus) { if (httpStatus !== 200) throw new TypeError(`status response requires HTTP 200, received ${httpStatus}`); - assertExactKeys(value, STATUS_KEYS, "status response"); + assertObject(value, "status response"); 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 { + schema_version: value.schema_version, + spec_version: value.spec_version, + observed_at_ms: value.observed_at_ms + }; + assertExactKeys(value, STATUS_KEYS, "status response"); validateUniverse(value.universe); if (!Array.isArray(value.units)) throw new TypeError("status.units must be an array"); if (value.units.length > 128) throw new TypeError("status.units exceeds the 128-unit bound"); @@ -1965,6 +1973,15 @@ spec.style.color = matched ? "#0ECB81" : "#F6465D"; spec.textContent = matched ? `Spec version matched · ${STRATEGY29_SPEC_VERSION}` : `Spec mismatch · local ${STRATEGY29_SPEC_VERSION} · server ${snapshot.spec_version}`; statusFreshness.textContent = `Status ${formatClock(snapshot.observed_at_ms)}`; + if (!matched) { + selection.dataset.state = "incompatible"; + selection.style.color = "#F6465D"; + selection.textContent = "Selection unavailable: observer specs are incompatible"; + selectionRefresh.textContent = ""; + units.replaceChildren(); + delivery.textContent = "Global delivery unavailable: observer specs are incompatible"; + return; + } const universe = snapshot.universe; const unavailable = universe.refresh_status === "fail_closed"; selection.dataset.state = universe.refresh_status; diff --git a/src/binance-strategy29-bollinger/core/remote-summary-contract.js b/src/binance-strategy29-bollinger/core/remote-summary-contract.js index 6a18087..dfb8e00 100644 --- a/src/binance-strategy29-bollinger/core/remote-summary-contract.js +++ b/src/binance-strategy29-bollinger/core/remote-summary-contract.js @@ -17,10 +17,14 @@ const UNIVERSE_KEYS = [ 'last_successful_refreshed_at_ms', 'last_success_age_seconds', 'last_refresh_error_at_ms', 'selection_expires_at_ms', ]; const UNIVERSE_STATES = new Set(['fresh', 'stale_if_error', 'fail_closed']); -const UNIVERSE_REASONS = new Set([ - 'current', 'using_stale_selection_after_refresh_error', 'selection_fail_closed', - 'selection_expired_or_unusable', 'missing_current_universe_facts', 'incompatible_current_universe_facts', -]); +const UNIVERSE_REASONS = { + fresh: new Set(['current']), + stale_if_error: new Set(['using_stale_selection_after_refresh_error']), + fail_closed: new Set([ + 'selection_fail_closed', 'selection_expired_or_unusable', + 'missing_current_universe_facts', 'incompatible_current_universe_facts', + ]), +}; const UNIT_KEYS = [ 'symbol', 'timeframe', 'status', 'reason', 'last_processed_open_ms', 'last_data_at_ms', 'last_event_id', ]; @@ -111,7 +115,7 @@ function validateUniverse(value) { if (value.source_monitor !== 'monitor29_bollinger_ma60') throw new TypeError('status.universe.source_monitor is invalid'); assertInteger(value.generation, 'status.universe.generation', { nullable: true, minimum: 1 }); assertEnum(value.refresh_status, UNIVERSE_STATES, 'status.universe.refresh_status'); - assertEnum(value.reason, UNIVERSE_REASONS, 'status.universe.reason'); + assertEnum(value.reason, UNIVERSE_REASONS[value.refresh_status], 'status.universe.reason'); for (const key of ['selected_markets', 'configured_timeframes']) { if (!Array.isArray(value[key]) || value[key].length > 128 || new Set(value[key]).size !== value[key].length) { throw new TypeError(`status.universe.${key} must be a bounded unique array`); @@ -140,10 +144,15 @@ function validateUniverse(value) { export function validateStrategy29StatusResponse(value, httpStatus) { if (httpStatus !== 200) throw new TypeError(`status response requires HTTP 200, received ${httpStatus}`); - assertExactKeys(value, STATUS_KEYS, 'status response'); + assertObject(value, 'status response'); assertSchema(value.schema_version, 'status.schema_version'); 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 { + schema_version: value.schema_version, spec_version: value.spec_version, observed_at_ms: value.observed_at_ms, + }; + assertExactKeys(value, STATUS_KEYS, 'status response'); validateUniverse(value.universe); if (!Array.isArray(value.units)) throw new TypeError('status.units must be an array'); if (value.units.length > 128) throw new TypeError('status.units exceeds the 128-unit bound'); diff --git a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js index 8eb0576..b64361c 100644 --- a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js +++ b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js @@ -155,6 +155,15 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve ? `Spec version matched · ${STRATEGY29_SPEC_VERSION}` : `Spec mismatch · local ${STRATEGY29_SPEC_VERSION} · server ${snapshot.spec_version}`; statusFreshness.textContent = `Status ${formatClock(snapshot.observed_at_ms)}`; + if (!matched) { + selection.dataset.state = 'incompatible'; + selection.style.color = '#F6465D'; + selection.textContent = 'Selection unavailable: observer specs are incompatible'; + selectionRefresh.textContent = ''; + units.replaceChildren(); + delivery.textContent = 'Global delivery unavailable: observer specs are incompatible'; + return; + } const universe = snapshot.universe; const unavailable = universe.refresh_status === 'fail_closed'; selection.dataset.state = universe.refresh_status; 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 52424a2..bc96001 100644 --- a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js +++ b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js @@ -90,9 +90,16 @@ test('shows multi-timeframe events, deduplicates identities and clears only remo test('makes server/local spec mismatch visible without rendering it as verified', () => { const dom = new JSDOM(''); const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); - panel.renderStatus({ ...status, spec_version: 'other_spec' }); + panel.renderStatus(status); + panel.addEvents(events.events, events.observed_at_ms); + panel.renderStatus({ schema_version: 1, spec_version: 'other_spec', observed_at_ms: status.observed_at_ms }); assert.match(dom.window.document.body.textContent, /Spec mismatch/); assert.equal(dom.window.document.querySelector('[data-role=spec]').dataset.state, 'error'); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 0); + assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 2); + assert.match(dom.window.document.querySelector('[data-role=selection]').textContent, /incompatible/); + panel.renderStatus(status); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 2); panel.destroy(); dom.window.close(); }); diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js index 0a6256a..524e89a 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js @@ -229,6 +229,7 @@ test('reports unavailable status separately from fatal HTTP and contract errors' test('exposes remote/local spec mismatch before requesting event history', async () => { const mismatch = { ...status, spec_version: 'other_spec' }; + delete mismatch.universe; const fixture = clientFixture([response(mismatch)]); assert.deepEqual( await fixture.client.poll(new AbortController().signal), 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 8a48da4..76b1634 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js @@ -34,12 +34,42 @@ test('canonical Strategy29 symbols round-trip without server-side normalization' assert.throws(() => canonicalSymbolToRoute('BTR/USDT'), /canonical symbol/); }); +test('accepts only coherent universe refresh state and reason combinations', () => { + const reasons = { + fresh: ['current'], + stale_if_error: ['using_stale_selection_after_refresh_error'], + fail_closed: ['selection_fail_closed', 'selection_expired_or_unusable', 'missing_current_universe_facts', 'incompatible_current_universe_facts'], + }; + for (const refreshState of Object.keys(reasons)) { + for (const reason of Object.values(reasons).flat()) { + const universe = { ...status.universe, refresh_status: refreshState, reason }; + if (refreshState === 'fail_closed') Object.assign(universe, { + selected_markets: [], selected_unit_count: 0, ready_unit_count: 0, pending_unit_count: 0, + }); + const candidate = { ...status, universe }; + if (reasons[refreshState].includes(reason)) assert.equal(validateStrategy29StatusResponse(candidate, 200), candidate); + else assert.throws(() => validateStrategy29StatusResponse(candidate, 200), /universe/); + } + } +}); + +test('projects only validated identity when status belongs to an incompatible spec', () => { + const identity = { schema_version: 1, spec_version: 'other_spec', observed_at_ms: status.observed_at_ms }; + const incompatible = { ...identity, get units() { throw new Error('incompatible payload must not be read'); } }; + assert.deepEqual(validateStrategy29StatusResponse(incompatible, 200), identity); + for (const invalid of [{ schema_version: 2 }, { spec_version: '' }, { observed_at_ms: '1' }]) { + assert.throws(() => validateStrategy29StatusResponse({ ...identity, ...invalid }, 200), /status/); + } +}); + test('validates exact status fields while preserving visible spec mismatch', () => { assert.equal(STRATEGY29_SPEC_VERSION, '29_2_spec_v2'); assert.equal(validateStrategy29StatusResponse(status, 200), status); const mismatch = structuredClone(status); mismatch.spec_version = 'other_spec'; - assert.equal(validateStrategy29StatusResponse(mismatch, 200), mismatch); + assert.deepEqual(validateStrategy29StatusResponse(mismatch, 200), { + schema_version: mismatch.schema_version, spec_version: mismatch.spec_version, observed_at_ms: mismatch.observed_at_ms, + }); assert.throws( () => validateStrategy29StatusResponse({ ...status, unexpected: true }, 200), /exact keys/, From 0441cfb7e90a036f84cabd9bf2c57040d92f7543 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:38:29 +0800 Subject: [PATCH 4/7] fix(strategy29): hide retired timeframe status rows --- scripts/binance-strategy29-bollinger.user.js | 2 +- .../dom/strategy29-summary-panel.js | 4 +++- .../strategy29-summary-panel.test.js | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index 4ce3b55..85e2d06 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -1990,7 +1990,7 @@ selectionRefresh.textContent = universe.last_successful_refreshed_at_ms === null ? "No successful selection has been observed" : `Last successful selection ${formatClock(universe.last_successful_refreshed_at_ms)} · ${universe.last_success_age_seconds.toFixed(1)}s ago`; units.replaceChildren(); const selected = universe.selected_markets.includes(canonicalSymbol); - const matching = unavailable || !selected ? [] : snapshot.units.filter((unit) => unit.symbol === canonicalSymbol); + const matching = unavailable || !selected ? [] : snapshot.units.filter((unit) => unit.symbol === canonicalSymbol && universe.configured_timeframes.includes(unit.timeframe)); for (const unit of matching) { const row = element(document, "div", { role: "unit", diff --git a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js index b64361c..cfbbe36 100644 --- a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js +++ b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js @@ -174,7 +174,9 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve : `Last successful selection ${formatClock(universe.last_successful_refreshed_at_ms)} · ${universe.last_success_age_seconds.toFixed(1)}s ago`; units.replaceChildren(); const selected = universe.selected_markets.includes(canonicalSymbol); - const matching = unavailable || !selected ? [] : snapshot.units.filter(unit => unit.symbol === canonicalSymbol); + const matching = unavailable || !selected ? [] : snapshot.units.filter(unit => ( + unit.symbol === canonicalSymbol && universe.configured_timeframes.includes(unit.timeframe) + )); for (const unit of matching) { const row = element(document, 'div', { role: 'unit', 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 bc96001..5d4a907 100644 --- a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js +++ b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js @@ -36,11 +36,14 @@ test('replaces dynamic membership while retaining durable signal history', () => panel.renderStatus(status); panel.addEvents(events.events); assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 2); + panel.renderStatus({ ...status, universe: { ...status.universe, configured_timeframes: ['1h'] } }); + assert.deepEqual([...dom.window.document.querySelectorAll('[data-role=unit]')].map(row => row.firstChild.textContent), ['1h']); + assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 2); panel.renderStatus({ ...status, universe: { ...status.universe, selected_markets: ['ETH/USDT:USDT'] }, units: [] }); assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 0); assert.match(dom.window.document.body.textContent, /Symbol is not watched/); assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 2); - panel.renderStatus({ ...status, units: [{ ...status.units[0], timeframe: '4h', status: 'warming', reason: 'awaiting_producer_generation' }] }); + panel.renderStatus({ ...status, universe: { ...status.universe, configured_timeframes: ['4h'] }, units: [{ ...status.units[0], timeframe: '4h', status: 'warming', reason: 'awaiting_producer_generation' }] }); const units = dom.window.document.querySelectorAll('[data-role=unit]'); assert.equal(units.length, 1); assert.match(units[0].textContent, /4h.*warming.*awaiting_producer_generation/); From 1865790e0c6445637cc5985107c2c7dc96c6849a Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:52:04 +0800 Subject: [PATCH 5/7] fix(strategy29): distinguish processing from live readiness --- docs/binance-strategy29-bollinger-development.md | 5 +++++ scripts/binance-strategy29-bollinger.user.js | 12 ++++++++---- .../dom/strategy29-summary-panel.js | 13 +++++++++---- .../strategy29-summary-panel.test.js | 16 ++++++++++++++++ .../remote-summary-contract.test.js | 8 ++++++++ 5 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index ba6a59a..72f2640 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -85,6 +85,11 @@ selected units awaiting observation and a healthy selection excluding the curren market. Unavailable selection suppresses healthy-looking processing rows while retained events remain visible. A never-successful refresh displays no successful time; generation-mismatched producer readiness contributes no ready units. +The aggregate is labeled `live units ready`. Per-period rows show the last stored +processing status, with `ready` displayed as neutral `Processed`; they do not +certify current live readiness. A stored ready row with zero live-ready units is +valid during producer-generation or admission/baseline transitions and must not +be rejected by the client validator. The current remote summary is disabled by default and has no panel while disabled. Authentication failure affects only the remote summary, not local chart detection. diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index 85e2d06..c0ffe6f 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -1831,7 +1831,7 @@ configuration_required: "#F0B90B" }); var STATUS_COLORS = Object.freeze({ - ready: "#0ECB81", + ready: "#848E9C", warming: "#F0B90B", stale: "#F6465D", insufficient_history: "#F0B90B", @@ -1920,7 +1920,11 @@ const selection = element(document, "div", { role: "selection", styles: { color: "#EAECEF", fontSize: "11px" } }); const selectionRefresh = element(document, "div", { role: "selection-refresh", styles: { color: "#848E9C", fontSize: "11px" } }); overview.append(connection, spec, reference, statusFreshness, selection, selectionRefresh, eventsFreshness); - const unitsTitle = element(document, "div", { text: "Watched timeframes", styles: { padding: "7px 10px 4px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontWeight: "600" } }); + const unitsTitle = element(document, "div", { text: "Last processing status", styles: { padding: "7px 10px 4px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontWeight: "600" } }); + unitsTitle.appendChild(element(document, "div", { + text: "Stored processing status does not confirm current live readiness.", + styles: { fontSize: "11px", fontWeight: "400" } + })); const units = element(document, "div", { role: "units", styles: { display: "grid", gap: "3px", padding: "0 7px 8px" } }); const delivery = element(document, "div", { text: "Global delivery — waiting", role: "delivery", styles: { padding: "7px 10px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontSize: "11px" } }); const eventsTitle = element(document, "div", { text: "Recent cross-timeframe signals", styles: { padding: "7px 10px 4px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontWeight: "600" } }); @@ -1986,7 +1990,7 @@ const unavailable = universe.refresh_status === "fail_closed"; selection.dataset.state = universe.refresh_status; selection.style.color = unavailable ? "#F6465D" : universe.refresh_status === "fresh" ? "#0ECB81" : "#F0B90B"; - selection.textContent = `${SELECTION_REASONS[universe.reason]} · Generation ${universe.generation ?? "pending"} · ${universe.selected_markets.length} markets · ${universe.ready_unit_count}/${universe.selected_unit_count} units ready · Intervals ${universe.configured_timeframes.join(", ") || "pending"}`; + selection.textContent = `${SELECTION_REASONS[universe.reason]} · Generation ${universe.generation ?? "pending"} · ${universe.selected_markets.length} markets · ${universe.ready_unit_count}/${universe.selected_unit_count} live units ready · Intervals ${universe.configured_timeframes.join(", ") || "pending"}`; selectionRefresh.textContent = universe.last_successful_refreshed_at_ms === null ? "No successful selection has been observed" : `Last successful selection ${formatClock(universe.last_successful_refreshed_at_ms)} · ${universe.last_success_age_seconds.toFixed(1)}s ago`; units.replaceChildren(); const selected = universe.selected_markets.includes(canonicalSymbol); @@ -1997,7 +2001,7 @@ styles: { display: "grid", gridTemplateColumns: "42px 64px minmax(0,1fr)", gap: "6px", padding: "4px 6px", borderRadius: "5px", background: "rgba(132,142,156,.08)" } }); row.appendChild(element(document, "strong", { text: unit.timeframe, styles: { color: "#EAECEF" } })); - row.appendChild(element(document, "span", { text: unit.status, styles: { color: STATUS_COLORS[unit.status] } })); + row.appendChild(element(document, "span", { text: unit.status === "ready" ? "Processed" : unit.status, styles: { color: STATUS_COLORS[unit.status] } })); row.appendChild(element(document, "span", { text: unit.reason, styles: { color: "#848E9C", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } })); units.appendChild(row); } diff --git a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js index cfbbe36..994221a 100644 --- a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js +++ b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js @@ -19,7 +19,7 @@ const STATE_COLORS = Object.freeze({ configuration_required: '#F0B90B', }); const STATUS_COLORS = Object.freeze({ - ready: '#0ECB81', + ready: '#848E9C', warming: '#F0B90B', stale: '#F6465D', insufficient_history: '#F0B90B', @@ -97,7 +97,11 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve const selection = element(document, 'div', { role: 'selection', styles: { color: '#EAECEF', fontSize: '11px' } }); const selectionRefresh = element(document, 'div', { role: 'selection-refresh', styles: { color: '#848E9C', fontSize: '11px' } }); overview.append(connection, spec, reference, statusFreshness, selection, selectionRefresh, eventsFreshness); - const unitsTitle = element(document, 'div', { text: 'Watched timeframes', styles: { padding: '7px 10px 4px', borderTop: '1px solid rgba(132,142,156,.18)', color: '#848E9C', fontWeight: '600' } }); + const unitsTitle = element(document, 'div', { text: 'Last processing status', styles: { padding: '7px 10px 4px', borderTop: '1px solid rgba(132,142,156,.18)', color: '#848E9C', fontWeight: '600' } }); + unitsTitle.appendChild(element(document, 'div', { + text: 'Stored processing status does not confirm current live readiness.', + styles: { fontSize: '11px', fontWeight: '400' }, + })); const units = element(document, 'div', { role: 'units', styles: { display: 'grid', gap: '3px', padding: '0 7px 8px' } }); const delivery = element(document, 'div', { text: 'Global delivery — waiting', role: 'delivery', styles: { padding: '7px 10px', borderTop: '1px solid rgba(132,142,156,.18)', color: '#848E9C', fontSize: '11px' } }); const eventsTitle = element(document, 'div', { text: 'Recent cross-timeframe signals', styles: { padding: '7px 10px 4px', borderTop: '1px solid rgba(132,142,156,.18)', color: '#848E9C', fontWeight: '600' } }); @@ -168,7 +172,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve const unavailable = universe.refresh_status === 'fail_closed'; selection.dataset.state = universe.refresh_status; selection.style.color = unavailable ? '#F6465D' : universe.refresh_status === 'fresh' ? '#0ECB81' : '#F0B90B'; - selection.textContent = `${SELECTION_REASONS[universe.reason]} · Generation ${universe.generation ?? 'pending'} · ${universe.selected_markets.length} markets · ${universe.ready_unit_count}/${universe.selected_unit_count} units ready · Intervals ${universe.configured_timeframes.join(', ') || 'pending'}`; + selection.textContent = `${SELECTION_REASONS[universe.reason]} · Generation ${universe.generation ?? 'pending'} · ${universe.selected_markets.length} markets · ${universe.ready_unit_count}/${universe.selected_unit_count} live units ready · Intervals ${universe.configured_timeframes.join(', ') || 'pending'}`; selectionRefresh.textContent = universe.last_successful_refreshed_at_ms === null ? 'No successful selection has been observed' : `Last successful selection ${formatClock(universe.last_successful_refreshed_at_ms)} · ${universe.last_success_age_seconds.toFixed(1)}s ago`; @@ -183,7 +187,8 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve styles: { display: 'grid', gridTemplateColumns: '42px 64px minmax(0,1fr)', gap: '6px', padding: '4px 6px', borderRadius: '5px', background: 'rgba(132,142,156,.08)' }, }); row.appendChild(element(document, 'strong', { text: unit.timeframe, styles: { color: '#EAECEF' } })); - row.appendChild(element(document, 'span', { text: unit.status, styles: { color: STATUS_COLORS[unit.status] } })); + // Stored processing can succeed before the current live admission is ready. + row.appendChild(element(document, 'span', { text: unit.status === 'ready' ? 'Processed' : unit.status, styles: { color: STATUS_COLORS[unit.status] } })); row.appendChild(element(document, 'span', { text: unit.reason, styles: { color: '#848E9C', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } })); units.appendChild(row); } 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 5d4a907..90d762c 100644 --- a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js +++ b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js @@ -8,6 +8,22 @@ import { createStrategy29SummaryPanel } from '../../../src/binance-strategy29-bo const status = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-status.json', import.meta.url))); const events = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-events.json', import.meta.url))); +test('distinguishes stored processing success from current live readiness', () => { + const dom = new JSDOM(''); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT'); + panel.renderStatus({ ...status, universe: { + ...status.universe, ready_unit_count: 0, pending_unit_count: status.universe.selected_unit_count, + } }); + assert.match(dom.window.document.querySelector('[data-role=selection]').textContent, /0\/3 live units ready/); + assert.match(dom.window.document.body.textContent, /Last processing status/); + assert.match(dom.window.document.body.textContent, /Stored processing status does not confirm current live readiness/); + const processing = dom.window.document.querySelector('[data-role=unit]').children[1]; + assert.equal(processing.textContent, 'Processed'); + assert.equal(processing.style.color, 'rgb(132, 142, 156)'); + panel.destroy(); + dom.window.close(); +}); + test('distinguishes unavailable selection, unselected symbol and pending unit registration', () => { const dom = new JSDOM(''); const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); 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 76b1634..3692397 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js @@ -14,6 +14,14 @@ import { const status = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-status.json', import.meta.url))); const events = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-events.json', import.meta.url))); +test('accepts stored ready processing while current live admission is pending', () => { + const candidate = { ...status, universe: { + ...status.universe, ready_unit_count: 0, pending_unit_count: status.universe.selected_unit_count, + } }; + assert.equal(candidate.units[0].status, 'ready'); + assert.equal(validateStrategy29StatusResponse(candidate, 200), candidate); +}); + test('rejects malformed or contradictory universe status fields', () => { for (const change of [ { generation: '1' }, { refresh_status: 'unknown' }, { unexpected: true }, From b437a4a880ab6f4037778d11123e76529eb75f9b Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:05:16 +0800 Subject: [PATCH 6/7] fix(strategy29): enforce refresh metadata state contracts --- ...inance-strategy29-bollinger-development.md | 5 ++ scripts/binance-strategy29-bollinger.user.js | 30 ++++++++ .../core/remote-summary-contract.js | 25 +++++++ .../remote-summary-contract.test.js | 70 +++++++++++++++++-- 4 files changed, 126 insertions(+), 4 deletions(-) diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index 72f2640..b56b370 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -90,6 +90,11 @@ processing status, with `ready` displayed as neutral `Processed`; they do not certify current live readiness. A stored ready row with zero live-ready units is valid during producer-generation or admission/baseline transitions and must not be rejected by the client validator. +Refresh metadata must match the reported state: successful and stale selections +require success time, age and expiry; failed refreshes require an error time; +missing or incompatible facts carry null metadata. Fail-closed selection may +retain a complete prior-success group. Expired selection can originate from fresh +or stale facts. Clock rollback does not invalidate otherwise coherent metadata. The current remote summary is disabled by default and has no panel while disabled. Authentication failure affects only the remote summary, not local chart detection. diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index c0ffe6f..de4596a 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -181,6 +181,36 @@ if (value.last_successful_refreshed_at_ms === null !== (value.last_success_age_seconds === null)) { throw new TypeError("status.universe last success fields are inconsistent"); } + const successMissing = value.last_successful_refreshed_at_ms === null; + if (successMissing !== (value.selection_expires_at_ms === null)) { + throw new TypeError("status.universe successful selection requires its expiry"); + } + const absentFacts = value.reason === "missing_current_universe_facts" || value.reason === "incompatible_current_universe_facts"; + if (absentFacts) { + if ([ + value.generation, + value.refreshed_at_ms, + value.last_successful_refreshed_at_ms, + value.last_success_age_seconds, + value.last_refresh_error_at_ms, + value.selection_expires_at_ms + ].some((item) => item !== null)) { + throw new TypeError("status.universe unavailable facts must have null refresh metadata"); + } + } else { + if (value.generation === null || value.refreshed_at_ms === null) { + throw new TypeError("status.universe current facts require generation and refresh time"); + } + if (value.reason !== "selection_fail_closed" && successMissing) { + throw new TypeError("status.universe successful selection metadata is required"); + } + if (value.refresh_status === "fresh" && value.last_refresh_error_at_ms !== null) { + throw new TypeError("status.universe fresh selection cannot report a refresh error"); + } + if ((value.refresh_status === "stale_if_error" || value.reason === "selection_fail_closed") && value.last_refresh_error_at_ms === null) { + throw new TypeError("status.universe failed refresh requires its error time"); + } + } } function validateStrategy29StatusResponse(value, httpStatus) { if (httpStatus !== 200) throw new TypeError(`status response requires HTTP 200, received ${httpStatus}`); diff --git a/src/binance-strategy29-bollinger/core/remote-summary-contract.js b/src/binance-strategy29-bollinger/core/remote-summary-contract.js index dfb8e00..5af52de 100644 --- a/src/binance-strategy29-bollinger/core/remote-summary-contract.js +++ b/src/binance-strategy29-bollinger/core/remote-summary-contract.js @@ -140,6 +140,31 @@ function validateUniverse(value) { if ((value.last_successful_refreshed_at_ms === null) !== (value.last_success_age_seconds === null)) { throw new TypeError('status.universe last success fields are inconsistent'); } + const successMissing = value.last_successful_refreshed_at_ms === null; + if (successMissing !== (value.selection_expires_at_ms === null)) { + throw new TypeError('status.universe successful selection requires its expiry'); + } + const absentFacts = value.reason === 'missing_current_universe_facts' || value.reason === 'incompatible_current_universe_facts'; + if (absentFacts) { + if ([value.generation, value.refreshed_at_ms, value.last_successful_refreshed_at_ms, + value.last_success_age_seconds, value.last_refresh_error_at_ms, value.selection_expires_at_ms].some(item => item !== null)) { + throw new TypeError('status.universe unavailable facts must have null refresh metadata'); + } + } else { + if (value.generation === null || value.refreshed_at_ms === null) { + throw new TypeError('status.universe current facts require generation and refresh time'); + } + if (value.reason !== 'selection_fail_closed' && successMissing) { + throw new TypeError('status.universe successful selection metadata is required'); + } + if (value.refresh_status === 'fresh' && value.last_refresh_error_at_ms !== null) { + throw new TypeError('status.universe fresh selection cannot report a refresh error'); + } + if ((value.refresh_status === 'stale_if_error' || value.reason === 'selection_fail_closed') + && value.last_refresh_error_at_ms === null) { + throw new TypeError('status.universe failed refresh requires its error time'); + } + } } export function validateStrategy29StatusResponse(value, httpStatus) { 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 3692397..1ef8610 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js @@ -14,6 +14,71 @@ import { const status = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-status.json', import.meta.url))); const events = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-events.json', import.meta.url))); +const METADATA_KEYS = ['generation', 'refreshed_at_ms', 'last_successful_refreshed_at_ms', 'last_success_age_seconds', 'last_refresh_error_at_ms', 'selection_expires_at_ms']; +const SUCCESS_KEYS = ['last_successful_refreshed_at_ms', 'last_success_age_seconds', 'selection_expires_at_ms']; + +function universeForReason(reason) { + const universe = { ...status.universe, reason }; + universe.refresh_status = reason === 'current' ? 'fresh' + : reason === 'using_stale_selection_after_refresh_error' ? 'stale_if_error' : 'fail_closed'; + if (universe.refresh_status === 'fail_closed') Object.assign(universe, { + selected_markets: [], selected_unit_count: 0, ready_unit_count: 0, pending_unit_count: 0, + }); + if (reason === 'selection_fail_closed' || universe.refresh_status === 'stale_if_error') { + universe.last_refresh_error_at_ms = status.observed_at_ms; + } + if (reason === 'missing_current_universe_facts' || reason === 'incompatible_current_universe_facts') { + for (const key of METADATA_KEYS) universe[key] = null; + universe.configured_timeframes = []; + } + return universe; +} + +test('requires metadata belonging to the reported refresh state', () => { + const required = { + current: ['generation', 'refreshed_at_ms', ...SUCCESS_KEYS], + using_stale_selection_after_refresh_error: METADATA_KEYS, + selection_fail_closed: ['generation', 'refreshed_at_ms', 'last_refresh_error_at_ms'], + selection_expired_or_unusable: ['generation', 'refreshed_at_ms', ...SUCCESS_KEYS], + }; + for (const [reason, keys] of Object.entries(required)) { + const universe = universeForReason(reason); + const candidate = { ...status, universe }; + assert.equal(validateStrategy29StatusResponse(candidate, 200), candidate); + for (const key of keys) assert.throws(() => validateStrategy29StatusResponse({ + ...status, universe: { ...universe, [key]: null }, + }, 200), /universe/, `${reason} requires ${key}`); + } + const freshWithoutSuccess = universeForReason('current'); + for (const key of SUCCESS_KEYS) freshWithoutSuccess[key] = null; + assert.throws(() => validateStrategy29StatusResponse({ ...status, universe: freshWithoutSuccess }, 200), /universe/); + const failedWithoutSuccess = universeForReason('selection_fail_closed'); + for (const key of SUCCESS_KEYS) failedWithoutSuccess[key] = null; + const failed = { ...status, universe: failedWithoutSuccess }; + assert.equal(validateStrategy29StatusResponse(failed, 200), failed); + for (const key of SUCCESS_KEYS) assert.throws(() => validateStrategy29StatusResponse({ + ...failed, universe: { ...failedWithoutSuccess, [key]: status.universe[key] }, + }, 200), /universe/); + for (const reason of ['missing_current_universe_facts', 'incompatible_current_universe_facts']) { + const universe = universeForReason(reason); + const candidate = { ...status, universe }; + assert.equal(validateStrategy29StatusResponse(candidate, 200), candidate); + for (const key of METADATA_KEYS) assert.throws(() => validateStrategy29StatusResponse({ + ...status, universe: { ...universe, [key]: 1 }, + }, 200), /universe/); + } + assert.throws(() => validateStrategy29StatusResponse({ ...status, universe: { + ...status.universe, last_refresh_error_at_ms: status.observed_at_ms, + } }, 200), /universe/); + const expiredStale = { ...status, universe: { + ...universeForReason('selection_expired_or_unusable'), last_refresh_error_at_ms: status.observed_at_ms, + } }; + assert.equal(validateStrategy29StatusResponse(expiredStale, 200), expiredStale); + const clockBack = { ...status, observed_at_ms: status.universe.refreshed_at_ms - 1000, + universe: { ...status.universe, last_success_age_seconds: 0 } }; + assert.equal(validateStrategy29StatusResponse(clockBack, 200), clockBack); +}); + test('accepts stored ready processing while current live admission is pending', () => { const candidate = { ...status, universe: { ...status.universe, ready_unit_count: 0, pending_unit_count: status.universe.selected_unit_count, @@ -50,10 +115,7 @@ test('accepts only coherent universe refresh state and reason combinations', () }; for (const refreshState of Object.keys(reasons)) { for (const reason of Object.values(reasons).flat()) { - const universe = { ...status.universe, refresh_status: refreshState, reason }; - if (refreshState === 'fail_closed') Object.assign(universe, { - selected_markets: [], selected_unit_count: 0, ready_unit_count: 0, pending_unit_count: 0, - }); + const universe = { ...universeForReason(reason), refresh_status: refreshState }; const candidate = { ...status, universe }; if (reasons[refreshState].includes(reason)) assert.equal(validateStrategy29StatusResponse(candidate, 200), candidate); else assert.throws(() => validateStrategy29StatusResponse(candidate, 200), /universe/); From 374757ce662e4639524f8e03f017896d2870803c Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:00:56 +0800 Subject: [PATCH 7/7] feat(strategy29): localize summary and persist draggable panel position --- ...inance-strategy29-bollinger-development.md | 22 + scripts/binance-strategy29-bollinger.user.js | 514 +++++++++++++++--- .../dom/panel-position.js | 52 ++ .../dom/strategy29-summary-panel.js | 145 +++-- .../remote-summary.js | 79 ++- src/binance-strategy29-bollinger/runtime.js | 13 +- src/binance-strategy29-bollinger/ui-copy.js | 90 +++ .../strategy29-summary-panel.test.js | 9 +- .../summary-locale-position.test.js | 121 +++++ .../strategy29-summary-panel-preview.mjs | 35 +- .../remote-summary-controller.test.js | 69 ++- 11 files changed, 963 insertions(+), 186 deletions(-) create mode 100644 src/binance-strategy29-bollinger/dom/panel-position.js create mode 100644 src/binance-strategy29-bollinger/ui-copy.js create mode 100644 test/dom/binance-strategy29-bollinger/summary-locale-position.test.js diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index b56b370..a3b31f2 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -52,6 +52,28 @@ or sent to the remote service. Recoverable snapshot races leave it unchanged. Independent instances of each bundle share the same controller regardless of load order. Strategy27 does not participate in this protocol. +## Panel Language and Position + +The summary follows the existing pathname locale contract: `/zh-CN/` uses Chinese, +while English and other routes use English. Strategy29 imports the existing pure +orderbook locale helpers without modifying orderbook or shared runtime behavior. +Headers, connection and selection states, signal names, processing labels, +notification totals, empty states, menus and prompts have bilingual copy. Technical +identifiers and arbitrary server diagnostic details remain verbatim, with localized +labels. Chart arrows contain no textual labels. + +A same-symbol locale change rerenders retained status, events and connection text +without retiring requests, rebuilding the client or resetting its cursor. Existing +Tampermonkey menu IDs are updated using the documented `GM_registerMenuCommand` +options contract (Tampermonkey 5.0 or later); no extra grant is required. + +Drag the header with the primary mouse button. Header buttons do not start a drag. +Mouse release or window blur saves only `{left, top}` under the private userscript +key `strategy29SummaryPanelPosition`; route changes and page reloads restore it. +The panel clamps its position after viewport, content, collapse and language changes. +Destroying the panel removes its document/window drag listeners. Invalid persisted +coordinates fail explicitly. No credentials cross the page boundary. + ## Optional Cross-Timeframe Server Summary The remote summary is disabled by default. Tampermonkey exposes three Strategy29 diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index de4596a..918eefc 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -1841,16 +1841,292 @@ }; } - // src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js - var PANEL_ID = "jh-strategy29-summary-panel"; + // src/binance-orderbook-trade/contracts/panel-copy.js + var UI_LOCALE_ZH_CN = "zh-CN"; + var UI_LOCALE_EN = "en"; + var SUPPORTED_UI_LOCALES = Object.freeze([ + UI_LOCALE_ZH_CN, + UI_LOCALE_EN + ]); + function localizedText(zhCN, en) { + if (typeof zhCN !== "string" || zhCN === "" || typeof en !== "string" || en === "") { + throw new Error("Localized UI text requires non-empty Chinese and English values"); + } + return Object.freeze({ zhCN, en }); + } + function isLocalizedText(value) { + return Boolean( + value && typeof value === "object" && typeof value.zhCN === "string" && typeof value.en === "string" + ); + } + function formatLocalizedText(value, locale) { + if (typeof value === "string") return value; + if (!isLocalizedText(value)) throw new Error("Invalid localized UI text"); + if (locale === UI_LOCALE_ZH_CN) return value.zhCN; + if (locale === UI_LOCALE_EN) return value.en; + throw new Error(`Unsupported UI locale: ${locale}`); + } + function resolveUiLocaleFromPathname(pathname) { + const firstSegment = String(pathname || "").split(/[?#]/, 1)[0].split("/").filter(Boolean)[0]; + return firstSegment?.toLowerCase() === "zh-cn" ? UI_LOCALE_ZH_CN : UI_LOCALE_EN; + } + var freezeCopy = (copy) => Object.freeze(copy); + var PANEL_COPY = Object.freeze({ + section: freezeCopy({ + singleOrder: localizedText("单击下单", "Single Order"), + ladderMaker: localizedText("阶梯下单 · Maker", "Ladder Orders · Maker") + }), + field: freezeCopy({ + clickOrderbook: localizedText("单击订单簿时", "On click"), + minimumOrderQuantity: localizedText("最小下单量的", "Minimum order qty"), + minimumOpenQuantity: localizedText("最小开仓量的", "Minimum open qty"), + minimumCloseQuantity: localizedText("最小平仓量的", "Minimum close qty"), + ratio: localizedText("比例", "Ratio"), + orderCount: localizedText("笔数", "Orders"), + interval: localizedText("间距", "Gap"), + pricePrecision: localizedText("精度", "Precision"), + multiplierUnit: localizedText("倍", "×") + }), + action: freezeCopy({ + openLong: localizedText("阶梯开多", "Open Long"), + openShort: localizedText("阶梯开空", "Open Short"), + closeLong: localizedText("阶梯平多", "Close Long"), + closeShort: localizedText("阶梯平空", "Close Short"), + cancel: localizedText("撤单", "Cancel"), + cancelRunning: localizedText("撤单处理中", "Cancelling"), + noOrders: localizedText("无挂单", "No Orders"), + accountRebalance: localizedText("账户再平衡", "Account Rebalance"), + stopLadderByAction: freezeCopy({ + OPEN_LONG: localizedText("停止开多", "Stop Open Long"), + OPEN_SHORT: localizedText("停止开空", "Stop Open Short"), + CLOSE_LONG: localizedText("停止平多", "Stop Close Long"), + CLOSE_SHORT: localizedText("停止平空", "Stop Close Short") + }) + }), + side: freezeCopy({ + long: localizedText("多", "Long"), + short: localizedText("空", "Short"), + openLong: localizedText("开多", "Open Long"), + openShort: localizedText("开空", "Open Short"), + closeLong: localizedText("平多", "Close Long"), + closeShort: localizedText("平空", "Close Short") + }), + state: freezeCopy({ + idle: localizedText("空闲", "Idle"), + allPositionsClosed: localizedText("已全部平仓", "All positions closed"), + waitingTradeMode: localizedText("等待开仓/平仓状态", "Waiting for trade mode"), + waitingPricePrecision: localizedText("等待价格精度", "Waiting for precision"), + minimumQuantityLoading: localizedText("最小量读取中", "Loading minimum qty"), + positiveIntegerMultiplier: localizedText("请输入正整数倍数", "Enter a positive integer"), + noClosablePosition: localizedText("暂无可平仓位", "No position to close") + }), + status: freezeCopy({ + precisionUpdated: localizedText("精度推荐已更新", "Precision recommendation updated"), + precisionInsufficient: localizedText( + "近期价格变化不足,请稍后重试", + "Recent price movement is insufficient. Try again later." + ) + }), + aria: freezeCopy({ + decrementMultiplier: localizedText("减少倍数", "Decrease multiplier"), + incrementMultiplier: localizedText("增加倍数", "Increase multiplier") + }), + rebalanceDialog: freezeCopy({ + targetSummary: localizedText( + "目标分配:资金 50% / 现货 40% / U本位 10%", + "Target allocation: Funding 50% / Spot 40% / USDⓈ-M Futures 10%" + ), + accountHeading: localizedText("账户", "Account"), + currentHeading: localizedText("当前 (USDT)", "Current (USDT)"), + targetHeading: localizedText("目标 (USDT)", "Target (USDT)"), + transferHeading: localizedText("划转计划", "Transfer Plan"), + cancel: localizedText("取消", "Cancel"), + confirm: localizedText("确认再平衡", "Confirm Rebalance") + }), + tooltip: freezeCopy({ + singleOrder: localizedText( + "单击订单簿中的某个价格,按当前方向和数量设置提交一笔订单。", + "Click a price in the order book to submit one order using the current side and quantity settings." + ), + ladderMaker: localizedText( + "根据当前比例、笔数、间距和价格精度设置,依次提交只做 Maker 的阶梯订单。", + "Submit Post Only ladder orders sequentially using the current ratio, order count, gap, and precision." + ), + ratio: localizedText( + "本次阶梯下单使用可开/可平数量的百分比。", + "Percentage of the available open or close quantity used by this ladder." + ), + orderCount: localizedText( + "计划拆分成多少笔阶梯订单。", + "Number of orders in the ladder." + ), + interval: localizedText( + "相邻订单跨越多少个订单簿价格级别。", + "Number of order-book price levels between adjacent orders." + ), + pricePrecision: localizedText( + "与订单簿中的价格精度联动。黄点表示推荐值。比例、笔数、间距会随所选精度恢复对应设置。", + "Linked to the order-book price precision. The yellow dot marks the recommendation. Ratio, orders, and gap restore their saved values for the selected precision." + ), + continuousClose: localizedText( + "Option/Alt + 单击:连续交易", + "Option/Alt + click: continuous trading" + ), + accountRebalance: localizedText( + "将资金、现货和 U 本位账户的 USDT 按 5:4:1 分配", + "Allocate USDT across Funding, Spot, and USDⓈ-M Futures accounts at a 5:4:1 ratio" + ) + }) + }); + + // src/binance-strategy29-bollinger/ui-copy.js + var pair = localizedText; + var SUMMARY_COPY = Object.freeze({ + title: pair("Strategy 29 汇总", "Strategy 29 Summary"), + drag: pair("拖动面板", "Drag panel"), + collapse: pair("收起", "Collapse"), + expand: pair("展开", "Expand"), + waiting: pair("等待中", "Waiting"), + observerSpec: (value) => pair(`观察器规格 ${value}`, `Observer spec ${value}`), + reference: (value) => pair(`本地参考版本 ${value}`, `Local reference ${value}`), + noStatus: pair("尚未收到状态", "Status not received"), + noEventsCheck: pair("尚未检查事件", "Events not checked"), + processing: pair("最近处理状态", "Last processing status"), + processingHint: pair("已保存的处理状态不代表当前实时数据已就绪。", "Stored processing status does not confirm current live readiness."), + waitingDelivery: pair("全局通知 — 等待中", "Global delivery — waiting"), + recent: pair("最近跨周期信号", "Recent cross-timeframe signals"), + noEvents: pair("暂无最近信号", "No recent signals"), + close: (value) => pair(`收盘 ${value}`, `Close ${value}`), + matched: (value) => pair(`规格版本一致 · ${value}`, `Spec version matched · ${value}`), + mismatch: (local, server) => pair(`规格不一致 · 本地 ${local} · 服务端 ${server}`, `Spec mismatch · local ${local} · server ${server}`), + statusAt: (value) => pair(`状态更新 ${value}`, `Status ${value}`), + eventsAt: (value) => pair(`事件检查 ${value}`, `Events checked ${value}`), + incompatibleSelection: pair("选币不可用:观察器规格不一致", "Selection unavailable: observer specs are incompatible"), + incompatibleDelivery: pair("全局通知状态不可用:观察器规格不一致", "Global delivery unavailable: observer specs are incompatible"), + pending: pair("等待中", "pending"), + generation: (value) => pair(`批次 ${value}`, `Generation ${value}`), + markets: (value) => pair(`${value} 个币种`, `${value} markets`), + ready: (ready, total) => pair(`${ready}/${total} 个实时监测单元已就绪`, `${ready}/${total} live units ready`), + intervals: (value) => pair(`周期 ${value}`, `Intervals ${value}`), + noSuccess: pair("尚无成功选币记录", "No successful selection has been observed"), + lastSuccess: (clock, age) => pair(`上次成功选币 ${clock} · ${age} 秒前`, `Last successful selection ${clock} · ${age}s ago`), + unavailableSelection: pair("服务端选币不可用", "Server selection is unavailable"), + awaitingUnits: pair("币种已入选,等待监测单元状态", "Symbol is selected; waiting for unit status"), + notSelected: pair("当前服务端选币未监听此币种", "Symbol is not watched by the current server selection"), + delivery: (c) => pair(`全局通知 · 待发送 ${c.pending} · 发送中 ${c.sending} · 已发送 ${c.sent} · 结果未知 ${c.unknown} · 已过期 ${c.expired} · 失败 ${c.failed}`, `Global delivery · Pending ${c.pending} · Sending ${c.sending} · Sent ${c.sent} · Unknown ${c.unknown} · Expired ${c.expired} · Failed ${c.failed}`), + configuration: pair("尚未配置网关密钥", "Gateway secret is not configured"), + connecting: pair("正在连接 Strategy 29 网关", "Connecting to Strategy 29 gateway"), + connected: pair("已连接", "Connected"), + moreHistory: pair("已连接 · 仍有历史记录待加载", "Connected · more history pending"), + unavailable: pair("网关数据库暂不可用", "Gateway database unavailable"), + incompatible: pair("服务端与本地规格不一致", "Server and local specs are incompatible"), + disconnected: pair("网关连接失败,将在下次定时检查时重试", "Gateway connection failed; next scheduled poll will retry"), + stopped: (detail) => pair(`远程汇总已停止。技术详情:${detail}`, `Remote summary stopped: ${detail}`), + menuToggle: pair("切换 Strategy 29 跨周期汇总", "Toggle Strategy 29 cross-timeframe summary"), + menuSecret: pair("设置 Strategy 29 网关密钥", "Set Strategy 29 gateway secret"), + menuOrigin: pair("设置 Strategy 29 网关地址", "Set Strategy 29 gateway origin"), + promptSecret: pair("请输入本地 Strategy 29 网关密钥,仅保存在此用户脚本的私有存储中。", "Enter the local Strategy 29 gateway secret. It is stored only in this userscript storage."), + promptOrigin: pair("请输入本机网关地址(http://127.0.0.1: