diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md index ccfe233..ad1c795 100644 --- a/docs/binance-orderbook-trade-development.md +++ b/docs/binance-orderbook-trade-development.md @@ -20,6 +20,36 @@ scripts/binance-orderbook-trade.user.js ## Runtime +### Chart save completion and serialization + +`core/chart-save-coalescer.js` batches the observed default +`saveChart(callback)` calls during native order-drawing bursts. A completed +batch serializes the current chart once and delivers a separate JSON snapshot +to every accepted callback, including repeated calls using the same function. +Never replace the callback queue with only its last element: unrelated callers +may depend on their own completion to continue. + +Explicit options, extra arguments and non-native receivers retain the original +method's synchronous return and error behavior. Queued batches are detached +before delivery; callbacks may reenter without overwriting the current batch. +One callback failure does not suppress later callbacks and is reported at the +asynchronous callback-error boundary. Stop, action failure, timeout and an +external wrapper replacing the method still deliver accepted calls while +preserving method ownership. A retained reference to a retired wrapper forwards +new calls to its original method instead of creating an orphan queue. + +These are serialization and callback guarantees, not database commit guarantees. +If each callback writes IndexedDB, it still performs its own write. Tests must +model `saveChart(callback, options)` rather than passing a snapshot as the first +argument. Browser fixtures report serialization, callback delivery and native +save requests separately, using request IDs to verify exactly-once delivery. + +Strategy27/29/31 transient annotations use the shared SVG overlay and never +enter the native drawing/save pipeline. Native order lines and manual drawings +continue to use the host chart APIs. Live verification must measure new calls +within a defined observation window; accumulated counters alone cannot establish +causality for an earlier browser database stall. + Use the repository Node version: ```bash diff --git a/docs/binance-strategy27-events-development.md b/docs/binance-strategy27-events-development.md index 3f310d5..83b8929 100644 --- a/docs/binance-strategy27-events-development.md +++ b/docs/binance-strategy27-events-development.md @@ -14,7 +14,7 @@ V10 live projection. The VPS remains the only market-data and event-analysis authority. The userscript opens no Binance market-data WebSocket, uses no Binance API key, and does not recalculate the four force groups. -Version 0.6.7 retains this installation's private gateway configuration and +Version 0.6.8 retains this installation's private gateway configuration and provides a shared read-only transport. Strategy29 owns its own summary panel, lifecycle and panel position. The existing `strategy27GatewayOrigin` and `strategy27GatewayAuthSecret` storage keys remain the single credential source; @@ -32,7 +32,7 @@ select another origin, set request headers or send a write. Future strategy routes require an explicit allowlist extension and contract review. The script reads an authenticated, loopback-only long-poll endpoint through an -SSH local forward. It draws transient entities only when the Binance route, +SSH local forward. It renders independent SVG overlays only when the Binance route, TradingView symbol, and `1S` chart interval all match the requested Strategy 27 symbol. @@ -70,8 +70,7 @@ use the reset/bootstrap protocol while retaining verified display history. Other JSON, cursor violations, and rendering contract failures stop immediately. A terminal ordinary-job failure suspends drawing and polling without deleting -previously verified markers or panel history. Pending candle waits and late -creations/repairs lose presentation ownership; late entities are removed. +previously verified markers or panel history. Pending candle waits lose presentation ownership and cannot publish late markers. Retained history is frozen evidence, not a live connection, and the visible error remains until explicit recovery or a context change. The existing context timer continues two-hour retention pruning but does not repair a suspended @@ -90,7 +89,8 @@ Manual clear remains available and does not dismiss a terminal error. missing timestamp to the nearest available bar. Directional markers use that candle for placement instead of the event response midpoint: an up arrow sits eight screen pixels below the candle low, and a down arrow sits eight screen - pixels above the candle high. Rendering waits on TradingView's `dataUpdated` + pixels above the candle high at creation. The resolved price stays immutable + when the chart is subsequently zoomed. Rendering waits on TradingView's `dataUpdated` event for at most three seconds. Order-book events can occur during a second with no trades, in which case Binance never publishes an exact one-second candle; after the wait, the marker is anchored to the latest prior candle so @@ -111,10 +111,12 @@ Manual clear remains available and does not dismiss a terminal error. - Incomplete or input-gap facts are marked as incomplete and carry no directional conclusion. -Every created entity uses the second obtained by flooring the projection's -`event_time_ms`. The script reads the entity point back after each create or -update. If TradingView shifts the point to a different bar, the entity is -removed and visualization stops with an alignment error. +Markers retain the resolved causal candle time and price. The shared SVG renderer +checks each timestamp against the loaded series before converting time and price +to CSS-pixel coordinates. A missing candle is hidden, never snapped to another +bar by the renderer. Strategy27 alone owns the three-second exact/prior-candle +placement policy; Strategy29 and Strategy31 retain their existing exact-time +projection rules. `triggered_at_ms` is the trigger bucket's start boundary. The browser requires `trigger_snapshot.bucket_start_ms` to equal it; the bucket end remains the @@ -125,9 +127,9 @@ before closure. `event_closed.event_time_ms` carries `active_end_at_ms` and can be later than that snapshot's end when an ineligible bucket advances the event to its lifecycle deadline without joining the event. -The script stores only its own returned marker IDs and its bounded in-memory +The script stores only its own SVG marker records and bounded in-memory panel records. Route, symbol, interval and manual clear remove only those -transient entities. Ordinary stream epochs and cursor resets reset protocol +overlay elements. Ordinary stream epochs and cursor resets reset protocol validation while preserving verified display history in the same chart context. The independent ordinary display registry retains at most 80 events (including neutral events), evicts by last observation time with event ID as the tie-break, @@ -145,33 +147,45 @@ ordinary job with an error while retaining its already verified history. Marker count and age are bounded on the chart; the panel retains at most eight events. -The existing one-second context check also reconciles retained records with -TradingView's `getAllShapes()` list. A host-evicted ordinary marker is restored -using its original resolved point and drawing options, even when no new gateway -message arrives. Compound candidates restore only missing parts of their -icon/label pair, preserving the original slot and surviving entity IDs. Each -record shares one in-flight repair across timer and message callbacks. Cleanup -skips IDs proven absent, while native removal failures still stop the owning job. -Manual clear, context changes and display retention eviction invalidate owned -repairs; terminal job failures suspend new presentation and repair; -late-created entities are removed instead of resurrecting retired records. -Reconciliation does not refresh retention timestamps. Drawings remain transient -and use `disableSave: true`, but a full page reload requests a bounded display -snapshot from the gateway before long polling and rebuilds retained ordinary and -compound records. The snapshot and its continuation cursor are committed with the -same Redis operation, so live messages after that cursor cannot be skipped. A -panel history reset is a separate lifecycle event, not evidence of native entity -eviction. +The ordinary and compound adapters use the same +`src/shared/chart-marker-overlay.js` renderer as Strategy29 and Strategy31. +It accepts only resolved timestamps, prices and explicit presentation styles; +it owns no event, retention, placement or slot policy. Each instance owns a +separate main-pane SVG with `pointer-events: none`. Axis areas are excluded. +Time/price delegates, data updates and pane resize notifications coalesce into +one animation frame; hidden pages cancel pending frames. Interval and data +invalidation hide stale presentation immediately. The Strategy27 context subscribes +to interval changes before its first marker. Any interval notification invalidates +that context, cancels pending candle waits and requests, and causes the next poll +to create a new context even after a `1S → other → 1S` round trip. Context cleanup +unsubscribes that exact owner. Every frame checks the chart, +symbol, interval and projection objects. A replaced pane or price scale fails +closed and reports through the owning job's visible error path. + +Only visible markers receive nodes. Stable IDs reuse nodes, and unchanged +samples cause no DOM writes. The context timer can reconcile active retained +records without querying or modifying native drawings. Suspend rejects new +presentation and cancels pending candle waits while preserving verified records +and their viewport projection. Retention pruning and manual/context cleanup +remove only owned SVG nodes. Cleanup releases listeners, resize observers and +scheduled frames; it does not wait for native drawing owners. No Strategy27 +marker calls `createShape`, `removeEntity`, `saveChart`, or wraps chart saving. +Foreign drawings and the orderbook's native drawing/save controller are separate. + +A full page reload still requests a bounded display snapshot before long +polling. The snapshot and its continuation cursor are committed with the same +Redis operation, so live messages after that cursor cannot be skipped. A panel +history reset remains a separate lifecycle event. ## Compound Candidate Extension ADR 032 in CorsairQuant owns the server-side rule and transport contract. The browser does not reconstruct candidates from ordinary events or recalculate -market evidence. The client, lifecycle, panel, native chart layer and optional-job +market evidence. The client, lifecycle, panel, SVG chart layer and optional-job controller are wired into the entrypoint and tested together. The source and -generated install artifact are version 0.6.7 with identical metadata headers. +generated install artifact are version 0.6.8 with identical metadata headers. The generated artifact passes syntax, release-contract and isolated execution -checks, including candidate delivery, paired entities, clear and context stop. +checks, including candidate delivery, paired SVG markers, clear and context stop. Binance operator-page validation remains outstanding. Server/gateway rollout must precede browser publication. Initial compound publication history is recorded in [CorsairQuant PR 324](https://github.com/jackhai9/CorsairQuant/pull/324) and @@ -222,35 +236,34 @@ Do not treat source unit tests or the panel fixture as deployment evidence. boundary. It constructs its chart layer only on the first accepted candidate, so a missing compound chart capability cannot fail ordinary startup. A stream state accepts the new epoch/sequence without clearing verified candidates. - Gateway resets, unavailability and bootstrap also preserve their native entity + Gateway resets, unavailability and bootstrap also preserve their SVG record IDs, slots and panel history, including candidates absent from a newer snapshot. Manual clear preserves replay bookkeeping but invalidates pending presentation. Age eviction also invalidates a pending draw, and a second age check runs after drawing before publication to the panel. The existing context timer calls - `reconcile()`, which prunes before repairing missing entities; there is no + `reconcile()`, which prunes before projecting retained records; there is no second timer. Route/interval changes and disappearance of the visible chart stop both clients before destroying the shared panel. The clear menu clears both views without restarting either client. -- Terminal protocol/render/repair failures stop only the compound job and freeze - verified pairs and panel records with an explicit error and reconnect guidance. - Pending candle waits and late creates/repairs lose presentation ownership; - incomplete pairs are removed, while surviving verified entities remain. - The existing context timer continues decision-time age pruning after failure, - and manual clear and context retirement remain effective. Native cleanup - attempts every retired entity once and aggregates failures without retrying an - unknown removal. Cleanup errors do not interrupt ordinary shutdown or discard - unrelated retained history. An asynchronous drawing failure after context - retirement is retained as `lastError`, without writing into a retired panel. -- Each candidate owns a 36-pixel native icon arrow and a short text label: +- Terminal protocol or rendering failures stop only the compound job with an + explicit error and reconnect guidance. Previously verified records and panel + history remain retained; pending candle waits cannot publish new pairs. + The context timer continues decision-time age pruning after failure, and + manual clear and context retirement remain effective. A coordinate/projection + failure removes the invalid SVG before reporting the error. It does not mutate + ordinary or user drawings. +- Each candidate owns a 36-pixel SVG arrow and a 12-pixel bold text label: dark red down/`候选高` above the candle, dark green up/`候选低` below it. - Annotation direction remains `arrow_down`/`arrow_up`; native drawing options - use `shape: icon` and supported arrow icons. The first icon center is 26 pixels - from the candle edge (18-pixel half-size plus an eight-pixel gap). - Candidates sharing a resolved candle and direction receive independent slots - 64 pixels apart. Removing one does not move surviving candidates. The slot - key uses the actual prior candle when multiple no-trade seconds resolve there. - The budget is 80 compound candidates / 160 native entities, plus 80 ordinary - markers: at most 240 owned chart entities in total. + English locale shows `High candidate` / `Low candidate`. The first icon center + is 26 pixels from the candle edge at creation (18-pixel half-size plus an + eight-pixel gap). Candidates sharing a resolved candle and direction receive + independent slots 64 pixels apart at creation. All offsets are converted to + prices once, preserving the previous placement semantics on later zooms. + Removing one candidate does not move surviving candidates; a new candidate + can reuse the free slot. The slot key uses the actual prior candle when + multiple no-trade seconds resolve there. + The budget is 80 compound candidates / 160 SVG elements, plus 80 ordinary + markers: at most 240 owned overlay elements, with zero native entities. - Compound detail shows the actual background, seed, confirmation and optional reinforcement evidence windows. Complete IDs are selectable inside a collapsed details section. Low candidates disclose the unvalidated mirror @@ -266,18 +279,13 @@ node test/manual/strategy27-compound-panel-preview.mjs This uses a disposable headless Chromium page without a dev server or access to the operator's browser/account. It verifies both directions, eight-row retention, selection, collapse, status and viewport bounds, and prints the -temporary screenshot directory. It does not verify native TradingView entities, +temporary screenshot directory. It does not verify live TradingView overlay coordinates, gateway connectivity, loaded userscript source, or prediction accuracy. -`test/manual/strategy27-native-drawing-probe.mjs` loads the actual compound chart -module in a disposable official TradingView demo. It draws two independent -candidates on each side, reads back their points/properties, captures the native -render after drawing resources finish loading, and verifies that cleanup removes -all eight owned entities while preserving baseline drawings. Visual inspection -confirms the arrows and short labels, including distinct same-candle slots. -This demo runs at its own one-hour resolution; it does not establish Binance -`1S` compatibility or loaded userscript identity. It uses public market data and -is never an authenticated operator page. +The historical `test/manual/strategy27-native-drawing-probe.mjs` describes the +retired native-drawing implementation. Its earlier demo evidence does not +validate SVG overlays or current Binance compatibility. Current validation must +exercise the shared renderer and inspect the actual main-pane SVG output. ## Development @@ -293,7 +301,7 @@ git diff --check ``` Live validation must confirm the current Binance main-world chart API, exact -`1S` resolution, exact route symbol, successful create/readback/remove behavior, +`1S` resolution, exact route symbol, exact candle placement, SVG geometry and zero native drawing/save calls, Tampermonkey source readback, and the actually loaded source after a hard reload. A source-level method name alone is not end-to-end proof. @@ -345,8 +353,8 @@ A language-only route change repaints the same panel and preserves selection, collapse state, monitoring evidence, both requests/cursors, history retention and marker IDs. Bilingual annotation copies retain the first directional candidate presentation in both languages. Compound labels update only owned -text properties with saving defaults disabled, including labels that finish -creation or repair after a language change. Changing symbol/chart/interval and +SVG text content, including labels whose candle wait completes after a language +change. No chart-save defaults are read or written. Changing symbol/chart/interval and explicit reconnect still use their existing context lifecycle. The menu uses Tampermonkey 5's returned registration IDs to update its four diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index bd04219..434f322 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -13,14 +13,14 @@ already-loaded native chart candles. The summary reads the authenticated unified loopback gateway; it does not call Binance market-data or account APIs, submit orders, rotate hidden charts, or add remote events as chart drawings. -Install Strategy29 0.5.7 with orderbook 2.7.199 or later, or use it alone. +Install Strategy29 0.5.8 with orderbook 2.7.199 or later, or use it alone. Install CorsairQuant signal client 0.6.7 for the remote summary. Do not combine it with the embedded observer in orderbook 2.7.198. After updating/disabling the old script, reload the page. An embedded observer is an explicit conflict: Strategy29 stops and displays an upgrade/reload notice. -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. +If the old script loads later, the observer stops and clears its own overlay. +This is not a supported compatibility mode; Strategy29 never removes old-script +or user drawings. The orderbook runs in page context. Strategy29 runs in a Tampermonkey sandbox with read access to its previous non-sensitive preferences, and passes @@ -30,16 +30,17 @@ 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 +this boundary. The orderbook retains its native save-controller protocol. Strategy27, +Strategy29 and Strategy31 do not install or arm that controller; their SVG presentation never +enters the native drawing model. The ownership record validates protocol version 1 +and rejects incompatible versions. These are coordination contracts between trusted scripts, not a security boundary against page code. The standalone entry has a per-page singleton on `unsafeWindow`. One poll discovers charts/routes and evaluates the existing monitor. Hidden documents and BFCache pagehide pause it; visibility/pageshow resumes it. Permanent disposal removes its listeners, aborts an in-flight summary request, and invalidates -pending drawing work. Non-trading routes perform no candle exports or gateway +pending overlay work. Non-trading routes perform no candle exports or gateway requests. The read-only diagnostics retain one `lastLocalFailure` after local fatal cleanup or stop. It records the export/reconcile/detect/render stage, thrown value type, @@ -52,14 +53,14 @@ monitor failure with a property-read failure. If a host Proxy throws during rejection classification, the monitor stops that context and records `classificationFailed: true`; normal fatal errors record false. The frozen detector/core source and its direct invalid-context assertion remain unchanged. -Time-alignment failure messages include both the requested and native returned -timestamp. A later healthy context does not clear that historical evidence or -establish why the native chart shifted the earlier marker. +A later healthy context does not clear historical failure evidence. Missing +exact candle timestamps are excluded by the overlay projection rather than +snapped to another candle. It describes the last fatal event, not necessarily the active context. It does not store stack traces, candles, requests or gateway credentials, and is never persisted or sent to the remote service. Recoverable snapshot races leave it unchanged. -Independent instances of each bundle share the same controller regardless of -load order. Strategy27 does not participate in this protocol. +Each overlay instance owns its DOM and exact event subscriptions independently. +Strategy27 uses the same SVG renderer with its own event and compound placement policies. ## Panel Language and Position @@ -232,39 +233,35 @@ does not claim hash-level remote parity. ## Bidirectional Bollinger Alerts -The chart alert is timeframe-agnostic and evaluates closed bars only. It supports TradingView second, minute, hour, day, and week resolutions; month resolutions are intentionally unsupported because their duration is calendar-dependent. It scans every closed bar already loaded in TradingView and does not issue a separate market-data request or force the chart to load older history. The active-page poll exports the current window once per second. Detection runs again when the `count:firstTime:lastTime` window or any closed-bar OHLC content changes; unchanged content is compared against a compact numeric snapshot instead of serializing the whole history on every poll. The cached signal set is reconciled with live TradingView shape IDs on every poll. Loading older chart history therefore expands the annotated range even when the latest bar is unchanged, and a TradingView chart refresh can no longer leave the alert registry pointing at evicted markers. +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 projected onto a script-owned SVG layer. Loading older chart history expands the annotated range even when the latest bar is unchanged. Viewport changes reproject visible markers independently of the detector 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. +`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 scheduled overlay redraw 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 and remove only the script-owned overlay. 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. +Indicator calculation traverses each fixed window directly without allocating sliced/mapped close arrays for every bar. Summation order and detector thresholds remain unchanged. -`window.__TM_STRATEGY29_DEBUG__.diagnostics` is an on-demand diagnostic snapshot of timer/task presence, context/session readiness, cached/rendered signal counts, the aggregate boolean drawing-mutation state, and non-sensitive remote state. It contains no gateway secret, authorization header, request payload, or order details; it does not export candles or audit drawings and adds no periodic work. Native model/data readiness is reported separately from session readiness so a waiting session is not mistaken for expensive calculation or a zero-signal window. +Markers use a script-owned SVG inside the main-series pane's canvas container. The overlay is clipped to that pane and has `pointer-events: none`; it does not cover the price/time axes or intercept drawing interactions. Each direction allows 1,000 signals, for 2,000 total. Validation rejects an over-limit or malformed snapshot before publishing it. Offscreen signals remain in the bounded signal set but create no SVG nodes. Keyed nodes are reused, and unchanged attributes are not rewritten. -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. +Projection uses the inspected TradingView model: exact `timePointToIndex(time, 0)`, candle timestamp readback, `indexToCoordinate`, and the main series' `priceToCoordinate(price, firstValue)`. Missing candle times are omitted rather than snapped to adjacent bars. Scale conversion belongs to TradingView, including logarithmic and inverted axes. The current model, series, scale and pane identities are checked before drawing; a changed projection contract stops the context instead of guessing coordinates. -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. +Time-range, bar-spacing, offset, price-range/mode/height, candle-data and pane-resize events coalesce into one animation frame. There is no continuous animation loop or added market-data request. Interval invalidation immediately hides the old layer, even while an orderbook owner is busy. Hiding the document cancels pending redraw work; clear removes the exact instance's SVG, listeners, observer and frame. Cleanup does not need native drawing ownership because it never touches native entities. Rendering still respects the existing orderbook mutation gate. Asynchronous redraw errors clear presentation and enter the existing monitor failure diagnostics or Strategy31 stopped notice. -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. +`window.__TM_STRATEGY29_DEBUG__.diagnostics` exposes timer/task presence, readiness, cached/rendered counts, the aggregate mutation gate and non-sensitive remote state. `markerOverlayStats` reports attached state, signal/visible counts, rendered frames, subscriptions and a pending frame. These are rendering counters, not successful database-save counts. Diagnostics contain no secrets, requests, order details or persisted candles and add no periodic work. -### Marker Save Bursts +### Why overlays do not use native drawings -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. +Binance's observed integration schedules `widget.save` 100 ms after non-click/non-move drawing events. `disableSave` excludes temporary entities from saved JSON but does not suppress the events. The previous marker controller coalesced serialization while preserving every callback: a 2026-10-05 snapshot showed 2,000 save requests/callbacks and 102 serializations. Those counters did not establish that asynchronous database writes had completed. -`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. +Strategy27, Strategy29 and Strategy31 therefore create, update and remove no native drawings and do not wrap `saveChart` or IndexedDB. The shared native save controller remains available to the orderbook. Its serialization coalescer delivers every accepted save callback with an independent snapshot; it does not merge downstream database writes. Manual-drawing persistence remains owned by Binance. -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. +During the same incident, `chart_futures` database opening succeeded but readonly requests remained pending; `chart_spot` reads completed. Disabling scripts and reloading OPN alone did not recover it. Reloading the remaining old US page allowed OPN to recover without another refresh, browser restart or database deletion. This identifies the chart configuration read as the immediate blocker and demonstrates a lifecycle relationship, but does not prove a particular script held a lock or was the sole underlying browser fault. Removing marker-generated save traffic addresses the independently verified write amplification. Do not describe this as proof that all possible IndexedDB hangs are fixed. ## Verification @@ -279,9 +276,10 @@ orders. The sandbox entry test proves that Strategy29 installs its singleton on the page `unsafeWindow`. Remote contract/controller tests cover strict symbol round trips, bounded cursors, spec mismatch, 409 reset, stale response ownership, and failure isolation. Independent bundle tests cover controller identity, active -owners, save drain, protocol conflicts and unregister. Existing marker tests cover -interval switches, late results, historical coverage, foreign drawings and -cooperative rendering. +owners, save drain, protocol conflicts and unregister. Marker tests cover exact candle placement, historical coverage, scale changes, +interval invalidation, hidden-page cleanup, independent instances and zero native +drawing/save calls. Browser tests exercise actual SVG rendering and isolated +cross-page IndexedDB readback; these are not production database tests. Before a separately authorized installation/release, inspect both real installed sources, reload once, and verify both load orders, chart interval switches, diff --git a/docs/binance-strategy31-volume-reversal-development.md b/docs/binance-strategy31-volume-reversal-development.md index 5121783..a76c18a 100644 --- a/docs/binance-strategy31-volume-reversal-development.md +++ b/docs/binance-strategy31-volume-reversal-development.md @@ -1,7 +1,7 @@ # Strategy31 volume reversal signals Source: `src/binance-strategy31-volume-reversal/`. Installer: -`scripts/binance-strategy31-volume-reversal.user.js` (0.1.1). +`scripts/binance-strategy31-volume-reversal.user.js` (0.1.2). Install alongside CorsairQuant signal client 0.6.7, which owns private gateway authentication. The shared bridge explicitly advertises the `strategy31` capability. @@ -28,9 +28,13 @@ waits for the next sample; malformed signal contracts still stop the observer. Loading earlier chart history makes matching retained server events eligible for rendering. -The client uses the existing native marker layer, exact interval visibility, -readback and drawing-save coordination. The green arrow anchors at the green -candle's open time and low. No exchange/account API or trading action is called. +The client shares Strategy29's script-owned SVG marker layer. Exact candle-time +readback and native time/price coordinate conversion keep each green arrow +anchored at the green candle's open time and low. Viewport events redraw the layer; +interval invalidation hides it immediately. Marker changes do not create native +drawings, emit drawing events or request chart saves. No exchange/account API or +trading action is called. See the Strategy29 development manual for the shared +projection and lifecycle contract. The projection is recent retained history, not all visible chart history. The shared capability permits only `/v1/strategy31/events` with exactly `symbol`, @@ -44,6 +48,6 @@ coordinates or numeric values stop this observer with a visible status. Build with `npm run build:binance-strategy31-volume-reversal`; validate with `npm run lint:tests`, `npm test`, `npm run check:binance-userscripts` and the -repository's browser suite. DOM integration tests use the shared native host +repository's browser suite. DOM integration tests use the shared chart-coordinate host fixture, exercising actual marker rendering and stale-response rejection. Fixture results do not establish current Binance rendering or installed code. diff --git a/e2e/binance-orderbook/fixtures/binance-futures.js b/e2e/binance-orderbook/fixtures/binance-futures.js index eb5ae1b..18aec75 100644 --- a/e2e/binance-orderbook/fixtures/binance-futures.js +++ b/e2e/binance-orderbook/fixtures/binance-futures.js @@ -555,13 +555,21 @@ export function renderBinanceFuturesFixture(scenario) { const chartOrdersTrigger = document.querySelector('[data-testid="chart-orders-trigger"]'); const chartRoot = document.querySelector('.chart-widget-root'); const chartEventListeners = new Map(); - const chartOrderDrawings = new Map(currentOrders().map(order => [ + const chartOrderDrawings = new Map((state.showOrders ? currentOrders() : []).map(order => [ 'order-' + order.id, { toolname: 'LineToolOrder', symbol: order.symbol }, ])); + function readChartSnapshot() { + return { checked: state.showOrders, drawingIds: [...chartOrderDrawings.keys()] }; + } const tradingViewApi = { - saveChart(snapshot) { - record('chart-saved', { snapshot }); + saveChart(callback, options) { + if (typeof callback !== 'function') throw new TypeError('Native chart save requires a callback'); + const snapshot = options?.includeDrawings === false + ? { checked: state.showOrders, drawingIds: [] } + : readChartSnapshot(); + record('chart-serialized', { snapshot: JSON.parse(JSON.stringify(snapshot)) }); + return callback(snapshot); }, subscribe(eventName, listener) { const listeners = chartEventListeners.get(eventName) || new Set(); @@ -589,14 +597,19 @@ export function renderBinanceFuturesFixture(scenario) { }); } + let chartSaveRequestSequence = 0; + function requestChartSave(details) { + const requestId = ++chartSaveRequestSequence; + record('chart-save-requested', { ...details, requestId, snapshot: readChartSnapshot() }); + tradingViewApi.saveChart(snapshot => record('chart-saved', { requestId, snapshot })); + } + /** Native broker events request a complete chart serialization 100 ms later. */ function publishNativeOrderDrawingEvent(drawingId, eventType) { record('chart-drawing-event', { drawingId, eventType, toolname: 'LineToolOrder' }); tradingViewApi.emit('drawing_event', drawingId, eventType); setTimeout(() => { - const snapshot = { checked: state.showOrders, drawingIds: [...chartOrderDrawings.keys()] }; - record('chart-save-requested', { drawingId, eventType, snapshot }); - tradingViewApi.saveChart(snapshot); + requestChartSave({ drawingId, eventType }); }, 100); } @@ -612,24 +625,15 @@ export function renderBinanceFuturesFixture(scenario) { if (!state.showOrders) return; orders.forEach((order, index) => { setTimeout(() => { + const drawingId = 'order-' + order.id; + // Off-chart order cancellations have no visible drawing to remove. + if (!chartOrderDrawings.delete(drawingId)) return; if (scenario.host.orderDrawingEvents) { - const drawingId = 'order-' + order.id; - // Off-chart order cancellations have no visible drawing to remove. - if (!chartOrderDrawings.delete(drawingId)) return; publishNativeOrderDrawingEvent(drawingId, 'remove'); return; } tradingViewApi.emit('drawing_event', 'order-' + order.id, 'remove'); - record('chart-save-requested', { - checked: true, - index, - orderId: order.id, - }); - tradingViewApi.saveChart({ - checked: true, - drawingCount: Math.max(0, orders.length - index - 1), - finalOrderId: order.id, - }); + requestChartSave({ checked: true, index, orderId: order.id }); }, scenario.host.mutationDelayMs + (index * 2)); }); } @@ -647,21 +651,15 @@ export function renderBinanceFuturesFixture(scenario) { record('chart-orders-checked', { value: nextChecked, drawings: drawings.length }); drawings.forEach((order, index) => { setTimeout(() => { + const drawingId = 'order-' + order.id; + if (nextChecked) chartOrderDrawings.set(drawingId, { toolname: 'LineToolOrder', symbol: order.symbol }); + else chartOrderDrawings.delete(drawingId); tradingViewApi.emit( 'drawing_event', 'order-' + order.id, nextChecked ? 'properties_changed' : 'remove', ); - record('chart-save-requested', { - checked: nextChecked, - index, - orderId: order.id, - }); - tradingViewApi.saveChart({ - checked: nextChecked, - drawingCount: drawings.length, - finalOrderId: order.id, - }); + requestChartSave({ checked: nextChecked, index, orderId: order.id }); }, scenario.host.mutationDelayMs + (index * 2)); }); }); diff --git a/e2e/binance-orderbook/helpers/marker-overlay-host.js b/e2e/binance-orderbook/helpers/marker-overlay-host.js new file mode 100644 index 0000000..5cd6a34 --- /dev/null +++ b/e2e/binance-orderbook/helpers/marker-overlay-host.js @@ -0,0 +1,78 @@ +/** Models the inspected coordinate/delegate boundary without entering native drawings. */ +export function installMarkerOverlayHost({ document, chart, rows }) { + const pane = document.createElement('div'); + pane.className = 'chart-gui-wrapper'; + pane.style.cssText = 'position:relative;width:900px;height:500px;background:#181a20'; + const canvas = document.createElement('canvas'); + canvas.width = 900; + canvas.height = 500; + pane.append(canvas); + document.body.append(pane); + const events = {}; + for (const name of ['logicalRangeChanged', 'barSpacingChanged', 'rightOffsetChanged', 'priceRangeChanged', 'modeChanged', 'internalHeightChanged', 'dataUpdated']) { + const listeners = new Map(); + events[name] = { + subscribe(owner, callback) { listeners.set(callback, owner); }, + unsubscribe(owner, callback) { + if (listeners.get(callback) !== owner) throw new Error('Subscription owner mismatch'); + listeners.delete(callback); + }, + emit() { for (const callback of listeners.keys()) callback(); }, + }; + } + let offset = 0; + let spacing = 2; + let inset = 10; + const time = { + timePointToIndex(timestamp, mode) { + if (mode !== 0 && mode !== 1) throw new Error('Expected exact or prior timestamp lookup'); + const index = mode === 0 ? rows.findIndex(row => row[0] === timestamp) + : rows.findLastIndex(row => row[0] <= timestamp); + return index < 0 ? null : index; + }, + indexToCoordinate: index => index * spacing + inset + offset, + logicalRangeChanged: () => events.logicalRangeChanged, + barSpacingChanged: () => events.barSpacingChanged, + rightOffsetChanged: () => events.rightOffsetChanged, + }; + const price = { + priceToCoordinate: value => 400 - value * 2, + coordinateToPrice: value => (400 - value) / 2, + priceRangeChanged: () => events.priceRangeChanged, + modeChanged: () => events.modeChanged, + internalHeightChanged: () => events.internalHeightChanged, + }; + const series = { priceScale: () => price, firstValue: () => 100, dataUpdated: () => events.dataUpdated }; + const paneState = {}; + const model = { mainSeries: () => series, timeScale: () => time, paneForSource: () => paneState }; + const paneWidget = { canvasElement: () => canvas }; + chart._chartWidget = { model: () => ({ model: () => model }), paneByState: () => paneWidget }; + chart.getSeries = () => ({ data: () => ({ valueAt: index => rows[index] || null }) }); + function paintCandles() { + const context = canvas.getContext('2d'); + context.clearRect(0, 0, canvas.width, canvas.height); + context.strokeStyle = '#30343d'; + for (const y of [100, 200, 300, 400]) { + context.beginPath(); context.moveTo(0, y); context.lineTo(900, y); context.stroke(); + } + for (const [index, row] of rows.entries()) { + const x = time.indexToCoordinate(index); + if (x < 0 || x > canvas.width) continue; + context.strokeStyle = '#a4aab6'; + context.beginPath(); context.moveTo(x, price.priceToCoordinate(row[2])); + context.lineTo(x, price.priceToCoordinate(row[3])); context.stroke(); + context.fillStyle = '#6c7485'; + const top = price.priceToCoordinate(Math.max(row[1], row[4])); + const bottom = price.priceToCoordinate(Math.min(row[1], row[4])); + context.fillRect(x - 3, top, 6, Math.max(1, bottom - top)); + } + } + paintCandles(); + return { pane, events, + pan(value) { offset = value; paintCandles(); events.logicalRangeChanged.emit(); }, + setSpacing(value, left) { + if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(left)) throw new Error('Invalid fixture spacing'); + spacing = value; inset = left; paintCandles(); events.barSpacingChanged.emit(); + }, + }; +} diff --git a/e2e/binance-orderbook/specs/cancel-boundaries-behavior.pw.js b/e2e/binance-orderbook/specs/cancel-boundaries-behavior.pw.js index 7b3c897..d241fa0 100644 --- a/e2e/binance-orderbook/specs/cancel-boundaries-behavior.pw.js +++ b/e2e/binance-orderbook/specs/cancel-boundaries-behavior.pw.js @@ -31,6 +31,7 @@ function expectOrdersUntouched(state, scenario) { expect(state.events.filter(({ type }) => [ 'chart-orders-checked', 'chart-save-requested', + 'chart-serialized', 'chart-saved', ].includes(type))).toEqual([]); expect(state.showOrders).toBe(scenario.ui.showOrders); diff --git a/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js b/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js index 701cbb2..f7cc53d 100644 --- a/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js +++ b/e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js @@ -93,8 +93,11 @@ for (const entry of CANCEL_COVERING_SCENARIOS) { expectedChartSaveRequestCount, ); expect(state.events.filter((event) => event.type === 'chart-saved')).toHaveLength( - expectsChartRemovalSave ? 1 : 0, + expectedChartSaveRequestCount, ); + expect(state.events.filter((event) => event.type === 'chart-serialized')).toHaveLength(expectsChartRemovalSave ? 1 : 0); + expect(state.events.filter((event) => event.type === 'chart-saved').map(({ requestId }) => requestId)) + .toEqual(state.events.filter((event) => event.type === 'chart-save-requested').map(({ requestId }) => requestId)); expect(errors).toEqual([]); }); } diff --git a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js index d6589f0..d94b877 100644 --- a/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js +++ b/e2e/binance-orderbook/specs/cancel-current-symbol.pw.js @@ -139,7 +139,7 @@ test('user cancels seventy orders while chart drawings stay visible and save onc await expect(page.getByRole('dialog')).toBeVisible(); await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(true); await page.getByRole('button', { name: '确认' }).click(); - // Then all seventy orders disappear and their drawing removals produce one final chart save. + // Then all seventy orders disappear and their drawing removals share one serialization across seventy callbacks. await expect(page.getByText('撤单已完成')).toBeVisible(); const state = await readFixtureState(page); @@ -147,7 +147,10 @@ test('user cancels seventy orders while chart drawings stay visible and save onc expect( state.events.filter((event) => event.type === 'chart-save-requested'), ).toHaveLength(70); - expect(state.events.filter((event) => event.type === 'chart-saved')).toHaveLength(1); + expect(state.events.filter((event) => event.type === 'chart-saved')).toHaveLength(70); + expect(state.events.filter((event) => event.type === 'chart-serialized')).toHaveLength(1); + expect(state.events.filter((event) => event.type === 'chart-saved').map(({ requestId }) => requestId)) + .toEqual(state.events.filter((event) => event.type === 'chart-save-requested').map(({ requestId }) => requestId)); expect( state.events .filter((event) => event.type === 'chart-orders-checked') @@ -156,7 +159,7 @@ test('user cancels seventy orders while chart drawings stay visible and save onc const finalSaveRequestIndex = state.events.findLastIndex( (event) => event.type === 'chart-save-requested', ); - const fullSaveIndex = state.events.findIndex((event) => event.type === 'chart-saved'); + const fullSaveIndex = state.events.findIndex((event) => event.type === 'chart-serialized'); expect(fullSaveIndex).toBeGreaterThan(finalSaveRequestIndex); await expectRestoredState(page, scenario); const probe = await finishInteractionProbe(page); diff --git a/e2e/binance-orderbook/specs/continuous-chart-saves-behavior.pw.js b/e2e/binance-orderbook/specs/continuous-chart-saves-behavior.pw.js index f79c9fd..f3376dc 100644 --- a/e2e/binance-orderbook/specs/continuous-chart-saves-behavior.pw.js +++ b/e2e/binance-orderbook/specs/continuous-chart-saves-behavior.pw.js @@ -13,8 +13,19 @@ async function eventsOfType(page, type) { return (await readFixtureState(page)).events.filter(event => event.type === type); } -async function savedSnapshots(page) { - return (await eventsOfType(page, 'chart-saved')).map(({ snapshot }) => snapshot); +async function serializedSnapshots(page) { + return (await eventsOfType(page, 'chart-serialized')).map(({ snapshot }) => snapshot); +} + +async function expectAllSaveCallbacks(page) { + const requested = await eventsOfType(page, 'chart-save-requested'); + const delivered = await eventsOfType(page, 'chart-saved'); + expect(delivered.map(({ requestId }) => requestId)).toEqual(requested.map(({ requestId }) => requestId)); + let serializedSnapshot = null; + for (const event of (await readFixtureState(page)).events) { + if (event.type === 'chart-serialized') serializedSnapshot = event.snapshot; + if (event.type === 'chart-saved') expect(event.snapshot).toEqual(serializedSnapshot); + } } async function saveMethodIsNative(nativeSave) { @@ -81,11 +92,11 @@ async function openContinuousDrawingHost(page, { return { ...host, nativeSave }; } -test('user saves one complete chart per continuous round after every accepted order drawing settles', async ({ page }) => { +test('user serializes once and delivers every save callback per continuous round', async ({ page }) => { // Given the first native request is pending in a three-order continuous close round. const host = await openContinuousDrawingHost(page); expect(await saveMethodIsNative(host.nativeSave)).toBe(true); - expect(await savedSnapshots(page)).toEqual([]); + expect(await serializedSnapshots(page)).toEqual([]); // When the first accepted order creates its native drawing and approaches the 120 ms quiet deadline. await releaseAcceptedDrawing(page, host, 1); @@ -96,7 +107,7 @@ test('user saves one complete chart per continuous round after every accepted or expect((await eventsOfType(page, 'chart-save-requested')).map(({ snapshot }) => snapshot)).toEqual([ { checked: true, drawingIds: ['order-submitted-1'] }, ]); - expect(await savedSnapshots(page)).toEqual([]); + expect(await serializedSnapshots(page)).toEqual([]); await expect(page.locator(STATUS)).toHaveText('连续阶梯平空 · 第 1 笔确认中 · 0/1 轮 · 本轮 0/3 笔 · 累计 0 笔'); // When the first two drawing bursts finish and the third native order is submitted. @@ -108,7 +119,7 @@ test('user saves one complete chart per continuous round after every accepted or await advanceToSubmission(page, host, 3); // Then no partial full-chart save escaped before the round's final order. - expect(await savedSnapshots(page)).toEqual([]); + expect(await serializedSnapshots(page)).toEqual([]); expect((await eventsOfType(page, 'chart-save-requested')).map(({ snapshot }) => snapshot)).toEqual([ { checked: true, drawingIds: ['order-submitted-1'] }, { checked: true, drawingIds: ['order-submitted-1', 'order-submitted-2'] }, @@ -118,12 +129,12 @@ test('user saves one complete chart per continuous round after every accepted or // When the last order is accepted and its own quiet period completes. await releaseAcceptedDrawing(page, host, 3); await advanceFromDrawing(page, 'order-submitted-3', 'create', 119); - expect(await savedSnapshots(page)).toEqual([]); + expect(await serializedSnapshots(page)).toEqual([]); await page.clock.runFor(1); - // Then the round saves only its final three-drawing snapshot and restores the native save method. + // Then one serialization supplies the final three-drawing snapshot to every callback and restores the native save method. await expect(page.locator(STATUS)).toHaveText('连续阶梯平空 · 1s 后继续 · 1/1 轮 · 本轮 3/3 笔 · 累计 3 笔'); - expect(await savedSnapshots(page)).toEqual([ + expect(await serializedSnapshots(page)).toEqual([ { checked: true, drawingIds: ['order-submitted-1', 'order-submitted-2', 'order-submitted-3'] }, ]); expect(await eventsOfType(page, 'chart-save-requested')).toHaveLength(3); @@ -133,11 +144,12 @@ test('user saves one complete chart per continuous round after every accepted or await page.getByRole('button', { name: '停止平空', exact: true }).click(); await page.clock.runFor(5_000); - // Then the same saved snapshot remains final and no fourth order can begin. + // Then all three callbacks stay delivered without another serialization or a fourth order. await expect(page.locator(STATUS)).toContainText('已停止'); expect(await eventsOfType(page, 'order-submitted')).toHaveLength(3); - expect(await eventsOfType(page, 'chart-saved')).toHaveLength(1); + expect(await eventsOfType(page, 'chart-saved')).toHaveLength(3); expect(await saveMethodIsNative(host.nativeSave)).toBe(true); + await expectAllSaveCallbacks(page); await host.nativeSave.dispose(); expect(host.errors).toEqual([]); }); @@ -159,7 +171,7 @@ test('user stopping inside a drawing burst preserves the partial round and resto await advanceFromDrawing(page, 'order-submitted-2', 'create', 119); // Then Stop has not discarded the pending snapshot or falsely advanced the last confirmation. - expect(await savedSnapshots(page)).toEqual([]); + expect(await serializedSnapshots(page)).toEqual([]); expect(await saveMethodIsNative(host.nativeSave)).toBe(false); await expect(page.locator(STATUS)).toContainText('停止中'); await expect(page.locator(STATUS)).toContainText('本轮 1/3 笔 · 累计 1 笔'); @@ -167,9 +179,9 @@ test('user stopping inside a drawing burst preserves the partial round and resto // When the final millisecond completes the active drawing burst and stopped-round cleanup. await page.clock.runFor(1); - // Then exactly the confirmed partial round is persisted and the native save function is restored. + // Then the confirmed partial round is serialized once and the native save function is restored. await expect(page.locator(STATUS)).toHaveText('连续阶梯平空 · 已停止 · 0/1 轮 · 本轮 2/3 笔 · 累计 2 笔'); - expect(await savedSnapshots(page)).toEqual([ + expect(await serializedSnapshots(page)).toEqual([ { checked: true, drawingIds: ['order-original-1', 'order-original-2', 'order-submitted-1', 'order-submitted-2'] }, ]); expect(await saveMethodIsNative(host.nativeSave)).toBe(true); @@ -180,14 +192,15 @@ test('user stopping inside a drawing burst preserves the partial round and resto await advanceFromDrawing(page, 'order-original-1', 'remove', 100); // Then its native save runs at 100 ms without a leaked continuous listener retaining it until 120 ms. - expect(await savedSnapshots(page)).toEqual([ + expect(await serializedSnapshots(page)).toEqual([ { checked: true, drawingIds: ['order-original-1', 'order-original-2', 'order-submitted-1', 'order-submitted-2'] }, { checked: true, drawingIds: ['order-original-2', 'order-submitted-1', 'order-submitted-2'] }, ]); expect(await saveMethodIsNative(host.nativeSave)).toBe(true); await page.clock.runFor(5_000); expect(await eventsOfType(page, 'order-submitted')).toHaveLength(2); - expect(await eventsOfType(page, 'chart-saved')).toHaveLength(2); + expect(await eventsOfType(page, 'chart-saved')).toHaveLength(3); + await expectAllSaveCallbacks(page); await host.nativeSave.dispose(); expect(host.errors).toEqual([]); }); @@ -201,7 +214,7 @@ test('user retains the last accepted chart snapshot when a later order ends the await releaseAcceptedDrawing(page, host, 1); await advanceFromDrawing(page, 'order-submitted-1', 'create', 120); await advanceToSubmission(page, host, 2); - expect(await savedSnapshots(page)).toEqual([]); + expect(await serializedSnapshots(page)).toEqual([]); // When the rejection arrives and the unmatched drawing-discovery deadline completes. await host.releaseSubmitResponse(2); @@ -212,7 +225,7 @@ test('user retains the last accepted chart snapshot when a later order ends the await expect(page.locator(STATUS)).toContainText('失败'); await expect(page.locator(STATUS)).toContainText('错误码 400123'); await expect(page.locator(STATUS)).toContainText('本轮 1/3 笔 · 累计 1 笔'); - expect(await savedSnapshots(page)).toEqual([{ checked: true, drawingIds: ['order-submitted-1'] }]); + expect(await serializedSnapshots(page)).toEqual([{ checked: true, drawingIds: ['order-submitted-1'] }]); expect((await eventsOfType(page, 'chart-drawing-event')).map(({ drawingId, eventType }) => ({ drawingId, eventType }))) .toEqual([{ drawingId: 'order-submitted-1', eventType: 'create' }]); expect(await saveMethodIsNative(host.nativeSave)).toBe(true); @@ -230,11 +243,12 @@ test('user retains the last accepted chart snapshot when a later order ends the // Then neither a duplicate snapshot nor a third order is created. expect(await eventsOfType(page, 'chart-saved')).toHaveLength(1); expect(await eventsOfType(page, 'order-submitted')).toHaveLength(2); + await expectAllSaveCallbacks(page); await host.nativeSave.dispose(); expect(host.errors).toEqual([]); }); -test('user coalesces native order-removal saves while a continuous submit is pending', async ({ page }) => { +test('user shares one native serialization across removal callbacks while a continuous submit is pending', async ({ page }) => { // Given the first submit remains unanswered while two original native orders are visible. const host = await openContinuousDrawingHost(page, { orders: EXISTING_ORDERS }); @@ -252,24 +266,25 @@ test('user coalesces native order-removal saves while a continuous submit is pen { drawingId: 'order-original-2', eventType: 'remove' }, ]); expect(await eventsOfType(page, 'chart-save-requested')).toHaveLength(2); - expect(await savedSnapshots(page)).toEqual([]); + expect(await serializedSnapshots(page)).toEqual([]); expect(await saveMethodIsNative(host.nativeSave)).toBe(false); // When the final removal settles and the user stops the still pending submit. await page.clock.runFor(1); - expect(await savedSnapshots(page)).toEqual([{ checked: true, drawingIds: [] }]); + expect(await serializedSnapshots(page)).toEqual([{ checked: true, drawingIds: [] }]); await page.getByRole('button', { name: '停止平空', exact: true }).click(); await page.clock.runFor(300); await host.releaseSubmitResponse(1, { outcome: 'rejected', code: '400123', message: 'Account restricted' }); await expect.poll(async () => (await eventsOfType(page, 'order-submit-api-rejected')).length).toBe(1); await page.clock.runFor(5_000); - // Then the final empty chart was saved once and stopped cleanup leaves the native method intact. + // Then both removal callbacks share the empty serialization and cleanup leaves the native method intact. await expect(page.locator(STATUS)).toContainText('已停止'); - expect(await savedSnapshots(page)).toEqual([{ checked: true, drawingIds: [] }]); + expect(await serializedSnapshots(page)).toEqual([{ checked: true, drawingIds: [] }]); expect(await saveMethodIsNative(host.nativeSave)).toBe(true); expect(await eventsOfType(page, 'order-submitted')).toHaveLength(1); expect((await readFixtureState(page)).orders).toEqual([]); + await expectAllSaveCallbacks(page); await host.nativeSave.dispose(); expect(host.errors).toEqual([]); }); diff --git a/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js b/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js index 080d7b3..e0f6816 100644 --- a/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js +++ b/e2e/binance-orderbook/specs/continuous-readiness-behavior.pw.js @@ -385,18 +385,32 @@ test('user waits for an invisible close button before starting another round', a // When the first round finishes while that button has no rendered geometry. await host.releaseSubmitResponse(3); await expect(host.status).toContainText('等待按钮恢复'); + const position = await gatePositionRecheck(page, { + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, data: [ + { symbol: CURRENT_SYMBOL, positionSide: 'SHORT', positionAmount: '-100' }, + ] }), + }); await page.clock.runFor(2_000); + await expect.poll(position.requestCount).toBe(1); // Then the runner holds the next round without fabricating a new submission. expect(await readSubmissions(page)).toHaveLength(3); // When the native host renders its button again. await host.nativeButton.evaluate((button) => { button.style.display = ''; }); - // The native-button lookup caches visible results for 250 milliseconds. + // Keep the response pending past the old fixed clock window to exercise the CI race. await page.clock.runFor(300); + await expect(host.status).toContainText('等待按钮恢复'); + await position.releaseFirst(); - // Then readiness starts a new full cooldown that the user can still stop. - await expect(host.status).toContainText('1s 后继续'); + // Then response consumption and the next readiness poll start a complete cooldown. + // Network completion can schedule that poll after the virtual clock has paused. + await expect.poll(async () => { + await page.clock.runFor(50); + return host.status.textContent(); + }).toContain('1s 后继续'); await advanceFromStatus(page, '1s 后继续', 999); expect(await readSubmissions(page)).toHaveLength(3); await expect(host.panel.getByRole('button', { name: '停止平空', exact: true })).toHaveCount(1); diff --git a/e2e/binance-orderbook/specs/marker-overlay.pw.js b/e2e/binance-orderbook/specs/marker-overlay.pw.js new file mode 100644 index 0000000..8c2cdca --- /dev/null +++ b/e2e/binance-orderbook/specs/marker-overlay.pw.js @@ -0,0 +1,200 @@ +import { build } from 'esbuild'; +import { test, expect } from '../test.js'; +import { installMarkerOverlayHost } from '../helpers/marker-overlay-host.js'; + +const bundle = await build({ entryPoints: ['src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js'], + bundle: true, write: false, format: 'iife', globalName: 'markerApi' }); + +test('user keeps chart markers out of native drawing saves while another page reads persisted drawings', async ({ page, context }) => { + // Given two pages share an isolated database and a chart with a user drawing. + await context.route('http://marker.test/**', route => route.fulfill({ contentType: 'text/html', body: '
' })); + await page.goto('http://marker.test/chart'); + const reader = await context.newPage(); + await reader.goto('http://marker.test/reader'); + await page.addScriptTag({ content: bundle.outputFiles[0].text }); + await page.addScriptTag({ content: `window.installMarkerOverlayHost = ${installMarkerOverlayHost.toString()};` }); + await page.evaluate(async () => { + const subscription = () => { + const listeners = new Map(); + return { subscribe: (owner, fn) => listeners.set(fn, owner), + unsubscribe: (owner, fn) => { if (listeners.get(fn) === owner) listeners.delete(fn); } }; + }; + const intervals = subscription(), loaded = subscription(); + window.nativeCalls = { create: 0, remove: 0, save: 0 }; + const chart = { hasModel: () => true, dataReady: () => true, resolution: () => '1', symbol: () => 'OPNUSDT', + onIntervalChanged: () => intervals, onDataLoaded: () => loaded, + createShape() { window.nativeCalls.create += 1; }, removeEntity() { window.nativeCalls.remove += 1; } }; + const api = { activeChart: () => chart, saveChart() { window.nativeCalls.save += 1; } }; + const rows = Array.from({ length: 2000 }, (_, i) => [(i + 1) * 60, 100, 110, 90, 105]); + window.host = window.installMarkerOverlayHost({ document, chart, rows }); + window.layer = window.markerApi.createBollingerMarkerLayer({ chart, chartRoot: document.body, + tradingViewApi: api, resolution: '1', routeSymbol: 'OPNUSDT' }); + window.signals = rows.map((row, i) => ({ id: `s-${i}`, time: row[0], markerPrice: 90 + i % 30, + direction: i % 2 ? 'bearish' : 'bullish', type: i % 3 === 0 ? 'warning' : 'confirmed' })); + window.openDrawingDatabase = () => new Promise((resolve, reject) => { + const request = indexedDB.open('marker-fixture', 1); + request.onupgradeneeded = () => request.result.createObjectStore('drawings'); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const db = await window.openDrawingDatabase(); + await new Promise((resolve, reject) => { + const tx = db.transaction('drawings', 'readwrite'); + tx.objectStore('drawings').put({ id: 'user-line', price: 123 }, 'user-line'); + tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); + }); + db.close(); + }); + + // When all markers render and the viewport pans without changing native drawings. + await page.evaluate(() => window.layer.render(window.signals, { isCurrent: () => true })); + await expect(page.locator('[data-marker-id="s-0"]')).toHaveAttribute('transform', 'translate(10 220)'); + await page.evaluate(() => window.host.pan(20)); + await expect(page.locator('[data-marker-id="s-0"]')).toHaveAttribute('transform', 'translate(30 220)'); + await page.screenshot({ path: 'test-results/ui/marker-overlay.png' }); + + // Then markers are clipped to the pane and the persisted user drawing remains readable from another page. + expect(await page.evaluate(() => window.nativeCalls)).toEqual({ create: 0, remove: 0, save: 0 }); + expect(await page.locator('[data-marker-id]').count()).toBe(436); + expect(await page.locator('[data-strategy-marker-overlay]').evaluate(svg => getComputedStyle(svg).pointerEvents)).toBe('none'); + expect(await reader.evaluate(() => new Promise((resolve, reject) => { + const request = indexedDB.open('marker-fixture', 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction('drawings', 'readonly'); + const read = tx.objectStore('drawings').get('user-line'); + read.onsuccess = () => resolve(read.result); + read.onerror = () => reject(read.error); + tx.oncomplete = () => db.close(); + }; + }))).toEqual({ id: 'user-line', price: 123 }); + + // When the layer is cleared after an update. + await page.evaluate(async () => { await window.layer.render(window.signals.slice(0, 100), { isCurrent: () => true }); window.layer.clear(); }); + + // Then only script-owned presentation disappears and no native persistence call occurred. + await expect(page.locator('[data-strategy-marker-overlay]')).toHaveCount(0); + expect(await page.evaluate(() => window.nativeCalls)).toEqual({ create: 0, remove: 0, save: 0 }); + await reader.close(); +}); + +const observers = await build({ stdin: { contents: ` + export { createTradingViewEventLayer } from './src/binance-strategy27-events/dom/tradingview-event-layer.js'; + export { createTradingViewCompoundLayer } from './src/binance-strategy27-events/dom/tradingview-compound-layer.js'; + export { parseStrategy31Events } from './src/binance-strategy31-volume-reversal/event-contract.js'; +`, resolveDir: process.cwd() }, bundle: true, write: false, format: 'iife', globalName: 'observerApi' }); + +test('user gets exact and prior candle coordinates from the inspected browser host boundary', async ({ page }) => { + // Given the host exposes two candles separated by a no-trade second + await page.setContent(''); + await page.addScriptTag({ content: `window.installMarkerOverlayHost = ${installMarkerOverlayHost.toString()};` }); + // When exact and previous lookups and inverse price coordinates are read + const coordinates = await page.evaluate(() => { + const chart = {}; + const host = window.installMarkerOverlayHost({ document, chart, rows: [[120, 100, 110, 90, 105], [122, 100, 110, 90, 105]] }); + const model = chart._chartWidget.model().model(); + const time = model.timeScale(), price = model.mainSeries().priceScale(); + host.setSpacing(100, 60); + return { exact: time.timePointToIndex(120, 0), gap: time.timePointToIndex(121, 0), + prior: time.timePointToIndex(121, 1), beforeFirst: time.timePointToIndex(119, 1), + x: time.indexToCoordinate(1), y: price.priceToCoordinate(90), + shiftedPrice: price.coordinateToPrice(price.priceToCoordinate(90) + 8) }; + }); + // Then gaps stay absent in exact mode and previous mode never selects a future candle + expect(coordinates).toEqual({ exact: 0, gap: null, prior: 0, beforeFirst: null, x: 160, y: 220, shiftedPrice: 86 }); +}); + +test('user sees independent Strategy27 arrows and labels beside Strategy29 and Strategy31 marker projections without native saves', async ({ page }, testInfo) => { + // Given four renderer instances share a controlled pane while retaining an unrelated drawing + await page.setContent('Controlled chart fixture · independent SVG layers · no native drawing writes
'); + await page.addScriptTag({ content: bundle.outputFiles[0].text }); + await page.addScriptTag({ content: observers.outputFiles[0].text }); + await page.addScriptTag({ content: `window.installMarkerOverlayHost = ${installMarkerOverlayHost.toString()};` }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.evaluate(() => { + const delegate = () => { + const listeners = new Map(); + return { subscribe(owner, callback) { listeners.set(callback, owner); }, + unsubscribe(owner, callback) { if (listeners.get(callback) !== owner) throw new Error('Wrong delegate owner'); listeners.delete(callback); } }; + }; + const interval = delegate(), loaded = delegate(); + window.nativeCalls = { create: 0, remove: 0, save: 0 }; + window.foreignDrawing = Object.freeze({ id: 'user-line', price: 123 }); + const chart = { hasModel: () => true, dataReady: () => true, resolution: () => '1S', symbol: () => 'OPNUSDT', + onIntervalChanged: () => interval, onDataLoaded: () => loaded, + getAllShapes: () => [window.foreignDrawing], + createShape() { window.nativeCalls.create += 1; throw new Error('Native creation is forbidden'); }, + removeEntity() { window.nativeCalls.remove += 1; throw new Error('Native removal is forbidden'); } }; + const api = { activeChart: () => chart, saveChart() { window.nativeCalls.save += 1; throw new Error('Native save is forbidden'); } }; + const rows = Array.from({ length: 8 }, (_, index) => [120 + index * 60, 100, 110, index === 7 ? 95 : 90, 105]); + const host = window.installMarkerOverlayHost({ document, chart, rows }); + host.setSpacing(100, 60); + const target = { chart, chartRoot: document.body, tradingViewApi: api, resolution: '1S', routeSymbol: 'OPNUSDT' }; + const ordinary = window.observerApi.createTradingViewEventLayer(target, { maxEvents: 80, maxAgeMs: 7200000 }); + const compound = window.observerApi.createTradingViewCompoundLayer(target, { maxCandidates: 80, locale: 'en' }); + const strategy29 = window.markerApi.createBollingerMarkerLayer(target); + const strategy31 = window.markerApi.createBollingerMarkerLayer(target); + // This tests renderer ownership; production Strategy31's interval activation is tested separately. + const strategy31Signals = window.observerApi.parseStrategy31Events({ schema_version: 1, strategy_id: '31', + spec_version: '31_2_spec_v1', symbol: 'OPN/USDT:USDT', timeframe: '1m', observed_at_ms: 660000, + events: [{ id: '31_2_spec_v1:OPN/USDT:USDT:1m:540000', symbol: 'OPN/USDT:USDT', timeframe: '1m', + bar_open_ms: 540000, bar_close_ms: 600000, open: 100, high: 110, low: 95, close: 105, volume: 2, previous_volume: 1 }] }, 'OPN/USDT:USDT', '1m'); + window.scene = { chart, host, ordinary, compound, strategy29, strategy31, strategy31Signals }; + }); + + // When ordinary directions, compound slots, Bollinger states and parsed server markers render + await page.evaluate(async () => { + const { ordinary, compound, strategy29, strategy31, strategy31Signals } = window.scene; + await ordinary.renderOpened('ordinary-up', { markerTime: 120, markerShape: 'arrow_up', markerColor: '#0ECB81' }, 660000); + await ordinary.renderOpened('ordinary-down', { markerTime: 180, markerShape: 'arrow_down', markerColor: '#F6465D' }, 660000); + const high = { markerTime: 240, markerShape: 'arrow_down', markerColor: '#B71C3B', markerLabel: 'High candidate' }; + await compound.renderCandidate('high', high, 660000); + await compound.renderCandidate('high-second', high, 660001); + await compound.renderCandidate('low', { markerTime: 300, markerShape: 'arrow_up', markerColor: '#087F5B', markerLabel: 'Low candidate' }, 660002); + await strategy29.render([ + { id: 'strategy29-warning', time: 360, markerPrice: 105, type: 'warning', direction: 'bullish' }, + { id: 'strategy29-confirmed', time: 420, markerPrice: 100, type: 'confirmed', direction: 'bearish' }, + { id: 'strategy29-reversal', time: 480, markerPrice: 100, type: 'reversal', direction: 'bearish' }, + ], { isCurrent: () => true }); + await strategy31.render(strategy31Signals, { isCurrent: () => true }); + }); + + // Then each marker has its intended candle anchor, color, arrow geometry or localized label + await expect(page.locator('[data-strategy-marker-overlay]')).toHaveCount(4); + await expect(page.locator('[data-marker-id]')).toHaveCount(12); + for (const [id, transform] of [ + ['event:ordinary-up', 'translate(60 228)'], ['event:ordinary-down', 'translate(160 172)'], + ['candidate:high:icon', 'translate(260 154)'], ['candidate:high:label', 'translate(260 114)'], + ['candidate:high-second:icon', 'translate(260 90)'], ['candidate:high-second:label', 'translate(260 50)'], + ['candidate:low:icon', 'translate(360 246)'], ['candidate:low:label', 'translate(360 264)'], + ['strategy29-warning', 'translate(460 190)'], ['31_2_spec_v1:OPN/USDT:USDT:1m:540000', 'translate(760 210)'], + ]) await expect(page.locator(`[data-marker-id="${id}"]`)).toHaveAttribute('transform', transform); + await expect(page.locator('[data-marker-id="candidate:high:icon"]')).toHaveAttribute('d', 'M 0 18 L -12 2 L -4 2 L -4 -18 L 4 -18 L 4 2 L 12 2 Z'); + await expect(page.locator('[data-marker-id="candidate:high:icon"]')).toHaveAttribute('fill', '#B71C3B'); + await expect(page.locator('[data-marker-id="candidate:low:icon"]')).toHaveAttribute('fill', '#087F5B'); + await expect(page.locator('[data-marker-id="candidate:high:label"]')).toHaveText('High candidate'); + await expect(page.locator('[data-marker-id="candidate:low:label"]')).toHaveText('Low candidate'); + await expect(page.locator('[data-marker-id="candidate:high:label"]')).toHaveAttribute('font-size', '12'); + await expect(page.locator('[data-marker-id="strategy29-warning"]')).toHaveAttribute('r', '5'); + expect(await page.locator('[data-strategy-marker-overlay]').evaluateAll(nodes => nodes.map(node => getComputedStyle(node).pointerEvents))).toEqual(['none', 'none', 'none', 'none']); + await page.screenshot({ path: testInfo.outputPath('strategy27-29-31-overlays.png') }); + + // When the pane pans and compound labels switch language before individual layers clear + await page.evaluate(() => { window.scene.host.pan(20); window.scene.compound.setLocale('zh-CN'); }); + await expect(page.locator('[data-marker-id="event:ordinary-up"]')).toHaveAttribute('transform', 'translate(80 228)'); + await expect(page.locator('[data-marker-id="candidate:high:label"]')).toHaveText('候选高'); + await expect(page.locator('[data-marker-id="candidate:low:label"]')).toHaveText('候选低'); + await page.evaluate(() => window.scene.compound.clear()); + + // Then clearing one layer preserves the other three and every native drawing/save counter remains zero + await expect(page.locator('[data-strategy-marker-overlay]')).toHaveCount(3); + await expect(page.locator('[data-marker-id]')).toHaveCount(6); + await expect(page.locator('[data-marker-id="31_2_spec_v1:OPN/USDT:USDT:1m:540000"]')).toHaveAttribute('transform', 'translate(780 210)'); + expect(await page.evaluate(() => ({ calls: window.nativeCalls, drawings: window.scene.chart.getAllShapes() }))).toEqual({ calls: { create: 0, remove: 0, save: 0 }, drawings: [{ id: 'user-line', price: 123 }] }); + await page.evaluate(() => { window.scene.ordinary.clear(); window.scene.strategy29.clear(); window.scene.strategy31.clear(); }); + await expect(page.locator('[data-strategy-marker-overlay]')).toHaveCount(0); + expect(await page.evaluate(() => window.nativeCalls)).toEqual({ create: 0, remove: 0, save: 0 }); + expect(errors).toEqual([]); +}); diff --git a/e2e/binance-orderbook/specs/route-recovery-behavior.pw.js b/e2e/binance-orderbook/specs/route-recovery-behavior.pw.js index cde75eb..1a07e25 100644 --- a/e2e/binance-orderbook/specs/route-recovery-behavior.pw.js +++ b/e2e/binance-orderbook/specs/route-recovery-behavior.pw.js @@ -330,7 +330,7 @@ test('user keeps a reload recovery record until the chart is ready and its final // Then exactly the final restored snapshot is saved and only now is the recovery record removed. await expect.poll(() => readRecoveryRecord(page)).toBe(null); expect((await eventsOfType(page, 'chart-saved')).map(({ snapshot }) => snapshot)).toEqual([ - { checked: true, drawingCount: 1, finalOrderId: 'current-1' }, + { checked: true, drawingIds: ['order-current-1'] }, ]); await expect(page.locator('#chart-orders-menu')).not.toHaveClass(/active/); expect((await readFixtureState(page)).orders).toEqual(ORDER_SETS.current); diff --git a/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js b/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js index 624b610..cc3c228 100644 --- a/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js +++ b/e2e/binance-orderbook/specs/strategy29-coexistence.pw.js @@ -2,6 +2,7 @@ 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'; +import { installMarkerOverlayHost } from '../helpers/marker-overlay-host.js'; const strategy29 = await readFile(new URL('../../../scripts/binance-strategy29-bollinger.user.js', import.meta.url), 'utf8'); @@ -22,6 +23,7 @@ for (const first of [true, false]) { const sandboxedStrategy29 = strategy29Sandbox(strategy29); const { errors } = await openUserscriptScenario(page, createCancelScenario(), first ? { beforeOrderbook: sandboxedStrategy29 } : { afterOrderbook: sandboxedStrategy29 }); + await page.addScriptTag({ content: `window.installMarkerOverlayHost = ${installMarkerOverlayHost.toString()};` }); // When the host exposes a ready chart with deterministic candles and drawing operations. await page.evaluate(symbol => { const api = document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi; @@ -57,15 +59,16 @@ for (const first of [true, false]) { }; api.activeChart = () => chart; api.saveChart = callback => callback({ drawings: ['foreign-channel'] }); + window.installMarkerOverlayHost({ document: document.querySelector('.chart-widget-root iframe').contentDocument, + chart, rows: rows.map(row => [row[0], row[1], row[2], row[3], row[4]]) }); }, CURRENT_SYMBOL); // Then Strategy29 draws nine markers and shares the existing orderbook coordination owner without embedding its detector. 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, + nativeShapes: document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi.activeChart().getAllShapes().length, owners: [...window[Symbol.for('jh-userscripts.chart-mutation-owners')].predicates.keys()], - }))).toEqual({ embedded: false, controller: 1, owners: ['orderbook'] }); + }))).toEqual({ embedded: false, nativeShapes: 0, owners: ['orderbook'] }); // When the same complete Strategy29 artifact is injected again. await page.addScriptTag({ content: sandboxedStrategy29 }); diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 87fabe8..b7d585d 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.216 +// @version 2.7.217 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -3022,6 +3022,26 @@ // src/binance-orderbook-trade/core/chart-save-coalescer.js var IGNORED_DRAWING_EVENT_TYPES = /* @__PURE__ */ new Set(["click", "move"]); + function isDefaultSaveCall(api, receiver, args) { + return receiver === api && args.length >= 1 && args.length <= 2 && typeof args[0] === "function" && args[1] === void 0; + } + function deliverSaveCallbacks(api, saveChart, callbacks, onCallbackError, setTimeoutFn) { + if (callbacks.length === 0) return; + saveChart.call(api, (snapshot) => { + const json = JSON.stringify(snapshot); + const errors = []; + for (const callback of callbacks) { + try { + callback(JSON.parse(json)); + } catch (error) { + errors.push(error); + } + } + if (errors.length) { + setTimeoutFn(() => onCallbackError(new AggregateError(errors, "TradingView chart save callbacks failed")), 0); + } + }); + } function validateTradingViewApi(api) { if (!api || typeof api !== "object") { throw new Error("图表接口不可用"); @@ -3056,7 +3076,10 @@ submitEventDiscoveryMs = 250, getDrawingToolName = readTradingViewDrawingToolName, setTimeoutFn = setTimeout, - clearTimeoutFn = clearTimeout + clearTimeoutFn = clearTimeout, + onCallbackError = (error) => { + throw error; + } } = {}) { validateTradingViewApi(api); if (!Number.isFinite(settleQuietMs) || settleQuietMs <= 0) { @@ -3119,26 +3142,18 @@ ); } try { - if (!burst.pendingSave) return void 0; - if (saveChartWasReplaced) { - if (activeRound?.pendingSave) activeRound.pendingSave = null; - fullSaveCount += 1; - return burst.originalSaveChart.apply( - burst.pendingSave.thisValue, - burst.pendingSave.args - ); - } - if (burst.deferToRound && activeRound) { - activeRound.pendingSave = burst.pendingSave; + const callbacks = burst.callbacks; + burst.callbacks = []; + if (callbacks.length === 0) return void 0; + if (!saveChartWasReplaced && burst.deferToRound && activeRound) { + activeRound.callbacks.push(...callbacks); deferredSubmitSaveCount += 1; return void 0; } - if (activeRound?.pendingSave) activeRound.pendingSave = null; + const pending = activeRound ? activeRound.callbacks.splice(0) : []; + pending.push(...callbacks); fullSaveCount += 1; - return burst.originalSaveChart.apply( - burst.pendingSave.thisValue, - burst.pendingSave.args - ); + return deliverSaveCallbacks(api, burst.originalSaveChart, pending, onCallbackError, setTimeoutFn); } finally { if (burst.submitCapture) { finishSubmitCapture( @@ -3161,14 +3176,20 @@ deferToRound: false, originalDescriptor, originalSaveChart, - pendingSave: null, + callbacks: [], settleTimer: null, submitCapture: null, wrapper: null }; burst.wrapper = function continuousSaveBurstWrapper(...args) { + if (activeBurst !== burst) return originalSaveChart.apply(this, args); saveRequestCount += 1; - burst.pendingSave = { thisValue: this, args }; + if (!isDefaultSaveCall(api, this, args)) { + flushActiveBurst(); + flushRoundPendingSave(); + return originalSaveChart.apply(this, args); + } + burst.callbacks.push(args[0]); return void 0; }; api.saveChart = burst.wrapper; @@ -3180,11 +3201,10 @@ return burst; }; const flushRoundPendingSave = () => { - const pendingSave = activeRound?.pendingSave || null; - if (!pendingSave) return void 0; - activeRound.pendingSave = null; + if (!activeRound || activeRound.callbacks.length === 0) return void 0; + const callbacks = activeRound.callbacks.splice(0); fullSaveCount += 1; - return api.saveChart.apply(pendingSave.thisValue, pendingSave.args); + return deliverSaveCallbacks(api, sessionSaveChart, callbacks, onCallbackError, setTimeoutFn); }; const handleDrawingEvent = (drawingId, eventType) => { if (stopped || IGNORED_DRAWING_EVENT_TYPES.has(eventType)) return; @@ -3227,7 +3247,7 @@ if (activeSubmitCapture) throw new Error("上一笔订单线捕获尚未结束"); flushActiveBurst(); sequence += 1; - activeRound = { id: sequence, pendingSave: null }; + activeRound = { id: sequence, callbacks: [] }; return activeRound; }, beginSubmitCapture(round) { @@ -3272,11 +3292,11 @@ throw new Error("结束图表保存轮次时仍有订单线捕获"); } flushActiveBurst(); - const pendingSave = activeRound.pendingSave; + const callbacks = activeRound.callbacks; activeRound = null; - if (pendingSave) { + if (callbacks.length) { fullSaveCount += 1; - api.saveChart.apply(pendingSave.thisValue, pendingSave.args); + deliverSaveCallbacks(api, sessionSaveChart, callbacks, onCallbackError, setTimeoutFn); } return getStats(); }, @@ -3312,7 +3332,10 @@ maxWaitMs = 600, eventDiscoveryMs = 250, setTimeoutFn = setTimeout, - clearTimeoutFn = clearTimeout + clearTimeoutFn = clearTimeout, + onCallbackError = (error) => { + throw error; + } } = {}) { validateTradingViewApi(api); if (!Number.isFinite(settleQuietMs) || settleQuietMs <= 0) { @@ -3331,7 +3354,7 @@ let discoveryTimer = null; let finished = false; let fullSaveCount = 0; - let pendingFinalSave = null; + let pendingFinalCallbacks = []; let removeEventCount = 0; let saveRequestCount = 0; let synchronousSaveCount = 0; @@ -3345,9 +3368,16 @@ saveRequestCount, synchronousSaveCount }); + const flushFinalCallbacks = () => { + if (pendingFinalCallbacks.length === 0) return; + const callbacks = pendingFinalCallbacks; + pendingFinalCallbacks = []; + fullSaveCount += 1; + deliverSaveCallbacks(api, sessionSaveChart, callbacks, onCallbackError, setTimeoutFn); + }; const monitoredSaveChart = function monitoredRemovalSessionSaveChart(...args) { synchronousSaveCount += 1; - pendingFinalSave = null; + flushFinalCallbacks(); return sessionSaveChart.apply(this, args); }; const clearBurstTimers = (burst) => { @@ -3363,7 +3393,8 @@ activeBurst = null; if (api.saveChart !== burst.wrapper) { controllerError || (controllerError = new Error("图表保存接口在删除事件合并期间发生变化")); - pendingFinalSave = null; + pendingFinalCallbacks.push(...burst.callbacks); + burst.callbacks = []; burst.resolve(); return; } @@ -3373,7 +3404,8 @@ burst.originalSaveChart, burst.originalDescriptor ); - if (burst.pendingSave) pendingFinalSave = burst.pendingSave; + pendingFinalCallbacks.push(...burst.callbacks); + burst.callbacks = []; burst.resolve(); }; const scheduleBurstSettle = (burst) => { @@ -3394,15 +3426,21 @@ maxWaitTimer: null, originalDescriptor: Object.getOwnPropertyDescriptor(api, "saveChart"), originalSaveChart: api.saveChart, - pendingSave: null, + callbacks: [], resolve, settleTimer: null, settled, wrapper: null }; burst.wrapper = function removalSaveBurstWrapper(...args) { + if (activeBurst !== burst) return sessionSaveChart.apply(this, args); saveRequestCount += 1; - burst.pendingSave = { thisValue: this, args }; + if (!isDefaultSaveCall(api, this, args)) { + finishBurst(); + flushFinalCallbacks(); + return monitoredSaveChart.apply(this, args); + } + burst.callbacks.push(args[0]); }; api.saveChart = burst.wrapper; if (api.saveChart !== burst.wrapper) { @@ -3459,12 +3497,8 @@ ); } else { controllerError || (controllerError = new Error("图表保存接口在删除事件监视期间发生变化")); - pendingFinalSave = null; - } - if (pendingFinalSave) { - fullSaveCount += 1; - sessionSaveChart.apply(pendingFinalSave.thisValue, pendingFinalSave.args); } + flushFinalCallbacks(); if (controllerError) throw controllerError; return getStats(); } @@ -3475,7 +3509,10 @@ settleQuietMs = 50, timeoutMs = 1800, setTimeoutFn = setTimeout, - clearTimeoutFn = clearTimeout + clearTimeoutFn = clearTimeout, + onCallbackError = (error) => { + throw error; + } } = {}) { validateTradingViewApi(api); if (typeof action !== "function") throw new Error("图表操作不可用"); @@ -3492,8 +3529,11 @@ const originalDescriptor = Object.getOwnPropertyDescriptor(api, "saveChart"); let drawingEventCount = 0; let saveRequestCount = 0; - let pendingSave = null; + let pendingCallbacks = []; + let fullSaveCount = 0; let actionFinished = false; + let interceptionFinished = false; + const failures = []; let eventStartResolve; let settleResolve; let settleReject; @@ -3527,9 +3567,28 @@ if (drawingEventCount === 1) eventStartResolve(); scheduleSettleIfReady(); }; + const flushPendingCallbacks = () => { + if (pendingCallbacks.length === 0) return; + const callbacks = pendingCallbacks; + pendingCallbacks = []; + fullSaveCount += 1; + deliverSaveCallbacks(api, originalSaveChart, callbacks, onCallbackError, setTimeoutFn); + }; const saveChartWrapper = function coalescedSaveChart(...args) { + if (interceptionFinished) return originalSaveChart.apply(this, args); saveRequestCount += 1; - pendingSave = { thisValue: this, args }; + if (!isDefaultSaveCall(api, this, args)) { + const ownsWrapper = api.saveChart === saveChartWrapper; + if (ownsWrapper) restoreSaveChartMethod(api, saveChartWrapper, originalSaveChart, originalDescriptor); + try { + flushPendingCallbacks(); + return originalSaveChart.apply(this, args); + } finally { + if (ownsWrapper && api.saveChart === originalSaveChart) api.saveChart = saveChartWrapper; + scheduleSettleIfReady(); + } + } + pendingCallbacks.push(args[0]); scheduleSettleIfReady(); }; api.subscribe("drawing_event", handleDrawingEvent); @@ -3572,21 +3631,36 @@ } api.unsubscribe("drawing_event", handleDrawingEvent); subscribed = false; - if (pendingSave) { - originalSaveChart.apply(pendingSave.thisValue, pendingSave.args); - } return { actionResult, drawingEventCount, saveRequestCount, - fullSaveCount: pendingSave ? 1 : 0 + fullSaveCount: fullSaveCount + (pendingCallbacks.length > 0 ? 1 : 0) }; + } catch (error) { + failures.push(error); } finally { if (eventDiscoveryTimeout !== null) clearTimeoutFn(eventDiscoveryTimeout); if (settleQuietTimeout !== null) clearTimeoutFn(settleQuietTimeout); if (waitTimeout !== null) clearTimeoutFn(waitTimeout); if (subscribed) api.unsubscribe("drawing_event", handleDrawingEvent); - restoreSaveChartMethod(api, saveChartWrapper, originalSaveChart, originalDescriptor); + interceptionFinished = true; + const ownershipChanged = api.saveChart !== saveChartWrapper; + if (!ownershipChanged) { + try { + restoreSaveChartMethod(api, saveChartWrapper, originalSaveChart, originalDescriptor); + } catch (error) { + failures.push(error); + } + } + try { + flushPendingCallbacks(); + } catch (error) { + failures.push(error); + } + if (ownershipChanged) failures.push(new Error("图表保存接口在操作期间发生变化")); + if (failures.length > 1) throw new AggregateError(failures, "TradingView drawing action and save cleanup failed"); + if (failures.length === 1) throw failures[0]; } } diff --git a/scripts/binance-strategy27-events.user.js b/scripts/binance-strategy27-events.user.js index b5f3202..6ee9e90 100644 --- a/scripts/binance-strategy27-events.user.js +++ b/scripts/binance-strategy27-events.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.6.7 +// @version 0.6.8 // @author jackhai9 // @description Display Strategy 27 events and provide the shared private CorsairQuant gateway connection // @match https://www.binance.com/*/futures/* @@ -1151,6 +1151,283 @@ return annotation; } + // src/shared/chart-marker-overlay.js + function createChartMarkerOverlay(target, { + maxMarkers, + canMutate = () => true, + onRenderError + } = {}) { + if (!Number.isSafeInteger(maxMarkers) || maxMarkers < 1) throw new Error("TradingView overlay marker limit is invalid"); + const { chart } = target; + const document = target.chartRoot.ownerDocument; + const owner = {}; + const subscriptions = []; + const nodes = /* @__PURE__ */ new Map(); + let svg = null, host = null, frame = null, observer = null, projection = null; + let signals = [], validateCurrent = null, invalidated = false; + let renderedFrames = 0, visibleMarkers = 0, generation = 0; + function hide() { + if (svg && svg.style.visibility !== "hidden") svg.style.visibility = "hidden"; + visibleMarkers = 0; + } + function cancelFrame() { + if (frame !== null) host.ownerDocument.defaultView.cancelAnimationFrame(frame); + frame = null; + } + function clear() { + generation += 1; + hide(); + cancelFrame(); + observer?.disconnect(); + observer = null; + for (const [event, callback] of subscriptions) event.unsubscribe(owner, callback); + subscriptions.length = 0; + document.removeEventListener("visibilitychange", visibilityChanged); + svg?.remove(); + svg = null; + host = null; + projection = null; + signals = []; + nodes.clear(); + validateCurrent = null; + invalidated = false; + return true; + } + function readProjection() { + const widget = chart._chartWidget; + const model = widget.model().model(); + const series = model.mainSeries(); + const time = model.timeScale(); + const price = series.priceScale(); + const pane = widget.paneByState(model.paneForSource(series)); + const container = pane.canvasElement().parentElement; + if (!container || !container.classList.contains("chart-gui-wrapper")) { + throw new Error("TradingView marker main-pane container is unavailable"); + } + return { model, series, time, price, pane, container }; + } + function current() { + return !invalidated && validateCurrent() && !document.hidden && target.chartRoot.isConnected && chart.hasModel() && chart.dataReady() && target.tradingViewApi.activeChart() === chart && chart.resolution() === target.resolution && String(chart.symbol()).split("@", 1)[0] === target.routeSymbol; + } + function draw() { + if (!current() || !canMutate()) { + hide(); + return false; + } + const next = readProjection(); + for (const key of ["model", "series", "time", "price", "pane", "container"]) { + if (next[key] !== projection[key]) { + throw new Error(`TradingView marker projection changed: ${key}`); + } + } + if (!host.isConnected) throw new Error("TradingView marker pane is detached"); + const { width, height } = host.getBoundingClientRect(); + if (!Number.isFinite(width) || !Number.isFinite(height)) { + throw new Error("TradingView marker pane dimensions are invalid"); + } + const projected = []; + const visibleIds = /* @__PURE__ */ new Set(); + const { series, time, price } = projection; + const data = chart.getSeries().data(); + for (const signal of signals) { + const index = time.timePointToIndex(signal.time, 0); + if (index === null) continue; + if (!Number.isFinite(index)) throw new Error("TradingView marker bar index is invalid"); + const row = data.valueAt(index); + if (!row || row[0] !== signal.time) continue; + const x = time.indexToCoordinate(index); + if (!Number.isFinite(x)) throw new Error("TradingView marker coordinates are invalid"); + if (width <= 0 || height <= 0 || x < 0 || x > width) continue; + const y = price.priceToCoordinate(signal.price, series.firstValue()); + if (!Number.isFinite(y)) { + throw new Error("TradingView marker coordinates are invalid"); + } + if (y < 0 || y > height) continue; + projected.push({ signal, x, y }); + visibleIds.add(signal.id); + } + function setAttribute(node, name, value) { + if (node.getAttribute(name) !== value) node.setAttribute(name, value); + } + setAttribute(svg, "width", String(width)); + setAttribute(svg, "height", String(height)); + setAttribute(svg, "viewBox", `0 0 ${width} ${height}`); + for (const [id, node] of nodes) { + if (!visibleIds.has(id)) { + node.remove(); + nodes.delete(id); + } + } + for (const { signal, x, y } of projected) { + const tag = signal.shape === "circle" ? "circle" : signal.shape === "text" ? "text" : "path"; + let node = nodes.get(signal.id); + if (node && node.localName !== tag) { + node.remove(); + nodes.delete(signal.id); + node = null; + } + if (!node) { + node = host.ownerDocument.createElementNS("http://www.w3.org/2000/svg", tag); + nodes.set(signal.id, node); + svg.append(node); + } + setAttribute(node, "data-marker-id", signal.id); + setAttribute(node, "data-marker-type", signal.type); + setAttribute(node, "data-marker-direction", signal.direction); + setAttribute(node, "transform", `translate(${x} ${y})`); + setAttribute(node, "fill", signal.color); + if (signal.shape === "circle") setAttribute(node, "r", String(signal.size / 2)); + else if (signal.shape === "text") { + setAttribute(node, "font-size", String(signal.size)); + setAttribute(node, "font-weight", "700"); + setAttribute(node, "font-family", "Arial, sans-serif"); + setAttribute(node, "text-anchor", "middle"); + setAttribute(node, "dominant-baseline", "hanging"); + if (node.textContent !== signal.text) node.textContent = signal.text; + } else { + setAttribute(node, "d", signal.pathData); + } + } + if (svg.style.visibility !== "visible") svg.style.visibility = "visible"; + visibleMarkers = projected.length; + renderedFrames += 1; + return true; + } + function schedule() { + if (!svg || frame !== null || invalidated || document.hidden) return; + const scheduledGeneration = generation; + frame = host.ownerDocument.defaultView.requestAnimationFrame(() => { + if (!svg || scheduledGeneration !== generation) return; + frame = null; + try { + draw(); + } catch (error) { + clear(); + if (onRenderError) onRenderError(error); + else throw error; + } + }); + } + function invalidate() { + invalidated = true; + hide(); + cancelFrame(); + } + function dataChanged() { + hide(); + schedule(); + } + function visibilityChanged() { + if (document.hidden) { + hide(); + cancelFrame(); + } else schedule(); + } + function subscribe(event, callback) { + event.subscribe(owner, callback); + subscriptions.push([event, callback]); + } + function install() { + projection = readProjection(); + host = projection.container; + svg = host.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("data-strategy-marker-overlay", ""); + svg.setAttribute("aria-hidden", "true"); + svg.style.cssText = "position:absolute;inset:0;pointer-events:none;overflow:hidden;visibility:hidden"; + host.append(svg); + const { series, time, price } = projection; + for (const event of [ + time.logicalRangeChanged(), + time.barSpacingChanged(), + time.rightOffsetChanged(), + price.priceRangeChanged(), + price.modeChanged(), + price.internalHeightChanged() + ]) { + subscribe(event, schedule); + } + subscribe(series.dataUpdated(), dataChanged); + subscribe(chart.onIntervalChanged(), invalidate); + subscribe(chart.onDataLoaded(), dataChanged); + observer = new host.ownerDocument.defaultView.ResizeObserver(schedule); + observer.observe(host); + document.addEventListener("visibilitychange", visibilityChanged); + } + return Object.freeze({ + render(nextSignals, { isCurrent }) { + try { + if (!Array.isArray(nextSignals) || nextSignals.length > maxMarkers) { + throw new Error("TradingView overlay marker collection is invalid"); + } + if (typeof isCurrent !== "function") throw new Error("TradingView overlay current-target validator is unavailable"); + const ids = /* @__PURE__ */ new Set(); + for (const marker of nextSignals) { + if (!marker || typeof marker.id !== "string" || !marker.id || ids.has(marker.id) || !Number.isInteger(marker.time) || !Number.isFinite(marker.price) || !["circle", "arrow_up", "arrow_down", "text"].includes(marker.shape) || typeof marker.color !== "string" || !/^#[0-9a-f]{6}$/i.test(marker.color) || !Number.isFinite(marker.size) || marker.size <= 0 || typeof marker.type !== "string" || !marker.type || !["bearish", "bullish"].includes(marker.direction)) { + throw new Error("TradingView overlay marker contract is invalid"); + } + if (marker.shape === "text" ? marker.anchor !== "top" || typeof marker.text !== "string" || !marker.text : marker.shape === "circle" ? marker.anchor !== "center" : !["tip", "center"].includes(marker.anchor)) { + throw new Error("TradingView overlay marker style is invalid"); + } + ids.add(marker.id); + } + validateCurrent = isCurrent; + if (!current() || !canMutate()) { + hide(); + return false; + } + if (!svg) install(); + signals = nextSignals.map((marker) => { + if (marker.shape !== "arrow_up" && marker.shape !== "arrow_down") return { ...marker }; + const sign = marker.shape === "arrow_up" ? 1 : -1; + const scale = marker.size / 18; + const centerShift = marker.anchor === "center" ? -sign * marker.size / 2 : 0; + const points = [[0, 0], [-6, 8], [-2, 8], [-2, 18], [2, 18], [2, 8], [6, 8]]; + const path = points.map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x * scale} ${sign * y * scale + centerShift}`).join(" "); + return { ...marker, pathData: `${path} Z` }; + }); + return draw(); + } catch (error) { + clear(); + throw error; + } + }, + clear, + updateText(id, text) { + if (typeof id !== "string" || typeof text !== "string" || !text) throw new Error("TradingView overlay text update is invalid"); + const marker = signals.find((candidate) => candidate.id === id); + if (!marker) return false; + if (marker.shape !== "text") throw new Error("TradingView overlay text target is invalid"); + marker.text = text; + const node = nodes.get(id); + if (node && node.textContent !== text) node.textContent = text; + return true; + }, + remove(ids) { + if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string")) throw new Error("TradingView overlay removal IDs are invalid"); + const removed = new Set(ids); + signals = signals.filter((signal) => !removed.has(signal.id)); + for (const id of removed) { + nodes.get(id)?.remove(); + nodes.delete(id); + } + visibleMarkers = svg?.style.visibility === "visible" ? nodes.size : 0; + }, + get size() { + return signals.length; + }, + get overlayStats() { + return { + renderedFrames, + visibleMarkers, + signalCount: signals.length, + attached: svg !== null, + pendingFrame: frame !== null, + subscriptions: subscriptions.length + }; + } + }); + } + // src/binance-strategy27-events/dom/tradingview-event-layer.js var CHART_ROOT_SELECTOR = ".chart-widget-root"; var STATUS_ID = "jh-strategy27-event-status"; @@ -1167,18 +1444,10 @@ return String(value || "").split("@", 1)[0]; } function assertChartContract(chart) { - for (const method of ["createShape", "getAllShapes", "getShapeById", "removeEntity", "resolution", "symbol"]) { + for (const method of ["hasModel", "dataReady", "onIntervalChanged", "onDataLoaded", "resolution", "symbol"]) { if (typeof chart?.[method] !== "function") throw new Error(`TradingView chart method is unavailable: ${method}`); } } - function readLiveShapeIds(chart) { - const shapes = chart.getAllShapes(); - if (!Array.isArray(shapes)) throw new Error("Strategy 27 chart shape list is invalid"); - return new Set(shapes.map((shape) => { - if (typeof shape?.id !== "string" || shape.id.length === 0) throw new Error("Strategy 27 chart shape id is invalid"); - return shape.id; - })); - } function pinMarkerChartContext(chart) { const symbol = chart.symbol(); const resolution = chart.resolution(); @@ -1207,20 +1476,6 @@ if (chartRoots.length !== 1) throw new Error(`Visible Strategy 27 chart root count is invalid: ${chartRoots.length}`); return chartRoots[0]; } - function shapeOptions(shape, color) { - return { - shape, - lock: true, - disableSave: true, - disableSelection: true, - disableUndo: true, - showInObjectsTree: false, - overrides: { - color, - fixedSize: true - } - }; - } function createMarkerPointResolver(chart) { const model = chart._chartWidget?.model?.()?.model?.(); const timeScale = model?.timeScale?.(); @@ -1288,29 +1543,6 @@ } return { dataUpdated, resolve, shift }; } - function verifyResolvedTime(chart, id, requestedTime) { - const shape = chart.getShapeById(id); - const points = shape?.getPoints?.(); - if (!Array.isArray(points) || points.length !== 1 || points[0].time !== requestedTime) { - const actualTime = Array.isArray(points) && points.length === 1 ? points[0]?.time : null; - const pointCount = Array.isArray(points) ? points.length : null; - throw new Error( - `Strategy 27 chart time alignment failed: expected ${requestedTime}, received ${actualTime} (point count ${pointCount})` - ); - } - return shape; - } - async function createAlignedShape(chart, point, options) { - const id = await chart.createShape(point, options); - if (typeof id !== "string" || id.length === 0) throw new Error("TradingView returned an invalid shape id"); - try { - verifyResolvedTime(chart, id, point.time); - } catch (error) { - chart.removeEntity(id); - throw error; - } - return id; - } function createTradingViewMarkerPlacement(chart, { candleWaitMs = DEFAULT_CANDLE_WAIT_MS } = {}) { @@ -1380,24 +1612,32 @@ function createTradingViewEventLayer(target, { maxEvents, maxAgeMs, - candleWaitMs = DEFAULT_CANDLE_WAIT_MS + candleWaitMs = DEFAULT_CANDLE_WAIT_MS, + onRenderError }) { if (!Number.isInteger(maxEvents) || maxEvents < 1) throw new Error("Strategy 27 maxEvents is invalid"); if (!Number.isInteger(maxAgeMs) || maxAgeMs < 1) throw new Error("Strategy 27 maxAgeMs is invalid"); - const { chart } = target; - const placement = createTradingViewMarkerPlacement(chart, { candleWaitMs }); - const isChartCurrent = pinMarkerChartContext(chart); + const placement = createTradingViewMarkerPlacement(target.chart, { candleWaitMs }); + const isChartCurrent = pinMarkerChartContext(target.chart); const registry = /* @__PURE__ */ new Map(); const pendingRenders = /* @__PURE__ */ new Map(); let renderGeneration = 0; - let reconciliation = null; let suspended = false; + const overlay = createChartMarkerOverlay(target, { + maxMarkers: maxEvents, + onRenderError(error) { + suspend(); + if (onRenderError) onRenderError(error); + else throw error; + } + }); + const markers = () => [...registry.values()].map((record) => record.marker); function removeRecord(eventId) { pendingRenders.get(eventId)?.abort(); const record = registry.get(eventId); if (!record) return; registry.delete(eventId); - if (readLiveShapeIds(chart).has(record.markerId)) chart.removeEntity(record.markerId); + overlay.remove([record.marker.id]); } function pruneAge(observedAtMs) { for (const [eventId, record] of registry) { @@ -1407,69 +1647,48 @@ function ensureCapacityForNew() { while (registry.size >= maxEvents) removeRecord(registry.keys().next().value); } - function restoreMarker(eventId, record, liveIds) { - if (record.restoring) return record.restoring; - const current = () => !suspended && registry.get(eventId) === record && isChartCurrent(); - if (!current()) return Promise.resolve(false); - if (liveIds.has(record.markerId)) return Promise.resolve(true); - record.restoring = (async () => { - const markerId = await createAlignedShape(chart, record.markerPoint, record.options); - if (!current()) { - if (readLiveShapeIds(chart).has(markerId)) chart.removeEntity(markerId); - return false; - } - record.markerId = markerId; - return true; - })().finally(() => { - record.restoring = null; - }); - return record.restoring; - } - function reconcile() { - if (suspended) return Promise.resolve(); - if (reconciliation) return reconciliation; - reconciliation = (async () => { - let liveIds = readLiveShapeIds(chart); - for (const [eventId, record] of [...registry]) { - if (suspended || registry.get(eventId) !== record || !isChartCurrent()) continue; - if (!record.restoring && liveIds.has(record.markerId)) continue; - await restoreMarker(eventId, record, liveIds); - liveIds = readLiveShapeIds(chart); - } - })().finally(() => { - reconciliation = null; - }); - return reconciliation; + async function reconcile() { + if (!suspended) overlay.render(markers(), { isCurrent: isChartCurrent }); } async function ensureMarker(eventId, annotation, observedAtMs) { if (suspended) return false; - let record = registry.get(eventId); - if (record) { - record.observedAtMs = observedAtMs; - return restoreMarker(eventId, record, readLiveShapeIds(chart)); + const existing = registry.get(eventId); + if (existing) { + existing.observedAtMs = observedAtMs; + return overlay.render(markers(), { isCurrent: isChartCurrent }); } if (annotation.markerShape === null) return true; const requestedGeneration = renderGeneration; const controller = new AbortController(); pendingRenders.set(eventId, controller); try { - const markerPoint = await placement.wait(annotation, { signal: controller.signal }); - if (!markerPoint || controller.signal.aborted || requestedGeneration !== renderGeneration || !isChartCurrent()) return false; + const point = await placement.wait(annotation, { signal: controller.signal }); + if (!point || suspended || controller.signal.aborted || requestedGeneration !== renderGeneration || !isChartCurrent()) return false; pruneAge(observedAtMs); ensureCapacityForNew(); - const options = shapeOptions(annotation.markerShape, annotation.markerColor); - const markerId = await createAlignedShape(chart, markerPoint, options); - if (controller.signal.aborted || requestedGeneration !== renderGeneration || !isChartCurrent()) { - if (readLiveShapeIds(chart).has(markerId)) chart.removeEntity(markerId); - return false; - } - record = { markerId, markerPoint, options, observedAtMs, restoring: null }; - registry.set(eventId, record); + const marker = { + id: `event:${eventId}`, + time: point.time, + price: point.price, + shape: annotation.markerShape, + color: annotation.markerColor, + size: 18, + anchor: "tip", + type: "ordinary", + direction: annotation.markerShape === "arrow_up" ? "bullish" : "bearish" + }; + if (!overlay.render([...markers(), marker], { isCurrent: isChartCurrent })) return false; + registry.set(eventId, { marker, observedAtMs }); return true; } finally { if (pendingRenders.get(eventId) === controller) pendingRenders.delete(eventId); } } + function suspend() { + suspended = true; + renderGeneration += 1; + for (const controller of pendingRenders.values()) controller.abort(); + } return Object.freeze({ renderOpened: (eventId, annotation, observedAtMs) => ensureMarker(eventId, annotation, observedAtMs), renderUpdated: (eventId, annotation, observedAtMs) => ensureMarker(eventId, annotation, observedAtMs), @@ -1478,16 +1697,12 @@ remove: removeRecord, prune: pruneAge, reconcile, - /** Stop new presentation without deleting verified history after a job failure. */ - suspend() { - suspended = true; - renderGeneration += 1; - for (const controller of pendingRenders.values()) controller.abort(); - }, + suspend, clear() { renderGeneration += 1; for (const controller of pendingRenders.values()) controller.abort(); - for (const eventId of [...registry.keys()]) removeRecord(eventId); + registry.clear(); + overlay.clear(); }, get size() { return registry.size; @@ -2667,7 +2882,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, if (action.type !== "candidate" || applicationGeneration !== viewGeneration) continue; const id = action.candidate.candidate_id; const annotation = buildCompoundCandidateAnnotation(action.candidate, { locale: currentLocale }); - if (layer === null) layer = createLayer(); + if (layer === null) layer = createLayer({ onRenderError: failJob }); const renderGeneration = viewGeneration; pendingCandidateId = id; try { @@ -2762,139 +2977,60 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, var ICON_SIZE_PX = 36; var CANDLE_GAP_PX = 8; var SLOT_STEP_PX = 64; - var ICONS = Object.freeze({ arrow_down: 61539, arrow_up: 61538 }); - function drawingOptions(color) { - return { - lock: true, - disableSave: true, - disableSelection: true, - disableUndo: true, - showInObjectsTree: false, - overrides: { color } - }; - } - function createTradingViewCompoundLayer(target, { maxCandidates, candleWaitMs = 3e3, locale = "zh-CN" }) { + function createTradingViewCompoundLayer(target, { + maxCandidates, + candleWaitMs = 3e3, + locale = "zh-CN", + onRenderError + }) { if (!Number.isSafeInteger(maxCandidates) || maxCandidates < 1 || maxCandidates > 80) throw new Error("Compound chart capacity must be 1..80"); let t = createStrategy27Translator(locale); const markerLabel = (shape) => shape === "arrow_down" ? t("候选高", "High candidate") : t("候选低", "Low candidate"); - const { chart } = target; - const placement = createTradingViewMarkerPlacement(chart, { candleWaitMs }); - const isChartCurrent = pinMarkerChartContext(chart); + const placement = createTradingViewMarkerPlacement(target.chart, { candleWaitMs }); + const isChartCurrent = pinMarkerChartContext(target.chart); const records = /* @__PURE__ */ new Map(); let pending = null; - let reconciliation = null; let suspended = false; - function dispose(recordsToRemove) { - const errors = []; - const liveIds = readLiveShapeIds(chart); - for (const record of recordsToRemove) { - for (const id of record.ids.splice(0)) { - if (!liveIds.has(id)) continue; - try { - chart.removeEntity(id); - } catch (error) { - errors.push(error); - } - } + const overlay = createChartMarkerOverlay(target, { + maxMarkers: maxCandidates * 2, + onRenderError(error) { + suspend(); + if (onRenderError) onRenderError(error); + else throw error; } - if (errors.length) throw new AggregateError(errors, `Compound chart cleanup failed: ${errors.map((error) => error.message).join("; ")}`); - } - async function createDrawing(point, options) { - const drawing = { ...options }; - const entityId = await createAlignedShape(chart, point, drawing); - try { - const properties = chart.getShapeById(entityId).getProperties(); - const matched = properties.color === drawing.overrides.color && (drawing.shape === "icon" ? properties.icon === drawing.icon && properties.size === ICON_SIZE_PX : properties.text === drawing.text && properties.fontsize === 12); - if (!matched) throw new Error("Compound chart drawing properties did not match the requested icon/label"); - } catch (error) { - try { - dispose([{ ids: [entityId] }]); - } catch (cleanupError) { - throw new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); - } - throw error; - } - return entityId; - } - function restoreCandidate(id, record, liveIds) { - if (record.restoring) return record.restoring; - const current = () => !suspended && records.get(id) === record && isChartCurrent(); - if (!current()) return Promise.resolve(false); - if (record.ids.every((entityId) => liveIds.has(entityId))) return Promise.resolve(true); - record.restoring = (async () => { - for (let index = 0; index < record.drawings.length; index += 1) { - if (!current()) return false; - if (liveIds.has(record.ids[index])) continue; - const [point, drawing] = record.drawings[index]; - const entityId = await createDrawing(point, drawing); - if (!current()) { - dispose([{ ids: [entityId] }]); - return false; - } - record.ids[index] = entityId; - if (drawing.shape === "text") updateLabel(entityId, drawing, record.markerShape); - liveIds = readLiveShapeIds(chart); - } - return true; - })().finally(() => { - record.restoring = null; - }); - return record.restoring; - } - function reconcile() { - if (suspended) return Promise.resolve(); - if (reconciliation) return reconciliation; - reconciliation = (async () => { - let liveIds = readLiveShapeIds(chart); - for (const [id, record] of [...records]) { - if (suspended || records.get(id) !== record || !isChartCurrent()) continue; - if (!record.restoring && record.ids.every((entityId) => liveIds.has(entityId))) continue; - await restoreCandidate(id, record, liveIds); - liveIds = readLiveShapeIds(chart); - } - })().finally(() => { - reconciliation = null; - }); - return reconciliation; + }); + const markers = () => [...records.values()].flatMap((record) => record.markers); + async function reconcile() { + if (!suspended) overlay.render(markers(), { isCurrent: isChartCurrent }); } function remove(id) { - const removals = []; - if (pending?.id === id) { - pending.controller.abort(); - removals.push(pending); - } + if (pending?.id === id) pending.controller.abort(); const record = records.get(id); - if (record) { - records.delete(id); - removals.push(record); - } - dispose(removals); + if (!record) return; + records.delete(id); + overlay.remove(record.markers.map((marker) => marker.id)); } function clear() { - if (pending) pending.controller.abort(); - const removals = [...records.values()]; + pending?.controller.abort(); records.clear(); - if (pending) removals.push(pending); - dispose(removals); + overlay.clear(); } async function renderCandidate(id, annotation, decisionAtMs) { if (suspended) return false; - const existing = records.get(id); - if (existing) return restoreCandidate(id, existing, readLiveShapeIds(chart)); + if (records.has(id)) return overlay.render(markers(), { isCurrent: isChartCurrent }); if (pending !== null) throw new Error("Compound chart rendering must be serial"); if (records.size >= maxCandidates) throw new Error("Compound chart capacity exceeded before eviction"); if (typeof id !== "string" || id.length === 0 || !Number.isSafeInteger(decisionAtMs) || decisionAtMs < 1) throw new Error("Compound chart candidate identity/time is invalid"); - const icon = ICONS[annotation.markerShape]; const labels = annotation.markerShape === "arrow_down" ? ["候选高", "High candidate"] : ["候选低", "Low candidate"]; - if (icon === void 0 || !labels.includes(annotation.markerLabel)) throw new Error("Compound chart direction/label is invalid"); - const operation = { id, markerShape: annotation.markerShape, controller: new AbortController(), ids: [] }; + if (!["arrow_down", "arrow_up"].includes(annotation.markerShape) || !labels.includes(annotation.markerLabel)) throw new Error("Compound chart direction/label is invalid"); + const operation = { id, controller: new AbortController() }; pending = operation; try { const base = await placement.wait(annotation, { signal: operation.controller.signal, gapPx: CANDLE_GAP_PX + ICON_SIZE_PX / 2 }); - if (!base || operation.controller.signal.aborted || !isChartCurrent()) return false; + if (!base || suspended || operation.controller.signal.aborted || !isChartCurrent()) return false; const group = `${base.time}/${annotation.markerShape}`; const occupied = new Set([...records.values()].filter((record) => record.group === group).map((record) => record.slot)); let slot = 0; @@ -2902,59 +3038,46 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, const sign = annotation.markerShape === "arrow_up" ? 1 : -1; const point = placement.shift(base, sign * slot * SLOT_STEP_PX); const labelPoint = placement.shift(point, sign > 0 ? 18 : -40); - const options = drawingOptions(annotation.markerColor); - const drawings = [ - [point, { ...options, shape: "icon", icon, overrides: { ...options.overrides, size: ICON_SIZE_PX } }], - [labelPoint, { ...options, shape: "text", text: markerLabel(annotation.markerShape), overrides: { ...options.overrides, fontsize: 12, bold: true, fillBackground: false, drawBorder: false } }] - ]; - operation.drawings = drawings; - for (const [drawingPoint, drawing] of drawings) { - const entityId = await createDrawing(drawingPoint, drawing); - operation.ids.push(entityId); - if (operation.controller.signal.aborted || !isChartCurrent()) { - dispose([operation]); - return false; + const style = { color: annotation.markerColor, direction: sign > 0 ? "bullish" : "bearish" }; + const candidateMarkers = [ + { + ...style, + id: `candidate:${id}:icon`, + ...point, + shape: annotation.markerShape, + size: ICON_SIZE_PX, + anchor: "center", + type: "compound-icon" + }, + { + ...style, + id: `candidate:${id}:label`, + ...labelPoint, + shape: "text", + size: 12, + anchor: "top", + type: "compound-label", + text: markerLabel(annotation.markerShape) } - if (drawing.shape === "text") updateLabel(entityId, drawing, annotation.markerShape); - } - records.set(id, { ids: operation.ids.splice(0), group, slot, decisionAtMs, markerShape: annotation.markerShape, drawings, restoring: null }); + ]; + if (!overlay.render([...markers(), ...candidateMarkers], { isCurrent: isChartCurrent })) return false; + records.set(id, { group, slot, decisionAtMs, markerShape: annotation.markerShape, markers: candidateMarkers }); return true; - } catch (error) { - try { - dispose([operation]); - } catch (cleanupError) { - throw new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); - } - throw error; } finally { - pending = null; - } - } - function updateLabel(entityId, drawing, shape) { - const text = markerLabel(shape); - drawing.text = text; - const entity = chart.getShapeById(entityId); - if (entity.getProperties().text !== text) { - entity.setProperties({ text }, false); - if (entity.getProperties().text !== text) throw new Error("Compound chart label did not match the selected language"); + if (pending === operation) pending = null; } } function setLocale(nextLocale) { t = createStrategy27Translator(nextLocale); - const liveIds = readLiveShapeIds(chart); - for (const record of [...records.values(), ...pending ? [pending] : []]) { - if (!record.drawings) continue; - const drawing = record.drawings[1][1]; - drawing.text = markerLabel(record.markerShape); - if (liveIds.has(record.ids[1])) updateLabel(record.ids[1], drawing, record.markerShape); + for (const record of records.values()) { + const label = record.markers[1]; + label.text = markerLabel(record.markerShape); + overlay.updateText(label.id, label.text); } } function suspend() { suspended = true; - if (pending) { - pending.controller.abort(); - dispose([pending]); - } + pending?.controller.abort(); } return Object.freeze({ setLocale, renderCandidate, reconcile, remove, clear, suspend, get size() { return records.size; @@ -3201,6 +3324,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, const gatewayBridge = installSignalGatewayBridge(page, { getValue: GM_getValue, gmXmlHttpRequest: GM_xmlhttpRequest }); function stopActive(resetReason) { if (!active) return; + active.intervalEvent.unsubscribe(active.intervalOwner, active.onIntervalChanged); active.controller.abort(); active.compound.stop(resetReason); active.lifecycle.reset(resetReason); @@ -3242,7 +3366,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, return retainedAtMs; } function failOrdinary(context, error) { - if (error.name === "AbortError" || active !== context || context.failed) return; + if (error.name === "AbortError" || active !== context || context.failed || context.intervalRevision !== 0) return; context.failed = true; context.controller.abort(); context.layer.suspend(); @@ -3262,7 +3386,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, } } async function renderGatewayResponse(context, response) { - if (active !== context || context.failed) return; + if (active !== context || context.failed || context.intervalRevision !== 0) return; context.panel.setOrdinaryConnection("connected"); pruneOrdinaryEvents(context); if (response.status === "reset") { @@ -3292,7 +3416,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, messages = [...bySequence.values()].sort((left, right) => left.sequence - right.sequence); } for (const message of messages) { - if (active !== context || context.failed) return; + if (active !== context || context.failed || context.intervalRevision !== 0) return; const action = context.lifecycle.apply(message); if (action.type === "stream_reset") { context.panel.retainHistory(); @@ -3319,11 +3443,11 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, event_outcome: "renderOutcome" }[action.messageKind]; const rendered = await context.layer[renderMethod](action.eventId, annotation, retainedAtMs); - if (!rendered || active !== context || context.failed || !context.ordinaryHistory.has(action.eventId)) continue; + if (!rendered || active !== context || context.failed || context.intervalRevision !== 0 || !context.ordinaryHistory.has(action.eventId)) continue; context.panel.upsert(action.eventId, localizeAnnotation(annotation, context.locale), retainedAtMs); hideStatus(); } - if (response.status === "bootstrap") { + if (response.status === "bootstrap" && active === context && context.intervalRevision === 0) { context.lifecycle.finishBootstrap(response.last_sequence); hideStatus(); } @@ -3342,7 +3466,8 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, }), layer: createTradingViewEventLayer(target, { maxEvents: MAX_RETAINED_EVENTS, - maxAgeMs: MAX_EVENT_AGE_MS + maxAgeMs: MAX_EVENT_AGE_MS, + onRenderError: (error) => failOrdinary(context, error) }), panel: createStrategy27EventPanel(pageDocument, target.chartRoot, { locale: uiLocale, @@ -3354,6 +3479,10 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, candidatePresentations: /* @__PURE__ */ new Map(), ordinaryHistory: /* @__PURE__ */ new Map(), reconciliation: null, + intervalOwner: {}, + intervalEvent: target.chart.onIntervalChanged(), + intervalRevision: 0, + onIntervalChanged: null, failed: false }; active = context; @@ -3364,11 +3493,22 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, authSecret, canonicalSymbol, panel: context.panel, - isCurrent: () => active === context, + isCurrent: () => active === context && context.intervalRevision === 0, maxCandidates: MAX_RETAINED_EVENTS, maxAgeMs: MAX_EVENT_AGE_MS, - createLayer: () => createTradingViewCompoundLayer(target, { maxCandidates: MAX_RETAINED_EVENTS, locale: context.locale }) + createLayer: ({ onRenderError }) => createTradingViewCompoundLayer(target, { + maxCandidates: MAX_RETAINED_EVENTS, + locale: context.locale, + onRenderError + }) }); + context.onIntervalChanged = () => { + context.intervalRevision += 1; + context.controller.abort(); + context.layer.suspend(); + context.compound.stop("interval_changed"); + }; + context.intervalEvent.subscribe(context.intervalOwner, context.onIntervalChanged); void context.compound.run(); showStatus(target.chartRoot, (locale) => createStrategy27Translator(locale)("Strategy 27 正在连接", "Strategy 27 connecting")); const client = createLiveEventClient({ @@ -3377,7 +3517,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, authSecret, canonicalSymbol, onConnectionStateChange: (state) => { - if (active !== context || context.failed) return; + if (active !== context || context.failed || context.intervalRevision !== 0) return; context.panel.setOrdinaryConnection(state); if (state === "reconnecting") { showStatus(context.target.chartRoot, (locale) => createStrategy27Translator(locale)("Strategy 27 网关连接中断,正在重连", "Strategy 27 gateway disconnected; reconnecting"), "inactive"); @@ -3443,7 +3583,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, showStatus(chartRoot, (locale) => createStrategy27Translator(locale)("Strategy 27 正在等待图表接口", "Strategy 27 waiting for the chart interface"), "inactive"); return; } - if (active && active.routeSymbol === routeSymbol && active.target.chart === target.chart && active.target.chartRoot === target.chartRoot) { + if (active && active.intervalRevision === 0 && active.routeSymbol === routeSymbol && active.target.chart === target.chart && active.target.chartRoot === target.chartRoot) { reconcileOrdinary(active); void active.compound.reconcile(); return; diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index b7580b8..c3f2d11 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -3,9 +3,9 @@ // @namespace binance.strategy29.bollinger // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.5.7 +// @version 0.5.8 // @author jackhai9 -// @description Native Bollinger/SMA60 markers and the default read-only cross-timeframe summary +// @description Bollinger/SMA60 overlay markers and the default read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* // @match https://www.binance.com/futures/* // @exclude https://www.binance.com/*/my/wallet/futures/* @@ -318,6 +318,283 @@ ); } + // src/shared/chart-marker-overlay.js + function createChartMarkerOverlay(target, { + maxMarkers, + canMutate = () => true, + onRenderError + } = {}) { + if (!Number.isSafeInteger(maxMarkers) || maxMarkers < 1) throw new Error("TradingView overlay marker limit is invalid"); + const { chart } = target; + const document = target.chartRoot.ownerDocument; + const owner = {}; + const subscriptions = []; + const nodes = /* @__PURE__ */ new Map(); + let svg = null, host = null, frame = null, observer = null, projection = null; + let signals = [], validateCurrent = null, invalidated = false; + let renderedFrames = 0, visibleMarkers = 0, generation = 0; + function hide() { + if (svg && svg.style.visibility !== "hidden") svg.style.visibility = "hidden"; + visibleMarkers = 0; + } + function cancelFrame() { + if (frame !== null) host.ownerDocument.defaultView.cancelAnimationFrame(frame); + frame = null; + } + function clear() { + generation += 1; + hide(); + cancelFrame(); + observer?.disconnect(); + observer = null; + for (const [event, callback] of subscriptions) event.unsubscribe(owner, callback); + subscriptions.length = 0; + document.removeEventListener("visibilitychange", visibilityChanged); + svg?.remove(); + svg = null; + host = null; + projection = null; + signals = []; + nodes.clear(); + validateCurrent = null; + invalidated = false; + return true; + } + function readProjection() { + const widget = chart._chartWidget; + const model = widget.model().model(); + const series = model.mainSeries(); + const time = model.timeScale(); + const price = series.priceScale(); + const pane = widget.paneByState(model.paneForSource(series)); + const container = pane.canvasElement().parentElement; + if (!container || !container.classList.contains("chart-gui-wrapper")) { + throw new Error("TradingView marker main-pane container is unavailable"); + } + return { model, series, time, price, pane, container }; + } + function current() { + return !invalidated && validateCurrent() && !document.hidden && target.chartRoot.isConnected && chart.hasModel() && chart.dataReady() && target.tradingViewApi.activeChart() === chart && chart.resolution() === target.resolution && String(chart.symbol()).split("@", 1)[0] === target.routeSymbol; + } + function draw() { + if (!current() || !canMutate()) { + hide(); + return false; + } + const next = readProjection(); + for (const key of ["model", "series", "time", "price", "pane", "container"]) { + if (next[key] !== projection[key]) { + throw new Error(`TradingView marker projection changed: ${key}`); + } + } + if (!host.isConnected) throw new Error("TradingView marker pane is detached"); + const { width, height } = host.getBoundingClientRect(); + if (!Number.isFinite(width) || !Number.isFinite(height)) { + throw new Error("TradingView marker pane dimensions are invalid"); + } + const projected = []; + const visibleIds = /* @__PURE__ */ new Set(); + const { series, time, price } = projection; + const data = chart.getSeries().data(); + for (const signal of signals) { + const index = time.timePointToIndex(signal.time, 0); + if (index === null) continue; + if (!Number.isFinite(index)) throw new Error("TradingView marker bar index is invalid"); + const row = data.valueAt(index); + if (!row || row[0] !== signal.time) continue; + const x = time.indexToCoordinate(index); + if (!Number.isFinite(x)) throw new Error("TradingView marker coordinates are invalid"); + if (width <= 0 || height <= 0 || x < 0 || x > width) continue; + const y = price.priceToCoordinate(signal.price, series.firstValue()); + if (!Number.isFinite(y)) { + throw new Error("TradingView marker coordinates are invalid"); + } + if (y < 0 || y > height) continue; + projected.push({ signal, x, y }); + visibleIds.add(signal.id); + } + function setAttribute(node, name, value) { + if (node.getAttribute(name) !== value) node.setAttribute(name, value); + } + setAttribute(svg, "width", String(width)); + setAttribute(svg, "height", String(height)); + setAttribute(svg, "viewBox", `0 0 ${width} ${height}`); + for (const [id, node] of nodes) { + if (!visibleIds.has(id)) { + node.remove(); + nodes.delete(id); + } + } + for (const { signal, x, y } of projected) { + const tag = signal.shape === "circle" ? "circle" : signal.shape === "text" ? "text" : "path"; + let node = nodes.get(signal.id); + if (node && node.localName !== tag) { + node.remove(); + nodes.delete(signal.id); + node = null; + } + if (!node) { + node = host.ownerDocument.createElementNS("http://www.w3.org/2000/svg", tag); + nodes.set(signal.id, node); + svg.append(node); + } + setAttribute(node, "data-marker-id", signal.id); + setAttribute(node, "data-marker-type", signal.type); + setAttribute(node, "data-marker-direction", signal.direction); + setAttribute(node, "transform", `translate(${x} ${y})`); + setAttribute(node, "fill", signal.color); + if (signal.shape === "circle") setAttribute(node, "r", String(signal.size / 2)); + else if (signal.shape === "text") { + setAttribute(node, "font-size", String(signal.size)); + setAttribute(node, "font-weight", "700"); + setAttribute(node, "font-family", "Arial, sans-serif"); + setAttribute(node, "text-anchor", "middle"); + setAttribute(node, "dominant-baseline", "hanging"); + if (node.textContent !== signal.text) node.textContent = signal.text; + } else { + setAttribute(node, "d", signal.pathData); + } + } + if (svg.style.visibility !== "visible") svg.style.visibility = "visible"; + visibleMarkers = projected.length; + renderedFrames += 1; + return true; + } + function schedule() { + if (!svg || frame !== null || invalidated || document.hidden) return; + const scheduledGeneration = generation; + frame = host.ownerDocument.defaultView.requestAnimationFrame(() => { + if (!svg || scheduledGeneration !== generation) return; + frame = null; + try { + draw(); + } catch (error) { + clear(); + if (onRenderError) onRenderError(error); + else throw error; + } + }); + } + function invalidate() { + invalidated = true; + hide(); + cancelFrame(); + } + function dataChanged() { + hide(); + schedule(); + } + function visibilityChanged() { + if (document.hidden) { + hide(); + cancelFrame(); + } else schedule(); + } + function subscribe(event, callback) { + event.subscribe(owner, callback); + subscriptions.push([event, callback]); + } + function install() { + projection = readProjection(); + host = projection.container; + svg = host.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("data-strategy-marker-overlay", ""); + svg.setAttribute("aria-hidden", "true"); + svg.style.cssText = "position:absolute;inset:0;pointer-events:none;overflow:hidden;visibility:hidden"; + host.append(svg); + const { series, time, price } = projection; + for (const event of [ + time.logicalRangeChanged(), + time.barSpacingChanged(), + time.rightOffsetChanged(), + price.priceRangeChanged(), + price.modeChanged(), + price.internalHeightChanged() + ]) { + subscribe(event, schedule); + } + subscribe(series.dataUpdated(), dataChanged); + subscribe(chart.onIntervalChanged(), invalidate); + subscribe(chart.onDataLoaded(), dataChanged); + observer = new host.ownerDocument.defaultView.ResizeObserver(schedule); + observer.observe(host); + document.addEventListener("visibilitychange", visibilityChanged); + } + return Object.freeze({ + render(nextSignals, { isCurrent }) { + try { + if (!Array.isArray(nextSignals) || nextSignals.length > maxMarkers) { + throw new Error("TradingView overlay marker collection is invalid"); + } + if (typeof isCurrent !== "function") throw new Error("TradingView overlay current-target validator is unavailable"); + const ids = /* @__PURE__ */ new Set(); + for (const marker of nextSignals) { + if (!marker || typeof marker.id !== "string" || !marker.id || ids.has(marker.id) || !Number.isInteger(marker.time) || !Number.isFinite(marker.price) || !["circle", "arrow_up", "arrow_down", "text"].includes(marker.shape) || typeof marker.color !== "string" || !/^#[0-9a-f]{6}$/i.test(marker.color) || !Number.isFinite(marker.size) || marker.size <= 0 || typeof marker.type !== "string" || !marker.type || !["bearish", "bullish"].includes(marker.direction)) { + throw new Error("TradingView overlay marker contract is invalid"); + } + if (marker.shape === "text" ? marker.anchor !== "top" || typeof marker.text !== "string" || !marker.text : marker.shape === "circle" ? marker.anchor !== "center" : !["tip", "center"].includes(marker.anchor)) { + throw new Error("TradingView overlay marker style is invalid"); + } + ids.add(marker.id); + } + validateCurrent = isCurrent; + if (!current() || !canMutate()) { + hide(); + return false; + } + if (!svg) install(); + signals = nextSignals.map((marker) => { + if (marker.shape !== "arrow_up" && marker.shape !== "arrow_down") return { ...marker }; + const sign = marker.shape === "arrow_up" ? 1 : -1; + const scale = marker.size / 18; + const centerShift = marker.anchor === "center" ? -sign * marker.size / 2 : 0; + const points = [[0, 0], [-6, 8], [-2, 8], [-2, 18], [2, 18], [2, 8], [6, 8]]; + const path = points.map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x * scale} ${sign * y * scale + centerShift}`).join(" "); + return { ...marker, pathData: `${path} Z` }; + }); + return draw(); + } catch (error) { + clear(); + throw error; + } + }, + clear, + updateText(id, text) { + if (typeof id !== "string" || typeof text !== "string" || !text) throw new Error("TradingView overlay text update is invalid"); + const marker = signals.find((candidate) => candidate.id === id); + if (!marker) return false; + if (marker.shape !== "text") throw new Error("TradingView overlay text target is invalid"); + marker.text = text; + const node = nodes.get(id); + if (node && node.textContent !== text) node.textContent = text; + return true; + }, + remove(ids) { + if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string")) throw new Error("TradingView overlay removal IDs are invalid"); + const removed = new Set(ids); + signals = signals.filter((signal) => !removed.has(signal.id)); + for (const id of removed) { + nodes.get(id)?.remove(); + nodes.delete(id); + } + visibleMarkers = svg?.style.visibility === "visible" ? nodes.size : 0; + }, + get size() { + return signals.length; + }, + get overlayStats() { + return { + renderedFrames, + visibleMarkers, + signalCount: signals.length, + attached: svg !== null, + pendingFrame: frame !== null, + subscriptions: subscriptions.length + }; + } + }); + } + // src/shared/tradingview-target.js var CHART_ROOT_SELECTOR = ".chart-widget-root"; function hasVisibleBox(element2) { @@ -340,201 +617,6 @@ 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; @@ -543,15 +625,11 @@ } function assertChartContract(chart) { for (const method of [ - "createShape", "dataReady", "exportData", - "getAllShapes", - "getShapeById", "hasModel", "onDataLoaded", "onIntervalChanged", - "removeEntity", "resolution", "symbol" ]) { @@ -560,20 +638,6 @@ } } } - 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 = [ @@ -591,42 +655,6 @@ } 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(); @@ -851,78 +879,6 @@ 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`); @@ -936,151 +892,62 @@ } 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); - } + function createMarkerLayer(target, defaultDirection, options) { + const overlay = createChartMarkerOverlay(target, { ...options, maxMarkers: MAX_BOLLINGER_MARKERS }); 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; + async render(nextSignals, { isCurrent }) { + try { + if (!Array.isArray(nextSignals)) throw new Error("TradingView Bollinger alert signals are invalid"); + if (nextSignals.length > MAX_BOLLINGER_MARKERS) { + throw new Error(`TradingView Bollinger alert marker limit exceeded: ${nextSignals.length}`); } - 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); + if (typeof isCurrent !== "function") { + throw new Error("TradingView Bollinger alert current-target validator is unavailable"); } - 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"); + const normalized = nextSignals.map((signal, index) => normalizeSignal(signal, index, defaultDirection)); + const counts = { bearish: 0, bullish: 0 }, ids = /* @__PURE__ */ new Set(); + for (const signal of normalized) { + if (!Number.isInteger(signal.time) || !Number.isFinite(signal.markerPrice)) { + throw new Error("TradingView Bollinger alert signal point is invalid"); } - pendingMarkers.add(markerId); - if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; - const shape = chart.getShapeById(markerId); - const point = readMarkerPoint(shape); - if (point.time !== signal.time) { - throw new Error(`TradingView Bollinger alert time alignment failed: expected ${signal.time}, received ${point.time}`); + if (!["warning", "confirmed", "reversal"].includes(signal.type)) { + throw new Error(`TradingView Bollinger alert signal type is invalid: ${signal.type}`); } - 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"); + if (ids.has(signal.id)) throw new Error(`TradingView Bollinger alert duplicate signal id: ${signal.id}`); + ids.add(signal.id); + counts[signal.direction] += 1; + if (counts[signal.direction] > MAX_BOLLINGER_MARKERS_PER_DIRECTION) { + throw new Error(`TradingView Bollinger alert ${signal.direction} marker limit exceeded: ${counts[signal.direction]}`); } - registry.set(signal.id, { - markerId, - resolvedPrice: point.price, - markerPrice: signal.markerPrice, + } + const markers = normalized.map((signal) => { + const bullish = signal.direction === "bullish"; + const up = signal.type === "reversal" ? !bullish : bullish; + const circle = signal.type === "warning"; + return { + id: signal.id, + time: signal.time, + price: signal.markerPrice, + shape: circle ? "circle" : up ? "arrow_up" : "arrow_down", + color: (circle ? bullish : up) ? "#0ECB81" : "#F6465D", + size: circle ? 10 : 18, + anchor: circle ? "center" : "tip", type: signal.type, direction: signal.direction - }); - pendingMarkers.delete(markerId); - } finally { - finishCreation(); - creating -= 1; - removePendingMarkers(); - } + }; + }); + return overlay.render(markers, { isCurrent }); + } catch (error) { + overlay.clear(); + throw error; } - 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; }, + clear: overlay.clear, get size() { - return registry.size; + return overlay.size; }, - get saveStats() { - return saveController.getStats(); + get overlayStats() { + return overlay.overlayStats; } }); } @@ -1110,7 +977,6 @@ return clearRetiredBollingerLayers(); } function clearRetiredBollingerLayers() { - if (isTradingViewDrawingMutationBusy()) return false; for (const layer of retiredBollingerLayers) { if (layer.clear()) retiredBollingerLayers.delete(layer); } @@ -1123,7 +989,7 @@ } } 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); + return bearishBollingerAlertContext === context && !context.failed && 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; @@ -1156,7 +1022,7 @@ if (!contextMatches) { if (!clearBearishBollingerAlertContext()) return; if (!intervalSession.isCurrent(intervalSession.revision) || isTradingViewDrawingMutationBusy()) return; - bearishBollingerAlertContext = { + const nextContext = { routeSymbol, resolution: target.resolution, intervalSession, @@ -1164,7 +1030,7 @@ target, layer: createBollingerMarkerLayer(target, { canMutate: () => !isTradingViewDrawingMutationBusy(), - onSaveError: (error) => err("Bollinger chart save failed:", error) + onRenderError: (error) => handleFailure(nextContext, error, "render") }), failed: false, cleanupPending: false, @@ -1172,6 +1038,7 @@ lastProcessedClosedBarsContentSnapshot: null, lastProcessedSignals: null }; + bearishBollingerAlertContext = nextContext; } if (isTradingViewDrawingMutationBusy() || !clearRetiredBollingerLayers()) return; const context = bearishBollingerAlertContext; @@ -1213,49 +1080,52 @@ } })(); bearishBollingerAlertTask = task; - task.catch((error) => { - if (bearishBollingerAlertContext !== context || context.intervalSession !== bollingerIntervalSession?.session || context.intervalRevision !== context.intervalSession.revision) return; - let failureKind; - let classificationFailed = false; + task.catch((error) => handleFailure(context, error, stage)).finally(() => { + if (bearishBollingerAlertTask === task) bearishBollingerAlertTask = null; + }); + } + function handleFailure(context, error, stage) { + if (bearishBollingerAlertContext !== context || context.intervalSession !== bollingerIntervalSession?.session || context.intervalRevision !== context.intervalSession.revision) return; + let failureKind; + let classificationFailed = false; + try { + failureKind = applyBollingerAlertTaskFailure(context, error); + } catch { + classificationFailed = true; + context.failed = true; + context.cleanupPending = true; + failureKind = "fatal"; + } + if (failureKind === "retry") { + warn("布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:", error); + return; + } + const details = { name: null, message: null }; + const unreadableFields = []; + for (const [key, limit] of [["name", 64], ["message", 512]]) { try { - failureKind = applyBollingerAlertTaskFailure(context, error); + const value = key === "message" && typeof error === "string" ? error : error?.[key]; + details[key] = typeof value === "string" ? value.slice(0, limit) : null; } catch { - classificationFailed = true; - context.failed = true; - context.cleanupPending = true; - failureKind = "fatal"; + unreadableFields.push(key); } - if (failureKind === "retry") { - warn("布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:", error); - return; - } - const details = { name: null, message: null }; - const unreadableFields = []; - for (const [key, limit] of [["name", 64], ["message", 512]]) { - try { - const value = key === "message" && typeof error === "string" ? error : error?.[key]; - details[key] = typeof value === "string" ? value.slice(0, limit) : null; - } catch { - unreadableFields.push(key); - } - } - lastLocalFailure = Object.freeze({ - thrownType: error === null ? "null" : typeof error, - classificationFailed, - ...details, - unreadableFields: Object.freeze(unreadableFields), - stage, - routeSymbol: context.routeSymbol, - resolution: context.resolution, - cachedSignalCount: context.lastProcessedSignals === null ? null : context.lastProcessedSignals.length, - layerSizeBeforeCleanup: context.layer.size, - sessionRevision: context.intervalSession.revision, - contextIntervalRevision: context.intervalRevision - }); - err("布林带形态预警已停止:", error); - }).finally(() => { - if (bearishBollingerAlertTask === task) bearishBollingerAlertTask = null; + } + lastLocalFailure = Object.freeze({ + thrownType: error === null ? "null" : typeof error, + classificationFailed, + ...details, + unreadableFields: Object.freeze(unreadableFields), + stage, + routeSymbol: context.routeSymbol, + resolution: context.resolution, + cachedSignalCount: context.lastProcessedSignals === null ? null : context.lastProcessedSignals.length, + layerSizeBeforeCleanup: context.layer.size, + sessionRevision: context.intervalSession.revision, + contextIntervalRevision: context.intervalRevision }); + context.layer.clear(); + context.cleanupPending = false; + err("布林带形态预警已停止:", error); } function stopBearishBollingerAlertMonitor() { disposeBollingerIntervalSession(); @@ -1274,7 +1144,7 @@ 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, + markerOverlayStats: context ? context.layer.overlayStats : null, retiredCount: retiredBollingerLayers.size, sessionPresent: session !== null, sessionRevision: session ? session.revision : null, diff --git a/scripts/binance-strategy31-volume-reversal.user.js b/scripts/binance-strategy31-volume-reversal.user.js index fdd79b6..bb70955 100644 --- a/scripts/binance-strategy31-volume-reversal.user.js +++ b/scripts/binance-strategy31-volume-reversal.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy31.volume-reversal // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.1.1 +// @version 0.1.2 // @author jackhai9 // @description Confirmed red-to-green volume signals from CorsairQuant // @match https://www.binance.com/*/futures/* @@ -166,199 +166,281 @@ } }; - // 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 + // src/shared/chart-marker-overlay.js + function createChartMarkerOverlay(target, { + maxMarkers, + canMutate = () => true, + onRenderError } = {}) { - 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); + if (!Number.isSafeInteger(maxMarkers) || maxMarkers < 1) throw new Error("TradingView overlay marker limit is invalid"); + const { chart } = target; + const document = target.chartRoot.ownerDocument; + const owner = {}; + const subscriptions = []; + const nodes = /* @__PURE__ */ new Map(); + let svg = null, host = null, frame = null, observer = null, projection = null; + let signals = [], validateCurrent = null, invalidated = false; + let renderedFrames = 0, visibleMarkers = 0, generation = 0; + function hide() { + if (svg && svg.style.visibility !== "hidden") svg.style.visibility = "hidden"; + visibleMarkers = 0; + } + function cancelFrame() { + if (frame !== null) host.ownerDocument.defaultView.cancelAnimationFrame(frame); + frame = null; + } + function clear() { + generation += 1; + hide(); + cancelFrame(); + observer?.disconnect(); + observer = null; + for (const [event, callback] of subscriptions) event.unsubscribe(owner, callback); + subscriptions.length = 0; + document.removeEventListener("visibilitychange", visibilityChanged); + svg?.remove(); + svg = null; + host = null; + projection = null; + signals = []; + nodes.clear(); + validateCurrent = null; + invalidated = false; + return true; + } + function readProjection() { + const widget = chart._chartWidget; + const model = widget.model().model(); + const series = model.mainSeries(); + const time = model.timeScale(); + const price = series.priceScale(); + const pane = widget.paneByState(model.paneForSource(series)); + const container = pane.canvasElement().parentElement; + if (!container || !container.classList.contains("chart-gui-wrapper")) { + throw new Error("TradingView marker main-pane container is unavailable"); } + return { model, series, time, price, pane, container }; } - 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 current() { + return !invalidated && validateCurrent() && !document.hidden && target.chartRoot.isConnected && chart.hasModel() && chart.dataReady() && target.tradingViewApi.activeChart() === chart && chart.resolution() === target.resolution && String(chart.symbol()).split("@", 1)[0] === target.routeSymbol; } - 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); + function draw() { + if (!current() || !canMutate()) { + hide(); + return false; } - if (!burst) return originalSaveChart.apply(this, args); - saveRequests += 1; - burst.callbacks.push(args[0]); - scheduleQuiet(); - return void 0; - } - api.saveChart = markerSaveChart; - if (api.saveChart !== markerSaveChart) { - throw new Error("TradingView marker save wrapper could not be installed"); - } - 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"); + const next = readProjection(); + for (const key of ["model", "series", "time", "price", "pane", "container"]) { + if (next[key] !== projection[key]) { + throw new Error(`TradingView marker projection changed: ${key}`); } - 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; + } + if (!host.isConnected) throw new Error("TradingView marker pane is detached"); + const { width, height } = host.getBoundingClientRect(); + if (!Number.isFinite(width) || !Number.isFinite(height)) { + throw new Error("TradingView marker pane dimensions are invalid"); + } + const projected = []; + const visibleIds = /* @__PURE__ */ new Set(); + const { series, time, price } = projection; + const data = chart.getSeries().data(); + for (const signal of signals) { + const index = time.timePointToIndex(signal.time, 0); + if (index === null) continue; + if (!Number.isFinite(index)) throw new Error("TradingView marker bar index is invalid"); + const row = data.valueAt(index); + if (!row || row[0] !== signal.time) continue; + const x = time.indexToCoordinate(index); + if (!Number.isFinite(x)) throw new Error("TradingView marker coordinates are invalid"); + if (width <= 0 || height <= 0 || x < 0 || x > width) continue; + const y = price.priceToCoordinate(signal.price, series.firstValue()); + if (!Number.isFinite(y)) { + throw new Error("TradingView marker coordinates are invalid"); + } + if (y < 0 || y > height) continue; + projected.push({ signal, x, y }); + visibleIds.add(signal.id); + } + function setAttribute(node, name, value) { + if (node.getAttribute(name) !== value) node.setAttribute(name, value); + } + setAttribute(svg, "width", String(width)); + setAttribute(svg, "height", String(height)); + setAttribute(svg, "viewBox", `0 0 ${width} ${height}`); + for (const [id, node] of nodes) { + if (!visibleIds.has(id)) { + node.remove(); + nodes.delete(id); + } + } + for (const { signal, x, y } of projected) { + const tag = signal.shape === "circle" ? "circle" : signal.shape === "text" ? "text" : "path"; + let node = nodes.get(signal.id); + if (node && node.localName !== tag) { + node.remove(); + nodes.delete(signal.id); + node = null; + } + if (!node) { + node = host.ownerDocument.createElementNS("http://www.w3.org/2000/svg", tag); + nodes.set(signal.id, node); + svg.append(node); + } + setAttribute(node, "data-marker-id", signal.id); + setAttribute(node, "data-marker-type", signal.type); + setAttribute(node, "data-marker-direction", signal.direction); + setAttribute(node, "transform", `translate(${x} ${y})`); + setAttribute(node, "fill", signal.color); + if (signal.shape === "circle") setAttribute(node, "r", String(signal.size / 2)); + else if (signal.shape === "text") { + setAttribute(node, "font-size", String(signal.size)); + setAttribute(node, "font-weight", "700"); + setAttribute(node, "font-family", "Arial, sans-serif"); + setAttribute(node, "text-anchor", "middle"); + setAttribute(node, "dominant-baseline", "hanging"); + if (node.textContent !== signal.text) node.textContent = signal.text; + } else { + setAttribute(node, "d", signal.pathData); + } + } + if (svg.style.visibility !== "visible") svg.style.visibility = "visible"; + visibleMarkers = projected.length; + renderedFrames += 1; + return true; + } + function schedule() { + if (!svg || frame !== null || invalidated || document.hidden) return; + const scheduledGeneration = generation; + frame = host.ownerDocument.defaultView.requestAnimationFrame(() => { + if (!svg || scheduledGeneration !== generation) return; + frame = 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); + draw(); + } catch (error) { + clear(); + if (onRenderError) onRenderError(error); + else throw error; + } + }); + } + function invalidate() { + invalidated = true; + hide(); + cancelFrame(); + } + function dataChanged() { + hide(); + schedule(); + } + function visibilityChanged() { + if (document.hidden) { + hide(); + cancelFrame(); + } else schedule(); + } + function subscribe(event, callback) { + event.subscribe(owner, callback); + subscriptions.push([event, callback]); + } + function install() { + projection = readProjection(); + host = projection.container; + svg = host.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("data-strategy-marker-overlay", ""); + svg.setAttribute("aria-hidden", "true"); + svg.style.cssText = "position:absolute;inset:0;pointer-events:none;overflow:hidden;visibility:hidden"; + host.append(svg); + const { series, time, price } = projection; + for (const event of [ + time.logicalRangeChanged(), + time.barSpacingChanged(), + time.rightOffsetChanged(), + price.priceRangeChanged(), + price.modeChanged(), + price.internalHeightChanged() + ]) { + subscribe(event, schedule); + } + subscribe(series.dataUpdated(), dataChanged); + subscribe(chart.onIntervalChanged(), invalidate); + subscribe(chart.onDataLoaded(), dataChanged); + observer = new host.ownerDocument.defaultView.ResizeObserver(schedule); + observer.observe(host); + document.addEventListener("visibilitychange", visibilityChanged); + } + return Object.freeze({ + render(nextSignals, { isCurrent }) { + try { + if (!Array.isArray(nextSignals) || nextSignals.length > maxMarkers) { + throw new Error("TradingView overlay marker collection is invalid"); + } + if (typeof isCurrent !== "function") throw new Error("TradingView overlay current-target validator is unavailable"); + const ids = /* @__PURE__ */ new Set(); + for (const marker of nextSignals) { + if (!marker || typeof marker.id !== "string" || !marker.id || ids.has(marker.id) || !Number.isInteger(marker.time) || !Number.isFinite(marker.price) || !["circle", "arrow_up", "arrow_down", "text"].includes(marker.shape) || typeof marker.color !== "string" || !/^#[0-9a-f]{6}$/i.test(marker.color) || !Number.isFinite(marker.size) || marker.size <= 0 || typeof marker.type !== "string" || !marker.type || !["bearish", "bullish"].includes(marker.direction)) { + throw new Error("TradingView overlay marker contract is invalid"); + } + if (marker.shape === "text" ? marker.anchor !== "top" || typeof marker.text !== "string" || !marker.text : marker.shape === "circle" ? marker.anchor !== "center" : !["tip", "center"].includes(marker.anchor)) { + throw new Error("TradingView overlay marker style is invalid"); + } + ids.add(marker.id); + } + validateCurrent = isCurrent; + if (!current() || !canMutate()) { + hide(); + return false; } - 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; + if (!svg) install(); + signals = nextSignals.map((marker) => { + if (marker.shape !== "arrow_up" && marker.shape !== "arrow_down") return { ...marker }; + const sign = marker.shape === "arrow_up" ? 1 : -1; + const scale = marker.size / 18; + const centerShift = marker.anchor === "center" ? -sign * marker.size / 2 : 0; + const points = [[0, 0], [-6, 8], [-2, 8], [-2, 18], [2, 18], [2, 8], [6, 8]]; + const path = points.map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x * scale} ${sign * y * scale + centerShift}`).join(" "); + return { ...marker, pathData: `${path} Z` }; + }); + return draw(); + } catch (error) { + clear(); + throw error; + } + }, + clear, + updateText(id, text) { + if (typeof id !== "string" || typeof text !== "string" || !text) throw new Error("TradingView overlay text update is invalid"); + const marker = signals.find((candidate) => candidate.id === id); + if (!marker) return false; + if (marker.shape !== "text") throw new Error("TradingView overlay text target is invalid"); + marker.text = text; + const node = nodes.get(id); + if (node && node.textContent !== text) node.textContent = text; + return true; + }, + remove(ids) { + if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string")) throw new Error("TradingView overlay removal IDs are invalid"); + const removed = new Set(ids); + signals = signals.filter((signal) => !removed.has(signal.id)); + for (const id of removed) { + nodes.get(id)?.remove(); + nodes.delete(id); } + visibleMarkers = svg?.style.visibility === "visible" ? nodes.size : 0; }, - getStats: () => ({ - busy: busy(), - mutations, - draining, - saveRequests, - serializations, - callbackCount, - failureCount, - pendingCallbacks: burst?.callbacks.length || 0 - }) + get size() { + return signals.length; + }, + get overlayStats() { + return { + renderedFrames, + visibleMarkers, + signalCount: signals.length, + attached: svg !== null, + pendingFrame: frame !== null, + subscriptions: subscriptions.length + }; + } }); - Object.defineProperty(api, CONTROLLER_SLOT, { value: Object.freeze({ version: PROTOCOL_VERSION, controller }) }); - return controller; } // src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js @@ -369,15 +451,11 @@ } function assertChartContract(chart) { for (const method of [ - "createShape", "dataReady", "exportData", - "getAllShapes", - "getShapeById", "hasModel", "onDataLoaded", "onIntervalChanged", - "removeEntity", "resolution", "symbol" ]) { @@ -386,20 +464,6 @@ } } } - 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 = [ @@ -417,42 +481,6 @@ } 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(); @@ -574,78 +602,6 @@ 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`); @@ -659,151 +615,62 @@ } 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); - } + function createMarkerLayer(target, defaultDirection, options) { + const overlay = createChartMarkerOverlay(target, { ...options, maxMarkers: MAX_BOLLINGER_MARKERS }); 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; + async render(nextSignals, { isCurrent }) { + try { + if (!Array.isArray(nextSignals)) throw new Error("TradingView Bollinger alert signals are invalid"); + if (nextSignals.length > MAX_BOLLINGER_MARKERS) { + throw new Error(`TradingView Bollinger alert marker limit exceeded: ${nextSignals.length}`); } - 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); + if (typeof isCurrent !== "function") { + throw new Error("TradingView Bollinger alert current-target validator is unavailable"); } - 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"); + const normalized = nextSignals.map((signal, index) => normalizeSignal(signal, index, defaultDirection)); + const counts = { bearish: 0, bullish: 0 }, ids = /* @__PURE__ */ new Set(); + for (const signal of normalized) { + if (!Number.isInteger(signal.time) || !Number.isFinite(signal.markerPrice)) { + throw new Error("TradingView Bollinger alert signal point is invalid"); } - pendingMarkers.add(markerId); - if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; - const shape = chart.getShapeById(markerId); - const point = readMarkerPoint(shape); - if (point.time !== signal.time) { - throw new Error(`TradingView Bollinger alert time alignment failed: expected ${signal.time}, received ${point.time}`); + if (!["warning", "confirmed", "reversal"].includes(signal.type)) { + throw new Error(`TradingView Bollinger alert signal type is invalid: ${signal.type}`); } - 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"); + if (ids.has(signal.id)) throw new Error(`TradingView Bollinger alert duplicate signal id: ${signal.id}`); + ids.add(signal.id); + counts[signal.direction] += 1; + if (counts[signal.direction] > MAX_BOLLINGER_MARKERS_PER_DIRECTION) { + throw new Error(`TradingView Bollinger alert ${signal.direction} marker limit exceeded: ${counts[signal.direction]}`); } - registry.set(signal.id, { - markerId, - resolvedPrice: point.price, - markerPrice: signal.markerPrice, + } + const markers = normalized.map((signal) => { + const bullish = signal.direction === "bullish"; + const up = signal.type === "reversal" ? !bullish : bullish; + const circle = signal.type === "warning"; + return { + id: signal.id, + time: signal.time, + price: signal.markerPrice, + shape: circle ? "circle" : up ? "arrow_up" : "arrow_down", + color: (circle ? bullish : up) ? "#0ECB81" : "#F6465D", + size: circle ? 10 : 18, + anchor: circle ? "center" : "tip", type: signal.type, direction: signal.direction - }); - pendingMarkers.delete(markerId); - } finally { - finishCreation(); - creating -= 1; - removePendingMarkers(); - } + }; + }); + return overlay.render(markers, { isCurrent }); + } catch (error) { + overlay.clear(); + throw error; } - 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; }, + clear: overlay.clear, get size() { - return registry.size; + return overlay.size; }, - get saveStats() { - return saveController.getStats(); + get overlayStats() { + return overlay.overlayStats; } }); } @@ -865,6 +732,7 @@ } function retire() { if (context) { + context.layer.clear(); retired.add(context.layer); context = null; } @@ -876,12 +744,11 @@ intervalOwner = null; } function cleanup() { - if (isChartMutationBlocked(view)) return; for (const layer of retired) if (layer.clear()) retired.delete(layer); if ((disposed || failed) && retired.size === 0) view.clearInterval(timer); } function current(candidate) { - return !disposed && !document.hidden && context === candidate && candidate.session.isCurrent(candidate.revision) && parseFuturesTradingSymbolFromPathname(view.location.pathname) === candidate.target.routeSymbol && isBearishBollingerChartTargetCurrent(document, candidate.target) && !isChartMutationBlocked(view); + return !disposed && !failed && !document.hidden && context === candidate && candidate.session.isCurrent(candidate.revision) && parseFuturesTradingSymbolFromPathname(view.location.pathname) === candidate.target.routeSymbol && isBearishBollingerChartTargetCurrent(document, candidate.target) && !isChartMutationBlocked(view); } async function sample() { cleanup(); @@ -937,7 +804,10 @@ target, session, revision: session.revision, - layer: createBollingerMarkerLayer(target, { canMutate: () => !isChartMutationBlocked(view) }) + layer: createBollingerMarkerLayer(target, { + canMutate: () => !isChartMutationBlocked(view), + onRenderError: stopAfterFailure + }) }; } const candidate = context; @@ -980,13 +850,14 @@ if (inflight === controller) inflight = null; } } + function stopAfterFailure() { + failed = true; + releaseChart(); + cleanup(); + if (!disposed) notice("Strategy31 stopped: invalid chart or signal data"); + } function tick() { - return sample().catch(() => { - failed = true; - releaseChart(); - cleanup(); - if (!disposed) notice("Strategy31 stopped: invalid chart or signal data"); - }); + return sample().catch(stopAfterFailure); } function visibility() { if (document.hidden) retire(); diff --git a/src/binance-orderbook-trade/core/chart-save-coalescer.js b/src/binance-orderbook-trade/core/chart-save-coalescer.js index b7c9472..b111a63 100644 --- a/src/binance-orderbook-trade/core/chart-save-coalescer.js +++ b/src/binance-orderbook-trade/core/chart-save-coalescer.js @@ -1,5 +1,30 @@ const IGNORED_DRAWING_EVENT_TYPES = new Set(['click', 'move']); +/** Only the observed default callback signature permits deferred completion. */ +function isDefaultSaveCall(api, receiver, args) { + return receiver === api && args.length >= 1 && args.length <= 2 + && typeof args[0] === 'function' && args[1] === undefined; +} + +/** + * The native serializer produces JSON chart state. Each accepted callback owns + * a separate copy; callback failures belong to a separate asynchronous job and + * must not interrupt another callback or an unrelated explicit save. + */ +function deliverSaveCallbacks(api, saveChart, callbacks, onCallbackError, setTimeoutFn) { + if (callbacks.length === 0) return; + saveChart.call(api, snapshot => { + const json = JSON.stringify(snapshot); + const errors = []; + for (const callback of callbacks) { + try { callback(JSON.parse(json)); } catch (error) { errors.push(error); } + } + if (errors.length) { + setTimeoutFn(() => onCallbackError(new AggregateError(errors, 'TradingView chart save callbacks failed')), 0); + } + }); +} + function validateTradingViewApi(api) { if (!api || typeof api !== 'object') { throw new Error('图表接口不可用'); @@ -37,8 +62,8 @@ function readTradingViewDrawingToolName(api, drawingId) { * Binance schedules a complete chart serialization 100ms after every broker * drawing event. A submit capture is armed by our own button click, but it does * not replace saveChart until the matching LineToolOrder event arrives. The - * wrapper is restored after that short event burst, while only the final - * cumulative submit snapshot is replayed at the end of the ladder round. + * wrapper is restored after that short event burst, while every accepted + * callback receives one final snapshot at the end of the ladder round. */ export function createTradingViewContinuousSaveController( api, @@ -49,6 +74,7 @@ export function createTradingViewContinuousSaveController( getDrawingToolName = readTradingViewDrawingToolName, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout, + onCallbackError = (error) => { throw error; }, } = {}, ) { validateTradingViewApi(api); @@ -115,26 +141,18 @@ export function createTradingViewContinuousSaveController( ); } try { - if (!burst.pendingSave) return undefined; - if (saveChartWasReplaced) { - if (activeRound?.pendingSave) activeRound.pendingSave = null; - fullSaveCount += 1; - return burst.originalSaveChart.apply( - burst.pendingSave.thisValue, - burst.pendingSave.args, - ); - } - if (burst.deferToRound && activeRound) { - activeRound.pendingSave = burst.pendingSave; + const callbacks = burst.callbacks; + burst.callbacks = []; + if (callbacks.length === 0) return undefined; + if (!saveChartWasReplaced && burst.deferToRound && activeRound) { + activeRound.callbacks.push(...callbacks); deferredSubmitSaveCount += 1; return undefined; } - if (activeRound?.pendingSave) activeRound.pendingSave = null; + const pending = activeRound ? activeRound.callbacks.splice(0) : []; + pending.push(...callbacks); fullSaveCount += 1; - return burst.originalSaveChart.apply( - burst.pendingSave.thisValue, - burst.pendingSave.args, - ); + return deliverSaveCallbacks(api, burst.originalSaveChart, pending, onCallbackError, setTimeoutFn); } finally { if (burst.submitCapture) { finishSubmitCapture( @@ -157,14 +175,21 @@ export function createTradingViewContinuousSaveController( deferToRound: false, originalDescriptor, originalSaveChart, - pendingSave: null, + callbacks: [], settleTimer: null, submitCapture: null, wrapper: null, }; burst.wrapper = function continuousSaveBurstWrapper(...args) { + // A foreign outer wrapper may retain this function after the burst ends. + if (activeBurst !== burst) return originalSaveChart.apply(this, args); saveRequestCount += 1; - burst.pendingSave = { thisValue: this, args }; + if (!isDefaultSaveCall(api, this, args)) { + flushActiveBurst(); + flushRoundPendingSave(); + return originalSaveChart.apply(this, args); + } + burst.callbacks.push(args[0]); return undefined; }; api.saveChart = burst.wrapper; @@ -176,11 +201,10 @@ export function createTradingViewContinuousSaveController( return burst; }; const flushRoundPendingSave = () => { - const pendingSave = activeRound?.pendingSave || null; - if (!pendingSave) return undefined; - activeRound.pendingSave = null; + if (!activeRound || activeRound.callbacks.length === 0) return undefined; + const callbacks = activeRound.callbacks.splice(0); fullSaveCount += 1; - return api.saveChart.apply(pendingSave.thisValue, pendingSave.args); + return deliverSaveCallbacks(api, sessionSaveChart, callbacks, onCallbackError, setTimeoutFn); }; const handleDrawingEvent = (drawingId, eventType) => { if (stopped || IGNORED_DRAWING_EVENT_TYPES.has(eventType)) return; @@ -226,7 +250,7 @@ export function createTradingViewContinuousSaveController( if (activeSubmitCapture) throw new Error('上一笔订单线捕获尚未结束'); flushActiveBurst(); sequence += 1; - activeRound = { id: sequence, pendingSave: null }; + activeRound = { id: sequence, callbacks: [] }; return activeRound; }, beginSubmitCapture(round) { @@ -271,11 +295,11 @@ export function createTradingViewContinuousSaveController( throw new Error('结束图表保存轮次时仍有订单线捕获'); } flushActiveBurst(); - const pendingSave = activeRound.pendingSave; + const callbacks = activeRound.callbacks; activeRound = null; - if (pendingSave) { + if (callbacks.length) { fullSaveCount += 1; - api.saveChart.apply(pendingSave.thisValue, pendingSave.args); + deliverSaveCallbacks(api, sessionSaveChart, callbacks, onCallbackError, setTimeoutFn); } return getStats(); }, @@ -310,7 +334,7 @@ export function createTradingViewContinuousSaveController( /** * A native bulk cancellation can emit many drawing removals over several short * bursts. Removal-triggered saves are held until the lifecycle finishes. Saves - * outside a burst still run synchronously and supersede older held snapshots. + * outside a burst flush accepted callbacks before running synchronously. */ export function createTradingViewRemovalSaveController( api, @@ -320,6 +344,7 @@ export function createTradingViewRemovalSaveController( eventDiscoveryMs = 250, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout, + onCallbackError = (error) => { throw error; }, } = {}, ) { validateTradingViewApi(api); @@ -340,7 +365,7 @@ export function createTradingViewRemovalSaveController( let discoveryTimer = null; let finished = false; let fullSaveCount = 0; - let pendingFinalSave = null; + let pendingFinalCallbacks = []; let removeEventCount = 0; let saveRequestCount = 0; let synchronousSaveCount = 0; @@ -355,9 +380,16 @@ export function createTradingViewRemovalSaveController( saveRequestCount, synchronousSaveCount, }); + const flushFinalCallbacks = () => { + if (pendingFinalCallbacks.length === 0) return; + const callbacks = pendingFinalCallbacks; + pendingFinalCallbacks = []; + fullSaveCount += 1; + deliverSaveCallbacks(api, sessionSaveChart, callbacks, onCallbackError, setTimeoutFn); + }; const monitoredSaveChart = function monitoredRemovalSessionSaveChart(...args) { synchronousSaveCount += 1; - pendingFinalSave = null; + flushFinalCallbacks(); return sessionSaveChart.apply(this, args); }; const clearBurstTimers = (burst) => { @@ -373,7 +405,8 @@ export function createTradingViewRemovalSaveController( activeBurst = null; if (api.saveChart !== burst.wrapper) { controllerError ||= new Error('图表保存接口在删除事件合并期间发生变化'); - pendingFinalSave = null; + pendingFinalCallbacks.push(...burst.callbacks); + burst.callbacks = []; burst.resolve(); return; } @@ -383,7 +416,8 @@ export function createTradingViewRemovalSaveController( burst.originalSaveChart, burst.originalDescriptor, ); - if (burst.pendingSave) pendingFinalSave = burst.pendingSave; + pendingFinalCallbacks.push(...burst.callbacks); + burst.callbacks = []; burst.resolve(); }; const scheduleBurstSettle = (burst) => { @@ -404,15 +438,21 @@ export function createTradingViewRemovalSaveController( maxWaitTimer: null, originalDescriptor: Object.getOwnPropertyDescriptor(api, 'saveChart'), originalSaveChart: api.saveChart, - pendingSave: null, + callbacks: [], resolve, settleTimer: null, settled, wrapper: null, }; burst.wrapper = function removalSaveBurstWrapper(...args) { + if (activeBurst !== burst) return sessionSaveChart.apply(this, args); saveRequestCount += 1; - burst.pendingSave = { thisValue: this, args }; + if (!isDefaultSaveCall(api, this, args)) { + finishBurst(); + flushFinalCallbacks(); + return monitoredSaveChart.apply(this, args); + } + burst.callbacks.push(args[0]); }; api.saveChart = burst.wrapper; if (api.saveChart !== burst.wrapper) { @@ -473,12 +513,8 @@ export function createTradingViewRemovalSaveController( ); } else { controllerError ||= new Error('图表保存接口在删除事件监视期间发生变化'); - pendingFinalSave = null; - } - if (pendingFinalSave) { - fullSaveCount += 1; - sessionSaveChart.apply(pendingFinalSave.thisValue, pendingFinalSave.args); } + flushFinalCallbacks(); if (controllerError) throw controllerError; return getStats(); }, @@ -487,8 +523,8 @@ export function createTradingViewRemovalSaveController( /** * Binance schedules one complete chart save for every broker drawing event. - * The last request contains the cumulative final state, so one burst can be - * persisted with only its final request after all matching events arrive. + * A burst shares the final serialization across every accepted default callback. + * Callback delivery and downstream persistence remain one per original request. */ export async function coalesceTradingViewDrawingSaves( api, @@ -499,6 +535,7 @@ export async function coalesceTradingViewDrawingSaves( timeoutMs = 1800, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout, + onCallbackError = (error) => { throw error; }, } = {}, ) { validateTradingViewApi(api); @@ -517,8 +554,11 @@ export async function coalesceTradingViewDrawingSaves( const originalDescriptor = Object.getOwnPropertyDescriptor(api, 'saveChart'); let drawingEventCount = 0; let saveRequestCount = 0; - let pendingSave = null; + let pendingCallbacks = []; + let fullSaveCount = 0; let actionFinished = false; + let interceptionFinished = false; + const failures = []; let eventStartResolve; let settleResolve; let settleReject; @@ -557,9 +597,30 @@ export async function coalesceTradingViewDrawingSaves( if (drawingEventCount === 1) eventStartResolve(); scheduleSettleIfReady(); }; + const flushPendingCallbacks = () => { + if (pendingCallbacks.length === 0) return; + const callbacks = pendingCallbacks; + pendingCallbacks = []; + fullSaveCount += 1; + deliverSaveCallbacks(api, originalSaveChart, callbacks, onCallbackError, setTimeoutFn); + }; const saveChartWrapper = function coalescedSaveChart(...args) { + if (interceptionFinished) return originalSaveChart.apply(this, args); saveRequestCount += 1; - pendingSave = { thisValue: this, args }; + if (!isDefaultSaveCall(api, this, args)) { + const ownsWrapper = api.saveChart === saveChartWrapper; + if (ownsWrapper) restoreSaveChartMethod(api, saveChartWrapper, originalSaveChart, originalDescriptor); + try { + flushPendingCallbacks(); + return originalSaveChart.apply(this, args); + } finally { + // Reentrant callbacks run against the original method; a foreign owner + // installed by one of them must never be overwritten on return. + if (ownsWrapper && api.saveChart === originalSaveChart) api.saveChart = saveChartWrapper; + scheduleSettleIfReady(); + } + } + pendingCallbacks.push(args[0]); scheduleSettleIfReady(); }; @@ -609,20 +670,33 @@ export async function coalesceTradingViewDrawingSaves( api.unsubscribe('drawing_event', handleDrawingEvent); subscribed = false; - if (pendingSave) { - originalSaveChart.apply(pendingSave.thisValue, pendingSave.args); - } return { actionResult, drawingEventCount, saveRequestCount, - fullSaveCount: pendingSave ? 1 : 0, + fullSaveCount: fullSaveCount + (pendingCallbacks.length > 0 ? 1 : 0), }; + } catch (error) { + failures.push(error); } finally { if (eventDiscoveryTimeout !== null) clearTimeoutFn(eventDiscoveryTimeout); if (settleQuietTimeout !== null) clearTimeoutFn(settleQuietTimeout); if (waitTimeout !== null) clearTimeoutFn(waitTimeout); if (subscribed) api.unsubscribe('drawing_event', handleDrawingEvent); - restoreSaveChartMethod(api, saveChartWrapper, originalSaveChart, originalDescriptor); + interceptionFinished = true; + // Accepted callbacks survive action errors, deadlines and external owners. + // Detach and restore before delivery so callback reentry cannot join its own batch. + const ownershipChanged = api.saveChart !== saveChartWrapper; + if (!ownershipChanged) { + try { + restoreSaveChartMethod(api, saveChartWrapper, originalSaveChart, originalDescriptor); + } catch (error) { failures.push(error); } + } + // Cleanup must attempt accepted callbacks once without replacing an earlier + // action/deadline failure when native serialization also fails. + try { flushPendingCallbacks(); } catch (error) { failures.push(error); } + if (ownershipChanged) failures.push(new Error('图表保存接口在操作期间发生变化')); + if (failures.length > 1) throw new AggregateError(failures, 'TradingView drawing action and save cleanup failed'); + if (failures.length === 1) throw failures[0]; } } diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 2711b7c..a9751ea 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.216 +// @version 2.7.217 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -589,8 +589,8 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js'; emit('ERR', ...args); } - // TradingView still emits drawing_event + saveChart when a disableSave marker is removed. - // Keep alert reconciliation outside every native order-line save/coalescing session. + // Signal overlays defer reconciliation while order automation owns the chart + // so chart exports and presentation work do not compete with active tasks. const unregisterChartMutationOwner = registerChartMutationOwner(window, 'orderbook', () => { return [ ladderTask, diff --git a/src/binance-strategy27-events/core/compound-candidate-controller.js b/src/binance-strategy27-events/core/compound-candidate-controller.js index 40a56a3..912ac01 100644 --- a/src/binance-strategy27-events/core/compound-candidate-controller.js +++ b/src/binance-strategy27-events/core/compound-candidate-controller.js @@ -8,7 +8,7 @@ import { CompoundCandidateLifecycle } from './compound-candidate-lifecycle.js'; * The lifecycle accepts immutable records before asynchronous drawing. A view * generation separately invalidates pending presentation on manual clear or * eviction without erasing sequence/replay bookkeeping. The chart layer must - * cancel pending owned entities on remove/clear/suspend, including late creates. + * cancel pending marker placement on remove/clear/suspend. * Polling failure suspends presentation; context retirement alone ends ownership * of the bounded history and its existing timer-driven age cleanup. */ @@ -62,7 +62,7 @@ export function createCompoundCandidateController({ for (const id of ids) { if (id === pendingCandidateId) viewGeneration += 1; // Prune has already retired every ID; attempt each cleanup once even if - // one native removal fails, then stop this job with the complete evidence. + // one overlay removal fails, then stop this job with the complete evidence. try { layer?.remove(id); } catch (error) { @@ -142,7 +142,7 @@ export function createCompoundCandidateController({ const id = action.candidate.candidate_id; const annotation = buildCompoundCandidateAnnotation(action.candidate, { locale: currentLocale }); // Optional chart capabilities are tested inside this job's error boundary. - if (layer === null) layer = createLayer(); + if (layer === null) layer = createLayer({ onRenderError: failJob }); const renderGeneration = viewGeneration; pendingCandidateId = id; try { diff --git a/src/binance-strategy27-events/dom/tradingview-compound-layer.js b/src/binance-strategy27-events/dom/tradingview-compound-layer.js index cb3cc23..363840f 100644 --- a/src/binance-strategy27-events/dom/tradingview-compound-layer.js +++ b/src/binance-strategy27-events/dom/tradingview-compound-layer.js @@ -1,219 +1,106 @@ import { createStrategy27Translator } from '../core/ui-copy.js'; -import { createAlignedShape, createTradingViewMarkerPlacement, pinMarkerChartContext, readLiveShapeIds } from './tradingview-event-layer.js'; +import { createChartMarkerOverlay } from '../../shared/chart-marker-overlay.js'; +import { createTradingViewMarkerPlacement, pinMarkerChartContext } from './tradingview-event-layer.js'; const ICON_SIZE_PX = 36; const CANDLE_GAP_PX = 8; const SLOT_STEP_PX = 64; -const ICONS = Object.freeze({ arrow_down: 0xf063, arrow_up: 0xf062 }); -function drawingOptions(color) { - return { - lock: true, disableSave: true, disableSelection: true, - disableUndo: true, showInObjectsTree: false, - overrides: { color }, - }; -} - -/** Two native entities per candidate; ordinary/user entity IDs never enter here. - * - * Native icon arrows support an explicit size and a centered anchor, unlike - * fixed-size arrow marks or font-dependent text glyphs. Slots belong to the - * resolved candle/side, not the decision timestamp: no-trade seconds can share - * a prior candle. Eviction frees a slot without repositioning any survivor. +/** + * Each immutable candidate owns one SVG arrow and label. Slots belong to its + * resolved candle/side; eviction frees a slot without moving surviving records. + * Pixel gaps are converted to prices once, preserving the existing placement contract. */ -export function createTradingViewCompoundLayer(target, { maxCandidates, candleWaitMs = 3000, locale = 'zh-CN' }) { +export function createTradingViewCompoundLayer(target, { + maxCandidates, candleWaitMs = 3000, locale = 'zh-CN', onRenderError, +}) { if (!Number.isSafeInteger(maxCandidates) || maxCandidates < 1 || maxCandidates > 80) throw new Error('Compound chart capacity must be 1..80'); let t = createStrategy27Translator(locale); - const markerLabel = (shape) => shape === 'arrow_down' ? t('候选高', 'High candidate') : t('候选低', 'Low candidate'); - const { chart } = target; - const placement = createTradingViewMarkerPlacement(chart, { candleWaitMs }); - const isChartCurrent = pinMarkerChartContext(chart); + const markerLabel = shape => shape === 'arrow_down' ? t('候选高', 'High candidate') : t('候选低', 'Low candidate'); + const placement = createTradingViewMarkerPlacement(target.chart, { candleWaitMs }); + const isChartCurrent = pinMarkerChartContext(target.chart); const records = new Map(); let pending = null; - let reconciliation = null; let suspended = false; + const overlay = createChartMarkerOverlay(target, { + maxMarkers: maxCandidates * 2, + onRenderError(error) { + suspend(); + if (onRenderError) onRenderError(error); + else throw error; + }, + }); + const markers = () => [...records.values()].flatMap(record => record.markers); - function dispose(recordsToRemove) { - const errors = []; - const liveIds = readLiveShapeIds(chart); - for (const record of recordsToRemove) { - // A thrown removal has an unknown outcome. Do not automatically retry it. - for (const id of record.ids.splice(0)) { - if (!liveIds.has(id)) continue; - try { - chart.removeEntity(id); - } catch (error) { - errors.push(error); - } - } - } - if (errors.length) throw new AggregateError(errors, `Compound chart cleanup failed: ${errors.map((error) => error.message).join('; ')}`); - } - - async function createDrawing(point, options) { - const drawing = { ...options }; - const entityId = await createAlignedShape(chart, point, drawing); - try { - const properties = chart.getShapeById(entityId).getProperties(); - const matched = properties.color === drawing.overrides.color && (drawing.shape === 'icon' - ? properties.icon === drawing.icon && properties.size === ICON_SIZE_PX - : properties.text === drawing.text && properties.fontsize === 12); - if (!matched) throw new Error('Compound chart drawing properties did not match the requested icon/label'); - } catch (error) { - try { - dispose([{ ids: [entityId] }]); - } catch (cleanupError) { - throw new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); - } - throw error; - } - return entityId; - } - - /** Retain slots and surviving parts; concurrent callers share one repair. */ - function restoreCandidate(id, record, liveIds) { - if (record.restoring) return record.restoring; - const current = () => !suspended && records.get(id) === record && isChartCurrent(); - if (!current()) return Promise.resolve(false); - if (record.ids.every((entityId) => liveIds.has(entityId))) return Promise.resolve(true); - record.restoring = (async () => { - for (let index = 0; index < record.drawings.length; index += 1) { - if (!current()) return false; - if (liveIds.has(record.ids[index])) continue; - const [point, drawing] = record.drawings[index]; - const entityId = await createDrawing(point, drawing); - if (!current()) { - dispose([{ ids: [entityId] }]); - return false; - } - record.ids[index] = entityId; - if (drawing.shape === 'text') updateLabel(entityId, drawing, record.markerShape); - liveIds = readLiveShapeIds(chart); - } - return true; - })().finally(() => { record.restoring = null; }); - return record.restoring; - } - - function reconcile() { - if (suspended) return Promise.resolve(); - if (reconciliation) return reconciliation; - reconciliation = (async () => { - let liveIds = readLiveShapeIds(chart); - for (const [id, record] of [...records]) { - if (suspended || records.get(id) !== record || !isChartCurrent()) continue; - if (!record.restoring && record.ids.every((entityId) => liveIds.has(entityId))) continue; - await restoreCandidate(id, record, liveIds); - liveIds = readLiveShapeIds(chart); - } - })().finally(() => { reconciliation = null; }); - return reconciliation; + async function reconcile() { + if (!suspended) overlay.render(markers(), { isCurrent: isChartCurrent }); } function remove(id) { - const removals = []; - if (pending?.id === id) { - pending.controller.abort(); - removals.push(pending); - } + if (pending?.id === id) pending.controller.abort(); const record = records.get(id); - if (record) { - records.delete(id); - removals.push(record); - } - dispose(removals); + if (!record) return; + records.delete(id); + overlay.remove(record.markers.map(marker => marker.id)); } function clear() { - if (pending) pending.controller.abort(); - const removals = [...records.values()]; + pending?.controller.abort(); records.clear(); - if (pending) removals.push(pending); - dispose(removals); + overlay.clear(); } async function renderCandidate(id, annotation, decisionAtMs) { if (suspended) return false; - const existing = records.get(id); - if (existing) return restoreCandidate(id, existing, readLiveShapeIds(chart)); + if (records.has(id)) return overlay.render(markers(), { isCurrent: isChartCurrent }); if (pending !== null) throw new Error('Compound chart rendering must be serial'); if (records.size >= maxCandidates) throw new Error('Compound chart capacity exceeded before eviction'); if (typeof id !== 'string' || id.length === 0 || !Number.isSafeInteger(decisionAtMs) || decisionAtMs < 1) throw new Error('Compound chart candidate identity/time is invalid'); - const icon = ICONS[annotation.markerShape]; const labels = annotation.markerShape === 'arrow_down' ? ['候选高', 'High candidate'] : ['候选低', 'Low candidate']; - if (icon === undefined || !labels.includes(annotation.markerLabel)) throw new Error('Compound chart direction/label is invalid'); - const operation = { id, markerShape: annotation.markerShape, controller: new AbortController(), ids: [] }; + if (!['arrow_down', 'arrow_up'].includes(annotation.markerShape) || !labels.includes(annotation.markerLabel)) throw new Error('Compound chart direction/label is invalid'); + const operation = { id, controller: new AbortController() }; pending = operation; try { const base = await placement.wait(annotation, { signal: operation.controller.signal, gapPx: CANDLE_GAP_PX + ICON_SIZE_PX / 2, }); - if (!base || operation.controller.signal.aborted || !isChartCurrent()) return false; + if (!base || suspended || operation.controller.signal.aborted || !isChartCurrent()) return false; const group = `${base.time}/${annotation.markerShape}`; - const occupied = new Set([...records.values()].filter((record) => record.group === group).map((record) => record.slot)); + const occupied = new Set([...records.values()].filter(record => record.group === group).map(record => record.slot)); let slot = 0; while (occupied.has(slot)) slot += 1; const sign = annotation.markerShape === 'arrow_up' ? 1 : -1; const point = placement.shift(base, sign * slot * SLOT_STEP_PX); const labelPoint = placement.shift(point, sign > 0 ? 18 : -40); - const options = drawingOptions(annotation.markerColor); - const drawings = [ - [point, { ...options, shape: 'icon', icon, overrides: { ...options.overrides, size: ICON_SIZE_PX } }], - [labelPoint, { ...options, shape: 'text', text: markerLabel(annotation.markerShape), overrides: { ...options.overrides, fontsize: 12, bold: true, fillBackground: false, drawBorder: false } }], + const style = { color: annotation.markerColor, direction: sign > 0 ? 'bullish' : 'bearish' }; + const candidateMarkers = [ + { ...style, id: `candidate:${id}:icon`, ...point, shape: annotation.markerShape, + size: ICON_SIZE_PX, anchor: 'center', type: 'compound-icon' }, + { ...style, id: `candidate:${id}:label`, ...labelPoint, shape: 'text', + size: 12, anchor: 'top', type: 'compound-label', text: markerLabel(annotation.markerShape) }, ]; - operation.drawings = drawings; - for (const [drawingPoint, drawing] of drawings) { - const entityId = await createDrawing(drawingPoint, drawing); - operation.ids.push(entityId); - if (operation.controller.signal.aborted || !isChartCurrent()) { - dispose([operation]); - return false; - } - if (drawing.shape === 'text') updateLabel(entityId, drawing, annotation.markerShape); - } - records.set(id, { ids: operation.ids.splice(0), group, slot, decisionAtMs, markerShape: annotation.markerShape, drawings, restoring: null }); + if (!overlay.render([...markers(), ...candidateMarkers], { isCurrent: isChartCurrent })) return false; + records.set(id, { group, slot, decisionAtMs, markerShape: annotation.markerShape, markers: candidateMarkers }); return true; - } catch (error) { - try { - dispose([operation]); - } catch (cleanupError) { - throw new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); - } - throw error; } finally { - pending = null; + if (pending === operation) pending = null; } } - function updateLabel(entityId, drawing, shape) { - const text = markerLabel(shape); - drawing.text = text; - const entity = chart.getShapeById(entityId); - if (entity.getProperties().text !== text) { - entity.setProperties({ text }, false); - if (entity.getProperties().text !== text) throw new Error('Compound chart label did not match the selected language'); - } - } - - /** Update only owned text entities; preserve arrow IDs, slots and restoration ownership. */ + /** Language changes update owned labels without moving immutable anchors or slots. */ function setLocale(nextLocale) { t = createStrategy27Translator(nextLocale); - const liveIds = readLiveShapeIds(chart); - for (const record of [...records.values(), ...(pending ? [pending] : [])]) { - if (!record.drawings) continue; - const drawing = record.drawings[1][1]; - drawing.text = markerLabel(record.markerShape); - if (liveIds.has(record.ids[1])) updateLabel(record.ids[1], drawing, record.markerShape); + for (const record of records.values()) { + const label = record.markers[1]; + label.text = markerLabel(record.markerShape); + overlay.updateText(label.id, label.text); } } - /** Freeze verified pairs while cancelling unfinished presentation and repair. */ + /** Freeze verified evidence while cancelling unfinished candle placement. */ function suspend() { suspended = true; - if (pending) { - pending.controller.abort(); - dispose([pending]); - } + pending?.controller.abort(); } return Object.freeze({ setLocale, renderCandidate, reconcile, remove, clear, suspend, get size() { return records.size; } }); diff --git a/src/binance-strategy27-events/dom/tradingview-event-layer.js b/src/binance-strategy27-events/dom/tradingview-event-layer.js index dd2bf2b..c93dbdf 100644 --- a/src/binance-strategy27-events/dom/tradingview-event-layer.js +++ b/src/binance-strategy27-events/dom/tradingview-event-layer.js @@ -1,3 +1,5 @@ +import { createChartMarkerOverlay } from '../../shared/chart-marker-overlay.js'; + const CHART_ROOT_SELECTOR = '.chart-widget-root'; const STATUS_ID = 'jh-strategy27-event-status'; const DIRECTIONAL_MARKER_GAP_PX = 8; @@ -16,22 +18,12 @@ function routeSymbolFromChartSymbol(value) { } function assertChartContract(chart) { - for (const method of ['createShape', 'getAllShapes', 'getShapeById', 'removeEntity', 'resolution', 'symbol']) { + for (const method of ['hasModel', 'dataReady', 'onIntervalChanged', 'onDataLoaded', 'resolution', 'symbol']) { if (typeof chart?.[method] !== 'function') throw new Error(`TradingView chart method is unavailable: ${method}`); } } -/** The host can evict transient entities without notifying their owner. */ -export function readLiveShapeIds(chart) { - const shapes = chart.getAllShapes(); - if (!Array.isArray(shapes)) throw new Error('Strategy 27 chart shape list is invalid'); - return new Set(shapes.map((shape) => { - if (typeof shape?.id !== 'string' || shape.id.length === 0) throw new Error('Strategy 27 chart shape id is invalid'); - return shape.id; - })); -} - -/** Check the native context around repair awaits, before the next context tick. */ +/** Check chart identity around candle waits, before the next context tick. */ export function pinMarkerChartContext(chart) { const symbol = chart.symbol(); const resolution = chart.resolution(); @@ -64,21 +56,6 @@ export function findStrategy27ChartRoot(document) { return chartRoots[0]; } -function shapeOptions(shape, color) { - return { - shape, - lock: true, - disableSave: true, - disableSelection: true, - disableUndo: true, - showInObjectsTree: false, - overrides: { - color, - fixedSize: true, - }, - }; -} - function createMarkerPointResolver(chart) { const model = chart._chartWidget?.model?.()?.model?.(); const timeScale = model?.timeScale?.(); @@ -154,31 +131,6 @@ function createMarkerPointResolver(chart) { return { dataUpdated, resolve, shift }; } -function verifyResolvedTime(chart, id, requestedTime) { - const shape = chart.getShapeById(id); - const points = shape?.getPoints?.(); - if (!Array.isArray(points) || points.length !== 1 || points[0].time !== requestedTime) { - const actualTime = Array.isArray(points) && points.length === 1 ? points[0]?.time : null; - const pointCount = Array.isArray(points) ? points.length : null; - throw new Error( - `Strategy 27 chart time alignment failed: expected ${requestedTime}, received ${actualTime} (point count ${pointCount})`, - ); - } - return shape; -} - -export async function createAlignedShape(chart, point, options) { - const id = await chart.createShape(point, options); - if (typeof id !== 'string' || id.length === 0) throw new Error('TradingView returned an invalid shape id'); - try { - verifyResolvedTime(chart, id, point.time); - } catch (error) { - chart.removeEntity(id); - throw error; - } - return id; -} - /** Shared causal candle placement; each caller owns cancellation of its wait. */ export function createTradingViewMarkerPlacement(chart, { candleWaitMs = DEFAULT_CANDLE_WAIT_MS, @@ -258,24 +210,32 @@ export function createTradingViewEventLayer(target, { maxEvents, maxAgeMs, candleWaitMs = DEFAULT_CANDLE_WAIT_MS, + onRenderError, }) { if (!Number.isInteger(maxEvents) || maxEvents < 1) throw new Error('Strategy 27 maxEvents is invalid'); if (!Number.isInteger(maxAgeMs) || maxAgeMs < 1) throw new Error('Strategy 27 maxAgeMs is invalid'); - const { chart } = target; - const placement = createTradingViewMarkerPlacement(chart, { candleWaitMs }); - const isChartCurrent = pinMarkerChartContext(chart); + const placement = createTradingViewMarkerPlacement(target.chart, { candleWaitMs }); + const isChartCurrent = pinMarkerChartContext(target.chart); const registry = new Map(); const pendingRenders = new Map(); let renderGeneration = 0; - let reconciliation = null; let suspended = false; + const overlay = createChartMarkerOverlay(target, { + maxMarkers: maxEvents, + onRenderError(error) { + suspend(); + if (onRenderError) onRenderError(error); + else throw error; + }, + }); + const markers = () => [...registry.values()].map(record => record.marker); function removeRecord(eventId) { pendingRenders.get(eventId)?.abort(); const record = registry.get(eventId); if (!record) return; registry.delete(eventId); - if (readLiveShapeIds(chart).has(record.markerId)) chart.removeEntity(record.markerId); + overlay.remove([record.marker.id]); } function pruneAge(observedAtMs) { @@ -288,69 +248,45 @@ export function createTradingViewEventLayer(target, { while (registry.size >= maxEvents) removeRecord(registry.keys().next().value); } - function restoreMarker(eventId, record, liveIds) { - if (record.restoring) return record.restoring; - const current = () => !suspended && registry.get(eventId) === record && isChartCurrent(); - if (!current()) return Promise.resolve(false); - if (liveIds.has(record.markerId)) return Promise.resolve(true); - record.restoring = (async () => { - const markerId = await createAlignedShape(chart, record.markerPoint, record.options); - if (!current()) { - if (readLiveShapeIds(chart).has(markerId)) chart.removeEntity(markerId); - return false; - } - record.markerId = markerId; - return true; - })().finally(() => { record.restoring = null; }); - return record.restoring; - } - - function reconcile() { - if (suspended) return Promise.resolve(); - if (reconciliation) return reconciliation; - reconciliation = (async () => { - let liveIds = readLiveShapeIds(chart); - for (const [eventId, record] of [...registry]) { - if (suspended || registry.get(eventId) !== record || !isChartCurrent()) continue; - if (!record.restoring && liveIds.has(record.markerId)) continue; - await restoreMarker(eventId, record, liveIds); - // A native create yields; refresh before examining another record. - liveIds = readLiveShapeIds(chart); - } - })().finally(() => { reconciliation = null; }); - return reconciliation; + async function reconcile() { + if (!suspended) overlay.render(markers(), { isCurrent: isChartCurrent }); } async function ensureMarker(eventId, annotation, observedAtMs) { if (suspended) return false; - let record = registry.get(eventId); - if (record) { - record.observedAtMs = observedAtMs; - return restoreMarker(eventId, record, readLiveShapeIds(chart)); + const existing = registry.get(eventId); + if (existing) { + existing.observedAtMs = observedAtMs; + return overlay.render(markers(), { isCurrent: isChartCurrent }); } if (annotation.markerShape === null) return true; const requestedGeneration = renderGeneration; const controller = new AbortController(); pendingRenders.set(eventId, controller); try { - const markerPoint = await placement.wait(annotation, { signal: controller.signal }); - if (!markerPoint || controller.signal.aborted || requestedGeneration !== renderGeneration || !isChartCurrent()) return false; + const point = await placement.wait(annotation, { signal: controller.signal }); + if (!point || suspended || controller.signal.aborted || requestedGeneration !== renderGeneration || !isChartCurrent()) return false; pruneAge(observedAtMs); ensureCapacityForNew(); - const options = shapeOptions(annotation.markerShape, annotation.markerColor); - const markerId = await createAlignedShape(chart, markerPoint, options); - if (controller.signal.aborted || requestedGeneration !== renderGeneration || !isChartCurrent()) { - if (readLiveShapeIds(chart).has(markerId)) chart.removeEntity(markerId); - return false; - } - record = { markerId, markerPoint, options, observedAtMs, restoring: null }; - registry.set(eventId, record); + const marker = { id: `event:${eventId}`, time: point.time, price: point.price, + shape: annotation.markerShape, color: annotation.markerColor, size: 18, anchor: 'tip', + type: 'ordinary', direction: annotation.markerShape === 'arrow_up' ? 'bullish' : 'bearish' }; + if (!overlay.render([...markers(), marker], { isCurrent: isChartCurrent })) return false; + // Retain the first accepted point and style; later facts never move it. + registry.set(eventId, { marker, observedAtMs }); return true; } finally { if (pendingRenders.get(eventId) === controller) pendingRenders.delete(eventId); } } + /** Freeze verified evidence while cancelling unfinished candle placement. */ + function suspend() { + suspended = true; + renderGeneration += 1; + for (const controller of pendingRenders.values()) controller.abort(); + } + return Object.freeze({ renderOpened: (eventId, annotation, observedAtMs) => ensureMarker(eventId, annotation, observedAtMs), renderUpdated: (eventId, annotation, observedAtMs) => ensureMarker(eventId, annotation, observedAtMs), @@ -359,20 +295,14 @@ export function createTradingViewEventLayer(target, { remove: removeRecord, prune: pruneAge, reconcile, - /** Stop new presentation without deleting verified history after a job failure. */ - suspend() { - suspended = true; - renderGeneration += 1; - for (const controller of pendingRenders.values()) controller.abort(); - }, + suspend, clear() { renderGeneration += 1; for (const controller of pendingRenders.values()) controller.abort(); - for (const eventId of [...registry.keys()]) removeRecord(eventId); - }, - get size() { - return registry.size; + registry.clear(); + overlay.clear(); }, + get size() { return registry.size; }, }); } diff --git a/src/binance-strategy27-events/index.user.js b/src/binance-strategy27-events/index.user.js index 48434ee..b4e26ff 100644 --- a/src/binance-strategy27-events/index.user.js +++ b/src/binance-strategy27-events/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.6.7 +// @version 0.6.8 // @author jackhai9 // @description Display Strategy 27 events and provide the shared private CorsairQuant gateway connection // @match https://www.binance.com/*/futures/* @@ -79,6 +79,7 @@ const promptUser = globalThis.prompt.bind(globalThis); function stopActive(resetReason) { if (!active) return; + active.intervalEvent.unsubscribe(active.intervalOwner, active.onIntervalChanged); active.controller.abort(); active.compound.stop(resetReason); active.lifecycle.reset(resetReason); @@ -129,7 +130,7 @@ const promptUser = globalThis.prompt.bind(globalThis); } function failOrdinary(context, error) { - if (error.name === 'AbortError' || active !== context || context.failed) return; + if (error.name === 'AbortError' || active !== context || context.failed || context.intervalRevision !== 0) return; context.failed = true; context.controller.abort(); context.layer.suspend(); @@ -151,7 +152,7 @@ const promptUser = globalThis.prompt.bind(globalThis); } async function renderGatewayResponse(context, response) { - if (active !== context || context.failed) return; + if (active !== context || context.failed || context.intervalRevision !== 0) return; context.panel.setOrdinaryConnection('connected'); pruneOrdinaryEvents(context); if (response.status === 'reset') { @@ -183,7 +184,7 @@ const promptUser = globalThis.prompt.bind(globalThis); } for (const message of messages) { - if (active !== context || context.failed) return; + if (active !== context || context.failed || context.intervalRevision !== 0) return; const action = context.lifecycle.apply(message); if (action.type === 'stream_reset') { context.panel.retainHistory(); @@ -210,11 +211,11 @@ const promptUser = globalThis.prompt.bind(globalThis); event_outcome: 'renderOutcome', }[action.messageKind]; const rendered = await context.layer[renderMethod](action.eventId, annotation, retainedAtMs); - if (!rendered || active !== context || context.failed || !context.ordinaryHistory.has(action.eventId)) continue; + if (!rendered || active !== context || context.failed || context.intervalRevision !== 0 || !context.ordinaryHistory.has(action.eventId)) continue; context.panel.upsert(action.eventId, localizeAnnotation(annotation, context.locale), retainedAtMs); hideStatus(); } - if (response.status === 'bootstrap') { + if (response.status === 'bootstrap' && active === context && context.intervalRevision === 0) { context.lifecycle.finishBootstrap(response.last_sequence); hideStatus(); } @@ -235,6 +236,7 @@ const promptUser = globalThis.prompt.bind(globalThis); layer: createTradingViewEventLayer(target, { maxEvents: MAX_RETAINED_EVENTS, maxAgeMs: MAX_EVENT_AGE_MS, + onRenderError: error => failOrdinary(context, error), }), panel: createStrategy27EventPanel(pageDocument, target.chartRoot, { locale: uiLocale, @@ -246,15 +248,30 @@ const promptUser = globalThis.prompt.bind(globalThis); candidatePresentations: new Map(), ordinaryHistory: new Map(), reconciliation: null, + intervalOwner: {}, + intervalEvent: target.chart.onIntervalChanged(), + intervalRevision: 0, + onIntervalChanged: null, failed: false, }; active = context; context.compound = createCompoundCandidateController({ locale: context.locale, request, gatewayBaseUrl: gatewayOrigin, authSecret, canonicalSymbol, - panel: context.panel, isCurrent: () => active === context, + panel: context.panel, isCurrent: () => active === context && context.intervalRevision === 0, maxCandidates: MAX_RETAINED_EVENTS, maxAgeMs: MAX_EVENT_AGE_MS, - createLayer: () => createTradingViewCompoundLayer(target, { maxCandidates: MAX_RETAINED_EVENTS, locale: context.locale }), + createLayer: ({ onRenderError }) => createTradingViewCompoundLayer(target, { + maxCandidates: MAX_RETAINED_EVENTS, locale: context.locale, onRenderError, + }), }); + // Invalidate even before the first marker exists: a candle wait must not + // survive a complete interval round trip between context polls. + context.onIntervalChanged = () => { + context.intervalRevision += 1; + context.controller.abort(); + context.layer.suspend(); + context.compound.stop('interval_changed'); + }; + context.intervalEvent.subscribe(context.intervalOwner, context.onIntervalChanged); void context.compound.run(); showStatus(target.chartRoot, (locale) => createStrategy27Translator(locale)('Strategy 27 正在连接', 'Strategy 27 connecting')); const client = createLiveEventClient({ @@ -263,7 +280,7 @@ const promptUser = globalThis.prompt.bind(globalThis); authSecret, canonicalSymbol, onConnectionStateChange: (state) => { - if (active !== context || context.failed) return; + if (active !== context || context.failed || context.intervalRevision !== 0) return; context.panel.setOrdinaryConnection(state); if (state === 'reconnecting') { showStatus(context.target.chartRoot, (locale) => createStrategy27Translator(locale)('Strategy 27 网关连接中断,正在重连', 'Strategy 27 gateway disconnected; reconnecting'), 'inactive'); @@ -335,6 +352,7 @@ const promptUser = globalThis.prompt.bind(globalThis); if ( active + && active.intervalRevision === 0 && active.routeSymbol === routeSymbol && active.target.chart === target.chart && active.target.chartRoot === target.chartRoot diff --git a/src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js b/src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js index 92c9ebc..34dfb93 100644 --- a/src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js +++ b/src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js @@ -1,5 +1,5 @@ +import { createChartMarkerOverlay } from '../../shared/chart-marker-overlay.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'; @@ -14,15 +14,11 @@ function routeSymbolFromChartSymbol(value) { function assertChartContract(chart) { for (const method of [ - 'createShape', 'dataReady', 'exportData', - 'getAllShapes', - 'getShapeById', 'hasModel', 'onDataLoaded', 'onIntervalChanged', - 'removeEntity', 'resolution', 'symbol', ]) { @@ -32,21 +28,6 @@ function assertChartContract(chart) { } } -function readLiveShapes(chart) { - const shapes = chart.getAllShapes(); - if (!Array.isArray(shapes)) { - throw new Error('TradingView Bollinger alert shape list is invalid'); - } - const ids = 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; -} - export function tradingViewResolutionToSeconds(resolution) { const value = String(resolution || '').toUpperCase(); const units = [ @@ -65,28 +46,6 @@ export function tradingViewResolutionToSeconds(resolution) { throw new Error(`TradingView Bollinger alert resolution is unsupported: ${resolution}`); } -/** Native drawings must stay hidden outside their originating interval, including while saves defer removal. */ -export 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); } - // TradingView groups 60+ minute intervals into integer-hour visibility buckets. - visibility[unit] = true; - visibility[`${unit}From`] = count; - visibility[`${unit}To`] = count; - return visibility; -} - /** * dataReady() in Binance's chart runtime only checks for nonempty data. A revision * and the data-completed event are needed to exclude old bars during A -> B -> A. @@ -365,81 +324,6 @@ export async function exportClosedTradingViewBars(target, session, observedAtMs }); } -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: 0xf111, - 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 !== undefined && 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`); @@ -454,165 +338,51 @@ function normalizeSignal(signal, index, defaultDirection) { 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 = new Map(); - const pendingMarkers = 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); - } - +/** Map strategy output onto the shared read-only chart presentation contract. */ +function createMarkerLayer(target, defaultDirection, options) { + const overlay = createChartMarkerOverlay(target, { ...options, maxMarkers: MAX_BOLLINGER_MARKERS }); 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) { - // Awaiting native shape creation may resolve as a microtask. Explicitly - // yield large audits/rebuilds so input and paint can run between batches. - 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; + async render(nextSignals, { isCurrent }) { + try { + if (!Array.isArray(nextSignals)) throw new Error('TradingView Bollinger alert signals are invalid'); + if (nextSignals.length > MAX_BOLLINGER_MARKERS) { + throw new Error(`TradingView Bollinger alert marker limit exceeded: ${nextSignals.length}`); } - 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); + if (typeof isCurrent !== 'function') { + throw new Error('TradingView Bollinger alert current-target validator is unavailable'); } - const finishCreation = saveController.beginMutation(); - creating += 1; - try { - // Native creation enables the interval active after its async loader. - // Keep pending drawings hidden until the originating session can publish. - 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'); + const normalized = nextSignals.map((signal, index) => normalizeSignal(signal, index, defaultDirection)); + const counts = { bearish: 0, bullish: 0 }, ids = new Set(); + for (const signal of normalized) { + if (!Number.isInteger(signal.time) || !Number.isFinite(signal.markerPrice)) { + throw new Error('TradingView Bollinger alert signal point is invalid'); } - // Own the result before checking the epoch. Late results may need to wait - // for a trade/save owner, and must never become untracked foreign drawings. - pendingMarkers.add(markerId); - if (requestedGeneration !== generation || !isCurrent() || !canMutate()) return false; - const shape = chart.getShapeById(markerId); - const point = readMarkerPoint(shape); - if (point.time !== signal.time) { - throw new Error(`TradingView Bollinger alert time alignment failed: expected ${signal.time}, received ${point.time}`); + if (!['warning', 'confirmed', 'reversal'].includes(signal.type)) { + throw new Error(`TradingView Bollinger alert signal type is invalid: ${signal.type}`); } - 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'); + if (ids.has(signal.id)) throw new Error(`TradingView Bollinger alert duplicate signal id: ${signal.id}`); + ids.add(signal.id); + counts[signal.direction] += 1; + if (counts[signal.direction] > MAX_BOLLINGER_MARKERS_PER_DIRECTION) { + throw new Error(`TradingView Bollinger alert ${signal.direction} marker limit exceeded: ${counts[signal.direction]}`); } - 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(); + const markers = normalized.map(signal => { + const bullish = signal.direction === 'bullish'; + const up = signal.type === 'reversal' ? !bullish : bullish; + const circle = signal.type === 'warning'; + return { id: signal.id, time: signal.time, price: signal.markerPrice, + shape: circle ? 'circle' : up ? 'arrow_up' : 'arrow_down', + color: (circle ? bullish : up) ? '#0ECB81' : '#F6465D', + size: circle ? 10 : 18, anchor: circle ? 'center' : 'tip', + type: signal.type, direction: signal.direction }; + }); + return overlay.render(markers, { isCurrent }); + } catch (error) { overlay.clear(); throw error; } }, + clear: overlay.clear, + get size() { return overlay.size; }, + get overlayStats() { return overlay.overlayStats; }, }); } diff --git a/src/binance-strategy29-bollinger/index.user.js b/src/binance-strategy29-bollinger/index.user.js index 6a799e2..c78fff3 100644 --- a/src/binance-strategy29-bollinger/index.user.js +++ b/src/binance-strategy29-bollinger/index.user.js @@ -3,9 +3,9 @@ // @namespace binance.strategy29.bollinger // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.5.7 +// @version 0.5.8 // @author jackhai9 -// @description Native Bollinger/SMA60 markers and the default read-only cross-timeframe summary +// @description Bollinger/SMA60 overlay markers and the default read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* // @match https://www.binance.com/futures/* // @exclude https://www.binance.com/*/my/wallet/futures/* diff --git a/src/binance-strategy29-bollinger/monitor.js b/src/binance-strategy29-bollinger/monitor.js index efc7673..0bc1f51 100644 --- a/src/binance-strategy29-bollinger/monitor.js +++ b/src/binance-strategy29-bollinger/monitor.js @@ -32,7 +32,6 @@ export function createBollingerMonitor({ } function clearRetiredBollingerLayers() { - if (isTradingViewDrawingMutationBusy()) return false; for (const layer of retiredBollingerLayers) { if (layer.clear()) retiredBollingerLayers.delete(layer); } @@ -49,6 +48,7 @@ export function createBollingerMonitor({ function isBearishBollingerAlertContextCurrent(context) { return ( bearishBollingerAlertContext === context + && !context.failed && context.intervalSession === bollingerIntervalSession?.session && context.intervalSession.isCurrent(context.intervalRevision) && !document.hidden @@ -104,7 +104,7 @@ export function createBollingerMonitor({ if (!contextMatches) { if (!clearBearishBollingerAlertContext()) return; if (!intervalSession.isCurrent(intervalSession.revision) || isTradingViewDrawingMutationBusy()) return; - bearishBollingerAlertContext = { + const nextContext = { routeSymbol, resolution: target.resolution, intervalSession, @@ -112,7 +112,7 @@ export function createBollingerMonitor({ target, layer: createBollingerMarkerLayer(target, { canMutate: () => !isTradingViewDrawingMutationBusy(), - onSaveError: (error) => err('Bollinger chart save failed:', error), + onRenderError: (error) => handleFailure(nextContext, error, 'render'), }), failed: false, cleanupPending: false, @@ -120,6 +120,7 @@ export function createBollingerMonitor({ lastProcessedClosedBarsContentSnapshot: null, lastProcessedSignals: null, }; + bearishBollingerAlertContext = nextContext; } if (isTradingViewDrawingMutationBusy() || !clearRetiredBollingerLayers()) return; @@ -163,63 +164,67 @@ export function createBollingerMonitor({ } })(); bearishBollingerAlertTask = task; - task.catch((error) => { - if ( - bearishBollingerAlertContext !== context - || context.intervalSession !== bollingerIntervalSession?.session - || context.intervalRevision !== context.intervalSession.revision - ) return; - let failureKind; - let classificationFailed = false; + task.catch((error) => handleFailure(context, error, stage)).finally(() => { + if (bearishBollingerAlertTask === task) bearishBollingerAlertTask = null; + }); + } + + function handleFailure(context, error, stage) { + if ( + bearishBollingerAlertContext !== context + || context.intervalSession !== bollingerIntervalSession?.session + || context.intervalRevision !== context.intervalSession.revision + ) return; + let failureKind; + let classificationFailed = false; + try { + failureKind = applyBollingerAlertTaskFailure(context, error); + } catch { + // A revoked host Proxy can throw during instanceof; an unclassifiable rejection must stop this context. + classificationFailed = true; + context.failed = true; + context.cleanupPending = true; + failureKind = 'fatal'; + } + 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; + } + /** Preserve host rejection types without serializing arbitrary objects or stacks. */ + const details = { name: null, message: null }; + const unreadableFields = []; + for (const [key, limit] of [['name', 64], ['message', 512]]) { try { - failureKind = applyBollingerAlertTaskFailure(context, error); + const value = key === 'message' && typeof error === 'string' ? error : error?.[key]; + details[key] = typeof value === 'string' ? value.slice(0, limit) : null; } catch { - // A revoked host Proxy can throw during instanceof; an unclassifiable rejection must stop this context. - classificationFailed = true; - context.failed = true; - context.cleanupPending = true; - failureKind = 'fatal'; - } - 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; - } - /** Preserve host rejection types without serializing arbitrary objects or stacks. */ - const details = { name: null, message: null }; - const unreadableFields = []; - for (const [key, limit] of [['name', 64], ['message', 512]]) { - try { - const value = key === 'message' && typeof error === 'string' ? error : error?.[key]; - details[key] = typeof value === 'string' ? value.slice(0, limit) : null; - } catch { - // Host rejection accessors may throw; preserve that fact without replacing the original failure. - unreadableFields.push(key); - } + // Host rejection accessors may throw; preserve that fact without replacing the original failure. + unreadableFields.push(key); } - lastLocalFailure = Object.freeze({ - thrownType: error === null ? 'null' : typeof error, - classificationFailed, - ...details, - unreadableFields: Object.freeze(unreadableFields), - stage, - routeSymbol: context.routeSymbol, - resolution: context.resolution, - cachedSignalCount: context.lastProcessedSignals === null ? null : context.lastProcessedSignals.length, - layerSizeBeforeCleanup: context.layer.size, - sessionRevision: context.intervalSession.revision, - contextIntervalRevision: context.intervalRevision, - }); - err('布林带形态预警已停止:', error); - }).finally(() => { - if (bearishBollingerAlertTask === task) bearishBollingerAlertTask = null; + } + lastLocalFailure = Object.freeze({ + thrownType: error === null ? 'null' : typeof error, + classificationFailed, + ...details, + unreadableFields: Object.freeze(unreadableFields), + stage, + routeSymbol: context.routeSymbol, + resolution: context.resolution, + cachedSignalCount: context.lastProcessedSignals === null ? null : context.lastProcessedSignals.length, + layerSizeBeforeCleanup: context.layer.size, + sessionRevision: context.intervalSession.revision, + contextIntervalRevision: context.intervalRevision, }); + context.layer.clear(); + context.cleanupPending = false; + err('布林带形态预警已停止:', error); } function stopBearishBollingerAlertMonitor() { - // Invalidate even while a trade/save owner defers physical marker removal. + // Independent SVG cleanup is safe while a native drawing owner is busy. disposeBollingerIntervalSession(); clearBearishBollingerAlertContext(); } @@ -239,7 +244,7 @@ export function createBollingerMonitor({ cachedSignalCount: context?.lastProcessedSignals === null || !context ? null : context.lastProcessedSignals.length, layerSize: context ? context.layer.size : null, - markerSaveStats: context ? context.layer.saveStats : null, + markerOverlayStats: context ? context.layer.overlayStats : null, retiredCount: retiredBollingerLayers.size, sessionPresent: session !== null, sessionRevision: session ? session.revision : null, diff --git a/src/binance-strategy31-volume-reversal/index.user.js b/src/binance-strategy31-volume-reversal/index.user.js index 5df1920..af87d6b 100644 --- a/src/binance-strategy31-volume-reversal/index.user.js +++ b/src/binance-strategy31-volume-reversal/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy31.volume-reversal // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.1.1 +// @version 0.1.2 // @author jackhai9 // @description Confirmed red-to-green volume signals from CorsairQuant // @match https://www.binance.com/*/futures/* diff --git a/src/binance-strategy31-volume-reversal/runtime.js b/src/binance-strategy31-volume-reversal/runtime.js index 6d66b54..b59744d 100644 --- a/src/binance-strategy31-volume-reversal/runtime.js +++ b/src/binance-strategy31-volume-reversal/runtime.js @@ -29,7 +29,7 @@ export function installStrategy31(view) { node.textContent = text; } function retire() { - if (context) { retired.add(context.layer); context = null; } + if (context) { context.layer.clear(); retired.add(context.layer); context = null; } inflight?.abort(); } function releaseChart() { @@ -38,12 +38,11 @@ export function installStrategy31(view) { intervalOwner = null; } function cleanup() { - if (isChartMutationBlocked(view)) return; for (const layer of retired) if (layer.clear()) retired.delete(layer); if ((disposed || failed) && retired.size === 0) view.clearInterval(timer); } function current(candidate) { - return !disposed && !document.hidden && context === candidate + return !disposed && !failed && !document.hidden && context === candidate && candidate.session.isCurrent(candidate.revision) && parseFuturesTradingSymbolFromPathname(view.location.pathname) === candidate.target.routeSymbol && isBearishBollingerChartTargetCurrent(document, candidate.target) @@ -84,7 +83,9 @@ export function installStrategy31(view) { if (!context) { const target = { ...base, chart, resolution, resolutionSeconds: seconds, routeSymbol: route }; context = { target, session, revision: session.revision, - layer: createBollingerMarkerLayer(target, { canMutate: () => !isChartMutationBlocked(view) }) }; + layer: createBollingerMarkerLayer(target, { + canMutate: () => !isChartMutationBlocked(view), onRenderError: stopAfterFailure, + }) }; } const candidate = context; if (!current(candidate)) return; @@ -102,7 +103,7 @@ export function installStrategy31(view) { if (!requestCurrent()) return; if (response.kind !== 'response' || response.status !== 200) { notice('Strategy31: signal service unavailable'); return; } const signals = parseStrategy31Events(JSON.parse(response.responseText), symbol, timeframe); - // Native drawings snap missing times to loaded bars; server history must be projected onto exact times. + // Server history is projected only onto exact loaded candle times. let bars; try { bars = await exportClosedTradingViewBars(candidate.target, candidate.session); @@ -118,9 +119,13 @@ export function installStrategy31(view) { if (rendered && requestCurrent()) notice(`Strategy31: ${visible.length} chart signals · ${timeframe}`); } finally { if (inflight === controller) inflight = null; } } + function stopAfterFailure() { + failed = true; releaseChart(); cleanup(); + if (!disposed) notice('Strategy31 stopped: invalid chart or signal data'); + } function tick() { // Job boundary: invalid contracts stop this observer and expose a visible failure. - return sample().catch(() => { failed = true; releaseChart(); cleanup(); if (!disposed) notice('Strategy31 stopped: invalid chart or signal data'); }); + return sample().catch(stopAfterFailure); } function visibility() { if (document.hidden) retire(); else void tick(); } const timer = view.setInterval(() => { void tick(); }, 5000); diff --git a/src/shared/chart-marker-overlay.js b/src/shared/chart-marker-overlay.js new file mode 100644 index 0000000..deea931 --- /dev/null +++ b/src/shared/chart-marker-overlay.js @@ -0,0 +1,270 @@ +/** + * Owns presentation only. SVG updates never enter TradingView's drawing model or + * serialization queue; the host remains the sole owner of user drawings. + * + * Inputs contain resolved time/price and an explicit style, never candle-wait + * or retention policy. Size means circle diameter, arrow height, or text font + * size in CSS pixels. Arrows use a tip or center anchor; text uses its top edge. + * Each strategy owns its immutable placement and failure-reporting contract. + */ +export function createChartMarkerOverlay(target, { + maxMarkers, + canMutate = () => true, + onRenderError, +} = {}) { + if (!Number.isSafeInteger(maxMarkers) || maxMarkers < 1) throw new Error('TradingView overlay marker limit is invalid'); + const { chart } = target; + const document = target.chartRoot.ownerDocument; + const owner = {}; + const subscriptions = []; + const nodes = new Map(); + let svg = null, host = null, frame = null, observer = null, projection = null; + let signals = [], validateCurrent = null, invalidated = false; + let renderedFrames = 0, visibleMarkers = 0, generation = 0; + + function hide() { + if (svg && svg.style.visibility !== 'hidden') svg.style.visibility = 'hidden'; + visibleMarkers = 0; + } + + function cancelFrame() { + if (frame !== null) host.ownerDocument.defaultView.cancelAnimationFrame(frame); + frame = null; + } + + function clear() { + generation += 1; + hide(); + cancelFrame(); + observer?.disconnect(); + observer = null; + for (const [event, callback] of subscriptions) event.unsubscribe(owner, callback); + subscriptions.length = 0; + document.removeEventListener('visibilitychange', visibilityChanged); + svg?.remove(); + svg = null; + host = null; + projection = null; + signals = []; + nodes.clear(); + validateCurrent = null; + invalidated = false; + return true; + } + + function readProjection() { + const widget = chart._chartWidget; + const model = widget.model().model(); + const series = model.mainSeries(); + const time = model.timeScale(); + const price = series.priceScale(); + const pane = widget.paneByState(model.paneForSource(series)); + const container = pane.canvasElement().parentElement; + if (!container || !container.classList.contains('chart-gui-wrapper')) { + throw new Error('TradingView marker main-pane container is unavailable'); + } + return { model, series, time, price, pane, container }; + } + + function current() { + return !invalidated && validateCurrent() && !document.hidden + && target.chartRoot.isConnected && chart.hasModel() && chart.dataReady() + && target.tradingViewApi.activeChart() === chart + && chart.resolution() === target.resolution + && String(chart.symbol()).split('@', 1)[0] === target.routeSymbol; + } + + function draw() { + if (!current() || !canMutate()) { hide(); return false; } + const next = readProjection(); + for (const key of ['model', 'series', 'time', 'price', 'pane', 'container']) { + if (next[key] !== projection[key]) { + throw new Error(`TradingView marker projection changed: ${key}`); + } + } + if (!host.isConnected) throw new Error('TradingView marker pane is detached'); + const { width, height } = host.getBoundingClientRect(); + if (!Number.isFinite(width) || !Number.isFinite(height)) { + throw new Error('TradingView marker pane dimensions are invalid'); + } + const projected = []; + const visibleIds = new Set(); + const { series, time, price } = projection; + const data = chart.getSeries().data(); + for (const signal of signals) { + const index = time.timePointToIndex(signal.time, 0); + if (index === null) continue; + if (!Number.isFinite(index)) throw new Error('TradingView marker bar index is invalid'); + // Exact readback excludes feed gaps even if the host returns a nearby index. + const row = data.valueAt(index); + if (!row || row[0] !== signal.time) continue; + const x = time.indexToCoordinate(index); + if (!Number.isFinite(x)) throw new Error('TradingView marker coordinates are invalid'); + if (width <= 0 || height <= 0 || x < 0 || x > width) continue; + const y = price.priceToCoordinate(signal.price, series.firstValue()); + if (!Number.isFinite(y)) { + throw new Error('TradingView marker coordinates are invalid'); + } + if (y < 0 || y > height) continue; + projected.push({ signal, x, y }); + visibleIds.add(signal.id); + } + // Compute the complete frame before touching DOM. Reuse visible nodes so + // panning and unchanged samples do not rebuild thousands of SVG elements. + function setAttribute(node, name, value) { + if (node.getAttribute(name) !== value) node.setAttribute(name, value); + } + setAttribute(svg, 'width', String(width)); + setAttribute(svg, 'height', String(height)); + setAttribute(svg, 'viewBox', `0 0 ${width} ${height}`); + for (const [id, node] of nodes) { + if (!visibleIds.has(id)) { node.remove(); nodes.delete(id); } + } + for (const { signal, x, y } of projected) { + const tag = signal.shape === 'circle' ? 'circle' : signal.shape === 'text' ? 'text' : 'path'; + let node = nodes.get(signal.id); + if (node && node.localName !== tag) { node.remove(); nodes.delete(signal.id); node = null; } + if (!node) { + node = host.ownerDocument.createElementNS('http://www.w3.org/2000/svg', tag); + nodes.set(signal.id, node); + svg.append(node); + } + setAttribute(node, 'data-marker-id', signal.id); + setAttribute(node, 'data-marker-type', signal.type); + setAttribute(node, 'data-marker-direction', signal.direction); + setAttribute(node, 'transform', `translate(${x} ${y})`); + setAttribute(node, 'fill', signal.color); + if (signal.shape === 'circle') setAttribute(node, 'r', String(signal.size / 2)); + else if (signal.shape === 'text') { + setAttribute(node, 'font-size', String(signal.size)); + setAttribute(node, 'font-weight', '700'); + setAttribute(node, 'font-family', 'Arial, sans-serif'); + setAttribute(node, 'text-anchor', 'middle'); + setAttribute(node, 'dominant-baseline', 'hanging'); + if (node.textContent !== signal.text) node.textContent = signal.text; + } else { + setAttribute(node, 'd', signal.pathData); + } + } + if (svg.style.visibility !== 'visible') svg.style.visibility = 'visible'; + visibleMarkers = projected.length; + renderedFrames += 1; + return true; + } + + function schedule() { + if (!svg || frame !== null || invalidated || document.hidden) return; + const scheduledGeneration = generation; + frame = host.ownerDocument.defaultView.requestAnimationFrame(() => { + if (!svg || scheduledGeneration !== generation) return; + frame = null; + try { draw(); } catch (error) { + // This is the asynchronous rendering boundary. Consumers expose terminal + // failures through their existing monitor diagnostics or status notice. + clear(); + if (onRenderError) onRenderError(error); + else throw error; + } + }); + } + + function invalidate() { invalidated = true; hide(); cancelFrame(); } + function dataChanged() { hide(); schedule(); } + function visibilityChanged() { + if (document.hidden) { hide(); cancelFrame(); } else schedule(); + } + function subscribe(event, callback) { + event.subscribe(owner, callback); + subscriptions.push([event, callback]); + } + + function install() { + projection = readProjection(); + host = projection.container; + svg = host.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('data-strategy-marker-overlay', ''); + svg.setAttribute('aria-hidden', 'true'); + svg.style.cssText = 'position:absolute;inset:0;pointer-events:none;overflow:hidden;visibility:hidden'; + host.append(svg); + const { series, time, price } = projection; + for (const event of [time.logicalRangeChanged(), time.barSpacingChanged(), time.rightOffsetChanged(), + price.priceRangeChanged(), price.modeChanged(), price.internalHeightChanged()]) { + subscribe(event, schedule); + } + subscribe(series.dataUpdated(), dataChanged); + subscribe(chart.onIntervalChanged(), invalidate); + subscribe(chart.onDataLoaded(), dataChanged); + observer = new host.ownerDocument.defaultView.ResizeObserver(schedule); + observer.observe(host); + document.addEventListener('visibilitychange', visibilityChanged); + } + + return Object.freeze({ + render(nextSignals, { isCurrent }) { + try { + if (!Array.isArray(nextSignals) || nextSignals.length > maxMarkers) { + throw new Error('TradingView overlay marker collection is invalid'); + } + if (typeof isCurrent !== 'function') throw new Error('TradingView overlay current-target validator is unavailable'); + const ids = new Set(); + for (const marker of nextSignals) { + if (!marker || typeof marker.id !== 'string' || !marker.id || ids.has(marker.id) + || !Number.isInteger(marker.time) || !Number.isFinite(marker.price) + || !['circle', 'arrow_up', 'arrow_down', 'text'].includes(marker.shape) + || typeof marker.color !== 'string' || !/^#[0-9a-f]{6}$/i.test(marker.color) + || !Number.isFinite(marker.size) || marker.size <= 0 + || typeof marker.type !== 'string' || !marker.type + || !['bearish', 'bullish'].includes(marker.direction)) { + throw new Error('TradingView overlay marker contract is invalid'); + } + if (marker.shape === 'text' ? marker.anchor !== 'top' || typeof marker.text !== 'string' || !marker.text + : marker.shape === 'circle' ? marker.anchor !== 'center' : !['tip', 'center'].includes(marker.anchor)) { + throw new Error('TradingView overlay marker style is invalid'); + } + ids.add(marker.id); + } + validateCurrent = isCurrent; + if (!current() || !canMutate()) { hide(); return false; } + if (!svg) install(); + signals = nextSignals.map(marker => { + if (marker.shape !== 'arrow_up' && marker.shape !== 'arrow_down') return { ...marker }; + const sign = marker.shape === 'arrow_up' ? 1 : -1; + const scale = marker.size / 18; + const centerShift = marker.anchor === 'center' ? -sign * marker.size / 2 : 0; + const points = [[0, 0], [-6, 8], [-2, 8], [-2, 18], [2, 18], [2, 8], [6, 8]]; + const path = points.map(([x, y], index) => `${index === 0 ? 'M' : 'L'} ${x * scale} ${sign * y * scale + centerShift}`).join(' '); + return { ...marker, pathData: `${path} Z` }; + }); + return draw(); + } catch (error) { clear(); throw error; } + }, + clear, + updateText(id, text) { + if (typeof id !== 'string' || typeof text !== 'string' || !text) throw new Error('TradingView overlay text update is invalid'); + const marker = signals.find(candidate => candidate.id === id); + // A cleared or retired overlay has no presentation to translate. + if (!marker) return false; + if (marker.shape !== 'text') throw new Error('TradingView overlay text target is invalid'); + marker.text = text; + const node = nodes.get(id); + if (node && node.textContent !== text) node.textContent = text; + return true; + }, + remove(ids) { + if (!Array.isArray(ids) || ids.some(id => typeof id !== 'string')) throw new Error('TradingView overlay removal IDs are invalid'); + const removed = new Set(ids); + signals = signals.filter(signal => !removed.has(signal.id)); + for (const id of removed) { + nodes.get(id)?.remove(); + nodes.delete(id); + } + visibleMarkers = svg?.style.visibility === 'visible' ? nodes.size : 0; + }, + get size() { return signals.length; }, + get overlayStats() { + return { renderedFrames, visibleMarkers, signalCount: signals.length, + attached: svg !== null, pendingFrame: frame !== null, subscriptions: subscriptions.length }; + }, + }); +} + diff --git a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js index 422d728..681827b 100644 --- a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js +++ b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { runInThisContext } from 'node:vm'; import test from 'node:test'; -import { loadFixtureDom } from '../../helpers/dom.js'; +import { createStrategy27OverlayHost } from '../../helpers/strategy27-overlay-host.js'; import { createStrategyPromptBoundary, installStrategyClock, observeStrategyQueries } from '../../helpers/strategy-migration-boundaries.js'; const fixtures = JSON.parse(readFileSync(new URL('../../fixtures/strategy27-compound-candidates.json', import.meta.url), 'utf8')); @@ -16,8 +16,10 @@ async function until(predicate) { } } -async function harness(t, { generated = false, beforeCreate, locale = 'zh-CN', migrationRecord, routeSymbol = 'BTCUSDT', candidateFixture = fixtures[0] } = {}) { - const dom = loadFixtureDom(''); +async function harness(t, { generated = false, holdCandle = false, locale = 'zh-CN', migrationRecord, routeSymbol = 'BTCUSDT', candidateFixture = fixtures[0] } = {}) { + const host = createStrategy27OverlayHost({ symbol: routeSymbol, + priceToCoordinate: price => 2000 - price * 10, coordinateToPrice: y => (2000 - y) / 10 }); + const { dom, chart, overlay } = host; dom.reconfigure({ url: `https://www.binance.com/${locale}/futures/${routeSymbol}` }); const page = dom.window; const clock = installStrategyClock(t, page); @@ -25,31 +27,11 @@ async function harness(t, { generated = false, beforeCreate, locale = 'zh-CN', m const promptBoundary = createStrategyPromptBoundary(); page.prompt = promptBoundary.pagePrompt; if (migrationRecord !== undefined) Object.defineProperty(page, Symbol.for('jh-userscripts.strategy29-preferences-migration'), { value: migrationRecord }); - const shapes = new Map([['user-owned', {}]]); - let resolution = '1S'; - let shapeSequence = 0; - const chart = { - resolution: () => resolution, symbol: () => routeSymbol, - createShape: async (point, options) => { - const id = `entry-owned-${++shapeSequence}`; - if (beforeCreate) await beforeCreate(); - shapes.set(id, { getPoints: () => [point], getProperties: () => ({ ...options.overrides, icon: options.icon, text: options.text }), setProperties: (properties) => Object.assign(options, properties) }); - return id; - }, - getShapeById: (id) => shapes.get(id), - getAllShapes: () => [...shapes.keys()].map((id) => ({ id })), - removeEntity: (id) => { assert.notEqual(id, 'user-owned'); assert.equal(shapes.delete(id), true); }, - getSeries: () => ({ data: () => ({ valueAt: (time) => [time, 100, 101, 99, 100] }) }), - _chartWidget: { model: () => ({ model: () => ({ - timeScale: () => ({ timePointToIndex: (time) => time }), - mainSeries: () => ({ - firstValue: () => 100, - priceScale: () => ({ priceToCoordinate: (price) => 2000 - price * 10, coordinateToPrice: (y) => (2000 - y) / 10 }), - dataUpdated: () => ({ subscribe() {}, unsubscribe() {} }), - }), - }) }) }, - }; - page.document.querySelector('iframe').contentWindow.tradingViewApi = { activeChart: () => chart }; + let candleHeld = holdCandle; + let heldTime = null; + overlay.timeScale.timePointToIndex = time => candleHeld || time === heldTime ? null : time; + chart.getSeries = () => ({ data: () => ({ valueAt: time => [time, 100, 101, 99, 100] }) }); + const markers = () => new Map(host.markers().map(node => [node.getAttribute('data-marker-id'), node])); const timers = clock.intervals; const menus = new Map(); const menuIds = new Map(); @@ -82,6 +64,9 @@ async function harness(t, { generated = false, beforeCreate, locale = 'zh-CN', m page.dispatchEvent(new page.Event('beforeunload')); await new Promise(setImmediate); for (const name of Object.keys(globals)) delete globalThis[name]; + assert.deepEqual([host.created.length, host.removed.length, host.saves.length], [0, 0, 0]); + assert.deepEqual([...host.shapes.keys()], ['user-owned']); + assert.deepEqual([host.interval.size, host.loaded.size, overlay.subscriptions], [0, 0, 0]); dom.window.close(); }); if (generated) { @@ -107,7 +92,7 @@ async function harness(t, { generated = false, beforeCreate, locale = 'zh-CN', m await until(() => pending('compound').length === 1); } return { - page, chart, shapes, requests, pending, respond, candidate, timers, menus, prompts, queries, promptBoundary, + page, chart, overlay, interval: host.interval, markers, requests, pending, respond, candidate, timers, menus, prompts, queries, promptBoundary, reset: () => respond('compound', { schema_version: 1, status: 'bootstrap', projection_kind: 'compound_candidates', requested_cursor: null, next_cursor: '1-0', runtime_epoch: 'a'.repeat(32), last_sequence: 1, bootstrap_observed_at_ms: 7000, records: [] }), ordinaryBootstrap: () => respond('ordinary', { schema_version: 1, status: 'bootstrap', projection_kind: 'strategy27_events', requested_cursor: null, next_cursor: '1-0', runtime_epoch: 'a'.repeat(32), last_sequence: 1, bootstrap_observed_at_ms: 7000, records: [] }), rows: () => page.document.querySelectorAll('[data-role="compound-row"]').length, @@ -115,7 +100,9 @@ async function harness(t, { generated = false, beforeCreate, locale = 'zh-CN', m clear: () => menus.get('清除 Strategy 27 图表标注')(), restart: () => menus.get('重新连接 Strategy 27 并恢复历史')(), setNow: value => clock.setTime(value), - setResolution: (value) => { resolution = value; }, + setResolution: host.setResolution, + holdTime(time) { heldTime = time; }, + releaseCandle() { candleHeld = false; heldTime = null; host.fireDataUpdated(); }, }; } @@ -128,7 +115,7 @@ for (const generated of [false, true]) { // Then neither strategy status nor clients nor drawings are created assert.equal(h.page.document.getElementById('jh-strategy27-event-status'), null); assert.equal(h.requests.length, 0); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); }); } @@ -142,13 +129,13 @@ test('user observes that real entrypoint starts independent clients and manual c await h.candidate(); // Then user observes that real entrypoint starts independent clients and manual clear preserves compound replay identity assert.equal(h.rows(), 1); - assert.equal(h.shapes.size, 3); + assert.equal(h.markers().size, 2); h.clear(); assert.equal(h.rows(), 0); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); await h.candidate(3, '2-0', '3-0'); assert.equal(h.rows(), 0); - assert.equal(h.shapes.size, 1); + assert.equal(h.markers().size, 0); assert.equal(h.pending('ordinary').length, 1); }); @@ -176,14 +163,14 @@ test('user observes that ordinary failure does not stop compound; the existing t h.setNow(7207001); h.tick(); assert.equal(h.rows(), 0); - assert.equal(h.shapes.size, 1); + assert.equal(h.markers().size, 0); assert.equal(h.pending('compound').length, 1); h.setResolution('1'); h.tick(); assert.equal(h.pending('compound').length, 0); assert.equal(h.requests.at(-1).aborted, true); assert.equal(h.page.document.querySelector('[data-role="compound-status"]'), null); - assert.equal(h.shapes.size, 1); + assert.equal(h.markers().size, 0); }); test('user observes that an unsupported compound route leaves ordinary polling alive without restarting on each context tick', async (t) => { @@ -200,7 +187,7 @@ test('user observes that an unsupported compound route leaves ordinary polling a h.page.history.pushState({}, '', '/zh-CN/markets'); assert.equal(h.pending('ordinary').length, 0); assert.equal(h.page.document.querySelector('[data-role="compound-status"]'), null); - assert.equal(h.shapes.size, 1); + assert.equal(h.markers().size, 0); }); test('user observes that a disappearing chart root retires both clients and removes only owned entities', async (t) => { @@ -215,7 +202,7 @@ test('user observes that a disappearing chart root retires both clients and remo assert.equal(h.pending('ordinary').length, 0); assert.equal(h.pending('compound').length, 0); assert.equal(h.rows(), 0); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); }); test('user observes that generated install artifact receives a candidate and cleans up its paired entities without affecting ordinary polling', async (t) => { @@ -226,15 +213,15 @@ test('user observes that generated install artifact receives a candidate and cle await h.candidate(); // Then user observes that generated install artifact receives a candidate and cleans up its paired entities without affecting ordinary polling assert.equal(h.rows(), 1); - assert.equal(h.shapes.size, 3); - const properties = [...h.shapes.entries()].filter(([id]) => id !== 'user-owned').map(([, shape]) => shape.getProperties()); - assert.equal(properties[0].icon, 0xf063); - assert.equal(properties[0].size, 36); - assert.equal(properties[1].text, '候选高'); + assert.equal(h.markers().size, 2); + const [icon, label] = [...h.markers().values()]; + assert.equal(icon.localName, 'path'); + assert.equal(icon.getAttribute('d'), 'M 0 18 L -12 2 L -4 2 L -4 -18 L 4 -18 L 4 2 L 12 2 Z'); + assert.equal(label.textContent, '候选高'); assert.equal(h.pending('ordinary').length, 1); h.clear(); assert.equal(h.rows(), 0); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); assert.equal(h.pending('ordinary').length, 1); h.setResolution('1'); h.tick(); @@ -243,31 +230,30 @@ test('user observes that generated install artifact receives a candidate and cle }); for (const generated of [false, true]) { - test(`user observes that ${generated ? 'generated' : 'source'} context timer restores externally evicted candidates without gateway traffic`, async (t) => { + test(`user observes that ${generated ? 'generated' : 'source'} context timer preserves SVG candidates without gateway traffic or native writes`, async (t) => { // Given the Binance page, gateway requests and Strategy 27 installation const h = await harness(t, { generated }); // When h.reset processes the configured inputs await h.reset(); await h.candidate(); - const oldIds = [...h.shapes.keys()].filter((id) => id !== 'user-owned'); - for (const id of oldIds) h.shapes.delete(id); + const oldIds = [...h.markers().keys()]; h.tick(); - await until(() => h.shapes.size === 3); - // Then user observes that the selected case context timer restores externally evicted candidates without gateway traffic + await until(() => h.markers().size === 2); + // Then user observes that the selected case context timer preserves SVG candidates without gateway traffic or native writes assert.equal(h.rows(), 1); - assert.equal(oldIds.some((id) => h.shapes.has(id)), false); - const repairedIds = [...h.shapes.keys()]; + assert.deepEqual([...h.markers().keys()], oldIds); + const retainedIds = [...h.markers().keys()]; h.tick(); await new Promise(setImmediate); - assert.deepEqual([...h.shapes.keys()], repairedIds); + assert.deepEqual([...h.markers().keys()], retainedIds); h.clear(); h.tick(); await new Promise(setImmediate); - assert.equal(h.shapes.size, 1); + assert.equal(h.markers().size, 0); assert.equal(h.rows(), 0); await h.candidate(3, '2-0', '3-0'); assert.equal(h.rows(), 0); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); }); } @@ -359,10 +345,10 @@ for (const generated of [false, true]) { await h.reset(); await h.respond('compound', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [compoundMessage(fixtures[0], 2), compoundMessage(fixtures[1], 3)] }); await until(() => h.pending('compound').length === 1); - const ids = [...h.shapes.keys()]; - const points = ids.slice(1).map((id) => h.shapes.get(id).getPoints()); + const ids = [...h.markers().keys()]; + const points = ids.map(id => h.markers().get(id).getAttribute('transform')); // Then user observes that the selected case compound recovery retains exact paired entities across epochs, stale cursors and 503 - assert.equal(ids.length, 6); + assert.equal(ids.length, 5); assert.equal(h.rows(), 2); const epoch = 'b'.repeat(32); await h.respond('compound', { schema_version: 1, status: 'ok', requested_cursor: '2-0', next_cursor: '3-0', messages: [ @@ -370,74 +356,73 @@ for (const generated of [false, true]) { compoundMessage(fixtures[0], 2, epoch), compoundMessage(fixtures[1], 3, epoch), ] }); await until(() => h.pending('compound').length === 1); - assert.deepEqual([...h.shapes.keys()], ids); + assert.deepEqual([...h.markers().keys()], ids); assert.equal(h.rows(), 2); await h.respond('compound', { schema_version: 1, status: 'reset', reason: 'stale_cursor', requested_cursor: '3-0', next_cursor: '5-0', messages: [] }, 409); await until(() => h.pending('compound').length === 1); assert.equal(new URL(h.pending('compound')[0].options.url).pathname, '/v1/strategy27/compound-candidates/bootstrap'); - assert.deepEqual([...h.shapes.keys()], ids); + assert.deepEqual([...h.markers().keys()], ids); await h.respond('compound', { schema_version: 1, status: 'bootstrap', projection_kind: 'compound_candidates', requested_cursor: null, next_cursor: '5-0', runtime_epoch: 'c'.repeat(32), last_sequence: 4, bootstrap_observed_at_ms: 7000, records: [compoundMessage(fixtures[0], 3, 'c'.repeat(32))] }); await until(() => h.pending('compound').length === 1); - assert.deepEqual([...h.shapes.keys()], ids); + assert.deepEqual([...h.markers().keys()], ids); assert.equal(h.rows(), 2, 'the candidate absent from the new snapshot remains visible'); await h.respond('compound', { schema_version: 1, status: 'error', error_code: 'redis_unavailable' }, 503); await until(() => h.page.document.querySelector('[data-role="compound-status"]').dataset.state === 'inactive'); - assert.deepEqual([...h.shapes.keys()], ids); + assert.deepEqual([...h.markers().keys()], ids); assert.equal(h.pending('compound').length, 0); t.mock.timers.tick(2000); await until(() => h.pending('compound').length === 1); assert.equal(new URL(h.pending('compound')[0].options.url).pathname, '/v1/strategy27/compound-candidates/bootstrap'); await h.respond('compound', { schema_version: 1, status: 'bootstrap', projection_kind: 'compound_candidates', requested_cursor: null, next_cursor: '8-0', runtime_epoch: 'd'.repeat(32), last_sequence: 1, bootstrap_observed_at_ms: 7000, records: [] }); await until(() => h.pending('compound').length === 1); - assert.deepEqual([...h.shapes.keys()], ids); - assert.deepEqual(ids.slice(1).map((id) => h.shapes.get(id).getPoints()), points); + assert.deepEqual([...h.markers().keys()], ids); + assert.deepEqual(ids.map(id => h.markers().get(id).getAttribute('transform')), points); assert.equal(h.rows(), 2); assert.equal(h.pending('ordinary').length, 1); h.setNow(7207001); h.tick(); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); assert.equal(h.rows(), 0); }); - test(`user observes that ${generated ? 'generated' : 'source'} compound repair failure freezes surviving pairs until clear or context retirement`, async (t) => { + test(`user observes that ${generated ? 'generated' : 'source'} compound projection failure clears invalid SVG and retains history until explicit recovery`, async (t) => { // Given the Binance page, gateway requests and Strategy 27 installation const h = await harness(t, { generated }); // When h.reset processes the configured inputs await h.reset(); await h.respond('compound', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [compoundMessage(fixtures[0], 2), compoundMessage(fixtures[1], 3)] }); await until(() => h.pending('compound').length === 1); - const ids = [...h.shapes.keys()]; - // Then user observes that the selected case compound repair failure freezes surviving pairs until clear or context retirement - assert.equal(ids.length, 5); - h.shapes.delete(ids[1]); - const create = h.chart.createShape; - h.chart.createShape = (point, options) => create({ ...point, time: point.time - 1 }, options); - h.tick(); + const ids = [...h.markers().keys()]; + // Then user observes that the selected case compound projection failure clears invalid SVG and retains history until explicit recovery + assert.equal(ids.length, 4); + h.overlay.setProjection({ price: () => Number.NaN }); + h.overlay.events.modeChanged.emit(); + h.overlay.flushFrames(); await until(() => h.page.document.querySelector('[data-role="compound-status"]').dataset.state === 'error'); - const surviving = [ids[0], ...ids.slice(2)]; - assert.deepEqual([...h.shapes.keys()], surviving); + const surviving = []; + assert.deepEqual([...h.markers().keys()], surviving); assert.equal(h.rows(), 2); assert.equal(h.pending('compound').length, 0); assert.equal(h.pending('ordinary').length, 1); - assert.match(h.page.document.querySelector('[data-role="compound-status"]').textContent, /历史记录已保留.*time alignment failed/); + assert.match(h.page.document.querySelector('[data-role="compound-status"]').textContent, /历史记录已保留.*coordinates are invalid/); h.tick(); await new Promise(setImmediate); - assert.deepEqual([...h.shapes.keys()], surviving); + assert.deepEqual([...h.markers().keys()], surviving); h.clear(); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); assert.equal(h.rows(), 0); - assert.match(h.page.document.querySelector('[data-role="compound-status"]').textContent, /time alignment failed/); - h.chart.createShape = create; + assert.match(h.page.document.querySelector('[data-role="compound-status"]').textContent, /coordinates are invalid/); + h.overlay.setProjection({ price: value => 2000 - value * 10 }); h.restart(); await h.reset(); await h.candidate(); assert.equal(h.rows(), 1); - assert.equal(h.shapes.size, 3); + assert.equal(h.markers().size, 2); await h.respond('compound', 'invalid JSON'); await until(() => h.page.document.querySelector('[data-role="compound-status"]').dataset.state === 'error'); h.setResolution('1'); h.tick(); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); assert.equal(h.rows(), 0); assert.equal(h.pending('compound').length, 0); }); @@ -480,7 +465,7 @@ for (const generated of [false, true]) { bootstrap_observed_at_ms: 7000, records: [compoundMessage()], }); - await until(() => h.shapes.size === 4); + await until(() => h.markers().size === 3); // Then user observes that the selected case refresh bootstrap rebuilds ordinary and compound markers before live polling assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); assert.equal(h.rows(), 1); @@ -494,49 +479,45 @@ for (const generated of [false, true]) { } for (const generated of [false, true]) { - test(`user observes that ${generated ? 'generated' : 'source'} timer restores ordinary drawings and prunes both lifecycles before repair`, async (t) => { + test(`user observes that ${generated ? 'generated' : 'source'} timer preserves ordinary SVG nodes and prunes both retained lifecycles`, async (t) => { // Given the Binance page, gateway requests and Strategy 27 installation const h = await harness(t, { generated }); // When h.ordinaryBootstrap processes the configured inputs await h.ordinaryBootstrap(); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinaryMessage()] }); - await until(() => h.shapes.size === 2 || h.page.document.getElementById('jh-strategy27-event-status')?.dataset.state === 'error'); - // Then user observes that the selected case timer restores ordinary drawings and prunes both lifecycles before repair - assert.equal(h.shapes.size, 2, h.page.document.getElementById('jh-strategy27-event-status')?.textContent); - const oldOrdinary = [...h.shapes.keys()].find((id) => id !== 'user-owned'); - h.shapes.delete(oldOrdinary); + await until(() => h.markers().size === 1 || h.page.document.getElementById('jh-strategy27-event-status')?.dataset.state === 'error'); + // Then user observes that the selected case timer preserves ordinary SVG nodes and prunes both retained lifecycles + assert.equal(h.markers().size, 1, h.page.document.getElementById('jh-strategy27-event-status')?.textContent); + const oldOrdinary = [...h.markers().keys()][0]; h.tick(); - await until(() => h.shapes.size === 2); - assert.equal(h.shapes.has(oldOrdinary), false); + await until(() => h.markers().size === 1); + assert.equal(h.markers().has(oldOrdinary), true); assert.equal(h.pending('ordinary').length, 1); await h.reset(); await h.candidate(); - assert.equal(h.shapes.size, 4); - for (const id of [...h.shapes.keys()]) if (id !== 'user-owned') h.shapes.delete(id); + assert.equal(h.markers().size, 3); h.setNow(7207001); h.tick(); await new Promise(setImmediate); assert.equal(h.rows(), 0); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); }); } test('user observes that timer expiry cancels an ordinary first creation that is still awaiting TradingView', async (t) => { // Given the Binance page, gateway requests and Strategy 27 installation - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const h = await harness(t, { beforeCreate: async () => { entered.resolve(); await release.promise; } }); + const h = await harness(t, { holdCandle: true }); // When h.ordinaryBootstrap processes the configured inputs await h.ordinaryBootstrap(); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinaryMessage()] }); - await entered.promise; + await until(() => h.overlay.events.dataUpdated.size > 0); h.setNow(7207001); h.tick(); - release.resolve(); + h.releaseCandle(); await until(() => h.pending('ordinary').length === 1); // Then user observes that timer expiry cancels an ordinary first creation that is still awaiting TradingView - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); }); @@ -548,14 +529,14 @@ for (const generated of [false, true]) { await h.ordinaryBootstrap(); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinaryMessage()] }); await until(() => h.pending('ordinary').length === 1); - const ids = [...h.shapes.keys()]; + const ids = [...h.markers().keys()]; // Then user observes that the selected case live 503 retains ordinary history and resumes at the same cursor - assert.equal(ids.length, 2); + assert.equal(ids.length, 1); await h.respond('ordinary', { schema_version: 1, status: 'error', error_code: 'redis_unavailable' }, 503); await until(() => h.page.document.getElementById('jh-strategy27-event-status') !== null); assert.equal(h.page.document.getElementById('jh-strategy27-event-status').dataset.state, 'inactive'); h.tick(); - assert.deepEqual([...h.shapes.keys()], ids); + assert.deepEqual([...h.markers().keys()], ids); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); t.mock.timers.tick(999); assert.equal(h.pending('ordinary').length, 0); @@ -564,12 +545,12 @@ for (const generated of [false, true]) { assert.equal(new URL(h.pending('ordinary')[0].options.url).searchParams.get('cursor'), '2-0'); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '2-0', next_cursor: '3-0', messages: [{ ...ordinaryMessage(), sequence: 3 }] }); await until(() => h.pending('ordinary').length === 1); - assert.deepEqual([...h.shapes.keys()], ids); + assert.deepEqual([...h.markers().keys()], ids); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); assert.equal(h.page.document.getElementById('jh-strategy27-event-status'), null); }); - test(`user observes that ${generated ? 'generated' : 'source'} fatal repair preserves surviving history until expiry or explicit restart`, async (t) => { + test(`user observes that ${generated ? 'generated' : 'source'} fatal projection retains panel history until expiry or explicit restart`, async (t) => { // Given the Binance page, gateway requests and Strategy 27 installation const h = await harness(t, { generated }); // When h.ordinaryBootstrap processes the configured inputs @@ -578,26 +559,25 @@ for (const generated of [false, true]) { const second = { ...ordinaryMessage(), event_id: 'c'.repeat(64), sequence: 3 }; await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '3-0', messages: [first, second] }); await until(() => h.pending('ordinary').length === 1); - // Then user observes that the selected case fatal repair preserves surviving history until expiry or explicit restart - assert.equal(h.shapes.size, 3); - const ids = [...h.shapes.keys()].filter((id) => id !== 'user-owned'); - h.shapes.delete(ids[0]); - const create = h.chart.createShape; - h.chart.createShape = async (point, options) => create({ ...point, time: point.time - 1 }, options); - h.tick(); + // Then user observes that the selected case fatal projection retains panel history until expiry or explicit restart + assert.equal(h.markers().size, 2); + const ids = [...h.markers().keys()]; + h.overlay.setProjection({ price: () => Number.NaN }); + h.overlay.events.modeChanged.emit(); + h.overlay.flushFrames(); await until(() => h.page.document.getElementById('jh-strategy27-event-status')?.dataset.state === 'error'); - assert.match(h.page.document.getElementById('jh-strategy27-event-status').textContent, /time alignment failed/); - assert.deepEqual([...h.shapes.keys()], ['user-owned', ids[1]]); + assert.match(h.page.document.getElementById('jh-strategy27-event-status').textContent, /coordinates are invalid/); + assert.deepEqual([...h.markers().keys()], []); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 2); assert.equal(h.pending('ordinary').length, 0); h.tick(); await new Promise(setImmediate); - assert.deepEqual([...h.shapes.keys()], ['user-owned', ids[1]]); + assert.deepEqual([...h.markers().keys()], []); h.setNow(7207001); h.tick(); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); - h.chart.createShape = create; + h.overlay.setProjection({ price: value => 2000 - value * 10 }); h.setNow(7000); h.restart(); assert.equal(h.pending('ordinary').length, 1); @@ -605,7 +585,7 @@ for (const generated of [false, true]) { await h.ordinaryBootstrap(); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [first] }); await until(() => h.pending('ordinary').length === 1); - assert.equal(h.shapes.size, 2); + assert.equal(h.markers().size, 1); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); }); } @@ -625,18 +605,18 @@ for (const generated of [false, true]) { await h.ordinaryBootstrap(); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinaryMessage()] }); await until(() => h.pending('ordinary').length === 1); - const ids = [...h.shapes.keys()]; + const ids = [...h.markers().keys()]; // Then user observes that the selected case transport epoch reset retains historical arrows and rehydrates without duplication - assert.equal(ids.length, 2); + assert.equal(ids.length, 1); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '2-0', next_cursor: '3-0', messages: [ordinaryStreamReset()] }); await until(() => h.pending('ordinary').length === 1); - assert.deepEqual([...h.shapes.keys()], ids); + assert.deepEqual([...h.markers().keys()], ids); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); assert.match(h.page.document.body.textContent, /历史记录/); const replay = { ...ordinaryMessage(), runtime_epoch: 'c'.repeat(32), sequence: 2 }; await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '3-0', next_cursor: '4-0', messages: [replay] }); await until(() => h.pending('ordinary').length === 1); - assert.deepEqual([...h.shapes.keys()], ids); + assert.deepEqual([...h.markers().keys()], ids); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); assert.doesNotMatch(h.page.document.body.textContent, /历史记录/); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '4-0', next_cursor: '5-0', messages: [ordinaryStreamReset('d'.repeat(32))] }); @@ -644,7 +624,7 @@ for (const generated of [false, true]) { h.setNow(7207001); h.tick(); await new Promise(setImmediate); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); }); } @@ -658,20 +638,20 @@ for (const generated of [false, true]) { const ordinary = ordinaryMessage(); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinary] }); await until(() => h.pending('ordinary').length === 1); - const ids = [...h.shapes.keys()]; + const ids = [...h.markers().keys()]; await h.respond('ordinary', { schema_version: 1, status: 'reset', reason: 'stale_cursor', requested_cursor: '2-0', next_cursor: '5-0', messages: [] }, 409); await until(() => h.pending('ordinary').length === 1); // Then user observes that the selected case stale cursor bootstrap merges retained history and context exit removes it - assert.deepEqual([...h.shapes.keys()], ids); + assert.deepEqual([...h.markers().keys()], ids); await h.respond('ordinary', { schema_version: 1, status: 'bootstrap', projection_kind: 'strategy27_events', requested_cursor: null, next_cursor: '6-0', runtime_epoch: ordinary.runtime_epoch, last_sequence: 2, bootstrap_observed_at_ms: 7000, records: [{ event_id: ordinary.event_id, event_envelope: ordinary, marker_envelope: ordinary, outcome_envelope: null }] }); await until(() => h.pending('ordinary').length === 1); - assert.deepEqual([...h.shapes.keys()], ids); + assert.deepEqual([...h.markers().keys()], ids); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); h.setResolution('1'); h.tick(); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); }); @@ -684,21 +664,21 @@ for (const generated of [false, true]) { await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '81-0', messages }); await until(() => h.pending('ordinary').length === 1); // Then user observes that the selected case retained display history stays bounded across epochs and manual clear removes it - assert.equal(h.shapes.size, 81); - const firstId = [...h.shapes.keys()][1]; + assert.equal(h.markers().size, 80); + const firstId = [...h.markers().keys()][0]; await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '81-0', next_cursor: '82-0', messages: [ordinaryStreamReset()] }); await until(() => h.pending('ordinary').length === 1); - assert.equal(h.shapes.size, 81); + assert.equal(h.markers().size, 80); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '82-0', next_cursor: '83-0', messages: [{ ...ordinaryMessage(), runtime_epoch: 'c'.repeat(32), sequence: 2 }] }); await until(() => h.pending('ordinary').length === 1); - assert.equal(h.shapes.size, 81); - assert.equal(h.shapes.has(firstId), false); + assert.equal(h.markers().size, 80); + assert.equal(h.markers().has(firstId), false); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 8); h.clear(); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); h.tick(); await new Promise(setImmediate); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); }); } @@ -711,20 +691,20 @@ test('user observes that display capacity uses last observation rather than inse const events = Array.from({ length: 80 }, (_, i) => ({ ...ordinaryMessage(), sequence: i + 2, event_id: i.toString(16).padStart(64, '0') })); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '81-0', messages: events }); await until(() => h.pending('ordinary').length === 1); - const ids = [...h.shapes.keys()]; + const ids = [...h.markers().keys()]; await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '81-0', next_cursor: '82-0', messages: [ordinaryStreamReset(), { ...events[0], runtime_epoch: 'c'.repeat(32), sequence: 2, observed_at_ms: 7000 }] }); await until(() => h.pending('ordinary').length === 1); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '82-0', next_cursor: '83-0', messages: [{ ...ordinaryMessage(), runtime_epoch: 'c'.repeat(32), sequence: 3, observed_at_ms: 7000 }] }); await until(() => h.pending('ordinary').length === 1); // Then user observes that display capacity uses last observation rather than insertion order after epoch rehydration - assert.equal(h.shapes.size, 81); - assert.equal(h.shapes.has(ids[1]), true); - assert.equal(h.shapes.has(ids[2]), false); + assert.equal(h.markers().size, 80); + assert.equal(h.markers().has(ids[0]), true); + assert.equal(h.markers().has(ids[1]), false); h.setNow(7202001); h.tick(); await new Promise(setImmediate); - assert.equal(h.shapes.size, 3); - assert.equal(h.shapes.has(ids[1]), true); + assert.equal(h.markers().size, 2); + assert.equal(h.markers().has(ids[0]), true); }); test('user observes that older bootstrap replay cannot shorten the retained display lifetime', async (t) => { @@ -734,7 +714,7 @@ test('user observes that older bootstrap replay cannot shorten the retained disp await h.ordinaryBootstrap(); await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [{ ...ordinaryMessage(), observed_at_ms: 7000 }] }); await until(() => h.pending('ordinary').length === 1); - const ids = [...h.shapes.keys()]; + const ids = [...h.markers().keys()]; await h.respond('ordinary', { schema_version: 1, status: 'reset', reason: 'stale_cursor', requested_cursor: '2-0', next_cursor: '5-0', messages: [] }, 409); await until(() => h.pending('ordinary').length === 1); const old = ordinaryMessage(); @@ -746,12 +726,12 @@ test('user observes that older bootstrap replay cannot shorten the retained disp h.tick(); await new Promise(setImmediate); // Then user observes that older bootstrap replay cannot shorten the retained display lifetime - assert.deepEqual([...h.shapes.keys()], ids); + assert.deepEqual([...h.markers().keys()], ids); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); h.setNow(7207001); h.tick(); await new Promise(setImmediate); - assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.deepEqual([...h.markers().keys()], []); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); }); @@ -775,7 +755,7 @@ for (const generated of [false, true]) { assert.equal(status.dataset.state, 'removed'); const connection = h.page.document.querySelector('[data-role="ordinary-connection-status"]'); assert.match(connection.textContent, /已连接/); - assert.equal(h.shapes.size, 2); + assert.equal(h.markers().size, 1); h.setResolution('1'); h.tick(); assert.equal(h.page.document.querySelector('[data-role="ordinary-monitoring-status"]'), null); }); @@ -822,15 +802,15 @@ for (const generated of [false, true]) { const priorPanel = panel(); const ordinaryRequest = h.pending('ordinary')[0]; const compoundRequest = h.pending('compound')[0]; - const shapeIds = [...h.shapes.keys()]; - assert.equal([...h.shapes.values()].filter(shape => shape.getProperties?.().text === 'High candidate').length, 1); + const shapeIds = [...h.markers().keys()]; + assert.equal([...h.markers().values()].filter(shape => shape.textContent === 'High candidate').length, 1); h.page.history.pushState({}, '', '/zh-CN/futures/BTCUSDT'); await until(() => h.menus.has('重新连接 Strategy 27 并恢复历史')); assert.equal(panel(), priorPanel); assert.equal(h.pending('ordinary')[0], ordinaryRequest); assert.equal(h.pending('compound')[0], compoundRequest); - assert.deepEqual([...h.shapes.keys()], shapeIds); - assert.equal([...h.shapes.values()].filter(shape => shape.getProperties?.().text === '候选高').length, 1); + assert.deepEqual([...h.markers().keys()], shapeIds); + assert.equal([...h.markers().values()].filter(shape => shape.textContent === '候选高').length, 1); assert.match(panel().textContent, /已收到观察记录/); assert.doesNotMatch(panel().textContent, /Monitoring|Connected|Historical/); assert.equal(h.menus.size, 4); @@ -840,7 +820,7 @@ for (const generated of [false, true]) { assert.equal(panel(), priorPanel); assert.equal(h.pending('ordinary')[0], ordinaryRequest); assert.equal(h.pending('compound')[0], compoundRequest); - assert.deepEqual([...h.shapes.keys()], shapeIds); + assert.deepEqual([...h.markers().keys()], shapeIds); assert.doesNotMatch(panel().textContent, /\p{Script=Han}/u); assert.equal(h.menus.size, 4); }); @@ -903,7 +883,81 @@ test(`user observes that ${generated ? 'generated' : 'source'} Unicode URL and c await h.candidate(); // Then user observes that the selected case Unicode URL and chart symbol start both clients and draw the Python candidate assert.equal(h.rows(), 1); - assert.equal(h.shapes.size, 3); + assert.equal(h.markers().size, 2); assert.ok(h.requests.every((request) => new URL(request.options.url).searchParams.get('symbol') === candidateFixture.symbol)); }); } + +for (const generated of [false, true]) { + test(`user keeps the ${generated ? 'generated' : 'source'} observer stopped when a pending candle arrives after an asynchronous render failure`, async t => { + // Given one retained event and a second accepted event waiting on its exact candle + const h = await harness(t, { generated }); + await h.ordinaryBootstrap(); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinaryMessage()] }); + await until(() => h.pending('ordinary').length === 1); + h.holdTime(2); + const second = ordinaryMessage(); + Object.assign(second, { sequence: 3, event_id: 'c'.repeat(64), event_time_ms: 3000, observed_at_ms: 3000 }); + Object.assign(second.payload.event.latest_snapshot, { bucket_start_ms: 2000, bucket_end_ms: 3000, source_bucket_count: 4 }); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '2-0', next_cursor: '3-0', messages: [second] }); + await until(() => h.overlay.events.dataUpdated.size === 2 || h.page.document.getElementById('jh-strategy27-event-status')?.dataset.state === 'error'); + assert.equal(h.overlay.events.dataUpdated.size, 2, h.page.document.getElementById('jh-strategy27-event-status')?.textContent); + // When a viewport frame fails and the pending candle subsequently becomes available + h.overlay.setProjection({ price: () => Number.NaN }); + h.overlay.events.modeChanged.emit(); + h.overlay.flushFrames(); + h.overlay.setProjection({ price: value => 2000 - value * 10 }); + h.releaseCandle(); + await new Promise(setImmediate); + h.tick(); + // Then neither the late candle nor the context timer resurrects chart presentation or polling + assert.equal(h.markers().size, 0); + assert.equal(h.pending('ordinary').length, 0); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); + assert.equal(h.page.document.getElementById('jh-strategy27-event-status').dataset.state, 'error'); + }); +} + +for (const generated of [false, true]) { + test(`user restarts the ${generated ? 'generated' : 'source'} chart context after an interval round trip between polls`, async t => { + // Given a one-second chart with a verified compound candidate and pending live requests + const h = await harness(t, { generated }); + await h.reset(); + await h.candidate(); + const oldRequest = h.pending('compound')[0]; + const oldOrdinary = h.pending('ordinary')[0]; + // When the chart leaves and returns to one second before the next context poll + h.setResolution('1'); + h.setResolution('1S'); + h.tick(); + // Then the old interval epoch is retired and fresh bootstrap requests replace it + assert.equal(oldRequest.aborted, true); + assert.equal(oldOrdinary.aborted, true); + assert.equal(h.markers().size, 0); + assert.equal(new URL(h.pending('compound')[0].options.url).pathname, '/v1/strategy27/compound-candidates/bootstrap'); + assert.equal(new URL(h.pending('ordinary')[0].options.url).pathname, '/v1/strategy27/events/bootstrap'); + }); +} + +for (const generated of [false, true]) { + test(`user discards the ${generated ? 'generated' : 'source'} first pending marker when the interval changes before an overlay exists`, async t => { + // Given the first ordinary marker is waiting for a candle and no SVG has been installed + const h = await harness(t, { generated, holdCandle: true }); + await h.ordinaryBootstrap(); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinaryMessage()] }); + await until(() => h.overlay.events.dataUpdated.size === 1); + assert.equal(h.overlay.pane.querySelectorAll('svg').length, 0); + // When an interval round trip occurs and the previous candle wait can finally complete + h.setResolution('1'); + h.setResolution('1S'); + h.releaseCandle(); + await new Promise(setImmediate); + // Then the invalidated epoch cannot publish a first marker and the next poll replaces its owner + assert.equal(h.markers().size, 0); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); + assert.equal(h.pending('ordinary').length, 0); + h.tick(); + assert.equal(h.interval.size, 1); + assert.equal(new URL(h.pending('ordinary')[0].options.url).pathname, '/v1/strategy27/events/bootstrap'); + }); +} diff --git a/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js b/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js index d09b082..ad94859 100644 --- a/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js +++ b/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js @@ -2,400 +2,242 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createTradingViewCompoundLayer } from '../../../src/binance-strategy27-events/dom/tradingview-compound-layer.js'; import { createTradingViewEventLayer } from '../../../src/binance-strategy27-events/dom/tradingview-event-layer.js'; +import { createStrategy27OverlayHost } from '../../helpers/strategy27-overlay-host.js'; const annotation = (overrides = {}) => ({ markerShape: 'arrow_down', markerLabel: '候选高', markerColor: '#B71C3B', markerTime: 10, markerPrice: 1.25, ...overrides }); -const deferred = () => Promise.withResolvers(); +const fixture = options => createStrategy27OverlayHost(options); +const compound = (host, options = {}) => createTradingViewCompoundLayer(host.target, { maxCandidates: 80, ...options }); +const coordinate = node => node.getAttribute('transform'); + +test('user cannot resurrect a failed compound overlay by changing language', async () => { + // Given a valid candidate whose native projection later fails + const host = fixture(); + const errors = []; + const layer = compound(host, { onRenderError: error => errors.push(error.message) }); + await layer.renderCandidate('first', annotation(), 11000); + host.overlay.setProjection({ price: () => NaN }); + host.overlay.events.priceRangeChanged.emit(); + host.overlay.flushFrames(); + assert.equal(host.markers().length, 0); + // When language changes after the native projection becomes valid again + host.overlay.setProjection({ price: price => 2000 - price * 1000 }); + layer.setLocale('en'); + // Then terminal rendering failure cannot recreate any overlay or subscription + assert.equal(errors.length, 1); + assert.equal(host.markers().length, 0); + assert.equal(host.overlay.subscriptions, 0); + layer.clear(); + host.close(); +}); -function fixture({ bars = [[10, 1.25, 1.3, 1.2, 1.25]], beforeCreate, shiftSeconds = 0, wrongProperties = false, removeError } = {}) { - const candles = new Map(bars.map((bar) => [bar[0], bar])); - const shapes = new Map([['user-owned', { untouched: true }]]); - const listeners = new Set(); - const created = []; - const removed = []; - let counter = 0; - const chart = { - symbol: () => 'BTRUSDT', - resolution: () => '1S', - async createShape(point, options) { - counter += 1; - const id = `owned-${counter}`; - if (beforeCreate) await beforeCreate(counter); - const properties = { ...options.overrides, ...(options.icon === undefined ? {} : { icon: options.icon }), ...(options.text === undefined ? {} : { text: options.text }) }; - if (wrongProperties) properties.color = '#000000'; - const shape = { getPoints: () => [{ ...point, time: point.time + shiftSeconds }], getProperties: () => properties, setProperties: (next, saveDefaults) => { assert.equal(saveDefaults, false); Object.assign(properties, next); } }; - shapes.set(id, shape); - created.push({ id, point, options }); - return id; - }, - getShapeById: (id) => shapes.get(id), - getAllShapes: () => [...shapes.keys()].map((id) => ({ id })), - removeEntity(id) { - assert.notEqual(id, 'user-owned'); - removed.push(id); - if (removeError) throw removeError; - assert.equal(shapes.delete(id), true, `duplicate/foreign removal: ${id}`); - }, - getSeries: () => ({ data: () => ({ valueAt: (index) => candles.get(index) ?? null }) }), - _chartWidget: { model: () => ({ model: () => ({ - timeScale: () => ({ timePointToIndex: (time, mode) => mode === 0 ? (candles.has(time) ? time : null) : ([...candles.keys()].filter((t) => t <= time).sort((a, b) => b - a)[0] ?? null) }), - mainSeries: () => ({ - firstValue: () => 1, - priceScale: () => ({ priceToCoordinate: (p) => 2000 - p * 1000, coordinateToPrice: (y) => (2000 - y) / 1000 }), - dataUpdated: () => ({ subscribe: (_, callback) => listeners.add(callback), unsubscribe: (_, callback) => listeners.delete(callback) }), - }), - }) }) }, - }; - return { chart, shapes, listeners, created, removed, layer: (maxCandidates = 80) => createTradingViewCompoundLayer({ chart }, { maxCandidates, candleWaitMs: 1 }) }; -} +test('user renders ordinary and compound markers without mutating native drawings', async () => { + // Given independent ordinary and compound layers beside a user drawing + const host = fixture(); + const ordinary = createTradingViewEventLayer(host.target, { maxEvents: 80, maxAgeMs: 60000 }); + const layer = compound(host); + // When both types render update reconcile and clear their owned presentation + await ordinary.renderOpened('ordinary', annotation(), 11000); + await layer.renderCandidate('candidate', annotation(), 11000); + assert.equal(host.markers().length, 3); + await ordinary.renderUpdated('ordinary', annotation(), 12000); + await layer.reconcile(); + layer.setLocale('en'); + ordinary.clear(); + assert.equal(host.markers().length, 2); + layer.clear(); + // Then no native shape or save API was called and the user drawing is unchanged + assert.deepEqual([host.created.length, host.removed.length, host.saves.length], [0, 0, 0]); + assert.deepEqual([...host.shapes.entries()], [['user-owned', { untouched: true }]]); + assert.equal(host.markers().length, 0); + assert.equal(host.overlay.subscriptions, 0); + host.close(); +}); -test('user observes that native compound arrows are larger, centered outside the candle and own a separate short label', async () => { - // Given a native chart and compound candidate annotations - const f = fixture(); - // When f.layer processes the configured inputs - const layer = f.layer(); +test('user sees compound arrows centered outside candle edges with separate localized labels', async () => { + // Given opposite compound candidates on one candle + const host = fixture(); + const layer = compound(host); + // When high and low candidate pairs are rendered await layer.renderCandidate('high', annotation(), 11000); await layer.renderCandidate('low', annotation({ markerShape: 'arrow_up', markerLabel: '候选低', markerColor: '#087F5B' }), 11000); - // Then user observes that native compound arrows are larger, centered outside the candle and own a separate short label + // Then centered arrows and labels keep their original price anchors and styles assert.equal(layer.size, 2); - assert.equal(f.shapes.size, 5); - assert.deepEqual(f.created.map((item) => item.point), [ - { time: 10, price: 1.326 }, { time: 10, price: 1.366 }, - { time: 10, price: 1.174 }, { time: 10, price: 1.156 }, + assert.deepEqual(host.markers().map(coordinate), ['translate(100 674)', 'translate(100 634)', 'translate(100 826)', 'translate(100 844)']); + assert.deepEqual(host.markers().map(node => [node.tagName, node.dataset.markerType, node.getAttribute('fill')]), [ + ['path', 'compound-icon', '#B71C3B'], ['text', 'compound-label', '#B71C3B'], + ['path', 'compound-icon', '#087F5B'], ['text', 'compound-label', '#087F5B'], ]); - assert.deepEqual(f.created.map((item) => item.options.shape), ['icon', 'text', 'icon', 'text']); - assert.deepEqual(f.created.filter((item) => item.options.shape === 'icon').map((item) => [item.options.icon, item.options.overrides.size]), [[0xf063, 36], [0xf062, 36]]); - assert.deepEqual(f.created.filter((item) => item.options.shape === 'text').map((item) => item.options.text), ['候选高', '候选低']); - for (const { options } of f.created) { - assert.equal(options.disableSave, true); - assert.equal(options.disableUndo, true); - assert.equal(options.disableSelection, true); - assert.equal(options.showInObjectsTree, false); - assert.equal(options.lock, true); - } + assert.deepEqual(host.markers().filter(node => node.tagName === 'text').map(node => node.textContent), ['候选高', '候选低']); + const original = host.markers(); + layer.setLocale('en'); + assert.deepEqual(host.markers(), original); + assert.deepEqual(host.markers().filter(node => node.tagName === 'text').map(node => node.textContent), ['High candidate', 'Low candidate']); + assert.equal(host.markers()[1].getAttribute('font-size'), '12'); + assert.equal(host.markers()[1].getAttribute('font-weight'), '700'); layer.clear(); - assert.deepEqual([...f.shapes.keys()], ['user-owned']); - assert.equal(f.removed.length, 4); + host.close(); }); -test('user observes that same-time rules own independent fixed slots and eviction never moves surviving entities', async () => { - // Given a native chart and compound candidate annotations - const f = fixture(); - // When f.layer processes the configured inputs - const layer = f.layer(); - await layer.renderCandidate('impact', annotation(), 11000); - await layer.renderCandidate('passive', annotation(), 11000); - await layer.renderCandidate('impact', annotation(), 11000); - const survivor = f.created.slice(2).map((item) => ({ ...item.point })); - // Then user observes that same-time rules own independent fixed slots and eviction never moves surviving entities - assert.deepEqual(survivor, [{ time: 10, price: 1.39 }, { time: 10, price: 1.43 }]); - layer.remove('impact'); - await layer.renderCandidate('reinforcement', annotation(), 11000); - assert.deepEqual(f.created.slice(2, 4).map((item) => item.point), survivor); - assert.deepEqual(f.created.slice(4).map((item) => item.point), f.created.slice(0, 2).map((item) => item.point)); +test('user retains compound slots when another candidate is removed and reuses the freed slot', async () => { + // Given two independent high candidates on the same resolved candle + const host = fixture(); + const layer = compound(host); + await layer.renderCandidate('first', annotation(), 11000); + await layer.renderCandidate('second', annotation(), 11001); + const second = host.markers().filter(node => node.dataset.markerId.includes(':second:')); + // When the first candidate is removed and replaced + layer.remove('first'); + await layer.renderCandidate('third', annotation(), 11002); + await layer.renderCandidate('second', annotation({ markerColor: '#000000' }), 11003); + // Then surviving nodes stay fixed and the new candidate occupies slot zero + assert.deepEqual(second.map(coordinate), ['translate(100 610)', 'translate(100 570)']); + assert.deepEqual(host.markers().filter(node => node.dataset.markerId.includes(':second:')), second); + assert.deepEqual(host.markers().filter(node => node.dataset.markerId.includes(':third:')).map(coordinate), ['translate(100 674)', 'translate(100 634)']); + assert.equal(second[0].getAttribute('fill'), '#B71C3B'); assert.equal(layer.size, 2); - assert.equal(f.created.length, 6); - assert.deepEqual(f.removed, ['owned-1', 'owned-2']); -}); - -test('user observes that different no-trade decision seconds sharing a prior candle receive distinct slots', async () => { - // Given a native chart and compound candidate annotations - const f = fixture(); - // When f.layer processes the configured inputs - const layer = f.layer(); - await layer.renderCandidate('second-11', annotation({ markerTime: 11 }), 12000); - await layer.renderCandidate('second-12', annotation({ markerTime: 12 }), 13000); - // Then user observes that different no-trade decision seconds sharing a prior candle receive distinct slots - assert.deepEqual(f.created.map((item) => item.point.time), [10, 10, 10, 10]); - assert.deepEqual(f.created.filter((item) => item.options.shape === 'icon').map((item) => item.point.price), [1.326, 1.39]); - assert.equal(f.listeners.size, 0); -}); - -test('user observes that remove cancels a pending candle wait without waiting for its deadline', async () => { - // Given a native chart and compound candidate annotations - const f = fixture({ bars: [] }); - // When f.layer processes the configured inputs - const layer = f.layer(); - const result = layer.renderCandidate('waiting', annotation(), 11000); - // Then user observes that remove cancels a pending candle wait without waiting for its deadline - assert.equal(f.listeners.size, 1); - layer.remove('waiting'); - assert.equal(await result, false); - assert.equal(f.listeners.size, 0); - assert.equal(f.created.length, 0); -}); - -test('user observes that clear removes a native icon that finishes creation late and never creates its label', async () => { - // Given a native chart and compound candidate annotations - const entered = deferred(); - // When deferred processes the configured inputs - const release = deferred(); - const f = fixture({ beforeCreate: async () => { entered.resolve(); await release.promise; } }); - const layer = f.layer(); - const result = layer.renderCandidate('late', annotation(), 11000); - await entered.promise; layer.clear(); - release.resolve(); - // Then user observes that clear removes a native icon that finishes creation late and never creates its label - assert.equal(await result, false); - assert.equal(layer.size, 0); - assert.deepEqual(f.removed, ['owned-1']); - assert.deepEqual([...f.shapes.keys()], ['user-owned']); -}); - -test('user observes that remove cancels a pending label and removes each part of the pair exactly once', async () => { - // Given a native chart and compound candidate annotations - const entered = deferred(); - // When deferred processes the configured inputs - const release = deferred(); - const f = fixture({ beforeCreate: async (count) => { if (count === 2) { entered.resolve(); await release.promise; } } }); - const layer = f.layer(); - const result = layer.renderCandidate('late-label', annotation(), 11000); - await entered.promise; - layer.remove('late-label'); - // Then user observes that remove cancels a pending label and removes each part of the pair exactly once - assert.deepEqual(f.removed, ['owned-1']); - release.resolve(); - assert.equal(await result, false); - assert.deepEqual(f.removed, ['owned-1', 'owned-2']); - assert.deepEqual([...f.shapes.keys()], ['user-owned']); + host.close(); }); -test('user observes that partial label failure rolls back the already-created icon', async () => { - // Given a native chart and compound candidate annotations - const f = fixture({ beforeCreate: (count) => { if (count === 2) throw new Error('fixture label failure'); } }); - // When f.layer processes the configured inputs - const layer = f.layer(); - // Then user observes that partial label failure rolls back the already-created icon - await assert.rejects(layer.renderCandidate('partial', annotation(), 11000), /fixture label failure/); - assert.deepEqual(f.removed, ['owned-1']); - assert.equal(layer.size, 0); - assert.deepEqual([...f.shapes.keys()], ['user-owned']); +test('user assigns separate slots when distinct no-trade seconds resolve to the same prior candle', async t => { + // Given two decision seconds missing from the native candle series + t.mock.timers.enable({ apis: ['setTimeout'] }); + const host = fixture(); + const layer = compound(host); + // When each candidate reaches its exact three second deadline + const first = layer.renderCandidate('first', annotation({ markerTime: 11 }), 12000); + t.mock.timers.tick(2999); + assert.equal(host.markers().length, 0); + t.mock.timers.tick(1); + assert.equal(await first, true); + const second = layer.renderCandidate('second', annotation({ markerTime: 12 }), 13000); + t.mock.timers.tick(3000); + // Then both candidates share candle time but keep distinct sixty-four pixel slots + assert.equal(await second, true); + assert.deepEqual(host.markers().map(coordinate), ['translate(100 674)', 'translate(100 634)', 'translate(100 610)', 'translate(100 570)']); + assert.equal(host.overlay.events.dataUpdated.size, 1); + layer.clear(); + assert.equal(host.overlay.events.dataUpdated.size, 0); + host.close(); }); -for (const options of [{ shiftSeconds: 1 }, { wrongProperties: true }]) { - test(`user observes that shifted times and unsupported drawing properties roll back their own entity (options=${JSON.stringify(options)})`, async () => { - // Given a native chart and compound candidate annotations - const f = fixture(options); - // When f.layer processes the configured inputs - const layer = f.layer(); - // Then user observes that shifted times and unsupported drawing properties roll back their own entity (options=the selected case) - await assert.rejects(layer.renderCandidate('bad', annotation(), 11000), options.shiftSeconds ? /time alignment failed/ : /drawing properties/); - assert.equal(layer.size, 0); - assert.deepEqual(f.removed, ['owned-1']); - assert.deepEqual([...f.shapes.keys()], ['user-owned']); - +for (const reason of ['remove', 'clear', 'suspend', 'symbol']) { + test(`user cancels an unfinished compound candidate after ${reason}`, async () => { + // Given one completed candidate and a second awaiting candle data + const host = fixture(); + const layer = compound(host); + await layer.renderCandidate('first', annotation(), 11000); + const pending = layer.renderCandidate('pending', annotation({ markerTime: 11 }), 12000); + // When its lifecycle changes before the exact candle arrives + if (reason === 'remove') layer.remove('pending'); + if (reason === 'clear') layer.clear(); + if (reason === 'suspend') layer.suspend(); + if (reason === 'symbol') host.setSymbol('ETHUSDT'); + host.candles.set(11, [11, 1.25, 1.3, 1.2, 1.25]); + host.fireDataUpdated(); + // Then no incomplete pair is published and owned cleanup remains isolated + assert.equal(await pending, false); + assert.equal(host.markers().some(node => node.dataset.markerId.includes(':pending:')), false); + assert.equal(layer.size, reason === 'clear' ? 0 : 1); + layer.clear(); + assert.equal(host.overlay.subscriptions, 0); + assert.deepEqual([...host.shapes.keys()], ['user-owned']); + host.close(); }); } -test('user observes that cleanup attempts both owned entities and reports failures without touching user drawings', async () => { - // Given a native chart and compound candidate annotations - const f = fixture({ removeError: new Error('fixture removal failure') }); - // When f.layer processes the configured inputs - const layer = f.layer(); - await layer.renderCandidate('high', annotation(), 11000); - // Then user observes that cleanup attempts both owned entities and reports failures without touching user drawings - assert.throws(() => layer.clear(), (error) => error instanceof AggregateError && error.errors.length === 2); - assert.equal(layer.size, 0); - assert.deepEqual(f.removed, ['owned-1', 'owned-2']); - assert.equal(f.shapes.get('user-owned').untouched, true); +test('user preserves suspended compound history through zoom and language updates without accepting new candidates', async () => { + // Given one completed candidate before a transport failure suspends presentation + const host = fixture(); + const layer = compound(host); + await layer.renderCandidate('first', annotation(), 11000); + const original = host.markers(); + // When the suspended chart is zoomed and its display language changes + layer.suspend(); + host.overlay.setProjection({ price: price => 2000 - price * 500 }); + host.overlay.events.modeChanged.emit(); + host.overlay.flushFrames(); + layer.setLocale('en'); + // Then existing history stays visible at its original prices and no candidate is added + assert.deepEqual(host.markers(), original); + assert.deepEqual(host.markers().map(coordinate), ['translate(100 1337)', 'translate(100 1317)']); + assert.equal(original[1].textContent, 'High candidate'); + assert.equal(await layer.renderCandidate('second', annotation(), 12000), false); + assert.equal(layer.size, 1); + layer.remove('first'); + assert.equal(host.markers().length, 0); layer.clear(); - assert.equal(f.removed.length, 2, 'unknown removals are not automatically retried'); + host.close(); }); -test('user observes that 80 compound pairs plus 80 ordinary markers have a strict 240-entity budget', async () => { - // Given a native chart and compound candidate annotations - const f = fixture(); - const ordinary = createTradingViewEventLayer({ chart: f.chart }, { maxEvents: 80, maxAgeMs: 7200000 }); - // When f.layer processes the configured inputs - const layer = f.layer(); - for (let i = 0; i < 80; i += 1) { - await ordinary.renderOpened(`ordinary-${i}`, annotation(), 11000); - await layer.renderCandidate(`compound-${i}`, annotation(), 11000); +test('user accepts eighty compound pairs and eighty ordinary arrows without native entity growth', async () => { + // Given the maximum supported compound and ordinary capacities + const host = fixture(); + const layer = compound(host); + const ordinary = createTradingViewEventLayer(host.target, { maxEvents: 80, maxAgeMs: 60000 }); + // When all available candidate slots and ordinary events are filled + for (let index = 0; index < 80; index += 1) { + await layer.renderCandidate(String(index), annotation(), 11000 + index); + await ordinary.renderOpened(String(index), annotation(), 11000 + index); } - // Then user observes that 80 compound pairs plus 80 ordinary markers have a strict 240-entity budget - assert.equal(f.shapes.size, 241); + // Then bounded logical history is retained and offscreen pairs create no native entities assert.equal(layer.size, 80); assert.equal(ordinary.size, 80); - await assert.rejects(layer.renderCandidate('over-budget', annotation(), 11000), /capacity exceeded/); - layer.clear(); - assert.equal(f.shapes.size, 81); - assert.equal(f.removed.length, 160); + assert.equal(host.overlay.pane.querySelectorAll('svg').length, 2); + assert.equal(host.markers().length, 101); + await assert.rejects(layer.renderCandidate('overflow', annotation(), 12000), /capacity exceeded/); + assert.deepEqual([host.created.length, host.removed.length, host.saves.length], [0, 0, 0]); ordinary.clear(); - assert.deepEqual([...f.shapes.keys()], ['user-owned']); -}); - -test('user observes that compound reconciliation restores only missing parts in their original slots', async () => { - // Given a native chart and compound candidate annotations - const f = fixture(); - // When f.layer processes the configured inputs - const layer = f.layer(); - await layer.renderCandidate('a', annotation(), 11000); - await layer.renderCandidate('b', annotation(), 11000); - f.shapes.delete('owned-1'); - f.shapes.delete('owned-3'); - f.shapes.delete('owned-4'); - await layer.reconcile(); - // Then user observes that compound reconciliation restores only missing parts in their original slots - assert.equal(layer.size, 2); - assert.equal(f.created.length, 7); - assert.equal(f.shapes.has('owned-2'), true); - assert.deepEqual(f.created.slice(4).map((drawing) => drawing.point), [f.created[0].point, f.created[2].point, f.created[3].point]); - assert.deepEqual(f.removed, []); - await layer.reconcile(); - assert.equal(f.created.length, 7); layer.clear(); - await layer.reconcile(); - assert.deepEqual([...f.shapes.keys()], ['user-owned']); + host.close(); }); -test('user observes that a repeated candidate restores externally removed entities', async () => { - // Given a native chart and compound candidate annotations - const f = fixture(); - // When f.layer processes the configured inputs - const layer = f.layer(); - await layer.renderCandidate('a', annotation(), 11000); - f.shapes.delete('owned-1'); - f.shapes.delete('owned-2'); - // Then user observes that a repeated candidate restores externally removed entities - assert.equal(await layer.renderCandidate('a', annotation(), 11000), true); - assert.equal(f.created.length, 4); - assert.equal(layer.size, 1); - assert.equal(f.shapes.size, 3); -}); - -test('user observes that suspension preserves completed pairs and cancels a pending candle wait', async () => { - // Given a native chart and compound candidate annotations - const f = fixture(); - const layer = createTradingViewCompoundLayer({ chart: f.chart }, { maxCandidates: 80, candleWaitMs: 3000 }); - // When layer.renderCandidate processes the configured inputs - await layer.renderCandidate('verified', annotation(), 11000); - const pending = layer.renderCandidate('waiting', annotation({ markerTime: 11 }), 12000); - // Then user observes that suspension preserves completed pairs and cancels a pending candle wait - assert.equal(f.listeners.size, 1); - layer.suspend(); - assert.equal(await pending, false); - assert.equal(f.listeners.size, 0); - assert.deepEqual([...f.shapes.keys()], ['user-owned', 'owned-1', 'owned-2']); - assert.equal(layer.size, 1); - f.shapes.delete('owned-1'); - await layer.reconcile(); - assert.equal(await layer.renderCandidate('new', annotation(), 11000), false); - assert.equal(f.created.length, 2); - layer.clear(); - assert.deepEqual([...f.shapes.keys()], ['user-owned']); -}); - -for (const phase of ['icon', 'label', 'repair']) { - test(`user observes that suspension removes late ${phase} entities without removing verified history`, async () => { - // Given a native chart and compound candidate annotations - const entered = deferred(); - // When deferred processes the configured inputs - const release = deferred(); - const blockedCreate = phase === 'label' ? 4 : 3; - const f = fixture({ beforeCreate: async (count) => { - if (count === blockedCreate) { entered.resolve(); await release.promise; } - } }); - const layer = f.layer(); - await layer.renderCandidate('verified', annotation(), 11000); - if (phase === 'repair') f.shapes.delete('owned-1'); - const pending = phase === 'repair' ? layer.reconcile() : layer.renderCandidate('pending', annotation(), 11000); - await entered.promise; - layer.suspend(); - release.resolve(); - await pending; - // Then user observes that suspension removes late the selected case entities without removing verified history - assert.deepEqual([...f.shapes.keys()], phase === 'repair' ? ['user-owned', 'owned-2'] : ['user-owned', 'owned-1', 'owned-2']); - assert.deepEqual(f.removed, phase === 'label' ? ['owned-3', 'owned-4'] : ['owned-3']); - assert.equal(layer.size, 1); - await layer.reconcile(); - assert.equal(f.created.length, blockedCreate); - layer.clear(); - assert.deepEqual([...f.shapes.keys()], ['user-owned']); +for (const maxCandidates of [0, 81, 1.5]) { + test(`user rejects unsupported compound capacity ${maxCandidates}`, () => { + // Given a chart and an invalid retention capacity + const host = fixture(); + // When compound presentation initializes + const action = () => compound(host, { maxCandidates }); + // Then no layer or native entity is installed + assert.throws(action, /capacity must be 1..80/); + assert.equal(host.overlay.pane.querySelectorAll('svg').length, 0); + host.close(); }); } -for (const action of ['retain', 'clear', 'remove', 'interval', 'symbol']) { - test(`user observes that compound timer and replay share a single repair and discard late parts after invalidation (action=${JSON.stringify(action)})`, async () => { - // Given a native chart and compound candidate annotations - const entered = deferred(); - // When deferred processes the configured inputs - const release = deferred(); - const f = fixture({ beforeCreate: async (count) => { if (count === 3) { entered.resolve(); await release.promise; } } }); - const layer = f.layer(); - await layer.renderCandidate('a', annotation(), 11000); - f.shapes.delete('owned-1'); - const repair = layer.reconcile(); - await entered.promise; - const replay = layer.renderCandidate('a', annotation(), 11000); - const anotherTick = layer.reconcile(); - if (action === 'clear') layer.clear(); - if (action === 'remove') layer.remove('a'); - if (action === 'interval') f.chart.resolution = () => '1'; - if (action === 'symbol') f.chart.symbol = () => 'BTCUSDT'; - release.resolve(); - // Then user observes that compound timer and replay share a single repair and discard late parts after invalidation (action=the selected case) - assert.equal(await replay, action === 'retain', action); - await Promise.all([repair, anotherTick]); - assert.equal(f.created.length, 3, action); - assert.equal(f.shapes.has('owned-3'), action === 'retain', action); - if (action === 'retain') assert.equal(f.shapes.has('owned-2'), true); +for (const invalid of ['id', 'time', 'shape', 'label']) { + test(`user rejects invalid compound ${invalid} before publishing presentation`, async () => { + // Given a malformed candidate at the presentation boundary + const host = fixture(); + const layer = compound(host); + const id = invalid === 'id' ? '' : 'candidate'; + const time = invalid === 'time' ? 0 : 11000; + const value = annotation(invalid === 'shape' ? { markerShape: 'triangle' } : invalid === 'label' ? { markerLabel: 'Unknown' } : {}); + // When that candidate is submitted + const rendering = layer.renderCandidate(id, value, time); + // Then its invalid contract fails before any visual ownership is accepted + await assert.rejects(rendering, /identity\/time is invalid|direction\/label is invalid/); + assert.equal(layer.size, 0); + assert.equal(host.markers().length, 0); layer.clear(); - await layer.reconcile(); - assert.deepEqual([...f.shapes.keys()], ['user-owned']); - + host.close(); }); } -test('user observes that cleanup skips evicted compound parts and still reports a live part removal failure', async () => { - // Given a native chart and compound candidate annotations - const f = fixture({ removeError: new Error('fixture removal failure') }); - // When f.layer processes the configured inputs - const layer = f.layer(); - await layer.renderCandidate('a', annotation(), 11000); - f.shapes.delete('owned-1'); - // Then user observes that cleanup skips evicted compound parts and still reports a live part removal failure - assert.throws(() => layer.clear(), (error) => error instanceof AggregateError && error.errors.length === 1); - assert.deepEqual(f.removed, ['owned-2']); - assert.equal(layer.size, 0); +test('user rejects overlapping compound placement and resumes after the earlier wait is cancelled', async () => { + // Given a candidate still waiting for its exact candle + const host = fixture({ bars: [] }); + const layer = compound(host); + const pending = layer.renderCandidate('waiting', annotation(), 11000); + // When another caller attempts concurrent compound placement + const concurrent = layer.renderCandidate('other', annotation(), 11001); + // Then serialization is enforced and cancellation permits a subsequent valid candidate + await assert.rejects(concurrent, /rendering must be serial/); + layer.remove('waiting'); + assert.equal(await pending, false); + host.candles.set(10, [10, 1.25, 1.3, 1.2, 1.25]); + assert.equal(await layer.renderCandidate('fresh', annotation(), 11002), true); + assert.deepEqual(host.markers().map(node => node.dataset.markerId), ['candidate:fresh:icon', 'candidate:fresh:label']); layer.clear(); - assert.deepEqual(f.removed, ['owned-2']); + host.close(); }); - -for (const phase of ['settled', 'creating', 'restoring']) { - test(`user observes that locale changes preserve compound entity ownership while ${phase}`, async () => { - // Given a native chart and compound candidate annotations - const gate = deferred(); - let blocked = false; - const f = fixture({ beforeCreate: (count) => { - if ((phase === 'creating' && count === 2) || (phase === 'restoring' && count === 3)) { - blocked = true; - return gate.promise; - } - } }); - // When f.layer processes the configured inputs - const layer = f.layer(); - let pending = layer.renderCandidate('high', annotation(), 11000); - if (phase !== 'creating') await pending; - if (phase === 'restoring') { - f.shapes.delete('owned-2'); - pending = layer.reconcile(); - } - if (phase !== 'settled') { - while (!blocked) await new Promise(setImmediate); - } - const ids = [...f.shapes.keys()]; - layer.setLocale('en'); - gate.resolve(); - await pending; - // Then user observes that locale changes preserve compound entity ownership while the selected case - assert.equal(f.shapes.get(phase === 'restoring' ? 'owned-3' : 'owned-2').getProperties().text, 'High candidate'); - assert.equal(f.shapes.has('owned-1'), true); - assert.deepEqual(f.removed, []); - if (phase === 'settled') assert.deepEqual([...f.shapes.keys()], ids); - layer.setLocale('zh-CN'); - assert.equal(f.shapes.get(phase === 'restoring' ? 'owned-3' : 'owned-2').getProperties().text, '候选高'); - assert.equal(layer.size, 1); - layer.clear(); - assert.deepEqual([...f.shapes.keys()], ['user-owned']); - }); -} diff --git a/test/dom/binance-strategy27-events/tradingview-event-layer.test.js b/test/dom/binance-strategy27-events/tradingview-event-layer.test.js index 9b6ad6a..aa9fb8e 100644 --- a/test/dom/binance-strategy27-events/tradingview-event-layer.test.js +++ b/test/dom/binance-strategy27-events/tradingview-event-layer.test.js @@ -1,550 +1,31 @@ import assert from 'node:assert/strict'; import test from 'node:test'; - import { loadFixtureDom } from '../../helpers/dom.js'; -import { captureStrategyError, createStrategyShapeBoundary } from '../../helpers/strategy-migration-boundaries.js'; -import { - createAlignedShape, - createTradingViewEventLayer, - createTradingViewMarkerPlacement, - findStrategy27ChartRoot, - findStrategy27ChartTarget as resolveStrategy27ChartTarget, - readLiveShapeIds, -} from '../../../src/binance-strategy27-events/dom/tradingview-event-layer.js'; +import { captureStrategyError } from '../../helpers/strategy-migration-boundaries.js'; +import { createStrategy27OverlayHost } from '../../helpers/strategy27-overlay-host.js'; +import { createTradingViewEventLayer, createTradingViewMarkerPlacement, findStrategy27ChartRoot, + findStrategy27ChartTarget as resolveStrategy27ChartTarget } from '../../../src/binance-strategy27-events/dom/tradingview-event-layer.js'; function findStrategy27ChartTarget(document, symbol) { return resolveStrategy27ChartTarget(findStrategy27ChartRoot(document), symbol); } - -function createChartDom({ - resolution = '1S', - symbol = 'BTRUSDT@PRICETYPE=LAST', - shiftSeconds = 0, - deferredCreate = false, - candle = [10, 1.25, 1.3, 1.2, 1.25], - previousCandle = null, - priceToCoordinate = price => 2_000 - (price * 1_000), - coordinateToPrice = coordinate => (2_000 - coordinate) / 1_000, -} = {}) { - const dom = loadFixtureDom(''); - const shapes = new Map(); - const removed = []; - const dataUpdatedListeners = []; +function createChartDom({ candle = [10, 1.25, 1.3, 1.2, 1.25], previousCandle = null, ...options } = {}) { + const fixture = createStrategy27OverlayHost(options); let currentCandle = candle; - let currentResolution = resolution; - let currentSymbol = symbol; - let nextId = 1; - let releaseCreate = null; - const chart = { - resolution: () => currentResolution, - symbol: () => currentSymbol, - async createShape(point, properties) { - if (deferredCreate) await new Promise((resolve) => { releaseCreate = resolve; }); - const id = `shape-${nextId++}`; - let points = [{ ...point, time: point.time + shiftSeconds }]; - let currentProperties = { ...properties, text: properties.text ?? '' }; - shapes.set(id, { - id, - getPoints: () => points, - setPoints: (value) => { points = value; }, - getProperties: () => currentProperties, - setProperties: (value) => { currentProperties = { ...currentProperties, ...value }; }, - }); - return id; - }, - getShapeById: (id) => shapes.get(id), - getAllShapes: () => [...shapes.keys()].map((id) => ({ id })), - removeEntity(id) { removed.push(id); shapes.delete(id); }, - getSeries: () => ({ - data: () => ({ - valueAt: (index) => { - if (index === 5) return currentCandle; - if (index === 4) return previousCandle; - return null; - }, - }), - }), - _chartWidget: { - model: () => ({ - model: () => ({ - timeScale: () => ({ - timePointToIndex: (time, matchMode) => { - if (time !== 10) return null; - if (matchMode === 1 && previousCandle) return 4; - return 5; - }, - }), - mainSeries: () => ({ - dataUpdated: () => ({ - subscribe: (owner, listener) => dataUpdatedListeners.push({ owner, listener }), - unsubscribe: (owner, listener) => { - const index = dataUpdatedListeners.findIndex( - (candidate) => candidate.owner === owner && candidate.listener === listener, - ); - if (index >= 0) dataUpdatedListeners.splice(index, 1); - }, - }), - firstValue: () => 1, - priceScale: () => ({ - priceToCoordinate, - coordinateToPrice, - }), - }), - }), - }), - }, - }; - dom.window.document.querySelector('iframe').contentWindow.tradingViewApi = { - activeChart: () => chart, - }; - return { - dom, - chart, - shapes, - removed, - releaseCreate: () => releaseCreate(), - setCandle: (value) => { currentCandle = value; }, - setResolution: (value) => { currentResolution = value; }, - setSymbol: (value) => { currentSymbol = value; }, - fireDataUpdated: () => { - for (const { listener } of [...dataUpdatedListeners]) listener(); - }, - get dataUpdatedListenerCount() { - return dataUpdatedListeners.length; - }, + fixture.chart.getSeries = () => ({ data: () => ({ valueAt: index => index === 5 ? currentCandle : index === 4 ? previousCandle : null }) }); + fixture.overlay.timeScale.timePointToIndex = (time, mode) => { + if (previousCandle && time === previousCandle[0] && mode === 0) return 4; + if (time !== 10) return null; + return mode === 1 && previousCandle ? 4 : 5; }; + fixture.setCandle = value => { currentCandle = value; }; + Object.defineProperty(fixture, 'dataUpdatedListenerCount', { get: () => fixture.overlay.events.dataUpdated.size }); + return fixture; } - function annotation(overrides = {}) { - return { - markerShape: 'arrow_up', - markerColor: '#0ECB81', - markerTime: 10, - markerPrice: 1.25, - ...overrides, - }; + return { markerShape: 'arrow_up', markerColor: '#0ECB81', markerTime: 10, markerPrice: 1.25, ...overrides }; } - -test('user requires an exact matching one-second TradingView chart', () => { - // Given a native chart and ordinary event annotations - const { dom } = createChartDom(); - // When findStrategy27ChartTarget processes the configured inputs - const target = findStrategy27ChartTarget(dom.window.document, 'BTRUSDT'); - // Then user requires an exact matching one-second TradingView chart - assert.equal(target.routeSymbol, 'BTRUSDT'); - assert.equal(target.resolution, '1S'); - - const minute = createChartDom({ resolution: '1' }); - assert.throws( - () => findStrategy27ChartTarget(minute.dom.window.document, 'BTRUSDT'), - /one-second chart/, - ); - const wrongSymbol = createChartDom({ symbol: 'BTCUSDT@PRICETYPE=LAST' }); - assert.throws( - () => findStrategy27ChartTarget(wrongSymbol.dom.window.document, 'BTRUSDT'), - /chart symbol/, - ); -}); - -test('user observes that one event owns one marker across its complete lifecycle', async () => { - // Given a native chart and ordinary event annotations - const { dom, shapes, removed } = createChartDom(); - // When findStrategy27ChartTarget processes the configured inputs - const target = findStrategy27ChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createTradingViewEventLayer(target, { maxEvents: 2, maxAgeMs: 60_000 }); - - await layer.renderOpened('event-a', annotation(), 10_000); - // Then user observes that one event owns one marker across its complete lifecycle - assert.equal(shapes.size, 1); - await layer.renderUpdated('event-a', annotation(), 11_000); - assert.equal(shapes.size, 1); - await layer.renderClosed('event-a', annotation(), 12_000); - assert.equal(shapes.size, 1); - const ids = [...shapes.keys()]; - await layer.renderOutcome('event-a', annotation(), 13_000); - assert.deepEqual([...shapes.keys()], ids); - assert.equal(shapes.get(ids[0]).getProperties().shape, 'arrow_up'); - - layer.remove('event-a'); - assert.equal(shapes.size, 0); - assert.equal(layer.size, 0); - - await layer.renderOpened('event-b', annotation(), 14_000); - - layer.clear(); - assert.equal(shapes.size, 0); - assert.equal(removed.length, 2); -}); - -test('user sees red and green directional arrows eight pixels outside the matching candle', async () => { - // Given a native chart and ordinary event annotations - const { dom, shapes } = createChartDom(); - // When findStrategy27ChartTarget processes the configured inputs - const target = findStrategy27ChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createTradingViewEventLayer(target, { maxEvents: 3, maxAgeMs: 60_000 }); - - await layer.renderOpened('up', annotation({ markerPrice: 9.99 }), 10_000); - await layer.renderOpened('down', annotation({ - markerShape: 'arrow_down', - markerColor: '#F6465D', - markerPrice: 0.01, - }), 10_001); - const [up, down] = [...shapes.values()]; - // Then user sees red and green directional arrows eight pixels outside the matching candle - assert.equal(up.getPoints()[0].price, 1.192); - assert.equal(down.getPoints()[0].price, 1.308); -}); - -test('user keeps the first event marker immutable when later updates change direction', async () => { - // Given a native chart and ordinary event annotations - const { dom, shapes, removed } = createChartDom(); - // When findStrategy27ChartTarget processes the configured inputs - const target = findStrategy27ChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createTradingViewEventLayer(target, { maxEvents: 2, maxAgeMs: 60_000 }); - - await layer.renderOpened('event-a', annotation(), 10_000); - const originalId = [...shapes.keys()][0]; - await layer.renderUpdated('event-a', annotation({ - markerShape: 'arrow_down', - markerColor: '#F6465D', - }), 11_000); - - // Then user keeps the first event marker immutable when later updates change direction - assert.equal(shapes.size, 1); - assert.deepEqual(removed, []); - assert.deepEqual([...shapes.keys()], [originalId]); - const marker = [...shapes.values()][0]; - assert.equal(marker.getProperties().shape, 'arrow_up'); - assert.equal(marker.getPoints()[0].price, 1.192); -}); - -test('user waits for the matching candle data update before placing a directional marker', async () => { - // Given a native chart and ordinary event annotations - const fixture = createChartDom({ candle: null }); - // When findStrategy27ChartTarget processes the configured inputs - const target = findStrategy27ChartTarget(fixture.dom.window.document, 'BTRUSDT'); - const layer = createTradingViewEventLayer(target, { - maxEvents: 2, - maxAgeMs: 60_000, - candleWaitMs: 50, - }); - - const renderPromise = layer.renderOpened('event-a', annotation(), 10_000); - await Promise.resolve(); - // Then user waits for the matching candle data update before placing a directional marker - assert.equal(fixture.shapes.size, 0); - assert.equal(fixture.dataUpdatedListenerCount, 1); - - fixture.setCandle([10, 1.25, 1.3, 1.2, 1.25]); - fixture.fireDataUpdated(); - - assert.equal(await renderPromise, true); - assert.equal(fixture.shapes.size, 1); - assert.equal(fixture.dataUpdatedListenerCount, 0); - assert.equal([...fixture.shapes.values()][0].getPoints()[0].price, 1.192); -}); - -test('user does not create a marker for a neutral observation', async () => { - // Given a native chart and ordinary event annotations - const fixture = createChartDom({ candle: null }); - // When findStrategy27ChartTarget processes the configured inputs - const target = findStrategy27ChartTarget(fixture.dom.window.document, 'BTRUSDT'); - const layer = createTradingViewEventLayer(target, { - maxEvents: 2, - maxAgeMs: 60_000, - candleWaitMs: 50, - }); - - const observedResult = await layer.renderOpened('event-a', annotation({ - markerShape: null, - markerColor: null, - }), 10_000); - // Then user does not create a marker for a neutral observation - assert.equal(observedResult, true); - assert.equal(fixture.shapes.size, 0); - assert.equal(fixture.dataUpdatedListenerCount, 0); -}); - -test('user rejects a directional marker when its matching candle misses the bounded wait', async () => { - // Given a native chart and ordinary event annotations - const { dom } = createChartDom({ candle: null }); - // When findStrategy27ChartTarget processes the configured inputs - const target = findStrategy27ChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createTradingViewEventLayer(target, { - maxEvents: 2, - maxAgeMs: 60_000, - candleWaitMs: 5, - }); - - const observedResult = layer.renderOpened('event-a', annotation(), 10_000); - // Then user rejects a directional marker when its matching candle misses the bounded wait - await assert.rejects( - observedResult, - /candle did not arrive within 5 ms for 10/, - ); -}); - -test('user does not anchor a neutral observation to a previous candle', async () => { - // Given a native chart and ordinary event annotations - const fixture = createChartDom({ - candle: null, - previousCandle: [9, 1.24, 1.28, 1.18, 1.23], - }); - // When findStrategy27ChartTarget processes the configured inputs - const target = findStrategy27ChartTarget(fixture.dom.window.document, 'BTRUSDT'); - const layer = createTradingViewEventLayer(target, { - maxEvents: 2, - maxAgeMs: 60_000, - candleWaitMs: 5, - }); - - const observedResult = await layer.renderOpened('event-a', annotation({ - markerShape: null, - markerColor: null, - }), 10_000); - // Then user does not anchor a neutral observation to a previous candle - assert.equal(observedResult, true); - - assert.equal(fixture.shapes.size, 0); - assert.equal(fixture.dataUpdatedListenerCount, 0); -}); - -test('user sees a directional event outside the latest prior candle when its exact second has no trade', async () => { - // Given a native chart and ordinary event annotations - const fixture = createChartDom({ - candle: null, - previousCandle: [9, 1.24, 1.28, 1.18, 1.23], - }); - // When findStrategy27ChartTarget processes the configured inputs - const target = findStrategy27ChartTarget(fixture.dom.window.document, 'BTRUSDT'); - const layer = createTradingViewEventLayer(target, { - maxEvents: 2, - maxAgeMs: 60_000, - candleWaitMs: 5, - }); - - const observedResult = await layer.renderOpened('event-a', annotation(), 10_000); - // Then user sees a directional event outside the latest prior candle when its exact second has no trade - assert.equal(observedResult, true); - - const marker = [...fixture.shapes.values()][0]; - assert.deepEqual(marker.getPoints(), [{ time: 9, price: 1.172 }]); - assert.equal(fixture.dataUpdatedListenerCount, 0); -}); - -test('user observes that clear cancels a pending candle wait without creating a late marker', async () => { - // Given a native chart and ordinary event annotations - const fixture = createChartDom({ candle: null }); - // When findStrategy27ChartTarget processes the configured inputs - const target = findStrategy27ChartTarget(fixture.dom.window.document, 'BTRUSDT'); - const layer = createTradingViewEventLayer(target, { - maxEvents: 2, - maxAgeMs: 60_000, - candleWaitMs: 50, - }); - - const renderPromise = layer.renderOpened('event-a', annotation(), 10_000); - await Promise.resolve(); - layer.clear(); - - // Then user observes that clear cancels a pending candle wait without creating a late marker - assert.equal(await renderPromise, false); - assert.equal(fixture.dataUpdatedListenerCount, 0); - fixture.setCandle([10, 1.25, 1.3, 1.2, 1.25]); - fixture.fireDataUpdated(); - assert.equal(fixture.shapes.size, 0); -}); - -test('user removes a shifted entity and reports chart alignment failure', async () => { - // Given a native chart and ordinary event annotations - const { dom, shapes, removed } = createChartDom({ shiftSeconds: -1 }); - // When findStrategy27ChartTarget processes the configured inputs - const target = findStrategy27ChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createTradingViewEventLayer(target, { maxEvents: 2, maxAgeMs: 60_000 }); - - const observedResult = layer.renderOpened('event-a', annotation(), 10_000); - // Then user removes a shifted entity and reports chart alignment failure - await assert.rejects( - observedResult, - /chart time alignment failed: expected 10, received 9 \(point count 1\)/, - ); - assert.equal(shapes.size, 0); - assert.equal(removed.length, 1); -}); - -test('user observes that clear removes a marker whose asynchronous creation finishes late', async () => { - // Given a native chart and ordinary event annotations - const { - dom, - shapes, - removed, - releaseCreate, - } = createChartDom({ deferredCreate: true }); - // When findStrategy27ChartTarget processes the configured inputs - const target = findStrategy27ChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createTradingViewEventLayer(target, { maxEvents: 2, maxAgeMs: 60_000 }); - - const renderPromise = layer.renderOpened('event-a', annotation(), 10_000); - await Promise.resolve(); - layer.clear(); - releaseCreate(); - - // Then user observes that clear removes a marker whose asynchronous creation finishes late - assert.equal(await renderPromise, false); - assert.equal(layer.size, 0); - assert.equal(shapes.size, 0); - assert.equal(removed.length, 1); -}); - -test('user observes that an update restores an externally evicted marker with its original immutable presentation', async () => { - // Given a native chart and ordinary event annotations - const f = createChartDom(); - const layer = createTradingViewEventLayer({ chart: f.chart }, { maxEvents: 2, maxAgeMs: 60000 }); - // When layer.renderOpened processes the configured inputs - await layer.renderOpened('a', annotation(), 10000); - const [oldId] = f.shapes.keys(); - const point = f.shapes.get(oldId).getPoints(); - f.shapes.delete(oldId); - // Then user observes that an update restores an externally evicted marker with its original immutable presentation - assert.equal(await layer.renderUpdated('a', annotation({ markerShape: 'arrow_down', markerColor: '#F6465D' }), 11000), true); - assert.equal(f.shapes.size, 1); - const [id, shape] = [...f.shapes][0]; - assert.notEqual(id, oldId); - assert.deepEqual(shape.getPoints(), point); - assert.equal(shape.getProperties().shape, 'arrow_up'); - assert.equal(shape.getProperties().overrides.color, '#0ECB81'); - assert.equal(layer.size, 1); - assert.deepEqual(f.removed, []); -}); - -test('user observes that reconciliation restores missing ordinary markers without an event and never revives a cleared record', async () => { - // Given a native chart and ordinary event annotations - const f = createChartDom(); - const layer = createTradingViewEventLayer({ chart: f.chart }, { maxEvents: 2, maxAgeMs: 60000 }); - // When f.shapes.set processes the configured inputs - f.shapes.set('foreign', {}); - await layer.renderOpened('a', annotation(), 10000); - f.shapes.delete('shape-1'); - await layer.reconcile(); - // Then user observes that reconciliation restores missing ordinary markers without an event and never revives a cleared record - assert.deepEqual([...f.shapes.keys()], ['foreign', 'shape-2']); - await layer.reconcile(); - assert.deepEqual([...f.shapes.keys()], ['foreign', 'shape-2']); - f.shapes.delete('shape-2'); - layer.clear(); - await layer.reconcile(); - assert.deepEqual([...f.shapes.keys()], ['foreign']); - assert.deepEqual(f.removed, []); - assert.equal(layer.size, 0); -}); - -for (const action of ['retain', 'clear', 'remove', 'expire', 'interval', 'symbol', 'suspend']) { - test(`user observes that ordinary timer and gateway repair share a single creation and cancel stale results (action=${JSON.stringify(action)})`, async () => { - // Given a native chart and ordinary event annotations - const f = createChartDom(); - const layer = createTradingViewEventLayer({ chart: f.chart }, { maxEvents: 2, maxAgeMs: 60000 }); - // When layer.renderOpened processes the configured inputs - await layer.renderOpened('a', annotation(), 10000); - f.shapes.delete('shape-1'); - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const nativeCreate = f.chart.createShape; - let creates = 0; - f.chart.createShape = async (...args) => { - creates += 1; - entered.resolve(); - await release.promise; - return nativeCreate(...args); - }; - const repair = layer.reconcile(); - await entered.promise; - const update = layer.renderUpdated('a', annotation(), 11000); - const anotherTick = layer.reconcile(); - if (action === 'clear') layer.clear(); - if (action === 'suspend') layer.suspend(); - if (action === 'remove') layer.remove('a'); - if (action === 'expire') layer.prune(7200000); - if (action === 'interval') f.chart.resolution = () => '1'; - if (action === 'symbol') f.chart.symbol = () => 'BTCUSDT'; - release.resolve(); - // Then user observes that ordinary timer and gateway repair share a single creation and cancel stale results (action=the selected case) - assert.equal(await update, action === 'retain', action); - await Promise.all([repair, anotherTick]); - assert.equal(creates, 1, action); - assert.equal(f.shapes.size, action === 'retain' ? 1 : 0, action); - if (['clear', 'remove', 'expire'].includes(action)) assert.equal(layer.size, 0); - layer.clear(); - await layer.reconcile(); - assert.equal(f.shapes.size, 0); - - }); -} - -test('user observes that suspension retains existing markers, cancels late first creation and still permits expiry', async () => { - // Given a native chart and ordinary event annotations - const f = createChartDom(); - const layer = createTradingViewEventLayer({ chart: f.chart }, { maxEvents: 3, maxAgeMs: 60000 }); - // When layer.renderOpened processes the configured inputs - await layer.renderOpened('a', annotation(), 10000); - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const create = f.chart.createShape; - f.chart.createShape = async (...args) => { entered.resolve(); await release.promise; return create(...args); }; - const pending = layer.renderOpened('b', annotation(), 11000); - await entered.promise; - layer.suspend(); - release.resolve(); - // Then user observes that suspension retains existing markers, cancels late first creation and still permits expiry - assert.equal(await pending, false); - assert.deepEqual([...f.shapes.keys()], ['shape-1']); - assert.equal(layer.size, 1); - assert.equal(await layer.renderOpened('c', annotation(), 12000), false); - await layer.reconcile(); - assert.deepEqual([...f.shapes.keys()], ['shape-1']); - layer.prune(70001); - assert.equal(layer.size, 0); - assert.equal(f.shapes.size, 0); -}); - -test('user observes native chart fixture coordinates, shape identity and listener ownership', async (t) => { - // Given the chart fixture exposes unmodified host data and coordinate operations - const malformedCandle = { incomplete: true }; - const fixture = createChartDom({ priceToCoordinate: price => price * 2, coordinateToPrice: coordinate => coordinate / 2 }); - t.after(() => fixture.dom.window.close()); - const model = fixture.chart._chartWidget.model().model(); - const series = model.mainSeries(); - const notifications = []; - const owner = {}; - const listener = () => notifications.push('update'); - series.dataUpdated().subscribe(owner, listener); - const point = { time: 10, price: 1.25 }; - - // When native shape creation, data delivery, and removal are exercised directly - const id = await fixture.chart.createShape(point, { shape: 'arrow_up' }); - const shape = fixture.chart.getShapeById(id); - fixture.setCandle(malformedCandle); - fixture.setSymbol('ETHUSDT'); - fixture.setResolution('5'); - fixture.fireDataUpdated(); - series.dataUpdated().unsubscribe(owner, listener); - fixture.fireDataUpdated(); - const listed = fixture.chart.getAllShapes(); - fixture.chart.removeEntity(id); - - // Then the boundary preserves raw data, exact operations and independent subscription cleanup - assert.equal(id, 'shape-1'); - assert.deepEqual(shape.getPoints(), [point]); - assert.equal(fixture.chart.getSeries().data().valueAt(5), malformedCandle); - assert.equal(series.priceScale().priceToCoordinate(1.25), 2.5); - assert.equal(series.priceScale().coordinateToPrice(2.5), 1.25); - assert.equal(fixture.chart.symbol(), 'ETHUSDT'); - assert.equal(fixture.chart.resolution(), '5'); - assert.deepEqual(notifications, ['update']); - assert.equal(fixture.dataUpdatedListenerCount, 0); - assert.deepEqual(listed, [{ id: 'shape-1' }]); - assert.deepEqual(fixture.removed, ['shape-1']); - assert.deepEqual(fixture.chart.getAllShapes(), []); -}); +const eventLayer = fixture => createTradingViewEventLayer(fixture.target, { maxEvents: 2, maxAgeMs: 60000 }); for (const [label, markup] of [ ['missing chart', ''], @@ -583,18 +64,18 @@ for (const [label, markup, expected] of [ }); } -test('user rejects a native chart without its required entity method', (t) => { +test('user rejects a native chart without its required chart method', (t) => { // Given the native chart no longer exposes the expected create method const fixture = createChartDom(); t.after(() => fixture.dom.window.close()); - delete fixture.chart.createShape; + delete fixture.chart.resolution; // When the current TradingView target is validated const failure = captureStrategyError(() => findStrategy27ChartTarget(fixture.dom.window.document, 'BTRUSDT')); // Then the unavailable method is identified before marker work begins - assert.equal(failure.message, 'TradingView chart method is unavailable: createShape'); - assert.equal(fixture.shapes.size, 0); + assert.equal(failure.message, 'TradingView chart method is unavailable: resolution'); + assert.equal(fixture.created.length, 0); }); test('user rejects a chart with no native symbol instead of adopting the route symbol', (t) => { @@ -609,60 +90,6 @@ test('user rejects a chart with no native symbol instead of adopting the route s assert.equal(failure.message, 'Strategy 27 chart symbol mismatch: expected BTRUSDT, received '); }); -for (const [label, listedShapes, expected] of [ - ['non-array list', null, 'Strategy 27 chart shape list is invalid'], - ['empty identity', [{ id: '' }], 'Strategy 27 chart shape id is invalid'], - ['numeric identity', [{ id: 42 }], 'Strategy 27 chart shape id is invalid'], - ['missing entity', [null], 'Strategy 27 chart shape id is invalid'], -]) { - test(`user rejects native shape discovery with ${label}`, () => { - // Given the host returns the malformed list without repairing its entries - const boundary = createStrategyShapeBoundary({ listedShapes }); - - // When live native shape identities are read for reconciliation - const failure = captureStrategyError(() => readLiveShapeIds(boundary.chart)); - - // Then invalid native identities fail before any removal is attempted - assert.equal(failure.message, expected); - assert.deepEqual(boundary.removed, []); - }); -} - -for (const shapeId of [null, '', 42]) { - test(`user rejects native shape creation returning identity ${JSON.stringify(shapeId)}`, async () => { - // Given native creation returns an unusable entity identity - const boundary = createStrategyShapeBoundary({ shapeId }); - const point = { time: 10, price: 1.25 }; - - // When the real aligned creation adapter receives that identity - const result = createAlignedShape(boundary.chart, point, { shape: 'arrow_up' }); - - // Then the unusable identity is reported without guessing which entity to remove - await assert.rejects(result, { message: 'TradingView returned an invalid shape id' }); - assert.equal(boundary.created.length, 1); - assert.deepEqual(boundary.removed, []); - }); -} - -for (const [label, points, count] of [ - ['missing points', null, null], - ['empty points', [], 0], - ['multiple points', [{ time: 10, price: 1.25 }, { time: 11, price: 1.26 }], 2], -]) { - test(`user removes only the newly created marker after native ${label}`, async () => { - // Given native creation succeeds but its point collection violates the marker contract - const boundary = createStrategyShapeBoundary({ points }); - - // When the real aligned creation adapter verifies the native point collection - const result = createAlignedShape(boundary.chart, { time: 10, price: 1.25 }, { shape: 'arrow_up' }); - - // Then the invalid owned marker is removed and its alignment error remains visible - await assert.rejects(result, { message: `Strategy 27 chart time alignment failed: expected 10, received null (point count ${count})` }); - assert.deepEqual(boundary.removed, ['native-shape']); - assert.equal(boundary.created.length, 1); - }); -} - for (const [label, candle, expected] of [ ['non-array candle', { time: 10 }, 'Strategy 27 candle is invalid for 10'], ['incomplete OHLC values', [10, 1.25], 'Strategy 27 candle is invalid for 10'], @@ -683,7 +110,7 @@ for (const [label, candle, expected] of [ // Then the malformed native data is explicit and no listener or marker is installed assert.equal(failure.message, expected); assert.equal(fixture.dataUpdatedListenerCount, 0); - assert.equal(fixture.shapes.size, 0); + assert.equal(fixture.created.length, 0); }); } @@ -704,7 +131,7 @@ for (const [label, chartOptions, expected] of [ // Then invalid geometry cannot become a guessed marker price assert.equal(failure.message, expected); assert.equal(fixture.dataUpdatedListenerCount, 0); - assert.equal(fixture.shapes.size, 0); + assert.equal(fixture.created.length, 0); }); } @@ -725,7 +152,7 @@ for (const [label, candidate, gapPx, expected] of [ // Then the invalid presentation is reported without waiting or creating a marker assert.equal(failure.message, expected); assert.equal(fixture.dataUpdatedListenerCount, 0); - assert.equal(fixture.shapes.size, 0); + assert.equal(fixture.created.length, 0); }); } @@ -769,7 +196,7 @@ test('user cancels placement before any native candle read or subscription', asy // Then cancellation returns no marker point and leaves no native subscription assert.equal(point, null); assert.equal(fixture.dataUpdatedListenerCount, 0); - assert.equal(fixture.shapes.size, 0); + assert.equal(fixture.created.length, 0); }); test('user releases candle subscriptions when a later native update is malformed', async (t) => { @@ -787,7 +214,7 @@ test('user releases candle subscriptions when a later native update is malformed // Then the actual data error rejects placement and its listener is removed await assert.rejects(pending, { message: 'Strategy 27 candle is invalid for 10' }); assert.equal(fixture.dataUpdatedListenerCount, 0); - assert.equal(fixture.shapes.size, 0); + assert.equal(fixture.created.length, 0); }); test('user cannot anchor an expired candle wait to a future native bar', async (t) => { @@ -806,7 +233,7 @@ test('user cannot anchor an expired candle wait to a future native bar', async ( // Then a future bar is rejected and the bounded wait releases its listener await assert.rejects(pending, { message: 'Strategy 27 candle is invalid for 10' }); assert.equal(fixture.dataUpdatedListenerCount, 0); - assert.equal(fixture.shapes.size, 0); + assert.equal(fixture.created.length, 0); }); test('user shifts a marker through the native price scale while preserving its candle time', (t) => { @@ -838,7 +265,7 @@ for (const [label, chartOptions, deltaPixels, expected] of [ // Then invalid geometry fails explicitly without creating an entity assert.equal(failure.message, expected); - assert.equal(fixture.shapes.size, 0); + assert.equal(fixture.created.length, 0); }); } @@ -854,26 +281,230 @@ for (const [field, value] of [['maxEvents', 0], ['maxEvents', 1.5], ['maxAgeMs', // Then the invalid bound is explicit and no chart entity is created assert.equal(failure.message, `Strategy 27 ${field} is invalid`); - assert.equal(fixture.shapes.size, 0); + assert.equal(fixture.created.length, 0); }); } -test('user does not restore an evicted marker after the native chart has changed symbols', async (t) => { - // Given the layer retains a verified BTR event whose native marker was evicted - const fixture = createChartDom(); - t.after(() => fixture.dom.window.close()); - const layer = createTradingViewEventLayer({ chart: fixture.chart }, { maxEvents: 2, maxAgeMs: 60000 }); - await layer.renderOpened('event-a', annotation(), 10000); - fixture.shapes.delete('shape-1'); - fixture.setSymbol('ETHUSDT'); - // When an old event update arrives before the outer context sample - const rendered = await layer.renderUpdated('event-a', annotation(), 11000); +test('user owns one immutable arrow across opened updated closed and outcome events', async () => { + // Given one accepted directional event beside a user drawing + const host = createChartDom(); + const layer = eventLayer(host); + await layer.renderOpened('first', annotation(), 10000); + const original = host.markers()[0]; + // When later event stages change their proposed direction and price + for (const stage of ['renderUpdated', 'renderClosed', 'renderOutcome']) { + await layer[stage]('first', annotation({ markerShape: 'arrow_down', markerColor: '#F6465D', markerPrice: 999 }), 11000); + } + // Then the original marker identity geometry and color are immutable + assert.equal(host.markers().length, 1); + assert.equal(host.markers()[0], original); + assert.equal(original.dataset.markerId, 'event:first'); + assert.equal(original.getAttribute('fill'), '#0ECB81'); + assert.equal(original.getAttribute('transform'), 'translate(50 808)'); + layer.remove('first'); + assert.equal(host.markers().length, 0); + await layer.renderOpened('second', annotation(), 12000); + layer.clear(); + assert.equal(host.markers().length, 0); + assert.deepEqual([...host.shapes.keys()], ['user-owned']); + assert.deepEqual([host.created.length, host.removed.length, host.saves.length], [0, 0, 0]); + host.close(); +}); - // Then the stale marker is not created on the replacement chart - assert.equal(rendered, false); +test('user sees arrows at candle edges with the original eight pixel price conversion', async () => { + // Given high and low candle prices independent of signal markerPrice + const host = createChartDom(); + const layer = eventLayer(host); + // When both arrow directions are rendered + await layer.renderOpened('up', annotation({ markerPrice: 999 }), 10000); + await layer.renderOpened('down', annotation({ markerShape: 'arrow_down', markerColor: '#F6465D', markerPrice: 0 }), 10000); + // Then both tips have eight pixel gaps and later zoom projects the stored prices + assert.deepEqual(host.markers().map(node => node.getAttribute('transform')), ['translate(50 808)', 'translate(50 692)']); + host.overlay.setProjection({ price: price => 2000 - price * 500 }); + host.overlay.events.priceRangeChanged.emit(); + host.overlay.flushFrames(); + assert.deepEqual(host.markers().map(node => node.getAttribute('transform')), ['translate(50 1404)', 'translate(50 1346)']); + layer.clear(); + host.close(); +}); + +test('user receives an exact candle arriving before three seconds without using the previous candle', async t => { + // Given a missing exact candle and an available previous candle + t.mock.timers.enable({ apis: ['setTimeout'] }); + const host = createChartDom({ candle: null, previousCandle: [9, 1.25, 1.3, 1.2, 1.25] }); + const layer = eventLayer(host); + const pending = layer.renderOpened('exact', annotation(), 10000); + // When the exact candle arrives one millisecond before the deadline + t.mock.timers.tick(2999); + assert.equal(host.markers().length, 0); + host.setCandle([10, 1.25, 1.3, 1.2, 1.25]); + host.fireDataUpdated(); + // Then the exact candle owns the arrow and the placement listener is released + assert.equal(await pending, true); + assert.equal(host.markers()[0].getAttribute('transform'), 'translate(50 808)'); + assert.equal(host.dataUpdatedListenerCount, 1); + layer.clear(); + assert.equal(host.dataUpdatedListenerCount, 0); + host.close(); +}); + +test('user anchors to a previous candle only after the full three second deadline', async t => { + // Given no exact candle but a valid earlier candle + t.mock.timers.enable({ apis: ['setTimeout'] }); + const host = createChartDom({ candle: null, previousCandle: [9, 1.25, 1.3, 1.2, 1.25] }); + const layer = eventLayer(host); + const pending = layer.renderOpened('previous', annotation(), 10000); + // When the exact deadline is crossed + t.mock.timers.tick(2999); + assert.equal(host.markers().length, 0); + t.mock.timers.tick(1); + // Then the preceding candle receives the marker without snapping to a later bar + assert.equal(await pending, true); + assert.equal(host.markers()[0].getAttribute('transform'), 'translate(40 808)'); + layer.clear(); + host.close(); +}); + +for (const reason of ['clear', 'remove', 'symbol', 'suspend']) { + test(`user never publishes a pending ordinary marker after ${reason}`, async () => { + // Given a marker waiting for its exact native candle + const host = createChartDom({ candle: null }); + const layer = eventLayer(host); + const pending = layer.renderOpened('pending', annotation(), 10000); + // When its owner is retired before native data arrives + if (reason === 'clear') layer.clear(); + if (reason === 'remove') layer.remove('pending'); + if (reason === 'symbol') host.setSymbol('ETHUSDT'); + if (reason === 'suspend') layer.suspend(); + host.setCandle([10, 1.25, 1.3, 1.2, 1.25]); + host.fireDataUpdated(); + // Then no late arrow or placement subscription survives + assert.equal(await pending, false); + assert.equal(host.markers().length, 0); + assert.equal(host.dataUpdatedListenerCount, 0); + assert.equal(layer.size, 0); + layer.clear(); + host.close(); + }); +} + +test('user retains suspended history through zoom while refusing new markers and permitting expiry', async () => { + // Given an accepted event and a later neutral observation + const host = createChartDom(); + const layer = eventLayer(host); + await layer.renderOpened('first', annotation(), 10000); + assert.equal(await layer.renderOpened('neutral', annotation({ markerShape: null }), 11000), true); + // When presentation is suspended and the native price scale changes + layer.suspend(); + host.overlay.setProjection({ price: price => 2000 - price * 500 }); + host.overlay.events.modeChanged.emit(); + host.overlay.flushFrames(); + // Then verified history remains projected but new events do not render + assert.equal(host.markers()[0].getAttribute('transform'), 'translate(50 1404)'); + assert.equal(await layer.renderOpened('second', annotation(), 12000), false); + assert.equal(layer.size, 1); + layer.prune(70000); assert.equal(layer.size, 1); - assert.equal(fixture.shapes.size, 0); - assert.deepEqual(fixture.removed, []); + layer.prune(70001); + assert.equal(layer.size, 0); + assert.equal(host.markers().length, 0); + layer.clear(); + host.close(); +}); + +test('user evicts only the oldest ordinary event when bounded capacity is reached', async () => { + // Given a two-event capacity filled in insertion order + const host = createChartDom(); + const layer = eventLayer(host); + await layer.renderOpened('first', annotation(), 10000); + await layer.renderOpened('second', annotation(), 10001); + // When a third event arrives and repeated reconciliation runs + await layer.renderOpened('third', annotation(), 10002); + const nodes = host.markers(); + await layer.reconcile(); + // Then the two newest immutable markers survive without native mutation + assert.deepEqual(host.markers().map(node => node.dataset.markerId), ['event:second', 'event:third']); + assert.deepEqual(host.markers(), nodes); + assert.deepEqual([host.created.length, host.removed.length, host.saves.length], [0, 0, 0]); + layer.clear(); + host.close(); +}); + +for (const [resolution, symbol, expected] of [['1', 'BTRUSDT', /one-second chart/], ['1S', 'ETHUSDT', /symbol mismatch/]]) { + test(`user rejects ${resolution} ${symbol} before binding the current one-second route`, () => { + // Given a visible native chart outside the requested symbol or interval + const host = createChartDom({ resolution, symbol }); + // When the current route attempts to bind presentation + const resolve = () => findStrategy27ChartTarget(host.document, 'BTRUSDT'); + // Then the incompatible chart fails before any overlay is installed + assert.throws(resolve, expected); + assert.equal(host.overlay.pane.querySelectorAll('svg').length, 0); + host.close(); + }); +} + +test('user sees a terminal ordinary projection failure without later marker resurrection', async () => { + // Given a valid ordinary event and an explicit failure consumer + const host = createChartDom(); + const errors = []; + const layer = createTradingViewEventLayer(host.target, { maxEvents: 2, maxAgeMs: 60000, onRenderError: error => errors.push(error.message) }); + await layer.renderOpened('first', annotation(), 10000); + // When native projection fails in a scheduled frame and later recovers + host.overlay.setProjection({ price: () => NaN }); + host.overlay.events.priceRangeChanged.emit(); + host.overlay.flushFrames(); + host.overlay.setProjection({ price: price => 2000 - price * 1000 }); + await layer.reconcile(); + // Then accepted history remains recorded but failed presentation cannot reappear + assert.equal(errors.length, 1); + assert.match(errors[0], /coordinates are invalid/); + assert.equal(layer.size, 1); + assert.equal(host.markers().length, 0); + assert.equal(await layer.renderUpdated('first', annotation(), 11000), false); + assert.equal(host.overlay.subscriptions, 0); + layer.clear(); + host.close(); +}); + +test('user receives the native candle and coordinate host contract without repaired data', () => { + // Given explicit candle rows and an owner-bound delegate + const host = createStrategy27OverlayHost({ bars: [[10, 1.25, 1.3, 1.2, 1.25], [12, 1.3, 1.4, 1.25, 1.35]] }); + const event = host.overlay.events.dataUpdated; + const owner = {}; + let notifications = 0; + const callback = () => { notifications += 1; }; + // When exact previous and missing indices are read and subscriptions are released + const exact = host.overlay.timeScale.timePointToIndex(12, 0); + const previous = host.overlay.timeScale.timePointToIndex(11, 1); + const missing = host.overlay.timeScale.timePointToIndex(11, 0); + event.subscribe(owner, callback); + host.fireDataUpdated(); + event.unsubscribe(owner, callback); + host.fireDataUpdated(); + // Then the host obeys independently specified native timing and scale contracts + assert.deepEqual([exact, previous, missing], [12, 10, null]); + assert.deepEqual(host.chart.getSeries().data().valueAt(exact), [12, 1.3, 1.4, 1.25, 1.35]); + assert.equal(host.overlay.priceScale.priceToCoordinate(1.3, 100), 700); + assert.equal(host.overlay.priceScale.coordinateToPrice(708, 100), 1.292); + assert.equal(notifications, 1); + assert.equal(event.size, 0); + host.close(); +}); + +test('user receives a bounded failure when neither the exact nor a previous candle exists', async t => { + // Given an empty native candle series and the default three second wait + t.mock.timers.enable({ apis: ['setTimeout'] }); + const host = createChartDom({ candle: null }); + const layer = eventLayer(host); + const pending = layer.renderOpened('missing', annotation(), 10000); + // When the full candle deadline expires + t.mock.timers.tick(3000); + // Then the specific placement failure releases its subscription without creating presentation + await assert.rejects(pending, /candle did not arrive within 3000 ms for 10/); + assert.equal(host.dataUpdatedListenerCount, 0); + assert.equal(layer.size, 0); + assert.equal(host.markers().length, 0); layer.clear(); + host.close(); }); diff --git a/test/dom/binance-strategy29-bollinger/chart-runtime-boundaries.test.js b/test/dom/binance-strategy29-bollinger/chart-runtime-boundaries.test.js index 57d4ac4..36beb88 100644 --- a/test/dom/binance-strategy29-bollinger/chart-runtime-boundaries.test.js +++ b/test/dom/binance-strategy29-bollinger/chart-runtime-boundaries.test.js @@ -3,7 +3,7 @@ import test from 'node:test'; import { createBollingerMonitor } from '../../../src/binance-strategy29-bollinger/monitor.js'; import { detectBollingerSignals } from '../../../src/binance-strategy29-bollinger/core/bearish-bollinger-pattern.js'; import { - bollingerIntervalVisibility, buildClosedBarsContentSnapshot, createBollingerIntervalSession, + buildClosedBarsContentSnapshot, createBollingerIntervalSession, createBollingerMarkerLayer, exportClosedTradingViewBars, findBearishBollingerChartTarget, isBearishBollingerChartTargetCurrent, matchesClosedBarsContentSnapshot, parseClosedTradingViewBars, } from '../../../src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js'; @@ -36,6 +36,7 @@ async function sample(fixture) { fixture.clock.advance(0); return !fixture.monitor.diagnostics.taskPending; }, 'native export and marker rendering complete'); + fixture.host.overlay.flushFrames(); } test('user sees both directions and reversal markers calculated from native OHLC candles', async (t) => { @@ -47,13 +48,13 @@ test('user sees both directions and reversal markers calculated from native OHLC // When the real monitor exports candles and runs its detector and drawing layer. await sample(fixture); - // Then each calculated event uses its directional shape and the pending shape starts hidden. - assert.deepEqual(host.created.map(({ point, options }) => [point.time, options.shape, options.overrides.color]), [ - [7440, 'icon', '#F6465D'], [7500, 'arrow_down', '#F6465D'], [7740, 'arrow_up', '#0ECB81'], - [9360, 'icon', '#0ECB81'], [9900, 'arrow_down', '#F6465D'], + // Then each calculated event uses its directional SVG style without native drawing writes. + assert.deepEqual(host.overlay.markers().map(node => [node.dataset.markerType, node.dataset.markerDirection, node.getAttribute('fill')]), [ + ['warning', 'bearish', '#F6465D'], ['confirmed', 'bearish', '#F6465D'], ['reversal', 'bearish', '#0ECB81'], + ['warning', 'bullish', '#0ECB81'], ['reversal', 'bullish', '#F6465D'], ]); - assert.equal(host.created.every(({ options }) => options.overrides.visible === false && options.disableSave === true), true); - assert.deepEqual(host.propertyWrites.map(({ properties }) => properties.visible), [true, true, true, true, true]); + assert.deepEqual(host.created, []); + assert.deepEqual(host.propertyWrites, []); assert.equal(monitor.diagnostics.cachedSignalCount, 5); assert.equal(monitor.diagnostics.layerSize, 5); assert.deepEqual(fixture.errors, []); @@ -65,7 +66,7 @@ test('user sees both directions and reversal markers calculated from native OHLC // Then user drawings are saved and unchanged strategy markers are reused. assert.deepEqual(saved, { drawings: [{ id: 'user-line' }] }); - assert.equal(host.created.length, 5); + assert.equal(host.overlay.markers().length, 5); assert.deepEqual(host.removed, []); assert.equal(host.exports.length, 2); }); @@ -80,150 +81,15 @@ test('user removes obsolete strategy markers when revised native candles contain fixture.host.setBars(createOscillatingStrategyBars().map(bar => ({ ...bar, open: 100, close: 100, high: 101, low: 99 }))); await sample(fixture); - // Then only the former strategy drawings are removed and the empty result is cached. - assert.deepEqual(fixture.host.removed, ['native-1', 'native-2', 'native-3', 'native-4', 'native-5']); + // Then strategy SVG markers disappear and the empty result is cached. + assert.deepEqual(fixture.host.removed, []); + assert.equal(fixture.host.overlay.markers().length, 0); assert.deepEqual(fixture.host.chart.getAllShapes(), [{ id: 'user-line', name: 'trend_line' }]); assert.equal(fixture.monitor.diagnostics.cachedSignalCount, 0); assert.equal(fixture.monitor.diagnostics.layerSize, 0); assert.deepEqual(fixture.errors, []); }); -for (const [label, properties] of [ - ['icon', { icon: 0xf110 }], - ['interval visibility', { intervalsVisibilities: { minutes: false } }], -]) { - test(`user repairs an externally changed marker ${label} without duplicating other signals`, async (t) => { - // Given five real signals whose first native drawing has been edited externally. - const fixture = monitorFixture(t); - await sample(fixture); - fixture.host.editProperties('native-1', properties); - - // When the unchanged candle window is audited again. - await sample(fixture); - - // Then the changed drawing is replaced and the complete real marker contract is restored. - assert.deepEqual(fixture.host.removed, ['native-1']); - assert.equal(fixture.host.created.length, 6); - assert.equal(fixture.monitor.diagnostics.layerSize, 5); - assert.deepEqual(fixture.host.shapes.get('native-6').getProperties(), { - visible: true, intervalsVisibilities: bollingerIntervalVisibility('1'), color: '#F6465D', size: 10, icon: 0xf111, - }); - }); -} - -test('user stops publishing markers when native property writes are declined and clears the pending drawing', async (t) => { - // Given a native chart that accepts creation but declines property writes. - const fixture = monitorFixture(t); - fixture.host.addForeignShape('user-line'); - fixture.host.ignorePropertyWrites(true); - - // When the real monitor tries to reveal its first signal marker. - await sample(fixture); - - // Then the unpublished marker is removed and diagnostics identify the fatal render failure. - assert.deepEqual(fixture.host.removed, ['native-1']); - assert.deepEqual(fixture.host.chart.getAllShapes(), [{ id: 'user-line', name: 'trend_line' }]); - assert.equal(fixture.monitor.diagnostics.failed, true); - assert.equal(fixture.monitor.diagnostics.cleanupPending, true); - assert.equal(fixture.monitor.diagnostics.lastLocalFailure.stage, 'render'); - assert.equal(fixture.monitor.diagnostics.lastLocalFailure.message, 'TradingView Bollinger alert marker properties were not applied'); - assert.equal(fixture.monitor.diagnostics.cachedSignalCount, null); - assert.equal(fixture.errors.length, 1); -}); - -test('user receives a fatal native-property diagnostic before old owned markers are cleaned on the next sample', async (t) => { - // Given an established signal layer whose native property readback becomes invalid. - const fixture = monitorFixture(t); - fixture.host.addForeignShape('user-line'); - await sample(fixture); - fixture.host.setNativeProperties('native-1', null); - - // When the next real audit reads the malformed external properties. - await sample(fixture); - - // Then the existing layer is marked for cleanup without losing the original failure stage. - assert.equal(fixture.monitor.diagnostics.lastLocalFailure.message, 'TradingView Bollinger alert marker properties are invalid'); - assert.equal(fixture.monitor.diagnostics.lastLocalFailure.layerSizeBeforeCleanup, 5); - assert.equal(fixture.monitor.diagnostics.cleanupPending, true); - - // When another sample applies the queued cleanup. - await sample(fixture); - - // Then all owned markers disappear, the foreign line survives, and no export is retried in the failed context. - assert.deepEqual(fixture.host.chart.getAllShapes(), [{ id: 'user-line', name: 'trend_line' }]); - assert.equal(fixture.monitor.diagnostics.layerSize, 0); - assert.equal(fixture.monitor.diagnostics.cleanupPending, false); - assert.equal(fixture.host.exports.length, 2); -}); - -for (const invalidId of ['', 42]) { - test(`user stops after a native marker creation returns ${invalidId === '' ? 'an empty ID' : 'a numeric ID'}`, async (t) => { - // Given a native creation response that violates the external chart contract. - const fixture = monitorFixture(t); - fixture.host.returnNextCreationId(invalidId); - - // When the first detected marker awaits that native response. - await sample(fixture); - - // Then no ID is claimed or removed and the render failure is retained. - assert.equal(fixture.monitor.diagnostics.lastLocalFailure.message, 'TradingView returned an invalid Bollinger alert shape id'); - assert.equal(fixture.monitor.diagnostics.failed, true); - assert.equal(fixture.monitor.diagnostics.layerSize, 0); - assert.equal(fixture.host.created.length, 1); - assert.deepEqual(fixture.host.removed, []); - assert.equal(fixture.host.shapes.size, 0); - }); -} - -test('user discards a marker whose native readback races leaving the futures page', async (t) => { - // Given a real signal whose native point read occurs as the route is retired. - const fixture = monitorFixture(t); - fixture.host.onNextPointRead(() => { fixture.state.futures = false; }); - - // When marker creation finishes and the layer reads its point. - await sample(fixture); - - // Then the pending drawing never becomes visible and is removed without recording a terminal error. - assert.equal(fixture.host.created.length, 1); - assert.deepEqual(fixture.host.propertyWrites, []); - assert.deepEqual(fixture.host.removed, ['native-1']); - assert.equal(fixture.monitor.diagnostics.cachedSignalCount, null); - assert.equal(fixture.monitor.diagnostics.failed, false); - assert.deepEqual(fixture.errors, []); -}); - -test('user defers retired drawing cleanup while another drawing owner is busy and resumes afterward', async (t) => { - // Given real signal candles whose first native marker creation is still pending. - const fixture = monitorFixture(t); - const gate = fixture.host.holdNextCreation(); - await fixture.monitor.tick(); - await gate.entered; - - // When stop invalidates that context while the host reports a drawing owner as busy. - fixture.state.busy = true; - fixture.monitor.stop(); - gate.release(); - await observeStrategyCondition(() => !fixture.monitor.diagnostics.taskPending, 'retired native creation settles'); - - // Then the late result remains owned and hidden while mutation is deferred. - assert.equal(fixture.monitor.diagnostics.contextPresent, false); - assert.equal(fixture.monitor.diagnostics.retiredCount, 1); - assert.equal(fixture.host.shapes.get('native-1').properties.visible, false); - assert.deepEqual(fixture.host.removed, []); - assert.deepEqual(fixture.host.propertyWrites, []); - - // When the drawing owner releases the page and the monitor samples again. - fixture.state.busy = false; - await sample(fixture); - - // Then the retired result is removed before one fresh complete layer is published. - assert.deepEqual(fixture.host.removed, ['native-1']); - assert.equal(fixture.monitor.diagnostics.retiredCount, 0); - assert.equal(fixture.monitor.diagnostics.cachedSignalCount, 5); - assert.equal(fixture.monitor.diagnostics.layerSize, 5); - assert.equal(fixture.host.shapes.size, 5); -}); - test('user ignores a late export error from a retired interval and renders the fresh interval', async (t) => { // Given a pending native export in the one-minute interval. const fixture = monitorFixture(t); @@ -246,8 +112,8 @@ test('user ignores a late export error from a retired interval and renders the f assert.equal(fixture.monitor.diagnostics.lastLocalFailure, null); assert.equal(fixture.monitor.diagnostics.failed, false); assert.equal(fixture.monitor.diagnostics.layerSize, 5); - assert.deepEqual(fixture.host.created.map(({ point }) => point.time), [37200, 37500, 38700, 46800, 49500]); - assert.equal(fixture.host.created.every(({ options }) => options.overrides.intervalsVisibilities.minutesFrom === 5), true); + assert.deepEqual(fixture.host.overlay.markers().map(node => Number(node.getAttribute('transform').split(' ')[0].slice(10))), [123, 124, 128, 155, 164]); + assert.deepEqual(fixture.host.created, []); }); for (const [label, reason, thrownType, name, message] of [ @@ -306,7 +172,7 @@ test('user waits through hidden pages, missing routes, busy drawings and an inco // Then the completed interval renders once and an established busy layer remains untouched. assert.equal(fixture.host.exports.length, 1); - assert.equal(fixture.host.created.length, 5); + assert.equal(fixture.host.overlay.markers().length, 5); assert.equal(fixture.monitor.diagnostics.layerSize, 5); }); @@ -378,22 +244,6 @@ test('user stops recognizing a chart target when the native chart or its model d assert.deepEqual({ missingChart, missingModel, restored }, { missingChart: false, missingModel: false, restored: true }); }); -for (const [resolution, unit, count] of [['10S', 'seconds', 10], ['1D', 'days', 1], ['2H', 'hours', 2], ['90', 'hours', 1]]) { - test(`user keeps ${resolution} markers visible only in their native interval bucket`, () => { - // Given an observed native interval that the chart groups into one visibility bucket. - const interval = resolution; - - // When the production adapter computes the drawing visibility contract. - const visibility = bollingerIntervalVisibility(interval); - - // Then exactly that native unit and count are enabled. - assert.deepEqual(visibility, { - ticks: false, seconds: false, minutes: false, hours: false, days: false, weeks: false, - months: false, ranges: false, [unit]: true, [`${unit}From`]: count, [`${unit}To`]: count, - }); - }); -} - for (const [label, change, expected] of [ ['schema', value => { value.schema = null; }, /export schema is invalid/], ['data list', value => { value.data = {}; }, /export data is invalid/], @@ -476,3 +326,77 @@ test('user leaves markers untouched when direct rendering loses drawing ownershi assert.deepEqual(host.created, []); assert.deepEqual(host.removed, []); }); + + +test('user receives a terminal render diagnostic when an event-driven overlay projection fails', async (t) => { + // Given a healthy monitored overlay and an unrelated native drawing. + const fixture = monitorFixture(t); + fixture.host.addForeignShape('user-line'); + await sample(fixture); + fixture.host.overlay.setProjection({ price: () => NaN }); + + // When a price-scale event redraws through the asynchronous frame boundary. + fixture.host.overlay.events.priceRangeChanged.emit(); + fixture.host.overlay.flushFrames(); + + // Then the visible layer is cleared and the existing monitor failure boundary records the cause. + assert.equal(fixture.monitor.diagnostics.failed, true); + assert.equal(fixture.monitor.diagnostics.lastLocalFailure.stage, 'render'); + assert.equal(fixture.monitor.diagnostics.lastLocalFailure.message, 'TradingView marker coordinates are invalid'); + assert.equal(fixture.host.overlay.markers().length, 0); + assert.equal(fixture.host.overlay.subscriptions, 0); + assert.deepEqual(fixture.host.chart.getAllShapes(), [{ id: 'user-line', name: 'trend_line' }]); +}); + +test('user immediately hides old interval overlays while a trading drawing owner is busy', async (t) => { + // Given a rendered overlay and another script holding native chart mutation ownership. + const fixture = monitorFixture(t); + await sample(fixture); + const svg = fixture.host.overlay.pane.querySelector('svg'); + fixture.state.busy = true; + + // When the native chart changes intervals before the monitor can resume. + fixture.host.changeInterval('5'); + + // Then the old overlay is hidden synchronously without any native drawing mutation. + assert.equal(svg.style.visibility, 'hidden'); + assert.equal(fixture.host.overlay.pendingFrames, 0); + assert.deepEqual(fixture.host.created, []); + assert.deepEqual(fixture.host.removed, []); + + // When the monitor is stopped while trading remains busy. + fixture.monitor.stop(); + + // Then private overlay resources disappear immediately without waiting for ownership. + assert.equal(fixture.host.overlay.markers().length, 0); + assert.equal(fixture.host.overlay.subscriptions, 0); + assert.equal(fixture.monitor.diagnostics.retiredCount, 0); +}); + + +test('user keeps a failed overlay empty when an older candle export finishes after the render failure', async (t) => { + // Given an established overlay and a second candle export still pending. + const fixture = monitorFixture(t); + await sample(fixture); + assert.equal(fixture.host.overlay.markers().length, 5); + const gate = fixture.host.holdNextExport(); + await fixture.monitor.tick(); + await gate.entered; + + // When a frame fails before the older export completes with valid candles. + fixture.host.overlay.setProjection({ price: () => NaN }); + fixture.host.overlay.events.priceRangeChanged.emit(); + fixture.host.overlay.flushFrames(); + assert.equal(fixture.monitor.diagnostics.failed, true); + assert.equal(fixture.host.overlay.markers().length, 0); + fixture.host.overlay.setProjection({ price: price => 500 - price }); + gate.resolve(exportStrategyBars(createOscillatingStrategyBars())); + await observeStrategyCondition(() => !fixture.monitor.diagnostics.taskPending, 'older export settles after terminal failure'); + + // Then terminal failure prevents the earlier request from recreating markers or subscriptions. + assert.equal(fixture.monitor.diagnostics.failed, true); + assert.equal(fixture.host.overlay.markers().length, 0); + assert.equal(fixture.host.overlay.subscriptions, 0); + assert.equal(fixture.errors.length, 1); + assert.equal(fixture.monitor.diagnostics.lastLocalFailure.message, 'TradingView marker coordinates are invalid'); +}); diff --git a/test/dom/binance-strategy29-bollinger/native-provider-boundaries.test.js b/test/dom/binance-strategy29-bollinger/native-provider-boundaries.test.js index 1d765e1..1363b01 100644 --- a/test/dom/binance-strategy29-bollinger/native-provider-boundaries.test.js +++ b/test/dom/binance-strategy29-bollinger/native-provider-boundaries.test.js @@ -176,29 +176,23 @@ for (const [label, options, change, expected] of [ }); } -for (const [label, list, expected] of [ - ['non-array shape list', null, 'TradingView Bollinger alert shape list is invalid'], - ['nonnative shape identifier', [{ id: 7, name: 'icon' }], 'TradingView Bollinger alert shape 0 id is invalid'], -]) { - test(`user stops marker publication after a native ${label} and keeps unrelated drawings`, async (t) => { - // Given real signal candles and a malformed list supplied by the native chart API. - const fixture = monitorFixture(t); - fixture.host.addForeignShape('user-line'); - fixture.host.listShapesNext(list); - - // When the real detector completes and the marker layer audits native ownership. - await sample(fixture); - - // Then the render contract failure is retained without creating or deleting any drawing. - assert.equal(fixture.monitor.diagnostics.failed, true); - assert.equal(fixture.monitor.diagnostics.lastLocalFailure.stage, 'render'); - assert.equal(fixture.monitor.diagnostics.lastLocalFailure.message, expected); - assert.equal(fixture.monitor.diagnostics.cachedSignalCount, null); - assert.deepEqual(fixture.host.created, []); - assert.deepEqual(fixture.host.removed, []); - assert.deepEqual(fixture.host.chart.getAllShapes(), [{ id: 'user-line', name: 'trend_line' }]); - }); -} +test('user renders overlay signals without consulting or mutating foreign native drawings', async (t) => { + // Given a host whose native drawing list is unavailable but candle projection is healthy. + const fixture = monitorFixture(t); + fixture.host.addForeignShape('user-line'); + fixture.host.listShapesNext(null); + + // When the monitor renders signals from native candle data. + await sample(fixture); + + // Then SVG signals render and native drawing ownership is never consulted or changed. + assert.equal(fixture.monitor.diagnostics.failed, false); + assert.equal(fixture.host.overlay.markers().length, 5); + assert.deepEqual(fixture.host.created, []); + assert.deepEqual(fixture.host.removed, []); + assert.equal(fixture.host.chart.getAllShapes(), null); + assert.deepEqual(fixture.host.chart.getAllShapes(), [{ id: 'user-line', name: 'trend_line' }]); +}); test('user rejects an unavailable interval subscription without attaching half a readiness session', (t) => { // Given a native chart that has not exposed its interval subscription interface. diff --git a/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js b/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js index 273ea51..6b06082 100644 --- a/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js +++ b/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js @@ -1,8 +1,8 @@ +import { attachChartMarkerOverlayHost } from '../../helpers/chart-marker-overlay-host.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { readFile } from 'node:fs/promises'; import * as alertApi from '../../../src/binance-strategy29-bollinger/dom/tradingview-bearish-alerts.js'; -import { getTradingViewMarkerSaveController } from '../../../src/shared/chart-marker-save-controller.js'; import { createBollingerMonitor } from '../../../src/binance-strategy29-bollinger/monitor.js'; import { loadFixtureDom } from '../../helpers/dom.js'; @@ -31,76 +31,30 @@ import { const monitorSource = await readFile(new URL('../../../src/binance-strategy29-bollinger/monitor.js', import.meta.url), 'utf8'); -test('user observes that native marker creation and clear save bursts preserve foreign drawings without arming stable audits', async () => { - // Given the chart candles, interval and marker ownership +test('user renders updates and clears 2000 markers without touching native drawings or chart saves', async () => { + // Given a chart with a user drawing and a full marker history const fixture = createChartDom(); - // When fixture.addForeignShape processes the configured inputs fixture.addForeignShape('user-channel'); - const snapshots = []; - const scheduleSave = () => setTimeout(() => fixture.tradingViewApi.saveChart((value) => snapshots.push(value)), 100); - const create = fixture.chart.createShape; - fixture.chart.createShape = async (...args) => { - const id = await create(...args); - const shape = fixture.chart.getShapeById(id); - const update = shape.setProperties.bind(shape); - shape.setProperties = (...properties) => { update(...properties); scheduleSave(); }; - scheduleSave(); - return id; - }; - const remove = fixture.chart.removeEntity; - fixture.chart.removeEntity = (id) => { remove(id); scheduleSave(); }; - const target = findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'); - const layer = createBollingerMarkerLayer(target); - const controller = getTradingViewMarkerSaveController(fixture.tradingViewApi); - const signals = Array.from({ length: 5 }, (_, i) => ({ - id: `signal-${i}`, direction: 'bearish', type: 'warning', time: 60 * (i + 1), markerPrice: 13, + const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT')); + const signals = Array.from({ length: 2000 }, (_, index) => ({ + id: `overlay-${index}`, direction: index < 1000 ? 'bearish' : 'bullish', type: 'warning', + time: 60 * (index + 1), markerPrice: 13, })); - const observedResult = await layer.render(signals, { isCurrent: () => true }); - // Then user observes that native marker creation and clear save bursts preserve foreign drawings without arming stable audits - assert.equal(observedResult, true); - await controller.runAfterIdle(() => {}); - assert.equal(layer.saveStats.saveRequests, 10); - assert.equal(layer.saveStats.serializations, 1); - assert.equal(snapshots.length, 10); - assert.deepEqual(snapshots[0], { drawings: [{ id: 'user-channel' }] }); + // When the history is rendered updated and cleared assert.equal(await layer.render(signals, { isCurrent: () => true }), true); - assert.equal(layer.saveStats.busy, false); - assert.equal(layer.saveStats.serializations, 1); + assert.equal(layer.size, 2000); + assert.equal(fixture.overlay.markers().length, 1000); + assert.equal(await layer.render(signals.map(signal => ({ ...signal, markerPrice: 14 })), { isCurrent: () => true }), true); + assert.equal(fixture.overlay.markers()[0].getAttribute('transform'), 'translate(1 486)'); assert.equal(layer.clear(), true); - assert.equal(layer.saveStats.busy, true); - await controller.runAfterIdle(() => {}); - assert.equal(layer.saveStats.saveRequests, 15); - assert.equal(layer.saveStats.serializations, 2); - assert.equal(snapshots.length, 15); + // Then native chart persistence and foreign drawings are untouched + assert.equal(fixture.createdOptions.length, 0); + assert.equal(fixture.removed.length, 0); + assert.equal(fixture.saveCalls.length, 0); assert.deepEqual([...fixture.shapes.keys()], ['user-channel']); - assert.deepEqual(snapshots.at(-1), { drawings: [{ id: 'user-channel' }] }); + fixture.dom.window.close(); }); -test('user observes that an outer save drain waits for native creation, leaves its late result hidden, and preserves cleanup ownership', async () => { - // Given the chart candles, interval and marker ownership - const fixture = createChartDom({ deferredCreate: true }); - // When fixture.addForeignShape processes the configured inputs - fixture.addForeignShape('user-channel'); - const target = findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'); - const layer = createBollingerMarkerLayer(target); - const rendering = layer.render([ - { id: 'warning', direction: 'bearish', type: 'warning', time: 60, markerPrice: 13 }, - ], { isCurrent: () => true }); - const controller = getTradingViewMarkerSaveController(fixture.tradingViewApi); - let starts = 0; - const drain = controller.runAfterIdle(() => { starts += 1; }); - // Then user observes that an outer save drain waits for native creation, leaves its late result hidden, and preserves cleanup ownership - assert.equal(starts, 0); - assert.equal(layer.saveStats.mutations, 1); - fixture.releaseCreate(); - assert.equal(await rendering, false); - assert.equal(fixture.shapes.get('shape-1').properties.overrides.visible, false); - assert.equal(fixture.propertyUpdates.length, 0); - await drain; - assert.equal(starts, 1); - assert.equal(layer.clear(), true); - assert.deepEqual([...fixture.shapes.keys()], ['user-channel']); -}); /** Execute the production monitor functions, without the unrelated trading/bootstrap side effects. */ function createMonitorHarness(fixture, dependencyOverrides = {}) { @@ -170,16 +124,12 @@ function createMonitorHarness(fixture, dependencyOverrides = {}) { function createChartDom({ resolution = '1', symbol = 'BTRUSDT@PRICETYPE=LAST', - shiftSeconds = 0, - deferredCreate = false, } = {}) { const dom = loadFixtureDom(''); const shapes = new Map(); const removed = []; const createdOptions = []; - const propertyUpdates = []; - let nextId = 1; - let releaseCreate = null; + const saveCalls = []; let currentResolution = resolution; let currentSymbol = symbol; let modelReady = true; @@ -207,28 +157,7 @@ function createChartDom({ onIntervalChanged: () => intervalChanged, onDataLoaded: () => dataLoaded, exportData: async () => ({ schema: [], data: [] }), - async createShape(point, properties) { - createdOptions.push(structuredClone(properties)); - if (deferredCreate) await new Promise((resolve) => { releaseCreate = resolve; }); - const id = `shape-${nextId++}`; - const currentVisibility = alertApi.bollingerIntervalVisibility(currentResolution); - const nativeVisibility = { ...properties.overrides.intervalsVisibilities }; - // Native creation enables the interval active when the async loader resolves. - for (const [key, value] of Object.entries(currentVisibility)) { - if (value !== false) nativeVisibility[key] = value; - } - shapes.set(id, { - point: { ...point, time: point.time + shiftSeconds }, - properties: { ...properties, overrides: { ...properties.overrides, intervalsVisibilities: nativeVisibility } }, - getPoints() { return [this.point]; }, - getProperties() { return { ...this.properties.overrides, icon: this.properties.icon }; }, - setProperties(overrides, saveDefaults) { - propertyUpdates.push({ id, overrides: structuredClone(overrides), saveDefaults }); - Object.assign(this.properties.overrides, overrides); - }, - }); - return id; - }, + async createShape(point, properties) { createdOptions.push({ point, properties }); throw new Error('Native markers are forbidden'); }, getShapeById: (id) => shapes.get(id), getAllShapes: () => [...shapes.entries()].map(([id, record]) => ({ id, @@ -240,16 +169,18 @@ function createChartDom({ shapes.delete(id); }, }; + const overlay = attachChartMarkerOverlayHost({ chart, document: dom.window.document }); let activeChart = chart; const tradingViewApi = { activeChart: () => activeChart, - saveChart: (callback) => callback({ + saveChart: (callback) => { saveCalls.push(true); return callback({ drawings: [...shapes].filter(([, record]) => !record.properties.disableSave).map(([id]) => ({ id })), - }), + }); }, }; dom.window.document.querySelector('iframe').contentWindow.tradingViewApi = tradingViewApi; return { dom, + overlay, chart, tradingViewApi, intervalChanged, @@ -257,13 +188,11 @@ function createChartDom({ shapes, removed, createdOptions, - propertyUpdates, + saveCalls, setModelReady: (value) => { modelReady = value; }, - releaseCreate: () => releaseCreate(), setActiveChart: (value) => { activeChart = value; }, setResolution: (value) => { currentResolution = value; intervalChanged.fire(value); }, setSymbol: (value) => { currentSymbol = value; }, - evictShape: (id) => { shapes.delete(id); }, addForeignShape(id = 'foreign-shape') { assert.equal(shapes.has(id), false, `shape ${id} should not already exist`); shapes.set(id, { @@ -341,7 +270,7 @@ test('user sees older loaded signals added by the real monitor without a new lat const fixture = fullMonitorFixture(t); fixture.addForeignShape('user-channel'); await fixture.sample(); - const recentIds = [...fixture.shapes.keys()].filter(id => id !== 'user-channel'); + const recentIds = fixture.overlay.markers().map(node => node.dataset.markerId); assert.equal(recentIds.length, 12); assert.equal(fixture.monitor.diagnostics.cachedSignalCount, 12); assert.equal(fixture.source.exported.data.at(-1)[0], 36000); @@ -353,11 +282,11 @@ test('user sees older loaded signals added by the real monitor without a new lat // Then all thirty-nine real detector signals are retained, including the earliest loaded setup assert.equal(fixture.monitor.diagnostics.cachedSignalCount, 39); assert.equal(fixture.monitor.diagnostics.layerSize, 39); - assert.equal(fixture.shapes.size, 40); + assert.equal(fixture.overlay.markers().length, 39); assert.equal(fixture.source.exported.data.at(-1)[0], 36000); - assert.equal(recentIds.every(id => fixture.shapes.has(id)), true); + assert.equal(recentIds.every(id => fixture.overlay.markers().some(node => node.dataset.markerId === id)), true); assert.deepEqual(fixture.removed, []); - assert.deepEqual([...fixture.shapes.values()].filter(shape => shape.point.time > 0).map(shape => shape.point.time).sort((a, b) => a - b).slice(0, 3), [5760, 5820, 6840]); + assert.deepEqual(fixture.overlay.markers().map(node => Number(node.getAttribute('transform').match(/translate\(([^ ]+)/)[1]) * 60).sort((a, b) => a - b).slice(0, 3), [5760, 5820, 6840]); assert.deepEqual(fixture.errors, []); // When another unchanged sample audits the full loaded history @@ -374,7 +303,7 @@ test('user keeps real monitor markers through a snapshot race and resumes the ne // Given twelve actual detector markers and the original valid native history const fixture = fullMonitorFixture(t); await fixture.sample(); - const ids = [...fixture.shapes.keys()]; + const ids = fixture.overlay.markers().map(node => node.dataset.markerId); const original = fixture.source.exported; const malformed = structuredClone(original); malformed.data[0][2] = malformed.data[0][3] - 1; @@ -388,7 +317,7 @@ test('user keeps real monitor markers through a snapshot race and resumes the ne assert.equal(fixture.monitor.diagnostics.cleanupPending, false); assert.equal(fixture.monitor.diagnostics.lastLocalFailure, null); assert.equal(fixture.monitor.diagnostics.cachedSignalCount, 12); - assert.deepEqual([...fixture.shapes.keys()], ids); + assert.deepEqual(fixture.overlay.markers().map(node => node.dataset.markerId), ids); assert.equal(fixture.warnings.length, 1); assert.equal(isTradingViewBarSnapshotInconsistentError(fixture.warnings[0][1]), true); @@ -398,12 +327,12 @@ test('user keeps real monitor markers through a snapshot race and resumes the ne // Then the real monitor resumes sampling without replacing unchanged markers assert.equal(fixture.source.requests, 3); - assert.deepEqual([...fixture.shapes.keys()], ids); + assert.deepEqual(fixture.overlay.markers().map(node => node.dataset.markerId), ids); assert.equal(fixture.monitor.diagnostics.failed, false); assert.deepEqual(fixture.errors, []); }); -test('user sees a fatal real monitor schema failure clear only owned markers on the next cleanup sample', async (t) => { +test('user sees a fatal real monitor schema failure clear only owned markers immediately', async (t) => { // Given verified detector markers beside a foreign user drawing const fixture = fullMonitorFixture(t); fixture.addForeignShape('user-channel'); @@ -413,21 +342,22 @@ test('user sees a fatal real monitor schema failure clear only owned markers on fixture.source.exported = { schema: [], data: [] }; await fixture.sample(); - // Then the fatal failure records pre-cleanup evidence and schedules owned-layer cleanup + // Then the fatal failure records pre-cleanup evidence and clears the owned overlay assert.equal(fixture.monitor.diagnostics.failed, true); - assert.equal(fixture.monitor.diagnostics.cleanupPending, true); + assert.equal(fixture.monitor.diagnostics.cleanupPending, false); assert.equal(fixture.monitor.diagnostics.lastLocalFailure.stage, 'export'); assert.equal(fixture.monitor.diagnostics.lastLocalFailure.cachedSignalCount, 12); assert.equal(fixture.monitor.diagnostics.lastLocalFailure.layerSizeBeforeCleanup, 12); assert.match(fixture.monitor.diagnostics.lastLocalFailure.message, /schema mismatch/); - assert.equal(fixture.shapes.size, 13); + assert.equal(fixture.overlay.markers().length, 0); // When the next sample executes the deferred fatal cleanup await fixture.sample(); // Then every owned marker is removed once and the failed context stops exporting assert.deepEqual([...fixture.shapes.keys()], ['user-channel']); - assert.equal(fixture.removed.length, 12); + assert.equal(fixture.removed.length, 0); + assert.equal(fixture.overlay.markers().length, 0); assert.equal(fixture.source.requests, 2); assert.equal(fixture.monitor.diagnostics.cleanupPending, false); assert.equal(fixture.monitor.diagnostics.failed, true); @@ -548,112 +478,6 @@ test('user observes that interval sessions invalidate A-B-A exports and wait for assert.equal(dataLoaded.size, 0); }); -test('user observes that reconciles moved points, changed signal prices and altered owned colors without touching foreign drawings', async () => { - // Given the chart candles, interval and marker ownership - const { dom, shapes, removed, addForeignShape } = createChartDom(); - const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT')); - const signal = { id: 'drift', direction: 'bearish', type: 'warning', time: 120, markerPrice: 10 }; - // When addForeignShape processes the configured inputs - addForeignShape(); - await layer.render([signal], { isCurrent: () => true }); - shapes.get('shape-1').point.time = 60; - await layer.render([signal], { isCurrent: () => true }); - // Then user observes that reconciles moved points, changed signal prices and altered owned colors without touching foreign drawings - assert.deepEqual(shapes.get('shape-2').point, { time: 120, price: 10 }); - shapes.get('shape-2').point.price = 12; - await layer.render([signal], { isCurrent: () => true }); - assert.deepEqual(shapes.get('shape-3').point, { time: 120, price: 10 }); - await layer.render([{ ...signal, markerPrice: 11 }], { isCurrent: () => true }); - assert.equal(shapes.get('shape-4').point.price, 11); - shapes.get('shape-4').properties.overrides.color = '#000000'; - await layer.render([{ ...signal, markerPrice: 11 }], { isCurrent: () => true }); - assert.equal(shapes.get('shape-5').properties.overrides.color, '#F6465D'); - shapes.get('shape-5').properties.shape = 'arrow_down'; - await layer.render([{ ...signal, markerPrice: 11 }], { isCurrent: () => true }); - assert.equal(shapes.get('shape-6').properties.shape, 'icon'); - assert.deepEqual(removed, ['shape-1', 'shape-2', 'shape-3', 'shape-4', 'shape-5']); - assert.equal(shapes.has('foreign-shape'), true); -}); - -test('user observes that native interval visibility hides second markers on minutes even while cleanup is busy', async () => { - // Given the chart candles, interval and marker ownership - const { dom, shapes, removed, setResolution } = createChartDom({ resolution: '1S' }); - let busy = false; - const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'), { - canMutate: () => !busy, - }); - // When layer.render processes the configured inputs - await layer.render([{ id: 'second', direction: 'bearish', type: 'warning', time: 3601, markerPrice: 10 }], { - isCurrent: () => true, - }); - const visibility = shapes.get('shape-1').properties.overrides.intervalsVisibilities; - // Then user observes that native interval visibility hides second markers on minutes even while cleanup is busy - assert.equal(visibility.seconds, true); - assert.equal(visibility.secondsFrom, 1); - assert.equal(visibility.secondsTo, 1); - assert.equal(visibility.minutes, false); - busy = true; - setResolution('1'); - assert.equal(layer.clear(), false); - assert.deepEqual(removed, []); - busy = false; - assert.equal(layer.clear(), true); - assert.equal(shapes.size, 0); -}); - -test('user retains late async marker ownership until a busy chart permits cleanup', async () => { - // Given the chart candles, interval and marker ownership - const { dom, shapes, removed, releaseCreate, setResolution, createdOptions, propertyUpdates } = createChartDom({ resolution: '1S', deferredCreate: true }); - let busy = false; - let current = true; - const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'), { - canMutate: () => !busy, - }); - // When layer.render processes the configured inputs - const pending = layer.render([{ id: 'late', direction: 'bearish', type: 'warning', time: 3601, markerPrice: 10 }], { - isCurrent: () => current, - }); - await Promise.resolve(); - // Then user retains late async marker ownership until a busy chart permits cleanup - assert.equal(layer.clear(), false); - current = false; - busy = true; - setResolution('1'); - releaseCreate(); - assert.equal(await pending, false); - assert.equal(shapes.size, 1); - assert.equal(createdOptions[0].overrides.visible, false); - assert.equal(shapes.get('shape-1').getProperties().intervalsVisibilities.minutes, true); - assert.equal(shapes.get('shape-1').getProperties().visible, false); - assert.deepEqual(propertyUpdates, []); - assert.deepEqual(removed, []); - busy = false; - assert.equal(layer.clear(), true); - assert.equal(shapes.size, 0); - assert.deepEqual(removed, ['shape-1']); -}); - -test('user publishes a current hidden creation only after restoring its exact interval mask', async () => { - // Given the chart candles, interval and marker ownership - const fixture = createChartDom({ resolution: '1S', deferredCreate: true }); - const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT')); - // When layer.render processes the configured inputs - const pending = layer.render([{ id: 'publish', direction: 'bullish', type: 'confirmed', time: 3601, markerPrice: 10 }], { - isCurrent: () => true, - }); - fixture.setResolution('1'); - fixture.releaseCreate(); - // Then user publishes a current hidden creation only after restoring its exact interval mask - assert.equal(await pending, true); - assert.equal(fixture.createdOptions[0].overrides.visible, false); - assert.equal(fixture.propertyUpdates.length, 1); - assert.equal(fixture.propertyUpdates[0].saveDefaults, false); - const properties = fixture.shapes.get('shape-1').getProperties(); - assert.equal(properties.visible, true); - assert.equal(properties.intervalsVisibilities.minutes, false); - assert.equal(properties.intervalsVisibilities.seconds, true); - assert.equal(properties.arrowColor, '#0ECB81'); -}); test('user observes that production monitor retires old interval before readiness and resumes with minute data', async () => { // Given the chart candles, interval and marker ownership @@ -667,17 +491,17 @@ test('user observes that production monitor retires old interval before readines await harness.tick(); const session = harness.monitor.session.session; // Then user observes that production monitor retires old interval before readiness and resumes with minute data - assert.equal(fixture.shapes.size, 1); + assert.equal(fixture.overlay.markers().length, 1); harness.setBusy(true); fixture.setResolution('1'); await harness.tick(); assert.equal(harness.monitor.context, null); - assert.equal(harness.monitor.retiredCount, 1); + assert.equal(harness.monitor.retiredCount, 0); assert.equal(fixture.removed.length, 0); assert.equal(harness.detectorCalls, 1); harness.setBusy(false); await harness.tick(); - assert.equal(fixture.shapes.size, 0); + assert.equal(fixture.overlay.markers().length, 0); assert.equal(harness.monitor.context, null); assert.equal(session.isCurrent(session.revision), false); fixture.chart.exportData = async () => exportResult([ @@ -687,9 +511,8 @@ test('user observes that production monitor retires old interval before readines fixture.dataLoaded.fire(); await harness.tick(); assert.equal(harness.monitor.context.resolution, '1'); - assert.equal(fixture.shapes.size, 1); - assert.deepEqual(fixture.shapes.get('shape-2').point, { time: 3600, price: 13 }); - assert.equal(fixture.shapes.get('shape-2').properties.overrides.intervalsVisibilities.seconds, false); + assert.equal(fixture.overlay.markers().length, 1); + assert.equal(fixture.overlay.markers()[0].getAttribute('transform'), 'translate(60 487)'); assert.equal(harness.detectorCalls, 2); assert.deepEqual(harness.errors, []); harness.monitor.stop(); @@ -716,45 +539,19 @@ test('user observes that production monitor stop disposes subscriptions and inva assert.equal(fixture.dataLoaded.size, 0); releaseExport(exportResult([{ 0: 3601, 1: 10, 2: 12, 3: 9, 4: 11 }])); await oldTask; - assert.equal(fixture.shapes.size, 0); + assert.equal(fixture.overlay.markers().length, 0); assert.equal(harness.detectorCalls, 0); harness.setBusy(false); harness.setHidden(false); fixture.chart.exportData = async () => exportResult([{ 0: 3601, 1: 10, 2: 12, 3: 9, 4: 11 }]); await harness.tick(); assert.equal(harness.monitor.retiredCount, 0); - assert.equal(fixture.intervalChanged.size, 1); - assert.equal(fixture.shapes.size, 1); + assert.equal(fixture.intervalChanged.size, 2); + assert.equal(fixture.overlay.markers().length, 1); harness.monitor.stop(); assert.deepEqual(harness.errors, []); }); -test('user observes that production monitor retains a retired layer until late creation and busy cleanup finish', async () => { - // Given the chart candles, interval and marker ownership - const fixture = createChartDom({ resolution: '1S', deferredCreate: true }); - const harness = createMonitorHarness(fixture); - fixture.chart.exportData = async () => exportResult([{ 0: 3601, 1: 10, 2: 12, 3: 9, 4: 11 }]); - // When harness.monitor.tick processes the configured inputs - await harness.monitor.tick(); - await Promise.resolve(); - const oldTask = harness.monitor.task; - harness.setBusy(true); - fixture.setResolution('1'); - await harness.monitor.tick(); - // Then user observes that production monitor retains a retired layer until late creation and busy cleanup finish - assert.equal(harness.monitor.context, null); - fixture.releaseCreate(); - await oldTask; - assert.equal(harness.monitor.retiredCount, 1); - assert.equal(fixture.shapes.size, 1); - assert.deepEqual(fixture.removed, []); - harness.setBusy(false); - await harness.tick(); - assert.equal(harness.monitor.retiredCount, 0); - assert.equal(fixture.shapes.size, 0); - assert.deepEqual(fixture.removed, ['shape-1']); - harness.monitor.stop(); -}); test('user observes that production monitor detects a round-trip interval switch between polls', async () => { // Given the chart candles, interval and marker ownership @@ -769,13 +566,13 @@ test('user observes that production monitor detects a round-trip interval switch await harness.tick(); // Then user observes that production monitor detects a round-trip interval switch between polls assert.equal(harness.monitor.context, null); - assert.equal(fixture.shapes.size, 0); + assert.equal(fixture.overlay.markers().length, 0); assert.equal(harness.detectorCalls, 1); fixture.dataLoaded.fire(); await harness.tick(); assert.notEqual(harness.monitor.context, originalContext); assert.equal(harness.monitor.context.intervalRevision, 2); - assert.equal(fixture.shapes.size, 1); + assert.equal(fixture.overlay.markers().length, 1); assert.equal(harness.detectorCalls, 2); harness.monitor.stop(); }); @@ -796,35 +593,17 @@ test('user observes that production monitor disposes old chart subscriptions eve assert.equal(fixture.intervalChanged.size, 0); assert.equal(fixture.dataLoaded.size, 0); assert.equal(harness.monitor.context, null); - assert.equal(harness.monitor.retiredCount, 1); + assert.equal(harness.monitor.retiredCount, 0); assert.deepEqual(fixture.removed, []); harness.setBusy(false); await harness.tick(); - assert.equal(fixture.shapes.size, 0); - assert.equal(replacement.shapes.size, 1); + assert.equal(fixture.overlay.markers().length, 0); + assert.equal(replacement.overlay.markers().length, 1); assert.equal(harness.monitor.context.target.chart, replacement.chart); harness.monitor.stop(); assert.equal(replacement.intervalChanged.size, 0); }); -for (const [resolution, unit, count] of [ - ['1S', 'seconds', 1], ['90S', 'minutes', 1], ['15', 'minutes', 15], ['60', 'hours', 1], - ['4H', 'hours', 4], ['1D', 'days', 1], ['1W', 'weeks', 1], - ]) { - test(`user uses native visibility buckets for Binance seconds, minutes, hours, days and weeks (resolution=${JSON.stringify(resolution)})`, () => { - // Given a Binance chart resolution with its native visibility bucket - const sourceResolution = resolution; - // When the marker visibility mask is constructed - const visibility = alertApi.bollingerIntervalVisibility(sourceResolution); - // Then only the exact expected bucket is enabled - assert.equal(visibility[unit], true); - assert.equal(visibility[`${unit}From`], count); - assert.equal(visibility[`${unit}To`], count); - assert.deepEqual(Object.entries(visibility).filter(([, value]) => value === true).map(([key]) => key), [unit]); - - }); -} - test('user rejects weekly bars off Monday while preserving legitimate multiweek gaps', () => { // Given weekly candles on the wrong weekday and legitimate Monday candles const row = time => ({ 0: time, 1: 10, 2: 12, 3: 9, 4: 11 }); @@ -866,11 +645,11 @@ test('user observes that production monitor waits through first-refresh model cr assert.deepEqual(harness.errors, []); assert.equal(harness.monitor.context, null); assert.equal(fixture.intervalChanged.size, 0); - assert.equal(fixture.shapes.size, 0); + assert.equal(fixture.overlay.markers().length, 0); fixture.setModelReady(true); await harness.tick(); assert.equal(harness.monitor.context.resolution, '1'); - assert.equal(fixture.shapes.size, 1); + assert.equal(fixture.overlay.markers().length, 1); assert.deepEqual(harness.errors, []); harness.monitor.stop(); }); @@ -891,8 +670,7 @@ test('user observes that on-demand diagnostics distinguish active, awaiting-data assert.deepEqual(harness.monitor.diagnostics, { taskPending: false, contextPresent: true, failed: false, lastLocalFailure: null, 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 }, + markerOverlayStats: { renderedFrames: 1, visibleMarkers: 1, signalCount: 1, attached: true, pendingFrame: false, subscriptions: 9 }, sessionPresent: true, sessionRevision: 0, contextIntervalRevision: 0, sessionMatchesContext: true, sessionCurrent: true, nativeModelReady: true, nativeDataReady: true, mutationBlocked: false, @@ -909,30 +687,6 @@ test('user observes that on-demand diagnostics distinguish active, awaiting-data harness.monitor.stop(); }); -test('user observes that initial one-second alignment failure retains requested and native times after cleanup', async () => { - // Given the chart candles, interval and marker ownership - const fixture = createChartDom({ resolution: '1S', shiftSeconds: -60 }); - const harness = createMonitorHarness(fixture); - fixture.chart.exportData = async () => exportResult([{ 0: 120, 1: 10, 2: 12, 3: 9, 4: 11 }]); - const message = 'TradingView Bollinger alert time alignment failed: expected 120, received 60'; - // When harness.tick processes the configured inputs - const observedResult = harness.tick(); - // Then user observes that initial one-second alignment failure retains requested and native times after cleanup - await assert.rejects(observedResult, { message }); - await harness.tick(); - assert.equal(harness.monitor.diagnostics.failed, true); - assert.equal(harness.monitor.diagnostics.layerSize, 0); - assert.equal(fixture.shapes.size, 0); - assert.deepEqual(harness.monitor.diagnostics.lastLocalFailure, { - thrownType: 'object', classificationFailed: false, name: 'Error', message, - unreadableFields: [], stage: 'render', routeSymbol: 'BTRUSDT', resolution: '1S', - cachedSignalCount: null, layerSizeBeforeCleanup: 0, - sessionRevision: 0, contextIntervalRevision: 0, - }); - harness.monitor.stop(); - assert.equal(harness.monitor.diagnostics.lastLocalFailure.message, message); - fixture.dom.window.close(); -}); for (const stage of ['export', 'render']) { test(`user retains the last local ${stage} failure after clearing the layer and stopping`, async () => { @@ -953,8 +707,8 @@ for (const stage of ['export', 'render']) { assert.equal(harness.monitor.diagnostics.cachedSignalCount, 6); assert.equal(harness.monitor.diagnostics.layerSize, 6); if (stage === 'export') fixture.chart.exportData = async () => { throw new Error('synthetic export failure'); }; - else fixture.chart.getShapeById = () => undefined; - const message = stage === 'export' ? 'synthetic export failure' : 'TradingView Bollinger alert marker point is invalid'; + else fixture.overlay.setProjection({ price: () => { throw new Error('synthetic projection failure'); } }); + const message = stage === 'export' ? 'synthetic export failure' : 'synthetic projection failure'; await assert.rejects(harness.tick(), { message }); await harness.tick(); assert.equal(harness.monitor.diagnostics.failed, true); @@ -962,7 +716,7 @@ for (const stage of ['export', 'render']) { assert.equal(harness.monitor.diagnostics.layerSize, 0); const expected = { thrownType: 'object', classificationFailed: false, name: 'Error', message, unreadableFields: [], stage, routeSymbol: 'BTRUSDT', resolution: '1', - cachedSignalCount: 6, layerSizeBeforeCleanup: 6, + cachedSignalCount: 6, layerSizeBeforeCleanup: stage === 'render' ? 0 : 6, sessionRevision: 0, contextIntervalRevision: 0, }; assert.deepEqual(harness.monitor.diagnostics.lastLocalFailure, expected); @@ -1264,112 +1018,6 @@ test('user changes the content key when OHLC changes inside the same closed-bar assert.equal(matchesClosedBarsContentSnapshot(corrected, snapshot), false); }); -test('user renders warning, bearish confirmation, and bullish reversal markers', async () => { - // Given the chart candles, interval and marker ownership - const { dom, shapes, removed } = createChartDom(); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createBearishBollingerMarkerLayer(target); - const isCurrent = () => true; - - const observedResult = await layer.render([ - { id: 'setup:warning', type: 'warning', time: 120, markerPrice: 10 }, - { id: 'setup:confirmed', type: 'confirmed', time: 180, markerPrice: 9 }, - { id: 'setup:reversal', type: 'reversal', time: 240, markerPrice: 8 }, - ], { isCurrent }); - // Then user renders warning, bearish confirmation, and bullish reversal markers - assert.equal(observedResult, true); - assert.equal(shapes.size, 3); - const records = [...shapes.values()]; - assert.equal(records[0].properties.shape, 'icon'); - assert.equal(records[0].properties.icon, 0xf111); - assert.equal(records[0].properties.overrides.color, '#F6465D'); - assert.equal(records[1].properties.shape, 'arrow_down'); - assert.equal(records[1].properties.overrides.arrowColor, '#F6465D'); - assert.equal(records[2].properties.shape, 'arrow_up'); - assert.equal(records[2].properties.overrides.arrowColor, '#0ECB81'); - assert.equal(records[2].point.price, 8); - - await layer.render([{ id: 'setup:reversal', type: 'reversal', time: 240, markerPrice: 8 }], { - isCurrent, - }); - assert.equal(shapes.size, 1); - assert.deepEqual(removed, ['shape-1', 'shape-2']); -}); - -test('user renders mirrored bullish marker shapes and colors in the shared layer', async () => { - // Given the chart candles, interval and marker ownership - const { dom, shapes } = createChartDom(); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createBearishBollingerMarkerLayer(target); - - await layer.render([ - { - id: 'setup:bullish:warning', - direction: 'bullish', - type: 'warning', - time: 120, - markerPrice: 10, - }, - { - id: 'setup:bullish:confirmed', - direction: 'bullish', - type: 'confirmed', - time: 180, - markerPrice: 9, - }, - { - id: 'setup:bullish:reversal', - direction: 'bullish', - type: 'reversal', - time: 240, - markerPrice: 8, - }, - ], { isCurrent: () => true }); - - const records = [...shapes.values()]; - // Then user renders mirrored bullish marker shapes and colors in the shared layer - assert.equal(records[0].properties.shape, 'icon'); - assert.equal(records[0].properties.overrides.color, '#0ECB81'); - assert.equal(records[1].properties.shape, 'arrow_up'); - assert.equal(records[1].properties.overrides.arrowColor, '#0ECB81'); - assert.equal(records[2].properties.shape, 'arrow_down'); - assert.equal(records[2].properties.overrides.arrowColor, '#F6465D'); -}); - -test('user keeps opposite-direction signals on the same candle as distinct markers', async () => { - // Given the chart candles, interval and marker ownership - const { dom, shapes } = createChartDom(); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createBollingerMarkerLayer(target); - - await layer.render([ - { - id: 'setup:bearish:reversal', - direction: 'bearish', - type: 'reversal', - time: 240, - markerPrice: 8, - }, - { - id: 'setup:bullish:reversal', - direction: 'bullish', - type: 'reversal', - time: 240, - markerPrice: 12, - }, - ], { isCurrent: () => true }); - - // Then user keeps opposite-direction signals on the same candle as distinct markers - assert.equal(shapes.size, 2); - const records = [...shapes.values()]; - assert.deepEqual( - records.map((record) => [record.point.time, record.point.price, record.properties.shape]), - [[240, 8, 'arrow_up'], [240, 12, 'arrow_down']], - ); -}); test('user requires explicit direction when using the shared marker layer', async () => { // Given the chart candles, interval and marker ownership @@ -1411,7 +1059,7 @@ test('user keeps existing markers through a recoverable snapshot error and retri ]; await layer.render([signal], { isCurrent: () => true }); // Then user keeps existing markers through a recoverable snapshot error and retries next tick - assert.equal(shapes.size, 1); + assert.equal(layer.size, 1); let detectorCalls = 0; await assert.rejects( @@ -1428,7 +1076,7 @@ test('user keeps existing markers through a recoverable snapshot error and retri /snapshot race/, ); assert.equal(detectorCalls, 1); - assert.equal(shapes.size, 1); + assert.equal(layer.size, 1); await reconcileBearishBollingerAlertWindow({ bars, @@ -1446,7 +1094,7 @@ test('user keeps existing markers through a recoverable snapshot error and retri ], renderSignals: (signals) => layer.render(signals, { isCurrent: () => true }), }); - assert.equal(shapes.size, 2); + assert.equal(layer.size, 2); }); test('user keeps the task context retryable across malformed-to-valid snapshots', async () => { @@ -1532,167 +1180,6 @@ test('user keeps the task context retryable across malformed-to-valid snapshots' assert.equal(layer.size, 2); }); -test('user recreates a marker that TradingView evicted outside the alert layer', async () => { - // Given the chart candles, interval and marker ownership - const { - dom, - shapes, - removed, - evictShape, - } = createChartDom(); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createBearishBollingerMarkerLayer(target); - const signals = [{ - id: 'setup:warning', - type: 'warning', - time: 120, - markerPrice: 10, - }]; - - const observedResult = await layer.render(signals, { isCurrent: () => true }); - // Then user recreates a marker that TradingView evicted outside the alert layer - assert.equal(observedResult, true); - assert.equal(shapes.has('shape-1'), true); - - evictShape('shape-1'); - - assert.equal(await layer.render(signals, { isCurrent: () => true }), true); - assert.equal(shapes.has('shape-1'), false); - assert.equal(shapes.has('shape-2'), true); - assert.deepEqual(removed, []); -}); - -test('user observes that same closed-bar window reuses detection and still restores an evicted marker', async () => { - // Given the chart candles, interval and marker ownership - const { - dom, - shapes, - removed, - evictShape, - } = createChartDom(); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createBearishBollingerMarkerLayer(target); - const bars = [ - { time: 60, open: 10, high: 12, low: 9, close: 11 }, - { time: 120, open: 11, high: 13, low: 10, close: 12 }, - ]; - const expectedSignals = [{ - id: 'setup:warning', - type: 'warning', - time: 120, - markerPrice: 10, - }]; - let detectorCalls = 0; - let rendererCalls = 0; - let cachedWindowKey = null; - let cachedContentSnapshot = null; - let cachedSignals = null; - - async function runMonitorTick() { - const result = await reconcileBearishBollingerAlertWindow({ - bars, - cachedWindowKey, - cachedContentSnapshot, - cachedSignals, - detectSignals: () => { - detectorCalls += 1; - return expectedSignals; - }, - renderSignals: (signals) => { - rendererCalls += 1; - return layer.render(signals, { isCurrent: () => true }); - }, - }); - if (result.rendered) { - cachedWindowKey = result.closedBarsWindowKey; - cachedContentSnapshot = result.closedBarsContentSnapshot; - cachedSignals = result.signals; - } - } - - await runMonitorTick(); - // Then user observes that same closed-bar window reuses detection and still restores an evicted marker - assert.equal(shapes.has('shape-1'), true); - evictShape('shape-1'); - - await runMonitorTick(); - - assert.equal(detectorCalls, 1); - assert.equal(rendererCalls, 2); - assert.equal(shapes.has('shape-1'), false); - assert.equal(shapes.has('shape-2'), true); - assert.deepEqual(removed, []); -}); - -test('user observes that reconciliation, signal removal, and clear preserve foreign drawings', async () => { - // Given the chart candles, interval and marker ownership - const { - dom, - shapes, - removed, - evictShape, - addForeignShape, - } = createChartDom(); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createBearishBollingerMarkerLayer(target); - const foreignId = addForeignShape(); - const signal = { - id: 'setup:warning', - type: 'warning', - time: 120, - markerPrice: 10, - }; - - await layer.render([signal], { isCurrent: () => true }); - evictShape('shape-1'); - await layer.render([signal], { isCurrent: () => true }); - - // Then user observes that reconciliation, signal removal, and clear preserve foreign drawings - assert.equal(shapes.has(foreignId), true); - assert.deepEqual(removed, []); - - await layer.render([], { isCurrent: () => true }); - assert.equal(shapes.has(foreignId), true); - assert.deepEqual(removed, ['shape-2']); - - await layer.render([signal], { isCurrent: () => true }); - layer.clear(); - - assert.equal(shapes.size, 1); - assert.equal(shapes.has(foreignId), true); - assert.deepEqual(removed, ['shape-2', 'shape-3']); -}); - -test('user observes that clear forgets an externally evicted marker without removing a missing entity', async () => { - // Given the chart candles, interval and marker ownership - const { - dom, - shapes, - removed, - evictShape, - } = createChartDom(); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createBearishBollingerMarkerLayer(target); - - await layer.render([{ - id: 'setup:warning', - type: 'warning', - time: 120, - markerPrice: 10, - }], { isCurrent: () => true }); - evictShape('shape-1'); - - layer.clear(); - - // Then user observes that clear forgets an externally evicted marker without removing a missing entity - assert.equal(layer.size, 0); - assert.equal(shapes.size, 0); - assert.deepEqual(removed, []); -}); test('user excludes an unclosed reversal breakout bar from detector input', () => { // Given the chart candles, interval and marker ownership @@ -1710,7 +1197,7 @@ test('user excludes an unclosed reversal breakout bar from detector input', () = assert.deepEqual(bars, [{ time: 60, open: 10, high: 12, low: 9, close: 11 }]); }); -test('user rejects an abnormal marker count before mutating the existing layer', async () => { +test('user rejects an abnormal marker count and clears the invalid overlay', async () => { // Given the chart candles, interval and marker ownership const { dom, shapes, removed } = createChartDom(); // When findBearishBollingerChartTarget processes the configured inputs @@ -1730,186 +1217,15 @@ test('user rejects an abnormal marker count before mutating the existing layer', }), ); - // Then user rejects an abnormal marker count before mutating the existing layer + // Then user rejects an abnormal marker count and clears the invalid overlay await assert.rejects( layer.render(excessive, { isCurrent }), /marker limit exceeded/, ); - assert.equal(shapes.size, 1); - assert.deepEqual(removed, []); -}); - -test('user applies the marker limit to the combined bullish and bearish layer', async () => { - // Given the chart candles, interval and marker ownership - const { dom, chart, shapes, removed, createdOptions, propertyUpdates } = createChartDom(); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'); - let yields = 0; - const layer = createBollingerMarkerLayer(target, { yieldToBrowser: async () => { yields += 1; } }); - const signals = Array.from({ length: MAX_BOLLINGER_MARKERS }, (_, index) => ({ - id: `combined-${index}`, - direction: index % 2 === 0 ? 'bearish' : 'bullish', - type: 'warning', - time: 120 + index, - markerPrice: 10, - })); - - const observedResult = await layer.render(signals, { isCurrent: () => true }); - // Then user applies the marker limit to the combined bullish and bearish layer - assert.equal(observedResult, true); - assert.equal(shapes.size, MAX_BOLLINGER_MARKERS); - - const reads = { handles: 0, points: 0, properties: 0 }; - yields = 0; - const getShapeById = chart.getShapeById; - chart.getShapeById = (id) => { reads.handles += 1; return getShapeById(id); }; - for (const shape of shapes.values()) { - const getPoints = shape.getPoints.bind(shape); - const getProperties = shape.getProperties.bind(shape); - shape.getPoints = () => { reads.points += 1; return getPoints(); }; - shape.getProperties = () => { reads.properties += 1; return getProperties(); }; - } - assert.equal(await layer.render(signals, { isCurrent: () => true }), true); - assert.ok(yields >= Math.floor((MAX_BOLLINGER_MARKERS - 1) / 32)); - assert.deepEqual(reads, { - handles: MAX_BOLLINGER_MARKERS, - points: MAX_BOLLINGER_MARKERS, - properties: MAX_BOLLINGER_MARKERS, - }); - assert.equal(createdOptions.length, MAX_BOLLINGER_MARKERS); - assert.equal(propertyUpdates.length, MAX_BOLLINGER_MARKERS); - - await assert.rejects( - layer.render([ - ...signals, - { - id: 'combined-overflow', - direction: 'bullish', - type: 'warning', - time: 120 + MAX_BOLLINGER_MARKERS, - markerPrice: 10, - }, - ], { isCurrent: () => true }), - /marker limit exceeded/, - ); - assert.equal(shapes.size, MAX_BOLLINGER_MARKERS); + assert.equal(layer.size, 0); assert.deepEqual(removed, []); }); -function batchSignals() { - return Array.from({ length: 64 }, (_, index) => ({ - id: `batch-${index}`, direction: 'bearish', type: 'warning', - time: 120 + index * 60, markerPrice: 10, - })); -} - -test('user observes that a real render batch yields a browser task before finishing', async (t) => { - // Given the chart candles, interval and marker ownership - t.mock.timers.enable({ apis: ['setTimeout'] }); - const fixture = createChartDom(); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'); - const layer = createBollingerMarkerLayer(target); - let completed = false; - const task = layer.render(batchSignals(), { isCurrent: () => true }).then(result => { completed = true; return result; }); - await observeStrategyCondition(() => fixture.shapes.size > 0, 'first render batch'); - // Then user observes that a real render batch yields a browser task before finishing - assert.ok(fixture.shapes.size > 0 && fixture.shapes.size <= 32); - assert.equal(completed, false, 'microtasks alone cannot resume the default browser-task yield'); - for (let batch = 0; batch < 64 && !completed; batch += 1) { - t.mock.timers.tick(0); - await new Promise(setImmediate); - } - assert.equal(completed, true, 'each explicit browser task advances the bounded render'); - assert.equal(await task, true); - assert.equal(fixture.shapes.size, 64); -}); - -for (const reason of ['stale', 'busy', 'clear']) { - test(`user observes that a ${reason} transition during a render yield stops the old batch`, async () => { - // Given the chart candles, interval and marker ownership - const fixture = createChartDom(); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'); - let current = true; - let busy = false; - let countAtYield = 0; - const layer = createBollingerMarkerLayer(target, { - canMutate: () => !busy, - yieldToBrowser: async () => { - countAtYield = fixture.createdOptions.length; - if (reason === 'stale') current = false; - if (reason === 'busy') busy = true; - if (reason === 'clear') assert.equal(layer.clear(), true); - }, - }); - const result = await reconcileBearishBollingerAlertWindow({ - bars: [{ time: 120, open: 10, high: 12, low: 9, close: 11 }], - cachedWindowKey: null, cachedSignals: null, - detectSignals: batchSignals, - renderSignals: (signals) => layer.render(signals, { isCurrent: () => current }), - }); - // Then user observes that a the selected case transition during a render yield stops the old batch - assert.ok(countAtYield > 0 && countAtYield <= 32); - assert.equal(fixture.createdOptions.length, countAtYield); - assert.equal(fixture.propertyUpdates.length, countAtYield); - assert.equal(result.rendered, false); - assert.equal(fixture.shapes.size, reason === 'clear' ? 0 : countAtYield); - }); -} - -test('user observes that a marker evicted during a yield is recreated from the refreshed shape list', async () => { - // Given the chart candles, interval and marker ownership - const fixture = createChartDom(); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'); - let evict = false; - let evicted = null; - const layer = createBollingerMarkerLayer(target, { - yieldToBrowser: async () => { - if (!evict) return; - evict = false; - evicted = [...fixture.shapes.keys()].at(-1); - fixture.evictShape(evicted); - }, - }); - const signals = batchSignals(); - // Then user observes that a marker evicted during a yield is recreated from the refreshed shape list - assert.equal(await layer.render(signals, { isCurrent: () => true }), true); - evict = true; - assert.equal(await layer.render(signals, { isCurrent: () => true }), true); - assert.equal(fixture.shapes.has(evicted), false); - assert.equal(fixture.shapes.size, 64); - assert.equal(fixture.createdOptions.length, 65); - assert.equal(fixture.shapes.get('shape-65').point.time, signals.at(-1).time); - assert.deepEqual(fixture.removed, []); -}); - -test('user observes that production monitor does not commit a batch interrupted by an interval switch', async () => { - // Given the chart candles, interval and marker ownership - const fixture = createChartDom(); - const harness = createMonitorHarness(fixture, { - detectBollingerSignals: batchSignals, - createBollingerMarkerLayer: (target, options) => createBollingerMarkerLayer(target, { - ...options, - yieldToBrowser: async () => { fixture.setResolution('5'); }, - }), - }); - fixture.chart.exportData = async () => exportResult([ - { 0: 3600, 1: 10, 2: 12, 3: 9, 4: 11 }, - { 0: 3660, 1: 10, 2: 12, 3: 9, 4: 11 }, - ]); - // When harness.tick processes the configured inputs - await harness.tick(); - // Then user observes that production monitor does not commit a batch interrupted by an interval switch - assert.ok(fixture.shapes.size > 0 && fixture.shapes.size <= 32); - assert.equal(harness.monitor.context.lastProcessedClosedBarsWindowKey, null); - assert.equal(harness.monitor.context.lastProcessedClosedBarsContentSnapshot, null); - assert.equal(harness.monitor.context.lastProcessedSignals, null); - assert.deepEqual(harness.errors, []); - harness.monitor.stop(); - assert.equal(fixture.shapes.size, 0); -}); test('user preserves the full per-direction capacity when both directions are present', async () => { // Given the chart candles, interval and marker ownership @@ -1940,66 +1256,253 @@ test('user preserves the full per-direction capacity when both directions are pr ], { isCurrent: () => true }); // Then user preserves the full per-direction capacity when both directions are present - assert.equal(shapes.size, MAX_BOLLINGER_MARKERS_PER_DIRECTION + 1); + assert.equal(layer.size, MAX_BOLLINGER_MARKERS_PER_DIRECTION + 1); }); -test('user removes a shifted marker and fails the alignment contract', async () => { - // Given the chart candles, interval and marker ownership - const { dom, shapes, removed } = createChartDom({ shiftSeconds: -60 }); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createBearishBollingerMarkerLayer(target); - const observedResult = layer.render( - [{ id: 'setup:warning', type: 'warning', time: 120, markerPrice: 10 }], - { isCurrent: () => true }, - ); - // Then user removes a shifted marker and fails the alignment contract - await assert.rejects( - observedResult, - /time alignment failed: expected 120, received 60/, - ); - assert.equal(shapes.size, 0); - assert.deepEqual(removed, ['shape-1']); +function overlaySignals() { + return ['bearish', 'bullish'].flatMap(direction => ['warning', 'confirmed', 'reversal'].map((type, index) => ({ + id: `${direction}:${type}`, direction, type, time: 60 * (index + 1), markerPrice: 10, + }))); +} + +test('user sees six directional marker styles anchored to the exact candle and price', async () => { + // Given all signal types in both directions beside an untouched user drawing + const fixture = createChartDom(); + fixture.addForeignShape('user-channel'); + const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT')); + // When the shared layer renders both strategies marker styles + await layer.render(overlaySignals(), { isCurrent: () => true }); + // Then warnings are circles and confirmations and reversals have directional arrow tips + assert.deepEqual(fixture.overlay.markers().map(node => [node.dataset.markerId, node.tagName, node.getAttribute('fill'), node.getAttribute('transform')]), [ + ['bearish:warning', 'circle', '#F6465D', 'translate(1 490)'], + ['bearish:confirmed', 'path', '#F6465D', 'translate(2 490)'], + ['bearish:reversal', 'path', '#0ECB81', 'translate(3 490)'], + ['bullish:warning', 'circle', '#0ECB81', 'translate(1 490)'], + ['bullish:confirmed', 'path', '#0ECB81', 'translate(2 490)'], + ['bullish:reversal', 'path', '#F6465D', 'translate(3 490)'], + ]); + const nodes = fixture.overlay.markers(); + assert.equal(nodes[1].getAttribute('d'), nodes[5].getAttribute('d')); + assert.equal(nodes[2].getAttribute('d'), nodes[4].getAttribute('d')); + assert.notEqual(nodes[1].getAttribute('d'), nodes[2].getAttribute('d')); + const svg = fixture.overlay.pane.querySelector('svg'); + assert.equal(svg.style.pointerEvents, 'none'); + assert.equal(svg.style.overflow, 'hidden'); + assert.deepEqual([...fixture.shapes.keys()], ['user-channel']); + layer.clear(); + fixture.dom.window.close(); }); -test('user observes that clear removes a marker whose asynchronous creation finishes late', async () => { - // Given the chart candles, interval and marker ownership - const { dom, shapes, removed, releaseCreate } = createChartDom({ deferredCreate: true }); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createBearishBollingerMarkerLayer(target); - const renderPromise = layer.render([ - { id: 'setup:warning', type: 'warning', time: 120, markerPrice: 10 }, - ], { isCurrent: () => true }); +test('user sees viewport zoom resize logarithmic and inverted prices through one coalesced frame', async () => { + // Given visible markers using the actual native price projection boundary + const fixture = createChartDom(); + const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT')); + await layer.render(overlaySignals(), { isCurrent: () => true }); + // When pan zoom price mode and resize delegates fire in the same frame + fixture.overlay.setProjection({ time: index => index * 2, price: price => Math.log10(price) * 40 }); + fixture.overlay.setViewport(300, 100); + for (const event of Object.values(fixture.overlay.events)) event.emit(); + assert.equal(fixture.overlay.pendingFrames, 1); + fixture.overlay.flushFrames(); + // Then only visible candles appear at native nonlinear price coordinates + assert.deepEqual(fixture.overlay.markers().map(node => node.getAttribute('transform')), ['translate(120 40)', 'translate(240 40)', 'translate(120 40)', 'translate(240 40)']); + assert.equal(layer.overlayStats.renderedFrames, 2); + assert.equal(fixture.overlay.pane.querySelector('svg').getAttribute('viewBox'), '0 0 300 100'); + // When inverse price mode changes and the viewport becomes empty + fixture.overlay.setProjection({ price: price => 100 - Math.log10(price) * 40 }); + fixture.overlay.events.modeChanged.emit(); + fixture.overlay.flushFrames(); + // Then the inverse axis moves the markers without touching drawings + assert.equal(fixture.overlay.markers()[0].getAttribute('transform'), 'translate(120 60)'); + fixture.overlay.setViewport(0, 0); + fixture.overlay.flushFrames(); + assert.equal(fixture.overlay.markers().length, 0); + assert.equal(layer.size, 6); + layer.clear(); + fixture.dom.window.close(); +}); - await Promise.resolve(); +test('user never sees a marker snapped to a missing neighboring candle', async () => { + // Given the time scale resolves a gap to a neighboring index and another time is missing + const fixture = createChartDom(); + fixture.overlay.setTimeLookup({ index: time => time === 180 ? null : time, time: index => index === 120 ? 119 : index }); + const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT')); + // When markers are projected with exact native time readback + await layer.render(overlaySignals(), { isCurrent: () => true }); + // Then only the exact existing candle is displayed while the signal history is retained + assert.deepEqual(fixture.overlay.markers().map(node => node.dataset.markerId), ['bearish:warning', 'bullish:warning']); + assert.equal(layer.size, 6); layer.clear(); - releaseCreate(); + fixture.dom.window.close(); +}); - // Then user observes that clear removes a marker whose asynchronous creation finishes late - assert.equal(await renderPromise, false); - assert.equal(shapes.size, 0); - assert.deepEqual(removed, ['shape-1']); +for (const reason of ['hidden', 'interval', 'busy', 'stale']) { + test(`user hides the overlay during ${reason} transitions and clears it while drawings are busy`, async () => { + // Given an active overlay with a pending coordinate frame + const fixture = createChartDom(); + let busy = false; + let current = true; + const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'), { canMutate: () => !busy }); + await layer.render(overlaySignals(), { isCurrent: () => current }); + const svg = fixture.overlay.pane.querySelector('svg'); + fixture.overlay.events.logicalRangeChanged.emit(); + // When the target can no longer publish visible markers + if (reason === 'hidden') fixture.overlay.setHidden(true); + if (reason === 'interval') fixture.setResolution('5'); + if (reason === 'busy') busy = true; + if (reason === 'stale') current = false; + if (reason === 'busy' || reason === 'stale') fixture.overlay.flushFrames(); + // Then the old overlay is hidden and clear removes every owned resource even while busy + assert.equal(svg.style.visibility, 'hidden'); + busy = true; + assert.equal(layer.clear(), true); + assert.equal(fixture.overlay.pane.querySelector('svg'), null); + assert.equal(fixture.overlay.pendingFrames, 0); + assert.equal(fixture.overlay.subscriptions, 0); + assert.equal(fixture.intervalChanged.size, 0); + assert.equal(fixture.dataLoaded.size, 0); + assert.equal(layer.size, 0); + fixture.dom.window.close(); + }); +} + +test('user reuses a cleared layer and keeps a second strategies overlay and foreign drawings intact', async () => { + // Given independent strategy layers attached to one chart + const fixture = createChartDom(); + fixture.addForeignShape('user-channel'); + const target = findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'); + const first = createBollingerMarkerLayer(target); + const second = createBollingerMarkerLayer(target); + await first.render(overlaySignals(), { isCurrent: () => true }); + await second.render([{ ...overlaySignals()[0], id: 'strategy31' }], { isCurrent: () => true }); + // When one layer clears and is used again + first.clear(); + assert.deepEqual(fixture.overlay.markers().map(node => node.dataset.markerId), ['strategy31']); + await first.render([{ ...overlaySignals()[0], id: 'strategy29' }], { isCurrent: () => true }); + // Then both layers remain independent and no native entity was changed + assert.deepEqual(fixture.overlay.markers().map(node => node.dataset.markerId), ['strategy31', 'strategy29']); + assert.equal(fixture.overlay.pane.querySelectorAll('svg').length, 2); + assert.equal(first.size, 1); + assert.equal(second.size, 1); + assert.deepEqual([...fixture.shapes.keys()], ['user-channel']); + assert.equal(fixture.createdOptions.length, 0); + first.clear(); + second.clear(); + assert.equal(fixture.overlay.subscriptions, 0); + fixture.dom.window.close(); }); -test('user removes a marker whose target changes while asynchronous creation is pending', async () => { - // Given the chart candles, interval and marker ownership - const { dom, shapes, removed, releaseCreate } = createChartDom({ deferredCreate: true }); - // When findBearishBollingerChartTarget processes the configured inputs - const target = findBearishBollingerChartTarget(dom.window.document, 'BTRUSDT'); - const layer = createBearishBollingerMarkerLayer(target); - let current = true; - const renderPromise = layer.render([ - { id: 'setup:warning', type: 'warning', time: 120, markerPrice: 10 }, - ], { isCurrent: () => current }); +test('user sees an asynchronous projection failure after complete overlay resource cleanup', async () => { + // Given a rendered overlay with a consumer failure boundary + const fixture = createChartDom(); + const failures = []; + const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT'), { + onRenderError(error) { failures.push({ message: error.message, attached: layer.overlayStats.attached, subscriptions: fixture.overlay.subscriptions }); }, + }); + await layer.render(overlaySignals(), { isCurrent: () => true }); + // When the native coordinate projection fails in a queued frame + fixture.overlay.setProjection({ price: () => { throw new Error('price projection failed'); } }); + fixture.overlay.events.priceRangeChanged.emit(); + fixture.overlay.flushFrames(); + // Then the consumer receives the real failure only after all resources are retired + assert.deepEqual(failures, [{ message: 'price projection failed', attached: false, subscriptions: 0 }]); + assert.equal(layer.size, 0); + assert.equal(fixture.overlay.pendingFrames, 0); + assert.equal(fixture.overlay.pane.querySelector('svg'), null); + assert.equal(fixture.intervalChanged.size, 0); + fixture.dom.window.close(); +}); - await Promise.resolve(); - current = false; - releaseCreate(); +test('user gets a visible thrown frame failure when no consumer error callback is installed', async () => { + // Given an overlay without a consumer recovery boundary + const fixture = createChartDom(); + const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT')); + await layer.render(overlaySignals(), { isCurrent: () => true }); + // When a native scale returns an invalid coordinate asynchronously + fixture.overlay.setProjection({ price: () => NaN }); + fixture.overlay.events.priceRangeChanged.emit(); + // Then the exception propagates and no stale partial overlay survives + assert.throws(() => fixture.overlay.flushFrames(), /coordinates are invalid/); + assert.equal(fixture.overlay.subscriptions, 0); + assert.equal(fixture.overlay.markers().length, 0); + fixture.dom.window.close(); +}); - // Then user removes a marker whose target changes while asynchronous creation is pending - assert.equal(await renderPromise, false); - assert.equal(shapes.size, 0); - assert.deepEqual(removed, ['shape-1']); +test('user keeps cached detector results and unchanged overlay nodes without DOM writes', async () => { + // Given a reconciled candle window and observable overlay subtree + const fixture = createChartDom(); + const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT')); + const bars = [{ time: 60, open: 10, high: 12, low: 9, close: 11 }]; + let detections = 0; + const detectSignals = () => { detections += 1; return overlaySignals(); }; + const first = await reconcileBearishBollingerAlertWindow({ bars, cachedWindowKey: null, cachedSignals: null, detectSignals, renderSignals: signals => layer.render(signals, { isCurrent: () => true }) }); + const initialNodes = fixture.overlay.markers(); + const observer = new fixture.overlay.pane.ownerDocument.defaultView.MutationObserver(() => {}); + observer.observe(fixture.overlay.pane, { attributes: true, childList: true, subtree: true }); + // When the unchanged closed-bar window is reconciled again + const next = await reconcileBearishBollingerAlertWindow({ bars, cachedWindowKey: first.closedBarsWindowKey, cachedContentSnapshot: first.closedBarsContentSnapshot, cachedSignals: first.signals, detectSignals, renderSignals: signals => layer.render(signals, { isCurrent: () => true }) }); + // Then detection is reused and no redraw mutates unchanged DOM + assert.equal(next.rendered, true); + assert.equal(detections, 1); + assert.deepEqual(fixture.overlay.markers(), initialNodes); + assert.equal(observer.takeRecords().length, 0); + observer.disconnect(); + layer.clear(); + fixture.dom.window.close(); }); + +test('user receives independent host index price delegate and animation cancellation contracts', () => { + // Given a fixture with explicit native candle rows and a private subscription owner + const fixture = createChartDom(); + const owner = {}; + let notifications = 0; + const callback = () => { notifications += 1; }; + fixture.overlay.setTimeLookup({ index: time => time === 60 ? 3 : null, time: index => index === 3 ? 60 : undefined }); + fixture.overlay.setProjection({ time: index => index * 12, price: price => Math.log10(price) * 20 }); + const event = fixture.overlay.events.modeChanged; + // When the chart host maps prices and delivers owner-bound notifications + event.subscribe(owner, callback); + event.emit(); + event.unsubscribe(owner, callback); + event.emit(); + const view = fixture.overlay.pane.ownerDocument.defaultView; + const cancelled = view.requestAnimationFrame(callback); + view.cancelAnimationFrame(cancelled); + fixture.overlay.flushFrames(); + // Then exact time readback nonlinear coordinates and cancellation follow the host contract + assert.equal(fixture.overlay.timeScale.timePointToIndex(60, 0), 3); + assert.equal(fixture.overlay.timeScale.timePointToIndex(120, 0), null); + assert.deepEqual(fixture.chart.getSeries().data().valueAt(3), [60, 1, 2, 0, 1]); + assert.equal(fixture.chart.getSeries().data().valueAt(4), null); + assert.equal(fixture.overlay.timeScale.indexToCoordinate(3), 36); + assert.equal(fixture.overlay.priceScale.priceToCoordinate(100, fixture.overlay.series.firstValue()), 40); + assert.equal(notifications, 1); + assert.equal(event.size, 0); + fixture.dom.window.close(); +}); + +for (const invalid of ['combined-limit', 'duplicate-id', 'invalid-time', 'invalid-price', 'invalid-type']) { + test(`user rejects ${invalid} signals and removes a previously valid overlay`, async () => { + // Given valid existing markers followed by a malformed marker request + const fixture = createChartDom(); + const layer = createBollingerMarkerLayer(findBearishBollingerChartTarget(fixture.dom.window.document, 'BTRUSDT')); + await layer.render(overlaySignals(), { isCurrent: () => true }); + const invalidRequests = { + 'combined-limit': Array.from({ length: MAX_BOLLINGER_MARKERS + 1 }, (_, index) => ({ ...overlaySignals()[0], id: String(index) })), + 'duplicate-id': [overlaySignals()[0], overlaySignals()[0]], + 'invalid-time': [{ ...overlaySignals()[0], time: 1.5 }], + 'invalid-price': [{ ...overlaySignals()[0], markerPrice: NaN }], + 'invalid-type': [{ ...overlaySignals()[0], type: 'unknown' }], + }; + // When the invalid request reaches the public rendering boundary + const rendering = layer.render(invalidRequests[invalid], { isCurrent: () => true }); + // Then a concrete validation failure propagates without stale overlay state + await assert.rejects(rendering, /limit exceeded|duplicate signal id|signal point is invalid|signal type is invalid/); + assert.equal(layer.size, 0); + assert.equal(fixture.overlay.markers().length, 0); + assert.equal(fixture.overlay.subscriptions, 0); + assert.equal(fixture.createdOptions.length, 0); + fixture.dom.window.close(); + }); +} diff --git a/test/dom/strategy31-runtime.test.js b/test/dom/strategy31-runtime.test.js index 25f62d5..ed1c78c 100644 --- a/test/dom/strategy31-runtime.test.js +++ b/test/dom/strategy31-runtime.test.js @@ -38,7 +38,7 @@ test('user sees no Strategy31 status or signal requests on a non-trading page', // Then it stays absent without acquiring the native chart or requesting signals assert.equal(f.document.getElementById('jh-strategy31-status'), null); assert.equal(f.requests.length, 0); - assert.equal(f.shapes.size, 0); + assert.equal(f.overlay.markers().length, 0); assert.equal(f.intervalChanged.size, 0); assert.equal(f.dataLoaded.size, 0); }); @@ -55,7 +55,7 @@ test('user clears a stopped Strategy31 notice after SPA navigation without resta f.view.history.pushState({}, '', '/en/futures/'); // Then route observation removes the failure status without another sample or request assert.equal(f.document.getElementById('jh-strategy31-status'), null); - assert.equal(f.shapes.size, 0); + assert.equal(f.overlay.markers().length, 0); assert.equal(f.requests.length, 1); // When valid data becomes available and navigation returns to a supported chart f.payload.schema_version = 1; @@ -63,7 +63,7 @@ test('user clears a stopped Strategy31 notice after SPA navigation without resta await f.runtime.sample(); // Then the failed observer remains stopped instead of implicitly retrying assert.equal(f.requests.length, 1); - assert.equal(f.shapes.size, 0); + assert.equal(f.overlay.markers().length, 0); }); test('user sees no late failure notice when native chart export fails after leaving the trading page', async (t) => { @@ -80,14 +80,14 @@ test('user sees no late failure notice when native chart export fails after leav await sample; // Then the late failure keeps all Strategy31 UI absent on the landing page assert.equal(f.document.getElementById('jh-strategy31-status'), null); - assert.equal(f.shapes.size, 0); + assert.equal(f.overlay.markers().length, 0); assert.equal(f.requests.length, 2); // When navigation returns to the supported trading page f.view.history.pushState({}, '', '/en/futures/BTRUSDT'); await f.runtime.sample(); // Then the invalid native contract has stopped further signal requests assert.equal(f.requests.length, 2); - assert.equal(f.shapes.size, 0); + assert.equal(f.overlay.markers().length, 0); }); test('user clears Strategy31 from the futures landing page and resumes on a trading page', async (t) => { @@ -95,23 +95,23 @@ test('user clears Strategy31 from the futures landing page and resumes on a trad const f = fixture(); t.after(() => { f.runtime.dispose(); f.close(); }); await f.runtime.sample(); - assert.equal(f.shapes.size, 1); + assert.equal(f.overlay.markers().length, 1); assert.equal(f.document.getElementById('jh-strategy31-status').textContent, 'Strategy31: 1 chart signals · 5m'); // When navigation leaves the trading route while its native chart remains mounted f.dom.reconfigure({ url: 'https://www.binance.com/en/futures/' }); await f.runtime.sample(); // Then the observer clears its UI and subscriptions without another gateway request assert.equal(f.document.getElementById('jh-strategy31-status'), null); - assert.equal(f.shapes.size, 0); + assert.equal(f.overlay.markers().length, 0); assert.equal(f.requests.length, 1); assert.equal(f.intervalChanged.size, 0); assert.equal(f.dataLoaded.size, 0); // When navigation returns to the supported trading route f.dom.reconfigure({ url: 'https://www.binance.com/en/futures/BTRUSDT' }); await f.runtime.sample(); - // Then the observer acquires fresh signals and restores its native arrow + // Then the observer acquires fresh signals and restores its overlay arrow assert.equal(f.requests.length, 2); - assert.equal(f.shapes.size, 1); + assert.equal(f.overlay.markers().length, 1); assert.equal(f.document.getElementById('jh-strategy31-status').textContent, 'Strategy31: 1 chart signals · 5m'); }); @@ -131,14 +131,14 @@ test('user aborts pending signals on leaving a trading page without late UI resu // Then the pending transport is cancelled and previous UI is removed immediately assert.equal(f.requests[1].signal.aborted, true); assert.equal(f.requests.length, 2); - assert.equal(f.shapes.size, 0); + assert.equal(f.overlay.markers().length, 0); assert.equal(f.document.getElementById('jh-strategy31-status'), null); // When the cancelled request still delivers its old response response.resolve({ kind: 'response', status: 200, responseText: JSON.stringify(f.payload) }); await sample; // Then neither drawings nor status return on the non-trading route - assert.equal(f.shapes.size, 0); - assert.equal(f.created.length, 1); + assert.equal(f.overlay.markers().length, 0); + assert.equal(f.created.length, 0); assert.equal(f.document.getElementById('jh-strategy31-status'), null); // When the user returns to a supported route after the old request finishes f.hold(null); @@ -147,11 +147,11 @@ test('user aborts pending signals on leaving a trading page without late UI resu // Then a new request restores observation without restarting the script assert.equal(f.requests.length, 3); assert.equal(f.requests[2].signal.aborted, false); - assert.equal(f.shapes.size, 1); + assert.equal(f.overlay.markers().length, 1); assert.equal(f.document.getElementById('jh-strategy31-status').textContent, 'Strategy31: 1 chart signals · 5m'); }); -test('user clears the landing-page status while native arrow cleanup waits for the chart owner', async (t) => { +test('user clears the landing-page overlay immediately while a chart owner is busy', async (t) => { // Given an existing signal and another script holding chart mutation ownership const f = fixture(); t.after(() => { f.runtime.dispose(); f.close(); }); @@ -161,36 +161,39 @@ test('user clears the landing-page status while native arrow cleanup waits for t // When navigation leaves the trading route before ownership is released f.dom.reconfigure({ url: 'https://www.binance.com/en/futures/' }); await f.runtime.sample(); - // Then status disappears while the observer waits to mutate the native chart + // Then owned overlay and status disappear without waiting for native chart ownership. assert.equal(f.document.getElementById('jh-strategy31-status'), null); - assert.equal(f.shapes.size, 1); + assert.equal(f.overlay.markers().length, 0); assert.equal(f.requests.length, 1); // When the chart owner releases its mutation reservation release(); await f.runtime.sample(); - // Then deferred cleanup removes the observer arrow without a new request - assert.equal(f.shapes.size, 0); + // Then no observer arrow or new request appears after ownership is released + assert.equal(f.overlay.markers().length, 0); assert.equal(f.requests.length, 1); }); -test('user sees one native green arrow across repeated server snapshots', async () => { +test('user sees one green overlay arrow across repeated server snapshots', async (t) => { // Given the existing native-chart fixture and one confirmed server event const f = fixture(); + t.after(() => { f.runtime.dispose(); f.close(); }); // When the observer reconciles the same snapshot twice await f.runtime.sample(); await f.runtime.sample(); // Then it owns one upward arrow at the server coordinate - assert.equal(f.shapes.size, 1); - assert.equal(f.created[0].options.shape, 'arrow_up'); - assert.deepEqual(f.created[0].point, { time: 300, price: 9 }); + assert.equal(f.overlay.markers().length, 1); + assert.equal(f.overlay.markers()[0].getAttribute('fill'), '#0ECB81'); + assert.equal(f.overlay.markers()[0].getAttribute('d'), 'M 0 0 L -6 8 L -2 8 L -2 18 L 2 18 L 2 8 L 6 8 Z'); + assert.equal(f.overlay.markers()[0].getAttribute('transform'), 'translate(0 491)'); f.runtime.dispose(); - assert.equal(f.shapes.size, 0); + assert.equal(f.overlay.markers().length, 0); f.close(); }); -test('user resumes signals after returning from an unsupported monthly interval', async () => { +test('user resumes signals after returning from an unsupported monthly interval', async (t) => { // Given a supported chart and its installed observer const f = fixture(); + t.after(() => { f.runtime.dispose(); f.close(); }); // When the chart visits a monthly interval and returns to five minutes f.changeInterval('1M'); f.finishData(); @@ -200,15 +203,16 @@ test('user resumes signals after returning from an unsupported monthly interval' f.finishData(); await f.runtime.sample(); // Then the observer resumes one correctly anchored arrow - assert.equal(f.shapes.size, 1); - assert.deepEqual(f.created[0].point, { time: 300, price: 9 }); + assert.equal(f.overlay.markers().length, 1); + assert.equal(f.overlay.markers()[0].getAttribute('transform'), 'translate(0 491)'); f.runtime.dispose(); f.close(); }); -test('user waits for native data completion even when old chart data remains ready', async () => { +test('user waits for native data completion even when old chart data remains ready', async (t) => { // Given an observer that already owns the chart interval session const f = fixture(); + t.after(() => { f.runtime.dispose(); f.close(); }); await f.runtime.sample(); const response = Promise.withResolvers(); f.hold(response.promise); @@ -220,31 +224,32 @@ test('user waits for native data completion even when old chart data remains rea response.resolve({ kind: 'response', status: 200, responseText: JSON.stringify(f.payload) }); await sample; // Then old readiness cannot recreate drawings before completion - assert.equal(f.shapes.size, 0); - assert.equal(f.created.length, 1); + assert.equal(f.overlay.markers().length, 0); + assert.equal(f.created.length, 0); f.finishData(); await f.runtime.sample(); - assert.equal(f.shapes.size, 1); + assert.equal(f.overlay.markers().length, 1); f.runtime.dispose(); f.close(); }); -test('user sees retained signals only after their exact chart candles load', async () => { +test('user sees retained signals only after their exact chart candles load', async (t) => { // Given a retained server signal older than the currently loaded chart const f = fixture(); + t.after(() => { f.runtime.dispose(); f.close(); }); f.setBars([{ time: 900, open: 10, high: 13, low: 9, close: 12 }]); // When the observer receives history outside the loaded candle times await f.runtime.sample(); // Then no native drawing is created at an unavailable coordinate assert.equal(f.created.length, 0); - assert.equal(f.shapes.size, 0); + assert.equal(f.overlay.markers().length, 0); // When loading earlier chart history exposes the exact green candle f.setBars([{ time: 300, open: 10, high: 13, low: 9, close: 12 }]); f.finishData(); await f.runtime.sample(); // Then the same retained signal is displayed without restarting - assert.deepEqual(f.created[0].point, { time: 300, price: 9 }); - assert.equal(f.shapes.size, 1); + assert.equal(f.overlay.markers()[0].getAttribute('transform'), 'translate(0 491)'); + assert.equal(f.overlay.markers().length, 1); f.runtime.dispose(); f.close(); }); @@ -265,7 +270,7 @@ test('user sees no USDT signals on a USDC chart and can resume a supported marke f.dom.reconfigure({ url: 'https://www.binance.com/en/futures/BTRUSDT' }); f.setSymbol('BTRUSDT@PRICETYPE=LAST'); await f.runtime.sample(); - assert.equal(f.shapes.size, 1); + assert.equal(f.overlay.markers().length, 1); }); test('user keeps arrows through one transient native candle update', async (t) => { @@ -278,15 +283,16 @@ test('user keeps arrows through one transient native candle update', async (t) = // When one sample races the native feed update await f.runtime.sample(); // Then the existing arrow remains and valid subsequent data resumes sampling - assert.equal(f.shapes.size, 1); + assert.equal(f.overlay.markers().length, 1); await f.runtime.sample(); assert.equal(f.exports.length, 3); - assert.equal(f.shapes.size, 1); + assert.equal(f.overlay.markers().length, 1); }); -test('user never receives a late arrow from the previous chart interval', async () => { +test('user never receives a late arrow from the previous chart interval', async (t) => { // Given a request held at the gateway boundary const f = fixture(); + t.after(() => { f.runtime.dispose(); f.close(); }); const response = Promise.withResolvers(); f.hold(response.promise); // When the interval changes before the request completes @@ -295,55 +301,58 @@ test('user never receives a late arrow from the previous chart interval', async response.resolve({ kind: 'response', status: 200, responseText: JSON.stringify(f.payload) }); await sample; // Then the stale event does not create any native drawing - assert.equal(f.shapes.size, 0); + assert.equal(f.overlay.markers().length, 0); f.runtime.dispose(); f.close(); }); -test('user never sees an old arrow finish after gateway settings change', async () => { - // Given native shape creation is still pending +test('user never sees an old arrow finish after gateway settings change', async (t) => { + // Given native candle export is still pending const f = fixture(); - const gate = f.holdNextCreation(); + t.after(() => { f.runtime.dispose(); f.close(); }); + const gate = f.holdNextExport(); const sample = f.runtime.sample(); await gate.entered; // When gateway settings change before the native operation completes f.changeSettings(); - gate.release(); + gate.resolve(exportStrategyBars([{ time: 300, open: 10, high: 13, low: 9, close: 12 }])); await sample; // Then no old-settings arrow remains visible - assert.equal(f.shapes.size, 0); + assert.equal(f.overlay.markers().length, 0); f.runtime.dispose(); f.close(); }); -test('user gets safe deferred arrow cleanup after stopping during trading drawing work', async () => { +test('user removes its overlay immediately when stopped during trading drawing work', async (t) => { // Given an arrow and a busy shared chart mutation owner const f = fixture(); + t.after(() => { f.runtime.dispose(); f.close(); }); await f.runtime.sample(); const release = registerChartMutationOwner(f.view, 'fixture-trading-owner', () => true); - // When stopping must wait for the owner to finish + // When stopping removes only private overlay resources f.runtime.dispose(); - assert.equal(f.shapes.size, 1); + assert.equal(f.overlay.markers().length, 0); release(); await f.runtime.sample(); - // Then deferred cleanup removes only the observer's arrow - assert.equal(f.shapes.size, 0); + // Then the observer stays empty without native chart mutations + assert.equal(f.overlay.markers().length, 0); f.close(); }); -test('user sees no resurrected status after stopping during native shape creation', async () => { - // Given native shape creation is pending +test('user sees no resurrected status after stopping during native candle export', async (t) => { + // Given native candle export is pending const f = fixture(); - const gate = f.holdNextCreation(); + t.after(() => { f.runtime.dispose(); f.close(); }); + const gate = f.holdNextExport(); const sample = f.runtime.sample(); await gate.entered; - // When the observer stops before creation completes + // When the observer stops before export completes f.runtime.dispose(); - gate.release(); + gate.resolve(exportStrategyBars([{ time: 300, open: 10, high: 13, low: 9, close: 12 }])); await sample; await f.runtime.sample(); // Then neither a drawing nor a success message reappears - assert.equal(f.shapes.size, 0); + assert.equal(f.overlay.markers().length, 0); assert.equal(f.document.getElementById('jh-strategy31-status'), null); f.close(); }); diff --git a/test/helpers/chart-marker-overlay-host.js b/test/helpers/chart-marker-overlay-host.js new file mode 100644 index 0000000..0350ca9 --- /dev/null +++ b/test/helpers/chart-marker-overlay-host.js @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; + +/** Host API mirrors the inspected Binance TradingView main-pane coordinate and delegate contract. */ +export function attachChartMarkerOverlayHost({ chart, document, bars = null }) { + const paneDocument = document.querySelector('iframe')?.contentDocument || document; + const view = paneDocument.defaultView; + const pane = paneDocument.createElement('div'); + pane.style.position = 'relative'; + pane.className = 'chart-gui-wrapper'; + const canvas = paneDocument.createElement('canvas'); + pane.append(canvas); + paneDocument.body.append(pane); + let width = 1000; + let height = 500; + let projectTime = index => index / 60; + let projectPrice = price => height - price; + let readTime = index => bars ? bars[index]?.time : index; + let resolveIndex = time => bars ? bars.findIndex(bar => bar.time === time) : time; + Object.defineProperties(pane, { clientWidth: { get: () => width }, clientHeight: { get: () => height } }); + pane.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: width, bottom: height, width, height }); + canvas.getBoundingClientRect = pane.getBoundingClientRect; + const events = {}; + for (const name of ['logicalRangeChanged', 'barSpacingChanged', 'rightOffsetChanged', 'priceRangeChanged', 'modeChanged', 'internalHeightChanged', 'dataUpdated']) { + const listeners = new Map(); + events[name] = { + subscribe(owner, fn) { listeners.set(fn, owner); }, + unsubscribe(owner, fn) { assert.equal(listeners.get(fn), owner); listeners.delete(fn); }, + emit() { for (const fn of [...listeners.keys()]) fn(); }, + get size() { return listeners.size; }, + }; + } + const timeScale = { + timePointToIndex: (time, mode) => { assert.equal(mode, 0); return resolveIndex(time); }, + indexToCoordinate: index => projectTime(index), + logicalRangeChanged: () => events.logicalRangeChanged, + barSpacingChanged: () => events.barSpacingChanged, + rightOffsetChanged: () => events.rightOffsetChanged, + }; + const priceScale = { + priceToCoordinate(price, first) { assert.equal(first, 100); return projectPrice(price); }, + priceRangeChanged: () => events.priceRangeChanged, + modeChanged: () => events.modeChanged, + internalHeightChanged: () => events.internalHeightChanged, + }; + const series = { priceScale: () => priceScale, firstValue: () => 100, dataUpdated: () => events.dataUpdated }; + const paneState = {}; + const model = { mainSeries: () => series, timeScale: () => timeScale, paneForSource(source) { assert.equal(source, series); return paneState; } }; + const paneWidget = { canvasElement: () => canvas }; + chart._chartWidget = { model: () => ({ model: () => model }), paneByState(state) { assert.equal(state, paneState); return paneWidget; } }; + chart.getSeries = () => ({ data: () => ({ valueAt: index => { const time = readTime(index); return time === undefined ? null : [time, 1, 2, 0, 1]; } }) }); + let frameId = 0; + const frames = new Map(); + view.requestAnimationFrame = fn => { frames.set(++frameId, fn); return frameId; }; + view.cancelAnimationFrame = id => { frames.delete(id); }; + const observers = new Set(); + view.ResizeObserver = class { + constructor(callback) { this.callback = callback; } + observe(element) { assert.equal(element, pane); observers.add(this); } + disconnect() { observers.delete(this); } + }; + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + return { + pane, canvas, events, timeScale, priceScale, series, model, + markers: () => [...pane.querySelectorAll('[data-marker-id]')], + flushFrames() { const callbacks = [...frames.values()]; frames.clear(); for (const callback of callbacks) callback(0); }, + get pendingFrames() { return frames.size; }, + get subscriptions() { return Object.values(events).reduce((sum, event) => sum + event.size, 0) + observers.size; }, + setViewport(w, h) { width = w; height = h; for (const observer of observers) observer.callback([]); }, + setProjection({ time, price }) { if (time) projectTime = time; if (price) projectPrice = price; }, + setTimeLookup({ index, time }) { if (index) resolveIndex = index; if (time) readTime = time; }, + setHidden(hidden) { Object.defineProperty(document, 'hidden', { configurable: true, value: hidden }); document.dispatchEvent(new document.defaultView.Event('visibilitychange')); }, + }; +} diff --git a/test/helpers/strategy-migration-boundaries.js b/test/helpers/strategy-migration-boundaries.js index 72893cf..a0949a1 100644 --- a/test/helpers/strategy-migration-boundaries.js +++ b/test/helpers/strategy-migration-boundaries.js @@ -118,30 +118,3 @@ export function createStrategyGmBoundary() { }, }; } - -/** Expose raw native shape outputs, including malformed values, without repairing them. */ -export function createStrategyShapeBoundary({ - shapeId = 'native-shape', - points = [{ time: 10, price: 1.25 }], - listedShapes = [{ id: shapeId }], -} = {}) { - const created = []; - const removed = []; - const shape = { getPoints: () => points }; - return { - created, - removed, - chart: { - async createShape(point, options) { - created.push({ point, options }); - return shapeId; - }, - getShapeById(id) { - assert.equal(id, shapeId); - return shape; - }, - getAllShapes: () => listedShapes, - removeEntity: id => removed.push(id), - }, - }; -} diff --git a/test/helpers/strategy27-overlay-host.js b/test/helpers/strategy27-overlay-host.js new file mode 100644 index 0000000..4ce3322 --- /dev/null +++ b/test/helpers/strategy27-overlay-host.js @@ -0,0 +1,42 @@ +import { loadFixtureDom } from './dom.js'; +import { attachChartMarkerOverlayHost } from './chart-marker-overlay-host.js'; + +/** The native boundary exposes exact candle lookup and real SVG DOM without native drawing side effects. */ +export function createStrategy27OverlayHost({ bars = [[10, 1.25, 1.3, 1.2, 1.25]], resolution = '1S', symbol = 'BTRUSDT@PRICETYPE=LAST', priceToCoordinate = price => 2000 - price * 1000, coordinateToPrice = y => (2000 - y) / 1000 } = {}) { + const dom = loadFixtureDom(''); + const document = dom.window.document; + let currentSymbol = symbol; + let currentResolution = resolution; + const candles = new Map(bars.map(bar => [bar[0], bar])); + const created = [], removed = [], saves = []; + const shapes = new Map([['user-owned', { untouched: true }]]); + const delegate = () => { + const listeners = new Map(); + return { subscribe(owner, callback) { listeners.set(callback, owner); }, unsubscribe(owner, callback) { if (listeners.get(callback) !== owner) throw new Error('Invalid subscription owner'); listeners.delete(callback); }, emit() { for (const callback of [...listeners.keys()]) callback(); }, get size() { return listeners.size; } }; + }; + const interval = delegate(), loaded = delegate(); + const chart = { + symbol: () => currentSymbol, resolution: () => currentResolution, + hasModel: () => true, dataReady: () => true, + onIntervalChanged: () => interval, onDataLoaded: () => loaded, + createShape(...args) { created.push(args); throw new Error('Native marker creation is forbidden'); }, + removeEntity(id) { removed.push(id); throw new Error('Native marker removal is forbidden'); }, + getAllShapes: () => [...shapes.keys()].map(id => ({ id })), getShapeById: id => shapes.get(id), + }; + const overlay = attachChartMarkerOverlayHost({ chart, document }); + overlay.setViewport(1000, 2000); + overlay.setProjection({ time: index => index * 10, price: priceToCoordinate }); + overlay.priceScale.coordinateToPrice = coordinateToPrice; + overlay.timeScale.timePointToIndex = (time, mode) => mode === 0 ? (candles.has(time) ? time : null) : ([...candles.keys()].filter(value => value <= time).sort((a, b) => b - a)[0] ?? null); + chart.getSeries = () => ({ data: () => ({ valueAt: index => candles.get(index) ?? null }) }); + const tradingViewApi = { activeChart: () => chart, saveChart(...args) { saves.push(args); throw new Error('Marker saves are forbidden'); } }; + document.querySelector('iframe').contentWindow.tradingViewApi = tradingViewApi; + const target = { chart, chartRoot: document.querySelector('.chart-widget-root'), tradingViewApi, routeSymbol: String(symbol || '').split('@')[0], resolution }; + return { dom, document, chart, target, overlay, created, removed, saves, shapes, candles, interval, loaded, + setSymbol(value) { currentSymbol = value; }, + setResolution(value) { currentResolution = value; interval.emit(); }, + fireDataUpdated() { overlay.events.dataUpdated.emit(); }, + markers: () => overlay.markers(), + close() { dom.window.close(); }, + }; +} diff --git a/test/helpers/strategy29-runtime-boundary-host.js b/test/helpers/strategy29-runtime-boundary-host.js index 3480f2e..e05d67a 100644 --- a/test/helpers/strategy29-runtime-boundary-host.js +++ b/test/helpers/strategy29-runtime-boundary-host.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { loadFixtureDom } from './dom.js'; +import { attachChartMarkerOverlayHost } from './chart-marker-overlay-host.js'; /** Positive OHLC candles provide both trend directions without supplying detector decisions. */ export function createOscillatingStrategyBars(count = 165) { @@ -107,6 +108,12 @@ export function createStrategy29ChartHost({ bars = createOscillatingStrategyBars shapes.delete(id); }, }; + const overlay = attachChartMarkerOverlayHost({ chart, document }); + overlay.setProjection({ time: index => index }); + overlay.setTimeLookup({ + index: time => { const index = currentBars.findIndex(bar => bar.time === time); return index < 0 ? null : index; }, + time: index => currentBars[index]?.time, + }); for (const method of omittedMethods) { assert.equal(typeof chart[method], 'function', 'Only a declared native chart capability can be omitted'); delete chart[method]; @@ -120,7 +127,7 @@ export function createStrategy29ChartHost({ bars = createOscillatingStrategyBars }; document.querySelector('iframe').contentWindow.tradingViewApi = tradingViewApi; return { - dom, view, document, root, chart, tradingViewApi, + dom, view, document, root, chart, tradingViewApi, overlay, created, removed, propertyWrites, exports, shapes, intervalChanged, dataLoaded, setBars(value) { currentBars = value; }, setSymbol(value) { symbol = value; }, diff --git a/test/unit/binance-fixture-contract.test.js b/test/unit/binance-fixture-contract.test.js index c64df90..6e2e3f4 100644 --- a/test/unit/binance-fixture-contract.test.js +++ b/test/unit/binance-fixture-contract.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { setImmediate } from 'node:timers/promises'; import { JSDOM } from 'jsdom'; +import { createTradingViewRemovalSaveController } from '../../src/binance-orderbook-trade/core/chart-save-coalescer.js'; import { renderBinanceFuturesFixture } from '../../e2e/binance-orderbook/fixtures/binance-futures.js'; import { @@ -520,7 +521,7 @@ for (const changed of ['symbol', 'visibility']) { // Then the API success stays visible without drawing an order into the wrong chart. assert.equal(host.fixture.snapshot().events.filter(({ type }) => type === 'order-submit-api-success').length, 1); assert.deepEqual(host.fixture.snapshot().events.filter(({ type }) => [ - 'chart-drawing-event', 'chart-save-requested', 'chart-saved', + 'chart-drawing-event', 'chart-save-requested', 'chart-serialized', 'chart-saved', ].includes(type)), []); }); } @@ -578,3 +579,56 @@ for (const enabled of [null, 'true', 1]) { assert.throws(construct, /Native order drawing events must be explicitly enabled or disabled/); }); } + + +test('user receives a native callback result while serialization and persistence remain separate fixture events', (t) => { + // Given the native fixture owns one visible order drawing. + const host = openNativeHost(t, createCancelScenario({ orders: ORDER_SETS.current })); + const api = host.document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi; + const received = []; + + // When direct callers request current chart state and explicitly omit drawings. + const first = api.saveChart(value => { received.push(structuredClone(value)); value.drawingIds.push('caller-only'); return 41; }); + const second = api.saveChart(value => { received.push(structuredClone(value)); return 42; }, { includeDrawings: false }); + + // Then native return values and isolated serialization evidence survive without fabricating persistence callbacks. + assert.deepEqual([first, second], [41, 42]); + assert.deepEqual(received, [{ checked: true, drawingIds: ['order-current-1'] }, { checked: true, drawingIds: [] }]); + const events = host.fixture.snapshot().events; + assert.deepEqual(events.filter(({ type }) => type === 'chart-serialized').map(({ snapshot }) => snapshot), received); + assert.deepEqual(events.filter(({ type }) => type === 'chart-saved'), []); +}); + +test('user receives every fixture persistence callback from one cumulative removal serialization', async (t) => { + // Given two visible native drawings and the real removal-save controller. + const orders = [ORDER_SETS.current[0], { ...ORDER_SETS.current[0], id: 'current-2' }]; + const host = openNativeHost(t, createCancelScenario({ orders, ui: { accountTab: 'openOrders' }, host: { orderDrawingEvents: true } })); + const api = host.document.querySelector('.chart-widget-root iframe').contentWindow.tradingViewApi; + const original = api.saveChart; + const controller = createTradingViewRemovalSaveController(api, { eventDiscoveryMs: 0 }); + t.after(() => { assert.equal(api.saveChart, original); }); + + // When both native row cancellations publish delayed requests before the controller finishes. + for (const order of orders) { + host.document.querySelector('[data-order-id="' + order.id + '"] svg') + .dispatchEvent(new host.document.defaultView.MouseEvent('click', { bubbles: true })); + } + t.mock.timers.tick(0); + t.mock.timers.tick(100); + const pending = host.fixture.snapshot().events; + assert.equal(pending.filter(({ type }) => type === 'chart-save-requested').length, 2); + assert.equal(pending.filter(({ type }) => type === 'chart-serialized').length, 0); + assert.equal(pending.filter(({ type }) => type === 'chart-saved').length, 0); + const completion = controller.finish(); + t.mock.timers.tick(40); + await completion; + + // Then one native serialization supplies the final empty chart to both original persistence callbacks. + const events = host.fixture.snapshot().events; + assert.equal(events.filter(({ type }) => type === 'chart-serialized').length, 1); + assert.deepEqual(events.filter(({ type }) => type === 'chart-saved').map(({ requestId, snapshot }) => ({ requestId, snapshot })), [ + { requestId: 1, snapshot: { checked: true, drawingIds: [] } }, + { requestId: 2, snapshot: { checked: true, drawingIds: [] } }, + ]); + assert.equal(api.saveChart, original); +}); diff --git a/test/unit/binance-orderbook-trade/chart-save-callback-contract.test.js b/test/unit/binance-orderbook-trade/chart-save-callback-contract.test.js new file mode 100644 index 0000000..668082e --- /dev/null +++ b/test/unit/binance-orderbook-trade/chart-save-callback-contract.test.js @@ -0,0 +1,272 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { coalesceTradingViewDrawingSaves, createTradingViewContinuousSaveController, createTradingViewRemovalSaveController } from '../../../src/binance-orderbook-trade/core/chart-save-coalescer.js'; + +import { createTradingViewApi as host, createManualTimers } from './chart-save-test-host.js'; + +for (const mode of ['continuous', 'removal', 'action']) { + test(`user receives every ${mode} save callback even when the same function is requested twice`, async (t) => { + // Given real callback-style native serialization and a pending chart-save lifecycle. + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = host(); + const original = h.api.saveChart; + const received = []; + const callback = snapshot => { received.push(snapshot); snapshot.drawings[0].points.push(99); }; + let controller; + let completion; + if (mode === 'continuous') controller = createTradingViewContinuousSaveController(h.api, { settleQuietMs: 1 }); + if (mode === 'removal') controller = createTradingViewRemovalSaveController(h.api, { settleQuietMs: 1, eventDiscoveryMs: 0 }); + if (mode === 'action') completion = coalesceTradingViewDrawingSaves(h.api, () => h.event(), { settleQuietMs: 1 }); + else h.event(); + + // When three accepted requests settle, including a duplicate callback function. + h.api.saveChart(callback); + h.api.saveChart(callback); + h.api.saveChart(value => received.push(value)); + await Promise.resolve(); + if (mode === 'removal') completion = controller.finish(); + t.mock.timers.tick(1); + if (completion) await completion; + if (mode === 'continuous') controller.stop(); + + // Then one serialization completes every request with independently owned JSON data. + assert.equal(h.calls.length, 1); + assert.equal(received.length, 3); + assert.deepEqual(received.map(value => value.drawings[0].points), [[1, 2, 99], [1, 2, 99], [1, 2]]); + assert.notEqual(received[0], received[1]); + assert.equal(h.api.saveChart, original); + assert.equal(h.listeners.get('drawing_event').size, 0); + }); +} + +function start(mode, h, timers, options = {}) { + const config = { settleQuietMs: 10, maxWaitMs: 40, timeoutMs: 80, eventDiscoveryMs: 0, eventDiscoveryTimeoutMs: 0, ...timers, ...options }; + let controller; + let completion; + if (mode === 'continuous') controller = createTradingViewContinuousSaveController(h.api, config); + if (mode === 'removal') controller = createTradingViewRemovalSaveController(h.api, config); + if (mode === 'action') completion = coalesceTradingViewDrawingSaves(h.api, () => h.event(), config); + else h.event(); + return { controller, async finish() { + await Promise.resolve(); + if (mode === 'removal') completion = controller.finish(); + timers.advance(10); + if (mode === 'continuous') return controller.stop(); + return await completion; + } }; +} + +for (const mode of ['continuous', 'removal', 'action']) { + test(`user receives remaining ${mode} callbacks after one fails without leaking that failure into an explicit save`, async () => { + // Given accepted callbacks including one host callback that throws. + const h = host(), timers = createManualTimers(), failures = [], received = []; + const failure = new Error('Host callback failed'); + const lifecycle = start(mode, h, timers, { onCallbackError: error => failures.push(error) }); + h.api.saveChart(() => { throw failure; }); + h.api.saveChart(value => received.push(value)); + + // When an explicit save flushes callbacks before running with its native options and return value. + const result = h.api.saveChart(value => { assert.deepEqual(value, { drawings: [] }); return 73; }, { includeDrawings: false }); + await lifecycle.finish(); + timers.advance(0); + + // Then all callbacks finish and their failure is reported only at the separate asynchronous boundary. + assert.equal(result, 73); + assert.deepEqual(received, [h.snapshot]); + assert.equal(failures.length, 1); + assert.deepEqual(failures[0].errors, [failure]); + assert.equal(h.calls.length, 2); + assert.equal(h.calls[1].args[1].includeDrawings, false); + }); + + test(`user preserves synchronous receiver return and throw semantics for nondefault ${mode} save calls`, async () => { + // Given a live save burst and a foreign receiver. + const h = host(), timers = createManualTimers(), receiver = {}; + const lifecycle = start(mode, h, timers); + let delivered = 0; + h.api.saveChart(() => { delivered += 1; }); + + // When a foreign receiver save and an invalid native signature bypass batching. + const result = h.api.saveChart.call(receiver, () => 91); + assert.throws(() => h.api.saveChart('invalid native signature'), /Native callback required/); + await lifecycle.finish(); + + // Then the earlier callback is delivered and the original API owns both synchronous outcomes. + assert.equal(result, 91); + assert.equal(delivered, 1); + assert.equal(h.calls[1].receiver, receiver); + assert.deepEqual(h.calls[2].args, ['invalid native signature']); + }); + + test(`user saves reentrantly from a ${mode} callback without rejoining the detached batch`, async () => { + // Given two callbacks where the first requests another native save. + const h = host(), timers = createManualTimers(), received = []; + const original = h.api.saveChart; + const lifecycle = start(mode, h, timers); + h.api.saveChart(() => { + assert.equal(h.api.saveChart, original); + h.api.saveChart(() => { received.push('nested'); }); + received.push('first'); + }); + h.api.saveChart(() => { received.push('second'); }); + + // When the lifecycle restores ownership and delivers the detached callback batch. + await lifecycle.finish(); + + // Then nested saving completes synchronously and does not consume the second callback. + assert.deepEqual(received, ['nested', 'first', 'second']); + assert.equal(h.calls.length, 2); + assert.equal(h.api.saveChart, original); + }); + + test(`user retains accepted ${mode} callbacks when another save wrapper takes ownership`, async () => { + // Given two accepted callbacks and an independently installed outer save owner. + const h = host(), timers = createManualTimers(), received = [], foreignCalls = []; + const lifecycle = start(mode, h, timers); + h.api.saveChart(value => received.push(value)); + h.api.saveChart(value => received.push(value)); + const foreign = (...args) => { foreignCalls.push(args); }; + h.api.saveChart = foreign; + + // When lifecycle cleanup encounters the replacement owner. + const finish = lifecycle.finish(); + if (mode === 'continuous') await finish; + else await assert.rejects(finish, /图表保存接口在/); + + // Then accepted callbacks complete using the known serializer without replacing the foreign method. + assert.deepEqual(received, [h.snapshot, h.snapshot]); + assert.equal(h.calls.length, 1); + assert.equal(h.api.saveChart, foreign); + assert.deepEqual(foreignCalls, []); + assert.equal(h.listeners.get('drawing_event').size, 0); + }); +} + +for (const mode of ['continuous', 'removal', 'action']) { + test(`user can keep saving through a foreign wrapper that retained the finished ${mode} wrapper`, async () => { + // Given a foreign owner delegating through the captured in-flight save method. + const h = host(), timers = createManualTimers(), received = []; + const lifecycle = start(mode, h, timers); + h.api.saveChart(() => received.push('accepted')); + const captured = h.api.saveChart; + const foreign = function (...args) { return captured.apply(this, args); }; + h.api.saveChart = foreign; + + // When cleanup finishes and the foreign owner receives a later save request. + if (mode === 'continuous') await lifecycle.finish(); + else await assert.rejects(lifecycle.finish(), /图表保存接口在/); + const returned = h.api.saveChart(() => { received.push('later'); return 27; }); + + // Then the retired wrapper delegates normally rather than retaining an unreachable callback queue. + assert.deepEqual(received, ['accepted', 'later']); + assert.equal(returned, 27); + assert.equal(h.calls.length, 2); + assert.equal(h.api.saveChart, foreign); + }); +} + +test('user keeps an explicit save synchronous through a foreign wrapper during a drawing action', async () => { + // Given a pending callback and an outer owner that delegates to the active coalescer. + const h = host(), timers = createManualTimers(), received = []; + const lifecycle = start('action', h, timers); + h.api.saveChart(() => received.push('pending')); + const captured = h.api.saveChart; + const foreign = function (...args) { return captured.apply(this, args); }; + h.api.saveChart = foreign; + + // When that foreign owner invokes an explicit native save before cleanup. + const returned = h.api.saveChart(() => { received.push('explicit'); return 52; }, { includeDrawings: false }); + await assert.rejects(lifecycle.finish(), /图表保存接口在/); + + // Then the pending callback is delivered without replacing the outer owner or changing synchronous return. + assert.equal(returned, 52); + assert.deepEqual(received, ['pending', 'explicit']); + assert.equal(h.api.saveChart, foreign); + assert.equal(h.calls.length, 2); +}); + +test('user receives the native callback return and current JSON state from the save host', () => { + // Given the callback-style host with an independently mutable chart state. + const h = host(); + h.snapshot.drawings.push({ id: 'second', points: [3] }); + const received = []; + + // When the native host is called with default and explicit drawing options. + const first = h.api.saveChart(value => { received.push(value); return 5; }); + const second = h.api.saveChart(value => { received.push(value); return 6; }, { includeDrawings: false }); + received[0].drawings[0].points.push(42); + + // Then synchronous results and copy ownership match the inspected native contract. + assert.deepEqual([first, second], [5, 6]); + assert.deepEqual(received[0].drawings[1], { id: 'second', points: [3] }); + assert.deepEqual(received[1], { drawings: [] }); + assert.deepEqual(h.snapshot.drawings[0].points, [1, 2]); + assert.equal(h.calls[0].receiver, h.api); +}); + +for (const mode of ['continuous', 'removal', 'action']) { + test(`user keeps extra-argument ${mode} calls synchronous while default undefined options can be deferred`, async () => { + // Given one default callback with explicit undefined options during a save burst. + const h = host(), timers = createManualTimers(), received = []; + const lifecycle = start(mode, h, timers); + const deferred = h.api.saveChart(() => received.push('default'), undefined); + + // When a nondefault three-argument call passes through the wrapper. + const token = { extra: true }; + const direct = h.api.saveChart(() => { received.push('extra'); return 33; }, undefined, token); + await lifecycle.finish(); + + // Then only the default call loses synchronous return semantics and all native arguments survive. + assert.equal(deferred, undefined); + assert.equal(direct, 33); + assert.deepEqual(received, ['default', 'extra']); + assert.equal(h.calls[1].args.length, 3); + assert.equal(h.calls[1].args[2], token); + }); +} + +test('user receives deferred callback errors through the default asynchronous error boundary', () => { + // Given a continuous-save burst with one failed and one successful callback. + const h = host(), timers = createManualTimers(), failure = new Error('Callback failed'); + const controller = createTradingViewContinuousSaveController(h.api, timers); + h.event(); + let delivered = 0; + h.api.saveChart(() => { throw failure; }); + h.api.saveChart(() => { delivered += 1; }); + + // When stopping delivers both callbacks before the error-reporting timer runs. + controller.stop(); + + // Then the successful callback is preserved and the later job reports the exact callback error. + assert.equal(delivered, 1); + assert.throws(() => timers.advance(0), error => error instanceof AggregateError && error.errors[0] === failure); + assert.equal(timers.timers.size, 0); +}); + +for (const reason of ['error-object', 'null']) { + test(`user retains the ${reason} action failure and final serialization failure without retrying`, async () => { + // Given one accepted callback and independent action and native serialization failures. + const h = host(), timers = createManualTimers(); + const actionFailure = reason === 'null' ? null : new Error('Native action failed'); + const saveFailure = new Error('Native final serialization failed'); + const original = h.api.saveChart; + h.setSaveFailure(saveFailure); + let delivered = 0; + + // When the action fails and cleanup cannot serialize its already accepted callback. + const completion = coalesceTradingViewDrawingSaves(h.api, () => { + h.event(); + h.api.saveChart(() => { delivered += 1; }); + throw actionFailure; + }, timers); + + // Then both original failures survive in order and serialization is attempted exactly once. + await assert.rejects(completion, error => error instanceof AggregateError + && error.errors.length === 2 && error.errors[0] === actionFailure && error.errors[1] === saveFailure); + assert.equal(h.calls.length, 1); + assert.equal(delivered, 0); + assert.equal(h.api.saveChart, original); + assert.equal(h.listeners.get('drawing_event').size, 0); + assert.equal(timers.timers.size, 0); + }); +} diff --git a/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js b/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js index e495b3c..8ff3694 100644 --- a/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js +++ b/test/unit/binance-orderbook-trade/chart-save-coalescer.test.js @@ -1,1278 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; - -import { - coalesceTradingViewDrawingSaves, - createTradingViewContinuousSaveController, - createTradingViewRemovalSaveController, -} from '../../../src/binance-orderbook-trade/core/chart-save-coalescer.js'; - -function createTradingViewApi() { - const listeners = new Map(); - const saved = []; - const drawingToolNames = new Map(); - const api = { - activeChart() { - return { - getShapeById(drawingId) { - const toolname = drawingToolNames.get(String(drawingId)); - if (!toolname) throw new Error('There is no such shape'); - return { lineDataSource: () => ({ toolname }) }; - }, - }; - }, - saveChart(...args) { - saved.push({ thisValue: this, args }); - }, - subscribe(name, callback) { - const callbacks = listeners.get(name) || new Set(); - callbacks.add(callback); - listeners.set(name, callbacks); - }, - unsubscribe(name, callback) { - listeners.get(name)?.delete(callback); - }, - emit(name, ...args) { - for (const callback of listeners.get(name) || []) callback(...args); - }, - }; - return { - api, - saved, - listeners, - setDrawingToolName(drawingId, toolname) { - drawingToolNames.set(String(drawingId), toolname); - }, - }; -} - -function expectedContinuousStats(overrides = {}) { - return { - deferredSubmitSaveCount: 0, - fullSaveCount: 0, - orderEventCount: 0, - removeEventCount: 0, - saveRequestCount: 0, - ...overrides, - }; -} - -function createManualTimers() { - let now = 0; - let sequence = 0; - const timers = new Map(); - const setTimeoutFn = (callback, delayMs) => { - sequence += 1; - timers.set(sequence, { callback, at: now + delayMs }); - return sequence; - }; - const clearTimeoutFn = (timerId) => timers.delete(timerId); - const advance = (elapsedMs) => { - const deadline = now + elapsedMs; - while (true) { - const next = Array.from(timers.entries()) - .filter(([, timer]) => timer.at <= deadline) - .sort((left, right) => left[1].at - right[1].at || left[0] - right[0])[0]; - if (!next) break; - const [timerId, timer] = next; - timers.delete(timerId); - now = timer.at; - timer.callback(); - } - now = deadline; - }; - return { advance, clearTimeoutFn, setTimeoutFn, timers }; -} - -test('user keeps unrelated chart saves synchronous before a removal starts', async () => { - // Given a native chart API and a removal-save lifecycle with recorded saves. - const { api, saved, listeners } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const controller = createTradingViewRemovalSaveController(api, { eventDiscoveryMs: 0 }); - assert.notEqual(api.saveChart, originalSaveChart); - - // When the native chart requests its next snapshot. - api.saveChart('unrelated'); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved.map((entry) => entry.args), [['unrelated']]); - - // When the removal lifecycle requests its final snapshot. - const lifecycleResult1 = await controller.finish(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, { - fullSaveCount: 0, - removeEventCount: 0, - saveRequestCount: 0, - synchronousSaveCount: 1, - }); - assert.equal(api.saveChart, originalSaveChart); - assert.deepEqual(saved.map((entry) => entry.args), [['unrelated']]); - assert.equal(listeners.get('drawing_event')?.size, 0); -}); - -test('user persists the latest chart snapshot across separate removal bursts', async () => { - // Given a native chart API and a removal-save lifecycle with recorded saves. - const { api, saved, listeners } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const timers = createManualTimers(); - const controller = createTradingViewRemovalSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - eventDiscoveryMs: 10, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('snapshot-1'); - timers.advance(20); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.notEqual(api.saveChart, originalSaveChart); - assert.deepEqual(saved, []); - - // When the native chart requests its next snapshot. - api.saveChart('unrelated-between-bursts'); - api.emit('drawing_event', 'order-2', 'remove'); - api.saveChart('snapshot-2'); - const completion = controller.finish(); - timers.advance(20); - const result = await completion; - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(result, { - fullSaveCount: 1, - removeEventCount: 2, - saveRequestCount: 2, - synchronousSaveCount: 1, - }); - assert.deepEqual(saved.map((entry) => entry.args), [ - ['unrelated-between-bursts'], - ['snapshot-2'], - ]); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(listeners.get('drawing_event')?.size, 0); -}); - -test('user includes delayed removal events before the chart lifecycle finishes', async () => { - // Given a native chart API and a removal-save lifecycle with recorded saves. - const { api, saved } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewRemovalSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - eventDiscoveryMs: 30, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const completion = controller.finish(); - - // When virtual time reaches the next capture or settle deadline. - timers.advance(10); - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('snapshot-1'); - timers.advance(20); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(await completion, { - fullSaveCount: 1, - removeEventCount: 1, - saveRequestCount: 1, - synchronousSaveCount: 0, - }); - assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-1']]); -}); - -test('user saves unrelated chart changes immediately during a removal lifecycle', async () => { - // Given a native chart API and a removal-save lifecycle with recorded saves. - const { api, saved } = createTradingViewApi(); - const controller = createTradingViewRemovalSaveController(api, { eventDiscoveryMs: 0 }); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'properties_changed'); - api.saveChart('unrelated'); - const lifecycleResult1 = await controller.finish(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, { - fullSaveCount: 0, - removeEventCount: 0, - saveRequestCount: 0, - synchronousSaveCount: 1, - }); - assert.deepEqual(saved.map((entry) => entry.args), [['unrelated']]); -}); - -test('user keeps the newer synchronous chart snapshot over an older deferred removal', async () => { - // Given a native chart API and a removal-save lifecycle with recorded saves. - const { api, saved } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewRemovalSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - eventDiscoveryMs: 0, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('removal-snapshot'); - timers.advance(20); - api.saveChart('newer-unrelated-snapshot'); - const lifecycleResult1 = await controller.finish(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, { - fullSaveCount: 0, - removeEventCount: 1, - saveRequestCount: 1, - synchronousSaveCount: 1, - }); - assert.deepEqual(saved.map((entry) => entry.args), [['newer-unrelated-snapshot']]); -}); - -test('user keeps a chart save method replaced by another operation during removals', async () => { - // Given a native chart API and a removal-save lifecycle with recorded saves. - const { api, saved, listeners } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewRemovalSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - eventDiscoveryMs: 0, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('old-removal-snapshot'); - const foreignSaves = []; - const foreignSaveChart = (...args) => foreignSaves.push(args); - api.saveChart = foreignSaveChart; - api.saveChart('newer-foreign-snapshot'); - timers.advance(20); - const lifecycleResult1 = controller.finish(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - await assert.rejects(lifecycleResult1, /图表保存接口在删除事件合并期间发生变化/); - assert.equal(api.saveChart, foreignSaveChart); - assert.deepEqual(saved, []); - assert.deepEqual(foreignSaves, [['newer-foreign-snapshot']]); - assert.equal(listeners.get('drawing_event')?.size, 0); -}); - -test('user avoids replaying a settled removal snapshot after save ownership changes', async () => { - // Given a native chart API and a removal-save lifecycle with recorded saves. - const { api, saved } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewRemovalSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - eventDiscoveryMs: 0, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('old-removal-snapshot'); - timers.advance(20); - const foreignSaves = []; - const foreignSaveChart = (...args) => foreignSaves.push(args); - api.saveChart = foreignSaveChart; - api.saveChart('newer-foreign-snapshot'); - const lifecycleResult1 = controller.finish(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - await assert.rejects( - lifecycleResult1, - /图表保存接口在删除事件监视期间发生变化/, - ); - assert.equal(api.saveChart, foreignSaveChart); - assert.deepEqual(saved, []); - assert.deepEqual(foreignSaves, [['newer-foreign-snapshot']]); -}); - -test('user regains chart ownership before a final removal-save failure is reported', async () => { - // Given a native chart API and a removal-save lifecycle with recorded saves. - const { api, listeners } = createTradingViewApi(); - const originalSaveChart = function saveChart() { - throw new Error('final save failed'); - }; - api.saveChart = originalSaveChart; - const timers = createManualTimers(); - const controller = createTradingViewRemovalSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - eventDiscoveryMs: 0, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('snapshot-1'); - const completion = controller.finish(); - timers.advance(20); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - await assert.rejects(completion, /final save failed/); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(listeners.get('drawing_event')?.size, 0); -}); - -test('user gets the ownership error without invoking an obsolete failing save method', async () => { - // Given a native chart API and a removal-save lifecycle with recorded saves. - const { api, listeners } = createTradingViewApi(); - api.saveChart = function saveChart() { - throw new Error('original save failed'); - }; - const timers = createManualTimers(); - const controller = createTradingViewRemovalSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - eventDiscoveryMs: 0, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('pending-snapshot'); - const foreignSaveChart = () => {}; - api.saveChart = foreignSaveChart; - timers.advance(20); - const lifecycleResult1 = controller.finish(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - await assert.rejects( - lifecycleResult1, - /图表保存接口在删除事件合并期间发生变化/, - ); - assert.equal(api.saveChart, foreignSaveChart); - assert.equal(listeners.get('drawing_event')?.size, 0); -}); - -test('user keeps unrelated saves synchronous throughout continuous closing', () => { - // Given a native chart API and a continuous-save controller with recorded saves. - const { api, saved } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const timers = createManualTimers(); - const coalescer = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - - // When the native chart requests its next snapshot. - api.saveChart('unrelated'); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved.map((entry) => entry.args), [['unrelated']]); - - // When the chart-save controller stops and releases its lifecycle. - const lifecycleResult1 = coalescer.stop(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, expectedContinuousStats()); - assert.equal(api.saveChart, originalSaveChart); -}); - -test('user persists only the final snapshot from a continuous-close removal burst', () => { - // Given a native chart API and a continuous-save controller with recorded saves. - const { api, saved } = createTradingViewApi(); - const timers = createManualTimers(); - const coalescer = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('snapshot-1'); - timers.advance(10); - api.emit('drawing_event', 'order-2', 'remove'); - api.saveChart('snapshot-2'); - timers.advance(10); - api.emit('drawing_event', 'order-3', 'remove'); - api.saveChart('snapshot-3'); - timers.advance(19); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved, []); - - // When virtual time reaches the next capture or settle deadline. - timers.advance(1); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-3']]); - - // When the chart-save controller stops and releases its lifecycle. - const lifecycleResult1 = coalescer.stop(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, expectedContinuousStats({ - fullSaveCount: 1, - removeEventCount: 3, - saveRequestCount: 3, - })); -}); - -test('user gets a chart save at the maximum deadline during sustained removals', () => { - // Given a native chart API and a continuous-save controller with recorded saves. - const { api, saved } = createTradingViewApi(); - const timers = createManualTimers(); - const coalescer = createTradingViewContinuousSaveController(api, { - settleQuietMs: 50, - maxWaitMs: 100, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('snapshot-1'); - for (let index = 2; index <= 5; index += 1) { - timers.advance(20); - api.emit('drawing_event', `order-${index}`, 'remove'); - api.saveChart(`snapshot-${index}`); - } - timers.advance(20); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-5']]); - coalescer.stop(); -}); - -test('user flushes pending chart state and restores normal saving when stopping', () => { - // Given a native chart API and a continuous-save controller with recorded saves. - const { api, saved } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const timers = createManualTimers(); - const coalescer = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('pending-final'); - const lifecycleResult1 = coalescer.stop(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, expectedContinuousStats({ - fullSaveCount: 1, - removeEventCount: 1, - saveRequestCount: 1, - })); - assert.deepEqual(saved.map((entry) => entry.args), [['pending-final']]); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(timers.timers.size, 0); - - // When the native chart requests its next snapshot. - api.saveChart('after-stop'); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved.map((entry) => entry.args), [['pending-final'], ['after-stop']]); -}); - -test('user saves unrelated drawing properties outside removal bursts', () => { - // Given a native chart API and a continuous-save controller with recorded saves. - const { api, saved } = createTradingViewApi(); - const coalescer = createTradingViewContinuousSaveController(api); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'properties_changed'); - api.saveChart('properties'); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved.map((entry) => entry.args), [['properties']]); - - // When the chart-save controller stops and releases its lifecycle. - const lifecycleResult1 = coalescer.stop(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, expectedContinuousStats()); -}); - -test('user keeps another operation in control of chart saving', () => { - // Given a native chart API and a continuous-save controller with recorded saves. - const { api, saved } = createTradingViewApi(); - const controller = createTradingViewContinuousSaveController(api); - const sessionSaveChart = api.saveChart; - const foreignSaves = []; - api.saveChart = (...args) => foreignSaves.push(args); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('foreign'); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(foreignSaves, [['foreign']]); - assert.deepEqual(saved, []); - - // When the next chart lifecycle operation runs. - api.saveChart = sessionSaveChart; - const lifecycleResult1 = controller.stop(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, expectedContinuousStats({ removeEventCount: 1 })); -}); - -test('user regains the original chart method after a stop-time save failure', () => { - // Given a native chart API and a continuous-save controller with recorded saves. - const { api, listeners } = createTradingViewApi(); - const originalSaveChart = function saveChart() { - throw new Error('final save failed'); - }; - api.saveChart = originalSaveChart; - const controller = createTradingViewContinuousSaveController(api); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('pending'); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.throws(() => controller.stop(), /final save failed/); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(listeners.get('drawing_event')?.size, 0); -}); - -test('user persists one final chart snapshot after five confirmed order-line captures', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { - api, - saved, - setDrawingToolName, - } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - submitEventDiscoveryMs: 10, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const round = controller.beginRound(); - - // When the native chart delivers the next sequence of drawing events and saves. - for (let index = 1; index <= 5; index += 1) { - const drawingId = `order-${index}`; - setDrawingToolName(drawingId, 'LineToolOrder'); - const capture = controller.beginSubmitCapture(round); - api.emit('drawing_event', drawingId, 'properties_changed'); - api.saveChart(`snapshot-${index}`); - const completion = controller.completeSubmitCapture(capture); - timers.advance(20); - assert.deepEqual(await completion, { matched: true, status: 'captured' }); - assert.equal(api.saveChart, originalSaveChart); - assert.deepEqual(saved, []); - } - const lifecycleResult1 = controller.endRound(round); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, expectedContinuousStats({ - deferredSubmitSaveCount: 5, - fullSaveCount: 1, - orderEventCount: 5, - saveRequestCount: 5, - })); - assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-5']]); - controller.stop(); -}); - -test('user keeps the final snapshot when one submit produces several order-line changes', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api, saved, setDrawingToolName } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - setDrawingToolName('order-1', 'LineToolOrder'); - setDrawingToolName('order-2', 'LineToolOrder'); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'properties_changed'); - api.saveChart('snapshot-1'); - timers.advance(10); - api.emit('drawing_event', 'order-2', 'properties_changed'); - api.saveChart('snapshot-2'); - const completion = controller.completeSubmitCapture(capture); - timers.advance(20); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(await completion, { matched: true, status: 'captured' }); - - // When the active chart-save round finishes. - controller.endRound(round); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-2']]); - - // When the chart-save controller stops and releases its lifecycle. - const lifecycleResult1 = controller.stop(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, expectedContinuousStats({ - deferredSubmitSaveCount: 1, - fullSaveCount: 1, - orderEventCount: 2, - saveRequestCount: 2, - })); -}); - -test('user persists preceding removals separately from the current submit capture', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api, saved, setDrawingToolName } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - setDrawingToolName('order-1', 'LineToolOrder'); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'removed-order', 'remove'); - api.saveChart('remove-snapshot'); - api.emit('drawing_event', 'order-1', 'properties_changed'); - api.saveChart('order-snapshot'); - const completion = controller.completeSubmitCapture(capture); - timers.advance(20); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(await completion, { matched: true, status: 'captured' }); - assert.deepEqual(saved.map((entry) => entry.args), [['remove-snapshot']]); - - // When the active chart-save round finishes. - controller.endRound(round); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved.map((entry) => entry.args), [ - ['remove-snapshot'], - ['order-snapshot'], - ]); - controller.stop(); -}); - -test('user keeps a newer removal snapshot over an older deferred submission', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api, saved, setDrawingToolName } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - setDrawingToolName('order-1', 'LineToolOrder'); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'properties_changed'); - api.saveChart('order-snapshot'); - api.emit('drawing_event', 'removed-order', 'remove'); - api.saveChart('newer-remove-snapshot'); - timers.advance(20); - const lifecycleResult1 = await controller.completeSubmitCapture(capture); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, { - matched: true, - status: 'captured', - }); - - // When the active chart-save round finishes. - controller.endRound(round); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved.map((entry) => entry.args), [['newer-remove-snapshot']]); - controller.stop(); -}); - -test('user saves position lines immediately while an order-line capture is armed', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api, saved, setDrawingToolName } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - submitEventDiscoveryMs: 10, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - setDrawingToolName('position-1', 'LineToolPosition'); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'position-1', 'properties_changed'); - api.saveChart('position-snapshot'); - const completion = controller.completeSubmitCapture(capture); - timers.advance(10); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(await completion, { matched: false, status: 'no-order-event' }); - assert.deepEqual(saved.map((entry) => entry.args), [['position-snapshot']]); - controller.endRound(round); - controller.stop(); -}); - -test('user does not treat order-line clicks or moves as submission drawings', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api, saved, setDrawingToolName } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { - submitEventDiscoveryMs: 10, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - setDrawingToolName('order-1', 'LineToolOrder'); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'click'); - api.emit('drawing_event', 'order-1', 'move'); - api.saveChart('interaction-snapshot'); - const completion = controller.completeSubmitCapture(capture); - timers.advance(10); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(await completion, { matched: false, status: 'no-order-event' }); - assert.deepEqual(saved.map((entry) => entry.args), [['interaction-snapshot']]); - controller.endRound(round); - controller.stop(); -}); - -test('user can save unrelated chart changes after an order-line capture settles', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api, saved, setDrawingToolName } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - setDrawingToolName('order-1', 'LineToolOrder'); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'properties_changed'); - api.saveChart('order-snapshot'); - const completion = controller.completeSubmitCapture(capture); - timers.advance(20); - await completion; - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.equal(api.saveChart, originalSaveChart); - - // When the native chart requests its next snapshot. - api.saveChart('unrelated-snapshot'); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved.map((entry) => entry.args), [['unrelated-snapshot']]); - - // When the active chart-save round finishes. - controller.endRound(round); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved.map((entry) => entry.args), [ - ['unrelated-snapshot'], - ['order-snapshot'], - ]); - controller.stop(); -}); - -test('user persists the pending order-line snapshot when continuous closing stops', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api, saved, listeners, setDrawingToolName } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - setDrawingToolName('order-1', 'LineToolOrder'); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'properties_changed'); - api.saveChart('pending-round'); - const completion = controller.completeSubmitCapture(capture); - timers.advance(20); - await completion; - const lifecycleResult1 = controller.stop(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, expectedContinuousStats({ - deferredSubmitSaveCount: 1, - fullSaveCount: 1, - orderEventCount: 1, - saveRequestCount: 1, - })); - assert.deepEqual(saved.map((entry) => entry.args), [['pending-round']]); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(listeners.get('drawing_event')?.size, 0); -}); - -test('user can flush chart state and continue capturing the same close round', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api, saved, setDrawingToolName } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const round = controller.beginRound(); - setDrawingToolName('order-1', 'LineToolOrder'); - const firstCapture = controller.beginSubmitCapture(round); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'properties_changed'); - api.saveChart('snapshot-1'); - const firstCompletion = controller.completeSubmitCapture(firstCapture); - timers.advance(20); - await firstCompletion; - controller.flush(); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-1']]); - - // When the next chart lifecycle operation runs. - setDrawingToolName('order-2', 'LineToolOrder'); - const secondCapture = controller.beginSubmitCapture(round); - api.emit('drawing_event', 'order-2', 'properties_changed'); - api.saveChart('snapshot-2'); - const secondCompletion = controller.completeSubmitCapture(secondCapture); - timers.advance(20); - await secondCompletion; - controller.endRound(round); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-1'], ['snapshot-2']]); - controller.stop(); -}); - -test('user finishes an unmatched order-line capture immediately on a lifecycle flush', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api } = createTradingViewApi(); - const controller = createTradingViewContinuousSaveController(api); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - - // When the active chart-save lifecycle is flushed. - controller.flush(); - const lifecycleResult1 = await controller.completeSubmitCapture(capture); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, { - matched: false, - status: 'flushed', - }); - controller.endRound(round); - controller.stop(); -}); - -test('user releases an unmatched order-line capture at its discovery deadline', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { - submitEventDiscoveryMs: 10, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - - // When virtual time reaches the next capture or settle deadline. - timers.advance(10); - const lifecycleResult1 = await controller.completeSubmitCapture(capture); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, { - matched: false, - status: 'no-order-event', - }); - controller.endRound(round); - controller.stop(); -}); - -test('user keeps an existing chart save owner when the submitted order line arrives', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api, saved, setDrawingToolName } = createTradingViewApi(); - const controller = createTradingViewContinuousSaveController(api); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - const foreignSaves = []; - const sessionSaveChart = api.saveChart; - api.saveChart = (...args) => foreignSaves.push(args); - setDrawingToolName('order-1', 'LineToolOrder'); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'properties_changed'); - const lifecycleResult1 = await controller.completeSubmitCapture(capture); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, { - matched: true, - status: 'save-chart-busy', - }); - - // When the native chart requests its next snapshot. - api.saveChart('foreign-order-snapshot'); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(foreignSaves, [['foreign-order-snapshot']]); - assert.deepEqual(saved, []); - api.saveChart = sessionSaveChart; - controller.endRound(round); - controller.stop(); -}); - -test('user preserves a chart wrapper installed during an active submit capture', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api, saved, setDrawingToolName } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - const sessionSaveChart = api.saveChart; - setDrawingToolName('order-1', 'LineToolOrder'); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'properties_changed'); - api.saveChart('pending-round'); - const foreignSaves = []; - const replacedSaveChart = api.saveChart; - api.saveChart = function foreignSaveChart(...args) { - foreignSaves.push({ thisValue: this, args }); - return replacedSaveChart.apply(this, args); - }; - timers.advance(20); - const lifecycleResult1 = await controller.completeSubmitCapture(capture); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(lifecycleResult1, { - matched: true, - status: 'save-chart-replaced', - }); - assert.deepEqual(foreignSaves, []); - assert.deepEqual(saved.map((entry) => entry.args), [['pending-round']]); - assert.notEqual(api.saveChart, sessionSaveChart); - controller.endRound(round); - api.saveChart = sessionSaveChart; - controller.stop(); -}); - -test('user replays the final round snapshot through the latest chart save owner', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api, saved, setDrawingToolName } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - setDrawingToolName('order-1', 'LineToolOrder'); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'properties_changed'); - api.saveChart('pending-round'); - const completion = controller.completeSubmitCapture(capture); - timers.advance(20); - await completion; - const sessionSaveChart = api.saveChart; - const foreignSaves = []; - api.saveChart = function foreignSaveChart(...args) { - foreignSaves.push({ thisValue: this, args }); - return sessionSaveChart.apply(this, args); - }; - controller.endRound(round); - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.deepEqual(foreignSaves.map((entry) => entry.args), [['pending-round']]); - assert.deepEqual(saved.map((entry) => entry.args), [['pending-round']]); - api.saveChart = sessionSaveChart; - controller.stop(); -}); - -test('user regains chart saving after the final round snapshot fails', async () => { - // Given a native chart API, an active close round, and order-line capture state. - const { api, setDrawingToolName } = createTradingViewApi(); - const originalSaveChart = function saveChart() { - throw new Error('round save failed'); - }; - api.saveChart = originalSaveChart; - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { - settleQuietMs: 20, - maxWaitMs: 100, - setTimeoutFn: timers.setTimeoutFn, - clearTimeoutFn: timers.clearTimeoutFn, - }); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - setDrawingToolName('order-1', 'LineToolOrder'); - - // When native drawing events and their save requests are delivered. - api.emit('drawing_event', 'order-1', 'properties_changed'); - api.saveChart('pending-round'); - const completion = controller.completeSubmitCapture(capture); - timers.advance(20); - await completion; - - // Then saves, capture results, and chart ownership match the observed lifecycle state. - assert.throws(() => controller.endRound(round), /round save failed/); - assert.equal(api.saveChart, originalSaveChart); - controller.stop(); -}); - -for (const drawingCount of [1, 5, 70, 120, 199, 200]) { - test(`user persists one final chart snapshot for ${drawingCount} drawing removals`, async () => { - // Given the chart API, original save method, and a manual timer scheduler. - const { api, saved, listeners } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const timers = createManualTimers(); - - // When a native drawing action emits removals and matching saves arrive after the action. - const completion = coalesceTradingViewDrawingSaves(api, () => { - for (let index = 0; index < drawingCount; index += 1) api.emit('drawing_event', 'order-' + index, 'remove'); - return 'hidden'; - }, { settleQuietMs: 1, timeoutMs: 100, ...timers }); - await Promise.resolve(); - for (let index = 0; index < drawingCount; index += 1) api.saveChart('snapshot-' + index); - timers.advance(1); - const result = await completion; - - // Then exactly the last cumulative save is persisted and chart ownership is released. - assert.deepEqual(result, { actionResult: 'hidden', drawingEventCount: drawingCount, saveRequestCount: drawingCount, fullSaveCount: 1 }); - assert.equal(saved.length, 1); - assert.equal(saved[0].thisValue, api); - assert.deepEqual(saved[0].args, ['snapshot-' + (drawingCount - 1)]); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(listeners.get('drawing_event')?.size, 0); - assert.equal(timers.timers.size, 0); - }); -} - -test('user persists the cumulative final drawing snapshot', async () => { - // Given three cumulative native chart snapshots and a manual clock. - const { api, saved } = createTradingViewApi(); - const timers = createManualTimers(); - const snapshots = [{ drawings: ['order-1'] }, { drawings: ['order-1', 'order-2'] }, { drawings: ['order-1', 'order-2', 'order-3'] }]; - - // When the drawing action completes and all three save requests settle. - const completion = coalesceTradingViewDrawingSaves(api, () => { - for (const drawingId of snapshots.at(-1).drawings) api.emit('drawing_event', drawingId, 'properties_changed'); - }, { settleQuietMs: 1, timeoutMs: 100, ...timers }); - await Promise.resolve(); - for (const snapshot of snapshots) api.saveChart(snapshot); - timers.advance(1); - await completion; - - // Then persisted drawings equal the final cumulative snapshot. - assert.deepEqual(saved.map((entry) => entry.args[0]), [snapshots.at(-1)]); -}); - -test('user waits for drawing changes that follow the checkbox update', async () => { - // Given an updated checkbox whose broker drawings have not arrived yet. - const { api, saved } = createTradingViewApi(); - const timers = createManualTimers(); - - // When drawing changes arrive at 10 ms and their saves arrive another 5 ms later. - const completion = coalesceTradingViewDrawingSaves(api, () => 'checkbox-changed', { - eventDiscoveryTimeoutMs: 50, settleQuietMs: 1, timeoutMs: 100, ...timers, - }); - await Promise.resolve(); - timers.advance(10); - api.emit('drawing_event', 'order-1', 'remove'); - api.emit('drawing_event', 'order-2', 'remove'); - timers.advance(5); - api.saveChart('snapshot-1'); - api.saveChart('snapshot-2'); - timers.advance(1); - const result = await completion; - - // Then both delayed changes contribute to one final snapshot. - assert.deepEqual(result, { actionResult: 'checkbox-changed', drawingEventCount: 2, saveRequestCount: 2, fullSaveCount: 1 }); - assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-2']]); - assert.equal(timers.timers.size, 0); -}); - -test('user waits for a full quiet window after interleaved drawing changes and saves', async () => { - // Given a native checkbox action and a 10 ms quiet window on a manual clock. - const { api, saved } = createTradingViewApi(); - const timers = createManualTimers(); - - // When three drawing/save pairs arrive in separate ticks five milliseconds apart. - const completion = coalesceTradingViewDrawingSaves(api, () => 'checkbox-changed', { - eventDiscoveryTimeoutMs: 20, settleQuietMs: 10, timeoutMs: 100, ...timers, - }); - await Promise.resolve(); - for (let index = 0; index < 3; index += 1) { - if (index > 0) timers.advance(5); - api.emit('drawing_event', 'order-' + index, 'remove'); - api.saveChart('snapshot-' + index); - } - timers.advance(9); - - // Then no snapshot is persisted one millisecond before the final quiet window closes. - assert.deepEqual(saved, []); - - // When the complete quiet window elapses. - timers.advance(1); - const result = await completion; - - // Then all three drawing changes settle into the final cumulative snapshot. - assert.deepEqual(result, { actionResult: 'checkbox-changed', drawingEventCount: 3, saveRequestCount: 3, fullSaveCount: 1 }); - assert.deepEqual(saved.map((entry) => entry.args), [['snapshot-2']]); -}); - -test('user does not wait for save requests from click or move drawing events', async () => { - // Given a manual discovery clock and drawing interactions that do not change chart data. - const { api, saved } = createTradingViewApi(); - const timers = createManualTimers(); - - // When the action emits only click and move events and discovery expires. - const completion = coalesceTradingViewDrawingSaves(api, () => { - api.emit('drawing_event', 'order-1', 'click'); - api.emit('drawing_event', 'order-1', 'move'); - return 'unchanged'; - }, { eventDiscoveryTimeoutMs: 5, ...timers }); - await Promise.resolve(); - timers.advance(5); - const result = await completion; - - // Then the unchanged result contains no drawing mutations or full saves. - assert.deepEqual(result, { actionResult: 'unchanged', drawingEventCount: 0, saveRequestCount: 0, fullSaveCount: 0 }); - assert.deepEqual(saved, []); - assert.equal(timers.timers.size, 0); -}); - -test('user skips event discovery when the chart is definitively empty', async () => { - // Given an empty-chart action and a timer adapter that rejects unexpected scheduling. - const { api, saved } = createTradingViewApi(); - let timerCalls = 0; - const options = { - eventDiscoveryTimeoutMs: 0, - setTimeoutFn() { timerCalls += 1; throw new Error('drawing discovery timer must not start'); }, - }; - - // When the empty action is coalesced with discovery explicitly disabled. - const result = await coalesceTradingViewDrawingSaves(api, () => 'definitively-empty', options); - - // Then the action completes without a timer or a chart write. - assert.deepEqual(result, { actionResult: 'definitively-empty', drawingEventCount: 0, saveRequestCount: 0, fullSaveCount: 0 }); - assert.equal(timerCalls, 0); - assert.deepEqual(saved, []); -}); - -test('user regains ordinary chart saving after the drawing action fails', async () => { - // Given an action that emits a drawing event and then fails while toggling chart state. - const { api, saved, listeners } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const failure = new Error('chart toggle failed'); - - // When the chart action throws before its expected save request arrives. - const completion = coalesceTradingViewDrawingSaves(api, () => { - api.emit('drawing_event', 'order-1', 'remove'); - throw failure; - }); - - // Then the action error is preserved and the original chart subscription and method are restored. - await assert.rejects(completion, /chart toggle failed/); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(listeners.get('drawing_event')?.size, 0); - - // When a later unrelated chart save is requested. - api.saveChart('after-error'); - - // Then it is persisted synchronously through the original method. - assert.deepEqual(saved.map((entry) => entry.args), [['after-error']]); -}); - -test('user gets an exact missing-save count at the drawing-save deadline', async () => { - // Given one drawing removal whose matching save request never arrives. - const { api, saved, listeners } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const timers = createManualTimers(); - - // When the action completes and its 10 ms response deadline expires. - const completion = coalesceTradingViewDrawingSaves(api, () => api.emit('drawing_event', 'order-1', 'remove'), { timeoutMs: 10, ...timers }); - await Promise.resolve(); - timers.advance(10); - - // Then the exact count mismatch is reported and timer, method, and listener ownership are restored. - await assert.rejects(completion, /图表保存请求数量不一致:预期 1,实际 0/); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(listeners.get('drawing_event')?.size, 0); - assert.equal(timers.timers.size, 0); - - // When the user later requests an unrelated save. - api.saveChart('after-timeout'); - - // Then the ordinary save remains functional after the failed observation. - assert.deepEqual(saved.map((entry) => entry.args), [['after-timeout']]); -}); - -test('user regains the original chart method when the final coalesced save fails', async () => { - // Given a chart whose original full-save method throws. - const { api, listeners } = createTradingViewApi(); - const originalSaveChart = function saveChart() { throw new Error('save failed'); }; - api.saveChart = originalSaveChart; - const timers = createManualTimers(); - - // When one drawing event and save request settle for final persistence. - const completion = coalesceTradingViewDrawingSaves(api, () => api.emit('drawing_event', 'order-1', 'properties_changed'), { - settleQuietMs: 1, timeoutMs: 100, ...timers, - }); - await Promise.resolve(); - api.saveChart('snapshot'); - timers.advance(1); - - // Then the persistence failure does not leave the temporary save wrapper or listener installed. - await assert.rejects(completion, /save failed/); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(listeners.get('drawing_event')?.size, 0); - assert.equal(timers.timers.size, 0); -}); +import { + coalesceTradingViewDrawingSaves, + createTradingViewContinuousSaveController, + createTradingViewRemovalSaveController, +} from '../../../src/binance-orderbook-trade/core/chart-save-coalescer.js'; +import { createTradingViewApi, createManualTimers } from './chart-save-test-host.js'; const chartSaveFactories = [ { name: 'continuous closing', create: (api, options) => createTradingViewContinuousSaveController(api, options) }, @@ -1369,259 +102,386 @@ test('user gets a drawing-save error if the continuous chart host refuses the bu assert.equal(listeners.get('drawing_event').size, 0); }); -test('user restores an inherited chart save method after a coalesced action', async () => { - // Given a chart host inheriting its save method rather than owning that property. - const { api, saved, listeners } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const timers = createManualTimers(); - delete api.saveChart; - Object.setPrototypeOf(api, { saveChart: originalSaveChart }); - // When one drawing event and save settle through the temporary wrapper. - const completion = coalesceTradingViewDrawingSaves(api, () => api.emit('drawing_event', 'order-1', 'remove'), { - settleQuietMs: 1, timeoutMs: 100, ...timers, +for (const drawingCount of [1, 5, 70, 120, 199, 200]) { + test(`user receives ${drawingCount} drawing-save callbacks from one final serialization`, async () => { + // Given a real native serializer and a drawing action with a bounded quiet window. + const h = createTradingViewApi(), timers = createManualTimers(), received = []; + const original = h.api.saveChart; + const completion = coalesceTradingViewDrawingSaves(h.api, () => { + for (let index = 0; index < drawingCount; index += 1) h.event(); + return 'hidden'; + }, { settleQuietMs: 10, timeoutMs: 100, ...timers }); + + // When every native callback arrives and the final chart state settles. + await Promise.resolve(); + for (let index = 0; index < drawingCount; index += 1) h.api.saveChart(value => received.push(value)); + h.snapshot.drawings.push({ id: 'latest-line', points: [3] }); + timers.advance(10); + const result = await completion; + + // Then serialization is shared while each requested callback receives the complete current chart. + assert.deepEqual(result, { actionResult: 'hidden', drawingEventCount: drawingCount, saveRequestCount: drawingCount, fullSaveCount: 1 }); + assert.equal(h.calls.length, 1); + assert.equal(received.length, drawingCount); + assert.equal(received.every(value => value.drawings[1].id === 'latest-line'), true); + assert.equal(h.api.saveChart, original); + assert.equal(timers.timers.size, 0); }); - await Promise.resolve(); - api.saveChart('inherited-snapshot'); - timers.advance(1); - const result = await completion; +} - // Then the final snapshot uses the original receiver and the temporary own property is removed. - assert.equal(result.fullSaveCount, 1); - assert.equal(Object.hasOwn(api, 'saveChart'), false); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(saved[0].thisValue, api); - assert.deepEqual(saved.map((entry) => entry.args), [['inherited-snapshot']]); - assert.equal(listeners.get('drawing_event').size, 0); -}); +for (const ending of ['endRound', 'flush', 'stop']) { + test(`user receives all callbacks from five order captures when continuous closing reaches ${ending}`, async () => { + // Given a continuous round and a chart that identifies native order drawings. + const h = createTradingViewApi(), timers = createManualTimers(), received = []; + const original = h.api.saveChart; + h.setDrawingToolName('order-1', 'LineToolOrder'); + const controller = createTradingViewContinuousSaveController(h.api, { settleQuietMs: 10, ...timers }); + const round = controller.beginRound(); + + // When five submit captures settle before the round lifecycle is completed. + for (let index = 0; index < 5; index += 1) { + const capture = controller.beginSubmitCapture(round); + h.event('properties_changed'); + h.api.saveChart(value => received.push(value)); + h.api.saveChart(value => received.push(value)); + timers.advance(10); + assert.deepEqual(await controller.completeSubmitCapture(capture), { matched: true, status: 'captured' }); + } + assert.equal(received.length, 0); + if (ending === 'endRound') controller.endRound(round); + if (ending === 'flush') controller.flush(); + if (ending === 'stop') controller.stop(); + + // Then one native serialization delivers every accepted callback across all submit captures. + assert.equal(received.length, 10); + assert.equal(h.calls.length, 1); + assert.equal(controller.getStats().deferredSubmitSaveCount, 5); + assert.equal(controller.getStats().fullSaveCount, 1); + assert.equal(h.api.saveChart, original); + if (ending !== 'stop') controller.stop(); + assert.equal(timers.timers.size, 0); + assert.equal(h.listeners.get('drawing_event').size, 0); + }); +} -test('user preserves a foreign chart owner when a drawing action replaces the save method', async () => { - // Given a native chart action that hands saving to another operation. - const { api, listeners, saved } = createTradingViewApi(); - const foreignSaves = []; - const foreignSave = (...args) => foreignSaves.push(args); +for (const ordering of ['remove-first', 'submit-first']) { + test(`user retains both removal and submission callbacks during ${ordering} continuous work`, async () => { + // Given an active round with a native order-line capture. + const h = createTradingViewApi(), timers = createManualTimers(), received = []; + h.setDrawingToolName('order-1', 'LineToolOrder'); + const controller = createTradingViewContinuousSaveController(h.api, { settleQuietMs: 10, ...timers }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); - // When the action changes save ownership before coalescer cleanup. - const completion = coalesceTradingViewDrawingSaves(api, () => { api.saveChart = foreignSave; }, { eventDiscoveryTimeoutMs: 0 }); + // When remove and submit bursts occur in either order before the round ends. + for (const event of ordering === 'remove-first' ? ['remove', 'properties_changed'] : ['properties_changed', 'remove']) { + h.event(event); + h.api.saveChart(() => received.push(event)); + timers.advance(10); + } + await controller.completeSubmitCapture(capture); + controller.endRound(round); + controller.stop(); - // Then cleanup reports the ownership change without overwriting the new owner. - await assert.rejects(completion, /图表保存接口在操作期间发生变化/); - assert.equal(api.saveChart, foreignSave); - assert.equal(listeners.get('drawing_event').size, 0); - assert.deepEqual(saved, []); - assert.deepEqual(foreignSaves, []); -}); + // Then neither the newer burst nor final-round cleanup discards an earlier callback. + assert.deepEqual(received, ordering === 'remove-first' ? ['remove', 'properties_changed'] : ['properties_changed', 'remove']); + assert.equal(h.calls.length, ordering === 'remove-first' ? 2 : 1); + assert.equal(timers.timers.size, 0); + }); +} -test('user stops waiting at the chart-save deadline even if every drawing has requested a save', async () => { - // Given one matching drawing/save pair whose quiet window exceeds the response deadline. - const { api, saved, listeners } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const timers = createManualTimers(); +test('user receives all removal callbacks across separated bursts and an unrelated synchronous save', async () => { + // Given a removal controller that spans multiple native event bursts. + const h = createTradingViewApi(), timers = createManualTimers(), received = []; + const controller = createTradingViewRemovalSaveController(h.api, { settleQuietMs: 10, eventDiscoveryMs: 0, ...timers }); + h.event(); + h.api.saveChart(() => received.push('first')); + timers.advance(10); - // When the 10 ms deadline expires before the 20 ms quiet window. - const completion = coalesceTradingViewDrawingSaves(api, () => { - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('still-changing'); - }, { settleQuietMs: 20, timeoutMs: 10, ...timers }); - await Promise.resolve(); + // When an unrelated save occurs between the first and second removal bursts. + const result = h.api.saveChart(() => { received.push('outside'); return 19; }); + h.event(); + h.api.saveChart(() => received.push('second')); + const completion = controller.finish(); timers.advance(10); + const stats = await completion; + + // Then the first callback is flushed before the synchronous call and later callbacks finish normally. + assert.equal(result, 19); + assert.deepEqual(received, ['first', 'outside', 'second']); + assert.equal(h.calls.length, 3); + assert.deepEqual(stats, { fullSaveCount: 2, removeEventCount: 2, saveRequestCount: 2, synchronousSaveCount: 1 }); +}); + +for (const ending of ['action-error', 'missing-save-timeout', 'quiet-timeout']) { + test(`user receives accepted callbacks even when a drawing action ends with ${ending}`, async () => { + // Given an action that accepts one callback before its controlled failure boundary. + const h = createTradingViewApi(), timers = createManualTimers(), received = []; + const original = h.api.saveChart; + const failure = new Error('Native action failed'); + const completion = coalesceTradingViewDrawingSaves(h.api, () => { + h.event(); + h.api.saveChart(value => received.push(value)); + if (ending === 'missing-save-timeout') h.event(); + if (ending === 'action-error') throw failure; + }, { settleQuietMs: 20, timeoutMs: 10, ...timers }); + + // When the action fails or reaches the exact response deadline. + await Promise.resolve(); + timers.advance(10); + + // Then callbacks are delivered after restoring ownership and the original failure remains visible. + await assert.rejects(completion, ending === 'action-error' ? failure : ending === 'missing-save-timeout' + ? /预期 2,实际 1/ : /未在 10 毫秒内完成/); + assert.deepEqual(received, [h.snapshot]); + assert.equal(h.calls.length, 1); + assert.equal(h.api.saveChart, original); + assert.equal(h.listeners.get('drawing_event').size, 0); + assert.equal(timers.timers.size, 0); + }); +} - // Then a settle timeout is reported distinctly from a missing-save count and no stale save is replayed. - await assert.rejects(completion, /图表保存未在 10 毫秒内完成/); - assert.deepEqual(saved, []); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(listeners.get('drawing_event').size, 0); +test('user reaches the continuous-save maximum deadline despite repeated removal events', () => { + // Given a busy removal stream with a shorter maximum wait than its sustained duration. + const h = createTradingViewApi(), timers = createManualTimers(), received = []; + const controller = createTradingViewContinuousSaveController(h.api, { settleQuietMs: 20, maxWaitMs: 40, ...timers }); + + // When removals continue every ten milliseconds through the maximum deadline. + for (let index = 0; index < 4; index += 1) { + h.event(); + h.api.saveChart(() => received.push(index)); + timers.advance(10); + } + + // Then every accepted callback completes at the cap without waiting for another quiet period. + assert.deepEqual(received, [0, 1, 2, 3]); + assert.equal(h.calls.length, 1); assert.equal(timers.timers.size, 0); + controller.stop(); }); -test('user can stop an unmatched capture and cannot reuse its closed round lifecycle', async () => { - // Given an active close round waiting to discover an order line. - const { api, saved, listeners } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, timers); +for (const ending of ['deadline', 'flush', 'stop']) { + test(`user completes an unmatched submit capture at ${ending} without claiming position or interaction events`, async () => { + // Given an armed order capture with only position-line and interaction events. + const h = createTradingViewApi(), timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(h.api, { submitEventDiscoveryMs: 10, ...timers }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + h.setDrawingToolName('order-1', 'LineToolPosition'); + + // When unrelated events arrive and discovery reaches its chosen lifecycle boundary. + h.event('properties_changed'); + h.event('click'); + h.event('move'); + const direct = h.api.saveChart(() => 42); + if (ending === 'deadline') timers.advance(10); + else controller[ending](); + const result = await controller.completeSubmitCapture(capture); + + // Then no order event is claimed and unrelated saves remain synchronous. + assert.equal(direct, 42); + assert.deepEqual(result, { matched: false, status: ending === 'deadline' ? 'no-order-event' : ending === 'flush' ? 'flushed' : 'stopped' }); + assert.equal(controller.getStats().orderEventCount, 0); + if (ending !== 'stop') { controller.endRound(round); controller.stop(); } + assert.equal(timers.timers.size, 0); + }); +} + +test('user receives a late native save normally after stopping a matched capture', async () => { + // Given an order event whose native delayed save has not arrived. + const h = createTradingViewApi(), timers = createManualTimers(); + h.setDrawingToolName('order-1', 'LineToolOrder'); + const controller = createTradingViewContinuousSaveController(h.api, timers); const round = controller.beginRound(); const capture = controller.beginSubmitCapture(round); + h.event('properties_changed'); - // When conflicting round and capture operations are attempted during that active capture. - const duplicateRound = () => controller.beginRound(); - const duplicateCapture = () => controller.beginSubmitCapture(round); - const wrongCaptureRound = () => controller.beginSubmitCapture({}); - const wrongEndRound = () => controller.endRound({}); - const earlyEnd = () => controller.endRound(round); - const unknownCapture = controller.completeSubmitCapture({}); - - // Then overlapping or mismatched lifecycle operations fail without altering the live capture. - assert.throws(duplicateRound, /已有图表保存轮次正在执行/); - assert.throws(duplicateCapture, /已有订单线保存捕获正在执行/); - assert.throws(wrongCaptureRound, /图表保存轮次不匹配/); - assert.throws(wrongEndRound, /结束的图表保存轮次不匹配/); - assert.throws(earlyEnd, /结束图表保存轮次时仍有订单线捕获/); - await assert.rejects(unknownCapture, /订单线保存捕获不匹配/); - - // When the user stops the controller before any order-line event arrives. - const stats = controller.stop(); - const result = await controller.completeSubmitCapture(capture); + // When stopping precedes the delayed native callback request. + controller.stop(); + const captured = await controller.completeSubmitCapture(capture); + const returned = h.api.saveChart(() => 23); - // Then capture discovery ends immediately, resources are released, and the stopped lifecycle rejects reuse. - assert.deepEqual(result, { matched: false, status: 'stopped' }); - assert.deepEqual(stats, expectedContinuousStats()); + // Then the capture is closed and ordinary native saving resumes without dropping a request. + assert.deepEqual(captured, { matched: true, status: 'captured' }); + assert.equal(returned, 23); + assert.equal(h.calls.length, 1); assert.equal(timers.timers.size, 0); - assert.equal(listeners.get('drawing_event').size, 0); - assert.deepEqual(saved, []); - assert.throws(() => controller.beginRound(), /连续图表保存控制器已停止/); - assert.throws(() => controller.beginSubmitCapture(round), /连续图表保存控制器已停止/); - assert.throws(() => controller.stop(), /连续图表保存控制器已停止/); -}); + assert.throws(() => controller.beginRound(), /已停止/); + assert.throws(() => controller.stop(), /已停止/); +}); + +for (const mode of ['continuous', 'removal', 'action']) { + test(`user regains save ownership when the native ${mode} serializer throws`, async () => { + // Given a native serialization failure and an accepted callback. + const h = createTradingViewApi(), timers = createManualTimers(); + const original = h.api.saveChart; + const failure = new Error('Native serialization failed'); + h.setSaveFailure(failure); + let controller; + let completion; + if (mode === 'continuous') controller = createTradingViewContinuousSaveController(h.api, timers); + if (mode === 'removal') controller = createTradingViewRemovalSaveController(h.api, { settleQuietMs: 10, eventDiscoveryMs: 0, ...timers }); + if (mode === 'action') completion = coalesceTradingViewDrawingSaves(h.api, () => h.event(), { settleQuietMs: 10, ...timers }); + else h.event(); + let delivered = false; + h.api.saveChart(() => { delivered = true; }); + + // When final delivery attempts the failing native serialization exactly once. + if (mode === 'continuous') assert.throws(() => controller.stop(), failure); + else { + await Promise.resolve(); + if (mode === 'removal') completion = controller.finish(); + timers.advance(10); + await assert.rejects(completion, failure); + } -test('user can stop a matched capture before its delayed native save arrives', async () => { - // Given an armed capture and a matching order line with no save request yet. - const { api, saved, setDrawingToolName, listeners } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, timers); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); - setDrawingToolName('order-1', 'LineToolOrder'); + // Then no snapshot is fabricated and all temporary ownership is released. + assert.equal(delivered, false); + assert.equal(h.calls.length, 1); + assert.equal(h.api.saveChart, original); + assert.equal(h.listeners.get('drawing_event').size, 0); + assert.equal(timers.timers.size, 0); + }); +} - // When the order-line event arrives and the user stops before the save callback. - api.emit('drawing_event', 'order-1', 'properties_changed'); - const stats = controller.stop(); - const result = await controller.completeSubmitCapture(capture); +for (const mode of ['removal', 'action']) { + test(`user waits for delayed ${mode} drawing discovery before delivering callbacks`, async () => { + // Given a lifecycle started before native drawing events become visible. + const h = createTradingViewApi(), timers = createManualTimers(), received = []; + let controller; + const completion = mode === 'removal' + ? (controller = createTradingViewRemovalSaveController(h.api, { settleQuietMs: 10, eventDiscoveryMs: 30, ...timers })).finish() + : coalesceTradingViewDrawingSaves(h.api, () => 'changed', { settleQuietMs: 10, eventDiscoveryTimeoutMs: 30, ...timers }); + await Promise.resolve(); - // Then the matched capture closes without inventing a save or leaving a wrapper and timer behind. - assert.deepEqual(result, { matched: true, status: 'captured' }); - assert.deepEqual(stats, expectedContinuousStats({ orderEventCount: 1 })); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(listeners.get('drawing_event').size, 0); - assert.equal(timers.timers.size, 0); - assert.deepEqual(saved, []); + // When a delayed event arrives before discovery expires and its callback reaches the quiet deadline. + timers.advance(5); + h.event(); + h.api.saveChart(value => received.push(value)); + await Promise.resolve(); + timers.advance(10); + const result = await completion; + + // Then delayed native work is included and all owned timers and subscriptions are removed. + assert.equal(result.fullSaveCount, 1); + assert.deepEqual(received, [h.snapshot]); + assert.equal(timers.timers.size, 0); + assert.equal(h.listeners.get('drawing_event').size, 0); + if (controller) await assert.rejects(controller.finish(), /已结束/); + }); - // When the delayed native save finally arrives after stopping. - api.saveChart('late-order-snapshot'); + test(`user finishes empty ${mode} discovery without inventing a native save`, async () => { + // Given a chart with no native drawing events during its discovery window. + const h = createTradingViewApi(), timers = createManualTimers(); + const completion = mode === 'removal' + ? createTradingViewRemovalSaveController(h.api, { eventDiscoveryMs: 10, ...timers }).finish() + : coalesceTradingViewDrawingSaves(h.api, () => 'empty', { eventDiscoveryTimeoutMs: 10, ...timers }); - // Then the original synchronous method persists it normally. - assert.deepEqual(saved.map((entry) => entry.args), [['late-order-snapshot']]); -}); + // When the complete discovery deadline passes without any event or callback. + await Promise.resolve(); + timers.advance(10); + const result = await completion; -test('user leaves unrelated saves synchronous when an order drawing disappears before inspection', async () => { - // Given an armed capture whose chart no longer has the referenced drawing. - const { api, saved } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { submitEventDiscoveryMs: 10, ...timers }); - const round = controller.beginRound(); - const capture = controller.beginSubmitCapture(round); + // Then no native serialization is fabricated and lifecycle resources are released. + assert.equal(result.fullSaveCount, 0); + assert.deepEqual(h.calls, []); + assert.equal(timers.timers.size, 0); + assert.equal(h.listeners.get('drawing_event').size, 0); + }); +} - // When the stale drawing event cannot be inspected and discovery reaches its deadline. - api.emit('drawing_event', 'already-removed-order', 'properties_changed'); - api.saveChart('unrelated-current-snapshot'); +test('user restores an inherited native save method before delivering callback snapshots', async () => { + // Given a chart that inherits its save method from a prototype. + const h = createTradingViewApi(), timers = createManualTimers(), received = []; + const original = h.api.saveChart; + delete h.api.saveChart; + Object.setPrototypeOf(h.api, { saveChart: original }); + + // When a drawing action captures a callback and settles. + const completion = coalesceTradingViewDrawingSaves(h.api, () => { + h.event(); + h.api.saveChart(value => { assert.equal(Object.hasOwn(h.api, 'saveChart'), false); received.push(value); }); + }, { settleQuietMs: 10, ...timers }); + await Promise.resolve(); timers.advance(10); - const result = await controller.completeSubmitCapture(capture); + await completion; - // Then no order line is claimed and the unrelated current snapshot is saved normally. - assert.deepEqual(result, { matched: false, status: 'no-order-event' }); - assert.deepEqual(saved.map((entry) => entry.args), [['unrelated-current-snapshot']]); - controller.endRound(round); - assert.deepEqual(controller.stop(), expectedContinuousStats()); - assert.equal(timers.timers.size, 0); + // Then ownership returns to the original prototype and the callback receives the native JSON snapshot. + assert.equal(h.api.saveChart, original); + assert.equal(Object.hasOwn(h.api, 'saveChart'), false); + assert.deepEqual(received, [h.snapshot]); }); -test('user receives a deferred round-save failure after stop still releases all chart resources', async () => { - // Given a settled order capture whose original chart save fails on final persistence. - const { api, listeners, setDrawingToolName } = createTradingViewApi(); - const failure = new Error('Deferred round save failed'); - const originalSaveChart = () => { throw failure; }; - api.saveChart = originalSaveChart; - const timers = createManualTimers(); - const controller = createTradingViewContinuousSaveController(api, { settleQuietMs: 20, ...timers }); +test('user cannot overlap or end a continuous round with an unresolved capture', async () => { + // Given one active round and its unresolved order-line capture. + const h = createTradingViewApi(), timers = createManualTimers(); + const controller = createTradingViewContinuousSaveController(h.api, timers); const round = controller.beginRound(); const capture = controller.beginSubmitCapture(round); - setDrawingToolName('order-1', 'LineToolOrder'); - api.emit('drawing_event', 'order-1', 'properties_changed'); - api.saveChart('pending-round'); - timers.advance(20); - const captured = await controller.completeSubmitCapture(capture); - - // When the user stops and the deferred final snapshot fails to persist. - const stop = () => controller.stop(); - - // Then the persistence error survives while capture, event, method, and timer resources are closed. - assert.deepEqual(captured, { matched: true, status: 'captured' }); - assert.throws(stop, (error) => error === failure); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(listeners.get('drawing_event').size, 0); - assert.equal(timers.timers.size, 0); - assert.throws(() => controller.beginRound(), /连续图表保存控制器已停止/); -}); -test('user finishes an empty removal lifecycle at its discovery deadline', async () => { - // Given an empty chart and a manual 10 ms removal discovery window. - const { api, saved, listeners } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const timers = createManualTimers(); - const controller = createTradingViewRemovalSaveController(api, { eventDiscoveryMs: 10, ...timers }); - let finished = false; - - // When finish waits through the first nine milliseconds without a removal. - const completion = controller.finish().then((result) => { finished = true; return result; }); - timers.advance(9); - - // Then discovery remains pending and no snapshot is invented. - assert.equal(finished, false); - assert.deepEqual(saved, []); - - // When the final discovery millisecond elapses. - timers.advance(1); - const result = await completion; - - // Then the empty lifecycle releases ownership and refuses a second finish. - assert.deepEqual(result, { fullSaveCount: 0, removeEventCount: 0, saveRequestCount: 0, synchronousSaveCount: 0 }); - assert.equal(finished, true); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(listeners.get('drawing_event').size, 0); + // When callers attempt mismatched or overlapping round operations. + const anotherRound = () => controller.beginRound(); + const anotherCapture = () => controller.beginSubmitCapture(round); + const earlyEnd = () => controller.endRound(round); + const wrongRound = () => controller.endRound({}); + + // Then lifecycle assertions prevent overlap while stop completes the existing capture exactly once. + assert.throws(anotherRound, /已有图表保存轮次/); + assert.throws(anotherCapture, /已有订单线保存捕获/); + assert.throws(earlyEnd, /仍有订单线捕获/); + assert.throws(wrongRound, /轮次不匹配/); + await assert.rejects(controller.completeSubmitCapture({}), /捕获不匹配/); + controller.stop(); + assert.deepEqual(await controller.completeSubmitCapture(capture), { matched: false, status: 'stopped' }); assert.equal(timers.timers.size, 0); - await assert.rejects(controller.finish(), /删除事件保存合并已结束/); }); -test('user preserves a foreign save owner installed before the first removal event', async () => { - // Given a removal lifecycle whose monitored save method is replaced by another chart operation. - const { api, saved, listeners } = createTradingViewApi(); - const timers = createManualTimers(); - const controller = createTradingViewRemovalSaveController(api, { eventDiscoveryMs: 0, ...timers }); - const foreignSaves = []; - const foreignSave = (...args) => foreignSaves.push(args); - api.saveChart = foreignSave; +test('user can flush and continue the same continuous round without losing either callback batch', async () => { + // Given a continuous round with a declared native order-line adapter. + const h = createTradingViewApi(), timers = createManualTimers(), received = []; + h.setDrawingToolName('order-1', 'LineToolOrder'); + const controller = createTradingViewContinuousSaveController(h.api, { settleQuietMs: 10, ...timers }); + const round = controller.beginRound(); - // When the first removal arrives under foreign ownership and the lifecycle finishes. - api.emit('drawing_event', 'order-1', 'remove'); - api.saveChart('foreign-snapshot'); - const completion = controller.finish(); + // When one captured callback is flushed before another capture in the same round. + for (let index = 0; index < 2; index += 1) { + const capture = controller.beginSubmitCapture(round); + h.event('properties_changed'); + h.api.saveChart(() => received.push(index)); + timers.advance(10); + await controller.completeSubmitCapture(capture); + if (index === 0) controller.flush(); + } + controller.endRound(round); + controller.stop(); - // Then ownership conflict prevents stale replay and leaves the foreign save method intact. - await assert.rejects(completion, /图表保存接口正被其他操作占用/); - assert.equal(api.saveChart, foreignSave); - assert.deepEqual(saved, []); - assert.deepEqual(foreignSaves, [['foreign-snapshot']]); - assert.equal(listeners.get('drawing_event').size, 0); + // Then each detached batch is serialized once and both callback requests complete. + assert.deepEqual(received, [0, 1]); + assert.equal(h.calls.length, 2); assert.equal(timers.timers.size, 0); }); -test('user restores removal monitoring if a host makes the save method read-only between bursts', async () => { - // Given an installed removal monitor whose host changes the save property to read-only. - const { api, saved, listeners } = createTradingViewApi(); - const originalSaveChart = api.saveChart; - const timers = createManualTimers(); - const controller = createTradingViewRemovalSaveController(api, { eventDiscoveryMs: 0, ...timers }); - Object.defineProperty(api, 'saveChart', { writable: false }); +test('user preserves settled round callbacks when a foreign owner replaces the restored native save method', async () => { + // Given a captured order callback deferred until the continuous round ends. + const h = createTradingViewApi(), timers = createManualTimers(), received = []; + h.setDrawingToolName('order-1', 'LineToolOrder'); + const controller = createTradingViewContinuousSaveController(h.api, { settleQuietMs: 10, ...timers }); + const round = controller.beginRound(); + const capture = controller.beginSubmitCapture(round); + h.event('properties_changed'); + h.api.saveChart(value => received.push(value)); + timers.advance(10); + await controller.completeSubmitCapture(capture); + const foreign = () => 83; + h.api.saveChart = foreign; - // When a removal cannot install its temporary burst wrapper. - api.emit('drawing_event', 'order-1', 'remove'); - const completion = controller.finish(); + // When the round ends after native-save ownership has changed. + controller.endRound(round); + controller.stop(); - // Then the assignment error is reported after the original property and listener are restored. - await assert.rejects(completion, TypeError); - assert.equal(api.saveChart, originalSaveChart); - assert.equal(Object.getOwnPropertyDescriptor(api, 'saveChart').writable, true); - assert.equal(listeners.get('drawing_event').size, 0); - assert.equal(timers.timers.size, 0); - assert.deepEqual(saved, []); + // Then the original accepted callback completes without involving or replacing the new owner. + assert.deepEqual(received, [h.snapshot]); + assert.equal(h.calls.length, 1); + assert.equal(h.api.saveChart, foreign); }); diff --git a/test/unit/binance-orderbook-trade/chart-save-test-host.js b/test/unit/binance-orderbook-trade/chart-save-test-host.js new file mode 100644 index 0000000..8650052 --- /dev/null +++ b/test/unit/binance-orderbook-trade/chart-save-test-host.js @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; + +/** The inspected Binance save API invokes a callback synchronously with JSON chart state. */ +export function createTradingViewApi() { + const listeners = new Map(); + const calls = []; + const snapshot = { drawings: [{ id: 'user-line', points: [1, 2] }] }; + const tools = new Map(); + let failure = null; + const api = { + activeChart() { return { getShapeById(id) { + if (!tools.has(id)) throw new Error('There is no such shape'); + return { lineDataSource: () => ({ toolname: tools.get(id) }) }; + } }; }, + saveChart(callback, options) { + calls.push({ receiver: this, args: [...arguments] }); + if (failure) throw failure; + if (typeof callback !== 'function') throw new TypeError('Native callback required'); + return callback(JSON.parse(JSON.stringify(options?.includeDrawings === false ? { drawings: [] } : snapshot))); + }, + subscribe(name, callback) { + assert.equal(name, 'drawing_event'); + if (!listeners.has(name)) listeners.set(name, new Set()); + listeners.get(name).add(callback); + }, + unsubscribe(name, callback) { listeners.get(name).delete(callback); }, + emit(name, ...args) { for (const callback of [...(listeners.get(name) || [])]) callback(...args); }, + }; + return { + api, calls, saved: calls, snapshot, listeners, + setSaveFailure(error) { failure = error; }, + setDrawingToolName(id, name) { tools.set(id, name); }, + event(type = 'remove') { api.emit('drawing_event', 'order-1', type); }, + }; +} + +export function createManualTimers() { + let now = 0; + let sequence = 0; + const timers = new Map(); + const setTimeoutFn = (callback, delayMs) => { + sequence += 1; + timers.set(sequence, { callback, at: now + delayMs }); + return sequence; + }; + const clearTimeoutFn = (timerId) => timers.delete(timerId); + const advance = (elapsedMs) => { + const deadline = now + elapsedMs; + while (true) { + const next = Array.from(timers.entries()) + .filter(([, timer]) => timer.at <= deadline) + .sort((left, right) => left[1].at - right[1].at || left[0] - right[0])[0]; + if (!next) break; + const [timerId, timer] = next; + timers.delete(timerId); + now = timer.at; + timer.callback(); + } + now = deadline; + }; + return { advance, clearTimeoutFn, setTimeoutFn, timers }; +} + diff --git a/test/unit/binance-orderbook-trade/remaining-core-boundaries.test.js b/test/unit/binance-orderbook-trade/remaining-core-boundaries.test.js index 514a10c..eb3dbe2 100644 --- a/test/unit/binance-orderbook-trade/remaining-core-boundaries.test.js +++ b/test/unit/binance-orderbook-trade/remaining-core-boundaries.test.js @@ -49,8 +49,10 @@ function createNativeChartHost() { activeChart() { return { getShapeById: id => shapes.get(String(id)) }; }, - saveChart(...args) { - saved.push({ receiver: this, args }); + saveChart(callback, options) { + saved.push({ receiver: this, args: [...arguments] }); + if (typeof callback !== 'function') throw new TypeError('Native chart save requires a callback'); + return callback({ drawingIds: options?.includeDrawings === false ? [] : [...shapes.keys()] }); }, subscribe(name, listener) { if (!listeners.has(name)) listeners.set(name, new Set()); @@ -124,7 +126,10 @@ test('user receives native chart event, save, metadata, and held-property behavi host.api.emit('drawing_event', '42', 'properties_changed'); host.api.unsubscribe('drawing_event', listener); host.api.emit('drawing_event', '42', 'remove'); - Reflect.apply(host.api.saveChart, receiver, ['snapshot', 2]); + const snapshots = []; + const saveCallback = snapshot => { snapshots.push(snapshot); return 17; }; + const options = { includeDrawings: false }; + const result = Reflect.apply(host.api.saveChart, receiver, [saveCallback, options]); const original = host.api.saveChart; const attempted = host.holdCurrentSave(); const replacement = () => 'replacement'; @@ -132,7 +137,9 @@ test('user receives native chart event, save, metadata, and held-property behavi // Then the boundary preserves each native operation and explicitly refuses held-property replacement. assert.deepEqual(received, [['42', 'properties_changed']]); - assert.deepEqual(host.saved, [{ receiver, args: ['snapshot', 2] }]); + assert.deepEqual(host.saved, [{ receiver, args: [saveCallback, options] }]); + assert.deepEqual(snapshots, [{ drawingIds: [] }]); + assert.equal(result, 17); assert.equal(host.listeners.get('drawing_event').size, 0); assert.equal(host.api.activeChart().getShapeById(42).lineDataSource().toolname, 'LineToolOrder'); assert.equal(host.api.activeChart().getShapeById('absent'), undefined); @@ -369,26 +376,26 @@ test('user drops an unobserved old depth symbol without reviving it when its nat assert.equal(host.sockets.length, 1); }); -test('user persists the newer burst only when save ownership changes after an older deferred snapshot', async t => { - // Given one completed order capture has deferred its older cumulative chart snapshot. +test('user delivers both deferred callbacks when save ownership changes during a newer burst', async t => { + // Given one completed order capture has deferred an accepted native save callback. t.mock.timers.enable({ apis: ['setTimeout'] }); const host = createNativeChartHost(); host.shapes.set('order-1', { lineDataSource: () => ({ toolname: 'LineToolOrder' }) }); - host.shapes.set('order-2', { lineDataSource: () => ({ toolname: 'LineToolOrder' }) }); + const snapshots = []; const controller = createTradingViewContinuousSaveController(host.api); const round = controller.beginRound(); const first = controller.beginSubmitCapture(round); host.api.emit('drawing_event', 'order-1', 'properties_changed'); - host.api.saveChart('older-snapshot'); + host.api.saveChart(snapshot => snapshots.push({ request: 'older', snapshot })); t.mock.timers.tick(120); assert.deepEqual(await controller.completeSubmitCapture(first), { matched: true, status: 'captured' }); assert.deepEqual(host.saved, []); // When another owner replaces saving during the newer captured burst. + host.shapes.set('order-2', { lineDataSource: () => ({ toolname: 'LineToolOrder' }) }); const second = controller.beginSubmitCapture(round); host.api.emit('drawing_event', 'order-2', 'properties_changed'); - const receiver = { caller: 'native-chart' }; - Reflect.apply(host.api.saveChart, receiver, ['newer-snapshot']); + host.api.saveChart(snapshot => snapshots.push({ request: 'newer', snapshot })); const foreignSaves = []; const foreignSave = (...args) => foreignSaves.push(args); host.api.saveChart = foreignSave; @@ -397,9 +404,15 @@ test('user persists the newer burst only when save ownership changes after an ol controller.endRound(round); const stats = controller.stop(); - // Then only the newer captured snapshot reaches its original owner and stale data is never replayed. + // Then one current serialization reaches both accepted callbacks and the foreign owner stays intact. assert.deepEqual(captured, { matched: true, status: 'save-chart-replaced' }); - assert.deepEqual(host.saved, [{ receiver, args: ['newer-snapshot'] }]); + assert.equal(host.saved.length, 1); + assert.equal(host.saved[0].receiver, host.api); + assert.deepEqual(snapshots, [ + { request: 'older', snapshot: { drawingIds: ['order-1', 'order-2'] } }, + { request: 'newer', snapshot: { drawingIds: ['order-1', 'order-2'] } }, + ]); + assert.notEqual(snapshots[0].snapshot, snapshots[1].snapshot); assert.deepEqual(foreignSaves, []); assert.equal(host.api.saveChart, foreignSave); assert.equal(host.listeners.get('drawing_event').size, 0); @@ -420,13 +433,16 @@ test('user leaves saves synchronous when a native shape has no order-tool metada // When the shape event arrives and discovery has one millisecond left. host.api.emit('drawing_event', 'unknown-shape', 'properties_changed'); - host.api.saveChart('native-unrelated-snapshot'); + const snapshots = []; + const returned = host.api.saveChart(snapshot => { snapshots.push(snapshot); return 19; }); t.mock.timers.tick(249); // Then no order event or save wrapper is invented from missing metadata. assert.equal(capture.status, null); assert.equal(host.api.saveChart, originalSave); - assert.deepEqual(host.saved.map(save => save.args), [['native-unrelated-snapshot']]); + assert.equal(host.saved.length, 1); + assert.deepEqual(snapshots, [{ drawingIds: ['unknown-shape'] }]); + assert.equal(returned, 19); // When the full native event discovery window expires. t.mock.timers.tick(1); diff --git a/test/unit/binance-strategy27-events/strategy-migration-boundaries.test.js b/test/unit/binance-strategy27-events/strategy-migration-boundaries.test.js index 0f2cc70..b31d1b3 100644 --- a/test/unit/binance-strategy27-events/strategy-migration-boundaries.test.js +++ b/test/unit/binance-strategy27-events/strategy-migration-boundaries.test.js @@ -2,38 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { JSDOM } from 'jsdom'; import { - captureStrategyError, createStrategyDigestGate, createStrategyGmBoundary, createStrategyPromptBoundary, createStrategyShapeBoundary, installStrategyClock, + captureStrategyError, createStrategyDigestGate, createStrategyGmBoundary, createStrategyPromptBoundary, installStrategyClock, observeStrategyCondition, observeStrategyQueries, } from '../../helpers/strategy-migration-boundaries.js'; -for (const [label, config] of [ - ['valid', { shapeId: 'native-shape', points: [{ time: 10, price: 1.25 }], listedShapes: [{ id: 'native-shape' }] }], - ['missing', { shapeId: null, points: null, listedShapes: null }], - ['malformed', { shapeId: '', points: [], listedShapes: [{ id: 42 }] }], -]) { - test(`user observes ${label} native shape outputs without fixture normalization`, async () => { - // Given a host boundary is configured with the exact native return values - const boundary = createStrategyShapeBoundary(config); - const point = { time: 10, price: 1.25 }; - const options = { shape: 'arrow_up' }; - - // When the host creates, reads, lists, and removes a shape - const id = await boundary.chart.createShape(point, options); - const points = boundary.chart.getShapeById(id).getPoints(); - const list = boundary.chart.getAllShapes(); - boundary.chart.removeEntity('requested-removal'); - - // Then every native value and caller-owned operation is preserved exactly - assert.equal(id, config.shapeId); - assert.equal(points, config.points); - assert.equal(list, config.listedShapes); - assert.equal(boundary.created.length, 1); - assert.equal(boundary.created[0].point, point); - assert.equal(boundary.created[0].options, options); - assert.deepEqual(boundary.removed, ['requested-removal']); - }); -} - test('user observes native interval cadence, arguments, independent ownership and cancellation under the virtual clock', (t) => { // Given two native browser intervals and a shared virtual Date const dom = new JSDOM(''); diff --git a/test/unit/binance-strategy29-bollinger/source-regressions.test.js b/test/unit/binance-strategy29-bollinger/source-regressions.test.js index ba3169c..6341aad 100644 --- a/test/unit/binance-strategy29-bollinger/source-regressions.test.js +++ b/test/unit/binance-strategy29-bollinger/source-regressions.test.js @@ -19,7 +19,7 @@ test('user installs Strategy29 with only the reviewed sandbox capabilities', asy const [entrySource, remoteSource] = await Promise.all(paths.map(path => readFile(path, 'utf8'))); // Then metadata and page coordination keep the exact reviewed grants and transport boundary. - assert.equal(readUserscriptVersion(entrySource), '0.5.7'); + assert.equal(readUserscriptVersion(entrySource), '0.5.8'); assert.deepEqual( [...entrySource.matchAll(/^\/\/ @grant\s+(\S+)\s*$/gm)].map(match => match[1]), ['unsafeWindow', 'GM_getValue', 'GM_setValue'], diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index 27a075e..3376fdb 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -24,7 +24,7 @@ test('user installs Strategy29 with its independent observation-only identity', // Then the installer retains its pinned identity and declared observation boundary. assert.equal(contract.name, '【自写】Binance Strategy 29 布林带信号'); assert.equal(contract.namespace, 'binance.strategy29.bollinger'); - assert.equal(contract.version, '0.5.7'); + assert.equal(contract.version, '0.5.8'); assert.equal(contract.runAt, 'document-start'); assert.equal(contract.updateURL, 'https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js'); assert.equal(contract.downloadURL, contract.updateURL); @@ -70,7 +70,7 @@ test('user identifies Strategy27 and its declared local gateway capabilities', ( // Then the installer retains its identity and exact gateway capability boundary. assert.equal(contract.name, '【自写】Binance Strategy 27 事件标注'); assert.equal(contract.namespace, 'binance.strategy27.events'); - assert.equal(contract.version, '0.6.7'); + assert.equal(contract.version, '0.6.8'); assert.equal(contract.runAt, 'document-idle'); assert.equal(contract.updateURL, contract.downloadURL); assert.deepEqual(contract.matches, [