@reefclaw/openclaw-plugin 0.1.10 → 0.1.12
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/gateway/event-parser.d.ts +23 -0
- package/bridge/gateway/event-parser.js +36 -0
- package/bridge/gateway/tool-discovery.d.ts +21 -2
- package/bridge/gateway/tool-discovery.js +109 -43
- package/bridge/providers/gateway.d.ts +11 -0
- package/bridge/providers/gateway.js +49 -18
- package/bridge/types.d.ts +4 -0
- package/index.js +10 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/venues/hyperliquid/hl-live-adapter.d.ts +8 -0
- package/venues/hyperliquid/hl-live-adapter.js +26 -1
- package/venues/symbols.d.ts +12 -0
- package/venues/symbols.js +20 -0
|
@@ -155,6 +155,29 @@ export declare function extractBracketField(pos: {
|
|
|
155
155
|
tpPrice?: number;
|
|
156
156
|
state: string;
|
|
157
157
|
} | undefined;
|
|
158
|
+
/**
|
|
159
|
+
* Narrow the agent's PLANNED protective levels off a plugin-decorated position.
|
|
160
|
+
*
|
|
161
|
+
* `bracket` above is exchange truth and only exists where a venue-side ledger
|
|
162
|
+
* row does. In PAPER (either venue) there are no exchange legs — the levels
|
|
163
|
+
* live in the position's metadata and the stop-watcher enforces them — so
|
|
164
|
+
* without these fields the dashboard had NO target at all and drew the
|
|
165
|
+
* frozen-at-fill `originalStopPrice` as the stop even after a modify_stop.
|
|
166
|
+
* Both adapters already emit `stopPrice` / `targetPrice` on the CCXT position
|
|
167
|
+
* (ExchangeSimulator.getPositions, LiveAdapter.decoratePositionsWithMetadata);
|
|
168
|
+
* we only re-validate the shape here because CcxtPosition's index signature
|
|
169
|
+
* is `unknown`.
|
|
170
|
+
*
|
|
171
|
+
* ★ `plannedStopPrice` is the CURRENT stop (modify_stop moves it), NOT the
|
|
172
|
+
* frozen `originalStopPrice` R-denominator — never conflate the two. Display
|
|
173
|
+
* precedence is bracket (exchange truth) → planned → original.
|
|
174
|
+
*/
|
|
175
|
+
export declare function extractPlannedLevels(pos: {
|
|
176
|
+
[key: string]: unknown;
|
|
177
|
+
}): {
|
|
178
|
+
plannedStopPrice?: number;
|
|
179
|
+
plannedTargetPrice?: number;
|
|
180
|
+
};
|
|
158
181
|
/** The payload shape received from GatewayWsClient 'agent' event */
|
|
159
182
|
export interface AgentEventPayload {
|
|
160
183
|
runId: string;
|
|
@@ -304,6 +304,42 @@ export function extractBracketField(pos) {
|
|
|
304
304
|
out.tpPrice = tpPrice;
|
|
305
305
|
return out;
|
|
306
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Narrow the agent's PLANNED protective levels off a plugin-decorated position.
|
|
309
|
+
*
|
|
310
|
+
* `bracket` above is exchange truth and only exists where a venue-side ledger
|
|
311
|
+
* row does. In PAPER (either venue) there are no exchange legs — the levels
|
|
312
|
+
* live in the position's metadata and the stop-watcher enforces them — so
|
|
313
|
+
* without these fields the dashboard had NO target at all and drew the
|
|
314
|
+
* frozen-at-fill `originalStopPrice` as the stop even after a modify_stop.
|
|
315
|
+
* Both adapters already emit `stopPrice` / `targetPrice` on the CCXT position
|
|
316
|
+
* (ExchangeSimulator.getPositions, LiveAdapter.decoratePositionsWithMetadata);
|
|
317
|
+
* we only re-validate the shape here because CcxtPosition's index signature
|
|
318
|
+
* is `unknown`.
|
|
319
|
+
*
|
|
320
|
+
* ★ `plannedStopPrice` is the CURRENT stop (modify_stop moves it), NOT the
|
|
321
|
+
* frozen `originalStopPrice` R-denominator — never conflate the two. Display
|
|
322
|
+
* precedence is bracket (exchange truth) → planned → original.
|
|
323
|
+
*/
|
|
324
|
+
export function extractPlannedLevels(pos) {
|
|
325
|
+
const num = (v) => typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
326
|
+
// A `fixed_target` realization rule carries the same number; use it only as a
|
|
327
|
+
// fallback for entries that pinned the rule without a top-level targetPrice.
|
|
328
|
+
const rule = pos.realizationRule;
|
|
329
|
+
const ruleTarget = rule && typeof rule === 'object'
|
|
330
|
+
? rule.type === 'fixed_target'
|
|
331
|
+
? num(rule.targetPrice)
|
|
332
|
+
: undefined
|
|
333
|
+
: undefined;
|
|
334
|
+
const stop = num(pos.stopPrice);
|
|
335
|
+
const target = num(pos.targetPrice) ?? ruleTarget;
|
|
336
|
+
const out = {};
|
|
337
|
+
if (stop !== undefined)
|
|
338
|
+
out.plannedStopPrice = stop;
|
|
339
|
+
if (target !== undefined)
|
|
340
|
+
out.plannedTargetPrice = target;
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
307
343
|
/**
|
|
308
344
|
* Build the wallet/available/locked tuple from Binance Futures `info.assets[]`
|
|
309
345
|
* when present. Returns null when the field is absent or unusable so the
|
|
@@ -1,9 +1,23 @@
|
|
|
1
|
-
import { GatewayHttpClient } from './gateway-http-client.js';
|
|
1
|
+
import { GatewayHttpClient, GatewayHttpError } from './gateway-http-client.js';
|
|
2
2
|
/**
|
|
3
3
|
* Logical tool names used by ReefClaw internally.
|
|
4
4
|
* Each maps to one or more candidate actual tool names on the gateway.
|
|
5
5
|
*/
|
|
6
6
|
export type LogicalTool = 'fetch_ticker' | 'fetch_balance' | 'fetch_ohlcv' | 'fetch_positions' | 'fetch_open_orders' | 'cancel_all_orders' | 'cancel_order' | 'close_position' | 'create_order' | 'get_market_structure' | 'get_crypto_metrics' | 'get_market_intel' | 'set_trading_mode' | 'set_exchange_credentials' | 'test_exchange_credentials' | 'clear_exchange_credentials' | 'get_bracket_config' | 'set_bracket_requirement';
|
|
7
|
+
/**
|
|
8
|
+
* Build the test args used when probing each logical tool.
|
|
9
|
+
*
|
|
10
|
+
* Read tools (ticker, ohlcv, market structure, …) use the CONFIGURED symbol so
|
|
11
|
+
* the probe is venue-correct — the old hardcoded 'BTC/USDT' made every probe
|
|
12
|
+
* throw on a Hyperliquid-venue box ("not USDC-quoted"), which dropped the
|
|
13
|
+
* tools whose probes throw (fetch_open_orders, cancel_all_orders) and left the
|
|
14
|
+
* bridge retrying discovery every 30s forever (observed live on the HL rig,
|
|
15
|
+
* 2026-07-24).
|
|
16
|
+
*
|
|
17
|
+
* Destructive-capable tools (cancel_all_orders, close_position) probe with the
|
|
18
|
+
* PROBE_SENTINEL_SYMBOL so they can never execute against real state.
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildProbeArgs(symbol?: string): Record<LogicalTool, Record<string, unknown>>;
|
|
7
21
|
/** Map of logical tool names to their discovered actual tool names on the gateway */
|
|
8
22
|
export type ToolMap = Partial<Record<LogicalTool, string>>;
|
|
9
23
|
export interface DiscoveryResult {
|
|
@@ -16,10 +30,15 @@ export interface DiscoveryResult {
|
|
|
16
30
|
/** Logical names of optional tools that are missing */
|
|
17
31
|
missingOptional: LogicalTool[];
|
|
18
32
|
}
|
|
33
|
+
/** True when an envelope-level (HTTP 200, ok:false) error denotes an unknown
|
|
34
|
+
* tool name rather than a tool that executed and failed. Exported for tests. */
|
|
35
|
+
export declare function isUnknownToolEnvelopeError(err: GatewayHttpError): boolean;
|
|
19
36
|
/**
|
|
20
37
|
* Discover all available trading tools on the gateway.
|
|
21
38
|
*
|
|
22
39
|
* Probes required tools first, then optional tools in parallel.
|
|
23
40
|
* Fails fast if no required tools are found.
|
|
24
41
|
*/
|
|
25
|
-
export declare function discoverTools(http: GatewayHttpClient
|
|
42
|
+
export declare function discoverTools(http: GatewayHttpClient, opts?: {
|
|
43
|
+
symbol?: string;
|
|
44
|
+
}): Promise<DiscoveryResult>;
|
|
@@ -47,53 +47,109 @@ const CANDIDATES = {
|
|
|
47
47
|
get_bracket_config: ['get_bracket_config'],
|
|
48
48
|
set_bracket_requirement: ['set_bracket_requirement'],
|
|
49
49
|
};
|
|
50
|
+
/** Default probe symbol when the caller does not supply the configured one.
|
|
51
|
+
* Kept for back-compat with pre-symbol callers; real deployments should pass
|
|
52
|
+
* the configured symbol so probes are venue-correct (see buildProbeArgs). */
|
|
53
|
+
const DEFAULT_PROBE_SYMBOL = 'BTC/USDT';
|
|
54
|
+
/** Sentinel symbol for probes of DESTRUCTIVE-CAPABLE tools. It never resolves
|
|
55
|
+
* to a market on any venue, so the probe can never touch a real order or
|
|
56
|
+
* position: the tool executes, fails fast client-side, and the envelope-level
|
|
57
|
+
* error still proves the tool exists (see probeCandidate).
|
|
58
|
+
*
|
|
59
|
+
* ★ Why this exists (2026-07-24): cancel_all_orders was probed with a REAL
|
|
60
|
+
* symbol — a valid, executable cancel-all sweep. On a live box with a resting
|
|
61
|
+
* working order, every bridge restart (and every 30s discovery retry) would
|
|
62
|
+
* silently cancel it. A probe must never be an order-book mutation. */
|
|
63
|
+
const PROBE_SENTINEL_SYMBOL = '__probe__';
|
|
50
64
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
65
|
+
* Build the test args used when probing each logical tool.
|
|
66
|
+
*
|
|
67
|
+
* Read tools (ticker, ohlcv, market structure, …) use the CONFIGURED symbol so
|
|
68
|
+
* the probe is venue-correct — the old hardcoded 'BTC/USDT' made every probe
|
|
69
|
+
* throw on a Hyperliquid-venue box ("not USDC-quoted"), which dropped the
|
|
70
|
+
* tools whose probes throw (fetch_open_orders, cancel_all_orders) and left the
|
|
71
|
+
* bridge retrying discovery every 30s forever (observed live on the HL rig,
|
|
72
|
+
* 2026-07-24).
|
|
73
|
+
*
|
|
74
|
+
* Destructive-capable tools (cancel_all_orders, close_position) probe with the
|
|
75
|
+
* PROBE_SENTINEL_SYMBOL so they can never execute against real state.
|
|
56
76
|
*/
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
77
|
+
export function buildProbeArgs(symbol = DEFAULT_PROBE_SYMBOL) {
|
|
78
|
+
return {
|
|
79
|
+
fetch_ticker: { symbol },
|
|
80
|
+
fetch_balance: {},
|
|
81
|
+
fetch_ohlcv: { symbol, timeframe: '1h', limit: 1 },
|
|
82
|
+
fetch_positions: { symbol },
|
|
83
|
+
fetch_open_orders: { symbol },
|
|
84
|
+
// ★ NEVER a real symbol: a cancel-all probe with a real symbol is a real
|
|
85
|
+
// cancel-all. The sentinel fails symbol resolution before any order is
|
|
86
|
+
// touched; the resulting envelope error still counts as "tool exists".
|
|
87
|
+
cancel_all_orders: { symbol: PROBE_SENTINEL_SYMBOL },
|
|
88
|
+
// cancel_order: nonexistent id — order-not-found error, nothing cancelled.
|
|
89
|
+
cancel_order: { id: 'probe-0', symbol },
|
|
90
|
+
// ★ Sentinel for the same reason: close_position with a real symbol and an
|
|
91
|
+
// open position could market-close it (the close-discipline validator is a
|
|
92
|
+
// live-mode reason gate, not a guarantee on every mode/venue/version).
|
|
93
|
+
close_position: { symbol: PROBE_SENTINEL_SYMBOL },
|
|
94
|
+
// amount 0 is rejected by order validation before reaching any exchange.
|
|
95
|
+
create_order: { symbol, type: 'limit', side: 'buy', amount: 0, price: 0 },
|
|
96
|
+
get_market_structure: { symbol },
|
|
97
|
+
get_crypto_metrics: { symbol },
|
|
98
|
+
get_market_intel: { category: 'sentiment' },
|
|
99
|
+
// Safe probe args — plugin tools early-return on invalid input without
|
|
100
|
+
// touching disk. `mode: '__probe__'` fails the VALID_MODES check; empty
|
|
101
|
+
// strings fail the length-8 validator in set_exchange_credentials. Both
|
|
102
|
+
// return HTTP 200 with ok:false, which the probe treats as "tool exists".
|
|
103
|
+
set_trading_mode: { mode: '__probe__' },
|
|
104
|
+
set_exchange_credentials: { apiKey: '', secret: '' },
|
|
105
|
+
// test_exchange_credentials: empty strings fail the length check and
|
|
106
|
+
// early-return without hitting Binance.
|
|
107
|
+
test_exchange_credentials: { apiKey: '', secret: '' },
|
|
108
|
+
// clear_exchange_credentials: tool requires confirm:true. Probing without
|
|
109
|
+
// it triggers the safety gate early-return — tool exists, nothing wiped.
|
|
110
|
+
clear_exchange_credentials: {},
|
|
111
|
+
// Bracket-config probes: no-arg read; set with an invalid flag triggers early-return.
|
|
112
|
+
get_bracket_config: {},
|
|
113
|
+
set_bracket_requirement: { flag: '__probe__', value: false },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
87
116
|
// ---- Discovery logic ----
|
|
117
|
+
/** Envelope-level error messages that mean "the gateway does not have this
|
|
118
|
+
* tool" (as opposed to "the tool executed and failed"). Matched against the
|
|
119
|
+
* error message + body of a 200-status GatewayHttpError.
|
|
120
|
+
*
|
|
121
|
+
* The name-not-found pattern requires a QUOTED tool name ("tool 'x' not
|
|
122
|
+
* found") — a bare `tool .* not found` would false-positive on legitimate
|
|
123
|
+
* execution errors like cancel_order's "Tool invoke failed: Order probe-0
|
|
124
|
+
* not found" (the probe's EXPECTED failure) and drop the tool. */
|
|
125
|
+
const UNKNOWN_TOOL_RE = /unknown tool|tool ['"][^'"]*['"] (?:not found|does not exist)|no such tool|is not registered|tool_not_found|unavailable tool|not in allowlist/i;
|
|
126
|
+
/** True when an envelope-level (HTTP 200, ok:false) error denotes an unknown
|
|
127
|
+
* tool name rather than a tool that executed and failed. Exported for tests. */
|
|
128
|
+
export function isUnknownToolEnvelopeError(err) {
|
|
129
|
+
const haystack = `${err.message} ${JSON.stringify(err.body ?? '')}`;
|
|
130
|
+
return UNKNOWN_TOOL_RE.test(haystack);
|
|
131
|
+
}
|
|
88
132
|
/**
|
|
89
133
|
* Probe a single candidate tool name. Returns true if the tool exists.
|
|
90
134
|
*
|
|
135
|
+
* ★ "Exists" means the invocation REACHED the tool — not that it succeeded.
|
|
136
|
+
* A tool that executed and threw (venue mismatch, transient rate limit, a
|
|
137
|
+
* refused destructive call) absolutely exists; dropping it from the toolMap
|
|
138
|
+
* was the 2026-07-24 HL-rig bug (fetch_open_orders/cancel_all_orders throw on
|
|
139
|
+
* a probe error → "missing" forever → 30s discovery-retry loop + emergency
|
|
140
|
+
* controls degraded).
|
|
141
|
+
*
|
|
91
142
|
* Strategy:
|
|
92
|
-
* - 200:
|
|
93
|
-
* - 400:
|
|
143
|
+
* - 200 parsed OK: exists and works
|
|
144
|
+
* - 400: exists, bad args
|
|
145
|
+
* - 200 + envelope ok:false: the tool EXECUTED and failed → exists — unless
|
|
146
|
+
* the envelope error says the tool name itself is unknown (gateways that
|
|
147
|
+
* report unknown tools in-envelope instead of via 404)
|
|
148
|
+
* - >=500: the tool executed and threw server-side → exists
|
|
94
149
|
* - 404: tool does not exist
|
|
95
150
|
* - 401: auth error — rethrow (don't mask)
|
|
96
|
-
* -
|
|
151
|
+
* - network error / timeout (status 0): unknown — treat as not found, the
|
|
152
|
+
* discovery retry loop will re-probe
|
|
97
153
|
*/
|
|
98
154
|
async function probeCandidate(http, candidateName, args) {
|
|
99
155
|
try {
|
|
@@ -108,6 +164,15 @@ async function probeCandidate(http, candidateName, args) {
|
|
|
108
164
|
return false; // Tool not found
|
|
109
165
|
if (err.status === 401)
|
|
110
166
|
throw err; // Auth error — fatal
|
|
167
|
+
if (err.status === 200) {
|
|
168
|
+
// Envelope-level failure: the HTTP layer accepted the call. Either the
|
|
169
|
+
// tool executed and failed (exists) or the gateway rejected an unknown
|
|
170
|
+
// tool name in-envelope (not found). Candidate order protects the
|
|
171
|
+
// ambiguous case: the real plugin name is probed before any alias.
|
|
172
|
+
return !isUnknownToolEnvelopeError(err);
|
|
173
|
+
}
|
|
174
|
+
if (err.status >= 500)
|
|
175
|
+
return true; // Tool executed and threw
|
|
111
176
|
}
|
|
112
177
|
return false;
|
|
113
178
|
}
|
|
@@ -116,9 +181,9 @@ async function probeCandidate(http, candidateName, args) {
|
|
|
116
181
|
* Discover the actual tool name for a logical tool by probing candidates in order.
|
|
117
182
|
* Returns the first candidate that exists, or null if none found.
|
|
118
183
|
*/
|
|
119
|
-
async function discoverTool(http, logical) {
|
|
184
|
+
async function discoverTool(http, logical, probeArgs) {
|
|
120
185
|
const candidates = CANDIDATES[logical];
|
|
121
|
-
const args =
|
|
186
|
+
const args = probeArgs[logical];
|
|
122
187
|
for (const candidate of candidates) {
|
|
123
188
|
logger.debug(TAG, `Probing ${candidate} for ${logical}...`);
|
|
124
189
|
const exists = await probeCandidate(http, candidate, args);
|
|
@@ -135,15 +200,16 @@ async function discoverTool(http, logical) {
|
|
|
135
200
|
* Probes required tools first, then optional tools in parallel.
|
|
136
201
|
* Fails fast if no required tools are found.
|
|
137
202
|
*/
|
|
138
|
-
export async function discoverTools(http) {
|
|
203
|
+
export async function discoverTools(http, opts) {
|
|
139
204
|
logger.info(TAG, 'Starting tool auto-discovery...');
|
|
205
|
+
const probeArgs = buildProbeArgs(opts?.symbol);
|
|
140
206
|
const toolMap = {};
|
|
141
207
|
const found = [];
|
|
142
208
|
const missingRequired = [];
|
|
143
209
|
const missingOptional = [];
|
|
144
210
|
// Discover required tools first (sequentially to fail fast on auth errors)
|
|
145
211
|
for (const logical of REQUIRED_TOOLS) {
|
|
146
|
-
const actual = await discoverTool(http, logical);
|
|
212
|
+
const actual = await discoverTool(http, logical, probeArgs);
|
|
147
213
|
if (actual) {
|
|
148
214
|
toolMap[logical] = actual;
|
|
149
215
|
found.push(logical);
|
|
@@ -167,7 +233,7 @@ export async function discoverTools(http) {
|
|
|
167
233
|
}
|
|
168
234
|
// Discover optional tools in parallel
|
|
169
235
|
const optionalResults = await Promise.allSettled(OPTIONAL_TOOLS.map(async (logical) => {
|
|
170
|
-
const actual = await discoverTool(http, logical);
|
|
236
|
+
const actual = await discoverTool(http, logical, probeArgs);
|
|
171
237
|
return { logical, actual };
|
|
172
238
|
}));
|
|
173
239
|
for (const result of optionalResults) {
|
|
@@ -14,6 +14,17 @@ export declare function shouldAnchorSessionNav(args: {
|
|
|
14
14
|
today: string;
|
|
15
15
|
mode: TradingMode;
|
|
16
16
|
}): boolean;
|
|
17
|
+
/** Decide whether to adopt the plugin-provided sessionStartNav from a balance
|
|
18
|
+
* response. Pure so it can be unit-tested without the full provider.
|
|
19
|
+
*
|
|
20
|
+
* ★ Deliberately MODE-INDEPENDENT: the plugin's anchor is authoritative in
|
|
21
|
+
* every mode — live/shadow anchor to the exchange income/day-anchor, and
|
|
22
|
+
* PAPER's simulator seeds + midnight-rolls its own anchor in state.json (the
|
|
23
|
+
* same value its pre-trade risk gate reads). The pre-2026-07-24 non-PAPER
|
|
24
|
+
* gate left paper with a SECOND, skill-computed anchor; on a customer box the
|
|
25
|
+
* two diverged (13942 vs 10024) and the skill auto-flattened a FLAT account
|
|
26
|
+
* at a phantom −27.81% drawdown while the plugin's own view was GREEN. */
|
|
27
|
+
export declare function shouldSyncPluginSessionNav(pluginNav: unknown, currentNav: number): pluginNav is number;
|
|
17
28
|
export declare class GatewayProvider implements OpenClawProvider {
|
|
18
29
|
private readonly config;
|
|
19
30
|
private http;
|
|
@@ -9,7 +9,7 @@ import { isTradingMode } from '../types.js';
|
|
|
9
9
|
import { GatewayHttpClient } from '../gateway/gateway-http-client.js';
|
|
10
10
|
import { GatewayWsClient } from '../gateway/gateway-ws-client.js';
|
|
11
11
|
import { discoverTools } from '../gateway/tool-discovery.js';
|
|
12
|
-
import { EventParser, mapCcxtBalance, mapCcxtOrder, extractLiquidationFields, extractBracketField, } from '../gateway/event-parser.js';
|
|
12
|
+
import { EventParser, mapCcxtBalance, mapCcxtOrder, extractLiquidationFields, extractBracketField, extractPlannedLevels, } from '../gateway/event-parser.js';
|
|
13
13
|
import { Poller } from '../gateway/poller.js';
|
|
14
14
|
import { ensureHeartbeatCron } from '../gateway/heartbeat-cron.js';
|
|
15
15
|
import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, } from './risk-calculator.js';
|
|
@@ -49,6 +49,21 @@ export function shouldAnchorSessionNav(args) {
|
|
|
49
49
|
return true; // never anchored
|
|
50
50
|
return args.mode === 'PAPER' && args.sessionDate !== args.today; // paper midnight rollover
|
|
51
51
|
}
|
|
52
|
+
/** Decide whether to adopt the plugin-provided sessionStartNav from a balance
|
|
53
|
+
* response. Pure so it can be unit-tested without the full provider.
|
|
54
|
+
*
|
|
55
|
+
* ★ Deliberately MODE-INDEPENDENT: the plugin's anchor is authoritative in
|
|
56
|
+
* every mode — live/shadow anchor to the exchange income/day-anchor, and
|
|
57
|
+
* PAPER's simulator seeds + midnight-rolls its own anchor in state.json (the
|
|
58
|
+
* same value its pre-trade risk gate reads). The pre-2026-07-24 non-PAPER
|
|
59
|
+
* gate left paper with a SECOND, skill-computed anchor; on a customer box the
|
|
60
|
+
* two diverged (13942 vs 10024) and the skill auto-flattened a FLAT account
|
|
61
|
+
* at a phantom −27.81% drawdown while the plugin's own view was GREEN. */
|
|
62
|
+
export function shouldSyncPluginSessionNav(pluginNav, currentNav) {
|
|
63
|
+
return typeof pluginNav === 'number'
|
|
64
|
+
&& pluginNav > 0
|
|
65
|
+
&& Math.abs(currentNav - pluginNav) > 0.001;
|
|
66
|
+
}
|
|
52
67
|
function loadSessionStartNav() {
|
|
53
68
|
for (const path of [getDayNavPath(), getLegacyDayNavPath()]) {
|
|
54
69
|
try {
|
|
@@ -751,6 +766,8 @@ export class GatewayProvider {
|
|
|
751
766
|
giveBackRatio: typeof pos.giveBackRatio === 'number' ? pos.giveBackRatio : undefined,
|
|
752
767
|
originalStopPrice: typeof pos.originalStopPrice === 'number' ? pos.originalStopPrice : undefined,
|
|
753
768
|
...(bracketField !== undefined ? { bracket: bracketField } : {}),
|
|
769
|
+
// Agent's planned stop/target — the ONLY SL/TP source in paper mode.
|
|
770
|
+
...extractPlannedLevels(pos),
|
|
754
771
|
...liqFields,
|
|
755
772
|
});
|
|
756
773
|
}
|
|
@@ -831,8 +848,11 @@ export class GatewayProvider {
|
|
|
831
848
|
else {
|
|
832
849
|
logger.warn(TAG, 'No device token from WS handshake — REST calls may fail (using raw gateway token)');
|
|
833
850
|
}
|
|
834
|
-
// Step 4: Discover available tools (async — check for stop() after)
|
|
835
|
-
|
|
851
|
+
// Step 4: Discover available tools (async — check for stop() after).
|
|
852
|
+
// Pass the configured symbol so probes are venue-correct — a hardcoded
|
|
853
|
+
// BTC/USDT probe throws on a Hyperliquid-venue plugin and silently drops
|
|
854
|
+
// the tools whose probes throw (2026-07-24 HL-rig bug).
|
|
855
|
+
const discovery = await discoverTools(this.http, { symbol: this.config.symbol });
|
|
836
856
|
if (!this.started) {
|
|
837
857
|
logger.info(TAG, 'Stopped during tool discovery — aborting initialization');
|
|
838
858
|
this.http = null;
|
|
@@ -975,7 +995,7 @@ export class GatewayProvider {
|
|
|
975
995
|
try {
|
|
976
996
|
// Capture http ref before await — stop() can nullify it
|
|
977
997
|
const http = this.http;
|
|
978
|
-
const discovery = await discoverTools(http);
|
|
998
|
+
const discovery = await discoverTools(http, { symbol: this.config.symbol });
|
|
979
999
|
// Guard: stop() may have been called during async discovery
|
|
980
1000
|
if (!this.started || !this.http)
|
|
981
1001
|
return;
|
|
@@ -1653,23 +1673,32 @@ export class GatewayProvider {
|
|
|
1653
1673
|
if (typeof result.data.equity === 'number') {
|
|
1654
1674
|
this.equity = result.data.equity;
|
|
1655
1675
|
}
|
|
1656
|
-
//
|
|
1657
|
-
//
|
|
1658
|
-
//
|
|
1659
|
-
//
|
|
1660
|
-
//
|
|
1661
|
-
//
|
|
1662
|
-
//
|
|
1663
|
-
//
|
|
1664
|
-
//
|
|
1665
|
-
//
|
|
1666
|
-
//
|
|
1667
|
-
|
|
1676
|
+
// The plugin's sessionStartNav is the authoritative day anchor in EVERY
|
|
1677
|
+
// mode, so sync continuously whenever it provides one:
|
|
1678
|
+
// - live/shadow: anchored to Binance's /fapi/v1/income at UTC midnight
|
|
1679
|
+
// (or the HL day-anchor reconstruction) — the 2026-05-14 lesson: the
|
|
1680
|
+
// skill's self-computed value drifts whenever trading happened before
|
|
1681
|
+
// the skill first polled ($1095.95 self-seed vs the plugin's $1099.86
|
|
1682
|
+
// income-anchored truth = a $3.92 dashboard-vs-Binance gap).
|
|
1683
|
+
// - PAPER: the simulator seeds + midnight-rolls its own anchor in
|
|
1684
|
+
// state.json and its pre-trade risk gate reads THAT value. The skill
|
|
1685
|
+
// previously kept a SECOND, self-computed paper anchor here — two
|
|
1686
|
+
// anchors for one number. On a customer box (2026-07-24) the skill's
|
|
1687
|
+
// boot-seeded 13942 diverged from the plugin's 10024 and the skill's
|
|
1688
|
+
// RED-zone math auto-flattened a FLAT account at a phantom −27.81%
|
|
1689
|
+
// while the plugin's own view was GREEN. One anchor, one truth.
|
|
1690
|
+
// Crossing UTC midnight is handled automatically — the plugin's anchor
|
|
1691
|
+
// refreshes, the skill syncs next poll. The skill's self-seed
|
|
1692
|
+
// (trySetSessionStartNav) remains only as a fallback for plugins too old
|
|
1693
|
+
// to emit sessionStartNav.
|
|
1694
|
+
{
|
|
1668
1695
|
const pluginNav = result.data.sessionStartNav;
|
|
1669
|
-
if (
|
|
1670
|
-
&& Math.abs(this.sessionStartNav - pluginNav) > 0.001) {
|
|
1696
|
+
if (shouldSyncPluginSessionNav(pluginNav, this.sessionStartNav)) {
|
|
1671
1697
|
const prev = this.sessionStartNav;
|
|
1672
1698
|
this.sessionStartNav = pluginNav;
|
|
1699
|
+
// Stamp the day so the PAPER self-seed path (shouldAnchorSessionNav
|
|
1700
|
+
// re-anchors on a stale sessionDate) doesn't flap against the sync.
|
|
1701
|
+
this.sessionDate = todayDateStr();
|
|
1673
1702
|
saveSessionStartNav(pluginNav);
|
|
1674
1703
|
logger.info(TAG, `Session start NAV synced from plugin: ${prev.toFixed(4)} -> ${pluginNav} (${this.tradingMode} mode)`);
|
|
1675
1704
|
}
|
|
@@ -2036,6 +2065,8 @@ export class GatewayProvider {
|
|
|
2036
2065
|
giveBackRatio: typeof p.giveBackRatio === 'number' ? p.giveBackRatio : undefined,
|
|
2037
2066
|
originalStopPrice: typeof p.originalStopPrice === 'number' ? p.originalStopPrice : undefined,
|
|
2038
2067
|
...(bracketField !== undefined ? { bracket: bracketField } : {}),
|
|
2068
|
+
// Agent's planned stop/target — the ONLY SL/TP source in paper mode.
|
|
2069
|
+
...extractPlannedLevels(p),
|
|
2039
2070
|
...liqFields,
|
|
2040
2071
|
};
|
|
2041
2072
|
});
|
package/bridge/types.d.ts
CHANGED
|
@@ -186,6 +186,8 @@ export interface RiskUpdatePayload {
|
|
|
186
186
|
tpPrice?: number;
|
|
187
187
|
state: string;
|
|
188
188
|
};
|
|
189
|
+
plannedStopPrice?: number;
|
|
190
|
+
plannedTargetPrice?: number;
|
|
189
191
|
}>;
|
|
190
192
|
balance?: {
|
|
191
193
|
total: number;
|
|
@@ -239,6 +241,8 @@ export interface ReconciliationSnapshot {
|
|
|
239
241
|
tpPrice?: number;
|
|
240
242
|
state: string;
|
|
241
243
|
};
|
|
244
|
+
plannedStopPrice?: number;
|
|
245
|
+
plannedTargetPrice?: number;
|
|
242
246
|
}>;
|
|
243
247
|
balance: {
|
|
244
248
|
currency: string;
|
package/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { DEFAULT_CONFIG } from './types.js';
|
|
|
22
22
|
import { PaperAdapter } from './paper-adapter.js';
|
|
23
23
|
import { LiveAdapter, } from './live/live-adapter.js';
|
|
24
24
|
import { createLiveAdapter, fillExchangeId, isLiveVenueSupported, parseVenue, venueQuoteCurrency, } from './venues/registry.js';
|
|
25
|
+
import { resolvePluginSymbol } from './venues/symbols.js';
|
|
25
26
|
import { HyperliquidPublicApi } from './venues/hyperliquid/hl-public.js';
|
|
26
27
|
import { bracketsEnabled, loadBracketMode } from './config/brackets-config.js';
|
|
27
28
|
import { loadUserDataStreamMode, loadUserDataStreamTunables, loadUserDataStreamDbWrite, getUserDataStreamIngestBaseUrl, resolveIngestToken, resolveReefclawUserId, } from './config/user-data-stream-config.js';
|
|
@@ -909,15 +910,22 @@ const paperTradingPlugin = {
|
|
|
909
910
|
// rules, and an unreadable config falls back to the binance default
|
|
910
911
|
// exactly like the main read does.
|
|
911
912
|
let paperQuoteCurrency = DEFAULT_CONFIG.quoteCurrency;
|
|
913
|
+
let peekedVenue = 'binance';
|
|
912
914
|
try {
|
|
913
|
-
|
|
915
|
+
peekedVenue = parseVenue(readPluginConfig().exchange?.venue).venue;
|
|
916
|
+
paperQuoteCurrency = venueQuoteCurrency(peekedVenue);
|
|
914
917
|
}
|
|
915
918
|
catch {
|
|
916
919
|
/* unreadable plugin-config → binance default, matching the main read */
|
|
917
920
|
}
|
|
918
921
|
const pluginConfig = {
|
|
919
922
|
startingBalance: DEFAULT_CONFIG.startingBalance,
|
|
920
|
-
|
|
923
|
+
// Symbol resolution (2026-07-24): honor the openclaw.json entry config
|
|
924
|
+
// (api.config.symbol — declared in configSchema but previously IGNORED),
|
|
925
|
+
// falling back to the VENUE's default (BTC/USDC on hyperliquid). The old
|
|
926
|
+
// venue-blind DEFAULT_CONFIG.symbol hardcode gave every in-plugin
|
|
927
|
+
// consumer on a hyperliquid box a symbol its own venue rejects.
|
|
928
|
+
symbol: resolvePluginSymbol(api.config?.symbol, peekedVenue),
|
|
921
929
|
quoteCurrency: paperQuoteCurrency,
|
|
922
930
|
};
|
|
923
931
|
logger.info(TAG, `Config: ${pluginConfig.startingBalance} ${pluginConfig.quoteCurrency}, symbol: ${pluginConfig.symbol}`);
|
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.12",
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reefclaw/openclaw-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "ReefClaw supervised trading plugin for OpenClaw \u2014 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",
|
|
@@ -79,6 +79,14 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
|
|
|
79
79
|
*/
|
|
80
80
|
closePosition(symbol: string, _closeReason?: CloseReason): Promise<CcxtOrder>;
|
|
81
81
|
getBalance(): Promise<CcxtBalance>;
|
|
82
|
+
/** The live SL/TP trigger prices for `symbol`, or undefined when no
|
|
83
|
+
* non-terminal ledger row exists. Mirrors LiveAdapter's Binance-side
|
|
84
|
+
* `lookupBracket` so the dashboard's protective-level surfaces read the same
|
|
85
|
+
* shape on both venues. Reads `_coordinator` directly rather than through
|
|
86
|
+
* the lazy getter — merely displaying positions must not construct the
|
|
87
|
+
* coordinator (which writes a ledger file); with no coordinator there are
|
|
88
|
+
* no brackets to report anyway. */
|
|
89
|
+
private lookupBracket;
|
|
82
90
|
/** Display contract (`?? []`) — the 20+ KPI/display callers. */
|
|
83
91
|
getPositions(symbol?: string): Promise<CcxtPosition[]>;
|
|
84
92
|
/** ★ Decision contract — null means UNKNOWN, and destructive paths must not act. */
|
|
@@ -120,6 +120,11 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
120
120
|
// Prime the ADDRESS action budget (the starvation guard, §5.6).
|
|
121
121
|
await this.api.refreshAddressBudget();
|
|
122
122
|
// ---- Bracket wiring (issue #209) ----
|
|
123
|
+
// Build the coordinator here, not on first bracket action: getPositions
|
|
124
|
+
// reads its ledger to surface live SL/TP, and until the first truth-check
|
|
125
|
+
// fired (60s) a fresh boot would otherwise show every open position as
|
|
126
|
+
// unprotected on the dashboard. Construction is idempotent + cheap.
|
|
127
|
+
this.getHlBracketCoordinator();
|
|
123
128
|
// Fast path: the user stream (fills drive attach for resting limits;
|
|
124
129
|
// orderUpdates is the authoritative leg-lifecycle signal — the HL analog
|
|
125
130
|
// of Binance's ALGO_UPDATE). Truth path: T-5 proved the WS replays
|
|
@@ -497,9 +502,29 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
497
502
|
enriched.equity = round4(nav.equity);
|
|
498
503
|
return enriched;
|
|
499
504
|
}
|
|
505
|
+
/** The live SL/TP trigger prices for `symbol`, or undefined when no
|
|
506
|
+
* non-terminal ledger row exists. Mirrors LiveAdapter's Binance-side
|
|
507
|
+
* `lookupBracket` so the dashboard's protective-level surfaces read the same
|
|
508
|
+
* shape on both venues. Reads `_coordinator` directly rather than through
|
|
509
|
+
* the lazy getter — merely displaying positions must not construct the
|
|
510
|
+
* coordinator (which writes a ledger file); with no coordinator there are
|
|
511
|
+
* no brackets to report anyway. */
|
|
512
|
+
lookupBracket(symbol) {
|
|
513
|
+
const row = this._coordinator?.getLedger().getBySymbol(symbol);
|
|
514
|
+
if (!row)
|
|
515
|
+
return undefined;
|
|
516
|
+
if (row.state !== 'active' && row.state !== 'partial' && row.state !== 'attaching') {
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
return { slPrice: row.stopPrice, tpPrice: row.targetPrice, state: row.state };
|
|
520
|
+
}
|
|
500
521
|
/** Display contract (`?? []`) — the 20+ KPI/display callers. */
|
|
501
522
|
async getPositions(symbol) {
|
|
502
|
-
|
|
523
|
+
const positions = (await this.api.fetchPositions(symbol)) ?? [];
|
|
524
|
+
// HL live keeps no per-symbol metadata map, so the bracket ledger is the
|
|
525
|
+
// ONLY protective-level source here — without this the dashboard drew
|
|
526
|
+
// neither a stop nor a target for an HL live position.
|
|
527
|
+
return positions.map((p) => ({ ...p, bracket: this.lookupBracket(p.symbol) }));
|
|
503
528
|
}
|
|
504
529
|
/** ★ Decision contract — null means UNKNOWN, and destructive paths must not act. */
|
|
505
530
|
async getPositionsOrNull(symbol) {
|
package/venues/symbols.d.ts
CHANGED
|
@@ -20,6 +20,18 @@ export declare function fillExchangeId(venue: VenueId): string;
|
|
|
20
20
|
export declare const VENUE_QUOTE_ASSET: Record<VenueId, string>;
|
|
21
21
|
/** Prefix that namespaces Hyperliquid rows inside the intel symbol column. */
|
|
22
22
|
export declare const HL_INTEL_PREFIX = "HL_";
|
|
23
|
+
/** Default canonical trading symbol per venue — BTC quoted in the venue's
|
|
24
|
+
* settle asset. A venue-blind 'BTC/USDT' default on a hyperliquid box is a
|
|
25
|
+
* guaranteed venue-rejection everywhere it's consumed (same class as the
|
|
26
|
+
* issue-#174 hardcoded-USDT wallet that read as $0 equity on the USDC venue). */
|
|
27
|
+
export declare const VENUE_DEFAULT_SYMBOL: Record<VenueId, string>;
|
|
28
|
+
/** Resolve the plugin's configured trading symbol: an explicit entry-config
|
|
29
|
+
* value wins (openclaw.json plugins.entries.<id>.config.symbol, passed to
|
|
30
|
+
* register() via api.config); otherwise the VENUE's default. Never a
|
|
31
|
+
* venue-blind hardcode — before 2026-07-24 register() ignored the entry
|
|
32
|
+
* config entirely and always used 'BTC/USDT', so every in-plugin consumer on
|
|
33
|
+
* a hyperliquid-venue box carried a symbol its own venue rejects. */
|
|
34
|
+
export declare function resolvePluginSymbol(entrySymbol: unknown, venue: VenueId): string;
|
|
23
35
|
/** Canonical symbol → the venue's CCXT unified symbol.
|
|
24
36
|
* 'BTC/USDT' (binance) → 'BTC/USDT:USDT'; 'BTC/USDC' (hyperliquid) →
|
|
25
37
|
* 'BTC/USDC:USDC'. Accepts an already-suffixed input (idempotent). */
|
package/venues/symbols.js
CHANGED
|
@@ -39,6 +39,26 @@ export const VENUE_QUOTE_ASSET = {
|
|
|
39
39
|
};
|
|
40
40
|
/** Prefix that namespaces Hyperliquid rows inside the intel symbol column. */
|
|
41
41
|
export const HL_INTEL_PREFIX = 'HL_';
|
|
42
|
+
/** Default canonical trading symbol per venue — BTC quoted in the venue's
|
|
43
|
+
* settle asset. A venue-blind 'BTC/USDT' default on a hyperliquid box is a
|
|
44
|
+
* guaranteed venue-rejection everywhere it's consumed (same class as the
|
|
45
|
+
* issue-#174 hardcoded-USDT wallet that read as $0 equity on the USDC venue). */
|
|
46
|
+
export const VENUE_DEFAULT_SYMBOL = {
|
|
47
|
+
binance: 'BTC/USDT',
|
|
48
|
+
hyperliquid: 'BTC/USDC',
|
|
49
|
+
};
|
|
50
|
+
/** Resolve the plugin's configured trading symbol: an explicit entry-config
|
|
51
|
+
* value wins (openclaw.json plugins.entries.<id>.config.symbol, passed to
|
|
52
|
+
* register() via api.config); otherwise the VENUE's default. Never a
|
|
53
|
+
* venue-blind hardcode — before 2026-07-24 register() ignored the entry
|
|
54
|
+
* config entirely and always used 'BTC/USDT', so every in-plugin consumer on
|
|
55
|
+
* a hyperliquid-venue box carried a symbol its own venue rejects. */
|
|
56
|
+
export function resolvePluginSymbol(entrySymbol, venue) {
|
|
57
|
+
if (typeof entrySymbol === 'string' && entrySymbol.trim().length > 0) {
|
|
58
|
+
return stripSettleSuffix(entrySymbol.trim());
|
|
59
|
+
}
|
|
60
|
+
return VENUE_DEFAULT_SYMBOL[venue];
|
|
61
|
+
}
|
|
42
62
|
/** Strip a CCXT settle suffix ('BTC/USDT:USDT' → 'BTC/USDT'). Same regex as
|
|
43
63
|
* webapp/src/lib/symbols.ts normalizeSymbol — kept inline so this module has
|
|
44
64
|
* zero imports and survives the sync-copy deploy boundary. */
|