openbroker 1.9.6 → 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.
- package/CHANGELOG.md +35 -0
- package/README.md +52 -0
- package/SKILL.md +57 -5
- package/bin/cli.ts +11 -0
- package/dist/auto/examples/grid.js +1 -1
- package/dist/auto/examples/mm-maker.js +4 -4
- package/dist/auto/examples/mm-spread.js +4 -4
- package/dist/core/client.d.ts +83 -32
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +214 -29
- package/dist/core/types.d.ts +22 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/utils.d.ts +29 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/core/utils.js +27 -0
- package/dist/core/ws.d.ts +5 -0
- package/dist/core/ws.d.ts.map +1 -1
- package/dist/core/ws.js +9 -7
- package/dist/guardian/cli.d.ts +2 -0
- package/dist/guardian/cli.d.ts.map +1 -0
- package/dist/guardian/cli.js +288 -0
- package/dist/guardian/engine.d.ts +107 -0
- package/dist/guardian/engine.d.ts.map +1 -0
- package/dist/guardian/engine.js +526 -0
- package/dist/guardian/rules.d.ts +35 -0
- package/dist/guardian/rules.d.ts.map +1 -0
- package/dist/guardian/rules.js +237 -0
- package/dist/guardian/telegram.d.ts +24 -0
- package/dist/guardian/telegram.d.ts.map +1 -0
- package/dist/guardian/telegram.js +125 -0
- package/dist/guardian/types.d.ts +90 -0
- package/dist/guardian/types.d.ts.map +1 -0
- package/dist/guardian/types.js +45 -0
- package/dist/lib.d.ts +10 -1
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +6 -1
- package/dist/operations/advanced-orders.test.js +302 -13
- package/dist/operations/bracket.d.ts +35 -5
- package/dist/operations/bracket.d.ts.map +1 -1
- package/dist/operations/bracket.js +238 -64
- package/dist/operations/cancel.js +4 -1
- package/dist/operations/chase.d.ts +4 -2
- package/dist/operations/chase.d.ts.map +1 -1
- package/dist/operations/chase.js +61 -24
- package/dist/operations/scale.d.ts.map +1 -1
- package/dist/operations/scale.js +10 -1
- package/dist/operations/set-tpsl.js +69 -57
- package/dist/operations/trigger-order.js +18 -6
- package/dist/operations/twap.js +13 -1
- package/package.json +2 -2
- package/scripts/auto/examples/grid.ts +1 -1
- package/scripts/auto/examples/mm-maker.ts +4 -4
- package/scripts/auto/examples/mm-spread.ts +4 -4
- package/scripts/core/client.ts +278 -33
- package/scripts/core/types.ts +23 -0
- package/scripts/core/utils.ts +37 -0
- package/scripts/core/ws.ts +9 -6
- package/scripts/guardian/cli.ts +322 -0
- package/scripts/guardian/engine.ts +614 -0
- package/scripts/guardian/rules.ts +265 -0
- package/scripts/guardian/telegram.ts +147 -0
- package/scripts/guardian/types.ts +160 -0
- package/scripts/lib.ts +36 -0
- package/scripts/operations/advanced-orders.test.ts +347 -14
- package/scripts/operations/bracket.ts +252 -73
- package/scripts/operations/cancel.ts +4 -1
- package/scripts/operations/chase.ts +61 -26
- package/scripts/operations/scale.ts +10 -1
- package/scripts/operations/set-tpsl.ts +62 -57
- package/scripts/operations/trigger-order.ts +20 -6
- package/scripts/operations/twap.ts +14 -1
|
@@ -0,0 +1,526 @@
|
|
|
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
|
+
import { getClient } from '../core/client.js';
|
|
7
|
+
import { getWebSocket, resetWebSocket } from '../core/ws.js';
|
|
8
|
+
import { GuardianRiskEngine } from './rules.js';
|
|
9
|
+
import { formatTelegramAlert, loadTelegramSettings, sendTelegramMessage, } from './telegram.js';
|
|
10
|
+
import { GUARDIAN_SEVERITY_RANK } from './types.js';
|
|
11
|
+
/** Price entries older than this are stale — alerts are suppressed. */
|
|
12
|
+
const PRICE_STALE_MS = 120_000;
|
|
13
|
+
/** Poll clearinghouseState this often when a position is within 10% of liquidation. */
|
|
14
|
+
const HOT_STATE_INTERVAL_MS = 15_000;
|
|
15
|
+
const HOT_LIQ_DIST = 0.10;
|
|
16
|
+
const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
17
|
+
function num(v) {
|
|
18
|
+
if (v === null || v === undefined)
|
|
19
|
+
return null;
|
|
20
|
+
const n = parseFloat(v);
|
|
21
|
+
return Number.isFinite(n) ? n : null;
|
|
22
|
+
}
|
|
23
|
+
function parsePositions(state) {
|
|
24
|
+
const out = new Map();
|
|
25
|
+
for (const ap of state.assetPositions ?? []) {
|
|
26
|
+
const p = ap.position;
|
|
27
|
+
const szi = num(p.szi);
|
|
28
|
+
if (szi === null || szi === 0)
|
|
29
|
+
continue;
|
|
30
|
+
out.set(p.coin, {
|
|
31
|
+
coin: p.coin,
|
|
32
|
+
szi,
|
|
33
|
+
side: szi > 0 ? 'long' : 'short',
|
|
34
|
+
entryPx: num(p.entryPx),
|
|
35
|
+
positionValue: num(p.positionValue) ?? 0,
|
|
36
|
+
unrealizedPnl: num(p.unrealizedPnl) ?? 0,
|
|
37
|
+
liquidationPx: num(p.liquidationPx ?? null),
|
|
38
|
+
leverage: p.leverage?.value ?? 0,
|
|
39
|
+
marginUsed: num(p.marginUsed) ?? 0,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
export class Guardian {
|
|
45
|
+
client;
|
|
46
|
+
targets = new Map();
|
|
47
|
+
prices = new Map();
|
|
48
|
+
risk;
|
|
49
|
+
prefs;
|
|
50
|
+
telegram;
|
|
51
|
+
agentHook;
|
|
52
|
+
opts;
|
|
53
|
+
timers = [];
|
|
54
|
+
wsSubs = [];
|
|
55
|
+
wsUsed = false;
|
|
56
|
+
stopped = false;
|
|
57
|
+
polling = false;
|
|
58
|
+
statePolls = 0;
|
|
59
|
+
orderPolls = 0;
|
|
60
|
+
alertsFired = 0;
|
|
61
|
+
alertsDelivered = 0;
|
|
62
|
+
deliveryErrors = 0;
|
|
63
|
+
lastPricesAt = null;
|
|
64
|
+
constructor(addresses, opts = {}) {
|
|
65
|
+
this.opts = opts;
|
|
66
|
+
this.client = opts.client ?? getClient();
|
|
67
|
+
this.prefs = opts.prefs ?? {};
|
|
68
|
+
for (const address of addresses) {
|
|
69
|
+
this.targets.set(address, {
|
|
70
|
+
address,
|
|
71
|
+
positions: new Map(),
|
|
72
|
+
equity: 0,
|
|
73
|
+
marginUsedPct: 0,
|
|
74
|
+
seeded: false,
|
|
75
|
+
lastStateAt: null,
|
|
76
|
+
orders: null,
|
|
77
|
+
lastOrdersAt: null,
|
|
78
|
+
firstSeen: new Map(),
|
|
79
|
+
minLiqDist: null,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const self = this;
|
|
83
|
+
const priceView = {
|
|
84
|
+
get: (coin) => self.prices.get(coin),
|
|
85
|
+
// Stale when nothing has refreshed within the window (or nothing seeded yet).
|
|
86
|
+
get stale() {
|
|
87
|
+
return self.lastPricesAt === null || Date.now() - self.lastPricesAt > PRICE_STALE_MS;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
this.risk = new GuardianRiskEngine(priceView, (alert) => void this.dispatch(alert), opts.thresholds);
|
|
91
|
+
if (opts.telegram === false) {
|
|
92
|
+
this.telegram = null;
|
|
93
|
+
}
|
|
94
|
+
else if (opts.telegram) {
|
|
95
|
+
this.telegram = opts.telegram;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const settings = loadTelegramSettings();
|
|
99
|
+
this.telegram = settings.token && settings.chatId
|
|
100
|
+
? { token: settings.token, chatId: settings.chatId }
|
|
101
|
+
: null;
|
|
102
|
+
}
|
|
103
|
+
if (opts.agentHook === false) {
|
|
104
|
+
this.agentHook = null;
|
|
105
|
+
}
|
|
106
|
+
else if (opts.agentHook) {
|
|
107
|
+
this.agentHook = opts.agentHook;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const hooksToken = process.env.OPENCLAW_HOOKS_TOKEN;
|
|
111
|
+
const portStr = process.env.OPENCLAW_GATEWAY_PORT;
|
|
112
|
+
const gatewayPort = portStr ? parseInt(portStr, 10) : undefined;
|
|
113
|
+
this.agentHook = hooksToken
|
|
114
|
+
? { hooksToken, gatewayPort: gatewayPort && !isNaN(gatewayPort) ? gatewayPort : undefined }
|
|
115
|
+
: null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
channels() {
|
|
119
|
+
const out = ['console'];
|
|
120
|
+
if (this.telegram)
|
|
121
|
+
out.push('telegram');
|
|
122
|
+
if (this.agentHook)
|
|
123
|
+
out.push('agent-hook');
|
|
124
|
+
if (this.opts.onAlert)
|
|
125
|
+
out.push('callback');
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
async start() {
|
|
129
|
+
const pollMs = this.opts.pollIntervalMs ?? 30_000;
|
|
130
|
+
const ordersMs = this.opts.ordersIntervalMs ?? 120_000;
|
|
131
|
+
const pricesMs = this.opts.pricesIntervalMs ?? 30_000;
|
|
132
|
+
const hip3Ms = this.opts.hip3PricesIntervalMs ?? 300_000;
|
|
133
|
+
const riskMs = this.opts.riskIntervalMs ?? 5_000;
|
|
134
|
+
// Seed everything once before the intervals so the first risk tick has data.
|
|
135
|
+
await this.refreshPrices();
|
|
136
|
+
await this.refreshHip3Prices();
|
|
137
|
+
for (const target of this.targets.values()) {
|
|
138
|
+
await this.pollState(target);
|
|
139
|
+
await this.pollOrders(target);
|
|
140
|
+
}
|
|
141
|
+
this.timers.push(setInterval(() => void this.pollAllStates(pollMs), Math.min(pollMs, HOT_STATE_INTERVAL_MS)));
|
|
142
|
+
this.timers.push(setInterval(() => void this.pollAllOrders(), ordersMs));
|
|
143
|
+
this.timers.push(setInterval(() => void this.refreshPrices(), pricesMs));
|
|
144
|
+
this.timers.push(setInterval(() => void this.refreshHip3Prices(), hip3Ms));
|
|
145
|
+
this.timers.push(setInterval(() => this.risk.tick(this.targets.values()), riskMs));
|
|
146
|
+
if ((this.opts.useWebSocket ?? true) && this.targets.size === 1) {
|
|
147
|
+
await this.startWebSocket();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async stop() {
|
|
151
|
+
this.stopped = true;
|
|
152
|
+
for (const timer of this.timers)
|
|
153
|
+
clearInterval(timer);
|
|
154
|
+
this.timers = [];
|
|
155
|
+
for (const sub of this.wsSubs) {
|
|
156
|
+
try {
|
|
157
|
+
await sub.unsubscribe();
|
|
158
|
+
}
|
|
159
|
+
catch { /* ignore */ }
|
|
160
|
+
}
|
|
161
|
+
this.wsSubs = [];
|
|
162
|
+
if (this.wsUsed) {
|
|
163
|
+
try {
|
|
164
|
+
await resetWebSocket();
|
|
165
|
+
}
|
|
166
|
+
catch { /* ignore */ }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
getStats() {
|
|
170
|
+
return {
|
|
171
|
+
addresses: [...this.targets.keys()],
|
|
172
|
+
statePolls: this.statePolls,
|
|
173
|
+
orderPolls: this.orderPolls,
|
|
174
|
+
alertsFired: this.alertsFired,
|
|
175
|
+
alertsDelivered: this.alertsDelivered,
|
|
176
|
+
deliveryErrors: this.deliveryErrors,
|
|
177
|
+
evaluations: this.risk.getStats().evaluations,
|
|
178
|
+
pricesAt: this.lastPricesAt,
|
|
179
|
+
channels: this.channels(),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
getTargets() {
|
|
183
|
+
return [...this.targets.values()];
|
|
184
|
+
}
|
|
185
|
+
// ── WebSocket fast lane (single-address runs) ─────────────────────
|
|
186
|
+
async startWebSocket() {
|
|
187
|
+
const address = [...this.targets.keys()][0];
|
|
188
|
+
try {
|
|
189
|
+
const ws = getWebSocket();
|
|
190
|
+
this.wsUsed = true;
|
|
191
|
+
if (!ws.connected)
|
|
192
|
+
await ws.connect();
|
|
193
|
+
ws.on('userEvent', (data) => {
|
|
194
|
+
if (this.stopped)
|
|
195
|
+
return;
|
|
196
|
+
if ('liquidation' in data && data.liquidation) {
|
|
197
|
+
const liq = data.liquidation;
|
|
198
|
+
void this.dispatch({
|
|
199
|
+
time: Date.now(),
|
|
200
|
+
address,
|
|
201
|
+
rule: 'liq_proximity',
|
|
202
|
+
coin: null,
|
|
203
|
+
severity: 'critical',
|
|
204
|
+
dedupKey: `liquidated:${liq.lid ?? Date.now()}`,
|
|
205
|
+
message: `LIQUIDATED: account was liquidated (account value $${liq.liquidated_account_value ?? '?'})`,
|
|
206
|
+
payload: { kind: 'liquidation', ...liq },
|
|
207
|
+
});
|
|
208
|
+
void this.pollState(this.targets.get(address));
|
|
209
|
+
}
|
|
210
|
+
else if ('fills' in data && Array.isArray(data.fills)) {
|
|
211
|
+
for (const fill of data.fills) {
|
|
212
|
+
const liq = fill.liquidation;
|
|
213
|
+
if (liq) {
|
|
214
|
+
void this.dispatch({
|
|
215
|
+
time: Date.now(),
|
|
216
|
+
address,
|
|
217
|
+
rule: 'liq_proximity',
|
|
218
|
+
coin: fill.coin ?? null,
|
|
219
|
+
severity: 'critical',
|
|
220
|
+
dedupKey: `liq-fill:${fill.tid ?? Date.now()}`,
|
|
221
|
+
message: `LIQUIDATED: forced ${fill.dir ?? 'close'} on ${fill.coin ?? '?'} at mark $${liq.markPx ?? '?'}`,
|
|
222
|
+
payload: { kind: 'liquidation_fill', fill },
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Any fill: re-poll state now so lifecycle diffs land fast.
|
|
227
|
+
void this.pollState(this.targets.get(address));
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
ws.on('error', () => { });
|
|
231
|
+
this.wsSubs.push(await ws.subscribeUserEvents(address));
|
|
232
|
+
this.log(`WS userEvents subscribed for ${address}`);
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
this.log(`WS unavailable, continuing REST-only: ${err instanceof Error ? err.message : String(err)}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// ── Price feed (REST) ─────────────────────────────────────────────
|
|
239
|
+
hasHip3Positions() {
|
|
240
|
+
for (const target of this.targets.values()) {
|
|
241
|
+
for (const coin of target.positions.keys()) {
|
|
242
|
+
if (coin.includes(':'))
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
async refreshPrices() {
|
|
249
|
+
const now = Date.now();
|
|
250
|
+
try {
|
|
251
|
+
const [mids, meta] = await Promise.all([
|
|
252
|
+
this.client.getAllMids(),
|
|
253
|
+
this.client.getMetaAndAssetCtxs(),
|
|
254
|
+
]);
|
|
255
|
+
for (const [coin, mid] of Object.entries(mids)) {
|
|
256
|
+
const entry = this.prices.get(coin) ?? { mid: null, mark: null, funding: null, updatedAt: 0 };
|
|
257
|
+
entry.mid = num(mid);
|
|
258
|
+
entry.updatedAt = now;
|
|
259
|
+
this.prices.set(coin, entry);
|
|
260
|
+
}
|
|
261
|
+
for (let i = 0; i < meta.meta.universe.length; i++) {
|
|
262
|
+
const coin = meta.meta.universe[i].name;
|
|
263
|
+
const ctx = meta.assetCtxs[i];
|
|
264
|
+
if (!ctx)
|
|
265
|
+
continue;
|
|
266
|
+
const entry = this.prices.get(coin) ?? { mid: null, mark: null, funding: null, updatedAt: 0 };
|
|
267
|
+
entry.mark = num(ctx.markPx);
|
|
268
|
+
entry.funding = num(ctx.funding);
|
|
269
|
+
entry.updatedAt = now;
|
|
270
|
+
this.prices.set(coin, entry);
|
|
271
|
+
}
|
|
272
|
+
this.lastPricesAt = now;
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
this.log(`price refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** HIP-3 mark/funding — heavier (per-dex metaAndAssetCtxs), only while HIP-3 positions exist. */
|
|
279
|
+
async refreshHip3Prices() {
|
|
280
|
+
if (!this.hasHip3Positions())
|
|
281
|
+
return;
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
try {
|
|
284
|
+
const allPerps = await this.client.getAllPerpMetas();
|
|
285
|
+
for (const dexData of allPerps) {
|
|
286
|
+
if (!dexData.dexName)
|
|
287
|
+
continue; // main dex handled by refreshPrices
|
|
288
|
+
for (let i = 0; i < dexData.meta.universe.length; i++) {
|
|
289
|
+
const coin = dexData.meta.universe[i].name; // already dex-prefixed, e.g. "hyna:XMR"
|
|
290
|
+
const ctx = dexData.assetCtxs[i];
|
|
291
|
+
if (!ctx)
|
|
292
|
+
continue;
|
|
293
|
+
const entry = this.prices.get(coin) ?? { mid: null, mark: null, funding: null, updatedAt: 0 };
|
|
294
|
+
entry.mark = num(ctx.markPx);
|
|
295
|
+
entry.funding = num(ctx.funding);
|
|
296
|
+
if (entry.mid === null)
|
|
297
|
+
entry.mid = num(ctx.midPx ?? ctx.markPx);
|
|
298
|
+
entry.updatedAt = now;
|
|
299
|
+
this.prices.set(coin, entry);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
this.log(`HIP-3 price refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// ── State + orders polling ────────────────────────────────────────
|
|
308
|
+
async pollAllStates(baseIntervalMs) {
|
|
309
|
+
if (this.polling)
|
|
310
|
+
return; // don't overlap slow cycles
|
|
311
|
+
this.polling = true;
|
|
312
|
+
try {
|
|
313
|
+
const now = Date.now();
|
|
314
|
+
for (const target of this.targets.values()) {
|
|
315
|
+
// Hot cadence near liquidation, base cadence otherwise. The interval
|
|
316
|
+
// fires at min(base, 15s); skip targets whose cadence isn't due.
|
|
317
|
+
const nearLiq = target.minLiqDist !== null && target.minLiqDist < HOT_LIQ_DIST;
|
|
318
|
+
const interval = nearLiq ? Math.min(baseIntervalMs, HOT_STATE_INTERVAL_MS) : baseIntervalMs;
|
|
319
|
+
if (target.lastStateAt !== null && now - target.lastStateAt < interval - 500)
|
|
320
|
+
continue;
|
|
321
|
+
await this.pollState(target);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
this.polling = false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async pollAllOrders() {
|
|
329
|
+
for (const target of this.targets.values()) {
|
|
330
|
+
await this.pollOrders(target);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async pollState(target) {
|
|
334
|
+
const now = Date.now();
|
|
335
|
+
try {
|
|
336
|
+
const state = await this.client.getUserStateAll(target.address);
|
|
337
|
+
const positions = parsePositions(state);
|
|
338
|
+
const events = target.seeded ? this.diff(target, positions) : [];
|
|
339
|
+
// Maintain firstSeen for the no-TP/SL rule.
|
|
340
|
+
for (const coin of positions.keys()) {
|
|
341
|
+
if (!target.firstSeen.has(coin))
|
|
342
|
+
target.firstSeen.set(coin, now);
|
|
343
|
+
}
|
|
344
|
+
for (const coin of [...target.firstSeen.keys()]) {
|
|
345
|
+
if (!positions.has(coin))
|
|
346
|
+
target.firstSeen.delete(coin);
|
|
347
|
+
}
|
|
348
|
+
const equity = num(state.marginSummary?.accountValue) ?? 0;
|
|
349
|
+
const marginUsed = num(state.marginSummary?.totalMarginUsed) ?? 0;
|
|
350
|
+
target.positions = positions;
|
|
351
|
+
target.equity = equity;
|
|
352
|
+
target.marginUsedPct = equity > 0 ? (marginUsed / equity) * 100 : 0;
|
|
353
|
+
target.seeded = true;
|
|
354
|
+
target.lastStateAt = now;
|
|
355
|
+
this.statePolls++;
|
|
356
|
+
for (const event of events)
|
|
357
|
+
void this.dispatch(event);
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
this.log(`state poll failed for ${target.address}: ${err instanceof Error ? err.message : String(err)}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async pollOrders(target) {
|
|
364
|
+
try {
|
|
365
|
+
const orders = await this.client.getFrontendOpenOrders(target.address);
|
|
366
|
+
// HIP-3 positions rest their TP/SL on their own dex — include those books
|
|
367
|
+
// so the no-TP/SL rule doesn't false-positive.
|
|
368
|
+
const dexes = new Set();
|
|
369
|
+
for (const coin of target.positions.keys()) {
|
|
370
|
+
const idx = coin.indexOf(':');
|
|
371
|
+
if (idx > 0)
|
|
372
|
+
dexes.add(coin.slice(0, idx));
|
|
373
|
+
}
|
|
374
|
+
for (const dex of dexes) {
|
|
375
|
+
try {
|
|
376
|
+
orders.push(...await this.client.getFrontendOpenOrders(target.address, dex));
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
this.log(`orders poll failed for dex ${dex}: ${err instanceof Error ? err.message : String(err)}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
target.orders = orders;
|
|
383
|
+
target.lastOrdersAt = Date.now();
|
|
384
|
+
this.orderPolls++;
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
this.log(`orders poll failed for ${target.address}: ${err instanceof Error ? err.message : String(err)}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/** Snapshot diff → position lifecycle alerts (mirrors the hosted poller). */
|
|
391
|
+
diff(target, current) {
|
|
392
|
+
const now = Date.now();
|
|
393
|
+
const events = [];
|
|
394
|
+
const prev = target.positions;
|
|
395
|
+
for (const [coin, pos] of current) {
|
|
396
|
+
if (!prev.has(coin)) {
|
|
397
|
+
events.push({
|
|
398
|
+
time: now,
|
|
399
|
+
address: target.address,
|
|
400
|
+
rule: 'position_lifecycle',
|
|
401
|
+
coin,
|
|
402
|
+
severity: 'info',
|
|
403
|
+
dedupKey: `opened:${now}`,
|
|
404
|
+
message: `Position opened: ${pos.side} ${Math.abs(pos.szi)} ${coin} at $${pos.entryPx ?? '?'}`,
|
|
405
|
+
payload: { kind: 'opened', coin, side: pos.side, size: pos.szi, entryPx: pos.entryPx },
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
for (const [coin, pos] of prev) {
|
|
410
|
+
if (!current.has(coin)) {
|
|
411
|
+
events.push({
|
|
412
|
+
time: now,
|
|
413
|
+
address: target.address,
|
|
414
|
+
rule: 'position_lifecycle',
|
|
415
|
+
coin,
|
|
416
|
+
severity: 'info',
|
|
417
|
+
dedupKey: `closed:${now}`,
|
|
418
|
+
message: `Position closed: ${coin} (was ${pos.szi} at $${pos.entryPx ?? '?'}, last uPnL $${pos.unrealizedPnl.toFixed(2)})`,
|
|
419
|
+
payload: { kind: 'closed', coin, previousSize: pos.szi, entryPx: pos.entryPx, lastPnl: pos.unrealizedPnl },
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
for (const [coin, pos] of current) {
|
|
424
|
+
const prevPos = prev.get(coin);
|
|
425
|
+
if (!prevPos || prevPos.szi === pos.szi)
|
|
426
|
+
continue;
|
|
427
|
+
const change = pos.szi - prevPos.szi;
|
|
428
|
+
const action = Math.abs(pos.szi) > Math.abs(prevPos.szi) ? 'increased' : 'decreased';
|
|
429
|
+
events.push({
|
|
430
|
+
time: now,
|
|
431
|
+
address: target.address,
|
|
432
|
+
rule: 'position_lifecycle',
|
|
433
|
+
coin,
|
|
434
|
+
severity: 'info',
|
|
435
|
+
dedupKey: `size:${now}`,
|
|
436
|
+
message: `Position ${coin} size ${action}: ${prevPos.szi} → ${pos.szi} (${change > 0 ? '+' : ''}${change.toFixed(6)})`,
|
|
437
|
+
payload: { kind: 'size_changed', coin, previousSize: prevPos.szi, newSize: pos.szi, change },
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return events;
|
|
441
|
+
}
|
|
442
|
+
// ── Dispatch ──────────────────────────────────────────────────────
|
|
443
|
+
passesPrefs(alert) {
|
|
444
|
+
if (this.prefs.rules?.[alert.rule] === false)
|
|
445
|
+
return false;
|
|
446
|
+
const min = this.prefs.minSeverity ?? 'info';
|
|
447
|
+
return GUARDIAN_SEVERITY_RANK[alert.severity] >= GUARDIAN_SEVERITY_RANK[min];
|
|
448
|
+
}
|
|
449
|
+
async dispatch(alert) {
|
|
450
|
+
if (!this.passesPrefs(alert))
|
|
451
|
+
return;
|
|
452
|
+
this.alertsFired++;
|
|
453
|
+
if (!this.opts.quiet) {
|
|
454
|
+
if (this.opts.json) {
|
|
455
|
+
console.log(JSON.stringify(alert));
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
const time = new Date(alert.time).toISOString().slice(11, 19);
|
|
459
|
+
const scope = alert.coin ? ` ${alert.coin}` : '';
|
|
460
|
+
console.log(`[${time}] ${alert.severity.toUpperCase()} ${alert.rule}${scope} — ${alert.message}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
this.opts.onAlert?.(alert);
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
this.log(`onAlert callback threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
468
|
+
}
|
|
469
|
+
if (this.telegram) {
|
|
470
|
+
try {
|
|
471
|
+
await sendTelegramMessage(this.telegram.token, this.telegram.chatId, formatTelegramAlert(alert));
|
|
472
|
+
this.alertsDelivered++;
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
this.deliveryErrors++;
|
|
476
|
+
this.log(`telegram delivery failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (this.agentHook) {
|
|
480
|
+
try {
|
|
481
|
+
const port = this.agentHook.gatewayPort ?? 18789;
|
|
482
|
+
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
483
|
+
method: 'POST',
|
|
484
|
+
headers: {
|
|
485
|
+
'Content-Type': 'application/json',
|
|
486
|
+
'Authorization': `Bearer ${this.agentHook.hooksToken}`,
|
|
487
|
+
},
|
|
488
|
+
body: JSON.stringify({
|
|
489
|
+
message: `[guardian] [${alert.severity}] ${alert.message}`,
|
|
490
|
+
name: 'openbroker-guardian',
|
|
491
|
+
wakeMode: 'now',
|
|
492
|
+
}),
|
|
493
|
+
signal: AbortSignal.timeout(5_000),
|
|
494
|
+
});
|
|
495
|
+
if (res.ok)
|
|
496
|
+
this.alertsDelivered++;
|
|
497
|
+
else {
|
|
498
|
+
this.deliveryErrors++;
|
|
499
|
+
this.log(`agent hook delivery failed: HTTP ${res.status}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
this.deliveryErrors++;
|
|
504
|
+
this.log(`agent hook delivery failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
log(message) {
|
|
509
|
+
if (this.opts.verbose)
|
|
510
|
+
console.error(`[guardian] ${message}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/** Start watching. Resolves once the first snapshot is seeded. */
|
|
514
|
+
export async function startGuardian(opts = {}) {
|
|
515
|
+
const client = opts.client ?? getClient();
|
|
516
|
+
const addresses = (opts.addresses && opts.addresses.length > 0 ? opts.addresses : [client.address])
|
|
517
|
+
.map((a) => a.toLowerCase());
|
|
518
|
+
for (const address of addresses) {
|
|
519
|
+
if (!ADDR_RE.test(address)) {
|
|
520
|
+
throw new Error(`Invalid address: ${address}. Pass --address 0x... or configure HYPERLIQUID_ACCOUNT_ADDRESS.`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const guardian = new Guardian(addresses, { ...opts, client });
|
|
524
|
+
await guardian.start();
|
|
525
|
+
return guardian;
|
|
526
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { GuardianAlert, GuardianPriceView, GuardianTargetState, GuardianThresholds } from './types.js';
|
|
2
|
+
export type GuardianAlertHandler = (alert: GuardianAlert) => void;
|
|
3
|
+
export declare class GuardianRiskEngine {
|
|
4
|
+
private readonly prices;
|
|
5
|
+
private readonly onAlert;
|
|
6
|
+
private readonly thresholds;
|
|
7
|
+
/** address:coin:threshold → state */
|
|
8
|
+
private liqState;
|
|
9
|
+
/** address → state */
|
|
10
|
+
private marginState;
|
|
11
|
+
/** address:coin → alerted */
|
|
12
|
+
private tpslState;
|
|
13
|
+
/** address:oid → last fired */
|
|
14
|
+
private staleOrderState;
|
|
15
|
+
/** address:coin → { since, firedAt } */
|
|
16
|
+
private fundingState;
|
|
17
|
+
private evaluations;
|
|
18
|
+
constructor(prices: GuardianPriceView, onAlert: GuardianAlertHandler, thresholds?: Partial<GuardianThresholds>);
|
|
19
|
+
getStats(): {
|
|
20
|
+
evaluations: number;
|
|
21
|
+
};
|
|
22
|
+
/** One evaluation pass over the given targets. */
|
|
23
|
+
tick(targets: Iterable<GuardianTargetState>, now?: number): void;
|
|
24
|
+
/** Distance = (mark − liqPx) / mark, signed by side. */
|
|
25
|
+
private liqDistance;
|
|
26
|
+
private updateLiqDistance;
|
|
27
|
+
private evalLiqProximity;
|
|
28
|
+
private evalMarginUsage;
|
|
29
|
+
private evalNoTpsl;
|
|
30
|
+
private evalStaleOrders;
|
|
31
|
+
private evalFundingBleed;
|
|
32
|
+
/** Drop per-position rule state when positions close (prevents unbounded growth). */
|
|
33
|
+
private gcClosedPositions;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=rules.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../../scripts/guardian/rules.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,aAAa,EACb,iBAAiB,EACjB,mBAAmB,EACnB,kBAAkB,EACnB,MAAM,YAAY,CAAC;AAOpB,MAAM,MAAM,oBAAoB,GAAG,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;AAElE,qBAAa,kBAAkB;IAe3B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAf1B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAqB;IAChD,qCAAqC;IACrC,OAAO,CAAC,QAAQ,CAAiC;IACjD,sBAAsB;IACtB,OAAO,CAAC,WAAW,CAAiC;IACpD,6BAA6B;IAC7B,OAAO,CAAC,SAAS,CAAqB;IACtC,+BAA+B;IAC/B,OAAO,CAAC,eAAe,CAA6B;IACpD,wCAAwC;IACxC,OAAO,CAAC,YAAY,CAAgE;IACpF,OAAO,CAAC,WAAW,CAAK;gBAGL,MAAM,EAAE,iBAAiB,EACzB,OAAO,EAAE,oBAAoB,EAC9C,UAAU,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC;IAK1C,QAAQ;;;IAIR,kDAAkD;IAClD,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,GAAG,SAAa,GAAG,IAAI;IAiBpE,wDAAwD;IACxD,OAAO,CAAC,WAAW;IAWnB,OAAO,CAAC,iBAAiB;IASzB,OAAO,CAAC,gBAAgB;IAqCxB,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,UAAU;IA+BlB,OAAO,CAAC,eAAe;IAqCvB,OAAO,CAAC,gBAAgB;IAuCxB,qFAAqF;IACrF,OAAO,CAAC,iBAAiB;CAU1B"}
|