@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/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
- const gatedTools = tools.map((t) => toolGate.wrapTool(t, jsonResult));
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
- /** Exchange-issued orderId the lookup key when the WS fill arrives. */
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 orderId arrives. */
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
- /** Retrieve metadata stashed for an orderId. Does NOT remove the entry —
36
- * callers should call `consume(orderId)` to remove on first match, since
37
- * multiple partial fills on the same limit order all share one orderId
38
- * and the FIRST fill is the entry; subsequent are scale-ins of an already-
39
- * registered position (state-store branch handles those). */
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 you've already consumed the metadata
42
- * to post an entry row and don't want it picked up again. */
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 orderId arrives. */
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
- /** Retrieve metadata stashed for an orderId. Does NOT remove the entry —
60
- * callers should call `consume(orderId)` to remove on first match, since
61
- * multiple partial fills on the same limit order all share one orderId
62
- * and the FIRST fill is the entry; subsequent are scale-ins of an already-
63
- * registered position (state-store branch handles those). */
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
- return this.entries.get(orderId);
93
+ const key = this.resolveKey(orderId);
94
+ return key === undefined ? undefined : this.entries.get(key);
67
95
  }
68
- /** Atomic get + remove. Use this when you've already consumed the metadata
69
- * to post an entry row and don't want it picked up again. */
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 entry = this.entries.get(orderId);
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.entries.delete(orderId);
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 existed = this.entries.delete(orderId);
82
- if (existed)
83
- this.persist();
84
- return existed;
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.entries.delete(orderId);
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.pendingEntries?.consume(fill.exchangeOrderId);
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 when the limit was placed).
231
- const pending = ctx.pendingEntries?.consume(fill.exchangeOrderId);
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
- const info = await exchange.loadMarkets(true); // force refresh
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.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.