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.
Files changed (71) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +52 -0
  3. package/SKILL.md +57 -5
  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 +83 -32
  9. package/dist/core/client.d.ts.map +1 -1
  10. package/dist/core/client.js +214 -29
  11. package/dist/core/types.d.ts +22 -0
  12. package/dist/core/types.d.ts.map +1 -1
  13. package/dist/core/utils.d.ts +29 -0
  14. package/dist/core/utils.d.ts.map +1 -1
  15. package/dist/core/utils.js +27 -0
  16. package/dist/core/ws.d.ts +5 -0
  17. package/dist/core/ws.d.ts.map +1 -1
  18. package/dist/core/ws.js +9 -7
  19. package/dist/guardian/cli.d.ts +2 -0
  20. package/dist/guardian/cli.d.ts.map +1 -0
  21. package/dist/guardian/cli.js +288 -0
  22. package/dist/guardian/engine.d.ts +107 -0
  23. package/dist/guardian/engine.d.ts.map +1 -0
  24. package/dist/guardian/engine.js +526 -0
  25. package/dist/guardian/rules.d.ts +35 -0
  26. package/dist/guardian/rules.d.ts.map +1 -0
  27. package/dist/guardian/rules.js +237 -0
  28. package/dist/guardian/telegram.d.ts +24 -0
  29. package/dist/guardian/telegram.d.ts.map +1 -0
  30. package/dist/guardian/telegram.js +125 -0
  31. package/dist/guardian/types.d.ts +90 -0
  32. package/dist/guardian/types.d.ts.map +1 -0
  33. package/dist/guardian/types.js +45 -0
  34. package/dist/lib.d.ts +10 -1
  35. package/dist/lib.d.ts.map +1 -1
  36. package/dist/lib.js +6 -1
  37. package/dist/operations/advanced-orders.test.js +302 -13
  38. package/dist/operations/bracket.d.ts +35 -5
  39. package/dist/operations/bracket.d.ts.map +1 -1
  40. package/dist/operations/bracket.js +238 -64
  41. package/dist/operations/cancel.js +4 -1
  42. package/dist/operations/chase.d.ts +4 -2
  43. package/dist/operations/chase.d.ts.map +1 -1
  44. package/dist/operations/chase.js +61 -24
  45. package/dist/operations/scale.d.ts.map +1 -1
  46. package/dist/operations/scale.js +10 -1
  47. package/dist/operations/set-tpsl.js +69 -57
  48. package/dist/operations/trigger-order.js +18 -6
  49. package/dist/operations/twap.js +13 -1
  50. package/package.json +2 -2
  51. package/scripts/auto/examples/grid.ts +1 -1
  52. package/scripts/auto/examples/mm-maker.ts +4 -4
  53. package/scripts/auto/examples/mm-spread.ts +4 -4
  54. package/scripts/core/client.ts +278 -33
  55. package/scripts/core/types.ts +23 -0
  56. package/scripts/core/utils.ts +37 -0
  57. package/scripts/core/ws.ts +9 -6
  58. package/scripts/guardian/cli.ts +322 -0
  59. package/scripts/guardian/engine.ts +614 -0
  60. package/scripts/guardian/rules.ts +265 -0
  61. package/scripts/guardian/telegram.ts +147 -0
  62. package/scripts/guardian/types.ts +160 -0
  63. package/scripts/lib.ts +36 -0
  64. package/scripts/operations/advanced-orders.test.ts +347 -14
  65. package/scripts/operations/bracket.ts +252 -73
  66. package/scripts/operations/cancel.ts +4 -1
  67. package/scripts/operations/chase.ts +61 -26
  68. package/scripts/operations/scale.ts +10 -1
  69. package/scripts/operations/set-tpsl.ts +62 -57
  70. package/scripts/operations/trigger-order.ts +20 -6
  71. package/scripts/operations/twap.ts +14 -1
@@ -0,0 +1,265 @@
1
+ // Guardian rule engine — pure arithmetic over in-memory target + price state,
2
+ // zero API cost per tick. Ported 1:1 from openbroker-copilot src/watcher/risk.ts
3
+ // (same thresholds, hysteresis, and cooldowns); position lifecycle events are
4
+ // emitted by the engine's snapshot diff, not here. Alerts are suppressed while
5
+ // the price feed is stale.
6
+
7
+ import type {
8
+ GuardianAlert,
9
+ GuardianPriceView,
10
+ GuardianTargetState,
11
+ GuardianThresholds,
12
+ } from './types.js';
13
+ import { DEFAULT_GUARDIAN_THRESHOLDS } from './types.js';
14
+
15
+ const HOURS_PER_YEAR = 24 * 365;
16
+
17
+ interface FiredState { firedAt: number; armed: boolean }
18
+
19
+ export type GuardianAlertHandler = (alert: GuardianAlert) => void;
20
+
21
+ export class GuardianRiskEngine {
22
+ private readonly thresholds: GuardianThresholds;
23
+ /** address:coin:threshold → state */
24
+ private liqState = new Map<string, FiredState>();
25
+ /** address → state */
26
+ private marginState = new Map<string, FiredState>();
27
+ /** address:coin → alerted */
28
+ private tpslState = new Set<string>();
29
+ /** address:oid → last fired */
30
+ private staleOrderState = new Map<string, number>();
31
+ /** address:coin → { since, firedAt } */
32
+ private fundingState = new Map<string, { since: number; firedAt: number | null }>();
33
+ private evaluations = 0;
34
+
35
+ constructor(
36
+ private readonly prices: GuardianPriceView,
37
+ private readonly onAlert: GuardianAlertHandler,
38
+ thresholds?: Partial<GuardianThresholds>,
39
+ ) {
40
+ this.thresholds = { ...DEFAULT_GUARDIAN_THRESHOLDS, ...thresholds };
41
+ }
42
+
43
+ getStats() {
44
+ return { evaluations: this.evaluations };
45
+ }
46
+
47
+ /** One evaluation pass over the given targets. */
48
+ tick(targets: Iterable<GuardianTargetState>, now = Date.now()): void {
49
+ const stale = this.prices.stale;
50
+
51
+ for (const target of targets) {
52
+ if (!target.seeded) continue;
53
+ this.updateLiqDistance(target);
54
+ if (stale) continue; // keep distances fresh, but never alert on stale prices
55
+ this.evaluations++;
56
+ this.evalLiqProximity(target, now);
57
+ this.evalMarginUsage(target, now);
58
+ this.evalNoTpsl(target, now);
59
+ this.evalStaleOrders(target, now);
60
+ this.evalFundingBleed(target, now);
61
+ this.gcClosedPositions(target);
62
+ }
63
+ }
64
+
65
+ /** Distance = (mark − liqPx) / mark, signed by side. */
66
+ private liqDistance(target: GuardianTargetState, coin: string): number | null {
67
+ const pos = target.positions.get(coin);
68
+ if (!pos || pos.liquidationPx === null || pos.liquidationPx <= 0) return null;
69
+ const entry = this.prices.get(coin);
70
+ const mark = entry?.mark ?? entry?.mid;
71
+ if (!mark || mark <= 0) return null;
72
+ return pos.side === 'long'
73
+ ? (mark - pos.liquidationPx) / mark
74
+ : (pos.liquidationPx - mark) / mark;
75
+ }
76
+
77
+ private updateLiqDistance(target: GuardianTargetState): void {
78
+ let min: number | null = null;
79
+ for (const coin of target.positions.keys()) {
80
+ const d = this.liqDistance(target, coin);
81
+ if (d !== null && (min === null || d < min)) min = d;
82
+ }
83
+ target.minLiqDist = min;
84
+ }
85
+
86
+ private evalLiqProximity(target: GuardianTargetState, now: number): void {
87
+ const t = this.thresholds;
88
+ for (const [coin, pos] of target.positions) {
89
+ const dist = this.liqDistance(target, coin);
90
+ if (dist === null) continue;
91
+
92
+ for (const { pct, severity } of t.liqThresholds) {
93
+ const key = `${target.address}:${coin}:${pct}`;
94
+ const state = this.liqState.get(key) ?? { firedAt: 0, armed: true };
95
+
96
+ if (!state.armed && dist >= pct * t.liqRearmFactor) {
97
+ state.armed = true; // hysteresis re-arm
98
+ }
99
+
100
+ if (state.armed && dist <= pct && now - state.firedAt >= t.liqCooldownMs) {
101
+ state.armed = false;
102
+ state.firedAt = now;
103
+ const entry = this.prices.get(coin);
104
+ const mark = entry?.mark ?? entry?.mid;
105
+ this.onAlert({
106
+ time: now,
107
+ address: target.address,
108
+ rule: 'liq_proximity',
109
+ coin,
110
+ severity,
111
+ dedupKey: String(pct),
112
+ message:
113
+ `Liquidation risk on ${coin}: mark $${mark} is ${(dist * 100).toFixed(2)}% from ` +
114
+ `liquidation price $${pos.liquidationPx} (${pos.side}, ${pos.leverage}x)`,
115
+ payload: { coin, distancePct: dist * 100, thresholdPct: pct * 100, mark, liquidationPx: pos.liquidationPx, side: pos.side },
116
+ });
117
+ }
118
+ this.liqState.set(key, state);
119
+ }
120
+ }
121
+ }
122
+
123
+ private evalMarginUsage(target: GuardianTargetState, now: number): void {
124
+ const t = this.thresholds;
125
+ const key = target.address;
126
+ const state = this.marginState.get(key) ?? { firedAt: 0, armed: true };
127
+
128
+ if (!state.armed && target.marginUsedPct < t.marginRearmPct) state.armed = true;
129
+
130
+ if (state.armed && target.marginUsedPct > t.marginThresholdPct && now - state.firedAt >= t.marginCooldownMs) {
131
+ state.armed = false;
132
+ state.firedAt = now;
133
+ this.onAlert({
134
+ time: now,
135
+ address: target.address,
136
+ rule: 'margin_usage',
137
+ coin: null,
138
+ severity: 'warning',
139
+ dedupKey: String(t.marginThresholdPct),
140
+ message: `Margin usage at ${target.marginUsedPct.toFixed(1)}% of equity ($${target.equity.toFixed(2)})`,
141
+ payload: { marginUsedPct: target.marginUsedPct, equity: target.equity, thresholdPct: t.marginThresholdPct },
142
+ });
143
+ }
144
+ this.marginState.set(key, state);
145
+ }
146
+
147
+ private evalNoTpsl(target: GuardianTargetState, now: number): void {
148
+ if (target.lastOrdersAt === null || target.orders === null) return; // no order data yet
149
+
150
+ for (const [coin, pos] of target.positions) {
151
+ const key = `${target.address}:${coin}`;
152
+ const openedAt = target.firstSeen.get(coin) ?? now;
153
+
154
+ const hasProtection = target.orders.some(
155
+ (o) => o.coin === coin && o.isTrigger && o.reduceOnly,
156
+ );
157
+ if (hasProtection) {
158
+ this.tpslState.delete(key);
159
+ continue;
160
+ }
161
+ if (this.tpslState.has(key)) continue;
162
+ if (now - openedAt < this.thresholds.noTpslAfterMs) continue;
163
+
164
+ this.tpslState.add(key);
165
+ this.onAlert({
166
+ time: now,
167
+ address: target.address,
168
+ rule: 'no_tpsl',
169
+ coin,
170
+ severity: 'info',
171
+ dedupKey: 'no_tpsl',
172
+ message: `${coin} ${pos.side} position has been open ${Math.round((now - openedAt) / 60_000)} min with no TP/SL orders`,
173
+ payload: { coin, side: pos.side, size: pos.szi, openMinutes: Math.round((now - openedAt) / 60_000) },
174
+ });
175
+ }
176
+ }
177
+
178
+ private evalStaleOrders(target: GuardianTargetState, now: number): void {
179
+ if (!target.orders) return;
180
+ const t = this.thresholds;
181
+
182
+ for (const order of target.orders) {
183
+ if (order.isTrigger) continue; // resting limit orders only
184
+ if (now - order.timestamp < t.staleOrderAgeMs) continue;
185
+
186
+ const entry = this.prices.get(order.coin);
187
+ const mid = entry?.mid ?? entry?.mark;
188
+ const limitPx = parseFloat(order.limitPx);
189
+ if (!mid || mid <= 0 || !Number.isFinite(limitPx)) continue;
190
+
191
+ const dist = Math.abs(limitPx - mid) / mid;
192
+ if (dist <= t.staleOrderDist) continue;
193
+
194
+ const key = `${target.address}:${order.oid}`;
195
+ const lastFired = this.staleOrderState.get(key) ?? 0;
196
+ if (now - lastFired < t.staleOrderCooldownMs) continue;
197
+
198
+ this.staleOrderState.set(key, now);
199
+ const ageH = Math.round((now - order.timestamp) / 3600_000);
200
+ this.onAlert({
201
+ time: now,
202
+ address: target.address,
203
+ rule: 'stale_order',
204
+ coin: order.coin,
205
+ severity: 'info',
206
+ dedupKey: String(order.oid),
207
+ message:
208
+ `Stale ${order.side === 'B' ? 'buy' : 'sell'} order on ${order.coin}: resting ${ageH}h, ` +
209
+ `limit $${order.limitPx} is ${(dist * 100).toFixed(1)}% from mid $${mid}`,
210
+ payload: { coin: order.coin, oid: order.oid, limitPx, mid, distancePct: dist * 100, ageHours: ageH },
211
+ });
212
+ }
213
+ }
214
+
215
+ private evalFundingBleed(target: GuardianTargetState, now: number): void {
216
+ const t = this.thresholds;
217
+ for (const [coin, pos] of target.positions) {
218
+ const funding = this.prices.get(coin)?.funding;
219
+ const key = `${target.address}:${coin}`;
220
+ if (funding === null || funding === undefined) continue;
221
+
222
+ // Longs pay when funding > 0; shorts pay when funding < 0. Funding is hourly.
223
+ const paying = pos.side === 'long' ? funding > 0 : funding < 0;
224
+ const aprPct = Math.abs(funding) * HOURS_PER_YEAR * 100;
225
+
226
+ if (!paying || aprPct <= t.fundingBleedAprPct) {
227
+ this.fundingState.delete(key);
228
+ continue;
229
+ }
230
+
231
+ const state = this.fundingState.get(key) ?? { since: now, firedAt: null };
232
+ this.fundingState.set(key, state);
233
+
234
+ const bleedingFor = now - state.since;
235
+ const cooledDown = state.firedAt === null || now - state.firedAt >= t.fundingCooldownMs;
236
+ if (bleedingFor >= t.fundingBleedForMs && cooledDown) {
237
+ state.firedAt = now;
238
+ this.onAlert({
239
+ time: now,
240
+ address: target.address,
241
+ rule: 'funding_bleed',
242
+ coin,
243
+ severity: 'warning',
244
+ dedupKey: 'funding_bleed',
245
+ message:
246
+ `Funding bleed on ${coin}: paying ~${aprPct.toFixed(1)}% APR against your ${pos.side} ` +
247
+ `for over ${Math.round(bleedingFor / 60_000)} min`,
248
+ payload: { coin, side: pos.side, fundingHourly: funding, aprPct, bleedingMinutes: Math.round(bleedingFor / 60_000) },
249
+ });
250
+ }
251
+ }
252
+ }
253
+
254
+ /** Drop per-position rule state when positions close (prevents unbounded growth). */
255
+ private gcClosedPositions(target: GuardianTargetState): void {
256
+ for (const key of this.tpslState) {
257
+ const [addr, coin] = [key.slice(0, 42), key.slice(43)];
258
+ if (addr === target.address && coin && !target.positions.has(coin)) this.tpslState.delete(key);
259
+ }
260
+ for (const key of this.fundingState.keys()) {
261
+ const [addr, coin] = [key.slice(0, 42), key.slice(43)];
262
+ if (addr === target.address && coin && !target.positions.has(coin)) this.fundingState.delete(key);
263
+ }
264
+ }
265
+ }
@@ -0,0 +1,147 @@
1
+ // Telegram delivery for the guardian via raw fetch — no SDK. Unlike the hosted
2
+ // guardian (shared bot + DB-backed link codes), the CLI uses the user's OWN bot:
3
+ // TELEGRAM_BOT_TOKEN comes from @BotFather, and `openbroker guardian connect`
4
+ // runs a one-shot getUpdates loop that captures the chat id from a
5
+ // `/start <code>` deep link and persists TELEGRAM_CHAT_ID to the config file.
6
+ //
7
+ // Caveat: getUpdates conflicts with any other consumer of the same bot token
8
+ // (including a set webhook) — use a dedicated bot for the CLI guardian.
9
+
10
+ import fs from 'fs';
11
+ import crypto from 'crypto';
12
+ import { ensureConfigDir, getConfigPath } from '../core/config.js';
13
+ import type { GuardianAlert } from './types.js';
14
+
15
+ export interface TelegramSettings {
16
+ token: string | null;
17
+ chatId: string | null;
18
+ }
19
+
20
+ /** Read TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID (dotenv is loaded by core/config on import). */
21
+ export function loadTelegramSettings(): TelegramSettings {
22
+ return {
23
+ token: process.env.TELEGRAM_BOT_TOKEN || null,
24
+ chatId: process.env.TELEGRAM_CHAT_ID || null,
25
+ };
26
+ }
27
+
28
+ function api(token: string, method: string): string {
29
+ return `https://api.telegram.org/bot${token}/${method}`;
30
+ }
31
+
32
+ async function call<T>(token: string, method: string, body: Record<string, unknown>, timeoutMs = 10_000): Promise<T> {
33
+ const res = await fetch(api(token, method), {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify(body),
37
+ signal: AbortSignal.timeout(timeoutMs),
38
+ });
39
+ const data = (await res.json().catch(() => null)) as { ok?: boolean; result?: T; description?: string } | null;
40
+ if (!res.ok || !data?.ok) {
41
+ throw new Error(`telegram ${method} failed: HTTP ${res.status}${data?.description ? ` — ${data.description}` : ''}`);
42
+ }
43
+ return data.result as T;
44
+ }
45
+
46
+ export async function sendTelegramMessage(token: string, chatId: string | number, text: string): Promise<void> {
47
+ await call(token, 'sendMessage', {
48
+ chat_id: chatId,
49
+ text,
50
+ parse_mode: 'HTML',
51
+ disable_web_page_preview: true,
52
+ });
53
+ }
54
+
55
+ export async function getTelegramBotInfo(token: string): Promise<{ id: number; username: string }> {
56
+ return call(token, 'getMe', {});
57
+ }
58
+
59
+ function shortAddr(address: string): string {
60
+ return `${address.slice(0, 6)}…${address.slice(-4)}`;
61
+ }
62
+
63
+ /** Same message shape as the hosted guardian's Telegram channel. */
64
+ export function formatTelegramAlert(alert: GuardianAlert): string {
65
+ const tag = alert.severity === 'critical' ? 'CRITICAL' : alert.severity === 'warning' ? 'WARNING' : 'INFO';
66
+ const scope = alert.coin ? `${alert.coin} — ` : '';
67
+ return `<b>[${tag}]</b> ${scope}${shortAddr(alert.address)}\n${alert.message}`;
68
+ }
69
+
70
+ export function generateLinkCode(): string {
71
+ return crypto.randomBytes(4).toString('hex');
72
+ }
73
+
74
+ /**
75
+ * Long-poll getUpdates until someone sends `/start <code>` (or the bare code)
76
+ * to the bot, then confirm in-chat and return the chat id. Returns null on
77
+ * timeout. Drains the update backlog first so old messages can't match.
78
+ */
79
+ export async function waitForTelegramLink(
80
+ token: string,
81
+ code: string,
82
+ timeoutMs = 10 * 60_000,
83
+ ): Promise<number | null> {
84
+ const deadline = Date.now() + timeoutMs;
85
+ let offset = -1; // -1 = skip backlog, start from the next incoming update
86
+
87
+ while (Date.now() < deadline) {
88
+ let updates: Array<{ update_id: number; message?: { chat: { id: number }; text?: string } }>;
89
+ try {
90
+ updates = await call(token, 'getUpdates', {
91
+ offset,
92
+ timeout: 25,
93
+ allowed_updates: ['message'],
94
+ }, 35_000);
95
+ } catch (err) {
96
+ if (Date.now() >= deadline) break;
97
+ await new Promise((r) => setTimeout(r, 3_000));
98
+ continue;
99
+ }
100
+
101
+ for (const update of updates ?? []) {
102
+ offset = update.update_id + 1;
103
+ const msg = update.message;
104
+ const text = msg?.text?.trim();
105
+ if (!msg || !text) continue;
106
+
107
+ const m = text.match(/^\/start(?:\s+(\S+))?$/);
108
+ const supplied = m ? m[1] : text;
109
+ if (supplied === code) {
110
+ try {
111
+ await sendTelegramMessage(
112
+ token,
113
+ msg.chat.id,
114
+ 'Linked. This chat will receive OpenBroker guardian alerts (liquidation proximity, missing TP/SL, funding bleed and more).',
115
+ );
116
+ } catch { /* link still succeeded */ }
117
+ return msg.chat.id;
118
+ }
119
+ if (m && !supplied) {
120
+ try {
121
+ await sendTelegramMessage(token, msg.chat.id, 'OpenBroker guardian bot. Run <code>openbroker guardian connect</code> in the terminal and follow the link it prints.');
122
+ } catch { /* ignore */ }
123
+ }
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /** Persist a key=value into the active openbroker config file (creates it if missing). */
130
+ export function saveEnvVar(key: string, value: string): string {
131
+ ensureConfigDir();
132
+ const configPath = getConfigPath();
133
+ const line = `${key}=${value}`;
134
+ let content = '';
135
+ if (fs.existsSync(configPath)) {
136
+ content = fs.readFileSync(configPath, 'utf8');
137
+ }
138
+ const re = new RegExp(`^${key}=.*$`, 'm');
139
+ if (re.test(content)) {
140
+ content = content.replace(re, line);
141
+ } else {
142
+ content = content.length > 0 && !content.endsWith('\n') ? `${content}\n${line}\n` : `${content}${line}\n`;
143
+ }
144
+ fs.writeFileSync(configPath, content, { mode: 0o600 });
145
+ process.env[key] = value;
146
+ return configPath;
147
+ }
@@ -0,0 +1,160 @@
1
+ // Guardian — read-only position risk monitoring for Hyperliquid addresses.
2
+ // CLI/library port of the hosted copilot guardian (openbroker-copilot
3
+ // src/watcher): same six rules, same default thresholds, same severity
4
+ // contract, minus the multi-tenant DB. Alerts are delivered to the console,
5
+ // Telegram, an OpenClaw agent hook, and/or an onAlert callback.
6
+
7
+ import type { FrontendOpenOrder } from '../core/types.js';
8
+
9
+ export type GuardianRuleId =
10
+ | 'liq_proximity'
11
+ | 'no_tpsl'
12
+ | 'stale_order'
13
+ | 'margin_usage'
14
+ | 'funding_bleed'
15
+ | 'position_lifecycle';
16
+
17
+ export const GUARDIAN_RULE_IDS: GuardianRuleId[] = [
18
+ 'liq_proximity',
19
+ 'no_tpsl',
20
+ 'stale_order',
21
+ 'margin_usage',
22
+ 'funding_bleed',
23
+ 'position_lifecycle',
24
+ ];
25
+
26
+ export const GUARDIAN_RULE_LABELS: Record<GuardianRuleId, string> = {
27
+ liq_proximity: 'Liquidation proximity',
28
+ no_tpsl: 'No TP/SL protection',
29
+ stale_order: 'Stale limit orders',
30
+ margin_usage: 'High margin usage',
31
+ funding_bleed: 'Funding bleed',
32
+ position_lifecycle: 'Position open / close / resize',
33
+ };
34
+
35
+ export type GuardianSeverity = 'info' | 'warning' | 'critical';
36
+
37
+ export const GUARDIAN_SEVERITY_RANK: Record<GuardianSeverity, number> = {
38
+ info: 0,
39
+ warning: 1,
40
+ critical: 2,
41
+ };
42
+
43
+ export interface GuardianAlert {
44
+ time: number;
45
+ address: string;
46
+ rule: GuardianRuleId;
47
+ coin: string | null;
48
+ severity: GuardianSeverity;
49
+ /** Dedup discriminator within address+rule+coin (e.g. threshold pct). */
50
+ dedupKey: string;
51
+ message: string;
52
+ payload: Record<string, unknown>;
53
+ }
54
+
55
+ /** Per-run alert preferences — same contract as the hosted guardian's alert_prefs. */
56
+ export interface GuardianPrefs {
57
+ /** Minimum severity to deliver. Default 'info'. */
58
+ minSeverity?: GuardianSeverity;
59
+ /** Per-rule opt-out, e.g. { position_lifecycle: false }. Default: all on. */
60
+ rules?: Partial<Record<GuardianRuleId, boolean>>;
61
+ }
62
+
63
+ /** Rule thresholds. Defaults mirror the hosted guardian. */
64
+ export interface GuardianThresholds {
65
+ /** Liquidation-distance tiers (fraction of mark), tightest last. */
66
+ liqThresholds: Array<{ pct: number; severity: GuardianSeverity }>;
67
+ /** Re-arm only after moving this multiple past a fired threshold. */
68
+ liqRearmFactor: number;
69
+ liqCooldownMs: number;
70
+
71
+ marginThresholdPct: number;
72
+ marginRearmPct: number;
73
+ marginCooldownMs: number;
74
+
75
+ /** Alert when a position has no reduce-only trigger after this long. */
76
+ noTpslAfterMs: number;
77
+
78
+ staleOrderAgeMs: number;
79
+ /** Distance from mid (fraction) before a resting order counts as stale. */
80
+ staleOrderDist: number;
81
+ staleOrderCooldownMs: number;
82
+
83
+ /** Annualized funding APR (%) the position must be paying to count as bleed. */
84
+ fundingBleedAprPct: number;
85
+ /** How long the bleed must persist before alerting. */
86
+ fundingBleedForMs: number;
87
+ fundingCooldownMs: number;
88
+ }
89
+
90
+ export const DEFAULT_GUARDIAN_THRESHOLDS: GuardianThresholds = {
91
+ liqThresholds: [
92
+ { pct: 0.10, severity: 'warning' },
93
+ { pct: 0.05, severity: 'critical' },
94
+ { pct: 0.02, severity: 'critical' },
95
+ ],
96
+ liqRearmFactor: 1.5,
97
+ liqCooldownMs: 30 * 60_000,
98
+
99
+ marginThresholdPct: 80,
100
+ marginRearmPct: 72,
101
+ marginCooldownMs: 60 * 60_000,
102
+
103
+ noTpslAfterMs: 15 * 60_000,
104
+
105
+ staleOrderAgeMs: 12 * 3600_000,
106
+ staleOrderDist: 0.03,
107
+ staleOrderCooldownMs: 12 * 3600_000,
108
+
109
+ fundingBleedAprPct: 15,
110
+ fundingBleedForMs: 60 * 60_000,
111
+ fundingCooldownMs: 6 * 3600_000,
112
+ };
113
+
114
+ export interface GuardianPositionSnap {
115
+ coin: string;
116
+ szi: number;
117
+ side: 'long' | 'short';
118
+ entryPx: number | null;
119
+ positionValue: number;
120
+ unrealizedPnl: number;
121
+ liquidationPx: number | null;
122
+ leverage: number;
123
+ marginUsed: number;
124
+ }
125
+
126
+ /** In-memory state per watched address. */
127
+ export interface GuardianTargetState {
128
+ address: string;
129
+
130
+ // clearinghouseState snapshot
131
+ positions: Map<string, GuardianPositionSnap>;
132
+ equity: number;
133
+ marginUsedPct: number;
134
+ seeded: boolean;
135
+ lastStateAt: number | null;
136
+
137
+ // frontendOpenOrders snapshot
138
+ orders: FrontendOpenOrder[] | null;
139
+ lastOrdersAt: number | null;
140
+
141
+ /** First time each open position (coin) was observed — for the no-TP/SL rule. */
142
+ firstSeen: Map<string, number>;
143
+ /** Min liquidation distance (fraction) across open positions; set by the risk engine. */
144
+ minLiqDist: number | null;
145
+ }
146
+
147
+ /** Live price entry maintained by the guardian's price feed. */
148
+ export interface GuardianPriceEntry {
149
+ mid: number | null;
150
+ mark: number | null;
151
+ /** Hourly funding rate (decimal, e.g. 0.0000125). */
152
+ funding: number | null;
153
+ updatedAt: number;
154
+ }
155
+
156
+ /** Narrow price view the rule engine reads — tests can pass plain fakes. */
157
+ export interface GuardianPriceView {
158
+ get(coin: string): GuardianPriceEntry | undefined;
159
+ readonly stale: boolean;
160
+ }
package/scripts/lib.ts CHANGED
@@ -47,7 +47,10 @@ export {
47
47
  generateCloid,
48
48
  orderToWire,
49
49
  checkBuilderFeeApproval,
50
+ MIN_ORDER_NOTIONAL_USD,
51
+ parseOrderStatus,
50
52
  } from './core/utils.js';
53
+ export type { ParsedOrderStatus } from './core/utils.js';
51
54
 
52
55
  export type * from './core/types.js';
53
56
 
@@ -101,3 +104,36 @@ export {
101
104
  } from './auto/registry.js';
102
105
 
103
106
  export type * from './auto/types.js';
107
+
108
+ // ── Guardian (read-only position risk monitoring) ───────────────────
109
+
110
+ export { startGuardian, Guardian } from './guardian/engine.js';
111
+ export type {
112
+ GuardianOptions,
113
+ GuardianHandle,
114
+ GuardianStats,
115
+ GuardianTelegramChannel,
116
+ GuardianAgentHookChannel,
117
+ } from './guardian/engine.js';
118
+
119
+ export { GuardianRiskEngine } from './guardian/rules.js';
120
+ export type { GuardianAlertHandler } from './guardian/rules.js';
121
+
122
+ export {
123
+ sendTelegramMessage,
124
+ getTelegramBotInfo,
125
+ formatTelegramAlert,
126
+ loadTelegramSettings,
127
+ waitForTelegramLink,
128
+ generateLinkCode,
129
+ saveEnvVar,
130
+ } from './guardian/telegram.js';
131
+ export type { TelegramSettings } from './guardian/telegram.js';
132
+
133
+ export {
134
+ GUARDIAN_RULE_IDS,
135
+ GUARDIAN_RULE_LABELS,
136
+ GUARDIAN_SEVERITY_RANK,
137
+ DEFAULT_GUARDIAN_THRESHOLDS,
138
+ } from './guardian/types.js';
139
+ export type * from './guardian/types.js';