@reefclaw/openclaw-plugin 0.1.26 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1001,6 +1001,13 @@ const paperTradingPlugin = {
1001
1001
  logger.warn(TAG, `State reload failed: ${formatError(err)}`);
1002
1002
  }
1003
1003
  };
1004
+ // Out-of-tool readers (periodic DB-vs-exchange sweep, stop-watcher) read
1005
+ // the PaperAdapter directly and never went through reloadState — in the
1006
+ // process that did not execute the entry they saw a stale book and posted
1007
+ // false `reconciler_observed_flat` closes seconds after every entry
1008
+ // (2026-09-14). Hook on the simulator so every adapter built from it
1009
+ // (boot + every reconnect path) refreshes before a read.
1010
+ simulator.setStateRefresher(reloadState);
1004
1011
  // Read config: ReefClaw plugin config from ~/.reefclaw/plugin-config.json
1005
1012
  // (OpenClaw's schema validation rejects custom keys in plugin entries)
1006
1013
  let connectionToken = '';
@@ -13,6 +13,14 @@ export interface DbVsExchangeContext {
13
13
  * venue's snapshot can never contain. Absent → unscoped (legacy). */
14
14
  resolveExchange?: () => 'binance' | 'hyperliquid';
15
15
  }
16
+ /** Rows opened more recently than this are never orphan-closed. An entry
17
+ * that filled seconds ago and is "absent from the exchange" is propagation
18
+ * lag, not an orphan: the paper book's debounced state.json save (1s) has
19
+ * not landed in the other process yet, or a live fill's REST snapshot raced
20
+ * the journal POST. A real orphan is still harvested by the next sweep
21
+ * (5 min) — the grace costs nothing and removes the false-close class that
22
+ * hit every entry on a paying tenant's box on 2026-09-14. */
23
+ export declare const DEFAULT_MIN_OPEN_AGE_MS = 120000;
16
24
  /**
17
25
  * Close webapp `positions` rows that are status='open' but absent from the
18
26
  * (trusted) exchange snapshot. Returns the number of synthetic closes posted.
@@ -23,6 +31,9 @@ export interface DbVsExchangeContext {
23
31
  export interface DbReconcileOptions {
24
32
  /** Provenance tag written into closeAssessment.source (default boot sweep). */
25
33
  source?: string;
34
+ /** Young-row grace (ms); rows with `nowMs - openedAt < minOpenAgeMs` are
35
+ * skipped with a log line. Default DEFAULT_MIN_OPEN_AGE_MS; 0 disables. */
36
+ minOpenAgeMs?: number;
26
37
  /** Attribution hook (issue #203): given an orphaned symbol, return a short
27
38
  * human-readable description of what the execution engine last knew about
28
39
  * it (e.g. the simulator's last trade). Logged with the synthetic close so
@@ -32,6 +43,8 @@ export interface DbReconcileOptions {
32
43
  export declare function reconcileDbOpenVsExchange(ctx: DbVsExchangeContext, exchangeSymbols: Iterable<string>, nowMs?: number, opts?: DbReconcileOptions): Promise<number>;
33
44
  export declare const DEFAULT_DB_RECONCILE_INTERVAL_MS = 300000;
34
45
  export interface PeriodicDbReconcileDeps extends DbVsExchangeContext {
46
+ /** Young-row grace forwarded to every sweep (default DEFAULT_MIN_OPEN_AGE_MS). */
47
+ minOpenAgeMs?: number;
35
48
  /** Resolve the ACTIVE adapter each tick (follows runtime reconnects). */
36
49
  resolveAdapter: () => {
37
50
  getPositionsOrNull(symbol?: string): Promise<Array<{
@@ -43,6 +43,14 @@ const TAG = 'reconcile-db-vs-exchange';
43
43
  function canonical(symbol) {
44
44
  return symbol.split(':')[0];
45
45
  }
46
+ /** Rows opened more recently than this are never orphan-closed. An entry
47
+ * that filled seconds ago and is "absent from the exchange" is propagation
48
+ * lag, not an orphan: the paper book's debounced state.json save (1s) has
49
+ * not landed in the other process yet, or a live fill's REST snapshot raced
50
+ * the journal POST. A real orphan is still harvested by the next sweep
51
+ * (5 min) — the grace costs nothing and removes the false-close class that
52
+ * hit every entry on a paying tenant's box on 2026-09-14. */
53
+ export const DEFAULT_MIN_OPEN_AGE_MS = 120_000;
46
54
  export async function reconcileDbOpenVsExchange(ctx, exchangeSymbols, nowMs = Date.now(), opts = {}) {
47
55
  if (!ctx.decisionsClient || !ctx.userId)
48
56
  return 0;
@@ -58,7 +66,16 @@ export async function reconcileDbOpenVsExchange(ctx, exchangeSymbols, nowMs = Da
58
66
  const exchangeSet = new Set();
59
67
  for (const s of exchangeSymbols)
60
68
  exchangeSet.add(canonical(s));
61
- const orphans = resp.positions.filter((p) => !exchangeSet.has(canonical(p.symbol)));
69
+ const minOpenAgeMs = opts.minOpenAgeMs ?? DEFAULT_MIN_OPEN_AGE_MS;
70
+ const absent = resp.positions.filter((p) => !exchangeSet.has(canonical(p.symbol)));
71
+ const orphans = absent.filter((p) => {
72
+ const ageMs = nowMs - p.openedAt;
73
+ if (Number.isFinite(ageMs) && ageMs < minOpenAgeMs) {
74
+ logger.info(TAG, `${p.symbol}: opened ${Math.round(ageMs / 1000)}s ago and absent from the snapshot — within the ${Math.round(minOpenAgeMs / 1000)}s grace, not treated as an orphan (propagation lag; next sweep re-checks)`);
75
+ return false;
76
+ }
77
+ return true;
78
+ });
62
79
  if (orphans.length === 0) {
63
80
  logger.info(TAG, `DB reconcile: all ${resp.positions.length} open row(s) present on exchange`);
64
81
  return 0;
@@ -149,7 +166,11 @@ export function startPeriodicDbReconcile(deps, intervalMs = resolveDbReconcileIn
149
166
  logger.warn(TAG, 'periodic sweep skipped — positions fetch untrusted (null)');
150
167
  return 0;
151
168
  }
152
- return await reconcileDbOpenVsExchange(deps, positions.map((p) => p.symbol), Date.now(), { source: 'db_exchange_sweep_periodic', describeLastExit: deps.describeLastExit });
169
+ return await reconcileDbOpenVsExchange(deps, positions.map((p) => p.symbol), Date.now(), {
170
+ source: 'db_exchange_sweep_periodic',
171
+ describeLastExit: deps.describeLastExit,
172
+ minOpenAgeMs: deps.minOpenAgeMs,
173
+ });
153
174
  }
154
175
  catch (err) {
155
176
  logger.warn(TAG, `periodic sweep failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "reefclaw-paper-trading",
3
3
  "name": "ReefClaw Trading",
4
- "version": "0.1.26",
4
+ "version": "0.1.27",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@reefclaw/openclaw-plugin",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
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",
package/paper-adapter.js CHANGED
@@ -25,17 +25,25 @@ export class PaperAdapter {
25
25
  async closePosition(symbol, closeReason) {
26
26
  return this.simulator.closePosition(symbol, closeReason);
27
27
  }
28
+ // Reads pull a newer state.json first (ExchangeSimulator.refreshState —
29
+ // no-op without a hook). Out-of-tool readers (DB-vs-exchange sweep,
30
+ // stop-watcher) otherwise see a stale book in the process that did not
31
+ // execute the entry and post false synthetic closes / phantom stop fills.
28
32
  async getBalance() {
33
+ this.simulator.refreshState();
29
34
  return this.simulator.getBalance();
30
35
  }
31
36
  async getPositions(symbol) {
37
+ this.simulator.refreshState();
32
38
  return this.simulator.getPositions(symbol);
33
39
  }
34
40
  /** Paper has no exchange fetch to fail — position state is always known. */
35
41
  async getPositionsOrNull(symbol) {
42
+ this.simulator.refreshState();
36
43
  return this.simulator.getPositions(symbol);
37
44
  }
38
45
  async getOpenOrders(symbol) {
46
+ this.simulator.refreshState();
39
47
  return this.simulator.getOpenOrders(symbol);
40
48
  }
41
49
  async fetchOrder(orderId, _symbol) {
@@ -63,6 +63,13 @@ export declare class ExchangeSimulator extends EventEmitter {
63
63
  * on open and is released on close, so we must add it back here. */
64
64
  computeEquity(): number;
65
65
  private getQuoteCurrency;
66
+ private stateRefresher;
67
+ /** Install the "reload state.json if it changed" hook (index.ts reloadState). */
68
+ setStateRefresher(fn: (() => void) | null): void;
69
+ /** Pull a newer on-disk snapshot before an out-of-tool read. No-op when no
70
+ * hook is installed (tests / single-process). Never throws — a failed
71
+ * reload leaves the current in-memory book in place. */
72
+ refreshState(): void;
66
73
  getBalance(): CcxtBalance;
67
74
  getPositions(symbol?: string): CcxtPosition[];
68
75
  getOpenOrders(symbol?: string): CcxtOrder[];
@@ -153,6 +153,36 @@ export class ExchangeSimulator extends EventEmitter {
153
153
  getQuoteCurrency() {
154
154
  return this.state.config?.quoteCurrency ?? 'USDT';
155
155
  }
156
+ // ---- Cross-process refresh (two-process architecture) ----
157
+ //
158
+ // The agent and gateway processes each hold their own ExchangeSimulator and
159
+ // share state through state.json. Every paper TOOL path calls index.ts
160
+ // reloadState() before reading, but out-of-tool readers (the periodic
161
+ // DB-vs-exchange sweep, the stop-watcher's 3s poll) read the adapter
162
+ // directly — and a process that did NOT execute the entry then sees a
163
+ // stale in-memory book. On 2026-09-14 that posted a synthetic
164
+ // `reconciler_observed_flat` close 5–100s after EVERY entry on a paying
165
+ // tenant's box while the engine kept the position alive for hours. The
166
+ // hook lives on the simulator (not the adapter) so every PaperAdapter ever
167
+ // built from it — boot or any reconnect path — inherits it.
168
+ stateRefresher = null;
169
+ /** Install the "reload state.json if it changed" hook (index.ts reloadState). */
170
+ setStateRefresher(fn) {
171
+ this.stateRefresher = fn;
172
+ }
173
+ /** Pull a newer on-disk snapshot before an out-of-tool read. No-op when no
174
+ * hook is installed (tests / single-process). Never throws — a failed
175
+ * reload leaves the current in-memory book in place. */
176
+ refreshState() {
177
+ if (!this.stateRefresher)
178
+ return;
179
+ try {
180
+ this.stateRefresher();
181
+ }
182
+ catch (err) {
183
+ logger.warn(TAG, `state refresh failed: ${err instanceof Error ? err.message : String(err)}`);
184
+ }
185
+ }
156
186
  // ---- Read operations (for tools) ----
157
187
  getBalance() {
158
188
  const free = {};