@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 +7 -0
- package/ingest/reconcile-db-vs-exchange.d.ts +13 -0
- package/ingest/reconcile-db-vs-exchange.js +23 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/paper-adapter.js +8 -0
- package/simulator/exchange-simulator.d.ts +7 -0
- package/simulator/exchange-simulator.js +30 -0
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
|
|
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(), {
|
|
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)}`);
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "reefclaw-paper-trading",
|
|
3
3
|
"name": "ReefClaw Trading",
|
|
4
|
-
"version": "0.1.
|
|
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.
|
|
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 = {};
|