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
31 changes: 22 additions & 9 deletions docs/binance-strategy29-bollinger-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ 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.0 with orderbook 2.7.199 or later, or use it alone.
Install Strategy29 0.2.1 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.
If the old script loads later, existing markers can remain because its private
save owner can block safe cleanup. This is not a supported compatibility mode;
Strategy29 never removes old-script or user drawings.

The orderbook runs in page context. Strategy29 0.2.0 runs in a Tampermonkey
The orderbook runs in page context. Strategy29 runs in a Tampermonkey
sandbox so its gateway secret remains in private userscript storage, and passes
`unsafeWindow` explicitly to the chart runtime. The orderbook registers a
synchronous boolean drawing-busy predicate under
Expand Down Expand Up @@ -56,13 +56,26 @@ Panel timestamps explicitly use `UTC+08` rather than inheriting the browser's
ambient timezone.

The browser polls status first and then consumes at most two event pages per
scheduled poll. A page may contain no matching events while still advancing the
global cursor; that progress is accepted. `cursor_expired` clears only the remote
event rows and resumes from the explicit server cursor. Route changes, page
hiding, disabling, and disposal abort the owned request. Every response checks
context ownership before changing the panel, so a late old-symbol response is
ignored. No extra recurring timer is installed: the existing one-second runtime
sample applies a five-second remote gate and keeps requests single-flight.
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
cursor from one SQLite snapshot. It does not scan retained global history to
fill the panel. Subsequent increments can contain no matching events while still
advancing that cursor. `cursor_expired` clears only remote rows and requests a new
latest snapshot. Rows are displayed and bounded by descending 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
source identity and reload before remote acceptance. Publication of either
component does not enable the observer, gateway, or notifications.

Route changes, page hiding, disabling, and disposal abort the owned request.
Hiding retains the current client, cursor, and panel rows; resume uses a fresh
request owner and continues increments. Route/settings changes retire the old
context. A permanent local runtime failure disposes the remote summary and removes
its panel; visibility or pageshow cannot revive it. Responses check their original abort signal before changing client state;
completion handlers also check request ownership, so an old request cannot publish
or clear the in-flight flag of resumed work. No extra recurring timer is installed:
the existing one-second runtime sample applies a five-second remote gate.

Transport failures and a temporarily unavailable database remain retryable
remote states. Authentication, request, JSON, and response-contract failures
Expand Down
3 changes: 3 additions & 0 deletions docs/userscript-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ are owned by `skills/userscript-release/SKILL.md`.
Run the full `npm test` suite before a release. A focused check can establish
that one layer passed; it does not replace a required build, generated-artifact
comparison, or live validation step.
The default suite runs all `test/unit/**/*.test.js` and `test/dom/**/*.test.js`
files. Scripts under `test/manual/` are explicit manual probes and previews;
they are not automatically executed by the test runner.

## Shared Contracts

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"check:binance-userscripts": "node --check scripts/binance-orderbook-trade.user.js && node --check scripts/binance-trading-data.user.js && node --check scripts/binance-coinmarketcap-data.user.js && node --check scripts/binance-strategy27-events.user.js && node --check scripts/binance-strategy29-bollinger.user.js",
"check:binance-orderbook-trade": "node --check scripts/binance-orderbook-trade.user.js",
"check:m3u8-downloader": "node --check scripts/m3u8-downloader.user.js",
"test": "node --test",
"test": "node --test 'test/unit/**/*.test.js' 'test/dom/**/*.test.js'",
"test:binance-orderbook-trade": "node --test test/unit/binance-orderbook-trade/*.test.js test/dom/binance-orderbook-trade/*.test.js",
"test:binance-orderbook-ui-toolchain": "node --test test/unit/binance-orderbook-trade/*.test.js test/dom/binance-orderbook-trade/*.test.js test/unit/binance-strategy29-bollinger/*.test.js test/dom/binance-strategy29-bollinger/*.test.js test/unit/binance-*.test.js",
"test:binance-strategy27-events": "node --test test/unit/binance-strategy27-events/*.test.js test/dom/binance-strategy27-events/*.test.js test/unit/userscript-metadata-icons.test.js test/unit/userscript-release-contract.test.js",
Expand Down
42 changes: 30 additions & 12 deletions scripts/binance-strategy29-bollinger.user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.0
// @version 0.2.1
// @author jackhai9
// @description Native Bollinger/SMA60 markers with an optional read-only cross-timeframe summary
// @match https://www.binance.com/*/futures/*
Expand Down Expand Up @@ -255,7 +255,10 @@
function buildEventsUrl(origin, canonicalSymbol, cursor) {
const url = new URL("/v1/strategy29/events", origin);
url.searchParams.set("symbol", canonicalSymbol);
if (cursor !== null) url.searchParams.set("cursor", String(cursor));
if (cursor === null) {
url.searchParams.set("mode", "latest");
url.searchParams.set("limit", "20");
} else url.searchParams.set("cursor", String(cursor));
return url.href;
}
function createStrategy29SummaryClient({
Expand All @@ -280,6 +283,7 @@
}
async function poll(signal) {
const statusResponse = await perform(`${origin}/v1/strategy29/status`, signal);
if (signal.aborted) throw signal.reason;
const statusBody = parseJsonResponse(statusResponse, "Strategy29 status");
if (statusResponse.status === 503) {
validateStrategy29GatewayError(statusBody, 503);
Expand All @@ -299,12 +303,13 @@
while (pages < maxPagesPerPoll) {
const requestedCursor = cursor;
const eventsResponse = await perform(buildEventsUrl(origin, canonicalSymbol, cursor), signal);
if (signal.aborted) throw signal.reason;
const eventsBody = parseJsonResponse(eventsResponse, "Strategy29 events");
pages += 1;
if (eventsResponse.status === 409) {
const error = validateStrategy29GatewayError(eventsBody, 409);
cursor = error.oldest_cursor;
onCursorReset(cursor);
cursor = null;
onCursorReset(error.oldest_cursor);
hasMore = true;
continue;
}
Expand All @@ -317,6 +322,9 @@
throw new Error(`Strategy29 events request failed with HTTP ${eventsResponse.status}`);
}
const page = validateStrategy29EventsResponse(eventsBody, 200);
if (requestedCursor === null && (page.has_more || page.events.length > 20)) {
throw new TypeError("Strategy29 latest snapshot must be complete and bounded to 20 events");
}
if (requestedCursor !== null && page.next_cursor < requestedCursor) {
throw new TypeError("Strategy29 event cursor moved backwards");
}
Expand Down Expand Up @@ -1807,7 +1815,7 @@
}
function renderEvents() {
events.replaceChildren();
const ordered = [...eventRecords.values()].sort((left, right) => right.detected_at_ms - left.detected_at_ms || right.sequence - left.sequence);
const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence);
for (const event of ordered) {
const row = element(document, "div", {
role: "remote-event",
Expand Down Expand Up @@ -1864,7 +1872,7 @@
addEvents(incoming, observedAtMs = null) {
assertLive();
for (const event of incoming) eventRecords.set(event.event_id, event);
const ordered = [...eventRecords.values()].sort((left, right) => right.detected_at_ms - left.detected_at_ms || right.sequence - left.sequence);
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)}`;
renderEvents();
Expand Down Expand Up @@ -2034,15 +2042,21 @@
}
}
function sample(nowMs = Date.now()) {
if (disposed) return;
if (disposed || view.document.hidden) return;
const context = synchronizeContext();
if (!context || !context.client || context.inFlight || context.failed || nowMs < context.nextPollAtMs) return;
if (context.abortController.signal.aborted) {
const AbortControllerConstructor = view.AbortController ?? AbortController;
context.abortController = new AbortControllerConstructor();
}
const controller = context.abortController;
const ownsRequest = () => isCurrent(context) && context.abortController === controller;
context.nextPollAtMs = nowMs + pollIntervalMs;
context.inFlight = true;
context.state = "connecting";
context.panel.setConnection("connecting", "Connecting to Strategy 29 gateway");
return context.client.poll(context.abortController.signal).then((result) => {
if (!isCurrent(context)) return;
return context.client.poll(controller.signal).then((result) => {
if (!ownsRequest()) return;
context.lastResult = result;
context.lastError = null;
context.state = result.state;
Expand All @@ -2054,7 +2068,7 @@
if (!presentation) throw new Error(`Strategy 29 remote state is invalid: ${result.state}`);
context.panel.setConnection(...presentation);
}).catch((error) => {
if (!isCurrent(context) || error?.name === "AbortError") return;
if (!ownsRequest() || error?.name === "AbortError") return;
context.lastError = error.message;
if (error instanceof Strategy29GatewayTransportError) {
context.state = "disconnected";
Expand All @@ -2066,7 +2080,7 @@
}
view.console.warn("[Strategy29 remote]", error.message);
}).finally(() => {
context.inFlight = false;
if (ownsRequest()) context.inFlight = false;
});
}
function restart() {
Expand Down Expand Up @@ -2096,7 +2110,10 @@
return Object.freeze({
sample,
pause() {
stopActive("Strategy 29 remote summary paused");
if (!active) return;
active.abortController.abort(abortError(view, "Strategy 29 remote summary paused"));
active.inFlight = false;
active.nextPollAtMs = 0;
},
restart,
dispose() {
Expand Down Expand Up @@ -2172,6 +2189,7 @@
function fail(message) {
failed = message;
pause();
remoteSummary?.dispose();
showFailure();
}
function sample() {
Expand Down
15 changes: 12 additions & 3 deletions src/binance-strategy29-bollinger/core/remote-summary-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ function assertConfiguration({ request, authSecret, canonicalSymbol, maxPagesPer
function buildEventsUrl(origin, canonicalSymbol, cursor) {
const url = new URL('/v1/strategy29/events', origin);
url.searchParams.set('symbol', canonicalSymbol);
if (cursor !== null) url.searchParams.set('cursor', String(cursor));
if (cursor === null) {
url.searchParams.set('mode', 'latest');
url.searchParams.set('limit', '20');
} else url.searchParams.set('cursor', String(cursor));
return url.href;
}

Expand Down Expand Up @@ -93,6 +96,8 @@ export function createStrategy29SummaryClient({

async function poll(signal) {
const statusResponse = await perform(`${origin}/v1/strategy29/status`, signal);
// Aborted host requests can still resolve; they must not mutate a resumed client.
if (signal.aborted) throw signal.reason;
const statusBody = parseJsonResponse(statusResponse, 'Strategy29 status');
if (statusResponse.status === 503) {
validateStrategy29GatewayError(statusBody, 503);
Expand All @@ -113,12 +118,13 @@ export function createStrategy29SummaryClient({
while (pages < maxPagesPerPoll) {
const requestedCursor = cursor;
const eventsResponse = await perform(buildEventsUrl(origin, canonicalSymbol, cursor), signal);
if (signal.aborted) throw signal.reason;
const eventsBody = parseJsonResponse(eventsResponse, 'Strategy29 events');
pages += 1;
if (eventsResponse.status === 409) {
const error = validateStrategy29GatewayError(eventsBody, 409);
cursor = error.oldest_cursor;
onCursorReset(cursor);
cursor = null;
onCursorReset(error.oldest_cursor);
hasMore = true;
continue;
}
Expand All @@ -131,6 +137,9 @@ export function createStrategy29SummaryClient({
throw new Error(`Strategy29 events request failed with HTTP ${eventsResponse.status}`);
}
const page = validateStrategy29EventsResponse(eventsBody, 200);
if (requestedCursor === null && (page.has_more || page.events.length > 20)) {
throw new TypeError('Strategy29 latest snapshot must be complete and bounded to 20 events');
}
if (requestedCursor !== null && page.next_cursor < requestedCursor) {
throw new TypeError('Strategy29 event cursor moved backwards');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve
}
function renderEvents() {
events.replaceChildren();
const ordered = [...eventRecords.values()].sort((left, right) => right.detected_at_ms - left.detected_at_ms || right.sequence - left.sequence);
const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence);
for (const event of ordered) {
const row = element(document, 'div', {
role: 'remote-event',
Expand Down Expand Up @@ -164,7 +164,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve
addEvents(incoming, observedAtMs = null) {
assertLive();
for (const event of incoming) eventRecords.set(event.event_id, event);
const ordered = [...eventRecords.values()].sort((left, right) => right.detected_at_ms - left.detected_at_ms || right.sequence - left.sequence);
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)}`;
renderEvents();
Expand Down
2 changes: 1 addition & 1 deletion src/binance-strategy29-bollinger/index.user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.0
// @version 0.2.1
// @author jackhai9
// @description Native Bollinger/SMA60 markers with an optional read-only cross-timeframe summary
// @match https://www.binance.com/*/futures/*
Expand Down
23 changes: 17 additions & 6 deletions src/binance-strategy29-bollinger/remote-summary.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,22 @@ export function createStrategy29RemoteSummary({
}

function sample(nowMs = Date.now()) {
if (disposed) return;
if (disposed || view.document.hidden) return;
const context = synchronizeContext();
if (!context || !context.client || context.inFlight || context.failed || nowMs < context.nextPollAtMs) return;
if (context.abortController.signal.aborted) {
const AbortControllerConstructor = view.AbortController ?? AbortController;
context.abortController = new AbortControllerConstructor();
}
const controller = context.abortController;
const ownsRequest = () => isCurrent(context) && context.abortController === controller;
context.nextPollAtMs = nowMs + pollIntervalMs;
context.inFlight = true;
context.state = 'connecting';
context.panel.setConnection('connecting', 'Connecting to Strategy 29 gateway');
return context.client.poll(context.abortController.signal)
return context.client.poll(controller.signal)
.then(result => {
if (!isCurrent(context)) return;
if (!ownsRequest()) return;
context.lastResult = result;
context.lastError = null;
context.state = result.state;
Expand All @@ -184,7 +190,7 @@ export function createStrategy29RemoteSummary({
context.panel.setConnection(...presentation);
})
.catch(error => {
if (!isCurrent(context) || error?.name === 'AbortError') return;
if (!ownsRequest() || error?.name === 'AbortError') return;
context.lastError = error.message;
if (error instanceof Strategy29GatewayTransportError) {
context.state = 'disconnected';
Expand All @@ -196,7 +202,7 @@ export function createStrategy29RemoteSummary({
}
view.console.warn('[Strategy29 remote]', error.message);
})
.finally(() => { context.inFlight = false; });
.finally(() => { if (ownsRequest()) context.inFlight = false; });
}

function restart() {
Expand Down Expand Up @@ -227,7 +233,12 @@ export function createStrategy29RemoteSummary({

return Object.freeze({
sample,
pause() { stopActive('Strategy 29 remote summary paused'); },
pause() {
if (!active) return;
active.abortController.abort(abortError(view, 'Strategy 29 remote summary paused'));
active.inFlight = false;
active.nextPollAtMs = 0;
Comment thread
jackhai9 marked this conversation as resolved.
},
restart,
dispose() {
if (disposed) return;
Expand Down
1 change: 1 addition & 0 deletions src/binance-strategy29-bollinger/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function installStrategy29(view, remoteAdapters = null) {
function fail(message) {
failed = message;
pause();
remoteSummary?.dispose();
showFailure();
}
function sample() {
Expand Down
Loading
Loading