perp-cli 0.5.2 → 0.6.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/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # perp-cli
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/perp-cli.svg)](https://www.npmjs.com/package/perp-cli)
4
+ [![npm downloads](https://img.shields.io/npm/dw/perp-cli.svg)](https://www.npmjs.com/package/perp-cli)
5
+ [![license](https://img.shields.io/npm/l/perp-cli.svg)](https://github.com/hypurrquant/perp-cli/blob/main/LICENSE)
6
+
3
7
  Multi-DEX perpetual futures CLI — **Pacifica** (Solana), **Hyperliquid** (HyperEVM), **Lighter** (Ethereum).
4
8
 
5
9
  ```bash
@@ -58,6 +62,7 @@ Same EVM key works for both Hyperliquid and Lighter.
58
62
  | `plan` | Multi-step composite execution plans |
59
63
  | `rebalance` | Cross-exchange balance management |
60
64
  | `jobs` | Background job management (tmux) |
65
+ | `alerts` | Telegram funding rate alerts with background daemon |
61
66
  | `agent` | Schema introspection, capabilities, health check |
62
67
 
63
68
  ## Core Commands
@@ -125,6 +130,30 @@ perp --json bridge quote --from solana --to arbitrum --amount 100
125
130
  perp --json bridge send --from solana --to arbitrum --amount 100
126
131
  ```
127
132
 
133
+ ## Telegram Alerts
134
+
135
+ Funding rate alerts via Telegram with background daemon support.
136
+
137
+ ```bash
138
+ # Interactive setup (BotFather token + chat ID auto-detection)
139
+ perp alerts setup
140
+
141
+ # Add alert rules
142
+ perp alerts add ETH 30 # alert when ETH funding > 30% annualized
143
+ perp alerts add --all 50 # alert for any symbol > 50%
144
+
145
+ # Test & manage
146
+ perp alerts test # send test message
147
+ perp alerts list # show active rules
148
+
149
+ # Run daemon
150
+ perp alerts start # foreground
151
+ perp alerts start --background # tmux background daemon
152
+ perp alerts stop # stop background daemon
153
+ ```
154
+
155
+ Setup flow: BotFather token → bot validation → send `/start` to bot → auto-detect chat ID → test message.
156
+
128
157
  Exchange flag: `-e hyperliquid` / `-e pacifica` / `-e lighter` (aliases: `hl`, `pac`, `lt`).
129
158
 
130
159
  Global flags: `--json`, `--fields <f>`, `--ndjson`, `--dry-run`, `--dex <name>` (HIP-3), `-w, --wallet <name>`.
@@ -987,6 +987,66 @@ export function registerAccountCommands(program, getAdapter, isJson, getAdapterF
987
987
  else {
988
988
  console.log(chalk.gray("\n No open positions.\n"));
989
989
  }
990
+ // ── Spot Holdings ──
991
+ const spotRows = [];
992
+ for (const exName of exchanges) {
993
+ try {
994
+ const adapter = await getAdapterForExchange(exName);
995
+ if (exName === "hyperliquid") {
996
+ const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
997
+ const hlSpot = new HyperliquidSpotAdapter(adapter);
998
+ await hlSpot.init();
999
+ const bals = await hlSpot.getSpotBalances();
1000
+ const markets = await hlSpot.getSpotMarkets();
1001
+ const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), Number(m.markPrice)]));
1002
+ for (const b of bals) {
1003
+ const base = b.token.replace(/-SPOT$/i, "").toUpperCase();
1004
+ if (base === "USDC" || Number(b.total) <= 0)
1005
+ continue;
1006
+ const price = priceMap.get(base) ?? 0;
1007
+ const value = price * Number(b.total);
1008
+ if (value < 1)
1009
+ continue; // skip dust
1010
+ spotRows.push([
1011
+ chalk.white.bold(base),
1012
+ chalk.gray("hyperliquid"),
1013
+ b.total,
1014
+ `$${formatUsd(price)}`,
1015
+ `$${formatUsd(value)}`,
1016
+ ]);
1017
+ }
1018
+ }
1019
+ else if (exName === "lighter") {
1020
+ const { LighterAdapter } = await import("../exchanges/lighter.js");
1021
+ const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
1022
+ const ltSpot = new LighterSpotAdapter(adapter);
1023
+ await ltSpot.init();
1024
+ const bals = await ltSpot.getSpotBalances();
1025
+ const markets = await ltSpot.getSpotMarkets();
1026
+ const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), Number(m.markPrice)]));
1027
+ for (const b of bals) {
1028
+ if (b.token === "USDC" || b.token === "USDC_SPOT" || Number(b.total) <= 0)
1029
+ continue;
1030
+ const price = priceMap.get(b.token.toUpperCase()) ?? 0;
1031
+ const value = price * Number(b.total);
1032
+ if (value < 1)
1033
+ continue;
1034
+ spotRows.push([
1035
+ chalk.white.bold(b.token),
1036
+ chalk.gray("lighter"),
1037
+ b.total,
1038
+ `$${formatUsd(price)}`,
1039
+ `$${formatUsd(value)}`,
1040
+ ]);
1041
+ }
1042
+ }
1043
+ }
1044
+ catch { /* spot not available */ }
1045
+ }
1046
+ if (spotRows.length > 0) {
1047
+ console.log(chalk.white.bold("\n Spot Holdings"));
1048
+ console.log(makeTable(["Token", "Exchange", "Amount", "Price", "Value"], spotRows));
1049
+ }
990
1050
  // ── Risk Metrics ──
991
1051
  console.log(chalk.white.bold("\n Risk Metrics"));
992
1052
  const levelColor = {
@@ -216,8 +216,10 @@ export function registerAgentCommands(program, getAdapter, isJson) {
216
216
  category: "analytics",
217
217
  commands: [
218
218
  "perp analytics summary --json",
219
- "perp analytics pnl --json",
220
- "perp analytics funding --json",
219
+ "perp analytics pnl --period 30d --json",
220
+ "perp analytics funding --period 30d --json",
221
+ "perp analytics funding --period 7d --daily --json",
222
+ "perp analytics compare --json",
221
223
  "perp portfolio --json",
222
224
  "perp health --json",
223
225
  "perp history list --json",
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerAlertCommands(program: Command, isJson: () => boolean): void;
@@ -0,0 +1,487 @@
1
+ import chalk from "chalk";
2
+ import { createInterface } from "readline";
3
+ import { loadSettings, saveSettings } from "../settings.js";
4
+ import { validateBotToken as validateDiscordBot, sendDiscordDM, openDMChannel } from "../discord.js";
5
+ import { validateTelegramBot, sendTelegramMessage, getRecentChatId } from "../telegram.js";
6
+ import { printJson, jsonOk } from "../utils.js";
7
+ import { fetchAllFundingRates } from "../funding-rates.js";
8
+ function ask(rl, question) {
9
+ return new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim())));
10
+ }
11
+ function askChoice(rl, question, choices) {
12
+ return new Promise((resolve) => {
13
+ const choiceStr = choices.map((c, i) => `${i + 1}) ${c}`).join(" ");
14
+ rl.question(`${question} [${choiceStr}]: `, (answer) => {
15
+ const trimmed = answer.trim();
16
+ const idx = parseInt(trimmed) - 1;
17
+ if (idx >= 0 && idx < choices.length)
18
+ return resolve(choices[idx]);
19
+ const match = choices.find((c) => c.toLowerCase().startsWith(trimmed.toLowerCase()));
20
+ resolve(match || choices[0]);
21
+ });
22
+ });
23
+ }
24
+ /** Send alert message to all configured channels (Telegram and/or Discord) */
25
+ async function sendAlert(alerts, message) {
26
+ const errors = [];
27
+ if (alerts.telegram?.botToken && alerts.telegram?.chatId) {
28
+ try {
29
+ await sendTelegramMessage(alerts.telegram, message);
30
+ }
31
+ catch (err) {
32
+ errors.push(`Telegram: ${err instanceof Error ? err.message : err}`);
33
+ }
34
+ }
35
+ if (alerts.discord?.botToken && alerts.discord?.userId) {
36
+ try {
37
+ await sendDiscordDM(alerts.discord, message);
38
+ }
39
+ catch (err) {
40
+ errors.push(`Discord: ${err instanceof Error ? err.message : err}`);
41
+ }
42
+ }
43
+ if (errors.length > 0 && !alerts.telegram && !alerts.discord) {
44
+ throw new Error("No messaging service configured");
45
+ }
46
+ }
47
+ function hasAnyService(alerts) {
48
+ return !!(alerts.telegram?.botToken || alerts.discord?.botToken);
49
+ }
50
+ function serviceStatus(alerts) {
51
+ const parts = [];
52
+ if (alerts.telegram?.botToken)
53
+ parts.push(`Telegram (${alerts.telegram.chatId})`);
54
+ if (alerts.discord?.botToken)
55
+ parts.push(`Discord (${alerts.discord.userId})`);
56
+ return parts;
57
+ }
58
+ export function registerAlertCommands(program, isJson) {
59
+ const alerts = program.command("alerts").description("Funding rate alerts (Telegram / Discord)");
60
+ // ── alerts setup ──
61
+ alerts
62
+ .command("setup")
63
+ .description("Interactive alert setup (Telegram or Discord)")
64
+ .action(async () => {
65
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
66
+ try {
67
+ const settings = loadSettings();
68
+ console.log(chalk.cyan.bold("\n Alert Setup\n"));
69
+ // Show existing config
70
+ const services = serviceStatus(settings.alerts);
71
+ if (services.length > 0) {
72
+ console.log(chalk.gray(` Configured: ${services.join(", ")}\n`));
73
+ }
74
+ const service = await askChoice(rl, " Service", ["Telegram", "Discord"]);
75
+ if (service === "Telegram") {
76
+ await setupTelegram(rl, settings);
77
+ }
78
+ else {
79
+ await setupDiscord(rl, settings);
80
+ }
81
+ saveSettings(settings);
82
+ console.log(chalk.green.bold("\n Setup complete!"));
83
+ console.log(chalk.gray(" Config saved to ~/.perp/settings.json\n"));
84
+ console.log(chalk.white(" Next steps:"));
85
+ console.log(chalk.gray(` perp alerts add ETH 30 # ETH funding > 30% alert`));
86
+ console.log(chalk.gray(` perp alerts add --all 50 # Any symbol > 50%`));
87
+ console.log(chalk.gray(` perp alerts start # Start monitoring\n`));
88
+ rl.close();
89
+ }
90
+ catch (err) {
91
+ rl.close();
92
+ throw err;
93
+ }
94
+ });
95
+ // ── alerts test ──
96
+ alerts
97
+ .command("test")
98
+ .description("Send a test message to verify connection")
99
+ .action(async () => {
100
+ const settings = loadSettings();
101
+ if (!hasAnyService(settings.alerts)) {
102
+ console.log(chalk.red(" Not configured. Run: perp alerts setup\n"));
103
+ return;
104
+ }
105
+ const msg = `[TEST] perp-cli alert system is working. ${new Date().toISOString()}`;
106
+ try {
107
+ await sendAlert(settings.alerts, msg);
108
+ if (isJson())
109
+ return printJson(jsonOk({ status: "sent", services: serviceStatus(settings.alerts) }));
110
+ console.log(chalk.green(` Test message sent! (${serviceStatus(settings.alerts).join(", ")})\n`));
111
+ }
112
+ catch (err) {
113
+ if (isJson())
114
+ return printJson(jsonOk({ error: String(err) }));
115
+ console.log(chalk.red(` Failed: ${err instanceof Error ? err.message : err}\n`));
116
+ }
117
+ });
118
+ // ── alerts add ──
119
+ alerts
120
+ .command("add")
121
+ .description("Add a funding rate alert rule")
122
+ .argument("[symbol]", "Symbol to monitor (e.g. ETH, BTC)")
123
+ .argument("[threshold]", "Annual funding rate % threshold")
124
+ .option("--all", "Monitor all symbols")
125
+ .option("--exchange <ex>", "Filter by exchange (hl, pac, lt)")
126
+ .option("--below", "Alert when rate drops below threshold (default: above)")
127
+ .action((symbol, thresholdStr, opts) => {
128
+ const sym = opts.all ? "*" : symbol?.toUpperCase();
129
+ const rawThreshold = opts.all && symbol && !thresholdStr ? symbol : thresholdStr;
130
+ const threshold = parseFloat(rawThreshold || "30");
131
+ if (!sym) {
132
+ console.log(chalk.red(" Specify a symbol or use --all\n"));
133
+ return;
134
+ }
135
+ if (isNaN(threshold) || threshold <= 0) {
136
+ console.log(chalk.red(" Threshold must be a positive number (annual %)\n"));
137
+ return;
138
+ }
139
+ const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
140
+ const exchange = opts.exchange ? (aliasMap[opts.exchange.toLowerCase()] || opts.exchange.toLowerCase()) : "*";
141
+ const settings = loadSettings();
142
+ const rule = {
143
+ id: `${sym}-${exchange}-${Date.now()}`,
144
+ symbol: sym,
145
+ exchange,
146
+ threshold,
147
+ direction: opts.below ? "below" : "above",
148
+ enabled: true,
149
+ };
150
+ settings.alerts.rules.push(rule);
151
+ saveSettings(settings);
152
+ if (isJson())
153
+ return printJson(jsonOk(rule));
154
+ const exLabel = exchange === "*" ? "all exchanges" : exchange;
155
+ const dirLabel = opts.below ? "below" : "above";
156
+ console.log(chalk.green(` Alert added: ${sym === "*" ? "ALL" : sym} on ${exLabel} — ${dirLabel} ${threshold}% annual\n`));
157
+ });
158
+ // ── alerts list ──
159
+ alerts
160
+ .command("list")
161
+ .description("List configured alert rules")
162
+ .action(() => {
163
+ const settings = loadSettings();
164
+ const rules = settings.alerts.rules;
165
+ const tg = settings.alerts.telegram;
166
+ const dc = settings.alerts.discord;
167
+ if (isJson()) {
168
+ return printJson(jsonOk({
169
+ telegram: tg ? { configured: true, chatId: tg.chatId } : { configured: false },
170
+ discord: dc ? { configured: true, userId: dc.userId } : { configured: false },
171
+ rules,
172
+ intervalSec: settings.alerts.intervalSec,
173
+ cooldownMin: settings.alerts.cooldownMin,
174
+ }));
175
+ }
176
+ console.log(chalk.cyan.bold("\n Alert Configuration\n"));
177
+ if (tg?.botToken) {
178
+ console.log(` Telegram: ${chalk.green("configured")} (Chat: ${tg.chatId})`);
179
+ }
180
+ else {
181
+ console.log(` Telegram: ${chalk.gray("not configured")}`);
182
+ }
183
+ if (dc?.botToken) {
184
+ console.log(` Discord: ${chalk.green("configured")} (User: ${dc.userId})`);
185
+ }
186
+ else {
187
+ console.log(` Discord: ${chalk.gray("not configured")}`);
188
+ }
189
+ if (!tg?.botToken && !dc?.botToken) {
190
+ console.log(chalk.yellow(` Run: perp alerts setup`));
191
+ }
192
+ console.log(` Interval: ${settings.alerts.intervalSec}s | Cooldown: ${settings.alerts.cooldownMin}min\n`);
193
+ if (rules.length === 0) {
194
+ console.log(chalk.gray(" No alert rules. Add one: perp alerts add ETH 30\n"));
195
+ return;
196
+ }
197
+ console.log(chalk.white(" Rules:"));
198
+ for (const r of rules) {
199
+ const sym = r.symbol === "*" ? "ALL" : r.symbol;
200
+ const ex = r.exchange === "*" ? "all" : r.exchange;
201
+ const dir = r.direction === "above" ? ">" : "<";
202
+ const status = r.enabled ? chalk.green("ON") : chalk.gray("OFF");
203
+ console.log(` ${status} ${chalk.white.bold(sym.padEnd(6))} ${ex.padEnd(12)} ${dir} ${r.threshold}% annual`);
204
+ }
205
+ console.log();
206
+ });
207
+ // ── alerts remove ──
208
+ alerts
209
+ .command("remove")
210
+ .description("Remove an alert rule")
211
+ .argument("<symbol>", "Symbol to remove (or 'all')")
212
+ .action((symbol) => {
213
+ const settings = loadSettings();
214
+ const sym = symbol.toUpperCase();
215
+ const before = settings.alerts.rules.length;
216
+ if (sym === "ALL") {
217
+ settings.alerts.rules = [];
218
+ }
219
+ else {
220
+ settings.alerts.rules = settings.alerts.rules.filter(r => r.symbol !== sym);
221
+ }
222
+ const removed = before - settings.alerts.rules.length;
223
+ saveSettings(settings);
224
+ if (isJson())
225
+ return printJson(jsonOk({ removed, remaining: settings.alerts.rules.length }));
226
+ if (removed > 0) {
227
+ console.log(chalk.green(` Removed ${removed} rule(s) for ${sym}\n`));
228
+ }
229
+ else {
230
+ console.log(chalk.gray(` No rules found for ${sym}\n`));
231
+ }
232
+ });
233
+ // ── alerts start ──
234
+ alerts
235
+ .command("start")
236
+ .description("Start funding rate alert daemon")
237
+ .option("--interval <sec>", "Check interval in seconds")
238
+ .option("--cooldown <min>", "Cooldown per symbol after alert fires (minutes)")
239
+ .option("--background", "Run in background (tmux)")
240
+ .action(async (opts) => {
241
+ const settings = loadSettings();
242
+ if (!hasAnyService(settings.alerts)) {
243
+ console.log(chalk.red(" No messaging service configured. Run: perp alerts setup\n"));
244
+ return;
245
+ }
246
+ const rules = settings.alerts.rules.filter(r => r.enabled);
247
+ if (rules.length === 0) {
248
+ console.log(chalk.red(" No active alert rules. Add one: perp alerts add ETH 30\n"));
249
+ return;
250
+ }
251
+ const intervalSec = parseInt(opts.interval || String(settings.alerts.intervalSec)) || 300;
252
+ const cooldownMin = parseInt(opts.cooldown || String(settings.alerts.cooldownMin)) || 60;
253
+ // ── Background mode: launch in tmux ──
254
+ if (opts.background) {
255
+ const { execSync } = await import("child_process");
256
+ try {
257
+ execSync("which tmux", { stdio: "ignore" });
258
+ }
259
+ catch {
260
+ console.log(chalk.red(" tmux required for --background. Install: brew install tmux\n"));
261
+ return;
262
+ }
263
+ const session = `perp-alerts-${Date.now().toString(36)}`;
264
+ const nodeCmd = process.argv[0];
265
+ const cliPath = process.argv[1];
266
+ const args = [
267
+ "alerts", "start",
268
+ "--interval", String(intervalSec),
269
+ "--cooldown", String(cooldownMin),
270
+ ].join(" ");
271
+ // Pass through env vars for keys
272
+ const envKeys = ["LIGHTER_PRIVATE_KEY", "LIGHTER_API_KEY", "LIGHTER_ACCOUNT_INDEX", "HL_PRIVATE_KEY", "PACIFICA_PRIVATE_KEY", "PRIVATE_KEY"];
273
+ const envStr = envKeys.filter(k => process.env[k]).map(k => `${k}='${process.env[k]}'`).join(" ");
274
+ const cmd = `${envStr} ${nodeCmd} ${cliPath} ${args}`;
275
+ execSync(`tmux new-session -d -s ${session} '${cmd.replace(/'/g, "'\\''")}'`);
276
+ if (isJson())
277
+ return printJson(jsonOk({ status: "started", session, rules: rules.length, interval: intervalSec }));
278
+ console.log(chalk.green(`\n Alert daemon started in background.`));
279
+ console.log(` Session: ${chalk.white.bold(session)}`);
280
+ console.log(` Rules: ${rules.length} active, ${intervalSec}s interval`);
281
+ console.log(` Attach: ${chalk.gray(`tmux attach -t ${session}`)}`);
282
+ console.log(` Stop: ${chalk.gray(`tmux kill-session -t ${session}`)}\n`);
283
+ return;
284
+ }
285
+ // ── Foreground mode ──
286
+ const cooldowns = new Map();
287
+ const services = serviceStatus(settings.alerts);
288
+ console.log(chalk.cyan.bold("\n Funding Rate Alert Daemon\n"));
289
+ console.log(chalk.gray(` Rules: ${rules.length} active`));
290
+ console.log(chalk.gray(` Interval: ${intervalSec}s`));
291
+ console.log(chalk.gray(` Cooldown: ${cooldownMin}min`));
292
+ console.log(chalk.gray(` Send via: ${services.join(", ")}`));
293
+ console.log(chalk.gray(` Press Ctrl+C to stop\n`));
294
+ try {
295
+ await sendAlert(settings.alerts, `Alert daemon started — ${rules.length} rule(s), ${intervalSec}s interval`);
296
+ }
297
+ catch { /* non-critical */ }
298
+ let cycle = 0;
299
+ const check = async () => {
300
+ cycle++;
301
+ const ts = new Date().toLocaleTimeString();
302
+ try {
303
+ const snapshot = await fetchAllFundingRates();
304
+ let alertCount = 0;
305
+ for (const comparison of snapshot.symbols) {
306
+ for (const rate of comparison.rates) {
307
+ const annualPct = Math.abs(rate.annualizedPct);
308
+ for (const rule of rules) {
309
+ if (rule.symbol !== "*" && rule.symbol !== comparison.symbol)
310
+ continue;
311
+ if (rule.exchange !== "*" && rule.exchange !== rate.exchange)
312
+ continue;
313
+ const triggered = rule.direction === "above"
314
+ ? annualPct >= rule.threshold
315
+ : annualPct <= rule.threshold;
316
+ if (!triggered)
317
+ continue;
318
+ const cooldownKey = `${comparison.symbol}:${rate.exchange}:${rule.id}`;
319
+ const lastAlert = cooldowns.get(cooldownKey) ?? 0;
320
+ if (Date.now() - lastAlert < cooldownMin * 60_000)
321
+ continue;
322
+ cooldowns.set(cooldownKey, Date.now());
323
+ alertCount++;
324
+ const exAbbr = rate.exchange === "pacifica" ? "PAC" : rate.exchange === "hyperliquid" ? "HL" : "LT";
325
+ const sign = rate.annualizedPct > 0 ? "+" : "";
326
+ const msg = [
327
+ `${comparison.symbol} funding alert`,
328
+ `${exAbbr}: ${sign}${rate.annualizedPct.toFixed(1)}% annual (raw: ${(rate.fundingRate * 100).toFixed(4)}%)`,
329
+ `Threshold: ${rule.direction} ${rule.threshold}%`,
330
+ `Price: $${comparison.bestMarkPrice.toLocaleString()}`,
331
+ ].join("\n");
332
+ try {
333
+ await sendAlert(settings.alerts, msg);
334
+ }
335
+ catch (err) {
336
+ console.log(chalk.red(` [${ts}] Send failed: ${err instanceof Error ? err.message : err}`));
337
+ }
338
+ }
339
+ }
340
+ }
341
+ console.log(chalk.gray(` [${ts}] Cycle ${cycle} — ${snapshot.symbols.length} symbols, ${alertCount} alert(s)`));
342
+ }
343
+ catch (err) {
344
+ console.log(chalk.red(` [${ts}] Error: ${err instanceof Error ? err.message : err}`));
345
+ }
346
+ };
347
+ await check();
348
+ const timer = setInterval(check, intervalSec * 1000);
349
+ process.on("SIGINT", () => {
350
+ clearInterval(timer);
351
+ console.log(chalk.gray("\n Alert daemon stopped.\n"));
352
+ process.exit(0);
353
+ });
354
+ await new Promise(() => { });
355
+ });
356
+ // ── alerts stop ──
357
+ alerts
358
+ .command("stop")
359
+ .description("Stop background alert daemon")
360
+ .action(async () => {
361
+ const { execSync } = await import("child_process");
362
+ try {
363
+ const sessions = execSync("tmux list-sessions -F '#{session_name}'", { encoding: "utf-8" })
364
+ .trim().split("\n").filter(s => s.startsWith("perp-alerts-"));
365
+ if (sessions.length === 0) {
366
+ if (isJson())
367
+ return printJson(jsonOk({ status: "none" }));
368
+ console.log(chalk.gray(" No alert daemon running.\n"));
369
+ return;
370
+ }
371
+ for (const session of sessions) {
372
+ try {
373
+ execSync(`tmux kill-session -t ${session}`);
374
+ }
375
+ catch { /* already dead */ }
376
+ }
377
+ if (isJson())
378
+ return printJson(jsonOk({ status: "stopped", sessions }));
379
+ console.log(chalk.green(` Stopped ${sessions.length} alert daemon(s): ${sessions.join(", ")}\n`));
380
+ }
381
+ catch {
382
+ if (isJson())
383
+ return printJson(jsonOk({ status: "no_tmux" }));
384
+ console.log(chalk.gray(" No tmux sessions found.\n"));
385
+ }
386
+ });
387
+ }
388
+ // ── Setup flows ──
389
+ async function setupTelegram(rl, settings) {
390
+ console.log(chalk.cyan.bold("\n Telegram Setup\n"));
391
+ console.log(chalk.white(" Step 1: Create a bot"));
392
+ console.log(chalk.gray(" 1. Open Telegram, search @BotFather"));
393
+ console.log(chalk.gray(" 2. Send /newbot, follow prompts"));
394
+ console.log(chalk.gray(" 3. Copy the bot token\n"));
395
+ const botToken = await ask(rl, " Bot Token: ");
396
+ if (!botToken) {
397
+ console.log(chalk.red(" No token provided."));
398
+ return;
399
+ }
400
+ console.log(chalk.gray(" Validating..."));
401
+ const { valid, username } = await validateTelegramBot(botToken);
402
+ if (!valid) {
403
+ console.log(chalk.red(" Invalid bot token.\n"));
404
+ return;
405
+ }
406
+ console.log(chalk.green(` Bot: ${username}\n`));
407
+ console.log(chalk.white(" Step 2: Get your Chat ID"));
408
+ console.log(chalk.gray(` 1. Open Telegram, search ${username}`));
409
+ console.log(chalk.gray(" 2. Send /start to the bot"));
410
+ console.log(chalk.gray(" 3. Come back here\n"));
411
+ const method = await askChoice(rl, " How to get Chat ID?", ["auto-detect", "manual"]);
412
+ let chatId = "";
413
+ if (method === "auto-detect") {
414
+ console.log(chalk.gray(`\n Make sure you sent /start to ${username}, then press Enter...`));
415
+ await ask(rl, " ");
416
+ console.log(chalk.gray(" Checking..."));
417
+ const recent = await getRecentChatId(botToken);
418
+ if (recent) {
419
+ chatId = recent.chatId;
420
+ console.log(chalk.green(` Found: ${recent.firstName} (${chatId})\n`));
421
+ }
422
+ else {
423
+ console.log(chalk.yellow(" No messages found. Enter Chat ID manually.\n"));
424
+ }
425
+ }
426
+ if (!chatId) {
427
+ console.log(chalk.gray(" You can find your Chat ID at @userinfobot or @RawDataBot\n"));
428
+ chatId = await ask(rl, " Chat ID: ");
429
+ }
430
+ if (!chatId) {
431
+ console.log(chalk.red(" No Chat ID provided.\n"));
432
+ return;
433
+ }
434
+ // Test message
435
+ console.log(chalk.gray(" Sending test message..."));
436
+ const config = { botToken, chatId };
437
+ try {
438
+ await sendTelegramMessage(config, "perp-cli alert setup complete. Funding rate alerts will be sent here.");
439
+ console.log(chalk.green(" Test message sent! Check Telegram."));
440
+ }
441
+ catch (err) {
442
+ console.log(chalk.red(` Failed: ${err instanceof Error ? err.message : err}`));
443
+ console.log(chalk.gray(" Make sure you sent /start to the bot first.\n"));
444
+ return;
445
+ }
446
+ settings.alerts.telegram = { botToken, chatId };
447
+ }
448
+ async function setupDiscord(rl, settings) {
449
+ console.log(chalk.cyan.bold("\n Discord Setup\n"));
450
+ console.log(chalk.white(" Step 1: Create a bot"));
451
+ console.log(chalk.gray(" 1. Go to https://discord.com/developers/applications"));
452
+ console.log(chalk.gray(" 2. New Application → Bot → Reset Token → Copy"));
453
+ console.log(chalk.gray(" 3. Enable MESSAGE CONTENT intent"));
454
+ console.log(chalk.gray(" 4. OAuth2 → bot scope → Send Messages → Invite\n"));
455
+ const botToken = await ask(rl, " Bot Token: ");
456
+ if (!botToken) {
457
+ console.log(chalk.red(" No token provided."));
458
+ return;
459
+ }
460
+ console.log(chalk.gray(" Validating..."));
461
+ const { valid, username } = await validateDiscordBot(botToken);
462
+ if (!valid) {
463
+ console.log(chalk.red(" Invalid bot token.\n"));
464
+ return;
465
+ }
466
+ console.log(chalk.green(` Bot: ${username}\n`));
467
+ console.log(chalk.white(" Step 2: Your User ID"));
468
+ console.log(chalk.gray(" Settings → Advanced → Developer Mode → Right-click → Copy ID\n"));
469
+ const userId = await ask(rl, " User ID: ");
470
+ if (!userId || !/^\d{17,20}$/.test(userId)) {
471
+ console.log(chalk.red(" Invalid User ID.\n"));
472
+ return;
473
+ }
474
+ console.log(chalk.gray(" Sending test DM..."));
475
+ const config = { botToken, userId };
476
+ try {
477
+ const channelId = await openDMChannel(config);
478
+ config.channelId = channelId;
479
+ await sendDiscordDM(config, "perp-cli alert setup complete. Funding rate alerts will be sent here.");
480
+ console.log(chalk.green(" Test DM sent! Check Discord."));
481
+ }
482
+ catch (err) {
483
+ console.log(chalk.red(` Failed: ${err instanceof Error ? err.message : err}`));
484
+ return;
485
+ }
486
+ settings.alerts.discord = { botToken, userId, channelId: config.channelId };
487
+ }
@@ -224,9 +224,10 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
224
224
  // ── Spot-Perp position detection ──
225
225
  // Fetch spot balances from HL and LT, match with perp shorts
226
226
  const spotPerpPositions = [];
227
- // Build reverse map: spot token → perp symbol (UETH→ETH, UBTC→BTC, etc.)
227
+ // Build reverse map: spot token → perp symbol (UETH→ETH, UBTC→BTC, PURR-SPOT→PURR, etc.)
228
228
  const spotToPerpSymbol = (token) => {
229
- const upper = token.toUpperCase();
229
+ // Strip "-SPOT" suffix that HL SDK appends to spot token names
230
+ const upper = token.replace(/-SPOT$/i, "").toUpperCase();
230
231
  return SPOT_PERP_TOKEN_MAP[upper] ?? upper;
231
232
  };
232
233
  // Collect spot balances per exchange
@@ -240,7 +241,10 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
240
241
  const hlSpot = new HyperliquidSpotAdapter(adapter);
241
242
  await hlSpot.init();
242
243
  const balances = await hlSpot.getSpotBalances();
243
- spotBalancesByExchange.set(exName, balances.filter(b => b.token !== "USDC" && Number(b.total) > 0));
244
+ spotBalancesByExchange.set(exName, balances.filter(b => {
245
+ const base = b.token.replace(/-SPOT$/i, "").toUpperCase();
246
+ return base !== "USDC" && Number(b.total) > 0;
247
+ }));
244
248
  }
245
249
  else if (exName === "lighter") {
246
250
  const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
@@ -266,6 +270,10 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
266
270
  continue;
267
271
  // Find matching perp short position (on any exchange)
268
272
  const matchingShorts = allPositions.filter(p => p.symbol === perpSymbol && p.side === "short");
273
+ // Skip dust balances (< $1 at perp mark price)
274
+ const refPrice = matchingShorts[0]?.markPrice ?? 0;
275
+ if (refPrice > 0 && spotAmount * refPrice < 1)
276
+ continue;
269
277
  for (const shortPos of matchingShorts) {
270
278
  // Check if this short is already used by a perp-perp pair
271
279
  const usedByPerpPerp = arbPairs.some(ap => ap.symbol === perpSymbol && ap.shortExchange === shortPos.exchange);
@@ -311,7 +319,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
311
319
  mode: "spot-perp",
312
320
  spotExchange: spotEx,
313
321
  perpExchange: shortPos.exchange,
314
- spotToken: bal.token,
322
+ spotToken: bal.token.replace(/-SPOT$/i, ""),
315
323
  spotAmount,
316
324
  spotValueUsd,
317
325
  perpSide: "short",