Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions docs/binance-strategy27-events-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
226 changes: 181 additions & 45 deletions scripts/binance-strategy27-events.user.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
81 changes: 69 additions & 12 deletions src/binance-strategy27-events/dom/tradingview-compound-layer.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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];
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -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; } });
}
97 changes: 73 additions & 24 deletions src/binance-strategy27-events/dom/tradingview-event-layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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({
Expand All @@ -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() {
Expand Down
Loading
Loading