diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index 7c43b5d..a3b31f2 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -9,7 +9,7 @@ already-loaded native chart candles. The optional summary reads the authenticate loopback observer 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.2.2 with orderbook 2.7.199 or later, or use it alone. +Install Strategy29 0.3.0 with orderbook 2.7.199 or later, or use it alone. Do not combine it with the embedded observer in orderbook 2.7.198. After updating/disabling the old script, reload the page. An embedded observer is an explicit conflict: Strategy29 stops and displays an upgrade/reload notice. @@ -52,6 +52,28 @@ or sent to the remote service. Recoverable snapshot races leave it unchanged. Independent instances of each bundle share the same controller regardless of load order. Strategy27 does not participate in this protocol. +## Panel Language and Position + +The summary follows the existing pathname locale contract: `/zh-CN/` uses Chinese, +while English and other routes use English. Strategy29 imports the existing pure +orderbook locale helpers without modifying orderbook or shared runtime behavior. +Headers, connection and selection states, signal names, processing labels, +notification totals, empty states, menus and prompts have bilingual copy. Technical +identifiers and arbitrary server diagnostic details remain verbatim, with localized +labels. Chart arrows contain no textual labels. + +A same-symbol locale change rerenders retained status, events and connection text +without retiring requests, rebuilding the client or resetting its cursor. Existing +Tampermonkey menu IDs are updated using the documented `GM_registerMenuCommand` +options contract (Tampermonkey 5.0 or later); no extra grant is required. + +Drag the header with the primary mouse button. Header buttons do not start a drag. +Mouse release or window blur saves only `{left, top}` under the private userscript +key `strategy29SummaryPanelPosition`; route changes and page reloads restore it. +The panel clamps its position after viewport, content, collapse and language changes. +Destroying the panel removes its document/window drag listeners. Invalid persisted +coordinates fail explicitly. No credentials cross the page boundary. + ## Optional Cross-Timeframe Server Summary The remote summary is disabled by default. Tampermonkey exposes three Strategy29 @@ -69,6 +91,42 @@ 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 compatibility is `29_2_spec_v2`; 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 +membership: removed symbols show "Symbol is not watched by the current selection" while +retained event history remains visible, and re-entering units can show warming +until producer readiness and historical baseline complete. The browser does not +choose markets or infer intervals from other strategies. + +The exact status contract includes public `universe` metadata: generation, refresh +state/reason, selected markets, configured intervals, selected/ready/pending unit +counts and refresh timestamps/age. It shares the server SQLite snapshot with unit +progress and delivery counts. The panel distinguishes unavailable selection, +selected units awaiting observation and a healthy selection excluding the current +market. Unavailable selection suppresses healthy-looking processing rows while +retained events remain visible. A never-successful refresh displays no successful +time; generation-mismatched producer readiness contributes no ready units. +The aggregate is labeled `live units ready`. Per-period rows show the last stored +processing status, with `ready` displayed as neutral `Processed`; they do not +certify current live readiness. A stored ready row with zero live-ready units is +valid during producer-generation or admission/baseline transitions and must not +be rejected by the client validator. +Refresh metadata must match the reported state: successful and stale selections +require success time, age and expiry; failed refreshes require an error time; +missing or incompatible facts carry null metadata. Fail-closed selection may +retain a complete prior-success group. Expired selection can originate from fresh +or stale facts. Clock rollback does not invalidate otherwise coherent metadata. + +The current remote summary is disabled by default and has no panel while disabled. +Authentication failure affects only the remote summary, not local chart detection. +Strategy27 and Strategy29 currently use separate private userscript storage. The +requested one-time gateway configuration across current and future strategies is +a pending unified-client design, including credential ownership, module lifecycle +and migration. Do not bridge credentials through page globals, localStorage or +page events, and do not ask users to duplicate secrets as the long-term solution. +A discoverable disabled-state entry is also pending, not implemented in 0.3.0. + 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 returns the latest retained sequences and a global increment @@ -78,7 +136,7 @@ advancing that cursor. `cursor_expired` clears only remote rows and requests a n latest snapshot. Rows are displayed and bounded by descending durable sequence, independent of detection timestamps. This requires the server's explicit latest query contract; a server rejecting it stops the remote context visibly. -Publish the gateway contract before the `0.2.1` client, then verify installed +Publish the V2 gateway contract before the `0.3.0` client, then verify installed source identity and reload before remote acceptance. Publication of either component does not enable the observer, gateway, or notifications. @@ -95,8 +153,12 @@ Transport failures and a temporarily unavailable database remain retryable remote states. Authentication, request, JSON, and response-contract failures stop only the remote context until it is restarted through a route or settings change. None of these states stop the local detector or remove local markers. -The panel compares server and local `spec_version`; a mismatch is visible and -event consumption is blocked. The local reference hash is displayed and exposed +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 +V2 responses still 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-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index e6619e9..918eefc 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.2.2 +// @version 0.3.0 // @author jackhai9 // @description Native Bollinger/SMA60 markers with an optional read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* @@ -23,7 +23,7 @@ (() => { // src/binance-strategy29-bollinger/core/remote-summary-contract.js var STRATEGY29_SCHEMA_VERSION = 1; - var STRATEGY29_SPEC_VERSION = "29_2_spec_v1"; + var STRATEGY29_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 UNIT_STATUSES = /* @__PURE__ */ new Set(["warming", "ready", "stale", "insufficient_history", "data_gap", "failed"]); @@ -33,7 +33,34 @@ var ORIGINS = /* @__PURE__ */ new Set(["historical", "catch_up", "live"]); var DELIVERY_STATES = /* @__PURE__ */ new Set(["pending", "sending", "sent", "unknown", "expired", "failed"]); var DELIVERY_COUNT_KEYS = ["pending", "sending", "sent", "unknown", "expired", "failed"]; - var STATUS_KEYS = ["schema_version", "spec_version", "observed_at_ms", "units", "delivery_counts"]; + var STATUS_KEYS = ["schema_version", "spec_version", "observed_at_ms", "universe", "units", "delivery_counts"]; + var UNIVERSE_KEYS = [ + "source_monitor", + "generation", + "refresh_status", + "reason", + "selected_markets", + "configured_timeframes", + "selected_unit_count", + "ready_unit_count", + "pending_unit_count", + "refreshed_at_ms", + "last_successful_refreshed_at_ms", + "last_success_age_seconds", + "last_refresh_error_at_ms", + "selection_expires_at_ms" + ]; + var UNIVERSE_STATES = /* @__PURE__ */ new Set(["fresh", "stale_if_error", "fail_closed"]); + var UNIVERSE_REASONS = { + fresh: /* @__PURE__ */ new Set(["current"]), + stale_if_error: /* @__PURE__ */ new Set(["using_stale_selection_after_refresh_error"]), + fail_closed: /* @__PURE__ */ new Set([ + "selection_fail_closed", + "selection_expired_or_unusable", + "missing_current_universe_facts", + "incompatible_current_universe_facts" + ]) + }; var UNIT_KEYS = [ "symbol", "timeframe", @@ -125,12 +152,79 @@ throw new TypeError(`${name}.last_event_id must be null or a lowercase hexadecimal event id`); } } + function validateUniverse(value) { + assertExactKeys(value, UNIVERSE_KEYS, "status.universe"); + if (value.source_monitor !== "monitor29_bollinger_ma60") throw new TypeError("status.universe.source_monitor is invalid"); + assertInteger(value.generation, "status.universe.generation", { nullable: true, minimum: 1 }); + assertEnum(value.refresh_status, UNIVERSE_STATES, "status.universe.refresh_status"); + assertEnum(value.reason, UNIVERSE_REASONS[value.refresh_status], "status.universe.reason"); + for (const key of ["selected_markets", "configured_timeframes"]) { + if (!Array.isArray(value[key]) || value[key].length > 128 || new Set(value[key]).size !== value[key].length) { + throw new TypeError(`status.universe.${key} must be a bounded unique array`); + } + } + value.selected_markets.forEach((symbol) => assertCanonicalSymbol(symbol, "status.universe.selected_markets")); + value.configured_timeframes.forEach((timeframe) => assertEnum(timeframe, TIMEFRAMES, "status.universe.configured_timeframes")); + for (const key of ["selected_unit_count", "ready_unit_count", "pending_unit_count"]) { + assertInteger(value[key], `status.universe.${key}`); + if (value[key] > 128) throw new TypeError(`status.universe.${key} exceeds the unit bound`); + } + if (value.ready_unit_count + value.pending_unit_count !== value.selected_unit_count || value.selected_markets.length > value.selected_unit_count) throw new TypeError("status.universe counts are inconsistent"); + if (value.refresh_status === "fail_closed" && value.selected_unit_count !== 0) throw new TypeError("status.universe unavailable selection must be empty"); + for (const key of ["refreshed_at_ms", "last_successful_refreshed_at_ms", "last_refresh_error_at_ms", "selection_expires_at_ms"]) { + assertInteger(value[key], `status.universe.${key}`, { nullable: true }); + } + if (value.last_success_age_seconds !== null) { + assertFiniteNumber(value.last_success_age_seconds, "status.universe.last_success_age_seconds"); + if (value.last_success_age_seconds < 0) throw new TypeError("status.universe last success age must be non-negative"); + } + if (value.last_successful_refreshed_at_ms === null !== (value.last_success_age_seconds === null)) { + throw new TypeError("status.universe last success fields are inconsistent"); + } + const successMissing = value.last_successful_refreshed_at_ms === null; + if (successMissing !== (value.selection_expires_at_ms === null)) { + throw new TypeError("status.universe successful selection requires its expiry"); + } + const absentFacts = value.reason === "missing_current_universe_facts" || value.reason === "incompatible_current_universe_facts"; + if (absentFacts) { + if ([ + value.generation, + value.refreshed_at_ms, + value.last_successful_refreshed_at_ms, + value.last_success_age_seconds, + value.last_refresh_error_at_ms, + value.selection_expires_at_ms + ].some((item) => item !== null)) { + throw new TypeError("status.universe unavailable facts must have null refresh metadata"); + } + } else { + if (value.generation === null || value.refreshed_at_ms === null) { + throw new TypeError("status.universe current facts require generation and refresh time"); + } + if (value.reason !== "selection_fail_closed" && successMissing) { + throw new TypeError("status.universe successful selection metadata is required"); + } + if (value.refresh_status === "fresh" && value.last_refresh_error_at_ms !== null) { + throw new TypeError("status.universe fresh selection cannot report a refresh error"); + } + if ((value.refresh_status === "stale_if_error" || value.reason === "selection_fail_closed") && value.last_refresh_error_at_ms === null) { + throw new TypeError("status.universe failed refresh requires its error time"); + } + } + } function validateStrategy29StatusResponse(value, httpStatus) { if (httpStatus !== 200) throw new TypeError(`status response requires HTTP 200, received ${httpStatus}`); - assertExactKeys(value, STATUS_KEYS, "status response"); + assertObject(value, "status response"); assertSchema(value.schema_version, "status.schema_version"); assertString(value.spec_version, "status.spec_version"); assertInteger(value.observed_at_ms, "status.observed_at_ms"); + if (value.spec_version !== STRATEGY29_SPEC_VERSION) return { + schema_version: value.schema_version, + spec_version: value.spec_version, + observed_at_ms: value.observed_at_ms + }; + assertExactKeys(value, STATUS_KEYS, "status response"); + validateUniverse(value.universe); if (!Array.isArray(value.units)) throw new TypeError("status.units must be an array"); if (value.units.length > 128) throw new TypeError("status.units exceeds the 128-unit bound"); value.units.forEach(validateUnit); @@ -1747,6 +1841,290 @@ }; } + // src/binance-orderbook-trade/contracts/panel-copy.js + var UI_LOCALE_ZH_CN = "zh-CN"; + var UI_LOCALE_EN = "en"; + var SUPPORTED_UI_LOCALES = Object.freeze([ + UI_LOCALE_ZH_CN, + UI_LOCALE_EN + ]); + function localizedText(zhCN, en) { + if (typeof zhCN !== "string" || zhCN === "" || typeof en !== "string" || en === "") { + throw new Error("Localized UI text requires non-empty Chinese and English values"); + } + return Object.freeze({ zhCN, en }); + } + function isLocalizedText(value) { + return Boolean( + value && typeof value === "object" && typeof value.zhCN === "string" && typeof value.en === "string" + ); + } + function formatLocalizedText(value, locale) { + if (typeof value === "string") return value; + if (!isLocalizedText(value)) throw new Error("Invalid localized UI text"); + if (locale === UI_LOCALE_ZH_CN) return value.zhCN; + if (locale === UI_LOCALE_EN) return value.en; + throw new Error(`Unsupported UI locale: ${locale}`); + } + function resolveUiLocaleFromPathname(pathname) { + const firstSegment = String(pathname || "").split(/[?#]/, 1)[0].split("/").filter(Boolean)[0]; + return firstSegment?.toLowerCase() === "zh-cn" ? UI_LOCALE_ZH_CN : UI_LOCALE_EN; + } + var freezeCopy = (copy) => Object.freeze(copy); + var PANEL_COPY = Object.freeze({ + section: freezeCopy({ + singleOrder: localizedText("单击下单", "Single Order"), + ladderMaker: localizedText("阶梯下单 · Maker", "Ladder Orders · Maker") + }), + field: freezeCopy({ + clickOrderbook: localizedText("单击订单簿时", "On click"), + minimumOrderQuantity: localizedText("最小下单量的", "Minimum order qty"), + minimumOpenQuantity: localizedText("最小开仓量的", "Minimum open qty"), + minimumCloseQuantity: localizedText("最小平仓量的", "Minimum close qty"), + ratio: localizedText("比例", "Ratio"), + orderCount: localizedText("笔数", "Orders"), + interval: localizedText("间距", "Gap"), + pricePrecision: localizedText("精度", "Precision"), + multiplierUnit: localizedText("倍", "×") + }), + action: freezeCopy({ + openLong: localizedText("阶梯开多", "Open Long"), + openShort: localizedText("阶梯开空", "Open Short"), + closeLong: localizedText("阶梯平多", "Close Long"), + closeShort: localizedText("阶梯平空", "Close Short"), + cancel: localizedText("撤单", "Cancel"), + cancelRunning: localizedText("撤单处理中", "Cancelling"), + noOrders: localizedText("无挂单", "No Orders"), + accountRebalance: localizedText("账户再平衡", "Account Rebalance"), + stopLadderByAction: freezeCopy({ + OPEN_LONG: localizedText("停止开多", "Stop Open Long"), + OPEN_SHORT: localizedText("停止开空", "Stop Open Short"), + CLOSE_LONG: localizedText("停止平多", "Stop Close Long"), + CLOSE_SHORT: localizedText("停止平空", "Stop Close Short") + }) + }), + side: freezeCopy({ + long: localizedText("多", "Long"), + short: localizedText("空", "Short"), + openLong: localizedText("开多", "Open Long"), + openShort: localizedText("开空", "Open Short"), + closeLong: localizedText("平多", "Close Long"), + closeShort: localizedText("平空", "Close Short") + }), + state: freezeCopy({ + idle: localizedText("空闲", "Idle"), + allPositionsClosed: localizedText("已全部平仓", "All positions closed"), + waitingTradeMode: localizedText("等待开仓/平仓状态", "Waiting for trade mode"), + waitingPricePrecision: localizedText("等待价格精度", "Waiting for precision"), + minimumQuantityLoading: localizedText("最小量读取中", "Loading minimum qty"), + positiveIntegerMultiplier: localizedText("请输入正整数倍数", "Enter a positive integer"), + noClosablePosition: localizedText("暂无可平仓位", "No position to close") + }), + status: freezeCopy({ + precisionUpdated: localizedText("精度推荐已更新", "Precision recommendation updated"), + precisionInsufficient: localizedText( + "近期价格变化不足,请稍后重试", + "Recent price movement is insufficient. Try again later." + ) + }), + aria: freezeCopy({ + decrementMultiplier: localizedText("减少倍数", "Decrease multiplier"), + incrementMultiplier: localizedText("增加倍数", "Increase multiplier") + }), + rebalanceDialog: freezeCopy({ + targetSummary: localizedText( + "目标分配:资金 50% / 现货 40% / U本位 10%", + "Target allocation: Funding 50% / Spot 40% / USDⓈ-M Futures 10%" + ), + accountHeading: localizedText("账户", "Account"), + currentHeading: localizedText("当前 (USDT)", "Current (USDT)"), + targetHeading: localizedText("目标 (USDT)", "Target (USDT)"), + transferHeading: localizedText("划转计划", "Transfer Plan"), + cancel: localizedText("取消", "Cancel"), + confirm: localizedText("确认再平衡", "Confirm Rebalance") + }), + tooltip: freezeCopy({ + singleOrder: localizedText( + "单击订单簿中的某个价格,按当前方向和数量设置提交一笔订单。", + "Click a price in the order book to submit one order using the current side and quantity settings." + ), + ladderMaker: localizedText( + "根据当前比例、笔数、间距和价格精度设置,依次提交只做 Maker 的阶梯订单。", + "Submit Post Only ladder orders sequentially using the current ratio, order count, gap, and precision." + ), + ratio: localizedText( + "本次阶梯下单使用可开/可平数量的百分比。", + "Percentage of the available open or close quantity used by this ladder." + ), + orderCount: localizedText( + "计划拆分成多少笔阶梯订单。", + "Number of orders in the ladder." + ), + interval: localizedText( + "相邻订单跨越多少个订单簿价格级别。", + "Number of order-book price levels between adjacent orders." + ), + pricePrecision: localizedText( + "与订单簿中的价格精度联动。黄点表示推荐值。比例、笔数、间距会随所选精度恢复对应设置。", + "Linked to the order-book price precision. The yellow dot marks the recommendation. Ratio, orders, and gap restore their saved values for the selected precision." + ), + continuousClose: localizedText( + "Option/Alt + 单击:连续交易", + "Option/Alt + click: continuous trading" + ), + accountRebalance: localizedText( + "将资金、现货和 U 本位账户的 USDT 按 5:4:1 分配", + "Allocate USDT across Funding, Spot, and USDⓈ-M Futures accounts at a 5:4:1 ratio" + ) + }) + }); + + // src/binance-strategy29-bollinger/ui-copy.js + var pair = localizedText; + var SUMMARY_COPY = Object.freeze({ + title: pair("Strategy 29 汇总", "Strategy 29 Summary"), + drag: pair("拖动面板", "Drag panel"), + collapse: pair("收起", "Collapse"), + expand: pair("展开", "Expand"), + waiting: pair("等待中", "Waiting"), + observerSpec: (value) => pair(`观察器规格 ${value}`, `Observer spec ${value}`), + reference: (value) => pair(`本地参考版本 ${value}`, `Local reference ${value}`), + 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"), + 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}`), + statusAt: (value) => pair(`状态更新 ${value}`, `Status ${value}`), + eventsAt: (value) => pair(`事件检查 ${value}`, `Events checked ${value}`), + incompatibleSelection: pair("选币不可用:观察器规格不一致", "Selection unavailable: observer specs are incompatible"), + incompatibleDelivery: pair("全局通知状态不可用:观察器规格不一致", "Global delivery unavailable: observer specs are incompatible"), + pending: pair("等待中", "pending"), + generation: (value) => pair(`批次 ${value}`, `Generation ${value}`), + markets: (value) => pair(`${value} 个币种`, `${value} markets`), + ready: (ready, total) => pair(`${ready}/${total} 个实时监测单元已就绪`, `${ready}/${total} live units ready`), + intervals: (value) => pair(`周期 ${value}`, `Intervals ${value}`), + noSuccess: pair("尚无成功选币记录", "No successful selection has been observed"), + lastSuccess: (clock, age) => pair(`上次成功选币 ${clock} · ${age} 秒前`, `Last successful selection ${clock} · ${age}s ago`), + unavailableSelection: pair("服务端选币不可用", "Server selection is unavailable"), + awaitingUnits: pair("币种已入选,等待监测单元状态", "Symbol is selected; waiting for unit status"), + notSelected: pair("当前服务端选币未监听此币种", "Symbol is not watched by the current server selection"), + delivery: (c) => pair(`全局通知 · 待发送 ${c.pending} · 发送中 ${c.sending} · 已发送 ${c.sent} · 结果未知 ${c.unknown} · 已过期 ${c.expired} · 失败 ${c.failed}`, `Global delivery · Pending ${c.pending} · Sending ${c.sending} · Sent ${c.sent} · Unknown ${c.unknown} · Expired ${c.expired} · Failed ${c.failed}`), + configuration: pair("尚未配置网关密钥", "Gateway secret is not configured"), + connecting: pair("正在连接 Strategy 29 网关", "Connecting to Strategy 29 gateway"), + connected: pair("已连接", "Connected"), + moreHistory: pair("已连接 · 仍有历史记录待加载", "Connected · more history pending"), + unavailable: pair("网关数据库暂不可用", "Gateway database unavailable"), + incompatible: pair("服务端与本地规格不一致", "Server and local specs are incompatible"), + disconnected: pair("网关连接失败,将在下次定时检查时重试", "Gateway connection failed; next scheduled poll will retry"), + stopped: (detail) => pair(`远程汇总已停止。技术详情:${detail}`, `Remote summary stopped: ${detail}`), + menuToggle: pair("切换 Strategy 29 跨周期汇总", "Toggle Strategy 29 cross-timeframe summary"), + menuSecret: pair("设置 Strategy 29 网关密钥", "Set Strategy 29 gateway secret"), + menuOrigin: pair("设置 Strategy 29 网关地址", "Set Strategy 29 gateway origin"), + promptSecret: pair("请输入本地 Strategy 29 网关密钥,仅保存在此用户脚本的私有存储中。", "Enter the local Strategy 29 gateway secret. It is stored only in this userscript storage."), + promptOrigin: pair("请输入本机网关地址(http://127.0.0.1:)", "Enter the loopback gateway origin (http://127.0.0.1:)"), + emptySecret: pair("Strategy 29 网关密钥不能为空", "Strategy 29 gateway secret cannot be empty"), + localStopped: (detail) => pair(`Strategy 29 已停止。技术详情:${detail}`, `Strategy 29 stopped: ${detail}`), + conflict: pair("Strategy 29 已停止:请将订单簿脚本更新至 2.7.199 或更高版本,或禁用内嵌布林带观察器的旧版本,然后刷新页面。", "Strategy 29 stopped: update Orderbook to 2.7.199 or disable its embedded Bollinger version, then reload this page.") + }); + var SELECTION_REASONS = Object.freeze({ + current: pair("当前选币有效", "Selection is current"), + using_stale_selection_after_refresh_error: pair("刷新失败,暂沿用上次选币直至过期", "Refresh failed; using the previous selection until expiry"), + selection_fail_closed: pair("刷新失败,选币不可用", "Selection unavailable after refresh failure"), + selection_expired_or_unusable: pair("选币已过期,等待成功刷新", "Selection expired; waiting for a successful refresh"), + missing_current_universe_facts: pair("等待服务端初始化选币", "Waiting for server selection to initialize"), + incompatible_current_universe_facts: pair("服务端选币规格不兼容", "Server selection has an incompatible specification") + }); + var STATUS_LABELS = Object.freeze({ + ready: pair("已处理", "Processed"), + warming: pair("预热中", "Warming"), + stale: pair("已过期", "Stale"), + insufficient_history: pair("历史不足", "Insufficient history"), + data_gap: pair("数据缺口", "Data gap"), + failed: pair("失败", "Failed") + }); + var SIGNAL_LABELS = Object.freeze({ + "bearish:warning": pair("看跌预警", "Bearish warning"), + "bearish:confirmed": pair("看跌确认", "Bearish confirmed"), + "bearish:reversal": pair("多头反转", "Long reversal"), + "bullish:warning": pair("看涨预警", "Bullish warning"), + "bullish:confirmed": pair("看涨确认", "Bullish confirmed"), + "bullish:reversal": pair("空头反转", "Short reversal") + }); + var PROCESSING_REASONS = Object.freeze({ + current: pair("进度已更新", "Progress current"), + awaiting_producer_generation: pair("等待当前批次行情就绪", "Awaiting producer generation"), + awaiting_initial_baseline: pair("等待建立初始历史基线", "Awaiting initial baseline"), + awaiting_reentry_baseline: pair("等待重新入选的历史基线", "Awaiting re-entry baseline"), + latest_closed_candle_missing: pair("缺少最新已收盘 K 线", "Latest closed candle missing"), + observer_progress_stale: pair("观察器进度已过期", "Observer progress stale") + }); + function processingReason(reason, locale) { + if (Object.hasOwn(PROCESSING_REASONS, reason)) return formatLocalizedText(PROCESSING_REASONS[reason], locale); + const required = /^requires_(\d+)_closed_candles$/.exec(reason); + if (required) return formatLocalizedText(pair(`需要 ${required[1]} 根已收盘 K 线`, `Requires ${required[1]} closed candles`), locale); + return formatLocalizedText(pair(`技术详情:${reason}`, `Details: ${reason}`), locale); + } + + // src/binance-strategy29-bollinger/dom/panel-position.js + function installPanelPosition(document, panel, header, { initialPosition, savePosition }) { + const view = document.defaultView; + if (!view) throw new Error("Strategy 29 panel window is unavailable"); + let position = initialPosition ?? { left: view.innerWidth - panel.getBoundingClientRect().width - 84, top: 68 }; + let drag = null; + function apply(next) { + const rect = panel.getBoundingClientRect(); + position = { + left: Math.max(0, Math.min(next.left, Math.max(0, view.innerWidth - rect.width))), + top: Math.max(0, Math.min(next.top, Math.max(0, view.innerHeight - rect.height))) + }; + panel.style.left = `${position.left}px`; + panel.style.top = `${position.top}px`; + panel.style.right = "auto"; + } + function clamp() { + apply(position); + } + function onDown(event) { + if (event.button !== 0 || event.target.closest("button,a")) return; + const rect = panel.getBoundingClientRect(); + drag = { x: event.clientX, y: event.clientY, left: rect.left, top: rect.top }; + event.preventDefault(); + } + function onMove(event) { + if (!drag) return; + apply({ left: drag.left + event.clientX - drag.x, top: drag.top + event.clientY - drag.y }); + } + function finish() { + if (!drag) return; + drag = null; + clamp(); + savePosition({ ...position }); + } + clamp(); + header.style.cursor = "move"; + header.addEventListener("mousedown", onDown); + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", finish); + view.addEventListener("blur", finish); + view.addEventListener("resize", clamp); + return Object.freeze({ + clamp, + destroy() { + drag = null; + header.removeEventListener("mousedown", onDown); + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", finish); + view.removeEventListener("blur", finish); + view.removeEventListener("resize", clamp); + } + }); + } + // src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js var PANEL_ID = "jh-strategy29-summary-panel"; var STATE_COLORS = Object.freeze({ @@ -1759,21 +2137,13 @@ configuration_required: "#F0B90B" }); var STATUS_COLORS = Object.freeze({ - ready: "#0ECB81", + ready: "#848E9C", warming: "#F0B90B", stale: "#F6465D", insufficient_history: "#F0B90B", data_gap: "#F6465D", failed: "#F6465D" }); - var TYPE_LABELS = Object.freeze({ - "bearish:warning": "Bearish warning", - "bearish:confirmed": "Bearish confirmed", - "bearish:reversal": "Long reversal", - "bullish:warning": "Bullish warning", - "bullish:confirmed": "Bullish confirmed", - "bullish:reversal": "Short reversal" - }); var CLOCK_FORMATTER = new Intl.DateTimeFormat("en-GB", { month: "2-digit", day: "2-digit", @@ -1796,20 +2166,22 @@ const part = (name) => parts.find((item) => item.type === name)?.value; return `${part("month")}-${part("day")} ${part("hour")}:${part("minute")}:${part("second")} UTC+08`; } - function signalLabel(event) { - return TYPE_LABELS[`${event.setup_direction}:${event.signal_type}`]; - } - function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEvents = 20 } = {}) { + function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEvents = 20, 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"); + const stored = loadPosition(); + if (stored !== null && (!stored || typeof stored !== "object" || !Number.isFinite(stored.left) || !Number.isFinite(stored.top))) throw new TypeError("Strategy 29 panel position is invalid"); document.getElementById(PANEL_ID)?.remove(); const panel = element(document, "section", { styles: { position: "fixed", zIndex: "999995", - top: "68px", - right: "84px", + left: "0", + top: "0", width: "340px", boxSizing: "border-box", maxWidth: "calc(100vw - 112px)", @@ -1829,9 +2201,12 @@ const header = element(document, "header", { styles: { display: "flex", alignItems: "center", gap: "7px", padding: "8px 10px", borderBottom: "1px solid rgba(132,142,156,.20)" } }); - header.appendChild(element(document, "strong", { text: "Strategy 29 Summary", styles: { flex: "1", fontSize: "13px" } })); + const heading = element(document, "strong", { text: text(SUMMARY_COPY.title), styles: { flex: "1", fontSize: "13px" } }); + header.appendChild(element(document, "span", { text: "☰", styles: { color: "#848E9C" } })); + header.appendChild(heading); + header.title = text(SUMMARY_COPY.drag); const collapse = element(document, "button", { - text: "Collapse", + text: text(SUMMARY_COPY.collapse), role: "collapse", styles: { border: "0", borderRadius: "5px", padding: "2px 7px", background: "rgba(132,142,156,.18)", color: "#EAECEF", cursor: "pointer" } }); @@ -1840,21 +2215,32 @@ const body = element(document, "div", { role: "body", styles: { maxHeight: "calc(100vh - 150px)", overflow: "auto" } }); const overview = element(document, "div", { styles: { display: "grid", gap: "4px", padding: "9px 10px" } }); overview.appendChild(element(document, "div", { text: canonicalSymbol, role: "symbol", styles: { fontWeight: "700" } })); - const connection = element(document, "div", { text: "Waiting", role: "connection", styles: { color: "#848E9C", fontSize: "11px" } }); - const spec = element(document, "div", { text: `Local spec ${STRATEGY29_SPEC_VERSION}`, role: "spec", styles: { color: "#848E9C", fontSize: "11px" } }); - const reference = element(document, "div", { text: `Local reference ${STRATEGY29_REFERENCE_SHA256}`, role: "reference", styles: { color: "#848E9C", fontSize: "10px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", userSelect: "text" } }); - const statusFreshness = element(document, "div", { text: "Status not received", role: "status-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); - const eventsFreshness = element(document, "div", { text: "Events not checked", role: "events-freshness", styles: { color: "#848E9C", fontSize: "11px" } }); - overview.append(connection, spec, reference, statusFreshness, eventsFreshness); - const unitsTitle = element(document, "div", { text: "Watched timeframes", styles: { padding: "7px 10px 4px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontWeight: "600" } }); + const connection = element(document, "div", { text: text(SUMMARY_COPY.waiting), role: "connection", styles: { color: "#848E9C", fontSize: "11px" } }); + const spec = element(document, "div", { text: text(SUMMARY_COPY.observerSpec(STRATEGY29_SPEC_VERSION)), role: "spec", styles: { color: "#848E9C", fontSize: "11px" } }); + const reference = element(document, "div", { text: text(SUMMARY_COPY.reference(STRATEGY29_REFERENCE_SHA256)), role: "reference", styles: { color: "#848E9C", fontSize: "10px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", userSelect: "text" } }); + 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 selectionRefresh = element(document, "div", { role: "selection-refresh", styles: { color: "#848E9C", fontSize: "11px" } }); + overview.append(connection, spec, reference, statusFreshness, selection, selectionRefresh, eventsFreshness); + 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), + styles: { fontSize: "11px", fontWeight: "400" } + }); + unitsTitle.appendChild(processingHint); const units = element(document, "div", { role: "units", styles: { display: "grid", gap: "3px", padding: "0 7px 8px" } }); - const delivery = element(document, "div", { text: "Global delivery — waiting", role: "delivery", styles: { padding: "7px 10px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontSize: "11px" } }); - const eventsTitle = element(document, "div", { text: "Recent cross-timeframe signals", styles: { padding: "7px 10px 4px", borderTop: "1px solid rgba(132,142,156,.18)", color: "#848E9C", fontWeight: "600" } }); + 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 events = element(document, "div", { role: "events", styles: { display: "grid", gap: "3px", padding: "0 7px 8px" } }); body.append(overview, unitsTitle, units, delivery, eventsTitle, events); panel.append(header, body); document.body.appendChild(panel); + const position = installPanelPosition(document, panel, header, { initialPosition: stored, savePosition }); const eventRecords = /* @__PURE__ */ new Map(); + let lastStatus = null; + let lastEventsAt = null; + let connectionCopy = SUMMARY_COPY.waiting; let destroyed = false; function assertLive() { if (destroyed) throw new Error("Strategy 29 summary panel is destroyed"); @@ -1870,77 +2256,134 @@ row.dataset.eventId = event.event_id; row.appendChild(element(document, "strong", { text: event.timeframe, styles: { color: "#F0B90B" } })); row.appendChild(element(document, "span", { - text: signalLabel(event), + text: text(SIGNAL_LABELS[`${event.setup_direction}:${event.signal_type}`]), styles: { color: event.signal_side === "long" ? "#0ECB81" : "#F6465D", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } })); - row.appendChild(element(document, "span", { text: `Close ${formatClock(event.bar_close_ms)}`, styles: { color: "#848E9C", fontSize: "10px", textAlign: "right" } })); + 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: "No recent signals", styles: { color: "#848E9C", padding: "4px" } })); + if (ordered.length === 0) events.appendChild(element(document, "span", { text: text(SUMMARY_COPY.noEvents), styles: { color: "#848E9C", padding: "4px" } })); } collapse.addEventListener("click", () => { const collapsed = body.style.display !== "none"; body.style.display = collapsed ? "none" : "block"; - collapse.textContent = collapsed ? "Expand" : "Collapse"; + collapse.textContent = text(collapsed ? SUMMARY_COPY.expand : SUMMARY_COPY.collapse); + position.clamp(); }); renderEvents(); - return Object.freeze({ + const api = Object.freeze({ + setLocale(nextLocale) { + assertLive(); + formatLocalizedText(SUMMARY_COPY.waiting, nextLocale); + if (locale === nextLocale) return; + locale = nextLocale; + heading.textContent = text(SUMMARY_COPY.title); + header.title = text(SUMMARY_COPY.drag); + 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)); + unitsTitle.firstChild.textContent = text(SUMMARY_COPY.processing); + processingHint.textContent = text(SUMMARY_COPY.processingHint); + eventsTitle.textContent = text(SUMMARY_COPY.recent); + eventsFreshness.textContent = text(lastEventsAt === null ? SUMMARY_COPY.noEventsCheck : SUMMARY_COPY.eventsAt(formatClock(lastEventsAt))); + if (lastStatus !== null) api.renderStatus(lastStatus); + else { + spec.textContent = text(SUMMARY_COPY.observerSpec(STRATEGY29_SPEC_VERSION)); + statusFreshness.textContent = text(SUMMARY_COPY.noStatus); + delivery.textContent = text(SUMMARY_COPY.waitingDelivery); + } + renderEvents(); + position.clamp(); + }, setConnection(state, message) { assertLive(); - if (!(state in STATE_COLORS) || typeof message !== "string") throw new Error("Strategy 29 panel connection state is invalid"); + if (!(state in STATE_COLORS)) throw new Error("Strategy 29 panel connection state is invalid"); connection.dataset.state = state; connection.style.color = STATE_COLORS[state]; - connection.textContent = message; + connectionCopy = message; + connection.textContent = text(message); + position.clamp(); }, renderStatus(snapshot) { assertLive(); + lastStatus = snapshot; const matched = snapshot.spec_version === STRATEGY29_SPEC_VERSION; spec.dataset.state = matched ? "matched" : "error"; spec.style.color = matched ? "#0ECB81" : "#F6465D"; - spec.textContent = matched ? `Spec version matched · ${STRATEGY29_SPEC_VERSION}` : `Spec mismatch · local ${STRATEGY29_SPEC_VERSION} · server ${snapshot.spec_version}`; - statusFreshness.textContent = `Status ${formatClock(snapshot.observed_at_ms)}`; + spec.textContent = matched ? text(SUMMARY_COPY.matched(STRATEGY29_SPEC_VERSION)) : text(SUMMARY_COPY.mismatch(STRATEGY29_SPEC_VERSION, snapshot.spec_version)); + statusFreshness.textContent = text(SUMMARY_COPY.statusAt(formatClock(snapshot.observed_at_ms))); + if (!matched) { + selection.dataset.state = "incompatible"; + selection.style.color = "#F6465D"; + selection.textContent = text(SUMMARY_COPY.incompatibleSelection); + selectionRefresh.textContent = ""; + units.replaceChildren(); + delivery.textContent = text(SUMMARY_COPY.incompatibleDelivery); + position.clamp(); + return; + } + const universe = snapshot.universe; + const unavailable = universe.refresh_status === "fail_closed"; + 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(" · "); + 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 matching = snapshot.units.filter((unit) => unit.symbol === canonicalSymbol); + const selected = universe.selected_markets.includes(canonicalSymbol); + const matching = unavailable || !selected ? [] : snapshot.units.filter((unit) => unit.symbol === canonicalSymbol && universe.configured_timeframes.includes(unit.timeframe)); for (const unit of matching) { const row = element(document, "div", { role: "unit", - styles: { display: "grid", gridTemplateColumns: "42px 64px minmax(0,1fr)", gap: "6px", padding: "4px 6px", borderRadius: "5px", background: "rgba(132,142,156,.08)" } + styles: { display: "grid", gridTemplateColumns: "36px 78px minmax(0,1fr)", gap: "6px", padding: "4px 6px", borderRadius: "5px", background: "rgba(132,142,156,.08)" } }); row.appendChild(element(document, "strong", { text: unit.timeframe, styles: { color: "#EAECEF" } })); - row.appendChild(element(document, "span", { text: unit.status, styles: { color: STATUS_COLORS[unit.status] } })); - row.appendChild(element(document, "span", { text: unit.reason, styles: { color: "#848E9C", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } })); + row.appendChild(element(document, "span", { text: text(STATUS_LABELS[unit.status]), styles: { color: STATUS_COLORS[unit.status] } })); + row.appendChild(element(document, "span", { text: processingReason(unit.reason, locale), styles: { color: "#848E9C", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } })); units.appendChild(row); } - if (matching.length === 0) units.appendChild(element(document, "span", { text: "Symbol is not watched by the server", styles: { color: "#F0B90B", padding: "4px" } })); + if (matching.length === 0) units.appendChild(element(document, "span", { + text: text(unavailable ? SUMMARY_COPY.unavailableSelection : selected ? SUMMARY_COPY.awaitingUnits : SUMMARY_COPY.notSelected), + styles: { color: "#F0B90B", padding: "4px" } + })); const counts = snapshot.delivery_counts; - delivery.textContent = `Global delivery · Pending ${counts.pending} · Sending ${counts.sending} · Sent ${counts.sent} · Unknown ${counts.unknown} · Expired ${counts.expired} · Failed ${counts.failed}`; + delivery.textContent = text(SUMMARY_COPY.delivery(counts)); + position.clamp(); }, addEvents(incoming, observedAtMs = null) { assertLive(); for (const event of incoming) eventRecords.set(event.event_id, event); const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence); while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id); - if (observedAtMs !== null) eventsFreshness.textContent = `Events checked ${formatClock(observedAtMs)}`; + if (observedAtMs !== null) { + lastEventsAt = observedAtMs; + eventsFreshness.textContent = text(SUMMARY_COPY.eventsAt(formatClock(observedAtMs))); + } renderEvents(); + position.clamp(); }, clearEvents() { assertLive(); eventRecords.clear(); renderEvents(); + position.clamp(); }, destroy() { if (destroyed) return; destroyed = true; eventRecords.clear(); + position.destroy(); panel.remove(); }, get size() { return eventRecords.size; } }); + position.clamp(); + return api; } // src/binance-strategy29-bollinger/remote-summary.js + var STRATEGY29_PANEL_POSITION_KEY = "strategy29SummaryPanelPosition"; var STRATEGY29_REMOTE_ENABLED_KEY = "strategy29RemoteSummaryEnabled"; var STRATEGY29_GATEWAY_ORIGIN_KEY = "strategy29GatewayOrigin"; var STRATEGY29_GATEWAY_SECRET_KEY = "strategy29GatewayAuthSecret"; @@ -1981,6 +2424,8 @@ let active = null; let disposed = false; let unsupportedRoute = null; + let locale = resolveUiLocaleFromPathname(view.location.pathname); + const text = (value) => formatLocalizedText(value, locale); function isCurrent(context) { return !disposed && active === context && !context.abortController.signal.aborted; } @@ -2001,7 +2446,12 @@ } function startContext(routeSymbol) { const canonicalSymbol = routeSymbolToCanonical(routeSymbol); - const panel = createPanel(view.document, canonicalSymbol, { maxEvents: 20 }); + const panel = createPanel(view.document, canonicalSymbol, { + maxEvents: 20, + locale, + loadPosition: () => getValue(STRATEGY29_PANEL_POSITION_KEY, null), + savePosition: (position) => setValue(STRATEGY29_PANEL_POSITION_KEY, position) + }); const AbortControllerConstructor = view.AbortController ?? AbortController; const context = { routeSymbol, @@ -2026,14 +2476,14 @@ context.failed = true; context.state = "stopped"; context.lastError = error.message; - panel.setConnection("stopped", `Remote summary stopped: ${error.message}`); + panel.setConnection("stopped", SUMMARY_COPY.stopped(error.message)); view.console.warn("[Strategy29 remote]", error.message); return context; } const { authSecret, gatewayOrigin } = settings; if (authSecret.length === 0) { context.state = "configuration_required"; - panel.setConnection("configuration_required", "Gateway secret is not configured"); + panel.setConnection("configuration_required", SUMMARY_COPY.configuration); return context; } try { @@ -2057,7 +2507,7 @@ context.failed = true; context.state = "stopped"; context.lastError = error.message; - panel.setConnection("stopped", `Remote summary stopped: ${error.message}`); + panel.setConnection("stopped", SUMMARY_COPY.stopped(error.message)); view.console.warn("[Strategy29 remote]", error.message); } return context; @@ -2089,6 +2539,7 @@ } function sample(nowMs = Date.now()) { if (disposed || view.document.hidden) return; + synchronizeLocale(); const context = synchronizeContext(); if (!context || !context.client || context.inFlight || context.failed || nowMs < context.nextPollAtMs) return; if (context.abortController.signal.aborted) { @@ -2100,16 +2551,16 @@ context.nextPollAtMs = nowMs + pollIntervalMs; context.inFlight = true; context.state = "connecting"; - context.panel.setConnection("connecting", "Connecting to Strategy 29 gateway"); + context.panel.setConnection("connecting", SUMMARY_COPY.connecting); return context.client.poll(controller.signal).then((result) => { if (!ownsRequest()) return; context.lastResult = result; context.lastError = null; context.state = result.state; const presentation = { - connected: ["connected", result.hasMore ? "Connected · more history pending" : "Connected"], - unavailable: ["unavailable", "Gateway database unavailable"], - incompatible: ["incompatible", "Server and local specs are incompatible"] + connected: ["connected", result.hasMore ? SUMMARY_COPY.moreHistory : SUMMARY_COPY.connected], + unavailable: ["unavailable", SUMMARY_COPY.unavailable], + incompatible: ["incompatible", SUMMARY_COPY.incompatible] }[result.state]; if (!presentation) throw new Error(`Strategy 29 remote state is invalid: ${result.state}`); context.panel.setConnection(...presentation); @@ -2118,11 +2569,11 @@ context.lastError = error.message; if (error instanceof Strategy29GatewayTransportError) { context.state = "disconnected"; - context.panel.setConnection("disconnected", "Gateway connection failed; next scheduled poll will retry"); + context.panel.setConnection("disconnected", SUMMARY_COPY.disconnected); } else { context.state = "stopped"; context.failed = true; - context.panel.setConnection("stopped", `Remote summary stopped: ${error.message}`); + context.panel.setConnection("stopped", SUMMARY_COPY.stopped(error.message)); } view.console.warn("[Strategy29 remote]", error.message); }).finally(() => { @@ -2134,25 +2585,35 @@ stopActive("Strategy 29 remote settings changed"); if (!disposed) void sample(Date.now()); } - registerMenuCommand("Toggle Strategy 29 cross-timeframe summary", () => { - enabled = !enabled; - setValue(STRATEGY29_REMOTE_ENABLED_KEY, enabled); - restart(); - }); - registerMenuCommand("Set Strategy 29 gateway secret", () => { - const value = promptUser2("Enter the local Strategy 29 gateway secret. It is stored only in this userscript storage."); - if (value === null) return; - if (value.length === 0) throw new Error("Strategy 29 gateway secret cannot be empty"); - setValue(STRATEGY29_GATEWAY_SECRET_KEY, value); - restart(); - }); - registerMenuCommand("Set Strategy 29 gateway origin", () => { - const current = getValue(STRATEGY29_GATEWAY_ORIGIN_KEY, STRATEGY29_DEFAULT_GATEWAY_ORIGIN); - const value = promptUser2("Enter the loopback gateway origin (http://127.0.0.1:)", current); - if (value === null) return; - setValue(STRATEGY29_GATEWAY_ORIGIN_KEY, normalizeStrategy29GatewayOrigin(value)); - restart(); - }); + const menus = [ + { copy: SUMMARY_COPY.menuToggle, run() { + enabled = !enabled; + setValue(STRATEGY29_REMOTE_ENABLED_KEY, enabled); + restart(); + } }, + { copy: SUMMARY_COPY.menuSecret, run() { + const value = promptUser2(formatLocalizedText(SUMMARY_COPY.promptSecret, resolveUiLocaleFromPathname(view.location.pathname))); + if (value === null) return; + if (value.length === 0) throw new Error(text(SUMMARY_COPY.emptySecret)); + setValue(STRATEGY29_GATEWAY_SECRET_KEY, value); + restart(); + } }, + { copy: SUMMARY_COPY.menuOrigin, run() { + const current = getValue(STRATEGY29_GATEWAY_ORIGIN_KEY, STRATEGY29_DEFAULT_GATEWAY_ORIGIN); + const value = promptUser2(formatLocalizedText(SUMMARY_COPY.promptOrigin, resolveUiLocaleFromPathname(view.location.pathname)), current); + if (value === null) return; + setValue(STRATEGY29_GATEWAY_ORIGIN_KEY, normalizeStrategy29GatewayOrigin(value)); + restart(); + } } + ]; + for (const menu of menus) menu.id = registerMenuCommand(text(menu.copy), menu.run); + function synchronizeLocale() { + const current = resolveUiLocaleFromPathname(view.location.pathname); + if (current === locale) return; + locale = current; + for (const menu of menus) menu.id = registerMenuCommand(text(menu.copy), menu.run, { id: menu.id }); + active?.panel.setLocale(locale); + } return Object.freeze({ sample, pause() { @@ -2189,7 +2650,7 @@ // src/binance-strategy29-bollinger/runtime.js var INSTANCE = Symbol.for("jh-userscripts.strategy29-bollinger"); var RUNTIME_VERSION = 2; - var CONFLICT = "Strategy 29 stopped: update Orderbook to 2.7.199 or disable its embedded Bollinger version, then reload this page."; + var CONFLICT = SUMMARY_COPY.conflict; function hasEmbeddedBollinger(view) { const debug = view.__TM_CLOSE_LONG_DEBUG__; return !!debug && Object.getOwnPropertyDescriptor(debug, "bollingerAlertState") !== void 0; @@ -2216,7 +2677,7 @@ notice.style.cssText = "position:fixed;left:16px;bottom:16px;z-index:10000;max-width:420px;padding:10px;background:#332b16;color:#ffcf67;font:13px sans-serif;pointer-events:none"; document.body.append(notice); } - notice.textContent = failed; + notice.textContent = formatLocalizedText(failed, resolveUiLocaleFromPathname(view.location.pathname)); } const monitor = createBollingerMonitor({ document, @@ -2239,7 +2700,11 @@ showFailure(); } function sample() { - if (disposed || failed || document.hidden) return; + if (disposed || document.hidden) return; + if (failed) { + showFailure(); + return; + } if (hasEmbeddedBollinger(view)) { fail(CONFLICT); return; @@ -2250,7 +2715,7 @@ monitor.stop(); return; } - void monitor.tick().catch((error) => fail(`Strategy 29 stopped: ${error.message}`)); + void monitor.tick().catch((error) => fail(SUMMARY_COPY.localStopped(error.message))); } function resume() { if (disposed || failed || document.hidden) return; @@ -2272,7 +2737,7 @@ get diagnostics() { return { ...monitor.diagnostics, - runtimeFailure: failed, + runtimeFailure: failed === null ? null : formatLocalizedText(failed, "en"), disposed, timerRunning: timer !== null, remoteSummary: remoteSummary?.diagnostics ?? Object.freeze({ enabled: false, state: "unavailable_in_this_installation" }) diff --git a/src/binance-strategy29-bollinger/core/remote-summary-contract.js b/src/binance-strategy29-bollinger/core/remote-summary-contract.js index e53c301..5af52de 100644 --- a/src/binance-strategy29-bollinger/core/remote-summary-contract.js +++ b/src/binance-strategy29-bollinger/core/remote-summary-contract.js @@ -1,5 +1,5 @@ export const STRATEGY29_SCHEMA_VERSION = 1; -export const STRATEGY29_SPEC_VERSION = '29_2_spec_v1'; +export const STRATEGY29_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']); @@ -10,7 +10,21 @@ const SIGNAL_SIDES = new Set(['short', 'long']); const ORIGINS = new Set(['historical', 'catch_up', 'live']); const DELIVERY_STATES = new Set(['pending', 'sending', 'sent', 'unknown', 'expired', 'failed']); const DELIVERY_COUNT_KEYS = ['pending', 'sending', 'sent', 'unknown', 'expired', 'failed']; -const STATUS_KEYS = ['schema_version', 'spec_version', 'observed_at_ms', 'units', 'delivery_counts']; +const STATUS_KEYS = ['schema_version', 'spec_version', 'observed_at_ms', 'universe', 'units', 'delivery_counts']; +const UNIVERSE_KEYS = [ + 'source_monitor', 'generation', 'refresh_status', 'reason', 'selected_markets', 'configured_timeframes', + 'selected_unit_count', 'ready_unit_count', 'pending_unit_count', 'refreshed_at_ms', + 'last_successful_refreshed_at_ms', 'last_success_age_seconds', 'last_refresh_error_at_ms', 'selection_expires_at_ms', +]; +const UNIVERSE_STATES = new Set(['fresh', 'stale_if_error', 'fail_closed']); +const UNIVERSE_REASONS = { + fresh: new Set(['current']), + stale_if_error: new Set(['using_stale_selection_after_refresh_error']), + fail_closed: new Set([ + 'selection_fail_closed', 'selection_expired_or_unusable', + 'missing_current_universe_facts', 'incompatible_current_universe_facts', + ]), +}; const UNIT_KEYS = [ 'symbol', 'timeframe', 'status', 'reason', 'last_processed_open_ms', 'last_data_at_ms', 'last_event_id', ]; @@ -96,12 +110,75 @@ function validateUnit(value, index) { } } +function validateUniverse(value) { + assertExactKeys(value, UNIVERSE_KEYS, 'status.universe'); + if (value.source_monitor !== 'monitor29_bollinger_ma60') throw new TypeError('status.universe.source_monitor is invalid'); + assertInteger(value.generation, 'status.universe.generation', { nullable: true, minimum: 1 }); + assertEnum(value.refresh_status, UNIVERSE_STATES, 'status.universe.refresh_status'); + assertEnum(value.reason, UNIVERSE_REASONS[value.refresh_status], 'status.universe.reason'); + for (const key of ['selected_markets', 'configured_timeframes']) { + if (!Array.isArray(value[key]) || value[key].length > 128 || new Set(value[key]).size !== value[key].length) { + throw new TypeError(`status.universe.${key} must be a bounded unique array`); + } + } + value.selected_markets.forEach(symbol => assertCanonicalSymbol(symbol, 'status.universe.selected_markets')); + value.configured_timeframes.forEach(timeframe => assertEnum(timeframe, TIMEFRAMES, 'status.universe.configured_timeframes')); + for (const key of ['selected_unit_count', 'ready_unit_count', 'pending_unit_count']) { + assertInteger(value[key], `status.universe.${key}`); + if (value[key] > 128) throw new TypeError(`status.universe.${key} exceeds the unit bound`); + } + if (value.ready_unit_count + value.pending_unit_count !== value.selected_unit_count + || value.selected_markets.length > value.selected_unit_count) throw new TypeError('status.universe counts are inconsistent'); + if (value.refresh_status === 'fail_closed' && value.selected_unit_count !== 0) throw new TypeError('status.universe unavailable selection must be empty'); + for (const key of ['refreshed_at_ms', 'last_successful_refreshed_at_ms', 'last_refresh_error_at_ms', 'selection_expires_at_ms']) { + assertInteger(value[key], `status.universe.${key}`, { nullable: true }); + } + if (value.last_success_age_seconds !== null) { + assertFiniteNumber(value.last_success_age_seconds, 'status.universe.last_success_age_seconds'); + if (value.last_success_age_seconds < 0) throw new TypeError('status.universe last success age must be non-negative'); + } + if ((value.last_successful_refreshed_at_ms === null) !== (value.last_success_age_seconds === null)) { + throw new TypeError('status.universe last success fields are inconsistent'); + } + const successMissing = value.last_successful_refreshed_at_ms === null; + if (successMissing !== (value.selection_expires_at_ms === null)) { + throw new TypeError('status.universe successful selection requires its expiry'); + } + const absentFacts = value.reason === 'missing_current_universe_facts' || value.reason === 'incompatible_current_universe_facts'; + if (absentFacts) { + if ([value.generation, value.refreshed_at_ms, value.last_successful_refreshed_at_ms, + value.last_success_age_seconds, value.last_refresh_error_at_ms, value.selection_expires_at_ms].some(item => item !== null)) { + throw new TypeError('status.universe unavailable facts must have null refresh metadata'); + } + } else { + if (value.generation === null || value.refreshed_at_ms === null) { + throw new TypeError('status.universe current facts require generation and refresh time'); + } + if (value.reason !== 'selection_fail_closed' && successMissing) { + throw new TypeError('status.universe successful selection metadata is required'); + } + if (value.refresh_status === 'fresh' && value.last_refresh_error_at_ms !== null) { + throw new TypeError('status.universe fresh selection cannot report a refresh error'); + } + if ((value.refresh_status === 'stale_if_error' || value.reason === 'selection_fail_closed') + && value.last_refresh_error_at_ms === null) { + throw new TypeError('status.universe failed refresh requires its error time'); + } + } +} + export function validateStrategy29StatusResponse(value, httpStatus) { if (httpStatus !== 200) throw new TypeError(`status response requires HTTP 200, received ${httpStatus}`); - assertExactKeys(value, STATUS_KEYS, 'status response'); + assertObject(value, 'status response'); assertSchema(value.schema_version, 'status.schema_version'); assertString(value.spec_version, 'status.spec_version'); assertInteger(value.observed_at_ms, 'status.observed_at_ms'); + // An incompatible spec exposes identity only; its payload is not our contract. + if (value.spec_version !== STRATEGY29_SPEC_VERSION) return { + schema_version: value.schema_version, spec_version: value.spec_version, observed_at_ms: value.observed_at_ms, + }; + assertExactKeys(value, STATUS_KEYS, 'status response'); + validateUniverse(value.universe); if (!Array.isArray(value.units)) throw new TypeError('status.units must be an array'); if (value.units.length > 128) throw new TypeError('status.units exceeds the 128-unit bound'); value.units.forEach(validateUnit); diff --git a/src/binance-strategy29-bollinger/dom/panel-position.js b/src/binance-strategy29-bollinger/dom/panel-position.js new file mode 100644 index 0000000..6922a81 --- /dev/null +++ b/src/binance-strategy29-bollinger/dom/panel-position.js @@ -0,0 +1,52 @@ +/** Header-only dragging with userscript-owned position persistence and viewport bounds. */ +export function installPanelPosition(document, panel, header, { initialPosition, savePosition }) { + const view = document.defaultView; + if (!view) throw new Error('Strategy 29 panel window is unavailable'); + let position = initialPosition ?? { left: view.innerWidth - panel.getBoundingClientRect().width - 84, top: 68 }; + let drag = null; + function apply(next) { + const rect = panel.getBoundingClientRect(); + position = { + left: Math.max(0, Math.min(next.left, Math.max(0, view.innerWidth - rect.width))), + top: Math.max(0, Math.min(next.top, Math.max(0, view.innerHeight - rect.height))), + }; + panel.style.left = `${position.left}px`; + panel.style.top = `${position.top}px`; + panel.style.right = 'auto'; + } + function clamp() { apply(position); } + function onDown(event) { + if (event.button !== 0 || event.target.closest('button,a')) return; + const rect = panel.getBoundingClientRect(); + drag = { x: event.clientX, y: event.clientY, left: rect.left, top: rect.top }; + event.preventDefault(); + } + function onMove(event) { + if (!drag) return; + apply({ left: drag.left + event.clientX - drag.x, top: drag.top + event.clientY - drag.y }); + } + function finish() { + if (!drag) return; + drag = null; + clamp(); + savePosition({ ...position }); + } + clamp(); + header.style.cursor = 'move'; + header.addEventListener('mousedown', onDown); + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', finish); + view.addEventListener('blur', finish); + view.addEventListener('resize', clamp); + return Object.freeze({ + clamp, + destroy() { + drag = null; + header.removeEventListener('mousedown', onDown); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', finish); + view.removeEventListener('blur', finish); + view.removeEventListener('resize', clamp); + }, + }); +} diff --git a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js index 694d27e..507ed5e 100644 --- a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js +++ b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js @@ -1,5 +1,8 @@ import { STRATEGY29_REFERENCE_SHA256, STRATEGY29_SPEC_VERSION } 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'; + const PANEL_ID = 'jh-strategy29-summary-panel'; const STATE_COLORS = Object.freeze({ connected: '#0ECB81', @@ -11,21 +14,13 @@ const STATE_COLORS = Object.freeze({ configuration_required: '#F0B90B', }); const STATUS_COLORS = Object.freeze({ - ready: '#0ECB81', + ready: '#848E9C', warming: '#F0B90B', stale: '#F6465D', insufficient_history: '#F0B90B', data_gap: '#F6465D', failed: '#F6465D', }); -const TYPE_LABELS = Object.freeze({ - 'bearish:warning': 'Bearish warning', - 'bearish:confirmed': 'Bearish confirmed', - 'bearish:reversal': 'Long reversal', - 'bullish:warning': 'Bullish warning', - 'bullish:confirmed': 'Bullish confirmed', - 'bullish:reversal': 'Short reversal', -}); const CLOCK_FORMATTER = new Intl.DateTimeFormat('en-GB', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Shanghai', @@ -46,19 +41,20 @@ function formatClock(timestampMs) { return `${part('month')}-${part('day')} ${part('hour')}:${part('minute')}:${part('second')} UTC+08`; } -function signalLabel(event) { - return TYPE_LABELS[`${event.setup_direction}:${event.signal_type}`]; -} - -export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEvents = 20 } = {}) { +export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEvents = 20, 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'); + const stored = loadPosition(); + if (stored !== null && (!stored || typeof stored !== 'object' || !Number.isFinite(stored.left) || !Number.isFinite(stored.top))) throw new TypeError('Strategy 29 panel position is invalid'); document.getElementById(PANEL_ID)?.remove(); const panel = element(document, 'section', { styles: { - position: 'fixed', zIndex: '999995', top: '68px', right: '84px', width: '340px', + position: 'fixed', zIndex: '999995', left: '0', top: '0', width: '340px', boxSizing: 'border-box', maxWidth: 'calc(100vw - 112px)', maxHeight: 'calc(100vh - 92px)', overflow: 'hidden', border: '1px solid rgba(132,142,156,.30)', borderRadius: '9px', @@ -71,9 +67,12 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve const header = element(document, 'header', { styles: { display: 'flex', alignItems: 'center', gap: '7px', padding: '8px 10px', borderBottom: '1px solid rgba(132,142,156,.20)' }, }); - header.appendChild(element(document, 'strong', { text: 'Strategy 29 Summary', styles: { flex: '1', fontSize: '13px' } })); + const heading = element(document, 'strong', { text: text(COPY.title), styles: { flex: '1', fontSize: '13px' } }); + header.appendChild(element(document, 'span', { text: '☰', styles: { color: '#848E9C' } })); + header.appendChild(heading); + header.title = text(COPY.drag); const collapse = element(document, 'button', { - text: 'Collapse', role: 'collapse', + text: text(COPY.collapse), role: 'collapse', styles: { border: '0', borderRadius: '5px', padding: '2px 7px', background: 'rgba(132,142,156,.18)', color: '#EAECEF', cursor: 'pointer' }, }); collapse.type = 'button'; @@ -81,22 +80,33 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve const body = element(document, 'div', { role: 'body', styles: { maxHeight: 'calc(100vh - 150px)', overflow: 'auto' } }); const overview = element(document, 'div', { styles: { display: 'grid', gap: '4px', padding: '9px 10px' } }); overview.appendChild(element(document, 'div', { text: canonicalSymbol, role: 'symbol', styles: { fontWeight: '700' } })); - const connection = element(document, 'div', { text: 'Waiting', role: 'connection', styles: { color: '#848E9C', fontSize: '11px' } }); - const spec = element(document, 'div', { text: `Local spec ${STRATEGY29_SPEC_VERSION}`, role: 'spec', styles: { color: '#848E9C', fontSize: '11px' } }); - const reference = element(document, 'div', { text: `Local reference ${STRATEGY29_REFERENCE_SHA256}`, role: 'reference', styles: { color: '#848E9C', fontSize: '10px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', userSelect: 'text' } }); - const statusFreshness = element(document, 'div', { text: 'Status not received', role: 'status-freshness', styles: { color: '#848E9C', fontSize: '11px' } }); - const eventsFreshness = element(document, 'div', { text: 'Events not checked', role: 'events-freshness', styles: { color: '#848E9C', fontSize: '11px' } }); - overview.append(connection, spec, reference, statusFreshness, eventsFreshness); - const unitsTitle = element(document, 'div', { text: 'Watched timeframes', styles: { padding: '7px 10px 4px', borderTop: '1px solid rgba(132,142,156,.18)', color: '#848E9C', fontWeight: '600' } }); + const connection = element(document, 'div', { text: text(COPY.waiting), role: 'connection', styles: { color: '#848E9C', fontSize: '11px' } }); + const spec = element(document, 'div', { text: text(COPY.observerSpec(STRATEGY29_SPEC_VERSION)), role: 'spec', styles: { color: '#848E9C', fontSize: '11px' } }); + const reference = element(document, 'div', { text: text(COPY.reference(STRATEGY29_REFERENCE_SHA256)), role: 'reference', styles: { color: '#848E9C', fontSize: '10px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', userSelect: 'text' } }); + 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 selectionRefresh = element(document, 'div', { role: 'selection-refresh', styles: { color: '#848E9C', fontSize: '11px' } }); + overview.append(connection, spec, reference, statusFreshness, selection, selectionRefresh, eventsFreshness); + 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), + styles: { fontSize: '11px', fontWeight: '400' }, + }); + unitsTitle.appendChild(processingHint); const units = element(document, 'div', { role: 'units', styles: { display: 'grid', gap: '3px', padding: '0 7px 8px' } }); - const delivery = element(document, 'div', { text: 'Global delivery — waiting', role: 'delivery', styles: { padding: '7px 10px', borderTop: '1px solid rgba(132,142,156,.18)', color: '#848E9C', fontSize: '11px' } }); - const eventsTitle = element(document, 'div', { text: 'Recent cross-timeframe signals', styles: { padding: '7px 10px 4px', borderTop: '1px solid rgba(132,142,156,.18)', color: '#848E9C', fontWeight: '600' } }); + 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 events = element(document, 'div', { role: 'events', styles: { display: 'grid', gap: '3px', padding: '0 7px 8px' } }); body.append(overview, unitsTitle, units, delivery, eventsTitle, events); panel.append(header, body); document.body.appendChild(panel); + const position = installPanelPosition(document, panel, header, { initialPosition: stored, savePosition }); const eventRecords = new Map(); + let lastStatus = null; + let lastEventsAt = null; + let connectionCopy = COPY.waiting; let destroyed = false; function assertLive() { if (destroyed) throw new Error('Strategy 29 summary panel is destroyed'); @@ -112,74 +122,135 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve row.dataset.eventId = event.event_id; row.appendChild(element(document, 'strong', { text: event.timeframe, styles: { color: '#F0B90B' } })); row.appendChild(element(document, 'span', { - text: signalLabel(event), + text: text(SIGNAL_LABELS[`${event.setup_direction}:${event.signal_type}`]), styles: { color: event.signal_side === 'long' ? '#0ECB81' : '#F6465D', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, })); - row.appendChild(element(document, 'span', { text: `Close ${formatClock(event.bar_close_ms)}`, styles: { color: '#848E9C', fontSize: '10px', textAlign: 'right' } })); + 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: 'No recent signals', styles: { color: '#848E9C', padding: '4px' } })); + if (ordered.length === 0) events.appendChild(element(document, 'span', { text: text(COPY.noEvents), styles: { color: '#848E9C', padding: '4px' } })); } collapse.addEventListener('click', () => { const collapsed = body.style.display !== 'none'; body.style.display = collapsed ? 'none' : 'block'; - collapse.textContent = collapsed ? 'Expand' : 'Collapse'; + collapse.textContent = text(collapsed ? COPY.expand : COPY.collapse); + position.clamp(); }); renderEvents(); - return Object.freeze({ + const api = Object.freeze({ + setLocale(nextLocale) { + assertLive(); + formatLocalizedText(COPY.waiting, nextLocale); + if (locale === nextLocale) return; + locale = nextLocale; + heading.textContent = text(COPY.title); + header.title = text(COPY.drag); + collapse.textContent = text(body.style.display === 'none' ? COPY.expand : COPY.collapse); + connection.textContent = text(connectionCopy); + reference.textContent = text(COPY.reference(STRATEGY29_REFERENCE_SHA256)); + unitsTitle.firstChild.textContent = text(COPY.processing); + processingHint.textContent = text(COPY.processingHint); + eventsTitle.textContent = text(COPY.recent); + eventsFreshness.textContent = text(lastEventsAt === null ? COPY.noEventsCheck : COPY.eventsAt(formatClock(lastEventsAt))); + if (lastStatus !== null) api.renderStatus(lastStatus); + else { + spec.textContent = text(COPY.observerSpec(STRATEGY29_SPEC_VERSION)); + statusFreshness.textContent = text(COPY.noStatus); + delivery.textContent = text(COPY.waitingDelivery); + } + renderEvents(); + position.clamp(); + }, setConnection(state, message) { assertLive(); - if (!(state in STATE_COLORS) || typeof message !== 'string') throw new Error('Strategy 29 panel connection state is invalid'); + if (!(state in STATE_COLORS)) throw new Error('Strategy 29 panel connection state is invalid'); connection.dataset.state = state; connection.style.color = STATE_COLORS[state]; - connection.textContent = message; + connectionCopy = message; + connection.textContent = text(message); + position.clamp(); }, renderStatus(snapshot) { assertLive(); + lastStatus = snapshot; const matched = snapshot.spec_version === STRATEGY29_SPEC_VERSION; spec.dataset.state = matched ? 'matched' : 'error'; spec.style.color = matched ? '#0ECB81' : '#F6465D'; spec.textContent = matched - ? `Spec version matched · ${STRATEGY29_SPEC_VERSION}` - : `Spec mismatch · local ${STRATEGY29_SPEC_VERSION} · server ${snapshot.spec_version}`; - statusFreshness.textContent = `Status ${formatClock(snapshot.observed_at_ms)}`; + ? text(COPY.matched(STRATEGY29_SPEC_VERSION)) + : text(COPY.mismatch(STRATEGY29_SPEC_VERSION, snapshot.spec_version)); + statusFreshness.textContent = text(COPY.statusAt(formatClock(snapshot.observed_at_ms))); + if (!matched) { + selection.dataset.state = 'incompatible'; + selection.style.color = '#F6465D'; + selection.textContent = text(COPY.incompatibleSelection); + selectionRefresh.textContent = ''; + units.replaceChildren(); + delivery.textContent = text(COPY.incompatibleDelivery); + position.clamp(); + return; + } + const universe = snapshot.universe; + const unavailable = universe.refresh_status === 'fail_closed'; + 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(' · '); + 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 matching = snapshot.units.filter(unit => unit.symbol === canonicalSymbol); + const selected = universe.selected_markets.includes(canonicalSymbol); + const matching = unavailable || !selected ? [] : snapshot.units.filter(unit => ( + unit.symbol === canonicalSymbol && universe.configured_timeframes.includes(unit.timeframe) + )); for (const unit of matching) { const row = element(document, 'div', { role: 'unit', - styles: { display: 'grid', gridTemplateColumns: '42px 64px minmax(0,1fr)', gap: '6px', padding: '4px 6px', borderRadius: '5px', background: 'rgba(132,142,156,.08)' }, + styles: { display: 'grid', gridTemplateColumns: '36px 78px minmax(0,1fr)', gap: '6px', padding: '4px 6px', borderRadius: '5px', background: 'rgba(132,142,156,.08)' }, }); row.appendChild(element(document, 'strong', { text: unit.timeframe, styles: { color: '#EAECEF' } })); - row.appendChild(element(document, 'span', { text: unit.status, styles: { color: STATUS_COLORS[unit.status] } })); - row.appendChild(element(document, 'span', { text: unit.reason, styles: { color: '#848E9C', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } })); + // Stored processing can succeed before the current live admission is ready. + row.appendChild(element(document, 'span', { text: text(STATUS_LABELS[unit.status]), styles: { color: STATUS_COLORS[unit.status] } })); + row.appendChild(element(document, 'span', { text: processingReason(unit.reason, locale), styles: { color: '#848E9C', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } })); units.appendChild(row); } - if (matching.length === 0) units.appendChild(element(document, 'span', { text: 'Symbol is not watched by the server', styles: { color: '#F0B90B', padding: '4px' } })); + if (matching.length === 0) units.appendChild(element(document, 'span', { + text: text(unavailable ? COPY.unavailableSelection : selected ? COPY.awaitingUnits : COPY.notSelected), + styles: { color: '#F0B90B', padding: '4px' }, + })); const counts = snapshot.delivery_counts; - delivery.textContent = `Global delivery · Pending ${counts.pending} · Sending ${counts.sending} · Sent ${counts.sent} · Unknown ${counts.unknown} · Expired ${counts.expired} · Failed ${counts.failed}`; + delivery.textContent = text(COPY.delivery(counts)); + position.clamp(); }, addEvents(incoming, observedAtMs = null) { assertLive(); for (const event of incoming) eventRecords.set(event.event_id, event); const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence); while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id); - if (observedAtMs !== null) eventsFreshness.textContent = `Events checked ${formatClock(observedAtMs)}`; + if (observedAtMs !== null) { + lastEventsAt = observedAtMs; + eventsFreshness.textContent = text(COPY.eventsAt(formatClock(observedAtMs))); + } renderEvents(); + position.clamp(); }, clearEvents() { assertLive(); eventRecords.clear(); renderEvents(); + position.clamp(); }, destroy() { if (destroyed) return; destroyed = true; eventRecords.clear(); + position.destroy(); panel.remove(); }, get size() { return eventRecords.size; }, }); + position.clamp(); + return api; } diff --git a/src/binance-strategy29-bollinger/index.user.js b/src/binance-strategy29-bollinger/index.user.js index 833df2c..b12ed7c 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.2.2 +// @version 0.3.0 // @author jackhai9 // @description Native Bollinger/SMA60 markers with an optional 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 747f6ff..8bce0f8 100644 --- a/src/binance-strategy29-bollinger/remote-summary.js +++ b/src/binance-strategy29-bollinger/remote-summary.js @@ -11,6 +11,9 @@ import { import { createStrategy29SummaryPanel } from './dom/strategy29-summary-panel.js'; import { parseFuturesTradingSymbolFromPathname } from '../shared/binance-futures-route.js'; +import { SUMMARY_COPY as COPY, formatLocalizedText, resolveUiLocaleFromPathname } from './ui-copy.js'; + +export const STRATEGY29_PANEL_POSITION_KEY = 'strategy29SummaryPanelPosition'; export const STRATEGY29_REMOTE_ENABLED_KEY = 'strategy29RemoteSummaryEnabled'; export const STRATEGY29_GATEWAY_ORIGIN_KEY = 'strategy29GatewayOrigin'; export const STRATEGY29_GATEWAY_SECRET_KEY = 'strategy29GatewayAuthSecret'; @@ -49,6 +52,8 @@ export function createStrategy29RemoteSummary({ let active = null; let disposed = false; let unsupportedRoute = null; + let locale = resolveUiLocaleFromPathname(view.location.pathname); + const text = value => formatLocalizedText(value, locale); function isCurrent(context) { return !disposed && active === context && !context.abortController.signal.aborted; @@ -73,7 +78,11 @@ export function createStrategy29RemoteSummary({ function startContext(routeSymbol) { const canonicalSymbol = routeSymbolToCanonical(routeSymbol); - const panel = createPanel(view.document, canonicalSymbol, { maxEvents: 20 }); + const panel = createPanel(view.document, canonicalSymbol, { + maxEvents: 20, locale, + loadPosition: () => getValue(STRATEGY29_PANEL_POSITION_KEY, null), + savePosition: position => setValue(STRATEGY29_PANEL_POSITION_KEY, position), + }); const AbortControllerConstructor = view.AbortController ?? AbortController; const context = { routeSymbol, @@ -98,14 +107,14 @@ export function createStrategy29RemoteSummary({ context.failed = true; context.state = 'stopped'; context.lastError = error.message; - panel.setConnection('stopped', `Remote summary stopped: ${error.message}`); + panel.setConnection('stopped', COPY.stopped(error.message)); view.console.warn('[Strategy29 remote]', error.message); return context; } const { authSecret, gatewayOrigin } = settings; if (authSecret.length === 0) { context.state = 'configuration_required'; - panel.setConnection('configuration_required', 'Gateway secret is not configured'); + panel.setConnection('configuration_required', COPY.configuration); return context; } try { @@ -129,7 +138,7 @@ export function createStrategy29RemoteSummary({ context.failed = true; context.state = 'stopped'; context.lastError = error.message; - panel.setConnection('stopped', `Remote summary stopped: ${error.message}`); + panel.setConnection('stopped', COPY.stopped(error.message)); view.console.warn('[Strategy29 remote]', error.message); } return context; @@ -163,6 +172,7 @@ export function createStrategy29RemoteSummary({ function sample(nowMs = Date.now()) { if (disposed || view.document.hidden) return; + synchronizeLocale(); const context = synchronizeContext(); if (!context || !context.client || context.inFlight || context.failed || nowMs < context.nextPollAtMs) return; if (context.abortController.signal.aborted) { @@ -174,7 +184,7 @@ export function createStrategy29RemoteSummary({ context.nextPollAtMs = nowMs + pollIntervalMs; context.inFlight = true; context.state = 'connecting'; - context.panel.setConnection('connecting', 'Connecting to Strategy 29 gateway'); + context.panel.setConnection('connecting', COPY.connecting); return context.client.poll(controller.signal) .then(result => { if (!ownsRequest()) return; @@ -182,9 +192,9 @@ export function createStrategy29RemoteSummary({ context.lastError = null; context.state = result.state; const presentation = { - connected: ['connected', result.hasMore ? 'Connected · more history pending' : 'Connected'], - unavailable: ['unavailable', 'Gateway database unavailable'], - incompatible: ['incompatible', 'Server and local specs are incompatible'], + connected: ['connected', result.hasMore ? COPY.moreHistory : COPY.connected], + unavailable: ['unavailable', COPY.unavailable], + incompatible: ['incompatible', COPY.incompatible], }[result.state]; if (!presentation) throw new Error(`Strategy 29 remote state is invalid: ${result.state}`); context.panel.setConnection(...presentation); @@ -194,11 +204,11 @@ export function createStrategy29RemoteSummary({ context.lastError = error.message; if (error instanceof Strategy29GatewayTransportError) { context.state = 'disconnected'; - context.panel.setConnection('disconnected', 'Gateway connection failed; next scheduled poll will retry'); + context.panel.setConnection('disconnected', COPY.disconnected); } else { context.state = 'stopped'; context.failed = true; - context.panel.setConnection('stopped', `Remote summary stopped: ${error.message}`); + context.panel.setConnection('stopped', COPY.stopped(error.message)); } view.console.warn('[Strategy29 remote]', error.message); }) @@ -211,25 +221,36 @@ export function createStrategy29RemoteSummary({ if (!disposed) void sample(Date.now()); } - registerMenuCommand('Toggle Strategy 29 cross-timeframe summary', () => { - enabled = !enabled; - setValue(STRATEGY29_REMOTE_ENABLED_KEY, enabled); - restart(); - }); - registerMenuCommand('Set Strategy 29 gateway secret', () => { - const value = promptUser('Enter the local Strategy 29 gateway secret. It is stored only in this userscript storage.'); - if (value === null) return; - if (value.length === 0) throw new Error('Strategy 29 gateway secret cannot be empty'); - setValue(STRATEGY29_GATEWAY_SECRET_KEY, value); - restart(); - }); - registerMenuCommand('Set Strategy 29 gateway origin', () => { - const current = getValue(STRATEGY29_GATEWAY_ORIGIN_KEY, STRATEGY29_DEFAULT_GATEWAY_ORIGIN); - const value = promptUser('Enter the loopback gateway origin (http://127.0.0.1:)', current); - if (value === null) return; - setValue(STRATEGY29_GATEWAY_ORIGIN_KEY, normalizeStrategy29GatewayOrigin(value)); - restart(); - }); + const menus = [ + { copy: COPY.menuToggle, run() { + enabled = !enabled; + setValue(STRATEGY29_REMOTE_ENABLED_KEY, enabled); + restart(); + } }, + { copy: COPY.menuSecret, run() { + const value = promptUser(formatLocalizedText(COPY.promptSecret, resolveUiLocaleFromPathname(view.location.pathname))); + if (value === null) return; + if (value.length === 0) throw new Error(text(COPY.emptySecret)); + setValue(STRATEGY29_GATEWAY_SECRET_KEY, value); + restart(); + } }, + { copy: COPY.menuOrigin, run() { + const current = getValue(STRATEGY29_GATEWAY_ORIGIN_KEY, STRATEGY29_DEFAULT_GATEWAY_ORIGIN); + const value = promptUser(formatLocalizedText(COPY.promptOrigin, resolveUiLocaleFromPathname(view.location.pathname)), current); + if (value === null) return; + setValue(STRATEGY29_GATEWAY_ORIGIN_KEY, normalizeStrategy29GatewayOrigin(value)); + restart(); + } }, + ]; + for (const menu of menus) menu.id = registerMenuCommand(text(menu.copy), menu.run); + function synchronizeLocale() { + const current = resolveUiLocaleFromPathname(view.location.pathname); + if (current === locale) return; + locale = current; + // Updating menu IDs and rendering text do not retire a client or its cursor. + for (const menu of menus) menu.id = registerMenuCommand(text(menu.copy), menu.run, { id: menu.id }); + active?.panel.setLocale(locale); + } return Object.freeze({ sample, diff --git a/src/binance-strategy29-bollinger/runtime.js b/src/binance-strategy29-bollinger/runtime.js index 89e06c7..dd202af 100644 --- a/src/binance-strategy29-bollinger/runtime.js +++ b/src/binance-strategy29-bollinger/runtime.js @@ -4,9 +4,11 @@ import { isFuturesTradingPathname, parseFuturesTradingSymbolFromPathname } from import { ensureSpaRouteChangePatched, installSpaRouteChangeListener } from '../shared/spa-route-change.js'; import { createStrategy29RemoteSummary } from './remote-summary.js'; +import { SUMMARY_COPY as COPY, formatLocalizedText, resolveUiLocaleFromPathname } from './ui-copy.js'; + const INSTANCE = Symbol.for('jh-userscripts.strategy29-bollinger'); const RUNTIME_VERSION = 2; -const CONFLICT = 'Strategy 29 stopped: update Orderbook to 2.7.199 or disable its embedded Bollinger version, then reload this page.'; +const CONFLICT = COPY.conflict; /** This is a migration refusal, not compatibility with the old independently owned save wrapper. */ export function hasEmbeddedBollinger(view) { @@ -39,7 +41,7 @@ export function installStrategy29(view, remoteAdapters = null) { notice.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:10000;max-width:420px;padding:10px;background:#332b16;color:#ffcf67;font:13px sans-serif;pointer-events:none'; document.body.append(notice); } - notice.textContent = failed; + notice.textContent = formatLocalizedText(failed, resolveUiLocaleFromPathname(view.location.pathname)); } const monitor = createBollingerMonitor({ document, @@ -62,13 +64,14 @@ export function installStrategy29(view, remoteAdapters = null) { showFailure(); } function sample() { - if (disposed || failed || document.hidden) return; + if (disposed || document.hidden) return; + if (failed) { showFailure(); return; } if (hasEmbeddedBollinger(view)) { fail(CONFLICT); return; } ensureSpaRouteChangePatched(view); void remoteSummary?.sample(Date.now()); if (!isFuturesTradingPathname(view.location.pathname)) { monitor.stop(); return; } // Job boundary: unexpected synchronization errors stop this observer only. - void monitor.tick().catch(error => fail(`Strategy 29 stopped: ${error.message}`)); + void monitor.tick().catch(error => fail(COPY.localStopped(error.message))); } function resume() { if (disposed || failed || document.hidden) return; @@ -82,7 +85,7 @@ export function installStrategy29(view, remoteAdapters = null) { get diagnostics() { return { ...monitor.diagnostics, - runtimeFailure: failed, + runtimeFailure: failed === null ? null : formatLocalizedText(failed, 'en'), disposed, timerRunning: timer !== null, remoteSummary: remoteSummary?.diagnostics ?? Object.freeze({ enabled: false, state: 'unavailable_in_this_installation' }), diff --git a/src/binance-strategy29-bollinger/ui-copy.js b/src/binance-strategy29-bollinger/ui-copy.js new file mode 100644 index 0000000..fd04f0f --- /dev/null +++ b/src/binance-strategy29-bollinger/ui-copy.js @@ -0,0 +1,90 @@ +import { localizedText, formatLocalizedText, resolveUiLocaleFromPathname } from '../binance-orderbook-trade/contracts/panel-copy.js'; + +export { formatLocalizedText, resolveUiLocaleFromPathname }; +const pair = localizedText; +export const SUMMARY_COPY = Object.freeze({ + title: pair('Strategy 29 汇总', 'Strategy 29 Summary'), + drag: pair('拖动面板', 'Drag panel'), + collapse: pair('收起', 'Collapse'), + expand: pair('展开', 'Expand'), + waiting: pair('等待中', 'Waiting'), + observerSpec: value => pair(`观察器规格 ${value}`, `Observer spec ${value}`), + reference: value => pair(`本地参考版本 ${value}`, `Local reference ${value}`), + 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'), + 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}`), + statusAt: value => pair(`状态更新 ${value}`, `Status ${value}`), + eventsAt: value => pair(`事件检查 ${value}`, `Events checked ${value}`), + incompatibleSelection: pair('选币不可用:观察器规格不一致', 'Selection unavailable: observer specs are incompatible'), + incompatibleDelivery: pair('全局通知状态不可用:观察器规格不一致', 'Global delivery unavailable: observer specs are incompatible'), + pending: pair('等待中', 'pending'), + generation: value => pair(`批次 ${value}`, `Generation ${value}`), + markets: value => pair(`${value} 个币种`, `${value} markets`), + ready: (ready, total) => pair(`${ready}/${total} 个实时监测单元已就绪`, `${ready}/${total} live units ready`), + intervals: value => pair(`周期 ${value}`, `Intervals ${value}`), + noSuccess: pair('尚无成功选币记录', 'No successful selection has been observed'), + lastSuccess: (clock, age) => pair(`上次成功选币 ${clock} · ${age} 秒前`, `Last successful selection ${clock} · ${age}s ago`), + unavailableSelection: pair('服务端选币不可用', 'Server selection is unavailable'), + awaitingUnits: pair('币种已入选,等待监测单元状态', 'Symbol is selected; waiting for unit status'), + notSelected: pair('当前服务端选币未监听此币种', 'Symbol is not watched by the current server selection'), + delivery: c => pair(`全局通知 · 待发送 ${c.pending} · 发送中 ${c.sending} · 已发送 ${c.sent} · 结果未知 ${c.unknown} · 已过期 ${c.expired} · 失败 ${c.failed}`, `Global delivery · Pending ${c.pending} · Sending ${c.sending} · Sent ${c.sent} · Unknown ${c.unknown} · Expired ${c.expired} · Failed ${c.failed}`), + configuration: pair('尚未配置网关密钥', 'Gateway secret is not configured'), + connecting: pair('正在连接 Strategy 29 网关', 'Connecting to Strategy 29 gateway'), + connected: pair('已连接', 'Connected'), + moreHistory: pair('已连接 · 仍有历史记录待加载', 'Connected · more history pending'), + unavailable: pair('网关数据库暂不可用', 'Gateway database unavailable'), + incompatible: pair('服务端与本地规格不一致', 'Server and local specs are incompatible'), + disconnected: pair('网关连接失败,将在下次定时检查时重试', 'Gateway connection failed; next scheduled poll will retry'), + stopped: detail => pair(`远程汇总已停止。技术详情:${detail}`, `Remote summary stopped: ${detail}`), + menuToggle: pair('切换 Strategy 29 跨周期汇总', 'Toggle Strategy 29 cross-timeframe summary'), + menuSecret: pair('设置 Strategy 29 网关密钥', 'Set Strategy 29 gateway secret'), + menuOrigin: pair('设置 Strategy 29 网关地址', 'Set Strategy 29 gateway origin'), + promptSecret: pair('请输入本地 Strategy 29 网关密钥,仅保存在此用户脚本的私有存储中。', 'Enter the local Strategy 29 gateway secret. It is stored only in this userscript storage.'), + promptOrigin: pair('请输入本机网关地址(http://127.0.0.1:)', 'Enter the loopback gateway origin (http://127.0.0.1:)'), + emptySecret: pair('Strategy 29 网关密钥不能为空', 'Strategy 29 gateway secret cannot be empty'), + localStopped: detail => pair(`Strategy 29 已停止。技术详情:${detail}`, `Strategy 29 stopped: ${detail}`), + conflict: pair('Strategy 29 已停止:请将订单簿脚本更新至 2.7.199 或更高版本,或禁用内嵌布林带观察器的旧版本,然后刷新页面。', 'Strategy 29 stopped: update Orderbook to 2.7.199 or disable its embedded Bollinger version, then reload this page.'), +}); +export const SELECTION_REASONS = Object.freeze({ + current: pair('当前选币有效', 'Selection is current'), + using_stale_selection_after_refresh_error: pair('刷新失败,暂沿用上次选币直至过期', 'Refresh failed; using the previous selection until expiry'), + selection_fail_closed: pair('刷新失败,选币不可用', 'Selection unavailable after refresh failure'), + selection_expired_or_unusable: pair('选币已过期,等待成功刷新', 'Selection expired; waiting for a successful refresh'), + missing_current_universe_facts: pair('等待服务端初始化选币', 'Waiting for server selection to initialize'), + incompatible_current_universe_facts: pair('服务端选币规格不兼容', 'Server selection has an incompatible specification'), +}); +export const STATUS_LABELS = Object.freeze({ + ready: pair('已处理', 'Processed'), warming: pair('预热中', 'Warming'), + stale: pair('已过期', 'Stale'), insufficient_history: pair('历史不足', 'Insufficient history'), + data_gap: pair('数据缺口', 'Data gap'), failed: pair('失败', 'Failed'), +}); +export const SIGNAL_LABELS = Object.freeze({ + 'bearish:warning': pair('看跌预警', 'Bearish warning'), + 'bearish:confirmed': pair('看跌确认', 'Bearish confirmed'), + 'bearish:reversal': pair('多头反转', 'Long reversal'), + 'bullish:warning': pair('看涨预警', 'Bullish warning'), + 'bullish:confirmed': pair('看涨确认', 'Bullish confirmed'), + 'bullish:reversal': pair('空头反转', 'Short reversal'), +}); +const PROCESSING_REASONS = Object.freeze({ + current: pair('进度已更新', 'Progress current'), + awaiting_producer_generation: pair('等待当前批次行情就绪', 'Awaiting producer generation'), + awaiting_initial_baseline: pair('等待建立初始历史基线', 'Awaiting initial baseline'), + awaiting_reentry_baseline: pair('等待重新入选的历史基线', 'Awaiting re-entry baseline'), + latest_closed_candle_missing: pair('缺少最新已收盘 K 线', 'Latest closed candle missing'), + observer_progress_stale: pair('观察器进度已过期', 'Observer progress stale'), +}); +/** Server reasons also carry arbitrary diagnostic details, which remain verbatim. */ +export function processingReason(reason, locale) { + if (Object.hasOwn(PROCESSING_REASONS, reason)) return formatLocalizedText(PROCESSING_REASONS[reason], locale); + const required = /^requires_(\d+)_closed_candles$/.exec(reason); + if (required) return formatLocalizedText(pair(`需要 ${required[1]} 根已收盘 K 线`, `Requires ${required[1]} closed candles`), locale); + return formatLocalizedText(pair(`技术详情:${reason}`, `Details: ${reason}`), locale); +} diff --git a/test/dom/binance-strategy29-bollinger/runtime.test.js b/test/dom/binance-strategy29-bollinger/runtime.test.js index fa2d6e2..be9e370 100644 --- a/test/dom/binance-strategy29-bollinger/runtime.test.js +++ b/test/dom/binance-strategy29-bollinger/runtime.test.js @@ -67,7 +67,7 @@ for (const [name, remoteResponse, expectedState] of [ ['HTTP 401', { status: 401, responseText: JSON.stringify({ schema_version: 1, error: 'unauthorized' }) }, 'stopped'], ['HTTP 503', { status: 503, responseText: JSON.stringify({ schema_version: 1, error: 'database_unavailable' }) }, 'unavailable'], ['invalid JSON', { status: 200, responseText: '' }, 'stopped'], - ['spec mismatch', { status: 200, responseText: JSON.stringify({ ...gatewayStatus, spec_version: '29_2_spec_v2' }) }, 'incompatible'], + ['spec mismatch', { status: 200, responseText: JSON.stringify({ ...gatewayStatus, spec_version: 'other_spec' }) }, 'incompatible'], ]) { test(`${name} remains a remote-only state while the local timer continues`, async () => { const f = fixture(); 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 18cf4d4..682a41d 100644 --- a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js +++ b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js @@ -3,11 +3,76 @@ import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { JSDOM } from 'jsdom'; -import { createStrategy29SummaryPanel } from '../../../src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js'; +import { createStrategy29SummaryPanel as createPanel } from '../../../src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js'; + +function createStrategy29SummaryPanel(document, symbol, options = {}) { + let position = null; + return createPanel(document, symbol, { locale: 'en', loadPosition: () => position, savePosition: value => { position = value; }, ...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('distinguishes stored processing success from current live readiness', () => { + const dom = new JSDOM(''); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT'); + 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.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]; + assert.equal(processing.textContent, 'Processed'); + assert.equal(processing.style.color, 'rgb(132, 142, 156)'); + panel.destroy(); + dom.window.close(); +}); + +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 }); + panel.addEvents(events.events); + panel.renderStatus({ ...status, units: [] }); + assert.match(dom.window.document.body.textContent, /Symbol is selected; waiting for unit status/); + assert.doesNotMatch(dom.window.document.body.textContent, /Symbol is not watched/); + panel.renderStatus({ ...status, universe: { + ...status.universe, refresh_status: 'fail_closed', reason: 'selection_expired_or_unusable', + selected_markets: [], selected_unit_count: 0, ready_unit_count: 0, pending_unit_count: 0, + } }); + assert.match(dom.window.document.body.textContent, /Selection expired/); + assert.match(dom.window.document.body.textContent, /Server selection is unavailable/); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 0); + assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 2); + panel.renderStatus({ ...status, universe: { ...status.universe, refresh_status: 'stale_if_error', reason: 'using_stale_selection_after_refresh_error' } }); + assert.match(dom.window.document.body.textContent, /Refresh failed; using the previous selection until expiry/); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 2); + panel.destroy(); + dom.window.close(); +}); + +test('replaces dynamic membership while retaining durable signal history', () => { + const dom = new JSDOM(''); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 8 }); + 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); + 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); + 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); + assert.match(units[0].textContent, /4h.*Warming.*Awaiting producer generation/); + assert.doesNotMatch(dom.window.document.body.textContent, /Symbol is not watched/); + panel.destroy(); + dom.window.close(); +}); + 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 }); @@ -49,9 +114,16 @@ 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 }); - panel.renderStatus({ ...status, spec_version: '29_2_spec_v2' }); + 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 }); assert.match(dom.window.document.body.textContent, /Spec mismatch/); assert.equal(dom.window.document.querySelector('[data-role=spec]').dataset.state, 'error'); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 0); + assert.equal(dom.window.document.querySelectorAll('[data-role=remote-event]').length, 2); + assert.match(dom.window.document.querySelector('[data-role=selection]').textContent, /incompatible/); + panel.renderStatus(status); + assert.equal(dom.window.document.querySelectorAll('[data-role=unit]').length, 2); panel.destroy(); dom.window.close(); }); diff --git a/test/dom/binance-strategy29-bollinger/summary-locale-position.test.js b/test/dom/binance-strategy29-bollinger/summary-locale-position.test.js new file mode 100644 index 0000000..f9f7100 --- /dev/null +++ b/test/dom/binance-strategy29-bollinger/summary-locale-position.test.js @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import { JSDOM } from 'jsdom'; +import { SUMMARY_COPY } from '../../../src/binance-strategy29-bollinger/ui-copy.js'; +import { createStrategy29SummaryPanel } from '../../../src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js'; + +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))); + +function fixture(locale = 'zh-CN', stored = { left: 100, top: 120 }) { + const dom = new JSDOM('', { url: `https://www.binance.com/${locale}/futures/BTCUSDT` }); + const saves = []; + const controller = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { + locale, loadPosition: () => stored, savePosition: value => saves.push(value), + }); + const panel = dom.window.document.getElementById('jh-strategy29-summary-panel'); + const close = () => { controller.destroy(); dom.window.close(); }; + return { dom, controller, panel, saves, close }; +} + +test('Chinese panel translates retained status and signals and switches to English without losing rows', () => { + const f = fixture(); + try { + f.controller.renderStatus(status); + f.controller.addEvents(events.events, events.observed_at_ms); + assert.match(f.panel.textContent, /Strategy 29 汇总/); + assert.match(f.panel.textContent, /最近处理状态/); + assert.match(f.panel.textContent, /看跌预警/); + assert.match(f.panel.textContent, /全局通知/); + assert.doesNotMatch(f.panel.textContent, /Global delivery|Last processing status|Bearish warning/); + const ids = [...f.panel.querySelectorAll('[data-role=remote-event]')].map(e => e.dataset.eventId); + f.controller.setLocale('en'); + assert.match(f.panel.textContent, /Strategy 29 Summary/); + assert.match(f.panel.textContent, /Last processing status/); + assert.match(f.panel.textContent, /Bearish warning/); + assert.deepEqual([...f.panel.querySelectorAll('[data-role=remote-event]')].map(e => e.dataset.eventId), ids); + assert.equal(f.panel.querySelectorAll('[data-role=unit]').length, 2); + f.controller.setLocale('zh-CN'); + assert.match(f.panel.textContent, /看跌预警/); + assert.equal(f.controller.size, 2); + } finally { f.close(); } +}); + +test('header drag restores, clamps and saves position while buttons and destroyed panels never drag', () => { + const f = fixture(); + try { + Object.defineProperties(f.dom.window, { innerWidth: { value: 500, configurable: true }, innerHeight: { value: 400, configurable: true } }); + f.panel.getBoundingClientRect = () => ({ left: parseFloat(f.panel.style.left), top: parseFloat(f.panel.style.top), width: 340, height: 200 }); + const header = f.panel.querySelector('header'); + const fire = (node, type, x, y, button = 0) => node.dispatchEvent(new f.dom.window.MouseEvent(type, { bubbles: true, clientX: x, clientY: y, button })); + assert.equal(f.panel.style.left, '100px'); + assert.equal(f.panel.style.top, '120px'); + fire(header, 'mousedown', 120, 130); + fire(f.dom.window.document, 'mousemove', 700, 600); + fire(f.dom.window.document, 'mouseup', 700, 600); + assert.equal(f.panel.style.left, '160px'); + assert.equal(f.panel.style.top, '200px'); + assert.deepEqual(f.saves, [{ left: 160, top: 200 }]); + fire(f.panel.querySelector('[data-role=collapse]'), 'mousedown', 170, 210); + fire(f.dom.window.document, 'mousemove', 0, 0); + fire(f.dom.window.document, 'mouseup', 0, 0); + assert.equal(f.saves.length, 1); + fire(header, 'mousedown', 170, 210); + f.controller.destroy(); + fire(f.dom.window.document, 'mousemove', 0, 0); + fire(f.dom.window.document, 'mouseup', 0, 0); + assert.equal(f.saves.length, 1); + } finally { f.close(); } +}); + +test('resize and content growth keep the panel visible, including collapse/expand and locale rerender', () => { + const f = fixture('en', { left: 900, top: 600 }); + try { + let height = 200; + f.panel.getBoundingClientRect = () => ({ left: parseFloat(f.panel.style.left), top: parseFloat(f.panel.style.top), width: 340, height: f.panel.querySelector('[data-role=body]').style.display === 'none' ? 40 : height }); + Object.defineProperties(f.dom.window, { innerWidth: { value: 500, configurable: true }, innerHeight: { value: 400, configurable: true } }); + f.dom.window.dispatchEvent(new f.dom.window.Event('resize')); + assert.equal(f.panel.style.left, '160px'); + assert.equal(f.panel.style.top, '200px'); + height = 300; + f.controller.renderStatus(status); + assert.equal(f.panel.style.top, '100px'); + const collapse = f.panel.querySelector('[data-role=collapse]'); + collapse.click(); + assert.equal(collapse.textContent, 'Expand'); + f.controller.setLocale('zh-CN'); + assert.equal(collapse.textContent, '展开'); + assert.equal(f.panel.querySelector('[data-role=body]').style.display, 'none'); + collapse.click(); + assert.equal(f.panel.style.top, '100px'); + assert.equal(collapse.textContent, '收起'); + assert.deepEqual(f.saves, []); + } finally { f.close(); } +}); + +test('invalid persisted positions fail explicitly before installing a panel', () => { + const dom = new JSDOM(''); + try { + assert.throws(() => createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { + loadPosition: () => ({ left: 'bad', top: 10 }), savePosition() {}, + }), /position is invalid/); + assert.equal(dom.window.document.getElementById('jh-strategy29-summary-panel'), null); + } finally { dom.window.close(); } +}); + + +test('configuration and error states rerender in the chosen language without clearing diagnostics', () => { + const f = fixture('en'); + try { + f.controller.setConnection('configuration_required', SUMMARY_COPY.configuration); + assert.equal(f.panel.querySelector('[data-role=connection]').textContent, 'Gateway secret is not configured'); + f.controller.setLocale('zh-CN'); + assert.equal(f.panel.querySelector('[data-role=connection]').textContent, '尚未配置网关密钥'); + f.controller.setConnection('stopped', SUMMARY_COPY.stopped('HTTP 401')); + assert.equal(f.panel.querySelector('[data-role=connection]').textContent, '远程汇总已停止。技术详情:HTTP 401'); + f.controller.setLocale('en'); + assert.equal(f.panel.querySelector('[data-role=connection]').textContent, 'Remote summary stopped: HTTP 401'); + assert.equal(f.panel.querySelector('[data-role=connection]').dataset.state, 'stopped'); + } finally { f.close(); } +}); diff --git a/test/fixtures/strategy29-gateway-events.json b/test/fixtures/strategy29-gateway-events.json index fa66234..bd06834 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_v1", + "spec_version": "29_2_spec_v2", "observed_at_ms": 1788580805000, "next_cursor": 42, "has_more": false, @@ -10,7 +10,7 @@ "event_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "schema_version": 1, "strategy_id": "29", - "spec_version": "29_2_spec_v1", + "spec_version": "29_2_spec_v2", "symbol": "BTC/USDT:USDT", "timeframe": "1m", "setup_direction": "bearish", @@ -34,7 +34,7 @@ "event_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "schema_version": 1, "strategy_id": "29", - "spec_version": "29_2_spec_v1", + "spec_version": "29_2_spec_v2", "symbol": "BTC/USDT:USDT", "timeframe": "1h", "setup_direction": "bullish", diff --git a/test/fixtures/strategy29-gateway-status.json b/test/fixtures/strategy29-gateway-status.json index a443445..d90fe16 100644 --- a/test/fixtures/strategy29-gateway-status.json +++ b/test/fixtures/strategy29-gateway-status.json @@ -1,7 +1,23 @@ { "schema_version": 1, - "spec_version": "29_2_spec_v1", + "spec_version": "29_2_spec_v2", "observed_at_ms": 1788580805000, + "universe": { + "source_monitor": "monitor29_bollinger_ma60", + "generation": 1, + "refresh_status": "fresh", + "reason": "current", + "selected_markets": ["BTC/USDT:USDT", "ETH/USDT:USDT"], + "configured_timeframes": ["1m", "5m", "1h"], + "selected_unit_count": 3, + "ready_unit_count": 1, + "pending_unit_count": 2, + "refreshed_at_ms": 1788580800000, + "last_successful_refreshed_at_ms": 1788580800000, + "last_success_age_seconds": 5, + "last_refresh_error_at_ms": null, + "selection_expires_at_ms": 1788583800000 + }, "units": [ { "symbol": "BTC/USDT:USDT", diff --git a/test/manual/strategy29-summary-panel-preview.mjs b/test/manual/strategy29-summary-panel-preview.mjs index 16da628..8203d7b 100644 --- a/test/manual/strategy29-summary-panel-preview.mjs +++ b/test/manual/strategy29-summary-panel-preview.mjs @@ -35,8 +35,10 @@ try { `); await page.evaluate(async ({ sourceUrl, status, events }) => { const { createStrategy29SummaryPanel } = await import(sourceUrl); - const panel = createStrategy29SummaryPanel(document, 'BTC/USDT:USDT', { maxEvents: 8 }); - panel.setConnection('connected', 'Connected'); + const panel = createStrategy29SummaryPanel(document, 'BTC/USDT:USDT', { + maxEvents: 8, locale: 'en', loadPosition: () => null, savePosition: value => { window.fixturePosition = value; }, + }); + panel.setConnection('connected', { zhCN: '已连接', en: 'Connected' }); panel.renderStatus(status); panel.addEvents(events.events, events.observed_at_ms); window.fixturePanel = panel; @@ -54,8 +56,35 @@ try { const box = await panel.boundingBox(); assert.equal(box.width, 340); assert.equal(box.x >= 0 && box.y >= 0 && box.y + box.height <= 900, true); + await page.evaluate(() => window.fixturePanel.setLocale('zh-CN')); + assert.match(await panel.textContent(), /Strategy 29 汇总/); + assert.match(await panel.textContent(), /看跌预警/); + assert.match(await panel.locator('[data-role="connection"]').textContent(), /已连接/); + await panel.screenshot({ path: join(output, 'chinese.png') }); + const beforeDrag = await panel.boundingBox(); + const header = await panel.locator('header').boundingBox(); + await page.mouse.move(header.x + 24, header.y + 14); + await page.mouse.down(); + await page.mouse.move(224, 114, { steps: 5 }); + await page.mouse.up(); + const moved = await panel.boundingBox(); + assert.equal(Math.round(moved.x), Math.round(beforeDrag.x + 224 - header.x - 24)); + assert.equal(Math.round(moved.y), Math.round(beforeDrag.y + 114 - header.y - 14)); + assert.deepEqual(await page.evaluate(() => window.fixturePosition), { left: moved.x, top: moved.y }); + await page.evaluate(() => { + window.fixtureResizeDelivered = false; + window.addEventListener('resize', () => { window.fixtureResizeDelivered = true; }, { once: true }); + }); + await page.setViewportSize({ width: 390, height: 600 }); + await page.waitForFunction(() => window.fixtureResizeDelivered, null, { timeout: 5000 }); + const narrow = await panel.boundingBox(); + assert.equal(narrow.x >= 0 && narrow.y >= 0 && narrow.x + narrow.width <= 390 && narrow.y + narrow.height <= 600, true, JSON.stringify({ narrow, viewport: await page.evaluate(() => ({ width: innerWidth, height: innerHeight, left: document.getElementById('jh-strategy29-summary-panel').style.left, top: document.getElementById('jh-strategy29-summary-panel').style.top })) })); + 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.deepEqual(errors, []); - console.log(JSON.stringify({ output, checked: ['two-timeframe-status', 'two-events', 'global-delivery-label', 'separate-freshness', 'collapse', 'viewport'], pageErrors: 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 })); } 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 34c1457..524e89a 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js @@ -228,12 +228,13 @@ test('reports unavailable status separately from fatal HTTP and contract errors' }); test('exposes remote/local spec mismatch before requesting event history', async () => { - const mismatch = { ...status, spec_version: '29_2_spec_v2' }; + const mismatch = { ...status, spec_version: 'other_spec' }; + delete mismatch.universe; const fixture = clientFixture([response(mismatch)]); assert.deepEqual( await fixture.client.poll(new AbortController().signal), { state: 'incompatible', pages: 0, hasMore: false }, ); assert.equal(fixture.requests.length, 1); - assert.equal(fixture.snapshots[0].spec_version, '29_2_spec_v2'); + assert.equal(fixture.snapshots[0].spec_version, 'other_spec'); }); 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 fd91bc0..1ef8610 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-contract.test.js @@ -14,6 +14,91 @@ 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 METADATA_KEYS = ['generation', 'refreshed_at_ms', 'last_successful_refreshed_at_ms', 'last_success_age_seconds', 'last_refresh_error_at_ms', 'selection_expires_at_ms']; +const SUCCESS_KEYS = ['last_successful_refreshed_at_ms', 'last_success_age_seconds', 'selection_expires_at_ms']; + +function universeForReason(reason) { + const universe = { ...status.universe, reason }; + universe.refresh_status = reason === 'current' ? 'fresh' + : reason === 'using_stale_selection_after_refresh_error' ? 'stale_if_error' : 'fail_closed'; + if (universe.refresh_status === 'fail_closed') Object.assign(universe, { + selected_markets: [], selected_unit_count: 0, ready_unit_count: 0, pending_unit_count: 0, + }); + if (reason === 'selection_fail_closed' || universe.refresh_status === 'stale_if_error') { + universe.last_refresh_error_at_ms = status.observed_at_ms; + } + if (reason === 'missing_current_universe_facts' || reason === 'incompatible_current_universe_facts') { + for (const key of METADATA_KEYS) universe[key] = null; + universe.configured_timeframes = []; + } + return universe; +} + +test('requires metadata belonging to the reported refresh state', () => { + const required = { + current: ['generation', 'refreshed_at_ms', ...SUCCESS_KEYS], + using_stale_selection_after_refresh_error: METADATA_KEYS, + selection_fail_closed: ['generation', 'refreshed_at_ms', 'last_refresh_error_at_ms'], + selection_expired_or_unusable: ['generation', 'refreshed_at_ms', ...SUCCESS_KEYS], + }; + for (const [reason, keys] of Object.entries(required)) { + const universe = universeForReason(reason); + const candidate = { ...status, universe }; + assert.equal(validateStrategy29StatusResponse(candidate, 200), candidate); + for (const key of keys) assert.throws(() => validateStrategy29StatusResponse({ + ...status, universe: { ...universe, [key]: null }, + }, 200), /universe/, `${reason} requires ${key}`); + } + const freshWithoutSuccess = universeForReason('current'); + for (const key of SUCCESS_KEYS) freshWithoutSuccess[key] = null; + assert.throws(() => validateStrategy29StatusResponse({ ...status, universe: freshWithoutSuccess }, 200), /universe/); + const failedWithoutSuccess = universeForReason('selection_fail_closed'); + for (const key of SUCCESS_KEYS) failedWithoutSuccess[key] = null; + const failed = { ...status, universe: failedWithoutSuccess }; + assert.equal(validateStrategy29StatusResponse(failed, 200), failed); + for (const key of SUCCESS_KEYS) assert.throws(() => validateStrategy29StatusResponse({ + ...failed, universe: { ...failedWithoutSuccess, [key]: status.universe[key] }, + }, 200), /universe/); + for (const reason of ['missing_current_universe_facts', 'incompatible_current_universe_facts']) { + const universe = universeForReason(reason); + const candidate = { ...status, universe }; + assert.equal(validateStrategy29StatusResponse(candidate, 200), candidate); + for (const key of METADATA_KEYS) assert.throws(() => validateStrategy29StatusResponse({ + ...status, universe: { ...universe, [key]: 1 }, + }, 200), /universe/); + } + assert.throws(() => validateStrategy29StatusResponse({ ...status, universe: { + ...status.universe, last_refresh_error_at_ms: status.observed_at_ms, + } }, 200), /universe/); + const expiredStale = { ...status, universe: { + ...universeForReason('selection_expired_or_unusable'), last_refresh_error_at_ms: status.observed_at_ms, + } }; + assert.equal(validateStrategy29StatusResponse(expiredStale, 200), expiredStale); + const clockBack = { ...status, observed_at_ms: status.universe.refreshed_at_ms - 1000, + universe: { ...status.universe, last_success_age_seconds: 0 } }; + assert.equal(validateStrategy29StatusResponse(clockBack, 200), clockBack); +}); + +test('accepts stored ready processing while current live admission is pending', () => { + const candidate = { ...status, universe: { + ...status.universe, ready_unit_count: 0, pending_unit_count: status.universe.selected_unit_count, + } }; + assert.equal(candidate.units[0].status, 'ready'); + assert.equal(validateStrategy29StatusResponse(candidate, 200), candidate); +}); + +test('rejects malformed or contradictory universe status fields', () => { + for (const change of [ + { generation: '1' }, { refresh_status: 'unknown' }, { unexpected: true }, + { selected_markets: ['BTC'] }, { configured_timeframes: ['1s'] }, + { ready_unit_count: 2 }, { selected_unit_count: 129 }, + { last_success_age_seconds: -1 }, { last_successful_refreshed_at_ms: null }, + { refresh_status: 'fail_closed' }, + ]) { + assert.throws(() => validateStrategy29StatusResponse({ ...status, universe: { ...status.universe, ...change } }, 200), /universe/); + } +}); + test('canonical Strategy29 symbols round-trip without server-side normalization', () => { assert.equal(routeSymbolToCanonical('BTRUSDT'), 'BTR/USDT:USDT'); assert.equal(canonicalSymbolToRoute('BTR/USDT:USDT'), 'BTRUSDT'); @@ -22,12 +107,39 @@ test('canonical Strategy29 symbols round-trip without server-side normalization' assert.throws(() => canonicalSymbolToRoute('BTR/USDT'), /canonical symbol/); }); +test('accepts only coherent universe refresh state and reason combinations', () => { + const reasons = { + fresh: ['current'], + stale_if_error: ['using_stale_selection_after_refresh_error'], + fail_closed: ['selection_fail_closed', 'selection_expired_or_unusable', 'missing_current_universe_facts', 'incompatible_current_universe_facts'], + }; + for (const refreshState of Object.keys(reasons)) { + for (const reason of Object.values(reasons).flat()) { + const universe = { ...universeForReason(reason), refresh_status: refreshState }; + const candidate = { ...status, universe }; + if (reasons[refreshState].includes(reason)) assert.equal(validateStrategy29StatusResponse(candidate, 200), candidate); + else assert.throws(() => validateStrategy29StatusResponse(candidate, 200), /universe/); + } + } +}); + +test('projects only validated identity when status belongs to an incompatible spec', () => { + const identity = { schema_version: 1, spec_version: 'other_spec', observed_at_ms: status.observed_at_ms }; + const incompatible = { ...identity, get units() { throw new Error('incompatible payload must not be read'); } }; + assert.deepEqual(validateStrategy29StatusResponse(incompatible, 200), identity); + for (const invalid of [{ schema_version: 2 }, { spec_version: '' }, { observed_at_ms: '1' }]) { + assert.throws(() => validateStrategy29StatusResponse({ ...identity, ...invalid }, 200), /status/); + } +}); + test('validates exact status fields while preserving visible spec mismatch', () => { - assert.equal(STRATEGY29_SPEC_VERSION, '29_2_spec_v1'); + assert.equal(STRATEGY29_SPEC_VERSION, '29_2_spec_v2'); assert.equal(validateStrategy29StatusResponse(status, 200), status); const mismatch = structuredClone(status); - mismatch.spec_version = '29_2_spec_v2'; - assert.equal(validateStrategy29StatusResponse(mismatch, 200), mismatch); + mismatch.spec_version = 'other_spec'; + assert.deepEqual(validateStrategy29StatusResponse(mismatch, 200), { + schema_version: mismatch.schema_version, spec_version: mismatch.spec_version, observed_at_ms: mismatch.observed_at_ms, + }); assert.throws( () => validateStrategy29StatusResponse({ ...status, unexpected: true }, 200), /exact keys/, diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js index eddc5e5..d61e0e8 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js @@ -5,6 +5,7 @@ import { STRATEGY29_REMOTE_ENABLED_KEY, createStrategy29RemoteSummary, } from '../../../src/binance-strategy29-bollinger/remote-summary.js'; +import { formatLocalizedText } from '../../../src/binance-strategy29-bollinger/ui-copy.js'; import { Strategy29GatewayTransportError } from '../../../src/binance-strategy29-bollinger/core/remote-summary-client.js'; function fixture({ enabled = true, authSecret = 'synthetic-secret', poll } = {}) { @@ -27,14 +28,21 @@ function fixture({ enabled = true, authSecret = 'synthetic-secret', poll } = {}) request: async () => { throw new Error('unexpected raw request'); }, getValue: (key, fallback) => values.has(key) ? values.get(key) : fallback, setValue: (key, value) => values.set(key, value), - registerMenuCommand: (label, callback) => menus.push({ label, callback }), + registerMenuCommand: (label, callback, options) => { + const id = options?.id ?? menus.length; + menus[id] = { label, callback }; + return id; + }, promptUser: (...args) => { prompts.push(args); return null; }, - createPanel: (_document, canonicalSymbol) => { + createPanel: (_document, canonicalSymbol, options) => { const calls = []; const panel = { canonicalSymbol, + options, + locale: options.locale, calls, - setConnection: (...args) => calls.push(['connection', ...args]), + setLocale: locale => { panel.locale = locale; calls.push(['locale', locale]); }, + setConnection: (state, message) => calls.push(['connection', state, formatLocalizedText(message, panel.locale)]), renderStatus: (...args) => calls.push(['status', ...args]), addEvents: (...args) => calls.push(['events', ...args]), clearEvents: (...args) => calls.push(['clear', ...args]), @@ -173,3 +181,58 @@ test('unsupported futures route is classified once without a retry/log loop', as assert.equal(f.panels.length, 0); assert.equal(f.clients.length, 0); }); + +test('locale switches preserve the pending request, client cursor and panel while updating existing menu IDs', async () => { + let complete; + let requestSignal; + const f = fixture({ poll: signal => { requestSignal = signal; return new Promise(resolve => { complete = resolve; }); } }); + const pending = f.summary.sample(0); + f.clients[0].diagnostics.cursor = 41; + f.view.location.pathname = '/zh-CN/futures/BTRUSDT'; + assert.equal(f.summary.sample(1), undefined); + assert.equal(f.clients.length, 1); + assert.equal(f.panels.length, 1); + assert.equal(f.panels[0].locale, 'zh-CN'); + assert.equal(f.summary.diagnostics.cursor, 41); + assert.equal(requestSignal.aborted, false); + assert.equal(f.menus.length, 3); + assert.deepEqual(f.menus.map(menu => menu.label), ['切换 Strategy 29 跨周期汇总', '设置 Strategy 29 网关密钥', '设置 Strategy 29 网关地址']); + complete({ state: 'connected', pages: 1, hasMore: false }); + await pending; + assert.deepEqual(f.panels[0].calls.at(-1), ['connection', 'connected', '已连接']); + f.view.location.pathname = '/en/futures/BTRUSDT'; + f.summary.sample(2); + assert.equal(f.panels[0].locale, 'en'); + assert.equal(f.summary.diagnostics.cursor, 41); + assert.equal(f.clients.length, 1); + assert.equal(f.menus.length, 3); + assert.equal(f.menus[0].label, 'Toggle Strategy 29 cross-timeframe summary'); + f.summary.dispose(); +}); + +test('no-auth locale change updates the existing panel and localized prompts without creating a client', () => { + const f = fixture({ authSecret: '' }); + f.summary.sample(0); + f.view.location.pathname = '/zh-CN/futures/BTRUSDT'; + f.summary.sample(1); + assert.equal(f.panels.length, 1); + assert.equal(f.panels[0].locale, 'zh-CN'); + assert.equal(f.clients.length, 0); + assert.equal(f.summary.diagnostics.state, 'configuration_required'); + f.menus[1].callback(); + assert.match(f.prompts[0][0], /请输入本地 Strategy 29 网关密钥/); + f.summary.dispose(); +}); + +test('position adapters persist only the dedicated coordinate value and restore it across symbol contexts', async () => { + const f = fixture(); + await f.summary.sample(0); + assert.equal(f.panels[0].options.loadPosition(), null); + f.panels[0].options.savePosition({ left: 72, top: 124 }); + assert.deepEqual(f.values.get('strategy29SummaryPanelPosition'), { left: 72, top: 124 }); + f.view.location.pathname = '/en/futures/ETHUSDT'; + await f.summary.sample(1); + assert.deepEqual(f.panels[1].options.loadPosition(), { left: 72, top: 124 }); + assert.equal(f.values.get('strategy29GatewayAuthSecret'), 'synthetic-secret'); + f.summary.dispose(); +}); diff --git a/test/unit/binance-strategy29-bollinger/source-regressions.test.js b/test/unit/binance-strategy29-bollinger/source-regressions.test.js index 1597196..2c0df8a 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.2.2'); + assert.equal(readUserscriptVersion(entrySource), '0.3.0'); assert.deepEqual( [...entrySource.matchAll(/^\/\/ @grant\s+(\S+)\s*$/gm)].map(match => match[1]), ['unsafeWindow', 'GM_xmlhttpRequest', 'GM_getValue', 'GM_setValue', 'GM_registerMenuCommand'], diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index 8784ae5..854fd66 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.2.2'); + assert.equal(contract.version, '0.3.0'); assert.equal(contract.runAt, 'document-start'); assert.equal(contract.updateURL, 'https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js'); assert.equal(contract.downloadURL, contract.updateURL);