diff --git a/docs/binance-strategy27-events-development.md b/docs/binance-strategy27-events-development.md index b47401c..746412a 100644 --- a/docs/binance-strategy27-events-development.md +++ b/docs/binance-strategy27-events-development.md @@ -11,7 +11,7 @@ V10 live projection. The VPS remains the only market-data and event-analysis authority. The userscript opens no Binance market-data WebSocket, uses no Binance API key, and does not recalculate the four force groups. -Version 0.6.4 retains this installation's private gateway configuration and +Version 0.6.5 retains this installation's private gateway configuration and provides a shared read-only transport. Strategy29 owns its own summary panel, lifecycle and panel position. The existing `strategy27GatewayOrigin` and `strategy27GatewayAuthSecret` storage keys remain the single credential source; @@ -165,11 +165,11 @@ ADR 032 in CorsairQuant owns the server-side rule and transport contract. The browser does not reconstruct candidates from ordinary events or recalculate market evidence. The client, lifecycle, panel, native chart layer and optional-job controller are wired into the entrypoint and tested together. The source and -generated install artifact are version 0.6.4 with identical metadata headers. +generated install artifact are version 0.6.5 with identical metadata headers. The generated artifact passes syntax, release-contract and isolated execution checks, including candidate delivery, paired entities, clear and context stop. Binance operator-page validation remains outstanding. Server/gateway rollout -must precede browser publication; release status is tracked in +must precede browser publication. Initial compound publication history is recorded 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. diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index 6288419..0faec04 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -10,8 +10,8 @@ already-loaded native chart candles. The summary reads the authenticated unified loopback gateway; it does not call Binance market-data or account APIs, submit orders, rotate hidden charts, or add remote events as chart drawings. -Install Strategy29 0.5.4 with orderbook 2.7.199 or later, or use it alone. -Install CorsairQuant signal client 0.6.1 for the remote summary. +Install Strategy29 0.5.5 with orderbook 2.7.199 or later, or use it alone. +Install CorsairQuant signal client 0.6.5 for the remote summary. Do not combine it with the embedded observer in orderbook 2.7.198. After updating/disabling the old script, reload the page. An embedded observer is an explicit conflict: Strategy29 stops and displays an upgrade/reload notice. @@ -99,7 +99,21 @@ Server status freshness and signal times remain separate from local chart state. Panel timestamps explicitly use `UTC+08` rather than inheriting the browser's ambient timezone. -Observer API compatibility is `29_2_spec_v3`; durable event records retain +The main view shows the current symbol, connection, monitoring selection, +configured intervals, event-check time and recent signals. Each configured +interval retains up to three signals, then all retained rows share the same +descending close-time and sequence ordering. With six configured intervals the +maximum is 18 rows. An interval with no retained events is named separately; +the panel never fills its quota with another interval or synthetic signals. +The default-collapsed Diagnostics section contains specification compatibility, +the local detector fingerprint, selection details, stored processing progress +and global delivery totals. Processing rows sort by interval duration, from +minutes through days and weeks. Actionable connection, compatibility, selection +and processing failures remain visible in the main view; raw processing reasons +remain in Diagnostics. Delivery totals describe the server-wide notification +outbox and do not establish whether notifications are enabled. + +Observer API compatibility is `29_2_spec_v4`; durable event records retain `29_2_spec_v2` and are validated independently; the chart detector retains the frozen V1 reference and unchanged hash. The server independently ranks an activity-score universe and applies its configured intervals. Each status poll replaces current @@ -157,21 +171,34 @@ off, `gateway_unavailable` when an enabled fixed backend cannot be reached, and these separately. Installing the unified client does not activate server monitoring. The browser polls status first and then consumes at most two event pages per -scheduled poll. A new route requests `mode=latest&limit=20` for its canonical -symbol: the server selects the most recent signal close times and a global increment -cursor from one SQLite snapshot. It does not scan retained global history to -fill the panel. Subsequent increments can contain no matching events while still -advancing that cursor. `cursor_expired` clears only remote rows and requests a new -latest snapshot. Rows are displayed and bounded by descending signal close time, -with descending durable sequence breaking ties. The snapshot response remains -sequence-ascending so the global increment contract is unchanged. Historical -backfills cannot evict newer signal times merely by being inserted later. +scheduled poll. A new route requests `mode=latest_per_timeframe&limit=3` for its +canonical symbol. The server reads its configured intervals, the three newest +retained events for each interval, and a global increment cursor from one SQLite +snapshot. Current market membership does not filter retained event history. +Subsequent increments can contain no matching events while still advancing that +cursor. `cursor_expired` clears only remote rows and requests another per-interval +snapshot. Rows are retained independently for each interval by descending signal +close time, with descending durable sequence breaking ties, and then displayed +in that same combined order. The snapshot response remains sequence-ascending so +the global increment contract is unchanged. Historical backfills cannot evict +newer signal times merely by being inserted later. + +The client remembers the last nonempty set of configured intervals. A change to +that set clears remote rows and resets the cursor before requesting a new +snapshot; order-only changes and selection generations do not reset it. An empty +configuration during unavailable selection preserves the last known set and +history. If initialization consumed events before any configuration was known, +the first nonempty set also resynchronizes. A failed resynchronization leaves the +cursor unset. Status and events are separate requests that can straddle a server +restart, so snapshot validation uses the protocol's 13 supported intervals and a +39-row maximum, not the preceding status response's interval count. Each interval +is still capped at three and the snapshot must have `has_more: false`. An empty increment advances the event-check timestamp without sorting retained records or replacing their DOM rows. Nonempty increments sort once for retention and rendering; locale changes still rebuild the translated rows. -This requires the server's explicit latest +This requires the server's explicit per-interval snapshot query contract; a server rejecting it stops the remote context visibly. -Publish the V3 observer API contract before the client, then verify installed +Publish the V4 observer API contract before the client, then verify installed source identity and reload before remote acceptance. Publication of either component does not enable the observer, gateway, or notifications. @@ -195,7 +222,7 @@ The status validator first checks the shared schema/spec/time identity envelope. A different spec exposes only those three fields; no incompatible unit, selection or delivery payload is interpreted. The panel clears current health rows, displays the mismatch and preserves retained events; event consumption is blocked. Matching -V3 API responses still require every exact field and a coherent refresh state/reason +V4 API responses require every exact field and a coherent refresh state/reason combination. An unknown schema envelope remains a contract error. The local reference hash is displayed and exposed for audit, but the current server status schema does not carry a hash, so the UI does not claim hash-level remote parity. diff --git a/scripts/binance-strategy27-events.user.js b/scripts/binance-strategy27-events.user.js index 99bb50c..9844d59 100644 --- a/scripts/binance-strategy27-events.user.js +++ b/scripts/binance-strategy27-events.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.6.4 +// @version 0.6.5 // @author jackhai9 // @description Display Strategy 27 events and provide the shared private CorsairQuant gateway connection // @match https://www.binance.com/*/futures/* @@ -3061,7 +3061,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`, throw new TypeError("Signal gateway route or symbol is invalid"); } const cursor = query.get("cursor"); - const latest = keys2.length === 3 && keys2.every((key) => ["symbol", "mode", "limit"].includes(key)) && query.get("mode") === "latest" && query.get("limit") === "20"; + const latest = keys2.length === 3 && keys2.every((key) => ["symbol", "mode", "limit"].includes(key)) && (query.get("mode") === "latest" && query.get("limit") === "20" || query.get("mode") === "latest_per_timeframe" && query.get("limit") === "3"); const increment = keys2.length === 2 && keys2.includes("cursor") && /^(0|[1-9]\d*)$/.test(cursor) && Number.isSafeInteger(Number(cursor)); if (!latest && !increment) throw new TypeError("Signal gateway event query is invalid"); return path; diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index 3dff227..ace93c4 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy29.bollinger // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.5.4 +// @version 0.5.5 // @author jackhai9 // @description Native Bollinger/SMA60 markers and the default read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* @@ -1406,10 +1406,18 @@ // src/binance-strategy29-bollinger/core/remote-summary-contract.js var STRATEGY29_SCHEMA_VERSION = 1; - var STRATEGY29_API_SPEC_VERSION = "29_2_spec_v3"; + var STRATEGY29_API_SPEC_VERSION = "29_2_spec_v4"; var STRATEGY29_EVENT_SPEC_VERSION = "29_2_spec_v2"; var STRATEGY29_REFERENCE_SHA256 = "eece8cf16e58340910587962f3bfbb19acb72155c09a52b4b6c0570cc979ef8d"; - var TIMEFRAMES = /* @__PURE__ */ new Set(["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "1w"]); + var STRATEGY29_TIMEFRAMES = Object.freeze(["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "1w"]); + var STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME = 3; + var TIMEFRAMES = new Set(STRATEGY29_TIMEFRAMES); + function compareStrategy29Timeframes(left, right) { + const leftIndex = STRATEGY29_TIMEFRAMES.indexOf(left); + const rightIndex = STRATEGY29_TIMEFRAMES.indexOf(right); + if (leftIndex < 0 || rightIndex < 0) throw new TypeError("Strategy29 timeframe is invalid"); + return leftIndex - rightIndex; + } var UNIT_STATUSES = /* @__PURE__ */ new Set(["warming", "ready", "stale", "insufficient_history", "data_gap", "failed"]); var DIRECTIONS = /* @__PURE__ */ new Set(["bearish", "bullish"]); var SIGNAL_TYPES = /* @__PURE__ */ new Set(["warning", "confirmed", "reversal"]); @@ -1723,8 +1731,8 @@ const url = new URL("/v1/strategy29/events", "https://gateway.invalid"); url.searchParams.set("symbol", canonicalSymbol); if (cursor === null) { - url.searchParams.set("mode", "latest"); - url.searchParams.set("limit", "20"); + url.searchParams.set("mode", "latest_per_timeframe"); + url.searchParams.set("limit", String(STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME)); } else url.searchParams.set("cursor", String(cursor)); return url.pathname + url.search; } @@ -1738,6 +1746,7 @@ }) { assertConfiguration({ request, canonicalSymbol, maxPagesPerPoll, onStatus, onEvents, onCursorReset }); let cursor = null; + let configuredTimeframesKey = null; async function perform(path, signal) { if (!signal || typeof signal.aborted !== "boolean" || typeof signal.addEventListener !== "function") { throw new TypeError("poll requires an AbortSignal"); @@ -1762,6 +1771,14 @@ if (status.spec_version !== STRATEGY29_API_SPEC_VERSION) { return { state: "incompatible", pages: 0, hasMore: false }; } + if (status.universe.configured_timeframes.length > 0) { + const nextKey = [...status.universe.configured_timeframes].sort(compareStrategy29Timeframes).join(","); + if (nextKey !== configuredTimeframesKey && cursor !== null) { + cursor = null; + onCursorReset(null); + } + configuredTimeframesKey = nextKey; + } let pages = 0; let hasMore = false; while (pages < maxPagesPerPoll) { @@ -1786,8 +1803,8 @@ throw new Error(`Strategy29 events request failed with HTTP ${eventsResponse.status}`); } const page = validateStrategy29EventsResponse(eventsBody, 200); - if (requestedCursor === null && (page.has_more || page.events.length > 20)) { - throw new TypeError("Strategy29 latest snapshot must be complete and bounded to 20 events"); + if (requestedCursor === null && (page.has_more || page.events.length > STRATEGY29_TIMEFRAMES.length * STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME)) { + throw new TypeError("Strategy29 latest snapshot must be complete and bounded by timeframe"); } if (requestedCursor !== null && page.next_cursor < requestedCursor) { throw new TypeError("Strategy29 event cursor moved backwards"); @@ -1796,7 +1813,13 @@ throw new TypeError("Strategy29 event cursor did not advance while has_more is true"); } let previousSequence = requestedCursor; + const snapshotCounts = /* @__PURE__ */ new Map(); for (const event of page.events) { + if (requestedCursor === null) { + const count = (snapshotCounts.get(event.timeframe) ?? 0) + 1; + if (count > STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME) throw new TypeError("Strategy29 latest snapshot must be complete and bounded to 3 events per timeframe"); + snapshotCounts.set(event.timeframe, count); + } if (event.symbol !== canonicalSymbol) throw new TypeError("Strategy29 event symbol does not match the requested symbol"); if (previousSequence !== null && event.sequence <= previousSequence) { throw new TypeError("Strategy29 event sequences must advance strictly"); @@ -1973,14 +1996,22 @@ expand: pair("展开", "Expand"), waiting: pair("等待中", "Waiting"), observerSpec: (value) => pair(`观察器规格 ${value}`, `Observer spec ${value}`), - reference: (value) => pair(`本地参考版本 ${value}`, `Local reference ${value}`), + reference: (value) => pair(`本地检测代码指纹 ${value}`, `Local detector fingerprint ${value}`), + diagnostics: pair("诊断详情", "Diagnostics"), + selected: pair("当前币种已纳入监测", "Symbol is selected for monitoring"), + updateRequired: pair("服务端与客户端不兼容,请更新信号客户端和 Strategy 29 脚本。", "Server and client are incompatible. Update the signal client and Strategy 29 script."), + historicalOnly: pair("当前无法确认实时监测状态;下方为保留的历史信号。", "Live monitoring cannot currently be confirmed; retained signals below are historical."), + globalPending: (count) => pair(`服务端有 ${count} 个监测单元尚未就绪`, `${count} server monitoring units are not ready`), + missingSignals: (value) => pair(`当前保留范围内暂无信号:${value}`, `No retained signals: ${value}`), + waitingSignals: pair("等待最近信号", "Waiting for recent signals"), + perTimeframe: (count) => pair(`每周期最多 ${count} 条 · 按收盘时间排序`, `Up to ${count} per timeframe · Ordered by signal close`), noStatus: pair("尚未收到状态", "Status not received"), noEventsCheck: pair("尚未检查事件", "Events not checked"), processing: pair("最近处理状态", "Last processing status"), processingHint: pair("已保存的处理状态不代表当前实时数据已就绪。", "Stored processing status does not confirm current live readiness."), waitingDelivery: pair("全局通知 — 等待中", "Global delivery — waiting"), recent: pair("最近跨周期信号", "Recent cross-timeframe signals"), - noEvents: pair("暂无最近信号", "No recent signals"), + noEvents: pair("当前保留范围内暂无信号", "No signals in retained history"), close: (value) => pair(`收盘 ${value}`, `Close ${value}`), matched: (value) => pair(`规格版本一致 · ${value}`, `Spec version matched · ${value}`), mismatch: (local, server) => pair(`规格不一致 · 本地 ${local} · 服务端 ${server}`, `Spec mismatch · local ${local} · server ${server}`), @@ -2165,10 +2196,9 @@ const part = (name) => parts.find((item) => item.type === name)?.value; return `${part("month")}-${part("day")} ${part("hour")}:${part("minute")}:${part("second")} UTC+08`; } - function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEvents = 20, locale = resolveUiLocaleFromPathname(document.location.pathname), loadPosition, savePosition } = {}) { + function createStrategy29SummaryPanel(document, canonicalSymbol, { locale = resolveUiLocaleFromPathname(document.location.pathname), loadPosition, savePosition } = {}) { if (!document?.body) throw new Error("Strategy 29 summary panel requires document.body"); if (typeof canonicalSymbol !== "string" || canonicalSymbol.length === 0) throw new Error("Strategy 29 panel symbol is invalid"); - if (!Number.isInteger(maxEvents) || maxEvents < 1 || maxEvents > 100) throw new Error("Strategy 29 panel maxEvents is invalid"); const text = (value) => formatLocalizedText(value, locale); text(SUMMARY_COPY.waiting); if (typeof loadPosition !== "function" || typeof savePosition !== "function") throw new TypeError("Strategy 29 panel position adapters are required"); @@ -2220,8 +2250,12 @@ const statusFreshness = element(document, "div", { text: text(SUMMARY_COPY.noStatus), role: "status-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); const eventsFreshness = element(document, "div", { text: text(SUMMARY_COPY.noEventsCheck), role: "events-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); const selection = element(document, "div", { role: "selection", styles: { color: "#EAECEF", fontSize: "11px" } }); + const timeframeSummary = element(document, "div", { role: "timeframes", styles: { color: "#848E9C", fontSize: "11px" } }); + const notices = element(document, "div", { role: "notices", styles: { color: "#F0B90B", fontSize: "11px", whiteSpace: "pre-line" } }); + notices.setAttribute("aria-live", "polite"); + const selectionDetails = element(document, "div", { role: "selection-details", styles: { color: "#848E9C", fontSize: "11px" } }); const selectionRefresh = element(document, "div", { role: "selection-refresh", styles: { color: "#848E9C", fontSize: "11px" } }); - overview.append(connection, spec, reference, statusFreshness, selection, selectionRefresh, eventsFreshness); + overview.append(connection, selection, timeframeSummary, eventsFreshness, notices); const unitsTitle = element(document, "div", { text: text(SUMMARY_COPY.processing), styles: { padding: "7px 10px 4px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontWeight: "600" } }); const processingHint = element(document, "div", { text: text(SUMMARY_COPY.processingHint), @@ -2231,12 +2265,23 @@ const units = element(document, "div", { role: "units", styles: { display: "grid", gap: "3px", padding: "0 7px 8px" } }); const delivery = element(document, "div", { text: text(SUMMARY_COPY.waitingDelivery), role: "delivery", styles: { padding: "7px 10px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontSize: "11px" } }); const eventsTitle = element(document, "div", { text: text(SUMMARY_COPY.recent), styles: { padding: "7px 10px 4px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontWeight: "600" } }); + const eventsHint = element(document, "div", { text: text(SUMMARY_COPY.perTimeframe(STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME)), styles: { padding: "0 10px 5px", color: "#848E9C", fontSize: "11px" } }); const events = element(document, "div", { role: "events", styles: { display: "grid", gap: "3px", padding: "0 7px 8px" } }); - body.append(overview, unitsTitle, units, delivery, eventsTitle, events); + const missingSignals = element(document, "div", { role: "missing-signals", styles: { padding: "0 10px 8px", color: "#848E9C", fontSize: "11px" } }); + const diagnostics = element(document, "details", { role: "diagnostics", styles: { borderTop: "1px solid rgba(132,142,156,.18)" } }); + const diagnosticsTitle = element(document, "summary", { text: text(SUMMARY_COPY.diagnostics), styles: { padding: "8px 10px", color: "#848E9C", cursor: "pointer" } }); + const diagnosticOverview = element(document, "div", { styles: { display: "grid", gap: "4px", padding: "0 10px 8px" } }); + diagnosticOverview.append(spec, reference, statusFreshness, selectionDetails, selectionRefresh); + diagnostics.append(diagnosticsTitle, diagnosticOverview, unitsTitle, units, delivery); + body.append(overview, eventsTitle, eventsHint, events, missingSignals, diagnostics); panel.append(header, body); document.body.appendChild(panel); const position = installPanelPosition(document, panel, header, { initialPosition: stored, savePosition }); const eventRecords = /* @__PURE__ */ new Map(); + let configuredTimeframes = null; + let statusMessages = []; + let connectionState = null; + let statusUnreliable = true; let lastStatus = null; function clearCurrentStatus() { lastStatus = null; @@ -2247,9 +2292,13 @@ selection.dataset.state = "unavailable"; selection.style.color = "#848E9C"; selection.textContent = text(SUMMARY_COPY.noLiveStatus); + selectionDetails.textContent = ""; selectionRefresh.textContent = ""; + statusMessages = []; + statusUnreliable = true; units.replaceChildren(); delivery.textContent = text(SUMMARY_COPY.waitingDelivery); + renderNotices(); } let lastEventsAt = null; let connectionCopy = SUMMARY_COPY.waiting; @@ -2257,6 +2306,22 @@ function assertLive() { if (destroyed) throw new Error("Strategy 29 summary panel is destroyed"); } + function renderNotices() { + const messages = [...statusMessages]; + if (eventRecords.size > 0 && (statusUnreliable || ["disabled", "module_disabled", "gateway_unavailable", "unavailable", "disconnected", "stopped", "incompatible"].includes(connectionState))) { + messages.unshift(text(SUMMARY_COPY.historicalOnly)); + } + const message = messages.join("\n"); + if (notices.textContent !== message) notices.textContent = message; + notices.hidden = messages.length === 0; + } + function renderMissingSignals() { + const present = new Set([...eventRecords.values()].map((event) => event.timeframe)); + const missing = lastEventsAt === null || configuredTimeframes === null ? [] : configuredTimeframes.filter((timeframe) => !present.has(timeframe)); + const message = missing.length === 0 ? "" : text(SUMMARY_COPY.missingSignals(missing.join(", "))); + if (missingSignals.textContent !== message) missingSignals.textContent = message; + missingSignals.hidden = missing.length === 0; + } function renderEvents(ordered) { events.replaceChildren(); for (const event of ordered) { @@ -2273,7 +2338,9 @@ row.appendChild(element(document, "span", { text: text(SUMMARY_COPY.close(formatClock(event.bar_close_ms))), styles: { color: "#848E9C", fontSize: "10px", textAlign: "right" } })); events.appendChild(row); } - if (ordered.length === 0) events.appendChild(element(document, "span", { text: text(SUMMARY_COPY.noEvents), styles: { color: "#848E9C", padding: "4px" } })); + if (ordered.length === 0) events.appendChild(element(document, "span", { text: text(lastEventsAt === null ? SUMMARY_COPY.waitingSignals : SUMMARY_COPY.noEvents), styles: { color: "#848E9C", padding: "4px" } })); + renderMissingSignals(); + renderNotices(); } collapse.addEventListener("click", () => { const collapsed = body.style.display !== "none"; @@ -2281,6 +2348,9 @@ collapse.textContent = text(collapsed ? SUMMARY_COPY.expand : SUMMARY_COPY.collapse); position.clamp(); }); + diagnostics.addEventListener("toggle", () => { + if (!destroyed) position.clamp(); + }); renderEvents([]); const api = Object.freeze({ setLocale(nextLocale) { @@ -2293,9 +2363,12 @@ collapse.textContent = text(body.style.display === "none" ? SUMMARY_COPY.expand : SUMMARY_COPY.collapse); connection.textContent = text(connectionCopy); reference.textContent = text(SUMMARY_COPY.reference(STRATEGY29_REFERENCE_SHA256)); + diagnosticsTitle.textContent = text(SUMMARY_COPY.diagnostics); unitsTitle.firstChild.textContent = text(SUMMARY_COPY.processing); processingHint.textContent = text(SUMMARY_COPY.processingHint); eventsTitle.textContent = text(SUMMARY_COPY.recent); + eventsHint.textContent = text(SUMMARY_COPY.perTimeframe(STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME)); + timeframeSummary.textContent = configuredTimeframes === null ? "" : text(SUMMARY_COPY.intervals(configuredTimeframes.join(", "))); eventsFreshness.textContent = text(lastEventsAt === null ? SUMMARY_COPY.noEventsCheck : SUMMARY_COPY.eventsAt(formatClock(lastEventsAt))); if (lastStatus !== null) api.renderStatus(lastStatus); else clearCurrentStatus(); @@ -2305,11 +2378,13 @@ setConnection(state, message) { assertLive(); if (!(state in STATE_COLORS)) throw new Error("Strategy 29 panel connection state is invalid"); + connectionState = state; connection.dataset.state = state; connection.style.color = STATE_COLORS[state]; connectionCopy = message; connection.textContent = text(message); if (["disabled", "module_disabled", "gateway_unavailable", "unavailable"].includes(state)) clearCurrentStatus(); + renderNotices(); position.clamp(); }, renderStatus(snapshot) { @@ -2321,25 +2396,50 @@ spec.textContent = matched ? text(SUMMARY_COPY.matched(STRATEGY29_API_SPEC_VERSION)) : text(SUMMARY_COPY.mismatch(STRATEGY29_API_SPEC_VERSION, snapshot.spec_version)); statusFreshness.textContent = text(SUMMARY_COPY.statusAt(formatClock(snapshot.observed_at_ms))); if (!matched) { + statusUnreliable = true; + statusMessages = [text(SUMMARY_COPY.updateRequired)]; selection.dataset.state = "incompatible"; selection.style.color = "#F6465D"; selection.textContent = text(SUMMARY_COPY.incompatibleSelection); + selectionDetails.textContent = ""; selectionRefresh.textContent = ""; units.replaceChildren(); delivery.textContent = text(SUMMARY_COPY.incompatibleDelivery); + renderNotices(); position.clamp(); return; } const universe = snapshot.universe; const unavailable = universe.refresh_status === "fail_closed"; + const selected = universe.selected_markets.includes(canonicalSymbol); + statusUnreliable = unavailable || !selected; + statusMessages = []; + if (universe.configured_timeframes.length > 0) { + const next = [...universe.configured_timeframes].sort(compareStrategy29Timeframes); + if (configuredTimeframes === null || next.join(",") !== configuredTimeframes.join(",")) { + configuredTimeframes = next; + let removed = false; + for (const [id, event] of eventRecords) { + if (!next.includes(event.timeframe)) { + eventRecords.delete(id); + removed = true; + } + } + if (removed) renderEvents([...eventRecords.values()].sort(newestSignalFirst)); + } + } + timeframeSummary.textContent = configuredTimeframes === null ? "" : text(SUMMARY_COPY.intervals(configuredTimeframes.join(", "))); selection.dataset.state = universe.refresh_status; selection.style.color = unavailable ? "#F6465D" : universe.refresh_status === "fresh" ? "#0ECB81" : "#F0B90B"; - selection.textContent = [text(SELECTION_REASONS[universe.reason]), text(SUMMARY_COPY.generation(universe.generation ?? text(SUMMARY_COPY.pending))), text(SUMMARY_COPY.markets(universe.selected_markets.length)), text(SUMMARY_COPY.ready(universe.ready_unit_count, universe.selected_unit_count)), text(SUMMARY_COPY.intervals(universe.configured_timeframes.join(", ") || text(SUMMARY_COPY.pending)))].join(" · "); + selection.textContent = text(unavailable ? SELECTION_REASONS[universe.reason] : selected ? SUMMARY_COPY.selected : SUMMARY_COPY.notSelected); + selectionDetails.textContent = [text(SELECTION_REASONS[universe.reason]), text(SUMMARY_COPY.generation(universe.generation ?? text(SUMMARY_COPY.pending))), text(SUMMARY_COPY.markets(universe.selected_markets.length)), text(SUMMARY_COPY.ready(universe.ready_unit_count, universe.selected_unit_count))].join(" · "); selectionRefresh.textContent = universe.last_successful_refreshed_at_ms === null ? text(SUMMARY_COPY.noSuccess) : text(SUMMARY_COPY.lastSuccess(formatClock(universe.last_successful_refreshed_at_ms), universe.last_success_age_seconds.toFixed(1))); units.replaceChildren(); - const selected = universe.selected_markets.includes(canonicalSymbol); - const matching = unavailable || !selected ? [] : snapshot.units.filter((unit) => unit.symbol === canonicalSymbol && universe.configured_timeframes.includes(unit.timeframe)); + const matching = unavailable || !selected ? [] : snapshot.units.filter((unit) => unit.symbol === canonicalSymbol && universe.configured_timeframes.includes(unit.timeframe)).sort((left, right) => compareStrategy29Timeframes(left.timeframe, right.timeframe)); + if (!unavailable && universe.refresh_status !== "fresh") statusMessages.push(text(SELECTION_REASONS[universe.reason])); + if (!unavailable && universe.pending_unit_count > 0) statusMessages.push(text(SUMMARY_COPY.globalPending(universe.pending_unit_count))); for (const unit of matching) { + if (unit.status !== "ready") statusMessages.push(`${unit.timeframe} · ${text(STATUS_LABELS[unit.status])}`); const row = element(document, "div", { role: "unit", styles: { display: "grid", gridTemplateColumns: "36px 78px minmax(0,1fr)", gap: "6px", padding: "4px 6px", borderRadius: "5px", background: "rgba(132,142,156,.08)" } @@ -2353,27 +2453,45 @@ text: text(unavailable ? SUMMARY_COPY.unavailableSelection : selected ? SUMMARY_COPY.awaitingUnits : SUMMARY_COPY.notSelected), styles: { color: "#F0B90B", padding: "4px" } })); + if (!unavailable && selected && matching.length === 0) statusMessages.push(text(SUMMARY_COPY.awaitingUnits)); const counts = snapshot.delivery_counts; delivery.textContent = text(SUMMARY_COPY.delivery(counts)); + renderMissingSignals(); + renderNotices(); position.clamp(); }, addEvents(incoming, observedAtMs = null) { assertLive(); + const firstCheck = lastEventsAt === null && observedAtMs !== null; + if (observedAtMs !== null) lastEventsAt = observedAtMs; if (incoming.length > 0) { - for (const event of incoming) eventRecords.set(event.event_id, event); + for (const event of incoming) { + if (configuredTimeframes === null || configuredTimeframes.includes(event.timeframe)) eventRecords.set(event.event_id, event); + } const ordered = [...eventRecords.values()].sort(newestSignalFirst); - while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id); - renderEvents(ordered); + const counts = /* @__PURE__ */ new Map(); + const retained = ordered.filter((event) => { + const count = (counts.get(event.timeframe) ?? 0) + 1; + counts.set(event.timeframe, count); + if (count <= STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME) return true; + eventRecords.delete(event.event_id); + return false; + }); + renderEvents(retained); + } else if (firstCheck && eventRecords.size === 0) { + renderEvents([]); } if (observedAtMs !== null) { - lastEventsAt = observedAtMs; eventsFreshness.textContent = text(SUMMARY_COPY.eventsAt(formatClock(observedAtMs))); } + renderMissingSignals(); position.clamp(); }, clearEvents() { assertLive(); eventRecords.clear(); + lastEventsAt = null; + eventsFreshness.textContent = text(SUMMARY_COPY.noEventsCheck); renderEvents([]); position.clamp(); }, @@ -2443,7 +2561,6 @@ } function startContext(routeSymbol, gatewayState, canonicalSymbol) { const panel = createPanel(view.document, canonicalSymbol, { - maxEvents: 20, locale, loadPosition: () => getValue(STRATEGY29_PANEL_POSITION_KEY, null), savePosition: (position) => setValue(STRATEGY29_PANEL_POSITION_KEY, position) diff --git a/src/binance-strategy27-events/index.user.js b/src/binance-strategy27-events/index.user.js index 17bf7cb..0058da4 100644 --- a/src/binance-strategy27-events/index.user.js +++ b/src/binance-strategy27-events/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.6.4 +// @version 0.6.5 // @author jackhai9 // @description Display Strategy 27 events and provide the shared private CorsairQuant gateway connection // @match https://www.binance.com/*/futures/* diff --git a/src/binance-strategy29-bollinger/core/remote-summary-client.js b/src/binance-strategy29-bollinger/core/remote-summary-client.js index 581e5cc..05607b9 100644 --- a/src/binance-strategy29-bollinger/core/remote-summary-client.js +++ b/src/binance-strategy29-bollinger/core/remote-summary-client.js @@ -1,6 +1,9 @@ import { isCanonicalUsdtSymbol } from '../../shared/canonical-symbol.js'; import { STRATEGY29_API_SPEC_VERSION, + STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME, + STRATEGY29_TIMEFRAMES, + compareStrategy29Timeframes, validateStrategy29EventsResponse, validateStrategy29GatewayError, validateStrategy29StatusResponse, @@ -41,8 +44,8 @@ function buildEventsPath(canonicalSymbol, cursor) { const url = new URL('/v1/strategy29/events', 'https://gateway.invalid'); url.searchParams.set('symbol', canonicalSymbol); if (cursor === null) { - url.searchParams.set('mode', 'latest'); - url.searchParams.set('limit', '20'); + url.searchParams.set('mode', 'latest_per_timeframe'); + url.searchParams.set('limit', String(STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME)); } else url.searchParams.set('cursor', String(cursor)); return url.pathname + url.search; } @@ -58,6 +61,7 @@ export function createStrategy29SummaryClient({ }) { assertConfiguration({ request, canonicalSymbol, maxPagesPerPoll, onStatus, onEvents, onCursorReset }); let cursor = null; + let configuredTimeframesKey = null; async function perform(path, signal) { if (!signal || typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function') { @@ -85,6 +89,16 @@ export function createStrategy29SummaryClient({ if (status.spec_version !== STRATEGY29_API_SPEC_VERSION) { return { state: 'incompatible', pages: 0, hasMore: false }; } + // Missing selection facts retain history. Only a known configuration change + // invalidates the snapshot; ordinary selection generations do not. + if (status.universe.configured_timeframes.length > 0) { + const nextKey = [...status.universe.configured_timeframes].sort(compareStrategy29Timeframes).join(','); + if (nextKey !== configuredTimeframesKey && cursor !== null) { + cursor = null; + onCursorReset(null); + } + configuredTimeframesKey = nextKey; + } let pages = 0; let hasMore = false; @@ -110,8 +124,8 @@ export function createStrategy29SummaryClient({ throw new Error(`Strategy29 events request failed with HTTP ${eventsResponse.status}`); } const page = validateStrategy29EventsResponse(eventsBody, 200); - if (requestedCursor === null && (page.has_more || page.events.length > 20)) { - throw new TypeError('Strategy29 latest snapshot must be complete and bounded to 20 events'); + if (requestedCursor === null && (page.has_more || page.events.length > STRATEGY29_TIMEFRAMES.length * STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME)) { + throw new TypeError('Strategy29 latest snapshot must be complete and bounded by timeframe'); } if (requestedCursor !== null && page.next_cursor < requestedCursor) { throw new TypeError('Strategy29 event cursor moved backwards'); @@ -120,7 +134,15 @@ export function createStrategy29SummaryClient({ throw new TypeError('Strategy29 event cursor did not advance while has_more is true'); } let previousSequence = requestedCursor; + const snapshotCounts = new Map(); for (const event of page.events) { + // Status and events are separate requests and may straddle a restart. + // Bound by the protocol's supported intervals, not the earlier status. + if (requestedCursor === null) { + const count = (snapshotCounts.get(event.timeframe) ?? 0) + 1; + if (count > STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME) throw new TypeError('Strategy29 latest snapshot must be complete and bounded to 3 events per timeframe'); + snapshotCounts.set(event.timeframe, count); + } if (event.symbol !== canonicalSymbol) throw new TypeError('Strategy29 event symbol does not match the requested symbol'); if (previousSequence !== null && event.sequence <= previousSequence) { throw new TypeError('Strategy29 event sequences must advance strictly'); diff --git a/src/binance-strategy29-bollinger/core/remote-summary-contract.js b/src/binance-strategy29-bollinger/core/remote-summary-contract.js index 8d4474d..069a97a 100644 --- a/src/binance-strategy29-bollinger/core/remote-summary-contract.js +++ b/src/binance-strategy29-bollinger/core/remote-summary-contract.js @@ -1,11 +1,20 @@ import { isCanonicalUsdtSymbol, usdtRouteToCanonical, canonicalUsdtToRoute } from '../../shared/canonical-symbol.js'; export const STRATEGY29_SCHEMA_VERSION = 1; -export const STRATEGY29_API_SPEC_VERSION = '29_2_spec_v3'; +export const STRATEGY29_API_SPEC_VERSION = '29_2_spec_v4'; export const STRATEGY29_EVENT_SPEC_VERSION = '29_2_spec_v2'; export const STRATEGY29_REFERENCE_SHA256 = 'eece8cf16e58340910587962f3bfbb19acb72155c09a52b4b6c0570cc979ef8d'; -const TIMEFRAMES = new Set(['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', '1d', '1w']); +export const STRATEGY29_TIMEFRAMES = Object.freeze(['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', '1d', '1w']); +export const STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME = 3; +const TIMEFRAMES = new Set(STRATEGY29_TIMEFRAMES); + +export function compareStrategy29Timeframes(left, right) { + const leftIndex = STRATEGY29_TIMEFRAMES.indexOf(left); + const rightIndex = STRATEGY29_TIMEFRAMES.indexOf(right); + if (leftIndex < 0 || rightIndex < 0) throw new TypeError('Strategy29 timeframe is invalid'); + return leftIndex - rightIndex; +} const UNIT_STATUSES = new Set(['warming', 'ready', 'stale', 'insufficient_history', 'data_gap', 'failed']); const DIRECTIONS = new Set(['bearish', 'bullish']); const SIGNAL_TYPES = new Set(['warning', 'confirmed', 'reversal']); diff --git a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js index 2aba222..f11e66d 100644 --- a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js +++ b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js @@ -1,4 +1,4 @@ -import { STRATEGY29_REFERENCE_SHA256, STRATEGY29_API_SPEC_VERSION } from '../core/remote-summary-contract.js'; +import { STRATEGY29_REFERENCE_SHA256, STRATEGY29_API_SPEC_VERSION, STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME, compareStrategy29Timeframes } from '../core/remote-summary-contract.js'; import { SUMMARY_COPY as COPY, SELECTION_REASONS, STATUS_LABELS, SIGNAL_LABELS, processingReason, formatLocalizedText, resolveUiLocaleFromPathname } from '../ui-copy.js'; import { installPanelPosition } from './panel-position.js'; @@ -50,10 +50,9 @@ function formatClock(timestampMs) { return `${part('month')}-${part('day')} ${part('hour')}:${part('minute')}:${part('second')} UTC+08`; } -export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEvents = 20, locale = resolveUiLocaleFromPathname(document.location.pathname), loadPosition, savePosition } = {}) { +export function createStrategy29SummaryPanel(document, canonicalSymbol, { locale = resolveUiLocaleFromPathname(document.location.pathname), loadPosition, savePosition } = {}) { if (!document?.body) throw new Error('Strategy 29 summary panel requires document.body'); if (typeof canonicalSymbol !== 'string' || canonicalSymbol.length === 0) throw new Error('Strategy 29 panel symbol is invalid'); - if (!Number.isInteger(maxEvents) || maxEvents < 1 || maxEvents > 100) throw new Error('Strategy 29 panel maxEvents is invalid'); const text = value => formatLocalizedText(value, locale); text(COPY.waiting); if (typeof loadPosition !== 'function' || typeof savePosition !== 'function') throw new TypeError('Strategy 29 panel position adapters are required'); @@ -95,8 +94,12 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve const statusFreshness = element(document, 'div', { text: text(COPY.noStatus), role: 'status-freshness', styles: { color: '#848E9C', fontSize: '11px' } }); const eventsFreshness = element(document, 'div', { text: text(COPY.noEventsCheck), role: 'events-freshness', styles: { color: '#848E9C', fontSize: '11px' } }); const selection = element(document, 'div', { role: 'selection', styles: { color: '#EAECEF', fontSize: '11px' } }); + const timeframeSummary = element(document, 'div', { role: 'timeframes', styles: { color: '#848E9C', fontSize: '11px' } }); + const notices = element(document, 'div', { role: 'notices', styles: { color: '#F0B90B', fontSize: '11px', whiteSpace: 'pre-line' } }); + notices.setAttribute('aria-live', 'polite'); + const selectionDetails = element(document, 'div', { role: 'selection-details', styles: { color: '#848E9C', fontSize: '11px' } }); const selectionRefresh = element(document, 'div', { role: 'selection-refresh', styles: { color: '#848E9C', fontSize: '11px' } }); - overview.append(connection, spec, reference, statusFreshness, selection, selectionRefresh, eventsFreshness); + overview.append(connection, selection, timeframeSummary, eventsFreshness, notices); const unitsTitle = element(document, 'div', { text: text(COPY.processing), styles: { padding: '7px 10px 4px', borderTop: '1px solid rgba(132,142,156,.18)', color: '#848E9C', fontWeight: '600' } }); const processingHint = element(document, 'div', { text: text(COPY.processingHint), @@ -106,13 +109,24 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve const units = element(document, 'div', { role: 'units', styles: { display: 'grid', gap: '3px', padding: '0 7px 8px' } }); const delivery = element(document, 'div', { text: text(COPY.waitingDelivery), role: 'delivery', styles: { padding: '7px 10px', borderTop: '1px solid rgba(132,142,156,.18)', color: '#848E9C', fontSize: '11px' } }); const eventsTitle = element(document, 'div', { text: text(COPY.recent), styles: { padding: '7px 10px 4px', borderTop: '1px solid rgba(132,142,156,.18)', color: '#848E9C', fontWeight: '600' } }); + const eventsHint = element(document, 'div', { text: text(COPY.perTimeframe(STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME)), styles: { padding: '0 10px 5px', color: '#848E9C', fontSize: '11px' } }); const events = element(document, 'div', { role: 'events', styles: { display: 'grid', gap: '3px', padding: '0 7px 8px' } }); - body.append(overview, unitsTitle, units, delivery, eventsTitle, events); + const missingSignals = element(document, 'div', { role: 'missing-signals', styles: { padding: '0 10px 8px', color: '#848E9C', fontSize: '11px' } }); + const diagnostics = element(document, 'details', { role: 'diagnostics', styles: { borderTop: '1px solid rgba(132,142,156,.18)' } }); + const diagnosticsTitle = element(document, 'summary', { text: text(COPY.diagnostics), styles: { padding: '8px 10px', color: '#848E9C', cursor: 'pointer' } }); + const diagnosticOverview = element(document, 'div', { styles: { display: 'grid', gap: '4px', padding: '0 10px 8px' } }); + diagnosticOverview.append(spec, reference, statusFreshness, selectionDetails, selectionRefresh); + diagnostics.append(diagnosticsTitle, diagnosticOverview, unitsTitle, units, delivery); + body.append(overview, eventsTitle, eventsHint, events, missingSignals, diagnostics); panel.append(header, body); document.body.appendChild(panel); const position = installPanelPosition(document, panel, header, { initialPosition: stored, savePosition }); const eventRecords = new Map(); + let configuredTimeframes = null; + let statusMessages = []; + let connectionState = null; + let statusUnreliable = true; let lastStatus = null; function clearCurrentStatus() { lastStatus = null; @@ -123,9 +137,13 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve selection.dataset.state = 'unavailable'; selection.style.color = '#848E9C'; selection.textContent = text(COPY.noLiveStatus); + selectionDetails.textContent = ''; selectionRefresh.textContent = ''; + statusMessages = []; + statusUnreliable = true; units.replaceChildren(); delivery.textContent = text(COPY.waitingDelivery); + renderNotices(); } let lastEventsAt = null; let connectionCopy = COPY.waiting; @@ -133,6 +151,22 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve function assertLive() { if (destroyed) throw new Error('Strategy 29 summary panel is destroyed'); } + function renderNotices() { + const messages = [...statusMessages]; + if (eventRecords.size > 0 && (statusUnreliable || ['disabled', 'module_disabled', 'gateway_unavailable', 'unavailable', 'disconnected', 'stopped', 'incompatible'].includes(connectionState))) { + messages.unshift(text(COPY.historicalOnly)); + } + const message = messages.join('\n'); + if (notices.textContent !== message) notices.textContent = message; + notices.hidden = messages.length === 0; + } + function renderMissingSignals() { + const present = new Set([...eventRecords.values()].map(event => event.timeframe)); + const missing = lastEventsAt === null || configuredTimeframes === null ? [] : configuredTimeframes.filter(timeframe => !present.has(timeframe)); + const message = missing.length === 0 ? '' : text(COPY.missingSignals(missing.join(', '))); + if (missingSignals.textContent !== message) missingSignals.textContent = message; + missingSignals.hidden = missing.length === 0; + } function renderEvents(ordered) { events.replaceChildren(); for (const event of ordered) { @@ -149,7 +183,9 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve row.appendChild(element(document, 'span', { text: text(COPY.close(formatClock(event.bar_close_ms))), styles: { color: '#848E9C', fontSize: '10px', textAlign: 'right' } })); events.appendChild(row); } - if (ordered.length === 0) events.appendChild(element(document, 'span', { text: text(COPY.noEvents), styles: { color: '#848E9C', padding: '4px' } })); + if (ordered.length === 0) events.appendChild(element(document, 'span', { text: text(lastEventsAt === null ? COPY.waitingSignals : COPY.noEvents), styles: { color: '#848E9C', padding: '4px' } })); + renderMissingSignals(); + renderNotices(); } collapse.addEventListener('click', () => { @@ -158,6 +194,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve collapse.textContent = text(collapsed ? COPY.expand : COPY.collapse); position.clamp(); }); + diagnostics.addEventListener('toggle', () => { if (!destroyed) position.clamp(); }); renderEvents([]); const api = Object.freeze({ @@ -171,9 +208,12 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve collapse.textContent = text(body.style.display === 'none' ? COPY.expand : COPY.collapse); connection.textContent = text(connectionCopy); reference.textContent = text(COPY.reference(STRATEGY29_REFERENCE_SHA256)); + diagnosticsTitle.textContent = text(COPY.diagnostics); unitsTitle.firstChild.textContent = text(COPY.processing); processingHint.textContent = text(COPY.processingHint); eventsTitle.textContent = text(COPY.recent); + eventsHint.textContent = text(COPY.perTimeframe(STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME)); + timeframeSummary.textContent = configuredTimeframes === null ? '' : text(COPY.intervals(configuredTimeframes.join(', '))); eventsFreshness.textContent = text(lastEventsAt === null ? COPY.noEventsCheck : COPY.eventsAt(formatClock(lastEventsAt))); if (lastStatus !== null) api.renderStatus(lastStatus); else clearCurrentStatus(); @@ -183,11 +223,13 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve setConnection(state, message) { assertLive(); if (!(state in STATE_COLORS)) throw new Error('Strategy 29 panel connection state is invalid'); + connectionState = state; connection.dataset.state = state; connection.style.color = STATE_COLORS[state]; connectionCopy = message; connection.textContent = text(message); if (['disabled', 'module_disabled', 'gateway_unavailable', 'unavailable'].includes(state)) clearCurrentStatus(); + renderNotices(); position.clamp(); }, renderStatus(snapshot) { @@ -201,29 +243,52 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve : text(COPY.mismatch(STRATEGY29_API_SPEC_VERSION, snapshot.spec_version)); statusFreshness.textContent = text(COPY.statusAt(formatClock(snapshot.observed_at_ms))); if (!matched) { + statusUnreliable = true; + statusMessages = [text(COPY.updateRequired)]; selection.dataset.state = 'incompatible'; selection.style.color = '#F6465D'; selection.textContent = text(COPY.incompatibleSelection); + selectionDetails.textContent = ''; selectionRefresh.textContent = ''; units.replaceChildren(); delivery.textContent = text(COPY.incompatibleDelivery); + renderNotices(); position.clamp(); return; } const universe = snapshot.universe; const unavailable = universe.refresh_status === 'fail_closed'; + const selected = universe.selected_markets.includes(canonicalSymbol); + statusUnreliable = unavailable || !selected; + statusMessages = []; + // Missing selection facts do not retire the last known configuration or its history. + if (universe.configured_timeframes.length > 0) { + const next = [...universe.configured_timeframes].sort(compareStrategy29Timeframes); + if (configuredTimeframes === null || next.join(',') !== configuredTimeframes.join(',')) { + configuredTimeframes = next; + let removed = false; + for (const [id, event] of eventRecords) { + if (!next.includes(event.timeframe)) { eventRecords.delete(id); removed = true; } + } + if (removed) renderEvents([...eventRecords.values()].sort(newestSignalFirst)); + } + } + timeframeSummary.textContent = configuredTimeframes === null ? '' : text(COPY.intervals(configuredTimeframes.join(', '))); selection.dataset.state = universe.refresh_status; selection.style.color = unavailable ? '#F6465D' : universe.refresh_status === 'fresh' ? '#0ECB81' : '#F0B90B'; - selection.textContent = [text(SELECTION_REASONS[universe.reason]), text(COPY.generation(universe.generation ?? text(COPY.pending))), text(COPY.markets(universe.selected_markets.length)), text(COPY.ready(universe.ready_unit_count, universe.selected_unit_count)), text(COPY.intervals(universe.configured_timeframes.join(', ') || text(COPY.pending)))].join(' · '); + selection.textContent = text(unavailable ? SELECTION_REASONS[universe.reason] : selected ? COPY.selected : COPY.notSelected); + selectionDetails.textContent = [text(SELECTION_REASONS[universe.reason]), text(COPY.generation(universe.generation ?? text(COPY.pending))), text(COPY.markets(universe.selected_markets.length)), text(COPY.ready(universe.ready_unit_count, universe.selected_unit_count))].join(' · '); selectionRefresh.textContent = universe.last_successful_refreshed_at_ms === null ? text(COPY.noSuccess) : text(COPY.lastSuccess(formatClock(universe.last_successful_refreshed_at_ms), universe.last_success_age_seconds.toFixed(1))); units.replaceChildren(); - const selected = universe.selected_markets.includes(canonicalSymbol); const matching = unavailable || !selected ? [] : snapshot.units.filter(unit => ( unit.symbol === canonicalSymbol && universe.configured_timeframes.includes(unit.timeframe) - )); + )).sort((left, right) => compareStrategy29Timeframes(left.timeframe, right.timeframe)); + if (!unavailable && universe.refresh_status !== 'fresh') statusMessages.push(text(SELECTION_REASONS[universe.reason])); + if (!unavailable && universe.pending_unit_count > 0) statusMessages.push(text(COPY.globalPending(universe.pending_unit_count))); for (const unit of matching) { + if (unit.status !== 'ready') statusMessages.push(`${unit.timeframe} · ${text(STATUS_LABELS[unit.status])}`); const row = element(document, 'div', { role: 'unit', styles: { display: 'grid', gridTemplateColumns: '36px 78px minmax(0,1fr)', gap: '6px', padding: '4px 6px', borderRadius: '5px', background: 'rgba(132,142,156,.08)' }, @@ -238,28 +303,46 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve text: text(unavailable ? COPY.unavailableSelection : selected ? COPY.awaitingUnits : COPY.notSelected), styles: { color: '#F0B90B', padding: '4px' }, })); + if (!unavailable && selected && matching.length === 0) statusMessages.push(text(COPY.awaitingUnits)); const counts = snapshot.delivery_counts; delivery.textContent = text(COPY.delivery(counts)); + renderMissingSignals(); + renderNotices(); position.clamp(); }, addEvents(incoming, observedAtMs = null) { assertLive(); + const firstCheck = lastEventsAt === null && observedAtMs !== null; + if (observedAtMs !== null) lastEventsAt = observedAtMs; if (incoming.length > 0) { - for (const event of incoming) eventRecords.set(event.event_id, event); + for (const event of incoming) { + if (configuredTimeframes === null || configuredTimeframes.includes(event.timeframe)) eventRecords.set(event.event_id, event); + } const ordered = [...eventRecords.values()].sort(newestSignalFirst); - while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id); - renderEvents(ordered); + const counts = new Map(); + const retained = ordered.filter(event => { + const count = (counts.get(event.timeframe) ?? 0) + 1; + counts.set(event.timeframe, count); + if (count <= STRATEGY29_RECENT_EVENTS_PER_TIMEFRAME) return true; + eventRecords.delete(event.event_id); + return false; + }); + renderEvents(retained); + } else if (firstCheck && eventRecords.size === 0) { + renderEvents([]); } // Empty increments still advance freshness, but contain no changes to retained rows. if (observedAtMs !== null) { - lastEventsAt = observedAtMs; eventsFreshness.textContent = text(COPY.eventsAt(formatClock(observedAtMs))); } + renderMissingSignals(); position.clamp(); }, clearEvents() { assertLive(); eventRecords.clear(); + lastEventsAt = null; + eventsFreshness.textContent = text(COPY.noEventsCheck); renderEvents([]); position.clamp(); }, diff --git a/src/binance-strategy29-bollinger/index.user.js b/src/binance-strategy29-bollinger/index.user.js index b02af12..9636510 100644 --- a/src/binance-strategy29-bollinger/index.user.js +++ b/src/binance-strategy29-bollinger/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy29.bollinger // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.5.4 +// @version 0.5.5 // @author jackhai9 // @description Native Bollinger/SMA60 markers and the default read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* diff --git a/src/binance-strategy29-bollinger/remote-summary.js b/src/binance-strategy29-bollinger/remote-summary.js index 7a6afee..7d8cc09 100644 --- a/src/binance-strategy29-bollinger/remote-summary.js +++ b/src/binance-strategy29-bollinger/remote-summary.js @@ -64,7 +64,7 @@ export function createStrategy29RemoteSummary({ function startContext(routeSymbol, gatewayState, canonicalSymbol) { const panel = createPanel(view.document, canonicalSymbol, { - maxEvents: 20, locale, + locale, loadPosition: () => getValue(STRATEGY29_PANEL_POSITION_KEY, null), savePosition: position => setValue(STRATEGY29_PANEL_POSITION_KEY, position), }); diff --git a/src/binance-strategy29-bollinger/ui-copy.js b/src/binance-strategy29-bollinger/ui-copy.js index 30df92c..467588c 100644 --- a/src/binance-strategy29-bollinger/ui-copy.js +++ b/src/binance-strategy29-bollinger/ui-copy.js @@ -13,14 +13,22 @@ export const SUMMARY_COPY = Object.freeze({ expand: pair('展开', 'Expand'), waiting: pair('等待中', 'Waiting'), observerSpec: value => pair(`观察器规格 ${value}`, `Observer spec ${value}`), - reference: value => pair(`本地参考版本 ${value}`, `Local reference ${value}`), + reference: value => pair(`本地检测代码指纹 ${value}`, `Local detector fingerprint ${value}`), + diagnostics: pair('诊断详情', 'Diagnostics'), + selected: pair('当前币种已纳入监测', 'Symbol is selected for monitoring'), + updateRequired: pair('服务端与客户端不兼容,请更新信号客户端和 Strategy 29 脚本。', 'Server and client are incompatible. Update the signal client and Strategy 29 script.'), + historicalOnly: pair('当前无法确认实时监测状态;下方为保留的历史信号。', 'Live monitoring cannot currently be confirmed; retained signals below are historical.'), + globalPending: count => pair(`服务端有 ${count} 个监测单元尚未就绪`, `${count} server monitoring units are not ready`), + missingSignals: value => pair(`当前保留范围内暂无信号:${value}`, `No retained signals: ${value}`), + waitingSignals: pair('等待最近信号', 'Waiting for recent signals'), + perTimeframe: count => pair(`每周期最多 ${count} 条 · 按收盘时间排序`, `Up to ${count} per timeframe · Ordered by signal close`), noStatus: pair('尚未收到状态', 'Status not received'), noEventsCheck: pair('尚未检查事件', 'Events not checked'), processing: pair('最近处理状态', 'Last processing status'), processingHint: pair('已保存的处理状态不代表当前实时数据已就绪。', 'Stored processing status does not confirm current live readiness.'), waitingDelivery: pair('全局通知 — 等待中', 'Global delivery — waiting'), recent: pair('最近跨周期信号', 'Recent cross-timeframe signals'), - noEvents: pair('暂无最近信号', 'No recent signals'), + noEvents: pair('当前保留范围内暂无信号', 'No signals in retained history'), close: value => pair(`收盘 ${value}`, `Close ${value}`), matched: value => pair(`规格版本一致 · ${value}`, `Spec version matched · ${value}`), mismatch: (local, server) => pair(`规格不一致 · 本地 ${local} · 服务端 ${server}`, `Spec mismatch · local ${local} · server ${server}`), diff --git a/src/shared/signal-gateway-bridge.js b/src/shared/signal-gateway-bridge.js index d150d90..7c4cac4 100644 --- a/src/shared/signal-gateway-bridge.js +++ b/src/shared/signal-gateway-bridge.js @@ -22,7 +22,8 @@ export function validateSignalGatewayPath(path) { } const cursor = query.get('cursor'); const latest = keys.length === 3 && keys.every(key => ['symbol', 'mode', 'limit'].includes(key)) - && query.get('mode') === 'latest' && query.get('limit') === '20'; + && ((query.get('mode') === 'latest' && query.get('limit') === '20') + || (query.get('mode') === 'latest_per_timeframe' && query.get('limit') === '3')); const increment = keys.length === 2 && keys.includes('cursor') && /^(0|[1-9]\d*)$/.test(cursor) && Number.isSafeInteger(Number(cursor)); if (!latest && !increment) throw new TypeError('Signal gateway event query is invalid'); diff --git a/test/dom/binance-strategy29-bollinger/runtime.test.js b/test/dom/binance-strategy29-bollinger/runtime.test.js index d3cfe2d..ec7e630 100644 --- a/test/dom/binance-strategy29-bollinger/runtime.test.js +++ b/test/dom/binance-strategy29-bollinger/runtime.test.js @@ -145,7 +145,8 @@ test('actual remote client retains rows and cursor across visibility and bootstr const settle = () => new Promise(resolve => f.view.setTimeout(resolve, 0)); await settle(); const panel = f.view.document.getElementById('jh-strategy29-summary-panel'); - assert.equal(urls[0].searchParams.get('mode'), 'latest'); + assert.equal(urls[0].searchParams.get('mode'), 'latest_per_timeframe'); + assert.equal(urls[0].searchParams.get('limit'), '3'); assert.equal(panel.querySelectorAll('[data-role=remote-event]').length, 1); f.hide(true); assert.equal(runtime.diagnostics.remoteSummary.cursor, 900); @@ -157,7 +158,8 @@ test('actual remote client retains rows and cursor across visibility and bootstr assert.deepEqual([...panel.querySelectorAll('[data-role=remote-event]')].map(row => row.dataset.eventId), [second.event_id, first.event_id]); f.view.history.pushState({}, '', '/en/futures/ETHUSDT'); await settle(); - assert.equal(urls[2].searchParams.get('mode'), 'latest'); + assert.equal(urls[2].searchParams.get('mode'), 'latest_per_timeframe'); + assert.equal(urls[2].searchParams.get('limit'), '3'); assert.equal(urls[2].searchParams.has('cursor'), false); assert.equal(panel.isConnected, false); assert.equal(f.view.document.querySelectorAll('[data-role=remote-event]').length, 0); diff --git a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js index 5a19c31..e2d18ae 100644 --- a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js +++ b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js @@ -13,6 +13,62 @@ function createStrategy29SummaryPanel(document, symbol, options = {}) { const status = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-status.json', import.meta.url))); const events = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-events.json', import.meta.url))); +test('each timeframe retains three signals through dense minute updates and historical backfills', () => { + const dom = new JSDOM(''); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT'); + const timeframes = ['1m', '5m', '15m', '1h', '4h', '1d']; + let sequence = 0; + const records = timeframes.flatMap((timeframe, group) => Array.from({ length: group === 0 ? 30 : 3 }, (_, index) => ({ + ...events.events[0], timeframe, event_id: String(++sequence).padStart(64, '0'), sequence, + bar_close_ms: events.observed_at_ms - group * 86_400_000 - index * 60_000, + }))); + panel.addEvents(records, events.observed_at_ms); + const rows = () => [...dom.window.document.querySelectorAll('[data-role=remote-event]')]; + assert.equal(panel.size, 18); + for (const timeframe of timeframes) assert.equal(rows().filter(row => row.firstChild.textContent === timeframe).length, 3); + const otherIds = rows().filter(row => row.firstChild.textContent !== '1m').map(row => row.dataset.eventId); + const updates = Array.from({ length: 5 }, (_, index) => ({ + ...records[0], event_id: String(++sequence).padStart(64, '0'), sequence, + bar_close_ms: events.observed_at_ms + (index + 1) * 60_000, + })); + panel.addEvents(updates, events.observed_at_ms + 300_000); + panel.addEvents([{ ...records[10], event_id: 'f'.repeat(64), sequence: ++sequence }]); + assert.deepEqual(rows().slice(0, 3).map(row => row.dataset.eventId), updates.slice(-3).reverse().map(event => event.event_id)); + assert.deepEqual(rows().filter(row => row.firstChild.textContent !== '1m').map(row => row.dataset.eventId), otherIds); + panel.setLocale('zh-CN'); + assert.equal(panel.size, 18); + panel.destroy(); + dom.window.close(); +}); + +test('diagnostics start collapsed with duration-sorted processing while actionable notices stay outside', () => { + const dom = new JSDOM(''); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT'); + const timeframes = ['15m', '1d', '1h', '1m', '4h', '5m']; + panel.renderStatus({ ...status, universe: { ...status.universe, configured_timeframes: timeframes }, + units: timeframes.map(timeframe => ({ ...status.units[0], timeframe, status: timeframe === '4h' ? 'data_gap' : 'ready', reason: timeframe === '4h' ? 'closed_bar_gap' : 'current' })), + }); + const diagnostics = dom.window.document.querySelector('[data-role=diagnostics]'); + assert.equal(diagnostics.tagName, 'DETAILS'); + assert.equal(diagnostics.open, false); + for (const role of ['spec', 'reference', 'units', 'delivery', 'selection-details']) { + assert.equal(dom.window.document.querySelector(`[data-role=${role}]`).closest('details'), diagnostics); + } + assert.deepEqual([...diagnostics.querySelectorAll('[data-role=unit]')].map(row => row.firstChild.textContent), ['1m', '5m', '15m', '1h', '4h', '1d']); + const notices = dom.window.document.querySelector('[data-role=notices]'); + assert.equal(notices.closest('details'), null); + assert.match(notices.textContent, /4h.*Data gap/); + assert.doesNotMatch(notices.textContent, /closed_bar_gap/); + assert.match(diagnostics.textContent, /closed_bar_gap/); + panel.addEvents(events.events, events.observed_at_ms); + panel.setConnection('disconnected', { en: 'Disconnected', zhCN: '连接中断' }); + assert.match(notices.textContent, /historical/i); + panel.renderStatus({ ...status, spec_version: 'incompatible-spec' }); + assert.match(notices.textContent, /update/i); + panel.destroy(); + dom.window.close(); +}); + test('empty increments retain event DOM identities while advancing freshness', () => { const dom = new JSDOM(''); const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT'); @@ -32,7 +88,7 @@ test('empty increments retain event DOM identities while advancing freshness', ( rows.forEach((row, index) => assert.equal(container.children[index], row)); assert.equal(observer.takeRecords().length, 0); assert.notEqual(freshness.textContent, before); - assert.equal(panel.size, 20); + assert.equal(panel.size, 3); panel.setLocale('zh-CN'); assert.notEqual(container.firstChild, rows[0]); assert.match(freshness.textContent, /UTC\+08/); @@ -69,7 +125,8 @@ test('distinguishes stored processing success from current live readiness', () = panel.renderStatus({ ...status, universe: { ...status.universe, ready_unit_count: 0, pending_unit_count: status.universe.selected_unit_count, } }); - assert.match(dom.window.document.querySelector('[data-role=selection]').textContent, /0\/3 live units ready/); + assert.match(dom.window.document.querySelector('[data-role=selection-details]').textContent, /0\/3 live units ready/); + assert.match(dom.window.document.querySelector('[data-role=notices]').textContent, /3 server monitoring units are not ready/); assert.match(dom.window.document.body.textContent, /Last processing status/); assert.match(dom.window.document.body.textContent, /Stored processing status does not confirm current live readiness/); const processing = dom.window.document.querySelector('[data-role=unit]').children[1]; @@ -81,7 +138,7 @@ test('distinguishes stored processing success from current live readiness', () = test('distinguishes unavailable selection, unselected symbol and pending unit registration', () => { const dom = new JSDOM(''); - const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT'); panel.addEvents(events.events); panel.renderStatus({ ...status, units: [] }); assert.match(dom.window.document.body.textContent, /Symbol is selected; waiting for unit status/); @@ -103,17 +160,17 @@ test('distinguishes unavailable selection, unselected symbol and pending unit re test('replaces dynamic membership while retaining durable signal history', () => { const dom = new JSDOM(''); - const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT'); panel.renderStatus(status); panel.addEvents(events.events); assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 2); panel.renderStatus({ ...status, universe: { ...status.universe, configured_timeframes: ['1h'] } }); assert.deepEqual([...dom.window.document.querySelectorAll('[data-role=unit]')].map(row => row.firstChild.textContent), ['1h']); - assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 2); + assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 1); panel.renderStatus({ ...status, universe: { ...status.universe, selected_markets: ['ETH/USDT:USDT'] }, units: [] }); assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 0); assert.match(dom.window.document.body.textContent, /Symbol is not watched/); - assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 2); + assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 1); panel.renderStatus({ ...status, universe: { ...status.universe, configured_timeframes: ['4h'] }, units: [{ ...status.units[0], timeframe: '4h', status: 'warming', reason: 'awaiting_producer_generation' }] }); const units = dom.window.document.querySelectorAll('[data-role=unit]'); assert.equal(units.length, 1); @@ -125,7 +182,7 @@ test('replaces dynamic membership while retaining durable signal history', () => test('renders all watched timeframes for the route symbol and labels global delivery totals', () => { const dom = new JSDOM(''); - const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT'); panel.renderStatus(status); const text = dom.window.document.body.textContent; assert.match(text, /BTC\/USDT:USDT/); @@ -143,7 +200,7 @@ test('renders all watched timeframes for the route symbol and labels global deli test('shows multi-timeframe events, deduplicates identities and clears only remote rows', () => { const dom = new JSDOM(''); - const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT'); panel.addEvents(events.events, events.observed_at_ms); panel.addEvents([events.events[0]]); assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 2); @@ -163,7 +220,7 @@ test('shows multi-timeframe events, deduplicates identities and clears only remo test('makes server/local spec mismatch visible without rendering it as verified', () => { const dom = new JSDOM(''); - const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT'); panel.renderStatus(status); panel.addEvents(events.events, events.observed_at_ms); panel.renderStatus({ schema_version: 1, spec_version: 'other_spec', observed_at_ms: status.observed_at_ms }); @@ -180,13 +237,14 @@ test('makes server/local spec mismatch visible without rendering it as verified' test('retains newest signal close times regardless of insertion or detection order', () => { const dom = new JSDOM(''); - const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 2 }); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT'); const first = { ...events.events[0], event_id: 'a'.repeat(64), sequence: 10, detected_at_ms: 3000, bar_close_ms: 3000 }; const second = { ...events.events[0], event_id: 'b'.repeat(64), sequence: 11, detected_at_ms: 2000, bar_close_ms: 3000 }; - const third = { ...events.events[0], event_id: 'c'.repeat(64), sequence: 12, detected_at_ms: 4000, bar_close_ms: 1000 }; - panel.addEvents([first, second]); - panel.addEvents([third]); - assert.deepEqual([...dom.window.document.querySelectorAll('[data-role=remote-event]')].map(row => row.dataset.eventId), [second.event_id, first.event_id]); + const third = { ...events.events[0], event_id: 'c'.repeat(64), sequence: 12, detected_at_ms: 4000, bar_close_ms: 2000 }; + const fourth = { ...events.events[0], event_id: 'd'.repeat(64), sequence: 13, detected_at_ms: 5000, bar_close_ms: 1000 }; + panel.addEvents([first, second, third]); + panel.addEvents([fourth]); + assert.deepEqual([...dom.window.document.querySelectorAll('[data-role=remote-event]')].map(row => row.dataset.eventId), [second.event_id, first.event_id, third.event_id]); panel.destroy(); dom.window.close(); }); diff --git a/test/fixtures/strategy29-gateway-events.json b/test/fixtures/strategy29-gateway-events.json index 5179641..db3e10f 100644 --- a/test/fixtures/strategy29-gateway-events.json +++ b/test/fixtures/strategy29-gateway-events.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "spec_version": "29_2_spec_v3", + "spec_version": "29_2_spec_v4", "observed_at_ms": 1788580805000, "next_cursor": 42, "has_more": false, diff --git a/test/fixtures/strategy29-gateway-status.json b/test/fixtures/strategy29-gateway-status.json index 805bd03..5eb45d9 100644 --- a/test/fixtures/strategy29-gateway-status.json +++ b/test/fixtures/strategy29-gateway-status.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "spec_version": "29_2_spec_v3", + "spec_version": "29_2_spec_v4", "observed_at_ms": 1788580805000, "universe": { "source_monitor": "monitor29_bollinger_ma60", diff --git a/test/manual/strategy29-summary-panel-preview.mjs b/test/manual/strategy29-summary-panel-preview.mjs index 4ce9827..88b32b2 100644 --- a/test/manual/strategy29-summary-panel-preview.mjs +++ b/test/manual/strategy29-summary-panel-preview.mjs @@ -8,6 +8,24 @@ import { build } from 'esbuild'; // Render the actual panel module with synthetic gateway data and no live connections. const status = JSON.parse(await readFile(new URL('../fixtures/strategy29-gateway-status.json', import.meta.url), 'utf8')); const events = JSON.parse(await readFile(new URL('../fixtures/strategy29-gateway-events.json', import.meta.url), 'utf8')); +const intervals = [['1m', 1], ['5m', 5], ['15m', 15], ['1h', 60], ['4h', 240], ['1d', 1440]]; +const timeframes = intervals.map(([timeframe]) => timeframe); +status.universe = { ...status.universe, selected_markets: ['BTC/USDT:USDT'], configured_timeframes: timeframes, + selected_unit_count: 6, ready_unit_count: 6, pending_unit_count: 0 }; +status.units = timeframes.map(timeframe => ({ ...status.units[0], timeframe })); +const templates = events.events; +let sequence = 0; +events.events = intervals.flatMap(([timeframe, minutes]) => { + const intervalMs = minutes * 60_000; + const lastCloseMs = Math.floor(events.observed_at_ms / intervalMs) * intervalMs; + const count = timeframe === '1m' ? 30 : 3; + return Array.from({ length: count }, (_, index) => { + sequence += 1; + const closeMs = lastCloseMs - (count - index - 1) * intervalMs; + return { ...templates[index % templates.length], sequence, event_id: sequence.toString(16).padStart(64, '0'), + timeframe, bar_close_ms: closeMs, bar_open_ms: closeMs - intervalMs, detected_at_ms: closeMs + 500 }; + }); +}); const bundle = await build({ stdin: { contents: "export { createStrategy29SummaryPanel } from './src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js';", @@ -36,7 +54,7 @@ try { await page.evaluate(async ({ sourceUrl, status, events }) => { const { createStrategy29SummaryPanel } = await import(sourceUrl); const panel = createStrategy29SummaryPanel(document, 'BTC/USDT:USDT', { - maxEvents: 8, locale: 'en', loadPosition: () => null, savePosition: value => { window.fixturePosition = value; }, + locale: 'en', loadPosition: () => null, savePosition: value => { window.fixturePosition = value; }, }); panel.setConnection('connected', { zhCN: '已连接', en: 'Connected' }); panel.renderStatus(status); @@ -45,8 +63,14 @@ try { }, { sourceUrl, status, events }); const panel = page.locator('#jh-strategy29-summary-panel'); await panel.waitFor({ state: 'visible' }); - assert.equal(await panel.locator('[data-role="unit"]').count(), 2); - assert.equal(await panel.locator('[data-role="remote-event"]').count(), 2); + assert.equal(await panel.locator('[data-role="unit"]').count(), 6); + assert.equal(await panel.locator('[data-role="remote-event"]').count(), 18); + assert.deepEqual(await panel.locator('[data-role="remote-event"] strong').allTextContents().then(values => + Object.fromEntries(timeframes.map(timeframe => [timeframe, values.filter(value => value === timeframe).length]))), + Object.fromEntries(timeframes.map(timeframe => [timeframe, 3]))); + assert.equal(await panel.locator('[data-role="diagnostics"]').evaluate(node => node.open), false); + assert.equal(await panel.locator('[data-role="spec"]').isVisible(), false); + assert.equal(await panel.locator('[data-role="notices"]').isVisible(), false); assert.match(await panel.locator('[data-role="delivery"]').textContent(), /Global delivery/); assert.match(await panel.locator('[data-role="events-freshness"]').textContent(), /Events checked/); await panel.screenshot({ path: join(output, 'connected.png') }); @@ -61,6 +85,13 @@ try { assert.match(await panel.textContent(), /看跌预警/); assert.match(await panel.locator('[data-role="connection"]').textContent(), /已连接/); await panel.screenshot({ path: join(output, 'chinese.png') }); + await panel.locator('[data-role="diagnostics"] summary').click(); + assert.equal(await panel.locator('[data-role="spec"]').isVisible(), true); + assert.deepEqual(await panel.locator('[data-role="unit"] strong').allTextContents(), timeframes); + await panel.locator('[data-role="body"]').evaluate(node => { node.scrollTop = node.scrollHeight; }); + await panel.screenshot({ path: join(output, 'chinese-diagnostics.png') }); + await panel.locator('[data-role="diagnostics"] summary').click(); + await panel.locator('[data-role="body"]').evaluate(node => { node.scrollTop = 0; }); const beforeDrag = await panel.boundingBox(); const header = await panel.locator('header').boundingBox(); await page.mouse.move(header.x + 24, header.y + 14); @@ -82,7 +113,17 @@ try { await panel.screenshot({ path: join(output, 'chinese-narrow.png') }); await page.evaluate(() => window.fixturePanel.setLocale('en')); assert.match(await panel.textContent(), /Bearish warning/); - assert.equal(await panel.locator('[data-role="remote-event"]').count(), 2); + assert.equal(await panel.locator('[data-role="remote-event"]').count(), 18); + await page.evaluate(snapshot => { + window.fixturePanel.setLocale('zh-CN'); + window.fixturePanel.renderStatus({ ...snapshot, units: snapshot.units.map(unit => unit.timeframe === '1h' + ? { ...unit, status: 'data_gap', reason: 'closed_bar_gap' } : unit) }); + }, status); + assert.match(await panel.locator('[data-role="notices"]').textContent(), /1h/); + assert.equal(await panel.locator('[data-role="notices"]').isVisible(), true); + assert.doesNotMatch(await panel.locator('[data-role="notices"]').textContent(), /closed_bar_gap/); + assert.equal(await panel.locator('[data-role="diagnostics"]').evaluate(node => node.open), false); + await panel.screenshot({ path: join(output, 'chinese-data-gap.png') }); await page.evaluate(() => { window.fixturePanel.setLocale('zh-CN'); window.fixturePanel.setConnection('module_disabled', { zhCN: '服务端尚未启用 Strategy 29 监控汇总', en: 'Strategy 29 monitoring summary is not enabled on the server' }); @@ -90,7 +131,7 @@ try { assert.match(await panel.locator('[data-role="connection"]').textContent(), /服务端尚未启用/); await panel.screenshot({ path: join(output, 'chinese-module-disabled.png') }); assert.deepEqual(errors, []); - console.log(JSON.stringify({ output, checked: ['two-timeframe-status', 'two-events', 'global-delivery-label', 'separate-freshness', 'collapse', 'viewport', 'Chinese-English-retained-state', 'header-drag-position-storage', 'narrow-viewport'], pageErrors: errors })); + console.log(JSON.stringify({ output, checked: ['six-timeframe-quotas', '18-retained-events', 'collapsed-diagnostics', 'duration-sorted-processing', 'visible-data-gap', 'global-delivery-label', 'separate-freshness', 'collapse', 'viewport', 'Chinese-English-retained-state', 'header-drag-position-storage', 'narrow-viewport'], pageErrors: errors })); } finally { await browser.close(); } diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js index 4f76440..be4e315 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js @@ -9,6 +9,12 @@ import { const status = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-status.json', import.meta.url))); const events = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-events.json', import.meta.url))); +const withoutSelection = { ...status, units: [], universe: { + ...status.universe, generation: null, refresh_status: 'fail_closed', reason: 'missing_current_universe_facts', + selected_markets: [], configured_timeframes: [], selected_unit_count: 0, ready_unit_count: 0, pending_unit_count: 0, + refreshed_at_ms: null, last_successful_refreshed_at_ms: null, last_success_age_seconds: null, + last_refresh_error_at_ms: null, selection_expires_at_ms: null, +} }; function response(body, httpStatus = 200) { return { status: httpStatus, responseText: JSON.stringify(body) }; @@ -43,8 +49,8 @@ test('bootstraps recent events once then follows the global high-water cursor', ]); await fixture.client.poll(new AbortController().signal); const firstUrl = new URL(fixture.requests[1].path, 'https://gateway.invalid'); - assert.equal(firstUrl.searchParams.get('mode'), 'latest'); - assert.equal(firstUrl.searchParams.get('limit'), '20'); + assert.equal(firstUrl.searchParams.get('mode'), 'latest_per_timeframe'); + assert.equal(firstUrl.searchParams.get('limit'), '3'); assert.equal(firstUrl.searchParams.has('cursor'), false); assert.deepEqual(fixture.received.map(event => event.sequence), [41, 42]); await fixture.client.poll(new AbortController().signal); @@ -54,6 +60,49 @@ test('bootstraps recent events once then follows the global high-water cursor', assert.equal(fixture.client.diagnostics.cursor, 100_200); }); +test('only known timeframe-set changes resynchronize snapshots, including initialization after missing facts', async () => { + const reordered = { ...status, universe: { ...status.universe, generation: 2, configured_timeframes: [...status.universe.configured_timeframes].reverse() } }; + const changed = { ...status, universe: { ...status.universe, configured_timeframes: ['1d'] } }; + for (const { states, modes, resets } of [ + { states: [status, reordered, withoutSelection, status], modes: ['latest_per_timeframe', null, null, null], resets: [] }, + { states: [status, withoutSelection, changed], modes: ['latest_per_timeframe', null, 'latest_per_timeframe'], resets: [null] }, + { states: [withoutSelection, status], modes: ['latest_per_timeframe', 'latest_per_timeframe'], resets: [null] }, + ]) { + const fixture = clientFixture(states.flatMap((snapshot, index) => [response(snapshot), response({ ...events, events: [], next_cursor: 100 + index })])); + for (const snapshot of states) await fixture.client.poll(new AbortController().signal); + assert.deepEqual(fixture.requests.filter((_, index) => index % 2 === 1).map(request => new URL(request.path, 'https://gateway.invalid').searchParams.get('mode')), modes); + assert.deepEqual(fixture.resets, resets); + assert.equal(fixture.client.diagnostics.cursor, 99 + states.length); + } +}); + +test('failed configuration resynchronization keeps the cursor empty until a new snapshot succeeds', async () => { + const changed = { ...status, universe: { ...status.universe, configured_timeframes: ['1d'] } }; + const fixture = clientFixture([ + response(status), response({ ...events, events: [], next_cursor: 100 }), + response(changed), response({ schema_version: 1, error: 'gateway_unavailable', strategy_id: '29' }, 503), + response(changed), response({ ...events, events: [], next_cursor: 200 }), + ]); + await fixture.client.poll(new AbortController().signal); + assert.equal((await fixture.client.poll(new AbortController().signal)).state, 'gateway_unavailable'); + assert.equal(fixture.client.diagnostics.cursor, null); + await fixture.client.poll(new AbortController().signal); + assert.equal(new URL(fixture.requests[5].path, 'https://gateway.invalid').searchParams.get('mode'), 'latest_per_timeframe'); + assert.deepEqual(fixture.resets, [null]); + assert.equal(fixture.client.diagnostics.cursor, 200); +}); + +test('snapshot quota validation is independent of the preceding status configuration', async () => { + const timeframes = ['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', '1d', '1w']; + const all = timeframes.flatMap((timeframe, group) => Array.from({ length: 3 }, (_, index) => ({ + ...events.events[0], timeframe, sequence: group * 3 + index + 1, event_id: String(group * 3 + index + 1).padStart(64, '0'), + }))); + const fixture = clientFixture([response(status), response({ ...events, events: all })]); + await fixture.client.poll(new AbortController().signal); + assert.equal(fixture.received.length, 39); + assert.equal(fixture.client.diagnostics.cursor, events.next_cursor); +}); + test('a cancelled response cannot advance a resumed client cursor or publish events', async () => { let completeOld; const oldResponse = new Promise(resolve => { completeOld = resolve; }); @@ -75,7 +124,7 @@ test('a cancelled response cannot advance a resumed client cursor or publish eve test('rejects incomplete or oversized latest snapshots', async () => { for (const latest of [ { ...events, has_more: true }, - { ...events, has_more: false, events: Array(21).fill(events.events[0]) }, + { ...events, has_more: false, events: Array.from({ length: 4 }, (_, index) => ({ ...events.events[0], event_id: String(index + 1).padStart(64, '0'), sequence: index + 1 })) }, ]) { const fixture = clientFixture([response(status), response(latest)]); await assert.rejects(fixture.client.poll(new AbortController().signal), /complete and bounded/); @@ -125,7 +174,7 @@ test('409 replaces retained history with a recent snapshot without clearing loca const result = await fixture.client.poll(new AbortController().signal); assert.deepEqual(result, { state: 'connected', pages: 2, hasMore: false }); assert.deepEqual(fixture.resets, [40]); - assert.match(fixture.requests[4].path, /mode=latest/); + assert.equal(new URL(fixture.requests[4].path, 'https://gateway.invalid').searchParams.get('mode'), 'latest_per_timeframe'); assert.doesNotMatch(fixture.requests[4].path, /cursor=/); }); @@ -200,6 +249,27 @@ test('exposes remote/local spec mismatch before requesting event history', async assert.equal(fixture.snapshots[0].spec_version, 'other_spec'); }); +test('a v3 backend is incompatible before requesting the per-timeframe snapshot', async () => { + const fixture = clientFixture([ + response({ ...status, spec_version: '29_2_spec_v3' }), response(events), + ]); + assert.deepEqual(await fixture.client.poll(new AbortController().signal), { + state: 'incompatible', pages: 0, hasMore: false, + }); + assert.deepEqual(fixture.requests.map(request => request.path), ['/v1/strategy29/status']); + assert.deepEqual(fixture.received, []); + assert.equal(fixture.client.diagnostics.cursor, null); +}); + +test('a rollback between status and events cannot publish a v3 snapshot or advance its cursor', async () => { + const fixture = clientFixture([ + response(status), response({ ...events, spec_version: '29_2_spec_v3' }), + ]); + await assert.rejects(fixture.client.poll(new AbortController().signal), /events.spec_version/); + assert.deepEqual(fixture.received, []); + assert.equal(fixture.client.diagnostics.cursor, null); +}); + test('Unicode selected markets and events preserve canonical identity through polling', async () => { const unicodeStatus = JSON.parse(JSON.stringify(status).replaceAll('BTC/USDT:USDT', '牛来/USDT:USDT')); diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js index 1f3af61..7acac96 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js @@ -147,7 +147,7 @@ test('projects only validated identity when status belongs to an incompatible sp }); test('validates exact status fields while preserving visible spec mismatch', () => { - assert.equal(STRATEGY29_API_SPEC_VERSION, '29_2_spec_v3'); + assert.equal(STRATEGY29_API_SPEC_VERSION, '29_2_spec_v4'); assert.equal(validateStrategy29StatusResponse(status, 200), status); const mismatch = structuredClone(status); mismatch.spec_version = 'other_spec'; @@ -219,12 +219,12 @@ test('validates Strategy29 error bodies using error rather than error_code', () }); -test('v3 API envelopes retain v2 event identities and reject mixed layers', () => { +test('v4 API envelopes retain v2 event identities and reject mixed layers', () => { assert.equal(validateStrategy29EventsResponse(events, 200), events); - assert.equal(events.spec_version, '29_2_spec_v3'); + assert.equal(events.spec_version, '29_2_spec_v4'); assert.deepEqual([...new Set(events.events.map(event => event.spec_version))], ['29_2_spec_v2']); const wrongRow = structuredClone(events); - wrongRow.events[0].spec_version = '29_2_spec_v3'; + wrongRow.events[0].spec_version = '29_2_spec_v4'; assert.throws(() => validateStrategy29EventsResponse(wrongRow, 200), /spec_version/); assert.throws(() => validateStrategy29EventsResponse({ ...events, spec_version: '29_2_spec_v2' }, 200), /spec_version/); const oldStatus = { ...status, spec_version: '29_2_spec_v2' }; diff --git a/test/unit/binance-strategy29-bollinger/source-regressions.test.js b/test/unit/binance-strategy29-bollinger/source-regressions.test.js index 14bd5c4..25704ec 100644 --- a/test/unit/binance-strategy29-bollinger/source-regressions.test.js +++ b/test/unit/binance-strategy29-bollinger/source-regressions.test.js @@ -52,7 +52,7 @@ test('Bollinger chart alert failures distinguish snapshot races from contract fa }); test('Strategy29 sandbox metadata is exact and coordinates through unsafeWindow', () => { - assert.equal(readUserscriptVersion(entrySource), '0.5.4'); + assert.equal(readUserscriptVersion(entrySource), '0.5.5'); assert.deepEqual( [...entrySource.matchAll(/^\/\/ @grant\s+(\S+)\s*$/gm)].map(match => match[1]), ['unsafeWindow', 'GM_getValue', 'GM_setValue'], diff --git a/test/unit/signal-gateway-bridge.test.js b/test/unit/signal-gateway-bridge.test.js index ca5c054..0038009 100644 --- a/test/unit/signal-gateway-bridge.test.js +++ b/test/unit/signal-gateway-bridge.test.js @@ -21,12 +21,16 @@ function fixture(implementation) { test('public capability accepts only exact bounded Strategy29 read paths', () => { for (const path of ['/v1/strategy29/status', '/v1/strategy29/events?symbol=BTC%2FUSDT%3AUSDT&mode=latest&limit=20', + '/v1/strategy29/events?symbol=BTC%2FUSDT%3AUSDT&mode=latest_per_timeframe&limit=3', '/v1/strategy29/events?symbol=%E7%89%9B%E6%9D%A5%2FUSDT%3AUSDT&cursor=42']) { assert.equal(validateSignalGatewayPath(path), path); } for (const path of ['https://127.0.0.1:18765/v1/strategy29/status', '//127.0.0.1/v1/strategy29/status', '/v1/strategy29/status?x=1', '/v1/strategy29/status#x', '/v1/strategy29/../strategy29/status', '/v1/strategy29/status/', '/v1/strategy29/events?symbol=BTC%2FUSDT%3AUSDT&mode=latest&limit=200', + '/v1/strategy29/events?symbol=BTC%2FUSDT%3AUSDT&mode=latest_per_timeframe&limit=4', + '/v1/strategy29/events?symbol=BTC%2FUSDT%3AUSDT&mode=latest_per_timeframe&limit=3&timeframe=1m', + '/v1/strategy29/events?symbol=BTC%2FUSDT%3AUSDT&mode=latest_per_timeframe&limit=3&cursor=0', '/v1/strategy29/events?symbol=BTC%2FUSDT%3AUSDT&cursor=0&cursor=1', '/v1/strategy29/events?symbol=BTC%2FUSDT%3AUSDT&cursor=9007199254740992', '/v1/strategy29/events?symbol=btc%2FUSDT%3AUSDT&cursor=0', diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index 6fb0f9d..92745b8 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -21,7 +21,7 @@ test('Strategy29 has an independent observation-only install identity', async () const metadata = parseUserscriptMetadata(text); assert.equal(contract.name, '【自写】Binance Strategy 29 布林带信号'); assert.equal(contract.namespace, 'binance.strategy29.bollinger'); - assert.equal(contract.version, '0.5.4'); + assert.equal(contract.version, '0.5.5'); assert.equal(contract.runAt, 'document-start'); assert.equal(contract.updateURL, 'https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js'); assert.equal(contract.downloadURL, contract.updateURL); @@ -61,7 +61,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.6.4'); + assert.equal(contract.version, '0.6.5'); assert.equal(contract.runAt, 'document-idle'); assert.equal(contract.updateURL, contract.downloadURL); assert.deepEqual(contract.matches, [