openbroker 1.10.0 → 1.12.0

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +52 -0
  3. package/SKILL.md +45 -2
  4. package/bin/cli.ts +11 -0
  5. package/dist/auto/examples/grid.js +1 -1
  6. package/dist/auto/examples/mm-maker.js +4 -4
  7. package/dist/auto/examples/mm-spread.js +4 -4
  8. package/dist/core/client.d.ts +23 -26
  9. package/dist/core/client.d.ts.map +1 -1
  10. package/dist/core/client.js +24 -3
  11. package/dist/core/types.d.ts +22 -0
  12. package/dist/core/types.d.ts.map +1 -1
  13. package/dist/core/ws.d.ts +5 -0
  14. package/dist/core/ws.d.ts.map +1 -1
  15. package/dist/core/ws.js +9 -7
  16. package/dist/guardian/cli.d.ts +2 -0
  17. package/dist/guardian/cli.d.ts.map +1 -0
  18. package/dist/guardian/cli.js +288 -0
  19. package/dist/guardian/engine.d.ts +107 -0
  20. package/dist/guardian/engine.d.ts.map +1 -0
  21. package/dist/guardian/engine.js +526 -0
  22. package/dist/guardian/rules.d.ts +35 -0
  23. package/dist/guardian/rules.d.ts.map +1 -0
  24. package/dist/guardian/rules.js +237 -0
  25. package/dist/guardian/telegram.d.ts +24 -0
  26. package/dist/guardian/telegram.d.ts.map +1 -0
  27. package/dist/guardian/telegram.js +125 -0
  28. package/dist/guardian/types.d.ts +90 -0
  29. package/dist/guardian/types.d.ts.map +1 -0
  30. package/dist/guardian/types.js +45 -0
  31. package/dist/lib.d.ts +8 -0
  32. package/dist/lib.d.ts.map +1 -1
  33. package/dist/lib.js +5 -0
  34. package/dist/operations/cancel.js +4 -1
  35. package/dist/operations/chase.d.ts +3 -1
  36. package/dist/operations/chase.d.ts.map +1 -1
  37. package/dist/operations/chase.js +4 -2
  38. package/package.json +2 -2
  39. package/scripts/auto/examples/grid.ts +1 -1
  40. package/scripts/auto/examples/mm-maker.ts +4 -4
  41. package/scripts/auto/examples/mm-spread.ts +4 -4
  42. package/scripts/core/client.ts +29 -3
  43. package/scripts/core/types.ts +23 -0
  44. package/scripts/core/ws.ts +9 -6
  45. package/scripts/guardian/cli.ts +322 -0
  46. package/scripts/guardian/engine.ts +614 -0
  47. package/scripts/guardian/rules.ts +265 -0
  48. package/scripts/guardian/telegram.ts +147 -0
  49. package/scripts/guardian/types.ts +160 -0
  50. package/scripts/lib.ts +33 -0
  51. package/scripts/operations/cancel.ts +4 -1
  52. package/scripts/operations/chase.ts +5 -3
@@ -0,0 +1,614 @@
1
+ // Guardian engine: REST poll (clearinghouseState + frontendOpenOrders) + price
2
+ // feed + rule engine + alert dispatch. Read-only — never places orders. This is
3
+ // the CLI/library counterpart of the hosted guardian's watcher: same rules and
4
+ // cadences, scaled down from a multi-tenant fleet to the handful of addresses a
5
+ // CLI user or agent watches, so no weight budget or tier queue is needed.
6
+
7
+ import { getClient, type HyperliquidClient } from '../core/client.js';
8
+ import { getWebSocket, resetWebSocket } from '../core/ws.js';
9
+ import type { ClearinghouseState, FrontendOpenOrder } from '../core/types.js';
10
+ import { GuardianRiskEngine } from './rules.js';
11
+ import {
12
+ formatTelegramAlert,
13
+ loadTelegramSettings,
14
+ sendTelegramMessage,
15
+ } from './telegram.js';
16
+ import type {
17
+ GuardianAlert,
18
+ GuardianPositionSnap,
19
+ GuardianPrefs,
20
+ GuardianPriceEntry,
21
+ GuardianTargetState,
22
+ GuardianThresholds,
23
+ } from './types.js';
24
+ import { GUARDIAN_SEVERITY_RANK } from './types.js';
25
+
26
+ /** Price entries older than this are stale — alerts are suppressed. */
27
+ const PRICE_STALE_MS = 120_000;
28
+ /** Poll clearinghouseState this often when a position is within 10% of liquidation. */
29
+ const HOT_STATE_INTERVAL_MS = 15_000;
30
+ const HOT_LIQ_DIST = 0.10;
31
+
32
+ const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
33
+
34
+ export interface GuardianTelegramChannel {
35
+ token: string;
36
+ chatId: string;
37
+ }
38
+
39
+ export interface GuardianAgentHookChannel {
40
+ hooksToken: string;
41
+ gatewayPort?: number;
42
+ }
43
+
44
+ export interface GuardianOptions {
45
+ /** Addresses to watch. Default: the configured account address. */
46
+ addresses?: string[];
47
+ /** Min severity + per-rule opt-out (same contract as the hosted guardian). */
48
+ prefs?: GuardianPrefs;
49
+ /** Rule threshold overrides. */
50
+ thresholds?: Partial<GuardianThresholds>;
51
+ /** clearinghouseState cadence (ms). Default 30s; drops to 15s near liquidation. */
52
+ pollIntervalMs?: number;
53
+ /** frontendOpenOrders cadence (ms). Default 120s. */
54
+ ordersIntervalMs?: number;
55
+ /** Price (mids + main-dex ctxs) cadence (ms). Default 30s. */
56
+ pricesIntervalMs?: number;
57
+ /** HIP-3 mark/funding cadence (ms), fetched only while HIP-3 positions exist. Default 5m. */
58
+ hip3PricesIntervalMs?: number;
59
+ /** Rule-engine tick (ms), pure in-memory. Default 5s. */
60
+ riskIntervalMs?: number;
61
+ /**
62
+ * Subscribe WS userEvents for instant liquidation alerts and fill-triggered
63
+ * re-polls. Only active when exactly one address is watched (the feed is not
64
+ * tagged per user). Default true.
65
+ */
66
+ useWebSocket?: boolean;
67
+ /** Telegram channel. Default: from TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID env. Pass false to disable. */
68
+ telegram?: GuardianTelegramChannel | false;
69
+ /** OpenClaw agent hook channel. Default: from OPENCLAW_HOOKS_TOKEN/OPENCLAW_GATEWAY_PORT env. Pass false to disable. */
70
+ agentHook?: GuardianAgentHookChannel | false;
71
+ /** Print alerts as JSON lines instead of human-readable text. */
72
+ json?: boolean;
73
+ /** Suppress console alert output entirely (library consumers). */
74
+ quiet?: boolean;
75
+ verbose?: boolean;
76
+ /** Callback fired for every alert that passes prefs. */
77
+ onAlert?: (alert: GuardianAlert) => void;
78
+ /** Injectable client (tests / plugin). Default getClient(). */
79
+ client?: HyperliquidClient;
80
+ }
81
+
82
+ export interface GuardianStats {
83
+ addresses: string[];
84
+ statePolls: number;
85
+ orderPolls: number;
86
+ alertsFired: number;
87
+ alertsDelivered: number;
88
+ deliveryErrors: number;
89
+ evaluations: number;
90
+ pricesAt: number | null;
91
+ channels: string[];
92
+ }
93
+
94
+ export interface GuardianHandle {
95
+ stop(): Promise<void>;
96
+ getStats(): GuardianStats;
97
+ getTargets(): GuardianTargetState[];
98
+ }
99
+
100
+ function num(v: string | null | undefined): number | null {
101
+ if (v === null || v === undefined) return null;
102
+ const n = parseFloat(v);
103
+ return Number.isFinite(n) ? n : null;
104
+ }
105
+
106
+ function parsePositions(state: ClearinghouseState): Map<string, GuardianPositionSnap> {
107
+ const out = new Map<string, GuardianPositionSnap>();
108
+ for (const ap of state.assetPositions ?? []) {
109
+ const p = ap.position;
110
+ const szi = num(p.szi);
111
+ if (szi === null || szi === 0) continue;
112
+ out.set(p.coin, {
113
+ coin: p.coin,
114
+ szi,
115
+ side: szi > 0 ? 'long' : 'short',
116
+ entryPx: num(p.entryPx),
117
+ positionValue: num(p.positionValue) ?? 0,
118
+ unrealizedPnl: num(p.unrealizedPnl) ?? 0,
119
+ liquidationPx: num(p.liquidationPx ?? null),
120
+ leverage: p.leverage?.value ?? 0,
121
+ marginUsed: num(p.marginUsed) ?? 0,
122
+ });
123
+ }
124
+ return out;
125
+ }
126
+
127
+ export class Guardian {
128
+ private readonly client: HyperliquidClient;
129
+ private readonly targets = new Map<string, GuardianTargetState>();
130
+ private readonly prices = new Map<string, GuardianPriceEntry>();
131
+ private readonly risk: GuardianRiskEngine;
132
+ private readonly prefs: GuardianPrefs;
133
+ private readonly telegram: GuardianTelegramChannel | null;
134
+ private readonly agentHook: GuardianAgentHookChannel | null;
135
+ private readonly opts: GuardianOptions;
136
+
137
+ private timers: Array<ReturnType<typeof setInterval>> = [];
138
+ private wsSubs: Array<{ unsubscribe(): Promise<void> }> = [];
139
+ private wsUsed = false;
140
+ private stopped = false;
141
+ private polling = false;
142
+
143
+ private statePolls = 0;
144
+ private orderPolls = 0;
145
+ private alertsFired = 0;
146
+ private alertsDelivered = 0;
147
+ private deliveryErrors = 0;
148
+ private lastPricesAt: number | null = null;
149
+
150
+ constructor(addresses: string[], opts: GuardianOptions = {}) {
151
+ this.opts = opts;
152
+ this.client = opts.client ?? getClient();
153
+ this.prefs = opts.prefs ?? {};
154
+
155
+ for (const address of addresses) {
156
+ this.targets.set(address, {
157
+ address,
158
+ positions: new Map(),
159
+ equity: 0,
160
+ marginUsedPct: 0,
161
+ seeded: false,
162
+ lastStateAt: null,
163
+ orders: null,
164
+ lastOrdersAt: null,
165
+ firstSeen: new Map(),
166
+ minLiqDist: null,
167
+ });
168
+ }
169
+
170
+ const self = this;
171
+ const priceView = {
172
+ get: (coin: string) => self.prices.get(coin),
173
+ // Stale when nothing has refreshed within the window (or nothing seeded yet).
174
+ get stale(): boolean {
175
+ return self.lastPricesAt === null || Date.now() - self.lastPricesAt > PRICE_STALE_MS;
176
+ },
177
+ };
178
+ this.risk = new GuardianRiskEngine(priceView, (alert) => void this.dispatch(alert), opts.thresholds);
179
+
180
+ if (opts.telegram === false) {
181
+ this.telegram = null;
182
+ } else if (opts.telegram) {
183
+ this.telegram = opts.telegram;
184
+ } else {
185
+ const settings = loadTelegramSettings();
186
+ this.telegram = settings.token && settings.chatId
187
+ ? { token: settings.token, chatId: settings.chatId }
188
+ : null;
189
+ }
190
+
191
+ if (opts.agentHook === false) {
192
+ this.agentHook = null;
193
+ } else if (opts.agentHook) {
194
+ this.agentHook = opts.agentHook;
195
+ } else {
196
+ const hooksToken = process.env.OPENCLAW_HOOKS_TOKEN;
197
+ const portStr = process.env.OPENCLAW_GATEWAY_PORT;
198
+ const gatewayPort = portStr ? parseInt(portStr, 10) : undefined;
199
+ this.agentHook = hooksToken
200
+ ? { hooksToken, gatewayPort: gatewayPort && !isNaN(gatewayPort) ? gatewayPort : undefined }
201
+ : null;
202
+ }
203
+ }
204
+
205
+ channels(): string[] {
206
+ const out = ['console'];
207
+ if (this.telegram) out.push('telegram');
208
+ if (this.agentHook) out.push('agent-hook');
209
+ if (this.opts.onAlert) out.push('callback');
210
+ return out;
211
+ }
212
+
213
+ async start(): Promise<void> {
214
+ const pollMs = this.opts.pollIntervalMs ?? 30_000;
215
+ const ordersMs = this.opts.ordersIntervalMs ?? 120_000;
216
+ const pricesMs = this.opts.pricesIntervalMs ?? 30_000;
217
+ const hip3Ms = this.opts.hip3PricesIntervalMs ?? 300_000;
218
+ const riskMs = this.opts.riskIntervalMs ?? 5_000;
219
+
220
+ // Seed everything once before the intervals so the first risk tick has data.
221
+ await this.refreshPrices();
222
+ await this.refreshHip3Prices();
223
+ for (const target of this.targets.values()) {
224
+ await this.pollState(target);
225
+ await this.pollOrders(target);
226
+ }
227
+
228
+ this.timers.push(setInterval(() => void this.pollAllStates(pollMs), Math.min(pollMs, HOT_STATE_INTERVAL_MS)));
229
+ this.timers.push(setInterval(() => void this.pollAllOrders(), ordersMs));
230
+ this.timers.push(setInterval(() => void this.refreshPrices(), pricesMs));
231
+ this.timers.push(setInterval(() => void this.refreshHip3Prices(), hip3Ms));
232
+ this.timers.push(setInterval(() => this.risk.tick(this.targets.values()), riskMs));
233
+
234
+ if ((this.opts.useWebSocket ?? true) && this.targets.size === 1) {
235
+ await this.startWebSocket();
236
+ }
237
+ }
238
+
239
+ async stop(): Promise<void> {
240
+ this.stopped = true;
241
+ for (const timer of this.timers) clearInterval(timer);
242
+ this.timers = [];
243
+ for (const sub of this.wsSubs) {
244
+ try { await sub.unsubscribe(); } catch { /* ignore */ }
245
+ }
246
+ this.wsSubs = [];
247
+ if (this.wsUsed) {
248
+ try { await resetWebSocket(); } catch { /* ignore */ }
249
+ }
250
+ }
251
+
252
+ getStats(): GuardianStats {
253
+ return {
254
+ addresses: [...this.targets.keys()],
255
+ statePolls: this.statePolls,
256
+ orderPolls: this.orderPolls,
257
+ alertsFired: this.alertsFired,
258
+ alertsDelivered: this.alertsDelivered,
259
+ deliveryErrors: this.deliveryErrors,
260
+ evaluations: this.risk.getStats().evaluations,
261
+ pricesAt: this.lastPricesAt,
262
+ channels: this.channels(),
263
+ };
264
+ }
265
+
266
+ getTargets(): GuardianTargetState[] {
267
+ return [...this.targets.values()];
268
+ }
269
+
270
+ // ── WebSocket fast lane (single-address runs) ─────────────────────
271
+
272
+ private async startWebSocket(): Promise<void> {
273
+ const address = [...this.targets.keys()][0] as `0x${string}`;
274
+ try {
275
+ const ws = getWebSocket();
276
+ this.wsUsed = true;
277
+ if (!ws.connected) await ws.connect();
278
+ ws.on('userEvent', (data) => {
279
+ if (this.stopped) return;
280
+ if ('liquidation' in data && data.liquidation) {
281
+ const liq = data.liquidation as { lid?: number; liquidated_account_value?: string };
282
+ void this.dispatch({
283
+ time: Date.now(),
284
+ address,
285
+ rule: 'liq_proximity',
286
+ coin: null,
287
+ severity: 'critical',
288
+ dedupKey: `liquidated:${liq.lid ?? Date.now()}`,
289
+ message: `LIQUIDATED: account was liquidated (account value $${liq.liquidated_account_value ?? '?'})`,
290
+ payload: { kind: 'liquidation', ...liq },
291
+ });
292
+ void this.pollState(this.targets.get(address)!);
293
+ } else if ('fills' in data && Array.isArray(data.fills)) {
294
+ for (const fill of data.fills) {
295
+ const liq = (fill as { liquidation?: { markPx?: string } }).liquidation;
296
+ if (liq) {
297
+ void this.dispatch({
298
+ time: Date.now(),
299
+ address,
300
+ rule: 'liq_proximity',
301
+ coin: (fill as { coin?: string }).coin ?? null,
302
+ severity: 'critical',
303
+ dedupKey: `liq-fill:${(fill as { tid?: number }).tid ?? Date.now()}`,
304
+ message: `LIQUIDATED: forced ${(fill as { dir?: string }).dir ?? 'close'} on ${(fill as { coin?: string }).coin ?? '?'} at mark $${liq.markPx ?? '?'}`,
305
+ payload: { kind: 'liquidation_fill', fill },
306
+ });
307
+ }
308
+ }
309
+ // Any fill: re-poll state now so lifecycle diffs land fast.
310
+ void this.pollState(this.targets.get(address)!);
311
+ }
312
+ });
313
+ ws.on('error', () => { /* reconnects internally; REST polling continues regardless */ });
314
+ this.wsSubs.push(await ws.subscribeUserEvents(address));
315
+ this.log(`WS userEvents subscribed for ${address}`);
316
+ } catch (err) {
317
+ this.log(`WS unavailable, continuing REST-only: ${err instanceof Error ? err.message : String(err)}`);
318
+ }
319
+ }
320
+
321
+ // ── Price feed (REST) ─────────────────────────────────────────────
322
+
323
+ private hasHip3Positions(): boolean {
324
+ for (const target of this.targets.values()) {
325
+ for (const coin of target.positions.keys()) {
326
+ if (coin.includes(':')) return true;
327
+ }
328
+ }
329
+ return false;
330
+ }
331
+
332
+ private async refreshPrices(): Promise<void> {
333
+ const now = Date.now();
334
+ try {
335
+ const [mids, meta] = await Promise.all([
336
+ this.client.getAllMids(),
337
+ this.client.getMetaAndAssetCtxs(),
338
+ ]);
339
+
340
+ for (const [coin, mid] of Object.entries(mids)) {
341
+ const entry = this.prices.get(coin) ?? { mid: null, mark: null, funding: null, updatedAt: 0 };
342
+ entry.mid = num(mid);
343
+ entry.updatedAt = now;
344
+ this.prices.set(coin, entry);
345
+ }
346
+
347
+ for (let i = 0; i < meta.meta.universe.length; i++) {
348
+ const coin = meta.meta.universe[i].name;
349
+ const ctx = meta.assetCtxs[i];
350
+ if (!ctx) continue;
351
+ const entry = this.prices.get(coin) ?? { mid: null, mark: null, funding: null, updatedAt: 0 };
352
+ entry.mark = num(ctx.markPx);
353
+ entry.funding = num(ctx.funding);
354
+ entry.updatedAt = now;
355
+ this.prices.set(coin, entry);
356
+ }
357
+
358
+ this.lastPricesAt = now;
359
+ } catch (err) {
360
+ this.log(`price refresh failed: ${err instanceof Error ? err.message : String(err)}`);
361
+ }
362
+ }
363
+
364
+ /** HIP-3 mark/funding — heavier (per-dex metaAndAssetCtxs), only while HIP-3 positions exist. */
365
+ private async refreshHip3Prices(): Promise<void> {
366
+ if (!this.hasHip3Positions()) return;
367
+ const now = Date.now();
368
+ try {
369
+ const allPerps = await this.client.getAllPerpMetas();
370
+ for (const dexData of allPerps) {
371
+ if (!dexData.dexName) continue; // main dex handled by refreshPrices
372
+ for (let i = 0; i < dexData.meta.universe.length; i++) {
373
+ const coin = dexData.meta.universe[i].name; // already dex-prefixed, e.g. "hyna:XMR"
374
+ const ctx = dexData.assetCtxs[i];
375
+ if (!ctx) continue;
376
+ const entry = this.prices.get(coin) ?? { mid: null, mark: null, funding: null, updatedAt: 0 };
377
+ entry.mark = num(ctx.markPx);
378
+ entry.funding = num(ctx.funding);
379
+ if (entry.mid === null) entry.mid = num(ctx.midPx ?? ctx.markPx);
380
+ entry.updatedAt = now;
381
+ this.prices.set(coin, entry);
382
+ }
383
+ }
384
+ } catch (err) {
385
+ this.log(`HIP-3 price refresh failed: ${err instanceof Error ? err.message : String(err)}`);
386
+ }
387
+ }
388
+
389
+ // ── State + orders polling ────────────────────────────────────────
390
+
391
+ private async pollAllStates(baseIntervalMs: number): Promise<void> {
392
+ if (this.polling) return; // don't overlap slow cycles
393
+ this.polling = true;
394
+ try {
395
+ const now = Date.now();
396
+ for (const target of this.targets.values()) {
397
+ // Hot cadence near liquidation, base cadence otherwise. The interval
398
+ // fires at min(base, 15s); skip targets whose cadence isn't due.
399
+ const nearLiq = target.minLiqDist !== null && target.minLiqDist < HOT_LIQ_DIST;
400
+ const interval = nearLiq ? Math.min(baseIntervalMs, HOT_STATE_INTERVAL_MS) : baseIntervalMs;
401
+ if (target.lastStateAt !== null && now - target.lastStateAt < interval - 500) continue;
402
+ await this.pollState(target);
403
+ }
404
+ } finally {
405
+ this.polling = false;
406
+ }
407
+ }
408
+
409
+ private async pollAllOrders(): Promise<void> {
410
+ for (const target of this.targets.values()) {
411
+ await this.pollOrders(target);
412
+ }
413
+ }
414
+
415
+ private async pollState(target: GuardianTargetState): Promise<void> {
416
+ const now = Date.now();
417
+ try {
418
+ const state = await this.client.getUserStateAll(target.address);
419
+ const positions = parsePositions(state);
420
+
421
+ const events = target.seeded ? this.diff(target, positions) : [];
422
+
423
+ // Maintain firstSeen for the no-TP/SL rule.
424
+ for (const coin of positions.keys()) {
425
+ if (!target.firstSeen.has(coin)) target.firstSeen.set(coin, now);
426
+ }
427
+ for (const coin of [...target.firstSeen.keys()]) {
428
+ if (!positions.has(coin)) target.firstSeen.delete(coin);
429
+ }
430
+
431
+ const equity = num(state.marginSummary?.accountValue) ?? 0;
432
+ const marginUsed = num(state.marginSummary?.totalMarginUsed) ?? 0;
433
+
434
+ target.positions = positions;
435
+ target.equity = equity;
436
+ target.marginUsedPct = equity > 0 ? (marginUsed / equity) * 100 : 0;
437
+ target.seeded = true;
438
+ target.lastStateAt = now;
439
+ this.statePolls++;
440
+
441
+ for (const event of events) void this.dispatch(event);
442
+ } catch (err) {
443
+ this.log(`state poll failed for ${target.address}: ${err instanceof Error ? err.message : String(err)}`);
444
+ }
445
+ }
446
+
447
+ private async pollOrders(target: GuardianTargetState): Promise<void> {
448
+ try {
449
+ const orders: FrontendOpenOrder[] = await this.client.getFrontendOpenOrders(target.address);
450
+ // HIP-3 positions rest their TP/SL on their own dex — include those books
451
+ // so the no-TP/SL rule doesn't false-positive.
452
+ const dexes = new Set<string>();
453
+ for (const coin of target.positions.keys()) {
454
+ const idx = coin.indexOf(':');
455
+ if (idx > 0) dexes.add(coin.slice(0, idx));
456
+ }
457
+ for (const dex of dexes) {
458
+ try {
459
+ orders.push(...await this.client.getFrontendOpenOrders(target.address, dex));
460
+ } catch (err) {
461
+ this.log(`orders poll failed for dex ${dex}: ${err instanceof Error ? err.message : String(err)}`);
462
+ }
463
+ }
464
+ target.orders = orders;
465
+ target.lastOrdersAt = Date.now();
466
+ this.orderPolls++;
467
+ } catch (err) {
468
+ this.log(`orders poll failed for ${target.address}: ${err instanceof Error ? err.message : String(err)}`);
469
+ }
470
+ }
471
+
472
+ /** Snapshot diff → position lifecycle alerts (mirrors the hosted poller). */
473
+ private diff(target: GuardianTargetState, current: Map<string, GuardianPositionSnap>): GuardianAlert[] {
474
+ const now = Date.now();
475
+ const events: GuardianAlert[] = [];
476
+ const prev = target.positions;
477
+
478
+ for (const [coin, pos] of current) {
479
+ if (!prev.has(coin)) {
480
+ events.push({
481
+ time: now,
482
+ address: target.address,
483
+ rule: 'position_lifecycle',
484
+ coin,
485
+ severity: 'info',
486
+ dedupKey: `opened:${now}`,
487
+ message: `Position opened: ${pos.side} ${Math.abs(pos.szi)} ${coin} at $${pos.entryPx ?? '?'}`,
488
+ payload: { kind: 'opened', coin, side: pos.side, size: pos.szi, entryPx: pos.entryPx },
489
+ });
490
+ }
491
+ }
492
+
493
+ for (const [coin, pos] of prev) {
494
+ if (!current.has(coin)) {
495
+ events.push({
496
+ time: now,
497
+ address: target.address,
498
+ rule: 'position_lifecycle',
499
+ coin,
500
+ severity: 'info',
501
+ dedupKey: `closed:${now}`,
502
+ message: `Position closed: ${coin} (was ${pos.szi} at $${pos.entryPx ?? '?'}, last uPnL $${pos.unrealizedPnl.toFixed(2)})`,
503
+ payload: { kind: 'closed', coin, previousSize: pos.szi, entryPx: pos.entryPx, lastPnl: pos.unrealizedPnl },
504
+ });
505
+ }
506
+ }
507
+
508
+ for (const [coin, pos] of current) {
509
+ const prevPos = prev.get(coin);
510
+ if (!prevPos || prevPos.szi === pos.szi) continue;
511
+ const change = pos.szi - prevPos.szi;
512
+ const action = Math.abs(pos.szi) > Math.abs(prevPos.szi) ? 'increased' : 'decreased';
513
+ events.push({
514
+ time: now,
515
+ address: target.address,
516
+ rule: 'position_lifecycle',
517
+ coin,
518
+ severity: 'info',
519
+ dedupKey: `size:${now}`,
520
+ message: `Position ${coin} size ${action}: ${prevPos.szi} → ${pos.szi} (${change > 0 ? '+' : ''}${change.toFixed(6)})`,
521
+ payload: { kind: 'size_changed', coin, previousSize: prevPos.szi, newSize: pos.szi, change },
522
+ });
523
+ }
524
+
525
+ return events;
526
+ }
527
+
528
+ // ── Dispatch ──────────────────────────────────────────────────────
529
+
530
+ private passesPrefs(alert: GuardianAlert): boolean {
531
+ if (this.prefs.rules?.[alert.rule] === false) return false;
532
+ const min = this.prefs.minSeverity ?? 'info';
533
+ return GUARDIAN_SEVERITY_RANK[alert.severity] >= GUARDIAN_SEVERITY_RANK[min];
534
+ }
535
+
536
+ private async dispatch(alert: GuardianAlert): Promise<void> {
537
+ if (!this.passesPrefs(alert)) return;
538
+ this.alertsFired++;
539
+
540
+ if (!this.opts.quiet) {
541
+ if (this.opts.json) {
542
+ console.log(JSON.stringify(alert));
543
+ } else {
544
+ const time = new Date(alert.time).toISOString().slice(11, 19);
545
+ const scope = alert.coin ? ` ${alert.coin}` : '';
546
+ console.log(`[${time}] ${alert.severity.toUpperCase()} ${alert.rule}${scope} — ${alert.message}`);
547
+ }
548
+ }
549
+
550
+ try {
551
+ this.opts.onAlert?.(alert);
552
+ } catch (err) {
553
+ this.log(`onAlert callback threw: ${err instanceof Error ? err.message : String(err)}`);
554
+ }
555
+
556
+ if (this.telegram) {
557
+ try {
558
+ await sendTelegramMessage(this.telegram.token, this.telegram.chatId, formatTelegramAlert(alert));
559
+ this.alertsDelivered++;
560
+ } catch (err) {
561
+ this.deliveryErrors++;
562
+ this.log(`telegram delivery failed: ${err instanceof Error ? err.message : String(err)}`);
563
+ }
564
+ }
565
+
566
+ if (this.agentHook) {
567
+ try {
568
+ const port = this.agentHook.gatewayPort ?? 18789;
569
+ const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
570
+ method: 'POST',
571
+ headers: {
572
+ 'Content-Type': 'application/json',
573
+ 'Authorization': `Bearer ${this.agentHook.hooksToken}`,
574
+ },
575
+ body: JSON.stringify({
576
+ message: `[guardian] [${alert.severity}] ${alert.message}`,
577
+ name: 'openbroker-guardian',
578
+ wakeMode: 'now',
579
+ }),
580
+ signal: AbortSignal.timeout(5_000),
581
+ });
582
+ if (res.ok) this.alertsDelivered++;
583
+ else {
584
+ this.deliveryErrors++;
585
+ this.log(`agent hook delivery failed: HTTP ${res.status}`);
586
+ }
587
+ } catch (err) {
588
+ this.deliveryErrors++;
589
+ this.log(`agent hook delivery failed: ${err instanceof Error ? err.message : String(err)}`);
590
+ }
591
+ }
592
+ }
593
+
594
+ private log(message: string): void {
595
+ if (this.opts.verbose) console.error(`[guardian] ${message}`);
596
+ }
597
+ }
598
+
599
+ /** Start watching. Resolves once the first snapshot is seeded. */
600
+ export async function startGuardian(opts: GuardianOptions = {}): Promise<GuardianHandle> {
601
+ const client = opts.client ?? getClient();
602
+ const addresses = (opts.addresses && opts.addresses.length > 0 ? opts.addresses : [client.address])
603
+ .map((a) => a.toLowerCase());
604
+
605
+ for (const address of addresses) {
606
+ if (!ADDR_RE.test(address)) {
607
+ throw new Error(`Invalid address: ${address}. Pass --address 0x... or configure HYPERLIQUID_ACCOUNT_ADDRESS.`);
608
+ }
609
+ }
610
+
611
+ const guardian = new Guardian(addresses, { ...opts, client });
612
+ await guardian.start();
613
+ return guardian;
614
+ }