@reefclaw/openclaw-plugin 0.1.23 → 0.1.24
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.js +72 -5
- package/bridge/connector.js +14 -2
- package/bridge/gateway/heartbeat-cron.js +30 -7
- package/bridge/gateway/poller.d.ts +5 -0
- package/bridge/gateway/poller.js +9 -0
- package/bridge/provider.d.ts +15 -0
- package/bridge/providers/connector-update.d.ts +89 -0
- package/bridge/providers/connector-update.js +212 -0
- package/bridge/providers/emergency-commands.d.ts +36 -0
- package/bridge/providers/emergency-commands.js +91 -0
- package/bridge/providers/gateway.d.ts +26 -1
- package/bridge/providers/gateway.js +159 -8
- package/bridge/providers/mock.js +1 -0
- package/bridge/types.d.ts +1 -1
- package/bridge/types.js +5 -0
- package/ccxt/binance-private.js +2 -1
- package/ccxt/binance-public.js +6 -1
- package/config/agent-config-client.d.ts +5 -2
- package/config/agent-config-client.js +13 -0
- package/config/agent-config-poller.js +5 -1
- package/config/gate-store.d.ts +9 -0
- package/config/gate-store.js +17 -2
- package/config/plugin-config-io.js +24 -2
- package/http/keepalive-fetch.d.ts +5 -0
- package/http/keepalive-fetch.js +50 -0
- package/index.js +48 -6
- package/ingest/position-decisions-client.d.ts +6 -0
- package/ingest/position-decisions-client.js +27 -9
- package/live/approval-lifecycle.d.ts +10 -0
- package/live/approval-lifecycle.js +16 -2
- package/live/microstructure-assembler.js +11 -2
- package/live/proposal-decision-listener.d.ts +21 -0
- package/live/proposal-decision-listener.js +39 -0
- package/live/proposal-manager.d.ts +12 -0
- package/live/proposal-manager.js +47 -0
- package/live/stop-watcher.d.ts +16 -1
- package/live/stop-watcher.js +48 -8
- package/openclaw.plugin.json +1 -1
- package/package.json +38 -38
- package/persistence/state-manager.d.ts +7 -0
- package/persistence/state-manager.js +28 -1
- package/simulator/exchange-simulator.d.ts +22 -0
- package/simulator/exchange-simulator.js +74 -32
- package/tools/audit-bracket-protection.js +11 -7
- package/tools/create-order.js +49 -7
- package/tools/get-funding-context.js +6 -1
- package/tools/get-liquidation-levels.js +5 -1
- package/tools/get-liquidation-pulse.js +7 -1
- package/tools/get-market-intel.js +2 -1
- package/tools/get-relevant-learnings.js +20 -1
- package/tools/get-resting-liquidity.js +6 -1
- package/tools/get-wave9-status.js +17 -0
- package/tools/intel-api.d.ts +9 -0
- package/tools/intel-api.js +32 -1
- package/tools/record-position-reviews.js +2 -2
- package/tools/scan-pairs.js +20 -11
- package/types.d.ts +7 -0
package/package.json
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@reefclaw/openclaw-plugin",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "ReefClaw supervised trading plugin for OpenClaw. Runs entirely on YOUR machine and starts in PAPER mode
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "index.js",
|
|
7
|
-
"openclaw": {
|
|
8
|
-
"extensions": [
|
|
9
|
-
"./index.js"
|
|
10
|
-
],
|
|
11
|
-
"compat": {
|
|
12
|
-
"pluginApi": ">=2026.6.0"
|
|
13
|
-
},
|
|
14
|
-
"build": {
|
|
15
|
-
"openclawVersion": "2026.6.11"
|
|
16
|
-
}
|
|
17
|
-
},
|
|
18
|
-
"files": [
|
|
19
|
-
"**/*",
|
|
20
|
-
"!scripts/**"
|
|
21
|
-
],
|
|
22
|
-
"engines": {
|
|
23
|
-
"node": ">=20"
|
|
24
|
-
},
|
|
25
|
-
"dependencies": {
|
|
26
|
-
"@reefclaw/shared": "0.1.4",
|
|
27
|
-
"ccxt": "4.5.37",
|
|
28
|
-
"json5": "2.2.3",
|
|
29
|
-
"ws": "8.21.1"
|
|
30
|
-
},
|
|
31
|
-
"scripts": {
|
|
32
|
-
"build": "node scripts/assemble.mjs",
|
|
33
|
-
"verify": "node scripts/verify-shared-contract.mjs",
|
|
34
|
-
"prepublishOnly": "node scripts/verify-shared-contract.mjs"
|
|
35
|
-
},
|
|
36
|
-
"license": "MIT",
|
|
37
|
-
"homepage": "https://reefclaw.com"
|
|
38
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@reefclaw/openclaw-plugin",
|
|
3
|
+
"version": "0.1.24",
|
|
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: /plugins install clawhub:@reefclaw/openclaw-plugin",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"openclaw": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"./index.js"
|
|
10
|
+
],
|
|
11
|
+
"compat": {
|
|
12
|
+
"pluginApi": ">=2026.6.0"
|
|
13
|
+
},
|
|
14
|
+
"build": {
|
|
15
|
+
"openclawVersion": "2026.6.11"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"**/*",
|
|
20
|
+
"!scripts/**"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@reefclaw/shared": "0.1.4",
|
|
27
|
+
"ccxt": "4.5.37",
|
|
28
|
+
"json5": "2.2.3",
|
|
29
|
+
"ws": "8.21.1"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "node scripts/assemble.mjs",
|
|
33
|
+
"verify": "node scripts/verify-shared-contract.mjs",
|
|
34
|
+
"prepublishOnly": "node scripts/verify-shared-contract.mjs"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"homepage": "https://reefclaw.com"
|
|
38
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SimulatorState } from '../simulator/types.js';
|
|
2
2
|
export declare class StateManager {
|
|
3
3
|
private readonly statePath;
|
|
4
|
+
private lastLoadedStat?;
|
|
4
5
|
private saveTimer;
|
|
5
6
|
private pendingState;
|
|
6
7
|
constructor(pluginId: string, baseDir?: string);
|
|
@@ -37,6 +38,12 @@ export declare class StateManager {
|
|
|
37
38
|
* missing-vs-corrupt distinction as load(): a corrupt file is quarantined
|
|
38
39
|
* (preserved), never silently overwritten by the default the caller seeds. */
|
|
39
40
|
loadSync(): SimulatorState | null;
|
|
41
|
+
/** loadSync that returns null when the file is unchanged since the last
|
|
42
|
+
* loadSync. The reload exists for the two-process case (another process
|
|
43
|
+
* wrote the file); an unchanged file means the caller's replaceState would
|
|
44
|
+
* be a no-op re-parse of the whole trade history — which grows with the
|
|
45
|
+
* account's age and was being paid on every paper-mode tool call. */
|
|
46
|
+
loadSyncIfChanged(): SimulatorState | null;
|
|
40
47
|
/** Synchronous load-or-create-default — for use in synchronous plugin register(). */
|
|
41
48
|
loadOrDefaultSync(startingBalance: number, quoteCurrency: string): SimulatorState;
|
|
42
49
|
private saveSyncSafe;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Saves/loads state to ~/.openclaw/plugins/reefclaw-paper-trading/state.json
|
|
3
3
|
// Uses atomic writes (temp file + rename) to prevent corruption.
|
|
4
4
|
import { readFile, writeFile, rename, mkdir, unlink } from 'node:fs/promises';
|
|
5
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { join, dirname } from 'node:path';
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
@@ -11,6 +11,7 @@ import { createDefaultState } from '../simulator/types.js';
|
|
|
11
11
|
const TAG = 'state-manager';
|
|
12
12
|
export class StateManager {
|
|
13
13
|
statePath;
|
|
14
|
+
lastLoadedStat;
|
|
14
15
|
saveTimer = null;
|
|
15
16
|
pendingState = null;
|
|
16
17
|
constructor(pluginId, baseDir) {
|
|
@@ -131,8 +132,17 @@ export class StateManager {
|
|
|
131
132
|
return null;
|
|
132
133
|
}
|
|
133
134
|
try {
|
|
135
|
+
// Stat BEFORE read: a write racing in between then causes one extra
|
|
136
|
+
// reload next time, never a skipped-but-needed one.
|
|
137
|
+
let stat;
|
|
138
|
+
try {
|
|
139
|
+
const s = statSync(this.statePath, { bigint: true });
|
|
140
|
+
stat = { mtimeNs: s.mtimeNs, size: s.size };
|
|
141
|
+
}
|
|
142
|
+
catch { /* stat raced a delete — plain read below decides */ }
|
|
134
143
|
const raw = readFileSync(this.statePath, 'utf-8');
|
|
135
144
|
const state = JSON.parse(raw);
|
|
145
|
+
this.lastLoadedStat = stat;
|
|
136
146
|
logger.info(TAG, `Loaded state: ${state.positions.length} positions, ${state.openOrders.length} orders, ${state.tradeHistory.length} trades`);
|
|
137
147
|
return state;
|
|
138
148
|
}
|
|
@@ -141,6 +151,23 @@ export class StateManager {
|
|
|
141
151
|
return null;
|
|
142
152
|
}
|
|
143
153
|
}
|
|
154
|
+
/** loadSync that returns null when the file is unchanged since the last
|
|
155
|
+
* loadSync. The reload exists for the two-process case (another process
|
|
156
|
+
* wrote the file); an unchanged file means the caller's replaceState would
|
|
157
|
+
* be a no-op re-parse of the whole trade history — which grows with the
|
|
158
|
+
* account's age and was being paid on every paper-mode tool call. */
|
|
159
|
+
loadSyncIfChanged() {
|
|
160
|
+
try {
|
|
161
|
+
const s = statSync(this.statePath, { bigint: true });
|
|
162
|
+
if (this.lastLoadedStat &&
|
|
163
|
+
this.lastLoadedStat.mtimeNs === s.mtimeNs &&
|
|
164
|
+
this.lastLoadedStat.size === s.size) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch { /* missing/unstatable — loadSync handles + logs it */ }
|
|
169
|
+
return this.loadSync();
|
|
170
|
+
}
|
|
144
171
|
/** Synchronous load-or-create-default — for use in synchronous plugin register(). */
|
|
145
172
|
loadOrDefaultSync(startingBalance, quoteCurrency) {
|
|
146
173
|
const loaded = this.loadSync();
|
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import type { SimulatorState, OrderBookDepth, SimulationConfig, ExecutionStats, PositionMetadata, CloseReason } from './types.js';
|
|
3
3
|
import type { CcxtOrder, CcxtBalance, CcxtPosition, CcxtTicker } from '../types.js';
|
|
4
|
+
/** Canonical key for the per-symbol quote/orderbook caches and for matching a
|
|
5
|
+
* ticker against stored positions/orders.
|
|
6
|
+
*
|
|
7
|
+
* CCXT echoes the VENUE-UNIFIED symbol back from fetchTicker: ask Binance USDM
|
|
8
|
+
* for 'ETH/USDT' and the ticker returns as 'ETH/USDT:USDT'. Positions and
|
|
9
|
+
* orders, however, are stored under whatever form the caller opened them with,
|
|
10
|
+
* so `lastTicker.get(position.symbol)` silently missed for every position held
|
|
11
|
+
* in the un-suffixed form. The consequences were all silent:
|
|
12
|
+
* - getPositions()/computeEquity() fell back to entryPrice, so the mark was
|
|
13
|
+
* FROZEN at entry — the stop-watcher compared a frozen mark, never saw a
|
|
14
|
+
* breach, and the position ran unprotected past its stop indefinitely;
|
|
15
|
+
* - createOrder() threw 'No ticker data for X' on the market leg, so the
|
|
16
|
+
* agent could not close by hand either (both automatic and manual exits
|
|
17
|
+
* were dead at once);
|
|
18
|
+
* - MFE, take-profit legs and resting-limit fills never advanced.
|
|
19
|
+
* Keying both sides through this normalizer makes the caches format-agnostic,
|
|
20
|
+
* which also repairs books already persisted with a mix of both forms.
|
|
21
|
+
*
|
|
22
|
+
* Same regex as webapp/src/lib/symbols.ts normalizeSymbol and
|
|
23
|
+
* plugin/src/venues/symbols.ts stripSettleSuffix — kept inline so the
|
|
24
|
+
* simulator keeps its zero-import-for-hot-path shape. */
|
|
25
|
+
export declare function tickerKey(symbol: string): string;
|
|
4
26
|
export declare class ExchangeSimulator extends EventEmitter {
|
|
5
27
|
private state;
|
|
6
28
|
private lastTicker;
|
|
@@ -12,6 +12,34 @@ import { fillMarketOrder, fillLimitOrder, parseSymbol } from './fill-engine.js';
|
|
|
12
12
|
import { updateMfe } from '../mfe.js';
|
|
13
13
|
import { computeInvalidationHit } from '../pinned-plan.js';
|
|
14
14
|
const TAG = 'simulator';
|
|
15
|
+
/** Canonical key for the per-symbol quote/orderbook caches and for matching a
|
|
16
|
+
* ticker against stored positions/orders.
|
|
17
|
+
*
|
|
18
|
+
* CCXT echoes the VENUE-UNIFIED symbol back from fetchTicker: ask Binance USDM
|
|
19
|
+
* for 'ETH/USDT' and the ticker returns as 'ETH/USDT:USDT'. Positions and
|
|
20
|
+
* orders, however, are stored under whatever form the caller opened them with,
|
|
21
|
+
* so `lastTicker.get(position.symbol)` silently missed for every position held
|
|
22
|
+
* in the un-suffixed form. The consequences were all silent:
|
|
23
|
+
* - getPositions()/computeEquity() fell back to entryPrice, so the mark was
|
|
24
|
+
* FROZEN at entry — the stop-watcher compared a frozen mark, never saw a
|
|
25
|
+
* breach, and the position ran unprotected past its stop indefinitely;
|
|
26
|
+
* - createOrder() threw 'No ticker data for X' on the market leg, so the
|
|
27
|
+
* agent could not close by hand either (both automatic and manual exits
|
|
28
|
+
* were dead at once);
|
|
29
|
+
* - MFE, take-profit legs and resting-limit fills never advanced.
|
|
30
|
+
* Keying both sides through this normalizer makes the caches format-agnostic,
|
|
31
|
+
* which also repairs books already persisted with a mix of both forms.
|
|
32
|
+
*
|
|
33
|
+
* Same regex as webapp/src/lib/symbols.ts normalizeSymbol and
|
|
34
|
+
* plugin/src/venues/symbols.ts stripSettleSuffix — kept inline so the
|
|
35
|
+
* simulator keeps its zero-import-for-hot-path shape. */
|
|
36
|
+
export function tickerKey(symbol) {
|
|
37
|
+
return symbol.replace(/:[A-Z]+$/, '');
|
|
38
|
+
}
|
|
39
|
+
/** True when two symbols denote the same market regardless of settle suffix. */
|
|
40
|
+
function sameMarket(a, b) {
|
|
41
|
+
return tickerKey(a) === tickerKey(b);
|
|
42
|
+
}
|
|
15
43
|
export class ExchangeSimulator extends EventEmitter {
|
|
16
44
|
state;
|
|
17
45
|
lastTicker = new Map();
|
|
@@ -112,7 +140,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
112
140
|
const walletTotal = this.state.wallet[quote]?.total ?? 0;
|
|
113
141
|
let positionEquity = 0;
|
|
114
142
|
for (const pos of this.state.positions) {
|
|
115
|
-
const ticker = this.lastTicker.get(pos.symbol);
|
|
143
|
+
const ticker = this.lastTicker.get(tickerKey(pos.symbol));
|
|
116
144
|
const mark = ticker?.last ?? pos.entryPrice;
|
|
117
145
|
const entryNotional = pos.entryPrice * pos.quantity;
|
|
118
146
|
const unrealized = pos.side === 'long'
|
|
@@ -182,10 +210,16 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
182
210
|
}
|
|
183
211
|
getPositions(symbol) {
|
|
184
212
|
const positions = symbol
|
|
185
|
-
? this.state.positions.filter(p => p.symbol
|
|
213
|
+
? this.state.positions.filter(p => sameMarket(p.symbol, symbol))
|
|
186
214
|
: this.state.positions;
|
|
187
215
|
return positions.map(p => {
|
|
188
|
-
const ticker = this.lastTicker.get(p.symbol);
|
|
216
|
+
const ticker = this.lastTicker.get(tickerKey(p.symbol));
|
|
217
|
+
// No quote for this symbol means we CANNOT mark this position. Falling
|
|
218
|
+
// back to entryPrice fabricates "flat since entry", which reads as a
|
|
219
|
+
// perfectly healthy position to every consumer — that is how a stop
|
|
220
|
+
// breach stayed invisible for 35h. Keep the fallback (callers need a
|
|
221
|
+
// number) but flag it so the stop-watcher can alarm instead of skipping.
|
|
222
|
+
const markPriceStale = ticker === undefined;
|
|
189
223
|
const markPrice = ticker?.last ?? p.entryPrice;
|
|
190
224
|
const notional = p.quantity * markPrice;
|
|
191
225
|
const pnlMultiplier = p.side === 'long' ? 1 : -1;
|
|
@@ -200,6 +234,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
200
234
|
contractSize: 1,
|
|
201
235
|
entryPrice: p.entryPrice,
|
|
202
236
|
markPrice,
|
|
237
|
+
markPriceStale,
|
|
203
238
|
notional,
|
|
204
239
|
unrealizedPnl,
|
|
205
240
|
percentage,
|
|
@@ -233,7 +268,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
233
268
|
}
|
|
234
269
|
getOpenOrders(symbol) {
|
|
235
270
|
const orders = symbol
|
|
236
|
-
? this.state.openOrders.filter(o => o.symbol
|
|
271
|
+
? this.state.openOrders.filter(o => sameMarket(o.symbol, symbol))
|
|
237
272
|
: this.state.openOrders;
|
|
238
273
|
return orders.map(o => this.toCcxtOrder(o));
|
|
239
274
|
}
|
|
@@ -244,10 +279,10 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
244
279
|
// ---- Order book ----
|
|
245
280
|
/** Cache the latest order book snapshot for a symbol. */
|
|
246
281
|
updateOrderBook(symbol, orderbook) {
|
|
247
|
-
this.lastOrderBook.set(symbol, orderbook);
|
|
282
|
+
this.lastOrderBook.set(tickerKey(symbol), orderbook);
|
|
248
283
|
}
|
|
249
284
|
getLastOrderBook(symbol) {
|
|
250
|
-
return this.lastOrderBook.get(symbol);
|
|
285
|
+
return this.lastOrderBook.get(tickerKey(symbol));
|
|
251
286
|
}
|
|
252
287
|
// ---- Write operations (for tools) ----
|
|
253
288
|
createOrder(symbol, side, type, amount, price, metadata,
|
|
@@ -294,7 +329,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
294
329
|
return this.executeMarketFill(order, referencePrice, metadata);
|
|
295
330
|
}
|
|
296
331
|
// Market orders fill immediately at current price
|
|
297
|
-
const ticker = this.lastTicker.get(symbol);
|
|
332
|
+
const ticker = this.lastTicker.get(tickerKey(symbol));
|
|
298
333
|
if (!ticker) {
|
|
299
334
|
throw new Error(`No ticker data for ${symbol}. Call updateTicker() first.`);
|
|
300
335
|
}
|
|
@@ -308,7 +343,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
308
343
|
// Limit order — check if it crosses the current price. A stale quote must
|
|
309
344
|
// not price an immediate cross-fill (same hazard as market fills); the
|
|
310
345
|
// order RESTS instead and fills on the next fresh tick via updateTicker.
|
|
311
|
-
const ticker = this.lastTicker.get(symbol);
|
|
346
|
+
const ticker = this.lastTicker.get(tickerKey(symbol));
|
|
312
347
|
if (ticker && this.quoteAgeMs(ticker) <= this.maxQuoteAgeMs() && this.shouldFillLimit(order, ticker.last)) {
|
|
313
348
|
return this.executeLimitFill(order, ticker.last, metadata);
|
|
314
349
|
}
|
|
@@ -341,7 +376,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
341
376
|
const cancelled = [];
|
|
342
377
|
const remaining = [];
|
|
343
378
|
for (const order of this.state.openOrders) {
|
|
344
|
-
if (!symbol || order.symbol
|
|
379
|
+
if (!symbol || sameMarket(order.symbol, symbol)) {
|
|
345
380
|
order.status = 'canceled';
|
|
346
381
|
cancelled.push(order);
|
|
347
382
|
this.pendingOrderMetadata.delete(order.id);
|
|
@@ -364,7 +399,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
364
399
|
* else, which keeps the normal market-close path byte-identical.
|
|
365
400
|
*/
|
|
366
401
|
closePosition(symbol, closeReason, referencePrice) {
|
|
367
|
-
const position = this.state.positions.find(p => p.symbol
|
|
402
|
+
const position = this.state.positions.find(p => sameMarket(p.symbol, symbol));
|
|
368
403
|
if (!position) {
|
|
369
404
|
throw new Error(`No open position for ${symbol}`);
|
|
370
405
|
}
|
|
@@ -387,7 +422,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
387
422
|
* and getPositions both read metadata.stopPrice, so a moved stop takes
|
|
388
423
|
* effect on the next watcher tick. Throws if there is no open position. (M9) */
|
|
389
424
|
updatePositionMetadata(symbol, patch) {
|
|
390
|
-
const pos = this.state.positions.find(p => p.symbol
|
|
425
|
+
const pos = this.state.positions.find(p => sameMarket(p.symbol, symbol));
|
|
391
426
|
if (!pos) {
|
|
392
427
|
throw new Error(`No open position for ${symbol}`);
|
|
393
428
|
}
|
|
@@ -401,7 +436,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
401
436
|
}
|
|
402
437
|
// ---- Ticker updates (checks pending limit fills) ----
|
|
403
438
|
updateTicker(ticker) {
|
|
404
|
-
this.lastTicker.set(ticker.symbol, ticker);
|
|
439
|
+
this.lastTicker.set(tickerKey(ticker.symbol), ticker);
|
|
405
440
|
this.refreshMfeForSymbol(ticker.symbol, ticker.last);
|
|
406
441
|
// MFE is refreshed FIRST so the peak this tick reached is recorded before a
|
|
407
442
|
// target close reads it — otherwise every TP exit would understate its own
|
|
@@ -411,7 +446,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
411
446
|
const toFill = [];
|
|
412
447
|
const remaining = [];
|
|
413
448
|
for (const order of this.state.openOrders) {
|
|
414
|
-
if (order.symbol
|
|
449
|
+
if (sameMarket(order.symbol, ticker.symbol) && this.shouldFillLimit(order, ticker.last)) {
|
|
415
450
|
toFill.push(order);
|
|
416
451
|
}
|
|
417
452
|
else {
|
|
@@ -449,7 +484,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
449
484
|
}
|
|
450
485
|
}
|
|
451
486
|
getLastTicker(symbol) {
|
|
452
|
-
return this.lastTicker.get(symbol);
|
|
487
|
+
return this.lastTicker.get(tickerKey(symbol));
|
|
453
488
|
}
|
|
454
489
|
/**
|
|
455
490
|
* Take-profit legs — the paper analog of the exchange-native
|
|
@@ -483,7 +518,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
483
518
|
if (!Number.isFinite(price) || price <= 0)
|
|
484
519
|
return;
|
|
485
520
|
// Snapshot: closing mutates state.positions mid-iteration.
|
|
486
|
-
const candidates = this.state.positions.filter((p) => p.symbol
|
|
521
|
+
const candidates = this.state.positions.filter((p) => sameMarket(p.symbol, symbol));
|
|
487
522
|
for (const position of candidates) {
|
|
488
523
|
const target = position.metadata?.targetPrice;
|
|
489
524
|
if (target === undefined || !Number.isFinite(target) || target <= 0)
|
|
@@ -492,17 +527,20 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
492
527
|
if (!breached)
|
|
493
528
|
continue;
|
|
494
529
|
// Guard against a re-entrant tick firing a second close on the same
|
|
495
|
-
// symbol while the first is still settling.
|
|
496
|
-
|
|
530
|
+
// symbol while the first is still settling. Keyed canonically so the
|
|
531
|
+
// guard still holds when the tick and the position disagree on suffix.
|
|
532
|
+
const pendingKey = tickerKey(position.symbol);
|
|
533
|
+
if (this.takeProfitPending.has(pendingKey))
|
|
497
534
|
continue;
|
|
498
|
-
this.takeProfitPending.add(
|
|
535
|
+
this.takeProfitPending.add(pendingKey);
|
|
499
536
|
try {
|
|
500
|
-
logger.info(TAG, `TARGET REACHED: ${symbol} ${position.side} price=${price} target=${target} — closing (exchange_target)`);
|
|
537
|
+
logger.info(TAG, `TARGET REACHED: ${position.symbol} ${position.side} price=${price} target=${target} — closing (exchange_target)`);
|
|
501
538
|
// Target = decision price; the fill engine adds realistic adverse
|
|
502
|
-
// slippage on top (see the fill-convention note above).
|
|
503
|
-
|
|
539
|
+
// slippage on top (see the fill-convention note above). Address the
|
|
540
|
+
// close with the POSITION's own symbol, never the ticker's form.
|
|
541
|
+
const order = this.closePosition(position.symbol, 'exchange_target', target);
|
|
504
542
|
this.emit('target_closed', {
|
|
505
|
-
symbol,
|
|
543
|
+
symbol: position.symbol,
|
|
506
544
|
side: position.side,
|
|
507
545
|
targetPrice: target,
|
|
508
546
|
markPrice: price,
|
|
@@ -514,10 +552,10 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
514
552
|
catch (err) {
|
|
515
553
|
// Never let a failed protective close kill the tick loop — the next
|
|
516
554
|
// tick retries, and the position is still visible to the agent.
|
|
517
|
-
logger.error(TAG, `Target close failed for ${symbol}: ${formatError(err)}`);
|
|
555
|
+
logger.error(TAG, `Target close failed for ${position.symbol}: ${formatError(err)}`);
|
|
518
556
|
}
|
|
519
557
|
finally {
|
|
520
|
-
this.takeProfitPending.delete(
|
|
558
|
+
this.takeProfitPending.delete(pendingKey);
|
|
521
559
|
}
|
|
522
560
|
}
|
|
523
561
|
}
|
|
@@ -530,7 +568,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
530
568
|
return;
|
|
531
569
|
let peakAdvanced = false;
|
|
532
570
|
for (const p of this.state.positions) {
|
|
533
|
-
if (p.symbol
|
|
571
|
+
if (!sameMarket(p.symbol, symbol))
|
|
534
572
|
continue;
|
|
535
573
|
const meta = { ...(p.metadata ?? {}) };
|
|
536
574
|
const priorPeak = meta.mfePeakPrice;
|
|
@@ -602,7 +640,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
602
640
|
const keptPeak = diskPeak === undefined
|
|
603
641
|
? priorPeak
|
|
604
642
|
: (p.side === 'long' ? Math.max(priorPeak, diskPeak) : Math.min(priorPeak, diskPeak));
|
|
605
|
-
const mark = this.lastTicker.get(p.symbol)?.last ?? meta.originalEntryPrice ?? p.entryPrice;
|
|
643
|
+
const mark = this.lastTicker.get(tickerKey(p.symbol))?.last ?? meta.originalEntryPrice ?? p.entryPrice;
|
|
606
644
|
const out = updateMfe({
|
|
607
645
|
side: p.side,
|
|
608
646
|
entryPrice: meta.originalEntryPrice ?? p.entryPrice,
|
|
@@ -671,7 +709,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
671
709
|
const max = this.maxQuoteAgeMs();
|
|
672
710
|
if (age <= max)
|
|
673
711
|
return;
|
|
674
|
-
const pos = this.state.positions.find(p => p.symbol
|
|
712
|
+
const pos = this.state.positions.find(p => sameMarket(p.symbol, symbol));
|
|
675
713
|
// The order side reaching here is the one being filled — derive reduce vs
|
|
676
714
|
// grow from the position side at the call site instead? The market path
|
|
677
715
|
// calls this before fill with the order side unavailable; use position
|
|
@@ -696,8 +734,10 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
696
734
|
: currentPrice >= order.price;
|
|
697
735
|
}
|
|
698
736
|
executeMarketFill(order, currentPrice, metadata) {
|
|
699
|
-
|
|
700
|
-
|
|
737
|
+
// Match canonically: a miss here would hand fillMarketOrder position=null,
|
|
738
|
+
// turning a CLOSE into a brand-new opposing position.
|
|
739
|
+
const position = this.state.positions.find(p => sameMarket(p.symbol, order.symbol)) ?? null;
|
|
740
|
+
const orderbook = this.lastOrderBook.get(tickerKey(order.symbol)) ?? null;
|
|
701
741
|
const realistic = {
|
|
702
742
|
orderbook,
|
|
703
743
|
config: this.simulationConfig,
|
|
@@ -707,7 +747,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
707
747
|
const result = fillMarketOrder(order, currentPrice, this.state.wallet, position, realistic);
|
|
708
748
|
// Observability for issue #202: stamp the quote's age onto the fill's
|
|
709
749
|
// execution-quality record so staleness is visible in trade history.
|
|
710
|
-
const tickerAtFill = this.lastTicker.get(order.symbol);
|
|
750
|
+
const tickerAtFill = this.lastTicker.get(tickerKey(order.symbol));
|
|
711
751
|
if (result.executionQuality && tickerAtFill) {
|
|
712
752
|
result.executionQuality.quoteAgeMs = this.quoteAgeMs(tickerAtFill);
|
|
713
753
|
}
|
|
@@ -725,7 +765,7 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
725
765
|
return ccxtOrder;
|
|
726
766
|
}
|
|
727
767
|
executeLimitFill(order, decisionPrice, metadata) {
|
|
728
|
-
const position = this.state.positions.find(p => p.symbol
|
|
768
|
+
const position = this.state.positions.find(p => sameMarket(p.symbol, order.symbol)) ?? null;
|
|
729
769
|
const result = fillLimitOrder(order, this.state.wallet, position, this.simulationConfig, decisionPrice, metadata);
|
|
730
770
|
const ccxtOrder = this.applyFillResult(result);
|
|
731
771
|
const eq = result.executionQuality;
|
|
@@ -778,7 +818,9 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
778
818
|
return ccxtOrder;
|
|
779
819
|
}
|
|
780
820
|
updatePosition(symbol, newPosition) {
|
|
781
|
-
|
|
821
|
+
// Canonical match: an exact-compare miss here would append a DUPLICATE
|
|
822
|
+
// position row instead of replacing/removing the existing one.
|
|
823
|
+
const idx = this.state.positions.findIndex(p => sameMarket(p.symbol, symbol));
|
|
782
824
|
if (newPosition) {
|
|
783
825
|
if (idx >= 0) {
|
|
784
826
|
this.state.positions[idx] = newPosition;
|
|
@@ -566,13 +566,16 @@ async function auditHlLive(adapter) {
|
|
|
566
566
|
};
|
|
567
567
|
}
|
|
568
568
|
const entries = [];
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
569
|
+
// Fan the per-symbol open-orders reads out (HL has no batched read; the
|
|
570
|
+
// serial loop cost N round-trips per heartbeat), then walk the results in
|
|
571
|
+
// the ORIGINAL position order — the first failing symbol still produces
|
|
572
|
+
// the identical error return, and no verdict logic changes.
|
|
573
|
+
const orderFetches = await Promise.allSettled(activePositions.map((pos) => adapter.getOpenOrders(pos.symbol)));
|
|
574
|
+
for (let i = 0; i < activePositions.length; i++) {
|
|
575
|
+
const p = activePositions[i];
|
|
576
|
+
const fetched = orderFetches[i];
|
|
577
|
+
if (fetched.status === 'rejected') {
|
|
578
|
+
const m = formatError(fetched.reason);
|
|
576
579
|
logger.warn(TAG, `HL audit skipped — open orders unavailable for ${p.symbol}: ${m}`);
|
|
577
580
|
return {
|
|
578
581
|
error: `Exchange data unavailable for ${p.symbol} (Hyperliquid open-orders fetch failed). ` +
|
|
@@ -580,6 +583,7 @@ async function auditHlLive(adapter) {
|
|
|
580
583
|
`close_position(reason='bracket_integrity') on the basis of this call. Underlying: ${m}`,
|
|
581
584
|
};
|
|
582
585
|
}
|
|
586
|
+
const symbolOrders = fetched.value;
|
|
583
587
|
const ledgerRow = ledger.getBySymbol(p.symbol) ?? null;
|
|
584
588
|
const rowNonTerminalWithCids = !!ledgerRow && !isTerminalBracketState(ledgerRow.state) && Boolean(ledgerRow.slCid || ledgerRow.tpCid);
|
|
585
589
|
if (symbolOrders.length === 0 && rowNonTerminalWithCids) {
|
package/tools/create-order.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Tool: create_order — order execution with real price data + pre-trade risk gate
|
|
2
2
|
// Readiness gate: BLOCKED unless adapter.readiness === 'READY'.
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
-
import { formatError } from '../logger.js';
|
|
4
|
+
import { formatError, logger } from '../logger.js';
|
|
5
5
|
import { getQuoteBalance, getQuoteWalletBalance } from '../balance-utils.js';
|
|
6
6
|
import { fetchCurrentPrice, fetchOrderBook, isError } from './helpers.js';
|
|
7
7
|
import { validateCreateOrder, sanitizeRealizationRule, validateProtectiveGeometry } from './assessment-validation.js';
|
|
@@ -875,6 +875,7 @@ export async function createOrderTool(args, deps) {
|
|
|
875
875
|
}
|
|
876
876
|
// In paper mode, fetch latest ticker + order book for realistic fills
|
|
877
877
|
let ticker = null;
|
|
878
|
+
let livePortfolioPromise;
|
|
878
879
|
if (!deps.adapter.isLive) {
|
|
879
880
|
const paperDeps = { binanceApi: deps.binanceApi, simulator: deps.adapter.getSimulator() };
|
|
880
881
|
const [priceResult] = await Promise.all([
|
|
@@ -886,7 +887,14 @@ export async function createOrderTool(args, deps) {
|
|
|
886
887
|
ticker = priceResult;
|
|
887
888
|
}
|
|
888
889
|
else {
|
|
889
|
-
// Live mode:
|
|
890
|
+
// Live mode: the risk-gate portfolio snapshot (balance + positions) has
|
|
891
|
+
// no dependency on the ticker — start it here so the two exchange
|
|
892
|
+
// round-trips overlap; awaited at the risk check below. The catch-keeper
|
|
893
|
+
// only silences the unhandled rejection an early return would otherwise
|
|
894
|
+
// leave floating; the later await still surfaces the real error.
|
|
895
|
+
livePortfolioPromise = buildPortfolioSnapshotFromAdapter(deps.adapter);
|
|
896
|
+
livePortfolioPromise.catch(() => { });
|
|
897
|
+
// Fetch ticker for risk check reference price
|
|
890
898
|
const lastPrice = await deps.adapter.getLastPrice(args.symbol);
|
|
891
899
|
if (lastPrice != null) {
|
|
892
900
|
ticker = {
|
|
@@ -1003,7 +1011,7 @@ export async function createOrderTool(args, deps) {
|
|
|
1003
1011
|
consecutiveLosses = computeConsecutiveLosses(simulator.getState().tradeHistory);
|
|
1004
1012
|
}
|
|
1005
1013
|
else {
|
|
1006
|
-
portfolio = await buildPortfolioSnapshotFromAdapter(deps.adapter);
|
|
1014
|
+
portfolio = await (livePortfolioPromise ?? buildPortfolioSnapshotFromAdapter(deps.adapter));
|
|
1007
1015
|
// In live mode, consecutive losses would come from intelligence DB — use 0 for now
|
|
1008
1016
|
}
|
|
1009
1017
|
// Bracket enforcement only applies in live mode when the feature is enabled.
|
|
@@ -1083,14 +1091,48 @@ export async function createOrderTool(args, deps) {
|
|
|
1083
1091
|
typeof args.confluence_score === 'number');
|
|
1084
1092
|
const proposalPathWired = !!(deps.proposalManager && deps.userId);
|
|
1085
1093
|
const approvalMode = deps.approvalMode ?? 'off';
|
|
1094
|
+
// per_trade + proposal path unwired: FAIL CLOSED.
|
|
1095
|
+
//
|
|
1096
|
+
// The operator asked for a human gate on every entry. If we can't deliver
|
|
1097
|
+
// the proposal (no ingest token / no REEFCLAW_USER_ID), the honest failure
|
|
1098
|
+
// is "no trade", not "trade without the gate". This branch previously fell
|
|
1099
|
+
// through to the normal fire path so the agent was never trapped in an
|
|
1100
|
+
// unfireable state — but that turned a misconfiguration into a live order
|
|
1101
|
+
// the operator never approved, silently, with only a boot-time warning as
|
|
1102
|
+
// the signal. A silent bypass of an explicitly-requested safety gate is the
|
|
1103
|
+
// one outcome this feature exists to prevent (Locked Decision #2: never
|
|
1104
|
+
// silent fire). Credentials drift for ordinary reasons — token rotation
|
|
1105
|
+
// touches four files (docs/CLAUDE/token-rotation.md), and a freshly
|
|
1106
|
+
// provisioned box can boot before they land — so this is a reachable state,
|
|
1107
|
+
// not a theoretical one.
|
|
1108
|
+
//
|
|
1109
|
+
// Halting entries is the safe direction: exits, closes, stops and every
|
|
1110
|
+
// operator emergency control are unaffected (they don't route through the
|
|
1111
|
+
// approval branch), so a mis-wired box can still protect and unwind an open
|
|
1112
|
+
// book — it just can't open new risk without the gate the operator asked
|
|
1113
|
+
// for. Logged at ERROR on EVERY occurrence, not once at boot, so the signal
|
|
1114
|
+
// is present at the time the trade is refused.
|
|
1115
|
+
if (approvalMode === 'per_trade' && !proposalPathWired) {
|
|
1116
|
+
logger.error('create-order', `approval.mode=per_trade but the proposal path is not wired — REFUSING to fire ` +
|
|
1117
|
+
`${args.symbol} ${side} (fail-closed). Missing plugin connectionToken/WEBAPP_INGEST_TOKEN ` +
|
|
1118
|
+
`and/or REEFCLAW_USER_ID. Restore the credentials, or set approval.mode=off to trade ` +
|
|
1119
|
+
`autonomously again.`);
|
|
1120
|
+
return {
|
|
1121
|
+
error: 'Order refused: approval mode is per_trade but this box cannot deliver the proposal ' +
|
|
1122
|
+
'to the operator (missing connection token and/or REEFCLAW_USER_ID), so there is no ' +
|
|
1123
|
+
'way for the operator to approve it. Refusing to fire un-approved — this is fail-closed ' +
|
|
1124
|
+
'by design, not a transient error. Tell the operator: either restore the plugin ' +
|
|
1125
|
+
'connection credentials, or set approval.mode=off to resume autonomous trading. ' +
|
|
1126
|
+
'Do not retry until they confirm one of those. Closing positions, stops and brackets ' +
|
|
1127
|
+
'are unaffected.',
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1086
1130
|
// per_trade mode: propose and EARLY-RETURN. The agent's call doesn't fire;
|
|
1087
1131
|
// the operator approves and ProposalDecisionListener fires via this same
|
|
1088
1132
|
// tool with proposalManager omitted (which takes the real path below).
|
|
1089
1133
|
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
// fire path — the startup warning is the operator's signal. We never trap
|
|
1093
|
-
// the agent inside an unfireable per_trade branch.
|
|
1134
|
+
// NOTE the listener's own fire passes NO approvalMode (defaults to 'off')
|
|
1135
|
+
// and no proposalManager, so it never re-enters this branch.
|
|
1094
1136
|
if (approvalMode === 'per_trade' && proposalPathWired) {
|
|
1095
1137
|
if (!argsMetadataComplete) {
|
|
1096
1138
|
return {
|
|
@@ -12,5 +12,10 @@
|
|
|
12
12
|
// FUNDING_OVERLAY flag is off or the symbol has < 100 30d samples.
|
|
13
13
|
import { fetchIntelApi, enc, resolveIntelSymbol } from './intel-api.js';
|
|
14
14
|
export async function getFundingContextTool(args, deps) {
|
|
15
|
-
|
|
15
|
+
// Server-side percentile context already has a 15-min TTL — 60s here just
|
|
16
|
+
// collapses same-heartbeat repeats.
|
|
17
|
+
return fetchIntelApi(`/api/funding/${enc(resolveIntelSymbol(deps, args.symbol))}`, deps, {
|
|
18
|
+
cacheTtlMs: 60_000,
|
|
19
|
+
timeoutMs: 10_000,
|
|
20
|
+
});
|
|
16
21
|
}
|
|
@@ -3,5 +3,9 @@
|
|
|
3
3
|
import { fetchIntelApi, enc, resolveIntelSymbol } from './intel-api.js';
|
|
4
4
|
export async function getLiquidationLevelsTool(args, deps) {
|
|
5
5
|
const hours = args.hours ?? 24;
|
|
6
|
-
|
|
6
|
+
// Historical clusters move slowly — 30s TTL collapses per-position repeats.
|
|
7
|
+
return fetchIntelApi(`/api/liquidation-levels/${enc(resolveIntelSymbol(deps, args.symbol))}?hours=${hours}`, deps, {
|
|
8
|
+
cacheTtlMs: 30_000,
|
|
9
|
+
timeoutMs: 10_000,
|
|
10
|
+
});
|
|
7
11
|
}
|
|
@@ -13,7 +13,13 @@ export async function getLiquidationPulseTool(args, deps) {
|
|
|
13
13
|
params.set('symbol', resolveIntelSymbol(deps, args.symbol));
|
|
14
14
|
const windowSeconds = clamp(args.window_seconds ?? 60, 5, 300);
|
|
15
15
|
params.set('window_seconds', String(windowSeconds));
|
|
16
|
-
|
|
16
|
+
// 10s TTL: short enough that an active_cascade classification is never
|
|
17
|
+
// stale-read into the override decision (cascades persist 60-180s), long
|
|
18
|
+
// enough to collapse the per-position repeats within one heartbeat.
|
|
19
|
+
return fetchIntelApi(`/api/liquidation-pulse?${params.toString()}`, deps, {
|
|
20
|
+
cacheTtlMs: 10_000,
|
|
21
|
+
timeoutMs: 10_000,
|
|
22
|
+
});
|
|
17
23
|
}
|
|
18
24
|
function clamp(n, lo, hi) {
|
|
19
25
|
if (!Number.isFinite(n))
|