@reefclaw/openclaw-plugin 0.1.5 → 0.1.7

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 (152) hide show
  1. package/bridge/gateway/event-parser.d.ts +6 -1
  2. package/bridge/gateway/event-parser.js +19 -2
  3. package/bridge/gateway/poller.d.ts +1 -0
  4. package/bridge/gateway/poller.js +14 -2
  5. package/bridge/providers/gateway.d.ts +22 -2
  6. package/bridge/providers/gateway.js +67 -9
  7. package/ccxt/binance-private.d.ts +21 -0
  8. package/ccxt/binance-private.js +132 -22
  9. package/ccxt/public-market-data-api.d.ts +14 -0
  10. package/ccxt/public-market-data-api.js +15 -1
  11. package/config/plugin-config-io.d.ts +13 -0
  12. package/config/plugin-config-io.js +15 -0
  13. package/config/tool-gate.js +3 -0
  14. package/exchange-adapter.d.ts +16 -0
  15. package/index.js +658 -83
  16. package/ingest/position-auto-capture.d.ts +68 -0
  17. package/ingest/position-auto-capture.js +321 -23
  18. package/ingest/position-decisions-client.d.ts +7 -2
  19. package/ingest/position-decisions-client.js +13 -3
  20. package/ingest/reconcile-db-vs-exchange.d.ts +39 -1
  21. package/ingest/reconcile-db-vs-exchange.js +66 -10
  22. package/lifecycle/trading-operation-lock.d.ts +17 -0
  23. package/lifecycle/trading-operation-lock.js +14 -0
  24. package/live/bracket-id.d.ts +2 -3
  25. package/live/bracket-id.js +22 -9
  26. package/live/fill-price.d.ts +13 -0
  27. package/live/fill-price.js +37 -0
  28. package/live/live-adapter.d.ts +57 -2
  29. package/live/live-adapter.js +290 -49
  30. package/live/local-signal-service.js +11 -6
  31. package/live/local-strategy-evaluator.js +4 -0
  32. package/live/position-state-store.d.ts +4 -0
  33. package/live/proposal-decision-listener.d.ts +6 -0
  34. package/live/proposal-decision-listener.js +4 -0
  35. package/live/stop-watcher.d.ts +35 -2
  36. package/live/stop-watcher.js +63 -3
  37. package/onboarding/runtime.d.ts +19 -0
  38. package/onboarding/runtime.js +34 -3
  39. package/openclaw.plugin.json +1 -0
  40. package/package.json +2 -2
  41. package/portfolio/reentry-tracker.d.ts +36 -0
  42. package/portfolio/reentry-tracker.js +127 -0
  43. package/portfolio/wave9-admission.d.ts +67 -0
  44. package/portfolio/wave9-admission.js +262 -0
  45. package/portfolio/wave9-policy.d.ts +36 -0
  46. package/portfolio/wave9-policy.js +183 -0
  47. package/signals/conditions/registry.js +61 -2
  48. package/signals/strategy-adapter.js +17 -7
  49. package/simulator/exchange-simulator.d.ts +12 -0
  50. package/simulator/exchange-simulator.js +78 -3
  51. package/simulator/fill-engine.js +5 -1
  52. package/simulator/types.d.ts +10 -1
  53. package/skills/reefclaw/SKILL.md +2 -0
  54. package/strategy/evaluator.d.ts +3 -0
  55. package/strategy/evaluator.js +5 -0
  56. package/tools/assessment-validation.d.ts +23 -0
  57. package/tools/assessment-validation.js +58 -0
  58. package/tools/attach-brackets.d.ts +7 -2
  59. package/tools/attach-brackets.js +201 -0
  60. package/tools/audit-bracket-protection.js +157 -1
  61. package/tools/bracket-control.d.ts +12 -0
  62. package/tools/bracket-control.js +35 -0
  63. package/tools/cancel-all-orders.d.ts +2 -0
  64. package/tools/cancel-all-orders.js +4 -1
  65. package/tools/cancel-order.d.ts +4 -0
  66. package/tools/cancel-order.js +49 -3
  67. package/tools/close-position.d.ts +23 -0
  68. package/tools/close-position.js +286 -13
  69. package/tools/create-order.d.ts +28 -0
  70. package/tools/create-order.js +1390 -190
  71. package/tools/get-analytics.js +2 -2
  72. package/tools/get-basis.js +2 -2
  73. package/tools/get-cascade-risk.js +2 -2
  74. package/tools/get-crypto-metrics.js +14 -4
  75. package/tools/get-cvd.js +2 -2
  76. package/tools/get-divergences.js +2 -2
  77. package/tools/get-funding-context.js +2 -2
  78. package/tools/get-liquidation-levels.js +2 -2
  79. package/tools/get-liquidation-pulse.js +2 -2
  80. package/tools/get-pattern-scan.js +2 -2
  81. package/tools/get-regime.js +2 -2
  82. package/tools/get-resting-liquidity.js +2 -2
  83. package/tools/get-risk-scenario.js +2 -2
  84. package/tools/get-session-review.js +2 -2
  85. package/tools/get-setup-detail.js +21 -2
  86. package/tools/get-signals.js +2 -2
  87. package/tools/get-sizing.js +2 -2
  88. package/tools/get-trade-feedback.js +2 -2
  89. package/tools/get-trade-flow.js +2 -2
  90. package/tools/get-volume-profile.js +2 -2
  91. package/tools/get-wave9-status.d.ts +127 -0
  92. package/tools/get-wave9-status.js +796 -0
  93. package/tools/intel-api.d.ts +20 -0
  94. package/tools/intel-api.js +67 -0
  95. package/tools/intel-cache.d.ts +1 -1
  96. package/tools/intel-cache.js +20 -5
  97. package/tools/list-strategies.d.ts +11 -1
  98. package/tools/list-strategies.js +17 -0
  99. package/tools/modify-stop.d.ts +4 -0
  100. package/tools/modify-stop.js +63 -24
  101. package/tools/modify-target.d.ts +4 -0
  102. package/tools/modify-target.js +62 -23
  103. package/tools/scan-pairs.d.ts +4 -0
  104. package/tools/scan-pairs.js +22 -8
  105. package/tools/toggle-strategy.js +7 -0
  106. package/types.d.ts +5 -0
  107. package/venues/hyperliquid/hl-balance.d.ts +116 -0
  108. package/venues/hyperliquid/hl-balance.js +145 -0
  109. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +123 -0
  110. package/venues/hyperliquid/hl-bracket-coordinator.js +533 -0
  111. package/venues/hyperliquid/hl-brackets.d.ts +102 -0
  112. package/venues/hyperliquid/hl-brackets.js +172 -0
  113. package/venues/hyperliquid/hl-cloid.d.ts +22 -0
  114. package/venues/hyperliquid/hl-cloid.js +82 -0
  115. package/venues/hyperliquid/hl-info-cache.d.ts +46 -0
  116. package/venues/hyperliquid/hl-info-cache.js +125 -0
  117. package/venues/hyperliquid/hl-live-adapter.d.ts +146 -0
  118. package/venues/hyperliquid/hl-live-adapter.js +728 -0
  119. package/venues/hyperliquid/hl-precision.d.ts +61 -0
  120. package/venues/hyperliquid/hl-precision.js +176 -0
  121. package/venues/hyperliquid/hl-private.d.ts +88 -0
  122. package/venues/hyperliquid/hl-private.js +357 -0
  123. package/venues/hyperliquid/hl-public.d.ts +31 -4
  124. package/venues/hyperliquid/hl-public.js +163 -12
  125. package/venues/hyperliquid/hl-rate-gate.d.ts +57 -0
  126. package/venues/hyperliquid/hl-rate-gate.js +220 -0
  127. package/venues/hyperliquid/hl-user-stream.d.ts +90 -0
  128. package/venues/hyperliquid/hl-user-stream.js +220 -0
  129. package/venues/registry.d.ts +23 -9
  130. package/venues/registry.js +12 -13
  131. package/venues/symbols.d.ts +43 -0
  132. package/venues/symbols.js +107 -0
  133. package/wave9/live-account-capture.d.ts +67 -0
  134. package/wave9/live-account-capture.js +435 -0
  135. package/wave9/live-autonomous-protection.d.ts +39 -0
  136. package/wave9/live-autonomous-protection.js +112 -0
  137. package/wave9/live-durable-reconciliation-scheduler.d.ts +33 -0
  138. package/wave9/live-durable-reconciliation-scheduler.js +115 -0
  139. package/wave9/live-execution-ledger.d.ts +107 -0
  140. package/wave9/live-execution-ledger.js +498 -0
  141. package/wave9/live-position-confirmation.d.ts +18 -0
  142. package/wave9/live-position-confirmation.js +111 -0
  143. package/wave9/live-residual-protection.d.ts +18 -0
  144. package/wave9/live-residual-protection.js +250 -0
  145. package/wave9/live-startup-reconciliation.d.ts +38 -0
  146. package/wave9/live-startup-reconciliation.js +454 -0
  147. package/wave9/live-symbol-ownership.d.ts +20 -0
  148. package/wave9/live-symbol-ownership.js +132 -0
  149. package/wave9/paper-admission-guard.d.ts +199 -0
  150. package/wave9/paper-admission-guard.js +650 -0
  151. package/wave9/usdm-evidence-provider.d.ts +42 -0
  152. package/wave9/usdm-evidence-provider.js +133 -0
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import type { IExchangeAdapter } from '../exchange-adapter.js';
3
- import type { CcxtPosition } from '../types.js';
3
+ import type { CcxtOrder, CcxtPosition } from '../types.js';
4
+ import type { TradingOperationLock } from '../lifecycle/trading-operation-lock.js';
4
5
  export declare const DEFAULT_INTERVAL_MS = 10000;
5
6
  export interface StopTriggeredEvent {
6
7
  symbol: string;
@@ -9,6 +10,33 @@ export interface StopTriggeredEvent {
9
10
  markPrice: number;
10
11
  quantity: number;
11
12
  }
13
+ /** Emitted with 'stop_closed' — carries the executed close order so listeners
14
+ * (the journal auto-capture wiring in index.ts) can record the close with the
15
+ * real fill. Without this, watcher closes bypassed the Position Decision
16
+ * Journal entirely and left phantom-open journal positions (issue #199). */
17
+ export interface StopClosedEvent extends StopTriggeredEvent {
18
+ order?: CcxtOrder;
19
+ }
20
+ export type Wave9StopCloseOutcome = {
21
+ status: 'flat';
22
+ detail: string;
23
+ candidateId: string;
24
+ symbol: string;
25
+ } | {
26
+ status: 'protected' | 'manual_intervention';
27
+ detail: string;
28
+ candidateId: string;
29
+ symbol: string;
30
+ stopPrice: number;
31
+ residualQuantity?: number;
32
+ stopClientOrderId?: string;
33
+ };
34
+ /** Optional durable Wave 9 lifecycle. Generic watcher behavior is unchanged
35
+ * when this is absent or the current position has no owned candidate. */
36
+ export interface Wave9StopCloseLifecycle {
37
+ resolveCandidateId(position: CcxtPosition): Promise<string | undefined> | string | undefined;
38
+ settleAfterClose(candidateId: string, symbol: string): Promise<Wave9StopCloseOutcome>;
39
+ }
12
40
  /** Decide whether a position has crossed its stop.
13
41
  * Exported for direct unit-testing without spinning up a watcher loop. */
14
42
  export declare function isStopBreached(position: CcxtPosition): boolean;
@@ -16,6 +44,8 @@ export declare class PositionWatcher extends EventEmitter {
16
44
  private interval;
17
45
  private intervalMs;
18
46
  private adapter;
47
+ private readonly operationLock?;
48
+ private wave9CloseLifecycle?;
19
49
  /** Symbols currently awaiting a close RPC — second trigger in the same tick
20
50
  * is suppressed so the watcher cannot double-close. Cleared after the close
21
51
  * call resolves (success or failure). */
@@ -23,7 +53,10 @@ export declare class PositionWatcher extends EventEmitter {
23
53
  /** Symbols we've already logged a breach for this session, to avoid spamming
24
54
  * the log every tick while the close is in flight. */
25
55
  private notifiedBreach;
26
- constructor(adapter: IExchangeAdapter, intervalMs?: number);
56
+ constructor(adapter: IExchangeAdapter, intervalMs?: number, operationLock?: TradingOperationLock);
57
+ /** Configure this after the durable execution ledger is ready. Runtime
58
+ * lifecycle hooks reapply it to every watcher after reconnect. */
59
+ setWave9CloseLifecycle(lifecycle?: Wave9StopCloseLifecycle): void;
27
60
  start(): void;
28
61
  stop(): void;
29
62
  /** Run one check cycle. Exposed for tests (skip setInterval). */
@@ -45,6 +45,8 @@ export class PositionWatcher extends EventEmitter {
45
45
  interval = null;
46
46
  intervalMs;
47
47
  adapter;
48
+ operationLock;
49
+ wave9CloseLifecycle;
48
50
  /** Symbols currently awaiting a close RPC — second trigger in the same tick
49
51
  * is suppressed so the watcher cannot double-close. Cleared after the close
50
52
  * call resolves (success or failure). */
@@ -52,10 +54,16 @@ export class PositionWatcher extends EventEmitter {
52
54
  /** Symbols we've already logged a breach for this session, to avoid spamming
53
55
  * the log every tick while the close is in flight. */
54
56
  notifiedBreach = new Set();
55
- constructor(adapter, intervalMs = DEFAULT_INTERVAL_MS) {
57
+ constructor(adapter, intervalMs = DEFAULT_INTERVAL_MS, operationLock) {
56
58
  super();
57
59
  this.adapter = adapter;
58
60
  this.intervalMs = intervalMs;
61
+ this.operationLock = operationLock;
62
+ }
63
+ /** Configure this after the durable execution ledger is ready. Runtime
64
+ * lifecycle hooks reapply it to every watcher after reconnect. */
65
+ setWave9CloseLifecycle(lifecycle) {
66
+ this.wave9CloseLifecycle = lifecycle;
59
67
  }
60
68
  start() {
61
69
  if (this.interval) {
@@ -130,10 +138,62 @@ export class PositionWatcher extends EventEmitter {
130
138
  quantity: position.contracts,
131
139
  };
132
140
  this.emit('stop_triggered', event);
141
+ // Captured close order for the 'stop_closed' event — set inside close()
142
+ // (which may run under the symbol lock) and read after it resolves.
143
+ let closeOrder;
144
+ const close = async () => {
145
+ let trustedPosition = position;
146
+ // Re-read after acquiring the shared trading boundary. A Wave 9
147
+ // reversal or another stop may have flattened the symbol while this
148
+ // watcher was queued.
149
+ if (this.operationLock) {
150
+ const current = await this.adapter.getPositionsOrNull(position.symbol);
151
+ if (current === null) {
152
+ throw new Error('position recheck unavailable while holding trading operation lock');
153
+ }
154
+ const latest = current.find((candidate) => candidate.symbol === position.symbol);
155
+ if (!latest || !isStopBreached(latest)) {
156
+ logger.info(TAG, `${position.symbol} no longer stop-triggered after locked recheck — skipping`);
157
+ return 'skipped';
158
+ }
159
+ trustedPosition = latest;
160
+ }
161
+ let candidateId;
162
+ let ownershipError;
163
+ if (this.wave9CloseLifecycle) {
164
+ try {
165
+ candidateId = await this.wave9CloseLifecycle.resolveCandidateId(trustedPosition);
166
+ }
167
+ catch (error) {
168
+ ownershipError = formatError(error);
169
+ }
170
+ }
171
+ if (ownershipError) {
172
+ throw new Error(`Wave 9 ownership resolution failed before stop close submission: ${ownershipError}; ` +
173
+ 'IMMEDIATE MANUAL INTERVENTION REQUIRED');
174
+ }
175
+ closeOrder = await this.adapter.closePosition(trustedPosition.symbol, 'stop_watcher');
176
+ if (!candidateId || !this.wave9CloseLifecycle)
177
+ return 'closed';
178
+ const outcome = await this.wave9CloseLifecycle.settleAfterClose(candidateId, trustedPosition.symbol);
179
+ if (outcome.status === 'flat')
180
+ return 'closed';
181
+ if (outcome.status === 'protected') {
182
+ logger.warn(TAG, `Stop close left a Wave 9 residual on ${trustedPosition.symbol}; ` +
183
+ `${outcome.detail}. The exact frozen stop is proven and the watcher will retry.`);
184
+ this.emit('stop_residual_protected', { ...event, ...outcome });
185
+ return 'residual_protected';
186
+ }
187
+ throw new Error(outcome.detail);
188
+ };
133
189
  try {
134
- await this.adapter.closePosition(position.symbol, 'stop_watcher');
190
+ const result = this.operationLock
191
+ ? await this.operationLock.withSymbolLock(position.symbol, `stop_watcher:${position.symbol}`, close)
192
+ : await close();
193
+ if (result !== 'closed')
194
+ return;
135
195
  logger.info(TAG, `Auto-closed ${position.symbol} (reason=stop_watcher)`);
136
- this.emit('stop_closed', event);
196
+ this.emit('stop_closed', { ...event, order: closeOrder });
137
197
  }
138
198
  catch (err) {
139
199
  const msg = formatError(err);
@@ -2,7 +2,9 @@ import type { IExchangeAdapter } from '../exchange-adapter.js';
2
2
  import type { ExchangeConfig, TradingMode } from '../types.js';
3
3
  import type { ExchangeSimulator } from '../simulator/exchange-simulator.js';
4
4
  import type { PaperMarketFeed } from '../simulator/paper-market-feed.js';
5
+ import { LiveAdapter } from '../live/live-adapter.js';
5
6
  import { PositionWatcher } from '../live/stop-watcher.js';
7
+ import type { TradingOperationLock } from '../lifecycle/trading-operation-lock.js';
6
8
  export interface MicroLiveConfig {
7
9
  sizeCapPercent?: number;
8
10
  maxPositionUSDT?: number;
@@ -13,6 +15,13 @@ export interface BuildAdapterInput {
13
15
  microLive?: MicroLiveConfig;
14
16
  simulator: ExchangeSimulator;
15
17
  }
18
+ /** Wave 9 safety wiring is created only after its durable ledger is loaded.
19
+ * Runtime reapplies these hooks to both the bootstrap objects and every
20
+ * adapter/watcher built by a reconnect. */
21
+ export interface Wave9LiveLifecycleHooks {
22
+ configureLiveAdapter?(adapter: LiveAdapter): void;
23
+ configurePositionWatcher?(watcher: PositionWatcher, adapter: IExchangeAdapter): void;
24
+ }
16
25
  /** Pure-ish factory: builds an adapter for the requested mode.
17
26
  * Falls back to PaperAdapter if a live mode is requested without credentials —
18
27
  * callers should pre-validate via `modeRequiresCredentials`, but this
@@ -36,6 +45,13 @@ export declare class PluginRuntime {
36
45
  * stops fire and NAV/uPnL stay live for non-dashboard symbols). Lives for
37
46
  * the runtime's lifetime; started in paper, stopped in live. */
38
47
  private readonly _marketFeed;
48
+ private readonly operationLock?;
49
+ private wave9LiveLifecycleHooks?;
50
+ /** Observer applied to EVERY stop-watcher this runtime creates (reconnects
51
+ * included). index.ts uses it to attach the journal auto-capture listener
52
+ * for watcher closes (issue #199) — without it, a live<->paper reconnect
53
+ * would silently shed the capture wiring. */
54
+ private readonly onWatcherCreated?;
39
55
  /** Reconnect is serialized — a second caller waits for the first to finish
40
56
  * so we never tear down an adapter that's mid-rebuild. */
41
57
  private reconnectInFlight;
@@ -45,11 +61,14 @@ export declare class PluginRuntime {
45
61
  simulator: ExchangeSimulator;
46
62
  stopWatcher?: PositionWatcher | null;
47
63
  marketFeed?: PaperMarketFeed | null;
64
+ operationLock?: TradingOperationLock;
65
+ onWatcherCreated?: (watcher: PositionWatcher) => void;
48
66
  });
49
67
  get adapter(): IExchangeAdapter;
50
68
  get mode(): TradingMode;
51
69
  get stopWatcher(): PositionWatcher | null;
52
70
  get marketFeed(): PaperMarketFeed | null;
71
+ setWave9LiveLifecycleHooks(hooks?: Wave9LiveLifecycleHooks): void;
53
72
  /**
54
73
  * Swap the current adapter for a new one built from `next`. The old
55
74
  * adapter's background loops are stopped before the new one is wired in.
@@ -15,6 +15,7 @@ import { PaperAdapter } from '../paper-adapter.js';
15
15
  import { LiveAdapter } from '../live/live-adapter.js';
16
16
  import { PositionWatcher } from '../live/stop-watcher.js';
17
17
  import { loadBracketMode } from '../config/brackets-config.js';
18
+ import { loadStopWatcherIntervalMs } from '../config/plugin-config-io.js';
18
19
  import { logger, formatError } from '../logger.js';
19
20
  const TAG = 'plugin-runtime';
20
21
  /** Pure-ish factory: builds an adapter for the requested mode.
@@ -55,6 +56,13 @@ export class PluginRuntime {
55
56
  * stops fire and NAV/uPnL stay live for non-dashboard symbols). Lives for
56
57
  * the runtime's lifetime; started in paper, stopped in live. */
57
58
  _marketFeed;
59
+ operationLock;
60
+ wave9LiveLifecycleHooks;
61
+ /** Observer applied to EVERY stop-watcher this runtime creates (reconnects
62
+ * included). index.ts uses it to attach the journal auto-capture listener
63
+ * for watcher closes (issue #199) — without it, a live<->paper reconnect
64
+ * would silently shed the capture wiring. */
65
+ onWatcherCreated;
58
66
  /** Reconnect is serialized — a second caller waits for the first to finish
59
67
  * so we never tear down an adapter that's mid-rebuild. */
60
68
  reconnectInFlight = null;
@@ -64,11 +72,22 @@ export class PluginRuntime {
64
72
  this.simulator = initial.simulator;
65
73
  this._stopWatcher = initial.stopWatcher ?? null;
66
74
  this._marketFeed = initial.marketFeed ?? null;
75
+ this.operationLock = initial.operationLock;
76
+ this.onWatcherCreated = initial.onWatcherCreated;
67
77
  }
68
78
  get adapter() { return this._adapter; }
69
79
  get mode() { return this._mode; }
70
80
  get stopWatcher() { return this._stopWatcher; }
71
81
  get marketFeed() { return this._marketFeed; }
82
+ setWave9LiveLifecycleHooks(hooks) {
83
+ this.wave9LiveLifecycleHooks = hooks;
84
+ if (this._adapter instanceof LiveAdapter) {
85
+ hooks?.configureLiveAdapter?.(this._adapter);
86
+ }
87
+ if (this._stopWatcher) {
88
+ hooks?.configurePositionWatcher?.(this._stopWatcher, this._adapter);
89
+ }
90
+ }
72
91
  /**
73
92
  * Swap the current adapter for a new one built from `next`. The old
74
93
  * adapter's background loops are stopped before the new one is wired in.
@@ -82,7 +101,9 @@ export class PluginRuntime {
82
101
  if (this.reconnectInFlight) {
83
102
  await this.reconnectInFlight;
84
103
  }
85
- const run = this.doReconnect(next, deps);
104
+ const run = this.operationLock
105
+ ? this.operationLock.withAccountLock('runtime-reconnect', () => this.doReconnect(next, deps))
106
+ : this.doReconnect(next, deps);
86
107
  this.reconnectInFlight = run.finally(() => {
87
108
  this.reconnectInFlight = null;
88
109
  });
@@ -117,6 +138,11 @@ export class PluginRuntime {
117
138
  microLive: next.microLive,
118
139
  simulator: this.simulator,
119
140
  });
141
+ // Install autonomous protection callbacks before initialization can emit
142
+ // user-data or bracket-reconciler events.
143
+ if (fresh instanceof LiveAdapter) {
144
+ this.wave9LiveLifecycleHooks?.configureLiveAdapter?.(fresh);
145
+ }
120
146
  // 4. Fire async init for live adapters (non-blocking — readiness flips
121
147
  // INIT_PENDING → READY/DEGRADED/BLOCKED on its own).
122
148
  if (fresh instanceof LiveAdapter) {
@@ -133,8 +159,13 @@ export class PluginRuntime {
133
159
  this._adapter = fresh;
134
160
  this._mode = next.mode;
135
161
  deps.adapterDeps.adapter = fresh;
136
- // 6. Start a new stop-watcher bound to the new adapter.
137
- const watcher = new PositionWatcher(fresh);
162
+ // 6. Start a new stop-watcher bound to the new adapter. Re-read the
163
+ // operator's cadence override building with `undefined` here
164
+ // silently reverted plugin-config `stopWatcher.intervalMs` to the
165
+ // default on every reconnect.
166
+ const watcher = new PositionWatcher(fresh, loadStopWatcherIntervalMs(), this.operationLock);
167
+ this.wave9LiveLifecycleHooks?.configurePositionWatcher?.(watcher, fresh);
168
+ this.onWatcherCreated?.(watcher);
138
169
  watcher.start();
139
170
  this._stopWatcher = watcher;
140
171
  // 7. Paper market feed follows the mode: run it when the new adapter is
@@ -36,6 +36,7 @@
36
36
  "get_market_intel",
37
37
  "get_regime",
38
38
  "get_signals",
39
+ "get_wave9_status",
39
40
  "get_analytics",
40
41
  "get_orderbook",
41
42
  "get_volume_profile",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reefclaw/openclaw-plugin",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "ReefClaw trading plugin for OpenClaw \u2014 paper trading with real Binance market data, plus the ReefClaw dashboard connector (supervised by OpenClaw, no service manager needed). Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -22,7 +22,7 @@
22
22
  "node": ">=20"
23
23
  },
24
24
  "dependencies": {
25
- "@reefclaw/shared": "0.1.1",
25
+ "@reefclaw/shared": "0.1.2",
26
26
  "ccxt": "4.5.37",
27
27
  "json5": "2.2.3",
28
28
  "ws": "8.19.0"
@@ -0,0 +1,36 @@
1
+ export interface ReentryExitRecord {
2
+ /** Canonical symbol (no settle suffix). */
3
+ symbol: string;
4
+ /** setup_type / strategy name from the entry metadata, when known. */
5
+ setupType?: string;
6
+ side: 'long' | 'short';
7
+ /** Whether the closed trade realized a loss (drives the stronger caution). */
8
+ wasLoss?: boolean;
9
+ closedAtMs: number;
10
+ }
11
+ /** Signal-bar duration for a strategy/setup name. Name-suffix inference:
12
+ * `..._4h` → 240min, `..._1d` / daily → 1440min, `..._Nh` → N×60. Unknown
13
+ * shapes default to 60min so short-TF strategies are never over-warned. */
14
+ export declare function strategyBarMinutes(name: string | undefined): number;
15
+ export declare class ReentryTracker {
16
+ private records;
17
+ private readonly filePath;
18
+ private readonly dir;
19
+ constructor(pluginId?: string, opts?: {
20
+ basePath?: string;
21
+ });
22
+ /** Record a position exit. Never throws (best-effort persistence). */
23
+ recordExit(record: Omit<ReentryExitRecord, 'symbol'> & {
24
+ symbol: string;
25
+ }): void;
26
+ /** Most recent exit for (symbol[, setup]). A setup-specific record wins over
27
+ * a symbol-only match so multi-strategy books get precise cautions. */
28
+ lastExit(symbol: string, setupType?: string): ReentryExitRecord | undefined;
29
+ /** Structured caution when (symbol, strategy) was already traded within the
30
+ * current signal bar. Undefined = no caution. Pure indication (issue #204):
31
+ * the agent decides; nothing here blocks an order. */
32
+ cautionFor(symbol: string, strategy: string | undefined, nowMs?: number): string | undefined;
33
+ /** Test seam. */
34
+ getRecords(): readonly ReentryExitRecord[];
35
+ private persist;
36
+ }
@@ -0,0 +1,127 @@
1
+ // Re-entry tracker — records recent position exits per (symbol, setup) so the
2
+ // entry funnel (scan_pairs / get_signals) can flag setups the agent already
3
+ // traded within the current signal bar (issue #204).
4
+ //
5
+ // WHY: the validated backtests for the 4h/1d templates have one-trade-per-
6
+ // signal semantics — a signal bar produces at most one trade. Live, the 4h
7
+ // condition stays true for hours, so after every exit the next heartbeat
8
+ // re-entered the same setup: measured 19× the backtest cadence with 79-minute
9
+ // median holds, including 22 same-direction re-entries within one bar of a
10
+ // LOSING close (2026-07-14 → 07-20 HL soak). Per the tools-not-mandates
11
+ // doctrine this ships as STRUCTURED INDICATION — the annotation tells the
12
+ // agent the setup was already traded this bar; it never blocks the order.
13
+ //
14
+ // Persistence: small JSON in the plugins base dir (same pattern as
15
+ // position-state-store) so restarts don't blind the indication. Best-effort —
16
+ // a persistence failure degrades to in-memory-only, never throws into the
17
+ // trading path.
18
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { logger, formatError } from '../logger.js';
21
+ import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
22
+ import { resolvePluginsBaseDir } from '../util/plugin-paths.js';
23
+ const TAG = 'reentry-tracker';
24
+ const DEFAULT_PLUGIN_ID = 'reefclaw-paper-trading';
25
+ const STATE_FILENAME = 'reentry-log.json';
26
+ const MAX_RECORDS = 300;
27
+ /** Signal-bar duration for a strategy/setup name. Name-suffix inference:
28
+ * `..._4h` → 240min, `..._1d` / daily → 1440min, `..._Nh` → N×60. Unknown
29
+ * shapes default to 60min so short-TF strategies are never over-warned. */
30
+ export function strategyBarMinutes(name) {
31
+ if (!name)
32
+ return 60;
33
+ const lower = name.toLowerCase();
34
+ const hourMatch = lower.match(/_(\d{1,2})h\b|_(\d{1,2})h_|_(\d{1,2})h$/);
35
+ if (hourMatch) {
36
+ const h = Number(hourMatch[1] ?? hourMatch[2] ?? hourMatch[3]);
37
+ if (Number.isFinite(h) && h > 0)
38
+ return h * 60;
39
+ }
40
+ if (/_1d\b|_1d_|_1d$|daily/.test(lower))
41
+ return 1440;
42
+ return 60;
43
+ }
44
+ export class ReentryTracker {
45
+ records = [];
46
+ filePath;
47
+ dir;
48
+ constructor(pluginId, opts) {
49
+ const base = resolvePluginsBaseDir(opts?.basePath);
50
+ this.dir = join(base, pluginId ?? DEFAULT_PLUGIN_ID);
51
+ this.filePath = join(this.dir, STATE_FILENAME);
52
+ try {
53
+ if (!existsSync(this.dir))
54
+ mkdirSync(this.dir, { recursive: true });
55
+ if (existsSync(this.filePath)) {
56
+ const parsed = JSON.parse(readFileSync(this.filePath, 'utf-8'));
57
+ if (Array.isArray(parsed?.records)) {
58
+ this.records = parsed.records.filter((r) => typeof r?.symbol === 'string' && Number.isFinite(r?.closedAtMs));
59
+ }
60
+ }
61
+ }
62
+ catch (err) {
63
+ logger.warn(TAG, `load failed (${formatError(err)}) — starting empty`);
64
+ this.records = [];
65
+ }
66
+ }
67
+ /** Record a position exit. Never throws (best-effort persistence). */
68
+ recordExit(record) {
69
+ try {
70
+ this.records.push({ ...record, symbol: normalizeBracketSymbol(record.symbol) });
71
+ if (this.records.length > MAX_RECORDS) {
72
+ this.records = this.records.slice(-MAX_RECORDS);
73
+ }
74
+ this.persist();
75
+ }
76
+ catch (err) {
77
+ logger.warn(TAG, `recordExit failed: ${formatError(err)}`);
78
+ }
79
+ }
80
+ /** Most recent exit for (symbol[, setup]). A setup-specific record wins over
81
+ * a symbol-only match so multi-strategy books get precise cautions. */
82
+ lastExit(symbol, setupType) {
83
+ const key = normalizeBracketSymbol(symbol);
84
+ let bySetup;
85
+ let bySymbol;
86
+ for (let i = this.records.length - 1; i >= 0; i--) {
87
+ const r = this.records[i];
88
+ if (r.symbol !== key)
89
+ continue;
90
+ if (!bySymbol)
91
+ bySymbol = r;
92
+ if (setupType && r.setupType === setupType) {
93
+ bySetup = r;
94
+ break;
95
+ }
96
+ if (!setupType)
97
+ break;
98
+ }
99
+ return bySetup ?? bySymbol;
100
+ }
101
+ /** Structured caution when (symbol, strategy) was already traded within the
102
+ * current signal bar. Undefined = no caution. Pure indication (issue #204):
103
+ * the agent decides; nothing here blocks an order. */
104
+ cautionFor(symbol, strategy, nowMs = Date.now()) {
105
+ const last = this.lastExit(symbol, strategy);
106
+ if (!last)
107
+ return undefined;
108
+ const barMin = strategyBarMinutes(strategy ?? last.setupType);
109
+ const agoMin = Math.round((nowMs - last.closedAtMs) / 60_000);
110
+ if (agoMin < 0 || agoMin > barMin)
111
+ return undefined;
112
+ const lossNote = last.wasLoss ? ' at a LOSS' : '';
113
+ return (`already traded this signal bar: exited a ${last.side} on this setup ${agoMin}m ago${lossNote} ` +
114
+ `(bar=${barMin}m). The validated backtest takes ONE trade per signal bar — re-enter only if ` +
115
+ `you can name what NEW information arrived since that exit.`);
116
+ }
117
+ /** Test seam. */
118
+ getRecords() {
119
+ return this.records;
120
+ }
121
+ persist() {
122
+ const payload = { schemaVersion: 1, records: this.records };
123
+ const tmp = `${this.filePath}.tmp`;
124
+ writeFileSync(tmp, JSON.stringify(payload), 'utf-8');
125
+ renameSync(tmp, this.filePath);
126
+ }
127
+ }
@@ -0,0 +1,67 @@
1
+ import type { Direction } from '../signals/types.js';
2
+ import { type Wave9StrategyName } from './wave9-policy.js';
3
+ export interface Wave9AdmissionPolicy {
4
+ riskPct: number;
5
+ maxPositions: number;
6
+ maxOpenRiskPct: number;
7
+ maxGrossExposureMultiple: number;
8
+ maxPositionUsd: number;
9
+ feeBps: number;
10
+ slippageBps: number;
11
+ capEnforcement: 'admission_only_no_forced_deleveraging';
12
+ riskMeasure: 'fixed_initial_stop_risk_usd';
13
+ }
14
+ export declare const WAVE9_ADMISSION_POLICY: Readonly<Wave9AdmissionPolicy>;
15
+ export interface Wave9AdmissionPosition {
16
+ candidateId: string;
17
+ symbol: string;
18
+ strategy: Wave9StrategyName;
19
+ direction: Direction;
20
+ entryPrice: number;
21
+ quantity: number;
22
+ markPrice: number;
23
+ initialStop: number;
24
+ dollarRiskUsd: number;
25
+ accruedFundingUsd: number;
26
+ }
27
+ export interface Wave9AdmissionState {
28
+ cashEquityUsd: number;
29
+ positions: Wave9AdmissionPosition[];
30
+ }
31
+ export interface Wave9AdmissionCandidate {
32
+ id: string;
33
+ eventTime: string;
34
+ symbol: string;
35
+ strategy: Wave9StrategyName;
36
+ direction: Direction;
37
+ referencePrice: number;
38
+ stopPrice: number;
39
+ }
40
+ export type Wave9AdmissionReason = 'accepted' | 'invalidGeometry' | 'symbolAlreadyOpen' | 'maxPositions' | 'maxGrossExposure' | 'maxOpenRisk' | 'invalidSizing';
41
+ export interface Wave9AdmissionLedgerRow extends Wave9AdmissionPosition {
42
+ estimatedExitCostUsd: number;
43
+ }
44
+ export interface Wave9AdmissionDecision extends Wave9AdmissionCandidate {
45
+ accepted: boolean;
46
+ reason: Wave9AdmissionReason;
47
+ entryPrice?: number;
48
+ quantity?: number;
49
+ notionalUsd?: number;
50
+ riskUsd?: number;
51
+ projectedCashEquityUsd?: number;
52
+ projectedLiquidationEquityUsd?: number;
53
+ projectedGrossExposureUsd?: number;
54
+ projectedOpenRiskUsd?: number;
55
+ projectedOpenPositions?: number;
56
+ projectedPositionLedger?: Wave9AdmissionLedgerRow[];
57
+ }
58
+ export interface Wave9AdmissionBatchResult {
59
+ decisions: Wave9AdmissionDecision[];
60
+ state: Wave9AdmissionState;
61
+ }
62
+ /**
63
+ * Apply a same-event candidate batch in the exact frozen priority. The caller
64
+ * must release stops and completed-daily reversals before invoking this
65
+ * function, just as the chronological research engine does.
66
+ */
67
+ export declare function applyWave9AdmissionBatch(inputState: Wave9AdmissionState, rawCandidates: readonly Wave9AdmissionCandidate[], policy?: Readonly<Wave9AdmissionPolicy>): Wave9AdmissionBatchResult;