@reefclaw/openclaw-plugin 0.1.12 → 0.1.13
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 +19 -0
- package/bridge/gateway/event-parser.js +52 -0
- package/bridge/gateway/heartbeat-cron.d.ts +1 -0
- package/bridge/gateway/heartbeat-cron.js +22 -1
- package/bridge/providers/gateway.d.ts +49 -0
- package/bridge/providers/gateway.js +162 -12
- package/bridge/types.d.ts +30 -0
- package/bridge/utils/identity-name.d.ts +24 -0
- package/bridge/utils/identity-name.js +54 -0
- package/ccxt/binance-public.d.ts +4 -2
- package/ccxt/binance-public.js +39 -3
- package/live/proposal-decision-listener.d.ts +20 -0
- package/live/proposal-decision-listener.js +211 -48
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/tools/create-order.d.ts +11 -0
- package/tools/create-order.js +23 -2
- package/tools/get-risk-summary.d.ts +4 -0
- package/tools/get-risk-summary.js +62 -23
- package/venues/hyperliquid/hl-bracket-coordinator.d.ts +4 -0
- package/venues/hyperliquid/hl-bracket-coordinator.js +2 -2
- package/venues/hyperliquid/hl-live-adapter.d.ts +4 -0
- package/venues/hyperliquid/hl-live-adapter.js +43 -5
- package/venues/hyperliquid/hl-order.d.ts +35 -0
- package/venues/hyperliquid/hl-order.js +123 -0
- package/venues/hyperliquid/hl-position.d.ts +36 -0
- package/venues/hyperliquid/hl-position.js +127 -0
- package/venues/hyperliquid/hl-private.d.ts +20 -3
- package/venues/hyperliquid/hl-private.js +37 -6
|
@@ -67,6 +67,25 @@ export declare function mapCcxtType(ccxtType: string): 'MARKET' | 'LIMIT';
|
|
|
67
67
|
* real protective signals; a plain LIMIT/MARKET entry has none → false.
|
|
68
68
|
*/
|
|
69
69
|
export declare function isProtectiveOrder(ccxt: CcxtOrder): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* The trigger price of a stop / take-profit order, wherever the venue put it.
|
|
72
|
+
*
|
|
73
|
+
* Verified against live payloads: Binance USD-M Futures uses unified
|
|
74
|
+
* `stopPrice` (raw `info.stopPrice`); Hyperliquid's `frontendOpenOrders` rows
|
|
75
|
+
* carry `info.triggerPx` alongside `isTrigger: true` and
|
|
76
|
+
* `orderType: 'Stop Market' | 'Take Profit Market'`, which ccxt surfaces as
|
|
77
|
+
* `triggerPrice`.
|
|
78
|
+
*/
|
|
79
|
+
export declare function extractTriggerPrice(ccxt: CcxtOrder): number | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Which protective leg an order is, or undefined when it is a working order.
|
|
82
|
+
*
|
|
83
|
+
* Venue type strings differ ('STOP_MARKET' / 'TAKE_PROFIT_MARKET' on Binance,
|
|
84
|
+
* 'Stop Market' / 'Take Profit Market' on Hyperliquid), so normalise here and
|
|
85
|
+
* let every consumer read one enum. Take-profit is tested FIRST because
|
|
86
|
+
* 'TAKE_PROFIT_MARKET' must never fall into a substring test for 'STOP'.
|
|
87
|
+
*/
|
|
88
|
+
export declare function classifyProtectiveRole(ccxt: CcxtOrder): 'stop' | 'target' | undefined;
|
|
70
89
|
export declare function mapCcxtOrder(ccxt: CcxtOrder): OrderData | null;
|
|
71
90
|
/**
|
|
72
91
|
* Build a FillData from a CCXT order (when the order has fills).
|
|
@@ -52,6 +52,49 @@ export function isProtectiveOrder(ccxt) {
|
|
|
52
52
|
return true;
|
|
53
53
|
return false;
|
|
54
54
|
}
|
|
55
|
+
/** Positive finite number from a unified field or a raw string (venues send both). */
|
|
56
|
+
function positiveNum(v) {
|
|
57
|
+
const n = typeof v === 'string' ? Number(v) : v;
|
|
58
|
+
return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The trigger price of a stop / take-profit order, wherever the venue put it.
|
|
62
|
+
*
|
|
63
|
+
* Verified against live payloads: Binance USD-M Futures uses unified
|
|
64
|
+
* `stopPrice` (raw `info.stopPrice`); Hyperliquid's `frontendOpenOrders` rows
|
|
65
|
+
* carry `info.triggerPx` alongside `isTrigger: true` and
|
|
66
|
+
* `orderType: 'Stop Market' | 'Take Profit Market'`, which ccxt surfaces as
|
|
67
|
+
* `triggerPrice`.
|
|
68
|
+
*/
|
|
69
|
+
export function extractTriggerPrice(ccxt) {
|
|
70
|
+
const o = ccxt;
|
|
71
|
+
return (positiveNum(o.stopPrice) ??
|
|
72
|
+
positiveNum(o.triggerPrice) ??
|
|
73
|
+
positiveNum(o.info?.stopPrice) ??
|
|
74
|
+
positiveNum(o.info?.triggerPx));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Which protective leg an order is, or undefined when it is a working order.
|
|
78
|
+
*
|
|
79
|
+
* Venue type strings differ ('STOP_MARKET' / 'TAKE_PROFIT_MARKET' on Binance,
|
|
80
|
+
* 'Stop Market' / 'Take Profit Market' on Hyperliquid), so normalise here and
|
|
81
|
+
* let every consumer read one enum. Take-profit is tested FIRST because
|
|
82
|
+
* 'TAKE_PROFIT_MARKET' must never fall into a substring test for 'STOP'.
|
|
83
|
+
*/
|
|
84
|
+
export function classifyProtectiveRole(ccxt) {
|
|
85
|
+
if (!isProtectiveOrder(ccxt))
|
|
86
|
+
return undefined;
|
|
87
|
+
const o = ccxt;
|
|
88
|
+
const rawType = String(o.info?.origType ?? o.info?.orderType ?? o.info?.type ?? o.type ?? '').toUpperCase();
|
|
89
|
+
if (rawType.includes('TAKE_PROFIT') || rawType.includes('TAKE PROFIT'))
|
|
90
|
+
return 'target';
|
|
91
|
+
if (rawType.includes('STOP'))
|
|
92
|
+
return 'stop';
|
|
93
|
+
// A resting reduce-only LIMIT with no trigger is a take-profit (split-TP legs).
|
|
94
|
+
if (String(o.type ?? '').toLowerCase() === 'limit')
|
|
95
|
+
return 'target';
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
55
98
|
export function mapCcxtOrder(ccxt) {
|
|
56
99
|
if (!ccxt.id || typeof ccxt.id !== 'string') {
|
|
57
100
|
logger.warn(TAG, `CCXT order missing valid ID: ${JSON.stringify(ccxt.id)}`);
|
|
@@ -68,6 +111,9 @@ export function mapCcxtOrder(ccxt) {
|
|
|
68
111
|
const now = new Date().toISOString();
|
|
69
112
|
const ts = ccxt.datetime ?? (ccxt.timestamp ? new Date(ccxt.timestamp).toISOString() : now);
|
|
70
113
|
const protective = isProtectiveOrder(ccxt);
|
|
114
|
+
const price = positiveNum(ccxt.price);
|
|
115
|
+
const stopPrice = extractTriggerPrice(ccxt);
|
|
116
|
+
const protectiveRole = classifyProtectiveRole(ccxt);
|
|
71
117
|
return {
|
|
72
118
|
id: ccxt.id,
|
|
73
119
|
symbol: ccxt.symbol,
|
|
@@ -81,6 +127,12 @@ export function mapCcxtOrder(ccxt) {
|
|
|
81
127
|
createdAt: ts,
|
|
82
128
|
updatedAt: ts,
|
|
83
129
|
...(protective && { protective: true }),
|
|
130
|
+
// Prices the dashboard draws its stop/target lines from. A resting leg is
|
|
131
|
+
// exchange truth and exists on every venue, so it is the source that
|
|
132
|
+
// survives a missing ledger row or a venue with no metadata map.
|
|
133
|
+
...(price !== undefined && { price }),
|
|
134
|
+
...(stopPrice !== undefined && { stopPrice }),
|
|
135
|
+
...(protectiveRole && { protectiveRole }),
|
|
84
136
|
};
|
|
85
137
|
}
|
|
86
138
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare const HEARTBEAT_CRON_NAME = "reefclaw-heartbeat";
|
|
2
2
|
export declare const HEARTBEAT_EVERY_MS: number;
|
|
3
|
+
export declare const HEARTBEAT_TIMEOUT_SECONDS = 780;
|
|
3
4
|
export declare const HEARTBEAT_MESSAGE: string;
|
|
4
5
|
export interface CronRpcClient {
|
|
5
6
|
sendRpc(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
@@ -16,7 +16,18 @@
|
|
|
16
16
|
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
17
17
|
import { dirname } from 'path';
|
|
18
18
|
export const HEARTBEAT_CRON_NAME = 'reefclaw-heartbeat';
|
|
19
|
+
// 15m is the default. A disciplined beat (early-exit on no-trade + trading
|
|
20
|
+
// tools only — see SKILL.md) finishes in a couple of minutes even on small
|
|
21
|
+
// models, so 15m is comfortable; the 2026-07-24 timeouts were caused by the
|
|
22
|
+
// agent's "coding" tool profile making it WANDER (a 9-min beat), not by the
|
|
23
|
+
// cadence — the funnel + tool discipline are the fix, not a slower schedule.
|
|
24
|
+
// Existing installs keep their cadence (once-per-install marker); operators
|
|
25
|
+
// change it any time via `openclaw cron edit <id> --every <duration>`.
|
|
19
26
|
export const HEARTBEAT_EVERY_MS = 15 * 60 * 1000;
|
|
27
|
+
// Explicit per-run cap. The server does NOT default payload.timeoutSeconds
|
|
28
|
+
// (verified empirically on 2026.7.1-2: a cron.add without it stores none) —
|
|
29
|
+
// 780s is the field-proven value from the 2026-07-22 small-box mitigations.
|
|
30
|
+
export const HEARTBEAT_TIMEOUT_SECONDS = 780;
|
|
20
31
|
// Byte-identical to the message the setup-time CLI path created, so new jobs
|
|
21
32
|
// match every existing install's job.
|
|
22
33
|
export const HEARTBEAT_MESSAGE = 'Heartbeat. Run SESSION START mandatory checks, then the full Decision Loop (Steps 0-9) from SKILL.md. ' +
|
|
@@ -74,7 +85,17 @@ export async function ensureHeartbeatCron(opts) {
|
|
|
74
85
|
schedule: { kind: 'every', everyMs: HEARTBEAT_EVERY_MS },
|
|
75
86
|
sessionTarget: 'isolated',
|
|
76
87
|
wakeMode: 'now',
|
|
77
|
-
payload
|
|
88
|
+
// payload.timeoutSeconds is the stored shape (verified via the CLI's own
|
|
89
|
+
// cron.add on 2026.7.1-2). No toolsAllow: absent = all-tools-allowed,
|
|
90
|
+
// which is future-proof — an explicit allowlist freezes at creation and
|
|
91
|
+
// silently excludes every plugin tool shipped later. Beat tool
|
|
92
|
+
// discipline (no subagents/web/media during heartbeats) lives in
|
|
93
|
+
// SKILL.md, which updates on every bridge start.
|
|
94
|
+
payload: {
|
|
95
|
+
kind: 'agentTurn',
|
|
96
|
+
message: HEARTBEAT_MESSAGE,
|
|
97
|
+
timeoutSeconds: HEARTBEAT_TIMEOUT_SECONDS,
|
|
98
|
+
},
|
|
78
99
|
};
|
|
79
100
|
let delivery = 'announce';
|
|
80
101
|
try {
|
|
@@ -40,12 +40,21 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
40
40
|
private agentIdentity;
|
|
41
41
|
/** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
|
|
42
42
|
private loggedAgentIdentityProbe;
|
|
43
|
+
/** Last IDENTITY.md read (epoch ms) — TTL gate for the display-name re-read. */
|
|
44
|
+
private agentNameReadAtMs;
|
|
43
45
|
/** One-shot latch for the boot-time heartbeat-cron ensure (re-armed on failure). */
|
|
44
46
|
private heartbeatCronEnsureStarted;
|
|
45
47
|
/** Live heartbeat cadence (seconds), read from the OpenClaw cron store; cached 60s. */
|
|
46
48
|
private heartbeatSeconds?;
|
|
47
49
|
private heartbeatReadAtMs;
|
|
48
50
|
private loggedHeartbeat;
|
|
51
|
+
/** Heartbeat-job health read from `cron.list` (RPC — the filesystem jobs.json
|
|
52
|
+
* is absent on 2026.7.x). Surfaced so the dashboard shows a failing/stale
|
|
53
|
+
* beat instead of a silent gap (a beat failed 10× over ~2 days undetected,
|
|
54
|
+
* 2026-07-24). undefined until first resolved; refreshed lazily (120s). */
|
|
55
|
+
private heartbeatHealth?;
|
|
56
|
+
private heartbeatHealthReadAtMs;
|
|
57
|
+
private heartbeatHealthRefreshing;
|
|
49
58
|
private lastTicker;
|
|
50
59
|
private positions;
|
|
51
60
|
private balance;
|
|
@@ -107,6 +116,13 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
107
116
|
private currentRegime;
|
|
108
117
|
/** Timestamp of the most recent trade (fill event) — used in buildAgentState */
|
|
109
118
|
private lastTradeTs;
|
|
119
|
+
/** Epoch ms of the most recent LIVE fill observed this process (issue #248).
|
|
120
|
+
* Deliberately separate from `lastTradeTs`: that one is a display string and
|
|
121
|
+
* is also seeded from the historical trades ledger at startup, which must
|
|
122
|
+
* NOT count here — this field exists solely to prove whether the current
|
|
123
|
+
* `positions` snapshot predates a fill, and a historical seed would make an
|
|
124
|
+
* untorn snapshot look torn forever. 0 = no fill seen yet this process. */
|
|
125
|
+
private lastFillAt;
|
|
110
126
|
/** Tracks open position entries for computing trade results on close.
|
|
111
127
|
* Key: `symbol:side` (e.g. "BTC/USDT:long"), Value: { entryPrice, quantity, side, entryTime, missionId?, setupType?, regime?, confluenceScore? } */
|
|
112
128
|
private openTradeEntries;
|
|
@@ -274,6 +290,24 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
274
290
|
* so the dashboard updates without waiting for the next periodic tick.
|
|
275
291
|
*/
|
|
276
292
|
private refreshAgentIdentity;
|
|
293
|
+
/**
|
|
294
|
+
* Read the heartbeat cron job's health from `cron.list` (RPC — the
|
|
295
|
+
* filesystem `jobs.json` the cadence reader uses is absent on 2026.7.x).
|
|
296
|
+
* Extracts `state.consecutiveErrors` + last-run info from the heartbeat-like
|
|
297
|
+
* job and caches it. Best-effort, fail-open (a version/scope that hides
|
|
298
|
+
* cron.list simply leaves health undefined → the dashboard shows nothing new,
|
|
299
|
+
* byte-identical to before). Re-emits agent_state when the value changes so a
|
|
300
|
+
* newly-failing beat surfaces without waiting for the next tick.
|
|
301
|
+
*
|
|
302
|
+
* Why this matters: on 2026-07-24 a customer's beat failed 10× over ~2 days
|
|
303
|
+
* (bad delivery target, then a heap crash-loop) and NOTHING surfaced it —
|
|
304
|
+
* `consecutiveErrors` was sitting right here in the cron state the whole time.
|
|
305
|
+
*/
|
|
306
|
+
private refreshHeartbeatHealth;
|
|
307
|
+
/** Kick a lazy heartbeat-health refresh when the cache is stale and no
|
|
308
|
+
* refresh is in flight. Fire-and-forget: the RPC completion re-emits
|
|
309
|
+
* agent_state, so callers stay synchronous. */
|
|
310
|
+
private maybeRefreshHeartbeatHealth;
|
|
277
311
|
/** Merge newly-resolved identity fields into the cache. Returns true if anything changed. */
|
|
278
312
|
private mergeAgentIdentity;
|
|
279
313
|
/**
|
|
@@ -294,6 +328,21 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
294
328
|
* unfilled placeholder, or the value looks like an id rather than a name.
|
|
295
329
|
*/
|
|
296
330
|
private resolveAgentNameFromIdentity;
|
|
331
|
+
/**
|
|
332
|
+
* Keep the display name fresh WITHOUT waiting for a reconnect.
|
|
333
|
+
*
|
|
334
|
+
* Naming the agent is a first-conversation ritual, so IDENTITY.md is usually
|
|
335
|
+
* written minutes-to-days AFTER the bridge connected. Resolving only on
|
|
336
|
+
* (re)connect meant the header served the unfilled template for the whole
|
|
337
|
+
* onboarding session (2026-07-26: rig showed `(pick something you like)` for
|
|
338
|
+
* ~24h after the agent wrote `Rook`). This is a small local file read, so a
|
|
339
|
+
* 60s TTL keeps it honest at negligible cost — unlike the model, which needs
|
|
340
|
+
* an RPC and legitimately only changes across a gateway restart.
|
|
341
|
+
*
|
|
342
|
+
* Called from buildAgentState(), so a newly-resolved name rides the payload
|
|
343
|
+
* being built right now — no extra emit needed.
|
|
344
|
+
*/
|
|
345
|
+
private maybeRefreshAgentName;
|
|
297
346
|
/**
|
|
298
347
|
* Live heartbeat cadence in seconds — the agent's TRUE beat interval, read
|
|
299
348
|
* from the OpenClaw cron store (`~/.openclaw/cron/jobs.json` → the enabled
|
|
@@ -11,7 +11,8 @@ import { GatewayWsClient } from '../gateway/gateway-ws-client.js';
|
|
|
11
11
|
import { discoverTools } from '../gateway/tool-discovery.js';
|
|
12
12
|
import { EventParser, mapCcxtBalance, mapCcxtOrder, extractLiquidationFields, extractBracketField, extractPlannedLevels, } from '../gateway/event-parser.js';
|
|
13
13
|
import { Poller } from '../gateway/poller.js';
|
|
14
|
-
import { ensureHeartbeatCron } from '../gateway/heartbeat-cron.js';
|
|
14
|
+
import { ensureHeartbeatCron, isHeartbeatLikeName } from '../gateway/heartbeat-cron.js';
|
|
15
|
+
import { parseIdentityName } from '../utils/identity-name.js';
|
|
15
16
|
import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, } from './risk-calculator.js';
|
|
16
17
|
import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, } from './emergency-commands.js';
|
|
17
18
|
import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, } from './onboarding-commands.js';
|
|
@@ -130,6 +131,8 @@ function saveSessionStartNav(nav) {
|
|
|
130
131
|
}
|
|
131
132
|
// ---- Agent state emission interval ----
|
|
132
133
|
const AGENT_STATE_INTERVAL_MS = 5_000;
|
|
134
|
+
/** How often IDENTITY.md is re-read for the agent's display name (local file read). */
|
|
135
|
+
const AGENT_NAME_TTL_MS = 60_000;
|
|
133
136
|
// ---- GatewayProvider ----
|
|
134
137
|
export class GatewayProvider {
|
|
135
138
|
config;
|
|
@@ -154,12 +157,21 @@ export class GatewayProvider {
|
|
|
154
157
|
agentIdentity = {};
|
|
155
158
|
/** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
|
|
156
159
|
loggedAgentIdentityProbe = false;
|
|
160
|
+
/** Last IDENTITY.md read (epoch ms) — TTL gate for the display-name re-read. */
|
|
161
|
+
agentNameReadAtMs = 0;
|
|
157
162
|
/** One-shot latch for the boot-time heartbeat-cron ensure (re-armed on failure). */
|
|
158
163
|
heartbeatCronEnsureStarted = false;
|
|
159
164
|
/** Live heartbeat cadence (seconds), read from the OpenClaw cron store; cached 60s. */
|
|
160
165
|
heartbeatSeconds;
|
|
161
166
|
heartbeatReadAtMs = 0;
|
|
162
167
|
loggedHeartbeat = false;
|
|
168
|
+
/** Heartbeat-job health read from `cron.list` (RPC — the filesystem jobs.json
|
|
169
|
+
* is absent on 2026.7.x). Surfaced so the dashboard shows a failing/stale
|
|
170
|
+
* beat instead of a silent gap (a beat failed 10× over ~2 days undetected,
|
|
171
|
+
* 2026-07-24). undefined until first resolved; refreshed lazily (120s). */
|
|
172
|
+
heartbeatHealth;
|
|
173
|
+
heartbeatHealthReadAtMs = 0;
|
|
174
|
+
heartbeatHealthRefreshing = false;
|
|
163
175
|
lastTicker = null;
|
|
164
176
|
positions = [];
|
|
165
177
|
balance = { currency: 'USDT', total: 0, available: 0, locked: 0 };
|
|
@@ -230,6 +242,13 @@ export class GatewayProvider {
|
|
|
230
242
|
// ---- Trade tracking ----
|
|
231
243
|
/** Timestamp of the most recent trade (fill event) — used in buildAgentState */
|
|
232
244
|
lastTradeTs = null;
|
|
245
|
+
/** Epoch ms of the most recent LIVE fill observed this process (issue #248).
|
|
246
|
+
* Deliberately separate from `lastTradeTs`: that one is a display string and
|
|
247
|
+
* is also seeded from the historical trades ledger at startup, which must
|
|
248
|
+
* NOT count here — this field exists solely to prove whether the current
|
|
249
|
+
* `positions` snapshot predates a fill, and a historical seed would make an
|
|
250
|
+
* untorn snapshot look torn forever. 0 = no fill seen yet this process. */
|
|
251
|
+
lastFillAt = 0;
|
|
233
252
|
/** Tracks open position entries for computing trade results on close.
|
|
234
253
|
* Key: `symbol:side` (e.g. "BTC/USDT:long"), Value: { entryPrice, quantity, side, entryTime, missionId?, setupType?, regime?, confluenceScore? } */
|
|
235
254
|
openTradeEntries = new Map();
|
|
@@ -1152,6 +1171,10 @@ export class GatewayProvider {
|
|
|
1152
1171
|
// Track last trade timestamp for agent state
|
|
1153
1172
|
if (data.status === 'FILLED') {
|
|
1154
1173
|
this.lastTradeTs = data.timestamp ?? new Date().toISOString();
|
|
1174
|
+
// A fill debits the paper wallet immediately; `positions` catches up
|
|
1175
|
+
// on its own poll. Record when, so the RED-zone guard can tell a torn
|
|
1176
|
+
// wallet/positions snapshot from a real drawdown (issue #248).
|
|
1177
|
+
this.lastFillAt = Date.now();
|
|
1155
1178
|
}
|
|
1156
1179
|
}
|
|
1157
1180
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -1423,6 +1446,10 @@ export class GatewayProvider {
|
|
|
1423
1446
|
};
|
|
1424
1447
|
logger.info(TAG, `Synthetic fill: ${side} ${quantity} ${pos.symbol} @ ${fillPrice} (from position poll)`);
|
|
1425
1448
|
this.lastTradeTs = now;
|
|
1449
|
+
// Same reasoning as the orderUpdate path (issue #248) — this one is derived
|
|
1450
|
+
// FROM a positions poll, so the snapshot already covers it and the guard
|
|
1451
|
+
// clears on the next refresh rather than latching.
|
|
1452
|
+
this.lastFillAt = Date.now();
|
|
1426
1453
|
this.fire('orderUpdate', payload);
|
|
1427
1454
|
// Track open entries for trade result reporting
|
|
1428
1455
|
const tradeSide = pos.side === 'short' ? 'short' : 'long';
|
|
@@ -1799,6 +1826,70 @@ export class GatewayProvider {
|
|
|
1799
1826
|
this.emitAgentState();
|
|
1800
1827
|
}
|
|
1801
1828
|
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Read the heartbeat cron job's health from `cron.list` (RPC — the
|
|
1831
|
+
* filesystem `jobs.json` the cadence reader uses is absent on 2026.7.x).
|
|
1832
|
+
* Extracts `state.consecutiveErrors` + last-run info from the heartbeat-like
|
|
1833
|
+
* job and caches it. Best-effort, fail-open (a version/scope that hides
|
|
1834
|
+
* cron.list simply leaves health undefined → the dashboard shows nothing new,
|
|
1835
|
+
* byte-identical to before). Re-emits agent_state when the value changes so a
|
|
1836
|
+
* newly-failing beat surfaces without waiting for the next tick.
|
|
1837
|
+
*
|
|
1838
|
+
* Why this matters: on 2026-07-24 a customer's beat failed 10× over ~2 days
|
|
1839
|
+
* (bad delivery target, then a heap crash-loop) and NOTHING surfaced it —
|
|
1840
|
+
* `consecutiveErrors` was sitting right here in the cron state the whole time.
|
|
1841
|
+
*/
|
|
1842
|
+
async refreshHeartbeatHealth() {
|
|
1843
|
+
const ws = this.wsClient;
|
|
1844
|
+
if (!ws)
|
|
1845
|
+
return;
|
|
1846
|
+
this.heartbeatHealthRefreshing = true;
|
|
1847
|
+
try {
|
|
1848
|
+
const resp = await ws.sendRpc('cron.list', { includeDisabled: true });
|
|
1849
|
+
const jobs = resp?.jobs;
|
|
1850
|
+
if (!Array.isArray(jobs))
|
|
1851
|
+
return; // unknown shape — leave last-known
|
|
1852
|
+
const hb = jobs.find((j) => isHeartbeatLikeName(j?.name));
|
|
1853
|
+
const state = hb?.state;
|
|
1854
|
+
if (!state || typeof state !== 'object')
|
|
1855
|
+
return;
|
|
1856
|
+
const s = state;
|
|
1857
|
+
const next = {
|
|
1858
|
+
consecutiveErrors: typeof s.consecutiveErrors === 'number' && s.consecutiveErrors >= 0 ? s.consecutiveErrors : 0,
|
|
1859
|
+
lastRunAtMs: typeof s.lastRunAtMs === 'number' ? s.lastRunAtMs : undefined,
|
|
1860
|
+
lastRunStatus: typeof s.lastRunStatus === 'string' ? s.lastRunStatus : undefined,
|
|
1861
|
+
};
|
|
1862
|
+
const prev = this.heartbeatHealth;
|
|
1863
|
+
const changed = !prev ||
|
|
1864
|
+
prev.consecutiveErrors !== next.consecutiveErrors ||
|
|
1865
|
+
prev.lastRunAtMs !== next.lastRunAtMs ||
|
|
1866
|
+
prev.lastRunStatus !== next.lastRunStatus;
|
|
1867
|
+
this.heartbeatHealth = next;
|
|
1868
|
+
this.heartbeatHealthReadAtMs = Date.now();
|
|
1869
|
+
if (changed) {
|
|
1870
|
+
if (next.consecutiveErrors > 0) {
|
|
1871
|
+
logger.warn(TAG, `Heartbeat health: ${next.consecutiveErrors} consecutive error(s), lastStatus=${next.lastRunStatus ?? 'unknown'}`);
|
|
1872
|
+
}
|
|
1873
|
+
this.emitAgentState();
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
catch {
|
|
1877
|
+
/* cron.list unavailable (version/scope) — fail open, keep last-known */
|
|
1878
|
+
}
|
|
1879
|
+
finally {
|
|
1880
|
+
this.heartbeatHealthRefreshing = false;
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
/** Kick a lazy heartbeat-health refresh when the cache is stale and no
|
|
1884
|
+
* refresh is in flight. Fire-and-forget: the RPC completion re-emits
|
|
1885
|
+
* agent_state, so callers stay synchronous. */
|
|
1886
|
+
maybeRefreshHeartbeatHealth() {
|
|
1887
|
+
if (this.heartbeatHealthRefreshing)
|
|
1888
|
+
return;
|
|
1889
|
+
if (Date.now() - this.heartbeatHealthReadAtMs < 120_000)
|
|
1890
|
+
return;
|
|
1891
|
+
void this.refreshHeartbeatHealth();
|
|
1892
|
+
}
|
|
1802
1893
|
/** Merge newly-resolved identity fields into the cache. Returns true if anything changed. */
|
|
1803
1894
|
mergeAgentIdentity(found) {
|
|
1804
1895
|
let changed = false;
|
|
@@ -1856,15 +1947,9 @@ export class GatewayProvider {
|
|
|
1856
1947
|
].filter((p) => !!p);
|
|
1857
1948
|
for (const p of candidates) {
|
|
1858
1949
|
try {
|
|
1859
|
-
const
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
if (!name)
|
|
1863
|
-
continue;
|
|
1864
|
-
name = name.replace(/^[_*`]+|[_*`]+$/g, '').trim(); // strip stray markdown emphasis
|
|
1865
|
-
if (!name || /^\d+$/.test(name) || /\btbd\b|fill this in/i.test(name))
|
|
1866
|
-
continue;
|
|
1867
|
-
return name;
|
|
1950
|
+
const name = parseIdentityName(readFileSync(p, 'utf8'));
|
|
1951
|
+
if (name)
|
|
1952
|
+
return name;
|
|
1868
1953
|
}
|
|
1869
1954
|
catch {
|
|
1870
1955
|
/* not found / unreadable — try the next candidate */
|
|
@@ -1872,6 +1957,31 @@ export class GatewayProvider {
|
|
|
1872
1957
|
}
|
|
1873
1958
|
return undefined;
|
|
1874
1959
|
}
|
|
1960
|
+
/**
|
|
1961
|
+
* Keep the display name fresh WITHOUT waiting for a reconnect.
|
|
1962
|
+
*
|
|
1963
|
+
* Naming the agent is a first-conversation ritual, so IDENTITY.md is usually
|
|
1964
|
+
* written minutes-to-days AFTER the bridge connected. Resolving only on
|
|
1965
|
+
* (re)connect meant the header served the unfilled template for the whole
|
|
1966
|
+
* onboarding session (2026-07-26: rig showed `(pick something you like)` for
|
|
1967
|
+
* ~24h after the agent wrote `Rook`). This is a small local file read, so a
|
|
1968
|
+
* 60s TTL keeps it honest at negligible cost — unlike the model, which needs
|
|
1969
|
+
* an RPC and legitimately only changes across a gateway restart.
|
|
1970
|
+
*
|
|
1971
|
+
* Called from buildAgentState(), so a newly-resolved name rides the payload
|
|
1972
|
+
* being built right now — no extra emit needed.
|
|
1973
|
+
*/
|
|
1974
|
+
maybeRefreshAgentName() {
|
|
1975
|
+
const now = Date.now();
|
|
1976
|
+
if (now - this.agentNameReadAtMs < AGENT_NAME_TTL_MS)
|
|
1977
|
+
return;
|
|
1978
|
+
this.agentNameReadAtMs = now;
|
|
1979
|
+
const name = this.resolveAgentNameFromIdentity();
|
|
1980
|
+
// Fail-open: an unreadable/placeholder file keeps the last known good name.
|
|
1981
|
+
if (name && this.mergeAgentIdentity({ name })) {
|
|
1982
|
+
logger.info(TAG, `Agent identity updated: name=${name} (IDENTITY.md re-read)`);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1875
1985
|
/**
|
|
1876
1986
|
* Live heartbeat cadence in seconds — the agent's TRUE beat interval, read
|
|
1877
1987
|
* from the OpenClaw cron store (`~/.openclaw/cron/jobs.json` → the enabled
|
|
@@ -1934,6 +2044,11 @@ export class GatewayProvider {
|
|
|
1934
2044
|
}
|
|
1935
2045
|
/** Build agent state from internal fields (used by both emitAgentState and getSnapshot). */
|
|
1936
2046
|
buildAgentState() {
|
|
2047
|
+
// Lazily keep the heartbeat-failure signal fresh (fire-and-forget, 120s).
|
|
2048
|
+
this.maybeRefreshHeartbeatHealth();
|
|
2049
|
+
// Lazily re-read IDENTITY.md (60s) so a name filled in after connect shows up.
|
|
2050
|
+
this.maybeRefreshAgentName();
|
|
2051
|
+
const hb = this.heartbeatHealth;
|
|
1937
2052
|
return {
|
|
1938
2053
|
mode: this.agentMode,
|
|
1939
2054
|
strategy: this.lastKnownStrategy,
|
|
@@ -1948,6 +2063,18 @@ export class GatewayProvider {
|
|
|
1948
2063
|
errorRate: this.eventParser?.getErrorRate() ?? 0,
|
|
1949
2064
|
thinkingInterval: this.resolveHeartbeatSeconds() ?? 1800,
|
|
1950
2065
|
gatewayWs: this.wsClient?.getState() ?? 'disconnected',
|
|
2066
|
+
// Conditional spread: absent until cron.list resolves the beat's state,
|
|
2067
|
+
// so the payload stays byte-identical to the pre-feature shape when the
|
|
2068
|
+
// gateway/version doesn't expose it.
|
|
2069
|
+
...(hb
|
|
2070
|
+
? {
|
|
2071
|
+
heartbeatHealth: {
|
|
2072
|
+
consecutiveErrors: hb.consecutiveErrors,
|
|
2073
|
+
...(hb.lastRunAtMs !== undefined ? { lastRunAtMs: hb.lastRunAtMs } : {}),
|
|
2074
|
+
...(hb.lastRunStatus !== undefined ? { lastRunStatus: hb.lastRunStatus } : {}),
|
|
2075
|
+
},
|
|
2076
|
+
}
|
|
2077
|
+
: {}),
|
|
1951
2078
|
},
|
|
1952
2079
|
};
|
|
1953
2080
|
}
|
|
@@ -1994,12 +2121,34 @@ export class GatewayProvider {
|
|
|
1994
2121
|
const IMPOSSIBLE_DRAWDOWN = -0.5;
|
|
1995
2122
|
const REQUIRED_RED_STREAK = 3;
|
|
1996
2123
|
const MIN_RED_PERSISTENCE_MS = 25_000;
|
|
2124
|
+
/** Ceiling on the issue #248 torn-snapshot deferral — see below. */
|
|
2125
|
+
const MAX_TORN_SNAPSHOT_DEFER_MS = 5 * 60_000;
|
|
1997
2126
|
if (metrics.drawdownZone === 'RED' && this.agentMode === 'ACTIVE') {
|
|
1998
2127
|
if (this.redZoneStreak === 0)
|
|
1999
2128
|
this.redZoneSince = Date.now();
|
|
2000
2129
|
this.redZoneStreak++;
|
|
2001
2130
|
const redForMs = Date.now() - this.redZoneSince;
|
|
2002
2131
|
const survivedPositionsRefresh = this.positionsUpdatedAt > this.redZoneSince;
|
|
2132
|
+
// ★ issue #248: `survivedPositionsRefresh` proves a refresh HAPPENED, not
|
|
2133
|
+
// that it INCORPORATED the fill that debited the wallet. When a fill has
|
|
2134
|
+
// landed since the last positions refresh the snapshot is torn BY
|
|
2135
|
+
// DEFINITION — paper deducts full notional at open, so equity is
|
|
2136
|
+
// understated by exactly that notional and any entry >2.5% of NAV reads
|
|
2137
|
+
// as RED. Measured on a live box: entries of 8.25% / 8.11% of NAV
|
|
2138
|
+
// produced -8.09% / -7.93% verdicts 4s after each fill, on an account
|
|
2139
|
+
// that was UP on the day. This condition can only ever WIDEN deferral —
|
|
2140
|
+
// it never licenses a flatten the old code would have refused.
|
|
2141
|
+
const positionsCoverLatestFill = this.lastFillAt === 0 || this.positionsUpdatedAt > this.lastFillAt;
|
|
2142
|
+
// ...but BOUND that deferral. The tear resolves within one positions poll
|
|
2143
|
+
// (~10s), so a RED verdict still standing after MAX_TORN_SNAPSHOT_DEFER_MS
|
|
2144
|
+
// is not a snapshot artifact and must be honoured. Without a ceiling, a
|
|
2145
|
+
// flapping synthetic-fill detector would keep `lastFillAt` ahead of every
|
|
2146
|
+
// refresh and disarm the auto-flatten INDEFINITELY — and that detector is
|
|
2147
|
+
// known to fabricate (it invented a duplicate fill 8s after a real one on
|
|
2148
|
+
// 2026-07-25). Disarming a safety control on the word of an unreliable
|
|
2149
|
+
// component is the worse failure direction, so the tear only ever BUYS
|
|
2150
|
+
// TIME; past the ceiling the ordinary #213 debounce governs again.
|
|
2151
|
+
const tornSnapshotBlocks = !positionsCoverLatestFill && redForMs < MAX_TORN_SNAPSHOT_DEFER_MS;
|
|
2003
2152
|
if (uptimeMs < STARTUP_GRACE_PERIOD_MS) {
|
|
2004
2153
|
logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) but within startup grace period (${Math.round(uptimeMs / 1000)}s) — skipping auto-flatten`);
|
|
2005
2154
|
}
|
|
@@ -2011,8 +2160,9 @@ export class GatewayProvider {
|
|
|
2011
2160
|
}
|
|
2012
2161
|
else if (this.redZoneStreak < REQUIRED_RED_STREAK ||
|
|
2013
2162
|
redForMs < MIN_RED_PERSISTENCE_MS ||
|
|
2014
|
-
!survivedPositionsRefresh
|
|
2015
|
-
|
|
2163
|
+
!survivedPositionsRefresh ||
|
|
2164
|
+
tornSnapshotBlocks) {
|
|
2165
|
+
logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) — deferring auto-flatten until the verdict persists (streak ${this.redZoneStreak}/${REQUIRED_RED_STREAK}, ${Math.round(redForMs / 1000)}s/${MIN_RED_PERSISTENCE_MS / 1000}s, survivedPositionsRefresh=${survivedPositionsRefresh}, positionsCoverLatestFill=${positionsCoverLatestFill}, tornSnapshotBlocks=${tornSnapshotBlocks}). A fresh entry's notional reads as phantom drawdown until the position snapshot catches up (issues #213/#248); a real drawdown will persist and flatten on a later evaluation.`);
|
|
2016
2166
|
}
|
|
2017
2167
|
else {
|
|
2018
2168
|
logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) — auto-flattening all positions`);
|
package/bridge/types.d.ts
CHANGED
|
@@ -54,6 +54,25 @@ export interface OrderData {
|
|
|
54
54
|
* paper / legacy paths (treated as a working order → cancellable).
|
|
55
55
|
*/
|
|
56
56
|
protective?: boolean;
|
|
57
|
+
/** Limit price. Undefined for market and trigger-only orders. */
|
|
58
|
+
price?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Trigger price for a stop / take-profit order — Binance `stopPrice`,
|
|
61
|
+
* Hyperliquid `triggerPx`.
|
|
62
|
+
*
|
|
63
|
+
* ★ Without this the dashboard cannot see a resting protective leg AT ALL:
|
|
64
|
+
* `type` is collapsed to MARKET|LIMIT here, so a STOP_MARKET is
|
|
65
|
+
* indistinguishable from a market order on the wire. That is what left an HL
|
|
66
|
+
* micro-live book — every position bracketed on the exchange — drawing an
|
|
67
|
+
* entry line and nothing else, under a false "NO STOP" alarm.
|
|
68
|
+
*/
|
|
69
|
+
stopPrice?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Which protective leg this order IS, normalised at the venue boundary so no
|
|
72
|
+
* consumer has to parse venue type strings ('STOP_MARKET' on Binance,
|
|
73
|
+
* 'Stop Market' on Hyperliquid). Undefined for working entry orders.
|
|
74
|
+
*/
|
|
75
|
+
protectiveRole?: 'stop' | 'target';
|
|
57
76
|
}
|
|
58
77
|
export interface FillData {
|
|
59
78
|
id: string;
|
|
@@ -103,6 +122,17 @@ export interface AgentStateData {
|
|
|
103
122
|
thinkingInterval: number;
|
|
104
123
|
/** Gateway WebSocket state — helps diagnose chat delivery failures */
|
|
105
124
|
gatewayWs?: string;
|
|
125
|
+
/** Heartbeat-cron health from `cron.list`. Absent on gateway versions
|
|
126
|
+
* that don't expose cron state (byte-identical to pre-feature). Lets the
|
|
127
|
+
* dashboard surface a failing/stale beat instead of a silent gap. */
|
|
128
|
+
heartbeatHealth?: {
|
|
129
|
+
/** Consecutive failed runs; >0 means the beat isn't delivering. */
|
|
130
|
+
consecutiveErrors: number;
|
|
131
|
+
/** Epoch ms of the last run start (any outcome). */
|
|
132
|
+
lastRunAtMs?: number;
|
|
133
|
+
/** Last run outcome, e.g. "ok" | "error". */
|
|
134
|
+
lastRunStatus?: string;
|
|
135
|
+
};
|
|
106
136
|
};
|
|
107
137
|
};
|
|
108
138
|
timestamp: string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the agent's display name out of an OpenClaw workspace `IDENTITY.md`.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from GatewayProvider so the placeholder rules are unit-testable
|
|
5
|
+
* against the real template text — a placeholder that slips through is shown
|
|
6
|
+
* to the user AS the agent's name, which is exactly the bug this guards
|
|
7
|
+
* (2026-07-26: a dashboard header read `(pick something you like)` while the
|
|
8
|
+
* file said `Rook`). See docs/CLAUDE/agent-runtime.md §6.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Extract `- **Name:** <X>` from IDENTITY.md markdown.
|
|
12
|
+
*
|
|
13
|
+
* Returns `undefined` when the file has no name, the value is an unfilled
|
|
14
|
+
* template placeholder, or it looks like an id rather than a name — callers
|
|
15
|
+
* fail open to their own UI placeholder.
|
|
16
|
+
*
|
|
17
|
+
* The value may sit on the line BELOW the label (the template's own layout):
|
|
18
|
+
*
|
|
19
|
+
* - **Name:**
|
|
20
|
+
* Rook
|
|
21
|
+
*
|
|
22
|
+
* which the `\s*` between `**` and the capture spans.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseIdentityName(markdown: string): string | undefined;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the agent's display name out of an OpenClaw workspace `IDENTITY.md`.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from GatewayProvider so the placeholder rules are unit-testable
|
|
5
|
+
* against the real template text — a placeholder that slips through is shown
|
|
6
|
+
* to the user AS the agent's name, which is exactly the bug this guards
|
|
7
|
+
* (2026-07-26: a dashboard header read `(pick something you like)` while the
|
|
8
|
+
* file said `Rook`). See docs/CLAUDE/agent-runtime.md §6.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Values the template ships (or a half-filled file leaves behind) that must
|
|
12
|
+
* never reach the UI. Matched AFTER markdown emphasis is stripped.
|
|
13
|
+
*/
|
|
14
|
+
const PLACEHOLDER_PATTERNS = [
|
|
15
|
+
/\btbd\b/i,
|
|
16
|
+
/fill this in/i,
|
|
17
|
+
// OpenClaw's own template values are parenthesised prompts, e.g.
|
|
18
|
+
// _(pick something you like)_
|
|
19
|
+
// _(AI? robot? familiar? ghost in the machine? something weirder?)_
|
|
20
|
+
// _(your signature — pick one that feels right)_
|
|
21
|
+
// Any value that is ENTIRELY a bracketed aside is a prompt, not a name.
|
|
22
|
+
/^\(.*\)$/s,
|
|
23
|
+
/^\[.*\]$/s,
|
|
24
|
+
/^<.*>$/s,
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Extract `- **Name:** <X>` from IDENTITY.md markdown.
|
|
28
|
+
*
|
|
29
|
+
* Returns `undefined` when the file has no name, the value is an unfilled
|
|
30
|
+
* template placeholder, or it looks like an id rather than a name — callers
|
|
31
|
+
* fail open to their own UI placeholder.
|
|
32
|
+
*
|
|
33
|
+
* The value may sit on the line BELOW the label (the template's own layout):
|
|
34
|
+
*
|
|
35
|
+
* - **Name:**
|
|
36
|
+
* Rook
|
|
37
|
+
*
|
|
38
|
+
* which the `\s*` between `**` and the capture spans.
|
|
39
|
+
*/
|
|
40
|
+
export function parseIdentityName(markdown) {
|
|
41
|
+
const m = markdown.match(/\*\*\s*Name\s*:?\s*\*\*\s*:?\s*(.+)/i);
|
|
42
|
+
let name = m?.[1]?.trim();
|
|
43
|
+
if (!name)
|
|
44
|
+
return undefined;
|
|
45
|
+
// Strip stray markdown emphasis (the template wraps placeholders in `_`).
|
|
46
|
+
name = name.replace(/^[_*`]+|[_*`]+$/g, '').trim();
|
|
47
|
+
if (!name)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (/^\d+$/.test(name))
|
|
50
|
+
return undefined; // an id, not a name
|
|
51
|
+
if (PLACEHOLDER_PATTERNS.some((re) => re.test(name)))
|
|
52
|
+
return undefined;
|
|
53
|
+
return name;
|
|
54
|
+
}
|
package/ccxt/binance-public.d.ts
CHANGED
|
@@ -18,8 +18,10 @@ export declare class BinancePublicApi implements PublicMarketDataApi {
|
|
|
18
18
|
fetchOHLCV(symbol: string, timeframe?: string, limit?: number): Promise<CcxtOHLCV[] | null>;
|
|
19
19
|
/** Probe Binance USD-M FUTURES reachability from this host — the readiness
|
|
20
20
|
* gate's core signal. Calls the futures-explicit implicit method so it hits
|
|
21
|
-
* `fapi.binance.com`
|
|
22
|
-
*
|
|
21
|
+
* `fapi.binance.com` regardless of how the instance is configured. Keep it
|
|
22
|
+
* explicit even though the client now sets `defaultType: 'future'`: this
|
|
23
|
+
* probe must detect a futures-specific 451 geo-block, and it must not start
|
|
24
|
+
* silently probing spot if that option is ever changed.
|
|
23
25
|
* HTTP 451 = Binance geo-restriction; the ban gate does NOT classify 451, so
|
|
24
26
|
* we inspect the message here. Ban-gate compliant (assertNotBanned/noteSuccess/
|
|
25
27
|
* noteBinanceError). Outcomes:
|