@reefclaw/openclaw-plugin 0.1.1 → 0.1.2
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/bridge/bridge.d.ts +28 -1
- package/bridge/bridge.js +99 -4
- package/ccxt/binance-ban-gate.js +9 -0
- package/config/agent-config-client.d.ts +22 -0
- package/config/agent-config-client.js +44 -1
- package/config/agent-config-poller.d.ts +8 -1
- package/config/agent-config-poller.js +1 -0
- package/config/entitlement-gate.d.ts +51 -0
- package/config/entitlement-gate.js +137 -0
- package/index.js +40 -23
- package/ingest/pending-entry-metadata.d.ts +31 -9
- package/ingest/pending-entry-metadata.js +70 -16
- package/ingest/position-auto-capture.js +14 -3
- package/ingest/readiness-reporter.d.ts +19 -0
- package/ingest/readiness-reporter.js +142 -0
- package/live/exchange-info-cache.d.ts +3 -1
- package/live/exchange-info-cache.js +17 -2
- package/package.json +1 -1
- package/signals/strategy-adapter.d.ts +35 -2
- package/signals/strategy-adapter.js +87 -10
- package/tools/create-order.js +26 -20
package/index.js
CHANGED
|
@@ -43,6 +43,7 @@ import { readPluginConfig, readOpenClawConnection } from './config/plugin-config
|
|
|
43
43
|
import { startConnectorSupervisor, hasBundledBridge } from './connector-supervisor.js';
|
|
44
44
|
import { ToolGate } from './config/tool-gate.js';
|
|
45
45
|
import { gateStore } from './config/gate-store.js';
|
|
46
|
+
import { entitlementGate } from './config/entitlement-gate.js';
|
|
46
47
|
import { startAgentConfigPoller } from './config/agent-config-poller.js';
|
|
47
48
|
import { PositionWatcher } from './live/stop-watcher.js';
|
|
48
49
|
import { PaperMarketFeed } from './simulator/paper-market-feed.js';
|
|
@@ -1273,31 +1274,39 @@ const paperTradingPlugin = {
|
|
|
1273
1274
|
// weight). getPositionsOrNull() returns null on a failed/weight-paced
|
|
1274
1275
|
// fetch; getPositions() would collapse that to [] (null≠empty trap).
|
|
1275
1276
|
const trusted = await liveAdapter.getPositionsOrNull();
|
|
1276
|
-
const positions = trusted ?? [];
|
|
1277
|
-
await reconcileStateStoreOnStartup({
|
|
1278
|
-
decisionsClient: positionDecisionsClient,
|
|
1279
|
-
stateStore: positionStateStore,
|
|
1280
|
-
userId: positionDecisionsUserId,
|
|
1281
|
-
lastPriceFn: (sym) => liveAdapter.getLastPrice(sym),
|
|
1282
|
-
}, positions.map((p) => p.symbol));
|
|
1283
|
-
// Seed remaining-contracts for positions still open on the exchange
|
|
1284
|
-
// so the reduce-only-fill close handler can detect flat for entries
|
|
1285
|
-
// that opened before this plugin instance started (or before the
|
|
1286
|
-
// size-tracking field existed). seedRemainingContracts never clobbers
|
|
1287
|
-
// live fill-tracked state.
|
|
1288
|
-
for (const p of positions) {
|
|
1289
|
-
const contracts = Math.abs(Number(p.contracts));
|
|
1290
|
-
if (Number.isFinite(contracts) && contracts > 0) {
|
|
1291
|
-
positionStateStore.seedRemainingContracts(p.symbol, contracts);
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
// DB-authoritative orphan close: close webapp `positions` rows that
|
|
1295
|
-
// are status='open' but absent from the exchange (closed on-exchange
|
|
1296
|
-
// while down / state-store wiped). ONLY on a TRUSTED snapshot — a
|
|
1297
|
-
// null (failed) fetch must never be read as "flat" and close the book.
|
|
1298
1277
|
if (trusted !== null) {
|
|
1278
|
+
// State-store sweep ONLY on a TRUSTED snapshot: this call treats
|
|
1279
|
+
// every state-store symbol absent from the list as closed and
|
|
1280
|
+
// removes it (incl. its webappPositionId journal linkage), so
|
|
1281
|
+
// collapsing a null (failed) fetch to [] would wipe the store
|
|
1282
|
+
// for every open position on a transient boot-time 429/paced
|
|
1283
|
+
// read — the null≠empty trap, with no self-healing retry.
|
|
1284
|
+
await reconcileStateStoreOnStartup({
|
|
1285
|
+
decisionsClient: positionDecisionsClient,
|
|
1286
|
+
stateStore: positionStateStore,
|
|
1287
|
+
userId: positionDecisionsUserId,
|
|
1288
|
+
lastPriceFn: (sym) => liveAdapter.getLastPrice(sym),
|
|
1289
|
+
}, trusted.map((p) => p.symbol));
|
|
1290
|
+
// Seed remaining-contracts for positions still open on the exchange
|
|
1291
|
+
// so the reduce-only-fill close handler can detect flat for entries
|
|
1292
|
+
// that opened before this plugin instance started (or before the
|
|
1293
|
+
// size-tracking field existed). seedRemainingContracts never clobbers
|
|
1294
|
+
// live fill-tracked state.
|
|
1295
|
+
for (const p of trusted) {
|
|
1296
|
+
const contracts = Math.abs(Number(p.contracts));
|
|
1297
|
+
if (Number.isFinite(contracts) && contracts > 0) {
|
|
1298
|
+
positionStateStore.seedRemainingContracts(p.symbol, contracts);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
// DB-authoritative orphan close: close webapp `positions` rows that
|
|
1302
|
+
// are status='open' but absent from the exchange (closed on-exchange
|
|
1303
|
+
// while down / state-store wiped). Same trusted-snapshot-only rule —
|
|
1304
|
+
// a null fetch must never be read as "flat" and close the book.
|
|
1299
1305
|
await reconcileDbOpenVsExchange({ decisionsClient: positionDecisionsClient, userId: positionDecisionsUserId }, trusted.map((p) => p.symbol));
|
|
1300
1306
|
}
|
|
1307
|
+
else {
|
|
1308
|
+
logger.warn(TAG, 'Startup reconciliation skipped — positions fetch untrusted (null); state-store left untouched. Runtime drift_detected + periodic reconcilers cover the gap.');
|
|
1309
|
+
}
|
|
1301
1310
|
}
|
|
1302
1311
|
catch (err) {
|
|
1303
1312
|
logger.warn(TAG, `Startup state-store reconciliation failed: ${formatError(err)}`);
|
|
@@ -1958,7 +1967,13 @@ const paperTradingPlugin = {
|
|
|
1958
1967
|
// config blob can never strip close_position/attach_brackets/etc.
|
|
1959
1968
|
// Kill-switch: RC_TOOL_GATE=off. See docs/TOOL_DISTRIBUTION_ARCHITECTURE.md §5f.
|
|
1960
1969
|
const toolGate = new ToolGate();
|
|
1961
|
-
|
|
1970
|
+
// Entitlement gate (account-level enforcement): wraps OUTSIDE the
|
|
1971
|
+
// ToolGate so an expired subscription's message wins over an operator
|
|
1972
|
+
// OFF-list message. It touches ONLY ENTITLEMENT_GATED_TOOLS (exactly
|
|
1973
|
+
// create_order — new risk); closes/stops/brackets/emergency are not in
|
|
1974
|
+
// the set by construction. No server verdict → fail-open. Kill-switch:
|
|
1975
|
+
// RC_ENTITLEMENT_GATE=off. See config/entitlement-gate.ts.
|
|
1976
|
+
const gatedTools = tools.map((t) => entitlementGate.wrapTool(toolGate.wrapTool(t, jsonResult), jsonResult));
|
|
1962
1977
|
// Poll the central config (boot + every ~60s), keyed by the same rc_
|
|
1963
1978
|
// token the ingest paths use (plugin-config connectionToken →
|
|
1964
1979
|
// WEBAPP_INGEST_TOKEN env fallback). No token → poller no-ops → gate
|
|
@@ -1966,9 +1981,11 @@ const paperTradingPlugin = {
|
|
|
1966
1981
|
// gateStore carries centrally-delivered operational gates (slice 2:
|
|
1967
1982
|
// exitGate) — loadExitGateMode() consults it before the local file;
|
|
1968
1983
|
// kill-switch RC_CENTRAL_GATES=off. See config/gate-store.ts.
|
|
1984
|
+
// entitlementGate receives the server-resolved billing verdict.
|
|
1969
1985
|
startAgentConfigPoller({
|
|
1970
1986
|
gate: toolGate,
|
|
1971
1987
|
gateStore,
|
|
1988
|
+
entitlementGate,
|
|
1972
1989
|
apiBaseUrl,
|
|
1973
1990
|
token: resolveIngestToken({ connectionToken }),
|
|
1974
1991
|
});
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { PositionMetadata } from '../simulator/types.js';
|
|
2
2
|
export interface PendingEntry {
|
|
3
|
-
/**
|
|
3
|
+
/** Primary lookup key. Initially the client order id (the stash happens
|
|
4
|
+
* BEFORE submission, when no exchange orderId exists yet); promote()
|
|
5
|
+
* re-keys it to the exchange-issued orderId once the REST ack returns. */
|
|
4
6
|
orderId: string;
|
|
7
|
+
/** Client order id (idempotency key) — secondary lookup key. The WS
|
|
8
|
+
* ORDER_TRADE_UPDATE carries it (`o.c`), so a fill that arrives BEFORE
|
|
9
|
+
* the REST response (routine for market orders) still finds the stash. */
|
|
10
|
+
clientOrderId?: string;
|
|
5
11
|
/** Canonical (un-suffixed) symbol. */
|
|
6
12
|
symbol: string;
|
|
7
13
|
/** Side the agent intended at create_order time. */
|
|
@@ -24,25 +30,41 @@ export declare class PendingEntryStore {
|
|
|
24
30
|
private readonly now;
|
|
25
31
|
private readonly ttlMs;
|
|
26
32
|
private entries;
|
|
33
|
+
/** clientOrderId → primary key in `entries`. Lets WS fills that beat the
|
|
34
|
+
* REST ack (no exchange orderId known yet) find the stash by cid. */
|
|
35
|
+
private cidIndex;
|
|
27
36
|
constructor(opts?: PendingEntryStoreOptions);
|
|
28
|
-
/** Stash metadata until the WS fill for this
|
|
37
|
+
/** Stash metadata until the WS fill for this order arrives. Keyed by
|
|
38
|
+
* `orderId`; when `clientOrderId` is provided the entry is also findable
|
|
39
|
+
* by it (pre-submission stashes pass the cid as BOTH — see create_order). */
|
|
29
40
|
put(input: {
|
|
30
41
|
orderId: string;
|
|
42
|
+
clientOrderId?: string;
|
|
31
43
|
symbol: string;
|
|
32
44
|
side: 'buy' | 'sell';
|
|
33
45
|
metadata: PositionMetadata;
|
|
34
46
|
}): void;
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
47
|
+
/** Re-key a pre-submission stash (keyed by clientOrderId) to the
|
|
48
|
+
* exchange-issued orderId once the REST ack returns. The cid alias keeps
|
|
49
|
+
* working. No-op when the entry is gone (already consumed by a WS fill
|
|
50
|
+
* that beat the REST response, or expired). */
|
|
51
|
+
promote(fromKey: string, exchangeOrderId: string): void;
|
|
52
|
+
/** Retrieve metadata stashed for an orderId (or clientOrderId alias).
|
|
53
|
+
* Does NOT remove the entry — callers should call `consume(orderId)` to
|
|
54
|
+
* remove on first match, since multiple partial fills on the same limit
|
|
55
|
+
* order all share one orderId and the FIRST fill is the entry; subsequent
|
|
56
|
+
* are scale-ins of an already-registered position (state-store branch
|
|
57
|
+
* handles those). */
|
|
40
58
|
get(orderId: string): PendingEntry | undefined;
|
|
41
|
-
/** Atomic get + remove. Use this when
|
|
42
|
-
* to post an entry row and don't
|
|
59
|
+
/** Atomic get + remove (accepts orderId or clientOrderId). Use this when
|
|
60
|
+
* you've already consumed the metadata to post an entry row and don't
|
|
61
|
+
* want it picked up again. */
|
|
43
62
|
consume(orderId: string): PendingEntry | undefined;
|
|
44
63
|
/** Remove an entry without retrieval (e.g. on cancel). */
|
|
45
64
|
drop(orderId: string): boolean;
|
|
65
|
+
/** Resolve a lookup key that may be an orderId or a clientOrderId alias. */
|
|
66
|
+
private resolveKey;
|
|
67
|
+
private deleteEntry;
|
|
46
68
|
size(): number;
|
|
47
69
|
getFilePath(): string;
|
|
48
70
|
private pruneExpired;
|
|
@@ -33,6 +33,9 @@ export class PendingEntryStore {
|
|
|
33
33
|
now;
|
|
34
34
|
ttlMs;
|
|
35
35
|
entries = new Map();
|
|
36
|
+
/** clientOrderId → primary key in `entries`. Lets WS fills that beat the
|
|
37
|
+
* REST ack (no exchange orderId known yet) find the stash by cid. */
|
|
38
|
+
cidIndex = new Map();
|
|
36
39
|
constructor(opts) {
|
|
37
40
|
const base = resolvePluginsBaseDir(opts?.basePath);
|
|
38
41
|
this.dir = join(base, opts?.pluginId ?? DEFAULT_PLUGIN_ID);
|
|
@@ -43,45 +46,91 @@ export class PendingEntryStore {
|
|
|
43
46
|
mkdirSync(this.dir, { recursive: true });
|
|
44
47
|
this.loadOrInit();
|
|
45
48
|
}
|
|
46
|
-
/** Stash metadata until the WS fill for this
|
|
49
|
+
/** Stash metadata until the WS fill for this order arrives. Keyed by
|
|
50
|
+
* `orderId`; when `clientOrderId` is provided the entry is also findable
|
|
51
|
+
* by it (pre-submission stashes pass the cid as BOTH — see create_order). */
|
|
47
52
|
put(input) {
|
|
48
53
|
const ts = this.now();
|
|
49
54
|
this.entries.set(input.orderId, {
|
|
50
55
|
orderId: input.orderId,
|
|
56
|
+
...(input.clientOrderId ? { clientOrderId: input.clientOrderId } : {}),
|
|
51
57
|
symbol: input.symbol,
|
|
52
58
|
side: input.side,
|
|
53
59
|
metadata: input.metadata,
|
|
54
60
|
createdAt: ts,
|
|
55
61
|
expiresAt: ts + this.ttlMs,
|
|
56
62
|
});
|
|
63
|
+
if (input.clientOrderId)
|
|
64
|
+
this.cidIndex.set(input.clientOrderId, input.orderId);
|
|
57
65
|
this.persist();
|
|
58
66
|
}
|
|
59
|
-
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
67
|
+
/** Re-key a pre-submission stash (keyed by clientOrderId) to the
|
|
68
|
+
* exchange-issued orderId once the REST ack returns. The cid alias keeps
|
|
69
|
+
* working. No-op when the entry is gone (already consumed by a WS fill
|
|
70
|
+
* that beat the REST response, or expired). */
|
|
71
|
+
promote(fromKey, exchangeOrderId) {
|
|
72
|
+
const key = this.resolveKey(fromKey);
|
|
73
|
+
if (key === undefined || key === exchangeOrderId)
|
|
74
|
+
return;
|
|
75
|
+
const entry = this.entries.get(key);
|
|
76
|
+
if (!entry)
|
|
77
|
+
return;
|
|
78
|
+
this.entries.delete(key);
|
|
79
|
+
entry.orderId = exchangeOrderId;
|
|
80
|
+
this.entries.set(exchangeOrderId, entry);
|
|
81
|
+
if (entry.clientOrderId)
|
|
82
|
+
this.cidIndex.set(entry.clientOrderId, exchangeOrderId);
|
|
83
|
+
this.persist();
|
|
84
|
+
}
|
|
85
|
+
/** Retrieve metadata stashed for an orderId (or clientOrderId alias).
|
|
86
|
+
* Does NOT remove the entry — callers should call `consume(orderId)` to
|
|
87
|
+
* remove on first match, since multiple partial fills on the same limit
|
|
88
|
+
* order all share one orderId and the FIRST fill is the entry; subsequent
|
|
89
|
+
* are scale-ins of an already-registered position (state-store branch
|
|
90
|
+
* handles those). */
|
|
64
91
|
get(orderId) {
|
|
65
92
|
this.pruneExpired();
|
|
66
|
-
|
|
93
|
+
const key = this.resolveKey(orderId);
|
|
94
|
+
return key === undefined ? undefined : this.entries.get(key);
|
|
67
95
|
}
|
|
68
|
-
/** Atomic get + remove. Use this when
|
|
69
|
-
* to post an entry row and don't
|
|
96
|
+
/** Atomic get + remove (accepts orderId or clientOrderId). Use this when
|
|
97
|
+
* you've already consumed the metadata to post an entry row and don't
|
|
98
|
+
* want it picked up again. */
|
|
70
99
|
consume(orderId) {
|
|
71
100
|
this.pruneExpired();
|
|
72
|
-
const
|
|
101
|
+
const key = this.resolveKey(orderId);
|
|
102
|
+
if (key === undefined)
|
|
103
|
+
return undefined;
|
|
104
|
+
const entry = this.entries.get(key);
|
|
73
105
|
if (!entry)
|
|
74
106
|
return undefined;
|
|
75
|
-
this.
|
|
107
|
+
this.deleteEntry(key, entry);
|
|
76
108
|
this.persist();
|
|
77
109
|
return entry;
|
|
78
110
|
}
|
|
79
111
|
/** Remove an entry without retrieval (e.g. on cancel). */
|
|
80
112
|
drop(orderId) {
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
113
|
+
const key = this.resolveKey(orderId);
|
|
114
|
+
if (key === undefined)
|
|
115
|
+
return false;
|
|
116
|
+
const entry = this.entries.get(key);
|
|
117
|
+
if (!entry)
|
|
118
|
+
return false;
|
|
119
|
+
this.deleteEntry(key, entry);
|
|
120
|
+
this.persist();
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
/** Resolve a lookup key that may be an orderId or a clientOrderId alias. */
|
|
124
|
+
resolveKey(k) {
|
|
125
|
+
if (this.entries.has(k))
|
|
126
|
+
return k;
|
|
127
|
+
const viaCid = this.cidIndex.get(k);
|
|
128
|
+
return viaCid !== undefined && this.entries.has(viaCid) ? viaCid : undefined;
|
|
129
|
+
}
|
|
130
|
+
deleteEntry(key, entry) {
|
|
131
|
+
this.entries.delete(key);
|
|
132
|
+
if (entry.clientOrderId)
|
|
133
|
+
this.cidIndex.delete(entry.clientOrderId);
|
|
85
134
|
}
|
|
86
135
|
size() {
|
|
87
136
|
return this.entries.size;
|
|
@@ -95,7 +144,7 @@ export class PendingEntryStore {
|
|
|
95
144
|
let pruned = false;
|
|
96
145
|
for (const [orderId, entry] of this.entries) {
|
|
97
146
|
if (entry.expiresAt <= ts) {
|
|
98
|
-
this.
|
|
147
|
+
this.deleteEntry(orderId, entry);
|
|
99
148
|
pruned = true;
|
|
100
149
|
}
|
|
101
150
|
}
|
|
@@ -128,6 +177,8 @@ export class PendingEntryStore {
|
|
|
128
177
|
for (const entry of file.entries) {
|
|
129
178
|
if (entry.expiresAt > ts) {
|
|
130
179
|
this.entries.set(entry.orderId, entry);
|
|
180
|
+
if (entry.clientOrderId)
|
|
181
|
+
this.cidIndex.set(entry.clientOrderId, entry.orderId);
|
|
131
182
|
kept++;
|
|
132
183
|
}
|
|
133
184
|
else {
|
|
@@ -163,6 +214,9 @@ export class PendingEntryStore {
|
|
|
163
214
|
if (typeof e.orderId !== 'string' || e.orderId.length === 0) {
|
|
164
215
|
throw new Error('pending-entry-metadata: entry missing orderId');
|
|
165
216
|
}
|
|
217
|
+
if (e.clientOrderId !== undefined && (typeof e.clientOrderId !== 'string' || e.clientOrderId.length === 0)) {
|
|
218
|
+
throw new Error('pending-entry-metadata: invalid clientOrderId');
|
|
219
|
+
}
|
|
166
220
|
if (typeof e.symbol !== 'string' || e.symbol.length === 0) {
|
|
167
221
|
throw new Error('pending-entry-metadata: entry missing symbol');
|
|
168
222
|
}
|
|
@@ -173,6 +173,17 @@ export async function onClosePositionFilled(ctx, inputs, order) {
|
|
|
173
173
|
ctx.stateStore.remove(inputs.symbol);
|
|
174
174
|
logger.info(TAG, `close captured ${inputs.symbol} reason=${inputs.closeReason} (positionId=${stateEntry.webappPositionId.slice(0, 8)}…)`);
|
|
175
175
|
}
|
|
176
|
+
/** Look up the metadata create_order stashed for this fill. Primary key is
|
|
177
|
+
* the exchange orderId (post-REST-ack `promote()`); the clientOrderId
|
|
178
|
+
* fallback covers the routine market-order race where the WS fill arrives
|
|
179
|
+
* BEFORE the REST response, i.e. before the promote — the pre-submission
|
|
180
|
+
* stash is keyed by the cid, which the WS event carries (`o.c`). */
|
|
181
|
+
function consumePendingEntry(ctx, fill) {
|
|
182
|
+
if (!ctx.pendingEntries)
|
|
183
|
+
return undefined;
|
|
184
|
+
return (ctx.pendingEntries.consume(fill.exchangeOrderId) ??
|
|
185
|
+
(fill.clientOrderId ? ctx.pendingEntries.consume(fill.clientOrderId) : undefined));
|
|
186
|
+
}
|
|
176
187
|
export async function onWsFillObserved(ctx, fill) {
|
|
177
188
|
if (!ctx.decisionsClient || !ctx.userId || !ctx.stateStore)
|
|
178
189
|
return;
|
|
@@ -208,7 +219,7 @@ export async function onWsFillObserved(ctx, fill) {
|
|
|
208
219
|
logger.warn(TAG, `onWsFillObserved ${fill.symbol}: scale-in observed but no webappPositionId yet — skipping (parent upsert may have failed; will retry next fill)`);
|
|
209
220
|
return;
|
|
210
221
|
}
|
|
211
|
-
const pending = ctx
|
|
222
|
+
const pending = consumePendingEntry(ctx, fill);
|
|
212
223
|
const entry = buildEntryPayload({
|
|
213
224
|
positionId: stateEntry.webappPositionId,
|
|
214
225
|
isScaleIn: true,
|
|
@@ -227,8 +238,8 @@ export async function onWsFillObserved(ctx, fill) {
|
|
|
227
238
|
}
|
|
228
239
|
// No state-store entry → NEW position via WS-driven fill. Replicates the
|
|
229
240
|
// synchronous-market-order path but pulls metadata from the pending cache
|
|
230
|
-
// (which create_order populated
|
|
231
|
-
const pending = ctx
|
|
241
|
+
// (which create_order populated BEFORE submitting the order).
|
|
242
|
+
const pending = consumePendingEntry(ctx, fill);
|
|
232
243
|
ctx.stateStore.upsert({
|
|
233
244
|
symbol: fill.symbol,
|
|
234
245
|
openedAt: ts,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ReadinessReport } from '@reefclaw/shared';
|
|
2
|
+
import type { BinancePublicApi } from '../ccxt/binance-public.js';
|
|
3
|
+
export interface ReadinessReporterOptions {
|
|
4
|
+
apiBaseUrl: string;
|
|
5
|
+
token: string;
|
|
6
|
+
binanceApi: Pick<BinancePublicApi, 'probeReachability'>;
|
|
7
|
+
/** Number of trading tools registered (a health signal). */
|
|
8
|
+
toolCount: number;
|
|
9
|
+
fetchImpl?: typeof fetch;
|
|
10
|
+
intervalMs?: number;
|
|
11
|
+
requestTimeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/** Run every connect-phase check once and assemble the report. Exported for
|
|
14
|
+
* unit tests. */
|
|
15
|
+
export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'binanceApi' | 'toolCount'>): Promise<ReadinessReport>;
|
|
16
|
+
/** Test-only — reset the singleton guard between unit tests. */
|
|
17
|
+
export declare function __resetReadinessReporterForTests(): void;
|
|
18
|
+
/** Fire the readiness report once at boot then on an unref'd interval. */
|
|
19
|
+
export declare function startReadinessReporter(opts: ReadinessReporterOptions): void;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Agent-readiness reporter — runs connect-phase health checks ON the trader's
|
|
2
|
+
// host and POSTs a self-describing report to the webapp, so a silently-broken
|
|
3
|
+
// agent (geo-blocked by Binance, clock-skewed, no tools) surfaces a plain-English
|
|
4
|
+
// dashboard alert instead of a false green. Advisory only: it NEVER blocks
|
|
5
|
+
// trading or the safety floor. See docs/AGENT_READINESS_GATE_PLAN.md (Phase 1).
|
|
6
|
+
//
|
|
7
|
+
// No token → no-op (nothing to authenticate; matches the config poller). A
|
|
8
|
+
// module-level singleton guard mirrors `pluginInitialised` in index.ts — OpenClaw
|
|
9
|
+
// calls register() multiple times per process and we must never spawn a second
|
|
10
|
+
// timer. The interval is unref()'d so it never holds the process open.
|
|
11
|
+
import { makeReadinessCheck, deriveOverallReadiness, } from '@reefclaw/shared';
|
|
12
|
+
import { logger, formatError } from '../logger.js';
|
|
13
|
+
const TAG = 'readiness';
|
|
14
|
+
const DEFAULT_INTERVAL_MS = 300_000; // 5 min — geo/clock state changes rarely.
|
|
15
|
+
const MIN_INTERVAL_MS = 60_000;
|
|
16
|
+
/** Best-effort display fact; kept in sync with the register() banner in index.ts. */
|
|
17
|
+
const PLUGIN_VERSION = '3.8.0';
|
|
18
|
+
function resolveIntervalMs(explicit) {
|
|
19
|
+
if (explicit && explicit > 0)
|
|
20
|
+
return Math.max(MIN_INTERVAL_MS, explicit);
|
|
21
|
+
const raw = Number(process.env.RC_READINESS_INTERVAL_MS);
|
|
22
|
+
if (!Number.isFinite(raw) || raw <= 0)
|
|
23
|
+
return DEFAULT_INTERVAL_MS;
|
|
24
|
+
return Math.max(MIN_INTERVAL_MS, raw);
|
|
25
|
+
}
|
|
26
|
+
/** Run every connect-phase check once and assemble the report. Exported for
|
|
27
|
+
* unit tests. */
|
|
28
|
+
export async function collectReadiness(opts) {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
const checks = [];
|
|
31
|
+
// plugin_loaded — trivially true (this code runs inside the loaded plugin),
|
|
32
|
+
// but a positive row is what proves the report path is alive at all.
|
|
33
|
+
checks.push(makeReadinessCheck('plugin_loaded', 'pass', { checkedAt: now }));
|
|
34
|
+
// tools_registered
|
|
35
|
+
checks.push(makeReadinessCheck('tools_registered', opts.toolCount > 0 ? 'pass' : 'fail', {
|
|
36
|
+
detail: `${opts.toolCount} tools`,
|
|
37
|
+
checkedAt: now,
|
|
38
|
+
}));
|
|
39
|
+
// binance_reachable (+ clock drift from the same probe response)
|
|
40
|
+
const probe = await opts.binanceApi.probeReachability();
|
|
41
|
+
if (probe.outcome === 'reachable') {
|
|
42
|
+
checks.push(makeReadinessCheck('binance_reachable', 'pass', { checkedAt: now }));
|
|
43
|
+
const drift = probe.driftMs;
|
|
44
|
+
if (drift == null) {
|
|
45
|
+
checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const abs = Math.abs(drift);
|
|
49
|
+
const status = abs <= 1000 ? 'pass' : abs <= 5000 ? 'warn' : 'fail';
|
|
50
|
+
checks.push(makeReadinessCheck('clock_in_sync', status, {
|
|
51
|
+
detail: `drift ${Math.round(drift)}ms`,
|
|
52
|
+
checkedAt: now,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (probe.outcome === 'geo_blocked') {
|
|
57
|
+
checks.push(makeReadinessCheck('binance_reachable', 'fail', { detail: 'HTTP 451', checkedAt: now }));
|
|
58
|
+
checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
|
|
59
|
+
}
|
|
60
|
+
else if (probe.outcome === 'unreachable') {
|
|
61
|
+
// Network/DNS/timeout — could be transient, so warn (amber) rather than
|
|
62
|
+
// asserting a definitive failure. A persistent problem stays amber across
|
|
63
|
+
// re-checks; only the 451 geo-block is a hard red.
|
|
64
|
+
checks.push(makeReadinessCheck('binance_reachable', 'warn', { detail: 'unreachable', checkedAt: now }));
|
|
65
|
+
checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// 'unknown' — the ban/weight gate paused the probe; don't assert anything.
|
|
69
|
+
checks.push(makeReadinessCheck('binance_reachable', 'unknown', { checkedAt: now }));
|
|
70
|
+
checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
schemaVersion: 1,
|
|
74
|
+
generatedAt: now,
|
|
75
|
+
overall: deriveOverallReadiness(checks),
|
|
76
|
+
checks,
|
|
77
|
+
agent: { pluginVersion: PLUGIN_VERSION, toolCount: opts.toolCount },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async function postReadiness(apiBaseUrl, token, report, fetchImpl, timeoutMs) {
|
|
81
|
+
const url = `${apiBaseUrl.replace(/\/+$/, '')}/api/internal/agent-readiness`;
|
|
82
|
+
const ac = new AbortController();
|
|
83
|
+
const tid = setTimeout(() => ac.abort(), timeoutMs);
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetchImpl(url, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: {
|
|
88
|
+
'content-type': 'application/json',
|
|
89
|
+
authorization: `Bearer ${token}`,
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify(report),
|
|
92
|
+
signal: ac.signal,
|
|
93
|
+
});
|
|
94
|
+
if (res.status < 200 || res.status >= 300) {
|
|
95
|
+
logger.warn(TAG, `POST ${url} → ${res.status}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
logger.warn(TAG, `readiness POST failed: ${formatError(err)}`);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
clearTimeout(tid);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
let reporterStarted = false;
|
|
106
|
+
/** Test-only — reset the singleton guard between unit tests. */
|
|
107
|
+
export function __resetReadinessReporterForTests() {
|
|
108
|
+
reporterStarted = false;
|
|
109
|
+
}
|
|
110
|
+
/** Fire the readiness report once at boot then on an unref'd interval. */
|
|
111
|
+
export function startReadinessReporter(opts) {
|
|
112
|
+
if (reporterStarted)
|
|
113
|
+
return;
|
|
114
|
+
if (!opts.token || !opts.token.trim()) {
|
|
115
|
+
logger.info(TAG, 'no connection token — readiness reporting disabled');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
reporterStarted = true;
|
|
119
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
120
|
+
const intervalMs = resolveIntervalMs(opts.intervalMs);
|
|
121
|
+
const timeoutMs = opts.requestTimeoutMs ?? 10_000;
|
|
122
|
+
const cycle = async () => {
|
|
123
|
+
try {
|
|
124
|
+
const report = await collectReadiness({
|
|
125
|
+
binanceApi: opts.binanceApi,
|
|
126
|
+
toolCount: opts.toolCount,
|
|
127
|
+
});
|
|
128
|
+
await postReadiness(opts.apiBaseUrl, opts.token, report, fetchImpl, timeoutMs);
|
|
129
|
+
if (report.overall === 'fail') {
|
|
130
|
+
const failing = report.checks.filter((c) => c.status === 'fail').map((c) => c.id).join(', ');
|
|
131
|
+
logger.warn(TAG, `readiness FAIL: ${failing}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
logger.warn(TAG, `readiness cycle failed: ${formatError(err)}`);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
void cycle();
|
|
139
|
+
const timer = setInterval(() => void cycle(), intervalMs);
|
|
140
|
+
timer.unref();
|
|
141
|
+
logger.info(TAG, `readiness reporter started (interval ${Math.round(intervalMs / 1000)}s)`);
|
|
142
|
+
}
|
|
@@ -23,7 +23,9 @@ export declare class ExchangeInfoCache {
|
|
|
23
23
|
private rules;
|
|
24
24
|
/** Set of all known symbols for normalization lookups. */
|
|
25
25
|
private knownSymbols;
|
|
26
|
-
/** Load market info from CCXT exchange instance.
|
|
26
|
+
/** Load market info from CCXT exchange instance. Throws on failure (incl.
|
|
27
|
+
* an active ban — the gate short-circuits with zero network so a boot
|
|
28
|
+
* during a 418 can't extend it); callers treat a throw as init failure. */
|
|
27
29
|
load(exchange: any): Promise<void>;
|
|
28
30
|
/** Get rules for a symbol (tries both BTC/USDT and BTC/USDT:USDT formats). */
|
|
29
31
|
getRules(symbol: string): SymbolRules | undefined;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Exchange info cache — caches per-symbol trading rules (min notional, lot sizes, tick sizes).
|
|
2
2
|
// Loaded once on startup from CCXT loadMarkets(), used to validate and round orders before submission.
|
|
3
3
|
import { logger } from '../logger.js';
|
|
4
|
+
import { assertNotBanned, noteBinanceError, noteSuccess } from '../ccxt/binance-ban-gate.js';
|
|
4
5
|
const TAG = 'exchange-info';
|
|
5
6
|
/**
|
|
6
7
|
* Normalize symbol format for consistent lookups.
|
|
@@ -26,10 +27,24 @@ export class ExchangeInfoCache {
|
|
|
26
27
|
rules = new Map();
|
|
27
28
|
/** Set of all known symbols for normalization lookups. */
|
|
28
29
|
knownSymbols = new Set();
|
|
29
|
-
/** Load market info from CCXT exchange instance.
|
|
30
|
+
/** Load market info from CCXT exchange instance. Throws on failure (incl.
|
|
31
|
+
* an active ban — the gate short-circuits with zero network so a boot
|
|
32
|
+
* during a 418 can't extend it); callers treat a throw as init failure. */
|
|
30
33
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
34
|
async load(exchange) {
|
|
32
|
-
|
|
35
|
+
let info;
|
|
36
|
+
try {
|
|
37
|
+
// GET /fapi/v1/exchangeInfo via ccxt loadMarkets — IP weight 1
|
|
38
|
+
// (doc-verified). Ban-gated but never weight-paced (NEVER_PACE):
|
|
39
|
+
// once-per-boot and required for order validation + emergency close.
|
|
40
|
+
assertNotBanned('loadMarkets');
|
|
41
|
+
info = await exchange.loadMarkets(true); // force refresh
|
|
42
|
+
noteSuccess();
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
noteBinanceError(err);
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
33
48
|
for (const [symbol, market] of Object.entries(info)) {
|
|
34
49
|
const m = market;
|
|
35
50
|
// Only cache futures (swap) markets
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reefclaw/openclaw-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
@@ -1,6 +1,39 @@
|
|
|
1
|
-
import type { StrategyDefinition } from './types.js';
|
|
2
|
-
import type { StrategyConfig } from './conditions/types.js';
|
|
1
|
+
import type { StrategyDefinition, MarketContext, OhlcvBar } from './types.js';
|
|
2
|
+
import type { StrategyConfig, PrimaryTimeframe } from './conditions/types.js';
|
|
3
3
|
export declare function clearStrategyGatingState(): void;
|
|
4
|
+
/**
|
|
5
|
+
* Live parity for higher-timeframe strategies (the tfHours-aware-stops fix,
|
|
6
|
+
* 2026-07 — see docs/STRATEGY_RESEARCH_2026-07.md §6.4 / CLAUDE.md ★).
|
|
7
|
+
*
|
|
8
|
+
* Every implicit bar read in this engine — stop rules
|
|
9
|
+
* (`findSwingPoints(ctx.ohlcv1h.slice(-48))`), entry rules
|
|
10
|
+
* (`computeEMA(ctx.ohlcv1h…)`), conditions without a `tfHours` param
|
|
11
|
+
* (ema_proximity, stoch_rsi_extreme, adx_*, …) and `ctx.atr14` — targets the
|
|
12
|
+
* `ohlcv1h` slot. The backtest engine feeds MAIN-timeframe bars into that
|
|
13
|
+
* slot (and computes atr14 from them), so a 4h/1d strategy backtests against
|
|
14
|
+
* primary-timeframe geometry. LIVE contexts put real 1h bars there, so the
|
|
15
|
+
* same strategy would compute stops/EMAs/ATR from 1h data — a 1d ATR is ~8×
|
|
16
|
+
* the 1h ATR, so live stops came out ~8× too tight. This helper gives the
|
|
17
|
+
* evaluation the exact context shape the backtest validated: primary bars in
|
|
18
|
+
* the `ohlcv1h` slot, atr14 recomputed from them (same computeATR the
|
|
19
|
+
* backtest and live context builders use).
|
|
20
|
+
*
|
|
21
|
+
* Detection, not configuration: when the `ohlcv1h` slot already carries
|
|
22
|
+
* primary-cadence bars (median spacing ≥ 90% of the primary bar duration —
|
|
23
|
+
* i.e. a backtest context), the context is returned UNTOUCHED, so backtest
|
|
24
|
+
* behaviour is byte-identical by construction (including warm-up: the
|
|
25
|
+
* backtest engine already refuses to build a context below 50 main bars).
|
|
26
|
+
* A live 1h series can only look primary-spaced through a data gap, in
|
|
27
|
+
* which case we fall back to the untouched context (pre-fix behaviour)
|
|
28
|
+
* rather than guessing.
|
|
29
|
+
*
|
|
30
|
+
* Returns null for a LIVE context whose primary-timeframe history is below
|
|
31
|
+
* the backtest's 50-bar warm-up — the caller skips evaluation, mirroring
|
|
32
|
+
* the backtest's null-context warm-up window.
|
|
33
|
+
*
|
|
34
|
+
* Exported for tests.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolvePrimaryContext(ctx: MarketContext, tf: PrimaryTimeframe, tfBars: OhlcvBar[]): MarketContext | null;
|
|
4
37
|
/**
|
|
5
38
|
* Convert a declarative StrategyConfig into a StrategyDefinition
|
|
6
39
|
* that the signal engine and backtest engine can evaluate.
|