diff --git a/docs/binance-strategy27-events-development.md b/docs/binance-strategy27-events-development.md index 96efa86..4ff3a24 100644 --- a/docs/binance-strategy27-events-development.md +++ b/docs/binance-strategy27-events-development.md @@ -88,13 +88,26 @@ discontinuities abort the request and remove only those transient entities. Marker count and age are bounded on the chart; the panel retains at most eight events. +The existing one-second context check also reconciles retained records with +TradingView's `getAllShapes()` list. A host-evicted ordinary marker is restored +using its original resolved point and drawing options, even when no new gateway +message arrives. Compound candidates restore only missing parts of their +icon/label pair, preserving the original slot and surviving entity IDs. Each +record shares one in-flight repair across timer and message callbacks. Cleanup +skips IDs proven absent, while native removal failures still stop the owning job. +Clear, reset, context changes and retention eviction invalidate repair ownership; +late-created entities are removed instead of resurrecting retired records. +Reconciliation does not refresh retention timestamps or persist drawings across +reloads. A panel history reset is a separate lifecycle event, not evidence of +native entity eviction. + ## Compound Candidate Extension ADR 032 in CorsairQuant owns the server-side rule and transport contract. The browser does not reconstruct candidates from ordinary events or recalculate market evidence. The client, lifecycle, panel, native chart layer and optional-job controller are wired into the entrypoint and tested together. The source and -generated install artifact are version 0.4.0 with identical metadata headers. +generated install artifact are version 0.4.1 with identical metadata headers. The generated artifact passes syntax, release-contract and isolated execution checks, including candidate delivery, paired entities, clear and context stop. Binance operator-page validation remains outstanding. Server/gateway rollout @@ -136,7 +149,8 @@ Do not treat source unit tests or the panel fixture as deployment evidence. Manual clear preserves replay bookkeeping but invalidates pending presentation. Age eviction also invalidates a pending draw, and a second age check runs after drawing before publication to the panel. The existing context timer calls - `prune()`; there is no second timer. Route/interval changes and disappearance + `reconcile()`, which prunes before repairing missing entities; there is no + second timer. Route/interval changes and disappearance of the visible chart stop both clients before destroying the shared panel. The clear menu clears both views without restarting either client. - Native cleanup attempts every owned entity once and aggregates failures. diff --git a/scripts/binance-strategy27-events.user.js b/scripts/binance-strategy27-events.user.js index a2f8219..312ed35 100644 --- a/scripts/binance-strategy27-events.user.js +++ b/scripts/binance-strategy27-events.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.4.0 +// @version 0.4.1 // @author jackhai9 // @description 在 Binance 一秒图表标注 VPS Strategy 27 的实时订单流候选观察 // @match https://www.binance.com/*/futures/* @@ -889,10 +889,23 @@ return String(value || "").split("@", 1)[0]; } function assertChartContract(chart) { - for (const method of ["createShape", "getShapeById", "removeEntity", "resolution", "symbol"]) { + for (const method of ["createShape", "getAllShapes", "getShapeById", "removeEntity", "resolution", "symbol"]) { if (typeof chart?.[method] !== "function") throw new Error(`TradingView chart method is unavailable: ${method}`); } } + function readLiveShapeIds(chart) { + const shapes = chart.getAllShapes(); + if (!Array.isArray(shapes)) throw new Error("Strategy 27 chart shape list is invalid"); + return new Set(shapes.map((shape) => { + if (typeof shape?.id !== "string" || shape.id.length === 0) throw new Error("Strategy 27 chart shape id is invalid"); + return shape.id; + })); + } + function pinMarkerChartContext(chart) { + const symbol = chart.symbol(); + const resolution = chart.resolution(); + return () => chart.symbol() === symbol && chart.resolution() === resolution; + } function findStrategy27ChartTarget(document, expectedRouteSymbol) { const chartRoot = findStrategy27ChartRoot(document); if (!chartRoot) return null; @@ -1096,14 +1109,17 @@ if (!Number.isInteger(maxAgeMs) || maxAgeMs < 1) throw new Error("Strategy 27 maxAgeMs is invalid"); const { chart } = target; const placement = createTradingViewMarkerPlacement(chart, { candleWaitMs }); + const isChartCurrent = pinMarkerChartContext(chart); const registry = /* @__PURE__ */ new Map(); - const pendingCandleWaits = /* @__PURE__ */ new Set(); + const pendingRenders = /* @__PURE__ */ new Map(); let renderGeneration = 0; + let reconciliation = null; function removeRecord(eventId) { + pendingRenders.get(eventId)?.abort(); const record = registry.get(eventId); if (!record) return; - chart.removeEntity(record.markerId); registry.delete(eventId); + if (readLiveShapeIds(chart).has(record.markerId)) chart.removeEntity(record.markerId); } function pruneAge(observedAtMs) { for (const [eventId, record] of registry) { @@ -1113,37 +1129,66 @@ function ensureCapacityForNew() { while (registry.size >= maxEvents) removeRecord(registry.keys().next().value); } + function restoreMarker(eventId, record, liveIds) { + if (record.restoring) return record.restoring; + const current = () => registry.get(eventId) === record && isChartCurrent(); + if (!current()) return Promise.resolve(false); + if (liveIds.has(record.markerId)) return Promise.resolve(true); + record.restoring = (async () => { + const markerId = await createAlignedShape(chart, record.markerPoint, record.options); + if (!current()) { + if (readLiveShapeIds(chart).has(markerId)) chart.removeEntity(markerId); + return false; + } + record.markerId = markerId; + return true; + })().finally(() => { + record.restoring = null; + }); + return record.restoring; + } + function reconcile() { + if (reconciliation) return reconciliation; + reconciliation = (async () => { + let liveIds = readLiveShapeIds(chart); + for (const [eventId, record] of [...registry]) { + if (registry.get(eventId) !== record || !isChartCurrent()) continue; + if (!record.restoring && liveIds.has(record.markerId)) continue; + await restoreMarker(eventId, record, liveIds); + liveIds = readLiveShapeIds(chart); + } + })().finally(() => { + reconciliation = null; + }); + return reconciliation; + } async function ensureMarker(eventId, annotation, observedAtMs) { let record = registry.get(eventId); if (record) { record.observedAtMs = observedAtMs; - return true; + return restoreMarker(eventId, record, readLiveShapeIds(chart)); } if (annotation.markerShape === null) return true; const requestedGeneration = renderGeneration; const controller = new AbortController(); - pendingCandleWaits.add(controller); - let markerPoint; + pendingRenders.set(eventId, controller); try { - markerPoint = await placement.wait(annotation, { signal: controller.signal }); + const markerPoint = await placement.wait(annotation, { signal: controller.signal }); + if (!markerPoint || controller.signal.aborted || requestedGeneration !== renderGeneration || !isChartCurrent()) return false; + pruneAge(observedAtMs); + ensureCapacityForNew(); + const options = shapeOptions(annotation.markerShape, annotation.markerColor); + const markerId = await createAlignedShape(chart, markerPoint, options); + if (controller.signal.aborted || requestedGeneration !== renderGeneration || !isChartCurrent()) { + if (readLiveShapeIds(chart).has(markerId)) chart.removeEntity(markerId); + return false; + } + record = { markerId, markerPoint, options, observedAtMs, restoring: null }; + registry.set(eventId, record); + return true; } finally { - pendingCandleWaits.delete(controller); - } - if (!markerPoint || requestedGeneration !== renderGeneration) return false; - pruneAge(observedAtMs); - ensureCapacityForNew(); - const markerId = await createAlignedShape( - chart, - markerPoint, - shapeOptions(annotation.markerShape, annotation.markerColor) - ); - if (requestedGeneration !== renderGeneration) { - chart.removeEntity(markerId); - return false; + if (pendingRenders.get(eventId) === controller) pendingRenders.delete(eventId); } - record = { markerId, markerShape: annotation.markerShape, observedAtMs }; - registry.set(eventId, record); - return true; } return Object.freeze({ renderOpened: (eventId, annotation, observedAtMs) => ensureMarker(eventId, annotation, observedAtMs), @@ -1152,9 +1197,10 @@ renderOutcome: (eventId, annotation, observedAtMs) => ensureMarker(eventId, annotation, observedAtMs), remove: removeRecord, prune: pruneAge, + reconcile, clear() { renderGeneration += 1; - for (const controller of [...pendingCandleWaits]) controller.abort(); + for (const controller of pendingRenders.values()) controller.abort(); for (const eventId of [...registry.keys()]) removeRecord(eventId); }, get size() { @@ -2209,6 +2255,14 @@ failJob(error); } }, + async reconcile() { + try { + prune(); + if (current() && layer !== null) await layer.reconcile(); + } catch (error) { + failJob(error); + } + }, stop(reason) { if (abortController.signal.aborted) return; abortController.abort(); @@ -2245,12 +2299,16 @@ if (!Number.isSafeInteger(maxCandidates) || maxCandidates < 1 || maxCandidates > 80) throw new Error("Compound chart capacity must be 1..80"); const { chart } = target; const placement = createTradingViewMarkerPlacement(chart, { candleWaitMs }); + const isChartCurrent = pinMarkerChartContext(chart); const records = /* @__PURE__ */ new Map(); let pending = null; + let reconciliation = null; function dispose(recordsToRemove) { const errors = []; + const liveIds = readLiveShapeIds(chart); for (const record of recordsToRemove) { for (const id of record.ids.splice(0)) { + if (!liveIds.has(id)) continue; try { chart.removeEntity(id); } catch (error) { @@ -2260,6 +2318,61 @@ } if (errors.length) throw new AggregateError(errors, `Compound chart cleanup failed: ${errors.map((error) => error.message).join("; ")}`); } + async function createDrawing(point, drawing) { + const entityId = await createAlignedShape(chart, point, drawing); + try { + const properties = chart.getShapeById(entityId).getProperties(); + const matched = properties.color === drawing.overrides.color && (drawing.shape === "icon" ? properties.icon === drawing.icon && properties.size === ICON_SIZE_PX : properties.text === drawing.text && properties.fontsize === 12); + if (!matched) throw new Error("Compound chart drawing properties did not match the requested icon/label"); + } catch (error) { + try { + dispose([{ ids: [entityId] }]); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); + } + throw error; + } + return entityId; + } + function restoreCandidate(id, record, liveIds) { + if (record.restoring) return record.restoring; + const current = () => records.get(id) === record && isChartCurrent(); + if (!current()) return Promise.resolve(false); + if (record.ids.every((entityId) => liveIds.has(entityId))) return Promise.resolve(true); + record.restoring = (async () => { + for (let index = 0; index < record.drawings.length; index += 1) { + if (!current()) return false; + if (liveIds.has(record.ids[index])) continue; + const [point, drawing] = record.drawings[index]; + const entityId = await createDrawing(point, drawing); + if (!current()) { + dispose([{ ids: [entityId] }]); + return false; + } + record.ids[index] = entityId; + liveIds = readLiveShapeIds(chart); + } + return true; + })().finally(() => { + record.restoring = null; + }); + return record.restoring; + } + function reconcile() { + if (reconciliation) return reconciliation; + reconciliation = (async () => { + let liveIds = readLiveShapeIds(chart); + for (const [id, record] of [...records]) { + if (records.get(id) !== record || !isChartCurrent()) continue; + if (!record.restoring && record.ids.every((entityId) => liveIds.has(entityId))) continue; + await restoreCandidate(id, record, liveIds); + liveIds = readLiveShapeIds(chart); + } + })().finally(() => { + reconciliation = null; + }); + return reconciliation; + } function remove(id) { const removals = []; if (pending?.id === id) { @@ -2281,8 +2394,9 @@ dispose(removals); } async function renderCandidate(id, annotation, decisionAtMs) { + const existing = records.get(id); + if (existing) return restoreCandidate(id, existing, readLiveShapeIds(chart)); if (pending !== null) throw new Error("Compound chart rendering must be serial"); - if (records.has(id)) return true; if (records.size >= maxCandidates) throw new Error("Compound chart capacity exceeded before eviction"); if (typeof id !== "string" || id.length === 0 || !Number.isSafeInteger(decisionAtMs) || decisionAtMs < 1) throw new Error("Compound chart candidate identity/time is invalid"); const icon = ICONS[annotation.markerShape]; @@ -2294,7 +2408,7 @@ signal: operation.controller.signal, gapPx: CANDLE_GAP_PX + ICON_SIZE_PX / 2 }); - if (!base || operation.controller.signal.aborted) return false; + if (!base || operation.controller.signal.aborted || !isChartCurrent()) return false; const group = `${base.time}/${annotation.markerShape}`; const occupied = new Set([...records.values()].filter((record) => record.group === group).map((record) => record.slot)); let slot = 0; @@ -2308,17 +2422,14 @@ [labelPoint, { ...options, shape: "text", text: annotation.markerLabel, overrides: { ...options.overrides, fontsize: 12, bold: true, fillBackground: false, drawBorder: false } }] ]; for (const [drawingPoint, drawing] of drawings) { - const entityId = await createAlignedShape(chart, drawingPoint, drawing); + const entityId = await createDrawing(drawingPoint, drawing); operation.ids.push(entityId); - if (operation.controller.signal.aborted) { + if (operation.controller.signal.aborted || !isChartCurrent()) { dispose([operation]); return false; } - const properties = chart.getShapeById(entityId).getProperties(); - const matched = properties.color === annotation.markerColor && (drawing.shape === "icon" ? properties.icon === icon && properties.size === ICON_SIZE_PX : properties.text === annotation.markerLabel && properties.fontsize === 12); - if (!matched) throw new Error("Compound chart drawing properties did not match the requested icon/label"); } - records.set(id, { ids: operation.ids.splice(0), group, slot, decisionAtMs }); + records.set(id, { ids: operation.ids.splice(0), group, slot, decisionAtMs, drawings, restoring: null }); return true; } catch (error) { try { @@ -2331,7 +2442,7 @@ pending = null; } } - return Object.freeze({ renderCandidate, remove, clear, get size() { + return Object.freeze({ renderCandidate, reconcile, remove, clear, get size() { return records.size; } }); } @@ -2423,13 +2534,41 @@ removeStrategy27StatusView(pageDocument); statusView = null; } - async function renderGatewayResponse(context, response) { - if (active !== context) return; + function pruneOrdinaryEvents(context) { for (const eventId of context.lifecycle.prune(Date.now())) { context.layer.remove(eventId); context.panel.remove(eventId); context.candidatePresentations.delete(eventId); } + } + function failOrdinary(context, error) { + if (error.name === "AbortError" || active !== context || context.failed) return; + context.failed = true; + context.controller.abort(); + let failure = error; + try { + context.layer.clear(); + } catch (cleanupError) { + failure = new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); + } + context.panel.clear(); + showStatus(context.target.chartRoot, `Strategy 27 已停止:${failure.message}`, "error"); + } + function reconcileOrdinary(context) { + if (context.failed) return; + try { + pruneOrdinaryEvents(context); + if (context.reconciliation) return; + context.reconciliation = context.layer.reconcile().catch((error) => failOrdinary(context, error)).finally(() => { + context.reconciliation = null; + }); + } catch (error) { + failOrdinary(context, error); + } + } + async function renderGatewayResponse(context, response) { + if (active !== context || context.failed) return; + pruneOrdinaryEvents(context); if (response.status === "reset") { context.lifecycle.reset(response.reason); context.layer.clear(); @@ -2439,6 +2578,7 @@ return; } for (const message of response.messages) { + if (active !== context || context.failed) return; const action = context.lifecycle.apply(message); for (const eventId of action.evictedEventIds ?? []) { context.layer.remove(eventId); @@ -2468,7 +2608,7 @@ event_outcome: "renderOutcome" }[action.messageKind]; const rendered = await context.layer[renderMethod](action.eventId, annotation, action.observedAtMs); - if (!rendered || active !== context) continue; + if (!rendered || active !== context || context.failed) continue; context.panel.upsert(action.eventId, annotation, action.observedAtMs); hideStatus(); } @@ -2495,6 +2635,7 @@ savePosition: (position) => GM_setValue(PANEL_POSITION_KEY, position) }), candidatePresentations: /* @__PURE__ */ new Map(), + reconciliation: null, failed: false }; active = context; @@ -2526,13 +2667,7 @@ }, onResponse: (response) => renderGatewayResponse(context, response) }); - client.run(context.controller.signal).catch((error) => { - if (error.name === "AbortError" || active !== context) return; - context.failed = true; - context.layer.clear(); - context.panel.clear(); - showStatus(target.chartRoot, `Strategy 27 已停止:${error.message}`, "error"); - }); + client.run(context.controller.signal).catch((error) => failOrdinary(context, error)); } function synchronizeContext() { const routeSymbol = parseFuturesTradingSymbolFromPathname(page.location.pathname); @@ -2578,7 +2713,8 @@ return; } if (active && active.routeSymbol === routeSymbol && active.target.chart === target.chart && active.target.chartRoot === target.chartRoot) { - active.compound.prune(); + reconcileOrdinary(active); + void active.compound.reconcile(); return; } stopActive("route_changed"); diff --git a/src/binance-strategy27-events/core/compound-candidate-controller.js b/src/binance-strategy27-events/core/compound-candidate-controller.js index 026f4e3..005d9d2 100644 --- a/src/binance-strategy27-events/core/compound-candidate-controller.js +++ b/src/binance-strategy27-events/core/compound-candidate-controller.js @@ -150,6 +150,15 @@ export function createCompoundCandidateController({ failJob(error); } }, + async reconcile() { + // Recovery belongs to the same optional-job boundary as incoming draws. + try { + prune(); + if (current() && layer !== null) await layer.reconcile(); + } catch (error) { + failJob(error); + } + }, stop(reason) { if (abortController.signal.aborted) return; abortController.abort(); diff --git a/src/binance-strategy27-events/dom/tradingview-compound-layer.js b/src/binance-strategy27-events/dom/tradingview-compound-layer.js index c109b3d..deb9078 100644 --- a/src/binance-strategy27-events/dom/tradingview-compound-layer.js +++ b/src/binance-strategy27-events/dom/tradingview-compound-layer.js @@ -1,4 +1,4 @@ -import { createAlignedShape, createTradingViewMarkerPlacement } from './tradingview-event-layer.js'; +import { createAlignedShape, createTradingViewMarkerPlacement, pinMarkerChartContext, readLiveShapeIds } from './tradingview-event-layer.js'; const ICON_SIZE_PX = 36; const CANDLE_GAP_PX = 8; @@ -24,14 +24,18 @@ export function createTradingViewCompoundLayer(target, { maxCandidates, candleWa if (!Number.isSafeInteger(maxCandidates) || maxCandidates < 1 || maxCandidates > 80) throw new Error('Compound chart capacity must be 1..80'); const { chart } = target; const placement = createTradingViewMarkerPlacement(chart, { candleWaitMs }); + const isChartCurrent = pinMarkerChartContext(chart); const records = new Map(); let pending = null; + let reconciliation = null; function dispose(recordsToRemove) { const errors = []; + const liveIds = readLiveShapeIds(chart); for (const record of recordsToRemove) { // A thrown removal has an unknown outcome. Do not automatically retry it. for (const id of record.ids.splice(0)) { + if (!liveIds.has(id)) continue; try { chart.removeEntity(id); } catch (error) { @@ -42,6 +46,63 @@ export function createTradingViewCompoundLayer(target, { maxCandidates, candleWa if (errors.length) throw new AggregateError(errors, `Compound chart cleanup failed: ${errors.map((error) => error.message).join('; ')}`); } + async function createDrawing(point, drawing) { + const entityId = await createAlignedShape(chart, point, drawing); + try { + const properties = chart.getShapeById(entityId).getProperties(); + const matched = properties.color === drawing.overrides.color && (drawing.shape === 'icon' + ? properties.icon === drawing.icon && properties.size === ICON_SIZE_PX + : properties.text === drawing.text && properties.fontsize === 12); + if (!matched) throw new Error('Compound chart drawing properties did not match the requested icon/label'); + } catch (error) { + try { + dispose([{ ids: [entityId] }]); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); + } + throw error; + } + return entityId; + } + + /** Retain slots and surviving parts; concurrent callers share one repair. */ + function restoreCandidate(id, record, liveIds) { + if (record.restoring) return record.restoring; + const current = () => records.get(id) === record && isChartCurrent(); + if (!current()) return Promise.resolve(false); + if (record.ids.every((entityId) => liveIds.has(entityId))) return Promise.resolve(true); + record.restoring = (async () => { + for (let index = 0; index < record.drawings.length; index += 1) { + if (!current()) return false; + if (liveIds.has(record.ids[index])) continue; + const [point, drawing] = record.drawings[index]; + const entityId = await createDrawing(point, drawing); + if (!current()) { + dispose([{ ids: [entityId] }]); + return false; + } + record.ids[index] = entityId; + liveIds = readLiveShapeIds(chart); + } + return true; + })().finally(() => { record.restoring = null; }); + return record.restoring; + } + + function reconcile() { + if (reconciliation) return reconciliation; + reconciliation = (async () => { + let liveIds = readLiveShapeIds(chart); + for (const [id, record] of [...records]) { + if (records.get(id) !== record || !isChartCurrent()) continue; + if (!record.restoring && record.ids.every((entityId) => liveIds.has(entityId))) continue; + await restoreCandidate(id, record, liveIds); + liveIds = readLiveShapeIds(chart); + } + })().finally(() => { reconciliation = null; }); + return reconciliation; + } + function remove(id) { const removals = []; if (pending?.id === id) { @@ -65,8 +126,9 @@ export function createTradingViewCompoundLayer(target, { maxCandidates, candleWa } async function renderCandidate(id, annotation, decisionAtMs) { + const existing = records.get(id); + if (existing) return restoreCandidate(id, existing, readLiveShapeIds(chart)); if (pending !== null) throw new Error('Compound chart rendering must be serial'); - if (records.has(id)) return true; if (records.size >= maxCandidates) throw new Error('Compound chart capacity exceeded before eviction'); if (typeof id !== 'string' || id.length === 0 || !Number.isSafeInteger(decisionAtMs) || decisionAtMs < 1) throw new Error('Compound chart candidate identity/time is invalid'); const icon = ICONS[annotation.markerShape]; @@ -77,7 +139,7 @@ export function createTradingViewCompoundLayer(target, { maxCandidates, candleWa const base = await placement.wait(annotation, { signal: operation.controller.signal, gapPx: CANDLE_GAP_PX + ICON_SIZE_PX / 2, }); - if (!base || operation.controller.signal.aborted) return false; + if (!base || operation.controller.signal.aborted || !isChartCurrent()) return false; const group = `${base.time}/${annotation.markerShape}`; const occupied = new Set([...records.values()].filter((record) => record.group === group).map((record) => record.slot)); let slot = 0; @@ -91,19 +153,14 @@ export function createTradingViewCompoundLayer(target, { maxCandidates, candleWa [labelPoint, { ...options, shape: 'text', text: annotation.markerLabel, overrides: { ...options.overrides, fontsize: 12, bold: true, fillBackground: false, drawBorder: false } }], ]; for (const [drawingPoint, drawing] of drawings) { - const entityId = await createAlignedShape(chart, drawingPoint, drawing); + const entityId = await createDrawing(drawingPoint, drawing); operation.ids.push(entityId); - if (operation.controller.signal.aborted) { + if (operation.controller.signal.aborted || !isChartCurrent()) { dispose([operation]); return false; } - const properties = chart.getShapeById(entityId).getProperties(); - const matched = properties.color === annotation.markerColor && (drawing.shape === 'icon' - ? properties.icon === icon && properties.size === ICON_SIZE_PX - : properties.text === annotation.markerLabel && properties.fontsize === 12); - if (!matched) throw new Error('Compound chart drawing properties did not match the requested icon/label'); } - records.set(id, { ids: operation.ids.splice(0), group, slot, decisionAtMs }); + records.set(id, { ids: operation.ids.splice(0), group, slot, decisionAtMs, drawings, restoring: null }); return true; } catch (error) { try { @@ -117,5 +174,5 @@ export function createTradingViewCompoundLayer(target, { maxCandidates, candleWa } } - return Object.freeze({ renderCandidate, remove, clear, get size() { return records.size; } }); + return Object.freeze({ renderCandidate, reconcile, remove, clear, get size() { return records.size; } }); } diff --git a/src/binance-strategy27-events/dom/tradingview-event-layer.js b/src/binance-strategy27-events/dom/tradingview-event-layer.js index 360da5b..2278e54 100644 --- a/src/binance-strategy27-events/dom/tradingview-event-layer.js +++ b/src/binance-strategy27-events/dom/tradingview-event-layer.js @@ -16,11 +16,28 @@ function routeSymbolFromChartSymbol(value) { } function assertChartContract(chart) { - for (const method of ['createShape', 'getShapeById', 'removeEntity', 'resolution', 'symbol']) { + for (const method of ['createShape', 'getAllShapes', 'getShapeById', 'removeEntity', 'resolution', 'symbol']) { if (typeof chart?.[method] !== 'function') throw new Error(`TradingView chart method is unavailable: ${method}`); } } +/** The host can evict transient entities without notifying their owner. */ +export function readLiveShapeIds(chart) { + const shapes = chart.getAllShapes(); + if (!Array.isArray(shapes)) throw new Error('Strategy 27 chart shape list is invalid'); + return new Set(shapes.map((shape) => { + if (typeof shape?.id !== 'string' || shape.id.length === 0) throw new Error('Strategy 27 chart shape id is invalid'); + return shape.id; + })); +} + +/** Check the native context around repair awaits, before the next context tick. */ +export function pinMarkerChartContext(chart) { + const symbol = chart.symbol(); + const resolution = chart.resolution(); + return () => chart.symbol() === symbol && chart.resolution() === resolution; +} + export function findStrategy27ChartTarget(document, expectedRouteSymbol) { const chartRoot = findStrategy27ChartRoot(document); if (!chartRoot) return null; @@ -246,15 +263,18 @@ export function createTradingViewEventLayer(target, { if (!Number.isInteger(maxAgeMs) || maxAgeMs < 1) throw new Error('Strategy 27 maxAgeMs is invalid'); const { chart } = target; const placement = createTradingViewMarkerPlacement(chart, { candleWaitMs }); + const isChartCurrent = pinMarkerChartContext(chart); const registry = new Map(); - const pendingCandleWaits = new Set(); + const pendingRenders = new Map(); let renderGeneration = 0; + let reconciliation = null; function removeRecord(eventId) { + pendingRenders.get(eventId)?.abort(); const record = registry.get(eventId); if (!record) return; - chart.removeEntity(record.markerId); registry.delete(eventId); + if (readLiveShapeIds(chart).has(record.markerId)) chart.removeEntity(record.markerId); } function pruneAge(observedAtMs) { @@ -267,37 +287,65 @@ export function createTradingViewEventLayer(target, { while (registry.size >= maxEvents) removeRecord(registry.keys().next().value); } + function restoreMarker(eventId, record, liveIds) { + if (record.restoring) return record.restoring; + const current = () => registry.get(eventId) === record && isChartCurrent(); + if (!current()) return Promise.resolve(false); + if (liveIds.has(record.markerId)) return Promise.resolve(true); + record.restoring = (async () => { + const markerId = await createAlignedShape(chart, record.markerPoint, record.options); + if (!current()) { + if (readLiveShapeIds(chart).has(markerId)) chart.removeEntity(markerId); + return false; + } + record.markerId = markerId; + return true; + })().finally(() => { record.restoring = null; }); + return record.restoring; + } + + function reconcile() { + if (reconciliation) return reconciliation; + reconciliation = (async () => { + let liveIds = readLiveShapeIds(chart); + for (const [eventId, record] of [...registry]) { + if (registry.get(eventId) !== record || !isChartCurrent()) continue; + if (!record.restoring && liveIds.has(record.markerId)) continue; + await restoreMarker(eventId, record, liveIds); + // A native create yields; refresh before examining another record. + liveIds = readLiveShapeIds(chart); + } + })().finally(() => { reconciliation = null; }); + return reconciliation; + } + async function ensureMarker(eventId, annotation, observedAtMs) { let record = registry.get(eventId); if (record) { record.observedAtMs = observedAtMs; - return true; + return restoreMarker(eventId, record, readLiveShapeIds(chart)); } if (annotation.markerShape === null) return true; const requestedGeneration = renderGeneration; const controller = new AbortController(); - pendingCandleWaits.add(controller); - let markerPoint; + pendingRenders.set(eventId, controller); try { - markerPoint = await placement.wait(annotation, { signal: controller.signal }); + const markerPoint = await placement.wait(annotation, { signal: controller.signal }); + if (!markerPoint || controller.signal.aborted || requestedGeneration !== renderGeneration || !isChartCurrent()) return false; + pruneAge(observedAtMs); + ensureCapacityForNew(); + const options = shapeOptions(annotation.markerShape, annotation.markerColor); + const markerId = await createAlignedShape(chart, markerPoint, options); + if (controller.signal.aborted || requestedGeneration !== renderGeneration || !isChartCurrent()) { + if (readLiveShapeIds(chart).has(markerId)) chart.removeEntity(markerId); + return false; + } + record = { markerId, markerPoint, options, observedAtMs, restoring: null }; + registry.set(eventId, record); + return true; } finally { - pendingCandleWaits.delete(controller); - } - if (!markerPoint || requestedGeneration !== renderGeneration) return false; - pruneAge(observedAtMs); - ensureCapacityForNew(); - const markerId = await createAlignedShape( - chart, - markerPoint, - shapeOptions(annotation.markerShape, annotation.markerColor), - ); - if (requestedGeneration !== renderGeneration) { - chart.removeEntity(markerId); - return false; + if (pendingRenders.get(eventId) === controller) pendingRenders.delete(eventId); } - record = { markerId, markerShape: annotation.markerShape, observedAtMs }; - registry.set(eventId, record); - return true; } return Object.freeze({ @@ -307,9 +355,10 @@ export function createTradingViewEventLayer(target, { renderOutcome: (eventId, annotation, observedAtMs) => ensureMarker(eventId, annotation, observedAtMs), remove: removeRecord, prune: pruneAge, + reconcile, clear() { renderGeneration += 1; - for (const controller of [...pendingCandleWaits]) controller.abort(); + for (const controller of pendingRenders.values()) controller.abort(); for (const eventId of [...registry.keys()]) removeRecord(eventId); }, get size() { diff --git a/src/binance-strategy27-events/index.user.js b/src/binance-strategy27-events/index.user.js index d05aa8b..fa52f26 100644 --- a/src/binance-strategy27-events/index.user.js +++ b/src/binance-strategy27-events/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.4.0 +// @version 0.4.1 // @author jackhai9 // @description 在 Binance 一秒图表标注 VPS Strategy 27 的实时订单流候选观察 // @match https://www.binance.com/*/futures/* @@ -86,13 +86,44 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; statusView = null; } - async function renderGatewayResponse(context, response) { - if (active !== context) return; + function pruneOrdinaryEvents(context) { for (const eventId of context.lifecycle.prune(Date.now())) { context.layer.remove(eventId); context.panel.remove(eventId); context.candidatePresentations.delete(eventId); } + } + + function failOrdinary(context, error) { + if (error.name === 'AbortError' || active !== context || context.failed) return; + context.failed = true; + context.controller.abort(); + let failure = error; + try { + context.layer.clear(); + } catch (cleanupError) { + failure = new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); + } + context.panel.clear(); + showStatus(context.target.chartRoot, `Strategy 27 已停止:${failure.message}`, 'error'); + } + + function reconcileOrdinary(context) { + if (context.failed) return; + try { + pruneOrdinaryEvents(context); + if (context.reconciliation) return; + context.reconciliation = context.layer.reconcile() + .catch((error) => failOrdinary(context, error)) + .finally(() => { context.reconciliation = null; }); + } catch (error) { + failOrdinary(context, error); + } + } + + async function renderGatewayResponse(context, response) { + if (active !== context || context.failed) return; + pruneOrdinaryEvents(context); if (response.status === 'reset') { context.lifecycle.reset(response.reason); context.layer.clear(); @@ -103,6 +134,7 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; } for (const message of response.messages) { + if (active !== context || context.failed) return; const action = context.lifecycle.apply(message); for (const eventId of action.evictedEventIds ?? []) { context.layer.remove(eventId); @@ -132,7 +164,7 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; event_outcome: 'renderOutcome', }[action.messageKind]; const rendered = await context.layer[renderMethod](action.eventId, annotation, action.observedAtMs); - if (!rendered || active !== context) continue; + if (!rendered || active !== context || context.failed) continue; context.panel.upsert(action.eventId, annotation, action.observedAtMs); hideStatus(); } @@ -160,6 +192,7 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; savePosition: (position) => GM_setValue(PANEL_POSITION_KEY, position), }), candidatePresentations: new Map(), + reconciliation: null, failed: false, }; active = context; @@ -186,13 +219,7 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; }, onResponse: (response) => renderGatewayResponse(context, response), }); - client.run(context.controller.signal).catch((error) => { - if (error.name === 'AbortError' || active !== context) return; - context.failed = true; - context.layer.clear(); - context.panel.clear(); - showStatus(target.chartRoot, `Strategy 27 已停止:${error.message}`, 'error'); - }); + client.run(context.controller.signal).catch((error) => failOrdinary(context, error)); } function synchronizeContext() { @@ -247,7 +274,8 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; && active.target.chart === target.chart && active.target.chartRoot === target.chartRoot ) { - active.compound.prune(); + reconcileOrdinary(active); + void active.compound.reconcile(); return; } stopActive('route_changed'); diff --git a/test/dom/binance-strategy27-events/compound-candidate-controller.test.js b/test/dom/binance-strategy27-events/compound-candidate-controller.test.js index 6c3c521..eaf7dbc 100644 --- a/test/dom/binance-strategy27-events/compound-candidate-controller.test.js +++ b/test/dom/binance-strategy27-events/compound-candidate-controller.test.js @@ -21,7 +21,7 @@ const state = (sequence, epoch = EPOCH) => ({ }); const deferred = () => Promise.withResolvers(); -function harness(t, steps, { render, createError, removeError, clearError, maxAgeMs = 7200000, maxCandidates = 80 } = {}) { +function harness(t, steps, { render, reconcile, createError, removeError, clearError, maxAgeMs = 7200000, maxCandidates = 80 } = {}) { const dom = loadFixtureDom('
'); const panel = createStrategy27EventPanel(dom.window.document, dom.window.document.querySelector('.chart-widget-root'), { maxEvents: 8, maxCompoundEvents: 8, loadPosition: () => null, savePosition: () => {}, @@ -60,6 +60,7 @@ function harness(t, steps, { render, createError, removeError, clearError, maxAg layerCreates += 1; if (createError) throw createError; return { + async reconcile() { if (reconcile) await reconcile(); }, async renderCandidate(id, annotation, decisionAtMs) { const token = { id, generation, cancelled: false }; pending = token; @@ -376,3 +377,18 @@ test('late drawing failure after context retirement remains inspectable without assert.equal(h.panel.compoundSize, 0); assert.equal(h.panel.size, 1); }); + +test('timer repair failures stop only the compound job and retain ordinary history', async (t) => { + const h = harness(t, [reset(), batch([envelope()])], { + reconcile: async () => { throw new Error('fixture native repair failure'); }, + }); + h.run(); + await h.parked; + await h.controller.reconcile(); + assert.equal(h.panel.size, 1); + assert.equal(h.panel.compoundSize, 0); + assert.equal(h.shapes.size, 0); + assert.equal(h.statusKind(), 'error'); + assert.match(h.status(), /fixture native repair failure/); + assert.match(h.controller.lastError.message, /fixture native repair failure/); +}); diff --git a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js index e6f5744..02a016c 100644 --- a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js +++ b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js @@ -15,7 +15,7 @@ async function until(predicate) { } } -async function harness(t, { generated = false } = {}) { +async function harness(t, { generated = false, beforeCreate } = {}) { const dom = loadFixtureDom(''); dom.reconfigure({ url: 'https://www.binance.com/zh-CN/futures/BTCUSDT' }); const page = dom.window; @@ -26,10 +26,12 @@ async function harness(t, { generated = false } = {}) { resolution: () => resolution, symbol: () => 'BTCUSDT', createShape: async (point, options) => { const id = `entry-owned-${++shapeSequence}`; + if (beforeCreate) await beforeCreate(); shapes.set(id, { getPoints: () => [point], getProperties: () => ({ ...options.overrides, icon: options.icon, text: options.text }) }); return id; }, getShapeById: (id) => shapes.get(id), + getAllShapes: () => [...shapes.keys()].map((id) => ({ id })), removeEntity: (id) => { assert.notEqual(id, 'user-owned'); assert.equal(shapes.delete(id), true); }, getSeries: () => ({ data: () => ({ valueAt: (time) => [time, 100, 101, 99, 100] }) }), _chartWidget: { model: () => ({ model: () => ({ @@ -186,3 +188,95 @@ test('generated install artifact receives a candidate and cleans up its paired e assert.equal(h.pending('ordinary').length, 0); assert.equal(h.pending('compound').length, 0); }); + +for (const generated of [false, true]) { + test(`${generated ? 'generated' : 'source'} context timer restores externally evicted candidates without gateway traffic`, async (t) => { + const h = await harness(t, { generated }); + await h.reset(); + await h.candidate(); + const oldIds = [...h.shapes.keys()].filter((id) => id !== 'user-owned'); + for (const id of oldIds) h.shapes.delete(id); + h.tick(); + await until(() => h.shapes.size === 3); + assert.equal(h.rows(), 1); + assert.equal(oldIds.some((id) => h.shapes.has(id)), false); + const repairedIds = [...h.shapes.keys()]; + h.tick(); + await new Promise(setImmediate); + assert.deepEqual([...h.shapes.keys()], repairedIds); + h.clear(); + h.tick(); + await new Promise(setImmediate); + assert.equal(h.shapes.size, 1); + assert.equal(h.rows(), 0); + await h.candidate(2, '2-0', '3-0'); + assert.equal(h.rows(), 0); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + }); +} + +function ordinaryMessage() { + const snapshot = { + bucket_start_ms: 1000, bucket_end_ms: 1250, source_bucket_count: 1, + bucket_trigger_reasons: ['aggressive_buy_to_ask_depth'], + candidate_observations: ['bullish_sell_impact_failure'], + aggressive_buy: { notional: '1200', trade_count: 3, to_opposite_depth: '0.4' }, + aggressive_sell: { notional: '200', trade_count: 1, to_opposite_depth: '0.1' }, + bid: { observed_addition_notional: '300', observed_decrease_notional: '100', best_price_migration_bps: '0.2', addition_to_depth: '0.3', decrease_to_depth: '0.1' }, + ask: { observed_addition_notional: '100', observed_decrease_notional: '500', best_price_migration_bps: '-0.4', addition_to_depth: '0.1', decrease_to_depth: '0.5' }, + price_response: { mid: '100', mid_return_bps: '2.5', spread_bps: '1.2', spread_change_bps: '-0.2' }, + }; + return { + schema_version: 2, strategy_id: '27', spec_version: '27_2_spec_v10', runtime_epoch: 'a'.repeat(32), + sequence: 1, message_kind: 'event_updated', symbol: 'BTC/USDT:USDT', event_id: 'b'.repeat(64), + observed_at_ms: 2000, event_time_ms: 2000, data_status: 'active', + payload: { event: { + event_kind: 'orderflow_event', analysis_start_at_ms: 0, triggered_at_ms: 1000, + active_end_at_ms: null, event_status: 'active', close_reason: null, + trigger_reasons: ['aggressive_buy_to_ask_depth'], + trigger_snapshot: { ...snapshot, candidate_observations: [] }, + latest_snapshot: { ...snapshot, source_bucket_count: 4, bucket_end_ms: 2000 }, + } }, + }; +} + +for (const generated of [false, true]) { + test(`${generated ? 'generated' : 'source'} timer restores ordinary drawings and prunes both lifecycles before repair`, async (t) => { + const h = await harness(t, { generated }); + await h.respond('ordinary', { schema_version: 1, status: 'reset', reason: 'initial_cursor', requested_cursor: null, next_cursor: '1-0', messages: [] }); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinaryMessage()] }); + await until(() => h.shapes.size === 2 || h.page.document.getElementById('jh-strategy27-event-status')?.dataset.state === 'error'); + assert.equal(h.shapes.size, 2, h.page.document.getElementById('jh-strategy27-event-status')?.textContent); + const oldOrdinary = [...h.shapes.keys()].find((id) => id !== 'user-owned'); + h.shapes.delete(oldOrdinary); + h.tick(); + await until(() => h.shapes.size === 2); + assert.equal(h.shapes.has(oldOrdinary), false); + assert.equal(h.pending('ordinary').length, 1); + await h.reset(); + await h.candidate(); + assert.equal(h.shapes.size, 4); + for (const id of [...h.shapes.keys()]) if (id !== 'user-owned') h.shapes.delete(id); + h.setNow(7207001); + h.tick(); + await new Promise(setImmediate); + assert.equal(h.rows(), 0); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + }); +} + +test('timer expiry cancels an ordinary first creation that is still awaiting TradingView', async (t) => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const h = await harness(t, { beforeCreate: async () => { entered.resolve(); await release.promise; } }); + await h.respond('ordinary', { schema_version: 1, status: 'reset', reason: 'initial_cursor', requested_cursor: null, next_cursor: '1-0', messages: [] }); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinaryMessage()] }); + await entered.promise; + h.setNow(7207001); + h.tick(); + release.resolve(); + await until(() => h.pending('ordinary').length === 1); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); +}); diff --git a/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js b/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js index 055aa03..ecf3e6b 100644 --- a/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js +++ b/test/dom/binance-strategy27-events/tradingview-compound-layer.test.js @@ -14,6 +14,8 @@ function fixture({ bars = [[10, 1.25, 1.3, 1.2, 1.25]], beforeCreate, shiftSecon const removed = []; let counter = 0; const chart = { + symbol: () => 'BTRUSDT', + resolution: () => '1S', async createShape(point, options) { counter += 1; const id = `owned-${counter}`; @@ -26,6 +28,7 @@ function fixture({ bars = [[10, 1.25, 1.3, 1.2, 1.25]], beforeCreate, shiftSecon return id; }, getShapeById: (id) => shapes.get(id), + getAllShapes: () => [...shapes.keys()].map((id) => ({ id })), removeEntity(id) { assert.notEqual(id, 'user-owned'); removed.push(id); @@ -189,3 +192,76 @@ test('80 compound pairs plus 80 ordinary markers have a strict 240-entity budget ordinary.clear(); assert.deepEqual([...f.shapes.keys()], ['user-owned']); }); + +test('compound reconciliation restores only missing parts in their original slots', async () => { + const f = fixture(); + const layer = f.layer(); + await layer.renderCandidate('a', annotation(), 11000); + await layer.renderCandidate('b', annotation(), 11000); + f.shapes.delete('owned-1'); + f.shapes.delete('owned-3'); + f.shapes.delete('owned-4'); + await layer.reconcile(); + assert.equal(layer.size, 2); + assert.equal(f.created.length, 7); + assert.equal(f.shapes.has('owned-2'), true); + assert.deepEqual(f.created.slice(4).map((drawing) => drawing.point), [f.created[0].point, f.created[2].point, f.created[3].point]); + assert.deepEqual(f.removed, []); + await layer.reconcile(); + assert.equal(f.created.length, 7); + layer.clear(); + await layer.reconcile(); + assert.deepEqual([...f.shapes.keys()], ['user-owned']); +}); + +test('a repeated candidate restores externally removed entities', async () => { + const f = fixture(); + const layer = f.layer(); + await layer.renderCandidate('a', annotation(), 11000); + f.shapes.delete('owned-1'); + f.shapes.delete('owned-2'); + assert.equal(await layer.renderCandidate('a', annotation(), 11000), true); + assert.equal(f.created.length, 4); + assert.equal(layer.size, 1); + assert.equal(f.shapes.size, 3); +}); + +test('compound timer and replay share a single repair and discard late parts after invalidation', async () => { + for (const action of ['retain', 'clear', 'remove', 'interval', 'symbol']) { + const entered = deferred(); + const release = deferred(); + const f = fixture({ beforeCreate: async (count) => { if (count === 3) { entered.resolve(); await release.promise; } } }); + const layer = f.layer(); + await layer.renderCandidate('a', annotation(), 11000); + f.shapes.delete('owned-1'); + const repair = layer.reconcile(); + await entered.promise; + const replay = layer.renderCandidate('a', annotation(), 11000); + const anotherTick = layer.reconcile(); + if (action === 'clear') layer.clear(); + if (action === 'remove') layer.remove('a'); + if (action === 'interval') f.chart.resolution = () => '1'; + if (action === 'symbol') f.chart.symbol = () => 'BTCUSDT'; + release.resolve(); + assert.equal(await replay, action === 'retain', action); + await Promise.all([repair, anotherTick]); + assert.equal(f.created.length, 3, action); + assert.equal(f.shapes.has('owned-3'), action === 'retain', action); + if (action === 'retain') assert.equal(f.shapes.has('owned-2'), true); + layer.clear(); + await layer.reconcile(); + assert.deepEqual([...f.shapes.keys()], ['user-owned']); + } +}); + +test('cleanup skips evicted compound parts and still reports a live part removal failure', async () => { + const f = fixture({ removeError: new Error('fixture removal failure') }); + const layer = f.layer(); + await layer.renderCandidate('a', annotation(), 11000); + f.shapes.delete('owned-1'); + assert.throws(() => layer.clear(), (error) => error instanceof AggregateError && error.errors.length === 1); + assert.deepEqual(f.removed, ['owned-2']); + assert.equal(layer.size, 0); + layer.clear(); + assert.deepEqual(f.removed, ['owned-2']); +}); diff --git a/test/dom/binance-strategy27-events/tradingview-event-layer.test.js b/test/dom/binance-strategy27-events/tradingview-event-layer.test.js index 7d38813..45ddc40 100644 --- a/test/dom/binance-strategy27-events/tradingview-event-layer.test.js +++ b/test/dom/binance-strategy27-events/tradingview-event-layer.test.js @@ -40,6 +40,7 @@ function createChartDom({ return id; }, getShapeById: (id) => shapes.get(id), + getAllShapes: () => [...shapes.keys()].map((id) => ({ id })), removeEntity(id) { removed.push(id); shapes.delete(id); }, getSeries: () => ({ data: () => ({ @@ -338,3 +339,76 @@ test('clear removes a marker whose asynchronous creation finishes late', async ( assert.equal(shapes.size, 0); assert.equal(removed.length, 1); }); + +test('an update restores an externally evicted marker with its original immutable presentation', async () => { + const f = createChartDom(); + const layer = createTradingViewEventLayer({ chart: f.chart }, { maxEvents: 2, maxAgeMs: 60000 }); + await layer.renderOpened('a', annotation(), 10000); + const [oldId] = f.shapes.keys(); + const point = f.shapes.get(oldId).getPoints(); + f.shapes.delete(oldId); + assert.equal(await layer.renderUpdated('a', annotation({ markerShape: 'arrow_down', markerColor: '#F6465D' }), 11000), true); + assert.equal(f.shapes.size, 1); + const [id, shape] = [...f.shapes][0]; + assert.notEqual(id, oldId); + assert.deepEqual(shape.getPoints(), point); + assert.equal(shape.getProperties().shape, 'arrow_up'); + assert.equal(shape.getProperties().overrides.color, '#0ECB81'); + assert.equal(layer.size, 1); + assert.deepEqual(f.removed, []); +}); + +test('reconciliation restores missing ordinary markers without an event and never revives a cleared record', async () => { + const f = createChartDom(); + const layer = createTradingViewEventLayer({ chart: f.chart }, { maxEvents: 2, maxAgeMs: 60000 }); + f.shapes.set('foreign', {}); + await layer.renderOpened('a', annotation(), 10000); + f.shapes.delete('shape-1'); + await layer.reconcile(); + assert.deepEqual([...f.shapes.keys()], ['foreign', 'shape-2']); + await layer.reconcile(); + assert.deepEqual([...f.shapes.keys()], ['foreign', 'shape-2']); + f.shapes.delete('shape-2'); + layer.clear(); + await layer.reconcile(); + assert.deepEqual([...f.shapes.keys()], ['foreign']); + assert.deepEqual(f.removed, []); + assert.equal(layer.size, 0); +}); + +test('ordinary timer and gateway repair share a single creation and cancel stale results', async () => { + for (const action of ['retain', 'clear', 'remove', 'expire', 'interval', 'symbol']) { + const f = createChartDom(); + const layer = createTradingViewEventLayer({ chart: f.chart }, { maxEvents: 2, maxAgeMs: 60000 }); + await layer.renderOpened('a', annotation(), 10000); + f.shapes.delete('shape-1'); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const nativeCreate = f.chart.createShape; + let creates = 0; + f.chart.createShape = async (...args) => { + creates += 1; + entered.resolve(); + await release.promise; + return nativeCreate(...args); + }; + const repair = layer.reconcile(); + await entered.promise; + const update = layer.renderUpdated('a', annotation(), 11000); + const anotherTick = layer.reconcile(); + if (action === 'clear') layer.clear(); + if (action === 'remove') layer.remove('a'); + if (action === 'expire') layer.prune(7200000); + if (action === 'interval') f.chart.resolution = () => '1'; + if (action === 'symbol') f.chart.symbol = () => 'BTCUSDT'; + release.resolve(); + assert.equal(await update, action === 'retain', action); + await Promise.all([repair, anotherTick]); + assert.equal(creates, 1, action); + assert.equal(f.shapes.size, action === 'retain' ? 1 : 0, action); + if (['clear', 'remove', 'expire'].includes(action)) assert.equal(layer.size, 0); + layer.clear(); + await layer.reconcile(); + assert.equal(f.shapes.size, 0); + } +}); diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index 8a8143e..a2097f0 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -37,7 +37,7 @@ test('release contract identifies the generated Strategy 27 annotation artifact' assert.equal(contract.name, '【自写】Binance Strategy 27 事件标注'); assert.equal(contract.namespace, 'binance.strategy27.events'); - assert.equal(contract.version, '0.4.0'); + assert.equal(contract.version, '0.4.1'); assert.equal(contract.runAt, 'document-idle'); assert.equal(contract.updateURL, contract.downloadURL); assert.deepEqual(contract.matches, [