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
30 changes: 30 additions & 0 deletions docs/binance-orderbook-trade-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,36 @@ scripts/binance-orderbook-trade.user.js

## Runtime

### Chart save completion and serialization

`core/chart-save-coalescer.js` batches the observed default
`saveChart(callback)` calls during native order-drawing bursts. A completed
batch serializes the current chart once and delivers a separate JSON snapshot
to every accepted callback, including repeated calls using the same function.
Never replace the callback queue with only its last element: unrelated callers
may depend on their own completion to continue.

Explicit options, extra arguments and non-native receivers retain the original
method's synchronous return and error behavior. Queued batches are detached
before delivery; callbacks may reenter without overwriting the current batch.
One callback failure does not suppress later callbacks and is reported at the
asynchronous callback-error boundary. Stop, action failure, timeout and an
external wrapper replacing the method still deliver accepted calls while
preserving method ownership. A retained reference to a retired wrapper forwards
new calls to its original method instead of creating an orphan queue.

These are serialization and callback guarantees, not database commit guarantees.
If each callback writes IndexedDB, it still performs its own write. Tests must
model `saveChart(callback, options)` rather than passing a snapshot as the first
argument. Browser fixtures report serialization, callback delivery and native
save requests separately, using request IDs to verify exactly-once delivery.

Strategy27/29/31 transient annotations use the shared SVG overlay and never
enter the native drawing/save pipeline. Native order lines and manual drawings
continue to use the host chart APIs. Live verification must measure new calls
within a defined observation window; accumulated counters alone cannot establish
causality for an earlier browser database stall.

Use the repository Node version:

```bash
Expand Down
138 changes: 73 additions & 65 deletions docs/binance-strategy27-events-development.md

Large diffs are not rendered by default.

60 changes: 29 additions & 31 deletions docs/binance-strategy29-bollinger-development.md

Large diffs are not rendered by default.

14 changes: 9 additions & 5 deletions docs/binance-strategy31-volume-reversal-development.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Strategy31 volume reversal signals

Source: `src/binance-strategy31-volume-reversal/`. Installer:
`scripts/binance-strategy31-volume-reversal.user.js` (0.1.1).
`scripts/binance-strategy31-volume-reversal.user.js` (0.1.2).
Install alongside CorsairQuant signal client 0.6.7, which owns private gateway
authentication. The shared bridge explicitly advertises the `strategy31` capability.

Expand All @@ -28,9 +28,13 @@ waits for the next sample; malformed signal contracts still stop the observer.
Loading earlier
chart history makes matching retained server events eligible for rendering.

The client uses the existing native marker layer, exact interval visibility,
readback and drawing-save coordination. The green arrow anchors at the green
candle's open time and low. No exchange/account API or trading action is called.
The client shares Strategy29's script-owned SVG marker layer. Exact candle-time
readback and native time/price coordinate conversion keep each green arrow
anchored at the green candle's open time and low. Viewport events redraw the layer;
interval invalidation hides it immediately. Marker changes do not create native
drawings, emit drawing events or request chart saves. No exchange/account API or
trading action is called. See the Strategy29 development manual for the shared
projection and lifecycle contract.
The projection is recent retained history, not all visible chart history.

The shared capability permits only `/v1/strategy31/events` with exactly `symbol`,
Expand All @@ -44,6 +48,6 @@ coordinates or numeric values stop this observer with a visible status.

Build with `npm run build:binance-strategy31-volume-reversal`; validate with
`npm run lint:tests`, `npm test`, `npm run check:binance-userscripts` and the
repository's browser suite. DOM integration tests use the shared native host
repository's browser suite. DOM integration tests use the shared chart-coordinate host
fixture, exercising actual marker rendering and stale-response rejection.
Fixture results do not establish current Binance rendering or installed code.
56 changes: 27 additions & 29 deletions e2e/binance-orderbook/fixtures/binance-futures.js
Original file line number Diff line number Diff line change
Expand Up @@ -555,13 +555,21 @@ export function renderBinanceFuturesFixture(scenario) {
const chartOrdersTrigger = document.querySelector('[data-testid="chart-orders-trigger"]');
const chartRoot = document.querySelector('.chart-widget-root');
const chartEventListeners = new Map();
const chartOrderDrawings = new Map(currentOrders().map(order => [
const chartOrderDrawings = new Map((state.showOrders ? currentOrders() : []).map(order => [
'order-' + order.id,
{ toolname: 'LineToolOrder', symbol: order.symbol },
]));
function readChartSnapshot() {
return { checked: state.showOrders, drawingIds: [...chartOrderDrawings.keys()] };
}
const tradingViewApi = {
saveChart(snapshot) {
record('chart-saved', { snapshot });
saveChart(callback, options) {
if (typeof callback !== 'function') throw new TypeError('Native chart save requires a callback');
const snapshot = options?.includeDrawings === false
? { checked: state.showOrders, drawingIds: [] }
: readChartSnapshot();
record('chart-serialized', { snapshot: JSON.parse(JSON.stringify(snapshot)) });
return callback(snapshot);
},
subscribe(eventName, listener) {
const listeners = chartEventListeners.get(eventName) || new Set();
Expand Down Expand Up @@ -589,14 +597,19 @@ export function renderBinanceFuturesFixture(scenario) {
});
}

let chartSaveRequestSequence = 0;
function requestChartSave(details) {
const requestId = ++chartSaveRequestSequence;
record('chart-save-requested', { ...details, requestId, snapshot: readChartSnapshot() });
tradingViewApi.saveChart(snapshot => record('chart-saved', { requestId, snapshot }));
}

/** Native broker events request a complete chart serialization 100 ms later. */
function publishNativeOrderDrawingEvent(drawingId, eventType) {
record('chart-drawing-event', { drawingId, eventType, toolname: 'LineToolOrder' });
tradingViewApi.emit('drawing_event', drawingId, eventType);
setTimeout(() => {
const snapshot = { checked: state.showOrders, drawingIds: [...chartOrderDrawings.keys()] };
record('chart-save-requested', { drawingId, eventType, snapshot });
tradingViewApi.saveChart(snapshot);
requestChartSave({ drawingId, eventType });
}, 100);
}

Expand All @@ -612,24 +625,15 @@ export function renderBinanceFuturesFixture(scenario) {
if (!state.showOrders) return;
orders.forEach((order, index) => {
setTimeout(() => {
const drawingId = 'order-' + order.id;
// Off-chart order cancellations have no visible drawing to remove.
if (!chartOrderDrawings.delete(drawingId)) return;
if (scenario.host.orderDrawingEvents) {
const drawingId = 'order-' + order.id;
// Off-chart order cancellations have no visible drawing to remove.
if (!chartOrderDrawings.delete(drawingId)) return;
publishNativeOrderDrawingEvent(drawingId, 'remove');
return;
}
tradingViewApi.emit('drawing_event', 'order-' + order.id, 'remove');
record('chart-save-requested', {
checked: true,
index,
orderId: order.id,
});
tradingViewApi.saveChart({
checked: true,
drawingCount: Math.max(0, orders.length - index - 1),
finalOrderId: order.id,
});
requestChartSave({ checked: true, index, orderId: order.id });
}, scenario.host.mutationDelayMs + (index * 2));
});
}
Expand All @@ -647,21 +651,15 @@ export function renderBinanceFuturesFixture(scenario) {
record('chart-orders-checked', { value: nextChecked, drawings: drawings.length });
drawings.forEach((order, index) => {
setTimeout(() => {
const drawingId = 'order-' + order.id;
if (nextChecked) chartOrderDrawings.set(drawingId, { toolname: 'LineToolOrder', symbol: order.symbol });
else chartOrderDrawings.delete(drawingId);
tradingViewApi.emit(
'drawing_event',
'order-' + order.id,
nextChecked ? 'properties_changed' : 'remove',
);
record('chart-save-requested', {
checked: nextChecked,
index,
orderId: order.id,
});
tradingViewApi.saveChart({
checked: nextChecked,
drawingCount: drawings.length,
finalOrderId: order.id,
});
requestChartSave({ checked: nextChecked, index, orderId: order.id });
}, scenario.host.mutationDelayMs + (index * 2));
});
});
Expand Down
78 changes: 78 additions & 0 deletions e2e/binance-orderbook/helpers/marker-overlay-host.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/** Models the inspected coordinate/delegate boundary without entering native drawings. */
export function installMarkerOverlayHost({ document, chart, rows }) {
const pane = document.createElement('div');
pane.className = 'chart-gui-wrapper';
pane.style.cssText = 'position:relative;width:900px;height:500px;background:#181a20';
const canvas = document.createElement('canvas');
canvas.width = 900;
canvas.height = 500;
pane.append(canvas);
document.body.append(pane);
const events = {};
for (const name of ['logicalRangeChanged', 'barSpacingChanged', 'rightOffsetChanged', 'priceRangeChanged', 'modeChanged', 'internalHeightChanged', 'dataUpdated']) {
const listeners = new Map();
events[name] = {
subscribe(owner, callback) { listeners.set(callback, owner); },
unsubscribe(owner, callback) {
if (listeners.get(callback) !== owner) throw new Error('Subscription owner mismatch');
listeners.delete(callback);
},
emit() { for (const callback of listeners.keys()) callback(); },
};
}
let offset = 0;
let spacing = 2;
let inset = 10;
const time = {
timePointToIndex(timestamp, mode) {
if (mode !== 0 && mode !== 1) throw new Error('Expected exact or prior timestamp lookup');
const index = mode === 0 ? rows.findIndex(row => row[0] === timestamp)
: rows.findLastIndex(row => row[0] <= timestamp);
return index < 0 ? null : index;
},
indexToCoordinate: index => index * spacing + inset + offset,
logicalRangeChanged: () => events.logicalRangeChanged,
barSpacingChanged: () => events.barSpacingChanged,
rightOffsetChanged: () => events.rightOffsetChanged,
};
const price = {
priceToCoordinate: value => 400 - value * 2,
coordinateToPrice: value => (400 - value) / 2,
priceRangeChanged: () => events.priceRangeChanged,
modeChanged: () => events.modeChanged,
internalHeightChanged: () => events.internalHeightChanged,
};
const series = { priceScale: () => price, firstValue: () => 100, dataUpdated: () => events.dataUpdated };
const paneState = {};
const model = { mainSeries: () => series, timeScale: () => time, paneForSource: () => paneState };
const paneWidget = { canvasElement: () => canvas };
chart._chartWidget = { model: () => ({ model: () => model }), paneByState: () => paneWidget };
chart.getSeries = () => ({ data: () => ({ valueAt: index => rows[index] || null }) });
function paintCandles() {
const context = canvas.getContext('2d');
context.clearRect(0, 0, canvas.width, canvas.height);
context.strokeStyle = '#30343d';
for (const y of [100, 200, 300, 400]) {
context.beginPath(); context.moveTo(0, y); context.lineTo(900, y); context.stroke();
}
for (const [index, row] of rows.entries()) {
const x = time.indexToCoordinate(index);
if (x < 0 || x > canvas.width) continue;
context.strokeStyle = '#a4aab6';
context.beginPath(); context.moveTo(x, price.priceToCoordinate(row[2]));
context.lineTo(x, price.priceToCoordinate(row[3])); context.stroke();
context.fillStyle = '#6c7485';
const top = price.priceToCoordinate(Math.max(row[1], row[4]));
const bottom = price.priceToCoordinate(Math.min(row[1], row[4]));
context.fillRect(x - 3, top, 6, Math.max(1, bottom - top));
}
}
paintCandles();
return { pane, events,
pan(value) { offset = value; paintCandles(); events.logicalRangeChanged.emit(); },
setSpacing(value, left) {
if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(left)) throw new Error('Invalid fixture spacing');
spacing = value; inset = left; paintCandles(); events.barSpacingChanged.emit();
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function expectOrdersUntouched(state, scenario) {
expect(state.events.filter(({ type }) => [
'chart-orders-checked',
'chart-save-requested',
'chart-serialized',
'chart-saved',
].includes(type))).toEqual([]);
expect(state.showOrders).toBe(scenario.ui.showOrders);
Expand Down
5 changes: 4 additions & 1 deletion e2e/binance-orderbook/specs/cancel-covering-matrix.pw.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,11 @@ for (const entry of CANCEL_COVERING_SCENARIOS) {
expectedChartSaveRequestCount,
);
expect(state.events.filter((event) => event.type === 'chart-saved')).toHaveLength(
expectsChartRemovalSave ? 1 : 0,
expectedChartSaveRequestCount,
);
expect(state.events.filter((event) => event.type === 'chart-serialized')).toHaveLength(expectsChartRemovalSave ? 1 : 0);
expect(state.events.filter((event) => event.type === 'chart-saved').map(({ requestId }) => requestId))
.toEqual(state.events.filter((event) => event.type === 'chart-save-requested').map(({ requestId }) => requestId));
expect(errors).toEqual([]);
});
}
9 changes: 6 additions & 3 deletions e2e/binance-orderbook/specs/cancel-current-symbol.pw.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,18 @@ test('user cancels seventy orders while chart drawings stay visible and save onc
await expect(page.getByRole('dialog')).toBeVisible();
await expect.poll(async () => (await readFixtureState(page)).showOrders).toBe(true);
await page.getByRole('button', { name: '确认' }).click();
// Then all seventy orders disappear and their drawing removals produce one final chart save.
// Then all seventy orders disappear and their drawing removals share one serialization across seventy callbacks.
await expect(page.getByText('撤单已完成')).toBeVisible();

const state = await readFixtureState(page);
expect(state.orders).toEqual([]);
expect(
state.events.filter((event) => event.type === 'chart-save-requested'),
).toHaveLength(70);
expect(state.events.filter((event) => event.type === 'chart-saved')).toHaveLength(1);
expect(state.events.filter((event) => event.type === 'chart-saved')).toHaveLength(70);
expect(state.events.filter((event) => event.type === 'chart-serialized')).toHaveLength(1);
expect(state.events.filter((event) => event.type === 'chart-saved').map(({ requestId }) => requestId))
.toEqual(state.events.filter((event) => event.type === 'chart-save-requested').map(({ requestId }) => requestId));
expect(
state.events
.filter((event) => event.type === 'chart-orders-checked')
Expand All @@ -156,7 +159,7 @@ test('user cancels seventy orders while chart drawings stay visible and save onc
const finalSaveRequestIndex = state.events.findLastIndex(
(event) => event.type === 'chart-save-requested',
);
const fullSaveIndex = state.events.findIndex((event) => event.type === 'chart-saved');
const fullSaveIndex = state.events.findIndex((event) => event.type === 'chart-serialized');
expect(fullSaveIndex).toBeGreaterThan(finalSaveRequestIndex);
await expectRestoredState(page, scenario);
const probe = await finishInteractionProbe(page);
Expand Down
Loading
Loading