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.
- package/CHANGELOG.md +16 -0
- package/README.md +52 -0
- package/SKILL.md +45 -2
- 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 +23 -26
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +24 -3
- package/dist/core/types.d.ts +22 -0
- package/dist/core/types.d.ts.map +1 -1
- 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 +8 -0
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +5 -0
- package/dist/operations/cancel.js +4 -1
- package/dist/operations/chase.d.ts +3 -1
- package/dist/operations/chase.d.ts.map +1 -1
- package/dist/operations/chase.js +4 -2
- 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 +29 -3
- package/scripts/core/types.ts +23 -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 +33 -0
- package/scripts/operations/cancel.ts +4 -1
- package/scripts/operations/chase.ts +5 -3
|
@@ -144,7 +144,7 @@ export default function grid(api: AutomationAPI) {
|
|
|
144
144
|
api.log.info('Cancelling grid orders...');
|
|
145
145
|
for (const level of levels) {
|
|
146
146
|
if (level.oid) {
|
|
147
|
-
try { await api.client.cancel(COIN, level.oid); } catch { /* may be filled */ }
|
|
147
|
+
try { await api.client.cancel(COIN, level.oid, { fast: true }); } catch { /* may be filled */ }
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
api.log.info(`Grid stopped. Realized PnL: ${api.utils.formatUsd(realizedPnl)}`);
|
|
@@ -98,14 +98,14 @@ export default function mmMaker(api: AutomationAPI) {
|
|
|
98
98
|
if (bidOid) {
|
|
99
99
|
const drift = Math.abs(bidPrice - safeBid) / book.midPrice;
|
|
100
100
|
if (drift > 0.0005 || !shouldBid) {
|
|
101
|
-
try { await api.client.cancel(COIN, bidOid); } catch { /* */ }
|
|
101
|
+
try { await api.client.cancel(COIN, bidOid, { fast: true }); } catch { /* */ }
|
|
102
102
|
bidOid = undefined;
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
if (askOid) {
|
|
106
106
|
const drift = Math.abs(askPrice - safeAsk) / book.midPrice;
|
|
107
107
|
if (drift > 0.0005 || !shouldAsk) {
|
|
108
|
-
try { await api.client.cancel(COIN, askOid); } catch { /* */ }
|
|
108
|
+
try { await api.client.cancel(COIN, askOid, { fast: true }); } catch { /* */ }
|
|
109
109
|
askOid = undefined;
|
|
110
110
|
}
|
|
111
111
|
}
|
|
@@ -132,8 +132,8 @@ export default function mmMaker(api: AutomationAPI) {
|
|
|
132
132
|
});
|
|
133
133
|
|
|
134
134
|
api.onStop(async () => {
|
|
135
|
-
if (bidOid) try { await api.client.cancel(COIN, bidOid); } catch { /* */ }
|
|
136
|
-
if (askOid) try { await api.client.cancel(COIN, askOid); } catch { /* */ }
|
|
135
|
+
if (bidOid) try { await api.client.cancel(COIN, bidOid, { fast: true }); } catch { /* */ }
|
|
136
|
+
if (askOid) try { await api.client.cancel(COIN, askOid, { fast: true }); } catch { /* */ }
|
|
137
137
|
const pnl = totalSellRevenue - totalBuyCost;
|
|
138
138
|
const volume = totalBuyCost + totalSellRevenue;
|
|
139
139
|
const rebates = volume * 0.00003;
|
|
@@ -94,14 +94,14 @@ export default function mmSpread(api: AutomationAPI) {
|
|
|
94
94
|
if (bidOid) {
|
|
95
95
|
const drift = Math.abs(bidPrice - targetBid) / mid;
|
|
96
96
|
if (drift > 0.001 || !shouldBid) {
|
|
97
|
-
try { await api.client.cancel(COIN, bidOid); } catch { /* */ }
|
|
97
|
+
try { await api.client.cancel(COIN, bidOid, { fast: true }); } catch { /* */ }
|
|
98
98
|
bidOid = undefined;
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
if (askOid) {
|
|
102
102
|
const drift = Math.abs(askPrice - targetAsk) / mid;
|
|
103
103
|
if (drift > 0.001 || !shouldAsk) {
|
|
104
|
-
try { await api.client.cancel(COIN, askOid); } catch { /* */ }
|
|
104
|
+
try { await api.client.cancel(COIN, askOid, { fast: true }); } catch { /* */ }
|
|
105
105
|
askOid = undefined;
|
|
106
106
|
}
|
|
107
107
|
}
|
|
@@ -124,8 +124,8 @@ export default function mmSpread(api: AutomationAPI) {
|
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
api.onStop(async () => {
|
|
127
|
-
if (bidOid) try { await api.client.cancel(COIN, bidOid); } catch { /* */ }
|
|
128
|
-
if (askOid) try { await api.client.cancel(COIN, askOid); } catch { /* */ }
|
|
127
|
+
if (bidOid) try { await api.client.cancel(COIN, bidOid, { fast: true }); } catch { /* */ }
|
|
128
|
+
if (askOid) try { await api.client.cancel(COIN, askOid, { fast: true }); } catch { /* */ }
|
|
129
129
|
const pnl = totalSellRevenue - totalBuyCost;
|
|
130
130
|
api.log.info(`MM stopped. Bought: ${totalBought.toFixed(6)} | Sold: ${totalSold.toFixed(6)} | PnL: ${api.utils.formatUsd(pnl)}`);
|
|
131
131
|
});
|
package/scripts/core/client.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
ClearinghouseState,
|
|
15
15
|
MarginSummary,
|
|
16
16
|
OpenOrder,
|
|
17
|
+
FrontendOpenOrder,
|
|
17
18
|
OutcomeMetaResponse,
|
|
18
19
|
OutcomeMarket,
|
|
19
20
|
OutcomeQuestion,
|
|
@@ -2350,6 +2351,22 @@ export class HyperliquidClient {
|
|
|
2350
2351
|
return orders;
|
|
2351
2352
|
}
|
|
2352
2353
|
|
|
2354
|
+
/**
|
|
2355
|
+
* Open orders with frontend display fields (isTrigger, reduceOnly,
|
|
2356
|
+
* isPositionTpsl, …) for one dex — empty/omitted dex = main. Unlike
|
|
2357
|
+
* getOpenOrders this does NOT aggregate HIP-3 dexes; callers that need a
|
|
2358
|
+
* HIP-3 book pass its dex name explicitly.
|
|
2359
|
+
*/
|
|
2360
|
+
async getFrontendOpenOrders(user?: string, dex?: string): Promise<FrontendOpenOrder[]> {
|
|
2361
|
+
const target = (user ?? this.address) as `0x${string}`;
|
|
2362
|
+
this.log('Fetching frontendOpenOrders for:', target, dex ? `(dex: ${dex})` : '');
|
|
2363
|
+
const response = await this.withRetry(
|
|
2364
|
+
() => this.info.frontendOpenOrders(dex ? { user: target, dex } : { user: target }),
|
|
2365
|
+
'frontendOpenOrders',
|
|
2366
|
+
);
|
|
2367
|
+
return response as unknown as FrontendOpenOrder[];
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2353
2370
|
// ============ Trading ============
|
|
2354
2371
|
|
|
2355
2372
|
/**
|
|
@@ -2990,17 +3007,24 @@ export class HyperliquidClient {
|
|
|
2990
3007
|
}
|
|
2991
3008
|
}
|
|
2992
3009
|
|
|
2993
|
-
|
|
3010
|
+
/**
|
|
3011
|
+
* `fast: true` sets the action's `f` flag so the mempool prioritizes the cancel
|
|
3012
|
+
* (post-2026-07 upgrade, only fast cancels get prioritization). The API REJECTS
|
|
3013
|
+
* fast cancels for trigger orders (TP/SL), so only pass it when the oid is known
|
|
3014
|
+
* to be a plain resting limit — e.g. a quoting loop cancelling its own quotes.
|
|
3015
|
+
*/
|
|
3016
|
+
async cancel(coin: string, oid: number, opts?: { fast?: boolean }): Promise<CancelResponse> {
|
|
2994
3017
|
await this.requireTrading();
|
|
2995
3018
|
await this.getMetaAndAssetCtxs();
|
|
2996
3019
|
|
|
2997
3020
|
const assetIndex = this.getAssetIndex(coin);
|
|
2998
3021
|
|
|
2999
|
-
this.log(`Cancelling order: ${coin} (asset ${assetIndex}) oid ${oid}`);
|
|
3022
|
+
this.log(`Cancelling order: ${coin} (asset ${assetIndex}) oid ${oid}${opts?.fast ? ' (fast)' : ''}`);
|
|
3000
3023
|
|
|
3001
3024
|
try {
|
|
3002
3025
|
const response = await this.exchange.cancel({
|
|
3003
3026
|
cancels: [{ a: assetIndex, o: oid }],
|
|
3027
|
+
...(opts?.fast ? { f: true as const } : {}),
|
|
3004
3028
|
}, this.vaultParam);
|
|
3005
3029
|
this.log('Cancel response:', JSON.stringify(response, null, 2));
|
|
3006
3030
|
return response as unknown as CancelResponse;
|
|
@@ -3016,14 +3040,16 @@ export class HyperliquidClient {
|
|
|
3016
3040
|
/**
|
|
3017
3041
|
* Cancel MANY resting orders in a SINGLE exchange request (one action-rate request for ≤40 cancels),
|
|
3018
3042
|
* the counterpart to `bulkOrder`. `response.data.statuses[i]` aligns with `cancels[i]`.
|
|
3043
|
+
* `fast` applies to the whole action and is rejected for trigger orders — see cancel().
|
|
3019
3044
|
*/
|
|
3020
|
-
async bulkCancel(cancels: Array<{ coin: string; oid: number }
|
|
3045
|
+
async bulkCancel(cancels: Array<{ coin: string; oid: number }>, opts?: { fast?: boolean }): Promise<CancelResponse> {
|
|
3021
3046
|
await this.requireTrading();
|
|
3022
3047
|
await this.getMetaAndAssetCtxs();
|
|
3023
3048
|
if (cancels.length === 0) return { status: 'ok', response: { type: 'cancel', data: { statuses: [] } } } as unknown as CancelResponse;
|
|
3024
3049
|
try {
|
|
3025
3050
|
const response = await this.exchange.cancel({
|
|
3026
3051
|
cancels: cancels.map((c) => ({ a: this.getAssetIndex(c.coin), o: c.oid })),
|
|
3052
|
+
...(opts?.fast ? { f: true as const } : {}),
|
|
3027
3053
|
}, this.vaultParam);
|
|
3028
3054
|
this.log('Bulk cancel response:', JSON.stringify(response, null, 2));
|
|
3029
3055
|
return response as unknown as CancelResponse;
|
package/scripts/core/types.ts
CHANGED
|
@@ -241,6 +241,29 @@ export interface OpenOrder {
|
|
|
241
241
|
timestamp: number;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Open order from the `frontendOpenOrders` info endpoint — same as OpenOrder
|
|
246
|
+
* plus the trigger/reduce-only display fields (needed to distinguish resting
|
|
247
|
+
* TP/SL protection from plain limit orders).
|
|
248
|
+
*/
|
|
249
|
+
export interface FrontendOpenOrder {
|
|
250
|
+
coin: string;
|
|
251
|
+
side: 'B' | 'A';
|
|
252
|
+
limitPx: string;
|
|
253
|
+
sz: string;
|
|
254
|
+
oid: number;
|
|
255
|
+
timestamp: number;
|
|
256
|
+
origSz: string;
|
|
257
|
+
triggerCondition: string;
|
|
258
|
+
isTrigger: boolean;
|
|
259
|
+
triggerPx: string;
|
|
260
|
+
isPositionTpsl: boolean;
|
|
261
|
+
reduceOnly: boolean;
|
|
262
|
+
orderType: string;
|
|
263
|
+
tif: string | null;
|
|
264
|
+
cloid: string | null;
|
|
265
|
+
}
|
|
266
|
+
|
|
244
267
|
// ============ API Request/Response ============
|
|
245
268
|
|
|
246
269
|
export interface InfoRequest {
|
package/scripts/core/ws.ts
CHANGED
|
@@ -251,12 +251,10 @@ export class WebSocketManager {
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
private trackSub(sub: ISubscription): ISubscription {
|
|
254
|
+
// SDK ≥0.33 dropped ISubscription.failureSignal, so a sub that dies after a
|
|
255
|
+
// failed resubscribe stays in this list; close() swallows unsubscribe errors,
|
|
256
|
+
// so a dead handle there is harmless.
|
|
254
257
|
this.subscriptions.push(sub);
|
|
255
|
-
sub.failureSignal.addEventListener('abort', () => {
|
|
256
|
-
this.log('Subscription failed, removing from tracked list');
|
|
257
|
-
const idx = this.subscriptions.indexOf(sub);
|
|
258
|
-
if (idx >= 0) this.subscriptions.splice(idx, 1);
|
|
259
|
-
});
|
|
260
258
|
return sub;
|
|
261
259
|
}
|
|
262
260
|
|
|
@@ -275,10 +273,15 @@ export class WebSocketManager {
|
|
|
275
273
|
|
|
276
274
|
/**
|
|
277
275
|
* Subscribe to L2 order book snapshots for a specific coin.
|
|
276
|
+
*
|
|
277
|
+
* `fast: true` = 5 levels every 0.5s; without it the API degrades to 20 levels
|
|
278
|
+
* every 5s (per the 2026-07 network-upgrade announcement). Every consumer here
|
|
279
|
+
* reads top-of-book only (chase / mm quoting / mid fallback), so fast wins;
|
|
280
|
+
* anything needing depth should use the REST l2Book, which stays full-depth.
|
|
278
281
|
*/
|
|
279
282
|
async subscribeL2Book(coin: string): Promise<ISubscription> {
|
|
280
283
|
const client = this.ensureClient();
|
|
281
|
-
const sub = await client.l2Book({ coin }, (data: L2BookWsEvent) => {
|
|
284
|
+
const sub = await client.l2Book({ coin, fast: true }, (data: L2BookWsEvent) => {
|
|
282
285
|
this.emit('l2Book', {
|
|
283
286
|
coin: data.coin,
|
|
284
287
|
time: data.time,
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// CLI entry point for `openbroker guardian` — read-only position risk
|
|
2
|
+
// monitoring with Telegram / agent-hook delivery. The CLI counterpart of the
|
|
3
|
+
// hosted guardian at /markets/guardian.
|
|
4
|
+
|
|
5
|
+
import { parseArgs } from '../core/utils.js';
|
|
6
|
+
import { getClient } from '../core/client.js';
|
|
7
|
+
import { getConfigPath } from '../core/config.js';
|
|
8
|
+
import { startGuardian, type GuardianOptions } from './engine.js';
|
|
9
|
+
import {
|
|
10
|
+
generateLinkCode,
|
|
11
|
+
getTelegramBotInfo,
|
|
12
|
+
loadTelegramSettings,
|
|
13
|
+
saveEnvVar,
|
|
14
|
+
sendTelegramMessage,
|
|
15
|
+
waitForTelegramLink,
|
|
16
|
+
} from './telegram.js';
|
|
17
|
+
import {
|
|
18
|
+
GUARDIAN_RULE_IDS,
|
|
19
|
+
GUARDIAN_RULE_LABELS,
|
|
20
|
+
type GuardianPrefs,
|
|
21
|
+
type GuardianRuleId,
|
|
22
|
+
type GuardianSeverity,
|
|
23
|
+
type GuardianThresholds,
|
|
24
|
+
} from './types.js';
|
|
25
|
+
|
|
26
|
+
function printUsage() {
|
|
27
|
+
console.log(`
|
|
28
|
+
OpenBroker Guardian — read-only position risk monitoring
|
|
29
|
+
|
|
30
|
+
Watches Hyperliquid addresses for liquidation risk, missing TP/SL, funding
|
|
31
|
+
bleed and more. Never touches your keys or places orders. Alerts go to the
|
|
32
|
+
console and (once connected) Telegram; if an OpenClaw agent gateway is
|
|
33
|
+
configured, alerts also wake the agent.
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
openbroker guardian run [options] Start watching (long-running)
|
|
37
|
+
openbroker guardian connect Link a Telegram chat for alerts
|
|
38
|
+
openbroker guardian test Send a test Telegram message
|
|
39
|
+
openbroker guardian status Show guardian configuration
|
|
40
|
+
openbroker guardian rules List alert rules
|
|
41
|
+
|
|
42
|
+
Options (for run):
|
|
43
|
+
--address <0x..[,0x..]> Address(es) to watch (default: configured account)
|
|
44
|
+
--min-severity <level> info | warning | critical (default: info)
|
|
45
|
+
--disable <rules> CSV of rules to turn off (see: guardian rules)
|
|
46
|
+
--only <rules> CSV of rules to run exclusively
|
|
47
|
+
--poll <ms> Position poll interval (default: 30000; 15000 near liquidation)
|
|
48
|
+
--orders-poll <ms> Open-orders poll interval (default: 120000)
|
|
49
|
+
--liq-warn <pct> Liq-distance warning threshold (default: 10)
|
|
50
|
+
--liq-critical <pct> Liq-distance critical threshold (default: 5)
|
|
51
|
+
--margin-pct <pct> Margin-usage warning threshold (default: 80)
|
|
52
|
+
--funding-apr <pct> Funding-bleed APR threshold (default: 15)
|
|
53
|
+
--tpsl-minutes <min> Minutes unprotected before no-TP/SL alert (default: 15)
|
|
54
|
+
--stale-hours <h> Hours resting before stale-order alert (default: 12)
|
|
55
|
+
--no-telegram Don't deliver to Telegram even if linked
|
|
56
|
+
--no-ws Disable the WebSocket liquidation fast lane
|
|
57
|
+
--json Print alerts as JSON lines (agent-friendly)
|
|
58
|
+
--verbose Show debug output
|
|
59
|
+
|
|
60
|
+
Telegram setup (one-time):
|
|
61
|
+
1. Message @BotFather on Telegram, send /newbot, and copy the bot token
|
|
62
|
+
2. Put it in your config: TELEGRAM_BOT_TOKEN=123456:ABC-...
|
|
63
|
+
3. Run: openbroker guardian connect (then tap the printed link)
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
openbroker guardian run
|
|
67
|
+
openbroker guardian run --address 0xabc...,0xdef... --min-severity warning
|
|
68
|
+
openbroker guardian run --disable position_lifecycle,stale_order
|
|
69
|
+
openbroker guardian run --json --no-telegram
|
|
70
|
+
`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SEVERITIES: GuardianSeverity[] = ['info', 'warning', 'critical'];
|
|
74
|
+
|
|
75
|
+
function parseRuleCsv(raw: string, flag: string): GuardianRuleId[] {
|
|
76
|
+
const rules = raw.split(',').map((r) => r.trim()).filter(Boolean);
|
|
77
|
+
for (const rule of rules) {
|
|
78
|
+
if (!GUARDIAN_RULE_IDS.includes(rule as GuardianRuleId)) {
|
|
79
|
+
console.error(`Error: unknown rule '${rule}' in ${flag}. Valid rules: ${GUARDIAN_RULE_IDS.join(', ')}`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return rules as GuardianRuleId[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseNum(args: Record<string, string | boolean>, key: string): number | undefined {
|
|
87
|
+
const raw = args[key];
|
|
88
|
+
if (raw === undefined || raw === true) return undefined;
|
|
89
|
+
const n = Number(raw);
|
|
90
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
91
|
+
console.error(`Error: --${key} must be a positive number`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
return n;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runCommand(args: Record<string, string | boolean>) {
|
|
98
|
+
const addresses = typeof args.address === 'string'
|
|
99
|
+
? args.address.split(',').map((a) => a.trim()).filter(Boolean)
|
|
100
|
+
: undefined;
|
|
101
|
+
|
|
102
|
+
const prefs: GuardianPrefs = {};
|
|
103
|
+
if (typeof args['min-severity'] === 'string') {
|
|
104
|
+
const sev = args['min-severity'] as GuardianSeverity;
|
|
105
|
+
if (!SEVERITIES.includes(sev)) {
|
|
106
|
+
console.error(`Error: --min-severity must be one of: ${SEVERITIES.join(', ')}`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
prefs.minSeverity = sev;
|
|
110
|
+
}
|
|
111
|
+
if (typeof args.disable === 'string') {
|
|
112
|
+
prefs.rules = Object.fromEntries(parseRuleCsv(args.disable, '--disable').map((r) => [r, false]));
|
|
113
|
+
}
|
|
114
|
+
if (typeof args.only === 'string') {
|
|
115
|
+
const only = new Set(parseRuleCsv(args.only, '--only'));
|
|
116
|
+
prefs.rules = Object.fromEntries(GUARDIAN_RULE_IDS.map((r) => [r, only.has(r)]));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const thresholds: Partial<GuardianThresholds> = {};
|
|
120
|
+
const liqWarn = parseNum(args, 'liq-warn');
|
|
121
|
+
const liqCritical = parseNum(args, 'liq-critical');
|
|
122
|
+
if (liqWarn !== undefined || liqCritical !== undefined) {
|
|
123
|
+
const warn = (liqWarn ?? 10) / 100;
|
|
124
|
+
const crit = (liqCritical ?? 5) / 100;
|
|
125
|
+
thresholds.liqThresholds = [
|
|
126
|
+
{ pct: warn, severity: 'warning' },
|
|
127
|
+
{ pct: crit, severity: 'critical' },
|
|
128
|
+
{ pct: crit / 2.5, severity: 'critical' },
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
const marginPct = parseNum(args, 'margin-pct');
|
|
132
|
+
if (marginPct !== undefined) {
|
|
133
|
+
thresholds.marginThresholdPct = marginPct;
|
|
134
|
+
thresholds.marginRearmPct = marginPct * 0.9;
|
|
135
|
+
}
|
|
136
|
+
const fundingApr = parseNum(args, 'funding-apr');
|
|
137
|
+
if (fundingApr !== undefined) thresholds.fundingBleedAprPct = fundingApr;
|
|
138
|
+
const tpslMinutes = parseNum(args, 'tpsl-minutes');
|
|
139
|
+
if (tpslMinutes !== undefined) thresholds.noTpslAfterMs = tpslMinutes * 60_000;
|
|
140
|
+
const staleHours = parseNum(args, 'stale-hours');
|
|
141
|
+
if (staleHours !== undefined) thresholds.staleOrderAgeMs = staleHours * 3600_000;
|
|
142
|
+
|
|
143
|
+
const options: GuardianOptions = {
|
|
144
|
+
addresses,
|
|
145
|
+
prefs,
|
|
146
|
+
thresholds,
|
|
147
|
+
pollIntervalMs: parseNum(args, 'poll'),
|
|
148
|
+
ordersIntervalMs: parseNum(args, 'orders-poll'),
|
|
149
|
+
useWebSocket: args['no-ws'] !== true,
|
|
150
|
+
telegram: args['no-telegram'] === true ? false : undefined,
|
|
151
|
+
json: args.json === true,
|
|
152
|
+
verbose: args.verbose === true,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const guardian = await startGuardian(options);
|
|
156
|
+
|
|
157
|
+
const stats = guardian.getStats();
|
|
158
|
+
const disabled = GUARDIAN_RULE_IDS.filter((r) => prefs.rules?.[r] === false);
|
|
159
|
+
if (!options.json) {
|
|
160
|
+
console.log('OpenBroker Guardian — watching (read-only, Ctrl+C to stop)');
|
|
161
|
+
console.log(` Addresses: ${stats.addresses.join(', ')}`);
|
|
162
|
+
console.log(` Min severity: ${prefs.minSeverity ?? 'info'}`);
|
|
163
|
+
console.log(` Rules off: ${disabled.length > 0 ? disabled.join(', ') : 'none'}`);
|
|
164
|
+
console.log(` Channels: ${stats.channels.join(', ')}`);
|
|
165
|
+
if (!stats.channels.includes('telegram')) {
|
|
166
|
+
console.log(' Tip: run `openbroker guardian connect` to get alerts in Telegram.');
|
|
167
|
+
}
|
|
168
|
+
console.log('');
|
|
169
|
+
for (const target of guardian.getTargets()) {
|
|
170
|
+
const positions = [...target.positions.values()];
|
|
171
|
+
if (positions.length === 0) {
|
|
172
|
+
console.log(` ${target.address}: no open positions`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
console.log(` ${target.address}: equity $${target.equity.toFixed(2)}, margin ${target.marginUsedPct.toFixed(1)}%`);
|
|
176
|
+
for (const pos of positions) {
|
|
177
|
+
const liq = pos.liquidationPx !== null ? `liq $${pos.liquidationPx}` : 'no liq px';
|
|
178
|
+
console.log(` ${pos.side} ${Math.abs(pos.szi)} ${pos.coin} @ $${pos.entryPx ?? '?'} (${pos.leverage}x, ${liq}, uPnL $${pos.unrealizedPnl.toFixed(2)})`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
console.log('');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const shutdown = async () => {
|
|
185
|
+
if (!options.json) console.log('\nStopping guardian...');
|
|
186
|
+
await guardian.stop();
|
|
187
|
+
process.exit(0);
|
|
188
|
+
};
|
|
189
|
+
process.on('SIGINT', shutdown);
|
|
190
|
+
process.on('SIGTERM', shutdown);
|
|
191
|
+
|
|
192
|
+
await new Promise(() => {});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function connectCommand(args: Record<string, string | boolean>) {
|
|
196
|
+
const settings = loadTelegramSettings();
|
|
197
|
+
const token = typeof args.token === 'string' ? args.token : settings.token;
|
|
198
|
+
|
|
199
|
+
if (!token) {
|
|
200
|
+
console.log('No Telegram bot token configured.\n');
|
|
201
|
+
console.log('One-time setup:');
|
|
202
|
+
console.log(' 1. Open Telegram and message @BotFather');
|
|
203
|
+
console.log(' 2. Send /newbot and follow the prompts (any name, e.g. "my-guardian-bot")');
|
|
204
|
+
console.log(' 3. Copy the bot token BotFather gives you');
|
|
205
|
+
console.log(` 4. Add it to your config (${getConfigPath()}):`);
|
|
206
|
+
console.log(' TELEGRAM_BOT_TOKEN=123456789:AAF...');
|
|
207
|
+
console.log(' 5. Re-run: openbroker guardian connect');
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let bot: { username: string };
|
|
212
|
+
try {
|
|
213
|
+
bot = await getTelegramBotInfo(token);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.error(`Could not reach the Telegram bot: ${err instanceof Error ? err.message : String(err)}`);
|
|
216
|
+
console.error('Check TELEGRAM_BOT_TOKEN.');
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const code = generateLinkCode();
|
|
221
|
+
console.log(`Bot: @${bot!.username}`);
|
|
222
|
+
console.log('\nOpen this link in Telegram and tap START (or send the code to the bot):\n');
|
|
223
|
+
console.log(` https://t.me/${bot!.username}?start=${code}`);
|
|
224
|
+
console.log(`\n code: ${code}`);
|
|
225
|
+
console.log('\nWaiting for the link (10 min timeout, Ctrl+C to abort)...');
|
|
226
|
+
|
|
227
|
+
const chatId = await waitForTelegramLink(token, code);
|
|
228
|
+
if (chatId === null) {
|
|
229
|
+
console.error('\nTimed out waiting for the Telegram link. Re-run: openbroker guardian connect');
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const savedTo = saveEnvVar('TELEGRAM_CHAT_ID', String(chatId));
|
|
234
|
+
if (typeof args.token === 'string') saveEnvVar('TELEGRAM_BOT_TOKEN', args.token);
|
|
235
|
+
console.log(`\nLinked chat ${chatId} — saved TELEGRAM_CHAT_ID to ${savedTo}`);
|
|
236
|
+
console.log('Guardian alerts will now be delivered to Telegram. Try: openbroker guardian test');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function testCommand() {
|
|
240
|
+
const { token, chatId } = loadTelegramSettings();
|
|
241
|
+
if (!token || !chatId) {
|
|
242
|
+
console.error('Telegram not connected. Run: openbroker guardian connect');
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
await sendTelegramMessage(token, chatId, '<b>[TEST]</b> OpenBroker guardian is connected. Risk alerts will arrive here.');
|
|
246
|
+
console.log('Test message sent.');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function statusCommand() {
|
|
250
|
+
const { token, chatId } = loadTelegramSettings();
|
|
251
|
+
let address = 'not configured';
|
|
252
|
+
try {
|
|
253
|
+
address = getClient().address;
|
|
254
|
+
} catch { /* read-only mode without an address */ }
|
|
255
|
+
|
|
256
|
+
console.log('OpenBroker Guardian — status\n');
|
|
257
|
+
console.log(` Default address: ${address}`);
|
|
258
|
+
console.log(` Config file: ${getConfigPath()}`);
|
|
259
|
+
console.log(` Telegram token: ${token ? 'set' : 'NOT SET (see: openbroker guardian connect)'}`);
|
|
260
|
+
console.log(` Telegram chat: ${chatId ? `linked (${chatId})` : 'not linked'}`);
|
|
261
|
+
console.log(` Agent hook: ${process.env.OPENCLAW_HOOKS_TOKEN ? 'configured' : 'not configured'}`);
|
|
262
|
+
console.log('\nRules (all on by default):');
|
|
263
|
+
for (const rule of GUARDIAN_RULE_IDS) {
|
|
264
|
+
console.log(` ${rule.padEnd(20)} ${GUARDIAN_RULE_LABELS[rule]}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function rulesCommand() {
|
|
269
|
+
console.log('Guardian alert rules:\n');
|
|
270
|
+
const details: Record<GuardianRuleId, string> = {
|
|
271
|
+
liq_proximity: 'warning at 10% from liquidation price, critical at 5% and 2% (--liq-warn/--liq-critical)',
|
|
272
|
+
no_tpsl: 'info when a position is open 15+ min with no reduce-only trigger orders (--tpsl-minutes)',
|
|
273
|
+
stale_order: 'info for limit orders resting 12h+ and 3%+ from mid (--stale-hours)',
|
|
274
|
+
margin_usage: 'warning when margin used exceeds 80% of equity (--margin-pct)',
|
|
275
|
+
funding_bleed: 'warning when paying 15%+ APR funding for 60+ min (--funding-apr)',
|
|
276
|
+
position_lifecycle: 'info on position open / close / resize',
|
|
277
|
+
};
|
|
278
|
+
for (const rule of GUARDIAN_RULE_IDS) {
|
|
279
|
+
console.log(` ${rule.padEnd(20)} ${GUARDIAN_RULE_LABELS[rule]}`);
|
|
280
|
+
console.log(` ${''.padEnd(20)} ${details[rule]}\n`);
|
|
281
|
+
}
|
|
282
|
+
console.log('Disable rules with --disable <csv>, or run a subset with --only <csv>.');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function main() {
|
|
286
|
+
const rawArgs = process.argv.slice(2);
|
|
287
|
+
|
|
288
|
+
if (rawArgs.length === 0 || rawArgs[0] === '--help' || rawArgs[0] === '-h') {
|
|
289
|
+
printUsage();
|
|
290
|
+
process.exit(0);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const subcommand = rawArgs[0];
|
|
294
|
+
const args = parseArgs(rawArgs.slice(1));
|
|
295
|
+
|
|
296
|
+
switch (subcommand) {
|
|
297
|
+
case 'run':
|
|
298
|
+
await runCommand(args);
|
|
299
|
+
break;
|
|
300
|
+
case 'connect':
|
|
301
|
+
await connectCommand(args);
|
|
302
|
+
break;
|
|
303
|
+
case 'test':
|
|
304
|
+
await testCommand();
|
|
305
|
+
break;
|
|
306
|
+
case 'status':
|
|
307
|
+
await statusCommand();
|
|
308
|
+
break;
|
|
309
|
+
case 'rules':
|
|
310
|
+
rulesCommand();
|
|
311
|
+
break;
|
|
312
|
+
default:
|
|
313
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
314
|
+
console.log('Run "openbroker guardian --help" for usage');
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
main().catch((err) => {
|
|
320
|
+
console.error(err instanceof Error ? err.message : err);
|
|
321
|
+
process.exit(1);
|
|
322
|
+
});
|