@reefclaw/openclaw-plugin 0.1.24 → 0.1.25
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/connector.d.ts +3 -1
- package/bridge/connector.js +37 -2
- package/bridge/gateway/heartbeat-cron.js +2 -1
- package/bridge/index.js +21 -0
- package/bridge/shock-wake.d.ts +80 -0
- package/bridge/shock-wake.js +291 -0
- package/bridge/types.d.ts +4 -0
- package/bridge/utils/instance-id.d.ts +3 -0
- package/bridge/utils/instance-id.js +48 -0
- package/config/agent-config-client.d.ts +3 -1
- package/config/agent-config-client.js +4 -0
- package/config/brackets-config.d.ts +2 -1
- package/config/brackets-config.js +25 -3
- package/config/gate-store.d.ts +3 -0
- package/config/gate-store.js +11 -2
- package/config/loss-streak-config.d.ts +2 -0
- package/config/loss-streak-config.js +33 -0
- package/config/plugin-config-io.d.ts +19 -0
- package/config/reentry-cooldown-config.d.ts +7 -0
- package/config/reentry-cooldown-config.js +59 -0
- package/index.js +29 -2
- package/ingest/position-auto-capture.js +49 -4
- package/ingest/readiness-reporter.d.ts +23 -2
- package/ingest/readiness-reporter.js +56 -1
- package/onboarding/runtime.js +4 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/portfolio/directional-scoreboard.d.ts +17 -0
- package/portfolio/directional-scoreboard.js +71 -0
- package/portfolio/reentry-tracker.d.ts +38 -1
- package/portfolio/reentry-tracker.js +49 -0
- package/signals/change-of-character.d.ts +38 -0
- package/signals/change-of-character.js +93 -0
- package/simulator/exchange-simulator.d.ts +5 -1
- package/simulator/exchange-simulator.js +24 -6
- package/simulator/types.d.ts +11 -0
- package/skills/reefclaw/SKILL.md +2 -2
- package/strategy/evaluator.d.ts +4 -0
- package/tools/close-position.js +10 -1
- package/tools/create-order.js +72 -2
- package/tools/hl-provision-agent-wallet.js +29 -11
- package/tools/reentry-cooldown.d.ts +33 -0
- package/tools/reentry-cooldown.js +74 -0
- package/tools/scan-pairs.d.ts +7 -0
- package/tools/scan-pairs.js +47 -0
- package/tools/set-exchange-credentials.js +19 -0
- package/tools/set-trading-mode.d.ts +6 -0
- package/tools/set-trading-mode.js +48 -1
- package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
- package/venues/hyperliquid/hl-agent-wallet.js +32 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
- package/venues/hyperliquid/hl-live-adapter.js +101 -13
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Re-entry cooldown gate — blocks (mode-laddered) a NEW entry on a symbol
|
|
2
|
+
// whose last close within the cooldown window was a LOSS.
|
|
3
|
+
//
|
|
4
|
+
// Evidence (2026-09-02, full live journal): entries opened within 60 minutes
|
|
5
|
+
// of a same-symbol losing close ran mean −0.077R (n=58) vs +0.090R for all
|
|
6
|
+
// other entries (n=1151) at identical ~41% win rates, plus the extra fee load
|
|
7
|
+
// — the LINK/DOGE churn signature of the 2026-06 investigation recurring on
|
|
8
|
+
// the HL live book. Suggestive, not proven, at n=58 — which is exactly what
|
|
9
|
+
// the shadow rung is for: this ships `off` by default, is flipped shadow-first
|
|
10
|
+
// via the central gate channel (`agent_config.gates.reentryCooldown`), and
|
|
11
|
+
// tags would-block entries into position_entries.metadata.reentry_cooldown so
|
|
12
|
+
// the forward counterfactual is measurable in the journal before any enforce
|
|
13
|
+
// decision.
|
|
14
|
+
//
|
|
15
|
+
// Scope guards (all fail OPEN — this gate can only ever suppress a NEW entry,
|
|
16
|
+
// never an exit, close, stop, or emergency action):
|
|
17
|
+
// - generic entries only (wave9 has its own frozen admission policy);
|
|
18
|
+
// - scale-ins exempt (position already open — entry already happened);
|
|
19
|
+
// - operator-approved proposal fires exempt (human already said yes);
|
|
20
|
+
// - tracker unavailable → pass.
|
|
21
|
+
//
|
|
22
|
+
// Mode semantics mirror the exit gate (docs/CLAUDE/exit-gate.md):
|
|
23
|
+
// off — not evaluated; byte-identical to the pre-gate path.
|
|
24
|
+
// shadow — evaluated + logged + journal-tagged; never affects the order.
|
|
25
|
+
// observe — as shadow, but a triggered eval logs at WARN (operator-visible).
|
|
26
|
+
// enforce — a triggered eval hard-rejects create_order with a recovery hint.
|
|
27
|
+
export function evaluateReentryCooldown(inputs) {
|
|
28
|
+
const base = {
|
|
29
|
+
mode: inputs.mode,
|
|
30
|
+
triggered: false,
|
|
31
|
+
blocked: false,
|
|
32
|
+
cooldownMinutes: inputs.cooldownMinutes,
|
|
33
|
+
};
|
|
34
|
+
if (inputs.isListenerFire)
|
|
35
|
+
return { ...base, code: 'operator_approved_exempt' };
|
|
36
|
+
if (inputs.hasOpenPosition)
|
|
37
|
+
return { ...base, code: 'scale_in_exempt' };
|
|
38
|
+
if (!inputs.tracker)
|
|
39
|
+
return { ...base, code: 'tracker_unavailable' };
|
|
40
|
+
const nowMs = inputs.nowMs ?? Date.now();
|
|
41
|
+
const windowMs = inputs.cooldownMinutes * 60_000;
|
|
42
|
+
const loss = inputs.tracker.lastLossyExit(inputs.symbol, windowMs, {
|
|
43
|
+
mode: inputs.book,
|
|
44
|
+
nowMs,
|
|
45
|
+
});
|
|
46
|
+
if (!loss)
|
|
47
|
+
return { ...base, code: 'no_recent_loss' };
|
|
48
|
+
const minutesSinceLoss = Math.max(0, Math.round((nowMs - loss.closedAtMs) / 60_000));
|
|
49
|
+
return {
|
|
50
|
+
...base,
|
|
51
|
+
triggered: true,
|
|
52
|
+
blocked: inputs.mode === 'enforce',
|
|
53
|
+
code: 'cooldown_active',
|
|
54
|
+
minutesSinceLoss,
|
|
55
|
+
lastLoss: {
|
|
56
|
+
side: loss.side,
|
|
57
|
+
setupType: loss.setupType,
|
|
58
|
+
closedAtMs: loss.closedAtMs,
|
|
59
|
+
lossSource: loss.lossSource,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Agent-facing rejection for enforce mode. Names the gate, the remaining
|
|
64
|
+
* wait, and the honest path forward — no bypass hint by design. */
|
|
65
|
+
export function buildReentryCooldownRejection(ev, symbol) {
|
|
66
|
+
const remaining = Math.max(1, ev.cooldownMinutes - (ev.minutesSinceLoss ?? 0));
|
|
67
|
+
return (`create_order rejected (reentry cooldown): ${symbol} closed at a LOSS ` +
|
|
68
|
+
`${ev.minutesSinceLoss}m ago and the operator-configured cooldown is ` +
|
|
69
|
+
`${ev.cooldownMinutes}m — ~${remaining}m remaining. This is a mechanical ` +
|
|
70
|
+
`gate against re-entry churn (measured −0.17R/trade edge gap on re-entries ` +
|
|
71
|
+
`within the window). Do not retry this symbol until the cooldown lapses; ` +
|
|
72
|
+
`spend the time re-scoring the setup — if it is still valid then, enter then. ` +
|
|
73
|
+
`Other symbols are unaffected.`);
|
|
74
|
+
}
|
package/tools/scan-pairs.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ export interface ScanPairsDecisionsDeps {
|
|
|
13
13
|
/** Re-entry tracker (issue #204) — flags setups already traded within the
|
|
14
14
|
* current signal bar. Indication only; nothing is filtered out. */
|
|
15
15
|
reentryTracker?: ReentryTracker;
|
|
16
|
+
/** WS2 directional scoreboard inputs (docs/MARKET_ADAPTIVITY_PLAN.md §3) —
|
|
17
|
+
* tracked open positions (state store; no exchange round-trip) + the
|
|
18
|
+
* current book. Indication only, like everything else in this block. */
|
|
19
|
+
openPositions?: () => Array<{
|
|
20
|
+
side: 'long' | 'short';
|
|
21
|
+
}>;
|
|
22
|
+
book?: () => 'paper' | 'live';
|
|
16
23
|
}
|
|
17
24
|
export declare function scanPairsTool(args: ScanPairsArgs, deps: IntelApiDeps, decisionsDeps?: ScanPairsDecisionsDeps): Promise<Record<string, unknown> | {
|
|
18
25
|
error: string;
|
package/tools/scan-pairs.js
CHANGED
|
@@ -17,8 +17,31 @@
|
|
|
17
17
|
// heartbeat the agent was rejecting post-scorecard pre-fix.
|
|
18
18
|
import { intelSymbolOnVenue, presentIntelSymbol, resolveIntelSymbol } from './intel-api.js';
|
|
19
19
|
import { scanAllPairs } from '../strategy/evaluator.js';
|
|
20
|
+
import { buildDirectionalScoreboard } from '../portfolio/directional-scoreboard.js';
|
|
20
21
|
import { getAllFactsCached, getStrategiesCached, __testing__ as cacheTesting } from './intel-cache.js';
|
|
21
22
|
import { normalizeSetupFamily, resolveTriggerFamilies } from '../learning/setup-family.js';
|
|
23
|
+
/** WS2 kill-switch — RC_CHANGE_OF_CHARACTER=off suppresses the market-shift
|
|
24
|
+
* cautions + directional scoreboard without a redeploy. Default on. */
|
|
25
|
+
function changeOfCharacterEnabled() {
|
|
26
|
+
return (process.env.RC_CHANGE_OF_CHARACTER ?? '').toLowerCase() !== 'off';
|
|
27
|
+
}
|
|
28
|
+
/** Tape line for the scoreboard from the market leader's fact (BTC on this
|
|
29
|
+
* venue; falls back to the first fact so alt-only books still get a tape). */
|
|
30
|
+
function leaderTapeLine(deps, facts) {
|
|
31
|
+
const leader = facts.find((f) => presentIntelSymbol(deps, f.symbol).toUpperCase().startsWith('BTC/')) ??
|
|
32
|
+
facts[0];
|
|
33
|
+
const coc = leader?.changeOfCharacter;
|
|
34
|
+
if (!coc)
|
|
35
|
+
return {};
|
|
36
|
+
const sym = presentIntelSymbol(deps, leader.symbol).split('/')[0];
|
|
37
|
+
const sign = (v) => (v >= 0 ? '+' : '');
|
|
38
|
+
return {
|
|
39
|
+
line: `${sym} 4h ${sign(coc.return4hPct)}${coc.return4hPct}% ` +
|
|
40
|
+
`(${sign(coc.tape4hAtr)}${coc.tape4hAtr}×ATR)` +
|
|
41
|
+
(coc.flags.length > 0 ? ` [${coc.flags.join(', ')}]` : ''),
|
|
42
|
+
caution: coc.flags.length > 0 ? coc.summary : undefined,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
22
45
|
const LEARNINGS_TTL_MS = 60_000;
|
|
23
46
|
const entryLearningsCache = new Map();
|
|
24
47
|
/** Family-keying gate (2026-06-22). Off by default → byte-identical exact-match
|
|
@@ -189,10 +212,14 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
|
|
|
189
212
|
const entryLearnings = await learningsPromise;
|
|
190
213
|
const rankings = [];
|
|
191
214
|
const vetoed = [];
|
|
215
|
+
// WS2 change-of-character: per-symbol lookup + market-leader tape line.
|
|
216
|
+
const cocOn = changeOfCharacterEnabled();
|
|
217
|
+
const factBySymbol = new Map(facts.map(f => [f.symbol, f]));
|
|
192
218
|
for (const r of results) {
|
|
193
219
|
const matches = entryLearnings.filter(l => learningMatches(l, r.strategy, r.regime));
|
|
194
220
|
const agentSymbol = presentIntelSymbol(deps, r.symbol);
|
|
195
221
|
const reentryCaution = decisionsDeps?.reentryTracker?.cautionFor(agentSymbol, r.strategy);
|
|
222
|
+
const coc = cocOn ? factBySymbol.get(r.symbol)?.changeOfCharacter : undefined;
|
|
196
223
|
const out = {
|
|
197
224
|
// Agent-facing form: on hyperliquid the agent must see the symbol it
|
|
198
225
|
// can hand straight to create_order ('BTC/USDC'), never 'HL_BTC'.
|
|
@@ -203,6 +230,7 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
|
|
|
203
230
|
conditions: `${r.conditionsMet}/${r.conditionsTotal} met: ${r.conditions.filter(c => c.met).map(c => c.name).join(', ')}`,
|
|
204
231
|
summary: r.summary,
|
|
205
232
|
...(reentryCaution ? { reentry_caution: reentryCaution } : {}),
|
|
233
|
+
...(coc && coc.flags.length > 0 && coc.summary ? { market_shift_caution: coc.summary } : {}),
|
|
206
234
|
};
|
|
207
235
|
if (matches.length === 0) {
|
|
208
236
|
rankings.push(out);
|
|
@@ -223,10 +251,29 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
|
|
|
223
251
|
const noSetup = facts
|
|
224
252
|
.filter(f => !setupSymbols.has(f.symbol))
|
|
225
253
|
.map(f => presentIntelSymbol(deps, f.symbol));
|
|
254
|
+
// WS2 top-level indication: leader tape + shift caution + the directional
|
|
255
|
+
// scoreboard (book tilt vs recent per-direction outcomes vs tape). All
|
|
256
|
+
// indication-only; RC_CHANGE_OF_CHARACTER=off suppresses without redeploy.
|
|
257
|
+
let marketCaution;
|
|
258
|
+
let scoreboard;
|
|
259
|
+
if (cocOn) {
|
|
260
|
+
const tape = leaderTapeLine(deps, facts);
|
|
261
|
+
marketCaution = tape.caution;
|
|
262
|
+
if (decisionsDeps?.reentryTracker) {
|
|
263
|
+
scoreboard = buildDirectionalScoreboard({
|
|
264
|
+
openPositions: decisionsDeps.openPositions?.() ?? [],
|
|
265
|
+
exitRecords: decisionsDeps.reentryTracker.getRecords(),
|
|
266
|
+
book: decisionsDeps.book?.() ?? 'paper',
|
|
267
|
+
tapeLine: tape.line,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
226
271
|
return {
|
|
227
272
|
timestamp: new Date().toISOString(),
|
|
228
273
|
pairs_scanned: facts.length,
|
|
229
274
|
setups_found: results.length,
|
|
275
|
+
...(marketCaution ? { market_caution: marketCaution } : {}),
|
|
276
|
+
...(scoreboard ? { directional_scoreboard: scoreboard } : {}),
|
|
230
277
|
rankings,
|
|
231
278
|
vetoed_setups: vetoed.length > 0 ? vetoed : undefined,
|
|
232
279
|
no_setup: noSetup.length > 5
|
|
@@ -34,12 +34,31 @@ import { parseVenue } from '../venues/registry.js';
|
|
|
34
34
|
import { unsealCredentials, SealedEnvelopeError } from '../security/sealed-credentials.js';
|
|
35
35
|
import { logger } from '../logger.js';
|
|
36
36
|
const TAG = 'set-exchange-credentials';
|
|
37
|
+
/** Non-secret CONTROL flags honored from the PLAINTEXT SIBLINGS of a sealed
|
|
38
|
+
* envelope (E2E audit 2026-08-11 #5). The envelope's anti-smuggle property —
|
|
39
|
+
* "plaintext siblings are ignored" — exists so a mixed payload can't override
|
|
40
|
+
* sealed CREDENTIAL values. But the dashboard sends `confirm_venue_switch`
|
|
41
|
+
* as a sibling NEXT TO `sealed` (it is not a secret and the sealer encrypts
|
|
42
|
+
* only the per-venue credential payload), so dropping every sibling made the
|
|
43
|
+
* confirm retry look unconfirmed forever: any live venue-switcher on a box
|
|
44
|
+
* with a transport key hit an infinite confirm loop. The flags below are
|
|
45
|
+
* safe to honor from plaintext because they cannot change WHAT gets stored
|
|
46
|
+
* (the sealed payload alone decides that) — they only acknowledge a warn
|
|
47
|
+
* step for the request the operator themselves sealed. A value INSIDE the
|
|
48
|
+
* envelope still wins (sealed-beats-plaintext is preserved). */
|
|
49
|
+
const SEALED_SIBLING_CONTROL_FLAGS = ['confirm_venue_switch'];
|
|
37
50
|
/** Open a sealed envelope into plain args, or return a renderable error. */
|
|
38
51
|
export function resolveSealedArgs(args, configDir) {
|
|
39
52
|
if (args?.sealed == null)
|
|
40
53
|
return { args, sealed: false };
|
|
41
54
|
try {
|
|
42
55
|
const plain = unsealCredentials(args.sealed, configDir);
|
|
56
|
+
for (const flag of SEALED_SIBLING_CONTROL_FLAGS) {
|
|
57
|
+
const sibling = args[flag];
|
|
58
|
+
if (plain[flag] === undefined && sibling !== undefined) {
|
|
59
|
+
plain[flag] = sibling;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
43
62
|
return { args: plain, sealed: true };
|
|
44
63
|
}
|
|
45
64
|
catch (err) {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { PluginRuntime } from '../onboarding/runtime.js';
|
|
2
2
|
import type { IExchangeAdapter } from '../exchange-adapter.js';
|
|
3
3
|
import type { TradingMode } from '../types.js';
|
|
4
|
+
import type { HlCredentials } from '../venues/hyperliquid/hl-private.js';
|
|
5
|
+
import { type HlAgentApprovalVerdict } from '../venues/hyperliquid/hl-agent-wallet.js';
|
|
4
6
|
import { type VenueId } from '../venues/registry.js';
|
|
5
7
|
export interface SetTradingModeArgs {
|
|
6
8
|
mode: TradingMode;
|
|
@@ -27,6 +29,10 @@ export interface SetTradingModeDeps {
|
|
|
27
29
|
* wired to the boot venue and a mixed-venue runtime is not a valid state.
|
|
28
30
|
* Absent (legacy tests) → guard skipped. */
|
|
29
31
|
bootVenue?: VenueId;
|
|
32
|
+
/** Injectable approval check (tests). DEFAULTS TO THE REAL ONE — unlike
|
|
33
|
+
* bootVenue this gate is on unless explicitly stubbed, so production
|
|
34
|
+
* wiring can never forget it. */
|
|
35
|
+
hlApprovalCheck?: (creds: HlCredentials) => Promise<HlAgentApprovalVerdict>;
|
|
30
36
|
/** Override the config file path — tests use this. */
|
|
31
37
|
configPath?: string;
|
|
32
38
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// by design: the check lives once, at registration, for all operator tools.
|
|
13
13
|
import { readPluginConfig } from '../config/plugin-config-io.js';
|
|
14
14
|
import { validateModeTransition, modeRequiresCredentials } from '../onboarding/mode-ladder.js';
|
|
15
|
+
import { checkHlAgentApproval, } from '../venues/hyperliquid/hl-agent-wallet.js';
|
|
15
16
|
import { parseVenue } from '../venues/registry.js';
|
|
16
17
|
import { logger } from '../logger.js';
|
|
17
18
|
import { recordModeTransition } from '../audit/mode-transition-audit.js';
|
|
@@ -133,6 +134,48 @@ export async function setTradingModeTool(args, deps) {
|
|
|
133
134
|
reason: 'acknowledgment_required',
|
|
134
135
|
};
|
|
135
136
|
}
|
|
137
|
+
// 4.5. HL agent-approval preflight (E2E audit 2026-08-11 #7). An agent key
|
|
138
|
+
// that is not in the master account's extraAgents list — or whose approval
|
|
139
|
+
// expired (they last ≤180 days) — signs orders Hyperliquid rejects one by
|
|
140
|
+
// one: the flip would "succeed", the dashboard would look healthy, and
|
|
141
|
+
// every order would bounce. Refuse ONLY on a definitive negative from the
|
|
142
|
+
// exchange; derivation/network failures fail OPEN with a warning (infra
|
|
143
|
+
// must never block a deliberate operator action — same rule as the
|
|
144
|
+
// credential-entry guards).
|
|
145
|
+
let preflightWarnings = [];
|
|
146
|
+
if (venue === 'hyperliquid' && hlCredentials) {
|
|
147
|
+
let verdict;
|
|
148
|
+
try {
|
|
149
|
+
verdict = await (deps.hlApprovalCheck ?? checkHlAgentApproval)(hlCredentials);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
verdict = {
|
|
153
|
+
approved: null,
|
|
154
|
+
validUntil: null,
|
|
155
|
+
warnings: [
|
|
156
|
+
`Approval preflight errored (${err instanceof Error ? err.message : String(err)}) — approval not verified.`,
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (verdict.approved === false) {
|
|
161
|
+
recordModeTransition({ previousMode, targetMode: target, acknowledged, ok: false, reason: 'hl_agent_not_approved' });
|
|
162
|
+
const why = verdict.warnings.length > 0 ? ` ${verdict.warnings.join(' ')}` : '';
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
message: `Hyperliquid reports this agent wallet is NOT approved to trade for ${hlCredentials.walletAddress}.` +
|
|
166
|
+
`${why} Every live order would be rejected. Approve it first: Dashboard → Settings → ` +
|
|
167
|
+
`Exchange connection → Hyperliquid (the guided setup signs one approveAgent transaction), ` +
|
|
168
|
+
`then retry ${target}.`,
|
|
169
|
+
previousMode,
|
|
170
|
+
mode: previousMode,
|
|
171
|
+
readiness: deps.runtime.adapter.readiness,
|
|
172
|
+
reason: 'hl_agent_not_approved',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
preflightWarnings = verdict.warnings;
|
|
176
|
+
for (const w of preflightWarnings)
|
|
177
|
+
logger.warn(TAG, `HL preflight: ${w}`);
|
|
178
|
+
}
|
|
136
179
|
// 5. Persist the new mode.
|
|
137
180
|
try {
|
|
138
181
|
const { updatePluginConfig } = await import('../config/plugin-config-io.js');
|
|
@@ -162,7 +205,11 @@ export async function setTradingModeTool(args, deps) {
|
|
|
162
205
|
});
|
|
163
206
|
return {
|
|
164
207
|
ok: true,
|
|
165
|
-
|
|
208
|
+
// Preflight warnings (near-expiry approval, zero balance, unverifiable
|
|
209
|
+
// approval) ride the success message — the operator just took a real-money
|
|
210
|
+
// action and these are the things to fix before the first order.
|
|
211
|
+
message: `Trading mode changed from ${previousMode} to ${target}` +
|
|
212
|
+
(preflightWarnings.length > 0 ? `. Note: ${preflightWarnings.join(' ')}` : ''),
|
|
166
213
|
previousMode,
|
|
167
214
|
mode: deps.runtime.mode,
|
|
168
215
|
readiness: deps.runtime.adapter.readiness,
|
|
@@ -27,3 +27,29 @@ export declare function __resetKeccakCacheForTests(): void;
|
|
|
27
27
|
export declare function deriveAddressFromPrivateKey(privateKey: string): Promise<DeriveAddressResult>;
|
|
28
28
|
/** Case-insensitive address equality (addresses may arrive checksummed). */
|
|
29
29
|
export declare function sameAddress(a: string, b: string): boolean;
|
|
30
|
+
/** One-call approval verdict for the STORED credentials — derivation + the
|
|
31
|
+
* unsigned preflight, packaged for the live-flip gate in set_trading_mode
|
|
32
|
+
* (E2E audit 2026-08-11 #7: an unapproved/expired agent key reached LIVE
|
|
33
|
+
* with a healthy-looking dashboard and every order rejected).
|
|
34
|
+
*
|
|
35
|
+
* Lives HERE, not in hl-preflight.ts, because it takes the PRIVATE KEY —
|
|
36
|
+
* hl-preflight's module boundary is addresses-only by design.
|
|
37
|
+
*
|
|
38
|
+
* Verdict semantics (the caller refuses ONLY on `approved === false`):
|
|
39
|
+
* false = the exchange answered definitively — the agent address is not in
|
|
40
|
+
* extraAgents, or its approval has expired. Real money would be
|
|
41
|
+
* un-tradeable; block the flip.
|
|
42
|
+
* null = could not verify (derivation unavailable, HL unreachable,
|
|
43
|
+
* extraAgents unreadable). Fail OPEN — infra must never block a
|
|
44
|
+
* deliberate operator action; the warning travels instead.
|
|
45
|
+
* true = approved (warnings may still carry a near-expiry nudge). */
|
|
46
|
+
export interface HlAgentApprovalVerdict {
|
|
47
|
+
approved: boolean | null;
|
|
48
|
+
validUntil: number | null;
|
|
49
|
+
warnings: string[];
|
|
50
|
+
}
|
|
51
|
+
export declare function checkHlAgentApproval(creds: {
|
|
52
|
+
walletAddress: string;
|
|
53
|
+
agentPrivateKey: string;
|
|
54
|
+
testnet?: boolean;
|
|
55
|
+
}, fetchImpl?: typeof fetch): Promise<HlAgentApprovalVerdict>;
|
|
@@ -116,3 +116,35 @@ export async function deriveAddressFromPrivateKey(privateKey) {
|
|
|
116
116
|
export function sameAddress(a, b) {
|
|
117
117
|
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
118
118
|
}
|
|
119
|
+
export async function checkHlAgentApproval(creds, fetchImpl) {
|
|
120
|
+
const derived = await deriveAddressFromPrivateKey(creds.agentPrivateKey);
|
|
121
|
+
if (!derived.ok) {
|
|
122
|
+
return {
|
|
123
|
+
approved: null,
|
|
124
|
+
validUntil: null,
|
|
125
|
+
warnings: [
|
|
126
|
+
derived.reason === 'invalid_key'
|
|
127
|
+
? 'The stored agent key looks malformed — approval could not be verified.'
|
|
128
|
+
: 'Could not derive the agent wallet address on this host — approval not verified.',
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const { hlPreflight } = await import('./hl-preflight.js');
|
|
133
|
+
const pre = await hlPreflight({
|
|
134
|
+
walletAddress: creds.walletAddress,
|
|
135
|
+
agentAddress: derived.address,
|
|
136
|
+
testnet: creds.testnet === true,
|
|
137
|
+
fetchImpl,
|
|
138
|
+
});
|
|
139
|
+
if (!pre.reachable) {
|
|
140
|
+
return {
|
|
141
|
+
approved: null,
|
|
142
|
+
validUntil: null,
|
|
143
|
+
warnings: [
|
|
144
|
+
`Could not reach Hyperliquid to verify the agent approval (${pre.unreachableError ?? 'unknown error'}).`,
|
|
145
|
+
...pre.warnings,
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return { approved: pre.agentApproved, validUntil: pre.agentValidUntil, warnings: pre.warnings };
|
|
150
|
+
}
|
|
@@ -6,6 +6,7 @@ import { type HlCredentials } from './hl-private.js';
|
|
|
6
6
|
import type { BracketId } from '../../live/bracket-types.js';
|
|
7
7
|
import { BracketLedger } from '../../live/bracket-ledger.js';
|
|
8
8
|
import { HlBracketCoordinator } from './hl-bracket-coordinator.js';
|
|
9
|
+
import { type AutoCaptureContext } from '../../ingest/position-auto-capture.js';
|
|
9
10
|
import type { TradeIngestWiring } from '../../live/live-adapter.js';
|
|
10
11
|
export interface HlLiveAdapterOptions {
|
|
11
12
|
credentials: HlCredentials;
|
|
@@ -23,6 +24,16 @@ export interface HlLiveAdapterOptions {
|
|
|
23
24
|
* via userFillsByTime. Same object boot passes the Binance adapter —
|
|
24
25
|
* `exchange` MUST be fillExchangeId('hyperliquid'). */
|
|
25
26
|
tradeIngest?: TradeIngestWiring;
|
|
27
|
+
/** Position-decision journal wiring. When present, a close-direction user-
|
|
28
|
+
* stream fill (dir "Close …" or a liquidation fill) is routed through
|
|
29
|
+
* `onWsFillObserved` so an exchange-native bracket SL/TP fill journals an
|
|
30
|
+
* EXACT close (reason `bracket_fill`) instead of leaking as status='open'
|
|
31
|
+
* until the reconciler heals it as `reconciler_observed_flat` — the HL
|
|
32
|
+
* analog of the wiring Binance's ws-ingest has carried since PR #205.
|
|
33
|
+
* Entry/scale-in fills are deliberately NOT routed (the sync create_order
|
|
34
|
+
* path captures them; the WS dedup key is unverified on HL — see
|
|
35
|
+
* onUserFill). */
|
|
36
|
+
autoCapture?: AutoCaptureContext;
|
|
26
37
|
/** Test seams. Production omits both. */
|
|
27
38
|
bracketLedger?: BracketLedger;
|
|
28
39
|
disableUserStream?: boolean;
|
|
@@ -54,6 +65,10 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
|
|
|
54
65
|
/** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
|
|
55
66
|
* low-water mark the reconnect gap backfill widens from. */
|
|
56
67
|
private lastFillIngestMs;
|
|
68
|
+
/** oid → cloid backfill for fills that omit `cloid` (the journal close path
|
|
69
|
+
* recognizes bracket legs by client id). Populated from `orderUpdates`,
|
|
70
|
+
* which always carries both. Bounded, insertion-order eviction. */
|
|
71
|
+
private readonly oidToCloid;
|
|
57
72
|
/** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
|
|
58
73
|
* income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
|
|
59
74
|
* each balance fetch. Without this the skill self-computes a bogus anchor and
|
|
@@ -187,10 +202,20 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
|
|
|
187
202
|
* `startPosition` is the position BEFORE this fill — the WS-authoritative
|
|
188
203
|
* way to know the after-fill total without an extra REST read. */
|
|
189
204
|
private onUserFill;
|
|
205
|
+
/** Close-direction fill → position-decision journal (close-bypass fix, HL
|
|
206
|
+
* arm). Fire-and-forget: a journal POST blip must never touch the WS hot
|
|
207
|
+
* path. Reduce-only is derived from `dir` (HL fills carry no reduceOnly
|
|
208
|
+
* flag): "Close Long"/"Close Short", plus the liquidation marker. Flip
|
|
209
|
+
* dirs ("Long > Short") are NOT closes of a tracked side we understand —
|
|
210
|
+
* they stay with the reconciler backstop. */
|
|
211
|
+
private captureCloseFill;
|
|
190
212
|
/** `orderUpdates` is authoritative for leg lifecycle (the ALGO_UPDATE
|
|
191
|
-
* analog). A trigger = the exchange closed the position — surface the
|
|
213
|
+
* analog). A trigger = the exchange closed the position — surface the
|
|
192
214
|
* `drift_detected` close shape the Binance reconciler emits so the journal
|
|
193
|
-
* close-bypass cleanup fires
|
|
215
|
+
* close-bypass cleanup fires fast — but AFTER a short grace, so the close
|
|
216
|
+
* FILL (the exact-close journal path, `captureCloseFill`) wins the
|
|
217
|
+
* undocumented orderUpdates/userFills frame ordering. The cleanup is
|
|
218
|
+
* idempotent: state already dropped by the fill path ⇒ no-op. */
|
|
194
219
|
private onUserOrderUpdate;
|
|
195
220
|
/** T-5 REST truth-check — serialized so a slow pass can't stack. */
|
|
196
221
|
private runTruthCheck;
|
|
@@ -38,7 +38,8 @@ import { generateBracketId } from '../../live/bracket-id.js';
|
|
|
38
38
|
import { validateStopDirection, validateTargetDirection } from '../../live/bracket-params.js';
|
|
39
39
|
import { HlBracketCoordinator, isRejectedOrder, isTerminalBracketState, } from './hl-bracket-coordinator.js';
|
|
40
40
|
import { HyperliquidUserStream } from './hl-user-stream.js';
|
|
41
|
-
import { hlFillToFillEvent } from './hl-fill-ingest.js';
|
|
41
|
+
import { hlFillToFillEvent, hlCoinToCanonical } from './hl-fill-ingest.js';
|
|
42
|
+
import { onWsFillObserved } from '../../ingest/position-auto-capture.js';
|
|
42
43
|
import { formatError } from '../../logger.js';
|
|
43
44
|
const TAG = 'hl-live-adapter';
|
|
44
45
|
/** Venue-distinct ledger storage — a venue switch on the same box must never
|
|
@@ -55,6 +56,21 @@ const DEFAULT_MARKET_SLIPPAGE = 0.005;
|
|
|
55
56
|
/** Sticky cooldown after a failed open-orders fetch (the Binance lesson: the
|
|
56
57
|
* SKILL.md audit→attach loop re-calls every ~3s and would pin the budget). */
|
|
57
58
|
const OPEN_ORDERS_COOLDOWN_MS = 45_000;
|
|
59
|
+
/** Grace before a bracket-trigger `drift_detected` emit. A trigger's close FILL
|
|
60
|
+
* arrives on `userFills` and journals the exact close (real price, real PnL,
|
|
61
|
+
* reason `bracket_fill`); the drift path's cleanup can only post a generic
|
|
62
|
+
* `reconciler_observed_flat`. HL's WS frame ordering between `orderUpdates`
|
|
63
|
+
* and `userFills` is undocumented, so without this grace the generic close
|
|
64
|
+
* routinely won the race and 50% of HL live closes carried no close reason
|
|
65
|
+
* (wisekid, 30d to 2026-08-17: 150/299). The cleanup is idempotent — when the
|
|
66
|
+
* fill already journaled + dropped state, the delayed drift is a no-op; when
|
|
67
|
+
* the fill never arrives (T-5 gap), the drift still heals, 10s late instead
|
|
68
|
+
* of instant (previously ≤5 min via the periodic sweep). */
|
|
69
|
+
const TRIGGER_DRIFT_GRACE_MS = 10_000;
|
|
70
|
+
/** Bound on the oid→cloid map (fills MAY omit `cloid` — facts ledger §3.4 —
|
|
71
|
+
* while `orderUpdates` always carries both, so the map backfills the fill's
|
|
72
|
+
* client id for bracket recognition). Insertion-ordered eviction. */
|
|
73
|
+
const OID_CLOID_MAP_MAX = 512;
|
|
58
74
|
export class HyperliquidLiveAdapter extends EventEmitter {
|
|
59
75
|
opts;
|
|
60
76
|
api;
|
|
@@ -82,6 +98,10 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
82
98
|
/** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
|
|
83
99
|
* low-water mark the reconnect gap backfill widens from. */
|
|
84
100
|
lastFillIngestMs = 0;
|
|
101
|
+
/** oid → cloid backfill for fills that omit `cloid` (the journal close path
|
|
102
|
+
* recognizes bracket legs by client id). Populated from `orderUpdates`,
|
|
103
|
+
* which always carries both. Bounded, insertion-order eviction. */
|
|
104
|
+
oidToCloid = new Map();
|
|
85
105
|
/** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
|
|
86
106
|
* income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
|
|
87
107
|
* each balance fetch. Without this the skill self-computes a bogus anchor and
|
|
@@ -821,6 +841,19 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
821
841
|
// healed it, in a repeating flap. Exchange truth is the resync's job.
|
|
822
842
|
if (meta?.isSnapshot)
|
|
823
843
|
return;
|
|
844
|
+
// Journal close capture: a close-direction fill (bracket SL/TP trigger,
|
|
845
|
+
// liquidation, external reduce) bypasses close_position, and before this
|
|
846
|
+
// wiring the journal only learned about it from the reconciler — 50% of
|
|
847
|
+
// wisekid's 30d closes were `reconciler_observed_flat` heals with the
|
|
848
|
+
// close reason lost. Route it through the SAME generic path Binance's
|
|
849
|
+
// ws-ingest uses; `handleReduceOnlyExit` posts the exact close when the
|
|
850
|
+
// position goes flat and defers to close_position/backstop otherwise.
|
|
851
|
+
// ONLY close-direction fills are routed: entry/scale-in fills stay with
|
|
852
|
+
// the synchronous create_order capture, because the WS dedup key
|
|
853
|
+
// (`openedFromExchangeTradeId === exchangeOrderId`) is unverified against
|
|
854
|
+
// ccxt's HL order.id shape and a dedup miss would re-mint the 38-duplicate-
|
|
855
|
+
// pairs class (issue #199 twin bug).
|
|
856
|
+
this.captureCloseFill(fill);
|
|
824
857
|
try {
|
|
825
858
|
// Matches the primary entry cid OR any additional cid recorded for a
|
|
826
859
|
// scale-in / second resting entry (F5) — matching on `entryCid` alone
|
|
@@ -848,27 +881,82 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
848
881
|
logger.error(TAG, `onUserFill handler error: ${formatError(err)}`);
|
|
849
882
|
}
|
|
850
883
|
}
|
|
884
|
+
/** Close-direction fill → position-decision journal (close-bypass fix, HL
|
|
885
|
+
* arm). Fire-and-forget: a journal POST blip must never touch the WS hot
|
|
886
|
+
* path. Reduce-only is derived from `dir` (HL fills carry no reduceOnly
|
|
887
|
+
* flag): "Close Long"/"Close Short", plus the liquidation marker. Flip
|
|
888
|
+
* dirs ("Long > Short") are NOT closes of a tracked side we understand —
|
|
889
|
+
* they stay with the reconciler backstop. */
|
|
890
|
+
captureCloseFill(fill) {
|
|
891
|
+
const capture = this.opts.autoCapture;
|
|
892
|
+
if (!capture)
|
|
893
|
+
return;
|
|
894
|
+
const dir = (fill.dir ?? '').toLowerCase();
|
|
895
|
+
const isClose = dir.startsWith('close') || fill.liquidation !== undefined;
|
|
896
|
+
if (!isClose)
|
|
897
|
+
return;
|
|
898
|
+
const price = Number(fill.px);
|
|
899
|
+
const size = Number(fill.sz);
|
|
900
|
+
if (!Number.isFinite(price) || price <= 0 || !Number.isFinite(size) || size <= 0)
|
|
901
|
+
return;
|
|
902
|
+
const realizedPnl = Number(fill.closedPnl);
|
|
903
|
+
const cloid = typeof fill.cloid === 'string' && fill.cloid.length > 0
|
|
904
|
+
? fill.cloid
|
|
905
|
+
: this.oidToCloid.get(fill.oid);
|
|
906
|
+
onWsFillObserved(capture, {
|
|
907
|
+
symbol: hlCoinToCanonical(fill.coin),
|
|
908
|
+
side: fill.side === 'B' ? 'buy' : 'sell',
|
|
909
|
+
exchangeOrderId: String(fill.oid),
|
|
910
|
+
exchangeTradeId: String(fill.tid),
|
|
911
|
+
fillPrice: price,
|
|
912
|
+
fillSize: size,
|
|
913
|
+
reduceOnly: true,
|
|
914
|
+
realizedPnl: Number.isFinite(realizedPnl) ? realizedPnl : undefined,
|
|
915
|
+
clientOrderId: cloid,
|
|
916
|
+
exchangeTimeMs: Number.isFinite(fill.time) ? fill.time : undefined,
|
|
917
|
+
}).catch((err) => {
|
|
918
|
+
logger.warn(TAG, `journal close capture failed for ${fill.coin} oid=${fill.oid}: ${msg(err)}`);
|
|
919
|
+
});
|
|
920
|
+
}
|
|
851
921
|
/** `orderUpdates` is authoritative for leg lifecycle (the ALGO_UPDATE
|
|
852
|
-
* analog). A trigger = the exchange closed the position — surface the
|
|
922
|
+
* analog). A trigger = the exchange closed the position — surface the
|
|
853
923
|
* `drift_detected` close shape the Binance reconciler emits so the journal
|
|
854
|
-
* close-bypass cleanup fires
|
|
924
|
+
* close-bypass cleanup fires fast — but AFTER a short grace, so the close
|
|
925
|
+
* FILL (the exact-close journal path, `captureCloseFill`) wins the
|
|
926
|
+
* undocumented orderUpdates/userFills frame ordering. The cleanup is
|
|
927
|
+
* idempotent: state already dropped by the fill path ⇒ no-op. */
|
|
855
928
|
onUserOrderUpdate(update) {
|
|
856
929
|
try {
|
|
930
|
+
// oid→cloid backfill for fills that omit their client id (see
|
|
931
|
+
// captureCloseFill). orderUpdates always carries both.
|
|
932
|
+
const { oid, cloid } = update.order;
|
|
933
|
+
if (typeof oid === 'number' && typeof cloid === 'string' && cloid.length > 0) {
|
|
934
|
+
this.oidToCloid.set(oid, cloid);
|
|
935
|
+
if (this.oidToCloid.size > OID_CLOID_MAP_MAX) {
|
|
936
|
+
const oldest = this.oidToCloid.keys().next().value;
|
|
937
|
+
if (oldest !== undefined)
|
|
938
|
+
this.oidToCloid.delete(oldest);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
857
941
|
const transition = this.getHlBracketCoordinator().handleOrderUpdate(update);
|
|
858
942
|
if (transition === 'triggered_sl' || transition === 'triggered_tp' || transition === 'forced_close') {
|
|
859
943
|
const row = this.getHlBracketCoordinator().getLedger().getAll().find((r) => update.order.cloid && (r.slCid === update.order.cloid || r.tpCid === update.order.cloid));
|
|
860
944
|
const symbol = row?.symbol;
|
|
861
945
|
if (symbol) {
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
946
|
+
const qty = row?.qty ?? 0;
|
|
947
|
+
const timer = setTimeout(() => {
|
|
948
|
+
this.emit('drift_detected', {
|
|
949
|
+
timestamp: new Date().toISOString(),
|
|
950
|
+
drifts: [
|
|
951
|
+
{
|
|
952
|
+
type: 'closed',
|
|
953
|
+
symbol,
|
|
954
|
+
localContracts: qty,
|
|
955
|
+
},
|
|
956
|
+
],
|
|
957
|
+
});
|
|
958
|
+
}, TRIGGER_DRIFT_GRACE_MS);
|
|
959
|
+
timer.unref?.();
|
|
872
960
|
}
|
|
873
961
|
}
|
|
874
962
|
}
|