diff --git a/README.md b/README.md index 3ae2039..81df1e3 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ This repository is the source of truth and distribution point for the scripts. G | Script | Target | Description | Source of truth | Install | | --- | --- | --- | --- | --- | -| Binance Strategy 29 Bollinger signals | Binance Futures | Annotate native loaded candles with mirrored Bollinger/SMA60 signals; standalone or alongside orderbook 2.7.199+ | This repository | [Install][install-binance-strategy29-bollinger] | +| Binance Strategy 29 Bollinger signals | Binance Futures | Annotate native loaded candles and optionally show the authenticated cross-timeframe server summary; standalone or alongside orderbook 2.7.199+ | This repository | [Install][install-binance-strategy29-bollinger] | | Binance orderbook one-click order entry | Binance Futures | Click an orderbook price, infer the current open/close tab, fill quantity, and submit an order with a multiplier panel | This repository | [Install][install-binance-orderbook-trade] | | Binance Futures data panel | Binance Futures | Overlay open interest, long/short ratios, funding rate, basis, and directional signals | This repository | [Install][install-binance-trading-data] | | Binance CoinMarketCap data panel | Binance Futures | Show CoinMarketCap valuation, supply, and liquidity data for the current symbol | This repository | [Install][install-binance-coinmarketcap-data] | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6450e8f..a8a8bf4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -35,7 +35,7 @@ | 脚本 | 适用场景 | 说明 | 源码真源 | 安装 | |---|---|---|---|---| -| 【自写】Binance Strategy 29 布林带信号 | Binance Futures | Annotate native loaded candles with mirrored Bollinger/SMA60 signals; standalone or alongside orderbook 2.7.199+ | This repository | [Install][install-binance-strategy29-bollinger] | +| 【自写】Binance Strategy 29 布林带信号 | Binance Futures | 标注原生图表已加载 K 线,并可选显示经认证的服务端跨周期摘要;可单独运行或与订单簿 2.7.199+ 共存 | 本仓库 | [安装][install-binance-strategy29-bollinger] | | 【自写】Binance 订单簿单击下单 | Binance Futures | 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 | 本仓库 | [`点击安装`][install-binance-orderbook-trade] | | 【自写】Binance 合约交易数据面板 | Binance Futures | 在合约交易页面叠加浮动面板,定时拉取交易数据(持仓量、多空比、资金费率等)并显示当前值 + 多空信号 | 本仓库 | [`点击安装`][install-binance-trading-data] | | 【自写】Binance CoinMarketCap 数据面板 | Binance Futures | 在 Binance 合约页面显示当前币种的 CoinMarketCap 估值、供应量和流动性数据 | 本仓库 | [`点击安装`][install-binance-coinmarketcap-data] | diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index 48f65fd..08fd6d1 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -2,13 +2,14 @@ ## Scope and Installation -The standalone `binance-strategy29-bollinger.user.js` owns the existing local -Bollinger/SMA60 observer. Source is `src/binance-strategy29-bollinger/`. -It reads already-loaded native chart candles and has no account, order, -exchange-network, Telegram or server-gateway operations. The server summary is -a separate future integration, not part of this local extraction. - -Install Strategy29 0.1.0 with orderbook 2.7.199 or later, or use it alone. +The standalone `binance-strategy29-bollinger.user.js` owns the local +Bollinger/SMA60 observer and an optional read-only Strategy29 server summary. +Source is `src/binance-strategy29-bollinger/`. The local observer reads only +already-loaded native chart candles. The optional summary reads the authenticated +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.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. @@ -16,8 +17,10 @@ If the old script loads later, existing markers can remain because its private save owner can block safe cleanup. This is not a supported compatibility mode; Strategy29 never removes old-script or user drawings. -Both supported scripts run with `@grant none` in the same page context. -The orderbook registers a synchronous boolean drawing-busy predicate under +The orderbook runs in page context. Strategy29 0.2.0 runs in a Tampermonkey +sandbox so its gateway secret remains in private userscript storage, and passes +`unsafeWindow` explicitly to the chart runtime. The orderbook registers a +synchronous boolean drawing-busy predicate under `Symbol.for('jh-userscripts.chart-mutation-owners')`; it unregisters on permanent page teardown. A missing owner means there is no coordinated orderbook instance, not a guessed order/account state. No task objects or financial actions cross @@ -26,13 +29,50 @@ this boundary. The exact native API owns the shared marker controller under protocol version 1 and reject incompatible versions. These are coordination contracts between trusted scripts, not a security boundary against page code. -The standalone entry has a per-page singleton. One poll discovers charts/routes -and evaluates the existing monitor. Hidden documents and BFCache pagehide pause -it; visibility/pageshow resumes it. Permanent disposal removes its listeners and -invalidates pending drawing work. Non-trading routes perform no candle exports. +The standalone entry has a per-page singleton on `unsafeWindow`. One poll +discovers charts/routes and evaluates the existing monitor. Hidden documents and +BFCache pagehide pause it; visibility/pageshow resumes it. Permanent disposal +removes its listeners, aborts an in-flight summary request, and invalidates +pending drawing work. Non-trading routes perform no candle exports or gateway +requests. Independent instances of each bundle share the same controller regardless of load order. Strategy27 does not participate in this protocol. +## Optional Cross-Timeframe Server Summary + +The remote summary is disabled by default. Tampermonkey exposes three Strategy29 +menu commands: toggle the cross-timeframe summary, set the loopback gateway +origin, and set the gateway secret. The origin contract is an explicit +`http://127.0.0.1:` origin; the default is `http://127.0.0.1:8729`. The +secret is never stored in the page, URL, panel DOM, or debug diagnostics. + +When enabled, the panel follows only the current Binance route symbol but shows +every timeframe watched for that symbol by the server. It therefore continues to +show 1m, 1h, and other server states and recent signals regardless of the chart +interval currently open. It labels delivery counts as global because those +counts cover the complete server outbox rather than just the current symbol. +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. + +The browser polls status first and then consumes at most two event pages per +scheduled poll. A page may contain no matching events while still advancing the +global cursor; that progress is accepted. `cursor_expired` clears only the remote +event rows and resumes from the explicit server cursor. Route changes, page +hiding, disabling, and disposal abort the owned request. Every response checks +context ownership before changing the panel, so a late old-symbol response is +ignored. No extra recurring timer is installed: the existing one-second runtime +sample applies a five-second remote gate and keeps requests single-flight. + +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 +for audit, but the current server status schema does not carry a hash, so the UI +does not claim hash-level remote parity. + ## Bidirectional Bollinger Alerts The chart alert is timeframe-agnostic and evaluates closed bars only. It supports TradingView second, minute, hour, day, and week resolutions; month resolutions are intentionally unsupported because their duration is calendar-dependent. It scans every closed bar already loaded in TradingView and does not issue a separate market-data request or force the chart to load older history. The active-page poll exports the current window once per second. Detection runs again when the `count:firstTime:lastTime` window or any closed-bar OHLC content changes; unchanged content is compared against a compact numeric snapshot instead of serializing the whole history on every poll. The cached signal set is reconciled with live TradingView shape IDs on every poll. Loading older chart history therefore expands the annotated range even when the latest bar is unchanged, and a TradingView chart refresh can no longer leave the alert registry pointing at evicted markers. @@ -49,7 +89,7 @@ The exposed chart API can exist before its internal model during initial loading Indicator calculation traverses each fixed window directly instead of allocating sliced/mapped close arrays for every bar. Summation order is preserved exactly, including population variance, to avoid changing threshold decisions through floating-point drift. Stable marker audits read each shape handle once while retaining the full point/property checks. The asynchronous render loop yields a browser task after 32 signals or 8 ms of batch work; each resumed batch refreshes native shape ownership and revalidates generation, chart session, and drawing-mutation ownership. This is a cooperative budget checked between native calls, not a hard limit on an individual native API call. It adds no recurring timer and does not reduce history coverage or audit frequency. Context cleanup and obsolete-marker deletion remain synchronous; host chart loading/rendering and those removal phases are not covered by the batch budget. -`window.__TM_STRATEGY29_DEBUG__.diagnostics` is an on-demand diagnostic snapshot of timer/task presence, context/session readiness, cached/rendered signal counts, and the aggregate boolean drawing-mutation state. It contains no order details, does not export candles or audit drawings, and adds no periodic work. Native model/data readiness is reported separately from session readiness so a waiting session is not mistaken for expensive calculation or a zero-signal window. +`window.__TM_STRATEGY29_DEBUG__.diagnostics` is an on-demand diagnostic snapshot of timer/task presence, context/session readiness, cached/rendered signal counts, the aggregate boolean drawing-mutation state, and non-sensitive remote state. It contains no gateway secret, authorization header, request payload, or order details; it does not export candles or audit drawings and adds no periodic work. Native model/data readiness is reported separately from session readiness so a waiting session is not mistaken for expensive calculation or a zero-signal window. Alert markers use TradingView's drawing API. Every detected signal in the loaded window is rendered; there is no recent-signal truncation. Each direction allows up to 1,000 simultaneous signals, for a shared maximum of 2,000, and an over-limit window is rejected before any partial marker mutation. Marker ownership is tracked by signal ID, but the live shape list remains authoritative: externally evicted marker IDs are discarded from the registry and recreated without removing or changing foreign drawings. A typed OHLC/time-order snapshot race is treated as recoverable: existing markers and cached signals remain in place and the next poll retries. Schema, nonnumeric data, chart API, band-width, and time-alignment contract failures remain fail-closed and clear the alert layer. Current live evidence shows that removing even a `disableSave` marker emits `drawing_event` and `saveChart`, so alert reconciliation pauses during every existing order-line drawing/save owner. Symbol changes, non-trading routes, hidden documents, and page teardown stop or clear the alert lifecycle. @@ -74,8 +114,11 @@ Native asynchronous shape creation can automatically enable the interval active Run `npm run test:binance-strategy29-bollinger`, `npm test`, both affected single-script builds, `npm run check:binance-userscripts` and `npm run test:ui`. The cross-script browser fixture loads the actual generated artifacts in both -orders. Independent bundle tests cover controller identity, active owners, -save drain, protocol conflicts and unregister. Existing marker tests cover +orders. The sandbox entry test proves that Strategy29 installs its singleton on +the page `unsafeWindow`. Remote contract/controller tests cover strict symbol +round trips, bounded cursors, spec mismatch, 409 reset, stale response ownership, +and failure isolation. Independent bundle tests cover controller identity, active +owners, save drain, protocol conflicts and unregister. Existing marker tests cover interval switches, late results, historical coverage, foreign drawings and cooperative rendering. diff --git a/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js b/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js index 96f864c..1d73a9e 100644 --- a/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js +++ b/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js @@ -5,10 +5,22 @@ import { openUserscriptScenario } from '../helpers/userscript-page.js'; const strategy29 = await readFile(new URL('../../../scripts/binance-strategy29-bollinger.user.js', import.meta.url), 'utf8'); +function strategy29Sandbox(source) { + return `{ + const unsafeWindow = window; + const GM_xmlhttpRequest = () => { throw new Error('Strategy29 remote summary must remain disabled in this fixture'); }; + const GM_getValue = (_key, fallback) => fallback; + const GM_setValue = () => {}; + const GM_registerMenuCommand = () => {}; + ${source} + }`; +} + for (const first of [true, false]) { test(`independent generated scripts share chart coordination (Strategy29 first=${first})`, async ({ page }) => { + const sandboxedStrategy29 = strategy29Sandbox(strategy29); const { errors } = await openUserscriptScenario(page, createCancelScenario(), first - ? { beforeOrderbook: strategy29 } : { afterOrderbook: strategy29 }); + ? { beforeOrderbook: sandboxedStrategy29 } : { afterOrderbook: sandboxedStrategy29 }); await page.evaluate(symbol => { const api = document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi; const shapes = new Map(); @@ -52,7 +64,7 @@ for (const first of [true, false]) { owners: [...window[Symbol.for('jh-userscripts.chart-mutation-owners')].predicates.keys()], }))).toEqual({ embedded: false, controller: 1, owners: ['orderbook'] }); // Reinjecting the complete standalone artifact must reuse its page singleton. - await page.addScriptTag({ content: strategy29 }); + await page.addScriptTag({ content: sandboxedStrategy29 }); expect(await page.evaluate(() => window.__TM_STRATEGY29_DEBUG__.diagnostics.layerSize)).toBe(9); await page.evaluate(() => window.__TM_STRATEGY29_DEBUG__.dispose()); expect(await page.evaluate(() => document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi.activeChart().getAllShapes())).toEqual([]); diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index 5831bdf..fe6297d 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -3,19 +3,386 @@ // @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.1.0 +// @version 0.2.0 // @author jackhai9 -// @description Closed-candle Bollinger/SMA60 signals on the native Binance chart +// @description Native Bollinger/SMA60 markers with an optional read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* // @match https://www.binance.com/futures/* // @exclude https://www.binance.com/*/my/wallet/futures/* // @exclude https://www.binance.com/my/wallet/futures/* +// @connect 127.0.0.1 // @updateURL https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js // @downloadURL https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js // @run-at document-start -// @grant none +// @grant unsafeWindow +// @grant GM_xmlhttpRequest +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_registerMenuCommand // ==/UserScript== (() => { + // src/binance-strategy29-bollinger/core/remote-summary-contract.js + var STRATEGY29_SCHEMA_VERSION = 1; + var STRATEGY29_SPEC_VERSION = "29_2_spec_v1"; + 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"]); + var DIRECTIONS = /* @__PURE__ */ new Set(["bearish", "bullish"]); + var SIGNAL_TYPES = /* @__PURE__ */ new Set(["warning", "confirmed", "reversal"]); + var SIGNAL_SIDES = /* @__PURE__ */ new Set(["short", "long"]); + 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 UNIT_KEYS = [ + "symbol", + "timeframe", + "status", + "reason", + "last_processed_open_ms", + "last_data_at_ms", + "last_event_id" + ]; + var EVENTS_KEYS = ["schema_version", "spec_version", "observed_at_ms", "next_cursor", "has_more", "events"]; + var EVENT_KEYS = [ + "sequence", + "event_id", + "schema_version", + "strategy_id", + "spec_version", + "symbol", + "timeframe", + "setup_direction", + "signal_type", + "signal_side", + "setup_open_ms", + "bar_open_ms", + "bar_close_ms", + "detected_at_ms", + "close_price", + "marker_price", + "warning_open_ms", + "warning_high", + "warning_low", + "origin", + "delivery_state", + "delivery_failure_reason" + ]; + var EVENT_ID_PATTERN = /^[0-9a-f]{64}$/; + var ROUTE_SYMBOL_PATTERN = /^([A-Z0-9]+)USDT$/; + var CANONICAL_SYMBOL_PATTERN = /^([A-Z0-9]+)\/USDT:USDT$/; + function assertObject(value, name) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${name} must be an object`); + } + function assertExactKeys(value, keys, name) { + assertObject(value, name); + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new TypeError(`${name} must contain exact keys: ${expected.join(", ")}`); + } + } + function assertInteger(value, name, { nullable = false, minimum = 0 } = {}) { + if (nullable && value === null) return; + if (!Number.isSafeInteger(value) || value < minimum) throw new TypeError(`${name} must be an integer >= ${minimum}`); + } + function assertString(value, name, { nullable = false, maximumLength = 256 } = {}) { + if (nullable && value === null) return; + if (typeof value !== "string" || value.length === 0) throw new TypeError(`${name} must be a non-empty string`); + if (value.length > maximumLength) throw new TypeError(`${name} exceeds ${maximumLength} characters`); + } + function assertEnum(value, allowed, name, { nullable = false } = {}) { + if (nullable && value === null) return; + if (!allowed.has(value)) throw new TypeError(`${name} is invalid`); + } + function assertFiniteNumber(value, name) { + if (!Number.isFinite(value)) throw new TypeError(`${name} must be a finite number`); + } + function assertSchema(value, name) { + if (value !== STRATEGY29_SCHEMA_VERSION) throw new TypeError(`${name} must equal ${STRATEGY29_SCHEMA_VERSION}`); + } + function assertCanonicalSymbol(value, name) { + if (typeof value !== "string" || !CANONICAL_SYMBOL_PATTERN.test(value)) { + throw new TypeError(`${name} must use canonical symbol format`); + } + } + function routeSymbolToCanonical(value) { + if (typeof value !== "string") throw new TypeError("route symbol must be a string"); + const match = ROUTE_SYMBOL_PATTERN.exec(value); + if (!match || match[1] === "") throw new TypeError("route symbol must end in USDT and use uppercase canonical route syntax"); + return `${match[1]}/USDT:USDT`; + } + function validateUnit(value, index) { + const name = `status.units[${index}]`; + assertExactKeys(value, UNIT_KEYS, name); + assertCanonicalSymbol(value.symbol, `${name}.symbol`); + assertEnum(value.timeframe, TIMEFRAMES, `${name}.timeframe`); + assertEnum(value.status, UNIT_STATUSES, `${name}.status`); + assertString(value.reason, `${name}.reason`); + assertInteger(value.last_processed_open_ms, `${name}.last_processed_open_ms`, { nullable: true }); + assertInteger(value.last_data_at_ms, `${name}.last_data_at_ms`, { nullable: true }); + if (value.last_event_id !== null && (typeof value.last_event_id !== "string" || !EVENT_ID_PATTERN.test(value.last_event_id))) { + throw new TypeError(`${name}.last_event_id must be null or a lowercase hexadecimal event id`); + } + } + 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"); + 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); + assertExactKeys(value.delivery_counts, DELIVERY_COUNT_KEYS, "status.delivery_counts"); + for (const key of DELIVERY_COUNT_KEYS) { + assertInteger(value.delivery_counts[key], `status.delivery_counts.${key}`); + } + return value; + } + function expectedSignalSide(direction, signalType) { + if (signalType === "reversal") return direction === "bearish" ? "long" : "short"; + return direction === "bearish" ? "short" : "long"; + } + function validateEvent(value, index) { + const name = `events.events[${index}]`; + assertExactKeys(value, EVENT_KEYS, name); + assertInteger(value.sequence, `${name}.sequence`, { minimum: 1 }); + if (typeof value.event_id !== "string" || !EVENT_ID_PATTERN.test(value.event_id)) { + throw new TypeError(`${name}.event_id must be a lowercase hexadecimal event id`); + } + assertSchema(value.schema_version, `${name}.schema_version`); + if (value.strategy_id !== "29") throw new TypeError(`${name}.strategy_id must equal 29`); + if (value.spec_version !== STRATEGY29_SPEC_VERSION) { + throw new TypeError(`${name}.spec_version must equal ${STRATEGY29_SPEC_VERSION}`); + } + assertCanonicalSymbol(value.symbol, `${name}.symbol`); + assertEnum(value.timeframe, TIMEFRAMES, `${name}.timeframe`); + assertEnum(value.setup_direction, DIRECTIONS, `${name}.setup_direction`); + assertEnum(value.signal_type, SIGNAL_TYPES, `${name}.signal_type`); + assertEnum(value.signal_side, SIGNAL_SIDES, `${name}.signal_side`); + if (value.signal_side !== expectedSignalSide(value.setup_direction, value.signal_type)) { + throw new TypeError(`${name}.signal_side does not match direction and signal type`); + } + for (const field of ["setup_open_ms", "bar_open_ms", "bar_close_ms", "detected_at_ms", "warning_open_ms"]) { + assertInteger(value[field], `${name}.${field}`); + } + if (value.bar_close_ms <= value.bar_open_ms) throw new TypeError(`${name}.bar_close_ms must follow bar_open_ms`); + for (const field of ["close_price", "marker_price", "warning_high", "warning_low"]) { + assertFiniteNumber(value[field], `${name}.${field}`); + } + if (value.warning_high < value.warning_low) throw new TypeError(`${name}.warning_high must not be below warning_low`); + assertEnum(value.origin, ORIGINS, `${name}.origin`); + assertEnum(value.delivery_state, DELIVERY_STATES, `${name}.delivery_state`, { nullable: true }); + assertString(value.delivery_failure_reason, `${name}.delivery_failure_reason`, { nullable: true, maximumLength: 512 }); + } + function validateStrategy29EventsResponse(value, httpStatus) { + if (httpStatus !== 200) throw new TypeError(`events response requires HTTP 200, received ${httpStatus}`); + assertExactKeys(value, EVENTS_KEYS, "events response"); + assertSchema(value.schema_version, "events.schema_version"); + if (value.spec_version !== STRATEGY29_SPEC_VERSION) { + throw new TypeError(`events.spec_version must equal ${STRATEGY29_SPEC_VERSION}`); + } + assertInteger(value.observed_at_ms, "events.observed_at_ms"); + assertInteger(value.next_cursor, "events.next_cursor"); + if (typeof value.has_more !== "boolean") throw new TypeError("events.has_more must be boolean"); + if (!Array.isArray(value.events)) throw new TypeError("events.events must be an array"); + if (value.events.length > 200) throw new TypeError("events.events exceeds the 200-event page bound"); + value.events.forEach(validateEvent); + return value; + } + function validateStrategy29GatewayError(value, httpStatus) { + if (httpStatus === 409) { + assertExactKeys(value, ["schema_version", "error", "oldest_cursor"], "gateway error"); + assertSchema(value.schema_version, "gateway error.schema_version"); + if (value.error !== "cursor_expired") throw new TypeError("gateway error.error must equal cursor_expired"); + assertInteger(value.oldest_cursor, "gateway error.oldest_cursor"); + return value; + } + const expected = /* @__PURE__ */ new Map([[400, "invalid_request"], [401, "unauthorized"], [503, "database_unavailable"]]); + if (!expected.has(httpStatus)) throw new TypeError(`unsupported gateway HTTP status ${httpStatus}`); + assertExactKeys(value, ["schema_version", "error"], "gateway error"); + assertSchema(value.schema_version, "gateway error.schema_version"); + if (value.error !== expected.get(httpStatus)) { + throw new TypeError(`gateway error.error must equal ${expected.get(httpStatus)}`); + } + return value; + } + + // src/binance-strategy29-bollinger/core/remote-summary-client.js + var Strategy29GatewayTransportError = class extends Error { + constructor(message) { + super(message); + this.name = "Strategy29GatewayTransportError"; + } + }; + function gatewayAbortError() { + return new DOMException("Strategy29 gateway request aborted", "AbortError"); + } + function normalizeStrategy29GatewayOrigin(value) { + let url; + try { + url = new URL(value); + } catch { + throw new TypeError("Strategy29 gateway must be an explicit loopback origin"); + } + if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || url.port === "" || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") throw new TypeError("Strategy29 gateway must be an explicit loopback origin"); + return url.origin; + } + function parseJsonResponse(response, label) { + if (!response || !Number.isInteger(response.status) || typeof response.responseText !== "string") { + throw new Strategy29GatewayTransportError(`${label} returned an invalid transport response`); + } + try { + return JSON.parse(response.responseText); + } catch { + throw new TypeError(`${label} returned invalid JSON`); + } + } + function assertConfiguration({ request, authSecret, canonicalSymbol, maxPagesPerPoll, onStatus, onEvents, onCursorReset }) { + if (typeof request !== "function") throw new TypeError("request must be a function"); + if (typeof authSecret !== "string" || authSecret.length === 0) throw new TypeError("authSecret must be non-empty"); + if (typeof canonicalSymbol !== "string" || !/^[A-Z0-9]+\/USDT:USDT$/.test(canonicalSymbol)) { + throw new TypeError("canonicalSymbol must use canonical symbol format"); + } + if (!Number.isInteger(maxPagesPerPoll) || maxPagesPerPoll < 1 || maxPagesPerPoll > 10) { + throw new TypeError("maxPagesPerPoll must be between 1 and 10"); + } + for (const [name, callback] of Object.entries({ onStatus, onEvents, onCursorReset })) { + if (typeof callback !== "function") throw new TypeError(`${name} must be a function`); + } + } + function buildEventsUrl(origin, canonicalSymbol, cursor) { + const url = new URL("/v1/strategy29/events", origin); + url.searchParams.set("symbol", canonicalSymbol); + if (cursor !== null) url.searchParams.set("cursor", String(cursor)); + return url.href; + } + function createStrategy29SummaryClient({ + request, + gatewayOrigin, + authSecret, + canonicalSymbol, + maxPagesPerPoll = 2, + onStatus, + onEvents, + onCursorReset + }) { + const origin = normalizeStrategy29GatewayOrigin(gatewayOrigin); + assertConfiguration({ request, authSecret, canonicalSymbol, maxPagesPerPoll, onStatus, onEvents, onCursorReset }); + let cursor = null; + async function perform(url, signal) { + if (!signal || typeof signal.aborted !== "boolean" || typeof signal.addEventListener !== "function") { + throw new TypeError("poll requires an AbortSignal"); + } + if (signal.aborted) throw signal.reason; + return request({ url, authSecret, signal }); + } + async function poll(signal) { + const statusResponse = await perform(`${origin}/v1/strategy29/status`, signal); + const statusBody = parseJsonResponse(statusResponse, "Strategy29 status"); + if (statusResponse.status === 503) { + validateStrategy29GatewayError(statusBody, 503); + return { state: "unavailable", pages: 0, hasMore: false }; + } + if (statusResponse.status !== 200) { + validateStrategy29GatewayError(statusBody, statusResponse.status); + throw new Error(`Strategy29 status request failed with HTTP ${statusResponse.status}`); + } + const status = validateStrategy29StatusResponse(statusBody, 200); + onStatus(status); + if (status.spec_version !== STRATEGY29_SPEC_VERSION) { + return { state: "incompatible", pages: 0, hasMore: false }; + } + let pages = 0; + let hasMore = false; + while (pages < maxPagesPerPoll) { + const requestedCursor = cursor; + const eventsResponse = await perform(buildEventsUrl(origin, canonicalSymbol, cursor), signal); + const eventsBody = parseJsonResponse(eventsResponse, "Strategy29 events"); + pages += 1; + if (eventsResponse.status === 409) { + const error = validateStrategy29GatewayError(eventsBody, 409); + cursor = error.oldest_cursor; + onCursorReset(cursor); + hasMore = true; + continue; + } + if (eventsResponse.status === 503) { + validateStrategy29GatewayError(eventsBody, 503); + return { state: "unavailable", pages, hasMore: false }; + } + if (eventsResponse.status !== 200) { + validateStrategy29GatewayError(eventsBody, eventsResponse.status); + throw new Error(`Strategy29 events request failed with HTTP ${eventsResponse.status}`); + } + const page = validateStrategy29EventsResponse(eventsBody, 200); + if (requestedCursor !== null && page.next_cursor < requestedCursor) { + throw new TypeError("Strategy29 event cursor moved backwards"); + } + if (page.has_more && (requestedCursor === null ? page.next_cursor <= 0 : page.next_cursor <= requestedCursor)) { + throw new TypeError("Strategy29 event cursor did not advance while has_more is true"); + } + let previousSequence = requestedCursor; + for (const event of page.events) { + if (event.symbol !== canonicalSymbol) throw new TypeError("Strategy29 event symbol does not match the requested symbol"); + if (previousSequence !== null && event.sequence <= previousSequence) { + throw new TypeError("Strategy29 event sequences must advance strictly"); + } + if (event.sequence > page.next_cursor) throw new TypeError("Strategy29 event sequence exceeds next_cursor"); + previousSequence = event.sequence; + } + onEvents(page.events, page.observed_at_ms); + cursor = page.next_cursor; + hasMore = page.has_more; + if (!hasMore) break; + } + return { state: "connected", pages, hasMore }; + } + return Object.freeze({ + poll, + get diagnostics() { + return Object.freeze({ cursor }); + } + }); + } + function createStrategy29GmJsonRequest(gmXmlHttpRequest, timeoutMs = 1e4) { + if (typeof gmXmlHttpRequest !== "function") throw new TypeError("GM_xmlhttpRequest must be a function"); + if (!Number.isInteger(timeoutMs) || timeoutMs < 1) throw new TypeError("timeoutMs must be a positive integer"); + return ({ url, authSecret, signal }) => new Promise((resolve, reject) => { + let settled = false; + function finish(callback, value) { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + callback(value); + } + let request; + function onAbort() { + request.abort(); + finish(reject, signal.reason ?? gatewayAbortError()); + } + try { + request = gmXmlHttpRequest({ + method: "GET", + url, + headers: { Authorization: `Bearer ${authSecret}` }, + timeout: timeoutMs, + onload: (response) => finish(resolve, response), + onerror: () => finish(reject, new Strategy29GatewayTransportError("Strategy29 gateway transport failure")), + ontimeout: () => finish(reject, new Strategy29GatewayTransportError("Strategy29 gateway transport timeout")), + onabort: () => finish(reject, signal.reason ?? gatewayAbortError()) + }); + } catch { + finish(reject, new Strategy29GatewayTransportError("Strategy29 gateway transport initialization failed")); + return; + } + if (settled) return; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); + } + // src/binance-strategy29-bollinger/core/bearish-bollinger-pattern.js var BOLLINGER_PATTERN = Object.freeze({ bollingerPeriod: 20, @@ -51,7 +418,7 @@ context.cleanupPending = true; return "fatal"; } - function assertFiniteNumber(value, label) { + function assertFiniteNumber2(value, label) { if (!Number.isFinite(value)) throw new Error(`${label} is invalid`); } function assertBars(bars, directionLabel) { @@ -70,7 +437,7 @@ ); } for (const field of ["open", "high", "low", "close"]) { - assertFiniteNumber(bar[field], `${directionLabel} Bollinger bar ${index} ${field}`); + assertFiniteNumber2(bar[field], `${directionLabel} Bollinger bar ${index} ${field}`); } if (bar.high < bar.low || bar.high < Math.max(bar.open, bar.close) || bar.low > Math.min(bar.open, bar.close)) { throw new TradingViewBarSnapshotInconsistentError( @@ -93,7 +460,7 @@ } for (const field of fields) { if (bar[field] !== null) { - assertFiniteNumber( + assertFiniteNumber2( bar[field], `${directionLabel} Bollinger indicator bar ${index} ${field}` ); @@ -318,9 +685,9 @@ // src/shared/tradingview-target.js var CHART_ROOT_SELECTOR = ".chart-widget-root"; - function hasVisibleBox(element) { - if (!element?.getClientRects().length) return false; - const rect = element.getBoundingClientRect(); + function hasVisibleBox(element2) { + if (!element2?.getClientRects().length) return false; + const rect = element2.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function findBinanceTradingViewTarget(document) { @@ -1250,20 +1617,18 @@ // src/shared/chart-mutation-owners.js var OWNER_SLOT = Symbol.for("jh-userscripts.chart-mutation-owners"); var VERSION = 1; - function owners(view) { - if (view[OWNER_SLOT] === void 0) { - Object.defineProperty(view, OWNER_SLOT, { - value: Object.freeze({ version: VERSION, predicates: /* @__PURE__ */ new Map() }) - }); - } + function existingOwners(view) { const record = view[OWNER_SLOT]; - if (record.version !== VERSION || !(record.predicates instanceof Map)) { + if (record === void 0) return null; + if (typeof view?.Map !== "function" || record.version !== VERSION || !(record.predicates instanceof view.Map)) { throw new Error("Incompatible chart mutation protocol; update both scripts and reload"); } return record.predicates; } function isChartMutationBlocked(view) { - for (const predicate of owners(view).values()) { + const registry = existingOwners(view); + if (registry === null) return false; + for (const predicate of registry.values()) { const blocked = predicate(); if (typeof blocked !== "boolean") throw new Error("Chart mutation owner must return a boolean"); if (blocked) return true; @@ -1328,16 +1693,447 @@ }; } + // src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js + var PANEL_ID = "jh-strategy29-summary-panel"; + var STATE_COLORS = Object.freeze({ + connected: "#0ECB81", + connecting: "#F0B90B", + unavailable: "#F0B90B", + disconnected: "#F6465D", + stopped: "#F6465D", + incompatible: "#F6465D", + configuration_required: "#F0B90B" + }); + var STATUS_COLORS = Object.freeze({ + ready: "#0ECB81", + warming: "#F0B90B", + stale: "#F6465D", + insufficient_history: "#F0B90B", + data_gap: "#F6465D", + failed: "#F6465D" + }); + var TYPE_LABELS = Object.freeze({ + "bearish:warning": "Bearish warning", + "bearish:confirmed": "Bearish confirmed", + "bearish:reversal": "Long reversal", + "bullish:warning": "Bullish warning", + "bullish:confirmed": "Bullish confirmed", + "bullish:reversal": "Short reversal" + }); + var CLOCK_FORMATTER = new Intl.DateTimeFormat("en-GB", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZone: "Asia/Shanghai" + }); + function element(document, tagName, { text = "", role = null, styles = null } = {}) { + const node = document.createElement(tagName); + node.textContent = text; + if (role) node.dataset.role = role; + if (styles) Object.assign(node.style, styles); + return node; + } + function formatClock(timestampMs) { + const date = new Date(timestampMs); + const parts = CLOCK_FORMATTER.formatToParts(date); + const part = (name) => parts.find((item) => item.type === name)?.value; + return `${part("month")}-${part("day")} ${part("hour")}:${part("minute")}:${part("second")} UTC+08`; + } + function signalLabel(event) { + return TYPE_LABELS[`${event.setup_direction}:${event.signal_type}`]; + } + function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEvents = 20 } = {}) { + if (!document?.body) throw new Error("Strategy 29 summary panel requires document.body"); + if (typeof canonicalSymbol !== "string" || canonicalSymbol.length === 0) throw new Error("Strategy 29 panel symbol is invalid"); + if (!Number.isInteger(maxEvents) || maxEvents < 1 || maxEvents > 100) throw new Error("Strategy 29 panel maxEvents is invalid"); + document.getElementById(PANEL_ID)?.remove(); + const panel = element(document, "section", { + styles: { + position: "fixed", + zIndex: "999995", + top: "68px", + right: "84px", + width: "340px", + boxSizing: "border-box", + maxWidth: "calc(100vw - 112px)", + maxHeight: "calc(100vh - 92px)", + overflow: "hidden", + border: "1px solid rgba(132,142,156,.30)", + borderRadius: "9px", + background: "rgba(24,26,32,.96)", + boxShadow: "0 5px 18px rgba(0,0,0,.30)", + color: "#EAECEF", + font: "12px/17px BinancePlex,ui-sans-serif,system-ui,sans-serif", + pointerEvents: "auto", + userSelect: "none" + } + }); + panel.id = PANEL_ID; + const header = element(document, "header", { + styles: { display: "flex", alignItems: "center", gap: "7px", padding: "8px 10px", borderBottom: "1px solid rgba(132,142,156,.20)" } + }); + header.appendChild(element(document, "strong", { text: "Strategy 29 Summary", styles: { flex: "1", fontSize: "13px" } })); + const collapse = element(document, "button", { + text: "Collapse", + role: "collapse", + styles: { border: "0", borderRadius: "5px", padding: "2px 7px", background: "rgba(132,142,156,.18)", color: "#EAECEF", cursor: "pointer" } + }); + collapse.type = "button"; + header.appendChild(collapse); + const body = element(document, "div", { role: "body", styles: { maxHeight: "calc(100vh - 150px)", overflow: "auto" } }); + 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 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 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" } }); + 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" } }); + const events = element(document, "div", { role: "events", styles: { display: "grid", gap: "3px", padding: "0 7px 8px" } }); + body.append(overview, unitsTitle, units, delivery, eventsTitle, events); + panel.append(header, body); + document.body.appendChild(panel); + const eventRecords = /* @__PURE__ */ new Map(); + let destroyed = false; + function assertLive() { + if (destroyed) throw new Error("Strategy 29 summary panel is destroyed"); + } + function renderEvents() { + events.replaceChildren(); + const ordered = [...eventRecords.values()].sort((left, right) => right.detected_at_ms - left.detected_at_ms || right.sequence - left.sequence); + for (const event of ordered) { + const row = element(document, "div", { + role: "remote-event", + styles: { display: "grid", gridTemplateColumns: "36px minmax(0,1fr) 116px", gap: "6px", alignItems: "center", padding: "5px 6px", borderRadius: "5px", background: "rgba(132,142,156,.08)" } + }); + row.dataset.eventId = event.event_id; + row.appendChild(element(document, "strong", { text: event.timeframe, styles: { color: "#F0B90B" } })); + row.appendChild(element(document, "span", { + text: signalLabel(event), + styles: { color: event.signal_side === "long" ? "#0ECB81" : "#F6465D", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } + })); + row.appendChild(element(document, "span", { text: `Close ${formatClock(event.bar_close_ms)}`, styles: { color: "#848E9C", fontSize: "10px", textAlign: "right" } })); + events.appendChild(row); + } + if (ordered.length === 0) events.appendChild(element(document, "span", { text: "No recent signals", styles: { color: "#848E9C", padding: "4px" } })); + } + collapse.addEventListener("click", () => { + const collapsed = body.style.display !== "none"; + body.style.display = collapsed ? "none" : "block"; + collapse.textContent = collapsed ? "Expand" : "Collapse"; + }); + renderEvents(); + return Object.freeze({ + setConnection(state, message) { + assertLive(); + if (!(state in STATE_COLORS) || typeof message !== "string") throw new Error("Strategy 29 panel connection state is invalid"); + connection.dataset.state = state; + connection.style.color = STATE_COLORS[state]; + connection.textContent = message; + }, + renderStatus(snapshot) { + assertLive(); + const matched = snapshot.spec_version === STRATEGY29_SPEC_VERSION; + spec.dataset.state = matched ? "matched" : "error"; + 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)}`; + units.replaceChildren(); + const matching = snapshot.units.filter((unit) => unit.symbol === canonicalSymbol); + for (const unit of matching) { + const row = element(document, "div", { + role: "unit", + 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.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" } })); + 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}`; + }, + addEvents(incoming, observedAtMs = null) { + assertLive(); + for (const event of incoming) eventRecords.set(event.event_id, event); + const ordered = [...eventRecords.values()].sort((left, right) => right.detected_at_ms - left.detected_at_ms || right.sequence - left.sequence); + while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id); + if (observedAtMs !== null) eventsFreshness.textContent = `Events checked ${formatClock(observedAtMs)}`; + renderEvents(); + }, + clearEvents() { + assertLive(); + eventRecords.clear(); + renderEvents(); + }, + destroy() { + if (destroyed) return; + destroyed = true; + eventRecords.clear(); + panel.remove(); + }, + get size() { + return eventRecords.size; + } + }); + } + + // src/binance-strategy29-bollinger/remote-summary.js + var STRATEGY29_REMOTE_ENABLED_KEY = "strategy29RemoteSummaryEnabled"; + var STRATEGY29_GATEWAY_ORIGIN_KEY = "strategy29GatewayOrigin"; + var STRATEGY29_GATEWAY_SECRET_KEY = "strategy29GatewayAuthSecret"; + var STRATEGY29_DEFAULT_GATEWAY_ORIGIN = "http://127.0.0.1:8729"; + var STRATEGY29_REMOTE_POLL_INTERVAL_MS = 5e3; + function abortError(view, message) { + const ErrorConstructor = view.DOMException ?? DOMException; + return new ErrorConstructor(message, "AbortError"); + } + function assertAdapters({ view, request, getValue, setValue, registerMenuCommand, promptUser: promptUser2, createPanel, createClient }) { + if (!view?.document || !view?.location) throw new TypeError("Strategy 29 remote summary requires a page window"); + for (const [name, value] of Object.entries({ + request, + getValue, + setValue, + registerMenuCommand, + promptUser: promptUser2, + createPanel, + createClient + })) { + if (typeof value !== "function") throw new TypeError(`Strategy 29 remote summary ${name} is invalid`); + } + } + function createStrategy29RemoteSummary({ + view, + request, + getValue, + setValue, + registerMenuCommand, + promptUser: promptUser2, + createPanel = createStrategy29SummaryPanel, + createClient = createStrategy29SummaryClient, + pollIntervalMs = STRATEGY29_REMOTE_POLL_INTERVAL_MS + }) { + assertAdapters({ view, request, getValue, setValue, registerMenuCommand, promptUser: promptUser2, createPanel, createClient }); + if (!Number.isInteger(pollIntervalMs) || pollIntervalMs < 1e3) throw new TypeError("Strategy 29 remote poll interval is invalid"); + let enabled = getValue(STRATEGY29_REMOTE_ENABLED_KEY, false) === true; + let active = null; + let disposed = false; + let unsupportedRoute = null; + function isCurrent(context) { + return !disposed && active === context && !context.abortController.signal.aborted; + } + function stopActive(reason = "Strategy 29 remote context retired") { + if (!active) return; + const context = active; + active = null; + context.abortController.abort(abortError(view, reason)); + context.panel.destroy(); + } + function configuredSettings() { + const authSecret = getValue(STRATEGY29_GATEWAY_SECRET_KEY, ""); + if (typeof authSecret !== "string") throw new TypeError("Strategy 29 gateway secret storage is invalid"); + const gatewayOrigin = normalizeStrategy29GatewayOrigin( + getValue(STRATEGY29_GATEWAY_ORIGIN_KEY, STRATEGY29_DEFAULT_GATEWAY_ORIGIN) + ); + return { authSecret, gatewayOrigin }; + } + function startContext(routeSymbol) { + const canonicalSymbol = routeSymbolToCanonical(routeSymbol); + const panel = createPanel(view.document, canonicalSymbol, { maxEvents: 20 }); + const AbortControllerConstructor = view.AbortController ?? AbortController; + const context = { + routeSymbol, + canonicalSymbol, + gatewayOrigin: null, + panel, + abortController: new AbortControllerConstructor(), + client: null, + inFlight: false, + failed: false, + nextPollAtMs: 0, + state: "idle", + lastError: null, + lastResult: null + }; + active = context; + let settings; + try { + settings = configuredSettings(); + context.gatewayOrigin = settings.gatewayOrigin; + } catch (error) { + context.failed = true; + context.state = "stopped"; + context.lastError = error.message; + panel.setConnection("stopped", `Remote summary stopped: ${error.message}`); + view.console.warn("[Strategy29 remote]", error.message); + return context; + } + const { authSecret, gatewayOrigin } = settings; + if (authSecret.length === 0) { + context.state = "configuration_required"; + panel.setConnection("configuration_required", "Gateway secret is not configured"); + return context; + } + try { + context.client = createClient({ + request, + gatewayOrigin, + authSecret, + canonicalSymbol, + maxPagesPerPoll: 2, + onStatus: (snapshot) => { + if (isCurrent(context)) context.panel.renderStatus(snapshot); + }, + onEvents: (events, observedAtMs) => { + if (isCurrent(context)) context.panel.addEvents(events, observedAtMs); + }, + onCursorReset: () => { + if (isCurrent(context)) context.panel.clearEvents(); + } + }); + } catch (error) { + context.failed = true; + context.state = "stopped"; + context.lastError = error.message; + panel.setConnection("stopped", `Remote summary stopped: ${error.message}`); + view.console.warn("[Strategy29 remote]", error.message); + } + return context; + } + function synchronizeContext() { + if (!enabled || !view.document.body) { + unsupportedRoute = null; + stopActive("Strategy 29 remote summary disabled"); + return null; + } + const routeSymbol = parseFuturesTradingSymbolFromPathname(view.location.pathname); + if (!routeSymbol) { + unsupportedRoute = null; + stopActive("Strategy 29 route changed"); + return null; + } + if (active?.routeSymbol === routeSymbol) return active; + if (unsupportedRoute === routeSymbol) return null; + unsupportedRoute = null; + stopActive("Strategy 29 route changed"); + try { + return startContext(routeSymbol); + } catch (error) { + unsupportedRoute = routeSymbol; + stopActive("Strategy 29 remote context initialization failed"); + view.console.warn("[Strategy29 remote]", error.message); + return null; + } + } + function sample(nowMs = Date.now()) { + if (disposed) return; + const context = synchronizeContext(); + if (!context || !context.client || context.inFlight || context.failed || nowMs < context.nextPollAtMs) return; + context.nextPollAtMs = nowMs + pollIntervalMs; + context.inFlight = true; + context.state = "connecting"; + context.panel.setConnection("connecting", "Connecting to Strategy 29 gateway"); + return context.client.poll(context.abortController.signal).then((result) => { + if (!isCurrent(context)) return; + context.lastResult = result; + context.lastError = null; + context.state = result.state; + const presentation = { + connected: ["connected", result.hasMore ? "Connected · more history pending" : "Connected"], + unavailable: ["unavailable", "Gateway database unavailable"], + incompatible: ["incompatible", "Server and local specs are incompatible"] + }[result.state]; + if (!presentation) throw new Error(`Strategy 29 remote state is invalid: ${result.state}`); + context.panel.setConnection(...presentation); + }).catch((error) => { + if (!isCurrent(context) || error?.name === "AbortError") return; + context.lastError = error.message; + if (error instanceof Strategy29GatewayTransportError) { + context.state = "disconnected"; + context.panel.setConnection("disconnected", "Gateway connection failed; next scheduled poll will retry"); + } else { + context.state = "stopped"; + context.failed = true; + context.panel.setConnection("stopped", `Remote summary stopped: ${error.message}`); + } + view.console.warn("[Strategy29 remote]", error.message); + }).finally(() => { + context.inFlight = false; + }); + } + function restart() { + unsupportedRoute = null; + stopActive("Strategy 29 remote settings changed"); + if (!disposed) void sample(Date.now()); + } + registerMenuCommand("Toggle Strategy 29 cross-timeframe summary", () => { + enabled = !enabled; + setValue(STRATEGY29_REMOTE_ENABLED_KEY, enabled); + restart(); + }); + registerMenuCommand("Set Strategy 29 gateway secret", () => { + const value = promptUser2("Enter the local Strategy 29 gateway secret. It is stored only in this userscript storage."); + if (value === null) return; + if (value.length === 0) throw new Error("Strategy 29 gateway secret cannot be empty"); + setValue(STRATEGY29_GATEWAY_SECRET_KEY, value); + restart(); + }); + registerMenuCommand("Set Strategy 29 gateway origin", () => { + const current = getValue(STRATEGY29_GATEWAY_ORIGIN_KEY, STRATEGY29_DEFAULT_GATEWAY_ORIGIN); + const value = promptUser2("Enter the loopback gateway origin (http://127.0.0.1:)", current); + if (value === null) return; + setValue(STRATEGY29_GATEWAY_ORIGIN_KEY, normalizeStrategy29GatewayOrigin(value)); + restart(); + }); + return Object.freeze({ + sample, + pause() { + stopActive("Strategy 29 remote summary paused"); + }, + restart, + dispose() { + if (disposed) return; + disposed = true; + stopActive("Strategy 29 remote summary disposed"); + }, + get diagnostics() { + return Object.freeze({ + enabled, + contextPresent: active !== null, + canonicalSymbol: active?.canonicalSymbol ?? null, + gatewayOrigin: active?.gatewayOrigin ?? null, + state: active?.state ?? (unsupportedRoute ? "unsupported_route" : enabled ? "waiting_for_route" : "disabled"), + inFlight: active?.inFlight ?? false, + stopped: active?.failed ?? false, + lastError: active?.lastError ?? null, + lastResult: active?.lastResult ?? null, + cursor: active?.client?.diagnostics.cursor ?? null, + specVersion: STRATEGY29_SPEC_VERSION, + referenceSha256: STRATEGY29_REFERENCE_SHA256 + }); + } + }); + } + // src/binance-strategy29-bollinger/runtime.js var INSTANCE = Symbol.for("jh-userscripts.strategy29-bollinger"); + var RUNTIME_VERSION = 2; var CONFLICT = "Strategy 29 stopped: update Orderbook to 2.7.199 or disable its embedded Bollinger version, then reload this page."; function hasEmbeddedBollinger(view) { const debug = view.__TM_CLOSE_LONG_DEBUG__; return !!debug && Object.getOwnPropertyDescriptor(debug, "bollingerAlertState") !== void 0; } - function installStrategy29(view) { + function installStrategy29(view, remoteAdapters = null) { if (view[INSTANCE] !== void 0) { - if (view[INSTANCE].version !== 1) throw new Error("Incompatible Strategy 29 runtime; reload the page"); + if (view[INSTANCE].version !== RUNTIME_VERSION) throw new Error("Incompatible Strategy 29 runtime; reload the page"); return view[INSTANCE].runtime; } const document = view.document; @@ -1345,6 +2141,7 @@ let failed = null; let disposed = false; let removeRouteListener = null; + const remoteSummary = remoteAdapters === null ? null : createStrategy29RemoteSummary({ view, ...remoteAdapters }); const noticeId = "jh-strategy29-bollinger-status"; function showFailure() { if (!failed || !document.body) return; @@ -1370,6 +2167,7 @@ if (timer !== null) view.clearInterval(timer); timer = null; monitor.stop(); + remoteSummary?.pause(); } function fail(message) { failed = message; @@ -1383,6 +2181,7 @@ return; } ensureSpaRouteChangePatched(view); + void remoteSummary?.sample(Date.now()); if (!isFuturesTradingPathname(view.location.pathname)) { monitor.stop(); return; @@ -1407,12 +2206,19 @@ } const runtime = Object.freeze({ get diagnostics() { - return { ...monitor.diagnostics, runtimeFailure: failed, disposed, timerRunning: timer !== null }; + return { + ...monitor.diagnostics, + runtimeFailure: failed, + disposed, + timerRunning: timer !== null, + remoteSummary: remoteSummary?.diagnostics ?? Object.freeze({ enabled: false, state: "unavailable_in_this_installation" }) + }; }, dispose() { if (disposed) return; disposed = true; pause(); + remoteSummary?.dispose(); removeRouteListener(); document.removeEventListener("visibilitychange", onVisibility); document.removeEventListener("DOMContentLoaded", showFailure); @@ -1421,7 +2227,7 @@ document.getElementById(noticeId)?.remove(); } }); - Object.defineProperty(view, INSTANCE, { value: Object.freeze({ version: 1, runtime }) }); + Object.defineProperty(view, INSTANCE, { value: Object.freeze({ version: RUNTIME_VERSION, runtime }) }); Object.defineProperty(view, "__TM_STRATEGY29_DEBUG__", { value: runtime }); removeRouteListener = installSpaRouteChangeListener(view, sample); document.addEventListener("visibilitychange", onVisibility); @@ -1433,5 +2239,12 @@ } // src/binance-strategy29-bollinger/index.user.js - installStrategy29(window); + var promptUser = globalThis.prompt.bind(globalThis); + installStrategy29(unsafeWindow, { + request: createStrategy29GmJsonRequest(GM_xmlhttpRequest), + getValue: GM_getValue, + setValue: GM_setValue, + registerMenuCommand: GM_registerMenuCommand, + promptUser + }); })(); diff --git a/src/binance-strategy29-bollinger/core/remote-summary-client.js b/src/binance-strategy29-bollinger/core/remote-summary-client.js new file mode 100644 index 0000000..47d5e61 --- /dev/null +++ b/src/binance-strategy29-bollinger/core/remote-summary-client.js @@ -0,0 +1,199 @@ +import { + STRATEGY29_SPEC_VERSION, + validateStrategy29EventsResponse, + validateStrategy29GatewayError, + validateStrategy29StatusResponse, +} from './remote-summary-contract.js'; + +export class Strategy29GatewayTransportError extends Error { + constructor(message) { + super(message); + this.name = 'Strategy29GatewayTransportError'; + } +} + +function gatewayAbortError() { + return new DOMException('Strategy29 gateway request aborted', 'AbortError'); +} + +export function normalizeStrategy29GatewayOrigin(value) { + let url; + try { + url = new URL(value); + } catch { + throw new TypeError('Strategy29 gateway must be an explicit loopback origin'); + } + if ( + url.protocol !== 'http:' + || url.hostname !== '127.0.0.1' + || url.port === '' + || url.username !== '' + || url.password !== '' + || url.pathname !== '/' + || url.search !== '' + || url.hash !== '' + ) throw new TypeError('Strategy29 gateway must be an explicit loopback origin'); + return url.origin; +} + +function parseJsonResponse(response, label) { + if (!response || !Number.isInteger(response.status) || typeof response.responseText !== 'string') { + throw new Strategy29GatewayTransportError(`${label} returned an invalid transport response`); + } + try { + return JSON.parse(response.responseText); + } catch { + throw new TypeError(`${label} returned invalid JSON`); + } +} + +function assertConfiguration({ request, authSecret, canonicalSymbol, maxPagesPerPoll, onStatus, onEvents, onCursorReset }) { + if (typeof request !== 'function') throw new TypeError('request must be a function'); + if (typeof authSecret !== 'string' || authSecret.length === 0) throw new TypeError('authSecret must be non-empty'); + if (typeof canonicalSymbol !== 'string' || !/^[A-Z0-9]+\/USDT:USDT$/.test(canonicalSymbol)) { + throw new TypeError('canonicalSymbol must use canonical symbol format'); + } + if (!Number.isInteger(maxPagesPerPoll) || maxPagesPerPoll < 1 || maxPagesPerPoll > 10) { + throw new TypeError('maxPagesPerPoll must be between 1 and 10'); + } + for (const [name, callback] of Object.entries({ onStatus, onEvents, onCursorReset })) { + if (typeof callback !== 'function') throw new TypeError(`${name} must be a function`); + } +} + +function buildEventsUrl(origin, canonicalSymbol, cursor) { + const url = new URL('/v1/strategy29/events', origin); + url.searchParams.set('symbol', canonicalSymbol); + if (cursor !== null) url.searchParams.set('cursor', String(cursor)); + return url.href; +} + +/** Bounded snapshot consumer. Filtered pages advance the global cursor even when events is empty. */ +export function createStrategy29SummaryClient({ + request, + gatewayOrigin, + authSecret, + canonicalSymbol, + maxPagesPerPoll = 2, + onStatus, + onEvents, + onCursorReset, +}) { + const origin = normalizeStrategy29GatewayOrigin(gatewayOrigin); + assertConfiguration({ request, authSecret, canonicalSymbol, maxPagesPerPoll, onStatus, onEvents, onCursorReset }); + let cursor = null; + + async function perform(url, signal) { + if (!signal || typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function') { + throw new TypeError('poll requires an AbortSignal'); + } + if (signal.aborted) throw signal.reason; + return request({ url, authSecret, signal }); + } + + async function poll(signal) { + const statusResponse = await perform(`${origin}/v1/strategy29/status`, signal); + const statusBody = parseJsonResponse(statusResponse, 'Strategy29 status'); + if (statusResponse.status === 503) { + validateStrategy29GatewayError(statusBody, 503); + return { state: 'unavailable', pages: 0, hasMore: false }; + } + if (statusResponse.status !== 200) { + validateStrategy29GatewayError(statusBody, statusResponse.status); + throw new Error(`Strategy29 status request failed with HTTP ${statusResponse.status}`); + } + const status = validateStrategy29StatusResponse(statusBody, 200); + onStatus(status); + if (status.spec_version !== STRATEGY29_SPEC_VERSION) { + return { state: 'incompatible', pages: 0, hasMore: false }; + } + + let pages = 0; + let hasMore = false; + while (pages < maxPagesPerPoll) { + const requestedCursor = cursor; + const eventsResponse = await perform(buildEventsUrl(origin, canonicalSymbol, cursor), signal); + const eventsBody = parseJsonResponse(eventsResponse, 'Strategy29 events'); + pages += 1; + if (eventsResponse.status === 409) { + const error = validateStrategy29GatewayError(eventsBody, 409); + cursor = error.oldest_cursor; + onCursorReset(cursor); + hasMore = true; + continue; + } + if (eventsResponse.status === 503) { + validateStrategy29GatewayError(eventsBody, 503); + return { state: 'unavailable', pages, hasMore: false }; + } + if (eventsResponse.status !== 200) { + validateStrategy29GatewayError(eventsBody, eventsResponse.status); + throw new Error(`Strategy29 events request failed with HTTP ${eventsResponse.status}`); + } + const page = validateStrategy29EventsResponse(eventsBody, 200); + if (requestedCursor !== null && page.next_cursor < requestedCursor) { + throw new TypeError('Strategy29 event cursor moved backwards'); + } + if (page.has_more && (requestedCursor === null ? page.next_cursor <= 0 : page.next_cursor <= requestedCursor)) { + throw new TypeError('Strategy29 event cursor did not advance while has_more is true'); + } + let previousSequence = requestedCursor; + for (const event of page.events) { + if (event.symbol !== canonicalSymbol) throw new TypeError('Strategy29 event symbol does not match the requested symbol'); + if (previousSequence !== null && event.sequence <= previousSequence) { + throw new TypeError('Strategy29 event sequences must advance strictly'); + } + if (event.sequence > page.next_cursor) throw new TypeError('Strategy29 event sequence exceeds next_cursor'); + previousSequence = event.sequence; + } + onEvents(page.events, page.observed_at_ms); + cursor = page.next_cursor; + hasMore = page.has_more; + if (!hasMore) break; + } + return { state: 'connected', pages, hasMore }; + } + + return Object.freeze({ + poll, + get diagnostics() { return Object.freeze({ cursor }); }, + }); +} + +/** Userscript-sandbox transport. Abort is part of route/visibility/dispose ownership. */ +export function createStrategy29GmJsonRequest(gmXmlHttpRequest, timeoutMs = 10_000) { + if (typeof gmXmlHttpRequest !== 'function') throw new TypeError('GM_xmlhttpRequest must be a function'); + if (!Number.isInteger(timeoutMs) || timeoutMs < 1) throw new TypeError('timeoutMs must be a positive integer'); + return ({ url, authSecret, signal }) => new Promise((resolve, reject) => { + let settled = false; + function finish(callback, value) { + if (settled) return; + settled = true; + signal.removeEventListener('abort', onAbort); + callback(value); + } + let request; + function onAbort() { + request.abort(); + finish(reject, signal.reason ?? gatewayAbortError()); + } + try { + request = gmXmlHttpRequest({ + method: 'GET', + url, + headers: { Authorization: `Bearer ${authSecret}` }, + timeout: timeoutMs, + onload: response => finish(resolve, response), + onerror: () => finish(reject, new Strategy29GatewayTransportError('Strategy29 gateway transport failure')), + ontimeout: () => finish(reject, new Strategy29GatewayTransportError('Strategy29 gateway transport timeout')), + onabort: () => finish(reject, signal.reason ?? gatewayAbortError()), + }); + } catch { + finish(reject, new Strategy29GatewayTransportError('Strategy29 gateway transport initialization failed')); + return; + } + if (settled) return; + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); +} diff --git a/src/binance-strategy29-bollinger/core/remote-summary-contract.js b/src/binance-strategy29-bollinger/core/remote-summary-contract.js new file mode 100644 index 0000000..e53c301 --- /dev/null +++ b/src/binance-strategy29-bollinger/core/remote-summary-contract.js @@ -0,0 +1,185 @@ +export const STRATEGY29_SCHEMA_VERSION = 1; +export const STRATEGY29_SPEC_VERSION = '29_2_spec_v1'; +export const STRATEGY29_REFERENCE_SHA256 = 'eece8cf16e58340910587962f3bfbb19acb72155c09a52b4b6c0570cc979ef8d'; + +const TIMEFRAMES = new Set(['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', '1d', '1w']); +const UNIT_STATUSES = new Set(['warming', 'ready', 'stale', 'insufficient_history', 'data_gap', 'failed']); +const DIRECTIONS = new Set(['bearish', 'bullish']); +const SIGNAL_TYPES = new Set(['warning', 'confirmed', 'reversal']); +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 UNIT_KEYS = [ + 'symbol', 'timeframe', 'status', 'reason', 'last_processed_open_ms', 'last_data_at_ms', 'last_event_id', +]; +const EVENTS_KEYS = ['schema_version', 'spec_version', 'observed_at_ms', 'next_cursor', 'has_more', 'events']; +const EVENT_KEYS = [ + 'sequence', 'event_id', 'schema_version', 'strategy_id', 'spec_version', 'symbol', 'timeframe', + 'setup_direction', 'signal_type', 'signal_side', 'setup_open_ms', 'bar_open_ms', 'bar_close_ms', + 'detected_at_ms', 'close_price', 'marker_price', 'warning_open_ms', 'warning_high', 'warning_low', + 'origin', 'delivery_state', 'delivery_failure_reason', +]; +const EVENT_ID_PATTERN = /^[0-9a-f]{64}$/; +const ROUTE_SYMBOL_PATTERN = /^([A-Z0-9]+)USDT$/; +const CANONICAL_SYMBOL_PATTERN = /^([A-Z0-9]+)\/USDT:USDT$/; + +function assertObject(value, name) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${name} must be an object`); +} + +function assertExactKeys(value, keys, name) { + assertObject(value, name); + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new TypeError(`${name} must contain exact keys: ${expected.join(', ')}`); + } +} + +function assertInteger(value, name, { nullable = false, minimum = 0 } = {}) { + if (nullable && value === null) return; + if (!Number.isSafeInteger(value) || value < minimum) throw new TypeError(`${name} must be an integer >= ${minimum}`); +} + +function assertString(value, name, { nullable = false, maximumLength = 256 } = {}) { + if (nullable && value === null) return; + if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${name} must be a non-empty string`); + if (value.length > maximumLength) throw new TypeError(`${name} exceeds ${maximumLength} characters`); +} + +function assertEnum(value, allowed, name, { nullable = false } = {}) { + if (nullable && value === null) return; + if (!allowed.has(value)) throw new TypeError(`${name} is invalid`); +} + +function assertFiniteNumber(value, name) { + if (!Number.isFinite(value)) throw new TypeError(`${name} must be a finite number`); +} + +function assertSchema(value, name) { + if (value !== STRATEGY29_SCHEMA_VERSION) throw new TypeError(`${name} must equal ${STRATEGY29_SCHEMA_VERSION}`); +} + +function assertCanonicalSymbol(value, name) { + if (typeof value !== 'string' || !CANONICAL_SYMBOL_PATTERN.test(value)) { + throw new TypeError(`${name} must use canonical symbol format`); + } +} + +export function routeSymbolToCanonical(value) { + if (typeof value !== 'string') throw new TypeError('route symbol must be a string'); + const match = ROUTE_SYMBOL_PATTERN.exec(value); + if (!match || match[1] === '') throw new TypeError('route symbol must end in USDT and use uppercase canonical route syntax'); + return `${match[1]}/USDT:USDT`; +} + +export function canonicalSymbolToRoute(value) { + if (typeof value !== 'string') throw new TypeError('canonical symbol must be a string'); + const match = CANONICAL_SYMBOL_PATTERN.exec(value); + if (!match || match[1] === '') throw new TypeError('canonical symbol must use BASE/USDT:USDT syntax'); + return `${match[1]}USDT`; +} + +function validateUnit(value, index) { + const name = `status.units[${index}]`; + assertExactKeys(value, UNIT_KEYS, name); + assertCanonicalSymbol(value.symbol, `${name}.symbol`); + assertEnum(value.timeframe, TIMEFRAMES, `${name}.timeframe`); + assertEnum(value.status, UNIT_STATUSES, `${name}.status`); + assertString(value.reason, `${name}.reason`); + assertInteger(value.last_processed_open_ms, `${name}.last_processed_open_ms`, { nullable: true }); + assertInteger(value.last_data_at_ms, `${name}.last_data_at_ms`, { nullable: true }); + if (value.last_event_id !== null && (typeof value.last_event_id !== 'string' || !EVENT_ID_PATTERN.test(value.last_event_id))) { + throw new TypeError(`${name}.last_event_id must be null or a lowercase hexadecimal event id`); + } +} + +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'); + 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); + assertExactKeys(value.delivery_counts, DELIVERY_COUNT_KEYS, 'status.delivery_counts'); + for (const key of DELIVERY_COUNT_KEYS) { + assertInteger(value.delivery_counts[key], `status.delivery_counts.${key}`); + } + return value; +} + +function expectedSignalSide(direction, signalType) { + if (signalType === 'reversal') return direction === 'bearish' ? 'long' : 'short'; + return direction === 'bearish' ? 'short' : 'long'; +} + +function validateEvent(value, index) { + const name = `events.events[${index}]`; + assertExactKeys(value, EVENT_KEYS, name); + assertInteger(value.sequence, `${name}.sequence`, { minimum: 1 }); + if (typeof value.event_id !== 'string' || !EVENT_ID_PATTERN.test(value.event_id)) { + throw new TypeError(`${name}.event_id must be a lowercase hexadecimal event id`); + } + assertSchema(value.schema_version, `${name}.schema_version`); + if (value.strategy_id !== '29') throw new TypeError(`${name}.strategy_id must equal 29`); + if (value.spec_version !== STRATEGY29_SPEC_VERSION) { + throw new TypeError(`${name}.spec_version must equal ${STRATEGY29_SPEC_VERSION}`); + } + assertCanonicalSymbol(value.symbol, `${name}.symbol`); + assertEnum(value.timeframe, TIMEFRAMES, `${name}.timeframe`); + assertEnum(value.setup_direction, DIRECTIONS, `${name}.setup_direction`); + assertEnum(value.signal_type, SIGNAL_TYPES, `${name}.signal_type`); + assertEnum(value.signal_side, SIGNAL_SIDES, `${name}.signal_side`); + if (value.signal_side !== expectedSignalSide(value.setup_direction, value.signal_type)) { + throw new TypeError(`${name}.signal_side does not match direction and signal type`); + } + for (const field of ['setup_open_ms', 'bar_open_ms', 'bar_close_ms', 'detected_at_ms', 'warning_open_ms']) { + assertInteger(value[field], `${name}.${field}`); + } + if (value.bar_close_ms <= value.bar_open_ms) throw new TypeError(`${name}.bar_close_ms must follow bar_open_ms`); + for (const field of ['close_price', 'marker_price', 'warning_high', 'warning_low']) { + assertFiniteNumber(value[field], `${name}.${field}`); + } + if (value.warning_high < value.warning_low) throw new TypeError(`${name}.warning_high must not be below warning_low`); + assertEnum(value.origin, ORIGINS, `${name}.origin`); + assertEnum(value.delivery_state, DELIVERY_STATES, `${name}.delivery_state`, { nullable: true }); + assertString(value.delivery_failure_reason, `${name}.delivery_failure_reason`, { nullable: true, maximumLength: 512 }); +} + +export function validateStrategy29EventsResponse(value, httpStatus) { + if (httpStatus !== 200) throw new TypeError(`events response requires HTTP 200, received ${httpStatus}`); + assertExactKeys(value, EVENTS_KEYS, 'events response'); + assertSchema(value.schema_version, 'events.schema_version'); + if (value.spec_version !== STRATEGY29_SPEC_VERSION) { + throw new TypeError(`events.spec_version must equal ${STRATEGY29_SPEC_VERSION}`); + } + assertInteger(value.observed_at_ms, 'events.observed_at_ms'); + assertInteger(value.next_cursor, 'events.next_cursor'); + if (typeof value.has_more !== 'boolean') throw new TypeError('events.has_more must be boolean'); + if (!Array.isArray(value.events)) throw new TypeError('events.events must be an array'); + if (value.events.length > 200) throw new TypeError('events.events exceeds the 200-event page bound'); + value.events.forEach(validateEvent); + return value; +} + +export function validateStrategy29GatewayError(value, httpStatus) { + if (httpStatus === 409) { + assertExactKeys(value, ['schema_version', 'error', 'oldest_cursor'], 'gateway error'); + assertSchema(value.schema_version, 'gateway error.schema_version'); + if (value.error !== 'cursor_expired') throw new TypeError('gateway error.error must equal cursor_expired'); + assertInteger(value.oldest_cursor, 'gateway error.oldest_cursor'); + return value; + } + const expected = new Map([[400, 'invalid_request'], [401, 'unauthorized'], [503, 'database_unavailable']]); + if (!expected.has(httpStatus)) throw new TypeError(`unsupported gateway HTTP status ${httpStatus}`); + assertExactKeys(value, ['schema_version', 'error'], 'gateway error'); + assertSchema(value.schema_version, 'gateway error.schema_version'); + if (value.error !== expected.get(httpStatus)) { + throw new TypeError(`gateway error.error must equal ${expected.get(httpStatus)}`); + } + return value; +} diff --git a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js new file mode 100644 index 0000000..327f4b9 --- /dev/null +++ b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js @@ -0,0 +1,185 @@ +import { STRATEGY29_REFERENCE_SHA256, STRATEGY29_SPEC_VERSION } from '../core/remote-summary-contract.js'; + +const PANEL_ID = 'jh-strategy29-summary-panel'; +const STATE_COLORS = Object.freeze({ + connected: '#0ECB81', + connecting: '#F0B90B', + unavailable: '#F0B90B', + disconnected: '#F6465D', + stopped: '#F6465D', + incompatible: '#F6465D', + configuration_required: '#F0B90B', +}); +const STATUS_COLORS = Object.freeze({ + ready: '#0ECB81', + warming: '#F0B90B', + stale: '#F6465D', + insufficient_history: '#F0B90B', + data_gap: '#F6465D', + failed: '#F6465D', +}); +const TYPE_LABELS = Object.freeze({ + 'bearish:warning': 'Bearish warning', + 'bearish:confirmed': 'Bearish confirmed', + 'bearish:reversal': 'Long reversal', + 'bullish:warning': 'Bullish warning', + 'bullish:confirmed': 'Bullish confirmed', + 'bullish:reversal': 'Short reversal', +}); +const CLOCK_FORMATTER = new Intl.DateTimeFormat('en-GB', { + month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + timeZone: 'Asia/Shanghai', +}); + +function element(document, tagName, { text = '', role = null, styles = null } = {}) { + const node = document.createElement(tagName); + node.textContent = text; + if (role) node.dataset.role = role; + if (styles) Object.assign(node.style, styles); + return node; +} + +function formatClock(timestampMs) { + const date = new Date(timestampMs); + const parts = CLOCK_FORMATTER.formatToParts(date); + const part = name => parts.find(item => item.type === name)?.value; + return `${part('month')}-${part('day')} ${part('hour')}:${part('minute')}:${part('second')} UTC+08`; +} + +function signalLabel(event) { + return TYPE_LABELS[`${event.setup_direction}:${event.signal_type}`]; +} + +export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEvents = 20 } = {}) { + if (!document?.body) throw new Error('Strategy 29 summary panel requires document.body'); + if (typeof canonicalSymbol !== 'string' || canonicalSymbol.length === 0) throw new Error('Strategy 29 panel symbol is invalid'); + if (!Number.isInteger(maxEvents) || maxEvents < 1 || maxEvents > 100) throw new Error('Strategy 29 panel maxEvents is invalid'); + document.getElementById(PANEL_ID)?.remove(); + + const panel = element(document, 'section', { + styles: { + position: 'fixed', zIndex: '999995', top: '68px', right: '84px', width: '340px', + boxSizing: 'border-box', + maxWidth: 'calc(100vw - 112px)', maxHeight: 'calc(100vh - 92px)', overflow: 'hidden', + border: '1px solid rgba(132,142,156,.30)', borderRadius: '9px', + background: 'rgba(24,26,32,.96)', boxShadow: '0 5px 18px rgba(0,0,0,.30)', + color: '#EAECEF', font: '12px/17px BinancePlex,ui-sans-serif,system-ui,sans-serif', + pointerEvents: 'auto', userSelect: 'none', + }, + }); + panel.id = PANEL_ID; + const header = element(document, 'header', { + styles: { display: 'flex', alignItems: 'center', gap: '7px', padding: '8px 10px', borderBottom: '1px solid rgba(132,142,156,.20)' }, + }); + header.appendChild(element(document, 'strong', { text: 'Strategy 29 Summary', styles: { flex: '1', fontSize: '13px' } })); + const collapse = element(document, 'button', { + text: 'Collapse', role: 'collapse', + styles: { border: '0', borderRadius: '5px', padding: '2px 7px', background: 'rgba(132,142,156,.18)', color: '#EAECEF', cursor: 'pointer' }, + }); + collapse.type = 'button'; + header.appendChild(collapse); + const body = element(document, 'div', { role: 'body', styles: { maxHeight: 'calc(100vh - 150px)', overflow: 'auto' } }); + 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 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 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' } }); + 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' } }); + const events = element(document, 'div', { role: 'events', styles: { display: 'grid', gap: '3px', padding: '0 7px 8px' } }); + body.append(overview, unitsTitle, units, delivery, eventsTitle, events); + panel.append(header, body); + document.body.appendChild(panel); + + const eventRecords = new Map(); + let destroyed = false; + function assertLive() { + if (destroyed) throw new Error('Strategy 29 summary panel is destroyed'); + } + function renderEvents() { + events.replaceChildren(); + const ordered = [...eventRecords.values()].sort((left, right) => right.detected_at_ms - left.detected_at_ms || right.sequence - left.sequence); + for (const event of ordered) { + const row = element(document, 'div', { + role: 'remote-event', + styles: { display: 'grid', gridTemplateColumns: '36px minmax(0,1fr) 116px', gap: '6px', alignItems: 'center', padding: '5px 6px', borderRadius: '5px', background: 'rgba(132,142,156,.08)' }, + }); + row.dataset.eventId = event.event_id; + row.appendChild(element(document, 'strong', { text: event.timeframe, styles: { color: '#F0B90B' } })); + row.appendChild(element(document, 'span', { + text: signalLabel(event), + styles: { color: event.signal_side === 'long' ? '#0ECB81' : '#F6465D', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, + })); + row.appendChild(element(document, 'span', { text: `Close ${formatClock(event.bar_close_ms)}`, styles: { color: '#848E9C', fontSize: '10px', textAlign: 'right' } })); + events.appendChild(row); + } + if (ordered.length === 0) events.appendChild(element(document, 'span', { text: 'No recent signals', styles: { color: '#848E9C', padding: '4px' } })); + } + + collapse.addEventListener('click', () => { + const collapsed = body.style.display !== 'none'; + body.style.display = collapsed ? 'none' : 'block'; + collapse.textContent = collapsed ? 'Expand' : 'Collapse'; + }); + renderEvents(); + + return Object.freeze({ + setConnection(state, message) { + assertLive(); + if (!(state in STATE_COLORS) || typeof message !== 'string') throw new Error('Strategy 29 panel connection state is invalid'); + connection.dataset.state = state; + connection.style.color = STATE_COLORS[state]; + connection.textContent = message; + }, + renderStatus(snapshot) { + assertLive(); + const matched = snapshot.spec_version === STRATEGY29_SPEC_VERSION; + spec.dataset.state = matched ? 'matched' : 'error'; + 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)}`; + units.replaceChildren(); + const matching = snapshot.units.filter(unit => unit.symbol === canonicalSymbol); + for (const unit of matching) { + const row = element(document, 'div', { + role: 'unit', + 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.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' } })); + 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}`; + }, + addEvents(incoming, observedAtMs = null) { + assertLive(); + for (const event of incoming) eventRecords.set(event.event_id, event); + const ordered = [...eventRecords.values()].sort((left, right) => right.detected_at_ms - left.detected_at_ms || right.sequence - left.sequence); + while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id); + if (observedAtMs !== null) eventsFreshness.textContent = `Events checked ${formatClock(observedAtMs)}`; + renderEvents(); + }, + clearEvents() { + assertLive(); + eventRecords.clear(); + renderEvents(); + }, + destroy() { + if (destroyed) return; + destroyed = true; + eventRecords.clear(); + panel.remove(); + }, + get size() { return eventRecords.size; }, + }); +} diff --git a/src/binance-strategy29-bollinger/index.user.js b/src/binance-strategy29-bollinger/index.user.js index 9e750fa..712acf9 100644 --- a/src/binance-strategy29-bollinger/index.user.js +++ b/src/binance-strategy29-bollinger/index.user.js @@ -3,18 +3,34 @@ // @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.1.0 +// @version 0.2.0 // @author jackhai9 -// @description Closed-candle Bollinger/SMA60 signals on the native Binance chart +// @description Native Bollinger/SMA60 markers with an optional read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* // @match https://www.binance.com/futures/* // @exclude https://www.binance.com/*/my/wallet/futures/* // @exclude https://www.binance.com/my/wallet/futures/* +// @connect 127.0.0.1 // @updateURL https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js // @downloadURL https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js // @run-at document-start -// @grant none +// @grant unsafeWindow +// @grant GM_xmlhttpRequest +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_registerMenuCommand // ==/UserScript== +import { createStrategy29GmJsonRequest } from './core/remote-summary-client.js'; import { installStrategy29 } from './runtime.js'; -installStrategy29(window); +// Capture the Tampermonkey sandbox function before any page script can affect +// later configuration callbacks. Sensitive input never crosses unsafeWindow. +const promptUser = globalThis.prompt.bind(globalThis); + +installStrategy29(unsafeWindow, { + request: createStrategy29GmJsonRequest(GM_xmlhttpRequest), + getValue: GM_getValue, + setValue: GM_setValue, + registerMenuCommand: GM_registerMenuCommand, + promptUser, +}); diff --git a/src/binance-strategy29-bollinger/remote-summary.js b/src/binance-strategy29-bollinger/remote-summary.js new file mode 100644 index 0000000..a0a6477 --- /dev/null +++ b/src/binance-strategy29-bollinger/remote-summary.js @@ -0,0 +1,254 @@ +import { + Strategy29GatewayTransportError, + createStrategy29SummaryClient, + normalizeStrategy29GatewayOrigin, +} from './core/remote-summary-client.js'; +import { + STRATEGY29_REFERENCE_SHA256, + STRATEGY29_SPEC_VERSION, + routeSymbolToCanonical, +} from './core/remote-summary-contract.js'; +import { createStrategy29SummaryPanel } from './dom/strategy29-summary-panel.js'; +import { parseFuturesTradingSymbolFromPathname } from '../shared/binance-futures-route.js'; + +export const STRATEGY29_REMOTE_ENABLED_KEY = 'strategy29RemoteSummaryEnabled'; +export const STRATEGY29_GATEWAY_ORIGIN_KEY = 'strategy29GatewayOrigin'; +export const STRATEGY29_GATEWAY_SECRET_KEY = 'strategy29GatewayAuthSecret'; +export const STRATEGY29_DEFAULT_GATEWAY_ORIGIN = 'http://127.0.0.1:8729'; +export const STRATEGY29_REMOTE_POLL_INTERVAL_MS = 5_000; + +function abortError(view, message) { + const ErrorConstructor = view.DOMException ?? DOMException; + return new ErrorConstructor(message, 'AbortError'); +} + +function assertAdapters({ view, request, getValue, setValue, registerMenuCommand, promptUser, createPanel, createClient }) { + if (!view?.document || !view?.location) throw new TypeError('Strategy 29 remote summary requires a page window'); + for (const [name, value] of Object.entries({ + request, getValue, setValue, registerMenuCommand, promptUser, createPanel, createClient, + })) { + if (typeof value !== 'function') throw new TypeError(`Strategy 29 remote summary ${name} is invalid`); + } +} + +/** Optional remote projection. Its state machine cannot stop or mutate the local chart observer. */ +export function createStrategy29RemoteSummary({ + view, + request, + getValue, + setValue, + registerMenuCommand, + promptUser, + createPanel = createStrategy29SummaryPanel, + createClient = createStrategy29SummaryClient, + pollIntervalMs = STRATEGY29_REMOTE_POLL_INTERVAL_MS, +}) { + assertAdapters({ view, request, getValue, setValue, registerMenuCommand, promptUser, createPanel, createClient }); + if (!Number.isInteger(pollIntervalMs) || pollIntervalMs < 1_000) throw new TypeError('Strategy 29 remote poll interval is invalid'); + let enabled = getValue(STRATEGY29_REMOTE_ENABLED_KEY, false) === true; + let active = null; + let disposed = false; + let unsupportedRoute = null; + + function isCurrent(context) { + return !disposed && active === context && !context.abortController.signal.aborted; + } + + function stopActive(reason = 'Strategy 29 remote context retired') { + if (!active) return; + const context = active; + active = null; + context.abortController.abort(abortError(view, reason)); + context.panel.destroy(); + } + + function configuredSettings() { + const authSecret = getValue(STRATEGY29_GATEWAY_SECRET_KEY, ''); + if (typeof authSecret !== 'string') throw new TypeError('Strategy 29 gateway secret storage is invalid'); + const gatewayOrigin = normalizeStrategy29GatewayOrigin( + getValue(STRATEGY29_GATEWAY_ORIGIN_KEY, STRATEGY29_DEFAULT_GATEWAY_ORIGIN), + ); + return { authSecret, gatewayOrigin }; + } + + function startContext(routeSymbol) { + const canonicalSymbol = routeSymbolToCanonical(routeSymbol); + const panel = createPanel(view.document, canonicalSymbol, { maxEvents: 20 }); + const AbortControllerConstructor = view.AbortController ?? AbortController; + const context = { + routeSymbol, + canonicalSymbol, + gatewayOrigin: null, + panel, + abortController: new AbortControllerConstructor(), + client: null, + inFlight: false, + failed: false, + nextPollAtMs: 0, + state: 'idle', + lastError: null, + lastResult: null, + }; + active = context; + let settings; + try { + settings = configuredSettings(); + context.gatewayOrigin = settings.gatewayOrigin; + } catch (error) { + context.failed = true; + context.state = 'stopped'; + context.lastError = error.message; + panel.setConnection('stopped', `Remote summary stopped: ${error.message}`); + view.console.warn('[Strategy29 remote]', error.message); + return context; + } + const { authSecret, gatewayOrigin } = settings; + if (authSecret.length === 0) { + context.state = 'configuration_required'; + panel.setConnection('configuration_required', 'Gateway secret is not configured'); + return context; + } + try { + context.client = createClient({ + request, + gatewayOrigin, + authSecret, + canonicalSymbol, + maxPagesPerPoll: 2, + onStatus: snapshot => { + if (isCurrent(context)) context.panel.renderStatus(snapshot); + }, + onEvents: (events, observedAtMs) => { + if (isCurrent(context)) context.panel.addEvents(events, observedAtMs); + }, + onCursorReset: () => { + if (isCurrent(context)) context.panel.clearEvents(); + }, + }); + } catch (error) { + context.failed = true; + context.state = 'stopped'; + context.lastError = error.message; + panel.setConnection('stopped', `Remote summary stopped: ${error.message}`); + view.console.warn('[Strategy29 remote]', error.message); + } + return context; + } + + function synchronizeContext() { + if (!enabled || !view.document.body) { + unsupportedRoute = null; + stopActive('Strategy 29 remote summary disabled'); + return null; + } + const routeSymbol = parseFuturesTradingSymbolFromPathname(view.location.pathname); + if (!routeSymbol) { + unsupportedRoute = null; + stopActive('Strategy 29 route changed'); + return null; + } + if (active?.routeSymbol === routeSymbol) return active; + if (unsupportedRoute === routeSymbol) return null; + unsupportedRoute = null; + stopActive('Strategy 29 route changed'); + try { + return startContext(routeSymbol); + } catch (error) { + unsupportedRoute = routeSymbol; + stopActive('Strategy 29 remote context initialization failed'); + view.console.warn('[Strategy29 remote]', error.message); + return null; + } + } + + function sample(nowMs = Date.now()) { + if (disposed) return; + const context = synchronizeContext(); + if (!context || !context.client || context.inFlight || context.failed || nowMs < context.nextPollAtMs) return; + context.nextPollAtMs = nowMs + pollIntervalMs; + context.inFlight = true; + context.state = 'connecting'; + context.panel.setConnection('connecting', 'Connecting to Strategy 29 gateway'); + return context.client.poll(context.abortController.signal) + .then(result => { + if (!isCurrent(context)) return; + context.lastResult = result; + context.lastError = null; + context.state = result.state; + const presentation = { + connected: ['connected', result.hasMore ? 'Connected · more history pending' : 'Connected'], + unavailable: ['unavailable', 'Gateway database unavailable'], + incompatible: ['incompatible', 'Server and local specs are incompatible'], + }[result.state]; + if (!presentation) throw new Error(`Strategy 29 remote state is invalid: ${result.state}`); + context.panel.setConnection(...presentation); + }) + .catch(error => { + if (!isCurrent(context) || error?.name === 'AbortError') return; + context.lastError = error.message; + if (error instanceof Strategy29GatewayTransportError) { + context.state = 'disconnected'; + context.panel.setConnection('disconnected', 'Gateway connection failed; next scheduled poll will retry'); + } else { + context.state = 'stopped'; + context.failed = true; + context.panel.setConnection('stopped', `Remote summary stopped: ${error.message}`); + } + view.console.warn('[Strategy29 remote]', error.message); + }) + .finally(() => { context.inFlight = false; }); + } + + function restart() { + unsupportedRoute = null; + stopActive('Strategy 29 remote settings changed'); + if (!disposed) void sample(Date.now()); + } + + registerMenuCommand('Toggle Strategy 29 cross-timeframe summary', () => { + enabled = !enabled; + setValue(STRATEGY29_REMOTE_ENABLED_KEY, enabled); + restart(); + }); + registerMenuCommand('Set Strategy 29 gateway secret', () => { + const value = promptUser('Enter the local Strategy 29 gateway secret. It is stored only in this userscript storage.'); + if (value === null) return; + if (value.length === 0) throw new Error('Strategy 29 gateway secret cannot be empty'); + setValue(STRATEGY29_GATEWAY_SECRET_KEY, value); + restart(); + }); + registerMenuCommand('Set Strategy 29 gateway origin', () => { + const current = getValue(STRATEGY29_GATEWAY_ORIGIN_KEY, STRATEGY29_DEFAULT_GATEWAY_ORIGIN); + const value = promptUser('Enter the loopback gateway origin (http://127.0.0.1:)', current); + if (value === null) return; + setValue(STRATEGY29_GATEWAY_ORIGIN_KEY, normalizeStrategy29GatewayOrigin(value)); + restart(); + }); + + return Object.freeze({ + sample, + pause() { stopActive('Strategy 29 remote summary paused'); }, + restart, + dispose() { + if (disposed) return; + disposed = true; + stopActive('Strategy 29 remote summary disposed'); + }, + get diagnostics() { + return Object.freeze({ + enabled, + contextPresent: active !== null, + canonicalSymbol: active?.canonicalSymbol ?? null, + gatewayOrigin: active?.gatewayOrigin ?? null, + state: active?.state ?? (unsupportedRoute ? 'unsupported_route' : enabled ? 'waiting_for_route' : 'disabled'), + inFlight: active?.inFlight ?? false, + stopped: active?.failed ?? false, + lastError: active?.lastError ?? null, + lastResult: active?.lastResult ?? null, + cursor: active?.client?.diagnostics.cursor ?? null, + specVersion: STRATEGY29_SPEC_VERSION, + referenceSha256: STRATEGY29_REFERENCE_SHA256, + }); + }, + }); +} diff --git a/src/binance-strategy29-bollinger/runtime.js b/src/binance-strategy29-bollinger/runtime.js index d3fca63..913504f 100644 --- a/src/binance-strategy29-bollinger/runtime.js +++ b/src/binance-strategy29-bollinger/runtime.js @@ -2,8 +2,10 @@ import { createBollingerMonitor } from './monitor.js'; import { isChartMutationBlocked } from '../shared/chart-mutation-owners.js'; import { isFuturesTradingPathname, parseFuturesTradingSymbolFromPathname } from '../shared/binance-futures-route.js'; import { ensureSpaRouteChangePatched, installSpaRouteChangeListener } from '../shared/spa-route-change.js'; +import { createStrategy29RemoteSummary } from './remote-summary.js'; const INSTANCE = Symbol.for('jh-userscripts.strategy29-bollinger'); +const RUNTIME_VERSION = 2; const CONFLICT = 'Strategy 29 stopped: update Orderbook to 2.7.199 or disable its embedded Bollinger version, then reload this page.'; /** This is a migration refusal, not compatibility with the old independently owned save wrapper. */ @@ -12,10 +14,10 @@ export function hasEmbeddedBollinger(view) { return !!debug && Object.getOwnPropertyDescriptor(debug, 'bollingerAlertState') !== undefined; } -/** Page-context singleton: independent installation, no exchange/account/network operations. */ -export function installStrategy29(view) { +/** Page-context singleton with no exchange/account operations and an optional loopback read projection. */ +export function installStrategy29(view, remoteAdapters = null) { if (view[INSTANCE] !== undefined) { - if (view[INSTANCE].version !== 1) throw new Error('Incompatible Strategy 29 runtime; reload the page'); + if (view[INSTANCE].version !== RUNTIME_VERSION) throw new Error('Incompatible Strategy 29 runtime; reload the page'); return view[INSTANCE].runtime; } const document = view.document; @@ -23,6 +25,9 @@ export function installStrategy29(view) { let failed = null; let disposed = false; let removeRouteListener = null; + const remoteSummary = remoteAdapters === null + ? null + : createStrategy29RemoteSummary({ view, ...remoteAdapters }); const noticeId = 'jh-strategy29-bollinger-status'; function showFailure() { if (!failed || !document.body) return; @@ -48,6 +53,7 @@ export function installStrategy29(view) { if (timer !== null) view.clearInterval(timer); timer = null; monitor.stop(); + remoteSummary?.pause(); } function fail(message) { failed = message; @@ -58,6 +64,7 @@ export function installStrategy29(view) { if (disposed || failed || document.hidden) return; if (hasEmbeddedBollinger(view)) { fail(CONFLICT); return; } ensureSpaRouteChangePatched(view); + void remoteSummary?.sample(Date.now()); if (!isFuturesTradingPathname(view.location.pathname)) { monitor.stop(); return; } // Job boundary: unexpected synchronization errors stop this observer only. void monitor.tick().catch(error => fail(`Strategy 29 stopped: ${error.message}`)); @@ -71,11 +78,20 @@ export function installStrategy29(view) { function onPageHide(event) { if (event.persisted) pause(); else runtime.dispose(); } function onPageShow() { resume(); } const runtime = Object.freeze({ - get diagnostics() { return { ...monitor.diagnostics, runtimeFailure: failed, disposed, timerRunning: timer !== null }; }, + get diagnostics() { + return { + ...monitor.diagnostics, + runtimeFailure: failed, + disposed, + timerRunning: timer !== null, + remoteSummary: remoteSummary?.diagnostics ?? Object.freeze({ enabled: false, state: 'unavailable_in_this_installation' }), + }; + }, dispose() { if (disposed) return; disposed = true; pause(); + remoteSummary?.dispose(); removeRouteListener(); document.removeEventListener('visibilitychange', onVisibility); document.removeEventListener('DOMContentLoaded', showFailure); @@ -84,7 +100,7 @@ export function installStrategy29(view) { document.getElementById(noticeId)?.remove(); }, }); - Object.defineProperty(view, INSTANCE, { value: Object.freeze({ version: 1, runtime }) }); + Object.defineProperty(view, INSTANCE, { value: Object.freeze({ version: RUNTIME_VERSION, runtime }) }); Object.defineProperty(view, '__TM_STRATEGY29_DEBUG__', { value: runtime }); removeRouteListener = installSpaRouteChangeListener(view, sample); document.addEventListener('visibilitychange', onVisibility); diff --git a/src/shared/chart-mutation-owners.js b/src/shared/chart-mutation-owners.js index 3971062..f072ffd 100644 --- a/src/shared/chart-mutation-owners.js +++ b/src/shared/chart-mutation-owners.js @@ -14,6 +14,15 @@ function owners(view) { return record.predicates; } +function existingOwners(view) { + const record = view[OWNER_SLOT]; + if (record === undefined) return null; + if (typeof view?.Map !== 'function' || record.version !== VERSION || !(record.predicates instanceof view.Map)) { + throw new Error('Incompatible chart mutation protocol; update both scripts and reload'); + } + return record.predicates; +} + /** Only a synchronous boolean crosses the script boundary, never task or account data. */ export function registerChartMutationOwner(view, name, predicate) { const registry = owners(view); @@ -24,7 +33,9 @@ export function registerChartMutationOwner(view, name, predicate) { } export function isChartMutationBlocked(view) { - for (const predicate of owners(view).values()) { + const registry = existingOwners(view); + if (registry === null) return false; + for (const predicate of registry.values()) { const blocked = predicate(); if (typeof blocked !== 'boolean') throw new Error('Chart mutation owner must return a boolean'); if (blocked) return true; diff --git a/test/dom/binance-strategy29-bollinger/runtime.test.js b/test/dom/binance-strategy29-bollinger/runtime.test.js index 97d7239..1f45d2d 100644 --- a/test/dom/binance-strategy29-bollinger/runtime.test.js +++ b/test/dom/binance-strategy29-bollinger/runtime.test.js @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { JSDOM } from 'jsdom'; import { installStrategy29 } from '../../../src/binance-strategy29-bollinger/runtime.js'; +import { STRATEGY29_REMOTE_ENABLED_KEY } from '../../../src/binance-strategy29-bollinger/remote-summary.js'; + +const gatewayStatus = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-status.json', import.meta.url))); function fixture() { const dom = new JSDOM('', { url: 'https://www.binance.com/en/futures/BTRUSDT' }); @@ -9,6 +13,7 @@ function fixture() { let hidden = false, next = 0; const timers = new Map(); Object.defineProperty(view.document, 'hidden', { get: () => hidden }); + view.console.warn = () => {}; view.setInterval = callback => { timers.set(++next, callback); return next; }; view.clearInterval = id => timers.delete(id); return { dom, view, timers, @@ -34,6 +39,88 @@ test('standalone injection is single-instance and pauses/resumes/disposes its on f.dom.window.close(); }); +test('remote transport failure never stops the local observer timer', async () => { + const f = fixture(); + const values = new Map([ + [STRATEGY29_REMOTE_ENABLED_KEY, true], + ['strategy29GatewayAuthSecret', 'synthetic-secret'], + ['strategy29GatewayOrigin', 'http://127.0.0.1:8729'], + ]); + const runtime = installStrategy29(f.view, { + request: async () => { throw new Error('synthetic remote failure'); }, + getValue: (key, fallback) => values.has(key) ? values.get(key) : fallback, + setValue: (key, value) => values.set(key, value), + registerMenuCommand() {}, + promptUser() { return null; }, + }); + await new Promise(resolve => f.view.setTimeout(resolve, 0)); + assert.equal(runtime.diagnostics.runtimeFailure, null); + assert.equal(runtime.diagnostics.remoteSummary.state, 'stopped'); + assert.equal(f.timers.size, 1); + runtime.dispose(); + f.dom.window.close(); +}); + +for (const [name, remoteResponse, expectedState] of [ + ['HTTP 400', { status: 400, responseText: JSON.stringify({ schema_version: 1, error: 'invalid_request' }) }, 'stopped'], + ['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'], +]) { + test(`${name} remains a remote-only state while the local timer continues`, async () => { + const f = fixture(); + const values = new Map([ + [STRATEGY29_REMOTE_ENABLED_KEY, true], + ['strategy29GatewayAuthSecret', 'synthetic-secret'], + ['strategy29GatewayOrigin', 'http://127.0.0.1:8729'], + ]); + const runtime = installStrategy29(f.view, { + request: async () => remoteResponse, + getValue: (key, fallback) => values.has(key) ? values.get(key) : fallback, + setValue: (key, value) => values.set(key, value), + registerMenuCommand() {}, + promptUser() { return null; }, + }); + await new Promise(resolve => f.view.setTimeout(resolve, 0)); + assert.equal(runtime.diagnostics.runtimeFailure, null); + assert.equal(runtime.diagnostics.remoteSummary.state, expectedState); + assert.equal(f.timers.size, 1); + runtime.dispose(); + f.dom.window.close(); + }); +} + +test('hiding the page aborts the remote request and resumes with one shared runtime timer', async () => { + const f = fixture(); + const values = new Map([ + [STRATEGY29_REMOTE_ENABLED_KEY, true], + ['strategy29GatewayAuthSecret', 'synthetic-secret'], + ['strategy29GatewayOrigin', 'http://127.0.0.1:8729'], + ]); + let aborts = 0; + const runtime = installStrategy29(f.view, { + request: ({ signal }) => new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { aborts += 1; reject(signal.reason); }, { once: true }); + }), + getValue: (key, fallback) => values.has(key) ? values.get(key) : fallback, + setValue: (key, value) => values.set(key, value), + registerMenuCommand() {}, + promptUser() { return null; }, + }); + assert.equal(runtime.diagnostics.remoteSummary.inFlight, true); + f.hide(true); + await new Promise(resolve => f.view.setTimeout(resolve, 0)); + assert.equal(aborts, 1); + assert.equal(f.timers.size, 0); + assert.equal(runtime.diagnostics.remoteSummary.contextPresent, false); + f.hide(false); + assert.equal(f.timers.size, 1); + assert.equal(runtime.diagnostics.remoteSummary.inFlight, true); + runtime.dispose(); + f.dom.window.close(); +}); + for (const legacyFirst of [true, false]) { test(`legacy embedded observer refuses coexistence (legacy first=${legacyFirst})`, () => { 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 new file mode 100644 index 0000000..91a69c6 --- /dev/null +++ b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { JSDOM } from 'jsdom'; + +import { createStrategy29SummaryPanel } from '../../../src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js'; + +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('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 }); + panel.renderStatus(status); + const text = dom.window.document.body.textContent; + assert.match(text, /BTC\/USDT:USDT/); + assert.match(text, /1m/); + assert.match(text, /1h/); + assert.doesNotMatch(text, /ETH\/USDT:USDT/); + assert.match(text, /Global delivery/); + assert.match(text, /Sent 4/); + assert.match(text, /Spec version matched/); + assert.match(text, /eece8cf16e58340910587962f3bfbb19acb72155c09a52b4b6c0570cc979ef8d/); + panel.destroy(); + assert.equal(dom.window.document.getElementById('jh-strategy29-summary-panel'), null); + dom.window.close(); +}); + +test('shows multi-timeframe events, deduplicates identities and clears only remote rows', () => { + const dom = new JSDOM(''); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); + panel.addEvents(events.events, events.observed_at_ms); + panel.addEvents([events.events[0]]); + assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 2); + const text = dom.window.document.body.textContent; + assert.match(text, /1m/); + assert.match(text, /1h/); + assert.match(text, /Bearish warning/); + assert.match(text, /Short reversal/); + assert.match(text, /Close/); + assert.match(text, /Events checked/); + assert.match(text, /UTC\+08/); + panel.clearEvents(); + assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 0); + panel.destroy(); + dom.window.close(); +}); + +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' }); + assert.match(dom.window.document.body.textContent, /Spec mismatch/); + assert.equal(dom.window.document.querySelector('[data-role=spec]').dataset.state, 'error'); + panel.destroy(); + dom.window.close(); +}); diff --git a/test/fixtures/strategy29-gateway-events.json b/test/fixtures/strategy29-gateway-events.json new file mode 100644 index 0000000..fa66234 --- /dev/null +++ b/test/fixtures/strategy29-gateway-events.json @@ -0,0 +1,57 @@ +{ + "schema_version": 1, + "spec_version": "29_2_spec_v1", + "observed_at_ms": 1788580805000, + "next_cursor": 42, + "has_more": false, + "events": [ + { + "sequence": 41, + "event_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "schema_version": 1, + "strategy_id": "29", + "spec_version": "29_2_spec_v1", + "symbol": "BTC/USDT:USDT", + "timeframe": "1m", + "setup_direction": "bearish", + "signal_type": "warning", + "signal_side": "short", + "setup_open_ms": 1788580500000, + "bar_open_ms": 1788580740000, + "bar_close_ms": 1788580800000, + "detected_at_ms": 1788580800500, + "close_price": 100.25, + "marker_price": 101.5, + "warning_open_ms": 1788580740000, + "warning_high": 101.25, + "warning_low": 99.5, + "origin": "live", + "delivery_state": "sent", + "delivery_failure_reason": null + }, + { + "sequence": 42, + "event_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "schema_version": 1, + "strategy_id": "29", + "spec_version": "29_2_spec_v1", + "symbol": "BTC/USDT:USDT", + "timeframe": "1h", + "setup_direction": "bullish", + "signal_type": "reversal", + "signal_side": "short", + "setup_open_ms": 1788566400000, + "bar_open_ms": 1788577200000, + "bar_close_ms": 1788580800000, + "detected_at_ms": 1788580800700, + "close_price": 99.75, + "marker_price": 99.25, + "warning_open_ms": 1788573600000, + "warning_high": 100.5, + "warning_low": 98.75, + "origin": "catch_up", + "delivery_state": null, + "delivery_failure_reason": null + } + ] +} diff --git a/test/fixtures/strategy29-gateway-status.json b/test/fixtures/strategy29-gateway-status.json new file mode 100644 index 0000000..a443445 --- /dev/null +++ b/test/fixtures/strategy29-gateway-status.json @@ -0,0 +1,42 @@ +{ + "schema_version": 1, + "spec_version": "29_2_spec_v1", + "observed_at_ms": 1788580805000, + "units": [ + { + "symbol": "BTC/USDT:USDT", + "timeframe": "1m", + "status": "ready", + "reason": "current", + "last_processed_open_ms": 1788580740000, + "last_data_at_ms": 1788580800000, + "last_event_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "symbol": "BTC/USDT:USDT", + "timeframe": "1h", + "status": "stale", + "reason": "observer_progress_stale", + "last_processed_open_ms": 1788573600000, + "last_data_at_ms": 1788577200000, + "last_event_id": null + }, + { + "symbol": "ETH/USDT:USDT", + "timeframe": "5m", + "status": "warming", + "reason": "awaiting_initial_baseline", + "last_processed_open_ms": null, + "last_data_at_ms": null, + "last_event_id": null + } + ], + "delivery_counts": { + "pending": 1, + "sending": 0, + "sent": 4, + "unknown": 1, + "expired": 2, + "failed": 0 + } +} diff --git a/test/manual/strategy29-summary-panel-preview.mjs b/test/manual/strategy29-summary-panel-preview.mjs new file mode 100644 index 0000000..16da628 --- /dev/null +++ b/test/manual/strategy29-summary-panel-preview.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { readFile, mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { chromium } from '@playwright/test'; +import { build } from 'esbuild'; + +// Render the actual panel module with synthetic gateway data and no live connections. +const status = JSON.parse(await readFile(new URL('../fixtures/strategy29-gateway-status.json', import.meta.url), 'utf8')); +const events = JSON.parse(await readFile(new URL('../fixtures/strategy29-gateway-events.json', import.meta.url), 'utf8')); +const bundle = await build({ + stdin: { + contents: "export { createStrategy29SummaryPanel } from './src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js';", + resolveDir: process.cwd(), + sourcefile: 'strategy29-summary-panel-preview.js', + }, + bundle: true, + write: false, + format: 'esm', +}); +const sourceUrl = `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString('base64')}`; +const output = await mkdtemp(join(tmpdir(), 'strategy29-summary-panel-')); +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.setContent(` + +
BTCUSDT · Synthetic chart · No live connection
+
+
Synthetic chart area
+
+ + `); + await page.evaluate(async ({ sourceUrl, status, events }) => { + const { createStrategy29SummaryPanel } = await import(sourceUrl); + const panel = createStrategy29SummaryPanel(document, 'BTC/USDT:USDT', { maxEvents: 8 }); + panel.setConnection('connected', 'Connected'); + panel.renderStatus(status); + panel.addEvents(events.events, events.observed_at_ms); + window.fixturePanel = panel; + }, { sourceUrl, status, events }); + const panel = page.locator('#jh-strategy29-summary-panel'); + await panel.waitFor({ state: 'visible' }); + assert.equal(await panel.locator('[data-role="unit"]').count(), 2); + assert.equal(await panel.locator('[data-role="remote-event"]').count(), 2); + assert.match(await panel.locator('[data-role="delivery"]').textContent(), /Global delivery/); + assert.match(await panel.locator('[data-role="events-freshness"]').textContent(), /Events checked/); + await panel.screenshot({ path: join(output, 'connected.png') }); + await panel.locator('[data-role="collapse"]').click(); + assert.equal(await panel.locator('[data-role="body"]').isVisible(), false); + await panel.locator('[data-role="collapse"]').click(); + const box = await panel.boundingBox(); + assert.equal(box.width, 340); + assert.equal(box.x >= 0 && box.y >= 0 && box.y + box.height <= 900, true); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ output, checked: ['two-timeframe-status', 'two-events', 'global-delivery-label', 'separate-freshness', 'collapse', 'viewport'], pageErrors: errors })); +} finally { + await browser.close(); +} diff --git a/test/unit/binance-strategy29-bollinger/coordination.test.js b/test/unit/binance-strategy29-bollinger/coordination.test.js index 8e6ba93..ab7dfa8 100644 --- a/test/unit/binance-strategy29-bollinger/coordination.test.js +++ b/test/unit/binance-strategy29-bollinger/coordination.test.js @@ -13,6 +13,15 @@ async function bundle() { return vm.runInThisContext(result.outputFiles[0].text + '; coordination;'); } +async function ownerBundle(context) { + const result = await build({ + stdin: { contents: "export * from './src/shared/chart-mutation-owners.js';", + resolveDir: process.cwd(), sourcefile: 'coordination-sandbox-test.js' }, + bundle: true, write: false, format: 'iife', globalName: 'coordination', + }); + return vm.runInContext(`${result.outputFiles[0].text}; coordination;`, context); +} + test('independent bundles share one exact API controller and either order sees the same drain', async () => { const a = await bundle(), b = await bundle(); for (const [first, second] of [[a, b], [b, a]]) { @@ -37,7 +46,7 @@ test('independent bundles share one exact API controller and either order sees t }); test('independent bundles expose only a live boolean and unregister their own owner', async () => { - const a = await bundle(), b = await bundle(), view = {}; + const a = await bundle(), b = await bundle(), view = { Map }; assert.equal(b.isChartMutationBlocked(view), false); let busy = false; const remove = a.registerChartMutationOwner(view, 'orderbook', () => busy); @@ -51,12 +60,35 @@ test('independent bundles expose only a live boolean and unregister their own ow assert.throws(() => b.isChartMutationBlocked(view), /boolean/); }); +test('page and userscript realms share the page-owned Map in either load order', async () => { + for (const strategy29First of [false, true]) { + const pageContext = vm.createContext({}); + const sandboxContext = vm.createContext({}); + const view = vm.runInContext('globalThis', pageContext); + const pageBundle = await ownerBundle(pageContext); + const strategy29Bundle = await ownerBundle(sandboxContext); + assert.notEqual(view.Map, vm.runInContext('Map', sandboxContext)); + if (strategy29First) { + assert.equal(strategy29Bundle.isChartMutationBlocked(view), false); + assert.equal(view[Symbol.for('jh-userscripts.chart-mutation-owners')], undefined); + } + let busy = false; + const remove = pageBundle.registerChartMutationOwner(view, `orderbook-${strategy29First}`, () => busy); + const record = view[Symbol.for('jh-userscripts.chart-mutation-owners')]; + assert.equal(record.predicates instanceof view.Map, true); + assert.equal(strategy29Bundle.isChartMutationBlocked(view), false); + busy = true; + assert.equal(strategy29Bundle.isChartMutationBlocked(view), true); + remove(); + } +}); + test('incompatible shared protocols fail without overwriting an owner', async () => { const a = await bundle(); const record = { version: 99 }; const api = { [Symbol.for('jh-userscripts.chart-marker-save-controller')]: record }; assert.throws(() => a.installTradingViewMarkerSaveController(api), /Incompatible/); assert.equal(api[Symbol.for('jh-userscripts.chart-marker-save-controller')], record); - const view = { [Symbol.for('jh-userscripts.chart-mutation-owners')]: record }; + const view = { Map, [Symbol.for('jh-userscripts.chart-mutation-owners')]: record }; assert.throws(() => a.isChartMutationBlocked(view), /Incompatible/); }); diff --git a/test/unit/binance-strategy29-bollinger/entry-sandbox.test.js b/test/unit/binance-strategy29-bollinger/entry-sandbox.test.js new file mode 100644 index 0000000..4a284e8 --- /dev/null +++ b/test/unit/binance-strategy29-bollinger/entry-sandbox.test.js @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { build } from 'esbuild'; +import { JSDOM } from 'jsdom'; +import vm from 'node:vm'; + +const ENTRY = 'src/binance-strategy29-bollinger/index.user.js'; + +async function bundledEntry() { + const result = await build({ entryPoints: [ENTRY], bundle: true, write: false, format: 'iife' }); + return result.outputFiles[0].text; +} + +test('sandbox entry installs the runtime on unsafeWindow and keeps the shared page singleton visible', async () => { + const dom = new JSDOM('', { url: 'https://www.binance.com/en/futures/BTRUSDT' }); + const menus = []; + const stored = new Map(); + let pagePromptCalls = 0; + dom.window.prompt = () => { pagePromptCalls += 1; return 'captured-by-page'; }; + const sandbox = { + unsafeWindow: dom.window, + prompt(message) { return message.includes('secret') ? 'sandbox-secret' : null; }, + GM_xmlhttpRequest() { throw new Error('remote summary is disabled by default'); }, + GM_getValue(key, fallback) { return stored.has(key) ? stored.get(key) : fallback; }, + GM_setValue(key, value) { stored.set(key, value); }, + GM_registerMenuCommand(label, callback) { menus.push({ label, callback }); }, + URL, + AbortController, + DOMException, + console, + }; + vm.runInNewContext(await bundledEntry(), sandbox); + assert.equal(typeof dom.window.__TM_STRATEGY29_DEBUG__.dispose, 'function'); + assert.equal(sandbox.__TM_STRATEGY29_DEBUG__, undefined); + assert.equal(dom.window[Symbol.for('jh-userscripts.strategy29-bollinger')].version, 2); + menus.find(menu => menu.label === 'Set Strategy 29 gateway secret').callback(); + assert.equal(stored.get('strategy29GatewayAuthSecret'), 'sandbox-secret'); + assert.equal(pagePromptCalls, 0); + dom.window.__TM_STRATEGY29_DEBUG__.dispose(); + 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 new file mode 100644 index 0000000..5431b85 --- /dev/null +++ b/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { + Strategy29GatewayTransportError, + createStrategy29GmJsonRequest, + createStrategy29SummaryClient, + normalizeStrategy29GatewayOrigin, +} from '../../../src/binance-strategy29-bollinger/core/remote-summary-client.js'; + +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))); + +function response(body, httpStatus = 200) { + return { status: httpStatus, responseText: JSON.stringify(body) }; +} + +function clientFixture(responses, overrides = {}) { + const requests = []; + const snapshots = []; + const received = []; + const resets = []; + const client = createStrategy29SummaryClient({ + request: async (request) => { + requests.push(request); + const next = responses.shift(); + if (next instanceof Error) throw next; + return next; + }, + gatewayOrigin: 'http://127.0.0.1:8729', + authSecret: 'synthetic-secret', + canonicalSymbol: 'BTC/USDT:USDT', + maxPagesPerPoll: 2, + onStatus: (value) => snapshots.push(value), + onEvents: (value) => received.push(...value), + onCursorReset: (value) => resets.push(value), + ...overrides, + }); + return { client, requests, snapshots, received, resets }; +} + +test('normalizes only an explicit loopback HTTP origin', () => { + assert.equal(normalizeStrategy29GatewayOrigin('http://127.0.0.1:8729'), 'http://127.0.0.1:8729'); + for (const value of [ + 'https://127.0.0.1:8729', 'http://localhost:8729', 'http://127.0.0.1', + 'http://user@127.0.0.1:8729', 'http://127.0.0.1:8729/path', + ]) assert.throws(() => normalizeStrategy29GatewayOrigin(value), /loopback origin/); +}); + +test('GM transport sends the secret only in Authorization and owns abort', async () => { + let options; + let aborted = false; + const request = createStrategy29GmJsonRequest((value) => { + options = value; + return { abort() { aborted = true; value.onabort(); } }; + }); + const controller = new AbortController(); + const pending = request({ + url: 'http://127.0.0.1:8729/v1/strategy29/status', + authSecret: 'synthetic-secret', + signal: controller.signal, + }); + assert.equal(options.method, 'GET'); + assert.deepEqual(options.headers, { Authorization: 'Bearer synthetic-secret' }); + assert.doesNotMatch(options.url, /synthetic-secret/); + controller.abort(new DOMException('retired', 'AbortError')); + await assert.rejects(pending, error => error.name === 'AbortError'); + assert.equal(aborted, true); +}); + +test('GM transport classifies synchronous initialization failure and synchronous completion', async () => { + const controller = new AbortController(); + const failure = createStrategy29GmJsonRequest(() => { throw new Error('host initialization failed'); }); + await assert.rejects( + failure({ url: 'http://127.0.0.1:8729/v1/strategy29/status', authSecret: 'x', signal: controller.signal }), + error => error instanceof Strategy29GatewayTransportError, + ); + + let aborts = 0; + const success = createStrategy29GmJsonRequest((options) => { + options.onload({ status: 200, responseText: '{}' }); + return { abort() { aborts += 1; } }; + }); + assert.deepEqual( + await success({ url: 'http://127.0.0.1:8729/v1/strategy29/status', authSecret: 'x', signal: controller.signal }), + { status: 200, responseText: '{}' }, + ); + controller.abort(); + assert.equal(aborts, 0); +}); + +test('polls status then bounded event pages and accepts filtered empty progress', async () => { + const first = { ...events, events: [], next_cursor: 20, has_more: true }; + const second = { ...events, next_cursor: 42, has_more: false }; + const fixture = clientFixture([response(status), response(first), response(second)]); + const result = await fixture.client.poll(new AbortController().signal); + assert.deepEqual(result, { state: 'connected', pages: 2, hasMore: false }); + assert.equal(fixture.requests[0].url, 'http://127.0.0.1:8729/v1/strategy29/status'); + assert.match(fixture.requests[1].url, /symbol=BTC%2FUSDT%3AUSDT/); + assert.doesNotMatch(fixture.requests[1].url, /secret|Authorization/i); + assert.match(fixture.requests[2].url, /cursor=20/); + assert.deepEqual(fixture.received.map((event) => event.sequence), [41, 42]); + assert.equal(fixture.client.diagnostics.cursor, 42); +}); + +test('409 advances to the explicit recovery cursor without clearing local chart state', async () => { + const fixture = clientFixture([ + response(status), + response({ schema_version: 1, error: 'cursor_expired', oldest_cursor: 40 }, 409), + response({ ...events, next_cursor: 42, has_more: false }), + ]); + const result = await fixture.client.poll(new AbortController().signal); + assert.deepEqual(result, { state: 'connected', pages: 2, hasMore: false }); + assert.deepEqual(fixture.resets, [40]); + assert.match(fixture.requests[2].url, /cursor=40/); +}); + +test('rejects has_more without cursor progress and does not loop', async () => { + const stalled = { ...events, events: [], next_cursor: 0, has_more: true }; + const fixture = clientFixture([response(status), response(stalled)]); + await assert.rejects( + fixture.client.poll(new AbortController().signal), + /cursor did not advance/, + ); + assert.equal(fixture.requests.length, 2); +}); + +test('rejects an event for a symbol other than the strict requested identity', async () => { + const wrongSymbol = structuredClone(events); + wrongSymbol.events[0].symbol = 'ETH/USDT:USDT'; + const fixture = clientFixture([response(status), response(wrongSymbol)]); + await assert.rejects(fixture.client.poll(new AbortController().signal), /requested symbol/); +}); + +test('reports unavailable status separately from fatal HTTP and contract errors', async () => { + const unavailable = clientFixture([ + response({ schema_version: 1, error: 'database_unavailable' }, 503), + ]); + assert.deepEqual( + await unavailable.client.poll(new AbortController().signal), + { state: 'unavailable', pages: 0, hasMore: false }, + ); + + for (const failing of [ + response({ schema_version: 1, error: 'unauthorized' }, 401), + { status: 200, responseText: '' }, + ]) { + const fixture = clientFixture([failing]); + await assert.rejects(fixture.client.poll(new AbortController().signal)); + } + + const transport = clientFixture([ + new Strategy29GatewayTransportError('synthetic transport failure'), + ]); + await assert.rejects(transport.client.poll(new AbortController().signal), /transport failure/); +}); + +test('exposes remote/local spec mismatch before requesting event history', async () => { + const mismatch = { ...status, spec_version: '29_2_spec_v2' }; + 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'); +}); diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js new file mode 100644 index 0000000..fd91bc0 --- /dev/null +++ b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { + STRATEGY29_SPEC_VERSION, + canonicalSymbolToRoute, + routeSymbolToCanonical, + validateStrategy29EventsResponse, + validateStrategy29GatewayError, + validateStrategy29StatusResponse, +} from '../../../src/binance-strategy29-bollinger/core/remote-summary-contract.js'; + +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('canonical Strategy29 symbols round-trip without server-side normalization', () => { + assert.equal(routeSymbolToCanonical('BTRUSDT'), 'BTR/USDT:USDT'); + assert.equal(canonicalSymbolToRoute('BTR/USDT:USDT'), 'BTRUSDT'); + assert.throws(() => routeSymbolToCanonical('BTRUSD'), /route symbol/); + assert.throws(() => routeSymbolToCanonical('btrUSDT'), /route symbol/); + assert.throws(() => canonicalSymbolToRoute('BTR/USDT'), /canonical symbol/); +}); + +test('validates exact status fields while preserving visible spec mismatch', () => { + assert.equal(STRATEGY29_SPEC_VERSION, '29_2_spec_v1'); + assert.equal(validateStrategy29StatusResponse(status, 200), status); + const mismatch = structuredClone(status); + mismatch.spec_version = '29_2_spec_v2'; + assert.equal(validateStrategy29StatusResponse(mismatch, 200), mismatch); + assert.throws( + () => validateStrategy29StatusResponse({ ...status, unexpected: true }, 200), + /exact keys/, + ); + const invalid = structuredClone(status); + invalid.units[0].last_data_at_ms = '1788580800000'; + assert.throws(() => validateStrategy29StatusResponse(invalid, 200), /last_data_at_ms/); + for (const unitStatus of ['insufficient_history', 'data_gap', 'failed']) { + const valid = structuredClone(status); + valid.units[0].status = unitStatus; + assert.equal(validateStrategy29StatusResponse(valid, 200), valid); + } + const oversized = structuredClone(status); + oversized.units = Array.from({ length: 129 }, () => status.units[0]); + assert.throws(() => validateStrategy29StatusResponse(oversized, 200), /128-unit bound/); +}); + +test('validates exact event identity, direction and cursor progress fields', () => { + assert.equal(validateStrategy29EventsResponse(events, 200), events); + const invalidIdentity = structuredClone(events); + invalidIdentity.events[0].strategy_id = '30'; + assert.throws(() => validateStrategy29EventsResponse(invalidIdentity, 200), /strategy_id/); + const invalidSide = structuredClone(events); + invalidSide.events[1].signal_side = 'long'; + assert.throws(() => validateStrategy29EventsResponse(invalidSide, 200), /signal_side/); + const extra = structuredClone(events); + extra.events[0].extra = true; + assert.throws(() => validateStrategy29EventsResponse(extra, 200), /exact keys/); + const historicalWeekly = structuredClone(events); + historicalWeekly.events[0].origin = 'historical'; + historicalWeekly.events[0].timeframe = '1w'; + assert.equal(validateStrategy29EventsResponse(historicalWeekly, 200), historicalWeekly); + const unsupportedSecond = structuredClone(events); + unsupportedSecond.events[0].timeframe = '1s'; + assert.throws(() => validateStrategy29EventsResponse(unsupportedSecond, 200), /timeframe/); + const finiteNonPositivePrices = structuredClone(events); + finiteNonPositivePrices.events[0].close_price = 0; + finiteNonPositivePrices.events[0].marker_price = -1; + finiteNonPositivePrices.events[0].warning_high = 0; + finiteNonPositivePrices.events[0].warning_low = -2; + assert.equal(validateStrategy29EventsResponse(finiteNonPositivePrices, 200), finiteNonPositivePrices); + const oversized = structuredClone(events); + oversized.events = Array.from({ length: 201 }, () => events.events[0]); + assert.throws(() => validateStrategy29EventsResponse(oversized, 200), /200-event page bound/); +}); + +test('validates Strategy29 error bodies using error rather than error_code', () => { + assert.deepEqual( + validateStrategy29GatewayError( + { schema_version: 1, error: 'cursor_expired', oldest_cursor: 42 }, + 409, + ), + { schema_version: 1, error: 'cursor_expired', oldest_cursor: 42 }, + ); + assert.deepEqual( + validateStrategy29GatewayError({ schema_version: 1, error: 'database_unavailable' }, 503), + { schema_version: 1, error: 'database_unavailable' }, + ); + assert.throws( + () => validateStrategy29GatewayError({ schema_version: 1, error_code: 'cursor_expired' }, 409), + /exact keys/, + ); +}); diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js new file mode 100644 index 0000000..b3a22d7 --- /dev/null +++ b/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + STRATEGY29_REMOTE_ENABLED_KEY, + createStrategy29RemoteSummary, +} from '../../../src/binance-strategy29-bollinger/remote-summary.js'; +import { Strategy29GatewayTransportError } from '../../../src/binance-strategy29-bollinger/core/remote-summary-client.js'; + +function fixture({ enabled = true, authSecret = 'synthetic-secret', poll } = {}) { + const values = new Map([ + [STRATEGY29_REMOTE_ENABLED_KEY, enabled], + ['strategy29GatewayAuthSecret', authSecret], + ['strategy29GatewayOrigin', 'http://127.0.0.1:8729'], + ]); + const menus = []; + const panels = []; + const prompts = []; + const view = { + location: { pathname: '/en/futures/BTRUSDT' }, + document: { body: {} }, + console: { warn() {} }, + }; + const clients = []; + const summary = createStrategy29RemoteSummary({ + view, + request: async () => { throw new Error('unexpected raw request'); }, + getValue: (key, fallback) => values.has(key) ? values.get(key) : fallback, + setValue: (key, value) => values.set(key, value), + registerMenuCommand: (label, callback) => menus.push({ label, callback }), + promptUser: (...args) => { prompts.push(args); return null; }, + createPanel: (_document, canonicalSymbol) => { + const calls = []; + const panel = { + canonicalSymbol, + calls, + setConnection: (...args) => calls.push(['connection', ...args]), + renderStatus: (...args) => calls.push(['status', ...args]), + addEvents: (...args) => calls.push(['events', ...args]), + clearEvents: (...args) => calls.push(['clear', ...args]), + destroy: () => calls.push(['destroy']), + }; + panels.push(panel); + return panel; + }, + createClient: (options) => { + const client = { + options, + diagnostics: { cursor: null }, + poll: poll ?? (async () => ({ state: 'connected', pages: 1, hasMore: false })), + }; + clients.push(client); + return client; + }, + }); + return { view, values, menus, prompts, panels, clients, summary }; +} + +test('remote summary is opt-in and registers configuration without requesting data', async () => { + const f = fixture({ enabled: false }); + assert.equal(f.summary.sample(0), undefined); + assert.equal(f.panels.length, 0); + assert.equal(f.clients.length, 0); + assert.equal(f.menus.length, 3); + assert.equal(f.summary.diagnostics.enabled, false); +}); + +test('gateway configuration uses only the injected userscript prompt adapter', () => { + const f = fixture({ enabled: false }); + f.view.prompt = () => { throw new Error('page prompt must not be called'); }; + f.menus.find(menu => menu.label === 'Set Strategy 29 gateway secret').callback(); + assert.equal(f.prompts.length, 1); + assert.match(f.prompts[0][0], /gateway secret/); +}); + +test('polls the current route symbol independently of the visible chart interval', async () => { + const f = fixture(); + await f.summary.sample(0); + assert.equal(f.panels[0].canonicalSymbol, 'BTR/USDT:USDT'); + assert.equal(f.clients[0].options.canonicalSymbol, 'BTR/USDT:USDT'); + assert.equal(f.summary.diagnostics.inFlight, false); + assert.equal(f.summary.diagnostics.state, 'connected'); + assert.equal(f.summary.sample(4_999), undefined); + assert.equal(f.clients.length, 1); +}); + +test('route retirement aborts ownership and ignores a late old-symbol response', async () => { + let resolve; + let polls = 0; + const pending = new Promise((value) => { resolve = value; }); + const f = fixture({ poll: () => { + polls += 1; + return polls === 1 ? pending : Promise.resolve({ state: 'connected', pages: 1, hasMore: false }); + } }); + const first = f.summary.sample(0); + f.view.location.pathname = '/en/futures/ETHUSDT'; + await f.summary.sample(1_000); + resolve({ state: 'connected', pages: 1, hasMore: false }); + await first; + assert.deepEqual(f.panels[0].calls.at(-1), ['destroy']); + assert.equal(f.panels[1].canonicalSymbol, 'ETH/USDT:USDT'); + assert.notEqual(f.summary.diagnostics.canonicalSymbol, 'BTR/USDT:USDT'); +}); + +test('transport and contract failures remain remote-only and never expose the secret', async () => { + for (const error of [ + new Strategy29GatewayTransportError('offline'), + new TypeError('invalid response contract'), + ]) { + const f = fixture({ poll: async () => { throw error; } }); + await f.summary.sample(0); + assert.equal(f.summary.diagnostics.inFlight, false); + assert.equal(f.summary.diagnostics.state, error instanceof TypeError ? 'stopped' : 'disconnected'); + assert.doesNotMatch(JSON.stringify(f.summary.diagnostics), /synthetic-secret/); + } +}); + +test('missing secret creates a visible configuration state without constructing a client', async () => { + const f = fixture({ authSecret: '' }); + await f.summary.sample(0); + assert.equal(f.clients.length, 0); + assert.deepEqual(f.panels[0].calls[0], ['connection', 'configuration_required', 'Gateway secret is not configured']); +}); + +test('invalid stored origin stops one visible remote context without per-second reconstruction', async () => { + const f = fixture(); + f.values.set('strategy29GatewayOrigin', 'https://127.0.0.1:8729'); + await f.summary.sample(0); + await f.summary.sample(10_000); + assert.equal(f.panels.length, 1); + assert.equal(f.clients.length, 0); + assert.equal(f.summary.diagnostics.state, 'stopped'); + assert.match(f.panels[0].calls[0][2], /loopback origin/); +}); + +test('unsupported futures route is classified once without a retry/log loop', async () => { + const f = fixture(); + let warnings = 0; + f.view.console.warn = () => { warnings += 1; }; + f.view.location.pathname = '/en/futures/BTCUSD_PERP'; + await f.summary.sample(0); + await f.summary.sample(1_000); + assert.equal(f.summary.diagnostics.state, 'unsupported_route'); + assert.equal(warnings, 1); + assert.equal(f.panels.length, 0); + assert.equal(f.clients.length, 0); +}); diff --git a/test/unit/binance-strategy29-bollinger/source-regressions.test.js b/test/unit/binance-strategy29-bollinger/source-regressions.test.js index e1f4ad9..ad1e12e 100644 --- a/test/unit/binance-strategy29-bollinger/source-regressions.test.js +++ b/test/unit/binance-strategy29-bollinger/source-regressions.test.js @@ -1,9 +1,13 @@ import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { STRATEGY29_REFERENCE_SHA256 } from '../../../src/binance-strategy29-bollinger/core/remote-summary-contract.js'; const source = await readFile(new URL('../../../src/binance-strategy29-bollinger/monitor.js', import.meta.url), 'utf8'); const bollingerPatternSource = await readFile(new URL('../../../src/binance-strategy29-bollinger/core/bearish-bollinger-pattern.js', import.meta.url), 'utf8'); +const entrySource = await readFile(new URL('../../../src/binance-strategy29-bollinger/index.user.js', import.meta.url), 'utf8'); +const remoteSource = await readFile(new URL('../../../src/binance-strategy29-bollinger/remote-summary.js', import.meta.url), 'utf8'); function readFunctionBody(name, sourceText = source) { const start = sourceText.indexOf(`function ${name}(`); @@ -46,3 +50,28 @@ test('Bollinger chart alert failures distinguish snapshot races from contract fa assert.match(bollingerPatternSource, /context\.failed = true;/); assert.match(bollingerPatternSource, /context\.cleanupPending = true;/); }); + +test('Strategy29 sandbox metadata is exact and coordinates through unsafeWindow', () => { + assert.equal(readUserscriptVersion(entrySource), '0.2.0'); + assert.deepEqual( + [...entrySource.matchAll(/^\/\/ @grant\s+(\S+)\s*$/gm)].map(match => match[1]), + ['unsafeWindow', 'GM_xmlhttpRequest', 'GM_getValue', 'GM_setValue', 'GM_registerMenuCommand'], + ); + assert.deepEqual( + [...entrySource.matchAll(/^\/\/ @connect\s+(\S+)\s*$/gm)].map(match => match[1]), + ['127.0.0.1'], + ); + assert.match(entrySource, /installStrategy29\(unsafeWindow,/); + assert.match(entrySource, /globalThis\.prompt\.bind\(globalThis\)/); + assert.doesNotMatch(remoteSource, /view\.prompt/); + assert.doesNotMatch(entrySource, /@grant\s+none/); +}); + +test('remote summary remains read-only and does not add chart or exchange transports', () => { + assert.doesNotMatch(remoteSource, /WebSocket|\.fetch\(|createMultipointShape|createShape|exchangeInfo|apiKey|apiSecret/); + assert.match(remoteSource, /createStrategy29RemoteSummary/); +}); + +test('remote parity display is pinned to the exact reviewed local detector bytes', () => { + assert.equal(createHash('sha256').update(bollingerPatternSource).digest('hex'), STRATEGY29_REFERENCE_SHA256); +}); diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index f62ffac..394cae7 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -21,13 +21,19 @@ 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.1.0'); + assert.equal(contract.version, '0.2.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); - assert.deepEqual(metadata.get('grant'), ['none']); - assert.equal(metadata.has('connect'), false); - for (const forbidden of ['new WebSocket', 'fetch(', 'place-order', 'detectBollingerSignals']) { + assert.deepEqual(metadata.get('connect'), ['127.0.0.1']); + assert.deepEqual(metadata.get('grant'), [ + 'unsafeWindow', + 'GM_xmlhttpRequest', + 'GM_getValue', + 'GM_setValue', + 'GM_registerMenuCommand', + ]); + for (const forbidden of ['new WebSocket', 'wss://', 'fetch(', 'place-order', 'apiKey', 'apiSecret', 'synthetic-secret', 'detectBollingerSignals']) { if (forbidden === 'detectBollingerSignals') { assert.equal(source.includes(forbidden), false, 'orderbook must not bundle the detector'); } else assert.equal(text.includes(forbidden), false);