@reefclaw/openclaw-plugin 0.1.24 → 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 (52) hide show
  1. package/bridge/connector.d.ts +3 -1
  2. package/bridge/connector.js +37 -2
  3. package/bridge/gateway/heartbeat-cron.js +2 -1
  4. package/bridge/index.js +21 -0
  5. package/bridge/shock-wake.d.ts +80 -0
  6. package/bridge/shock-wake.js +291 -0
  7. package/bridge/types.d.ts +4 -0
  8. package/bridge/utils/instance-id.d.ts +3 -0
  9. package/bridge/utils/instance-id.js +48 -0
  10. package/config/agent-config-client.d.ts +3 -1
  11. package/config/agent-config-client.js +4 -0
  12. package/config/brackets-config.d.ts +2 -1
  13. package/config/brackets-config.js +25 -3
  14. package/config/gate-store.d.ts +3 -0
  15. package/config/gate-store.js +11 -2
  16. package/config/loss-streak-config.d.ts +2 -0
  17. package/config/loss-streak-config.js +33 -0
  18. package/config/plugin-config-io.d.ts +19 -0
  19. package/config/reentry-cooldown-config.d.ts +7 -0
  20. package/config/reentry-cooldown-config.js +59 -0
  21. package/index.js +29 -2
  22. package/ingest/position-auto-capture.js +49 -4
  23. package/ingest/readiness-reporter.d.ts +23 -2
  24. package/ingest/readiness-reporter.js +56 -1
  25. package/onboarding/runtime.js +4 -0
  26. package/openclaw.plugin.json +1 -1
  27. package/package.json +2 -2
  28. package/portfolio/directional-scoreboard.d.ts +17 -0
  29. package/portfolio/directional-scoreboard.js +71 -0
  30. package/portfolio/reentry-tracker.d.ts +38 -1
  31. package/portfolio/reentry-tracker.js +49 -0
  32. package/signals/change-of-character.d.ts +38 -0
  33. package/signals/change-of-character.js +93 -0
  34. package/simulator/exchange-simulator.d.ts +5 -1
  35. package/simulator/exchange-simulator.js +24 -6
  36. package/simulator/types.d.ts +11 -0
  37. package/skills/reefclaw/SKILL.md +2 -2
  38. package/strategy/evaluator.d.ts +4 -0
  39. package/tools/close-position.js +10 -1
  40. package/tools/create-order.js +72 -2
  41. package/tools/hl-provision-agent-wallet.js +29 -11
  42. package/tools/reentry-cooldown.d.ts +33 -0
  43. package/tools/reentry-cooldown.js +74 -0
  44. package/tools/scan-pairs.d.ts +7 -0
  45. package/tools/scan-pairs.js +47 -0
  46. package/tools/set-exchange-credentials.js +19 -0
  47. package/tools/set-trading-mode.d.ts +6 -0
  48. package/tools/set-trading-mode.js +48 -1
  49. package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
  50. package/venues/hyperliquid/hl-agent-wallet.js +32 -0
  51. package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
  52. package/venues/hyperliquid/hl-live-adapter.js +101 -13
@@ -5,10 +5,32 @@
5
5
  // in memory on the plugin after push; not persisted to plugin-config.json.
6
6
  // Wired in Phase 2; for Phase 1 this is always undefined.
7
7
  // 2. Local plugin-config.json `brackets.mode` field.
8
- // 3. Default: 'off' (legacy stop-watcher remains authoritative).
8
+ // 3. Default: CONTEXT-AWARE since 2026-08-25 (E2E audit #3, the last
9
+ // real-money item on the fresh-box list): a box whose persisted
10
+ // tradingMode is MICRO_LIVE/LIVE on the BINANCE venue defaults to
11
+ // 'enforce' — the historical 'off' default meant a fresh npx install
12
+ // flipped to live traded with NO exchange-side stops AND no mandatory-
13
+ // stop pre-trade gate (the gate is wired behind bracketsEnabled), i.e.
14
+ // genuinely naked, with no in-product way to notice. Everything else
15
+ // (paper, shadow, hyperliquid — whose adapter enforces brackets
16
+ // unconditionally and ignores this Binance-only knob) keeps 'off'.
17
+ // An EXPLICIT 'off' in config is still honored (operator break-glass,
18
+ // behind the Settings type-to-confirm guard) — with the new default it
19
+ // can only ever be a deliberate choice.
9
20
  import { readPluginConfig } from './plugin-config-io.js';
10
21
  const VALID = new Set(['off', 'observe', 'enforce']);
11
- /** Read bracket mode from a config object. Invalid values fall back to 'off'. */
22
+ /** Does this config describe a live-Binance box the one context where an
23
+ * unset bracket mode must NOT mean "no protection"? Reads the PERSISTED
24
+ * trading mode (set_trading_mode persists BEFORE the adapter swap, so
25
+ * construction-time and per-call reads agree). */
26
+ function isLiveBinanceContext(config) {
27
+ const mode = config?.tradingMode;
28
+ if (mode !== 'MICRO_LIVE' && mode !== 'LIVE')
29
+ return false;
30
+ return config?.exchange?.venue !== 'hyperliquid';
31
+ }
32
+ /** Read bracket mode from a config object. Invalid values fall back to the
33
+ * context default ('enforce' on a live-Binance box, else 'off'). */
12
34
  export function getBracketMode(config, remoteOverride) {
13
35
  if (remoteOverride && VALID.has(remoteOverride))
14
36
  return remoteOverride;
@@ -16,7 +38,7 @@ export function getBracketMode(config, remoteOverride) {
16
38
  if (typeof raw === 'string' && VALID.has(raw)) {
17
39
  return raw;
18
40
  }
19
- return 'off';
41
+ return isLiveBinanceContext(config) ? 'enforce' : 'off';
20
42
  }
21
43
  /** Convenience: load from disk and return the effective mode. */
22
44
  export function loadBracketMode() {
@@ -18,6 +18,9 @@ declare class GateStore {
18
18
  * is the pre-existing autonomous behaviour, so neither value can leave a
19
19
  * position unprotected. */
20
20
  getApprovalMode(): AgentGates['approvalMode'] | null;
21
+ /** The central reentryCooldown mode, or null when central has no value (or
22
+ * the kill-switch is on) — null tells the reader to fall back to the file. */
23
+ getReentryCooldown(): AgentGates['reentryCooldown'] | null;
21
24
  /** Test-only. */
22
25
  __reset(): void;
23
26
  }
@@ -31,12 +31,14 @@ class GateStore {
31
31
  const next = gates ?? {};
32
32
  const changed = next.exitGate !== this.gates.exitGate ||
33
33
  next.positionReviewMode !== this.gates.positionReviewMode ||
34
- next.approvalMode !== this.gates.approvalMode;
34
+ next.approvalMode !== this.gates.approvalMode ||
35
+ next.reentryCooldown !== this.gates.reentryCooldown;
35
36
  this.gates = { ...next };
36
37
  if (changed) {
37
38
  logger.info(TAG, `applied central gates: exitGate=${next.exitGate ?? UNSET} ` +
38
39
  `positionReviewMode=${next.positionReviewMode ?? UNSET} ` +
39
- `approvalMode=${next.approvalMode ?? UNSET}`);
40
+ `approvalMode=${next.approvalMode ?? UNSET} ` +
41
+ `reentryCooldown=${next.reentryCooldown ?? UNSET}`);
40
42
  }
41
43
  }
42
44
  /** The central exitGate mode, or null when central has no value (or the
@@ -66,6 +68,13 @@ class GateStore {
66
68
  return null;
67
69
  return this.gates.approvalMode ?? null;
68
70
  }
71
+ /** The central reentryCooldown mode, or null when central has no value (or
72
+ * the kill-switch is on) — null tells the reader to fall back to the file. */
73
+ getReentryCooldown() {
74
+ if (!centralGatesEnabled())
75
+ return null;
76
+ return this.gates.reentryCooldown ?? null;
77
+ }
69
78
  /** Test-only. */
70
79
  __reset() {
71
80
  this.gates = {};
@@ -0,0 +1,2 @@
1
+ export type LossStreakSizingMode = 'off' | 'log' | 'enforce';
2
+ export declare function resolveLossStreakSizingMode(): LossStreakSizingMode;
@@ -0,0 +1,33 @@
1
+ // WS1 (docs/MARKET_ADAPTIVITY_PLAN.md §3) — mode resolver for the LIVE
2
+ // loss-streak sizing brake feed.
3
+ //
4
+ // preTradeRiskCheck has always had a graduated loss-streak brake (0.5× /
5
+ // 0.25× position size at the operator-tunable lossStreakHalfSize /
6
+ // lossStreakQuarterSize thresholds — never a hard block), but live fed it a
7
+ // hardcoded consecutiveLosses=0 ("would come from intelligence DB — use 0
8
+ // for now"). The real feed now comes from the ReentryTracker exit records
9
+ // (wasLoss + book tag, persisted).
10
+ //
11
+ // Env RC_LOSS_STREAK_SIZING:
12
+ // 'enforce' (DEFAULT since 2026-09-05) — feed the real streak to the risk
13
+ // check; the graduated size reduction applies on
14
+ // live exactly as it always has on paper. Promoted
15
+ // from 'log' after the pre-registered soak: 3 days
16
+ // of clean streak logs (1→3 tracked + reset
17
+ // correctly) and an 18/18 loss-sign agreement audit
18
+ // between the agent's r_multiple_at_close and DB
19
+ // realized_r.
20
+ // 'log' — compute + log on entries; sizing UNAFFECTED
21
+ // (riskCheck still sees 0). The rollout soak mode.
22
+ // 'off' — no compute, no log; byte-identical to the
23
+ // pre-WS1 path (kill-switch).
24
+ //
25
+ // Deliberately env-based (not the central gate channel): it is a
26
+ // live/paper-parity bug fix on a risk-reduction mechanism, not a new policy
27
+ // ladder — and its only enforce-direction effect is SMALLER size.
28
+ export function resolveLossStreakSizingMode() {
29
+ const raw = (process.env.RC_LOSS_STREAK_SIZING ?? '').toLowerCase();
30
+ if (raw === 'off' || raw === 'log')
31
+ return raw;
32
+ return 'enforce';
33
+ }
@@ -197,6 +197,25 @@ export interface PluginConfigFile {
197
197
  stopWatcher?: {
198
198
  intervalMs?: number;
199
199
  };
200
+ /** Re-entry cooldown gate — blocks (mode-laddered) a NEW create_order entry
201
+ * on a symbol whose last close within `minutes` was a LOSS. See
202
+ * plugin/src/tools/reentry-cooldown.ts for the evidence + semantics.
203
+ *
204
+ * mode='off' (default) — gate never runs; behaviour identical to today.
205
+ * mode='shadow' — gate runs; verdict logged + tagged into
206
+ * position_entries.metadata.reentry_cooldown;
207
+ * the order always fires.
208
+ * mode='observe' — as shadow, but a triggered verdict logs at WARN.
209
+ * mode='enforce' — triggered verdict hard-rejects create_order.
210
+ *
211
+ * `mode` here is the LOCAL fallback — once the central gate
212
+ * (agent_config.gates.reentryCooldown) is set, central rules (kill-switch
213
+ * RC_CENTRAL_GATES=off). `minutes` is local-only (default 60, clamped
214
+ * 5–1440). */
215
+ reentryCooldown?: {
216
+ mode?: 'off' | 'shadow' | 'observe' | 'enforce';
217
+ minutes?: number;
218
+ };
200
219
  [extra: string]: unknown;
201
220
  }
202
221
  export declare function defaultConfigPath(): string;
@@ -0,0 +1,7 @@
1
+ import { type PluginConfigFile } from './plugin-config-io.js';
2
+ import type { ReentryCooldownMode } from '../tools/reentry-cooldown.js';
3
+ export declare const DEFAULT_REENTRY_COOLDOWN_MINUTES = 60;
4
+ export declare function getReentryCooldownMode(config?: PluginConfigFile): ReentryCooldownMode;
5
+ export declare function loadReentryCooldownMode(): ReentryCooldownMode;
6
+ export declare function getReentryCooldownMinutes(config?: PluginConfigFile): number;
7
+ export declare function loadReentryCooldownMinutes(): number;
@@ -0,0 +1,59 @@
1
+ // Feature-flag readers for the re-entry cooldown gate (create_order).
2
+ //
3
+ // Mode default 'off' so the gate ships dead-code; the cooldown window default
4
+ // (60 min) matches the 2026-09-02 measurement window that motivated the gate.
5
+ // Mode resolution follows the exitGate pattern (config-service slice 2):
6
+ //
7
+ // central (agent_config.gates.reentryCooldown via gate-store)
8
+ // → plugin-config.json reentryCooldown.mode
9
+ // → 'off'
10
+ //
11
+ // create_order reads the mode PER CALL, so a dashboard/API flip via
12
+ // scripts/enable-reentry-cooldown.py hot-applies within one config poll —
13
+ // no restart. Kill-switch RC_CENTRAL_GATES=off hands control back to the
14
+ // local file. The minutes knob is LOCAL-only (mechanism tunable, not a
15
+ // ladder) — central carries only the mode.
16
+ import { readPluginConfig } from './plugin-config-io.js';
17
+ import { gateStore } from './gate-store.js';
18
+ const VALID_MODES = new Set([
19
+ 'off',
20
+ 'shadow',
21
+ 'observe',
22
+ 'enforce',
23
+ ]);
24
+ export const DEFAULT_REENTRY_COOLDOWN_MINUTES = 60;
25
+ const MIN_COOLDOWN_MINUTES = 5;
26
+ const MAX_COOLDOWN_MINUTES = 1440;
27
+ export function getReentryCooldownMode(config) {
28
+ const raw = config?.reentryCooldown?.mode;
29
+ if (typeof raw === 'string' && VALID_MODES.has(raw)) {
30
+ return raw;
31
+ }
32
+ return 'off';
33
+ }
34
+ export function loadReentryCooldownMode() {
35
+ const central = gateStore.getReentryCooldown();
36
+ if (central)
37
+ return central;
38
+ try {
39
+ return getReentryCooldownMode(readPluginConfig());
40
+ }
41
+ catch {
42
+ return 'off';
43
+ }
44
+ }
45
+ export function getReentryCooldownMinutes(config) {
46
+ const raw = config?.reentryCooldown?.minutes;
47
+ if (typeof raw === 'number' && Number.isFinite(raw)) {
48
+ return Math.max(MIN_COOLDOWN_MINUTES, Math.min(MAX_COOLDOWN_MINUTES, raw));
49
+ }
50
+ return DEFAULT_REENTRY_COOLDOWN_MINUTES;
51
+ }
52
+ export function loadReentryCooldownMinutes() {
53
+ try {
54
+ return getReentryCooldownMinutes(readPluginConfig());
55
+ }
56
+ catch {
57
+ return DEFAULT_REENTRY_COOLDOWN_MINUTES;
58
+ }
59
+ }
package/index.js CHANGED
@@ -1385,12 +1385,21 @@ const paperTradingPlugin = {
1385
1385
  // Micro-live cap — same loader every runtime reconnect uses
1386
1386
  // (buildAdapter), so boot and reconnect can never disagree (F8).
1387
1387
  const microLiveConfig = loadMicroLiveConfig();
1388
- // Bracket-orders feature flag read from plugin-config at construction time.
1389
- // Default 'off' keeps legacy stop-watcher behaviour while the feature rolls out.
1388
+ // Bracket-orders feature flag read from plugin-config at construction
1389
+ // time. Since 2026-08-25 (E2E audit #3) the default on a live-Binance
1390
+ // box is 'enforce' — the old 'off' default meant a fresh npx install
1391
+ // flipped to live traded genuinely naked (no exchange stops AND the
1392
+ // mandatory-stop gate skipped, since it is wired behind
1393
+ // bracketsEnabled). An 'off' here can only be an explicit override.
1390
1394
  const bracketMode = loadBracketMode();
1391
1395
  if (bracketMode !== 'off') {
1392
1396
  logger.info(TAG, `Bracket orders enabled in mode=${bracketMode}`);
1393
1397
  }
1398
+ else {
1399
+ logger.warn(TAG, `LIVE Binance with brackets.mode='off' (explicit config override) — NO exchange-side ` +
1400
+ `stops; positions rely on the software watcher alone and the mandatory-stop ` +
1401
+ `pre-trade gate is OFF. The dashboard readiness banner will show this red.`);
1402
+ }
1394
1403
  // User-data WebSocket stream flag — same mode-ladder pattern as brackets.
1395
1404
  // Default 'off' keeps REST polling authoritative. Phase 1 ships dead-code;
1396
1405
  // the flag flip to 'shadow' / 'observe' / 'enforce' is operator-driven.
@@ -1418,6 +1427,11 @@ const paperTradingPlugin = {
1418
1427
  // F26: same wiring object as the Binance arm — the SIGTERM
1419
1428
  // drain covers both venues because it drains this client.
1420
1429
  tradeIngest,
1430
+ // Journal close capture (close-bypass fix, HL arm): without
1431
+ // this, every bracket SL/TP fill leaked as status='open'
1432
+ // until the reconciler healed it reason-less (50% of wisekid
1433
+ // 30d closes were reconciler_observed_flat).
1434
+ autoCapture,
1421
1435
  },
1422
1436
  }
1423
1437
  : {
@@ -2623,6 +2637,11 @@ const paperTradingPlugin = {
2623
2637
  decisionsClient: positionDecisionsClient,
2624
2638
  userId: positionDecisionsUserId,
2625
2639
  reentryTracker,
2640
+ // WS2 directional scoreboard (docs/MARKET_ADAPTIVITY_PLAN.md §3):
2641
+ // tracked positions from the state store (no exchange round-trip)
2642
+ // + book resolved per call so a paper↔live flip follows.
2643
+ openPositions: () => positionStateStore.getAll().map((e) => ({ side: e.side })),
2644
+ book: () => (runtime.adapter.isLive ? 'live' : 'paper'),
2626
2645
  })),
2627
2646
  },
2628
2647
  {
@@ -2920,6 +2939,14 @@ const paperTradingPlugin = {
2920
2939
  venue,
2921
2940
  publicApi: hlPublicApi ?? binanceApi,
2922
2941
  toolCount: toolNames.length,
2942
+ // live_stop_protection (E2E audit #3): what would stop a losing live
2943
+ // position. Deferred closure over the runtime so paper↔live flips and
2944
+ // adapter swaps surface on the next 5-min report without a restart.
2945
+ resolveStopProtection: () => ({
2946
+ tradingMode: runtime.mode,
2947
+ venueEnforced: runtime.adapter.bracketsAlwaysEnforced === true,
2948
+ bracketMode: loadBracketMode(),
2949
+ }),
2923
2950
  });
2924
2951
  maybeStartConnectorSupervisor();
2925
2952
  },
@@ -19,7 +19,7 @@
19
19
  // that hooks into the WS-ingest pipeline (see POSITION_DECISION_JOURNAL_PLAN
20
20
  // §5.1 for the longer-term design).
21
21
  import { logger } from '../logger.js';
22
- import { isBracketCid } from '../live/bracket-id.js';
22
+ import { isBracketClientId, parseBracketClientId } from '../live/bracket-id.js';
23
23
  import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
24
24
  import { fillPriceFromOrder } from '../live/fill-price.js';
25
25
  import { getSkillVersionCached, withSkillVersion } from './skill-version-reader.js';
@@ -306,12 +306,27 @@ export async function onClosePositionFilled(ctx, inputs, order) {
306
306
  };
307
307
  ctx.decisionsClient.postClose(ctx.userId, close);
308
308
  // Re-entry indication (issue #204) — record the exit so scan_pairs can flag
309
- // same-bar re-entries on this (symbol, setup).
309
+ // same-bar re-entries on this (symbol, setup), and so the reentryCooldown
310
+ // gate can see recent losses. Live agent-closes have no engine trade record
311
+ // here (exchange-exact PnL arrives later on the WS fill, racing this path),
312
+ // so the loss sign falls back to the agent's own r_multiple_at_close — the
313
+ // same validator-checked field the exit gate trusts. lossSource lets the
314
+ // shadow soak audit that sign against the DB before enforce.
315
+ const rRaw = inputs.closeAssessment?.['r_multiple_at_close'];
316
+ const rAtClose = typeof rRaw === 'number' && Number.isFinite(rRaw) ? rRaw : undefined;
310
317
  ctx.reentryTracker?.recordExit({
311
318
  symbol: inputs.symbol,
312
319
  setupType: stateEntry.setupType ?? paperTrade?.setupType,
313
320
  side: stateEntry.side,
314
- wasLoss: paperTrade ? paperTrade.netRealizedPnl < 0 : undefined,
321
+ wasLoss: paperTrade
322
+ ? paperTrade.netRealizedPnl < 0
323
+ : rAtClose != null
324
+ ? rAtClose < 0
325
+ : undefined,
326
+ lossSource: paperTrade ? 'paper_engine' : rAtClose != null ? 'assessment_r' : undefined,
327
+ mode: ctx.resolveMode?.(),
328
+ realizedR: rAtClose,
329
+ realizedPnl: paperTrade?.netRealizedPnl,
315
330
  closedAtMs: closeAtMs,
316
331
  });
317
332
  // Drop local state — symbol can re-enter as a new position.
@@ -388,6 +403,7 @@ export async function onAutoFlattenClose(ctx, inputs, lookup = {
388
403
  symbol: inputs.symbol,
389
404
  setupType: flattenState?.setupType,
390
405
  side: flattenState?.side ?? 'long',
406
+ mode: ctx.resolveMode?.(),
391
407
  closedAtMs: inputs.observedAtMs ?? Date.now(),
392
408
  });
393
409
  ctx.stateStore.remove(inputs.symbol);
@@ -421,6 +437,9 @@ export async function onStopWatcherClose(ctx, inputs) {
421
437
  setupType: stateEntry?.setupType ?? paperTrade?.setupType,
422
438
  side: stateEntry?.side ?? 'long',
423
439
  wasLoss: paperTrade ? paperTrade.netRealizedPnl < 0 : undefined,
440
+ lossSource: paperTrade ? 'paper_engine' : undefined,
441
+ mode: ctx.resolveMode?.(),
442
+ realizedPnl: paperTrade?.netRealizedPnl,
424
443
  closedAtMs: closeAtMs,
425
444
  });
426
445
  const dropState = () => { ctx.stateStore?.remove(inputs.symbol); };
@@ -627,7 +646,13 @@ async function handleReduceOnlyExit(ctx, fill) {
627
646
  // onClosePositionFilled) or a manual/external close — both are handled by
628
647
  // their own paths (close_position's rich reason+assessment, or the reconciler
629
648
  // backstop). Closing here would clobber the agent's close reasoning, so defer.
630
- const isBracket = fill.clientOrderId ? isBracketCid(fill.clientOrderId) : false;
649
+ // Recognition is VENUE-DISPATCHED (bracket-id rule): Binance `bkt…`/`rc-…`
650
+ // cids, Hyperliquid `0xbc7…` cloids — the Binance-only check silently
651
+ // classed every HL bracket fill as external and deferred it forever.
652
+ const cidVenue = ctx.venue ?? 'binance';
653
+ const isBracket = fill.clientOrderId
654
+ ? isBracketClientId(cidVenue, fill.clientOrderId)
655
+ : false;
631
656
  if (!isBracket) {
632
657
  logger.info(TAG, `${fill.symbol} flat via non-bracket reduce-only fill (cid=${fill.clientOrderId ?? 'none'}) — ` +
633
658
  `deferring close to close_position / reconciler backstop (no clobber)`);
@@ -643,6 +668,12 @@ async function handleReduceOnlyExit(ctx, fill) {
643
668
  'Auto-journaled from the WS fill — no close_position call (close-bypass path).',
644
669
  observedFrom: 'ws_reduce_only_fill',
645
670
  clientOrderId: fill.clientOrderId,
671
+ // Which protective leg fired ('stop' | 'target'), parsed from the cid.
672
+ // Kept in the assessment (not a new close reason) so the close_reason
673
+ // vocabulary stays stable for the miner's plan-adherence classifier.
674
+ leg: fill.clientOrderId
675
+ ? parseBracketClientId(cidVenue, fill.clientOrderId)?.role
676
+ : undefined,
646
677
  },
647
678
  scorecardVerdict: 'NO_GO',
648
679
  confluenceScore: 0,
@@ -668,6 +699,9 @@ async function handleReduceOnlyExit(ctx, fill) {
668
699
  setupType: stateEntry.setupType,
669
700
  side: stateEntry.side,
670
701
  wasLoss: realizedPnl < 0,
702
+ lossSource: 'ws_fill',
703
+ mode: ctx.resolveMode?.(),
704
+ realizedPnl,
671
705
  closedAtMs: fill.exchangeTimeMs ?? Date.now(),
672
706
  });
673
707
  ctx.stateStore.remove(fill.symbol);
@@ -733,5 +767,16 @@ export function buildEntryPlanMetadata(md) {
733
767
  j.note = rr.note;
734
768
  out.realization_rule = j;
735
769
  }
770
+ // Cooldown-gate measurement tag (snake_case per the canonical JSONB key
771
+ // rule) — present only when the gate triggered and the entry fired anyway.
772
+ const rc = md.reentryCooldown;
773
+ if (rc) {
774
+ out.reentry_cooldown = {
775
+ mode: rc.mode,
776
+ minutes_since_loss: rc.minutesSinceLoss,
777
+ cooldown_minutes: rc.cooldownMinutes,
778
+ would_block: rc.wouldBlock,
779
+ };
780
+ }
736
781
  return Object.keys(out).length > 0 ? out : undefined;
737
782
  }
@@ -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(', ');
@@ -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.24",
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,7 +1,7 @@
1
1
  {
2
2
  "name": "@reefclaw/openclaw-plugin",
3
- "version": "0.1.24",
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: /plugins install clawhub:@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
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "openclaw": {
@@ -0,0 +1,17 @@
1
+ import type { ReentryExitRecord } from './reentry-tracker.js';
2
+ export interface ScoreboardInputs {
3
+ /** Tracked open positions on the current book (state store, not exchange). */
4
+ openPositions: Array<{
5
+ side: 'long' | 'short';
6
+ }>;
7
+ /** Tracker exit records, oldest first (as stored). */
8
+ exitRecords: readonly ReentryExitRecord[];
9
+ book: 'paper' | 'live';
10
+ /** e.g. "BTC 4h −2.4% (−2.3×ATR)" — from the leader fact's changeOfCharacter. */
11
+ tapeLine?: string;
12
+ /** How many recent closes to summarize. */
13
+ lastN?: number;
14
+ }
15
+ /** Build the scoreboard line, or undefined when there is nothing to show
16
+ * (no open positions AND no recent closes on this book). */
17
+ export declare function buildDirectionalScoreboard(inputs: ScoreboardInputs): string | undefined;
@@ -0,0 +1,71 @@
1
+ // Directional scoreboard — WS2 of docs/MARKET_ADAPTIVITY_PLAN.md.
2
+ //
3
+ // One compact line of counter-evidence attached to the entry funnel
4
+ // (scan_pairs): the book's current directional tilt, how the last N closes
5
+ // per direction actually went, and what the market-leader tape did — the
6
+ // three facts an anchored agent never sees together. Pure indication: it
7
+ // never blocks or vetoes anything (that is the refuted-ledger's territory).
8
+ //
9
+ // Data sources are all local + free: the PositionStateStore (tracked open
10
+ // positions — no exchange round-trip, so no HL address-budget cost) and the
11
+ // ReentryTracker exit records (persisted; realizedR/realizedPnl where a
12
+ // source had them). The tape line comes from the market leader's
13
+ // change-of-character block on the intel facts the caller already fetched.
14
+ const emptyStats = () => ({
15
+ wins: 0, losses: 0, unknown: 0, pnl: 0, pnlKnown: false, rSum: 0, rKnown: false,
16
+ });
17
+ function fmtDir(label, s) {
18
+ const n = s.wins + s.losses + s.unknown;
19
+ if (n === 0)
20
+ return undefined;
21
+ let out = `${label} ${s.wins}W/${s.losses}L${s.unknown > 0 ? `/${s.unknown}?` : ''}`;
22
+ if (s.rKnown)
23
+ out += ` ${s.rSum >= 0 ? '+' : ''}${Math.round(s.rSum * 100) / 100}R`;
24
+ else if (s.pnlKnown) {
25
+ const abs = Math.round(Math.abs(s.pnl) * 100) / 100;
26
+ out += ` ${s.pnl >= 0 ? '+' : '-'}$${abs}`;
27
+ }
28
+ return out;
29
+ }
30
+ /** Build the scoreboard line, or undefined when there is nothing to show
31
+ * (no open positions AND no recent closes on this book). */
32
+ export function buildDirectionalScoreboard(inputs) {
33
+ const lastN = inputs.lastN ?? 8;
34
+ const longs = inputs.openPositions.filter((p) => p.side === 'long').length;
35
+ const shorts = inputs.openPositions.filter((p) => p.side === 'short').length;
36
+ // Newest-first walk over this book's records (legacy untagged match either).
37
+ const recent = [];
38
+ for (let i = inputs.exitRecords.length - 1; i >= 0 && recent.length < lastN; i--) {
39
+ const r = inputs.exitRecords[i];
40
+ if (r.mode && r.mode !== inputs.book)
41
+ continue;
42
+ recent.push(r);
43
+ }
44
+ if (longs + shorts === 0 && recent.length === 0)
45
+ return undefined;
46
+ const stats = { long: emptyStats(), short: emptyStats() };
47
+ for (const r of recent) {
48
+ const s = stats[r.side];
49
+ if (r.wasLoss === true)
50
+ s.losses++;
51
+ else if (r.wasLoss === false)
52
+ s.wins++;
53
+ else
54
+ s.unknown++;
55
+ if (typeof r.realizedPnl === 'number' && Number.isFinite(r.realizedPnl)) {
56
+ s.pnl += r.realizedPnl;
57
+ s.pnlKnown = true;
58
+ }
59
+ if (typeof r.realizedR === 'number' && Number.isFinite(r.realizedR)) {
60
+ s.rSum += r.realizedR;
61
+ s.rKnown = true;
62
+ }
63
+ }
64
+ const parts = [`book: ${longs}L/${shorts}S open`];
65
+ const closeBits = [fmtDir('longs', stats.long), fmtDir('shorts', stats.short)].filter((v) => v !== undefined);
66
+ if (closeBits.length > 0)
67
+ parts.push(`last ${recent.length} closes: ${closeBits.join(', ')}`);
68
+ if (inputs.tapeLine)
69
+ parts.push(`tape: ${inputs.tapeLine}`);
70
+ return parts.join(' · ');
71
+ }