From 3b08a4860046eb601f974a6ce7c7d6d70fe88780 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:46:26 +0800 Subject: [PATCH 1/2] feat(strategy27): display independent compound candidates - validate the versioned candidate stream with independent lifecycle ownership - show distinct native arrows and labels without hiding simultaneous rules - integrate bounded candidate history into the existing draggable panel - publish the tested 0.4.0 artifact with unchanged installation identity --- README.md | 2 +- README.zh-CN.md | 2 +- docs/binance-strategy27-events-development.md | 104 +- scripts/binance-strategy27-events.user.js | 939 ++++++++++++++++-- .../core/compound-candidate-annotation.js | 70 ++ .../core/compound-candidate-client.js | 80 ++ .../core/compound-candidate-contract.js | 181 ++++ .../core/compound-candidate-controller.js | 166 ++++ .../core/compound-candidate-lifecycle.js | 130 +++ .../core/event-annotation.js | 3 +- .../dom/strategy27-event-panel.js | 133 ++- .../dom/tradingview-compound-layer.js | 121 +++ .../dom/tradingview-event-layer.js | 74 +- src/binance-strategy27-events/index.user.js | 21 +- .../compound-candidate-controller.test.js | 378 +++++++ .../strategy27-entrypoint.test.js | 188 ++++ .../strategy27-event-panel.test.js | 70 ++ .../tradingview-compound-layer.test.js | 191 ++++ .../strategy27-compound-candidates.json | 176 ++++ .../strategy27-compound-panel-preview.mjs | 53 + .../strategy27-native-drawing-probe.mjs | 89 ++ .../compound-candidate-annotation.test.js | 59 ++ .../compound-candidate-client.test.js | 126 +++ .../compound-candidate-contract.test.js | 104 ++ .../compound-candidate-lifecycle.test.js | 121 +++ test/unit/userscript-release-contract.test.js | 2 +- 26 files changed, 3455 insertions(+), 128 deletions(-) create mode 100644 src/binance-strategy27-events/core/compound-candidate-annotation.js create mode 100644 src/binance-strategy27-events/core/compound-candidate-client.js create mode 100644 src/binance-strategy27-events/core/compound-candidate-contract.js create mode 100644 src/binance-strategy27-events/core/compound-candidate-controller.js create mode 100644 src/binance-strategy27-events/core/compound-candidate-lifecycle.js create mode 100644 src/binance-strategy27-events/dom/tradingview-compound-layer.js create mode 100644 test/dom/binance-strategy27-events/compound-candidate-controller.test.js create mode 100644 test/dom/binance-strategy27-events/strategy27-entrypoint.test.js create mode 100644 test/dom/binance-strategy27-events/tradingview-compound-layer.test.js create mode 100644 test/fixtures/strategy27-compound-candidates.json create mode 100644 test/manual/strategy27-compound-panel-preview.mjs create mode 100644 test/manual/strategy27-native-drawing-probe.mjs create mode 100644 test/unit/binance-strategy27-events/compound-candidate-annotation.test.js create mode 100644 test/unit/binance-strategy27-events/compound-candidate-client.test.js create mode 100644 test/unit/binance-strategy27-events/compound-candidate-contract.test.js create mode 100644 test/unit/binance-strategy27-events/compound-candidate-lifecycle.test.js diff --git a/README.md b/README.md index b7bffe2..9e33af0 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ This repository is the source of truth and distribution point for the scripts. G | Binance orderbook one-click order entry | Binance Futures | Click an orderbook price, infer the current open/close tab, fill quantity, and submit an order with a multiplier panel | This repository | [Install][install-binance-orderbook-trade] | | Binance Futures data panel | Binance Futures | Overlay open interest, long/short ratios, funding rate, basis, and directional signals | This repository | [Install][install-binance-trading-data] | | Binance CoinMarketCap data panel | Binance Futures | Show CoinMarketCap valuation, supply, and liquidity data for the current symbol | This repository | [Install][install-binance-coinmarketcap-data] | -| Binance Strategy 27 event annotations | Binance Futures | Draw VPS-computed Strategy 27 event markers and outcome notes on the matching one-second chart | This repository | [Install][install-binance-strategy27-events] | +| Binance Strategy 27 event annotations | Binance Futures | Draw VPS-computed order-flow observations and exploratory compound candidates on the matching one-second chart | This repository | [Install][install-binance-strategy27-events] | | Auto refresh | Any page | Refresh selected pages on a configurable schedule | This repository | [Install][install-auto-refresh] | | CoinMarketCap valuation labels | CoinMarketCap | Label and highlight circulating market cap and FDV / total valuation in the Chinese UI | This repository | [Install][install-coinmarketcap-valuation-helper] | | m3u8 downloader | Video pages | Enhanced m3u8 detection and export workflow for allowlisted video sites | This repository | [Install][install-m3u8] | diff --git a/README.zh-CN.md b/README.zh-CN.md index 0ac10ef..9bdcd5a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,7 +38,7 @@ | 【自写】Binance 订单簿单击下单 | Binance Futures | 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 | 本仓库 | [`点击安装`][install-binance-orderbook-trade] | | 【自写】Binance 合约交易数据面板 | Binance Futures | 在合约交易页面叠加浮动面板,定时拉取交易数据(持仓量、多空比、资金费率等)并显示当前值 + 多空信号 | 本仓库 | [`点击安装`][install-binance-trading-data] | | 【自写】Binance CoinMarketCap 数据面板 | Binance Futures | 在 Binance 合约页面显示当前币种的 CoinMarketCap 估值、供应量和流动性数据 | 本仓库 | [`点击安装`][install-binance-coinmarketcap-data] | -| 【自写】Binance Strategy 27 事件标注 | Binance Futures | 在匹配币种的一秒图表标注 VPS 已计算的 Strategy 27 事件和后续结果 | 本仓库 | [`点击安装`][install-binance-strategy27-events] | +| 【自写】Binance Strategy 27 事件标注 | Binance Futures | Draw VPS-computed order-flow observations and exploratory compound candidates on the matching one-second chart | 本仓库 | [`点击安装`][install-binance-strategy27-events] | | 【自写】定时刷新指定页面 | Any page | 指定页面按设定时间自动刷新 | 本仓库 | [`点击安装`][install-auto-refresh] | | 【自写】CoinMarketCap 估值口径命名 | CoinMarketCap | 在中文币种页面左上角统计区标注并高亮流通市值和 FDV / 总估值 | 本仓库 | [`点击安装`][install-coinmarketcap-valuation-helper] | | 【改写】m3u8-downloader | Video pages | m3u8 下载增强脚本,仅在白名单视频站启用 | 本仓库 | [`点击安装`][install-m3u8] | diff --git a/docs/binance-strategy27-events-development.md b/docs/binance-strategy27-events-development.md index d299544..48e3988 100644 --- a/docs/binance-strategy27-events-development.md +++ b/docs/binance-strategy27-events-development.md @@ -38,11 +38,12 @@ still stop immediately. ## Rendering Contract -- Every event owns exactly one chart marker throughout its lifecycle. Updates, - closure, and later outcomes update that marker without creating TradingView - text shapes. +- An ordinary event receives one chart marker when a directional observation + first appears. Its first marker direction, color, time and position remain + immutable. Neutral observations do not create yellow flags. Later updates + refresh panel facts without changing that directional marker. - Every time-based marker waits for its exact matching one-second candle before - creation, including neutral flags, because TradingView otherwise adjusts a + creation, because TradingView otherwise adjusts a 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 @@ -53,8 +54,8 @@ still stop immediately. it never depends on a future bar. A malformed candle or the absence of both an exact and prior candle stops rendering with an explicit contract error. - A single draggable panel shows the selected event's four-force facts, - immediate price response, trigger reasons, close reason, and ordered outcome - horizons. Dragging the header keeps the panel inside the viewport and stores + immediate price response, trigger reasons and close reason, but no future + outcome horizons. Dragging the header keeps the panel inside the viewport and stores its last position in Tampermonkey private storage. This prevents persistent multiline notes from overlapping one-second bars while preserving the operator's preferred placement across chart context changes and reloads. @@ -87,6 +88,97 @@ discontinuities abort the request and remove only those transient entities. Marker count and age are bounded on the chart; the panel retains at most eight events. +## Compound Candidate Extension (Local Implementation) + +ADR 032 in CorsairQuant owns the server-side rule and transport contract. The +browser does not reconstruct candidates from ordinary events or recalculate +market evidence. The client, lifecycle, panel, native chart layer and optional-job +controller are wired into the entrypoint and tested together. The source and +generated install artifact are version 0.4.0 with identical metadata headers. +The generated artifact passes syntax, release-contract and isolated execution +checks, including candidate delivery, paired entities, clear and context stop. +Publication and Binance operator-page validation remain outstanding. +Do not treat source unit tests or the panel fixture as deployment evidence. + +- The compound client has a separate cursor for + `/v1/strategy27/compound-candidates`. Non-JSON HTTP 404 disables only that + client until restart. Validated HTTP 503 `compound_unavailable` and + `redis_unavailable` clear only compound state and retry after two seconds. + Typed request transport failures retain the cursor. Other contract failures + are not retried. Cancellation is checked after request and async validation + boundaries so a stopped context cannot publish a late status. +- Canonical Python/JavaScript SHA-256 identities are checked against synthetic + Python detector fixtures. Wire decimals remain exact strings for validation; + numeric conversion is limited to presentation. Small nonzero display + amounts retain significant digits instead of rounding to zero. +- The independent lifecycle holds at most 80 candidates for two hours measured + from the original decision time. Exact replay does not refresh that age or + create another marker. Heartbeats do not clear history. Epoch changes require + `stream_state`; symbol filtering permits increasing sequence gaps, not + regressions. Capacity eviction follows decision time and candidate ID with a + monotonic cutoff so old replay cannot resurrect evicted observations. +- Base rule identity is `(family, direction, profile_id)`. Reinforcement also + includes `parent_candidate_id` in its displayed lineage identity. Each + occurrence has its own candidate ID; different rules or parents at the same + second are never collapsed into one record. +- The same draggable panel has independent eight-row ordinary and compound + histories. Ordinary clear/update operations do not clear compound history or + its connection-status element. Both histories can be selected; follow-latest + chooses the latest observation without removing either list. +- The compound controller owns its request cancellation and terminal error + 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 clears compound views without erasing the newly accepted epoch/sequence; + a gateway reset or explicit unavailability resets both lifecycle and view. + 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 + `prune()`; 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. +- Native cleanup attempts every owned entity once and aggregates failures. + Cleanup failure stops only the compound job, clears its panel records and + reports the original and cleanup errors without interrupting ordinary + shutdown. An asynchronous drawing failure after context retirement is retained + as the controller's `lastError`, without writing into a retired panel. +- Each candidate owns a 36-pixel native icon arrow and a short 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. +- 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 + assumption; all candidates retain exploratory wording. No confidence score + or future-outcome fields are displayed. + +For an isolated render of the actual panel module using synthetic records: + +```bash +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, +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. + ## Development Source lives under `src/binance-strategy27-events/`; the generated installable diff --git a/scripts/binance-strategy27-events.user.js b/scripts/binance-strategy27-events.user.js index 13fad31..a2f8219 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.3.1 +// @version 0.4.0 // @author jackhai9 // @description 在 Binance 一秒图表标注 VPS Strategy 27 的实时订单流候选观察 // @match https://www.binance.com/*/futures/* @@ -21,6 +21,15 @@ // @grant GM_registerMenuCommand // ==/UserScript== (() => { + var __typeError = (msg) => { + throw TypeError(msg); + }; + var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg); + var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj)); + var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value); + var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value); + var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method); + // src/binance-strategy27-events/core/live-event-contract.js var CANONICAL_SYMBOL_PATTERN = /^([A-Z0-9]+)\/USDT:USDT$/; var ROUTE_SYMBOL_PATTERN = /^([A-Z0-9]+)USDT$/; @@ -56,10 +65,10 @@ function assertCondition(condition, message) { if (!condition) throw new Error(message); } - function assertExactKeys(value, keys, label) { + function assertExactKeys(value, keys2, label) { assertCondition(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); const actual = Object.keys(value).sort(); - const expected = [...keys].sort(); + const expected = [...keys2].sort(); assertCondition( actual.length === expected.length && actual.every((key, index) => key === expected[index]), `${label} must contain exact keys: ${expected.join(", ")}` @@ -754,6 +763,7 @@ function formatNotional(value, label) { const numeric = finiteNumber(value, label); const absolute = Math.abs(numeric); + if (absolute > 0 && absolute < 0.1) return numeric.toLocaleString("en-US", { maximumSignificantDigits: 2, useGrouping: false }); if (absolute >= 1e6) return `${compactDecimal(numeric / 1e6, { digits: 2, label })}M`; if (absolute >= 1e3) return `${compactDecimal(numeric / 1e3, { digits: 1, label })}K`; return compactDecimal(numeric, { digits: 1, label }); @@ -942,7 +952,8 @@ throw new Error(`TradingView marker placement method is unavailable: ${method}`); } } - const resolve = (annotation, { allowPreviousCandle = false } = {}) => { + const resolve = (annotation, { allowPreviousCandle = false, gapPx = DIRECTIONAL_MARKER_GAP_PX } = {}) => { + if (!Number.isFinite(gapPx)) throw new Error("Strategy 27 marker pixel gap is invalid"); if (!["arrow_up", "arrow_down"].includes(annotation.markerShape)) { throw new Error(`Unsupported Strategy 27 marker shape: ${annotation.markerShape}`); } @@ -956,7 +967,6 @@ if (!Array.isArray(candle) || candle.length < 5 || !timeMatches) { throw new Error(`Strategy 27 candle is invalid for ${annotation.markerTime}`); } - const point = { time: candleTime, price: annotation.markerPrice }; const candleHigh = Number(candle[2]); const candleLow = Number(candle[3]); if (!Number.isFinite(candleHigh) || !Number.isFinite(candleLow)) { @@ -970,7 +980,7 @@ } const direction = annotation.markerShape === "arrow_up" ? 1 : -1; const markerPrice = priceScale.coordinateToPrice( - edgeCoordinate + direction * DIRECTIONAL_MARKER_GAP_PX, + edgeCoordinate + direction * gapPx, firstValue ); if (!Number.isFinite(markerPrice)) { @@ -978,7 +988,15 @@ } return { time: candleTime, price: markerPrice }; }; - return { dataUpdated, resolve }; + function shift(point, deltaPixels) { + if (!Number.isFinite(deltaPixels)) throw new Error("Strategy 27 marker pixel shift is invalid"); + const firstValue = mainSeries.firstValue(); + const y = priceScale.priceToCoordinate(point.price, firstValue); + const price = priceScale.coordinateToPrice(y + deltaPixels, firstValue); + if (!Number.isFinite(y) || !Number.isFinite(price)) throw new Error("Strategy 27 shifted marker coordinate is invalid"); + return { time: point.time, price }; + } + return { dataUpdated, resolve, shift }; } function verifyResolvedTime(chart, id, requestedTime) { const shape = chart.getShapeById(id); @@ -1003,23 +1021,16 @@ } return id; } - function createTradingViewEventLayer(target, { - maxEvents, - maxAgeMs, + function createTradingViewMarkerPlacement(chart, { candleWaitMs = DEFAULT_CANDLE_WAIT_MS - }) { - 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"); + } = {}) { if (!Number.isInteger(candleWaitMs) || candleWaitMs < 1) { throw new Error("Strategy 27 candleWaitMs is invalid"); } - const { chart } = target; - const { dataUpdated, resolve: resolveMarkerPoint } = createMarkerPointResolver(chart); - const registry = /* @__PURE__ */ new Map(); - const pendingCandleWaits = /* @__PURE__ */ new Set(); - let renderGeneration = 0; - function waitForMarkerPoint(annotation, requestedGeneration) { - const immediate = resolveMarkerPoint(annotation); + const { dataUpdated, resolve: resolveMarkerPoint, shift } = createMarkerPointResolver(chart); + function wait2(annotation, { signal, gapPx = DIRECTIONAL_MARKER_GAP_PX }) { + if (signal.aborted) return Promise.resolve(null); + const immediate = resolveMarkerPoint(annotation, { gapPx }); if (immediate) return Promise.resolve(immediate); return new Promise((resolve, reject) => { const owner = {}; @@ -1028,7 +1039,7 @@ const cleanup = () => { clearTimeout(timeoutId); dataUpdated.unsubscribe(owner, onDataUpdated); - pendingCandleWaits.delete(cancel); + signal.removeEventListener("abort", cancel); }; const finish = (value) => { if (settled) return; @@ -1044,12 +1055,12 @@ }; const cancel = () => finish(null); const onDataUpdated = () => { - if (requestedGeneration !== renderGeneration) { + if (signal.aborted) { cancel(); return; } try { - const point = resolveMarkerPoint(annotation); + const point = resolveMarkerPoint(annotation, { gapPx }); if (point) finish(point); } catch (error) { fail(error); @@ -1057,7 +1068,7 @@ }; timeoutId = setTimeout(() => { try { - const previousPoint = resolveMarkerPoint(annotation, { allowPreviousCandle: true }); + const previousPoint = resolveMarkerPoint(annotation, { allowPreviousCandle: true, gapPx }); if (previousPoint) { finish(previousPoint); return; @@ -1069,11 +1080,25 @@ fail(error); } }, candleWaitMs); - pendingCandleWaits.add(cancel); + signal.addEventListener("abort", cancel, { once: true }); dataUpdated.subscribe(owner, onDataUpdated); onDataUpdated(); }); } + return Object.freeze({ wait: wait2, shift }); + } + function createTradingViewEventLayer(target, { + maxEvents, + maxAgeMs, + candleWaitMs = DEFAULT_CANDLE_WAIT_MS + }) { + 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 registry = /* @__PURE__ */ new Map(); + const pendingCandleWaits = /* @__PURE__ */ new Set(); + let renderGeneration = 0; function removeRecord(eventId) { const record = registry.get(eventId); if (!record) return; @@ -1096,7 +1121,14 @@ } if (annotation.markerShape === null) return true; const requestedGeneration = renderGeneration; - const markerPoint = await waitForMarkerPoint(annotation, requestedGeneration); + const controller = new AbortController(); + pendingCandleWaits.add(controller); + let markerPoint; + try { + markerPoint = await placement.wait(annotation, { signal: controller.signal }); + } finally { + pendingCandleWaits.delete(controller); + } if (!markerPoint || requestedGeneration !== renderGeneration) return false; pruneAge(observedAtMs); ensureCapacityForNew(); @@ -1122,7 +1154,7 @@ prune: pruneAge, clear() { renderGeneration += 1; - for (const cancel of [...pendingCandleWaits]) cancel(); + for (const controller of [...pendingCandleWaits]) controller.abort(); for (const eventId of [...registry.keys()]) removeRecord(eventId); }, get size() { @@ -1303,10 +1335,12 @@ } function createStrategy27EventPanel(document, chartRoot, { maxEvents, + maxCompoundEvents, loadPosition, savePosition }) { if (!Number.isInteger(maxEvents) || maxEvents < 1) throw new Error("Strategy 27 panel maxEvents is invalid"); + if (!Number.isInteger(maxCompoundEvents) || maxCompoundEvents < 1 || maxCompoundEvents > 8) throw new Error("Strategy 27 panel maxCompoundEvents is invalid"); if (typeof loadPosition !== "function") throw new Error("Strategy 27 panel loadPosition is invalid"); if (typeof savePosition !== "function") throw new Error("Strategy 27 panel savePosition is invalid"); document.getElementById(PANEL_ID)?.remove(); @@ -1385,22 +1419,52 @@ role: "event-list", styles: { display: "grid", gap: "2px", padding: "0 6px 7px" } }); - body.append(detail, recentTitle, recent); + const compoundTitle = createElement(document, "strong", { + text: "复合候选", + styles: { display: "block", padding: "7px 9px 4px", borderTop: "1px solid rgba(132, 142, 156, .18)" } + }); + const compoundStatus = createElement(document, "div", { + text: "复合候选等待连接", + role: "compound-status", + styles: { padding: "0 9px 5px", color: "#848E9C", fontSize: "11px", overflowWrap: "anywhere" } + }); + const compoundRecent = createElement(document, "div", { + role: "compound-list", + styles: { display: "grid", gap: "2px", padding: "0 6px 7px" } + }); + body.append(detail, compoundTitle, compoundStatus, compoundRecent, recentTitle, recent); panel.appendChild(body); document.body.appendChild(panel); const initialPosition = assertPanelPosition(loadPosition()) ?? createDefaultPosition(chartRoot); applyPanelPosition(panel, normalizePanelPosition(document, panel, initialPosition)); const cleanupDrag = setupPanelDrag(document, panel, header, savePosition); const records = /* @__PURE__ */ new Map(); + const compoundRecords = /* @__PURE__ */ new Map(); let selectedEventId = null; + let selectedKind = "ordinary"; let followLatest = true; let collapsed = false; - function orderedEntries() { - return [...records.entries()].sort((left, right) => right[1].annotation.eventTimeMs - left[1].annotation.eventTimeMs || right[1].observedAtMs - left[1].observedAtMs); + function orderedEntries(collection = records) { + return [...collection.entries()].sort((left, right) => right[1].annotation.eventTimeMs - left[1].annotation.eventTimeMs || right[1].observedAtMs - left[1].observedAtMs); + } + function selectedCollection() { + return selectedKind === "compound" ? compoundRecords : records; + } + function selectLatest() { + const all = [ + ...orderedEntries().map(([id, record]) => ({ id, record, kind: "ordinary" })), + ...orderedEntries(compoundRecords).map(([id, record]) => ({ id, record, kind: "compound" })) + ].sort((a, b) => b.record.annotation.eventTimeMs - a.record.annotation.eventTimeMs || b.record.observedAtMs - a.record.observedAtMs); + selectedEventId = all[0]?.id ?? null; + selectedKind = all[0]?.kind ?? "ordinary"; + } + function reconcileSelection() { + if (!selectedCollection().has(selectedEventId)) followLatest = true; + if (followLatest) selectLatest(); } function renderDetail() { detail.replaceChildren(); - const record = records.get(selectedEventId); + const record = selectedCollection().get(selectedEventId); if (!record) { detail.appendChild(createElement(document, "span", { text: "等待新事件", @@ -1414,14 +1478,27 @@ }); title.appendChild(createElement(document, "span", { text: annotation.title, - styles: { color: annotation.markerColor ?? "#EAECEF", fontWeight: "700", flex: "1" } + styles: { color: selectedKind === "compound" ? annotation.titleColor : annotation.markerColor ?? "#EAECEF", fontWeight: "700", flex: "1" } })); title.appendChild(createElement(document, "span", { - text: STATUS_LABELS[annotation.status], + text: selectedKind === "compound" ? "探索版" : STATUS_LABELS[annotation.status], styles: { color: "#848E9C", fontSize: "11px" } })); detail.appendChild(title); appendDetailLine(document, detail, "时间", formatClock(annotation.eventTimeMs)); + if (selectedKind === "compound") { + for (const row of annotation.detailRows) appendDetailLine(document, detail, row.label, row.value); + const identity = createElement(document, "details", { role: "compound-identity", styles: { color: "#848E9C" } }); + identity.appendChild(createElement(document, "summary", { text: "规则与候选 ID", styles: { cursor: "pointer" } })); + identity.appendChild(createElement(document, "div", { + text: `规则 ${annotation.ruleIdentity} +候选 ${annotation.candidateId}`, + styles: { whiteSpace: "pre-wrap", overflowWrap: "anywhere", userSelect: "text", fontSize: "10px" } + })); + detail.appendChild(identity); + for (const notice of annotation.notices) appendDetailLine(document, detail, "说明", notice, "#848E9C"); + return; + } appendDetailLine(document, detail, "统计", annotation.windowText); if (annotation.candidateText) { appendDetailLine(document, detail, "候选观察", annotation.candidateText, annotation.markerColor); @@ -1435,12 +1512,12 @@ if (annotation.closeText) appendDetailLine(document, detail, "结束", annotation.closeText); for (const notice of annotation.notices) appendDetailLine(document, detail, "说明", notice, "#F0B90B"); } - function renderRecent() { - recent.replaceChildren(); - for (const [eventId, record] of orderedEntries()) { + function renderRecent(container, collection, kind) { + container.replaceChildren(); + for (const [eventId, record] of orderedEntries(collection)) { const { annotation } = record; const row = createElement(document, "button", { - role: "event-row", + role: kind === "compound" ? "compound-row" : "event-row", styles: { display: "grid", gridTemplateColumns: "7px 54px minmax(0, 1fr)", @@ -1450,7 +1527,7 @@ border: "0", borderRadius: "5px", padding: "5px 6px", - background: eventId === selectedEventId ? "rgba(132, 142, 156, .18)" : "transparent", + background: kind === selectedKind && eventId === selectedEventId ? "rgba(132, 142, 156, .18)" : "transparent", color: "#EAECEF", font: "11px/16px BinancePlex, ui-sans-serif, system-ui, sans-serif", textAlign: "left", @@ -1465,7 +1542,8 @@ width: "7px", height: "7px", borderRadius: "50%", - background: annotation.markerColor ?? "transparent" + background: annotation.markerColor ?? "transparent", + outline: kind === "compound" ? "1px solid #EAECEF" : "none" } })); row.appendChild(createElement(document, "span", { @@ -1478,20 +1556,22 @@ })); row.addEventListener("click", () => { selectedEventId = eventId; + selectedKind = kind; followLatest = false; render(); }); - recent.appendChild(row); + container.appendChild(row); } } function render() { latestButton.style.color = followLatest ? "#F0B90B" : "#EAECEF"; renderDetail(); - renderRecent(); + renderRecent(recent, records, "ordinary"); + renderRecent(compoundRecent, compoundRecords, "compound"); } latestButton.addEventListener("click", () => { followLatest = true; - selectedEventId = orderedEntries()[0]?.[0] ?? null; + selectLatest(); render(); }); collapseButton.addEventListener("click", () => { @@ -1500,43 +1580,762 @@ collapseButton.textContent = collapsed ? "展开" : "收起"; }); render(); + function upsertRecord(collection, capacity, eventId, annotation, observedAtMs) { + collection.set(eventId, { annotation, observedAtMs }); + const ordered = orderedEntries(collection); + while (ordered.length > capacity) collection.delete(ordered.pop()[0]); + reconcileSelection(); + render(); + } + function removeRecord(collection, eventId) { + collection.delete(eventId); + reconcileSelection(); + render(); + } return Object.freeze({ upsert(eventId, annotation, observedAtMs) { - records.set(eventId, { annotation, observedAtMs }); - const ordered = orderedEntries(); - while (ordered.length > maxEvents) { - const [expiredId] = ordered.pop(); - records.delete(expiredId); - } - if (!records.has(selectedEventId)) followLatest = true; - if (followLatest) selectedEventId = orderedEntries()[0]?.[0] ?? null; - render(); + upsertRecord(records, maxEvents, eventId, annotation, observedAtMs); + }, + upsertCompound(eventId, annotation, observedAtMs) { + upsertRecord(compoundRecords, maxCompoundEvents, eventId, annotation, observedAtMs); }, remove(eventId) { - records.delete(eventId); - if (selectedEventId === eventId) { - followLatest = true; - selectedEventId = orderedEntries()[0]?.[0] ?? null; - } - render(); + removeRecord(records, eventId); + }, + removeCompound(eventId) { + removeRecord(compoundRecords, eventId); }, clear() { records.clear(); - selectedEventId = null; - followLatest = true; + reconcileSelection(); + render(); + }, + clearCompound() { + compoundRecords.clear(); + reconcileSelection(); render(); }, + setCompoundStatus(text, state) { + if (typeof text !== "string" || !["normal", "inactive", "error"].includes(state)) throw new Error("Strategy 27 compound panel status is invalid"); + compoundStatus.textContent = text; + compoundStatus.dataset.state = state; + compoundStatus.style.color = state === "error" ? "#F6465D" : "#848E9C"; + }, destroy() { records.clear(); + compoundRecords.clear(); cleanupDrag(); panel.remove(); }, get size() { return records.size; + }, + get compoundSize() { + return compoundRecords.size; + } + }); + } + + // src/binance-strategy27-events/core/compound-candidate-annotation.js + var DIRECTIONS = Object.freeze({ + high: Object.freeze({ title: "复合候选高", label: "候选高", shape: "arrow_down", color: "#B71C3B" }), + low: Object.freeze({ title: "复合候选低", label: "候选低", shape: "arrow_up", color: "#087F5B" }) + }); + var FAMILY_LABELS = Object.freeze({ + high: Object.freeze({ impact_failure: "买入推动失效", passive_support_loss: "被动承接转弱", failed_rebound: "反弹失败强化" }), + low: Object.freeze({ impact_failure: "卖出推动失效", passive_support_loss: "被动抛压转弱", failed_rebound: "回落失败强化" }) + }); + function clock(ms) { + const date = new Date(ms); + return [date.getHours(), date.getMinutes(), date.getSeconds()].map((value) => String(value).padStart(2, "0")).join(":"); + } + function priceWindow(value) { + return `${clock(value.start_ms)}–${clock(value.end_ms)} · ${value.opening_mid} → ${value.closing_mid}`; + } + function flow(value, count, label) { + return value === "0" && count === 0 ? "无主动成交" : `${formatNotional(value, label)} USDT · ${count} 笔`; + } + function buildCompoundCandidateAnnotation(candidate) { + const direction = DIRECTIONS[candidate.direction]; + const family = FAMILY_LABELS[candidate.direction]?.[candidate.family]; + if (!direction || !family) throw new Error("Strategy 27 compound display rule is invalid"); + const reinforcement = candidate.family === "failed_rebound"; + const detailRows = [ + { label: "规则", value: family }, + { label: "背景", value: priceWindow(candidate.context) }, + { label: "触发秒", value: priceWindow(candidate.seed) }, + { label: "主动买", value: flow(candidate.seed.buy_notional, candidate.seed.buy_count, "seed buy") }, + { label: "主动卖", value: flow(candidate.seed.sell_notional, candidate.seed.sell_count, "seed sell") }, + { label: "bid", value: `增 ${formatNotional(candidate.seed.bid_addition, "bid addition")} · 减 ${formatNotional(candidate.seed.bid_decrease, "bid decrease")} USDT` }, + { label: "ask", value: `增 ${formatNotional(candidate.seed.ask_addition, "ask addition")} · 减 ${formatNotional(candidate.seed.ask_decrease, "ask decrease")} USDT` }, + { label: "基础确认", value: priceWindow(candidate.confirmation) } + ]; + if (reinforcement) { + detailRows.push( + { label: candidate.direction === "high" ? "低点秒" : "高点秒", value: priceWindow(candidate.trough) }, + { label: candidate.direction === "high" ? "反弹秒" : "回落秒", value: priceWindow(candidate.rebound) }, + { label: "强化确认", value: priceWindow(candidate.decision) }, + { label: "关联候选", value: candidate.parent_candidate_id } + ); + } + detailRows.push({ label: "参数版本", value: candidate.profile.revision }); + const notices = ["探索候选,尚未验证预测能力"]; + if (candidate.direction === "low") notices.push("镜像规则,尚未独立验证"); + return Object.freeze({ + kind: "compound", + title: direction.title, + titleColor: candidate.direction === "high" ? "#FF718A" : "#53DDB1", + eventTimeMs: candidate.decision.end_ms - 1, + markerTime: Math.floor((candidate.decision.end_ms - 1) / 1e3), + markerPrice: Number(candidate.decision.closing_mid), + markerShape: direction.shape, + markerColor: direction.color, + markerLabel: direction.label, + summary: `${direction.label} · ${family}`, + ruleIdentity: `${candidate.family}/${candidate.direction}/${candidate.profile_id}${reinforcement ? `/${candidate.parent_candidate_id}` : ""}`, + candidateId: candidate.candidate_id, + profileId: candidate.profile_id, + reinforcement, + detailRows: Object.freeze(detailRows.map(Object.freeze)), + notices: Object.freeze(notices) + }); + } + + // src/binance-strategy27-events/core/compound-candidate-contract.js + var HASH = /^[a-f0-9]{64}$/; + var DECIMAL = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]*[1-9])?$/; + var PROFILE_DECIMALS = ["context_move_bps", "significant_flow_ratio", "quiet_flow_ratio", "confirmation_move_bps", "rebound_move_bps"]; + var PROFILE_TIMES = ["context_seconds", "minimum_context_seconds", "confirmation_seconds", "reinforcement_seconds"]; + var PRICES = ["opening_mid", "closing_mid", "minimum_mid", "maximum_mid"]; + var METRICS = ["buy_notional", "sell_notional", "bid_addition", "bid_decrease", "ask_addition", "ask_decrease", "bid_depth", "ask_depth"]; + function check(condition, message) { + if (!condition) throw new Error(`Strategy 27 compound ${message}`); + } + function keys(value, expected, label) { + check(value !== null && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + check(actual.length === wanted.length && actual.every((key, i) => key === wanted[i]), `${label} keys must be exact`); + } + function integer(value, label, minimum = 0) { + check(Number.isSafeInteger(value) && value >= minimum, `${label} must be a safe integer >= ${minimum}`); + } + function hash(value, label) { + check(typeof value === "string" && HASH.test(value), `${label} must be SHA-256`); + } + function decimal(value, label) { + check(typeof value === "string" && value.length <= 128 && DECIMAL.test(value) && value !== "-0", `${label} must be a canonical decimal`); + } + function compare(left, right) { + const scale = (value) => value.includes(".") ? value.length - value.indexOf(".") - 1 : 0; + const leftScale = scale(left); + const rightScale = scale(right); + const a = BigInt(left.replace(".", "")) * 10n ** BigInt(rightScale); + const b = BigInt(right.replace(".", "")) * 10n ** BigInt(leftScale); + return a === b ? 0 : a > b ? 1 : -1; + } + function canonicalCompoundJson(value) { + if (value !== null && typeof value === "object") { + check(!Array.isArray(value), "canonical record cannot contain arrays"); + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalCompoundJson(value[key])}`).join(",")}}`; + } + check(value === null || typeof value === "string" || Number.isSafeInteger(value), "canonical value is invalid"); + if (typeof value === "string") check(/^[\x00-\x7f]*$/.test(value), "canonical text must be ASCII"); + return JSON.stringify(value); + } + async function compoundHash(value) { + const bytes = new TextEncoder().encode(canonicalCompoundJson(value)); + const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + } + function profile(value) { + keys(value, ["revision", ...PROFILE_DECIMALS, ...PROFILE_TIMES], "profile"); + check(typeof value.revision === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(value.revision), "profile revision is invalid"); + for (const field of PROFILE_TIMES) { + integer(value[field], field, 1); + check(value[field] <= 300, "profile time exceeds 300 seconds"); + } + check(value.minimum_context_seconds >= 2 && value.minimum_context_seconds <= value.context_seconds, "profile context bounds are invalid"); + for (const field of PROFILE_DECIMALS) { + decimal(value[field], field); + check(compare(value[field], "0") > 0, `${field} must be positive`); + } + check(compare(value.quiet_flow_ratio, value.significant_flow_ratio) <= 0, "quiet ratio exceeds significant ratio"); + } + function priceRange(value, label) { + for (const field of PRICES) { + decimal(value[field], `${label}.${field}`); + check(compare(value[field], "0") > 0, `${label} prices must be positive`); + } + for (const field of ["opening_mid", "closing_mid"]) { + check(compare(value.minimum_mid, value[field]) <= 0 && compare(value[field], value.maximum_mid) <= 0, `${label} extrema do not contain endpoints`); + } + integer(value.start_ms, `${label}.start_ms`); + integer(value.end_ms, `${label}.end_ms`); + check(value.start_ms % 1e3 === 0 && value.end_ms % 1e3 === 0 && value.end_ms > value.start_ms, `${label} interval is invalid`); + } + function second(value, label) { + keys(value, ["start_ms", "end_ms", ...PRICES, ...METRICS, "buy_count", "sell_count"], label); + priceRange(value, label); + check(value.end_ms - value.start_ms === 1e3, `${label} must span one second`); + integer(value.buy_count, `${label}.buy_count`); + integer(value.sell_count, `${label}.sell_count`); + for (const field of METRICS) { + decimal(value[field], `${label}.${field}`); + check(compare(value[field], "0") >= 0, `${label}.${field} must be nonnegative`); + } + check(compare(value.bid_depth, "0") > 0 && compare(value.ask_depth, "0") > 0, `${label} depths must be positive`); + } + async function validateCompoundCandidate(value) { + keys(value, ["candidate_id", "profile", "profile_id", "symbol", "source_id", "family", "direction", "validation_status", "parent_candidate_id", "context", "established_extreme", "seed", "confirmation", "trough", "rebound", "decision"], "candidate"); + hash(value.candidate_id, "candidate_id"); + hash(value.profile_id, "profile_id"); + hash(value.source_id, "source_id"); + profile(value.profile); + check(typeof value.symbol === "string" && /^[A-Z0-9]+\/USDT:USDT$/.test(value.symbol), "symbol is invalid"); + check(["impact_failure", "passive_support_loss", "failed_rebound"].includes(value.family), "family is invalid"); + check(["high", "low"].includes(value.direction), "direction is invalid"); + check(value.validation_status === (value.direction === "high" ? "exploratory" : "unvalidated_mirror"), "validation status does not match direction"); + keys(value.context, ["start_ms", "end_ms", ...PRICES], "context"); + priceRange(value.context, "context"); + for (const field of ["seed", "confirmation", "decision"]) second(value[field], field); + decimal(value.established_extreme, "established_extreme"); + const extremeField = value.direction === "high" ? "maximum_mid" : "minimum_mid"; + const extremes = [value.context[extremeField], value.seed[extremeField]].sort(compare); + check(value.established_extreme === extremes[value.direction === "high" ? 1 : 0], "frozen extreme does not match evidence"); + const duration = value.context.end_ms - value.context.start_ms; + check(value.context.end_ms === value.seed.start_ms && value.seed.end_ms <= value.confirmation.start_ms && duration >= value.profile.minimum_context_seconds * 1e3 && duration <= value.profile.context_seconds * 1e3 && value.confirmation.end_ms - value.seed.end_ms <= value.profile.confirmation_seconds * 1e3, "base evidence ordering is invalid"); + if (value.family === "failed_rebound") { + hash(value.parent_candidate_id, "parent_candidate_id"); + check(value.parent_candidate_id !== value.candidate_id, "candidate cannot parent itself"); + second(value.trough, "trough"); + second(value.rebound, "rebound"); + check(value.confirmation.end_ms <= value.trough.start_ms && value.trough.end_ms <= value.rebound.start_ms && value.rebound.end_ms <= value.decision.start_ms && value.decision.end_ms - value.confirmation.end_ms <= value.profile.reinforcement_seconds * 1e3, "reinforcement evidence ordering is invalid"); + } else { + check(value.parent_candidate_id === null && value.trough === null && value.rebound === null && canonicalCompoundJson(value.decision) === canonicalCompoundJson(value.confirmation), "base cannot carry reinforcement evidence"); + } + check(await compoundHash(value.profile) === value.profile_id, "profile hash mismatch"); + const { candidate_id: candidateId, ...record } = value; + check(await compoundHash(record) === candidateId, "candidate hash mismatch"); + return value; + } + async function validateCompoundEnvelope(value) { + keys(value, ["schema_version", "projection_kind", "runtime_epoch", "sequence", "message_kind", "symbol", "observed_at_ms", "payload"], "envelope"); + check(value.schema_version === 1 && value.projection_kind === "compound_candidate", "envelope version is invalid"); + check(typeof value.runtime_epoch === "string" && /^[a-f0-9]{32}$/.test(value.runtime_epoch), "runtime epoch is invalid"); + integer(value.sequence, "sequence", 1); + integer(value.observed_at_ms, "observed_at_ms"); + check(new TextEncoder().encode(canonicalCompoundJson(value)).length <= 16384, "envelope exceeds byte bound"); + if (value.message_kind === "candidate") { + await validateCompoundCandidate(value.payload); + check(value.symbol === value.payload.symbol && value.observed_at_ms >= value.payload.decision.end_ms, "candidate identity/time mismatch"); + } else { + check(["stream_state", "heartbeat"].includes(value.message_kind), "message kind is invalid"); + keys(value.payload, value.message_kind === "stream_state" ? ["state", "reason"] : ["state"], "state"); + check(value.symbol === null && value.payload.state === "ready", "state identity is invalid"); + if (value.message_kind === "stream_state") check(["startup", "transport_recovered", "queue_recovered"].includes(value.payload.reason), "reset reason is invalid"); + } + return value; + } + var STREAM_ID = /^(0|[1-9]\d*)-(0|[1-9]\d*)$/; + async function validateCompoundGatewayResponse(value, httpStatus) { + if (value?.status === "error") { + keys(value, ["schema_version", "status", "error_code"], "gateway error"); + check(value.schema_version === 1, "gateway schema is invalid"); + const codes = { 400: ["invalid_request"], 401: ["unauthorized"], 503: ["redis_unavailable", "compound_unavailable"] }; + check(codes[httpStatus]?.includes(value.error_code), "gateway error status/code mismatch"); + return value; + } + check(value?.status === "ok" || value?.status === "reset", "gateway status is invalid"); + keys(value, ["schema_version", "status", "requested_cursor", "next_cursor", "messages", ...value.status === "reset" ? ["reason"] : []], "gateway response"); + check(value.schema_version === 1, "gateway schema is invalid"); + check(typeof value.next_cursor === "string" && STREAM_ID.test(value.next_cursor), "next cursor is invalid"); + check(Array.isArray(value.messages) && value.messages.length <= 128, "gateway message bound is invalid"); + if (value.status === "reset") { + check(["initial_cursor", "stale_cursor"].includes(value.reason), "gateway reset reason is invalid"); + check(value.messages.length === 0, "gateway reset messages must be empty"); + check(httpStatus === (value.reason === "initial_cursor" ? 200 : 409), "gateway reset HTTP status is invalid"); + if (value.reason === "initial_cursor") { + check(value.requested_cursor === null, "initial cursor must be null"); + return value; + } + } else { + check(httpStatus === 200, "gateway success must use HTTP 200"); + for (const message of value.messages) await validateCompoundEnvelope(message); + } + check(typeof value.requested_cursor === "string" && STREAM_ID.test(value.requested_cursor), "requested cursor is invalid"); + return value; + } + + // src/binance-strategy27-events/core/compound-candidate-client.js + function wait(delay, signal) { + const aborted = () => new DOMException("Compound request aborted", "AbortError"); + if (signal.aborted) return Promise.reject(aborted()); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", cancel); + resolve(); + }, delay); + function cancel() { + clearTimeout(timer); + reject(aborted()); + } + signal.addEventListener("abort", cancel, { once: true }); + }); + } + function cursorRegressed(next, previous) { + const [nextMs, nextSequence] = next.split("-").map(BigInt); + const [previousMs, previousSequence] = previous.split("-").map(BigInt); + return nextMs < previousMs || nextMs === previousMs && nextSequence < previousSequence; + } + function createCompoundCandidateClient({ request, gatewayBaseUrl, authSecret, canonicalSymbol, onResponse, onConnectionStateChange, reconnectDelayMs = 2e3 }) { + if (typeof request !== "function" || typeof onResponse !== "function" || typeof onConnectionStateChange !== "function") throw new Error("Compound client callbacks are required"); + if (typeof authSecret !== "string" || authSecret.length === 0) throw new Error("Compound gateway secret is not configured"); + if (typeof canonicalSymbol !== "string" || !/^[A-Z0-9]+\/USDT:USDT$/.test(canonicalSymbol)) throw new Error("Compound canonical symbol is invalid"); + if (!Number.isSafeInteger(reconnectDelayMs) || reconnectDelayMs < 0) throw new Error("Compound reconnect delay is invalid"); + const origin = normalizeGatewayBaseUrl(gatewayBaseUrl); + let cursor = null; + let state = null; + function transition(next) { + if (state === next) return; + state = next; + onConnectionStateChange(next); + } + return Object.freeze({ + async run(signal) { + while (!signal.aborted) { + const url = new URL("/v1/strategy27/compound-candidates", origin); + url.searchParams.set("symbol", canonicalSymbol); + if (cursor !== null) url.searchParams.set("cursor", cursor); + let response; + try { + response = await request({ url: url.href, authSecret, signal }); + } catch (error) { + if (signal.aborted) return; + if (!(error instanceof Strategy27GatewayTransportError)) throw error; + transition("reconnecting"); + await wait(reconnectDelayMs, signal); + continue; + } + if (signal.aborted) return; + if (!Number.isInteger(response?.status) || typeof response.responseText !== "string") throw new Error("Compound gateway returned an invalid response"); + if (response.status === 404) { + transition("unsupported"); + return; + } + const payload = await validateCompoundGatewayResponse(JSON.parse(response.responseText), response.status); + if (signal.aborted) return; + if (payload.status === "error") { + if (response.status !== 503) throw new Error(`Compound gateway error: ${payload.error_code}`); + cursor = null; + transition("unavailable"); + await wait(reconnectDelayMs, signal); + continue; + } + if (payload.requested_cursor !== cursor || cursor !== null && cursorRegressed(payload.next_cursor, cursor)) throw new Error("Compound gateway response cursor mismatch/regression"); + if (signal.aborted) return; + transition("connected"); + await onResponse(payload); + cursor = payload.next_cursor; + } } }); } + // src/binance-strategy27-events/core/compound-candidate-lifecycle.js + function check2(condition, message) { + if (!condition) throw new Error(`Strategy 27 compound ${message}`); + } + function freezeRecord(value) { + if (value !== null && typeof value === "object") { + for (const child of Object.values(value)) freezeRecord(child); + Object.freeze(value); + } + return value; + } + function orderOf(candidate) { + return { time: candidate.decision.end_ms, id: candidate.candidate_id }; + } + function compareOrder(a, b) { + return a.time - b.time || (a.id === b.id ? 0 : a.id < b.id ? -1 : 1); + } + var _records, _evictionBoundary, _generation, _applying, _CompoundCandidateLifecycle_instances, evict_fn; + var CompoundCandidateLifecycle = class { + constructor(canonicalSymbol, { maxCandidates, maxAgeMs }) { + __privateAdd(this, _CompoundCandidateLifecycle_instances); + __privateAdd(this, _records, /* @__PURE__ */ new Map()); + __privateAdd(this, _evictionBoundary, null); + __privateAdd(this, _generation, 0); + __privateAdd(this, _applying, false); + canonicalSymbolToRoute(canonicalSymbol); + check2(Number.isSafeInteger(maxCandidates) && maxCandidates >= 1 && maxCandidates <= 80, "maxCandidates must be 1..80"); + check2(Number.isSafeInteger(maxAgeMs) && maxAgeMs >= 1 && maxAgeMs <= 72e5, "maxAgeMs must be 1..7200000"); + this.canonicalSymbol = canonicalSymbol; + this.maxCandidates = maxCandidates; + this.maxAgeMs = maxAgeMs; + this.reset("initial_cursor"); + } + reset(reason) { + check2(["initial_cursor", "stale_cursor", "route_changed", "interval_changed", "unavailable", "stopped"].includes(reason), "reset reason is invalid"); + __privateSet(this, _generation, __privateGet(this, _generation) + 1); + __privateGet(this, _records).clear(); + __privateSet(this, _evictionBoundary, null); + this.runtimeEpoch = null; + this.lastSequence = null; + } + prune(nowMs) { + check2(Number.isSafeInteger(nowMs) && nowMs >= 0, "prune time is invalid"); + const removed = []; + for (const [id, record] of __privateGet(this, _records)) { + if (nowMs - record.candidate.decision.end_ms > this.maxAgeMs) { + __privateMethod(this, _CompoundCandidateLifecycle_instances, evict_fn).call(this, id); + removed.push(id); + } + } + return removed; + } + async apply(rawEnvelope, nowMs) { + check2(!__privateGet(this, _applying), "lifecycle applications must be serial"); + check2(Number.isSafeInteger(nowMs) && nowMs >= 0, "application time is invalid"); + __privateSet(this, _applying, true); + const generation = __privateGet(this, _generation); + try { + const envelope = await validateCompoundEnvelope(structuredClone(rawEnvelope)); + if (generation !== __privateGet(this, _generation)) return { type: "cancelled", removedCandidateIds: [] }; + const isState = envelope.message_kind === "stream_state"; + if (envelope.message_kind === "candidate") check2(envelope.symbol === this.canonicalSymbol, "symbol does not match requested symbol"); + const changedEpoch = this.runtimeEpoch !== null && envelope.runtime_epoch !== this.runtimeEpoch; + if (changedEpoch) check2(isState, "epoch changed without stream_state"); + if (this.runtimeEpoch === envelope.runtime_epoch) { + check2(envelope.sequence > this.lastSequence, "sequence regression"); + check2(!isState, "Unexpected stream_state inside an active epoch"); + } + this.runtimeEpoch = envelope.runtime_epoch; + this.lastSequence = envelope.sequence; + if (isState) { + const removedCandidateIds2 = [...__privateGet(this, _records).keys()]; + __privateGet(this, _records).clear(); + __privateSet(this, _evictionBoundary, null); + return { type: "stream_reset", removedCandidateIds: removedCandidateIds2 }; + } + const removedCandidateIds = this.prune(nowMs); + if (envelope.message_kind === "heartbeat") return { type: "heartbeat", removedCandidateIds }; + const candidate = envelope.payload; + const id = candidate.candidate_id; + const canonical = canonicalCompoundJson(candidate); + const retained = __privateGet(this, _records).get(id); + if (retained) { + check2(retained.canonical === canonical, "candidate replay changed immutable content"); + return { type: "replay", removedCandidateIds }; + } + if (nowMs - candidate.decision.end_ms > this.maxAgeMs || __privateGet(this, _evictionBoundary) !== null && compareOrder(orderOf(candidate), __privateGet(this, _evictionBoundary)) <= 0) { + return { type: "expired", removedCandidateIds }; + } + freezeRecord(candidate); + __privateGet(this, _records).set(id, { candidate, canonical }); + if (__privateGet(this, _records).size > this.maxCandidates) { + const oldest = [...__privateGet(this, _records).values()].sort((a, b) => compareOrder(orderOf(a.candidate), orderOf(b.candidate)))[0].candidate.candidate_id; + __privateMethod(this, _CompoundCandidateLifecycle_instances, evict_fn).call(this, oldest); + removedCandidateIds.push(oldest); + } + return __privateGet(this, _records).has(id) ? { type: "candidate", candidate, observedAtMs: envelope.observed_at_ms, removedCandidateIds } : { type: "expired", removedCandidateIds }; + } finally { + __privateSet(this, _applying, false); + } + } + get size() { + return __privateGet(this, _records).size; + } + }; + _records = new WeakMap(); + _evictionBoundary = new WeakMap(); + _generation = new WeakMap(); + _applying = new WeakMap(); + _CompoundCandidateLifecycle_instances = new WeakSet(); + evict_fn = function(id) { + const record = __privateGet(this, _records).get(id); + const order = orderOf(record.candidate); + if (__privateGet(this, _evictionBoundary) === null || compareOrder(order, __privateGet(this, _evictionBoundary)) > 0) __privateSet(this, _evictionBoundary, order); + __privateGet(this, _records).delete(id); + }; + + // src/binance-strategy27-events/core/compound-candidate-controller.js + var CONNECTION_STATUS = Object.freeze({ + connected: ["复合候选已连接", "normal"], + reconnecting: ["复合候选连接中断,正在重连", "inactive"], + unavailable: ["复合候选暂不可用,正在重连", "inactive"], + unsupported: ["网关尚未启用复合候选", "inactive"] + }); + function createCompoundCandidateController({ + request, + gatewayBaseUrl, + authSecret, + canonicalSymbol, + panel, + createLayer, + isCurrent, + maxCandidates, + maxAgeMs, + nowMs = Date.now, + reconnectDelayMs = 2e3 + }) { + const lifecycle = new CompoundCandidateLifecycle(canonicalSymbol, { maxCandidates, maxAgeMs }); + const abortController = new AbortController(); + let layer = null; + let started = false; + let viewGeneration = 0; + let pendingCandidateId = null; + let lastError = null; + const current = () => !abortController.signal.aborted && isCurrent(); + function clearView() { + viewGeneration += 1; + pendingCandidateId = null; + let cleanupError = null; + try { + layer?.clear(); + } catch (error) { + cleanupError = error; + } + panel.clearCompound(); + return cleanupError; + } + function remove(ids) { + for (const id of ids) { + if (id === pendingCandidateId) viewGeneration += 1; + layer?.remove(id); + panel.removeCompound(id); + } + } + function prune() { + if (current()) remove(lifecycle.prune(nowMs())); + } + function failJob(error, { clear = true } = {}) { + lastError = error; + if (!current()) return; + abortController.abort(); + lifecycle.reset("stopped"); + const cleanupError = clear ? clearView() : null; + if (cleanupError) lastError = new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); + panel.setCompoundStatus(`复合候选已停止:${lastError.message}`, "error"); + } + function onConnectionStateChange(state) { + if (!current()) return; + const status = CONNECTION_STATUS[state]; + if (!status) throw new Error(`Unknown compound connection state: ${state}`); + if (state === "unavailable" || state === "unsupported") { + lifecycle.reset("unavailable"); + const error = clearView(); + if (error) { + failJob(error, { clear: false }); + return; + } + } + panel.setCompoundStatus(...status); + } + async function onResponse(response) { + if (!current()) return; + if (response.status === "reset") { + lifecycle.reset(response.reason); + const error = clearView(); + if (error) failJob(error, { clear: false }); + return; + } + for (const message of response.messages) { + if (!current()) return; + const applicationGeneration = viewGeneration; + const action = await lifecycle.apply(message, nowMs()); + if (!current()) return; + remove(action.removedCandidateIds); + if (action.type === "stream_reset") { + const error = clearView(); + if (error) { + failJob(error, { clear: false }); + return; + } + continue; + } + if (action.type !== "candidate" || applicationGeneration !== viewGeneration) continue; + const id = action.candidate.candidate_id; + const annotation = buildCompoundCandidateAnnotation(action.candidate); + if (layer === null) layer = createLayer(); + const renderGeneration = viewGeneration; + pendingCandidateId = id; + try { + const rendered = await layer.renderCandidate(id, annotation, action.candidate.decision.end_ms); + if (typeof rendered !== "boolean") throw new Error("Compound renderer must return a boolean"); + if (!current()) return; + prune(); + if (rendered && renderGeneration === viewGeneration) { + panel.upsertCompound(id, annotation, action.observedAtMs); + } + } finally { + pendingCandidateId = null; + } + } + } + return Object.freeze({ + run() { + if (started) throw new Error("Compound controller already started"); + started = true; + return (async () => { + if (!current()) return; + try { + panel.setCompoundStatus("复合候选正在连接", "inactive"); + const client = createCompoundCandidateClient({ + request, + gatewayBaseUrl, + authSecret, + canonicalSymbol, + reconnectDelayMs, + onResponse, + onConnectionStateChange + }); + await client.run(abortController.signal); + } catch (error) { + if (abortController.signal.aborted && error.name === "AbortError") return; + failJob(error); + } + })(); + }, + clear() { + if (!current()) return; + const error = clearView(); + if (error) failJob(error, { clear: false }); + }, + prune() { + try { + prune(); + } catch (error) { + failJob(error); + } + }, + stop(reason) { + if (abortController.signal.aborted) return; + abortController.abort(); + lifecycle.reset(reason); + const error = clearView(); + if (error) { + lastError = error; + panel.setCompoundStatus(`复合候选已停止:${error.message}`, "error"); + } + }, + // A late drawing rejection remains inspectable without touching a retired panel. + get lastError() { + return lastError; + } + }); + } + + // src/binance-strategy27-events/dom/tradingview-compound-layer.js + 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 }) { + if (!Number.isSafeInteger(maxCandidates) || maxCandidates < 1 || maxCandidates > 80) throw new Error("Compound chart capacity must be 1..80"); + const { chart } = target; + const placement = createTradingViewMarkerPlacement(chart, { candleWaitMs }); + const records = /* @__PURE__ */ new Map(); + let pending = null; + function dispose(recordsToRemove) { + const errors = []; + for (const record of recordsToRemove) { + for (const id of record.ids.splice(0)) { + 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("; ")}`); + } + function remove(id) { + const removals = []; + if (pending?.id === id) { + pending.controller.abort(); + removals.push(pending); + } + const record = records.get(id); + if (record) { + records.delete(id); + removals.push(record); + } + dispose(removals); + } + function clear() { + if (pending) pending.controller.abort(); + const removals = [...records.values()]; + records.clear(); + if (pending) removals.push(pending); + dispose(removals); + } + async function renderCandidate(id, annotation, decisionAtMs) { + if (pending !== null) throw new Error("Compound chart rendering must be serial"); + if (records.has(id)) return true; + 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]; + if (icon === void 0 || !["候选高", "候选低"].includes(annotation.markerLabel)) throw new Error("Compound chart direction/label is invalid"); + const operation = { id, controller: new AbortController(), ids: [] }; + 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) 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; + 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: annotation.markerLabel, overrides: { ...options.overrides, fontsize: 12, bold: true, fillBackground: false, drawBorder: false } }] + ]; + for (const [drawingPoint, drawing] of drawings) { + const entityId = await createAlignedShape(chart, drawingPoint, drawing); + operation.ids.push(entityId); + if (operation.controller.signal.aborted) { + dispose([operation]); + return false; + } + const properties = chart.getShapeById(entityId).getProperties(); + const matched = properties.color === annotation.markerColor && (drawing.shape === "icon" ? properties.icon === icon && properties.size === ICON_SIZE_PX : properties.text === annotation.markerLabel && properties.fontsize === 12); + if (!matched) throw new Error("Compound chart drawing properties did not match the requested icon/label"); + } + records.set(id, { ids: operation.ids.splice(0), group, slot, decisionAtMs }); + return true; + } catch (error) { + try { + dispose([operation]); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); + } + throw error; + } finally { + pending = null; + } + } + return Object.freeze({ renderCandidate, remove, clear, get size() { + return records.size; + } }); + } + // src/shared/binance-futures-route.js var FUTURES_TRADING_PATH_RE = /^\/(?:[a-z]{2}(?:-[A-Za-z]{2})?\/)?futures\/([A-Za-z0-9_]{3,})\/?$/; function parseFuturesTradingSymbolFromPathname(pathname) { @@ -1610,6 +2409,7 @@ function stopActive(resetReason) { if (!active) return; active.controller.abort(); + active.compound.stop(resetReason); active.lifecycle.reset(resetReason); active.layer.clear(); active.panel.destroy(); @@ -1690,6 +2490,7 @@ }), panel: createStrategy27EventPanel(pageDocument, target.chartRoot, { maxEvents: MAX_PANEL_EVENTS, + maxCompoundEvents: MAX_PANEL_EVENTS, loadPosition: () => GM_getValue(PANEL_POSITION_KEY, null), savePosition: (position) => GM_setValue(PANEL_POSITION_KEY, position) }), @@ -1697,6 +2498,18 @@ failed: false }; active = context; + context.compound = createCompoundCandidateController({ + request, + gatewayBaseUrl: gatewayOrigin, + authSecret, + canonicalSymbol, + panel: context.panel, + isCurrent: () => active === context, + maxCandidates: MAX_RETAINED_EVENTS, + maxAgeMs: MAX_EVENT_AGE_MS, + createLayer: () => createTradingViewCompoundLayer(target, { maxCandidates: MAX_RETAINED_EVENTS }) + }); + void context.compound.run(); showStatus(target.chartRoot, "Strategy 27 正在连接"); const client = createLiveEventClient({ request, @@ -1730,7 +2543,11 @@ return; } const chartRoot = findStrategy27ChartRoot(pageDocument); - if (!chartRoot) return; + if (!chartRoot) { + stopActive("interval_changed"); + hideStatus(); + return; + } let canonicalSymbol; try { canonicalSymbol = routeSymbolToCanonical(routeSymbol); @@ -1761,6 +2578,7 @@ return; } if (active && active.routeSymbol === routeSymbol && active.target.chart === target.chart && active.target.chartRoot === target.chartRoot) { + active.compound.prune(); return; } stopActive("route_changed"); @@ -1797,6 +2615,7 @@ restart(); }); GM_registerMenuCommand("清除 Strategy 27 图表标注", () => { + active?.compound.clear(); active?.layer.clear(); active?.panel.clear(); hideStatus(); diff --git a/src/binance-strategy27-events/core/compound-candidate-annotation.js b/src/binance-strategy27-events/core/compound-candidate-annotation.js new file mode 100644 index 0000000..98d3408 --- /dev/null +++ b/src/binance-strategy27-events/core/compound-candidate-annotation.js @@ -0,0 +1,70 @@ +import { formatNotional } from './event-annotation.js'; + +const DIRECTIONS = Object.freeze({ + high: Object.freeze({ title: '复合候选高', label: '候选高', shape: 'arrow_down', color: '#B71C3B' }), + low: Object.freeze({ title: '复合候选低', label: '候选低', shape: 'arrow_up', color: '#087F5B' }), +}); +const FAMILY_LABELS = Object.freeze({ + high: Object.freeze({ impact_failure: '买入推动失效', passive_support_loss: '被动承接转弱', failed_rebound: '反弹失败强化' }), + low: Object.freeze({ impact_failure: '卖出推动失效', passive_support_loss: '被动抛压转弱', failed_rebound: '回落失败强化' }), +}); + +function clock(ms) { + const date = new Date(ms); + return [date.getHours(), date.getMinutes(), date.getSeconds()].map((value) => String(value).padStart(2, '0')).join(':'); +} + +function priceWindow(value) { + return `${clock(value.start_ms)}–${clock(value.end_ms)} · ${value.opening_mid} → ${value.closing_mid}`; +} + +function flow(value, count, label) { + return value === '0' && count === 0 ? '无主动成交' : `${formatNotional(value, label)} USDT · ${count} 笔`; +} + +/** Format server-confirmed evidence only; do not infer additional stages. */ +export function buildCompoundCandidateAnnotation(candidate) { + const direction = DIRECTIONS[candidate.direction]; + const family = FAMILY_LABELS[candidate.direction]?.[candidate.family]; + if (!direction || !family) throw new Error('Strategy 27 compound display rule is invalid'); + const reinforcement = candidate.family === 'failed_rebound'; + const detailRows = [ + { label: '规则', value: family }, + { label: '背景', value: priceWindow(candidate.context) }, + { label: '触发秒', value: priceWindow(candidate.seed) }, + { label: '主动买', value: flow(candidate.seed.buy_notional, candidate.seed.buy_count, 'seed buy') }, + { label: '主动卖', value: flow(candidate.seed.sell_notional, candidate.seed.sell_count, 'seed sell') }, + { label: 'bid', value: `增 ${formatNotional(candidate.seed.bid_addition, 'bid addition')} · 减 ${formatNotional(candidate.seed.bid_decrease, 'bid decrease')} USDT` }, + { label: 'ask', value: `增 ${formatNotional(candidate.seed.ask_addition, 'ask addition')} · 减 ${formatNotional(candidate.seed.ask_decrease, 'ask decrease')} USDT` }, + { label: '基础确认', value: priceWindow(candidate.confirmation) }, + ]; + if (reinforcement) { + detailRows.push( + { label: candidate.direction === 'high' ? '低点秒' : '高点秒', value: priceWindow(candidate.trough) }, + { label: candidate.direction === 'high' ? '反弹秒' : '回落秒', value: priceWindow(candidate.rebound) }, + { label: '强化确认', value: priceWindow(candidate.decision) }, + { label: '关联候选', value: candidate.parent_candidate_id }, + ); + } + detailRows.push({ label: '参数版本', value: candidate.profile.revision }); + const notices = ['探索候选,尚未验证预测能力']; + if (candidate.direction === 'low') notices.push('镜像规则,尚未独立验证'); + return Object.freeze({ + kind: 'compound', + title: direction.title, + titleColor: candidate.direction === 'high' ? '#FF718A' : '#53DDB1', + eventTimeMs: candidate.decision.end_ms - 1, + markerTime: Math.floor((candidate.decision.end_ms - 1) / 1000), + markerPrice: Number(candidate.decision.closing_mid), + markerShape: direction.shape, + markerColor: direction.color, + markerLabel: direction.label, + summary: `${direction.label} · ${family}`, + ruleIdentity: `${candidate.family}/${candidate.direction}/${candidate.profile_id}${reinforcement ? `/${candidate.parent_candidate_id}` : ''}`, + candidateId: candidate.candidate_id, + profileId: candidate.profile_id, + reinforcement, + detailRows: Object.freeze(detailRows.map(Object.freeze)), + notices: Object.freeze(notices), + }); +} diff --git a/src/binance-strategy27-events/core/compound-candidate-client.js b/src/binance-strategy27-events/core/compound-candidate-client.js new file mode 100644 index 0000000..47126cf --- /dev/null +++ b/src/binance-strategy27-events/core/compound-candidate-client.js @@ -0,0 +1,80 @@ +import { normalizeGatewayBaseUrl, Strategy27GatewayTransportError } from './live-event-client.js'; +import { validateCompoundGatewayResponse } from './compound-candidate-contract.js'; + +function wait(delay, signal) { + const aborted = () => new DOMException('Compound request aborted', 'AbortError'); + if (signal.aborted) return Promise.reject(aborted()); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', cancel); + resolve(); + }, delay); + function cancel() { + clearTimeout(timer); + reject(aborted()); + } + signal.addEventListener('abort', cancel, { once: true }); + }); +} + +function cursorRegressed(next, previous) { + const [nextMs, nextSequence] = next.split('-').map(BigInt); + const [previousMs, previousSequence] = previous.split('-').map(BigInt); + return nextMs < previousMs || (nextMs === previousMs && nextSequence < previousSequence); +} + +/** A missing/failed compound route must never own the ordinary client's state. */ +export function createCompoundCandidateClient({ request, gatewayBaseUrl, authSecret, canonicalSymbol, onResponse, onConnectionStateChange, reconnectDelayMs = 2000 }) { + if (typeof request !== 'function' || typeof onResponse !== 'function' || typeof onConnectionStateChange !== 'function') throw new Error('Compound client callbacks are required'); + if (typeof authSecret !== 'string' || authSecret.length === 0) throw new Error('Compound gateway secret is not configured'); + if (typeof canonicalSymbol !== 'string' || !/^[A-Z0-9]+\/USDT:USDT$/.test(canonicalSymbol)) throw new Error('Compound canonical symbol is invalid'); + if (!Number.isSafeInteger(reconnectDelayMs) || reconnectDelayMs < 0) throw new Error('Compound reconnect delay is invalid'); + const origin = normalizeGatewayBaseUrl(gatewayBaseUrl); + let cursor = null; + let state = null; + function transition(next) { + if (state === next) return; + state = next; + onConnectionStateChange(next); + } + return Object.freeze({ + async run(signal) { + while (!signal.aborted) { + const url = new URL('/v1/strategy27/compound-candidates', origin); + url.searchParams.set('symbol', canonicalSymbol); + if (cursor !== null) url.searchParams.set('cursor', cursor); + let response; + try { + response = await request({ url: url.href, authSecret, signal }); + } catch (error) { + if (signal.aborted) return; + if (!(error instanceof Strategy27GatewayTransportError)) throw error; + transition('reconnecting'); + await wait(reconnectDelayMs, signal); + continue; + } + if (signal.aborted) return; + if (!Number.isInteger(response?.status) || typeof response.responseText !== 'string') throw new Error('Compound gateway returned an invalid response'); + // Old gateways return a non-JSON 404; do not mistake it for bad candidate data. + if (response.status === 404) { + transition('unsupported'); + return; + } + const payload = await validateCompoundGatewayResponse(JSON.parse(response.responseText), response.status); + if (signal.aborted) return; + if (payload.status === 'error') { + if (response.status !== 503) throw new Error(`Compound gateway error: ${payload.error_code}`); + cursor = null; + transition('unavailable'); + await wait(reconnectDelayMs, signal); + continue; + } + if (payload.requested_cursor !== cursor || (cursor !== null && cursorRegressed(payload.next_cursor, cursor))) throw new Error('Compound gateway response cursor mismatch/regression'); + if (signal.aborted) return; + transition('connected'); + await onResponse(payload); + cursor = payload.next_cursor; + } + }, + }); +} diff --git a/src/binance-strategy27-events/core/compound-candidate-contract.js b/src/binance-strategy27-events/core/compound-candidate-contract.js new file mode 100644 index 0000000..cd7bcf2 --- /dev/null +++ b/src/binance-strategy27-events/core/compound-candidate-contract.js @@ -0,0 +1,181 @@ +/** Exact ADR 032 display contract; no market interpretation belongs here. */ +const HASH = /^[a-f0-9]{64}$/; +const DECIMAL = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]*[1-9])?$/; +const PROFILE_DECIMALS = ['context_move_bps', 'significant_flow_ratio', 'quiet_flow_ratio', 'confirmation_move_bps', 'rebound_move_bps']; +const PROFILE_TIMES = ['context_seconds', 'minimum_context_seconds', 'confirmation_seconds', 'reinforcement_seconds']; +const PRICES = ['opening_mid', 'closing_mid', 'minimum_mid', 'maximum_mid']; +const METRICS = ['buy_notional', 'sell_notional', 'bid_addition', 'bid_decrease', 'ask_addition', 'ask_decrease', 'bid_depth', 'ask_depth']; + +function check(condition, message) { + if (!condition) throw new Error(`Strategy 27 compound ${message}`); +} + +function keys(value, expected, label) { + check(value !== null && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + check(actual.length === wanted.length && actual.every((key, i) => key === wanted[i]), `${label} keys must be exact`); +} + +function integer(value, label, minimum = 0) { + check(Number.isSafeInteger(value) && value >= minimum, `${label} must be a safe integer >= ${minimum}`); +} + +function hash(value, label) { + check(typeof value === 'string' && HASH.test(value), `${label} must be SHA-256`); +} + +function decimal(value, label) { + check(typeof value === 'string' && value.length <= 128 && DECIMAL.test(value) && value !== '-0', `${label} must be a canonical decimal`); +} + +/** Compare wire decimals exactly, including prices beyond Number precision. */ +function compare(left, right) { + const scale = (value) => value.includes('.') ? value.length - value.indexOf('.') - 1 : 0; + const leftScale = scale(left); + const rightScale = scale(right); + const a = BigInt(left.replace('.', '')) * (10n ** BigInt(rightScale)); + const b = BigInt(right.replace('.', '')) * (10n ** BigInt(leftScale)); + return a === b ? 0 : a > b ? 1 : -1; +} + +export function canonicalCompoundJson(value) { + if (value !== null && typeof value === 'object') { + check(!Array.isArray(value), 'canonical record cannot contain arrays'); + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalCompoundJson(value[key])}`).join(',')}}`; + } + check(value === null || typeof value === 'string' || Number.isSafeInteger(value), 'canonical value is invalid'); + if (typeof value === 'string') check(/^[\x00-\x7f]*$/.test(value), 'canonical text must be ASCII'); + return JSON.stringify(value); +} + +export async function compoundHash(value) { + const bytes = new TextEncoder().encode(canonicalCompoundJson(value)); + const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function profile(value) { + keys(value, ['revision', ...PROFILE_DECIMALS, ...PROFILE_TIMES], 'profile'); + check(typeof value.revision === 'string' && /^[a-z0-9][a-z0-9-]{0,63}$/.test(value.revision), 'profile revision is invalid'); + for (const field of PROFILE_TIMES) { + integer(value[field], field, 1); + check(value[field] <= 300, 'profile time exceeds 300 seconds'); + } + check(value.minimum_context_seconds >= 2 && value.minimum_context_seconds <= value.context_seconds, 'profile context bounds are invalid'); + for (const field of PROFILE_DECIMALS) { + decimal(value[field], field); + check(compare(value[field], '0') > 0, `${field} must be positive`); + } + check(compare(value.quiet_flow_ratio, value.significant_flow_ratio) <= 0, 'quiet ratio exceeds significant ratio'); +} + +function priceRange(value, label) { + for (const field of PRICES) { + decimal(value[field], `${label}.${field}`); + check(compare(value[field], '0') > 0, `${label} prices must be positive`); + } + for (const field of ['opening_mid', 'closing_mid']) { + check(compare(value.minimum_mid, value[field]) <= 0 && compare(value[field], value.maximum_mid) <= 0, `${label} extrema do not contain endpoints`); + } + integer(value.start_ms, `${label}.start_ms`); + integer(value.end_ms, `${label}.end_ms`); + check(value.start_ms % 1000 === 0 && value.end_ms % 1000 === 0 && value.end_ms > value.start_ms, `${label} interval is invalid`); +} + +function second(value, label) { + keys(value, ['start_ms', 'end_ms', ...PRICES, ...METRICS, 'buy_count', 'sell_count'], label); + priceRange(value, label); + check(value.end_ms - value.start_ms === 1000, `${label} must span one second`); + integer(value.buy_count, `${label}.buy_count`); + integer(value.sell_count, `${label}.sell_count`); + for (const field of METRICS) { + decimal(value[field], `${label}.${field}`); + check(compare(value[field], '0') >= 0, `${label}.${field} must be nonnegative`); + } + check(compare(value.bid_depth, '0') > 0 && compare(value.ask_depth, '0') > 0, `${label} depths must be positive`); +} + +export async function validateCompoundCandidate(value) { + keys(value, ['candidate_id', 'profile', 'profile_id', 'symbol', 'source_id', 'family', 'direction', 'validation_status', 'parent_candidate_id', 'context', 'established_extreme', 'seed', 'confirmation', 'trough', 'rebound', 'decision'], 'candidate'); + hash(value.candidate_id, 'candidate_id'); + hash(value.profile_id, 'profile_id'); + hash(value.source_id, 'source_id'); + profile(value.profile); + check(typeof value.symbol === 'string' && /^[A-Z0-9]+\/USDT:USDT$/.test(value.symbol), 'symbol is invalid'); + check(['impact_failure', 'passive_support_loss', 'failed_rebound'].includes(value.family), 'family is invalid'); + check(['high', 'low'].includes(value.direction), 'direction is invalid'); + check(value.validation_status === (value.direction === 'high' ? 'exploratory' : 'unvalidated_mirror'), 'validation status does not match direction'); + keys(value.context, ['start_ms', 'end_ms', ...PRICES], 'context'); + priceRange(value.context, 'context'); + for (const field of ['seed', 'confirmation', 'decision']) second(value[field], field); + decimal(value.established_extreme, 'established_extreme'); + const extremeField = value.direction === 'high' ? 'maximum_mid' : 'minimum_mid'; + const extremes = [value.context[extremeField], value.seed[extremeField]].sort(compare); + check(value.established_extreme === extremes[value.direction === 'high' ? 1 : 0], 'frozen extreme does not match evidence'); + const duration = value.context.end_ms - value.context.start_ms; + check(value.context.end_ms === value.seed.start_ms && value.seed.end_ms <= value.confirmation.start_ms && duration >= value.profile.minimum_context_seconds * 1000 && duration <= value.profile.context_seconds * 1000 && value.confirmation.end_ms - value.seed.end_ms <= value.profile.confirmation_seconds * 1000, 'base evidence ordering is invalid'); + if (value.family === 'failed_rebound') { + hash(value.parent_candidate_id, 'parent_candidate_id'); + check(value.parent_candidate_id !== value.candidate_id, 'candidate cannot parent itself'); + second(value.trough, 'trough'); + second(value.rebound, 'rebound'); + check(value.confirmation.end_ms <= value.trough.start_ms && value.trough.end_ms <= value.rebound.start_ms && value.rebound.end_ms <= value.decision.start_ms && value.decision.end_ms - value.confirmation.end_ms <= value.profile.reinforcement_seconds * 1000, 'reinforcement evidence ordering is invalid'); + } else { + check(value.parent_candidate_id === null && value.trough === null && value.rebound === null && canonicalCompoundJson(value.decision) === canonicalCompoundJson(value.confirmation), 'base cannot carry reinforcement evidence'); + } + check(await compoundHash(value.profile) === value.profile_id, 'profile hash mismatch'); + const { candidate_id: candidateId, ...record } = value; + check(await compoundHash(record) === candidateId, 'candidate hash mismatch'); + return value; +} + +export async function validateCompoundEnvelope(value) { + keys(value, ['schema_version', 'projection_kind', 'runtime_epoch', 'sequence', 'message_kind', 'symbol', 'observed_at_ms', 'payload'], 'envelope'); + check(value.schema_version === 1 && value.projection_kind === 'compound_candidate', 'envelope version is invalid'); + check(typeof value.runtime_epoch === 'string' && /^[a-f0-9]{32}$/.test(value.runtime_epoch), 'runtime epoch is invalid'); + integer(value.sequence, 'sequence', 1); + integer(value.observed_at_ms, 'observed_at_ms'); + check(new TextEncoder().encode(canonicalCompoundJson(value)).length <= 16384, 'envelope exceeds byte bound'); + if (value.message_kind === 'candidate') { + await validateCompoundCandidate(value.payload); + check(value.symbol === value.payload.symbol && value.observed_at_ms >= value.payload.decision.end_ms, 'candidate identity/time mismatch'); + } else { + check(['stream_state', 'heartbeat'].includes(value.message_kind), 'message kind is invalid'); + keys(value.payload, value.message_kind === 'stream_state' ? ['state', 'reason'] : ['state'], 'state'); + check(value.symbol === null && value.payload.state === 'ready', 'state identity is invalid'); + if (value.message_kind === 'stream_state') check(['startup', 'transport_recovered', 'queue_recovered'].includes(value.payload.reason), 'reset reason is invalid'); + } + return value; +} + +const STREAM_ID = /^(0|[1-9]\d*)-(0|[1-9]\d*)$/; + +export async function validateCompoundGatewayResponse(value, httpStatus) { + if (value?.status === 'error') { + keys(value, ['schema_version', 'status', 'error_code'], 'gateway error'); + check(value.schema_version === 1, 'gateway schema is invalid'); + const codes = { 400: ['invalid_request'], 401: ['unauthorized'], 503: ['redis_unavailable', 'compound_unavailable'] }; + check(codes[httpStatus]?.includes(value.error_code), 'gateway error status/code mismatch'); + return value; + } + check(value?.status === 'ok' || value?.status === 'reset', 'gateway status is invalid'); + keys(value, ['schema_version', 'status', 'requested_cursor', 'next_cursor', 'messages', ...(value.status === 'reset' ? ['reason'] : [])], 'gateway response'); + check(value.schema_version === 1, 'gateway schema is invalid'); + check(typeof value.next_cursor === 'string' && STREAM_ID.test(value.next_cursor), 'next cursor is invalid'); + check(Array.isArray(value.messages) && value.messages.length <= 128, 'gateway message bound is invalid'); + if (value.status === 'reset') { + check(['initial_cursor', 'stale_cursor'].includes(value.reason), 'gateway reset reason is invalid'); + check(value.messages.length === 0, 'gateway reset messages must be empty'); + check(httpStatus === (value.reason === 'initial_cursor' ? 200 : 409), 'gateway reset HTTP status is invalid'); + if (value.reason === 'initial_cursor') { + check(value.requested_cursor === null, 'initial cursor must be null'); + return value; + } + } else { + check(httpStatus === 200, 'gateway success must use HTTP 200'); + for (const message of value.messages) await validateCompoundEnvelope(message); + } + check(typeof value.requested_cursor === 'string' && STREAM_ID.test(value.requested_cursor), 'requested cursor is invalid'); + return value; +} diff --git a/src/binance-strategy27-events/core/compound-candidate-controller.js b/src/binance-strategy27-events/core/compound-candidate-controller.js new file mode 100644 index 0000000..026f4e3 --- /dev/null +++ b/src/binance-strategy27-events/core/compound-candidate-controller.js @@ -0,0 +1,166 @@ +import { buildCompoundCandidateAnnotation } from './compound-candidate-annotation.js'; +import { createCompoundCandidateClient } from './compound-candidate-client.js'; +import { CompoundCandidateLifecycle } from './compound-candidate-lifecycle.js'; + +const CONNECTION_STATUS = Object.freeze({ + connected: ['复合候选已连接', 'normal'], + reconnecting: ['复合候选连接中断,正在重连', 'inactive'], + unavailable: ['复合候选暂不可用,正在重连', 'inactive'], + unsupported: ['网关尚未启用复合候选', 'inactive'], +}); + +/** Own the optional compound job, never the ordinary client's state. + * + * 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, including late create results. + */ +export function createCompoundCandidateController({ + request, gatewayBaseUrl, authSecret, canonicalSymbol, panel, createLayer, + isCurrent, maxCandidates, maxAgeMs, nowMs = Date.now, reconnectDelayMs = 2000, +}) { + const lifecycle = new CompoundCandidateLifecycle(canonicalSymbol, { maxCandidates, maxAgeMs }); + const abortController = new AbortController(); + let layer = null; + let started = false; + let viewGeneration = 0; + let pendingCandidateId = null; + let lastError = null; + const current = () => !abortController.signal.aborted && isCurrent(); + + function clearView() { + viewGeneration += 1; + pendingCandidateId = null; + let cleanupError = null; + try { + layer?.clear(); + } catch (error) { + cleanupError = error; + } + panel.clearCompound(); + return cleanupError; + } + + function remove(ids) { + for (const id of ids) { + if (id === pendingCandidateId) viewGeneration += 1; + layer?.remove(id); + panel.removeCompound(id); + } + } + + function prune() { + if (current()) remove(lifecycle.prune(nowMs())); + } + + function failJob(error, { clear = true } = {}) { + lastError = error; + if (!current()) return; + abortController.abort(); + lifecycle.reset('stopped'); + const cleanupError = clear ? clearView() : null; + if (cleanupError) lastError = new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); + panel.setCompoundStatus(`复合候选已停止:${lastError.message}`, 'error'); + } + + function onConnectionStateChange(state) { + if (!current()) return; + const status = CONNECTION_STATUS[state]; + if (!status) throw new Error(`Unknown compound connection state: ${state}`); + if (state === 'unavailable' || state === 'unsupported') { + lifecycle.reset('unavailable'); + const error = clearView(); + if (error) { failJob(error, { clear: false }); return; } + } + panel.setCompoundStatus(...status); + } + + async function onResponse(response) { + if (!current()) return; + if (response.status === 'reset') { + lifecycle.reset(response.reason); + const error = clearView(); + if (error) failJob(error, { clear: false }); + return; + } + for (const message of response.messages) { + if (!current()) return; + const applicationGeneration = viewGeneration; + const action = await lifecycle.apply(message, nowMs()); + if (!current()) return; + remove(action.removedCandidateIds); + if (action.type === 'stream_reset') { + // apply() already accepted the epoch and sequence. Only clear the view. + const error = clearView(); + if (error) { failJob(error, { clear: false }); return; } + continue; + } + if (action.type !== 'candidate' || applicationGeneration !== viewGeneration) continue; + const id = action.candidate.candidate_id; + const annotation = buildCompoundCandidateAnnotation(action.candidate); + // Optional chart capabilities are tested inside this job's error boundary. + if (layer === null) layer = createLayer(); + const renderGeneration = viewGeneration; + pendingCandidateId = id; + try { + const rendered = await layer.renderCandidate(id, annotation, action.candidate.decision.end_ms); + if (typeof rendered !== 'boolean') throw new Error('Compound renderer must return a boolean'); + if (!current()) return; + prune(); + if (rendered && renderGeneration === viewGeneration) { + panel.upsertCompound(id, annotation, action.observedAtMs); + } + } finally { + pendingCandidateId = null; + } + } + } + + return Object.freeze({ + run() { + if (started) throw new Error('Compound controller already started'); + started = true; + return (async () => { + if (!current()) return; + try { + panel.setCompoundStatus('复合候选正在连接', 'inactive'); + const client = createCompoundCandidateClient({ + request, gatewayBaseUrl, authSecret, canonicalSymbol, reconnectDelayMs, + onResponse, onConnectionStateChange, + }); + await client.run(abortController.signal); + } catch (error) { + // Contract/render failures stop this optional job, not ordinary events. + if (abortController.signal.aborted && error.name === 'AbortError') return; + failJob(error); + } + })(); + }, + clear() { + if (!current()) return; + const error = clearView(); + if (error) failJob(error, { clear: false }); + }, + prune() { + // The shared context timer is a second entry into this optional job. + try { + prune(); + } catch (error) { + failJob(error); + } + }, + stop(reason) { + if (abortController.signal.aborted) return; + abortController.abort(); + lifecycle.reset(reason); + const error = clearView(); + if (error) { + lastError = error; + panel.setCompoundStatus(`复合候选已停止:${error.message}`, 'error'); + } + }, + // A late drawing rejection remains inspectable without touching a retired panel. + get lastError() { return lastError; }, + }); +} diff --git a/src/binance-strategy27-events/core/compound-candidate-lifecycle.js b/src/binance-strategy27-events/core/compound-candidate-lifecycle.js new file mode 100644 index 0000000..301996d --- /dev/null +++ b/src/binance-strategy27-events/core/compound-candidate-lifecycle.js @@ -0,0 +1,130 @@ +import { canonicalCompoundJson, validateCompoundEnvelope } from './compound-candidate-contract.js'; +import { canonicalSymbolToRoute } from './live-event-contract.js'; + +function check(condition, message) { + if (!condition) throw new Error(`Strategy 27 compound ${message}`); +} + +function freezeRecord(value) { + if (value !== null && typeof value === 'object') { + for (const child of Object.values(value)) freezeRecord(child); + Object.freeze(value); + } + return value; +} + +function orderOf(candidate) { + return { time: candidate.decision.end_ms, id: candidate.candidate_id }; +} + +function compareOrder(a, b) { + return a.time - b.time || (a.id === b.id ? 0 : a.id < b.id ? -1 : 1); +} + +/** Bounded immutable observations, independent of ordinary event lifecycles. + * + * Retention follows decision time rather than delivery/replay time. A monotonic + * eviction boundary prevents old replay from resurrecting markers without an + * unbounded tombstone map. Full payload hashes are validated even after eviction. + */ +export class CompoundCandidateLifecycle { + #records = new Map(); + #evictionBoundary = null; + #generation = 0; + #applying = false; + + constructor(canonicalSymbol, { maxCandidates, maxAgeMs }) { + canonicalSymbolToRoute(canonicalSymbol); + check(Number.isSafeInteger(maxCandidates) && maxCandidates >= 1 && maxCandidates <= 80, 'maxCandidates must be 1..80'); + check(Number.isSafeInteger(maxAgeMs) && maxAgeMs >= 1 && maxAgeMs <= 7200000, 'maxAgeMs must be 1..7200000'); + this.canonicalSymbol = canonicalSymbol; + this.maxCandidates = maxCandidates; + this.maxAgeMs = maxAgeMs; + this.reset('initial_cursor'); + } + + reset(reason) { + check(['initial_cursor', 'stale_cursor', 'route_changed', 'interval_changed', 'unavailable', 'stopped'].includes(reason), 'reset reason is invalid'); + this.#generation += 1; + this.#records.clear(); + this.#evictionBoundary = null; + this.runtimeEpoch = null; + this.lastSequence = null; + } + + #evict(id) { + const record = this.#records.get(id); + const order = orderOf(record.candidate); + if (this.#evictionBoundary === null || compareOrder(order, this.#evictionBoundary) > 0) this.#evictionBoundary = order; + this.#records.delete(id); + } + + prune(nowMs) { + check(Number.isSafeInteger(nowMs) && nowMs >= 0, 'prune time is invalid'); + const removed = []; + for (const [id, record] of this.#records) { + if (nowMs - record.candidate.decision.end_ms > this.maxAgeMs) { + this.#evict(id); + removed.push(id); + } + } + return removed; + } + + async apply(rawEnvelope, nowMs) { + check(!this.#applying, 'lifecycle applications must be serial'); + check(Number.isSafeInteger(nowMs) && nowMs >= 0, 'application time is invalid'); + this.#applying = true; + const generation = this.#generation; + try { + const envelope = await validateCompoundEnvelope(structuredClone(rawEnvelope)); + if (generation !== this.#generation) return { type: 'cancelled', removedCandidateIds: [] }; + const isState = envelope.message_kind === 'stream_state'; + if (envelope.message_kind === 'candidate') check(envelope.symbol === this.canonicalSymbol, 'symbol does not match requested symbol'); + const changedEpoch = this.runtimeEpoch !== null && envelope.runtime_epoch !== this.runtimeEpoch; + if (changedEpoch) check(isState, 'epoch changed without stream_state'); + if (this.runtimeEpoch === envelope.runtime_epoch) { + // Filtering other symbols produces sequence gaps, not discontinuities. + check(envelope.sequence > this.lastSequence, 'sequence regression'); + check(!isState, 'Unexpected stream_state inside an active epoch'); + } + this.runtimeEpoch = envelope.runtime_epoch; + this.lastSequence = envelope.sequence; + if (isState) { + const removedCandidateIds = [...this.#records.keys()]; + this.#records.clear(); + this.#evictionBoundary = null; + return { type: 'stream_reset', removedCandidateIds }; + } + const removedCandidateIds = this.prune(nowMs); + if (envelope.message_kind === 'heartbeat') return { type: 'heartbeat', removedCandidateIds }; + const candidate = envelope.payload; + const id = candidate.candidate_id; + const canonical = canonicalCompoundJson(candidate); + const retained = this.#records.get(id); + if (retained) { + check(retained.canonical === canonical, 'candidate replay changed immutable content'); + return { type: 'replay', removedCandidateIds }; + } + if (nowMs - candidate.decision.end_ms > this.maxAgeMs || (this.#evictionBoundary !== null && compareOrder(orderOf(candidate), this.#evictionBoundary) <= 0)) { + return { type: 'expired', removedCandidateIds }; + } + freezeRecord(candidate); + this.#records.set(id, { candidate, canonical }); + if (this.#records.size > this.maxCandidates) { + const oldest = [...this.#records.values()].sort((a, b) => compareOrder(orderOf(a.candidate), orderOf(b.candidate)))[0].candidate.candidate_id; + this.#evict(oldest); + removedCandidateIds.push(oldest); + } + return this.#records.has(id) + ? { type: 'candidate', candidate, observedAtMs: envelope.observed_at_ms, removedCandidateIds } + : { type: 'expired', removedCandidateIds }; + } finally { + this.#applying = false; + } + } + + get size() { + return this.#records.size; + } +} diff --git a/src/binance-strategy27-events/core/event-annotation.js b/src/binance-strategy27-events/core/event-annotation.js index 94ce59e..a4d5159 100644 --- a/src/binance-strategy27-events/core/event-annotation.js +++ b/src/binance-strategy27-events/core/event-annotation.js @@ -75,9 +75,10 @@ function formatRatio(value, label) { return compactDecimal(value, { digits: 2, label }); } -function formatNotional(value, label) { +export function formatNotional(value, label) { const numeric = finiteNumber(value, label); const absolute = Math.abs(numeric); + if (absolute > 0 && absolute < 0.1) return numeric.toLocaleString('en-US', { maximumSignificantDigits: 2, useGrouping: false }); if (absolute >= 1_000_000) return `${compactDecimal(numeric / 1_000_000, { digits: 2, label })}M`; if (absolute >= 1_000) return `${compactDecimal(numeric / 1_000, { digits: 1, label })}K`; return compactDecimal(numeric, { digits: 1, label }); diff --git a/src/binance-strategy27-events/dom/strategy27-event-panel.js b/src/binance-strategy27-events/dom/strategy27-event-panel.js index d267a61..2c2dc2a 100644 --- a/src/binance-strategy27-events/dom/strategy27-event-panel.js +++ b/src/binance-strategy27-events/dom/strategy27-event-panel.js @@ -158,10 +158,12 @@ function appendDetailLine(document, parent, label, value, color = '#EAECEF') { export function createStrategy27EventPanel(document, chartRoot, { maxEvents, + maxCompoundEvents, loadPosition, savePosition, }) { if (!Number.isInteger(maxEvents) || maxEvents < 1) throw new Error('Strategy 27 panel maxEvents is invalid'); + if (!Number.isInteger(maxCompoundEvents) || maxCompoundEvents < 1 || maxCompoundEvents > 8) throw new Error('Strategy 27 panel maxCompoundEvents is invalid'); if (typeof loadPosition !== 'function') throw new Error('Strategy 27 panel loadPosition is invalid'); if (typeof savePosition !== 'function') throw new Error('Strategy 27 panel savePosition is invalid'); document.getElementById(PANEL_ID)?.remove(); @@ -243,7 +245,20 @@ export function createStrategy27EventPanel(document, chartRoot, { role: 'event-list', styles: { display: 'grid', gap: '2px', padding: '0 6px 7px' }, }); - body.append(detail, recentTitle, recent); + const compoundTitle = createElement(document, 'strong', { + text: '复合候选', + styles: { display: 'block', padding: '7px 9px 4px', borderTop: '1px solid rgba(132, 142, 156, .18)' }, + }); + const compoundStatus = createElement(document, 'div', { + text: '复合候选等待连接', + role: 'compound-status', + styles: { padding: '0 9px 5px', color: '#848E9C', fontSize: '11px', overflowWrap: 'anywhere' }, + }); + const compoundRecent = createElement(document, 'div', { + role: 'compound-list', + styles: { display: 'grid', gap: '2px', padding: '0 6px 7px' }, + }); + body.append(detail, compoundTitle, compoundStatus, compoundRecent, recentTitle, recent); panel.appendChild(body); document.body.appendChild(panel); const initialPosition = assertPanelPosition(loadPosition()) ?? createDefaultPosition(chartRoot); @@ -251,20 +266,40 @@ export function createStrategy27EventPanel(document, chartRoot, { const cleanupDrag = setupPanelDrag(document, panel, header, savePosition); const records = new Map(); + const compoundRecords = new Map(); let selectedEventId = null; + let selectedKind = 'ordinary'; let followLatest = true; let collapsed = false; - function orderedEntries() { - return [...records.entries()].sort((left, right) => ( + function orderedEntries(collection = records) { + return [...collection.entries()].sort((left, right) => ( right[1].annotation.eventTimeMs - left[1].annotation.eventTimeMs || right[1].observedAtMs - left[1].observedAtMs )); } + function selectedCollection() { + return selectedKind === 'compound' ? compoundRecords : records; + } + + function selectLatest() { + const all = [ + ...orderedEntries().map(([id, record]) => ({ id, record, kind: 'ordinary' })), + ...orderedEntries(compoundRecords).map(([id, record]) => ({ id, record, kind: 'compound' })), + ].sort((a, b) => b.record.annotation.eventTimeMs - a.record.annotation.eventTimeMs || b.record.observedAtMs - a.record.observedAtMs); + selectedEventId = all[0]?.id ?? null; + selectedKind = all[0]?.kind ?? 'ordinary'; + } + + function reconcileSelection() { + if (!selectedCollection().has(selectedEventId)) followLatest = true; + if (followLatest) selectLatest(); + } + function renderDetail() { detail.replaceChildren(); - const record = records.get(selectedEventId); + const record = selectedCollection().get(selectedEventId); if (!record) { detail.appendChild(createElement(document, 'span', { text: '等待新事件', @@ -278,14 +313,26 @@ export function createStrategy27EventPanel(document, chartRoot, { }); title.appendChild(createElement(document, 'span', { text: annotation.title, - styles: { color: annotation.markerColor ?? '#EAECEF', fontWeight: '700', flex: '1' }, + styles: { color: selectedKind === 'compound' ? annotation.titleColor : annotation.markerColor ?? '#EAECEF', fontWeight: '700', flex: '1' }, })); title.appendChild(createElement(document, 'span', { - text: STATUS_LABELS[annotation.status], + text: selectedKind === 'compound' ? '探索版' : STATUS_LABELS[annotation.status], styles: { color: '#848E9C', fontSize: '11px' }, })); detail.appendChild(title); appendDetailLine(document, detail, '时间', formatClock(annotation.eventTimeMs)); + if (selectedKind === 'compound') { + for (const row of annotation.detailRows) appendDetailLine(document, detail, row.label, row.value); + const identity = createElement(document, 'details', { role: 'compound-identity', styles: { color: '#848E9C' } }); + identity.appendChild(createElement(document, 'summary', { text: '规则与候选 ID', styles: { cursor: 'pointer' } })); + identity.appendChild(createElement(document, 'div', { + text: `规则 ${annotation.ruleIdentity}\n候选 ${annotation.candidateId}`, + styles: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', userSelect: 'text', fontSize: '10px' }, + })); + detail.appendChild(identity); + for (const notice of annotation.notices) appendDetailLine(document, detail, '说明', notice, '#848E9C'); + return; + } appendDetailLine(document, detail, '统计', annotation.windowText); if (annotation.candidateText) { appendDetailLine(document, detail, '候选观察', annotation.candidateText, annotation.markerColor); @@ -300,12 +347,12 @@ export function createStrategy27EventPanel(document, chartRoot, { for (const notice of annotation.notices) appendDetailLine(document, detail, '说明', notice, '#F0B90B'); } - function renderRecent() { - recent.replaceChildren(); - for (const [eventId, record] of orderedEntries()) { + function renderRecent(container, collection, kind) { + container.replaceChildren(); + for (const [eventId, record] of orderedEntries(collection)) { const { annotation } = record; const row = createElement(document, 'button', { - role: 'event-row', + role: kind === 'compound' ? 'compound-row' : 'event-row', styles: { display: 'grid', gridTemplateColumns: '7px 54px minmax(0, 1fr)', @@ -315,7 +362,7 @@ export function createStrategy27EventPanel(document, chartRoot, { border: '0', borderRadius: '5px', padding: '5px 6px', - background: eventId === selectedEventId ? 'rgba(132, 142, 156, .18)' : 'transparent', + background: kind === selectedKind && eventId === selectedEventId ? 'rgba(132, 142, 156, .18)' : 'transparent', color: '#EAECEF', font: '11px/16px BinancePlex, ui-sans-serif, system-ui, sans-serif', textAlign: 'left', @@ -331,6 +378,7 @@ export function createStrategy27EventPanel(document, chartRoot, { height: '7px', borderRadius: '50%', background: annotation.markerColor ?? 'transparent', + outline: kind === 'compound' ? '1px solid #EAECEF' : 'none', }, })); row.appendChild(createElement(document, 'span', { @@ -343,22 +391,24 @@ export function createStrategy27EventPanel(document, chartRoot, { })); row.addEventListener('click', () => { selectedEventId = eventId; + selectedKind = kind; followLatest = false; render(); }); - recent.appendChild(row); + container.appendChild(row); } } function render() { latestButton.style.color = followLatest ? '#F0B90B' : '#EAECEF'; renderDetail(); - renderRecent(); + renderRecent(recent, records, 'ordinary'); + renderRecent(compoundRecent, compoundRecords, 'compound'); } latestButton.addEventListener('click', () => { followLatest = true; - selectedEventId = orderedEntries()[0]?.[0] ?? null; + selectLatest(); render(); }); collapseButton.addEventListener('click', () => { @@ -368,39 +418,60 @@ export function createStrategy27EventPanel(document, chartRoot, { }); render(); + function upsertRecord(collection, capacity, eventId, annotation, observedAtMs) { + collection.set(eventId, { annotation, observedAtMs }); + const ordered = orderedEntries(collection); + while (ordered.length > capacity) collection.delete(ordered.pop()[0]); + reconcileSelection(); + render(); + } + + function removeRecord(collection, eventId) { + collection.delete(eventId); + reconcileSelection(); + render(); + } + return Object.freeze({ upsert(eventId, annotation, observedAtMs) { - records.set(eventId, { annotation, observedAtMs }); - const ordered = orderedEntries(); - while (ordered.length > maxEvents) { - const [expiredId] = ordered.pop(); - records.delete(expiredId); - } - if (!records.has(selectedEventId)) followLatest = true; - if (followLatest) selectedEventId = orderedEntries()[0]?.[0] ?? null; - render(); + upsertRecord(records, maxEvents, eventId, annotation, observedAtMs); + }, + upsertCompound(eventId, annotation, observedAtMs) { + upsertRecord(compoundRecords, maxCompoundEvents, eventId, annotation, observedAtMs); }, remove(eventId) { - records.delete(eventId); - if (selectedEventId === eventId) { - followLatest = true; - selectedEventId = orderedEntries()[0]?.[0] ?? null; - } - render(); + removeRecord(records, eventId); + }, + removeCompound(eventId) { + removeRecord(compoundRecords, eventId); }, clear() { records.clear(); - selectedEventId = null; - followLatest = true; + reconcileSelection(); + render(); + }, + clearCompound() { + compoundRecords.clear(); + reconcileSelection(); render(); }, + setCompoundStatus(text, state) { + if (typeof text !== 'string' || !['normal', 'inactive', 'error'].includes(state)) throw new Error('Strategy 27 compound panel status is invalid'); + compoundStatus.textContent = text; + compoundStatus.dataset.state = state; + compoundStatus.style.color = state === 'error' ? '#F6465D' : '#848E9C'; + }, destroy() { records.clear(); + compoundRecords.clear(); cleanupDrag(); panel.remove(); }, get size() { return records.size; }, + get compoundSize() { + return compoundRecords.size; + }, }); } diff --git a/src/binance-strategy27-events/dom/tradingview-compound-layer.js b/src/binance-strategy27-events/dom/tradingview-compound-layer.js new file mode 100644 index 0000000..c109b3d --- /dev/null +++ b/src/binance-strategy27-events/dom/tradingview-compound-layer.js @@ -0,0 +1,121 @@ +import { createAlignedShape, createTradingViewMarkerPlacement } 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. + */ +export function createTradingViewCompoundLayer(target, { maxCandidates, candleWaitMs = 3000 }) { + if (!Number.isSafeInteger(maxCandidates) || maxCandidates < 1 || maxCandidates > 80) throw new Error('Compound chart capacity must be 1..80'); + const { chart } = target; + const placement = createTradingViewMarkerPlacement(chart, { candleWaitMs }); + const records = new Map(); + let pending = null; + + function dispose(recordsToRemove) { + const errors = []; + 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)) { + 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('; ')}`); + } + + function remove(id) { + const removals = []; + if (pending?.id === id) { + pending.controller.abort(); + removals.push(pending); + } + const record = records.get(id); + if (record) { + records.delete(id); + removals.push(record); + } + dispose(removals); + } + + function clear() { + if (pending) pending.controller.abort(); + const removals = [...records.values()]; + records.clear(); + if (pending) removals.push(pending); + dispose(removals); + } + + async function renderCandidate(id, annotation, decisionAtMs) { + if (pending !== null) throw new Error('Compound chart rendering must be serial'); + if (records.has(id)) return true; + 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]; + if (icon === undefined || !['候选高', '候选低'].includes(annotation.markerLabel)) throw new Error('Compound chart direction/label is invalid'); + const operation = { id, controller: new AbortController(), ids: [] }; + 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) 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; + 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: annotation.markerLabel, overrides: { ...options.overrides, fontsize: 12, bold: true, fillBackground: false, drawBorder: false } }], + ]; + for (const [drawingPoint, drawing] of drawings) { + const entityId = await createAlignedShape(chart, drawingPoint, drawing); + operation.ids.push(entityId); + if (operation.controller.signal.aborted) { + dispose([operation]); + return false; + } + const properties = chart.getShapeById(entityId).getProperties(); + const matched = properties.color === annotation.markerColor && (drawing.shape === 'icon' + ? properties.icon === icon && properties.size === ICON_SIZE_PX + : properties.text === annotation.markerLabel && properties.fontsize === 12); + if (!matched) throw new Error('Compound chart drawing properties did not match the requested icon/label'); + } + records.set(id, { ids: operation.ids.splice(0), group, slot, decisionAtMs }); + return true; + } catch (error) { + try { + dispose([operation]); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); + } + throw error; + } finally { + pending = null; + } + } + + return Object.freeze({ renderCandidate, remove, clear, 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 7e82480..360da5b 100644 --- a/src/binance-strategy27-events/dom/tradingview-event-layer.js +++ b/src/binance-strategy27-events/dom/tradingview-event-layer.js @@ -84,7 +84,8 @@ function createMarkerPointResolver(chart) { } } - const resolve = (annotation, { allowPreviousCandle = false } = {}) => { + const resolve = (annotation, { allowPreviousCandle = false, gapPx = DIRECTIONAL_MARKER_GAP_PX } = {}) => { + if (!Number.isFinite(gapPx)) throw new Error('Strategy 27 marker pixel gap is invalid'); if (!['arrow_up', 'arrow_down'].includes(annotation.markerShape)) { throw new Error(`Unsupported Strategy 27 marker shape: ${annotation.markerShape}`); } @@ -103,7 +104,6 @@ function createMarkerPointResolver(chart) { if (!Array.isArray(candle) || candle.length < 5 || !timeMatches) { throw new Error(`Strategy 27 candle is invalid for ${annotation.markerTime}`); } - const point = { time: candleTime, price: annotation.markerPrice }; const candleHigh = Number(candle[2]); const candleLow = Number(candle[3]); if (!Number.isFinite(candleHigh) || !Number.isFinite(candleLow)) { @@ -118,7 +118,7 @@ function createMarkerPointResolver(chart) { } const direction = annotation.markerShape === 'arrow_up' ? 1 : -1; const markerPrice = priceScale.coordinateToPrice( - edgeCoordinate + (direction * DIRECTIONAL_MARKER_GAP_PX), + edgeCoordinate + (direction * gapPx), firstValue, ); if (!Number.isFinite(markerPrice)) { @@ -126,7 +126,15 @@ function createMarkerPointResolver(chart) { } return { time: candleTime, price: markerPrice }; }; - return { dataUpdated, resolve }; + function shift(point, deltaPixels) { + if (!Number.isFinite(deltaPixels)) throw new Error('Strategy 27 marker pixel shift is invalid'); + const firstValue = mainSeries.firstValue(); + const y = priceScale.priceToCoordinate(point.price, firstValue); + const price = priceScale.coordinateToPrice(y + deltaPixels, firstValue); + if (!Number.isFinite(y) || !Number.isFinite(price)) throw new Error('Strategy 27 shifted marker coordinate is invalid'); + return { time: point.time, price }; + } + return { dataUpdated, resolve, shift }; } function verifyResolvedTime(chart, id, requestedTime) { @@ -142,7 +150,7 @@ function verifyResolvedTime(chart, id, requestedTime) { return shape; } -async function createAlignedShape(chart, point, options) { +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 { @@ -154,24 +162,18 @@ async function createAlignedShape(chart, point, options) { return id; } -export function createTradingViewEventLayer(target, { - maxEvents, - maxAgeMs, +/** Shared causal candle placement; each caller owns cancellation of its wait. */ +export function createTradingViewMarkerPlacement(chart, { candleWaitMs = DEFAULT_CANDLE_WAIT_MS, -}) { - 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'); +} = {}) { if (!Number.isInteger(candleWaitMs) || candleWaitMs < 1) { throw new Error('Strategy 27 candleWaitMs is invalid'); } - const { chart } = target; - const { dataUpdated, resolve: resolveMarkerPoint } = createMarkerPointResolver(chart); - const registry = new Map(); - const pendingCandleWaits = new Set(); - let renderGeneration = 0; + const { dataUpdated, resolve: resolveMarkerPoint, shift } = createMarkerPointResolver(chart); - function waitForMarkerPoint(annotation, requestedGeneration) { - const immediate = resolveMarkerPoint(annotation); + function wait(annotation, { signal, gapPx = DIRECTIONAL_MARKER_GAP_PX }) { + if (signal.aborted) return Promise.resolve(null); + const immediate = resolveMarkerPoint(annotation, { gapPx }); if (immediate) return Promise.resolve(immediate); return new Promise((resolve, reject) => { @@ -182,7 +184,7 @@ export function createTradingViewEventLayer(target, { const cleanup = () => { clearTimeout(timeoutId); dataUpdated.unsubscribe(owner, onDataUpdated); - pendingCandleWaits.delete(cancel); + signal.removeEventListener('abort', cancel); }; const finish = (value) => { if (settled) return; @@ -198,12 +200,12 @@ export function createTradingViewEventLayer(target, { }; const cancel = () => finish(null); const onDataUpdated = () => { - if (requestedGeneration !== renderGeneration) { + if (signal.aborted) { cancel(); return; } try { - const point = resolveMarkerPoint(annotation); + const point = resolveMarkerPoint(annotation, { gapPx }); if (point) finish(point); } catch (error) { fail(error); @@ -215,7 +217,7 @@ export function createTradingViewEventLayer(target, { // Order-book events can occur during seconds with no trades, so a // one-second chart may never create the exact candle. Anchor those // events to the latest causal candle instead of a future bar. - const previousPoint = resolveMarkerPoint(annotation, { allowPreviousCandle: true }); + const previousPoint = resolveMarkerPoint(annotation, { allowPreviousCandle: true, gapPx }); if (previousPoint) { finish(previousPoint); return; @@ -227,11 +229,26 @@ export function createTradingViewEventLayer(target, { fail(error); } }, candleWaitMs); - pendingCandleWaits.add(cancel); + signal.addEventListener('abort', cancel, { once: true }); dataUpdated.subscribe(owner, onDataUpdated); onDataUpdated(); }); } + return Object.freeze({ wait, shift }); +} + +export function createTradingViewEventLayer(target, { + maxEvents, + maxAgeMs, + candleWaitMs = DEFAULT_CANDLE_WAIT_MS, +}) { + 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 registry = new Map(); + const pendingCandleWaits = new Set(); + let renderGeneration = 0; function removeRecord(eventId) { const record = registry.get(eventId); @@ -258,7 +275,14 @@ export function createTradingViewEventLayer(target, { } if (annotation.markerShape === null) return true; const requestedGeneration = renderGeneration; - const markerPoint = await waitForMarkerPoint(annotation, requestedGeneration); + const controller = new AbortController(); + pendingCandleWaits.add(controller); + let markerPoint; + try { + markerPoint = await placement.wait(annotation, { signal: controller.signal }); + } finally { + pendingCandleWaits.delete(controller); + } if (!markerPoint || requestedGeneration !== renderGeneration) return false; pruneAge(observedAtMs); ensureCapacityForNew(); @@ -285,7 +309,7 @@ export function createTradingViewEventLayer(target, { prune: pruneAge, clear() { renderGeneration += 1; - for (const cancel of [...pendingCandleWaits]) cancel(); + for (const controller of [...pendingCandleWaits]) controller.abort(); for (const eventId of [...registry.keys()]) removeRecord(eventId); }, get size() { diff --git a/src/binance-strategy27-events/index.user.js b/src/binance-strategy27-events/index.user.js index 5f4d686..d05aa8b 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.3.1 +// @version 0.4.0 // @author jackhai9 // @description 在 Binance 一秒图表标注 VPS Strategy 27 的实时订单流候选观察 // @match https://www.binance.com/*/futures/* @@ -44,6 +44,8 @@ import { setStrategy27Status, } from './dom/tradingview-event-layer.js'; import { createStrategy27EventPanel } from './dom/strategy27-event-panel.js'; +import { createCompoundCandidateController } from './core/compound-candidate-controller.js'; +import { createTradingViewCompoundLayer } from './dom/tradingview-compound-layer.js'; import { parseFuturesTradingSymbolFromPathname } from '../shared/binance-futures-route.js'; import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; @@ -67,6 +69,7 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; function stopActive(resetReason) { if (!active) return; active.controller.abort(); + active.compound.stop(resetReason); active.lifecycle.reset(resetReason); active.layer.clear(); active.panel.destroy(); @@ -152,6 +155,7 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; }), panel: createStrategy27EventPanel(pageDocument, target.chartRoot, { maxEvents: MAX_PANEL_EVENTS, + maxCompoundEvents: MAX_PANEL_EVENTS, loadPosition: () => GM_getValue(PANEL_POSITION_KEY, null), savePosition: (position) => GM_setValue(PANEL_POSITION_KEY, position), }), @@ -159,6 +163,13 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; failed: false, }; active = context; + context.compound = createCompoundCandidateController({ + request, gatewayBaseUrl: gatewayOrigin, authSecret, canonicalSymbol, + panel: context.panel, isCurrent: () => active === context, + maxCandidates: MAX_RETAINED_EVENTS, maxAgeMs: MAX_EVENT_AGE_MS, + createLayer: () => createTradingViewCompoundLayer(target, { maxCandidates: MAX_RETAINED_EVENTS }), + }); + void context.compound.run(); showStatus(target.chartRoot, 'Strategy 27 正在连接'); const client = createLiveEventClient({ request, @@ -194,7 +205,11 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; } const chartRoot = findStrategy27ChartRoot(pageDocument); - if (!chartRoot) return; + if (!chartRoot) { + stopActive('interval_changed'); + hideStatus(); + return; + } let canonicalSymbol; try { canonicalSymbol = routeSymbolToCanonical(routeSymbol); @@ -232,6 +247,7 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; && active.target.chart === target.chart && active.target.chartRoot === target.chartRoot ) { + active.compound.prune(); return; } stopActive('route_changed'); @@ -271,6 +287,7 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; restart(); }); GM_registerMenuCommand('清除 Strategy 27 图表标注', () => { + active?.compound.clear(); active?.layer.clear(); active?.panel.clear(); hideStatus(); diff --git a/test/dom/binance-strategy27-events/compound-candidate-controller.test.js b/test/dom/binance-strategy27-events/compound-candidate-controller.test.js new file mode 100644 index 0000000..6c3c521 --- /dev/null +++ b/test/dom/binance-strategy27-events/compound-candidate-controller.test.js @@ -0,0 +1,378 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { loadFixtureDom } from '../../helpers/dom.js'; +import { createCompoundCandidateController } from '../../../src/binance-strategy27-events/core/compound-candidate-controller.js'; +import { createStrategy27EventPanel } from '../../../src/binance-strategy27-events/dom/strategy27-event-panel.js'; +import { Strategy27GatewayTransportError } from '../../../src/binance-strategy27-events/core/live-event-client.js'; + +const fixtures = JSON.parse(readFileSync(new URL('../../fixtures/strategy27-compound-candidates.json', import.meta.url), 'utf8')); +const EPOCH = 'a'.repeat(32); +const response = (body, status = 200) => ({ status, responseText: JSON.stringify(body) }); +const reset = (next = '1-0') => response({ schema_version: 1, status: 'reset', reason: 'initial_cursor', requested_cursor: null, next_cursor: next, messages: [] }); +const batch = (messages, from = '1-0', to = '2-0') => response({ schema_version: 1, status: 'ok', requested_cursor: from, next_cursor: to, messages }); +const envelope = (payload = fixtures[0], sequence = 1, epoch = EPOCH) => ({ + schema_version: 1, projection_kind: 'compound_candidate', runtime_epoch: epoch, + sequence, message_kind: 'candidate', symbol: payload.symbol, observed_at_ms: payload.decision.end_ms, payload, +}); +const state = (sequence, epoch = EPOCH) => ({ + ...envelope(fixtures[0], sequence, epoch), message_kind: 'stream_state', symbol: null, + payload: { state: 'ready', reason: 'startup' }, +}); +const deferred = () => Promise.withResolvers(); + +function harness(t, steps, { render, createError, removeError, clearError, maxAgeMs = 7200000, maxCandidates = 80 } = {}) { + const dom = loadFixtureDom('
'); + const panel = createStrategy27EventPanel(dom.window.document, dom.window.document.querySelector('.chart-widget-root'), { + maxEvents: 8, maxCompoundEvents: 8, loadPosition: () => null, savePosition: () => {}, + }); + panel.upsert('ordinary-owned', { + title: '订单流观察', eventTimeMs: 1, markerColor: '#0ECB81', summary: 'ordinary sentinel', + forceRows: [], notices: [], closeText: null, triggerText: '', + }, 1); + const parked = deferred(); + const shapes = new Map(); + const renders = []; + const removed = []; + const calls = []; + let layerCreates = 0; + let layerClears = 0; + let generation = 0; + let pending = null; + let clock = 7000; + let current = true; + let running; + const controller = createCompoundCandidateController({ + gatewayBaseUrl: 'http://127.0.0.1:18765', authSecret: 'fixture-only-not-a-credential', + canonicalSymbol: 'BTC/USDT:USDT', maxCandidates, maxAgeMs, reconnectDelayMs: 0, + panel, isCurrent: () => current, nowMs: () => clock, + request: async ({ url, signal }) => { + calls.push(new URL(url)); + if (!steps.length) { + parked.resolve(); + return new Promise((resolve) => signal.addEventListener('abort', () => resolve({ status: 404, responseText: 'late response' }), { once: true })); + } + const step = steps.shift(); + if (step instanceof Error) throw step; + return typeof step === 'function' ? step({ controller, panel, shapes, calls }) : step; + }, + createLayer: () => { + layerCreates += 1; + if (createError) throw createError; + return { + async renderCandidate(id, annotation, decisionAtMs) { + const token = { id, generation, cancelled: false }; + pending = token; + renders.push({ id, annotation, decisionAtMs }); + if (render) await render({ controller, id, annotation }); + pending = null; + if (token.cancelled || token.generation !== generation) return false; + shapes.set(id, annotation); + return true; + }, + remove(id) { + if (removeError) throw removeError; + if (pending?.id === id) pending.cancelled = true; + removed.push(id); + shapes.delete(id); + }, + clear() { + layerClears += 1; + generation += 1; + shapes.clear(); + if (clearError) throw clearError; + }, + }; + }, + }); + t.after(async () => { + controller.stop('stopped'); + if (running) await running; + dom.window.close(); + }); + return { + controller, panel, shapes, renders, removed, calls, + run: () => { running = controller.run(); return running; }, + parked: parked.promise, + status: () => dom.window.document.querySelector('[data-role="compound-status"]').textContent, + statusKind: () => dom.window.document.querySelector('[data-role="compound-status"]').dataset.state, + compoundRows: () => [...dom.window.document.querySelectorAll('[data-role="compound-row"]')].map((row) => row.dataset.eventId), + setClock: (value) => { clock = value; }, + setCurrent: (value) => { current = value; }, + get layerCreates() { return layerCreates; }, + get layerClears() { return layerClears; }, + }; +} + +test('controller composes real protocol/lifecycle/panel and renders each independent candidate once', async (t) => { + const h = harness(t, [reset(), batch([state(1), envelope(fixtures[0], 2), envelope(fixtures[1], 3), envelope(fixtures[0], 4)])]); + h.run(); + await h.parked; + assert.equal(h.panel.size, 1); + assert.equal(h.panel.compoundSize, 2); + assert.deepEqual([...h.shapes.keys()], fixtures.map((item) => item.candidate_id)); + assert.deepEqual(h.renders.map((item) => item.decisionAtMs), [7000, 7000]); + assert.deepEqual(h.renders.map((item) => item.annotation.markerShape), ['arrow_down', 'arrow_up']); + assert.equal(h.layerCreates, 1); + assert.equal(h.status(), '复合候选已连接'); + assert.throws(() => h.controller.run(), /already started/); + h.controller.stop('route_changed'); + assert.equal(h.shapes.size, 0); + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.panel.size, 1); +}); + +test('unsupported gateways never construct a chart layer and leave ordinary data intact', async (t) => { + const h = harness(t, [{ status: 404, responseText: 'old gateway' }]); + await h.run(); + assert.equal(h.calls.length, 1); + assert.equal(h.layerCreates, 0); + assert.equal(h.panel.size, 1); + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.status(), '网关尚未启用复合候选'); + assert.equal(h.statusKind(), 'inactive'); +}); + +test('same-decision panel ordering uses publication time without extending chart retention time', async (t) => { + const high = { ...envelope(fixtures[0], 1), observed_at_ms: 7010 }; + const low = { ...envelope(fixtures[1], 2), observed_at_ms: 7020 }; + const h = harness(t, [reset(), batch([high, low])]); + h.setClock(7030); + h.run(); + await h.parked; + assert.deepEqual(h.compoundRows(), [fixtures[1].candidate_id, fixtures[0].candidate_id]); + assert.deepEqual(h.renders.map((item) => item.decisionAtMs), [7000, 7000]); +}); + +test('503 clears compound state and recovers with a new cursor without clearing ordinary data', async (t) => { + const h = harness(t, [reset(), batch([envelope()]), response({ schema_version: 1, status: 'error', error_code: 'compound_unavailable' }, 503), + ({ panel, shapes, calls }) => { + assert.equal(panel.size, 1); + assert.equal(panel.compoundSize, 0); + assert.equal(shapes.size, 0); + assert.equal(calls.at(-1).searchParams.has('cursor'), false); + return reset('5-0'); + }, batch([envelope(fixtures[1], 1, 'b'.repeat(32))], '5-0', '6-0')]); + h.run(); + await h.parked; + assert.equal(h.panel.compoundSize, 1); + assert.deepEqual([...h.shapes.keys()], [fixtures[1].candidate_id]); + assert.equal(h.status(), '复合候选已连接'); +}); + +test('network reconnect retains compound history and original cursor', async (t) => { + const h = harness(t, [reset(), batch([envelope()]), new Strategy27GatewayTransportError('fixture connection failure'), + ({ panel, shapes, calls }) => { + assert.equal(panel.compoundSize, 1); + assert.equal(shapes.size, 1); + assert.equal(calls.at(-1).searchParams.get('cursor'), '2-0'); + return batch([envelope(fixtures[0], 2)], '2-0', '3-0'); + }]); + h.run(); + await h.parked; + assert.equal(h.renders.length, 1); + assert.equal(h.panel.size, 1); +}); + +test('stream reset clears compound view but preserves the newly accepted epoch sequence', async (t) => { + const h = harness(t, [reset(), batch([envelope(), state(1, 'b'.repeat(32)), envelope(fixtures[1], 2, 'b'.repeat(32))]), + batch([envelope(fixtures[0], 2, 'b'.repeat(32))], '2-0', '3-0')]); + await h.run(); + assert.equal(h.renders.length, 2); + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.panel.size, 1); + assert.match(h.status(), /sequence regression/); + assert.equal(h.statusKind(), 'error'); +}); + +test('contract and lazy renderer failures are terminal only for the compound job', async (t) => { + for (const mode of ['protocol', 'renderer']) { + const h = harness(t, mode === 'protocol' ? [{ status: 200, responseText: 'invalid JSON' }] : [reset(), batch([envelope()])], + mode === 'renderer' ? { createError: new Error('fixture chart capability missing') } : {}); + await h.run(); + assert.equal(h.panel.size, 1); + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.statusKind(), 'error'); + assert.match(h.status(), mode === 'renderer' ? /fixture chart capability missing/ : /JSON/); + assert.equal(h.calls.length, mode === 'renderer' ? 2 : 1); + } +}); + +test('manual clear suppresses pending render and exact replay without restarting the stream', async (t) => { + const h = harness(t, [reset(), batch([envelope(), envelope(fixtures[0], 2), envelope(fixtures[1], 3)])], { + render: ({ controller, id }) => { if (id === fixtures[0].candidate_id) controller.clear(); }, + }); + h.run(); + await h.parked; + assert.equal(h.renders.length, 2); + assert.deepEqual([...h.shapes.keys()], [fixtures[1].candidate_id]); + assert.equal(h.panel.compoundSize, 1); + assert.equal(h.panel.size, 1); + assert.equal(h.calls.length, 3); +}); + +test('ordinary clear does not reset compound replay bookkeeping', async (t) => { + const h = harness(t, [reset(), batch([envelope()]), ({ panel }) => { + panel.clear(); + return batch([envelope(fixtures[0], 2)], '2-0', '3-0'); + }]); + h.run(); + await h.parked; + assert.equal(h.panel.size, 0); + assert.equal(h.panel.compoundSize, 1); + assert.equal(h.renders.length, 1); +}); + +test('manual clear during lifecycle hash validation suppresses late display and later exact replay', async (t) => { + const validating = deferred(); + const release = deferred(); + const digest = globalThis.crypto.subtle.digest.bind(globalThis.crypto.subtle); + let digestCalls = 0; + t.mock.method(globalThis.crypto.subtle, 'digest', async (...args) => { + digestCalls += 1; + // Two gateway hashes precede the lifecycle's independent profile hash. + if (digestCalls === 3) { validating.resolve(); await release.promise; } + return digest(...args); + }); + const h = harness(t, [reset(), batch([envelope()]), batch([envelope(fixtures[0], 2)], '2-0', '3-0')]); + h.run(); + await validating.promise; + h.controller.clear(); + release.resolve(); + await h.parked; + assert.equal(digestCalls, 8); + assert.equal(h.renders.length, 0); + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.panel.size, 1); + assert.equal(h.calls.length, 4); +}); + +test('stale cursor reset removes old compound history and accepts the new stream', async (t) => { + const h = harness(t, [reset(), batch([envelope()]), + response({ schema_version: 1, status: 'reset', reason: 'stale_cursor', requested_cursor: '2-0', next_cursor: '7-0', messages: [] }, 409), + ({ panel, shapes }) => { + assert.equal(panel.compoundSize, 0); + assert.equal(shapes.size, 0); + assert.equal(panel.size, 1); + return batch([envelope(fixtures[1], 1, 'b'.repeat(32))], '7-0', '8-0'); + }]); + h.run(); + await h.parked; + assert.deepEqual([...h.shapes.keys()], [fixtures[1].candidate_id]); + assert.equal(h.panel.compoundSize, 1); + assert.equal(h.statusKind(), 'normal'); +}); + +test('stop during an asynchronous draw prevents late shapes and panel publication', async (t) => { + const drawing = deferred(); + const release = deferred(); + const h = harness(t, [reset(), batch([envelope()])], { render: async () => { drawing.resolve(); await release.promise; } }); + const done = h.run(); + await drawing.promise; + h.setCurrent(false); + h.controller.stop('interval_changed'); + release.resolve(); + await done; + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.shapes.size, 0); + assert.equal(h.panel.size, 1); + assert.equal(h.calls.length, 2); +}); + +test('age eviction during a pending draw prevents late publication and needs no new timer', async (t) => { + const drawing = deferred(); + const release = deferred(); + const h = harness(t, [reset(), batch([envelope()])], { maxAgeMs: 1000, render: async () => { drawing.resolve(); await release.promise; } }); + h.run(); + await drawing.promise; + h.setClock(8001); + h.controller.prune(); + release.resolve(); + await h.parked; + assert.deepEqual(h.removed, [fixtures[0].candidate_id]); + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.shapes.size, 0); + assert.equal(h.panel.size, 1); +}); + +test('post-draw age check rejects a candidate that expired while rendering', async (t) => { + const h = harness(t, [reset(), batch([envelope()])], { maxAgeMs: 1000, render: () => h.setClock(8001) }); + h.run(); + await h.parked; + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.shapes.size, 0); + assert.deepEqual(h.removed, [fixtures[0].candidate_id]); +}); + +test('timer-driven prune failures stop only the optional job and do not escape to the shared context timer', async (t) => { + const h = harness(t, [reset(), batch([envelope()])], { maxAgeMs: 1000, removeError: new Error('fixture removal failure') }); + const done = h.run(); + await h.parked; + h.setClock(8001); + assert.doesNotThrow(() => h.controller.prune()); + await done; + assert.equal(h.panel.size, 1); + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.shapes.size, 0); + assert.equal(h.statusKind(), 'error'); + assert.match(h.status(), /fixture removal failure/); +}); + +test('capacity evictions remove only the evicted compound marker', async (t) => { + const sorted = [...fixtures].sort((a, b) => a.candidate_id.localeCompare(b.candidate_id)); + const h = harness(t, [reset(), batch(sorted.map((item, index) => envelope(item, index + 1)))], { maxCandidates: 1 }); + h.run(); + await h.parked; + assert.deepEqual([...h.shapes.keys()], [sorted[1].candidate_id]); + assert.deepEqual(h.removed, [sorted[0].candidate_id]); + assert.equal(h.panel.compoundSize, 1); + assert.equal(h.panel.size, 1); +}); + +test('manual clear and stop contain native cleanup failures without retrying removal', async (t) => { + for (const action of ['clear', 'stop']) { + const h = harness(t, [reset(), batch([envelope()])], { clearError: new Error('fixture native cleanup failure') }); + const done = h.run(); + await h.parked; + assert.doesNotThrow(() => h.controller[action]('route_changed')); + await done; + assert.equal(h.layerClears, 1); + assert.equal(h.panel.size, 1); + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.statusKind(), 'error'); + assert.match(h.status(), /fixture native cleanup failure/); + } +}); + +test('terminal protocol failure preserves both the original and cleanup errors', async (t) => { + const h = harness(t, [reset(), batch([envelope()]), { status: 200, responseText: 'invalid JSON' }], { + clearError: new Error('fixture native cleanup failure'), + }); + await assert.doesNotReject(h.run()); + assert.equal(h.layerClears, 1); + assert.equal(h.panel.size, 1); + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.statusKind(), 'error'); + assert.match(h.status(), /JSON/); + assert.match(h.status(), /fixture native cleanup failure/); +}); + +test('late drawing failure after context retirement remains inspectable without updating the old panel', async (t) => { + const drawing = deferred(); + const release = deferred(); + const failure = new Error('fixture late cleanup failure'); + const h = harness(t, [reset(), batch([envelope()])], { + render: async () => { drawing.resolve(); await release.promise; throw failure; }, + }); + const done = h.run(); + await drawing.promise; + h.setCurrent(false); + h.controller.stop('route_changed'); + const retiredStatus = h.status(); + release.resolve(); + await assert.doesNotReject(done); + assert.equal(h.controller.lastError, failure); + assert.equal(h.status(), retiredStatus); + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.panel.size, 1); +}); diff --git a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js new file mode 100644 index 0000000..e6f5744 --- /dev/null +++ b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js @@ -0,0 +1,188 @@ +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'; + +const fixtures = JSON.parse(readFileSync(new URL('../../fixtures/strategy27-compound-candidates.json', import.meta.url), 'utf8')); +let importNumber = 0; + +async function until(predicate) { + const deadline = performance.now() + 2000; + while (!predicate()) { + if (performance.now() > deadline) throw new Error('Entrypoint condition deadline exceeded'); + await new Promise(setImmediate); + } +} + +async function harness(t, { generated = false } = {}) { + const dom = loadFixtureDom(''); + dom.reconfigure({ url: 'https://www.binance.com/zh-CN/futures/BTCUSDT' }); + const page = dom.window; + const shapes = new Map([['user-owned', {}]]); + let resolution = '1S'; + let shapeSequence = 0; + const chart = { + resolution: () => resolution, symbol: () => 'BTCUSDT', + createShape: async (point, options) => { + const id = `entry-owned-${++shapeSequence}`; + shapes.set(id, { getPoints: () => [point], getProperties: () => ({ ...options.overrides, icon: options.icon, text: options.text }) }); + return id; + }, + getShapeById: (id) => shapes.get(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 }; + const timers = new Map(); + const menus = new Map(); + const requests = []; + let now = 7000; + t.mock.method(Date, 'now', () => now); + t.mock.method(page, 'setInterval', (callback, delay) => { assert.equal(delay, 1000); timers.set(1, callback); return 1; }); + t.mock.method(page, 'clearInterval', (id) => timers.delete(id)); + const globals = { + unsafeWindow: page, + GM_getValue: (key, initial) => key === 'strategy27GatewayAuthSecret' ? 'synthetic-test-value' : initial, + GM_setValue: () => { throw new Error('Unexpected settings write'); }, + GM_registerMenuCommand: (name, callback) => menus.set(name, callback), + GM_xmlhttpRequest: (options) => { + const path = new URL(options.url).pathname; + const request = { kind: path.endsWith('/compound-candidates') ? 'compound' : 'ordinary', options, settled: false, aborted: false }; + requests.push(request); + return { abort() { request.aborted = true; request.settled = true; options.onabort(); } }; + }, + }; + for (const [name, value] of Object.entries(globals)) { + assert.equal(Object.hasOwn(globalThis, name), false); + globalThis[name] = value; + } + t.after(async () => { + page.dispatchEvent(new page.Event('beforeunload')); + await new Promise(setImmediate); + for (const name of Object.keys(globals)) delete globalThis[name]; + dom.window.close(); + }); + if (generated) { + const artifact = new URL('../../../scripts/binance-strategy27-events.user.js', import.meta.url); + runInThisContext(readFileSync(artifact, 'utf8'), { filename: artifact.pathname }); + } else { + await import(`../../../src/binance-strategy27-events/index.user.js?entry-test=${++importNumber}`); + } + const pending = (kind) => requests.filter((request) => request.kind === kind && !request.settled); + async function respond(kind, body, status = 200) { + await until(() => pending(kind).length > 0); + assert.equal(pending(kind).length, 1); + const request = pending(kind)[0]; + request.settled = true; + request.options.onload({ status, responseText: typeof body === 'string' ? body : JSON.stringify(body) }); + } + async function candidate(sequence = 1, cursor = '1-0', next = '2-0') { + await respond('compound', { + schema_version: 1, status: 'ok', requested_cursor: cursor, next_cursor: next, + messages: [{ schema_version: 1, projection_kind: 'compound_candidate', runtime_epoch: 'a'.repeat(32), + sequence, message_kind: 'candidate', symbol: fixtures[0].symbol, observed_at_ms: 7000, payload: fixtures[0] }], + }); + await until(() => pending('compound').length === 1); + } + return { + page, shapes, requests, pending, respond, candidate, timers, + reset: () => respond('compound', { schema_version: 1, status: 'reset', reason: 'initial_cursor', requested_cursor: null, next_cursor: '1-0', messages: [] }), + rows: () => page.document.querySelectorAll('[data-role="compound-row"]').length, + tick: () => timers.get(1)(), + clear: () => menus.get('清除 Strategy 27 图表标注')(), + setNow: (value) => { now = value; }, + setResolution: (value) => { resolution = value; }, + }; +} + +test('real entrypoint starts independent clients and manual clear preserves compound replay identity', async (t) => { + const h = await harness(t); + assert.equal(h.timers.size, 1); + assert.equal(h.pending('ordinary').length, 1); + await h.reset(); + await h.candidate(); + assert.equal(h.rows(), 1); + assert.equal(h.shapes.size, 3); + h.clear(); + assert.equal(h.rows(), 0); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + await h.candidate(2, '2-0', '3-0'); + assert.equal(h.rows(), 0); + assert.equal(h.shapes.size, 1); + assert.equal(h.pending('ordinary').length, 1); +}); + +test('ordinary failure does not stop compound; the existing timer expires candidates and interval changes abort both', async (t) => { + const h = await harness(t); + await h.respond('ordinary', 'invalid JSON'); + await h.reset(); + await h.candidate(); + assert.equal(h.rows(), 1); + h.setNow(7207001); + h.tick(); + assert.equal(h.rows(), 0); + assert.equal(h.shapes.size, 1); + 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); +}); + +test('an unsupported compound route leaves ordinary polling alive without restarting on each context tick', async (t) => { + const h = await harness(t); + await h.respond('compound', 'missing route', 404); + await until(() => h.page.document.querySelector('[data-role="compound-status"]')?.textContent === '网关尚未启用复合候选'); + h.tick(); + h.tick(); + assert.equal(h.requests.filter((request) => request.kind === 'compound').length, 1); + assert.equal(h.pending('ordinary').length, 1); + 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); +}); + +test('a disappearing chart root retires both clients and removes only owned entities', async (t) => { + const h = await harness(t); + await h.reset(); + await h.candidate(); + h.page.document.querySelector('.chart-widget-root').setAttribute('data-hidden', ''); + h.tick(); + 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']); +}); + +test('generated install artifact receives a candidate and cleans up its paired entities without affecting ordinary polling', async (t) => { + const h = await harness(t, { generated: true }); + await h.reset(); + await h.candidate(); + 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.pending('ordinary').length, 1); + h.clear(); + assert.equal(h.rows(), 0); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.equal(h.pending('ordinary').length, 1); + h.setResolution('1'); + h.tick(); + assert.equal(h.pending('ordinary').length, 0); + assert.equal(h.pending('compound').length, 0); +}); diff --git a/test/dom/binance-strategy27-events/strategy27-event-panel.test.js b/test/dom/binance-strategy27-events/strategy27-event-panel.test.js index 34f2490..2063b5e 100644 --- a/test/dom/binance-strategy27-events/strategy27-event-panel.test.js +++ b/test/dom/binance-strategy27-events/strategy27-event-panel.test.js @@ -27,6 +27,7 @@ function annotation({ time = 1_000, summary = '价格 +4.2 bps · 点差 1.2 bps function panelOptions(maxEvents, overrides = {}) { return { maxEvents, + maxCompoundEvents: maxEvents, loadPosition: () => null, savePosition: () => {}, ...overrides, @@ -51,6 +52,75 @@ test('renders one fixed detail panel and a bounded recent-event list', () => { assert.equal(panel.size, 2); }); +function compoundAnnotation(time, family = '买入推动失效') { + return { + kind: 'compound', + title: '复合候选高', + titleColor: '#FF718A', + eventTimeMs: time, + markerColor: '#B71C3B', + ruleIdentity: `impact_failure/high/${'a'.repeat(64)}`, + candidateId: 'b'.repeat(64), + summary: family, + detailRows: [ + { label: '规则', value: family }, + { label: '证据', value: '上涨背景 → 买入未推动 → 后续转弱' }, + { label: '参数版本', value: 'compound-exploration-1' }, + ], + notices: ['探索候选,尚未验证预测能力'], + }; +} + +test('ordinary and compound histories have independent bounds and reset ownership', () => { + const dom = loadFixtureDom(''); + const { document } = dom.window; + const panel = createStrategy27EventPanel(document, document.querySelector('.chart-widget-root'), panelOptions(8)); + for (let i = 0; i < 9; i += 1) { + panel.upsert(`event-${i}`, annotation({ time: 1000 + i }), 2000 + i); + panel.upsertCompound(`candidate-${i}`, compoundAnnotation(1000 + i), 2000 + i); + } + assert.equal(panel.size, 8); + assert.equal(panel.compoundSize, 8); + assert.equal(document.querySelectorAll('[data-role="event-row"]').length, 8); + assert.equal(document.querySelectorAll('[data-role="compound-row"]').length, 8); + assert.equal(document.querySelector('[data-event-id="candidate-0"]'), null); + panel.clear(); + assert.equal(panel.size, 0); + assert.equal(panel.compoundSize, 8); + assert.equal(document.querySelectorAll('[data-role="compound-row"]').length, 8); + panel.upsert('retained-ordinary', annotation({ time: 3000 }), 3000); + panel.clearCompound(); + assert.equal(panel.compoundSize, 0); + assert.equal(panel.size, 1); + assert.equal(document.querySelector('[data-role="event-row"]').dataset.eventId, 'retained-ordinary'); + panel.destroy(); +}); + +test('compound connection status survives ordinary updates and both streams expose selectable detail', () => { + const dom = loadFixtureDom(''); + const { document } = dom.window; + const panel = createStrategy27EventPanel(document, document.querySelector('.chart-widget-root'), panelOptions(8)); + panel.upsertCompound('impact', compoundAnnotation(7000), 8000); + panel.upsertCompound('passive', compoundAnnotation(7000, '被动承接转弱'), 8000); + panel.setCompoundStatus('复合候选不可用,正在重连', 'inactive'); + panel.upsert('ordinary', annotation({ time: 9000 }), 9000); + assert.equal(document.querySelector('[data-role="compound-status"]').textContent, '复合候选不可用,正在重连'); + const rows = [...document.querySelectorAll('[data-role="compound-row"]')]; + assert.equal(rows.length, 2); + assert.deepEqual(new Set(rows.map((row) => row.dataset.eventId)), new Set(['impact', 'passive'])); + rows.find((row) => row.dataset.eventId === 'passive').click(); + assert.match(document.querySelector('[data-role="event-detail"]').textContent, /被动承接转弱/); + panel.upsert('ordinary-new', annotation({ time: 10000 }), 10000); + assert.match(document.querySelector('[data-role="event-detail"]').textContent, /被动承接转弱/); + panel.removeCompound('impact'); + assert.match(document.querySelector('[data-role="event-detail"]').textContent, /被动承接转弱/); + document.querySelector('[data-role="follow-latest"]').click(); + assert.match(document.querySelector('[data-role="event-detail"]').textContent, /订单流观察/); + panel.setCompoundStatus('复合候选已连接', 'normal'); + assert.equal(document.querySelector('[data-role="compound-status"]').textContent, '复合候选已连接'); + panel.destroy(); +}); + test('keeps a manually selected event until follow-latest is restored', () => { const dom = loadFixtureDom(''); const { document } = dom.window; diff --git a/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js b/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js new file mode 100644 index 0000000..055aa03 --- /dev/null +++ b/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js @@ -0,0 +1,191 @@ +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'; + +const annotation = (overrides = {}) => ({ markerShape: 'arrow_down', markerLabel: '候选高', markerColor: '#B71C3B', markerTime: 10, markerPrice: 1.25, ...overrides }); +const deferred = () => Promise.withResolvers(); + +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 = { + 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 }; + shapes.set(id, shape); + created.push({ id, point, options }); + return id; + }, + getShapeById: (id) => shapes.get(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('native compound arrows are larger, centered outside the candle and own a separate short label', async () => { + const f = fixture(); + const layer = f.layer(); + await layer.renderCandidate('high', annotation(), 11000); + await layer.renderCandidate('low', annotation({ markerShape: 'arrow_up', markerLabel: '候选低', markerColor: '#087F5B' }), 11000); + 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(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); + } + layer.clear(); + assert.deepEqual([...f.shapes.keys()], ['user-owned']); + assert.equal(f.removed.length, 4); +}); + +test('same-time rules own independent fixed slots and eviction never moves surviving entities', async () => { + const f = fixture(); + 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 })); + 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)); + assert.equal(layer.size, 2); + assert.equal(f.created.length, 6); + assert.deepEqual(f.removed, ['owned-1', 'owned-2']); +}); + +test('different no-trade decision seconds sharing a prior candle receive distinct slots', async () => { + const f = fixture(); + const layer = f.layer(); + await layer.renderCandidate('second-11', annotation({ markerTime: 11 }), 12000); + await layer.renderCandidate('second-12', annotation({ markerTime: 12 }), 13000); + 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('remove cancels a pending candle wait without waiting for its deadline', async () => { + const f = fixture({ bars: [] }); + const layer = f.layer(); + const result = layer.renderCandidate('waiting', annotation(), 11000); + 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('clear removes a native icon that finishes creation late and never creates its label', async () => { + const entered = deferred(); + 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(); + assert.equal(await result, false); + assert.equal(layer.size, 0); + assert.deepEqual(f.removed, ['owned-1']); + assert.deepEqual([...f.shapes.keys()], ['user-owned']); +}); + +test('remove cancels a pending label and removes each part of the pair exactly once', async () => { + const entered = deferred(); + 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'); + 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']); +}); + +test('partial label failure rolls back the already-created icon', async () => { + const f = fixture({ beforeCreate: (count) => { if (count === 2) throw new Error('fixture label failure'); } }); + const layer = f.layer(); + 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('shifted times and unsupported drawing properties roll back their own entity', async () => { + for (const options of [{ shiftSeconds: 1 }, { wrongProperties: true }]) { + const f = fixture(options); + const layer = f.layer(); + 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']); + } +}); + +test('cleanup attempts both owned entities and reports failures without touching user drawings', async () => { + const f = fixture({ removeError: new Error('fixture removal failure') }); + const layer = f.layer(); + await layer.renderCandidate('high', annotation(), 11000); + 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); + layer.clear(); + assert.equal(f.removed.length, 2, 'unknown removals are not automatically retried'); +}); + +test('80 compound pairs plus 80 ordinary markers have a strict 240-entity budget', async () => { + const f = fixture(); + const ordinary = createTradingViewEventLayer({ chart: f.chart }, { maxEvents: 80, maxAgeMs: 7200000 }); + 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); + } + assert.equal(f.shapes.size, 241); + 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); + ordinary.clear(); + assert.deepEqual([...f.shapes.keys()], ['user-owned']); +}); diff --git a/test/fixtures/strategy27-compound-candidates.json b/test/fixtures/strategy27-compound-candidates.json new file mode 100644 index 0000000..3229978 --- /dev/null +++ b/test/fixtures/strategy27-compound-candidates.json @@ -0,0 +1,176 @@ +[ + { + "profile": { + "revision": "compound-exploration-1", + "context_seconds": 30, + "minimum_context_seconds": 5, + "context_move_bps": "5", + "significant_flow_ratio": "0.05", + "quiet_flow_ratio": "0.05", + "confirmation_move_bps": "1", + "confirmation_seconds": 20, + "rebound_move_bps": "1", + "reinforcement_seconds": 30 + }, + "symbol": "BTC/USDT:USDT", + "source_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "family": "impact_failure", + "direction": "high", + "parent_candidate_id": null, + "context": { + "start_ms": 0, + "end_ms": 5000, + "opening_mid": "100", + "closing_mid": "100.1", + "minimum_mid": "100", + "maximum_mid": "100.1" + }, + "established_extreme": "100.1", + "seed": { + "start_ms": 5000, + "end_ms": 6000, + "opening_mid": "100.1", + "closing_mid": "100.09", + "minimum_mid": "100.09", + "maximum_mid": "100.1", + "buy_notional": "6", + "sell_notional": "0", + "buy_count": 1, + "sell_count": 0, + "bid_addition": "0", + "bid_decrease": "1", + "ask_addition": "0", + "ask_decrease": "0", + "bid_depth": "100", + "ask_depth": "100" + }, + "confirmation": { + "start_ms": 6000, + "end_ms": 7000, + "opening_mid": "100.09", + "closing_mid": "100.07", + "minimum_mid": "100.07", + "maximum_mid": "100.09", + "buy_notional": "0", + "sell_notional": "100", + "buy_count": 0, + "sell_count": 5, + "bid_addition": "0", + "bid_decrease": "0", + "ask_addition": "0", + "ask_decrease": "0", + "bid_depth": "100", + "ask_depth": "100" + }, + "trough": null, + "rebound": null, + "decision": { + "start_ms": 6000, + "end_ms": 7000, + "opening_mid": "100.09", + "closing_mid": "100.07", + "minimum_mid": "100.07", + "maximum_mid": "100.09", + "buy_notional": "0", + "sell_notional": "100", + "buy_count": 0, + "sell_count": 5, + "bid_addition": "0", + "bid_decrease": "0", + "ask_addition": "0", + "ask_decrease": "0", + "bid_depth": "100", + "ask_depth": "100" + }, + "profile_id": "7497661840ed679fa244a0276f48be70bc077c0b8496b0c627f3744eb7c48961", + "validation_status": "exploratory", + "candidate_id": "e9695ec55fc07c3882d80e91235e04f37ebd02d16967f30e5913c63486c12dcf" + }, + { + "profile": { + "revision": "compound-exploration-1", + "context_seconds": 30, + "minimum_context_seconds": 5, + "context_move_bps": "5", + "significant_flow_ratio": "0.05", + "quiet_flow_ratio": "0.05", + "confirmation_move_bps": "1", + "confirmation_seconds": 20, + "rebound_move_bps": "1", + "reinforcement_seconds": 30 + }, + "symbol": "BTC/USDT:USDT", + "source_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "family": "impact_failure", + "direction": "low", + "parent_candidate_id": null, + "context": { + "start_ms": 0, + "end_ms": 5000, + "opening_mid": "100", + "closing_mid": "99.9", + "minimum_mid": "99.9", + "maximum_mid": "100" + }, + "established_extreme": "99.9", + "seed": { + "start_ms": 5000, + "end_ms": 6000, + "opening_mid": "99.9", + "closing_mid": "99.91", + "minimum_mid": "99.9", + "maximum_mid": "99.91", + "buy_notional": "0", + "sell_notional": "6", + "buy_count": 0, + "sell_count": 1, + "bid_addition": "0", + "bid_decrease": "0", + "ask_addition": "0", + "ask_decrease": "1", + "bid_depth": "100", + "ask_depth": "100" + }, + "confirmation": { + "start_ms": 6000, + "end_ms": 7000, + "opening_mid": "99.91", + "closing_mid": "99.93", + "minimum_mid": "99.91", + "maximum_mid": "99.93", + "buy_notional": "100", + "sell_notional": "0", + "buy_count": 5, + "sell_count": 0, + "bid_addition": "0", + "bid_decrease": "0", + "ask_addition": "0", + "ask_decrease": "0", + "bid_depth": "100", + "ask_depth": "100" + }, + "trough": null, + "rebound": null, + "decision": { + "start_ms": 6000, + "end_ms": 7000, + "opening_mid": "99.91", + "closing_mid": "99.93", + "minimum_mid": "99.91", + "maximum_mid": "99.93", + "buy_notional": "100", + "sell_notional": "0", + "buy_count": 5, + "sell_count": 0, + "bid_addition": "0", + "bid_decrease": "0", + "ask_addition": "0", + "ask_decrease": "0", + "bid_depth": "100", + "ask_depth": "100" + }, + "profile_id": "7497661840ed679fa244a0276f48be70bc077c0b8496b0c627f3744eb7c48961", + "validation_status": "unvalidated_mirror", + "candidate_id": "d41dfdb6add66f1ee6a2f5457db3535facfc2ddb74459b98ac4c826ff0a61f58" + } +] diff --git a/test/manual/strategy27-compound-panel-preview.mjs b/test/manual/strategy27-compound-panel-preview.mjs new file mode 100644 index 0000000..b13f1a9 --- /dev/null +++ b/test/manual/strategy27-compound-panel-preview.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { readFile, mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { chromium } from '@playwright/test'; +import { buildCompoundCandidateAnnotation } from '../../src/binance-strategy27-events/core/compound-candidate-annotation.js'; + +// Render the actual panel module with synthetic evidence, without a dev server +// or access to the operator's browser, accounts, or market connections. +const fixtures = JSON.parse(await readFile(new URL('../fixtures/strategy27-compound-candidates.json', import.meta.url), 'utf8')); +const source = await readFile(new URL('../../src/binance-strategy27-events/dom/strategy27-event-panel.js', import.meta.url), 'utf8'); +const sourceUrl = `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`; +const output = await mkdtemp(join(tmpdir(), 'strategy27-compound-panel-')); +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage({ viewport: { width: 1000, height: 1000 } }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.setContent('Strategy 27 · Synthetic panel fixture · No live connection
'); + await page.evaluate(async ({ sourceUrl, annotations }) => { + const { createStrategy27EventPanel } = await import(sourceUrl); + const panel = createStrategy27EventPanel(document, document.querySelector('.chart-widget-root'), { + maxEvents: 8, maxCompoundEvents: 8, + loadPosition: () => ({ left: 40, top: 80 }), savePosition: () => {}, + }); + window.fixturePanel = panel; + for (let i = 0; i < 8; i += 1) { + const annotation = annotations[i % 2]; + panel.upsertCompound(`fixture-${i}`, { ...annotation, eventTimeMs: annotation.eventTimeMs + i * 1000 }, 8000 + i * 1000); + } + panel.setCompoundStatus('复合候选已连接', 'normal'); + }, { sourceUrl, annotations: fixtures.map(buildCompoundCandidateAnnotation) }); + const panel = page.locator('#jh-strategy27-event-panel'); + await panel.waitFor({ state: 'visible' }); + assert.equal(await panel.locator('[data-role="compound-row"]').count(), 8); + assert.match(await panel.locator('[data-role="event-detail"]').textContent(), /镜像规则,尚未独立验证/); + await panel.screenshot({ path: join(output, 'low-candidate.png') }); + await page.locator('[data-role="compound-row"][data-event-id="fixture-6"]').click(); + assert.match(await panel.locator('[data-role="event-detail"]').textContent(), /复合候选高/); + await panel.screenshot({ path: join(output, 'high-candidate.png') }); + await page.evaluate(() => window.fixturePanel.setCompoundStatus('复合候选不可用,正在重连', 'inactive')); + await page.locator('[data-role="collapse"]').click(); + assert.equal(await panel.locator('[data-role="panel-body"]').isVisible(), false); + await page.locator('[data-role="collapse"]').click(); + assert.equal(await panel.locator('[data-role="compound-status"]').textContent(), '复合候选不可用,正在重连'); + const box = await panel.boundingBox(); + assert.equal(box.width, 322); + assert.equal(box.x >= 0 && box.y >= 0 && box.y + box.height <= 1000, true); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ output, checked: ['high', 'mirrored-low', 'eight-row-bound', 'collapse', 'status', 'viewport'], pageErrors: errors })); +} finally { + await browser.close(); +} diff --git a/test/manual/strategy27-native-drawing-probe.mjs b/test/manual/strategy27-native-drawing-probe.mjs new file mode 100644 index 0000000..a049a36 --- /dev/null +++ b/test/manual/strategy27-native-drawing-probe.mjs @@ -0,0 +1,89 @@ +import { mkdtemp, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { chromium } from '@playwright/test'; + +// Inspect drawing capabilities in a disposable official demo, never in an +// authenticated Binance session. Results are not Binance loaded-source proof. +const output = await mkdtemp(join(tmpdir(), 'strategy27-native-drawing-')); +const moduleUrl = (source) => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`; +const placementSource = await readFile(new URL('../../src/binance-strategy27-events/dom/tradingview-event-layer.js', import.meta.url), 'utf8'); +const compoundSource = await readFile(new URL('../../src/binance-strategy27-events/dom/tradingview-compound-layer.js', import.meta.url), 'utf8'); +const compoundUrl = moduleUrl(compoundSource.replace("'./tradingview-event-layer.js'", JSON.stringify(moduleUrl(placementSource)))); +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage({ viewport: { width: 1200, height: 800 } }); + await page.goto('https://charting-library.tradingview-widget.com/', { waitUntil: 'domcontentloaded', timeout: 20000 }); + await page.evaluate(() => Promise.race([ + new Promise((resolve) => window.tvWidget.onChartReady(resolve)), + new Promise((_, reject) => setTimeout(() => reject(new Error('Demo chart readiness deadline exceeded')), 15000)), + ])); + const facts = await page.evaluate(async (url) => { + const { createTradingViewCompoundLayer } = await import(url); + const chart = window.tvWidget.activeChart(); + const data = chart.getSeries().data(); + const last = data.last().index; + const model = chart._chartWidget.model().model(); + const mainSeries = model.mainSeries(); + const scale = mainSeries.priceScale(); + const firstValue = mainSeries.firstValue(); + const sentinel = await chart.createShape({ time: data.valueAt(last - 15)[0], price: data.valueAt(last - 15)[2] }, { + shape: 'arrow_down', overrides: { arrowColor: '#F6465D' }, disableSave: true, + }); + const baseline = new Set(chart.getAllShapes().map((shape) => shape.id)); + const layer = createTradingViewCompoundLayer({ chart }, { maxCandidates: 80 }); + const placements = []; + for (const [id, offset, direction] of [ + ['impact-high', 45, 'high'], + ['passive-high', 45, 'high'], + ['impact-low', 85, 'low'], + ['passive-low', 85, 'low'], + ]) { + const index = last - offset; + const bar = data.valueAt(index); + const candleEdge = direction === 'high' ? bar[2] : bar[3]; + const edgeY = scale.priceToCoordinate(candleEdge, firstValue); + const rendered = await layer.renderCandidate(id, { + markerTime: bar[0], markerShape: direction === 'high' ? 'arrow_down' : 'arrow_up', + markerLabel: direction === 'high' ? '候选高' : '候选低', + markerColor: direction === 'high' ? '#B71C3B' : '#087F5B', + }, bar[0] * 1000 + 1000); + if (!rendered) throw new Error('Actual compound module did not render'); + placements.push({ id, direction, candleEdge, edgeY, x: model.timeScale().indexToCoordinate(index) }); + } + const ids = chart.getAllShapes().map((shape) => shape.id).filter((id) => !baseline.has(id)); + if (ids.length !== 8 || layer.size !== 4) throw new Error('Independent compound entity count mismatch'); + window.probeCleanup = () => { + layer.clear(); + const remaining = chart.getAllShapes().map((shape) => shape.id); + if (remaining.length !== baseline.size || remaining.some((id) => !baseline.has(id))) throw new Error('Compound cleanup changed baseline drawings'); + chart.removeEntity(sentinel); + return { compoundEntitiesRemoved: ids.length, baselinePreserved: true }; + }; + return { + resolution: chart.resolution(), + placements, + properties: ids.map((id) => { + const shape = chart.getShapeById(id); + const properties = shape.getProperties(); + const points = shape.getPoints(); + return { points, properties, y: scale.priceToCoordinate(points[0].price, firstValue) }; + }), + }; + }, compoundUrl); + await page.waitForLoadState('networkidle', { timeout: 10000 }); + const fonts = await Promise.all(page.frames().map((frame) => frame.evaluate(async () => { + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + await Promise.race([ + document.fonts.ready, + new Promise((_, reject) => setTimeout(() => reject(new Error('Demo drawing font deadline exceeded')), 5000)), + ]); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return [...document.fonts].map((font) => ({ family: font.family, status: font.status })); + }))); + await page.screenshot({ path: join(output, 'native-shapes.png') }); + const cleanup = await page.evaluate(() => window.probeCleanup()); + console.log(JSON.stringify({ output, ...facts, fonts, cleanup })); +} finally { + await browser.close(); +} diff --git a/test/unit/binance-strategy27-events/compound-candidate-annotation.test.js b/test/unit/binance-strategy27-events/compound-candidate-annotation.test.js new file mode 100644 index 0000000..7e84ce5 --- /dev/null +++ b/test/unit/binance-strategy27-events/compound-candidate-annotation.test.js @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { buildCompoundCandidateAnnotation } from '../../../src/binance-strategy27-events/core/compound-candidate-annotation.js'; + +const fixtures = JSON.parse(readFileSync(new URL('../../fixtures/strategy27-compound-candidates.json', import.meta.url), 'utf8')); + +test('compound high and mirrored low carry explicit rule identity and causal candle time', () => { + for (const [index, expected] of [[0, ['候选高', 'arrow_down', '#B71C3B']], [1, ['候选低', 'arrow_up', '#087F5B']]]) { + const candidate = fixtures[index]; + const value = buildCompoundCandidateAnnotation(candidate); + assert.deepEqual([value.markerLabel, value.markerShape, value.markerColor], expected); + assert.equal(value.eventTimeMs, candidate.decision.end_ms - 1); + assert.equal(value.markerTime, 6); + assert.equal(value.ruleIdentity, `impact_failure/${candidate.direction}/${candidate.profile_id}`); + assert.equal(value.candidateId, candidate.candidate_id); + assert.equal(value.reinforcement, false); + assert.deepEqual(value.detailRows.map((row) => row.label), ['规则', '背景', '触发秒', '主动买', '主动卖', 'bid', 'ask', '基础确认', '参数版本']); + assert.equal(value.notices.includes('镜像规则,尚未独立验证'), index === 1); + assert.equal(value.notices.includes('探索候选,尚未验证预测能力'), true); + assert.equal(Object.isFrozen(value.detailRows[0]), true); + assert.doesNotMatch(JSON.stringify(value), /15\/60|outcome|confidence/); + } +}); + +test('reinforcement describes only its own later confirmation and linked parent', () => { + const base = buildCompoundCandidateAnnotation(fixtures[0]); + const source = structuredClone(fixtures[0]); + source.family = 'failed_rebound'; + source.parent_candidate_id = source.candidate_id; + source.candidate_id = 'b'.repeat(64); + source.trough = { ...source.confirmation, start_ms: 7000, end_ms: 8000 }; + source.rebound = { ...source.confirmation, start_ms: 8000, end_ms: 9000 }; + source.decision = { ...source.confirmation, start_ms: 9000, end_ms: 10000 }; + const later = buildCompoundCandidateAnnotation(source); + assert.equal(later.markerTime, 9); + assert.equal(later.reinforcement, true); + assert.equal(later.ruleIdentity, `failed_rebound/high/${source.profile_id}/${source.parent_candidate_id}`); + assert.equal(later.summary, '候选高 · 反弹失败强化'); + assert.deepEqual(later.detailRows.filter((row) => ['低点秒', '反弹秒', '强化确认', '关联候选'].includes(row.label)).map((row) => row.label), ['低点秒', '反弹秒', '强化确认', '关联候选']); + assert.equal(later.detailRows.find((row) => row.label === '关联候选').value, fixtures[0].candidate_id); + assert.equal(base.markerTime, 6); + assert.equal(base.summary, '候选高 · 买入推动失效'); +}); + +test('zero active trades are explained and large quantities remain compact', () => { + const source = structuredClone(fixtures[0]); + source.seed.sell_notional = '0'; + source.seed.sell_count = 0; + source.seed.buy_notional = '1200000'; + source.seed.buy_count = 200; + const value = buildCompoundCandidateAnnotation(source); + assert.equal(value.detailRows.find((row) => row.label === '主动卖').value, '无主动成交'); + assert.equal(value.detailRows.find((row) => row.label === '主动买').value, '1.2M USDT · 200 笔'); + source.seed.bid_addition = '0.01234'; + source.seed.bid_decrease = '0.00000678'; + const tiny = buildCompoundCandidateAnnotation(source); + assert.equal(tiny.detailRows.find((row) => row.label === 'bid').value, '增 0.012 · 减 0.0000068 USDT'); +}); diff --git a/test/unit/binance-strategy27-events/compound-candidate-client.test.js b/test/unit/binance-strategy27-events/compound-candidate-client.test.js new file mode 100644 index 0000000..3ea240a --- /dev/null +++ b/test/unit/binance-strategy27-events/compound-candidate-client.test.js @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createCompoundCandidateClient } from '../../../src/binance-strategy27-events/core/compound-candidate-client.js'; +import { Strategy27GatewayTransportError } from '../../../src/binance-strategy27-events/core/live-event-client.js'; +import { validateCompoundGatewayResponse } from '../../../src/binance-strategy27-events/core/compound-candidate-contract.js'; + +const initial = (next = '5-0') => ({ schema_version: 1, status: 'reset', reason: 'initial_cursor', requested_cursor: null, next_cursor: next, messages: [] }); +const ok = (requested = '5-0', next = '8-0') => ({ schema_version: 1, status: 'ok', requested_cursor: requested, next_cursor: next, messages: [] }); +const response = (body, status = 200) => ({ status, responseText: JSON.stringify(body) }); +const unavailable = (code = 'compound_unavailable') => response({ schema_version: 1, status: 'error', error_code: code }, 503); + +function harness(steps, { onResponse = () => {}, onState = () => {} } = {}) { + const controller = new AbortController(); + const calls = []; + const states = []; + const received = []; + const client = createCompoundCandidateClient({ + gatewayBaseUrl: 'http://127.0.0.1:18765', + authSecret: 'fixture-only-not-a-credential', + canonicalSymbol: 'BTR/USDT:USDT', + reconnectDelayMs: 0, + request: async ({ url }) => { + calls.push(new URL(url)); + assert.ok(steps.length > 0, 'unexpected extra request'); + const step = steps.shift(); + if (step instanceof Error) throw step; + return typeof step === 'function' ? step(controller) : step; + }, + onConnectionStateChange: (state) => { states.push(state); onState(state, controller); }, + onResponse: (payload) => { received.push(payload); onResponse(payload, controller); }, + }); + return { controller, calls, states, received, run: () => client.run(controller.signal) }; +} + +test('compound route owns its cursor and accepts stale-cursor resets', async () => { + const stale = { ...initial('12-0'), reason: 'stale_cursor', requested_cursor: '8-0' }; + const h = harness([response(initial()), response(ok()), response(stale, 409)], { + onResponse: (payload, controller) => { if (payload.next_cursor === '12-0') controller.abort(); }, + }); + await h.run(); + assert.deepEqual(h.calls.map((url) => url.pathname), Array(3).fill('/v1/strategy27/compound-candidates')); + assert.deepEqual(h.calls.map((url) => url.searchParams.get('cursor')), [null, '5-0', '8-0']); + assert.deepEqual(h.calls.map((url) => url.searchParams.get('symbol')), Array(3).fill('BTR/USDT:USDT')); + assert.deepEqual(h.received, [initial(), ok(), stale]); + assert.deepEqual(h.states, ['connected']); +}); + +test('404 disables compound without parsing HTML or retrying', async () => { + const h = harness([{ status: 404, responseText: 'Not Found' }]); + await h.run(); + assert.deepEqual(h.states, ['unsupported']); + assert.equal(h.calls.length, 1); + assert.deepEqual(h.received, []); +}); + +test('explicit unavailable responses reset only this cursor before recovery', async () => { + for (const code of ['compound_unavailable', 'redis_unavailable']) { + const h = harness([response(initial()), unavailable(code), response(initial('20-0'))], { + onResponse: (payload, controller) => { if (payload.next_cursor === '20-0') controller.abort(); }, + }); + await h.run(); + assert.deepEqual(h.states, ['connected', 'unavailable', 'connected']); + assert.deepEqual(h.calls.map((url) => url.searchParams.get('cursor')), [null, '5-0', null]); + assert.deepEqual(h.received, [initial(), initial('20-0')]); + } +}); + +test('typed network failures retain the cursor and recover without replaying reset', async () => { + const h = harness([response(initial()), new Strategy27GatewayTransportError('fixture transport failure'), response(ok())], { + onResponse: (payload, controller) => { if (payload.status === 'ok') controller.abort(); }, + }); + await h.run(); + assert.deepEqual(h.states, ['connected', 'reconnecting', 'connected']); + assert.deepEqual(h.calls.map((url) => url.searchParams.get('cursor')), [null, '5-0', '5-0']); + assert.deepEqual(h.received, [initial(), ok()]); +}); + +test('protocol failures stop instead of being classified as transient transport failures', async () => { + for (const bad of [ + { status: 200, responseText: 'not JSON' }, + unavailable('unknown_error'), + response({ schema_version: 1, status: 'error', error_code: 'unauthorized' }, 401), + response(ok('9-0')), + new Error('fixture internal failure'), + ]) { + const h = harness([bad]); + await assert.rejects(h.run()); + assert.equal(h.calls.length, 1); + assert.deepEqual(h.received, []); + assert.deepEqual(h.states, []); + } + const h = harness([response(initial()), response(ok('5-0', '4-9'))]); + await assert.rejects(h.run(), /cursor mismatch\/regression/); + assert.deepEqual(h.received, [initial()]); +}); + +test('aborted requests cannot publish late unsupported or unavailable states', async () => { + for (const late of [{ status: 404, responseText: 'missing' }, unavailable(), response(initial())]) { + const h = harness([(controller) => { controller.abort(); return late; }]); + await h.run(); + assert.deepEqual(h.states, []); + assert.deepEqual(h.received, []); + assert.equal(h.calls.length, 1); + } +}); + +test('abort interrupts the designed unavailable retry without issuing another request', async () => { + const h = harness([unavailable()], { onState: (_, controller) => controller.abort() }); + await assert.rejects(h.run(), { name: 'AbortError' }); + assert.equal(h.calls.length, 1); + assert.deepEqual(h.states, ['unavailable']); +}); + +test('gateway response wrapper validates exact status, cursor and message bounds', async () => { + assert.deepEqual(await validateCompoundGatewayResponse(initial(), 200), initial()); + assert.deepEqual(await validateCompoundGatewayResponse(ok(), 200), ok()); + for (const [body, status] of [ + [{ ...initial(), extra: true }, 200], + [{ ...initial(), requested_cursor: '1-0' }, 200], + [{ ...initial(), messages: [{}] }, 200], + [{ ...ok(), next_cursor: '01-0' }, 200], + [{ ...ok(), messages: Array(129).fill({}) }, 200], + [ok(), 409], + [{ ...initial(), reason: 'stale_cursor', requested_cursor: '1-0' }, 200], + ]) await assert.rejects(validateCompoundGatewayResponse(body, status)); +}); diff --git a/test/unit/binance-strategy27-events/compound-candidate-contract.test.js b/test/unit/binance-strategy27-events/compound-candidate-contract.test.js new file mode 100644 index 0000000..8cbe936 --- /dev/null +++ b/test/unit/binance-strategy27-events/compound-candidate-contract.test.js @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + canonicalCompoundJson, + compoundHash, + validateCompoundCandidate, + validateCompoundEnvelope, +} from '../../../src/binance-strategy27-events/core/compound-candidate-contract.js'; + +// Synthetic Python detector output locks cross-language canonical bytes/hashes. +const fixtures = JSON.parse(readFileSync(new URL('../../fixtures/strategy27-compound-candidates.json', import.meta.url), 'utf8')); +const candidate = (index = 0) => structuredClone(fixtures[index]); +const envelope = (payload = candidate()) => ({ + schema_version: 1, projection_kind: 'compound_candidate', runtime_epoch: 'a'.repeat(32), + sequence: 2, message_kind: 'candidate', symbol: payload.symbol, observed_at_ms: 8000, payload, +}); + +test('Python high/low records validate with identical canonical hashes', async () => { + assert.equal(fixtures[0].candidate_id, 'e9695ec55fc07c3882d80e91235e04f37ebd02d16967f30e5913c63486c12dcf'); + assert.equal(fixtures[1].candidate_id, 'd41dfdb6add66f1ee6a2f5457db3535facfc2ddb74459b98ac4c826ff0a61f58'); + for (const fixture of fixtures) { + assert.deepEqual(await validateCompoundCandidate(fixture), fixture); + assert.deepEqual(await validateCompoundEnvelope(envelope(fixture)), envelope(fixture)); + const reordered = Object.fromEntries(Object.entries(fixture).reverse()); + assert.equal(canonicalCompoundJson(reordered), canonicalCompoundJson(fixture)); + assert.deepEqual(await validateCompoundCandidate(reordered), fixture); + } +}); + +test('changed evidence cannot retain an old candidate identity', async () => { + const changed = candidate(); + changed.seed.buy_notional = '7'; + await assert.rejects(validateCompoundCandidate(changed), /candidate hash mismatch/); + const profile = candidate(); + profile.profile.significant_flow_ratio = '0.06'; + await assert.rejects(validateCompoundCandidate(profile), /profile hash mismatch/); +}); + +test('canonical decimal grammar and exact endpoint comparisons reject invalid evidence', async () => { + for (const invalid of ['-0', '01', '1e-5', '1.0', 'NaN']) { + const value = candidate(); + value.seed.buy_notional = invalid; + await assert.rejects(validateCompoundCandidate(value), /canonical decimal/); + } + const precise = candidate(); + precise.seed.minimum_mid = '100.0900000000000000000001'; + await assert.rejects(validateCompoundCandidate(precise), /extrema/); + const unsafe = envelope(); + unsafe.sequence = Number.MAX_SAFE_INTEGER + 1; + await assert.rejects(validateCompoundEnvelope(unsafe), /safe integer/); +}); + +test('mirror disclosure, causal order, frozen extreme and exact keys are required', async () => { + const low = candidate(1); + low.validation_status = 'exploratory'; + await assert.rejects(validateCompoundCandidate(low), /validation status/); + const earlier = candidate(); + earlier.confirmation = structuredClone(earlier.seed); + await assert.rejects(validateCompoundCandidate(earlier), /evidence ordering/); + const extreme = candidate(); + extreme.established_extreme = '1000'; + await assert.rejects(validateCompoundCandidate(extreme), /frozen extreme/); + const extra = candidate(); + extra.confidence = '0.9'; + await assert.rejects(validateCompoundCandidate(extra), /keys must be exact/); +}); + +test('self-contained reinforcement keeps its distinct identity and later decision', async () => { + const value = candidate(); + value.parent_candidate_id = value.candidate_id; + value.family = 'failed_rebound'; + value.trough = { ...value.confirmation, start_ms: 7000, end_ms: 8000 }; + value.rebound = { ...value.confirmation, start_ms: 8000, end_ms: 9000 }; + value.decision = { ...value.confirmation, start_ms: 9000, end_ms: 10000 }; + const { candidate_id: baseId, ...record } = value; + value.candidate_id = await compoundHash(record); + const validated = await validateCompoundCandidate(value); + assert.notEqual(validated.candidate_id, baseId); + assert.equal(validated.parent_candidate_id, baseId); + assert.equal(validated.decision.end_ms, 10000); + value.rebound = structuredClone(value.trough); + await assert.rejects(validateCompoundCandidate(value), /reinforcement evidence ordering/); +}); + +test('heartbeat and reset use separate exact control payloads', async () => { + const value = { ...envelope(), message_kind: 'heartbeat', symbol: null, payload: { state: 'ready' } }; + assert.deepEqual(await validateCompoundEnvelope(value), value); + value.payload.reason = 'startup'; + await assert.rejects(validateCompoundEnvelope(value), /keys must be exact/); + value.message_kind = 'stream_state'; + assert.deepEqual(await validateCompoundEnvelope(value), value); + value.symbol = 'BTC/USDT:USDT'; + await assert.rejects(validateCompoundEnvelope(value), /state identity/); +}); + +test('envelope cannot claim a decision before it is available or on a different symbol', async () => { + const early = envelope(); + early.observed_at_ms = 6999; + await assert.rejects(validateCompoundEnvelope(early), /identity\/time/); + const other = envelope(); + other.symbol = 'ETH/USDT:USDT'; + await assert.rejects(validateCompoundEnvelope(other), /identity\/time/); +}); diff --git a/test/unit/binance-strategy27-events/compound-candidate-lifecycle.test.js b/test/unit/binance-strategy27-events/compound-candidate-lifecycle.test.js new file mode 100644 index 0000000..9e5f399 --- /dev/null +++ b/test/unit/binance-strategy27-events/compound-candidate-lifecycle.test.js @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { compoundHash } from '../../../src/binance-strategy27-events/core/compound-candidate-contract.js'; +import { CompoundCandidateLifecycle } from '../../../src/binance-strategy27-events/core/compound-candidate-lifecycle.js'; + +const fixtures = JSON.parse(readFileSync(new URL('../../fixtures/strategy27-compound-candidates.json', import.meta.url), 'utf8')); +const EPOCH = 'a'.repeat(32); +const envelope = (payload = structuredClone(fixtures[0]), sequence = 2, epoch = EPOCH) => ({ + schema_version: 1, projection_kind: 'compound_candidate', runtime_epoch: epoch, + sequence, message_kind: 'candidate', symbol: payload.symbol, observed_at_ms: payload.decision.end_ms, payload, +}); +const control = (sequence, kind = 'heartbeat', epoch = EPOCH) => ({ + ...envelope(fixtures[0], sequence, epoch), message_kind: kind, symbol: null, + payload: kind === 'heartbeat' ? { state: 'ready' } : { state: 'ready', reason: 'startup' }, +}); +const lifecycle = (maxCandidates = 80, maxAgeMs = 7200000) => new CompoundCandidateLifecycle('BTC/USDT:USDT', { maxCandidates, maxAgeMs }); + +async function shiftedCandidate(seconds) { + const value = structuredClone(fixtures[0]); + for (const field of ['context', 'seed', 'confirmation', 'decision']) { + value[field].start_ms += seconds * 1000; + value[field].end_ms += seconds * 1000; + } + const { candidate_id, ...record } = value; + value.candidate_id = await compoundHash(record); + return value; +} + +test('independent high/low records coexist at one second and exact replay is immutable', async () => { + const state = lifecycle(); + const high = await state.apply(envelope(), 7000); + const low = await state.apply(envelope(structuredClone(fixtures[1]), 5), 7000); + assert.equal(high.type, 'candidate'); + assert.equal(low.type, 'candidate'); + assert.equal(state.size, 2); + assert.notEqual(high.candidate.candidate_id, low.candidate.candidate_id); + const replay = await state.apply(envelope(structuredClone(fixtures[0]), 8), 8000); + assert.equal(replay.type, 'replay'); + assert.equal(state.size, 2); + assert.throws(() => { high.candidate.seed.buy_notional = '999'; }, TypeError); + const altered = envelope(structuredClone(fixtures[0]), 9); + altered.payload.seed.buy_notional = '999'; + await assert.rejects(state.apply(altered, 8000), /candidate hash mismatch/); + assert.equal(state.lastSequence, 8); +}); + +test('heartbeat retains history, epoch reset clears it, and sequence jumps are valid', async () => { + const state = lifecycle(); + assert.equal((await state.apply(control(1, 'stream_state'), 7000)).type, 'stream_reset'); + await state.apply(envelope(), 7000); + assert.equal((await state.apply(control(7), 7000)).type, 'heartbeat'); + assert.equal(state.size, 1); + await assert.rejects(state.apply(control(7), 7000), /sequence regression/); + await assert.rejects(state.apply(control(8, 'stream_state'), 7000), /Unexpected stream_state/); + await assert.rejects(state.apply(envelope(fixtures[0], 1, 'b'.repeat(32)), 7000), /epoch changed without stream_state/); + const reset = await state.apply(control(1, 'stream_state', 'b'.repeat(32)), 7000); + assert.deepEqual(reset.removedCandidateIds, [fixtures[0].candidate_id]); + assert.equal(state.size, 0); + assert.equal(state.runtimeEpoch, 'b'.repeat(32)); +}); + +test('age is based on the original decision, not replay or heartbeat delivery', async () => { + const state = lifecycle(80, 1000); + await state.apply(envelope(), 7000); + assert.equal((await state.apply(envelope(fixtures[0], 3), 8000)).type, 'replay'); + const heartbeat = await state.apply(control(4), 8001); + assert.deepEqual(heartbeat.removedCandidateIds, [fixtures[0].candidate_id]); + assert.equal(state.size, 0); + const old = await state.apply(envelope(fixtures[0], 5), 8001); + assert.equal(old.type, 'expired'); + assert.equal(state.size, 0); +}); + +test('capacity eviction keeps the newest decisions and does not resurrect evicted replay', async () => { + const state = lifecycle(2); + const candidates = await Promise.all([shiftedCandidate(0), shiftedCandidate(1), shiftedCandidate(2)]); + await state.apply(envelope(candidates[0], 2), 9000); + await state.apply(envelope(candidates[1], 3), 9000); + const latest = await state.apply(envelope(candidates[2], 4), 9000); + assert.deepEqual(latest.removedCandidateIds, [candidates[0].candidate_id]); + assert.equal(state.size, 2); + assert.equal((await state.apply(envelope(candidates[0], 5), 9000)).type, 'expired'); + assert.equal(state.size, 2); + assert.deepEqual(state.prune(7209001), candidates.slice(1).map((value) => value.candidate_id)); + assert.equal(state.size, 0); +}); + +test('same-time independent family is not deduplicated by direction or timestamp', async () => { + const state = lifecycle(); + const passive = structuredClone(fixtures[0]); + passive.family = 'passive_support_loss'; + const { candidate_id, ...record } = passive; + passive.candidate_id = await compoundHash(record); + await state.apply(envelope(), 7000); + const action = await state.apply(envelope(passive, 3), 7000); + assert.equal(action.type, 'candidate'); + assert.equal(state.size, 2); +}); + +test('a reset during async validation invalidates the in-flight application', async () => { + const state = lifecycle(); + const pending = state.apply(envelope(), 7000); + state.reset('unavailable'); + assert.equal((await pending).type, 'cancelled'); + assert.equal(state.size, 0); + assert.equal(state.runtimeEpoch, null); + const raw = envelope(); + const applying = state.apply(raw, 7000); + raw.payload.seed.buy_notional = '999'; + const action = await applying; + assert.equal(action.type, 'candidate'); + assert.equal(action.candidate.seed.buy_notional, fixtures[0].seed.buy_notional); +}); + +test('wrong-symbol data cannot mutate stream state', async () => { + const state = new CompoundCandidateLifecycle('ETH/USDT:USDT', { maxCandidates: 2, maxAgeMs: 1000 }); + await assert.rejects(state.apply(envelope(), 7000), /symbol does not match/); + assert.equal(state.size, 0); + assert.equal(state.runtimeEpoch, null); +}); diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index e310c18..8a8143e 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -37,7 +37,7 @@ test('release contract identifies the generated Strategy 27 annotation artifact' assert.equal(contract.name, '【自写】Binance Strategy 27 事件标注'); assert.equal(contract.namespace, 'binance.strategy27.events'); - assert.equal(contract.version, '0.3.1'); + assert.equal(contract.version, '0.4.0'); assert.equal(contract.runAt, 'document-idle'); assert.equal(contract.updateURL, contract.downloadURL); assert.deepEqual(contract.matches, [ From 68898b2459fbe85cd4f08443099d8799bdee28e1 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:55:33 +0800 Subject: [PATCH 2/2] docs(strategy27): separate publication and operator acceptance Track coordinated release evidence separately from pending Binance one-second chart validation. --- docs/binance-strategy27-events-development.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/binance-strategy27-events-development.md b/docs/binance-strategy27-events-development.md index 48e3988..96efa86 100644 --- a/docs/binance-strategy27-events-development.md +++ b/docs/binance-strategy27-events-development.md @@ -88,7 +88,7 @@ discontinuities abort the request and remove only those transient entities. Marker count and age are bounded on the chart; the panel retains at most eight events. -## Compound Candidate Extension (Local Implementation) +## 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 @@ -97,7 +97,10 @@ controller are wired into the entrypoint and tested together. The source and generated install artifact are version 0.4.0 with identical metadata headers. The generated artifact passes syntax, release-contract and isolated execution checks, including candidate delivery, paired entities, clear and context stop. -Publication and Binance operator-page validation remain outstanding. +Binance operator-page validation remains outstanding. Server/gateway rollout +must precede browser publication; release status is tracked in +[CorsairQuant PR 324](https://github.com/jackhai9/CorsairQuant/pull/324) and +[userscripts PR 267](https://github.com/jackhai9/userscripts/pull/267). Do not treat source unit tests or the panel fixture as deployment evidence. - The compound client has a separate cursor for