perp-cli 0.5.3 → 0.6.1
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 +54 -0
- package/dist/commands/agent.js +4 -2
- package/dist/commands/alerts.d.ts +2 -0
- package/dist/commands/alerts.js +487 -0
- package/dist/commands/history.js +329 -70
- package/dist/discord.d.ts +29 -0
- package/dist/discord.js +66 -0
- package/dist/index.js +52 -38
- package/dist/mcp-server.js +444 -1
- package/dist/settings.d.ts +26 -0
- package/dist/settings.js +14 -2
- package/dist/telegram.d.ts +32 -0
- package/dist/telegram.js +74 -0
- package/package.json +9 -2
- package/skills/perp-cli/package.json +35 -0
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# perp-cli
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/perp-cli)
|
|
4
|
+
[](https://www.npmjs.com/package/perp-cli)
|
|
5
|
+
[](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,10 +130,59 @@ 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>`.
|
|
131
160
|
|
|
161
|
+
## MCP Server
|
|
162
|
+
|
|
163
|
+
perp-cli includes a full-featured MCP server (18 tools, 3 resources, 2 prompts) for Claude Desktop, Cursor, and other MCP clients.
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"mcpServers": {
|
|
168
|
+
"perp-cli": {
|
|
169
|
+
"command": "npx",
|
|
170
|
+
"args": ["-y", "-p", "perp-cli", "perp-mcp"],
|
|
171
|
+
"env": {
|
|
172
|
+
"HYPERLIQUID_PRIVATE_KEY": "your-evm-key",
|
|
173
|
+
"PACIFICA_PRIVATE_KEY": "your-solana-key"
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Tools:** market data, portfolio, orderbook, funding rates, arb scanning, trade preview/execute (with dry-run safety), funding analysis, PnL analysis, arb comparison.
|
|
181
|
+
|
|
182
|
+
**Resources:** `market://prices`, `market://funding-rates`, `perp://schema`
|
|
183
|
+
|
|
184
|
+
**Prompts:** `trading-guide`, `arb-strategy`
|
|
185
|
+
|
|
132
186
|
## AI Agent Skill
|
|
133
187
|
|
|
134
188
|
Install as a skill for Claude Code, Cursor, Codex, Gemini CLI, etc.:
|
package/dist/commands/agent.js
CHANGED
|
@@ -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,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
|
+
}
|