@reefclaw/connect 0.1.40 → 0.1.42
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/README.md +23 -0
- package/assets/bridge/gateway/agent-scope.d.ts +30 -0
- package/assets/bridge/gateway/agent-scope.js +67 -0
- package/assets/bridge/gateway/gateway-config.d.ts +18 -3
- package/assets/bridge/gateway/gateway-config.js +42 -4
- package/assets/bridge/gateway/gateway-ws-client.d.ts +1 -0
- package/assets/bridge/gateway/gateway-ws-client.js +3 -0
- package/assets/bridge/index.js +35 -30
- package/assets/bridge/providers/connector-update.d.ts +3 -0
- package/assets/bridge/providers/connector-update.js +26 -0
- package/assets/bridge/providers/gateway.d.ts +5 -0
- package/assets/bridge/providers/gateway.js +33 -4
- package/assets/plugin/exchange-adapter.d.ts +7 -2
- package/assets/plugin/ingest/readiness-reporter.js +6 -0
- package/assets/plugin/live/stop-watcher.js +7 -1
- package/assets/plugin/openclaw.plugin.json +1 -1
- package/assets/plugin/paper-adapter.d.ts +1 -1
- package/assets/plugin/paper-adapter.js +2 -2
- package/assets/plugin/simulator/exchange-simulator.js +5 -1
- package/assets/plugin/simulator/realistic-fills.d.ts +16 -0
- package/assets/plugin/simulator/realistic-fills.js +26 -2
- package/assets/plugin/simulator/types.d.ts +4 -0
- package/dist/agents.js +92 -0
- package/dist/cli.js +16 -0
- package/package.json +5 -2
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @reefclaw/connect
|
|
2
|
+
|
|
3
|
+
One-command installer that connects an OpenClaw agent to [ReefClaw](https://reefclaw.com) for paper trading. No exchange keys are needed for paper mode.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npx -y @reefclaw/connect
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It places two things next to your OpenClaw install: the ReefClaw trading plugin (tools the agent calls) and the bridge (a small daemon that links your gateway to the ReefClaw dashboard over an outbound WebSocket). Re-running the command updates both in place, then restarts the gateway.
|
|
10
|
+
|
|
11
|
+
Requirements: Node 20 or newer for the installer itself, and whatever your OpenClaw version needs (OpenClaw 2026.9.3 and later requires Node 24).
|
|
12
|
+
|
|
13
|
+
## Notes for self-hosters
|
|
14
|
+
|
|
15
|
+
**One gateway per trading agent.** OpenClaw broadcasts every agent's run events to every operator connection, and an operator token can't be scoped to a single agent. The bridge keeps only its own agent's runs. It follows the gateway's default agent (the one the installer set up) with no configuration; if your trading agent is a different one, set `OPENCLAW_AGENT_ID` or `"agentId"` in `~/.reefclaw/plugin-config.json`. The cleanest setup for a gateway that hosts other agents is still a separate gateway instance for the trading agent.
|
|
16
|
+
|
|
17
|
+
**One-click updates from the dashboard.** The "Update connector" button asks the bridge to run `npx -y @reefclaw/connect@latest` in a gateway terminal on your box. The command is a fixed constant; nothing from the dashboard or relay is interpolated into it. If you'd rather run updates yourself (for example because you carry local patches), switch it off per host with `REEFCLAW_ALLOW_CONNECTOR_UPDATE=0` in the bridge's environment or `"connectorUpdate": "off"` in `~/.reefclaw/plugin-config.json`. Setting `gateway.terminal.enabled=false` in `openclaw.json` disables it at the gateway level too. The dashboard then shows the manual command instead.
|
|
18
|
+
|
|
19
|
+
**Paper fills.** The paper engine prices market fills off a cached order book. Since 0.1.41 a fill whose book has drifted more than 10 bps from the decision price is anchored to the decision price (stop breaches and target hits used to fill at entry-time levels).
|
|
20
|
+
|
|
21
|
+
## Reporting issues
|
|
22
|
+
|
|
23
|
+
Mail support@reefclaw.com. Security reports are welcome at the same address; please keep them at the design level until we've replied.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** OpenClaw's default agent id, the one the installer sets the trading agent
|
|
2
|
+
* up as, and the id the bridge has always addressed chat to. */
|
|
3
|
+
export declare const DEFAULT_AGENT_ID = "main";
|
|
4
|
+
/** Trim + lowercase (OpenClaw normalizes ids the same way). Undefined for
|
|
5
|
+
* anything that is not a non-empty string. */
|
|
6
|
+
export declare function normalizeAgentId(value: unknown): string | undefined;
|
|
7
|
+
/** `agent:<agentId>:<rest>` → `<agentId>`. Undefined when the key carries no
|
|
8
|
+
* agent scope (legacy keys, `global`, `unknown`, missing). */
|
|
9
|
+
export declare function resolveAgentIdFromSessionKey(sessionKey: unknown): string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* OpenClaw's own rule for the gateway's default agent (agent-scope-config.ts
|
|
12
|
+
* `resolveDefaultAgentId`): the `agents.list` entry flagged `default: true`,
|
|
13
|
+
* else the first entry, else `main`. The installer places the ReefClaw skill
|
|
14
|
+
* in that agent's workspace and the heartbeat cron runs on it, so with no
|
|
15
|
+
* override the default agent IS the trading agent. Nobody has to configure
|
|
16
|
+
* anything; an override exists for hand-built multi-agent setups.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveDefaultAgentIdFromConfig(cfg: unknown): string;
|
|
19
|
+
/** The agent an event belongs to, when the payload says. An explicit
|
|
20
|
+
* `agentId` field wins (future-proofing; today's gateway sends only the
|
|
21
|
+
* session key). */
|
|
22
|
+
export declare function resolveAgentEventOwner(payload: {
|
|
23
|
+
agentId?: unknown;
|
|
24
|
+
sessionKey?: unknown;
|
|
25
|
+
}): string | undefined;
|
|
26
|
+
/** True when the event is attributable to an agent OTHER than ours. */
|
|
27
|
+
export declare function isForeignAgentEvent(payload: {
|
|
28
|
+
agentId?: unknown;
|
|
29
|
+
sessionKey?: unknown;
|
|
30
|
+
}, ownAgentId: string): boolean;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Which OpenClaw agent this bridge belongs to, and how to tell whose run an
|
|
2
|
+
// incoming `agent` event is.
|
|
3
|
+
//
|
|
4
|
+
// An OpenClaw gateway can host several agents (`agents.list`), and it
|
|
5
|
+
// broadcasts EVERY agent's run events to EVERY operator connection —
|
|
6
|
+
// `sendAgentPayload` → `broadcast('agent', …)` in OpenClaw's server-chat.ts
|
|
7
|
+
// carries no per-agent scoping, and an operator token cannot be scoped to one
|
|
8
|
+
// agent. Until 2026-09-16 the bridge forwarded whatever arrived, so on a
|
|
9
|
+
// shared gateway a second agent's morning briefing streamed into the ReefClaw
|
|
10
|
+
// dashboard chat and across the relay (reported by a self-hosting subscriber
|
|
11
|
+
// against 0.1.31 and 0.1.40). The only owner marker on the event is its
|
|
12
|
+
// session key: `agent:<agentId>:<rest>` (OpenClaw routing/session-key.ts).
|
|
13
|
+
//
|
|
14
|
+
// Fail-open on purpose: an event with NO attributable session key (older
|
|
15
|
+
// gateways omit it; legacy/unscoped keys such as `global`) is treated as our
|
|
16
|
+
// own. Dropping it would blind the chat on exactly the single-agent boxes
|
|
17
|
+
// that cannot leak anything.
|
|
18
|
+
/** OpenClaw's default agent id, the one the installer sets the trading agent
|
|
19
|
+
* up as, and the id the bridge has always addressed chat to. */
|
|
20
|
+
export const DEFAULT_AGENT_ID = 'main';
|
|
21
|
+
/** Trim + lowercase (OpenClaw normalizes ids the same way). Undefined for
|
|
22
|
+
* anything that is not a non-empty string. */
|
|
23
|
+
export function normalizeAgentId(value) {
|
|
24
|
+
if (typeof value !== 'string')
|
|
25
|
+
return undefined;
|
|
26
|
+
const trimmed = value.trim().toLowerCase();
|
|
27
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
28
|
+
}
|
|
29
|
+
/** `agent:<agentId>:<rest>` → `<agentId>`. Undefined when the key carries no
|
|
30
|
+
* agent scope (legacy keys, `global`, `unknown`, missing). */
|
|
31
|
+
export function resolveAgentIdFromSessionKey(sessionKey) {
|
|
32
|
+
if (typeof sessionKey !== 'string')
|
|
33
|
+
return undefined;
|
|
34
|
+
const key = sessionKey.trim();
|
|
35
|
+
if (!/^agent:/i.test(key))
|
|
36
|
+
return undefined;
|
|
37
|
+
return normalizeAgentId(key.split(':')[1]);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* OpenClaw's own rule for the gateway's default agent (agent-scope-config.ts
|
|
41
|
+
* `resolveDefaultAgentId`): the `agents.list` entry flagged `default: true`,
|
|
42
|
+
* else the first entry, else `main`. The installer places the ReefClaw skill
|
|
43
|
+
* in that agent's workspace and the heartbeat cron runs on it, so with no
|
|
44
|
+
* override the default agent IS the trading agent. Nobody has to configure
|
|
45
|
+
* anything; an override exists for hand-built multi-agent setups.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveDefaultAgentIdFromConfig(cfg) {
|
|
48
|
+
const list = cfg?.agents?.list;
|
|
49
|
+
if (!Array.isArray(list))
|
|
50
|
+
return DEFAULT_AGENT_ID;
|
|
51
|
+
const entries = list.filter((e) => !!e && typeof e === 'object' && !Array.isArray(e));
|
|
52
|
+
if (entries.length === 0)
|
|
53
|
+
return DEFAULT_AGENT_ID;
|
|
54
|
+
const chosen = entries.find((e) => e.default === true) ?? entries[0];
|
|
55
|
+
return normalizeAgentId(chosen.id) ?? DEFAULT_AGENT_ID;
|
|
56
|
+
}
|
|
57
|
+
/** The agent an event belongs to, when the payload says. An explicit
|
|
58
|
+
* `agentId` field wins (future-proofing; today's gateway sends only the
|
|
59
|
+
* session key). */
|
|
60
|
+
export function resolveAgentEventOwner(payload) {
|
|
61
|
+
return normalizeAgentId(payload.agentId) ?? resolveAgentIdFromSessionKey(payload.sessionKey);
|
|
62
|
+
}
|
|
63
|
+
/** True when the event is attributable to an agent OTHER than ours. */
|
|
64
|
+
export function isForeignAgentEvent(payload, ownAgentId) {
|
|
65
|
+
const owner = resolveAgentEventOwner(payload);
|
|
66
|
+
return owner !== undefined && owner !== (normalizeAgentId(ownAgentId) ?? DEFAULT_AGENT_ID);
|
|
67
|
+
}
|
|
@@ -16,6 +16,13 @@ export interface GatewayConfig {
|
|
|
16
16
|
* Optional so a hand-built config (tests, embedders) stays valid; every
|
|
17
17
|
* consumer treats absent as 'binance', the historical behaviour. */
|
|
18
18
|
venue?: VenueId;
|
|
19
|
+
/** The OpenClaw agent this bridge serves: chat is addressed to it and ONLY
|
|
20
|
+
* its run events reach the dashboard (gateway/agent-scope.ts). Optional so
|
|
21
|
+
* a hand-built config stays valid; consumers default to 'main'. */
|
|
22
|
+
agentId?: string;
|
|
23
|
+
/** Per-host switch for the dashboard's one-click connector update, which
|
|
24
|
+
* opens a PTY on this box (providers/connector-update.ts). Absent = allowed. */
|
|
25
|
+
allowConnectorUpdate?: boolean;
|
|
19
26
|
}
|
|
20
27
|
/** CLI args specific to gateway provider */
|
|
21
28
|
export interface GatewayCliArgs {
|
|
@@ -23,14 +30,20 @@ export interface GatewayCliArgs {
|
|
|
23
30
|
gatewayToken?: string;
|
|
24
31
|
symbol?: string;
|
|
25
32
|
intelligenceUrl?: string;
|
|
33
|
+
agentId?: string;
|
|
26
34
|
}
|
|
35
|
+
/** `1/true/on/yes/enabled` → true, `0/false/off/no/disabled` → false, anything
|
|
36
|
+
* else (unset, blank, garbage) → undefined so the next source is consulted. */
|
|
37
|
+
export declare function parseOnOffSwitch(raw: unknown): boolean | undefined;
|
|
27
38
|
/**
|
|
28
39
|
* Resolve gateway connection config with priority:
|
|
29
|
-
* 1. CLI args (--gateway-url, --gateway-token, --symbol)
|
|
40
|
+
* 1. CLI args (--gateway-url, --gateway-token, --symbol, --agent-id)
|
|
30
41
|
* 2. Env vars (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL,
|
|
31
|
-
* REEFCLAW_INTELLIGENCE_URL, REEFCLAW_VENUE
|
|
42
|
+
* REEFCLAW_INTELLIGENCE_URL, REEFCLAW_VENUE, OPENCLAW_AGENT_ID,
|
|
43
|
+
* REEFCLAW_ALLOW_CONNECTOR_UPDATE)
|
|
32
44
|
* 3. Config files (~/.openclaw/openclaw.json, ~/.reefclaw/plugin-config.json)
|
|
33
|
-
* 4. Defaults (localhost:18789, the venue's default symbol, intel.reefclaw.com
|
|
45
|
+
* 4. Defaults (localhost:18789, the venue's default symbol, intel.reefclaw.com,
|
|
46
|
+
* the gateway's default agent)
|
|
34
47
|
*
|
|
35
48
|
* Returns null for gatewayToken if it can't be resolved (caller must handle).
|
|
36
49
|
* `intelligenceUrl` is never null — see DEFAULT_INTELLIGENCE_URL for why that
|
|
@@ -42,6 +55,8 @@ export declare function resolveGatewayConfig(args: GatewayCliArgs): {
|
|
|
42
55
|
symbol: string;
|
|
43
56
|
intelligenceUrl: string;
|
|
44
57
|
venue: VenueId;
|
|
58
|
+
agentId: string;
|
|
59
|
+
allowConnectorUpdate: boolean;
|
|
45
60
|
};
|
|
46
61
|
/**
|
|
47
62
|
* Validate that the resolved config has all required fields.
|
|
@@ -7,6 +7,7 @@ import { homedir } from 'os';
|
|
|
7
7
|
import JSON5 from 'json5';
|
|
8
8
|
import { parseVenue, VENUE_DEFAULT_SYMBOL } from '@reefclaw/shared';
|
|
9
9
|
import { logger } from '../logger.js';
|
|
10
|
+
import { normalizeAgentId, resolveDefaultAgentIdFromConfig } from './agent-scope.js';
|
|
10
11
|
const TAG = 'gateway-config';
|
|
11
12
|
const DEFAULT_GATEWAY_PORT = 18789;
|
|
12
13
|
const DEFAULT_GATEWAY_HOST = 'localhost';
|
|
@@ -70,14 +71,30 @@ function readPluginConfig() {
|
|
|
70
71
|
return null;
|
|
71
72
|
}
|
|
72
73
|
}
|
|
74
|
+
/** `1/true/on/yes/enabled` → true, `0/false/off/no/disabled` → false, anything
|
|
75
|
+
* else (unset, blank, garbage) → undefined so the next source is consulted. */
|
|
76
|
+
export function parseOnOffSwitch(raw) {
|
|
77
|
+
if (typeof raw === 'boolean')
|
|
78
|
+
return raw;
|
|
79
|
+
if (typeof raw !== 'string')
|
|
80
|
+
return undefined;
|
|
81
|
+
const v = raw.trim().toLowerCase();
|
|
82
|
+
if (['1', 'true', 'on', 'yes', 'enabled'].includes(v))
|
|
83
|
+
return true;
|
|
84
|
+
if (['0', 'false', 'off', 'no', 'disabled'].includes(v))
|
|
85
|
+
return false;
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
73
88
|
// ---- Resolve gateway config ----
|
|
74
89
|
/**
|
|
75
90
|
* Resolve gateway connection config with priority:
|
|
76
|
-
* 1. CLI args (--gateway-url, --gateway-token, --symbol)
|
|
91
|
+
* 1. CLI args (--gateway-url, --gateway-token, --symbol, --agent-id)
|
|
77
92
|
* 2. Env vars (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL,
|
|
78
|
-
* REEFCLAW_INTELLIGENCE_URL, REEFCLAW_VENUE
|
|
93
|
+
* REEFCLAW_INTELLIGENCE_URL, REEFCLAW_VENUE, OPENCLAW_AGENT_ID,
|
|
94
|
+
* REEFCLAW_ALLOW_CONNECTOR_UPDATE)
|
|
79
95
|
* 3. Config files (~/.openclaw/openclaw.json, ~/.reefclaw/plugin-config.json)
|
|
80
|
-
* 4. Defaults (localhost:18789, the venue's default symbol, intel.reefclaw.com
|
|
96
|
+
* 4. Defaults (localhost:18789, the venue's default symbol, intel.reefclaw.com,
|
|
97
|
+
* the gateway's default agent)
|
|
81
98
|
*
|
|
82
99
|
* Returns null for gatewayToken if it can't be resolved (caller must handle).
|
|
83
100
|
* `intelligenceUrl` is never null — see DEFAULT_INTELLIGENCE_URL for why that
|
|
@@ -127,9 +144,30 @@ export function resolveGatewayConfig(args) {
|
|
|
127
144
|
|| process.env.REEFCLAW_INTELLIGENCE_URL
|
|
128
145
|
|| pluginConfig?.intelligenceUrl
|
|
129
146
|
|| DEFAULT_INTELLIGENCE_URL;
|
|
147
|
+
// --- Agent id ---
|
|
148
|
+
// CLI > env > plugin-config.json agentId > the gateway's DEFAULT agent from
|
|
149
|
+
// openclaw.json (OpenClaw's own rule, see resolveDefaultAgentIdFromConfig)
|
|
150
|
+
// > 'main'. Zero configuration for every installer-built box: the installer
|
|
151
|
+
// puts the skill in the default agent's workspace and the heartbeat cron
|
|
152
|
+
// runs on it. ONE value drives both chat addressing and the agent-event
|
|
153
|
+
// filter (agent-scope.ts), so a multi-agent gateway can never end up
|
|
154
|
+
// chatting to one agent while displaying another's runs.
|
|
155
|
+
const overrideAgentId = normalizeAgentId(args.agentId)
|
|
156
|
+
?? normalizeAgentId(process.env.OPENCLAW_AGENT_ID)
|
|
157
|
+
?? normalizeAgentId(pluginConfig?.agentId);
|
|
158
|
+
const agentId = overrideAgentId ?? resolveDefaultAgentIdFromConfig(ocConfig);
|
|
159
|
+
const agentIdSource = overrideAgentId ? 'configured override' : 'gateway default agent';
|
|
160
|
+
// --- One-click connector update switch ---
|
|
161
|
+
// env > plugin-config.json connectorUpdate > allowed. A per-host opt-out
|
|
162
|
+
// for the dashboard's "Update connector" (which opens a PTY on this box);
|
|
163
|
+
// see providers/connector-update.ts.
|
|
164
|
+
const allowConnectorUpdate = parseOnOffSwitch(process.env.REEFCLAW_ALLOW_CONNECTOR_UPDATE)
|
|
165
|
+
?? parseOnOffSwitch(pluginConfig?.connectorUpdate)
|
|
166
|
+
?? true;
|
|
130
167
|
logger.info(TAG, `Intelligence service: ${intelligenceUrl} (venue=${venue})`);
|
|
168
|
+
logger.info(TAG, `Agent: ${agentId} (${agentIdSource}); one-click connector update: ${allowConnectorUpdate ? 'allowed' : 'DISABLED on this box'}`);
|
|
131
169
|
logger.debug(TAG, `Resolved: url=${gatewayUrl}, token=${gatewayToken ? '***' : '(none)'}, symbol=${symbol}`);
|
|
132
|
-
return { gatewayUrl, gatewayToken, symbol, intelligenceUrl, venue };
|
|
170
|
+
return { gatewayUrl, gatewayToken, symbol, intelligenceUrl, venue, agentId, allowConnectorUpdate };
|
|
133
171
|
}
|
|
134
172
|
/**
|
|
135
173
|
* Validate that the resolved config has all required fields.
|
|
@@ -344,6 +344,9 @@ export class GatewayWsClient {
|
|
|
344
344
|
seq: frame.seq,
|
|
345
345
|
...(typeof p.sessionKey === 'string' ? { sessionKey: p.sessionKey } : {}),
|
|
346
346
|
...(typeof p.sessionId === 'string' ? { sessionId: p.sessionId } : {}),
|
|
347
|
+
// Not sent by today's gateway (the owner rides in sessionKey);
|
|
348
|
+
// forwarded if a future build adds it — see agent-scope.ts.
|
|
349
|
+
...(typeof p.agentId === 'string' ? { agentId: p.agentId } : {}),
|
|
347
350
|
});
|
|
348
351
|
}
|
|
349
352
|
break;
|
package/assets/bridge/index.js
CHANGED
|
@@ -91,6 +91,10 @@ function parseArgs() {
|
|
|
91
91
|
gateway.symbol = next;
|
|
92
92
|
i++;
|
|
93
93
|
break;
|
|
94
|
+
case '--agent-id':
|
|
95
|
+
gateway.agentId = next;
|
|
96
|
+
i++;
|
|
97
|
+
break;
|
|
94
98
|
case '--log-level':
|
|
95
99
|
case '-l':
|
|
96
100
|
if (['debug', 'info', 'warn', 'error'].includes(next)) {
|
|
@@ -118,36 +122,37 @@ function parseArgs() {
|
|
|
118
122
|
return { cli, gateway };
|
|
119
123
|
}
|
|
120
124
|
function printUsage() {
|
|
121
|
-
console.log(`
|
|
122
|
-
ReefClaw Skill — Bridge between OpenClaw and ReefClaw relay
|
|
123
|
-
|
|
124
|
-
Usage:
|
|
125
|
-
tsx src/index.ts [options]
|
|
126
|
-
|
|
127
|
-
Options:
|
|
128
|
-
--provider, -p <mock|gateway> Provider type (default: mock)
|
|
129
|
-
--token, -t <token> Connection token (or REEFCLAW_TOKEN env)
|
|
130
|
-
--user-id, -u <id> User ID (or REEFCLAW_USER_ID env)
|
|
131
|
-
--relay-url, -r <url> Relay URL (default: wss://reefclaw.radunlupsa.partykit.dev)
|
|
132
|
-
--gateway-url <url> OpenClaw gateway URL (or OPENCLAW_GATEWAY_URL env)
|
|
133
|
-
--gateway-token <token> OpenClaw gateway token (or OPENCLAW_GATEWAY_TOKEN env)
|
|
134
|
-
--symbol <symbol> Trading symbol (default: BTC/USDT, or REEFCLAW_SYMBOL env)
|
|
135
|
-
--
|
|
136
|
-
--
|
|
137
|
-
--
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
125
|
+
console.log(`
|
|
126
|
+
ReefClaw Skill — Bridge between OpenClaw and ReefClaw relay
|
|
127
|
+
|
|
128
|
+
Usage:
|
|
129
|
+
tsx src/index.ts [options]
|
|
130
|
+
|
|
131
|
+
Options:
|
|
132
|
+
--provider, -p <mock|gateway> Provider type (default: mock)
|
|
133
|
+
--token, -t <token> Connection token (or REEFCLAW_TOKEN env)
|
|
134
|
+
--user-id, -u <id> User ID (or REEFCLAW_USER_ID env)
|
|
135
|
+
--relay-url, -r <url> Relay URL (default: wss://reefclaw.radunlupsa.partykit.dev)
|
|
136
|
+
--gateway-url <url> OpenClaw gateway URL (or OPENCLAW_GATEWAY_URL env)
|
|
137
|
+
--gateway-token <token> OpenClaw gateway token (or OPENCLAW_GATEWAY_TOKEN env)
|
|
138
|
+
--symbol <symbol> Trading symbol (default: BTC/USDT, or REEFCLAW_SYMBOL env)
|
|
139
|
+
--agent-id <id> OpenClaw agent this bridge serves (default: main, or OPENCLAW_AGENT_ID env)
|
|
140
|
+
--log-level, -l <level> Log level: debug, info, warn, error (default: info)
|
|
141
|
+
--setup, -s [token] Run setup flow (validate token, test connection, save to config)
|
|
142
|
+
--help, -h Show this help
|
|
143
|
+
|
|
144
|
+
Relay config resolution (highest priority first):
|
|
145
|
+
1. CLI arguments (--token, --user-id, --relay-url)
|
|
146
|
+
2. Environment variables (REEFCLAW_TOKEN, REEFCLAW_USER_ID, REEFCLAW_RELAY_URL)
|
|
147
|
+
3. OpenClaw config file (~/.openclaw/openclaw.json → skills.entries.reefclaw)
|
|
148
|
+
4. Fallback env vars (MOCK_TOKEN, USER_ID)
|
|
149
|
+
5. Defaults (relay URL → wss://reefclaw.radunlupsa.partykit.dev)
|
|
150
|
+
|
|
151
|
+
Gateway config resolution (for --provider gateway):
|
|
152
|
+
1. CLI arguments (--gateway-url, --gateway-token, --symbol, --agent-id)
|
|
153
|
+
2. Environment variables (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL, OPENCLAW_AGENT_ID)
|
|
154
|
+
3. OpenClaw config file (~/.openclaw/openclaw.json → gateway.auth.token, gateway.port)
|
|
155
|
+
4. Defaults (http://localhost:18789, BTC/USDT)
|
|
151
156
|
`);
|
|
152
157
|
}
|
|
153
158
|
// ---- Main ----
|
|
@@ -45,6 +45,9 @@ export interface ConnectorUpdateOutcome {
|
|
|
45
45
|
* - agent runs in a sandbox → in-sandbox terminals are unsupported
|
|
46
46
|
*/
|
|
47
47
|
export declare function describeOpenFailure(err: unknown): ConnectorUpdateOutcome;
|
|
48
|
+
/** The operator switched one-click updates off on this box. Refused before
|
|
49
|
+
* any gateway call is made; the copy carries the manual path. */
|
|
50
|
+
export declare function describeUpdateDisabled(): ConnectorUpdateOutcome;
|
|
48
51
|
/**
|
|
49
52
|
* Once the command is running, losing the transport is EXPECTED — the installer
|
|
50
53
|
* restarts the gateway, which is the process hosting both the PTY and our own
|
|
@@ -33,6 +33,22 @@
|
|
|
33
33
|
// `classifyTransportLoss`.
|
|
34
34
|
// - The PTY runs as the gateway's own user (`openclaw`), which is exactly the
|
|
35
35
|
// user the installer must run as. Correct by construction.
|
|
36
|
+
//
|
|
37
|
+
// Hardening after the 2026-09-16 self-hoster audit (a relay message that ends
|
|
38
|
+
// in a PTY on the operator's machine is a capability worth fencing even with
|
|
39
|
+
// a constant command):
|
|
40
|
+
// - Per-host opt-out: REEFCLAW_ALLOW_CONNECTOR_UPDATE=0 (or plugin-config.json
|
|
41
|
+
// `"connectorUpdate": "off"`) makes the provider refuse before any gateway
|
|
42
|
+
// call (`describeUpdateDisabled`). The gateway's own switch
|
|
43
|
+
// `gateway.terminal.enabled=false` is honoured too (`describeOpenFailure`).
|
|
44
|
+
// - terminal.* needs operator.admin: the update opens a separate short-lived
|
|
45
|
+
// admin session and checks the granted scopes before `terminal.open`.
|
|
46
|
+
// - Residual trust: `@latest` resolves on the npm registry, so the trust root
|
|
47
|
+
// is the registry plus the publisher account (2FA), exactly as for a manual
|
|
48
|
+
// `npx`. Pinning an exact, offline-signed version is the planned follow-up
|
|
49
|
+
// (the SKILL.md OTA already carries the Ed25519 key for it).
|
|
50
|
+
// - The updater overwrites local modifications to the placed bridge/plugin,
|
|
51
|
+
// as any reinstall would. Self-hosters carrying patches: switch it off.
|
|
36
52
|
import { logger } from '../logger.js';
|
|
37
53
|
const TAG = 'connector-update';
|
|
38
54
|
/**
|
|
@@ -105,6 +121,16 @@ export function describeOpenFailure(err) {
|
|
|
105
121
|
message: `Could not open a terminal on the box: ${raw}`,
|
|
106
122
|
};
|
|
107
123
|
}
|
|
124
|
+
/** The operator switched one-click updates off on this box. Refused before
|
|
125
|
+
* any gateway call is made; the copy carries the manual path. */
|
|
126
|
+
export function describeUpdateDisabled() {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
status: 'blocked',
|
|
130
|
+
message: 'One-click updates are switched off on this box (REEFCLAW_ALLOW_CONNECTOR_UPDATE=0). '
|
|
131
|
+
+ `Update from a shell on the box instead: ${CONNECTOR_UPDATE_COMMAND}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
108
134
|
/**
|
|
109
135
|
* Once the command is running, losing the transport is EXPECTED — the installer
|
|
110
136
|
* restarts the gateway, which is the process hosting both the PTY and our own
|
|
@@ -40,6 +40,11 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
40
40
|
* Surfaced in agent_state so the dashboard header can show the real agent +
|
|
41
41
|
* model instead of a hardcoded placeholder. Empty until the gateway answers. */
|
|
42
42
|
private agentIdentity;
|
|
43
|
+
/** Foreign agent ids whose runs we have already logged as ignored (once each). */
|
|
44
|
+
private readonly ignoredForeignAgents;
|
|
45
|
+
/** The OpenClaw agent this bridge serves: chat goes to it, and only its runs
|
|
46
|
+
* reach the dashboard (gateway/agent-scope.ts). */
|
|
47
|
+
private agentId;
|
|
43
48
|
/** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
|
|
44
49
|
private loggedAgentIdentityProbe;
|
|
45
50
|
/** Last IDENTITY.md read (epoch ms) — TTL gate for the display-name re-read. */
|
|
@@ -19,7 +19,8 @@ import { parseIdentityName } from '../utils/identity-name.js';
|
|
|
19
19
|
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';
|
|
20
20
|
import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, cancelPendingProposals, withProposalDetail, } from './emergency-commands.js';
|
|
21
21
|
import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, executeProvisionHlAgentWallet, executeHlAgentWalletStatus, executeSubmitHlAgentApproval, } from './onboarding-commands.js';
|
|
22
|
-
import { startConnectorUpdate, runConnectorUpdate, openAdminGatewaySession, checkPositionGuard, } from './connector-update.js';
|
|
22
|
+
import { startConnectorUpdate, runConnectorUpdate, openAdminGatewaySession, checkPositionGuard, describeUpdateDisabled, } from './connector-update.js';
|
|
23
|
+
import { DEFAULT_AGENT_ID, isForeignAgentEvent, resolveAgentEventOwner } from '../gateway/agent-scope.js';
|
|
23
24
|
const TAG = 'gateway';
|
|
24
25
|
// ---- Day-start NAV persistence ----
|
|
25
26
|
// Persists sessionStartNav (the UTC-day P&L anchor) per date so Day P&L
|
|
@@ -159,6 +160,13 @@ export class GatewayProvider {
|
|
|
159
160
|
* Surfaced in agent_state so the dashboard header can show the real agent +
|
|
160
161
|
* model instead of a hardcoded placeholder. Empty until the gateway answers. */
|
|
161
162
|
agentIdentity = {};
|
|
163
|
+
/** Foreign agent ids whose runs we have already logged as ignored (once each). */
|
|
164
|
+
ignoredForeignAgents = new Set();
|
|
165
|
+
/** The OpenClaw agent this bridge serves: chat goes to it, and only its runs
|
|
166
|
+
* reach the dashboard (gateway/agent-scope.ts). */
|
|
167
|
+
agentId() {
|
|
168
|
+
return this.config.agentId ?? DEFAULT_AGENT_ID;
|
|
169
|
+
}
|
|
162
170
|
/** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
|
|
163
171
|
loggedAgentIdentityProbe = false;
|
|
164
172
|
/** Last IDENTITY.md read (epoch ms) — TTL gate for the display-name re-read. */
|
|
@@ -669,7 +677,7 @@ export class GatewayProvider {
|
|
|
669
677
|
}
|
|
670
678
|
try {
|
|
671
679
|
const idempotencyKey = `chat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
672
|
-
await ws.sendRpc('agent', { message: content, idempotencyKey, agentId:
|
|
680
|
+
await ws.sendRpc('agent', { message: content, idempotencyKey, agentId: this.agentId() });
|
|
673
681
|
return { received: true };
|
|
674
682
|
}
|
|
675
683
|
catch (err) {
|
|
@@ -749,6 +757,13 @@ export class GatewayProvider {
|
|
|
749
757
|
* `restarting` and the dashboard waits for the reconnect.
|
|
750
758
|
*/
|
|
751
759
|
async updateConnector(args) {
|
|
760
|
+
// Per-host opt-out FIRST: a box whose operator switched this off must
|
|
761
|
+
// never open a PTY, connected or not (REEFCLAW_ALLOW_CONNECTOR_UPDATE=0 /
|
|
762
|
+
// plugin-config.json connectorUpdate:'off').
|
|
763
|
+
if (this.config.allowConnectorUpdate === false) {
|
|
764
|
+
logger.info(TAG, 'connector.update refused: one-click updates are switched off on this box');
|
|
765
|
+
return describeUpdateDisabled();
|
|
766
|
+
}
|
|
752
767
|
if (!this.wsClient) {
|
|
753
768
|
return { ok: false, status: 'failed', message: 'Not connected to the OpenClaw gateway.' };
|
|
754
769
|
}
|
|
@@ -1366,6 +1381,19 @@ export class GatewayProvider {
|
|
|
1366
1381
|
onAgentEvent(payload) {
|
|
1367
1382
|
if (!this.started || !this.eventParser)
|
|
1368
1383
|
return;
|
|
1384
|
+
// Only OUR agent's runs belong in the dashboard. The gateway broadcasts
|
|
1385
|
+
// every agent's events to every operator connection, so on a shared
|
|
1386
|
+
// gateway a neighbour's cron briefing used to stream into the chat and
|
|
1387
|
+
// across the relay (2026-09-16 audit, item 1). See gateway/agent-scope.ts.
|
|
1388
|
+
if (isForeignAgentEvent(payload, this.agentId())) {
|
|
1389
|
+
const owner = resolveAgentEventOwner(payload) ?? '?';
|
|
1390
|
+
if (!this.ignoredForeignAgents.has(owner)) {
|
|
1391
|
+
this.ignoredForeignAgents.add(owner);
|
|
1392
|
+
logger.warn(TAG, `Ignoring run events from agent '${owner}' — this bridge serves '${this.agentId()}' ` +
|
|
1393
|
+
'(the gateway default agent unless overridden; set OPENCLAW_AGENT_ID / --agent-id if that is the wrong agent)');
|
|
1394
|
+
}
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1369
1397
|
// Turn lifecycle WITH the session key — the heartbeat flight recorder
|
|
1370
1398
|
// keys off `agent:main:cron:…` to scan the beat's transcript right after
|
|
1371
1399
|
// it ends. Fired before parsing so it never depends on parser state.
|
|
@@ -2345,7 +2373,8 @@ export class GatewayProvider {
|
|
|
2345
2373
|
(Array.isArray(root.data) && root.data) ||
|
|
2346
2374
|
[root];
|
|
2347
2375
|
const rows = rowsRaw.filter((r) => !!r && typeof r === 'object');
|
|
2348
|
-
const
|
|
2376
|
+
const own = this.agentId();
|
|
2377
|
+
const row = rows.find((r) => r.agentId === own) ?? rows[0];
|
|
2349
2378
|
const runtime = row ? (asObj(row.agentRuntime) ?? asObj(row.runtime)) : undefined;
|
|
2350
2379
|
const defaults = asObj(root.defaults);
|
|
2351
2380
|
return flatModel(row?.model) ?? flatModel(runtime?.model) ?? flatModel(defaults?.model) ?? flatModel(root.model);
|
|
@@ -3104,7 +3133,7 @@ export class GatewayProvider {
|
|
|
3104
3133
|
].join('\n');
|
|
3105
3134
|
try {
|
|
3106
3135
|
const idempotencyKey = `mission-${mission.missionId}-${Date.now()}`;
|
|
3107
|
-
await ws.sendRpc('agent', { message, idempotencyKey, agentId:
|
|
3136
|
+
await ws.sendRpc('agent', { message, idempotencyKey, agentId: this.agentId() });
|
|
3108
3137
|
logger.info(TAG, `Presented mission ${mission.missionId} to agent ($${absoluteSize.toFixed(2)}, ${quantity.toFixed(6)} units)`);
|
|
3109
3138
|
}
|
|
3110
3139
|
catch (err) {
|
|
@@ -48,8 +48,13 @@ export interface IExchangeAdapter {
|
|
|
48
48
|
* @param closeReason Tags the resulting Trade record with who initiated the close.
|
|
49
49
|
* Paper mode persists it on the closing Trade.metadata.closeReason so the agent
|
|
50
50
|
* can see on its next heartbeat that e.g. a stop_watcher auto-closed the trade.
|
|
51
|
-
* Live mode currently ignores this (metadata not tracked in live path).
|
|
52
|
-
|
|
51
|
+
* Live mode currently ignores this (metadata not tracked in live path).
|
|
52
|
+
* @param referencePrice Paper-only: the price the closing DECISION was made
|
|
53
|
+
* on (a stop's breach mark, a target level). The simulator anchors the
|
|
54
|
+
* fill to it instead of whatever tick/book it has cached: a stop that
|
|
55
|
+
* filled off an entry-time cache booked a -1.04R loss as -0.02R
|
|
56
|
+
* (2026-09-16 audit). Live venues fill at market and ignore it. */
|
|
57
|
+
closePosition(symbol: string, closeReason?: CloseReason, referencePrice?: number): Promise<CcxtOrder>;
|
|
53
58
|
getBalance(): Promise<CcxtBalance>;
|
|
54
59
|
getPositions(symbol?: string): Promise<CcxtPosition[]>;
|
|
55
60
|
/**
|
|
@@ -355,6 +355,12 @@ export function startReadinessReporter(opts) {
|
|
|
355
355
|
publicApi: opts.publicApi,
|
|
356
356
|
toolCount: opts.toolCount,
|
|
357
357
|
resolveStopProtection: opts.resolveStopProtection,
|
|
358
|
+
// The dashboard's update banner reads these off EVERY post (boot and
|
|
359
|
+
// periodic). 0.1.28-0.1.29 received them here and dropped them, so
|
|
360
|
+
// every updated box posted agent:{} without a version and the banner
|
|
361
|
+
// went silent for it (found 2026-09-16 on the rig).
|
|
362
|
+
pluginVersion: opts.pluginVersion,
|
|
363
|
+
installChannel: opts.installChannel,
|
|
358
364
|
}, bootWarmup, { state });
|
|
359
365
|
await postReadiness(opts.apiBaseUrl, opts.token, report, fetchImpl, timeoutMs);
|
|
360
366
|
if (report.overall === 'fail') {
|
|
@@ -212,7 +212,13 @@ export class PositionWatcher extends EventEmitter {
|
|
|
212
212
|
throw new Error(`Wave 9 ownership resolution failed before stop close submission: ${ownershipError}; ` +
|
|
213
213
|
'IMMEDIATE MANUAL INTERVENTION REQUIRED');
|
|
214
214
|
}
|
|
215
|
-
|
|
215
|
+
// Fill at the mark the breach decision was made on, not at whatever the
|
|
216
|
+
// paper engine has cached (the take-profit leg already passes its level
|
|
217
|
+
// the same way). Live adapters fill at market and ignore the reference.
|
|
218
|
+
const stopFillPrice = Number.isFinite(trustedPosition.markPrice) && trustedPosition.markPrice > 0
|
|
219
|
+
? trustedPosition.markPrice
|
|
220
|
+
: undefined;
|
|
221
|
+
closeOrder = await this.adapter.closePosition(trustedPosition.symbol, 'stop_watcher', stopFillPrice);
|
|
216
222
|
if (!candidateId || !this.wave9CloseLifecycle)
|
|
217
223
|
return 'closed';
|
|
218
224
|
const outcome = await this.wave9CloseLifecycle.settleAfterClose(candidateId, trustedPosition.symbol);
|
|
@@ -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.30",
|
|
5
5
|
"description": "Supervised trading plugin for the ReefClaw dashboard. It runs on YOUR machine and starts in PAPER mode with no API keys. It cannot trade real funds until you supply exchange credentials and step PAPER→MICRO_LIVE→LIVE yourself from the dashboard — the agent cannot make that change (the tool is refused without operator provenance). Exchange keys stay local, are used only to sign requests to the exchange, and are never transmitted to ReefClaw (asserted by a test in this package). Trading telemetry — positions, fills, decision journal — is sent to ReefClaw to render the dashboard. Every live position carries exchange-native protective stops. Remote updates to the agent's trading instructions are applied only after an Ed25519 signature is verified against a public key pinned in this build.",
|
|
6
6
|
"author": "ReefClaw",
|
|
7
7
|
"activation": {
|
|
@@ -13,7 +13,7 @@ export declare class PaperAdapter implements IExchangeAdapter {
|
|
|
13
13
|
createOrder(symbol: string, side: 'buy' | 'sell', type: 'market' | 'limit', amount: number, price?: number, metadata?: PositionMetadata, _options?: OrderOptions): Promise<CcxtOrder>;
|
|
14
14
|
cancelOrder(orderId: string, _symbol?: string): Promise<CcxtOrder>;
|
|
15
15
|
cancelAllOrders(symbol?: string): Promise<CcxtOrder[]>;
|
|
16
|
-
closePosition(symbol: string, closeReason?: CloseReason): Promise<CcxtOrder>;
|
|
16
|
+
closePosition(symbol: string, closeReason?: CloseReason, referencePrice?: number): Promise<CcxtOrder>;
|
|
17
17
|
getBalance(): Promise<CcxtBalance>;
|
|
18
18
|
getPositions(symbol?: string): Promise<CcxtPosition[]>;
|
|
19
19
|
/** Paper has no exchange fetch to fail — position state is always known. */
|
|
@@ -22,8 +22,8 @@ export class PaperAdapter {
|
|
|
22
22
|
async cancelAllOrders(symbol) {
|
|
23
23
|
return this.simulator.cancelAllOrders(symbol);
|
|
24
24
|
}
|
|
25
|
-
async closePosition(symbol, closeReason) {
|
|
26
|
-
return this.simulator.closePosition(symbol, closeReason);
|
|
25
|
+
async closePosition(symbol, closeReason, referencePrice) {
|
|
26
|
+
return this.simulator.closePosition(symbol, closeReason, referencePrice);
|
|
27
27
|
}
|
|
28
28
|
// Reads pull a newer state.json first (ExchangeSimulator.refreshState —
|
|
29
29
|
// no-op without a hook). Out-of-tool readers (DB-vs-exchange sweep,
|
|
@@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
9
9
|
import { logger, formatError } from '../logger.js';
|
|
10
10
|
import { MAX_TRADE_HISTORY, DEFAULT_SIMULATION_CONFIG } from './types.js';
|
|
11
11
|
import { fillMarketOrder, fillLimitOrder, parseSymbol } from './fill-engine.js';
|
|
12
|
+
import { MAX_BOOK_DRIFT_BPS } from './realistic-fills.js';
|
|
12
13
|
import { updateMfe } from '../mfe.js';
|
|
13
14
|
import { computeInvalidationHit } from '../pinned-plan.js';
|
|
14
15
|
const TAG = 'simulator';
|
|
@@ -805,7 +806,10 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
805
806
|
logger.info(TAG, `Market order filled: ${order.side} ${order.amount} ${order.symbol} @ ${result.order.average}` +
|
|
806
807
|
` (decision: ${eq.decisionPrice.toFixed(2)}, slippage: ${eq.slippageBps.toFixed(2)}bps` +
|
|
807
808
|
`, latency: ${eq.latencyMs.toFixed(0)}ms, fee: ${eq.feeRate * 100}%` +
|
|
808
|
-
`, book: ${eq.bookDepthAvailable ? `${eq.bookLevelsConsumed} levels` : 'unavailable'}
|
|
809
|
+
`, book: ${eq.bookDepthAvailable ? `${eq.bookLevelsConsumed} levels` : 'unavailable'}` +
|
|
810
|
+
`${eq.bookDriftBps !== undefined && Math.abs(eq.bookDriftBps) > MAX_BOOK_DRIFT_BPS
|
|
811
|
+
? `, stale book ${eq.bookDriftBps.toFixed(0)}bps off the decision price: fill anchored`
|
|
812
|
+
: ''})`);
|
|
809
813
|
}
|
|
810
814
|
else {
|
|
811
815
|
logger.info(TAG, `Market order filled: ${order.side} ${order.amount} ${order.symbol} @ ${result.order.average}`);
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import type { OrderBookDepth, SimulationConfig, ExecutionQuality } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* How far (bps) the cached book's mid may sit from the decision price before
|
|
4
|
+
* the fill is anchored to the decision price instead of the raw book VWAP.
|
|
5
|
+
*
|
|
6
|
+
* The book is refreshed only when a tool fetches it (tools/helpers.ts
|
|
7
|
+
* fetchOrderBook, typically at entry), so a stop or target hours later used to
|
|
8
|
+
* fill against an hours-old book: an ADA/USDT short stop with a 0.2123 breach
|
|
9
|
+
* mark filled at 0.207103, 245 bps in the trader's favour, booking a -1.04R
|
|
10
|
+
* loss as -0.02R (2026-09-16 audit). Inside the band the book is fresh enough
|
|
11
|
+
* to price the fill directly (unchanged behaviour); outside it the book only
|
|
12
|
+
* shapes the impact (VWAP vs mid), applied to the price the decision was
|
|
13
|
+
* actually made on.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MAX_BOOK_DRIFT_BPS = 10;
|
|
2
16
|
/**
|
|
3
17
|
* Walk the order book to compute a volume-weighted average fill price.
|
|
4
18
|
*
|
|
@@ -45,6 +59,8 @@ export declare function getFeeRate(orderType: 'market' | 'limit', config: Simula
|
|
|
45
59
|
*
|
|
46
60
|
* Combines: orderbook VWAP + latency drift + appropriate fee rate.
|
|
47
61
|
* Falls back to simple random slippage if no orderbook is available.
|
|
62
|
+
* A book whose mid has drifted more than MAX_BOOK_DRIFT_BPS from the decision
|
|
63
|
+
* price is stale: its impact is kept, the level is re-anchored.
|
|
48
64
|
*
|
|
49
65
|
* @returns fillPrice and ExecutionQuality metrics
|
|
50
66
|
*/
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
// Phase 9a: Realistic fill simulation.
|
|
2
2
|
// Order book-aware VWAP fills, latency modeling, maker/taker fees.
|
|
3
3
|
import { DEFAULT_SIMULATION_CONFIG, priceToBps } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* How far (bps) the cached book's mid may sit from the decision price before
|
|
6
|
+
* the fill is anchored to the decision price instead of the raw book VWAP.
|
|
7
|
+
*
|
|
8
|
+
* The book is refreshed only when a tool fetches it (tools/helpers.ts
|
|
9
|
+
* fetchOrderBook, typically at entry), so a stop or target hours later used to
|
|
10
|
+
* fill against an hours-old book: an ADA/USDT short stop with a 0.2123 breach
|
|
11
|
+
* mark filled at 0.207103, 245 bps in the trader's favour, booking a -1.04R
|
|
12
|
+
* loss as -0.02R (2026-09-16 audit). Inside the band the book is fresh enough
|
|
13
|
+
* to price the fill directly (unchanged behaviour); outside it the book only
|
|
14
|
+
* shapes the impact (VWAP vs mid), applied to the price the decision was
|
|
15
|
+
* actually made on.
|
|
16
|
+
*/
|
|
17
|
+
export const MAX_BOOK_DRIFT_BPS = 10;
|
|
4
18
|
/**
|
|
5
19
|
* Walk the order book to compute a volume-weighted average fill price.
|
|
6
20
|
*
|
|
@@ -92,6 +106,8 @@ export function getFeeRate(orderType, config) {
|
|
|
92
106
|
*
|
|
93
107
|
* Combines: orderbook VWAP + latency drift + appropriate fee rate.
|
|
94
108
|
* Falls back to simple random slippage if no orderbook is available.
|
|
109
|
+
* A book whose mid has drifted more than MAX_BOOK_DRIFT_BPS from the decision
|
|
110
|
+
* price is stale: its impact is kept, the level is re-anchored.
|
|
95
111
|
*
|
|
96
112
|
* @returns fillPrice and ExecutionQuality metrics
|
|
97
113
|
*/
|
|
@@ -103,6 +119,7 @@ export function computeRealisticMarketFill(side, amount, decisionPrice, orderboo
|
|
|
103
119
|
let latencyImpactBps;
|
|
104
120
|
let bookLevelsConsumed;
|
|
105
121
|
let bookDepthAvailable;
|
|
122
|
+
let bookDriftBps;
|
|
106
123
|
if (orderbook && orderbook.asks.length > 0 && orderbook.bids.length > 0) {
|
|
107
124
|
// Book-aware VWAP fill
|
|
108
125
|
const { vwap, levelsConsumed } = computeBookAwareFillPrice(side, amount, orderbook);
|
|
@@ -113,8 +130,14 @@ export function computeRealisticMarketFill(side, amount, decisionPrice, orderboo
|
|
|
113
130
|
marketImpactBps = priceToBps(vwap, midPrice);
|
|
114
131
|
// For sells, impact is negative (received less), so take absolute for the metric
|
|
115
132
|
// but keep signed for the actual price
|
|
116
|
-
//
|
|
117
|
-
|
|
133
|
+
// A book that has drifted from the decision price is stale: keep its
|
|
134
|
+
// impact, re-anchor the level (see MAX_BOOK_DRIFT_BPS).
|
|
135
|
+
bookDriftBps = priceToBps(midPrice, decisionPrice);
|
|
136
|
+
const level = Math.abs(bookDriftBps) > MAX_BOOK_DRIFT_BPS
|
|
137
|
+
? decisionPrice * (1 + marketImpactBps / 10_000)
|
|
138
|
+
: vwap;
|
|
139
|
+
// Apply latency drift on top of the (possibly re-anchored) VWAP
|
|
140
|
+
const latencyResult = applyLatencyDrift(level, side, latencyMs, volFactor);
|
|
118
141
|
fillPrice = latencyResult.adjustedPrice;
|
|
119
142
|
latencyImpactBps = latencyResult.latencyImpactBps;
|
|
120
143
|
}
|
|
@@ -147,6 +170,7 @@ export function computeRealisticMarketFill(side, amount, decisionPrice, orderboo
|
|
|
147
170
|
feePaid,
|
|
148
171
|
bookLevelsConsumed,
|
|
149
172
|
bookDepthAvailable,
|
|
173
|
+
...(bookDriftBps !== undefined ? { bookDriftBps } : {}),
|
|
150
174
|
};
|
|
151
175
|
return { fillPrice, executionQuality };
|
|
152
176
|
}
|
|
@@ -39,6 +39,10 @@ export interface ExecutionQuality {
|
|
|
39
39
|
feePaid: number;
|
|
40
40
|
bookLevelsConsumed: number;
|
|
41
41
|
bookDepthAvailable: boolean;
|
|
42
|
+
/** Signed bps between the cached book's mid and the decision price at fill
|
|
43
|
+
* time. Beyond MAX_BOOK_DRIFT_BPS the fill was anchored to the decision
|
|
44
|
+
* price (stale book). Absent when no book was available. */
|
|
45
|
+
bookDriftBps?: number;
|
|
42
46
|
/** Age of the quote the fill priced against (fill time − ticker.timestamp).
|
|
43
47
|
* Surfaces feed staleness (issue #202); absent on records from before the
|
|
44
48
|
* field existed or when the ticker carried no usable timestamp. */
|
package/dist/agents.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Which OpenClaw agent ReefClaw attaches to, and a loud notice when the
|
|
2
|
+
// gateway hosts more than one.
|
|
3
|
+
//
|
|
4
|
+
// The installer targets the gateway's DEFAULT agent: `openclaw skills install`
|
|
5
|
+
// puts the trading skill in its workspace, the bridge's heartbeat cron runs on
|
|
6
|
+
// it, and the bridge addresses chat to it and shows only its runs
|
|
7
|
+
// (skill/src/gateway/agent-scope.ts applies the same rule). On a single-agent
|
|
8
|
+
// box that is simply `main`. On a gateway that already runs, say, a business
|
|
9
|
+
// agent as its default, a fresh "trading" agent added alongside gets NOTHING
|
|
10
|
+
// and the business agent starts trading — silently, until 2026-09-16. This
|
|
11
|
+
// module makes that choice visible at install time. A full "pick the agent"
|
|
12
|
+
// flow (skill placed with --agent, heartbeat cron with agentId, per-agent
|
|
13
|
+
// paths in the bridge) is a later feature.
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { REEFCLAW_HOME } from './paths.js';
|
|
17
|
+
export const DEFAULT_AGENT_ID = 'main';
|
|
18
|
+
function normalizeAgentId(value) {
|
|
19
|
+
if (typeof value !== 'string')
|
|
20
|
+
return undefined;
|
|
21
|
+
const trimmed = value.trim().toLowerCase();
|
|
22
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
23
|
+
}
|
|
24
|
+
function agentEntries(cfg) {
|
|
25
|
+
const list = cfg?.agents?.list;
|
|
26
|
+
if (!Array.isArray(list))
|
|
27
|
+
return [];
|
|
28
|
+
return list.filter((e) => !!e && typeof e === 'object' && !Array.isArray(e));
|
|
29
|
+
}
|
|
30
|
+
/** Every agent id the gateway lists; empty on a single-agent box. */
|
|
31
|
+
export function listAgentIds(cfg) {
|
|
32
|
+
const ids = [];
|
|
33
|
+
for (const entry of agentEntries(cfg)) {
|
|
34
|
+
const id = normalizeAgentId(entry.id);
|
|
35
|
+
if (id && !ids.includes(id))
|
|
36
|
+
ids.push(id);
|
|
37
|
+
}
|
|
38
|
+
return ids;
|
|
39
|
+
}
|
|
40
|
+
/** OpenClaw's own rule (agent-scope-config.ts `resolveDefaultAgentId`): the
|
|
41
|
+
* `agents.list` entry flagged `default: true`, else the first, else `main`. */
|
|
42
|
+
export function resolveDefaultAgentId(cfg) {
|
|
43
|
+
const entries = agentEntries(cfg);
|
|
44
|
+
if (entries.length === 0)
|
|
45
|
+
return DEFAULT_AGENT_ID;
|
|
46
|
+
const chosen = entries.find((e) => e.default === true) ?? entries[0];
|
|
47
|
+
return normalizeAgentId(chosen.id) ?? DEFAULT_AGENT_ID;
|
|
48
|
+
}
|
|
49
|
+
/** Best-effort read of ~/.reefclaw/plugin-config.json (the bridge reads the
|
|
50
|
+
* same file for its `agentId` override). Null when absent or unreadable. */
|
|
51
|
+
export function readPluginConfig(path = join(REEFCLAW_HOME, 'plugin-config.json')) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
54
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function describeAgentAttachment(cfg, pluginConfig) {
|
|
61
|
+
const defaultAgentId = resolveDefaultAgentId(cfg);
|
|
62
|
+
const override = normalizeAgentId(pluginConfig?.agentId);
|
|
63
|
+
const agentId = override ?? defaultAgentId;
|
|
64
|
+
const hosted = listAgentIds(cfg);
|
|
65
|
+
return {
|
|
66
|
+
agentId,
|
|
67
|
+
source: override ? 'override' : 'default',
|
|
68
|
+
defaultAgentId,
|
|
69
|
+
hosted,
|
|
70
|
+
shared: hosted.some((id) => id !== agentId),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** The lines the installer prints. Pure, so the copy is unit-tested. */
|
|
74
|
+
export function agentAttachmentNotice(a) {
|
|
75
|
+
if (!a.shared) {
|
|
76
|
+
return { level: 'ok', lines: [`ReefClaw attaches to agent '${a.agentId}'`] };
|
|
77
|
+
}
|
|
78
|
+
const others = a.hosted.filter((id) => id !== a.agentId).join(', ');
|
|
79
|
+
const lines = [
|
|
80
|
+
`this gateway also hosts: ${others}. ReefClaw attaches to '${a.agentId}' `
|
|
81
|
+
+ (a.source === 'override'
|
|
82
|
+
? '(the agentId set in ~/.reefclaw/plugin-config.json).'
|
|
83
|
+
: '(the gateway default agent).'),
|
|
84
|
+
`The trading skill goes into that agent's workspace, the heartbeat runs on it, and the dashboard shows only its runs.`,
|
|
85
|
+
`To trade on a different agent, flag it "default": true in agents.list of openclaw.json and re-run this installer, `
|
|
86
|
+
+ `or give the trading agent its own OpenClaw instance (cleanest).`,
|
|
87
|
+
];
|
|
88
|
+
if (a.source === 'override' && a.agentId !== a.defaultAgentId) {
|
|
89
|
+
lines.push(`Note: the override only steers the bridge on this version; the skill and heartbeat still land on the default agent '${a.defaultAgentId}'.`);
|
|
90
|
+
}
|
|
91
|
+
return { level: 'warn', lines };
|
|
92
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { installPlugin } from './plugin.js';
|
|
|
14
14
|
import { installBridge } from './bridge.js';
|
|
15
15
|
import { installSkill } from './skill.js';
|
|
16
16
|
import { enableConnectorSupervisor } from './supervisor-config.js';
|
|
17
|
+
import { describeAgentAttachment, readPluginConfig, agentAttachmentNotice } from './agents.js';
|
|
17
18
|
import { tuneGatewayMemory } from './gateway-tuning.js';
|
|
18
19
|
import { checkGateway, checkBinanceRegion, checkHyperliquidRegion } from './validate.js';
|
|
19
20
|
import { readConfig, writeConfig, mergeReefClawConfig, gatewayAuthAlreadyRelaxed, openClawInstalled, openClawConfigPath, readGatewayPort, } from './openclaw.js';
|
|
@@ -149,6 +150,20 @@ function parseVenueArg(argv) {
|
|
|
149
150
|
}
|
|
150
151
|
return null;
|
|
151
152
|
}
|
|
153
|
+
/** Say which agent ReefClaw is about to attach to. On a gateway that hosts
|
|
154
|
+
* several agents this is the difference between the trading agent trading
|
|
155
|
+
* and the business agent trading (see agents.ts). */
|
|
156
|
+
function noticeAgentAttachment(config) {
|
|
157
|
+
step('Choosing the agent ReefClaw attaches to');
|
|
158
|
+
const notice = agentAttachmentNotice(describeAgentAttachment(config, readPluginConfig()));
|
|
159
|
+
if (notice.level === 'ok') {
|
|
160
|
+
ok(notice.lines[0]);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
warn(notice.lines[0]);
|
|
164
|
+
for (const line of notice.lines.slice(1))
|
|
165
|
+
info(line);
|
|
166
|
+
}
|
|
152
167
|
async function main() {
|
|
153
168
|
banner(bold(cyan('ReefClaw connect')) + dim(' — link your OpenClaw agent to ReefClaw (paper trading)'));
|
|
154
169
|
preflight();
|
|
@@ -165,6 +180,7 @@ async function main() {
|
|
|
165
180
|
binanceReachable = await checkBinanceRegion();
|
|
166
181
|
}
|
|
167
182
|
const pre = readConfig();
|
|
183
|
+
noticeAgentAttachment(pre);
|
|
168
184
|
const plugin = installPlugin();
|
|
169
185
|
const merged = wireConfig(pre);
|
|
170
186
|
// After wireConfig: the config is schema-valid (flat legacy keys migrated),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reefclaw/connect",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.42",
|
|
4
4
|
"description": "One-command installer that connects your OpenClaw agent to ReefClaw (paper trading, no exchange keys).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,5 +29,8 @@
|
|
|
29
29
|
"vitest": "^4.0.18"
|
|
30
30
|
},
|
|
31
31
|
"license": "MIT",
|
|
32
|
-
"homepage": "https://reefclaw.com"
|
|
32
|
+
"homepage": "https://reefclaw.com",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"email": "support@reefclaw.com"
|
|
35
|
+
}
|
|
33
36
|
}
|