diff --git a/README.md b/README.md index 81df1e3..77f6fa8 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,11 @@ 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 and optionally show the authenticated cross-timeframe server summary; 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; remote summaries belong to the CorsairQuant signal client | 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] | -| Binance Strategy 27 event annotations | Binance Futures | Draw VPS-computed order-flow observations and exploratory compound candidates on the matching one-second chart | This repository | [Install][install-binance-strategy27-events] | +| Binance CorsairQuant signal client | Binance Futures | Configure one gateway for Strategy27 event annotations and Strategy29 cross-timeframe summaries | This repository | [Install][install-binance-strategy27-events] | | Auto refresh | Any page | Refresh selected pages on a configurable schedule | This repository | [Install][install-auto-refresh] | | CoinMarketCap valuation labels | CoinMarketCap | Label and highlight circulating market cap and FDV / total valuation in the Chinese UI | This repository | [Install][install-coinmarketcap-valuation-helper] | | m3u8 downloader | Video pages | Enhanced m3u8 detection and export workflow for allowlisted video sites | This repository | [Install][install-m3u8] | @@ -61,9 +61,10 @@ This repository is the source of truth and distribution point for the scripts. G - Displays values and simple directional signals inside the trading page. - Reduces context switching between Binance and external data pages. -### Binance Strategy 27 event annotations +### Binance CorsairQuant signal client - Reads only the authenticated loopback gateway through an SSH local forward. +- Updates the existing Strategy27 installation in place and retains its private gateway configuration for all remote modules. - Adds no Binance market-data WebSocket or Binance API key to Chrome. - Draws bounded, transient event markers and objective four-force notes on the matching one-second chart. diff --git a/README.zh-CN.md b/README.zh-CN.md index a8a8bf4..d0b134e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -35,11 +35,11 @@ | 脚本 | 适用场景 | 说明 | 源码真源 | 安装 | |---|---|---|---|---| -| 【自写】Binance Strategy 29 布林带信号 | Binance Futures | 标注原生图表已加载 K 线,并可选显示经认证的服务端跨周期摘要;可单独运行或与订单簿 2.7.199+ 共存 | 本仓库 | [安装][install-binance-strategy29-bollinger] | +| 【自写】Binance Strategy 29 布林带信号 | Binance Futures | Annotate native loaded candles; remote summaries belong to the CorsairQuant signal client | 本仓库 | [安装][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] | -| 【自写】Binance Strategy 27 事件标注 | Binance Futures | Draw VPS-computed order-flow observations and exploratory compound candidates on the matching one-second chart | 本仓库 | [`点击安装`][install-binance-strategy27-events] | +| 【自写】Binance Strategy 27 事件标注(CorsairQuant 信号客户端) | Binance Futures | Configure one gateway for Strategy27 event annotations and Strategy29 cross-timeframe summaries | 本仓库 | [`点击安装`][install-binance-strategy27-events] | | 【自写】定时刷新指定页面 | Any page | 指定页面按设定时间自动刷新 | 本仓库 | [`点击安装`][install-auto-refresh] | | 【自写】CoinMarketCap 估值口径命名 | CoinMarketCap | 在中文币种页面左上角统计区标注并高亮流通市值和 FDV / 总估值 | 本仓库 | [`点击安装`][install-coinmarketcap-valuation-helper] | | 【改写】m3u8-downloader | Video pages | m3u8 下载增强脚本,仅在白名单视频站启用 | 本仓库 | [`点击安装`][install-m3u8] | @@ -59,7 +59,7 @@ - 使用浮动面板展示当前值和多空信号。 - 适合在交易页面内快速判断市场结构,不需要频繁切换数据网站。 -### Binance Strategy 27 事件标注 +### Binance CorsairQuant 信号客户端 - 只通过 SSH 本地转发读取带认证的本机网关。 - 不在 Chrome 中新增 Binance 行情 WebSocket,也不使用 Binance API Key。 diff --git a/docs/binance-strategy27-events-development.md b/docs/binance-strategy27-events-development.md index 65050b0..5d790a8 100644 --- a/docs/binance-strategy27-events-development.md +++ b/docs/binance-strategy27-events-development.md @@ -1,4 +1,4 @@ -# Binance Strategy 27 Event Annotations +# CorsairQuant Signal Client and Strategy27 Event Annotations This document owns the Strategy 27 gateway, chart-rendering, and entity contracts. Generic Codex/tool timeout and connection policy belongs to the global @@ -11,6 +11,23 @@ V10 live projection. The VPS remains the only market-data and event-analysis authority. The userscript opens no Binance market-data WebSocket, uses no Binance API key, and does not recalculate the four force groups. +Version 0.5.0 uses the existing installation as the CorsairQuant signal client. +Strategy29 remote ownership starts only after the local-only Strategy29 companion +publishes a valid page-scoped readiness record. Without it, the host reports +`waiting_for_companion` and leaves any legacy Strategy29 remote owner untouched. +This handshake is separate from one-time preference migration, so later reloads +continue to use the host's saved choices. Update both existing installations and +reload to complete a staged upgrade. +The historical installed script name is retained to preserve update identity. +Its private gateway URL and secret are shared by the Strategy27 consumers and +the independently controlled Strategy29 remote summary. The installation namespace +and update URLs remain unchanged; update the existing installation in place. +The retained `strategy27GatewayOrigin` and `strategy27GatewayAuthSecret` storage +keys are the single credential source, avoiding a second setup during migration. +The standalone Strategy29 script continues to own its local detector and has no +gateway request or configuration permissions. New remote modules must receive +the host's private settings adapter rather than creating another credential store. + The script reads an authenticated, loopback-only long-poll endpoint through an SSH local forward. It draws transient entities only when the Binance route, TradingView symbol, and `1S` chart interval all match the requested Strategy 27 @@ -21,13 +38,27 @@ symbol. 1. Keep an SSH local forward open from `127.0.0.1:` to the VPS Strategy 27 gateway on `127.0.0.1:8765`. 2. Install `scripts/binance-strategy27-events.user.js` in Tampermonkey. -3. Use the userscript menu to set the loopback gateway origin. The default is +3. Use the CorsairQuant userscript menu to set the loopback gateway origin. The default is `http://127.0.0.1:18765`. -4. Use the userscript menu to set the gateway installation secret. Tampermonkey +4. Use the CorsairQuant userscript menu to set the gateway installation secret. Tampermonkey stores it in this script's private value storage; it is never embedded in source, URL parameters, chart text, console messages, or status text. 5. Open the matching Binance futures route and select the one-second chart. +The secret prompt is captured from the Tampermonkey sandbox before page code can +replace the page prompt. No credential is passed through page globals, events, +localStorage or diagnostics. Strategy29's only public migration record contains +enabled state and panel coordinates, validates an exact schema and is copied once +without overwriting destination preferences. Its summary follows the route symbol +on any chart interval and remains visible while locally disabled. The host's +existing context timer samples it; visibility and pagehide abort pending summary +requests without resetting the retained cursor. A summary failure is contained at +the module boundary and does not stop the independent Strategy27 consumers. + +Use the existing forward for `/v1/strategy29/status` and `/v1/strategy29/events`. +The unified gateway's disabled module state is intentional and does not require +another secret. Server monitoring and notification activation remain separate. + The macOS operator machine keeps this forward under a `launchd` user agent so the SSH process is restarted after sleep, network changes, or a broken connection. The ordinary browser client treats `GM_xmlhttpRequest` transport @@ -138,7 +169,7 @@ ADR 032 in CorsairQuant owns the server-side rule and transport contract. The browser does not reconstruct candidates from ordinary events or recalculate market evidence. The client, lifecycle, panel, native chart layer and optional-job controller are wired into the entrypoint and tested together. The source and -generated install artifact are version 0.4.6 with identical metadata headers. +generated install artifact are version 0.5.0 with identical metadata headers. The generated artifact passes syntax, release-contract and isolated execution checks, including candidate delivery, paired entities, clear and context stop. Binance operator-page validation remains outstanding. Server/gateway rollout diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index a3b31f2..4098dab 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -3,13 +3,15 @@ ## Scope and Installation The standalone `binance-strategy29-bollinger.user.js` owns the local -Bollinger/SMA60 observer and an optional read-only Strategy29 server summary. +Bollinger/SMA60 observer. The CorsairQuant signal client, delivered through the +existing Strategy27 installation, owns the 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, +already-loaded native chart candles. The summary reads the authenticated +unified loopback gateway; it does not call Binance market-data or account APIs, submit orders, rotate hidden charts, or add remote events as chart drawings. -Install Strategy29 0.3.0 with orderbook 2.7.199 or later, or use it alone. +Install Strategy29 0.4.0 with orderbook 2.7.199 or later, or use it alone. +Install CorsairQuant signal client 0.5.0 for the remote summary. Do not combine it with the embedded observer in orderbook 2.7.198. After updating/disabling the old script, reload the page. An embedded observer is an explicit conflict: Strategy29 stops and displays an upgrade/reload notice. @@ -18,8 +20,9 @@ save owner can block safe cleanup. This is not a supported compatibility mode; Strategy29 never removes old-script or user drawings. The orderbook runs in page context. Strategy29 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 +sandbox with read access to its previous non-sensitive preferences, and passes +`unsafeWindow` explicitly to the chart runtime. Gateway credentials belong only +to the separate unified client's private storage. 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, @@ -46,6 +49,9 @@ monitor failure with a property-read failure. If a host Proxy throws during rejection classification, the monitor stops that context and records `classificationFailed: true`; normal fatal errors record false. The frozen detector/core source and its direct invalid-context assertion remain unchanged. +Time-alignment failure messages include both the requested and native returned +timestamp. A later healthy context does not clear that historical evidence or +establish why the native chart shifted the earlier marker. It describes the last fatal event, not necessarily the active context. It does not store stack traces, candles, requests or gateway credentials, and is never persisted or sent to the remote service. Recoverable snapshot races leave it unchanged. @@ -76,10 +82,11 @@ coordinates fail explicitly. No credentials cross the page boundary. ## Optional Cross-Timeframe Server Summary -The remote summary is disabled by default. Tampermonkey exposes three Strategy29 -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 +The remote summary is owned by the CorsairQuant signal client. Its single +gateway URL and secret configuration serves both Strategy27 and Strategy29, +using the existing `http://127.0.0.1:18765` local forward. The Strategy29 module +has one enable/disable menu and no separate gateway configuration. An inactive +module still displays a discoverable panel without making requests. 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 @@ -118,14 +125,29 @@ missing or incompatible facts carry null metadata. Fail-closed selection may retain a complete prior-success group. Expired selection can originate from fresh or stale facts. Clock rollback does not invalidate otherwise coherent metadata. -The current remote summary is disabled by default and has no panel while disabled. Authentication failure affects only the remote summary, not local chart detection. -Strategy27 and Strategy29 currently use separate private userscript storage. The -requested one-time gateway configuration across current and future strategies is -a pending unified-client design, including credential ownership, module lifecycle -and migration. Do not bridge credentials through page globals, localStorage or -page events, and do not ask users to duplicate secrets as the long-term solution. -A discoverable disabled-state entry is also pending, not implemented in 0.3.0. +The unified client preserves the Strategy27 installation namespace, update URL and +private gateway keys. Independent automatic updates require a readiness handshake: +the host waits for the local-only companion before creating a remote panel. A +legacy companion retains its panel and polling until refresh with both scripts +updated. The new local-only runtime has a new singleton version and refuses to +reuse a legacy remote-owning runtime. If the host has not updated, the companion +shows a localized update/reload notice while local chart detection continues. +The notice disappears when the host acknowledges ownership. Existing private +host preferences win on subsequent reloads as well as initial migration. + +Update the existing installation in place. Strategy29's +standalone entry is local-only and publishes only a versioned preference record +containing enabled state and panel coordinates. The host validates its exact keys, +copies missing preferences once into its own private storage and preserves existing +host choices. This public record cannot supply gateway credentials, origins or +event state. Both script load orders are supported; update both scripts before +reloading. The host never installs or replaces the local detector singleton. + +The unified gateway reports `module_disabled` when the server module is intentionally +off, `gateway_unavailable` when an enabled fixed backend cannot be reached, and +`database_unavailable` only for the backend's own database failure. The panel labels +these separately. Installing the unified client does not activate server monitoring. The browser polls status first and then consumes at most two event pages per scheduled poll. A new route requests `mode=latest&limit=20` for its canonical @@ -136,7 +158,7 @@ advancing that cursor. `cursor_expired` clears only remote rows and requests a n latest snapshot. Rows are displayed and bounded by descending durable sequence, independent of detection timestamps. This requires the server's explicit latest query contract; a server rejecting it stops the remote context visibly. -Publish the V2 gateway contract before the `0.3.0` client, then verify installed +Publish the unified V2 gateway contract before the client, then verify installed source identity and reload before remote acceptance. Publication of either component does not enable the observer, gateway, or notifications. diff --git a/scripts/binance-strategy27-events.user.js b/scripts/binance-strategy27-events.user.js index 684aac7..28efc2f 100644 --- a/scripts/binance-strategy27-events.user.js +++ b/scripts/binance-strategy27-events.user.js @@ -3,9 +3,9 @@ // @namespace binance.strategy27.events // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.4.6 +// @version 0.5.0 // @author jackhai9 -// @description 在 Binance 一秒图表标注 VPS Strategy 27 的实时订单流候选观察 +// @description 统一配置 CorsairQuant 网关,显示 Strategy 27 事件与 Strategy 29 跨周期汇总 // @match https://www.binance.com/*/futures/* // @match https://www.binance.com/futures/* // @exclude https://www.binance.com/*/my/wallet/futures/* @@ -1137,9 +1137,9 @@ var DEFAULT_CANDLE_WAIT_MS = 3e3; var EXACT_TIME_MATCH_MODE = 0; var PREVIOUS_OR_EXACT_TIME_MATCH_MODE = 1; - 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 routeSymbolFromChartSymbol(value) { @@ -1515,16 +1515,16 @@ var PANEL_WIDTH = 320; var DEFAULT_RIGHT_OFFSET = 84; var DEFAULT_TOP_OFFSET = 68; - function setStyles(element, styles) { - Object.assign(element.style, styles); - return element; + function setStyles(element2, styles) { + Object.assign(element2.style, styles); + return element2; } function createElement(document, tagName, { text = "", role = null, styles = null } = {}) { - const element = document.createElement(tagName); - element.textContent = text; - if (role) element.dataset.role = role; - if (styles) setStyles(element, styles); - return element; + const element2 = document.createElement(tagName); + element2.textContent = text; + if (role) element2.dataset.role = role; + if (styles) setStyles(element2, styles); + return element2; } function formatClock(timestampMs) { const date = new Date(timestampMs); @@ -2050,7 +2050,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, } function formatCompoundCandidateAnnotation(candidate, locale) { const t = createStrategy27Translator(locale); - const DIRECTIONS = Object.freeze({ + const DIRECTIONS2 = Object.freeze({ high: Object.freeze({ title: t("复合候选高", "Compound high candidate"), label: t("候选高", "High candidate"), shape: "arrow_down", color: "#B71C3B" }), low: Object.freeze({ title: t("复合候选低", "Compound low candidate"), label: t("候选低", "Low candidate"), shape: "arrow_up", color: "#087F5B" }) }); @@ -2058,7 +2058,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, high: Object.freeze({ impact_failure: t("买入推动失效", "Buy impact failure"), passive_support_loss: t("被动承接转弱", "Weakening passive support"), failed_rebound: t("反弹失败强化", "Failed rebound reinforcement") }), low: Object.freeze({ impact_failure: t("卖出推动失效", "Sell impact failure"), passive_support_loss: t("被动抛压转弱", "Weakening passive selling pressure"), failed_rebound: t("回落失败强化", "Failed pullback reinforcement") }) }); - const direction = DIRECTIONS[candidate.direction]; + const direction = DIRECTIONS2[candidate.direction]; const family = FAMILY_LABELS[candidate.direction]?.[candidate.family]; if (!direction || !family) throw new Error("Strategy 27 compound display rule is invalid"); const reinforcement = candidate.family === "failed_rebound"; @@ -2979,12 +2979,1199 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, }; } + // src/binance-strategy29-bollinger/core/remote-summary-contract.js + var STRATEGY29_SCHEMA_VERSION = 1; + var STRATEGY29_SPEC_VERSION = "29_2_spec_v2"; + var STRATEGY29_REFERENCE_SHA256 = "eece8cf16e58340910587962f3bfbb19acb72155c09a52b4b6c0570cc979ef8d"; + var TIMEFRAMES = /* @__PURE__ */ new Set(["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "1w"]); + var UNIT_STATUSES = /* @__PURE__ */ new Set(["warming", "ready", "stale", "insufficient_history", "data_gap", "failed"]); + 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", "universe", "units", "delivery_counts"]; + var UNIVERSE_KEYS = [ + "source_monitor", + "generation", + "refresh_status", + "reason", + "selected_markets", + "configured_timeframes", + "selected_unit_count", + "ready_unit_count", + "pending_unit_count", + "refreshed_at_ms", + "last_successful_refreshed_at_ms", + "last_success_age_seconds", + "last_refresh_error_at_ms", + "selection_expires_at_ms" + ]; + var UNIVERSE_STATES = /* @__PURE__ */ new Set(["fresh", "stale_if_error", "fail_closed"]); + var UNIVERSE_REASONS = { + fresh: /* @__PURE__ */ new Set(["current"]), + stale_if_error: /* @__PURE__ */ new Set(["using_stale_selection_after_refresh_error"]), + fail_closed: /* @__PURE__ */ new Set([ + "selection_fail_closed", + "selection_expired_or_unusable", + "missing_current_universe_facts", + "incompatible_current_universe_facts" + ]) + }; + var UNIT_KEYS = [ + "symbol", + "timeframe", + "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_PATTERN2 = /^[0-9a-f]{64}$/; + var ROUTE_SYMBOL_PATTERN2 = /^([A-Z0-9]+)USDT$/; + var CANONICAL_SYMBOL_PATTERN2 = /^([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 assertExactKeys2(value, keys2, name) { + assertObject(value, name); + const actual = Object.keys(value).sort(); + const expected = [...keys2].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new TypeError(`${name} must contain exact keys: ${expected.join(", ")}`); + } + } + function assertInteger2(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_PATTERN2.test(value)) { + throw new TypeError(`${name} must use canonical symbol format`); + } + } + function routeSymbolToCanonical2(value) { + if (typeof value !== "string") throw new TypeError("route symbol must be a string"); + const match = ROUTE_SYMBOL_PATTERN2.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}]`; + assertExactKeys2(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`); + assertInteger2(value.last_processed_open_ms, `${name}.last_processed_open_ms`, { nullable: true }); + assertInteger2(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_PATTERN2.test(value.last_event_id))) { + throw new TypeError(`${name}.last_event_id must be null or a lowercase hexadecimal event id`); + } + } + function validateUniverse(value) { + assertExactKeys2(value, UNIVERSE_KEYS, "status.universe"); + if (value.source_monitor !== "monitor29_bollinger_ma60") throw new TypeError("status.universe.source_monitor is invalid"); + assertInteger2(value.generation, "status.universe.generation", { nullable: true, minimum: 1 }); + assertEnum(value.refresh_status, UNIVERSE_STATES, "status.universe.refresh_status"); + assertEnum(value.reason, UNIVERSE_REASONS[value.refresh_status], "status.universe.reason"); + for (const key of ["selected_markets", "configured_timeframes"]) { + if (!Array.isArray(value[key]) || value[key].length > 128 || new Set(value[key]).size !== value[key].length) { + throw new TypeError(`status.universe.${key} must be a bounded unique array`); + } + } + value.selected_markets.forEach((symbol) => assertCanonicalSymbol(symbol, "status.universe.selected_markets")); + value.configured_timeframes.forEach((timeframe) => assertEnum(timeframe, TIMEFRAMES, "status.universe.configured_timeframes")); + for (const key of ["selected_unit_count", "ready_unit_count", "pending_unit_count"]) { + assertInteger2(value[key], `status.universe.${key}`); + if (value[key] > 128) throw new TypeError(`status.universe.${key} exceeds the unit bound`); + } + if (value.ready_unit_count + value.pending_unit_count !== value.selected_unit_count || value.selected_markets.length > value.selected_unit_count) throw new TypeError("status.universe counts are inconsistent"); + if (value.refresh_status === "fail_closed" && value.selected_unit_count !== 0) throw new TypeError("status.universe unavailable selection must be empty"); + for (const key of ["refreshed_at_ms", "last_successful_refreshed_at_ms", "last_refresh_error_at_ms", "selection_expires_at_ms"]) { + assertInteger2(value[key], `status.universe.${key}`, { nullable: true }); + } + if (value.last_success_age_seconds !== null) { + assertFiniteNumber(value.last_success_age_seconds, "status.universe.last_success_age_seconds"); + if (value.last_success_age_seconds < 0) throw new TypeError("status.universe last success age must be non-negative"); + } + if (value.last_successful_refreshed_at_ms === null !== (value.last_success_age_seconds === null)) { + throw new TypeError("status.universe last success fields are inconsistent"); + } + const successMissing = value.last_successful_refreshed_at_ms === null; + if (successMissing !== (value.selection_expires_at_ms === null)) { + throw new TypeError("status.universe successful selection requires its expiry"); + } + const absentFacts = value.reason === "missing_current_universe_facts" || value.reason === "incompatible_current_universe_facts"; + if (absentFacts) { + if ([ + value.generation, + value.refreshed_at_ms, + value.last_successful_refreshed_at_ms, + value.last_success_age_seconds, + value.last_refresh_error_at_ms, + value.selection_expires_at_ms + ].some((item) => item !== null)) { + throw new TypeError("status.universe unavailable facts must have null refresh metadata"); + } + } else { + if (value.generation === null || value.refreshed_at_ms === null) { + throw new TypeError("status.universe current facts require generation and refresh time"); + } + if (value.reason !== "selection_fail_closed" && successMissing) { + throw new TypeError("status.universe successful selection metadata is required"); + } + if (value.refresh_status === "fresh" && value.last_refresh_error_at_ms !== null) { + throw new TypeError("status.universe fresh selection cannot report a refresh error"); + } + if ((value.refresh_status === "stale_if_error" || value.reason === "selection_fail_closed") && value.last_refresh_error_at_ms === null) { + throw new TypeError("status.universe failed refresh requires its error time"); + } + } + } + function validateStrategy29StatusResponse(value, httpStatus) { + if (httpStatus !== 200) throw new TypeError(`status response requires HTTP 200, received ${httpStatus}`); + assertObject(value, "status response"); + assertSchema(value.schema_version, "status.schema_version"); + assertString(value.spec_version, "status.spec_version"); + assertInteger2(value.observed_at_ms, "status.observed_at_ms"); + if (value.spec_version !== STRATEGY29_SPEC_VERSION) return { + schema_version: value.schema_version, + spec_version: value.spec_version, + observed_at_ms: value.observed_at_ms + }; + assertExactKeys2(value, STATUS_KEYS, "status response"); + validateUniverse(value.universe); + if (!Array.isArray(value.units)) throw new TypeError("status.units must be an array"); + if (value.units.length > 128) throw new TypeError("status.units exceeds the 128-unit bound"); + value.units.forEach(validateUnit); + assertExactKeys2(value.delivery_counts, DELIVERY_COUNT_KEYS, "status.delivery_counts"); + for (const key of DELIVERY_COUNT_KEYS) { + assertInteger2(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 validateEvent2(value, index) { + const name = `events.events[${index}]`; + assertExactKeys2(value, EVENT_KEYS, name); + assertInteger2(value.sequence, `${name}.sequence`, { minimum: 1 }); + if (typeof value.event_id !== "string" || !EVENT_ID_PATTERN2.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"]) { + assertInteger2(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}`); + assertExactKeys2(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}`); + } + assertInteger2(value.observed_at_ms, "events.observed_at_ms"); + assertInteger2(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(validateEvent2); + return value; + } + function validateStrategy29GatewayError(value, httpStatus) { + if (httpStatus === 503 && (value.error === "module_disabled" || value.error === "gateway_unavailable")) { + assertExactKeys2(value, value.error === "module_disabled" ? ["schema_version", "error", "strategy_id", "status"] : ["schema_version", "error", "strategy_id"], "gateway error"); + assertSchema(value.schema_version, "gateway error.schema_version"); + if (value.strategy_id !== "29" || value.error === "module_disabled" && value.status !== "disabled") { + throw new TypeError("Strategy29 gateway module identity is invalid"); + } + return value; + } + if (httpStatus === 409) { + assertExactKeys2(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"); + assertInteger2(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}`); + assertExactKeys2(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("mode", "latest"); + url.searchParams.set("limit", "20"); + } else 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); + if (signal.aborted) throw signal.reason; + const statusBody = parseJsonResponse(statusResponse, "Strategy29 status"); + if (statusResponse.status === 503) { + const error = validateStrategy29GatewayError(statusBody, 503); + return { state: error.error === "database_unavailable" ? "unavailable" : error.error, 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); + if (signal.aborted) throw signal.reason; + const eventsBody = parseJsonResponse(eventsResponse, "Strategy29 events"); + pages += 1; + if (eventsResponse.status === 409) { + const error = validateStrategy29GatewayError(eventsBody, 409); + cursor = null; + onCursorReset(error.oldest_cursor); + hasMore = true; + continue; + } + if (eventsResponse.status === 503) { + const error = validateStrategy29GatewayError(eventsBody, 503); + return { state: error.error === "database_unavailable" ? "unavailable" : error.error, 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.has_more || page.events.length > 20)) { + throw new TypeError("Strategy29 latest snapshot must be complete and bounded to 20 events"); + } + 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/ui-copy.js + var pair = localizedText; + var SUMMARY_COPY = Object.freeze({ + upgradeClient: pair("Strategy 29 本地信号已加载。跨周期汇总需要更新或安装 Strategy 27 信号客户端,并刷新页面。", "Strategy 29 local signals are loaded. Update or install the Strategy 27 signal client and reload for the cross-timeframe summary."), + disabled: pair("跨周期汇总未启用,可在 CorsairQuant 信号客户端菜单中开启。", "Cross-timeframe summary is disabled. Enable it in the CorsairQuant signal client menu."), + moduleDisabled: pair("服务端尚未启用 Strategy 29 监控汇总", "Strategy 29 monitoring summary is not enabled on the server"), + gatewayUnavailable: pair("Strategy 29 后端暂不可用,等待恢复", "Strategy 29 backend is unavailable; waiting for recovery"), + noLiveStatus: pair("当前监控状态不可用;下方仅保留历史信号。", "Current monitoring status is unavailable; only retained signals are shown below."), + title: pair("Strategy 29 汇总", "Strategy 29 Summary"), + drag: pair("拖动面板", "Drag panel"), + collapse: pair("收起", "Collapse"), + expand: pair("展开", "Expand"), + waiting: pair("等待中", "Waiting"), + observerSpec: (value) => pair(`观察器规格 ${value}`, `Observer spec ${value}`), + reference: (value) => pair(`本地参考版本 ${value}`, `Local reference ${value}`), + noStatus: pair("尚未收到状态", "Status not received"), + noEventsCheck: pair("尚未检查事件", "Events not checked"), + processing: pair("最近处理状态", "Last processing status"), + processingHint: pair("已保存的处理状态不代表当前实时数据已就绪。", "Stored processing status does not confirm current live readiness."), + waitingDelivery: pair("全局通知 — 等待中", "Global delivery — waiting"), + recent: pair("最近跨周期信号", "Recent cross-timeframe signals"), + noEvents: pair("暂无最近信号", "No recent signals"), + close: (value) => pair(`收盘 ${value}`, `Close ${value}`), + matched: (value) => pair(`规格版本一致 · ${value}`, `Spec version matched · ${value}`), + mismatch: (local, server) => pair(`规格不一致 · 本地 ${local} · 服务端 ${server}`, `Spec mismatch · local ${local} · server ${server}`), + statusAt: (value) => pair(`状态更新 ${value}`, `Status ${value}`), + eventsAt: (value) => pair(`事件检查 ${value}`, `Events checked ${value}`), + incompatibleSelection: pair("选币不可用:观察器规格不一致", "Selection unavailable: observer specs are incompatible"), + incompatibleDelivery: pair("全局通知状态不可用:观察器规格不一致", "Global delivery unavailable: observer specs are incompatible"), + pending: pair("等待中", "pending"), + generation: (value) => pair(`批次 ${value}`, `Generation ${value}`), + markets: (value) => pair(`${value} 个币种`, `${value} markets`), + ready: (ready, total) => pair(`${ready}/${total} 个实时监测单元已就绪`, `${ready}/${total} live units ready`), + intervals: (value) => pair(`周期 ${value}`, `Intervals ${value}`), + noSuccess: pair("尚无成功选币记录", "No successful selection has been observed"), + lastSuccess: (clock2, age) => pair(`上次成功选币 ${clock2} · ${age} 秒前`, `Last successful selection ${clock2} · ${age}s ago`), + unavailableSelection: pair("服务端选币不可用", "Server selection is unavailable"), + awaitingUnits: pair("币种已入选,等待监测单元状态", "Symbol is selected; waiting for unit status"), + notSelected: pair("当前服务端选币未监听此币种", "Symbol is not watched by the current server selection"), + delivery: (c) => pair(`全局通知 · 待发送 ${c.pending} · 发送中 ${c.sending} · 已发送 ${c.sent} · 结果未知 ${c.unknown} · 已过期 ${c.expired} · 失败 ${c.failed}`, `Global delivery · Pending ${c.pending} · Sending ${c.sending} · Sent ${c.sent} · Unknown ${c.unknown} · Expired ${c.expired} · Failed ${c.failed}`), + configuration: pair("尚未配置网关密钥", "Gateway secret is not configured"), + connecting: pair("正在连接 Strategy 29 网关", "Connecting to Strategy 29 gateway"), + connected: pair("已连接", "Connected"), + moreHistory: pair("已连接 · 仍有历史记录待加载", "Connected · more history pending"), + unavailable: pair("网关数据库暂不可用", "Gateway database unavailable"), + incompatible: pair("服务端与本地规格不一致", "Server and local specs are incompatible"), + disconnected: pair("网关连接失败,将在下次定时检查时重试", "Gateway connection failed; next scheduled poll will retry"), + stopped: (detail) => pair(`远程汇总已停止。技术详情:${detail}`, `Remote summary stopped: ${detail}`), + menuToggle: pair("切换 Strategy 29 跨周期汇总", "Toggle Strategy 29 cross-timeframe summary"), + localStopped: (detail) => pair(`Strategy 29 已停止。技术详情:${detail}`, `Strategy 29 stopped: ${detail}`), + conflict: pair("Strategy 29 已停止:请将订单簿脚本更新至 2.7.199 或更高版本,或禁用内嵌布林带观察器的旧版本,然后刷新页面。", "Strategy 29 stopped: update Orderbook to 2.7.199 or disable its embedded Bollinger version, then reload this page.") + }); + var SELECTION_REASONS = Object.freeze({ + current: pair("当前选币有效", "Selection is current"), + using_stale_selection_after_refresh_error: pair("刷新失败,暂沿用上次选币直至过期", "Refresh failed; using the previous selection until expiry"), + selection_fail_closed: pair("刷新失败,选币不可用", "Selection unavailable after refresh failure"), + selection_expired_or_unusable: pair("选币已过期,等待成功刷新", "Selection expired; waiting for a successful refresh"), + missing_current_universe_facts: pair("等待服务端初始化选币", "Waiting for server selection to initialize"), + incompatible_current_universe_facts: pair("服务端选币规格不兼容", "Server selection has an incompatible specification") + }); + var STATUS_LABELS = Object.freeze({ + ready: pair("已处理", "Processed"), + warming: pair("预热中", "Warming"), + stale: pair("已过期", "Stale"), + insufficient_history: pair("历史不足", "Insufficient history"), + data_gap: pair("数据缺口", "Data gap"), + failed: pair("失败", "Failed") + }); + var SIGNAL_LABELS = Object.freeze({ + "bearish:warning": pair("看跌预警", "Bearish warning"), + "bearish:confirmed": pair("看跌确认", "Bearish confirmed"), + "bearish:reversal": pair("多头反转", "Long reversal"), + "bullish:warning": pair("看涨预警", "Bullish warning"), + "bullish:confirmed": pair("看涨确认", "Bullish confirmed"), + "bullish:reversal": pair("空头反转", "Short reversal") + }); + var PROCESSING_REASONS = Object.freeze({ + current: pair("进度已更新", "Progress current"), + awaiting_producer_generation: pair("等待当前批次行情就绪", "Awaiting producer generation"), + awaiting_initial_baseline: pair("等待建立初始历史基线", "Awaiting initial baseline"), + awaiting_reentry_baseline: pair("等待重新入选的历史基线", "Awaiting re-entry baseline"), + latest_closed_candle_missing: pair("缺少最新已收盘 K 线", "Latest closed candle missing"), + observer_progress_stale: pair("观察器进度已过期", "Observer progress stale") + }); + function processingReason(reason, locale) { + if (Object.hasOwn(PROCESSING_REASONS, reason)) return formatLocalizedText(PROCESSING_REASONS[reason], locale); + const required = /^requires_(\d+)_closed_candles$/.exec(reason); + if (required) return formatLocalizedText(pair(`需要 ${required[1]} 根已收盘 K 线`, `Requires ${required[1]} closed candles`), locale); + return formatLocalizedText(pair(`技术详情:${reason}`, `Details: ${reason}`), locale); + } + + // src/binance-strategy29-bollinger/dom/panel-position.js + function installPanelPosition(document, panel, header, { initialPosition, savePosition }) { + const view = document.defaultView; + if (!view) throw new Error("Strategy 29 panel window is unavailable"); + let position = initialPosition ?? { left: view.innerWidth - panel.getBoundingClientRect().width - 84, top: 68 }; + let drag = null; + function apply(next) { + const rect = panel.getBoundingClientRect(); + position = { + left: Math.max(0, Math.min(next.left, Math.max(0, view.innerWidth - rect.width))), + top: Math.max(0, Math.min(next.top, Math.max(0, view.innerHeight - rect.height))) + }; + panel.style.left = `${position.left}px`; + panel.style.top = `${position.top}px`; + panel.style.right = "auto"; + } + function clamp2() { + apply(position); + } + function onDown(event) { + if (event.button !== 0 || event.target.closest("button,a")) return; + const rect = panel.getBoundingClientRect(); + drag = { x: event.clientX, y: event.clientY, left: rect.left, top: rect.top }; + event.preventDefault(); + } + function onMove(event) { + if (!drag) return; + apply({ left: drag.left + event.clientX - drag.x, top: drag.top + event.clientY - drag.y }); + } + function finish() { + if (!drag) return; + drag = null; + clamp2(); + savePosition({ ...position }); + } + clamp2(); + header.style.cursor = "move"; + header.addEventListener("mousedown", onDown); + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", finish); + view.addEventListener("blur", finish); + view.addEventListener("resize", clamp2); + return Object.freeze({ + clamp: clamp2, + destroy() { + drag = null; + header.removeEventListener("mousedown", onDown); + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", finish); + view.removeEventListener("blur", finish); + view.removeEventListener("resize", clamp2); + } + }); + } + + // src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js + var PANEL_ID2 = "jh-strategy29-summary-panel"; + var STATE_COLORS = Object.freeze({ + disabled: "#848E9C", + module_disabled: "#848E9C", + gateway_unavailable: "#F0B90B", + connected: "#0ECB81", + connecting: "#F0B90B", + unavailable: "#F0B90B", + disconnected: "#F6465D", + stopped: "#F6465D", + incompatible: "#F6465D", + configuration_required: "#F0B90B" + }); + var STATUS_COLORS = Object.freeze({ + ready: "#848E9C", + warming: "#F0B90B", + stale: "#F6465D", + insufficient_history: "#F0B90B", + data_gap: "#F6465D", + failed: "#F6465D" + }); + 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 formatClock2(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 createStrategy29SummaryPanel(document, canonicalSymbol, { maxEvents = 20, locale = resolveUiLocaleFromPathname(document.location.pathname), loadPosition, savePosition } = {}) { + 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"); + const text = (value) => formatLocalizedText(value, locale); + text(SUMMARY_COPY.waiting); + if (typeof loadPosition !== "function" || typeof savePosition !== "function") throw new TypeError("Strategy 29 panel position adapters are required"); + const stored = loadPosition(); + if (stored !== null && (!stored || typeof stored !== "object" || !Number.isFinite(stored.left) || !Number.isFinite(stored.top))) throw new TypeError("Strategy 29 panel position is invalid"); + document.getElementById(PANEL_ID2)?.remove(); + const panel = element(document, "section", { + styles: { + position: "fixed", + zIndex: "999995", + left: "0", + top: "0", + 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_ID2; + const header = element(document, "header", { + styles: { display: "flex", alignItems: "center", gap: "7px", padding: "8px 10px", borderBottom: "1px solid rgba(132,142,156,.20)" } + }); + const heading = element(document, "strong", { text: text(SUMMARY_COPY.title), styles: { flex: "1", fontSize: "13px" } }); + header.appendChild(element(document, "span", { text: "☰", styles: { color: "#848E9C" } })); + header.appendChild(heading); + header.title = text(SUMMARY_COPY.drag); + const collapse = element(document, "button", { + text: text(SUMMARY_COPY.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: text(SUMMARY_COPY.waiting), role: "connection", styles: { color: "#848E9C", fontSize: "11px" } }); + const spec = element(document, "div", { text: text(SUMMARY_COPY.observerSpec(STRATEGY29_SPEC_VERSION)), role: "spec", styles: { color: "#848E9C", fontSize: "11px" } }); + const reference = element(document, "div", { text: text(SUMMARY_COPY.reference(STRATEGY29_REFERENCE_SHA256)), role: "reference", styles: { color: "#848E9C", fontSize: "10px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", userSelect: "text" } }); + const statusFreshness = element(document, "div", { text: text(SUMMARY_COPY.noStatus), role: "status-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); + const eventsFreshness = element(document, "div", { text: text(SUMMARY_COPY.noEventsCheck), role: "events-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); + const selection = element(document, "div", { role: "selection", styles: { color: "#EAECEF", fontSize: "11px" } }); + const selectionRefresh = element(document, "div", { role: "selection-refresh", styles: { color: "#848E9C", fontSize: "11px" } }); + overview.append(connection, spec, reference, statusFreshness, selection, selectionRefresh, eventsFreshness); + const unitsTitle = element(document, "div", { text: text(SUMMARY_COPY.processing), styles: { padding: "7px 10px 4px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontWeight: "600" } }); + const processingHint = element(document, "div", { + text: text(SUMMARY_COPY.processingHint), + styles: { fontSize: "11px", fontWeight: "400" } + }); + unitsTitle.appendChild(processingHint); + const units = element(document, "div", { role: "units", styles: { display: "grid", gap: "3px", padding: "0 7px 8px" } }); + const delivery = element(document, "div", { text: text(SUMMARY_COPY.waitingDelivery), role: "delivery", styles: { padding: "7px 10px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontSize: "11px" } }); + const eventsTitle = element(document, "div", { text: text(SUMMARY_COPY.recent), 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 position = installPanelPosition(document, panel, header, { initialPosition: stored, savePosition }); + const eventRecords = /* @__PURE__ */ new Map(); + let lastStatus = null; + function clearCurrentStatus() { + lastStatus = null; + spec.dataset.state = "unavailable"; + spec.style.color = "#848E9C"; + spec.textContent = text(SUMMARY_COPY.observerSpec(STRATEGY29_SPEC_VERSION)); + statusFreshness.textContent = text(SUMMARY_COPY.noStatus); + selection.dataset.state = "unavailable"; + selection.style.color = "#848E9C"; + selection.textContent = text(SUMMARY_COPY.noLiveStatus); + selectionRefresh.textContent = ""; + units.replaceChildren(); + delivery.textContent = text(SUMMARY_COPY.waitingDelivery); + } + let lastEventsAt = null; + let connectionCopy = SUMMARY_COPY.waiting; + 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.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: text(SIGNAL_LABELS[`${event.setup_direction}:${event.signal_type}`]), + styles: { color: event.signal_side === "long" ? "#0ECB81" : "#F6465D", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } + })); + row.appendChild(element(document, "span", { text: text(SUMMARY_COPY.close(formatClock2(event.bar_close_ms))), styles: { color: "#848E9C", fontSize: "10px", textAlign: "right" } })); + events.appendChild(row); + } + if (ordered.length === 0) events.appendChild(element(document, "span", { text: text(SUMMARY_COPY.noEvents), styles: { color: "#848E9C", padding: "4px" } })); + } + collapse.addEventListener("click", () => { + const collapsed = body.style.display !== "none"; + body.style.display = collapsed ? "none" : "block"; + collapse.textContent = text(collapsed ? SUMMARY_COPY.expand : SUMMARY_COPY.collapse); + position.clamp(); + }); + renderEvents(); + const api = Object.freeze({ + setLocale(nextLocale) { + assertLive(); + formatLocalizedText(SUMMARY_COPY.waiting, nextLocale); + if (locale === nextLocale) return; + locale = nextLocale; + heading.textContent = text(SUMMARY_COPY.title); + header.title = text(SUMMARY_COPY.drag); + collapse.textContent = text(body.style.display === "none" ? SUMMARY_COPY.expand : SUMMARY_COPY.collapse); + connection.textContent = text(connectionCopy); + reference.textContent = text(SUMMARY_COPY.reference(STRATEGY29_REFERENCE_SHA256)); + unitsTitle.firstChild.textContent = text(SUMMARY_COPY.processing); + processingHint.textContent = text(SUMMARY_COPY.processingHint); + eventsTitle.textContent = text(SUMMARY_COPY.recent); + eventsFreshness.textContent = text(lastEventsAt === null ? SUMMARY_COPY.noEventsCheck : SUMMARY_COPY.eventsAt(formatClock2(lastEventsAt))); + if (lastStatus !== null) api.renderStatus(lastStatus); + else clearCurrentStatus(); + renderEvents(); + position.clamp(); + }, + setConnection(state, message) { + assertLive(); + if (!(state in STATE_COLORS)) throw new Error("Strategy 29 panel connection state is invalid"); + connection.dataset.state = state; + connection.style.color = STATE_COLORS[state]; + connectionCopy = message; + connection.textContent = text(message); + if (["disabled", "module_disabled", "gateway_unavailable", "unavailable"].includes(state)) clearCurrentStatus(); + position.clamp(); + }, + renderStatus(snapshot) { + assertLive(); + lastStatus = snapshot; + const matched = snapshot.spec_version === STRATEGY29_SPEC_VERSION; + spec.dataset.state = matched ? "matched" : "error"; + spec.style.color = matched ? "#0ECB81" : "#F6465D"; + spec.textContent = matched ? text(SUMMARY_COPY.matched(STRATEGY29_SPEC_VERSION)) : text(SUMMARY_COPY.mismatch(STRATEGY29_SPEC_VERSION, snapshot.spec_version)); + statusFreshness.textContent = text(SUMMARY_COPY.statusAt(formatClock2(snapshot.observed_at_ms))); + if (!matched) { + selection.dataset.state = "incompatible"; + selection.style.color = "#F6465D"; + selection.textContent = text(SUMMARY_COPY.incompatibleSelection); + selectionRefresh.textContent = ""; + units.replaceChildren(); + delivery.textContent = text(SUMMARY_COPY.incompatibleDelivery); + position.clamp(); + return; + } + const universe = snapshot.universe; + const unavailable = universe.refresh_status === "fail_closed"; + selection.dataset.state = universe.refresh_status; + selection.style.color = unavailable ? "#F6465D" : universe.refresh_status === "fresh" ? "#0ECB81" : "#F0B90B"; + selection.textContent = [text(SELECTION_REASONS[universe.reason]), text(SUMMARY_COPY.generation(universe.generation ?? text(SUMMARY_COPY.pending))), text(SUMMARY_COPY.markets(universe.selected_markets.length)), text(SUMMARY_COPY.ready(universe.ready_unit_count, universe.selected_unit_count)), text(SUMMARY_COPY.intervals(universe.configured_timeframes.join(", ") || text(SUMMARY_COPY.pending)))].join(" · "); + selectionRefresh.textContent = universe.last_successful_refreshed_at_ms === null ? text(SUMMARY_COPY.noSuccess) : text(SUMMARY_COPY.lastSuccess(formatClock2(universe.last_successful_refreshed_at_ms), universe.last_success_age_seconds.toFixed(1))); + units.replaceChildren(); + const selected = universe.selected_markets.includes(canonicalSymbol); + const matching = unavailable || !selected ? [] : snapshot.units.filter((unit) => unit.symbol === canonicalSymbol && universe.configured_timeframes.includes(unit.timeframe)); + for (const unit of matching) { + const row = element(document, "div", { + role: "unit", + styles: { display: "grid", gridTemplateColumns: "36px 78px 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: text(STATUS_LABELS[unit.status]), styles: { color: STATUS_COLORS[unit.status] } })); + row.appendChild(element(document, "span", { text: processingReason(unit.reason, locale), styles: { color: "#848E9C", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } })); + units.appendChild(row); + } + if (matching.length === 0) units.appendChild(element(document, "span", { + text: text(unavailable ? SUMMARY_COPY.unavailableSelection : selected ? SUMMARY_COPY.awaitingUnits : SUMMARY_COPY.notSelected), + styles: { color: "#F0B90B", padding: "4px" } + })); + const counts = snapshot.delivery_counts; + delivery.textContent = text(SUMMARY_COPY.delivery(counts)); + position.clamp(); + }, + addEvents(incoming, observedAtMs = null) { + assertLive(); + for (const event of incoming) eventRecords.set(event.event_id, event); + const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence); + while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id); + if (observedAtMs !== null) { + lastEventsAt = observedAtMs; + eventsFreshness.textContent = text(SUMMARY_COPY.eventsAt(formatClock2(observedAtMs))); + } + renderEvents(); + position.clamp(); + }, + clearEvents() { + assertLive(); + eventRecords.clear(); + renderEvents(); + position.clamp(); + }, + destroy() { + if (destroyed) return; + destroyed = true; + eventRecords.clear(); + position.destroy(); + panel.remove(); + }, + get size() { + return eventRecords.size; + } + }); + position.clamp(); + return api; + } + + // src/binance-strategy29-bollinger/remote-summary.js + var STRATEGY29_PANEL_POSITION_KEY = "strategy29SummaryPanelPosition"; + var STRATEGY29_REMOTE_ENABLED_KEY = "strategy29RemoteSummaryEnabled"; + var STRATEGY29_REMOTE_POLL_INTERVAL_MS = 5e3; + function abortError2(view, message) { + const ErrorConstructor = view.DOMException ?? DOMException; + return new ErrorConstructor(message, "AbortError"); + } + function assertAdapters({ view, request, getValue, setValue, registerMenuCommand, getGatewaySettings, 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, + getGatewaySettings, + createPanel, + createClient + })) { + if (typeof value !== "function") throw new TypeError(`Strategy 29 remote summary ${name} is invalid`); + } + } + function createStrategy29RemoteSummary({ + view, + request, + getValue, + setValue, + registerMenuCommand, + getGatewaySettings, + createPanel = createStrategy29SummaryPanel, + createClient = createStrategy29SummaryClient, + pollIntervalMs = STRATEGY29_REMOTE_POLL_INTERVAL_MS + }) { + assertAdapters({ view, request, getValue, setValue, registerMenuCommand, getGatewaySettings, 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; + let locale = resolveUiLocaleFromPathname(view.location.pathname); + const text = (value) => formatLocalizedText(value, locale); + 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(abortError2(view, reason)); + context.panel.destroy(); + } + function configuredSettings() { + const { authSecret, gatewayOrigin } = getGatewaySettings(); + if (typeof authSecret !== "string") throw new TypeError("Strategy 29 gateway secret storage is invalid"); + return { authSecret, gatewayOrigin: normalizeStrategy29GatewayOrigin(gatewayOrigin) }; + } + function startContext(routeSymbol) { + const canonicalSymbol = routeSymbolToCanonical2(routeSymbol); + const panel = createPanel(view.document, canonicalSymbol, { + maxEvents: 20, + locale, + loadPosition: () => getValue(STRATEGY29_PANEL_POSITION_KEY, null), + savePosition: (position) => setValue(STRATEGY29_PANEL_POSITION_KEY, position) + }); + 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; + if (!enabled) { + context.state = "disabled"; + panel.setConnection("disabled", SUMMARY_COPY.disabled); + return 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", SUMMARY_COPY.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", SUMMARY_COPY.configuration); + 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", SUMMARY_COPY.stopped(error.message)); + view.console.warn("[Strategy29 remote]", error.message); + } + return context; + } + function synchronizeContext() { + if (!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 || view.document.hidden) return; + synchronizeLocale(); + const context = synchronizeContext(); + if (!context || !context.client || context.inFlight || context.failed || nowMs < context.nextPollAtMs) return; + if (context.abortController.signal.aborted) { + const AbortControllerConstructor = view.AbortController ?? AbortController; + context.abortController = new AbortControllerConstructor(); + } + const controller = context.abortController; + const ownsRequest = () => isCurrent(context) && context.abortController === controller; + context.nextPollAtMs = nowMs + pollIntervalMs; + context.inFlight = true; + context.state = "connecting"; + context.panel.setConnection("connecting", SUMMARY_COPY.connecting); + return context.client.poll(controller.signal).then((result) => { + if (!ownsRequest()) return; + context.lastResult = result; + context.lastError = null; + context.state = result.state; + const presentation = { + connected: ["connected", result.hasMore ? SUMMARY_COPY.moreHistory : SUMMARY_COPY.connected], + unavailable: ["unavailable", SUMMARY_COPY.unavailable], + module_disabled: ["module_disabled", SUMMARY_COPY.moduleDisabled], + gateway_unavailable: ["gateway_unavailable", SUMMARY_COPY.gatewayUnavailable], + incompatible: ["incompatible", SUMMARY_COPY.incompatible] + }[result.state]; + if (!presentation) throw new Error(`Strategy 29 remote state is invalid: ${result.state}`); + context.panel.setConnection(...presentation); + }).catch((error) => { + if (!ownsRequest() || error?.name === "AbortError") return; + context.lastError = error.message; + if (error instanceof Strategy29GatewayTransportError) { + context.state = "disconnected"; + context.panel.setConnection("disconnected", SUMMARY_COPY.disconnected); + } else { + context.state = "stopped"; + context.failed = true; + context.panel.setConnection("stopped", SUMMARY_COPY.stopped(error.message)); + } + view.console.warn("[Strategy29 remote]", error.message); + }).finally(() => { + if (ownsRequest()) context.inFlight = false; + }); + } + function restart() { + enabled = getValue(STRATEGY29_REMOTE_ENABLED_KEY, false) === true; + unsupportedRoute = null; + stopActive("Strategy 29 remote settings changed"); + if (!disposed) void sample(Date.now()); + } + const menus = [ + { copy: SUMMARY_COPY.menuToggle, run() { + enabled = !enabled; + setValue(STRATEGY29_REMOTE_ENABLED_KEY, enabled); + restart(); + } } + ]; + for (const menu of menus) menu.id = registerMenuCommand(text(menu.copy), menu.run); + function synchronizeLocale() { + const current = resolveUiLocaleFromPathname(view.location.pathname); + if (current === locale) return; + locale = current; + for (const menu of menus) menu.id = registerMenuCommand(text(menu.copy), menu.run, { id: menu.id }); + active?.panel.setLocale(locale); + } + return Object.freeze({ + sample, + pause() { + if (!active) return; + active.abortController.abort(abortError2(view, "Strategy 29 remote summary paused")); + active.inFlight = false; + active.nextPollAtMs = 0; + }, + 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/shared/signal-client-settings.js + var SIGNAL_GATEWAY_ORIGIN_KEY = "strategy27GatewayOrigin"; + var SIGNAL_GATEWAY_SECRET_KEY = "strategy27GatewayAuthSecret"; + var SIGNAL_GATEWAY_ORIGIN = "http://127.0.0.1:18765"; + function readSignalGatewaySettings(getValue) { + const authSecret = getValue(SIGNAL_GATEWAY_SECRET_KEY, ""); + if (typeof authSecret !== "string") throw new TypeError("Signal gateway secret storage is invalid"); + return { + authSecret, + gatewayOrigin: normalizeGatewayBaseUrl(getValue(SIGNAL_GATEWAY_ORIGIN_KEY, SIGNAL_GATEWAY_ORIGIN)) + }; + } + + // src/shared/strategy29-preferences-migration.js + var RECORD = Symbol.for("jh-userscripts.strategy29-preferences-migration"); + var STRATEGY29_PREFERENCES_EVENT = "jh-strategy29-preferences-ready"; + var SIGNAL_HOST_READY = Symbol.for("jh-userscripts.strategy29-signal-host-ready"); + var SIGNAL_HOST_READY_EVENT = "jh-strategy29-signal-host-ready"; + var COMPLETE = "strategy29UnifiedPreferencesMigrated"; + var ENABLED = "strategy29RemoteSummaryEnabled"; + var POSITION = "strategy29SummaryPanelPosition"; + function validate(record) { + if (!record || Object.keys(record).sort().join(",") !== "enabled,position,version" || record.version !== 1 || typeof record.enabled !== "boolean") { + throw new TypeError("Strategy29 preference migration record is invalid"); + } + const point = record.position; + if (point !== null && (Object.keys(point).sort().join(",") !== "left,top" || !Number.isFinite(point.left) || !Number.isFinite(point.top))) { + throw new TypeError("Strategy29 preference migration position is invalid"); + } + return { version: 1, enabled: record.enabled, position: point === null ? null : { left: point.left, top: point.top } }; + } + function migrateStrategy29Preferences(view, getValue, setValue) { + if (!isStrategy29CompanionReady(view) || getValue(COMPLETE, false) === true) return false; + const record = validate(view[RECORD]); + if (getValue(ENABLED, null) === null) setValue(ENABLED, record.enabled); + if (getValue(POSITION, null) === null && record.position !== null) setValue(POSITION, record.position); + setValue(COMPLETE, true); + return true; + } + function isStrategy29CompanionReady(view) { + if (view[RECORD] === void 0) return false; + validate(view[RECORD]); + return true; + } + // src/binance-strategy27-events/index.user.js + var promptUser = globalThis.prompt.bind(globalThis); (function() { "use strict"; - const DEFAULT_GATEWAY_ORIGIN = "http://127.0.0.1:18765"; - const GATEWAY_ORIGIN_KEY = "strategy27GatewayOrigin"; - const GATEWAY_SECRET_KEY = "strategy27GatewayAuthSecret"; + const DEFAULT_GATEWAY_ORIGIN = SIGNAL_GATEWAY_ORIGIN; + const GATEWAY_ORIGIN_KEY = SIGNAL_GATEWAY_ORIGIN_KEY; + const GATEWAY_SECRET_KEY = SIGNAL_GATEWAY_SECRET_KEY; const PANEL_POSITION_KEY = "strategy27EventPanelPosition"; const CONTEXT_CHECK_INTERVAL_MS = 1e3; const MAX_RETAINED_EVENTS = 80; @@ -2998,6 +4185,68 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, let uiLocale = resolveUiLocaleFromPathname(page.location.pathname); let t = createStrategy27Translator(uiLocale); let statusCopy = null; + let strategy29Summary = null; + let strategy29Failure = null; + function failStrategy29(error) { + if (strategy29Failure !== null) return; + strategy29Failure = String(error.message).slice(0, 512); + if (strategy29Summary !== null) strategy29Summary.dispose(); + page.console.warn("[CorsairQuant Strategy29]", strategy29Failure); + } + function initializeStrategy29() { + if (strategy29Summary !== null || !isStrategy29CompanionReady(page)) return; + migrateStrategy29Preferences(page, GM_getValue, GM_setValue); + strategy29Summary = createStrategy29RemoteSummary({ + view: page, + request: createStrategy29GmJsonRequest(GM_xmlhttpRequest), + getValue: GM_getValue, + setValue: GM_setValue, + registerMenuCommand: GM_registerMenuCommand, + getGatewaySettings: () => readSignalGatewaySettings(GM_getValue) + }); + Object.defineProperty(page, SIGNAL_HOST_READY, { value: true }); + page.dispatchEvent(new page.Event(SIGNAL_HOST_READY_EVENT)); + } + try { + initializeStrategy29(); + } catch (error) { + failStrategy29(error); + } + function sampleStrategy29() { + if (strategy29Failure !== null || strategy29Summary === null) return; + try { + const pending = strategy29Summary.sample(Date.now()); + if (pending) void pending.catch(failStrategy29); + } catch (error) { + failStrategy29(error); + } + } + function onStrategy29Preferences() { + if (strategy29Failure !== null) return; + try { + initializeStrategy29(); + sampleStrategy29(); + } catch (error) { + failStrategy29(error); + } + } + page.addEventListener(STRATEGY29_PREFERENCES_EVENT, onStrategy29Preferences); + function onSummaryVisibility() { + if (strategy29Failure !== null || strategy29Summary === null) return; + if (pageDocument.hidden) strategy29Summary.pause(); + else sampleStrategy29(); + } + function pauseSummary() { + if (strategy29Failure === null && strategy29Summary !== null) strategy29Summary.pause(); + } + pageDocument.addEventListener("visibilitychange", onSummaryVisibility); + page.addEventListener("pagehide", pauseSummary); + page.addEventListener("pageshow", onSummaryVisibility); + Object.defineProperty(page, "__TM_SIGNAL_CLIENT_DEBUG__", { + value: Object.freeze({ get strategy29() { + return strategy29Summary === null ? { state: strategy29Failure === null ? "waiting_for_companion" : "initialization_failed", moduleFailure: strategy29Failure } : { ...strategy29Summary.diagnostics, moduleFailure: strategy29Failure }; + } }) + }); function stopActive(resetReason) { if (!active) return; active.controller.abort(); @@ -3189,6 +4438,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, client.run(context.controller.signal).catch((error) => failOrdinary(context, error)); } function synchronizeContext() { + sampleStrategy29(); const nextLocale = resolveUiLocaleFromPathname(page.location.pathname); if (nextLocale !== uiLocale) { uiLocale = nextLocale; @@ -3264,19 +4514,26 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, } function restart() { stopActive("route_changed"); + if (strategy29Failure === null && strategy29Summary !== null) { + try { + strategy29Summary.restart(); + } catch (error) { + failStrategy29(error); + } + } synchronizeContext(); } const menuDefinitions = [ - { label: () => t("设置 Strategy 27 网关密钥", "Set Strategy 27 gateway secret"), run: () => { - const value = page.prompt(t("输入本机 Strategy 27 网关密钥。该值只保存在此油猴脚本的私有存储中。", "Enter the local Strategy 27 gateway secret. It is saved only in this userscript’s private storage.")); + { label: () => t("设置 CorsairQuant 网关密钥", "Set CorsairQuant gateway secret"), run: () => { + const value = promptUser(t("输入 CorsairQuant 网关密钥。所有远程策略模块共用此配置,仅保存在当前用户脚本私有存储中。", "Enter the CorsairQuant gateway secret. All remote strategy modules share this private userscript configuration.")); if (value === null) return; - if (value.length === 0) throw new Error(t("Strategy 27 网关密钥不能为空", "Strategy 27 gateway secret must not be empty")); + if (value.length === 0) throw new Error(t("CorsairQuant 网关密钥不能为空", "CorsairQuant gateway secret must not be empty")); GM_setValue(GATEWAY_SECRET_KEY, value); restart(); } }, - { label: () => t("设置 Strategy 27 本机网关地址", "Set Strategy 27 local gateway URL"), run: () => { + { label: () => t("设置 CorsairQuant 本机网关地址", "Set CorsairQuant local gateway URL"), run: () => { const current = GM_getValue(GATEWAY_ORIGIN_KEY, DEFAULT_GATEWAY_ORIGIN); - const value = page.prompt(t("输入 SSH 本地转发地址(仅允许 http://127.0.0.1:<端口>)", "Enter the SSH local forwarding URL (only http://127.0.0.1: is allowed)"), current); + const value = promptUser(t("输入 SSH 本地转发地址(仅允许 http://127.0.0.1:<端口>)", "Enter the SSH local forwarding URL (only http://127.0.0.1: is allowed)"), current); if (value === null) return; GM_setValue(GATEWAY_ORIGIN_KEY, normalizeGatewayBaseUrl(value)); restart(); @@ -3304,6 +4561,11 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, page.addEventListener("beforeunload", () => { page.clearInterval(contextTimer); removeRouteListener(); + page.removeEventListener(STRATEGY29_PREFERENCES_EVENT, onStrategy29Preferences); + pageDocument.removeEventListener("visibilitychange", onSummaryVisibility); + page.removeEventListener("pagehide", pauseSummary); + page.removeEventListener("pageshow", onSummaryVisibility); + if (strategy29Summary !== null) strategy29Summary.dispose(); stopActive("route_changed"); }, { once: true }); synchronizeContext(); diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index 918eefc..cc6e24a 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -3,1842 +3,1807 @@ // @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.3.0 +// @version 0.4.0 // @author jackhai9 // @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 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_v2"; - var STRATEGY29_REFERENCE_SHA256 = "eece8cf16e58340910587962f3bfbb19acb72155c09a52b4b6c0570cc979ef8d"; - var TIMEFRAMES = /* @__PURE__ */ new Set(["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "1w"]); - var UNIT_STATUSES = /* @__PURE__ */ new Set(["warming", "ready", "stale", "insufficient_history", "data_gap", "failed"]); - 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", "universe", "units", "delivery_counts"]; - var UNIVERSE_KEYS = [ - "source_monitor", - "generation", - "refresh_status", - "reason", - "selected_markets", - "configured_timeframes", - "selected_unit_count", - "ready_unit_count", - "pending_unit_count", - "refreshed_at_ms", - "last_successful_refreshed_at_ms", - "last_success_age_seconds", - "last_refresh_error_at_ms", - "selection_expires_at_ms" - ]; - var UNIVERSE_STATES = /* @__PURE__ */ new Set(["fresh", "stale_if_error", "fail_closed"]); - var UNIVERSE_REASONS = { - fresh: /* @__PURE__ */ new Set(["current"]), - stale_if_error: /* @__PURE__ */ new Set(["using_stale_selection_after_refresh_error"]), - fail_closed: /* @__PURE__ */ new Set([ - "selection_fail_closed", - "selection_expired_or_unusable", - "missing_current_universe_facts", - "incompatible_current_universe_facts" - ]) - }; - var UNIT_KEYS = [ - "symbol", - "timeframe", - "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(", ")}`); + // src/binance-strategy29-bollinger/core/bearish-bollinger-pattern.js + var BOLLINGER_PATTERN = Object.freeze({ + bollingerPeriod: 20, + bollingerStdDev: 2, + maPeriod: 60, + preCrossBars: 8, + minPreCrossChannelCloses: 4, + maxPreCrossAboveMiddleCloses: 1, + maxPreCrossBelowLowerCloses: 3, + trendLookbackBars: 3, + minMiddleDeclineBandFraction: 0.01, + postCrossBars: 20, + middleApproachBandFraction: 0.12, + maxPostCrossCloseAboveMiddleBandFraction: 0.05, + lowerTouchBandFraction: 0.05, + reversalFollowBars: 60 + }); + var TradingViewBarSnapshotInconsistentError = class extends Error { + constructor(message) { + super(message); + this.name = "TradingViewBarSnapshotInconsistentError"; } + }; + function isTradingViewBarSnapshotInconsistentError(error) { + return error instanceof TradingViewBarSnapshotInconsistentError; } - 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 applyBollingerAlertTaskFailure(context, error) { + if (!context || typeof context !== "object" || typeof context.failed !== "boolean" || typeof context.cleanupPending !== "boolean") { + throw new Error("Bollinger alert task context is invalid"); } + if (isTradingViewBarSnapshotInconsistentError(error)) return "retry"; + context.failed = true; + context.cleanupPending = true; + return "fatal"; } - 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 assertFiniteNumber(value, label) { + if (!Number.isFinite(value)) throw new Error(`${label} is invalid`); } - function validateUniverse(value) { - assertExactKeys(value, UNIVERSE_KEYS, "status.universe"); - if (value.source_monitor !== "monitor29_bollinger_ma60") throw new TypeError("status.universe.source_monitor is invalid"); - assertInteger(value.generation, "status.universe.generation", { nullable: true, minimum: 1 }); - assertEnum(value.refresh_status, UNIVERSE_STATES, "status.universe.refresh_status"); - assertEnum(value.reason, UNIVERSE_REASONS[value.refresh_status], "status.universe.reason"); - for (const key of ["selected_markets", "configured_timeframes"]) { - if (!Array.isArray(value[key]) || value[key].length > 128 || new Set(value[key]).size !== value[key].length) { - throw new TypeError(`status.universe.${key} must be a bounded unique array`); - } - } - value.selected_markets.forEach((symbol) => assertCanonicalSymbol(symbol, "status.universe.selected_markets")); - value.configured_timeframes.forEach((timeframe) => assertEnum(timeframe, TIMEFRAMES, "status.universe.configured_timeframes")); - for (const key of ["selected_unit_count", "ready_unit_count", "pending_unit_count"]) { - assertInteger(value[key], `status.universe.${key}`); - if (value[key] > 128) throw new TypeError(`status.universe.${key} exceeds the unit bound`); - } - if (value.ready_unit_count + value.pending_unit_count !== value.selected_unit_count || value.selected_markets.length > value.selected_unit_count) throw new TypeError("status.universe counts are inconsistent"); - if (value.refresh_status === "fail_closed" && value.selected_unit_count !== 0) throw new TypeError("status.universe unavailable selection must be empty"); - for (const key of ["refreshed_at_ms", "last_successful_refreshed_at_ms", "last_refresh_error_at_ms", "selection_expires_at_ms"]) { - assertInteger(value[key], `status.universe.${key}`, { nullable: true }); - } - if (value.last_success_age_seconds !== null) { - assertFiniteNumber(value.last_success_age_seconds, "status.universe.last_success_age_seconds"); - if (value.last_success_age_seconds < 0) throw new TypeError("status.universe last success age must be non-negative"); - } - if (value.last_successful_refreshed_at_ms === null !== (value.last_success_age_seconds === null)) { - throw new TypeError("status.universe last success fields are inconsistent"); - } - const successMissing = value.last_successful_refreshed_at_ms === null; - if (successMissing !== (value.selection_expires_at_ms === null)) { - throw new TypeError("status.universe successful selection requires its expiry"); - } - const absentFacts = value.reason === "missing_current_universe_facts" || value.reason === "incompatible_current_universe_facts"; - if (absentFacts) { - if ([ - value.generation, - value.refreshed_at_ms, - value.last_successful_refreshed_at_ms, - value.last_success_age_seconds, - value.last_refresh_error_at_ms, - value.selection_expires_at_ms - ].some((item) => item !== null)) { - throw new TypeError("status.universe unavailable facts must have null refresh metadata"); + function assertBars(bars, directionLabel) { + if (!Array.isArray(bars)) throw new Error(`${directionLabel} Bollinger bars must be an array`); + let previousTime = -Infinity; + for (const [index, bar] of bars.entries()) { + if (!bar || typeof bar !== "object") { + throw new Error(`${directionLabel} Bollinger bar ${index} is invalid`); } - } else { - if (value.generation === null || value.refreshed_at_ms === null) { - throw new TypeError("status.universe current facts require generation and refresh time"); + if (!Number.isInteger(bar.time)) { + throw new Error(`${directionLabel} Bollinger bar time ${index} is invalid`); } - if (value.reason !== "selection_fail_closed" && successMissing) { - throw new TypeError("status.universe successful selection metadata is required"); + if (bar.time <= previousTime) { + throw new TradingViewBarSnapshotInconsistentError( + `${directionLabel} Bollinger bar time ${index} is invalid` + ); } - if (value.refresh_status === "fresh" && value.last_refresh_error_at_ms !== null) { - throw new TypeError("status.universe fresh selection cannot report a refresh error"); + for (const field of ["open", "high", "low", "close"]) { + assertFiniteNumber(bar[field], `${directionLabel} Bollinger bar ${index} ${field}`); } - if ((value.refresh_status === "stale_if_error" || value.reason === "selection_fail_closed") && value.last_refresh_error_at_ms === null) { - throw new TypeError("status.universe failed refresh requires its error time"); + if (bar.high < bar.low || bar.high < Math.max(bar.open, bar.close) || bar.low > Math.min(bar.open, bar.close)) { + throw new TradingViewBarSnapshotInconsistentError( + `${directionLabel} Bollinger bar ${index} OHLC range is invalid` + ); } + previousTime = bar.time; } } - function validateStrategy29StatusResponse(value, httpStatus) { - if (httpStatus !== 200) throw new TypeError(`status response requires HTTP 200, received ${httpStatus}`); - assertObject(value, "status response"); - assertSchema(value.schema_version, "status.schema_version"); - assertString(value.spec_version, "status.spec_version"); - assertInteger(value.observed_at_ms, "status.observed_at_ms"); - if (value.spec_version !== STRATEGY29_SPEC_VERSION) return { - schema_version: value.schema_version, - spec_version: value.spec_version, - observed_at_ms: value.observed_at_ms - }; - assertExactKeys(value, STATUS_KEYS, "status response"); - validateUniverse(value.universe); - if (!Array.isArray(value.units)) throw new TypeError("status.units must be an array"); - if (value.units.length > 128) throw new TypeError("status.units exceeds the 128-unit bound"); - 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}`); + function assertIndicatorBars(indicatorBars, directionLabel) { + if (!Array.isArray(indicatorBars)) { + throw new Error(`${directionLabel} Bollinger indicator bars must be an array`); } - 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}`); + assertBars(indicatorBars, directionLabel); + for (const [index, bar] of indicatorBars.entries()) { + const fields = ["middle", "upper", "lower", "ma60"]; + const nullFields = fields.filter((field) => bar[field] === null); + if (nullFields.length !== 0 && nullFields.length !== fields.length) { + throw new Error(`${directionLabel} Bollinger indicator bar ${index} is incomplete`); + } + for (const field of fields) { + if (bar[field] !== null) { + assertFiniteNumber( + bar[field], + `${directionLabel} Bollinger indicator bar ${index} ${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 calculateBollingerIndicatorBars(bars, directionLabel) { + assertBars(bars, directionLabel); + const config = BOLLINGER_PATTERN; + return bars.map((bar, index) => { + if (index < config.maPeriod - 1) { + return { ...bar, middle: null, upper: null, lower: null, ma60: null }; + } + const start = index - config.bollingerPeriod + 1; + let closeSum = 0; + for (let cursor = start; cursor <= index; cursor += 1) closeSum += bars[cursor].close; + const middle = closeSum / config.bollingerPeriod; + let squaredDeviationSum = 0; + for (let cursor = start; cursor <= index; cursor += 1) { + squaredDeviationSum += (bars[cursor].close - middle) ** 2; + } + const deviation = Math.sqrt(squaredDeviationSum / config.bollingerPeriod) * config.bollingerStdDev; + let maSum = 0; + for (let cursor = index - config.maPeriod + 1; cursor <= index; cursor += 1) maSum += bars[cursor].close; + return { + ...bar, + middle, + upper: middle + deviation, + lower: middle - deviation, + ma60: maSum / config.maPeriod + }; + }); } - 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; + function bandWidth(bar) { + const width = bar.upper - bar.lower; + if (!(width > 0)) throw new Error(`Bollinger band width is invalid at ${bar.time}`); + return width; } - - // 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 hasDownwardBandCenter(indicatorBars, index) { + const { trendLookbackBars, minMiddleDeclineBandFraction } = BOLLINGER_PATTERN; + const current = indicatorBars[index]; + const earlier = indicatorBars[index - trendLookbackBars]; + const averageWidth = (bandWidth(current) + bandWidth(earlier)) / 2; + return (earlier.middle - current.middle) / averageWidth >= minMiddleDeclineBandFraction; } - 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 isRejectedAboveMiddleClose(indicatorBars, index) { + const next = indicatorBars[index + 1]; + return next.close < next.middle && next.close < next.open; } - 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 matchesPreCrossCompression(indicatorBars, crossIndex) { + const config = BOLLINGER_PATTERN; + const start = crossIndex - config.preCrossBars; + const preCross = indicatorBars.slice(start, crossIndex); + const channelCloses = preCross.filter( + (bar) => bar.close >= bar.lower && bar.close <= bar.middle + ).length; + const aboveMiddleIndexes = []; + let belowLowerCloses = 0; + for (let offset = 0; offset < preCross.length; offset += 1) { + const bar = preCross[offset]; + if (bar.close > bar.middle) aboveMiddleIndexes.push(start + offset); + if (bar.close < bar.lower) belowLowerCloses += 1; } + return channelCloses >= config.minPreCrossChannelCloses && aboveMiddleIndexes.length <= config.maxPreCrossAboveMiddleCloses && belowLowerCloses <= config.maxPreCrossBelowLowerCloses && aboveMiddleIndexes.every((index) => isRejectedAboveMiddleClose(indicatorBars, index)); } - 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 isDownwardCross(previous, current) { + return previous.middle >= previous.ma60 && current.middle < current.ma60; } - function buildEventsUrl(origin, canonicalSymbol, cursor) { - const url = new URL("/v1/strategy29/events", origin); - url.searchParams.set("symbol", canonicalSymbol); - if (cursor === null) { - url.searchParams.set("mode", "latest"); - url.searchParams.set("limit", "20"); - } else url.searchParams.set("cursor", String(cursor)); - return url.href; + function buildSignal(type, setup, bar) { + const width = bandWidth(bar); + const markerGapFraction = type === "warning" ? 0.06 : 0.1; + return Object.freeze({ + id: `${setup.time}:${type}`, + type, + setupTime: setup.time, + time: bar.time, + markerPrice: type === "reversal" ? bar.low - width * markerGapFraction : bar.high + width * markerGapFraction + }); } - 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 }); + function detectReversalSignal(indicatorBars, setup, warningIndex) { + const { reversalFollowBars } = BOLLINGER_PATTERN; + const warning = indicatorBars[warningIndex]; + const endIndex = Math.min( + indicatorBars.length - 1, + warningIndex + reversalFollowBars + ); + for (let index = warningIndex + 1; index <= endIndex; index += 1) { + const bar = indicatorBars[index]; + if (bar.close > warning.high) return buildSignal("reversal", setup, bar); } - async function poll(signal) { - const statusResponse = await perform(`${origin}/v1/strategy29/status`, signal); - if (signal.aborted) throw signal.reason; - const statusBody = parseJsonResponse(statusResponse, "Strategy29 status"); - if (statusResponse.status === 503) { - validateStrategy29GatewayError(statusBody, 503); - return { state: "unavailable", pages: 0, hasMore: false }; + return null; + } + function detectSetupSignals(indicatorBars, crossIndex) { + const config = BOLLINGER_PATTERN; + const setup = indicatorBars[crossIndex]; + const signals = []; + let warningIndex = null; + let pendingMiddleRejection = false; + let aboveMiddleCloseCount = 0; + const endIndex = Math.min( + indicatorBars.length - 1, + crossIndex + config.postCrossBars + ); + for (let index = crossIndex + 1; index <= endIndex; index += 1) { + const bar = indicatorBars[index]; + const width = bandWidth(bar); + if (pendingMiddleRejection) { + if (!(bar.close < bar.middle && bar.close < bar.open)) break; + pendingMiddleRejection = false; } - if (statusResponse.status !== 200) { - validateStrategy29GatewayError(statusBody, statusResponse.status); - throw new Error(`Strategy29 status request failed with HTTP ${statusResponse.status}`); + if (bar.close > bar.middle) { + aboveMiddleCloseCount += 1; + if (aboveMiddleCloseCount > 1 || bar.close > bar.middle + width * config.maxPostCrossCloseAboveMiddleBandFraction || bar.close > bar.upper) break; + pendingMiddleRejection = true; + continue; } - const status = validateStrategy29StatusResponse(statusBody, 200); - onStatus(status); - if (status.spec_version !== STRATEGY29_SPEC_VERSION) { - return { state: "incompatible", pages: 0, hasMore: false }; + const bandStillDown = bar.middle < setup.middle && hasDownwardBandCenter(indicatorBars, index); + if (!bandStillDown) continue; + if (warningIndex === null && bar.high >= bar.middle - width * config.middleApproachBandFraction) { + warningIndex = index; + signals.push(buildSignal("warning", setup, bar)); + continue; } - let pages = 0; - let hasMore = false; - while (pages < maxPagesPerPoll) { - const requestedCursor = cursor; - const eventsResponse = await perform(buildEventsUrl(origin, canonicalSymbol, cursor), signal); - if (signal.aborted) throw signal.reason; - const eventsBody = parseJsonResponse(eventsResponse, "Strategy29 events"); - pages += 1; - if (eventsResponse.status === 409) { - const error = validateStrategy29GatewayError(eventsBody, 409); - cursor = null; - onCursorReset(error.oldest_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.has_more || page.events.length > 20)) { - throw new TypeError("Strategy29 latest snapshot must be complete and bounded to 20 events"); - } - 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; + if (warningIndex !== null && index > warningIndex && bar.close < bar.open && bar.low <= bar.lower + width * config.lowerTouchBandFraction) { + signals.push(buildSignal("confirmed", setup, bar)); + break; } - return { state: "connected", pages, hasMore }; } - return Object.freeze({ - poll, - get diagnostics() { - return Object.freeze({ cursor }); + if (warningIndex !== null) { + const reversal = detectReversalSignal(indicatorBars, setup, warningIndex); + if (reversal) signals.push(reversal); + } + return signals; + } + function appendSetupSignals(signals, setupSignals) { + for (const signal of setupSignals) { + if (signal.type !== "reversal") { + signals.push(signal); + continue; + } + const duplicateIndex = signals.findIndex( + (existing) => existing.type === "reversal" && existing.time === signal.time + ); + if (duplicateIndex === -1) { + signals.push(signal); + continue; + } + if (signal.setupTime > signals[duplicateIndex].setupTime) { + signals[duplicateIndex] = signal; } + } + } + function detectBearishBollingerSignalsFromIndicatorBarsInternal(indicatorBars) { + const config = BOLLINGER_PATTERN; + const firstCrossIndex = Math.max( + config.maPeriod, + config.maPeriod - 1 + config.preCrossBars, + config.trendLookbackBars + ); + const signals = []; + for (let index = firstCrossIndex; index < indicatorBars.length; index += 1) { + const previous = indicatorBars[index - 1]; + const current = indicatorBars[index]; + if (!isDownwardCross(previous, current)) continue; + if (!hasDownwardBandCenter(indicatorBars, index)) continue; + if (!matchesPreCrossCompression(indicatorBars, index)) continue; + appendSetupSignals(signals, detectSetupSignals(indicatorBars, index)); + } + const typeOrder = { warning: 0, confirmed: 1, reversal: 2 }; + return signals.sort( + (left, right) => left.time - right.time || typeOrder[left.type] - typeOrder[right.type] + ); + } + function mirrorIndicatorBar(bar) { + return { + ...bar, + open: -bar.open, + high: -bar.low, + low: -bar.high, + close: -bar.close, + middle: bar.middle === null ? null : -bar.middle, + upper: bar.upper === null ? null : -bar.lower, + lower: bar.lower === null ? null : -bar.upper, + ma60: bar.ma60 === null ? null : -bar.ma60 + }; + } + function mapMirroredBullishSignal(signal) { + return Object.freeze({ + ...signal, + id: `${signal.setupTime}:bullish:${signal.type}`, + direction: "bullish", + markerPrice: -signal.markerPrice }); } - 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) => { + function detectBullishBollingerSignalsFromIndicatorBarsInternal(indicatorBars) { + return detectBearishBollingerSignalsFromIndicatorBarsInternal( + indicatorBars.map(mirrorIndicatorBar) + ).map(mapMirroredBullishSignal); + } + function compareBollingerSignals(left, right) { + const directionOrder = { bearish: 0, bullish: 1 }; + const typeOrder = { warning: 0, confirmed: 1, reversal: 2 }; + const leftDirectionOrder = directionOrder[left.direction]; + const rightDirectionOrder = directionOrder[right.direction]; + if (leftDirectionOrder === void 0 || rightDirectionOrder === void 0) { + throw new Error("Bollinger signal direction is invalid"); + } + return left.time - right.time || leftDirectionOrder - rightDirectionOrder || typeOrder[left.type] - typeOrder[right.type]; + } + function detectBollingerSignalsFromIndicatorBars(indicatorBars) { + assertIndicatorBars(indicatorBars, "Bollinger"); + const bearishSignals = detectBearishBollingerSignalsFromIndicatorBarsInternal(indicatorBars).map((signal) => Object.freeze({ ...signal, direction: "bearish" })); + const bullishSignals = detectBullishBollingerSignalsFromIndicatorBarsInternal(indicatorBars); + return [...bearishSignals, ...bullishSignals].sort(compareBollingerSignals); + } + function detectBollingerSignals(bars) { + return detectBollingerSignalsFromIndicatorBars( + calculateBollingerIndicatorBars(bars, "Bollinger") + ); + } + + // src/shared/tradingview-target.js + var CHART_ROOT_SELECTOR = ".chart-widget-root"; + function hasVisibleBox(element2) { + if (!element2?.getClientRects().length) return false; + const rect = element2.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + function findBinanceTradingViewTarget(document) { + const chartRoots = Array.from(document.querySelectorAll(CHART_ROOT_SELECTOR)).filter(hasVisibleBox); + if (!chartRoots.length) return null; + if (chartRoots.length > 1) { + throw new Error(`可见图表区域数量异常:${chartRoots.length}`); + } + const chartRoot = chartRoots[0]; + const tradingViewApis = Array.from(chartRoot.querySelectorAll("iframe")).map((frame) => frame.contentWindow?.tradingViewApi).filter(Boolean); + if (!tradingViewApis.length) return null; + if (tradingViewApis.length > 1) { + throw new Error(`图表接口数量异常:${tradingViewApis.length}`); + } + return { chartRoot, tradingViewApi: tradingViewApis[0] }; + } + + // src/shared/abort.js + function getAbortReason(signal) { + if (signal?.reason instanceof Error) return signal.reason; + const error = new Error("Operation aborted"); + error.name = "AbortError"; + return error; + } + function throwIfAborted(signal) { + if (signal?.aborted) throw getAbortReason(signal); + } + function waitForPromiseOrAbort(task, signal) { + if (!signal) return Promise.resolve(task); + throwIfAborted(signal); + return new Promise((resolve, reject) => { let settled = false; - function finish(callback, value) { + const 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; + }; + const onAbort = () => finish(reject, getAbortReason(signal)); signal.addEventListener("abort", onAbort, { once: true }); - if (signal.aborted) onAbort(); + Promise.resolve(task).then( + (value) => finish(resolve, value), + (error) => finish(reject, error) + ); }); } - // src/binance-strategy29-bollinger/core/bearish-bollinger-pattern.js - var BOLLINGER_PATTERN = Object.freeze({ - bollingerPeriod: 20, - bollingerStdDev: 2, - maPeriod: 60, - preCrossBars: 8, - minPreCrossChannelCloses: 4, - maxPreCrossAboveMiddleCloses: 1, - maxPreCrossBelowLowerCloses: 3, - trendLookbackBars: 3, - minMiddleDeclineBandFraction: 0.01, - postCrossBars: 20, - middleApproachBandFraction: 0.12, - maxPostCrossCloseAboveMiddleBandFraction: 0.05, - lowerTouchBandFraction: 0.05, - reversalFollowBars: 60 - }); - var TradingViewBarSnapshotInconsistentError = class extends Error { - constructor(message) { - super(message); - this.name = "TradingViewBarSnapshotInconsistentError"; + // src/shared/chart-marker-save-controller.js + var CONTROLLER_SLOT = Symbol.for("jh-userscripts.chart-marker-save-controller"); + var PROTOCOL_VERSION = 1; + function readController(api) { + const record = api[CONTROLLER_SLOT]; + if (record === void 0) return null; + if (record.version !== PROTOCOL_VERSION || typeof record.controller?.runAfterIdle !== "function") { + throw new Error("Incompatible TradingView marker save protocol; update both scripts and reload"); } - }; - function isTradingViewBarSnapshotInconsistentError(error) { - return error instanceof TradingViewBarSnapshotInconsistentError; + return record.controller; } - function applyBollingerAlertTaskFailure(context, error) { - if (!context || typeof context !== "object" || typeof context.failed !== "boolean" || typeof context.cleanupPending !== "boolean") { - throw new Error("Bollinger alert task context is invalid"); + var QUIET_MS = 150; + var MAX_BURST_MS = 1e3; + var DRAIN_TIMEOUT_MS = 2e3; + function installTradingViewMarkerSaveController(api, { + onError = (error) => { + throw error; + }, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout + } = {}) { + const existing = readController(api); + if (existing) return existing; + if (typeof api?.saveChart !== "function") { + throw new Error("TradingView marker save API is unavailable"); } - if (isTradingViewBarSnapshotInconsistentError(error)) return "retry"; - context.failed = true; - context.cleanupPending = true; - return "fatal"; - } - function assertFiniteNumber2(value, label) { - if (!Number.isFinite(value)) throw new Error(`${label} is invalid`); - } - function assertBars(bars, directionLabel) { - if (!Array.isArray(bars)) throw new Error(`${directionLabel} Bollinger bars must be an array`); - let previousTime = -Infinity; - for (const [index, bar] of bars.entries()) { - if (!bar || typeof bar !== "object") { - throw new Error(`${directionLabel} Bollinger bar ${index} is invalid`); - } - if (!Number.isInteger(bar.time)) { - throw new Error(`${directionLabel} Bollinger bar time ${index} is invalid`); - } - if (bar.time <= previousTime) { - throw new TradingViewBarSnapshotInconsistentError( - `${directionLabel} Bollinger bar time ${index} is invalid` - ); - } - for (const field of ["open", "high", "low", "close"]) { - 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( - `${directionLabel} Bollinger bar ${index} OHLC range is invalid` - ); - } - previousTime = bar.time; + const originalSaveChart = api.saveChart; + let burst = null; + let tailTimer = null; + let mutations = 0; + let draining = 0; + let saveRequests = 0; + let serializations = 0; + let callbackCount = 0; + let failureCount = 0; + const idleWaiters = /* @__PURE__ */ new Set(); + const busy = () => burst !== null || mutations !== 0 || tailTimer !== null; + function notifyIdle() { + if (busy()) return; + for (const resolve of idleWaiters) resolve(); + idleWaiters.clear(); } - } - function assertIndicatorBars(indicatorBars, directionLabel) { - if (!Array.isArray(indicatorBars)) { - throw new Error(`${directionLabel} Bollinger indicator bars must be an array`); + function reportErrors(errors) { + if (errors.length === 0) return; + failureCount += errors.length; + setTimeoutFn(() => onError(new AggregateError(errors, "TradingView marker save burst failed")), 0); } - assertBars(indicatorBars, directionLabel); - for (const [index, bar] of indicatorBars.entries()) { - const fields = ["middle", "upper", "lower", "ma60"]; - const nullFields = fields.filter((field) => bar[field] === null); - if (nullFields.length !== 0 && nullFields.length !== fields.length) { - throw new Error(`${directionLabel} Bollinger indicator bar ${index} is incomplete`); - } - for (const field of fields) { - if (bar[field] !== null) { - assertFiniteNumber2( - bar[field], - `${directionLabel} Bollinger indicator bar ${index} ${field}` - ); + function flush() { + const pending = burst; + if (!pending) return; + burst = null; + clearTimeoutFn(pending.quietTimer); + clearTimeoutFn(pending.maxTimer); + const errors = []; + try { + if (pending.callbacks.length > 0) { + serializations += 1; + originalSaveChart.call(api, (snapshot) => { + const json = JSON.stringify(snapshot); + for (const callback of pending.callbacks) { + try { + callbackCount += 1; + callback(JSON.parse(json)); + } catch (error) { + errors.push(error); + } + } + }); } + } catch (error) { + errors.push(error); + } finally { + pending.callbacks.length = 0; + notifyIdle(); + reportErrors(errors); } } - } - function calculateBollingerIndicatorBars(bars, directionLabel) { - assertBars(bars, directionLabel); - const config = BOLLINGER_PATTERN; - return bars.map((bar, index) => { - if (index < config.maPeriod - 1) { - return { ...bar, middle: null, upper: null, lower: null, ma60: null }; + function scheduleQuiet() { + clearTimeoutFn(burst.quietTimer); + burst.quietTimer = setTimeoutFn(flush, QUIET_MS); + } + function markMutation() { + if (tailTimer !== null) clearTimeoutFn(tailTimer); + tailTimer = setTimeoutFn(() => { + tailTimer = null; + notifyIdle(); + }, QUIET_MS); + if (!burst) { + burst = { callbacks: [], quietTimer: null, maxTimer: setTimeoutFn(flush, MAX_BURST_MS) }; } - const start = index - config.bollingerPeriod + 1; - let closeSum = 0; - for (let cursor = start; cursor <= index; cursor += 1) closeSum += bars[cursor].close; - const middle = closeSum / config.bollingerPeriod; - let squaredDeviationSum = 0; - for (let cursor = start; cursor <= index; cursor += 1) { - squaredDeviationSum += (bars[cursor].close - middle) ** 2; + scheduleQuiet(); + } + function markerSaveChart(...args) { + const defaultCall = this === api && args.length <= 2 && typeof args[0] === "function" && args[1] === void 0; + if (api.saveChart !== markerSaveChart || !defaultCall) { + flush(); + return originalSaveChart.apply(this, args); } - const deviation = Math.sqrt(squaredDeviationSum / config.bollingerPeriod) * config.bollingerStdDev; - let maSum = 0; - for (let cursor = index - config.maPeriod + 1; cursor <= index; cursor += 1) maSum += bars[cursor].close; - return { - ...bar, - middle, - upper: middle + deviation, - lower: middle - deviation, - ma60: maSum / config.maPeriod - }; + if (!burst) return originalSaveChart.apply(this, args); + saveRequests += 1; + burst.callbacks.push(args[0]); + scheduleQuiet(); + return void 0; + } + api.saveChart = markerSaveChart; + if (api.saveChart !== markerSaveChart) { + throw new Error("TradingView marker save wrapper could not be installed"); + } + const controller = Object.freeze({ + canMutate: () => draining === 0 && api.saveChart === markerSaveChart, + beginMutation() { + if (!controller.canMutate()) { + throw new Error("TradingView marker mutation overlaps a chart save owner"); + } + mutations += 1; + markMutation(); + let finished = false; + return () => { + if (finished) throw new Error("TradingView marker mutation finished twice"); + finished = true; + mutations -= 1; + markMutation(); + }; + }, + async runAfterIdle(action, { signal } = {}) { + throwIfAborted(signal); + draining += 1; + let timeout = null; + let wake = null; + try { + if (busy()) { + await waitForPromiseOrAbort(new Promise((resolve, reject) => { + wake = resolve; + idleWaiters.add(wake); + timeout = setTimeoutFn(() => { + const error = new Error("TradingView marker saves did not finish before the chart operation"); + error.name = "TradingViewMarkerSaveDrainTimeoutError"; + reject(error); + }, DRAIN_TIMEOUT_MS); + }), signal); + } + if (busy()) throw new Error("TradingView marker save drain was invalidated"); + throwIfAborted(signal); + return await action(); + } finally { + if (timeout !== null) clearTimeoutFn(timeout); + if (wake !== null) idleWaiters.delete(wake); + draining -= 1; + } + }, + getStats: () => ({ + busy: busy(), + mutations, + draining, + saveRequests, + serializations, + callbackCount, + failureCount, + pendingCallbacks: burst?.callbacks.length || 0 + }) }); + Object.defineProperty(api, CONTROLLER_SLOT, { value: Object.freeze({ version: PROTOCOL_VERSION, controller }) }); + return controller; } - function bandWidth(bar) { - const width = bar.upper - bar.lower; - if (!(width > 0)) throw new Error(`Bollinger band width is invalid at ${bar.time}`); - return width; - } - function hasDownwardBandCenter(indicatorBars, index) { - const { trendLookbackBars, minMiddleDeclineBandFraction } = BOLLINGER_PATTERN; - const current = indicatorBars[index]; - const earlier = indicatorBars[index - trendLookbackBars]; - const averageWidth = (bandWidth(current) + bandWidth(earlier)) / 2; - return (earlier.middle - current.middle) / averageWidth >= minMiddleDeclineBandFraction; - } - function isRejectedAboveMiddleClose(indicatorBars, index) { - const next = indicatorBars[index + 1]; - return next.close < next.middle && next.close < next.open; + + // src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js + var MAX_BOLLINGER_MARKERS_PER_DIRECTION = 1e3; + var MAX_BOLLINGER_MARKERS = MAX_BOLLINGER_MARKERS_PER_DIRECTION * 2; + function routeSymbolFromChartSymbol(value) { + return String(value || "").split("@", 1)[0]; } - function matchesPreCrossCompression(indicatorBars, crossIndex) { - const config = BOLLINGER_PATTERN; - const start = crossIndex - config.preCrossBars; - const preCross = indicatorBars.slice(start, crossIndex); - const channelCloses = preCross.filter( - (bar) => bar.close >= bar.lower && bar.close <= bar.middle - ).length; - const aboveMiddleIndexes = []; - let belowLowerCloses = 0; - for (let offset = 0; offset < preCross.length; offset += 1) { - const bar = preCross[offset]; - if (bar.close > bar.middle) aboveMiddleIndexes.push(start + offset); - if (bar.close < bar.lower) belowLowerCloses += 1; + function assertChartContract(chart) { + for (const method of [ + "createShape", + "dataReady", + "exportData", + "getAllShapes", + "getShapeById", + "hasModel", + "onDataLoaded", + "onIntervalChanged", + "removeEntity", + "resolution", + "symbol" + ]) { + if (typeof chart?.[method] !== "function") { + throw new Error(`TradingView Bollinger alert method is unavailable: ${method}`); + } } - return channelCloses >= config.minPreCrossChannelCloses && aboveMiddleIndexes.length <= config.maxPreCrossAboveMiddleCloses && belowLowerCloses <= config.maxPreCrossBelowLowerCloses && aboveMiddleIndexes.every((index) => isRejectedAboveMiddleClose(indicatorBars, index)); } - function isDownwardCross(previous, current) { - return previous.middle >= previous.ma60 && current.middle < current.ma60; + function readLiveShapes(chart) { + const shapes = chart.getAllShapes(); + if (!Array.isArray(shapes)) { + throw new Error("TradingView Bollinger alert shape list is invalid"); + } + const ids = /* @__PURE__ */ new Map(); + for (const [index, shape] of shapes.entries()) { + if (typeof shape?.id !== "string" || shape.id.length === 0 || typeof shape.name !== "string") { + throw new Error(`TradingView Bollinger alert shape ${index} id is invalid`); + } + ids.set(shape.id, shape.name); + } + return ids; } - function buildSignal(type, setup, bar) { - const width = bandWidth(bar); - const markerGapFraction = type === "warning" ? 0.06 : 0.1; - return Object.freeze({ - id: `${setup.time}:${type}`, - type, - setupTime: setup.time, - time: bar.time, - markerPrice: type === "reversal" ? bar.low - width * markerGapFraction : bar.high + width * markerGapFraction - }); + function tradingViewResolutionToSeconds(resolution) { + const value = String(resolution || "").toUpperCase(); + const units = [ + { pattern: /^(\d+)S$/, seconds: 1 }, + { pattern: /^(\d+)$/, seconds: 60 }, + { pattern: /^(\d+)H$/, seconds: 60 * 60 }, + { pattern: /^(\d+)D$/, seconds: 24 * 60 * 60 }, + { pattern: /^(\d+)W$/, seconds: 7 * 24 * 60 * 60 } + ]; + for (const { pattern, seconds } of units) { + const match = value.match(pattern); + if (!match) continue; + const count = Number(match[1]); + if (Number.isSafeInteger(count) && count > 0) return count * seconds; + } + throw new Error(`TradingView Bollinger alert resolution is unsupported: ${resolution}`); } - function detectReversalSignal(indicatorBars, setup, warningIndex) { - const { reversalFollowBars } = BOLLINGER_PATTERN; - const warning = indicatorBars[warningIndex]; - const endIndex = Math.min( - indicatorBars.length - 1, - warningIndex + reversalFollowBars - ); - for (let index = warningIndex + 1; index <= endIndex; index += 1) { - const bar = indicatorBars[index]; - if (bar.close > warning.high) return buildSignal("reversal", setup, bar); + function bollingerIntervalVisibility(resolution) { + const seconds = tradingViewResolutionToSeconds(resolution); + const value = String(resolution).toUpperCase(); + const visibility = { + ticks: false, + seconds: false, + minutes: false, + hours: false, + days: false, + weeks: false, + months: false, + ranges: false + }; + let unit; + let count; + if (value.endsWith("W")) { + unit = "weeks"; + count = seconds / 604800; + } else if (value.endsWith("D")) { + unit = "days"; + count = seconds / 86400; + } else if (seconds < 60) { + unit = "seconds"; + count = seconds; + } else if (value.endsWith("S") || seconds < 3600) { + unit = "minutes"; + count = Math.floor(seconds / 60); + } else { + unit = "hours"; + count = Math.floor(seconds / 3600); } - return null; + visibility[unit] = true; + visibility[`${unit}From`] = count; + visibility[`${unit}To`] = count; + return visibility; } - function detectSetupSignals(indicatorBars, crossIndex) { - const config = BOLLINGER_PATTERN; - const setup = indicatorBars[crossIndex]; - const signals = []; - let warningIndex = null; - let pendingMiddleRejection = false; - let aboveMiddleCloseCount = 0; - const endIndex = Math.min( - indicatorBars.length - 1, - crossIndex + config.postCrossBars - ); - for (let index = crossIndex + 1; index <= endIndex; index += 1) { - const bar = indicatorBars[index]; - const width = bandWidth(bar); - if (pendingMiddleRejection) { - if (!(bar.close < bar.middle && bar.close < bar.open)) break; - pendingMiddleRejection = false; - } - if (bar.close > bar.middle) { - aboveMiddleCloseCount += 1; - if (aboveMiddleCloseCount > 1 || bar.close > bar.middle + width * config.maxPostCrossCloseAboveMiddleBandFraction || bar.close > bar.upper) break; - pendingMiddleRejection = true; - continue; - } - const bandStillDown = bar.middle < setup.middle && hasDownwardBandCenter(indicatorBars, index); - if (!bandStillDown) continue; - if (warningIndex === null && bar.high >= bar.middle - width * config.middleApproachBandFraction) { - warningIndex = index; - signals.push(buildSignal("warning", setup, bar)); - continue; - } - if (warningIndex !== null && index > warningIndex && bar.close < bar.open && bar.low <= bar.lower + width * config.lowerTouchBandFraction) { - signals.push(buildSignal("confirmed", setup, bar)); - break; + function createBollingerIntervalSession(chart) { + const intervalChanged = chart.onIntervalChanged(); + const dataLoaded = chart.onDataLoaded(); + for (const subscription of [intervalChanged, dataLoaded]) { + if (typeof subscription?.subscribe !== "function" || typeof subscription.unsubscribe !== "function") { + throw new Error("TradingView Bollinger interval subscription is unavailable"); } } - if (warningIndex !== null) { - const reversal = detectReversalSignal(indicatorBars, setup, warningIndex); - if (reversal) signals.push(reversal); + let revision = 0; + let disposed = false; + let awaitingData = !chart.dataReady(); + const owner = {}; + function invalidate() { + revision += 1; + awaitingData = true; } - return signals; - } - function appendSetupSignals(signals, setupSignals) { - for (const signal of setupSignals) { - if (signal.type !== "reversal") { - signals.push(signal); - continue; - } - const duplicateIndex = signals.findIndex( - (existing) => existing.type === "reversal" && existing.time === signal.time - ); - if (duplicateIndex === -1) { - signals.push(signal); - continue; - } - if (signal.setupTime > signals[duplicateIndex].setupTime) { - signals[duplicateIndex] = signal; - } + function complete() { + awaitingData = false; } + intervalChanged.subscribe(owner, invalidate); + dataLoaded.subscribe(owner, complete); + return Object.freeze({ + get revision() { + return revision; + }, + isCurrent(candidate) { + return !disposed && !awaitingData && candidate === revision && chart.dataReady(); + }, + dispose() { + if (disposed) return; + disposed = true; + revision += 1; + intervalChanged.unsubscribe(owner, invalidate); + dataLoaded.unsubscribe(owner, complete); + } + }); } - function detectBearishBollingerSignalsFromIndicatorBarsInternal(indicatorBars) { - const config = BOLLINGER_PATTERN; - const firstCrossIndex = Math.max( - config.maPeriod, - config.maPeriod - 1 + config.preCrossBars, - config.trendLookbackBars - ); - const signals = []; - for (let index = firstCrossIndex; index < indicatorBars.length; index += 1) { - const previous = indicatorBars[index - 1]; - const current = indicatorBars[index]; - if (!isDownwardCross(previous, current)) continue; - if (!hasDownwardBandCenter(indicatorBars, index)) continue; - if (!matchesPreCrossCompression(indicatorBars, index)) continue; - appendSetupSignals(signals, detectSetupSignals(indicatorBars, index)); + function findBearishBollingerChartTarget(document, expectedRouteSymbol) { + const baseTarget = findBinanceTradingViewTarget(document); + if (!baseTarget) return null; + const chart = baseTarget.tradingViewApi.activeChart?.(); + if (!chart) return null; + assertChartContract(chart); + if (!chart.hasModel()) return null; + const resolution = chart.resolution(); + const resolutionSeconds = tradingViewResolutionToSeconds(resolution); + const routeSymbol = routeSymbolFromChartSymbol(chart.symbol()); + if (routeSymbol !== expectedRouteSymbol) { + throw new Error( + `TradingView Bollinger alert symbol mismatch: expected ${expectedRouteSymbol}, received ${routeSymbol}` + ); } - const typeOrder = { warning: 0, confirmed: 1, reversal: 2 }; - return signals.sort( - (left, right) => left.time - right.time || typeOrder[left.type] - typeOrder[right.type] - ); - } - function mirrorIndicatorBar(bar) { return { - ...bar, - open: -bar.open, - high: -bar.low, - low: -bar.high, - close: -bar.close, - middle: bar.middle === null ? null : -bar.middle, - upper: bar.upper === null ? null : -bar.lower, - lower: bar.lower === null ? null : -bar.upper, - ma60: bar.ma60 === null ? null : -bar.ma60 + ...baseTarget, + chart, + resolution, + resolutionSeconds, + routeSymbol }; } - function mapMirroredBullishSignal(signal) { - return Object.freeze({ - ...signal, - id: `${signal.setupTime}:bullish:${signal.type}`, - direction: "bullish", - markerPrice: -signal.markerPrice - }); + function isBearishBollingerChartTargetCurrent(document, target) { + const baseTarget = findBinanceTradingViewTarget(document); + if (!baseTarget) return false; + const chart = baseTarget.tradingViewApi.activeChart?.(); + if (!chart) return false; + assertChartContract(chart); + if (!chart.hasModel()) return false; + return baseTarget.chartRoot === target.chartRoot && baseTarget.tradingViewApi === target.tradingViewApi && chart === target.chart && chart.resolution() === target.resolution && routeSymbolFromChartSymbol(chart.symbol()) === target.routeSymbol; } - function detectBullishBollingerSignalsFromIndicatorBarsInternal(indicatorBars) { - return detectBearishBollingerSignalsFromIndicatorBarsInternal( - indicatorBars.map(mirrorIndicatorBar) - ).map(mapMirroredBullishSignal); + function assertExportSchema(schema) { + if (!Array.isArray(schema)) throw new Error("TradingView Bollinger alert export schema is invalid"); + const fields = schema.map((column) => column.plotTitle || column.type); + const expected = ["time", "open", "high", "low", "close"]; + if (fields.length !== expected.length || fields.some((field, index) => field !== expected[index])) { + throw new Error(`TradingView Bollinger alert export schema mismatch: ${fields.join(",")}`); + } } - function compareBollingerSignals(left, right) { - const directionOrder = { bearish: 0, bullish: 1 }; - const typeOrder = { warning: 0, confirmed: 1, reversal: 2 }; - const leftDirectionOrder = directionOrder[left.direction]; - const rightDirectionOrder = directionOrder[right.direction]; - if (leftDirectionOrder === void 0 || rightDirectionOrder === void 0) { - throw new Error("Bollinger signal direction is invalid"); + function parseExportRow(row, index) { + if (!row || typeof row !== "object") { + throw new Error(`TradingView Bollinger alert export row ${index} is invalid`); } - return left.time - right.time || leftDirectionOrder - rightDirectionOrder || typeOrder[left.type] - typeOrder[right.type]; + const bar = { + time: row[0], + open: row[1], + high: row[2], + low: row[3], + close: row[4] + }; + if (!Number.isInteger(bar.time)) { + throw new Error(`TradingView Bollinger alert export time ${index} is invalid`); + } + for (const field of ["open", "high", "low", "close"]) { + if (!Number.isFinite(bar[field])) { + throw new Error(`TradingView Bollinger alert export ${field} ${index} is invalid`); + } + } + return bar; } - function detectBollingerSignalsFromIndicatorBars(indicatorBars) { - assertIndicatorBars(indicatorBars, "Bollinger"); - const bearishSignals = detectBearishBollingerSignalsFromIndicatorBarsInternal(indicatorBars).map((signal) => Object.freeze({ ...signal, direction: "bearish" })); - const bullishSignals = detectBullishBollingerSignalsFromIndicatorBarsInternal(indicatorBars); - return [...bearishSignals, ...bullishSignals].sort(compareBollingerSignals); - } - function detectBollingerSignals(bars) { - return detectBollingerSignalsFromIndicatorBars( - calculateBollingerIndicatorBars(bars, "Bollinger") - ); - } - - // src/shared/tradingview-target.js - var CHART_ROOT_SELECTOR = ".chart-widget-root"; - function hasVisibleBox(element2) { - if (!element2?.getClientRects().length) return false; - const rect = element2.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - } - function findBinanceTradingViewTarget(document) { - const chartRoots = Array.from(document.querySelectorAll(CHART_ROOT_SELECTOR)).filter(hasVisibleBox); - if (!chartRoots.length) return null; - if (chartRoots.length > 1) { - throw new Error(`可见图表区域数量异常:${chartRoots.length}`); + function parseClosedTradingViewBars(exported, { resolutionSeconds, observedAtSeconds, resolution }) { + if (!Number.isSafeInteger(resolutionSeconds) || resolutionSeconds < 1) { + throw new Error("TradingView Bollinger alert resolution seconds are invalid"); } - const chartRoot = chartRoots[0]; - const tradingViewApis = Array.from(chartRoot.querySelectorAll("iframe")).map((frame) => frame.contentWindow?.tradingViewApi).filter(Boolean); - if (!tradingViewApis.length) return null; - if (tradingViewApis.length > 1) { - throw new Error(`图表接口数量异常:${tradingViewApis.length}`); + if (!Number.isFinite(observedAtSeconds)) { + throw new Error("TradingView Bollinger alert observation time is invalid"); } - return { chartRoot, tradingViewApi: tradingViewApis[0] }; - } - - // src/shared/abort.js - function getAbortReason(signal) { - if (signal?.reason instanceof Error) return signal.reason; - const error = new Error("Operation aborted"); - error.name = "AbortError"; - return error; - } - function throwIfAborted(signal) { - if (signal?.aborted) throw getAbortReason(signal); - } - function waitForPromiseOrAbort(task, signal) { - if (!signal) return Promise.resolve(task); - throwIfAborted(signal); - return new Promise((resolve, reject) => { - let settled = false; - const finish = (callback, value) => { - if (settled) return; - settled = true; - signal.removeEventListener("abort", onAbort); - callback(value); - }; - const onAbort = () => finish(reject, getAbortReason(signal)); - signal.addEventListener("abort", onAbort, { once: true }); - Promise.resolve(task).then( - (value) => finish(resolve, value), - (error) => finish(reject, error) - ); - }); - } - - // src/shared/chart-marker-save-controller.js - var CONTROLLER_SLOT = Symbol.for("jh-userscripts.chart-marker-save-controller"); - var PROTOCOL_VERSION = 1; - function readController(api) { - const record = api[CONTROLLER_SLOT]; - if (record === void 0) return null; - if (record.version !== PROTOCOL_VERSION || typeof record.controller?.runAfterIdle !== "function") { - throw new Error("Incompatible TradingView marker save protocol; update both scripts and reload"); + assertExportSchema(exported?.schema); + if (!Array.isArray(exported.data)) { + throw new Error("TradingView Bollinger alert export data is invalid"); } - return record.controller; + const bars = exported.data.map(parseExportRow); + const gridSeconds = Math.min(resolutionSeconds, 86400); + for (const [index, bar] of bars.entries()) { + if (bar.time % gridSeconds !== 0 || String(resolution).toUpperCase().endsWith("W") && new Date(bar.time * 1e3).getUTCDay() !== 1) { + throw new TradingViewBarSnapshotInconsistentError( + `TradingView Bollinger alert export interval grid is invalid at ${index}` + ); + } + } + for (let index = 1; index < bars.length; index += 1) { + if (bars[index].time <= bars[index - 1].time) { + throw new TradingViewBarSnapshotInconsistentError( + `TradingView Bollinger alert export order is invalid at ${index}` + ); + } + if ((bars[index].time - bars[index - 1].time) % resolutionSeconds !== 0) { + throw new TradingViewBarSnapshotInconsistentError( + `TradingView Bollinger alert export interval spacing is invalid at ${index}` + ); + } + } + return bars.filter((bar) => bar.time + resolutionSeconds <= observedAtSeconds); } - var QUIET_MS = 150; - var MAX_BURST_MS = 1e3; - var DRAIN_TIMEOUT_MS = 2e3; - function installTradingViewMarkerSaveController(api, { - onError = (error) => { - throw error; - }, - setTimeoutFn = setTimeout, - clearTimeoutFn = clearTimeout - } = {}) { - const existing = readController(api); - if (existing) return existing; - if (typeof api?.saveChart !== "function") { - throw new Error("TradingView marker save API is unavailable"); + function buildClosedBarsWindowKey(bars) { + if (!Array.isArray(bars) || bars.length === 0) { + throw new Error("TradingView Bollinger alert closed-bar window is empty"); } - const originalSaveChart = api.saveChart; - let burst = null; - let tailTimer = null; - let mutations = 0; - let draining = 0; - let saveRequests = 0; - let serializations = 0; - let callbackCount = 0; - let failureCount = 0; - const idleWaiters = /* @__PURE__ */ new Set(); - const busy = () => burst !== null || mutations !== 0 || tailTimer !== null; - function notifyIdle() { - if (busy()) return; - for (const resolve of idleWaiters) resolve(); - idleWaiters.clear(); + return `${bars.length}:${bars[0].time}:${bars.at(-1).time}`; + } + function writeClosedBarToSnapshot(values, offset, bar, index) { + if (!bar || typeof bar !== "object") { + throw new Error(`TradingView Bollinger closed bar ${index} is invalid`); } - function reportErrors(errors) { - if (errors.length === 0) return; - failureCount += errors.length; - setTimeoutFn(() => onError(new AggregateError(errors, "TradingView marker save burst failed")), 0); + if (!Number.isInteger(bar.time)) { + throw new Error(`TradingView Bollinger closed bar time ${index} is invalid`); } - function flush() { - const pending = burst; - if (!pending) return; - burst = null; - clearTimeoutFn(pending.quietTimer); - clearTimeoutFn(pending.maxTimer); - const errors = []; - try { - if (pending.callbacks.length > 0) { - serializations += 1; - originalSaveChart.call(api, (snapshot) => { - const json = JSON.stringify(snapshot); - for (const callback of pending.callbacks) { - try { - callbackCount += 1; - callback(JSON.parse(json)); - } catch (error) { - errors.push(error); - } - } - }); - } - } catch (error) { - errors.push(error); - } finally { - pending.callbacks.length = 0; - notifyIdle(); - reportErrors(errors); + const fields = ["open", "high", "low", "close"]; + for (const field of fields) { + if (!Number.isFinite(bar[field])) { + throw new Error(`TradingView Bollinger closed bar ${field} ${index} is invalid`); } } - function scheduleQuiet() { - clearTimeoutFn(burst.quietTimer); - burst.quietTimer = setTimeoutFn(flush, QUIET_MS); + values[offset] = bar.time; + values[offset + 1] = bar.open; + values[offset + 2] = bar.high; + values[offset + 3] = bar.low; + values[offset + 4] = bar.close; + } + function buildClosedBarsContentSnapshot(bars) { + const windowKey = buildClosedBarsWindowKey(bars); + const values = new Float64Array(bars.length * 5); + for (let index = 0; index < bars.length; index += 1) { + writeClosedBarToSnapshot(values, index * 5, bars[index], index); } - function markMutation() { - if (tailTimer !== null) clearTimeoutFn(tailTimer); - tailTimer = setTimeoutFn(() => { - tailTimer = null; - notifyIdle(); - }, QUIET_MS); - if (!burst) { - burst = { callbacks: [], quietTimer: null, maxTimer: setTimeoutFn(flush, MAX_BURST_MS) }; - } - scheduleQuiet(); + return { windowKey, values }; + } + function matchesClosedBarsContentSnapshot(bars, snapshot) { + if (!snapshot || typeof snapshot !== "object" || typeof snapshot.windowKey !== "string" || !(snapshot.values instanceof Float64Array)) { + throw new Error("TradingView Bollinger closed-bar snapshot is invalid"); } - function markerSaveChart(...args) { - const defaultCall = this === api && args.length <= 2 && typeof args[0] === "function" && args[1] === void 0; - if (api.saveChart !== markerSaveChart || !defaultCall) { - flush(); - return originalSaveChart.apply(this, args); + if (buildClosedBarsWindowKey(bars) !== snapshot.windowKey) return false; + if (snapshot.values.length !== bars.length * 5) return false; + const candidate = new Float64Array(5); + for (let index = 0; index < bars.length; index += 1) { + writeClosedBarToSnapshot(candidate, 0, bars[index], index); + const offset = index * 5; + for (let fieldIndex = 0; fieldIndex < candidate.length; fieldIndex += 1) { + if (!Object.is(snapshot.values[offset + fieldIndex], candidate[fieldIndex])) return false; } - if (!burst) return originalSaveChart.apply(this, args); - saveRequests += 1; - burst.callbacks.push(args[0]); - scheduleQuiet(); - return void 0; } - api.saveChart = markerSaveChart; - if (api.saveChart !== markerSaveChart) { - throw new Error("TradingView marker save wrapper could not be installed"); + return true; + } + async function reconcileBearishBollingerAlertWindow({ + bars, + cachedWindowKey, + cachedContentSnapshot = null, + cachedSignals, + detectSignals, + renderSignals + }) { + if (typeof detectSignals !== "function") { + throw new Error("TradingView Bollinger alert detector is unavailable"); } - const controller = Object.freeze({ - canMutate: () => draining === 0 && api.saveChart === markerSaveChart, - beginMutation() { - if (!controller.canMutate()) { - throw new Error("TradingView marker mutation overlaps a chart save owner"); - } - mutations += 1; - markMutation(); - let finished = false; - return () => { - if (finished) throw new Error("TradingView marker mutation finished twice"); - finished = true; - mutations -= 1; - markMutation(); - }; - }, - async runAfterIdle(action, { signal } = {}) { - throwIfAborted(signal); - draining += 1; - let timeout = null; - let wake = null; - try { - if (busy()) { - await waitForPromiseOrAbort(new Promise((resolve, reject) => { - wake = resolve; - idleWaiters.add(wake); - timeout = setTimeoutFn(() => { - const error = new Error("TradingView marker saves did not finish before the chart operation"); - error.name = "TradingViewMarkerSaveDrainTimeoutError"; - reject(error); - }, DRAIN_TIMEOUT_MS); - }), signal); - } - if (busy()) throw new Error("TradingView marker save drain was invalidated"); - throwIfAborted(signal); - return await action(); - } finally { - if (timeout !== null) clearTimeoutFn(timeout); - if (wake !== null) idleWaiters.delete(wake); - draining -= 1; - } - }, - getStats: () => ({ - busy: busy(), - mutations, - draining, - saveRequests, - serializations, - callbackCount, - failureCount, - pendingCallbacks: burst?.callbacks.length || 0 - }) - }); - Object.defineProperty(api, CONTROLLER_SLOT, { value: Object.freeze({ version: PROTOCOL_VERSION, controller }) }); - return controller; + if (typeof renderSignals !== "function") { + throw new Error("TradingView Bollinger alert renderer is unavailable"); + } + const closedBarsWindowKey = buildClosedBarsWindowKey(bars); + const contentUnchanged = closedBarsWindowKey === cachedWindowKey && cachedContentSnapshot !== null && matchesClosedBarsContentSnapshot(bars, cachedContentSnapshot); + const signals = contentUnchanged ? cachedSignals : detectSignals(bars); + if (!Array.isArray(signals)) { + throw new Error("Bollinger signal cache is invalid"); + } + const rendered = await renderSignals(signals); + if (typeof rendered !== "boolean") { + throw new Error("TradingView Bollinger alert render result is invalid"); + } + return { + rendered, + closedBarsWindowKey, + closedBarsContentSnapshot: contentUnchanged ? cachedContentSnapshot : buildClosedBarsContentSnapshot(bars), + signals + }; } - - // src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js - var MAX_BOLLINGER_MARKERS_PER_DIRECTION = 1e3; - var MAX_BOLLINGER_MARKERS = MAX_BOLLINGER_MARKERS_PER_DIRECTION * 2; - function routeSymbolFromChartSymbol(value) { - return String(value || "").split("@", 1)[0]; + async function exportClosedTradingViewBars(target, session, observedAtMs = Date.now()) { + const revision = session.revision; + const isCurrent = () => session.isCurrent(revision) && target.chart.resolution() === target.resolution && routeSymbolFromChartSymbol(target.chart.symbol()) === target.routeSymbol; + if (!isCurrent()) return null; + const exported = await target.chart.exportData({ includedStudies: [] }); + if (!isCurrent()) return null; + return parseClosedTradingViewBars(exported, { + resolutionSeconds: target.resolutionSeconds, + resolution: target.resolution, + observedAtSeconds: observedAtMs / 1e3 + }); } - function assertChartContract(chart) { - for (const method of [ - "createShape", - "dataReady", - "exportData", - "getAllShapes", - "getShapeById", - "hasModel", - "onDataLoaded", - "onIntervalChanged", - "removeEntity", - "resolution", - "symbol" - ]) { - if (typeof chart?.[method] !== "function") { - throw new Error(`TradingView Bollinger alert method is unavailable: ${method}`); - } + function markerOptions(signal, resolution) { + const direction = signal.direction; + if (direction !== "bearish" && direction !== "bullish") { + throw new Error(`TradingView Bollinger alert signal direction is invalid: ${direction}`); } - } - function readLiveShapes(chart) { - const shapes = chart.getAllShapes(); - if (!Array.isArray(shapes)) { - throw new Error("TradingView Bollinger alert shape list is invalid"); + const isBullish = direction === "bullish"; + const common = { + lock: true, + disableSave: true, + disableSelection: true, + disableUndo: true, + showInObjectsTree: false + }; + if (signal.type === "warning") { + return { + ...common, + shape: "icon", + icon: 61713, + overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), + color: isBullish ? "#0ECB81" : "#F6465D", + size: 10 + } + }; } - const ids = /* @__PURE__ */ new Map(); - for (const [index, shape] of shapes.entries()) { - if (typeof shape?.id !== "string" || shape.id.length === 0 || typeof shape.name !== "string") { - throw new Error(`TradingView Bollinger alert shape ${index} id is invalid`); - } - ids.set(shape.id, shape.name); + if (signal.type === "confirmed") { + return { + ...common, + shape: isBullish ? "arrow_up" : "arrow_down", + overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), + color: isBullish ? "#0ECB81" : "#F6465D", + arrowColor: isBullish ? "#0ECB81" : "#F6465D" + } + }; } - return ids; + if (signal.type === "reversal") { + return { + ...common, + shape: isBullish ? "arrow_down" : "arrow_up", + overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), + color: isBullish ? "#F6465D" : "#0ECB81", + arrowColor: isBullish ? "#F6465D" : "#0ECB81" + } + }; + } + throw new Error(`TradingView Bollinger alert signal type is invalid: ${signal.type}`); } - function tradingViewResolutionToSeconds(resolution) { - const value = String(resolution || "").toUpperCase(); - const units = [ - { pattern: /^(\d+)S$/, seconds: 1 }, - { pattern: /^(\d+)$/, seconds: 60 }, - { pattern: /^(\d+)H$/, seconds: 60 * 60 }, - { pattern: /^(\d+)D$/, seconds: 24 * 60 * 60 }, - { pattern: /^(\d+)W$/, seconds: 7 * 24 * 60 * 60 } - ]; - for (const { pattern, seconds } of units) { - const match = value.match(pattern); - if (!match) continue; - const count = Number(match[1]); - if (Number.isSafeInteger(count) && count > 0) return count * seconds; + function readMarkerPoint(shape) { + const points = shape?.getPoints?.(); + if (!Array.isArray(points) || points.length !== 1 || !Number.isInteger(points[0].time) || !Number.isFinite(points[0].price)) { + throw new Error("TradingView Bollinger alert marker point is invalid"); } - throw new Error(`TradingView Bollinger alert resolution is unsupported: ${resolution}`); + return points[0]; } - function bollingerIntervalVisibility(resolution) { - const seconds = tradingViewResolutionToSeconds(resolution); - const value = String(resolution).toUpperCase(); - const visibility = { - ticks: false, - seconds: false, - minutes: false, - hours: false, - days: false, - weeks: false, - months: false, - ranges: false - }; - let unit; - let count; - if (value.endsWith("W")) { - unit = "weeks"; - count = seconds / 604800; - } else if (value.endsWith("D")) { - unit = "days"; - count = seconds / 86400; - } else if (seconds < 60) { - unit = "seconds"; - count = seconds; - } else if (value.endsWith("S") || seconds < 3600) { - unit = "minutes"; - count = Math.floor(seconds / 60); - } else { - unit = "hours"; - count = Math.floor(seconds / 3600); + function markerPropertiesMatch(shape, options) { + const properties = shape.getProperties(); + if (!properties || typeof properties !== "object") { + throw new Error("TradingView Bollinger alert marker properties are invalid"); } - visibility[unit] = true; - visibility[`${unit}From`] = count; - visibility[`${unit}To`] = count; - return visibility; + if (options.icon !== void 0 && properties.icon !== options.icon) return false; + for (const [key, expected] of Object.entries(options.overrides)) { + if (key === "intervalsVisibilities") { + if (!properties[key] || Object.entries(expected).some(([unit, value]) => properties[key][unit] !== value)) return false; + } else if (properties[key] !== expected) return false; + } + return true; } - function createBollingerIntervalSession(chart) { - const intervalChanged = chart.onIntervalChanged(); - const dataLoaded = chart.onDataLoaded(); - for (const subscription of [intervalChanged, dataLoaded]) { - if (typeof subscription?.subscribe !== "function" || typeof subscription.unsubscribe !== "function") { - throw new Error("TradingView Bollinger interval subscription is unavailable"); - } + function normalizeSignal(signal, index, defaultDirection) { + if (!signal || typeof signal !== "object") { + throw new Error(`TradingView Bollinger alert signal ${index} is invalid`); } - let revision = 0; - let disposed = false; - let awaitingData = !chart.dataReady(); - const owner = {}; - function invalidate() { - revision += 1; - awaitingData = true; + if (typeof signal.id !== "string" || signal.id.length === 0) { + throw new Error(`TradingView Bollinger alert signal ${index} id is invalid`); } - function complete() { - awaitingData = false; + const direction = signal.direction === void 0 ? defaultDirection : signal.direction; + if (direction !== "bearish" && direction !== "bullish") { + throw new Error(`TradingView Bollinger alert signal ${index} direction is invalid: ${direction}`); } - intervalChanged.subscribe(owner, invalidate); - dataLoaded.subscribe(owner, complete); - return Object.freeze({ - get revision() { - return revision; - }, - isCurrent(candidate) { - return !disposed && !awaitingData && candidate === revision && chart.dataReady(); - }, - dispose() { - if (disposed) return; - disposed = true; - revision += 1; - intervalChanged.unsubscribe(owner, invalidate); - dataLoaded.unsubscribe(owner, complete); - } - }); + return signal.direction === direction ? signal : { ...signal, direction }; } - function findBearishBollingerChartTarget(document, expectedRouteSymbol) { - const baseTarget = findBinanceTradingViewTarget(document); - if (!baseTarget) return null; - const chart = baseTarget.tradingViewApi.activeChart?.(); - if (!chart) return null; - assertChartContract(chart); - if (!chart.hasModel()) return null; - const resolution = chart.resolution(); - const resolutionSeconds = tradingViewResolutionToSeconds(resolution); - const routeSymbol = routeSymbolFromChartSymbol(chart.symbol()); - if (routeSymbol !== expectedRouteSymbol) { - throw new Error( - `TradingView Bollinger alert symbol mismatch: expected ${expectedRouteSymbol}, received ${routeSymbol}` - ); - } - return { - ...baseTarget, - chart, - resolution, - resolutionSeconds, - routeSymbol - }; - } - function isBearishBollingerChartTargetCurrent(document, target) { - const baseTarget = findBinanceTradingViewTarget(document); - if (!baseTarget) return false; - const chart = baseTarget.tradingViewApi.activeChart?.(); - if (!chart) return false; - assertChartContract(chart); - if (!chart.hasModel()) return false; - return baseTarget.chartRoot === target.chartRoot && baseTarget.tradingViewApi === target.tradingViewApi && chart === target.chart && chart.resolution() === target.resolution && routeSymbolFromChartSymbol(chart.symbol()) === target.routeSymbol; - } - function assertExportSchema(schema) { - if (!Array.isArray(schema)) throw new Error("TradingView Bollinger alert export schema is invalid"); - const fields = schema.map((column) => column.plotTitle || column.type); - const expected = ["time", "open", "high", "low", "close"]; - if (fields.length !== expected.length || fields.some((field, index) => field !== expected[index])) { - throw new Error(`TradingView Bollinger alert export schema mismatch: ${fields.join(",")}`); + function createMarkerLayer(target, defaultDirection, { + canMutate: canMutateExternally = () => true, + onSaveError, + yieldToBrowser = () => new Promise((resolve) => setTimeout(resolve, 0)) + } = {}) { + const { chart } = target; + const saveController = installTradingViewMarkerSaveController(target.tradingViewApi, { onError: onSaveError }); + const canMutate = () => canMutateExternally() && saveController.canMutate(); + const registry = /* @__PURE__ */ new Map(); + const pendingMarkers = /* @__PURE__ */ new Set(); + let generation = 0; + let creating = 0; + function mutate(action) { + const finish = saveController.beginMutation(); + try { + return action(); + } finally { + finish(); + } } - } - function parseExportRow(row, index) { - if (!row || typeof row !== "object") { - throw new Error(`TradingView Bollinger alert export row ${index} is invalid`); + function removePendingMarkers() { + if (pendingMarkers.size === 0 || !canMutate()) return; + const liveShapeIds = readLiveShapes(chart); + for (const id of pendingMarkers) { + if (liveShapeIds.has(id)) mutate(() => chart.removeEntity(id)); + pendingMarkers.delete(id); + } } - const bar = { - time: row[0], - open: row[1], - high: row[2], - low: row[3], - close: row[4] - }; - if (!Number.isInteger(bar.time)) { - throw new Error(`TradingView Bollinger alert export time ${index} is invalid`); + function discardMissingSignals(liveShapeIds) { + for (const [signalId, record] of registry) { + if (!liveShapeIds.has(record.markerId)) registry.delete(signalId); + } } - for (const field of ["open", "high", "low", "close"]) { - if (!Number.isFinite(bar[field])) { - throw new Error(`TradingView Bollinger alert export ${field} ${index} is invalid`); + function removeSignal(signalId, liveShapeIds) { + const record = registry.get(signalId); + if (!record) return; + if (liveShapeIds.has(record.markerId)) { + mutate(() => chart.removeEntity(record.markerId)); + liveShapeIds.delete(record.markerId); } + registry.delete(signalId); } - return bar; + return Object.freeze({ + async render(signals, { isCurrent }) { + if (!Array.isArray(signals)) throw new Error("TradingView Bollinger alert signals are invalid"); + if (signals.length > MAX_BOLLINGER_MARKERS) { + throw new Error( + `TradingView Bollinger alert marker limit exceeded: ${signals.length}` + ); + } + if (typeof isCurrent !== "function") { + throw new Error("TradingView Bollinger alert current-target validator is unavailable"); + } + const normalizedSignals = signals.map((signal, index) => normalizeSignal(signal, index, defaultDirection)); + const directionCounts = { bearish: 0, bullish: 0 }; + for (const signal of normalizedSignals) { + directionCounts[signal.direction] += 1; + if (directionCounts[signal.direction] > MAX_BOLLINGER_MARKERS_PER_DIRECTION) { + throw new Error( + `TradingView Bollinger alert ${signal.direction} marker limit exceeded: ` + directionCounts[signal.direction] + ); + } + } + const requestedGeneration = generation; + if (!isCurrent() || !canMutate()) return false; + removePendingMarkers(); + let liveShapeIds = readLiveShapes(chart); + discardMissingSignals(liveShapeIds); + const nextIds = new Set(normalizedSignals.map((signal) => signal.id)); + for (const signalId of [...registry.keys()]) { + if (!nextIds.has(signalId)) removeSignal(signalId, liveShapeIds); + } + let batchStartedAt = performance.now(); + let batchOps = 0; + for (const signal of normalizedSignals) { + if (batchOps > 0 && (batchOps >= 32 || performance.now() - batchStartedAt >= 8)) { + await yieldToBrowser(); + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + liveShapeIds = readLiveShapes(chart); + discardMissingSignals(liveShapeIds); + batchStartedAt = performance.now(); + batchOps = 0; + } + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + batchOps += 1; + const options = markerOptions(signal, target.resolution); + const existing = registry.get(signal.id); + if (existing) { + const shape = chart.getShapeById(existing.markerId); + const point = readMarkerPoint(shape); + if (point.time === signal.time && point.price === existing.resolvedPrice && existing.markerPrice === signal.markerPrice && existing.type === signal.type && existing.direction === signal.direction && liveShapeIds.get(existing.markerId) === options.shape && markerPropertiesMatch(shape, options)) continue; + removeSignal(signal.id, liveShapeIds); + } + const finishCreation = saveController.beginMutation(); + creating += 1; + try { + const markerId = await chart.createShape({ time: signal.time, price: signal.markerPrice }, { + ...options, + overrides: { ...options.overrides, visible: false } + }); + if (typeof markerId !== "string" || markerId.length === 0) { + throw new Error("TradingView returned an invalid Bollinger alert shape id"); + } + pendingMarkers.add(markerId); + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + const shape = chart.getShapeById(markerId); + const point = readMarkerPoint(shape); + if (point.time !== signal.time) { + throw new Error(`TradingView Bollinger alert time alignment failed: expected ${signal.time}, received ${point.time}`); + } + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + mutate(() => shape.setProperties(options.overrides, false)); + if (!markerPropertiesMatch(shape, options)) { + throw new Error("TradingView Bollinger alert marker properties were not applied"); + } + registry.set(signal.id, { + markerId, + resolvedPrice: point.price, + markerPrice: signal.markerPrice, + type: signal.type, + direction: signal.direction + }); + pendingMarkers.delete(markerId); + } finally { + finishCreation(); + creating -= 1; + removePendingMarkers(); + } + } + return true; + }, + clear() { + generation += 1; + if (!canMutate()) return false; + removePendingMarkers(); + const liveShapeIds = readLiveShapes(chart); + discardMissingSignals(liveShapeIds); + for (const signalId of [...registry.keys()]) removeSignal(signalId, liveShapeIds); + return creating === 0 && pendingMarkers.size === 0; + }, + get size() { + return registry.size; + }, + get saveStats() { + return saveController.getStats(); + } + }); } - function parseClosedTradingViewBars(exported, { resolutionSeconds, observedAtSeconds, resolution }) { - if (!Number.isSafeInteger(resolutionSeconds) || resolutionSeconds < 1) { - throw new Error("TradingView Bollinger alert resolution seconds are invalid"); - } - if (!Number.isFinite(observedAtSeconds)) { - throw new Error("TradingView Bollinger alert observation time is invalid"); - } - assertExportSchema(exported?.schema); - if (!Array.isArray(exported.data)) { - throw new Error("TradingView Bollinger alert export data is invalid"); - } - const bars = exported.data.map(parseExportRow); - const gridSeconds = Math.min(resolutionSeconds, 86400); - for (const [index, bar] of bars.entries()) { - if (bar.time % gridSeconds !== 0 || String(resolution).toUpperCase().endsWith("W") && new Date(bar.time * 1e3).getUTCDay() !== 1) { - throw new TradingViewBarSnapshotInconsistentError( - `TradingView Bollinger alert export interval grid is invalid at ${index}` - ); + function createBollingerMarkerLayer(target, options) { + return createMarkerLayer(target, void 0, options); + } + + // src/binance-strategy29-bollinger/monitor.js + function createBollingerMonitor({ + document, + getCurrentSymbol, + isFuturesTradingPage, + isTradingViewDrawingMutationBusy, + err, + warn + }) { + let bearishBollingerAlertTask = null; + let bearishBollingerAlertContext = null; + let bollingerIntervalSession = null; + let lastLocalFailure = null; + const retiredBollingerLayers = /* @__PURE__ */ new Set(); + function clearBearishBollingerAlertContext() { + if (bearishBollingerAlertContext) { + retiredBollingerLayers.add(bearishBollingerAlertContext.layer); + bearishBollingerAlertContext = null; } + return clearRetiredBollingerLayers(); } - for (let index = 1; index < bars.length; index += 1) { - if (bars[index].time <= bars[index - 1].time) { - throw new TradingViewBarSnapshotInconsistentError( - `TradingView Bollinger alert export order is invalid at ${index}` - ); + function clearRetiredBollingerLayers() { + if (isTradingViewDrawingMutationBusy()) return false; + for (const layer of retiredBollingerLayers) { + if (layer.clear()) retiredBollingerLayers.delete(layer); } - if ((bars[index].time - bars[index - 1].time) % resolutionSeconds !== 0) { - throw new TradingViewBarSnapshotInconsistentError( - `TradingView Bollinger alert export interval spacing is invalid at ${index}` - ); + return retiredBollingerLayers.size === 0; + } + function disposeBollingerIntervalSession() { + if (bollingerIntervalSession) { + bollingerIntervalSession.session.dispose(); + bollingerIntervalSession = null; } } - return bars.filter((bar) => bar.time + resolutionSeconds <= observedAtSeconds); - } - function buildClosedBarsWindowKey(bars) { - if (!Array.isArray(bars) || bars.length === 0) { - throw new Error("TradingView Bollinger alert closed-bar window is empty"); - } - return `${bars.length}:${bars[0].time}:${bars.at(-1).time}`; - } - function writeClosedBarToSnapshot(values, offset, bar, index) { - if (!bar || typeof bar !== "object") { - throw new Error(`TradingView Bollinger closed bar ${index} is invalid`); - } - if (!Number.isInteger(bar.time)) { - throw new Error(`TradingView Bollinger closed bar time ${index} is invalid`); + function isBearishBollingerAlertContextCurrent(context) { + return bearishBollingerAlertContext === context && context.intervalSession === bollingerIntervalSession?.session && context.intervalSession.isCurrent(context.intervalRevision) && !document.hidden && isFuturesTradingPage() && !isTradingViewDrawingMutationBusy() && getCurrentSymbol() === context.routeSymbol && isBearishBollingerChartTargetCurrent(document, context.target); } - const fields = ["open", "high", "low", "close"]; - for (const field of fields) { - if (!Number.isFinite(bar[field])) { - throw new Error(`TradingView Bollinger closed bar ${field} ${index} is invalid`); + async function synchronizeBearishBollingerAlerts() { + if (document.hidden || !isFuturesTradingPage()) return; + const routeSymbol = getCurrentSymbol(); + if (!routeSymbol) return; + let target; + try { + target = findBearishBollingerChartTarget(document, routeSymbol); + } catch (error) { + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + err("Bollinger chart lookup failed for this sample:", error); + return; + } + if (!target) { + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + return; + } + if (!bollingerIntervalSession || bollingerIntervalSession.chart !== target.chart || bollingerIntervalSession.routeSymbol !== routeSymbol) { + disposeBollingerIntervalSession(); + bollingerIntervalSession = { + chart: target.chart, + routeSymbol, + session: createBollingerIntervalSession(target.chart) + }; + } + const intervalSession = bollingerIntervalSession.session; + const contextMatches = bearishBollingerAlertContext && bearishBollingerAlertContext.target.chart === target.chart && bearishBollingerAlertContext.target.chartRoot === target.chartRoot && bearishBollingerAlertContext.target.tradingViewApi === target.tradingViewApi && bearishBollingerAlertContext.routeSymbol === routeSymbol && bearishBollingerAlertContext.resolution === target.resolution && bearishBollingerAlertContext.intervalSession === intervalSession && bearishBollingerAlertContext.intervalRevision === intervalSession.revision; + if (!contextMatches) { + if (!clearBearishBollingerAlertContext()) return; + if (!intervalSession.isCurrent(intervalSession.revision) || isTradingViewDrawingMutationBusy()) return; + bearishBollingerAlertContext = { + routeSymbol, + resolution: target.resolution, + intervalSession, + intervalRevision: intervalSession.revision, + target, + layer: createBollingerMarkerLayer(target, { + canMutate: () => !isTradingViewDrawingMutationBusy(), + onSaveError: (error) => err("Bollinger chart save failed:", error) + }), + failed: false, + cleanupPending: false, + lastProcessedClosedBarsWindowKey: null, + lastProcessedClosedBarsContentSnapshot: null, + lastProcessedSignals: null + }; } + if (isTradingViewDrawingMutationBusy() || !clearRetiredBollingerLayers()) return; + const context = bearishBollingerAlertContext; + if (context.cleanupPending) { + context.layer.clear(); + context.cleanupPending = false; + } + if (context.failed || bearishBollingerAlertTask) return; + let stage = "export"; + const task = (async () => { + const bars = await exportClosedTradingViewBars(context.target, context.intervalSession); + if (!bars || !isBearishBollingerAlertContextCurrent(context)) return; + if (bars.length === 0) return; + stage = "reconcile"; + const result = await reconcileBearishBollingerAlertWindow({ + bars, + cachedWindowKey: context.lastProcessedClosedBarsWindowKey, + cachedContentSnapshot: context.lastProcessedClosedBarsContentSnapshot, + cachedSignals: context.lastProcessedSignals, + detectSignals: (bars2) => { + stage = "detect"; + const signals = detectBollingerSignals(bars2); + stage = "reconcile"; + return signals; + }, + renderSignals: async (signals) => { + stage = "render"; + const rendered = await context.layer.render(signals, { + isCurrent: () => isBearishBollingerAlertContextCurrent(context) + }); + stage = "reconcile"; + return rendered; + } + }); + if (result.rendered && isBearishBollingerAlertContextCurrent(context)) { + context.lastProcessedClosedBarsWindowKey = result.closedBarsWindowKey; + context.lastProcessedClosedBarsContentSnapshot = result.closedBarsContentSnapshot; + context.lastProcessedSignals = result.signals; + } + })(); + bearishBollingerAlertTask = task; + task.catch((error) => { + if (bearishBollingerAlertContext !== context || context.intervalSession !== bollingerIntervalSession?.session || context.intervalRevision !== context.intervalSession.revision) return; + let failureKind; + let classificationFailed = false; + try { + failureKind = applyBollingerAlertTaskFailure(context, error); + } catch { + classificationFailed = true; + context.failed = true; + context.cleanupPending = true; + failureKind = "fatal"; + } + if (failureKind === "retry") { + warn("布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:", error); + return; + } + const details = { name: null, message: null }; + const unreadableFields = []; + for (const [key, limit] of [["name", 64], ["message", 512]]) { + try { + const value = key === "message" && typeof error === "string" ? error : error?.[key]; + details[key] = typeof value === "string" ? value.slice(0, limit) : null; + } catch { + unreadableFields.push(key); + } + } + lastLocalFailure = Object.freeze({ + thrownType: error === null ? "null" : typeof error, + classificationFailed, + ...details, + unreadableFields: Object.freeze(unreadableFields), + stage, + routeSymbol: context.routeSymbol, + resolution: context.resolution, + cachedSignalCount: context.lastProcessedSignals === null ? null : context.lastProcessedSignals.length, + layerSizeBeforeCleanup: context.layer.size, + sessionRevision: context.intervalSession.revision, + contextIntervalRevision: context.intervalRevision + }); + err("布林带形态预警已停止:", error); + }).finally(() => { + if (bearishBollingerAlertTask === task) bearishBollingerAlertTask = null; + }); } - values[offset] = bar.time; - values[offset + 1] = bar.open; - values[offset + 2] = bar.high; - values[offset + 3] = bar.low; - values[offset + 4] = bar.close; - } - function buildClosedBarsContentSnapshot(bars) { - const windowKey = buildClosedBarsWindowKey(bars); - const values = new Float64Array(bars.length * 5); - for (let index = 0; index < bars.length; index += 1) { - writeClosedBarToSnapshot(values, index * 5, bars[index], index); + function stopBearishBollingerAlertMonitor() { + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); } - return { windowKey, values }; - } - function matchesClosedBarsContentSnapshot(bars, snapshot) { - if (!snapshot || typeof snapshot !== "object" || typeof snapshot.windowKey !== "string" || !(snapshot.values instanceof Float64Array)) { - throw new Error("TradingView Bollinger closed-bar snapshot is invalid"); + function getBollingerAlertDiagnostics() { + const context = bearishBollingerAlertContext; + const session = bollingerIntervalSession?.session || null; + const chart = bollingerIntervalSession?.chart || context?.target.chart || null; + const nativeModelReady = chart ? chart.hasModel() : null; + return { + taskPending: bearishBollingerAlertTask !== null, + contextPresent: context !== null, + failed: context ? context.failed : null, + lastLocalFailure, + cleanupPending: context ? context.cleanupPending : null, + cachedSignalCount: context?.lastProcessedSignals === null || !context ? null : context.lastProcessedSignals.length, + layerSize: context ? context.layer.size : null, + markerSaveStats: context ? context.layer.saveStats : null, + retiredCount: retiredBollingerLayers.size, + sessionPresent: session !== null, + sessionRevision: session ? session.revision : null, + contextIntervalRevision: context ? context.intervalRevision : null, + sessionMatchesContext: context && session ? context.intervalSession === session : null, + sessionCurrent: session && nativeModelReady ? session.isCurrent(session.revision) : null, + nativeModelReady, + nativeDataReady: nativeModelReady ? chart.dataReady() : null, + mutationBlocked: isTradingViewDrawingMutationBusy() + }; } - if (buildClosedBarsWindowKey(bars) !== snapshot.windowKey) return false; - if (snapshot.values.length !== bars.length * 5) return false; - const candidate = new Float64Array(5); - for (let index = 0; index < bars.length; index += 1) { - writeClosedBarToSnapshot(candidate, 0, bars[index], index); - const offset = index * 5; - for (let fieldIndex = 0; fieldIndex < candidate.length; fieldIndex += 1) { - if (!Object.is(snapshot.values[offset + fieldIndex], candidate[fieldIndex])) return false; + return Object.freeze({ + tick: synchronizeBearishBollingerAlerts, + stop: stopBearishBollingerAlertMonitor, + get diagnostics() { + return getBollingerAlertDiagnostics(); } - } - return true; + }); } - async function reconcileBearishBollingerAlertWindow({ - bars, - cachedWindowKey, - cachedContentSnapshot = null, - cachedSignals, - detectSignals, - renderSignals - }) { - if (typeof detectSignals !== "function") { - throw new Error("TradingView Bollinger alert detector is unavailable"); - } - if (typeof renderSignals !== "function") { - throw new Error("TradingView Bollinger alert renderer is unavailable"); - } - const closedBarsWindowKey = buildClosedBarsWindowKey(bars); - const contentUnchanged = closedBarsWindowKey === cachedWindowKey && cachedContentSnapshot !== null && matchesClosedBarsContentSnapshot(bars, cachedContentSnapshot); - const signals = contentUnchanged ? cachedSignals : detectSignals(bars); - if (!Array.isArray(signals)) { - throw new Error("Bollinger signal cache is invalid"); + + // src/shared/chart-mutation-owners.js + var OWNER_SLOT = Symbol.for("jh-userscripts.chart-mutation-owners"); + var VERSION = 1; + function existingOwners(view) { + const record = view[OWNER_SLOT]; + 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"); } - const rendered = await renderSignals(signals); - if (typeof rendered !== "boolean") { - throw new Error("TradingView Bollinger alert render result is invalid"); + return record.predicates; + } + function isChartMutationBlocked(view) { + 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; } - return { - rendered, - closedBarsWindowKey, - closedBarsContentSnapshot: contentUnchanged ? cachedContentSnapshot : buildClosedBarsContentSnapshot(bars), - signals - }; + return false; } - async function exportClosedTradingViewBars(target, session, observedAtMs = Date.now()) { - const revision = session.revision; - const isCurrent = () => session.isCurrent(revision) && target.chart.resolution() === target.resolution && routeSymbolFromChartSymbol(target.chart.symbol()) === target.routeSymbol; - if (!isCurrent()) return null; - const exported = await target.chart.exportData({ includedStudies: [] }); - if (!isCurrent()) return null; - return parseClosedTradingViewBars(exported, { - resolutionSeconds: target.resolutionSeconds, - resolution: target.resolution, - observedAtSeconds: observedAtMs / 1e3 + + // src/shared/binance-futures-route.js + var FUTURES_TRADING_PATH_RE = /^\/(?:[a-z]{2}(?:-[A-Za-z]{2})?\/)?futures\/([A-Za-z0-9_]{3,})\/?$/; + function parseFuturesTradingSymbolFromPathname(pathname) { + const normalized = String(pathname || "").split(/[?#]/, 1)[0]; + const match = normalized.match(FUTURES_TRADING_PATH_RE); + return match?.[1] ? match[1].toUpperCase() : null; + } + function isFuturesTradingPathname(pathname) { + return Boolean(parseFuturesTradingSymbolFromPathname(pathname)); + } + + // src/shared/spa-route-change.js + var ROUTE_CHANGE_EVENT = "jh-userscripts:spa-route-change"; + var ROUTE_PATCH_MARKER = Symbol.for("jh-userscripts.spa-route-change-patched"); + var ROUTE_DISPATCH_STATE = Symbol.for("jh-userscripts.spa-route-change-dispatch"); + function dispatchRouteChange(view) { + const href = view.location.href; + if (view[ROUTE_DISPATCH_STATE]?.href === href) return; + const state = { href }; + view[ROUTE_DISPATCH_STATE] = state; + view.dispatchEvent(new view.Event(ROUTE_CHANGE_EVENT)); + view.queueMicrotask(() => { + if (view[ROUTE_DISPATCH_STATE] === state) delete view[ROUTE_DISPATCH_STATE]; }); } - function markerOptions(signal, resolution) { - const direction = signal.direction; - if (direction !== "bearish" && direction !== "bullish") { - throw new Error(`TradingView Bollinger alert signal direction is invalid: ${direction}`); - } - const isBullish = direction === "bullish"; - const common = { - lock: true, - disableSave: true, - disableSelection: true, - disableUndo: true, - showInObjectsTree: false - }; - if (signal.type === "warning") { - return { - ...common, - shape: "icon", - icon: 61713, - overrides: { - visible: true, - intervalsVisibilities: bollingerIntervalVisibility(resolution), - color: isBullish ? "#0ECB81" : "#F6465D", - size: 10 - } - }; - } - if (signal.type === "confirmed") { - return { - ...common, - shape: isBullish ? "arrow_up" : "arrow_down", - overrides: { - visible: true, - intervalsVisibilities: bollingerIntervalVisibility(resolution), - color: isBullish ? "#0ECB81" : "#F6465D", - arrowColor: isBullish ? "#0ECB81" : "#F6465D" - } - }; - } - if (signal.type === "reversal") { - return { - ...common, - shape: isBullish ? "arrow_down" : "arrow_up", - overrides: { - visible: true, - intervalsVisibilities: bollingerIntervalVisibility(resolution), - color: isBullish ? "#F6465D" : "#0ECB81", - arrowColor: isBullish ? "#F6465D" : "#0ECB81" - } - }; + function patchHistoryMethod(view, methodName) { + const current = view.history[methodName]; + if (current[ROUTE_PATCH_MARKER]) return; + function routeAwareHistoryMethod(...args) { + const previousHref = view.location.href; + const result = Reflect.apply(current, this, args); + if (view.location.href !== previousHref) dispatchRouteChange(view); + return result; } - throw new Error(`TradingView Bollinger alert signal type is invalid: ${signal.type}`); + Object.defineProperty(routeAwareHistoryMethod, ROUTE_PATCH_MARKER, { value: true }); + view.history[methodName] = routeAwareHistoryMethod; } - function readMarkerPoint(shape) { - const points = shape?.getPoints?.(); - if (!Array.isArray(points) || points.length !== 1 || !Number.isInteger(points[0].time) || !Number.isFinite(points[0].price)) { - throw new Error("TradingView Bollinger alert marker point is invalid"); - } - return points[0]; + function ensureSpaRouteChangePatched(view) { + if (!view?.history) throw new Error("SPA route patch requires a window"); + patchHistoryMethod(view, "pushState"); + patchHistoryMethod(view, "replaceState"); } - function markerPropertiesMatch(shape, options) { - const properties = shape.getProperties(); - if (!properties || typeof properties !== "object") { - throw new Error("TradingView Bollinger alert marker properties are invalid"); - } - if (options.icon !== void 0 && properties.icon !== options.icon) return false; - for (const [key, expected] of Object.entries(options.overrides)) { - if (key === "intervalsVisibilities") { - if (!properties[key] || Object.entries(expected).some(([unit, value]) => properties[key][unit] !== value)) return false; - } else if (properties[key] !== expected) return false; + function installSpaRouteChangeListener(view, listener) { + if (!view?.history || typeof listener !== "function") { + throw new Error("SPA route listener requires a window and callback"); } - return true; + ensureSpaRouteChangePatched(view); + view.addEventListener(ROUTE_CHANGE_EVENT, listener); + view.addEventListener("popstate", listener); + view.addEventListener("hashchange", listener); + return () => { + view.removeEventListener(ROUTE_CHANGE_EVENT, listener); + view.removeEventListener("popstate", listener); + view.removeEventListener("hashchange", listener); + }; } - function normalizeSignal(signal, index, defaultDirection) { - if (!signal || typeof signal !== "object") { - throw new Error(`TradingView Bollinger alert signal ${index} is invalid`); - } - if (typeof signal.id !== "string" || signal.id.length === 0) { - throw new Error(`TradingView Bollinger alert signal ${index} id is invalid`); - } - const direction = signal.direction === void 0 ? defaultDirection : signal.direction; - if (direction !== "bearish" && direction !== "bullish") { - throw new Error(`TradingView Bollinger alert signal ${index} direction is invalid: ${direction}`); - } - return signal.direction === direction ? signal : { ...signal, direction }; + + // src/binance-strategy29-bollinger/core/remote-summary-contract.js + var STRATEGY29_SCHEMA_VERSION = 1; + var STRATEGY29_SPEC_VERSION = "29_2_spec_v2"; + var STRATEGY29_REFERENCE_SHA256 = "eece8cf16e58340910587962f3bfbb19acb72155c09a52b4b6c0570cc979ef8d"; + var TIMEFRAMES = /* @__PURE__ */ new Set(["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "1w"]); + var UNIT_STATUSES = /* @__PURE__ */ new Set(["warming", "ready", "stale", "insufficient_history", "data_gap", "failed"]); + 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", "universe", "units", "delivery_counts"]; + var UNIVERSE_KEYS = [ + "source_monitor", + "generation", + "refresh_status", + "reason", + "selected_markets", + "configured_timeframes", + "selected_unit_count", + "ready_unit_count", + "pending_unit_count", + "refreshed_at_ms", + "last_successful_refreshed_at_ms", + "last_success_age_seconds", + "last_refresh_error_at_ms", + "selection_expires_at_ms" + ]; + var UNIVERSE_STATES = /* @__PURE__ */ new Set(["fresh", "stale_if_error", "fail_closed"]); + var UNIVERSE_REASONS = { + fresh: /* @__PURE__ */ new Set(["current"]), + stale_if_error: /* @__PURE__ */ new Set(["using_stale_selection_after_refresh_error"]), + fail_closed: /* @__PURE__ */ new Set([ + "selection_fail_closed", + "selection_expired_or_unusable", + "missing_current_universe_facts", + "incompatible_current_universe_facts" + ]) + }; + var UNIT_KEYS = [ + "symbol", + "timeframe", + "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 createMarkerLayer(target, defaultDirection, { - canMutate: canMutateExternally = () => true, - onSaveError, - yieldToBrowser = () => new Promise((resolve) => setTimeout(resolve, 0)) - } = {}) { - const { chart } = target; - const saveController = installTradingViewMarkerSaveController(target.tradingViewApi, { onError: onSaveError }); - const canMutate = () => canMutateExternally() && saveController.canMutate(); - const registry = /* @__PURE__ */ new Map(); - const pendingMarkers = /* @__PURE__ */ new Set(); - let generation = 0; - let creating = 0; - function mutate(action) { - const finish = saveController.beginMutation(); - try { - return action(); - } finally { - finish(); - } - } - function removePendingMarkers() { - if (pendingMarkers.size === 0 || !canMutate()) return; - const liveShapeIds = readLiveShapes(chart); - for (const id of pendingMarkers) { - if (liveShapeIds.has(id)) mutate(() => chart.removeEntity(id)); - pendingMarkers.delete(id); - } - } - function discardMissingSignals(liveShapeIds) { - for (const [signalId, record] of registry) { - if (!liveShapeIds.has(record.markerId)) registry.delete(signalId); - } + 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 removeSignal(signalId, liveShapeIds) { - const record = registry.get(signalId); - if (!record) return; - if (liveShapeIds.has(record.markerId)) { - mutate(() => chart.removeEntity(record.markerId)); - liveShapeIds.delete(record.markerId); - } - registry.delete(signalId); + } + 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 assertFiniteNumber2(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`); } - return Object.freeze({ - async render(signals, { isCurrent }) { - if (!Array.isArray(signals)) throw new Error("TradingView Bollinger alert signals are invalid"); - if (signals.length > MAX_BOLLINGER_MARKERS) { - throw new Error( - `TradingView Bollinger alert marker limit exceeded: ${signals.length}` - ); - } - if (typeof isCurrent !== "function") { - throw new Error("TradingView Bollinger alert current-target validator is unavailable"); - } - const normalizedSignals = signals.map((signal, index) => normalizeSignal(signal, index, defaultDirection)); - const directionCounts = { bearish: 0, bullish: 0 }; - for (const signal of normalizedSignals) { - directionCounts[signal.direction] += 1; - if (directionCounts[signal.direction] > MAX_BOLLINGER_MARKERS_PER_DIRECTION) { - throw new Error( - `TradingView Bollinger alert ${signal.direction} marker limit exceeded: ` + directionCounts[signal.direction] - ); - } - } - const requestedGeneration = generation; - if (!isCurrent() || !canMutate()) return false; - removePendingMarkers(); - let liveShapeIds = readLiveShapes(chart); - discardMissingSignals(liveShapeIds); - const nextIds = new Set(normalizedSignals.map((signal) => signal.id)); - for (const signalId of [...registry.keys()]) { - if (!nextIds.has(signalId)) removeSignal(signalId, liveShapeIds); - } - let batchStartedAt = performance.now(); - let batchOps = 0; - for (const signal of normalizedSignals) { - if (batchOps > 0 && (batchOps >= 32 || performance.now() - batchStartedAt >= 8)) { - await yieldToBrowser(); - if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; - liveShapeIds = readLiveShapes(chart); - discardMissingSignals(liveShapeIds); - batchStartedAt = performance.now(); - batchOps = 0; - } - if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; - batchOps += 1; - const options = markerOptions(signal, target.resolution); - const existing = registry.get(signal.id); - if (existing) { - const shape = chart.getShapeById(existing.markerId); - const point = readMarkerPoint(shape); - if (point.time === signal.time && point.price === existing.resolvedPrice && existing.markerPrice === signal.markerPrice && existing.type === signal.type && existing.direction === signal.direction && liveShapeIds.get(existing.markerId) === options.shape && markerPropertiesMatch(shape, options)) continue; - removeSignal(signal.id, liveShapeIds); - } - const finishCreation = saveController.beginMutation(); - creating += 1; - try { - const markerId = await chart.createShape({ time: signal.time, price: signal.markerPrice }, { - ...options, - overrides: { ...options.overrides, visible: false } - }); - if (typeof markerId !== "string" || markerId.length === 0) { - throw new Error("TradingView returned an invalid Bollinger alert shape id"); - } - pendingMarkers.add(markerId); - if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; - const shape = chart.getShapeById(markerId); - const point = readMarkerPoint(shape); - if (point.time !== signal.time) { - throw new Error(`TradingView Bollinger alert time alignment failed for ${signal.time}`); - } - if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; - mutate(() => shape.setProperties(options.overrides, false)); - if (!markerPropertiesMatch(shape, options)) { - throw new Error("TradingView Bollinger alert marker properties were not applied"); - } - registry.set(signal.id, { - markerId, - resolvedPrice: point.price, - markerPrice: signal.markerPrice, - type: signal.type, - direction: signal.direction - }); - pendingMarkers.delete(markerId); - } finally { - finishCreation(); - creating -= 1; - removePendingMarkers(); - } - } - return true; - }, - clear() { - generation += 1; - if (!canMutate()) return false; - removePendingMarkers(); - const liveShapeIds = readLiveShapes(chart); - discardMissingSignals(liveShapeIds); - for (const signalId of [...registry.keys()]) removeSignal(signalId, liveShapeIds); - return creating === 0 && pendingMarkers.size === 0; - }, - get size() { - return registry.size; - }, - get saveStats() { - return saveController.getStats(); - } - }); } - function createBollingerMarkerLayer(target, options) { - return createMarkerLayer(target, void 0, options); + 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`; } - - // src/binance-strategy29-bollinger/monitor.js - function createBollingerMonitor({ - document, - getCurrentSymbol, - isFuturesTradingPage, - isTradingViewDrawingMutationBusy, - err, - warn - }) { - let bearishBollingerAlertTask = null; - let bearishBollingerAlertContext = null; - let bollingerIntervalSession = null; - let lastLocalFailure = null; - const retiredBollingerLayers = /* @__PURE__ */ new Set(); - function clearBearishBollingerAlertContext() { - if (bearishBollingerAlertContext) { - retiredBollingerLayers.add(bearishBollingerAlertContext.layer); - bearishBollingerAlertContext = null; - } - return clearRetiredBollingerLayers(); + 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 clearRetiredBollingerLayers() { - if (isTradingViewDrawingMutationBusy()) return false; - for (const layer of retiredBollingerLayers) { - if (layer.clear()) retiredBollingerLayers.delete(layer); + } + function validateUniverse(value) { + assertExactKeys(value, UNIVERSE_KEYS, "status.universe"); + if (value.source_monitor !== "monitor29_bollinger_ma60") throw new TypeError("status.universe.source_monitor is invalid"); + assertInteger(value.generation, "status.universe.generation", { nullable: true, minimum: 1 }); + assertEnum(value.refresh_status, UNIVERSE_STATES, "status.universe.refresh_status"); + assertEnum(value.reason, UNIVERSE_REASONS[value.refresh_status], "status.universe.reason"); + for (const key of ["selected_markets", "configured_timeframes"]) { + if (!Array.isArray(value[key]) || value[key].length > 128 || new Set(value[key]).size !== value[key].length) { + throw new TypeError(`status.universe.${key} must be a bounded unique array`); } - return retiredBollingerLayers.size === 0; } - function disposeBollingerIntervalSession() { - if (bollingerIntervalSession) { - bollingerIntervalSession.session.dispose(); - bollingerIntervalSession = null; - } + value.selected_markets.forEach((symbol) => assertCanonicalSymbol(symbol, "status.universe.selected_markets")); + value.configured_timeframes.forEach((timeframe) => assertEnum(timeframe, TIMEFRAMES, "status.universe.configured_timeframes")); + for (const key of ["selected_unit_count", "ready_unit_count", "pending_unit_count"]) { + assertInteger(value[key], `status.universe.${key}`); + if (value[key] > 128) throw new TypeError(`status.universe.${key} exceeds the unit bound`); } - function isBearishBollingerAlertContextCurrent(context) { - return bearishBollingerAlertContext === context && context.intervalSession === bollingerIntervalSession?.session && context.intervalSession.isCurrent(context.intervalRevision) && !document.hidden && isFuturesTradingPage() && !isTradingViewDrawingMutationBusy() && getCurrentSymbol() === context.routeSymbol && isBearishBollingerChartTargetCurrent(document, context.target); + if (value.ready_unit_count + value.pending_unit_count !== value.selected_unit_count || value.selected_markets.length > value.selected_unit_count) throw new TypeError("status.universe counts are inconsistent"); + if (value.refresh_status === "fail_closed" && value.selected_unit_count !== 0) throw new TypeError("status.universe unavailable selection must be empty"); + for (const key of ["refreshed_at_ms", "last_successful_refreshed_at_ms", "last_refresh_error_at_ms", "selection_expires_at_ms"]) { + assertInteger(value[key], `status.universe.${key}`, { nullable: true }); } - async function synchronizeBearishBollingerAlerts() { - if (document.hidden || !isFuturesTradingPage()) return; - const routeSymbol = getCurrentSymbol(); - if (!routeSymbol) return; - let target; - try { - target = findBearishBollingerChartTarget(document, routeSymbol); - } catch (error) { - disposeBollingerIntervalSession(); - clearBearishBollingerAlertContext(); - err("Bollinger chart lookup failed for this sample:", error); - return; + if (value.last_success_age_seconds !== null) { + assertFiniteNumber2(value.last_success_age_seconds, "status.universe.last_success_age_seconds"); + if (value.last_success_age_seconds < 0) throw new TypeError("status.universe last success age must be non-negative"); + } + if (value.last_successful_refreshed_at_ms === null !== (value.last_success_age_seconds === null)) { + throw new TypeError("status.universe last success fields are inconsistent"); + } + const successMissing = value.last_successful_refreshed_at_ms === null; + if (successMissing !== (value.selection_expires_at_ms === null)) { + throw new TypeError("status.universe successful selection requires its expiry"); + } + const absentFacts = value.reason === "missing_current_universe_facts" || value.reason === "incompatible_current_universe_facts"; + if (absentFacts) { + if ([ + value.generation, + value.refreshed_at_ms, + value.last_successful_refreshed_at_ms, + value.last_success_age_seconds, + value.last_refresh_error_at_ms, + value.selection_expires_at_ms + ].some((item) => item !== null)) { + throw new TypeError("status.universe unavailable facts must have null refresh metadata"); } - if (!target) { - disposeBollingerIntervalSession(); - clearBearishBollingerAlertContext(); - return; + } else { + if (value.generation === null || value.refreshed_at_ms === null) { + throw new TypeError("status.universe current facts require generation and refresh time"); } - if (!bollingerIntervalSession || bollingerIntervalSession.chart !== target.chart || bollingerIntervalSession.routeSymbol !== routeSymbol) { - disposeBollingerIntervalSession(); - bollingerIntervalSession = { - chart: target.chart, - routeSymbol, - session: createBollingerIntervalSession(target.chart) - }; + if (value.reason !== "selection_fail_closed" && successMissing) { + throw new TypeError("status.universe successful selection metadata is required"); } - const intervalSession = bollingerIntervalSession.session; - const contextMatches = bearishBollingerAlertContext && bearishBollingerAlertContext.target.chart === target.chart && bearishBollingerAlertContext.target.chartRoot === target.chartRoot && bearishBollingerAlertContext.target.tradingViewApi === target.tradingViewApi && bearishBollingerAlertContext.routeSymbol === routeSymbol && bearishBollingerAlertContext.resolution === target.resolution && bearishBollingerAlertContext.intervalSession === intervalSession && bearishBollingerAlertContext.intervalRevision === intervalSession.revision; - if (!contextMatches) { - if (!clearBearishBollingerAlertContext()) return; - if (!intervalSession.isCurrent(intervalSession.revision) || isTradingViewDrawingMutationBusy()) return; - bearishBollingerAlertContext = { - routeSymbol, - resolution: target.resolution, - intervalSession, - intervalRevision: intervalSession.revision, - target, - layer: createBollingerMarkerLayer(target, { - canMutate: () => !isTradingViewDrawingMutationBusy(), - onSaveError: (error) => err("Bollinger chart save failed:", error) - }), - failed: false, - cleanupPending: false, - lastProcessedClosedBarsWindowKey: null, - lastProcessedClosedBarsContentSnapshot: null, - lastProcessedSignals: null - }; + if (value.refresh_status === "fresh" && value.last_refresh_error_at_ms !== null) { + throw new TypeError("status.universe fresh selection cannot report a refresh error"); } - if (isTradingViewDrawingMutationBusy() || !clearRetiredBollingerLayers()) return; - const context = bearishBollingerAlertContext; - if (context.cleanupPending) { - context.layer.clear(); - context.cleanupPending = false; + if ((value.refresh_status === "stale_if_error" || value.reason === "selection_fail_closed") && value.last_refresh_error_at_ms === null) { + throw new TypeError("status.universe failed refresh requires its error time"); } - if (context.failed || bearishBollingerAlertTask) return; - let stage = "export"; - const task = (async () => { - const bars = await exportClosedTradingViewBars(context.target, context.intervalSession); - if (!bars || !isBearishBollingerAlertContextCurrent(context)) return; - if (bars.length === 0) return; - stage = "reconcile"; - const result = await reconcileBearishBollingerAlertWindow({ - bars, - cachedWindowKey: context.lastProcessedClosedBarsWindowKey, - cachedContentSnapshot: context.lastProcessedClosedBarsContentSnapshot, - cachedSignals: context.lastProcessedSignals, - detectSignals: (bars2) => { - stage = "detect"; - const signals = detectBollingerSignals(bars2); - stage = "reconcile"; - return signals; - }, - renderSignals: async (signals) => { - stage = "render"; - const rendered = await context.layer.render(signals, { - isCurrent: () => isBearishBollingerAlertContextCurrent(context) - }); - stage = "reconcile"; - return rendered; - } - }); - if (result.rendered && isBearishBollingerAlertContextCurrent(context)) { - context.lastProcessedClosedBarsWindowKey = result.closedBarsWindowKey; - context.lastProcessedClosedBarsContentSnapshot = result.closedBarsContentSnapshot; - context.lastProcessedSignals = result.signals; - } - })(); - bearishBollingerAlertTask = task; - task.catch((error) => { - if (bearishBollingerAlertContext !== context || context.intervalSession !== bollingerIntervalSession?.session || context.intervalRevision !== context.intervalSession.revision) return; - let failureKind; - let classificationFailed = false; - try { - failureKind = applyBollingerAlertTaskFailure(context, error); - } catch { - classificationFailed = true; - context.failed = true; - context.cleanupPending = true; - failureKind = "fatal"; - } - if (failureKind === "retry") { - warn("布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:", error); - return; - } - const details = { name: null, message: null }; - const unreadableFields = []; - for (const [key, limit] of [["name", 64], ["message", 512]]) { - try { - const value = key === "message" && typeof error === "string" ? error : error?.[key]; - details[key] = typeof value === "string" ? value.slice(0, limit) : null; - } catch { - unreadableFields.push(key); - } - } - lastLocalFailure = Object.freeze({ - thrownType: error === null ? "null" : typeof error, - classificationFailed, - ...details, - unreadableFields: Object.freeze(unreadableFields), - stage, - routeSymbol: context.routeSymbol, - resolution: context.resolution, - cachedSignalCount: context.lastProcessedSignals === null ? null : context.lastProcessedSignals.length, - layerSizeBeforeCleanup: context.layer.size, - sessionRevision: context.intervalSession.revision, - contextIntervalRevision: context.intervalRevision - }); - err("布林带形态预警已停止:", error); - }).finally(() => { - if (bearishBollingerAlertTask === task) bearishBollingerAlertTask = null; - }); } - function stopBearishBollingerAlertMonitor() { - disposeBollingerIntervalSession(); - clearBearishBollingerAlertContext(); + } + function validateStrategy29StatusResponse(value, httpStatus) { + if (httpStatus !== 200) throw new TypeError(`status response requires HTTP 200, received ${httpStatus}`); + assertObject(value, "status response"); + assertSchema(value.schema_version, "status.schema_version"); + assertString(value.spec_version, "status.spec_version"); + assertInteger(value.observed_at_ms, "status.observed_at_ms"); + if (value.spec_version !== STRATEGY29_SPEC_VERSION) return { + schema_version: value.schema_version, + spec_version: value.spec_version, + observed_at_ms: value.observed_at_ms + }; + assertExactKeys(value, STATUS_KEYS, "status response"); + validateUniverse(value.universe); + if (!Array.isArray(value.units)) throw new TypeError("status.units must be an array"); + if (value.units.length > 128) throw new TypeError("status.units exceeds the 128-unit bound"); + 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}`); } - function getBollingerAlertDiagnostics() { - const context = bearishBollingerAlertContext; - const session = bollingerIntervalSession?.session || null; - const chart = bollingerIntervalSession?.chart || context?.target.chart || null; - const nativeModelReady = chart ? chart.hasModel() : null; - return { - taskPending: bearishBollingerAlertTask !== null, - contextPresent: context !== null, - failed: context ? context.failed : null, - lastLocalFailure, - cleanupPending: context ? context.cleanupPending : null, - cachedSignalCount: context?.lastProcessedSignals === null || !context ? null : context.lastProcessedSignals.length, - layerSize: context ? context.layer.size : null, - markerSaveStats: context ? context.layer.saveStats : null, - retiredCount: retiredBollingerLayers.size, - sessionPresent: session !== null, - sessionRevision: session ? session.revision : null, - contextIntervalRevision: context ? context.intervalRevision : null, - sessionMatchesContext: context && session ? context.intervalSession === session : null, - sessionCurrent: session && nativeModelReady ? session.isCurrent(session.revision) : null, - nativeModelReady, - nativeDataReady: nativeModelReady ? chart.dataReady() : null, - mutationBlocked: isTradingViewDrawingMutationBusy() - }; + 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`); } - return Object.freeze({ - tick: synchronizeBearishBollingerAlerts, - stop: stopBearishBollingerAlertMonitor, - get diagnostics() { - return getBollingerAlertDiagnostics(); - } - }); + 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"]) { + assertFiniteNumber2(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 }); } - - // src/shared/chart-mutation-owners.js - var OWNER_SLOT = Symbol.for("jh-userscripts.chart-mutation-owners"); - var VERSION = 1; - function existingOwners(view) { - const record = view[OWNER_SLOT]; - 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"); + 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}`); } - return record.predicates; + 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 isChartMutationBlocked(view) { - 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; + function validateStrategy29GatewayError(value, httpStatus) { + if (httpStatus === 503 && (value.error === "module_disabled" || value.error === "gateway_unavailable")) { + assertExactKeys(value, value.error === "module_disabled" ? ["schema_version", "error", "strategy_id", "status"] : ["schema_version", "error", "strategy_id"], "gateway error"); + assertSchema(value.schema_version, "gateway error.schema_version"); + if (value.strategy_id !== "29" || value.error === "module_disabled" && value.status !== "disabled") { + throw new TypeError("Strategy29 gateway module identity is invalid"); + } + return value; } - return false; + 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/shared/binance-futures-route.js - var FUTURES_TRADING_PATH_RE = /^\/(?:[a-z]{2}(?:-[A-Za-z]{2})?\/)?futures\/([A-Za-z0-9_]{3,})\/?$/; - function parseFuturesTradingSymbolFromPathname(pathname) { - const normalized = String(pathname || "").split(/[?#]/, 1)[0]; - const match = normalized.match(FUTURES_TRADING_PATH_RE); - return match?.[1] ? match[1].toUpperCase() : null; - } - function isFuturesTradingPathname(pathname) { - return Boolean(parseFuturesTradingSymbolFromPathname(pathname)); + // src/binance-strategy29-bollinger/core/remote-summary-client.js + var Strategy29GatewayTransportError = class extends Error { + constructor(message) { + super(message); + this.name = "Strategy29GatewayTransportError"; + } + }; + 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; } - - // src/shared/spa-route-change.js - var ROUTE_CHANGE_EVENT = "jh-userscripts:spa-route-change"; - var ROUTE_PATCH_MARKER = Symbol.for("jh-userscripts.spa-route-change-patched"); - var ROUTE_DISPATCH_STATE = Symbol.for("jh-userscripts.spa-route-change-dispatch"); - function dispatchRouteChange(view) { - const href = view.location.href; - if (view[ROUTE_DISPATCH_STATE]?.href === href) return; - const state = { href }; - view[ROUTE_DISPATCH_STATE] = state; - view.dispatchEvent(new view.Event(ROUTE_CHANGE_EVENT)); - view.queueMicrotask(() => { - if (view[ROUTE_DISPATCH_STATE] === state) delete view[ROUTE_DISPATCH_STATE]; - }); + 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 patchHistoryMethod(view, methodName) { - const current = view.history[methodName]; - if (current[ROUTE_PATCH_MARKER]) return; - function routeAwareHistoryMethod(...args) { - const previousHref = view.location.href; - const result = Reflect.apply(current, this, args); - if (view.location.href !== previousHref) dispatchRouteChange(view); - return result; + 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`); } - Object.defineProperty(routeAwareHistoryMethod, ROUTE_PATCH_MARKER, { value: true }); - view.history[methodName] = routeAwareHistoryMethod; } - function ensureSpaRouteChangePatched(view) { - if (!view?.history) throw new Error("SPA route patch requires a window"); - patchHistoryMethod(view, "pushState"); - patchHistoryMethod(view, "replaceState"); + function buildEventsUrl(origin, canonicalSymbol, cursor) { + const url = new URL("/v1/strategy29/events", origin); + url.searchParams.set("symbol", canonicalSymbol); + if (cursor === null) { + url.searchParams.set("mode", "latest"); + url.searchParams.set("limit", "20"); + } else url.searchParams.set("cursor", String(cursor)); + return url.href; } - function installSpaRouteChangeListener(view, listener) { - if (!view?.history || typeof listener !== "function") { - throw new Error("SPA route listener requires a window and callback"); + 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 }); } - ensureSpaRouteChangePatched(view); - view.addEventListener(ROUTE_CHANGE_EVENT, listener); - view.addEventListener("popstate", listener); - view.addEventListener("hashchange", listener); - return () => { - view.removeEventListener(ROUTE_CHANGE_EVENT, listener); - view.removeEventListener("popstate", listener); - view.removeEventListener("hashchange", listener); - }; + async function poll(signal) { + const statusResponse = await perform(`${origin}/v1/strategy29/status`, signal); + if (signal.aborted) throw signal.reason; + const statusBody = parseJsonResponse(statusResponse, "Strategy29 status"); + if (statusResponse.status === 503) { + const error = validateStrategy29GatewayError(statusBody, 503); + return { state: error.error === "database_unavailable" ? "unavailable" : error.error, 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); + if (signal.aborted) throw signal.reason; + const eventsBody = parseJsonResponse(eventsResponse, "Strategy29 events"); + pages += 1; + if (eventsResponse.status === 409) { + const error = validateStrategy29GatewayError(eventsBody, 409); + cursor = null; + onCursorReset(error.oldest_cursor); + hasMore = true; + continue; + } + if (eventsResponse.status === 503) { + const error = validateStrategy29GatewayError(eventsBody, 503); + return { state: error.error === "database_unavailable" ? "unavailable" : error.error, 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.has_more || page.events.length > 20)) { + throw new TypeError("Strategy29 latest snapshot must be complete and bounded to 20 events"); + } + 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 }); + } + }); } // src/binance-orderbook-trade/contracts/panel-copy.js @@ -1982,6 +1947,11 @@ // src/binance-strategy29-bollinger/ui-copy.js var pair = localizedText; var SUMMARY_COPY = Object.freeze({ + upgradeClient: pair("Strategy 29 本地信号已加载。跨周期汇总需要更新或安装 Strategy 27 信号客户端,并刷新页面。", "Strategy 29 local signals are loaded. Update or install the Strategy 27 signal client and reload for the cross-timeframe summary."), + disabled: pair("跨周期汇总未启用,可在 CorsairQuant 信号客户端菜单中开启。", "Cross-timeframe summary is disabled. Enable it in the CorsairQuant signal client menu."), + moduleDisabled: pair("服务端尚未启用 Strategy 29 监控汇总", "Strategy 29 monitoring summary is not enabled on the server"), + gatewayUnavailable: pair("Strategy 29 后端暂不可用,等待恢复", "Strategy 29 backend is unavailable; waiting for recovery"), + noLiveStatus: pair("当前监控状态不可用;下方仅保留历史信号。", "Current monitoring status is unavailable; only retained signals are shown below."), title: pair("Strategy 29 汇总", "Strategy 29 Summary"), drag: pair("拖动面板", "Drag panel"), collapse: pair("收起", "Collapse"), @@ -2023,11 +1993,6 @@ disconnected: pair("网关连接失败,将在下次定时检查时重试", "Gateway connection failed; next scheduled poll will retry"), stopped: (detail) => pair(`远程汇总已停止。技术详情:${detail}`, `Remote summary stopped: ${detail}`), menuToggle: pair("切换 Strategy 29 跨周期汇总", "Toggle Strategy 29 cross-timeframe summary"), - menuSecret: pair("设置 Strategy 29 网关密钥", "Set Strategy 29 gateway secret"), - menuOrigin: pair("设置 Strategy 29 网关地址", "Set Strategy 29 gateway origin"), - promptSecret: pair("请输入本地 Strategy 29 网关密钥,仅保存在此用户脚本的私有存储中。", "Enter the local Strategy 29 gateway secret. It is stored only in this userscript storage."), - promptOrigin: pair("请输入本机网关地址(http://127.0.0.1:)", "Enter the loopback gateway origin (http://127.0.0.1:)"), - emptySecret: pair("Strategy 29 网关密钥不能为空", "Strategy 29 gateway secret cannot be empty"), localStopped: (detail) => pair(`Strategy 29 已停止。技术详情:${detail}`, `Strategy 29 stopped: ${detail}`), conflict: pair("Strategy 29 已停止:请将订单簿脚本更新至 2.7.199 或更高版本,或禁用内嵌布林带观察器的旧版本,然后刷新页面。", "Strategy 29 stopped: update Orderbook to 2.7.199 or disable its embedded Bollinger version, then reload this page.") }); @@ -2128,6 +2093,9 @@ // src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js var PANEL_ID = "jh-strategy29-summary-panel"; var STATE_COLORS = Object.freeze({ + disabled: "#848E9C", + module_disabled: "#848E9C", + gateway_unavailable: "#F0B90B", connected: "#0ECB81", connecting: "#F0B90B", unavailable: "#F0B90B", @@ -2239,6 +2207,19 @@ const position = installPanelPosition(document, panel, header, { initialPosition: stored, savePosition }); const eventRecords = /* @__PURE__ */ new Map(); let lastStatus = null; + function clearCurrentStatus() { + lastStatus = null; + spec.dataset.state = "unavailable"; + spec.style.color = "#848E9C"; + spec.textContent = text(SUMMARY_COPY.observerSpec(STRATEGY29_SPEC_VERSION)); + statusFreshness.textContent = text(SUMMARY_COPY.noStatus); + selection.dataset.state = "unavailable"; + selection.style.color = "#848E9C"; + selection.textContent = text(SUMMARY_COPY.noLiveStatus); + selectionRefresh.textContent = ""; + units.replaceChildren(); + delivery.textContent = text(SUMMARY_COPY.waitingDelivery); + } let lastEventsAt = null; let connectionCopy = SUMMARY_COPY.waiting; let destroyed = false; @@ -2287,11 +2268,7 @@ eventsTitle.textContent = text(SUMMARY_COPY.recent); eventsFreshness.textContent = text(lastEventsAt === null ? SUMMARY_COPY.noEventsCheck : SUMMARY_COPY.eventsAt(formatClock(lastEventsAt))); if (lastStatus !== null) api.renderStatus(lastStatus); - else { - spec.textContent = text(SUMMARY_COPY.observerSpec(STRATEGY29_SPEC_VERSION)); - statusFreshness.textContent = text(SUMMARY_COPY.noStatus); - delivery.textContent = text(SUMMARY_COPY.waitingDelivery); - } + else clearCurrentStatus(); renderEvents(); position.clamp(); }, @@ -2302,6 +2279,7 @@ connection.style.color = STATE_COLORS[state]; connectionCopy = message; connection.textContent = text(message); + if (["disabled", "module_disabled", "gateway_unavailable", "unavailable"].includes(state)) clearCurrentStatus(); position.clamp(); }, renderStatus(snapshot) { @@ -2385,22 +2363,19 @@ // src/binance-strategy29-bollinger/remote-summary.js var STRATEGY29_PANEL_POSITION_KEY = "strategy29SummaryPanelPosition"; 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 }) { + function assertAdapters({ view, request, getValue, setValue, registerMenuCommand, getGatewaySettings, 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, + getGatewaySettings, createPanel, createClient })) { @@ -2413,12 +2388,12 @@ getValue, setValue, registerMenuCommand, - promptUser: promptUser2, + getGatewaySettings, createPanel = createStrategy29SummaryPanel, createClient = createStrategy29SummaryClient, pollIntervalMs = STRATEGY29_REMOTE_POLL_INTERVAL_MS }) { - assertAdapters({ view, request, getValue, setValue, registerMenuCommand, promptUser: promptUser2, createPanel, createClient }); + assertAdapters({ view, request, getValue, setValue, registerMenuCommand, getGatewaySettings, 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; @@ -2437,12 +2412,9 @@ context.panel.destroy(); } function configuredSettings() { - const authSecret = getValue(STRATEGY29_GATEWAY_SECRET_KEY, ""); + const { authSecret, gatewayOrigin } = getGatewaySettings(); 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 }; + return { authSecret, gatewayOrigin: normalizeStrategy29GatewayOrigin(gatewayOrigin) }; } function startContext(routeSymbol) { const canonicalSymbol = routeSymbolToCanonical(routeSymbol); @@ -2468,6 +2440,11 @@ lastResult: null }; active = context; + if (!enabled) { + context.state = "disabled"; + panel.setConnection("disabled", SUMMARY_COPY.disabled); + return context; + } let settings; try { settings = configuredSettings(); @@ -2513,7 +2490,7 @@ return context; } function synchronizeContext() { - if (!enabled || !view.document.body) { + if (!view.document.body) { unsupportedRoute = null; stopActive("Strategy 29 remote summary disabled"); return null; @@ -2560,6 +2537,8 @@ const presentation = { connected: ["connected", result.hasMore ? SUMMARY_COPY.moreHistory : SUMMARY_COPY.connected], unavailable: ["unavailable", SUMMARY_COPY.unavailable], + module_disabled: ["module_disabled", SUMMARY_COPY.moduleDisabled], + gateway_unavailable: ["gateway_unavailable", SUMMARY_COPY.gatewayUnavailable], incompatible: ["incompatible", SUMMARY_COPY.incompatible] }[result.state]; if (!presentation) throw new Error(`Strategy 29 remote state is invalid: ${result.state}`); @@ -2581,6 +2560,7 @@ }); } function restart() { + enabled = getValue(STRATEGY29_REMOTE_ENABLED_KEY, false) === true; unsupportedRoute = null; stopActive("Strategy 29 remote settings changed"); if (!disposed) void sample(Date.now()); @@ -2590,20 +2570,6 @@ enabled = !enabled; setValue(STRATEGY29_REMOTE_ENABLED_KEY, enabled); restart(); - } }, - { copy: SUMMARY_COPY.menuSecret, run() { - const value = promptUser2(formatLocalizedText(SUMMARY_COPY.promptSecret, resolveUiLocaleFromPathname(view.location.pathname))); - if (value === null) return; - if (value.length === 0) throw new Error(text(SUMMARY_COPY.emptySecret)); - setValue(STRATEGY29_GATEWAY_SECRET_KEY, value); - restart(); - } }, - { copy: SUMMARY_COPY.menuOrigin, run() { - const current = getValue(STRATEGY29_GATEWAY_ORIGIN_KEY, STRATEGY29_DEFAULT_GATEWAY_ORIGIN); - const value = promptUser2(formatLocalizedText(SUMMARY_COPY.promptOrigin, resolveUiLocaleFromPathname(view.location.pathname)), current); - if (value === null) return; - setValue(STRATEGY29_GATEWAY_ORIGIN_KEY, normalizeStrategy29GatewayOrigin(value)); - restart(); } } ]; for (const menu of menus) menu.id = registerMenuCommand(text(menu.copy), menu.run); @@ -2647,9 +2613,37 @@ }); } + // src/shared/strategy29-preferences-migration.js + var RECORD = Symbol.for("jh-userscripts.strategy29-preferences-migration"); + var STRATEGY29_PREFERENCES_EVENT = "jh-strategy29-preferences-ready"; + var SIGNAL_HOST_READY = Symbol.for("jh-userscripts.strategy29-signal-host-ready"); + var SIGNAL_HOST_READY_EVENT = "jh-strategy29-signal-host-ready"; + var ENABLED = "strategy29RemoteSummaryEnabled"; + var POSITION = "strategy29SummaryPanelPosition"; + function validate(record) { + if (!record || Object.keys(record).sort().join(",") !== "enabled,position,version" || record.version !== 1 || typeof record.enabled !== "boolean") { + throw new TypeError("Strategy29 preference migration record is invalid"); + } + const point = record.position; + if (point !== null && (Object.keys(point).sort().join(",") !== "left,top" || !Number.isFinite(point.left) || !Number.isFinite(point.top))) { + throw new TypeError("Strategy29 preference migration position is invalid"); + } + return { version: 1, enabled: record.enabled, position: point === null ? null : { left: point.left, top: point.top } }; + } + function publishStrategy29Preferences(view, getValue) { + if (view[RECORD] !== void 0) { + validate(view[RECORD]); + return; + } + const record = validate({ version: 1, enabled: getValue(ENABLED, false), position: getValue(POSITION, null) }); + if (record.position !== null) Object.freeze(record.position); + Object.defineProperty(view, RECORD, { value: Object.freeze(record) }); + view.dispatchEvent(new view.Event(STRATEGY29_PREFERENCES_EVENT)); + } + // src/binance-strategy29-bollinger/runtime.js var INSTANCE = Symbol.for("jh-userscripts.strategy29-bollinger"); - var RUNTIME_VERSION = 2; + var RUNTIME_VERSION = 3; var CONFLICT = SUMMARY_COPY.conflict; function hasEmbeddedBollinger(view) { const debug = view.__TM_CLOSE_LONG_DEBUG__; @@ -2667,6 +2661,23 @@ let removeRouteListener = null; const remoteSummary = remoteAdapters === null ? null : createStrategy29RemoteSummary({ view, ...remoteAdapters }); const noticeId = "jh-strategy29-bollinger-status"; + const upgradeNoticeId = "jh-strategy29-client-upgrade"; + function showUpgradeNotice() { + if (remoteSummary !== null || disposed || view[SIGNAL_HOST_READY] === true || !isFuturesTradingPathname(view.location.pathname)) { + document.getElementById(upgradeNoticeId)?.remove(); + return; + } + if (!document.body) return; + let notice = document.getElementById(upgradeNoticeId); + if (!notice) { + notice = document.createElement("div"); + notice.id = upgradeNoticeId; + notice.setAttribute("role", "status"); + notice.style.cssText = "position:fixed;left:16px;top:16px;z-index:10000;max-width:420px;padding:10px;background:#332b16;color:#ffcf67;font:13px sans-serif;pointer-events:none"; + document.body.append(notice); + } + notice.textContent = formatLocalizedText(SUMMARY_COPY.upgradeClient, resolveUiLocaleFromPathname(view.location.pathname)); + } function showFailure() { if (!failed || !document.body) return; let notice = document.getElementById(noticeId); @@ -2701,6 +2712,7 @@ } function sample() { if (disposed || document.hidden) return; + showUpgradeNotice(); if (failed) { showFailure(); return; @@ -2751,9 +2763,12 @@ removeRouteListener(); document.removeEventListener("visibilitychange", onVisibility); document.removeEventListener("DOMContentLoaded", showFailure); + document.removeEventListener("DOMContentLoaded", showUpgradeNotice); + view.removeEventListener(SIGNAL_HOST_READY_EVENT, showUpgradeNotice); view.removeEventListener("pagehide", onPageHide); view.removeEventListener("pageshow", onPageShow); document.getElementById(noticeId)?.remove(); + document.getElementById(upgradeNoticeId)?.remove(); } }); Object.defineProperty(view, INSTANCE, { value: Object.freeze({ version: RUNTIME_VERSION, runtime }) }); @@ -2761,6 +2776,8 @@ removeRouteListener = installSpaRouteChangeListener(view, sample); document.addEventListener("visibilitychange", onVisibility); document.addEventListener("DOMContentLoaded", showFailure, { once: true }); + document.addEventListener("DOMContentLoaded", showUpgradeNotice, { once: true }); + view.addEventListener(SIGNAL_HOST_READY_EVENT, showUpgradeNotice); view.addEventListener("pagehide", onPageHide); view.addEventListener("pageshow", onPageShow); resume(); @@ -2768,12 +2785,6 @@ } // src/binance-strategy29-bollinger/index.user.js - var promptUser = globalThis.prompt.bind(globalThis); - installStrategy29(unsafeWindow, { - request: createStrategy29GmJsonRequest(GM_xmlhttpRequest), - getValue: GM_getValue, - setValue: GM_setValue, - registerMenuCommand: GM_registerMenuCommand, - promptUser - }); + installStrategy29(unsafeWindow); + publishStrategy29Preferences(unsafeWindow, GM_getValue); })(); diff --git a/src/binance-strategy27-events/index.user.js b/src/binance-strategy27-events/index.user.js index 22eae60..c3d3341 100644 --- a/src/binance-strategy27-events/index.user.js +++ b/src/binance-strategy27-events/index.user.js @@ -3,9 +3,9 @@ // @namespace binance.strategy27.events // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.4.6 +// @version 0.5.0 // @author jackhai9 -// @description 在 Binance 一秒图表标注 VPS Strategy 27 的实时订单流候选观察 +// @description 统一配置 CorsairQuant 网关,显示 Strategy 27 事件与 Strategy 29 跨周期汇总 // @match https://www.binance.com/*/futures/* // @match https://www.binance.com/futures/* // @exclude https://www.binance.com/*/my/wallet/futures/* @@ -49,13 +49,19 @@ import { createTradingViewCompoundLayer } from './dom/tradingview-compound-layer import { parseFuturesTradingSymbolFromPathname } from '../shared/binance-futures-route.js'; import { createStrategy27Translator, localizeAnnotation, resolveUiLocaleFromPathname } from './core/ui-copy.js'; import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; +import { createStrategy29RemoteSummary } from '../binance-strategy29-bollinger/remote-summary.js'; +import { createStrategy29GmJsonRequest } from '../binance-strategy29-bollinger/core/remote-summary-client.js'; +import { readSignalGatewaySettings, SIGNAL_GATEWAY_ORIGIN, SIGNAL_GATEWAY_ORIGIN_KEY, SIGNAL_GATEWAY_SECRET_KEY } from '../shared/signal-client-settings.js'; +import { migrateStrategy29Preferences, isStrategy29CompanionReady, STRATEGY29_PREFERENCES_EVENT, SIGNAL_HOST_READY, SIGNAL_HOST_READY_EVENT } from '../shared/strategy29-preferences-migration.js'; + +const promptUser = globalThis.prompt.bind(globalThis); (function () { 'use strict'; - const DEFAULT_GATEWAY_ORIGIN = 'http://127.0.0.1:18765'; - const GATEWAY_ORIGIN_KEY = 'strategy27GatewayOrigin'; - const GATEWAY_SECRET_KEY = 'strategy27GatewayAuthSecret'; + const DEFAULT_GATEWAY_ORIGIN = SIGNAL_GATEWAY_ORIGIN; + const GATEWAY_ORIGIN_KEY = SIGNAL_GATEWAY_ORIGIN_KEY; + const GATEWAY_SECRET_KEY = SIGNAL_GATEWAY_SECRET_KEY; const PANEL_POSITION_KEY = 'strategy27EventPanelPosition'; const CONTEXT_CHECK_INTERVAL_MS = 1_000; const MAX_RETAINED_EVENTS = 80; @@ -69,6 +75,60 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; let uiLocale = resolveUiLocaleFromPathname(page.location.pathname); let t = createStrategy27Translator(uiLocale); let statusCopy = null; + let strategy29Summary = null; + let strategy29Failure = null; + function failStrategy29(error) { + if (strategy29Failure !== null) return; + strategy29Failure = String(error.message).slice(0, 512); + if (strategy29Summary !== null) strategy29Summary.dispose(); + page.console.warn('[CorsairQuant Strategy29]', strategy29Failure); + } + /** The companion must relinquish legacy remote ownership before this installation takes it. */ + function initializeStrategy29() { + if (strategy29Summary !== null || !isStrategy29CompanionReady(page)) return; + migrateStrategy29Preferences(page, GM_getValue, GM_setValue); + strategy29Summary = createStrategy29RemoteSummary({ + view: page, + request: createStrategy29GmJsonRequest(GM_xmlhttpRequest), + getValue: GM_getValue, + setValue: GM_setValue, + registerMenuCommand: GM_registerMenuCommand, + getGatewaySettings: () => readSignalGatewaySettings(GM_getValue), + }); + Object.defineProperty(page, SIGNAL_HOST_READY, { value: true }); + page.dispatchEvent(new page.Event(SIGNAL_HOST_READY_EVENT)); + } + try { initializeStrategy29(); } catch (error) { failStrategy29(error); } + /** Module boundary: a panel failure must not interrupt independent Strategy27 consumers. */ + function sampleStrategy29() { + if (strategy29Failure !== null || strategy29Summary === null) return; + try { + const pending = strategy29Summary.sample(Date.now()); + if (pending) void pending.catch(failStrategy29); + } catch (error) { failStrategy29(error); } + } + function onStrategy29Preferences() { + if (strategy29Failure !== null) return; + try { + initializeStrategy29(); + sampleStrategy29(); + } catch (error) { failStrategy29(error); } + } + page.addEventListener(STRATEGY29_PREFERENCES_EVENT, onStrategy29Preferences); + function onSummaryVisibility() { + if (strategy29Failure !== null || strategy29Summary === null) return; + if (pageDocument.hidden) strategy29Summary.pause(); + else sampleStrategy29(); + } + function pauseSummary() { if (strategy29Failure === null && strategy29Summary !== null) strategy29Summary.pause(); } + pageDocument.addEventListener('visibilitychange', onSummaryVisibility); + page.addEventListener('pagehide', pauseSummary); + page.addEventListener('pageshow', onSummaryVisibility); + Object.defineProperty(page, '__TM_SIGNAL_CLIENT_DEBUG__', { + value: Object.freeze({ get strategy29() { return strategy29Summary === null + ? { state: strategy29Failure === null ? 'waiting_for_companion' : 'initialization_failed', moduleFailure: strategy29Failure } + : { ...strategy29Summary.diagnostics, moduleFailure: strategy29Failure }; } }), + }); function stopActive(resetReason) { if (!active) return; @@ -270,6 +330,7 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; } function synchronizeContext() { + sampleStrategy29(); const nextLocale = resolveUiLocaleFromPathname(page.location.pathname); if (nextLocale !== uiLocale) { uiLocale = nextLocale; @@ -355,20 +416,23 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; function restart() { stopActive('route_changed'); + if (strategy29Failure === null && strategy29Summary !== null) { + try { strategy29Summary.restart(); } catch (error) { failStrategy29(error); } + } synchronizeContext(); } const menuDefinitions = [ - { label: () => t('设置 Strategy 27 网关密钥', 'Set Strategy 27 gateway secret'), run: () => { - const value = page.prompt(t('输入本机 Strategy 27 网关密钥。该值只保存在此油猴脚本的私有存储中。', 'Enter the local Strategy 27 gateway secret. It is saved only in this userscript’s private storage.')); + { label: () => t('设置 CorsairQuant 网关密钥', 'Set CorsairQuant gateway secret'), run: () => { + const value = promptUser(t('输入 CorsairQuant 网关密钥。所有远程策略模块共用此配置,仅保存在当前用户脚本私有存储中。', 'Enter the CorsairQuant gateway secret. All remote strategy modules share this private userscript configuration.')); if (value === null) return; - if (value.length === 0) throw new Error(t('Strategy 27 网关密钥不能为空', 'Strategy 27 gateway secret must not be empty')); + if (value.length === 0) throw new Error(t('CorsairQuant 网关密钥不能为空', 'CorsairQuant gateway secret must not be empty')); GM_setValue(GATEWAY_SECRET_KEY, value); restart(); } }, - { label: () => t('设置 Strategy 27 本机网关地址', 'Set Strategy 27 local gateway URL'), run: () => { + { label: () => t('设置 CorsairQuant 本机网关地址', 'Set CorsairQuant local gateway URL'), run: () => { const current = GM_getValue(GATEWAY_ORIGIN_KEY, DEFAULT_GATEWAY_ORIGIN); - const value = page.prompt(t('输入 SSH 本地转发地址(仅允许 http://127.0.0.1:<端口>)', 'Enter the SSH local forwarding URL (only http://127.0.0.1: is allowed)'), current); + const value = promptUser(t('输入 SSH 本地转发地址(仅允许 http://127.0.0.1:<端口>)', 'Enter the SSH local forwarding URL (only http://127.0.0.1: is allowed)'), current); if (value === null) return; GM_setValue(GATEWAY_ORIGIN_KEY, normalizeGatewayBaseUrl(value)); restart(); @@ -397,6 +461,11 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; page.addEventListener('beforeunload', () => { page.clearInterval(contextTimer); removeRouteListener(); + page.removeEventListener(STRATEGY29_PREFERENCES_EVENT, onStrategy29Preferences); + pageDocument.removeEventListener('visibilitychange', onSummaryVisibility); + page.removeEventListener('pagehide', pauseSummary); + page.removeEventListener('pageshow', onSummaryVisibility); + if (strategy29Summary !== null) strategy29Summary.dispose(); stopActive('route_changed'); }, { once: true }); synchronizeContext(); diff --git a/src/binance-strategy29-bollinger/core/remote-summary-client.js b/src/binance-strategy29-bollinger/core/remote-summary-client.js index 6a6f2b6..ea2dde3 100644 --- a/src/binance-strategy29-bollinger/core/remote-summary-client.js +++ b/src/binance-strategy29-bollinger/core/remote-summary-client.js @@ -100,8 +100,8 @@ export function createStrategy29SummaryClient({ if (signal.aborted) throw signal.reason; const statusBody = parseJsonResponse(statusResponse, 'Strategy29 status'); if (statusResponse.status === 503) { - validateStrategy29GatewayError(statusBody, 503); - return { state: 'unavailable', pages: 0, hasMore: false }; + const error = validateStrategy29GatewayError(statusBody, 503); + return { state: error.error === 'database_unavailable' ? 'unavailable' : error.error, pages: 0, hasMore: false }; } if (statusResponse.status !== 200) { validateStrategy29GatewayError(statusBody, statusResponse.status); @@ -129,8 +129,8 @@ export function createStrategy29SummaryClient({ continue; } if (eventsResponse.status === 503) { - validateStrategy29GatewayError(eventsBody, 503); - return { state: 'unavailable', pages, hasMore: false }; + const error = validateStrategy29GatewayError(eventsBody, 503); + return { state: error.error === 'database_unavailable' ? 'unavailable' : error.error, pages, hasMore: false }; } if (eventsResponse.status !== 200) { validateStrategy29GatewayError(eventsBody, eventsResponse.status); diff --git a/src/binance-strategy29-bollinger/core/remote-summary-contract.js b/src/binance-strategy29-bollinger/core/remote-summary-contract.js index 5af52de..c6b4f48 100644 --- a/src/binance-strategy29-bollinger/core/remote-summary-contract.js +++ b/src/binance-strategy29-bollinger/core/remote-summary-contract.js @@ -244,6 +244,16 @@ export function validateStrategy29EventsResponse(value, httpStatus) { } export function validateStrategy29GatewayError(value, httpStatus) { + if (httpStatus === 503 && (value.error === 'module_disabled' || value.error === 'gateway_unavailable')) { + assertExactKeys(value, value.error === 'module_disabled' + ? ['schema_version', 'error', 'strategy_id', 'status'] + : ['schema_version', 'error', 'strategy_id'], 'gateway error'); + assertSchema(value.schema_version, 'gateway error.schema_version'); + if (value.strategy_id !== '29' || (value.error === 'module_disabled' && value.status !== 'disabled')) { + throw new TypeError('Strategy29 gateway module identity is invalid'); + } + return value; + } if (httpStatus === 409) { assertExactKeys(value, ['schema_version', 'error', 'oldest_cursor'], 'gateway error'); assertSchema(value.schema_version, 'gateway error.schema_version'); diff --git a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js index 507ed5e..5c36965 100644 --- a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js +++ b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js @@ -5,6 +5,9 @@ import { installPanelPosition } from './panel-position.js'; const PANEL_ID = 'jh-strategy29-summary-panel'; const STATE_COLORS = Object.freeze({ + disabled: '#848E9C', + module_disabled: '#848E9C', + gateway_unavailable: '#F0B90B', connected: '#0ECB81', connecting: '#F0B90B', unavailable: '#F0B90B', @@ -105,6 +108,19 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve const position = installPanelPosition(document, panel, header, { initialPosition: stored, savePosition }); const eventRecords = new Map(); let lastStatus = null; + function clearCurrentStatus() { + lastStatus = null; + spec.dataset.state = 'unavailable'; + spec.style.color = '#848E9C'; + spec.textContent = text(COPY.observerSpec(STRATEGY29_SPEC_VERSION)); + statusFreshness.textContent = text(COPY.noStatus); + selection.dataset.state = 'unavailable'; + selection.style.color = '#848E9C'; + selection.textContent = text(COPY.noLiveStatus); + selectionRefresh.textContent = ''; + units.replaceChildren(); + delivery.textContent = text(COPY.waitingDelivery); + } let lastEventsAt = null; let connectionCopy = COPY.waiting; let destroyed = false; @@ -155,11 +171,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve eventsTitle.textContent = text(COPY.recent); eventsFreshness.textContent = text(lastEventsAt === null ? COPY.noEventsCheck : COPY.eventsAt(formatClock(lastEventsAt))); if (lastStatus !== null) api.renderStatus(lastStatus); - else { - spec.textContent = text(COPY.observerSpec(STRATEGY29_SPEC_VERSION)); - statusFreshness.textContent = text(COPY.noStatus); - delivery.textContent = text(COPY.waitingDelivery); - } + else clearCurrentStatus(); renderEvents(); position.clamp(); }, @@ -170,6 +182,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve connection.style.color = STATE_COLORS[state]; connectionCopy = message; connection.textContent = text(message); + if (['disabled', 'module_disabled', 'gateway_unavailable', 'unavailable'].includes(state)) clearCurrentStatus(); position.clamp(); }, renderStatus(snapshot) { diff --git a/src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js b/src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js index e23a3e9..92c9ebc 100644 --- a/src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js +++ b/src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js @@ -578,7 +578,7 @@ function createMarkerLayer(target, defaultDirection, { const shape = chart.getShapeById(markerId); const point = readMarkerPoint(shape); if (point.time !== signal.time) { - throw new Error(`TradingView Bollinger alert time alignment failed for ${signal.time}`); + throw new Error(`TradingView Bollinger alert time alignment failed: expected ${signal.time}, received ${point.time}`); } if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; mutate(() => shape.setProperties(options.overrides, false)); diff --git a/src/binance-strategy29-bollinger/index.user.js b/src/binance-strategy29-bollinger/index.user.js index b12ed7c..55b89d2 100644 --- a/src/binance-strategy29-bollinger/index.user.js +++ b/src/binance-strategy29-bollinger/index.user.js @@ -3,34 +3,21 @@ // @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.3.0 +// @version 0.4.0 // @author jackhai9 // @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 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'; +import { publishStrategy29Preferences } from '../shared/strategy29-preferences-migration.js'; -// 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, -}); +installStrategy29(unsafeWindow); +publishStrategy29Preferences(unsafeWindow, GM_getValue); diff --git a/src/binance-strategy29-bollinger/remote-summary.js b/src/binance-strategy29-bollinger/remote-summary.js index 8bce0f8..f7bf287 100644 --- a/src/binance-strategy29-bollinger/remote-summary.js +++ b/src/binance-strategy29-bollinger/remote-summary.js @@ -15,9 +15,6 @@ import { SUMMARY_COPY as COPY, formatLocalizedText, resolveUiLocaleFromPathname export const STRATEGY29_PANEL_POSITION_KEY = 'strategy29SummaryPanelPosition'; 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) { @@ -25,10 +22,10 @@ function abortError(view, message) { return new ErrorConstructor(message, 'AbortError'); } -function assertAdapters({ view, request, getValue, setValue, registerMenuCommand, promptUser, createPanel, createClient }) { +function assertAdapters({ view, request, getValue, setValue, registerMenuCommand, getGatewaySettings, 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, + request, getValue, setValue, registerMenuCommand, getGatewaySettings, createPanel, createClient, })) { if (typeof value !== 'function') throw new TypeError(`Strategy 29 remote summary ${name} is invalid`); } @@ -41,12 +38,12 @@ export function createStrategy29RemoteSummary({ getValue, setValue, registerMenuCommand, - promptUser, + getGatewaySettings, createPanel = createStrategy29SummaryPanel, createClient = createStrategy29SummaryClient, pollIntervalMs = STRATEGY29_REMOTE_POLL_INTERVAL_MS, }) { - assertAdapters({ view, request, getValue, setValue, registerMenuCommand, promptUser, createPanel, createClient }); + assertAdapters({ view, request, getValue, setValue, registerMenuCommand, getGatewaySettings, 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; @@ -68,12 +65,9 @@ export function createStrategy29RemoteSummary({ } function configuredSettings() { - const authSecret = getValue(STRATEGY29_GATEWAY_SECRET_KEY, ''); + const { authSecret, gatewayOrigin } = getGatewaySettings(); 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 }; + return { authSecret, gatewayOrigin: normalizeStrategy29GatewayOrigin(gatewayOrigin) }; } function startContext(routeSymbol) { @@ -99,6 +93,11 @@ export function createStrategy29RemoteSummary({ lastResult: null, }; active = context; + if (!enabled) { + context.state = 'disabled'; + panel.setConnection('disabled', COPY.disabled); + return context; + } let settings; try { settings = configuredSettings(); @@ -145,7 +144,7 @@ export function createStrategy29RemoteSummary({ } function synchronizeContext() { - if (!enabled || !view.document.body) { + if (!view.document.body) { unsupportedRoute = null; stopActive('Strategy 29 remote summary disabled'); return null; @@ -194,6 +193,8 @@ export function createStrategy29RemoteSummary({ const presentation = { connected: ['connected', result.hasMore ? COPY.moreHistory : COPY.connected], unavailable: ['unavailable', COPY.unavailable], + module_disabled: ['module_disabled', COPY.moduleDisabled], + gateway_unavailable: ['gateway_unavailable', COPY.gatewayUnavailable], incompatible: ['incompatible', COPY.incompatible], }[result.state]; if (!presentation) throw new Error(`Strategy 29 remote state is invalid: ${result.state}`); @@ -216,6 +217,7 @@ export function createStrategy29RemoteSummary({ } function restart() { + enabled = getValue(STRATEGY29_REMOTE_ENABLED_KEY, false) === true; unsupportedRoute = null; stopActive('Strategy 29 remote settings changed'); if (!disposed) void sample(Date.now()); @@ -227,20 +229,6 @@ export function createStrategy29RemoteSummary({ setValue(STRATEGY29_REMOTE_ENABLED_KEY, enabled); restart(); } }, - { copy: COPY.menuSecret, run() { - const value = promptUser(formatLocalizedText(COPY.promptSecret, resolveUiLocaleFromPathname(view.location.pathname))); - if (value === null) return; - if (value.length === 0) throw new Error(text(COPY.emptySecret)); - setValue(STRATEGY29_GATEWAY_SECRET_KEY, value); - restart(); - } }, - { copy: COPY.menuOrigin, run() { - const current = getValue(STRATEGY29_GATEWAY_ORIGIN_KEY, STRATEGY29_DEFAULT_GATEWAY_ORIGIN); - const value = promptUser(formatLocalizedText(COPY.promptOrigin, resolveUiLocaleFromPathname(view.location.pathname)), current); - if (value === null) return; - setValue(STRATEGY29_GATEWAY_ORIGIN_KEY, normalizeStrategy29GatewayOrigin(value)); - restart(); - } }, ]; for (const menu of menus) menu.id = registerMenuCommand(text(menu.copy), menu.run); function synchronizeLocale() { diff --git a/src/binance-strategy29-bollinger/runtime.js b/src/binance-strategy29-bollinger/runtime.js index dd202af..9d56cd3 100644 --- a/src/binance-strategy29-bollinger/runtime.js +++ b/src/binance-strategy29-bollinger/runtime.js @@ -3,11 +3,12 @@ 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'; +import { SIGNAL_HOST_READY, SIGNAL_HOST_READY_EVENT } from '../shared/strategy29-preferences-migration.js'; import { SUMMARY_COPY as COPY, formatLocalizedText, resolveUiLocaleFromPathname } from './ui-copy.js'; const INSTANCE = Symbol.for('jh-userscripts.strategy29-bollinger'); -const RUNTIME_VERSION = 2; +const RUNTIME_VERSION = 3; const CONFLICT = COPY.conflict; /** This is a migration refusal, not compatibility with the old independently owned save wrapper. */ @@ -31,6 +32,23 @@ export function installStrategy29(view, remoteAdapters = null) { ? null : createStrategy29RemoteSummary({ view, ...remoteAdapters }); const noticeId = 'jh-strategy29-bollinger-status'; + const upgradeNoticeId = 'jh-strategy29-client-upgrade'; + function showUpgradeNotice() { + if (remoteSummary !== null || disposed || view[SIGNAL_HOST_READY] === true || !isFuturesTradingPathname(view.location.pathname)) { + document.getElementById(upgradeNoticeId)?.remove(); + return; + } + if (!document.body) return; + let notice = document.getElementById(upgradeNoticeId); + if (!notice) { + notice = document.createElement('div'); + notice.id = upgradeNoticeId; + notice.setAttribute('role', 'status'); + notice.style.cssText = 'position:fixed;left:16px;top:16px;z-index:10000;max-width:420px;padding:10px;background:#332b16;color:#ffcf67;font:13px sans-serif;pointer-events:none'; + document.body.append(notice); + } + notice.textContent = formatLocalizedText(COPY.upgradeClient, resolveUiLocaleFromPathname(view.location.pathname)); + } function showFailure() { if (!failed || !document.body) return; let notice = document.getElementById(noticeId); @@ -65,6 +83,7 @@ export function installStrategy29(view, remoteAdapters = null) { } function sample() { if (disposed || document.hidden) return; + showUpgradeNotice(); if (failed) { showFailure(); return; } if (hasEmbeddedBollinger(view)) { fail(CONFLICT); return; } ensureSpaRouteChangePatched(view); @@ -99,9 +118,12 @@ export function installStrategy29(view, remoteAdapters = null) { removeRouteListener(); document.removeEventListener('visibilitychange', onVisibility); document.removeEventListener('DOMContentLoaded', showFailure); + document.removeEventListener('DOMContentLoaded', showUpgradeNotice); + view.removeEventListener(SIGNAL_HOST_READY_EVENT, showUpgradeNotice); view.removeEventListener('pagehide', onPageHide); view.removeEventListener('pageshow', onPageShow); document.getElementById(noticeId)?.remove(); + document.getElementById(upgradeNoticeId)?.remove(); }, }); Object.defineProperty(view, INSTANCE, { value: Object.freeze({ version: RUNTIME_VERSION, runtime }) }); @@ -109,6 +131,8 @@ export function installStrategy29(view, remoteAdapters = null) { removeRouteListener = installSpaRouteChangeListener(view, sample); document.addEventListener('visibilitychange', onVisibility); document.addEventListener('DOMContentLoaded', showFailure, { once: true }); + document.addEventListener('DOMContentLoaded', showUpgradeNotice, { once: true }); + view.addEventListener(SIGNAL_HOST_READY_EVENT, showUpgradeNotice); view.addEventListener('pagehide', onPageHide); view.addEventListener('pageshow', onPageShow); resume(); diff --git a/src/binance-strategy29-bollinger/ui-copy.js b/src/binance-strategy29-bollinger/ui-copy.js index fd04f0f..5e23332 100644 --- a/src/binance-strategy29-bollinger/ui-copy.js +++ b/src/binance-strategy29-bollinger/ui-copy.js @@ -3,6 +3,11 @@ import { localizedText, formatLocalizedText, resolveUiLocaleFromPathname } from export { formatLocalizedText, resolveUiLocaleFromPathname }; const pair = localizedText; export const SUMMARY_COPY = Object.freeze({ + upgradeClient: pair('Strategy 29 本地信号已加载。跨周期汇总需要更新或安装 Strategy 27 信号客户端,并刷新页面。', 'Strategy 29 local signals are loaded. Update or install the Strategy 27 signal client and reload for the cross-timeframe summary.'), + disabled: pair('跨周期汇总未启用,可在 CorsairQuant 信号客户端菜单中开启。', 'Cross-timeframe summary is disabled. Enable it in the CorsairQuant signal client menu.'), + moduleDisabled: pair('服务端尚未启用 Strategy 29 监控汇总', 'Strategy 29 monitoring summary is not enabled on the server'), + gatewayUnavailable: pair('Strategy 29 后端暂不可用,等待恢复', 'Strategy 29 backend is unavailable; waiting for recovery'), + noLiveStatus: pair('当前监控状态不可用;下方仅保留历史信号。', 'Current monitoring status is unavailable; only retained signals are shown below.'), title: pair('Strategy 29 汇总', 'Strategy 29 Summary'), drag: pair('拖动面板', 'Drag panel'), collapse: pair('收起', 'Collapse'), @@ -44,11 +49,6 @@ export const SUMMARY_COPY = Object.freeze({ disconnected: pair('网关连接失败,将在下次定时检查时重试', 'Gateway connection failed; next scheduled poll will retry'), stopped: detail => pair(`远程汇总已停止。技术详情:${detail}`, `Remote summary stopped: ${detail}`), menuToggle: pair('切换 Strategy 29 跨周期汇总', 'Toggle Strategy 29 cross-timeframe summary'), - menuSecret: pair('设置 Strategy 29 网关密钥', 'Set Strategy 29 gateway secret'), - menuOrigin: pair('设置 Strategy 29 网关地址', 'Set Strategy 29 gateway origin'), - promptSecret: pair('请输入本地 Strategy 29 网关密钥,仅保存在此用户脚本的私有存储中。', 'Enter the local Strategy 29 gateway secret. It is stored only in this userscript storage.'), - promptOrigin: pair('请输入本机网关地址(http://127.0.0.1:)', 'Enter the loopback gateway origin (http://127.0.0.1:)'), - emptySecret: pair('Strategy 29 网关密钥不能为空', 'Strategy 29 gateway secret cannot be empty'), localStopped: detail => pair(`Strategy 29 已停止。技术详情:${detail}`, `Strategy 29 stopped: ${detail}`), conflict: pair('Strategy 29 已停止:请将订单簿脚本更新至 2.7.199 或更高版本,或禁用内嵌布林带观察器的旧版本,然后刷新页面。', 'Strategy 29 stopped: update Orderbook to 2.7.199 or disable its embedded Bollinger version, then reload this page.'), }); diff --git a/src/shared/signal-client-settings.js b/src/shared/signal-client-settings.js new file mode 100644 index 0000000..b656bb1 --- /dev/null +++ b/src/shared/signal-client-settings.js @@ -0,0 +1,15 @@ +import { normalizeGatewayBaseUrl } from '../binance-strategy27-events/core/live-event-client.js'; + +/** Keep the existing installation keys so an in-place update retains private credentials. */ +export const SIGNAL_GATEWAY_ORIGIN_KEY = 'strategy27GatewayOrigin'; +export const SIGNAL_GATEWAY_SECRET_KEY = 'strategy27GatewayAuthSecret'; +export const SIGNAL_GATEWAY_ORIGIN = 'http://127.0.0.1:18765'; + +export function readSignalGatewaySettings(getValue) { + const authSecret = getValue(SIGNAL_GATEWAY_SECRET_KEY, ''); + if (typeof authSecret !== 'string') throw new TypeError('Signal gateway secret storage is invalid'); + return { + authSecret, + gatewayOrigin: normalizeGatewayBaseUrl(getValue(SIGNAL_GATEWAY_ORIGIN_KEY, SIGNAL_GATEWAY_ORIGIN)), + }; +} diff --git a/src/shared/strategy29-preferences-migration.js b/src/shared/strategy29-preferences-migration.js new file mode 100644 index 0000000..f0ce3c8 --- /dev/null +++ b/src/shared/strategy29-preferences-migration.js @@ -0,0 +1,47 @@ +const RECORD = Symbol.for('jh-userscripts.strategy29-preferences-migration'); +export const STRATEGY29_PREFERENCES_EVENT = 'jh-strategy29-preferences-ready'; +export const SIGNAL_HOST_READY = Symbol.for('jh-userscripts.strategy29-signal-host-ready'); +export const SIGNAL_HOST_READY_EVENT = 'jh-strategy29-signal-host-ready'; +const COMPLETE = 'strategy29UnifiedPreferencesMigrated'; +const ENABLED = 'strategy29RemoteSummaryEnabled'; +const POSITION = 'strategy29SummaryPanelPosition'; + +function validate(record) { + if (!record || Object.keys(record).sort().join(',') !== 'enabled,position,version' || record.version !== 1 || typeof record.enabled !== 'boolean') { + throw new TypeError('Strategy29 preference migration record is invalid'); + } + const point = record.position; + if (point !== null && (Object.keys(point).sort().join(',') !== 'left,top' || !Number.isFinite(point.left) || !Number.isFinite(point.top))) { + throw new TypeError('Strategy29 preference migration position is invalid'); + } + return { version: 1, enabled: record.enabled, position: point === null ? null : { left: point.left, top: point.top } }; +} + +/** This public bridge carries cosmetic/module preferences only, never authentication or event data. */ +export function publishStrategy29Preferences(view, getValue) { + if (view[RECORD] !== undefined) { + validate(view[RECORD]); + return; + } + const record = validate({ version: 1, enabled: getValue(ENABLED, false), position: getValue(POSITION, null) }); + if (record.position !== null) Object.freeze(record.position); + Object.defineProperty(view, RECORD, { value: Object.freeze(record) }); + view.dispatchEvent(new view.Event(STRATEGY29_PREFERENCES_EVENT)); +} + +/** Private destination values win; untrusted page preferences cannot supply a gateway or secret. */ +export function migrateStrategy29Preferences(view, getValue, setValue) { + if (!isStrategy29CompanionReady(view) || getValue(COMPLETE, false) === true) return false; + const record = validate(view[RECORD]); + if (getValue(ENABLED, null) === null) setValue(ENABLED, record.enabled); + if (getValue(POSITION, null) === null && record.position !== null) setValue(POSITION, record.position); + setValue(COMPLETE, true); + return true; +} + +/** Readiness is page-scoped; completing a previous migration does not establish a remote owner. */ +export function isStrategy29CompanionReady(view) { + if (view[RECORD] === undefined) return false; + validate(view[RECORD]); + return true; +} diff --git a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js index 9aebf22..acf8ce0 100644 --- a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js +++ b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js @@ -15,10 +15,11 @@ async function until(predicate) { } } -async function harness(t, { generated = false, beforeCreate, locale = 'zh-CN' } = {}) { +async function harness(t, { generated = false, beforeCreate, locale = 'zh-CN', migrationRecord } = {}) { const dom = loadFixtureDom('
'); dom.reconfigure({ url: `https://www.binance.com/${locale}/futures/BTCUSDT` }); const page = dom.window; + if (migrationRecord !== undefined) Object.defineProperty(page, Symbol.for('jh-userscripts.strategy29-preferences-migration'), { value: migrationRecord }); const shapes = new Map([['user-owned', {}]]); let resolution = '1S'; let shapeSequence = 0; @@ -48,12 +49,14 @@ async function harness(t, { generated = false, beforeCreate, locale = 'zh-CN' } const menus = new Map(); const menuIds = new Map(); const requests = []; + const prompts = []; let now = 7000; t.mock.method(Date, 'now', () => now); t.mock.method(page, 'setInterval', (callback, delay) => { assert.equal(delay, 1000); timers.set(1, callback); return 1; }); t.mock.method(page, 'clearInterval', (id) => timers.delete(id)); const globals = { unsafeWindow: page, + prompt: (...args) => { prompts.push(args[0]); return null; }, GM_getValue: (key, initial) => key === 'strategy27GatewayAuthSecret' ? 'synthetic-test-value' : initial, GM_setValue: () => { throw new Error('Unexpected settings write'); }, GM_registerMenuCommand: (name, callback, options = {}) => { @@ -103,7 +106,7 @@ async function harness(t, { generated = false, beforeCreate, locale = 'zh-CN' } await until(() => pending('compound').length === 1); } return { - page, chart, shapes, requests, pending, respond, candidate, timers, menus, + page, chart, shapes, requests, pending, respond, candidate, timers, menus, prompts, reset: () => respond('compound', { schema_version: 1, status: 'bootstrap', projection_kind: 'compound_candidates', requested_cursor: null, next_cursor: '1-0', runtime_epoch: 'a'.repeat(32), last_sequence: 1, bootstrap_observed_at_ms: 7000, records: [] }), ordinaryBootstrap: () => respond('ordinary', { schema_version: 1, status: 'bootstrap', projection_kind: 'strategy27_events', requested_cursor: null, next_cursor: '1-0', runtime_epoch: 'a'.repeat(32), last_sequence: 1, bootstrap_observed_at_ms: 7000, records: [] }), rows: () => page.document.querySelectorAll('[data-role="compound-row"]').length, @@ -669,12 +672,24 @@ for (const generated of [false, true]) { } for (const generated of [false, true]) { + test(`${generated ? 'generated' : 'source'} rejects malformed preference migration without stopping Strategy27`, async (t) => { + const h = await harness(t, { generated, migrationRecord: { version: 1, enabled: true, position: null, secret: 'synthetic-rejected-value' } }); + assert.equal(h.page.__TM_SIGNAL_CLIENT_DEBUG__.strategy29.state, 'initialization_failed'); + assert.match(h.page.__TM_SIGNAL_CLIENT_DEBUG__.strategy29.moduleFailure, /migration record is invalid/); + assert.equal(h.pending('ordinary').length, 1); + assert.equal(h.pending('compound').length, 1); + await h.ordinaryBootstrap(); + await until(() => h.pending('ordinary').length === 1); + assert.equal(h.pending('ordinary').length, 1); + assert.equal(h.page.document.querySelectorAll('#jh-strategy29-summary-panel').length, 0); + }); + test(`${generated ? 'generated' : 'source'} locale switch updates stopped status and prompts without reconnecting`, async (t) => { const h = await harness(t, { generated, locale: 'en' }); - const prompts = []; - t.mock.method(h.page, 'prompt', (text) => { prompts.push(text); return null; }); - h.menus.get('Set Strategy 27 gateway secret')(); - h.menus.get('Set Strategy 27 local gateway URL')(); + const prompts = h.prompts; + t.mock.method(h.page, 'prompt', () => { throw new Error('Page prompt must not receive private input'); }); + h.menus.get('Set CorsairQuant gateway secret')(); + h.menus.get('Set CorsairQuant local gateway URL')(); assert.equal(prompts.length, 2); assert.doesNotMatch(prompts.join(' '), /\p{Script=Han}/u); await h.respond('ordinary', 'invalid JSON'); @@ -686,9 +701,9 @@ for (const generated of [false, true]) { assert.equal(h.requests.length, count); assert.match(status.textContent, /已停止,历史记录已保留/); assert.equal(h.page.document.querySelector('[data-role="ordinary-connection-status"]').textContent, '事件数据:已停止'); - h.menus.get('设置 Strategy 27 网关密钥')(); - h.menus.get('设置 Strategy 27 本机网关地址')(); - assert.match(prompts[2], /输入本机/); + h.menus.get('设置 CorsairQuant 网关密钥')(); + h.menus.get('设置 CorsairQuant 本机网关地址')(); + assert.match(prompts[2], /输入 CorsairQuant/); assert.match(prompts[3], /输入 SSH/); assert.equal(h.menus.size, 4); }); diff --git a/test/dom/binance-strategy29-bollinger/runtime.test.js b/test/dom/binance-strategy29-bollinger/runtime.test.js index be9e370..b329074 100644 --- a/test/dom/binance-strategy29-bollinger/runtime.test.js +++ b/test/dom/binance-strategy29-bollinger/runtime.test.js @@ -52,7 +52,7 @@ test('remote transport failure never stops the local observer timer', async () = getValue: (key, fallback) => values.has(key) ? values.get(key) : fallback, setValue: (key, value) => values.set(key, value), registerMenuCommand() {}, - promptUser() { return null; }, + getGatewaySettings() { return { authSecret: 'synthetic-secret', gatewayOrigin: 'http://127.0.0.1:18765' }; }, }); await new Promise(resolve => f.view.setTimeout(resolve, 0)); assert.equal(runtime.diagnostics.runtimeFailure, null); @@ -81,7 +81,7 @@ for (const [name, remoteResponse, expectedState] of [ getValue: (key, fallback) => values.has(key) ? values.get(key) : fallback, setValue: (key, value) => values.set(key, value), registerMenuCommand() {}, - promptUser() { return null; }, + getGatewaySettings() { return { authSecret: 'synthetic-secret', gatewayOrigin: 'http://127.0.0.1:18765' }; }, }); await new Promise(resolve => f.view.setTimeout(resolve, 0)); assert.equal(runtime.diagnostics.runtimeFailure, null); @@ -107,7 +107,7 @@ test('hiding the page aborts the remote request and resumes with one shared runt getValue: (key, fallback) => values.has(key) ? values.get(key) : fallback, setValue: (key, value) => values.set(key, value), registerMenuCommand() {}, - promptUser() { return null; }, + getGatewaySettings() { return { authSecret: 'synthetic-secret', gatewayOrigin: 'http://127.0.0.1:18765' }; }, }); assert.equal(runtime.diagnostics.remoteSummary.inFlight, true); f.hide(true); @@ -145,7 +145,7 @@ test('actual remote client retains rows and cursor across visibility and bootstr }, getValue: (key, fallback) => values.has(key) ? values.get(key) : fallback, setValue: (key, value) => values.set(key, value), - registerMenuCommand() {}, promptUser() { return null; }, + registerMenuCommand() {}, getGatewaySettings() { return { authSecret: 'synthetic-secret', gatewayOrigin: 'http://127.0.0.1:18765' }; }, }); const settle = () => new Promise(resolve => f.view.setTimeout(resolve, 0)); await settle(); @@ -195,7 +195,7 @@ for (const failure of ['embedded conflict', 'interval subscription failure']) { }, getValue: (key, fallback) => values.has(key) ? values.get(key) : fallback, setValue: (key, value) => values.set(key, value), - registerMenuCommand() {}, promptUser() { return null; }, + registerMenuCommand() {}, getGatewaySettings() { return { authSecret: 'synthetic-secret', gatewayOrigin: 'http://127.0.0.1:18765' }; }, }); const settle = () => new Promise(resolve => f.view.setTimeout(resolve, 0)); try { diff --git a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js index 682a41d..f19cd5c 100644 --- a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js +++ b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js @@ -13,6 +13,28 @@ function createStrategy29SummaryPanel(document, symbol, options = {}) { 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))); +for (const state of ['module_disabled', 'gateway_unavailable', 'unavailable']) { + test(`${state} removes current readiness while preserving historical signals across locale changes`, () => { + const dom = new JSDOM(''); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT'); + panel.renderStatus(status); + panel.addEvents(events.events, events.observed_at_ms); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 2); + panel.setConnection(state, { zhCN: '暂不可用', en: 'Unavailable' }); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 0); + assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 2); + assert.doesNotMatch(dom.window.document.querySelector('[data-role=selection]').textContent, /live units ready/); + panel.setLocale('zh-CN'); + assert.match(dom.window.document.querySelector('[data-role=selection]').textContent, /当前监控状态不可用/); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 0); + panel.renderStatus(status); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 2); + assert.equal(panel.size, 2); + panel.destroy(); + dom.window.close(); + }); +} + test('distinguishes stored processing success from current live readiness', () => { const dom = new JSDOM(''); const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT'); diff --git a/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js b/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js index f55bdad..0d61bb6 100644 --- a/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js +++ b/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js @@ -687,6 +687,27 @@ test('on-demand diagnostics distinguish active, awaiting-data and torn-down stat harness.monitor.stop(); }); +test('initial one-second alignment failure retains requested and native times after cleanup', async () => { + const fixture = createChartDom({ resolution: '1S', shiftSeconds: -60 }); + const harness = createMonitorHarness(fixture); + fixture.chart.exportData = async () => exportResult([{ 0: 120, 1: 10, 2: 12, 3: 9, 4: 11 }]); + const message = 'TradingView Bollinger alert time alignment failed: expected 120, received 60'; + await assert.rejects(harness.tick(), { message }); + await harness.tick(); + assert.equal(harness.monitor.diagnostics.failed, true); + assert.equal(harness.monitor.diagnostics.layerSize, 0); + assert.equal(fixture.shapes.size, 0); + assert.deepEqual(harness.monitor.diagnostics.lastLocalFailure, { + thrownType: 'object', classificationFailed: false, name: 'Error', message, + unreadableFields: [], stage: 'render', routeSymbol: 'BTRUSDT', resolution: '1S', + cachedSignalCount: null, layerSizeBeforeCleanup: 0, + sessionRevision: 0, contextIntervalRevision: 0, + }); + harness.monitor.stop(); + assert.equal(harness.monitor.diagnostics.lastLocalFailure.message, message); + fixture.dom.window.close(); +}); + for (const stage of ['export', 'render']) { test(`retains the last local ${stage} failure after clearing the layer and stopping`, async () => { const fixture = createChartDom(); @@ -1598,7 +1619,7 @@ test('removes a shifted marker and fails the alignment contract', async () => { [{ id: 'setup:warning', type: 'warning', time: 120, markerPrice: 10 }], { isCurrent: () => true }, ), - /time alignment failed/, + /time alignment failed: expected 120, received 60/, ); assert.equal(shapes.size, 0); assert.deepEqual(removed, ['shape-1']); diff --git a/test/fixtures/strategy29-migration/README.md b/test/fixtures/strategy29-migration/README.md new file mode 100644 index 0000000..56c5bf1 --- /dev/null +++ b/test/fixtures/strategy29-migration/README.md @@ -0,0 +1,10 @@ +# Released migration fixture + +`legacy-0.3.0.user.js` is the exact published Strategy29 0.3.0 artifact from +userscripts PR #277. SHA-256: +`e596ee8fb971cda4307205349182eb05b44b461c9193c2b8693ef4fc71009e2a`. + +It is immutable test input, not an install entry or editable implementation. +The generated-client tests execute this historical remote owner to reproduce +independently scheduled Tampermonkey upgrades and verify that the unified client +does not replace its panel or start a second remote consumer. diff --git a/test/fixtures/strategy29-migration/legacy-0.3.0.user.js b/test/fixtures/strategy29-migration/legacy-0.3.0.user.js new file mode 100644 index 0000000..918eefc --- /dev/null +++ b/test/fixtures/strategy29-migration/legacy-0.3.0.user.js @@ -0,0 +1,2779 @@ +// ==UserScript== +// @name 【自写】Binance Strategy 29 布林带信号 +// @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.3.0 +// @author jackhai9 +// @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 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_v2"; + var STRATEGY29_REFERENCE_SHA256 = "eece8cf16e58340910587962f3bfbb19acb72155c09a52b4b6c0570cc979ef8d"; + var TIMEFRAMES = /* @__PURE__ */ new Set(["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "1w"]); + var UNIT_STATUSES = /* @__PURE__ */ new Set(["warming", "ready", "stale", "insufficient_history", "data_gap", "failed"]); + 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", "universe", "units", "delivery_counts"]; + var UNIVERSE_KEYS = [ + "source_monitor", + "generation", + "refresh_status", + "reason", + "selected_markets", + "configured_timeframes", + "selected_unit_count", + "ready_unit_count", + "pending_unit_count", + "refreshed_at_ms", + "last_successful_refreshed_at_ms", + "last_success_age_seconds", + "last_refresh_error_at_ms", + "selection_expires_at_ms" + ]; + var UNIVERSE_STATES = /* @__PURE__ */ new Set(["fresh", "stale_if_error", "fail_closed"]); + var UNIVERSE_REASONS = { + fresh: /* @__PURE__ */ new Set(["current"]), + stale_if_error: /* @__PURE__ */ new Set(["using_stale_selection_after_refresh_error"]), + fail_closed: /* @__PURE__ */ new Set([ + "selection_fail_closed", + "selection_expired_or_unusable", + "missing_current_universe_facts", + "incompatible_current_universe_facts" + ]) + }; + var UNIT_KEYS = [ + "symbol", + "timeframe", + "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 validateUniverse(value) { + assertExactKeys(value, UNIVERSE_KEYS, "status.universe"); + if (value.source_monitor !== "monitor29_bollinger_ma60") throw new TypeError("status.universe.source_monitor is invalid"); + assertInteger(value.generation, "status.universe.generation", { nullable: true, minimum: 1 }); + assertEnum(value.refresh_status, UNIVERSE_STATES, "status.universe.refresh_status"); + assertEnum(value.reason, UNIVERSE_REASONS[value.refresh_status], "status.universe.reason"); + for (const key of ["selected_markets", "configured_timeframes"]) { + if (!Array.isArray(value[key]) || value[key].length > 128 || new Set(value[key]).size !== value[key].length) { + throw new TypeError(`status.universe.${key} must be a bounded unique array`); + } + } + value.selected_markets.forEach((symbol) => assertCanonicalSymbol(symbol, "status.universe.selected_markets")); + value.configured_timeframes.forEach((timeframe) => assertEnum(timeframe, TIMEFRAMES, "status.universe.configured_timeframes")); + for (const key of ["selected_unit_count", "ready_unit_count", "pending_unit_count"]) { + assertInteger(value[key], `status.universe.${key}`); + if (value[key] > 128) throw new TypeError(`status.universe.${key} exceeds the unit bound`); + } + if (value.ready_unit_count + value.pending_unit_count !== value.selected_unit_count || value.selected_markets.length > value.selected_unit_count) throw new TypeError("status.universe counts are inconsistent"); + if (value.refresh_status === "fail_closed" && value.selected_unit_count !== 0) throw new TypeError("status.universe unavailable selection must be empty"); + for (const key of ["refreshed_at_ms", "last_successful_refreshed_at_ms", "last_refresh_error_at_ms", "selection_expires_at_ms"]) { + assertInteger(value[key], `status.universe.${key}`, { nullable: true }); + } + if (value.last_success_age_seconds !== null) { + assertFiniteNumber(value.last_success_age_seconds, "status.universe.last_success_age_seconds"); + if (value.last_success_age_seconds < 0) throw new TypeError("status.universe last success age must be non-negative"); + } + if (value.last_successful_refreshed_at_ms === null !== (value.last_success_age_seconds === null)) { + throw new TypeError("status.universe last success fields are inconsistent"); + } + const successMissing = value.last_successful_refreshed_at_ms === null; + if (successMissing !== (value.selection_expires_at_ms === null)) { + throw new TypeError("status.universe successful selection requires its expiry"); + } + const absentFacts = value.reason === "missing_current_universe_facts" || value.reason === "incompatible_current_universe_facts"; + if (absentFacts) { + if ([ + value.generation, + value.refreshed_at_ms, + value.last_successful_refreshed_at_ms, + value.last_success_age_seconds, + value.last_refresh_error_at_ms, + value.selection_expires_at_ms + ].some((item) => item !== null)) { + throw new TypeError("status.universe unavailable facts must have null refresh metadata"); + } + } else { + if (value.generation === null || value.refreshed_at_ms === null) { + throw new TypeError("status.universe current facts require generation and refresh time"); + } + if (value.reason !== "selection_fail_closed" && successMissing) { + throw new TypeError("status.universe successful selection metadata is required"); + } + if (value.refresh_status === "fresh" && value.last_refresh_error_at_ms !== null) { + throw new TypeError("status.universe fresh selection cannot report a refresh error"); + } + if ((value.refresh_status === "stale_if_error" || value.reason === "selection_fail_closed") && value.last_refresh_error_at_ms === null) { + throw new TypeError("status.universe failed refresh requires its error time"); + } + } + } + function validateStrategy29StatusResponse(value, httpStatus) { + if (httpStatus !== 200) throw new TypeError(`status response requires HTTP 200, received ${httpStatus}`); + assertObject(value, "status response"); + assertSchema(value.schema_version, "status.schema_version"); + assertString(value.spec_version, "status.spec_version"); + assertInteger(value.observed_at_ms, "status.observed_at_ms"); + if (value.spec_version !== STRATEGY29_SPEC_VERSION) return { + schema_version: value.schema_version, + spec_version: value.spec_version, + observed_at_ms: value.observed_at_ms + }; + assertExactKeys(value, STATUS_KEYS, "status response"); + validateUniverse(value.universe); + if (!Array.isArray(value.units)) throw new TypeError("status.units must be an array"); + if (value.units.length > 128) throw new TypeError("status.units exceeds the 128-unit bound"); + 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("mode", "latest"); + url.searchParams.set("limit", "20"); + } else 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); + if (signal.aborted) throw signal.reason; + 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); + if (signal.aborted) throw signal.reason; + const eventsBody = parseJsonResponse(eventsResponse, "Strategy29 events"); + pages += 1; + if (eventsResponse.status === 409) { + const error = validateStrategy29GatewayError(eventsBody, 409); + cursor = null; + onCursorReset(error.oldest_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.has_more || page.events.length > 20)) { + throw new TypeError("Strategy29 latest snapshot must be complete and bounded to 20 events"); + } + 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, + bollingerStdDev: 2, + maPeriod: 60, + preCrossBars: 8, + minPreCrossChannelCloses: 4, + maxPreCrossAboveMiddleCloses: 1, + maxPreCrossBelowLowerCloses: 3, + trendLookbackBars: 3, + minMiddleDeclineBandFraction: 0.01, + postCrossBars: 20, + middleApproachBandFraction: 0.12, + maxPostCrossCloseAboveMiddleBandFraction: 0.05, + lowerTouchBandFraction: 0.05, + reversalFollowBars: 60 + }); + var TradingViewBarSnapshotInconsistentError = class extends Error { + constructor(message) { + super(message); + this.name = "TradingViewBarSnapshotInconsistentError"; + } + }; + function isTradingViewBarSnapshotInconsistentError(error) { + return error instanceof TradingViewBarSnapshotInconsistentError; + } + function applyBollingerAlertTaskFailure(context, error) { + if (!context || typeof context !== "object" || typeof context.failed !== "boolean" || typeof context.cleanupPending !== "boolean") { + throw new Error("Bollinger alert task context is invalid"); + } + if (isTradingViewBarSnapshotInconsistentError(error)) return "retry"; + context.failed = true; + context.cleanupPending = true; + return "fatal"; + } + function assertFiniteNumber2(value, label) { + if (!Number.isFinite(value)) throw new Error(`${label} is invalid`); + } + function assertBars(bars, directionLabel) { + if (!Array.isArray(bars)) throw new Error(`${directionLabel} Bollinger bars must be an array`); + let previousTime = -Infinity; + for (const [index, bar] of bars.entries()) { + if (!bar || typeof bar !== "object") { + throw new Error(`${directionLabel} Bollinger bar ${index} is invalid`); + } + if (!Number.isInteger(bar.time)) { + throw new Error(`${directionLabel} Bollinger bar time ${index} is invalid`); + } + if (bar.time <= previousTime) { + throw new TradingViewBarSnapshotInconsistentError( + `${directionLabel} Bollinger bar time ${index} is invalid` + ); + } + for (const field of ["open", "high", "low", "close"]) { + 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( + `${directionLabel} Bollinger bar ${index} OHLC range is invalid` + ); + } + previousTime = bar.time; + } + } + function assertIndicatorBars(indicatorBars, directionLabel) { + if (!Array.isArray(indicatorBars)) { + throw new Error(`${directionLabel} Bollinger indicator bars must be an array`); + } + assertBars(indicatorBars, directionLabel); + for (const [index, bar] of indicatorBars.entries()) { + const fields = ["middle", "upper", "lower", "ma60"]; + const nullFields = fields.filter((field) => bar[field] === null); + if (nullFields.length !== 0 && nullFields.length !== fields.length) { + throw new Error(`${directionLabel} Bollinger indicator bar ${index} is incomplete`); + } + for (const field of fields) { + if (bar[field] !== null) { + assertFiniteNumber2( + bar[field], + `${directionLabel} Bollinger indicator bar ${index} ${field}` + ); + } + } + } + } + function calculateBollingerIndicatorBars(bars, directionLabel) { + assertBars(bars, directionLabel); + const config = BOLLINGER_PATTERN; + return bars.map((bar, index) => { + if (index < config.maPeriod - 1) { + return { ...bar, middle: null, upper: null, lower: null, ma60: null }; + } + const start = index - config.bollingerPeriod + 1; + let closeSum = 0; + for (let cursor = start; cursor <= index; cursor += 1) closeSum += bars[cursor].close; + const middle = closeSum / config.bollingerPeriod; + let squaredDeviationSum = 0; + for (let cursor = start; cursor <= index; cursor += 1) { + squaredDeviationSum += (bars[cursor].close - middle) ** 2; + } + const deviation = Math.sqrt(squaredDeviationSum / config.bollingerPeriod) * config.bollingerStdDev; + let maSum = 0; + for (let cursor = index - config.maPeriod + 1; cursor <= index; cursor += 1) maSum += bars[cursor].close; + return { + ...bar, + middle, + upper: middle + deviation, + lower: middle - deviation, + ma60: maSum / config.maPeriod + }; + }); + } + function bandWidth(bar) { + const width = bar.upper - bar.lower; + if (!(width > 0)) throw new Error(`Bollinger band width is invalid at ${bar.time}`); + return width; + } + function hasDownwardBandCenter(indicatorBars, index) { + const { trendLookbackBars, minMiddleDeclineBandFraction } = BOLLINGER_PATTERN; + const current = indicatorBars[index]; + const earlier = indicatorBars[index - trendLookbackBars]; + const averageWidth = (bandWidth(current) + bandWidth(earlier)) / 2; + return (earlier.middle - current.middle) / averageWidth >= minMiddleDeclineBandFraction; + } + function isRejectedAboveMiddleClose(indicatorBars, index) { + const next = indicatorBars[index + 1]; + return next.close < next.middle && next.close < next.open; + } + function matchesPreCrossCompression(indicatorBars, crossIndex) { + const config = BOLLINGER_PATTERN; + const start = crossIndex - config.preCrossBars; + const preCross = indicatorBars.slice(start, crossIndex); + const channelCloses = preCross.filter( + (bar) => bar.close >= bar.lower && bar.close <= bar.middle + ).length; + const aboveMiddleIndexes = []; + let belowLowerCloses = 0; + for (let offset = 0; offset < preCross.length; offset += 1) { + const bar = preCross[offset]; + if (bar.close > bar.middle) aboveMiddleIndexes.push(start + offset); + if (bar.close < bar.lower) belowLowerCloses += 1; + } + return channelCloses >= config.minPreCrossChannelCloses && aboveMiddleIndexes.length <= config.maxPreCrossAboveMiddleCloses && belowLowerCloses <= config.maxPreCrossBelowLowerCloses && aboveMiddleIndexes.every((index) => isRejectedAboveMiddleClose(indicatorBars, index)); + } + function isDownwardCross(previous, current) { + return previous.middle >= previous.ma60 && current.middle < current.ma60; + } + function buildSignal(type, setup, bar) { + const width = bandWidth(bar); + const markerGapFraction = type === "warning" ? 0.06 : 0.1; + return Object.freeze({ + id: `${setup.time}:${type}`, + type, + setupTime: setup.time, + time: bar.time, + markerPrice: type === "reversal" ? bar.low - width * markerGapFraction : bar.high + width * markerGapFraction + }); + } + function detectReversalSignal(indicatorBars, setup, warningIndex) { + const { reversalFollowBars } = BOLLINGER_PATTERN; + const warning = indicatorBars[warningIndex]; + const endIndex = Math.min( + indicatorBars.length - 1, + warningIndex + reversalFollowBars + ); + for (let index = warningIndex + 1; index <= endIndex; index += 1) { + const bar = indicatorBars[index]; + if (bar.close > warning.high) return buildSignal("reversal", setup, bar); + } + return null; + } + function detectSetupSignals(indicatorBars, crossIndex) { + const config = BOLLINGER_PATTERN; + const setup = indicatorBars[crossIndex]; + const signals = []; + let warningIndex = null; + let pendingMiddleRejection = false; + let aboveMiddleCloseCount = 0; + const endIndex = Math.min( + indicatorBars.length - 1, + crossIndex + config.postCrossBars + ); + for (let index = crossIndex + 1; index <= endIndex; index += 1) { + const bar = indicatorBars[index]; + const width = bandWidth(bar); + if (pendingMiddleRejection) { + if (!(bar.close < bar.middle && bar.close < bar.open)) break; + pendingMiddleRejection = false; + } + if (bar.close > bar.middle) { + aboveMiddleCloseCount += 1; + if (aboveMiddleCloseCount > 1 || bar.close > bar.middle + width * config.maxPostCrossCloseAboveMiddleBandFraction || bar.close > bar.upper) break; + pendingMiddleRejection = true; + continue; + } + const bandStillDown = bar.middle < setup.middle && hasDownwardBandCenter(indicatorBars, index); + if (!bandStillDown) continue; + if (warningIndex === null && bar.high >= bar.middle - width * config.middleApproachBandFraction) { + warningIndex = index; + signals.push(buildSignal("warning", setup, bar)); + continue; + } + if (warningIndex !== null && index > warningIndex && bar.close < bar.open && bar.low <= bar.lower + width * config.lowerTouchBandFraction) { + signals.push(buildSignal("confirmed", setup, bar)); + break; + } + } + if (warningIndex !== null) { + const reversal = detectReversalSignal(indicatorBars, setup, warningIndex); + if (reversal) signals.push(reversal); + } + return signals; + } + function appendSetupSignals(signals, setupSignals) { + for (const signal of setupSignals) { + if (signal.type !== "reversal") { + signals.push(signal); + continue; + } + const duplicateIndex = signals.findIndex( + (existing) => existing.type === "reversal" && existing.time === signal.time + ); + if (duplicateIndex === -1) { + signals.push(signal); + continue; + } + if (signal.setupTime > signals[duplicateIndex].setupTime) { + signals[duplicateIndex] = signal; + } + } + } + function detectBearishBollingerSignalsFromIndicatorBarsInternal(indicatorBars) { + const config = BOLLINGER_PATTERN; + const firstCrossIndex = Math.max( + config.maPeriod, + config.maPeriod - 1 + config.preCrossBars, + config.trendLookbackBars + ); + const signals = []; + for (let index = firstCrossIndex; index < indicatorBars.length; index += 1) { + const previous = indicatorBars[index - 1]; + const current = indicatorBars[index]; + if (!isDownwardCross(previous, current)) continue; + if (!hasDownwardBandCenter(indicatorBars, index)) continue; + if (!matchesPreCrossCompression(indicatorBars, index)) continue; + appendSetupSignals(signals, detectSetupSignals(indicatorBars, index)); + } + const typeOrder = { warning: 0, confirmed: 1, reversal: 2 }; + return signals.sort( + (left, right) => left.time - right.time || typeOrder[left.type] - typeOrder[right.type] + ); + } + function mirrorIndicatorBar(bar) { + return { + ...bar, + open: -bar.open, + high: -bar.low, + low: -bar.high, + close: -bar.close, + middle: bar.middle === null ? null : -bar.middle, + upper: bar.upper === null ? null : -bar.lower, + lower: bar.lower === null ? null : -bar.upper, + ma60: bar.ma60 === null ? null : -bar.ma60 + }; + } + function mapMirroredBullishSignal(signal) { + return Object.freeze({ + ...signal, + id: `${signal.setupTime}:bullish:${signal.type}`, + direction: "bullish", + markerPrice: -signal.markerPrice + }); + } + function detectBullishBollingerSignalsFromIndicatorBarsInternal(indicatorBars) { + return detectBearishBollingerSignalsFromIndicatorBarsInternal( + indicatorBars.map(mirrorIndicatorBar) + ).map(mapMirroredBullishSignal); + } + function compareBollingerSignals(left, right) { + const directionOrder = { bearish: 0, bullish: 1 }; + const typeOrder = { warning: 0, confirmed: 1, reversal: 2 }; + const leftDirectionOrder = directionOrder[left.direction]; + const rightDirectionOrder = directionOrder[right.direction]; + if (leftDirectionOrder === void 0 || rightDirectionOrder === void 0) { + throw new Error("Bollinger signal direction is invalid"); + } + return left.time - right.time || leftDirectionOrder - rightDirectionOrder || typeOrder[left.type] - typeOrder[right.type]; + } + function detectBollingerSignalsFromIndicatorBars(indicatorBars) { + assertIndicatorBars(indicatorBars, "Bollinger"); + const bearishSignals = detectBearishBollingerSignalsFromIndicatorBarsInternal(indicatorBars).map((signal) => Object.freeze({ ...signal, direction: "bearish" })); + const bullishSignals = detectBullishBollingerSignalsFromIndicatorBarsInternal(indicatorBars); + return [...bearishSignals, ...bullishSignals].sort(compareBollingerSignals); + } + function detectBollingerSignals(bars) { + return detectBollingerSignalsFromIndicatorBars( + calculateBollingerIndicatorBars(bars, "Bollinger") + ); + } + + // src/shared/tradingview-target.js + var CHART_ROOT_SELECTOR = ".chart-widget-root"; + function hasVisibleBox(element2) { + if (!element2?.getClientRects().length) return false; + const rect = element2.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + function findBinanceTradingViewTarget(document) { + const chartRoots = Array.from(document.querySelectorAll(CHART_ROOT_SELECTOR)).filter(hasVisibleBox); + if (!chartRoots.length) return null; + if (chartRoots.length > 1) { + throw new Error(`可见图表区域数量异常:${chartRoots.length}`); + } + const chartRoot = chartRoots[0]; + const tradingViewApis = Array.from(chartRoot.querySelectorAll("iframe")).map((frame) => frame.contentWindow?.tradingViewApi).filter(Boolean); + if (!tradingViewApis.length) return null; + if (tradingViewApis.length > 1) { + throw new Error(`图表接口数量异常:${tradingViewApis.length}`); + } + return { chartRoot, tradingViewApi: tradingViewApis[0] }; + } + + // src/shared/abort.js + function getAbortReason(signal) { + if (signal?.reason instanceof Error) return signal.reason; + const error = new Error("Operation aborted"); + error.name = "AbortError"; + return error; + } + function throwIfAborted(signal) { + if (signal?.aborted) throw getAbortReason(signal); + } + function waitForPromiseOrAbort(task, signal) { + if (!signal) return Promise.resolve(task); + throwIfAborted(signal); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback, value) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + callback(value); + }; + const onAbort = () => finish(reject, getAbortReason(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + Promise.resolve(task).then( + (value) => finish(resolve, value), + (error) => finish(reject, error) + ); + }); + } + + // src/shared/chart-marker-save-controller.js + var CONTROLLER_SLOT = Symbol.for("jh-userscripts.chart-marker-save-controller"); + var PROTOCOL_VERSION = 1; + function readController(api) { + const record = api[CONTROLLER_SLOT]; + if (record === void 0) return null; + if (record.version !== PROTOCOL_VERSION || typeof record.controller?.runAfterIdle !== "function") { + throw new Error("Incompatible TradingView marker save protocol; update both scripts and reload"); + } + return record.controller; + } + var QUIET_MS = 150; + var MAX_BURST_MS = 1e3; + var DRAIN_TIMEOUT_MS = 2e3; + function installTradingViewMarkerSaveController(api, { + onError = (error) => { + throw error; + }, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout + } = {}) { + const existing = readController(api); + if (existing) return existing; + if (typeof api?.saveChart !== "function") { + throw new Error("TradingView marker save API is unavailable"); + } + const originalSaveChart = api.saveChart; + let burst = null; + let tailTimer = null; + let mutations = 0; + let draining = 0; + let saveRequests = 0; + let serializations = 0; + let callbackCount = 0; + let failureCount = 0; + const idleWaiters = /* @__PURE__ */ new Set(); + const busy = () => burst !== null || mutations !== 0 || tailTimer !== null; + function notifyIdle() { + if (busy()) return; + for (const resolve of idleWaiters) resolve(); + idleWaiters.clear(); + } + function reportErrors(errors) { + if (errors.length === 0) return; + failureCount += errors.length; + setTimeoutFn(() => onError(new AggregateError(errors, "TradingView marker save burst failed")), 0); + } + function flush() { + const pending = burst; + if (!pending) return; + burst = null; + clearTimeoutFn(pending.quietTimer); + clearTimeoutFn(pending.maxTimer); + const errors = []; + try { + if (pending.callbacks.length > 0) { + serializations += 1; + originalSaveChart.call(api, (snapshot) => { + const json = JSON.stringify(snapshot); + for (const callback of pending.callbacks) { + try { + callbackCount += 1; + callback(JSON.parse(json)); + } catch (error) { + errors.push(error); + } + } + }); + } + } catch (error) { + errors.push(error); + } finally { + pending.callbacks.length = 0; + notifyIdle(); + reportErrors(errors); + } + } + function scheduleQuiet() { + clearTimeoutFn(burst.quietTimer); + burst.quietTimer = setTimeoutFn(flush, QUIET_MS); + } + function markMutation() { + if (tailTimer !== null) clearTimeoutFn(tailTimer); + tailTimer = setTimeoutFn(() => { + tailTimer = null; + notifyIdle(); + }, QUIET_MS); + if (!burst) { + burst = { callbacks: [], quietTimer: null, maxTimer: setTimeoutFn(flush, MAX_BURST_MS) }; + } + scheduleQuiet(); + } + function markerSaveChart(...args) { + const defaultCall = this === api && args.length <= 2 && typeof args[0] === "function" && args[1] === void 0; + if (api.saveChart !== markerSaveChart || !defaultCall) { + flush(); + return originalSaveChart.apply(this, args); + } + if (!burst) return originalSaveChart.apply(this, args); + saveRequests += 1; + burst.callbacks.push(args[0]); + scheduleQuiet(); + return void 0; + } + api.saveChart = markerSaveChart; + if (api.saveChart !== markerSaveChart) { + throw new Error("TradingView marker save wrapper could not be installed"); + } + const controller = Object.freeze({ + canMutate: () => draining === 0 && api.saveChart === markerSaveChart, + beginMutation() { + if (!controller.canMutate()) { + throw new Error("TradingView marker mutation overlaps a chart save owner"); + } + mutations += 1; + markMutation(); + let finished = false; + return () => { + if (finished) throw new Error("TradingView marker mutation finished twice"); + finished = true; + mutations -= 1; + markMutation(); + }; + }, + async runAfterIdle(action, { signal } = {}) { + throwIfAborted(signal); + draining += 1; + let timeout = null; + let wake = null; + try { + if (busy()) { + await waitForPromiseOrAbort(new Promise((resolve, reject) => { + wake = resolve; + idleWaiters.add(wake); + timeout = setTimeoutFn(() => { + const error = new Error("TradingView marker saves did not finish before the chart operation"); + error.name = "TradingViewMarkerSaveDrainTimeoutError"; + reject(error); + }, DRAIN_TIMEOUT_MS); + }), signal); + } + if (busy()) throw new Error("TradingView marker save drain was invalidated"); + throwIfAborted(signal); + return await action(); + } finally { + if (timeout !== null) clearTimeoutFn(timeout); + if (wake !== null) idleWaiters.delete(wake); + draining -= 1; + } + }, + getStats: () => ({ + busy: busy(), + mutations, + draining, + saveRequests, + serializations, + callbackCount, + failureCount, + pendingCallbacks: burst?.callbacks.length || 0 + }) + }); + Object.defineProperty(api, CONTROLLER_SLOT, { value: Object.freeze({ version: PROTOCOL_VERSION, controller }) }); + return controller; + } + + // src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js + var MAX_BOLLINGER_MARKERS_PER_DIRECTION = 1e3; + var MAX_BOLLINGER_MARKERS = MAX_BOLLINGER_MARKERS_PER_DIRECTION * 2; + function routeSymbolFromChartSymbol(value) { + return String(value || "").split("@", 1)[0]; + } + function assertChartContract(chart) { + for (const method of [ + "createShape", + "dataReady", + "exportData", + "getAllShapes", + "getShapeById", + "hasModel", + "onDataLoaded", + "onIntervalChanged", + "removeEntity", + "resolution", + "symbol" + ]) { + if (typeof chart?.[method] !== "function") { + throw new Error(`TradingView Bollinger alert method is unavailable: ${method}`); + } + } + } + function readLiveShapes(chart) { + const shapes = chart.getAllShapes(); + if (!Array.isArray(shapes)) { + throw new Error("TradingView Bollinger alert shape list is invalid"); + } + const ids = /* @__PURE__ */ new Map(); + for (const [index, shape] of shapes.entries()) { + if (typeof shape?.id !== "string" || shape.id.length === 0 || typeof shape.name !== "string") { + throw new Error(`TradingView Bollinger alert shape ${index} id is invalid`); + } + ids.set(shape.id, shape.name); + } + return ids; + } + function tradingViewResolutionToSeconds(resolution) { + const value = String(resolution || "").toUpperCase(); + const units = [ + { pattern: /^(\d+)S$/, seconds: 1 }, + { pattern: /^(\d+)$/, seconds: 60 }, + { pattern: /^(\d+)H$/, seconds: 60 * 60 }, + { pattern: /^(\d+)D$/, seconds: 24 * 60 * 60 }, + { pattern: /^(\d+)W$/, seconds: 7 * 24 * 60 * 60 } + ]; + for (const { pattern, seconds } of units) { + const match = value.match(pattern); + if (!match) continue; + const count = Number(match[1]); + if (Number.isSafeInteger(count) && count > 0) return count * seconds; + } + throw new Error(`TradingView Bollinger alert resolution is unsupported: ${resolution}`); + } + function bollingerIntervalVisibility(resolution) { + const seconds = tradingViewResolutionToSeconds(resolution); + const value = String(resolution).toUpperCase(); + const visibility = { + ticks: false, + seconds: false, + minutes: false, + hours: false, + days: false, + weeks: false, + months: false, + ranges: false + }; + let unit; + let count; + if (value.endsWith("W")) { + unit = "weeks"; + count = seconds / 604800; + } else if (value.endsWith("D")) { + unit = "days"; + count = seconds / 86400; + } else if (seconds < 60) { + unit = "seconds"; + count = seconds; + } else if (value.endsWith("S") || seconds < 3600) { + unit = "minutes"; + count = Math.floor(seconds / 60); + } else { + unit = "hours"; + count = Math.floor(seconds / 3600); + } + visibility[unit] = true; + visibility[`${unit}From`] = count; + visibility[`${unit}To`] = count; + return visibility; + } + function createBollingerIntervalSession(chart) { + const intervalChanged = chart.onIntervalChanged(); + const dataLoaded = chart.onDataLoaded(); + for (const subscription of [intervalChanged, dataLoaded]) { + if (typeof subscription?.subscribe !== "function" || typeof subscription.unsubscribe !== "function") { + throw new Error("TradingView Bollinger interval subscription is unavailable"); + } + } + let revision = 0; + let disposed = false; + let awaitingData = !chart.dataReady(); + const owner = {}; + function invalidate() { + revision += 1; + awaitingData = true; + } + function complete() { + awaitingData = false; + } + intervalChanged.subscribe(owner, invalidate); + dataLoaded.subscribe(owner, complete); + return Object.freeze({ + get revision() { + return revision; + }, + isCurrent(candidate) { + return !disposed && !awaitingData && candidate === revision && chart.dataReady(); + }, + dispose() { + if (disposed) return; + disposed = true; + revision += 1; + intervalChanged.unsubscribe(owner, invalidate); + dataLoaded.unsubscribe(owner, complete); + } + }); + } + function findBearishBollingerChartTarget(document, expectedRouteSymbol) { + const baseTarget = findBinanceTradingViewTarget(document); + if (!baseTarget) return null; + const chart = baseTarget.tradingViewApi.activeChart?.(); + if (!chart) return null; + assertChartContract(chart); + if (!chart.hasModel()) return null; + const resolution = chart.resolution(); + const resolutionSeconds = tradingViewResolutionToSeconds(resolution); + const routeSymbol = routeSymbolFromChartSymbol(chart.symbol()); + if (routeSymbol !== expectedRouteSymbol) { + throw new Error( + `TradingView Bollinger alert symbol mismatch: expected ${expectedRouteSymbol}, received ${routeSymbol}` + ); + } + return { + ...baseTarget, + chart, + resolution, + resolutionSeconds, + routeSymbol + }; + } + function isBearishBollingerChartTargetCurrent(document, target) { + const baseTarget = findBinanceTradingViewTarget(document); + if (!baseTarget) return false; + const chart = baseTarget.tradingViewApi.activeChart?.(); + if (!chart) return false; + assertChartContract(chart); + if (!chart.hasModel()) return false; + return baseTarget.chartRoot === target.chartRoot && baseTarget.tradingViewApi === target.tradingViewApi && chart === target.chart && chart.resolution() === target.resolution && routeSymbolFromChartSymbol(chart.symbol()) === target.routeSymbol; + } + function assertExportSchema(schema) { + if (!Array.isArray(schema)) throw new Error("TradingView Bollinger alert export schema is invalid"); + const fields = schema.map((column) => column.plotTitle || column.type); + const expected = ["time", "open", "high", "low", "close"]; + if (fields.length !== expected.length || fields.some((field, index) => field !== expected[index])) { + throw new Error(`TradingView Bollinger alert export schema mismatch: ${fields.join(",")}`); + } + } + function parseExportRow(row, index) { + if (!row || typeof row !== "object") { + throw new Error(`TradingView Bollinger alert export row ${index} is invalid`); + } + const bar = { + time: row[0], + open: row[1], + high: row[2], + low: row[3], + close: row[4] + }; + if (!Number.isInteger(bar.time)) { + throw new Error(`TradingView Bollinger alert export time ${index} is invalid`); + } + for (const field of ["open", "high", "low", "close"]) { + if (!Number.isFinite(bar[field])) { + throw new Error(`TradingView Bollinger alert export ${field} ${index} is invalid`); + } + } + return bar; + } + function parseClosedTradingViewBars(exported, { resolutionSeconds, observedAtSeconds, resolution }) { + if (!Number.isSafeInteger(resolutionSeconds) || resolutionSeconds < 1) { + throw new Error("TradingView Bollinger alert resolution seconds are invalid"); + } + if (!Number.isFinite(observedAtSeconds)) { + throw new Error("TradingView Bollinger alert observation time is invalid"); + } + assertExportSchema(exported?.schema); + if (!Array.isArray(exported.data)) { + throw new Error("TradingView Bollinger alert export data is invalid"); + } + const bars = exported.data.map(parseExportRow); + const gridSeconds = Math.min(resolutionSeconds, 86400); + for (const [index, bar] of bars.entries()) { + if (bar.time % gridSeconds !== 0 || String(resolution).toUpperCase().endsWith("W") && new Date(bar.time * 1e3).getUTCDay() !== 1) { + throw new TradingViewBarSnapshotInconsistentError( + `TradingView Bollinger alert export interval grid is invalid at ${index}` + ); + } + } + for (let index = 1; index < bars.length; index += 1) { + if (bars[index].time <= bars[index - 1].time) { + throw new TradingViewBarSnapshotInconsistentError( + `TradingView Bollinger alert export order is invalid at ${index}` + ); + } + if ((bars[index].time - bars[index - 1].time) % resolutionSeconds !== 0) { + throw new TradingViewBarSnapshotInconsistentError( + `TradingView Bollinger alert export interval spacing is invalid at ${index}` + ); + } + } + return bars.filter((bar) => bar.time + resolutionSeconds <= observedAtSeconds); + } + function buildClosedBarsWindowKey(bars) { + if (!Array.isArray(bars) || bars.length === 0) { + throw new Error("TradingView Bollinger alert closed-bar window is empty"); + } + return `${bars.length}:${bars[0].time}:${bars.at(-1).time}`; + } + function writeClosedBarToSnapshot(values, offset, bar, index) { + if (!bar || typeof bar !== "object") { + throw new Error(`TradingView Bollinger closed bar ${index} is invalid`); + } + if (!Number.isInteger(bar.time)) { + throw new Error(`TradingView Bollinger closed bar time ${index} is invalid`); + } + const fields = ["open", "high", "low", "close"]; + for (const field of fields) { + if (!Number.isFinite(bar[field])) { + throw new Error(`TradingView Bollinger closed bar ${field} ${index} is invalid`); + } + } + values[offset] = bar.time; + values[offset + 1] = bar.open; + values[offset + 2] = bar.high; + values[offset + 3] = bar.low; + values[offset + 4] = bar.close; + } + function buildClosedBarsContentSnapshot(bars) { + const windowKey = buildClosedBarsWindowKey(bars); + const values = new Float64Array(bars.length * 5); + for (let index = 0; index < bars.length; index += 1) { + writeClosedBarToSnapshot(values, index * 5, bars[index], index); + } + return { windowKey, values }; + } + function matchesClosedBarsContentSnapshot(bars, snapshot) { + if (!snapshot || typeof snapshot !== "object" || typeof snapshot.windowKey !== "string" || !(snapshot.values instanceof Float64Array)) { + throw new Error("TradingView Bollinger closed-bar snapshot is invalid"); + } + if (buildClosedBarsWindowKey(bars) !== snapshot.windowKey) return false; + if (snapshot.values.length !== bars.length * 5) return false; + const candidate = new Float64Array(5); + for (let index = 0; index < bars.length; index += 1) { + writeClosedBarToSnapshot(candidate, 0, bars[index], index); + const offset = index * 5; + for (let fieldIndex = 0; fieldIndex < candidate.length; fieldIndex += 1) { + if (!Object.is(snapshot.values[offset + fieldIndex], candidate[fieldIndex])) return false; + } + } + return true; + } + async function reconcileBearishBollingerAlertWindow({ + bars, + cachedWindowKey, + cachedContentSnapshot = null, + cachedSignals, + detectSignals, + renderSignals + }) { + if (typeof detectSignals !== "function") { + throw new Error("TradingView Bollinger alert detector is unavailable"); + } + if (typeof renderSignals !== "function") { + throw new Error("TradingView Bollinger alert renderer is unavailable"); + } + const closedBarsWindowKey = buildClosedBarsWindowKey(bars); + const contentUnchanged = closedBarsWindowKey === cachedWindowKey && cachedContentSnapshot !== null && matchesClosedBarsContentSnapshot(bars, cachedContentSnapshot); + const signals = contentUnchanged ? cachedSignals : detectSignals(bars); + if (!Array.isArray(signals)) { + throw new Error("Bollinger signal cache is invalid"); + } + const rendered = await renderSignals(signals); + if (typeof rendered !== "boolean") { + throw new Error("TradingView Bollinger alert render result is invalid"); + } + return { + rendered, + closedBarsWindowKey, + closedBarsContentSnapshot: contentUnchanged ? cachedContentSnapshot : buildClosedBarsContentSnapshot(bars), + signals + }; + } + async function exportClosedTradingViewBars(target, session, observedAtMs = Date.now()) { + const revision = session.revision; + const isCurrent = () => session.isCurrent(revision) && target.chart.resolution() === target.resolution && routeSymbolFromChartSymbol(target.chart.symbol()) === target.routeSymbol; + if (!isCurrent()) return null; + const exported = await target.chart.exportData({ includedStudies: [] }); + if (!isCurrent()) return null; + return parseClosedTradingViewBars(exported, { + resolutionSeconds: target.resolutionSeconds, + resolution: target.resolution, + observedAtSeconds: observedAtMs / 1e3 + }); + } + function markerOptions(signal, resolution) { + const direction = signal.direction; + if (direction !== "bearish" && direction !== "bullish") { + throw new Error(`TradingView Bollinger alert signal direction is invalid: ${direction}`); + } + const isBullish = direction === "bullish"; + const common = { + lock: true, + disableSave: true, + disableSelection: true, + disableUndo: true, + showInObjectsTree: false + }; + if (signal.type === "warning") { + return { + ...common, + shape: "icon", + icon: 61713, + overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), + color: isBullish ? "#0ECB81" : "#F6465D", + size: 10 + } + }; + } + if (signal.type === "confirmed") { + return { + ...common, + shape: isBullish ? "arrow_up" : "arrow_down", + overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), + color: isBullish ? "#0ECB81" : "#F6465D", + arrowColor: isBullish ? "#0ECB81" : "#F6465D" + } + }; + } + if (signal.type === "reversal") { + return { + ...common, + shape: isBullish ? "arrow_down" : "arrow_up", + overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), + color: isBullish ? "#F6465D" : "#0ECB81", + arrowColor: isBullish ? "#F6465D" : "#0ECB81" + } + }; + } + throw new Error(`TradingView Bollinger alert signal type is invalid: ${signal.type}`); + } + function readMarkerPoint(shape) { + const points = shape?.getPoints?.(); + if (!Array.isArray(points) || points.length !== 1 || !Number.isInteger(points[0].time) || !Number.isFinite(points[0].price)) { + throw new Error("TradingView Bollinger alert marker point is invalid"); + } + return points[0]; + } + function markerPropertiesMatch(shape, options) { + const properties = shape.getProperties(); + if (!properties || typeof properties !== "object") { + throw new Error("TradingView Bollinger alert marker properties are invalid"); + } + if (options.icon !== void 0 && properties.icon !== options.icon) return false; + for (const [key, expected] of Object.entries(options.overrides)) { + if (key === "intervalsVisibilities") { + if (!properties[key] || Object.entries(expected).some(([unit, value]) => properties[key][unit] !== value)) return false; + } else if (properties[key] !== expected) return false; + } + return true; + } + function normalizeSignal(signal, index, defaultDirection) { + if (!signal || typeof signal !== "object") { + throw new Error(`TradingView Bollinger alert signal ${index} is invalid`); + } + if (typeof signal.id !== "string" || signal.id.length === 0) { + throw new Error(`TradingView Bollinger alert signal ${index} id is invalid`); + } + const direction = signal.direction === void 0 ? defaultDirection : signal.direction; + if (direction !== "bearish" && direction !== "bullish") { + throw new Error(`TradingView Bollinger alert signal ${index} direction is invalid: ${direction}`); + } + return signal.direction === direction ? signal : { ...signal, direction }; + } + function createMarkerLayer(target, defaultDirection, { + canMutate: canMutateExternally = () => true, + onSaveError, + yieldToBrowser = () => new Promise((resolve) => setTimeout(resolve, 0)) + } = {}) { + const { chart } = target; + const saveController = installTradingViewMarkerSaveController(target.tradingViewApi, { onError: onSaveError }); + const canMutate = () => canMutateExternally() && saveController.canMutate(); + const registry = /* @__PURE__ */ new Map(); + const pendingMarkers = /* @__PURE__ */ new Set(); + let generation = 0; + let creating = 0; + function mutate(action) { + const finish = saveController.beginMutation(); + try { + return action(); + } finally { + finish(); + } + } + function removePendingMarkers() { + if (pendingMarkers.size === 0 || !canMutate()) return; + const liveShapeIds = readLiveShapes(chart); + for (const id of pendingMarkers) { + if (liveShapeIds.has(id)) mutate(() => chart.removeEntity(id)); + pendingMarkers.delete(id); + } + } + function discardMissingSignals(liveShapeIds) { + for (const [signalId, record] of registry) { + if (!liveShapeIds.has(record.markerId)) registry.delete(signalId); + } + } + function removeSignal(signalId, liveShapeIds) { + const record = registry.get(signalId); + if (!record) return; + if (liveShapeIds.has(record.markerId)) { + mutate(() => chart.removeEntity(record.markerId)); + liveShapeIds.delete(record.markerId); + } + registry.delete(signalId); + } + return Object.freeze({ + async render(signals, { isCurrent }) { + if (!Array.isArray(signals)) throw new Error("TradingView Bollinger alert signals are invalid"); + if (signals.length > MAX_BOLLINGER_MARKERS) { + throw new Error( + `TradingView Bollinger alert marker limit exceeded: ${signals.length}` + ); + } + if (typeof isCurrent !== "function") { + throw new Error("TradingView Bollinger alert current-target validator is unavailable"); + } + const normalizedSignals = signals.map((signal, index) => normalizeSignal(signal, index, defaultDirection)); + const directionCounts = { bearish: 0, bullish: 0 }; + for (const signal of normalizedSignals) { + directionCounts[signal.direction] += 1; + if (directionCounts[signal.direction] > MAX_BOLLINGER_MARKERS_PER_DIRECTION) { + throw new Error( + `TradingView Bollinger alert ${signal.direction} marker limit exceeded: ` + directionCounts[signal.direction] + ); + } + } + const requestedGeneration = generation; + if (!isCurrent() || !canMutate()) return false; + removePendingMarkers(); + let liveShapeIds = readLiveShapes(chart); + discardMissingSignals(liveShapeIds); + const nextIds = new Set(normalizedSignals.map((signal) => signal.id)); + for (const signalId of [...registry.keys()]) { + if (!nextIds.has(signalId)) removeSignal(signalId, liveShapeIds); + } + let batchStartedAt = performance.now(); + let batchOps = 0; + for (const signal of normalizedSignals) { + if (batchOps > 0 && (batchOps >= 32 || performance.now() - batchStartedAt >= 8)) { + await yieldToBrowser(); + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + liveShapeIds = readLiveShapes(chart); + discardMissingSignals(liveShapeIds); + batchStartedAt = performance.now(); + batchOps = 0; + } + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + batchOps += 1; + const options = markerOptions(signal, target.resolution); + const existing = registry.get(signal.id); + if (existing) { + const shape = chart.getShapeById(existing.markerId); + const point = readMarkerPoint(shape); + if (point.time === signal.time && point.price === existing.resolvedPrice && existing.markerPrice === signal.markerPrice && existing.type === signal.type && existing.direction === signal.direction && liveShapeIds.get(existing.markerId) === options.shape && markerPropertiesMatch(shape, options)) continue; + removeSignal(signal.id, liveShapeIds); + } + const finishCreation = saveController.beginMutation(); + creating += 1; + try { + const markerId = await chart.createShape({ time: signal.time, price: signal.markerPrice }, { + ...options, + overrides: { ...options.overrides, visible: false } + }); + if (typeof markerId !== "string" || markerId.length === 0) { + throw new Error("TradingView returned an invalid Bollinger alert shape id"); + } + pendingMarkers.add(markerId); + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + const shape = chart.getShapeById(markerId); + const point = readMarkerPoint(shape); + if (point.time !== signal.time) { + throw new Error(`TradingView Bollinger alert time alignment failed for ${signal.time}`); + } + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + mutate(() => shape.setProperties(options.overrides, false)); + if (!markerPropertiesMatch(shape, options)) { + throw new Error("TradingView Bollinger alert marker properties were not applied"); + } + registry.set(signal.id, { + markerId, + resolvedPrice: point.price, + markerPrice: signal.markerPrice, + type: signal.type, + direction: signal.direction + }); + pendingMarkers.delete(markerId); + } finally { + finishCreation(); + creating -= 1; + removePendingMarkers(); + } + } + return true; + }, + clear() { + generation += 1; + if (!canMutate()) return false; + removePendingMarkers(); + const liveShapeIds = readLiveShapes(chart); + discardMissingSignals(liveShapeIds); + for (const signalId of [...registry.keys()]) removeSignal(signalId, liveShapeIds); + return creating === 0 && pendingMarkers.size === 0; + }, + get size() { + return registry.size; + }, + get saveStats() { + return saveController.getStats(); + } + }); + } + function createBollingerMarkerLayer(target, options) { + return createMarkerLayer(target, void 0, options); + } + + // src/binance-strategy29-bollinger/monitor.js + function createBollingerMonitor({ + document, + getCurrentSymbol, + isFuturesTradingPage, + isTradingViewDrawingMutationBusy, + err, + warn + }) { + let bearishBollingerAlertTask = null; + let bearishBollingerAlertContext = null; + let bollingerIntervalSession = null; + let lastLocalFailure = null; + const retiredBollingerLayers = /* @__PURE__ */ new Set(); + function clearBearishBollingerAlertContext() { + if (bearishBollingerAlertContext) { + retiredBollingerLayers.add(bearishBollingerAlertContext.layer); + bearishBollingerAlertContext = null; + } + return clearRetiredBollingerLayers(); + } + function clearRetiredBollingerLayers() { + if (isTradingViewDrawingMutationBusy()) return false; + for (const layer of retiredBollingerLayers) { + if (layer.clear()) retiredBollingerLayers.delete(layer); + } + return retiredBollingerLayers.size === 0; + } + function disposeBollingerIntervalSession() { + if (bollingerIntervalSession) { + bollingerIntervalSession.session.dispose(); + bollingerIntervalSession = null; + } + } + function isBearishBollingerAlertContextCurrent(context) { + return bearishBollingerAlertContext === context && context.intervalSession === bollingerIntervalSession?.session && context.intervalSession.isCurrent(context.intervalRevision) && !document.hidden && isFuturesTradingPage() && !isTradingViewDrawingMutationBusy() && getCurrentSymbol() === context.routeSymbol && isBearishBollingerChartTargetCurrent(document, context.target); + } + async function synchronizeBearishBollingerAlerts() { + if (document.hidden || !isFuturesTradingPage()) return; + const routeSymbol = getCurrentSymbol(); + if (!routeSymbol) return; + let target; + try { + target = findBearishBollingerChartTarget(document, routeSymbol); + } catch (error) { + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + err("Bollinger chart lookup failed for this sample:", error); + return; + } + if (!target) { + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + return; + } + if (!bollingerIntervalSession || bollingerIntervalSession.chart !== target.chart || bollingerIntervalSession.routeSymbol !== routeSymbol) { + disposeBollingerIntervalSession(); + bollingerIntervalSession = { + chart: target.chart, + routeSymbol, + session: createBollingerIntervalSession(target.chart) + }; + } + const intervalSession = bollingerIntervalSession.session; + const contextMatches = bearishBollingerAlertContext && bearishBollingerAlertContext.target.chart === target.chart && bearishBollingerAlertContext.target.chartRoot === target.chartRoot && bearishBollingerAlertContext.target.tradingViewApi === target.tradingViewApi && bearishBollingerAlertContext.routeSymbol === routeSymbol && bearishBollingerAlertContext.resolution === target.resolution && bearishBollingerAlertContext.intervalSession === intervalSession && bearishBollingerAlertContext.intervalRevision === intervalSession.revision; + if (!contextMatches) { + if (!clearBearishBollingerAlertContext()) return; + if (!intervalSession.isCurrent(intervalSession.revision) || isTradingViewDrawingMutationBusy()) return; + bearishBollingerAlertContext = { + routeSymbol, + resolution: target.resolution, + intervalSession, + intervalRevision: intervalSession.revision, + target, + layer: createBollingerMarkerLayer(target, { + canMutate: () => !isTradingViewDrawingMutationBusy(), + onSaveError: (error) => err("Bollinger chart save failed:", error) + }), + failed: false, + cleanupPending: false, + lastProcessedClosedBarsWindowKey: null, + lastProcessedClosedBarsContentSnapshot: null, + lastProcessedSignals: null + }; + } + if (isTradingViewDrawingMutationBusy() || !clearRetiredBollingerLayers()) return; + const context = bearishBollingerAlertContext; + if (context.cleanupPending) { + context.layer.clear(); + context.cleanupPending = false; + } + if (context.failed || bearishBollingerAlertTask) return; + let stage = "export"; + const task = (async () => { + const bars = await exportClosedTradingViewBars(context.target, context.intervalSession); + if (!bars || !isBearishBollingerAlertContextCurrent(context)) return; + if (bars.length === 0) return; + stage = "reconcile"; + const result = await reconcileBearishBollingerAlertWindow({ + bars, + cachedWindowKey: context.lastProcessedClosedBarsWindowKey, + cachedContentSnapshot: context.lastProcessedClosedBarsContentSnapshot, + cachedSignals: context.lastProcessedSignals, + detectSignals: (bars2) => { + stage = "detect"; + const signals = detectBollingerSignals(bars2); + stage = "reconcile"; + return signals; + }, + renderSignals: async (signals) => { + stage = "render"; + const rendered = await context.layer.render(signals, { + isCurrent: () => isBearishBollingerAlertContextCurrent(context) + }); + stage = "reconcile"; + return rendered; + } + }); + if (result.rendered && isBearishBollingerAlertContextCurrent(context)) { + context.lastProcessedClosedBarsWindowKey = result.closedBarsWindowKey; + context.lastProcessedClosedBarsContentSnapshot = result.closedBarsContentSnapshot; + context.lastProcessedSignals = result.signals; + } + })(); + bearishBollingerAlertTask = task; + task.catch((error) => { + if (bearishBollingerAlertContext !== context || context.intervalSession !== bollingerIntervalSession?.session || context.intervalRevision !== context.intervalSession.revision) return; + let failureKind; + let classificationFailed = false; + try { + failureKind = applyBollingerAlertTaskFailure(context, error); + } catch { + classificationFailed = true; + context.failed = true; + context.cleanupPending = true; + failureKind = "fatal"; + } + if (failureKind === "retry") { + warn("布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:", error); + return; + } + const details = { name: null, message: null }; + const unreadableFields = []; + for (const [key, limit] of [["name", 64], ["message", 512]]) { + try { + const value = key === "message" && typeof error === "string" ? error : error?.[key]; + details[key] = typeof value === "string" ? value.slice(0, limit) : null; + } catch { + unreadableFields.push(key); + } + } + lastLocalFailure = Object.freeze({ + thrownType: error === null ? "null" : typeof error, + classificationFailed, + ...details, + unreadableFields: Object.freeze(unreadableFields), + stage, + routeSymbol: context.routeSymbol, + resolution: context.resolution, + cachedSignalCount: context.lastProcessedSignals === null ? null : context.lastProcessedSignals.length, + layerSizeBeforeCleanup: context.layer.size, + sessionRevision: context.intervalSession.revision, + contextIntervalRevision: context.intervalRevision + }); + err("布林带形态预警已停止:", error); + }).finally(() => { + if (bearishBollingerAlertTask === task) bearishBollingerAlertTask = null; + }); + } + function stopBearishBollingerAlertMonitor() { + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + } + function getBollingerAlertDiagnostics() { + const context = bearishBollingerAlertContext; + const session = bollingerIntervalSession?.session || null; + const chart = bollingerIntervalSession?.chart || context?.target.chart || null; + const nativeModelReady = chart ? chart.hasModel() : null; + return { + taskPending: bearishBollingerAlertTask !== null, + contextPresent: context !== null, + failed: context ? context.failed : null, + lastLocalFailure, + cleanupPending: context ? context.cleanupPending : null, + cachedSignalCount: context?.lastProcessedSignals === null || !context ? null : context.lastProcessedSignals.length, + layerSize: context ? context.layer.size : null, + markerSaveStats: context ? context.layer.saveStats : null, + retiredCount: retiredBollingerLayers.size, + sessionPresent: session !== null, + sessionRevision: session ? session.revision : null, + contextIntervalRevision: context ? context.intervalRevision : null, + sessionMatchesContext: context && session ? context.intervalSession === session : null, + sessionCurrent: session && nativeModelReady ? session.isCurrent(session.revision) : null, + nativeModelReady, + nativeDataReady: nativeModelReady ? chart.dataReady() : null, + mutationBlocked: isTradingViewDrawingMutationBusy() + }; + } + return Object.freeze({ + tick: synchronizeBearishBollingerAlerts, + stop: stopBearishBollingerAlertMonitor, + get diagnostics() { + return getBollingerAlertDiagnostics(); + } + }); + } + + // src/shared/chart-mutation-owners.js + var OWNER_SLOT = Symbol.for("jh-userscripts.chart-mutation-owners"); + var VERSION = 1; + function existingOwners(view) { + const record = view[OWNER_SLOT]; + 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) { + 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; + } + return false; + } + + // src/shared/binance-futures-route.js + var FUTURES_TRADING_PATH_RE = /^\/(?:[a-z]{2}(?:-[A-Za-z]{2})?\/)?futures\/([A-Za-z0-9_]{3,})\/?$/; + function parseFuturesTradingSymbolFromPathname(pathname) { + const normalized = String(pathname || "").split(/[?#]/, 1)[0]; + const match = normalized.match(FUTURES_TRADING_PATH_RE); + return match?.[1] ? match[1].toUpperCase() : null; + } + function isFuturesTradingPathname(pathname) { + return Boolean(parseFuturesTradingSymbolFromPathname(pathname)); + } + + // src/shared/spa-route-change.js + var ROUTE_CHANGE_EVENT = "jh-userscripts:spa-route-change"; + var ROUTE_PATCH_MARKER = Symbol.for("jh-userscripts.spa-route-change-patched"); + var ROUTE_DISPATCH_STATE = Symbol.for("jh-userscripts.spa-route-change-dispatch"); + function dispatchRouteChange(view) { + const href = view.location.href; + if (view[ROUTE_DISPATCH_STATE]?.href === href) return; + const state = { href }; + view[ROUTE_DISPATCH_STATE] = state; + view.dispatchEvent(new view.Event(ROUTE_CHANGE_EVENT)); + view.queueMicrotask(() => { + if (view[ROUTE_DISPATCH_STATE] === state) delete view[ROUTE_DISPATCH_STATE]; + }); + } + function patchHistoryMethod(view, methodName) { + const current = view.history[methodName]; + if (current[ROUTE_PATCH_MARKER]) return; + function routeAwareHistoryMethod(...args) { + const previousHref = view.location.href; + const result = Reflect.apply(current, this, args); + if (view.location.href !== previousHref) dispatchRouteChange(view); + return result; + } + Object.defineProperty(routeAwareHistoryMethod, ROUTE_PATCH_MARKER, { value: true }); + view.history[methodName] = routeAwareHistoryMethod; + } + function ensureSpaRouteChangePatched(view) { + if (!view?.history) throw new Error("SPA route patch requires a window"); + patchHistoryMethod(view, "pushState"); + patchHistoryMethod(view, "replaceState"); + } + function installSpaRouteChangeListener(view, listener) { + if (!view?.history || typeof listener !== "function") { + throw new Error("SPA route listener requires a window and callback"); + } + ensureSpaRouteChangePatched(view); + view.addEventListener(ROUTE_CHANGE_EVENT, listener); + view.addEventListener("popstate", listener); + view.addEventListener("hashchange", listener); + return () => { + view.removeEventListener(ROUTE_CHANGE_EVENT, listener); + view.removeEventListener("popstate", listener); + view.removeEventListener("hashchange", listener); + }; + } + + // src/binance-orderbook-trade/contracts/panel-copy.js + var UI_LOCALE_ZH_CN = "zh-CN"; + var UI_LOCALE_EN = "en"; + var SUPPORTED_UI_LOCALES = Object.freeze([ + UI_LOCALE_ZH_CN, + UI_LOCALE_EN + ]); + function localizedText(zhCN, en) { + if (typeof zhCN !== "string" || zhCN === "" || typeof en !== "string" || en === "") { + throw new Error("Localized UI text requires non-empty Chinese and English values"); + } + return Object.freeze({ zhCN, en }); + } + function isLocalizedText(value) { + return Boolean( + value && typeof value === "object" && typeof value.zhCN === "string" && typeof value.en === "string" + ); + } + function formatLocalizedText(value, locale) { + if (typeof value === "string") return value; + if (!isLocalizedText(value)) throw new Error("Invalid localized UI text"); + if (locale === UI_LOCALE_ZH_CN) return value.zhCN; + if (locale === UI_LOCALE_EN) return value.en; + throw new Error(`Unsupported UI locale: ${locale}`); + } + function resolveUiLocaleFromPathname(pathname) { + const firstSegment = String(pathname || "").split(/[?#]/, 1)[0].split("/").filter(Boolean)[0]; + return firstSegment?.toLowerCase() === "zh-cn" ? UI_LOCALE_ZH_CN : UI_LOCALE_EN; + } + var freezeCopy = (copy) => Object.freeze(copy); + var PANEL_COPY = Object.freeze({ + section: freezeCopy({ + singleOrder: localizedText("单击下单", "Single Order"), + ladderMaker: localizedText("阶梯下单 · Maker", "Ladder Orders · Maker") + }), + field: freezeCopy({ + clickOrderbook: localizedText("单击订单簿时", "On click"), + minimumOrderQuantity: localizedText("最小下单量的", "Minimum order qty"), + minimumOpenQuantity: localizedText("最小开仓量的", "Minimum open qty"), + minimumCloseQuantity: localizedText("最小平仓量的", "Minimum close qty"), + ratio: localizedText("比例", "Ratio"), + orderCount: localizedText("笔数", "Orders"), + interval: localizedText("间距", "Gap"), + pricePrecision: localizedText("精度", "Precision"), + multiplierUnit: localizedText("倍", "×") + }), + action: freezeCopy({ + openLong: localizedText("阶梯开多", "Open Long"), + openShort: localizedText("阶梯开空", "Open Short"), + closeLong: localizedText("阶梯平多", "Close Long"), + closeShort: localizedText("阶梯平空", "Close Short"), + cancel: localizedText("撤单", "Cancel"), + cancelRunning: localizedText("撤单处理中", "Cancelling"), + noOrders: localizedText("无挂单", "No Orders"), + accountRebalance: localizedText("账户再平衡", "Account Rebalance"), + stopLadderByAction: freezeCopy({ + OPEN_LONG: localizedText("停止开多", "Stop Open Long"), + OPEN_SHORT: localizedText("停止开空", "Stop Open Short"), + CLOSE_LONG: localizedText("停止平多", "Stop Close Long"), + CLOSE_SHORT: localizedText("停止平空", "Stop Close Short") + }) + }), + side: freezeCopy({ + long: localizedText("多", "Long"), + short: localizedText("空", "Short"), + openLong: localizedText("开多", "Open Long"), + openShort: localizedText("开空", "Open Short"), + closeLong: localizedText("平多", "Close Long"), + closeShort: localizedText("平空", "Close Short") + }), + state: freezeCopy({ + idle: localizedText("空闲", "Idle"), + allPositionsClosed: localizedText("已全部平仓", "All positions closed"), + waitingTradeMode: localizedText("等待开仓/平仓状态", "Waiting for trade mode"), + waitingPricePrecision: localizedText("等待价格精度", "Waiting for precision"), + minimumQuantityLoading: localizedText("最小量读取中", "Loading minimum qty"), + positiveIntegerMultiplier: localizedText("请输入正整数倍数", "Enter a positive integer"), + noClosablePosition: localizedText("暂无可平仓位", "No position to close") + }), + status: freezeCopy({ + precisionUpdated: localizedText("精度推荐已更新", "Precision recommendation updated"), + precisionInsufficient: localizedText( + "近期价格变化不足,请稍后重试", + "Recent price movement is insufficient. Try again later." + ) + }), + aria: freezeCopy({ + decrementMultiplier: localizedText("减少倍数", "Decrease multiplier"), + incrementMultiplier: localizedText("增加倍数", "Increase multiplier") + }), + rebalanceDialog: freezeCopy({ + targetSummary: localizedText( + "目标分配:资金 50% / 现货 40% / U本位 10%", + "Target allocation: Funding 50% / Spot 40% / USDⓈ-M Futures 10%" + ), + accountHeading: localizedText("账户", "Account"), + currentHeading: localizedText("当前 (USDT)", "Current (USDT)"), + targetHeading: localizedText("目标 (USDT)", "Target (USDT)"), + transferHeading: localizedText("划转计划", "Transfer Plan"), + cancel: localizedText("取消", "Cancel"), + confirm: localizedText("确认再平衡", "Confirm Rebalance") + }), + tooltip: freezeCopy({ + singleOrder: localizedText( + "单击订单簿中的某个价格,按当前方向和数量设置提交一笔订单。", + "Click a price in the order book to submit one order using the current side and quantity settings." + ), + ladderMaker: localizedText( + "根据当前比例、笔数、间距和价格精度设置,依次提交只做 Maker 的阶梯订单。", + "Submit Post Only ladder orders sequentially using the current ratio, order count, gap, and precision." + ), + ratio: localizedText( + "本次阶梯下单使用可开/可平数量的百分比。", + "Percentage of the available open or close quantity used by this ladder." + ), + orderCount: localizedText( + "计划拆分成多少笔阶梯订单。", + "Number of orders in the ladder." + ), + interval: localizedText( + "相邻订单跨越多少个订单簿价格级别。", + "Number of order-book price levels between adjacent orders." + ), + pricePrecision: localizedText( + "与订单簿中的价格精度联动。黄点表示推荐值。比例、笔数、间距会随所选精度恢复对应设置。", + "Linked to the order-book price precision. The yellow dot marks the recommendation. Ratio, orders, and gap restore their saved values for the selected precision." + ), + continuousClose: localizedText( + "Option/Alt + 单击:连续交易", + "Option/Alt + click: continuous trading" + ), + accountRebalance: localizedText( + "将资金、现货和 U 本位账户的 USDT 按 5:4:1 分配", + "Allocate USDT across Funding, Spot, and USDⓈ-M Futures accounts at a 5:4:1 ratio" + ) + }) + }); + + // src/binance-strategy29-bollinger/ui-copy.js + var pair = localizedText; + var SUMMARY_COPY = Object.freeze({ + title: pair("Strategy 29 汇总", "Strategy 29 Summary"), + drag: pair("拖动面板", "Drag panel"), + collapse: pair("收起", "Collapse"), + expand: pair("展开", "Expand"), + waiting: pair("等待中", "Waiting"), + observerSpec: (value) => pair(`观察器规格 ${value}`, `Observer spec ${value}`), + reference: (value) => pair(`本地参考版本 ${value}`, `Local reference ${value}`), + noStatus: pair("尚未收到状态", "Status not received"), + noEventsCheck: pair("尚未检查事件", "Events not checked"), + processing: pair("最近处理状态", "Last processing status"), + processingHint: pair("已保存的处理状态不代表当前实时数据已就绪。", "Stored processing status does not confirm current live readiness."), + waitingDelivery: pair("全局通知 — 等待中", "Global delivery — waiting"), + recent: pair("最近跨周期信号", "Recent cross-timeframe signals"), + noEvents: pair("暂无最近信号", "No recent signals"), + close: (value) => pair(`收盘 ${value}`, `Close ${value}`), + matched: (value) => pair(`规格版本一致 · ${value}`, `Spec version matched · ${value}`), + mismatch: (local, server) => pair(`规格不一致 · 本地 ${local} · 服务端 ${server}`, `Spec mismatch · local ${local} · server ${server}`), + statusAt: (value) => pair(`状态更新 ${value}`, `Status ${value}`), + eventsAt: (value) => pair(`事件检查 ${value}`, `Events checked ${value}`), + incompatibleSelection: pair("选币不可用:观察器规格不一致", "Selection unavailable: observer specs are incompatible"), + incompatibleDelivery: pair("全局通知状态不可用:观察器规格不一致", "Global delivery unavailable: observer specs are incompatible"), + pending: pair("等待中", "pending"), + generation: (value) => pair(`批次 ${value}`, `Generation ${value}`), + markets: (value) => pair(`${value} 个币种`, `${value} markets`), + ready: (ready, total) => pair(`${ready}/${total} 个实时监测单元已就绪`, `${ready}/${total} live units ready`), + intervals: (value) => pair(`周期 ${value}`, `Intervals ${value}`), + noSuccess: pair("尚无成功选币记录", "No successful selection has been observed"), + lastSuccess: (clock, age) => pair(`上次成功选币 ${clock} · ${age} 秒前`, `Last successful selection ${clock} · ${age}s ago`), + unavailableSelection: pair("服务端选币不可用", "Server selection is unavailable"), + awaitingUnits: pair("币种已入选,等待监测单元状态", "Symbol is selected; waiting for unit status"), + notSelected: pair("当前服务端选币未监听此币种", "Symbol is not watched by the current server selection"), + delivery: (c) => pair(`全局通知 · 待发送 ${c.pending} · 发送中 ${c.sending} · 已发送 ${c.sent} · 结果未知 ${c.unknown} · 已过期 ${c.expired} · 失败 ${c.failed}`, `Global delivery · Pending ${c.pending} · Sending ${c.sending} · Sent ${c.sent} · Unknown ${c.unknown} · Expired ${c.expired} · Failed ${c.failed}`), + configuration: pair("尚未配置网关密钥", "Gateway secret is not configured"), + connecting: pair("正在连接 Strategy 29 网关", "Connecting to Strategy 29 gateway"), + connected: pair("已连接", "Connected"), + moreHistory: pair("已连接 · 仍有历史记录待加载", "Connected · more history pending"), + unavailable: pair("网关数据库暂不可用", "Gateway database unavailable"), + incompatible: pair("服务端与本地规格不一致", "Server and local specs are incompatible"), + disconnected: pair("网关连接失败,将在下次定时检查时重试", "Gateway connection failed; next scheduled poll will retry"), + stopped: (detail) => pair(`远程汇总已停止。技术详情:${detail}`, `Remote summary stopped: ${detail}`), + menuToggle: pair("切换 Strategy 29 跨周期汇总", "Toggle Strategy 29 cross-timeframe summary"), + menuSecret: pair("设置 Strategy 29 网关密钥", "Set Strategy 29 gateway secret"), + menuOrigin: pair("设置 Strategy 29 网关地址", "Set Strategy 29 gateway origin"), + promptSecret: pair("请输入本地 Strategy 29 网关密钥,仅保存在此用户脚本的私有存储中。", "Enter the local Strategy 29 gateway secret. It is stored only in this userscript storage."), + promptOrigin: pair("请输入本机网关地址(http://127.0.0.1:)", "Enter the loopback gateway origin (http://127.0.0.1:)"), + emptySecret: pair("Strategy 29 网关密钥不能为空", "Strategy 29 gateway secret cannot be empty"), + localStopped: (detail) => pair(`Strategy 29 已停止。技术详情:${detail}`, `Strategy 29 stopped: ${detail}`), + conflict: pair("Strategy 29 已停止:请将订单簿脚本更新至 2.7.199 或更高版本,或禁用内嵌布林带观察器的旧版本,然后刷新页面。", "Strategy 29 stopped: update Orderbook to 2.7.199 or disable its embedded Bollinger version, then reload this page.") + }); + var SELECTION_REASONS = Object.freeze({ + current: pair("当前选币有效", "Selection is current"), + using_stale_selection_after_refresh_error: pair("刷新失败,暂沿用上次选币直至过期", "Refresh failed; using the previous selection until expiry"), + selection_fail_closed: pair("刷新失败,选币不可用", "Selection unavailable after refresh failure"), + selection_expired_or_unusable: pair("选币已过期,等待成功刷新", "Selection expired; waiting for a successful refresh"), + missing_current_universe_facts: pair("等待服务端初始化选币", "Waiting for server selection to initialize"), + incompatible_current_universe_facts: pair("服务端选币规格不兼容", "Server selection has an incompatible specification") + }); + var STATUS_LABELS = Object.freeze({ + ready: pair("已处理", "Processed"), + warming: pair("预热中", "Warming"), + stale: pair("已过期", "Stale"), + insufficient_history: pair("历史不足", "Insufficient history"), + data_gap: pair("数据缺口", "Data gap"), + failed: pair("失败", "Failed") + }); + var SIGNAL_LABELS = Object.freeze({ + "bearish:warning": pair("看跌预警", "Bearish warning"), + "bearish:confirmed": pair("看跌确认", "Bearish confirmed"), + "bearish:reversal": pair("多头反转", "Long reversal"), + "bullish:warning": pair("看涨预警", "Bullish warning"), + "bullish:confirmed": pair("看涨确认", "Bullish confirmed"), + "bullish:reversal": pair("空头反转", "Short reversal") + }); + var PROCESSING_REASONS = Object.freeze({ + current: pair("进度已更新", "Progress current"), + awaiting_producer_generation: pair("等待当前批次行情就绪", "Awaiting producer generation"), + awaiting_initial_baseline: pair("等待建立初始历史基线", "Awaiting initial baseline"), + awaiting_reentry_baseline: pair("等待重新入选的历史基线", "Awaiting re-entry baseline"), + latest_closed_candle_missing: pair("缺少最新已收盘 K 线", "Latest closed candle missing"), + observer_progress_stale: pair("观察器进度已过期", "Observer progress stale") + }); + function processingReason(reason, locale) { + if (Object.hasOwn(PROCESSING_REASONS, reason)) return formatLocalizedText(PROCESSING_REASONS[reason], locale); + const required = /^requires_(\d+)_closed_candles$/.exec(reason); + if (required) return formatLocalizedText(pair(`需要 ${required[1]} 根已收盘 K 线`, `Requires ${required[1]} closed candles`), locale); + return formatLocalizedText(pair(`技术详情:${reason}`, `Details: ${reason}`), locale); + } + + // src/binance-strategy29-bollinger/dom/panel-position.js + function installPanelPosition(document, panel, header, { initialPosition, savePosition }) { + const view = document.defaultView; + if (!view) throw new Error("Strategy 29 panel window is unavailable"); + let position = initialPosition ?? { left: view.innerWidth - panel.getBoundingClientRect().width - 84, top: 68 }; + let drag = null; + function apply(next) { + const rect = panel.getBoundingClientRect(); + position = { + left: Math.max(0, Math.min(next.left, Math.max(0, view.innerWidth - rect.width))), + top: Math.max(0, Math.min(next.top, Math.max(0, view.innerHeight - rect.height))) + }; + panel.style.left = `${position.left}px`; + panel.style.top = `${position.top}px`; + panel.style.right = "auto"; + } + function clamp() { + apply(position); + } + function onDown(event) { + if (event.button !== 0 || event.target.closest("button,a")) return; + const rect = panel.getBoundingClientRect(); + drag = { x: event.clientX, y: event.clientY, left: rect.left, top: rect.top }; + event.preventDefault(); + } + function onMove(event) { + if (!drag) return; + apply({ left: drag.left + event.clientX - drag.x, top: drag.top + event.clientY - drag.y }); + } + function finish() { + if (!drag) return; + drag = null; + clamp(); + savePosition({ ...position }); + } + clamp(); + header.style.cursor = "move"; + header.addEventListener("mousedown", onDown); + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", finish); + view.addEventListener("blur", finish); + view.addEventListener("resize", clamp); + return Object.freeze({ + clamp, + destroy() { + drag = null; + header.removeEventListener("mousedown", onDown); + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", finish); + view.removeEventListener("blur", finish); + view.removeEventListener("resize", clamp); + } + }); + } + + // 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: "#848E9C", + warming: "#F0B90B", + stale: "#F6465D", + insufficient_history: "#F0B90B", + data_gap: "#F6465D", + failed: "#F6465D" + }); + 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 createStrategy29SummaryPanel(document, canonicalSymbol, { maxEvents = 20, locale = resolveUiLocaleFromPathname(document.location.pathname), loadPosition, savePosition } = {}) { + 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"); + const text = (value) => formatLocalizedText(value, locale); + text(SUMMARY_COPY.waiting); + if (typeof loadPosition !== "function" || typeof savePosition !== "function") throw new TypeError("Strategy 29 panel position adapters are required"); + const stored = loadPosition(); + if (stored !== null && (!stored || typeof stored !== "object" || !Number.isFinite(stored.left) || !Number.isFinite(stored.top))) throw new TypeError("Strategy 29 panel position is invalid"); + document.getElementById(PANEL_ID)?.remove(); + const panel = element(document, "section", { + styles: { + position: "fixed", + zIndex: "999995", + left: "0", + top: "0", + 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)" } + }); + const heading = element(document, "strong", { text: text(SUMMARY_COPY.title), styles: { flex: "1", fontSize: "13px" } }); + header.appendChild(element(document, "span", { text: "☰", styles: { color: "#848E9C" } })); + header.appendChild(heading); + header.title = text(SUMMARY_COPY.drag); + const collapse = element(document, "button", { + text: text(SUMMARY_COPY.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: text(SUMMARY_COPY.waiting), role: "connection", styles: { color: "#848E9C", fontSize: "11px" } }); + const spec = element(document, "div", { text: text(SUMMARY_COPY.observerSpec(STRATEGY29_SPEC_VERSION)), role: "spec", styles: { color: "#848E9C", fontSize: "11px" } }); + const reference = element(document, "div", { text: text(SUMMARY_COPY.reference(STRATEGY29_REFERENCE_SHA256)), role: "reference", styles: { color: "#848E9C", fontSize: "10px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", userSelect: "text" } }); + const statusFreshness = element(document, "div", { text: text(SUMMARY_COPY.noStatus), role: "status-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); + const eventsFreshness = element(document, "div", { text: text(SUMMARY_COPY.noEventsCheck), role: "events-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); + const selection = element(document, "div", { role: "selection", styles: { color: "#EAECEF", fontSize: "11px" } }); + const selectionRefresh = element(document, "div", { role: "selection-refresh", styles: { color: "#848E9C", fontSize: "11px" } }); + overview.append(connection, spec, reference, statusFreshness, selection, selectionRefresh, eventsFreshness); + const unitsTitle = element(document, "div", { text: text(SUMMARY_COPY.processing), styles: { padding: "7px 10px 4px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontWeight: "600" } }); + const processingHint = element(document, "div", { + text: text(SUMMARY_COPY.processingHint), + styles: { fontSize: "11px", fontWeight: "400" } + }); + unitsTitle.appendChild(processingHint); + const units = element(document, "div", { role: "units", styles: { display: "grid", gap: "3px", padding: "0 7px 8px" } }); + const delivery = element(document, "div", { text: text(SUMMARY_COPY.waitingDelivery), role: "delivery", styles: { padding: "7px 10px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontSize: "11px" } }); + const eventsTitle = element(document, "div", { text: text(SUMMARY_COPY.recent), 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 position = installPanelPosition(document, panel, header, { initialPosition: stored, savePosition }); + const eventRecords = /* @__PURE__ */ new Map(); + let lastStatus = null; + let lastEventsAt = null; + let connectionCopy = SUMMARY_COPY.waiting; + 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.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: text(SIGNAL_LABELS[`${event.setup_direction}:${event.signal_type}`]), + styles: { color: event.signal_side === "long" ? "#0ECB81" : "#F6465D", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } + })); + row.appendChild(element(document, "span", { text: text(SUMMARY_COPY.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: text(SUMMARY_COPY.noEvents), styles: { color: "#848E9C", padding: "4px" } })); + } + collapse.addEventListener("click", () => { + const collapsed = body.style.display !== "none"; + body.style.display = collapsed ? "none" : "block"; + collapse.textContent = text(collapsed ? SUMMARY_COPY.expand : SUMMARY_COPY.collapse); + position.clamp(); + }); + renderEvents(); + const api = Object.freeze({ + setLocale(nextLocale) { + assertLive(); + formatLocalizedText(SUMMARY_COPY.waiting, nextLocale); + if (locale === nextLocale) return; + locale = nextLocale; + heading.textContent = text(SUMMARY_COPY.title); + header.title = text(SUMMARY_COPY.drag); + collapse.textContent = text(body.style.display === "none" ? SUMMARY_COPY.expand : SUMMARY_COPY.collapse); + connection.textContent = text(connectionCopy); + reference.textContent = text(SUMMARY_COPY.reference(STRATEGY29_REFERENCE_SHA256)); + unitsTitle.firstChild.textContent = text(SUMMARY_COPY.processing); + processingHint.textContent = text(SUMMARY_COPY.processingHint); + eventsTitle.textContent = text(SUMMARY_COPY.recent); + eventsFreshness.textContent = text(lastEventsAt === null ? SUMMARY_COPY.noEventsCheck : SUMMARY_COPY.eventsAt(formatClock(lastEventsAt))); + if (lastStatus !== null) api.renderStatus(lastStatus); + else { + spec.textContent = text(SUMMARY_COPY.observerSpec(STRATEGY29_SPEC_VERSION)); + statusFreshness.textContent = text(SUMMARY_COPY.noStatus); + delivery.textContent = text(SUMMARY_COPY.waitingDelivery); + } + renderEvents(); + position.clamp(); + }, + setConnection(state, message) { + assertLive(); + if (!(state in STATE_COLORS)) throw new Error("Strategy 29 panel connection state is invalid"); + connection.dataset.state = state; + connection.style.color = STATE_COLORS[state]; + connectionCopy = message; + connection.textContent = text(message); + position.clamp(); + }, + renderStatus(snapshot) { + assertLive(); + lastStatus = snapshot; + const matched = snapshot.spec_version === STRATEGY29_SPEC_VERSION; + spec.dataset.state = matched ? "matched" : "error"; + spec.style.color = matched ? "#0ECB81" : "#F6465D"; + spec.textContent = matched ? text(SUMMARY_COPY.matched(STRATEGY29_SPEC_VERSION)) : text(SUMMARY_COPY.mismatch(STRATEGY29_SPEC_VERSION, snapshot.spec_version)); + statusFreshness.textContent = text(SUMMARY_COPY.statusAt(formatClock(snapshot.observed_at_ms))); + if (!matched) { + selection.dataset.state = "incompatible"; + selection.style.color = "#F6465D"; + selection.textContent = text(SUMMARY_COPY.incompatibleSelection); + selectionRefresh.textContent = ""; + units.replaceChildren(); + delivery.textContent = text(SUMMARY_COPY.incompatibleDelivery); + position.clamp(); + return; + } + const universe = snapshot.universe; + const unavailable = universe.refresh_status === "fail_closed"; + selection.dataset.state = universe.refresh_status; + selection.style.color = unavailable ? "#F6465D" : universe.refresh_status === "fresh" ? "#0ECB81" : "#F0B90B"; + selection.textContent = [text(SELECTION_REASONS[universe.reason]), text(SUMMARY_COPY.generation(universe.generation ?? text(SUMMARY_COPY.pending))), text(SUMMARY_COPY.markets(universe.selected_markets.length)), text(SUMMARY_COPY.ready(universe.ready_unit_count, universe.selected_unit_count)), text(SUMMARY_COPY.intervals(universe.configured_timeframes.join(", ") || text(SUMMARY_COPY.pending)))].join(" · "); + selectionRefresh.textContent = universe.last_successful_refreshed_at_ms === null ? text(SUMMARY_COPY.noSuccess) : text(SUMMARY_COPY.lastSuccess(formatClock(universe.last_successful_refreshed_at_ms), universe.last_success_age_seconds.toFixed(1))); + units.replaceChildren(); + const selected = universe.selected_markets.includes(canonicalSymbol); + const matching = unavailable || !selected ? [] : snapshot.units.filter((unit) => unit.symbol === canonicalSymbol && universe.configured_timeframes.includes(unit.timeframe)); + for (const unit of matching) { + const row = element(document, "div", { + role: "unit", + styles: { display: "grid", gridTemplateColumns: "36px 78px 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: text(STATUS_LABELS[unit.status]), styles: { color: STATUS_COLORS[unit.status] } })); + row.appendChild(element(document, "span", { text: processingReason(unit.reason, locale), styles: { color: "#848E9C", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } })); + units.appendChild(row); + } + if (matching.length === 0) units.appendChild(element(document, "span", { + text: text(unavailable ? SUMMARY_COPY.unavailableSelection : selected ? SUMMARY_COPY.awaitingUnits : SUMMARY_COPY.notSelected), + styles: { color: "#F0B90B", padding: "4px" } + })); + const counts = snapshot.delivery_counts; + delivery.textContent = text(SUMMARY_COPY.delivery(counts)); + position.clamp(); + }, + addEvents(incoming, observedAtMs = null) { + assertLive(); + for (const event of incoming) eventRecords.set(event.event_id, event); + const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence); + while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id); + if (observedAtMs !== null) { + lastEventsAt = observedAtMs; + eventsFreshness.textContent = text(SUMMARY_COPY.eventsAt(formatClock(observedAtMs))); + } + renderEvents(); + position.clamp(); + }, + clearEvents() { + assertLive(); + eventRecords.clear(); + renderEvents(); + position.clamp(); + }, + destroy() { + if (destroyed) return; + destroyed = true; + eventRecords.clear(); + position.destroy(); + panel.remove(); + }, + get size() { + return eventRecords.size; + } + }); + position.clamp(); + return api; + } + + // src/binance-strategy29-bollinger/remote-summary.js + var STRATEGY29_PANEL_POSITION_KEY = "strategy29SummaryPanelPosition"; + 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; + let locale = resolveUiLocaleFromPathname(view.location.pathname); + const text = (value) => formatLocalizedText(value, locale); + 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, + locale, + loadPosition: () => getValue(STRATEGY29_PANEL_POSITION_KEY, null), + savePosition: (position) => setValue(STRATEGY29_PANEL_POSITION_KEY, position) + }); + 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", SUMMARY_COPY.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", SUMMARY_COPY.configuration); + 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", SUMMARY_COPY.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 || view.document.hidden) return; + synchronizeLocale(); + const context = synchronizeContext(); + if (!context || !context.client || context.inFlight || context.failed || nowMs < context.nextPollAtMs) return; + if (context.abortController.signal.aborted) { + const AbortControllerConstructor = view.AbortController ?? AbortController; + context.abortController = new AbortControllerConstructor(); + } + const controller = context.abortController; + const ownsRequest = () => isCurrent(context) && context.abortController === controller; + context.nextPollAtMs = nowMs + pollIntervalMs; + context.inFlight = true; + context.state = "connecting"; + context.panel.setConnection("connecting", SUMMARY_COPY.connecting); + return context.client.poll(controller.signal).then((result) => { + if (!ownsRequest()) return; + context.lastResult = result; + context.lastError = null; + context.state = result.state; + const presentation = { + connected: ["connected", result.hasMore ? SUMMARY_COPY.moreHistory : SUMMARY_COPY.connected], + unavailable: ["unavailable", SUMMARY_COPY.unavailable], + incompatible: ["incompatible", SUMMARY_COPY.incompatible] + }[result.state]; + if (!presentation) throw new Error(`Strategy 29 remote state is invalid: ${result.state}`); + context.panel.setConnection(...presentation); + }).catch((error) => { + if (!ownsRequest() || error?.name === "AbortError") return; + context.lastError = error.message; + if (error instanceof Strategy29GatewayTransportError) { + context.state = "disconnected"; + context.panel.setConnection("disconnected", SUMMARY_COPY.disconnected); + } else { + context.state = "stopped"; + context.failed = true; + context.panel.setConnection("stopped", SUMMARY_COPY.stopped(error.message)); + } + view.console.warn("[Strategy29 remote]", error.message); + }).finally(() => { + if (ownsRequest()) context.inFlight = false; + }); + } + function restart() { + unsupportedRoute = null; + stopActive("Strategy 29 remote settings changed"); + if (!disposed) void sample(Date.now()); + } + const menus = [ + { copy: SUMMARY_COPY.menuToggle, run() { + enabled = !enabled; + setValue(STRATEGY29_REMOTE_ENABLED_KEY, enabled); + restart(); + } }, + { copy: SUMMARY_COPY.menuSecret, run() { + const value = promptUser2(formatLocalizedText(SUMMARY_COPY.promptSecret, resolveUiLocaleFromPathname(view.location.pathname))); + if (value === null) return; + if (value.length === 0) throw new Error(text(SUMMARY_COPY.emptySecret)); + setValue(STRATEGY29_GATEWAY_SECRET_KEY, value); + restart(); + } }, + { copy: SUMMARY_COPY.menuOrigin, run() { + const current = getValue(STRATEGY29_GATEWAY_ORIGIN_KEY, STRATEGY29_DEFAULT_GATEWAY_ORIGIN); + const value = promptUser2(formatLocalizedText(SUMMARY_COPY.promptOrigin, resolveUiLocaleFromPathname(view.location.pathname)), current); + if (value === null) return; + setValue(STRATEGY29_GATEWAY_ORIGIN_KEY, normalizeStrategy29GatewayOrigin(value)); + restart(); + } } + ]; + for (const menu of menus) menu.id = registerMenuCommand(text(menu.copy), menu.run); + function synchronizeLocale() { + const current = resolveUiLocaleFromPathname(view.location.pathname); + if (current === locale) return; + locale = current; + for (const menu of menus) menu.id = registerMenuCommand(text(menu.copy), menu.run, { id: menu.id }); + active?.panel.setLocale(locale); + } + return Object.freeze({ + sample, + pause() { + if (!active) return; + active.abortController.abort(abortError(view, "Strategy 29 remote summary paused")); + active.inFlight = false; + active.nextPollAtMs = 0; + }, + 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 = SUMMARY_COPY.conflict; + function hasEmbeddedBollinger(view) { + const debug = view.__TM_CLOSE_LONG_DEBUG__; + return !!debug && Object.getOwnPropertyDescriptor(debug, "bollingerAlertState") !== void 0; + } + function installStrategy29(view, remoteAdapters = null) { + if (view[INSTANCE] !== void 0) { + if (view[INSTANCE].version !== RUNTIME_VERSION) throw new Error("Incompatible Strategy 29 runtime; reload the page"); + return view[INSTANCE].runtime; + } + const document = view.document; + let timer = null; + 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; + let notice = document.getElementById(noticeId); + if (!notice) { + notice = document.createElement("div"); + notice.id = noticeId; + notice.setAttribute("role", "status"); + notice.style.cssText = "position:fixed;left:16px;bottom:16px;z-index:10000;max-width:420px;padding:10px;background:#332b16;color:#ffcf67;font:13px sans-serif;pointer-events:none"; + document.body.append(notice); + } + notice.textContent = formatLocalizedText(failed, resolveUiLocaleFromPathname(view.location.pathname)); + } + const monitor = createBollingerMonitor({ + document, + getCurrentSymbol: () => parseFuturesTradingSymbolFromPathname(view.location.pathname), + isFuturesTradingPage: () => !disposed && !failed && isFuturesTradingPathname(view.location.pathname), + isTradingViewDrawingMutationBusy: () => hasEmbeddedBollinger(view) || isChartMutationBlocked(view), + err: (...args) => view.console.error("[Strategy29]", ...args), + warn: (...args) => view.console.warn("[Strategy29]", ...args) + }); + function pause() { + if (timer !== null) view.clearInterval(timer); + timer = null; + monitor.stop(); + remoteSummary?.pause(); + } + function fail(message) { + failed = message; + pause(); + remoteSummary?.dispose(); + showFailure(); + } + function sample() { + if (disposed || document.hidden) return; + if (failed) { + showFailure(); + return; + } + if (hasEmbeddedBollinger(view)) { + fail(CONFLICT); + return; + } + ensureSpaRouteChangePatched(view); + void remoteSummary?.sample(Date.now()); + if (!isFuturesTradingPathname(view.location.pathname)) { + monitor.stop(); + return; + } + void monitor.tick().catch((error) => fail(SUMMARY_COPY.localStopped(error.message))); + } + function resume() { + if (disposed || failed || document.hidden) return; + sample(); + if (!failed && timer === null) timer = view.setInterval(sample, 1e3); + } + function onVisibility() { + if (document.hidden) pause(); + else resume(); + } + function onPageHide(event) { + if (event.persisted) pause(); + else runtime.dispose(); + } + function onPageShow() { + resume(); + } + const runtime = Object.freeze({ + get diagnostics() { + return { + ...monitor.diagnostics, + runtimeFailure: failed === null ? null : formatLocalizedText(failed, "en"), + 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); + view.removeEventListener("pagehide", onPageHide); + view.removeEventListener("pageshow", onPageShow); + document.getElementById(noticeId)?.remove(); + } + }); + 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); + document.addEventListener("DOMContentLoaded", showFailure, { once: true }); + view.addEventListener("pagehide", onPageHide); + view.addEventListener("pageshow", onPageShow); + resume(); + return runtime; + } + + // src/binance-strategy29-bollinger/index.user.js + 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/test/manual/strategy29-summary-panel-preview.mjs b/test/manual/strategy29-summary-panel-preview.mjs index 8203d7b..4ce9827 100644 --- a/test/manual/strategy29-summary-panel-preview.mjs +++ b/test/manual/strategy29-summary-panel-preview.mjs @@ -83,6 +83,12 @@ try { await page.evaluate(() => window.fixturePanel.setLocale('en')); assert.match(await panel.textContent(), /Bearish warning/); assert.equal(await panel.locator('[data-role="remote-event"]').count(), 2); + await page.evaluate(() => { + window.fixturePanel.setLocale('zh-CN'); + window.fixturePanel.setConnection('module_disabled', { zhCN: '服务端尚未启用 Strategy 29 监控汇总', en: 'Strategy 29 monitoring summary is not enabled on the server' }); + }); + assert.match(await panel.locator('[data-role="connection"]').textContent(), /服务端尚未启用/); + await panel.screenshot({ path: join(output, 'chinese-module-disabled.png') }); assert.deepEqual(errors, []); console.log(JSON.stringify({ output, checked: ['two-timeframe-status', 'two-events', 'global-delivery-label', 'separate-freshness', 'collapse', 'viewport', 'Chinese-English-retained-state', 'header-drag-position-storage', 'narrow-viewport'], pageErrors: errors })); } finally { diff --git a/test/unit/binance-signal-client-settings.test.js b/test/unit/binance-signal-client-settings.test.js new file mode 100644 index 0000000..898a9d9 --- /dev/null +++ b/test/unit/binance-signal-client-settings.test.js @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { readSignalGatewaySettings } from '../../src/shared/signal-client-settings.js'; +import { migrateStrategy29Preferences, isStrategy29CompanionReady } from '../../src/shared/strategy29-preferences-migration.js'; + +test('signal settings retain the existing private installation keys', () => { + const read = []; + const settings = readSignalGatewaySettings((key, initial) => { + read.push(key); + return key === 'strategy27GatewayAuthSecret' ? 'synthetic-existing-secret' : initial; + }); + assert.deepEqual(read, ['strategy27GatewayAuthSecret', 'strategy27GatewayOrigin']); + assert.deepEqual(settings, { authSecret: 'synthetic-existing-secret', gatewayOrigin: 'http://127.0.0.1:18765' }); +}); + +for (const forbidden of ['secret', 'origin', 'cursor', 'events', 'Authorization']) { + test(`migration rejects the unexpected ${forbidden} field before private writes`, () => { + const view = { [Symbol.for('jh-userscripts.strategy29-preferences-migration')]: { + version: 1, enabled: true, position: null, [forbidden]: 'synthetic-value', + } }; + let writes = 0; + assert.throws(() => migrateStrategy29Preferences(view, (_key, initial) => initial, () => { writes += 1; }), /record is invalid/); + assert.equal(writes, 0); + }); +} + +for (const order of [[27, 29], [29, 27]]) { + for (const migrated of [false, true]) { + test(`generated clients share one private gateway with load order ${order.join('-')} and migrated=${migrated}`, async () => { + const dom = new JSDOM('', { url: 'https://www.binance.com/zh-CN/futures/ARBUSDT', pretendToBeVisual: true }); + const page = dom.window; + page.prompt = () => { throw new Error('Private prompt crossed the page boundary'); }; + const hostValues = new Map([['strategy27GatewayAuthSecret', 'synthetic-existing-secret']]); + if (migrated) { + hostValues.set('strategy29UnifiedPreferencesMigrated', true); + hostValues.set('strategy29RemoteSummaryEnabled', true); + hostValues.set('strategy29SummaryPanelPosition', { left: 60, top: 70 }); + } + const localValues = new Map([ + ['strategy29RemoteSummaryEnabled', true], ['strategy29SummaryPanelPosition', { left: 30, top: 40 }], + ['strategy29GatewayAuthSecret', 'synthetic-obsolete-secret'], + ]); + const requests = []; + const menus = new Map(); + const timers = new Map(); + page.setInterval = callback => { const id = timers.size + 1; timers.set(id, callback); return id; }; + page.clearInterval = id => timers.delete(id); + for (const id of order) { + const values = id === 27 ? hostValues : localValues; + const sandbox = { + unsafeWindow: page, prompt: () => null, URL, AbortController, DOMException, console, + GM_getValue: (key, initial) => values.has(key) ? values.get(key) : initial, + GM_setValue: (key, value) => { assert.equal(id, 27); values.set(key, value); }, + GM_registerMenuCommand: (label, run, options = {}) => { + assert.equal(id, 27); + const menuId = options.id ?? menus.size + 1; + menus.set(menuId, { label, run }); + return menuId; + }, + GM_xmlhttpRequest: options => { + assert.equal(id, 27); + requests.push(options); + queueMicrotask(() => options.onload({ status: 503, responseText: JSON.stringify({ schema_version: 1, error: 'module_disabled', strategy_id: '29', status: 'disabled' }) })); + return { abort() { options.onabort(); } }; + }, + }; + const name = id === 27 ? 'binance-strategy27-events' : 'binance-strategy29-bollinger'; + vm.runInNewContext(await readFile(new URL(`../../scripts/${name}.user.js`, import.meta.url), 'utf8'), sandbox); + await new Promise(setImmediate); + if (id === 27 && order[0] === 27) { + page.document.dispatchEvent(new page.Event('visibilitychange')); + page.dispatchEvent(new page.Event('pageshow')); + [...menus.values()].find(menu => menu.label.includes('重新连接')).run(); + assert.equal(page.__TM_SIGNAL_CLIENT_DEBUG__.strategy29.state, 'waiting_for_companion'); + assert.equal(page.__TM_SIGNAL_CLIENT_DEBUG__.strategy29.moduleFailure, null); + assert.equal(requests.length, 0); + } + } + page.dispatchEvent(new page.Event('jh-strategy29-preferences-ready')); + await new Promise(setImmediate); + assert.equal(requests.length, 1); + assert.equal(requests[0].url, 'http://127.0.0.1:18765/v1/strategy29/status'); + assert.equal(requests[0].headers.Authorization, 'Bearer synthetic-existing-secret'); + assert.equal(page.__TM_SIGNAL_CLIENT_DEBUG__.strategy29.state, 'module_disabled'); + assert.equal(page.document.querySelectorAll('#jh-strategy29-summary-panel').length, 1); + assert.match(page.document.querySelector('#jh-strategy29-summary-panel').textContent, /服务端尚未启用/); + assert.equal(hostValues.get('strategy29SummaryPanelPosition').left, migrated ? 60 : 30); + assert.equal(hostValues.get('strategy29SummaryPanelPosition').top, migrated ? 70 : 40); + assert.equal(hostValues.get('strategy29RemoteSummaryEnabled'), true); + assert.equal(hostValues.get('strategy27GatewayAuthSecret'), 'synthetic-existing-secret'); + assert.equal(hostValues.has('strategy29GatewayAuthSecret'), false); + const labels = [...menus.values()].map(menu => menu.label); + assert.deepEqual(labels.filter(label => label.includes('网关密钥')), ['设置 CorsairQuant 网关密钥']); + assert.equal(timers.size, 2); + assert.equal(page.document.getElementById('jh-strategy29-client-upgrade'), null); + assert.doesNotMatch(JSON.stringify(page[Symbol.for('jh-userscripts.strategy29-preferences-migration')]), /secret|origin|cursor|events/i); + page.dispatchEvent(new page.Event('beforeunload')); + page.__TM_STRATEGY29_DEBUG__.dispose(); + assert.equal(timers.size, 0); + dom.window.close(); + }); + } +} + +test('completed migration does not bypass readiness validation', () => { + const page = { [Symbol.for('jh-userscripts.strategy29-preferences-migration')]: { version: 1, secret: 'synthetic' } }; + assert.throws(() => isStrategy29CompanionReady(page), /invalid/); + assert.throws(() => migrateStrategy29Preferences(page, () => true, () => assert.fail('write')), /invalid/); +}); + +/** Frozen released entry reproduces real ownership during independently scheduled Tampermonkey updates. */ +for (const order of [['legacy', 'host'], ['host', 'legacy']]) { + test(`partial upgrade preserves the legacy remote owner: ${order.join('-')}`, async (t) => { + const dom = new JSDOM('', { url: 'https://www.binance.com/zh-CN/futures/ARBUSDT', pretendToBeVisual: true }); + const page = dom.window; + const requests = []; + const timers = new Map(); + const menus = new Map(); + let timerId = 0; + page.setInterval = callback => { timers.set(++timerId, callback); return timerId; }; + page.clearInterval = id => timers.delete(id); + t.after(() => { page.dispatchEvent(new page.Event('beforeunload')); page.__TM_STRATEGY29_DEBUG__.dispose(); page.close(); }); + async function run(kind) { + const path = kind === 'legacy' ? '../fixtures/strategy29-migration/legacy-0.3.0.user.js' + : kind === 'local' ? '../../scripts/binance-strategy29-bollinger.user.js' : '../../scripts/binance-strategy27-events.user.js'; + vm.runInNewContext(await readFile(new URL(path, import.meta.url), 'utf8'), { + unsafeWindow: page, prompt: () => null, URL, AbortController, DOMException, console, + GM_getValue: (key, initial) => key.endsWith('AuthSecret') ? 'synthetic-existing-secret' : key === 'strategy29RemoteSummaryEnabled' ? true : initial, + GM_setValue: () => assert.fail('No migration is possible with the legacy owner'), + GM_registerMenuCommand: (label, run, options = {}) => { const id = options.id ?? menus.size + 1; menus.set(id, { kind, label, run }); return id; }, + GM_xmlhttpRequest: options => { requests.push({ kind, options }); return { abort() { options.onabort(); } }; }, + }); + await new Promise(setImmediate); + } + let legacyPanel; + for (const kind of order) { + await run(kind); + if (kind === 'legacy') legacyPanel = page.document.getElementById('jh-strategy29-summary-panel'); + } + assert.equal(page.document.getElementById('jh-strategy29-summary-panel'), legacyPanel); + assert.equal(page.document.querySelectorAll('#jh-strategy29-summary-panel').length, 1); + assert.equal(requests.filter(r => r.kind === 'legacy').length, 1); + assert.equal(requests.filter(r => r.kind === 'host').length, 0); + assert.equal([...menus.values()].filter(m => m.kind === 'host').length, 4); + assert.equal(page.__TM_SIGNAL_CLIENT_DEBUG__.strategy29.state, 'waiting_for_companion'); + page.document.dispatchEvent(new page.Event('visibilitychange')); + page.dispatchEvent(new page.PageTransitionEvent('pagehide', { persisted: true })); + page.dispatchEvent(new page.Event('pageshow')); + [...menus.values()].find(m => m.kind === 'host' && m.label.includes('重新连接')).run(); + for (const callback of timers.values()) callback(); + assert.equal(page.__TM_SIGNAL_CLIENT_DEBUG__.strategy29.moduleFailure, null); + assert.equal(requests.filter(r => r.kind === 'host').length, 0); + await assert.rejects(run('local'), /Incompatible Strategy 29 runtime; reload/); + assert.equal(isStrategy29CompanionReady(page), false); + assert.equal(page.document.getElementById('jh-strategy29-summary-panel'), legacyPanel); + }); +} diff --git a/test/unit/binance-strategy29-bollinger/entry-sandbox.test.js b/test/unit/binance-strategy29-bollinger/entry-sandbox.test.js index 4a284e8..d3ef9cf 100644 --- a/test/unit/binance-strategy29-bollinger/entry-sandbox.test.js +++ b/test/unit/binance-strategy29-bollinger/entry-sandbox.test.js @@ -12,7 +12,7 @@ async function bundledEntry() { } 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 dom = new JSDOM('', { url: 'https://www.binance.com/en/futures/BTRUSDT', pretendToBeVisual: true }); const menus = []; const stored = new Map(); let pagePromptCalls = 0; @@ -32,10 +32,19 @@ test('sandbox entry installs the runtime on unsafeWindow and keeps the shared pa 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(dom.window[Symbol.for('jh-userscripts.strategy29-bollinger')].version, 3); + assert.deepEqual(menus, []); + assert.equal(stored.size, 0); + assert.deepEqual(JSON.parse(JSON.stringify(dom.window[Symbol.for('jh-userscripts.strategy29-preferences-migration')])), { + version: 1, enabled: false, position: null, + }); assert.equal(pagePromptCalls, 0); + assert.match(dom.window.document.getElementById('jh-strategy29-client-upgrade').textContent, /Update or install the Strategy 27 signal client/); + assert.equal(dom.window.document.getElementById('jh-strategy29-summary-panel'), null); + dom.window.history.pushState({}, '', '/zh-CN/futures/BTRUSDT'); + assert.match(dom.window.document.getElementById('jh-strategy29-client-upgrade').textContent, /更新或安装 Strategy 27 信号客户端/); + assert.equal(dom.window.__TM_STRATEGY29_DEBUG__.diagnostics.timerRunning, true); dom.window.__TM_STRATEGY29_DEBUG__.dispose(); + assert.equal(dom.window.document.getElementById('jh-strategy29-client-upgrade'), null); dom.window.close(); }); diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js index 524e89a..72e87a3 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js @@ -227,6 +227,21 @@ test('reports unavailable status separately from fatal HTTP and contract errors' await assert.rejects(transport.client.poll(new AbortController().signal), /transport failure/); }); +for (const error of ['module_disabled', 'gateway_unavailable']) { + for (const endpoint of ['status', 'events']) { + test(`${endpoint} preserves the typed ${error} state without requesting extra pages`, async () => { + const body = { schema_version: 1, error, strategy_id: '29', ...(error === 'module_disabled' ? { status: 'disabled' } : {}) }; + const fixture = clientFixture(endpoint === 'status' ? [response(body, 503)] : [response(status), response(body, 503)]); + assert.deepEqual(await fixture.client.poll(new AbortController().signal), { + state: error, pages: endpoint === 'status' ? 0 : 1, hasMore: false, + }); + assert.equal(fixture.requests.length, endpoint === 'status' ? 1 : 2); + assert.deepEqual(fixture.resets, []); + assert.deepEqual(fixture.received, []); + }); + } +} + test('exposes remote/local spec mismatch before requesting event history', async () => { const mismatch = { ...status, spec_version: 'other_spec' }; delete mismatch.universe; diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js index 1ef8610..ae0d504 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js @@ -14,6 +14,18 @@ import { const status = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-status.json', import.meta.url))); const events = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-events.json', import.meta.url))); +test('unified gateway module errors require exact identity and state fields', () => { + const disabled = { schema_version: 1, error: 'module_disabled', strategy_id: '29', status: 'disabled' }; + const unavailable = { schema_version: 1, error: 'gateway_unavailable', strategy_id: '29' }; + assert.equal(validateStrategy29GatewayError(disabled, 503), disabled); + assert.equal(validateStrategy29GatewayError(unavailable, 503), unavailable); + for (const body of [ + { ...disabled, strategy_id: '27' }, { ...disabled, status: 'running' }, + { ...unavailable, status: 'disabled' }, { ...disabled, extra: 1 }, + { schema_version: 1, error: 'module_disabled' }, + ]) assert.throws(() => validateStrategy29GatewayError(body, 503), TypeError); +}); + const METADATA_KEYS = ['generation', 'refreshed_at_ms', 'last_successful_refreshed_at_ms', 'last_success_age_seconds', 'last_refresh_error_at_ms', 'selection_expires_at_ms']; const SUCCESS_KEYS = ['last_successful_refreshed_at_ms', 'last_success_age_seconds', 'selection_expires_at_ms']; diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js index d61e0e8..5241d47 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js @@ -33,7 +33,7 @@ function fixture({ enabled = true, authSecret = 'synthetic-secret', poll } = {}) menus[id] = { label, callback }; return id; }, - promptUser: (...args) => { prompts.push(args); return null; }, + getGatewaySettings: () => ({ authSecret: values.get('strategy29GatewayAuthSecret'), gatewayOrigin: values.get('strategy29GatewayOrigin') }), createPanel: (_document, canonicalSymbol, options) => { const calls = []; const panel = { @@ -64,12 +64,12 @@ function fixture({ enabled = true, authSecret = 'synthetic-secret', poll } = {}) return { view, values, menus, prompts, panels, clients, summary }; } -test('remote summary is opt-in and registers configuration without requesting data', async () => { +test('disabled remote summary remains discoverable without requesting data or reading credentials', async () => { const f = fixture({ enabled: false }); assert.equal(f.summary.sample(0), undefined); - assert.equal(f.panels.length, 0); + assert.equal(f.panels.length, 1); assert.equal(f.clients.length, 0); - assert.equal(f.menus.length, 3); + assert.equal(f.menus.length, 1); assert.equal(f.summary.diagnostics.enabled, false); }); @@ -101,12 +101,12 @@ test('pause preserves the current client and panel and permits one resumed reque f.summary.dispose(); }); -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('remote module owns no gateway configuration menu and uses host settings', async () => { + const f = fixture(); + await f.summary.sample(0); + assert.deepEqual(f.menus.map(menu => menu.label), ['Toggle Strategy 29 cross-timeframe summary']); + assert.equal(f.clients[0].options.authSecret, 'synthetic-secret'); + assert.equal(f.clients[0].options.gatewayOrigin, 'http://127.0.0.1:8729'); }); test('polls the current route symbol independently of the visible chart interval', async () => { @@ -195,8 +195,8 @@ test('locale switches preserve the pending request, client cursor and panel whil assert.equal(f.panels[0].locale, 'zh-CN'); assert.equal(f.summary.diagnostics.cursor, 41); assert.equal(requestSignal.aborted, false); - assert.equal(f.menus.length, 3); - assert.deepEqual(f.menus.map(menu => menu.label), ['切换 Strategy 29 跨周期汇总', '设置 Strategy 29 网关密钥', '设置 Strategy 29 网关地址']); + assert.equal(f.menus.length, 1); + assert.deepEqual(f.menus.map(menu => menu.label), ['切换 Strategy 29 跨周期汇总']); complete({ state: 'connected', pages: 1, hasMore: false }); await pending; assert.deepEqual(f.panels[0].calls.at(-1), ['connection', 'connected', '已连接']); @@ -205,7 +205,7 @@ test('locale switches preserve the pending request, client cursor and panel whil assert.equal(f.panels[0].locale, 'en'); assert.equal(f.summary.diagnostics.cursor, 41); assert.equal(f.clients.length, 1); - assert.equal(f.menus.length, 3); + assert.equal(f.menus.length, 1); assert.equal(f.menus[0].label, 'Toggle Strategy 29 cross-timeframe summary'); f.summary.dispose(); }); @@ -219,8 +219,7 @@ test('no-auth locale change updates the existing panel and localized prompts wit assert.equal(f.panels[0].locale, 'zh-CN'); assert.equal(f.clients.length, 0); assert.equal(f.summary.diagnostics.state, 'configuration_required'); - f.menus[1].callback(); - assert.match(f.prompts[0][0], /请输入本地 Strategy 29 网关密钥/); + assert.deepEqual(f.menus.map(menu => menu.label), ['切换 Strategy 29 跨周期汇总']); f.summary.dispose(); }); diff --git a/test/unit/binance-strategy29-bollinger/source-regressions.test.js b/test/unit/binance-strategy29-bollinger/source-regressions.test.js index 2c0df8a..42d2a95 100644 --- a/test/unit/binance-strategy29-bollinger/source-regressions.test.js +++ b/test/unit/binance-strategy29-bollinger/source-regressions.test.js @@ -52,17 +52,17 @@ test('Bollinger chart alert failures distinguish snapshot races from contract fa }); test('Strategy29 sandbox metadata is exact and coordinates through unsafeWindow', () => { - assert.equal(readUserscriptVersion(entrySource), '0.3.0'); + assert.equal(readUserscriptVersion(entrySource), '0.4.0'); assert.deepEqual( [...entrySource.matchAll(/^\/\/ @grant\s+(\S+)\s*$/gm)].map(match => match[1]), - ['unsafeWindow', 'GM_xmlhttpRequest', 'GM_getValue', 'GM_setValue', 'GM_registerMenuCommand'], + ['unsafeWindow', 'GM_getValue'], ); 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.match(entrySource, /installStrategy29\(unsafeWindow\)/); + assert.doesNotMatch(entrySource, /prompt|GM_xmlhttpRequest/); assert.doesNotMatch(remoteSource, /view\.prompt/); assert.doesNotMatch(entrySource, /@grant\s+none/); }); diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index b2b0cf1..5ee3278 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -21,17 +21,14 @@ 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.3.0'); + assert.equal(contract.version, '0.4.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('connect'), ['127.0.0.1']); + assert.equal(metadata.get('connect'), undefined); 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') { @@ -63,7 +60,7 @@ test('release contract identifies the generated Strategy 27 annotation artifact' assert.equal(contract.name, '【自写】Binance Strategy 27 事件标注'); assert.equal(contract.namespace, 'binance.strategy27.events'); - assert.equal(contract.version, '0.4.6'); + assert.equal(contract.version, '0.5.0'); assert.equal(contract.runAt, 'document-idle'); assert.equal(contract.updateURL, contract.downloadURL); assert.deepEqual(contract.matches, [