@reefclaw/openclaw-plugin 0.1.14 → 0.1.15
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 +20 -5
- package/bridge/bridge.js +29 -14
- package/bridge/config.js +6 -0
- package/bridge/gateway/gateway-ws-client.d.ts +4 -1
- package/bridge/gateway/gateway-ws-client.js +41 -11
- package/bridge/providers/gateway.d.ts +28 -0
- package/bridge/providers/gateway.js +130 -5
- package/bridge/providers/onboarding-commands.d.ts +8 -5
- package/bridge/providers/onboarding-commands.js +1 -1
- package/bridge/providers/risk-calculator.d.ts +61 -2
- package/bridge/providers/risk-calculator.js +92 -20
- package/bridge/utils/skill-signing.js +8 -3
- package/config/plugin-config-io.js +5 -0
- package/exchange-adapter.d.ts +13 -0
- package/index.js +15 -4
- package/ingest/event-loop-monitor.d.ts +11 -0
- package/ingest/event-loop-monitor.js +77 -0
- package/ingest/readiness-reporter.d.ts +9 -0
- package/ingest/readiness-reporter.js +54 -5
- package/live/user-data-stream.js +10 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -4
- package/risk/pre-trade-check.js +18 -5
- package/skills/reefclaw/SKILL.md +6 -11
- package/strategy/condition-registry.js +9 -2
- package/strategy/evaluator.d.ts +5 -0
- package/tools/cancel-all-orders.js +9 -1
- package/tools/create-order.js +18 -1
- package/tools/get-bracket-config.d.ts +21 -2
- package/tools/get-bracket-config.js +18 -2
- package/tools/set-trading-mode.js +6 -3
- package/venues/hyperliquid/hl-fill-ingest.js +20 -1
- package/venues/hyperliquid/hl-live-adapter.d.ts +4 -0
- package/venues/hyperliquid/hl-live-adapter.js +4 -0
- package/venues/registry.js +8 -7
- package/wave9/paper-admission-guard.d.ts +12 -1
- package/wave9/paper-admission-guard.js +12 -1
- package/scripts/assemble.mjs +0 -130
|
@@ -43,11 +43,62 @@ export function applyRegimeAdjustment(baseLimits, regime) {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
// ---- Drawdown zone thresholds ----
|
|
46
|
+
/** Fallback zone boundaries, used only until the tenant's trading params load.
|
|
47
|
+
* The AUTHORITATIVE values are the operator's `drawdownYellow/Orange/Red`
|
|
48
|
+
* trading params — the same numbers the plugin's pre-trade gate rejects on.
|
|
49
|
+
* Keeping a second hardcoded ladder here is how the skill ended up
|
|
50
|
+
* auto-flattening at -2.5% on an account configured to -4%. */
|
|
46
51
|
export const DRAWDOWN_ZONE_THRESHOLDS = {
|
|
47
52
|
YELLOW: -0.01, // -1%
|
|
48
53
|
ORANGE: -0.02, // -2%
|
|
49
54
|
RED: -0.025, // -2.5%
|
|
50
55
|
};
|
|
56
|
+
// ---- Hardcoded bounds on tenant-supplied risk params ----
|
|
57
|
+
//
|
|
58
|
+
// CLAUDE.md load-bearing rule: central config may move knobs only WITHIN
|
|
59
|
+
// hardcoded bounds — it must never be able to disable a safety mechanism.
|
|
60
|
+
// The RED threshold drives the auto-flatten, so without a floor a single
|
|
61
|
+
// careless dashboard edit (drawdownRed = -0.5) would silently switch the
|
|
62
|
+
// auto-flatten off. Out-of-band values are REJECTED (keep last-good), not
|
|
63
|
+
// clamped — we never invent a threshold the operator didn't set.
|
|
64
|
+
/** The auto-flatten trigger can never be configured looser than −10%. */
|
|
65
|
+
export const AUTO_FLATTEN_RED_FLOOR = -0.10;
|
|
66
|
+
/** Sanity bands for tenant limit fields. Values outside → field ignored,
|
|
67
|
+
* previous value kept. Wide on purpose: these reject nonsense, not policy. */
|
|
68
|
+
export const TENANT_LIMIT_BOUNDS = {
|
|
69
|
+
maxPositionSize: { min: 1, max: 1e9 },
|
|
70
|
+
maxOpenPositions: { min: 1, max: 100 },
|
|
71
|
+
maxGrossExposure: { min: 0.01, max: 20 },
|
|
72
|
+
maxPerTradeLoss: { min: 0.01, max: 1e7 }, // magnitude; sign applied by caller
|
|
73
|
+
};
|
|
74
|
+
/** Finite number within [min, max], else null. */
|
|
75
|
+
export function boundedNum(v, min, max) {
|
|
76
|
+
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
77
|
+
return null;
|
|
78
|
+
return v >= min && v <= max ? v : null;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Validate a tenant drawdown-zone ladder. Returns null when acceptable, else
|
|
82
|
+
* a human-readable reason. Requirements: all finite; YELLOW ≤ 0 (zones are
|
|
83
|
+
* losses); strictly monotonic YELLOW > ORANGE > RED (a scrambled ladder would
|
|
84
|
+
* put the book straight into RED and auto-flatten it); RED no looser than
|
|
85
|
+
* AUTO_FLATTEN_RED_FLOOR.
|
|
86
|
+
*/
|
|
87
|
+
export function validateDrawdownLadder(t) {
|
|
88
|
+
for (const [k, v] of Object.entries(t)) {
|
|
89
|
+
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
90
|
+
return `${k} is not a finite number`;
|
|
91
|
+
}
|
|
92
|
+
if (t.YELLOW > 0)
|
|
93
|
+
return `YELLOW ${t.YELLOW} must be ≤ 0`;
|
|
94
|
+
if (!(t.YELLOW > t.ORANGE && t.ORANGE > t.RED)) {
|
|
95
|
+
return `not monotonic (need YELLOW > ORANGE > RED, got ${t.YELLOW} / ${t.ORANGE} / ${t.RED})`;
|
|
96
|
+
}
|
|
97
|
+
if (t.RED < AUTO_FLATTEN_RED_FLOOR) {
|
|
98
|
+
return `RED ${t.RED} is looser than the hardcoded auto-flatten floor ${AUTO_FLATTEN_RED_FLOOR}`;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
51
102
|
// ---- Rate tracking window ----
|
|
52
103
|
const RATE_WINDOW_MS = 60_000; // 1 minute
|
|
53
104
|
// ---- Volatility + drawdown pure functions ----
|
|
@@ -73,12 +124,12 @@ export function applyVolatilityAdjustment(baseLimits, volFactor) {
|
|
|
73
124
|
};
|
|
74
125
|
}
|
|
75
126
|
/** Determine drawdown zone from drawdown ratio (e.g. -0.015 = -1.5%). */
|
|
76
|
-
export function getDrawdownZone(drawdownRatio) {
|
|
77
|
-
if (drawdownRatio <=
|
|
127
|
+
export function getDrawdownZone(drawdownRatio, thresholds = DRAWDOWN_ZONE_THRESHOLDS) {
|
|
128
|
+
if (drawdownRatio <= thresholds.RED)
|
|
78
129
|
return 'RED';
|
|
79
|
-
if (drawdownRatio <=
|
|
130
|
+
if (drawdownRatio <= thresholds.ORANGE)
|
|
80
131
|
return 'ORANGE';
|
|
81
|
-
if (drawdownRatio <=
|
|
132
|
+
if (drawdownRatio <= thresholds.YELLOW)
|
|
82
133
|
return 'YELLOW';
|
|
83
134
|
return 'GREEN';
|
|
84
135
|
}
|
|
@@ -143,10 +194,22 @@ export function countRecentEvents(timestamps) {
|
|
|
143
194
|
}
|
|
144
195
|
/**
|
|
145
196
|
* Detect risk limit breaches from current metrics.
|
|
197
|
+
*
|
|
198
|
+
* ★ CRITICAL is reserved for the limits that are ACTUALLY ENFORCED — the
|
|
199
|
+
* tenant's trading params, which the plugin's `preTradeRiskCheck` rejects
|
|
200
|
+
* orders against (`plugin/src/risk/pre-trade-check.ts`). A red "Gate fail" on
|
|
201
|
+
* the dashboard must mean "the agent is being blocked right now".
|
|
202
|
+
*
|
|
203
|
+
* `advisoryLimits` carries the vol/regime-TIGHTENED view. Those multipliers
|
|
204
|
+
* live only in this file — no enforcer applies them — so they may raise a
|
|
205
|
+
* WARNING (amber, indication) but must never produce a CRITICAL. Conflating
|
|
206
|
+
* the two is what put a permanent red "Gate fail · Gross exposure 121%" on a
|
|
207
|
+
* live book whose 1.5x enforced limit was never even approached.
|
|
146
208
|
*/
|
|
147
|
-
export function detectBreaches(metrics, limits) {
|
|
209
|
+
export function detectBreaches(metrics, limits, advisoryLimits) {
|
|
148
210
|
const breaches = [];
|
|
149
211
|
const now = new Date().toISOString();
|
|
212
|
+
const advisory = advisoryLimits ?? limits;
|
|
150
213
|
// Gross exposure
|
|
151
214
|
if (metrics.grossExposure > limits.position.maxGrossExposure) {
|
|
152
215
|
breaches.push({
|
|
@@ -158,10 +221,11 @@ export function detectBreaches(metrics, limits) {
|
|
|
158
221
|
timestamp: now,
|
|
159
222
|
});
|
|
160
223
|
}
|
|
161
|
-
else if (metrics.grossExposure >
|
|
224
|
+
else if (metrics.grossExposure > advisory.position.maxGrossExposure ||
|
|
225
|
+
metrics.grossExposure > limits.position.maxGrossExposure * 0.8) {
|
|
162
226
|
breaches.push({
|
|
163
227
|
metric: 'grossExposure',
|
|
164
|
-
limit: limits.position.maxGrossExposure,
|
|
228
|
+
limit: Math.min(advisory.position.maxGrossExposure, limits.position.maxGrossExposure),
|
|
165
229
|
current: metrics.grossExposure,
|
|
166
230
|
level: 'WARNING',
|
|
167
231
|
action: 'ALERT',
|
|
@@ -179,10 +243,11 @@ export function detectBreaches(metrics, limits) {
|
|
|
179
243
|
timestamp: now,
|
|
180
244
|
});
|
|
181
245
|
}
|
|
182
|
-
else if (Math.abs(metrics.netExposure) >
|
|
246
|
+
else if (Math.abs(metrics.netExposure) > advisory.position.maxNetExposure ||
|
|
247
|
+
Math.abs(metrics.netExposure) > limits.position.maxNetExposure * 0.8) {
|
|
183
248
|
breaches.push({
|
|
184
249
|
metric: 'netExposure',
|
|
185
|
-
limit: limits.position.maxNetExposure,
|
|
250
|
+
limit: Math.min(advisory.position.maxNetExposure, limits.position.maxNetExposure),
|
|
186
251
|
current: metrics.netExposure,
|
|
187
252
|
level: 'WARNING',
|
|
188
253
|
action: 'ALERT',
|
|
@@ -270,8 +335,12 @@ export function computeRiskMetrics(state) {
|
|
|
270
335
|
// Prune and count rate events (non-mutating)
|
|
271
336
|
const orderResult = countRecentEvents(state.orderTimestamps);
|
|
272
337
|
const cancelResult = countRecentEvents(state.cancelTimestamps);
|
|
338
|
+
// Enforced limits: the tenant's configured trading params when they've been
|
|
339
|
+
// fetched, else the fallback constants. These are what the plugin's
|
|
340
|
+
// pre-trade gate rejects on, so they — and only they — drive CRITICAL.
|
|
341
|
+
const enforcedLimits = state.baseLimits ?? DEFAULT_RISK_LIMITS;
|
|
273
342
|
// Volatility adjustment (when ATR data is available)
|
|
274
|
-
let adjustedLimits =
|
|
343
|
+
let adjustedLimits = enforcedLimits;
|
|
275
344
|
let effectiveLimits;
|
|
276
345
|
let volatilityInfo;
|
|
277
346
|
let volFactor = 1.0;
|
|
@@ -279,7 +348,7 @@ export function computeRiskMetrics(state) {
|
|
|
279
348
|
volFactor = computeVolFactor(state.atrData);
|
|
280
349
|
const adjusted = volFactor > 1.0;
|
|
281
350
|
if (adjusted) {
|
|
282
|
-
adjustedLimits = applyVolatilityAdjustment(
|
|
351
|
+
adjustedLimits = applyVolatilityAdjustment(enforcedLimits, volFactor);
|
|
283
352
|
}
|
|
284
353
|
volatilityInfo = {
|
|
285
354
|
atr14: state.atrData.currentAtr,
|
|
@@ -305,14 +374,17 @@ export function computeRiskMetrics(state) {
|
|
|
305
374
|
};
|
|
306
375
|
}
|
|
307
376
|
}
|
|
308
|
-
if (adjustedLimits !==
|
|
377
|
+
if (adjustedLimits !== enforcedLimits) {
|
|
309
378
|
effectiveLimits = adjustedLimits;
|
|
310
379
|
}
|
|
311
|
-
// Drawdown zone
|
|
312
|
-
|
|
380
|
+
// Drawdown zone — boundaries come from the tenant's trading params so the
|
|
381
|
+
// skill's RED-zone auto-flatten fires at the operator's configured level,
|
|
382
|
+
// not a second hardcoded ladder that can be stricter than the plugin's.
|
|
383
|
+
const zoneThresholds = state.drawdownThresholds ?? DRAWDOWN_ZONE_THRESHOLDS;
|
|
384
|
+
const drawdownZone = getDrawdownZone(dailyDrawdown, zoneThresholds);
|
|
313
385
|
const drawdownZoneMessage = getDrawdownZoneMessage(drawdownZone, dailyDrawdown);
|
|
314
|
-
//
|
|
315
|
-
|
|
386
|
+
// Breaches: CRITICAL against the ENFORCED limits, WARNING against the
|
|
387
|
+
// vol/regime-tightened advisory view (see detectBreaches).
|
|
316
388
|
const dailyLoss = state.realizedPnlToday ?? 0;
|
|
317
389
|
const breaches = detectBreaches({
|
|
318
390
|
grossExposure: grossRatio,
|
|
@@ -321,12 +393,12 @@ export function computeRiskMetrics(state) {
|
|
|
321
393
|
dailyLoss,
|
|
322
394
|
ordersPerMinute: orderResult.count,
|
|
323
395
|
cancelsPerMinute: cancelResult.count,
|
|
324
|
-
},
|
|
396
|
+
}, enforcedLimits, effectiveLimits);
|
|
325
397
|
// Add zone-specific breaches
|
|
326
398
|
if (drawdownZone === 'ORANGE' || drawdownZone === 'RED') {
|
|
327
399
|
breaches.push({
|
|
328
400
|
metric: 'drawdownZone',
|
|
329
|
-
limit: drawdownZone === 'RED' ?
|
|
401
|
+
limit: drawdownZone === 'RED' ? zoneThresholds.RED : zoneThresholds.ORANGE,
|
|
330
402
|
current: dailyDrawdown,
|
|
331
403
|
level: 'CRITICAL',
|
|
332
404
|
action: drawdownZone === 'RED' ? 'AUTO_PAUSE' : 'REJECT_ORDER',
|
|
@@ -336,7 +408,7 @@ export function computeRiskMetrics(state) {
|
|
|
336
408
|
else if (drawdownZone === 'YELLOW') {
|
|
337
409
|
breaches.push({
|
|
338
410
|
metric: 'drawdownZone',
|
|
339
|
-
limit:
|
|
411
|
+
limit: zoneThresholds.YELLOW,
|
|
340
412
|
current: dailyDrawdown,
|
|
341
413
|
level: 'WARNING',
|
|
342
414
|
action: 'ALERT',
|
|
@@ -345,7 +417,7 @@ export function computeRiskMetrics(state) {
|
|
|
345
417
|
}
|
|
346
418
|
return {
|
|
347
419
|
metrics: {
|
|
348
|
-
limits:
|
|
420
|
+
limits: enforcedLimits,
|
|
349
421
|
utilization: {
|
|
350
422
|
currentPositionSize: positionSizes,
|
|
351
423
|
openPositionCount: state.positions.filter((p) => computePositionNotional(p, state.lastTickerPrice) !== 0).length,
|
|
@@ -14,9 +14,14 @@
|
|
|
14
14
|
// (gitignored — keep it offline; NEVER on the relay or any server)
|
|
15
15
|
// 4. rebuild + redeploy the skill (deploy-skillmd-ota.py is already wired to sign)
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// —
|
|
17
|
+
// ENFORCEMENT IS CURRENTLY DORMANT: SKILL_OTA_REQUIRE_SIGNATURE defaults OFF
|
|
18
|
+
// and no key is pinned (C1 is ON HOLD pending the agent-update-mechanism
|
|
19
|
+
// decision — see signatureRequired() below). While dormant, OTA updates apply
|
|
20
|
+
// unsigned exactly as before this module existed; the authenticated webapp
|
|
21
|
+
// pull channel (rc_ token) remains the trust boundary. When the flag is turned
|
|
22
|
+
// ON: a pinned key verifies every update, and flag-on-without-a-key FAILS
|
|
23
|
+
// CLOSED (every push rejected). Normal trading is unaffected either way —
|
|
24
|
+
// only the OTA-update path gates on this.
|
|
20
25
|
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
21
26
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
22
27
|
import { join } from 'node:path';
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
// sets credentials or switches trading mode from the webapp. Writes must be
|
|
6
6
|
// crash-safe (no half-written JSON) and must preserve keys we don't understand
|
|
7
7
|
// (forwards compatibility — other tools may add fields we don't know about).
|
|
8
|
+
//
|
|
9
|
+
// Write authorization: every mutation path into this module is an
|
|
10
|
+
// operator-only tool gated on dashboard provenance (verifyOperatorProvenance,
|
|
11
|
+
// audit F12) — the agent cannot reach these writes conversationally. The file
|
|
12
|
+
// is the plugin's OWN config store; nothing here touches OpenClaw's config.
|
|
8
13
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
|
|
9
14
|
import { homedir } from 'node:os';
|
|
10
15
|
import { dirname, join } from 'node:path';
|
package/exchange-adapter.d.ts
CHANGED
|
@@ -28,6 +28,19 @@ export interface OrderOptions {
|
|
|
28
28
|
* Only create_order is blocked when readiness !== 'READY'.
|
|
29
29
|
*/
|
|
30
30
|
export interface IExchangeAdapter {
|
|
31
|
+
/**
|
|
32
|
+
* ★ Venue capability: this adapter's exchange-side protective legs ARE the
|
|
33
|
+
* safety floor — there is no watcher fallback and no `brackets.mode=off`
|
|
34
|
+
* arm, so brackets are attached unconditionally (Hyperliquid live).
|
|
35
|
+
*
|
|
36
|
+
* Read it wherever `brackets.mode` is consulted. That flag is a
|
|
37
|
+
* BINANCE-ONLY lifecycle knob (it is only ever passed to `LiveAdapter`), so
|
|
38
|
+
* treating it as the global answer made an HL rig report "Brackets off" on
|
|
39
|
+
* the dashboard AND — worse — skipped the mandatory-stop pre-trade gate,
|
|
40
|
+
* because that gate was wired behind `bracketsEnabled(loadBracketMode())`.
|
|
41
|
+
* Omitted/false on Binance + paper: byte-identical to the old behaviour.
|
|
42
|
+
*/
|
|
43
|
+
readonly bracketsAlwaysEnforced?: boolean;
|
|
31
44
|
createOrder(symbol: string, side: 'buy' | 'sell', type: 'market' | 'limit', amount: number, price?: number, metadata?: PositionMetadata, options?: OrderOptions): Promise<CcxtOrder>;
|
|
32
45
|
cancelOrder(orderId: string, symbol?: string): Promise<CcxtOrder>;
|
|
33
46
|
cancelAllOrders(symbol?: string): Promise<CcxtOrder[]>;
|
package/index.js
CHANGED
|
@@ -816,6 +816,7 @@ const TOOL_PARAMS = {
|
|
|
816
816
|
test_exchange_credentials: {
|
|
817
817
|
type: 'object',
|
|
818
818
|
properties: {
|
|
819
|
+
operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
|
|
819
820
|
apiKey: { type: 'string', description: 'Binance API key to verify (not persisted)' },
|
|
820
821
|
secret: { type: 'string', description: 'Binance API secret to verify (not persisted)' },
|
|
821
822
|
testnet: { type: 'boolean', description: 'Test against Binance testnet' },
|
|
@@ -2650,7 +2651,7 @@ const paperTradingPlugin = {
|
|
|
2650
2651
|
{
|
|
2651
2652
|
name: 'set_trading_mode',
|
|
2652
2653
|
label: 'Set Trading Mode',
|
|
2653
|
-
description: 'Operator-only. Move the plugin between PAPER / MICRO_LIVE / LIVE. Enforces the one-rung-at-a-time ladder. Refused without dashboard operator provenance.',
|
|
2654
|
+
description: 'Operator-only. Move the plugin between PAPER / MICRO_LIVE / LIVE. Enforces the one-rung-at-a-time ladder. Briefly reconnects the trading adapter (open positions keep their exchange-side protective brackets throughout). Refused without dashboard operator provenance.',
|
|
2654
2655
|
parameters: TOOL_PARAMS.set_trading_mode,
|
|
2655
2656
|
execute: async (_id, params) => {
|
|
2656
2657
|
const prov = verifyOperatorProvenance(params.operator_token);
|
|
@@ -2662,9 +2663,17 @@ const paperTradingPlugin = {
|
|
|
2662
2663
|
{
|
|
2663
2664
|
name: 'test_exchange_credentials',
|
|
2664
2665
|
label: 'Test Exchange Credentials',
|
|
2665
|
-
description: 'Operator-only. Verify a Binance API key + secret with a read-only call (fetchBalance) without persisting anything. Used by the dashboard pre-flight check before set_exchange_credentials.
|
|
2666
|
+
description: 'Operator-only. Verify a Binance API key + secret with a read-only call (fetchBalance) without persisting anything. Used by the dashboard pre-flight check before set_exchange_credentials. Refused without dashboard operator provenance.',
|
|
2666
2667
|
parameters: TOOL_PARAMS.test_exchange_credentials,
|
|
2667
|
-
|
|
2668
|
+
// Read-only, but provenance-gated anyway: it makes a live authenticated
|
|
2669
|
+
// exchange call with caller-supplied keys, which would otherwise hand a
|
|
2670
|
+
// prompt-injected agent a credential-validation oracle.
|
|
2671
|
+
execute: async (_id, params) => {
|
|
2672
|
+
const prov = verifyOperatorProvenance(params.operator_token);
|
|
2673
|
+
if (!prov.ok)
|
|
2674
|
+
return jsonResult({ error: prov.error });
|
|
2675
|
+
return jsonResult(await testExchangeCredentialsTool(params));
|
|
2676
|
+
},
|
|
2668
2677
|
},
|
|
2669
2678
|
{
|
|
2670
2679
|
name: 'clear_exchange_credentials',
|
|
@@ -2683,7 +2692,9 @@ const paperTradingPlugin = {
|
|
|
2683
2692
|
label: 'Get Bracket Config',
|
|
2684
2693
|
description: 'Operator-only. Returns the current bracket-orders configuration (mode + requireStopLoss + requireTakeProfit). Used by the dashboard Trading Parameters panel.',
|
|
2685
2694
|
parameters: TOOL_PARAMS.get_bracket_config,
|
|
2686
|
-
execute
|
|
2695
|
+
// Adapter read at execute time (not registration) so a PAPER→HL-live
|
|
2696
|
+
// swap changes the reported protection without a restart.
|
|
2697
|
+
execute: async () => jsonResult(getBracketConfigTool({}, { adapter: runtime.adapter })),
|
|
2687
2698
|
},
|
|
2688
2699
|
{
|
|
2689
2700
|
name: 'set_bracket_requirement',
|
|
@@ -7,5 +7,16 @@ export declare function startEventLoopMonitor(): void;
|
|
|
7
7
|
* The histogram stores nanoseconds; `max` is Infinity-safe but can read as a
|
|
8
8
|
* sentinel before the first sample lands, so anything non-finite → null. */
|
|
9
9
|
export declare function sampleEventLoopDelayMs(): number | null;
|
|
10
|
+
/** Time (ms) this process spent waiting for a CPU since the previous call, then
|
|
11
|
+
* re-baselines — so consecutive calls partition the timeline into the SAME
|
|
12
|
+
* non-overlapping windows as `sampleEventLoopDelayMs`, and the two readings of
|
|
13
|
+
* one cycle describe one window.
|
|
14
|
+
*
|
|
15
|
+
* ★ Call this EVERY cycle, not only when a stall was observed: the counter is
|
|
16
|
+
* cumulative since process start, so a first read taken at the moment of a
|
|
17
|
+
* freeze would report hours of ordinary scheduling as if it were the freeze.
|
|
18
|
+
*
|
|
19
|
+
* Returns null when unavailable or on the first (baseline-establishing) call. */
|
|
20
|
+
export declare function sampleRunqueueWaitMs(): number | null;
|
|
10
21
|
/** Test-only — drop the singleton so a fresh histogram is created. */
|
|
11
22
|
export declare function __resetEventLoopMonitorForTests(): void;
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// createRequire dance exists for that dependency's module shape, not for
|
|
26
26
|
// builtins. See docs/CLAUDE/plugin-integration.md.
|
|
27
27
|
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
|
28
|
+
import { readFileSync } from 'node:fs';
|
|
28
29
|
import { logger } from '../logger.js';
|
|
29
30
|
const TAG = 'event-loop-monitor';
|
|
30
31
|
/** Sampling resolution. 20ms is fine-grained enough to catch a real stall
|
|
@@ -99,6 +100,80 @@ export function sampleEventLoopDelayMs() {
|
|
|
99
100
|
return null;
|
|
100
101
|
}
|
|
101
102
|
}
|
|
103
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
// Runqueue wait — the measurement that says WHO froze the loop.
|
|
105
|
+
//
|
|
106
|
+
// ★ Event-loop delay is a symptom with two very different causes: the host
|
|
107
|
+
// descheduled us (co-tenant load, an oversubscribed VPS) or we blocked our own
|
|
108
|
+
// loop (a synchronous burst — on a ReefClaw box, an LLM turn, which runs
|
|
109
|
+
// EMBEDDED in this same process alongside order handling). The histogram above
|
|
110
|
+
// cannot tell them apart, so the check used to assert the first and tell the
|
|
111
|
+
// operator to buy more CPU. Verified wrong on prod 2026-07-29: a "froze 5.9s"
|
|
112
|
+
// warn on a box at 0.05 load, 0 steal, 0 swap, whose cumulative runqueue wait
|
|
113
|
+
// was 1.95s across the whole process lifetime. Resizing would have done nothing.
|
|
114
|
+
//
|
|
115
|
+
// /proc/self/schedstat field 2 is the kernel's own count of nanoseconds this
|
|
116
|
+
// thread spent RUNNABLE but waiting for a CPU. It is exactly the discriminator:
|
|
117
|
+
// large ⇒ the host really is starving us; ~0 while the loop froze ⇒ the freeze
|
|
118
|
+
// came from inside. `/proc/self` is the thread-group leader = the main thread,
|
|
119
|
+
// which is where the event loop runs, so this is the right thread to ask.
|
|
120
|
+
//
|
|
121
|
+
// Same evidence rule as everything else on this path (issue #265): unavailable
|
|
122
|
+
// ⇒ null ⇒ 'unknown' attribution and the cautious copy — never a fabricated
|
|
123
|
+
// zero, which would read as "definitely self-inflicted" and is the same
|
|
124
|
+
// false-confidence bug pointed the other way.
|
|
125
|
+
const SCHEDSTAT_PATH = '/proc/self/schedstat';
|
|
126
|
+
/** Cumulative ns at the previous sample; null until the first read establishes
|
|
127
|
+
* a baseline. A delta needs two points — the first call can only arm. */
|
|
128
|
+
let lastRunqueueWaitNs = null;
|
|
129
|
+
/** Latched after the first failed read: not Linux, or a kernel built without
|
|
130
|
+
* CONFIG_SCHEDSTATS. Stops us re-reading a file that will never exist. */
|
|
131
|
+
let schedstatUnavailable = false;
|
|
132
|
+
/** Read the cumulative runqueue-wait counter (ns). Null when unreadable or
|
|
133
|
+
* malformed — never a guess. */
|
|
134
|
+
function readRunqueueWaitNs() {
|
|
135
|
+
if (schedstatUnavailable)
|
|
136
|
+
return null;
|
|
137
|
+
try {
|
|
138
|
+
// Three space-separated numbers: cpu_time_ns, runqueue_wait_ns, timeslices.
|
|
139
|
+
const parts = readFileSync(SCHEDSTAT_PATH, 'utf8').trim().split(/\s+/);
|
|
140
|
+
const ns = Number(parts[1]);
|
|
141
|
+
if (!Number.isFinite(ns) || ns < 0) {
|
|
142
|
+
schedstatUnavailable = true;
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return ns;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
schedstatUnavailable = true;
|
|
149
|
+
logger.info(TAG, 'runqueue-wait unavailable (not Linux, or kernel without CONFIG_SCHEDSTATS) — host-stall attribution will report unknown');
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Time (ms) this process spent waiting for a CPU since the previous call, then
|
|
154
|
+
* re-baselines — so consecutive calls partition the timeline into the SAME
|
|
155
|
+
* non-overlapping windows as `sampleEventLoopDelayMs`, and the two readings of
|
|
156
|
+
* one cycle describe one window.
|
|
157
|
+
*
|
|
158
|
+
* ★ Call this EVERY cycle, not only when a stall was observed: the counter is
|
|
159
|
+
* cumulative since process start, so a first read taken at the moment of a
|
|
160
|
+
* freeze would report hours of ordinary scheduling as if it were the freeze.
|
|
161
|
+
*
|
|
162
|
+
* Returns null when unavailable or on the first (baseline-establishing) call. */
|
|
163
|
+
export function sampleRunqueueWaitMs() {
|
|
164
|
+
const ns = readRunqueueWaitNs();
|
|
165
|
+
if (ns == null)
|
|
166
|
+
return null;
|
|
167
|
+
const prev = lastRunqueueWaitNs;
|
|
168
|
+
lastRunqueueWaitNs = ns;
|
|
169
|
+
if (prev == null)
|
|
170
|
+
return null;
|
|
171
|
+
// Counters only climb; a decrease means we are not reading what we think we
|
|
172
|
+
// are (or it wrapped). Report unknown rather than a negative/garbage window.
|
|
173
|
+
if (ns < prev)
|
|
174
|
+
return null;
|
|
175
|
+
return (ns - prev) / 1e6;
|
|
176
|
+
}
|
|
102
177
|
/** Test-only — drop the singleton so a fresh histogram is created. */
|
|
103
178
|
export function __resetEventLoopMonitorForTests() {
|
|
104
179
|
try {
|
|
@@ -110,4 +185,6 @@ export function __resetEventLoopMonitorForTests() {
|
|
|
110
185
|
histogram = null;
|
|
111
186
|
initFailed = false;
|
|
112
187
|
armed = false;
|
|
188
|
+
lastRunqueueWaitNs = null;
|
|
189
|
+
schedstatUnavailable = false;
|
|
113
190
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { type ReadinessReport, type VenueId, type VenueReachabilityResult } from '@reefclaw/shared';
|
|
2
|
+
/** Who froze the loop. 'unknown' when the kernel counter is unreadable (not
|
|
3
|
+
* Linux / no CONFIG_SCHEDSTATS / first cycle) — attribution is evidence, and
|
|
4
|
+
* absent evidence stays absent rather than defaulting to a blame. */
|
|
5
|
+
export type StallAttribution = 'host' | 'self' | 'unknown';
|
|
6
|
+
/** Compare the freeze against the CPU the host actually denied us. Exported for
|
|
7
|
+
* unit tests — this is the whole discrimination rule in one place. */
|
|
8
|
+
export declare function attributeStall(stallMs: number, runqueueWaitMs: number | null): StallAttribution;
|
|
2
9
|
/** Venue-agnostic reachability probe — BinancePublicApi.probeReachability and
|
|
3
10
|
* HyperliquidPublicApi.probeReachability both return exactly this shape. */
|
|
4
11
|
export interface VenueReachabilityProbe {
|
|
@@ -46,6 +53,8 @@ export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'v
|
|
|
46
53
|
state?: ReadinessCycleState;
|
|
47
54
|
/** Injected for tests; production reads the real event-loop histogram. */
|
|
48
55
|
sampleHostStallMs?: () => number | null;
|
|
56
|
+
/** Injected for tests; production reads /proc/self/schedstat. */
|
|
57
|
+
sampleRunqueueWaitMs?: () => number | null;
|
|
49
58
|
}): Promise<ReadinessReport>;
|
|
50
59
|
/** Test-only — reset the singleton guard between unit tests. */
|
|
51
60
|
export declare function __resetReadinessReporterForTests(): void;
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
// module-level singleton guard mirrors `pluginInitialised` in index.ts — OpenClaw
|
|
9
9
|
// calls register() multiple times per process and we must never spawn a second
|
|
10
10
|
// timer. The interval is unref()'d so it never holds the process open.
|
|
11
|
-
import { makeReadinessCheck, deriveOverallReadiness, GEO_BLOCK_FIX_HINT, } from '@reefclaw/shared';
|
|
11
|
+
import { makeReadinessCheck, deriveOverallReadiness, GEO_BLOCK_FIX_HINT, HOST_STALL_FIX_HINT, } from '@reefclaw/shared';
|
|
12
12
|
import { logger, formatError } from '../logger.js';
|
|
13
|
-
import { startEventLoopMonitor, sampleEventLoopDelayMs } from './event-loop-monitor.js';
|
|
13
|
+
import { startEventLoopMonitor, sampleEventLoopDelayMs, sampleRunqueueWaitMs, } from './event-loop-monitor.js';
|
|
14
14
|
const TAG = 'readiness';
|
|
15
15
|
const DEFAULT_INTERVAL_MS = 300_000; // 5 min — geo/clock state changes rarely.
|
|
16
16
|
const MIN_INTERVAL_MS = 60_000;
|
|
@@ -25,6 +25,24 @@ const REACH_WARN_AFTER_FAILURES = 2;
|
|
|
25
25
|
* Well above ordinary GC/boot jitter (tens to low hundreds of ms) and far
|
|
26
26
|
* below the freezes worth alarming on — the live incident produced 89s-556s. */
|
|
27
27
|
const HOST_STALL_WARN_MS = 5_000;
|
|
28
|
+
/** How much of a freeze the kernel must attribute to runqueue wait before we
|
|
29
|
+
* blame the HOST for it. Half is deliberately generous to the host-starved
|
|
30
|
+
* reading: a host that froze us for N seconds necessarily made us wait most of
|
|
31
|
+
* those N seconds, while a self-blocked loop accrues ~nothing (prod: 1.95s
|
|
32
|
+
* across an entire process lifetime, against a 5.9s freeze in one cycle). */
|
|
33
|
+
const HOST_WAIT_SHARE_OF_STALL = 0.5;
|
|
34
|
+
/** Absolute floor under the share test. Sub-second waiting is ordinary
|
|
35
|
+
* scheduling on any box and must never read as starvation, however short the
|
|
36
|
+
* freeze it is compared against. */
|
|
37
|
+
const HOST_WAIT_FLOOR_MS = 1_000;
|
|
38
|
+
/** Compare the freeze against the CPU the host actually denied us. Exported for
|
|
39
|
+
* unit tests — this is the whole discrimination rule in one place. */
|
|
40
|
+
export function attributeStall(stallMs, runqueueWaitMs) {
|
|
41
|
+
if (runqueueWaitMs == null)
|
|
42
|
+
return 'unknown';
|
|
43
|
+
const hostThreshold = Math.max(HOST_WAIT_FLOOR_MS, stallMs * HOST_WAIT_SHARE_OF_STALL);
|
|
44
|
+
return runqueueWaitMs >= hostThreshold ? 'host' : 'self';
|
|
45
|
+
}
|
|
28
46
|
export function createReadinessCycleState() {
|
|
29
47
|
return { consecutiveReachFailures: 0 };
|
|
30
48
|
}
|
|
@@ -52,6 +70,7 @@ export async function collectReadiness(opts, bootWarmup = false, deps = {}) {
|
|
|
52
70
|
const checks = [];
|
|
53
71
|
const state = deps.state ?? createReadinessCycleState();
|
|
54
72
|
const sampleStall = deps.sampleHostStallMs ?? sampleEventLoopDelayMs;
|
|
73
|
+
const sampleWait = deps.sampleRunqueueWaitMs ?? sampleRunqueueWaitMs;
|
|
55
74
|
// The venue decides which reachability check this report carries; the copy
|
|
56
75
|
// for both ids lives in shared/src/readiness.ts. Clock drift comes from the
|
|
57
76
|
// same probe on both venues (Binance fapi serverTime / Hyperliquid
|
|
@@ -77,6 +96,12 @@ export async function collectReadiness(opts, bootWarmup = false, deps = {}) {
|
|
|
77
96
|
// 'unknown', never a fabricated healthy zero — except that a probe which
|
|
78
97
|
// measured its own overshoot IS a stall measurement, so it stands in.
|
|
79
98
|
const stallMs = sampleStall() ?? (probe.outcome === 'stalled' ? (probe.stallMs ?? null) : null);
|
|
99
|
+
// ★ Sampled unconditionally, even on a passing cycle: the counter behind it is
|
|
100
|
+
// cumulative, so it must be re-baselined every cycle for its window to line up
|
|
101
|
+
// with the freeze window. Reading it only when a stall appeared would charge
|
|
102
|
+
// that one freeze with every millisecond of ordinary scheduling since boot and
|
|
103
|
+
// score a self-blocked loop as a starved host.
|
|
104
|
+
const runqueueWaitMs = sampleWait();
|
|
80
105
|
if (stallMs == null) {
|
|
81
106
|
checks.push(makeReadinessCheck('host_responsive', 'unknown', { checkedAt: now }));
|
|
82
107
|
}
|
|
@@ -85,16 +110,40 @@ export async function collectReadiness(opts, bootWarmup = false, deps = {}) {
|
|
|
85
110
|
// Boot congestion (WS start, snapshot, seed all racing) legitimately stalls
|
|
86
111
|
// the loop for seconds — same warm-up rule as the clock check below.
|
|
87
112
|
const status = bootWarmup && rawStatus !== 'pass' ? 'unknown' : rawStatus;
|
|
113
|
+
// WHO froze it — computed only for a real freeze, since the whole question
|
|
114
|
+
// is what to tell the operator to go fix.
|
|
115
|
+
const attribution = rawStatus === 'pass' ? 'unknown' : attributeStall(stallMs, runqueueWaitMs);
|
|
116
|
+
const frozeSecs = (stallMs / 1000).toFixed(1);
|
|
117
|
+
const waitedSecs = runqueueWaitMs != null ? (runqueueWaitMs / 1000).toFixed(1) : null;
|
|
118
|
+
// The detail line carries the evidence, not just the verdict: an operator
|
|
119
|
+
// who disagrees with the attribution can see the number it rests on.
|
|
120
|
+
const stallDetail = attribution === 'host'
|
|
121
|
+
? `froze ${frozeSecs}s (waited ${waitedSecs}s for CPU)`
|
|
122
|
+
: attribution === 'self'
|
|
123
|
+
? `froze ${frozeSecs}s (host had CPU free — blocked inside the agent process)`
|
|
124
|
+
: `froze ${frozeSecs}s`;
|
|
88
125
|
checks.push(makeReadinessCheck('host_responsive', status, {
|
|
89
126
|
detail: rawStatus === 'pass'
|
|
90
127
|
? `max loop delay ${Math.round(stallMs)}ms`
|
|
91
128
|
: bootWarmup
|
|
92
|
-
? `froze ${
|
|
93
|
-
:
|
|
129
|
+
? `froze ${frozeSecs}s (boot warm-up — rechecking)`
|
|
130
|
+
: stallDetail,
|
|
131
|
+
// Only an attributed stall may name its cause; 'unknown' falls through to
|
|
132
|
+
// the cautious default in the copy map (issue #265 rule).
|
|
133
|
+
...(attribution === 'host'
|
|
134
|
+
? { fixHint: HOST_STALL_FIX_HINT.host_starved }
|
|
135
|
+
: attribution === 'self'
|
|
136
|
+
? { fixHint: HOST_STALL_FIX_HINT.self_inflicted }
|
|
137
|
+
: {}),
|
|
94
138
|
checkedAt: now,
|
|
95
139
|
}));
|
|
96
140
|
if (rawStatus !== 'pass' && !bootWarmup) {
|
|
97
|
-
|
|
141
|
+
const cause = attribution === 'host'
|
|
142
|
+
? `host starved: waited ${waitedSecs}s for CPU`
|
|
143
|
+
: attribution === 'self'
|
|
144
|
+
? `blocked inside this process (host had CPU free — waited only ${waitedSecs}s)`
|
|
145
|
+
: 'cause unattributable (runqueue wait unreadable)';
|
|
146
|
+
logger.warn(TAG, `event loop froze ${frozeSecs}s this cycle — order handling and bracket resync can lag by that much; ${cause}`);
|
|
98
147
|
}
|
|
99
148
|
}
|
|
100
149
|
// venue reachability (+ clock drift from the same probe response)
|
package/live/user-data-stream.js
CHANGED
|
@@ -297,8 +297,16 @@ export class UserDataStream extends EventEmitter {
|
|
|
297
297
|
// Capped to avoid flooding the journal under heavy bracket
|
|
298
298
|
// traffic. Operators read these first-N samples once after
|
|
299
299
|
// deploy to verify the parser matches Binance's actual shape.
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
// Raw account payloads (order ids, prices, quantities) only
|
|
301
|
+
// reach the journal behind an explicit opt-in — by default we
|
|
302
|
+
// log the parser-relevant SHAPE (field names), which is what
|
|
303
|
+
// shape-drift verification actually needs.
|
|
304
|
+
const raw = process.env.RC_LOG_RAW_WS === 'on';
|
|
305
|
+
const shape = raw
|
|
306
|
+
? JSON.stringify(obj)
|
|
307
|
+
: `keys=[${Object.keys(obj).join(',')}] event.keys=[${Object.keys(obj.o ?? {}).join(',')}]`;
|
|
308
|
+
logger.info(TAG, `ALGO_UPDATE ${raw ? 'raw' : 'shape'} (${this.algoUpdateRawLogged + 1}/` +
|
|
309
|
+
`${UserDataStream.ALGO_UPDATE_RAW_LOG_CAP}): ${shape}`);
|
|
302
310
|
this.algoUpdateRawLogged++;
|
|
303
311
|
}
|
|
304
312
|
this.emit('algoUpdate', ev);
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "reefclaw-paper-trading",
|
|
3
3
|
"name": "ReefClaw Trading",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.15",
|
|
5
5
|
"description": "Supervised trading plugin for the ReefClaw dashboard: paper trading with real market data (no API keys required), and optional live trading on Binance or Hyperliquid behind explicit operator opt-in, exchange API credentials, and always-on protective stop brackets. Includes the dashboard connector bridge, heartbeat automation, and remote SKILL.md instruction updates from the ReefClaw webapp.",
|
|
6
6
|
"author": "ReefClaw",
|
|
7
7
|
"activation": {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reefclaw/openclaw-plugin",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "ReefClaw supervised trading plugin for OpenClaw
|
|
3
|
+
"version": "0.1.15",
|
|
4
|
+
"description": "ReefClaw supervised trading plugin for OpenClaw — paper trading with real market data, optional live trading on Binance or Hyperliquid (operator opt-in, API keys, always-on protective brackets), plus the ReefClaw dashboard connector with heartbeat automation and remote SKILL.md updates from the ReefClaw webapp. Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"openclaw": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
-
"**/*"
|
|
19
|
+
"**/*",
|
|
20
|
+
"!scripts/**"
|
|
20
21
|
],
|
|
21
22
|
"engines": {
|
|
22
23
|
"node": ">=20"
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
"@reefclaw/shared": "0.1.3",
|
|
26
27
|
"ccxt": "4.5.37",
|
|
27
28
|
"json5": "2.2.3",
|
|
28
|
-
"ws": "8.
|
|
29
|
+
"ws": "8.21.1"
|
|
29
30
|
},
|
|
30
31
|
"scripts": {
|
|
31
32
|
"build": "node scripts/assemble.mjs"
|