@reefclaw/openclaw-plugin 0.1.23 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge/bridge.js +72 -5
- package/bridge/connector.js +14 -2
- package/bridge/gateway/heartbeat-cron.js +30 -7
- package/bridge/gateway/poller.d.ts +5 -0
- package/bridge/gateway/poller.js +9 -0
- package/bridge/provider.d.ts +15 -0
- package/bridge/providers/connector-update.d.ts +89 -0
- package/bridge/providers/connector-update.js +212 -0
- package/bridge/providers/emergency-commands.d.ts +36 -0
- package/bridge/providers/emergency-commands.js +91 -0
- package/bridge/providers/gateway.d.ts +26 -1
- package/bridge/providers/gateway.js +159 -8
- package/bridge/providers/mock.js +1 -0
- package/bridge/types.d.ts +1 -1
- package/bridge/types.js +5 -0
- package/ccxt/binance-private.js +2 -1
- package/ccxt/binance-public.js +6 -1
- package/config/agent-config-client.d.ts +5 -2
- package/config/agent-config-client.js +13 -0
- package/config/agent-config-poller.js +5 -1
- package/config/gate-store.d.ts +9 -0
- package/config/gate-store.js +17 -2
- package/config/plugin-config-io.js +24 -2
- package/http/keepalive-fetch.d.ts +5 -0
- package/http/keepalive-fetch.js +50 -0
- package/index.js +48 -6
- package/ingest/position-decisions-client.d.ts +6 -0
- package/ingest/position-decisions-client.js +27 -9
- package/live/approval-lifecycle.d.ts +10 -0
- package/live/approval-lifecycle.js +16 -2
- package/live/microstructure-assembler.js +11 -2
- package/live/proposal-decision-listener.d.ts +21 -0
- package/live/proposal-decision-listener.js +39 -0
- package/live/proposal-manager.d.ts +12 -0
- package/live/proposal-manager.js +47 -0
- package/live/stop-watcher.d.ts +16 -1
- package/live/stop-watcher.js +48 -8
- package/openclaw.plugin.json +1 -1
- package/package.json +38 -38
- package/persistence/state-manager.d.ts +7 -0
- package/persistence/state-manager.js +28 -1
- package/simulator/exchange-simulator.d.ts +22 -0
- package/simulator/exchange-simulator.js +74 -32
- package/tools/audit-bracket-protection.js +11 -7
- package/tools/create-order.js +49 -7
- package/tools/get-funding-context.js +6 -1
- package/tools/get-liquidation-levels.js +5 -1
- package/tools/get-liquidation-pulse.js +7 -1
- package/tools/get-market-intel.js +2 -1
- package/tools/get-relevant-learnings.js +20 -1
- package/tools/get-resting-liquidity.js +6 -1
- package/tools/get-wave9-status.js +17 -0
- package/tools/intel-api.d.ts +9 -0
- package/tools/intel-api.js +32 -1
- package/tools/record-position-reviews.js +2 -2
- package/tools/scan-pairs.js +20 -11
- package/types.d.ts +7 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Keep-alive HTTP for the plugin's intel/webapp clients.
|
|
2
|
+
//
|
|
3
|
+
// Node's built-in fetch closes idle sockets after undici's 4s default, and the
|
|
4
|
+
// gap between two agent tool calls is LLM think-time (seconds to tens of
|
|
5
|
+
// seconds) — so every intel/webapp call was paying a fresh TCP+TLS handshake
|
|
6
|
+
// (~2 RTTs) before any server work started. A dedicated undici Agent with a
|
|
7
|
+
// 60s idle timeout holds the connection across those gaps.
|
|
8
|
+
//
|
|
9
|
+
// undici loads via createRequire (same rule as CCXT — see
|
|
10
|
+
// docs/CLAUDE/plugin-integration.md) and the whole module FAILS OPEN to the
|
|
11
|
+
// global fetch: dist-only overlay deploys land on boxes whose node_modules
|
|
12
|
+
// predate this dependency, and a missing package must degrade to today's
|
|
13
|
+
// behaviour, never crash the connector.
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
// Captured at module load. When someone REPLACES globalThis.fetch later
|
|
16
|
+
// (vitest fetch mocks, tracing wrappers), keepAliveFetch honors the
|
|
17
|
+
// replacement instead of undici — otherwise every fetch-stubbing test (and
|
|
18
|
+
// any legitimate instrumentation) would be silently bypassed onto the real
|
|
19
|
+
// network.
|
|
20
|
+
const nativeFetch = globalThis.fetch;
|
|
21
|
+
let cached;
|
|
22
|
+
function build() {
|
|
23
|
+
try {
|
|
24
|
+
const req = createRequire(import.meta.url);
|
|
25
|
+
const undici = req('undici');
|
|
26
|
+
const dispatcher = new undici.Agent({
|
|
27
|
+
keepAliveTimeout: 60_000,
|
|
28
|
+
keepAliveMaxTimeout: 300_000,
|
|
29
|
+
connections: 16,
|
|
30
|
+
});
|
|
31
|
+
// undici's own fetch + Agent are used together: passing an npm-undici
|
|
32
|
+
// dispatcher to Node's built-in fetch can fail an instanceof check against
|
|
33
|
+
// the internal undici copy.
|
|
34
|
+
return (url, init) => undici.fetch(url, { ...init, dispatcher });
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return (url, init) => fetch(url, init);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Drop-in fetch with connection keep-alive; falls back to global fetch when
|
|
41
|
+
* undici is unavailable, and defers to globalThis.fetch whenever it has been
|
|
42
|
+
* replaced (mocks/instrumentation). */
|
|
43
|
+
export function keepAliveFetch(url, init) {
|
|
44
|
+
if (globalThis.fetch !== nativeFetch) {
|
|
45
|
+
return globalThis.fetch(url, init);
|
|
46
|
+
}
|
|
47
|
+
if (!cached)
|
|
48
|
+
cached = build();
|
|
49
|
+
return cached(url, init);
|
|
50
|
+
}
|
package/index.js
CHANGED
|
@@ -989,7 +989,10 @@ const paperTradingPlugin = {
|
|
|
989
989
|
// gateway process must reload it before serving HTTP API calls.
|
|
990
990
|
const reloadState = () => {
|
|
991
991
|
try {
|
|
992
|
-
|
|
992
|
+
// Skips the full read+parse (and replaceState) when the on-disk file
|
|
993
|
+
// hasn't changed since the last load — this runs on every paper-mode
|
|
994
|
+
// tool call and the file grows with trade history.
|
|
995
|
+
const fresh = stateManager.loadSyncIfChanged();
|
|
993
996
|
if (fresh) {
|
|
994
997
|
simulator.replaceState(fresh);
|
|
995
998
|
}
|
|
@@ -1293,11 +1296,32 @@ const paperTradingPlugin = {
|
|
|
1293
1296
|
// rows while collecting real ones). See docs/APPROVAL_MODE_DESIGN.md §12.
|
|
1294
1297
|
const approvalShadowEnabled = (process.env.APPROVAL_SHADOW_MODE ?? '').trim() === '1';
|
|
1295
1298
|
const resolveApprovalMode = () => {
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
+
// Precedence (config-service slice 4, mirrors loadExitGateMode /
|
|
1300
|
+
// loadPositionReviewMode):
|
|
1301
|
+
//
|
|
1302
|
+
// central (agent_config.gates.approvalMode) → plugin-config.json →
|
|
1303
|
+
// APPROVAL_SHADOW_MODE env → 'off'
|
|
1304
|
+
//
|
|
1305
|
+
// Central is enum-validated plugin-side (agent-config-client
|
|
1306
|
+
// validateGates) and can only ever say 'off' | 'per_trade' — it cannot
|
|
1307
|
+
// enable shadow telemetry and cannot weaken the safety floor. Kill-switch
|
|
1308
|
+
// RC_CENTRAL_GATES=off makes the store report null and the file rules
|
|
1309
|
+
// again. Read per call, so a dashboard flip applies within one poll
|
|
1310
|
+
// (~60 s) with no restart.
|
|
1311
|
+
const central = gateStore.getApprovalMode();
|
|
1312
|
+
if (central === 'per_trade')
|
|
1313
|
+
return 'per_trade';
|
|
1314
|
+
if (central !== 'off') {
|
|
1315
|
+
// No central value — fall back to the local file.
|
|
1316
|
+
try {
|
|
1317
|
+
if (readPluginConfig().approval?.mode === 'per_trade')
|
|
1318
|
+
return 'per_trade';
|
|
1319
|
+
}
|
|
1320
|
+
catch { /* config unreadable — env-only fallback below */ }
|
|
1299
1321
|
}
|
|
1300
|
-
|
|
1322
|
+
// Central 'off' still permits local shadow telemetry: shadow does not
|
|
1323
|
+
// change trading behaviour (the order fires either way), it only writes
|
|
1324
|
+
// an extra row, and the env flag is the operator's own local choice.
|
|
1301
1325
|
return approvalShadowEnabled ? 'shadow' : 'off';
|
|
1302
1326
|
};
|
|
1303
1327
|
// Proposal manager + listener credentials: built whenever ingest
|
|
@@ -1327,8 +1351,18 @@ const paperTradingPlugin = {
|
|
|
1327
1351
|
logger.info(TAG, `Approval wiring active (mode=${bootApprovalMode}) → ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
|
|
1328
1352
|
}
|
|
1329
1353
|
}
|
|
1354
|
+
else if (bootApprovalMode === 'per_trade') {
|
|
1355
|
+
// FAIL-CLOSED state: create_order will REFUSE every new entry until the
|
|
1356
|
+
// credentials are restored or approval.mode is set back to off. Loud at
|
|
1357
|
+
// boot AND at each refusal (create-order.ts) — a boot-only signal is how
|
|
1358
|
+
// this used to go unnoticed while orders fired without the gate.
|
|
1359
|
+
logger.error(TAG, `approval mode=per_trade but ingest token / REEFCLAW_USER_ID missing — proposals CANNOT ` +
|
|
1360
|
+
`reach the operator, so create_order will REFUSE every new entry (fail-closed). ` +
|
|
1361
|
+
`Restore the plugin connectionToken/WEBAPP_INGEST_TOKEN + REEFCLAW_USER_ID, or set ` +
|
|
1362
|
+
`approval.mode=off. Exits, stops, brackets and operator controls are unaffected.`);
|
|
1363
|
+
}
|
|
1330
1364
|
else if (bootApprovalMode !== 'off') {
|
|
1331
|
-
logger.warn(TAG, `approval mode=${bootApprovalMode} but ingest token / REEFCLAW_USER_ID missing —
|
|
1365
|
+
logger.warn(TAG, `approval mode=${bootApprovalMode} but ingest token / REEFCLAW_USER_ID missing — shadow telemetry disabled (orders fire directly, as in off mode)`);
|
|
1332
1366
|
}
|
|
1333
1367
|
}
|
|
1334
1368
|
// ---- Create exchange adapter based on trading mode ----
|
|
@@ -1887,6 +1921,14 @@ const paperTradingPlugin = {
|
|
|
1887
1921
|
pollIntervalMs: approvalCfg?.pollIntervalMs ?? 3_000,
|
|
1888
1922
|
});
|
|
1889
1923
|
},
|
|
1924
|
+
// The approval path just went away (mode flipped off, or live→PAPER).
|
|
1925
|
+
// Anything still pending is now un-fireable but still shows an Approve
|
|
1926
|
+
// button — cancel it rather than leave the operator a dead control.
|
|
1927
|
+
onApprovalPathDisabled: async () => {
|
|
1928
|
+
if (!proposalManagerCtx)
|
|
1929
|
+
return;
|
|
1930
|
+
await proposalManagerCtx.manager.cancelAll(proposalManagerCtx.userId, 'mode_disabled');
|
|
1931
|
+
},
|
|
1890
1932
|
});
|
|
1891
1933
|
runtime.setOnAdapterSwapped((a) => { void approvalLifecycle.onAdapterSwapped(a); });
|
|
1892
1934
|
// Boot application — the same path every later swap takes.
|
|
@@ -322,6 +322,12 @@ export declare class PositionDecisionsClient {
|
|
|
322
322
|
private fireAndForget;
|
|
323
323
|
private run;
|
|
324
324
|
private runReturning;
|
|
325
|
+
/** Decision-path GET budget. Every read caller degrades to empty/null on
|
|
326
|
+
* failure, so burning the write-grade budget (4 × 10s + backoff ≈ 42s
|
|
327
|
+
* worst case) of the agent's turn to reach an optional result is pure
|
|
328
|
+
* heartbeat latency. Background reads (reconcile sweep) override per call. */
|
|
329
|
+
private static readonly READ_MAX_ATTEMPTS;
|
|
330
|
+
private static readonly READ_DEADLINE_MS;
|
|
325
331
|
private runGetReturning;
|
|
326
332
|
private sleepBackoff;
|
|
327
333
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// - postEntry/Review/Close are fire-and-forget (don't block the WS hot path).
|
|
13
13
|
//
|
|
14
14
|
// All four routes accept the same auth: Bearer + X-User-Id headers.
|
|
15
|
+
import { keepAliveFetch } from '../http/keepalive-fetch.js';
|
|
15
16
|
import { logger, formatError } from '../logger.js';
|
|
16
17
|
const TAG = 'position-decisions-client';
|
|
17
18
|
export class PositionDecisionsClient {
|
|
@@ -25,7 +26,7 @@ export class PositionDecisionsClient {
|
|
|
25
26
|
this.opts = {
|
|
26
27
|
baseUrl: options.baseUrl.replace(/\/+$/, ''),
|
|
27
28
|
ingestToken: options.ingestToken,
|
|
28
|
-
fetchImpl: options.fetchImpl ??
|
|
29
|
+
fetchImpl: options.fetchImpl ?? keepAliveFetch,
|
|
29
30
|
requestTimeoutMs: options.requestTimeoutMs ?? 10_000,
|
|
30
31
|
maxAttempts: options.maxAttempts ?? 4,
|
|
31
32
|
baseBackoffMs: options.baseBackoffMs ?? 250,
|
|
@@ -65,7 +66,12 @@ export class PositionDecisionsClient {
|
|
|
65
66
|
qs.set('mode', mode);
|
|
66
67
|
if (exchange)
|
|
67
68
|
qs.set('exchange', exchange);
|
|
68
|
-
return this.runGetReturning(userId, `/api/internal/positions?${qs.toString()}
|
|
69
|
+
return this.runGetReturning(userId, `/api/internal/positions?${qs.toString()}`, {
|
|
70
|
+
// Reconcile-sweep read, NOT on the agent decision path — keep the
|
|
71
|
+
// write-grade retry budget: a null here skips a whole reconcile pass.
|
|
72
|
+
maxAttempts: this.opts.maxAttempts,
|
|
73
|
+
deadlineMs: Number.POSITIVE_INFINITY,
|
|
74
|
+
});
|
|
69
75
|
}
|
|
70
76
|
/** Read endpoint for the Phase 1 self-reflection feature. Awaited.
|
|
71
77
|
* Returns null on terminal/retry-exhausted failure (caller logs + degrades). */
|
|
@@ -246,12 +252,24 @@ export class PositionDecisionsClient {
|
|
|
246
252
|
logger.error(TAG, `POST ${url} dropped after ${this.opts.maxAttempts} attempts. userId=${userId}.`);
|
|
247
253
|
return null;
|
|
248
254
|
}
|
|
249
|
-
|
|
255
|
+
/** Decision-path GET budget. Every read caller degrades to empty/null on
|
|
256
|
+
* failure, so burning the write-grade budget (4 × 10s + backoff ≈ 42s
|
|
257
|
+
* worst case) of the agent's turn to reach an optional result is pure
|
|
258
|
+
* heartbeat latency. Background reads (reconcile sweep) override per call. */
|
|
259
|
+
static READ_MAX_ATTEMPTS = 2;
|
|
260
|
+
static READ_DEADLINE_MS = 8_000;
|
|
261
|
+
async runGetReturning(userId, path, budget) {
|
|
250
262
|
const url = `${this.opts.baseUrl}${path}`;
|
|
251
|
-
|
|
263
|
+
const maxAttempts = budget?.maxAttempts ??
|
|
264
|
+
Math.min(this.opts.maxAttempts, PositionDecisionsClient.READ_MAX_ATTEMPTS);
|
|
265
|
+
const deadlineAt = Date.now() + (budget?.deadlineMs ?? PositionDecisionsClient.READ_DEADLINE_MS);
|
|
266
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
267
|
+
const remainingMs = deadlineAt - Date.now();
|
|
268
|
+
if (remainingMs <= 0)
|
|
269
|
+
break;
|
|
252
270
|
try {
|
|
253
271
|
const ac = new AbortController();
|
|
254
|
-
const tid = setTimeout(() => ac.abort(), this.opts.requestTimeoutMs);
|
|
272
|
+
const tid = setTimeout(() => ac.abort(), Math.min(this.opts.requestTimeoutMs, remainingMs));
|
|
255
273
|
let res;
|
|
256
274
|
try {
|
|
257
275
|
res = await this.opts.fetchImpl(url, {
|
|
@@ -285,16 +303,16 @@ export class PositionDecisionsClient {
|
|
|
285
303
|
logger.warn(TAG, `GET ${url} → ${res.status} (terminal, dropped). userId=${userId} body=${errBody.slice(0, 200)}`);
|
|
286
304
|
return null;
|
|
287
305
|
}
|
|
288
|
-
logger.warn(TAG, `GET ${url} → ${res.status} (attempt ${attempt}/${
|
|
306
|
+
logger.warn(TAG, `GET ${url} → ${res.status} (attempt ${attempt}/${maxAttempts}).`);
|
|
289
307
|
}
|
|
290
308
|
catch (err) {
|
|
291
|
-
logger.warn(TAG, `GET ${url} threw (attempt ${attempt}/${
|
|
309
|
+
logger.warn(TAG, `GET ${url} threw (attempt ${attempt}/${maxAttempts}): ${formatError(err)}`);
|
|
292
310
|
}
|
|
293
|
-
if (attempt <
|
|
311
|
+
if (attempt < maxAttempts && Date.now() < deadlineAt) {
|
|
294
312
|
await this.sleepBackoff(attempt);
|
|
295
313
|
}
|
|
296
314
|
}
|
|
297
|
-
logger.error(TAG, `GET ${url} failed after ${
|
|
315
|
+
logger.error(TAG, `GET ${url} failed after ${maxAttempts} attempt(s)/deadline. userId=${userId}.`);
|
|
298
316
|
return null;
|
|
299
317
|
}
|
|
300
318
|
async sleepBackoff(attempt) {
|
|
@@ -14,6 +14,16 @@ export interface ApprovalLifecycleDeps {
|
|
|
14
14
|
hasWiring: () => boolean;
|
|
15
15
|
/** Build a listener bound to THIS adapter. Called only when starting. */
|
|
16
16
|
buildListener: (adapter: IExchangeAdapter) => StartableListener;
|
|
17
|
+
/** Called when a RUNNING listener was torn down and no replacement started —
|
|
18
|
+
* i.e. the approval path was deliberately disabled (mode flipped away from
|
|
19
|
+
* per_trade, or the live adapter went away). Any proposal still pending is
|
|
20
|
+
* now un-fireable but still approvable on the dashboard, so the caller
|
|
21
|
+
* cancels them (design doc §10 row 12).
|
|
22
|
+
*
|
|
23
|
+
* Deliberately NOT called from stop() — a shutdown drain is a restart, not a
|
|
24
|
+
* disable, and cancelling the operator's live proposals on every plugin
|
|
25
|
+
* restart would be both wrong and obnoxious. */
|
|
26
|
+
onApprovalPathDisabled?: () => void | Promise<void>;
|
|
17
27
|
}
|
|
18
28
|
export declare class ApprovalListenerLifecycle {
|
|
19
29
|
private readonly deps;
|
|
@@ -52,14 +52,28 @@ export class ApprovalListenerLifecycle {
|
|
|
52
52
|
return this.chain;
|
|
53
53
|
}
|
|
54
54
|
async apply(adapter) {
|
|
55
|
+
// A listener was running before this swap? Then if we end up NOT starting a
|
|
56
|
+
// replacement, whatever is still pending has been orphaned.
|
|
57
|
+
const hadListener = this.listener !== undefined;
|
|
55
58
|
// Always tear down the previous listener first — it is bound to the OLD
|
|
56
59
|
// adapter and must never fire through it again.
|
|
57
60
|
await this.stopCurrent();
|
|
61
|
+
const orphaned = async (why) => {
|
|
62
|
+
if (!hadListener || !this.deps.onApprovalPathDisabled)
|
|
63
|
+
return;
|
|
64
|
+
logger.info(TAG, `approval path disabled (${why}) — cancelling any pending proposals`);
|
|
65
|
+
try {
|
|
66
|
+
await this.deps.onApprovalPathDisabled();
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
logger.warn(TAG, `pending-proposal cancel failed: ${formatError(err)}`);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
58
72
|
if (!adapter.isLive)
|
|
59
|
-
return;
|
|
73
|
+
return orphaned('adapter is no longer live');
|
|
60
74
|
const mode = this.deps.resolveApprovalMode();
|
|
61
75
|
if (mode !== 'per_trade')
|
|
62
|
-
return;
|
|
76
|
+
return orphaned(`approval.mode=${mode}`);
|
|
63
77
|
if (!this.deps.hasWiring()) {
|
|
64
78
|
logger.warn(TAG, "approval.mode='per_trade' but ingest credentials/proposal manager missing — listener NOT started; approvals will not fire");
|
|
65
79
|
return;
|
|
@@ -66,9 +66,18 @@ export class IntelMicrostructureAssembler {
|
|
|
66
66
|
return cached.block;
|
|
67
67
|
// Map canonical "BTC/USDT" → intel "BTCUSDT".
|
|
68
68
|
const intelSymbol = canonical.replace('/', '');
|
|
69
|
+
// cacheTtlMs mirrors the agent-facing tools (get_resting_liquidity /
|
|
70
|
+
// get_liquidation_pulse) so the assembler and the agent's own calls share
|
|
71
|
+
// one round-trip per heartbeat instead of duplicate-fetching.
|
|
69
72
|
const [restingResult, pulseResult] = await Promise.allSettled([
|
|
70
|
-
fetchIntelApi(`/api/resting-liquidity/${enc(intelSymbol)}`, this.intelDeps
|
|
71
|
-
|
|
73
|
+
fetchIntelApi(`/api/resting-liquidity/${enc(intelSymbol)}`, this.intelDeps, {
|
|
74
|
+
cacheTtlMs: 15_000,
|
|
75
|
+
timeoutMs: 10_000,
|
|
76
|
+
}),
|
|
77
|
+
fetchIntelApi(`/api/liquidation-pulse?symbol=${enc(intelSymbol)}&window_seconds=60`, this.intelDeps, {
|
|
78
|
+
cacheTtlMs: 10_000,
|
|
79
|
+
timeoutMs: 10_000,
|
|
80
|
+
}),
|
|
72
81
|
]);
|
|
73
82
|
const block = { symbol: canonical, updatedAt: ts };
|
|
74
83
|
let anySignal = false;
|
|
@@ -44,6 +44,9 @@ export interface ProposalDecisionListenerHealth {
|
|
|
44
44
|
firesFailedTrading: number;
|
|
45
45
|
patchFailures: number;
|
|
46
46
|
pollFailures: number;
|
|
47
|
+
/** Distinct proposals seen holding a claim with no result — each one is an
|
|
48
|
+
* operator-actionable reconciliation, not a retryable error. */
|
|
49
|
+
strandedObserved: number;
|
|
47
50
|
lastTickAt: string | null;
|
|
48
51
|
}
|
|
49
52
|
export declare class ProposalDecisionListener {
|
|
@@ -56,6 +59,11 @@ export declare class ProposalDecisionListener {
|
|
|
56
59
|
* that finds ≥1 pending. */
|
|
57
60
|
private currentIntervalMs;
|
|
58
61
|
private health;
|
|
62
|
+
/** Stranded rows already reported by THIS process — the poll re-reports them
|
|
63
|
+
* every tick (they never clear themselves), so dedupe the ERROR log. A
|
|
64
|
+
* restart deliberately re-reports: if it's still stranded, it still needs
|
|
65
|
+
* reconciling. */
|
|
66
|
+
private readonly strandedReported;
|
|
59
67
|
/** Claim tokens are process-local by design. A new process must never steal
|
|
60
68
|
* an old process's durable claim, because it cannot know whether the
|
|
61
69
|
* exchange accepted an order just before the crash. */
|
|
@@ -82,6 +90,19 @@ export declare class ProposalDecisionListener {
|
|
|
82
90
|
private scheduleNextTick;
|
|
83
91
|
private runTick;
|
|
84
92
|
private tick;
|
|
93
|
+
/** Report claims that outlived any plausible fire attempt.
|
|
94
|
+
*
|
|
95
|
+
* These are proposals a listener (usually a previous incarnation of this
|
|
96
|
+
* process) claimed and then died before reporting an outcome. Claims never
|
|
97
|
+
* expire and the work queue skips claimed rows, so nothing retries them —
|
|
98
|
+
* before this, the operator's approval simply produced no position and no
|
|
99
|
+
* message. We can't safely re-fire (the order may already be live), so the
|
|
100
|
+
* actionable thing is the deterministic client-order id: it answers "did
|
|
101
|
+
* this ever reach the exchange?" definitively.
|
|
102
|
+
*
|
|
103
|
+
* Logged at ERROR once per row per process so a restart re-surfaces it,
|
|
104
|
+
* without spamming every 3 s tick. */
|
|
105
|
+
private reportStranded;
|
|
85
106
|
private fetchPending;
|
|
86
107
|
private claimPending;
|
|
87
108
|
private forgetClaim;
|
|
@@ -51,8 +51,14 @@ export class ProposalDecisionListener {
|
|
|
51
51
|
firesFailedTrading: 0,
|
|
52
52
|
patchFailures: 0,
|
|
53
53
|
pollFailures: 0,
|
|
54
|
+
strandedObserved: 0,
|
|
54
55
|
lastTickAt: null,
|
|
55
56
|
};
|
|
57
|
+
/** Stranded rows already reported by THIS process — the poll re-reports them
|
|
58
|
+
* every tick (they never clear themselves), so dedupe the ERROR log. A
|
|
59
|
+
* restart deliberately re-reports: if it's still stranded, it still needs
|
|
60
|
+
* reconciling. */
|
|
61
|
+
strandedReported = new Set();
|
|
56
62
|
/** Claim tokens are process-local by design. A new process must never steal
|
|
57
63
|
* an old process's durable claim, because it cannot know whether the
|
|
58
64
|
* exchange accepted an order just before the crash. */
|
|
@@ -171,6 +177,36 @@ export class ProposalDecisionListener {
|
|
|
171
177
|
}
|
|
172
178
|
}
|
|
173
179
|
}
|
|
180
|
+
/** Report claims that outlived any plausible fire attempt.
|
|
181
|
+
*
|
|
182
|
+
* These are proposals a listener (usually a previous incarnation of this
|
|
183
|
+
* process) claimed and then died before reporting an outcome. Claims never
|
|
184
|
+
* expire and the work queue skips claimed rows, so nothing retries them —
|
|
185
|
+
* before this, the operator's approval simply produced no position and no
|
|
186
|
+
* message. We can't safely re-fire (the order may already be live), so the
|
|
187
|
+
* actionable thing is the deterministic client-order id: it answers "did
|
|
188
|
+
* this ever reach the exchange?" definitively.
|
|
189
|
+
*
|
|
190
|
+
* Logged at ERROR once per row per process so a restart re-surfaces it,
|
|
191
|
+
* without spamming every 3 s tick. */
|
|
192
|
+
reportStranded(rows) {
|
|
193
|
+
for (const row of rows) {
|
|
194
|
+
if (this.strandedReported.has(row.id))
|
|
195
|
+
continue;
|
|
196
|
+
this.strandedReported.add(row.id);
|
|
197
|
+
this.health.strandedObserved++;
|
|
198
|
+
let cid = '(unavailable)';
|
|
199
|
+
try {
|
|
200
|
+
cid = proposalEntryClientOrderId(this.opts.adapter, row.proposalUuid);
|
|
201
|
+
}
|
|
202
|
+
catch { /* CID derivation is best-effort — the report still goes out */ }
|
|
203
|
+
logger.error(TAG, `STRANDED approved proposal ${row.id} (${row.symbol} ${row.side}) — claimed at ` +
|
|
204
|
+
`${row.claimedAt ?? 'unknown'} and never reported a result, so no listener will ` +
|
|
205
|
+
`retry it. The order may or may not have reached the exchange. Reconcile by ` +
|
|
206
|
+
`clientOrderId=${cid}: if present on the exchange the entry is live (attach/verify ` +
|
|
207
|
+
`its brackets); if absent, nothing was submitted and the proposal can be re-made.`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
174
210
|
async fetchPending() {
|
|
175
211
|
const url = `${this.opts.baseUrl}/api/internal/proposed_orders/pending-decisions`;
|
|
176
212
|
try {
|
|
@@ -197,6 +233,9 @@ export class ProposalDecisionListener {
|
|
|
197
233
|
return [];
|
|
198
234
|
}
|
|
199
235
|
const body = await res.json();
|
|
236
|
+
if (Array.isArray(body.stranded) && body.stranded.length > 0) {
|
|
237
|
+
this.reportStranded(body.stranded);
|
|
238
|
+
}
|
|
200
239
|
return Array.isArray(body.proposals) ? body.proposals : [];
|
|
201
240
|
}
|
|
202
241
|
catch (err) {
|
|
@@ -68,6 +68,18 @@ export declare class ProposalManager {
|
|
|
68
68
|
* + hard expiry synchronously; the actual POST runs in the background.
|
|
69
69
|
* Caller may correlate via the returned proposalUuid before the post lands. */
|
|
70
70
|
propose(userId: string, req: ProposalRequest): ProposeResult;
|
|
71
|
+
/** Cancel every outstanding proposal for this tenant because the approval
|
|
72
|
+
* path is being disabled (mode flipped off, or the live adapter went away).
|
|
73
|
+
*
|
|
74
|
+
* Without this, flipping `per_trade` → `off` — or a live→PAPER swap — leaves
|
|
75
|
+
* rows the operator can still see and approve while no listener exists to
|
|
76
|
+
* fire them: the card sits there, Approve "works", and nothing ever happens
|
|
77
|
+
* (design doc §10 row 12). The webapp refuses to cancel rows a listener has
|
|
78
|
+
* already claimed, so this can never disown an order that may be live.
|
|
79
|
+
*
|
|
80
|
+
* Best-effort by design: awaited by the caller only for logging. A failure
|
|
81
|
+
* is bounded by hard expiry (≤4 min) and must never block an adapter swap. */
|
|
82
|
+
cancelAll(userId: string, reason: 'mode_disabled'): Promise<void>;
|
|
71
83
|
/** Await all in-flight POSTs. Used at shutdown so we don't lose proposals. */
|
|
72
84
|
drain(): Promise<void>;
|
|
73
85
|
getHealth(): ProposalManagerHealth;
|
package/live/proposal-manager.js
CHANGED
|
@@ -50,6 +50,53 @@ export class ProposalManager {
|
|
|
50
50
|
this.inFlight.add(promise);
|
|
51
51
|
return { proposalUuid, setupBucket, hardExpiresAt };
|
|
52
52
|
}
|
|
53
|
+
/** Cancel every outstanding proposal for this tenant because the approval
|
|
54
|
+
* path is being disabled (mode flipped off, or the live adapter went away).
|
|
55
|
+
*
|
|
56
|
+
* Without this, flipping `per_trade` → `off` — or a live→PAPER swap — leaves
|
|
57
|
+
* rows the operator can still see and approve while no listener exists to
|
|
58
|
+
* fire them: the card sits there, Approve "works", and nothing ever happens
|
|
59
|
+
* (design doc §10 row 12). The webapp refuses to cancel rows a listener has
|
|
60
|
+
* already claimed, so this can never disown an order that may be live.
|
|
61
|
+
*
|
|
62
|
+
* Best-effort by design: awaited by the caller only for logging. A failure
|
|
63
|
+
* is bounded by hard expiry (≤4 min) and must never block an adapter swap. */
|
|
64
|
+
async cancelAll(userId, reason) {
|
|
65
|
+
const url = `${this.opts.baseUrl}/api/internal/proposed_orders/cancel-all`;
|
|
66
|
+
try {
|
|
67
|
+
const ac = new AbortController();
|
|
68
|
+
const tid = setTimeout(() => ac.abort(), this.opts.requestTimeoutMs);
|
|
69
|
+
let res;
|
|
70
|
+
try {
|
|
71
|
+
res = await this.opts.fetchImpl(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'content-type': 'application/json',
|
|
75
|
+
authorization: `Bearer ${this.opts.ingestToken}`,
|
|
76
|
+
'x-user-id': userId,
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify({ reason }),
|
|
79
|
+
signal: ac.signal,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
clearTimeout(tid);
|
|
84
|
+
}
|
|
85
|
+
if (res.status < 200 || res.status >= 300) {
|
|
86
|
+
logger.warn(TAG, `cancel-all (${reason}) returned HTTP ${res.status}`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const body = (await res.json().catch(() => ({})));
|
|
90
|
+
const cancelled = typeof body.cancelled === 'number' ? body.cancelled : 0;
|
|
91
|
+
const inFlight = Array.isArray(body.inFlight) ? body.inFlight.length : 0;
|
|
92
|
+
if (cancelled > 0 || inFlight > 0) {
|
|
93
|
+
logger.info(TAG, `cancel-all (${reason}): cancelled=${cancelled} inFlight-uncancellable=${inFlight}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
logger.warn(TAG, `cancel-all (${reason}) failed: ${formatError(err)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
53
100
|
/** Await all in-flight POSTs. Used at shutdown so we don't lose proposals. */
|
|
54
101
|
async drain() {
|
|
55
102
|
if (this.inFlight.size === 0)
|
package/live/stop-watcher.d.ts
CHANGED
|
@@ -37,8 +37,20 @@ export interface Wave9StopCloseLifecycle {
|
|
|
37
37
|
resolveCandidateId(position: CcxtPosition): Promise<string | undefined> | string | undefined;
|
|
38
38
|
settleAfterClose(candidateId: string, symbol: string): Promise<Wave9StopCloseOutcome>;
|
|
39
39
|
}
|
|
40
|
+
/** True when this position carries a protective stop we are supposed to be
|
|
41
|
+
* enforcing. Used to decide whether an unusable mark is an ALARM (a stop we
|
|
42
|
+
* cannot evaluate) or simply uninteresting (no stop set). */
|
|
43
|
+
export declare function hasEnforceableStop(position: CcxtPosition): boolean;
|
|
44
|
+
/** True when the mark cannot be trusted for a protective decision — either the
|
|
45
|
+
* venue gave us nothing usable, or (paper) it is the fabricated entryPrice
|
|
46
|
+
* fallback. Both mean "we do not know where price is", NOT "price is fine". */
|
|
47
|
+
export declare function isMarkUnusable(position: CcxtPosition): boolean;
|
|
40
48
|
/** Decide whether a position has crossed its stop.
|
|
41
|
-
* Exported for direct unit-testing without spinning up a watcher loop.
|
|
49
|
+
* Exported for direct unit-testing without spinning up a watcher loop.
|
|
50
|
+
*
|
|
51
|
+
* NOTE: a `false` here means "not breached OR not knowable". Callers that care
|
|
52
|
+
* about the difference must check isMarkUnusable() — see tick(), which alarms
|
|
53
|
+
* on an unknowable mark rather than treating it as safe. */
|
|
42
54
|
export declare function isStopBreached(position: CcxtPosition): boolean;
|
|
43
55
|
export declare class PositionWatcher extends EventEmitter {
|
|
44
56
|
private interval;
|
|
@@ -53,6 +65,9 @@ export declare class PositionWatcher extends EventEmitter {
|
|
|
53
65
|
/** Symbols we've already logged a breach for this session, to avoid spamming
|
|
54
66
|
* the log every tick while the close is in flight. */
|
|
55
67
|
private notifiedBreach;
|
|
68
|
+
/** Symbols already alarmed for an unusable mark, so the ERROR fires once per
|
|
69
|
+
* episode rather than every tick. Cleared as soon as a usable mark returns. */
|
|
70
|
+
private notifiedUnusableMark;
|
|
56
71
|
constructor(adapter: IExchangeAdapter, intervalMs?: number, operationLock?: TradingOperationLock);
|
|
57
72
|
/** Configure this after the durable execution ledger is ready. Runtime
|
|
58
73
|
* lifecycle hooks reapply it to every watcher after reconnect. */
|
package/live/stop-watcher.js
CHANGED
|
@@ -28,18 +28,35 @@ const TAG = 'stop-watcher';
|
|
|
28
28
|
// revert with zero deploy via plugin-config.json `stopWatcher.intervalMs`.
|
|
29
29
|
// See docs/EFFICIENCY_QUICK_WINS_PLAN.md + the Binance ban-gate context.
|
|
30
30
|
export const DEFAULT_INTERVAL_MS = 10_000;
|
|
31
|
+
/** True when this position carries a protective stop we are supposed to be
|
|
32
|
+
* enforcing. Used to decide whether an unusable mark is an ALARM (a stop we
|
|
33
|
+
* cannot evaluate) or simply uninteresting (no stop set). */
|
|
34
|
+
export function hasEnforceableStop(position) {
|
|
35
|
+
const stop = position.stopPrice;
|
|
36
|
+
return stop !== undefined && stop !== null && Number.isFinite(stop) && stop > 0;
|
|
37
|
+
}
|
|
38
|
+
/** True when the mark cannot be trusted for a protective decision — either the
|
|
39
|
+
* venue gave us nothing usable, or (paper) it is the fabricated entryPrice
|
|
40
|
+
* fallback. Both mean "we do not know where price is", NOT "price is fine". */
|
|
41
|
+
export function isMarkUnusable(position) {
|
|
42
|
+
if (position.markPriceStale === true)
|
|
43
|
+
return true;
|
|
44
|
+
const mark = position.markPrice;
|
|
45
|
+
return !Number.isFinite(mark) || mark <= 0;
|
|
46
|
+
}
|
|
31
47
|
/** Decide whether a position has crossed its stop.
|
|
32
|
-
* Exported for direct unit-testing without spinning up a watcher loop.
|
|
48
|
+
* Exported for direct unit-testing without spinning up a watcher loop.
|
|
49
|
+
*
|
|
50
|
+
* NOTE: a `false` here means "not breached OR not knowable". Callers that care
|
|
51
|
+
* about the difference must check isMarkUnusable() — see tick(), which alarms
|
|
52
|
+
* on an unknowable mark rather than treating it as safe. */
|
|
33
53
|
export function isStopBreached(position) {
|
|
34
|
-
|
|
35
|
-
if (stop === undefined || stop === null || !Number.isFinite(stop) || stop <= 0) {
|
|
54
|
+
if (!hasEnforceableStop(position))
|
|
36
55
|
return false;
|
|
37
|
-
|
|
38
|
-
const mark = position.markPrice;
|
|
39
|
-
if (!Number.isFinite(mark) || mark <= 0) {
|
|
56
|
+
if (isMarkUnusable(position))
|
|
40
57
|
return false;
|
|
41
|
-
|
|
42
|
-
return position.side === 'long' ?
|
|
58
|
+
const stop = position.stopPrice;
|
|
59
|
+
return position.side === 'long' ? position.markPrice <= stop : position.markPrice >= stop;
|
|
43
60
|
}
|
|
44
61
|
export class PositionWatcher extends EventEmitter {
|
|
45
62
|
interval = null;
|
|
@@ -54,6 +71,9 @@ export class PositionWatcher extends EventEmitter {
|
|
|
54
71
|
/** Symbols we've already logged a breach for this session, to avoid spamming
|
|
55
72
|
* the log every tick while the close is in flight. */
|
|
56
73
|
notifiedBreach = new Set();
|
|
74
|
+
/** Symbols already alarmed for an unusable mark, so the ERROR fires once per
|
|
75
|
+
* episode rather than every tick. Cleared as soon as a usable mark returns. */
|
|
76
|
+
notifiedUnusableMark = new Set();
|
|
57
77
|
constructor(adapter, intervalMs = DEFAULT_INTERVAL_MS, operationLock) {
|
|
58
78
|
super();
|
|
59
79
|
this.adapter = adapter;
|
|
@@ -82,6 +102,7 @@ export class PositionWatcher extends EventEmitter {
|
|
|
82
102
|
}
|
|
83
103
|
this.closePending.clear();
|
|
84
104
|
this.notifiedBreach.clear();
|
|
105
|
+
this.notifiedUnusableMark.clear();
|
|
85
106
|
logger.info(TAG, 'Stopped');
|
|
86
107
|
}
|
|
87
108
|
/** Run one check cycle. Exposed for tests (skip setInterval). */
|
|
@@ -115,7 +136,26 @@ export class PositionWatcher extends EventEmitter {
|
|
|
115
136
|
if (!openSymbols.has(sym))
|
|
116
137
|
this.closePending.delete(sym);
|
|
117
138
|
}
|
|
139
|
+
for (const sym of this.notifiedUnusableMark) {
|
|
140
|
+
if (!openSymbols.has(sym))
|
|
141
|
+
this.notifiedUnusableMark.delete(sym);
|
|
142
|
+
}
|
|
118
143
|
for (const position of positions) {
|
|
144
|
+
// A position with a stop we CANNOT evaluate is unprotected, not healthy.
|
|
145
|
+
// Silence here is what let a breached stop run for 35h: the mark was a
|
|
146
|
+
// fabricated entryPrice fallback, isStopBreached read false, and the
|
|
147
|
+
// watcher skipped it every tick without ever saying so. Alarm instead.
|
|
148
|
+
if (hasEnforceableStop(position) && isMarkUnusable(position)) {
|
|
149
|
+
if (!this.notifiedUnusableMark.has(position.symbol)) {
|
|
150
|
+
logger.error(TAG, `STOP UNENFORCEABLE: ${position.symbol} ${position.side} has stop=${position.stopPrice} ` +
|
|
151
|
+
`but no usable mark (markPrice=${position.markPrice}` +
|
|
152
|
+
`${position.markPriceStale ? ', stale/fabricated' : ''}). The position is running ` +
|
|
153
|
+
'UNPROTECTED — the watcher cannot evaluate the breach. Check the quote feed for this symbol.');
|
|
154
|
+
this.notifiedUnusableMark.add(position.symbol);
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
this.notifiedUnusableMark.delete(position.symbol);
|
|
119
159
|
if (!isStopBreached(position))
|
|
120
160
|
continue;
|
|
121
161
|
if (this.closePending.has(position.symbol))
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "reefclaw-paper-trading",
|
|
3
3
|
"name": "ReefClaw Trading",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.24",
|
|
5
5
|
"description": "Supervised trading plugin for the ReefClaw dashboard. It runs on YOUR machine and starts in PAPER mode with no API keys. It cannot trade real funds until you supply exchange credentials and step PAPER→MICRO_LIVE→LIVE yourself from the dashboard — the agent cannot make that change (the tool is refused without operator provenance). Exchange keys stay local, are used only to sign requests to the exchange, and are never transmitted to ReefClaw (asserted by a test in this package). Trading telemetry — positions, fills, decision journal — is sent to ReefClaw to render the dashboard. Every live position carries exchange-native protective stops. Remote updates to the agent's trading instructions are applied only after an Ed25519 signature is verified against a public key pinned in this build.",
|
|
6
6
|
"author": "ReefClaw",
|
|
7
7
|
"activation": {
|