diff --git a/.github/workflows/binance-orderbook-ui.yml b/.github/workflows/binance-orderbook-ui.yml index 39a6ed9..1b61026 100644 --- a/.github/workflows/binance-orderbook-ui.yml +++ b/.github/workflows/binance-orderbook-ui.yml @@ -8,6 +8,14 @@ on: - "package-lock.json" - "playwright.config.js" - "src/binance-orderbook-trade/**" + - "src/binance-strategy29-bollinger/**" + - "scripts/binance-strategy29-bollinger.user.js" + - "src/shared/abort.js" + - "src/shared/tradingview-target.js" + - "src/shared/chart-marker-save-controller.js" + - "src/shared/chart-mutation-owners.js" + - "test/unit/binance-strategy29-bollinger/**" + - "test/dom/binance-strategy29-bollinger/**" - "scripts/binance-orderbook-trade.user.js" - "scripts/binance-*.mjs" - "e2e/binance-orderbook/**" @@ -23,6 +31,14 @@ on: - "package-lock.json" - "playwright.config.js" - "src/binance-orderbook-trade/**" + - "src/binance-strategy29-bollinger/**" + - "scripts/binance-strategy29-bollinger.user.js" + - "src/shared/abort.js" + - "src/shared/tradingview-target.js" + - "src/shared/chart-marker-save-controller.js" + - "src/shared/chart-mutation-owners.js" + - "test/unit/binance-strategy29-bollinger/**" + - "test/dom/binance-strategy29-bollinger/**" - "scripts/binance-orderbook-trade.user.js" - "scripts/binance-*.mjs" - "e2e/binance-orderbook/**" @@ -47,7 +63,8 @@ jobs: - run: npx playwright install --with-deps chromium - run: npm run test:binance-orderbook-ui-toolchain - run: npm run build:binance-orderbook-trade - - run: git diff --exit-code -- scripts/binance-orderbook-trade.user.js + - run: npm run build:binance-strategy29-bollinger + - run: git diff --exit-code -- scripts/binance-orderbook-trade.user.js scripts/binance-strategy29-bollinger.user.js - run: npm run test:ui - name: Upload Playwright report if: failure() diff --git a/AGENTS.md b/AGENTS.md index b71265b..2e08aab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ | src/binance-trading-data/ | scripts/binance-trading-data.user.js | | 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/m3u8-downloader/ | scripts/m3u8-downloader.user.js | - Other scripts remain hand-maintained under scripts/*.user.js until migrated. @@ -35,6 +36,7 @@ | Orderbook source, business contracts, or semantic DOM | docs/binance-orderbook-trade-development.md | | 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 | | 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 | | Read-only review | skills/userscript-review/SKILL.md | @@ -54,6 +56,7 @@ | src/binance-orderbook-trade/** | npm run build:binance-orderbook-trade | | 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/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 9e33af0..3ae2039 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ This repository is the source of truth and distribution point for the scripts. G | Script | Target | Description | Source of truth | Install | | --- | --- | --- | --- | --- | +| Binance Strategy 29 Bollinger signals | Binance Futures | Annotate native loaded candles with mirrored Bollinger/SMA60 signals; standalone or alongside orderbook 2.7.199+ | This repository | [Install][install-binance-strategy29-bollinger] | | Binance 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] | @@ -94,7 +95,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/`, 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/`, 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. @@ -103,6 +104,7 @@ npm run build:binance-userscripts ## Documentation - [Binance orderbook trade development](docs/binance-orderbook-trade-development.md) +- [Binance Strategy 29 Bollinger signals and migration](docs/binance-strategy29-bollinger-development.md) - [Binance orderbook UI automation](docs/binance-orderbook-trade-ui-automation.md) - [Userscript validation and maintenance](docs/userscript-validation.md) - [Binance Strategy 27 event annotations](docs/binance-strategy27-events-development.md) @@ -113,6 +115,7 @@ npm run build:binance-userscripts MIT. See [LICENSE](LICENSE). [install-binance-orderbook-trade]: https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-orderbook-trade.user.js +[install-binance-strategy29-bollinger]: https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js [install-binance-trading-data]: https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-trading-data.user.js [install-binance-coinmarketcap-data]: https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-coinmarketcap-data.user.js [install-binance-strategy27-events]: https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy27-events.user.js diff --git a/README.zh-CN.md b/README.zh-CN.md index 9bdcd5a..6450e8f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -35,6 +35,7 @@ | 脚本 | 适用场景 | 说明 | 源码真源 | 安装 | |---|---|---|---|---| +| 【自写】Binance Strategy 29 布林带信号 | Binance Futures | Annotate native loaded candles with mirrored Bollinger/SMA60 signals; standalone or alongside orderbook 2.7.199+ | This repository | [Install][install-binance-strategy29-bollinger] | | 【自写】Binance 订单簿单击下单 | Binance Futures | 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 | 本仓库 | [`点击安装`][install-binance-orderbook-trade] | | 【自写】Binance 合约交易数据面板 | Binance Futures | 在合约交易页面叠加浮动面板,定时拉取交易数据(持仓量、多空比、资金费率等)并显示当前值 + 多空信号 | 本仓库 | [`点击安装`][install-binance-trading-data] | | 【自写】Binance CoinMarketCap 数据面板 | Binance Futures | 在 Binance 合约页面显示当前币种的 CoinMarketCap 估值、供应量和流动性数据 | 本仓库 | [`点击安装`][install-binance-coinmarketcap-data] | @@ -92,7 +93,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/m3u8-downloader/` 是对应脚本的开发真源。 +2. `src/binance-orderbook-trade/`、`src/binance-trading-data/`、`src/binance-coinmarketcap-data/`、`src/binance-strategy27-events/`、`src/binance-strategy29-bollinger/`、`src/m3u8-downloader/` 是对应脚本的开发真源。 3. 公开安装入口仍是生成后的 `scripts/*.user.js`;修改对应 `src/` 后运行 `npm run build:binance-userscripts` 或单脚本 build 命令。 4. 非真源仓库只放安装链接,不复制脚本源码。 5. 行为变更时递增 `@version`;纯文档变更不需要 bump,并保留 `@updateURL/@downloadURL` 指向真源 raw 地址。 @@ -107,6 +108,7 @@ npm run build:binance-userscripts - [Brooks media sync workflow](docs/brooks-media-sync-workflow.md) [install-binance-orderbook-trade]: https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-orderbook-trade.user.js +[install-binance-strategy29-bollinger]: https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js [install-binance-trading-data]: https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-trading-data.user.js [install-binance-coinmarketcap-data]: https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-coinmarketcap-data.user.js [install-binance-strategy27-events]: https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy27-events.user.js diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md index 125d5f2..c09a19f 100644 --- a/docs/binance-orderbook-trade-development.md +++ b/docs/binance-orderbook-trade-development.md @@ -57,7 +57,6 @@ The build command rewrites `scripts/binance-orderbook-trade.user.js` from `src/b src/binance-orderbook-trade/ index.user.js core/ - bearish-bollinger-pattern.js cancel-orders.js continuous-ladder.js decimal.js @@ -71,7 +70,6 @@ src/binance-orderbook-trade/ account-orders.js depth-profile.js trade-form.js - tradingview-bearish-alerts.js test/ unit/ binance-orderbook-trade/ @@ -91,7 +89,6 @@ scripts/ - decimal normalization and exact arithmetic - quantity allocation and step-size rounding - cancel-order text evidence -- bearish Bollinger/MA60 setup and signal detection - orderbook display-step inference - public depth snapshot/diff synchronization and bounded reconnect lifecycle - ladder action specs @@ -102,7 +99,6 @@ scripts/ - active current-orders pane detection - vertical depth-profile host discovery and canvas rendering - trade form tab and action button filtering -- TradingView OHLC export and bidirectional Bollinger-alert marker ownership `index.user.js` owns side effects: @@ -114,41 +110,13 @@ scripts/ - panel rendering - async execution flow -## Bidirectional Bollinger Alerts +## Strategy 29 Boundary -The chart alert is timeframe-agnostic and evaluates closed bars only. It supports TradingView second, minute, hour, day, and week resolutions; month resolutions are intentionally unsupported because their duration is calendar-dependent. It scans every closed bar already loaded in TradingView and does not issue a separate market-data request or force the chart to load older history. The active-page poll exports the current window once per second. Detection runs again when the `count:firstTime:lastTime` window or any closed-bar OHLC content changes; unchanged content is compared against a compact numeric snapshot instead of serializing the whole history on every poll. The cached signal set is reconciled with live TradingView shape IDs on every poll. Loading older chart history therefore expands the annotated range even when the latest bar is unchanged, and a TradingView chart refresh can no longer leave the alert registry pointing at evicted markers. +Bollinger detection and chart annotations belong to the independent Strategy29 +script; see `docs/binance-strategy29-bollinger-development.md`. The orderbook +publishes only its live boolean drawing-busy predicate and retains shared +pre-action marker-save drains. It does not run a strategy detector or chart poll. -A bearish setup requires the Bollinger middle line to cross down through SMA60 while the band center is declining. A bullish setup is generated by the exact price-axis mirror: OHLC values are transformed as `open=-open`, `high=-low`, `low=-high`, `close=-close`, and the indicator axes are transformed as `middle=-middle`, `upper=-lower`, `lower=-upper`, `ma60=-ma60`. This yields a middle-line cross up through SMA60, pre-cross closes in the middle/upper channel, and post-cross middle-line support without maintaining a second drifting detector. Both directions use the same warning/confirmation/reversal lifecycle. Bearish warning dots are red and remain above the candle high; bullish warning dots are green and remain below the candle low. Bearish confirmation is a red down arrow and bullish confirmation is a green up arrow. A reversal uses the opposite colored/directional arrow. Mirrored marker prices remain on the corresponding side of the candle (bullish confirmation below the low, bearish confirmation above the high). If overlapping setups in one direction reverse on the same candle, the newest setup owns that direction's visual reversal; opposite-direction signals retain distinct IDs and are both rendered. - -The current Binance `trading-platform-30` chart runtime exposes `exportData()` as row-major numeric-keyed OHLC objects. The parser deliberately enforces that observed contract and fails if the schema changes. It validates the entire export before filtering closed bars: intraday timestamps must lie on the UTC interval grid, D/W timestamps must be at UTC midnight, weekly timestamps must be Mondays, and positive timestamp deltas must be multiples of the bar duration. Missing bars remain valid; multi-day and multi-week feeds do not have to share the Unix epoch's phase. Off-grid or incompatible-spacing snapshots are recoverable and never reach detection. - -`dataReady()` alone is insufficient: Binance's chart implementation checks whether data is nonempty, not whether an interval switch has completed. A chart-owned interval session subscribes to `onIntervalChanged()` and `onDataLoaded()`. An interval change increments a revision and blocks exports until data completion; callbacks never export or mutate drawings. Every asynchronous export and marker creation revalidates the session identity/revision as well as chart instance, route symbol, and resolution. This rejects stale work even after a rapid `1S -> 1 -> 1S` switch. Stop, page hiding, teardown, and chart replacement dispose the session independently of deferred drawing removal. - -Each interval session uses a private subscription owner token. The observed Binance chart integration calls `unsubscribeAll(null)` on both data-loaded and interval-changed channels when binding its own callbacks. Sharing the null owner lets that native initialization silently remove our callbacks, leaving a running monitor stuck waiting for data that has already arrived. Cleanup uses the same private token and exact callbacks; it never clears native or other-script subscriptions. - -The exposed chart API can exist before its internal model during initial loading. Target discovery and current-target validation use the observed Trading Platform 30 `hasModel()` contract before reading `resolution()`. A missing model is an expected not-ready state, not a fatal error; the existing poll resumes when the model exists. Model readiness does not replace the interval/data session guard. - -Indicator calculation traverses each fixed window directly instead of allocating sliced/mapped close arrays for every bar. Summation order is preserved exactly, including population variance, to avoid changing threshold decisions through floating-point drift. Stable marker audits read each shape handle once while retaining the full point/property checks. The asynchronous render loop yields a browser task after 32 signals or 8 ms of batch work; each resumed batch refreshes native shape ownership and revalidates generation, chart session, and drawing-mutation ownership. This is a cooperative budget checked between native calls, not a hard limit on an individual native API call. It adds no recurring timer and does not reduce history coverage or audit frequency. Context cleanup and obsolete-marker deletion remain synchronous; host chart loading/rendering and those removal phases are not covered by the batch budget. - -`window.__TM_CLOSE_LONG_DEBUG__.bollingerAlertState` is an on-demand diagnostic snapshot of timer/task presence, context/session readiness, cached/rendered signal counts, and boolean drawing-mutation owners. It contains no order details, does not export candles or audit drawings, and adds no periodic work. Native model/data readiness is reported separately from session readiness so a waiting session is not mistaken for expensive calculation or a zero-signal window. - -Alert markers use TradingView's drawing API. Every detected signal in the loaded window is rendered; there is no recent-signal truncation. Each direction allows up to 1,000 simultaneous signals, for a shared maximum of 2,000, and an over-limit window is rejected before any partial marker mutation. Marker ownership is tracked by signal ID, but the live shape list remains authoritative: externally evicted marker IDs are discarded from the registry and recreated without removing or changing foreign drawings. A typed OHLC/time-order snapshot race is treated as recoverable: existing markers and cached signals remain in place and the next poll retries. Schema, nonnumeric data, chart API, band-width, and time-alignment contract failures remain fail-closed and clear the alert layer. Current live evidence shows that removing even a `disableSave` marker emits `drawing_event` and `saveChart`, so alert reconciliation pauses during every existing order-line drawing/save owner. Symbol changes, non-trading routes, hidden documents, and page teardown stop or clear the alert lifecycle. - -Existing owned markers are also checked for timestamp, resolved price, current signal price/type/direction, native shape name, color/icon and interval visibility. A changed marker is recreated; an unchanged marker is not rewritten. The price read back at creation is retained separately from the detector's requested price to tolerate host price normalization without perpetual recreation. Native `intervalsVisibilities` overrides restrict each marker to its originating interval bucket, so `1S` drawings cannot appear on a minute chart while physical cleanup is blocked. TradingView groups 60+ minute resolutions into integer-hour buckets: this matches Binance's standard hour intervals but does not provide distinct native visibility for nonstandard intervals such as 60 and 90 minutes. The session revision still invalidates computation on every interval change. Unsupported `fixedSize` overrides are not sent to arrow drawings; the current live arrow API does not expose that property. - -Retiring a context always invalidates it immediately. Its layer remains in a cleanup set until owned markers and outstanding asynchronous creations have finished; late creations are owned before checking currentness. No late callback removes a drawing while a trade/save owner is busy. The existing poll drains retired layers when safe, without deleting user or other-script drawings or making additional market requests. - -### Marker Save Bursts - -The observed Binance Trading Platform 30 integration schedules a full `widget.save` 100 ms after every non-click/non-move drawing event. `disableSave` excludes temporary markers from the saved JSON but does not suppress these events. A native CPU profile of timeframe switching attributed the main scripting hotspot to repeated chart serialization, especially unchanged parallel-channel properties; marker audits and indicator detection were not the dominant sampled branch. - -`core/chart-marker-save-controller.js` installs one stable base `saveChart` wrapper per API in a WeakMap. Only actual Bollinger marker creation, publication and deletion arm its burst; unchanged audits do not. Default callback saves during that burst share one complete serialization after 150 ms of quiet, capped at 1,000 ms per burst. Every pending callback receives a separate JSON snapshot, including all saveable user drawings. Callback failures are reported at a separate asynchronous job boundary, without skipping later callbacks or interrupting an unrelated explicit save. No drawings are deleted or excluded to accelerate serialization. - -This is deliberately not a fully transparent public-API replacement: default `saveChart(callback)` callers during a marker burst receive a deferred callback and no synchronous callback return value. Binance's observed autosave caller does not use that return value, but a third-party default caller in the same window has this limitation. Idle calls, explicit options (including `includeDrawings: false`), unusual arguments and foreign receivers remain synchronous. An outer order-save wrapper remains authoritative; the base never restores over it. This optimization does not cover independent Strategy 27 or manual-drawing bursts outside a Bollinger mutation window. - -Before starting a continuous-order save owner, toggling order-line visibility, or opening the native cancellation confirmation, the workflow drains pending marker mutations and their delayed-save tail. Asynchronous native creations remain counted through completion, including stale hidden results. An independent 150 ms mutation tail survives an explicit save interrupting the burst. Draining blocks new marker mutations, has a 2,000 ms timeout that refuses the chart workflow before its next action, and supports immediate continuous-task abort. The cancellation confirmation callback remains synchronous; the script does not delay, repeat or confirm a financial click. `clear()` remains synchronous and save timers settle afterward. The controller has no idle recurring timer or retained serialized snapshot; on-demand `bollingerAlertState.markerSaveStats` exposes aggregate counts only. - -Native asynchronous shape creation can automatically enable the interval active when it resolves. Markers therefore start with `visible: false`. Only a current session with drawing mutation ownership may synchronously publish the marker using `setProperties`, restoring its originating interval mask and setting `visible: true`; properties are read back before registration. Stale or busy results remain hidden until safe cleanup. Native visibility normalizes second resolutions of at least 60 seconds into integer-minute buckets as well. ## Vertical Depth Profile diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md new file mode 100644 index 0000000..48f65fd --- /dev/null +++ b/docs/binance-strategy29-bollinger-development.md @@ -0,0 +1,85 @@ +# Binance Strategy 29 Bollinger Signals + +## Scope and Installation + +The standalone `binance-strategy29-bollinger.user.js` owns the existing local +Bollinger/SMA60 observer. Source is `src/binance-strategy29-bollinger/`. +It reads already-loaded native chart candles and has no account, order, +exchange-network, Telegram or server-gateway operations. The server summary is +a separate future integration, not part of this local extraction. + +Install Strategy29 0.1.0 with orderbook 2.7.199 or later, or use it alone. +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. +If the old script loads later, existing markers can remain because its private +save owner can block safe cleanup. This is not a supported compatibility mode; +Strategy29 never removes old-script or user drawings. + +Both supported scripts run with `@grant none` in the same page context. +The orderbook registers a synchronous boolean drawing-busy predicate under +`Symbol.for('jh-userscripts.chart-mutation-owners')`; it unregisters on permanent +page teardown. A missing owner means there is no coordinated orderbook instance, +not a guessed order/account state. No task objects or financial actions cross +this boundary. The exact native API owns the shared marker controller under +`Symbol.for('jh-userscripts.chart-marker-save-controller')`. Both records validate +protocol version 1 and reject incompatible versions. These are coordination +contracts between trusted scripts, not a security boundary against page code. + +The standalone entry has a per-page singleton. One poll discovers charts/routes +and evaluates the existing monitor. Hidden documents and BFCache pagehide pause +it; visibility/pageshow resumes it. Permanent disposal removes its listeners and +invalidates pending drawing work. Non-trading routes perform no candle exports. +Independent instances of each bundle share the same controller regardless of +load order. Strategy27 does not participate in this protocol. + +## Bidirectional Bollinger Alerts + +The chart alert is timeframe-agnostic and evaluates closed bars only. It supports TradingView second, minute, hour, day, and week resolutions; month resolutions are intentionally unsupported because their duration is calendar-dependent. It scans every closed bar already loaded in TradingView and does not issue a separate market-data request or force the chart to load older history. The active-page poll exports the current window once per second. Detection runs again when the `count:firstTime:lastTime` window or any closed-bar OHLC content changes; unchanged content is compared against a compact numeric snapshot instead of serializing the whole history on every poll. The cached signal set is reconciled with live TradingView shape IDs on every poll. Loading older chart history therefore expands the annotated range even when the latest bar is unchanged, and a TradingView chart refresh can no longer leave the alert registry pointing at evicted markers. + +A bearish setup requires the Bollinger middle line to cross down through SMA60 while the band center is declining. A bullish setup is generated by the exact price-axis mirror: OHLC values are transformed as `open=-open`, `high=-low`, `low=-high`, `close=-close`, and the indicator axes are transformed as `middle=-middle`, `upper=-lower`, `lower=-upper`, `ma60=-ma60`. This yields a middle-line cross up through SMA60, pre-cross closes in the middle/upper channel, and post-cross middle-line support without maintaining a second drifting detector. Both directions use the same warning/confirmation/reversal lifecycle. Bearish warning dots are red and remain above the candle high; bullish warning dots are green and remain below the candle low. Bearish confirmation is a red down arrow and bullish confirmation is a green up arrow. A reversal uses the opposite colored/directional arrow. Mirrored marker prices remain on the corresponding side of the candle (bullish confirmation below the low, bearish confirmation above the high). If overlapping setups in one direction reverse on the same candle, the newest setup owns that direction's visual reversal; opposite-direction signals retain distinct IDs and are both rendered. + +The current Binance `trading-platform-30` chart runtime exposes `exportData()` as row-major numeric-keyed OHLC objects. The parser deliberately enforces that observed contract and fails if the schema changes. It validates the entire export before filtering closed bars: intraday timestamps must lie on the UTC interval grid, D/W timestamps must be at UTC midnight, weekly timestamps must be Mondays, and positive timestamp deltas must be multiples of the bar duration. Missing bars remain valid; multi-day and multi-week feeds do not have to share the Unix epoch's phase. Off-grid or incompatible-spacing snapshots are recoverable and never reach detection. + +`dataReady()` alone is insufficient: Binance's chart implementation checks whether data is nonempty, not whether an interval switch has completed. A chart-owned interval session subscribes to `onIntervalChanged()` and `onDataLoaded()`. An interval change increments a revision and blocks exports until data completion; callbacks never export or mutate drawings. Every asynchronous export and marker creation revalidates the session identity/revision as well as chart instance, route symbol, and resolution. This rejects stale work even after a rapid `1S -> 1 -> 1S` switch. Stop, page hiding, teardown, and chart replacement dispose the session independently of deferred drawing removal. + +Each interval session uses a private subscription owner token. The observed Binance chart integration calls `unsubscribeAll(null)` on both data-loaded and interval-changed channels when binding its own callbacks. Sharing the null owner lets that native initialization silently remove our callbacks, leaving a running monitor stuck waiting for data that has already arrived. Cleanup uses the same private token and exact callbacks; it never clears native or other-script subscriptions. + +The exposed chart API can exist before its internal model during initial loading. Target discovery and current-target validation use the observed Trading Platform 30 `hasModel()` contract before reading `resolution()`. A missing model is an expected not-ready state, not a fatal error; the existing poll resumes when the model exists. Model readiness does not replace the interval/data session guard. + +Indicator calculation traverses each fixed window directly instead of allocating sliced/mapped close arrays for every bar. Summation order is preserved exactly, including population variance, to avoid changing threshold decisions through floating-point drift. Stable marker audits read each shape handle once while retaining the full point/property checks. The asynchronous render loop yields a browser task after 32 signals or 8 ms of batch work; each resumed batch refreshes native shape ownership and revalidates generation, chart session, and drawing-mutation ownership. This is a cooperative budget checked between native calls, not a hard limit on an individual native API call. It adds no recurring timer and does not reduce history coverage or audit frequency. Context cleanup and obsolete-marker deletion remain synchronous; host chart loading/rendering and those removal phases are not covered by the batch budget. + +`window.__TM_STRATEGY29_DEBUG__.diagnostics` is an on-demand diagnostic snapshot of timer/task presence, context/session readiness, cached/rendered signal counts, and the aggregate boolean drawing-mutation state. It contains no order details, does not export candles or audit drawings, and adds no periodic work. Native model/data readiness is reported separately from session readiness so a waiting session is not mistaken for expensive calculation or a zero-signal window. + +Alert markers use TradingView's drawing API. Every detected signal in the loaded window is rendered; there is no recent-signal truncation. Each direction allows up to 1,000 simultaneous signals, for a shared maximum of 2,000, and an over-limit window is rejected before any partial marker mutation. Marker ownership is tracked by signal ID, but the live shape list remains authoritative: externally evicted marker IDs are discarded from the registry and recreated without removing or changing foreign drawings. A typed OHLC/time-order snapshot race is treated as recoverable: existing markers and cached signals remain in place and the next poll retries. Schema, nonnumeric data, chart API, band-width, and time-alignment contract failures remain fail-closed and clear the alert layer. Current live evidence shows that removing even a `disableSave` marker emits `drawing_event` and `saveChart`, so alert reconciliation pauses during every existing order-line drawing/save owner. Symbol changes, non-trading routes, hidden documents, and page teardown stop or clear the alert lifecycle. + +Existing owned markers are also checked for timestamp, resolved price, current signal price/type/direction, native shape name, color/icon and interval visibility. A changed marker is recreated; an unchanged marker is not rewritten. The price read back at creation is retained separately from the detector's requested price to tolerate host price normalization without perpetual recreation. Native `intervalsVisibilities` overrides restrict each marker to its originating interval bucket, so `1S` drawings cannot appear on a minute chart while physical cleanup is blocked. TradingView groups 60+ minute resolutions into integer-hour buckets: this matches Binance's standard hour intervals but does not provide distinct native visibility for nonstandard intervals such as 60 and 90 minutes. The session revision still invalidates computation on every interval change. Unsupported `fixedSize` overrides are not sent to arrow drawings; the current live arrow API does not expose that property. + +Retiring a context always invalidates it immediately. Its layer remains in a cleanup set until owned markers and outstanding asynchronous creations have finished; late creations are owned before checking currentness. No late callback removes a drawing while a trade/save owner is busy. The existing poll drains retired layers when safe, without deleting user or other-script drawings or making additional market requests. + +### Marker Save Bursts + +The observed Binance Trading Platform 30 integration schedules a full `widget.save` 100 ms after every non-click/non-move drawing event. `disableSave` excludes temporary markers from the saved JSON but does not suppress these events. A native CPU profile of timeframe switching attributed the main scripting hotspot to repeated chart serialization, especially unchanged parallel-channel properties; marker audits and indicator detection were not the dominant sampled branch. + +`src/shared/chart-marker-save-controller.js` installs one stable base `saveChart` wrapper per native API in a page-visible symbol slot with protocol version 1. Only actual Bollinger marker creation, publication and deletion arm its burst; unchanged audits do not. Default callback saves during that burst share one complete serialization after 150 ms of quiet, capped at 1,000 ms per burst. Every pending callback receives a separate JSON snapshot, including all saveable user drawings. Callback failures are reported at a separate asynchronous job boundary, without skipping later callbacks or interrupting an unrelated explicit save. No drawings are deleted or excluded to accelerate serialization. + +This is deliberately not a fully transparent public-API replacement: default `saveChart(callback)` callers during a marker burst receive a deferred callback and no synchronous callback return value. Binance's observed autosave caller does not use that return value, but a third-party default caller in the same window has this limitation. Idle calls, explicit options (including `includeDrawings: false`), unusual arguments and foreign receivers remain synchronous. An outer order-save wrapper remains authoritative; the base never restores over it. This optimization does not cover independent Strategy 27 or manual-drawing bursts outside a Bollinger mutation window. + +Before starting a continuous-order save owner, toggling order-line visibility, or opening the native cancellation confirmation, the workflow drains pending marker mutations and their delayed-save tail. Asynchronous native creations remain counted through completion, including stale hidden results. An independent 150 ms mutation tail survives an explicit save interrupting the burst. Draining blocks new marker mutations, has a 2,000 ms timeout that refuses the chart workflow before its next action, and supports immediate continuous-task abort. The cancellation confirmation callback remains synchronous; the script does not delay, repeat or confirm a financial click. `clear()` remains synchronous and save timers settle afterward. The controller has no idle recurring timer or retained serialized snapshot; on-demand `window.__TM_STRATEGY29_DEBUG__.diagnostics.markerSaveStats` exposes aggregate counts only. + +Native asynchronous shape creation can automatically enable the interval active when it resolves. Markers therefore start with `visible: false`. Only a current session with drawing mutation ownership may synchronously publish the marker using `setProperties`, restoring its originating interval mask and setting `visible: true`; properties are read back before registration. Stale or busy results remain hidden until safe cleanup. Native visibility normalizes second resolutions of at least 60 seconds into integer-minute buckets as well. + +## Verification + +Run `npm run test:binance-strategy29-bollinger`, `npm test`, both affected +single-script builds, `npm run check:binance-userscripts` and `npm run test:ui`. +The cross-script browser fixture loads the actual generated artifacts in both +orders. Independent bundle tests cover controller identity, active owners, +save drain, protocol conflicts and unregister. Existing marker tests cover +interval switches, late results, historical coverage, foreign drawings and +cooperative rendering. + +Before a separately authorized installation/release, inspect both real installed +sources, reload once, and verify both load orders, chart interval switches, +hide/show, old-version conflict notice and the minimum non-financial orderbook +path. Fixture results do not certify the current native TradingView build. diff --git a/docs/strategy29-extraction-plan.md b/docs/strategy29-extraction-plan.md new file mode 100644 index 0000000..6c94daa --- /dev/null +++ b/docs/strategy29-extraction-plan.md @@ -0,0 +1,78 @@ +# Strategy 29 Userscript Extraction + +Status: local extraction implemented; automated validation and independent +read-only review passed. Not committed, released or installed. + +## Goal + +Extract the existing Bollinger/SMA60 observer from orderbook 2.7.198 into +`binance-strategy29-bollinger.user.js`. Keep closed-candle predicates, native +loaded-history coverage, second-through-week intervals, mirrored signals, +marker ownership and the existing performance budgets unchanged. + +## Ownership + +- Strategy29 owns detection, chart polling, session revisions, historical markers + and observer diagnostics. Its entrypoint has no account or order operations. +- Orderbook owns trading, account/order UI and depth rendering. It publishes only + a synchronous drawing-busy predicate for its existing operation owners. +- Shared page-context coordination owns save-controller identity and busy-owner + registration. Independently bundled copies must use the same versioned slot on + the exact native chart API and the same page-level owner registry. These slots + contain no credentials, positions, quantities or order commands. +- Each script works alone. When both run, either injection order must produce + one native save wrapper and preserve pre-action draining and abort behavior. +- Strategy27 remains unchanged; it does not opt into this coordination contract. + +## Migration + +Orderbook 2.7.199 removes embedded Bollinger behavior; Strategy29 starts at 0.1.0. +Install/update both together and reload the page. A running legacy orderbook +with embedded Bollinger diagnostics is an explicit conflict: the standalone +observer must refuse rendering and report the required update/disable action, +not operate a duplicate detector or delete foreign drawings. Duplicate standalone +injection must not create a second poller. + +## Implementation and Validation + +1. Move the detector byte-for-byte and relocate its tests; extract the monitor + lifecycle into an independently testable Strategy29 module. +2. Move generic abort, chart-target and marker-save utilities to shared sources; + replace module-local singleton identity with the page/API coordination contract. +3. Remove orderbook detector imports, timer, lifecycle and diagnostics; retain its + existing transaction guards and pre-action save drains. +4. Add the independent entrypoint, route/visibility/page lifecycle, duplicate and + legacy conflict behavior. Preserve existing marker and performance tests. +5. Add independently evaluated bundle tests for both load orders, either script + alone, active-operation blocking, async save drain, duplicate injection and + old/new conflict. Verify chart switches and interrupted cleanup. +6. Update build mapping, CI triggers, metadata/install contracts and manuals. + Update the Strategy29 server oracle path without changing its detector hash. +7. Run focused and full tests, affected builds/syntax, deterministic browser tests, + exact detector parity and an independent implementation review. Record live + browser validation separately; no live acceptance from fixture-only tests. + +## Non-Goals + +No server runtime, remote panel, Telegram transport, changed thresholds, new data +requests, Strategy27 changes, release/merge, Tampermonkey installation or financial +actions are included in this local extraction step. + +## Local Validation Record + +- Full Node suite: 766/766 passed. +- Chromium fixture suite: 64/64 passed, including both generated-script load orders. +- Orderbook 2.7.199 and Strategy29 0.1.0 builds, Binance artifact syntax checks, + metadata inspection and whitespace checks passed. +- Detector relocation is byte-identical to the orderbook baseline. The server + oracle compared 5,368 prefixes, 27,938 signals and 3,000 indicator rows, covering + all six signal types without changing the pinned SHA-256. +- Strategy29 server documentation/workflow checks and the updated release skill + validator passed. The server runtime and notification integration remain pending. +- Independent implementation review found no new functional issues and reran + 77/77 Strategy29 tests. Its in-memory builds matched both generated artifacts; + the reviewer performed no writes. +- The first new browser fixture run used a mismatched symbol and failed correctly; + the fixture now uses its canonical symbol. No production guard was relaxed. +- Real Binance/Tampermonkey rendered behavior and loaded-source identity were not + inspected in this local-only step. Fixture results are not live acceptance. diff --git a/docs/userscript-validation.md b/docs/userscript-validation.md index 6e17130..11809c8 100644 --- a/docs/userscript-validation.md +++ b/docs/userscript-validation.md @@ -12,6 +12,7 @@ are owned by `skills/userscript-release/SKILL.md`. | Script | Editable source | Artifact | Focused checks | Detailed guide | | --- | --- | --- | --- | --- | +| Binance Strategy 29 Bollinger | `src/binance-strategy29-bollinger/` | `scripts/binance-strategy29-bollinger.user.js` | `npm run test:binance-strategy29-bollinger`, `npm run build:binance-strategy29-bollinger`, `npm run check:binance-strategy29-bollinger` | `docs/binance-strategy29-bollinger-development.md` | | Binance orderbook trade | `src/binance-orderbook-trade/` | `scripts/binance-orderbook-trade.user.js` | `npm run test:binance-orderbook-trade`, `npm run build:binance-orderbook-trade`, `npm run check:binance-orderbook-trade` | `docs/binance-orderbook-trade-development.md` and `docs/binance-orderbook-trade-ui-automation.md` | | Binance trading data | `src/binance-trading-data/` | `scripts/binance-trading-data.user.js` | `node --test test/unit/binance-data-panel-*.test.js`, `npm run build:binance-trading-data`, `node --check scripts/binance-trading-data.user.js` | this document | | Binance CoinMarketCap data | `src/binance-coinmarketcap-data/` | `scripts/binance-coinmarketcap-data.user.js` | `node --test test/unit/binance-data-panel-*.test.js`, `npm run build:binance-coinmarketcap-data`, `node --check scripts/binance-coinmarketcap-data.user.js` | this document | diff --git a/e2e/binance-orderbook/helpers/userscript-page.js b/e2e/binance-orderbook/helpers/userscript-page.js index 83bf292..137bd1e 100644 --- a/e2e/binance-orderbook/helpers/userscript-page.js +++ b/e2e/binance-orderbook/helpers/userscript-page.js @@ -19,7 +19,7 @@ export function readScenarioEvidence(page) { return evidenceByPage.get(page) || null; } -export async function openUserscriptScenario(page, scenario) { +export async function openUserscriptScenario(page, scenario, { beforeOrderbook = '', afterOrderbook = '' } = {}) { const errors = []; page.on('pageerror', (error) => errors.push(String(error?.stack || error))); const userscriptSource = await readFile(USERSCRIPT_PATH, 'utf8'); @@ -35,7 +35,7 @@ export async function openUserscriptScenario(page, scenario) { await route.fulfill({ status: 200, contentType: 'application/javascript', - body: userscriptSource, + body: beforeOrderbook + '\n' + userscriptSource + '\n' + afterOrderbook, }); return; } diff --git a/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js b/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js new file mode 100644 index 0000000..96f864c --- /dev/null +++ b/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js @@ -0,0 +1,61 @@ +import { readFile } from 'node:fs/promises'; +import { test, expect } from '../test.js'; +import { createCancelScenario, CURRENT_SYMBOL } from '../scenarios/cancel-current-symbol.js'; +import { openUserscriptScenario } from '../helpers/userscript-page.js'; + +const strategy29 = await readFile(new URL('../../../scripts/binance-strategy29-bollinger.user.js', import.meta.url), 'utf8'); + +for (const first of [true, false]) { + test(`independent generated scripts share chart coordination (Strategy29 first=${first})`, async ({ page }) => { + const { errors } = await openUserscriptScenario(page, createCancelScenario(), first + ? { beforeOrderbook: strategy29 } : { afterOrderbook: strategy29 }); + await page.evaluate(symbol => { + const api = document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi; + const shapes = new Map(); + let sequence = 0, seed = 29, close = 100; + const rows = Array.from({ length: 512 }, (_, index) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + const open = close; + close = Math.max(1, close + (seed / 4294967296 - 0.5) * 4); + return { 0: (index + 1) * 60, 1: open, 2: Math.max(open, close) + 0.5, + 3: Math.min(open, close) - 0.5, 4: close }; + }); + const subscription = () => { + const callbacks = new Map(); + return { subscribe: (owner, callback) => callbacks.set(owner, callback), + unsubscribe: owner => callbacks.delete(owner) }; + }; + const intervals = subscription(), loaded = subscription(); + const chart = { + hasModel: () => true, dataReady: () => true, resolution: () => '1', + symbol: () => symbol, onIntervalChanged: () => intervals, onDataLoaded: () => loaded, + exportData: async () => ({ schema: ['time', 'open', 'high', 'low', 'close'].map(type => ({ type })), data: rows }), + getAllShapes: () => [...shapes].map(([id, s]) => ({ id, name: s.options.shape })), + getShapeById: id => shapes.get(id), + removeEntity: id => shapes.delete(id), + createShape: async (point, options) => { + const id = 's29-' + (++sequence); + const properties = { ...options.overrides, icon: options.icon }; + shapes.set(id, { options, getPoints: () => [point], getProperties: () => properties, + setProperties: next => Object.assign(properties, next) }); + return id; + }, + }; + api.activeChart = () => chart; + api.saveChart = callback => callback({ drawings: ['foreign-channel'] }); + }, CURRENT_SYMBOL); + await expect.poll(() => page.evaluate(() => window.__TM_STRATEGY29_DEBUG__.diagnostics.layerSize)).toBe(9); + expect(await page.evaluate(() => ({ + embedded: Object.hasOwn(window.__TM_CLOSE_LONG_DEBUG__, 'bollingerAlertState'), + controller: document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi[ + Symbol.for('jh-userscripts.chart-marker-save-controller')].version, + owners: [...window[Symbol.for('jh-userscripts.chart-mutation-owners')].predicates.keys()], + }))).toEqual({ embedded: false, controller: 1, owners: ['orderbook'] }); + // Reinjecting the complete standalone artifact must reuse its page singleton. + await page.addScriptTag({ content: strategy29 }); + expect(await page.evaluate(() => window.__TM_STRATEGY29_DEBUG__.diagnostics.layerSize)).toBe(9); + await page.evaluate(() => window.__TM_STRATEGY29_DEBUG__.dispose()); + expect(await page.evaluate(() => document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi.activeChart().getAllShapes())).toEqual([]); + expect(errors).toEqual([]); + }); +} diff --git a/package.json b/package.json index 786a04e..c990bdc 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,11 @@ "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", + "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-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", + "test:binance-strategy29-bollinger": "node --test test/unit/binance-strategy29-bollinger/*.test.js test/dom/binance-strategy29-bollinger/*.test.js", "build:binance-orderbook-trade": "node scripts/build-userscript.mjs binance-orderbook-trade", "build:binance-trading-data": "node scripts/build-userscript.mjs binance-trading-data", "build:binance-coinmarketcap-data": "node scripts/build-userscript.mjs binance-coinmarketcap-data", @@ -17,12 +21,12 @@ "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", + "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-orderbook-trade": "node --check scripts/binance-orderbook-trade.user.js", "check:m3u8-downloader": "node --check scripts/m3u8-downloader.user.js", "test": "node --test", "test:binance-orderbook-trade": "node --test test/unit/binance-orderbook-trade/*.test.js test/dom/binance-orderbook-trade/*.test.js", - "test:binance-orderbook-ui-toolchain": "node --test test/unit/binance-orderbook-trade/*.test.js test/dom/binance-orderbook-trade/*.test.js test/unit/binance-*.test.js", + "test:binance-orderbook-ui-toolchain": "node --test test/unit/binance-orderbook-trade/*.test.js test/dom/binance-orderbook-trade/*.test.js test/unit/binance-strategy29-bollinger/*.test.js test/dom/binance-strategy29-bollinger/*.test.js test/unit/binance-*.test.js", "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", diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 5f34c50..ccedfe3 100644 --- a/scripts/binance-orderbook-trade.user.js +++ b/scripts/binance-orderbook-trade.user.js @@ -3,7 +3,7 @@ // @namespace binance.orderbook.trade // @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 2.7.198 +// @version 2.7.199 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -1172,7 +1172,7 @@ ); } - // src/binance-orderbook-trade/core/abort.js + // src/shared/abort.js function getAbortReason(signal) { if (signal?.reason instanceof Error) return signal.reason; const error = new Error("Operation aborted"); @@ -3378,7 +3378,7 @@ } } - // src/binance-orderbook-trade/dom/tradingview-target.js + // src/shared/tradingview-target.js var CHART_ROOT_SELECTOR = ".chart-widget-root"; function hasVisibleBox(element) { if (!element?.getClientRects().length) return false; @@ -3400,163 +3400,45 @@ return { chartRoot, tradingViewApi: tradingViewApis[0] }; } - // src/binance-orderbook-trade/core/chart-marker-save-controller.js - var controllers = /* @__PURE__ */ new WeakMap(); - 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 - } = {}) { - if (controllers.has(api)) return controllers.get(api); - if (typeof api?.saveChart !== "function") { - throw new Error("TradingView marker save API is unavailable"); + // src/shared/chart-mutation-owners.js + var OWNER_SLOT = Symbol.for("jh-userscripts.chart-mutation-owners"); + var VERSION = 1; + function owners(view) { + if (view[OWNER_SLOT] === void 0) { + Object.defineProperty(view, OWNER_SLOT, { + value: Object.freeze({ version: VERSION, predicates: /* @__PURE__ */ new Map() }) + }); } - 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); - } + const record = view[OWNER_SLOT]; + if (record.version !== VERSION || !(record.predicates instanceof Map)) { + throw new Error("Incompatible chart mutation protocol; update both scripts and reload"); } - function scheduleQuiet() { - clearTimeoutFn(burst.quietTimer); - burst.quietTimer = setTimeoutFn(flush, QUIET_MS); + return record.predicates; + } + function registerChartMutationOwner(view, name, predicate) { + const registry = owners(view); + if (registry.has(name)) throw new Error("Duplicate chart mutation owner"); + if (typeof predicate !== "function") throw new Error("Chart mutation owner requires a predicate"); + registry.set(name, predicate); + return () => { + if (registry.get(name) === predicate) registry.delete(name); + }; + } + + // src/shared/chart-marker-save-controller.js + var CONTROLLER_SLOT = Symbol.for("jh-userscripts.chart-marker-save-controller"); + var PROTOCOL_VERSION = 1; + function readController(api) { + const record = api[CONTROLLER_SLOT]; + if (record === void 0) return null; + if (record.version !== PROTOCOL_VERSION || typeof record.controller?.runAfterIdle !== "function") { + throw new Error("Incompatible TradingView marker save protocol; update both scripts and reload"); } - function 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 - }) - }); - controllers.set(api, controller); - return controller; + return record.controller; } function afterTradingViewMarkerSaves(api, action, options) { throwIfAborted(options?.signal); - const controller = controllers.get(api); + const controller = readController(api); return controller ? controller.runAfterIdle(action, options) : action(); } @@ -4910,872 +4792,6 @@ }); } - // src/binance-orderbook-trade/core/bearish-bollinger-pattern.js - var BOLLINGER_PATTERN = Object.freeze({ - bollingerPeriod: 20, - bollingerStdDev: 2, - maPeriod: 60, - preCrossBars: 8, - minPreCrossChannelCloses: 4, - maxPreCrossAboveMiddleCloses: 1, - maxPreCrossBelowLowerCloses: 3, - trendLookbackBars: 3, - minMiddleDeclineBandFraction: 0.01, - postCrossBars: 20, - middleApproachBandFraction: 0.12, - maxPostCrossCloseAboveMiddleBandFraction: 0.05, - lowerTouchBandFraction: 0.05, - reversalFollowBars: 60 - }); - var TradingViewBarSnapshotInconsistentError = class extends Error { - constructor(message) { - super(message); - this.name = "TradingViewBarSnapshotInconsistentError"; - } - }; - function isTradingViewBarSnapshotInconsistentError(error) { - return error instanceof TradingViewBarSnapshotInconsistentError; - } - function applyBollingerAlertTaskFailure(context, error) { - if (!context || typeof context !== "object" || typeof context.failed !== "boolean" || typeof context.cleanupPending !== "boolean") { - throw new Error("Bollinger alert task context is invalid"); - } - if (isTradingViewBarSnapshotInconsistentError(error)) return "retry"; - context.failed = true; - context.cleanupPending = true; - return "fatal"; - } - function assertFiniteNumber(value, label) { - if (!Number.isFinite(value)) throw new Error(`${label} is invalid`); - } - function assertBars(bars, directionLabel) { - if (!Array.isArray(bars)) throw new Error(`${directionLabel} Bollinger bars must be an array`); - let previousTime = -Infinity; - for (const [index, bar] of bars.entries()) { - if (!bar || typeof bar !== "object") { - throw new Error(`${directionLabel} Bollinger bar ${index} is invalid`); - } - if (!Number.isInteger(bar.time)) { - throw new Error(`${directionLabel} Bollinger bar time ${index} is invalid`); - } - if (bar.time <= previousTime) { - throw new TradingViewBarSnapshotInconsistentError( - `${directionLabel} Bollinger bar time ${index} is invalid` - ); - } - for (const field of ["open", "high", "low", "close"]) { - assertFiniteNumber(bar[field], `${directionLabel} Bollinger bar ${index} ${field}`); - } - if (bar.high < bar.low || bar.high < Math.max(bar.open, bar.close) || bar.low > Math.min(bar.open, bar.close)) { - throw new TradingViewBarSnapshotInconsistentError( - `${directionLabel} Bollinger bar ${index} OHLC range is invalid` - ); - } - previousTime = bar.time; - } - } - function assertIndicatorBars(indicatorBars, directionLabel) { - if (!Array.isArray(indicatorBars)) { - throw new Error(`${directionLabel} Bollinger indicator bars must be an array`); - } - assertBars(indicatorBars, directionLabel); - for (const [index, bar] of indicatorBars.entries()) { - const fields = ["middle", "upper", "lower", "ma60"]; - const nullFields = fields.filter((field) => bar[field] === null); - if (nullFields.length !== 0 && nullFields.length !== fields.length) { - throw new Error(`${directionLabel} Bollinger indicator bar ${index} is incomplete`); - } - for (const field of fields) { - if (bar[field] !== null) { - assertFiniteNumber( - bar[field], - `${directionLabel} Bollinger indicator bar ${index} ${field}` - ); - } - } - } - } - function calculateBollingerIndicatorBars(bars, directionLabel) { - assertBars(bars, directionLabel); - const config = BOLLINGER_PATTERN; - return bars.map((bar, index) => { - if (index < config.maPeriod - 1) { - return { ...bar, middle: null, upper: null, lower: null, ma60: null }; - } - const start = index - config.bollingerPeriod + 1; - let closeSum = 0; - for (let cursor = start; cursor <= index; cursor += 1) closeSum += bars[cursor].close; - const middle = closeSum / config.bollingerPeriod; - let squaredDeviationSum = 0; - for (let cursor = start; cursor <= index; cursor += 1) { - squaredDeviationSum += (bars[cursor].close - middle) ** 2; - } - const deviation = Math.sqrt(squaredDeviationSum / config.bollingerPeriod) * config.bollingerStdDev; - let maSum = 0; - for (let cursor = index - config.maPeriod + 1; cursor <= index; cursor += 1) maSum += bars[cursor].close; - return { - ...bar, - middle, - upper: middle + deviation, - lower: middle - deviation, - ma60: maSum / config.maPeriod - }; - }); - } - function bandWidth(bar) { - const width = bar.upper - bar.lower; - if (!(width > 0)) throw new Error(`Bollinger band width is invalid at ${bar.time}`); - return width; - } - function hasDownwardBandCenter(indicatorBars, index) { - const { trendLookbackBars, minMiddleDeclineBandFraction } = BOLLINGER_PATTERN; - const current = indicatorBars[index]; - const earlier = indicatorBars[index - trendLookbackBars]; - const averageWidth = (bandWidth(current) + bandWidth(earlier)) / 2; - return (earlier.middle - current.middle) / averageWidth >= minMiddleDeclineBandFraction; - } - function isRejectedAboveMiddleClose(indicatorBars, index) { - const next = indicatorBars[index + 1]; - return next.close < next.middle && next.close < next.open; - } - function matchesPreCrossCompression(indicatorBars, crossIndex) { - const config = BOLLINGER_PATTERN; - const start = crossIndex - config.preCrossBars; - const preCross = indicatorBars.slice(start, crossIndex); - const channelCloses = preCross.filter( - (bar) => bar.close >= bar.lower && bar.close <= bar.middle - ).length; - const aboveMiddleIndexes = []; - let belowLowerCloses = 0; - for (let offset = 0; offset < preCross.length; offset += 1) { - const bar = preCross[offset]; - if (bar.close > bar.middle) aboveMiddleIndexes.push(start + offset); - if (bar.close < bar.lower) belowLowerCloses += 1; - } - return channelCloses >= config.minPreCrossChannelCloses && aboveMiddleIndexes.length <= config.maxPreCrossAboveMiddleCloses && belowLowerCloses <= config.maxPreCrossBelowLowerCloses && aboveMiddleIndexes.every((index) => isRejectedAboveMiddleClose(indicatorBars, index)); - } - function isDownwardCross(previous, current) { - return previous.middle >= previous.ma60 && current.middle < current.ma60; - } - function buildSignal(type, setup, bar) { - const width = bandWidth(bar); - const markerGapFraction = type === "warning" ? 0.06 : 0.1; - return Object.freeze({ - id: `${setup.time}:${type}`, - type, - setupTime: setup.time, - time: bar.time, - markerPrice: type === "reversal" ? bar.low - width * markerGapFraction : bar.high + width * markerGapFraction - }); - } - function detectReversalSignal(indicatorBars, setup, warningIndex) { - const { reversalFollowBars } = BOLLINGER_PATTERN; - const warning = indicatorBars[warningIndex]; - const endIndex = Math.min( - indicatorBars.length - 1, - warningIndex + reversalFollowBars - ); - for (let index = warningIndex + 1; index <= endIndex; index += 1) { - const bar = indicatorBars[index]; - if (bar.close > warning.high) return buildSignal("reversal", setup, bar); - } - return null; - } - function detectSetupSignals(indicatorBars, crossIndex) { - const config = BOLLINGER_PATTERN; - const setup = indicatorBars[crossIndex]; - const signals = []; - let warningIndex = null; - let pendingMiddleRejection = false; - let aboveMiddleCloseCount = 0; - const endIndex = Math.min( - indicatorBars.length - 1, - crossIndex + config.postCrossBars - ); - for (let index = crossIndex + 1; index <= endIndex; index += 1) { - const bar = indicatorBars[index]; - const width = bandWidth(bar); - if (pendingMiddleRejection) { - if (!(bar.close < bar.middle && bar.close < bar.open)) break; - pendingMiddleRejection = false; - } - if (bar.close > bar.middle) { - aboveMiddleCloseCount += 1; - if (aboveMiddleCloseCount > 1 || bar.close > bar.middle + width * config.maxPostCrossCloseAboveMiddleBandFraction || bar.close > bar.upper) break; - pendingMiddleRejection = true; - continue; - } - const bandStillDown = bar.middle < setup.middle && hasDownwardBandCenter(indicatorBars, index); - if (!bandStillDown) continue; - if (warningIndex === null && bar.high >= bar.middle - width * config.middleApproachBandFraction) { - warningIndex = index; - signals.push(buildSignal("warning", setup, bar)); - continue; - } - if (warningIndex !== null && index > warningIndex && bar.close < bar.open && bar.low <= bar.lower + width * config.lowerTouchBandFraction) { - signals.push(buildSignal("confirmed", setup, bar)); - break; - } - } - if (warningIndex !== null) { - const reversal = detectReversalSignal(indicatorBars, setup, warningIndex); - if (reversal) signals.push(reversal); - } - return signals; - } - function appendSetupSignals(signals, setupSignals) { - for (const signal of setupSignals) { - if (signal.type !== "reversal") { - signals.push(signal); - continue; - } - const duplicateIndex = signals.findIndex( - (existing) => existing.type === "reversal" && existing.time === signal.time - ); - if (duplicateIndex === -1) { - signals.push(signal); - continue; - } - if (signal.setupTime > signals[duplicateIndex].setupTime) { - signals[duplicateIndex] = signal; - } - } - } - function detectBearishBollingerSignalsFromIndicatorBarsInternal(indicatorBars) { - const config = BOLLINGER_PATTERN; - const firstCrossIndex = Math.max( - config.maPeriod, - config.maPeriod - 1 + config.preCrossBars, - config.trendLookbackBars - ); - const signals = []; - for (let index = firstCrossIndex; index < indicatorBars.length; index += 1) { - const previous = indicatorBars[index - 1]; - const current = indicatorBars[index]; - if (!isDownwardCross(previous, current)) continue; - if (!hasDownwardBandCenter(indicatorBars, index)) continue; - if (!matchesPreCrossCompression(indicatorBars, index)) continue; - appendSetupSignals(signals, detectSetupSignals(indicatorBars, index)); - } - const typeOrder = { warning: 0, confirmed: 1, reversal: 2 }; - return signals.sort( - (left, right) => left.time - right.time || typeOrder[left.type] - typeOrder[right.type] - ); - } - function mirrorIndicatorBar(bar) { - return { - ...bar, - open: -bar.open, - high: -bar.low, - low: -bar.high, - close: -bar.close, - middle: bar.middle === null ? null : -bar.middle, - upper: bar.upper === null ? null : -bar.lower, - lower: bar.lower === null ? null : -bar.upper, - ma60: bar.ma60 === null ? null : -bar.ma60 - }; - } - function mapMirroredBullishSignal(signal) { - return Object.freeze({ - ...signal, - id: `${signal.setupTime}:bullish:${signal.type}`, - direction: "bullish", - markerPrice: -signal.markerPrice - }); - } - function detectBullishBollingerSignalsFromIndicatorBarsInternal(indicatorBars) { - return detectBearishBollingerSignalsFromIndicatorBarsInternal( - indicatorBars.map(mirrorIndicatorBar) - ).map(mapMirroredBullishSignal); - } - function compareBollingerSignals(left, right) { - const directionOrder = { bearish: 0, bullish: 1 }; - const typeOrder = { warning: 0, confirmed: 1, reversal: 2 }; - const leftDirectionOrder = directionOrder[left.direction]; - const rightDirectionOrder = directionOrder[right.direction]; - if (leftDirectionOrder === void 0 || rightDirectionOrder === void 0) { - throw new Error("Bollinger signal direction is invalid"); - } - return left.time - right.time || leftDirectionOrder - rightDirectionOrder || typeOrder[left.type] - typeOrder[right.type]; - } - function detectBollingerSignalsFromIndicatorBars(indicatorBars) { - assertIndicatorBars(indicatorBars, "Bollinger"); - const bearishSignals = detectBearishBollingerSignalsFromIndicatorBarsInternal(indicatorBars).map((signal) => Object.freeze({ ...signal, direction: "bearish" })); - const bullishSignals = detectBullishBollingerSignalsFromIndicatorBarsInternal(indicatorBars); - return [...bearishSignals, ...bullishSignals].sort(compareBollingerSignals); - } - function detectBollingerSignals(bars) { - return detectBollingerSignalsFromIndicatorBars( - calculateBollingerIndicatorBars(bars, "Bollinger") - ); - } - function isBollingerDrawingMutationBlocked(state) { - if (!state || typeof state !== "object") { - throw new Error("Bollinger drawing state is invalid"); - } - return [ - state.ladderTask, - state.continuousLadderTask, - state.singleOrderTask, - state.cancelCurrentSymbolOpenOrdersTask, - state.chartOrdersRecoveryTask, - state.continuousChartSaveController - ].some((value) => value !== null); - } - - // src/binance-orderbook-trade/dom/tradingview-bearish-alerts.js - var MAX_BOLLINGER_MARKERS_PER_DIRECTION = 1e3; - var MAX_BOLLINGER_MARKERS = MAX_BOLLINGER_MARKERS_PER_DIRECTION * 2; - function routeSymbolFromChartSymbol(value) { - return String(value || "").split("@", 1)[0]; - } - function assertChartContract(chart) { - for (const method of [ - "createShape", - "dataReady", - "exportData", - "getAllShapes", - "getShapeById", - "hasModel", - "onDataLoaded", - "onIntervalChanged", - "removeEntity", - "resolution", - "symbol" - ]) { - if (typeof chart?.[method] !== "function") { - throw new Error(`TradingView Bollinger alert method is unavailable: ${method}`); - } - } - } - function readLiveShapes(chart) { - const shapes = chart.getAllShapes(); - if (!Array.isArray(shapes)) { - throw new Error("TradingView Bollinger alert shape list is invalid"); - } - const ids = /* @__PURE__ */ new Map(); - for (const [index, shape] of shapes.entries()) { - if (typeof shape?.id !== "string" || shape.id.length === 0 || typeof shape.name !== "string") { - throw new Error(`TradingView Bollinger alert shape ${index} id is invalid`); - } - ids.set(shape.id, shape.name); - } - return ids; - } - function tradingViewResolutionToSeconds(resolution) { - const value = String(resolution || "").toUpperCase(); - const units = [ - { pattern: /^(\d+)S$/, seconds: 1 }, - { pattern: /^(\d+)$/, seconds: 60 }, - { pattern: /^(\d+)H$/, seconds: 60 * 60 }, - { pattern: /^(\d+)D$/, seconds: 24 * 60 * 60 }, - { pattern: /^(\d+)W$/, seconds: 7 * 24 * 60 * 60 } - ]; - for (const { pattern, seconds } of units) { - const match = value.match(pattern); - if (!match) continue; - const count = Number(match[1]); - if (Number.isSafeInteger(count) && count > 0) return count * seconds; - } - throw new Error(`TradingView Bollinger alert resolution is unsupported: ${resolution}`); - } - function bollingerIntervalVisibility(resolution) { - const seconds = tradingViewResolutionToSeconds(resolution); - const value = String(resolution).toUpperCase(); - const visibility = { - ticks: false, - seconds: false, - minutes: false, - hours: false, - days: false, - weeks: false, - months: false, - ranges: false - }; - let unit; - let count; - if (value.endsWith("W")) { - unit = "weeks"; - count = seconds / 604800; - } else if (value.endsWith("D")) { - unit = "days"; - count = seconds / 86400; - } else if (seconds < 60) { - unit = "seconds"; - count = seconds; - } else if (value.endsWith("S") || seconds < 3600) { - unit = "minutes"; - count = Math.floor(seconds / 60); - } else { - unit = "hours"; - count = Math.floor(seconds / 3600); - } - visibility[unit] = true; - visibility[`${unit}From`] = count; - visibility[`${unit}To`] = count; - return visibility; - } - function createBollingerIntervalSession(chart) { - const intervalChanged = chart.onIntervalChanged(); - const dataLoaded = chart.onDataLoaded(); - for (const subscription of [intervalChanged, dataLoaded]) { - if (typeof subscription?.subscribe !== "function" || typeof subscription.unsubscribe !== "function") { - throw new Error("TradingView Bollinger interval subscription is unavailable"); - } - } - let revision = 0; - let disposed = false; - let awaitingData = !chart.dataReady(); - const owner = {}; - function invalidate() { - revision += 1; - awaitingData = true; - } - function complete() { - awaitingData = false; - } - intervalChanged.subscribe(owner, invalidate); - dataLoaded.subscribe(owner, complete); - return Object.freeze({ - get revision() { - return revision; - }, - isCurrent(candidate) { - return !disposed && !awaitingData && candidate === revision && chart.dataReady(); - }, - dispose() { - if (disposed) return; - disposed = true; - revision += 1; - intervalChanged.unsubscribe(owner, invalidate); - dataLoaded.unsubscribe(owner, complete); - } - }); - } - function findBearishBollingerChartTarget(document2, expectedRouteSymbol) { - const baseTarget = findBinanceTradingViewTarget(document2); - if (!baseTarget) return null; - const chart = baseTarget.tradingViewApi.activeChart?.(); - if (!chart) return null; - assertChartContract(chart); - if (!chart.hasModel()) return null; - const resolution = chart.resolution(); - const resolutionSeconds = tradingViewResolutionToSeconds(resolution); - const routeSymbol = routeSymbolFromChartSymbol(chart.symbol()); - if (routeSymbol !== expectedRouteSymbol) { - throw new Error( - `TradingView Bollinger alert symbol mismatch: expected ${expectedRouteSymbol}, received ${routeSymbol}` - ); - } - return { - ...baseTarget, - chart, - resolution, - resolutionSeconds, - routeSymbol - }; - } - function isBearishBollingerChartTargetCurrent(document2, target) { - const baseTarget = findBinanceTradingViewTarget(document2); - if (!baseTarget) return false; - const chart = baseTarget.tradingViewApi.activeChart?.(); - if (!chart) return false; - assertChartContract(chart); - if (!chart.hasModel()) return false; - return baseTarget.chartRoot === target.chartRoot && baseTarget.tradingViewApi === target.tradingViewApi && chart === target.chart && chart.resolution() === target.resolution && routeSymbolFromChartSymbol(chart.symbol()) === target.routeSymbol; - } - function assertExportSchema(schema) { - if (!Array.isArray(schema)) throw new Error("TradingView Bollinger alert export schema is invalid"); - const fields = schema.map((column) => column.plotTitle || column.type); - const expected = ["time", "open", "high", "low", "close"]; - if (fields.length !== expected.length || fields.some((field, index) => field !== expected[index])) { - throw new Error(`TradingView Bollinger alert export schema mismatch: ${fields.join(",")}`); - } - } - function parseExportRow(row, index) { - if (!row || typeof row !== "object") { - throw new Error(`TradingView Bollinger alert export row ${index} is invalid`); - } - const bar = { - time: row[0], - open: row[1], - high: row[2], - low: row[3], - close: row[4] - }; - if (!Number.isInteger(bar.time)) { - throw new Error(`TradingView Bollinger alert export time ${index} is invalid`); - } - for (const field of ["open", "high", "low", "close"]) { - if (!Number.isFinite(bar[field])) { - throw new Error(`TradingView Bollinger alert export ${field} ${index} is invalid`); - } - } - return bar; - } - function parseClosedTradingViewBars(exported, { resolutionSeconds, observedAtSeconds, resolution }) { - if (!Number.isSafeInteger(resolutionSeconds) || resolutionSeconds < 1) { - throw new Error("TradingView Bollinger alert resolution seconds are invalid"); - } - if (!Number.isFinite(observedAtSeconds)) { - throw new Error("TradingView Bollinger alert observation time is invalid"); - } - assertExportSchema(exported?.schema); - if (!Array.isArray(exported.data)) { - throw new Error("TradingView Bollinger alert export data is invalid"); - } - const bars = exported.data.map(parseExportRow); - const gridSeconds = Math.min(resolutionSeconds, 86400); - for (const [index, bar] of bars.entries()) { - if (bar.time % gridSeconds !== 0 || String(resolution).toUpperCase().endsWith("W") && new Date(bar.time * 1e3).getUTCDay() !== 1) { - throw new TradingViewBarSnapshotInconsistentError( - `TradingView Bollinger alert export interval grid is invalid at ${index}` - ); - } - } - for (let index = 1; index < bars.length; index += 1) { - if (bars[index].time <= bars[index - 1].time) { - throw new TradingViewBarSnapshotInconsistentError( - `TradingView Bollinger alert export order is invalid at ${index}` - ); - } - if ((bars[index].time - bars[index - 1].time) % resolutionSeconds !== 0) { - throw new TradingViewBarSnapshotInconsistentError( - `TradingView Bollinger alert export interval spacing is invalid at ${index}` - ); - } - } - return bars.filter((bar) => bar.time + resolutionSeconds <= observedAtSeconds); - } - function buildClosedBarsWindowKey(bars) { - if (!Array.isArray(bars) || bars.length === 0) { - throw new Error("TradingView Bollinger alert closed-bar window is empty"); - } - return `${bars.length}:${bars[0].time}:${bars.at(-1).time}`; - } - function writeClosedBarToSnapshot(values, offset, bar, index) { - if (!bar || typeof bar !== "object") { - throw new Error(`TradingView Bollinger closed bar ${index} is invalid`); - } - if (!Number.isInteger(bar.time)) { - throw new Error(`TradingView Bollinger closed bar time ${index} is invalid`); - } - const fields = ["open", "high", "low", "close"]; - for (const field of fields) { - if (!Number.isFinite(bar[field])) { - throw new Error(`TradingView Bollinger closed bar ${field} ${index} is invalid`); - } - } - values[offset] = bar.time; - values[offset + 1] = bar.open; - values[offset + 2] = bar.high; - values[offset + 3] = bar.low; - values[offset + 4] = bar.close; - } - function buildClosedBarsContentSnapshot(bars) { - const windowKey = buildClosedBarsWindowKey(bars); - const values = new Float64Array(bars.length * 5); - for (let index = 0; index < bars.length; index += 1) { - writeClosedBarToSnapshot(values, index * 5, bars[index], index); - } - return { windowKey, values }; - } - function matchesClosedBarsContentSnapshot(bars, snapshot) { - if (!snapshot || typeof snapshot !== "object" || typeof snapshot.windowKey !== "string" || !(snapshot.values instanceof Float64Array)) { - throw new Error("TradingView Bollinger closed-bar snapshot is invalid"); - } - if (buildClosedBarsWindowKey(bars) !== snapshot.windowKey) return false; - if (snapshot.values.length !== bars.length * 5) return false; - const candidate = new Float64Array(5); - for (let index = 0; index < bars.length; index += 1) { - writeClosedBarToSnapshot(candidate, 0, bars[index], index); - const offset = index * 5; - for (let fieldIndex = 0; fieldIndex < candidate.length; fieldIndex += 1) { - if (!Object.is(snapshot.values[offset + fieldIndex], candidate[fieldIndex])) return false; - } - } - return true; - } - async function reconcileBearishBollingerAlertWindow({ - bars, - cachedWindowKey, - cachedContentSnapshot = null, - cachedSignals, - detectSignals, - renderSignals - }) { - if (typeof detectSignals !== "function") { - throw new Error("TradingView Bollinger alert detector is unavailable"); - } - if (typeof renderSignals !== "function") { - throw new Error("TradingView Bollinger alert renderer is unavailable"); - } - const closedBarsWindowKey = buildClosedBarsWindowKey(bars); - const contentUnchanged = closedBarsWindowKey === cachedWindowKey && cachedContentSnapshot !== null && matchesClosedBarsContentSnapshot(bars, cachedContentSnapshot); - const signals = contentUnchanged ? cachedSignals : detectSignals(bars); - if (!Array.isArray(signals)) { - throw new Error("Bollinger signal cache is invalid"); - } - const rendered = await renderSignals(signals); - if (typeof rendered !== "boolean") { - throw new Error("TradingView Bollinger alert render result is invalid"); - } - return { - rendered, - closedBarsWindowKey, - closedBarsContentSnapshot: contentUnchanged ? cachedContentSnapshot : buildClosedBarsContentSnapshot(bars), - signals - }; - } - async function exportClosedTradingViewBars(target, session, observedAtMs = Date.now()) { - const revision = session.revision; - const isCurrent = () => session.isCurrent(revision) && target.chart.resolution() === target.resolution && routeSymbolFromChartSymbol(target.chart.symbol()) === target.routeSymbol; - if (!isCurrent()) return null; - const exported = await target.chart.exportData({ includedStudies: [] }); - if (!isCurrent()) return null; - return parseClosedTradingViewBars(exported, { - resolutionSeconds: target.resolutionSeconds, - resolution: target.resolution, - observedAtSeconds: observedAtMs / 1e3 - }); - } - function markerOptions(signal, resolution) { - const direction = signal.direction; - if (direction !== "bearish" && direction !== "bullish") { - throw new Error(`TradingView Bollinger alert signal direction is invalid: ${direction}`); - } - const isBullish = direction === "bullish"; - const common = { - lock: true, - disableSave: true, - disableSelection: true, - disableUndo: true, - showInObjectsTree: false - }; - if (signal.type === "warning") { - return { - ...common, - shape: "icon", - icon: 61713, - overrides: { - visible: true, - intervalsVisibilities: bollingerIntervalVisibility(resolution), - color: isBullish ? "#0ECB81" : "#F6465D", - size: 10 - } - }; - } - if (signal.type === "confirmed") { - return { - ...common, - shape: isBullish ? "arrow_up" : "arrow_down", - overrides: { - visible: true, - intervalsVisibilities: bollingerIntervalVisibility(resolution), - color: isBullish ? "#0ECB81" : "#F6465D", - arrowColor: isBullish ? "#0ECB81" : "#F6465D" - } - }; - } - if (signal.type === "reversal") { - return { - ...common, - shape: isBullish ? "arrow_down" : "arrow_up", - overrides: { - visible: true, - intervalsVisibilities: bollingerIntervalVisibility(resolution), - color: isBullish ? "#F6465D" : "#0ECB81", - arrowColor: isBullish ? "#F6465D" : "#0ECB81" - } - }; - } - throw new Error(`TradingView Bollinger alert signal type is invalid: ${signal.type}`); - } - function readMarkerPoint(shape) { - const points = shape?.getPoints?.(); - if (!Array.isArray(points) || points.length !== 1 || !Number.isInteger(points[0].time) || !Number.isFinite(points[0].price)) { - throw new Error("TradingView Bollinger alert marker point is invalid"); - } - return points[0]; - } - function markerPropertiesMatch(shape, options) { - const properties = shape.getProperties(); - if (!properties || typeof properties !== "object") { - throw new Error("TradingView Bollinger alert marker properties are invalid"); - } - if (options.icon !== void 0 && properties.icon !== options.icon) return false; - for (const [key, expected] of Object.entries(options.overrides)) { - if (key === "intervalsVisibilities") { - if (!properties[key] || Object.entries(expected).some(([unit, value]) => properties[key][unit] !== value)) return false; - } else if (properties[key] !== expected) return false; - } - return true; - } - function normalizeSignal(signal, index, defaultDirection) { - if (!signal || typeof signal !== "object") { - throw new Error(`TradingView Bollinger alert signal ${index} is invalid`); - } - if (typeof signal.id !== "string" || signal.id.length === 0) { - throw new Error(`TradingView Bollinger alert signal ${index} id is invalid`); - } - const direction = signal.direction === void 0 ? defaultDirection : signal.direction; - if (direction !== "bearish" && direction !== "bullish") { - throw new Error(`TradingView Bollinger alert signal ${index} direction is invalid: ${direction}`); - } - return signal.direction === direction ? signal : { ...signal, direction }; - } - function createMarkerLayer(target, defaultDirection, { - canMutate: canMutateExternally = () => true, - onSaveError, - yieldToBrowser = () => new Promise((resolve) => setTimeout(resolve, 0)) - } = {}) { - const { chart } = target; - const saveController = installTradingViewMarkerSaveController(target.tradingViewApi, { onError: onSaveError }); - const canMutate = () => canMutateExternally() && saveController.canMutate(); - const registry = /* @__PURE__ */ new Map(); - const pendingMarkers = /* @__PURE__ */ new Set(); - let generation = 0; - let creating = 0; - function mutate(action) { - const finish = saveController.beginMutation(); - try { - return action(); - } finally { - finish(); - } - } - function removePendingMarkers() { - if (pendingMarkers.size === 0 || !canMutate()) return; - const liveShapeIds = readLiveShapes(chart); - for (const id of pendingMarkers) { - if (liveShapeIds.has(id)) mutate(() => chart.removeEntity(id)); - pendingMarkers.delete(id); - } - } - function discardMissingSignals(liveShapeIds) { - for (const [signalId, record] of registry) { - if (!liveShapeIds.has(record.markerId)) registry.delete(signalId); - } - } - function removeSignal(signalId, liveShapeIds) { - const record = registry.get(signalId); - if (!record) return; - if (liveShapeIds.has(record.markerId)) { - mutate(() => chart.removeEntity(record.markerId)); - liveShapeIds.delete(record.markerId); - } - registry.delete(signalId); - } - return Object.freeze({ - async render(signals, { isCurrent }) { - if (!Array.isArray(signals)) throw new Error("TradingView Bollinger alert signals are invalid"); - if (signals.length > MAX_BOLLINGER_MARKERS) { - throw new Error( - `TradingView Bollinger alert marker limit exceeded: ${signals.length}` - ); - } - if (typeof isCurrent !== "function") { - throw new Error("TradingView Bollinger alert current-target validator is unavailable"); - } - const normalizedSignals = signals.map((signal, index) => normalizeSignal(signal, index, defaultDirection)); - const directionCounts = { bearish: 0, bullish: 0 }; - for (const signal of normalizedSignals) { - directionCounts[signal.direction] += 1; - if (directionCounts[signal.direction] > MAX_BOLLINGER_MARKERS_PER_DIRECTION) { - throw new Error( - `TradingView Bollinger alert ${signal.direction} marker limit exceeded: ` + directionCounts[signal.direction] - ); - } - } - const requestedGeneration = generation; - if (!isCurrent() || !canMutate()) return false; - removePendingMarkers(); - let liveShapeIds = readLiveShapes(chart); - discardMissingSignals(liveShapeIds); - const nextIds = new Set(normalizedSignals.map((signal) => signal.id)); - for (const signalId of [...registry.keys()]) { - if (!nextIds.has(signalId)) removeSignal(signalId, liveShapeIds); - } - let batchStartedAt = performance.now(); - let batchOps = 0; - for (const signal of normalizedSignals) { - if (batchOps > 0 && (batchOps >= 32 || performance.now() - batchStartedAt >= 8)) { - await yieldToBrowser(); - if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; - liveShapeIds = readLiveShapes(chart); - discardMissingSignals(liveShapeIds); - batchStartedAt = performance.now(); - batchOps = 0; - } - if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; - batchOps += 1; - const options = markerOptions(signal, target.resolution); - const existing = registry.get(signal.id); - if (existing) { - const shape = chart.getShapeById(existing.markerId); - const point = readMarkerPoint(shape); - if (point.time === signal.time && point.price === existing.resolvedPrice && existing.markerPrice === signal.markerPrice && existing.type === signal.type && existing.direction === signal.direction && liveShapeIds.get(existing.markerId) === options.shape && markerPropertiesMatch(shape, options)) continue; - removeSignal(signal.id, liveShapeIds); - } - const finishCreation = saveController.beginMutation(); - creating += 1; - try { - const markerId = await chart.createShape({ time: signal.time, price: signal.markerPrice }, { - ...options, - overrides: { ...options.overrides, visible: false } - }); - if (typeof markerId !== "string" || markerId.length === 0) { - throw new Error("TradingView returned an invalid Bollinger alert shape id"); - } - pendingMarkers.add(markerId); - if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; - const shape = chart.getShapeById(markerId); - const point = readMarkerPoint(shape); - if (point.time !== signal.time) { - throw new Error(`TradingView Bollinger alert time alignment failed for ${signal.time}`); - } - if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; - mutate(() => shape.setProperties(options.overrides, false)); - if (!markerPropertiesMatch(shape, options)) { - throw new Error("TradingView Bollinger alert marker properties were not applied"); - } - registry.set(signal.id, { - markerId, - resolvedPrice: point.price, - markerPrice: signal.markerPrice, - type: signal.type, - direction: signal.direction - }); - pendingMarkers.delete(markerId); - } finally { - finishCreation(); - creating -= 1; - removePendingMarkers(); - } - } - return true; - }, - clear() { - generation += 1; - if (!canMutate()) return false; - removePendingMarkers(); - const liveShapeIds = readLiveShapes(chart); - discardMissingSignals(liveShapeIds); - for (const signalId of [...registry.keys()]) removeSignal(signalId, liveShapeIds); - return creating === 0 && pendingMarkers.size === 0; - }, - get size() { - return registry.size; - }, - get saveStats() { - return saveController.getStats(); - } - }); - } - function createBollingerMarkerLayer(target, options) { - return createMarkerLayer(target, void 0, options); - } - // src/binance-orderbook-trade/index.user.js (function() { "use strict"; @@ -5949,7 +4965,6 @@ const TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS = 3; const TRADE_ACTION_BUTTON_READY_TIMEOUT_MS = TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS * 1e3; const ROUTE_WATCHDOG_MS = 5e3; - const BEARISH_BOLLINGER_ALERT_POLL_MS = 1e3; let lastTs = 0; let isEditingMultiplier = false; let multiplierEditContext = null; @@ -6037,11 +5052,6 @@ let depthProfileObserverRoot = null; let depthProfileRenderQueued = false; let depthProfileSyncQueued = false; - let bearishBollingerAlertTimer = null; - let bearishBollingerAlertTask = null; - let bearishBollingerAlertContext = null; - let bollingerIntervalSession = null; - const retiredBollingerLayers = /* @__PURE__ */ new Set(); const controlledNativeButtons = /* @__PURE__ */ new Set(); let lastObservedSymbol = getCurrentSymbol(); const MODE_HINT_ID = "jh-binance-trade-mode-hint"; @@ -6088,175 +5098,19 @@ function err(...args) { emit("ERR", ...args); } - function isTradingViewDrawingMutationBusy() { - return isBollingerDrawingMutationBlocked({ + const unregisterChartMutationOwner = registerChartMutationOwner(window, "orderbook", () => { + return [ ladderTask, continuousLadderTask, singleOrderTask, cancelCurrentSymbolOpenOrdersTask, chartOrdersRecoveryTask, continuousChartSaveController - }); - } - function clearBearishBollingerAlertContext() { - if (bearishBollingerAlertContext) { - retiredBollingerLayers.add(bearishBollingerAlertContext.layer); - bearishBollingerAlertContext = null; - } - return clearRetiredBollingerLayers(); - } - function clearRetiredBollingerLayers() { - if (isTradingViewDrawingMutationBusy()) return false; - for (const layer of retiredBollingerLayers) { - if (layer.clear()) retiredBollingerLayers.delete(layer); - } - return retiredBollingerLayers.size === 0; - } - function disposeBollingerIntervalSession() { - if (bollingerIntervalSession) { - bollingerIntervalSession.session.dispose(); - bollingerIntervalSession = null; - } - } - function isBearishBollingerAlertContextCurrent(context) { - return bearishBollingerAlertContext === context && context.intervalSession === bollingerIntervalSession?.session && context.intervalSession.isCurrent(context.intervalRevision) && !document.hidden && isFuturesTradingPage() && !isTradingViewDrawingMutationBusy() && getCurrentSymbol() === context.routeSymbol && isBearishBollingerChartTargetCurrent(document, context.target); - } - async function synchronizeBearishBollingerAlerts() { - if (document.hidden || !isFuturesTradingPage()) return; - const routeSymbol = getCurrentSymbol(); - if (!routeSymbol) return; - let target; - try { - target = findBearishBollingerChartTarget(document, routeSymbol); - } catch (error) { - disposeBollingerIntervalSession(); - clearBearishBollingerAlertContext(); - err("Bollinger chart lookup failed for this sample:", error); - return; - } - if (!target) { - disposeBollingerIntervalSession(); - clearBearishBollingerAlertContext(); - return; - } - if (!bollingerIntervalSession || bollingerIntervalSession.chart !== target.chart || bollingerIntervalSession.routeSymbol !== routeSymbol) { - disposeBollingerIntervalSession(); - bollingerIntervalSession = { - chart: target.chart, - routeSymbol, - session: createBollingerIntervalSession(target.chart) - }; - } - const intervalSession = bollingerIntervalSession.session; - const contextMatches = bearishBollingerAlertContext && bearishBollingerAlertContext.target.chart === target.chart && bearishBollingerAlertContext.target.chartRoot === target.chartRoot && bearishBollingerAlertContext.target.tradingViewApi === target.tradingViewApi && bearishBollingerAlertContext.routeSymbol === routeSymbol && bearishBollingerAlertContext.resolution === target.resolution && bearishBollingerAlertContext.intervalSession === intervalSession && bearishBollingerAlertContext.intervalRevision === intervalSession.revision; - if (!contextMatches) { - if (!clearBearishBollingerAlertContext()) return; - if (!intervalSession.isCurrent(intervalSession.revision) || isTradingViewDrawingMutationBusy()) return; - bearishBollingerAlertContext = { - routeSymbol, - resolution: target.resolution, - intervalSession, - intervalRevision: intervalSession.revision, - target, - layer: createBollingerMarkerLayer(target, { - canMutate: () => !isTradingViewDrawingMutationBusy(), - onSaveError: (error) => err("Bollinger chart save failed:", error) - }), - failed: false, - cleanupPending: false, - lastProcessedClosedBarsWindowKey: null, - lastProcessedClosedBarsContentSnapshot: null, - lastProcessedSignals: null - }; - } - if (isTradingViewDrawingMutationBusy() || !clearRetiredBollingerLayers()) return; - const context = bearishBollingerAlertContext; - if (context.cleanupPending) { - context.layer.clear(); - context.cleanupPending = false; - } - if (context.failed || bearishBollingerAlertTask) return; - const task = (async () => { - const bars = await exportClosedTradingViewBars(context.target, context.intervalSession); - if (!bars || !isBearishBollingerAlertContextCurrent(context)) return; - if (bars.length === 0) return; - const result = await reconcileBearishBollingerAlertWindow({ - bars, - cachedWindowKey: context.lastProcessedClosedBarsWindowKey, - cachedContentSnapshot: context.lastProcessedClosedBarsContentSnapshot, - cachedSignals: context.lastProcessedSignals, - detectSignals: detectBollingerSignals, - renderSignals: (signals) => context.layer.render(signals, { - isCurrent: () => isBearishBollingerAlertContextCurrent(context) - }) - }); - if (result.rendered && isBearishBollingerAlertContextCurrent(context)) { - context.lastProcessedClosedBarsWindowKey = result.closedBarsWindowKey; - context.lastProcessedClosedBarsContentSnapshot = result.closedBarsContentSnapshot; - context.lastProcessedSignals = result.signals; - } - })(); - bearishBollingerAlertTask = task; - task.catch((error) => { - if (bearishBollingerAlertContext !== context || context.intervalSession !== bollingerIntervalSession?.session || context.intervalRevision !== context.intervalSession.revision) return; - const failureKind = applyBollingerAlertTaskFailure(context, error); - if (failureKind === "retry") { - warn("布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:", error); - return; - } - err("布林带形态预警已停止:", error); - }).finally(() => { - if (bearishBollingerAlertTask === task) bearishBollingerAlertTask = null; - }); - } - function startBearishBollingerAlertMonitor() { - if (bearishBollingerAlertTimer || document.hidden || !isFuturesTradingPage()) return; - synchronizeBearishBollingerAlerts(); - bearishBollingerAlertTimer = setInterval( - synchronizeBearishBollingerAlerts, - BEARISH_BOLLINGER_ALERT_POLL_MS - ); - } - function stopBearishBollingerAlertMonitor() { - if (bearishBollingerAlertTimer) clearInterval(bearishBollingerAlertTimer); - bearishBollingerAlertTimer = null; - disposeBollingerIntervalSession(); - clearBearishBollingerAlertContext(); - } - function getBollingerAlertDiagnostics() { - const context = bearishBollingerAlertContext; - const session = bollingerIntervalSession?.session || null; - const chart = bollingerIntervalSession?.chart || context?.target.chart || null; - const nativeModelReady = chart ? chart.hasModel() : null; - const ownerFlags = { - ladderTask: ladderTask !== null, - continuousLadderTask: continuousLadderTask !== null, - singleOrderTask: singleOrderTask !== null, - cancelCurrentSymbolOpenOrdersTask: cancelCurrentSymbolOpenOrdersTask !== null, - chartOrdersRecoveryTask: chartOrdersRecoveryTask !== null, - continuousChartSaveController: continuousChartSaveController !== null - }; - return { - timerRunning: bearishBollingerAlertTimer !== null, - taskPending: bearishBollingerAlertTask !== null, - contextPresent: context !== null, - failed: context ? context.failed : null, - cleanupPending: context ? context.cleanupPending : null, - cachedSignalCount: context?.lastProcessedSignals === null || !context ? null : context.lastProcessedSignals.length, - layerSize: context ? context.layer.size : null, - markerSaveStats: context ? context.layer.saveStats : null, - retiredCount: retiredBollingerLayers.size, - sessionPresent: session !== null, - sessionRevision: session ? session.revision : null, - contextIntervalRevision: context ? context.intervalRevision : null, - sessionMatchesContext: context && session ? context.intervalSession === session : null, - sessionCurrent: session && nativeModelReady ? session.isCurrent(session.revision) : null, - nativeModelReady, - nativeDataReady: nativeModelReady ? chart.dataReady() : null, - mutationBlocked: Object.values(ownerFlags).some(Boolean), - ownerFlags - }; - } + ].some((value) => value !== null); + }); + window.addEventListener("pagehide", (event) => { + if (!event.persisted) unregisterChartMutationOwner(); + }); function parseJsonSafe(raw) { if (!raw || typeof raw !== "string") return null; try { @@ -12719,7 +11573,6 @@ removePanel(); removeDepthProfileRuntimeView(); stopTradingTimers(); - clearBearishBollingerAlertContext(); invalidateTradeButtonCache(); lastDisplayCloseState = null; } @@ -13110,8 +11963,6 @@ installUiSyncObservers(); function clearSymbolOwnedRuntimeState(symbol) { stopDepthProfileSession(); - disposeBollingerIntervalSession(); - clearBearishBollingerAlertContext(); depthProfileData = null; depthProfileFailedSymbol = null; depthProfileStatus = { status: "connecting", detail: "" }; @@ -13156,7 +12007,6 @@ ensureOrderbookPrecisionObserver(); ensureDepthProfileObserver(); scheduleDepthProfileSync(); - startBearishBollingerAlertMonitor(); } function stopTradingTimers() { stopTradeModeTabObserver(); @@ -13164,7 +12014,6 @@ stopOrderbookPrecisionObserver(); stopDepthProfileObserver(); stopDepthProfileSession(); - stopBearishBollingerAlertMonitor(); clearTradeUiMutationWait(); } function syncRouteState() { @@ -13226,7 +12075,6 @@ window.addEventListener("pagehide", () => { stopDepthProfileObserver(); stopDepthProfileSession(); - stopBearishBollingerAlertMonitor(); removeDepthProfileRuntimeView(); }, { once: true }); document.addEventListener("visibilitychange", () => { @@ -13255,9 +12103,6 @@ } window.__TM_CLOSE_LONG_DEBUG__ = { cfg: CFG, - get bollingerAlertState() { - return getBollingerAlertDiagnostics(); - }, get continuousChartSaveStats() { return continuousChartSaveController?.getStats() || null; }, diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js new file mode 100644 index 0000000..5831bdf --- /dev/null +++ b/scripts/binance-strategy29-bollinger.user.js @@ -0,0 +1,1437 @@ +// ==UserScript== +// @name 【自写】Binance Strategy 29 布林带信号 +// @namespace binance.strategy29.bollinger +// @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E +// @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E +// @version 0.1.0 +// @author jackhai9 +// @description Closed-candle Bollinger/SMA60 signals on the native Binance chart +// @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-strategy29-bollinger.user.js +// @downloadURL https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js +// @run-at document-start +// @grant none +// ==/UserScript== +(() => { + // src/binance-strategy29-bollinger/core/bearish-bollinger-pattern.js + var BOLLINGER_PATTERN = Object.freeze({ + bollingerPeriod: 20, + bollingerStdDev: 2, + maPeriod: 60, + preCrossBars: 8, + minPreCrossChannelCloses: 4, + maxPreCrossAboveMiddleCloses: 1, + maxPreCrossBelowLowerCloses: 3, + trendLookbackBars: 3, + minMiddleDeclineBandFraction: 0.01, + postCrossBars: 20, + middleApproachBandFraction: 0.12, + maxPostCrossCloseAboveMiddleBandFraction: 0.05, + lowerTouchBandFraction: 0.05, + reversalFollowBars: 60 + }); + var TradingViewBarSnapshotInconsistentError = class extends Error { + constructor(message) { + super(message); + this.name = "TradingViewBarSnapshotInconsistentError"; + } + }; + function isTradingViewBarSnapshotInconsistentError(error) { + return error instanceof TradingViewBarSnapshotInconsistentError; + } + function applyBollingerAlertTaskFailure(context, error) { + if (!context || typeof context !== "object" || typeof context.failed !== "boolean" || typeof context.cleanupPending !== "boolean") { + throw new Error("Bollinger alert task context is invalid"); + } + if (isTradingViewBarSnapshotInconsistentError(error)) return "retry"; + context.failed = true; + context.cleanupPending = true; + return "fatal"; + } + function assertFiniteNumber(value, label) { + if (!Number.isFinite(value)) throw new Error(`${label} is invalid`); + } + function assertBars(bars, directionLabel) { + if (!Array.isArray(bars)) throw new Error(`${directionLabel} Bollinger bars must be an array`); + let previousTime = -Infinity; + for (const [index, bar] of bars.entries()) { + if (!bar || typeof bar !== "object") { + throw new Error(`${directionLabel} Bollinger bar ${index} is invalid`); + } + if (!Number.isInteger(bar.time)) { + throw new Error(`${directionLabel} Bollinger bar time ${index} is invalid`); + } + if (bar.time <= previousTime) { + throw new TradingViewBarSnapshotInconsistentError( + `${directionLabel} Bollinger bar time ${index} is invalid` + ); + } + for (const field of ["open", "high", "low", "close"]) { + assertFiniteNumber(bar[field], `${directionLabel} Bollinger bar ${index} ${field}`); + } + if (bar.high < bar.low || bar.high < Math.max(bar.open, bar.close) || bar.low > Math.min(bar.open, bar.close)) { + throw new TradingViewBarSnapshotInconsistentError( + `${directionLabel} Bollinger bar ${index} OHLC range is invalid` + ); + } + previousTime = bar.time; + } + } + function assertIndicatorBars(indicatorBars, directionLabel) { + if (!Array.isArray(indicatorBars)) { + throw new Error(`${directionLabel} Bollinger indicator bars must be an array`); + } + assertBars(indicatorBars, directionLabel); + for (const [index, bar] of indicatorBars.entries()) { + const fields = ["middle", "upper", "lower", "ma60"]; + const nullFields = fields.filter((field) => bar[field] === null); + if (nullFields.length !== 0 && nullFields.length !== fields.length) { + throw new Error(`${directionLabel} Bollinger indicator bar ${index} is incomplete`); + } + for (const field of fields) { + if (bar[field] !== null) { + assertFiniteNumber( + bar[field], + `${directionLabel} Bollinger indicator bar ${index} ${field}` + ); + } + } + } + } + function calculateBollingerIndicatorBars(bars, directionLabel) { + assertBars(bars, directionLabel); + const config = BOLLINGER_PATTERN; + return bars.map((bar, index) => { + if (index < config.maPeriod - 1) { + return { ...bar, middle: null, upper: null, lower: null, ma60: null }; + } + const start = index - config.bollingerPeriod + 1; + let closeSum = 0; + for (let cursor = start; cursor <= index; cursor += 1) closeSum += bars[cursor].close; + const middle = closeSum / config.bollingerPeriod; + let squaredDeviationSum = 0; + for (let cursor = start; cursor <= index; cursor += 1) { + squaredDeviationSum += (bars[cursor].close - middle) ** 2; + } + const deviation = Math.sqrt(squaredDeviationSum / config.bollingerPeriod) * config.bollingerStdDev; + let maSum = 0; + for (let cursor = index - config.maPeriod + 1; cursor <= index; cursor += 1) maSum += bars[cursor].close; + return { + ...bar, + middle, + upper: middle + deviation, + lower: middle - deviation, + ma60: maSum / config.maPeriod + }; + }); + } + function bandWidth(bar) { + const width = bar.upper - bar.lower; + if (!(width > 0)) throw new Error(`Bollinger band width is invalid at ${bar.time}`); + return width; + } + function hasDownwardBandCenter(indicatorBars, index) { + const { trendLookbackBars, minMiddleDeclineBandFraction } = BOLLINGER_PATTERN; + const current = indicatorBars[index]; + const earlier = indicatorBars[index - trendLookbackBars]; + const averageWidth = (bandWidth(current) + bandWidth(earlier)) / 2; + return (earlier.middle - current.middle) / averageWidth >= minMiddleDeclineBandFraction; + } + function isRejectedAboveMiddleClose(indicatorBars, index) { + const next = indicatorBars[index + 1]; + return next.close < next.middle && next.close < next.open; + } + function matchesPreCrossCompression(indicatorBars, crossIndex) { + const config = BOLLINGER_PATTERN; + const start = crossIndex - config.preCrossBars; + const preCross = indicatorBars.slice(start, crossIndex); + const channelCloses = preCross.filter( + (bar) => bar.close >= bar.lower && bar.close <= bar.middle + ).length; + const aboveMiddleIndexes = []; + let belowLowerCloses = 0; + for (let offset = 0; offset < preCross.length; offset += 1) { + const bar = preCross[offset]; + if (bar.close > bar.middle) aboveMiddleIndexes.push(start + offset); + if (bar.close < bar.lower) belowLowerCloses += 1; + } + return channelCloses >= config.minPreCrossChannelCloses && aboveMiddleIndexes.length <= config.maxPreCrossAboveMiddleCloses && belowLowerCloses <= config.maxPreCrossBelowLowerCloses && aboveMiddleIndexes.every((index) => isRejectedAboveMiddleClose(indicatorBars, index)); + } + function isDownwardCross(previous, current) { + return previous.middle >= previous.ma60 && current.middle < current.ma60; + } + function buildSignal(type, setup, bar) { + const width = bandWidth(bar); + const markerGapFraction = type === "warning" ? 0.06 : 0.1; + return Object.freeze({ + id: `${setup.time}:${type}`, + type, + setupTime: setup.time, + time: bar.time, + markerPrice: type === "reversal" ? bar.low - width * markerGapFraction : bar.high + width * markerGapFraction + }); + } + function detectReversalSignal(indicatorBars, setup, warningIndex) { + const { reversalFollowBars } = BOLLINGER_PATTERN; + const warning = indicatorBars[warningIndex]; + const endIndex = Math.min( + indicatorBars.length - 1, + warningIndex + reversalFollowBars + ); + for (let index = warningIndex + 1; index <= endIndex; index += 1) { + const bar = indicatorBars[index]; + if (bar.close > warning.high) return buildSignal("reversal", setup, bar); + } + return null; + } + function detectSetupSignals(indicatorBars, crossIndex) { + const config = BOLLINGER_PATTERN; + const setup = indicatorBars[crossIndex]; + const signals = []; + let warningIndex = null; + let pendingMiddleRejection = false; + let aboveMiddleCloseCount = 0; + const endIndex = Math.min( + indicatorBars.length - 1, + crossIndex + config.postCrossBars + ); + for (let index = crossIndex + 1; index <= endIndex; index += 1) { + const bar = indicatorBars[index]; + const width = bandWidth(bar); + if (pendingMiddleRejection) { + if (!(bar.close < bar.middle && bar.close < bar.open)) break; + pendingMiddleRejection = false; + } + if (bar.close > bar.middle) { + aboveMiddleCloseCount += 1; + if (aboveMiddleCloseCount > 1 || bar.close > bar.middle + width * config.maxPostCrossCloseAboveMiddleBandFraction || bar.close > bar.upper) break; + pendingMiddleRejection = true; + continue; + } + const bandStillDown = bar.middle < setup.middle && hasDownwardBandCenter(indicatorBars, index); + if (!bandStillDown) continue; + if (warningIndex === null && bar.high >= bar.middle - width * config.middleApproachBandFraction) { + warningIndex = index; + signals.push(buildSignal("warning", setup, bar)); + continue; + } + if (warningIndex !== null && index > warningIndex && bar.close < bar.open && bar.low <= bar.lower + width * config.lowerTouchBandFraction) { + signals.push(buildSignal("confirmed", setup, bar)); + break; + } + } + if (warningIndex !== null) { + const reversal = detectReversalSignal(indicatorBars, setup, warningIndex); + if (reversal) signals.push(reversal); + } + return signals; + } + function appendSetupSignals(signals, setupSignals) { + for (const signal of setupSignals) { + if (signal.type !== "reversal") { + signals.push(signal); + continue; + } + const duplicateIndex = signals.findIndex( + (existing) => existing.type === "reversal" && existing.time === signal.time + ); + if (duplicateIndex === -1) { + signals.push(signal); + continue; + } + if (signal.setupTime > signals[duplicateIndex].setupTime) { + signals[duplicateIndex] = signal; + } + } + } + function detectBearishBollingerSignalsFromIndicatorBarsInternal(indicatorBars) { + const config = BOLLINGER_PATTERN; + const firstCrossIndex = Math.max( + config.maPeriod, + config.maPeriod - 1 + config.preCrossBars, + config.trendLookbackBars + ); + const signals = []; + for (let index = firstCrossIndex; index < indicatorBars.length; index += 1) { + const previous = indicatorBars[index - 1]; + const current = indicatorBars[index]; + if (!isDownwardCross(previous, current)) continue; + if (!hasDownwardBandCenter(indicatorBars, index)) continue; + if (!matchesPreCrossCompression(indicatorBars, index)) continue; + appendSetupSignals(signals, detectSetupSignals(indicatorBars, index)); + } + const typeOrder = { warning: 0, confirmed: 1, reversal: 2 }; + return signals.sort( + (left, right) => left.time - right.time || typeOrder[left.type] - typeOrder[right.type] + ); + } + function mirrorIndicatorBar(bar) { + return { + ...bar, + open: -bar.open, + high: -bar.low, + low: -bar.high, + close: -bar.close, + middle: bar.middle === null ? null : -bar.middle, + upper: bar.upper === null ? null : -bar.lower, + lower: bar.lower === null ? null : -bar.upper, + ma60: bar.ma60 === null ? null : -bar.ma60 + }; + } + function mapMirroredBullishSignal(signal) { + return Object.freeze({ + ...signal, + id: `${signal.setupTime}:bullish:${signal.type}`, + direction: "bullish", + markerPrice: -signal.markerPrice + }); + } + function detectBullishBollingerSignalsFromIndicatorBarsInternal(indicatorBars) { + return detectBearishBollingerSignalsFromIndicatorBarsInternal( + indicatorBars.map(mirrorIndicatorBar) + ).map(mapMirroredBullishSignal); + } + function compareBollingerSignals(left, right) { + const directionOrder = { bearish: 0, bullish: 1 }; + const typeOrder = { warning: 0, confirmed: 1, reversal: 2 }; + const leftDirectionOrder = directionOrder[left.direction]; + const rightDirectionOrder = directionOrder[right.direction]; + if (leftDirectionOrder === void 0 || rightDirectionOrder === void 0) { + throw new Error("Bollinger signal direction is invalid"); + } + return left.time - right.time || leftDirectionOrder - rightDirectionOrder || typeOrder[left.type] - typeOrder[right.type]; + } + function detectBollingerSignalsFromIndicatorBars(indicatorBars) { + assertIndicatorBars(indicatorBars, "Bollinger"); + const bearishSignals = detectBearishBollingerSignalsFromIndicatorBarsInternal(indicatorBars).map((signal) => Object.freeze({ ...signal, direction: "bearish" })); + const bullishSignals = detectBullishBollingerSignalsFromIndicatorBarsInternal(indicatorBars); + return [...bearishSignals, ...bullishSignals].sort(compareBollingerSignals); + } + function detectBollingerSignals(bars) { + return detectBollingerSignalsFromIndicatorBars( + calculateBollingerIndicatorBars(bars, "Bollinger") + ); + } + + // src/shared/tradingview-target.js + var CHART_ROOT_SELECTOR = ".chart-widget-root"; + function hasVisibleBox(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/dom/tradingview-bearish-alerts.js + var MAX_BOLLINGER_MARKERS_PER_DIRECTION = 1e3; + var MAX_BOLLINGER_MARKERS = MAX_BOLLINGER_MARKERS_PER_DIRECTION * 2; + function routeSymbolFromChartSymbol(value) { + return String(value || "").split("@", 1)[0]; + } + function assertChartContract(chart) { + for (const method of [ + "createShape", + "dataReady", + "exportData", + "getAllShapes", + "getShapeById", + "hasModel", + "onDataLoaded", + "onIntervalChanged", + "removeEntity", + "resolution", + "symbol" + ]) { + if (typeof chart?.[method] !== "function") { + throw new Error(`TradingView Bollinger alert method is unavailable: ${method}`); + } + } + } + function readLiveShapes(chart) { + const shapes = chart.getAllShapes(); + if (!Array.isArray(shapes)) { + throw new Error("TradingView Bollinger alert shape list is invalid"); + } + const ids = /* @__PURE__ */ new Map(); + for (const [index, shape] of shapes.entries()) { + if (typeof shape?.id !== "string" || shape.id.length === 0 || typeof shape.name !== "string") { + throw new Error(`TradingView Bollinger alert shape ${index} id is invalid`); + } + ids.set(shape.id, shape.name); + } + return ids; + } + function tradingViewResolutionToSeconds(resolution) { + const value = String(resolution || "").toUpperCase(); + const units = [ + { pattern: /^(\d+)S$/, seconds: 1 }, + { pattern: /^(\d+)$/, seconds: 60 }, + { pattern: /^(\d+)H$/, seconds: 60 * 60 }, + { pattern: /^(\d+)D$/, seconds: 24 * 60 * 60 }, + { pattern: /^(\d+)W$/, seconds: 7 * 24 * 60 * 60 } + ]; + for (const { pattern, seconds } of units) { + const match = value.match(pattern); + if (!match) continue; + const count = Number(match[1]); + if (Number.isSafeInteger(count) && count > 0) return count * seconds; + } + throw new Error(`TradingView Bollinger alert resolution is unsupported: ${resolution}`); + } + function bollingerIntervalVisibility(resolution) { + const seconds = tradingViewResolutionToSeconds(resolution); + const value = String(resolution).toUpperCase(); + const visibility = { + ticks: false, + seconds: false, + minutes: false, + hours: false, + days: false, + weeks: false, + months: false, + ranges: false + }; + let unit; + let count; + if (value.endsWith("W")) { + unit = "weeks"; + count = seconds / 604800; + } else if (value.endsWith("D")) { + unit = "days"; + count = seconds / 86400; + } else if (seconds < 60) { + unit = "seconds"; + count = seconds; + } else if (value.endsWith("S") || seconds < 3600) { + unit = "minutes"; + count = Math.floor(seconds / 60); + } else { + unit = "hours"; + count = Math.floor(seconds / 3600); + } + visibility[unit] = true; + visibility[`${unit}From`] = count; + visibility[`${unit}To`] = count; + return visibility; + } + function createBollingerIntervalSession(chart) { + const intervalChanged = chart.onIntervalChanged(); + const dataLoaded = chart.onDataLoaded(); + for (const subscription of [intervalChanged, dataLoaded]) { + if (typeof subscription?.subscribe !== "function" || typeof subscription.unsubscribe !== "function") { + throw new Error("TradingView Bollinger interval subscription is unavailable"); + } + } + let revision = 0; + let disposed = false; + let awaitingData = !chart.dataReady(); + const owner = {}; + function invalidate() { + revision += 1; + awaitingData = true; + } + function complete() { + awaitingData = false; + } + intervalChanged.subscribe(owner, invalidate); + dataLoaded.subscribe(owner, complete); + return Object.freeze({ + get revision() { + return revision; + }, + isCurrent(candidate) { + return !disposed && !awaitingData && candidate === revision && chart.dataReady(); + }, + dispose() { + if (disposed) return; + disposed = true; + revision += 1; + intervalChanged.unsubscribe(owner, invalidate); + dataLoaded.unsubscribe(owner, complete); + } + }); + } + function findBearishBollingerChartTarget(document, expectedRouteSymbol) { + const baseTarget = findBinanceTradingViewTarget(document); + if (!baseTarget) return null; + const chart = baseTarget.tradingViewApi.activeChart?.(); + if (!chart) return null; + assertChartContract(chart); + if (!chart.hasModel()) return null; + const resolution = chart.resolution(); + const resolutionSeconds = tradingViewResolutionToSeconds(resolution); + const routeSymbol = routeSymbolFromChartSymbol(chart.symbol()); + if (routeSymbol !== expectedRouteSymbol) { + throw new Error( + `TradingView Bollinger alert symbol mismatch: expected ${expectedRouteSymbol}, received ${routeSymbol}` + ); + } + return { + ...baseTarget, + chart, + resolution, + resolutionSeconds, + routeSymbol + }; + } + function isBearishBollingerChartTargetCurrent(document, target) { + const baseTarget = findBinanceTradingViewTarget(document); + if (!baseTarget) return false; + const chart = baseTarget.tradingViewApi.activeChart?.(); + if (!chart) return false; + assertChartContract(chart); + if (!chart.hasModel()) return false; + return baseTarget.chartRoot === target.chartRoot && baseTarget.tradingViewApi === target.tradingViewApi && chart === target.chart && chart.resolution() === target.resolution && routeSymbolFromChartSymbol(chart.symbol()) === target.routeSymbol; + } + function assertExportSchema(schema) { + if (!Array.isArray(schema)) throw new Error("TradingView Bollinger alert export schema is invalid"); + const fields = schema.map((column) => column.plotTitle || column.type); + const expected = ["time", "open", "high", "low", "close"]; + if (fields.length !== expected.length || fields.some((field, index) => field !== expected[index])) { + throw new Error(`TradingView Bollinger alert export schema mismatch: ${fields.join(",")}`); + } + } + function parseExportRow(row, index) { + if (!row || typeof row !== "object") { + throw new Error(`TradingView Bollinger alert export row ${index} is invalid`); + } + const bar = { + time: row[0], + open: row[1], + high: row[2], + low: row[3], + close: row[4] + }; + if (!Number.isInteger(bar.time)) { + throw new Error(`TradingView Bollinger alert export time ${index} is invalid`); + } + for (const field of ["open", "high", "low", "close"]) { + if (!Number.isFinite(bar[field])) { + throw new Error(`TradingView Bollinger alert export ${field} ${index} is invalid`); + } + } + return bar; + } + function parseClosedTradingViewBars(exported, { resolutionSeconds, observedAtSeconds, resolution }) { + if (!Number.isSafeInteger(resolutionSeconds) || resolutionSeconds < 1) { + throw new Error("TradingView Bollinger alert resolution seconds are invalid"); + } + if (!Number.isFinite(observedAtSeconds)) { + throw new Error("TradingView Bollinger alert observation time is invalid"); + } + assertExportSchema(exported?.schema); + if (!Array.isArray(exported.data)) { + throw new Error("TradingView Bollinger alert export data is invalid"); + } + const bars = exported.data.map(parseExportRow); + const gridSeconds = Math.min(resolutionSeconds, 86400); + for (const [index, bar] of bars.entries()) { + if (bar.time % gridSeconds !== 0 || String(resolution).toUpperCase().endsWith("W") && new Date(bar.time * 1e3).getUTCDay() !== 1) { + throw new TradingViewBarSnapshotInconsistentError( + `TradingView Bollinger alert export interval grid is invalid at ${index}` + ); + } + } + for (let index = 1; index < bars.length; index += 1) { + if (bars[index].time <= bars[index - 1].time) { + throw new TradingViewBarSnapshotInconsistentError( + `TradingView Bollinger alert export order is invalid at ${index}` + ); + } + if ((bars[index].time - bars[index - 1].time) % resolutionSeconds !== 0) { + throw new TradingViewBarSnapshotInconsistentError( + `TradingView Bollinger alert export interval spacing is invalid at ${index}` + ); + } + } + return bars.filter((bar) => bar.time + resolutionSeconds <= observedAtSeconds); + } + function buildClosedBarsWindowKey(bars) { + if (!Array.isArray(bars) || bars.length === 0) { + throw new Error("TradingView Bollinger alert closed-bar window is empty"); + } + return `${bars.length}:${bars[0].time}:${bars.at(-1).time}`; + } + function writeClosedBarToSnapshot(values, offset, bar, index) { + if (!bar || typeof bar !== "object") { + throw new Error(`TradingView Bollinger closed bar ${index} is invalid`); + } + if (!Number.isInteger(bar.time)) { + throw new Error(`TradingView Bollinger closed bar time ${index} is invalid`); + } + const fields = ["open", "high", "low", "close"]; + for (const field of fields) { + if (!Number.isFinite(bar[field])) { + throw new Error(`TradingView Bollinger closed bar ${field} ${index} is invalid`); + } + } + values[offset] = bar.time; + values[offset + 1] = bar.open; + values[offset + 2] = bar.high; + values[offset + 3] = bar.low; + values[offset + 4] = bar.close; + } + function buildClosedBarsContentSnapshot(bars) { + const windowKey = buildClosedBarsWindowKey(bars); + const values = new Float64Array(bars.length * 5); + for (let index = 0; index < bars.length; index += 1) { + writeClosedBarToSnapshot(values, index * 5, bars[index], index); + } + return { windowKey, values }; + } + function matchesClosedBarsContentSnapshot(bars, snapshot) { + if (!snapshot || typeof snapshot !== "object" || typeof snapshot.windowKey !== "string" || !(snapshot.values instanceof Float64Array)) { + throw new Error("TradingView Bollinger closed-bar snapshot is invalid"); + } + if (buildClosedBarsWindowKey(bars) !== snapshot.windowKey) return false; + if (snapshot.values.length !== bars.length * 5) return false; + const candidate = new Float64Array(5); + for (let index = 0; index < bars.length; index += 1) { + writeClosedBarToSnapshot(candidate, 0, bars[index], index); + const offset = index * 5; + for (let fieldIndex = 0; fieldIndex < candidate.length; fieldIndex += 1) { + if (!Object.is(snapshot.values[offset + fieldIndex], candidate[fieldIndex])) return false; + } + } + return true; + } + async function reconcileBearishBollingerAlertWindow({ + bars, + cachedWindowKey, + cachedContentSnapshot = null, + cachedSignals, + detectSignals, + renderSignals + }) { + if (typeof detectSignals !== "function") { + throw new Error("TradingView Bollinger alert detector is unavailable"); + } + if (typeof renderSignals !== "function") { + throw new Error("TradingView Bollinger alert renderer is unavailable"); + } + const closedBarsWindowKey = buildClosedBarsWindowKey(bars); + const contentUnchanged = closedBarsWindowKey === cachedWindowKey && cachedContentSnapshot !== null && matchesClosedBarsContentSnapshot(bars, cachedContentSnapshot); + const signals = contentUnchanged ? cachedSignals : detectSignals(bars); + if (!Array.isArray(signals)) { + throw new Error("Bollinger signal cache is invalid"); + } + const rendered = await renderSignals(signals); + if (typeof rendered !== "boolean") { + throw new Error("TradingView Bollinger alert render result is invalid"); + } + return { + rendered, + closedBarsWindowKey, + closedBarsContentSnapshot: contentUnchanged ? cachedContentSnapshot : buildClosedBarsContentSnapshot(bars), + signals + }; + } + async function exportClosedTradingViewBars(target, session, observedAtMs = Date.now()) { + const revision = session.revision; + const isCurrent = () => session.isCurrent(revision) && target.chart.resolution() === target.resolution && routeSymbolFromChartSymbol(target.chart.symbol()) === target.routeSymbol; + if (!isCurrent()) return null; + const exported = await target.chart.exportData({ includedStudies: [] }); + if (!isCurrent()) return null; + return parseClosedTradingViewBars(exported, { + resolutionSeconds: target.resolutionSeconds, + resolution: target.resolution, + observedAtSeconds: observedAtMs / 1e3 + }); + } + function markerOptions(signal, resolution) { + const direction = signal.direction; + if (direction !== "bearish" && direction !== "bullish") { + throw new Error(`TradingView Bollinger alert signal direction is invalid: ${direction}`); + } + const isBullish = direction === "bullish"; + const common = { + lock: true, + disableSave: true, + disableSelection: true, + disableUndo: true, + showInObjectsTree: false + }; + if (signal.type === "warning") { + return { + ...common, + shape: "icon", + icon: 61713, + overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), + color: isBullish ? "#0ECB81" : "#F6465D", + size: 10 + } + }; + } + if (signal.type === "confirmed") { + return { + ...common, + shape: isBullish ? "arrow_up" : "arrow_down", + overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), + color: isBullish ? "#0ECB81" : "#F6465D", + arrowColor: isBullish ? "#0ECB81" : "#F6465D" + } + }; + } + if (signal.type === "reversal") { + return { + ...common, + shape: isBullish ? "arrow_down" : "arrow_up", + overrides: { + visible: true, + intervalsVisibilities: bollingerIntervalVisibility(resolution), + color: isBullish ? "#F6465D" : "#0ECB81", + arrowColor: isBullish ? "#F6465D" : "#0ECB81" + } + }; + } + throw new Error(`TradingView Bollinger alert signal type is invalid: ${signal.type}`); + } + function readMarkerPoint(shape) { + const points = shape?.getPoints?.(); + if (!Array.isArray(points) || points.length !== 1 || !Number.isInteger(points[0].time) || !Number.isFinite(points[0].price)) { + throw new Error("TradingView Bollinger alert marker point is invalid"); + } + return points[0]; + } + function markerPropertiesMatch(shape, options) { + const properties = shape.getProperties(); + if (!properties || typeof properties !== "object") { + throw new Error("TradingView Bollinger alert marker properties are invalid"); + } + if (options.icon !== void 0 && properties.icon !== options.icon) return false; + for (const [key, expected] of Object.entries(options.overrides)) { + if (key === "intervalsVisibilities") { + if (!properties[key] || Object.entries(expected).some(([unit, value]) => properties[key][unit] !== value)) return false; + } else if (properties[key] !== expected) return false; + } + return true; + } + function normalizeSignal(signal, index, defaultDirection) { + if (!signal || typeof signal !== "object") { + throw new Error(`TradingView Bollinger alert signal ${index} is invalid`); + } + if (typeof signal.id !== "string" || signal.id.length === 0) { + throw new Error(`TradingView Bollinger alert signal ${index} id is invalid`); + } + const direction = signal.direction === void 0 ? defaultDirection : signal.direction; + if (direction !== "bearish" && direction !== "bullish") { + throw new Error(`TradingView Bollinger alert signal ${index} direction is invalid: ${direction}`); + } + return signal.direction === direction ? signal : { ...signal, direction }; + } + function createMarkerLayer(target, defaultDirection, { + canMutate: canMutateExternally = () => true, + onSaveError, + yieldToBrowser = () => new Promise((resolve) => setTimeout(resolve, 0)) + } = {}) { + const { chart } = target; + const saveController = installTradingViewMarkerSaveController(target.tradingViewApi, { onError: onSaveError }); + const canMutate = () => canMutateExternally() && saveController.canMutate(); + const registry = /* @__PURE__ */ new Map(); + const pendingMarkers = /* @__PURE__ */ new Set(); + let generation = 0; + let creating = 0; + function mutate(action) { + const finish = saveController.beginMutation(); + try { + return action(); + } finally { + finish(); + } + } + function removePendingMarkers() { + if (pendingMarkers.size === 0 || !canMutate()) return; + const liveShapeIds = readLiveShapes(chart); + for (const id of pendingMarkers) { + if (liveShapeIds.has(id)) mutate(() => chart.removeEntity(id)); + pendingMarkers.delete(id); + } + } + function discardMissingSignals(liveShapeIds) { + for (const [signalId, record] of registry) { + if (!liveShapeIds.has(record.markerId)) registry.delete(signalId); + } + } + function removeSignal(signalId, liveShapeIds) { + const record = registry.get(signalId); + if (!record) return; + if (liveShapeIds.has(record.markerId)) { + mutate(() => chart.removeEntity(record.markerId)); + liveShapeIds.delete(record.markerId); + } + registry.delete(signalId); + } + return Object.freeze({ + async render(signals, { isCurrent }) { + if (!Array.isArray(signals)) throw new Error("TradingView Bollinger alert signals are invalid"); + if (signals.length > MAX_BOLLINGER_MARKERS) { + throw new Error( + `TradingView Bollinger alert marker limit exceeded: ${signals.length}` + ); + } + if (typeof isCurrent !== "function") { + throw new Error("TradingView Bollinger alert current-target validator is unavailable"); + } + const normalizedSignals = signals.map((signal, index) => normalizeSignal(signal, index, defaultDirection)); + const directionCounts = { bearish: 0, bullish: 0 }; + for (const signal of normalizedSignals) { + directionCounts[signal.direction] += 1; + if (directionCounts[signal.direction] > MAX_BOLLINGER_MARKERS_PER_DIRECTION) { + throw new Error( + `TradingView Bollinger alert ${signal.direction} marker limit exceeded: ` + directionCounts[signal.direction] + ); + } + } + const requestedGeneration = generation; + if (!isCurrent() || !canMutate()) return false; + removePendingMarkers(); + let liveShapeIds = readLiveShapes(chart); + discardMissingSignals(liveShapeIds); + const nextIds = new Set(normalizedSignals.map((signal) => signal.id)); + for (const signalId of [...registry.keys()]) { + if (!nextIds.has(signalId)) removeSignal(signalId, liveShapeIds); + } + let batchStartedAt = performance.now(); + let batchOps = 0; + for (const signal of normalizedSignals) { + if (batchOps > 0 && (batchOps >= 32 || performance.now() - batchStartedAt >= 8)) { + await yieldToBrowser(); + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + liveShapeIds = readLiveShapes(chart); + discardMissingSignals(liveShapeIds); + batchStartedAt = performance.now(); + batchOps = 0; + } + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + batchOps += 1; + const options = markerOptions(signal, target.resolution); + const existing = registry.get(signal.id); + if (existing) { + const shape = chart.getShapeById(existing.markerId); + const point = readMarkerPoint(shape); + if (point.time === signal.time && point.price === existing.resolvedPrice && existing.markerPrice === signal.markerPrice && existing.type === signal.type && existing.direction === signal.direction && liveShapeIds.get(existing.markerId) === options.shape && markerPropertiesMatch(shape, options)) continue; + removeSignal(signal.id, liveShapeIds); + } + const finishCreation = saveController.beginMutation(); + creating += 1; + try { + const markerId = await chart.createShape({ time: signal.time, price: signal.markerPrice }, { + ...options, + overrides: { ...options.overrides, visible: false } + }); + if (typeof markerId !== "string" || markerId.length === 0) { + throw new Error("TradingView returned an invalid Bollinger alert shape id"); + } + pendingMarkers.add(markerId); + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + const shape = chart.getShapeById(markerId); + const point = readMarkerPoint(shape); + if (point.time !== signal.time) { + throw new Error(`TradingView Bollinger alert time alignment failed for ${signal.time}`); + } + if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; + mutate(() => shape.setProperties(options.overrides, false)); + if (!markerPropertiesMatch(shape, options)) { + throw new Error("TradingView Bollinger alert marker properties were not applied"); + } + registry.set(signal.id, { + markerId, + resolvedPrice: point.price, + markerPrice: signal.markerPrice, + type: signal.type, + direction: signal.direction + }); + pendingMarkers.delete(markerId); + } finally { + finishCreation(); + creating -= 1; + removePendingMarkers(); + } + } + return true; + }, + clear() { + generation += 1; + if (!canMutate()) return false; + removePendingMarkers(); + const liveShapeIds = readLiveShapes(chart); + discardMissingSignals(liveShapeIds); + for (const signalId of [...registry.keys()]) removeSignal(signalId, liveShapeIds); + return creating === 0 && pendingMarkers.size === 0; + }, + get size() { + return registry.size; + }, + get saveStats() { + return saveController.getStats(); + } + }); + } + function createBollingerMarkerLayer(target, options) { + return createMarkerLayer(target, void 0, options); + } + + // src/binance-strategy29-bollinger/monitor.js + function createBollingerMonitor({ + document, + getCurrentSymbol, + isFuturesTradingPage, + isTradingViewDrawingMutationBusy, + err, + warn + }) { + let bearishBollingerAlertTask = null; + let bearishBollingerAlertContext = null; + let bollingerIntervalSession = null; + const retiredBollingerLayers = /* @__PURE__ */ new Set(); + function clearBearishBollingerAlertContext() { + if (bearishBollingerAlertContext) { + retiredBollingerLayers.add(bearishBollingerAlertContext.layer); + bearishBollingerAlertContext = null; + } + return clearRetiredBollingerLayers(); + } + function clearRetiredBollingerLayers() { + if (isTradingViewDrawingMutationBusy()) return false; + for (const layer of retiredBollingerLayers) { + if (layer.clear()) retiredBollingerLayers.delete(layer); + } + return retiredBollingerLayers.size === 0; + } + function disposeBollingerIntervalSession() { + if (bollingerIntervalSession) { + bollingerIntervalSession.session.dispose(); + bollingerIntervalSession = null; + } + } + function isBearishBollingerAlertContextCurrent(context) { + return bearishBollingerAlertContext === context && context.intervalSession === bollingerIntervalSession?.session && context.intervalSession.isCurrent(context.intervalRevision) && !document.hidden && isFuturesTradingPage() && !isTradingViewDrawingMutationBusy() && getCurrentSymbol() === context.routeSymbol && isBearishBollingerChartTargetCurrent(document, context.target); + } + async function synchronizeBearishBollingerAlerts() { + if (document.hidden || !isFuturesTradingPage()) return; + const routeSymbol = getCurrentSymbol(); + if (!routeSymbol) return; + let target; + try { + target = findBearishBollingerChartTarget(document, routeSymbol); + } catch (error) { + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + err("Bollinger chart lookup failed for this sample:", error); + return; + } + if (!target) { + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + return; + } + if (!bollingerIntervalSession || bollingerIntervalSession.chart !== target.chart || bollingerIntervalSession.routeSymbol !== routeSymbol) { + disposeBollingerIntervalSession(); + bollingerIntervalSession = { + chart: target.chart, + routeSymbol, + session: createBollingerIntervalSession(target.chart) + }; + } + const intervalSession = bollingerIntervalSession.session; + const contextMatches = bearishBollingerAlertContext && bearishBollingerAlertContext.target.chart === target.chart && bearishBollingerAlertContext.target.chartRoot === target.chartRoot && bearishBollingerAlertContext.target.tradingViewApi === target.tradingViewApi && bearishBollingerAlertContext.routeSymbol === routeSymbol && bearishBollingerAlertContext.resolution === target.resolution && bearishBollingerAlertContext.intervalSession === intervalSession && bearishBollingerAlertContext.intervalRevision === intervalSession.revision; + if (!contextMatches) { + if (!clearBearishBollingerAlertContext()) return; + if (!intervalSession.isCurrent(intervalSession.revision) || isTradingViewDrawingMutationBusy()) return; + bearishBollingerAlertContext = { + routeSymbol, + resolution: target.resolution, + intervalSession, + intervalRevision: intervalSession.revision, + target, + layer: createBollingerMarkerLayer(target, { + canMutate: () => !isTradingViewDrawingMutationBusy(), + onSaveError: (error) => err("Bollinger chart save failed:", error) + }), + failed: false, + cleanupPending: false, + lastProcessedClosedBarsWindowKey: null, + lastProcessedClosedBarsContentSnapshot: null, + lastProcessedSignals: null + }; + } + if (isTradingViewDrawingMutationBusy() || !clearRetiredBollingerLayers()) return; + const context = bearishBollingerAlertContext; + if (context.cleanupPending) { + context.layer.clear(); + context.cleanupPending = false; + } + if (context.failed || bearishBollingerAlertTask) return; + const task = (async () => { + const bars = await exportClosedTradingViewBars(context.target, context.intervalSession); + if (!bars || !isBearishBollingerAlertContextCurrent(context)) return; + if (bars.length === 0) return; + const result = await reconcileBearishBollingerAlertWindow({ + bars, + cachedWindowKey: context.lastProcessedClosedBarsWindowKey, + cachedContentSnapshot: context.lastProcessedClosedBarsContentSnapshot, + cachedSignals: context.lastProcessedSignals, + detectSignals: detectBollingerSignals, + renderSignals: (signals) => context.layer.render(signals, { + isCurrent: () => isBearishBollingerAlertContextCurrent(context) + }) + }); + if (result.rendered && isBearishBollingerAlertContextCurrent(context)) { + context.lastProcessedClosedBarsWindowKey = result.closedBarsWindowKey; + context.lastProcessedClosedBarsContentSnapshot = result.closedBarsContentSnapshot; + context.lastProcessedSignals = result.signals; + } + })(); + bearishBollingerAlertTask = task; + task.catch((error) => { + if (bearishBollingerAlertContext !== context || context.intervalSession !== bollingerIntervalSession?.session || context.intervalRevision !== context.intervalSession.revision) return; + const failureKind = applyBollingerAlertTaskFailure(context, error); + if (failureKind === "retry") { + warn("布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:", error); + return; + } + err("布林带形态预警已停止:", error); + }).finally(() => { + if (bearishBollingerAlertTask === task) bearishBollingerAlertTask = null; + }); + } + function stopBearishBollingerAlertMonitor() { + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + } + function getBollingerAlertDiagnostics() { + const context = bearishBollingerAlertContext; + const session = bollingerIntervalSession?.session || null; + const chart = bollingerIntervalSession?.chart || context?.target.chart || null; + const nativeModelReady = chart ? chart.hasModel() : null; + return { + taskPending: bearishBollingerAlertTask !== null, + contextPresent: context !== null, + failed: context ? context.failed : null, + cleanupPending: context ? context.cleanupPending : null, + cachedSignalCount: context?.lastProcessedSignals === null || !context ? null : context.lastProcessedSignals.length, + layerSize: context ? context.layer.size : null, + markerSaveStats: context ? context.layer.saveStats : null, + retiredCount: retiredBollingerLayers.size, + sessionPresent: session !== null, + sessionRevision: session ? session.revision : null, + contextIntervalRevision: context ? context.intervalRevision : null, + sessionMatchesContext: context && session ? context.intervalSession === session : null, + sessionCurrent: session && nativeModelReady ? session.isCurrent(session.revision) : null, + nativeModelReady, + nativeDataReady: nativeModelReady ? chart.dataReady() : null, + mutationBlocked: isTradingViewDrawingMutationBusy() + }; + } + return Object.freeze({ + tick: synchronizeBearishBollingerAlerts, + stop: stopBearishBollingerAlertMonitor, + get diagnostics() { + return getBollingerAlertDiagnostics(); + } + }); + } + + // src/shared/chart-mutation-owners.js + var OWNER_SLOT = Symbol.for("jh-userscripts.chart-mutation-owners"); + var VERSION = 1; + function owners(view) { + if (view[OWNER_SLOT] === void 0) { + Object.defineProperty(view, OWNER_SLOT, { + value: Object.freeze({ version: VERSION, predicates: /* @__PURE__ */ new Map() }) + }); + } + const record = view[OWNER_SLOT]; + if (record.version !== VERSION || !(record.predicates instanceof Map)) { + throw new Error("Incompatible chart mutation protocol; update both scripts and reload"); + } + return record.predicates; + } + function isChartMutationBlocked(view) { + for (const predicate of owners(view).values()) { + const blocked = predicate(); + if (typeof blocked !== "boolean") throw new Error("Chart mutation owner must return a boolean"); + if (blocked) return true; + } + return false; + } + + // src/shared/binance-futures-route.js + var FUTURES_TRADING_PATH_RE = /^\/(?:[a-z]{2}(?:-[A-Za-z]{2})?\/)?futures\/([A-Za-z0-9_]{3,})\/?$/; + function parseFuturesTradingSymbolFromPathname(pathname) { + const normalized = String(pathname || "").split(/[?#]/, 1)[0]; + const match = normalized.match(FUTURES_TRADING_PATH_RE); + return match?.[1] ? match[1].toUpperCase() : null; + } + function isFuturesTradingPathname(pathname) { + return Boolean(parseFuturesTradingSymbolFromPathname(pathname)); + } + + // src/shared/spa-route-change.js + var ROUTE_CHANGE_EVENT = "jh-userscripts:spa-route-change"; + var ROUTE_PATCH_MARKER = Symbol.for("jh-userscripts.spa-route-change-patched"); + var ROUTE_DISPATCH_STATE = Symbol.for("jh-userscripts.spa-route-change-dispatch"); + function dispatchRouteChange(view) { + const href = view.location.href; + if (view[ROUTE_DISPATCH_STATE]?.href === href) return; + const state = { href }; + view[ROUTE_DISPATCH_STATE] = state; + view.dispatchEvent(new view.Event(ROUTE_CHANGE_EVENT)); + view.queueMicrotask(() => { + if (view[ROUTE_DISPATCH_STATE] === state) delete view[ROUTE_DISPATCH_STATE]; + }); + } + function patchHistoryMethod(view, methodName) { + const current = view.history[methodName]; + if (current[ROUTE_PATCH_MARKER]) return; + function routeAwareHistoryMethod(...args) { + const previousHref = view.location.href; + const result = Reflect.apply(current, this, args); + if (view.location.href !== previousHref) dispatchRouteChange(view); + return result; + } + Object.defineProperty(routeAwareHistoryMethod, ROUTE_PATCH_MARKER, { value: true }); + view.history[methodName] = routeAwareHistoryMethod; + } + function ensureSpaRouteChangePatched(view) { + if (!view?.history) throw new Error("SPA route patch requires a window"); + patchHistoryMethod(view, "pushState"); + patchHistoryMethod(view, "replaceState"); + } + function installSpaRouteChangeListener(view, listener) { + if (!view?.history || typeof listener !== "function") { + throw new Error("SPA route listener requires a window and callback"); + } + ensureSpaRouteChangePatched(view); + view.addEventListener(ROUTE_CHANGE_EVENT, listener); + view.addEventListener("popstate", listener); + view.addEventListener("hashchange", listener); + return () => { + view.removeEventListener(ROUTE_CHANGE_EVENT, listener); + view.removeEventListener("popstate", listener); + view.removeEventListener("hashchange", listener); + }; + } + + // src/binance-strategy29-bollinger/runtime.js + var INSTANCE = Symbol.for("jh-userscripts.strategy29-bollinger"); + var CONFLICT = "Strategy 29 stopped: update Orderbook to 2.7.199 or disable its embedded Bollinger version, then reload this page."; + function hasEmbeddedBollinger(view) { + const debug = view.__TM_CLOSE_LONG_DEBUG__; + return !!debug && Object.getOwnPropertyDescriptor(debug, "bollingerAlertState") !== void 0; + } + function installStrategy29(view) { + if (view[INSTANCE] !== void 0) { + if (view[INSTANCE].version !== 1) throw new Error("Incompatible Strategy 29 runtime; reload the page"); + return view[INSTANCE].runtime; + } + const document = view.document; + let timer = null; + let failed = null; + let disposed = false; + let removeRouteListener = null; + const noticeId = "jh-strategy29-bollinger-status"; + function showFailure() { + if (!failed || !document.body) return; + let notice = document.getElementById(noticeId); + if (!notice) { + notice = document.createElement("div"); + notice.id = noticeId; + notice.setAttribute("role", "status"); + notice.style.cssText = "position:fixed;left:16px;bottom:16px;z-index:10000;max-width:420px;padding:10px;background:#332b16;color:#ffcf67;font:13px sans-serif;pointer-events:none"; + document.body.append(notice); + } + notice.textContent = failed; + } + const monitor = createBollingerMonitor({ + document, + getCurrentSymbol: () => parseFuturesTradingSymbolFromPathname(view.location.pathname), + isFuturesTradingPage: () => !disposed && !failed && isFuturesTradingPathname(view.location.pathname), + isTradingViewDrawingMutationBusy: () => hasEmbeddedBollinger(view) || isChartMutationBlocked(view), + err: (...args) => view.console.error("[Strategy29]", ...args), + warn: (...args) => view.console.warn("[Strategy29]", ...args) + }); + function pause() { + if (timer !== null) view.clearInterval(timer); + timer = null; + monitor.stop(); + } + function fail(message) { + failed = message; + pause(); + showFailure(); + } + function sample() { + if (disposed || failed || document.hidden) return; + if (hasEmbeddedBollinger(view)) { + fail(CONFLICT); + return; + } + ensureSpaRouteChangePatched(view); + if (!isFuturesTradingPathname(view.location.pathname)) { + monitor.stop(); + return; + } + void monitor.tick().catch((error) => fail(`Strategy 29 stopped: ${error.message}`)); + } + function resume() { + if (disposed || failed || document.hidden) return; + sample(); + if (!failed && timer === null) timer = view.setInterval(sample, 1e3); + } + function onVisibility() { + if (document.hidden) pause(); + else resume(); + } + function onPageHide(event) { + if (event.persisted) pause(); + else runtime.dispose(); + } + function onPageShow() { + resume(); + } + const runtime = Object.freeze({ + get diagnostics() { + return { ...monitor.diagnostics, runtimeFailure: failed, disposed, timerRunning: timer !== null }; + }, + dispose() { + if (disposed) return; + disposed = true; + pause(); + removeRouteListener(); + document.removeEventListener("visibilitychange", onVisibility); + document.removeEventListener("DOMContentLoaded", showFailure); + view.removeEventListener("pagehide", onPageHide); + view.removeEventListener("pageshow", onPageShow); + document.getElementById(noticeId)?.remove(); + } + }); + Object.defineProperty(view, INSTANCE, { value: Object.freeze({ version: 1, runtime }) }); + Object.defineProperty(view, "__TM_STRATEGY29_DEBUG__", { value: runtime }); + removeRouteListener = installSpaRouteChangeListener(view, sample); + document.addEventListener("visibilitychange", onVisibility); + document.addEventListener("DOMContentLoaded", showFailure, { once: true }); + view.addEventListener("pagehide", onPageHide); + view.addEventListener("pageshow", onPageShow); + resume(); + return runtime; + } + + // src/binance-strategy29-bollinger/index.user.js + installStrategy29(window); +})(); diff --git a/scripts/build-userscript.mjs b/scripts/build-userscript.mjs index 5f7e000..d95663b 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-strategy29-bollinger': { + entry: 'src/binance-strategy29-bollinger/index.user.js', + output: 'scripts/binance-strategy29-bollinger.user.js', + }, 'binance-orderbook-trade': { entry: 'src/binance-orderbook-trade/index.user.js', output: 'scripts/binance-orderbook-trade.user.js', diff --git a/skills/userscript-release/SKILL.md b/skills/userscript-release/SKILL.md index b466b16..09f6560 100644 --- a/skills/userscript-release/SKILL.md +++ b/skills/userscript-release/SKILL.md @@ -18,6 +18,7 @@ remote repository, Tampermonkey, a browser page, or a trading account. - orderbook source, architecture, or build: `docs/binance-orderbook-trade-development.md`; - orderbook browser, Tampermonkey, CDP, or live validation: `docs/binance-orderbook-trade-ui-automation.md`; - Strategy 27 annotations: `docs/binance-strategy27-events-development.md`; + - Strategy 29 Bollinger signals: `docs/binance-strategy29-bollinger-development.md`; - Brooks or m3u8 export behavior: `docs/brooks-media-sync-workflow.md`; - trading-data, CoinMarketCap-data, auto-refresh, or cross-script lifecycle: `docs/userscript-validation.md`. @@ -29,6 +30,7 @@ remote repository, Tampermonkey, a browser page, or a trading account. | `src/binance-orderbook-trade/**` | `src/binance-orderbook-trade/index.user.js` | `npm run build:binance-orderbook-trade` | | `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/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 | @@ -40,6 +42,7 @@ readable, non-compressed, non-obfuscated, and preserve its `@updateURL` and - orderbook: `npm test`, `npm run check:binance-orderbook-trade`; - migrated Binance userscripts: `npm test`, `npm run check:binance-userscripts`; - Strategy 27: `npm run test:binance-strategy27-events`; + - Strategy 29: `npm run test:binance-strategy29-bollinger`; - m3u8: `node --test test/unit/m3u8-downloader-course-export.test.js`, `npm run check:m3u8-downloader`; - unmigrated scripts: `node --check `. diff --git a/src/binance-orderbook-trade/core/continuous-ladder.js b/src/binance-orderbook-trade/core/continuous-ladder.js index cf2f024..b6606d5 100644 --- a/src/binance-orderbook-trade/core/continuous-ladder.js +++ b/src/binance-orderbook-trade/core/continuous-ladder.js @@ -1,7 +1,7 @@ import { throwIfAborted, waitForPromiseOrAbort, -} from './abort.js'; +} from '../../shared/abort.js'; import { snapshotLadderProgress } from './ladder-progress.js'; import { combineLocalizedText, diff --git a/src/binance-orderbook-trade/dom/account-orders.js b/src/binance-orderbook-trade/dom/account-orders.js index a002c22..ac405ee 100644 --- a/src/binance-orderbook-trade/dom/account-orders.js +++ b/src/binance-orderbook-trade/dom/account-orders.js @@ -12,7 +12,7 @@ import { import { throwIfAborted, waitForPromiseOrAbort, -} from '../core/abort.js'; +} from '../../shared/abort.js'; function getNormalizedText(el) { return normalizeText(el?.textContent || ''); diff --git a/src/binance-orderbook-trade/dom/cancel-all-dialog.js b/src/binance-orderbook-trade/dom/cancel-all-dialog.js index bb7a9df..7dfb260 100644 --- a/src/binance-orderbook-trade/dom/cancel-all-dialog.js +++ b/src/binance-orderbook-trade/dom/cancel-all-dialog.js @@ -5,7 +5,7 @@ import { import { throwIfAborted, waitForPromiseOrAbort, -} from '../core/abort.js'; +} from '../../shared/abort.js'; const CANCEL_ALL_DIALOG_CANDIDATE_SELECTOR = '[role="dialog"], [class*="modal"], [class*="Modal"]'; diff --git a/src/binance-orderbook-trade/dom/chart-orders.js b/src/binance-orderbook-trade/dom/chart-orders.js index c8afe1e..859da78 100644 --- a/src/binance-orderbook-trade/dom/chart-orders.js +++ b/src/binance-orderbook-trade/dom/chart-orders.js @@ -1,4 +1,4 @@ -import { findBinanceTradingViewTarget } from './tradingview-target.js'; +import { findBinanceTradingViewTarget } from '../../shared/tradingview-target.js'; const ACTIVE_POPOVER_SELECTOR = '.bn-bubble.active'; const OPEN_ORDERS_LABEL_PATTERN = /^(?:当前委托|Open Orders)$/i; diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 1fd10f9..4db2b9a 100644 --- a/src/binance-orderbook-trade/index.user.js +++ b/src/binance-orderbook-trade/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.orderbook.trade // @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 2.7.198 +// @version 2.7.199 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -116,7 +116,7 @@ import { import { throwIfAborted, waitForPromiseOrAbort, -} from './core/abort.js'; +} from '../shared/abort.js'; import { formatStatusBaseAsset } from './core/status-symbol.js'; import { selectFarthestOpenOrders } from './core/open-order-capacity.js'; import { @@ -203,10 +203,11 @@ import { createTradingViewContinuousSaveController, createTradingViewRemovalSaveController, } from './core/chart-save-coalescer.js'; -import { findBinanceTradingViewTarget } from './dom/tradingview-target.js'; +import { findBinanceTradingViewTarget } from '../shared/tradingview-target.js'; +import { registerChartMutationOwner } from '../shared/chart-mutation-owners.js'; import { afterTradingViewMarkerSaves, -} from './core/chart-marker-save-controller.js'; +} from '../shared/chart-marker-save-controller.js'; import { installBinanceNativeDepthSource } from './core/binance-native-depth-source.js'; import { createDepthProfileSession } from './core/depth-profile-session.js'; import { @@ -238,19 +239,6 @@ import { getBinanceChartOrdersTarget as getBinanceChartOrdersTargetDom, } from './dom/chart-orders.js'; import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; -import { - applyBollingerAlertTaskFailure, - detectBollingerSignals, - isBollingerDrawingMutationBlocked, -} from './core/bearish-bollinger-pattern.js'; -import { - createBollingerIntervalSession, - createBollingerMarkerLayer, - exportClosedTradingViewBars, - findBearishBollingerChartTarget, - isBearishBollingerChartTargetCurrent, - reconcileBearishBollingerAlertWindow, -} from './dom/tradingview-bearish-alerts.js'; (function () { 'use strict'; @@ -432,7 +420,6 @@ import { const TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS = 3; const TRADE_ACTION_BUTTON_READY_TIMEOUT_MS = TRADE_ACTION_BUTTON_READY_TIMEOUT_SECONDS * 1000; const ROUTE_WATCHDOG_MS = 5000; - const BEARISH_BOLLINGER_ALERT_POLL_MS = 1000; let lastTs = 0; let isEditingMultiplier = false; @@ -522,11 +509,6 @@ import { let depthProfileObserverRoot = null; let depthProfileRenderQueued = false; let depthProfileSyncQueued = false; - let bearishBollingerAlertTimer = null; - let bearishBollingerAlertTask = null; - let bearishBollingerAlertContext = null; - let bollingerIntervalSession = null; - const retiredBollingerLayers = new Set(); const controlledNativeButtons = new Set(); let lastObservedSymbol = getCurrentSymbol(); @@ -587,218 +569,20 @@ import { // TradingView still emits drawing_event + saveChart when a disableSave marker is removed. // Keep alert reconciliation outside every native order-line save/coalescing session. - function isTradingViewDrawingMutationBusy() { - return isBollingerDrawingMutationBlocked({ + const unregisterChartMutationOwner = registerChartMutationOwner(window, 'orderbook', () => { + return [ ladderTask, continuousLadderTask, singleOrderTask, cancelCurrentSymbolOpenOrdersTask, chartOrdersRecoveryTask, continuousChartSaveController, - }); - } - - function clearBearishBollingerAlertContext() { - if (bearishBollingerAlertContext) { - retiredBollingerLayers.add(bearishBollingerAlertContext.layer); - bearishBollingerAlertContext = null; - } - return clearRetiredBollingerLayers(); - } - - function clearRetiredBollingerLayers() { - if (isTradingViewDrawingMutationBusy()) return false; - for (const layer of retiredBollingerLayers) { - if (layer.clear()) retiredBollingerLayers.delete(layer); - } - return retiredBollingerLayers.size === 0; - } - - function disposeBollingerIntervalSession() { - if (bollingerIntervalSession) { - bollingerIntervalSession.session.dispose(); - bollingerIntervalSession = null; - } - } - - function isBearishBollingerAlertContextCurrent(context) { - return ( - bearishBollingerAlertContext === context - && context.intervalSession === bollingerIntervalSession?.session - && context.intervalSession.isCurrent(context.intervalRevision) - && !document.hidden - && isFuturesTradingPage() - && !isTradingViewDrawingMutationBusy() - && getCurrentSymbol() === context.routeSymbol - && isBearishBollingerChartTargetCurrent(document, context.target) - ); - } - - async function synchronizeBearishBollingerAlerts() { - if (document.hidden || !isFuturesTradingPage()) return; - const routeSymbol = getCurrentSymbol(); - if (!routeSymbol) return; - - let target; - try { - target = findBearishBollingerChartTarget(document, routeSymbol); - } catch (error) { - disposeBollingerIntervalSession(); - clearBearishBollingerAlertContext(); - err('Bollinger chart lookup failed for this sample:', error); - return; - } - if (!target) { - disposeBollingerIntervalSession(); - clearBearishBollingerAlertContext(); - return; - } - - if ( - !bollingerIntervalSession - || bollingerIntervalSession.chart !== target.chart - || bollingerIntervalSession.routeSymbol !== routeSymbol - ) { - disposeBollingerIntervalSession(); - bollingerIntervalSession = { - chart: target.chart, - routeSymbol, - session: createBollingerIntervalSession(target.chart), - }; - } - const intervalSession = bollingerIntervalSession.session; - - const contextMatches = bearishBollingerAlertContext - && bearishBollingerAlertContext.target.chart === target.chart - && bearishBollingerAlertContext.target.chartRoot === target.chartRoot - && bearishBollingerAlertContext.target.tradingViewApi === target.tradingViewApi - && bearishBollingerAlertContext.routeSymbol === routeSymbol - && bearishBollingerAlertContext.resolution === target.resolution - && bearishBollingerAlertContext.intervalSession === intervalSession - && bearishBollingerAlertContext.intervalRevision === intervalSession.revision; - if (!contextMatches) { - if (!clearBearishBollingerAlertContext()) return; - if (!intervalSession.isCurrent(intervalSession.revision) || isTradingViewDrawingMutationBusy()) return; - bearishBollingerAlertContext = { - routeSymbol, - resolution: target.resolution, - intervalSession, - intervalRevision: intervalSession.revision, - target, - layer: createBollingerMarkerLayer(target, { - canMutate: () => !isTradingViewDrawingMutationBusy(), - onSaveError: (error) => err('Bollinger chart save failed:', error), - }), - failed: false, - cleanupPending: false, - lastProcessedClosedBarsWindowKey: null, - lastProcessedClosedBarsContentSnapshot: null, - lastProcessedSignals: null, - }; - } - - if (isTradingViewDrawingMutationBusy() || !clearRetiredBollingerLayers()) return; - - const context = bearishBollingerAlertContext; - if (context.cleanupPending) { - context.layer.clear(); - context.cleanupPending = false; - } - if (context.failed || bearishBollingerAlertTask) return; - const task = (async () => { - const bars = await exportClosedTradingViewBars(context.target, context.intervalSession); - if (!bars || !isBearishBollingerAlertContextCurrent(context)) return; - if (bars.length === 0) return; - const result = await reconcileBearishBollingerAlertWindow({ - bars, - cachedWindowKey: context.lastProcessedClosedBarsWindowKey, - cachedContentSnapshot: context.lastProcessedClosedBarsContentSnapshot, - cachedSignals: context.lastProcessedSignals, - detectSignals: detectBollingerSignals, - renderSignals: (signals) => context.layer.render(signals, { - isCurrent: () => isBearishBollingerAlertContextCurrent(context), - }), - }); - if (result.rendered && isBearishBollingerAlertContextCurrent(context)) { - context.lastProcessedClosedBarsWindowKey = result.closedBarsWindowKey; - context.lastProcessedClosedBarsContentSnapshot = result.closedBarsContentSnapshot; - context.lastProcessedSignals = result.signals; - } - })(); - bearishBollingerAlertTask = task; - task.catch((error) => { - if ( - bearishBollingerAlertContext !== context - || context.intervalSession !== bollingerIntervalSession?.session - || context.intervalRevision !== context.intervalSession.revision - ) return; - const failureKind = applyBollingerAlertTaskFailure(context, error); - if (failureKind === 'retry') { - // TradingView can expose one feed-update race through exportData(). Keep the - // already-rendered layer and retry the next poll instead of turning a transient - // snapshot into a permanent failed context. - warn('布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:', error); - return; - } - err('布林带形态预警已停止:', error); - }).finally(() => { - if (bearishBollingerAlertTask === task) bearishBollingerAlertTask = null; - }); - } - - function startBearishBollingerAlertMonitor() { - if (bearishBollingerAlertTimer || document.hidden || !isFuturesTradingPage()) return; - synchronizeBearishBollingerAlerts(); - bearishBollingerAlertTimer = setInterval( - synchronizeBearishBollingerAlerts, - BEARISH_BOLLINGER_ALERT_POLL_MS, - ); - } + ].some(value => value !== null); + }); + window.addEventListener('pagehide', event => { + if (!event.persisted) unregisterChartMutationOwner(); + }); - function stopBearishBollingerAlertMonitor() { - if (bearishBollingerAlertTimer) clearInterval(bearishBollingerAlertTimer); - bearishBollingerAlertTimer = null; - // Invalidate even while a trade/save owner defers physical marker removal. - disposeBollingerIntervalSession(); - clearBearishBollingerAlertContext(); - } - - /** On-demand lifecycle diagnostics; never exports market data or mutates drawings. */ - function getBollingerAlertDiagnostics() { - const context = bearishBollingerAlertContext; - const session = bollingerIntervalSession?.session || null; - const chart = bollingerIntervalSession?.chart || context?.target.chart || null; - const nativeModelReady = chart ? chart.hasModel() : null; - const ownerFlags = { - ladderTask: ladderTask !== null, - continuousLadderTask: continuousLadderTask !== null, - singleOrderTask: singleOrderTask !== null, - cancelCurrentSymbolOpenOrdersTask: cancelCurrentSymbolOpenOrdersTask !== null, - chartOrdersRecoveryTask: chartOrdersRecoveryTask !== null, - continuousChartSaveController: continuousChartSaveController !== null, - }; - return { - timerRunning: bearishBollingerAlertTimer !== null, - taskPending: bearishBollingerAlertTask !== null, - contextPresent: context !== null, - failed: context ? context.failed : null, - cleanupPending: context ? context.cleanupPending : null, - cachedSignalCount: context?.lastProcessedSignals === null || !context - ? null : context.lastProcessedSignals.length, - layerSize: context ? context.layer.size : null, - markerSaveStats: context ? context.layer.saveStats : null, - retiredCount: retiredBollingerLayers.size, - sessionPresent: session !== null, - sessionRevision: session ? session.revision : null, - contextIntervalRevision: context ? context.intervalRevision : null, - sessionMatchesContext: context && session ? context.intervalSession === session : null, - sessionCurrent: session && nativeModelReady ? session.isCurrent(session.revision) : null, - nativeModelReady, - nativeDataReady: nativeModelReady ? chart.dataReady() : null, - mutationBlocked: Object.values(ownerFlags).some(Boolean), - ownerFlags, - }; - } function parseJsonSafe(raw) { if (!raw || typeof raw !== 'string') return null; @@ -8383,7 +8167,6 @@ import { removePanel(); removeDepthProfileRuntimeView(); stopTradingTimers(); - clearBearishBollingerAlertContext(); invalidateTradeButtonCache(); lastDisplayCloseState = null; } @@ -8839,8 +8622,6 @@ import { // ── 切换币种 / 首次进入时触发杠杆重置 ── function clearSymbolOwnedRuntimeState(symbol) { stopDepthProfileSession(); - disposeBollingerIntervalSession(); - clearBearishBollingerAlertContext(); depthProfileData = null; depthProfileFailedSymbol = null; depthProfileStatus = { status: 'connecting', detail: '' }; @@ -8888,7 +8669,6 @@ import { ensureOrderbookPrecisionObserver(); ensureDepthProfileObserver(); scheduleDepthProfileSync(); - startBearishBollingerAlertMonitor(); } function stopTradingTimers() { @@ -8897,7 +8677,6 @@ import { stopOrderbookPrecisionObserver(); stopDepthProfileObserver(); stopDepthProfileSession(); - stopBearishBollingerAlertMonitor(); clearTradeUiMutationWait(); } @@ -8966,7 +8745,6 @@ import { window.addEventListener('pagehide', () => { stopDepthProfileObserver(); stopDepthProfileSession(); - stopBearishBollingerAlertMonitor(); removeDepthProfileRuntimeView(); }, { once: true }); @@ -8999,7 +8777,6 @@ import { window.__TM_CLOSE_LONG_DEBUG__ = { cfg: CFG, - get bollingerAlertState() { return getBollingerAlertDiagnostics(); }, get continuousChartSaveStats() { return continuousChartSaveController?.getStats() || null; }, diff --git a/src/binance-orderbook-trade/core/bearish-bollinger-pattern.js b/src/binance-strategy29-bollinger/core/bearish-bollinger-pattern.js similarity index 100% rename from src/binance-orderbook-trade/core/bearish-bollinger-pattern.js rename to src/binance-strategy29-bollinger/core/bearish-bollinger-pattern.js diff --git a/src/binance-orderbook-trade/dom/tradingview-bearish-alerts.js b/src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js similarity index 99% rename from src/binance-orderbook-trade/dom/tradingview-bearish-alerts.js rename to src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js index 8eebb57..e23a3e9 100644 --- a/src/binance-orderbook-trade/dom/tradingview-bearish-alerts.js +++ b/src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js @@ -1,5 +1,5 @@ -import { findBinanceTradingViewTarget } from './tradingview-target.js'; -import { installTradingViewMarkerSaveController } from '../core/chart-marker-save-controller.js'; +import { findBinanceTradingViewTarget } from '../../shared/tradingview-target.js'; +import { installTradingViewMarkerSaveController } from '../../shared/chart-marker-save-controller.js'; import { TradingViewBarSnapshotInconsistentError, } from '../core/bearish-bollinger-pattern.js'; diff --git a/src/binance-strategy29-bollinger/index.user.js b/src/binance-strategy29-bollinger/index.user.js new file mode 100644 index 0000000..9e750fa --- /dev/null +++ b/src/binance-strategy29-bollinger/index.user.js @@ -0,0 +1,20 @@ +// ==UserScript== +// @name 【自写】Binance Strategy 29 布林带信号 +// @namespace binance.strategy29.bollinger +// @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E +// @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E +// @version 0.1.0 +// @author jackhai9 +// @description Closed-candle Bollinger/SMA60 signals on the native Binance chart +// @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-strategy29-bollinger.user.js +// @downloadURL https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js +// @run-at document-start +// @grant none +// ==/UserScript== +import { installStrategy29 } from './runtime.js'; + +installStrategy29(window); diff --git a/src/binance-strategy29-bollinger/monitor.js b/src/binance-strategy29-bollinger/monitor.js new file mode 100644 index 0000000..9f33b12 --- /dev/null +++ b/src/binance-strategy29-bollinger/monitor.js @@ -0,0 +1,211 @@ +import { + applyBollingerAlertTaskFailure, + detectBollingerSignals, +} from './core/bearish-bollinger-pattern.js'; +import { + createBollingerIntervalSession, + createBollingerMarkerLayer, + exportClosedTradingViewBars, + findBearishBollingerChartTarget, + isBearishBollingerChartTargetCurrent, + reconcileBearishBollingerAlertWindow, +} from './dom/tradingview-bearish-alerts.js'; + + +/** Keeps strategy lifecycle independent of trading UI and makes the host boundary testable. */ +export function createBollingerMonitor({ + document, getCurrentSymbol, isFuturesTradingPage, isTradingViewDrawingMutationBusy, + err, warn, +}) { + let bearishBollingerAlertTask = null; + let bearishBollingerAlertContext = null; + let bollingerIntervalSession = null; + const retiredBollingerLayers = new Set(); + + function clearBearishBollingerAlertContext() { + if (bearishBollingerAlertContext) { + retiredBollingerLayers.add(bearishBollingerAlertContext.layer); + bearishBollingerAlertContext = null; + } + return clearRetiredBollingerLayers(); + } + + function clearRetiredBollingerLayers() { + if (isTradingViewDrawingMutationBusy()) return false; + for (const layer of retiredBollingerLayers) { + if (layer.clear()) retiredBollingerLayers.delete(layer); + } + return retiredBollingerLayers.size === 0; + } + + function disposeBollingerIntervalSession() { + if (bollingerIntervalSession) { + bollingerIntervalSession.session.dispose(); + bollingerIntervalSession = null; + } + } + + function isBearishBollingerAlertContextCurrent(context) { + return ( + bearishBollingerAlertContext === context + && context.intervalSession === bollingerIntervalSession?.session + && context.intervalSession.isCurrent(context.intervalRevision) + && !document.hidden + && isFuturesTradingPage() + && !isTradingViewDrawingMutationBusy() + && getCurrentSymbol() === context.routeSymbol + && isBearishBollingerChartTargetCurrent(document, context.target) + ); + } + + async function synchronizeBearishBollingerAlerts() { + if (document.hidden || !isFuturesTradingPage()) return; + const routeSymbol = getCurrentSymbol(); + if (!routeSymbol) return; + + let target; + try { + target = findBearishBollingerChartTarget(document, routeSymbol); + } catch (error) { + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + err('Bollinger chart lookup failed for this sample:', error); + return; + } + if (!target) { + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + return; + } + + if ( + !bollingerIntervalSession + || bollingerIntervalSession.chart !== target.chart + || bollingerIntervalSession.routeSymbol !== routeSymbol + ) { + disposeBollingerIntervalSession(); + bollingerIntervalSession = { + chart: target.chart, + routeSymbol, + session: createBollingerIntervalSession(target.chart), + }; + } + const intervalSession = bollingerIntervalSession.session; + + const contextMatches = bearishBollingerAlertContext + && bearishBollingerAlertContext.target.chart === target.chart + && bearishBollingerAlertContext.target.chartRoot === target.chartRoot + && bearishBollingerAlertContext.target.tradingViewApi === target.tradingViewApi + && bearishBollingerAlertContext.routeSymbol === routeSymbol + && bearishBollingerAlertContext.resolution === target.resolution + && bearishBollingerAlertContext.intervalSession === intervalSession + && bearishBollingerAlertContext.intervalRevision === intervalSession.revision; + if (!contextMatches) { + if (!clearBearishBollingerAlertContext()) return; + if (!intervalSession.isCurrent(intervalSession.revision) || isTradingViewDrawingMutationBusy()) return; + bearishBollingerAlertContext = { + routeSymbol, + resolution: target.resolution, + intervalSession, + intervalRevision: intervalSession.revision, + target, + layer: createBollingerMarkerLayer(target, { + canMutate: () => !isTradingViewDrawingMutationBusy(), + onSaveError: (error) => err('Bollinger chart save failed:', error), + }), + failed: false, + cleanupPending: false, + lastProcessedClosedBarsWindowKey: null, + lastProcessedClosedBarsContentSnapshot: null, + lastProcessedSignals: null, + }; + } + + if (isTradingViewDrawingMutationBusy() || !clearRetiredBollingerLayers()) return; + + const context = bearishBollingerAlertContext; + if (context.cleanupPending) { + context.layer.clear(); + context.cleanupPending = false; + } + if (context.failed || bearishBollingerAlertTask) return; + const task = (async () => { + const bars = await exportClosedTradingViewBars(context.target, context.intervalSession); + if (!bars || !isBearishBollingerAlertContextCurrent(context)) return; + if (bars.length === 0) return; + const result = await reconcileBearishBollingerAlertWindow({ + bars, + cachedWindowKey: context.lastProcessedClosedBarsWindowKey, + cachedContentSnapshot: context.lastProcessedClosedBarsContentSnapshot, + cachedSignals: context.lastProcessedSignals, + detectSignals: detectBollingerSignals, + renderSignals: (signals) => context.layer.render(signals, { + isCurrent: () => isBearishBollingerAlertContextCurrent(context), + }), + }); + if (result.rendered && isBearishBollingerAlertContextCurrent(context)) { + context.lastProcessedClosedBarsWindowKey = result.closedBarsWindowKey; + context.lastProcessedClosedBarsContentSnapshot = result.closedBarsContentSnapshot; + context.lastProcessedSignals = result.signals; + } + })(); + bearishBollingerAlertTask = task; + task.catch((error) => { + if ( + bearishBollingerAlertContext !== context + || context.intervalSession !== bollingerIntervalSession?.session + || context.intervalRevision !== context.intervalSession.revision + ) return; + const failureKind = applyBollingerAlertTaskFailure(context, error); + if (failureKind === 'retry') { + // TradingView can expose one feed-update race through exportData(). Keep the + // already-rendered layer and retry the next poll instead of turning a transient + // snapshot into a permanent failed context. + warn('布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:', error); + return; + } + err('布林带形态预警已停止:', error); + }).finally(() => { + if (bearishBollingerAlertTask === task) bearishBollingerAlertTask = null; + }); + } + + function stopBearishBollingerAlertMonitor() { + // Invalidate even while a trade/save owner defers physical marker removal. + disposeBollingerIntervalSession(); + clearBearishBollingerAlertContext(); + } + + /** On-demand lifecycle diagnostics; never exports market data or mutates drawings. */ + function getBollingerAlertDiagnostics() { + const context = bearishBollingerAlertContext; + const session = bollingerIntervalSession?.session || null; + const chart = bollingerIntervalSession?.chart || context?.target.chart || null; + const nativeModelReady = chart ? chart.hasModel() : null; + return { + taskPending: bearishBollingerAlertTask !== null, + contextPresent: context !== null, + failed: context ? context.failed : null, + cleanupPending: context ? context.cleanupPending : null, + cachedSignalCount: context?.lastProcessedSignals === null || !context + ? null : context.lastProcessedSignals.length, + layerSize: context ? context.layer.size : null, + markerSaveStats: context ? context.layer.saveStats : null, + retiredCount: retiredBollingerLayers.size, + sessionPresent: session !== null, + sessionRevision: session ? session.revision : null, + contextIntervalRevision: context ? context.intervalRevision : null, + sessionMatchesContext: context && session ? context.intervalSession === session : null, + sessionCurrent: session && nativeModelReady ? session.isCurrent(session.revision) : null, + nativeModelReady, + nativeDataReady: nativeModelReady ? chart.dataReady() : null, + mutationBlocked: isTradingViewDrawingMutationBusy(), + }; + } + + return Object.freeze({ + tick: synchronizeBearishBollingerAlerts, + stop: stopBearishBollingerAlertMonitor, + get diagnostics() { return getBollingerAlertDiagnostics(); }, + }); +} diff --git a/src/binance-strategy29-bollinger/runtime.js b/src/binance-strategy29-bollinger/runtime.js new file mode 100644 index 0000000..d3fca63 --- /dev/null +++ b/src/binance-strategy29-bollinger/runtime.js @@ -0,0 +1,96 @@ +import { createBollingerMonitor } from './monitor.js'; +import { isChartMutationBlocked } from '../shared/chart-mutation-owners.js'; +import { isFuturesTradingPathname, parseFuturesTradingSymbolFromPathname } from '../shared/binance-futures-route.js'; +import { ensureSpaRouteChangePatched, installSpaRouteChangeListener } from '../shared/spa-route-change.js'; + +const INSTANCE = Symbol.for('jh-userscripts.strategy29-bollinger'); +const CONFLICT = 'Strategy 29 stopped: update Orderbook to 2.7.199 or disable its embedded Bollinger version, then reload this page.'; + +/** This is a migration refusal, not compatibility with the old independently owned save wrapper. */ +export function hasEmbeddedBollinger(view) { + const debug = view.__TM_CLOSE_LONG_DEBUG__; + return !!debug && Object.getOwnPropertyDescriptor(debug, 'bollingerAlertState') !== undefined; +} + +/** Page-context singleton: independent installation, no exchange/account/network operations. */ +export function installStrategy29(view) { + if (view[INSTANCE] !== undefined) { + if (view[INSTANCE].version !== 1) throw new Error('Incompatible Strategy 29 runtime; reload the page'); + return view[INSTANCE].runtime; + } + const document = view.document; + let timer = null; + let failed = null; + let disposed = false; + let removeRouteListener = null; + const noticeId = 'jh-strategy29-bollinger-status'; + function showFailure() { + if (!failed || !document.body) return; + let notice = document.getElementById(noticeId); + if (!notice) { + notice = document.createElement('div'); + notice.id = noticeId; + notice.setAttribute('role', 'status'); + notice.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:10000;max-width:420px;padding:10px;background:#332b16;color:#ffcf67;font:13px sans-serif;pointer-events:none'; + document.body.append(notice); + } + notice.textContent = failed; + } + const monitor = createBollingerMonitor({ + document, + getCurrentSymbol: () => parseFuturesTradingSymbolFromPathname(view.location.pathname), + isFuturesTradingPage: () => !disposed && !failed && isFuturesTradingPathname(view.location.pathname), + isTradingViewDrawingMutationBusy: () => hasEmbeddedBollinger(view) || isChartMutationBlocked(view), + err: (...args) => view.console.error('[Strategy29]', ...args), + warn: (...args) => view.console.warn('[Strategy29]', ...args), + }); + function pause() { + if (timer !== null) view.clearInterval(timer); + timer = null; + monitor.stop(); + } + function fail(message) { + failed = message; + pause(); + showFailure(); + } + function sample() { + if (disposed || failed || document.hidden) return; + if (hasEmbeddedBollinger(view)) { fail(CONFLICT); return; } + ensureSpaRouteChangePatched(view); + if (!isFuturesTradingPathname(view.location.pathname)) { monitor.stop(); return; } + // Job boundary: unexpected synchronization errors stop this observer only. + void monitor.tick().catch(error => fail(`Strategy 29 stopped: ${error.message}`)); + } + function resume() { + if (disposed || failed || document.hidden) return; + sample(); + if (!failed && timer === null) timer = view.setInterval(sample, 1000); + } + function onVisibility() { if (document.hidden) pause(); else resume(); } + function onPageHide(event) { if (event.persisted) pause(); else runtime.dispose(); } + function onPageShow() { resume(); } + const runtime = Object.freeze({ + get diagnostics() { return { ...monitor.diagnostics, runtimeFailure: failed, disposed, timerRunning: timer !== null }; }, + dispose() { + if (disposed) return; + disposed = true; + pause(); + removeRouteListener(); + document.removeEventListener('visibilitychange', onVisibility); + document.removeEventListener('DOMContentLoaded', showFailure); + view.removeEventListener('pagehide', onPageHide); + view.removeEventListener('pageshow', onPageShow); + document.getElementById(noticeId)?.remove(); + }, + }); + Object.defineProperty(view, INSTANCE, { value: Object.freeze({ version: 1, runtime }) }); + Object.defineProperty(view, '__TM_STRATEGY29_DEBUG__', { value: runtime }); + removeRouteListener = installSpaRouteChangeListener(view, sample); + document.addEventListener('visibilitychange', onVisibility); + document.addEventListener('DOMContentLoaded', showFailure, { once: true }); + view.addEventListener('pagehide', onPageHide); + view.addEventListener('pageshow', onPageShow); + resume(); + return runtime; +} diff --git a/src/binance-orderbook-trade/core/abort.js b/src/shared/abort.js similarity index 100% rename from src/binance-orderbook-trade/core/abort.js rename to src/shared/abort.js diff --git a/src/binance-orderbook-trade/core/chart-marker-save-controller.js b/src/shared/chart-marker-save-controller.js similarity index 89% rename from src/binance-orderbook-trade/core/chart-marker-save-controller.js rename to src/shared/chart-marker-save-controller.js index a233954..40e0fa2 100644 --- a/src/binance-orderbook-trade/core/chart-marker-save-controller.js +++ b/src/shared/chart-marker-save-controller.js @@ -1,6 +1,16 @@ import { throwIfAborted, waitForPromiseOrAbort } from './abort.js'; -const controllers = new WeakMap(); +const CONTROLLER_SLOT = Symbol.for('jh-userscripts.chart-marker-save-controller'); +const PROTOCOL_VERSION = 1; + +function readController(api) { + const record = api[CONTROLLER_SLOT]; + if (record === undefined) 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; +} const QUIET_MS = 150; const MAX_BURST_MS = 1000; const DRAIN_TIMEOUT_MS = 2000; @@ -17,7 +27,8 @@ export function installTradingViewMarkerSaveController(api, { setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout, } = {}) { - if (controllers.has(api)) return controllers.get(api); + const existing = readController(api); + if (existing) return existing; if (typeof api?.saveChart !== 'function') { throw new Error('TradingView marker save API is unavailable'); } @@ -158,17 +169,17 @@ export function installTradingViewMarkerSaveController(api, { failureCount, pendingCallbacks: burst?.callbacks.length || 0, }), }); - controllers.set(api, controller); + Object.defineProperty(api, CONTROLLER_SLOT, { value: Object.freeze({ version: PROTOCOL_VERSION, controller }) }); return controller; } export function getTradingViewMarkerSaveController(api) { - return controllers.get(api) || null; + return readController(api); } /** Call the outer owner's installer in the same continuation that confirms idle. */ export function afterTradingViewMarkerSaves(api, action, options) { throwIfAborted(options?.signal); - const controller = controllers.get(api); + const controller = readController(api); return controller ? controller.runAfterIdle(action, options) : action(); } diff --git a/src/shared/chart-mutation-owners.js b/src/shared/chart-mutation-owners.js new file mode 100644 index 0000000..3971062 --- /dev/null +++ b/src/shared/chart-mutation-owners.js @@ -0,0 +1,33 @@ +const OWNER_SLOT = Symbol.for('jh-userscripts.chart-mutation-owners'); +const VERSION = 1; + +function owners(view) { + if (view[OWNER_SLOT] === undefined) { + Object.defineProperty(view, OWNER_SLOT, { + value: Object.freeze({ version: VERSION, predicates: new Map() }), + }); + } + const record = view[OWNER_SLOT]; + if (record.version !== VERSION || !(record.predicates instanceof Map)) { + throw new Error('Incompatible chart mutation protocol; update both scripts and reload'); + } + return record.predicates; +} + +/** Only a synchronous boolean crosses the script boundary, never task or account data. */ +export function registerChartMutationOwner(view, name, predicate) { + const registry = owners(view); + if (registry.has(name)) throw new Error('Duplicate chart mutation owner'); + if (typeof predicate !== 'function') throw new Error('Chart mutation owner requires a predicate'); + registry.set(name, predicate); + return () => { if (registry.get(name) === predicate) registry.delete(name); }; +} + +export function isChartMutationBlocked(view) { + for (const predicate of owners(view).values()) { + const blocked = predicate(); + if (typeof blocked !== 'boolean') throw new Error('Chart mutation owner must return a boolean'); + if (blocked) return true; + } + return false; +} diff --git a/src/binance-orderbook-trade/dom/tradingview-target.js b/src/shared/tradingview-target.js similarity index 100% rename from src/binance-orderbook-trade/dom/tradingview-target.js rename to src/shared/tradingview-target.js diff --git a/test/dom/binance-strategy29-bollinger/runtime.test.js b/test/dom/binance-strategy29-bollinger/runtime.test.js new file mode 100644 index 0000000..97d7239 --- /dev/null +++ b/test/dom/binance-strategy29-bollinger/runtime.test.js @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { JSDOM } from 'jsdom'; +import { installStrategy29 } from '../../../src/binance-strategy29-bollinger/runtime.js'; + +function fixture() { + const dom = new JSDOM('', { url: 'https://www.binance.com/en/futures/BTRUSDT' }); + const view = dom.window; + let hidden = false, next = 0; + const timers = new Map(); + Object.defineProperty(view.document, 'hidden', { get: () => hidden }); + view.setInterval = callback => { timers.set(++next, callback); return next; }; + view.clearInterval = id => timers.delete(id); + return { dom, view, timers, + tick() { for (const callback of timers.values()) callback(); }, + hide(value) { hidden = value; view.document.dispatchEvent(new view.Event('visibilitychange')); } }; +} + +test('standalone injection is single-instance and pauses/resumes/disposes its only timer', () => { + const f = fixture(); + const runtime = installStrategy29(f.view); + assert.equal(installStrategy29(f.view), runtime); + assert.equal(f.timers.size, 1); + f.hide(true); + assert.equal(f.timers.size, 0); + f.hide(false); + assert.equal(f.timers.size, 1); + f.view.history.pushState({}, '', '/en/my/wallet/futures'); + assert.equal(runtime.diagnostics.contextPresent, false); + runtime.dispose(); + assert.equal(f.timers.size, 0); + f.hide(true); f.hide(false); + assert.equal(f.timers.size, 0); + f.dom.window.close(); +}); + +for (const legacyFirst of [true, false]) { + test(`legacy embedded observer refuses coexistence (legacy first=${legacyFirst})`, () => { + const f = fixture(); + const legacy = {}; + Object.defineProperty(legacy, 'bollingerAlertState', { get() { throw new Error('Do not inspect legacy runtime data'); } }); + if (legacyFirst) f.view.__TM_CLOSE_LONG_DEBUG__ = legacy; + const runtime = installStrategy29(f.view); + if (!legacyFirst) { f.view.__TM_CLOSE_LONG_DEBUG__ = legacy; f.tick(); } + assert.match(runtime.diagnostics.runtimeFailure, /update Orderbook to 2.7.199/); + assert.equal(runtime.diagnostics.failed, null); + assert.equal(f.timers.size, 0); + assert.match(f.view.document.querySelector('[role=status]').textContent, /reload/); + assert.equal(f.view.__TM_CLOSE_LONG_DEBUG__, legacy); + runtime.dispose(); f.dom.window.close(); + }); +} diff --git a/test/dom/binance-orderbook-trade/tradingview-bearish-alerts.test.js b/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js similarity index 98% rename from test/dom/binance-orderbook-trade/tradingview-bearish-alerts.test.js rename to test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js index 596134e..cb6cdd7 100644 --- a/test/dom/binance-orderbook-trade/tradingview-bearish-alerts.test.js +++ b/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { readFile } from 'node:fs/promises'; -import * as alertApi from '../../../src/binance-orderbook-trade/dom/tradingview-bearish-alerts.js'; -import { getTradingViewMarkerSaveController } from '../../../src/binance-orderbook-trade/core/chart-marker-save-controller.js'; +import * as alertApi from '../../../src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js'; +import { getTradingViewMarkerSaveController } from '../../../src/shared/chart-marker-save-controller.js'; import { loadFixtureDom } from '../../helpers/dom.js'; import { @@ -20,14 +20,14 @@ import { MAX_BEARISH_BOLLINGER_MARKERS, matchesClosedBarsContentSnapshot, tradingViewResolutionToSeconds, -} from '../../../src/binance-orderbook-trade/dom/tradingview-bearish-alerts.js'; +} from '../../../src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js'; import { applyBollingerAlertTaskFailure, isTradingViewBarSnapshotInconsistentError, TradingViewBarSnapshotInconsistentError, -} from '../../../src/binance-orderbook-trade/core/bearish-bollinger-pattern.js'; +} from '../../../src/binance-strategy29-bollinger/core/bearish-bollinger-pattern.js'; -const monitorSource = await readFile(new URL('../../../src/binance-orderbook-trade/index.user.js', import.meta.url), 'utf8'); +const monitorSource = await readFile(new URL('../../../src/binance-strategy29-bollinger/monitor.js', import.meta.url), 'utf8'); test('native marker creation and clear save bursts preserve foreign drawings without arming stable audits', async () => { const fixture = createChartDom(); @@ -96,7 +96,7 @@ test('an outer save drain waits for native creation, leaves its late result hidd /** Execute the production monitor functions, without the unrelated trading/bootstrap side effects. */ function createMonitorHarness(fixture, dependencyOverrides = {}) { const start = monitorSource.indexOf(' function clearBearishBollingerAlertContext()'); - const end = monitorSource.indexOf(' function parseJsonSafe(', start); + const end = monitorSource.indexOf(' return Object.freeze(', start); assert.ok(start > 0 && end > start); let busy = false; let hidden = false; @@ -119,11 +119,9 @@ function createMonitorHarness(fixture, dependencyOverrides = {}) { warn: () => {}, setInterval: () => 1, clearInterval: () => {}, - BEARISH_BOLLINGER_ALERT_POLL_MS: 1000, ...dependencyOverrides, }; const factory = new Function(...Object.keys(dependencies), ` - let bearishBollingerAlertTimer = null; let bearishBollingerAlertTask = null; let bearishBollingerAlertContext = null; let bollingerIntervalSession = null; @@ -668,16 +666,13 @@ test('on-demand diagnostics distinguish active, awaiting-data and torn-down stat fixture.chart.getAllShapes = () => { throw new Error('Diagnostics must not audit drawings'); }; fixture.chart.exportData = () => { throw new Error('Diagnostics must not export data'); }; assert.deepEqual(harness.monitor.diagnostics, { - timerRunning: false, taskPending: false, contextPresent: true, failed: false, + taskPending: false, contextPresent: true, failed: false, cleanupPending: false, cachedSignalCount: 1, layerSize: 1, retiredCount: 0, markerSaveStats: { busy: true, mutations: 0, draining: 0, saveRequests: 0, serializations: 0, callbackCount: 0, failureCount: 0, pendingCallbacks: 0 }, sessionPresent: true, sessionRevision: 0, contextIntervalRevision: 0, sessionMatchesContext: true, sessionCurrent: true, nativeModelReady: true, nativeDataReady: true, mutationBlocked: false, - ownerFlags: { ladderTask: false, continuousLadderTask: false, singleOrderTask: false, - cancelCurrentSymbolOpenOrdersTask: false, chartOrdersRecoveryTask: false, - continuousChartSaveController: false }, }); fixture.setResolution('5'); assert.equal(harness.monitor.diagnostics.sessionRevision, 1); diff --git a/test/unit/binance-orderbook-trade/chart-marker-save-controller.test.js b/test/unit/binance-orderbook-trade/chart-marker-save-controller.test.js index 1cef05c..9a6930b 100644 --- a/test/unit/binance-orderbook-trade/chart-marker-save-controller.test.js +++ b/test/unit/binance-orderbook-trade/chart-marker-save-controller.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import * as subject from '../../../src/binance-orderbook-trade/core/chart-marker-save-controller.js'; +import * as subject from '../../../src/shared/chart-marker-save-controller.js'; function fixture() { let now = 0; diff --git a/test/unit/binance-orderbook-trade/chart-marker-save-entrypoints.test.js b/test/unit/binance-orderbook-trade/chart-marker-save-entrypoints.test.js index d483dd6..d6f12fe 100644 --- a/test/unit/binance-orderbook-trade/chart-marker-save-entrypoints.test.js +++ b/test/unit/binance-orderbook-trade/chart-marker-save-entrypoints.test.js @@ -1,11 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; -import { throwIfAborted } from '../../../src/binance-orderbook-trade/core/abort.js'; +import { throwIfAborted } from '../../../src/shared/abort.js'; import { afterTradingViewMarkerSaves, installTradingViewMarkerSaveController, -} from '../../../src/binance-orderbook-trade/core/chart-marker-save-controller.js'; +} from '../../../src/shared/chart-marker-save-controller.js'; const source = await readFile(new URL('../../../src/binance-orderbook-trade/index.user.js', import.meta.url), 'utf8'); diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index bb4b769..ca321e2 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -3,7 +3,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; const source = await readFile(new URL('../../../src/binance-orderbook-trade/index.user.js', import.meta.url), 'utf8'); -const bollingerPatternSource = await readFile(new URL('../../../src/binance-orderbook-trade/core/bearish-bollinger-pattern.js', import.meta.url), 'utf8'); const generatedSource = await readFile(new URL('../../../scripts/binance-orderbook-trade.user.js', import.meta.url), 'utf8'); const ladderPlanSource = await readFile(new URL('../../../src/binance-orderbook-trade/core/ladder-plan.js', import.meta.url), 'utf8'); const chartSaveCoalescerSource = await readFile(new URL('../../../src/binance-orderbook-trade/core/chart-save-coalescer.js', import.meta.url), 'utf8'); @@ -51,27 +50,6 @@ test('route changes are event-driven with one low-frequency watchdog', () => { assert.match(visibilityBody, /syncRouteState\(\)/); }); -test('bearish chart alerts reconcile every loaded closed-bar window without silent truncation', () => { - const synchronizeBody = readFunctionBody('synchronizeBearishBollingerAlerts'); - assert.match(synchronizeBody, /reconcileBearishBollingerAlertWindow\(\{/); - assert.match(synchronizeBody, /lastProcessedClosedBarsWindowKey/); - assert.match(synchronizeBody, /lastProcessedSignals/); - assert.doesNotMatch(synchronizeBody, /lastProcessedClosedBarTime/); - assert.doesNotMatch(synchronizeBody, /\.slice\(-BEARISH_BOLLINGER_ALERT_MAX_MARKERS\)/); - assert.doesNotMatch(source, /BEARISH_BOLLINGER_ALERT_MAX_MARKERS/); - assert.doesNotMatch(source, /nextExportAtMs/); -}); - -test('Bollinger chart alert failures distinguish snapshot races from contract failures', () => { - const synchronizeBody = readFunctionBody('synchronizeBearishBollingerAlerts'); - const snapshotBranchStart = synchronizeBody.indexOf("failureKind === 'retry'"); - assert.ok(snapshotBranchStart >= 0); - assert.match(synchronizeBody, /applyBollingerAlertTaskFailure\(context, error\)/); - assert.match(synchronizeBody, /failureKind === 'retry'[\s\S]*等待下一次采样/); - assert.match(synchronizeBody, /context\.cleanupPending[\s\S]*context\.layer\.clear\(\)/); - assert.match(bollingerPatternSource, /context\.failed = true;/); - assert.match(bollingerPatternSource, /context\.cleanupPending = true;/); -}); test('permanent trade-mode observer is scoped to the trade tab root', () => { const observerBody = readFunctionBody('ensureTradeModeTabObserver'); diff --git a/test/unit/binance-orderbook-trade/tradingview-target.test.js b/test/unit/binance-orderbook-trade/tradingview-target.test.js index ec5c63d..16acc77 100644 --- a/test/unit/binance-orderbook-trade/tradingview-target.test.js +++ b/test/unit/binance-orderbook-trade/tradingview-target.test.js @@ -5,7 +5,7 @@ import { loadFixtureDom } from '../../helpers/dom.js'; import { findBinanceTradingViewTarget, getBinanceTradingViewTarget, -} from '../../../src/binance-orderbook-trade/dom/tradingview-target.js'; +} from '../../../src/shared/tradingview-target.js'; function createChartMarkup({ mode = 'tradingview' } = {}) { const chartBody = mode === 'tradingview' diff --git a/test/unit/binance-orderbook-trade/bearish-bollinger-pattern.test.js b/test/unit/binance-strategy29-bollinger/bearish-bollinger-pattern.test.js similarity index 99% rename from test/unit/binance-orderbook-trade/bearish-bollinger-pattern.test.js rename to test/unit/binance-strategy29-bollinger/bearish-bollinger-pattern.test.js index f0c2306..a95ad5b 100644 --- a/test/unit/binance-orderbook-trade/bearish-bollinger-pattern.test.js +++ b/test/unit/binance-strategy29-bollinger/bearish-bollinger-pattern.test.js @@ -11,7 +11,7 @@ import { isTradingViewBarSnapshotInconsistentError, isBearishBollingerDrawingMutationBlocked, TradingViewBarSnapshotInconsistentError, -} from '../../../src/binance-orderbook-trade/core/bearish-bollinger-pattern.js'; +} from '../../../src/binance-strategy29-bollinger/core/bearish-bollinger-pattern.js'; function createOhlcBars(count, secondsPerBar = 60) { return Array.from({ length: count }, (_, index) => { diff --git a/test/unit/binance-strategy29-bollinger/coordination.test.js b/test/unit/binance-strategy29-bollinger/coordination.test.js new file mode 100644 index 0000000..8e6ba93 --- /dev/null +++ b/test/unit/binance-strategy29-bollinger/coordination.test.js @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { build } from 'esbuild'; +import vm from 'node:vm'; + +/** Separate bundles emulate separate Tampermonkey installations sharing one page. */ +async function bundle() { + const result = await build({ + stdin: { contents: "export * from './src/shared/chart-marker-save-controller.js'; export * from './src/shared/chart-mutation-owners.js';", + resolveDir: process.cwd(), sourcefile: 'coordination-test.js' }, + bundle: true, write: false, format: 'iife', globalName: 'coordination', + }); + return vm.runInThisContext(result.outputFiles[0].text + '; coordination;'); +} + +test('independent bundles share one exact API controller and either order sees the same drain', async () => { + const a = await bundle(), b = await bundle(); + for (const [first, second] of [[a, b], [b, a]]) { + let saves = 0; + const api = { saveChart: callback => { saves += 1; return callback({ drawings: ['user'] }); } }; + assert.equal(second.afterTradingViewMarkerSaves(api, () => 3), 3); + const controller = first.installTradingViewMarkerSaveController(api); + const wrapper = api.saveChart; + assert.equal(second.installTradingViewMarkerSaveController(api), controller); + assert.equal(api.saveChart, wrapper); + const finish = controller.beginMutation(); + let started = false; + const drain = second.afterTradingViewMarkerSaves(api, () => { started = true; return 4; }); + assert.equal(started, false); + assert.equal(controller.canMutate(), false); + finish(); + api.saveChart(snapshot => assert.deepEqual(snapshot.drawings, ['user'])); + assert.equal(await drain, 4); + assert.equal(started, true); + assert.equal(saves, 1); + } +}); + +test('independent bundles expose only a live boolean and unregister their own owner', async () => { + const a = await bundle(), b = await bundle(), view = {}; + assert.equal(b.isChartMutationBlocked(view), false); + let busy = false; + const remove = a.registerChartMutationOwner(view, 'orderbook', () => busy); + assert.equal(b.isChartMutationBlocked(view), false); + busy = true; + assert.equal(b.isChartMutationBlocked(view), true); + assert.throws(() => b.registerChartMutationOwner(view, 'orderbook', () => false), /Duplicate/); + remove(); + assert.equal(b.isChartMutationBlocked(view), false); + a.registerChartMutationOwner(view, 'invalid', () => 1); + assert.throws(() => b.isChartMutationBlocked(view), /boolean/); +}); + +test('incompatible shared protocols fail without overwriting an owner', async () => { + const a = await bundle(); + const record = { version: 99 }; + const api = { [Symbol.for('jh-userscripts.chart-marker-save-controller')]: record }; + assert.throws(() => a.installTradingViewMarkerSaveController(api), /Incompatible/); + assert.equal(api[Symbol.for('jh-userscripts.chart-marker-save-controller')], record); + const view = { [Symbol.for('jh-userscripts.chart-mutation-owners')]: record }; + assert.throws(() => a.isChartMutationBlocked(view), /Incompatible/); +}); diff --git a/test/unit/binance-strategy29-bollinger/source-regressions.test.js b/test/unit/binance-strategy29-bollinger/source-regressions.test.js new file mode 100644 index 0000000..e1f4ad9 --- /dev/null +++ b/test/unit/binance-strategy29-bollinger/source-regressions.test.js @@ -0,0 +1,48 @@ +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const source = await readFile(new URL('../../../src/binance-strategy29-bollinger/monitor.js', import.meta.url), 'utf8'); +const bollingerPatternSource = await readFile(new URL('../../../src/binance-strategy29-bollinger/core/bearish-bollinger-pattern.js', import.meta.url), 'utf8'); + +function readFunctionBody(name, sourceText = source) { + const start = sourceText.indexOf(`function ${name}(`); + assert.notEqual(start, -1, `${name} should exist`); + const braceStart = sourceText.indexOf('{', start); + let depth = 0; + for (let index = braceStart; index < sourceText.length; index += 1) { + const char = sourceText[index]; + if (char === '{') depth += 1; + if (char === '}') depth -= 1; + if (depth === 0) return sourceText.slice(braceStart + 1, index); + } + assert.fail(`${name} body should be closed`); +} + +function readUserscriptVersion(sourceText) { + const match = sourceText.match(/^\/\/ @version\s+(\S+)\s*$/m); + assert.notEqual(match, null, 'userscript version metadata should exist'); + return match[1]; +} + +test('bearish chart alerts reconcile every loaded closed-bar window without silent truncation', () => { + const synchronizeBody = readFunctionBody('synchronizeBearishBollingerAlerts'); + assert.match(synchronizeBody, /reconcileBearishBollingerAlertWindow\(\{/); + assert.match(synchronizeBody, /lastProcessedClosedBarsWindowKey/); + assert.match(synchronizeBody, /lastProcessedSignals/); + assert.doesNotMatch(synchronizeBody, /lastProcessedClosedBarTime/); + assert.doesNotMatch(synchronizeBody, /\.slice\(-BEARISH_BOLLINGER_ALERT_MAX_MARKERS\)/); + assert.doesNotMatch(source, /BEARISH_BOLLINGER_ALERT_MAX_MARKERS/); + assert.doesNotMatch(source, /nextExportAtMs/); +}); + +test('Bollinger chart alert failures distinguish snapshot races from contract failures', () => { + const synchronizeBody = readFunctionBody('synchronizeBearishBollingerAlerts'); + const snapshotBranchStart = synchronizeBody.indexOf("failureKind === 'retry'"); + assert.ok(snapshotBranchStart >= 0); + assert.match(synchronizeBody, /applyBollingerAlertTaskFailure\(context, error\)/); + assert.match(synchronizeBody, /failureKind === 'retry'[\s\S]*等待下一次采样/); + assert.match(synchronizeBody, /context\.cleanupPending[\s\S]*context\.layer\.clear\(\)/); + assert.match(bollingerPatternSource, /context\.failed = true;/); + assert.match(bollingerPatternSource, /context\.cleanupPending = true;/); +}); diff --git a/test/unit/binance-ui-workflow.test.js b/test/unit/binance-ui-workflow.test.js index 0c58d78..e6674c2 100644 --- a/test/unit/binance-ui-workflow.test.js +++ b/test/unit/binance-ui-workflow.test.js @@ -12,6 +12,11 @@ test('Binance UI workflow gates the complete deterministic and live test toolcha 'test/unit/binance-*.test.js', 'test/unit/binance-orderbook-trade/**', 'test/dom/binance-orderbook-trade/**', + 'src/binance-strategy29-bollinger/**', + 'test/unit/binance-strategy29-bollinger/**', + 'test/dom/binance-strategy29-bollinger/**', + 'src/shared/chart-marker-save-controller.js', + 'src/shared/chart-mutation-owners.js', ]) { assert.ok(workflow.includes(`- "${pathPattern}"`), `Missing workflow path: ${pathPattern}`); } diff --git a/test/unit/userscript-metadata-icons.test.js b/test/unit/userscript-metadata-icons.test.js index b3de790..e52586d 100644 --- a/test/unit/userscript-metadata-icons.test.js +++ b/test/unit/userscript-metadata-icons.test.js @@ -3,6 +3,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; const iconSources = [ + 'src/binance-strategy29-bollinger/index.user.js', + 'scripts/binance-strategy29-bollinger.user.js', 'src/binance-orderbook-trade/index.user.js', 'scripts/binance-orderbook-trade.user.js', 'src/binance-trading-data/index.user.js', diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index a2097f0..dc33c2a 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -14,6 +14,26 @@ const source = await readFile(artifactPath, 'utf8'); const strategy27ArtifactPath = new URL('../../scripts/binance-strategy27-events.user.js', import.meta.url); const strategy27Source = await readFile(strategy27ArtifactPath, 'utf8'); +test('Strategy29 has an independent observation-only install identity', async () => { + const artifact = new URL('../../scripts/binance-strategy29-bollinger.user.js', import.meta.url); + const text = await readFile(artifact, 'utf8'); + const contract = createUserscriptReleaseContract(text, artifact.pathname); + const metadata = parseUserscriptMetadata(text); + assert.equal(contract.name, '【自写】Binance Strategy 29 布林带信号'); + assert.equal(contract.namespace, 'binance.strategy29.bollinger'); + assert.equal(contract.version, '0.1.0'); + assert.equal(contract.runAt, 'document-start'); + assert.equal(contract.updateURL, 'https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js'); + assert.equal(contract.downloadURL, contract.updateURL); + assert.deepEqual(metadata.get('grant'), ['none']); + assert.equal(metadata.has('connect'), false); + for (const forbidden of ['new WebSocket', 'fetch(', 'place-order', 'detectBollingerSignals']) { + if (forbidden === 'detectBollingerSignals') { + assert.equal(source.includes(forbidden), false, 'orderbook must not bundle the detector'); + } else assert.equal(text.includes(forbidden), false); + } +}); + test('release contract identifies the generated Binance orderbook artifact', () => { const contract = createUserscriptReleaseContract(source, artifactPath.pathname);