@reefclaw/openclaw-plugin 0.1.23 → 0.1.25

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 (95) hide show
  1. package/bridge/bridge.js +72 -5
  2. package/bridge/connector.d.ts +3 -1
  3. package/bridge/connector.js +51 -4
  4. package/bridge/gateway/heartbeat-cron.js +31 -7
  5. package/bridge/gateway/poller.d.ts +5 -0
  6. package/bridge/gateway/poller.js +9 -0
  7. package/bridge/index.js +21 -0
  8. package/bridge/provider.d.ts +15 -0
  9. package/bridge/providers/connector-update.d.ts +89 -0
  10. package/bridge/providers/connector-update.js +212 -0
  11. package/bridge/providers/emergency-commands.d.ts +36 -0
  12. package/bridge/providers/emergency-commands.js +91 -0
  13. package/bridge/providers/gateway.d.ts +26 -1
  14. package/bridge/providers/gateway.js +159 -8
  15. package/bridge/providers/mock.js +1 -0
  16. package/bridge/shock-wake.d.ts +80 -0
  17. package/bridge/shock-wake.js +291 -0
  18. package/bridge/types.d.ts +5 -1
  19. package/bridge/types.js +5 -0
  20. package/bridge/utils/instance-id.d.ts +3 -0
  21. package/bridge/utils/instance-id.js +48 -0
  22. package/ccxt/binance-private.js +2 -1
  23. package/ccxt/binance-public.js +6 -1
  24. package/config/agent-config-client.d.ts +7 -2
  25. package/config/agent-config-client.js +17 -0
  26. package/config/agent-config-poller.js +5 -1
  27. package/config/brackets-config.d.ts +2 -1
  28. package/config/brackets-config.js +25 -3
  29. package/config/gate-store.d.ts +12 -0
  30. package/config/gate-store.js +26 -2
  31. package/config/loss-streak-config.d.ts +2 -0
  32. package/config/loss-streak-config.js +33 -0
  33. package/config/plugin-config-io.d.ts +19 -0
  34. package/config/plugin-config-io.js +24 -2
  35. package/config/reentry-cooldown-config.d.ts +7 -0
  36. package/config/reentry-cooldown-config.js +59 -0
  37. package/http/keepalive-fetch.d.ts +5 -0
  38. package/http/keepalive-fetch.js +50 -0
  39. package/index.js +77 -8
  40. package/ingest/position-auto-capture.js +49 -4
  41. package/ingest/position-decisions-client.d.ts +6 -0
  42. package/ingest/position-decisions-client.js +27 -9
  43. package/ingest/readiness-reporter.d.ts +23 -2
  44. package/ingest/readiness-reporter.js +56 -1
  45. package/live/approval-lifecycle.d.ts +10 -0
  46. package/live/approval-lifecycle.js +16 -2
  47. package/live/microstructure-assembler.js +11 -2
  48. package/live/proposal-decision-listener.d.ts +21 -0
  49. package/live/proposal-decision-listener.js +39 -0
  50. package/live/proposal-manager.d.ts +12 -0
  51. package/live/proposal-manager.js +47 -0
  52. package/live/stop-watcher.d.ts +16 -1
  53. package/live/stop-watcher.js +48 -8
  54. package/onboarding/runtime.js +4 -0
  55. package/openclaw.plugin.json +1 -1
  56. package/package.json +38 -38
  57. package/persistence/state-manager.d.ts +7 -0
  58. package/persistence/state-manager.js +28 -1
  59. package/portfolio/directional-scoreboard.d.ts +17 -0
  60. package/portfolio/directional-scoreboard.js +71 -0
  61. package/portfolio/reentry-tracker.d.ts +38 -1
  62. package/portfolio/reentry-tracker.js +49 -0
  63. package/signals/change-of-character.d.ts +38 -0
  64. package/signals/change-of-character.js +93 -0
  65. package/simulator/exchange-simulator.d.ts +27 -1
  66. package/simulator/exchange-simulator.js +98 -38
  67. package/simulator/types.d.ts +11 -0
  68. package/skills/reefclaw/SKILL.md +2 -2
  69. package/strategy/evaluator.d.ts +4 -0
  70. package/tools/audit-bracket-protection.js +11 -7
  71. package/tools/close-position.js +10 -1
  72. package/tools/create-order.js +121 -9
  73. package/tools/get-funding-context.js +6 -1
  74. package/tools/get-liquidation-levels.js +5 -1
  75. package/tools/get-liquidation-pulse.js +7 -1
  76. package/tools/get-market-intel.js +2 -1
  77. package/tools/get-relevant-learnings.js +20 -1
  78. package/tools/get-resting-liquidity.js +6 -1
  79. package/tools/get-wave9-status.js +17 -0
  80. package/tools/hl-provision-agent-wallet.js +29 -11
  81. package/tools/intel-api.d.ts +9 -0
  82. package/tools/intel-api.js +32 -1
  83. package/tools/record-position-reviews.js +2 -2
  84. package/tools/reentry-cooldown.d.ts +33 -0
  85. package/tools/reentry-cooldown.js +74 -0
  86. package/tools/scan-pairs.d.ts +7 -0
  87. package/tools/scan-pairs.js +67 -11
  88. package/tools/set-exchange-credentials.js +19 -0
  89. package/tools/set-trading-mode.d.ts +6 -0
  90. package/tools/set-trading-mode.js +48 -1
  91. package/types.d.ts +7 -0
  92. package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
  93. package/venues/hyperliquid/hl-agent-wallet.js +32 -0
  94. package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
  95. package/venues/hyperliquid/hl-live-adapter.js +101 -13
@@ -1,4 +1,4 @@
1
- import { type ReadinessReport, type VenueId, type VenueReachabilityResult } from '@reefclaw/shared';
1
+ import { type ReadinessCheck, type ReadinessReport, type VenueId, type VenueReachabilityResult } from '@reefclaw/shared';
2
2
  /** Who froze the loop. 'unknown' when the kernel counter is unreadable (not
3
3
  * Linux / no CONFIG_SCHEDSTATS / first cycle) — attribution is evidence, and
4
4
  * absent evidence stays absent rather than defaulting to a blame. */
@@ -11,6 +11,23 @@ export declare function attributeStall(stallMs: number, runqueueWaitMs: number |
11
11
  export interface VenueReachabilityProbe {
12
12
  probeReachability(): Promise<VenueReachabilityResult>;
13
13
  }
14
+ /** Snapshot for the `live_stop_protection` check (E2E audit #3, 2026-08-25):
15
+ * what — if anything — would stop a losing live position. Resolved per cycle
16
+ * via a deferred closure over the runtime so it follows paper↔live flips and
17
+ * adapter swaps without a restart. */
18
+ export interface StopProtectionSnapshot {
19
+ /** 'PAPER' | 'SHADOW' | 'MICRO_LIVE' | 'LIVE' (runtime.mode). */
20
+ tradingMode: string;
21
+ /** Adapter declares venue-enforced brackets (Hyperliquid live). */
22
+ venueEnforced: boolean;
23
+ /** The effective Binance bracket mode ('off' | 'observe' | 'enforce'). */
24
+ bracketMode: string;
25
+ }
26
+ /** Map a snapshot to the readiness check. Pure — the whole point of the row is
27
+ * that `fail` means REAL MONEY WITH NO STOP, so the mapping is unit-tested
28
+ * branch by branch. Null snapshot → null (older wiring: omit the row rather
29
+ * than fabricate a verdict). */
30
+ export declare function stopProtectionCheck(snap: StopProtectionSnapshot | null, checkedAt: number): ReadinessCheck | null;
14
31
  /** Cross-cycle memory for the debounced rungs. Held by the interval loop and
15
32
  * passed in explicitly so `collectReadiness` stays a pure function of its
16
33
  * inputs — a module-global counter would leak between unit tests. */
@@ -31,6 +48,10 @@ export interface ReadinessReporterOptions {
31
48
  publicApi: VenueReachabilityProbe;
32
49
  /** Number of trading tools registered (a health signal). */
33
50
  toolCount: number;
51
+ /** Resolve the stop-protection snapshot at CALL time (deferred closure over
52
+ * the runtime — follows paper↔live flips and adapter swaps). Absent/null →
53
+ * the `live_stop_protection` row is omitted, never fabricated. */
54
+ resolveStopProtection?: () => StopProtectionSnapshot | null;
34
55
  fetchImpl?: typeof fetch;
35
56
  intervalMs?: number;
36
57
  requestTimeoutMs?: number;
@@ -46,7 +67,7 @@ export interface ReadinessReporterOptions {
46
67
  * warn/fail drift is reported 'unknown' (not amber/red) so the readiness
47
68
  * banner doesn't cry-wolf for ~5 min after every restart; a genuinely
48
69
  * skewed clock still surfaces on cycle 2. */
49
- export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'venue' | 'publicApi' | 'toolCount'>, bootWarmup?: boolean, deps?: {
70
+ export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'venue' | 'publicApi' | 'toolCount' | 'resolveStopProtection'>, bootWarmup?: boolean, deps?: {
50
71
  /** Debounce memory. Omitted → a fresh state, so a lone unreachable reads
51
72
  * `unknown`; only a caller that persists state across cycles can ever
52
73
  * reach the warn rung. */
@@ -43,6 +43,45 @@ export function attributeStall(stallMs, runqueueWaitMs) {
43
43
  const hostThreshold = Math.max(HOST_WAIT_FLOOR_MS, stallMs * HOST_WAIT_SHARE_OF_STALL);
44
44
  return runqueueWaitMs >= hostThreshold ? 'host' : 'self';
45
45
  }
46
+ /** Map a snapshot to the readiness check. Pure — the whole point of the row is
47
+ * that `fail` means REAL MONEY WITH NO STOP, so the mapping is unit-tested
48
+ * branch by branch. Null snapshot → null (older wiring: omit the row rather
49
+ * than fabricate a verdict). */
50
+ export function stopProtectionCheck(snap, checkedAt) {
51
+ if (!snap)
52
+ return null;
53
+ const live = snap.tradingMode === 'LIVE' || snap.tradingMode === 'MICRO_LIVE';
54
+ if (!live) {
55
+ return makeReadinessCheck('live_stop_protection', 'pass', {
56
+ detail: 'paper — software stop-watcher',
57
+ checkedAt,
58
+ });
59
+ }
60
+ if (snap.venueEnforced) {
61
+ return makeReadinessCheck('live_stop_protection', 'pass', {
62
+ detail: 'venue-enforced exchange brackets',
63
+ checkedAt,
64
+ });
65
+ }
66
+ if (snap.bracketMode === 'enforce') {
67
+ return makeReadinessCheck('live_stop_protection', 'pass', {
68
+ detail: 'exchange-native brackets (enforce)',
69
+ checkedAt,
70
+ });
71
+ }
72
+ if (snap.bracketMode === 'observe') {
73
+ return makeReadinessCheck('live_stop_protection', 'pass', {
74
+ detail: 'exchange brackets (observe) + software watcher',
75
+ checkedAt,
76
+ });
77
+ }
78
+ // LIVE with brackets off — with the live-enforce default this can only be an
79
+ // explicit operator override, and it must burn red on every surface.
80
+ return makeReadinessCheck('live_stop_protection', 'fail', {
81
+ detail: `brackets.mode='${snap.bracketMode}' on a ${snap.tradingMode} box`,
82
+ checkedAt,
83
+ });
84
+ }
46
85
  export function createReadinessCycleState() {
47
86
  return { consecutiveReachFailures: 0 };
48
87
  }
@@ -84,6 +123,17 @@ export async function collectReadiness(opts, bootWarmup = false, deps = {}) {
84
123
  detail: `${opts.toolCount} tools`,
85
124
  checkedAt: now,
86
125
  }));
126
+ // live_stop_protection — a local config/adapter read, no network. Resolved
127
+ // per cycle so a paper→live flip surfaces on the next report without a
128
+ // restart. Resolver failure → omit the row (absent evidence stays absent).
129
+ try {
130
+ const stopCheck = stopProtectionCheck(opts.resolveStopProtection?.() ?? null, now);
131
+ if (stopCheck)
132
+ checks.push(stopCheck);
133
+ }
134
+ catch (err) {
135
+ logger.warn(TAG, `stop-protection snapshot failed (row omitted): ${formatError(err)}`);
136
+ }
87
137
  // ★ Probe FIRST, sample the loop delay AFTER. The freeze that makes a probe
88
138
  // abort happens *during* the probe, so sampling first would file the evidence
89
139
  // in the NEXT cycle's window — reachability would read 'stalled' this cycle
@@ -296,7 +346,12 @@ export function startReadinessReporter(opts) {
296
346
  const state = createReadinessCycleState();
297
347
  const cycle = async (bootWarmup) => {
298
348
  try {
299
- const report = await collectReadiness({ venue: opts.venue, publicApi: opts.publicApi, toolCount: opts.toolCount }, bootWarmup, { state });
349
+ const report = await collectReadiness({
350
+ venue: opts.venue,
351
+ publicApi: opts.publicApi,
352
+ toolCount: opts.toolCount,
353
+ resolveStopProtection: opts.resolveStopProtection,
354
+ }, bootWarmup, { state });
300
355
  await postReadiness(opts.apiBaseUrl, opts.token, report, fetchImpl, timeoutMs);
301
356
  if (report.overall === 'fail') {
302
357
  const failing = report.checks.filter((c) => c.status === 'fail').map((c) => c.id).join(', ');
@@ -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))
@@ -63,6 +63,10 @@ export function buildAdapter(input) {
63
63
  marketSlippagePct: readPluginConfig().hl?.marketSlippagePct,
64
64
  microLive,
65
65
  tradeIngest,
66
+ // Journal close capture (close-bypass fix) — same wiring the boot
67
+ // path passes; omitting it here would shed the capture on every
68
+ // reconnect-built adapter (the F9 class).
69
+ autoCapture: input.wiring?.autoCapture,
66
70
  },
67
71
  });
68
72
  }
@@ -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.25",
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": {
package/package.json CHANGED
@@ -1,38 +1,38 @@
1
- {
2
- "name": "@reefclaw/openclaw-plugin",
3
- "version": "0.1.23",
4
- "description": "ReefClaw supervised trading plugin for OpenClaw. Runs entirely on YOUR machine and starts in PAPER mode \u00e2\u20ac\u201d it cannot trade real funds until you supply exchange credentials and walk the PAPER\u00e2\u2020\u2019MICRO_LIVE\u00e2\u2020\u2019LIVE ladder yourself from the ReefClaw dashboard (the agent cannot make that change; it is refused without operator provenance). Your exchange API keys stay on your machine to sign requests to the exchange and are NEVER sent to ReefClaw \u00e2\u20ac\u201d a test in the package asserts this. What does reach ReefClaw is trading telemetry for the dashboard (positions, fills, decision journal). Live trading always carries exchange-native protective stops. Trading instructions can be updated remotely, and every update must carry a valid Ed25519 signature verified against a key pinned in this build before it is applied. Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
5
- "type": "module",
6
- "main": "index.js",
7
- "openclaw": {
8
- "extensions": [
9
- "./index.js"
10
- ],
11
- "compat": {
12
- "pluginApi": ">=2026.6.0"
13
- },
14
- "build": {
15
- "openclawVersion": "2026.6.11"
16
- }
17
- },
18
- "files": [
19
- "**/*",
20
- "!scripts/**"
21
- ],
22
- "engines": {
23
- "node": ">=20"
24
- },
25
- "dependencies": {
26
- "@reefclaw/shared": "0.1.4",
27
- "ccxt": "4.5.37",
28
- "json5": "2.2.3",
29
- "ws": "8.21.1"
30
- },
31
- "scripts": {
32
- "build": "node scripts/assemble.mjs",
33
- "verify": "node scripts/verify-shared-contract.mjs",
34
- "prepublishOnly": "node scripts/verify-shared-contract.mjs"
35
- },
36
- "license": "MIT",
37
- "homepage": "https://reefclaw.com"
38
- }
1
+ {
2
+ "name": "@reefclaw/openclaw-plugin",
3
+ "version": "0.1.25",
4
+ "description": "ReefClaw supervised trading plugin for OpenClaw. Runs entirely on YOUR machine and starts in PAPER mode — it cannot trade real funds until you supply exchange credentials and walk the PAPER→MICRO_LIVE→LIVE ladder yourself from the ReefClaw dashboard (the agent cannot make that change; it is refused without operator provenance). Your exchange API keys stay on your machine to sign requests to the exchange and are NEVER sent to ReefClaw — a test in the package asserts this. What does reach ReefClaw is trading telemetry for the dashboard (positions, fills, decision journal). Live trading always carries exchange-native protective stops. Trading instructions can be updated remotely, and every update must carry a valid Ed25519 signature verified against a key pinned in this build before it is applied. Install: npx --yes @reefclaw/connect, or from ClawHub on OpenClaw 2026.8.1+ (Control UI Plugins > Discover, or /plugins install clawhub:@reefclaw/openclaw-plugin then the same with --accept-capabilities after reviewing the listed capabilities)",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "openclaw": {
8
+ "extensions": [
9
+ "./index.js"
10
+ ],
11
+ "compat": {
12
+ "pluginApi": ">=2026.6.0"
13
+ },
14
+ "build": {
15
+ "openclawVersion": "2026.6.11"
16
+ }
17
+ },
18
+ "files": [
19
+ "**/*",
20
+ "!scripts/**"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "dependencies": {
26
+ "@reefclaw/shared": "0.1.4",
27
+ "ccxt": "4.5.37",
28
+ "json5": "2.2.3",
29
+ "ws": "8.21.1"
30
+ },
31
+ "scripts": {
32
+ "build": "node scripts/assemble.mjs",
33
+ "verify": "node scripts/verify-shared-contract.mjs",
34
+ "prepublishOnly": "node scripts/verify-shared-contract.mjs"
35
+ },
36
+ "license": "MIT",
37
+ "homepage": "https://reefclaw.com"
38
+ }
@@ -1,6 +1,7 @@
1
1
  import type { SimulatorState } from '../simulator/types.js';
2
2
  export declare class StateManager {
3
3
  private readonly statePath;
4
+ private lastLoadedStat?;
4
5
  private saveTimer;
5
6
  private pendingState;
6
7
  constructor(pluginId: string, baseDir?: string);
@@ -37,6 +38,12 @@ export declare class StateManager {
37
38
  * missing-vs-corrupt distinction as load(): a corrupt file is quarantined
38
39
  * (preserved), never silently overwritten by the default the caller seeds. */
39
40
  loadSync(): SimulatorState | null;
41
+ /** loadSync that returns null when the file is unchanged since the last
42
+ * loadSync. The reload exists for the two-process case (another process
43
+ * wrote the file); an unchanged file means the caller's replaceState would
44
+ * be a no-op re-parse of the whole trade history — which grows with the
45
+ * account's age and was being paid on every paper-mode tool call. */
46
+ loadSyncIfChanged(): SimulatorState | null;
40
47
  /** Synchronous load-or-create-default — for use in synchronous plugin register(). */
41
48
  loadOrDefaultSync(startingBalance: number, quoteCurrency: string): SimulatorState;
42
49
  private saveSyncSafe;