@reefclaw/openclaw-plugin 0.1.9 → 0.1.11

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.
@@ -280,7 +280,15 @@ export class GatewayWsClient {
280
280
  mode: 'backend',
281
281
  },
282
282
  role: 'operator',
283
- scopes: ['operator.read', 'operator.write'],
283
+ // operator.admin: cron management (the boot-time heartbeat-cron ensure
284
+ // calls cron.list/cron.add) moved behind the admin scope on OpenClaw
285
+ // 2026.7.x — observed live on the first real npx onboarding
286
+ // ("Heartbeat cron: skipped (missing scope: operator.admin)",
287
+ // 2026-07-22, openclaw 2026.7.1-2). The gateway is localhost-bound and
288
+ // this connection already holds operator.write (orders, chat), so the
289
+ // marginal grant is small; without it fresh 2026.7.x installs never
290
+ // get a heartbeat cron.
291
+ scopes: ['operator.read', 'operator.write', 'operator.admin'],
284
292
  auth: {
285
293
  token: this.gatewayToken,
286
294
  ...(this.deviceToken && { deviceToken: this.deviceToken }),
@@ -58,20 +58,35 @@ export async function ensureHeartbeatCron(opts) {
58
58
  log(`Heartbeat cron: already exists (${String(existing.name)})`);
59
59
  return 'found_existing';
60
60
  }
61
- // Parity with the params the CLI built for the old setup command
61
+ // First attempt mirrors the params the CLI built for the old setup command
62
62
  // (`openclaw cron add --name reefclaw-heartbeat --every 15m --session isolated --message ...`):
63
- // isolated agentTurn defaults to delivery announce on the 'last' channel.
64
- await rpc.sendRpc('cron.add', {
63
+ // isolated agentTurn defaults to delivery announce on the 'last' channel
64
+ // right for boxes with a chat channel (the heartbeat's summary reaches the
65
+ // operator). On a FRESH box with no channels configured, cron.add REJECTS
66
+ // announce/'last' (no resolvable recipient — observed live on a first
67
+ // real npx onboarding, 2026-07-22; the old setup-time CLI path failed the
68
+ // same way, silently). Fall back to delivery mode 'none': a heartbeat that
69
+ // runs without announcing beats no heartbeat at all, and the operator sees
70
+ // the results on the dashboard anyway.
71
+ const baseJob = {
65
72
  name: HEARTBEAT_CRON_NAME,
66
73
  enabled: true,
67
74
  schedule: { kind: 'every', everyMs: HEARTBEAT_EVERY_MS },
68
75
  sessionTarget: 'isolated',
69
76
  wakeMode: 'now',
70
77
  payload: { kind: 'agentTurn', message: HEARTBEAT_MESSAGE },
71
- delivery: { mode: 'announce', channel: 'last' },
72
- });
73
- writeMarker(markerPath, { ensuredAt: new Date().toISOString(), created: HEARTBEAT_CRON_NAME });
74
- log(`Heartbeat cron: created (${HEARTBEAT_CRON_NAME}, every 15m)`);
78
+ };
79
+ let delivery = 'announce';
80
+ try {
81
+ await rpc.sendRpc('cron.add', { ...baseJob, delivery: { mode: 'announce', channel: 'last' } });
82
+ }
83
+ catch (addErr) {
84
+ log(`Heartbeat cron: announce delivery rejected (${addErr instanceof Error ? addErr.message.split('\n')[0] : String(addErr)}) — retrying without delivery`);
85
+ await rpc.sendRpc('cron.add', { ...baseJob, delivery: { mode: 'none' } });
86
+ delivery = 'none';
87
+ }
88
+ writeMarker(markerPath, { ensuredAt: new Date().toISOString(), created: HEARTBEAT_CRON_NAME, delivery });
89
+ log(`Heartbeat cron: created (${HEARTBEAT_CRON_NAME}, every 15m, delivery=${delivery})`);
75
90
  return 'created';
76
91
  }
77
92
  catch (err) {
@@ -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): Promise<DiscoveryResult>;
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
- * Test args used when probing each logical tool.
52
- * Read tools (ticker, balance, etc.) use real args that return data.
53
- * Write tools (create_order, cancel, close) use args that will be
54
- * rejected by exchange validation (amount:0, etc.) we only need
55
- * to distinguish 400 (tool exists, bad args) from 404 (tool not found).
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
- const PROBE_ARGS = {
58
- fetch_ticker: { symbol: 'BTC/USDT' },
59
- fetch_balance: {},
60
- fetch_ohlcv: { symbol: 'BTC/USDT', timeframe: '1h', limit: 1 },
61
- fetch_positions: { symbol: 'BTC/USDT' },
62
- fetch_open_orders: { symbol: 'BTC/USDT' },
63
- // For write tools: probe with minimal args. We expect 400 (bad args) = tool exists.
64
- cancel_all_orders: { symbol: 'BTC/USDT' },
65
- cancel_order: { id: 'probe-0', symbol: 'BTC/USDT' },
66
- close_position: { symbol: 'BTC/USDT' },
67
- create_order: { symbol: 'BTC/USDT', type: 'limit', side: 'buy', amount: 0, price: 0 },
68
- get_market_structure: { symbol: 'BTC/USDT' },
69
- get_crypto_metrics: { symbol: 'BTC/USDT' },
70
- get_market_intel: { category: 'sentiment' },
71
- // Safe probe args plugin tools early-return on invalid input without
72
- // touching disk. `mode: '__probe__'` fails the VALID_MODES check; empty
73
- // strings fail the length-8 validator in set_exchange_credentials. Both
74
- // return HTTP 200 with ok:false, which the probe treats as "tool exists".
75
- set_trading_mode: { mode: '__probe__' },
76
- set_exchange_credentials: { apiKey: '', secret: '' },
77
- // test_exchange_credentials: empty strings fail the length check and
78
- // early-return without hitting Binance.
79
- test_exchange_credentials: { apiKey: '', secret: '' },
80
- // clear_exchange_credentials: tool requires confirm:true. Probing without
81
- // it triggers the safety gate early-return tool exists, nothing wiped.
82
- clear_exchange_credentials: {},
83
- // Bracket-config probes: no-arg read; set with an invalid flag triggers early-return.
84
- get_bracket_config: {},
85
- set_bracket_requirement: { flag: '__probe__', value: false },
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: tool exists and invocation succeeded
93
- * - 400: tool exists but args were bad (still counts as found)
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
- * - Other: treat as not found
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 = PROBE_ARGS[logical];
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;
@@ -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 {
@@ -831,8 +846,11 @@ export class GatewayProvider {
831
846
  else {
832
847
  logger.warn(TAG, 'No device token from WS handshake — REST calls may fail (using raw gateway token)');
833
848
  }
834
- // Step 4: Discover available tools (async — check for stop() after)
835
- const discovery = await discoverTools(this.http);
849
+ // Step 4: Discover available tools (async — check for stop() after).
850
+ // Pass the configured symbol so probes are venue-correct — a hardcoded
851
+ // BTC/USDT probe throws on a Hyperliquid-venue plugin and silently drops
852
+ // the tools whose probes throw (2026-07-24 HL-rig bug).
853
+ const discovery = await discoverTools(this.http, { symbol: this.config.symbol });
836
854
  if (!this.started) {
837
855
  logger.info(TAG, 'Stopped during tool discovery — aborting initialization');
838
856
  this.http = null;
@@ -975,7 +993,7 @@ export class GatewayProvider {
975
993
  try {
976
994
  // Capture http ref before await — stop() can nullify it
977
995
  const http = this.http;
978
- const discovery = await discoverTools(http);
996
+ const discovery = await discoverTools(http, { symbol: this.config.symbol });
979
997
  // Guard: stop() may have been called during async discovery
980
998
  if (!this.started || !this.http)
981
999
  return;
@@ -1653,23 +1671,32 @@ export class GatewayProvider {
1653
1671
  if (typeof result.data.equity === 'number') {
1654
1672
  this.equity = result.data.equity;
1655
1673
  }
1656
- // In non-PAPER modes, the plugin anchors sessionStartNav to Binance's
1657
- // /fapi/v1/income at UTC midnight on every balance fetch — that's the
1658
- // authoritative truth. Sync continuously, not just on first set: the
1659
- // pre-2026-05-14 guard `this.sessionStartNav < 0` meant once the skill
1660
- // had ANY value (from file load or computeEquity), the plugin's correct
1661
- // value was ignored forever. The skill's self-computed value drifts
1662
- // whenever trading has already happened before the skill first polls
1663
- // (operator screenshot 2026-05-14 found a $3.92 dashboard-vs-Binance
1664
- // gap from a $1095.95 self-computed seed vs the plugin's $1099.86
1665
- // midnight-anchored truth). Crossing UTC midnight is also handled
1666
- // automatically plugin's anchor refreshes, skill syncs next poll.
1667
- if (this.tradingMode !== 'PAPER') {
1674
+ // The plugin's sessionStartNav is the authoritative day anchor in EVERY
1675
+ // mode, so sync continuously whenever it provides one:
1676
+ // - live/shadow: anchored to Binance's /fapi/v1/income at UTC midnight
1677
+ // (or the HL day-anchor reconstruction) — the 2026-05-14 lesson: the
1678
+ // skill's self-computed value drifts whenever trading happened before
1679
+ // the skill first polled ($1095.95 self-seed vs the plugin's $1099.86
1680
+ // income-anchored truth = a $3.92 dashboard-vs-Binance gap).
1681
+ // - PAPER: the simulator seeds + midnight-rolls its own anchor in
1682
+ // state.json and its pre-trade risk gate reads THAT value. The skill
1683
+ // previously kept a SECOND, self-computed paper anchor here — two
1684
+ // anchors for one number. On a customer box (2026-07-24) the skill's
1685
+ // boot-seeded 13942 diverged from the plugin's 10024 and the skill's
1686
+ // RED-zone math auto-flattened a FLAT account at a phantom −27.81%
1687
+ // while the plugin's own view was GREEN. One anchor, one truth.
1688
+ // Crossing UTC midnight is handled automatically — the plugin's anchor
1689
+ // refreshes, the skill syncs next poll. The skill's self-seed
1690
+ // (trySetSessionStartNav) remains only as a fallback for plugins too old
1691
+ // to emit sessionStartNav.
1692
+ {
1668
1693
  const pluginNav = result.data.sessionStartNav;
1669
- if (typeof pluginNav === 'number' && pluginNav > 0
1670
- && Math.abs(this.sessionStartNav - pluginNav) > 0.001) {
1694
+ if (shouldSyncPluginSessionNav(pluginNav, this.sessionStartNav)) {
1671
1695
  const prev = this.sessionStartNav;
1672
1696
  this.sessionStartNav = pluginNav;
1697
+ // Stamp the day so the PAPER self-seed path (shouldAnchorSessionNav
1698
+ // re-anchors on a stale sessionDate) doesn't flap against the sync.
1699
+ this.sessionDate = todayDateStr();
1673
1700
  saveSessionStartNav(pluginNav);
1674
1701
  logger.info(TAG, `Session start NAV synced from plugin: ${prev.toFixed(4)} -> ${pluginNav} (${this.tradingMode} mode)`);
1675
1702
  }
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
- paperQuoteCurrency = venueQuoteCurrency(parseVenue(readPluginConfig().exchange?.venue).venue);
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
- symbol: DEFAULT_CONFIG.symbol,
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}`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "reefclaw-paper-trading",
3
3
  "name": "ReefClaw Trading",
4
- "version": "0.1.9",
4
+ "version": "0.1.11",
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.9",
3
+ "version": "0.1.11",
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",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: reefclaw
3
- version: 0.0.5
3
+ version: 0.0.6
4
4
  description: ReefClaw trading control room — bootstrap (connects your agent; full trading instructions arrive automatically after first connect)
5
5
  author: ReefClaw
6
6
  homepage: https://reefclaw.com
@@ -80,9 +80,12 @@ OpenClaw can install the ReefClaw plugin through its own owner chat command —
80
80
  2. Tell the user to type `/restart` in this chat.
81
81
  3. Then the user types: `/plugins install clawhub:@reefclaw/openclaw-plugin` — the install restarts OpenClaw automatically; the bundled connector starts by itself and the dashboard flips to Connected.
82
82
 
83
- If that install is **cancelled asking for a ClawHub risk acknowledgement** (OpenClaw 2026.7+ does this; older versions never ask and reject the flag), rerun the identical command with the acknowledgement appended: `/plugins install clawhub:@reefclaw/openclaw-plugin --acknowledge-clawhub-risk`. If it is still refused, use the `npx @reefclaw/connect` fallback — it does not go through ClawHub.
83
+ If that install is **cancelled with a ClawHub security warning** ("Security scan: suspicious"): this is expected ReefClaw is a trading plugin with disclosed live-trading capability, exchange-credential storage, and remote instruction updates, and ClawHub flags that capability set for review (the linked security page shows the details; VirusTotal reports it clean). **The chat command cannot acknowledge the warningdo NOT retry it in chat.** Two working paths, in order of preference:
84
84
 
85
- Only fall back to `npx @reefclaw/connect` (below) when the chat install is unavailable.
85
+ 1. Run `npx --yes @reefclaw/connect` — it does not go through ClawHub. Use your exec tool if permitted (see the elevated-exec section below if it is not), or ask the user to run it once in a terminal.
86
+ 2. A human who has reviewed the warning runs `openclaw plugins install clawhub:@reefclaw/openclaw-plugin --acknowledge-clawhub-risk` in a terminal (the acknowledgement is accepted from a trusted shell, only refused from chat).
87
+
88
+ Only fall back to `npx @reefclaw/connect` (below) when the chat install is unavailable or cancelled as above.
86
89
 
87
90
  ## If you cannot run the installer (elevated exec disabled)
88
91
 
@@ -0,0 +1,36 @@
1
+ import { type HlFillForAnchor, type HlFundingForAnchor } from './hl-balance.js';
2
+ /** The two anchor-input reads the private API supplies. `HyperliquidPrivateApi`
3
+ * satisfies this structurally, so production passes `this.api`; tests pass a fake. */
4
+ export interface HlAnchorFetchers {
5
+ fetchFillsSince(sinceMs: number): Promise<HlFillForAnchor[] | null>;
6
+ fetchFundingSince(sinceMs: number): Promise<HlFundingForAnchor[] | null>;
7
+ }
8
+ export declare class HlDayPnlAnchor {
9
+ private readonly refreshIntervalMs;
10
+ private sessionStartNav;
11
+ private realizedPnlToday;
12
+ private anchorUtcDay;
13
+ private lastRefreshMs;
14
+ constructor(refreshIntervalMs?: number);
15
+ /** `wallet_at_midnight` (+ any capital flows since). `null` until the first
16
+ * successful refresh — the caller then emits nothing rather than a fabricated
17
+ * anchor, and the skill's safe fallback (self-computed) applies. */
18
+ getSessionStartNav(): number | null;
19
+ /** `netNonTransfer` since UTC midnight = Σ closedPnl − fees + funding. Matches
20
+ * the HL app's "today's realized" decomposition (fees + funding included). */
21
+ getRealizedPnlToday(): number;
22
+ /** True when a recompute is due: bootstrap (no anchor yet), UTC-day rollover, or
23
+ * the throttle has elapsed. */
24
+ shouldRefresh(now: number): boolean;
25
+ /**
26
+ * Recompute the anchor from this UTC day's fills + funding. `walletNow` is
27
+ * `nav.wallet` (= accountValue − ΣuPnl). Best-effort — NEVER throws for the
28
+ * caller (a failed fetch keeps the last good anchor). Returns whether the anchor
29
+ * was (re)computed this call.
30
+ */
31
+ refresh(args: {
32
+ walletNow: number;
33
+ fetchers: HlAnchorFetchers;
34
+ now?: number;
35
+ }): Promise<boolean>;
36
+ }
@@ -0,0 +1,114 @@
1
+ // Hyperliquid Day-P&L anchor — the stateful orchestration around the pure
2
+ // reconstruction in `hl-balance.ts`. This is the HL analog of the income-anchor
3
+ // half of Binance's `LiveBalanceEnricher`: HL has no `/fapi/v1/income` endpoint,
4
+ // so `sessionStartNav` (the UTC-midnight NAV baseline) is REBUILT each refresh
5
+ // from `userFillsByTime` + `userFunding` since midnight.
6
+ //
7
+ // ★ WHY THIS EXISTS — the incident it closes (2026-07-24). Before it was wired,
8
+ // the HL adapter's `getBalance()` returned a BARE balance with no
9
+ // `sessionStartNav`/`realizedPnlToday`/`equity`. The skill therefore fell back
10
+ // to self-computing the anchor ONCE per day from `computeEquity()` — a snapshot
11
+ // of equity at whatever instant it first polled, that neither tracked capital
12
+ // flows nor re-anchored to true UTC midnight. On a real micro-live account that
13
+ // printed a phantom **−$91.85 / −29.91% Day P&L** while the account was actually
14
+ // flat (real equity $215, unrealized −$0.19). This is the "T-4 KPI parity vs the
15
+ // HL app" gate (docs/CLAUDE/hyperliquid.md, plan §5.8).
16
+ //
17
+ // ★ SELF-CORRECTING BY CONSTRUCTION. `sessionStartNav = wallet_now − netNonTransfer`
18
+ // is recomputed from scratch every refresh. `netNonTransfer` (Σ closedPnl − fees
19
+ // + funding) EXCLUDES capital flows — a deposit/withdrawal is neither a fill nor
20
+ // funding — so a flow raises `wallet_now` and the anchor by the SAME signed
21
+ // amount and cancels out of Day-P&L automatically, with no incremental ledger
22
+ // bookkeeping that could drift. (`applyLedgerDelta` in hl-balance.ts remains for
23
+ // a possible future incremental path; the recompute makes it unnecessary here.)
24
+ //
25
+ // ★ null ≠ empty. A FAILED fills/funding fetch must NOT recompute the anchor from
26
+ // partial data — that would understate `netNonTransfer` and print exactly the
27
+ // phantom Day-P&L this fixes. On a failed fetch the refresh KEEPS the last good
28
+ // anchor and retries next cycle; the anchor is invariant within a UTC day except
29
+ // for capital flows, so a held value is correct, never fabricated.
30
+ import { logger } from '../../logger.js';
31
+ import { computeNetNonTransfer, deriveSessionStartNav, utcMidnightMs, } from './hl-balance.js';
32
+ const TAG = 'hl-day-anchor';
33
+ /** Recompute cadence. The anchor is invariant within a UTC day except for capital
34
+ * flows, so a modest throttle is plenty; forced on bootstrap + UTC-day rollover.
35
+ * Each refresh costs ~40 IP weight (userFills 20 + userFunding 20) against the
36
+ * 1200/min budget — trivial at heartbeat cadence. */
37
+ const DEFAULT_REFRESH_INTERVAL_MS = 60_000;
38
+ /** UTC day (YYYY-MM-DD) — the Day-P&L rollover key. */
39
+ function utcDayString(ms) {
40
+ return new Date(ms).toISOString().slice(0, 10);
41
+ }
42
+ export class HlDayPnlAnchor {
43
+ refreshIntervalMs;
44
+ sessionStartNav = null;
45
+ realizedPnlToday = 0;
46
+ anchorUtcDay = null;
47
+ lastRefreshMs = 0;
48
+ constructor(refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS) {
49
+ this.refreshIntervalMs = refreshIntervalMs;
50
+ }
51
+ /** `wallet_at_midnight` (+ any capital flows since). `null` until the first
52
+ * successful refresh — the caller then emits nothing rather than a fabricated
53
+ * anchor, and the skill's safe fallback (self-computed) applies. */
54
+ getSessionStartNav() {
55
+ return this.sessionStartNav;
56
+ }
57
+ /** `netNonTransfer` since UTC midnight = Σ closedPnl − fees + funding. Matches
58
+ * the HL app's "today's realized" decomposition (fees + funding included). */
59
+ getRealizedPnlToday() {
60
+ return this.realizedPnlToday;
61
+ }
62
+ /** True when a recompute is due: bootstrap (no anchor yet), UTC-day rollover, or
63
+ * the throttle has elapsed. */
64
+ shouldRefresh(now) {
65
+ if (this.sessionStartNav === null)
66
+ return true;
67
+ if (utcDayString(now) !== this.anchorUtcDay)
68
+ return true;
69
+ return now - this.lastRefreshMs >= this.refreshIntervalMs;
70
+ }
71
+ /**
72
+ * Recompute the anchor from this UTC day's fills + funding. `walletNow` is
73
+ * `nav.wallet` (= accountValue − ΣuPnl). Best-effort — NEVER throws for the
74
+ * caller (a failed fetch keeps the last good anchor). Returns whether the anchor
75
+ * was (re)computed this call.
76
+ */
77
+ async refresh(args) {
78
+ const now = args.now ?? Date.now();
79
+ if (!this.shouldRefresh(now))
80
+ return false;
81
+ const sinceMs = utcMidnightMs(now);
82
+ const [fills, fundings] = await Promise.all([
83
+ args.fetchers.fetchFillsSince(sinceMs),
84
+ args.fetchers.fetchFundingSince(sinceMs),
85
+ ]);
86
+ // null ≠ empty: a failed read must not recompute from partial data. Keep the
87
+ // last good anchor (invariant within the day bar capital flows) and retry.
88
+ if (fills === null || fundings === null) {
89
+ logger.warn(TAG, `anchor refresh skipped — fetch failed (fills=${fills === null ? 'FAIL' : 'ok'}, ` +
90
+ `funding=${fundings === null ? 'FAIL' : 'ok'}); keeping last anchor ` +
91
+ `(sessionStartNav=${this.sessionStartNav ?? 'unset'})`);
92
+ return false;
93
+ }
94
+ const { netNonTransfer, realizedPnlGross, fees, funding } = computeNetNonTransfer({
95
+ fills,
96
+ fundings,
97
+ sinceMs,
98
+ });
99
+ const prev = this.sessionStartNav;
100
+ const today = utcDayString(now);
101
+ const dayRolled = prev !== null && today !== this.anchorUtcDay;
102
+ this.sessionStartNav = deriveSessionStartNav({ walletNow: args.walletNow, netNonTransfer });
103
+ this.realizedPnlToday = netNonTransfer;
104
+ this.anchorUtcDay = today;
105
+ this.lastRefreshMs = now;
106
+ if (prev === null || dayRolled) {
107
+ logger.info(TAG, `Day-P&L anchor ${prev === null ? 'established' : `rolled to ${today}`}: ` +
108
+ `sessionStartNav=${this.sessionStartNav.toFixed(4)} ` +
109
+ `(wallet ${args.walletNow.toFixed(4)} − netNonTransfer ${netNonTransfer.toFixed(4)}; ` +
110
+ `realizedGross ${realizedPnlGross.toFixed(4)}, fees ${fees.toFixed(4)}, funding ${funding.toFixed(4)})`);
111
+ }
112
+ return true;
113
+ }
114
+ }
@@ -30,6 +30,11 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
30
30
  private truthCheckRunning;
31
31
  private _readiness;
32
32
  private openOrdersUnavailableUntil;
33
+ /** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
34
+ * income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
35
+ * each balance fetch. Without this the skill self-computes a bogus anchor and
36
+ * prints a phantom Day P&L (the 2026-07-24 −$91.85 incident). */
37
+ private readonly dayAnchor;
33
38
  constructor(opts: HlLiveAdapterOptions);
34
39
  /** The HL bracket orchestrator — the venue-aware tools (attach_brackets /
35
40
  * modify_stop / modify_target / audit) drive brackets through this. */
@@ -31,6 +31,7 @@ import { HyperliquidInfoCache } from './hl-info-cache.js';
31
31
  import { HyperliquidPublicApi } from './hl-public.js';
32
32
  import { planBracket, planResize, buildBracketOrders, bracketCoversPosition, } from './hl-brackets.js';
33
33
  import { deriveHlNav, toCcxtBalance } from './hl-balance.js';
34
+ import { HlDayPnlAnchor } from './hl-day-anchor.js';
34
35
  import { buildHlOrderCloid, parseHlBracketCloid } from './hl-cloid.js';
35
36
  import { BracketLedger } from '../../live/bracket-ledger.js';
36
37
  import { generateBracketId } from '../../live/bracket-id.js';
@@ -69,6 +70,11 @@ export class HyperliquidLiveAdapter extends EventEmitter {
69
70
  truthCheckRunning = false;
70
71
  _readiness = 'INIT_PENDING';
71
72
  openOrdersUnavailableUntil = 0;
73
+ /** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
74
+ * income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
75
+ * each balance fetch. Without this the skill self-computes a bogus anchor and
76
+ * prints a phantom Day P&L (the 2026-07-24 −$91.85 incident). */
77
+ dayAnchor = new HlDayPnlAnchor();
72
78
  constructor(opts) {
73
79
  super();
74
80
  this.opts = opts;
@@ -467,7 +473,29 @@ export class HyperliquidLiveAdapter extends EventEmitter {
467
473
  if (!nav) {
468
474
  throw new Error('getBalance: clearinghouseState unreadable — balance UNKNOWN (never reported as $0)');
469
475
  }
470
- return toCcxtBalance(nav);
476
+ // Recompute the UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app §5.8).
477
+ // Best-effort + self-correcting: never throws, keeps the last good anchor on a
478
+ // failed fills/funding fetch (null ≠ empty). Emitting sessionStartNav here is
479
+ // what lets the skill's non-PAPER sync (gateway.ts) show a correct Day P&L
480
+ // instead of self-computing a bogus one (the 2026-07-24 −$91.85 incident).
481
+ try {
482
+ await this.dayAnchor.refresh({ walletNow: nav.wallet, fetchers: this.api });
483
+ }
484
+ catch (err) {
485
+ logger.warn(TAG, `day-anchor refresh error (non-fatal): ${msg(err)}`);
486
+ }
487
+ const round4 = (n) => +n.toFixed(4);
488
+ const enriched = { ...toCcxtBalance(nav) };
489
+ const sessionStartNav = this.dayAnchor.getSessionStartNav();
490
+ // Only emit a POSITIVE anchor: the skill's non-PAPER sync gate is
491
+ // `pluginNav > 0`, and a null/0 anchor must fall through to the safe fallback
492
+ // rather than pin Day P&L. `equity` = accountValue (HL app "Account Equity").
493
+ if (sessionStartNav !== null && sessionStartNav > 0) {
494
+ enriched.sessionStartNav = round4(sessionStartNav);
495
+ enriched.realizedPnlToday = round4(this.dayAnchor.getRealizedPnlToday());
496
+ }
497
+ enriched.equity = round4(nav.equity);
498
+ return enriched;
471
499
  }
472
500
  /** Display contract (`?? []`) — the 20+ KPI/display callers. */
473
501
  async getPositions(symbol) {
@@ -1,4 +1,5 @@
1
1
  import type { CcxtOrder, CcxtPosition, CcxtBalance } from '../../types.js';
2
+ import type { HlFillForAnchor, HlFundingForAnchor } from './hl-balance.js';
2
3
  export interface HlCredentials {
3
4
  /** MASTER account address (0x…). Queries always use this — an agent wallet
4
5
  * holds no balance and no positions (learned the hard way 2026-07-12). */
@@ -64,6 +65,21 @@ export declare class HyperliquidPrivateApi {
64
65
  * fills exist server-side — deep history is NOT queryable on HL, which is why
65
66
  * the `trades` table must be WS-first. */
66
67
  fetchMyTrades(symbol?: string, since?: number, limit?: number): Promise<unknown[] | null>;
68
+ /** Fills at/after `sinceMs` — the Day-P&L anchor's realized+fee input
69
+ * (`userFillsByTime`, plan §3.6 / §5.8; weight 20). The raw rows structurally
70
+ * satisfy `HlFillForAnchor` ({time, closedPnl, fee, builderFee?}) so they feed
71
+ * `computeNetNonTransfer` directly. `null` = fetch FAILED (state unknown) — the
72
+ * anchor MUST keep its last good value, NEVER recompute from partial data
73
+ * (`null ≠ empty`; a fabricated anchor prints a phantom Day-P&L). NOTE: HL's
74
+ * `fee` is inclusive of `builderFee`, but we hard-disable the builder fee
75
+ * (`options.builderFee:false`, pinned by hl-private.test.ts) so `builderFee` is
76
+ * absent/0 on our fills and the sum is exact either way. */
77
+ fetchFillsSince(sinceMs: number): Promise<HlFillForAnchor[] | null>;
78
+ /** Funding deltas at/after `sinceMs` — the Day-P&L anchor's funding input
79
+ * (`userFunding`, plan §3.6 / §5.8; weight 20, shed-able). Raw rows satisfy
80
+ * `HlFundingForAnchor` via `delta.usdc` (signed USDC; negative = paid).
81
+ * `null` = fetch FAILED / paced-shed — anchor keeps its last good value. */
82
+ fetchFundingSince(sinceMs: number): Promise<HlFundingForAnchor[] | null>;
67
83
  /** Refresh the ADDRESS action budget (the starvation guard). Weight 20 — call
68
84
  * every ~5 min, never per-heartbeat. */
69
85
  refreshAddressBudget(): Promise<void>;
@@ -206,6 +206,53 @@ export class HyperliquidPrivateApi {
206
206
  return null;
207
207
  }
208
208
  }
209
+ /** Fills at/after `sinceMs` — the Day-P&L anchor's realized+fee input
210
+ * (`userFillsByTime`, plan §3.6 / §5.8; weight 20). The raw rows structurally
211
+ * satisfy `HlFillForAnchor` ({time, closedPnl, fee, builderFee?}) so they feed
212
+ * `computeNetNonTransfer` directly. `null` = fetch FAILED (state unknown) — the
213
+ * anchor MUST keep its last good value, NEVER recompute from partial data
214
+ * (`null ≠ empty`; a fabricated anchor prints a phantom Day-P&L). NOTE: HL's
215
+ * `fee` is inclusive of `builderFee`, but we hard-disable the builder fee
216
+ * (`options.builderFee:false`, pinned by hl-private.test.ts) so `builderFee` is
217
+ * absent/0 on our fills and the sum is exact either way. */
218
+ async fetchFillsSince(sinceMs) {
219
+ try {
220
+ assertNotLimited('userFills');
221
+ const raw = await this.rawInfo({
222
+ type: 'userFillsByTime',
223
+ user: this.creds.walletAddress,
224
+ startTime: Math.floor(sinceMs),
225
+ });
226
+ noteSuccess('userFills');
227
+ return Array.isArray(raw) ? raw : null;
228
+ }
229
+ catch (err) {
230
+ noteError(err, 'fetchFillsSince');
231
+ logger.warn(TAG, `fetchFillsSince failed: ${msg(err)}`);
232
+ return null;
233
+ }
234
+ }
235
+ /** Funding deltas at/after `sinceMs` — the Day-P&L anchor's funding input
236
+ * (`userFunding`, plan §3.6 / §5.8; weight 20, shed-able). Raw rows satisfy
237
+ * `HlFundingForAnchor` via `delta.usdc` (signed USDC; negative = paid).
238
+ * `null` = fetch FAILED / paced-shed — anchor keeps its last good value. */
239
+ async fetchFundingSince(sinceMs) {
240
+ try {
241
+ assertNotLimited('userFunding');
242
+ const raw = await this.rawInfo({
243
+ type: 'userFunding',
244
+ user: this.creds.walletAddress,
245
+ startTime: Math.floor(sinceMs),
246
+ });
247
+ noteSuccess('userFunding');
248
+ return Array.isArray(raw) ? raw : null;
249
+ }
250
+ catch (err) {
251
+ noteError(err, 'fetchFundingSince');
252
+ logger.warn(TAG, `fetchFundingSince failed: ${msg(err)}`);
253
+ return null;
254
+ }
255
+ }
209
256
  /** Refresh the ADDRESS action budget (the starvation guard). Weight 20 — call
210
257
  * every ~5 min, never per-heartbeat. */
211
258
  async refreshAddressBudget() {
@@ -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. */