@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.
Files changed (57) hide show
  1. package/bridge/bridge.js +72 -5
  2. package/bridge/connector.js +14 -2
  3. package/bridge/gateway/heartbeat-cron.js +30 -7
  4. package/bridge/gateway/poller.d.ts +5 -0
  5. package/bridge/gateway/poller.js +9 -0
  6. package/bridge/provider.d.ts +15 -0
  7. package/bridge/providers/connector-update.d.ts +89 -0
  8. package/bridge/providers/connector-update.js +212 -0
  9. package/bridge/providers/emergency-commands.d.ts +36 -0
  10. package/bridge/providers/emergency-commands.js +91 -0
  11. package/bridge/providers/gateway.d.ts +26 -1
  12. package/bridge/providers/gateway.js +159 -8
  13. package/bridge/providers/mock.js +1 -0
  14. package/bridge/types.d.ts +1 -1
  15. package/bridge/types.js +5 -0
  16. package/ccxt/binance-private.js +2 -1
  17. package/ccxt/binance-public.js +6 -1
  18. package/config/agent-config-client.d.ts +5 -2
  19. package/config/agent-config-client.js +13 -0
  20. package/config/agent-config-poller.js +5 -1
  21. package/config/gate-store.d.ts +9 -0
  22. package/config/gate-store.js +17 -2
  23. package/config/plugin-config-io.js +24 -2
  24. package/http/keepalive-fetch.d.ts +5 -0
  25. package/http/keepalive-fetch.js +50 -0
  26. package/index.js +48 -6
  27. package/ingest/position-decisions-client.d.ts +6 -0
  28. package/ingest/position-decisions-client.js +27 -9
  29. package/live/approval-lifecycle.d.ts +10 -0
  30. package/live/approval-lifecycle.js +16 -2
  31. package/live/microstructure-assembler.js +11 -2
  32. package/live/proposal-decision-listener.d.ts +21 -0
  33. package/live/proposal-decision-listener.js +39 -0
  34. package/live/proposal-manager.d.ts +12 -0
  35. package/live/proposal-manager.js +47 -0
  36. package/live/stop-watcher.d.ts +16 -1
  37. package/live/stop-watcher.js +48 -8
  38. package/openclaw.plugin.json +1 -1
  39. package/package.json +38 -38
  40. package/persistence/state-manager.d.ts +7 -0
  41. package/persistence/state-manager.js +28 -1
  42. package/simulator/exchange-simulator.d.ts +22 -0
  43. package/simulator/exchange-simulator.js +74 -32
  44. package/tools/audit-bracket-protection.js +11 -7
  45. package/tools/create-order.js +49 -7
  46. package/tools/get-funding-context.js +6 -1
  47. package/tools/get-liquidation-levels.js +5 -1
  48. package/tools/get-liquidation-pulse.js +7 -1
  49. package/tools/get-market-intel.js +2 -1
  50. package/tools/get-relevant-learnings.js +20 -1
  51. package/tools/get-resting-liquidity.js +6 -1
  52. package/tools/get-wave9-status.js +17 -0
  53. package/tools/intel-api.d.ts +9 -0
  54. package/tools/intel-api.js +32 -1
  55. package/tools/record-position-reviews.js +2 -2
  56. package/tools/scan-pairs.js +20 -11
  57. 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
- const fresh = stateManager.loadSync();
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
- try {
1297
- if (readPluginConfig().approval?.mode === 'per_trade')
1298
- return 'per_trade';
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
- catch { /* config unreadable env-only fallback below */ }
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 — proposal path disabled (orders fire directly)`);
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 ?? fetch,
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
- async runGetReturning(userId, path) {
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
- for (let attempt = 1; attempt <= this.opts.maxAttempts; attempt++) {
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}/${this.opts.maxAttempts}).`);
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}/${this.opts.maxAttempts}): ${formatError(err)}`);
309
+ logger.warn(TAG, `GET ${url} threw (attempt ${attempt}/${maxAttempts}): ${formatError(err)}`);
292
310
  }
293
- if (attempt < this.opts.maxAttempts) {
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 ${this.opts.maxAttempts} attempts. userId=${userId}.`);
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
- fetchIntelApi(`/api/liquidation-pulse?symbol=${enc(intelSymbol)}&window_seconds=60`, this.intelDeps),
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;
@@ -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)
@@ -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. */
@@ -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
- const stop = position.stopPrice;
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' ? mark <= stop : mark >= stop;
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))
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "reefclaw-paper-trading",
3
3
  "name": "ReefClaw Trading",
4
- "version": "0.1.23",
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": {