From c32d8c155471809d0e85cb21a75e8dea7bf6ddba Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:22:18 +0800 Subject: [PATCH 1/3] feat(strategy31): display server volume reversal signals on native charts --- AGENTS.md | 3 + README.md | 5 +- README.zh-CN.md | 5 +- ...inance-strategy29-bollinger-development.md | 4 +- ...-strategy31-volume-reversal-development.md | 33 + docs/signal-gateway-architecture.zh-CN.md | 2 + package.json | 8 +- scripts/binance-strategy27-events.user.js | 11 +- scripts/binance-strategy29-bollinger.user.js | 2 +- ...binance-strategy31-volume-reversal.user.js | 938 ++++++++++++++++++ scripts/build-userscript.mjs | 4 + skills/userscript-release/SKILL.md | 1 + src/binance-strategy27-events/index.user.js | 2 +- .../index.user.js | 2 +- .../event-contract.js | 28 + .../index.user.js | 19 + .../runtime.js | 118 +++ src/shared/signal-gateway-bridge.js | 11 +- test/dom/strategy31-runtime.test.js | 166 ++++ .../source-regressions.test.js | 2 +- test/unit/coverage-report.test.js | 4 +- test/unit/signal-gateway-bridge.test.js | 3 +- test/unit/strategy31-contract.test.js | 30 + test/unit/userscript-release-contract.test.js | 4 +- 24 files changed, 1384 insertions(+), 21 deletions(-) create mode 100644 docs/binance-strategy31-volume-reversal-development.md create mode 100644 scripts/binance-strategy31-volume-reversal.user.js create mode 100644 src/binance-strategy31-volume-reversal/event-contract.js create mode 100644 src/binance-strategy31-volume-reversal/index.user.js create mode 100644 src/binance-strategy31-volume-reversal/runtime.js create mode 100644 test/dom/strategy31-runtime.test.js create mode 100644 test/unit/strategy31-contract.test.js diff --git a/AGENTS.md b/AGENTS.md index b80d800..5228e96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ | src/binance-coinmarketcap-data/ | scripts/binance-coinmarketcap-data.user.js | | src/binance-strategy27-events/ | scripts/binance-strategy27-events.user.js | | src/binance-strategy29-bollinger/ | scripts/binance-strategy29-bollinger.user.js | +| src/binance-strategy31-volume-reversal/ | scripts/binance-strategy31-volume-reversal.user.js | | src/m3u8-downloader/ | scripts/m3u8-downloader.user.js | - Other scripts remain hand-maintained under scripts/*.user.js until migrated. @@ -37,6 +38,7 @@ | Orderbook browser, Tampermonkey, CDP, performance, or live evidence | docs/binance-orderbook-trade-ui-automation.md | | Strategy 27 gateway, chart, or entity contract | docs/binance-strategy27-events-development.md | | Strategy 29 observer or cross-script chart coordination | docs/binance-strategy29-bollinger-development.md | +| Strategy 31 server signals or chart annotations | docs/binance-strategy31-volume-reversal-development.md | | Brooks/m3u8 indexing, export state, timing, or captions | docs/brooks-media-sync-workflow.md | | Trading-data, CoinMarketCap-data, auto-refresh, or cross-script validation | docs/userscript-validation.md | | Behavioral tests, test lint, affected selection, or source coverage | docs/test-policy.md | @@ -58,6 +60,7 @@ | src/binance-trading-data/**, src/binance-coinmarketcap-data/**, or src/shared/** | npm run build:binance-userscripts or the affected single-script build | | src/binance-strategy27-events/** | npm run build:binance-strategy27-events | | src/binance-strategy29-bollinger/** | npm run build:binance-strategy29-bollinger | + | src/binance-strategy31-volume-reversal/** | npm run build:binance-strategy31-volume-reversal | | src/m3u8-downloader/** | npm run build:m3u8-downloader | - Publish, ship, or merge to main only through a GitHub PR when the current diff --git a/README.md b/README.md index 6d465b1..26f1b7d 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,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 show cross-timeframe server summaries through the shared gateway | This repository | [Install][install-binance-strategy29-bollinger] | +| Binance Strategy 31 volume reversal | Binance Futures | Show server-confirmed red-to-green higher-volume candles as upward arrows | This repository | [Install](https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy31-volume-reversal.user.js) | | 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 CorsairQuant signal client | Binance Futures | Annotate Strategy27 events and provide the shared private gateway connection for Strategy29 | This repository | [Install][install-binance-strategy27-events] | +| Binance CorsairQuant signal client | Binance Futures | Annotate Strategy27 events and provide the shared private gateway connection for Strategy29 and Strategy31 | 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] | @@ -99,7 +100,7 @@ npm run build:binance-userscripts ## Maintenance Rules 1. Each script has exactly one source of truth. -2. `src/binance-orderbook-trade/`, `src/binance-trading-data/`, `src/binance-coinmarketcap-data/`, `src/binance-strategy27-events/`, `src/binance-strategy29-bollinger/`, and `src/m3u8-downloader/` are source directories for the corresponding generated scripts. +2. `src/binance-orderbook-trade/`, `src/binance-trading-data/`, `src/binance-coinmarketcap-data/`, `src/binance-strategy27-events/`, `src/binance-strategy29-bollinger/`, `src/binance-strategy31-volume-reversal/`, and `src/m3u8-downloader/` are source directories for the corresponding generated scripts. 3. Public install entry points remain generated files under `scripts/*.user.js`. 4. Do not copy script source into secondary repositories. 5. Bump `@version` when behavior changes. diff --git a/README.zh-CN.md b/README.zh-CN.md index 4983bfd..1a207fe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,10 +36,11 @@ | 脚本 | 适用场景 | 说明 | 源码真源 | 安装 | |---|---|---|---|---| | 【自写】Binance Strategy 29 布林带信号 | Binance Futures | 标注本地图表信号,并通过共享网关显示服务端跨周期汇总 | 本仓库 | [安装][install-binance-strategy29-bollinger] | +| Binance Strategy 31 放量信号 | Binance Futures | 在服务端确认的前红后绿、绿 K 放量形态下方画上涨箭头 | 本仓库 | [安装](https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy31-volume-reversal.user.js) | | 【自写】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 事件标注(CorsairQuant 信号客户端) | Binance Futures | 标注 Strategy27 事件,保存共享网关配置并为 Strategy29 提供只读连接 | 本仓库 | [`点击安装`][install-binance-strategy27-events] | +| 【自写】Binance Strategy 27 事件标注(CorsairQuant 信号客户端) | Binance Futures | 标注 Strategy27 事件,保存共享网关配置并为 Strategy29 和 Strategy31 提供只读连接 | 本仓库 | [`点击安装`][install-binance-strategy27-events] | | 【自写】定时刷新指定页面 | Any page | 指定页面按设定时间自动刷新 | 本仓库 | [`点击安装`][install-auto-refresh] | | 【自写】CoinMarketCap 估值口径命名 | CoinMarketCap | 在中文币种页面左上角统计区标注并高亮流通市值和 FDV / 总估值 | 本仓库 | [`点击安装`][install-coinmarketcap-valuation-helper] | | 【改写】m3u8-downloader | Video pages | m3u8 下载增强脚本,仅在白名单视频站启用 | 本仓库 | [`点击安装`][install-m3u8] | @@ -96,7 +97,7 @@ npm run build:binance-userscripts ## 维护规则 1. 同一脚本只允许一个真源仓库改代码。 -2. `src/binance-orderbook-trade/`、`src/binance-trading-data/`、`src/binance-coinmarketcap-data/`、`src/binance-strategy27-events/`、`src/binance-strategy29-bollinger/`、`src/m3u8-downloader/` 是对应脚本的开发真源。 +2. `src/binance-orderbook-trade/`、`src/binance-trading-data/`、`src/binance-coinmarketcap-data/`、`src/binance-strategy27-events/`、`src/binance-strategy29-bollinger/`、`src/binance-strategy31-volume-reversal/`、`src/m3u8-downloader/` 是对应脚本的开发真源。 3. 公开安装入口仍是生成后的 `scripts/*.user.js`;修改对应 `src/` 后运行 `npm run build:binance-userscripts` 或单脚本 build 命令。 4. 非真源仓库只放安装链接,不复制脚本源码。 5. 行为变更时递增 `@version`;纯文档变更不需要 bump,并保留 `@updateURL/@downloadURL` 指向真源 raw 地址。 diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index 30167e4..3f4378a 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -13,8 +13,8 @@ already-loaded native chart candles. The summary reads the authenticated unified loopback gateway; it does not call Binance market-data or account APIs, submit orders, rotate hidden charts, or add remote events as chart drawings. -Install Strategy29 0.5.5 with orderbook 2.7.199 or later, or use it alone. -Install CorsairQuant signal client 0.6.5 for the remote summary. +Install Strategy29 0.5.6 with orderbook 2.7.199 or later, or use it alone. +Install CorsairQuant signal client 0.6.6 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. diff --git a/docs/binance-strategy31-volume-reversal-development.md b/docs/binance-strategy31-volume-reversal-development.md new file mode 100644 index 0000000..f990a37 --- /dev/null +++ b/docs/binance-strategy31-volume-reversal-development.md @@ -0,0 +1,33 @@ +# Strategy31 volume reversal signals + +Source: `src/binance-strategy31-volume-reversal/`. Installer: +`scripts/binance-strategy31-volume-reversal.user.js` (0.1.0). +Install alongside CorsairQuant signal client 0.6.6, which owns private gateway +authentication. The shared bridge explicitly advertises the `strategy31` capability. + +CorsairQuant confirms consecutive closed red/green candles when green base volume +strictly exceeds red base volume. This client reads native chart candle times +only to limit rendering to loaded history; it does not detect signals locally. +It requests a bounded snapshot of up to 200 retained +events for the current canonical symbol and native timeframe every five seconds. +Hidden documents suspend requests; navigation and settings revisions invalidate +late responses. Repeated snapshots reconcile one arrow per stable event ID. +Interval changes preserve the native data-completion subscription. Unsupported +periods pause observation until a supported interval is selected. Loading earlier +chart history makes matching retained server events eligible for rendering. + +The client uses the existing native marker layer, exact interval visibility, +readback and drawing-save coordination. The green arrow anchors at the green +candle's open time and low. No exchange/account API or trading action is called. +The projection is recent retained history, not all visible chart history. + +The shared capability permits only `/v1/strategy31/events` with exactly `symbol`, +`timeframe` and `limit=200`. It does not expose credentials or an arbitrary URL +proxy. Server contract uses schema 1 and `31_2_spec_v1`. Invalid event identity, +coordinates or numeric values stop this observer with a visible status. + +Build with `npm run build:binance-strategy31-volume-reversal`; validate with +`npm run lint:tests`, `npm test`, `npm run check:binance-userscripts` and the +repository's browser suite. DOM integration tests use the shared native host +fixture, exercising actual marker rendering and stale-response rejection. +Fixture results do not establish current Binance rendering or installed code. diff --git a/docs/signal-gateway-architecture.zh-CN.md b/docs/signal-gateway-architecture.zh-CN.md index 045a280..cf89f02 100644 --- a/docs/signal-gateway-architecture.zh-CN.md +++ b/docs/signal-gateway-architecture.zh-CN.md @@ -4,6 +4,8 @@ Gateway(网关)是 **CorsairQuant 服务端的只读 HTTP 数据入口**。 用户电脑通过 SSH 本地转发访问这个入口。Strategy27 脚本保存一份网关地址和认证配置,Strategy29 复用这条连接,并管理自己的汇总面板。网关不负责下单,也不需要 Binance API Key。 +Strategy31 也复用这份连接,读取 `/v1/strategy31/events`,并在当前币种、周期的绿 K 下方画上涨箭头。它只展示服务端确认的前红后绿放量形态,不在浏览器采集成交量。每次读取最多 200 条近期信号,仅对图表已加载的 K 线绘制。服务端启用状态以部署配置为准。需要 CorsairQuant 信号客户端 0.6.6;详见 [Strategy31 开发说明](binance-strategy31-volume-reversal-development.md)。下文原有组件图描述 Strategy27/29 链路。 + 本文说明当前源码的组件关系和使用方式。图中的服务存在于代码中,不代表某台 VPS 已启动它们;服务端启用状态、浏览器安装状态和实时连通性需要分别确认。 ## 哪些脚本使用它 diff --git a/package.json b/package.json index 68afed0..919b31a 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "type": "module", "scripts": { "build:userscripts": "node scripts/build-userscript.mjs", - "build:binance-userscripts": "node scripts/build-userscript.mjs binance-orderbook-trade binance-trading-data binance-coinmarketcap-data binance-strategy27-events binance-strategy29-bollinger", + "build:binance-userscripts": "node scripts/build-userscript.mjs binance-orderbook-trade binance-trading-data binance-coinmarketcap-data binance-strategy27-events binance-strategy29-bollinger binance-strategy31-volume-reversal", "build:binance-strategy29-bollinger": "node scripts/build-userscript.mjs binance-strategy29-bollinger", "check:binance-strategy29-bollinger": "node --check scripts/binance-strategy29-bollinger.user.js", "inspect:binance-strategy29-bollinger": "node scripts/userscript-release-contract.mjs scripts/binance-strategy29-bollinger.user.js", @@ -21,7 +21,7 @@ "verify:binance-orderbook-stage3": "node scripts/binance-stage3-evidence.mjs", "assemble:binance-orderbook-live": "node scripts/binance-live-capture.mjs", "summarize:binance-orderbook-live": "node scripts/binance-live-performance.mjs", - "check:binance-userscripts": "node --check scripts/binance-orderbook-trade.user.js && node --check scripts/binance-trading-data.user.js && node --check scripts/binance-coinmarketcap-data.user.js && node --check scripts/binance-strategy27-events.user.js && node --check scripts/binance-strategy29-bollinger.user.js", + "check:binance-userscripts": "node --check scripts/binance-orderbook-trade.user.js && node --check scripts/binance-trading-data.user.js && node --check scripts/binance-coinmarketcap-data.user.js && node --check scripts/binance-strategy27-events.user.js && node --check scripts/binance-strategy29-bollinger.user.js && node --check scripts/binance-strategy31-volume-reversal.user.js", "check:binance-orderbook-trade": "node --check scripts/binance-orderbook-trade.user.js", "check:m3u8-downloader": "node --check scripts/m3u8-downloader.user.js", "test": "node --test 'test/unit/**/*.test.js' 'test/dom/**/*.test.js'", @@ -35,7 +35,9 @@ "test:binance-strategy27-events": "node --test test/unit/binance-strategy27-events/*.test.js test/dom/binance-strategy27-events/*.test.js test/unit/userscript-metadata-icons.test.js test/unit/userscript-release-contract.test.js", "test:ui": "playwright test", "test:ui:headed": "playwright test --headed", - "test:ui:debug": "playwright test --debug" + "test:ui:debug": "playwright test --debug", + "build:binance-strategy31-volume-reversal": "node scripts/build-userscript.mjs binance-strategy31-volume-reversal", + "check:binance-strategy31-volume-reversal": "node --check scripts/binance-strategy31-volume-reversal.user.js" }, "devDependencies": { "@bcoe/v8-coverage": "1.0.2", diff --git a/scripts/binance-strategy27-events.user.js b/scripts/binance-strategy27-events.user.js index 9844d59..5dc7378 100644 --- a/scripts/binance-strategy27-events.user.js +++ b/scripts/binance-strategy27-events.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.6.5 +// @version 0.6.6 // @author jackhai9 // @description Display Strategy 27 events and provide the shared private CorsairQuant gateway connection // @match https://www.binance.com/*/futures/* @@ -3046,7 +3046,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, var SIGNAL_GATEWAY_BRIDGE = Symbol.for("jh-userscripts.signal-gateway"); var MAX_RESPONSE_LENGTH = 2 * 1024 * 1024; function validateSignalGatewayPath(path) { - if (typeof path !== "string" || path.length > 2048 || !path.startsWith("/v1/strategy29/")) { + if (typeof path !== "string" || path.length > 2048 || !/^\/v1\/strategy(29|31)\//.test(path)) { throw new TypeError("Signal gateway route is not allowed"); } const url = new URL(path, "https://gateway.invalid"); @@ -3056,6 +3056,12 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, const query = url.searchParams; const keys2 = [...query.keys()]; if (new Set(keys2).size !== keys2.length) throw new TypeError("Signal gateway query contains duplicates"); + if (url.pathname === "/v1/strategy31/events") { + if (keys2.length !== 3 || !keys2.every((key) => ["symbol", "timeframe", "limit"].includes(key)) || !isCanonicalUsdtSymbol(query.get("symbol")) || query.get("limit") !== "200" || !["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"].includes(query.get("timeframe"))) { + throw new TypeError("Signal gateway Strategy31 query is invalid"); + } + return path; + } if (url.pathname === "/v1/strategy29/status" && keys2.length === 0) return path; if (url.pathname !== "/v1/strategy29/events" || !isCanonicalUsdtSymbol(query.get("symbol"))) { throw new TypeError("Signal gateway route or symbol is invalid"); @@ -3073,6 +3079,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, const pending = /* @__PURE__ */ new Set(); const api = Object.freeze({ version: 1, + capabilities: Object.freeze(["strategy29", "strategy31"]), getState() { const value = getValue(SIGNAL_GATEWAY_SECRET_KEY, ""); if (typeof value !== "string") throw new TypeError("Signal gateway configuration is invalid"); diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index ace93c4..deb62c9 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy29.bollinger // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.5.5 +// @version 0.5.6 // @author jackhai9 // @description Native Bollinger/SMA60 markers and the default read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* diff --git a/scripts/binance-strategy31-volume-reversal.user.js b/scripts/binance-strategy31-volume-reversal.user.js new file mode 100644 index 0000000..fcc4897 --- /dev/null +++ b/scripts/binance-strategy31-volume-reversal.user.js @@ -0,0 +1,938 @@ +// ==UserScript== +// @name 【自写】Binance Strategy 31 Volume Reversal +// @namespace binance.strategy31.volume-reversal +// @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E +// @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E +// @version 0.1.0 +// @author jackhai9 +// @description Confirmed red-to-green volume signals from CorsairQuant +// @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/* +// @updateURL https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy31-volume-reversal.user.js +// @downloadURL https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy31-volume-reversal.user.js +// @run-at document-start +// @grant unsafeWindow +// ==/UserScript== +(() => { + // src/shared/signal-gateway-bridge.js + var SIGNAL_GATEWAY_BRIDGE = Symbol.for("jh-userscripts.signal-gateway"); + var MAX_RESPONSE_LENGTH = 2 * 1024 * 1024; + + // src/shared/binance-symbol.js + var BINANCE_SYMBOL_CHARACTERS = "\\p{L}\\p{N}_"; + var SYMBOL_PATTERN = new RegExp(`^[${BINANCE_SYMBOL_CHARACTERS}]+$`, "u"); + + // src/shared/binance-futures-route.js + var FUTURES_TRADING_PATH_RE = /^\/(?:[a-z]{2}(?:-[A-Za-z]{2})?\/)?futures\/([^/]+)\/?$/; + var TRADING_SYMBOL_RE = new RegExp(`^[${BINANCE_SYMBOL_CHARACTERS}]{3,}$`, "u"); + function parseFuturesTradingSymbolFromPathname(pathname) { + const normalized = String(pathname || "").split(/[?#]/, 1)[0]; + const match = normalized.match(FUTURES_TRADING_PATH_RE); + if (!match || match[0] !== normalized) return null; + let symbol; + try { + symbol = decodeURIComponent(match[1]); + } catch (error) { + if (error instanceof URIError) return null; + throw error; + } + const symbolMatch = symbol.match(TRADING_SYMBOL_RE); + return symbolMatch && symbolMatch[0] === symbol ? symbol.toUpperCase() : null; + } + + // 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/tradingview-target.js + var CHART_ROOT_SELECTOR = ".chart-widget-root"; + function hasVisibleBox(element) { + if (!element?.getClientRects().length) return false; + const rect = element.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/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/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 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); + } + 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: 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 createBollingerMarkerLayer(target, options) { + return createMarkerLayer(target, void 0, options); + } + + // src/binance-strategy31-volume-reversal/event-contract.js + var STRATEGY31_PERIODS = Object.freeze({ + "1m": 60, + "3m": 180, + "5m": 300, + "15m": 900, + "30m": 1800, + "1h": 3600, + "2h": 7200, + "4h": 14400, + "6h": 21600, + "8h": 28800, + "12h": 43200, + "1d": 86400, + "3d": 259200, + "1w": 604800 + }); + function parseStrategy31Events(payload, symbol, timeframe) { + const interval = STRATEGY31_PERIODS[timeframe] * 1e3; + if (!interval || payload?.schema_version !== 1 || payload.strategy_id !== "31" || payload.spec_version !== "31_2_spec_v1" || payload.symbol !== symbol || payload.timeframe !== timeframe || !Number.isSafeInteger(payload.observed_at_ms) || !Array.isArray(payload.events) || payload.events.length > 200) { + throw new TypeError("Invalid Strategy31 event snapshot"); + } + let previousOpen = -1; + return payload.events.map((event) => { + const anchor = timeframe === "1w" ? 3456e5 : 0; + if (event.symbol !== symbol || event.timeframe !== timeframe || !Number.isSafeInteger(event.bar_open_ms) || event.bar_open_ms <= previousOpen || (event.bar_open_ms - anchor) % interval !== 0 || event.bar_close_ms !== event.bar_open_ms + interval || event.bar_close_ms > payload.observed_at_ms || event.id !== `31_2_spec_v1:${symbol}:${timeframe}:${event.bar_open_ms}` || !["open", "high", "low", "close", "volume", "previous_volume"].every((key) => Number.isFinite(event[key])) || event.low <= 0 || event.low > event.open || event.high < event.close || event.close <= event.open || event.previous_volume < 0 || event.volume <= event.previous_volume) { + throw new TypeError("Invalid Strategy31 event"); + } + previousOpen = event.bar_open_ms; + return { id: event.id, direction: "bullish", type: "confirmed", time: event.bar_open_ms / 1e3, markerPrice: event.low }; + }); + } + + // src/binance-strategy31-volume-reversal/runtime.js + function installStrategy31(view) { + const key = Symbol.for("jh-userscripts.strategy31"); + if (view[key]) return view[key]; + let context = null, intervalOwner = null, inflight = null, disposed = false, failed = false; + const retired = /* @__PURE__ */ new Set(); + const document = view.document; + function notice(text) { + if (!document.body) return; + let node = document.getElementById("jh-strategy31-status"); + if (!node) { + node = document.createElement("div"); + node.id = "jh-strategy31-status"; + node.setAttribute("role", "status"); + node.style.cssText = "position:fixed;bottom:40px;left:16px;z-index:10000;padding:6px;background:#181a20;color:#ddd;font:12px sans-serif;pointer-events:none"; + document.body.append(node); + } + node.textContent = text; + } + function retire() { + if (context) { + retired.add(context.layer); + context = null; + } + inflight?.abort(); + } + function releaseChart() { + retire(); + intervalOwner?.session.dispose(); + intervalOwner = null; + } + function cleanup() { + if (isChartMutationBlocked(view)) return; + for (const layer of retired) if (layer.clear()) retired.delete(layer); + if ((disposed || failed) && retired.size === 0) view.clearInterval(timer); + } + function current(candidate) { + return !disposed && !document.hidden && context === candidate && candidate.session.isCurrent(candidate.revision) && parseFuturesTradingSymbolFromPathname(view.location.pathname) === candidate.target.routeSymbol && isBearishBollingerChartTargetCurrent(document, candidate.target) && !isChartMutationBlocked(view); + } + async function sample() { + cleanup(); + if (disposed || failed || document.hidden || inflight) return; + const route = parseFuturesTradingSymbolFromPathname(view.location.pathname); + const base = findBinanceTradingViewTarget(document); + const chart = base?.tradingViewApi.activeChart?.(); + if (!route || !chart?.hasModel() || String(chart.symbol()).split("@")[0] !== route) { + releaseChart(); + cleanup(); + return; + } + if (!intervalOwner || intervalOwner.chart !== chart || intervalOwner.route !== route) { + releaseChart(); + intervalOwner = { chart, route, session: createBollingerIntervalSession(chart) }; + } + const resolution = chart.resolution(); + if (!/^\d+(?:S|H|D|W)?$/i.test(String(resolution))) { + retire(); + cleanup(); + notice("Strategy31: unsupported interval"); + return; + } + const seconds = tradingViewResolutionToSeconds(resolution); + const timeframe = Object.keys(STRATEGY31_PERIODS).find((period) => STRATEGY31_PERIODS[period] === seconds); + if (!timeframe) { + retire(); + cleanup(); + notice("Strategy31: unsupported interval"); + return; + } + if (context && (context.target.chart !== chart || context.target.routeSymbol !== route || context.target.chartRoot !== base.chartRoot || context.target.tradingViewApi !== base.tradingViewApi || context.target.resolution !== resolution || context.revision !== context.session.revision)) retire(); + cleanup(); + const session = intervalOwner.session; + if (retired.size || !session.isCurrent(session.revision)) return; + if (!context) { + const target = { ...base, chart, resolution, resolutionSeconds: seconds, routeSymbol: route }; + context = { + target, + session, + revision: session.revision, + layer: createBollingerMarkerLayer(target, { canMutate: () => !isChartMutationBlocked(view) }) + }; + } + const candidate = context; + if (!current(candidate)) return; + const provider = view[SIGNAL_GATEWAY_BRIDGE]; + if (!provider?.capabilities?.includes("strategy31")) { + notice("Strategy31: update CorsairQuant signal client"); + return; + } + const state = provider.getState(); + if (!state.configured || !state.available) { + notice("Strategy31: configure CorsairQuant signal client"); + return; + } + const symbol = `${route.slice(0, -4)}/USDT:USDT`; + const controller = new AbortController(); + const requestCurrent = () => current(candidate) && view[SIGNAL_GATEWAY_BRIDGE] === provider && provider.getState().settingsRevision === state.settingsRevision; + inflight = controller; + try { + const path = `/v1/strategy31/events?${new URLSearchParams({ symbol, timeframe, limit: "200" })}`; + const response = await provider.request(path, controller.signal); + if (!requestCurrent()) return; + if (response.kind !== "response" || response.status !== 200) { + notice("Strategy31: signal service unavailable"); + return; + } + const signals = parseStrategy31Events(JSON.parse(response.responseText), symbol, timeframe); + const bars = await exportClosedTradingViewBars(candidate.target, candidate.session); + if (!bars || !requestCurrent()) return; + const loadedTimes = new Set(bars.map((bar) => bar.time)); + const visible = signals.filter((signal) => loadedTimes.has(signal.time)); + const rendered = await candidate.layer.render(visible, { isCurrent: requestCurrent }); + if (rendered && requestCurrent()) notice(`Strategy31: ${visible.length} chart signals · ${timeframe}`); + } finally { + if (inflight === controller) inflight = null; + } + } + function tick() { + return sample().catch(() => { + failed = true; + releaseChart(); + cleanup(); + if (!disposed) notice("Strategy31 stopped: invalid chart or signal data"); + }); + } + function visibility() { + if (document.hidden) retire(); + else void tick(); + } + const timer = view.setInterval(() => { + void tick(); + }, 5e3); + document.addEventListener("visibilitychange", visibility); + const runtime = Object.freeze({ sample: tick, dispose() { + disposed = true; + releaseChart(); + cleanup(); + document.removeEventListener("visibilitychange", visibility); + document.getElementById("jh-strategy31-status")?.remove(); + } }); + Object.defineProperty(view, key, { value: runtime }); + void tick(); + return runtime; + } + + // src/binance-strategy31-volume-reversal/index.user.js + installStrategy31(unsafeWindow); +})(); diff --git a/scripts/build-userscript.mjs b/scripts/build-userscript.mjs index d95663b..fe24128 100644 --- a/scripts/build-userscript.mjs +++ b/scripts/build-userscript.mjs @@ -6,6 +6,10 @@ import * as esbuild from 'esbuild'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); export const TARGETS = { + 'binance-strategy31-volume-reversal': { + entry: 'src/binance-strategy31-volume-reversal/index.user.js', + output: 'scripts/binance-strategy31-volume-reversal.user.js', + }, 'binance-strategy29-bollinger': { entry: 'src/binance-strategy29-bollinger/index.user.js', output: 'scripts/binance-strategy29-bollinger.user.js', diff --git a/skills/userscript-release/SKILL.md b/skills/userscript-release/SKILL.md index 524bc33..5c35416 100644 --- a/skills/userscript-release/SKILL.md +++ b/skills/userscript-release/SKILL.md @@ -31,6 +31,7 @@ remote repository, Tampermonkey, a browser page, or a trading account. | `src/binance-trading-data/**`, `src/binance-coinmarketcap-data/**`, or `src/shared/**` | the metadata header of every affected generated artifact | `npm run build:binance-userscripts` (or the affected single-script build) | | `src/binance-strategy27-events/**` | `src/binance-strategy27-events/index.user.js` | `npm run build:binance-strategy27-events` | | `src/binance-strategy29-bollinger/**` | `src/binance-strategy29-bollinger/index.user.js` | `npm run build:binance-strategy29-bollinger` | +| `src/binance-strategy31-volume-reversal/**` | `src/binance-strategy31-volume-reversal/index.user.js` | `npm run build:binance-strategy31-volume-reversal` | | `src/m3u8-downloader/**` | `src/m3u8-downloader/index.user.js` | `npm run build:m3u8-downloader` | | An unmigrated `scripts/*.user.js` | that userscript file | no build; bump its header directly | diff --git a/src/binance-strategy27-events/index.user.js b/src/binance-strategy27-events/index.user.js index 0058da4..37a568e 100644 --- a/src/binance-strategy27-events/index.user.js +++ b/src/binance-strategy27-events/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.6.5 +// @version 0.6.6 // @author jackhai9 // @description Display Strategy 27 events and provide the shared private CorsairQuant gateway connection // @match https://www.binance.com/*/futures/* diff --git a/src/binance-strategy29-bollinger/index.user.js b/src/binance-strategy29-bollinger/index.user.js index 9636510..f7a945b 100644 --- a/src/binance-strategy29-bollinger/index.user.js +++ b/src/binance-strategy29-bollinger/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy29.bollinger // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.5.5 +// @version 0.5.6 // @author jackhai9 // @description Native Bollinger/SMA60 markers and the default read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* diff --git a/src/binance-strategy31-volume-reversal/event-contract.js b/src/binance-strategy31-volume-reversal/event-contract.js new file mode 100644 index 0000000..1544815 --- /dev/null +++ b/src/binance-strategy31-volume-reversal/event-contract.js @@ -0,0 +1,28 @@ +export const STRATEGY31_PERIODS = Object.freeze({ '1m': 60, '3m': 180, '5m': 300, '15m': 900, + '30m': 1800, '1h': 3600, '2h': 7200, '4h': 14400, '6h': 21600, '8h': 28800, + '12h': 43200, '1d': 86400, '3d': 259200, '1w': 604800 }); + +/** Validate server ownership and coordinates before passing events to native drawings. */ +export function parseStrategy31Events(payload, symbol, timeframe) { + const interval = STRATEGY31_PERIODS[timeframe] * 1000; + if (!interval || payload?.schema_version !== 1 || payload.strategy_id !== '31' + || payload.spec_version !== '31_2_spec_v1' || payload.symbol !== symbol || payload.timeframe !== timeframe + || !Number.isSafeInteger(payload.observed_at_ms) || !Array.isArray(payload.events) || payload.events.length > 200) { + throw new TypeError('Invalid Strategy31 event snapshot'); + } + let previousOpen = -1; + return payload.events.map(event => { + const anchor = timeframe === '1w' ? 345600000 : 0; + if (event.symbol !== symbol || event.timeframe !== timeframe || !Number.isSafeInteger(event.bar_open_ms) + || event.bar_open_ms <= previousOpen || (event.bar_open_ms - anchor) % interval !== 0 + || event.bar_close_ms !== event.bar_open_ms + interval || event.bar_close_ms > payload.observed_at_ms + || event.id !== `31_2_spec_v1:${symbol}:${timeframe}:${event.bar_open_ms}` + || !['open', 'high', 'low', 'close', 'volume', 'previous_volume'].every(key => Number.isFinite(event[key])) + || event.low <= 0 || event.low > event.open || event.high < event.close || event.close <= event.open + || event.previous_volume < 0 || event.volume <= event.previous_volume) { + throw new TypeError('Invalid Strategy31 event'); + } + previousOpen = event.bar_open_ms; + return { id: event.id, direction: 'bullish', type: 'confirmed', time: event.bar_open_ms / 1000, markerPrice: event.low }; + }); +} diff --git a/src/binance-strategy31-volume-reversal/index.user.js b/src/binance-strategy31-volume-reversal/index.user.js new file mode 100644 index 0000000..87462fa --- /dev/null +++ b/src/binance-strategy31-volume-reversal/index.user.js @@ -0,0 +1,19 @@ +// ==UserScript== +// @name 【自写】Binance Strategy 31 Volume Reversal +// @namespace binance.strategy31.volume-reversal +// @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E +// @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E +// @version 0.1.0 +// @author jackhai9 +// @description Confirmed red-to-green volume signals from CorsairQuant +// @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/* +// @updateURL https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy31-volume-reversal.user.js +// @downloadURL https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy31-volume-reversal.user.js +// @run-at document-start +// @grant unsafeWindow +// ==/UserScript== +import { installStrategy31 } from "./runtime.js"; +installStrategy31(unsafeWindow); diff --git a/src/binance-strategy31-volume-reversal/runtime.js b/src/binance-strategy31-volume-reversal/runtime.js new file mode 100644 index 0000000..c7c2caa --- /dev/null +++ b/src/binance-strategy31-volume-reversal/runtime.js @@ -0,0 +1,118 @@ +import { SIGNAL_GATEWAY_BRIDGE } from '../shared/signal-gateway-bridge.js'; +import { parseFuturesTradingSymbolFromPathname } from '../shared/binance-futures-route.js'; +import { isChartMutationBlocked } from '../shared/chart-mutation-owners.js'; +import { findBinanceTradingViewTarget } from '../shared/tradingview-target.js'; +import { createBollingerIntervalSession, createBollingerMarkerLayer, isBearishBollingerChartTargetCurrent, + exportClosedTradingViewBars, tradingViewResolutionToSeconds } from '../binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js'; +import { parseStrategy31Events, STRATEGY31_PERIODS } from './event-contract.js'; + +/** Server signals only; this lifecycle owns neither market acquisition nor trading actions. */ +export function installStrategy31(view) { + const key = Symbol.for('jh-userscripts.strategy31'); + if (view[key]) return view[key]; + let context = null, intervalOwner = null, inflight = null, disposed = false, failed = false; + const retired = new Set(); + const document = view.document; + function notice(text) { + if (!document.body) return; + let node = document.getElementById('jh-strategy31-status'); + if (!node) { + node = document.createElement('div'); + node.id = 'jh-strategy31-status'; + node.setAttribute('role', 'status'); + node.style.cssText = 'position:fixed;bottom:40px;left:16px;z-index:10000;padding:6px;background:#181a20;color:#ddd;font:12px sans-serif;pointer-events:none'; + document.body.append(node); + } + node.textContent = text; + } + function retire() { + if (context) { retired.add(context.layer); context = null; } + inflight?.abort(); + } + function releaseChart() { + retire(); + intervalOwner?.session.dispose(); + intervalOwner = null; + } + function cleanup() { + if (isChartMutationBlocked(view)) return; + for (const layer of retired) if (layer.clear()) retired.delete(layer); + if ((disposed || failed) && retired.size === 0) view.clearInterval(timer); + } + function current(candidate) { + return !disposed && !document.hidden && context === candidate + && candidate.session.isCurrent(candidate.revision) + && parseFuturesTradingSymbolFromPathname(view.location.pathname) === candidate.target.routeSymbol + && isBearishBollingerChartTargetCurrent(document, candidate.target) + && !isChartMutationBlocked(view); + } + async function sample() { + cleanup(); + if (disposed || failed || document.hidden || inflight) return; + const route = parseFuturesTradingSymbolFromPathname(view.location.pathname); + const base = findBinanceTradingViewTarget(document); + const chart = base?.tradingViewApi.activeChart?.(); + if (!route || !chart?.hasModel() || String(chart.symbol()).split('@')[0] !== route) { releaseChart(); cleanup(); return; } + // Keep the readiness subscription across interval changes: dataReady can still describe old bars. + if (!intervalOwner || intervalOwner.chart !== chart || intervalOwner.route !== route) { + releaseChart(); + intervalOwner = { chart, route, session: createBollingerIntervalSession(chart) }; + } + const resolution = chart.resolution(); + if (!/^\d+(?:S|H|D|W)?$/i.test(String(resolution))) { retire(); cleanup(); notice('Strategy31: unsupported interval'); return; } + const seconds = tradingViewResolutionToSeconds(resolution); + const timeframe = Object.keys(STRATEGY31_PERIODS).find(period => STRATEGY31_PERIODS[period] === seconds); + if (!timeframe) { retire(); cleanup(); notice('Strategy31: unsupported interval'); return; } + if (context && (context.target.chart !== chart || context.target.routeSymbol !== route + || context.target.chartRoot !== base.chartRoot || context.target.tradingViewApi !== base.tradingViewApi + || context.target.resolution !== resolution || context.revision !== context.session.revision)) retire(); + cleanup(); + const session = intervalOwner.session; + if (retired.size || !session.isCurrent(session.revision)) return; + if (!context) { + const target = { ...base, chart, resolution, resolutionSeconds: seconds, routeSymbol: route }; + context = { target, session, revision: session.revision, + layer: createBollingerMarkerLayer(target, { canMutate: () => !isChartMutationBlocked(view) }) }; + } + const candidate = context; + if (!current(candidate)) return; + const provider = view[SIGNAL_GATEWAY_BRIDGE]; + if (!provider?.capabilities?.includes('strategy31')) { notice('Strategy31: update CorsairQuant signal client'); return; } + const state = provider.getState(); + if (!state.configured || !state.available) { notice('Strategy31: configure CorsairQuant signal client'); return; } + const symbol = `${route.slice(0, -4)}/USDT:USDT`; + const controller = new AbortController(); + const requestCurrent = () => current(candidate) && view[SIGNAL_GATEWAY_BRIDGE] === provider + && provider.getState().settingsRevision === state.settingsRevision; + inflight = controller; + try { + const path = `/v1/strategy31/events?${new URLSearchParams({ symbol, timeframe, limit: '200' })}`; + const response = await provider.request(path, controller.signal); + if (!requestCurrent()) return; + if (response.kind !== 'response' || response.status !== 200) { notice('Strategy31: signal service unavailable'); return; } + const signals = parseStrategy31Events(JSON.parse(response.responseText), symbol, timeframe); + // Native drawings snap missing times to loaded bars; server history must be projected onto exact times. + const bars = await exportClosedTradingViewBars(candidate.target, candidate.session); + if (!bars || !requestCurrent()) return; + const loadedTimes = new Set(bars.map(bar => bar.time)); + const visible = signals.filter(signal => loadedTimes.has(signal.time)); + const rendered = await candidate.layer.render(visible, { isCurrent: requestCurrent }); + if (rendered && requestCurrent()) notice(`Strategy31: ${visible.length} chart signals · ${timeframe}`); + } finally { if (inflight === controller) inflight = null; } + } + function tick() { + // Job boundary: invalid contracts stop this observer and expose a visible failure. + return sample().catch(() => { failed = true; releaseChart(); cleanup(); if (!disposed) notice('Strategy31 stopped: invalid chart or signal data'); }); + } + function visibility() { if (document.hidden) retire(); else void tick(); } + const timer = view.setInterval(() => { void tick(); }, 5000); + document.addEventListener('visibilitychange', visibility); + const runtime = Object.freeze({ sample: tick, dispose() { + disposed = true; releaseChart(); cleanup(); + document.removeEventListener('visibilitychange', visibility); + document.getElementById('jh-strategy31-status')?.remove(); + } }); + Object.defineProperty(view, key, { value: runtime }); + void tick(); + return runtime; +} diff --git a/src/shared/signal-gateway-bridge.js b/src/shared/signal-gateway-bridge.js index 7c4cac4..7fd31a1 100644 --- a/src/shared/signal-gateway-bridge.js +++ b/src/shared/signal-gateway-bridge.js @@ -6,7 +6,7 @@ const MAX_RESPONSE_LENGTH = 2 * 1024 * 1024; /** Public read capability: the page may request these projections, never arbitrary authenticated URLs. */ export function validateSignalGatewayPath(path) { - if (typeof path !== 'string' || path.length > 2048 || !path.startsWith('/v1/strategy29/')) { + if (typeof path !== 'string' || path.length > 2048 || !/^\/v1\/strategy(29|31)\//.test(path)) { throw new TypeError('Signal gateway route is not allowed'); } const url = new URL(path, 'https://gateway.invalid'); @@ -16,6 +16,14 @@ export function validateSignalGatewayPath(path) { const query = url.searchParams; const keys = [...query.keys()]; if (new Set(keys).size !== keys.length) throw new TypeError('Signal gateway query contains duplicates'); + if (url.pathname === '/v1/strategy31/events') { + if (keys.length !== 3 || !keys.every(key => ['symbol', 'timeframe', 'limit'].includes(key)) + || !isCanonicalUsdtSymbol(query.get('symbol')) || query.get('limit') !== '200' + || !['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', '1d', '3d', '1w'].includes(query.get('timeframe'))) { + throw new TypeError('Signal gateway Strategy31 query is invalid'); + } + return path; + } if (url.pathname === '/v1/strategy29/status' && keys.length === 0) return path; if (url.pathname !== '/v1/strategy29/events' || !isCanonicalUsdtSymbol(query.get('symbol'))) { throw new TypeError('Signal gateway route or symbol is invalid'); @@ -38,6 +46,7 @@ export function installSignalGatewayBridge(view, { getValue, gmXmlHttpRequest }) const pending = new Set(); const api = Object.freeze({ version: 1, + capabilities: Object.freeze(['strategy29', 'strategy31']), getState() { const value = getValue(SIGNAL_GATEWAY_SECRET_KEY, ''); if (typeof value !== 'string') throw new TypeError('Signal gateway configuration is invalid'); diff --git a/test/dom/strategy31-runtime.test.js b/test/dom/strategy31-runtime.test.js new file mode 100644 index 0000000..e1e1466 --- /dev/null +++ b/test/dom/strategy31-runtime.test.js @@ -0,0 +1,166 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createStrategy29ChartHost } from '../helpers/strategy29-runtime-boundary-host.js'; +import { installStrategy31 } from '../../src/binance-strategy31-volume-reversal/runtime.js'; +import { SIGNAL_GATEWAY_BRIDGE } from '../../src/shared/signal-gateway-bridge.js'; +import { registerChartMutationOwner } from '../../src/shared/chart-mutation-owners.js'; + +function fixture() { + const host = createStrategy29ChartHost({ resolution: '5', bars: [ + { time: 300, open: 10, high: 13, low: 9, close: 12 }, + ] }); + host.dom.reconfigure({ url: 'https://www.binance.com/en/futures/BTRUSDT' }); + const symbol = 'BTR/USDT:USDT'; + const event = { id: `31_2_spec_v1:${symbol}:5m:300000`, symbol, timeframe: '5m', bar_open_ms: 300000, + bar_close_ms: 600000, open: 10, high: 13, low: 9, close: 12, volume: 101, previous_volume: 100 }; + const payload = { schema_version: 1, strategy_id: '31', spec_version: '31_2_spec_v1', symbol, + timeframe: '5m', observed_at_ms: 600000, events: [event] }; + let nextResponse = null, revision = 0; + host.view[SIGNAL_GATEWAY_BRIDGE] = { version: 1, capabilities: ['strategy31'], + getState: () => ({ available: true, configured: true, settingsRevision: revision }), + request: () => nextResponse ?? Promise.resolve({ kind: 'response', status: 200, responseText: JSON.stringify(payload) }) }; + host.setHidden(true); + const runtime = installStrategy31(host.view); + host.setHidden(false); + return { ...host, runtime, hold(promise) { nextResponse = promise; }, changeSettings() { revision += 1; }, payload }; +} + +test('user sees one native green arrow across repeated server snapshots', async () => { + // Given the existing native-chart fixture and one confirmed server event + const f = fixture(); + // When the observer reconciles the same snapshot twice + await f.runtime.sample(); + await f.runtime.sample(); + // Then it owns one upward arrow at the server coordinate + assert.equal(f.shapes.size, 1); + assert.equal(f.created[0].options.shape, 'arrow_up'); + assert.deepEqual(f.created[0].point, { time: 300, price: 9 }); + f.runtime.dispose(); + assert.equal(f.shapes.size, 0); + f.close(); +}); + +test('user resumes signals after returning from an unsupported monthly interval', async () => { + // Given a supported chart and its installed observer + const f = fixture(); + // When the chart visits a monthly interval and returns to five minutes + f.changeInterval('1M'); + f.finishData(); + await f.runtime.sample(); + assert.equal(f.document.getElementById('jh-strategy31-status').textContent, 'Strategy31: unsupported interval'); + f.changeInterval('5'); + f.finishData(); + await f.runtime.sample(); + // Then the observer resumes one correctly anchored arrow + assert.equal(f.shapes.size, 1); + assert.deepEqual(f.created[0].point, { time: 300, price: 9 }); + f.runtime.dispose(); + f.close(); +}); + +test('user waits for native data completion even when old chart data remains ready', async () => { + // Given an observer that already owns the chart interval session + const f = fixture(); + await f.runtime.sample(); + const response = Promise.withResolvers(); + f.hold(response.promise); + // When interval invalidation precedes the native data-completed event + f.changeInterval('15'); + f.changeInterval('5'); + f.setDataReady(true); + const sample = f.runtime.sample(); + response.resolve({ kind: 'response', status: 200, responseText: JSON.stringify(f.payload) }); + await sample; + // Then old readiness cannot recreate drawings before completion + assert.equal(f.shapes.size, 0); + assert.equal(f.created.length, 1); + f.finishData(); + await f.runtime.sample(); + assert.equal(f.shapes.size, 1); + f.runtime.dispose(); + f.close(); +}); + +test('user sees retained signals only after their exact chart candles load', async () => { + // Given a retained server signal older than the currently loaded chart + const f = fixture(); + f.setBars([{ time: 900, open: 10, high: 13, low: 9, close: 12 }]); + // When the observer receives history outside the loaded candle times + await f.runtime.sample(); + // Then no native drawing is created at an unavailable coordinate + assert.equal(f.created.length, 0); + assert.equal(f.shapes.size, 0); + // When loading earlier chart history exposes the exact green candle + f.setBars([{ time: 300, open: 10, high: 13, low: 9, close: 12 }]); + f.finishData(); + await f.runtime.sample(); + // Then the same retained signal is displayed without restarting + assert.deepEqual(f.created[0].point, { time: 300, price: 9 }); + assert.equal(f.shapes.size, 1); + f.runtime.dispose(); + f.close(); +}); + +test('user never receives a late arrow from the previous chart interval', async () => { + // Given a request held at the gateway boundary + const f = fixture(); + const response = Promise.withResolvers(); + f.hold(response.promise); + // When the interval changes before the request completes + const sample = f.runtime.sample(); + f.changeInterval('15'); + response.resolve({ kind: 'response', status: 200, responseText: JSON.stringify(f.payload) }); + await sample; + // Then the stale event does not create any native drawing + assert.equal(f.shapes.size, 0); + f.runtime.dispose(); + f.close(); +}); + +test('user never sees an old arrow finish after gateway settings change', async () => { + // Given native shape creation is still pending + const f = fixture(); + const gate = f.holdNextCreation(); + const sample = f.runtime.sample(); + await gate.entered; + // When gateway settings change before the native operation completes + f.changeSettings(); + gate.release(); + await sample; + // Then no old-settings arrow remains visible + assert.equal(f.shapes.size, 0); + f.runtime.dispose(); + f.close(); +}); + +test('user gets safe deferred arrow cleanup after stopping during trading drawing work', async () => { + // Given an arrow and a busy shared chart mutation owner + const f = fixture(); + await f.runtime.sample(); + const release = registerChartMutationOwner(f.view, 'fixture-trading-owner', () => true); + // When stopping must wait for the owner to finish + f.runtime.dispose(); + assert.equal(f.shapes.size, 1); + release(); + await f.runtime.sample(); + // Then deferred cleanup removes only the observer's arrow + assert.equal(f.shapes.size, 0); + f.close(); +}); + +test('user sees no resurrected status after stopping during native shape creation', async () => { + // Given native shape creation is pending + const f = fixture(); + const gate = f.holdNextCreation(); + const sample = f.runtime.sample(); + await gate.entered; + // When the observer stops before creation completes + f.runtime.dispose(); + gate.release(); + await sample; + await f.runtime.sample(); + // Then neither a drawing nor a success message reappears + assert.equal(f.shapes.size, 0); + assert.equal(f.document.getElementById('jh-strategy31-status'), null); + f.close(); +}); diff --git a/test/unit/binance-strategy29-bollinger/source-regressions.test.js b/test/unit/binance-strategy29-bollinger/source-regressions.test.js index 7a94d2d..a02d02b 100644 --- a/test/unit/binance-strategy29-bollinger/source-regressions.test.js +++ b/test/unit/binance-strategy29-bollinger/source-regressions.test.js @@ -19,7 +19,7 @@ test('user installs Strategy29 with only the reviewed sandbox capabilities', asy const [entrySource, remoteSource] = await Promise.all(paths.map(path => readFile(path, 'utf8'))); // Then metadata and page coordination keep the exact reviewed grants and transport boundary. - assert.equal(readUserscriptVersion(entrySource), '0.5.5'); + assert.equal(readUserscriptVersion(entrySource), '0.5.6'); assert.deepEqual( [...entrySource.matchAll(/^\/\/ @grant\s+(\S+)\s*$/gm)].map(match => match[1]), ['unsafeWindow', 'GM_getValue', 'GM_setValue'], diff --git a/test/unit/coverage-report.test.js b/test/unit/coverage-report.test.js index 5612c7a..dacc20a 100644 --- a/test/unit/coverage-report.test.js +++ b/test/unit/coverage-report.test.js @@ -13,11 +13,11 @@ import { const registry = await createSourceRegistry(); test('user gets coverage maps for the exact install artifacts and complete original sources', async () => { - // Given the six generated installers and two hand-maintained installers. + // Given the seven generated installers and two hand-maintained installers. const paths = registry.artifacts.map((artifact) => artifact.path).sort(); const expected = [ 'auto_refresh', 'binance-coinmarketcap-data', 'binance-orderbook-trade', - 'binance-strategy27-events', 'binance-strategy29-bollinger', 'binance-trading-data', + 'binance-strategy27-events', 'binance-strategy29-bollinger', 'binance-strategy31-volume-reversal', 'binance-trading-data', 'coinmarketcap-valuation-helper', 'm3u8-downloader', ].map((name) => 'scripts/' + name + '.user.js').sort(); // When the coverage compiler produces its maps without changing executable bytes. diff --git a/test/unit/signal-gateway-bridge.test.js b/test/unit/signal-gateway-bridge.test.js index 8a3e850..57a97e6 100644 --- a/test/unit/signal-gateway-bridge.test.js +++ b/test/unit/signal-gateway-bridge.test.js @@ -57,7 +57,8 @@ test('user observes that only provider headers receive private authentication; p assert.equal(request.anonymous, true); request.onload({ status: 200, responseText: '{}', responseHeaders: 'synthetic-private-header', context: 'private' }); assert.deepEqual(await pending, { status: 200, responseText: '{}' }); - assert.deepEqual(Object.keys(f.api).sort(), ['getState', 'request', 'version']); + assert.deepEqual(Object.keys(f.api).sort(), ['capabilities', 'getState', 'request', 'version']); + assert.deepEqual(f.api.capabilities, ['strategy29', 'strategy31']); assert.deepEqual(f.api.getState(), { available: true, configured: true, settingsRevision: 0 }); assert.equal(f.timers.size, 0); f.owner.dispose(); diff --git a/test/unit/strategy31-contract.test.js b/test/unit/strategy31-contract.test.js new file mode 100644 index 0000000..bab2136 --- /dev/null +++ b/test/unit/strategy31-contract.test.js @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseStrategy31Events } from '../../src/binance-strategy31-volume-reversal/event-contract.js'; +import { validateSignalGatewayPath } from '../../src/shared/signal-gateway-bridge.js'; + +test('user receives a green arrow at the confirmed candle low', () => { + // Given a confirmed backend signal for the selected unit + const symbol = 'ARK/USDT:USDT'; + const event = { id: `31_2_spec_v1:${symbol}:5m:300000`, symbol, timeframe: '5m', bar_open_ms: 300000, + bar_close_ms: 600000, open: 10, high: 13, low: 9, close: 12, volume: 101, previous_volume: 100 }; + const payload = { schema_version: 1, strategy_id: '31', spec_version: '31_2_spec_v1', symbol, + timeframe: '5m', observed_at_ms: 600000, events: [event] }; + // When the client validates and adapts the event + const signals = parseStrategy31Events(payload, symbol, '5m'); + // Then the marker uses the green candle open and low, and duplicate events fail + assert.deepEqual(signals, [{ id: event.id, direction: 'bullish', type: 'confirmed', time: 300, markerPrice: 9 }]); + assert.throws(() => parseStrategy31Events({ ...payload, events: [event, event] }, symbol, '5m')); + assert.throws(() => parseStrategy31Events(payload, symbol, '15m')); + assert.throws(() => parseStrategy31Events({ ...payload, events: [{ ...event, volume: 100 }] }, symbol, '5m')); +}); + +test('user can request only a bounded Strategy31 projection', () => { + // Given the exact supported read projection + const path = '/v1/strategy31/events?symbol=ARK%2FUSDT%3AUSDT&timeframe=5m&limit=200'; + // When the shared capability validates it + const accepted = validateSignalGatewayPath(path); + // Then the fixed limit is accepted and arbitrary limits are rejected + assert.equal(accepted, path); + assert.throws(() => validateSignalGatewayPath(path.replace('200', '201'))); +}); diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index e6361bc..82bb7e1 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -24,7 +24,7 @@ test('user installs Strategy29 with its independent observation-only identity', // Then the installer retains its pinned identity and declared observation boundary. assert.equal(contract.name, '【自写】Binance Strategy 29 布林带信号'); assert.equal(contract.namespace, 'binance.strategy29.bollinger'); - assert.equal(contract.version, '0.5.5'); + assert.equal(contract.version, '0.5.6'); 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); @@ -70,7 +70,7 @@ test('user identifies Strategy27 and its declared local gateway capabilities', ( // Then the installer retains its identity and exact gateway capability boundary. assert.equal(contract.name, '【自写】Binance Strategy 27 事件标注'); assert.equal(contract.namespace, 'binance.strategy27.events'); - assert.equal(contract.version, '0.6.5'); + assert.equal(contract.version, '0.6.6'); assert.equal(contract.runAt, 'document-idle'); assert.equal(contract.updateURL, contract.downloadURL); assert.deepEqual(contract.matches, [ From 576eefd49020663e295d3a4bab9e14ce4dd337ee Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:45:21 +0800 Subject: [PATCH 2/3] fix(strategy31): isolate unsupported markets and transient chart updates --- ...-strategy31-volume-reversal-development.md | 5 +- ...binance-strategy31-volume-reversal.user.js | 74 ++++++++++++------- .../runtime.js | 14 +++- test/dom/strategy31-runtime.test.js | 35 ++++++++- 4 files changed, 98 insertions(+), 30 deletions(-) diff --git a/docs/binance-strategy31-volume-reversal-development.md b/docs/binance-strategy31-volume-reversal-development.md index f990a37..b888d83 100644 --- a/docs/binance-strategy31-volume-reversal-development.md +++ b/docs/binance-strategy31-volume-reversal-development.md @@ -13,7 +13,10 @@ events for the current canonical symbol and native timeframe every five seconds. Hidden documents suspend requests; navigation and settings revisions invalidate late responses. Repeated snapshots reconcile one arrow per stable event ID. Interval changes preserve the native data-completion subscription. Unsupported -periods pause observation until a supported interval is selected. Loading earlier +periods and non-USDT markets pause observation until a supported chart is selected. +A transient native candle snapshot inconsistency retains existing arrows and +waits for the next sample; malformed signal contracts still stop the observer. +Loading earlier chart history makes matching retained server events eligible for rendering. The client uses the existing native marker layer, exact interval visibility, diff --git a/scripts/binance-strategy31-volume-reversal.user.js b/scripts/binance-strategy31-volume-reversal.user.js index fcc4897..e7472d2 100644 --- a/scripts/binance-strategy31-volume-reversal.user.js +++ b/scripts/binance-strategy31-volume-reversal.user.js @@ -16,6 +16,16 @@ // @grant unsafeWindow // ==/UserScript== (() => { + // src/shared/canonical-symbol.js + var ROUTE_SYMBOL_PATTERN = /^([\p{L}\p{N}]+)USDT$/u; + function usdtRouteToCanonical(value) { + const match = typeof value === "string" && value.match(ROUTE_SYMBOL_PATTERN); + if (!match || match[0] !== value || match[1] !== match[1].toUpperCase()) { + throw new TypeError("Invalid Binance futures route symbol"); + } + return `${match[1]}/USDT:USDT`; + } + // src/shared/signal-gateway-bridge.js var SIGNAL_GATEWAY_BRIDGE = Symbol.for("jh-userscripts.signal-gateway"); var MAX_RESPONSE_LENGTH = 2 * 1024 * 1024; @@ -86,6 +96,30 @@ return { chartRoot, tradingViewApi: tradingViewApis[0] }; } + // 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/abort.js function getAbortReason(signal) { if (signal?.reason instanceof Error) return signal.reason; @@ -281,30 +315,6 @@ return controller; } - // 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/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; @@ -831,6 +841,13 @@ cleanup(); if (disposed || failed || document.hidden || inflight) return; const route = parseFuturesTradingSymbolFromPathname(view.location.pathname); + if (!route?.endsWith("USDT")) { + releaseChart(); + cleanup(); + notice("Strategy31: unsupported market"); + return; + } + const symbol = usdtRouteToCanonical(route); const base = findBinanceTradingViewTarget(document); const chart = base?.tradingViewApi.activeChart?.(); if (!route || !chart?.hasModel() || String(chart.symbol()).split("@")[0] !== route) { @@ -882,7 +899,6 @@ notice("Strategy31: configure CorsairQuant signal client"); return; } - const symbol = `${route.slice(0, -4)}/USDT:USDT`; const controller = new AbortController(); const requestCurrent = () => current(candidate) && view[SIGNAL_GATEWAY_BRIDGE] === provider && provider.getState().settingsRevision === state.settingsRevision; inflight = controller; @@ -895,7 +911,13 @@ return; } const signals = parseStrategy31Events(JSON.parse(response.responseText), symbol, timeframe); - const bars = await exportClosedTradingViewBars(candidate.target, candidate.session); + let bars; + try { + bars = await exportClosedTradingViewBars(candidate.target, candidate.session); + } catch (error) { + if (error instanceof TradingViewBarSnapshotInconsistentError) return; + throw error; + } if (!bars || !requestCurrent()) return; const loadedTimes = new Set(bars.map((bar) => bar.time)); const visible = signals.filter((signal) => loadedTimes.has(signal.time)); diff --git a/src/binance-strategy31-volume-reversal/runtime.js b/src/binance-strategy31-volume-reversal/runtime.js index c7c2caa..82d87e0 100644 --- a/src/binance-strategy31-volume-reversal/runtime.js +++ b/src/binance-strategy31-volume-reversal/runtime.js @@ -2,6 +2,8 @@ import { SIGNAL_GATEWAY_BRIDGE } from '../shared/signal-gateway-bridge.js'; import { parseFuturesTradingSymbolFromPathname } from '../shared/binance-futures-route.js'; import { isChartMutationBlocked } from '../shared/chart-mutation-owners.js'; import { findBinanceTradingViewTarget } from '../shared/tradingview-target.js'; +import { usdtRouteToCanonical } from '../shared/canonical-symbol.js'; +import { TradingViewBarSnapshotInconsistentError } from '../binance-strategy29-bollinger/core/bearish-bollinger-pattern.js'; import { createBollingerIntervalSession, createBollingerMarkerLayer, isBearishBollingerChartTargetCurrent, exportClosedTradingViewBars, tradingViewResolutionToSeconds } from '../binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js'; import { parseStrategy31Events, STRATEGY31_PERIODS } from './event-contract.js'; @@ -50,6 +52,8 @@ export function installStrategy31(view) { cleanup(); if (disposed || failed || document.hidden || inflight) return; const route = parseFuturesTradingSymbolFromPathname(view.location.pathname); + if (!route?.endsWith('USDT')) { releaseChart(); cleanup(); notice('Strategy31: unsupported market'); return; } + const symbol = usdtRouteToCanonical(route); const base = findBinanceTradingViewTarget(document); const chart = base?.tradingViewApi.activeChart?.(); if (!route || !chart?.hasModel() || String(chart.symbol()).split('@')[0] !== route) { releaseChart(); cleanup(); return; } @@ -80,7 +84,6 @@ export function installStrategy31(view) { if (!provider?.capabilities?.includes('strategy31')) { notice('Strategy31: update CorsairQuant signal client'); return; } const state = provider.getState(); if (!state.configured || !state.available) { notice('Strategy31: configure CorsairQuant signal client'); return; } - const symbol = `${route.slice(0, -4)}/USDT:USDT`; const controller = new AbortController(); const requestCurrent = () => current(candidate) && view[SIGNAL_GATEWAY_BRIDGE] === provider && provider.getState().settingsRevision === state.settingsRevision; @@ -92,7 +95,14 @@ export function installStrategy31(view) { if (response.kind !== 'response' || response.status !== 200) { notice('Strategy31: signal service unavailable'); return; } const signals = parseStrategy31Events(JSON.parse(response.responseText), symbol, timeframe); // Native drawings snap missing times to loaded bars; server history must be projected onto exact times. - const bars = await exportClosedTradingViewBars(candidate.target, candidate.session); + let bars; + try { + bars = await exportClosedTradingViewBars(candidate.target, candidate.session); + } catch (error) { + // A native feed update can expose inconsistent rows for one sample; retain the existing arrows. + if (error instanceof TradingViewBarSnapshotInconsistentError) return; + throw error; + } if (!bars || !requestCurrent()) return; const loadedTimes = new Set(bars.map(bar => bar.time)); const visible = signals.filter(signal => loadedTimes.has(signal.time)); diff --git a/test/dom/strategy31-runtime.test.js b/test/dom/strategy31-runtime.test.js index e1e1466..feb61f7 100644 --- a/test/dom/strategy31-runtime.test.js +++ b/test/dom/strategy31-runtime.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createStrategy29ChartHost } from '../helpers/strategy29-runtime-boundary-host.js'; +import { createStrategy29ChartHost, exportStrategyBars } from '../helpers/strategy29-runtime-boundary-host.js'; import { installStrategy31 } from '../../src/binance-strategy31-volume-reversal/runtime.js'; import { SIGNAL_GATEWAY_BRIDGE } from '../../src/shared/signal-gateway-bridge.js'; import { registerChartMutationOwner } from '../../src/shared/chart-mutation-owners.js'; @@ -101,6 +101,39 @@ test('user sees retained signals only after their exact chart candles load', asy f.close(); }); +test('user sees no USDT signals on a USDC chart and can resume a supported market', async (t) => { + // Given the observer on a USDC chart, outside its market contract + const f = fixture(); + t.after(() => { f.runtime.dispose(); f.close(); }); + f.dom.reconfigure({ url: 'https://www.binance.com/en/futures/BTRUSDC' }); + f.setSymbol('BTRUSDC@PRICETYPE=LAST'); + // When the observer samples and later returns to its supported market + await f.runtime.sample(); + // Then no USDT event is drawn on the unsupported market + assert.equal(f.created.length, 0); + assert.equal(f.exports.length, 0); + f.dom.reconfigure({ url: 'https://www.binance.com/en/futures/BTRUSDT' }); + f.setSymbol('BTRUSDT@PRICETYPE=LAST'); + await f.runtime.sample(); + assert.equal(f.shapes.size, 1); +}); + +test('user keeps arrows through one transient native candle update', async (t) => { + // Given a drawn signal followed by an inconsistent native feed snapshot + const f = fixture(); + t.after(() => { f.runtime.dispose(); f.close(); }); + await f.runtime.sample(); + const bar = { time: 300, open: 10, high: 13, low: 9, close: 12 }; + f.exportNext(exportStrategyBars([bar, bar])); + // When one sample races the native feed update + await f.runtime.sample(); + // Then the existing arrow remains and valid subsequent data resumes sampling + assert.equal(f.shapes.size, 1); + await f.runtime.sample(); + assert.equal(f.exports.length, 3); + assert.equal(f.shapes.size, 1); +}); + test('user never receives a late arrow from the previous chart interval', async () => { // Given a request held at the gateway boundary const f = fixture(); From 66e1379783c8cf8c3c2c61e8197b86042534dded Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:53:14 +0800 Subject: [PATCH 3/3] docs(signals): align shared gateway capability and version --- docs/binance-strategy27-events-development.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/binance-strategy27-events-development.md b/docs/binance-strategy27-events-development.md index 06d399c..83c358e 100644 --- a/docs/binance-strategy27-events-development.md +++ b/docs/binance-strategy27-events-development.md @@ -14,7 +14,7 @@ V10 live projection. The VPS remains the only market-data and event-analysis authority. The userscript opens no Binance market-data WebSocket, uses no Binance API key, and does not recalculate the four force groups. -Version 0.6.5 retains this installation's private gateway configuration and +Version 0.6.6 retains this installation's private gateway configuration and provides a shared read-only transport. Strategy29 owns its own summary panel, lifecycle and panel position. The existing `strategy27GatewayOrigin` and `strategy27GatewayAuthSecret` storage keys remain the single credential source; @@ -25,7 +25,8 @@ Strategy27 ownership of the new panel. Invalid old coordinates are reported to Strategy29 without preventing the shared provider or Strategy27 from starting. The page-visible `jh-userscripts.signal-gateway` capability exposes only fixed -Strategy29 status and event reads. It is intentionally a public-data capability: +Strategy29 status/event reads and bounded Strategy31 event snapshots. It is +intentionally a public-data capability: page code can request the allowed observation data, but cannot obtain the secret, select another origin, set request headers or send a write. Future strategy routes require an explicit allowlist extension and contract review. @@ -168,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.6.5 with identical metadata headers. +generated install artifact are version 0.6.6 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