@reefclaw/openclaw-plugin 0.1.14 → 0.1.16
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 +9 -6
- 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
package/bridge/bridge.d.ts
CHANGED
|
@@ -66,11 +66,26 @@ export declare class Bridge {
|
|
|
66
66
|
private stopThrottleTimer;
|
|
67
67
|
private handleRequest;
|
|
68
68
|
private handleStateChange;
|
|
69
|
-
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
|
|
69
|
+
/** Whether operator-write methods are allowed for the current session mix.
|
|
70
|
+
*
|
|
71
|
+
* This is NOT a per-frame credential check, and does not claim to be. The
|
|
72
|
+
* real authorization boundary for operator-write methods is layered
|
|
73
|
+
* OUTSIDE this method:
|
|
74
|
+
* 1. Transport: every frame reaching handleRequest arrives over the relay
|
|
75
|
+
* connection, which authenticated the per-user rc_ token against the
|
|
76
|
+
* user-bound relay room before any frame flows (relay/src/auth.ts).
|
|
77
|
+
* There is no other ingress to this dispatcher.
|
|
78
|
+
* 2. State-mutating tools: the plugin independently refuses
|
|
79
|
+
* set_trading_mode / set_exchange_credentials /
|
|
80
|
+
* clear_exchange_credentials / set_bracket_requirement /
|
|
81
|
+
* test_exchange_credentials without the `operator_token` provenance
|
|
82
|
+
* proof (audit F12) — a compromised bridge still cannot flip trading
|
|
83
|
+
* state.
|
|
84
|
+
* While every relay session is an operator session by construction, this
|
|
85
|
+
* returns true; it is the seam where per-session roles attach if a
|
|
86
|
+
* non-operator ingress is ever added, without restructuring the
|
|
87
|
+
* dispatcher. */
|
|
88
|
+
private operatorWriteAllowed;
|
|
74
89
|
/** Handle a set_trading_mode request. Returns 'responded' if the handler
|
|
75
90
|
* already wrote the response frame (e.g. on validation failure), otherwise
|
|
76
91
|
* returns the structured outcome for the caller to wrap in a success
|
package/bridge/bridge.js
CHANGED
|
@@ -326,15 +326,15 @@ export class Bridge {
|
|
|
326
326
|
async handleRequest(frame) {
|
|
327
327
|
const { id, method, params } = frame;
|
|
328
328
|
try {
|
|
329
|
-
// ---- Operator-write
|
|
330
|
-
// Methods in OPERATOR_WRITE_METHODS
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
// list
|
|
337
|
-
if (OPERATOR_WRITE_METHODS.has(method) && !this.
|
|
329
|
+
// ---- Operator-write gate ----
|
|
330
|
+
// Methods in OPERATOR_WRITE_METHODS are dashboard-operator actions. The
|
|
331
|
+
// enforcement is layered and documented on operatorWriteAllowed():
|
|
332
|
+
// transport auth at the relay (token↔room binding — the only ingress to
|
|
333
|
+
// this dispatcher) + plugin-side operator_token provenance on every
|
|
334
|
+
// state-mutating tool. This branch is the forward-compatible seam for
|
|
335
|
+
// per-session roles; it does not itself verify credentials. Emergency
|
|
336
|
+
// methods are on the list for the same forward-compatibility reason.
|
|
337
|
+
if (OPERATOR_WRITE_METHODS.has(method) && !this.operatorWriteAllowed()) {
|
|
338
338
|
audit('operator_write.denied', { method, id });
|
|
339
339
|
this.connector.sendResponse(id, false, undefined, {
|
|
340
340
|
code: 403,
|
|
@@ -578,11 +578,26 @@ export class Bridge {
|
|
|
578
578
|
logger.info(TAG, `Connector state: ${state}`);
|
|
579
579
|
}
|
|
580
580
|
// ---- Operator onboarding (PR2) ----
|
|
581
|
-
/**
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
|
|
581
|
+
/** Whether operator-write methods are allowed for the current session mix.
|
|
582
|
+
*
|
|
583
|
+
* This is NOT a per-frame credential check, and does not claim to be. The
|
|
584
|
+
* real authorization boundary for operator-write methods is layered
|
|
585
|
+
* OUTSIDE this method:
|
|
586
|
+
* 1. Transport: every frame reaching handleRequest arrives over the relay
|
|
587
|
+
* connection, which authenticated the per-user rc_ token against the
|
|
588
|
+
* user-bound relay room before any frame flows (relay/src/auth.ts).
|
|
589
|
+
* There is no other ingress to this dispatcher.
|
|
590
|
+
* 2. State-mutating tools: the plugin independently refuses
|
|
591
|
+
* set_trading_mode / set_exchange_credentials /
|
|
592
|
+
* clear_exchange_credentials / set_bracket_requirement /
|
|
593
|
+
* test_exchange_credentials without the `operator_token` provenance
|
|
594
|
+
* proof (audit F12) — a compromised bridge still cannot flip trading
|
|
595
|
+
* state.
|
|
596
|
+
* While every relay session is an operator session by construction, this
|
|
597
|
+
* returns true; it is the seam where per-session roles attach if a
|
|
598
|
+
* non-operator ingress is ever added, without restructuring the
|
|
599
|
+
* dispatcher. */
|
|
600
|
+
operatorWriteAllowed() {
|
|
586
601
|
return true;
|
|
587
602
|
}
|
|
588
603
|
/** Handle a set_trading_mode request. Returns 'responded' if the handler
|
package/bridge/config.js
CHANGED
|
@@ -11,6 +11,12 @@ const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
|
|
11
11
|
const CONFIG_PATH = join(OPENCLAW_DIR, 'openclaw.json');
|
|
12
12
|
const DEFAULT_RELAY_URL = 'wss://reefclaw.radunlupsa.partykit.dev';
|
|
13
13
|
// ---- Read/write OpenClaw config ----
|
|
14
|
+
//
|
|
15
|
+
// Scope contract: these helpers touch ONLY the ReefClaw skill entry
|
|
16
|
+
// (skills.entries.reefclaw.config — the token/userId/relayUrl the USER pasted
|
|
17
|
+
// during onboarding) and preserve every other key verbatim. They exist so the
|
|
18
|
+
// connector can pick up a connection the user saved via chat and persist it
|
|
19
|
+
// across restarts — not to manage OpenClaw's own configuration.
|
|
14
20
|
/** Read the OpenClaw config file and extract ReefClaw skill settings.
|
|
15
21
|
* Prefers the schema-valid nested shape (entry.config.*), falls back to the
|
|
16
22
|
* legacy flat shape per-field. */
|
|
@@ -71,7 +71,10 @@ export declare class GatewayWsClient {
|
|
|
71
71
|
private listeners;
|
|
72
72
|
private readonly gatewayUrl;
|
|
73
73
|
private readonly gatewayToken;
|
|
74
|
-
|
|
74
|
+
private readonly requestAdminScope;
|
|
75
|
+
constructor(config: Pick<GatewayConfig, 'gatewayUrl' | 'gatewayToken'> & {
|
|
76
|
+
requestAdminScope?: boolean;
|
|
77
|
+
}, reconnect?: Partial<ReconnectConfig>);
|
|
75
78
|
/** Start the WebSocket connection and handshake */
|
|
76
79
|
connect(): void;
|
|
77
80
|
/** Disconnect and clean up. Cannot reconnect after this. */
|
|
@@ -5,6 +5,21 @@ import WebSocket from 'ws';
|
|
|
5
5
|
import { logger } from '../logger.js';
|
|
6
6
|
import { computeDelay } from '../utils/reconnect.js';
|
|
7
7
|
const TAG = 'gateway-ws';
|
|
8
|
+
/** True when a `host[:port][/path]` string points at this machine. Used to
|
|
9
|
+
* decide the default scheme for bare gateway hosts (loopback → ws://, remote
|
|
10
|
+
* → wss://) and to warn on plaintext-to-remote. */
|
|
11
|
+
function isLoopbackHost(hostAndRest) {
|
|
12
|
+
const authority = hostAndRest.split('/')[0].toLowerCase();
|
|
13
|
+
if (authority.startsWith('[')) {
|
|
14
|
+
// Bracketed IPv6, e.g. [::1]:18789
|
|
15
|
+
const end = authority.indexOf(']');
|
|
16
|
+
return (end > 0 ? authority.slice(1, end) : authority.slice(1)) === '::1';
|
|
17
|
+
}
|
|
18
|
+
if (authority === '::1')
|
|
19
|
+
return true; // bare IPv6 loopback (no port possible)
|
|
20
|
+
const host = authority.split(':')[0];
|
|
21
|
+
return host === 'localhost' || host.startsWith('127.');
|
|
22
|
+
}
|
|
8
23
|
// ---- Protocol constants ----
|
|
9
24
|
// Protocol range we can speak. The gateway picks its own version if it falls
|
|
10
25
|
// inside [min, max]. v3 = OpenClaw ≤2026.4.x (prod); v4 = OpenClaw 2026.6+
|
|
@@ -45,8 +60,14 @@ export class GatewayWsClient {
|
|
|
45
60
|
listeners = new Map();
|
|
46
61
|
gatewayUrl;
|
|
47
62
|
gatewayToken;
|
|
63
|
+
requestAdminScope;
|
|
48
64
|
constructor(config, reconnect) {
|
|
49
|
-
|
|
65
|
+
this.requestAdminScope = config.requestAdminScope === true;
|
|
66
|
+
// Convert to WebSocket URL: http→ws, https→wss. A bare host:port defaults
|
|
67
|
+
// by destination: loopback → ws:// (the normal localhost gateway), anything
|
|
68
|
+
// else → wss:// — a remote default must never silently downgrade to
|
|
69
|
+
// plaintext, because the hello frame carries the gateway token. An explicit
|
|
70
|
+
// ws:// to a remote host is honored but warned about below.
|
|
50
71
|
let url = config.gatewayUrl.replace(/\/$/, '');
|
|
51
72
|
if (/^https:\/\//.test(url)) {
|
|
52
73
|
url = url.replace(/^https:\/\//, 'wss://');
|
|
@@ -55,7 +76,12 @@ export class GatewayWsClient {
|
|
|
55
76
|
url = url.replace(/^http:\/\//, 'ws://');
|
|
56
77
|
}
|
|
57
78
|
else if (!/^wss?:\/\//.test(url)) {
|
|
58
|
-
url =
|
|
79
|
+
url = `${isLoopbackHost(url) ? 'ws' : 'wss'}://${url}`;
|
|
80
|
+
}
|
|
81
|
+
if (/^ws:\/\//.test(url) && !isLoopbackHost(url.slice('ws://'.length))) {
|
|
82
|
+
logger.warn(TAG, `Gateway URL ${url} is PLAINTEXT ws:// to a non-loopback host — the ` +
|
|
83
|
+
`gateway token will transit unencrypted. Use wss:// (or an SSH tunnel ` +
|
|
84
|
+
`to localhost) unless this network is fully trusted.`);
|
|
59
85
|
}
|
|
60
86
|
this.gatewayUrl = url;
|
|
61
87
|
this.gatewayToken = config.gatewayToken;
|
|
@@ -280,15 +306,19 @@ export class GatewayWsClient {
|
|
|
280
306
|
mode: 'backend',
|
|
281
307
|
},
|
|
282
308
|
role: 'operator',
|
|
283
|
-
// operator.admin
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
// 2026-07-22
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
scopes: [
|
|
309
|
+
// Least privilege: operator.admin is requested ONLY while the
|
|
310
|
+
// once-per-install heartbeat-cron ensure is still owed (cron.list/
|
|
311
|
+
// cron.add moved behind the admin scope on OpenClaw 2026.7.x —
|
|
312
|
+
// "Heartbeat cron: skipped (missing scope: operator.admin)", observed
|
|
313
|
+
// 2026-07-22 on openclaw 2026.7.1-2; without it fresh installs never
|
|
314
|
+
// get a heartbeat cron). Once the ensure marker exists, the caller
|
|
315
|
+
// constructs this client with requestAdminScope=false and every later
|
|
316
|
+
// session runs on read/write only.
|
|
317
|
+
scopes: [
|
|
318
|
+
'operator.read',
|
|
319
|
+
'operator.write',
|
|
320
|
+
...(this.requestAdminScope ? ['operator.admin'] : []),
|
|
321
|
+
],
|
|
292
322
|
auth: {
|
|
293
323
|
token: this.gatewayToken,
|
|
294
324
|
...(this.deviceToken && { deviceToken: this.deviceToken }),
|
|
@@ -100,6 +100,7 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
100
100
|
private agentStateInterval;
|
|
101
101
|
private toolRetryInterval;
|
|
102
102
|
private regimeInterval;
|
|
103
|
+
private tradingParamsInterval;
|
|
103
104
|
private signalInterval;
|
|
104
105
|
private missionInterval;
|
|
105
106
|
private analyticsInterval;
|
|
@@ -114,6 +115,17 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
114
115
|
private atrData;
|
|
115
116
|
private atrSampleCount;
|
|
116
117
|
private currentRegime;
|
|
118
|
+
/** ★ The tenant's ENFORCED risk limits, mirrored from intel
|
|
119
|
+
* `/api/trading-params` — the same row the plugin's `preTradeRiskCheck`
|
|
120
|
+
* rejects orders against. Null until the first fetch lands (then
|
|
121
|
+
* DEFAULT_RISK_LIMITS applies). Before this existed the dashboard's risk
|
|
122
|
+
* banner ran on a hardcoded ladder that no enforcer used, so it could show
|
|
123
|
+
* a red "Gate fail" for a limit nothing blocked on — and, in the other
|
|
124
|
+
* direction, stay green while the plugin rejected every entry. */
|
|
125
|
+
private riskLimits;
|
|
126
|
+
/** Tenant drawdown-zone boundaries. Drives the RED-zone auto-flatten, so a
|
|
127
|
+
* stale/stricter local ladder here can flatten a live book early. */
|
|
128
|
+
private drawdownThresholds;
|
|
117
129
|
/** Timestamp of the most recent trade (fill event) — used in buildAgentState */
|
|
118
130
|
private lastTradeTs;
|
|
119
131
|
/** Epoch ms of the most recent LIVE fill observed this process (issue #248).
|
|
@@ -416,6 +428,22 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
416
428
|
/** Log first error per poller, suppress subsequent repeats. */
|
|
417
429
|
private trackIntelError;
|
|
418
430
|
private startRegimePoller;
|
|
431
|
+
/**
|
|
432
|
+
* Mirror the tenant's trading params into the risk limits the dashboard
|
|
433
|
+
* banner and the RED-zone auto-flatten run on.
|
|
434
|
+
*
|
|
435
|
+
* Deliberately NOT routed through `startIntelligencePoller`: that helper
|
|
436
|
+
* appends `/${symbol}` and is gated by `RC_SKILL_INTEL_POLL=off`. These
|
|
437
|
+
* limits are a safety input, not a dashboard read — shedding intel display
|
|
438
|
+
* load must not silently revert the operator's configured limits to the
|
|
439
|
+
* fallback ladder.
|
|
440
|
+
*
|
|
441
|
+
* Fail-open by design: a failed fetch keeps the last good values (or the
|
|
442
|
+
* fallback constants on a cold start). We never tighten limits from a
|
|
443
|
+
* parse failure, and we never widen them past what the plugin enforces —
|
|
444
|
+
* the plugin re-reads the same row for the gate that actually blocks.
|
|
445
|
+
*/
|
|
446
|
+
private startTradingParamsPoller;
|
|
419
447
|
private startSignalPoller;
|
|
420
448
|
private startMissionPoller;
|
|
421
449
|
private startAnalyticsPoller;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GatewayProvider — real OpenClaw gateway integration.
|
|
2
2
|
// Connects to the OpenClaw gateway via HTTP and WebSocket,
|
|
3
3
|
// streams live trading data, and emits ProviderEvents.
|
|
4
|
-
import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
5
5
|
import { join } from 'path';
|
|
6
6
|
import { homedir } from 'os';
|
|
7
7
|
import { logger, formatError } from '../logger.js';
|
|
@@ -14,7 +14,7 @@ import { EventParser, mapCcxtBalance, mapCcxtOrder, extractLiquidationFields, ex
|
|
|
14
14
|
import { Poller } from '../gateway/poller.js';
|
|
15
15
|
import { ensureHeartbeatCron, isHeartbeatLikeName } from '../gateway/heartbeat-cron.js';
|
|
16
16
|
import { parseIdentityName } from '../utils/identity-name.js';
|
|
17
|
-
import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, } from './risk-calculator.js';
|
|
17
|
+
import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, DRAWDOWN_ZONE_THRESHOLDS, TENANT_LIMIT_BOUNDS, boundedNum, validateDrawdownLadder, } from './risk-calculator.js';
|
|
18
18
|
import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, } from './emergency-commands.js';
|
|
19
19
|
import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, } from './onboarding-commands.js';
|
|
20
20
|
const TAG = 'gateway';
|
|
@@ -220,6 +220,7 @@ export class GatewayProvider {
|
|
|
220
220
|
agentStateInterval = null;
|
|
221
221
|
toolRetryInterval = null;
|
|
222
222
|
regimeInterval = null;
|
|
223
|
+
tradingParamsInterval = null;
|
|
223
224
|
signalInterval = null;
|
|
224
225
|
missionInterval = null;
|
|
225
226
|
analyticsInterval = null;
|
|
@@ -240,6 +241,17 @@ export class GatewayProvider {
|
|
|
240
241
|
atrSampleCount = 0;
|
|
241
242
|
// ---- Current regime (Phase 9e: regime-conditional risk) ----
|
|
242
243
|
currentRegime = 'UNKNOWN';
|
|
244
|
+
/** ★ The tenant's ENFORCED risk limits, mirrored from intel
|
|
245
|
+
* `/api/trading-params` — the same row the plugin's `preTradeRiskCheck`
|
|
246
|
+
* rejects orders against. Null until the first fetch lands (then
|
|
247
|
+
* DEFAULT_RISK_LIMITS applies). Before this existed the dashboard's risk
|
|
248
|
+
* banner ran on a hardcoded ladder that no enforcer used, so it could show
|
|
249
|
+
* a red "Gate fail" for a limit nothing blocked on — and, in the other
|
|
250
|
+
* direction, stay green while the plugin rejected every entry. */
|
|
251
|
+
riskLimits = null;
|
|
252
|
+
/** Tenant drawdown-zone boundaries. Drives the RED-zone auto-flatten, so a
|
|
253
|
+
* stale/stricter local ladder here can flatten a live book early. */
|
|
254
|
+
drawdownThresholds = null;
|
|
243
255
|
// ---- Trade tracking ----
|
|
244
256
|
/** Timestamp of the most recent trade (fill event) — used in buildAgentState */
|
|
245
257
|
lastTradeTs = null;
|
|
@@ -352,6 +364,10 @@ export class GatewayProvider {
|
|
|
352
364
|
clearInterval(this.regimeInterval);
|
|
353
365
|
this.regimeInterval = null;
|
|
354
366
|
}
|
|
367
|
+
if (this.tradingParamsInterval) {
|
|
368
|
+
clearInterval(this.tradingParamsInterval);
|
|
369
|
+
this.tradingParamsInterval = null;
|
|
370
|
+
}
|
|
355
371
|
if (this.signalInterval) {
|
|
356
372
|
clearInterval(this.signalInterval);
|
|
357
373
|
this.signalInterval = null;
|
|
@@ -671,7 +687,10 @@ export class GatewayProvider {
|
|
|
671
687
|
return executeSetExchangeCredentials({ http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, apiKey, secret, testnet);
|
|
672
688
|
}
|
|
673
689
|
async testExchangeCredentials(apiKey, secret, testnet) {
|
|
674
|
-
return executeTestExchangeCredentials(
|
|
690
|
+
return executeTestExchangeCredentials(
|
|
691
|
+
// operatorToken required since the SkillSpector hardening: the plugin
|
|
692
|
+
// refuses the credential-validation call without dashboard provenance.
|
|
693
|
+
{ http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, apiKey, secret, testnet);
|
|
675
694
|
}
|
|
676
695
|
async clearExchangeCredentials() {
|
|
677
696
|
return executeClearExchangeCredentials({
|
|
@@ -860,7 +879,13 @@ export class GatewayProvider {
|
|
|
860
879
|
// Step 3: Create WS client and connect FIRST to obtain device token.
|
|
861
880
|
// OpenClaw's REST API requires a device token from the WS handshake,
|
|
862
881
|
// not the raw gateway auth token. Tool discovery must wait for this.
|
|
863
|
-
|
|
882
|
+
// Least privilege: operator.admin (cron management) is requested only
|
|
883
|
+
// while the once-per-install heartbeat-cron ensure is still owed — after
|
|
884
|
+
// the marker exists, every session runs on operator.read/write alone.
|
|
885
|
+
this.wsClient = new GatewayWsClient({
|
|
886
|
+
...this.config,
|
|
887
|
+
requestAdminScope: !existsSync(join(homedir(), '.openclaw', 'workspace', 'heartbeat-cron-ensured.json')),
|
|
888
|
+
});
|
|
864
889
|
this.wireWsEvents();
|
|
865
890
|
// Wait for WS handshake to complete (or fail)
|
|
866
891
|
const deviceToken = await this.waitForDeviceToken();
|
|
@@ -925,6 +950,9 @@ export class GatewayProvider {
|
|
|
925
950
|
}
|
|
926
951
|
// Step 6: Start regime poller if intelligence service is configured
|
|
927
952
|
this.startRegimePoller();
|
|
953
|
+
// Step 6b: Mirror the tenant's ENFORCED risk limits. A safety input, so it
|
|
954
|
+
// runs even when the intel display pollers are shed.
|
|
955
|
+
this.startTradingParamsPoller();
|
|
928
956
|
// Step 7: Start signal poller if intelligence service is configured
|
|
929
957
|
this.startSignalPoller();
|
|
930
958
|
// Step 8: Start mission poller if intelligence service is configured
|
|
@@ -1641,7 +1669,9 @@ export class GatewayProvider {
|
|
|
1641
1669
|
/** Simple heat score (0–100) matching webapp's HeatGauge logic */
|
|
1642
1670
|
computeHeatScoreSimple(metrics) {
|
|
1643
1671
|
const { grossExposure, dailyDrawdown, ordersPerMinute, cancelsPerMinute } = metrics.utilization;
|
|
1644
|
-
|
|
1672
|
+
// Same enforced limits the breach detector uses — otherwise the heat gauge
|
|
1673
|
+
// and the gate disagree about what "hot" means.
|
|
1674
|
+
const limits = this.riskLimits ?? DEFAULT_RISK_LIMITS;
|
|
1645
1675
|
const scores = [
|
|
1646
1676
|
limits.position.maxGrossExposure > 0 ? (grossExposure / limits.position.maxGrossExposure) * 100 : 0,
|
|
1647
1677
|
limits.loss.maxDailyDrawdown < 0 ? (dailyDrawdown / limits.loss.maxDailyDrawdown) * 100 : 0,
|
|
@@ -2302,6 +2332,8 @@ export class GatewayProvider {
|
|
|
2302
2332
|
atrData: this.atrData ?? undefined,
|
|
2303
2333
|
regime: this.currentRegime,
|
|
2304
2334
|
realizedPnlToday: this.realizedPnlToday ?? undefined,
|
|
2335
|
+
baseLimits: this.riskLimits ?? undefined,
|
|
2336
|
+
drawdownThresholds: this.drawdownThresholds ?? undefined,
|
|
2305
2337
|
});
|
|
2306
2338
|
// Store pruned timestamps back (non-mutating function returns new arrays)
|
|
2307
2339
|
this.orderTimestamps = result.prunedOrderTimestamps;
|
|
@@ -2536,6 +2568,99 @@ export class GatewayProvider {
|
|
|
2536
2568
|
this.fire('regimeUpdate', { ...data, event: 'regime_update', symbol });
|
|
2537
2569
|
}, 'regimeInterval');
|
|
2538
2570
|
}
|
|
2571
|
+
/**
|
|
2572
|
+
* Mirror the tenant's trading params into the risk limits the dashboard
|
|
2573
|
+
* banner and the RED-zone auto-flatten run on.
|
|
2574
|
+
*
|
|
2575
|
+
* Deliberately NOT routed through `startIntelligencePoller`: that helper
|
|
2576
|
+
* appends `/${symbol}` and is gated by `RC_SKILL_INTEL_POLL=off`. These
|
|
2577
|
+
* limits are a safety input, not a dashboard read — shedding intel display
|
|
2578
|
+
* load must not silently revert the operator's configured limits to the
|
|
2579
|
+
* fallback ladder.
|
|
2580
|
+
*
|
|
2581
|
+
* Fail-open by design: a failed fetch keeps the last good values (or the
|
|
2582
|
+
* fallback constants on a cold start). We never tighten limits from a
|
|
2583
|
+
* parse failure, and we never widen them past what the plugin enforces —
|
|
2584
|
+
* the plugin re-reads the same row for the gate that actually blocks.
|
|
2585
|
+
*/
|
|
2586
|
+
startTradingParamsPoller() {
|
|
2587
|
+
const url = this.config.intelligenceUrl;
|
|
2588
|
+
const token = this.config.connectionToken;
|
|
2589
|
+
if (!url || !token)
|
|
2590
|
+
return;
|
|
2591
|
+
const fullUrl = `${url}/api/trading-params`;
|
|
2592
|
+
logger.info(TAG, `Starting trading-params poller: ${fullUrl}`);
|
|
2593
|
+
const poll = async () => {
|
|
2594
|
+
if (!this.started)
|
|
2595
|
+
return;
|
|
2596
|
+
try {
|
|
2597
|
+
const res = await fetch(fullUrl, {
|
|
2598
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
2599
|
+
signal: AbortSignal.timeout(10_000),
|
|
2600
|
+
});
|
|
2601
|
+
if (!res.ok) {
|
|
2602
|
+
this.trackIntelError('tradingParams', `HTTP ${res.status}`);
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
const body = (await res.json());
|
|
2606
|
+
const p = body?.params;
|
|
2607
|
+
if (!p)
|
|
2608
|
+
return;
|
|
2609
|
+
// Every field is bounds-checked against hardcoded bands
|
|
2610
|
+
// (TENANT_LIMIT_BOUNDS / validateDrawdownLadder) — CLAUDE.md
|
|
2611
|
+
// load-bearing rule: central config moves knobs only WITHIN hardcoded
|
|
2612
|
+
// bounds; it must never be able to disable a safety mechanism.
|
|
2613
|
+
// Out-of-band → field ignored, previous value kept.
|
|
2614
|
+
const B = TENANT_LIMIT_BOUNDS;
|
|
2615
|
+
const maxPositionSize = boundedNum(p.maxPositionSize, B.maxPositionSize.min, B.maxPositionSize.max);
|
|
2616
|
+
const maxOpenPositions = boundedNum(p.maxOpenPositions, B.maxOpenPositions.min, B.maxOpenPositions.max);
|
|
2617
|
+
const maxGrossExposure = boundedNum(p.maxGrossExposure, B.maxGrossExposure.min, B.maxGrossExposure.max);
|
|
2618
|
+
const maxPerTradeLoss = boundedNum(p.maxPerTradeLoss, B.maxPerTradeLoss.min, B.maxPerTradeLoss.max);
|
|
2619
|
+
// Validate the zone ladder FIRST — the loss limit below reads from the
|
|
2620
|
+
// ACCEPTED ladder, so a rejected row can't leak its RED into
|
|
2621
|
+
// maxDailyDrawdown (which drives the dailyDrawdown AUTO_PAUSE breach).
|
|
2622
|
+
const base = this.drawdownThresholds ?? DRAWDOWN_ZONE_THRESHOLDS;
|
|
2623
|
+
const next = {
|
|
2624
|
+
YELLOW: boundedNum(p.drawdownYellow, -1, 0) ?? base.YELLOW,
|
|
2625
|
+
ORANGE: boundedNum(p.drawdownOrange, -1, 0) ?? base.ORANGE,
|
|
2626
|
+
RED: boundedNum(p.drawdownRed, -1, 0) ?? base.RED,
|
|
2627
|
+
};
|
|
2628
|
+
const ladderError = validateDrawdownLadder(next);
|
|
2629
|
+
if (ladderError === null) {
|
|
2630
|
+
this.drawdownThresholds = next;
|
|
2631
|
+
}
|
|
2632
|
+
else {
|
|
2633
|
+
logger.warn(TAG, `Ignoring drawdown zones from trading params: ${ladderError}`);
|
|
2634
|
+
}
|
|
2635
|
+
const prev = this.riskLimits ?? DEFAULT_RISK_LIMITS;
|
|
2636
|
+
const acceptedRed = this.drawdownThresholds?.RED;
|
|
2637
|
+
this.riskLimits = {
|
|
2638
|
+
position: {
|
|
2639
|
+
maxPositionSize: maxPositionSize ?? prev.position.maxPositionSize,
|
|
2640
|
+
maxOpenPositions: maxOpenPositions ?? prev.position.maxOpenPositions,
|
|
2641
|
+
maxGrossExposure: maxGrossExposure ?? prev.position.maxGrossExposure,
|
|
2642
|
+
// Intel has no separate net-tilt param; the gross cap bounds it
|
|
2643
|
+
// (|net| ≤ gross always, so this can never fire before gross).
|
|
2644
|
+
maxNetExposure: maxGrossExposure ?? prev.position.maxNetExposure,
|
|
2645
|
+
},
|
|
2646
|
+
loss: {
|
|
2647
|
+
// Drawdown limit tracks the operator's RED boundary rather than a
|
|
2648
|
+
// third independent number.
|
|
2649
|
+
maxDailyDrawdown: acceptedRed ?? prev.loss.maxDailyDrawdown,
|
|
2650
|
+
maxPerTradeLoss: maxPerTradeLoss != null ? -Math.abs(maxPerTradeLoss) : prev.loss.maxPerTradeLoss,
|
|
2651
|
+
maxDailyLoss: prev.loss.maxDailyLoss,
|
|
2652
|
+
},
|
|
2653
|
+
rate: prev.rate,
|
|
2654
|
+
};
|
|
2655
|
+
this.intelErrors.tradingParams = 0;
|
|
2656
|
+
}
|
|
2657
|
+
catch (err) {
|
|
2658
|
+
this.trackIntelError('tradingParams', formatError(err));
|
|
2659
|
+
}
|
|
2660
|
+
};
|
|
2661
|
+
void poll();
|
|
2662
|
+
this.tradingParamsInterval = setInterval(() => { void poll(); }, 60_000);
|
|
2663
|
+
}
|
|
2539
2664
|
startSignalPoller() {
|
|
2540
2665
|
this.startIntelligencePoller('signals', 'signals', 10_000, (raw) => {
|
|
2541
2666
|
const data = raw;
|
|
@@ -38,12 +38,15 @@ export interface ClearExchangeCredentialsOutcome {
|
|
|
38
38
|
export interface OnboardingContext {
|
|
39
39
|
http: GatewayHttpClient | null;
|
|
40
40
|
toolMap: ToolMap;
|
|
41
|
-
/** rc_* connection token, injected as `operator_token` on the
|
|
42
|
-
* state-MUTATING
|
|
41
|
+
/** rc_* connection token, injected as `operator_token` on the operator-only
|
|
42
|
+
* tools: the four state-MUTATING ones (set_trading_mode /
|
|
43
43
|
* set_exchange_credentials / clear_exchange_credentials /
|
|
44
|
-
* set_bracket_requirement
|
|
45
|
-
* (
|
|
46
|
-
*
|
|
44
|
+
* set_bracket_requirement, audit 2026-07-26 F12) plus
|
|
45
|
+
* test_exchange_credentials (read-only, but a live authenticated exchange
|
|
46
|
+
* call with caller-supplied keys — without the gate it would hand a
|
|
47
|
+
* prompt-injected agent a credential-validation oracle). The plugin refuses
|
|
48
|
+
* these calls without it — the operator-provenance proof that the request
|
|
49
|
+
* came through the dashboard path, which the agent cannot forge
|
|
47
50
|
* conversationally (chat redaction strips rc_* tokens). */
|
|
48
51
|
operatorToken?: string;
|
|
49
52
|
}
|
|
@@ -57,7 +57,7 @@ export async function executeTestExchangeCredentials(ctx, apiKey, secret, testne
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
try {
|
|
60
|
-
const result = await ctx.http.invoke(tool, { apiKey, secret, testnet });
|
|
60
|
+
const result = await ctx.http.invoke(tool, { apiKey, secret, testnet, operator_token: ctx.operatorToken });
|
|
61
61
|
return result.data;
|
|
62
62
|
}
|
|
63
63
|
catch (err) {
|
|
@@ -10,11 +10,53 @@ export declare const REGIME_LIMIT_PROFILES: Record<string, Partial<{
|
|
|
10
10
|
}>>;
|
|
11
11
|
/** Apply regime-conditional adjustments to limits. */
|
|
12
12
|
export declare function applyRegimeAdjustment(baseLimits: RiskLimits, regime: string): RiskLimits;
|
|
13
|
+
/** Fallback zone boundaries, used only until the tenant's trading params load.
|
|
14
|
+
* The AUTHORITATIVE values are the operator's `drawdownYellow/Orange/Red`
|
|
15
|
+
* trading params — the same numbers the plugin's pre-trade gate rejects on.
|
|
16
|
+
* Keeping a second hardcoded ladder here is how the skill ended up
|
|
17
|
+
* auto-flattening at -2.5% on an account configured to -4%. */
|
|
13
18
|
export declare const DRAWDOWN_ZONE_THRESHOLDS: {
|
|
14
19
|
readonly YELLOW: -0.01;
|
|
15
20
|
readonly ORANGE: -0.02;
|
|
16
21
|
readonly RED: -0.025;
|
|
17
22
|
};
|
|
23
|
+
export interface DrawdownThresholds {
|
|
24
|
+
YELLOW: number;
|
|
25
|
+
ORANGE: number;
|
|
26
|
+
RED: number;
|
|
27
|
+
}
|
|
28
|
+
/** The auto-flatten trigger can never be configured looser than −10%. */
|
|
29
|
+
export declare const AUTO_FLATTEN_RED_FLOOR = -0.1;
|
|
30
|
+
/** Sanity bands for tenant limit fields. Values outside → field ignored,
|
|
31
|
+
* previous value kept. Wide on purpose: these reject nonsense, not policy. */
|
|
32
|
+
export declare const TENANT_LIMIT_BOUNDS: {
|
|
33
|
+
readonly maxPositionSize: {
|
|
34
|
+
readonly min: 1;
|
|
35
|
+
readonly max: 1000000000;
|
|
36
|
+
};
|
|
37
|
+
readonly maxOpenPositions: {
|
|
38
|
+
readonly min: 1;
|
|
39
|
+
readonly max: 100;
|
|
40
|
+
};
|
|
41
|
+
readonly maxGrossExposure: {
|
|
42
|
+
readonly min: 0.01;
|
|
43
|
+
readonly max: 20;
|
|
44
|
+
};
|
|
45
|
+
readonly maxPerTradeLoss: {
|
|
46
|
+
readonly min: 0.01;
|
|
47
|
+
readonly max: 10000000;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
/** Finite number within [min, max], else null. */
|
|
51
|
+
export declare function boundedNum(v: unknown, min: number, max: number): number | null;
|
|
52
|
+
/**
|
|
53
|
+
* Validate a tenant drawdown-zone ladder. Returns null when acceptable, else
|
|
54
|
+
* a human-readable reason. Requirements: all finite; YELLOW ≤ 0 (zones are
|
|
55
|
+
* losses); strictly monotonic YELLOW > ORANGE > RED (a scrambled ladder would
|
|
56
|
+
* put the book straight into RED and auto-flatten it); RED no looser than
|
|
57
|
+
* AUTO_FLATTEN_RED_FLOOR.
|
|
58
|
+
*/
|
|
59
|
+
export declare function validateDrawdownLadder(t: DrawdownThresholds): string | null;
|
|
18
60
|
export type DrawdownZone = 'GREEN' | 'YELLOW' | 'ORANGE' | 'RED';
|
|
19
61
|
/** State snapshot needed to compute equity. */
|
|
20
62
|
export interface EquityState {
|
|
@@ -36,6 +78,12 @@ export interface RiskState {
|
|
|
36
78
|
};
|
|
37
79
|
regime?: string;
|
|
38
80
|
realizedPnlToday?: number;
|
|
81
|
+
/** The tenant's configured limits (from intel `/api/trading-params`) — the
|
|
82
|
+
* SAME numbers the plugin's pre-trade gate enforces. Omitted until the
|
|
83
|
+
* first fetch lands, in which case DEFAULT_RISK_LIMITS applies. */
|
|
84
|
+
baseLimits?: RiskLimits;
|
|
85
|
+
/** The tenant's configured drawdown-zone boundaries. Omitted → fallback. */
|
|
86
|
+
drawdownThresholds?: DrawdownThresholds;
|
|
39
87
|
}
|
|
40
88
|
/** Result of computeRiskMetrics including pruned timestamp arrays. */
|
|
41
89
|
export interface RiskMetricsResult {
|
|
@@ -51,7 +99,7 @@ export declare function computeVolFactor(state: {
|
|
|
51
99
|
/** Apply volatility adjustment to base limits. Tightens position/exposure, leaves loss/rate unchanged. */
|
|
52
100
|
export declare function applyVolatilityAdjustment(baseLimits: RiskLimits, volFactor: number): RiskLimits;
|
|
53
101
|
/** Determine drawdown zone from drawdown ratio (e.g. -0.015 = -1.5%). */
|
|
54
|
-
export declare function getDrawdownZone(drawdownRatio: number): DrawdownZone;
|
|
102
|
+
export declare function getDrawdownZone(drawdownRatio: number, thresholds?: DrawdownThresholds): DrawdownZone;
|
|
55
103
|
/** Human-readable message for a drawdown zone. */
|
|
56
104
|
export declare function getDrawdownZoneMessage(zone: DrawdownZone, ratio: number): string;
|
|
57
105
|
/**
|
|
@@ -80,6 +128,17 @@ export declare function countRecentEvents(timestamps: number[]): {
|
|
|
80
128
|
};
|
|
81
129
|
/**
|
|
82
130
|
* Detect risk limit breaches from current metrics.
|
|
131
|
+
*
|
|
132
|
+
* ★ CRITICAL is reserved for the limits that are ACTUALLY ENFORCED — the
|
|
133
|
+
* tenant's trading params, which the plugin's `preTradeRiskCheck` rejects
|
|
134
|
+
* orders against (`plugin/src/risk/pre-trade-check.ts`). A red "Gate fail" on
|
|
135
|
+
* the dashboard must mean "the agent is being blocked right now".
|
|
136
|
+
*
|
|
137
|
+
* `advisoryLimits` carries the vol/regime-TIGHTENED view. Those multipliers
|
|
138
|
+
* live only in this file — no enforcer applies them — so they may raise a
|
|
139
|
+
* WARNING (amber, indication) but must never produce a CRITICAL. Conflating
|
|
140
|
+
* the two is what put a permanent red "Gate fail · Gross exposure 121%" on a
|
|
141
|
+
* live book whose 1.5x enforced limit was never even approached.
|
|
83
142
|
*/
|
|
84
143
|
export declare function detectBreaches(metrics: {
|
|
85
144
|
grossExposure: number;
|
|
@@ -88,7 +147,7 @@ export declare function detectBreaches(metrics: {
|
|
|
88
147
|
dailyLoss?: number;
|
|
89
148
|
ordersPerMinute: number;
|
|
90
149
|
cancelsPerMinute: number;
|
|
91
|
-
}, limits: RiskLimits): RiskBreach[];
|
|
150
|
+
}, limits: RiskLimits, advisoryLimits?: RiskLimits): RiskBreach[];
|
|
92
151
|
/**
|
|
93
152
|
* Compute full risk metrics from a state snapshot.
|
|
94
153
|
* Returns metrics + pruned timestamp arrays for the caller to store.
|