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,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
@@ -104,3 +104,36 @@ export {
104
104
  } from './auto/registry.js';
105
105
 
106
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';
@@ -18,6 +18,8 @@ Options:
18
18
  --coin Cancel orders for specific coin only
19
19
  --oid Cancel specific order by ID
20
20
  --all Cancel all open orders
21
+ --fast Fast cancel (mempool-prioritized). Rejected by the API for
22
+ trigger orders (TP/SL) - use only on plain resting limits
21
23
  --dry Dry run - show what would be cancelled
22
24
 
23
25
  Examples:
@@ -34,6 +36,7 @@ async function main() {
34
36
  const coin = args.coin as string | undefined;
35
37
  const oid = args.oid ? parseInt(args.oid as string) : undefined;
36
38
  const all = args.all as boolean;
39
+ const fast = args.fast as boolean;
37
40
  const dryRun = args.dry as boolean;
38
41
 
39
42
  // Must specify something to cancel
@@ -99,7 +102,7 @@ async function main() {
99
102
 
100
103
  for (const order of targetOrders) {
101
104
  try {
102
- const response = await client.cancel(order.coin, order.oid);
105
+ const response = await client.cancel(order.coin, order.oid, { fast });
103
106
  if (response.status === 'ok') {
104
107
  console.log(`✅ Cancelled ${order.coin} order ${order.oid}`);
105
108
  successCount++;
@@ -64,7 +64,7 @@ export interface ChaseClient {
64
64
  getOpenOrders(): Promise<OpenOrder[]>;
65
65
  getUserFills(user?: string): Promise<Array<{ coin: string; px: string; sz: string; time: number; oid: number }>>;
66
66
  limitOrder(coin: string, isBuy: boolean, size: number, price: number, tif?: 'Gtc' | 'Ioc' | 'Alo', reduceOnly?: boolean, leverage?: number): Promise<OrderResponse>;
67
- cancel(coin: string, oid: number): Promise<CancelResponse>;
67
+ cancel(coin: string, oid: number, opts?: { fast?: boolean }): Promise<CancelResponse>;
68
68
  }
69
69
 
70
70
  export interface ChaseResult {
@@ -207,7 +207,9 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
207
207
  break;
208
208
  }
209
209
  try {
210
- await client.cancel(opts.coin, currentOid);
210
+ // Fast cancel (f: true): the chase quote is always a plain resting
211
+ // limit, and repricing latency is the whole point of chase.
212
+ await client.cancel(opts.coin, currentOid, { fast: true });
211
213
  } catch {
212
214
  // Order might have filled between the fill check and cancel.
213
215
  }
@@ -299,7 +301,7 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
299
301
  applyFills(currentOid);
300
302
  out(`\nCancelling unfilled order...`);
301
303
  try {
302
- await client.cancel(opts.coin, currentOid);
304
+ await client.cancel(opts.coin, currentOid, { fast: true });
303
305
  out(`✅ Cancelled`);
304
306
  } catch {
305
307
  out(`⚠️ Could not cancel (may have filled)`);